Skip to main content

turbovault_git/
changeset.rs

1//! Changeset = one commit (GWS.7).
2//!
3//! A [`Changeset`] is a set of tree changes plus per-file preconditions and a
4//! commit message. [`VaultRepo::commit_changeset`] runs the full pipeline as
5//! **one commit**, under the worktree commit lock:
6//!
7//! 1. acquire the per-worktree commit lock (GWS.6);
8//! 2. `commit_with_retry` on the current branch ref (GWS.3): each attempt
9//!    resolves the tip to its base tree, **checks preconditions** against that
10//!    base (GWS.4) — a mismatch aborts the whole changeset (the
11//!    reconsideration domino), no retry — then builds the new tree in an
12//!    isolated index (GWS.2) and `commit-tree`s on the tip. On a ref CAS loss
13//!    the tip is re-read and the attempt retried, which re-checks preconditions
14//!    on the new base, so a concurrent change to one of the changeset's own
15//!    paths surfaces as an abort, not a silent overwrite;
16//! 3. **materialize** the committed paths into the working tree (GWS.5);
17//! 4. release the lock.
18//!
19//! Single-file writes are a degenerate one-change changeset; batches and
20//! move+link-updates are multi-change changesets — all atomic, one commit.
21
22use 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/// A unit of change applied as a single commit.
31#[derive(Debug, Clone, Default)]
32pub struct Changeset {
33    message: String,
34    changes: Vec<TreeChange>,
35    preconditions: Vec<Precondition>,
36}
37
38impl Changeset {
39    /// Start a changeset with a commit message.
40    pub fn new(message: impl Into<String>) -> Self {
41        Self {
42            message: message.into(),
43            ..Default::default()
44        }
45    }
46
47    /// Add or overwrite a file.
48    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    /// Remove a file.
57    pub fn remove(mut self, path: impl Into<String>) -> Self {
58        self.changes.push(TreeChange::Remove { path: path.into() });
59        self
60    }
61
62    /// Add an arbitrary [`TreeChange`].
63    pub fn with_change(mut self, change: TreeChange) -> Self {
64        self.changes.push(change);
65        self
66    }
67
68    /// Require `path` to currently hold `blob` (an update of known content).
69    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    /// Require `path` to currently be absent (a create).
76    pub fn expect_absent(mut self, path: impl Into<String>) -> Self {
77        self.preconditions.push(Precondition::expect_absent(path));
78        self
79    }
80
81    /// Add an arbitrary [`Precondition`] (e.g. over a page read but not written,
82    /// extending the multi-file CAS to the changeset's read set).
83    pub fn with_precondition(mut self, precondition: Precondition) -> Self {
84        self.preconditions.push(precondition);
85        self
86    }
87
88    // -------- Semantic ops --------
89    // These compose the raw primitives (`upsert`/`remove`/preconditions) with the
90    // safe-by-default precondition policy. Use them for the standard ops; reach
91    // for the raw builders only when you explicitly want a blind write.
92
93    /// Create a new file. Precondition: `path` must currently be absent.
94    /// Aborts the changeset if the path exists.
95    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    /// Update an existing file. Precondition: `path` must currently hold
106    /// `expected` (the version token the caller read). Protects against lost
107    /// updates — a concurrent change to `path` aborts the changeset.
108    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    /// Delete an existing file. Precondition: it must currently hold `expected`.
125    /// Aborts if the file changed or is absent since the caller read it.
126    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    /// Atomic rename: move `from` (which must currently hold `expected_from`)
135    /// to `to` (which must currently be absent), as **one commit**. Both
136    /// endpoints get preconditions. `content` is the bytes to write at `to` —
137    /// usually the caller passes the source's bytes unchanged for a pure
138    /// rename; passing different bytes is a rename-and-modify. Chain
139    /// `.update()`/`.upsert()` after this for link-target updates in the same
140    /// commit (move + link-updates is atomic).
141    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    /// The distinct paths this changeset mutates (the materialization set).
162    fn changed_paths(&self) -> Vec<String> {
163        self.changes.iter().map(|c| c.path().to_string()).collect()
164    }
165
166    /// turbovault-lri: enumerate the paths this changeset mutates so
167    /// higher layers can apply policies like the `include_ignored`
168    /// gitignore-refusal check before submission. Same content as the
169    /// private `changed_paths` helper; exposed for consumers in
170    /// `turbovault-tools`.
171    pub fn touched_paths(&self) -> Vec<String> {
172        self.changed_paths()
173    }
174}
175
176/// Outcome of a committed changeset.
177#[derive(Debug, Clone)]
178pub struct ChangesetResult {
179    /// The commit the branch points at. For a no-op changeset
180    /// (`no_op == true`) this is the *unchanged* HEAD — nothing was committed.
181    pub commit: Oid,
182    /// The paths materialized into the working tree. Empty when `no_op`.
183    pub paths: Vec<String>,
184    /// turbovault-4nc: `true` when the changeset's changes produced a tree
185    /// identical to the parent's (an idempotent / no-effect write). The
186    /// substrate skipped the commit, ref CAS, materialize, and reindex hook —
187    /// the working tree already matched HEAD. Preconditions are still checked
188    /// first, so a stale read aborts even when the result would be identical.
189    pub no_op: bool,
190}
191
192impl VaultRepo {
193    /// Apply `txn` as a single commit (see the module docs for the pipeline).
194    ///
195    /// Aborts with [`Error::PreconditionFailed`] if any precondition is stale
196    /// (nothing committed, working tree untouched) and with [`Error::Other`] for
197    /// an empty changeset or duplicate change paths.
198    #[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        // A path mutated twice in one changeset is ambiguous — reject it.
212        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()?; // errors if HEAD is detached
223        let changed = txn.changed_paths();
224
225        self.with_commit_lock(|| {
226            // `parent_at_apply` is captured INSIDE `commit_with_retry`'s
227            // success closure so the post-commit hook reports the correct
228            // first parent even after a CAS-rebuild loop (the parent we
229            // committed against, NOT the parent at function entry, which
230            // may be stale).
231            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                // Abort the whole changeset if any precondition is stale.
240                // This MUST run before the identity-tree short-circuit below: a
241                // stale read aborts loudly (the reconsideration domino) even
242                // when the resulting tree would be identical, because the read
243                // was against a now-changed base.
244                self.check_preconditions(base_tree, &txn.preconditions)?;
245                let tree = self.build_tree(base_tree, &txn.changes)?;
246                // turbovault-4nc: identity-tree short-circuit. If the changes
247                // produce a tree byte-identical to the base (an idempotent
248                // rewrite, a remove of an already-absent path, ...), there is
249                // nothing to commit — return `None` so `commit_with_retry`
250                // skips the ref CAS. The working tree already matches HEAD, so
251                // materialize + the reindex hook are skipped below too.
252                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                    // Reveal the commit to the working tree (still under the lock).
262                    self.materialize(commit, &changed)?;
263
264                    // Fire the GWS.14 reindex hook inside the commit lock so the
265                    // queue observes commits in commit order (matches the order
266                    // a future drainer must replay them).
267                    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                    // Identity tree -> no commit, no CAS, no materialize, no
279                    // hook. `parent_at_apply` is the tip we evaluated (whose
280                    // preconditions passed); an identity tree implies a
281                    // non-unborn base, so it is always `Some` here.
282                    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        // Caller thinks a.md still holds "stale" content.
425        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"); // remove of absent path is a no-op in the tree
449        let res = vr.commit_changeset(&txn).unwrap();
450
451        // Exactly one commit; both writes present.
452        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        // Multi-file CAS over the read set: the txn writes a.md but also asserts
465        // b.md is unchanged. If b.md moved, the whole batch aborts even though we
466        // never write b.md.
467        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        // The move+links shape (GWS.8 will build these): remove old + add new in
505        // one atomic commit.
506        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        // One commit carried both the removal and the add.
518        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    // -------- Semantic constructors (GWS.8) --------
524
525    #[test]
526    fn create_on_absent_succeeds_create_on_existing_fails() {
527        let (_tmp, vr) = open_unborn();
528        // create succeeds when path is absent.
529        vr.commit_changeset(&Changeset::new("c").create("a.md", "alpha"))
530            .unwrap();
531        assert_eq!(read_wt(&vr, "a.md"), "alpha");
532
533        // create on an existing path fails (expect_absent precondition).
534        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        // Update with stale expected (still v1, but file is now v2) aborts.
555        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        // Stale expected -> abort, file still there.
567        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        // Correct expected -> file gone.
573        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        // Move + update-links: rename old->new AND fix link targets in two other
635        // files, all in one atomic commit (the case the legacy batch couldn't).
636        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        // All four file changes landed in ONE commit.
658        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    // -------- GWS.14: commit hook --------
666
667    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        // Stale precondition -> reconsideration domino -> no commit.
743        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        // Sanity: open_with_locks (no hook) still applies cleanly.
756        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    // -------- turbovault-uag: mutation-testing survivors (cargo-mutants) --------
762
763    /// `touched_paths` must list EVERY changed path (a surviving mutant replaced
764    /// it with `vec![]` / `vec![""]` — the gitignore-gate + reindex layers rely
765    /// on this).
766    #[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    /// The raw escape hatches `with_change` / `with_precondition` must actually
782    /// register the change / precondition (surviving mutants dropped them).
783    #[test]
784    fn raw_with_change_and_with_precondition_take_effect() {
785        let (_tmp, vr) = open_unborn();
786        // with_change(Upsert) lands the file.
787        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        // with_precondition(expect_absent) on an EXISTING path must abort — and
795        // specifically on the PRECONDITION, not because a dropped builder left an
796        // empty txn (the txn carries a real upsert, so an empty-txn error would
797        // mean with_precondition discarded the chain).
798        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    /// `git_commit_first_parent` must resolve the real parent chain (a survivor
814    /// replaced it with `Ok(None)`); the reindex drainer depends on it.
815    #[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    /// tlx.5: `first_parent_range` must return EVERY commit a multi-commit jump
838    /// introduced (oldest-first), and `None` on a non-fast-forward target so the
839    /// ref listener falls back to tip-only.
840    #[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        // (c1, c3] = [c2, c3], oldest-first.
859        assert_eq!(
860            vr.first_parent_range(Some(c1), c3).unwrap(),
861            Some(vec![c2, c3])
862        );
863        // No stop = the whole chain back to root.
864        assert_eq!(
865            vr.first_parent_range(None, c3).unwrap(),
866            Some(vec![c1, c2, c3])
867        );
868        // stop == tip = empty range (nothing new).
869        assert_eq!(vr.first_parent_range(Some(c3), c3).unwrap(), Some(vec![]));
870        // Non-ff: a stop that does NOT precede the tip (c1 doesn't descend from
871        // c3) has no clean range -> None -> caller falls back to tip-only.
872        assert_eq!(vr.first_parent_range(Some(c3), c1).unwrap(), None);
873    }
874
875    /// hq8: a `stop` reachable from `tip` ONLY through a merge's SECOND parent
876    /// is not on the first-parent chain — `first_parent_range` must return None
877    /// (fallback), NOT walk past it to root and re-enqueue all of history (the
878    /// graph_descendant_of bug coderabbit caught).
879    #[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        // f1: a side-branch commit off c1 (same tree, distinct commit).
892        let c1_tree = vr.git().find_commit(c1).unwrap().tree_id();
893        let f1 = vr.commit_tree(c1_tree, &[c1], "f1").unwrap();
894        // m: a merge whose FIRST parent is c2 and SECOND parent is f1.
895        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        // f1 is reachable from m only via the 2nd parent -> not on the
899        // first-parent chain -> None (fallback), not the whole history.
900        assert_eq!(vr.first_parent_range(Some(f1), m).unwrap(), None);
901        // sanity: a stop ON the first-parent chain still yields the range.
902        assert_eq!(
903            vr.first_parent_range(Some(c1), m).unwrap(),
904            Some(vec![c2, m])
905        );
906    }
907
908    /// `is_path_ignored` must honor `.gitignore` (survivors hard-coded
909    /// `Ok(false)` / `Ok(true)`); the substrate's include_ignored gate uses it.
910    #[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    /// turbovault-xw4: a move-shaped multi-file txn (remove old + new blob + a
925    /// linker rewrite) must abort ATOMICALLY when ANY participant's precondition
926    /// is stale — here the LINKER (not the source). Prior coverage only staled
927    /// the source. Zero files change.
928    #[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); // stale linker precondition
947        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    /// turbovault-xw4 / PERF-2: identity-tree elision is per-TREE, not
960    /// per-change. A txn mixing a no-op sub-change (rewrite a.md with identical
961    /// bytes) with a REAL change (create b.md) must still commit — never be
962    /// elided as a no-op.
963    #[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) // identity for a.md
975                    .create("b.md", "B"), // real change
976            )
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    // -------- turbovault-4nc: identity-tree no-op short-circuit --------
985
986    #[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        // Rewrite a.md with the SAME content + correct precondition: the
994        // resulting tree is identical to the base -> no-op.
995        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        // Same content (the tree WOULD be identical) but a stale precondition:
1044        // the abort must win — preconditions are checked before the identity
1045        // short-circuit, so a stale read never silently passes as a no-op.
1046        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        // Removing a path that isn't in the tree leaves the tree unchanged.
1064        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}