This repository has been archived on 2026-10-08. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
maestro/crates/maestro-daemon/tests/worktree.rs
T
Miguel Palhas eb4655a9d2 test(maestro): update collision tests for branch suffixing
49ab7e9 made a new-branch worktree add suffix the branch name instead of
failing, and documented it, but left two tests asserting the old hard
rejection. The rejection contract (#66) still holds for an existing
branch that is already checked out, so that test now exercises that
case.
2026-09-04 20:02:20 +01:00

575 lines
20 KiB
Rust

//! Worktree-manager integration tests against a real `git` binary and a
//! tmpdir-backed source repo.
mod common;
use std::path::PathBuf;
use std::process::Command;
use common::{init_bare_repo, init_repo, push_all_to_origin};
use maestro_daemon::worktree::WorktreeManager;
use maestro_daemon::worktree_probe::in_the_way;
use tempfile::TempDir;
fn worktree_branches(repo: &PathBuf) -> Vec<String> {
let out = Command::new("git")
.arg("-C")
.arg(repo)
.args(["worktree", "list", "--porcelain"])
.output()
.expect("git worktree list");
assert!(out.status.success());
let s = String::from_utf8_lossy(&out.stdout);
s.lines()
.filter_map(|l| l.strip_prefix("branch refs/heads/").map(String::from))
.collect()
}
#[test]
fn add_creates_worktree_on_new_branch() {
let repo_dir = TempDir::new().unwrap();
let repo = repo_dir.path().to_path_buf();
init_repo(&repo);
let wt_root = TempDir::new().unwrap();
let mgr = WorktreeManager::new(Some(wt_root.path().to_path_buf()));
let path = mgr.add_new(&repo, "feat/example").expect("add worktree");
assert!(path.exists());
assert!(path.join("README").exists());
assert!(path.starts_with(wt_root.path()));
let branches = worktree_branches(&repo);
assert!(branches.iter().any(|b| b == "feat/example"), "{branches:?}");
}
#[test]
fn add_places_worktree_inside_the_base_repo_by_default() {
let repo_dir = TempDir::new().unwrap();
let repo = repo_dir.path().to_path_buf();
init_repo(&repo);
// No override: the worktree must land where the repository keeps its
// own worktrees (#56), not under some daemon state dir.
let mgr = WorktreeManager::new(None);
let path = mgr.add_new(&repo, "feat/example").expect("add worktree");
assert_eq!(path, repo.join("worktrees").join("feat").join("example"));
assert!(path.exists());
assert!(path.join("README").exists());
let branches = worktree_branches(&repo);
assert!(branches.iter().any(|b| b == "feat/example"), "{branches:?}");
}
#[test]
fn worktree_root_override_redirects_placement() {
let repo_dir = TempDir::new().unwrap();
let repo = repo_dir.path().to_path_buf();
init_repo(&repo);
let wt_root = TempDir::new().unwrap();
let mgr = WorktreeManager::new(Some(wt_root.path().to_path_buf()));
// The repo's basename sits between the root and the branch slug, so two
// repos spawning the same branch name don't target one directory.
let repo_name = repo.file_name().unwrap();
let path = mgr.add_new(&repo, "feat/example").expect("add worktree");
// The override root disambiguates same-named checkouts with a digest of
// the full repo path, and the branch keeps its own shape.
// <root>/<repo basename>-<digest>/feat/example
let prefix = format!("{}-", repo_name.to_string_lossy());
let repo_dir_name = path
.strip_prefix(wt_root.path())
.unwrap()
.components()
.next()
.unwrap();
assert!(
repo_dir_name
.as_os_str()
.to_string_lossy()
.starts_with(&prefix),
"{path:?}"
);
assert!(path.ends_with("feat/example"), "{path:?}");
assert!(path.exists());
}
/// Anything at all occupying the target path — ours, the operator's, or an
/// unrelated directory — is sidestepped, not adopted and not reported (#77).
#[test]
fn an_occupied_target_path_gets_a_numeric_suffix() {
let repo_dir = TempDir::new().unwrap();
let repo = repo_dir.path().to_path_buf();
init_repo(&repo);
// A non-git directory sits exactly where the worktree would go.
let squatter = repo.join("worktrees").join("feat").join("collide");
std::fs::create_dir_all(&squatter).unwrap();
std::fs::write(squatter.join("operator-notes.txt"), b"mine\n").unwrap();
let mgr = WorktreeManager::new(None);
let path = mgr.add_new(&repo, "feat/collide").expect("sidesteps");
assert_eq!(path, repo.join("worktrees").join("feat").join("collide-2"));
assert!(path.exists());
assert!(
squatter.join("operator-notes.txt").exists(),
"the occupant is untouched"
);
}
#[test]
fn add_twice_generates_distinct_paths() {
let repo_dir = TempDir::new().unwrap();
let repo = repo_dir.path().to_path_buf();
init_repo(&repo);
let wt_root = TempDir::new().unwrap();
let mgr = WorktreeManager::new(Some(wt_root.path().to_path_buf()));
let p1 = mgr.add_new(&repo, "feat/a").expect("add a");
let p2 = mgr.add_new(&repo, "feat/b").expect("add b");
assert_ne!(p1, p2);
assert!(p1.exists());
assert!(p2.exists());
}
/// The unsaved-work probe (#79) counts uncommitted tracked changes.
#[test]
fn the_probe_counts_uncommitted_tracked_changes() {
let repo_dir = TempDir::new().unwrap();
let repo = repo_dir.path().to_path_buf();
init_repo(&repo);
let mgr = WorktreeManager::new(None);
let path = mgr.add_new(&repo, "feat/dirty").expect("add");
std::fs::write(path.join("README"), b"unsaved edits\n").unwrap();
let w = in_the_way(&path).expect("probe");
assert_eq!(w.uncommitted_files, 1);
assert_eq!(w.untracked_files, 0);
}
/// Untracked sources count; a staged new file is also unsaved tracked work.
#[test]
fn the_probe_counts_untracked_files() {
let repo_dir = TempDir::new().unwrap();
let repo = repo_dir.path().to_path_buf();
init_repo(&repo);
let mgr = WorktreeManager::new(None);
let path = mgr.add_new(&repo, "feat/untracked").expect("add");
std::fs::write(path.join("notes.txt"), b"wip\n").unwrap();
std::fs::write(path.join("more.txt"), b"also wip\n").unwrap();
let w = in_the_way(&path).expect("probe");
assert_eq!(w.untracked_files, 2);
assert_eq!(w.uncommitted_files, 0);
run_git(&path, &["add", "notes.txt"]);
let w = in_the_way(&path).expect("probe");
assert_eq!(w.uncommitted_files, 1, "staged counts as uncommitted");
assert_eq!(w.untracked_files, 1);
}
/// Ignored files NEVER block: every Rust checkout has a `target/`, and
/// counting ignored output would leave no session killable (#79).
#[test]
fn the_probe_ignores_ignored_files() {
let repo_dir = TempDir::new().unwrap();
let repo = repo_dir.path().to_path_buf();
init_repo(&repo);
// .gitignore must be committed, or it would itself read as untracked.
std::fs::write(repo.join(".gitignore"), b"/target\n").unwrap();
run_git(&repo, &["add", ".gitignore"]);
run_git(&repo, &["commit", "-q", "-m", "ignore target"]);
let mgr = WorktreeManager::new(None);
let path = mgr.add_new(&repo, "feat/builds").expect("add");
std::fs::create_dir_all(path.join("target")).unwrap();
std::fs::write(path.join("target").join("app.o"), b"binary\n").unwrap();
let w = in_the_way(&path).expect("probe");
assert_eq!(w.uncommitted_files, 0);
assert_eq!(w.untracked_files, 0);
// With the branch pushed too, an ignored-only tree reads fully clean.
let origin_dir = TempDir::new().unwrap();
init_bare_repo(origin_dir.path());
push_all_to_origin(&repo, origin_dir.path());
let w = in_the_way(&path).expect("probe after push");
assert!(w.is_clear(), "ignored-only worktree must read clean: {w:?}");
}
/// Commits reachable from no remote-tracking ref block; pushing the branch
/// clears them. With no remote at all every commit is unreachable from one,
/// which is exactly why feature branches almost always refuse (#79).
#[test]
fn the_probe_counts_unpushed_commits_until_the_branch_is_pushed() {
let repo_dir = TempDir::new().unwrap();
let repo = repo_dir.path().to_path_buf();
init_repo(&repo);
let mgr = WorktreeManager::new(None);
let path = mgr.add_new(&repo, "feat/branch").expect("add");
let w = in_the_way(&path).expect("probe");
assert!(
w.unpushed_commits >= 1,
"a fresh branch has no remote-tracking ref: {w:?}"
);
let origin_dir = TempDir::new().unwrap();
init_bare_repo(origin_dir.path());
push_all_to_origin(&repo, origin_dir.path());
let w = in_the_way(&path).expect("probe after push");
assert_eq!(w.unpushed_commits, 0, "{w:?}");
assert!(w.is_clear(), "pushed and clean must be empty: {w:?}");
}
/// The full breakdown names each blocker with its count.
#[test]
fn the_probe_reports_every_blocker_at_once() {
let repo_dir = TempDir::new().unwrap();
let repo = repo_dir.path().to_path_buf();
init_repo(&repo);
let mgr = WorktreeManager::new(None);
let path = mgr.add_new(&repo, "feat/mixed").expect("add");
std::fs::write(path.join("README"), b"edited\n").unwrap();
std::fs::write(path.join("scratch.txt"), b"untracked\n").unwrap();
let w = in_the_way(&path).expect("probe");
assert_eq!(w.uncommitted_files, 1);
assert_eq!(w.untracked_files, 1);
assert!(w.unpushed_commits >= 1);
let summary = w.to_string();
assert!(summary.contains("1 uncommitted file"), "{summary}");
assert!(summary.contains("1 untracked file"), "{summary}");
assert!(summary.contains("unpushed commit"), "{summary}");
}
#[test]
fn remove_prunes_worktree() {
let repo_dir = TempDir::new().unwrap();
let repo = repo_dir.path().to_path_buf();
init_repo(&repo);
let wt_root = TempDir::new().unwrap();
let mgr = WorktreeManager::new(Some(wt_root.path().to_path_buf()));
let path = mgr.add_new(&repo, "feat/gone").expect("add");
assert!(path.exists());
mgr.remove(&repo, &path).expect("remove");
assert!(!path.exists());
let branches = worktree_branches(&repo);
// The branch itself still exists, but the worktree should be gone — only
// the base repo's main branch should show in `worktree list`.
assert_eq!(branches, vec!["main".to_string()]);
}
#[test]
fn add_existing_checks_out_branch_without_creating_it() {
let repo_dir = TempDir::new().unwrap();
let repo = repo_dir.path().to_path_buf();
init_repo(&repo);
// Pre-create the branch with its own commit so we can tell a checkout
// from an accidental `-b`.
let created = Command::new("git")
.arg("-C")
.arg(&repo)
.args(["checkout", "-q", "-b", "feat/dup"])
.status()
.unwrap();
assert!(created.success());
std::fs::write(repo.join("FEATURE"), b"dup\n").unwrap();
let staged = Command::new("git")
.arg("-C")
.arg(&repo)
.args(["add", "."])
.status()
.unwrap();
assert!(staged.success());
let committed = Command::new("git")
.arg("-C")
.arg(&repo)
.args(["commit", "-q", "-am", "wip"])
.status()
.unwrap();
assert!(committed.success());
let back = Command::new("git")
.arg("-C")
.arg(&repo)
.args(["checkout", "-q", "main"])
.status()
.unwrap();
assert!(back.success());
let wt_root = TempDir::new().unwrap();
let mgr = WorktreeManager::new(Some(wt_root.path().to_path_buf()));
let path = mgr.add_existing(&repo, "feat/dup").expect("add existing");
assert!(path.exists());
assert!(path.join("README").exists());
// The branch set is unchanged: `feat/dup` was checked out, not recreated.
let branches = worktree_branches(&repo);
let mut sorted = branches.clone();
sorted.sort();
assert_eq!(
sorted,
vec!["feat/dup".to_string(), "main".to_string()],
"{branches:?}"
);
}
/// Removal is guarded upstream now (#79): the kill refuses unsaved work
/// before anything is torn down, so by the time `remove` runs the tree is
/// clean. Git still refuses a dirty tree on its own — this asserts that last
/// line of defense — and committed-but-unpushed work does not block removal,
/// because `worktree remove` keeps `refs/heads/<branch>` and the commits stay
/// reachable.
#[test]
fn remove_still_relies_on_git_refusing_a_dirty_tree() {
let repo_dir = TempDir::new().unwrap();
let repo = repo_dir.path().to_path_buf();
init_repo(&repo);
let mgr = WorktreeManager::new(None);
let path = mgr.add_new(&repo, "feat/dirty").expect("add");
std::fs::write(path.join("README"), b"unsaved edits\n").unwrap();
mgr.remove(&repo, &path)
.expect_err("dirty worktree must be refused");
assert!(path.exists(), "guarded worktree must stay on disk");
run_git(&path, &["add", "."]);
run_git(&path, &["commit", "-q", "-m", "saved"]);
mgr.remove(&repo, &path).expect("committed work can go");
assert!(!path.exists());
assert!(
worktree_branches(&repo).contains(&"main".to_string()),
"the branch survives removal"
);
let branches = String::from_utf8(
Command::new("git")
.arg("-C")
.arg(&repo)
.args(["for-each-ref", "--format=%(refname:short)", "refs/heads"])
.output()
.unwrap()
.stdout,
)
.unwrap();
assert!(branches.contains("feat/dirty"), "{branches}");
}
/// A worktree whose directory vanished must be pruned, not misread as holding
/// unsaved work — otherwise the registration is stranded and, because paths
/// are deterministic, that branch can never be spawned again (#56 review).
#[test]
fn remove_prunes_a_worktree_whose_directory_vanished() {
let repo_dir = TempDir::new().unwrap();
let repo = repo_dir.path().to_path_buf();
init_repo(&repo);
let mgr = WorktreeManager::new(None);
let path = mgr.add_new(&repo, "feat/gone").expect("add");
std::fs::remove_dir_all(&path).unwrap();
mgr.remove(&repo, &path)
.expect("a vanished worktree prunes");
// The registration is gone too, so the same branch can be spawned again.
mgr.add_existing(&repo, "feat/gone")
.expect("branch is spawnable again");
}
fn run_git(dir: &std::path::Path, args: &[&str]) {
let out = Command::new("git")
.arg("-C")
.arg(dir)
.args(args)
.output()
.expect("git spawn");
assert!(
out.status.success(),
"git {args:?} failed: {}",
String::from_utf8_lossy(&out.stderr)
);
}
/// 49ab7e9: a new-branch add onto a name that already exists suffixes the
/// branch (and so the directory) instead of failing, per docs/WORKTREES.md.
#[test]
fn add_new_suffixes_when_branch_already_exists() {
let repo_dir = TempDir::new().unwrap();
let repo = repo_dir.path().to_path_buf();
init_repo(&repo);
// pre-create the branch
let status = Command::new("git")
.arg("-C")
.arg(&repo)
.args(["branch", "feat/dup"])
.status()
.unwrap();
assert!(status.success());
let wt_root = TempDir::new().unwrap();
let mgr = WorktreeManager::new(Some(wt_root.path().to_path_buf()));
let path = mgr.add_new(&repo, "feat/dup").unwrap();
assert!(path.ends_with("feat/dup-2"), "{}", path.display());
assert!(path.is_dir());
}
/// The project picker lists linked worktrees as pickable rows, so `base_repo`
/// is easily a worktree rather than the main checkout. Placing inside it would
/// nest one worktree in another — and git hides a registered worktree's
/// contents from the outer `git status`, so the outer tree reads clean and
/// removing it deletes the inner one's uncommitted work (#54 review).
#[test]
fn placement_resolves_a_linked_worktree_to_the_main_checkout() {
let repo_dir = TempDir::new().unwrap();
let repo = repo_dir.path().to_path_buf();
init_repo(&repo);
let mgr = WorktreeManager::new(None);
let outer = mgr.add_new(&repo, "feat/outer").expect("add outer");
// Spawn again with the *worktree* as the base, the way the picker would.
let inner = mgr.add_new(&outer, "feat/inner").expect("add inner");
assert!(
!inner.starts_with(&outer),
"must not nest: {inner:?} inside {outer:?}"
);
assert!(
inner.starts_with(repo.canonicalize().unwrap().join("worktrees")),
"placed against the main checkout: {inner:?}"
);
}
/// Belt and braces for a nesting that predates this daemon: refuse to remove
/// a worktree that has another registered underneath it.
#[test]
fn remove_refuses_a_worktree_holding_a_nested_one() {
let repo_dir = TempDir::new().unwrap();
let repo = repo_dir.path().to_path_buf();
init_repo(&repo);
let mgr = WorktreeManager::new(None);
let outer = mgr.add_new(&repo, "feat/outer").expect("add outer");
// Register one by hand inside `outer`, bypassing our placement.
let nested = outer.join("worktrees").join("hand-rolled");
run_git(
&repo,
&[
"worktree",
"add",
"-b",
"feat/hand",
nested.to_str().unwrap(),
],
);
std::fs::write(nested.join("notes.txt"), b"unsaved\n").unwrap();
mgr.remove(&repo, &outer)
.expect_err("a worktree holding a nested one must be refused");
assert!(nested.join("notes.txt").exists(), "inner work survives");
}
/// Two repos spawning the same branch name under one override root must not
/// collide (#54 review).
#[test]
fn override_root_keeps_repos_apart() {
let root_dir = TempDir::new().unwrap();
let a_dir = TempDir::new().unwrap();
let b_dir = TempDir::new().unwrap();
init_repo(a_dir.path());
init_repo(b_dir.path());
let mgr = WorktreeManager::new(Some(root_dir.path().to_path_buf()));
let a = mgr.add_new(a_dir.path(), "feat/shared").expect("repo a");
let b = mgr.add_new(b_dir.path(), "feat/shared").expect("repo b");
assert_ne!(a, b, "same branch name in two repos must not collide");
}
/// Flattening `/` and `.` to `-` made these three share one directory, so the
/// losers could never get a worktree (#54 review).
#[test]
fn similar_branch_names_get_distinct_directories() {
let repo_dir = TempDir::new().unwrap();
let repo = repo_dir.path().to_path_buf();
init_repo(&repo);
let mgr = WorktreeManager::new(None);
let a = mgr.add_new(&repo, "feat/x").expect("feat/x");
let b = mgr.add_new(&repo, "feat.x").expect("feat.x");
let c = mgr.add_new(&repo, "feat-x").expect("feat-x");
assert_ne!(a, b);
assert_ne!(b, c);
assert_ne!(a, c);
assert!(a.ends_with("feat/x"), "{a:?}");
}
/// A guarded leftover stays on disk untouched, and is never adopted (#77).
/// Re-spawning its branch is refused by git itself — the branch is still
/// checked out inside the leftover — which the caller surfaces.
#[test]
fn a_guarded_leftover_is_never_adopted() {
let repo_dir = TempDir::new().unwrap();
let repo = repo_dir.path().to_path_buf();
init_repo(&repo);
let mgr = WorktreeManager::new(None);
let first = mgr.add_new(&repo, "feat/resume").expect("add");
std::fs::write(first.join("notes.txt"), b"unsaved\n").unwrap();
// Removal is refused — git itself refuses a dirty tree — leaving it on
// disk; the daemon-side guard lives on the kill (#79).
mgr.remove(&repo, &first)
.expect_err("dirty tree is guarded");
let err = mgr
.add_existing(&repo, "feat/resume")
.expect_err("a branch already checked out cannot be spawned twice");
assert!(format!("{err}").contains("git exited"), "{err}");
assert!(
first.join("notes.txt").exists(),
"the leftover's unsaved work is untouched"
);
}
/// The `worktree_root` override gets the same collision suffix (#77).
#[test]
fn the_override_root_suffixes_collisions_too() {
let repo_dir = TempDir::new().unwrap();
let repo = repo_dir.path().to_path_buf();
init_repo(&repo);
let wt_root = TempDir::new().unwrap();
let mgr = WorktreeManager::new(Some(wt_root.path().to_path_buf()));
// First add settles `<root>/<repo basename>-<digest>/` so the squatter
// can be created at the exact target.
mgr.add_new(&repo, "feat/first").expect("add first");
let repo_dir_in_root = std::fs::read_dir(wt_root.path())
.unwrap()
.next()
.unwrap()
.unwrap()
.path();
let squatter = repo_dir_in_root.join("feat").join("collide");
std::fs::create_dir_all(&squatter).unwrap();
let path = mgr.add_new(&repo, "feat/collide").expect("sidesteps");
assert_eq!(path, squatter.with_file_name("collide-2"), "{path:?}");
}