using System.Diagnostics; using System.Text; namespace ClaudeDo.Data.Git; public sealed class GitService { public async Task IsGitRepoAsync(string dir, CancellationToken ct = default) { var (exitCode, _, _) = await RunGitAsync(dir, ["rev-parse", "--git-dir"], ct); return exitCode == 0; } public async Task 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 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 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 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; } /// /// Full diff between 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. /// public async Task 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 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 { "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> 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? 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 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> 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 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 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 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, }; 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()); } }