Ensures non-ASCII git output (branch names, paths, commit messages) is read and written without locale-dependent corruption.
281 lines
12 KiB
C#
281 lines
12 KiB
C#
using System.Diagnostics;
|
|
using System.Text;
|
|
|
|
namespace ClaudeDo.Data.Git;
|
|
|
|
public sealed class GitService
|
|
{
|
|
public async Task<bool> IsGitRepoAsync(string dir, CancellationToken ct = default)
|
|
{
|
|
var (exitCode, _, _) = await RunGitAsync(dir, ["rev-parse", "--git-dir"], ct);
|
|
return exitCode == 0;
|
|
}
|
|
|
|
public async Task<string> RevParseHeadAsync(string dir, CancellationToken ct = default)
|
|
{
|
|
var (exitCode, stdout, stderr) = await RunGitAsync(dir, ["rev-parse", "HEAD"], ct);
|
|
if (exitCode != 0)
|
|
throw new InvalidOperationException($"git rev-parse HEAD failed (exit {exitCode}): {stderr}");
|
|
return stdout.Trim();
|
|
}
|
|
|
|
public async Task WorktreeAddAsync(string repoDir, string branchName, string worktreePath, string baseCommit, CancellationToken ct = default)
|
|
{
|
|
var (exitCode, _, stderr) = await RunGitAsync(repoDir,
|
|
["worktree", "add", "-b", branchName, worktreePath, baseCommit], ct);
|
|
if (exitCode != 0)
|
|
throw new InvalidOperationException($"git worktree add failed (exit {exitCode}): {stderr}");
|
|
}
|
|
|
|
public async Task<string> GetStatusPorcelainAsync(string workingDirectory, CancellationToken ct = default)
|
|
{
|
|
var (exitCode, stdout, stderr) = await RunGitAsync(workingDirectory, ["status", "--porcelain"], ct);
|
|
if (exitCode != 0)
|
|
throw new InvalidOperationException($"git status --porcelain failed (exit {exitCode}): {stderr}");
|
|
return stdout;
|
|
}
|
|
|
|
public async Task<bool> HasChangesAsync(string worktreePath, CancellationToken ct = default)
|
|
{
|
|
var (exitCode, stdout, stderr) = await RunGitAsync(worktreePath, ["status", "--porcelain"], ct);
|
|
if (exitCode != 0)
|
|
throw new InvalidOperationException($"git status --porcelain failed (exit {exitCode}): {stderr}");
|
|
return !string.IsNullOrWhiteSpace(stdout);
|
|
}
|
|
|
|
public async Task AddAllAsync(string worktreePath, CancellationToken ct = default)
|
|
{
|
|
var (exitCode, _, stderr) = await RunGitAsync(worktreePath, ["add", "-A"], ct);
|
|
if (exitCode != 0)
|
|
throw new InvalidOperationException($"git add -A failed (exit {exitCode}): {stderr}");
|
|
}
|
|
|
|
public async Task CommitAsync(string worktreePath, string message, CancellationToken ct = default)
|
|
{
|
|
// Use -F - (read message from stdin) to handle multi-line messages safely.
|
|
var (exitCode, _, stderr) = await RunGitAsync(worktreePath, ["commit", "-F", "-"], ct, stdinData: message);
|
|
if (exitCode != 0)
|
|
throw new InvalidOperationException($"git commit failed (exit {exitCode}): {stderr}");
|
|
}
|
|
|
|
public async Task<string> GetDiffAsync(string worktreePath, CancellationToken ct = default)
|
|
{
|
|
var (exitCode, stdout, stderr) = await RunGitAsync(worktreePath,
|
|
["diff", "HEAD"], ct);
|
|
if (exitCode != 0)
|
|
throw new InvalidOperationException($"git diff HEAD failed (exit {exitCode}): {stderr}");
|
|
// If nothing staged vs HEAD, try the index (untracked is never in diff)
|
|
if (string.IsNullOrWhiteSpace(stdout))
|
|
{
|
|
var (e2, s2, _) = await RunGitAsync(worktreePath, ["diff", "--cached"], ct);
|
|
if (e2 == 0) return s2;
|
|
}
|
|
return stdout;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Full diff between <paramref name="baseRef"/> and the current working tree
|
|
/// (committed-on-branch changes + uncommitted work). Used for viewing a Claude
|
|
/// task's total impact relative to where the branch started.
|
|
/// </summary>
|
|
public async Task<string> GetBranchDiffAsync(string worktreePath, string baseRef, CancellationToken ct = default)
|
|
{
|
|
var (exitCode, stdout, _) = await RunGitAsync(worktreePath,
|
|
["diff", baseRef], ct);
|
|
if (exitCode == 0 && !string.IsNullOrWhiteSpace(stdout))
|
|
return stdout;
|
|
// Fallback: whatever the worktree has vs HEAD (uncommitted only).
|
|
return await GetDiffAsync(worktreePath, ct);
|
|
}
|
|
|
|
public async Task<string> DiffStatAsync(string worktreePath, string baseCommit, string headCommit, CancellationToken ct = default)
|
|
{
|
|
var (exitCode, stdout, stderr) = await RunGitAsync(worktreePath,
|
|
["diff", "--stat", $"{baseCommit}..{headCommit}"], ct);
|
|
if (exitCode != 0)
|
|
throw new InvalidOperationException($"git diff --stat failed (exit {exitCode}): {stderr}");
|
|
return stdout.Trim();
|
|
}
|
|
|
|
public async Task WorktreeRemoveAsync(string repoDir, string worktreePath, bool force = false, CancellationToken ct = default)
|
|
{
|
|
var args = new List<string> { "worktree", "remove" };
|
|
if (force) args.Add("--force");
|
|
args.Add(worktreePath);
|
|
|
|
var (exitCode, _, stderr) = await RunGitAsync(repoDir, args, ct);
|
|
if (exitCode != 0)
|
|
throw new InvalidOperationException($"git worktree remove failed (exit {exitCode}): {stderr}");
|
|
}
|
|
|
|
public async Task<List<string>> ListWorktreePathsForBranchAsync(string repoDir, string branchName, CancellationToken ct = default)
|
|
{
|
|
var (exitCode, stdout, _) = await RunGitAsync(repoDir, ["worktree", "list", "--porcelain"], ct);
|
|
if (exitCode != 0) return new();
|
|
|
|
var target = $"refs/heads/{branchName}";
|
|
var paths = new List<string>();
|
|
string? currentPath = null;
|
|
foreach (var raw in stdout.Split('\n'))
|
|
{
|
|
var line = raw.TrimEnd('\r');
|
|
if (line.StartsWith("worktree ", StringComparison.Ordinal))
|
|
{
|
|
currentPath = line["worktree ".Length..].Trim();
|
|
}
|
|
else if (line.StartsWith("branch ", StringComparison.Ordinal))
|
|
{
|
|
var b = line["branch ".Length..].Trim();
|
|
if (b == target && currentPath is not null) paths.Add(currentPath);
|
|
}
|
|
else if (string.IsNullOrWhiteSpace(line))
|
|
{
|
|
currentPath = null;
|
|
}
|
|
}
|
|
return paths;
|
|
}
|
|
|
|
public async Task WorktreePruneAsync(string repoDir, CancellationToken ct = default)
|
|
{
|
|
var (exitCode, _, stderr) = await RunGitAsync(repoDir, ["worktree", "prune"], ct);
|
|
if (exitCode != 0)
|
|
throw new InvalidOperationException($"git worktree prune failed (exit {exitCode}): {stderr}");
|
|
}
|
|
|
|
public async Task BranchDeleteAsync(string repoDir, string branchName, bool force = false, CancellationToken ct = default)
|
|
{
|
|
var flag = force ? "-D" : "-d";
|
|
var (exitCode, _, stderr) = await RunGitAsync(repoDir, ["branch", flag, branchName], ct);
|
|
if (exitCode != 0)
|
|
throw new InvalidOperationException($"git branch {flag} failed (exit {exitCode}): {stderr}");
|
|
}
|
|
|
|
public async Task<string> GetCurrentBranchAsync(string repoDir, CancellationToken ct = default)
|
|
{
|
|
var (exitCode, stdout, stderr) = await RunGitAsync(repoDir,
|
|
["symbolic-ref", "--short", "HEAD"], ct);
|
|
if (exitCode != 0)
|
|
throw new InvalidOperationException($"git symbolic-ref --short HEAD failed (exit {exitCode}): {stderr}");
|
|
return stdout.Trim();
|
|
}
|
|
|
|
public async Task CheckoutBranchAsync(string repoDir, string branchName, CancellationToken ct = default)
|
|
{
|
|
var (exitCode, _, stderr) = await RunGitAsync(repoDir, ["checkout", branchName], ct);
|
|
if (exitCode != 0)
|
|
throw new InvalidOperationException($"git checkout '{branchName}' failed (exit {exitCode}): {stderr}");
|
|
}
|
|
|
|
public async Task<List<string>> ListLocalBranchesAsync(string repoDir, CancellationToken ct = default)
|
|
{
|
|
var (exitCode, stdout, stderr) = await RunGitAsync(repoDir,
|
|
["branch", "--format=%(refname:short)"], ct);
|
|
if (exitCode != 0)
|
|
throw new InvalidOperationException($"git branch --format failed (exit {exitCode}): {stderr}");
|
|
|
|
return stdout
|
|
.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
|
.Where(s => s.Length > 0)
|
|
.ToList();
|
|
}
|
|
|
|
public async Task<bool> IsMidMergeAsync(string repoDir, CancellationToken ct = default)
|
|
{
|
|
var (exitCode, stdout, _) = await RunGitAsync(repoDir, ["rev-parse", "--git-dir"], ct);
|
|
if (exitCode != 0) return false;
|
|
var gitDir = stdout.Trim();
|
|
if (!Path.IsPathRooted(gitDir))
|
|
gitDir = Path.Combine(repoDir, gitDir);
|
|
return File.Exists(Path.Combine(gitDir, "MERGE_HEAD"));
|
|
}
|
|
|
|
public async Task<(int ExitCode, string Stderr)> MergeNoFfAsync(
|
|
string repoDir, string sourceBranch, string message, CancellationToken ct = default)
|
|
{
|
|
var (exitCode, _, stderr) = await RunGitAsync(repoDir,
|
|
["merge", "--no-ff", "-m", message, sourceBranch], ct);
|
|
return (exitCode, stderr);
|
|
}
|
|
|
|
public async Task MergeAbortAsync(string repoDir, CancellationToken ct = default)
|
|
{
|
|
var (exitCode, _, stderr) = await RunGitAsync(repoDir, ["merge", "--abort"], ct);
|
|
if (exitCode != 0)
|
|
throw new InvalidOperationException($"git merge --abort failed (exit {exitCode}): {stderr}");
|
|
}
|
|
|
|
public async Task<List<string>> ListConflictedFilesAsync(string repoDir, CancellationToken ct = default)
|
|
{
|
|
var (exitCode, stdout, stderr) = await RunGitAsync(repoDir,
|
|
["diff", "--name-only", "--diff-filter=U"], ct);
|
|
if (exitCode != 0)
|
|
throw new InvalidOperationException($"git diff --diff-filter=U failed (exit {exitCode}): {stderr}");
|
|
|
|
return stdout
|
|
.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
|
.Where(s => s.Length > 0)
|
|
.ToList();
|
|
}
|
|
|
|
public async Task MergeFfOnlyAsync(string repoDir, string branchName, CancellationToken ct = default)
|
|
{
|
|
var (exitCode, _, stderr) = await RunGitAsync(repoDir, ["merge", "--ff-only", branchName], ct);
|
|
if (exitCode != 0)
|
|
throw new InvalidOperationException($"Fast-forward merge of '{branchName}' failed. Manual merge required. git stderr: {stderr}");
|
|
}
|
|
|
|
private static async Task<(int ExitCode, string Stdout, string Stderr)> RunGitAsync(
|
|
string workDir, IEnumerable<string> args, CancellationToken ct, string? stdinData = null)
|
|
{
|
|
var psi = new ProcessStartInfo
|
|
{
|
|
FileName = "git",
|
|
RedirectStandardOutput = true,
|
|
RedirectStandardError = true,
|
|
RedirectStandardInput = stdinData is not null,
|
|
UseShellExecute = false,
|
|
CreateNoWindow = true,
|
|
StandardOutputEncoding = Encoding.UTF8,
|
|
StandardErrorEncoding = Encoding.UTF8,
|
|
StandardInputEncoding = stdinData is not null ? Encoding.UTF8 : null,
|
|
};
|
|
psi.ArgumentList.Add("-C");
|
|
psi.ArgumentList.Add(workDir);
|
|
foreach (var a in args) psi.ArgumentList.Add(a);
|
|
|
|
using var proc = new Process { StartInfo = psi };
|
|
proc.Start();
|
|
|
|
// On cancellation: kill the git process tree. Killing closes the
|
|
// redirected pipes, which unblocks the ReadToEndAsync calls below
|
|
// and lets WaitForExitAsync return so the process is reaped.
|
|
// Without this, cancelling mid-git leaves zombie processes.
|
|
await using var ctr = ct.Register(() =>
|
|
{
|
|
try { proc.Kill(entireProcessTree: true); }
|
|
catch { /* already exited */ }
|
|
});
|
|
|
|
if (stdinData is not null)
|
|
{
|
|
await proc.StandardInput.WriteAsync(stdinData.AsMemory(), ct);
|
|
proc.StandardInput.Close();
|
|
}
|
|
|
|
// Drain output without ct — pipes close when the process exits
|
|
// (whether naturally or via Kill above), so these always complete.
|
|
var stdoutTask = proc.StandardOutput.ReadToEndAsync();
|
|
var stderrTask = proc.StandardError.ReadToEndAsync();
|
|
|
|
await proc.WaitForExitAsync(CancellationToken.None);
|
|
|
|
var stdout = await stdoutTask;
|
|
var stderr = await stderrTask;
|
|
|
|
ct.ThrowIfCancellationRequested();
|
|
|
|
return (proc.ExitCode, stdout.TrimEnd(), stderr.TrimEnd());
|
|
}
|
|
}
|