Skip to main content

vcs_git/
parse.rs

1//! Pure parsers for git's machine-readable output. No process execution, so the
2//! tests here are hermetic and run on CI.
3//!
4//! The git-format unified-diff model + parser and the version type live in the
5//! shared [`vcs_diff`] crate (`git diff` and `jj diff --git` are byte-identical);
6//! this module keeps only the git-specific parsers (porcelain, log, blame, …).
7
8use std::path::PathBuf;
9
10use vcs_diff::DiffStat;
11
12/// One entry from `git status --porcelain=v1 -z` (`XY <path>`, NUL-delimited).
13#[derive(Debug, Clone, PartialEq, Eq)]
14#[non_exhaustive]
15pub struct StatusEntry {
16    /// Two-character status code, e.g. `" M"`, `"??"`, `"A "`, `"R "`.
17    pub code: String,
18    /// Path the status applies to (the *new* path for a rename/copy). A
19    /// [`PathBuf`] built from the raw `-z` bytes (no C-quoting to undo, even for
20    /// paths with spaces), so a filename whose bytes are not valid UTF-8 (legal on
21    /// Unix) is carried losslessly and can be fed straight back into `add` /
22    /// `commit_paths` — decoding it through `String::from_utf8_lossy` would
23    /// substitute `U+FFFD` and address a different file.
24    pub path: PathBuf,
25    /// For a rename/copy, the original path; `None` otherwise. Named to match
26    /// `vcs_jj::ChangedPath::old_path` so cross-backend code reads the rename
27    /// source the same way on both wrappers.
28    pub old_path: Option<PathBuf>,
29}
30
31/// A combined branch + working-tree snapshot from `git status --porcelain=v2
32/// --branch -z`: HEAD, branch, upstream tracking, ahead/behind, and change
33/// counts — everything a prompt/status-bar needs, in **one** process spawn.
34#[derive(Debug, Clone, PartialEq, Eq, Default)]
35#[non_exhaustive]
36pub struct BranchStatus {
37    /// The HEAD commit's full object id (`# branch.oid`); `None` on an unborn
38    /// repo (git reports `(initial)`). Truncate for display.
39    pub head: Option<String>,
40    /// Current branch name (`# branch.head`); `None` when detached.
41    pub branch: Option<String>,
42    /// Upstream tracking branch (`# branch.upstream`); `None` when unset.
43    pub upstream: Option<String>,
44    /// Commits ahead of the upstream (`# branch.ab +A`); `None` when no upstream.
45    pub ahead: Option<usize>,
46    /// Commits behind the upstream (`# branch.ab -B`); `None` when no upstream.
47    pub behind: Option<usize>,
48    /// Count of changed *tracked* entries — modified/added/deleted/renamed/copied
49    /// and unmerged (the `1`/`2`/`u` records).
50    pub tracked_changes: usize,
51    /// Count of untracked files (the `?` records).
52    pub untracked: usize,
53    /// Count of unmerged (conflicted) entries (the `u` records; also in
54    /// `tracked_changes`).
55    pub conflicts: usize,
56}
57
58impl BranchStatus {
59    /// Whether the working tree has any change at all — tracked or untracked.
60    pub fn is_dirty(&self) -> bool {
61        self.tracked_changes > 0 || self.untracked > 0
62    }
63}
64
65/// A commit, parsed from a `\x1f`-delimited `git log` line.
66#[derive(Debug, Clone, PartialEq, Eq)]
67#[non_exhaustive]
68pub struct Commit {
69    /// Full commit hash (`%H`).
70    pub hash: String,
71    /// Abbreviated commit hash (`%h`).
72    pub short_hash: String,
73    /// Author name (`%an`).
74    pub author: String,
75    /// Author date, strict ISO-8601 (`%aI`), e.g. `2026-05-31T10:00:00+00:00`.
76    pub date: String,
77    /// Subject line (`%s`).
78    pub subject: String,
79}
80
81/// A local branch from `git branch`.
82#[derive(Debug, Clone, PartialEq, Eq)]
83#[non_exhaustive]
84pub struct Branch {
85    /// Branch name.
86    pub name: String,
87    /// Whether this is the checked-out branch (the `*` marker).
88    pub current: bool,
89}
90
91/// A worktree from `git worktree list --porcelain`.
92#[derive(Debug, Clone, PartialEq, Eq)]
93#[non_exhaustive]
94pub struct Worktree {
95    /// Absolute path to the worktree. A [`PathBuf`] built from the raw
96    /// `worktree list --porcelain` bytes (via [`vcs_diff::path_from_bytes`]), so a
97    /// worktree whose directory name is not valid UTF-8 (legal on Unix) is carried
98    /// losslessly instead of being flattened to `U+FFFD` — the same platform-correct
99    /// type `StatusEntry::path` uses, and what the facade's `WorktreeInfo.path`
100    /// forwards.
101    pub path: PathBuf,
102    /// Short branch name (`refs/heads/` stripped); `None` when detached or bare.
103    pub branch: Option<String>,
104    /// The checked-out commit (`HEAD <sha>`); `None` for a bare entry.
105    pub head: Option<String>,
106    /// The main worktree of a bare repository.
107    pub bare: bool,
108    /// Checked out at a detached HEAD (no branch).
109    pub detached: bool,
110    /// Locked against pruning.
111    pub locked: bool,
112}
113
114/// Parse `git status --porcelain=v1 -z` output: NUL-delimited records, raw
115/// (unquoted) paths. A rename/copy entry is followed by its source path as the
116/// next NUL record (e.g. `R  new\0old\0`).
117///
118/// Consumes **raw bytes** (not a lossily-decoded `&str`): the path is part of the
119/// payload and, on Unix, need not be valid UTF-8 — decoding through
120/// `String::from_utf8_lossy` first would corrupt it to `U+FFFD` and break the
121/// round-trip back into `add`/`commit_paths`. The two-byte status code is ASCII;
122/// only the path bytes are carried losslessly (via [`vcs_diff::path_from_bytes`]).
123pub(crate) fn parse_porcelain(output: &[u8]) -> Vec<StatusEntry> {
124    let mut entries = Vec::new();
125    let mut records = output.split(|&b| b == 0).filter(|rec| !rec.is_empty());
126    while let Some(rec) = records.next() {
127        // "XY path": two status-code bytes, then a space at index 2, then the raw
128        // path bytes. Require the separating space (git's porcelain always emits
129        // it) so a malformed/short record — e.g. one whose leading bytes are a
130        // multibyte char, where index 2 is not the space — is skipped, not turned
131        // into a garbage entry.
132        let (Some(code), Some(&b' ')) = (rec.get(..2), rec.get(2)) else {
133            continue;
134        };
135        let path = &rec[3..];
136        // A rename/copy carries its source path as the immediately following NUL
137        // record; consume it. The `R`/`C` can sit in EITHER status column — the index
138        // column (`R ` staged rename) or the worktree column (` R` worktree rename) —
139        // so check both. Missing the ` R`/` C` case left the source record as a
140        // phantom entry with a garbage `code`/`path` (M11).
141        let old_path = if matches!(code, [b'R' | b'C', _] | [_, b'R' | b'C']) {
142            records.next().map(vcs_diff::path_from_bytes)
143        } else {
144            None
145        };
146        entries.push(StatusEntry {
147            // The status code is always 2 ASCII bytes, so this decode is exact.
148            code: String::from_utf8_lossy(code).into_owned(),
149            path: vcs_diff::path_from_bytes(path),
150            old_path,
151        });
152    }
153    entries
154}
155
156/// Parse `git status --porcelain=v2 --branch -z` output into a [`BranchStatus`].
157///
158/// Records are NUL-terminated: `# branch.*` header lines first, then entry lines
159/// (`1`/`2` changed, `u` unmerged, `?` untracked, `!` ignored). A `2` (rename/copy)
160/// entry stores its original path as the *next* NUL record, so that record is
161/// consumed and skipped. Everything is `strip_prefix`/compare based — no byte
162/// indexing — so arbitrary bytes never panic (proven by proptest).
163pub(crate) fn parse_porcelain_v2(output: &str) -> BranchStatus {
164    let mut status = BranchStatus::default();
165    let mut records = output.split('\0');
166    while let Some(rec) = records.next() {
167        if let Some(rest) = rec.strip_prefix("# branch.oid ") {
168            // `(initial)` marks an unborn repo (no commits yet).
169            status.head = (rest != "(initial)").then(|| rest.to_string());
170        } else if let Some(rest) = rec.strip_prefix("# branch.head ") {
171            status.branch = (rest != "(detached)").then(|| rest.to_string());
172        } else if let Some(rest) = rec.strip_prefix("# branch.upstream ") {
173            status.upstream = Some(rest.to_string());
174        } else if let Some(rest) = rec.strip_prefix("# branch.ab ") {
175            // `+<ahead> -<behind>`.
176            let mut parts = rest.split(' ');
177            status.ahead = parts
178                .next()
179                .and_then(|t| t.strip_prefix('+'))
180                .and_then(|n| n.parse().ok());
181            status.behind = parts
182                .next()
183                .and_then(|t| t.strip_prefix('-'))
184                .and_then(|n| n.parse().ok());
185        } else if rec.starts_with("1 ") {
186            status.tracked_changes += 1;
187        } else if rec.starts_with("2 ") {
188            status.tracked_changes += 1;
189            // The rename/copy original path is the next NUL record; consume it so
190            // it isn't mis-read as another entry.
191            records.next();
192        } else if rec.starts_with("u ") {
193            status.tracked_changes += 1;
194            status.conflicts += 1;
195        } else if rec.starts_with("? ") {
196            status.untracked += 1;
197        }
198        // `! ` (ignored) and other `# ` headers contribute nothing.
199    }
200    status
201}
202
203/// Parse `git --version` output (`git version 2.54.0.windows.1`) into the shared
204/// [`vcs_diff::Version`]: the first dotted-numeric token wins; non-numeric
205/// trailers (`.windows.1`, `-rc1`) are ignored; a missing patch reads as `0`.
206pub(crate) fn parse_git_version(raw: &str) -> Option<vcs_diff::Version> {
207    vcs_diff::parse_dotted_version(raw)
208}
209
210/// Parse a NUL-delimited path list (e.g. `git diff --name-only -z`): one
211/// repo-relative path per record, `/` separators, no quoting.
212///
213/// Consumes **raw bytes** and yields [`PathBuf`]s (via
214/// [`vcs_diff::path_from_bytes`]) so a non-UTF-8 conflicted/diff path survives
215/// losslessly rather than being flattened to `U+FFFD` by a `&str` decode.
216pub(crate) fn parse_nul_paths(output: &[u8]) -> Vec<PathBuf> {
217    output
218        .split(|&b| b == 0)
219        .filter(|path| !path.is_empty())
220        .map(vcs_diff::path_from_bytes)
221        .collect()
222}
223
224/// Parse `git log -z --format=%H%x1f%h%x1f%an%x1f%aI%x1f%s` output: commits are
225/// NUL-separated (robust to multi-line fields), fields split on the ASCII unit
226/// separator.
227pub(crate) fn parse_log(output: &str) -> Vec<Commit> {
228    output
229        .split('\0')
230        .filter(|rec| !rec.is_empty())
231        .filter_map(|rec| {
232            let mut fields = rec.split('\u{1f}');
233            Some(Commit {
234                hash: fields.next()?.to_string(),
235                short_hash: fields.next()?.to_string(),
236                author: fields.next()?.to_string(),
237                date: fields.next()?.to_string(),
238                subject: fields.next().unwrap_or("").to_string(),
239            })
240        })
241        .collect()
242}
243
244/// Parse `git branch` output. The first column is the `* `/`  `/`+ ` marker.
245pub(crate) fn parse_branches(output: &str) -> Vec<Branch> {
246    output
247        .lines()
248        .filter(|line| !line.trim().is_empty())
249        .filter_map(|line| {
250            let current = line.starts_with('*');
251            let name = line.get(1..).unwrap_or("").trim();
252            // Skip the detached-HEAD pseudo-entry, e.g. "* (HEAD detached at …)".
253            if name.is_empty() || name.starts_with('(') {
254                return None;
255            }
256            Some(Branch {
257                name: name.to_string(),
258                current,
259            })
260        })
261        .collect()
262}
263
264/// Parse `git worktree list --porcelain`: records separated by a blank line,
265/// each a set of `label [value]` lines — `worktree <path>`, `HEAD <sha>`,
266/// `branch refs/heads/<name>`, plus the valueless attributes `bare` / `detached`
267/// / `locked`. Unknown labels (e.g. `prunable`) are ignored.
268///
269/// Consumes **raw bytes** (not a lossily-decoded `&str`): the `worktree <path>`
270/// value is a filesystem path that, on Unix, need not be valid UTF-8, so its bytes
271/// are carried losslessly (via [`vcs_diff::path_from_bytes`]) — a `String` decode
272/// would substitute `U+FFFD` and make `Worktree.path` name a *different* directory,
273/// the same defect the status/diff surface already avoids. The labels and the
274/// text-typed values (`HEAD` sha, `branch` ref) are ASCII, so they still decode as
275/// `String`.
276///
277/// This parses the **newline-framed** porcelain (no `-z`): git only grew
278/// `worktree list --porcelain -z` in 2.36, above this crate's git-support floor
279/// (2.31), and requesting `-z` there would hard-fail the listing. Newline framing
280/// already covers the non-UTF-8 case this task targets — a path byte is never `\n`
281/// — so only a worktree path containing a *literal newline* stays out of scope,
282/// exactly as before this change.
283pub(crate) fn parse_worktree_porcelain(output: &[u8]) -> Vec<Worktree> {
284    let mut worktrees = Vec::new();
285    let mut current: Option<Worktree> = None;
286    let flush = |current: &mut Option<Worktree>, out: &mut Vec<Worktree>| {
287        if let Some(wt) = current.take() {
288            out.push(wt);
289        }
290    };
291    for line in output.split(|&b| b == b'\n') {
292        if line.is_empty() {
293            flush(&mut current, &mut worktrees);
294            continue;
295        }
296        // `label value`, split on the FIRST ASCII space (the path itself may hold
297        // spaces); a valueless attribute (`bare`/`detached`/`locked`) has none.
298        let (label, value) = match line.iter().position(|&b| b == b' ') {
299            Some(i) => (&line[..i], Some(&line[i + 1..])),
300            None => (line, None),
301        };
302        match label {
303            // A new record begins; flush any record not closed by a blank line.
304            b"worktree" => {
305                flush(&mut current, &mut worktrees);
306                current = Some(Worktree {
307                    // Raw path bytes → `PathBuf`, lossless on Unix.
308                    path: value.map(vcs_diff::path_from_bytes).unwrap_or_default(),
309                    branch: None,
310                    head: None,
311                    bare: false,
312                    detached: false,
313                    locked: false,
314                });
315            }
316            b"HEAD" => {
317                if let Some(wt) = current.as_mut() {
318                    wt.head = value.map(|v| String::from_utf8_lossy(v).into_owned());
319                }
320            }
321            b"branch" => {
322                if let Some(wt) = current.as_mut() {
323                    // Value is a full ref (`refs/heads/main`); expose the short name.
324                    wt.branch = value.map(|v| {
325                        let full = String::from_utf8_lossy(v);
326                        full.strip_prefix("refs/heads/")
327                            .unwrap_or(&full)
328                            .to_string()
329                    });
330                }
331            }
332            b"bare" => {
333                if let Some(wt) = current.as_mut() {
334                    wt.bare = true;
335                }
336            }
337            b"detached" => {
338                if let Some(wt) = current.as_mut() {
339                    wt.detached = true;
340                }
341            }
342            b"locked" => {
343                if let Some(wt) = current.as_mut() {
344                    wt.locked = true;
345                }
346            }
347            _ => {}
348        }
349    }
350    flush(&mut current, &mut worktrees);
351    worktrees
352}
353
354/// One line of `git blame --line-porcelain` output: who last touched the line
355/// and where it came from.
356#[derive(Debug, Clone, PartialEq, Eq)]
357#[non_exhaustive]
358pub struct BlameLine {
359    /// Full hash of the commit that last changed the line.
360    pub commit: String,
361    /// Line number in that commit's version of the file (1-based).
362    pub orig_line: u32,
363    /// Line number in the blamed version of the file (1-based).
364    pub final_line: u32,
365    /// Author name of that commit.
366    pub author: String,
367    /// Author timestamp as a unix epoch (seconds).
368    pub author_time: i64,
369    /// Author timezone offset, e.g. `+0200`.
370    pub author_tz: String,
371    /// The line's content (without the trailing newline).
372    pub content: String,
373}
374
375/// Parse `git blame --line-porcelain` output. Every line gets a header
376/// (`<sha> <orig> <final> [<group count>]`, where `<sha>` is a 40-hex SHA-1 or a
377/// 64-hex SHA-256 object id), a full set of `tag value` metadata lines (`author`,
378/// `author-time`, …, optional `boundary`), then the content prefixed with a literal
379/// TAB.
380pub(crate) fn parse_blame_porcelain(output: &str) -> Vec<BlameLine> {
381    let mut lines = Vec::new();
382    let mut current: Option<BlameLine> = None;
383    for line in output.lines() {
384        // Content line: closes the current record.
385        if let Some(content) = line.strip_prefix('\t') {
386            if let Some(mut entry) = current.take() {
387                entry.content = content.to_string();
388                lines.push(entry);
389            }
390            continue;
391        }
392        let (label, value) = match line.split_once(' ') {
393            Some((l, v)) => (l, v),
394            None => (line, ""),
395        };
396        // Header: a commit sha followed by line numbers (and an optional group
397        // count, which only appears on a group's first line). Accept both SHA-1
398        // (40 hex) and SHA-256 (64 hex) object ids — a SHA-256 repo would otherwise
399        // never match, so `blame` would silently return an empty `Vec`.
400        if (label.len() == 40 || label.len() == 64) && label.bytes().all(|b| b.is_ascii_hexdigit())
401        {
402            let mut nums = value.split(' ');
403            let orig = nums.next().and_then(|n| n.parse().ok()).unwrap_or(0);
404            let fin = nums.next().and_then(|n| n.parse().ok()).unwrap_or(0);
405            current = Some(BlameLine {
406                commit: label.to_string(),
407                orig_line: orig,
408                final_line: fin,
409                author: String::new(),
410                author_time: 0,
411                author_tz: String::new(),
412                content: String::new(),
413            });
414            continue;
415        }
416        let Some(entry) = current.as_mut() else {
417            continue;
418        };
419        match label {
420            "author" => entry.author = value.to_string(),
421            "author-time" => entry.author_time = value.parse().unwrap_or(0),
422            "author-tz" => entry.author_tz = value.to_string(),
423            // committer*/summary/filename/previous/boundary intentionally not
424            // captured — `#[non_exhaustive]` leaves room to add them later.
425            _ => {}
426        }
427    }
428    lines
429}
430
431/// Parse `git diff --shortstat`, e.g. ` 3 files changed, 12 insertions(+), 4
432/// deletions(-)`. Any clause may be absent (a pure-insertion diff omits
433/// deletions; no changes yields an empty string → all zeros).
434pub(crate) fn parse_shortstat(output: &str) -> DiffStat {
435    let mut stat = DiffStat::default();
436    for part in output.split(',') {
437        let part = part.trim();
438        let n = part
439            .split_whitespace()
440            .next()
441            .and_then(|tok| tok.parse().ok())
442            .unwrap_or(0);
443        if part.contains("file") {
444            stat.files_changed = n;
445        } else if part.contains("insertion") {
446            stat.insertions = n;
447        } else if part.contains("deletion") {
448            stat.deletions = n;
449        }
450    }
451    stat
452}
453
454/// Parse `git ls-remote --heads <remote>` output — `<sha>\trefs/heads/<name>`
455/// per line — into the bare branch names.
456pub(crate) fn parse_ls_remote_heads(output: &str) -> Vec<String> {
457    output
458        .lines()
459        .filter_map(|line| {
460            let (_sha, refname) = line.split_once('\t')?;
461            refname
462                .trim()
463                .strip_prefix("refs/heads/")
464                .map(str::to_string)
465        })
466        .collect()
467}
468
469#[cfg(test)]
470mod tests {
471    use super::*;
472
473    #[test]
474    fn porcelain_parses_codes_and_paths() {
475        // NUL-delimited records; the path with a space stays raw (no quoting).
476        let got = parse_porcelain(b" M src/lib.rs\0?? new file.txt\0A  added.rs\0");
477        assert_eq!(
478            got,
479            vec![
480                StatusEntry {
481                    code: " M".into(),
482                    path: "src/lib.rs".into(),
483                    old_path: None,
484                },
485                StatusEntry {
486                    code: "??".into(),
487                    path: "new file.txt".into(),
488                    old_path: None,
489                },
490                StatusEntry {
491                    code: "A ".into(),
492                    path: "added.rs".into(),
493                    old_path: None,
494                },
495            ]
496        );
497    }
498
499    // A path whose bytes are not valid UTF-8 (legal on Unix) survives byte-for-byte
500    // through `parse_porcelain` — the load-bearing property for the status→add
501    // round-trip. `0xFF` is never valid UTF-8; the old `from_utf8_lossy` path would
502    // have replaced it with U+FFFD and named a different file.
503    #[cfg(unix)]
504    #[test]
505    fn porcelain_preserves_non_utf8_path_bytes() {
506        use std::os::unix::ffi::OsStrExt;
507        let got = parse_porcelain(b" M caf\xff.txt\0");
508        assert_eq!(got.len(), 1);
509        assert_eq!(got[0].path.as_os_str().as_bytes(), b"caf\xff.txt");
510    }
511
512    #[test]
513    fn porcelain_parses_rename_with_old_path() {
514        // `R  new\0old\0` — the source path is the next NUL record.
515        let got = parse_porcelain(b"R  new.rs\0old.rs\0 M other.rs\0");
516        assert_eq!(
517            got,
518            vec![
519                StatusEntry {
520                    code: "R ".into(),
521                    path: "new.rs".into(),
522                    old_path: Some("old.rs".into()),
523                },
524                StatusEntry {
525                    code: " M".into(),
526                    path: "other.rs".into(),
527                    old_path: None,
528                },
529            ]
530        );
531    }
532
533    // M11: a rename/copy in the WORKTREE column (` R`/` C`, not just the index `R `)
534    // must also consume its source record — otherwise the source became a phantom
535    // entry with a garbage code/path.
536    #[test]
537    fn porcelain_parses_worktree_rename_in_the_y_column() {
538        // ` R new\0old\0` — space in X, R in Y (a worktree rename).
539        let got = parse_porcelain(b" R new.rs\0old.rs\0 M other.rs\0");
540        assert_eq!(
541            got,
542            vec![
543                StatusEntry {
544                    code: " R".into(),
545                    path: "new.rs".into(),
546                    old_path: Some("old.rs".into()),
547                },
548                StatusEntry {
549                    code: " M".into(),
550                    path: "other.rs".into(),
551                    old_path: None,
552                },
553            ],
554            "the source record must be consumed, not left as a phantom entry"
555        );
556    }
557
558    #[test]
559    fn porcelain_ignores_blank_and_short_records() {
560        assert!(parse_porcelain(b"\0  \0X\0").is_empty());
561    }
562
563    // A record whose leading char is multibyte has no space at index 2, so it is
564    // skipped (git's porcelain always emits `XY<space>path`). `𝓁` is 4 bytes, so
565    // the byte at index 2 is a continuation byte, not the separating space.
566    #[test]
567    fn porcelain_skips_non_ascii_status_records() {
568        assert!(parse_porcelain("𝓁abc\0".as_bytes()).is_empty());
569        // A well-formed record alongside the garbage still parses.
570        let entries = parse_porcelain("𝓁abc\0 M a.rs\0".as_bytes());
571        assert_eq!(entries.len(), 1);
572        assert_eq!(entries[0].path, std::path::Path::new("a.rs"));
573    }
574
575    #[test]
576    fn porcelain_v2_parses_branch_and_change_counts() {
577        // The rename's original path (`1 trap.rs`) is the next NUL record; it must
578        // be CONSUMED, not counted as a fourth `1 …` change.
579        let out = concat!(
580            "# branch.oid abcdef1234567890\0",
581            "# branch.head main\0",
582            "# branch.upstream origin/main\0",
583            "# branch.ab +2 -1\0",
584            "1 .M N... 100644 100644 100644 1111 2222 a.rs\0",
585            "2 R. N... 100644 100644 100644 3333 4444 R100 new.rs\0",
586            "1 trap.rs\0",
587            "u UU N... 100644 100644 100644 100644 5 6 7 conflict.rs\0",
588            "? untracked.txt\0",
589            "! ignored.txt\0",
590        );
591        let s = parse_porcelain_v2(out);
592        assert_eq!(s.head.as_deref(), Some("abcdef1234567890"));
593        assert_eq!(s.branch.as_deref(), Some("main"));
594        assert_eq!(s.upstream.as_deref(), Some("origin/main"));
595        assert_eq!((s.ahead, s.behind), (Some(2), Some(1)));
596        assert_eq!(
597            s.tracked_changes, 3,
598            "1 + 2(rename) + u; the trap is consumed"
599        );
600        assert_eq!(s.untracked, 1);
601        assert_eq!(s.conflicts, 1);
602        assert!(s.is_dirty());
603    }
604
605    #[test]
606    fn porcelain_v2_handles_unborn_detached_and_no_upstream() {
607        // Unborn repo: `(initial)` oid, no ab line, clean tree.
608        let s = parse_porcelain_v2("# branch.oid (initial)\0# branch.head main\0");
609        assert_eq!(s.head, None);
610        assert_eq!(s.branch.as_deref(), Some("main"));
611        assert_eq!(s.upstream, None);
612        assert_eq!((s.ahead, s.behind), (None, None));
613        assert!(!s.is_dirty());
614
615        // Detached HEAD, no upstream tracking.
616        let s = parse_porcelain_v2("# branch.oid deadbeef\0# branch.head (detached)\0");
617        assert_eq!(s.head.as_deref(), Some("deadbeef"));
618        assert_eq!(s.branch, None);
619        assert_eq!(s.upstream, None);
620    }
621
622    // --line-porcelain repeats the full metadata for every line; the group
623    // count appears only on a group's first header, and `boundary` is a
624    // valueless tag — both must parse.
625    #[test]
626    fn blame_line_porcelain_parses_headers_and_metadata() {
627        let sha_a = "a".repeat(40);
628        let sha_b = "b".repeat(40);
629        let out = format!(
630            "{sha_a} 1 1 2\nauthor Alice\nauthor-mail <a@x>\nauthor-time 1717500000\n\
631             author-tz +0200\ncommitter Alice\nsummary first\nboundary\nfilename f.txt\n\
632             \tline one\n\
633             {sha_a} 2 2\nauthor Alice\nauthor-mail <a@x>\nauthor-time 1717500000\n\
634             author-tz +0200\ncommitter Alice\nsummary first\nfilename f.txt\n\
635             \tline two\n\
636             {sha_b} 1 3 1\nauthor Bob\nauthor-mail <b@x>\nauthor-time 1717600000\n\
637             author-tz -0500\ncommitter Bob\nsummary second\nfilename f.txt\n\
638             \t\n"
639        );
640        let lines = parse_blame_porcelain(&out);
641        assert_eq!(lines.len(), 3);
642        assert_eq!(lines[0].commit, sha_a);
643        assert_eq!(lines[0].orig_line, 1);
644        assert_eq!(lines[0].final_line, 1);
645        assert_eq!(lines[0].author, "Alice");
646        assert_eq!(lines[0].author_time, 1717500000);
647        assert_eq!(lines[0].author_tz, "+0200");
648        assert_eq!(lines[0].content, "line one");
649        // Second line of the same group: header without a group count.
650        assert_eq!(lines[1].final_line, 2);
651        assert_eq!(lines[1].content, "line two");
652        // A different commit, and an empty content line stays empty.
653        assert_eq!(lines[2].commit, sha_b);
654        assert_eq!(lines[2].author, "Bob");
655        assert_eq!(lines[2].content, "");
656    }
657
658    #[test]
659    fn blame_ignores_garbage_and_empty_input() {
660        assert!(parse_blame_porcelain("").is_empty());
661        assert!(parse_blame_porcelain("not a header\n\torphan content\n").is_empty());
662    }
663
664    // A SHA-256 repository emits 64-hex commit ids; the header must still be
665    // recognised (the old `len()==40`-only check made `blame` return an empty Vec).
666    #[test]
667    fn blame_recognises_sha256_object_ids() {
668        let sha = "c".repeat(64);
669        let out = format!(
670            "{sha} 1 1 1\nauthor Carol\nauthor-mail <c@x>\nauthor-time 1717700000\n\
671             author-tz +0000\ncommitter Carol\nsummary s\nfilename f.txt\n\
672             \tline\n"
673        );
674        let lines = parse_blame_porcelain(&out);
675        assert_eq!(
676            lines.len(),
677            1,
678            "a SHA-256 blame must parse, not drop to empty"
679        );
680        assert_eq!(lines[0].commit, sha);
681        assert_eq!(lines[0].author, "Carol");
682        assert_eq!(lines[0].content, "line");
683    }
684
685    #[test]
686    fn git_version_parses_real_world_shapes() {
687        // The Windows build trailer (`.windows.1`) is extra dotted components
688        // beyond the patch; an `-rc1` suffix rides on the patch itself.
689        let v = parse_git_version("git version 2.54.0.windows.1").unwrap();
690        assert_eq!((v.major, v.minor, v.patch), (2, 54, 0));
691        let v = parse_git_version("git version 2.41.0-rc1").unwrap();
692        assert_eq!((v.major, v.minor, v.patch), (2, 41, 0));
693        let v = parse_git_version("git version 2.54").unwrap();
694        assert_eq!(v.patch, 0, "missing patch defaults to 0");
695        assert!(parse_git_version("no digits here").is_none());
696        assert!(parse_git_version("git version unknowable").is_none());
697    }
698
699    #[test]
700    fn nul_paths_split_and_keep_special_characters() {
701        assert_eq!(
702            parse_nul_paths(b"a.rs\0sub/with space.rs\0"),
703            [PathBuf::from("a.rs"), PathBuf::from("sub/with space.rs")]
704        );
705        assert!(parse_nul_paths(b"").is_empty());
706    }
707
708    #[test]
709    fn log_splits_unit_separated_fields() {
710        let input = "abc123\u{1f}abc\u{1f}Ada\u{1f}2026-05-31T10:00:00+00:00\u{1f}Add feature\0\
711                     def456\u{1f}def\u{1f}Linus\u{1f}2026-05-30T09:00:00+00:00\u{1f}Fix bug\0";
712        let got = parse_log(input);
713        assert_eq!(got.len(), 2);
714        assert_eq!(
715            got[0],
716            Commit {
717                hash: "abc123".into(),
718                short_hash: "abc".into(),
719                author: "Ada".into(),
720                date: "2026-05-31T10:00:00+00:00".into(),
721                subject: "Add feature".into(),
722            }
723        );
724        assert_eq!(got[1].subject, "Fix bug");
725    }
726
727    #[test]
728    fn log_tolerates_empty_subject() {
729        let got = parse_log("h\u{1f}h\u{1f}A\u{1f}2026-05-31T10:00:00+00:00\u{1f}\0");
730        assert_eq!(got[0].subject, "");
731    }
732
733    #[test]
734    fn branches_marks_current_and_skips_detached() {
735        let got = parse_branches("* main\n  feature\n  (HEAD detached at abc123)\n");
736        assert_eq!(
737            got,
738            vec![
739                Branch {
740                    name: "main".into(),
741                    current: true
742                },
743                Branch {
744                    name: "feature".into(),
745                    current: false
746                },
747            ]
748        );
749    }
750
751    #[test]
752    fn worktrees_parse_branch_detached_and_bare() {
753        let input = "worktree /repo\nHEAD abc123\nbranch refs/heads/main\n\
754                     \nworktree /repo/wt\nHEAD def456\ndetached\n\
755                     \nworktree /repo/bare\nbare\n";
756        let got = parse_worktree_porcelain(input.as_bytes());
757        assert_eq!(got.len(), 3);
758        assert_eq!(got[0].path, PathBuf::from("/repo"));
759        assert_eq!(got[0].branch.as_deref(), Some("main"));
760        assert_eq!(got[0].head.as_deref(), Some("abc123"));
761        assert!(got[1].detached && got[1].branch.is_none());
762        assert!(got[2].bare && got[2].head.is_none());
763    }
764
765    // A worktree whose directory name is not valid UTF-8 (legal on Unix) survives
766    // byte-for-byte through `parse_worktree_porcelain`, so the facade's
767    // `WorktreeInfo.path` addresses the SAME directory. `0xFF` is never valid UTF-8;
768    // the old `&str` (`from_utf8_lossy`) parse would have replaced it with U+FFFD.
769    #[cfg(unix)]
770    #[test]
771    fn worktrees_preserve_non_utf8_path_bytes() {
772        use std::os::unix::ffi::OsStrExt;
773        let got = parse_worktree_porcelain(b"worktree /repo/wt-caf\xff\nHEAD abc123\n");
774        assert_eq!(got.len(), 1);
775        assert_eq!(got[0].path.as_os_str().as_bytes(), b"/repo/wt-caf\xff");
776        assert_eq!(got[0].head.as_deref(), Some("abc123"));
777    }
778
779    #[test]
780    fn worktrees_parse_last_record_without_trailing_blank() {
781        // The final record may not be followed by a blank line.
782        let got = parse_worktree_porcelain(b"worktree /only\nHEAD aaa\nbranch refs/heads/x\n");
783        assert_eq!(got.len(), 1);
784        assert_eq!(got[0].branch.as_deref(), Some("x"));
785    }
786
787    #[test]
788    fn shortstat_parses_all_clauses() {
789        let got = parse_shortstat(" 3 files changed, 12 insertions(+), 4 deletions(-)\n");
790        assert_eq!(got, DiffStat::new(3, 12, 4));
791    }
792
793    #[test]
794    fn shortstat_tolerates_missing_clauses_and_empty() {
795        // Pure-insertion diff omits deletions; no changes yields all zeros.
796        let only_ins = parse_shortstat(" 1 file changed, 2 insertions(+)\n");
797        assert_eq!(only_ins.insertions, 2);
798        assert_eq!(only_ins.deletions, 0);
799        assert_eq!(parse_shortstat(""), DiffStat::default());
800    }
801}
802
803// Property-based fuzzing: the parsers are pure functions over *arbitrary* CLI
804// text (a git on the user's machine we don't control), so the load-bearing
805// invariant is "never panic, whatever the bytes". These feed both unconstrained
806// Unicode and structure-biased inputs (real delimiters: NUL, tab, unit
807// separator, `diff --git`, `@@` hunks, rename braces) so the fuzzer reaches the
808// byte-offset branches, not just the early returns.
809#[cfg(test)]
810mod proptests {
811    use super::*;
812    use proptest::prelude::*;
813
814    /// A line drawn from git's structural vocabulary plus multibyte text, so a
815    /// joined document exercises the porcelain/diff/blame branches.
816    fn structured_line() -> impl Strategy<Value = String> {
817        prop_oneof![
818            Just("diff --git a/f b/f\n".to_string()),
819            Just("--- a/f\n".to_string()),
820            Just("+++ b/f\n".to_string()),
821            Just("@@ -1,2 +3,4 @@ ctx\n".to_string()),
822            Just("@@ -1 +1 @@\n".to_string()),
823            Just("rename from {old => new}.rs\n".to_string()),
824            Just("R100\told\tnew\n".to_string()),
825            Just(format!("{}\n", "a".repeat(40))), // a 40-hex-ish blame header
826            "[-+ ]?[a-zé\t]{0,12}\n",              // diff body / text incl. multibyte
827            "[ MARD?]{0,2} [a-zé/]{0,8}\0",        // porcelain-ish NUL record
828        ]
829    }
830
831    fn structured_doc() -> impl Strategy<Value = String> {
832        prop::collection::vec(structured_line(), 0..40).prop_map(|lines| lines.concat())
833    }
834
835    proptest! {
836        // Panic-freedom on completely arbitrary input.
837        #[test]
838        fn parsers_never_panic_on_arbitrary_text(s in any::<String>()) {
839            let _ = parse_porcelain(s.as_bytes());
840            let _ = parse_porcelain_v2(&s);
841            let _ = parse_log(&s);
842            let _ = parse_branches(&s);
843            let _ = parse_worktree_porcelain(s.as_bytes());
844            let _ = parse_blame_porcelain(&s);
845            let _ = parse_shortstat(&s);
846            let _ = parse_ls_remote_heads(&s);
847            let _ = parse_nul_paths(s.as_bytes());
848            let _ = parse_git_version(&s);
849        }
850
851        // The byte parsers must also never panic on *arbitrary bytes* — the actual
852        // shape of a `-z` stream carrying a non-UTF-8 path, which the `String`
853        // generator above can never produce.
854        #[test]
855        fn byte_parsers_never_panic_on_arbitrary_bytes(b in any::<Vec<u8>>()) {
856            let _ = parse_porcelain(&b);
857            let _ = parse_nul_paths(&b);
858            let _ = parse_worktree_porcelain(&b);
859        }
860
861        // …and on structure-biased input that reaches the parsing branches.
862        #[test]
863        fn parsers_never_panic_on_structured_text(s in structured_doc()) {
864            let _ = parse_porcelain(s.as_bytes());
865            let _ = parse_porcelain_v2(&s);
866            let _ = parse_log(&s);
867            let _ = parse_blame_porcelain(&s);
868        }
869
870        // porcelain v2 header/entry lines (with the `2`-consumes-next-record path)
871        // must never panic on arbitrary NUL-joined records.
872        #[test]
873        fn porcelain_v2_never_panics(records in prop::collection::vec(
874            prop_oneof![
875                Just("# branch.oid (initial)".to_string()),
876                Just("# branch.head main".to_string()),
877                Just("# branch.ab +1 -2".to_string()),
878                "1 [.MADRCU]{2} [a-zé /]{0,10}".prop_map(|s| s),
879                "2 R\\. .* R100 [a-zé /]{0,8}".prop_map(|s| s),
880                "u UU [a-zé /]{0,8}".prop_map(|s| s),
881                "\\? [a-zé /]{0,8}".prop_map(|s| s),
882                "[a-zé0-9# ]{0,12}".prop_map(|s| s),
883            ],
884            0..20,
885        ).prop_map(|r| r.join("\0"))) {
886            let _ = parse_porcelain_v2(&records);
887        }
888    }
889}