//! Low-level synchronous git operations for worktree management. use crate::slog; use std::path::Path; use std::process::Command; pub(crate) fn branch_name(story_id: &str) -> String { format!("feature/story-{story_id}") } /// Detect the current branch of the project root (the base branch worktrees fork from). pub(crate) fn detect_base_branch(project_root: &Path) -> String { Command::new("git") .args(["rev-parse", "--abbrev-ref", "HEAD"]) .current_dir(project_root) .output() .ok() .and_then(|o| { if o.status.success() { Some(String::from_utf8_lossy(&o.stdout).trim().to_string()) } else { None } }) .unwrap_or_else(|| "master".to_string()) } /// Placeholder for worktree isolation of `.huskies/work/`. /// /// Previous approaches (sparse checkout, skip-worktree) all leaked state /// from worktrees back to the main checkout's config/index. For now this /// is a no-op — merge conflicts from pipeline file moves are handled at /// merge time by the mergemaster (squash merge ignores work/ diffs). pub(crate) fn configure_sparse_checkout(_wt_path: &Path) -> Result<(), String> { Ok(()) } /// Create a git worktree at `wt_path` on `branch`, pruning stale references first. pub(crate) fn create_worktree_sync( project_root: &Path, wt_path: &Path, branch: &str, ) -> Result<(), String> { // Ensure the parent directory exists if let Some(parent) = wt_path.parent() { std::fs::create_dir_all(parent).map_err(|e| format!("Create worktree dir: {e}"))?; } // Prune stale worktree references (e.g. directories deleted externally) let _ = Command::new("git") .args(["worktree", "prune"]) .current_dir(project_root) .output(); // Try to create branch. If it already exists that's fine. let _ = Command::new("git") .args(["branch", branch]) .current_dir(project_root) .output(); // Create worktree let output = Command::new("git") .args(["worktree", "add", &wt_path.to_string_lossy(), branch]) .current_dir(project_root) .output() .map_err(|e| format!("git worktree add: {e}"))?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); // If it says already checked out, that's fine if stderr.contains("already checked out") || stderr.contains("already exists") { return Ok(()); } return Err(format!("git worktree add failed: {stderr}")); } // Enable sparse checkout to exclude pipeline files from the worktree. // This prevents .huskies/work/ changes from ending up in feature branches, // which cause rename/delete merge conflicts when merging back to master. configure_sparse_checkout(wt_path)?; Ok(()) } /// Remove a git worktree directory and delete its branch. pub(crate) fn remove_worktree_sync( project_root: &Path, wt_path: &Path, branch: &str, ) -> Result<(), String> { // Remove worktree let output = Command::new("git") .args(["worktree", "remove", "--force", &wt_path.to_string_lossy()]) .current_dir(project_root) .output() .map_err(|e| format!("git worktree remove: {e}"))?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); if stderr.contains("not a working tree") { // Orphaned directory: git doesn't recognise it as a worktree. // Remove the directory directly and prune stale git metadata. slog!( "[worktree] orphaned worktree detected, removing directory: {}", wt_path.display() ); if let Err(e) = std::fs::remove_dir_all(wt_path) { slog!("[worktree] failed to remove orphaned directory: {e}"); } let _ = Command::new("git") .args(["worktree", "prune"]) .current_dir(project_root) .output(); } else { slog!("[worktree] remove warning: {stderr}"); } } // Delete branch (best effort) let _ = Command::new("git") .args(["branch", "-d", branch]) .current_dir(project_root) .output(); Ok(()) } /// Remove the git worktree for a story if it exists, deriving the path and /// branch name deterministically from `project_root` and `story_id`. /// /// Returns `Ok(())` if the worktree was removed or did not exist. /// Removal is best-effort: `remove_worktree_sync` logs failures internally /// but always returns `Ok`. pub fn prune_worktree_sync(project_root: &Path, story_id: &str) -> Result<(), String> { let wt_path = super::worktree_path(project_root, story_id); if !wt_path.exists() { return Ok(()); } let branch = branch_name(story_id); remove_worktree_sync(project_root, &wt_path, &branch) } /// Migrate filesystem artifacts for story IDs that were rewritten from slug form /// (`664_story_my_feature`) to numeric-only form (`664`). /// /// For each `(old_id, new_id)` pair (as returned by /// `crdt_state::migrate_story_ids_to_numeric`), this function: /// 1. Moves the git worktree directory via `git worktree move` when it exists. /// 2. Renames the git branch from `feature/story-{old_id}` to `feature/story-{new_id}`. /// 3. Renames the log directory at `.huskies/logs/{old_id}` when it exists. /// /// All steps are best-effort: failures are logged but do not abort the migration. /// Operations are skipped when the destination path already exists (idempotent). pub fn migrate_slug_paths(project_root: &Path, migrations: &[(String, String)]) { for (old_id, new_id) in migrations { // ── Worktree directory ────────────────────────────────────────────── let old_wt = super::worktree_path(project_root, old_id); let new_wt = super::worktree_path(project_root, new_id); if old_wt.exists() && !new_wt.exists() { let out = Command::new("git") .args([ "worktree", "move", &old_wt.to_string_lossy(), &new_wt.to_string_lossy(), ]) .current_dir(project_root) .output(); match out { Ok(o) if o.status.success() => { slog!("[migrate] Moved worktree {old_id} → {new_id}"); } Ok(o) => { let stderr = String::from_utf8_lossy(&o.stderr); slog!( "[migrate] git worktree move failed for {old_id}: {stderr}; \ falling back to directory rename" ); if let Err(e) = std::fs::rename(&old_wt, &new_wt) { slog!("[migrate] Directory rename for worktree {old_id} failed: {e}"); } else { slog!("[migrate] Renamed worktree directory {old_id} → {new_id}"); } } Err(e) => { slog!("[migrate] git worktree move error for {old_id}: {e}"); } } } // ── Git branch ────────────────────────────────────────────────────── let old_branch = branch_name(old_id); let new_branch = branch_name(new_id); let out = Command::new("git") .args(["branch", "-m", &old_branch, &new_branch]) .current_dir(project_root) .output(); match out { Ok(o) if o.status.success() => { slog!("[migrate] Renamed branch {old_branch} → {new_branch}"); } Ok(o) => { let stderr = String::from_utf8_lossy(&o.stderr); // Branch may not exist (story already merged/archived) — log at debug level. slog!("[migrate] Branch rename skipped for {old_id}: {stderr}"); } Err(e) => { slog!("[migrate] Branch rename error for {old_id}: {e}"); } } // ── Log directory ─────────────────────────────────────────────────── let old_log = project_root.join(".huskies").join("logs").join(old_id); let new_log = project_root.join(".huskies").join("logs").join(new_id); if old_log.exists() && !new_log.exists() { if let Err(e) = std::fs::rename(&old_log, &new_log) { slog!("[migrate] Log directory rename for {old_id} failed: {e}"); } else { slog!("[migrate] Moved log directory {old_id} → {new_id}"); } } } } #[cfg(test)] mod tests { use super::*; use std::fs; use tempfile::TempDir; fn init_git_repo(dir: &Path) { Command::new("git") .args(["init"]) .current_dir(dir) .output() .expect("git init"); Command::new("git") .args(["commit", "--allow-empty", "-m", "init"]) .current_dir(dir) .output() .expect("git commit"); } #[test] fn branch_name_format() { assert_eq!(branch_name("42_my_story"), "feature/story-42_my_story"); assert_eq!(branch_name("1_test"), "feature/story-1_test"); } #[test] fn numeric_id_branch_name_uses_number_only() { assert_eq!(branch_name("664"), "feature/story-664"); assert_eq!(branch_name("730"), "feature/story-730"); } #[test] fn numeric_id_worktree_path_uses_number_only() { let project_root = Path::new("/home/user/my-project"); let path = super::super::worktree_path(project_root, "664"); assert_eq!( path, Path::new("/home/user/my-project/.huskies/worktrees/664") ); } #[test] fn detect_base_branch_returns_branch_in_git_repo() { let tmp = TempDir::new().unwrap(); let project_root = tmp.path().join("my-project"); fs::create_dir_all(&project_root).unwrap(); init_git_repo(&project_root); let branch = detect_base_branch(&project_root); assert!(!branch.is_empty()); } #[test] fn detect_base_branch_falls_back_to_master_for_non_git_dir() { let tmp = TempDir::new().unwrap(); let branch = detect_base_branch(tmp.path()); assert_eq!(branch, "master"); } #[test] fn configure_sparse_checkout_is_noop() { let tmp = TempDir::new().unwrap(); assert!(configure_sparse_checkout(tmp.path()).is_ok()); } #[test] fn create_worktree_after_stale_reference() { let tmp = TempDir::new().unwrap(); let project_root = tmp.path().join("my-project"); fs::create_dir_all(&project_root).unwrap(); init_git_repo(&project_root); let wt_path = tmp.path().join("my-worktree"); let branch = "feature/test-stale"; // First creation should succeed create_worktree_sync(&project_root, &wt_path, branch).unwrap(); assert!(wt_path.exists()); // Simulate external deletion (e.g., rm -rf by another agent) fs::remove_dir_all(&wt_path).unwrap(); assert!(!wt_path.exists()); // Second creation should succeed despite stale git reference. // Without `git worktree prune`, this fails with "already checked out" // or "already exists". let result = create_worktree_sync(&project_root, &wt_path, branch); assert!( result.is_ok(), "Expected worktree creation to succeed after stale reference, got: {:?}", result.err() ); assert!(wt_path.exists()); } #[test] fn worktree_has_all_files_including_work() { let tmp = TempDir::new().unwrap(); let project_root = tmp.path().join("my-project"); fs::create_dir_all(&project_root).unwrap(); init_git_repo(&project_root); // Create a tracked file under .huskies/work/ on the initial branch let work_dir = project_root.join(".huskies").join("work"); fs::create_dir_all(&work_dir).unwrap(); fs::write(work_dir.join("test_story.md"), "# Test").unwrap(); Command::new("git") .args(["add", "."]) .current_dir(&project_root) .output() .unwrap(); Command::new("git") .args(["commit", "-m", "add work file"]) .current_dir(&project_root) .output() .unwrap(); let wt_path = tmp.path().join("my-worktree"); let branch = "feature/test-sparse"; create_worktree_sync(&project_root, &wt_path, branch).unwrap(); // Worktree should have all files including .huskies/work/ assert!(wt_path.join(".huskies").join("work").exists()); assert!(wt_path.join(".git").exists()); // Main checkout must NOT be affected by worktree creation. assert!( work_dir.exists(), ".huskies/work/ must still exist in the main checkout" ); } #[test] fn remove_worktree_sync_removes_orphaned_directory() { let tmp = TempDir::new().unwrap(); let project_root = tmp.path().join("my-project"); fs::create_dir_all(&project_root).unwrap(); init_git_repo(&project_root); // Create a directory that looks like a worktree but isn't registered with git let wt_path = project_root .join(".huskies") .join("worktrees") .join("orphan"); fs::create_dir_all(&wt_path).unwrap(); fs::write(wt_path.join("some_file.txt"), "stale").unwrap(); assert!(wt_path.exists()); // git worktree remove will fail with "not a working tree", // but the fallback should rm -rf the directory remove_worktree_sync(&project_root, &wt_path, "feature/orphan").unwrap(); assert!(!wt_path.exists()); } #[test] fn remove_worktree_sync_cleans_up_directory() { let tmp = TempDir::new().unwrap(); let project_root = tmp.path().join("my-project"); fs::create_dir_all(&project_root).unwrap(); init_git_repo(&project_root); let wt_path = project_root .join(".huskies") .join("worktrees") .join("test_rm"); create_worktree_sync(&project_root, &wt_path, "feature/test-rm").unwrap(); assert!(wt_path.exists()); remove_worktree_sync(&project_root, &wt_path, "feature/test-rm").unwrap(); assert!(!wt_path.exists()); } #[test] fn prune_worktree_sync_noop_when_no_worktree_dir() { let tmp = TempDir::new().unwrap(); // No worktree directory exists — must return Ok without touching git. let result = prune_worktree_sync(tmp.path(), "42_story_nonexistent"); assert!( result.is_ok(), "Expected Ok when worktree dir absent: {:?}", result.err() ); } #[test] fn prune_worktree_sync_removes_real_worktree() { let tmp = TempDir::new().unwrap(); let project_root = tmp.path().join("my-project"); fs::create_dir_all(&project_root).unwrap(); init_git_repo(&project_root); let story_id = "55_story_prune_test"; let wt_path = super::super::worktree_path(&project_root, story_id); create_worktree_sync( &project_root, &wt_path, &format!("feature/story-{story_id}"), ) .unwrap(); assert!(wt_path.exists(), "worktree dir should exist before prune"); let result = prune_worktree_sync(&project_root, story_id); assert!( result.is_ok(), "prune_worktree_sync must return Ok: {:?}", result.err() ); assert!(!wt_path.exists(), "worktree dir should be gone after prune"); } }