use std::path::PathBuf;
use vcs_diff::DiffStat;
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct StatusEntry {
pub code: String,
pub path: String,
pub orig_path: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
#[non_exhaustive]
pub struct BranchStatus {
pub head: Option<String>,
pub branch: Option<String>,
pub upstream: Option<String>,
pub ahead: Option<usize>,
pub behind: Option<usize>,
pub tracked_changes: usize,
pub untracked: usize,
pub conflicts: usize,
}
impl BranchStatus {
pub fn is_dirty(&self) -> bool {
self.tracked_changes > 0 || self.untracked > 0
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct Commit {
pub hash: String,
pub short_hash: String,
pub author: String,
pub date: String,
pub subject: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct Branch {
pub name: String,
pub current: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct Worktree {
pub path: PathBuf,
pub branch: Option<String>,
pub head: Option<String>,
pub bare: bool,
pub detached: bool,
pub locked: bool,
}
pub(crate) fn parse_porcelain(output: &str) -> Vec<StatusEntry> {
let mut entries = Vec::new();
let mut records = output.split('\0').filter(|rec| !rec.is_empty());
while let Some(rec) = records.next() {
let (Some(code), Some(path)) = (rec.get(..2), rec.get(3..)) else {
continue;
};
let orig_path = if matches!(rec.as_bytes().first(), Some(b'R' | b'C')) {
records.next().map(str::to_string)
} else {
None
};
entries.push(StatusEntry {
code: code.to_string(),
path: path.to_string(),
orig_path,
});
}
entries
}
pub(crate) fn parse_porcelain_v2(output: &str) -> BranchStatus {
let mut status = BranchStatus::default();
let mut records = output.split('\0');
while let Some(rec) = records.next() {
if let Some(rest) = rec.strip_prefix("# branch.oid ") {
status.head = (rest != "(initial)").then(|| rest.to_string());
} else if let Some(rest) = rec.strip_prefix("# branch.head ") {
status.branch = (rest != "(detached)").then(|| rest.to_string());
} else if let Some(rest) = rec.strip_prefix("# branch.upstream ") {
status.upstream = Some(rest.to_string());
} else if let Some(rest) = rec.strip_prefix("# branch.ab ") {
let mut parts = rest.split(' ');
status.ahead = parts
.next()
.and_then(|t| t.strip_prefix('+'))
.and_then(|n| n.parse().ok());
status.behind = parts
.next()
.and_then(|t| t.strip_prefix('-'))
.and_then(|n| n.parse().ok());
} else if rec.starts_with("1 ") {
status.tracked_changes += 1;
} else if rec.starts_with("2 ") {
status.tracked_changes += 1;
records.next();
} else if rec.starts_with("u ") {
status.tracked_changes += 1;
status.conflicts += 1;
} else if rec.starts_with("? ") {
status.untracked += 1;
}
}
status
}
pub(crate) fn parse_git_version(raw: &str) -> Option<vcs_diff::Version> {
vcs_diff::parse_dotted_version(raw)
}
pub(crate) fn parse_nul_paths(output: &str) -> Vec<String> {
output
.split('\0')
.filter(|path| !path.is_empty())
.map(str::to_string)
.collect()
}
pub(crate) fn parse_log(output: &str) -> Vec<Commit> {
output
.split('\0')
.filter(|rec| !rec.is_empty())
.filter_map(|rec| {
let mut fields = rec.split('\u{1f}');
Some(Commit {
hash: fields.next()?.to_string(),
short_hash: fields.next()?.to_string(),
author: fields.next()?.to_string(),
date: fields.next()?.to_string(),
subject: fields.next().unwrap_or("").to_string(),
})
})
.collect()
}
pub(crate) fn parse_branches(output: &str) -> Vec<Branch> {
output
.lines()
.filter(|line| !line.trim().is_empty())
.filter_map(|line| {
let current = line.starts_with('*');
let name = line.get(1..).unwrap_or("").trim();
if name.is_empty() || name.starts_with('(') {
return None;
}
Some(Branch {
name: name.to_string(),
current,
})
})
.collect()
}
pub(crate) fn parse_worktree_porcelain(output: &str) -> Vec<Worktree> {
let mut worktrees = Vec::new();
let mut current: Option<Worktree> = None;
let flush = |current: &mut Option<Worktree>, out: &mut Vec<Worktree>| {
if let Some(wt) = current.take() {
out.push(wt);
}
};
for line in output.lines() {
if line.is_empty() {
flush(&mut current, &mut worktrees);
continue;
}
let (label, value) = match line.split_once(' ') {
Some((l, v)) => (l, Some(v)),
None => (line, None),
};
match label {
"worktree" => {
flush(&mut current, &mut worktrees);
current = Some(Worktree {
path: PathBuf::from(value.unwrap_or("")),
branch: None,
head: None,
bare: false,
detached: false,
locked: false,
});
}
"HEAD" => {
if let Some(wt) = current.as_mut() {
wt.head = value.map(str::to_string);
}
}
"branch" => {
if let Some(wt) = current.as_mut() {
wt.branch =
value.map(|v| v.strip_prefix("refs/heads/").unwrap_or(v).to_string());
}
}
"bare" => {
if let Some(wt) = current.as_mut() {
wt.bare = true;
}
}
"detached" => {
if let Some(wt) = current.as_mut() {
wt.detached = true;
}
}
"locked" => {
if let Some(wt) = current.as_mut() {
wt.locked = true;
}
}
_ => {}
}
}
flush(&mut current, &mut worktrees);
worktrees
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct BlameLine {
pub commit: String,
pub orig_line: u32,
pub final_line: u32,
pub author: String,
pub author_time: i64,
pub author_tz: String,
pub content: String,
}
pub(crate) fn parse_blame_porcelain(output: &str) -> Vec<BlameLine> {
let mut lines = Vec::new();
let mut current: Option<BlameLine> = None;
for line in output.lines() {
if let Some(content) = line.strip_prefix('\t') {
if let Some(mut entry) = current.take() {
entry.content = content.to_string();
lines.push(entry);
}
continue;
}
let (label, value) = match line.split_once(' ') {
Some((l, v)) => (l, v),
None => (line, ""),
};
if label.len() == 40 && label.bytes().all(|b| b.is_ascii_hexdigit()) {
let mut nums = value.split(' ');
let orig = nums.next().and_then(|n| n.parse().ok()).unwrap_or(0);
let fin = nums.next().and_then(|n| n.parse().ok()).unwrap_or(0);
current = Some(BlameLine {
commit: label.to_string(),
orig_line: orig,
final_line: fin,
author: String::new(),
author_time: 0,
author_tz: String::new(),
content: String::new(),
});
continue;
}
let Some(entry) = current.as_mut() else {
continue;
};
match label {
"author" => entry.author = value.to_string(),
"author-time" => entry.author_time = value.parse().unwrap_or(0),
"author-tz" => entry.author_tz = value.to_string(),
_ => {}
}
}
lines
}
pub(crate) fn parse_shortstat(output: &str) -> DiffStat {
let mut stat = DiffStat::default();
for part in output.split(',') {
let part = part.trim();
let n = part
.split_whitespace()
.next()
.and_then(|tok| tok.parse().ok())
.unwrap_or(0);
if part.contains("file") {
stat.files_changed = n;
} else if part.contains("insertion") {
stat.insertions = n;
} else if part.contains("deletion") {
stat.deletions = n;
}
}
stat
}
pub(crate) fn parse_ls_remote_heads(output: &str) -> Vec<String> {
output
.lines()
.filter_map(|line| {
let (_sha, refname) = line.split_once('\t')?;
refname
.trim()
.strip_prefix("refs/heads/")
.map(str::to_string)
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn porcelain_parses_codes_and_paths() {
let got = parse_porcelain(" M src/lib.rs\0?? new file.txt\0A added.rs\0");
assert_eq!(
got,
vec![
StatusEntry {
code: " M".into(),
path: "src/lib.rs".into(),
orig_path: None,
},
StatusEntry {
code: "??".into(),
path: "new file.txt".into(),
orig_path: None,
},
StatusEntry {
code: "A ".into(),
path: "added.rs".into(),
orig_path: None,
},
]
);
}
#[test]
fn porcelain_parses_rename_with_orig_path() {
let got = parse_porcelain("R new.rs\0old.rs\0 M other.rs\0");
assert_eq!(
got,
vec![
StatusEntry {
code: "R ".into(),
path: "new.rs".into(),
orig_path: Some("old.rs".into()),
},
StatusEntry {
code: " M".into(),
path: "other.rs".into(),
orig_path: None,
},
]
);
}
#[test]
fn porcelain_ignores_blank_and_short_records() {
assert!(parse_porcelain("\0 \0X\0").is_empty());
}
#[test]
fn porcelain_skips_non_ascii_status_records() {
assert!(parse_porcelain("𝓁abc\0").is_empty());
let entries = parse_porcelain("𝓁abc\0 M a.rs\0");
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].path, "a.rs");
}
#[test]
fn porcelain_v2_parses_branch_and_change_counts() {
let out = concat!(
"# branch.oid abcdef1234567890\0",
"# branch.head main\0",
"# branch.upstream origin/main\0",
"# branch.ab +2 -1\0",
"1 .M N... 100644 100644 100644 1111 2222 a.rs\0",
"2 R. N... 100644 100644 100644 3333 4444 R100 new.rs\0",
"1 trap.rs\0",
"u UU N... 100644 100644 100644 100644 5 6 7 conflict.rs\0",
"? untracked.txt\0",
"! ignored.txt\0",
);
let s = parse_porcelain_v2(out);
assert_eq!(s.head.as_deref(), Some("abcdef1234567890"));
assert_eq!(s.branch.as_deref(), Some("main"));
assert_eq!(s.upstream.as_deref(), Some("origin/main"));
assert_eq!((s.ahead, s.behind), (Some(2), Some(1)));
assert_eq!(
s.tracked_changes, 3,
"1 + 2(rename) + u; the trap is consumed"
);
assert_eq!(s.untracked, 1);
assert_eq!(s.conflicts, 1);
assert!(s.is_dirty());
}
#[test]
fn porcelain_v2_handles_unborn_detached_and_no_upstream() {
let s = parse_porcelain_v2("# branch.oid (initial)\0# branch.head main\0");
assert_eq!(s.head, None);
assert_eq!(s.branch.as_deref(), Some("main"));
assert_eq!(s.upstream, None);
assert_eq!((s.ahead, s.behind), (None, None));
assert!(!s.is_dirty());
let s = parse_porcelain_v2("# branch.oid deadbeef\0# branch.head (detached)\0");
assert_eq!(s.head.as_deref(), Some("deadbeef"));
assert_eq!(s.branch, None);
assert_eq!(s.upstream, None);
}
#[test]
fn blame_line_porcelain_parses_headers_and_metadata() {
let sha_a = "a".repeat(40);
let sha_b = "b".repeat(40);
let out = format!(
"{sha_a} 1 1 2\nauthor Alice\nauthor-mail <a@x>\nauthor-time 1717500000\n\
author-tz +0200\ncommitter Alice\nsummary first\nboundary\nfilename f.txt\n\
\tline one\n\
{sha_a} 2 2\nauthor Alice\nauthor-mail <a@x>\nauthor-time 1717500000\n\
author-tz +0200\ncommitter Alice\nsummary first\nfilename f.txt\n\
\tline two\n\
{sha_b} 1 3 1\nauthor Bob\nauthor-mail <b@x>\nauthor-time 1717600000\n\
author-tz -0500\ncommitter Bob\nsummary second\nfilename f.txt\n\
\t\n"
);
let lines = parse_blame_porcelain(&out);
assert_eq!(lines.len(), 3);
assert_eq!(lines[0].commit, sha_a);
assert_eq!(lines[0].orig_line, 1);
assert_eq!(lines[0].final_line, 1);
assert_eq!(lines[0].author, "Alice");
assert_eq!(lines[0].author_time, 1717500000);
assert_eq!(lines[0].author_tz, "+0200");
assert_eq!(lines[0].content, "line one");
assert_eq!(lines[1].final_line, 2);
assert_eq!(lines[1].content, "line two");
assert_eq!(lines[2].commit, sha_b);
assert_eq!(lines[2].author, "Bob");
assert_eq!(lines[2].content, "");
}
#[test]
fn blame_ignores_garbage_and_empty_input() {
assert!(parse_blame_porcelain("").is_empty());
assert!(parse_blame_porcelain("not a header\n\torphan content\n").is_empty());
}
#[test]
fn git_version_parses_real_world_shapes() {
let v = parse_git_version("git version 2.54.0.windows.1").unwrap();
assert_eq!((v.major, v.minor, v.patch), (2, 54, 0));
let v = parse_git_version("git version 2.41.0-rc1").unwrap();
assert_eq!((v.major, v.minor, v.patch), (2, 41, 0));
let v = parse_git_version("git version 2.54").unwrap();
assert_eq!(v.patch, 0, "missing patch defaults to 0");
assert!(parse_git_version("no digits here").is_none());
assert!(parse_git_version("git version unknowable").is_none());
}
#[test]
fn nul_paths_split_and_keep_special_characters() {
assert_eq!(
parse_nul_paths("a.rs\0sub/with space.rs\0"),
["a.rs", "sub/with space.rs"]
);
assert!(parse_nul_paths("").is_empty());
}
#[test]
fn log_splits_unit_separated_fields() {
let input = "abc123\u{1f}abc\u{1f}Ada\u{1f}2026-05-31T10:00:00+00:00\u{1f}Add feature\0\
def456\u{1f}def\u{1f}Linus\u{1f}2026-05-30T09:00:00+00:00\u{1f}Fix bug\0";
let got = parse_log(input);
assert_eq!(got.len(), 2);
assert_eq!(
got[0],
Commit {
hash: "abc123".into(),
short_hash: "abc".into(),
author: "Ada".into(),
date: "2026-05-31T10:00:00+00:00".into(),
subject: "Add feature".into(),
}
);
assert_eq!(got[1].subject, "Fix bug");
}
#[test]
fn log_tolerates_empty_subject() {
let got = parse_log("h\u{1f}h\u{1f}A\u{1f}2026-05-31T10:00:00+00:00\u{1f}\0");
assert_eq!(got[0].subject, "");
}
#[test]
fn branches_marks_current_and_skips_detached() {
let got = parse_branches("* main\n feature\n (HEAD detached at abc123)\n");
assert_eq!(
got,
vec![
Branch {
name: "main".into(),
current: true
},
Branch {
name: "feature".into(),
current: false
},
]
);
}
#[test]
fn worktrees_parse_branch_detached_and_bare() {
let input = "worktree /repo\nHEAD abc123\nbranch refs/heads/main\n\
\nworktree /repo/wt\nHEAD def456\ndetached\n\
\nworktree /repo/bare\nbare\n";
let got = parse_worktree_porcelain(input);
assert_eq!(got.len(), 3);
assert_eq!(got[0].path, PathBuf::from("/repo"));
assert_eq!(got[0].branch.as_deref(), Some("main"));
assert_eq!(got[0].head.as_deref(), Some("abc123"));
assert!(got[1].detached && got[1].branch.is_none());
assert!(got[2].bare && got[2].head.is_none());
}
#[test]
fn worktrees_parse_last_record_without_trailing_blank() {
let got = parse_worktree_porcelain("worktree /only\nHEAD aaa\nbranch refs/heads/x\n");
assert_eq!(got.len(), 1);
assert_eq!(got[0].branch.as_deref(), Some("x"));
}
#[test]
fn shortstat_parses_all_clauses() {
let got = parse_shortstat(" 3 files changed, 12 insertions(+), 4 deletions(-)\n");
assert_eq!(got, DiffStat::new(3, 12, 4));
}
#[test]
fn shortstat_tolerates_missing_clauses_and_empty() {
let only_ins = parse_shortstat(" 1 file changed, 2 insertions(+)\n");
assert_eq!(only_ins.insertions, 2);
assert_eq!(only_ins.deletions, 0);
assert_eq!(parse_shortstat(""), DiffStat::default());
}
}
#[cfg(test)]
mod proptests {
use super::*;
use proptest::prelude::*;
fn structured_line() -> impl Strategy<Value = String> {
prop_oneof![
Just("diff --git a/f b/f\n".to_string()),
Just("--- a/f\n".to_string()),
Just("+++ b/f\n".to_string()),
Just("@@ -1,2 +3,4 @@ ctx\n".to_string()),
Just("@@ -1 +1 @@\n".to_string()),
Just("rename from {old => new}.rs\n".to_string()),
Just("R100\told\tnew\n".to_string()),
Just(format!("{}\n", "a".repeat(40))), "[-+ ]?[a-zé\t]{0,12}\n", "[ MARD?]{0,2} [a-zé/]{0,8}\0", ]
}
fn structured_doc() -> impl Strategy<Value = String> {
prop::collection::vec(structured_line(), 0..40).prop_map(|lines| lines.concat())
}
proptest! {
#[test]
fn parsers_never_panic_on_arbitrary_text(s in any::<String>()) {
let _ = parse_porcelain(&s);
let _ = parse_porcelain_v2(&s);
let _ = parse_log(&s);
let _ = parse_branches(&s);
let _ = parse_worktree_porcelain(&s);
let _ = parse_blame_porcelain(&s);
let _ = parse_shortstat(&s);
let _ = parse_ls_remote_heads(&s);
let _ = parse_nul_paths(&s);
let _ = parse_git_version(&s);
}
#[test]
fn parsers_never_panic_on_structured_text(s in structured_doc()) {
let _ = parse_porcelain(&s);
let _ = parse_porcelain_v2(&s);
let _ = parse_log(&s);
let _ = parse_blame_porcelain(&s);
}
#[test]
fn porcelain_v2_never_panics(records in prop::collection::vec(
prop_oneof![
Just("# branch.oid (initial)".to_string()),
Just("# branch.head main".to_string()),
Just("# branch.ab +1 -2".to_string()),
"1 [.MADRCU]{2} [a-zé /]{0,10}".prop_map(|s| s),
"2 R\\. .* R100 [a-zé /]{0,8}".prop_map(|s| s),
"u UU [a-zé /]{0,8}".prop_map(|s| s),
"\\? [a-zé /]{0,8}".prop_map(|s| s),
"[a-zé0-9# ]{0,12}".prop_map(|s| s),
],
0..20,
).prop_map(|r| r.join("\0"))) {
let _ = parse_porcelain_v2(&records);
}
}
}