//! Git 类 AI 工具声明式注册(register_git_tools 6 个迁入)。 //! //! 迁自 `tool_registry.rs::register_git_tools`(原 6 个:git_status/git_diff/git_log 只读 + //! git_commit/git_branch/git_merge 写),改用 `declare_tool!` 宏。 //! //! 迁移策略(handler 逻辑零变更): //! - handler body 逐字照搬原 `register_git_tools` 内 async move 块(逻辑等价), //! 仅闭包包装(`{ let db = db.clone(); Box::new(move |args| { let db = db.clone(); //! Box::pin(async move { ... }) }) }`)改由 `declare_tool!` 宏生成。 //! - name/desc/schema/risk 与原手写定义逐字一致。 //! - 配套私有 helper(exec_git/run_git_status/run_git_diff/run_git_log)原样搬入本文件, //! 仅 register_git_tools 使用(external module.rs::run_git_status 是同名不同签名的另一函数)。 //! //! 等价性验证:基线测试 `test_build_ai_tool_registry_baseline_tool_count` 仍断言 48 总量 + //! 工具名集合稳定(防 rename / 漏注册)。 use std::sync::Arc; use df_ai::ai_tools::{object_schema, AiToolRegistry, RiskLevel}; use df_ai::declare_tool; use df_storage::db::Database; // ============================================================ // Git 私有 helper(原 tool_registry.rs 986-1080 行原样搬入,仅本模块使用) // ============================================================ /// 在指定目录执行 git 命令(10s 超时,返回 (stdout, success))。 /// /// 返回 (stdout, success): /// - success = child 退出码为 0(status.success()); /// - 失败时 stdout 含 stderr 内容(便于上层拼 reason);超时/启动失败 → (空串, false)。 /// /// BUG-2026-07-18: 原实现 spawn_blocking 内裸 std::process::Command::output() 无 timeout /// (注释谎称"10s 超时")。git 在 OneDrive/网盘/挂载盘/lfs/大仓库场景会卡数十秒到无限, /// spawn_blocking 线程永不返回 → 累计耗尽 tokio blocking 池 → 间接卡死单线程 runtime /// (与 env_snapshot::probe_version 同型病根)。AI 的 git_status/log/diff 工具在会话内高频触发。 /// /// 改用 tokio::process + tokio::time::timeout(10s) + kill_on_drop:超时 drop 时 child 进程 /// 被 kill,不泄漏线程/进程,对齐 shell.rs execute 同源封装。 /// /// BUG-2026-08-02: 原签名返 String,失败/超时统一吞成空串,使 create/switch/commit 三个写操作 /// 恒返成功假象(commit 失败 HEAD 不动,log -1 仍返上次 hash → committed:true)。 /// 改返 (String, bool),上层据 success 判定真实成败。 async fn exec_git(working_dir: &str, args: &[&str]) -> (String, bool) { let mut cmd = tokio::process::Command::new("git"); cmd.args(args) .current_dir(working_dir) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()) // 捕获 stderr 拼 reason(失败时) .kill_on_drop(true); // Windows 屏蔽控制台窗口弹出。tokio::process::Command 在 Windows 上有 inherent // creation_flags(无需 std::os::windows::process::CommandExt trait,故不 use)。 #[cfg(windows)] { cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW } match tokio::time::timeout(std::time::Duration::from_secs(10), cmd.output()).await { Ok(Ok(out)) => { let success = out.status.success(); // 失败时把 stderr 拼进 stdout 返回(上层据 success=false 读 reason) let stdout = String::from_utf8_lossy(&out.stdout).to_string(); if success { (stdout, true) } else { let stderr = String::from_utf8_lossy(&out.stderr).to_string(); if stderr.is_empty() { (stdout, false) } else { (stderr, false) } } } Ok(Err(_)) => (String::new(), false), Err(_elapsed) => (String::new(), false), // 超时:child 被 kill_on_drop 终止 } } /// git status --porcelain 解析为结构化文件列表。 /// 返回 (当前分支, 改动文件列表 [{path, status}]) async fn run_git_status(working_dir: &str) -> (String, Vec) { let branch = exec_git(working_dir, &["branch", "--show-current"]).await.0; let branch = branch.trim().to_string(); let raw = exec_git(working_dir, &["status", "--porcelain"]).await.0; let files: Vec = raw .lines() .filter(|l| !l.is_empty()) .map(|line| { // porcelain 格式:"XY filename",XY 为两位状态码 let status = line.chars().take(2).collect::(); let path = line.get(3..).unwrap_or("").trim().to_string(); // 简化状态码:M/A/D/??/R let simple = if status.starts_with("??") { "??" } else if status.contains('A') { "A" } else if status.contains('D') { "D" } else if status.contains('R') { "R" } else { "M" }; serde_json::json!({ "path": path, "status": simple }) }) .collect(); (branch, files) } /// git diff 解析为结构化文件列表(每文件统计 + patch 截断)。 async fn run_git_diff(working_dir: &str, staged: bool) -> serde_json::Value { let mut args = vec!["diff", "--stat"]; if staged { args.push("--cached"); } let stat_raw = exec_git(working_dir, &args).await.0; // 每文件 patch(截断防 token 爆) let mut patch_args = vec!["diff" ]; if staged { patch_args.push("--cached"); } let patch_raw = exec_git(working_dir, &patch_args).await.0; // 截断到 8000 字符(防大体量 diff) let patch_truncated = if patch_raw.len() > 8000 { format!("{}\n... (diff 截断,共 {} 字符)", &patch_raw[..8000], patch_raw.len()) } else { patch_raw }; serde_json::json!({ "stat": stat_raw, "patch": patch_truncated, }) } /// git log 解析为结构化提交列表。 async fn run_git_log(working_dir: &str, limit: usize) -> Vec { let format = "%H|%an|%ad|%s"; let limit_str = format!("-{}", limit); let raw = exec_git(working_dir, &["log", "--oneline", &format!("--format={}", format), &limit_str, "--date=short"]).await.0; raw.lines() .filter(|l| !l.is_empty()) .filter_map(|line| { let parts: Vec<&str> = line.splitn(4, '|').collect(); if parts.len() == 4 { Some(serde_json::json!({ "hash": parts[0], "author": parts[1], "date": parts[2], "message": parts[3], })) } else { None } }) .collect() } /// Git AI 工具注册(6 个:status/diff/log 只读 Low + commit/branch 写 Medium + merge High)。 /// /// 与原手写 register(name, desc, schema, risk, handler) 语义 1:1: /// - name/desc/schema 字符串与 JSON Schema 逐字照搬原定义 /// - risk 与原一致(status/diff/log=Low,commit/branch=Medium,merge=High) /// - handler body 与原 async move 块逐字一致(逻辑零变更) /// /// 唯一差异:闭包包装改由 `declare_tool!` 宏生成,handler body 直接写业务逻辑。 pub fn register(registry: &mut AiToolRegistry, db: &Arc) { // ── git_status (Low 只读) ── declare_tool!( registry, db: Arc, "git_status", "查看工程 Git 工作区状态。参数:module_id(工程 ID)。返回当前分支、改动文件列表(每个文件含路径+状态 M/A/D/??)、改动总数。只读无副作用", RiskLevel::Low, schema: object_schema(vec![ ("module_id", "string", true), ]), args => { let module_id = args["module_id"].as_str() .ok_or_else(|| anyhow::anyhow!("缺少 module_id"))?; let repo = df_storage::crud::ProjectModuleRepo::new(&db); let module = repo.get_by_id(module_id).await? .ok_or_else(|| anyhow::anyhow!("工程不存在: {}", module_id))?; let (branch, files) = run_git_status(&module.path).await; Ok(serde_json::json!({ "branch": branch, "files": files, "total_changes": files.len(), })) } ); // ── git_diff (Low 只读) ── declare_tool!( registry, db: Arc, "git_diff", "查看工程未提交的代码改动。参数:module_id(工程 ID)、staged(可选 bool,仅看已暂存改动,默认 false=全部含未暂存)。返回改动统计 + patch 内容(截断防 token 爆)。只读无副作用", RiskLevel::Low, schema: object_schema(vec![ ("module_id", "string", true), ("staged", "boolean", false), ]), args => { let module_id = args["module_id"].as_str() .ok_or_else(|| anyhow::anyhow!("缺少 module_id"))?; let staged = args.get("staged").and_then(|v| v.as_bool()).unwrap_or(false); let repo = df_storage::crud::ProjectModuleRepo::new(&db); let module = repo.get_by_id(module_id).await? .ok_or_else(|| anyhow::anyhow!("工程不存在: {}", module_id))?; let diff = run_git_diff(&module.path, staged).await; Ok(diff) } ); // ── git_log (Low 只读) ── declare_tool!( registry, db: Arc, "git_log", "查看工程 Git 提交历史。参数:module_id(工程 ID)、limit(可选 int,最近 N 条提交,默认 20,最大 100)。返回提交列表(每个含哈希/作者/消息/日期)。只读无副作用", RiskLevel::Low, schema: object_schema(vec![ ("module_id", "string", true), ("limit", "integer", false), ]), args => { let module_id = args["module_id"].as_str() .ok_or_else(|| anyhow::anyhow!("缺少 module_id"))?; let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(20).min(100) as usize; let repo = df_storage::crud::ProjectModuleRepo::new(&db); let module = repo.get_by_id(module_id).await? .ok_or_else(|| anyhow::anyhow!("工程不存在: {}", module_id))?; let commits = run_git_log(&module.path, limit).await; Ok(serde_json::json!({ "commits": commits })) } ); // ── git_commit (Medium 审批) ── // 提交工作区改动。需审批(写操作影响代码仓库)。 // 安全边界:不提交敏感文件(.env/.key/.pem,由 .gitignore 兜底 + 此处检查)。 declare_tool!( registry, db: Arc, "git_commit", "提交工程工作区改动到本地仓库。参数:module_id(工程 ID)、message(提交信息,必填)、add_all(可选 bool,是否添加全部改动到暂存区,默认 true)。中等风险,需审批。提交前检查不提交敏感文件(.env/.key/.pem)", RiskLevel::Medium, schema: object_schema(vec![ ("module_id", "string", true), ("message", "string", true), ("add_all", "boolean", false), ]), args => { let module_id = args["module_id"].as_str() .ok_or_else(|| anyhow::anyhow!("缺少 module_id"))?; let message = args["message"].as_str() .ok_or_else(|| anyhow::anyhow!("缺少 message"))?; let add_all = args.get("add_all").and_then(|v| v.as_bool()).unwrap_or(true); if message.trim().is_empty() { anyhow::bail!("提交信息不能为空"); } let repo = df_storage::crud::ProjectModuleRepo::new(&db); let module = repo.get_by_id(module_id).await? .ok_or_else(|| anyhow::anyhow!("工程不存在: {}", module_id))?; // 敏感文件检查:提交前查看待提交文件列表,如有 .env/.key/.pem 则拒绝 let status_raw = exec_git(&module.path, &["status", "--porcelain"]).await.0; let sensitive_patterns = [".env", ".key", ".pem", "id_rsa", ".htpasswd"]; for line in status_raw.lines() { let path = line.get(3..).unwrap_or("").trim().to_lowercase(); if sensitive_patterns.iter().any(|p| path.contains(p)) { anyhow::bail!("检测到敏感文件在待提交列表中: {}。请手动检查 .gitignore 或移除该文件后重试", line.get(3..).unwrap_or("")); } } // git add if add_all { let _ = exec_git(&module.path, &["add", "-A"]).await; } // git commit(用 commit 命令的 success 判定 committed,根治假成功) let (commit_out, commit_ok) = exec_git(&module.path, &["commit", "-m", message]).await; // 提交后取最新 commit hash(失败时仍取,留作参考;committed 只认 commit_ok) let hash = exec_git(&module.path, &["log", "-1", "--format=%H"]).await.0.trim().to_string(); // 无可提交内容时 commit 退出码非 0 + stderr 含 "nothing to commit" let reason = if !commit_ok && commit_out.contains("nothing to commit") { "nothing to commit, working tree clean".to_string() } else if !commit_ok { commit_out.trim().to_string() } else { String::new() }; Ok(serde_json::json!({ "committed": commit_ok, "hash": hash, "message": message, "reason": reason, "output": if commit_ok { String::new() } else { commit_out }, })) } ); // ── git_branch (Medium 审批) ── // 分支管理:列表/创建/切换。需审批(影响工作区分支状态)。 declare_tool!( registry, db: Arc, "git_branch", "工程分支管理。参数:module_id(工程 ID)、action(list/create/switch,默认 list)、name(分支名,create/switch 时必填)。中等风险,需审批。list=列出所有分支+当前分支,create=创建新分支,switch=切换分支", RiskLevel::Medium, schema: object_schema(vec![ ("module_id", "string", true), ("action", "string", false), ("name", "string", false), ]), args => { let module_id = args["module_id"].as_str() .ok_or_else(|| anyhow::anyhow!("缺少 module_id"))?; let action = args.get("action").and_then(|v| v.as_str()).unwrap_or("list"); let name = args.get("name").and_then(|v| v.as_str()).unwrap_or(""); let repo = df_storage::crud::ProjectModuleRepo::new(&db); let module = repo.get_by_id(module_id).await? .ok_or_else(|| anyhow::anyhow!("工程不存在: {}", module_id))?; match action { "list" => { let raw = exec_git(&module.path, &["branch", "--list"]).await.0; let current = exec_git(&module.path, &["branch", "--show-current"]).await.0.trim().to_string(); let branches: Vec = raw.lines() .map(|l| l.trim_start_matches("* ").trim().to_string()) .filter(|s| !s.is_empty()) .collect(); Ok(serde_json::json!({ "current": current, "branches": branches })) } "create" => { if name.is_empty() { anyhow::bail!("create 需要 name 参数"); } let (msg, ok) = exec_git(&module.path, &["branch", name]).await; if ok { Ok(serde_json::json!({ "created": true, "name": name })) } else { Ok(serde_json::json!({ "created": false, "name": name, "reason": msg.trim(), })) } } "switch" => { if name.is_empty() { anyhow::bail!("switch 需要 name 参数"); } let (_, checkout_ok) = exec_git(&module.path, &["checkout", name]).await; let current = exec_git(&module.path, &["branch", "--show-current"]).await.0.trim().to_string(); if checkout_ok && current == name { Ok(serde_json::json!({ "switched": true, "current": current, })) } else { Ok(serde_json::json!({ "switched": false, "current": current, "name": name, "reason": if current == name { "checkout 未改变分支".to_string() } else { format!("切换失败,当前仍在 {}", if current.is_empty() { "(空)" } else { ¤t }) }, })) } } _ => anyhow::bail!("未知 action: {},合法值: list/create/switch", action), } } ); // ── git_merge (High 审批) ── // 合并分支。高风险(可能产生冲突,影响代码完整性)。 declare_tool!( registry, db: Arc, "git_merge", "合并指定分支到当前分支。参数:module_id(工程 ID)、branch(要合并的分支名,必填)。高风险,需审批。合并冲突时返回冲突文件列表,需用户手动解决", RiskLevel::High, schema: object_schema(vec![ ("module_id", "string", true), ("branch", "string", true), ]), args => { let module_id = args["module_id"].as_str() .ok_or_else(|| anyhow::anyhow!("缺少 module_id"))?; let branch = args["branch"].as_str() .ok_or_else(|| anyhow::anyhow!("缺少 branch"))?; let repo = df_storage::crud::ProjectModuleRepo::new(&db); let module = repo.get_by_id(module_id).await? .ok_or_else(|| anyhow::anyhow!("工程不存在: {}", module_id))?; let (merge_out, merge_ok) = exec_git(&module.path, &["merge", branch]).await; let has_conflict = merge_out.contains("CONFLICT") || merge_out.contains("Merge conflict"); if has_conflict { let status_raw = exec_git(&module.path, &["status", "--porcelain"]).await.0; let conflicts: Vec = status_raw.lines() .filter(|l| l.starts_with("UU") || l.starts_with("AA") || l.starts_with("DD")) .map(|l| l.get(3..).unwrap_or("").trim().to_string()) .collect(); Ok(serde_json::json!({ "merged": false, "conflicts": conflicts, "message": "合并冲突,需手动解决", })) } else if merge_ok { Ok(serde_json::json!({ "merged": true, "branch": branch, "output": merge_out, })) } else { // 非冲突但 merge 退出码非 0(如 fast-forward 失败/被中断),如实暴露失败 Ok(serde_json::json!({ "merged": false, "branch": branch, "reason": merge_out.trim(), })) } } ); }