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
12use crate::{BINARY, BisectStep, Error, Result, RevSpec};
13
14/// One entry from `git status --porcelain=v1 -z` (`XY <path>`, NUL-delimited).
15#[derive(Debug, Clone, PartialEq, Eq)]
16#[non_exhaustive]
17pub struct StatusEntry {
18    /// Two-character status code, e.g. `" M"`, `"??"`, `"A "`, `"R "`.
19    pub code: String,
20    /// Path the status applies to (the *new* path for a rename/copy). A
21    /// [`PathBuf`] built from the raw `-z` bytes (no C-quoting to undo, even for
22    /// paths with spaces), so a filename whose bytes are not valid UTF-8 (legal on
23    /// Unix) is carried losslessly and can be fed straight back into `add` /
24    /// `commit_paths` — decoding it through `String::from_utf8_lossy` would
25    /// substitute `U+FFFD` and address a different file.
26    pub path: PathBuf,
27    /// For a rename/copy, the original path; `None` otherwise. Named to match
28    /// `vcs_jj::ChangedPath::old_path` so cross-backend code reads the rename
29    /// source the same way on both wrappers.
30    pub old_path: Option<PathBuf>,
31}
32
33/// A combined branch + working-tree snapshot from `git status --porcelain=v2
34/// --branch -z`: HEAD, branch, upstream tracking, ahead/behind, and change
35/// counts — everything a prompt/status-bar needs, in **one** process spawn.
36#[derive(Debug, Clone, PartialEq, Eq, Default)]
37#[non_exhaustive]
38pub struct BranchStatus {
39    /// The HEAD commit's full object id (`# branch.oid`); `None` on an unborn
40    /// repo (git reports `(initial)`). Truncate for display.
41    pub head: Option<String>,
42    /// Current branch name (`# branch.head`); `None` when detached.
43    pub branch: Option<String>,
44    /// Upstream tracking branch (`# branch.upstream`); `None` when unset.
45    pub upstream: Option<String>,
46    /// Commits ahead of the upstream (`# branch.ab +A`); `None` when no upstream.
47    pub ahead: Option<usize>,
48    /// Commits behind the upstream (`# branch.ab -B`); `None` when no upstream.
49    pub behind: Option<usize>,
50    /// Count of changed *tracked* entries — modified/added/deleted/renamed/copied
51    /// and unmerged (the `1`/`2`/`u` records).
52    pub tracked_changes: usize,
53    /// Count of untracked files (the `?` records).
54    pub untracked: usize,
55    /// Count of unmerged (conflicted) entries (the `u` records; also in
56    /// `tracked_changes`).
57    pub conflicts: usize,
58}
59
60impl BranchStatus {
61    /// Whether the working tree has any change at all — tracked or untracked.
62    pub fn is_dirty(&self) -> bool {
63        self.tracked_changes > 0 || self.untracked > 0
64    }
65}
66
67/// A commit, parsed from a `\x1f`-delimited `git log` line.
68#[derive(Debug, Clone, PartialEq, Eq)]
69#[non_exhaustive]
70pub struct Commit {
71    /// Full commit hash (`%H`).
72    pub hash: String,
73    /// Abbreviated commit hash (`%h`).
74    pub short_hash: String,
75    /// Author name (`%an`).
76    pub author: String,
77    /// Author date, strict ISO-8601 (`%aI`), e.g. `2026-05-31T10:00:00+00:00`.
78    pub date: String,
79    /// Subject line (`%s`).
80    pub subject: String,
81}
82
83/// A local branch from `git branch`.
84#[derive(Debug, Clone, PartialEq, Eq)]
85#[non_exhaustive]
86pub struct Branch {
87    /// Branch name.
88    pub name: String,
89    /// Whether this is the checked-out branch (the `*` marker).
90    pub current: bool,
91}
92
93/// One entry from `git stash list`, parsed via
94/// `--format=%gd%x1f%H%x1f%gs -z`: the stash's position, the stashed commit's
95/// hash, and its label split into an optional branch and the rest of the
96/// message.
97#[derive(Debug, Clone, PartialEq, Eq)]
98#[non_exhaustive]
99pub struct StashEntry {
100    /// The stash's position in the list (`stash@{<index>}`'s `<index>`), most
101    /// recent first (`0`) — the numeral [`crate::GitApi::stash_apply`] /
102    /// [`crate::GitApi::stash_drop`] take.
103    pub index: usize,
104    /// The stashed commit's full object id (`%H`).
105    pub hash: String,
106    /// The branch checked out when the stash was pushed, from git's default
107    /// `"WIP on <branch>: …"` / `stash push -m`'s `"On <branch>: …"` label;
108    /// `None` when git recorded no branch (a detached HEAD, `"(no branch)"`).
109    pub branch: Option<String>,
110    /// The rest of the label: the default `<abbrev-sha> <subject>` when
111    /// `stash push` was given no `-m`, or the caller's message verbatim when
112    /// it was.
113    pub message: String,
114}
115
116/// A worktree from `git worktree list --porcelain`.
117#[derive(Debug, Clone, PartialEq, Eq)]
118#[non_exhaustive]
119pub struct Worktree {
120    /// Absolute path to the worktree. A [`PathBuf`] built from the raw
121    /// `worktree list --porcelain` bytes (via [`vcs_diff::path_from_bytes`]), so a
122    /// worktree whose directory name is not valid UTF-8 (legal on Unix) is carried
123    /// losslessly instead of being flattened to `U+FFFD` — the same platform-correct
124    /// type `StatusEntry::path` uses, and what the facade's `WorktreeInfo.path`
125    /// forwards.
126    pub path: PathBuf,
127    /// Short branch name (`refs/heads/` stripped); `None` when detached or bare.
128    pub branch: Option<String>,
129    /// The checked-out commit (`HEAD <sha>`); `None` for a bare entry.
130    pub head: Option<String>,
131    /// The main worktree of a bare repository.
132    pub bare: bool,
133    /// Checked out at a detached HEAD (no branch).
134    pub detached: bool,
135    /// Locked against pruning.
136    pub locked: bool,
137}
138
139/// Parse `git status --porcelain=v1 -z` output: NUL-delimited records, raw
140/// (unquoted) paths. A rename/copy entry is followed by its source path as the
141/// next NUL record (e.g. `R  new\0old\0`).
142///
143/// Consumes **raw bytes** (not a lossily-decoded `&str`): the path is part of the
144/// payload and, on Unix, need not be valid UTF-8 — decoding through
145/// `String::from_utf8_lossy` first would corrupt it to `U+FFFD` and break the
146/// round-trip back into `add`/`commit_paths`. The two-byte status code is ASCII;
147/// only the path bytes are carried losslessly (via [`vcs_diff::path_from_bytes`]).
148pub(crate) fn parse_porcelain(output: &[u8]) -> Vec<StatusEntry> {
149    let mut entries = Vec::new();
150    let mut records = output.split(|&b| b == 0).filter(|rec| !rec.is_empty());
151    while let Some(rec) = records.next() {
152        // "XY path": two status-code bytes, then a space at index 2, then the raw
153        // path bytes. Require the separating space (git's porcelain always emits
154        // it) so a malformed/short record — e.g. one whose leading bytes are a
155        // multibyte char, where index 2 is not the space — is skipped, not turned
156        // into a garbage entry.
157        let (Some(code), Some(&b' ')) = (rec.get(..2), rec.get(2)) else {
158            continue;
159        };
160        let path = &rec[3..];
161        // A rename/copy carries its source path as the immediately following NUL
162        // record; consume it. The `R`/`C` can sit in EITHER status column — the index
163        // column (`R ` staged rename) or the worktree column (` R` worktree rename) —
164        // so check both. Missing the ` R`/` C` case left the source record as a
165        // phantom entry with a garbage `code`/`path` (M11).
166        let old_path = if matches!(code, [b'R' | b'C', _] | [_, b'R' | b'C']) {
167            records.next().map(vcs_diff::path_from_bytes)
168        } else {
169            None
170        };
171        entries.push(StatusEntry {
172            // The status code is always 2 ASCII bytes, so this decode is exact.
173            code: String::from_utf8_lossy(code).into_owned(),
174            path: vcs_diff::path_from_bytes(path),
175            old_path,
176        });
177    }
178    entries
179}
180
181/// Parse `git status --porcelain=v2 --branch -z` output into a [`BranchStatus`].
182///
183/// Records are NUL-terminated: `# branch.*` header lines first, then entry lines
184/// (`1`/`2` changed, `u` unmerged, `?` untracked, `!` ignored). A `2` (rename/copy)
185/// entry stores its original path as the *next* NUL record, so that record is
186/// consumed and skipped. Everything is `strip_prefix`/compare based — no byte
187/// indexing — so arbitrary bytes never panic (proven by proptest).
188#[doc(hidden)]
189pub fn parse_porcelain_v2(output: &str) -> BranchStatus {
190    let mut status = BranchStatus::default();
191    let mut records = output.split('\0');
192    while let Some(rec) = records.next() {
193        if let Some(rest) = rec.strip_prefix("# branch.oid ") {
194            // `(initial)` marks an unborn repo (no commits yet).
195            status.head = (rest != "(initial)").then(|| rest.to_string());
196        } else if let Some(rest) = rec.strip_prefix("# branch.head ") {
197            status.branch = (rest != "(detached)").then(|| rest.to_string());
198        } else if let Some(rest) = rec.strip_prefix("# branch.upstream ") {
199            status.upstream = Some(rest.to_string());
200        } else if let Some(rest) = rec.strip_prefix("# branch.ab ") {
201            // `+<ahead> -<behind>`.
202            let mut parts = rest.split(' ');
203            status.ahead = parts
204                .next()
205                .and_then(|t| t.strip_prefix('+'))
206                .and_then(|n| n.parse().ok());
207            status.behind = parts
208                .next()
209                .and_then(|t| t.strip_prefix('-'))
210                .and_then(|n| n.parse().ok());
211        } else if rec.starts_with("1 ") {
212            status.tracked_changes += 1;
213        } else if rec.starts_with("2 ") {
214            status.tracked_changes += 1;
215            // The rename/copy original path is the next NUL record; consume it so
216            // it isn't mis-read as another entry.
217            records.next();
218        } else if rec.starts_with("u ") {
219            status.tracked_changes += 1;
220            status.conflicts += 1;
221        } else if rec.starts_with("? ") {
222            status.untracked += 1;
223        }
224        // `! ` (ignored) and other `# ` headers contribute nothing.
225    }
226    status
227}
228
229/// Parse `git --version` output (`git version 2.54.0.windows.1`) into the shared
230/// [`vcs_diff::Version`]: the first dotted-numeric token wins; non-numeric
231/// trailers (`.windows.1`, `-rc1`) are ignored; a missing patch reads as `0`.
232pub(crate) fn parse_git_version(raw: &str) -> Option<vcs_diff::Version> {
233    vcs_diff::parse_dotted_version(raw)
234}
235
236/// Parse one successful `git bisect` classification output.
237///
238/// Git prints the next checkout as `[<oid>] <subject>`, and prints the terminal
239/// result as `<oid> is the first 'bad' commit` (older versions omit the quotes
240/// around `bad`). The object id may be a SHA-1/SHA-256 id or Git's abbreviated
241/// hexadecimal spelling, but it must be at least four and at most 64 hex bytes.
242/// Other output — including Git's list of several possible commits after an
243/// unhelpful skip — is intentionally not accepted as a result. This avoids
244/// turning an ambiguous search into a false first-bad success.
245pub(crate) fn parse_bisect_step(output: &str) -> Result<BisectStep> {
246    let mut result = None;
247
248    for raw_line in output.lines() {
249        let line = raw_line.trim();
250        let candidate = line
251            .strip_suffix(" is the first 'bad' commit")
252            .or_else(|| line.strip_suffix(" is the first bad commit"));
253
254        if let Some(oid) = candidate {
255            let revision = parse_bisect_oid(oid)?;
256            set_bisect_result(&mut result, BisectStep::FirstBad { revision })?;
257            continue;
258        }
259
260        if let Some(rest) = line.strip_prefix('[') {
261            let Some((oid, subject)) = rest.split_once("] ") else {
262                return Err(bisect_parse_error(format!(
263                    "malformed next-candidate line: {line:?}"
264                )));
265            };
266            if subject.trim().is_empty() {
267                return Err(bisect_parse_error(format!(
268                    "next-candidate line has no subject: {line:?}"
269                )));
270            }
271            let revision = parse_bisect_oid(oid)?;
272            set_bisect_result(&mut result, BisectStep::NextCandidate { revision })?;
273        }
274    }
275
276    result.ok_or_else(|| bisect_parse_error(format!("unrecognised bisect output: {output:?}")))
277}
278
279fn parse_bisect_oid(raw: &str) -> Result<RevSpec> {
280    let oid = raw.trim();
281    let valid = (4..=64).contains(&oid.len()) && oid.bytes().all(|byte| byte.is_ascii_hexdigit());
282    if !valid {
283        return Err(bisect_parse_error(format!(
284            "invalid bisect object id: {raw:?}"
285        )));
286    }
287    RevSpec::new(oid)
288}
289
290fn set_bisect_result(result: &mut Option<BisectStep>, next: BisectStep) -> Result<()> {
291    if result.is_some() {
292        return Err(bisect_parse_error(
293            "bisect output contains more than one possible result".to_string(),
294        ));
295    }
296    *result = Some(next);
297    Ok(())
298}
299
300fn bisect_parse_error(message: String) -> Error {
301    Error::parse(BINARY, message)
302}
303
304/// Parse a NUL-delimited path list (e.g. `git diff --name-only -z`): one
305/// repo-relative path per record, `/` separators, no quoting.
306///
307/// Consumes **raw bytes** and yields [`PathBuf`]s (via
308/// [`vcs_diff::path_from_bytes`]) so a non-UTF-8 conflicted/diff path survives
309/// losslessly rather than being flattened to `U+FFFD` by a `&str` decode.
310pub(crate) fn parse_nul_paths(output: &[u8]) -> Vec<PathBuf> {
311    output
312        .split(|&b| b == 0)
313        .filter(|path| !path.is_empty())
314        .map(vcs_diff::path_from_bytes)
315        .collect()
316}
317
318/// Parse `git log -z --format=%H%x1f%h%x1f%an%x1f%aI%x1f%s` output: commits are
319/// NUL-separated (robust to multi-line fields), fields split on the ASCII unit
320/// separator.
321pub(crate) fn parse_log(output: &str) -> Vec<Commit> {
322    output
323        .split('\0')
324        .filter(|rec| !rec.is_empty())
325        .filter_map(|rec| {
326            let mut fields = rec.split('\u{1f}');
327            Some(Commit {
328                hash: fields.next()?.to_string(),
329                short_hash: fields.next()?.to_string(),
330                author: fields.next()?.to_string(),
331                date: fields.next()?.to_string(),
332                subject: fields.next().unwrap_or("").to_string(),
333            })
334        })
335        .collect()
336}
337
338/// Parse `git stash list -z --format=%gd%x1f%H%x1f%gs` output into
339/// [`StashEntry`] records: NUL-separated entries (robust to a multi-line
340/// message), `\x1f`-separated fields — the same framing [`parse_log`] uses. A
341/// record whose selector isn't the expected `stash@{<n>}` shape (unexpected
342/// git output) is skipped rather than turned into a garbage entry.
343pub(crate) fn parse_stash_list(output: &str) -> Vec<StashEntry> {
344    output
345        .split('\0')
346        .filter(|rec| !rec.is_empty())
347        .filter_map(|rec| {
348            let mut fields = rec.split('\u{1f}');
349            let selector = fields.next()?;
350            let hash = fields.next()?.to_string();
351            let subject = fields.next().unwrap_or("");
352            let index: usize = selector
353                .strip_prefix("stash@{")?
354                .strip_suffix('}')?
355                .parse()
356                .ok()?;
357            let (branch, message) = parse_stash_subject(subject);
358            Some(StashEntry {
359                index,
360                hash,
361                branch,
362                message,
363            })
364        })
365        .collect()
366}
367
368/// Split a `git stash` reflog subject (`%gs`) into the branch it names and the
369/// rest of the message. git's default label is `WIP on <branch>: <subject>`;
370/// an explicit `stash push -m <msg>` instead records `On <branch>: <msg>`. A
371/// detached-HEAD stash names the placeholder `(no branch)`, reported here as
372/// `None` rather than that literal string. A subject matching neither shape
373/// (an unrecognized or hand-crafted reflog entry) is returned whole as the
374/// message, with no branch.
375fn parse_stash_subject(subject: &str) -> (Option<String>, String) {
376    let Some(rest) = subject
377        .strip_prefix("WIP on ")
378        .or_else(|| subject.strip_prefix("On "))
379    else {
380        return (None, subject.to_string());
381    };
382    match rest.split_once(": ") {
383        Some((branch, message)) => {
384            let branch = (branch != "(no branch)").then(|| branch.to_string());
385            (branch, message.to_string())
386        }
387        None => (None, rest.to_string()),
388    }
389}
390
391/// Parse `git branch` output. The first column is the `* `/`  `/`+ ` marker.
392pub(crate) fn parse_branches(output: &str) -> Vec<Branch> {
393    output
394        .lines()
395        .filter(|line| !line.trim().is_empty())
396        .filter_map(|line| {
397            let current = line.starts_with('*');
398            let name = line.get(1..).unwrap_or("").trim();
399            // Skip the detached-HEAD pseudo-entry, e.g. "* (HEAD detached at …)".
400            if name.is_empty() || name.starts_with('(') {
401                return None;
402            }
403            Some(Branch {
404                name: name.to_string(),
405                current,
406            })
407        })
408        .collect()
409}
410
411/// Parse `git worktree list --porcelain`: records separated by a blank line,
412/// each a set of `label [value]` lines — `worktree <path>`, `HEAD <sha>`,
413/// `branch refs/heads/<name>`, plus the valueless attributes `bare` / `detached`
414/// / `locked`. Unknown labels (e.g. `prunable`) are ignored.
415///
416/// Consumes **raw bytes** (not a lossily-decoded `&str`): the `worktree <path>`
417/// value is a filesystem path that, on Unix, need not be valid UTF-8, so its bytes
418/// are carried losslessly (via [`vcs_diff::path_from_bytes`]) — a `String` decode
419/// would substitute `U+FFFD` and make `Worktree.path` name a *different* directory,
420/// the same defect the status/diff surface already avoids. The labels and the
421/// text-typed values (`HEAD` sha, `branch` ref) are ASCII, so they still decode as
422/// `String`. A trailing `\r` is stripped from every line so CRLF-framed output
423/// is equivalent to LF-framed output.
424///
425/// This parses the **newline-framed** porcelain (no `-z`): git only grew
426/// `worktree list --porcelain -z` in 2.36, above this crate's git-support floor
427/// (2.31), and requesting `-z` there would hard-fail the listing. Newline framing
428/// already covers the non-UTF-8 case this task targets — a path byte is never `\n`
429/// — so only a worktree path containing a *literal newline* stays out of scope,
430/// exactly as before this change.
431pub(crate) fn parse_worktree_porcelain(output: &[u8]) -> Vec<Worktree> {
432    let mut worktrees = Vec::new();
433    let mut current: Option<Worktree> = None;
434    let flush = |current: &mut Option<Worktree>, out: &mut Vec<Worktree>| {
435        if let Some(wt) = current.take() {
436            out.push(wt);
437        }
438    };
439    for line in output.split(|&b| b == b'\n') {
440        // Trim a trailing CR so CRLF-framed output (Windows) parses identically.
441        let line = line.strip_suffix(b"\r").unwrap_or(line);
442        if line.is_empty() {
443            flush(&mut current, &mut worktrees);
444            continue;
445        }
446        // `label value`, split on the FIRST ASCII space (the path itself may hold
447        // spaces); a valueless attribute (`bare`/`detached`/`locked`) has none.
448        let (label, value) = match line.iter().position(|&b| b == b' ') {
449            Some(i) => (&line[..i], Some(&line[i + 1..])),
450            None => (line, None),
451        };
452        match label {
453            // A new record begins; flush any record not closed by a blank line.
454            b"worktree" => {
455                flush(&mut current, &mut worktrees);
456                current = Some(Worktree {
457                    // Raw path bytes → `PathBuf`, lossless on Unix.
458                    path: value.map(vcs_diff::path_from_bytes).unwrap_or_default(),
459                    branch: None,
460                    head: None,
461                    bare: false,
462                    detached: false,
463                    locked: false,
464                });
465            }
466            b"HEAD" => {
467                if let Some(wt) = current.as_mut() {
468                    wt.head = value.map(|v| String::from_utf8_lossy(v).into_owned());
469                }
470            }
471            b"branch" => {
472                if let Some(wt) = current.as_mut() {
473                    // Value is a full ref (`refs/heads/main`); expose the short name.
474                    wt.branch = value.map(|v| {
475                        let full = String::from_utf8_lossy(v);
476                        full.strip_prefix("refs/heads/")
477                            .unwrap_or(&full)
478                            .to_string()
479                    });
480                }
481            }
482            b"bare" => {
483                if let Some(wt) = current.as_mut() {
484                    wt.bare = true;
485                }
486            }
487            b"detached" => {
488                if let Some(wt) = current.as_mut() {
489                    wt.detached = true;
490                }
491            }
492            b"locked" => {
493                if let Some(wt) = current.as_mut() {
494                    wt.locked = true;
495                }
496            }
497            _ => {}
498        }
499    }
500    flush(&mut current, &mut worktrees);
501    worktrees
502}
503
504/// One path `git clean` would remove (`-n`, dry run) or removed (`-f`,
505/// forced), from a `Would remove <path>` / `Removing <path>` output line.
506#[derive(Debug, Clone, PartialEq, Eq)]
507#[non_exhaustive]
508pub struct CleanEntry {
509    /// The path, decoded from git's C-quoting (unquoted the same way this
510    /// crate unquotes any other git porcelain path) and stripped of the
511    /// directory-entry trailing `/` when [`is_dir`](Self::is_dir) is set.
512    pub path: PathBuf,
513    /// Whether this entry names a whole untracked **directory** (`-d`), from
514    /// git's trailing `/` on directory entries, rather than a single file.
515    pub is_dir: bool,
516}
517
518/// Parse `git clean -n`/`-f` output: one line per candidate/removed path,
519/// `Would remove <path>` (dry run, `-n`) or `Removing <path>` (forced, `-f`,
520/// unless `-q`). Any other line — e.g. `Skipping repository <path>` for a
521/// nested untracked `.git`, or a `warning:`/`fatal:` line — names neither a
522/// delete candidate nor a deleted path, so it is ignored rather than
523/// mis-parsed as one.
524///
525/// `git clean` has no `-z`/NUL machine framing, so this parses newline-framed
526/// text (`str::lines` also strips a CRLF `\r`, so Windows output parses
527/// identically); a path needing escaping is C-quoted like any other git
528/// porcelain path — decoded by [`vcs_diff::unquote_c_style_path`].
529pub(crate) fn parse_clean_output(output: &str) -> Vec<CleanEntry> {
530    output
531        .lines()
532        .filter_map(|line| {
533            let rest = line
534                .strip_prefix("Would remove ")
535                .or_else(|| line.strip_prefix("Removing "))?;
536            let mut decoded = vcs_diff::unquote_c_style_path(rest);
537            let is_dir = decoded.last() == Some(&b'/');
538            if is_dir {
539                decoded.pop();
540            }
541            Some(CleanEntry {
542                path: vcs_diff::path_from_bytes(&decoded),
543                is_dir,
544            })
545        })
546        .collect()
547}
548
549/// One line of `git blame --line-porcelain` output: who last touched the line
550/// and where it came from.
551#[derive(Debug, Clone, PartialEq, Eq)]
552#[non_exhaustive]
553pub struct BlameLine {
554    /// Full hash of the commit that last changed the line.
555    pub commit: String,
556    /// Line number in that commit's version of the file (1-based).
557    pub orig_line: u32,
558    /// Line number in the blamed version of the file (1-based).
559    pub final_line: u32,
560    /// Author name of that commit.
561    pub author: String,
562    /// Author timestamp as a unix epoch (seconds).
563    pub author_time: i64,
564    /// Author timezone offset, e.g. `+0200`.
565    pub author_tz: String,
566    /// The line's content (without the trailing newline).
567    pub content: String,
568}
569
570/// Parse `git blame --line-porcelain` output. Every line gets a header
571/// (`<sha> <orig> <final> [<group count>]`, where `<sha>` is a 40-hex SHA-1 or a
572/// 64-hex SHA-256 object id), a full set of `tag value` metadata lines (`author`,
573/// `author-time`, …, optional `boundary`), then the content prefixed with a literal
574/// TAB.
575pub(crate) fn parse_blame_porcelain(output: &str) -> Vec<BlameLine> {
576    let mut lines = Vec::new();
577    let mut current: Option<BlameLine> = None;
578    for line in output.lines() {
579        // Content line: closes the current record.
580        if let Some(content) = line.strip_prefix('\t') {
581            if let Some(mut entry) = current.take() {
582                entry.content = content.to_string();
583                lines.push(entry);
584            }
585            continue;
586        }
587        let (label, value) = match line.split_once(' ') {
588            Some((l, v)) => (l, v),
589            None => (line, ""),
590        };
591        // Header: a commit sha followed by line numbers (and an optional group
592        // count, which only appears on a group's first line). Accept both SHA-1
593        // (40 hex) and SHA-256 (64 hex) object ids — a SHA-256 repo would otherwise
594        // never match, so `blame` would silently return an empty `Vec`.
595        if (label.len() == 40 || label.len() == 64) && label.bytes().all(|b| b.is_ascii_hexdigit())
596        {
597            let mut nums = value.split(' ');
598            let orig = nums.next().and_then(|n| n.parse().ok()).unwrap_or(0);
599            let fin = nums.next().and_then(|n| n.parse().ok()).unwrap_or(0);
600            current = Some(BlameLine {
601                commit: label.to_string(),
602                orig_line: orig,
603                final_line: fin,
604                author: String::new(),
605                author_time: 0,
606                author_tz: String::new(),
607                content: String::new(),
608            });
609            continue;
610        }
611        let Some(entry) = current.as_mut() else {
612            continue;
613        };
614        match label {
615            "author" => entry.author = value.to_string(),
616            "author-time" => entry.author_time = value.parse().unwrap_or(0),
617            "author-tz" => entry.author_tz = value.to_string(),
618            // committer*/summary/filename/previous/boundary intentionally not
619            // captured — `#[non_exhaustive]` leaves room to add them later.
620            _ => {}
621        }
622    }
623    lines
624}
625
626/// Parse `git diff --shortstat`, e.g. ` 3 files changed, 12 insertions(+), 4
627/// deletions(-)`. Any clause may be absent (a pure-insertion diff omits
628/// deletions; no changes yields an empty string → all zeros). Delegates to the
629/// shared [`DiffStat::parse`] (also used by `vcs_jj::parse::parse_diff_stat`),
630/// which both crates' callers force the **C locale** for — see `c_locale` at
631/// the `shortstat`/`diff --stat` call sites.
632pub(crate) fn parse_shortstat(output: &str) -> DiffStat {
633    DiffStat::parse(output)
634}
635
636/// Parse `git ls-remote --heads <remote>` output — `<sha>\trefs/heads/<name>`
637/// per line — into the bare branch names.
638pub(crate) fn parse_ls_remote_heads(output: &str) -> Vec<String> {
639    output
640        .lines()
641        .filter_map(|line| {
642            let (_sha, refname) = line.split_once('\t')?;
643            refname
644                .trim()
645                .strip_prefix("refs/heads/")
646                .map(str::to_string)
647        })
648        .collect()
649}
650
651/// One configured Git remote, as listed by `git remote -v`.
652///
653/// Git emits one row for each fetch and push URL. `parse_remotes` coalesces
654/// those rows to one remote name and prefers its fetch URL.
655#[derive(Debug, Clone, PartialEq, Eq)]
656#[non_exhaustive]
657pub struct Remote {
658    /// Configured remote name (for example, `origin`).
659    pub name: String,
660    /// The remote's fetch URL.
661    pub url: String,
662}
663
664/// Parse `git remote -v` output into one row per configured remote.
665///
666/// The normal format is `<name> <url> (fetch)` followed by a matching `(push)`
667/// row. Only the first whitespace separates the name, and the direction is
668/// stripped as an exact trailing suffix, so legal local-path URLs retain interior
669/// spaces. Rows with a name and URL but no recognised direction are tolerated as a
670/// fallback, while a recognised fetch row always replaces an earlier fallback or
671/// push URL. Malformed/blank rows are ignored rather than aborting a whole listing
672/// because a future Git display-format change should remain diagnosable without
673/// making the configured remotes disappear behind a parser error.
674pub(crate) fn parse_remotes(output: &str) -> Vec<Remote> {
675    let mut remotes: Vec<(Remote, bool)> = Vec::new();
676
677    for line in output.lines() {
678        let line = line.trim();
679        let Some((name, rest)) = line.split_once(char::is_whitespace) else {
680            continue;
681        };
682        let rest = rest.trim_start();
683        let (url, is_fetch) = if let Some(url) = rest.strip_suffix(" (fetch)") {
684            (url, true)
685        } else if let Some(url) = rest.strip_suffix(" (push)") {
686            (url, false)
687        } else {
688            (rest, false)
689        };
690        if name.is_empty() || url.is_empty() {
691            continue;
692        }
693
694        if let Some((remote, has_fetch)) =
695            remotes.iter_mut().find(|(remote, _)| remote.name == name)
696        {
697            if is_fetch && !*has_fetch {
698                remote.url = url.to_string();
699                *has_fetch = true;
700            }
701        } else {
702            remotes.push((
703                Remote {
704                    name: name.to_string(),
705                    url: url.to_string(),
706                },
707                is_fetch,
708            ));
709        }
710    }
711
712    remotes.into_iter().map(|(remote, _)| remote).collect()
713}
714
715/// One submodule declared in the superproject's `.gitmodules`, parsed from the
716/// machine-unambiguous `git config --file .gitmodules --list -z` source rather
717/// than a hand-rolled text scan of the ini-style file.
718#[derive(Debug, Clone, PartialEq, Eq)]
719#[non_exhaustive]
720pub struct Submodule {
721    /// The subsection name — the quoted key in `[submodule "<name>"]`. Usually
722    /// equal to [`path`](Self::path), but git allows the two to differ (a
723    /// renamed submodule keeps its original section name), so it is captured
724    /// separately.
725    pub name: String,
726    /// `submodule.<name>.path` — the repo-relative mount point of the submodule.
727    /// A [`PathBuf`] built from the raw config bytes (via
728    /// [`vcs_diff::path_from_bytes`]), so a path that is not valid UTF-8 (legal
729    /// on Unix) is carried losslessly, matching [`StatusEntry::path`].
730    pub path: PathBuf,
731    /// `submodule.<name>.url` — the upstream the submodule is fetched from.
732    /// Empty when the entry declares no `url` (a malformed `.gitmodules`).
733    pub url: String,
734    /// `submodule.<name>.branch`, the tracked branch for
735    /// `git submodule update --remote`; `None` when unset.
736    pub branch: Option<String>,
737}
738
739/// The sync state of a submodule, from the one-character prefix in the
740/// `git submodule status` output.
741#[derive(Debug, Clone, Copy, PartialEq, Eq)]
742#[non_exhaustive]
743pub enum SubmoduleState {
744    /// Initialized, and the checked-out commit matches the commit the
745    /// superproject records for it — no prefix (a leading space) in
746    /// `git submodule status`.
747    Current,
748    /// Not initialized (`-` prefix): the working tree is absent, so
749    /// `git submodule update --init` is needed before the submodule can be used.
750    Uninitialized,
751    /// The currently checked-out submodule commit does **not** match the commit
752    /// recorded in the superproject's index (`+` prefix) — the working submodule
753    /// is out of sync with the recorded gitlink.
754    RevisionMismatch,
755    /// The submodule has unresolved merge conflicts (`U` prefix).
756    Conflict,
757}
758
759/// One entry from `git submodule status`: the checked-out commit, the mount
760/// path, and the sync [`state`](Self::state) derived from the line's leading
761/// prefix character.
762#[derive(Debug, Clone, PartialEq, Eq)]
763#[non_exhaustive]
764pub struct SubmoduleStatus {
765    /// The repo-relative mount path of the submodule. A [`PathBuf`] built from
766    /// the raw bytes (via [`vcs_diff::path_from_bytes`]), lossless on Unix.
767    pub path: PathBuf,
768    /// The submodule commit `git submodule status` reports: the checked-out
769    /// commit when initialized, or the commit the superproject records (the
770    /// gitlink) when uninitialized. Full object id (40-hex SHA-1 or 64-hex
771    /// SHA-256).
772    pub sha: String,
773    /// The sync state from the line's prefix character.
774    pub state: SubmoduleState,
775    /// The trailing `git describe` of the submodule HEAD (the `(…)` suffix) —
776    /// e.g. `heads/main`, a tag, or an abbreviated sha; `None` for an
777    /// uninitialized submodule, which has no such suffix.
778    pub describe: Option<String>,
779}
780
781/// Parse `git config --file .gitmodules --list -z` output into the declared
782/// submodules, preserving `.gitmodules` file order.
783///
784/// The `-z` framing makes each record `key\nvalue`, records separated by NUL —
785/// robust against a value containing `=` (which the non-`-z` `key=value` form
786/// would mis-split) or whitespace. Only `submodule.<name>.<attr>` keys are
787/// consumed; `<name>` is everything between `submodule.` and the final `.`
788/// (`rsplit_once`), so a subsection name that itself contains dots or slashes
789/// (e.g. `libs/sub`) is recovered intact while the trailing `<attr>`
790/// (`path`/`url`/`branch`, lowercased by git) is read off the end.
791pub(crate) fn parse_gitmodules_config(output: &[u8]) -> Vec<Submodule> {
792    let mut subs: Vec<Submodule> = Vec::new();
793    for record in output.split(|&b| b == 0).filter(|r| !r.is_empty()) {
794        // `key\nvalue`: split on the FIRST newline (the value may itself contain
795        // newlines under `-z`, though path/url/branch never do). A record with no
796        // newline is a bare valueless key → empty value.
797        let (key_bytes, value_bytes) = match record.iter().position(|&b| b == b'\n') {
798            Some(i) => (&record[..i], &record[i + 1..]),
799            None => (record, &b""[..]),
800        };
801        // Keys are ASCII config identifiers; a lossy decode is exact for them.
802        let key = String::from_utf8_lossy(key_bytes);
803        let Some(rest) = key.strip_prefix("submodule.") else {
804            continue;
805        };
806        // `<name>.<attr>` — the attr is the final dot-component; the name is
807        // everything before it (and may contain dots/slashes itself).
808        let Some((name, attr)) = rest.rsplit_once('.') else {
809            continue;
810        };
811        // Find-or-insert by name, preserving first-seen (file) order.
812        let sub = match subs.iter_mut().find(|s| s.name == name) {
813            Some(existing) => existing,
814            None => {
815                subs.push(Submodule {
816                    name: name.to_string(),
817                    path: PathBuf::new(),
818                    url: String::new(),
819                    branch: None,
820                });
821                subs.last_mut().expect("just pushed")
822            }
823        };
824        match attr {
825            "path" => sub.path = vcs_diff::path_from_bytes(value_bytes),
826            "url" => sub.url = String::from_utf8_lossy(value_bytes).into_owned(),
827            "branch" => sub.branch = Some(String::from_utf8_lossy(value_bytes).into_owned()),
828            // Other keys (update/ignore/shallow/…) intentionally not captured;
829            // `#[non_exhaustive]` leaves room to add them later.
830            _ => {}
831        }
832    }
833    subs
834}
835
836/// Parse `git submodule status` output into typed entries.
837///
838/// Each line is `<prefix><sha> <path>[ (<describe>)]`, where `<prefix>` is a
839/// single status character (a space, `-`, `+`, or `U`; see [`SubmoduleState`]).
840/// `git submodule status` has no `-z`/NUL framing, so the path is separated
841/// from the optional trailing ` (<describe>)` heuristically: when the line ends
842/// in `)`, the last ` (` opens the describe suffix and everything before it is
843/// the path; otherwise the whole remainder after the sha is the path. A line
844/// whose leading byte is not one of the four known status characters is skipped
845/// as unrecognized rather than mis-parsed.
846pub(crate) fn parse_submodule_status(output: &[u8]) -> Vec<SubmoduleStatus> {
847    let mut entries = Vec::new();
848    for line in output.split(|&b| b == b'\n') {
849        // Trim a trailing CR so CRLF-framed output (Windows) parses identically.
850        let line = line.strip_suffix(b"\r").unwrap_or(line);
851        if line.is_empty() {
852            continue;
853        }
854        let state = match line[0] {
855            b' ' => SubmoduleState::Current,
856            b'-' => SubmoduleState::Uninitialized,
857            b'+' => SubmoduleState::RevisionMismatch,
858            b'U' => SubmoduleState::Conflict,
859            // No recognized prefix — skip rather than fold the first byte into
860            // the sha and emit a corrupt entry.
861            _ => continue,
862        };
863        let rest = &line[1..];
864        // `<sha> <path>…`: the sha runs up to the first space.
865        let Some(sp) = rest.iter().position(|&b| b == b' ') else {
866            continue;
867        };
868        let sha = String::from_utf8_lossy(&rest[..sp]).into_owned();
869        let tail = &rest[sp + 1..];
870        // Split off a trailing ` (<describe>)` suffix, if present.
871        let (path_bytes, describe) = match tail.last() {
872            Some(b')') => match tail
873                .windows(2)
874                .rposition(|w| w == b" (")
875                .filter(|&i| i + 2 < tail.len())
876            {
877                Some(i) => (
878                    &tail[..i],
879                    Some(String::from_utf8_lossy(&tail[i + 2..tail.len() - 1]).into_owned()),
880                ),
881                None => (tail, None),
882            },
883            _ => (tail, None),
884        };
885        entries.push(SubmoduleStatus {
886            path: vcs_diff::path_from_bytes(path_bytes),
887            sha,
888            state,
889            describe,
890        });
891    }
892    entries
893}
894
895#[cfg(test)]
896mod tests {
897    use super::*;
898
899    #[test]
900    fn porcelain_parses_codes_and_paths() {
901        // NUL-delimited records; the path with a space stays raw (no quoting).
902        let got = parse_porcelain(b" M src/lib.rs\0?? new file.txt\0A  added.rs\0");
903        assert_eq!(
904            got,
905            vec![
906                StatusEntry {
907                    code: " M".into(),
908                    path: "src/lib.rs".into(),
909                    old_path: None,
910                },
911                StatusEntry {
912                    code: "??".into(),
913                    path: "new file.txt".into(),
914                    old_path: None,
915                },
916                StatusEntry {
917                    code: "A ".into(),
918                    path: "added.rs".into(),
919                    old_path: None,
920                },
921            ]
922        );
923    }
924
925    // A path whose bytes are not valid UTF-8 (legal on Unix) survives byte-for-byte
926    // through `parse_porcelain` — the load-bearing property for the status→add
927    // round-trip. `0xFF` is never valid UTF-8; the old `from_utf8_lossy` path would
928    // have replaced it with U+FFFD and named a different file.
929    #[cfg(unix)]
930    #[test]
931    fn porcelain_preserves_non_utf8_path_bytes() {
932        use std::os::unix::ffi::OsStrExt;
933        let got = parse_porcelain(b" M caf\xff.txt\0");
934        assert_eq!(got.len(), 1);
935        assert_eq!(got[0].path.as_os_str().as_bytes(), b"caf\xff.txt");
936    }
937
938    #[test]
939    fn porcelain_parses_rename_with_old_path() {
940        // `R  new\0old\0` — the source path is the next NUL record.
941        let got = parse_porcelain(b"R  new.rs\0old.rs\0 M other.rs\0");
942        assert_eq!(
943            got,
944            vec![
945                StatusEntry {
946                    code: "R ".into(),
947                    path: "new.rs".into(),
948                    old_path: Some("old.rs".into()),
949                },
950                StatusEntry {
951                    code: " M".into(),
952                    path: "other.rs".into(),
953                    old_path: None,
954                },
955            ]
956        );
957    }
958
959    // M11: a rename/copy in the WORKTREE column (` R`/` C`, not just the index `R `)
960    // must also consume its source record — otherwise the source became a phantom
961    // entry with a garbage code/path.
962    #[test]
963    fn porcelain_parses_worktree_rename_in_the_y_column() {
964        // ` R new\0old\0` — space in X, R in Y (a worktree rename).
965        let got = parse_porcelain(b" R new.rs\0old.rs\0 M other.rs\0");
966        assert_eq!(
967            got,
968            vec![
969                StatusEntry {
970                    code: " R".into(),
971                    path: "new.rs".into(),
972                    old_path: Some("old.rs".into()),
973                },
974                StatusEntry {
975                    code: " M".into(),
976                    path: "other.rs".into(),
977                    old_path: None,
978                },
979            ],
980            "the source record must be consumed, not left as a phantom entry"
981        );
982    }
983
984    #[test]
985    fn porcelain_ignores_blank_and_short_records() {
986        assert!(parse_porcelain(b"\0  \0X\0").is_empty());
987    }
988
989    // A record whose leading char is multibyte has no space at index 2, so it is
990    // skipped (git's porcelain always emits `XY<space>path`). `𝓁` is 4 bytes, so
991    // the byte at index 2 is a continuation byte, not the separating space.
992    #[test]
993    fn porcelain_skips_non_ascii_status_records() {
994        assert!(parse_porcelain("𝓁abc\0".as_bytes()).is_empty());
995        // A well-formed record alongside the garbage still parses.
996        let entries = parse_porcelain("𝓁abc\0 M a.rs\0".as_bytes());
997        assert_eq!(entries.len(), 1);
998        assert_eq!(entries[0].path, std::path::Path::new("a.rs"));
999    }
1000
1001    #[test]
1002    fn porcelain_v2_parses_branch_and_change_counts() {
1003        // The rename's original path (`1 trap.rs`) is the next NUL record; it must
1004        // be CONSUMED, not counted as a fourth `1 …` change.
1005        let out = concat!(
1006            "# branch.oid abcdef1234567890\0",
1007            "# branch.head main\0",
1008            "# branch.upstream origin/main\0",
1009            "# branch.ab +2 -1\0",
1010            "1 .M N... 100644 100644 100644 1111 2222 a.rs\0",
1011            "2 R. N... 100644 100644 100644 3333 4444 R100 new.rs\0",
1012            "1 trap.rs\0",
1013            "u UU N... 100644 100644 100644 100644 5 6 7 conflict.rs\0",
1014            "? untracked.txt\0",
1015            "! ignored.txt\0",
1016        );
1017        let s = parse_porcelain_v2(out);
1018        assert_eq!(s.head.as_deref(), Some("abcdef1234567890"));
1019        assert_eq!(s.branch.as_deref(), Some("main"));
1020        assert_eq!(s.upstream.as_deref(), Some("origin/main"));
1021        assert_eq!((s.ahead, s.behind), (Some(2), Some(1)));
1022        assert_eq!(
1023            s.tracked_changes, 3,
1024            "1 + 2(rename) + u; the trap is consumed"
1025        );
1026        assert_eq!(s.untracked, 1);
1027        assert_eq!(s.conflicts, 1);
1028        assert!(s.is_dirty());
1029    }
1030
1031    #[test]
1032    fn porcelain_v2_handles_unborn_detached_and_no_upstream() {
1033        // Unborn repo: `(initial)` oid, no ab line, clean tree.
1034        let s = parse_porcelain_v2("# branch.oid (initial)\0# branch.head main\0");
1035        assert_eq!(s.head, None);
1036        assert_eq!(s.branch.as_deref(), Some("main"));
1037        assert_eq!(s.upstream, None);
1038        assert_eq!((s.ahead, s.behind), (None, None));
1039        assert!(!s.is_dirty());
1040
1041        // Detached HEAD, no upstream tracking.
1042        let s = parse_porcelain_v2("# branch.oid deadbeef\0# branch.head (detached)\0");
1043        assert_eq!(s.head.as_deref(), Some("deadbeef"));
1044        assert_eq!(s.branch, None);
1045        assert_eq!(s.upstream, None);
1046    }
1047
1048    // --line-porcelain repeats the full metadata for every line; the group
1049    // count appears only on a group's first header, and `boundary` is a
1050    // valueless tag — both must parse.
1051    #[test]
1052    fn blame_line_porcelain_parses_headers_and_metadata() {
1053        let sha_a = "a".repeat(40);
1054        let sha_b = "b".repeat(40);
1055        let out = format!(
1056            "{sha_a} 1 1 2\nauthor Alice\nauthor-mail <a@x>\nauthor-time 1717500000\n\
1057             author-tz +0200\ncommitter Alice\nsummary first\nboundary\nfilename f.txt\n\
1058             \tline one\n\
1059             {sha_a} 2 2\nauthor Alice\nauthor-mail <a@x>\nauthor-time 1717500000\n\
1060             author-tz +0200\ncommitter Alice\nsummary first\nfilename f.txt\n\
1061             \tline two\n\
1062             {sha_b} 1 3 1\nauthor Bob\nauthor-mail <b@x>\nauthor-time 1717600000\n\
1063             author-tz -0500\ncommitter Bob\nsummary second\nfilename f.txt\n\
1064             \t\n"
1065        );
1066        let lines = parse_blame_porcelain(&out);
1067        assert_eq!(lines.len(), 3);
1068        assert_eq!(lines[0].commit, sha_a);
1069        assert_eq!(lines[0].orig_line, 1);
1070        assert_eq!(lines[0].final_line, 1);
1071        assert_eq!(lines[0].author, "Alice");
1072        assert_eq!(lines[0].author_time, 1717500000);
1073        assert_eq!(lines[0].author_tz, "+0200");
1074        assert_eq!(lines[0].content, "line one");
1075        // Second line of the same group: header without a group count.
1076        assert_eq!(lines[1].final_line, 2);
1077        assert_eq!(lines[1].content, "line two");
1078        // A different commit, and an empty content line stays empty.
1079        assert_eq!(lines[2].commit, sha_b);
1080        assert_eq!(lines[2].author, "Bob");
1081        assert_eq!(lines[2].content, "");
1082    }
1083
1084    #[test]
1085    fn blame_ignores_garbage_and_empty_input() {
1086        assert!(parse_blame_porcelain("").is_empty());
1087        assert!(parse_blame_porcelain("not a header\n\torphan content\n").is_empty());
1088    }
1089
1090    // A SHA-256 repository emits 64-hex commit ids; the header must still be
1091    // recognised (the old `len()==40`-only check made `blame` return an empty Vec).
1092    #[test]
1093    fn blame_recognises_sha256_object_ids() {
1094        let sha = "c".repeat(64);
1095        let out = format!(
1096            "{sha} 1 1 1\nauthor Carol\nauthor-mail <c@x>\nauthor-time 1717700000\n\
1097             author-tz +0000\ncommitter Carol\nsummary s\nfilename f.txt\n\
1098             \tline\n"
1099        );
1100        let lines = parse_blame_porcelain(&out);
1101        assert_eq!(
1102            lines.len(),
1103            1,
1104            "a SHA-256 blame must parse, not drop to empty"
1105        );
1106        assert_eq!(lines[0].commit, sha);
1107        assert_eq!(lines[0].author, "Carol");
1108        assert_eq!(lines[0].content, "line");
1109    }
1110
1111    #[test]
1112    fn git_version_parses_real_world_shapes() {
1113        // The Windows build trailer (`.windows.1`) is extra dotted components
1114        // beyond the patch; an `-rc1` suffix rides on the patch itself.
1115        let v = parse_git_version("git version 2.54.0.windows.1").unwrap();
1116        assert_eq!((v.major, v.minor, v.patch), (2, 54, 0));
1117        let v = parse_git_version("git version 2.41.0-rc1").unwrap();
1118        assert_eq!((v.major, v.minor, v.patch), (2, 41, 0));
1119        let v = parse_git_version("git version 2.54").unwrap();
1120        assert_eq!(v.patch, 0, "missing patch defaults to 0");
1121        assert!(parse_git_version("no digits here").is_none());
1122        assert!(parse_git_version("git version unknowable").is_none());
1123    }
1124
1125    #[test]
1126    fn nul_paths_split_and_keep_special_characters() {
1127        assert_eq!(
1128            parse_nul_paths(b"a.rs\0sub/with space.rs\0"),
1129            [PathBuf::from("a.rs"), PathBuf::from("sub/with space.rs")]
1130        );
1131        assert!(parse_nul_paths(b"").is_empty());
1132    }
1133
1134    #[test]
1135    fn log_splits_unit_separated_fields() {
1136        let input = "abc123\u{1f}abc\u{1f}Ada\u{1f}2026-05-31T10:00:00+00:00\u{1f}Add feature\0\
1137                     def456\u{1f}def\u{1f}Linus\u{1f}2026-05-30T09:00:00+00:00\u{1f}Fix bug\0";
1138        let got = parse_log(input);
1139        assert_eq!(got.len(), 2);
1140        assert_eq!(
1141            got[0],
1142            Commit {
1143                hash: "abc123".into(),
1144                short_hash: "abc".into(),
1145                author: "Ada".into(),
1146                date: "2026-05-31T10:00:00+00:00".into(),
1147                subject: "Add feature".into(),
1148            }
1149        );
1150        assert_eq!(got[1].subject, "Fix bug");
1151    }
1152
1153    #[test]
1154    fn log_tolerates_empty_subject() {
1155        let got = parse_log("h\u{1f}h\u{1f}A\u{1f}2026-05-31T10:00:00+00:00\u{1f}\0");
1156        assert_eq!(got[0].subject, "");
1157    }
1158
1159    #[test]
1160    fn branches_marks_current_and_skips_detached() {
1161        let got = parse_branches("* main\n  feature\n  (HEAD detached at abc123)\n");
1162        assert_eq!(
1163            got,
1164            vec![
1165                Branch {
1166                    name: "main".into(),
1167                    current: true
1168                },
1169                Branch {
1170                    name: "feature".into(),
1171                    current: false
1172                },
1173            ]
1174        );
1175    }
1176
1177    #[test]
1178    fn worktrees_parse_branch_detached_and_bare() {
1179        let input = "worktree /repo\nHEAD abc123\nbranch refs/heads/main\n\
1180                     \nworktree /repo/wt\nHEAD def456\ndetached\n\
1181                     \nworktree /repo/bare\nbare\n";
1182        let got = parse_worktree_porcelain(input.as_bytes());
1183        assert_eq!(got.len(), 3);
1184        assert_eq!(got[0].path, PathBuf::from("/repo"));
1185        assert_eq!(got[0].branch.as_deref(), Some("main"));
1186        assert_eq!(got[0].head.as_deref(), Some("abc123"));
1187        assert!(got[1].detached && got[1].branch.is_none());
1188        assert!(got[2].bare && got[2].head.is_none());
1189    }
1190
1191    #[test]
1192    fn worktrees_parse_crlf_without_trailing_carriage_returns() {
1193        let got = parse_worktree_porcelain(
1194            b"worktree /repo/wt\r\nHEAD abc123\r\nbranch refs/heads/main\r\nlocked\r\n\r\n\
1195              worktree /repo/bare\r\nbare\r\n\r\n\
1196              worktree /repo/detached\r\nHEAD def456\r\ndetached\r\n",
1197        );
1198        assert_eq!(got.len(), 3);
1199        assert_eq!(got[0].path, PathBuf::from("/repo/wt"));
1200        assert_eq!(got[0].head.as_deref(), Some("abc123"));
1201        assert_eq!(got[0].branch.as_deref(), Some("main"));
1202        assert!(got[0].locked);
1203        assert!(got[1].bare && got[1].head.is_none());
1204        assert!(got[2].detached && got[2].branch.is_none());
1205        assert_eq!(got[2].head.as_deref(), Some("def456"));
1206    }
1207
1208    // A worktree whose directory name is not valid UTF-8 (legal on Unix) survives
1209    // byte-for-byte through `parse_worktree_porcelain`, so the facade's
1210    // `WorktreeInfo.path` addresses the SAME directory. `0xFF` is never valid UTF-8;
1211    // the old `&str` (`from_utf8_lossy`) parse would have replaced it with U+FFFD.
1212    #[cfg(unix)]
1213    #[test]
1214    fn worktrees_preserve_non_utf8_path_bytes() {
1215        use std::os::unix::ffi::OsStrExt;
1216        let got = parse_worktree_porcelain(b"worktree /repo/wt-caf\xff\nHEAD abc123\n");
1217        assert_eq!(got.len(), 1);
1218        assert_eq!(got[0].path.as_os_str().as_bytes(), b"/repo/wt-caf\xff");
1219        assert_eq!(got[0].head.as_deref(), Some("abc123"));
1220    }
1221
1222    #[test]
1223    fn worktrees_parse_last_record_without_trailing_blank() {
1224        // The final record may not be followed by a blank line.
1225        let got = parse_worktree_porcelain(b"worktree /only\nHEAD aaa\nbranch refs/heads/x\n");
1226        assert_eq!(got.len(), 1);
1227        assert_eq!(got[0].branch.as_deref(), Some("x"));
1228    }
1229
1230    #[test]
1231    fn shortstat_parses_all_clauses() {
1232        let got = parse_shortstat(" 3 files changed, 12 insertions(+), 4 deletions(-)\n");
1233        assert_eq!(got, DiffStat::new(3, 12, 4));
1234    }
1235
1236    #[test]
1237    fn shortstat_tolerates_missing_clauses_and_empty() {
1238        // Pure-insertion diff omits deletions; no changes yields all zeros.
1239        let only_ins = parse_shortstat(" 1 file changed, 2 insertions(+)\n");
1240        assert_eq!(only_ins.insertions, 2);
1241        assert_eq!(only_ins.deletions, 0);
1242        assert_eq!(parse_shortstat(""), DiffStat::default());
1243    }
1244
1245    #[test]
1246    fn gitmodules_config_parses_z_framed_records() {
1247        // `-z` layout: `key\nvalue\0` per record. Two attributes per submodule,
1248        // in `.gitmodules` order, and a subsection name containing a slash.
1249        let out = b"submodule.libs/sub.path\nlibs/sub\0\
1250                    submodule.libs/sub.url\n../sub\0\
1251                    submodule.libs/sub.branch\nmain\0\
1252                    submodule.second.path\nsecond\0\
1253                    submodule.second.url\n../sub\0";
1254        let got = parse_gitmodules_config(out);
1255        assert_eq!(
1256            got,
1257            vec![
1258                Submodule {
1259                    name: "libs/sub".into(),
1260                    path: "libs/sub".into(),
1261                    url: "../sub".into(),
1262                    branch: Some("main".into()),
1263                },
1264                Submodule {
1265                    name: "second".into(),
1266                    path: "second".into(),
1267                    url: "../sub".into(),
1268                    branch: None,
1269                },
1270            ]
1271        );
1272    }
1273
1274    #[test]
1275    fn gitmodules_config_keeps_value_with_equals_and_ignores_non_submodule_keys() {
1276        // A value containing `=` survives (the non-`-z` `key=value` split would
1277        // corrupt it); a non-`submodule.*` key is ignored.
1278        let out = b"submodule.x.url\nhttps://h/r?a=b\0\
1279                    core.autocrlf\nfalse\0\
1280                    submodule.x.path\nx\0";
1281        let got = parse_gitmodules_config(out);
1282        assert_eq!(got.len(), 1);
1283        assert_eq!(got[0].url, "https://h/r?a=b");
1284        assert_eq!(got[0].path, PathBuf::from("x"));
1285    }
1286
1287    #[test]
1288    fn gitmodules_config_empty_is_no_submodules() {
1289        assert!(parse_gitmodules_config(b"").is_empty());
1290    }
1291
1292    #[test]
1293    fn remotes_empty_output_is_empty() {
1294        assert!(parse_remotes("\n \t\r\n").is_empty());
1295    }
1296
1297    #[test]
1298    fn remotes_one_remote_prefers_fetch_url() {
1299        assert_eq!(
1300            parse_remotes(
1301                "origin\thttps://example.test/fetch.git (fetch)\norigin\thttps://example.test/push.git (push)\n"
1302            ),
1303            vec![Remote {
1304                name: "origin".into(),
1305                url: "https://example.test/fetch.git".into(),
1306            }]
1307        );
1308    }
1309
1310    #[test]
1311    fn remotes_preserve_spaces_and_prefer_the_fetch_url() {
1312        assert_eq!(
1313            parse_remotes(
1314                "origin C:/Users/John Doe/repo (push)\n\
1315                 origin C:/Users/John Doe/fetch repo (fetch)\n"
1316            ),
1317            vec![Remote {
1318                name: "origin".into(),
1319                url: "C:/Users/John Doe/fetch repo".into(),
1320            }]
1321        );
1322    }
1323
1324    #[test]
1325    fn remotes_multiple_rows_dedupe_and_tolerate_malformed_output() {
1326        assert_eq!(
1327            parse_remotes(
1328                "origin ssh://example.test/push.git (push)\n\
1329                 upstream https://example.test/upstream.git (fetch)\r\n\
1330                 malformed-only-name\n\
1331                 origin https://example.test/fetch.git (fetch)\n\
1332                 upstream https://example.test/upstream-push.git (push)\n",
1333            ),
1334            vec![
1335                Remote {
1336                    name: "origin".into(),
1337                    url: "https://example.test/fetch.git".into(),
1338                },
1339                Remote {
1340                    name: "upstream".into(),
1341                    url: "https://example.test/upstream.git".into(),
1342                },
1343            ]
1344        );
1345    }
1346
1347    #[cfg(unix)]
1348    #[test]
1349    fn gitmodules_config_preserves_non_utf8_path_bytes() {
1350        use std::os::unix::ffi::OsStrExt;
1351        let out = b"submodule.s.path\ncaf\xff/sub\0submodule.s.url\n../sub\0";
1352        let got = parse_gitmodules_config(out);
1353        assert_eq!(got.len(), 1);
1354        assert_eq!(got[0].path.as_os_str().as_bytes(), b"caf\xff/sub");
1355    }
1356
1357    #[test]
1358    fn submodule_status_parses_all_prefix_states() {
1359        // One line per state: current (space), revision-mismatch (+), conflict
1360        // (U), uninitialized (-, no describe suffix).
1361        let out = b" 833caa0 libs/sub (heads/main)\n\
1362                    +530fd06 plus/mod (530fd06)\n\
1363                    U000aaaa conf/mod (heads/topic)\n\
1364                    -deadbee minus/mod\n";
1365        let got = parse_submodule_status(out);
1366        assert_eq!(got.len(), 4);
1367
1368        assert_eq!(got[0].state, SubmoduleState::Current);
1369        assert_eq!(got[0].sha, "833caa0");
1370        assert_eq!(got[0].path, PathBuf::from("libs/sub"));
1371        assert_eq!(got[0].describe.as_deref(), Some("heads/main"));
1372
1373        assert_eq!(got[1].state, SubmoduleState::RevisionMismatch);
1374        assert_eq!(got[1].path, PathBuf::from("plus/mod"));
1375        assert_eq!(got[1].describe.as_deref(), Some("530fd06"));
1376
1377        assert_eq!(got[2].state, SubmoduleState::Conflict);
1378        assert_eq!(got[2].path, PathBuf::from("conf/mod"));
1379
1380        assert_eq!(got[3].state, SubmoduleState::Uninitialized);
1381        assert_eq!(got[3].sha, "deadbee");
1382        assert_eq!(got[3].path, PathBuf::from("minus/mod"));
1383        assert_eq!(got[3].describe, None);
1384    }
1385
1386    #[test]
1387    fn submodule_status_handles_spaced_path_and_crlf() {
1388        // A path containing a space is kept whole (the ` (describe)` suffix is
1389        // split off from the END), and a CRLF line terminator parses identically.
1390        let out = b" abc123 dir with space/sub (v1.0)\r\n";
1391        let got = parse_submodule_status(out);
1392        assert_eq!(got.len(), 1);
1393        assert_eq!(got[0].path, PathBuf::from("dir with space/sub"));
1394        assert_eq!(got[0].describe.as_deref(), Some("v1.0"));
1395    }
1396
1397    #[test]
1398    fn submodule_status_without_describe_keeps_full_path() {
1399        // No trailing `(...)`: the whole remainder after the sha is the path.
1400        let out = b" abc123 libs/no-describe\n";
1401        let got = parse_submodule_status(out);
1402        assert_eq!(got.len(), 1);
1403        assert_eq!(got[0].path, PathBuf::from("libs/no-describe"));
1404        assert_eq!(got[0].describe, None);
1405    }
1406
1407    #[test]
1408    fn submodule_status_empty_is_no_entries() {
1409        assert!(parse_submodule_status(b"").is_empty());
1410    }
1411
1412    #[test]
1413    fn stash_list_parses_default_and_custom_labels() {
1414        // Entry 0: `stash push -m "my label"` on `feature`. Entry 1: a plain
1415        // `stash push` (no `-m`), whose default label embeds the abbrev sha +
1416        // subject of the commit stashed on top of.
1417        let out = concat!(
1418            "stash@{0}\u{1f}aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\u{1f}",
1419            "On feature: my label\0",
1420            "stash@{1}\u{1f}bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\u{1f}",
1421            "WIP on feature: f1c02c2 init\0",
1422        );
1423        let got = parse_stash_list(out);
1424        assert_eq!(got.len(), 2);
1425        assert_eq!(got[0].index, 0);
1426        assert_eq!(got[0].hash, "a".repeat(40));
1427        assert_eq!(got[0].branch.as_deref(), Some("feature"));
1428        assert_eq!(got[0].message, "my label");
1429        assert_eq!(got[1].index, 1);
1430        assert_eq!(got[1].branch.as_deref(), Some("feature"));
1431        assert_eq!(got[1].message, "f1c02c2 init");
1432    }
1433
1434    #[test]
1435    fn stash_list_detached_head_has_no_branch() {
1436        let out = "stash@{0}\u{1f}cccccccccccccccccccccccccccccccccccccccc\u{1f}\
1437                    On (no branch): detached label\0";
1438        let got = parse_stash_list(out);
1439        assert_eq!(got.len(), 1);
1440        assert_eq!(got[0].branch, None);
1441        assert_eq!(got[0].message, "detached label");
1442    }
1443
1444    #[test]
1445    fn stash_list_empty_is_no_entries() {
1446        assert!(parse_stash_list("").is_empty());
1447    }
1448
1449    #[test]
1450    fn stash_list_skips_a_record_with_an_unrecognized_selector() {
1451        // A malformed/foreign selector (not `stash@{<n>}`) must be skipped, not
1452        // turned into a garbage entry with index 0.
1453        let out = "not-a-selector\u{1f}deadbeef\u{1f}subject\0";
1454        assert!(parse_stash_list(out).is_empty());
1455    }
1456
1457    #[test]
1458    fn clean_output_parses_dry_run_files_and_directories() {
1459        let out = "Would remove junk.txt\nWould remove sub/\n";
1460        let got = parse_clean_output(out);
1461        assert_eq!(
1462            got,
1463            vec![
1464                CleanEntry {
1465                    path: PathBuf::from("junk.txt"),
1466                    is_dir: false,
1467                },
1468                CleanEntry {
1469                    path: PathBuf::from("sub"),
1470                    is_dir: true,
1471                },
1472            ]
1473        );
1474    }
1475
1476    #[test]
1477    fn clean_output_parses_forced_removals() {
1478        let out = "Removing junk.txt\nRemoving sub/\n";
1479        let got = parse_clean_output(out);
1480        assert_eq!(got.len(), 2);
1481        assert_eq!(got[0].path, PathBuf::from("junk.txt"));
1482        assert!(!got[0].is_dir);
1483        assert_eq!(got[1].path, PathBuf::from("sub"));
1484        assert!(got[1].is_dir);
1485    }
1486
1487    #[test]
1488    fn clean_output_unquotes_c_quoted_paths() {
1489        // `é` under the default `core.quotePath=true` is octal-escaped
1490        // (`\303\251`); the directory's trailing `/` sits INSIDE the quotes.
1491        let out = "Would remove \"caf\\303\\251.txt\"\nWould remove \"w\\303\\251ird dir/\"\n";
1492        let got = parse_clean_output(out);
1493        assert_eq!(got.len(), 2);
1494        assert_eq!(got[0].path, PathBuf::from("café.txt"));
1495        assert!(!got[0].is_dir);
1496        assert_eq!(got[1].path, PathBuf::from("wéird dir"));
1497        assert!(got[1].is_dir);
1498    }
1499
1500    #[test]
1501    fn clean_output_ignores_unrecognized_lines() {
1502        // `Skipping repository …` (a nested untracked `.git`) names neither a
1503        // delete candidate nor a deleted path.
1504        let out = "Skipping repository sub/nested\nWould remove real.txt\n";
1505        let got = parse_clean_output(out);
1506        assert_eq!(got.len(), 1);
1507        assert_eq!(got[0].path, PathBuf::from("real.txt"));
1508    }
1509
1510    #[test]
1511    fn clean_output_empty_is_no_entries() {
1512        assert!(parse_clean_output("").is_empty());
1513    }
1514}
1515
1516// Property-based fuzzing: the parsers are pure functions over *arbitrary* CLI
1517// text (a git on the user's machine we don't control), so the load-bearing
1518// invariant is "never panic, whatever the bytes". These feed both unconstrained
1519// Unicode and structure-biased inputs (real delimiters: NUL, tab, unit
1520// separator, `diff --git`, `@@` hunks, rename braces) so the fuzzer reaches the
1521// byte-offset branches, not just the early returns.
1522#[cfg(test)]
1523mod proptests {
1524    use super::*;
1525    use proptest::prelude::*;
1526
1527    /// A line drawn from git's structural vocabulary plus multibyte text, so a
1528    /// joined document exercises the porcelain/diff/blame branches.
1529    fn structured_line() -> impl Strategy<Value = String> {
1530        prop_oneof![
1531            Just("diff --git a/f b/f\n".to_string()),
1532            Just("--- a/f\n".to_string()),
1533            Just("+++ b/f\n".to_string()),
1534            Just("@@ -1,2 +3,4 @@ ctx\n".to_string()),
1535            Just("@@ -1 +1 @@\n".to_string()),
1536            Just("rename from {old => new}.rs\n".to_string()),
1537            Just("R100\told\tnew\n".to_string()),
1538            Just(format!("{}\n", "a".repeat(40))), // a 40-hex-ish blame header
1539            "[-+ ]?[a-zé\t]{0,12}\n",              // diff body / text incl. multibyte
1540            "[ MARD?]{0,2} [a-zé/]{0,8}\0",        // porcelain-ish NUL record
1541        ]
1542    }
1543
1544    fn structured_doc() -> impl Strategy<Value = String> {
1545        prop::collection::vec(structured_line(), 0..40).prop_map(|lines| lines.concat())
1546    }
1547
1548    proptest! {
1549        // Panic-freedom on completely arbitrary input.
1550        #[test]
1551        fn parsers_never_panic_on_arbitrary_text(s in any::<String>()) {
1552            let _ = parse_porcelain(s.as_bytes());
1553            let _ = parse_porcelain_v2(&s);
1554            let _ = parse_log(&s);
1555            let _ = parse_branches(&s);
1556            let _ = parse_worktree_porcelain(s.as_bytes());
1557            let _ = parse_blame_porcelain(&s);
1558            let _ = parse_shortstat(&s);
1559            let _ = parse_ls_remote_heads(&s);
1560            let _ = parse_remotes(&s);
1561            let _ = parse_nul_paths(s.as_bytes());
1562            let _ = parse_git_version(&s);
1563            let _ = parse_stash_list(&s);
1564            let _ = parse_clean_output(&s);
1565        }
1566
1567        // The byte parsers must also never panic on *arbitrary bytes* — the actual
1568        // shape of a `-z` stream carrying a non-UTF-8 path, which the `String`
1569        // generator above can never produce.
1570        #[test]
1571        fn byte_parsers_never_panic_on_arbitrary_bytes(b in any::<Vec<u8>>()) {
1572            let _ = parse_porcelain(&b);
1573            let _ = parse_nul_paths(&b);
1574            let _ = parse_worktree_porcelain(&b);
1575        }
1576
1577        // …and on structure-biased input that reaches the parsing branches.
1578        #[test]
1579        fn parsers_never_panic_on_structured_text(s in structured_doc()) {
1580            let _ = parse_porcelain(s.as_bytes());
1581            let _ = parse_porcelain_v2(&s);
1582            let _ = parse_log(&s);
1583            let _ = parse_blame_porcelain(&s);
1584            let _ = parse_gitmodules_config(s.as_bytes());
1585            let _ = parse_submodule_status(s.as_bytes());
1586            let _ = parse_stash_list(&s);
1587            let _ = parse_clean_output(&s);
1588        }
1589
1590        // porcelain v2 header/entry lines (with the `2`-consumes-next-record path)
1591        // must never panic on arbitrary NUL-joined records.
1592        #[test]
1593        fn porcelain_v2_never_panics(records in prop::collection::vec(
1594            prop_oneof![
1595                Just("# branch.oid (initial)".to_string()),
1596                Just("# branch.head main".to_string()),
1597                Just("# branch.ab +1 -2".to_string()),
1598                "1 [.MADRCU]{2} [a-zé /]{0,10}".prop_map(|s| s),
1599                "2 R\\. .* R100 [a-zé /]{0,8}".prop_map(|s| s),
1600                "u UU [a-zé /]{0,8}".prop_map(|s| s),
1601                "\\? [a-zé /]{0,8}".prop_map(|s| s),
1602                "[a-zé0-9# ]{0,12}".prop_map(|s| s),
1603            ],
1604            0..20,
1605        ).prop_map(|r| r.join("\0"))) {
1606            let _ = parse_porcelain_v2(&records);
1607        }
1608    }
1609}