use super::basic::run_git;
use crate::vcs::{VcsError, VcsResult};
use std::path::{Path, PathBuf};
use std::process::Stdio;
use tokio::process::Command;
use tracing::{debug, info, warn};
#[derive(Debug, Clone, Copy, Default)]
pub struct WorktreeRemoveOptions {
pub skip_teardown: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum WorktreeAddFailure {
PathExists,
BranchDuplicate,
BranchExists,
InvalidReference,
PermissionDenied,
Unknown,
}
impl WorktreeAddFailure {
pub fn classify(stderr: &str) -> Self {
let stderr_lower = stderr.to_lowercase();
if stderr_lower.contains("is already checked out")
|| (stderr_lower.contains("already checked out") && stderr_lower.contains("at"))
{
Self::BranchDuplicate
} else if stderr_lower.contains("a branch named") && stderr_lower.contains("already exists")
{
Self::BranchExists
} else if stderr_lower.contains("already exists")
|| stderr_lower.contains("path already exists")
{
Self::PathExists
} else if stderr_lower.contains("invalid reference")
|| stderr_lower.contains("not a valid")
|| stderr_lower.contains("unknown revision")
|| stderr_lower.contains("bad revision")
{
Self::InvalidReference
} else if stderr_lower.contains("permission denied")
|| stderr_lower.contains("operation not permitted")
{
Self::PermissionDenied
} else {
Self::Unknown
}
}
pub fn description(&self) -> &'static str {
match self {
Self::PathExists => "path already exists (possibly stale worktree)",
Self::BranchDuplicate => "branch is already checked out in another worktree",
Self::BranchExists => "branch already exists (not checked out in any worktree)",
Self::InvalidReference => "invalid reference (base commit/branch not found)",
Self::PermissionDenied => "permission denied",
Self::Unknown => "unknown error",
}
}
}
pub async fn worktree_add<P: AsRef<Path>>(
cwd: P,
worktree_path: &str,
branch_name: &str,
base_commit: &str,
) -> VcsResult<()> {
let cwd_ref = cwd.as_ref();
debug!(
"Creating worktree at {} with branch {} from {}",
worktree_path, branch_name, base_commit
);
let result = run_git(
&[
"worktree",
"add",
worktree_path,
"-b",
branch_name,
base_commit,
],
cwd_ref,
)
.await;
if result.is_ok() {
return Ok(());
}
let err = result.unwrap_err();
let (classification, should_retry) = match &err {
VcsError::Command { stderr, .. } => {
let stderr_str = stderr.as_deref().unwrap_or("");
let classification = WorktreeAddFailure::classify(stderr_str);
let should_retry = classification == WorktreeAddFailure::PathExists
|| classification == WorktreeAddFailure::BranchExists;
debug!(
"Worktree add failed with classification: {:?} ({})",
classification,
classification.description()
);
(classification, should_retry)
}
_ => (WorktreeAddFailure::Unknown, false),
};
if !should_retry {
return Err(enhance_error_with_classification(err, classification));
}
if classification == WorktreeAddFailure::BranchExists {
let is_checked_out = match is_branch_checked_out(cwd_ref, branch_name).await {
Ok(checked_out) => checked_out,
Err(e) => {
warn!("Failed to check if branch is checked out: {}", e);
return Err(enhance_error_with_classification(err, classification));
}
};
if is_checked_out {
debug!(
"Branch {} is checked out in another worktree, cannot attach",
branch_name
);
return Err(enhance_error_with_classification(err, classification));
}
info!(
"Branch {} exists but not checked out, attempting to attach existing branch",
branch_name
);
let retry_result = run_git(&["worktree", "add", worktree_path, branch_name], cwd_ref).await;
match retry_result {
Ok(_) => {
info!("Worktree add succeeded by attaching existing branch");
return Ok(());
}
Err(retry_err) => {
warn!("Failed to attach existing branch");
return Err(enhance_error_with_retry_info(
err,
retry_err,
classification,
));
}
}
}
let is_stale = match check_stale_worktree_path(cwd_ref, worktree_path).await {
Ok(stale) => stale,
Err(e) => {
warn!("Failed to check if worktree path is stale: {}", e);
false
}
};
if !is_stale {
return Err(enhance_error_with_classification(err, classification));
}
info!(
"Detected stale worktree path at {}, attempting prune and retry",
worktree_path
);
if let Err(prune_err) = run_git(&["worktree", "prune"], cwd_ref).await {
warn!("git worktree prune failed: {}", prune_err);
return Err(enhance_error_with_classification(err, classification));
}
let path_buf = PathBuf::from(worktree_path);
if path_buf.exists() {
if let Err(remove_err) = std::fs::remove_dir_all(&path_buf) {
warn!(
"Failed to remove stale directory {}: {}",
worktree_path, remove_err
);
return Err(enhance_error_with_classification(err, classification));
}
}
let retry_result = run_git(
&[
"worktree",
"add",
worktree_path,
"-b",
branch_name,
base_commit,
],
cwd_ref,
)
.await;
match retry_result {
Ok(_) => {
info!("Worktree add succeeded after prune");
Ok(())
}
Err(retry_err) => {
let retry_classification = match &retry_err {
VcsError::Command { stderr, .. } => {
let stderr_str = stderr.as_deref().unwrap_or("");
WorktreeAddFailure::classify(stderr_str)
}
_ => WorktreeAddFailure::Unknown,
};
if retry_classification == WorktreeAddFailure::BranchExists {
info!(
"Stale-path retry failed with BranchExists, attempting safe existing-branch attach for '{}'",
branch_name
);
let is_checked_out = match is_branch_checked_out(cwd_ref, branch_name).await {
Ok(checked_out) => checked_out,
Err(e) => {
warn!("Failed to check if branch is checked out: {}", e);
return Err(enhance_error_with_retry_info(
err,
retry_err,
classification,
));
}
};
if is_checked_out {
debug!(
"Branch {} is checked out in another worktree, cannot attach after stale-path retry",
branch_name
);
return Err(enhance_error_with_retry_info(
err,
retry_err,
classification,
));
}
info!(
"Branch {} exists but not checked out after stale-path prune, attempting attach",
branch_name
);
let attach_result =
run_git(&["worktree", "add", worktree_path, branch_name], cwd_ref).await;
match attach_result {
Ok(_) => {
info!("Worktree add succeeded by attaching existing branch after stale-path prune");
return Ok(());
}
Err(attach_err) => {
warn!("Failed to attach existing branch after stale-path prune");
return Err(enhance_error_with_retry_info(
err,
attach_err,
classification,
));
}
}
}
warn!("Worktree add retry failed after prune");
Err(enhance_error_with_retry_info(
err,
retry_err,
classification,
))
}
}
}
async fn check_stale_worktree_path<P: AsRef<Path>>(cwd: P, worktree_path: &str) -> VcsResult<bool> {
let path_buf = PathBuf::from(worktree_path);
if !path_buf.exists() {
return Ok(false);
}
let worktrees = list_worktrees(cwd).await?;
let normalized_path = path_buf.canonicalize().unwrap_or_else(|_| path_buf.clone());
for (wt_path, _, _, _, _) in worktrees {
let wt_path_buf = PathBuf::from(&wt_path);
let normalized_wt = wt_path_buf.canonicalize().unwrap_or(wt_path_buf);
if normalized_path == normalized_wt {
return Ok(false);
}
}
Ok(true)
}
async fn is_branch_checked_out<P: AsRef<Path>>(cwd: P, branch_name: &str) -> VcsResult<bool> {
let worktrees = list_worktrees(cwd).await?;
for (_, _, branch, is_detached, _) in worktrees {
if !is_detached && branch == branch_name {
return Ok(true);
}
}
Ok(false)
}
fn enhance_error_with_classification(
err: VcsError,
classification: WorktreeAddFailure,
) -> VcsError {
match err {
VcsError::Command {
backend,
message,
command,
working_dir,
stderr,
stdout,
} => VcsError::Command {
backend,
message: format!(
"{} (classified as: {})",
message,
classification.description()
),
command,
working_dir,
stderr,
stdout,
},
other => other,
}
}
fn enhance_error_with_retry_info(
original_err: VcsError,
retry_err: VcsError,
classification: WorktreeAddFailure,
) -> VcsError {
match (original_err, retry_err) {
(
VcsError::Command {
backend,
message: orig_msg,
command,
working_dir,
stderr: orig_stderr,
stdout: orig_stdout,
},
VcsError::Command {
message: retry_msg,
stderr: retry_stderr,
..
},
) => VcsError::Command {
backend,
message: format!(
"{} (classified as: {}). Retry after prune also failed: {}",
orig_msg,
classification.description(),
retry_msg
),
command,
working_dir,
stderr: Some(format!(
"Original: {}\nRetry: {}",
orig_stderr.unwrap_or_default(),
retry_stderr.unwrap_or_default()
)),
stdout: orig_stdout,
},
(orig, _) => orig,
}
}
#[allow(dead_code)]
pub async fn worktree_add_detached<P: AsRef<Path>>(
cwd: P,
worktree_path: &str,
base_commit: &str,
) -> VcsResult<()> {
debug!(
"Creating detached worktree at {} from {}",
worktree_path, base_commit
);
run_git(
&["worktree", "add", "--detach", worktree_path, base_commit],
cwd,
)
.await?;
Ok(())
}
#[allow(dead_code)]
pub async fn worktree_remove<P: AsRef<Path>>(cwd: P, worktree_path: &str) -> VcsResult<()> {
worktree_remove_with_options(cwd, worktree_path, WorktreeRemoveOptions::default()).await
}
pub async fn worktree_remove_with_options<P: AsRef<Path>>(
cwd: P,
worktree_path: &str,
options: WorktreeRemoveOptions,
) -> VcsResult<()> {
let cwd_ref = cwd.as_ref();
if options.skip_teardown {
info!(
worktree_path = %worktree_path,
"Skipping .wt/teardown due to explicit skip_teardown option"
);
} else {
run_worktree_teardown(cwd_ref, worktree_path).await?;
}
debug!("Removing worktree at {}", worktree_path);
run_git(&["worktree", "remove", worktree_path, "--force"], cwd_ref).await?;
Ok(())
}
pub async fn list_worktrees<P: AsRef<Path>>(
cwd: P,
) -> VcsResult<Vec<(String, String, String, bool, bool)>> {
let output = run_git(&["worktree", "list", "--porcelain"], &cwd).await?;
let mut worktrees = Vec::new();
let mut current_path: Option<String> = None;
let mut current_head: Option<String> = None;
let mut current_branch: Option<String> = None;
let mut is_detached = false;
let mut is_first = true;
for line in output.lines() {
let line = line.trim();
if line.is_empty() {
if let (Some(path), Some(head)) = (current_path.take(), current_head.take()) {
let branch = current_branch.take().unwrap_or_default();
worktrees.push((path, head, branch, is_detached, is_first));
is_first = false;
is_detached = false;
}
} else if let Some(stripped) = line.strip_prefix("worktree ") {
current_path = Some(stripped.to_string());
} else if let Some(stripped) = line.strip_prefix("HEAD ") {
current_head = Some(stripped.to_string());
} else if let Some(stripped) = line.strip_prefix("branch ") {
current_branch = Some(stripped.trim_start_matches("refs/heads/").to_string());
} else if line == "detached" {
is_detached = true;
}
}
if let (Some(path), Some(head)) = (current_path, current_head) {
let branch = current_branch.unwrap_or_default();
worktrees.push((path, head, branch, is_detached, is_first));
}
Ok(worktrees)
}
pub async fn is_worktree<P1: AsRef<Path>, P2: AsRef<Path>>(
repo_root: P1,
path: P2,
) -> VcsResult<bool> {
let path = path.as_ref();
let worktrees = list_worktrees(repo_root).await?;
let normalized_path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
for (worktree_path, _head, _branch, _is_detached, is_main) in worktrees {
let worktree_path_buf = PathBuf::from(&worktree_path);
let normalized_worktree = worktree_path_buf
.canonicalize()
.unwrap_or(worktree_path_buf);
if normalized_path == normalized_worktree {
return Ok(!is_main);
}
}
Ok(false)
}
pub async fn count_commits_ahead<P: AsRef<Path>>(
cwd: P,
base_branch: &str,
worktree_branch: &str,
) -> VcsResult<usize> {
let range = format!("{}..{}", base_branch, worktree_branch);
let output = run_git(&["rev-list", "--count", &range], cwd).await?;
let count = output
.trim()
.parse::<usize>()
.map_err(|e| VcsError::git_command(format!("Invalid count: {}", e)))?;
Ok(count)
}
pub async fn run_worktree_teardown<P1: AsRef<Path>, P2: AsRef<Path>>(
repo_root: P1,
worktree_path: P2,
) -> VcsResult<()> {
let repo_root = repo_root.as_ref();
let worktree_path = worktree_path.as_ref();
let teardown_script = worktree_path.join(".wt").join("teardown");
if !teardown_script.exists() {
debug!(
worktree = %worktree_path.display(),
teardown_script = %teardown_script.display(),
"Teardown script not found, skipping"
);
return Ok(());
}
if !teardown_script.is_file() {
debug!(
worktree = %worktree_path.display(),
teardown_script = %teardown_script.display(),
"Teardown path exists but is not a regular file, skipping"
);
return Ok(());
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let metadata = std::fs::metadata(&teardown_script).map_err(|e| {
VcsError::git_command(format!(
"Failed to read teardown script metadata at {}: {}",
teardown_script.display(),
e
))
})?;
let mode = metadata.permissions().mode();
if mode & 0o111 == 0 {
debug!(
worktree = %worktree_path.display(),
teardown_script = %teardown_script.display(),
mode = format_args!("{:o}", mode),
"Teardown script is not executable, skipping"
);
return Ok(());
}
}
info!(
worktree = %worktree_path.display(),
teardown_script = %teardown_script.display(),
"Executing worktree teardown script"
);
let output = Command::new(&teardown_script)
.current_dir(worktree_path)
.env("ROOT_WORKTREE_PATH", repo_root)
.stdin(Stdio::null())
.output()
.await
.map_err(|e| {
VcsError::git_command(format!(
"Failed to execute teardown script {}: {}",
teardown_script.display(),
e
))
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
let exit_code = output.status.code().unwrap_or(-1);
return Err(VcsError::git_command(format!(
"Teardown script failed with exit code {} at {}\nstdout: {}\nstderr: {}",
exit_code,
teardown_script.display(),
stdout,
stderr
)));
}
info!(
worktree = %worktree_path.display(),
teardown_script = %teardown_script.display(),
"Worktree teardown script completed successfully"
);
Ok(())
}
pub async fn run_worktree_setup<P1: AsRef<Path>, P2: AsRef<Path>>(
repo_root: P1,
worktree_path: P2,
) -> VcsResult<()> {
let repo_root = repo_root.as_ref();
let worktree_path = worktree_path.as_ref();
let setup_script = repo_root.join(".wt").join("setup");
if !setup_script.exists() {
debug!(
"Setup script not found at {:?}, skipping setup",
setup_script
);
return Ok(());
}
info!(
"Found setup script at {:?}, executing in worktree {:?}",
setup_script, worktree_path
);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let metadata = std::fs::metadata(&setup_script).map_err(|e| {
VcsError::git_command(format!("Failed to read setup script metadata: {}", e))
})?;
let mut permissions = metadata.permissions();
permissions.set_mode(permissions.mode() | 0o111);
std::fs::set_permissions(&setup_script, permissions).map_err(|e| {
VcsError::git_command(format!("Failed to set setup script permissions: {}", e))
})?;
}
debug!(
module = module_path!(),
"Executing setup script: {:?} (cwd: {:?}, env: ROOT_WORKTREE_PATH={:?})",
setup_script,
worktree_path,
repo_root
);
let output = Command::new(&setup_script)
.current_dir(worktree_path)
.env("ROOT_WORKTREE_PATH", repo_root)
.stdin(Stdio::null())
.output()
.await
.map_err(|e| VcsError::git_command(format!("Failed to execute setup script: {}", e)))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
let exit_code = output.status.code().unwrap_or(-1);
return Err(VcsError::git_command(format!(
"Setup script failed with exit code {}\nstdout: {}\nstderr: {}",
exit_code, stdout, stderr
)));
}
info!("Setup script completed successfully");
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_worktree_add_error_classification() {
let stderr = "fatal: '/path/to/worktree' already exists";
assert_eq!(
WorktreeAddFailure::classify(stderr),
WorktreeAddFailure::PathExists
);
let stderr = "fatal: 'my-branch' is already checked out at '/other/path'";
assert_eq!(
WorktreeAddFailure::classify(stderr),
WorktreeAddFailure::BranchDuplicate
);
let stderr = "fatal: a branch named 'my-branch' already exists";
assert_eq!(
WorktreeAddFailure::classify(stderr),
WorktreeAddFailure::BranchExists
);
let stderr = "fatal: invalid reference: nonexistent-branch";
assert_eq!(
WorktreeAddFailure::classify(stderr),
WorktreeAddFailure::InvalidReference
);
let stderr = "fatal: could not create worktree: Permission denied";
assert_eq!(
WorktreeAddFailure::classify(stderr),
WorktreeAddFailure::PermissionDenied
);
let stderr = "fatal: some other error";
assert_eq!(
WorktreeAddFailure::classify(stderr),
WorktreeAddFailure::Unknown
);
}
#[tokio::test]
async fn test_worktree_add_retry_on_stale_path() {
let temp_dir = TempDir::new().unwrap();
let init = Command::new("git")
.args(["init", "-b", "main"])
.current_dir(temp_dir.path())
.output()
.await;
if init.is_err() {
return; }
let _ = Command::new("git")
.args(["config", "user.email", "test@example.com"])
.current_dir(temp_dir.path())
.output()
.await;
let _ = Command::new("git")
.args(["config", "user.name", "Test User"])
.current_dir(temp_dir.path())
.output()
.await;
std::fs::write(temp_dir.path().join("README.md"), "test").unwrap();
let _ = Command::new("git")
.args(["add", "."])
.current_dir(temp_dir.path())
.output()
.await;
let _ = Command::new("git")
.args(["commit", "-m", "Initial commit"])
.current_dir(temp_dir.path())
.output()
.await;
let worktree_path = temp_dir.path().join("worktrees").join("test-wt");
std::fs::create_dir_all(worktree_path.parent().unwrap()).unwrap();
let _ = worktree_add(
temp_dir.path(),
worktree_path.to_str().unwrap(),
"test-branch",
"HEAD",
)
.await;
let _ = Command::new("git")
.args([
"worktree",
"remove",
"--force",
worktree_path.to_str().unwrap(),
])
.current_dir(temp_dir.path())
.output()
.await;
std::fs::create_dir_all(&worktree_path).unwrap();
assert!(worktree_path.exists());
let worktrees = list_worktrees(temp_dir.path()).await.unwrap();
let is_registered = worktrees
.iter()
.any(|(path, _, _, _, _)| *path == worktree_path.to_str().unwrap());
assert!(!is_registered, "Worktree should not be registered");
let result = worktree_add(
temp_dir.path(),
worktree_path.to_str().unwrap(),
"test-branch-2",
"HEAD",
)
.await;
if let Err(e) = &result {
eprintln!("Retry failed with error: {:?}", e);
}
assert!(
result.is_ok(),
"Expected retry to succeed after prune and cleanup"
);
let _ = worktree_remove(temp_dir.path(), worktree_path.to_str().unwrap()).await;
}
#[tokio::test]
async fn test_worktree_add_retry_preserves_error() {
let temp_dir = TempDir::new().unwrap();
let init = Command::new("git")
.args(["init", "-b", "main"])
.current_dir(temp_dir.path())
.output()
.await;
if init.is_err() {
return; }
let _ = Command::new("git")
.args(["config", "user.email", "test@example.com"])
.current_dir(temp_dir.path())
.output()
.await;
let _ = Command::new("git")
.args(["config", "user.name", "Test User"])
.current_dir(temp_dir.path())
.output()
.await;
std::fs::write(temp_dir.path().join("README.md"), "test").unwrap();
let _ = Command::new("git")
.args(["add", "."])
.current_dir(temp_dir.path())
.output()
.await;
let _ = Command::new("git")
.args(["commit", "-m", "Initial commit"])
.current_dir(temp_dir.path())
.output()
.await;
let worktree_path = temp_dir.path().join("worktrees").join("test-wt");
std::fs::create_dir_all(worktree_path.parent().unwrap()).unwrap();
let result = worktree_add(
temp_dir.path(),
worktree_path.to_str().unwrap(),
"test-branch",
"nonexistent-commit",
)
.await;
assert!(result.is_err());
let err = result.unwrap_err();
match err {
VcsError::Command { message, .. } => {
assert!(
message.contains("invalid reference") || message.contains("classified as"),
"Expected error message to contain classification info, got: {}",
message
);
}
_ => panic!("Expected VcsError::Command"),
}
}
#[tokio::test]
async fn test_worktree_add_with_oso_session_branch() {
let temp_dir = TempDir::new().unwrap();
let init = Command::new("git")
.args(["init", "-b", "main"])
.current_dir(temp_dir.path())
.output()
.await;
if init.is_err() {
return; }
let _ = Command::new("git")
.args(["config", "user.email", "test@example.com"])
.current_dir(temp_dir.path())
.output()
.await;
let _ = Command::new("git")
.args(["config", "user.name", "Test User"])
.current_dir(temp_dir.path())
.output()
.await;
std::fs::write(temp_dir.path().join("README.md"), "test").unwrap();
let _ = Command::new("git")
.args(["add", "."])
.current_dir(temp_dir.path())
.output()
.await;
let _ = Command::new("git")
.args(["commit", "-m", "Initial commit"])
.current_dir(temp_dir.path())
.output()
.await;
use crate::vcs::git::commands::basic::generate_unique_branch_name;
let branch_name = generate_unique_branch_name(temp_dir.path(), "oso-session", 10)
.await
.unwrap();
let worktree_path = temp_dir.path().join("worktrees").join(&branch_name);
std::fs::create_dir_all(worktree_path.parent().unwrap()).unwrap();
let result = worktree_add(
temp_dir.path(),
worktree_path.to_str().unwrap(),
&branch_name,
"HEAD",
)
.await;
assert!(result.is_ok());
assert!(worktree_path.exists());
let branch_check = Command::new("git")
.args([
"show-ref",
"--verify",
&format!("refs/heads/{}", branch_name),
])
.current_dir(temp_dir.path())
.output()
.await
.unwrap();
assert!(branch_check.status.success());
let branch_output = Command::new("git")
.args(["rev-parse", "--abbrev-ref", "HEAD"])
.current_dir(&worktree_path)
.output()
.await
.unwrap();
let current_branch = String::from_utf8_lossy(&branch_output.stdout);
assert_eq!(current_branch.trim(), branch_name);
assert_ne!(current_branch.trim(), "HEAD");
let _ = worktree_remove(temp_dir.path(), worktree_path.to_str().unwrap()).await;
}
#[tokio::test]
async fn test_worktree_add_existing_branch_attach_success() {
let temp_dir = TempDir::new().unwrap();
let init = Command::new("git")
.args(["init", "-b", "main"])
.current_dir(temp_dir.path())
.output()
.await;
if init.is_err() {
return; }
let _ = Command::new("git")
.args(["config", "user.email", "test@example.com"])
.current_dir(temp_dir.path())
.output()
.await;
let _ = Command::new("git")
.args(["config", "user.name", "Test User"])
.current_dir(temp_dir.path())
.output()
.await;
std::fs::write(temp_dir.path().join("README.md"), "test").unwrap();
let _ = Command::new("git")
.args(["add", "."])
.current_dir(temp_dir.path())
.output()
.await;
let _ = Command::new("git")
.args(["commit", "-m", "Initial commit"])
.current_dir(temp_dir.path())
.output()
.await;
let _ = Command::new("git")
.args(["branch", "existing-branch"])
.current_dir(temp_dir.path())
.output()
.await;
let worktree_path = temp_dir.path().join("worktrees").join("test-wt");
std::fs::create_dir_all(worktree_path.parent().unwrap()).unwrap();
let result = worktree_add(
temp_dir.path(),
worktree_path.to_str().unwrap(),
"existing-branch",
"HEAD",
)
.await;
if let Err(e) = &result {
eprintln!("Attach existing branch failed: {:?}", e);
}
assert!(
result.is_ok(),
"Expected to succeed by attaching existing branch"
);
assert!(worktree_path.exists());
let branch_output = Command::new("git")
.args(["rev-parse", "--abbrev-ref", "HEAD"])
.current_dir(&worktree_path)
.output()
.await
.unwrap();
let current_branch = String::from_utf8_lossy(&branch_output.stdout);
assert_eq!(current_branch.trim(), "existing-branch");
let _ = worktree_remove(temp_dir.path(), worktree_path.to_str().unwrap()).await;
}
#[tokio::test]
async fn test_worktree_add_existing_branch_attach_failure() {
let temp_dir = TempDir::new().unwrap();
let init = Command::new("git")
.args(["init", "-b", "main"])
.current_dir(temp_dir.path())
.output()
.await;
if init.is_err() {
return; }
let _ = Command::new("git")
.args(["config", "user.email", "test@example.com"])
.current_dir(temp_dir.path())
.output()
.await;
let _ = Command::new("git")
.args(["config", "user.name", "Test User"])
.current_dir(temp_dir.path())
.output()
.await;
std::fs::write(temp_dir.path().join("README.md"), "test").unwrap();
let _ = Command::new("git")
.args(["add", "."])
.current_dir(temp_dir.path())
.output()
.await;
let _ = Command::new("git")
.args(["commit", "-m", "Initial commit"])
.current_dir(temp_dir.path())
.output()
.await;
let worktree_path1 = temp_dir.path().join("worktrees").join("test-wt-1");
std::fs::create_dir_all(worktree_path1.parent().unwrap()).unwrap();
let _ = worktree_add(
temp_dir.path(),
worktree_path1.to_str().unwrap(),
"existing-branch",
"HEAD",
)
.await;
let worktree_path2 = temp_dir.path().join("worktrees").join("test-wt-2");
std::fs::create_dir_all(worktree_path2.parent().unwrap()).unwrap();
let result = worktree_add(
temp_dir.path(),
worktree_path2.to_str().unwrap(),
"existing-branch",
"HEAD",
)
.await;
assert!(
result.is_err(),
"Expected to fail when branch is already checked out"
);
if let Err(VcsError::Command { message, .. }) = &result {
assert!(
message.contains("branch") || message.contains("checked out"),
"Expected error message to indicate branch is checked out, got: {}",
message
);
}
let _ = worktree_remove(temp_dir.path(), worktree_path1.to_str().unwrap()).await;
}
#[tokio::test]
async fn test_list_worktrees() {
let temp_dir = TempDir::new().unwrap();
let init_result = Command::new("git")
.args(["init", "-b", "main"])
.current_dir(temp_dir.path())
.output()
.await;
if init_result.is_err() {
return; }
let _ = Command::new("git")
.args(["config", "user.email", "test@example.com"])
.current_dir(temp_dir.path())
.output()
.await;
let _ = Command::new("git")
.args(["config", "user.name", "Test User"])
.current_dir(temp_dir.path())
.output()
.await;
std::fs::write(temp_dir.path().join("README.md"), "test").unwrap();
let _ = Command::new("git")
.args(["add", "."])
.current_dir(temp_dir.path())
.output()
.await;
let _ = Command::new("git")
.args(["commit", "-m", "Initial commit"])
.current_dir(temp_dir.path())
.output()
.await;
let worktrees = list_worktrees(temp_dir.path()).await.unwrap();
assert_eq!(worktrees.len(), 1);
let (path, _head, branch, is_detached, is_main) = &worktrees[0];
assert!(path.contains(temp_dir.path().to_str().unwrap()));
assert_eq!(branch, "main");
assert!(!is_detached);
assert!(is_main);
let worktree_path = temp_dir.path().join("worktrees").join("test-wt");
std::fs::create_dir_all(worktree_path.parent().unwrap()).unwrap();
let _ = worktree_add(
temp_dir.path(),
worktree_path.to_str().unwrap(),
"test-branch",
"HEAD",
)
.await;
let worktrees = list_worktrees(temp_dir.path()).await.unwrap();
assert_eq!(worktrees.len(), 2);
let _ = worktree_remove(temp_dir.path(), worktree_path.to_str().unwrap()).await;
}
#[tokio::test]
async fn test_stale_path_retry_falls_through_to_existing_branch_attach() {
let temp_dir = TempDir::new().unwrap();
let init = Command::new("git")
.args(["init", "-b", "main"])
.current_dir(temp_dir.path())
.output()
.await;
if init.is_err() {
return; }
let _ = Command::new("git")
.args(["config", "user.email", "test@example.com"])
.current_dir(temp_dir.path())
.output()
.await;
let _ = Command::new("git")
.args(["config", "user.name", "Test User"])
.current_dir(temp_dir.path())
.output()
.await;
std::fs::write(temp_dir.path().join("README.md"), "test").unwrap();
let _ = Command::new("git")
.args(["add", "."])
.current_dir(temp_dir.path())
.output()
.await;
let _ = Command::new("git")
.args(["commit", "-m", "Initial commit"])
.current_dir(temp_dir.path())
.output()
.await;
let worktree_path = temp_dir.path().join("worktrees").join("stale-wt");
std::fs::create_dir_all(worktree_path.parent().unwrap()).unwrap();
let _ = Command::new("git")
.args(["branch", "stale-branch"])
.current_dir(temp_dir.path())
.output()
.await;
std::fs::create_dir_all(&worktree_path).unwrap();
assert!(worktree_path.exists());
let worktrees = list_worktrees(temp_dir.path()).await.unwrap();
let is_registered = worktrees
.iter()
.any(|(path, _, _, _, _)| *path == worktree_path.to_string_lossy());
assert!(!is_registered, "Worktree path should NOT be registered");
let branch_check = Command::new("git")
.args(["show-ref", "--verify", "refs/heads/stale-branch"])
.current_dir(temp_dir.path())
.output()
.await
.unwrap();
assert!(branch_check.status.success(), "Branch should exist");
let result = worktree_add(
temp_dir.path(),
worktree_path.to_str().unwrap(),
"stale-branch",
"HEAD",
)
.await;
if let Err(e) = &result {
eprintln!(
"Stale-path → existing-branch attach failed unexpectedly: {:?}",
e
);
}
assert!(
result.is_ok(),
"Expected stale-path retry to fall through to existing-branch attach"
);
assert!(worktree_path.exists());
let branch_output = Command::new("git")
.args(["rev-parse", "--abbrev-ref", "HEAD"])
.current_dir(&worktree_path)
.output()
.await
.unwrap();
let current_branch = String::from_utf8_lossy(&branch_output.stdout);
assert_eq!(current_branch.trim(), "stale-branch");
let _ = worktree_remove(temp_dir.path(), worktree_path.to_str().unwrap()).await;
}
#[tokio::test]
async fn test_stale_path_retry_does_not_attach_checked_out_branch() {
let temp_dir = TempDir::new().unwrap();
let init = Command::new("git")
.args(["init", "-b", "main"])
.current_dir(temp_dir.path())
.output()
.await;
if init.is_err() {
return; }
let _ = Command::new("git")
.args(["config", "user.email", "test@example.com"])
.current_dir(temp_dir.path())
.output()
.await;
let _ = Command::new("git")
.args(["config", "user.name", "Test User"])
.current_dir(temp_dir.path())
.output()
.await;
std::fs::write(temp_dir.path().join("README.md"), "test").unwrap();
let _ = Command::new("git")
.args(["add", "."])
.current_dir(temp_dir.path())
.output()
.await;
let _ = Command::new("git")
.args(["commit", "-m", "Initial commit"])
.current_dir(temp_dir.path())
.output()
.await;
let worktree_existing = temp_dir.path().join("worktrees").join("existing-wt");
std::fs::create_dir_all(worktree_existing.parent().unwrap()).unwrap();
let create_result = worktree_add(
temp_dir.path(),
worktree_existing.to_str().unwrap(),
"shared-branch",
"HEAD",
)
.await;
assert!(
create_result.is_ok(),
"Should succeed creating the first worktree"
);
let worktree_stale = temp_dir.path().join("worktrees").join("stale-wt-2");
std::fs::create_dir_all(&worktree_stale).unwrap();
assert!(worktree_stale.exists());
let worktrees = list_worktrees(temp_dir.path()).await.unwrap();
let is_registered = worktrees
.iter()
.any(|(path, _, _, _, _)| *path == worktree_stale.to_string_lossy());
assert!(
!is_registered,
"Stale worktree path should NOT be registered"
);
let is_checked = is_branch_checked_out(temp_dir.path(), "shared-branch")
.await
.unwrap();
assert!(
is_checked,
"Branch should be checked out in the first worktree"
);
let result = worktree_add(
temp_dir.path(),
worktree_stale.to_str().unwrap(),
"shared-branch",
"HEAD",
)
.await;
assert!(
result.is_err(),
"Expected failure because branch is checked out in another worktree"
);
if let Err(VcsError::Command { message, .. }) = &result {
assert!(
message.contains("classified as") || message.contains("checked out"),
"Expected classified error, got: {}",
message
);
}
let _ = worktree_remove(temp_dir.path(), worktree_existing.to_str().unwrap()).await;
}
#[cfg(unix)]
fn set_executable(path: &Path) {
use std::os::unix::fs::PermissionsExt;
let mut permissions = std::fs::metadata(path).unwrap().permissions();
permissions.set_mode(0o755);
std::fs::set_permissions(path, permissions).unwrap();
}
async fn init_test_repo(temp_dir: &TempDir) {
let init = Command::new("git")
.args(["init", "-b", "main"])
.current_dir(temp_dir.path())
.output()
.await;
if init.is_err() {
return;
}
let _ = Command::new("git")
.args(["config", "user.email", "test@example.com"])
.current_dir(temp_dir.path())
.output()
.await;
let _ = Command::new("git")
.args(["config", "user.name", "Test User"])
.current_dir(temp_dir.path())
.output()
.await;
std::fs::write(temp_dir.path().join("README.md"), "test").unwrap();
let _ = Command::new("git")
.args(["add", "."])
.current_dir(temp_dir.path())
.output()
.await;
let _ = Command::new("git")
.args(["commit", "-m", "Initial commit"])
.current_dir(temp_dir.path())
.output()
.await;
}
#[tokio::test]
async fn test_worktree_remove_runs_teardown_before_removal() {
let temp_dir = TempDir::new().unwrap();
init_test_repo(&temp_dir).await;
let worktree_path = temp_dir.path().join("worktrees").join("teardown-ok");
std::fs::create_dir_all(worktree_path.parent().unwrap()).unwrap();
worktree_add(
temp_dir.path(),
worktree_path.to_str().unwrap(),
"teardown-ok",
"HEAD",
)
.await
.unwrap();
let teardown_dir = worktree_path.join(".wt");
std::fs::create_dir_all(&teardown_dir).unwrap();
let teardown_script = teardown_dir.join("teardown");
std::fs::write(
&teardown_script,
"#!/bin/sh\nif [ \"$ROOT_WORKTREE_PATH\" = \"\" ]; then exit 9; fi\npwd > .teardown-cwd\nprintf 'ok' > .teardown-ran\n",
)
.unwrap();
#[cfg(unix)]
set_executable(&teardown_script);
worktree_remove(temp_dir.path(), worktree_path.to_str().unwrap())
.await
.unwrap();
assert!(!worktree_path.exists(), "worktree should be removed");
}
#[tokio::test]
async fn test_worktree_remove_blocks_on_teardown_failure() {
let temp_dir = TempDir::new().unwrap();
init_test_repo(&temp_dir).await;
let worktree_path = temp_dir.path().join("worktrees").join("teardown-fail");
std::fs::create_dir_all(worktree_path.parent().unwrap()).unwrap();
worktree_add(
temp_dir.path(),
worktree_path.to_str().unwrap(),
"teardown-fail",
"HEAD",
)
.await
.unwrap();
let teardown_dir = worktree_path.join(".wt");
std::fs::create_dir_all(&teardown_dir).unwrap();
let teardown_script = teardown_dir.join("teardown");
std::fs::write(&teardown_script, "#!/bin/sh\nprintf 'boom' 1>&2\nexit 13\n").unwrap();
#[cfg(unix)]
set_executable(&teardown_script);
let err = worktree_remove(temp_dir.path(), worktree_path.to_str().unwrap())
.await
.unwrap_err();
match err {
VcsError::Command { message, .. } => {
assert!(message.contains("Teardown script failed"));
}
other => panic!("unexpected error type: {other:?}"),
}
assert!(worktree_path.exists(), "worktree should be preserved");
}
#[tokio::test]
async fn test_worktree_remove_skip_teardown_option_ignores_failure() {
let temp_dir = TempDir::new().unwrap();
init_test_repo(&temp_dir).await;
let worktree_path = temp_dir.path().join("worktrees").join("teardown-skip");
std::fs::create_dir_all(worktree_path.parent().unwrap()).unwrap();
worktree_add(
temp_dir.path(),
worktree_path.to_str().unwrap(),
"teardown-skip",
"HEAD",
)
.await
.unwrap();
let teardown_dir = worktree_path.join(".wt");
std::fs::create_dir_all(&teardown_dir).unwrap();
let teardown_script = teardown_dir.join("teardown");
std::fs::write(&teardown_script, "#!/bin/sh\nexit 99\n").unwrap();
#[cfg(unix)]
set_executable(&teardown_script);
worktree_remove_with_options(
temp_dir.path(),
worktree_path.to_str().unwrap(),
WorktreeRemoveOptions {
skip_teardown: true,
},
)
.await
.unwrap();
assert!(
!worktree_path.exists(),
"worktree should be removed with skip"
);
}
#[tokio::test]
async fn test_worktree_remove_skips_non_executable_teardown() {
let temp_dir = TempDir::new().unwrap();
init_test_repo(&temp_dir).await;
let worktree_path = temp_dir.path().join("worktrees").join("teardown-nonexec");
std::fs::create_dir_all(worktree_path.parent().unwrap()).unwrap();
worktree_add(
temp_dir.path(),
worktree_path.to_str().unwrap(),
"teardown-nonexec",
"HEAD",
)
.await
.unwrap();
let teardown_dir = worktree_path.join(".wt");
std::fs::create_dir_all(&teardown_dir).unwrap();
let teardown_script = teardown_dir.join("teardown");
std::fs::write(&teardown_script, "#!/bin/sh\nexit 77\n").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut permissions = std::fs::metadata(&teardown_script).unwrap().permissions();
permissions.set_mode(0o644);
std::fs::set_permissions(&teardown_script, permissions).unwrap();
}
worktree_remove(temp_dir.path(), worktree_path.to_str().unwrap())
.await
.unwrap();
assert!(!worktree_path.exists(), "worktree should be removed");
}
}