Some checks failed
Release Gate / gate (push) Has been cancelled
- /chrome: 인자 없는 진단 모드와 실행 라우팅 분리, MCP 재연결 자동 재시도 경로 보강 - /mcp: status/enable/disable/reconnect 명령 정리 및 상태 라벨 표준화 - /settings, /permissions 하위 액션 명확화, /verify·/commit 로컬 실행 흐름 정리 - /commit files:path1,path2 :: message 형태의 부분 커밋 지원 추가 - GitTool commit 경로의 레거시 비활성 응답 제거로 정책 일관성 확보 - ChatWindowSlashPolicyTests 신규 추가 및 AgentParityToolsTests 회귀 방지 테스트 보강 - docs/DEVELOPMENT.md, docs/AGENT_ROADMAP.md에 2026-04-04 진행 기록/스냅샷 반영
194 lines
7.7 KiB
C#
194 lines
7.7 KiB
C#
using System.Diagnostics;
|
|
using System.IO;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
|
|
namespace AxCopilot.Services.Agent;
|
|
|
|
/// <summary>
|
|
/// Git 버전 관리 도구.
|
|
/// 사내 GitHub Enterprise 환경을 고려하여 안전한 Git 작업을 지원합니다.
|
|
/// push/force push는 차단되며, 사용자가 직접 수행해야 합니다.
|
|
/// </summary>
|
|
public class GitTool : IAgentTool
|
|
{
|
|
public string Name => "git_tool";
|
|
public string Description =>
|
|
"Execute safe Git operations. Supports: status, diff, log, add, commit, branch, checkout. " +
|
|
"Push operations are blocked for safety — user must push manually. " +
|
|
"Works with enterprise GitHub (on-premise) repositories.";
|
|
|
|
public ToolParameterSchema Parameters => new()
|
|
{
|
|
Properties = new()
|
|
{
|
|
["action"] = new()
|
|
{
|
|
Type = "string",
|
|
Description = "Git action: status, diff, log, add, commit, branch, checkout, stash, remote",
|
|
Enum = ["status", "diff", "log", "add", "commit", "branch", "checkout", "stash", "remote"],
|
|
},
|
|
["args"] = new()
|
|
{
|
|
Type = "string",
|
|
Description = "Additional arguments. For commit: commit message. For add: file path(s). For log: '--oneline -10'. For diff: file path or '--staged'.",
|
|
},
|
|
},
|
|
Required = ["action"]
|
|
};
|
|
|
|
// 차단 명령 패턴 — 원격 수정 및 위험 작업
|
|
private static readonly string[] BlockedPatterns =
|
|
[
|
|
"push", "push --force", "push -f",
|
|
"pull", "fetch",
|
|
"reset --hard", "clean -f",
|
|
"rebase", "merge",
|
|
"remote add", "remote remove", "remote set-url",
|
|
"branch -D", "branch -d",
|
|
"tag -d", "tag -D",
|
|
];
|
|
|
|
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
|
|
{
|
|
if (!args.TryGetProperty("action", out var actionEl))
|
|
return ToolResult.Fail("action이 필요합니다.");
|
|
var action = actionEl.GetString() ?? "status";
|
|
var extraArgs = args.TryGetProperty("args", out var a) ? a.GetString() ?? "" : "";
|
|
|
|
var workDir = context.WorkFolder;
|
|
if (string.IsNullOrEmpty(workDir) || !Directory.Exists(workDir))
|
|
return ToolResult.Fail("작업 폴더가 설정되지 않았습니다.");
|
|
|
|
// Git 설치 확인
|
|
var gitPath = FindGit();
|
|
if (gitPath == null)
|
|
return ToolResult.Fail("Git이 설치되어 있지 않습니다. PATH에 git이 있는지 확인하세요.");
|
|
|
|
// Git 저장소 확인
|
|
if (!Directory.Exists(Path.Combine(workDir, ".git")))
|
|
{
|
|
// 상위 디렉토리에서 .git 확인 (서브디렉토리 작업 지원)
|
|
var checkDir = workDir;
|
|
bool found = false;
|
|
while (!string.IsNullOrEmpty(checkDir))
|
|
{
|
|
if (Directory.Exists(Path.Combine(checkDir, ".git"))) { found = true; break; }
|
|
var parent = Directory.GetParent(checkDir)?.FullName;
|
|
if (parent == checkDir) break;
|
|
checkDir = parent;
|
|
}
|
|
if (!found)
|
|
return ToolResult.Fail("현재 작업 폴더는 Git 저장소가 아닙니다.");
|
|
}
|
|
|
|
// 명령 구성
|
|
var gitCommand = action switch
|
|
{
|
|
"status" => "status --short --branch",
|
|
"diff" => string.IsNullOrEmpty(extraArgs) ? "diff" : $"diff {extraArgs}",
|
|
"log" => string.IsNullOrEmpty(extraArgs) ? "log --oneline -15" : $"log {extraArgs}",
|
|
"add" => string.IsNullOrEmpty(extraArgs) ? "add -A" : $"add {extraArgs}",
|
|
"commit" => string.IsNullOrEmpty(extraArgs)
|
|
? null // 커밋 메시지 필수
|
|
: $"commit -m \"{extraArgs.Replace("\"", "\\\"")}\"",
|
|
"branch" => string.IsNullOrEmpty(extraArgs) ? "branch -a" : $"branch {extraArgs}",
|
|
"checkout" => string.IsNullOrEmpty(extraArgs) ? null : $"checkout {extraArgs}",
|
|
"stash" => string.IsNullOrEmpty(extraArgs) ? "stash list" : $"stash {extraArgs}",
|
|
"remote" => "remote -v",
|
|
_ => null,
|
|
};
|
|
|
|
if (gitCommand == null)
|
|
{
|
|
if (action == "commit")
|
|
return ToolResult.Fail("커밋 메시지가 필요합니다. args에 커밋 메시지를 지정하세요.");
|
|
if (action == "checkout")
|
|
return ToolResult.Fail("체크아웃할 브랜치/파일을 args에 지정하세요.");
|
|
return ToolResult.Fail($"알 수 없는 액션: {action}");
|
|
}
|
|
|
|
// 위험 명령 차단
|
|
var fullCmd = $"git {gitCommand}";
|
|
foreach (var pattern in BlockedPatterns)
|
|
{
|
|
if (fullCmd.Contains(pattern, StringComparison.OrdinalIgnoreCase))
|
|
return ToolResult.Fail(
|
|
$"안전을 위해 '{pattern}' 작업은 차단됩니다.\n" +
|
|
"원격 저장소 작업(push/pull/fetch)과 이력 변경 작업은 사용자가 직접 수행하세요.");
|
|
}
|
|
|
|
// 쓰기 작업은 권한 확인
|
|
var writeActions = new HashSet<string> { "add", "commit", "checkout", "stash" };
|
|
if (writeActions.Contains(action))
|
|
{
|
|
if (!await context.CheckWritePermissionAsync(Name, workDir))
|
|
return ToolResult.Fail("Git 쓰기 권한이 거부되었습니다.");
|
|
}
|
|
|
|
// 명령 실행
|
|
try
|
|
{
|
|
var psi = new ProcessStartInfo(gitPath, gitCommand)
|
|
{
|
|
WorkingDirectory = workDir,
|
|
RedirectStandardOutput = true,
|
|
RedirectStandardError = true,
|
|
UseShellExecute = false,
|
|
CreateNoWindow = true,
|
|
StandardOutputEncoding = Encoding.UTF8,
|
|
StandardErrorEncoding = Encoding.UTF8,
|
|
};
|
|
|
|
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
|
cts.CancelAfter(TimeSpan.FromSeconds(30));
|
|
|
|
using var proc = Process.Start(psi);
|
|
if (proc == null) return ToolResult.Fail("Git 프로세스 시작 실패");
|
|
|
|
var stdout = await proc.StandardOutput.ReadToEndAsync(cts.Token);
|
|
var stderr = await proc.StandardError.ReadToEndAsync(cts.Token);
|
|
await proc.WaitForExitAsync(cts.Token);
|
|
|
|
// 출력 제한
|
|
if (stdout.Length > 8000) stdout = stdout[..8000] + "\n... (출력 잘림)";
|
|
|
|
var sb = new StringBuilder();
|
|
sb.AppendLine($"[git {action}] Exit code: {proc.ExitCode}");
|
|
if (!string.IsNullOrWhiteSpace(stdout)) sb.Append(stdout);
|
|
if (!string.IsNullOrWhiteSpace(stderr) && proc.ExitCode != 0) sb.AppendLine($"\n[stderr] {stderr.Trim()}");
|
|
|
|
return proc.ExitCode == 0
|
|
? ToolResult.Ok(sb.ToString())
|
|
: ToolResult.Fail(sb.ToString());
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
return ToolResult.Fail("Git 명령 타임아웃 (30초)");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
return ToolResult.Fail($"Git 실행 오류: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
private static string? FindGit()
|
|
{
|
|
try
|
|
{
|
|
var psi = new ProcessStartInfo("where.exe", "git")
|
|
{
|
|
RedirectStandardOutput = true,
|
|
UseShellExecute = false,
|
|
CreateNoWindow = true,
|
|
};
|
|
using var proc = Process.Start(psi);
|
|
if (proc == null) return null;
|
|
var output = proc.StandardOutput.ReadToEnd().Trim();
|
|
proc.WaitForExit(5000);
|
|
return string.IsNullOrWhiteSpace(output) ? null : output.Split('\n')[0].Trim();
|
|
}
|
|
catch { return null; }
|
|
}
|
|
}
|