use std::path::PathBuf;
use vcs_diff::DiffStat;
use crate::{BINARY, BisectStep, Error, Result, RevSpec};
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct StatusEntry {
pub code: String,
pub path: PathBuf,
pub old_path: Option<PathBuf>,
}
#[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 StashEntry {
pub index: usize,
pub hash: String,
pub branch: Option<String>,
pub message: String,
}
#[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: &[u8]) -> Vec<StatusEntry> {
let mut entries = Vec::new();
let mut records = output.split(|&b| b == 0).filter(|rec| !rec.is_empty());
while let Some(rec) = records.next() {
let (Some(code), Some(&b' ')) = (rec.get(..2), rec.get(2)) else {
continue;
};
let path = &rec[3..];
let old_path = if matches!(code, [b'R' | b'C', _] | [_, b'R' | b'C']) {
records.next().map(vcs_diff::path_from_bytes)
} else {
None
};
entries.push(StatusEntry {
code: String::from_utf8_lossy(code).into_owned(),
path: vcs_diff::path_from_bytes(path),
old_path,
});
}
entries
}
#[doc(hidden)]
pub 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_bisect_step(output: &str) -> Result<BisectStep> {
let mut result = None;
for raw_line in output.lines() {
let line = raw_line.trim();
let candidate = line
.strip_suffix(" is the first 'bad' commit")
.or_else(|| line.strip_suffix(" is the first bad commit"));
if let Some(oid) = candidate {
let revision = parse_bisect_oid(oid)?;
set_bisect_result(&mut result, BisectStep::FirstBad { revision })?;
continue;
}
if let Some(rest) = line.strip_prefix('[') {
let Some((oid, subject)) = rest.split_once("] ") else {
return Err(bisect_parse_error(format!(
"malformed next-candidate line: {line:?}"
)));
};
if subject.trim().is_empty() {
return Err(bisect_parse_error(format!(
"next-candidate line has no subject: {line:?}"
)));
}
let revision = parse_bisect_oid(oid)?;
set_bisect_result(&mut result, BisectStep::NextCandidate { revision })?;
}
}
result.ok_or_else(|| bisect_parse_error(format!("unrecognised bisect output: {output:?}")))
}
fn parse_bisect_oid(raw: &str) -> Result<RevSpec> {
let oid = raw.trim();
let valid = (4..=64).contains(&oid.len()) && oid.bytes().all(|byte| byte.is_ascii_hexdigit());
if !valid {
return Err(bisect_parse_error(format!(
"invalid bisect object id: {raw:?}"
)));
}
RevSpec::new(oid)
}
fn set_bisect_result(result: &mut Option<BisectStep>, next: BisectStep) -> Result<()> {
if result.is_some() {
return Err(bisect_parse_error(
"bisect output contains more than one possible result".to_string(),
));
}
*result = Some(next);
Ok(())
}
fn bisect_parse_error(message: String) -> Error {
Error::parse(BINARY, message)
}
pub(crate) fn parse_nul_paths(output: &[u8]) -> Vec<PathBuf> {
output
.split(|&b| b == 0)
.filter(|path| !path.is_empty())
.map(vcs_diff::path_from_bytes)
.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_stash_list(output: &str) -> Vec<StashEntry> {
output
.split('\0')
.filter(|rec| !rec.is_empty())
.filter_map(|rec| {
let mut fields = rec.split('\u{1f}');
let selector = fields.next()?;
let hash = fields.next()?.to_string();
let subject = fields.next().unwrap_or("");
let index: usize = selector
.strip_prefix("stash@{")?
.strip_suffix('}')?
.parse()
.ok()?;
let (branch, message) = parse_stash_subject(subject);
Some(StashEntry {
index,
hash,
branch,
message,
})
})
.collect()
}
fn parse_stash_subject(subject: &str) -> (Option<String>, String) {
let Some(rest) = subject
.strip_prefix("WIP on ")
.or_else(|| subject.strip_prefix("On "))
else {
return (None, subject.to_string());
};
match rest.split_once(": ") {
Some((branch, message)) => {
let branch = (branch != "(no branch)").then(|| branch.to_string());
(branch, message.to_string())
}
None => (None, rest.to_string()),
}
}
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: &[u8]) -> 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.split(|&b| b == b'\n') {
let line = line.strip_suffix(b"\r").unwrap_or(line);
if line.is_empty() {
flush(&mut current, &mut worktrees);
continue;
}
let (label, value) = match line.iter().position(|&b| b == b' ') {
Some(i) => (&line[..i], Some(&line[i + 1..])),
None => (line, None),
};
match label {
b"worktree" => {
flush(&mut current, &mut worktrees);
current = Some(Worktree {
path: value.map(vcs_diff::path_from_bytes).unwrap_or_default(),
branch: None,
head: None,
bare: false,
detached: false,
locked: false,
});
}
b"HEAD" => {
if let Some(wt) = current.as_mut() {
wt.head = value.map(|v| String::from_utf8_lossy(v).into_owned());
}
}
b"branch" => {
if let Some(wt) = current.as_mut() {
wt.branch = value.map(|v| {
let full = String::from_utf8_lossy(v);
full.strip_prefix("refs/heads/")
.unwrap_or(&full)
.to_string()
});
}
}
b"bare" => {
if let Some(wt) = current.as_mut() {
wt.bare = true;
}
}
b"detached" => {
if let Some(wt) = current.as_mut() {
wt.detached = true;
}
}
b"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 CleanEntry {
pub path: PathBuf,
pub is_dir: bool,
}
pub(crate) fn parse_clean_output(output: &str) -> Vec<CleanEntry> {
output
.lines()
.filter_map(|line| {
let rest = line
.strip_prefix("Would remove ")
.or_else(|| line.strip_prefix("Removing "))?;
let mut decoded = vcs_diff::unquote_c_style_path(rest);
let is_dir = decoded.last() == Some(&b'/');
if is_dir {
decoded.pop();
}
Some(CleanEntry {
path: vcs_diff::path_from_bytes(&decoded),
is_dir,
})
})
.collect()
}
#[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.len() == 64) && 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 {
DiffStat::parse(output)
}
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()
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct Remote {
pub name: String,
pub url: String,
}
pub(crate) fn parse_remotes(output: &str) -> Vec<Remote> {
let mut remotes: Vec<(Remote, bool)> = Vec::new();
for line in output.lines() {
let line = line.trim();
let Some((name, rest)) = line.split_once(char::is_whitespace) else {
continue;
};
let rest = rest.trim_start();
let (url, is_fetch) = if let Some(url) = rest.strip_suffix(" (fetch)") {
(url, true)
} else if let Some(url) = rest.strip_suffix(" (push)") {
(url, false)
} else {
(rest, false)
};
if name.is_empty() || url.is_empty() {
continue;
}
if let Some((remote, has_fetch)) =
remotes.iter_mut().find(|(remote, _)| remote.name == name)
{
if is_fetch && !*has_fetch {
remote.url = url.to_string();
*has_fetch = true;
}
} else {
remotes.push((
Remote {
name: name.to_string(),
url: url.to_string(),
},
is_fetch,
));
}
}
remotes.into_iter().map(|(remote, _)| remote).collect()
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct Submodule {
pub name: String,
pub path: PathBuf,
pub url: String,
pub branch: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum SubmoduleState {
Current,
Uninitialized,
RevisionMismatch,
Conflict,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct SubmoduleStatus {
pub path: PathBuf,
pub sha: String,
pub state: SubmoduleState,
pub describe: Option<String>,
}
pub(crate) fn parse_gitmodules_config(output: &[u8]) -> Vec<Submodule> {
let mut subs: Vec<Submodule> = Vec::new();
for record in output.split(|&b| b == 0).filter(|r| !r.is_empty()) {
let (key_bytes, value_bytes) = match record.iter().position(|&b| b == b'\n') {
Some(i) => (&record[..i], &record[i + 1..]),
None => (record, &b""[..]),
};
let key = String::from_utf8_lossy(key_bytes);
let Some(rest) = key.strip_prefix("submodule.") else {
continue;
};
let Some((name, attr)) = rest.rsplit_once('.') else {
continue;
};
let sub = match subs.iter_mut().find(|s| s.name == name) {
Some(existing) => existing,
None => {
subs.push(Submodule {
name: name.to_string(),
path: PathBuf::new(),
url: String::new(),
branch: None,
});
subs.last_mut().expect("just pushed")
}
};
match attr {
"path" => sub.path = vcs_diff::path_from_bytes(value_bytes),
"url" => sub.url = String::from_utf8_lossy(value_bytes).into_owned(),
"branch" => sub.branch = Some(String::from_utf8_lossy(value_bytes).into_owned()),
_ => {}
}
}
subs
}
pub(crate) fn parse_submodule_status(output: &[u8]) -> Vec<SubmoduleStatus> {
let mut entries = Vec::new();
for line in output.split(|&b| b == b'\n') {
let line = line.strip_suffix(b"\r").unwrap_or(line);
if line.is_empty() {
continue;
}
let state = match line[0] {
b' ' => SubmoduleState::Current,
b'-' => SubmoduleState::Uninitialized,
b'+' => SubmoduleState::RevisionMismatch,
b'U' => SubmoduleState::Conflict,
_ => continue,
};
let rest = &line[1..];
let Some(sp) = rest.iter().position(|&b| b == b' ') else {
continue;
};
let sha = String::from_utf8_lossy(&rest[..sp]).into_owned();
let tail = &rest[sp + 1..];
let (path_bytes, describe) = match tail.last() {
Some(b')') => match tail
.windows(2)
.rposition(|w| w == b" (")
.filter(|&i| i + 2 < tail.len())
{
Some(i) => (
&tail[..i],
Some(String::from_utf8_lossy(&tail[i + 2..tail.len() - 1]).into_owned()),
),
None => (tail, None),
},
_ => (tail, None),
};
entries.push(SubmoduleStatus {
path: vcs_diff::path_from_bytes(path_bytes),
sha,
state,
describe,
});
}
entries
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn porcelain_parses_codes_and_paths() {
let got = parse_porcelain(b" 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(),
old_path: None,
},
StatusEntry {
code: "??".into(),
path: "new file.txt".into(),
old_path: None,
},
StatusEntry {
code: "A ".into(),
path: "added.rs".into(),
old_path: None,
},
]
);
}
#[cfg(unix)]
#[test]
fn porcelain_preserves_non_utf8_path_bytes() {
use std::os::unix::ffi::OsStrExt;
let got = parse_porcelain(b" M caf\xff.txt\0");
assert_eq!(got.len(), 1);
assert_eq!(got[0].path.as_os_str().as_bytes(), b"caf\xff.txt");
}
#[test]
fn porcelain_parses_rename_with_old_path() {
let got = parse_porcelain(b"R new.rs\0old.rs\0 M other.rs\0");
assert_eq!(
got,
vec![
StatusEntry {
code: "R ".into(),
path: "new.rs".into(),
old_path: Some("old.rs".into()),
},
StatusEntry {
code: " M".into(),
path: "other.rs".into(),
old_path: None,
},
]
);
}
#[test]
fn porcelain_parses_worktree_rename_in_the_y_column() {
let got = parse_porcelain(b" R new.rs\0old.rs\0 M other.rs\0");
assert_eq!(
got,
vec![
StatusEntry {
code: " R".into(),
path: "new.rs".into(),
old_path: Some("old.rs".into()),
},
StatusEntry {
code: " M".into(),
path: "other.rs".into(),
old_path: None,
},
],
"the source record must be consumed, not left as a phantom entry"
);
}
#[test]
fn porcelain_ignores_blank_and_short_records() {
assert!(parse_porcelain(b"\0 \0X\0").is_empty());
}
#[test]
fn porcelain_skips_non_ascii_status_records() {
assert!(parse_porcelain("𝓁abc\0".as_bytes()).is_empty());
let entries = parse_porcelain("𝓁abc\0 M a.rs\0".as_bytes());
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].path, std::path::Path::new("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 blame_recognises_sha256_object_ids() {
let sha = "c".repeat(64);
let out = format!(
"{sha} 1 1 1\nauthor Carol\nauthor-mail <c@x>\nauthor-time 1717700000\n\
author-tz +0000\ncommitter Carol\nsummary s\nfilename f.txt\n\
\tline\n"
);
let lines = parse_blame_porcelain(&out);
assert_eq!(
lines.len(),
1,
"a SHA-256 blame must parse, not drop to empty"
);
assert_eq!(lines[0].commit, sha);
assert_eq!(lines[0].author, "Carol");
assert_eq!(lines[0].content, "line");
}
#[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(b"a.rs\0sub/with space.rs\0"),
[PathBuf::from("a.rs"), PathBuf::from("sub/with space.rs")]
);
assert!(parse_nul_paths(b"").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.as_bytes());
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_crlf_without_trailing_carriage_returns() {
let got = parse_worktree_porcelain(
b"worktree /repo/wt\r\nHEAD abc123\r\nbranch refs/heads/main\r\nlocked\r\n\r\n\
worktree /repo/bare\r\nbare\r\n\r\n\
worktree /repo/detached\r\nHEAD def456\r\ndetached\r\n",
);
assert_eq!(got.len(), 3);
assert_eq!(got[0].path, PathBuf::from("/repo/wt"));
assert_eq!(got[0].head.as_deref(), Some("abc123"));
assert_eq!(got[0].branch.as_deref(), Some("main"));
assert!(got[0].locked);
assert!(got[1].bare && got[1].head.is_none());
assert!(got[2].detached && got[2].branch.is_none());
assert_eq!(got[2].head.as_deref(), Some("def456"));
}
#[cfg(unix)]
#[test]
fn worktrees_preserve_non_utf8_path_bytes() {
use std::os::unix::ffi::OsStrExt;
let got = parse_worktree_porcelain(b"worktree /repo/wt-caf\xff\nHEAD abc123\n");
assert_eq!(got.len(), 1);
assert_eq!(got[0].path.as_os_str().as_bytes(), b"/repo/wt-caf\xff");
assert_eq!(got[0].head.as_deref(), Some("abc123"));
}
#[test]
fn worktrees_parse_last_record_without_trailing_blank() {
let got = parse_worktree_porcelain(b"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());
}
#[test]
fn gitmodules_config_parses_z_framed_records() {
let out = b"submodule.libs/sub.path\nlibs/sub\0\
submodule.libs/sub.url\n../sub\0\
submodule.libs/sub.branch\nmain\0\
submodule.second.path\nsecond\0\
submodule.second.url\n../sub\0";
let got = parse_gitmodules_config(out);
assert_eq!(
got,
vec![
Submodule {
name: "libs/sub".into(),
path: "libs/sub".into(),
url: "../sub".into(),
branch: Some("main".into()),
},
Submodule {
name: "second".into(),
path: "second".into(),
url: "../sub".into(),
branch: None,
},
]
);
}
#[test]
fn gitmodules_config_keeps_value_with_equals_and_ignores_non_submodule_keys() {
let out = b"submodule.x.url\nhttps://h/r?a=b\0\
core.autocrlf\nfalse\0\
submodule.x.path\nx\0";
let got = parse_gitmodules_config(out);
assert_eq!(got.len(), 1);
assert_eq!(got[0].url, "https://h/r?a=b");
assert_eq!(got[0].path, PathBuf::from("x"));
}
#[test]
fn gitmodules_config_empty_is_no_submodules() {
assert!(parse_gitmodules_config(b"").is_empty());
}
#[test]
fn remotes_empty_output_is_empty() {
assert!(parse_remotes("\n \t\r\n").is_empty());
}
#[test]
fn remotes_one_remote_prefers_fetch_url() {
assert_eq!(
parse_remotes(
"origin\thttps://example.test/fetch.git (fetch)\norigin\thttps://example.test/push.git (push)\n"
),
vec![Remote {
name: "origin".into(),
url: "https://example.test/fetch.git".into(),
}]
);
}
#[test]
fn remotes_preserve_spaces_and_prefer_the_fetch_url() {
assert_eq!(
parse_remotes(
"origin C:/Users/John Doe/repo (push)\n\
origin C:/Users/John Doe/fetch repo (fetch)\n"
),
vec![Remote {
name: "origin".into(),
url: "C:/Users/John Doe/fetch repo".into(),
}]
);
}
#[test]
fn remotes_multiple_rows_dedupe_and_tolerate_malformed_output() {
assert_eq!(
parse_remotes(
"origin ssh://example.test/push.git (push)\n\
upstream https://example.test/upstream.git (fetch)\r\n\
malformed-only-name\n\
origin https://example.test/fetch.git (fetch)\n\
upstream https://example.test/upstream-push.git (push)\n",
),
vec![
Remote {
name: "origin".into(),
url: "https://example.test/fetch.git".into(),
},
Remote {
name: "upstream".into(),
url: "https://example.test/upstream.git".into(),
},
]
);
}
#[cfg(unix)]
#[test]
fn gitmodules_config_preserves_non_utf8_path_bytes() {
use std::os::unix::ffi::OsStrExt;
let out = b"submodule.s.path\ncaf\xff/sub\0submodule.s.url\n../sub\0";
let got = parse_gitmodules_config(out);
assert_eq!(got.len(), 1);
assert_eq!(got[0].path.as_os_str().as_bytes(), b"caf\xff/sub");
}
#[test]
fn submodule_status_parses_all_prefix_states() {
let out = b" 833caa0 libs/sub (heads/main)\n\
+530fd06 plus/mod (530fd06)\n\
U000aaaa conf/mod (heads/topic)\n\
-deadbee minus/mod\n";
let got = parse_submodule_status(out);
assert_eq!(got.len(), 4);
assert_eq!(got[0].state, SubmoduleState::Current);
assert_eq!(got[0].sha, "833caa0");
assert_eq!(got[0].path, PathBuf::from("libs/sub"));
assert_eq!(got[0].describe.as_deref(), Some("heads/main"));
assert_eq!(got[1].state, SubmoduleState::RevisionMismatch);
assert_eq!(got[1].path, PathBuf::from("plus/mod"));
assert_eq!(got[1].describe.as_deref(), Some("530fd06"));
assert_eq!(got[2].state, SubmoduleState::Conflict);
assert_eq!(got[2].path, PathBuf::from("conf/mod"));
assert_eq!(got[3].state, SubmoduleState::Uninitialized);
assert_eq!(got[3].sha, "deadbee");
assert_eq!(got[3].path, PathBuf::from("minus/mod"));
assert_eq!(got[3].describe, None);
}
#[test]
fn submodule_status_handles_spaced_path_and_crlf() {
let out = b" abc123 dir with space/sub (v1.0)\r\n";
let got = parse_submodule_status(out);
assert_eq!(got.len(), 1);
assert_eq!(got[0].path, PathBuf::from("dir with space/sub"));
assert_eq!(got[0].describe.as_deref(), Some("v1.0"));
}
#[test]
fn submodule_status_without_describe_keeps_full_path() {
let out = b" abc123 libs/no-describe\n";
let got = parse_submodule_status(out);
assert_eq!(got.len(), 1);
assert_eq!(got[0].path, PathBuf::from("libs/no-describe"));
assert_eq!(got[0].describe, None);
}
#[test]
fn submodule_status_empty_is_no_entries() {
assert!(parse_submodule_status(b"").is_empty());
}
#[test]
fn stash_list_parses_default_and_custom_labels() {
let out = concat!(
"stash@{0}\u{1f}aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\u{1f}",
"On feature: my label\0",
"stash@{1}\u{1f}bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\u{1f}",
"WIP on feature: f1c02c2 init\0",
);
let got = parse_stash_list(out);
assert_eq!(got.len(), 2);
assert_eq!(got[0].index, 0);
assert_eq!(got[0].hash, "a".repeat(40));
assert_eq!(got[0].branch.as_deref(), Some("feature"));
assert_eq!(got[0].message, "my label");
assert_eq!(got[1].index, 1);
assert_eq!(got[1].branch.as_deref(), Some("feature"));
assert_eq!(got[1].message, "f1c02c2 init");
}
#[test]
fn stash_list_detached_head_has_no_branch() {
let out = "stash@{0}\u{1f}cccccccccccccccccccccccccccccccccccccccc\u{1f}\
On (no branch): detached label\0";
let got = parse_stash_list(out);
assert_eq!(got.len(), 1);
assert_eq!(got[0].branch, None);
assert_eq!(got[0].message, "detached label");
}
#[test]
fn stash_list_empty_is_no_entries() {
assert!(parse_stash_list("").is_empty());
}
#[test]
fn stash_list_skips_a_record_with_an_unrecognized_selector() {
let out = "not-a-selector\u{1f}deadbeef\u{1f}subject\0";
assert!(parse_stash_list(out).is_empty());
}
#[test]
fn clean_output_parses_dry_run_files_and_directories() {
let out = "Would remove junk.txt\nWould remove sub/\n";
let got = parse_clean_output(out);
assert_eq!(
got,
vec![
CleanEntry {
path: PathBuf::from("junk.txt"),
is_dir: false,
},
CleanEntry {
path: PathBuf::from("sub"),
is_dir: true,
},
]
);
}
#[test]
fn clean_output_parses_forced_removals() {
let out = "Removing junk.txt\nRemoving sub/\n";
let got = parse_clean_output(out);
assert_eq!(got.len(), 2);
assert_eq!(got[0].path, PathBuf::from("junk.txt"));
assert!(!got[0].is_dir);
assert_eq!(got[1].path, PathBuf::from("sub"));
assert!(got[1].is_dir);
}
#[test]
fn clean_output_unquotes_c_quoted_paths() {
let out = "Would remove \"caf\\303\\251.txt\"\nWould remove \"w\\303\\251ird dir/\"\n";
let got = parse_clean_output(out);
assert_eq!(got.len(), 2);
assert_eq!(got[0].path, PathBuf::from("café.txt"));
assert!(!got[0].is_dir);
assert_eq!(got[1].path, PathBuf::from("wéird dir"));
assert!(got[1].is_dir);
}
#[test]
fn clean_output_ignores_unrecognized_lines() {
let out = "Skipping repository sub/nested\nWould remove real.txt\n";
let got = parse_clean_output(out);
assert_eq!(got.len(), 1);
assert_eq!(got[0].path, PathBuf::from("real.txt"));
}
#[test]
fn clean_output_empty_is_no_entries() {
assert!(parse_clean_output("").is_empty());
}
}
#[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.as_bytes());
let _ = parse_porcelain_v2(&s);
let _ = parse_log(&s);
let _ = parse_branches(&s);
let _ = parse_worktree_porcelain(s.as_bytes());
let _ = parse_blame_porcelain(&s);
let _ = parse_shortstat(&s);
let _ = parse_ls_remote_heads(&s);
let _ = parse_remotes(&s);
let _ = parse_nul_paths(s.as_bytes());
let _ = parse_git_version(&s);
let _ = parse_stash_list(&s);
let _ = parse_clean_output(&s);
}
#[test]
fn byte_parsers_never_panic_on_arbitrary_bytes(b in any::<Vec<u8>>()) {
let _ = parse_porcelain(&b);
let _ = parse_nul_paths(&b);
let _ = parse_worktree_porcelain(&b);
}
#[test]
fn parsers_never_panic_on_structured_text(s in structured_doc()) {
let _ = parse_porcelain(s.as_bytes());
let _ = parse_porcelain_v2(&s);
let _ = parse_log(&s);
let _ = parse_blame_porcelain(&s);
let _ = parse_gitmodules_config(s.as_bytes());
let _ = parse_submodule_status(s.as_bytes());
let _ = parse_stash_list(&s);
let _ = parse_clean_output(&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);
}
}
}