Skip to main content

turbovault_git/
fanout.rs

1//! Fan-out worktree mode (GWS.9).
2//!
3//! `begin_fanout` opens a **scratch git worktree** on a `wip/<id>` branch
4//! forked from main's current tip. All changesets applied through that
5//! worktree's [`VaultRepo`] commit to the wip branch — they share the parent's
6//! object DB but use a separate working tree + index. Obsidian, pointed at
7//! main's working tree, stays stable for the whole fan-out.
8//!
9//! When the fan-out is done, `commit_fanout` merges the wip branch back into
10//! main (configurable strategy) and cleans up the scratch worktree + branch.
11//! `abandon_fanout` discards the fan-out (no commits land on main).
12//!
13//! The fan-out's worktree gets a separate per-worktree commit mutex (it's a
14//! different worktree key), so changesets inside the fan-out never contend
15//! with main's commit mutex.
16
17use crate::error::{Error, Result};
18use crate::repo::VaultRepo;
19use git2::Oid;
20use std::path::{Path, PathBuf};
21use tracing::instrument;
22
23/// How a fan-out merges back into main.
24#[derive(Debug, Clone, Copy)]
25pub enum MergeStrategy {
26    /// `git merge --no-ff` — a merge commit on main with main's tip and the
27    /// wip tip as parents. Preserves the wip branch's per-changeset commits
28    /// (no squash). The default.
29    MergeCommit,
30    /// Advance main's ref directly to the wip tip — fails if main has moved
31    /// since the fan-out began (would create a fork; use `MergeCommit` then).
32    FastForward,
33}
34
35/// Result of a successful merge-back.
36#[derive(Debug, Clone)]
37pub struct MergeBackResult {
38    /// Main's tip after the merge-back.
39    pub tip_after: Oid,
40    /// Main's tip before the merge-back (the CAS expected-old).
41    pub tip_before: Oid,
42    /// `Some(oid)` of the merge commit (`MergeCommit` strategy); `None` for a
43    /// pure fast-forward (no new commit object, just a ref advance).
44    pub merge_commit: Option<Oid>,
45}
46
47/// Stateless handle to an open fan-out scratch worktree — everything needed
48/// to merge OR abandon the fan-out later, without holding a borrowed
49/// [`FanoutWorktree`] across the wait (e.g. between MCP tool calls).
50///
51/// Returned by [`VaultRepo::open_fanout_worktree`]; consumed by
52/// [`VaultRepo::merge_fanout_back`] and [`VaultRepo::abandon_fanout_by_info`].
53/// The MCP layer uses this triple; the in-process programmatic API
54/// ([`FanoutWorktree`]) wraps the same info for ergonomic borrowing.
55#[derive(Debug, Clone)]
56pub struct FanoutInfo {
57    pub wip_branch: String,
58    pub worktree_name: String,
59    pub worktree_path: PathBuf,
60    pub parent_tip: Oid,
61    pub main_branch: String,
62}
63
64/// An open fan-out scratch worktree. Hold txns through `worktree_repo()`;
65/// finalize via `commit_fanout` (merge back) or `abandon_fanout` (discard).
66pub struct FanoutWorktree<'a> {
67    main: &'a VaultRepo,
68    worktree_repo: VaultRepo,
69    info: FanoutInfo,
70}
71
72impl<'a> FanoutWorktree<'a> {
73    /// The substrate handle for changesets inside the fan-out.
74    pub fn worktree_repo(&self) -> &VaultRepo {
75        &self.worktree_repo
76    }
77
78    /// The wip branch the fan-out commits to (`wip/<id>`).
79    pub fn wip_branch(&self) -> &str {
80        &self.info.wip_branch
81    }
82
83    /// Main's tip when the fan-out began.
84    pub fn parent_tip(&self) -> Oid {
85        self.info.parent_tip
86    }
87
88    /// The full info handle (stateless; usable by `merge_fanout_back` /
89    /// `abandon_fanout_by_info` when this `FanoutWorktree`'s borrow ends).
90    pub fn info(&self) -> &FanoutInfo {
91        &self.info
92    }
93
94    /// Merge the fan-out back into main and clean up the scratch worktree.
95    /// On any error the fan-out artifacts may be left behind; call
96    /// `abandon_fanout` to clean up explicitly.
97    #[instrument(
98        skip(self),
99        fields(
100            wip_branch = %self.info.wip_branch,
101            main_branch = %self.info.main_branch,
102            strategy = ?strategy,
103        ),
104        name = "git_commit_fanout"
105    )]
106    pub fn commit_fanout(self, strategy: MergeStrategy) -> Result<MergeBackResult> {
107        self.commit_fanout_with_message(strategy, None)
108    }
109
110    /// turbovault-b1q: like [`Self::commit_fanout`] but with a caller-supplied
111    /// merge-commit subject (used by the MCP `commit_transaction` tool's
112    /// `commit_message`). `None` falls back to the auto-derived message.
113    pub fn commit_fanout_with_message(
114        self,
115        strategy: MergeStrategy,
116        message: Option<&str>,
117    ) -> Result<MergeBackResult> {
118        // Delegate to the stateless API so behavior is identical to the
119        // MCP path. `main.merge_fanout_back` does the lock + merge + cleanup.
120        self.main.merge_fanout_back(&self.info, strategy, message)
121    }
122
123    /// Discard the fan-out: nothing lands on main; scratch worktree + wip
124    /// branch removed.
125    pub fn abandon_fanout(self) -> Result<()> {
126        self.main.abandon_fanout_by_info(&self.info)
127    }
128}
129
130/// Prune the scratch worktree + delete the wip branch. Tolerant: try every
131/// step; if one fails we still attempt the rest, then return the first error.
132fn cleanup_inner(
133    main: &VaultRepo,
134    wip_branch: &str,
135    worktree_name: &str,
136    worktree_path: &Path,
137) -> Result<()> {
138    let repo = main.git();
139    let mut first_err: Option<Error> = None;
140
141    // (a) Remove the worktree's working-tree directory. Ignore NotFound (the
142    // dir may already be gone, e.g. if the caller cleaned it up).
143    match std::fs::remove_dir_all(worktree_path) {
144        Ok(()) => {}
145        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
146        Err(e) => {
147            first_err.get_or_insert(Error::Io(e));
148        }
149    }
150    // (b) Prune the .git/worktrees/<name>/ metadata.
151    match repo.find_worktree(worktree_name) {
152        Ok(wt) => {
153            let mut opts = git2::WorktreePruneOptions::new();
154            opts.valid(true).working_tree(true).locked(true);
155            if let Err(e) = wt.prune(Some(&mut opts)) {
156                first_err.get_or_insert(Error::Git(e));
157            }
158        }
159        Err(e) if e.code() == git2::ErrorCode::NotFound => {} // already pruned
160        Err(e) => {
161            first_err.get_or_insert(Error::Git(e));
162        }
163    }
164    // (c) Delete the wip branch.
165    match repo.find_branch(wip_branch, git2::BranchType::Local) {
166        Ok(mut b) => {
167            if let Err(e) = b.delete() {
168                first_err.get_or_insert(Error::Git(e));
169            }
170        }
171        Err(e) if e.code() == git2::ErrorCode::NotFound => {}
172        Err(e) => {
173            first_err.get_or_insert(Error::Git(e));
174        }
175    }
176    match first_err {
177        Some(e) => Err(e),
178        None => Ok(()),
179    }
180}
181
182impl VaultRepo {
183    /// Open a fan-out scratch worktree (GWS.9). Creates a wip branch
184    /// `wip/<id>` at this repo's current HEAD commit, then creates a git
185    /// worktree at `worktree_path` (must be OUTSIDE main's working tree —
186    /// git refuses nested worktrees). Returns a [`FanoutWorktree`] whose
187    /// `worktree_repo()` is the substrate handle for all txns inside the
188    /// fan-out.
189    ///
190    /// Errors if this branch is unborn (no commit to fork from) or detached.
191    #[instrument(
192        skip(self),
193        fields(id = %id, worktree_path = ?worktree_path),
194        name = "git_begin_fanout"
195    )]
196    pub fn begin_fanout(&self, id: &str, worktree_path: &Path) -> Result<FanoutWorktree<'_>> {
197        let info = self.open_fanout_worktree(id, worktree_path)?;
198        // Open the worktree as a VaultRepo, sharing the commit-lock registry.
199        let worktree_repo = VaultRepo::open_with_locks(worktree_path, self.commit_locks())?;
200        Ok(FanoutWorktree {
201            main: self,
202            worktree_repo,
203            info,
204        })
205    }
206
207    /// Stateless variant of [`Self::begin_fanout`] — does the same work but
208    /// returns a [`FanoutInfo`] handle that survives the call boundary
209    /// (where `FanoutWorktree<'a>`'s borrow on `&self` does not). The MCP
210    /// `begin_transaction` tool uses this so it can return to the agent
211    /// between the begin call and the eventual `commit_transaction` /
212    /// `abandon_transaction`.
213    ///
214    /// Same preconditions: branch must be born + not detached.
215    #[instrument(
216        skip(self),
217        fields(id = %id, worktree_path = ?worktree_path),
218        name = "git_open_fanout_worktree"
219    )]
220    pub fn open_fanout_worktree(&self, id: &str, worktree_path: &Path) -> Result<FanoutInfo> {
221        let main_branch = self.head_ref()?; // errors if detached
222        let parent_tip = self
223            .head_oid()
224            .ok_or_else(|| Error::Other("cannot fan-out from an unborn branch".to_string()))?;
225
226        let wip_branch = format!("wip/{id}");
227        let worktree_name = format!("wip-{id}");
228
229        // Create the wip branch off main's tip.
230        let parent_commit = self.git().find_commit(parent_tip)?;
231        let wip_branch_obj = self.git().branch(&wip_branch, &parent_commit, false)?;
232        let wip_ref = wip_branch_obj.into_reference();
233
234        // Create the git worktree on the wip branch.
235        let mut opts = git2::WorktreeAddOptions::new();
236        opts.reference(Some(&wip_ref));
237        self.git()
238            .worktree(&worktree_name, worktree_path, Some(&opts))?;
239
240        Ok(FanoutInfo {
241            wip_branch,
242            worktree_name,
243            worktree_path: worktree_path.to_path_buf(),
244            parent_tip,
245            main_branch,
246        })
247    }
248
249    /// Stateless merge-back. Mirrors [`FanoutWorktree::commit_fanout`] but
250    /// takes the info handle instead of consuming a borrowed `FanoutWorktree`.
251    /// Holds main's commit lock for the critical section and ALWAYS attempts
252    /// cleanup (worktree + wip branch) — even on merge error.
253    #[instrument(
254        skip(self, info),
255        fields(
256            wip_branch = %info.wip_branch,
257            main_branch = %info.main_branch,
258            strategy = ?strategy,
259        ),
260        name = "git_merge_fanout_back"
261    )]
262    pub fn merge_fanout_back(
263        &self,
264        info: &FanoutInfo,
265        strategy: MergeStrategy,
266        message: Option<&str>,
267    ) -> Result<MergeBackResult> {
268        let result = self.with_commit_lock(|| merge_inner(self, info, strategy, message));
269        let _ = cleanup_inner(
270            self,
271            &info.wip_branch,
272            &info.worktree_name,
273            &info.worktree_path,
274        );
275        result
276    }
277
278    /// Stateless abandon — cleanup the worktree + wip branch without
279    /// touching main.
280    #[instrument(
281        skip(self, info),
282        fields(
283            wip_branch = %info.wip_branch,
284            worktree_name = %info.worktree_name,
285        ),
286        name = "git_abandon_fanout_by_info"
287    )]
288    pub fn abandon_fanout_by_info(&self, info: &FanoutInfo) -> Result<()> {
289        cleanup_inner(
290            self,
291            &info.wip_branch,
292            &info.worktree_name,
293            &info.worktree_path,
294        )
295    }
296
297    /// Scan this repo's registered worktrees for `wip-*` entries — fanout
298    /// artifacts left over from a previous session. Pure read; never mutates.
299    /// Caller decides whether to clean each one up (via
300    /// [`Self::abandon_fanout_by_info`] if they can rebuild the [`FanoutInfo`], or
301    /// manually via `git worktree remove` + `git branch -D`).
302    pub fn list_orphan_fanouts(&self) -> Result<Vec<OrphanFanout>> {
303        let repo = self.git();
304        let names = repo.worktrees()?;
305        let mut out = Vec::new();
306        for i in 0..names.len() {
307            let Ok(Some(name)) = names.get(i) else {
308                continue;
309            };
310            let Some(id) = name.strip_prefix("wip-") else {
311                continue;
312            };
313            let wt = match repo.find_worktree(name) {
314                Ok(wt) => wt,
315                Err(_) => continue,
316            };
317            out.push(OrphanFanout {
318                worktree_name: name.to_string(),
319                wip_branch: format!("wip/{id}"),
320                worktree_path: wt.path().to_path_buf(),
321            });
322        }
323        Ok(out)
324    }
325}
326
327/// One fan-out artifact (`wip-<id>` worktree + `wip/<id>` branch) found on
328/// disk by [`VaultRepo::list_orphan_fanouts`]. Whether a given entry is
329/// truly "orphan" — i.e. not tracked by a live caller — is a server-layer
330/// concern; the substrate just enumerates.
331#[derive(Debug, Clone, PartialEq, Eq)]
332pub struct OrphanFanout {
333    pub worktree_name: String,
334    pub wip_branch: String,
335    pub worktree_path: PathBuf,
336}
337
338/// Implementation extracted from the old `FanoutWorktree::merge_back` so
339/// the stateless and borrowed APIs share one body.
340fn merge_inner(
341    main: &VaultRepo,
342    info: &FanoutInfo,
343    strategy: MergeStrategy,
344    message: Option<&str>,
345) -> Result<MergeBackResult> {
346    let repo = main.git();
347    let wip_ref = format!("refs/heads/{}", info.wip_branch);
348
349    let wip_tip = repo
350        .refname_to_id(&wip_ref)
351        .map_err(|e| Error::Other(format!("wip branch {} missing: {e}", info.wip_branch)))?;
352    let main_tip_before = repo
353        .refname_to_id(&info.main_branch)
354        .map_err(|e| Error::Other(format!("main branch {} missing: {e}", info.main_branch)))?;
355
356    // If the fan-out made no commits, the wip branch still points at the
357    // parent tip — there is nothing to merge back. Treat as a no-op success.
358    if wip_tip == info.parent_tip {
359        return Ok(MergeBackResult {
360            tip_after: main_tip_before,
361            tip_before: main_tip_before,
362            merge_commit: None,
363        });
364    }
365
366    match strategy {
367        MergeStrategy::FastForward => {
368            if main_tip_before != info.parent_tip {
369                return Err(Error::Other(format!(
370                    "fast-forward merge-back failed: main advanced ({} -> {}) during the \
371                     fan-out; use MergeCommit instead",
372                    info.parent_tip, main_tip_before
373                )));
374            }
375            main.cas_ref(&info.main_branch, Some(main_tip_before), wip_tip)?;
376            let changed = main.paths_changed_between(main_tip_before, wip_tip)?;
377            main.materialize(wip_tip, &changed)?;
378            Ok(MergeBackResult {
379                tip_after: wip_tip,
380                tip_before: main_tip_before,
381                merge_commit: None,
382            })
383        }
384        MergeStrategy::MergeCommit => {
385            let base_tree = repo.find_commit(info.parent_tip)?.tree()?;
386            let ours_tree = repo.find_commit(main_tip_before)?.tree()?;
387            let theirs_tree = repo.find_commit(wip_tip)?.tree()?;
388            let mut idx = repo.merge_trees(&base_tree, &ours_tree, &theirs_tree, None)?;
389            if idx.has_conflicts() {
390                return Err(Error::Other(format!(
391                    "merge-back conflict between main ({}) and wip {} ({}); \
392                     resolve manually",
393                    main_tip_before, info.wip_branch, wip_tip
394                )));
395            }
396            let merged_tree_oid = idx.write_tree_to(repo)?;
397            // turbovault-b1q: caller-supplied merge-commit subject (the fanout
398            // merge-back is a mutation that produces a commit); fall back to the
399            // auto-derived message when none is given.
400            let message = message.map(str::to_string).unwrap_or_else(|| {
401                format!(
402                    "merge fan-out {} into {}",
403                    info.wip_branch, info.main_branch
404                )
405            });
406            let merge_commit_oid =
407                main.commit_tree(merged_tree_oid, &[main_tip_before, wip_tip], &message)?;
408            main.cas_ref(&info.main_branch, Some(main_tip_before), merge_commit_oid)?;
409            let changed = main.paths_changed_between(main_tip_before, merge_commit_oid)?;
410            main.materialize(merge_commit_oid, &changed)?;
411            Ok(MergeBackResult {
412                tip_after: merge_commit_oid,
413                tip_before: main_tip_before,
414                merge_commit: Some(merge_commit_oid),
415            })
416        }
417    }
418}
419
420#[cfg(test)]
421mod tests {
422    use super::*;
423    use crate::Changeset;
424    use git2::Repository;
425    use tempfile::TempDir;
426
427    /// Init main repo + apply one seed commit so main is BORN (begin_fanout
428    /// requires a tip to fork from).
429    fn open_born() -> (TempDir, TempDir, VaultRepo) {
430        let main_dir = TempDir::new().unwrap();
431        // Worktree path must live OUTSIDE main's workdir. Hold its TempDir in
432        // its parent so it survives until both are dropped.
433        let scratch_parent = TempDir::new().unwrap();
434        let mut opts = git2::RepositoryInitOptions::new();
435        opts.initial_head("main");
436        Repository::init_opts(main_dir.path(), &opts).unwrap();
437        let vr = VaultRepo::open(main_dir.path()).unwrap();
438        vr.commit_changeset(&Changeset::new("seed").create("seed.md", "S"))
439            .unwrap();
440        (main_dir, scratch_parent, vr)
441    }
442
443    fn scratch_path(parent: &TempDir, id: &str) -> PathBuf {
444        parent.path().join(format!("worktree-{id}"))
445    }
446
447    fn wt_read(repo: &VaultRepo, rel: &str) -> String {
448        std::fs::read_to_string(repo.git().workdir().unwrap().join(rel)).unwrap()
449    }
450
451    #[test]
452    fn begin_isolates_worktree_main_untouched() {
453        let (_m, scratch, vr) = open_born();
454        let wt_path = scratch_path(&scratch, "1");
455        let fanout = vr.begin_fanout("1", &wt_path).unwrap();
456
457        // The wip branch was created off main's tip.
458        let main_tip = vr.head_oid().unwrap();
459        assert_eq!(fanout.parent_tip(), main_tip);
460        assert_eq!(fanout.wip_branch(), "wip/1");
461
462        // Apply a txn in the fan-out — main's tip is UNCHANGED.
463        fanout
464            .worktree_repo()
465            .commit_changeset(&Changeset::new("c").create("a.md", "alpha"))
466            .unwrap();
467        assert_eq!(
468            vr.head_oid(),
469            Some(main_tip),
470            "main unchanged during fan-out"
471        );
472        // The worktree's working tree has the new file; main's does not.
473        assert_eq!(wt_read(fanout.worktree_repo(), "a.md"), "alpha");
474        assert!(!vr.git().workdir().unwrap().join("a.md").exists());
475
476        fanout.abandon_fanout().unwrap();
477    }
478
479    #[test]
480    fn commit_fanout_merge_commit_lands_on_main_with_two_parents() {
481        let (_m, scratch, vr) = open_born();
482        let main_tip_before = vr.head_oid().unwrap();
483        let wt_path = scratch_path(&scratch, "2");
484        let fanout = vr.begin_fanout("2", &wt_path).unwrap();
485        fanout
486            .worktree_repo()
487            .commit_changeset(&Changeset::new("c").create("a.md", "alpha"))
488            .unwrap();
489
490        let res = fanout.commit_fanout(MergeStrategy::MergeCommit).unwrap();
491
492        let merge_oid = res.merge_commit.expect("merge commit expected");
493        assert_eq!(vr.head_oid(), Some(merge_oid));
494        let merge_commit = vr.git().find_commit(merge_oid).unwrap();
495        assert_eq!(
496            merge_commit.parent_count(),
497            2,
498            "merge commit has two parents"
499        );
500        assert_eq!(merge_commit.parent_id(0).unwrap(), main_tip_before);
501        // Main's working tree now contains the fan-out's file.
502        assert_eq!(wt_read(&vr, "a.md"), "alpha");
503        // Scratch worktree + wip branch cleaned up.
504        assert!(!wt_path.exists());
505        assert!(
506            vr.git()
507                .find_branch("wip/2", git2::BranchType::Local)
508                .is_err()
509        );
510    }
511
512    #[test]
513    fn commit_fanout_fast_forward_when_main_unchanged() {
514        let (_m, scratch, vr) = open_born();
515        let wt_path = scratch_path(&scratch, "3");
516        let fanout = vr.begin_fanout("3", &wt_path).unwrap();
517        fanout
518            .worktree_repo()
519            .commit_changeset(&Changeset::new("c").create("a.md", "alpha"))
520            .unwrap();
521
522        let res = fanout.commit_fanout(MergeStrategy::FastForward).unwrap();
523        assert!(res.merge_commit.is_none(), "FF makes no new commit object");
524        assert_eq!(vr.head_oid(), Some(res.tip_after));
525        assert_eq!(wt_read(&vr, "a.md"), "alpha");
526    }
527
528    #[test]
529    fn fast_forward_fails_when_main_advanced_concurrently() {
530        let (_m, scratch, vr) = open_born();
531        let wt_path = scratch_path(&scratch, "4");
532        let fanout = vr.begin_fanout("4", &wt_path).unwrap();
533        fanout
534            .worktree_repo()
535            .commit_changeset(&Changeset::new("c").create("a.md", "alpha"))
536            .unwrap();
537
538        // Concurrent writer on main (e.g. cross-process / Workflow B) advances
539        // main while the fan-out was working.
540        vr.commit_changeset(&Changeset::new("concurrent").create("c.md", "concurrent"))
541            .unwrap();
542
543        let res = fanout.commit_fanout(MergeStrategy::FastForward);
544        assert!(
545            matches!(res, Err(Error::Other(_))),
546            "FF must refuse when main advanced"
547        );
548    }
549
550    #[test]
551    fn merge_commit_handles_concurrent_main_advance_disjoint() {
552        let (_m, scratch, vr) = open_born();
553        let wt_path = scratch_path(&scratch, "5");
554        let fanout = vr.begin_fanout("5", &wt_path).unwrap();
555        fanout
556            .worktree_repo()
557            .commit_changeset(&Changeset::new("c").create("a.md", "alpha"))
558            .unwrap();
559
560        // Concurrent main writer touches a DISJOINT path.
561        vr.commit_changeset(&Changeset::new("concurrent").create("c.md", "concurrent"))
562            .unwrap();
563
564        let res = fanout.commit_fanout(MergeStrategy::MergeCommit).unwrap();
565        let merge_oid = res.merge_commit.unwrap();
566        let tree = vr.git().find_commit(merge_oid).unwrap().tree_id();
567        // Both changes present in the merged tree.
568        assert!(vr.blob_oid_at(tree, "a.md").unwrap().is_some());
569        assert!(vr.blob_oid_at(tree, "c.md").unwrap().is_some());
570        // Working tree matches.
571        assert_eq!(wt_read(&vr, "a.md"), "alpha");
572        assert_eq!(wt_read(&vr, "c.md"), "concurrent");
573    }
574
575    /// turbovault-uag: a CONFLICTING same-path edit on main vs. the fanout
576    /// worktree must abort the merge-back loudly and leave main untouched —
577    /// never a silent 3-way text merge. Every prior merge test used disjoint
578    /// paths, so this conflict branch was unverified.
579    #[test]
580    fn merge_commit_aborts_on_conflicting_same_path_edit() {
581        let (_m, scratch, vr) = open_born();
582        // Seed a shared file on main so both sides edit the SAME path.
583        vr.commit_changeset(&Changeset::new("seed").create("shared.md", "base"))
584            .unwrap();
585        let base = crate::VaultRepo::blob_oid_of(b"base").unwrap();
586
587        let wt_path = scratch_path(&scratch, "conflict");
588        let fanout = vr.begin_fanout("conflict", &wt_path).unwrap();
589        // wip edits shared.md one way...
590        fanout
591            .worktree_repo()
592            .commit_changeset(&Changeset::new("wip").update("shared.md", "wip-side", base))
593            .unwrap();
594        // ...main edits the SAME path a different way (concurrent).
595        vr.commit_changeset(&Changeset::new("concurrent").update("shared.md", "main-side", base))
596            .unwrap();
597        let main_after_concurrent = vr.head_oid().unwrap();
598
599        // Merge-back must ABORT — no silent text merge.
600        let res = fanout.commit_fanout(MergeStrategy::MergeCommit);
601        assert!(
602            res.is_err(),
603            "conflicting same-path edit must abort: {res:?}"
604        );
605        assert!(
606            res.unwrap_err().to_string().contains("conflict"),
607            "loud conflict error"
608        );
609        // Main never advanced past the concurrent edit (no merge landed).
610        assert_eq!(
611            vr.head_oid(),
612            Some(main_after_concurrent),
613            "main untouched by the aborted merge"
614        );
615    }
616
617    #[test]
618    fn abandon_leaves_main_untouched_and_cleans_up() {
619        let (_m, scratch, vr) = open_born();
620        let main_tip = vr.head_oid().unwrap();
621        let wt_path = scratch_path(&scratch, "6");
622        let fanout = vr.begin_fanout("6", &wt_path).unwrap();
623        fanout
624            .worktree_repo()
625            .commit_changeset(&Changeset::new("c").create("a.md", "alpha"))
626            .unwrap();
627
628        fanout.abandon_fanout().unwrap();
629
630        assert_eq!(vr.head_oid(), Some(main_tip), "main unchanged on abandon");
631        assert!(!wt_path.exists(), "worktree dir removed");
632        assert!(
633            vr.git()
634                .find_branch("wip/6", git2::BranchType::Local)
635                .is_err()
636        );
637    }
638
639    #[test]
640    fn empty_fanout_commit_is_a_noop() {
641        let (_m, scratch, vr) = open_born();
642        let main_tip = vr.head_oid().unwrap();
643        let wt_path = scratch_path(&scratch, "7");
644        let fanout = vr.begin_fanout("7", &wt_path).unwrap();
645        // No txns applied — wip tip == parent_tip.
646        let res = fanout.commit_fanout(MergeStrategy::MergeCommit).unwrap();
647        assert!(res.merge_commit.is_none());
648        assert_eq!(res.tip_after, main_tip);
649    }
650
651    // -------- GWS.13 stateless fanout API --------
652
653    #[test]
654    fn stateless_open_returns_info_borrow_ends() {
655        let (_m, scratch, vr) = open_born();
656        let wt_path = scratch_path(&scratch, "stateless-1");
657        let info = vr.open_fanout_worktree("stateless-1", &wt_path).unwrap();
658        assert_eq!(info.wip_branch, "wip/stateless-1");
659        assert_eq!(info.worktree_name, "wip-stateless-1");
660        assert_eq!(info.worktree_path, wt_path);
661        assert_eq!(info.parent_tip, vr.head_oid().unwrap());
662
663        // info survives — we can now drop it / move it / pass through MCP.
664        let info_clone = info.clone();
665        vr.abandon_fanout_by_info(&info_clone).unwrap();
666        assert!(!wt_path.exists());
667    }
668
669    #[test]
670    fn stateless_open_then_write_then_merge_back_lands_on_main() {
671        let (_m, scratch, vr) = open_born();
672        let wt_path = scratch_path(&scratch, "stateless-2");
673        let info = vr.open_fanout_worktree("stateless-2", &wt_path).unwrap();
674
675        // Open the worktree separately (the stateless API doesn't return a
676        // VaultRepo handle — caller manages that lifecycle).
677        let wt = VaultRepo::open_with_locks(&wt_path, vr.commit_locks()).unwrap();
678        wt.commit_changeset(&Changeset::new("c").create("page.md", "PAGE"))
679            .unwrap();
680
681        let res = vr
682            .merge_fanout_back(&info, MergeStrategy::MergeCommit, None)
683            .unwrap();
684        assert!(res.merge_commit.is_some(), "merge commit landed");
685        assert_eq!(wt_read(&vr, "page.md"), "PAGE");
686        assert!(!wt_path.exists(), "scratch worktree cleaned up");
687    }
688
689    /// turbovault-b1q: a caller-supplied merge message becomes the merge-commit
690    /// subject; `None` falls back to the auto-derived message.
691    #[test]
692    fn merge_fanout_back_uses_caller_supplied_message() {
693        let (_m, scratch, vr) = open_born();
694        let wt_path = scratch_path(&scratch, "msg-1");
695        let info = vr.open_fanout_worktree("msg-1", &wt_path).unwrap();
696        let wt = VaultRepo::open_with_locks(&wt_path, vr.commit_locks()).unwrap();
697        wt.commit_changeset(&Changeset::new("c").create("page.md", "PAGE"))
698            .unwrap();
699
700        let res = vr
701            .merge_fanout_back(&info, MergeStrategy::MergeCommit, Some("ingest source X"))
702            .unwrap();
703        let oid = res.merge_commit.expect("merge commit");
704        let msg = vr
705            .git()
706            .find_commit(oid)
707            .unwrap()
708            .message()
709            .unwrap()
710            .to_string();
711        assert_eq!(
712            msg, "ingest source X",
713            "caller message is the merge subject"
714        );
715    }
716
717    #[test]
718    fn stateless_abandon_after_writes_leaves_main_untouched() {
719        let (_m, scratch, vr) = open_born();
720        let main_tip = vr.head_oid().unwrap();
721        let wt_path = scratch_path(&scratch, "stateless-3");
722        let info = vr.open_fanout_worktree("stateless-3", &wt_path).unwrap();
723
724        let wt = VaultRepo::open_with_locks(&wt_path, vr.commit_locks()).unwrap();
725        wt.commit_changeset(&Changeset::new("c").create("orphan.md", "discarded"))
726            .unwrap();
727
728        vr.abandon_fanout_by_info(&info).unwrap();
729        assert_eq!(vr.head_oid(), Some(main_tip), "main unchanged");
730        assert!(!wt_path.exists());
731        assert!(
732            vr.git()
733                .find_branch("wip/stateless-3", git2::BranchType::Local)
734                .is_err()
735        );
736    }
737
738    #[test]
739    fn stateless_merge_back_no_commits_is_noop() {
740        let (_m, scratch, vr) = open_born();
741        let main_tip = vr.head_oid().unwrap();
742        let wt_path = scratch_path(&scratch, "stateless-4");
743        let info = vr.open_fanout_worktree("stateless-4", &wt_path).unwrap();
744        // No writes through wt — merge_back should be a no-op.
745        let res = vr
746            .merge_fanout_back(&info, MergeStrategy::MergeCommit, None)
747            .unwrap();
748        assert!(res.merge_commit.is_none());
749        assert_eq!(res.tip_after, main_tip);
750    }
751
752    #[test]
753    fn list_orphan_fanouts_empty_when_no_worktrees() {
754        let (_m, _scratch, vr) = open_born();
755        assert!(vr.list_orphan_fanouts().unwrap().is_empty());
756    }
757
758    #[test]
759    fn list_orphan_fanouts_detects_open_wip_worktree() {
760        let (_m, scratch, vr) = open_born();
761        let wt_path = scratch_path(&scratch, "orphan-1");
762        let info = vr.open_fanout_worktree("orphan-1", &wt_path).unwrap();
763        let orphans = vr.list_orphan_fanouts().unwrap();
764        assert_eq!(orphans.len(), 1);
765        assert_eq!(orphans[0].worktree_name, "wip-orphan-1");
766        assert_eq!(orphans[0].wip_branch, "wip/orphan-1");
767        // git2 may canonicalize paths; compare by canonical form.
768        assert_eq!(
769            orphans[0].worktree_path.canonicalize().unwrap(),
770            wt_path.canonicalize().unwrap()
771        );
772        // Cleanup so the temp dirs drop cleanly.
773        vr.abandon_fanout_by_info(&info).unwrap();
774    }
775
776    #[test]
777    fn list_orphan_fanouts_skips_non_wip_worktrees() {
778        let (_m, scratch, vr) = open_born();
779        // Create a worktree on a NEW branch (not main, since main is
780        // checked out in the primary worktree). Name it without the `wip-`
781        // prefix to verify the filter.
782        let wt_path = scratch.path().join("worktree-other");
783        let head_oid = vr.head_oid().unwrap();
784        let head_commit = vr.git().find_commit(head_oid).unwrap();
785        let feature_branch = vr.git().branch("feature-x", &head_commit, false).unwrap();
786        let feature_ref = feature_branch.into_reference();
787        let mut opts = git2::WorktreeAddOptions::new();
788        opts.reference(Some(&feature_ref));
789        let _wt = vr
790            .git()
791            .worktree("notwip-1", &wt_path, Some(&opts))
792            .unwrap();
793        let orphans = vr.list_orphan_fanouts().unwrap();
794        assert!(
795            orphans.is_empty(),
796            "non-wip worktree should not be reported, got: {:?}",
797            orphans
798        );
799    }
800
801    #[test]
802    fn list_orphan_fanouts_detects_after_abandon_is_empty() {
803        let (_m, scratch, vr) = open_born();
804        let wt_path = scratch_path(&scratch, "orphan-2");
805        let info = vr.open_fanout_worktree("orphan-2", &wt_path).unwrap();
806        assert_eq!(vr.list_orphan_fanouts().unwrap().len(), 1);
807        vr.abandon_fanout_by_info(&info).unwrap();
808        assert!(
809            vr.list_orphan_fanouts().unwrap().is_empty(),
810            "abandon should remove the orphan entry"
811        );
812    }
813}