//! Git worktree management — creates, lists, and removes worktrees for agent isolation. use std::path::{Path, PathBuf}; mod cleanup; mod create; mod git; mod remove; mod sweep; pub use cleanup::{format_report, run_cleanup}; pub use create::create_worktree; pub use create::install_pre_commit_hook; pub(crate) use git::detect_base_branch; pub use git::migrate_slug_paths; pub use remove::remove_worktree_by_story_id; #[derive(Debug, Clone)] /// Details about a newly created worktree: path, branch, and base branch. pub struct WorktreeInfo { pub path: PathBuf, pub branch: String, pub base_branch: String, } #[derive(Debug, Clone)] /// A discovered worktree on disk: its story ID and filesystem path. pub struct WorktreeListEntry { pub story_id: String, pub path: PathBuf, } /// Worktree path inside the project: `{project_root}/.huskies/worktrees/{story_id}`. pub fn worktree_path(project_root: &Path, story_id: &str) -> PathBuf { project_root .join(".huskies") .join("worktrees") .join(story_id) } /// Write a `.mcp.json` file in the given directory pointing to the huskies /// HTTP MCP endpoint at the given port. pub fn write_mcp_json(dir: &Path, port: u16) -> Result<(), String> { let content = format!( "{{\n \"mcpServers\": {{\n \"huskies\": {{\n \"type\": \"http\",\n \"url\": \"http://localhost:{port}/mcp\"\n }}\n }}\n}}\n" ); std::fs::write(dir.join(".mcp.json"), content).map_err(|e| format!("Write .mcp.json: {e}")) } /// Find the worktree path for a given story ID, if it exists. pub fn find_worktree_path(project_root: &Path, story_id: &str) -> Option { let wt_path = project_root .join(".huskies") .join("worktrees") .join(story_id); if wt_path.is_dir() { Some(wt_path) } else { None } } /// List all worktrees under `{project_root}/.huskies/worktrees/`. pub fn list_worktrees(project_root: &Path) -> Result, String> { let worktrees_dir = project_root.join(".huskies").join("worktrees"); if !worktrees_dir.exists() { return Ok(Vec::new()); } let mut entries = Vec::new(); for entry in std::fs::read_dir(&worktrees_dir).map_err(|e| format!("list worktrees: {e}"))? { let entry = entry.map_err(|e| format!("list worktrees entry: {e}"))?; let path = entry.path(); if path.is_dir() { let story_id = path .file_name() .map(|n| n.to_string_lossy().to_string()) .unwrap_or_default(); entries.push(WorktreeListEntry { story_id, path }); } } entries.sort_by(|a, b| a.story_id.cmp(&b.story_id)); Ok(entries) } #[cfg(test)] mod tests { use super::*; use std::fs; use tempfile::TempDir; #[test] fn write_mcp_json_uses_given_port() { let tmp = TempDir::new().unwrap(); write_mcp_json(tmp.path(), 4242).unwrap(); let content = std::fs::read_to_string(tmp.path().join(".mcp.json")).unwrap(); assert!(content.contains("http://localhost:4242/mcp")); } #[test] fn write_mcp_json_default_port() { let tmp = TempDir::new().unwrap(); write_mcp_json(tmp.path(), 3001).unwrap(); let content = std::fs::read_to_string(tmp.path().join(".mcp.json")).unwrap(); assert!(content.contains("http://localhost:3001/mcp")); } #[test] fn worktree_path_is_inside_project() { let project_root = Path::new("/home/user/my-project"); let path = worktree_path(project_root, "42_my_story"); assert_eq!( path, Path::new("/home/user/my-project/.huskies/worktrees/42_my_story") ); } #[test] fn list_worktrees_empty_when_no_dir() { let tmp = TempDir::new().unwrap(); let entries = list_worktrees(tmp.path()).unwrap(); assert!(entries.is_empty()); } #[test] fn list_worktrees_returns_subdirs() { let tmp = TempDir::new().unwrap(); let worktrees_dir = tmp.path().join(".huskies").join("worktrees"); fs::create_dir_all(worktrees_dir.join("42_story_a")).unwrap(); fs::create_dir_all(worktrees_dir.join("43_story_b")).unwrap(); // A file (not dir) — should be ignored fs::write(worktrees_dir.join("readme.txt"), "").unwrap(); let entries = list_worktrees(tmp.path()).unwrap(); assert_eq!(entries.len(), 2); assert_eq!(entries[0].story_id, "42_story_a"); assert_eq!(entries[1].story_id, "43_story_b"); } }