//! 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 { 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. // /-/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/` 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) ); } #[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(); // 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 `/-/` 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:?}"); }