The cleanup guard plus deterministic paths had created a trap: one untracked file an agent left behind — a log, a .env, a stray artifact — made the guard refuse removal, the directory stayed, and every later spawn of that branch hit PathExists. The branch was burned permanently and the product offered no way to clear it. A leftover tree registered for the same branch is now adopted, so the operator lands back in their own unsaved work. A tree a *live* session holds is still rejected, since two sessions must not share a checkout. Also: - Fast-forward still awaited /api/info, /api/projects and /api/names inline, the exact freeze the draft loaders were restructured to avoid. It posts to the same channel now. - Draft updates were routed by tab index, which shifts when a tab closes: an in-flight load could be dropped or land on the wrong pane. Drafts carry an id and are found by it. - extend_names left the name it had just displayed at the head of the queue, so the next reroll produced it again and the key looked dead. - The fast-forward fallback discarded info.home_dir, so its draft showed unabbreviated paths and seeded "Other directory…" with / — disagreeing with the normal draft entry point. - Web fast-forward mounted via openSession, which replaces the focused pane: pressing it while attached evicted that session from the layout. It reuses an empty pane or splits, as the TUI does. - branchEdited was inferred from the seeded branch value, but in "new" mode that value is the auto-synced session name — so any form rebuild froze the branch against later rerolls. - resolveBaseRepo ignored the worktree checkbox, rebuilding the fieldset expanded under an unticked box and probing gitroot for a worktree the operator had turned off. - short_hash used DefaultHasher, whose algorithm is not stable across Rust releases, in a directory name — a toolchain bump would orphan every worktree under the override root. Now FNV-1a.
445 lines
15 KiB
Rust
445 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());
|
|
}
|
|
|
|
#[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").join("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")
|
|
.join("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}");
|
|
}
|
|
|
|
/// 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, and a
|
|
/// deterministic path plus a hard `PathExists` meant 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:?}");
|
|
}
|
|
|
|
/// The dead end the cleanup guard created: it refuses to delete unsaved work,
|
|
/// the path is deterministic, and `PathExists` is a hard error — so one stray
|
|
/// untracked file used to burn that branch permanently, with no way to clear
|
|
/// it from the product. The leftover is adopted instead (#67 review).
|
|
#[test]
|
|
fn a_guarded_leftover_is_adopted_rather_than_burning_the_branch() {
|
|
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");
|
|
assert!(first.exists());
|
|
|
|
// Spawning that branch again lands in the operator's own work, not an error.
|
|
let again = mgr
|
|
.add_existing(&repo, "feat/resume")
|
|
.expect("leftover is adopted");
|
|
assert_eq!(again, first);
|
|
assert!(
|
|
again.join("notes.txt").exists(),
|
|
"their work is still there"
|
|
);
|
|
}
|
|
|
|
/// Adoption must not extend to a directory that merely sits in the way.
|
|
#[test]
|
|
fn an_unrelated_directory_at_the_target_path_is_still_an_error() {
|
|
let repo_dir = TempDir::new().unwrap();
|
|
let repo = repo_dir.path().to_path_buf();
|
|
init_repo(&repo);
|
|
|
|
std::fs::create_dir_all(repo.join("worktrees").join("feat").join("squatter")).unwrap();
|
|
let mgr = WorktreeManager::new(None);
|
|
mgr.add_new(&repo, "feat/squatter")
|
|
.expect_err("a non-worktree directory must not be adopted");
|
|
}
|