use std::path::{Path, PathBuf};
use color_print::cformat;
use crate::styling::eprintln;
use super::Repository;
pub fn current_or_recover() -> anyhow::Result<(Repository, bool)> {
match Repository::current() {
Ok(repo) => Ok((repo, false)),
Err(err) => match recover_from_deleted_cwd() {
Some(repo) => {
eprintln!(
"{}",
crate::styling::info_message("Current worktree was removed, recovering...")
);
Ok((repo, true))
}
None => Err(err),
},
}
}
pub fn cwd_removed_hint() -> Option<String> {
let repo = Repository::current()
.ok()
.or_else(recover_from_deleted_cwd)?;
Some(hint_for_repo(&repo))
}
fn hint_for_repo(repo: &Repository) -> String {
if let Some(branch) = repo.default_branch()
&& repo
.worktree_for_branch(&branch)
.ok()
.flatten()
.is_some_and(|p| p.exists())
{
return cformat!("Current directory was removed. Try: <underline>wt switch ^</>");
}
cformat!("Current directory was removed. Run <underline>wt list</> to see worktrees")
}
fn recover_from_deleted_cwd() -> Option<Repository> {
match std::env::current_dir() {
Ok(p) if p.exists() => return None,
_ => {}
}
let pwd = std::env::var_os("PWD")?;
let deleted_path = PathBuf::from(pwd);
recover_from_path(&deleted_path)
}
fn recover_from_path(deleted_path: &Path) -> Option<Repository> {
let mut candidate = deleted_path.parent()?;
loop {
if candidate.is_dir() {
log::debug!(
"Deleted CWD recovery: path={}, checking ancestor={}",
deleted_path.display(),
candidate.display()
);
if let Some(repo) = find_validated_repo_near(candidate, deleted_path) {
return Some(repo);
}
}
candidate = candidate.parent()?;
}
}
fn find_validated_repo_near(dir: &Path, deleted_path: &Path) -> Option<Repository> {
if let Some(repo) = try_repo_at(dir)
&& was_worktree_of(&repo, deleted_path)
{
return Some(repo);
}
let entries = std::fs::read_dir(dir).ok()?;
for entry in entries.flatten() {
if entry.file_type().ok().is_some_and(|ft| ft.is_dir())
&& let Some(repo) = try_repo_at(&entry.path())
&& was_worktree_of(&repo, deleted_path)
{
return Some(repo);
}
}
None
}
fn try_repo_at(dir: &Path) -> Option<Repository> {
let git_path = dir.join(".git");
if git_path.is_dir() {
Repository::at(dir).ok()
} else {
None
}
}
fn was_worktree_of(repo: &Repository, deleted_path: &Path) -> bool {
repo.list_worktrees().is_ok_and(|worktrees| {
worktrees.iter().any(|wt| {
deleted_path.starts_with(&wt.path)
|| (wt.is_prunable() && paths_match(&wt.path, deleted_path))
})
})
}
fn paths_match(worktree_path: &Path, deleted_path: &Path) -> bool {
if deleted_path.starts_with(worktree_path) {
return true;
}
let wt_name = worktree_path.file_name();
let del_name = deleted_path.file_name();
if wt_name != del_name {
return false;
}
let wt_parent = worktree_path
.parent()
.and_then(|p| dunce::canonicalize(p).ok());
let del_parent = deleted_path
.parent()
.and_then(|p| dunce::canonicalize(p).ok());
matches!((wt_parent, del_parent), (Some(a), Some(b)) if a == b)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::shell_exec::Cmd;
use crate::testing::{TestRepo, set_test_identity};
use ansi_str::AnsiStr;
fn git_init(path: &Path) {
Cmd::new("git")
.args(["init", "--quiet", "-b", "main"])
.current_dir(path)
.run()
.unwrap();
}
#[test]
fn test_try_repo_at_rejects_git_file() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join(".git"), "gitdir: /some/path").unwrap();
assert!(try_repo_at(tmp.path()).is_none());
}
#[test]
fn test_try_repo_at_accepts_git_dir() {
let tmp = tempfile::tempdir().unwrap();
git_init(tmp.path());
assert!(try_repo_at(tmp.path()).is_some());
}
#[test]
fn test_recover_returns_none_when_cwd_exists() {
assert!(recover_from_deleted_cwd().is_none());
}
#[test]
fn test_paths_match_identical_paths() {
let p = PathBuf::from("/some/path/feature");
assert!(paths_match(&p, &p));
}
#[test]
fn test_paths_match_different_names() {
let a = PathBuf::from("/repos/feature-a");
let b = PathBuf::from("/repos/feature-b");
assert!(!paths_match(&a, &b));
}
#[test]
fn test_paths_match_same_name_same_parent() {
let tmp = tempfile::tempdir().unwrap();
let a = tmp.path().join("feature");
let b = tmp.path().join("feature");
assert!(paths_match(&a, &b));
}
#[test]
fn test_paths_match_different_parent() {
let tmp = tempfile::tempdir().unwrap();
let dir_a = tmp.path().join("a");
let dir_b = tmp.path().join("b");
std::fs::create_dir(&dir_a).unwrap();
std::fs::create_dir(&dir_b).unwrap();
let a = dir_a.join("feature");
let b = dir_b.join("feature");
assert!(!paths_match(&a, &b));
}
#[test]
fn test_was_worktree_of_finds_existing_worktree() {
let tmp = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(tmp.path()).unwrap();
let repo_dir = base.join("repo");
std::fs::create_dir(&repo_dir).unwrap();
git_init(&repo_dir);
let repo = Repository::at(&repo_dir).unwrap();
set_test_identity(&repo);
repo.run_command(&["commit", "--allow-empty", "-m", "init"])
.unwrap();
let wt_path = base.join("feature-wt");
let wt_str = wt_path.to_string_lossy();
repo.run_command(&["worktree", "add", &wt_str, "-b", "feature"])
.unwrap();
assert!(was_worktree_of(&repo, &wt_path));
}
#[test]
fn test_was_worktree_of_rejects_unknown_path() {
let test = TestRepo::with_initial_commit();
let unknown = PathBuf::from("/nonexistent/unknown");
assert!(!was_worktree_of(&test.repo, &unknown));
}
#[test]
fn test_current_or_recover_returns_repo_when_cwd_exists() {
let (repo, recovered) = current_or_recover().unwrap();
assert!(!recovered);
assert!(repo.repo_path().unwrap().exists());
}
#[test]
fn test_recover_from_path_finds_deleted_worktree() {
let tmp = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(tmp.path()).unwrap();
let repo_dir = base.join("repo");
std::fs::create_dir(&repo_dir).unwrap();
git_init(&repo_dir);
let repo = Repository::at(&repo_dir).unwrap();
set_test_identity(&repo);
repo.run_command(&["commit", "--allow-empty", "-m", "init"])
.unwrap();
let wt_path = base.join("feature-wt");
let wt_str = wt_path.to_string_lossy();
repo.run_command(&["worktree", "add", &wt_str, "-b", "feature"])
.unwrap();
std::fs::remove_dir_all(&wt_path).unwrap();
assert!(recover_from_path(&wt_path).is_some());
}
#[test]
fn test_recover_from_path_returns_none_for_unrelated_path() {
let tmp = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(tmp.path()).unwrap();
let repo_dir = base.join("repo");
std::fs::create_dir(&repo_dir).unwrap();
git_init(&repo_dir);
let repo = Repository::at(&repo_dir).unwrap();
set_test_identity(&repo);
repo.run_command(&["commit", "--allow-empty", "-m", "init"])
.unwrap();
let unrelated = base.join("not-a-worktree");
assert!(recover_from_path(&unrelated).is_none());
}
#[test]
fn test_recover_from_path_multi_repo_siblings() {
let tmp = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(tmp.path()).unwrap();
let repo_a = base.join("alpha");
let repo_b = base.join("beta");
std::fs::create_dir(&repo_a).unwrap();
std::fs::create_dir(&repo_b).unwrap();
git_init(&repo_a);
git_init(&repo_b);
let repo_a_handle = Repository::at(&repo_a).unwrap();
let repo_b_handle = Repository::at(&repo_b).unwrap();
for repo in [&repo_a_handle, &repo_b_handle] {
set_test_identity(repo);
repo.run_command(&["commit", "--allow-empty", "-m", "init"])
.unwrap();
}
let wt_a = base.join("alpha.feature");
let wt_b = base.join("beta.feature");
let wt_a_str = wt_a.to_string_lossy();
let wt_b_str = wt_b.to_string_lossy();
repo_a_handle
.run_command(&["worktree", "add", &wt_a_str, "-b", "feature"])
.unwrap();
repo_b_handle
.run_command(&["worktree", "add", &wt_b_str, "-b", "feature"])
.unwrap();
std::fs::remove_dir_all(&wt_b).unwrap();
let recovered = recover_from_path(&wt_b).unwrap();
assert_eq!(
dunce::canonicalize(recovered.repo_path().unwrap()).unwrap(),
repo_b
);
}
#[test]
fn test_recover_from_path_nested_worktree() {
let tmp = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(tmp.path()).unwrap();
let repo_dir = base.join("myrepo");
std::fs::create_dir(&repo_dir).unwrap();
git_init(&repo_dir);
let repo = Repository::at(&repo_dir).unwrap();
set_test_identity(&repo);
repo.run_command(&["commit", "--allow-empty", "-m", "init"])
.unwrap();
let wt_path = repo_dir.join(".worktrees").join("feature");
std::fs::create_dir_all(wt_path.parent().unwrap()).unwrap();
let wt_str = wt_path.to_string_lossy();
repo.run_command(&["worktree", "add", &wt_str, "-b", "feature"])
.unwrap();
std::fs::remove_dir_all(&wt_path).unwrap();
assert!(recover_from_path(&wt_path).is_some());
}
#[test]
fn test_recover_from_path_deep_pwd() {
let tmp = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(tmp.path()).unwrap();
let repo_dir = base.join("repo");
std::fs::create_dir(&repo_dir).unwrap();
git_init(&repo_dir);
let repo = Repository::at(&repo_dir).unwrap();
set_test_identity(&repo);
repo.run_command(&["commit", "--allow-empty", "-m", "init"])
.unwrap();
let wt_path = base.join("feature-wt");
let wt_str = wt_path.to_string_lossy();
repo.run_command(&["worktree", "add", &wt_str, "-b", "feature"])
.unwrap();
std::fs::remove_dir_all(&wt_path).unwrap();
let deep_path = wt_path.join("src").join("lib.rs");
assert!(recover_from_path(&deep_path).is_some());
}
#[test]
fn test_hint_for_repo_suggests_switch() {
let test = TestRepo::with_initial_commit();
let hint = hint_for_repo(&test.repo);
insta::assert_snapshot!(hint.ansi_strip(), @"Current directory was removed. Try: wt switch ^");
}
#[test]
fn test_hint_for_repo_fallback_to_list() {
let tmp = tempfile::tempdir().unwrap();
Cmd::new("git")
.args(["init", "--bare", "--quiet"])
.current_dir(tmp.path())
.run()
.unwrap();
let repo = Repository::at(tmp.path()).unwrap();
let hint = hint_for_repo(&repo);
insta::assert_snapshot!(hint.ansi_strip(), @"Current directory was removed. Run wt list to see worktrees");
}
}