Files
huskies/server/src/worktree/mod.rs
T

139 lines
4.5 KiB
Rust
Raw Normal View History

2026-04-28 14:01:24 +00:00
//! Git worktree management — creates, lists, and removes worktrees for agent isolation.
use std::path::{Path, PathBuf};
2026-04-29 13:38:34 +00:00
mod cleanup;
2026-04-28 14:01:24 +00:00
mod create;
mod git;
mod remove;
2026-04-29 10:28:18 +00:00
mod sweep;
2026-04-28 14:01:24 +00:00
2026-04-29 13:38:34 +00:00
pub use cleanup::{format_report, run_cleanup};
2026-04-28 14:01:24 +00:00
pub use create::create_worktree;
2026-05-13 14:38:55 +00:00
pub use create::install_pre_commit_hook;
2026-05-13 21:37:07 +00:00
pub(crate) use git::detect_base_branch;
2026-04-29 21:14:27 +00:00
pub use git::migrate_slug_paths;
2026-04-28 14:01:24 +00:00
pub use remove::remove_worktree_by_story_id;
2026-04-29 10:28:18 +00:00
pub use sweep::sweep_orphaned_worktrees;
2026-04-28 14:01:24 +00:00
#[derive(Debug, Clone)]
2026-04-29 10:41:32 +00:00
/// Details about a newly created worktree: path, branch, and base branch.
2026-04-28 14:01:24 +00:00
pub struct WorktreeInfo {
pub path: PathBuf,
pub branch: String,
pub base_branch: String,
}
#[derive(Debug, Clone)]
2026-04-29 10:41:32 +00:00
/// A discovered worktree on disk: its story ID and filesystem path.
2026-04-28 14:01:24 +00:00
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)
}
2026-04-29 21:35:55 +00:00
/// Write a `.mcp.json` file in the given directory pointing to the huskies
/// HTTP MCP endpoint at the given port.
2026-04-28 14:01:24 +00:00
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"
2026-04-28 14:01:24 +00:00
);
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<PathBuf> {
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<Vec<WorktreeListEntry>, 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"));
2026-04-28 14:01:24 +00:00
}
#[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"));
2026-04-28 14:01:24 +00:00
}
#[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");
}
}