1use std::collections::HashMap;
10use std::io::Write;
11use std::path::{Path, PathBuf};
12use std::process::Command;
13use std::sync::OnceLock;
14
15use super::git_command;
16
17pub fn is_git_repo(path: &Path) -> bool {
25 git_command::command("git")
26 .args(["rev-parse", "--git-dir"])
27 .current_dir(path)
28 .output()
29 .is_ok_and(|out| out.status.success())
30}
31
32pub fn worker_root(cwd: &Path) -> Result<std::path::PathBuf, String> {
43 let resolved = std::fs::canonicalize(cwd)
44 .map_err(|error| format!("{} is not a directory: {error}", cwd.display()))?;
45 if !resolved.is_dir() {
46 return Err(format!("{} is not a directory", cwd.display()));
47 }
48 let Some(toplevel) = git_in_repo(&resolved, &["rev-parse", "--show-toplevel"]) else {
49 return Ok(resolved);
50 };
51 Ok(std::fs::canonicalize(toplevel.trim()).unwrap_or(resolved))
52}
53
54pub fn worktree_base(root: &Path) -> PathBuf {
75 static BASES: OnceLock<parking_lot::Mutex<HashMap<PathBuf, PathBuf>>> = OnceLock::new();
76 let cache = BASES.get_or_init(|| parking_lot::Mutex::new(HashMap::new()));
77 if let Some(base) = cache.lock().get(root).cloned() {
78 return base;
79 }
80 if !root.is_dir() {
84 return root.to_path_buf();
85 }
86 let Some(base) = resolve_worktree_base(root) else {
87 if is_git_repo(root) && first_unresolved(root) {
91 report_unresolved_root(root);
92 }
93 return root.to_path_buf();
94 };
95 cache.lock().insert(root.to_path_buf(), base.clone());
96 base
97}
98
99fn first_unresolved(root: &Path) -> bool {
102 static UNRESOLVED: OnceLock<parking_lot::Mutex<std::collections::HashSet<PathBuf>>> =
103 OnceLock::new();
104 UNRESOLVED.get_or_init(Default::default).lock().insert(root.to_path_buf())
105}
106
107fn report_unresolved_root(root: &Path) {
110 tracing::warn!(
111 target: "forge_agent::env::worktree",
112 event_name = "worktree_base_unresolved",
113 repo = %root.display(),
114 "git would not name this checkout's root, so worker paths compose from the project path \
115 until it can; a linked-worktree project records the wrong directory meanwhile",
116 );
117}
118
119fn resolve_worktree_base(root: &Path) -> Option<PathBuf> {
127 let listing = git_in_repo(root, &["worktree", "list", "--porcelain"])?;
128 let first = listing.lines().find_map(|line| line.strip_prefix("worktree "))?;
129 std::fs::canonicalize(first).ok()
130}
131
132pub fn worktree_branch(path: &Path) -> Option<String> {
138 let branch = git_in_repo(path, &["rev-parse", "--abbrev-ref", "HEAD"])?.trim().to_owned();
139 if branch.is_empty() || branch == "HEAD" { None } else { Some(branch) }
140}
141
142#[derive(Debug, thiserror::Error)]
144pub enum WorktreeError {
145 #[error("worktree path {0} has no parent directory to run git from")]
148 NoParent(String),
149 #[error("failed to spawn git worktree remove: {0}")]
151 GitSpawn(String),
152 #[error("git worktree remove failed: {0}")]
156 RemoveFailed(String),
157 #[error("git worktree add failed: {0}")]
159 CreateFailed(String),
160 #[error("refusing to remove {path}: not a worktree under {root}")]
163 OutsideWorktreesRoot { path: String, root: String },
164 #[error("refusing to remove {0}: not a directory")]
168 NotAPlainDirectory(String),
169 #[error("refusing to remove {0}: git still lists it as a worktree")]
174 StillRegistered(String),
175 #[error("refusing to remove {path}: {detail}")]
181 ForeignLeftover { path: String, detail: String },
182 #[error("refusing to reuse {path}: {detail}")]
188 RegistrationUnusable { path: String, detail: String },
189 #[error("failed to delete the leftover worktree directory {path}: {error}")]
191 LeftoverUndeletable { path: String, error: String },
192}
193
194pub fn worktree_dirty_reason(path: &Path) -> Option<String> {
207 if let Some(reason) = worktree_uncommitted_reason(path) {
208 return Some(reason);
209 }
210
211 if let Some(ahead) = unpushed_commit_count(path)
212 && ahead > 0
213 {
214 let plural = if ahead == 1 { "" } else { "s" };
215 return Some(format!("{ahead} unpushed commit{plural}"));
216 }
217
218 None
219}
220
221fn worktree_uncommitted_reason(path: &Path) -> Option<String> {
229 match git_command::command("git").args(["status", "--porcelain"]).current_dir(path).output() {
230 Ok(out) if out.status.success() => {
231 if !out.stdout.is_empty() {
232 return Some("uncommitted or untracked changes".to_owned());
233 }
234 None
235 }
236 _ => Some("could not determine the worktree's git status".to_owned()),
239 }
240}
241
242fn unpushed_commit_count(path: &Path) -> Option<u64> {
251 let has_remote_refs = git_command::command("git")
252 .args(["for-each-ref", "--count=1", "--format=%(refname)", "refs/remotes/"])
253 .current_dir(path)
254 .output()
255 .is_ok_and(|out| out.status.success() && !out.stdout.is_empty());
256 if !has_remote_refs {
257 return None;
258 }
259 let out = git_command::command("git")
260 .args(["rev-list", "--count", "HEAD", "--not", "--remotes"])
261 .current_dir(path)
262 .output()
263 .ok()?;
264 if !out.status.success() {
265 return None;
266 }
267 let count = String::from_utf8_lossy(&out.stdout).trim().parse().ok()?;
268 if count == 0 || upstream_ref_deleted(path) { Some(0) } else { Some(count) }
269}
270
271fn upstream_ref_deleted(path: &Path) -> bool {
285 let Some(branch) = worktree_branch(path) else {
286 return false;
287 };
288 let Some(remote) = git_config_value(path, &format!("branch.{branch}.remote")) else {
289 return false;
290 };
291 if remote == "." {
293 return false;
294 }
295 let Some(tracked) = git_config_value(path, &format!("branch.{branch}.merge"))
296 .and_then(|merge| merge.strip_prefix("refs/heads/").map(str::to_owned))
297 else {
298 return false;
299 };
300 let refname = format!("refs/remotes/{remote}/{tracked}");
301 git_command::command("git")
304 .args(["rev-parse", "--verify", "--quiet", &refname])
305 .current_dir(path)
306 .output()
307 .is_ok_and(|out| out.status.code() == Some(1))
308}
309
310fn git_config_value(path: &Path, key: &str) -> Option<String> {
312 let out = git_command::command("git")
313 .args(["config", "--get", key])
314 .current_dir(path)
315 .output()
316 .ok()?;
317 if !out.status.success() {
318 return None;
319 }
320 let value = String::from_utf8_lossy(&out.stdout).trim().to_owned();
321 if value.is_empty() { None } else { Some(value) }
322}
323
324pub fn remove_worktree(path: &Path, force: bool) -> Result<(), WorktreeError> {
338 let parent =
339 path.parent().ok_or_else(|| WorktreeError::NoParent(path.display().to_string()))?;
340 let _ = git_command::command("git")
344 .arg("worktree")
345 .arg("unlock")
346 .arg(path)
347 .current_dir(parent)
348 .output();
349 let mut cmd = git_command::command("git");
350 cmd.arg("worktree").arg("remove");
351 if force {
352 cmd.arg("--force");
353 }
354 cmd.arg(path).current_dir(parent);
355 let output = cmd.output().map_err(|e| WorktreeError::GitSpawn(e.to_string()))?;
356 if output.status.success() {
357 Ok(())
358 } else {
359 Err(WorktreeError::RemoveFailed(String::from_utf8_lossy(&output.stderr).trim().to_owned()))
360 }
361}
362
363pub fn remove_orphaned_worktree_dir(repo: &Path, path: &Path) -> Result<(), WorktreeError> {
401 let root = repo.join(".claude/worktrees");
402 if path.parent() != Some(root.as_path()) || path.file_name().is_none() {
410 return Err(WorktreeError::OutsideWorktreesRoot {
411 path: path.display().to_string(),
412 root: root.display().to_string(),
413 });
414 }
415 match std::fs::symlink_metadata(path) {
418 Ok(meta) if meta.is_dir() => {}
419 _ => return Err(WorktreeError::NotAPlainDirectory(path.display().to_string())),
420 }
421 if worktree_registered(repo, path) {
422 return Err(WorktreeError::StillRegistered(path.display().to_string()));
423 }
424 if let Some(detail) = foreign_leftover_detail(repo, path) {
425 return Err(WorktreeError::ForeignLeftover { path: path.display().to_string(), detail });
426 }
427 std::fs::remove_dir_all(path).map_err(|error| WorktreeError::LeftoverUndeletable {
428 path: path.display().to_string(),
429 error: error.to_string(),
430 })?;
431 if let Some(label) = path.file_name().and_then(std::ffi::OsStr::to_str) {
432 record_worktree_state(repo, label, path, WorktreeRecord::Cleared);
433 }
434 Ok(())
435}
436
437fn foreign_leftover_detail(repo: &Path, path: &Path) -> Option<String> {
463 match std::fs::symlink_metadata(path.join(".git")) {
464 Ok(meta) if meta.is_dir() => {
465 return Some(
466 "its .git is a directory, so it is a checkout of its own, not a worktree this \
467 repo forked"
468 .to_owned(),
469 );
470 }
471 Err(_) => {
476 let empty = std::fs::read_dir(path).is_ok_and(|mut entries| entries.next().is_none());
477 return (!empty && !worktree_removal_admitted(repo, path)).then(|| {
478 "it holds no .git and this repo recorded no removal of it; remove the directory \
479 by hand if it is yours, then spawn again"
480 .to_owned()
481 });
482 }
483 Ok(_) => {}
484 }
485 let Ok(link) = std::fs::read_to_string(path.join(".git")) else {
486 return Some("its .git file could not be read".to_owned());
487 };
488 let Some(named) = link.trim().strip_prefix("gitdir:").map(str::trim) else {
489 return Some("its .git file does not name a gitdir".to_owned());
490 };
491 let named = Path::new(named);
492 let named = if named.is_absolute() { named.to_path_buf() } else { path.join(named) };
493 if named.parent().and_then(Path::parent).is_some_and(Path::exists) {
494 return live_foreign_git_dir(repo, path);
496 }
497 (!worktree_removal_admitted(repo, path)).then(|| {
498 format!(
499 "its .git link points at '{}', outside this repo's worktree admin dir, and this repo \
500 recorded no removal of this worktree; remove the directory by hand if it is yours, \
501 then spawn again",
502 named.display()
503 )
504 })
505}
506
507fn live_foreign_git_dir(repo: &Path, path: &Path) -> Option<String> {
512 let link = std::fs::read_to_string(path.join(".git")).ok()?;
513 let named = link.trim().strip_prefix("gitdir:").map(str::trim)?;
514 let named = Path::new(named);
515 let named = if named.is_absolute() { named.to_path_buf() } else { path.join(named) };
516 let git_dir = named.parent().and_then(Path::parent)?;
517 if !git_dir.exists() {
518 return None;
519 }
520 let ours = matches!(
523 (git_common_dir(repo), std::fs::canonicalize(git_dir).ok()),
524 (Some(ours), Some(named)) if ours == named
525 );
526 (!ours).then(|| {
527 format!(
528 "its .git link points at '{}', outside this repo's worktree admin dir",
529 named.display()
530 )
531 })
532}
533
534#[derive(Debug, Clone, Copy, PartialEq, Eq)]
536enum WorktreeRegistration {
537 Live,
539 Stale,
542 Absent,
544}
545
546fn worktree_registration(repo: &Path, path: &Path) -> Option<WorktreeRegistration> {
550 let ours = std::fs::canonicalize(path).ok()?;
554 let out = scrubbed_git(repo, &["worktree", "list", "--porcelain"]).output().ok()?;
555 if !out.status.success() {
556 return None;
557 }
558 let mut found = WorktreeRegistration::Absent;
559 for listed in String::from_utf8_lossy(&out.stdout)
560 .lines()
561 .filter_map(|line| line.strip_prefix("worktree "))
562 {
563 let resolved = std::fs::canonicalize(listed);
564 if resolved.as_ref().is_ok_and(|resolved| *resolved == ours) {
565 return Some(WorktreeRegistration::Live);
566 }
567 if resolved.is_err() && moved_registration_names(listed, &ours) {
572 found = WorktreeRegistration::Stale;
573 }
574 }
575 Some(found)
576}
577
578fn worktree_registered(repo: &Path, path: &Path) -> bool {
581 !matches!(worktree_registration(repo, path), Some(WorktreeRegistration::Absent))
582}
583
584fn moved_registration_names(listed: &str, ours: &Path) -> bool {
591 let Some(label) = ours.file_name() else {
592 return false;
593 };
594 Path::new(listed).ends_with(Path::new(".claude").join("worktrees").join(label))
595}
596
597fn repair_worktree(repo: &Path, path: &Path) -> Result<(), WorktreeError> {
602 let output =
603 scrubbed_git(repo, &["worktree", "repair"]).arg(path).output().map_err(|error| {
604 WorktreeError::RegistrationUnusable {
605 path: path.display().to_string(),
606 detail: format!("git worktree repair could not be spawned: {error}"),
607 }
608 })?;
609 if output.status.success() {
610 Ok(())
611 } else {
612 Err(WorktreeError::RegistrationUnusable {
613 path: path.display().to_string(),
614 detail: format!(
615 "git worktree repair failed: {}",
616 String::from_utf8_lossy(&output.stderr).trim()
617 ),
618 })
619 }
620}
621
622#[derive(Debug, Clone, Copy, PartialEq, Eq)]
625enum WorktreeRecord {
626 Owned,
628 Removing,
630 Cleared,
632}
633
634impl WorktreeRecord {
635 fn as_str(self) -> &'static str {
636 match self {
637 Self::Owned => "owned",
638 Self::Removing => "removing",
639 Self::Cleared => "cleared",
640 }
641 }
642}
643
644const WORKTREE_RECORDS: &str = "forge-worktrees";
645
646fn git_common_dir(repo: &Path) -> Option<std::path::PathBuf> {
650 let raw = git_in_repo(repo, &["rev-parse", "--git-common-dir"])?;
651 let raw = raw.trim();
652 if raw.is_empty() {
653 return None;
654 }
655 let dir = Path::new(raw);
656 let dir = if dir.is_absolute() { dir.to_path_buf() } else { repo.join(dir) };
657 std::fs::canonicalize(dir).ok()
658}
659
660fn worktree_record_path(repo: &Path) -> Option<std::path::PathBuf> {
661 git_common_dir(repo).map(|dir| dir.join(WORKTREE_RECORDS))
662}
663
664fn record_worktree_state(repo: &Path, label: &str, path: &Path, state: WorktreeRecord) {
670 let Some(file) = worktree_record_path(repo) else {
671 tracing::warn!(
672 target: "forge_agent::env::worktree",
673 event_name = "worktree_record_unavailable",
674 repo = %repo.display(),
675 label = %label,
676 "the repo's git common dir could not be read, so this worktree's ownership is not \
677 recorded and a leftover it leaves may be refused rather than cleared",
678 );
679 return;
680 };
681 let line = format!("{}\t{label}\t{}\t{}\n", state.as_str(), repo.display(), path.display());
682 let written = std::fs::OpenOptions::new()
683 .create(true)
684 .append(true)
685 .open(&file)
686 .and_then(|mut file| file.write_all(line.as_bytes()));
687 if let Err(err) = written {
688 tracing::warn!(
689 target: "forge_agent::env::worktree",
690 event_name = "worktree_record_unwritable",
691 repo = %repo.display(),
692 label = %label,
693 record = %file.display(),
694 %err,
695 "this worktree's ownership could not be recorded, so a leftover it leaves may be \
696 refused rather than cleared",
697 );
698 }
699}
700
701struct RecordedWorktree {
703 state: WorktreeRecord,
704 root: std::path::PathBuf,
706 path: std::path::PathBuf,
708}
709
710fn recorded_worktree(repo: &Path, path: &Path) -> Option<RecordedWorktree> {
723 let label = path.file_name().and_then(std::ffi::OsStr::to_str)?;
724 let text = std::fs::read_to_string(worktree_record_path(repo)?).ok()?;
725 let mut live = None;
726 let mut stale = None;
727 for line in text.lines() {
728 let mut fields = line.splitn(4, '\t');
729 let Some(recorded_state) = fields.next().and_then(|field| match field {
730 "owned" => Some(WorktreeRecord::Owned),
731 "removing" => Some(WorktreeRecord::Removing),
732 "cleared" => Some(WorktreeRecord::Cleared),
733 _ => None,
734 }) else {
735 continue;
736 };
737 if fields.next() != Some(label) {
738 continue;
739 }
740 let Some(recorded_root) = fields.next() else {
741 continue;
742 };
743 let Some(recorded_path) = fields.next() else {
744 continue;
745 };
746 let recorded = RecordedWorktree {
747 state: recorded_state,
748 root: std::path::PathBuf::from(recorded_root),
749 path: std::path::PathBuf::from(recorded_path),
750 };
751 if recorded.path.exists() {
752 if recorded_names(&recorded.path, path) {
753 live = Some(recorded);
754 }
755 } else if recorded_root_is_ours(&recorded.root, repo) {
756 stale = Some(recorded);
757 }
758 }
759 live.or(stale)
760}
761
762fn recorded_root_is_ours(recorded: &Path, repo: &Path) -> bool {
766 if !recorded.exists() {
767 return true;
768 }
769 matches!(
770 (std::fs::canonicalize(recorded), std::fs::canonicalize(repo)),
771 (Ok(recorded), Ok(repo)) if recorded == repo
772 )
773}
774
775fn recorded_names(recorded: &Path, current: &Path) -> bool {
778 matches!(
779 (std::fs::canonicalize(recorded), std::fs::canonicalize(current)),
780 (Ok(recorded), Ok(current)) if recorded == current
781 )
782}
783
784fn worktree_removal_admitted(repo: &Path, path: &Path) -> bool {
789 recorded_worktree(repo, path).is_some_and(|record| record.state == WorktreeRecord::Removing)
790}
791
792fn record_worktree_owned(repo: &Path, label: &str, path: &Path) {
794 record_worktree_state(repo, label, path, WorktreeRecord::Owned);
795}
796
797pub fn record_worktree_gone(repo: &Path, label: &str, path: &Path) {
801 record_worktree_state(repo, label, path, WorktreeRecord::Cleared);
802}
803
804pub fn remove_owned_worktree(
815 repo: &Path,
816 label: &str,
817 path: &Path,
818 force: bool,
819) -> Result<(), WorktreeError> {
820 let admission = if path.is_dir() { WorktreeRecord::Removing } else { WorktreeRecord::Cleared };
821 record_worktree_state(repo, label, path, admission);
822 let outcome = remove_worktree(path, force);
823 if outcome.is_ok() {
824 record_worktree_state(repo, label, path, WorktreeRecord::Cleared);
825 }
826 outcome
827}
828
829#[derive(Debug, Clone, Copy, PartialEq, Eq)]
833pub enum WorktreeEnsure {
834 AlreadyPresent,
837 AttachedExistingBranch,
841 CreatedWithNewBranch,
845}
846
847pub fn ensure_worker_worktree(
885 repo: &Path,
886 label: &str,
887 path: &Path,
888) -> Result<WorktreeEnsure, WorktreeError> {
889 if path.exists() {
890 match worktree_registration(repo, path) {
891 Some(WorktreeRegistration::Absent) => remove_orphaned_worktree_dir(repo, path)?,
892 Some(WorktreeRegistration::Stale) => {
893 if let Some(detail) = live_foreign_git_dir(repo, path) {
897 return Err(WorktreeError::RegistrationUnusable {
898 path: path.display().to_string(),
899 detail: format!("{detail}; git worktree repair would adopt it"),
900 });
901 }
902 repair_worktree(repo, path)?;
903 record_worktree_owned(repo, label, path);
904 return Ok(WorktreeEnsure::AlreadyPresent);
905 }
906 Some(WorktreeRegistration::Live) => {
910 record_worktree_owned(repo, label, path);
911 return Ok(WorktreeEnsure::AlreadyPresent);
912 }
913 None => return Ok(WorktreeEnsure::AlreadyPresent),
915 }
916 }
917 let branch = format!("worktree-{label}");
918 let mut cmd = git_command::command("git");
919 cmd.arg("worktree").arg("add").arg("-q");
920 let outcome = if branch_ref_exists(repo, &branch) {
921 cmd.arg(path).arg(&branch);
922 WorktreeEnsure::AttachedExistingBranch
923 } else {
924 cmd.arg("-b").arg(&branch).arg(path);
931 if let Some(base) = origin_base(repo) {
932 cmd.arg(base);
933 }
934 WorktreeEnsure::CreatedWithNewBranch
935 };
936 cmd.current_dir(repo);
937 let output = cmd.output().map_err(|e| WorktreeError::GitSpawn(e.to_string()))?;
938 if output.status.success() {
939 record_worktree_owned(repo, label, path);
940 Ok(outcome)
941 } else {
942 Err(WorktreeError::CreateFailed(git_error(&output)))
943 }
944}
945
946pub fn discard_worker_worktree(
955 repo: &Path,
956 label: &str,
957 path: &Path,
958 ensured: WorktreeEnsure,
959) -> Option<String> {
960 if matches!(ensured, WorktreeEnsure::AlreadyPresent) {
961 return None;
962 }
963 if let Err(err) = remove_owned_worktree(repo, label, path, true) {
964 return Some(err.to_string());
965 }
966 if !matches!(ensured, WorktreeEnsure::CreatedWithNewBranch) {
967 return None;
968 }
969 let branch = format!("worktree-{label}");
970 match reap_worktree_branch(repo, &branch) {
971 BranchReapOutcome::Reaped | BranchReapOutcome::NotPresent => None,
972 other => Some(format!("branch '{branch}' left behind: {other:?}")),
973 }
974}
975
976pub fn worktree_creation_obstacle(repo: &Path, label: &str, path: &Path) -> Option<String> {
995 let branch = format!("worktree-{label}");
996 let mut command = git_command::command("git");
997 command.arg("-C").arg(repo).args(["worktree", "list", "--porcelain"]);
998 let output = match command.output() {
1002 Ok(output) => output,
1003 Err(err) => {
1004 tracing::warn!(
1005 target: "forge_agent::env::worktree",
1006 event_name = "worktree_obstacle_unavailable",
1007 repo = %repo.display(),
1008 label = %label,
1009 %err,
1010 "git could not be asked whether this worker's worktree can be created, so this \
1011 spawn is not refused here and may start without a worktree",
1012 );
1013 return None;
1014 }
1015 };
1016 if !output.status.success() {
1017 tracing::warn!(
1018 target: "forge_agent::env::worktree",
1019 event_name = "worktree_obstacle_unreadable",
1020 repo = %repo.display(),
1021 label = %label,
1022 status = %output.status,
1023 stderr = %String::from_utf8_lossy(&output.stderr).trim(),
1024 "git would not list this repo's worktrees, so this spawn is not refused here and \
1025 may start without a worktree",
1026 );
1027 return None;
1028 }
1029
1030 let listing = String::from_utf8_lossy(&output.stdout);
1031 obstacle_from_listing(&listed_worktrees(&listing), repo, &branch, path)
1032}
1033
1034struct ListedWorktree {
1036 at: String,
1038 branch: Option<String>,
1040}
1041
1042fn listed_worktrees(listing: &str) -> Vec<ListedWorktree> {
1045 let mut entries: Vec<ListedWorktree> = Vec::new();
1046 for line in listing.lines() {
1047 if let Some(at) = line.strip_prefix("worktree ") {
1048 entries.push(ListedWorktree { at: at.to_owned(), branch: None });
1049 } else if let Some(found) = line.strip_prefix("branch ")
1050 && let Some(entry) = entries.last_mut()
1051 {
1052 entry.branch = Some(found.to_owned());
1053 }
1054 }
1055 entries
1056}
1057
1058fn obstacle_from_listing(
1070 entries: &[ListedWorktree],
1071 repo: &Path,
1072 branch: &str,
1073 path: &Path,
1074) -> Option<String> {
1075 let wanted = format!("refs/heads/{branch}");
1076 if let Some(at) = entries.iter().find(|e| e.branch.as_deref() == Some(&wanted)).map(|e| &e.at)
1077 && !same_path(at, path)
1078 {
1079 return Some(format!(
1080 "the branch '{branch}' is already checked out in the worktree at {at}, so this \
1081 worker's worktree cannot be created; remove that worktree, or spawn under a \
1082 different label"
1083 ));
1084 }
1085 match entries.iter().find(|e| same_path(&e.at, path)) {
1086 Some(_) if worktree_gitdir_resolves(path) => None,
1089 Some(_) => Some(format!(
1090 "the worktree registration at {} cannot be reused - its .git link is not the plain \
1091 file git writes, the gitdir it names is gone or belongs to another repository, or \
1092 its HEAD does not resolve - so this worker's worktree cannot be created there; \
1093 unlock it with `git worktree unlock {}` (which errors harmlessly when it is not \
1094 locked), delete that directory if it is still there, then `git worktree prune` in \
1095 {} and spawn again",
1096 path.display(),
1097 path.display(),
1098 repo.display(),
1099 )),
1100 None if path_is_taken(path) => Some(format!(
1101 "{} already exists and is not an empty directory, so this worker's worktree cannot \
1102 be created there; remove it, or spawn under a different label",
1103 path.display()
1104 )),
1105 None => None,
1106 }
1107}
1108
1109fn worktree_gitdir_resolves(path: &Path) -> bool {
1120 let Ok(link_meta) = std::fs::symlink_metadata(path.join(".git")) else {
1121 return false;
1122 };
1123 if !link_meta.is_file() {
1124 return false;
1125 }
1126 let Ok(link) = std::fs::read_to_string(path.join(".git")) else {
1127 return false;
1128 };
1129 let Some(gitdir) = link.trim().strip_prefix("gitdir:") else {
1130 return false;
1131 };
1132 let gitdir = Path::new(gitdir.trim());
1133 let gitdir = if gitdir.is_absolute() { gitdir.to_path_buf() } else { path.join(gitdir) };
1134 if !gitdir.is_dir() {
1135 return false;
1136 }
1137 let Ok(back) = std::fs::read_to_string(gitdir.join("gitdir")) else {
1138 return false;
1139 };
1140 let back = Path::new(back.trim());
1141 let back = if back.is_absolute() { back.to_path_buf() } else { gitdir.join(back) };
1142 let (Ok(back), Ok(here)) =
1145 (std::fs::canonicalize(back), std::fs::canonicalize(path.join(".git")))
1146 else {
1147 return false;
1148 };
1149 if back != here {
1150 return false;
1151 }
1152 git_command::command("git")
1153 .arg("--git-dir")
1154 .arg(&gitdir)
1155 .args(["rev-parse", "--verify", "--quiet", "HEAD"])
1156 .output()
1157 .is_ok_and(|out| out.status.success())
1158}
1159
1160pub fn worktree_missing_reason(path: &Path) -> Option<String> {
1167 if !path.is_dir() {
1168 return Some(format!("its worktree {} is gone", path.display()));
1169 }
1170 if !worktree_gitdir_resolves(path) {
1171 return Some(format!(
1172 "its worktree {} is not a git worktree git can reuse",
1173 path.display()
1174 ));
1175 }
1176 None
1177}
1178
1179fn path_is_taken(path: &Path) -> bool {
1184 match std::fs::read_dir(path) {
1185 Ok(mut entries) => entries.next().is_some(),
1186 Err(_) => path.exists() || path.symlink_metadata().is_ok(),
1191 }
1192}
1193
1194fn same_path(listed: &str, composed: &Path) -> bool {
1201 let listed = Path::new(listed);
1202 match (spelled_from_parent(listed), spelled_from_parent(composed)) {
1203 (Some(listed), Some(composed)) => listed == composed,
1204 _ => listed == composed,
1205 }
1206}
1207
1208fn spelled_from_parent(path: &Path) -> Option<std::path::PathBuf> {
1211 let parent = std::fs::canonicalize(path.parent()?).ok()?;
1212 Some(parent.join(path.file_name()?))
1213}
1214
1215#[derive(Debug, PartialEq, Eq)]
1219pub enum BranchReapOutcome {
1220 Reaped,
1222 NotPresent,
1224 Kept { count: u64, tip: String },
1227 KeptOnError { reason: String },
1230 DeleteFailed { reason: String },
1232}
1233
1234pub fn reap_worktree_branch(repo: &Path, branch: &str) -> BranchReapOutcome {
1242 let refname = format!("refs/heads/{branch}");
1243
1244 let tip = match git_command::command("git")
1248 .args(["rev-parse", "--short", "--verify", "--quiet", &refname])
1249 .current_dir(repo)
1250 .output()
1251 {
1252 Ok(out) if out.status.success() => String::from_utf8_lossy(&out.stdout).trim().to_owned(),
1253 Ok(out) if out.status.code() == Some(1) => return BranchReapOutcome::NotPresent,
1254 Ok(out) => return BranchReapOutcome::KeptOnError { reason: git_error(&out) },
1255 Err(err) => return BranchReapOutcome::KeptOnError { reason: err.to_string() },
1256 };
1257
1258 let exclude = format!("--exclude={refname}");
1262 let count = match git_command::command("git")
1263 .args(["rev-list", "--count", &refname, "--not", &exclude, "--all"])
1264 .current_dir(repo)
1265 .output()
1266 {
1267 Ok(out) if out.status.success() => {
1268 match String::from_utf8_lossy(&out.stdout).trim().parse::<u64>() {
1269 Ok(count) => count,
1270 Err(err) => {
1271 return BranchReapOutcome::KeptOnError {
1272 reason: format!("unreadable commit count: {err}"),
1273 };
1274 }
1275 }
1276 }
1277 Ok(out) => return BranchReapOutcome::KeptOnError { reason: git_error(&out) },
1278 Err(err) => return BranchReapOutcome::KeptOnError { reason: err.to_string() },
1279 };
1280 if count > 0 {
1281 return BranchReapOutcome::Kept { count, tip };
1282 }
1283
1284 match git_command::command("git").args(["branch", "-D", branch]).current_dir(repo).output() {
1288 Ok(out) if out.status.success() => BranchReapOutcome::Reaped,
1289 Ok(out) => BranchReapOutcome::DeleteFailed { reason: git_error(&out) },
1290 Err(err) => BranchReapOutcome::DeleteFailed { reason: err.to_string() },
1291 }
1292}
1293
1294pub fn branch_ref_exists(repo: &Path, branch: &str) -> bool {
1311 repo_branch_names(repo).is_none_or(|repo| repo.names.contains(branch))
1312}
1313
1314fn origin_base(repo: &Path) -> Option<String> {
1321 let name = git_in_repo(repo, &["symbolic-ref", "--short", "refs/remotes/origin/HEAD"])?
1322 .trim()
1323 .to_owned();
1324 if name.is_empty() {
1325 return None;
1326 }
1327 git_in_repo(repo, &["rev-parse", "--verify", "--quiet", &name]).map(|_| name)
1328}
1329
1330fn git_in_repo(repo: &Path, args: &[&str]) -> Option<String> {
1339 let out = scrubbed_git(repo, args).output().ok()?;
1340 out.status.success().then(|| String::from_utf8_lossy(&out.stdout).into_owned())
1341}
1342
1343fn scrubbed_git(repo: &Path, args: &[&str]) -> Command {
1346 let mut command = git_command::command("git");
1347 command.arg("-C").arg(repo).args(args);
1348 command
1349}
1350
1351pub struct RepoBranches {
1363 pub names: std::collections::HashSet<String>,
1368 pub ref_count: usize,
1374}
1375
1376pub fn repo_branch_names(repo: &Path) -> Option<RepoBranches> {
1405 let toplevel = git_in_repo(repo, &["rev-parse", "--show-toplevel"])?;
1406 let toplevel = std::fs::canonicalize(toplevel.trim()).ok()?;
1407 if toplevel != std::fs::canonicalize(repo).ok()? {
1408 return None;
1409 }
1410 let listing = git_in_repo(
1411 repo,
1412 &["for-each-ref", "--format=%(refname) %(symref)", "refs/heads", "refs/remotes"],
1413 )?;
1414 let mut names = std::collections::HashSet::new();
1415 let mut ref_count = 0;
1416 for line in listing.lines() {
1417 let (refname, symref) = line.split_once(' ').unwrap_or((line, ""));
1420 if !symref.trim().is_empty() {
1427 continue;
1428 }
1429 if let Some(head) = refname.strip_prefix("refs/heads/") {
1430 ref_count += 1;
1431 names.insert(head.to_owned());
1432 } else if let Some(tail) = refname.strip_prefix("refs/remotes/") {
1433 ref_count += 1;
1434 let mut rest = tail;
1435 while let Some((_, suffix)) = rest.split_once('/') {
1436 names.insert(suffix.to_owned());
1437 rest = suffix;
1438 }
1439 }
1440 }
1441 names.remove("");
1442 Some(RepoBranches { names, ref_count })
1443}
1444
1445fn git_error(out: &std::process::Output) -> String {
1447 let stderr = String::from_utf8_lossy(&out.stderr).trim().to_owned();
1448 if stderr.is_empty() { out.status.to_string() } else { stderr }
1449}
1450
1451#[derive(Debug)]
1455pub struct AgentWorktree {
1456 pub path: std::path::PathBuf,
1458 pub repo_root: std::path::PathBuf,
1460 pub branch: String,
1463}
1464
1465#[derive(Debug)]
1467pub enum AgentWorktreeReap {
1468 Reaped { branch: BranchReapOutcome },
1470 KeptDirty { reason: String },
1473 KeptUniqueCommit { tip: String },
1477 Absent,
1479 NotAWorktree,
1481 RemoveFailed { reason: String },
1483}
1484
1485pub fn is_isolated_agent_call(tool_name: &str, tool_input: &serde_json::Value) -> bool {
1489 (tool_name == "Agent" || tool_name == "Task")
1490 && tool_input.get("isolation").and_then(serde_json::Value::as_str) == Some("worktree")
1491}
1492
1493pub fn completed_agent_worktree(
1502 tool_name: &str,
1503 tool_input: &serde_json::Value,
1504 tool_response: &serde_json::Value,
1505) -> Option<AgentWorktree> {
1506 if !is_isolated_agent_call(tool_name, tool_input) {
1507 return None;
1508 }
1509 let path: std::path::PathBuf = find_string_field(tool_response, "worktreePath", 0)?.into();
1510 if !path.is_absolute() {
1511 return None;
1512 }
1513 let name = path.file_name()?.to_str()?;
1514 if !name.starts_with("agent-") {
1515 return None;
1516 }
1517 let worktrees = path.parent()?;
1518 if worktrees.file_name()? != std::ffi::OsStr::new("worktrees") {
1519 return None;
1520 }
1521 let dot_claude = worktrees.parent()?;
1522 if dot_claude.file_name()? != std::ffi::OsStr::new(".claude") {
1523 return None;
1524 }
1525 let branch = find_string_field(tool_response, "worktreeBranch", 0)
1526 .unwrap_or_else(|| format!("worktree-{name}"));
1527 let repo_root = dot_claude.parent()?.to_path_buf();
1528 Some(AgentWorktree { path, repo_root, branch })
1529}
1530
1531fn find_string_field(value: &serde_json::Value, key: &str, depth: u8) -> Option<String> {
1537 if depth > 6 {
1538 return None;
1539 }
1540 match value {
1541 serde_json::Value::Object(map) => {
1542 for (k, v) in map {
1543 if k == key
1544 && let Some(s) = v.as_str()
1545 {
1546 return Some(s.to_owned());
1547 }
1548 }
1549 map.values().find_map(|v| find_string_field(v, key, depth + 1))
1550 }
1551 serde_json::Value::Array(items) => {
1552 items.iter().find_map(|v| find_string_field(v, key, depth + 1))
1553 }
1554 _ => None,
1555 }
1556}
1557
1558fn detached_unique_tip(repo_root: &Path, path: &Path) -> Option<String> {
1568 if worktree_branch(path).is_some() {
1569 return None;
1570 }
1571 let tip = match git_in_repo(path, &["rev-parse", "HEAD"]) {
1572 Some(sha) => sha.trim().to_owned(),
1573 None => return Some(String::from("(unreadable HEAD)")),
1574 };
1575 if tip.is_empty() {
1576 return Some(String::from("(unreadable HEAD)"));
1577 }
1578 let survivors = survivor_tips(repo_root, path)?;
1579 let exclusions: Vec<String> = survivors.iter().map(|sha| format!("^{sha}")).collect();
1580 let mut cmd = git_command::command("git");
1581 cmd.arg("-C")
1582 .arg(repo_root)
1583 .args(["rev-list", "--count", &tip, "--stdin"])
1584 .stdin(std::process::Stdio::piped())
1585 .stdout(std::process::Stdio::piped())
1586 .stderr(std::process::Stdio::piped());
1587 let Ok(mut child) = cmd.spawn() else {
1588 return Some(tip);
1589 };
1590 if let Some(mut stdin) = child.stdin.take() {
1591 use std::io::Write;
1592 let _ = stdin.write_all(exclusions.join("\n").as_bytes());
1593 let _ = stdin.write_all(b"\n");
1594 }
1595 let unique = match child.wait_with_output() {
1596 Ok(out) if out.status.success() => {
1597 String::from_utf8_lossy(&out.stdout).trim().parse::<u64>().ok()
1598 }
1599 _ => None,
1600 };
1601 match unique {
1602 Some(0) => None,
1603 _ => Some(tip),
1604 }
1605}
1606
1607fn survivor_tips(repo_root: &Path, worktree_path: &Path) -> Option<Vec<String>> {
1611 let mut tips: Vec<String> =
1612 git_in_repo(repo_root, &["for-each-ref", "--format=%(objectname)"])?
1613 .lines()
1614 .filter(|l| !l.trim().is_empty())
1615 .map(|l| l.trim().to_owned())
1616 .collect();
1617 let listing = git_in_repo(repo_root, &["worktree", "list", "--porcelain"])?;
1618 let here = std::fs::canonicalize(worktree_path).ok();
1619 let mut current: Option<(String, Option<String>)> = None;
1620 let flush = |current: &mut Option<(String, Option<String>)>, tips: &mut Vec<String>| {
1621 if let Some((path, Some(sha))) = current.take() {
1622 let same = std::fs::canonicalize(std::path::Path::new(&path))
1623 .ok()
1624 .zip(here.clone())
1625 .is_some_and(|(a, b)| a == b)
1626 || path == worktree_path.to_string_lossy();
1627 if !same {
1628 tips.push(sha);
1629 }
1630 }
1631 };
1632 for line in listing.lines() {
1633 if let Some(path) = line.strip_prefix("worktree ") {
1634 flush(&mut current, &mut tips);
1635 current = Some((path.to_owned(), None));
1636 } else if let Some(sha) = line.strip_prefix("HEAD ") {
1637 if let Some((_, head)) = current.as_mut() {
1638 *head = Some(sha.to_owned());
1639 }
1640 } else if line.is_empty() {
1641 flush(&mut current, &mut tips);
1642 }
1643 }
1644 flush(&mut current, &mut tips);
1645 Some(tips)
1646}
1647
1648pub fn stale_agent_worktrees(
1660 repo_root: &Path,
1661 age: std::time::Duration,
1662) -> std::io::Result<Vec<AgentWorktree>> {
1663 let worktrees_dir = worktree_base(repo_root).join(".claude").join("worktrees");
1667 let entries = match std::fs::read_dir(&worktrees_dir) {
1668 Ok(entries) => entries,
1669 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
1670 Err(err) => return Err(err),
1671 };
1672 let Some(locks) = locked_worktrees(repo_root) else {
1673 return Ok(Vec::new());
1674 };
1675 let mut stale = Vec::new();
1676 for entry in entries.flatten() {
1677 let name = entry.file_name();
1678 let Some(name) = name.to_str() else { continue };
1679 if !name.starts_with("agent-") {
1680 continue;
1681 }
1682 let Ok(meta) = entry.metadata() else { continue };
1683 if !meta.is_dir() {
1684 continue;
1685 }
1686 let untouched = meta.modified().ok().and_then(|m| m.elapsed().ok());
1687 if untouched.is_none_or(|tree_age| tree_age < age) {
1688 continue;
1689 }
1690 let path = entry.path();
1691 let real = std::fs::canonicalize(&path);
1692 let lock = locks
1693 .iter()
1694 .find(|lock| lock.path == path || real.as_ref().is_ok_and(|real| lock.path == *real));
1695 match lock {
1696 Some(lock) if lock_pid_is_live(lock.pid) => continue,
1698 _ => {}
1699 }
1700 stale.push(AgentWorktree {
1701 path,
1702 repo_root: repo_root.to_path_buf(),
1703 branch: format!("worktree-{name}"),
1704 });
1705 }
1706 Ok(stale)
1707}
1708
1709fn lock_pid_is_live(pid: Option<u32>) -> bool {
1713 match pid {
1714 Some(pid) => process_is_live(pid),
1715 None => true,
1716 }
1717}
1718
1719fn process_is_live(pid: u32) -> bool {
1722 let Ok(out) = Command::new("ps").args(["-p", &pid.to_string(), "-o", "pid="]).output() else {
1723 return true;
1724 };
1725 out.status.success() && !out.stdout.is_empty()
1726}
1727
1728pub fn agent_worktree_lock_is_live(repo_root: &Path, path: &Path) -> bool {
1733 let Ok(real) = std::fs::canonicalize(path) else {
1734 return true;
1735 };
1736 let Some(locks) = locked_worktrees(repo_root) else {
1737 return true;
1738 };
1739 match locks.into_iter().find(|lock| lock.path == real) {
1740 Some(lock) => lock_pid_is_live(lock.pid),
1741 None => false,
1742 }
1743}
1744
1745struct WorktreeLock {
1748 path: std::path::PathBuf,
1749 pid: Option<u32>,
1750}
1751
1752fn locked_worktrees(repo_root: &Path) -> Option<Vec<WorktreeLock>> {
1757 let listing = git_in_repo(repo_root, &["worktree", "list", "--porcelain"])?;
1758 let mut locks = Vec::new();
1759 let mut current: Option<String> = None;
1760 let mut reason: Option<String> = None;
1761 let flush = |current: &mut Option<String>,
1762 reason: &mut Option<String>,
1763 locks: &mut Vec<WorktreeLock>| {
1764 if let (Some(path), Some(reason)) = (current.take(), reason.take())
1765 && let Ok(real) = std::fs::canonicalize(std::path::Path::new(&path))
1766 {
1767 locks.push(WorktreeLock { path: real, pid: parse_lock_pid(&reason) });
1768 }
1769 };
1770 for line in listing.lines() {
1771 if let Some(path) = line.strip_prefix("worktree ") {
1772 flush(&mut current, &mut reason, &mut locks);
1773 current = Some(path.to_owned());
1774 } else if let Some(reason_text) = line.strip_prefix("locked") {
1775 reason = Some(reason_text.trim().to_owned());
1776 } else if line.is_empty() {
1777 flush(&mut current, &mut reason, &mut locks);
1778 }
1779 }
1780 flush(&mut current, &mut reason, &mut locks);
1781 Some(locks)
1782}
1783
1784fn parse_lock_pid(reason: &str) -> Option<u32> {
1787 let digits = reason.split("pid ").nth(1)?;
1788 let digits: String = digits.chars().take_while(char::is_ascii_digit).collect();
1789 digits.parse().ok()
1790}
1791
1792pub fn reap_agent_worktree(agent_worktree: &AgentWorktree) -> AgentWorktreeReap {
1800 let path = &agent_worktree.path;
1801 if !path.exists() {
1802 return AgentWorktreeReap::Absent;
1803 }
1804 if !is_git_repo(path) {
1805 return AgentWorktreeReap::NotAWorktree;
1806 }
1807 if let Some(reason) = worktree_uncommitted_reason(path) {
1808 return AgentWorktreeReap::KeptDirty { reason };
1809 }
1810 if let Some(tip) = detached_unique_tip(&agent_worktree.repo_root, path) {
1811 return AgentWorktreeReap::KeptUniqueCommit { tip };
1812 }
1813 match remove_worktree(path, true) {
1822 Ok(()) => AgentWorktreeReap::Reaped {
1823 branch: reap_worktree_branch(&agent_worktree.repo_root, &agent_worktree.branch),
1824 },
1825 Err(err) => AgentWorktreeReap::RemoveFailed { reason: err.to_string() },
1826 }
1827}
1828
1829#[cfg(test)]
1830mod tests {
1831 use super::*;
1832 use std::fs;
1833 use tempfile::tempdir;
1834
1835 #[test]
1836 fn returns_false_for_empty_dir() {
1837 let dir = tempdir().expect("tempdir");
1838 assert!(!is_git_repo(dir.path()));
1839 }
1840
1841 #[test]
1842 fn returns_true_for_initialised_repo() {
1843 let dir = tempdir().expect("tempdir");
1844 std::process::Command::new("git")
1845 .args(["init", "-q"])
1846 .current_dir(dir.path())
1847 .status()
1848 .expect("git init");
1849 assert!(is_git_repo(dir.path()));
1850 }
1851
1852 #[test]
1853 fn returns_false_for_nonexistent_path() {
1854 assert!(!is_git_repo(Path::new("/nonexistent/path/that/does/not/exist")));
1855 }
1856
1857 #[test]
1858 fn returns_true_for_subdir_of_repo() {
1859 let dir = tempdir().expect("tempdir");
1860 std::process::Command::new("git")
1861 .args(["init", "-q"])
1862 .current_dir(dir.path())
1863 .status()
1864 .expect("git init");
1865 let sub = dir.path().join("sub");
1866 fs::create_dir(&sub).expect("mkdir");
1867 assert!(is_git_repo(&sub));
1868 }
1869
1870 #[test]
1874 fn worker_root_resolves_a_subdirectory_to_the_repo_root() {
1875 let repo = init_repo_with_commit();
1876 let nested = repo.path().join("crates/deep");
1877 fs::create_dir_all(&nested).expect("nested dir");
1878
1879 let root = worker_root(&nested).expect("resolves");
1880
1881 assert_eq!(
1882 root,
1883 fs::canonicalize(repo.path()).expect("canonical repo"),
1884 "a subdirectory must resolve to the repo root, not itself",
1885 );
1886 }
1887
1888 #[test]
1891 fn worker_root_keeps_a_path_outside_a_repo_as_itself() {
1892 let dir = tempdir().expect("tempdir");
1893
1894 let root = worker_root(dir.path()).expect("resolves");
1895
1896 assert_eq!(
1897 root,
1898 fs::canonicalize(dir.path()).expect("canonical dir"),
1899 "a non-repo directory is its own root",
1900 );
1901 }
1902
1903 #[test]
1908 fn worker_root_canonicalizes_the_path_it_returns() {
1909 let dir = tempdir().expect("tempdir");
1910 let alias = dir.path().join("alias");
1911 fs::create_dir_all(&alias).expect("alias dir");
1912 std::os::unix::fs::symlink(&alias, dir.path().join("link")).expect("symlink");
1913
1914 let root = worker_root(&dir.path().join("link")).expect("resolves");
1915
1916 assert_eq!(
1917 root,
1918 fs::canonicalize(&alias).expect("canonical"),
1919 "the resolved root must carry no symlink component",
1920 );
1921 }
1922
1923 #[test]
1930 fn worktree_base_is_the_cli_canonical_root() {
1931 let repo = init_repo_with_commit();
1932 let main = fs::canonicalize(repo.path()).expect("canonical repo");
1933 let linked = main.join("linked");
1934 run_git(&main, &["worktree", "add", "-q", "-b", "linked", linked.to_str().expect("utf8")]);
1935 let linked_nested = linked.join("crates/deep");
1936 fs::create_dir_all(&linked_nested).expect("nested dir");
1937 let main_nested = main.join("crates/deep");
1938 fs::create_dir_all(&main_nested).expect("nested dir");
1939
1940 assert_eq!(
1941 worktree_base(&linked),
1942 main,
1943 "a project that is a linked worktree forks its workers' worktrees under the main \
1944 checkout",
1945 );
1946 assert_eq!(
1947 worktree_base(&linked_nested),
1948 main,
1949 "a path inside a linked worktree resolves to the main checkout",
1950 );
1951 assert_eq!(
1952 worktree_base(&main_nested),
1953 main,
1954 "a subdirectory of a normal checkout resolves to its toplevel, as the CLI does",
1955 );
1956 assert_eq!(worktree_base(&main), main, "the main checkout is its own base");
1957
1958 let holder = tempdir().expect("alias holder");
1962 let alias = holder.path().join("project");
1963 std::os::unix::fs::symlink(&main, &alias).expect("symlink");
1964 assert_eq!(worktree_base(&alias), main, "a symlinked spelling resolves to the same root");
1965
1966 let separate = tempdir().expect("separate holder");
1969 let checkout = separate.path().join("checkout");
1970 let store = separate.path().join("store.git");
1971 run_git(
1972 separate.path(),
1973 &[
1974 "init",
1975 "-q",
1976 "--separate-git-dir",
1977 store.to_str().expect("utf8"),
1978 checkout.to_str().expect("utf8"),
1979 ],
1980 );
1981 run_git(&checkout, &["config", "user.email", "t@example.com"]);
1982 run_git(&checkout, &["config", "user.name", "Test"]);
1983 run_git(&checkout, &["config", "commit.gpgsign", "false"]);
1984 fs::write(checkout.join("README.md"), "seed").expect("write seed");
1985 run_git(&checkout, &["add", "."]);
1986 run_git(&checkout, &["commit", "-q", "-m", "init"]);
1987 let separate_linked = checkout.join("linked");
1988 run_git(
1989 &checkout,
1990 &["worktree", "add", "-q", "-b", "sep-linked", separate_linked.to_str().expect("utf8")],
1991 );
1992 assert_eq!(
1997 worktree_base(&separate_linked),
1998 fs::canonicalize(&store).expect("canonical store"),
1999 "a separate git dir's base is the admin dir git names, and the layout is unsupported",
2000 );
2001
2002 let flaky_holder = tempdir().expect("flaky holder");
2008 let flaky = flaky_holder.path().join("flaky");
2009 run_git(&main, &["worktree", "add", "-q", "-b", "flaky", flaky.to_str().expect("utf8")]);
2010 let link = flaky.join(".git");
2011 let held = flaky_holder.path().join("flaky-link");
2012 fs::rename(&link, &held).expect("lift the worktree link");
2013 assert_eq!(worktree_base(&flaky), flaky, "a path git cannot answer for is its own base");
2014 fs::rename(&held, &link).expect("restore the worktree link");
2015 assert_eq!(
2016 worktree_base(&flaky),
2017 main,
2018 "the failed resolution was not memoised, so the next call answers with the main \
2019 checkout",
2020 );
2021
2022 let outside = tempdir().expect("outside");
2023 assert_eq!(
2024 worktree_base(outside.path()),
2025 outside.path(),
2026 "a path outside any repo is its own base",
2027 );
2028 let missing = outside.path().join("not-there");
2029 assert_eq!(
2030 worktree_base(&missing),
2031 missing,
2032 "a path that is not there answers without a git call and is its own base",
2033 );
2034 }
2035
2036 #[test]
2039 fn an_unresolved_root_is_reported_once() {
2040 let holder = tempdir().expect("holder");
2041 let first = holder.path().join("one");
2042 let second = holder.path().join("two");
2043
2044 assert!(first_unresolved(&first), "the first report of a root goes through");
2045 assert!(!first_unresolved(&first), "a repeat for the same root is suppressed");
2046 assert!(first_unresolved(&second), "a different root reports on its own");
2047 }
2048
2049 #[test]
2052 fn the_unresolved_root_warning_names_the_event_and_the_root() {
2053 #[derive(Clone)]
2054 struct Capture(std::sync::Arc<std::sync::Mutex<Vec<u8>>>);
2055 impl std::io::Write for Capture {
2056 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
2057 self.0.lock().expect("capture lock").extend_from_slice(buf);
2058 Ok(buf.len())
2059 }
2060 fn flush(&mut self) -> std::io::Result<()> {
2061 Ok(())
2062 }
2063 }
2064 impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for Capture {
2065 type Writer = Capture;
2066 fn make_writer(&'a self) -> Self::Writer {
2067 self.clone()
2068 }
2069 }
2070
2071 let buffer = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
2072 let subscriber = tracing_subscriber::fmt()
2073 .with_max_level(tracing::Level::WARN)
2074 .with_writer(Capture(std::sync::Arc::clone(&buffer)))
2075 .finish();
2076 let root = std::path::Path::new("/nowhere/at/all");
2077 tracing::subscriber::with_default(subscriber, || report_unresolved_root(root));
2078
2079 let text =
2080 String::from_utf8(buffer.lock().expect("capture lock").clone()).expect("utf8 log");
2081 assert!(text.contains("worktree_base_unresolved"), "the event name is logged: {text}");
2082 assert!(text.contains("/nowhere/at/all"), "and the root it is about: {text}");
2083 }
2084
2085 #[test]
2089 fn worker_root_refuses_a_directory_that_is_not_there() {
2090 let dir = tempdir().expect("tempdir");
2091 let missing = dir.path().join("not-there");
2092
2093 let error = worker_root(&missing).expect_err("a missing directory is refused");
2094
2095 assert!(
2096 error.contains("not-there") && error.contains("not a directory"),
2097 "the refusal names the path and what is wrong with it: {error}",
2098 );
2099 }
2100
2101 fn run_git(dir: &Path, args: &[&str]) {
2102 let status = std::process::Command::new("git")
2103 .args(args)
2104 .current_dir(dir)
2105 .status()
2106 .expect("spawn git");
2107 assert!(status.success(), "git {args:?} failed in {dir:?}");
2108 }
2109
2110 fn init_repo_with_commit() -> tempfile::TempDir {
2113 let dir = tempdir().expect("tempdir");
2114 init_repo_at(dir.path());
2115 dir
2116 }
2117
2118 fn init_repo_at(path: &Path) {
2119 run_git(path, &["init", "-q"]);
2120 run_git(path, &["config", "user.email", "t@example.com"]);
2121 run_git(path, &["config", "user.name", "Test"]);
2122 run_git(path, &["config", "commit.gpgsign", "false"]);
2123 fs::write(path.join("README.md"), "seed").expect("write seed");
2124 run_git(path, &["add", "."]);
2125 run_git(path, &["commit", "-q", "-m", "init"]);
2126 }
2127
2128 fn init_repo_with_worktree() -> (tempfile::TempDir, std::path::PathBuf) {
2131 let dir = init_repo_with_commit();
2132 let wt = dir.path().join("wt");
2133 run_git(dir.path(), &["worktree", "add", "-q", wt.to_str().expect("utf8 path")]);
2134 (dir, wt)
2135 }
2136
2137 #[test]
2138 fn repo_branch_names_lists_local_heads_and_remote_tracking_refs() {
2139 let dir = init_repo_with_commit();
2140 run_git(dir.path(), &["branch", "feat/live"]);
2141 run_git(dir.path(), &["update-ref", "refs/remotes/origin/feat/pushed", "HEAD"]);
2142 run_git(dir.path(), &["update-ref", "refs/remotes/upstream/main", "HEAD"]);
2145
2146 let names = repo_branch_names(dir.path()).expect("a repo root answers").names;
2147 assert!(names.contains("feat/live"), "local heads are listed: {names:?}");
2148 assert!(names.contains("feat/pushed"), "remote-tracking refs count: {names:?}");
2149 assert!(names.contains("main") || names.contains("master"), "{names:?}");
2150 assert!(
2151 !names.iter().any(|n| n.starts_with("refs/")),
2152 "names are branch names, not refnames: {names:?}",
2153 );
2154 }
2155
2156 #[test]
2162 fn worktree_branch_is_scrubbed_like_every_other_repo_question() {
2163 let foreign = init_repo_with_commit();
2164 run_git(foreign.path(), &["checkout", "-q", "-b", "only-in-foreign"]);
2165 let host = init_repo_with_commit();
2166 run_git(host.path(), &["checkout", "-q", "-b", "the-real-branch"]);
2167
2168 let command = scrubbed_git(host.path(), &["rev-parse", "--abbrev-ref", "HEAD"]);
2169 let removed: Vec<&str> = command
2170 .get_envs()
2171 .filter(|(_, value)| value.is_none())
2172 .filter_map(|(key, _)| key.to_str())
2173 .collect();
2174 assert!(removed.contains(&"GIT_DIR"), "the read goes through the scrub: {removed:?}");
2175 assert_eq!(worktree_branch(host.path()).as_deref(), Some("the-real-branch"));
2176 let _ = foreign;
2177 }
2178
2179 #[test]
2185 fn a_branch_legally_named_head_is_not_mistaken_for_the_symref() {
2186 let dir = init_repo_with_commit();
2187 run_git(dir.path(), &["update-ref", "refs/remotes/origin/release/HEAD", "HEAD"]);
2188 run_git(dir.path(), &["update-ref", "refs/remotes/origin/feat/a", "HEAD"]);
2189 run_git(
2191 dir.path(),
2192 &["symbolic-ref", "refs/remotes/origin/HEAD", "refs/remotes/origin/feat/a"],
2193 );
2194
2195 let repo = repo_branch_names(dir.path()).expect("a repo root answers");
2196 assert!(
2197 repo.names.contains("release/HEAD"),
2198 "a branch that happens to end in HEAD is still a branch: {:?}",
2199 repo.names,
2200 );
2201 assert!(branch_ref_exists(dir.path(), "release/HEAD"), "and the delete gate agrees");
2202 assert_eq!(repo.ref_count, 3, "local head + two remote branches: {:?}", repo.names);
2205 }
2206
2207 #[test]
2214 fn a_slash_bearing_remote_still_yields_the_real_branch_name() {
2215 let dir = init_repo_with_commit();
2216 run_git(dir.path(), &["update-ref", "refs/remotes/gh/upstream/feat/pushed", "HEAD"]);
2217 run_git(dir.path(), &["update-ref", "refs/remotes/origin/feat/plain", "HEAD"]);
2218
2219 let names = repo_branch_names(dir.path()).expect("a repo root answers").names;
2220 assert!(
2221 names.contains("feat/pushed"),
2222 "the branch behind a slash-bearing remote must not read as gone: {names:?}",
2223 );
2224 assert!(names.contains("feat/plain"), "the ordinary case still works: {names:?}");
2225 assert!(names.contains("upstream/feat/pushed"), "{names:?}");
2228 assert!(names.contains("pushed"), "{names:?}");
2229 }
2230
2231 #[test]
2236 fn repo_branch_names_refuses_a_directory_nested_inside_a_repo() {
2237 let dir = init_repo_with_commit();
2238 let nested = dir.path().join("crates");
2239 fs::create_dir_all(&nested).expect("mkdir");
2240 assert!(
2241 repo_branch_names(&nested).is_none(),
2242 "only the work-tree root answers for its own refs",
2243 );
2244 assert!(repo_branch_names(dir.path()).is_some(), "the root itself still answers");
2245 }
2246
2247 #[test]
2248 fn repo_branch_names_is_none_outside_a_repo() {
2249 let dir = tempdir().expect("tempdir");
2250 assert!(repo_branch_names(dir.path()).is_none());
2251 }
2252
2253 #[test]
2259 fn env_remove_beats_a_git_dir_set_on_the_same_command() {
2260 let host = init_repo_with_commit();
2261 run_git(host.path(), &["branch", "only-in-host"]);
2262 let foreign = init_repo_with_commit();
2263 run_git(foreign.path(), &["branch", "only-in-foreign"]);
2264
2265 let out = Command::new("git")
2266 .arg("-C")
2267 .arg(host.path())
2268 .args(["for-each-ref", "--format=%(refname)", "refs/heads"])
2269 .env("GIT_DIR", foreign.path().join(".git"))
2270 .env_remove("GIT_DIR")
2271 .output()
2272 .expect("git runs");
2273 let listing = String::from_utf8_lossy(&out.stdout);
2274
2275 assert!(out.status.success());
2276 assert!(
2277 listing.contains("only-in-host"),
2278 "the scrubbed command reads its -C repo: {listing:?}"
2279 );
2280 assert!(
2281 !listing.contains("only-in-foreign"),
2282 "and not the one GIT_DIR pointed at: {listing:?}",
2283 );
2284 }
2285
2286 #[test]
2291 fn the_git_helper_scrubs_every_repo_location_variable() {
2292 let dir = tempdir().expect("tempdir");
2293 let command = scrubbed_git(dir.path(), &["rev-parse", "--show-toplevel"]);
2294 let removed: Vec<&str> = command
2295 .get_envs()
2296 .filter(|(_, value)| value.is_none())
2297 .filter_map(|(key, _)| key.to_str())
2298 .collect();
2299 for key in ["GIT_DIR", "GIT_WORK_TREE", "GIT_COMMON_DIR"] {
2300 assert!(removed.contains(&key), "{key} is not scrubbed: {removed:?}");
2301 }
2302 }
2303
2304 #[test]
2308 fn an_inherited_git_dir_beats_dash_c() {
2309 let foreign = init_repo_with_commit();
2310 run_git(foreign.path(), &["branch", "only-in-foreign"]);
2311 let elsewhere = tempdir().expect("tempdir");
2312
2313 let out = Command::new("git")
2314 .env("GIT_DIR", foreign.path().join(".git"))
2315 .arg("-C")
2316 .arg(elsewhere.path())
2317 .args(["for-each-ref", "--format=%(refname)", "refs/heads"])
2318 .output()
2319 .expect("git runs");
2320 let listing = String::from_utf8_lossy(&out.stdout);
2321
2322 assert!(out.status.success(), "the wrong answer arrives as a success");
2323 assert!(
2324 listing.contains("only-in-foreign"),
2325 "GIT_DIR overrides -C, so this reports the foreign repo's refs: {listing:?}",
2326 );
2327 }
2328
2329 #[test]
2330 fn clean_worktree_reports_not_dirty() {
2331 let (_dir, wt) = init_repo_with_worktree();
2332 assert_eq!(worktree_dirty_reason(&wt), None);
2333 }
2334
2335 #[test]
2336 fn untracked_file_reports_dirty() {
2337 let (_dir, wt) = init_repo_with_worktree();
2338 fs::write(wt.join("scratch.txt"), "uncommitted").expect("write scratch");
2339 assert!(worktree_dirty_reason(&wt).is_some());
2340 }
2341
2342 #[test]
2343 fn unpushed_commit_reports_dirty() {
2344 let (_dir, wt) = init_repo_with_worktree();
2345 let remote = tempdir().expect("remote tempdir");
2349 run_git(remote.path(), &["init", "-q", "--bare"]);
2350 run_git(&wt, &["remote", "add", "origin", remote.path().to_str().expect("utf8 path")]);
2351 run_git(&wt, &["push", "-q", "-u", "origin", "HEAD"]);
2352 fs::write(wt.join("ahead.txt"), "local only").expect("write ahead");
2353 run_git(&wt, &["add", "."]);
2354 run_git(&wt, &["commit", "-q", "-m", "ahead of upstream"]);
2355 let reason = worktree_dirty_reason(&wt).expect("unpushed commit reports dirty");
2356 assert!(reason.contains("unpushed"), "reason names unpushed commits: {reason:?}");
2357 }
2358
2359 #[test]
2368 fn a_squash_merged_branch_head_reports_clean() {
2369 let (dir, wt) = init_repo_with_worktree();
2370 let remote = tempdir().expect("remote tempdir");
2371 run_git(remote.path(), &["init", "-q", "--bare"]);
2372 run_git(
2373 dir.path(),
2374 &["remote", "add", "origin", remote.path().to_str().expect("utf8 path")],
2375 );
2376 let fork = tempdir().expect("fork tempdir");
2377 run_git(fork.path(), &["init", "-q", "--bare"]);
2378 run_git(
2379 dir.path(),
2380 &["remote", "add", "upstream", fork.path().to_str().expect("utf8 path")],
2381 );
2382 let main = git_stdout(dir.path(), &["rev-parse", "--abbrev-ref", "HEAD"]);
2383 run_git(dir.path(), &["push", "-q", "-u", "origin", "HEAD"]);
2384
2385 run_git(&wt, &["branch", "--set-upstream-to", &format!("origin/{main}")]);
2388 fs::write(wt.join("work.txt"), "the PR's content").expect("write work");
2389 run_git(&wt, &["add", "."]);
2390 run_git(&wt, &["commit", "-q", "-m", "the PR's commit"]);
2391 run_git(&wt, &["push", "-q", "upstream", "HEAD:refs/heads/wt"]);
2392
2393 run_git(dir.path(), &["merge", "-q", "--squash", "wt"]);
2395 run_git(dir.path(), &["commit", "-q", "-m", "squash-merge wt"]);
2396 run_git(dir.path(), &["push", "-q", "origin", "HEAD"]);
2397
2398 assert_eq!(
2399 git_stdout(&wt, &["rev-list", "--count", "@{upstream}..HEAD"]),
2400 "1",
2401 "the fixture reproduces the trap: the upstream comparison still reads ahead",
2402 );
2403 let reaching = git_stdout(&wt, &["branch", "-r", "--contains", "HEAD"]);
2404 assert!(
2405 reaching.contains("upstream/wt"),
2406 "the head is on a remote ref, and not origin's: {reaching:?}",
2407 );
2408 assert!(!reaching.contains("origin/wt"), "origin alone cannot see it: {reaching:?}");
2409 assert_eq!(worktree_dirty_reason(&wt), None);
2410 }
2411
2412 #[test]
2419 fn a_squash_merged_branch_survives_its_remote_branch_being_pruned() {
2420 let (dir, wt) = init_repo_with_worktree();
2421 let remote = tempdir().expect("remote tempdir");
2422 run_git(remote.path(), &["init", "-q", "--bare"]);
2423 run_git(
2424 dir.path(),
2425 &["remote", "add", "origin", remote.path().to_str().expect("utf8 path")],
2426 );
2427 run_git(dir.path(), &["push", "-q", "-u", "origin", "HEAD"]);
2428
2429 fs::write(wt.join("one.txt"), "first").expect("write one");
2431 run_git(&wt, &["add", "."]);
2432 run_git(&wt, &["commit", "-q", "-m", "first"]);
2433 fs::write(wt.join("two.txt"), "second").expect("write two");
2434 run_git(&wt, &["add", "."]);
2435 run_git(&wt, &["commit", "-q", "-m", "second"]);
2436 run_git(&wt, &["commit", "-q", "--allow-empty", "-m", "empty"]);
2437 run_git(&wt, &["push", "-q", "-u", "origin", "HEAD:refs/heads/wt"]);
2438
2439 run_git(dir.path(), &["merge", "-q", "--squash", "wt"]);
2440 run_git(dir.path(), &["commit", "-q", "-m", "squash-merge wt"]);
2441 run_git(dir.path(), &["push", "-q", "origin", "HEAD"]);
2442
2443 run_git(dir.path(), &["push", "-q", "origin", "--delete", "wt"]);
2444 run_git(dir.path(), &["fetch", "-q", "--prune", "origin"]);
2445
2446 assert_eq!(
2447 git_stdout(&wt, &["rev-list", "--count", "HEAD", "--not", "--remotes"]),
2448 "3",
2449 "no remote ref reaches the branch's commits any more",
2450 );
2451 assert_eq!(worktree_dirty_reason(&wt), None);
2452 }
2453
2454 #[test]
2458 fn a_head_pushed_to_any_remote_counts_as_pushed() {
2459 let (dir, wt) = init_repo_with_worktree();
2460 let remote = tempdir().expect("remote tempdir");
2461 run_git(remote.path(), &["init", "-q", "--bare"]);
2462 run_git(
2463 dir.path(),
2464 &["remote", "add", "origin", remote.path().to_str().expect("utf8 path")],
2465 );
2466 let fork = tempdir().expect("fork tempdir");
2467 run_git(fork.path(), &["init", "-q", "--bare"]);
2468 run_git(
2469 dir.path(),
2470 &["remote", "add", "upstream", fork.path().to_str().expect("utf8 path")],
2471 );
2472 run_git(dir.path(), &["push", "-q", "-u", "origin", "HEAD"]);
2473
2474 fs::write(wt.join("work.txt"), "pushed to the fork").expect("write work");
2475 run_git(&wt, &["add", "."]);
2476 run_git(&wt, &["commit", "-q", "-m", "on the fork only"]);
2477 run_git(&wt, &["push", "-q", "upstream", "HEAD:refs/heads/wt"]);
2478
2479 assert!(
2480 !git_stdout(&wt, &["branch", "-r", "--contains", "HEAD"]).contains("origin/"),
2481 "nothing on origin reaches the head",
2482 );
2483 assert_eq!(worktree_dirty_reason(&wt), None);
2484 }
2485
2486 #[test]
2490 fn a_commit_no_remote_ref_reaches_reports_dirty() {
2491 let (dir, wt) = init_repo_with_worktree();
2492 let remote = tempdir().expect("remote tempdir");
2493 run_git(remote.path(), &["init", "-q", "--bare"]);
2494 run_git(
2495 dir.path(),
2496 &["remote", "add", "origin", remote.path().to_str().expect("utf8 path")],
2497 );
2498 run_git(dir.path(), &["push", "-q", "-u", "origin", "HEAD"]);
2499
2500 fs::write(wt.join("work.txt"), "local only").expect("write work");
2503 run_git(&wt, &["add", "."]);
2504 run_git(&wt, &["commit", "-q", "-m", "never pushed"]);
2505
2506 assert_eq!(
2507 worktree_dirty_reason(&wt),
2508 Some("1 unpushed commit".to_owned()),
2509 "the named reason counts the commits no remote ref reaches",
2510 );
2511 }
2512
2513 #[test]
2514 fn worktree_branch_reports_checked_out_branch() {
2515 let (dir, wt) = init_repo_with_worktree();
2516 run_git(&wt, &["checkout", "-q", "-b", "review-loop/feat"]);
2519 assert_eq!(worktree_branch(&wt).as_deref(), Some("review-loop/feat"));
2520 assert!(worktree_branch(dir.path()).is_some(), "main worktree also reports a branch");
2521 }
2522
2523 #[test]
2524 fn worktree_branch_none_for_non_repo() {
2525 let dir = tempdir().expect("tempdir");
2526 assert!(worktree_branch(dir.path()).is_none());
2527 }
2528
2529 #[test]
2530 fn remove_worktree_removes_clean() {
2531 let (_dir, wt) = init_repo_with_worktree();
2532 assert!(wt.exists());
2533 remove_worktree(&wt, false).expect("clean worktree removes");
2534 assert!(!wt.exists(), "worktree dir gone after remove");
2535 }
2536
2537 #[test]
2538 fn remove_worktree_removes_locked_clean() {
2539 let (dir, wt) = init_repo_with_worktree();
2540 run_git(dir.path(), &["worktree", "lock", wt.to_str().expect("utf8 path")]);
2541 assert!(wt.exists());
2542 remove_worktree(&wt, false).expect("locked clean worktree removes");
2543 assert!(!wt.exists(), "locked worktree dir gone after remove");
2544 }
2545
2546 #[test]
2547 fn remove_worktree_errors_on_dirty_without_force() {
2548 let (_dir, wt) = init_repo_with_worktree();
2549 fs::write(wt.join("scratch.txt"), "uncommitted").expect("write scratch");
2550 run_git(&wt, &["add", "."]);
2551 assert!(remove_worktree(&wt, false).is_err(), "dirty worktree blocks removal");
2552 assert!(wt.exists(), "worktree survives a blocked removal");
2553 }
2554
2555 #[test]
2556 fn remove_worktree_force_removes_dirty() {
2557 let (_dir, wt) = init_repo_with_worktree();
2558 fs::write(wt.join("scratch.txt"), "uncommitted").expect("write scratch");
2559 run_git(&wt, &["add", "."]);
2560 remove_worktree(&wt, true).expect("force removes a dirty worktree");
2561 assert!(!wt.exists(), "worktree dir gone after force remove");
2562 }
2563
2564 #[test]
2569 fn orphan_removal_removes_a_deregistered_leftover_directory() {
2570 let (dir, wt, _branch) = init_repo_with_worker_worktree("leftover");
2571 fs::create_dir_all(wt.join("target").join("debug")).expect("target dir");
2572 fs::write(wt.join("target").join("debug").join("obj.o"), "x").expect("object file");
2573 record_worktree_state(dir.path(), "leftover", &wt, WorktreeRecord::Removing);
2575 fs::remove_dir_all(dir.path().join(".git").join("worktrees").join("leftover"))
2576 .expect("deregister the worktree");
2577 assert!(
2581 remove_worktree(&wt, true).is_err(),
2582 "fixture precondition: git refuses a deregistered worktree"
2583 );
2584 assert!(wt.exists(), "and the refusal leaves the directory on disk");
2585
2586 remove_orphaned_worktree_dir(dir.path(), &wt).expect("the leftover directory removes");
2587
2588 assert!(!wt.exists(), "the leftover directory is gone");
2589 }
2590
2591 #[test]
2595 fn orphan_removal_refuses_a_registered_worktree() {
2596 let (dir, wt, _branch) = init_repo_with_worker_worktree("registered");
2597
2598 let err = remove_orphaned_worktree_dir(dir.path(), &wt).expect_err("refused");
2599
2600 assert!(
2601 matches!(err, WorktreeError::StillRegistered(_)),
2602 "the refusal names the registration: {err:?}"
2603 );
2604 assert!(wt.exists(), "the registered worktree is untouched");
2605 assert!(worktree_registered(dir.path(), &wt), "and git still lists it");
2606 }
2607
2608 #[test]
2611 fn orphan_removal_refuses_paths_outside_the_worktrees_root() {
2612 let (dir, wt, _branch) = init_repo_with_worker_worktree("elsewhere");
2613 fs::create_dir_all(dir.path().join("some-dir")).expect("unrelated dir");
2614 let root = wt.parent().expect("worktrees root").to_path_buf();
2615 let targets =
2616 [dir.path().to_path_buf(), root.clone(), root.join(".."), dir.path().join("some-dir")];
2617 for target in targets {
2618 let err = remove_orphaned_worktree_dir(dir.path(), &target).expect_err("refused");
2619 assert!(
2620 matches!(err, WorktreeError::OutsideWorktreesRoot { .. }),
2621 "the refusal is about the path, not the caller: {err:?} for {target:?}"
2622 );
2623 }
2624 assert!(dir.path().join("some-dir").is_dir(), "nothing outside the root was touched");
2625 assert!(root.is_dir(), "the worktrees root stands");
2626 assert!(wt.exists(), "the worktree it does hold stands");
2627 }
2628
2629 #[test]
2632 fn orphan_removal_refuses_a_non_directory() {
2633 let (dir, wt, _branch) = init_repo_with_worker_worktree("shape");
2634 std::fs::remove_dir_all(dir.path().join(".git").join("worktrees").join("shape"))
2635 .expect("deregister the worktree");
2636 std::fs::remove_dir_all(&wt).expect("clear the worktree dir");
2637 let elsewhere = dir.path().join("precious");
2638 fs::create_dir_all(&elsewhere).expect("precious dir");
2639 fs::write(elsewhere.join("keep.txt"), "keep").expect("keep file");
2640 std::os::unix::fs::symlink(&elsewhere, &wt).expect("symlink at the worktree path");
2641
2642 let err = remove_orphaned_worktree_dir(dir.path(), &wt).expect_err("refused");
2643
2644 assert!(
2645 matches!(err, WorktreeError::NotAPlainDirectory(_)),
2646 "a symlink is not a directory to delete: {err:?}"
2647 );
2648 assert!(
2649 elsewhere.join("keep.txt").exists(),
2650 "the symlink's target was not deleted through the link"
2651 );
2652
2653 fs::remove_file(&wt).expect("clear the symlink");
2654 fs::write(&wt, "not a directory").expect("file at the path");
2655 let err = remove_orphaned_worktree_dir(dir.path(), &wt).expect_err("refused");
2656 assert!(
2657 matches!(err, WorktreeError::NotAPlainDirectory(_)),
2658 "a file is not a directory to delete: {err:?}"
2659 );
2660 assert!(wt.is_file(), "the file was not deleted");
2661 }
2662
2663 #[test]
2667 fn orphan_removal_refuses_a_checkout_of_its_own() {
2668 let dir = init_repo_with_commit();
2669 let wt = dir.path().join(".claude").join("worktrees").join("lbl");
2670 fs::create_dir_all(wt.join(".git")).expect("a .git directory");
2671 fs::write(wt.join("real-work.txt"), "someone's repository").expect("write");
2672
2673 let err =
2674 remove_orphaned_worktree_dir(dir.path(), &wt).expect_err("a foreign checkout refuses");
2675
2676 assert!(matches!(err, WorktreeError::ForeignLeftover { .. }), "{err:?}");
2677 assert!(wt.join("real-work.txt").exists(), "nothing was deleted");
2678 }
2679
2680 #[test]
2687 fn orphan_removal_refuses_another_repos_worktree_link() {
2688 let dir = init_repo_with_commit();
2689 let other = init_repo_with_commit();
2690 let wt = dir.path().join(".claude").join("worktrees").join("lbl");
2691 fs::create_dir_all(&wt).expect("mkdir");
2692 fs::write(
2693 wt.join(".git"),
2694 format!(
2695 "gitdir: {}\n",
2696 other.path().join(".git").join("worktrees").join("lbl").display()
2697 ),
2698 )
2699 .expect("write the foreign link");
2700
2701 let err =
2702 remove_orphaned_worktree_dir(dir.path(), &wt).expect_err("a foreign link refuses");
2703
2704 assert!(matches!(err, WorktreeError::ForeignLeftover { .. }), "{err:?}");
2705 assert!(wt.exists(), "the directory was left alone");
2706 }
2707
2708 #[test]
2714 fn orphan_removal_clears_its_leftover_after_the_repo_moved() {
2715 let parent = tempdir().expect("parent");
2716 let before = parent.path().join("before");
2717 fs::create_dir_all(&before).expect("mkdir");
2718 init_repo_at(&before);
2719 add_worker_worktree(&before, "lbl");
2720 let wt = before.join(".claude").join("worktrees").join("lbl");
2721 record_worktree_owned(&before, "lbl", &wt);
2722 record_worktree_state(&before, "lbl", &wt, WorktreeRecord::Removing);
2724 deregister_worktree(&before, "lbl");
2725 let after = parent.path().join("after");
2726 fs::rename(&before, &after).expect("move the repo");
2727 let wt = after.join(".claude").join("worktrees").join("lbl");
2728 let link = fs::read_to_string(wt.join(".git")).expect("leftover link");
2729 let named = link.trim().strip_prefix("gitdir:").expect("a gitdir link").trim();
2730 assert!(
2731 !Path::new(named).exists(),
2732 "fixture precondition: the link names the path the repo left behind: {named}",
2733 );
2734 assert_eq!(
2735 recorded_worktree(&after, &wt).map(|record| record.state),
2736 Some(WorktreeRecord::Removing),
2737 "the removal record moved with the repo",
2738 );
2739
2740 remove_orphaned_worktree_dir(&after, &wt).expect("its own leftover clears after a move");
2741
2742 assert!(!wt.exists(), "the leftover directory is gone");
2743 assert_eq!(
2744 recorded_worktree(&after, &wt).map(|record| record.state),
2745 Some(WorktreeRecord::Cleared),
2746 "the clear leaves no authorization behind for the next directory at this label",
2747 );
2748 }
2749
2750 #[test]
2756 fn orphan_removal_refuses_a_moved_worktree_without_a_removal_record() {
2757 let parent = tempdir().expect("parent");
2758 let before = parent.path().join("before");
2759 fs::create_dir_all(&before).expect("mkdir");
2760 init_repo_at(&before);
2761 add_worker_worktree(&before, "lbl");
2762 let after = parent.path().join("after");
2763 fs::rename(&before, &after).expect("move the repo");
2764 let wt = after.join(".claude").join("worktrees").join("lbl");
2765 deregister_worktree(&after, "lbl");
2768 fs::write(wt.join("uncommitted.txt"), "work in progress").expect("write work");
2769
2770 let err = remove_orphaned_worktree_dir(&after, &wt)
2771 .expect_err("a live worktree with no admitted removal refuses");
2772
2773 assert!(matches!(err, WorktreeError::ForeignLeftover { .. }), "{err:?}");
2774 assert!(wt.join("uncommitted.txt").exists(), "the uncommitted work was not deleted");
2775 }
2776
2777 #[test]
2782 fn orphan_removal_refuses_a_moved_foreign_worktree_link() {
2783 let dir = init_repo_with_commit();
2784 let foreign_parent = tempdir().expect("foreign parent");
2785 let foreign = foreign_parent.path().join("foreign");
2786 fs::create_dir_all(&foreign).expect("mkdir");
2787 init_repo_at(&foreign);
2788 let wt = dir.path().join(".claude").join("worktrees").join("lbl");
2789 fs::create_dir_all(&wt).expect("mkdir");
2790 fs::write(
2791 wt.join(".git"),
2792 format!("gitdir: {}\n", foreign.join(".git").join("worktrees").join("lbl").display()),
2793 )
2794 .expect("write the foreign link");
2795 fs::rename(&foreign, foreign_parent.path().join("gone")).expect("move the foreign repo");
2796
2797 let err = remove_orphaned_worktree_dir(dir.path(), &wt)
2798 .expect_err("a moved foreign link refuses");
2799
2800 assert!(matches!(err, WorktreeError::ForeignLeftover { .. }), "{err:?}");
2801 assert!(wt.exists(), "the directory was left alone");
2802 }
2803
2804 #[test]
2808 fn orphan_removal_refuses_a_stale_link_naming_another_worktree() {
2809 let dir = init_repo_with_commit();
2810 let foreign_parent = tempdir().expect("foreign parent");
2811 let foreign = foreign_parent.path().join("foreign");
2812 fs::create_dir_all(&foreign).expect("mkdir");
2813 init_repo_at(&foreign);
2814 let wt = dir.path().join(".claude").join("worktrees").join("lbl");
2815 fs::create_dir_all(&wt).expect("mkdir");
2816 fs::write(
2817 wt.join(".git"),
2818 format!("gitdir: {}\n", foreign.join(".git").join("worktrees").join("other").display()),
2819 )
2820 .expect("write the foreign link");
2821 fs::rename(&foreign, foreign_parent.path().join("gone")).expect("move the foreign repo");
2822
2823 let err = remove_orphaned_worktree_dir(dir.path(), &wt)
2824 .expect_err("a stale link to another worktree refuses");
2825
2826 assert!(matches!(err, WorktreeError::ForeignLeftover { .. }), "{err:?}");
2827 assert!(wt.exists(), "the directory was left alone");
2828 }
2829
2830 #[test]
2835 fn orphan_removal_refuses_a_moved_worktree_recorded_owned_or_cleared() {
2836 for state in [WorktreeRecord::Owned, WorktreeRecord::Cleared] {
2837 let parent = tempdir().expect("parent");
2838 let before = parent.path().join("before");
2839 fs::create_dir_all(&before).expect("mkdir");
2840 init_repo_at(&before);
2841 add_worker_worktree(&before, "lbl");
2842 let wt = before.join(".claude").join("worktrees").join("lbl");
2843 record_worktree_state(&before, "lbl", &wt, state);
2844 deregister_worktree(&before, "lbl");
2845 let after = parent.path().join("after");
2846 fs::rename(&before, &after).expect("move the repo");
2847 let wt = after.join(".claude").join("worktrees").join("lbl");
2848 fs::write(wt.join("uncommitted.txt"), "work in progress").expect("write work");
2849
2850 let err = remove_orphaned_worktree_dir(&after, &wt)
2851 .expect_err("only an admitted removal authorizes the clear");
2852
2853 assert!(matches!(err, WorktreeError::ForeignLeftover { .. }), "{state:?}: {err:?}");
2854 assert!(wt.exists(), "{state:?}: the directory was left alone");
2855 }
2856 }
2857
2858 #[test]
2861 fn orphan_removal_refuses_a_leftover_whose_label_has_no_admission() {
2862 let parent = tempdir().expect("parent");
2863 let before = parent.path().join("before");
2864 fs::create_dir_all(&before).expect("mkdir");
2865 init_repo_at(&before);
2866 add_worker_worktree(&before, "b");
2867 record_worktree_state(
2868 &before,
2869 "a",
2870 &before.join(".claude").join("worktrees").join("a"),
2871 WorktreeRecord::Removing,
2872 );
2873 deregister_worktree(&before, "b");
2874 let after = parent.path().join("after");
2875 fs::rename(&before, &after).expect("move the repo");
2876 let wt = after.join(".claude").join("worktrees").join("b");
2877
2878 let err = remove_orphaned_worktree_dir(&after, &wt)
2879 .expect_err("another label's admission does not authorize the clear");
2880
2881 assert!(matches!(err, WorktreeError::ForeignLeftover { .. }), "{err:?}");
2882 assert!(wt.exists(), "the directory was left alone");
2883 }
2884
2885 #[test]
2892 fn another_checkouts_record_does_not_speak_for_this_one() {
2893 let dir = init_repo_with_commit();
2894 let left = dir.path().join("left");
2895 let right = dir.path().join("right");
2896 run_git(
2897 dir.path(),
2898 &["worktree", "add", "-q", "-b", "left", left.to_str().expect("utf8 path")],
2899 );
2900 run_git(
2901 dir.path(),
2902 &["worktree", "add", "-q", "-b", "right", right.to_str().expect("utf8 path")],
2903 );
2904 let left_wt = left.join(".claude").join("worktrees").join("w");
2905 run_git(
2906 &left,
2907 &["worktree", "add", "-q", "-b", "worktree-w", left_wt.to_str().expect("utf8 path")],
2908 );
2909 record_worktree_state(&left, "w", &left_wt, WorktreeRecord::Removing);
2910 let right_wt = right.join(".claude").join("worktrees").join("w");
2914 fs::create_dir_all(&right_wt).expect("mkdir");
2915 fs::write(right_wt.join("uncommitted.txt"), "right's work").expect("write");
2916
2917 let err = remove_orphaned_worktree_dir(&right, &right_wt)
2918 .expect_err("the left checkout's admission does not speak for the right");
2919
2920 assert!(matches!(err, WorktreeError::ForeignLeftover { .. }), "{err:?}");
2921 assert!(right_wt.join("uncommitted.txt").exists(), "right's work was not deleted");
2922
2923 record_worktree_state(&right, "w", &right_wt.join("gone"), WorktreeRecord::Cleared);
2927 assert_eq!(
2928 recorded_worktree(&left, &left_wt).map(|record| record.state),
2929 Some(WorktreeRecord::Removing),
2930 "the left checkout's live admission still speaks for its own worktree",
2931 );
2932
2933 record_worktree_state(&left, "w", &left.join("gone-wt"), WorktreeRecord::Removing);
2937 let err = remove_orphaned_worktree_dir(&right, &right_wt)
2938 .expect_err("a stale record from a live other checkout does not speak for the right");
2939 assert!(matches!(err, WorktreeError::ForeignLeftover { .. }), "{err:?}");
2940 assert!(right_wt.join("uncommitted.txt").exists(), "right's work was not deleted");
2941 }
2942
2943 #[test]
2948 fn remove_owned_worktree_leaves_the_record_a_failed_removal_needs() {
2949 use std::os::unix::fs::PermissionsExt;
2950
2951 let parent = tempdir().expect("parent");
2952 let before = parent.path().join("before");
2953 fs::create_dir_all(&before).expect("mkdir");
2954 init_repo_at(&before);
2955 let branch = add_worker_worktree(&before, "lbl");
2956 let wt = before.join(".claude").join("worktrees").join("lbl");
2957 let blocked = wt.join("blocked");
2958 fs::create_dir_all(&blocked).expect("blocked dir");
2959 fs::write(blocked.join("file"), "x").expect("file");
2960 fs::set_permissions(&blocked, fs::Permissions::from_mode(0o555)).expect("chmod");
2961 let err = remove_owned_worktree(&before, "lbl", &wt, true)
2962 .expect_err("the removal dies on the unwritable directory");
2963 assert!(wt.exists(), "the directory survives the failed removal: {err:?}");
2964 assert_eq!(
2965 recorded_worktree(&before, &wt).map(|record| record.state),
2966 Some(WorktreeRecord::Removing),
2967 "the admission stands after the removal died",
2968 );
2969
2970 fs::set_permissions(&blocked, fs::Permissions::from_mode(0o755)).expect("chmod back");
2971 let after = parent.path().join("after");
2972 fs::rename(&before, &after).expect("move the repo");
2973 let wt = after.join(".claude").join("worktrees").join("lbl");
2974
2975 let outcome = ensure_worker_worktree(&after, "lbl", &wt)
2976 .expect("the moved leftover clears on the record");
2977
2978 assert_eq!(outcome, WorktreeEnsure::AttachedExistingBranch, "the branch is reattached");
2979 assert_eq!(worktree_branch(&wt).as_deref(), Some(branch.as_str()));
2980 assert!(!wt.join("blocked").exists(), "the leftover's dead tree is gone");
2981 }
2982
2983 #[test]
2986 fn a_completed_removal_clears_the_record() {
2987 let (dir, wt, _branch) = init_repo_with_worker_worktree("lbl");
2988
2989 remove_owned_worktree(dir.path(), "lbl", &wt, true).expect("the worktree removes");
2990
2991 assert_eq!(
2992 recorded_worktree(dir.path(), &wt).map(|record| record.state),
2993 Some(WorktreeRecord::Cleared),
2994 "a completed removal leaves nothing to clear",
2995 );
2996 }
2997
2998 #[test]
3003 fn a_forced_removal_of_a_gone_worktree_admits_nothing() {
3004 let parent = tempdir().expect("parent");
3005 let before = parent.path().join("before");
3006 fs::create_dir_all(&before).expect("mkdir");
3007 init_repo_at(&before);
3008 let branch = add_worker_worktree(&before, "lbl");
3009 let wt = before.join(".claude").join("worktrees").join("lbl");
3010 fs::remove_dir_all(wt.parent().expect("worktrees root")).expect("the worktree is gone");
3015 remove_owned_worktree(&before, "lbl", &wt, true)
3016 .expect_err("git cannot run from a missing parent");
3017 assert_eq!(
3018 recorded_worktree(&before, &wt).map(|record| record.state),
3019 Some(WorktreeRecord::Cleared),
3020 "a gone path admits nothing",
3021 );
3022 if before.join(".git").join("worktrees").join("lbl").exists() {
3025 deregister_worktree(&before, "lbl");
3026 }
3027 run_git(&before, &["worktree", "add", "-q", wt.to_str().expect("utf8 path"), &branch]);
3028 fs::write(wt.join("uncommitted.txt"), "new worker's work").expect("write work");
3029 deregister_worktree(&before, "lbl");
3030 let after = parent.path().join("after");
3031 fs::rename(&before, &after).expect("move the repo");
3032 let wt = after.join(".claude").join("worktrees").join("lbl");
3033
3034 let err = ensure_worker_worktree(&after, "lbl", &wt)
3035 .expect_err("the spent admission does not authorize deleting the new worktree");
3036
3037 assert!(matches!(err, WorktreeError::ForeignLeftover { .. }), "{err:?}");
3038 assert!(wt.join("uncommitted.txt").exists(), "the new worker's work is intact");
3039 }
3040
3041 #[test]
3046 fn orphan_removal_decides_a_no_git_directory_by_its_record() {
3047 let dir = init_repo_with_commit();
3048 let wt = dir.path().join(".claude").join("worktrees").join("lbl");
3049 fs::create_dir_all(&wt).expect("mkdir");
3050 fs::write(wt.join("uncommitted.txt"), "work").expect("write");
3051
3052 let err = remove_orphaned_worktree_dir(dir.path(), &wt)
3053 .expect_err("no .git and no admission refuses");
3054 assert!(matches!(err, WorktreeError::ForeignLeftover { .. }), "{err:?}");
3055 assert!(wt.join("uncommitted.txt").exists(), "the directory was left alone");
3056
3057 record_worktree_state(dir.path(), "lbl", &wt, WorktreeRecord::Removing);
3058 remove_orphaned_worktree_dir(dir.path(), &wt).expect("an admitted removal clears it");
3059 assert!(!wt.exists(), "the leftover directory is gone");
3060 }
3061
3062 #[test]
3063 fn orphan_removal_clears_an_empty_directory_without_a_record() {
3064 let dir = init_repo_with_commit();
3065 let wt = dir.path().join(".claude").join("worktrees").join("lbl");
3066 fs::create_dir_all(&wt).expect("mkdir");
3067
3068 remove_orphaned_worktree_dir(dir.path(), &wt)
3069 .expect("an empty directory holds nothing to lose");
3070
3071 assert!(!wt.exists(), "the empty directory is gone");
3072 }
3073
3074 #[test]
3078 fn ensure_does_not_repair_a_registration_over_a_foreign_link() {
3079 let other = init_repo_with_commit();
3080 let parent = tempdir().expect("parent");
3081 let before = parent.path().join("before");
3082 fs::create_dir_all(&before).expect("mkdir");
3083 init_repo_at(&before);
3084 add_worker_worktree(&before, "lbl");
3085 let wt = before.join(".claude").join("worktrees").join("lbl");
3086 let foreign = format!(
3087 "gitdir: {}\n",
3088 other.path().join(".git").join("worktrees").join("lbl").display()
3089 );
3090 fs::write(wt.join(".git"), &foreign).expect("write the foreign link");
3091 let after = parent.path().join("after");
3092 fs::rename(&before, &after).expect("move the repo");
3093 let wt = after.join(".claude").join("worktrees").join("lbl");
3094
3095 let err = ensure_worker_worktree(&after, "lbl", &wt)
3096 .expect_err("a repair over a live foreign link refuses");
3097
3098 assert!(matches!(err, WorktreeError::RegistrationUnusable { .. }), "{err:?}");
3099 assert_eq!(
3100 fs::read_to_string(wt.join(".git")).expect("the link"),
3101 foreign,
3102 "the foreign link was not rewritten",
3103 );
3104 }
3105
3106 #[test]
3109 fn ensure_refuses_a_moved_registration_git_cannot_repair() {
3110 use std::os::unix::fs::PermissionsExt;
3111
3112 let parent = tempdir().expect("parent");
3113 let before = parent.path().join("before");
3114 fs::create_dir_all(&before).expect("mkdir");
3115 init_repo_at(&before);
3116 add_worker_worktree(&before, "lbl");
3117 let after = parent.path().join("after");
3118 fs::rename(&before, &after).expect("move the repo");
3119 let wt = after.join(".claude").join("worktrees").join("lbl");
3120 let gitdir = after.join(".git").join("worktrees").join("lbl").join("gitdir");
3123 fs::set_permissions(&gitdir, fs::Permissions::from_mode(0o444)).expect("chmod the gitdir");
3124
3125 let err = ensure_worker_worktree(&after, "lbl", &wt)
3126 .expect_err("a repair git refuses is a refusal");
3127
3128 assert!(matches!(err, WorktreeError::RegistrationUnusable { .. }), "{err:?}");
3129 fs::set_permissions(&gitdir, fs::Permissions::from_mode(0o644)).expect("chmod back");
3130 }
3131
3132 #[test]
3138 fn ensure_retracts_a_stale_admission_when_the_worktree_is_alive() {
3139 let parent = tempdir().expect("parent");
3140 let before = parent.path().join("before");
3141 fs::create_dir_all(&before).expect("mkdir");
3142 init_repo_at(&before);
3143 add_worker_worktree(&before, "lbl");
3144 let wt = before.join(".claude").join("worktrees").join("lbl");
3145 record_worktree_state(&before, "lbl", &wt, WorktreeRecord::Removing);
3148 ensure_worker_worktree(&before, "lbl", &wt).expect("the live worktree is reused");
3149 assert_eq!(
3150 recorded_worktree(&before, &wt).map(|record| record.state),
3151 Some(WorktreeRecord::Owned),
3152 "the live observation retracts the admission",
3153 );
3154 let after = parent.path().join("after");
3157 fs::rename(&before, &after).expect("move the repo");
3158 let wt = after.join(".claude").join("worktrees").join("lbl");
3159 deregister_worktree(&after, "lbl");
3160 fs::write(wt.join("uncommitted.txt"), "work in progress").expect("write work");
3161
3162 let err = ensure_worker_worktree(&after, "lbl", &wt)
3163 .expect_err("the retracted admission does not authorize the clear");
3164
3165 assert!(matches!(err, WorktreeError::ForeignLeftover { .. }), "{err:?}");
3166 assert!(wt.exists(), "the directory was left alone");
3167 }
3168
3169 #[test]
3173 fn orphan_removal_clears_a_leftover_with_a_live_link_and_no_record() {
3174 let (dir, wt, _branch) = init_repo_with_worker_worktree("lbl");
3175 deregister_worktree(dir.path(), "lbl");
3176
3177 remove_orphaned_worktree_dir(dir.path(), &wt).expect("a live link still proves ownership");
3178
3179 assert!(!wt.exists(), "the leftover directory is gone");
3180 }
3181
3182 #[test]
3188 fn ensure_reuses_a_moved_registered_worktree_after_repairing_it() {
3189 let parent = tempdir().expect("parent");
3190 let before = parent.path().join("before");
3191 fs::create_dir_all(&before).expect("mkdir");
3192 init_repo_at(&before);
3193 add_worker_worktree(&before, "lbl");
3194 let after = parent.path().join("after");
3195 fs::rename(&before, &after).expect("move the repo");
3196 let wt = after.join(".claude").join("worktrees").join("lbl");
3197 assert!(
3198 !worktree_runs_git(&wt),
3199 "fixture precondition: the moved worktree cannot run git before the repair",
3200 );
3201
3202 let outcome =
3203 ensure_worker_worktree(&after, "lbl", &wt).expect("the moved registration repairs");
3204
3205 assert_eq!(
3206 outcome,
3207 WorktreeEnsure::AlreadyPresent,
3208 "the worktree is reused, not recreated"
3209 );
3210 assert!(worktree_runs_git(&wt), "git commands work in the worktree after the repair");
3211 assert_eq!(
3212 worktree_registration(&after, &wt),
3213 Some(WorktreeRegistration::Live),
3214 "the listing now resolves at the new path",
3215 );
3216 assert_eq!(
3217 recorded_worktree(&after, &wt).map(|record| record.state),
3218 Some(WorktreeRecord::Owned),
3219 "the repaired worktree is recorded as this checkout's and not being removed",
3220 );
3221 assert!(wt.exists(), "the worktree stands");
3222 }
3223
3224 fn worktree_runs_git(wt: &Path) -> bool {
3225 std::process::Command::new("git")
3226 .args(["rev-parse", "--show-toplevel"])
3227 .current_dir(wt)
3228 .output()
3229 .is_ok_and(|out| out.status.success())
3230 }
3231
3232 fn copy_tree(from: &Path, to: &Path) {
3233 let status = std::process::Command::new("cp")
3234 .arg("-a")
3235 .arg(from)
3236 .arg(to)
3237 .status()
3238 .expect("spawn cp");
3239 assert!(status.success(), "cp -a {from:?} {to:?}");
3240 }
3241
3242 #[test]
3247 fn a_copied_repo_does_not_claim_the_original_worktree_registration() {
3248 let dir = init_repo_with_commit();
3249 add_worker_worktree(dir.path(), "lbl");
3250 let holder = tempdir().expect("holder");
3251 let clone = holder.path().join("clone");
3252 copy_tree(dir.path(), &clone);
3253 let clone_wt = clone.join(".claude").join("worktrees").join("lbl");
3254
3255 assert!(
3256 matches!(worktree_registration(&clone, &clone_wt), Some(WorktreeRegistration::Absent)),
3257 "the copied registration names the original worktree, which still resolves",
3258 );
3259 assert!(!worktree_registered(&clone, &clone_wt), "and is not this copy's registration");
3260 }
3261
3262 #[test]
3268 fn orphan_removal_clears_a_leftover_in_a_project_that_is_a_linked_worktree() {
3269 let dir = init_repo_with_commit();
3270 let linked = dir.path().join("linked");
3271 run_git(
3272 dir.path(),
3273 &["worktree", "add", "-q", "-b", "linked", linked.to_str().expect("utf8 path")],
3274 );
3275 let wt = linked.join(".claude").join("worktrees").join("lbl");
3276 run_git(
3277 &linked,
3278 &["worktree", "add", "-q", "-b", "worktree-lbl", wt.to_str().expect("utf8 path")],
3279 );
3280 record_worktree_state(&linked, "lbl", &wt, WorktreeRecord::Removing);
3281 fs::remove_dir_all(dir.path().join(".git").join("worktrees").join("lbl"))
3282 .expect("deregister the worktree under the common dir");
3283
3284 remove_orphaned_worktree_dir(&linked, &wt)
3285 .expect("its own leftover clears under a linked worktree");
3286
3287 assert!(!wt.exists(), "the leftover directory is gone");
3288 }
3289
3290 fn branch_exists(repo: &Path, branch: &str) -> bool {
3291 let out = std::process::Command::new("git")
3292 .args(["rev-parse", "--verify", "--quiet", &format!("refs/heads/{branch}")])
3293 .current_dir(repo)
3294 .output()
3295 .expect("spawn git");
3296 match out.status.code() {
3297 Some(0) => true,
3298 Some(1) => false,
3299 other => panic!("git rev-parse in {repo:?} exited {other:?}, so absence is unproven"),
3300 }
3301 }
3302
3303 fn init_repo_with_worker_worktree(
3308 label: &str,
3309 ) -> (tempfile::TempDir, std::path::PathBuf, String) {
3310 let dir = init_repo_with_commit();
3311 let branch = add_worker_worktree(dir.path(), label);
3312 let wt = dir.path().join(".claude").join("worktrees").join(label);
3313 (dir, wt, branch)
3314 }
3315
3316 fn add_worker_worktree(repo: &Path, label: &str) -> String {
3320 let branch = format!("worktree-{label}");
3321 let wt = repo.join(".claude").join("worktrees").join(label);
3322 run_git(repo, &["worktree", "add", "-q", "-b", &branch, wt.to_str().expect("utf8 path")]);
3323 branch
3324 }
3325
3326 fn worktree_gitdir(wt: &Path) -> PathBuf {
3328 let link = fs::read_to_string(wt.join(".git")).expect("the worktree's link");
3329 let gitdir = link.trim().strip_prefix("gitdir:").expect("a gitdir link").trim();
3330 PathBuf::from(gitdir)
3331 }
3332
3333 fn git_stdout(repo: &Path, args: &[&str]) -> String {
3334 let out = std::process::Command::new("git")
3335 .args(args)
3336 .current_dir(repo)
3337 .output()
3338 .expect("spawn git");
3339 assert!(out.status.success(), "git {args:?} failed in {repo:?}");
3340 String::from_utf8_lossy(&out.stdout).trim().to_owned()
3341 }
3342
3343 fn drop_worktree(repo: &Path, wt: &Path) {
3344 run_git(repo, &["worktree", "remove", "--force", wt.to_str().expect("utf8 path")]);
3345 }
3346
3347 fn deregister_worktree(repo: &Path, name: &str) {
3353 fs::remove_dir_all(repo.join(".git").join("worktrees").join(name))
3354 .expect("drop the registration");
3355 }
3356
3357 #[test]
3358 fn reaps_a_pristine_worker_branch() {
3359 let (dir, wt, branch) = init_repo_with_worker_worktree("lbl");
3360 drop_worktree(dir.path(), &wt);
3361 assert_eq!(reap_worktree_branch(dir.path(), &branch), BranchReapOutcome::Reaped);
3362 assert!(!branch_exists(dir.path(), &branch), "the ref is actually gone");
3363 }
3364
3365 #[test]
3369 fn reaps_a_branch_whose_commits_live_on_a_remote_ref() {
3370 let (dir, wt, branch) = init_repo_with_worker_worktree("lbl");
3371 let remote = tempdir().expect("remote tempdir");
3372 run_git(remote.path(), &["init", "-q", "--bare"]);
3373 run_git(&wt, &["remote", "add", "origin", remote.path().to_str().expect("utf8 path")]);
3374 fs::write(wt.join("work.txt"), "pushed work").expect("write work");
3375 run_git(&wt, &["add", "."]);
3376 run_git(&wt, &["commit", "-q", "-m", "real work"]);
3377 run_git(&wt, &["push", "-q", "-u", "origin", &branch]);
3378 drop_worktree(dir.path(), &wt);
3379
3380 assert_eq!(reap_worktree_branch(dir.path(), &branch), BranchReapOutcome::Reaped);
3381 assert!(!branch_exists(dir.path(), &branch), "the local ref goes, the commits do not");
3382 }
3383
3384 #[test]
3385 fn keeps_a_branch_holding_unique_commits() {
3386 let (dir, wt, branch) = init_repo_with_worker_worktree("lbl");
3387 fs::write(wt.join("work.txt"), "a worker committed here").expect("write work");
3388 run_git(&wt, &["add", "."]);
3389 run_git(&wt, &["commit", "-q", "-m", "real work"]);
3390 drop_worktree(dir.path(), &wt);
3391
3392 let outcome = reap_worktree_branch(dir.path(), &branch);
3393 let BranchReapOutcome::Kept { count, tip } = outcome else {
3394 panic!("a branch with its own commit is kept, got {outcome:?}");
3395 };
3396 assert_eq!(count, 1, "the one commit reachable from no other ref");
3397 assert_eq!(
3398 tip,
3399 git_stdout(dir.path(), &["rev-parse", "--short", &branch]),
3400 "the warning names the BRANCH tip, so 'git log <tip>' lands in the right history",
3401 );
3402 assert!(branch_exists(dir.path(), &branch), "the branch survives");
3403 }
3404
3405 #[test]
3409 fn keeps_a_squash_merged_branch() {
3410 let (dir, wt, branch) = init_repo_with_worker_worktree("lbl");
3411 fs::write(wt.join("work.txt"), "a worker committed here").expect("write work");
3412 run_git(&wt, &["add", "."]);
3413 run_git(&wt, &["commit", "-q", "-m", "real work"]);
3414 run_git(dir.path(), &["merge", "-q", "--squash", &branch]);
3415 run_git(dir.path(), &["commit", "-q", "-m", "squashed the worker's work"]);
3416 drop_worktree(dir.path(), &wt);
3417
3418 assert!(
3419 git_stdout(dir.path(), &["cherry", "HEAD", &branch]).starts_with("- "),
3420 "fixture precondition: the squash is patch-identical, so a patch-id gate reaps it",
3421 );
3422 assert!(
3423 matches!(reap_worktree_branch(dir.path(), &branch), BranchReapOutcome::Kept { .. }),
3424 "a squash-merged branch still holds its own commit object",
3425 );
3426 assert!(branch_exists(dir.path(), &branch));
3427 }
3428
3429 #[test]
3434 fn reaps_a_pristine_branch_whose_tip_is_not_an_ancestor_of_head() {
3435 let dir = init_repo_with_commit();
3436 run_git(dir.path(), &["branch", "operator-work"]);
3439 fs::write(dir.path().join("README.md"), "advanced").expect("write advance");
3440 run_git(dir.path(), &["commit", "-qam", "advance"]);
3441 let branch = add_worker_worktree(dir.path(), "lbl");
3442 drop_worktree(dir.path(), &dir.path().join(".claude").join("worktrees").join("lbl"));
3443 run_git(dir.path(), &["checkout", "-q", "operator-work"]);
3444
3445 let not_ancestor = std::process::Command::new("git")
3446 .args(["merge-base", "--is-ancestor", &branch, "HEAD"])
3447 .current_dir(dir.path())
3448 .status()
3449 .expect("spawn git");
3450 assert!(!not_ancestor.success(), "fixture precondition: the tip is ahead of HEAD");
3451
3452 assert_eq!(reap_worktree_branch(dir.path(), &branch), BranchReapOutcome::Reaped);
3453 assert!(!branch_exists(dir.path(), &branch));
3454 }
3455
3456 #[test]
3457 fn missing_branch_is_a_noop() {
3458 let (dir, wt, _branch) = init_repo_with_worker_worktree("lbl");
3459 drop_worktree(dir.path(), &wt);
3460 assert_eq!(
3461 reap_worktree_branch(dir.path(), "worktree-never-existed"),
3462 BranchReapOutcome::NotPresent
3463 );
3464 }
3465
3466 #[test]
3467 fn ensure_worker_worktree_leaves_an_existing_worktree_alone() {
3468 let (dir, wt, branch) = init_repo_with_worker_worktree("lbl");
3469 let outcome = ensure_worker_worktree(dir.path(), "lbl", &wt).expect("no-op");
3470 assert_eq!(outcome, WorktreeEnsure::AlreadyPresent, "nothing was touched");
3471 assert!(wt.exists());
3472 assert_eq!(worktree_branch(&wt).as_deref(), Some(branch.as_str()));
3473 }
3474
3475 #[test]
3479 fn ensure_worker_worktree_never_touches_a_dirty_existing_worktree() {
3480 let (dir, wt, _branch) = init_repo_with_worker_worktree("lbl");
3481 fs::write(wt.join("uncommitted.txt"), "in flight").expect("write uncommitted");
3482 let outcome = ensure_worker_worktree(dir.path(), "lbl", &wt).expect("no-op");
3483 assert_eq!(outcome, WorktreeEnsure::AlreadyPresent);
3484 assert!(wt.join("uncommitted.txt").exists(), "the uncommitted work survives ensure");
3485 }
3486
3487 #[test]
3492 fn ensure_worker_worktree_reattaches_a_surviving_branch() {
3493 let (dir, wt, branch) = init_repo_with_worker_worktree("lbl");
3494 fs::write(wt.join("work.txt"), "a worker committed here").expect("write work");
3495 run_git(&wt, &["add", "."]);
3496 run_git(&wt, &["commit", "-q", "-m", "real work"]);
3497 drop_worktree(dir.path(), &wt);
3498 assert!(!wt.exists(), "fixture precondition: the worktree is gone");
3499 assert!(branch_ref_exists(dir.path(), &branch), "fixture precondition: branch kept");
3500
3501 let outcome = ensure_worker_worktree(dir.path(), "lbl", &wt).expect("reattach succeeds");
3502 assert_eq!(
3503 outcome,
3504 WorktreeEnsure::AttachedExistingBranch,
3505 "the branch predates the call, so a rollback must spare it"
3506 );
3507 assert!(wt.exists(), "the worktree exists again");
3508 assert_eq!(worktree_branch(&wt).as_deref(), Some(branch.as_str()));
3509 assert!(wt.join("work.txt").exists(), "reattaching the branch restores its content");
3510 }
3511
3512 #[test]
3520 fn ensure_worker_worktree_clears_a_leftover_dir_git_no_longer_tracks() {
3521 let (dir, wt, branch) = init_repo_with_worker_worktree("lbl");
3522 record_worktree_state(dir.path(), "lbl", &wt, WorktreeRecord::Removing);
3524 deregister_worktree(dir.path(), "lbl");
3525 fs::write(wt.join("late-write.txt"), "wrote while dying").expect("late write");
3526 assert!(wt.exists(), "fixture precondition: the directory is still there");
3527 assert!(
3528 !worktree_registered(dir.path(), &wt),
3529 "fixture precondition: git no longer lists the path",
3530 );
3531
3532 let outcome = ensure_worker_worktree(dir.path(), "lbl", &wt).expect("the leftover clears");
3533
3534 assert_eq!(
3535 outcome,
3536 WorktreeEnsure::AttachedExistingBranch,
3537 "the branch predates the call and may hold real work, so it is reused",
3538 );
3539 assert!(wt.exists(), "the worktree stands again");
3540 assert_eq!(worktree_branch(&wt).as_deref(), Some(branch.as_str()));
3541 assert!(!wt.join("late-write.txt").exists(), "the leftover's death-throes write is gone");
3542 assert_eq!(
3543 recorded_worktree(dir.path(), &wt).map(|record| record.state),
3544 Some(WorktreeRecord::Owned),
3545 "a created worktree is recorded as this checkout's",
3546 );
3547 }
3548
3549 #[test]
3554 fn ensure_worker_worktree_refuses_a_leftover_that_is_not_a_directory() {
3555 let dir = init_repo_with_commit();
3556 let wt = dir.path().join(".claude").join("worktrees").join("lbl");
3557 fs::create_dir_all(wt.parent().expect("worktrees dir")).expect("mkdir worktrees");
3558 fs::write(&wt, "a file where the worktree goes").expect("write a file at the path");
3559
3560 let err = ensure_worker_worktree(dir.path(), "lbl", &wt).expect_err("a file is refused");
3561
3562 assert!(
3563 matches!(err, WorktreeError::NotAPlainDirectory(ref at) if at.ends_with("lbl")),
3564 "the refusal names the occupied path: {err:?}",
3565 );
3566 }
3567
3568 #[test]
3573 fn ensure_worker_worktree_recreates_the_branch_when_reaped() {
3574 let (dir, wt, _branch) = init_repo_with_worker_worktree("lbl");
3575 drop_worktree(dir.path(), &wt);
3576 assert_eq!(
3577 reap_worktree_branch(dir.path(), "worktree-lbl"),
3578 BranchReapOutcome::Reaped,
3579 "fixture precondition: despawn reaped the branch"
3580 );
3581 assert!(!wt.exists());
3582
3583 let outcome = ensure_worker_worktree(dir.path(), "lbl", &wt).expect("recreate succeeds");
3584 assert_eq!(outcome, WorktreeEnsure::CreatedWithNewBranch);
3585 assert!(wt.exists());
3586 assert_eq!(worktree_branch(&wt).as_deref(), Some("worktree-lbl"));
3587 assert_eq!(
3588 git_stdout(dir.path(), &["rev-parse", "worktree-lbl"]),
3589 git_stdout(dir.path(), &["rev-parse", "HEAD"]),
3590 "the recreated branch starts at the repo's current HEAD"
3591 );
3592 }
3593
3594 #[test]
3600 fn ensure_worker_worktree_cuts_a_recreated_branch_from_the_origin_base() {
3601 let dir = init_repo_with_commit();
3602 let origin = git_stdout(dir.path(), &["rev-parse", "HEAD"]);
3603 run_git(dir.path(), &["update-ref", "refs/remotes/origin/main", &origin]);
3605 run_git(
3606 dir.path(),
3607 &["symbolic-ref", "refs/remotes/origin/HEAD", "refs/remotes/origin/main"],
3608 );
3609 fs::write(dir.path().join("README.md"), "local work").expect("write advance");
3611 run_git(dir.path(), &["commit", "-qam", "local ahead"]);
3612 let local = git_stdout(dir.path(), &["rev-parse", "HEAD"]);
3613 assert_ne!(origin, local, "fixture precondition: local HEAD is ahead of the origin base");
3614
3615 let wt = dir.path().join(".claude").join("worktrees").join("lbl");
3616 let outcome = ensure_worker_worktree(dir.path(), "lbl", &wt).expect("recreate succeeds");
3617
3618 assert_eq!(outcome, WorktreeEnsure::CreatedWithNewBranch);
3619 assert_eq!(
3620 git_stdout(dir.path(), &["rev-parse", "worktree-lbl"]),
3621 origin,
3622 "the new branch starts at origin/HEAD's commit, the base claude's own path uses",
3623 );
3624 }
3625
3626 #[test]
3630 fn ensure_worker_worktree_falls_back_when_origin_head_dangles() {
3631 let dir = init_repo_with_commit();
3632 let local = git_stdout(dir.path(), &["rev-parse", "HEAD"]);
3633 run_git(
3634 dir.path(),
3635 &["symbolic-ref", "refs/remotes/origin/HEAD", "refs/remotes/origin/gone"],
3636 );
3637 let wt = dir.path().join(".claude").join("worktrees").join("lbl");
3638
3639 let outcome = ensure_worker_worktree(dir.path(), "lbl", &wt).expect("recreate succeeds");
3640
3641 assert_eq!(outcome, WorktreeEnsure::CreatedWithNewBranch);
3642 assert_eq!(
3643 git_stdout(dir.path(), &["rev-parse", "worktree-lbl"]),
3644 local,
3645 "the local HEAD is the fallback when the origin base does not resolve",
3646 );
3647 }
3648
3649 #[test]
3653 fn ensure_worker_worktree_fails_when_the_branch_is_checked_out_elsewhere() {
3654 let (dir, wt, _branch) = init_repo_with_worker_worktree("lbl");
3655 drop_worktree(dir.path(), &wt);
3656 let second = dir.path().join("elsewhere");
3657 run_git(
3658 dir.path(),
3659 &["worktree", "add", "-q", second.to_str().expect("utf8"), "worktree-lbl"],
3660 );
3661
3662 let err = ensure_worker_worktree(dir.path(), "lbl", &wt).expect_err("attach is refused");
3663 assert!(
3664 matches!(err, WorktreeError::CreateFailed(ref reason) if reason.contains("worktree-lbl")),
3665 "the error carries git's stderr naming the branch: {err:?}"
3666 );
3667 assert!(!wt.exists(), "the failed add leaves no worktree behind");
3668 }
3669
3670 #[test]
3674 fn obstacle_names_a_branch_checked_out_elsewhere() {
3675 let repo = init_repo_with_commit();
3676 let elsewhere = repo.path().join("elsewhere");
3677 run_git(
3678 repo.path(),
3679 &["worktree", "add", "-q", "-b", "worktree-lbl", elsewhere.to_str().expect("utf8")],
3680 );
3681 let wt = repo.path().join(".claude").join("worktrees").join("lbl");
3682
3683 let obstacle = worktree_creation_obstacle(repo.path(), "lbl", &wt)
3684 .expect("the branch held elsewhere is an obstacle");
3685
3686 assert!(
3687 obstacle.contains("worktree-lbl") && obstacle.contains("already checked out"),
3688 "the obstacle names the branch and what holds it: {obstacle}"
3689 );
3690 }
3691
3692 #[test]
3695 fn obstacle_is_none_for_the_workers_own_worktree() {
3696 let repo = init_repo_with_commit();
3697 let wt = repo.path().join(".claude").join("worktrees").join("lbl");
3698 fs::create_dir_all(wt.parent().expect("worktrees dir")).expect("mkdir worktrees");
3699 run_git(
3700 repo.path(),
3701 &["worktree", "add", "-q", "-b", "worktree-lbl", wt.to_str().expect("utf8")],
3702 );
3703
3704 assert_eq!(
3705 worktree_creation_obstacle(repo.path(), "lbl", &wt),
3706 None,
3707 "a worktree registered at the worker's own path is not an obstacle",
3708 );
3709 }
3710
3711 #[test]
3712 fn obstacle_is_none_where_the_worktree_can_be_created() {
3713 let repo = init_repo_with_commit();
3714 let wt = repo.path().join(".claude").join("worktrees").join("lbl");
3715
3716 assert_eq!(worktree_creation_obstacle(repo.path(), "lbl", &wt), None);
3717 }
3718
3719 #[test]
3724 fn obstacle_is_none_for_an_empty_directory() {
3725 let repo = init_repo_with_commit();
3726 let wt = repo.path().join(".claude").join("worktrees").join("lbl");
3727 fs::create_dir_all(&wt).expect("empty worktrees dir");
3728
3729 assert_eq!(
3730 worktree_creation_obstacle(repo.path(), "lbl", &wt),
3731 None,
3732 "an empty directory is a path git will take",
3733 );
3734 }
3735
3736 #[test]
3741 fn obstacle_remedy_unlocks_before_pruning() {
3742 let (dir, wt, _branch) = init_repo_with_worker_worktree("lbl");
3743 run_git(dir.path(), &["worktree", "lock", wt.to_str().expect("utf8 path")]);
3744 fs::remove_dir_all(&wt).expect("delete the worktree directory");
3745
3746 let obstacle = worktree_creation_obstacle(dir.path(), "lbl", &wt)
3747 .expect("a locked registration with no directory is an obstacle");
3748
3749 let unlock = obstacle.find("worktree unlock").expect("the remedy clears the lock");
3750 let prune = obstacle.find("worktree prune").expect("and then clears the registration");
3751 assert!(
3752 unlock < prune,
3753 "the order is the remedy, not the pair of names: unlock comes first in {obstacle}"
3754 );
3755 }
3756
3757 #[test]
3758 fn obstacle_names_a_path_taken_by_something_else() {
3759 let repo = init_repo_with_commit();
3760 let wt = repo.path().join(".claude").join("worktrees").join("lbl");
3761 fs::create_dir_all(&wt).expect("stray dir");
3762 fs::write(wt.join("notes.txt"), "hand-made").expect("stray file");
3763
3764 let obstacle = worktree_creation_obstacle(repo.path(), "lbl", &wt)
3765 .expect("a stray directory at the path is an obstacle");
3766
3767 assert!(
3768 obstacle.contains("is not an empty directory"),
3769 "the obstacle names what is in the way: {obstacle}"
3770 );
3771 }
3772
3773 #[test]
3781 fn obstacle_refuses_an_entry_whose_listing_says_nothing_about_it() {
3782 let repo = Path::new("/repo");
3783 let path = Path::new("/repo/.claude/worktrees/lbl");
3784 let listing = "worktree /repo\nbranch refs/heads/main\n\n\
3785 worktree /repo/.claude/worktrees/lbl\nbranch refs/heads/worktree-lbl\n";
3786 assert!(
3787 !listing.contains("prunable") && !listing.contains("locked"),
3788 "fixture precondition: the listing carries neither marker, the older git shape",
3789 );
3790 let entries = listed_worktrees(listing);
3791
3792 let obstacle = obstacle_from_listing(&entries, repo, "worktree-lbl", path)
3793 .expect("a registration whose gitdir cannot resolve is an obstacle");
3794
3795 assert!(
3796 obstacle.contains("registration at"),
3797 "the obstacle names the registration that cannot be reused: {obstacle}"
3798 );
3799 }
3800
3801 #[test]
3808 fn obstacle_refuses_a_registration_whose_gitdir_git_cannot_reuse() {
3809 let (dir, wt, _branch) = init_repo_with_worker_worktree("lbl");
3811 let real = wt.join("real-link");
3812 fs::rename(wt.join(".git"), &real).expect("move the link aside");
3813 std::os::unix::fs::symlink(&real, wt.join(".git")).expect("symlink the link");
3814 assert!(
3815 worktree_creation_obstacle(dir.path(), "lbl", &wt).is_some(),
3816 "a symlinked .git link is not a registration git reuses",
3817 );
3818
3819 let (dir, wt, _branch) = init_repo_with_worker_worktree("lbl");
3821 let gitdir = worktree_gitdir(&wt);
3822 fs::remove_file(gitdir.join("HEAD")).expect("remove HEAD");
3823 assert!(
3824 worktree_creation_obstacle(dir.path(), "lbl", &wt).is_some(),
3825 "a gitdir with no HEAD cannot be reused",
3826 );
3827
3828 let (dir, wt, _branch) = init_repo_with_worker_worktree("lbl");
3830 let gitdir = worktree_gitdir(&wt);
3831 fs::write(gitdir.join("HEAD"), "ref: refs/heads/gone\n").expect("point HEAD at nothing");
3832 assert!(
3833 worktree_creation_obstacle(dir.path(), "lbl", &wt).is_some(),
3834 "a HEAD naming a deleted branch cannot be reused",
3835 );
3836
3837 let (dir, wt, _branch) = init_repo_with_worker_worktree("lbl");
3840 let (_other, other_wt, _other_branch) = init_repo_with_worker_worktree("lbl");
3841 let other_gitdir = worktree_gitdir(&other_wt);
3842 fs::write(wt.join(".git"), format!("gitdir: {}\n", other_gitdir.display()))
3843 .expect("point at another repository");
3844 assert!(
3845 worktree_creation_obstacle(dir.path(), "lbl", &wt).is_some(),
3846 "another repository's worktree gitdir is not this worktree's",
3847 );
3848 }
3849
3850 #[test]
3856 fn obstacle_refuses_a_locked_worktree_whose_gitdir_link_is_broken() {
3857 let (dir, wt, _branch) = init_repo_with_worker_worktree("lbl");
3858 run_git(dir.path(), &["worktree", "lock", wt.to_str().expect("utf8 path")]);
3859 fs::remove_file(wt.join(".git")).expect("delete the worktree's gitdir link");
3860 assert!(wt.is_dir(), "fixture precondition: the directory itself stands");
3861
3862 let obstacle = worktree_creation_obstacle(dir.path(), "lbl", &wt)
3863 .expect("a worktree git will not reuse is an obstacle");
3864
3865 assert!(
3866 obstacle.contains("cannot be reused"),
3867 "the obstacle names the registration git will not reuse: {obstacle}"
3868 );
3869 }
3870
3871 #[test]
3876 fn obstacle_refuses_a_dangling_symlink_at_the_path() {
3877 let repo = init_repo_with_commit();
3878 let wt = repo.path().join(".claude").join("worktrees").join("lbl");
3879 fs::create_dir_all(wt.parent().expect("worktrees dir")).expect("mkdir worktrees");
3880 std::os::unix::fs::symlink(repo.path().join("nowhere"), &wt).expect("dangling symlink");
3881
3882 let obstacle = worktree_creation_obstacle(repo.path(), "lbl", &wt)
3883 .expect("a symlink git refuses is an obstacle");
3884
3885 assert!(
3886 obstacle.contains("is not an empty directory"),
3887 "the obstacle names what is in the way: {obstacle}"
3888 );
3889 }
3890
3891 #[test]
3892 fn discard_worker_worktree_removes_a_minted_worktree_and_branch() {
3893 let (dir, wt, branch) = init_repo_with_worker_worktree("lbl");
3894 drop_worktree(dir.path(), &wt);
3895 assert_eq!(
3896 reap_worktree_branch(dir.path(), &branch),
3897 BranchReapOutcome::Reaped,
3898 "fixture precondition: the branch is gone, so ensure must mint it"
3899 );
3900 let outcome = ensure_worker_worktree(dir.path(), "lbl", &wt).expect("recreate succeeds");
3901 assert_eq!(outcome, WorktreeEnsure::CreatedWithNewBranch);
3902
3903 let warning = discard_worker_worktree(dir.path(), "lbl", &wt, outcome);
3904 assert!(warning.is_none(), "a clean discard warns about nothing: {warning:?}");
3905 assert!(!wt.exists(), "the minted worktree is gone");
3906 assert!(!branch_exists(dir.path(), &branch), "the minted branch is reaped");
3907 }
3908
3909 #[test]
3913 fn discard_worker_worktree_keeps_a_pre_existing_branch() {
3914 let (dir, wt, branch) = init_repo_with_worker_worktree("lbl");
3915 fs::write(wt.join("work.txt"), "a worker committed here").expect("write work");
3916 run_git(&wt, &["add", "."]);
3917 run_git(&wt, &["commit", "-q", "-m", "real work"]);
3918 drop_worktree(dir.path(), &wt);
3919 let outcome = ensure_worker_worktree(dir.path(), "lbl", &wt).expect("reattach succeeds");
3920 assert_eq!(outcome, WorktreeEnsure::AttachedExistingBranch);
3921
3922 let warning = discard_worker_worktree(dir.path(), "lbl", &wt, outcome);
3923 assert!(warning.is_none(), "a clean discard warns about nothing: {warning:?}");
3924 assert!(!wt.exists(), "the worktree ensure created is gone");
3925 assert!(branch_exists(dir.path(), &branch), "the pre-existing branch survives");
3926 assert_eq!(
3927 git_stdout(dir.path(), &["rev-list", "--count", &branch]),
3928 "2",
3929 "the branch's two commits (seed + the worker's) are all still reachable"
3930 );
3931 }
3932
3933 #[test]
3934 fn discard_worker_worktree_is_a_noop_when_ensure_touched_nothing() {
3935 let (dir, wt, _branch) = init_repo_with_worker_worktree("lbl");
3936 let warning =
3937 discard_worker_worktree(dir.path(), "lbl", &wt, WorktreeEnsure::AlreadyPresent);
3938 assert!(warning.is_none());
3939 assert!(wt.exists(), "an already-present worktree is left alone");
3940 }
3941
3942 #[test]
3943 fn a_refused_delete_is_not_reported_as_a_verification_failure() {
3944 let (dir, _wt, branch) = init_repo_with_worker_worktree("lbl");
3945 let outcome = reap_worktree_branch(dir.path(), &branch);
3946 let BranchReapOutcome::DeleteFailed { reason } = outcome else {
3947 panic!("a refused delete reports DeleteFailed, got {outcome:?}");
3948 };
3949 assert!(!reason.is_empty(), "the warning can name git's complaint");
3950 assert!(branch_exists(dir.path(), &branch), "the branch survives a refused delete");
3951 }
3952
3953 #[test]
3954 fn keeps_on_error_when_the_path_is_not_a_repo() {
3955 let dir = tempdir().expect("tempdir");
3956 assert!(
3957 matches!(
3958 reap_worktree_branch(dir.path(), "worktree-lbl"),
3959 BranchReapOutcome::KeptOnError { .. }
3960 ),
3961 "a repo we cannot read is never treated as a branch that does not exist",
3962 );
3963 }
3964
3965 #[test]
3966 fn branch_ref_exists_separates_present_from_absent() {
3967 let (dir, _wt, branch) = init_repo_with_worker_worktree("lbl");
3968 assert!(branch_ref_exists(dir.path(), &branch));
3969 assert!(!branch_ref_exists(dir.path(), "never-created"));
3970 }
3971
3972 #[test]
3976 fn branch_ref_exists_counts_a_branch_that_only_survives_on_a_remote() {
3977 let (dir, wt, branch) = init_repo_with_worker_worktree("lbl");
3978 let remote = tempdir().expect("remote tempdir");
3979 run_git(remote.path(), &["init", "-q", "--bare"]);
3980 run_git(&wt, &["remote", "add", "origin", remote.path().to_str().expect("utf8 path")]);
3981 fs::write(wt.join("work.txt"), "pushed work").expect("write work");
3982 run_git(&wt, &["add", "."]);
3983 run_git(&wt, &["commit", "-q", "-m", "real work"]);
3984 run_git(&wt, &["push", "-q", "-u", "origin", &branch]);
3985 drop_worktree(dir.path(), &wt);
3986 assert_eq!(reap_worktree_branch(dir.path(), &branch), BranchReapOutcome::Reaped);
3987
3988 assert!(!branch_exists(dir.path(), &branch), "the local head really is gone");
3989 assert!(
3990 branch_ref_exists(dir.path(), &branch),
3991 "a branch alive only on a remote still exists for review purposes",
3992 );
3993 }
3994
3995 #[test]
3997 fn branch_ref_exists_handles_a_slashed_branch_name() {
3998 let (dir, wt, _branch) = init_repo_with_worker_worktree("lbl");
3999 let remote = tempdir().expect("remote tempdir");
4000 run_git(remote.path(), &["init", "-q", "--bare"]);
4001 run_git(&wt, &["remote", "add", "origin", remote.path().to_str().expect("utf8 path")]);
4002 run_git(&wt, &["checkout", "-q", "-b", "fix/deep/name"]);
4003 run_git(&wt, &["push", "-q", "-u", "origin", "fix/deep/name"]);
4004 run_git(&wt, &["checkout", "-q", "-"]);
4005 run_git(dir.path(), &["branch", "-D", "fix/deep/name"]);
4006
4007 assert!(branch_ref_exists(dir.path(), "fix/deep/name"));
4008 assert!(!branch_ref_exists(dir.path(), "fix/deep"));
4009 }
4010
4011 #[test]
4016 fn branch_ref_exists_sees_a_branch_behind_a_slash_bearing_remote() {
4017 let dir = init_repo_with_commit();
4018 run_git(dir.path(), &["update-ref", "refs/remotes/gh/upstream/feat/pushed", "HEAD"]);
4019 assert!(
4020 branch_ref_exists(dir.path(), "feat/pushed"),
4021 "a branch surviving only on a slash-named remote is still present",
4022 );
4023 assert!(!branch_ref_exists(dir.path(), "feat/never-existed"));
4024 }
4025
4026 #[test]
4031 fn branch_ref_exists_reads_a_nested_path_as_present() {
4032 let dir = init_repo_with_commit();
4033 let nested = dir.path().join("crates");
4034 fs::create_dir_all(&nested).expect("mkdir");
4035 assert!(branch_ref_exists(&nested, "anything-at-all"));
4036 }
4037
4038 #[test]
4039 fn branch_ref_exists_reads_an_unreadable_repo_as_present() {
4040 let dir = tempdir().expect("tempdir");
4041 assert!(
4042 branch_ref_exists(dir.path(), "worktree-lbl"),
4043 "a repo we cannot read must not read as a branch that is gone",
4044 );
4045 }
4046
4047 fn kept_worktree_response(path: &str, branch: &str) -> serde_json::Value {
4053 serde_json::json!({
4054 "status": "completed",
4055 "prompt": "Create a file named kept.txt in the repo root containing the word hello. Do not commit it. When finished, reply with just the word done.",
4056 "agentId": "ac437073a41a2149d",
4057 "agentType": "general-purpose",
4058 "content": [{ "type": "text", "text": "done" }],
4059 "resolvedModel": "glm-5.3-flash",
4060 "totalDurationMs": 111_649,
4061 "totalTokens": 35201,
4062 "totalToolUseCount": 8,
4063 "usage": {
4064 "input_tokens": 190,
4065 "cache_creation_input_tokens": 0,
4066 "cache_read_input_tokens": 35008,
4067 "output_tokens": 3,
4068 "server_tool_use": { "web_search_requests": 0, "web_fetch_requests": 0 },
4069 "service_tier": "standard",
4070 },
4071 "toolStats": { "editFileCount": 1, "linesAdded": 2, "linesRemoved": 0 },
4072 "worktreePath": path,
4073 "worktreeBranch": branch,
4074 })
4075 }
4076
4077 fn async_launch_response(output_file: &str) -> serde_json::Value {
4082 serde_json::json!({
4083 "isAsync": true,
4084 "status": "async_launched",
4085 "agentId": "a01ffe965157f5add",
4086 "description": "capture async",
4087 "resolvedModel": "glm-5.3-flash",
4088 "prompt": "sleep then done",
4089 "outputFile": output_file,
4090 "canReadOutputFile": true,
4091 })
4092 }
4093
4094 fn agent_input() -> serde_json::Value {
4095 serde_json::json!({ "isolation": "worktree", "prompt": "review it" })
4096 }
4097
4098 #[test]
4102 fn completed_agent_worktree_ignores_a_completed_response_without_a_worktree() {
4103 let mut response = kept_worktree_response("/repo/.claude/worktrees/agent-abc123", "w");
4104 response.as_object_mut().expect("object").remove("worktreePath");
4105 response.as_object_mut().expect("object").remove("worktreeBranch");
4106 assert!(completed_agent_worktree("Agent", &agent_input(), &response).is_none());
4107 }
4108
4109 #[test]
4112 fn completed_agent_worktree_never_reaps_an_async_launch() {
4113 assert!(
4114 completed_agent_worktree(
4115 "Agent",
4116 &agent_input(),
4117 &async_launch_response("/tmp/tasks/a01ffe965157f5add.output")
4118 )
4119 .is_none()
4120 );
4121 }
4122
4123 #[test]
4124 fn is_isolated_agent_call_tracks_the_gate_population() {
4125 assert!(is_isolated_agent_call("Agent", &agent_input()));
4126 assert!(is_isolated_agent_call("Task", &agent_input()));
4127 assert!(!is_isolated_agent_call("EnterWorktree", &agent_input()));
4128 assert!(!is_isolated_agent_call("Agent", &serde_json::json!({ "prompt": "plain" })));
4129 }
4130
4131 #[test]
4132 fn completed_agent_worktree_parses_the_managed_path_and_branch() {
4133 let wt = completed_agent_worktree(
4134 "Agent",
4135 &agent_input(),
4136 &kept_worktree_response(
4137 "/repo/.claude/worktrees/agent-a83052083ca02b1ce",
4138 "worktree-agent-a83052083ca02b1ce",
4139 ),
4140 )
4141 .expect("an isolated Agent completion yields its worktree");
4142 assert_eq!(wt.path, Path::new("/repo/.claude/worktrees/agent-a83052083ca02b1ce"));
4143 assert_eq!(wt.repo_root, Path::new("/repo"));
4144 assert_eq!(wt.branch, "worktree-agent-a83052083ca02b1ce");
4145 }
4146
4147 #[test]
4148 fn completed_agent_worktree_derives_the_branch_when_the_response_omits_it() {
4149 let response = serde_json::json!({
4150 "worktreePath": "/repo/.claude/worktrees/agent-abc123",
4151 });
4152 let wt = completed_agent_worktree("Agent", &agent_input(), &response)
4153 .expect("a path-only response still yields the worktree");
4154 assert_eq!(wt.branch, "worktree-agent-abc123", "the CLI's worktree-<slug> convention");
4155 }
4156
4157 #[test]
4159 fn completed_agent_worktree_accepts_the_task_spelling() {
4160 let wt = completed_agent_worktree(
4161 "Task",
4162 &agent_input(),
4163 &kept_worktree_response(
4164 "/repo/.claude/worktrees/agent-abc123",
4165 "worktree-agent-abc123",
4166 ),
4167 );
4168 assert!(wt.is_some(), "Task is the pre-rename Agent tool name");
4169 }
4170
4171 #[test]
4174 fn completed_agent_worktree_ignores_the_path_when_it_only_appears_in_text() {
4175 let response = serde_json::json!({
4176 "content": [{ "type": "text", "text": "worktreePath: /repo/.claude/worktrees/agent-abc123" }],
4177 });
4178 assert!(completed_agent_worktree("Agent", &agent_input(), &response).is_none());
4179 }
4180
4181 #[test]
4182 fn completed_agent_worktree_requires_the_isolation_input() {
4183 let plain = serde_json::json!({ "prompt": "review it" });
4184 assert!(
4185 completed_agent_worktree(
4186 "Agent",
4187 &plain,
4188 &kept_worktree_response(
4189 "/repo/.claude/worktrees/agent-abc123",
4190 "worktree-agent-abc123"
4191 ),
4192 )
4193 .is_none(),
4194 "a non-isolated Agent call never names a managed worktree",
4195 );
4196 }
4197
4198 #[test]
4201 fn completed_agent_worktree_rejects_other_tool_names() {
4202 assert!(
4203 completed_agent_worktree(
4204 "EnterWorktree",
4205 &agent_input(),
4206 &kept_worktree_response(
4207 "/repo/.claude/worktrees/agent-abc123",
4208 "worktree-agent-abc123"
4209 ),
4210 )
4211 .is_none()
4212 );
4213 }
4214
4215 #[test]
4216 fn completed_agent_worktree_rejects_paths_outside_the_managed_layout() {
4217 for path in [
4218 "/repo/other/agent-abc123",
4219 "/repo/worktrees/agent-abc123",
4220 "/repo/.claude/worktrees/worker-abc123",
4221 ".claude/worktrees/agent-abc123",
4222 ] {
4223 assert!(
4224 completed_agent_worktree(
4225 "Agent",
4226 &agent_input(),
4227 &kept_worktree_response(path, "worktree-agent-abc123"),
4228 )
4229 .is_none(),
4230 "{path} is not a managed agent worktree",
4231 );
4232 }
4233 }
4234
4235 #[test]
4239 fn completed_agent_worktree_walks_nested_response_objects() {
4240 let response = serde_json::json!({
4241 "result": { "worktreePath": "/repo/.claude/worktrees/agent-abc123" },
4242 });
4243 let wt = completed_agent_worktree("Agent", &agent_input(), &response)
4244 .expect("a nested worktreePath is still a structured field");
4245 assert_eq!(wt.repo_root, Path::new("/repo"));
4246 }
4247
4248 fn init_repo_with_agent_worktree(hex: &str) -> (tempfile::TempDir, std::path::PathBuf, String) {
4249 let dir = init_repo_with_commit();
4250 let branch = add_worker_worktree(dir.path(), &format!("agent-{hex}"));
4251 let wt = dir.path().join(".claude").join("worktrees").join(format!("agent-{hex}"));
4252 (dir, wt, branch)
4253 }
4254
4255 fn agent_worktree_for(dir: &Path, hex: &str, branch: &str) -> AgentWorktree {
4256 AgentWorktree {
4257 path: dir.join(".claude").join("worktrees").join(format!("agent-{hex}")),
4258 repo_root: dir.to_path_buf(),
4259 branch: branch.to_owned(),
4260 }
4261 }
4262
4263 fn init_repo_with_submodule_agent_worktree(
4269 hex: &str,
4270 ) -> (tempfile::TempDir, std::path::PathBuf, String) {
4271 let (dir, wt, branch) = init_repo_with_agent_worktree(hex);
4272 let source = tempdir().expect("submodule source tempdir");
4273 run_git(source.path(), &["init", "-q"]);
4274 run_git(source.path(), &["config", "commit.gpgsign", "false"]);
4275 fs::write(source.path().join("sub.txt"), "submodule seed").expect("write seed");
4276 run_git(source.path(), &["add", "."]);
4277 run_git(
4278 source.path(),
4279 &[
4280 "-c",
4281 "user.email=t@example.com",
4282 "-c",
4283 "user.name=Test",
4284 "commit",
4285 "-q",
4286 "-m",
4287 "sub",
4288 ],
4289 );
4290 run_git(
4291 dir.path(),
4292 &[
4293 "-c",
4294 "protocol.file.allow=always",
4295 "submodule",
4296 "add",
4297 source.path().to_str().expect("utf8 path"),
4298 "contracts/sub",
4299 ],
4300 );
4301 run_git(dir.path(), &["commit", "-q", "-m", "add submodule"]);
4302 run_git(&wt, &["reset", "--hard", &git_stdout(dir.path(), &["rev-parse", "HEAD"])]);
4305 run_git(&wt, &["-c", "protocol.file.allow=always", "submodule", "update", "--init"]);
4306 (dir, wt, branch)
4307 }
4308
4309 #[test]
4315 fn reaps_a_clean_agent_worktree_holding_initialized_submodules() {
4316 let (dir, wt, branch) = init_repo_with_submodule_agent_worktree("abc123");
4317 assert!(
4322 dir.path().join(".git").join("worktrees").join("agent-abc123").join("modules").is_dir(),
4323 "fixture precondition: the tree's gitdir holds the submodule's modules dir",
4324 );
4325 let refused = std::process::Command::new("git")
4326 .args(["worktree", "remove", wt.to_str().expect("utf8 path")])
4327 .current_dir(wt.parent().expect("wt parent"))
4328 .output()
4329 .expect("spawn git");
4330 assert!(!refused.status.success(), "fixture precondition: git refuses this tree unforced");
4331 assert!(wt.exists(), "and the refusal leaves it on disk");
4332
4333 let outcome = reap_agent_worktree(&agent_worktree_for(dir.path(), "abc123", &branch));
4334
4335 assert!(
4336 matches!(outcome, AgentWorktreeReap::Reaped { .. }),
4337 "a clean tree git refuses by shape is reaped, not leaked: {outcome:?}"
4338 );
4339 assert!(!wt.exists(), "the directory is gone");
4340 assert!(!branch_exists(dir.path(), &branch), "the conventional branch goes with it");
4341 }
4342
4343 #[test]
4344 fn reaps_a_clean_agent_worktree_and_its_conventional_branch() {
4345 let (dir, wt, branch) = init_repo_with_agent_worktree("abc123");
4346 let agent_wt = agent_worktree_for(dir.path(), "abc123", &branch);
4347
4348 let outcome = reap_agent_worktree(&agent_wt);
4349
4350 assert!(
4351 matches!(outcome, AgentWorktreeReap::Reaped { .. }),
4352 "a clean completed agent worktree is reaped, got {outcome:?}"
4353 );
4354 assert!(!wt.exists(), "the directory is gone");
4355 assert!(!branch_exists(dir.path(), &branch), "the conventional branch is gone");
4356 }
4357
4358 #[test]
4362 fn keeps_an_agent_worktree_holding_uncommitted_changes() {
4363 let (dir, wt, branch) = init_repo_with_agent_worktree("abc123");
4364 fs::write(wt.join("notes.md"), "uncommitted").expect("write scratch");
4365 let agent_wt = agent_worktree_for(dir.path(), "abc123", &branch);
4366
4367 let outcome = reap_agent_worktree(&agent_wt);
4368
4369 let AgentWorktreeReap::KeptDirty { reason } = outcome else {
4370 panic!("a dirty agent worktree is kept, got {outcome:?}");
4371 };
4372 assert!(!reason.is_empty());
4373 assert!(wt.exists(), "the directory survives");
4374 }
4375
4376 #[test]
4382 fn removes_a_committed_agent_worktree_in_a_repo_with_remote_refs() {
4383 let (dir, wt, branch) = init_repo_with_agent_worktree("abc123");
4384 run_git(dir.path(), &["update-ref", "refs/remotes/origin/main", "HEAD"]);
4385 fs::write(wt.join("work.txt"), "committed by the subagent").expect("write work");
4386 run_git(&wt, &["add", "."]);
4387 run_git(&wt, &["commit", "-q", "-m", "real work"]);
4388 let agent_wt = agent_worktree_for(dir.path(), "abc123", &branch);
4389
4390 let outcome = reap_agent_worktree(&agent_wt);
4391
4392 let AgentWorktreeReap::Reaped { branch: branch_outcome } = outcome else {
4393 panic!("a committed, unpushed branch does not keep the tree, got {outcome:?}");
4394 };
4395 assert!(!wt.exists(), "the directory is gone");
4396 assert!(
4397 matches!(branch_outcome, BranchReapOutcome::Kept { .. }),
4398 "the branch keeps the commit no ref reaches, got {branch_outcome:?}"
4399 );
4400 }
4401
4402 #[test]
4405 fn removes_a_clean_worktree_but_keeps_a_branch_holding_unique_commits() {
4406 let (dir, wt, branch) = init_repo_with_agent_worktree("abc123");
4407 fs::write(wt.join("work.txt"), "committed by the subagent").expect("write work");
4408 run_git(&wt, &["add", "."]);
4409 run_git(&wt, &["commit", "-q", "-m", "real work"]);
4410 let agent_wt = agent_worktree_for(dir.path(), "abc123", &branch);
4411
4412 let outcome = reap_agent_worktree(&agent_wt);
4413
4414 let AgentWorktreeReap::Reaped { branch: branch_outcome } = outcome else {
4415 panic!("a clean tree is removed, got {outcome:?}");
4416 };
4417 assert!(!wt.exists(), "the directory is gone");
4418 assert!(
4419 matches!(branch_outcome, BranchReapOutcome::Kept { .. }),
4420 "the branch holds a unique commit, got {branch_outcome:?}"
4421 );
4422 assert!(branch_exists(dir.path(), &branch));
4423 }
4424
4425 #[test]
4429 fn never_aims_at_a_branch_the_subagent_checked_out_itself() {
4430 let (dir, wt, branch) = init_repo_with_agent_worktree("abc123");
4431 run_git(&wt, &["checkout", "-q", "-b", "review-971"]);
4432 let agent_wt = agent_worktree_for(dir.path(), "abc123", &branch);
4433
4434 let outcome = reap_agent_worktree(&agent_wt);
4435
4436 assert!(
4437 matches!(outcome, AgentWorktreeReap::Reaped { .. }),
4438 "the tree is clean, got {outcome:?}"
4439 );
4440 assert!(!wt.exists());
4441 assert!(!branch_exists(dir.path(), &branch), "the conventional branch goes");
4442 assert!(
4443 branch_exists(dir.path(), "review-971"),
4444 "the subagent's own branch is not the reap's target",
4445 );
4446 }
4447
4448 #[test]
4454 fn keeps_a_detached_agent_worktree_whose_commits_no_ref_reaches() {
4455 let dir = init_repo_with_commit();
4456 let wt = dir.path().join(".claude").join("worktrees").join("agent-abc123");
4457 std::fs::create_dir_all(wt.parent().expect("parent")).expect("mkdir");
4458 run_git(
4459 dir.path(),
4460 &["worktree", "add", "-q", "--detach", wt.to_str().expect("utf8 path")],
4461 );
4462 fs::write(wt.join("work.txt"), "committed while detached").expect("write work");
4463 run_git(&wt, &["add", "."]);
4464 run_git(&wt, &["commit", "-q", "-m", "detached work"]);
4465 let agent_wt = agent_worktree_for(dir.path(), "abc123", "worktree-agent-abc123");
4466
4467 let outcome = reap_agent_worktree(&agent_wt);
4468
4469 let AgentWorktreeReap::KeptUniqueCommit { tip } = outcome else {
4470 panic!("a detached HEAD holding unique commits keeps the tree, got {outcome:?}");
4471 };
4472 assert_eq!(
4473 tip,
4474 git_stdout(&wt, &["rev-parse", "HEAD"]),
4475 "the warning names the full tip, so the commit stays findable"
4476 );
4477 assert!(wt.exists(), "the tree survives, and the commit with it");
4478 }
4479
4480 #[test]
4483 fn removes_a_detached_head_agent_worktree() {
4484 let dir = init_repo_with_commit();
4485 let wt = dir.path().join(".claude").join("worktrees").join("agent-abc123");
4486 std::fs::create_dir_all(wt.parent().expect("parent")).expect("mkdir");
4487 run_git(
4488 dir.path(),
4489 &["worktree", "add", "-q", "--detach", wt.to_str().expect("utf8 path")],
4490 );
4491 let agent_wt = agent_worktree_for(dir.path(), "abc123", "worktree-agent-abc123");
4492
4493 let outcome = reap_agent_worktree(&agent_wt);
4494
4495 assert!(
4496 matches!(outcome, AgentWorktreeReap::Reaped { .. }),
4497 "a detached clean worktree is reaped, got {outcome:?}"
4498 );
4499 assert!(!wt.exists());
4500 assert!(
4501 !branch_exists(dir.path(), "worktree-agent-abc123"),
4502 "the conventional branch never existed here"
4503 );
4504 }
4505
4506 #[test]
4507 fn reports_absent_when_the_worktree_is_already_gone() {
4508 let dir = init_repo_with_commit();
4509 let agent_wt = agent_worktree_for(dir.path(), "abc123", "worktree-agent-abc123");
4510
4511 assert!(
4512 matches!(reap_agent_worktree(&agent_wt), AgentWorktreeReap::Absent),
4513 "the CLI's own auto-clean may have removed it first"
4514 );
4515 }
4516
4517 #[test]
4518 fn reports_not_a_worktree_for_a_plain_directory() {
4519 let dir = tempdir().expect("tempdir");
4520 let fake = dir.path().join(".claude").join("worktrees").join("agent-abc123");
4521 std::fs::create_dir_all(&fake).expect("mkdir");
4522 let agent_wt = AgentWorktree {
4523 path: fake,
4524 repo_root: dir.path().to_path_buf(),
4525 branch: "worktree-agent-abc123".to_owned(),
4526 };
4527
4528 assert!(
4529 matches!(reap_agent_worktree(&agent_wt), AgentWorktreeReap::NotAWorktree),
4530 "a directory git does not vouch for is never removed",
4531 );
4532 }
4533
4534 #[test]
4535 fn stale_agent_worktrees_lists_a_tree_once_its_age_elapses() {
4536 let (dir, wt, branch) = init_repo_with_agent_worktree("abc123");
4537
4538 let listed =
4539 stale_agent_worktrees(dir.path(), std::time::Duration::ZERO).expect("listing works");
4540 assert_eq!(listed.len(), 1, "a fresh tree is stale at age zero: {listed:?}");
4541 assert_eq!(
4543 fs::canonicalize(&listed[0].path).expect("canonical listed tree"),
4544 fs::canonicalize(&wt).expect("canonical worktree"),
4545 );
4546 assert_eq!(listed[0].branch, branch);
4547 assert_eq!(listed[0].repo_root, dir.path());
4548
4549 assert!(
4550 stale_agent_worktrees(dir.path(), std::time::Duration::from_secs(3600))
4551 .expect("listing works")
4552 .is_empty(),
4553 "a tree younger than the age is not listed"
4554 );
4555 }
4556
4557 #[test]
4561 fn stale_agent_worktrees_reads_the_main_checkout_for_a_linked_project() {
4562 let repo = init_repo_with_commit();
4563 let main = fs::canonicalize(repo.path()).expect("canonical main");
4564 let linked = main.join("linked");
4565 run_git(&main, &["worktree", "add", "-q", "-b", "linked", linked.to_str().expect("utf8")]);
4566 let branch = add_worker_worktree(&main, "agent-abc123");
4567
4568 let listed = stale_agent_worktrees(&linked, std::time::Duration::ZERO).expect("listing");
4569 assert_eq!(
4570 listed.len(),
4571 1,
4572 "the tree under the main checkout is seen from the linked project: {listed:?}",
4573 );
4574 assert_eq!(
4575 fs::canonicalize(&listed[0].path).expect("canonical listed tree"),
4576 main.join(".claude").join("worktrees").join("agent-abc123"),
4577 );
4578 assert_eq!(listed[0].branch, branch);
4579 }
4580
4581 fn dead_child_pid() -> u32 {
4583 let mut child = std::process::Command::new("true").spawn().expect("spawn true");
4584 let pid = child.id();
4585 child.wait().expect("wait true");
4586 pid
4587 }
4588
4589 #[test]
4592 fn stale_agent_worktrees_never_lists_a_locked_tree() {
4593 let (dir, wt, _branch) = init_repo_with_agent_worktree("abc123");
4594 run_git(dir.path(), &["worktree", "lock", wt.to_str().expect("utf8 path")]);
4595
4596 assert!(
4597 stale_agent_worktrees(dir.path(), std::time::Duration::ZERO)
4598 .expect("listing works")
4599 .is_empty(),
4600 "a locked tree stays out of the sweep"
4601 );
4602 }
4603
4604 #[test]
4607 fn stale_agent_worktrees_skips_a_live_pid_lock() {
4608 let (dir, wt, _branch) = init_repo_with_agent_worktree("abc123");
4609 let reason = format!("claude agent agent-abc123 (pid {} start now)", std::process::id());
4610 run_git(
4611 dir.path(),
4612 &["worktree", "lock", "--reason", &reason, wt.to_str().expect("utf8 path")],
4613 );
4614
4615 assert!(
4616 stale_agent_worktrees(dir.path(), std::time::Duration::ZERO)
4617 .expect("listing works")
4618 .is_empty(),
4619 "a live owner keeps its tree out of the sweep"
4620 );
4621 }
4622
4623 #[test]
4627 fn stale_agent_worktrees_lists_a_dead_pid_lock() {
4628 let (dir, wt, _branch) = init_repo_with_agent_worktree("abc123");
4629 let exited = std::process::Command::new("true")
4630 .spawn()
4631 .expect("spawn true")
4632 .wait()
4633 .expect("wait true");
4634 assert!(exited.success());
4635 let dead = dead_child_pid();
4636 let reason = format!("claude agent agent-abc123 (pid {dead} start now)");
4637 run_git(
4638 dir.path(),
4639 &["worktree", "lock", "--reason", &reason, wt.to_str().expect("utf8 path")],
4640 );
4641
4642 let listed =
4643 stale_agent_worktrees(dir.path(), std::time::Duration::ZERO).expect("listing works");
4644 assert_eq!(listed.len(), 1, "a dead owner's leftover is reapable: {listed:?}");
4645 }
4646
4647 #[test]
4650 fn stale_agent_worktrees_skips_a_lock_without_a_parsable_pid() {
4651 let (dir, wt, _branch) = init_repo_with_agent_worktree("abc123");
4652 run_git(
4653 dir.path(),
4654 &["worktree", "lock", "--reason", "held by hand", wt.to_str().expect("utf8 path")],
4655 );
4656
4657 assert!(
4658 stale_agent_worktrees(dir.path(), std::time::Duration::ZERO)
4659 .expect("listing works")
4660 .is_empty()
4661 );
4662 }
4663
4664 #[test]
4667 fn agent_worktree_lock_is_live_distinguishes_the_three_states() {
4668 let (dir, wt_locked, _b) = init_repo_with_agent_worktree("locked1");
4669 let reason = format!("claude agent agent-locked1 (pid {} start now)", std::process::id());
4670 run_git(
4671 dir.path(),
4672 &["worktree", "lock", "--reason", &reason, wt_locked.to_str().expect("utf8 path")],
4673 );
4674 let (_d2, wt_dead, _b2) = {
4675 let (dir2, wt2, b2) = init_repo_with_agent_worktree("dead1");
4676 let dead = dead_child_pid();
4677 let reason = format!("claude agent agent-dead1 (pid {dead} start now)");
4678 run_git(
4679 dir2.path(),
4680 &["worktree", "lock", "--reason", &reason, wt2.to_str().expect("utf8 path")],
4681 );
4682 (dir2, wt2, b2)
4683 };
4684 let (_d3, wt_unlocked, _b3) = init_repo_with_agent_worktree("free1");
4685
4686 assert!(agent_worktree_lock_is_live(dir.path(), &wt_locked), "a live owner's lock is live");
4687 assert!(!agent_worktree_lock_is_live(dir.path(), &wt_dead), "a dead owner's lock is not");
4688 assert!(
4689 !agent_worktree_lock_is_live(dir.path(), &wt_unlocked),
4690 "an unlocked tree has nothing live",
4691 );
4692 }
4693
4694 #[test]
4695 fn stale_agent_worktrees_ignores_non_agent_names_and_plain_files() {
4696 let (dir, _wt, _branch) = init_repo_with_agent_worktree("abc123");
4697 add_worker_worktree(dir.path(), "worker-z");
4698 let scratch = dir.path().join(".claude").join("worktrees").join("agent-plain-file");
4699 fs::write(&scratch, "a file, not a tree").expect("write scratch");
4700
4701 let listed =
4702 stale_agent_worktrees(dir.path(), std::time::Duration::ZERO).expect("listing works");
4703 assert_eq!(listed.len(), 1, "only the agent-prefixed dir is listed: {listed:?}");
4704 assert!(listed[0].path.ends_with("agent-abc123"), "{:?}", listed[0].path);
4705 }
4706
4707 #[cfg(unix)]
4710 #[test]
4711 fn stale_agent_worktrees_propagates_an_unreadable_worktrees_dir() {
4712 use std::os::unix::fs::PermissionsExt;
4713
4714 let dir = init_repo_with_commit();
4715 let worktrees = dir.path().join(".claude").join("worktrees");
4716 std::fs::create_dir_all(&worktrees).expect("mkdir");
4717 std::fs::set_permissions(&worktrees, std::fs::Permissions::from_mode(0o000))
4718 .expect("chmod");
4719
4720 let outcome = stale_agent_worktrees(dir.path(), std::time::Duration::ZERO);
4721 std::fs::set_permissions(&worktrees, std::fs::Permissions::from_mode(0o755))
4722 .expect("restore chmod");
4723 assert!(
4724 outcome.is_err(),
4725 "an unreadable dir is an error, never an empty sweep: {outcome:?}"
4726 );
4727 }
4728
4729 #[test]
4730 fn stale_agent_worktrees_is_empty_outside_a_repo() {
4731 let dir = tempdir().expect("tempdir");
4732 assert!(
4733 stale_agent_worktrees(dir.path(), std::time::Duration::ZERO)
4734 .expect("listing works")
4735 .is_empty()
4736 );
4737 }
4738}