243 lines
8.0 KiB
C#
243 lines
8.0 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Diagnostics;
|
|
using System.IO;
|
|
using System.Runtime.InteropServices;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
|
|
namespace AxCopilot.Services.Agent;
|
|
|
|
public class GitTool : IAgentTool
|
|
{
|
|
private static readonly string[] BlockedPatterns = new string[16]
|
|
{
|
|
"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 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
|
|
{
|
|
get
|
|
{
|
|
ToolParameterSchema toolParameterSchema = new ToolParameterSchema();
|
|
Dictionary<string, ToolProperty> dictionary = new Dictionary<string, ToolProperty>();
|
|
ToolProperty obj = new ToolProperty
|
|
{
|
|
Type = "string",
|
|
Description = "Git action: status, diff, log, add, commit, branch, checkout, stash, remote"
|
|
};
|
|
int num = 9;
|
|
List<string> list = new List<string>(num);
|
|
CollectionsMarshal.SetCount(list, num);
|
|
Span<string> span = CollectionsMarshal.AsSpan(list);
|
|
span[0] = "status";
|
|
span[1] = "diff";
|
|
span[2] = "log";
|
|
span[3] = "add";
|
|
span[4] = "commit";
|
|
span[5] = "branch";
|
|
span[6] = "checkout";
|
|
span[7] = "stash";
|
|
span[8] = "remote";
|
|
obj.Enum = list;
|
|
dictionary["action"] = obj;
|
|
dictionary["args"] = new ToolProperty
|
|
{
|
|
Type = "string",
|
|
Description = "Additional arguments. For commit: commit message. For add: file path(s). For log: '--oneline -10'. For diff: file path or '--staged'."
|
|
};
|
|
toolParameterSchema.Properties = dictionary;
|
|
num = 1;
|
|
List<string> list2 = new List<string>(num);
|
|
CollectionsMarshal.SetCount(list2, num);
|
|
CollectionsMarshal.AsSpan(list2)[0] = "action";
|
|
toolParameterSchema.Required = list2;
|
|
return toolParameterSchema;
|
|
}
|
|
}
|
|
|
|
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
|
|
{
|
|
if (!args.TryGetProperty("action", out var actionEl))
|
|
{
|
|
return ToolResult.Fail("action이 필요합니다.");
|
|
}
|
|
string action = actionEl.GetString() ?? "status";
|
|
JsonElement a;
|
|
string extraArgs = (args.TryGetProperty("args", out a) ? (a.GetString() ?? "") : "");
|
|
string workDir = context.WorkFolder;
|
|
if (string.IsNullOrEmpty(workDir) || !Directory.Exists(workDir))
|
|
{
|
|
return ToolResult.Fail("작업 폴더가 설정되지 않았습니다.");
|
|
}
|
|
string gitPath = FindGit();
|
|
if (gitPath == null)
|
|
{
|
|
return ToolResult.Fail("Git이 설치되어 있지 않습니다. PATH에 git이 있는지 확인하세요.");
|
|
}
|
|
if (!Directory.Exists(Path.Combine(workDir, ".git")))
|
|
{
|
|
string checkDir = workDir;
|
|
bool found = false;
|
|
while (!string.IsNullOrEmpty(checkDir))
|
|
{
|
|
if (Directory.Exists(Path.Combine(checkDir, ".git")))
|
|
{
|
|
found = true;
|
|
break;
|
|
}
|
|
string parent = Directory.GetParent(checkDir)?.FullName;
|
|
if (parent == checkDir)
|
|
{
|
|
break;
|
|
}
|
|
checkDir = parent;
|
|
}
|
|
if (!found)
|
|
{
|
|
return ToolResult.Fail("현재 작업 폴더는 Git 저장소가 아닙니다.");
|
|
}
|
|
}
|
|
if (1 == 0)
|
|
{
|
|
}
|
|
string text = 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 (1 == 0)
|
|
{
|
|
}
|
|
string gitCommand = text;
|
|
if (gitCommand == null)
|
|
{
|
|
if (action == "commit")
|
|
{
|
|
return ToolResult.Fail("커밋 메시지가 필요합니다. args에 커밋 메시지를 지정하세요.");
|
|
}
|
|
if (action == "checkout")
|
|
{
|
|
return ToolResult.Fail("체크아웃할 브랜치/파일을 args에 지정하세요.");
|
|
}
|
|
return ToolResult.Fail("알 수 없는 액션: " + action);
|
|
}
|
|
string fullCmd = "git " + gitCommand;
|
|
string[] blockedPatterns = BlockedPatterns;
|
|
foreach (string pattern in blockedPatterns)
|
|
{
|
|
if (fullCmd.Contains(pattern, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return ToolResult.Fail("안전을 위해 '" + pattern + "' 작업은 차단됩니다.\n원격 저장소 작업(push/pull/fetch)과 이력 변경 작업은 사용자가 직접 수행하세요.");
|
|
}
|
|
}
|
|
HashSet<string> writeActions = new HashSet<string> { "add", "commit", "checkout", "stash" };
|
|
if (writeActions.Contains(action) && !(await context.CheckWritePermissionAsync(Name, workDir)))
|
|
{
|
|
return ToolResult.Fail("Git 쓰기 권한이 거부되었습니다.");
|
|
}
|
|
if (action == "commit")
|
|
{
|
|
return ToolResult.Fail("Git 커밋 기능은 현재 비활성 상태입니다.\n안전을 위해 커밋은 사용자가 직접 수행하세요.\n향후 버전에서 활성화될 예정입니다.");
|
|
}
|
|
try
|
|
{
|
|
ProcessStartInfo psi = new ProcessStartInfo(gitPath, gitCommand)
|
|
{
|
|
WorkingDirectory = workDir,
|
|
RedirectStandardOutput = true,
|
|
RedirectStandardError = true,
|
|
UseShellExecute = false,
|
|
CreateNoWindow = true,
|
|
StandardOutputEncoding = Encoding.UTF8,
|
|
StandardErrorEncoding = Encoding.UTF8
|
|
};
|
|
using CancellationTokenSource cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
|
cts.CancelAfter(TimeSpan.FromSeconds(30.0));
|
|
using Process proc = Process.Start(psi);
|
|
if (proc == null)
|
|
{
|
|
return ToolResult.Fail("Git 프로세스 시작 실패");
|
|
}
|
|
string stdout = await proc.StandardOutput.ReadToEndAsync(cts.Token);
|
|
string stderr = await proc.StandardError.ReadToEndAsync(cts.Token);
|
|
await proc.WaitForExitAsync(cts.Token);
|
|
if (stdout.Length > 8000)
|
|
{
|
|
stdout = stdout.Substring(0, 8000) + "\n... (출력 잘림)";
|
|
}
|
|
StringBuilder sb = new StringBuilder();
|
|
StringBuilder stringBuilder = sb;
|
|
StringBuilder stringBuilder2 = stringBuilder;
|
|
StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(18, 2, stringBuilder);
|
|
handler.AppendLiteral("[git ");
|
|
handler.AppendFormatted(action);
|
|
handler.AppendLiteral("] Exit code: ");
|
|
handler.AppendFormatted(proc.ExitCode);
|
|
stringBuilder2.AppendLine(ref handler);
|
|
if (!string.IsNullOrWhiteSpace(stdout))
|
|
{
|
|
sb.Append(stdout);
|
|
}
|
|
if (!string.IsNullOrWhiteSpace(stderr) && proc.ExitCode != 0)
|
|
{
|
|
stringBuilder = sb;
|
|
StringBuilder stringBuilder3 = stringBuilder;
|
|
handler = new StringBuilder.AppendInterpolatedStringHandler(10, 1, stringBuilder);
|
|
handler.AppendLiteral("\n[stderr] ");
|
|
handler.AppendFormatted(stderr.Trim());
|
|
stringBuilder3.AppendLine(ref handler);
|
|
}
|
|
return (proc.ExitCode == 0) ? ToolResult.Ok(sb.ToString()) : ToolResult.Fail(sb.ToString());
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
return ToolResult.Fail("Git 명령 타임아웃 (30초)");
|
|
}
|
|
catch (Exception ex2)
|
|
{
|
|
return ToolResult.Fail("Git 실행 오류: " + ex2.Message);
|
|
}
|
|
}
|
|
|
|
private static string? FindGit()
|
|
{
|
|
try
|
|
{
|
|
ProcessStartInfo startInfo = new ProcessStartInfo("where.exe", "git")
|
|
{
|
|
RedirectStandardOutput = true,
|
|
UseShellExecute = false,
|
|
CreateNoWindow = true
|
|
};
|
|
using Process process = Process.Start(startInfo);
|
|
if (process == null)
|
|
{
|
|
return null;
|
|
}
|
|
string text = process.StandardOutput.ReadToEnd().Trim();
|
|
process.WaitForExit(5000);
|
|
return string.IsNullOrWhiteSpace(text) ? null : text.Split('\n')[0].Trim();
|
|
}
|
|
catch
|
|
{
|
|
return null;
|
|
}
|
|
}
|
|
}
|