using System.Diagnostics; using System.Text; namespace ClaudeDo.Data.Git; // TreeOid is only populated when Clean — the tree `merge-tree --write-tree` would produce, // usable to materialize the merge result into a scratch worktree without touching the real // working tree, index, or refs (see GitService.CommitTreeAsync / WorktreeAddDetachedAsync). public sealed record MergePreview(bool Supported, bool Clean, IReadOnlyList ConflictFiles, string? TreeOid = null); public sealed class GitService { // git mutates shared .git/worktrees/ metadata during `worktree add`; concurrent adds // race and fail with "failed to read .git/worktrees//commondir". Serialize them // process-wide so parallel task starts don't collide. private static readonly SemaphoreSlim WorktreeAddGate = new(1, 1); 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(); } /// /// True if is an ancestor of (or equal to) , /// via `git merge-base --is-ancestor`. Null means the answer can't be determined (e.g. the commit is /// unknown in this repo) — callers must treat that as "unknown", never as "not an ancestor". /// public async Task IsAncestorAsync(string repoDir, string ancestorSha, string descendantSha, CancellationToken ct = default) { var (exitCode, _, _) = await RunGitAsync(repoDir, ["merge-base", "--is-ancestor", ancestorSha, descendantSha], ct); return exitCode switch { 0 => true, 1 => false, _ => null, }; } /// /// The merge base of two refs, or null when git can't find one (e.g. an unresolvable ref) — /// callers treat that as "can't evaluate", not "no common history". /// public async Task MergeBaseAsync(string repoDir, string refA, string refB, CancellationToken ct = default) { var (exitCode, stdout, _) = await RunGitAsync(repoDir, ["merge-base", refA, refB], ct); return exitCode == 0 ? stdout.Trim() : null; } public async Task WorktreeAddAsync(string repoDir, string branchName, string worktreePath, string baseCommit, CancellationToken ct = default) { await WorktreeAddGate.WaitAsync(ct); try { const int maxAttempts = 3; for (var attempt = 1; ; attempt++) { var (exitCode, _, stderr) = await RunGitAsync(repoDir, ["worktree", "add", "-b", branchName, worktreePath, baseCommit], ct); if (exitCode == 0) return; // Transient races leave a half-written worktree metadata dir; retry briefly. var transient = stderr.Contains("commondir", StringComparison.OrdinalIgnoreCase) || stderr.Contains("failed to read", StringComparison.OrdinalIgnoreCase); if (!transient || attempt >= maxAttempts) throw new InvalidOperationException($"git worktree add failed (exit {exitCode}): {stderr}"); await Task.Delay(150 * attempt, ct); } } finally { WorktreeAddGate.Release(); } } // --untracked-files=all: without it, a brand-new untracked directory collapses into a single // "?? dir/" entry instead of listing the files inside it — callers matching against specific // paths (e.g. the untracked-collision guard) need the individual files. public async Task GetStatusPorcelainAsync(string workingDirectory, CancellationToken ct = default) { var (exitCode, stdout, stderr) = await RunGitAsync(workingDirectory, ["status", "--porcelain", "--untracked-files=all"], ct); if (exitCode != 0) throw new InvalidOperationException($"git status --porcelain failed (exit {exitCode}): {stderr}"); return stdout; } public async Task GetCommittedFilesAsync(string worktreePath, string baseCommit, CancellationToken ct = default) { var (exitCode, stdout, stderr) = await RunGitAsync(worktreePath, ["diff", "--name-status", $"{baseCommit}..HEAD"], ct); if (exitCode != 0) throw new InvalidOperationException($"git diff --name-status failed (exit {exitCode}): {stderr}"); return stdout; } public Task HasChangesAsync(string worktreePath, CancellationToken ct = default) => HasChangesAsync(worktreePath, includeUntracked: true, ct); /// /// Uncommitted-changes check. =false ignores untracked /// files — use this for merge preflights on a shared target working dir, where stray /// untracked files (e.g. from a concurrent session) shouldn't block a merge. Auto-commit /// and data-loss-guard callers keep the default (true): a new file a task created, or an /// untracked file about to be discarded, is a real uncommitted change. /// public async Task HasChangesAsync(string worktreePath, bool includeUntracked, CancellationToken ct = default) { string[] args = includeUntracked ? ["status", "--porcelain"] : ["status", "--porcelain", "--untracked-files=no"]; var (exitCode, stdout, stderr) = await RunGitAsync(worktreePath, args, 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, IReadOnlyList? paths = null, CancellationToken ct = default) { var args = new List { "diff", "HEAD" }; AppendPathFilter(args, paths); var (exitCode, stdout, stderr) = await RunGitAsync(worktreePath, args, 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 cachedArgs = new List { "diff", "--cached" }; AppendPathFilter(cachedArgs, paths); var (e2, s2, _) = await RunGitAsync(worktreePath, cachedArgs, 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, IReadOnlyList? paths = null, CancellationToken ct = default) { var args = new List { "diff", baseRef }; AppendPathFilter(args, paths); var (exitCode, stdout, _) = await RunGitAsync(worktreePath, args, ct); if (exitCode == 0 && !string.IsNullOrWhiteSpace(stdout)) return stdout; // Fallback: whatever the worktree has vs HEAD (uncommitted only). The same pathspec has to // come along — a filter makes an empty ranged diff the common case (the caller asked for // paths this range never touched), and an unfiltered fallback would answer that with the // whole worktree diff, the exact opposite of what was requested. return await GetDiffAsync(worktreePath, paths, ct); } /// /// Diff between two commits, run in any repo that can reach them. Used to view a /// task's changes after its worktree has been merged away (the commits survive on /// the target branch even though the worktree directory and branch ref are gone). /// public async Task GetCommitRangeDiffAsync( string repoDir, string baseCommit, string headCommit, IReadOnlyList? paths = null, CancellationToken ct = default) { var args = new List { "diff", $"{baseCommit}..{headCommit}" }; AppendPathFilter(args, paths); var (exitCode, stdout, stderr) = await RunGitAsync(repoDir, args, ct); if (exitCode != 0) throw new InvalidOperationException($"git diff {baseCommit}..{headCommit} failed (exit {exitCode}): {stderr}"); return stdout; } public async Task DiffStatAsync( string worktreePath, string baseCommit, string headCommit, IReadOnlyList? paths = null, CancellationToken ct = default) { var args = new List { "diff", "--stat", $"{baseCommit}..{headCommit}" }; AppendPathFilter(args, paths); var (exitCode, stdout, stderr) = await RunGitAsync(worktreePath, args, ct); if (exitCode != 0) throw new InvalidOperationException($"git diff --stat failed (exit {exitCode}): {stderr}"); return stdout.Trim(); } // Appends a `-- ` pathspec filter so git itself narrows the diff instead of the // caller filtering the result after the fact (works identically for --stat and full diffs). // No-op when paths is null/empty so existing callers see no behavior change. private static void AppendPathFilter(List args, IReadOnlyList? paths) { if (paths is not { Count: > 0 }) return; args.Add("--"); args.AddRange(paths); } public async Task GetFileDiffAsync(string worktreePath, string? baseCommit, string relativePath, CancellationToken ct = default) { string[] args = string.IsNullOrEmpty(baseCommit) ? ["diff", "--", relativePath] : ["diff", $"{baseCommit}..HEAD", "--", relativePath]; var (_, stdout, _) = await RunGitAsync(worktreePath, args, ct); return stdout; } 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 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> 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) { // diff3 conflict style writes the merge base (|||||||) into conflict markers so the // in-app resolver can show a true three-way view. It only enriches conflicted hunks; // clean merges are unaffected. var (exitCode, _, stderr) = await RunGitAsync(repoDir, ["-c", "merge.conflictStyle=diff3", "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 IsMidRevertAsync(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, "REVERT_HEAD")); } /// /// Reverts a single commit with `-m 1` (diff against its first parent) — the form needed to /// revert a merge commit. On success this creates a new commit with the inverse changes; /// the original commit and all history stay intact (no rewrite, no reset). /// public async Task<(int ExitCode, string Stderr)> RevertMergeCommitAsync( string repoDir, string mergeCommitSha, CancellationToken ct = default) { var (exitCode, _, stderr) = await RunGitAsync(repoDir, ["-c", "merge.conflictStyle=diff3", "revert", "--no-edit", "-m", "1", mergeCommitSha], ct); return (exitCode, stderr); } public async Task RevertAbortAsync(string repoDir, CancellationToken ct = default) { var (exitCode, _, stderr) = await RunGitAsync(repoDir, ["revert", "--abort"], ct); if (exitCode != 0) throw new InvalidOperationException($"git revert --abort failed (exit {exitCode}): {stderr}"); } public async Task> 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 AddPathAsync(string repoDir, string path, CancellationToken ct = default) { var (exitCode, _, stderr) = await RunGitAsync(repoDir, ["add", "--", path], ct); if (exitCode != 0) throw new InvalidOperationException($"git add '{path}' failed (exit {exitCode}): {stderr}"); } /// /// Non-destructive mergeability probe via `git merge-tree --write-tree`. Writes only /// loose objects — the working tree, index, and refs are left untouched. /// public async Task PreviewMergeAsync( string repoDir, string targetBranch, string sourceBranch, CancellationToken ct = default) { var (exitCode, stdout, _) = await RunGitAsync(repoDir, ["merge-tree", "--write-tree", "--name-only", targetBranch, sourceBranch], ct); if (exitCode == 0) // stdout is just the written tree's oid on a clean merge. return new MergePreview(true, true, Array.Empty(), stdout.Trim()); if (exitCode == 1) { // stdout: \n\n...\n\n var lines = stdout.Split('\n'); var files = new List(); for (int i = 1; i < lines.Length; i++) { var line = lines[i].TrimEnd('\r'); if (string.IsNullOrWhiteSpace(line)) break; files.Add(line.Trim()); } return new MergePreview(true, false, files); } // Any other exit (e.g. git too old: "unknown option --write-tree"). return new MergePreview(false, false, Array.Empty()); } /// Resolves (a branch, tag, or SHA) to a full commit SHA. public async Task RevParseAsync(string repoDir, string revision, CancellationToken ct = default) { var (exitCode, stdout, stderr) = await RunGitAsync(repoDir, ["rev-parse", revision], ct); if (exitCode != 0) throw new InvalidOperationException($"git rev-parse '{revision}' failed (exit {exitCode}): {stderr}"); return stdout.Trim(); } /// /// Creates a new commit object wrapping with /// as its single parent, writing only a loose object — no ref is created or moved. /// public async Task CommitTreeAsync( string repoDir, string treeOid, string parentSha, string message, CancellationToken ct = default) { var (exitCode, stdout, stderr) = await RunGitAsync(repoDir, ["commit-tree", treeOid, "-p", parentSha, "-m", message], ct); if (exitCode != 0) throw new InvalidOperationException($"git commit-tree failed (exit {exitCode}): {stderr}"); return stdout.Trim(); } /// /// Materializes into a new, branchless worktree at /// (detached HEAD) — used to build/verify a merge-tree /// result without ever creating a branch or touching the real working tree. /// public async Task WorktreeAddDetachedAsync( string repoDir, string worktreePath, string commitish, CancellationToken ct = default) { await WorktreeAddGate.WaitAsync(ct); try { const int maxAttempts = 3; for (var attempt = 1; ; attempt++) { var (exitCode, _, stderr) = await RunGitAsync(repoDir, ["worktree", "add", "--detach", worktreePath, commitish], ct); if (exitCode == 0) return; var transient = stderr.Contains("commondir", StringComparison.OrdinalIgnoreCase) || stderr.Contains("failed to read", StringComparison.OrdinalIgnoreCase); if (!transient || attempt >= maxAttempts) throw new InvalidOperationException($"git worktree add --detach failed (exit {exitCode}): {stderr}"); await Task.Delay(150 * attempt, ct); } } finally { WorktreeAddGate.Release(); } } /// Count of files that differ on since its merge base with the target. public async Task CountChangedFilesAsync( string repoDir, string targetBranch, string sourceBranch, CancellationToken ct = default) { var (exitCode, stdout, _) = await RunGitAsync(repoDir, ["diff", "--name-only", $"{targetBranch}...{sourceBranch}"], ct); if (exitCode != 0) return 0; return stdout .Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .Count(s => s.Length > 0); } /// Files that differ between two exact refs (2-dot, no merge-base resolution) -- used to see /// what a target branch itself picked up since a task's fork point, as opposed to /// 's 3-dot count of a branch's own changes. public async Task> GetChangedFileNamesAsync( string repoDir, string fromRef, string toRef, CancellationToken ct = default) { var (exitCode, stdout, _) = await RunGitAsync(repoDir, ["diff", "--name-only", $"{fromRef}..{toRef}"], ct); if (exitCode != 0) return Array.Empty(); return stdout .Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .Where(s => s.Length > 0) .ToList(); } /// /// Rebases the branch checked out at onto . /// On conflict or any other failure the rebase is aborted before returning, so the worktree is left /// exactly as it was rather than stranded mid-rebase; ConflictFiles is best-effort and only /// populated when the failure was an actual conflict. /// public async Task<(int ExitCode, string Stderr, IReadOnlyList ConflictFiles)> RebaseAsync( string worktreePath, string ontoRef, CancellationToken ct = default) { var (exitCode, _, stderr) = await RunGitAsync(worktreePath, ["rebase", ontoRef], ct); if (exitCode == 0) return (0, stderr, Array.Empty()); List conflictFiles; try { conflictFiles = await ListConflictedFilesAsync(worktreePath, ct); } catch { conflictFiles = new(); } await RunGitAsync(worktreePath, ["rebase", "--abort"], ct); return (exitCode, stderr, conflictFiles); } private static async Task<(int ExitCode, string Stdout, string Stderr)> RunGitAsync( string workDir, IEnumerable args, CancellationToken ct, string? stdinData = null, bool trimOutput = true) { 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, trimOutput ? stdout.TrimEnd() : stdout, stderr.TrimEnd()); } }