use std::process::Command;
use pretty_assertions::assert_eq;
use super::*;
const MIXED_STATUS: &[u8] = b"# branch.oid 542a8d53d8a56c5e28be851801f21d0f589205a1\0\
# branch.head main\0\
1 MM N... 100644 100644 100644 78981922613b2afb6025042ff6bd878ac1994e85 9ad2ebbaff6f3397bb65002dcf4294d8d6243982 a.txt\0\
1 A. N... 000000 100644 100644 0000000000000000000000000000000000000000 3e757656cf36eca53338e520d134963a44f793f8 added.txt\0\
1 M. N... 100644 100644 100644 bdc955b7b2e610ad5a72302b139a2e6cb325519a 40d36f1cb4d063007c75d68c47b44fb356f164dd bin.dat\0\
1 D. N... 100644 000000 000000 587be6b4c3f93f93c489c0111bba5596147a26cb 0000000000000000000000000000000000000000 del.txt\0\
1 .T N... 100644 100644 120000 1f9d725a9de833a65966881dce2e907b86e72c5e 1f9d725a9de833a65966881dce2e907b86e72c5e link\0\
2 RM N... 100644 100644 100644 4cb29ea38f70d7c61b2a3a25b02e3bdf44905402 4cb29ea38f70d7c61b2a3a25b02e3bdf44905402 R100 new [name].txt\0\
old.txt\0\
? sub dir/n*.txt\0";
const MIXED_STAGED_NUMSTAT: &[u8] = b"1\t0\ta.txt\0\
1\t0\tadded.txt\0\
-\t-\tbin.dat\0\
0\t1\tdel.txt\0\
0\t0\t\0\
old.txt\0\
new [name].txt\0";
const DETACHED_STATUS: &[u8] = b"# branch.oid d7c7adf1a4e973ac68a80dc8a4df229e191934fc\0\
# branch.head (detached)\0\
2 .R N... 100644 100644 100644 4cb29ea38f70d7c61b2a3a25b02e3bdf44905402 4cb29ea38f70d7c61b2a3a25b02e3bdf44905402 R100 moved c.txt\0\
c.txt\0";
const UNBORN_STATUS: &[u8] = b"# branch.oid (initial)\0\
# branch.head main\0\
1 A. N... 000000 100644 100644 0000000000000000000000000000000000000000 45b983be36b73c0788dc9cbcb76cbb80fc7bb057 a.txt\0";
const CONFLICT_STATUS: &[u8] = b"# branch.oid 645edccfe1693672925d8eefffafb10e5852f7a7\0\
# branch.head main\0\
u UU N... 100644 100644 100644 100644 df967b96a579e45a18b8251732d16804b2e56a55 b19a1e93bec1317dc6097229e12afaffbfa74dc2 950b81b7eee953d050aa05a641f8e056c85dd1bd c.txt\0";
const CONFLICT_NUMSTAT: &[u8] = b"0\t0\tc.txt\0\
4\t0\tc.txt\0";
fn changed(section: DiffSection, change: FileChange, path: &str) -> ChangedFile {
ChangedFile {
section,
change,
path: path.into(),
orig_path: None,
stats: None,
}
}
#[test]
fn parse_status_groups_entries_by_section_in_git_order() {
use super::DiffSection::{Staged, Unstaged, Untracked};
let cases = [
(
"mixed worktree",
MIXED_STATUS,
Some("main"),
vec![
changed(Staged, FileChange::Modified, "a.txt"),
changed(Staged, FileChange::Added, "added.txt"),
changed(Staged, FileChange::Modified, "bin.dat"),
changed(Staged, FileChange::Deleted, "del.txt"),
ChangedFile {
orig_path: Some("old.txt".into()),
..changed(Staged, FileChange::Renamed, "new [name].txt")
},
changed(Unstaged, FileChange::Modified, "a.txt"),
changed(Unstaged, FileChange::TypeChanged, "link"),
changed(Unstaged, FileChange::Modified, "new [name].txt"),
changed(Untracked, FileChange::Untracked, "sub dir/n*.txt"),
],
),
(
"detached head with unstaged rename",
DETACHED_STATUS,
None,
vec![ChangedFile {
orig_path: Some("c.txt".into()),
..changed(Unstaged, FileChange::Renamed, "moved c.txt")
}],
),
(
"unborn head",
UNBORN_STATUS,
Some("main"),
vec![changed(Staged, FileChange::Added, "a.txt")],
),
(
"merge conflict",
CONFLICT_STATUS,
Some("main"),
vec![changed(Unstaged, FileChange::Unmerged, "c.txt")],
),
(
"clean worktree",
b"# branch.head main\0".as_slice(),
Some("main"),
vec![],
),
];
for (name, bytes, branch, files) in cases {
assert_eq!(
parse_status(bytes).unwrap(),
(branch.map(str::to_string), files),
"{name}"
);
}
}
#[test]
fn parse_numstat_keys_counts_by_new_path() {
let cases: [(&str, &[u8], NumstatMap); 3] = [
(
"staged mixed worktree",
MIXED_STAGED_NUMSTAT,
HashMap::from([
("a.txt".into(), Some((1, 0))),
("added.txt".into(), Some((1, 0))),
("bin.dat".into(), None),
("del.txt".into(), Some((0, 1))),
("new [name].txt".into(), Some((0, 0))),
]),
),
(
"merge conflict",
CONFLICT_NUMSTAT,
HashMap::from([("c.txt".into(), Some((4, 0)))]),
),
("no changes", b"".as_slice(), HashMap::new()),
];
for (name, bytes, expected) in cases {
assert_eq!(parse_numstat(bytes).unwrap(), expected, "{name}");
}
}
#[test]
fn parsers_reject_malformed_records() {
let status_cases: [&[u8]; 3] = [
b"2 R. N... 100644 100644 100644 aaaa aaaa R100 new.txt\0",
b"1 Z. N... 100644 100644 100644 aaaa aaaa a.txt\0",
b"1 M. N... 100644\0",
];
for bytes in status_cases {
assert!(
parse_status(bytes).is_err(),
"{}",
String::from_utf8_lossy(bytes)
);
}
let numstat_cases: [&[u8]; 3] = [b"x\t0\ta.txt\0", b"1\t0\0", b"0\t0\t\0old.txt\0"];
for bytes in numstat_cases {
assert!(
parse_numstat(bytes).is_err(),
"{}",
String::from_utf8_lossy(bytes)
);
}
}
#[cfg(unix)]
#[test]
fn command_output_keeps_first_stderr_line_only() {
use std::os::unix::process::ExitStatusExt;
let output = Output {
status: std::process::ExitStatus::from_raw(256),
stdout: Vec::new(),
stderr: b"fatal: not a git repository\nStopping at filesystem boundary (GIT_DISCOVERY_ACROSS_FILESYSTEM not set).\n".to_vec(),
};
let error = command_output(output).unwrap_err();
assert_eq!(error.to_string(), "fatal: not a git repository");
}
fn run_git(cwd: &Path, args: &[&str]) {
let status = Command::new("git")
.args(["-c", "user.name=rho", "-c", "user.email=rho@example.com"])
.args(args)
.current_dir(cwd)
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_SYSTEM", "/dev/null")
.status()
.expect("git should be available for local diff tests");
assert!(status.success(), "git {args:?} failed");
}
fn patch_summary(patch: &str) -> Vec<&str> {
patch
.lines()
.filter(|line| {
line.starts_with("diff --git ")
|| (line.starts_with('+') && !line.starts_with("+++"))
|| (line.starts_with('-') && !line.starts_with("---"))
})
.collect()
}
#[test]
fn collect_status_and_file_patch_cover_whole_repo_from_subdirectory() {
use super::DiffSection::{Staged, Unstaged, Untracked};
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
run_git(root, &["init", "-q", "-b", "main"]);
std::fs::write(root.join("a.txt"), "a\n").unwrap();
std::fs::write(root.join("[a].txt"), "x\n").unwrap();
std::fs::write(root.join("old.txt"), "one\ntwo\nthree\n").unwrap();
run_git(root, &["add", "."]);
run_git(root, &["commit", "-q", "-m", "init"]);
std::fs::write(root.join("a.txt"), "a\nb\n").unwrap();
run_git(root, &["add", "a.txt"]);
std::fs::write(root.join("a.txt"), "a\nb\nc\n").unwrap();
std::fs::write(root.join("[a].txt"), "y\n").unwrap();
run_git(root, &["mv", "old.txt", "new name.txt"]);
std::fs::create_dir(root.join("sub dir")).unwrap();
std::fs::write(root.join("sub dir/new file.txt"), "hi\n").unwrap();
let status = collect_status(&root.join("sub dir")).unwrap();
assert_eq!(
status.repo_root.canonicalize().unwrap(),
root.canonicalize().unwrap()
);
let expected_files = vec![
ChangedFile {
stats: Some((1, 0)),
..changed(Staged, FileChange::Modified, "a.txt")
},
ChangedFile {
orig_path: Some("old.txt".into()),
stats: Some((0, 0)),
..changed(Staged, FileChange::Renamed, "new name.txt")
},
ChangedFile {
stats: Some((1, 1)),
..changed(Unstaged, FileChange::Modified, "[a].txt")
},
ChangedFile {
stats: Some((1, 0)),
..changed(Unstaged, FileChange::Modified, "a.txt")
},
changed(Untracked, FileChange::Untracked, "sub dir/new file.txt"),
];
assert_eq!(
(status.branch.as_deref(), &status.files),
(Some("main"), &expected_files)
);
let patches: Vec<String> = status
.files
.iter()
.map(|file| file_patch(&status.repo_root, file).unwrap())
.collect();
let summaries: Vec<Vec<&str>> = patches.iter().map(|patch| patch_summary(patch)).collect();
assert_eq!(
summaries,
vec![
vec!["diff --git a/a.txt b/a.txt", "+b"],
vec!["diff --git a/old.txt b/new name.txt"],
vec!["diff --git a/[a].txt b/[a].txt", "-x", "+y"],
vec!["diff --git a/a.txt b/a.txt", "+c"],
vec![
"diff --git a/sub dir/new file.txt b/sub dir/new file.txt",
"+hi"
],
]
);
}