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) { // Unborn HEAD (fresh `git init`, no commits) — the raw git stderr ("ambiguous // argument 'HEAD'") is useless to a user; call sites should have run // EnsureHeadCommitAsync, this message is the safety net for any that didn't. if (stderr.Contains("ambiguous argument 'HEAD'", StringComparison.Ordinal) || stderr.Contains("unknown revision", StringComparison.Ordinal)) throw new InvalidOperationException( $"The repository at {dir} has no commits yet (unborn or broken HEAD) — ClaudeDo needs at least one commit to work in it."); 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; } /// HEAD resolves to a commit (false for a fresh `git init` repo or a broken HEAD). public async Task HasHeadCommitAsync(string dir, CancellationToken ct = default) { // ^{commit} forces the object to actually exist and be a commit — a bare // `--verify HEAD` happily accepts a detached HEAD pointing at a missing object. var (exitCode, _, _) = await RunGitAsync(dir, ["rev-parse", "--verify", "--quiet", "HEAD^{commit}"], ct); return exitCode == 0; } private const string FallbackUserName = "ClaudeDo"; private const string FallbackUserEmail = "claudedo@local"; // Two parallel task starts on the same empty repo must create exactly one initial commit. private static readonly SemaphoreSlim BootstrapGate = new(1, 1); /// /// Guarantees HEAD resolves to a commit so worktrees can be created. A repo with an /// unborn HEAD (fresh `git init`) gets an empty initial commit built from plumbing only /// (hash-object/commit-tree/update-ref) — the user's index and working tree are never /// touched, so staged and untracked files stay exactly as they were. Identity is passed /// inline, so this also works on machines with no git identity configured. A repo whose /// HEAD is broken (not a symbolic ref and not resolvable) throws instead of guessing. /// /// true if a bootstrap commit was created; false if HEAD already resolved. public async Task EnsureHeadCommitAsync(string dir, CancellationToken ct = default) { if (await HasHeadCommitAsync(dir, ct)) return false; var (symExit, symRef, _) = await RunGitAsync(dir, ["symbolic-ref", "--quiet", "HEAD"], ct); if (symExit != 0) throw new InvalidOperationException( $"The repository at {dir} has a broken HEAD (detached onto a missing commit, or a corrupt .git/HEAD). Repair the repository manually."); await BootstrapGate.WaitAsync(ct); try { if (await HasHeadCommitAsync(dir, ct)) return false; var (treeExit, treeSha, treeErr) = await RunGitAsync(dir, ["mktree"], ct, stdinData: ""); if (treeExit != 0) throw new InvalidOperationException($"git mktree failed (exit {treeExit}): {treeErr}"); var (commitExit, commitSha, commitErr) = await RunGitAsync(dir, ["-c", $"user.name={FallbackUserName}", "-c", $"user.email={FallbackUserEmail}", "commit-tree", treeSha.Trim(), "-m", "chore: initialize repository (ClaudeDo)"], ct); if (commitExit != 0) throw new InvalidOperationException($"git commit-tree failed (exit {commitExit}): {commitErr}"); var (refExit, _, refErr) = await RunGitAsync(dir, ["update-ref", symRef.Trim(), commitSha.Trim()], ct); if (refExit != 0) throw new InvalidOperationException($"git update-ref failed (exit {refExit}): {refErr}"); return true; } finally { BootstrapGate.Release(); } } 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 && IsMissingIdentityError(stderr)) { // A machine without git identity configured must not fail every auto-commit — // retry once as ClaudeDo. The happy path pays no preflight cost. (exitCode, _, stderr) = await RunGitAsync(worktreePath, ["-c", $"user.name={FallbackUserName}", "-c", $"user.email={FallbackUserEmail}", "commit", "-F", "-"], ct, stdinData: message); } if (exitCode != 0) throw new InvalidOperationException($"git commit failed (exit {exitCode}): {stderr}"); } private static bool IsMissingIdentityError(string stderr) => stderr.Contains("tell me who you are", StringComparison.OrdinalIgnoreCase) || stderr.Contains("empty ident", StringComparison.OrdinalIgnoreCase) || stderr.Contains("no email was given", StringComparison.OrdinalIgnoreCase) || stderr.Contains("unable to auto-detect email address", StringComparison.OrdinalIgnoreCase); 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 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}"); } /// /// Re-applies a merge commit's own diff (against its first parent, `-m 1`) as a new commit on /// top of HEAD. Needed to redo a merge that was previously reverted: once a branch tip is an /// ancestor of HEAD, `git merge` on that branch again is a silent "Already up to date." no-op /// (ancestry, not content, is what merge checks) -- cherry-picking the original merge commit's /// diff is what actually restores the content. /// public async Task<(int ExitCode, string Stderr)> CherryPickMergeCommitAsync( string repoDir, string mergeCommitSha, CancellationToken ct = default) { var (exitCode, _, stderr) = await RunGitAsync(repoDir, ["-c", "merge.conflictStyle=diff3", "cherry-pick", "-m", "1", "--no-edit", mergeCommitSha], ct); return (exitCode, stderr); } public async Task IsMidCherryPickAsync(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, "CHERRY_PICK_HEAD")); } public async Task CherryPickAbortAsync(string repoDir, CancellationToken ct = default) { var (exitCode, _, stderr) = await RunGitAsync(repoDir, ["cherry-pick", "--abort"], ct); if (exitCode != 0) throw new InvalidOperationException($"git cherry-pick --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}"); } /// /// Stages as deleted (`git rm -f`), removing it from both the index /// and, if present, the working tree. Used to resolve a merge conflict to "keep the /// deletion" — unlike , which would stage an empty file as a /// tracked, zero-byte file rather than actually removing it. /// public async Task RemovePathAsync(string repoDir, string path, CancellationToken ct = default) { var (exitCode, _, stderr) = await RunGitAsync(repoDir, ["rm", "-f", "--", path], ct); if (exitCode != 0) throw new InvalidOperationException($"git rm '{path}' failed (exit {exitCode}): {stderr}"); } /// /// Content of at a merge stage (1=base, 2=ours, 3=theirs) via /// `git show :<stage>:<path>`. Null when that stage doesn't exist for the path — /// the conventional way to tell a modify/delete or rename/delete conflict apart from an /// ordinary content conflict (git never writes `<<<<<<<` markers for those, /// it just leaves whichever side wasn't deleted in the working tree). /// public async Task ShowConflictStageAsync(string repoDir, int stage, string path, CancellationToken ct = default) { var (exitCode, stdout, _) = await RunGitAsync(repoDir, ["show", $":{stage}:{path}"], ct, trimOutput: false); return exitCode == 0 ? stdout : null; } /// /// 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, // BOM-less: Encoding.UTF8 writes a BOM preamble, which git rejects as input // (mktree "input format error") and would prepend to stdin commit messages. StandardInputEncoding = stdinData is not null ? new UTF8Encoding(false) : 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()); } }