using System.Diagnostics; using System.IO; using System.Text; using System.Text.Json; namespace AxCopilot.Services.Agent; /// /// Git 버전 관리 도구. /// 사내 GitHub Enterprise 환경을 고려하여 안전한 Git 작업을 지원합니다. /// push/force push는 차단되며, 사용자가 직접 수행해야 합니다. /// 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 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 { "add", "commit", "checkout", "stash" }; if (writeActions.Contains(action)) { if (!await context.CheckWritePermissionAsync(Name, workDir)) return ToolResult.Fail("Git 쓰기 권한이 거부되었습니다."); } // Git 커밋 — 현재 비활성 (향후 활성화 예정) // 의사결정 수준에서 무조건 확인을 받더라도, 커밋 자체를 차단합니다. if (action == "commit") { return ToolResult.Fail( "Git 커밋 기능은 현재 비활성 상태입니다.\n" + "안전을 위해 커밋은 사용자가 직접 수행하세요.\n" + "향후 버전에서 활성화될 예정입니다."); } // 명령 실행 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; } } }