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