Files
DevFlow/crates/df-nodes/src/git_node.rs
绝尘 f40287bb00 新增: 模板系统+工作流节点(YAML加载器+GitNode/HTTPNode/NotifyNode)
- template_loader.rs: YAML→DagDef加载器+校验(空节点/未知类型/依赖环/边引用)

- GitNode: 分支/checkout/commit/merge/push/status/log(git CLI封装)

- HTTPNode: GET/POST/PUT/DELETE(reqwest,30s超时)

- NotifyNode: 桌面通知(日志)+ Webhook(飞书/钉钉/自定义)

- 8个模板加载器测试+37个节点测试全绿
2026-07-01 22:45:56 +08:00

427 lines
14 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<Self> {
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<String>,
/// commit 信息(commit)
pub message: Option<String>,
/// 仓库路径(None=当前目录)
pub working_dir: Option<String>,
}
/// 从 config JSON 解析 GitParams。action 必填;其余按需。
pub fn parse_params(config: &serde_json::Value) -> anyhow::Result<GitParams> {
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<String> {
match params.action {
GitAction::Branch => {
// 无 branch_name → 列出所有本地分支;有 → 创建新分支
match &params.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(&params)?;
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(&params.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<serde_json::Value> = 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");
}
}