This repository has no description
1use std::path::Path;
2
3use knot_git::{
4 ApplyOutcome, ConflictReason, Identity, Layout, NewCommit, PatchApplier, RefUpdate, Repo,
5 is_format_patch, parse_mailbox, parse_patch,
6};
7use knot_types::{AuthorName, Email, Oid, RefName, RepoDid, UnixSeconds};
8
9mod common;
10use common::{git_ok as git, seeded};
11
12fn stage_and_commit(work: &Path, message: &str) {
13 git(work, &["add", "-A"]);
14 git(work, &["commit", "-q", "-m", message]);
15}
16
17fn push_main(work: &Path, layout: &Layout, did: &RepoDid) {
18 let bare = layout.repo_path(did).unwrap();
19 git(work, &["push", "-q", bare.to_str().unwrap(), "main"]);
20 git(&bare, &["symbolic-ref", "HEAD", "refs/heads/main"]);
21}
22
23fn main_ref() -> RefName {
24 RefName::new("refs/heads/main").unwrap()
25}
26
27fn committer() -> Identity {
28 Identity {
29 name: AuthorName::new("Tangled"),
30 email: Email::new("noreply@tangled.sh"),
31 time: UnixSeconds::new(1_700_000_000),
32 offset_seconds: 0,
33 }
34}
35
36fn apply_mailbox_natively(bare: &Repo, patch: &str) -> Vec<Oid> {
37 let mails = parse_mailbox(patch).unwrap();
38 let tip = bare.find_ref(&main_ref()).unwrap().unwrap();
39 let base_tree = bare.find_commit(tip).unwrap().tree;
40 let mut applier = PatchApplier::new(bare, tip);
41 let (commits, _, new_tip) = mails.iter().fold(
42 (Vec::new(), base_tree, tip),
43 |(mut commits, tree, parent), mail| {
44 let staged = match applier.step(&mail.files).unwrap() {
45 ApplyOutcome::Clean(staged) => staged,
46 ApplyOutcome::Conflicted(conflicts) => {
47 panic!("expected clean apply, got conflicts {conflicts:?}")
48 }
49 };
50 let next_tree = bare.write_staged_tree(tree, &staged).unwrap();
51 let commit = bare
52 .write_commit(&NewCommit {
53 tree: next_tree,
54 parents: vec![parent],
55 author: Identity {
56 name: mail.author_name.clone(),
57 email: mail.author_email.clone(),
58 time: UnixSeconds::new(1_700_000_000),
59 offset_seconds: 0,
60 },
61 committer: committer(),
62 message: mail.commit_message(),
63 extra_headers: mail
64 .change_id
65 .iter()
66 .map(|id| ("change-id".to_string(), id.as_str().as_bytes().to_vec()))
67 .collect(),
68 })
69 .unwrap();
70 commits.push(commit);
71 (commits, next_tree, commit)
72 },
73 );
74 bare.update_ref(&RefUpdate::Update {
75 name: main_ref(),
76 old: tip,
77 new: new_tip,
78 })
79 .unwrap();
80 commits
81}
82
83#[test]
84fn a_format_patch_series_applies_tree_identical_to_git_am() {
85 let (_scan, work_dir, layout, did) = seeded();
86 let work = work_dir.path();
87
88 let blob: Vec<u8> = (0u32..8192).map(|i| (i * 31 % 251) as u8).collect();
89 std::fs::write(work.join("a.txt"), "alpha\nbeta\ngamma\n").unwrap();
90 std::fs::create_dir_all(work.join("sub")).unwrap();
91 std::fs::write(work.join("sub/inner.txt"), "nested\n").unwrap();
92 std::fs::write(work.join("noeol.txt"), "tail without newline").unwrap();
93 std::fs::write(work.join("data.bin"), &blob).unwrap();
94 std::fs::write(work.join("drop.bin"), [0u8, 1, 2, 3, 0, 9]).unwrap();
95 stage_and_commit(work, "base");
96 push_main(work, &layout, &did);
97 let base = git(work, &["rev-parse", "HEAD"]);
98
99 std::fs::write(work.join("a.txt"), "alpha\nBETA\ngamma\n").unwrap();
100 stage_and_commit(work, "first subject\n\nfirst body line");
101
102 git(work, &["mv", "a.txt", "moved.txt"]);
103 std::fs::write(work.join("moved.txt"), "alpha\nBETA\ngamma\ndelta\n").unwrap();
104 std::fs::write(work.join("run.sh"), "#!/bin/sh\necho reef\n").unwrap();
105 git(work, &["add", "-A"]);
106 git(work, &["update-index", "--chmod=+x", "run.sh"]);
107 git(work, &["commit", "-q", "-m", "second"]);
108
109 std::fs::remove_file(work.join("noeol.txt")).unwrap();
110 std::fs::write(work.join("sp ace.txt"), "spaced\n").unwrap();
111 std::fs::write(work.join("café.txt"), "unicode\n").unwrap();
112 stage_and_commit(work, "third");
113
114 let mutated: Vec<u8> = blob
115 .iter()
116 .copied()
117 .chain([0u8, 255, 254, 7])
118 .map(|byte| match byte {
119 42 => 24,
120 other => other,
121 })
122 .collect();
123 std::fs::write(work.join("data.bin"), &mutated).unwrap();
124 std::fs::remove_file(work.join("drop.bin")).unwrap();
125 std::fs::write(work.join("new.bin"), [9u8, 0, 8, 0, 7]).unwrap();
126 stage_and_commit(work, "binary churn");
127
128 let patch = git(
129 work,
130 &["format-patch", "--stdout", &format!("{base}..HEAD")],
131 );
132 assert!(is_format_patch(&patch));
133 assert!(patch.contains("GIT binary patch"));
134
135 let bare = layout.open(&did).unwrap();
136 let ours = apply_mailbox_natively(&bare, &patch);
137
138 let expected: Vec<String> = git(work, &["rev-list", "--reverse", &format!("{base}..HEAD")])
139 .lines()
140 .map(str::to_string)
141 .collect();
142 assert_eq!(ours.len(), expected.len());
143 ours.iter().zip(&expected).for_each(|(our_oid, real)| {
144 let our_commit = bare.find_commit(*our_oid).unwrap();
145 let real_tree =
146 Oid::from_hex(&git(work, &["rev-parse", &format!("{real}^{{tree}}")])).unwrap();
147 assert_eq!(
148 our_commit.tree, real_tree,
149 "natively applied tree must be byte-identical to git am's"
150 );
151 let real_message = git(work, &["log", "-1", "--format=%B", real]);
152 assert_eq!(our_commit.message.trim_end(), real_message.trim_end());
153 assert_eq!(our_commit.author.name.as_str(), "nel");
154 assert_eq!(our_commit.author.email.as_str(), "nel@oyster.cafe");
155 assert_eq!(our_commit.committer.name.as_str(), "Tangled");
156 assert_eq!(our_commit.committer.email.as_str(), "noreply@tangled.sh");
157 });
158 assert_eq!(
159 bare.find_ref(&main_ref()).unwrap(),
160 Some(*ours.last().unwrap())
161 );
162
163 git(work, &["checkout", "-q", "-b", "subline"]);
164 std::fs::write(work.join("seed.txt"), "one\n").unwrap();
165 stage_and_commit(work, "seed one");
166 let old_oid = git(work, &["rev-parse", "HEAD"]);
167 std::fs::write(work.join("seed.txt"), "two\n").unwrap();
168 stage_and_commit(work, "seed two");
169 let new_oid = git(work, &["rev-parse", "HEAD"]);
170 git(
171 work,
172 &[
173 "update-index",
174 "--add",
175 "--cacheinfo",
176 &format!("160000,{old_oid},vendor"),
177 ],
178 );
179 git(work, &["commit", "-q", "-m", "add submodule"]);
180 let sub_base = git(work, &["rev-parse", "HEAD"]);
181 let bare_path = layout.repo_path(&did).unwrap();
182 git(
183 work,
184 &[
185 "push",
186 "-q",
187 "-f",
188 bare_path.to_str().unwrap(),
189 &format!("{sub_base}:refs/heads/main"),
190 ],
191 );
192
193 git(
194 work,
195 &[
196 "update-index",
197 "--cacheinfo",
198 &format!("160000,{new_oid},vendor"),
199 ],
200 );
201 git(work, &["commit", "-q", "-m", "bump submodule"]);
202 let bump = git(
203 work,
204 &["format-patch", "--stdout", &format!("{sub_base}..HEAD")],
205 );
206 assert!(bump.contains("Subproject commit"));
207
208 let bumped = layout.open(&did).unwrap();
209 let applied = apply_mailbox_natively(&bumped, &bump);
210 let real_tree = Oid::from_hex(&git(work, &["rev-parse", "HEAD^{tree}"])).unwrap();
211 assert_eq!(bumped.find_commit(applied[0]).unwrap().tree, real_tree);
212}
213
214#[test]
215fn a_unified_diff_applies_tree_identical_to_git_apply() {
216 let (_scan, work_dir, layout, did) = seeded();
217 let work = work_dir.path();
218
219 std::fs::write(work.join("a.txt"), "one\ntwo\nthree\n").unwrap();
220 std::fs::write(work.join("gone.txt"), "doomed\n").unwrap();
221 std::fs::write(work.join("noeol.txt"), "no newline here").unwrap();
222 stage_and_commit(work, "base");
223 push_main(work, &layout, &did);
224 let base = git(work, &["rev-parse", "HEAD"]);
225
226 std::fs::write(work.join("a.txt"), "one\nTWO\nthree\nfour\n").unwrap();
227 std::fs::remove_file(work.join("gone.txt")).unwrap();
228 std::fs::write(work.join("fresh.txt"), "brand new\n").unwrap();
229 std::fs::write(work.join("noeol.txt"), "still no newline").unwrap();
230 stage_and_commit(work, "changes");
231
232 let patch = git(work, &["diff", &base, "HEAD"]);
233 assert!(!is_format_patch(&patch));
234 let files = parse_patch(&patch).unwrap();
235
236 let bare = layout.open(&did).unwrap();
237 let tip = bare.find_ref(&main_ref()).unwrap().unwrap();
238 let base_tree = bare.find_commit(tip).unwrap().tree;
239 let mut applier = PatchApplier::new(&bare, tip);
240 let staged = match applier.step(&files).unwrap() {
241 ApplyOutcome::Clean(staged) => staged,
242 ApplyOutcome::Conflicted(conflicts) => panic!("unexpected conflicts {conflicts:?}"),
243 };
244 let our_tree = bare.write_staged_tree(base_tree, &staged).unwrap();
245
246 let real_tree = Oid::from_hex(&git(work, &["rev-parse", "HEAD^{tree}"])).unwrap();
247 assert_eq!(our_tree, real_tree);
248}
249
250#[test]
251fn a_stale_patch_conflicts_instead_of_applying() {
252 let (_scan, work_dir, layout, did) = seeded();
253 let work = work_dir.path();
254
255 std::fs::write(work.join("a.txt"), "original\n").unwrap();
256 stage_and_commit(work, "base");
257 let base = git(work, &["rev-parse", "HEAD"]);
258
259 std::fs::write(work.join("a.txt"), "patched from original\n").unwrap();
260 stage_and_commit(work, "feature");
261 let patch = git(work, &["diff", &base, "HEAD"]);
262
263 git(work, &["checkout", "-q", &base]);
264 git(work, &["checkout", "-q", "-b", "drifted"]);
265 std::fs::write(work.join("a.txt"), "diverged\n").unwrap();
266 stage_and_commit(work, "drift");
267 git(work, &["branch", "-q", "-f", "main", "HEAD"]);
268 push_main(work, &layout, &did);
269
270 let bare = layout.open(&did).unwrap();
271 let tip = bare.find_ref(&main_ref()).unwrap().unwrap();
272 let files = parse_patch(&patch).unwrap();
273 let mut applier = PatchApplier::new(&bare, tip);
274 match applier.step(&files).unwrap() {
275 ApplyOutcome::Conflicted(conflicts) => {
276 assert_eq!(conflicts.len(), 1);
277 assert_eq!(conflicts[0].path, "a.txt");
278 assert_eq!(conflicts[0].reason, ConflictReason::DoesNotApply);
279 }
280 ApplyOutcome::Clean(_) => panic!("stale patch mustn't apply cleanly"),
281 }
282}
283
284fn git_apply_applies(cwd: &Path, patch: &str) -> bool {
285 use std::io::Write;
286 use std::process::Stdio;
287 let mut child = knot_fixtures::command(cwd)
288 .args(["apply", "--check"])
289 .stdin(Stdio::piped())
290 .stdout(Stdio::null())
291 .stderr(Stdio::null())
292 .spawn()
293 .expect("git is available");
294 child
295 .stdin
296 .take()
297 .expect("stdin is piped")
298 .write_all(patch.as_bytes())
299 .expect("write patch to git apply");
300 child.wait().expect("git apply completes").success()
301}
302
303#[test]
304fn apply_verdict_matches_git_apply() {
305 let (_scan, work_dir, layout, did) = seeded();
306 let work = work_dir.path();
307 std::fs::write(work.join("a.txt"), "one\ntwo\nthree\nfour\nfive\n").unwrap();
308 stage_and_commit(work, "base");
309 push_main(work, &layout, &did);
310
311 let bare = layout.open(&did).unwrap();
312 let tip = bare.find_ref(&main_ref()).unwrap().unwrap();
313
314 let cases: [&str; 8] = [
315 "diff --git a/a.txt b/a.txt\n--- a/a.txt\n+++ b/a.txt\n@@ -1,3 +1,3 @@\n one\n-two\n+TWO\n three\n",
316 "diff --git a/a.txt b/a.txt\n--- a/a.txt\n+++ b/a.txt\n@@ -1,3 +1,3 @@\n nope\n-gone\n+HERE\n zero\n",
317 "diff --git a/a.txt b/a.txt\nnew file mode 100644\n--- /dev/null\n+++ b/a.txt\n@@ -0,0 +1 @@\n+x\n",
318 "diff --git a/ghost.txt b/ghost.txt\ndeleted file mode 100644\n--- a/ghost.txt\n+++ /dev/null\n@@ -1 +0,0 @@\n-x\n",
319 "diff --git a/fresh.txt b/fresh.txt\nnew file mode 100644\n--- /dev/null\n+++ b/fresh.txt\n@@ -0,0 +1 @@\n+brand new\n",
320 "diff --git a/a.txt b/a.txt\n--- a/a.txt\n+++ b/a.txt\n@@ -3,1 +3,1 @@\n-three\n+THREE\n",
321 "diff --git a/a.txt b/a.txt\n--- a/a.txt\n+++ b/a.txt\n@@ -5,1 +5,1 @@\n-five\n+FIVE\n",
322 "diff --git a/a.txt b/a.txt\n--- a/a.txt\n+++ b/a.txt\n@@ -2,1 +1,0 @@\n-two\n",
323 ];
324
325 cases.iter().for_each(|patch| {
326 let git_clean = git_apply_applies(work, patch);
327 let mut applier = PatchApplier::new(&bare, tip);
328 let ours_clean = matches!(
329 applier.step(&parse_patch(patch).unwrap()).unwrap(),
330 ApplyOutcome::Clean(_)
331 );
332 assert_eq!(
333 git_clean, ours_clean,
334 "verdict disagrees with git apply for patch:\n{patch}"
335 );
336 });
337}
338
339fn git_apply_to_worktree_fails(cwd: &Path, patch: &str) -> bool {
340 use std::io::Write;
341 use std::process::Stdio;
342 let mut child = knot_fixtures::command(cwd)
343 .args(["apply"])
344 .stdin(Stdio::piped())
345 .stdout(Stdio::null())
346 .stderr(Stdio::null())
347 .spawn()
348 .expect("git is available");
349 child
350 .stdin
351 .take()
352 .expect("stdin is piped")
353 .write_all(patch.as_bytes())
354 .expect("write patch to git apply");
355 !child.wait().expect("git apply completes").success()
356}
357
358#[test]
359fn refused_patches_conflict_with_the_reason_git_apply_rejects() {
360 let (_scan, work_dir, layout, did) = seeded();
361 let work = work_dir.path();
362 std::fs::write(work.join("a.txt"), "present\n").unwrap();
363 std::fs::write(work.join("dir"), "i am a file\n").unwrap();
364 stage_and_commit(work, "base");
365 push_main(work, &layout, &did);
366
367 let bare = layout.open(&did).unwrap();
368 let tip = bare.find_ref(&main_ref()).unwrap().unwrap();
369
370 let create_existing = concat!(
371 "diff --git a/a.txt b/a.txt\n",
372 "new file mode 100644\n",
373 "--- /dev/null\n",
374 "+++ b/a.txt\n",
375 "@@ -0,0 +1 @@\n",
376 "+x\n",
377 );
378 let delete_missing = concat!(
379 "diff --git a/ghost.txt b/ghost.txt\n",
380 "deleted file mode 100644\n",
381 "--- a/ghost.txt\n",
382 "+++ /dev/null\n",
383 "@@ -1 +0,0 @@\n",
384 "-x\n",
385 );
386 let escape = concat!(
387 "diff --git a/../escape.txt b/../escape.txt\n",
388 "new file mode 100644\n",
389 "--- /dev/null\n",
390 "+++ b/../escape.txt\n",
391 "@@ -0,0 +1 @@\n",
392 "+boom\n",
393 );
394 let under_a_file = concat!(
395 "diff --git a/dir/inner.txt b/dir/inner.txt\n",
396 "new file mode 100644\n",
397 "--- /dev/null\n",
398 "+++ b/dir/inner.txt\n",
399 "@@ -0,0 +1 @@\n",
400 "+nested\n",
401 );
402
403 let cases: &[(&str, ConflictReason, &str)] = &[
404 (
405 create_existing,
406 ConflictReason::AlreadyExists,
407 "file already exists",
408 ),
409 (
410 delete_missing,
411 ConflictReason::DoesNotExist,
412 "file doesn't exist",
413 ),
414 (escape, ConflictReason::DoesNotApply, "patch doesn't apply"),
415 (
416 under_a_file,
417 ConflictReason::DoesNotApply,
418 "patch doesn't apply",
419 ),
420 ];
421
422 cases.iter().for_each(|(patch, reason, message)| {
423 assert!(
424 git_apply_to_worktree_fails(work, patch),
425 "git apply must refuse:\n{patch}"
426 );
427 let mut applier = PatchApplier::new(&bare, tip);
428 match applier.step(&parse_patch(patch).unwrap()).unwrap() {
429 ApplyOutcome::Conflicted(conflicts) => {
430 assert_eq!(conflicts[0].reason, *reason);
431 assert_eq!(conflicts[0].reason.as_str(), *message);
432 }
433 ApplyOutcome::Clean(_) => panic!("must conflict, not apply cleanly:\n{patch}"),
434 }
435 });
436}