huskies: merge 618_story_extract_mcp_only_domain_services

This commit is contained in:
dave
2026-04-24 21:12:03 +00:00
parent 360bca45c8
commit c16d9e471d
29 changed files with 1924 additions and 409 deletions
+16 -71
View File
@@ -1,68 +1,34 @@
//! MCP git tools — status, diff, add, commit, and log operations on agent worktrees.
//!
//! This file is a thin adapter: it deserialises MCP payloads, delegates to
//! `crate::service::git_ops` for all business logic, and serialises responses.
use crate::http::context::AppContext;
use serde_json::{Value, json};
use std::path::PathBuf;
/// Validates that `worktree_path` exists and is inside the project's
/// `.huskies/worktrees/` directory. Returns the canonicalized path.
///
/// Thin wrapper that obtains the project root from `ctx` and delegates to
/// `service::git_ops::io::validate_worktree_path`.
fn validate_worktree_path(worktree_path: &str, ctx: &AppContext) -> Result<PathBuf, String> {
let wd = PathBuf::from(worktree_path);
if !wd.is_absolute() {
return Err("worktree_path must be an absolute path".to_string());
}
if !wd.exists() {
return Err(format!("worktree_path does not exist: {worktree_path}"));
}
let project_root = ctx.agents.get_project_root(&ctx.state)?;
let worktrees_root = project_root.join(".huskies").join("worktrees");
let canonical_wd = wd
.canonicalize()
.map_err(|e| format!("Cannot canonicalize worktree_path: {e}"))?;
let canonical_wt = if worktrees_root.exists() {
worktrees_root
.canonicalize()
.map_err(|e| format!("Cannot canonicalize worktrees root: {e}"))?
} else {
return Err("No worktrees directory found in project".to_string());
};
if !canonical_wd.starts_with(&canonical_wt) {
return Err(format!(
"worktree_path must be inside .huskies/worktrees/. Got: {worktree_path}"
));
}
Ok(canonical_wd)
crate::service::git_ops::io::validate_worktree_path(worktree_path, &project_root)
.map_err(|e| e.to_string())
}
/// Run a git command in the given directory and return its output.
async fn run_git(args: Vec<&'static str>, dir: PathBuf) -> Result<std::process::Output, String> {
tokio::task::spawn_blocking(move || {
std::process::Command::new("git")
.args(&args)
.current_dir(&dir)
.output()
})
.await
.map_err(|e| format!("Task join error: {e}"))?
.map_err(|e| format!("Failed to run git: {e}"))
crate::service::git_ops::io::run_git(args, dir)
.await
.map_err(|e| e.to_string())
}
/// Run a git command with owned args in the given directory.
async fn run_git_owned(args: Vec<String>, dir: PathBuf) -> Result<std::process::Output, String> {
tokio::task::spawn_blocking(move || {
std::process::Command::new("git")
.args(&args)
.current_dir(&dir)
.output()
})
.await
.map_err(|e| format!("Task join error: {e}"))?
.map_err(|e| format!("Failed to run git: {e}"))
crate::service::git_ops::io::run_git_owned(args, dir)
.await
.map_err(|e| e.to_string())
}
/// git_status — returns working tree status (staged, unstaged, untracked files).
@@ -86,29 +52,8 @@ pub(super) async fn tool_git_status(args: &Value, ctx: &AppContext) -> Result<St
));
}
let mut staged: Vec<String> = Vec::new();
let mut unstaged: Vec<String> = Vec::new();
let mut untracked: Vec<String> = Vec::new();
for line in stdout.lines() {
if line.len() < 3 {
continue;
}
let x = line.chars().next().unwrap_or(' ');
let y = line.chars().nth(1).unwrap_or(' ');
let path = line[3..].to_string();
match (x, y) {
('?', '?') => untracked.push(path),
(' ', _) => unstaged.push(path),
(_, ' ') => staged.push(path),
_ => {
// Both staged and unstaged modifications
staged.push(path.clone());
unstaged.push(path);
}
}
}
let (staged, unstaged, untracked) =
crate::service::git_ops::parse_git_status_porcelain(&stdout);
serde_json::to_string_pretty(&json!({
"staged": staged,