#[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,
}
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()
}
#[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
},
]
);
}
}