578 lines
26 KiB
C#
578 lines
26 KiB
C#
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<string> 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/<other>/commondir". Serialize them
|
|
// process-wide so parallel task starts don't collide.
|
|
private static readonly SemaphoreSlim WorktreeAddGate = new(1, 1);
|
|
|
|
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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// True if <paramref name="ancestorSha"/> is an ancestor of (or equal to) <paramref name="descendantSha"/>,
|
|
/// 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".
|
|
/// </summary>
|
|
public async Task<bool?> 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,
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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".
|
|
/// </summary>
|
|
public async Task<string?> 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<string> 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<string> 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<bool> HasChangesAsync(string worktreePath, CancellationToken ct = default) =>
|
|
HasChangesAsync(worktreePath, includeUntracked: true, ct);
|
|
|
|
/// <summary>
|
|
/// Uncommitted-changes check. <paramref name="includeUntracked"/>=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.
|
|
/// </summary>
|
|
public async Task<bool> 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<string> GetDiffAsync(
|
|
string worktreePath, IReadOnlyList<string>? paths = null, CancellationToken ct = default)
|
|
{
|
|
var args = new List<string> { "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<string> { "diff", "--cached" };
|
|
AppendPathFilter(cachedArgs, paths);
|
|
var (e2, s2, _) = await RunGitAsync(worktreePath, cachedArgs, 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, IReadOnlyList<string>? paths = null, CancellationToken ct = default)
|
|
{
|
|
var args = new List<string> { "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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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).
|
|
/// </summary>
|
|
public async Task<string> GetCommitRangeDiffAsync(
|
|
string repoDir, string baseCommit, string headCommit, IReadOnlyList<string>? paths = null, CancellationToken ct = default)
|
|
{
|
|
var args = new List<string> { "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<string> DiffStatAsync(
|
|
string worktreePath, string baseCommit, string headCommit, IReadOnlyList<string>? paths = null, CancellationToken ct = default)
|
|
{
|
|
var args = new List<string> { "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 `-- <paths>` 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<string> args, IReadOnlyList<string>? paths)
|
|
{
|
|
if (paths is not { Count: > 0 }) return;
|
|
args.Add("--");
|
|
args.AddRange(paths);
|
|
}
|
|
|
|
public async Task<string> 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<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)
|
|
{
|
|
// 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<bool> 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"));
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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).
|
|
/// </summary>
|
|
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<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 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}");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Non-destructive mergeability probe via `git merge-tree --write-tree`. Writes only
|
|
/// loose objects — the working tree, index, and refs are left untouched.
|
|
/// </summary>
|
|
public async Task<MergePreview> 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<string>(), stdout.Trim());
|
|
|
|
if (exitCode == 1)
|
|
{
|
|
// stdout: <tree-oid>\n<file>\n...\n\n<informational messages>
|
|
var lines = stdout.Split('\n');
|
|
var files = new List<string>();
|
|
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<string>());
|
|
}
|
|
|
|
/// <summary>Resolves <paramref name="revision"/> (a branch, tag, or SHA) to a full commit SHA.</summary>
|
|
public async Task<string> 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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates a new commit object wrapping <paramref name="treeOid"/> with <paramref name="parentSha"/>
|
|
/// as its single parent, writing only a loose object — no ref is created or moved.
|
|
/// </summary>
|
|
public async Task<string> 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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Materializes <paramref name="commitish"/> into a new, branchless worktree at
|
|
/// <paramref name="worktreePath"/> (detached HEAD) — used to build/verify a merge-tree
|
|
/// result without ever creating a branch or touching the real working tree.
|
|
/// </summary>
|
|
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();
|
|
}
|
|
}
|
|
|
|
/// <summary>Count of files that differ on <paramref name="sourceBranch"/> since its merge base with the target.</summary>
|
|
public async Task<int> 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);
|
|
}
|
|
|
|
/// <summary>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
|
|
/// <see cref="CountChangedFilesAsync"/>'s 3-dot count of a branch's own changes.</summary>
|
|
public async Task<IReadOnlyList<string>> 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<string>();
|
|
return stdout
|
|
.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
|
.Where(s => s.Length > 0)
|
|
.ToList();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Rebases the branch checked out at <paramref name="worktreePath"/> onto <paramref name="ontoRef"/>.
|
|
/// 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; <c>ConflictFiles</c> is best-effort and only
|
|
/// populated when the failure was an actual conflict.
|
|
/// </summary>
|
|
public async Task<(int ExitCode, string Stderr, IReadOnlyList<string> 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<string>());
|
|
|
|
List<string> 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<string> 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());
|
|
}
|
|
}
|