//! Git 节点 — 执行 git CLI 命令(branch/checkout/commit/merge/push/status/log) //! //! 通过 df_execute::shell::execute 调用本地 git CLI,working_dir 指定仓库路径。 //! 不内嵌 git2/libgit2:CLI 路径更通用、调试透明,与 ScriptNode 一致。 use async_trait::async_trait; use df_workflow::node::{Node, NodeContext, NodeOutput, NodeResult, NodeSchema}; /// Git 节点 pub struct GitNode; /// 支持的 Git 动作 #[derive(Debug, Clone, PartialEq, Eq)] pub enum GitAction { Branch, Checkout, Commit, Merge, Push, Status, Log, } impl GitAction { /// 从 config.action 字符串解析动作,非法值返回 Err。 pub fn parse(raw: &str) -> anyhow::Result { match raw { "branch" => Ok(GitAction::Branch), "checkout" => Ok(GitAction::Checkout), "commit" => Ok(GitAction::Commit), "merge" => Ok(GitAction::Merge), "push" => Ok(GitAction::Push), "status" => Ok(GitAction::Status), "log" => Ok(GitAction::Log), other => anyhow::bail!( "GitNode 非法 action: {}(合法值: branch|checkout|commit|merge|push|status|log)", other ), } } fn as_str(&self) -> &'static str { match self { GitAction::Branch => "branch", GitAction::Checkout => "checkout", GitAction::Commit => "commit", GitAction::Merge => "merge", GitAction::Push => "push", GitAction::Status => "status", GitAction::Log => "log", } } } /// 从 NodeContext.config 解析出的 GitNode 参数。 /// 抽离此结构便于单元测试 config 解析逻辑(无需构造完整 NodeContext / 起 shell)。 #[derive(Debug, Clone)] pub struct GitParams { pub action: GitAction, /// 目标分支(branch/checkout/merge) pub branch_name: Option, /// commit 信息(commit) pub message: Option, /// 仓库路径(None=当前目录) pub working_dir: Option, } /// 从 config JSON 解析 GitParams。action 必填;其余按需。 pub fn parse_params(config: &serde_json::Value) -> anyhow::Result { let action = config .get("action") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("GitNode 缺少必填参数: action"))?; let action = GitAction::parse(action)?; let branch_name = config .get("branch_name") .and_then(|v| v.as_str()) .map(|s| s.to_string()); let message = config .get("message") .and_then(|v| v.as_str()) .map(|s| s.to_string()); let working_dir = config .get("working_dir") .and_then(|v| v.as_str()) .map(|s| s.to_string()); Ok(GitParams { action, branch_name, message, working_dir, }) } /// 根据 action + 参数构建 git CLI 命令字符串。working_dir 由 ShellRequest 处理,此处不含 cd。 fn build_command(params: &GitParams) -> anyhow::Result { match params.action { GitAction::Branch => { // 无 branch_name → 列出所有本地分支;有 → 创建新分支 match ¶ms.branch_name { Some(name) => Ok(format!("git branch {}", shell_quote(name))), None => Ok("git branch".to_string()), } } GitAction::Checkout => { let name = params .branch_name .as_deref() .ok_or_else(|| anyhow::anyhow!("GitNode action=checkout 缺少 branch_name"))?; Ok(format!("git checkout {}", shell_quote(name))) } GitAction::Commit => { let msg = params .message .as_deref() .ok_or_else(|| anyhow::anyhow!("GitNode action=commit 缺少 message"))?; Ok(format!("git commit -m {}", shell_quote(msg))) } GitAction::Merge => { let name = params .branch_name .as_deref() .ok_or_else(|| anyhow::anyhow!("GitNode action=merge 缺少 branch_name"))?; Ok(format!("git merge {}", shell_quote(name))) } GitAction::Push => Ok("git push".to_string()), GitAction::Status => Ok("git status".to_string()), GitAction::Log => Ok("git log --oneline -20".to_string()), } } /// 简单 shell 引号包裹:含空格/特殊字符时用双引号包裹并转义内嵌双引号。 /// 仅用于内部参数拼接(branch_name/message),命令名固定白名单不用户可控。 fn shell_quote(s: &str) -> String { if s.chars().any(|c| c.is_whitespace() || c == '"' || c == '$' || c == '`') { format!("\"{}\"", s.replace('"', "\\\"")) } else { s.to_string() } } #[async_trait] impl Node for GitNode { async fn execute(&self, ctx: NodeContext) -> NodeResult { tracing::info!("GitNode 执行: node_id={}", ctx.node_id); let params = parse_params(&ctx.config)?; let command = build_command(¶ms)?; tracing::info!( action = params.action.as_str(), working_dir = ?params.working_dir, "GitNode 构建命令: {}", command ); let request = df_execute::shell::ShellRequest { command, working_dir: params.working_dir.clone(), env: std::collections::HashMap::new(), timeout_secs: Some(60), shell_type: Default::default(), }; let result = df_execute::shell::execute(request).await?; let exit_code = result.exit_code.unwrap_or(-1); if exit_code != 0 { anyhow::bail!( "GitNode 执行失败 (exit_code={}): {}", exit_code, result.stderr.trim() ); } // 按 action 提取结构化字段(branch 列表/commit hash 等),无法解析时回退原始 stdout。 let parsed = parse_action_output(¶ms.action, &result.stdout); tracing::info!( action = params.action.as_str(), exit_code, duration_ms = result.duration_ms, "GitNode 完成" ); Ok(NodeOutput::from_value(serde_json::json!({ "action": params.action.as_str(), "stdout": result.stdout, "stderr": result.stderr, "exit_code": exit_code, "duration_ms": result.duration_ms, "parsed": parsed, }))) } fn schema(&self) -> NodeSchema { NodeSchema { params: serde_json::json!({ "type": "object", "properties": { "action": { "type": "string", "enum": ["branch", "checkout", "commit", "merge", "push", "status", "log"] }, "branch_name": { "type": "string", "description": "目标分支(branch/checkout/merge)" }, "message": { "type": "string", "description": "commit 信息(commit)" }, "working_dir": { "type": "string", "description": "git 仓库路径(可选)" } }, "required": ["action"] }), output: serde_json::json!({ "type": "object", "properties": { "action": { "type": "string" }, "stdout": { "type": "string" }, "stderr": { "type": "string" }, "exit_code": { "type": "integer" }, "duration_ms": { "type": "integer" }, "parsed": { "type": "object" } } }), } } fn node_type(&self) -> &str { "git" } } /// 按 action 解析 stdout 成结构化字段。 /// - branch(无参列出):每行一个分支,`*` 标记当前分支 /// - log:每行一条 `hash subject` /// - commit:首段 hash /// - 其余动作无强结构,返回空对象 fn parse_action_output(action: &GitAction, stdout: &str) -> serde_json::Value { match action { GitAction::Branch => { let branches: Vec<&str> = stdout.lines().map(|l| l.trim_start_matches("* ").trim()).collect(); let current = stdout .lines() .find(|l| l.starts_with('*')) .map(|l| l.trim_start_matches("* ").trim().to_string()); serde_json::json!({ "branches": branches, "current": current }) } GitAction::Log => { let entries: Vec = stdout .lines() .filter_map(|l| { let l = l.trim(); let mut parts = l.splitn(2, ' '); let hash = parts.next()?.to_string(); let subject = parts.next().unwrap_or("").to_string(); Some(serde_json::json!({ "hash": hash, "subject": subject })) }) .collect(); serde_json::json!({ "entries": entries }) } GitAction::Commit => { // `git commit` 默认输出含 `[branch hash]` 形式;取首个 7+ 位十六进制段 let hash = stdout .split_whitespace() .find(|t| t.len() >= 7 && t.chars().all(|c| c.is_ascii_hexdigit())) .unwrap_or("") .to_string(); serde_json::json!({ "commit_hash": hash }) } _ => serde_json::json!({}), } } #[cfg(test)] mod tests { use super::*; use serde_json::json; // ── GitAction::parse ── #[test] fn parse_all_valid_actions() { for raw in ["branch", "checkout", "commit", "merge", "push", "status", "log"] { let a = GitAction::parse(raw).unwrap_or_else(|e| panic!("合法 action {} 应解析成功: {}", raw, e)); assert_eq!(a.as_str(), raw); } } #[test] fn parse_invalid_action_errors() { let err = GitAction::parse("rebase").unwrap_err().to_string(); assert!(err.contains("非法 action"), "实际: {}", err); assert!(err.contains("rebase")); } #[test] fn parse_empty_action_errors() { let err = GitAction::parse("").unwrap_err().to_string(); assert!(err.contains("非法 action"), "实际: {}", err); } // ── parse_params(config) ── #[test] fn params_missing_action_errors() { let err = parse_params(&json!({ "branch_name": "main" })) .unwrap_err() .to_string(); assert!(err.contains("action"), "实际: {}", err); } #[test] fn params_minimal_valid() { let p = parse_params(&json!({ "action": "status" })).unwrap(); assert_eq!(p.action, GitAction::Status); assert_eq!(p.branch_name, None); assert_eq!(p.message, None); assert_eq!(p.working_dir, None); } #[test] fn params_full_fields() { let p = parse_params(&json!({ "action": "commit", "branch_name": "feature/x", "message": "fix: 修复", "working_dir": "/repo" })) .unwrap(); assert_eq!(p.action, GitAction::Commit); assert_eq!(p.branch_name.as_deref(), Some("feature/x")); assert_eq!(p.message.as_deref(), Some("fix: 修复")); assert_eq!(p.working_dir.as_deref(), Some("/repo")); } // ── build_command ── #[test] fn command_branch_list_when_no_name() { let p = parse_params(&json!({ "action": "branch" })).unwrap(); assert_eq!(build_command(&p).unwrap(), "git branch"); } #[test] fn command_branch_create_when_name() { let p = parse_params(&json!({ "action": "branch", "branch_name": "feat" })).unwrap(); assert_eq!(build_command(&p).unwrap(), "git branch feat"); } #[test] fn command_checkout_requires_name() { let p = parse_params(&json!({ "action": "checkout" })).unwrap(); let err = build_command(&p).unwrap_err().to_string(); assert!(err.contains("branch_name"), "实际: {}", err); } #[test] fn command_commit_requires_message() { let p = parse_params(&json!({ "action": "commit" })).unwrap(); let err = build_command(&p).unwrap_err().to_string(); assert!(err.contains("message"), "实际: {}", err); } #[test] fn command_commit_quotes_message_with_space() { let p = parse_params(&json!({ "action": "commit", "message": "fix bug a" })).unwrap(); let cmd = build_command(&p).unwrap(); assert_eq!(cmd, "git commit -m \"fix bug a\""); } #[test] fn command_merge_requires_name() { let p = parse_params(&json!({ "action": "merge" })).unwrap(); assert!(build_command(&p).is_err()); } #[test] fn command_push_status_log_fixed() { for (action, expected) in [ ("push", "git push"), ("status", "git status"), ("log", "git log --oneline -20"), ] { let p = parse_params(&json!({ "action": action })).unwrap(); assert_eq!(build_command(&p).unwrap(), expected); } } // ── shell_quote ── #[test] fn shell_quote_plain_passthrough() { assert_eq!(shell_quote("feat"), "feat"); } #[test] fn shell_quote_escapes_embedded_double_quote() { let q = shell_quote("a\"b"); assert_eq!(q, "\"a\\\"b\""); } // ── parse_action_output ── #[test] fn parse_branch_output() { let out = parse_action_output( &GitAction::Branch, "* main\n develop\n feature/x\n", ); assert_eq!(out["current"], "main"); assert_eq!(out["branches"].as_array().unwrap().len(), 3); } #[test] fn parse_log_output() { let out = parse_action_output( &GitAction::Log, "abc1234 fix bug\n deadbeef add feature\n", ); let entries = out["entries"].as_array().unwrap(); assert_eq!(entries.len(), 2); assert_eq!(entries[0]["hash"], "abc1234"); assert_eq!(entries[1]["subject"], "add feature"); } }