1use crate::error::{Error, Result};
23use crate::occ::Precondition;
24use crate::plumbing::TreeChange;
25use crate::repo::VaultRepo;
26use git2::Oid;
27use std::collections::BTreeSet;
28use tracing::instrument;
29
30#[derive(Debug, Clone, Default)]
32pub struct Changeset {
33 message: String,
34 changes: Vec<TreeChange>,
35 preconditions: Vec<Precondition>,
36}
37
38impl Changeset {
39 pub fn new(message: impl Into<String>) -> Self {
41 Self {
42 message: message.into(),
43 ..Default::default()
44 }
45 }
46
47 pub fn upsert(mut self, path: impl Into<String>, content: impl Into<Vec<u8>>) -> Self {
49 self.changes.push(TreeChange::Upsert {
50 path: path.into(),
51 content: content.into(),
52 });
53 self
54 }
55
56 pub fn remove(mut self, path: impl Into<String>) -> Self {
58 self.changes.push(TreeChange::Remove { path: path.into() });
59 self
60 }
61
62 pub fn with_change(mut self, change: TreeChange) -> Self {
64 self.changes.push(change);
65 self
66 }
67
68 pub fn expect_blob(mut self, path: impl Into<String>, blob: Oid) -> Self {
70 self.preconditions
71 .push(Precondition::expect_blob(path, blob));
72 self
73 }
74
75 pub fn expect_absent(mut self, path: impl Into<String>) -> Self {
77 self.preconditions.push(Precondition::expect_absent(path));
78 self
79 }
80
81 pub fn with_precondition(mut self, precondition: Precondition) -> Self {
84 self.preconditions.push(precondition);
85 self
86 }
87
88 pub fn create(mut self, path: impl Into<String>, content: impl Into<Vec<u8>>) -> Self {
96 let path = path.into();
97 self.changes.push(TreeChange::Upsert {
98 path: path.clone(),
99 content: content.into(),
100 });
101 self.preconditions.push(Precondition::expect_absent(path));
102 self
103 }
104
105 pub fn update(
109 mut self,
110 path: impl Into<String>,
111 content: impl Into<Vec<u8>>,
112 expected: Oid,
113 ) -> Self {
114 let path = path.into();
115 self.changes.push(TreeChange::Upsert {
116 path: path.clone(),
117 content: content.into(),
118 });
119 self.preconditions
120 .push(Precondition::expect_blob(path, expected));
121 self
122 }
123
124 pub fn delete(mut self, path: impl Into<String>, expected: Oid) -> Self {
127 let path = path.into();
128 self.changes.push(TreeChange::Remove { path: path.clone() });
129 self.preconditions
130 .push(Precondition::expect_blob(path, expected));
131 self
132 }
133
134 pub fn rename(
142 mut self,
143 from: impl Into<String>,
144 to: impl Into<String>,
145 content: impl Into<Vec<u8>>,
146 expected_from: Oid,
147 ) -> Self {
148 let from = from.into();
149 let to = to.into();
150 self.changes.push(TreeChange::Remove { path: from.clone() });
151 self.changes.push(TreeChange::Upsert {
152 path: to.clone(),
153 content: content.into(),
154 });
155 self.preconditions
156 .push(Precondition::expect_blob(from, expected_from));
157 self.preconditions.push(Precondition::expect_absent(to));
158 self
159 }
160
161 fn changed_paths(&self) -> Vec<String> {
163 self.changes.iter().map(|c| c.path().to_string()).collect()
164 }
165
166 pub fn touched_paths(&self) -> Vec<String> {
172 self.changed_paths()
173 }
174}
175
176#[derive(Debug, Clone)]
178pub struct ChangesetResult {
179 pub commit: Oid,
182 pub paths: Vec<String>,
184 pub no_op: bool,
190}
191
192impl VaultRepo {
193 #[instrument(
199 skip(self, txn),
200 fields(
201 message = %txn.message,
202 n_changes = txn.changes.len(),
203 n_preconditions = txn.preconditions.len(),
204 ),
205 name = "git_commit_changeset"
206 )]
207 pub fn commit_changeset(&self, txn: &Changeset) -> Result<ChangesetResult> {
208 if txn.changes.is_empty() {
209 return Err(Error::Other("empty changeset (no changes)".to_string()));
210 }
211 let mut seen = BTreeSet::new();
213 for c in &txn.changes {
214 if !seen.insert(c.path()) {
215 return Err(Error::Other(format!(
216 "duplicate change for path {} in one changeset",
217 c.path()
218 )));
219 }
220 }
221
222 let refname = self.head_ref()?; let changed = txn.changed_paths();
224
225 self.with_commit_lock(|| {
226 let mut parent_at_apply: Option<Oid> = None;
232 let committed = self.commit_with_retry(&refname, |tip| {
233 parent_at_apply = tip;
234 self.ensure_worktree_matches_commit(tip, &changed)?;
235 let base_tree = match tip {
236 Some(c) => Some(self.git().find_commit(c)?.tree_id()),
237 None => None,
238 };
239 self.check_preconditions(base_tree, &txn.preconditions)?;
245 let tree = self.build_tree(base_tree, &txn.changes)?;
246 if Some(tree) == base_tree {
253 return Ok(None);
254 }
255 let parents: Vec<Oid> = tip.into_iter().collect();
256 Ok(Some(self.commit_tree(tree, &parents, &txn.message)?))
257 })?;
258
259 match committed {
260 Some(commit) => {
261 self.materialize(commit, &changed)?;
263
264 if let Some(hook) = &self.commit_hook {
268 hook(parent_at_apply, commit);
269 }
270
271 Ok(ChangesetResult {
272 commit,
273 paths: changed,
274 no_op: false,
275 })
276 }
277 None => {
278 let commit = parent_at_apply.ok_or_else(|| {
283 Error::Other(
284 "identity-tree no-op on an unborn branch is impossible".to_string(),
285 )
286 })?;
287 Ok(ChangesetResult {
288 commit,
289 paths: Vec::new(),
290 no_op: true,
291 })
292 }
293 }
294 })
295 }
296}
297
298#[cfg(test)]
299mod tests {
300 use super::*;
301 use git2::Repository;
302 use tempfile::TempDir;
303
304 fn open_unborn() -> (TempDir, VaultRepo) {
305 let tmp = TempDir::new().unwrap();
306 let mut opts = git2::RepositoryInitOptions::new();
307 opts.initial_head("main");
308 Repository::init_opts(tmp.path(), &opts).unwrap();
309 let vr = VaultRepo::open(tmp.path()).unwrap();
310 (tmp, vr)
311 }
312
313 fn workfile(vr: &VaultRepo, rel: &str) -> std::path::PathBuf {
314 vr.git().workdir().unwrap().join(rel)
315 }
316
317 fn read_wt(vr: &VaultRepo, rel: &str) -> String {
318 std::fs::read_to_string(workfile(vr, rel)).unwrap()
319 }
320
321 #[test]
322 fn create_on_unborn_makes_initial_commit() {
323 let (_tmp, vr) = open_unborn();
324 let txn = Changeset::new("create a")
325 .upsert("a.md", "alpha")
326 .expect_absent("a.md");
327 let res = vr.commit_changeset(&txn).unwrap();
328
329 assert_eq!(
330 vr.head_oid(),
331 Some(res.commit),
332 "branch advanced to the commit"
333 );
334 assert_eq!(
335 read_wt(&vr, "a.md"),
336 "alpha",
337 "materialized to working tree"
338 );
339 }
340
341 #[test]
342 fn create_refuses_to_clobber_untracked_worktree_file() {
343 let (_tmp, vr) = open_unborn();
344 std::fs::write(workfile(&vr, "draft.md"), "local draft").unwrap();
345
346 let result = vr.commit_changeset(
347 &Changeset::new("create draft").create("draft.md", "generated content"),
348 );
349
350 assert!(
351 matches!(result, Err(Error::Other(message)) if message.contains("differs from HEAD"))
352 );
353 assert_eq!(vr.head_oid(), None, "ref did not advance");
354 assert_eq!(read_wt(&vr, "draft.md"), "local draft");
355 }
356
357 #[test]
358 fn update_refuses_to_clobber_dirty_worktree_file() {
359 let (_tmp, vr) = open_unborn();
360 vr.commit_changeset(&Changeset::new("seed").create("note.md", "v1"))
361 .unwrap();
362 let head_before = vr.head_oid();
363 let v1 = VaultRepo::blob_oid_of(b"v1").unwrap();
364 std::fs::write(workfile(&vr, "note.md"), "manual edit").unwrap();
365
366 let result = vr.commit_changeset(&Changeset::new("update").update("note.md", "v2", v1));
367
368 assert!(
369 matches!(result, Err(Error::Other(message)) if message.contains("differs from HEAD"))
370 );
371 assert_eq!(vr.head_oid(), head_before, "ref did not advance");
372 assert_eq!(read_wt(&vr, "note.md"), "manual edit");
373 }
374
375 #[test]
376 fn write_refuses_to_discard_unrelated_staged_change() {
377 let (_tmp, vr) = open_unborn();
378 vr.commit_changeset(
379 &Changeset::new("seed")
380 .create("a.md", "a")
381 .create("b.md", "b"),
382 )
383 .unwrap();
384 let head_before = vr.head_oid();
385 std::fs::write(workfile(&vr, "b.md"), "staged b").unwrap();
386 let mut index = vr.git().index().unwrap();
387 index.add_path(std::path::Path::new("b.md")).unwrap();
388 index.write().unwrap();
389
390 let result = vr.commit_changeset(&Changeset::new("update a").upsert("a.md", "a2"));
391
392 assert!(matches!(result, Err(Error::Other(message)) if message.contains("staged changes")));
393 assert_eq!(vr.head_oid(), head_before);
394 assert!(
395 vr.git()
396 .status_file(std::path::Path::new("b.md"))
397 .unwrap()
398 .contains(git2::Status::INDEX_MODIFIED),
399 "the caller's staged change remains staged"
400 );
401 }
402
403 #[test]
404 fn update_with_correct_precondition_succeeds() {
405 let (_tmp, vr) = open_unborn();
406 vr.commit_changeset(&Changeset::new("c").upsert("a.md", "v1"))
407 .unwrap();
408
409 let v1 = VaultRepo::blob_oid_of(b"v1").unwrap();
410 let txn = Changeset::new("update a")
411 .upsert("a.md", "v2")
412 .expect_blob("a.md", v1);
413 vr.commit_changeset(&txn).unwrap();
414 assert_eq!(read_wt(&vr, "a.md"), "v2");
415 }
416
417 #[test]
418 fn stale_precondition_aborts_nothing_applied() {
419 let (_tmp, vr) = open_unborn();
420 vr.commit_changeset(&Changeset::new("c").upsert("a.md", "v1"))
421 .unwrap();
422 let head_before = vr.head_oid();
423
424 let stale = VaultRepo::blob_oid_of(b"stale").unwrap();
426 let txn = Changeset::new("bad update")
427 .upsert("a.md", "v2")
428 .expect_blob("a.md", stale);
429 assert!(matches!(
430 vr.commit_changeset(&txn),
431 Err(Error::PreconditionFailed { .. })
432 ));
433
434 assert_eq!(vr.head_oid(), head_before, "no commit on abort");
435 assert_eq!(
436 read_wt(&vr, "a.md"),
437 "v1",
438 "working tree untouched on abort"
439 );
440 }
441
442 #[test]
443 fn multi_file_batch_is_one_atomic_commit() {
444 let (_tmp, vr) = open_unborn();
445 let txn = Changeset::new("batch")
446 .upsert("a.md", "A")
447 .upsert("dir/b.md", "B")
448 .remove("ghost.md"); let res = vr.commit_changeset(&txn).unwrap();
450
451 let commit = vr.git().find_commit(res.commit).unwrap();
453 assert_eq!(
454 commit.parent_count(),
455 0,
456 "single initial commit for the batch"
457 );
458 assert_eq!(read_wt(&vr, "a.md"), "A");
459 assert_eq!(read_wt(&vr, "dir/b.md"), "B");
460 }
461
462 #[test]
463 fn read_set_precondition_aborts_batch() {
464 let (_tmp, vr) = open_unborn();
468 vr.commit_changeset(&Changeset::new("seed").upsert("b.md", "B1"))
469 .unwrap();
470 let head_before = vr.head_oid();
471
472 let stale_b = VaultRepo::blob_oid_of(b"B-OLD").unwrap();
473 let txn = Changeset::new("write a, guard b")
474 .upsert("a.md", "A")
475 .expect_blob("b.md", stale_b);
476 assert!(matches!(
477 vr.commit_changeset(&txn),
478 Err(Error::PreconditionFailed { path, .. }) if path == "b.md"
479 ));
480 assert_eq!(vr.head_oid(), head_before, "nothing committed");
481 assert!(!workfile(&vr, "a.md").exists(), "a.md never materialized");
482 }
483
484 #[test]
485 fn empty_changeset_rejected() {
486 let (_tmp, vr) = open_unborn();
487 assert!(matches!(
488 vr.commit_changeset(&Changeset::new("empty")),
489 Err(Error::Other(_))
490 ));
491 }
492
493 #[test]
494 fn duplicate_change_path_rejected() {
495 let (_tmp, vr) = open_unborn();
496 let txn = Changeset::new("dup")
497 .upsert("a.md", "x")
498 .upsert("a.md", "y");
499 assert!(matches!(vr.commit_changeset(&txn), Err(Error::Other(_))));
500 }
501
502 #[test]
503 fn move_as_remove_plus_upsert_one_commit() {
504 let (_tmp, vr) = open_unborn();
507 vr.commit_changeset(&Changeset::new("seed").upsert("old.md", "body"))
508 .unwrap();
509
510 let txn = Changeset::new("move old->new")
511 .remove("old.md")
512 .upsert("new.md", "body");
513 let res = vr.commit_changeset(&txn).unwrap();
514
515 assert!(!workfile(&vr, "old.md").exists(), "old path removed");
516 assert_eq!(read_wt(&vr, "new.md"), "body", "new path written");
517 let tree = vr.git().find_commit(res.commit).unwrap().tree_id();
519 assert!(vr.blob_oid_at(tree, "old.md").unwrap().is_none());
520 assert!(vr.blob_oid_at(tree, "new.md").unwrap().is_some());
521 }
522
523 #[test]
526 fn create_on_absent_succeeds_create_on_existing_fails() {
527 let (_tmp, vr) = open_unborn();
528 vr.commit_changeset(&Changeset::new("c").create("a.md", "alpha"))
530 .unwrap();
531 assert_eq!(read_wt(&vr, "a.md"), "alpha");
532
533 let res = vr.commit_changeset(&Changeset::new("c2").create("a.md", "again"));
535 assert!(matches!(res, Err(Error::PreconditionFailed { path, .. }) if path == "a.md"));
536 assert_eq!(
537 read_wt(&vr, "a.md"),
538 "alpha",
539 "no overwrite on create-existing"
540 );
541 }
542
543 #[test]
544 fn update_requires_correct_expected_blob() {
545 let (_tmp, vr) = open_unborn();
546 vr.commit_changeset(&Changeset::new("seed").create("a.md", "v1"))
547 .unwrap();
548
549 let v1 = VaultRepo::blob_oid_of(b"v1").unwrap();
550 vr.commit_changeset(&Changeset::new("u").update("a.md", "v2", v1))
551 .unwrap();
552 assert_eq!(read_wt(&vr, "a.md"), "v2");
553
554 let res = vr.commit_changeset(&Changeset::new("u-stale").update("a.md", "v3", v1));
556 assert!(matches!(res, Err(Error::PreconditionFailed { path, .. }) if path == "a.md"));
557 assert_eq!(read_wt(&vr, "a.md"), "v2", "stale update did not apply");
558 }
559
560 #[test]
561 fn delete_requires_correct_expected_blob() {
562 let (_tmp, vr) = open_unborn();
563 vr.commit_changeset(&Changeset::new("seed").create("a.md", "v1"))
564 .unwrap();
565
566 let stale = VaultRepo::blob_oid_of(b"OLD").unwrap();
568 let res = vr.commit_changeset(&Changeset::new("d-stale").delete("a.md", stale));
569 assert!(matches!(res, Err(Error::PreconditionFailed { path, .. }) if path == "a.md"));
570 assert!(workfile(&vr, "a.md").exists(), "stale delete did not apply");
571
572 let v1 = VaultRepo::blob_oid_of(b"v1").unwrap();
574 vr.commit_changeset(&Changeset::new("d").delete("a.md", v1))
575 .unwrap();
576 assert!(!workfile(&vr, "a.md").exists());
577 }
578
579 #[test]
580 fn rename_atomically_with_endpoint_preconditions() {
581 let (_tmp, vr) = open_unborn();
582 vr.commit_changeset(&Changeset::new("seed").create("old.md", "body"))
583 .unwrap();
584
585 let from_blob = VaultRepo::blob_oid_of(b"body").unwrap();
586 let res = vr
587 .commit_changeset(&Changeset::new("rn").rename("old.md", "new.md", "body", from_blob))
588 .unwrap();
589
590 assert!(!workfile(&vr, "old.md").exists(), "source removed");
591 assert_eq!(read_wt(&vr, "new.md"), "body", "destination written");
592 let tree = vr.git().find_commit(res.commit).unwrap().tree_id();
593 assert!(vr.blob_oid_at(tree, "old.md").unwrap().is_none());
594 assert!(vr.blob_oid_at(tree, "new.md").unwrap().is_some());
595 }
596
597 #[test]
598 fn rename_aborts_on_stale_source() {
599 let (_tmp, vr) = open_unborn();
600 vr.commit_changeset(&Changeset::new("seed").create("old.md", "body"))
601 .unwrap();
602
603 let stale = VaultRepo::blob_oid_of(b"different").unwrap();
604 let res =
605 vr.commit_changeset(&Changeset::new("rn").rename("old.md", "new.md", "body", stale));
606 assert!(matches!(res, Err(Error::PreconditionFailed { path, .. }) if path == "old.md"));
607 assert!(workfile(&vr, "old.md").exists(), "source kept on abort");
608 assert!(
609 !workfile(&vr, "new.md").exists(),
610 "destination not written on abort"
611 );
612 }
613
614 #[test]
615 fn rename_aborts_when_destination_exists() {
616 let (_tmp, vr) = open_unborn();
617 vr.commit_changeset(
618 &Changeset::new("seed")
619 .create("old.md", "body")
620 .create("new.md", "occupied"),
621 )
622 .unwrap();
623
624 let from_blob = VaultRepo::blob_oid_of(b"body").unwrap();
625 let res = vr
626 .commit_changeset(&Changeset::new("rn").rename("old.md", "new.md", "body", from_blob));
627 assert!(matches!(res, Err(Error::PreconditionFailed { path, .. }) if path == "new.md"));
628 assert!(workfile(&vr, "old.md").exists());
629 assert_eq!(read_wt(&vr, "new.md"), "occupied", "destination untouched");
630 }
631
632 #[test]
633 fn rename_chained_with_link_updates_is_one_commit() {
634 let (_tmp, vr) = open_unborn();
637 vr.commit_changeset(
638 &Changeset::new("seed")
639 .create("old.md", "body")
640 .create("link1.md", "see [[old]]")
641 .create("link2.md", "ref [[old]] here"),
642 )
643 .unwrap();
644
645 let body_blob = VaultRepo::blob_oid_of(b"body").unwrap();
646 let l1_blob = VaultRepo::blob_oid_of(b"see [[old]]").unwrap();
647 let l2_blob = VaultRepo::blob_oid_of(b"ref [[old]] here").unwrap();
648 let res = vr
649 .commit_changeset(
650 &Changeset::new("mv+links")
651 .rename("old.md", "new.md", "body", body_blob)
652 .update("link1.md", "see [[new]]", l1_blob)
653 .update("link2.md", "ref [[new]] here", l2_blob),
654 )
655 .unwrap();
656
657 let tree = vr.git().find_commit(res.commit).unwrap().tree_id();
659 assert!(vr.blob_oid_at(tree, "old.md").unwrap().is_none());
660 assert!(vr.blob_oid_at(tree, "new.md").unwrap().is_some());
661 assert_eq!(read_wt(&vr, "link1.md"), "see [[new]]");
662 assert_eq!(read_wt(&vr, "link2.md"), "ref [[new]] here");
663 }
664
665 type HookCalls = std::sync::Arc<std::sync::Mutex<Vec<(Option<Oid>, Oid)>>>;
668 type CommitOnlyCalls = std::sync::Arc<std::sync::Mutex<Vec<Oid>>>;
669
670 fn open_unborn_with_hook(hook: crate::CommitHook) -> (TempDir, crate::VaultRepo) {
671 let tmp = TempDir::new().unwrap();
672 let mut opts = git2::RepositoryInitOptions::new();
673 opts.initial_head("main");
674 Repository::init_opts(tmp.path(), &opts).unwrap();
675 let vr = crate::VaultRepo::open_with_locks_and_hook(
676 tmp.path(),
677 std::sync::Arc::new(crate::CommitLocks::new()),
678 hook,
679 )
680 .unwrap();
681 (tmp, vr)
682 }
683
684 #[test]
685 fn commit_hook_fires_on_initial_commit_with_no_parent() {
686 let calls: HookCalls = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
687 let calls_clone = std::sync::Arc::clone(&calls);
688 let hook: crate::CommitHook = std::sync::Arc::new(move |p, c| {
689 calls_clone.lock().unwrap().push((p, c));
690 });
691 let (_tmp, vr) = open_unborn_with_hook(hook);
692
693 let res = vr
694 .commit_changeset(&Changeset::new("c").create("a.md", "alpha"))
695 .unwrap();
696
697 let calls = calls.lock().unwrap();
698 assert_eq!(calls.len(), 1);
699 assert_eq!(calls[0].0, None, "initial commit has no parent");
700 assert_eq!(calls[0].1, res.commit);
701 }
702
703 #[test]
704 fn commit_hook_reports_parent_on_followup_commit() {
705 let calls: HookCalls = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
706 let calls_clone = std::sync::Arc::clone(&calls);
707 let hook: crate::CommitHook = std::sync::Arc::new(move |p, c| {
708 calls_clone.lock().unwrap().push((p, c));
709 });
710 let (_tmp, vr) = open_unborn_with_hook(hook);
711
712 let r1 = vr
713 .commit_changeset(&Changeset::new("c1").create("a.md", "v1"))
714 .unwrap();
715 let v1 = crate::VaultRepo::blob_oid_of(b"v1").unwrap();
716 let r2 = vr
717 .commit_changeset(&Changeset::new("c2").update("a.md", "v2", v1))
718 .unwrap();
719
720 let calls = calls.lock().unwrap();
721 assert_eq!(calls.len(), 2);
722 assert_eq!(calls[0], (None, r1.commit));
723 assert_eq!(
724 calls[1],
725 (Some(r1.commit), r2.commit),
726 "second commit's parent is the first commit"
727 );
728 }
729
730 #[test]
731 fn commit_hook_does_not_fire_on_precondition_abort() {
732 let calls: CommitOnlyCalls = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
733 let calls_clone = std::sync::Arc::clone(&calls);
734 let hook: crate::CommitHook = std::sync::Arc::new(move |_p, c| {
735 calls_clone.lock().unwrap().push(c);
736 });
737 let (_tmp, vr) = open_unborn_with_hook(hook);
738
739 vr.commit_changeset(&Changeset::new("c").create("a.md", "v1"))
740 .unwrap();
741
742 let stale = crate::VaultRepo::blob_oid_of(b"OLD").unwrap();
744 assert!(
745 vr.commit_changeset(&Changeset::new("u").update("a.md", "v2", stale))
746 .is_err()
747 );
748
749 let calls = calls.lock().unwrap();
750 assert_eq!(calls.len(), 1, "hook only fires for the successful commit");
751 }
752
753 #[test]
754 fn vault_repo_without_hook_is_silent() {
755 let (_tmp, vr) = open_unborn();
757 let r = vr.commit_changeset(&Changeset::new("c").create("a.md", "x"));
758 assert!(r.is_ok());
759 }
760
761 #[test]
767 fn changeset_touched_paths_lists_every_changed_path() {
768 let v = crate::VaultRepo::blob_oid_of(b"old").unwrap();
769 let txn = Changeset::new("c")
770 .create("a.md", "a")
771 .update("b.md", "b", v)
772 .remove("c.md");
773 let mut p = txn.touched_paths();
774 p.sort();
775 assert_eq!(
776 p,
777 vec!["a.md".to_string(), "b.md".to_string(), "c.md".to_string()]
778 );
779 }
780
781 #[test]
784 fn raw_with_change_and_with_precondition_take_effect() {
785 let (_tmp, vr) = open_unborn();
786 let txn = Changeset::new("raw").with_change(TreeChange::Upsert {
788 path: "x.md".into(),
789 content: b"hi".to_vec(),
790 });
791 assert_eq!(txn.touched_paths(), vec!["x.md".to_string()]);
792 vr.commit_changeset(&txn).unwrap();
793 assert_eq!(read_wt(&vr, "x.md"), "hi");
794 let blocked = Changeset::new("b")
799 .upsert("y.md", b"y")
800 .with_precondition(Precondition::expect_absent("x.md"));
801 assert_eq!(
802 blocked.touched_paths(),
803 vec!["y.md".to_string()],
804 "with_precondition must preserve the builder chain"
805 );
806 let err = vr.commit_changeset(&blocked).unwrap_err().to_string();
807 assert!(
808 !err.contains("empty"),
809 "must abort on the precondition, not because the txn was emptied: {err}"
810 );
811 }
812
813 #[test]
816 fn git_commit_first_parent_resolves_chain() {
817 let (_tmp, vr) = open_unborn();
818 let r1 = vr
819 .commit_changeset(&Changeset::new("c1").create("a.md", "1"))
820 .unwrap();
821 let v1 = crate::VaultRepo::blob_oid_of(b"1").unwrap();
822 let r2 = vr
823 .commit_changeset(&Changeset::new("c2").update("a.md", "2", v1))
824 .unwrap();
825 assert_eq!(
826 vr.git_commit_first_parent(r2.commit).unwrap(),
827 Some(r1.commit),
828 "c2's first parent is c1"
829 );
830 assert_eq!(
831 vr.git_commit_first_parent(r1.commit).unwrap(),
832 None,
833 "the root commit has no parent"
834 );
835 }
836
837 #[test]
841 fn first_parent_range_walks_the_chain() {
842 let (_tmp, vr) = open_unborn();
843 let c1 = vr
844 .commit_changeset(&Changeset::new("c1").create("a.md", "1"))
845 .unwrap()
846 .commit;
847 let v1 = crate::VaultRepo::blob_oid_of(b"1").unwrap();
848 let c2 = vr
849 .commit_changeset(&Changeset::new("c2").update("a.md", "2", v1))
850 .unwrap()
851 .commit;
852 let v2 = crate::VaultRepo::blob_oid_of(b"2").unwrap();
853 let c3 = vr
854 .commit_changeset(&Changeset::new("c3").update("a.md", "3", v2))
855 .unwrap()
856 .commit;
857
858 assert_eq!(
860 vr.first_parent_range(Some(c1), c3).unwrap(),
861 Some(vec![c2, c3])
862 );
863 assert_eq!(
865 vr.first_parent_range(None, c3).unwrap(),
866 Some(vec![c1, c2, c3])
867 );
868 assert_eq!(vr.first_parent_range(Some(c3), c3).unwrap(), Some(vec![]));
870 assert_eq!(vr.first_parent_range(Some(c3), c1).unwrap(), None);
873 }
874
875 #[test]
880 fn first_parent_range_falls_back_on_merge_second_parent() {
881 let (_tmp, vr) = open_unborn();
882 let c1 = vr
883 .commit_changeset(&Changeset::new("c1").create("a.md", "1"))
884 .unwrap()
885 .commit;
886 let v1 = crate::VaultRepo::blob_oid_of(b"1").unwrap();
887 let c2 = vr
888 .commit_changeset(&Changeset::new("c2").update("a.md", "2", v1))
889 .unwrap()
890 .commit;
891 let c1_tree = vr.git().find_commit(c1).unwrap().tree_id();
893 let f1 = vr.commit_tree(c1_tree, &[c1], "f1").unwrap();
894 let c2_tree = vr.git().find_commit(c2).unwrap().tree_id();
896 let m = vr.commit_tree(c2_tree, &[c2, f1], "m").unwrap();
897
898 assert_eq!(vr.first_parent_range(Some(f1), m).unwrap(), None);
901 assert_eq!(
903 vr.first_parent_range(Some(c1), m).unwrap(),
904 Some(vec![c2, m])
905 );
906 }
907
908 #[test]
911 fn is_path_ignored_honors_gitignore() {
912 let (tmp, vr) = open_unborn();
913 std::fs::write(tmp.path().join(".gitignore"), "*.tmp\n").unwrap();
914 assert!(
915 vr.is_path_ignored("scratch.tmp").unwrap(),
916 "*.tmp must be ignored"
917 );
918 assert!(
919 !vr.is_path_ignored("note.md").unwrap(),
920 "note.md must not be ignored"
921 );
922 }
923
924 #[test]
929 fn multi_file_move_aborts_atomically_when_a_linker_is_stale() {
930 let (tmp, vr) = open_unborn();
931 vr.commit_changeset(
932 &Changeset::new("seed")
933 .create("old.md", "# Old")
934 .create("linker.md", "[[old]]"),
935 )
936 .unwrap();
937 let old_blob = crate::VaultRepo::blob_oid_of(b"# Old").unwrap();
938 let stale = crate::VaultRepo::blob_oid_of(b"DIFFERENT").unwrap();
939 let head_before = vr.head_oid().unwrap();
940
941 let txn = Changeset::new("move")
942 .remove("old.md")
943 .upsert("new.md", b"# Old".to_vec())
944 .expect_blob("old.md", old_blob)
945 .upsert("linker.md", b"[[new]]".to_vec())
946 .expect_blob("linker.md", stale); assert!(
948 vr.commit_changeset(&txn).is_err(),
949 "a stale linker must abort the whole move"
950 );
951 assert_eq!(vr.head_oid(), Some(head_before), "nothing committed");
952 assert_eq!(read_wt(&vr, "old.md"), "# Old", "old.md untouched");
953 assert!(
954 !tmp.path().join("new.md").exists(),
955 "new.md must not have been created"
956 );
957 }
958
959 #[test]
964 fn batch_commits_real_change_despite_an_identity_subchange() {
965 let (_tmp, vr) = open_unborn();
966 vr.commit_changeset(&Changeset::new("seed").create("a.md", "v1"))
967 .unwrap();
968 let head_before = vr.head_oid().unwrap();
969 let v1 = crate::VaultRepo::blob_oid_of(b"v1").unwrap();
970
971 let res = vr
972 .commit_changeset(
973 &Changeset::new("mixed")
974 .update("a.md", "v1", v1) .create("b.md", "B"), )
977 .unwrap();
978 assert!(!res.no_op, "a txn carrying a real change is not a no-op");
979 assert_ne!(vr.head_oid(), Some(head_before), "HEAD advanced");
980 assert_eq!(read_wt(&vr, "b.md"), "B");
981 assert_eq!(read_wt(&vr, "a.md"), "v1");
982 }
983
984 #[test]
987 fn identity_tree_write_is_noop() {
988 let (_tmp, vr) = open_unborn();
989 vr.commit_changeset(&Changeset::new("seed").create("a.md", "v1"))
990 .unwrap();
991 let head_before = vr.head_oid();
992
993 let v1 = VaultRepo::blob_oid_of(b"v1").unwrap();
996 let res = vr
997 .commit_changeset(&Changeset::new("idempotent").update("a.md", "v1", v1))
998 .unwrap();
999
1000 assert!(res.no_op, "identity rewrite is a no-op");
1001 assert!(res.paths.is_empty(), "no paths materialized on a no-op");
1002 assert_eq!(vr.head_oid(), head_before, "HEAD did not advance");
1003 assert_eq!(
1004 res.commit,
1005 head_before.unwrap(),
1006 "result.commit is the unchanged HEAD"
1007 );
1008 assert_eq!(read_wt(&vr, "a.md"), "v1", "working tree unchanged");
1009 }
1010
1011 #[test]
1012 fn noop_skips_commit_hook() {
1013 let calls: CommitOnlyCalls = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
1014 let calls_clone = std::sync::Arc::clone(&calls);
1015 let hook: crate::CommitHook = std::sync::Arc::new(move |_p, c| {
1016 calls_clone.lock().unwrap().push(c);
1017 });
1018 let (_tmp, vr) = open_unborn_with_hook(hook);
1019
1020 vr.commit_changeset(&Changeset::new("seed").create("a.md", "v1"))
1021 .unwrap();
1022 let v1 = crate::VaultRepo::blob_oid_of(b"v1").unwrap();
1023 let res = vr
1024 .commit_changeset(&Changeset::new("idempotent").update("a.md", "v1", v1))
1025 .unwrap();
1026
1027 assert!(res.no_op);
1028 let calls = calls.lock().unwrap();
1029 assert_eq!(
1030 calls.len(),
1031 1,
1032 "hook fires for the seed commit only, never for the no-op"
1033 );
1034 }
1035
1036 #[test]
1037 fn stale_precondition_aborts_before_identity_shortcircuit() {
1038 let (_tmp, vr) = open_unborn();
1039 vr.commit_changeset(&Changeset::new("seed").create("a.md", "v1"))
1040 .unwrap();
1041 let head_before = vr.head_oid();
1042
1043 let stale = VaultRepo::blob_oid_of(b"WRONG").unwrap();
1047 let res = vr
1048 .commit_changeset(&Changeset::new("idempotent-but-stale").update("a.md", "v1", stale));
1049 assert!(
1050 matches!(res, Err(Error::PreconditionFailed { ref path, .. }) if path == "a.md"),
1051 "stale precondition aborts even when the tree would be identical: {res:?}"
1052 );
1053 assert_eq!(vr.head_oid(), head_before, "nothing committed on abort");
1054 }
1055
1056 #[test]
1057 fn remove_absent_path_alone_is_noop() {
1058 let (_tmp, vr) = open_unborn();
1059 vr.commit_changeset(&Changeset::new("seed").create("a.md", "v1"))
1060 .unwrap();
1061 let head_before = vr.head_oid();
1062
1063 let res = vr
1065 .commit_changeset(&Changeset::new("rm ghost").remove("ghost.md"))
1066 .unwrap();
1067 assert!(res.no_op, "removing an absent path is a no-op");
1068 assert!(res.paths.is_empty());
1069 assert_eq!(vr.head_oid(), head_before, "HEAD unchanged");
1070 }
1071}