steid

@jamesgill /

steid/src/domain/object.rs
13.0 KBCode·Blame·Raw
1//! Git objects, as the domain sees them.
2//!
3//! Deliberately not a model of git: an id, what a tree entry is, and enough of a commit
4//! to list one. [0006](../../plans/decisions/0006-git-binary-behind-narrow-ports.md)
5//! rejected modelling git objects properly as premature, and it still is — this exists
6//! so a query port returns meaning rather than `String`s.
7
8use std::{fmt, time::SystemTime};
9
10use super::{DomainError, RefName, RepoPath};
11
12/// The id of a git object, hex-encoded.
13///
14/// Accepts both widths git uses: 40 characters for SHA-1 and 64 for SHA-256. Steid
15/// creates SHA-1 repositories today, and refusing the wider form would turn a
16/// repository created elsewhere unreadable rather than merely unsupported.
17#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
18pub struct ObjectId(String);
19
20impl ObjectId {
21 const SHA1_LEN: usize = 40;
22 const SHA256_LEN: usize = 64;
23
24 /// How much of an id to show. Seven is what git itself abbreviates to by default.
25 const SHORT_LEN: usize = 7;
26
27 pub fn new(value: impl AsRef<str>) -> Result<Self, DomainError> {
28 let value = value.as_ref().trim();
29
30 if value.len() != Self::SHA1_LEN && value.len() != Self::SHA256_LEN {
31 return Err(DomainError::validation(
32 "object id",
33 format!(
34 "an object id is {} or {} characters, got {}",
35 Self::SHA1_LEN,
36 Self::SHA256_LEN,
37 value.len()
38 ),
39 ));
40 }
41
42 if !value.chars().all(|c| c.is_ascii_hexdigit()) {
43 return Err(DomainError::validation(
44 "object id",
45 "an object id is hexadecimal",
46 ));
47 }
48
49 // Lowercased on the way in so two spellings of one id compare equal.
50 Ok(Self(value.to_ascii_lowercase()))
51 }
52
53 pub fn from_trusted(value: impl Into<String>) -> Self {
54 Self(value.into())
55 }
56
57 pub fn as_str(&self) -> &str {
58 &self.0
59 }
60
61 /// The abbreviated form, for display.
62 ///
63 /// Never for lookup: an abbreviation can become ambiguous as a repository grows,
64 /// which is exactly the bug that only appears once a repository is large.
65 pub fn short(&self) -> &str {
66 &self.0[..Self::SHORT_LEN.min(self.0.len())]
67 }
68}
69
70impl fmt::Display for ObjectId {
71 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
72 f.write_str(&self.0)
73 }
74}
75
76/// What a tree entry is.
77///
78/// Git encodes this in a file mode, which is a POSIX mode only by resemblance — the
79/// meaningful values are a fixed set, so this is an enum rather than a bitfield.
80#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
81pub enum EntryKind {
82 /// A directory.
83 ///
84 /// Ordered first so a listing sorts directories above files without a custom
85 /// comparator at every call site.
86 Tree,
87 /// A file.
88 Blob,
89 /// A symbolic link. Its "content" is the target path.
90 Symlink,
91 /// Another repository, mounted as a submodule. Steid cannot look inside one.
92 Submodule,
93}
94
95impl EntryKind {
96 /// Reads git's mode field.
97 ///
98 /// Returns `Result`, never a default: an unknown mode silently becoming a file
99 /// would render a submodule as an empty blob.
100 pub fn from_mode(mode: &str) -> Result<Self, DomainError> {
101 // Trimmed because `ls-tree` pads the mode of a tree to six characters with a
102 // leading zero, while `cat-file` does not.
103 match mode.trim().trim_start_matches('0') {
104 "40000" => Ok(Self::Tree),
105 "100644" | "100755" => Ok(Self::Blob),
106 "120000" => Ok(Self::Symlink),
107 "160000" => Ok(Self::Submodule),
108 other => Err(DomainError::validation(
109 "mode",
110 format!("unknown git file mode {other:?}"),
111 )),
112 }
113 }
114
115 pub fn is_tree(self) -> bool {
116 self == Self::Tree
117 }
118
119 pub fn as_str(self) -> &'static str {
120 match self {
121 Self::Tree => "tree",
122 Self::Blob => "blob",
123 Self::Symlink => "symlink",
124 Self::Submodule => "submodule",
125 }
126 }
127}
128
129/// One entry in a directory listing.
130#[derive(Debug, Clone, PartialEq, Eq)]
131pub struct TreeEntry {
132 /// The entry's own name, never a path — the containing path is the caller's.
133 pub name: String,
134 pub kind: EntryKind,
135 pub id: ObjectId,
136 /// A blob's size in bytes. `None` for anything without one.
137 pub size: Option<u64>,
138}
139
140impl TreeEntry {
141 /// Orders a listing the way a file browser does: directories first, then by name.
142 ///
143 /// Case-insensitive, because a listing sorted by byte value puts every capitalised
144 /// name above every lowercase one, which reads as unsorted.
145 pub fn ordering_key(&self) -> (EntryKind, String) {
146 (self.kind, self.name.to_lowercase())
147 }
148}
149
150/// Whether a ref is a branch or a tag.
151///
152/// The two are told apart by which namespace the ref lives in, not by what it points
153/// at: a lightweight tag and a branch both point straight at a commit, so the object
154/// says nothing about which one a visitor asked for.
155#[derive(Debug, Clone, Copy, PartialEq, Eq)]
156pub enum RefKind {
157 Branch,
158 Tag,
159}
160
161/// A branch or a tag, short-named as a switcher shows it.
162///
163/// Short (`main`, not `refs/heads/main`) because that is what the browse URLs take and
164/// what a person recognises. The ambiguity a full name would resolve — a branch and a
165/// tag sharing a name — is carried by [`kind`](Self::kind) instead.
166#[derive(Debug, Clone, PartialEq, Eq)]
167pub struct GitRef {
168 pub name: RefName,
169 pub kind: RefKind,
170}
171
172/// A tag and when it was made, for the "latest tag" a repository page states.
173///
174/// The time is git's `creatordate`: the tag's own date for an annotated tag, and the
175/// date of the commit it points at for a lightweight one. That is the only definition
176/// that gives both kinds a usable answer — a lightweight tag has no date of its own,
177/// and reporting nothing for one would make the newest tag look older than it is.
178#[derive(Debug, Clone, PartialEq, Eq)]
179pub struct TagSummary {
180 pub name: RefName,
181 pub created_at: SystemTime,
182}
183
184/// A commit, reduced to what a log entry shows.
185#[derive(Debug, Clone, PartialEq, Eq)]
186pub struct CommitSummary {
187 pub id: ObjectId,
188 /// The first line of the message.
189 pub summary: String,
190 pub author_name: String,
191 pub committed_at: SystemTime,
192}
193
194/// A branch, with everything one row of the branches page shows.
195///
196/// The commit fields are the branch tip's, read in the same `for-each-ref` that named
197/// the branch — a page listing thirty branches cannot afford a `git log` each.
198///
199/// `is_default` comes from git's own `%(HEAD)` marker rather than from a second
200/// `symbolic-ref` call, which is what keeps the whole page to one process.
201#[derive(Debug, Clone, PartialEq, Eq)]
202pub struct BranchRow {
203 pub name: RefName,
204 /// Whether `HEAD` points at this branch: the repository's default.
205 pub is_default: bool,
206 pub commit: ObjectId,
207 /// The first line of the tip commit's message.
208 pub summary: String,
209 pub committed_at: SystemTime,
210}
211
212/// A tag, with everything one row of the tags page shows.
213///
214/// An annotated tag is a git object of its own carrying a message and a date; a
215/// lightweight tag is just a name for a commit. Both are here, and
216/// [`annotated`](Self::annotated) is what tells them apart — not the presence of a
217/// message, because an annotated tag may have an empty one.
218#[derive(Debug, Clone, PartialEq, Eq)]
219pub struct TagRow {
220 pub name: RefName,
221 /// The commit the tag names, peeled through the tag object when there is one, so
222 /// both kinds of tag report the thing a visitor would browse.
223 pub commit: ObjectId,
224 /// The first line of an annotated tag's own message. `None` for a lightweight tag,
225 /// which has none — the commit's subject is the commit's, not the tag's.
226 pub message: Option<String>,
227 pub annotated: bool,
228 /// git's `creatordate`: the tag's own date when it has one, the commit's otherwise.
229 pub created_at: SystemTime,
230}
231
232/// One matching line, as `git grep` reports it.
233///
234/// Carries the whole line rather than the matched fragment: a line can hold more than
235/// one match, and deciding which parts to mark is the page's job, not the port's.
236#[derive(Debug, Clone, PartialEq, Eq)]
237pub struct GrepHit {
238 pub path: RepoPath,
239 /// 1-based, the way git counts and the way a `#L…` anchor spells it.
240 pub line: u32,
241 /// 1-based, at the first match on the line. Not used for rendering — the whole
242 /// line is re-scanned for every occurrence — but it is what git was asked for and
243 /// it is the answer to "where", which nothing else here holds.
244 pub column: u32,
245 /// The line itself, without its newline, and cut if it is absurdly long — see
246 /// `application::search`.
247 pub text: String,
248}
249
250/// A commit, in the detail a commit page shows.
251///
252/// Separate from [`CommitSummary`] rather than an extension of it: a log reads fifty
253/// commits and wants four fields each, while a commit page reads one and wants all of
254/// them. Asking git for the wider format fifty times over would be paid on the page
255/// that can least afford it.
256///
257/// The author and the committer are kept apart because they genuinely differ — a
258/// rebase, a patch applied by a maintainer, a cherry-pick — and a page that showed only
259/// one would quietly misattribute all three.
260#[derive(Debug, Clone, PartialEq, Eq)]
261pub struct CommitDetail {
262 pub id: ObjectId,
263 /// The tree this commit points at, so a page can link to browsing it.
264 pub tree: ObjectId,
265 /// Every parent, in git's order. Empty for a root commit, two or more for a merge.
266 pub parents: Vec<ObjectId>,
267 /// The first line of the message.
268 pub summary: String,
269 /// Everything after the first paragraph break, verbatim. Empty when there is none.
270 pub body: String,
271 pub author_name: String,
272 pub author_email: String,
273 pub authored_at: SystemTime,
274 pub committer_name: String,
275 pub committer_email: String,
276 pub committed_at: SystemTime,
277}
278
279impl CommitDetail {
280 /// Whether the commit was recorded by someone other than the person who wrote it.
281 ///
282 /// The one question a page asks before deciding to name the committer at all:
283 /// showing "Ada authored and Ada committed" on every ordinary commit is noise.
284 pub fn has_distinct_committer(&self) -> bool {
285 self.committer_name != self.author_name || self.committer_email != self.author_email
286 }
287
288 /// Whether this commit has no parent — the first commit in a history.
289 pub fn is_root(&self) -> bool {
290 self.parents.is_empty()
291 }
292}
293
294#[cfg(test)]
295mod tests {
296 use super::*;
297
298 const SHA1: &str = "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0";
299
300 #[test]
301 fn both_hash_widths_are_accepted() {
302 assert!(ObjectId::new(SHA1).is_ok());
303 assert!(ObjectId::new("a".repeat(64)).is_ok());
304 }
305
306 #[test]
307 fn an_id_is_lowercased_so_two_spellings_compare_equal() {
308 assert_eq!(
309 ObjectId::new(SHA1.to_uppercase()).expect("valid"),
310 ObjectId::new(SHA1).expect("valid")
311 );
312 }
313
314 #[test]
315 fn a_wrong_width_or_non_hex_id_is_refused() {
316 for value in ["", "abc", &"a".repeat(39), &"a".repeat(41), &"g".repeat(40)] {
317 assert!(ObjectId::new(value).is_err(), "{value:?} should be refused");
318 }
319 }
320
321 #[test]
322 fn the_short_form_is_for_display_only() {
323 assert_eq!(ObjectId::new(SHA1).expect("valid").short(), "a1b2c3d");
324 }
325
326 #[test]
327 fn git_modes_map_to_kinds() {
328 for (mode, expected) in [
329 ("040000", EntryKind::Tree),
330 ("40000", EntryKind::Tree),
331 ("100644", EntryKind::Blob),
332 ("100755", EntryKind::Blob),
333 ("120000", EntryKind::Symlink),
334 ("160000", EntryKind::Submodule),
335 ] {
336 assert_eq!(
337 EntryKind::from_mode(mode).expect("known mode"),
338 expected,
339 "mode {mode}"
340 );
341 }
342 }
343
344 #[test]
345 fn an_unknown_mode_is_an_error_not_a_default() {
346 // Defaulting would render a submodule as an empty file.
347 assert!(EntryKind::from_mode("100600").is_err());
348 assert!(EntryKind::from_mode("").is_err());
349 }
350
351 #[test]
352 fn a_listing_sorts_directories_first_then_case_insensitively_by_name() {
353 let entry = |name: &str, kind| TreeEntry {
354 name: name.to_owned(),
355 kind,
356 id: ObjectId::new(SHA1).expect("valid"),
357 size: None,
358 };
359
360 let mut entries = [
361 entry("README.md", EntryKind::Blob),
362 entry("src", EntryKind::Tree),
363 entry("Cargo.toml", EntryKind::Blob),
364 entry("migrations", EntryKind::Tree),
365 ];
366 entries.sort_by_key(TreeEntry::ordering_key);
367
368 assert_eq!(
369 entries.iter().map(|e| e.name.as_str()).collect::<Vec<_>>(),
370 vec!["migrations", "src", "Cargo.toml", "README.md"]
371 );
372 }
373}