steid

@jamesgill /

steid/src/domain/object.rs
8.7 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};
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#[cfg(test)]
195mod tests {
196 use super::*;
197
198 const SHA1: &str = "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0";
199
200 #[test]
201 fn both_hash_widths_are_accepted() {
202 assert!(ObjectId::new(SHA1).is_ok());
203 assert!(ObjectId::new("a".repeat(64)).is_ok());
204 }
205
206 #[test]
207 fn an_id_is_lowercased_so_two_spellings_compare_equal() {
208 assert_eq!(
209 ObjectId::new(SHA1.to_uppercase()).expect("valid"),
210 ObjectId::new(SHA1).expect("valid")
211 );
212 }
213
214 #[test]
215 fn a_wrong_width_or_non_hex_id_is_refused() {
216 for value in ["", "abc", &"a".repeat(39), &"a".repeat(41), &"g".repeat(40)] {
217 assert!(ObjectId::new(value).is_err(), "{value:?} should be refused");
218 }
219 }
220
221 #[test]
222 fn the_short_form_is_for_display_only() {
223 assert_eq!(ObjectId::new(SHA1).expect("valid").short(), "a1b2c3d");
224 }
225
226 #[test]
227 fn git_modes_map_to_kinds() {
228 for (mode, expected) in [
229 ("040000", EntryKind::Tree),
230 ("40000", EntryKind::Tree),
231 ("100644", EntryKind::Blob),
232 ("100755", EntryKind::Blob),
233 ("120000", EntryKind::Symlink),
234 ("160000", EntryKind::Submodule),
235 ] {
236 assert_eq!(
237 EntryKind::from_mode(mode).expect("known mode"),
238 expected,
239 "mode {mode}"
240 );
241 }
242 }
243
244 #[test]
245 fn an_unknown_mode_is_an_error_not_a_default() {
246 // Defaulting would render a submodule as an empty file.
247 assert!(EntryKind::from_mode("100600").is_err());
248 assert!(EntryKind::from_mode("").is_err());
249 }
250
251 #[test]
252 fn a_listing_sorts_directories_first_then_case_insensitively_by_name() {
253 let entry = |name: &str, kind| TreeEntry {
254 name: name.to_owned(),
255 kind,
256 id: ObjectId::new(SHA1).expect("valid"),
257 size: None,
258 };
259
260 let mut entries = [
261 entry("README.md", EntryKind::Blob),
262 entry("src", EntryKind::Tree),
263 entry("Cargo.toml", EntryKind::Blob),
264 entry("migrations", EntryKind::Tree),
265 ];
266 entries.sort_by_key(TreeEntry::ordering_key);
267
268 assert_eq!(
269 entries.iter().map(|e| e.name.as_str()).collect::<Vec<_>>(),
270 vec!["migrations", "src", "Cargo.toml", "README.md"]
271 );
272 }
273}