1use crate::error::{Error, Result};
18use crate::repo::VaultRepo;
19use git2::Oid;
20use std::path::{Path, PathBuf};
21use tracing::instrument;
22
23#[derive(Debug, Clone, Copy)]
25pub enum MergeStrategy {
26 MergeCommit,
30 FastForward,
33}
34
35#[derive(Debug, Clone)]
37pub struct MergeBackResult {
38 pub tip_after: Oid,
40 pub tip_before: Oid,
42 pub merge_commit: Option<Oid>,
45}
46
47#[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
64pub struct FanoutWorktree<'a> {
67 main: &'a VaultRepo,
68 worktree_repo: VaultRepo,
69 info: FanoutInfo,
70}
71
72impl<'a> FanoutWorktree<'a> {
73 pub fn worktree_repo(&self) -> &VaultRepo {
75 &self.worktree_repo
76 }
77
78 pub fn wip_branch(&self) -> &str {
80 &self.info.wip_branch
81 }
82
83 pub fn parent_tip(&self) -> Oid {
85 self.info.parent_tip
86 }
87
88 pub fn info(&self) -> &FanoutInfo {
91 &self.info
92 }
93
94 #[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 pub fn commit_fanout_with_message(
114 self,
115 strategy: MergeStrategy,
116 message: Option<&str>,
117 ) -> Result<MergeBackResult> {
118 self.main.merge_fanout_back(&self.info, strategy, message)
121 }
122
123 pub fn abandon_fanout(self) -> Result<()> {
126 self.main.abandon_fanout_by_info(&self.info)
127 }
128}
129
130fn 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 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 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 => {} Err(e) => {
161 first_err.get_or_insert(Error::Git(e));
162 }
163 }
164 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 #[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 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 #[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()?; 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 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 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 #[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 #[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 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#[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
338fn 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 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 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 fn open_born() -> (TempDir, TempDir, VaultRepo) {
430 let main_dir = TempDir::new().unwrap();
431 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 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 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 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 assert_eq!(wt_read(&vr, "a.md"), "alpha");
503 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 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 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 assert!(vr.blob_oid_at(tree, "a.md").unwrap().is_some());
569 assert!(vr.blob_oid_at(tree, "c.md").unwrap().is_some());
570 assert_eq!(wt_read(&vr, "a.md"), "alpha");
572 assert_eq!(wt_read(&vr, "c.md"), "concurrent");
573 }
574
575 #[test]
580 fn merge_commit_aborts_on_conflicting_same_path_edit() {
581 let (_m, scratch, vr) = open_born();
582 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 fanout
591 .worktree_repo()
592 .commit_changeset(&Changeset::new("wip").update("shared.md", "wip-side", base))
593 .unwrap();
594 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 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 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 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 #[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 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 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 #[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 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 assert_eq!(
769 orphans[0].worktree_path.canonicalize().unwrap(),
770 wt_path.canonicalize().unwrap()
771 );
772 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 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}