use std::io::Write; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use forge_git::history::{ ArchiveFormat, CommitDiff, DiffLimits, DiffStatus, FileDiff, LineKind, archive_command, commit_diff, last_commits, log, }; use forge_git::{Error, GitEnv}; const BIG: DiffLimits = DiffLimits { max_files: 1000, max_lines_per_file: 10_000, max_total_lines: 100_000, }; struct Fixture { _dir: tempfile::TempDir, work: PathBuf, git_dir: PathBuf, env: GitEnv, } fn git_in(dir: &Path, home: &Path, args: &[&str]) -> String { let out = Command::new("/usr/bin/git") .arg("-C") .arg(dir) .args([ "-c", "user.name=Tester", "-c", "user.email=tester@example.com", "-c", "commit.gpgsign=false", ]) .args(args) .env_clear() .env("PATH", "/usr/bin:/bin") .env("HOME", home) .env("GIT_CONFIG_NOSYSTEM", "1") .env("GIT_CONFIG_GLOBAL", "/dev/null") .output() .unwrap(); assert!( out.status.success(), "git {args:?} failed: {}", String::from_utf8_lossy(&out.stderr) ); String::from_utf8_lossy(&out.stdout).into_owned() } fn fixture() -> Fixture { let dir = tempfile::tempdir().unwrap(); let work = dir.path().join("work"); std::fs::create_dir(&work).unwrap(); git_in(&work, dir.path(), &["init", "-q", "-b", "main"]); let env = GitEnv { git_bin: PathBuf::from("/usr/bin/git"), data_dir: dir.path().to_path_buf(), }; let git_dir = work.join(".git"); Fixture { _dir: dir, work, git_dir, env, } } impl Fixture { fn git(&self, args: &[&str]) -> String { git_in(&self.work, self._dir.path(), args) } fn write(&self, name: &str, content: impl AsRef<[u8]>) { let p = self.work.join(name); if let Some(parent) = p.parent() { std::fs::create_dir_all(parent).unwrap(); } std::fs::write(p, content).unwrap(); } /// Stages everything, commits, and returns the new full oid. fn commit(&self, msg: &str) -> String { self.git(&["add", "-A"]); self.git(&["commit", "-q", "--allow-empty", "-m", msg]); self.git(&["rev-parse", "HEAD"]).trim().to_owned() } } fn find<'a>(diff: &'a CommitDiff, path: &str) -> &'a FileDiff { diff.files .iter() .find(|f| f.new_path.as_deref() == Some(path) || f.old_path.as_deref() == Some(path)) .unwrap_or_else(|| panic!("no file {path} in diff")) } fn summaries(commits: &[forge_git::browse::CommitInfo]) -> Vec<&str> { commits.iter().map(|c| c.summary.as_str()).collect() } #[tokio::test] async fn log_path_filter_and_pagination() { let f = fixture(); f.write("a.txt", "1\n"); f.commit("add a"); f.write("b.txt", "1\n"); f.commit("add b"); f.write("a.txt", "2\n"); f.commit("change a"); f.write("b.txt", "2\n"); let head = f.commit("change b\n\nbody with \"quotes\"\n"); let all = log(&f.env, &f.git_dir, &head, None, 0, 10).await.unwrap(); assert_eq!(summaries(&all), ["change b", "change a", "add b", "add a"]); assert!(all[0].message.contains("body with \"quotes\"")); assert_eq!(all[0].parents, vec![all[1].oid.clone()]); assert!(all[3].parents.is_empty()); let only_a = log(&f.env, &f.git_dir, &head, Some("a.txt"), 0, 10) .await .unwrap(); assert_eq!(summaries(&only_a), ["change a", "add a"]); let page = log(&f.env, &f.git_dir, &head, None, 1, 2).await.unwrap(); assert_eq!(summaries(&page), ["change a", "add b"]); let page_a = log(&f.env, &f.git_dir, &head, Some("a.txt"), 1, 1) .await .unwrap(); assert_eq!(summaries(&page_a), ["add a"]); } #[tokio::test] async fn commit_diff_modify_add_delete_rename_binary() { let f = fixture(); f.write("a.txt", "one\ntwo\n"); f.write("gone.txt", "bye\n"); f.write("old.txt", "alpha\nbeta\ngamma\ndelta\nepsilon\n"); f.commit("base"); f.write("a.txt", "one\n2\n"); f.write("new.txt", "fresh\n"); f.write("bin.dat", b"\0\x01\x02binary"); f.git(&["rm", "-q", "gone.txt"]); f.git(&["mv", "old.txt", "moved.txt"]); let oid = f.commit("mixed"); let diff = commit_diff(&f.env, &f.git_dir, &oid, BIG).await.unwrap(); assert!(!diff.truncated_files); assert_eq!(diff.files.len(), 5); let a = find(&diff, "a.txt"); assert_eq!(a.status, DiffStatus::Modified); assert_eq!((a.additions, a.deletions), (1, 1)); assert!(!a.is_binary); assert_eq!(a.hunks.len(), 1); assert!(!a.truncated); let new = find(&diff, "new.txt"); assert_eq!(new.status, DiffStatus::Added); assert_eq!(new.old_path, None); assert_eq!(new.additions, 1); let gone = find(&diff, "gone.txt"); assert_eq!(gone.status, DiffStatus::Deleted); assert_eq!(gone.new_path, None); assert_eq!(gone.deletions, 1); let moved = find(&diff, "moved.txt"); assert_eq!(moved.status, DiffStatus::Renamed); assert_eq!(moved.old_path.as_deref(), Some("old.txt")); assert_eq!(moved.new_path.as_deref(), Some("moved.txt")); assert!(moved.hunks.is_empty()); let bin = find(&diff, "bin.dat"); assert_eq!(bin.status, DiffStatus::Added); assert!(bin.is_binary); assert!(bin.hunks.is_empty()); } #[tokio::test] async fn commit_diff_line_numbers_on_known_hunk() { let f = fixture(); f.write("n.txt", "l1\nl2\nl3\nl4\nl5\n"); f.commit("base"); f.write("n.txt", "l1\nl2\nX3\nl4\nl5\nl6\n"); let oid = f.commit("edit"); let diff = commit_diff(&f.env, &f.git_dir, &oid, BIG).await.unwrap(); let file = find(&diff, "n.txt"); assert_eq!(file.hunks.len(), 1); assert!(file.hunks[0].header.starts_with("@@ -1,5 +1,6 @@")); let got: Vec<(LineKind, Option, Option, &str)> = file.hunks[0] .lines .iter() .map(|l| (l.kind, l.old_no, l.new_no, l.text.as_str())) .collect(); assert_eq!( got, vec![ (LineKind::Context, Some(1), Some(1), "l1"), (LineKind::Context, Some(2), Some(2), "l2"), (LineKind::Del, Some(3), None, "l3"), (LineKind::Add, None, Some(3), "X3"), (LineKind::Context, Some(4), Some(4), "l4"), (LineKind::Context, Some(5), Some(5), "l5"), (LineKind::Add, None, Some(6), "l6"), ] ); assert_eq!((file.additions, file.deletions), (2, 1)); } #[tokio::test] async fn commit_diff_root_commit_uses_empty_tree() { let f = fixture(); f.write("a.txt", "x\ny\n"); f.write("d/b.txt", "z\n"); let oid = f.commit("root"); let diff = commit_diff(&f.env, &f.git_dir, &oid, BIG).await.unwrap(); assert_eq!(diff.files.len(), 2); for file in &diff.files { assert_eq!(file.status, DiffStatus::Added); assert_eq!(file.deletions, 0); } let a = find(&diff, "a.txt"); assert_eq!(a.additions, 2); assert_eq!(a.hunks[0].header, "@@ -0,0 +1,2 @@"); } #[tokio::test] async fn commit_diff_limits_truncate() { let f = fixture(); let old: String = (0..10).map(|i| format!("old{i}\n")).collect(); f.write("big.txt", &old); f.commit("base"); let new: String = (0..10).map(|i| format!("new{i}\n")).collect(); f.write("big.txt", &new); f.write("f1.txt", "x\n"); f.write("f2.txt", "x\n"); f.write("f3.txt", "x\n"); let oid = f.commit("rewrite"); let limits = DiffLimits { max_files: 2, max_lines_per_file: 3, max_total_lines: 100_000, }; let diff = commit_diff(&f.env, &f.git_dir, &oid, limits).await.unwrap(); assert!(diff.truncated_files); assert_eq!(diff.files.len(), 2); let big = find(&diff, "big.txt"); assert!(big.truncated); assert_eq!((big.additions, big.deletions), (10, 10)); let kept: usize = big.hunks.iter().map(|h| h.lines.len()).sum(); assert_eq!(kept, 3); let limits = DiffLimits { max_files: 10, max_lines_per_file: 100, max_total_lines: 4, }; let diff = commit_diff(&f.env, &f.git_dir, &oid, limits).await.unwrap(); assert!(!diff.truncated_files); let total: usize = diff .files .iter() .flat_map(|f| f.hunks.iter()) .map(|h| h.lines.len()) .sum(); assert_eq!(total, 4); let last = find(&diff, "f3.txt"); assert!(last.truncated); assert!(last.hunks.is_empty()); assert_eq!(last.additions, 1); } #[tokio::test] async fn last_commits_finds_newest_touch_per_child() { let f = fixture(); f.write("d/x.txt", "1\n"); f.commit("c1 add x"); f.write("d/y.txt", "1\n"); let c2 = f.commit("c2 add y"); f.write("d/z.txt", "1\n"); let c3 = f.commit("c3 add z"); f.write("d/x.txt", "2\n"); let c4 = f.commit("c4 change x"); f.write("d/sub/w.txt", "1\n"); let c5 = f.commit("c5 add sub/w"); f.write("other.txt", "1\n"); let c6 = f.commit("c6 other"); let names: Vec = ["x.txt", "y.txt", "z.txt", "sub", "missing"] .iter() .map(|s| s.to_string()) .collect(); let map = last_commits(&f.env, &f.git_dir, &c6, "d", &names, 100) .await .unwrap(); assert_eq!(map.len(), 4); assert_eq!(map["x.txt"].oid, c4); assert_eq!(map["y.txt"].oid, c2); assert_eq!(map["z.txt"].oid, c3); assert_eq!(map["sub"].oid, c5); assert!(!map.contains_key("missing")); let root_names: Vec = vec!["d".into(), "other.txt".into()]; let root = last_commits(&f.env, &f.git_dir, &c6, "", &root_names, 100) .await .unwrap(); assert_eq!(root["d"].oid, c5); assert_eq!(root["other.txt"].oid, c6); // max_walk counts commits in the pathspec-limited walk: c5 then c4. let short = last_commits(&f.env, &f.git_dir, &c6, "d", &names, 1) .await .unwrap(); assert_eq!(short.len(), 1); assert_eq!(short["sub"].oid, c5); } #[tokio::test] async fn archive_command_produces_listable_tar_gz() { let f = fixture(); f.write("a.txt", "hello\n"); f.write("d/b.txt", "world\n"); let oid = f.commit("archive me"); let cmd = archive_command(&f.env, &f.git_dir, &oid, ArchiveFormat::TarGz, "proj-1").unwrap(); let out = cmd.into_tokio().output().await.unwrap(); assert!(out.status.success()); let mut tar = Command::new("tar") .arg("-tz") .stdin(Stdio::piped()) .stdout(Stdio::piped()) .spawn() .unwrap(); if let Some(mut stdin) = tar.stdin.take() { stdin.write_all(&out.stdout).unwrap(); } let listing = tar.wait_with_output().unwrap(); assert!(listing.status.success()); let names = String::from_utf8_lossy(&listing.stdout); assert!(names.lines().any(|l| l == "proj-1/a.txt"), "{names}"); assert!(names.lines().any(|l| l == "proj-1/d/b.txt"), "{names}"); } #[tokio::test] async fn non_hex_commit_arguments_are_rejected() { let f = fixture(); f.write("a.txt", "1\n"); let oid = f.commit("one"); let bad_oid = "--output=/tmp/pwned"; assert!(matches!( log(&f.env, &f.git_dir, bad_oid, None, 0, 10).await, Err(Error::InvalidInput(_)) )); assert!(matches!( log(&f.env, &f.git_dir, "HEAD", None, 0, 10).await, Err(Error::InvalidInput(_)) )); assert!(matches!( commit_diff(&f.env, &f.git_dir, "main", BIG).await, Err(Error::InvalidInput(_)) )); assert!(matches!( last_commits(&f.env, &f.git_dir, "g".repeat(40).as_str(), "", &[], 10).await, Err(Error::InvalidInput(_)) )); assert!(matches!( archive_command(&f.env, &f.git_dir, bad_oid, ArchiveFormat::Zip, "p"), Err(Error::InvalidInput(_)) )); assert!(matches!( archive_command(&f.env, &f.git_dir, &oid, ArchiveFormat::TarGz, "../evil"), Err(Error::InvalidInput(_)) )); assert!(matches!( log(&f.env, &f.git_dir, &oid, Some("../escape"), 0, 10).await, Err(Error::InvalidInput(_)) )); }