use std::path::PathBuf;
#[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)]
#[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,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[non_exhaustive]
pub struct DiffStat {
pub files_changed: usize,
pub insertions: usize,
pub deletions: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum ChangeKind {
Added,
Modified,
Deleted,
Renamed,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum DiffLine {
Context(String),
Added(String),
Removed(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct Hunk {
pub old_start: usize,
pub old_lines: usize,
pub new_start: usize,
pub new_lines: usize,
pub section: String,
pub lines: Vec<DiffLine>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct FileDiff {
pub change: ChangeKind,
pub path: String,
pub old_path: Option<String>,
pub hunks: Vec<Hunk>,
}
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() {
if rec.len() < 4 {
continue;
}
let orig_path = if matches!(rec.as_bytes()[0], b'R' | b'C') {
records.next().map(str::to_string)
} else {
None
};
entries.push(StatusEntry {
code: rec[..2].to_string(),
path: rec[3..].to_string(),
orig_path,
});
}
entries
}
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
}
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 fn parse_diff(diff: &str) -> Vec<FileDiff> {
diff_sections(diff).filter_map(parse_section).collect()
}
fn diff_sections(full: &str) -> impl Iterator<Item = &str> {
let mut bounds = Vec::new();
let mut idx = 0;
for line in full.split_inclusive('\n') {
if line.starts_with("diff --git ") {
bounds.push(idx);
}
idx += line.len();
}
let ends = bounds
.iter()
.skip(1)
.copied()
.chain(std::iter::once(full.len()));
bounds
.clone()
.into_iter()
.zip(ends)
.map(move |(s, e)| &full[s..e])
.collect::<Vec<_>>()
.into_iter()
}
fn parse_section(section: &str) -> Option<FileDiff> {
let mut kind = ChangeKind::Modified;
let mut new_path = None;
let mut minus_path = None;
let mut rename_to = None;
let mut rename_from = None;
let mut hunks: Vec<Hunk> = Vec::new();
let mut current: Option<Hunk> = None;
for line in section.lines() {
if let Some(hunk) = parse_hunk_header(line) {
if let Some(done) = current.replace(hunk) {
hunks.push(done);
}
continue;
}
if let Some(hunk) = current.as_mut() {
match line.as_bytes().first() {
Some(b' ') => hunk.lines.push(DiffLine::Context(line[1..].to_string())),
Some(b'+') => hunk.lines.push(DiffLine::Added(line[1..].to_string())),
Some(b'-') => hunk.lines.push(DiffLine::Removed(line[1..].to_string())),
_ => {}
}
continue;
}
if line.starts_with("new file") {
kind = ChangeKind::Added;
} else if line.starts_with("deleted file") {
kind = ChangeKind::Deleted;
} else if let Some(p) = line.strip_prefix("rename to ") {
rename_to = Some(p.trim_end().to_string());
} else if let Some(p) = line.strip_prefix("rename from ") {
rename_from = Some(p.trim_end().to_string());
} else if let Some(p) = line.strip_prefix("+++ b/") {
new_path = Some(p.trim_end().to_string());
} else if let Some(p) = line.strip_prefix("--- a/") {
minus_path = Some(p.trim_end().to_string());
}
}
if let Some(done) = current.take() {
hunks.push(done);
}
let normalize = |p: String| p.replace('\\', "/");
let old_path = if rename_to.is_some() {
kind = ChangeKind::Renamed;
rename_from.map(normalize)
} else {
None
};
let path = rename_to
.or(new_path)
.or(minus_path)
.or_else(|| header_b_path(section))?;
Some(FileDiff {
change: kind,
path: normalize(path),
old_path,
hunks,
})
}
fn parse_hunk_header(line: &str) -> Option<Hunk> {
let rest = line.strip_prefix("@@ ")?;
let (ranges, section) = rest.split_once(" @@")?;
let mut parts = ranges.split_whitespace();
let (old_start, old_lines) = parse_hunk_range(parts.next()?.strip_prefix('-')?);
let (new_start, new_lines) = parse_hunk_range(parts.next()?.strip_prefix('+')?);
Some(Hunk {
old_start,
old_lines,
new_start,
new_lines,
section: section.strip_prefix(' ').unwrap_or(section).to_string(),
lines: Vec::new(),
})
}
fn parse_hunk_range(range: &str) -> (usize, usize) {
match range.split_once(',') {
Some((start, count)) => (start.parse().unwrap_or(0), count.parse().unwrap_or(0)),
None => (range.parse().unwrap_or(0), 1),
}
}
fn header_b_path(section: &str) -> Option<String> {
let first = section.lines().next()?;
let s = first.strip_prefix("diff --git ")?;
let idx = s.find(" b/")?;
Some(s[idx + 1..].strip_prefix("b/").unwrap_or("").to_string())
}
#[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 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 {
files_changed: 3,
insertions: 12,
deletions: 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());
}
#[test]
fn diff_covers_add_modify_delete_rename() {
let full = concat!(
"diff --git a/new b/new\n",
"new file mode 100644\n--- /dev/null\n+++ b/new\n@@ -0,0 +1 @@\n+n\n",
"diff --git a/mod b/mod\n",
"--- a/mod\n+++ b/mod\n@@ -1 +1 @@\n-a\n+b\n",
"diff --git a/gone b/gone\n",
"deleted file mode 100644\n--- a/gone\n+++ /dev/null\n@@ -1 +0,0 @@\n-x\n",
"diff --git a/old/f.txt b/new/f.txt\n",
"similarity index 100%\nrename from old/f.txt\nrename to new/f.txt\n",
);
let files = parse_diff(full);
let kinds: Vec<_> = files.iter().map(|f| (f.path.as_str(), f.change)).collect();
assert_eq!(
kinds,
vec![
("new", ChangeKind::Added),
("mod", ChangeKind::Modified),
("gone", ChangeKind::Deleted),
("new/f.txt", ChangeKind::Renamed),
]
);
let rename = files
.iter()
.find(|f| f.change == ChangeKind::Renamed)
.unwrap();
assert_eq!(rename.old_path.as_deref(), Some("old/f.txt"));
}
#[test]
fn diff_handles_space_paths() {
let full = "diff --git a/a b/c.txt b/a b/c.txt\n--- a/a b/c.txt\t\n+++ b/a b/c.txt\t\n@@ -1 +1 @@\n-x\n+y\n";
let files = parse_diff(full);
assert_eq!(files.len(), 1);
assert_eq!(files[0].path, "a b/c.txt");
}
#[test]
fn diff_parses_hunk_ranges_and_body() {
let full = "diff --git a/f b/f\n--- a/f\n+++ b/f\n@@ -1,2 +1,3 @@ fn main()\n ctx\n-old\n+new\n+added\n";
let files = parse_diff(full);
assert_eq!(files.len(), 1);
let hunk = &files[0].hunks[0];
assert_eq!(
(
hunk.old_start,
hunk.old_lines,
hunk.new_start,
hunk.new_lines
),
(1, 2, 1, 3)
);
assert_eq!(hunk.section, "fn main()");
assert_eq!(
hunk.lines,
vec![
DiffLine::Context("ctx".into()),
DiffLine::Removed("old".into()),
DiffLine::Added("new".into()),
DiffLine::Added("added".into()),
]
);
}
#[test]
fn diff_omitted_count_defaults_to_one() {
let full = "diff --git a/f b/f\n--- a/f\n+++ b/f\n@@ -3 +3 @@\n-a\n+b\n";
let hunk = &parse_diff(full)[0].hunks[0];
assert_eq!((hunk.old_start, hunk.old_lines), (3, 1));
assert_eq!((hunk.new_start, hunk.new_lines), (3, 1));
}
}