steid

@jamesgill /

Rust15.3 KBCode·Blame·History·Raw
1//! Parsing what `git for-each-ref` writes about branches and tags.
2
3use crate::{
4 application::port::GitQueryError,
5 domain::{BranchRow, GitRef, ObjectId, RefKind, RefName, TagRow, TagSummary},
6};
7
8use super::unix_time;
9
10/// What `for-each-ref` prints per ref: the full name and the object it names,
11/// NUL-separated and NUL-terminated.
12///
13/// The kind is *not* asked for. `%(objecttype)` says `commit` for both a branch and a
14/// lightweight tag, so the namespace in the name is the only thing that answers which
15/// one a visitor asked for.
16pub(super) const REF_FORMAT: &str = "--format=%(refname)%00";
17
18/// What a snapshot asks for: the full name and the object the ref itself names.
19///
20/// `%(objectname)` rather than `%(*objectname)`, so an annotated tag reports the tag
21/// object. What a push changed is the *ref*, and re-tagging the same commit with a new
22/// annotation is a change even though the peeled ids match.
23pub(super) const SNAPSHOT_FORMAT: &str = "--format=%(refname)%00%(objectname)%00";
24
25/// Parses a snapshot's NUL-separated pairs.
26///
27/// Same shape as [`parse_refs`], two fields instead of one, and the same rule about a
28/// ref that Steid's own [`RefName`] refuses: left out rather than carried. A push event
29/// naming a ref no page can address is a row nothing can do anything with.
30pub(super) fn parse_ref_snapshot(stdout: &[u8]) -> Vec<(RefName, ObjectId)> {
31 let fields = ref_fields(stdout);
32 let mut snapshot = Vec::with_capacity(fields.len() / 2);
33
34 for pair in fields.chunks_exact(2) {
35 // git ends each record with a newline the format cannot suppress, so it arrives
36 // at the front of the next record's first field. A ref name contains neither a
37 // newline nor a space, so trimming cannot eat part of one.
38 let Ok(full) = std::str::from_utf8(pair[0].trim_ascii()) else {
39 continue;
40 };
41 let Ok(id) = std::str::from_utf8(pair[1].trim_ascii()) else {
42 continue;
43 };
44
45 let (Ok(name), Ok(id)) = (RefName::new(full), ObjectId::new(id)) else {
46 continue;
47 };
48
49 snapshot.push((name, id));
50 }
51
52 snapshot
53}
54
55/// Parses `for-each-ref`'s NUL-separated output into branches and tags.
56///
57/// Each record is `<full refname> NUL`, and git ends every record with a newline of its
58/// own that the format cannot suppress — so the newline arrives at the *front* of the
59/// next record's first field and is trimmed off. A ref name can contain neither a
60/// newline nor a space, so trimming cannot eat part of a name.
61pub(super) fn parse_refs(stdout: &[u8]) -> Vec<GitRef> {
62 let mut refs = Vec::new();
63
64 for record in stdout.split(|byte| *byte == 0) {
65 let record = record.trim_ascii();
66
67 if record.is_empty() {
68 continue;
69 }
70
71 // Lossy would be wrong here: a name that is not UTF-8 cannot be put in a URL,
72 // and offering a link that cannot work is worse than leaving the ref out of the
73 // switcher. It is still browsable by object id.
74 let Ok(full) = std::str::from_utf8(record) else {
75 continue;
76 };
77
78 let (kind, short) = if let Some(short) = full.strip_prefix("refs/heads/") {
79 (RefKind::Branch, short)
80 } else if let Some(short) = full.strip_prefix("refs/tags/") {
81 (RefKind::Tag, short)
82 } else {
83 // Only the two namespaces were asked for, so this cannot happen — and if a
84 // future pattern is added and this is forgotten, skipping is the safe half
85 // of the mistake.
86 continue;
87 };
88
89 // Validated rather than trusted: this name is about to become a URL, and
90 // `RefName` is what decides a name is safe to hand back to git. A ref git
91 // accepts but Steid's rules do not is left out rather than linked to.
92 let Ok(name) = RefName::new(short) else {
93 continue;
94 };
95
96 refs.push(GitRef { name, kind });
97 }
98
99 refs
100}
101
102/// What the latest-tag query asks for: the full ref name and its creation time.
103///
104/// `creatordate` rather than `taggerdate`, which is empty for a lightweight tag, or
105/// `committerdate`, which is empty for an annotated one. `creatordate` is git's own
106/// "whichever of those this ref has".
107pub(super) const TAG_FORMAT: &str = "--format=%(refname)%00%(creatordate:unix)%00";
108
109/// Parses the one record [`TAG_FORMAT`] produces, or `None` for a repository with no
110/// tags.
111///
112/// Anything unreadable is `None` rather than an error: this decorates a page with a
113/// fact, and a tag whose name is not UTF-8 or whose date git spelled unexpectedly is a
114/// reason to say nothing, not to fail the repository's landing page.
115pub(super) fn parse_latest_tag(stdout: &[u8]) -> Option<TagSummary> {
116 let fields: Vec<&[u8]> = stdout
117 .split(|byte| *byte == 0)
118 .map(<[u8]>::trim_ascii)
119 .filter(|field| !field.is_empty())
120 .collect();
121
122 let [name, created_at] = fields[..] else {
123 return None;
124 };
125
126 let short = std::str::from_utf8(name).ok()?.strip_prefix("refs/tags/")?;
127 let created_at: i64 = std::str::from_utf8(created_at).ok()?.parse().ok()?;
128
129 Some(TagSummary {
130 // Validated rather than trusted, exactly as `parse_refs` does: the name is
131 // about to become a link.
132 name: RefName::new(short).ok()?,
133 created_at: unix_time(created_at),
134 })
135}
136
137/// What the branches page asks for, per branch.
138///
139/// `%(HEAD)` is git's own marker for the branch `HEAD` points at — `*` for it and a
140/// space for everything else. It is asked for here rather than resolved separately
141/// because a second process to learn one bit is the cost 0006 is about.
142///
143/// `%(objectname)` is the tip commit itself: a branch, unlike a tag, never points at
144/// anything else.
145pub(super) const BRANCH_FORMAT: &str = "--format=%(refname)%00%(HEAD)%00%(objectname)%00%(committerdate:unix)%00%(contents:subject)%00";
146
147/// What the tags page asks for, per tag.
148///
149/// `%(objecttype)` is `tag` for an annotated tag and `commit` for a lightweight one,
150/// which is the only reliable way to tell them apart. `%(*objectname)` is the peeled
151/// object and is empty for a lightweight tag, so the commit is "the peeled one if there
152/// is one". `%(contents:subject)` is the *tag's* message for an annotated tag and the
153/// *commit's* for a lightweight one — so it is read only when the type says `tag`,
154/// otherwise a lightweight tag would appear to carry a message it does not have.
155pub(super) const TAG_ROW_FORMAT: &str = "--format=%(refname)%00%(objecttype)%00%(objectname)%00%(*objectname)%00%(creatordate:unix)%00%(contents:subject)%00";
156
157/// Splits `for-each-ref` output into its NUL-terminated fields.
158///
159/// Every field ends with a NUL and git adds a newline after each record that the format
160/// cannot suppress, so the split yields exactly one field per `%00` plus a trailing
161/// remainder holding that last newline — dropped here.
162///
163/// **Empty fields are kept.** A lightweight tag has no peeled object, and filtering
164/// empties the way [`parse_latest_tag`] can afford to would shift every later field of
165/// that record onto the wrong name.
166fn ref_fields(stdout: &[u8]) -> Vec<&[u8]> {
167 let mut fields: Vec<&[u8]> = stdout.split(|byte| *byte == 0).collect();
168 fields.pop();
169 fields
170}
171
172/// The first line of git's subject, or `None` when there is nothing to show.
173///
174/// `%(contents:subject)` is already one line, but that is git's invariant rather than
175/// something this parser should assume — the same reason [`parse_log`] trims `%s`.
176fn subject(field: &[u8]) -> Option<String> {
177 let line = String::from_utf8_lossy(field)
178 .lines()
179 .next()
180 .unwrap_or_default()
181 .trim()
182 .to_owned();
183
184 (!line.is_empty()).then_some(line)
185}
186
187/// Parses [`BRANCH_FORMAT`] into rows, in the order git sorted them.
188///
189/// A record whose name or commit id Steid cannot use is skipped rather than failing the
190/// page, exactly as [`parse_refs`] skips one: a branch that cannot be linked to is a
191/// reason to leave a row out, not to refuse the whole list. A record with the wrong
192/// number of fields is different — that is git saying something this code does not
193/// understand, and it is an error.
194pub(super) fn parse_branches(stdout: &[u8]) -> Result<Vec<BranchRow>, GitQueryError> {
195 let fields = ref_fields(stdout);
196 let mut rows = Vec::with_capacity(fields.len() / 5);
197
198 for record in fields.chunks(5) {
199 let [name, head, commit, committed_at, summary] = record[..] else {
200 return Err(GitQueryError::new(
201 "git listed a branch with missing fields",
202 ));
203 };
204
205 // git's trailing newline arrives in front of the next record's first field.
206 // A ref name can contain neither a newline nor a space, so trimming cannot eat
207 // part of one.
208 let Some(name) = short_ref(name.trim_ascii(), "refs/heads/") else {
209 continue;
210 };
211
212 let Ok(commit) = ObjectId::new(String::from_utf8_lossy(commit).trim()) else {
213 continue;
214 };
215
216 let committed_at = String::from_utf8_lossy(committed_at);
217 let Ok(committed_at) = committed_at.trim().parse::<i64>() else {
218 continue;
219 };
220
221 rows.push(BranchRow {
222 name,
223 // `*` for the branch HEAD names, a space for the rest.
224 is_default: head.trim_ascii() == b"*",
225 commit,
226 summary: subject(summary).unwrap_or_default(),
227 committed_at: unix_time(committed_at),
228 });
229 }
230
231 Ok(rows)
232}
233
234/// Parses [`TAG_ROW_FORMAT`] into rows, in the order git sorted them.
235///
236/// Skips and errors on the same terms as [`parse_branches`].
237pub(super) fn parse_tags(stdout: &[u8]) -> Result<Vec<TagRow>, GitQueryError> {
238 let fields = ref_fields(stdout);
239 let mut rows = Vec::with_capacity(fields.len() / 6);
240
241 for record in fields.chunks(6) {
242 let [name, kind, object, peeled, created_at, message] = record[..] else {
243 return Err(GitQueryError::new("git listed a tag with missing fields"));
244 };
245
246 let Some(name) = short_ref(name.trim_ascii(), "refs/tags/") else {
247 continue;
248 };
249
250 // An annotated tag's `objectname` is the tag object, so the thing worth linking
251 // to is the peeled one. A lightweight tag has no peel and already names its
252 // commit.
253 let annotated = kind.trim_ascii() == b"tag";
254 let id = if peeled.trim_ascii().is_empty() {
255 object
256 } else {
257 peeled
258 };
259
260 let Ok(commit) = ObjectId::new(String::from_utf8_lossy(id).trim()) else {
261 continue;
262 };
263
264 let created_at = String::from_utf8_lossy(created_at);
265 let Ok(created_at) = created_at.trim().parse::<i64>() else {
266 continue;
267 };
268
269 rows.push(TagRow {
270 name,
271 commit,
272 // Only an annotated tag has a message of its own; for a lightweight one
273 // this field is the commit's subject, which belongs to the commit.
274 message: annotated.then(|| subject(message)).flatten(),
275 annotated,
276 created_at: unix_time(created_at),
277 });
278 }
279
280 Ok(rows)
281}
282
283/// A full ref name reduced to the short form Steid puts in a URL, or `None` when it is
284/// outside the namespace asked for or is not a name Steid will hand back to git.
285///
286/// Validated rather than trusted for the reason [`parse_refs`] gives: this name is
287/// about to become a link.
288fn short_ref(full: &[u8], namespace: &str) -> Option<RefName> {
289 let full = std::str::from_utf8(full).ok()?;
290 RefName::new(full.strip_prefix(namespace)?).ok()
291}
292
293#[cfg(test)]
294mod tests {
295 use super::*;
296
297 // Pure, so these are tested against bytes written by hand rather than against a
298 // fixture repository: the point is the format, and a fixture would only prove
299 // that git and this parser agree about the easy cases. The round trip through a
300 // real repository is covered by the fixture tests in `git_query_tests.rs`.
301
302 #[test]
303 fn refs_are_parsed_from_nul_terminated_records() {
304 // git ends each record with a newline the format cannot suppress, so it arrives
305 // in front of the next record's name. Anything outside the two namespaces is
306 // dropped rather than guessed at.
307 let stdout = b"refs/heads/main\0\nrefs/tags/v1.0\0\nrefs/pull/7/head\0\n";
308 let refs = parse_refs(stdout);
309
310 assert_eq!(refs.len(), 2);
311 assert_eq!(refs[0].name.as_str(), "main");
312 assert_eq!(refs[0].kind, RefKind::Branch);
313 assert_eq!(refs[1].name.as_str(), "v1.0");
314 assert_eq!(refs[1].kind, RefKind::Tag);
315 }
316
317 #[test]
318 fn nothing_is_parsed_from_an_empty_listing() {
319 assert!(parse_refs(b"").is_empty());
320 }
321
322 #[test]
323 fn a_tag_record_is_parsed_past_the_trailing_newline() {
324 // `for-each-ref` ends every record with a newline the format cannot suppress,
325 // exactly as it does for `parse_refs`.
326 let tag = parse_latest_tag(b"refs/tags/v1.0\x001700000000\x00\n").expect("a tag");
327
328 assert_eq!(tag.name.as_str(), "v1.0");
329 assert_eq!(tag.created_at, unix_time(1_700_000_000));
330 }
331
332 #[test]
333 fn nothing_is_parsed_from_an_empty_tag_listing() {
334 assert_eq!(parse_latest_tag(b""), None);
335 // A ref outside the tags namespace is not a tag, whatever asked for it.
336 assert_eq!(
337 parse_latest_tag(b"refs/heads/main\x001700000000\x00\n"),
338 None
339 );
340 }
341
342 #[test]
343 fn branch_records_survive_the_newline_git_puts_between_them() {
344 let rows = parse_branches(
345 b"refs/heads/main\x00*\x00aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\x001700000000\x00first\x00\nrefs/heads/side\x00 \x00bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\x001700000100\x00second\x00\n",
346 )
347 .expect("should parse");
348
349 assert_eq!(rows.len(), 2);
350 assert!(rows[0].is_default);
351 assert_eq!(rows[0].summary, "first");
352 // The newline in front of `refs/heads/side` is git's record separator, not part
353 // of the name.
354 assert_eq!(rows[1].name.as_str(), "side");
355 assert!(!rows[1].is_default);
356 assert_eq!(rows[1].committed_at, unix_time(1_700_000_100));
357 }
358
359 #[test]
360 fn a_lightweight_tags_empty_peel_does_not_shift_the_fields_after_it() {
361 // The reason `ref_fields` keeps empty fields: filtering them would read this
362 // record's date as its commit id.
363 let rows = parse_tags(
364 b"refs/tags/v1.0\x00commit\x00aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\x00\x001700000000\x00a commit subject\x00\n",
365 )
366 .expect("should parse");
367
368 assert_eq!(rows.len(), 1);
369 assert_eq!(rows[0].name.as_str(), "v1.0");
370 assert_eq!(rows[0].commit.as_str(), "a".repeat(40));
371 assert!(!rows[0].annotated);
372 assert_eq!(rows[0].message, None);
373 assert_eq!(rows[0].created_at, unix_time(1_700_000_000));
374 }
375
376 #[test]
377 fn nothing_is_parsed_from_an_empty_row_listing() {
378 assert_eq!(parse_branches(b"").expect("should parse"), Vec::new());
379 assert_eq!(parse_tags(b"").expect("should parse"), Vec::new());
380 }
381
382 #[test]
383 fn a_record_with_the_wrong_number_of_fields_is_a_fault() {
384 // Skipping a ref Steid cannot link to is right; misreading git's output is not.
385 assert!(parse_branches(b"refs/heads/main\x00*\x00\n").is_err());
386 }
387}