use std::fmt::Write as _;
use std::io::{Read as _, Write as _};
use std::path::Path;
use std::process::{Command, Stdio};
use git_stats::app;
use git_stats::model::{Options, SortBy};
use git_stats::repo::Repo;
fn git(dir: &Path, args: &[&str]) {
let status = Command::new("git")
.current_dir(dir)
.args(args)
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_SYSTEM", "/dev/null")
.status()
.expect("git should be installed");
assert!(status.success(), "git {args:?} failed");
}
fn git_out(dir: &Path, args: &[&str]) -> String {
let out = Command::new("git")
.current_dir(dir)
.args(args)
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_SYSTEM", "/dev/null")
.output()
.expect("git should be installed");
assert!(out.status.success(), "git {args:?} failed");
String::from_utf8(out.stdout).unwrap().trim().to_string()
}
fn options(range: &str, reviews: bool) -> Options {
Options {
range: range.to_string(),
email: false,
reviews,
sort: SortBy::Commits,
reverse: false,
authors: Vec::new(),
since: None,
until: None,
}
}
fn row<'a>(out: &'a str, label: &str) -> &'a str {
out.lines()
.find(|l| l.trim_start().starts_with(label))
.unwrap_or_else(|| panic!("no row starting with {label:?} in:\n{out}"))
}
fn report(repo: &Repo, opts: &Options) -> String {
yansi::disable();
app::run(repo, opts).unwrap()
}
fn git_available() -> bool {
Command::new("git").arg("--version").output().is_ok()
}
#[test]
fn reads_a_real_repository() {
if !git_available() {
eprintln!("git not available; skipping integration test");
return;
}
let dir = tempfile::tempdir().unwrap();
let p = dir.path();
git(p, &["init", "-q", "-b", "main"]);
git(p, &["config", "user.name", "Ada"]);
git(p, &["config", "user.email", "ada@example.com"]);
std::fs::write(p.join("a.txt"), "line1\nline2\n").unwrap();
git(p, &["add", "."]);
git(
p,
&[
"commit",
"-q",
"-m",
"first",
"-m",
"Reviewed-by: Rev Iewer <rev@example.com>",
],
);
std::fs::write(p.join("a.txt"), "line1\nline2\nline3\n").unwrap();
git(p, &["add", "."]);
git(p, &["commit", "-q", "-m", "second"]);
let repo = Repo::open(p).unwrap();
assert!(!repo.is_shallow(), "full repository detected as shallow");
let out = report(&repo, &options("HEAD", true));
let ada = row(&out, "Ada");
assert!(ada.contains("+3"), "ada row: {ada}");
assert!(out.contains("Rev Iewer"), "reviews missing:\n{out}");
}
#[test]
fn range_excludes_commits_on_the_excluded_side() {
if !git_available() {
eprintln!("git not available; skipping integration test");
return;
}
let dir = tempfile::tempdir().unwrap();
let p = dir.path();
git(p, &["init", "-q", "-b", "main"]);
git(p, &["config", "user.name", "Ada"]);
git(p, &["config", "user.email", "ada@example.com"]);
std::fs::write(p.join("a.txt"), "a\n").unwrap();
git(p, &["add", "."]);
git(p, &["commit", "-q", "-m", "base on main"]);
git(p, &["checkout", "-q", "-b", "feature"]);
std::fs::write(p.join("b.txt"), "b\n").unwrap();
git(p, &["add", "."]);
git(p, &["commit", "-q", "-m", "feature 1"]);
std::fs::write(p.join("c.txt"), "c\n").unwrap();
git(p, &["add", "."]);
git(p, &["commit", "-q", "-m", "feature 2"]);
let repo = Repo::open(p).unwrap();
let out = report(&repo, &options("main..feature", false));
let total = row(&out, "Total");
assert_eq!(
total.split_whitespace().nth(1),
Some("2"),
"main..feature should count only the 2 feature commits:\n{out}"
);
}
#[test]
fn symmetric_difference_range_excludes_the_common_ancestor() {
if !git_available() {
eprintln!("git not available; skipping integration test");
return;
}
let dir = tempfile::tempdir().unwrap();
let p = dir.path();
git(p, &["init", "-q", "-b", "main"]);
git(p, &["config", "user.name", "Ada"]);
git(p, &["config", "user.email", "ada@example.com"]);
std::fs::write(p.join("a.txt"), "a\n").unwrap();
git(p, &["add", "."]);
git(p, &["commit", "-q", "-m", "base"]);
git(p, &["checkout", "-q", "-b", "feature"]);
std::fs::write(p.join("b.txt"), "b\n").unwrap();
git(p, &["add", "."]);
git(p, &["commit", "-q", "-m", "feature only"]);
git(p, &["checkout", "-q", "main"]);
std::fs::write(p.join("c.txt"), "c\n").unwrap();
git(p, &["add", "."]);
git(p, &["commit", "-q", "-m", "main only"]);
let repo = Repo::open(p).unwrap();
let out = report(&repo, &options("main...feature", false));
let total = row(&out, "Total");
assert_eq!(
total.split_whitespace().nth(1),
Some("2"),
"main...feature should count the 2 divergent commits:\n{out}"
);
}
#[test]
fn symmetric_difference_hides_every_merge_base() {
if !git_available() {
eprintln!("git not available; skipping integration test");
return;
}
let dir = tempfile::tempdir().unwrap();
let p = dir.path();
git(p, &["init", "-q", "-b", "main"]);
git(p, &["config", "user.name", "Ada"]);
git(p, &["config", "user.email", "ada@example.com"]);
std::fs::write(p.join("base.txt"), "base\n").unwrap();
git(p, &["add", "."]);
git(p, &["commit", "-q", "-m", "base"]);
git(p, &["checkout", "-q", "-b", "br-a"]);
std::fs::write(p.join("a.txt"), "a\n").unwrap();
git(p, &["add", "."]);
git(p, &["commit", "-q", "-m", "A"]);
git(p, &["tag", "a-tip"]);
git(p, &["checkout", "-q", "main"]);
git(p, &["checkout", "-q", "-b", "br-b"]);
std::fs::write(p.join("b.txt"), "b\n").unwrap();
git(p, &["add", "."]);
git(p, &["commit", "-q", "-m", "B"]);
git(p, &["checkout", "-q", "br-a"]);
git(p, &["merge", "-q", "--no-ff", "--no-edit", "br-b"]);
git(p, &["checkout", "-q", "br-b"]);
git(p, &["merge", "-q", "--no-ff", "--no-edit", "a-tip"]);
let repo = Repo::open(p).unwrap();
let out = report(&repo, &options("br-a...br-b", false));
assert_eq!(
total_commits(&out),
Some("2"),
"criss-cross merge bases must all be hidden:\n{out}"
);
}
fn tag_fixture() -> (tempfile::TempDir, Repo) {
let dir = tempfile::tempdir().unwrap();
let p = dir.path();
git(p, &["init", "-q", "-b", "main"]);
git(p, &["config", "user.name", "Ada"]);
git(p, &["config", "user.email", "ada@example.com"]);
for name in ["a", "b", "c"] {
std::fs::write(p.join(name).with_extension("txt"), "x\n").unwrap();
git(p, &["add", "."]);
git(p, &["commit", "-q", "-m", name]);
}
git(p, &["tag", "-a", "-m", "release", "annotated", "HEAD~1"]);
git(p, &["tag", "lightweight", "HEAD~1"]);
let repo = Repo::open(p).unwrap();
(dir, repo)
}
fn total_commits(out: &str) -> Option<&str> {
row(out, "Total").split_whitespace().nth(1)
}
#[test]
fn lightweight_tag_range_is_unaffected() {
if !git_available() {
eprintln!("git not available; skipping integration test");
return;
}
let (_dir, repo) = tag_fixture();
let out = report(&repo, &options("lightweight..HEAD", false));
assert_eq!(
total_commits(&out),
Some("1"),
"lightweight..HEAD should count only the commit after the tag:\n{out}"
);
}
#[test]
fn annotated_tag_range_excludes_commits_behind_the_tag() {
if !git_available() {
eprintln!("git not available; skipping integration test");
return;
}
let (_dir, repo) = tag_fixture();
let out = report(&repo, &options("annotated..HEAD", false));
assert_eq!(
total_commits(&out),
Some("1"),
"annotated..HEAD should count only the commit after the tag:\n{out}"
);
}
#[test]
fn annotated_tag_on_the_inclusion_side_resolves() {
if !git_available() {
eprintln!("git not available; skipping integration test");
return;
}
let (_dir, repo) = tag_fixture();
let out = report(&repo, &options("HEAD~2..annotated", false));
assert_eq!(
total_commits(&out),
Some("1"),
"HEAD~2..annotated should count only the tagged commit:\n{out}"
);
}
#[test]
fn a_bare_annotated_tag_resolves_to_its_target_commit() {
if !git_available() {
eprintln!("git not available; skipping integration test");
return;
}
let (dir, repo) = tag_fixture();
let out = report(&repo, &options("annotated", false));
assert_eq!(
total_commits(&out),
Some("2"),
"a bare annotated tag should walk from its target commit:\n{out}"
);
git(
dir.path(),
&["tag", "-a", "-m", "wrap", "nested", "annotated"],
);
let out = report(&repo, &options("nested", false));
assert_eq!(
total_commits(&out),
Some("2"),
"a tag-to-tag chain should peel to the same commit:\n{out}"
);
}
#[test]
fn symmetric_difference_with_an_annotated_tag_resolves() {
if !git_available() {
eprintln!("git not available; skipping integration test");
return;
}
let dir = tempfile::tempdir().unwrap();
let p = dir.path();
git(p, &["init", "-q", "-b", "main"]);
git(p, &["config", "user.name", "Ada"]);
git(p, &["config", "user.email", "ada@example.com"]);
std::fs::write(p.join("a.txt"), "a\n").unwrap();
git(p, &["add", "."]);
git(p, &["commit", "-q", "-m", "base"]);
git(p, &["checkout", "-q", "-b", "feature"]);
std::fs::write(p.join("b.txt"), "b\n").unwrap();
git(p, &["add", "."]);
git(p, &["commit", "-q", "-m", "feature only"]);
git(p, &["tag", "-a", "-m", "release", "feature-tag"]);
git(p, &["checkout", "-q", "main"]);
std::fs::write(p.join("c.txt"), "c\n").unwrap();
git(p, &["add", "."]);
git(p, &["commit", "-q", "-m", "main only"]);
let repo = Repo::open(p).unwrap();
let out = report(&repo, &options("main...feature-tag", false));
assert_eq!(
total_commits(&out),
Some("2"),
"main...feature-tag should count the 2 divergent commits:\n{out}"
);
}
#[test]
fn filter_validation_precedes_the_walk() {
if !git_available() {
eprintln!("git not available; skipping integration test");
return;
}
let dir = tempfile::tempdir().unwrap();
let p = dir.path();
git(p, &["init", "-q", "-b", "main"]);
git(p, &["config", "user.name", "Ada"]);
git(p, &["config", "user.email", "ada@example.com"]);
std::fs::write(p.join("a.txt"), "a\n").unwrap();
git(p, &["add", "."]);
git(p, &["commit", "-q", "-m", "base"]);
let repo = Repo::open(p).unwrap();
let mut opts = options("no-such-ref..HEAD", false);
opts.since = Some("not-a-date".to_string());
let err = app::run(&repo, &opts).unwrap_err();
assert!(
matches!(err, git_stats::Error::InvalidDate { .. }),
"expected InvalidDate before any range resolution, got: {err:?}"
);
}
#[test]
fn binary_file_changes_count_as_changed_files() {
if !git_available() {
eprintln!("git not available; skipping integration test");
return;
}
let dir = tempfile::tempdir().unwrap();
let p = dir.path();
git(p, &["init", "-q", "-b", "main"]);
git(p, &["config", "user.name", "Ada"]);
git(p, &["config", "user.email", "ada@example.com"]);
std::fs::write(p.join("a.txt"), "one\ntwo\n").unwrap();
std::fs::write(p.join("blob.bin"), [0u8, 159, 146, 150, 0, 10]).unwrap();
git(p, &["add", "."]);
git(p, &["commit", "-q", "-m", "add text and binary"]);
let repo = Repo::open(p).unwrap();
let out = report(&repo, &options("HEAD", false));
let cols: Vec<&str> = row(&out, "Total").split_whitespace().collect();
assert_eq!(
cols[2], "2",
"the binary file should count as a changed file:\n{out}"
);
assert_eq!(
cols[3], "+2",
"only the text file should contribute lines:\n{out}"
);
}
#[test]
fn broken_pipe_exits_quietly() {
if !git_available() {
eprintln!("git not available; skipping integration test");
return;
}
let dir = tempfile::tempdir().unwrap();
let p = dir.path();
git(p, &["init", "-q", "-b", "main"]);
let mut input = String::new();
for i in 0..2000 {
let msg = format!("c{i}");
write!(
input,
"commit refs/heads/main\ncommitter Author Number {i:04} \
<author{i:04}@example.com> {} +0000\ndata {}\n{msg}\n",
1_600_000_000 + i,
msg.len(),
)
.unwrap();
}
let mut fast_import = Command::new("git")
.current_dir(p)
.args(["fast-import", "--quiet"])
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_SYSTEM", "/dev/null")
.stdin(Stdio::piped())
.stdout(Stdio::null())
.spawn()
.unwrap();
let mut stdin = fast_import.stdin.take().unwrap();
stdin.write_all(input.as_bytes()).unwrap();
drop(stdin);
assert!(fast_import.wait().unwrap().success(), "fast-import failed");
let mut child = Command::new(env!("CARGO_BIN_EXE_git-stats"))
.current_dir(p)
.arg("--email")
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
drop(child.stdout.take());
let status = child.wait().unwrap();
let mut stderr = String::new();
child
.stderr
.take()
.unwrap()
.read_to_string(&mut stderr)
.unwrap();
assert!(
!stderr.contains("panicked"),
"panicked on closed stdout:\n{stderr}"
);
assert_eq!(
status.code(),
Some(141),
"expected git-style SIGPIPE exit, stderr:\n{stderr}"
);
}
#[test]
fn git_dir_environment_override_is_honored() {
if !git_available() {
eprintln!("git not available; skipping integration test");
return;
}
let dir = tempfile::tempdir().unwrap();
let p = dir.path();
let repo_dir = p.join("repo");
std::fs::create_dir(&repo_dir).unwrap();
git(&repo_dir, &["init", "-q", "-b", "main"]);
git(&repo_dir, &["config", "user.name", "Ada"]);
git(&repo_dir, &["config", "user.email", "ada@example.com"]);
std::fs::write(repo_dir.join("a.txt"), "a\n").unwrap();
git(&repo_dir, &["add", "."]);
git(&repo_dir, &["commit", "-q", "-m", "c1"]);
let elsewhere = p.join("elsewhere");
std::fs::create_dir(&elsewhere).unwrap();
let out = Command::new(env!("CARGO_BIN_EXE_git-stats"))
.current_dir(&elsewhere)
.env("GIT_DIR", repo_dir.join(".git"))
.output()
.unwrap();
assert!(
out.status.success(),
"GIT_DIR run failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8(out.stdout).unwrap();
assert!(
stdout.contains("Ada"),
"report should show the GIT_DIR repository's author:\n{stdout}"
);
}
#[test]
fn mailmap_folds_identities_like_git_shortlog() {
if !git_available() {
eprintln!("git not available; skipping integration test");
return;
}
let dir = tempfile::tempdir().unwrap();
let p = dir.path();
git(p, &["init", "-q", "-b", "main"]);
git(p, &["config", "user.name", "Old Name"]);
git(p, &["config", "user.email", "old@example.com"]);
std::fs::write(p.join("a.txt"), "a\n").unwrap();
git(p, &["add", "."]);
git(p, &["commit", "-q", "-m", "as old identity"]);
std::fs::write(
p.join(".mailmap"),
"New Name <new@example.com> <old@example.com>\n",
)
.unwrap();
git(p, &["add", "."]);
git(p, &["commit", "-q", "-m", "add mailmap"]);
git(p, &["config", "user.name", "Other"]);
git(p, &["config", "user.email", "other@example.com"]);
std::fs::write(p.join("b.txt"), "b\n").unwrap();
git(p, &["add", "."]);
git(p, &["commit", "-q", "-m", "untouched author"]);
let repo = Repo::open(p).unwrap();
let mut opts = options("HEAD", false);
opts.email = true;
let out = report(&repo, &opts);
let new = row(&out, "New Name <new@example.com>");
assert_eq!(
new.split_whitespace().nth(3),
Some("2"),
"both old-identity commits should fold into the mapped one:\n{out}"
);
assert!(
!out.contains("old@example.com"),
"unmapped identity leaked:\n{out}"
);
assert!(
out.contains("Other <other@example.com>"),
"unmapped author should pass through:\n{out}"
);
}
#[test]
fn submodule_changes_count_like_git_numstat() {
if !git_available() {
eprintln!("git not available; skipping integration test");
return;
}
let dir = tempfile::tempdir().unwrap();
let p = dir.path();
let inner = p.join("inner");
std::fs::create_dir(&inner).unwrap();
git(&inner, &["init", "-q", "-b", "main"]);
git(&inner, &["config", "user.name", "Ada"]);
git(&inner, &["config", "user.email", "ada@example.com"]);
std::fs::write(inner.join("x.txt"), "x\n").unwrap();
git(&inner, &["add", "."]);
git(&inner, &["commit", "-q", "-m", "inner 1"]);
let sha1 = git_out(&inner, &["rev-parse", "HEAD"]);
std::fs::write(inner.join("y.txt"), "y\n").unwrap();
git(&inner, &["add", "."]);
git(&inner, &["commit", "-q", "-m", "inner 2"]);
let sha2 = git_out(&inner, &["rev-parse", "HEAD"]);
let outer = p.join("outer");
std::fs::create_dir(&outer).unwrap();
git(&outer, &["init", "-q", "-b", "main"]);
git(&outer, &["config", "user.name", "Ada"]);
git(&outer, &["config", "user.email", "ada@example.com"]);
std::fs::write(outer.join("r.txt"), "readme\n").unwrap();
git(&outer, &["add", "."]);
git(&outer, &["commit", "-q", "-m", "base"]);
let link = format!("160000,{sha1},sub");
git(&outer, &["update-index", "--add", "--cacheinfo", &link]);
git(&outer, &["commit", "-q", "-m", "add sub"]);
let link = format!("160000,{sha2},sub");
git(&outer, &["update-index", "--add", "--cacheinfo", &link]);
git(&outer, &["commit", "-q", "-m", "bump sub"]);
let repo = Repo::open(&outer).unwrap();
let out = report(&repo, &options("HEAD", false));
let cols: Vec<&str> = row(&out, "Total").split_whitespace().collect();
assert_eq!(
cols[2], "3",
"gitlink changes should count as changed files:\n{out}"
);
assert_eq!(cols[3], "+3", "expected +3 insertions:\n{out}");
assert_eq!(cols[4], "-1", "expected -1 deletions:\n{out}");
}
#[test]
fn gitlink_renames_count_as_add_plus_delete() {
if !git_available() {
eprintln!("git not available; skipping integration test");
return;
}
let dir = tempfile::tempdir().unwrap();
let p = dir.path();
let inner = p.join("inner");
std::fs::create_dir(&inner).unwrap();
git(&inner, &["init", "-q", "-b", "main"]);
git(&inner, &["config", "user.name", "Ada"]);
git(&inner, &["config", "user.email", "ada@example.com"]);
std::fs::write(inner.join("x.txt"), "x\n").unwrap();
git(&inner, &["add", "."]);
git(&inner, &["commit", "-q", "-m", "inner"]);
let sha = git_out(&inner, &["rev-parse", "HEAD"]);
let outer = p.join("outer");
std::fs::create_dir(&outer).unwrap();
git(&outer, &["init", "-q", "-b", "main"]);
git(&outer, &["config", "user.name", "Ada"]);
git(&outer, &["config", "user.email", "ada@example.com"]);
std::fs::write(outer.join("r.txt"), "readme\n").unwrap();
git(&outer, &["add", "."]);
git(&outer, &["commit", "-q", "-m", "base"]);
let link = format!("160000,{sha},sub");
git(&outer, &["update-index", "--add", "--cacheinfo", &link]);
git(&outer, &["commit", "-q", "-m", "add sub"]);
git(&outer, &["update-index", "--force-remove", "sub"]);
let link = format!("160000,{sha},newsub");
git(&outer, &["update-index", "--add", "--cacheinfo", &link]);
git(&outer, &["commit", "-q", "-m", "rename sub"]);
let repo = Repo::open(&outer).unwrap();
let out = report(&repo, &options("HEAD", false));
let cols: Vec<&str> = row(&out, "Total").split_whitespace().collect();
assert_eq!(cols[2], "4", "rename should count as add + delete:\n{out}");
assert_eq!(cols[3], "+3", "expected +3 insertions:\n{out}");
assert_eq!(cols[4], "-1", "expected -1 deletions:\n{out}");
}
#[test]
fn shallow_clones_treat_boundary_commits_as_parentless() {
if !git_available() {
eprintln!("git not available; skipping integration test");
return;
}
let dir = tempfile::tempdir().unwrap();
let p = dir.path();
let origin = p.join("origin");
std::fs::create_dir(&origin).unwrap();
git(&origin, &["init", "-q", "-b", "main"]);
git(&origin, &["config", "user.name", "Ada"]);
git(&origin, &["config", "user.email", "ada@example.com"]);
let mut content = String::new();
for i in 1..=3 {
writeln!(content, "line{i}").unwrap();
std::fs::write(origin.join("f.txt"), &content).unwrap();
git(&origin, &["add", "."]);
git(&origin, &["commit", "-q", "-m", &format!("c{i}")]);
}
let url = format!("file://{}", origin.display());
git(p, &["clone", "-q", "--depth", "2", &url, "shallow"]);
let repo = Repo::open(p.join("shallow")).unwrap();
assert!(repo.is_shallow(), "depth-2 clone should detect as shallow");
let out = report(&repo, &options("HEAD", false));
let cols: Vec<&str> = row(&out, "Total").split_whitespace().collect();
assert_eq!(cols[1], "2", "both retained commits should count:\n{out}");
assert_eq!(
cols[3], "+3",
"the boundary commit should diff against the empty tree:\n{out}"
);
}
#[test]
fn shallow_boundary_merges_diff_like_root_commits() {
if !git_available() {
eprintln!("git not available; skipping integration test");
return;
}
let dir = tempfile::tempdir().unwrap();
let p = dir.path();
let origin = p.join("origin");
std::fs::create_dir(&origin).unwrap();
git(&origin, &["init", "-q", "-b", "main"]);
git(&origin, &["config", "user.name", "Ada"]);
git(&origin, &["config", "user.email", "ada@example.com"]);
std::fs::write(origin.join("a.txt"), "a\n").unwrap();
git(&origin, &["add", "."]);
git(&origin, &["commit", "-q", "-m", "base"]);
git(&origin, &["checkout", "-q", "-b", "feat"]);
std::fs::write(origin.join("b.txt"), "b\n").unwrap();
git(&origin, &["add", "."]);
git(&origin, &["commit", "-q", "-m", "feat"]);
git(&origin, &["checkout", "-q", "main"]);
std::fs::write(origin.join("c.txt"), "c\n").unwrap();
git(&origin, &["add", "."]);
git(&origin, &["commit", "-q", "-m", "main side"]);
git(&origin, &["merge", "-q", "--no-ff", "--no-edit", "feat"]);
std::fs::write(origin.join("d.txt"), "d\n").unwrap();
git(&origin, &["add", "."]);
git(&origin, &["commit", "-q", "-m", "top"]);
let url = format!("file://{}", origin.display());
git(p, &["clone", "-q", "--depth", "2", &url, "shallow"]);
let repo = Repo::open(p.join("shallow")).unwrap();
let out = report(&repo, &options("HEAD", false));
let cols: Vec<&str> = row(&out, "Total").split_whitespace().collect();
assert_eq!(cols[1], "2", "both retained commits should count:\n{out}");
assert_eq!(
cols[2], "4",
"the grafted merge should contribute its whole tree:\n{out}"
);
assert_eq!(cols[3], "+4", "expected +4 total insertions:\n{out}");
}
#[test]
fn decode_failures_name_the_offending_commit() {
if !git_available() {
eprintln!("git not available; skipping integration test");
return;
}
let dir = tempfile::tempdir().unwrap();
let p = dir.path();
git(p, &["init", "-q", "-b", "main"]);
git(p, &["config", "user.name", "Ada"]);
git(p, &["config", "user.email", "ada@example.com"]);
std::fs::write(p.join("a.txt"), "a\n").unwrap();
git(p, &["add", "."]);
git(p, &["commit", "-q", "-m", "good"]);
let tree = git_out(p, &["rev-parse", "HEAD^{tree}"]);
let parent = git_out(p, &["rev-parse", "HEAD"]);
let raw = format!(
"tree {tree}\nparent {parent}\nauthor Ada <ada@x> not-a-timestamp\n\
committer Ada <ada@x> not-a-timestamp\n\nbad date\n"
);
std::fs::write(p.join("raw-commit"), &raw).unwrap();
let bad = git_out(
p,
&[
"hash-object",
"--literally",
"-t",
"commit",
"-w",
"raw-commit",
],
);
git(p, &["update-ref", "refs/heads/main", &bad]);
git(p, &["commit-graph", "write", "--reachable"]);
let repo = Repo::open(p).unwrap();
let err = app::run(&repo, &options("HEAD", false)).unwrap_err();
assert!(
err.to_string().contains(&bad),
"error should name the undecodable commit {bad}: {err}"
);
}
#[test]
fn merge_commits_are_counted_but_add_no_lines() {
if !git_available() {
eprintln!("git not available; skipping integration test");
return;
}
let dir = tempfile::tempdir().unwrap();
let p = dir.path();
git(p, &["init", "-q", "-b", "main"]);
git(p, &["config", "user.name", "Ada"]);
git(p, &["config", "user.email", "ada@example.com"]);
std::fs::write(p.join("a.txt"), "a\n").unwrap();
git(p, &["add", "."]);
git(p, &["commit", "-q", "-m", "base"]);
git(p, &["checkout", "-q", "-b", "feature"]);
std::fs::write(p.join("b.txt"), "b\n").unwrap();
git(p, &["add", "."]);
git(p, &["commit", "-q", "-m", "feature"]);
git(p, &["checkout", "-q", "main"]);
std::fs::write(p.join("c.txt"), "c\n").unwrap();
git(p, &["add", "."]);
git(p, &["commit", "-q", "-m", "main"]);
git(p, &["merge", "--no-ff", "--no-edit", "feature"]);
let repo = Repo::open(p).unwrap();
let out = report(&repo, &options("HEAD", false));
let cols: Vec<&str> = row(&out, "Total").split_whitespace().collect();
assert_eq!(cols[1], "4", "merge commit should be counted:\n{out}");
assert_eq!(
cols[3], "+3",
"merge should contribute no insertions:\n{out}"
);
}