feat: git 工具升级为全操作支持(16 个子操作)

git 工具支持的操作:
- status: 查看状态(分支/修改/暂存/未追踪/删除/重命名)
- log: 提交历史
- diff: 差异查看(支持 staged)
- add: 暂存文件
- commit: 提交
- push/pull: 推送/拉取
- branch: 列出/创建/删除分支
- checkout: 切换分支
- merge: 合并分支
- stash: 暂存更改
- remote: 查看/添加远程仓库
- clone: 克隆仓库
- init: 初始化仓库
- reset: 重置
- tag: 标签管理

全部自动执行,无需用户确认
This commit is contained in:
OpenClaw Agent
2026-04-16 09:55:33 +08:00
parent 396100335a
commit 80158cb5bf
4 changed files with 213 additions and 40 deletions
+2 -2
View File
@@ -32,7 +32,7 @@ import {
handleDiffFiles,
handleReplaceInFiles,
handleReadMultipleFiles,
handleGitStatus,
handleGit,
handleCompress
} from './tool-handlers.js';
import { getAllowedDirs, getBlockedDirs, setAllowedDirs } from './tool-security.js';
@@ -132,7 +132,7 @@ export function setupIPC(): void {
case 'diff_files': return await handleDiffFiles(args as { file1: string; file2: string; context_lines?: number });
case 'replace_in_files': return await handleReplaceInFiles(args as { path: string; glob: string; old_text: string; new_text: string });
case 'read_multiple_files':return await handleReadMultipleFiles(args as { paths: string[]; max_chars_per_file?: number });
case 'git_status': return await handleGitStatus(args as { path?: string });
case 'git': return await handleGit(args as any);
case 'compress': return await handleCompress(args as { action: string; path: string; destination?: string; format?: string });
default: return { success: false, error: `未知工具: ${toolName}` };
}
+189 -29
View File
@@ -799,31 +799,37 @@ export async function handleReadMultipleFiles(params: { paths: string[]; max_cha
}
}
export async function handleGitStatus(params: { path?: string }): Promise<ToolResult> {
export async function handleGit(params: { action: string; path?: string; files?: string[]; message?: string; branch?: string; remote?: string; remote_url?: string; count?: number; all?: boolean; staged?: boolean; new_branch?: boolean; delete_branch?: boolean; force?: boolean; url?: string }): Promise<ToolResult> {
try {
const cwd = params.path ? path.resolve(params.path) : getWorkspaceDir();
return new Promise((resolve) => {
const git = spawn('git', ['status', '--porcelain=v1', '-b'], { cwd, stdio: ['pipe', 'pipe', 'pipe'] });
let stdout = '';
let stderr = '';
async function runGit(args: string[]): Promise<{ stdout: string; stderr: string; code: number }> {
return new Promise((resolve) => {
const git = spawn('git', args, { cwd, stdio: ['pipe', 'pipe', 'pipe'], env: { ...process.env, LANG: 'en_US.UTF-8' } });
let stdout = '';
let stderr = '';
git.stdout.on('data', (d: Buffer) => { stdout += d.toString(); });
git.stderr.on('data', (d: Buffer) => { stderr += d.toString(); });
git.on('close', (code) => resolve({ stdout, stderr, code: code ?? 1 }));
git.on('error', (err) => resolve({ stdout, stderr: err.message, code: 1 }));
});
}
git.stdout.on('data', (d: Buffer) => { stdout += d.toString(); });
git.stderr.on('data', (d: Buffer) => { stderr += d.toString(); });
switch (params.action) {
case 'status': {
const r = await runGit(['status', '--porcelain=v1', '-b']);
if (r.code !== 0) return { success: false, error: r.stderr || '不是 git 仓库' };
git.on('close', (code) => {
if (code !== 0) {
resolve({ success: false, error: stderr || '不是 git 仓库' });
return;
}
const lines = stdout.split('\n').filter(l => l.trim());
const lines = r.stdout.split('\n').filter(l => l.trim());
const branchLine = lines.find(l => l.startsWith('##')) || '';
const branch = branchLine.replace('## ', '').split('...')[0] || 'unknown';
const tracking = branchLine.includes('...') ? branchLine.split('...')[1] : '';
const modified: string[] = [];
const staged: string[] = [];
const untracked: string[] = [];
const deleted: string[] = [];
const renamed: string[] = [];
for (const line of lines) {
if (line.startsWith('##')) continue;
@@ -831,25 +837,179 @@ export async function handleGitStatus(params: { path?: string }): Promise<ToolRe
const file = line.slice(3);
if (status === '??') untracked.push(file);
else {
if (status[0] !== ' ' && status[0] !== '?') staged.push(file);
if (status[1] !== ' ' && status[1] !== '?') modified.push(file);
if (status[0] === 'D') deleted.push(file);
else if (status[0] === 'R') renamed.push(file);
else if (status[0] !== ' ' && status[0] !== '?') staged.push(file);
if (status[1] === 'M') modified.push(file);
else if (status[1] === 'D') deleted.push(file);
}
}
resolve({
success: true,
branch,
modified,
staged,
untracked,
isClean: modified.length === 0 && staged.length === 0 && untracked.length === 0
});
});
return {
success: true, branch, tracking, modified, staged, untracked, deleted, renamed,
isClean: modified.length === 0 && staged.length === 0 && untracked.length === 0 && deleted.length === 0
};
}
git.on('error', (err) => {
resolve({ success: false, error: err.message });
});
});
case 'log': {
const count = params.count || 20;
const format = '--oneline';
const r = await runGit(['log', format, `--max-count=${count}`, ...(params.all ? ['--all'] : [])]);
if (r.code !== 0) return { success: false, error: r.stderr };
const commits = r.stdout.split('\n').filter(l => l.trim()).map(l => {
const spaceIdx = l.indexOf(' ');
return { hash: l.slice(0, spaceIdx), message: l.slice(spaceIdx + 1) };
});
return { success: true, commits, total: commits.length };
}
case 'diff': {
const args = ['diff'];
if (params.staged) args.push('--cached');
if (params.files?.length) args.push('--', ...params.files);
const r = await runGit(args);
if (r.code !== 0) return { success: false, error: r.stderr };
const hasChanges = r.stdout.trim().length > 0;
return { success: true, diff: r.stdout, hasChanges, staged: !!params.staged };
}
case 'add': {
if (!params.files?.length) return { success: false, error: '请指定要暂存的文件' };
const r = await runGit(['add', ...params.files]);
if (r.code !== 0) return { success: false, error: r.stderr };
return { success: true, action: 'add', files: params.files };
}
case 'commit': {
if (!params.message) return { success: false, error: '请提供提交信息' };
const r = await runGit(['commit', '-m', params.message]);
if (r.code !== 0) return { success: false, error: r.stderr || '提交失败(可能没有暂存的更改)' };
return { success: true, action: 'commit', message: params.message, output: r.stdout.trim() };
}
case 'push': {
const args = ['push'];
if (params.remote) args.push(params.remote);
if (params.branch) args.push(params.branch);
if (params.force) args.push('--force');
const r = await runGit(args);
if (r.code !== 0) return { success: false, error: r.stderr };
return { success: true, action: 'push', output: r.stderr.trim() || r.stdout.trim() };
}
case 'pull': {
const args = ['pull'];
if (params.remote) args.push(params.remote);
if (params.branch) args.push(params.branch);
const r = await runGit(args);
if (r.code !== 0) return { success: false, error: r.stderr };
return { success: true, action: 'pull', output: r.stdout.trim() };
}
case 'branch': {
if (params.new_branch && params.branch) {
const r = await runGit(['checkout', '-b', params.branch]);
if (r.code !== 0) return { success: false, error: r.stderr };
return { success: true, action: 'branch', created: params.branch };
}
if (params.delete_branch && params.branch) {
const r = await runGit(['branch', '-d', params.branch]);
if (r.code !== 0) return { success: false, error: r.stderr };
return { success: true, action: 'branch', deleted: params.branch };
}
const r = await runGit(['branch', ...(params.all ? ['-a'] : [])]);
if (r.code !== 0) return { success: false, error: r.stderr };
const branches = r.stdout.split('\n').filter(l => l.trim()).map(l => ({
name: l.replace(/^\*?\s*/, '').trim(),
current: l.startsWith('*')
}));
return { success: true, action: 'branch', branches };
}
case 'checkout': {
if (!params.branch) return { success: false, error: '请指定分支名' };
const args = ['checkout'];
if (params.force) args.push('-f');
args.push(params.branch);
const r = await runGit(args);
if (r.code !== 0) return { success: false, error: r.stderr };
return { success: true, action: 'checkout', branch: params.branch };
}
case 'merge': {
if (!params.branch) return { success: false, error: '请指定要合并的分支' };
const r = await runGit(['merge', params.branch]);
if (r.code !== 0) return { success: false, error: r.stderr || '合并冲突' };
return { success: true, action: 'merge', branch: params.branch, output: r.stdout.trim() };
}
case 'stash': {
const subAction = params.message || 'push';
const args = ['stash', subAction];
if (subAction === 'push' && params.message) args.push('-m', params.message);
const r = await runGit(args);
if (r.code !== 0) return { success: false, error: r.stderr };
return { success: true, action: 'stash', subAction, output: r.stdout.trim() };
}
case 'remote': {
if (params.remote_url && params.remote) {
const r = await runGit(['remote', 'add', params.remote, params.remote_url]);
if (r.code !== 0) return { success: false, error: r.stderr };
return { success: true, action: 'remote', added: params.remote, url: params.remote_url };
}
const args = ['remote', '-v'];
const r = await runGit(args);
if (r.code !== 0) return { success: false, error: r.stderr };
const remotes = r.stdout.split('\n').filter(l => l.trim()).map(l => {
const [name, url] = l.split(/\s+/);
return { name, url };
});
return { success: true, action: 'remote', remotes };
}
case 'clone': {
if (!params.url) return { success: false, error: '请提供仓库 URL' };
const destDir = params.path ? path.resolve(params.path) : getWorkspaceDir();
const r = await runGit(['clone', params.url, destDir]);
if (r.code !== 0) return { success: false, error: r.stderr };
return { success: true, action: 'clone', url: params.url, destination: destDir };
}
case 'init': {
const r = await runGit(['init']);
if (r.code !== 0) return { success: false, error: r.stderr };
return { success: true, action: 'init', output: r.stdout.trim() };
}
case 'reset': {
const args = ['reset'];
if (params.staged) args.push('--cached');
if (params.force) args.push('--hard');
if (params.files?.length) args.push('--', ...params.files);
const r = await runGit(args);
if (r.code !== 0) return { success: false, error: r.stderr };
return { success: true, action: 'reset', output: r.stdout.trim() };
}
case 'tag': {
if (params.branch) {
// branch field reused as tag name
const args = ['tag', params.branch];
if (params.message) args.push('-m', params.message);
const r = await runGit(args);
if (r.code !== 0) return { success: false, error: r.stderr };
return { success: true, action: 'tag', created: params.branch };
}
const r = await runGit(['tag', '-l']);
if (r.code !== 0) return { success: false, error: r.stderr };
const tags = r.stdout.split('\n').filter(l => l.trim());
return { success: true, action: 'tag', tags };
}
default:
return { success: false, error: `未知 git 操作: ${params.action}` };
}
} catch (err) {
return { success: false, error: (err as Error).message };
}
+2 -2
View File
@@ -461,8 +461,8 @@
<div class="tool-card-desc">一次读取多个文件内容</div>
</div>
<div class="tool-card">
<div class="tool-card-header"><span class="tool-card-icon">🔖</span><span class="tool-card-name">git_status</span><span class="tool-card-badge auto">自动</span></div>
<div class="tool-card-desc">查看 Git 仓库状态:分支、修改、暂存</div>
<div class="tool-card-header"><span class="tool-card-icon">🔖</span><span class="tool-card-name">git</span><span class="tool-card-badge auto">自动</span></div>
<div class="tool-card-desc">Git 全操作:status/log/diff/add/commit/push/pull/branch/checkout/merge/stash/remote/clone/init/reset/tag</div>
</div>
<div class="tool-card">
<div class="tool-card-header"><span class="tool-card-icon">🗜️</span><span class="tool-card-name">compress</span><span class="tool-card-badge confirm">需确认</span></div>
+20 -7
View File
@@ -295,13 +295,26 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
{
type: 'function',
function: {
name: 'git_status',
description: 'Show git repository status: branch, modified/staged/untracked files.',
name: 'git',
description: 'Git operations: status, log, diff, add, commit, push, pull, branch, checkout, merge, stash, remote, clone, init. All operations are automatic (no confirmation).',
parameters: {
type: 'object',
required: [],
required: ['action'],
properties: {
path: { type: 'string', description: 'Repository path. Defaults to workspace.' }
action: { type: 'string', enum: ['status', 'log', 'diff', 'add', 'commit', 'push', 'pull', 'branch', 'checkout', 'merge', 'stash', 'remote', 'clone', 'init', 'reset', 'tag'], description: 'Git action to perform.' },
path: { type: 'string', description: 'Repository path. Defaults to workspace.' },
files: { type: 'array', items: { type: 'string' }, description: 'Files for add/diff/checkout.' },
message: { type: 'string', description: 'Commit message for commit action.' },
branch: { type: 'string', description: 'Branch name for branch/checkout/merge.' },
remote: { type: 'string', description: 'Remote name for push/pull/remote.' },
remote_url: { type: 'string', description: 'Remote URL for remote add.' },
count: { type: 'integer', description: 'Number of log entries. Default: 20.' },
all: { type: 'boolean', description: 'Show all (branches, stashes, etc). Default: false.' },
staged: { type: 'boolean', description: 'Show staged changes in diff. Default: false.' },
new_branch: { type: 'boolean', description: 'Create new branch in branch action. Default: false.' },
delete_branch: { type: 'boolean', description: 'Delete branch in branch action. Default: false.' },
force: { type: 'boolean', description: 'Force push/checkout. Default: false.' },
url: { type: 'string', description: 'Repository URL for clone.' }
}
}
}
@@ -337,7 +350,7 @@ let enabledTools: Set<string> = new Set([
'run_command',
'move_file', 'copy_file', 'web_fetch', 'append_file', 'edit_file',
'get_file_info', 'tree', 'download_file', 'diff_files', 'replace_in_files',
'read_multiple_files', 'git_status', 'compress'
'read_multiple_files', 'git', 'compress'
]);
export function setToolEnabled(toolName: string, enabled: boolean): void {
@@ -422,7 +435,7 @@ export function getToolIcon(name: string): string {
diff_files: '🔀',
replace_in_files: '🔄',
read_multiple_files: '📚',
git_status: '🔖',
git: '🔖',
compress: '🗜️'
};
return icons[name] || '🔧';
@@ -448,7 +461,7 @@ export function formatToolName(name: string): string {
diff_files: '文件对比',
replace_in_files: '批量替换',
read_multiple_files: '批量读取',
git_status: 'Git 状态',
git: 'Git 操作',
compress: '压缩/解压'
};
return names[name] || name;