Tahinli/forge
public0 watching
★ 0
| 1 | use std::io::Write; |
| 2 | use std::path::{Path, PathBuf}; |
| 3 | use std::process::{Command, Stdio}; |
| 4 | |
| 5 | use forge_git::history::{ |
| 6 | ArchiveFormat, CommitDiff, DiffLimits, DiffStatus, FileDiff, LineKind, archive_command, |
| 7 | commit_diff, last_commits, log, |
| 8 | }; |
| 9 | use forge_git::{Error, GitEnv}; |
| 10 | |
| 11 | const BIG: DiffLimits = DiffLimits { |
| 12 | max_files: 1000, |
| 13 | max_lines_per_file: 10_000, |
| 14 | max_total_lines: 100_000, |
| 15 | }; |
| 16 | |
| 17 | struct Fixture { |
| 18 | _dir: tempfile::TempDir, |
| 19 | work: PathBuf, |
| 20 | git_dir: PathBuf, |
| 21 | env: GitEnv, |
| 22 | } |
| 23 | |
| 24 | fn git_in(dir: &Path, home: &Path, args: &[&str]) -> String { |
| 25 | let out = Command::new("/usr/bin/git") |
| 26 | .arg("-C") |
| 27 | .arg(dir) |
| 28 | .args([ |
| 29 | "-c", |
| 30 | "user.name=Tester", |
| 31 | "-c", |
| 32 | "user.email=tester@example.com", |
| 33 | "-c", |
| 34 | "commit.gpgsign=false", |
| 35 | ]) |
| 36 | .args(args) |
| 37 | .env_clear() |
| 38 | .env("PATH", "/usr/bin:/bin") |
| 39 | .env("HOME", home) |
| 40 | .env("GIT_CONFIG_NOSYSTEM", "1") |
| 41 | .env("GIT_CONFIG_GLOBAL", "/dev/null") |
| 42 | .output() |
| 43 | .unwrap(); |
| 44 | assert!( |
| 45 | out.status.success(), |
| 46 | "git {args:?} failed: {}", |
| 47 | String::from_utf8_lossy(&out.stderr) |
| 48 | ); |
| 49 | String::from_utf8_lossy(&out.stdout).into_owned() |
| 50 | } |
| 51 | |
| 52 | fn fixture() -> Fixture { |
| 53 | let dir = tempfile::tempdir().unwrap(); |
| 54 | let work = dir.path().join("work"); |
| 55 | std::fs::create_dir(&work).unwrap(); |
| 56 | git_in(&work, dir.path(), &["init", "-q", "-b", "main"]); |
| 57 | let env = GitEnv { |
| 58 | git_bin: PathBuf::from("/usr/bin/git"), |
| 59 | data_dir: dir.path().to_path_buf(), |
| 60 | }; |
| 61 | let git_dir = work.join(".git"); |
| 62 | Fixture { |
| 63 | _dir: dir, |
| 64 | work, |
| 65 | git_dir, |
| 66 | env, |
| 67 | } |
| 68 | } |
| 69 | |
| 70 | impl Fixture { |
| 71 | fn git(&self, args: &[&str]) -> String { |
| 72 | git_in(&self.work, self._dir.path(), args) |
| 73 | } |
| 74 | |
| 75 | fn write(&self, name: &str, content: impl AsRef<[u8]>) { |
| 76 | let p = self.work.join(name); |
| 77 | if let Some(parent) = p.parent() { |
| 78 | std::fs::create_dir_all(parent).unwrap(); |
| 79 | } |
| 80 | std::fs::write(p, content).unwrap(); |
| 81 | } |
| 82 | |
| 83 | /// Stages everything, commits, and returns the new full oid. |
| 84 | fn commit(&self, msg: &str) -> String { |
| 85 | self.git(&["add", "-A"]); |
| 86 | self.git(&["commit", "-q", "--allow-empty", "-m", msg]); |
| 87 | self.git(&["rev-parse", "HEAD"]).trim().to_owned() |
| 88 | } |
| 89 | } |
| 90 | |
| 91 | fn find<'a>(diff: &'a CommitDiff, path: &str) -> &'a FileDiff { |
| 92 | diff.files |
| 93 | .iter() |
| 94 | .find(|f| f.new_path.as_deref() == Some(path) || f.old_path.as_deref() == Some(path)) |
| 95 | .unwrap_or_else(|| panic!("no file {path} in diff")) |
| 96 | } |
| 97 | |
| 98 | fn summaries(commits: &[forge_git::browse::CommitInfo]) -> Vec<&str> { |
| 99 | commits.iter().map(|c| c.summary.as_str()).collect() |
| 100 | } |
| 101 | |
| 102 | #[tokio::test] |
| 103 | async fn log_path_filter_and_pagination() { |
| 104 | let f = fixture(); |
| 105 | f.write("a.txt", "1\n"); |
| 106 | f.commit("add a"); |
| 107 | f.write("b.txt", "1\n"); |
| 108 | f.commit("add b"); |
| 109 | f.write("a.txt", "2\n"); |
| 110 | f.commit("change a"); |
| 111 | f.write("b.txt", "2\n"); |
| 112 | let head = f.commit("change b\n\nbody with \"quotes\"\n"); |
| 113 | |
| 114 | let all = log(&f.env, &f.git_dir, &head, None, 0, 10).await.unwrap(); |
| 115 | assert_eq!(summaries(&all), ["change b", "change a", "add b", "add a"]); |
| 116 | assert!(all[0].message.contains("body with \"quotes\"")); |
| 117 | assert_eq!(all[0].parents, vec![all[1].oid.clone()]); |
| 118 | assert!(all[3].parents.is_empty()); |
| 119 | |
| 120 | let only_a = log(&f.env, &f.git_dir, &head, Some("a.txt"), 0, 10) |
| 121 | .await |
| 122 | .unwrap(); |
| 123 | assert_eq!(summaries(&only_a), ["change a", "add a"]); |
| 124 | |
| 125 | let page = log(&f.env, &f.git_dir, &head, None, 1, 2).await.unwrap(); |
| 126 | assert_eq!(summaries(&page), ["change a", "add b"]); |
| 127 | |
| 128 | let page_a = log(&f.env, &f.git_dir, &head, Some("a.txt"), 1, 1) |
| 129 | .await |
| 130 | .unwrap(); |
| 131 | assert_eq!(summaries(&page_a), ["add a"]); |
| 132 | } |
| 133 | |
| 134 | #[tokio::test] |
| 135 | async fn commit_diff_modify_add_delete_rename_binary() { |
| 136 | let f = fixture(); |
| 137 | f.write("a.txt", "one\ntwo\n"); |
| 138 | f.write("gone.txt", "bye\n"); |
| 139 | f.write("old.txt", "alpha\nbeta\ngamma\ndelta\nepsilon\n"); |
| 140 | f.commit("base"); |
| 141 | |
| 142 | f.write("a.txt", "one\n2\n"); |
| 143 | f.write("new.txt", "fresh\n"); |
| 144 | f.write("bin.dat", b"\0\x01\x02binary"); |
| 145 | f.git(&["rm", "-q", "gone.txt"]); |
| 146 | f.git(&["mv", "old.txt", "moved.txt"]); |
| 147 | let oid = f.commit("mixed"); |
| 148 | |
| 149 | let diff = commit_diff(&f.env, &f.git_dir, &oid, BIG).await.unwrap(); |
| 150 | assert!(!diff.truncated_files); |
| 151 | assert_eq!(diff.files.len(), 5); |
| 152 | |
| 153 | let a = find(&diff, "a.txt"); |
| 154 | assert_eq!(a.status, DiffStatus::Modified); |
| 155 | assert_eq!((a.additions, a.deletions), (1, 1)); |
| 156 | assert!(!a.is_binary); |
| 157 | assert_eq!(a.hunks.len(), 1); |
| 158 | assert!(!a.truncated); |
| 159 | |
| 160 | let new = find(&diff, "new.txt"); |
| 161 | assert_eq!(new.status, DiffStatus::Added); |
| 162 | assert_eq!(new.old_path, None); |
| 163 | assert_eq!(new.additions, 1); |
| 164 | |
| 165 | let gone = find(&diff, "gone.txt"); |
| 166 | assert_eq!(gone.status, DiffStatus::Deleted); |
| 167 | assert_eq!(gone.new_path, None); |
| 168 | assert_eq!(gone.deletions, 1); |
| 169 | |
| 170 | let moved = find(&diff, "moved.txt"); |
| 171 | assert_eq!(moved.status, DiffStatus::Renamed); |
| 172 | assert_eq!(moved.old_path.as_deref(), Some("old.txt")); |
| 173 | assert_eq!(moved.new_path.as_deref(), Some("moved.txt")); |
| 174 | assert!(moved.hunks.is_empty()); |
| 175 | |
| 176 | let bin = find(&diff, "bin.dat"); |
| 177 | assert_eq!(bin.status, DiffStatus::Added); |
| 178 | assert!(bin.is_binary); |
| 179 | assert!(bin.hunks.is_empty()); |
| 180 | } |
| 181 | |
| 182 | #[tokio::test] |
| 183 | async fn commit_diff_line_numbers_on_known_hunk() { |
| 184 | let f = fixture(); |
| 185 | f.write("n.txt", "l1\nl2\nl3\nl4\nl5\n"); |
| 186 | f.commit("base"); |
| 187 | f.write("n.txt", "l1\nl2\nX3\nl4\nl5\nl6\n"); |
| 188 | let oid = f.commit("edit"); |
| 189 | |
| 190 | let diff = commit_diff(&f.env, &f.git_dir, &oid, BIG).await.unwrap(); |
| 191 | let file = find(&diff, "n.txt"); |
| 192 | assert_eq!(file.hunks.len(), 1); |
| 193 | assert!(file.hunks[0].header.starts_with("@@ -1,5 +1,6 @@")); |
| 194 | |
| 195 | let got: Vec<(LineKind, Option<u32>, Option<u32>, &str)> = file.hunks[0] |
| 196 | .lines |
| 197 | .iter() |
| 198 | .map(|l| (l.kind, l.old_no, l.new_no, l.text.as_str())) |
| 199 | .collect(); |
| 200 | assert_eq!( |
| 201 | got, |
| 202 | vec![ |
| 203 | (LineKind::Context, Some(1), Some(1), "l1"), |
| 204 | (LineKind::Context, Some(2), Some(2), "l2"), |
| 205 | (LineKind::Del, Some(3), None, "l3"), |
| 206 | (LineKind::Add, None, Some(3), "X3"), |
| 207 | (LineKind::Context, Some(4), Some(4), "l4"), |
| 208 | (LineKind::Context, Some(5), Some(5), "l5"), |
| 209 | (LineKind::Add, None, Some(6), "l6"), |
| 210 | ] |
| 211 | ); |
| 212 | assert_eq!((file.additions, file.deletions), (2, 1)); |
| 213 | } |
| 214 | |
| 215 | #[tokio::test] |
| 216 | async fn commit_diff_root_commit_uses_empty_tree() { |
| 217 | let f = fixture(); |
| 218 | f.write("a.txt", "x\ny\n"); |
| 219 | f.write("d/b.txt", "z\n"); |
| 220 | let oid = f.commit("root"); |
| 221 | |
| 222 | let diff = commit_diff(&f.env, &f.git_dir, &oid, BIG).await.unwrap(); |
| 223 | assert_eq!(diff.files.len(), 2); |
| 224 | for file in &diff.files { |
| 225 | assert_eq!(file.status, DiffStatus::Added); |
| 226 | assert_eq!(file.deletions, 0); |
| 227 | } |
| 228 | let a = find(&diff, "a.txt"); |
| 229 | assert_eq!(a.additions, 2); |
| 230 | assert_eq!(a.hunks[0].header, "@@ -0,0 +1,2 @@"); |
| 231 | } |
| 232 | |
| 233 | #[tokio::test] |
| 234 | async fn commit_diff_limits_truncate() { |
| 235 | let f = fixture(); |
| 236 | let old: String = (0..10).map(|i| format!("old{i}\n")).collect(); |
| 237 | f.write("big.txt", &old); |
| 238 | f.commit("base"); |
| 239 | let new: String = (0..10).map(|i| format!("new{i}\n")).collect(); |
| 240 | f.write("big.txt", &new); |
| 241 | f.write("f1.txt", "x\n"); |
| 242 | f.write("f2.txt", "x\n"); |
| 243 | f.write("f3.txt", "x\n"); |
| 244 | let oid = f.commit("rewrite"); |
| 245 | |
| 246 | let limits = DiffLimits { |
| 247 | max_files: 2, |
| 248 | max_lines_per_file: 3, |
| 249 | max_total_lines: 100_000, |
| 250 | }; |
| 251 | let diff = commit_diff(&f.env, &f.git_dir, &oid, limits).await.unwrap(); |
| 252 | assert!(diff.truncated_files); |
| 253 | assert_eq!(diff.files.len(), 2); |
| 254 | let big = find(&diff, "big.txt"); |
| 255 | assert!(big.truncated); |
| 256 | assert_eq!((big.additions, big.deletions), (10, 10)); |
| 257 | let kept: usize = big.hunks.iter().map(|h| h.lines.len()).sum(); |
| 258 | assert_eq!(kept, 3); |
| 259 | |
| 260 | let limits = DiffLimits { |
| 261 | max_files: 10, |
| 262 | max_lines_per_file: 100, |
| 263 | max_total_lines: 4, |
| 264 | }; |
| 265 | let diff = commit_diff(&f.env, &f.git_dir, &oid, limits).await.unwrap(); |
| 266 | assert!(!diff.truncated_files); |
| 267 | let total: usize = diff |
| 268 | .files |
| 269 | .iter() |
| 270 | .flat_map(|f| f.hunks.iter()) |
| 271 | .map(|h| h.lines.len()) |
| 272 | .sum(); |
| 273 | assert_eq!(total, 4); |
| 274 | let last = find(&diff, "f3.txt"); |
| 275 | assert!(last.truncated); |
| 276 | assert!(last.hunks.is_empty()); |
| 277 | assert_eq!(last.additions, 1); |
| 278 | } |
| 279 | |
| 280 | #[tokio::test] |
| 281 | async fn last_commits_finds_newest_touch_per_child() { |
| 282 | let f = fixture(); |
| 283 | f.write("d/x.txt", "1\n"); |
| 284 | f.commit("c1 add x"); |
| 285 | f.write("d/y.txt", "1\n"); |
| 286 | let c2 = f.commit("c2 add y"); |
| 287 | f.write("d/z.txt", "1\n"); |
| 288 | let c3 = f.commit("c3 add z"); |
| 289 | f.write("d/x.txt", "2\n"); |
| 290 | let c4 = f.commit("c4 change x"); |
| 291 | f.write("d/sub/w.txt", "1\n"); |
| 292 | let c5 = f.commit("c5 add sub/w"); |
| 293 | f.write("other.txt", "1\n"); |
| 294 | let c6 = f.commit("c6 other"); |
| 295 | |
| 296 | let names: Vec<String> = ["x.txt", "y.txt", "z.txt", "sub", "missing"] |
| 297 | .iter() |
| 298 | .map(|s| s.to_string()) |
| 299 | .collect(); |
| 300 | let map = last_commits(&f.env, &f.git_dir, &c6, "d", &names, 100) |
| 301 | .await |
| 302 | .unwrap(); |
| 303 | assert_eq!(map.len(), 4); |
| 304 | assert_eq!(map["x.txt"].oid, c4); |
| 305 | assert_eq!(map["y.txt"].oid, c2); |
| 306 | assert_eq!(map["z.txt"].oid, c3); |
| 307 | assert_eq!(map["sub"].oid, c5); |
| 308 | assert!(!map.contains_key("missing")); |
| 309 | |
| 310 | let root_names: Vec<String> = vec!["d".into(), "other.txt".into()]; |
| 311 | let root = last_commits(&f.env, &f.git_dir, &c6, "", &root_names, 100) |
| 312 | .await |
| 313 | .unwrap(); |
| 314 | assert_eq!(root["d"].oid, c5); |
| 315 | assert_eq!(root["other.txt"].oid, c6); |
| 316 | |
| 317 | // max_walk counts commits in the pathspec-limited walk: c5 then c4. |
| 318 | let short = last_commits(&f.env, &f.git_dir, &c6, "d", &names, 1) |
| 319 | .await |
| 320 | .unwrap(); |
| 321 | assert_eq!(short.len(), 1); |
| 322 | assert_eq!(short["sub"].oid, c5); |
| 323 | } |
| 324 | |
| 325 | #[tokio::test] |
| 326 | async fn archive_command_produces_listable_tar_gz() { |
| 327 | let f = fixture(); |
| 328 | f.write("a.txt", "hello\n"); |
| 329 | f.write("d/b.txt", "world\n"); |
| 330 | let oid = f.commit("archive me"); |
| 331 | |
| 332 | let cmd = archive_command(&f.env, &f.git_dir, &oid, ArchiveFormat::TarGz, "proj-1").unwrap(); |
| 333 | let out = cmd.into_tokio().output().await.unwrap(); |
| 334 | assert!(out.status.success()); |
| 335 | |
| 336 | let mut tar = Command::new("tar") |
| 337 | .arg("-tz") |
| 338 | .stdin(Stdio::piped()) |
| 339 | .stdout(Stdio::piped()) |
| 340 | .spawn() |
| 341 | .unwrap(); |
| 342 | if let Some(mut stdin) = tar.stdin.take() { |
| 343 | stdin.write_all(&out.stdout).unwrap(); |
| 344 | } |
| 345 | let listing = tar.wait_with_output().unwrap(); |
| 346 | assert!(listing.status.success()); |
| 347 | let names = String::from_utf8_lossy(&listing.stdout); |
| 348 | assert!(names.lines().any(|l| l == "proj-1/a.txt"), "{names}"); |
| 349 | assert!(names.lines().any(|l| l == "proj-1/d/b.txt"), "{names}"); |
| 350 | } |
| 351 | |
| 352 | #[tokio::test] |
| 353 | async fn non_hex_commit_arguments_are_rejected() { |
| 354 | let f = fixture(); |
| 355 | f.write("a.txt", "1\n"); |
| 356 | let oid = f.commit("one"); |
| 357 | |
| 358 | let bad_oid = "--output=/tmp/pwned"; |
| 359 | assert!(matches!( |
| 360 | log(&f.env, &f.git_dir, bad_oid, None, 0, 10).await, |
| 361 | Err(Error::InvalidInput(_)) |
| 362 | )); |
| 363 | assert!(matches!( |
| 364 | log(&f.env, &f.git_dir, "HEAD", None, 0, 10).await, |
| 365 | Err(Error::InvalidInput(_)) |
| 366 | )); |
| 367 | assert!(matches!( |
| 368 | commit_diff(&f.env, &f.git_dir, "main", BIG).await, |
| 369 | Err(Error::InvalidInput(_)) |
| 370 | )); |
| 371 | assert!(matches!( |
| 372 | last_commits(&f.env, &f.git_dir, "g".repeat(40).as_str(), "", &[], 10).await, |
| 373 | Err(Error::InvalidInput(_)) |
| 374 | )); |
| 375 | assert!(matches!( |
| 376 | archive_command(&f.env, &f.git_dir, bad_oid, ArchiveFormat::Zip, "p"), |
| 377 | Err(Error::InvalidInput(_)) |
| 378 | )); |
| 379 | assert!(matches!( |
| 380 | archive_command(&f.env, &f.git_dir, &oid, ArchiveFormat::TarGz, "../evil"), |
| 381 | Err(Error::InvalidInput(_)) |
| 382 | )); |
| 383 | assert!(matches!( |
| 384 | log(&f.env, &f.git_dir, &oid, Some("../escape"), 0, 10).await, |
| 385 | Err(Error::InvalidInput(_)) |
| 386 | )); |
| 387 | } |