This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

core / knot2 / crates / knot-git / tests / apply.rs
16 kB 436 lines
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}