forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
11.9 KiB
1use std::io::Write;
2use std::path::{Path, PathBuf};
3use std::process::{Command, Stdio};
4
5use forge_git::history::{
6 ArchiveFormat, CommitDiff, DiffLimits, DiffStatus, FileDiff, LineKind, archive_command,
7 commit_diff, last_commits, log,
8};
9use forge_git::{Error, GitEnv};
10
11const BIG: DiffLimits = DiffLimits {
12 max_files: 1000,
13 max_lines_per_file: 10_000,
14 max_total_lines: 100_000,
15};
16
17struct Fixture {
18 _dir: tempfile::TempDir,
19 work: PathBuf,
20 git_dir: PathBuf,
21 env: GitEnv,
22}
23
24fn 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
52fn 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
70impl 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
91fn 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
98fn summaries(commits: &[forge_git::browse::CommitInfo]) -> Vec<&str> {
99 commits.iter().map(|c| c.summary.as_str()).collect()
100}
101
102#[tokio::test]
103async 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]
135async 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]
183async 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]
216async 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]
234async 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]
281async 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]
326async 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]
353async 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}