Ten findings from reviewing the accumulated blitz/spawn-v2 diff. The two that could wedge a repo: - The cleanup guard refused any worktree whose commits were on no other ref, which is every ordinary unpushed branch. Paths are deterministic now, so the leftover tree blocked the next spawn and each branch became a one-shot. `worktree remove` keeps refs/heads/<branch>, so committed work is never at risk: guard on an unclean working tree alone. - A worktree whose directory had vanished failed the git probe and was reported as unsaved work, stranding the registration forever. Prune it instead, and prune before `worktree add` tests the target path. Also: - The CLI hardcoded create_branch: true, so the daemon's existing-branch path was unreachable. Added --existing-branch. - Pastes were forwarded without their DECSET 2004 markers, so a remote shell ran a multi-line paste line by line. - spawnForm emitted before its seed was restored and after destroy(), so a rebuilt draft blanked its cwd and a dead form clobbered the live one. - showRawCwd and the probe's worktree untick never reached the leaf. - A late autocomplete fetch reopened the popup under a blurred input. - The "remove worktree when killed" checkbox promised something the daemon no longer honours; removed.
291 lines
9.0 KiB
Rust
291 lines
9.0 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-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()));
|
|
|
|
let path = mgr.add_new(&repo, "feat/example").expect("add worktree");
|
|
assert_eq!(path, wt_root.path().join("feat-example"));
|
|
assert!(path.exists());
|
|
}
|
|
|
|
#[test]
|
|
fn existing_directory_at_target_path_is_an_error_carrying_the_path() {
|
|
let repo_dir = TempDir::new().unwrap();
|
|
let repo = repo_dir.path().to_path_buf();
|
|
init_repo(&repo);
|
|
|
|
// A predictable path means collisions are possible; they must be
|
|
// reported, never silently relocated.
|
|
std::fs::create_dir_all(repo.join("worktrees").join("feat-collide")).unwrap();
|
|
|
|
let mgr = WorktreeManager::new(None);
|
|
let err = mgr.add_new(&repo, "feat/collide").unwrap_err();
|
|
let msg = format!("{err}");
|
|
assert!(
|
|
msg.contains(
|
|
repo.join("worktrees")
|
|
.join("feat-collide")
|
|
.to_str()
|
|
.unwrap()
|
|
),
|
|
"{msg}"
|
|
);
|
|
}
|
|
|
|
#[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}");
|
|
}
|