Files
ClaudeDo/src/ClaudeDo.Data/Git/GitService.cs
Mika Kuns 07dee31847 fix(data): use UTF-8 encoding for git process stdio
Ensures non-ASCII git output (branch names, paths, commit messages) is
read and written without locale-dependent corruption.
2026-04-22 11:03:24 +02:00

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());
}
}