use crate::error::{Error, Result};
use crate::repo::VaultRepo;
use git2::Oid;
use std::path::{Path, PathBuf};
use tracing::instrument;
#[derive(Debug, Clone, Copy)]
pub enum MergeStrategy {
MergeCommit,
FastForward,
}
#[derive(Debug, Clone)]
pub struct MergeBackResult {
pub tip_after: Oid,
pub tip_before: Oid,
pub merge_commit: Option<Oid>,
}
#[derive(Debug, Clone)]
pub struct FanoutInfo {
pub wip_branch: String,
pub worktree_name: String,
pub worktree_path: PathBuf,
pub parent_tip: Oid,
pub main_branch: String,
}
pub struct FanoutWorktree<'a> {
main: &'a VaultRepo,
worktree_repo: VaultRepo,
info: FanoutInfo,
}
impl<'a> FanoutWorktree<'a> {
pub fn worktree_repo(&self) -> &VaultRepo {
&self.worktree_repo
}
pub fn wip_branch(&self) -> &str {
&self.info.wip_branch
}
pub fn parent_tip(&self) -> Oid {
self.info.parent_tip
}
pub fn info(&self) -> &FanoutInfo {
&self.info
}
#[instrument(
skip(self),
fields(
wip_branch = %self.info.wip_branch,
main_branch = %self.info.main_branch,
strategy = ?strategy,
),
name = "git_commit_fanout"
)]
pub fn commit_fanout(self, strategy: MergeStrategy) -> Result<MergeBackResult> {
self.commit_fanout_with_message(strategy, None)
}
pub fn commit_fanout_with_message(
self,
strategy: MergeStrategy,
message: Option<&str>,
) -> Result<MergeBackResult> {
self.main.merge_fanout_back(&self.info, strategy, message)
}
pub fn abandon_fanout(self) -> Result<()> {
self.main.abandon_fanout_by_info(&self.info)
}
}
fn cleanup_inner(
main: &VaultRepo,
wip_branch: &str,
worktree_name: &str,
worktree_path: &Path,
) -> Result<()> {
let repo = main.git();
let mut first_err: Option<Error> = None;
match std::fs::remove_dir_all(worktree_path) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => {
first_err.get_or_insert(Error::Io(e));
}
}
match repo.find_worktree(worktree_name) {
Ok(wt) => {
let mut opts = git2::WorktreePruneOptions::new();
opts.valid(true).working_tree(true).locked(true);
if let Err(e) = wt.prune(Some(&mut opts)) {
first_err.get_or_insert(Error::Git(e));
}
}
Err(e) if e.code() == git2::ErrorCode::NotFound => {} Err(e) => {
first_err.get_or_insert(Error::Git(e));
}
}
match repo.find_branch(wip_branch, git2::BranchType::Local) {
Ok(mut b) => {
if let Err(e) = b.delete() {
first_err.get_or_insert(Error::Git(e));
}
}
Err(e) if e.code() == git2::ErrorCode::NotFound => {}
Err(e) => {
first_err.get_or_insert(Error::Git(e));
}
}
match first_err {
Some(e) => Err(e),
None => Ok(()),
}
}
impl VaultRepo {
#[instrument(
skip(self),
fields(id = %id, worktree_path = ?worktree_path),
name = "git_begin_fanout"
)]
pub fn begin_fanout(&self, id: &str, worktree_path: &Path) -> Result<FanoutWorktree<'_>> {
let info = self.open_fanout_worktree(id, worktree_path)?;
let worktree_repo = VaultRepo::open_with_locks(worktree_path, self.commit_locks())?;
Ok(FanoutWorktree {
main: self,
worktree_repo,
info,
})
}
#[instrument(
skip(self),
fields(id = %id, worktree_path = ?worktree_path),
name = "git_open_fanout_worktree"
)]
pub fn open_fanout_worktree(&self, id: &str, worktree_path: &Path) -> Result<FanoutInfo> {
let main_branch = self.head_ref()?; let parent_tip = self
.head_oid()
.ok_or_else(|| Error::Other("cannot fan-out from an unborn branch".to_string()))?;
let wip_branch = format!("wip/{id}");
let worktree_name = format!("wip-{id}");
let parent_commit = self.git().find_commit(parent_tip)?;
let wip_branch_obj = self.git().branch(&wip_branch, &parent_commit, false)?;
let wip_ref = wip_branch_obj.into_reference();
let mut opts = git2::WorktreeAddOptions::new();
opts.reference(Some(&wip_ref));
self.git()
.worktree(&worktree_name, worktree_path, Some(&opts))?;
Ok(FanoutInfo {
wip_branch,
worktree_name,
worktree_path: worktree_path.to_path_buf(),
parent_tip,
main_branch,
})
}
#[instrument(
skip(self, info),
fields(
wip_branch = %info.wip_branch,
main_branch = %info.main_branch,
strategy = ?strategy,
),
name = "git_merge_fanout_back"
)]
pub fn merge_fanout_back(
&self,
info: &FanoutInfo,
strategy: MergeStrategy,
message: Option<&str>,
) -> Result<MergeBackResult> {
let result = self.with_commit_lock(|| merge_inner(self, info, strategy, message));
let _ = cleanup_inner(
self,
&info.wip_branch,
&info.worktree_name,
&info.worktree_path,
);
result
}
#[instrument(
skip(self, info),
fields(
wip_branch = %info.wip_branch,
worktree_name = %info.worktree_name,
),
name = "git_abandon_fanout_by_info"
)]
pub fn abandon_fanout_by_info(&self, info: &FanoutInfo) -> Result<()> {
cleanup_inner(
self,
&info.wip_branch,
&info.worktree_name,
&info.worktree_path,
)
}
pub fn list_orphan_fanouts(&self) -> Result<Vec<OrphanFanout>> {
let repo = self.git();
let names = repo.worktrees()?;
let mut out = Vec::new();
for i in 0..names.len() {
let Ok(Some(name)) = names.get(i) else {
continue;
};
let Some(id) = name.strip_prefix("wip-") else {
continue;
};
let wt = match repo.find_worktree(name) {
Ok(wt) => wt,
Err(_) => continue,
};
out.push(OrphanFanout {
worktree_name: name.to_string(),
wip_branch: format!("wip/{id}"),
worktree_path: wt.path().to_path_buf(),
});
}
Ok(out)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OrphanFanout {
pub worktree_name: String,
pub wip_branch: String,
pub worktree_path: PathBuf,
}
fn merge_inner(
main: &VaultRepo,
info: &FanoutInfo,
strategy: MergeStrategy,
message: Option<&str>,
) -> Result<MergeBackResult> {
let repo = main.git();
let wip_ref = format!("refs/heads/{}", info.wip_branch);
let wip_tip = repo
.refname_to_id(&wip_ref)
.map_err(|e| Error::Other(format!("wip branch {} missing: {e}", info.wip_branch)))?;
let main_tip_before = repo
.refname_to_id(&info.main_branch)
.map_err(|e| Error::Other(format!("main branch {} missing: {e}", info.main_branch)))?;
if wip_tip == info.parent_tip {
return Ok(MergeBackResult {
tip_after: main_tip_before,
tip_before: main_tip_before,
merge_commit: None,
});
}
match strategy {
MergeStrategy::FastForward => {
if main_tip_before != info.parent_tip {
return Err(Error::Other(format!(
"fast-forward merge-back failed: main advanced ({} -> {}) during the \
fan-out; use MergeCommit instead",
info.parent_tip, main_tip_before
)));
}
main.cas_ref(&info.main_branch, Some(main_tip_before), wip_tip)?;
let changed = main.paths_changed_between(main_tip_before, wip_tip)?;
main.materialize(wip_tip, &changed)?;
Ok(MergeBackResult {
tip_after: wip_tip,
tip_before: main_tip_before,
merge_commit: None,
})
}
MergeStrategy::MergeCommit => {
let base_tree = repo.find_commit(info.parent_tip)?.tree()?;
let ours_tree = repo.find_commit(main_tip_before)?.tree()?;
let theirs_tree = repo.find_commit(wip_tip)?.tree()?;
let mut idx = repo.merge_trees(&base_tree, &ours_tree, &theirs_tree, None)?;
if idx.has_conflicts() {
return Err(Error::Other(format!(
"merge-back conflict between main ({}) and wip {} ({}); \
resolve manually",
main_tip_before, info.wip_branch, wip_tip
)));
}
let merged_tree_oid = idx.write_tree_to(repo)?;
let message = message.map(str::to_string).unwrap_or_else(|| {
format!(
"merge fan-out {} into {}",
info.wip_branch, info.main_branch
)
});
let merge_commit_oid =
main.commit_tree(merged_tree_oid, &[main_tip_before, wip_tip], &message)?;
main.cas_ref(&info.main_branch, Some(main_tip_before), merge_commit_oid)?;
let changed = main.paths_changed_between(main_tip_before, merge_commit_oid)?;
main.materialize(merge_commit_oid, &changed)?;
Ok(MergeBackResult {
tip_after: merge_commit_oid,
tip_before: main_tip_before,
merge_commit: Some(merge_commit_oid),
})
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Changeset;
use git2::Repository;
use tempfile::TempDir;
fn open_born() -> (TempDir, TempDir, VaultRepo) {
let main_dir = TempDir::new().unwrap();
let scratch_parent = TempDir::new().unwrap();
let mut opts = git2::RepositoryInitOptions::new();
opts.initial_head("main");
Repository::init_opts(main_dir.path(), &opts).unwrap();
let vr = VaultRepo::open(main_dir.path()).unwrap();
vr.commit_changeset(&Changeset::new("seed").create("seed.md", "S"))
.unwrap();
(main_dir, scratch_parent, vr)
}
fn scratch_path(parent: &TempDir, id: &str) -> PathBuf {
parent.path().join(format!("worktree-{id}"))
}
fn wt_read(repo: &VaultRepo, rel: &str) -> String {
std::fs::read_to_string(repo.git().workdir().unwrap().join(rel)).unwrap()
}
#[test]
fn begin_isolates_worktree_main_untouched() {
let (_m, scratch, vr) = open_born();
let wt_path = scratch_path(&scratch, "1");
let fanout = vr.begin_fanout("1", &wt_path).unwrap();
let main_tip = vr.head_oid().unwrap();
assert_eq!(fanout.parent_tip(), main_tip);
assert_eq!(fanout.wip_branch(), "wip/1");
fanout
.worktree_repo()
.commit_changeset(&Changeset::new("c").create("a.md", "alpha"))
.unwrap();
assert_eq!(
vr.head_oid(),
Some(main_tip),
"main unchanged during fan-out"
);
assert_eq!(wt_read(fanout.worktree_repo(), "a.md"), "alpha");
assert!(!vr.git().workdir().unwrap().join("a.md").exists());
fanout.abandon_fanout().unwrap();
}
#[test]
fn commit_fanout_merge_commit_lands_on_main_with_two_parents() {
let (_m, scratch, vr) = open_born();
let main_tip_before = vr.head_oid().unwrap();
let wt_path = scratch_path(&scratch, "2");
let fanout = vr.begin_fanout("2", &wt_path).unwrap();
fanout
.worktree_repo()
.commit_changeset(&Changeset::new("c").create("a.md", "alpha"))
.unwrap();
let res = fanout.commit_fanout(MergeStrategy::MergeCommit).unwrap();
let merge_oid = res.merge_commit.expect("merge commit expected");
assert_eq!(vr.head_oid(), Some(merge_oid));
let merge_commit = vr.git().find_commit(merge_oid).unwrap();
assert_eq!(
merge_commit.parent_count(),
2,
"merge commit has two parents"
);
assert_eq!(merge_commit.parent_id(0).unwrap(), main_tip_before);
assert_eq!(wt_read(&vr, "a.md"), "alpha");
assert!(!wt_path.exists());
assert!(
vr.git()
.find_branch("wip/2", git2::BranchType::Local)
.is_err()
);
}
#[test]
fn commit_fanout_fast_forward_when_main_unchanged() {
let (_m, scratch, vr) = open_born();
let wt_path = scratch_path(&scratch, "3");
let fanout = vr.begin_fanout("3", &wt_path).unwrap();
fanout
.worktree_repo()
.commit_changeset(&Changeset::new("c").create("a.md", "alpha"))
.unwrap();
let res = fanout.commit_fanout(MergeStrategy::FastForward).unwrap();
assert!(res.merge_commit.is_none(), "FF makes no new commit object");
assert_eq!(vr.head_oid(), Some(res.tip_after));
assert_eq!(wt_read(&vr, "a.md"), "alpha");
}
#[test]
fn fast_forward_fails_when_main_advanced_concurrently() {
let (_m, scratch, vr) = open_born();
let wt_path = scratch_path(&scratch, "4");
let fanout = vr.begin_fanout("4", &wt_path).unwrap();
fanout
.worktree_repo()
.commit_changeset(&Changeset::new("c").create("a.md", "alpha"))
.unwrap();
vr.commit_changeset(&Changeset::new("concurrent").create("c.md", "concurrent"))
.unwrap();
let res = fanout.commit_fanout(MergeStrategy::FastForward);
assert!(
matches!(res, Err(Error::Other(_))),
"FF must refuse when main advanced"
);
}
#[test]
fn merge_commit_handles_concurrent_main_advance_disjoint() {
let (_m, scratch, vr) = open_born();
let wt_path = scratch_path(&scratch, "5");
let fanout = vr.begin_fanout("5", &wt_path).unwrap();
fanout
.worktree_repo()
.commit_changeset(&Changeset::new("c").create("a.md", "alpha"))
.unwrap();
vr.commit_changeset(&Changeset::new("concurrent").create("c.md", "concurrent"))
.unwrap();
let res = fanout.commit_fanout(MergeStrategy::MergeCommit).unwrap();
let merge_oid = res.merge_commit.unwrap();
let tree = vr.git().find_commit(merge_oid).unwrap().tree_id();
assert!(vr.blob_oid_at(tree, "a.md").unwrap().is_some());
assert!(vr.blob_oid_at(tree, "c.md").unwrap().is_some());
assert_eq!(wt_read(&vr, "a.md"), "alpha");
assert_eq!(wt_read(&vr, "c.md"), "concurrent");
}
#[test]
fn merge_commit_aborts_on_conflicting_same_path_edit() {
let (_m, scratch, vr) = open_born();
vr.commit_changeset(&Changeset::new("seed").create("shared.md", "base"))
.unwrap();
let base = crate::VaultRepo::blob_oid_of(b"base").unwrap();
let wt_path = scratch_path(&scratch, "conflict");
let fanout = vr.begin_fanout("conflict", &wt_path).unwrap();
fanout
.worktree_repo()
.commit_changeset(&Changeset::new("wip").update("shared.md", "wip-side", base))
.unwrap();
vr.commit_changeset(&Changeset::new("concurrent").update("shared.md", "main-side", base))
.unwrap();
let main_after_concurrent = vr.head_oid().unwrap();
let res = fanout.commit_fanout(MergeStrategy::MergeCommit);
assert!(
res.is_err(),
"conflicting same-path edit must abort: {res:?}"
);
assert!(
res.unwrap_err().to_string().contains("conflict"),
"loud conflict error"
);
assert_eq!(
vr.head_oid(),
Some(main_after_concurrent),
"main untouched by the aborted merge"
);
}
#[test]
fn abandon_leaves_main_untouched_and_cleans_up() {
let (_m, scratch, vr) = open_born();
let main_tip = vr.head_oid().unwrap();
let wt_path = scratch_path(&scratch, "6");
let fanout = vr.begin_fanout("6", &wt_path).unwrap();
fanout
.worktree_repo()
.commit_changeset(&Changeset::new("c").create("a.md", "alpha"))
.unwrap();
fanout.abandon_fanout().unwrap();
assert_eq!(vr.head_oid(), Some(main_tip), "main unchanged on abandon");
assert!(!wt_path.exists(), "worktree dir removed");
assert!(
vr.git()
.find_branch("wip/6", git2::BranchType::Local)
.is_err()
);
}
#[test]
fn empty_fanout_commit_is_a_noop() {
let (_m, scratch, vr) = open_born();
let main_tip = vr.head_oid().unwrap();
let wt_path = scratch_path(&scratch, "7");
let fanout = vr.begin_fanout("7", &wt_path).unwrap();
let res = fanout.commit_fanout(MergeStrategy::MergeCommit).unwrap();
assert!(res.merge_commit.is_none());
assert_eq!(res.tip_after, main_tip);
}
#[test]
fn stateless_open_returns_info_borrow_ends() {
let (_m, scratch, vr) = open_born();
let wt_path = scratch_path(&scratch, "stateless-1");
let info = vr.open_fanout_worktree("stateless-1", &wt_path).unwrap();
assert_eq!(info.wip_branch, "wip/stateless-1");
assert_eq!(info.worktree_name, "wip-stateless-1");
assert_eq!(info.worktree_path, wt_path);
assert_eq!(info.parent_tip, vr.head_oid().unwrap());
let info_clone = info.clone();
vr.abandon_fanout_by_info(&info_clone).unwrap();
assert!(!wt_path.exists());
}
#[test]
fn stateless_open_then_write_then_merge_back_lands_on_main() {
let (_m, scratch, vr) = open_born();
let wt_path = scratch_path(&scratch, "stateless-2");
let info = vr.open_fanout_worktree("stateless-2", &wt_path).unwrap();
let wt = VaultRepo::open_with_locks(&wt_path, vr.commit_locks()).unwrap();
wt.commit_changeset(&Changeset::new("c").create("page.md", "PAGE"))
.unwrap();
let res = vr
.merge_fanout_back(&info, MergeStrategy::MergeCommit, None)
.unwrap();
assert!(res.merge_commit.is_some(), "merge commit landed");
assert_eq!(wt_read(&vr, "page.md"), "PAGE");
assert!(!wt_path.exists(), "scratch worktree cleaned up");
}
#[test]
fn merge_fanout_back_uses_caller_supplied_message() {
let (_m, scratch, vr) = open_born();
let wt_path = scratch_path(&scratch, "msg-1");
let info = vr.open_fanout_worktree("msg-1", &wt_path).unwrap();
let wt = VaultRepo::open_with_locks(&wt_path, vr.commit_locks()).unwrap();
wt.commit_changeset(&Changeset::new("c").create("page.md", "PAGE"))
.unwrap();
let res = vr
.merge_fanout_back(&info, MergeStrategy::MergeCommit, Some("ingest source X"))
.unwrap();
let oid = res.merge_commit.expect("merge commit");
let msg = vr
.git()
.find_commit(oid)
.unwrap()
.message()
.unwrap()
.to_string();
assert_eq!(
msg, "ingest source X",
"caller message is the merge subject"
);
}
#[test]
fn stateless_abandon_after_writes_leaves_main_untouched() {
let (_m, scratch, vr) = open_born();
let main_tip = vr.head_oid().unwrap();
let wt_path = scratch_path(&scratch, "stateless-3");
let info = vr.open_fanout_worktree("stateless-3", &wt_path).unwrap();
let wt = VaultRepo::open_with_locks(&wt_path, vr.commit_locks()).unwrap();
wt.commit_changeset(&Changeset::new("c").create("orphan.md", "discarded"))
.unwrap();
vr.abandon_fanout_by_info(&info).unwrap();
assert_eq!(vr.head_oid(), Some(main_tip), "main unchanged");
assert!(!wt_path.exists());
assert!(
vr.git()
.find_branch("wip/stateless-3", git2::BranchType::Local)
.is_err()
);
}
#[test]
fn stateless_merge_back_no_commits_is_noop() {
let (_m, scratch, vr) = open_born();
let main_tip = vr.head_oid().unwrap();
let wt_path = scratch_path(&scratch, "stateless-4");
let info = vr.open_fanout_worktree("stateless-4", &wt_path).unwrap();
let res = vr
.merge_fanout_back(&info, MergeStrategy::MergeCommit, None)
.unwrap();
assert!(res.merge_commit.is_none());
assert_eq!(res.tip_after, main_tip);
}
#[test]
fn list_orphan_fanouts_empty_when_no_worktrees() {
let (_m, _scratch, vr) = open_born();
assert!(vr.list_orphan_fanouts().unwrap().is_empty());
}
#[test]
fn list_orphan_fanouts_detects_open_wip_worktree() {
let (_m, scratch, vr) = open_born();
let wt_path = scratch_path(&scratch, "orphan-1");
let info = vr.open_fanout_worktree("orphan-1", &wt_path).unwrap();
let orphans = vr.list_orphan_fanouts().unwrap();
assert_eq!(orphans.len(), 1);
assert_eq!(orphans[0].worktree_name, "wip-orphan-1");
assert_eq!(orphans[0].wip_branch, "wip/orphan-1");
assert_eq!(
orphans[0].worktree_path.canonicalize().unwrap(),
wt_path.canonicalize().unwrap()
);
vr.abandon_fanout_by_info(&info).unwrap();
}
#[test]
fn list_orphan_fanouts_skips_non_wip_worktrees() {
let (_m, scratch, vr) = open_born();
let wt_path = scratch.path().join("worktree-other");
let head_oid = vr.head_oid().unwrap();
let head_commit = vr.git().find_commit(head_oid).unwrap();
let feature_branch = vr.git().branch("feature-x", &head_commit, false).unwrap();
let feature_ref = feature_branch.into_reference();
let mut opts = git2::WorktreeAddOptions::new();
opts.reference(Some(&feature_ref));
let _wt = vr
.git()
.worktree("notwip-1", &wt_path, Some(&opts))
.unwrap();
let orphans = vr.list_orphan_fanouts().unwrap();
assert!(
orphans.is_empty(),
"non-wip worktree should not be reported, got: {:?}",
orphans
);
}
#[test]
fn list_orphan_fanouts_detects_after_abandon_is_empty() {
let (_m, scratch, vr) = open_born();
let wt_path = scratch_path(&scratch, "orphan-2");
let info = vr.open_fanout_worktree("orphan-2", &wt_path).unwrap();
assert_eq!(vr.list_orphan_fanouts().unwrap().len(), 1);
vr.abandon_fanout_by_info(&info).unwrap();
assert!(
vr.list_orphan_fanouts().unwrap().is_empty(),
"abandon should remove the orphan entry"
);
}
}