diff options
Diffstat (limited to 'src/worktree.rs')
| -rw-r--r-- | src/worktree.rs | 478 |
1 files changed, 478 insertions, 0 deletions
diff --git a/src/worktree.rs b/src/worktree.rs new file mode 100644 index 00000000..058ee2b8 --- /dev/null +++ b/src/worktree.rs @@ -0,0 +1,478 @@ +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<String>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ExistingWorktree { + pub path: PathBuf, + pub branch: Option<String>, + 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 { + if path == "~" { + return std::env::var("HOME") + .map(PathBuf::from) + .unwrap_or_else(|_| PathBuf::from(path)); + } + + if let Some(rest) = path.strip_prefix("~/") { + return std::env::var("HOME") + .map(|home| PathBuf::from(home).join(rest)) + .unwrap_or_else(|_| PathBuf::from(path)); + } + + PathBuf::from(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 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 run_worktree_command(command: &WorktreeCommand) -> Result<(), String> { + let output = std::process::Command::new(&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 parse_worktree_list_porcelain(output: &str) -> Vec<ExistingWorktree> { + let mut entries = Vec::new(); + let mut path: Option<PathBuf> = 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<ExistingWorktree>, + path: &mut Option<PathBuf>, + branch: &mut Option<String>, + 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<Vec<ExistingWorktree>, String> { + let output = std::process::Command::new("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 + }) +} + +#[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() { + let old_home = std::env::var_os("HOME"); + std::env::set_var("HOME", "/home/me"); + assert_eq!( + expand_tilde_path("~/.herdr/worktrees"), + PathBuf::from("/home/me/.herdr/worktrees") + ); + assert_eq!( + expand_tilde_path("/tmp/worktrees"), + PathBuf::from("/tmp/worktrees") + ); + if let Some(home) = old_home { + std::env::set_var("HOME", home); + } else { + std::env::remove_var("HOME"); + } + } + + #[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 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 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); + } +} |
