steid

@jamesgill /

steid/src/domain/object.rs
10.4 KBCode·Blame·Raw
dce0bf3feat: browse a repository's files and history8d
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
4ef3945feat: repository settings, README rendering, branch switcher, raw files7d
10use super::{DomainError, RefName};
dce0bf3feat: browse a repository's files and history8d
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
4ef3945feat: repository settings, README rendering, branch switcher, raw files7d
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
dd5b600feat: the facts a repository page states about itself19h
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
dce0bf3feat: browse a repository's files and history8d
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
3bbcd9ffeat: a branch and a tag are rows, not just names19h
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
dce0bf3feat: browse a repository's files and history8d
232#[cfg(test)]
233mod tests {
234 use super::*;
235
236 const SHA1: &str = "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0";
237
238 #[test]
239 fn both_hash_widths_are_accepted() {
240 assert!(ObjectId::new(SHA1).is_ok());
241 assert!(ObjectId::new("a".repeat(64)).is_ok());
242 }
243
244 #[test]
245 fn an_id_is_lowercased_so_two_spellings_compare_equal() {
246 assert_eq!(
247 ObjectId::new(SHA1.to_uppercase()).expect("valid"),
248 ObjectId::new(SHA1).expect("valid")
249 );
250 }
251
252 #[test]
253 fn a_wrong_width_or_non_hex_id_is_refused() {
254 for value in ["", "abc", &"a".repeat(39), &"a".repeat(41), &"g".repeat(40)] {
255 assert!(ObjectId::new(value).is_err(), "{value:?} should be refused");
256 }
257 }
258
259 #[test]
260 fn the_short_form_is_for_display_only() {
261 assert_eq!(ObjectId::new(SHA1).expect("valid").short(), "a1b2c3d");
262 }
263
264 #[test]
265 fn git_modes_map_to_kinds() {
266 for (mode, expected) in [
267 ("040000", EntryKind::Tree),
268 ("40000", EntryKind::Tree),
269 ("100644", EntryKind::Blob),
270 ("100755", EntryKind::Blob),
271 ("120000", EntryKind::Symlink),
272 ("160000", EntryKind::Submodule),
273 ] {
274 assert_eq!(
275 EntryKind::from_mode(mode).expect("known mode"),
276 expected,
277 "mode {mode}"
278 );
279 }
280 }
281
282 #[test]
283 fn an_unknown_mode_is_an_error_not_a_default() {
284 // Defaulting would render a submodule as an empty file.
285 assert!(EntryKind::from_mode("100600").is_err());
286 assert!(EntryKind::from_mode("").is_err());
287 }
288
289 #[test]
290 fn a_listing_sorts_directories_first_then_case_insensitively_by_name() {
291 let entry = |name: &str, kind| TreeEntry {
292 name: name.to_owned(),
293 kind,
294 id: ObjectId::new(SHA1).expect("valid"),
295 size: None,
296 };
297
298 let mut entries = [
299 entry("README.md", EntryKind::Blob),
300 entry("src", EntryKind::Tree),
301 entry("Cargo.toml", EntryKind::Blob),
302 entry("migrations", EntryKind::Tree),
303 ];
304 entries.sort_by_key(TreeEntry::ordering_key);
305
306 assert_eq!(
307 entries.iter().map(|e| e.name.as_str()).collect::<Vec<_>>(),
308 vec!["migrations", "src", "Cargo.toml", "README.md"]
309 );
310 }
311}