use std::ffi::OsString; use std::path::{Path, PathBuf}; const DEFAULT_WORKTREE_PREFIX: &str = "worktree"; #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct WorktreeCommand { pub program: String, pub args: Vec, } #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct ExistingWorktree { pub path: PathBuf, pub branch: Option, pub is_bare: bool, pub is_detached: bool, pub is_prunable: bool, } pub(crate) fn generated_branch_slug(seed: u64) -> String { let adjectives = [ "brave", "calm", "clear", "green", "lucky", "quiet", "rapid", "silver", ]; let nouns = [ "river", "cloud", "field", "forest", "harbor", "meadow", "stone", "valley", ]; let adjective = adjectives[(seed as usize) % adjectives.len()]; let noun = nouns[((seed / adjectives.len() as u64) as usize) % nouns.len()]; let suffix = seed & 0xffff; format!("{DEFAULT_WORKTREE_PREFIX}/{adjective}-{noun}-{suffix:04x}") } pub(crate) fn branch_to_path_slug(branch: &str) -> String { let mut slug = String::new(); let mut last_was_dash = false; for ch in branch.chars() { if ch.is_ascii_alphanumeric() { slug.push(ch.to_ascii_lowercase()); last_was_dash = false; } else if !last_was_dash { slug.push('-'); last_was_dash = true; } } let trimmed = slug.trim_matches('-').to_string(); if trimmed.is_empty() { DEFAULT_WORKTREE_PREFIX.to_string() } else { trimmed } } pub(crate) fn expand_tilde_path(path: &str) -> PathBuf { expand_tilde_path_from_env(path, cfg!(windows), |key| std::env::var_os(key)) } fn expand_tilde_path_from_env( path: &str, is_windows: bool, env: impl Fn(&str) -> Option + Copy, ) -> PathBuf { if path == "~" { return home_dir_from_env(is_windows, env).unwrap_or_else(|_| PathBuf::from(path)); } let tilde_rest = path.strip_prefix("~/").or_else(|| { if is_windows { path.strip_prefix("~\\") } else { None } }); if let Some(rest) = tilde_rest { return home_dir_from_env(is_windows, env) .map(|home| join_tilde_rest(home, rest, is_windows)) .unwrap_or_else(|_| PathBuf::from(path)); } PathBuf::from(path) } fn join_tilde_rest(home: PathBuf, rest: &str, is_windows: bool) -> PathBuf { if is_windows { rest.split(['/', '\\']) .filter(|component| !component.is_empty()) .fold(home, |path, component| path.join(component)) } else { home.join(rest) } } fn home_dir_from_env( is_windows: bool, env: impl Fn(&str) -> Option, ) -> Result { if !is_windows { return env("HOME").map(PathBuf::from).ok_or(()); } if let Some(path) = usable_home_path(env("USERPROFILE")) { return Ok(path); } if let (Some(drive), Some(path)) = ( usable_home_component(env("HOMEDRIVE")), usable_home_component(env("HOMEPATH")), ) { let path = path.to_string_lossy(); if !path.starts_with(['\\', '/']) { return usable_home_path(env("HOME")).ok_or(()); } let combined = format!("{}{}", drive.to_string_lossy(), path); if let Some(path) = usable_home_path(Some(OsString::from(combined))) { return Ok(path); } } usable_home_path(env("HOME")).ok_or(()) } fn usable_home_path(value: Option) -> Option { let value = value?; if value.is_empty() || value == "~" { return None; } Some(PathBuf::from(value)) } fn usable_home_component(value: Option) -> Option { let value = value?; if value.is_empty() || value == "~" { return None; } Some(value) } pub(crate) fn expand_tilde_absolute_path(path: &str) -> PathBuf { let path = expand_tilde_path(path); if path.is_absolute() { path } else { std::env::current_dir() .map(|cwd| cwd.join(&path)) .unwrap_or(path) } } pub(crate) fn canonical_or_original(path: &Path) -> PathBuf { std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()) } pub(crate) fn default_checkout_path(root: &Path, repo_name: &str, branch: &str) -> PathBuf { root.join(repo_name).join(branch_to_path_slug(branch)) } pub(crate) fn build_worktree_remove_command( repo_root: &Path, path: &Path, force: bool, ) -> WorktreeCommand { let mut args = vec![ "-C".to_string(), repo_root.display().to_string(), "worktree".to_string(), "remove".to_string(), ]; if force { args.push("--force".to_string()); } args.push(path.display().to_string()); WorktreeCommand { program: "git".to_string(), args, } } pub(crate) fn is_dirty_worktree_remove_error(message: &str) -> bool { let lower = message.to_ascii_lowercase(); lower.contains("contains modified or untracked files") && lower.contains("use --force to delete it") } pub(crate) fn is_not_working_tree_remove_error(message: &str) -> bool { let lower = message.to_ascii_lowercase(); lower.contains("is not a working tree") || lower.contains("is not a worktree") } #[cfg(windows)] pub(crate) fn worktree_dirty_remove_message(path: &Path) -> String { format!( "fatal: '{}' contains modified or untracked files, use --force to delete it", path.display() ) } #[cfg(any(windows, test))] pub(crate) fn checkout_has_dirty_files(path: &Path) -> Result { let path_arg = path.display().to_string(); let output = crate::noninteractive_process::command("git") .args([ "-C", &path_arg, "status", "--porcelain", "--untracked-files=all", ]) .output() .map_err(|err| err.to_string())?; if output.status.success() { return Ok(!output.stdout.is_empty()); } let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); if !stderr.is_empty() { Err(stderr) } else if !stdout.is_empty() { Err(stdout) } else { Err(format!("git status failed with status {}", output.status)) } } pub(crate) fn build_worktree_add_new_branch_command( repo_root: &Path, path: &Path, branch: &str, base: &str, ) -> WorktreeCommand { WorktreeCommand { program: "git".to_string(), args: vec![ "-C".to_string(), repo_root.display().to_string(), "worktree".to_string(), "add".to_string(), "-b".to_string(), branch.to_string(), path.display().to_string(), base.to_string(), ], } } pub(crate) fn build_worktree_add_existing_branch_command( repo_root: &Path, path: &Path, branch: &str, ) -> WorktreeCommand { WorktreeCommand { program: "git".to_string(), args: vec![ "-C".to_string(), repo_root.display().to_string(), "worktree".to_string(), "add".to_string(), path.display().to_string(), branch.to_string(), ], } } pub(crate) fn local_branch_exists(repo_root: &Path, branch: &str) -> Result { let output = crate::noninteractive_process::command("git") .arg("-C") .arg(repo_root) .args(["show-ref", "--verify", "--quiet"]) .arg(format!("refs/heads/{branch}")) .output() .map_err(|err| err.to_string())?; if output.status.success() { return Ok(true); } if output.status.code() == Some(1) { return Ok(false); } let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); if !stderr.is_empty() { Err(stderr) } else if !stdout.is_empty() { Err(stdout) } else { Err(format!("git show-ref failed with status {}", output.status)) } } pub(crate) fn run_worktree_add_command( repo_root: &Path, path: &Path, branch: &str, base: &str, ) -> Result<(), String> { let command = if local_branch_exists(repo_root, branch)? { build_worktree_add_existing_branch_command(repo_root, path, branch) } else { build_worktree_add_new_branch_command(repo_root, path, branch, base) }; run_worktree_command(&command) } pub(crate) fn run_worktree_command(command: &WorktreeCommand) -> Result<(), String> { let output = crate::noninteractive_process::command(&command.program) .args(&command.args) .output() .map_err(|err| err.to_string())?; if output.status.success() { return Ok(()); } let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); let message = if stderr.is_empty() { stdout } else { stderr }; Err(if message.is_empty() { format!("{} failed with status {}", command.program, output.status) } else { message }) } pub(crate) fn run_worktree_remove_command_with_recovery( command: &WorktreeCommand, repo_root: &Path, path: &Path, force: bool, ) -> Result<(), String> { match run_worktree_command(command) { Ok(()) => Ok(()), Err(err) if force && is_not_working_tree_remove_error(&err) => { if worktree_list_contains_path(repo_root, path)? { return Err(err); } if path.exists() { if !leftover_worktree_checkout_matches_repo(repo_root, path) { return Err(err); } std::fs::remove_dir_all(path).map_err(|remove_err| { format!( "{err}; failed to remove leftover checkout {}: {remove_err}", path.display() ) })?; } Ok(()) } Err(err) => Err(err), } } fn leftover_worktree_checkout_matches_repo(repo_root: &Path, path: &Path) -> bool { let git_file = path.join(".git"); let Ok(content) = std::fs::read_to_string(&git_file) else { return false; }; let Some(gitdir) = content.trim().strip_prefix("gitdir:") else { return false; }; let gitdir = PathBuf::from(gitdir.trim()); let gitdir = if gitdir.is_absolute() { gitdir } else { path.join(gitdir) }; let Some(worktrees_dir) = git_common_worktrees_dir(repo_root) else { return false; }; canonical_or_original(&gitdir).starts_with(canonical_or_original(&worktrees_dir)) } fn git_common_worktrees_dir(repo_root: &Path) -> Option { let output = crate::noninteractive_process::command("git") .arg("-C") .arg(repo_root) .args(["rev-parse", "--git-common-dir"]) .output() .ok()?; if !output.status.success() { return None; } let stdout = String::from_utf8_lossy(&output.stdout); let common_dir = stdout.trim(); if common_dir.is_empty() { None } else { let common_dir = PathBuf::from(common_dir); let common_dir = if common_dir.is_absolute() { common_dir } else { repo_root.join(common_dir) }; Some(common_dir.join("worktrees")) } } pub(crate) fn parse_worktree_list_porcelain(output: &str) -> Vec { let mut entries = Vec::new(); let mut path: Option = None; let mut branch = None; let mut is_bare = false; let mut is_detached = false; let mut is_prunable = false; let finish = |entries: &mut Vec, path: &mut Option, branch: &mut Option, is_bare: &mut bool, is_detached: &mut bool, is_prunable: &mut bool| { if let Some(path) = path.take() { entries.push(ExistingWorktree { path, branch: branch.take(), is_bare: *is_bare, is_detached: *is_detached, is_prunable: *is_prunable, }); } *is_bare = false; *is_detached = false; *is_prunable = false; }; for line in output.lines() { if line.trim().is_empty() { finish( &mut entries, &mut path, &mut branch, &mut is_bare, &mut is_detached, &mut is_prunable, ); continue; } if let Some(value) = line.strip_prefix("worktree ") { path = Some(PathBuf::from(value)); } else if let Some(value) = line.strip_prefix("branch ") { branch = Some( value .strip_prefix("refs/heads/") .unwrap_or(value) .to_string(), ); } else if line == "detached" { is_detached = true; } else if line == "bare" { is_bare = true; } else if line.starts_with("prunable") { is_prunable = true; } } finish( &mut entries, &mut path, &mut branch, &mut is_bare, &mut is_detached, &mut is_prunable, ); entries } pub(crate) fn list_existing_worktrees(repo_root: &Path) -> Result, String> { let output = crate::noninteractive_process::command("git") .arg("-C") .arg(repo_root) .args(["worktree", "list", "--porcelain"]) .output() .map_err(|err| err.to_string())?; if output.status.success() { let stdout = String::from_utf8_lossy(&output.stdout); return Ok(parse_worktree_list_porcelain(&stdout)); } let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); Err(if stderr.is_empty() { format!("git worktree list failed with status {}", output.status) } else { stderr }) } pub(crate) fn worktree_list_contains_path(repo_root: &Path, path: &Path) -> Result { let expected = canonical_or_original(path); Ok(list_existing_worktrees(repo_root)? .into_iter() .any(|entry| canonical_or_original(&entry.path) == expected)) } #[cfg(test)] mod tests { use super::*; fn unique_temp_path(name: &str) -> PathBuf { let nanos = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_nanos()) .unwrap_or(0); std::env::temp_dir().join(format!("herdr-{name}-{}-{nanos}", std::process::id())) } fn run_git(repo: &Path, args: &[&str]) { let status = std::process::Command::new("git") .arg("-C") .arg(repo) .args(args) .status() .unwrap(); assert!( status.success(), "git command failed: git -C {} {}", repo.display(), args.join(" ") ); } fn create_committed_repo(name: &str) -> PathBuf { let repo = unique_temp_path(name); std::fs::create_dir_all(&repo).unwrap(); run_git(&repo, &["init", "--quiet"]); run_git(&repo, &["config", "user.email", "herdr@example.invalid"]); run_git(&repo, &["config", "user.name", "Herdr Test"]); std::fs::write(repo.join("README.md"), "test\n").unwrap(); run_git(&repo, &["add", "README.md"]); run_git(&repo, &["commit", "--quiet", "-m", "initial"]); repo } #[test] fn generated_branch_slug_is_worktree_namespaced_and_stable() { assert_eq!(generated_branch_slug(0), "worktree/brave-river-0000"); assert_eq!(generated_branch_slug(9), "worktree/calm-cloud-0009"); } #[test] fn parses_git_worktree_list_porcelain() { let output = "\ worktree /repo/main HEAD abc branch refs/heads/main worktree /repo/issue HEAD def branch refs/heads/worktree/issue worktree /repo/detached HEAD fed detached prunable stale "; assert_eq!( parse_worktree_list_porcelain(output), vec![ ExistingWorktree { path: PathBuf::from("/repo/main"), branch: Some("main".into()), is_bare: false, is_detached: false, is_prunable: false, }, ExistingWorktree { path: PathBuf::from("/repo/issue"), branch: Some("worktree/issue".into()), is_bare: false, is_detached: false, is_prunable: false, }, ExistingWorktree { path: PathBuf::from("/repo/detached"), branch: None, is_bare: false, is_detached: true, is_prunable: true, }, ] ); } #[test] fn branch_to_path_slug_makes_branch_safe_folder_name() { assert_eq!( branch_to_path_slug("worktree/brave-river"), "worktree-brave-river" ); assert_eq!( branch_to_path_slug("issue/137 Worktree Spaces"), "issue-137-worktree-spaces" ); assert_eq!(branch_to_path_slug("///"), "worktree"); } #[test] fn expand_tilde_path_uses_home_when_available() { assert_eq!( expand_tilde_path_from_env("~/.herdr/worktrees", false, |key| match key { "HOME" => Some("/home/me".into()), _ => None, }), PathBuf::from("/home/me/.herdr/worktrees") ); assert_eq!( expand_tilde_path_from_env("/tmp/worktrees", false, |_| None), PathBuf::from("/tmp/worktrees") ); } #[test] fn home_dir_uses_windows_profile_before_literal_home() { assert_eq!( home_dir_from_env(true, |key| match key { "HOME" => Some("~".into()), "USERPROFILE" => Some(r"C:\Users\herdr".into()), _ => None, }), Ok(PathBuf::from(r"C:\Users\herdr")) ); } #[test] fn home_dir_uses_windows_drive_and_path_when_profile_is_missing() { assert_eq!( home_dir_from_env(true, |key| match key { "HOMEDRIVE" => Some("C:".into()), "HOMEPATH" => Some(r"\Users\herdr".into()), _ => None, }), Ok(PathBuf::from(r"C:\Users\herdr")) ); } #[test] fn home_dir_rejects_incomplete_windows_drive_and_path() { assert_eq!( home_dir_from_env(true, |key| match key { "HOMEDRIVE" => Some("C:".into()), "HOMEPATH" => Some("".into()), _ => None, }), Err(()) ); assert_eq!( home_dir_from_env(true, |key| match key { "HOMEDRIVE" => Some("C:".into()), "HOMEPATH" => Some("Users\\herdr".into()), _ => None, }), Err(()) ); } #[cfg(not(windows))] #[test] fn non_windows_tilde_expansion_keeps_windows_separator_literal() { assert_eq!( expand_tilde_path_from_env(r"~\.herdr\worktrees", false, |key| match key { "HOME" => Some("/home/me".into()), _ => None, }), PathBuf::from(r"~\.herdr\worktrees") ); } #[cfg(windows)] #[test] fn windows_tilde_expansion_normalizes_separators() { fn env(key: &str) -> Option { match key { "HOME" => Some("~".into()), "USERPROFILE" => Some(r"C:\Users\herdr".into()), _ => None, } } let default_path = expand_tilde_path_from_env("~/.herdr/worktrees", true, env); assert_eq!( default_path, PathBuf::from(r"C:\Users\herdr\.herdr\worktrees") ); assert_eq!( default_path.display().to_string(), r"C:\Users\herdr\.herdr\worktrees" ); assert_eq!( expand_tilde_path_from_env(r"~\.herdr\worktrees", true, env), PathBuf::from(r"C:\Users\herdr\.herdr\worktrees") ); } #[test] fn default_checkout_path_appends_repo_and_branch_slug() { assert_eq!( default_checkout_path( Path::new("/home/me/.herdr/worktrees"), "herdr", "worktree/brave-river", ), PathBuf::from("/home/me/.herdr/worktrees/herdr/worktree-brave-river") ); } #[test] fn checkout_dirty_detection_reports_clean_and_dirty_worktrees() { let repo = create_committed_repo("worktree-dirty-detection-repo"); let checkout = unique_temp_path("worktree-dirty-detection-checkout"); run_git( &repo, &[ "worktree", "add", "--quiet", "-b", "worktree/dirty-detection", checkout.to_str().unwrap(), "HEAD", ], ); assert_eq!(checkout_has_dirty_files(&checkout), Ok(false)); std::fs::write(checkout.join("README.md"), "dirty\n").unwrap(); assert_eq!(checkout_has_dirty_files(&checkout), Ok(true)); let remove = build_worktree_remove_command(&repo, &checkout, true); run_worktree_command(&remove).unwrap(); let _ = std::fs::remove_dir_all(repo); } #[test] fn worktree_remove_command_preserves_branch_by_not_deleting_it() { let command = build_worktree_remove_command( Path::new("/repo/herdr"), Path::new("/w/herdr/issue-137"), false, ); assert_eq!(command.program, "git"); assert_eq!( command.args, vec![ "-C", "/repo/herdr", "worktree", "remove", "/w/herdr/issue-137" ] ); } #[test] fn forced_worktree_remove_command_uses_git_force_flag() { let command = build_worktree_remove_command( Path::new("/repo/herdr"), Path::new("/w/herdr/issue-137"), true, ); assert_eq!( command.args, vec![ "-C", "/repo/herdr", "worktree", "remove", "--force", "/w/herdr/issue-137" ] ); } #[test] fn dirty_remove_error_detection_matches_git_force_hint() { assert!(is_dirty_worktree_remove_error( "fatal: '/w/herdr' contains modified or untracked files, use --force to delete it" )); assert!(!is_dirty_worktree_remove_error( "fatal: '/w/herdr' is a missing but already registered worktree" )); assert!(!is_dirty_worktree_remove_error( "fatal: '/w/herdr' contains a locked worktree, use --force only if you know why" )); } #[test] fn worktree_add_command_creates_new_branch_from_base() { let command = build_worktree_add_new_branch_command( Path::new("/repo/herdr"), Path::new("/w/herdr/worktree-brave-river"), "worktree/brave-river", "HEAD", ); assert_eq!(command.program, "git"); assert_eq!( command.args, vec![ "-C", "/repo/herdr", "worktree", "add", "-b", "worktree/brave-river", "/w/herdr/worktree-brave-river", "HEAD" ] ); } #[test] fn worktree_add_command_checks_out_existing_branch() { let command = build_worktree_add_existing_branch_command( Path::new("/repo/herdr"), Path::new("/w/herdr/worktree-brave-river"), "worktree/brave-river", ); assert_eq!(command.program, "git"); assert_eq!( command.args, vec![ "-C", "/repo/herdr", "worktree", "add", "/w/herdr/worktree-brave-river", "worktree/brave-river" ] ); } #[test] fn run_worktree_add_and_remove_create_and_delete_checkout() { let repo = create_committed_repo("worktree-run-repo"); let checkout = unique_temp_path("worktree-run-checkout"); let branch = "worktree/test-create-remove"; let add = build_worktree_add_new_branch_command(&repo, &checkout, branch, "HEAD"); run_worktree_command(&add).unwrap(); assert!(checkout.join("README.md").exists()); let branch_name = std::process::Command::new("git") .arg("-C") .arg(&checkout) .args(["branch", "--show-current"]) .output() .unwrap(); assert!(branch_name.status.success()); assert_eq!( String::from_utf8(branch_name.stdout).unwrap().trim(), branch ); let remove = build_worktree_remove_command(&repo, &checkout, false); run_worktree_command(&remove).unwrap(); assert!(!checkout.exists()); let _ = std::fs::remove_dir_all(repo); } #[test] fn forced_worktree_remove_recovers_leftover_unregistered_checkout() { let repo = create_committed_repo("worktree-recovery-repo"); let checkout = unique_temp_path("worktree-recovery-checkout"); let branch = "worktree/recovery"; let add = build_worktree_add_new_branch_command(&repo, &checkout, branch, "HEAD"); run_worktree_command(&add).unwrap(); let remove = build_worktree_remove_command(&repo, &checkout, true); run_worktree_command(&remove).unwrap(); std::fs::create_dir_all(&checkout).unwrap(); let stale_admin_dir = git_common_worktrees_dir(&repo).unwrap().join("stale"); std::fs::write( checkout.join(".git"), format!("gitdir: {}\n", stale_admin_dir.display()), ) .unwrap(); std::fs::write(checkout.join("leftover"), "leftover\n").unwrap(); run_worktree_remove_command_with_recovery(&remove, &repo, &checkout, true).unwrap(); assert!(!checkout.exists()); let _ = std::fs::remove_dir_all(repo); } #[test] fn forced_worktree_remove_recovery_keeps_unrelated_replacement_directory() { let repo = create_committed_repo("worktree-recovery-unrelated-repo"); let checkout = unique_temp_path("worktree-recovery-unrelated-checkout"); let branch = "worktree/recovery-unrelated"; let add = build_worktree_add_new_branch_command(&repo, &checkout, branch, "HEAD"); run_worktree_command(&add).unwrap(); let remove = build_worktree_remove_command(&repo, &checkout, true); run_worktree_command(&remove).unwrap(); std::fs::create_dir_all(&checkout).unwrap(); std::fs::write(checkout.join("unrelated"), "do not delete\n").unwrap(); let err = run_worktree_remove_command_with_recovery(&remove, &repo, &checkout, true) .expect_err("unrelated replacement directory should not be removed"); assert!(is_not_working_tree_remove_error(&err)); assert!(checkout.join("unrelated").exists()); let _ = std::fs::remove_dir_all(checkout); let _ = std::fs::remove_dir_all(repo); } }