Anything occupying <repo>/worktrees/<branch> — one of ours, one the operator made, an unrelated directory — is now bypassed by appending -2, -3, ... to the directory; the branch stays as requested. The same suffix applies under the [daemon].worktree_root override. The daemon no longer asks whether an occupant is "ours": delete registered_for(), the adoption branch in run_add and the SessionManager::create live-session check, plus WorktreeError::PathExists. A branch already checked out elsewhere is git's refusal to surface, already marked by the branch picker's is_head/worktree flags.
452 lines
15 KiB
Rust
452 lines
15 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_repo;
|
|
use maestro_daemon::worktree::WorktreeManager;
|
|
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());
|
|
}
|
|
|
|
#[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:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn remove_refuses_worktree_with_uncommitted_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();
|
|
|
|
mgr.remove(&repo, &path)
|
|
.expect_err("dirty worktree must be refused");
|
|
assert!(path.exists(), "guarded worktree must stay on disk");
|
|
|
|
// Committing it makes removal safe: `worktree remove` keeps
|
|
// `refs/heads/feat/dirty`, so the work is still reachable. Refusing here
|
|
// instead would make every unpushed branch a one-shot, since the path is
|
|
// deterministic and the leftover tree blocks the next spawn (#56 review).
|
|
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)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn add_fails_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 err = mgr.add_new(&repo, "feat/dup").unwrap_err();
|
|
let msg = format!("{err}");
|
|
assert!(msg.contains("git exited"), "{msg}");
|
|
}
|
|
|
|
/// 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();
|
|
|
|
// The guard refuses, leaving it on disk.
|
|
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:?}");
|
|
}
|