Merge branch 'claudedo/340b4a2df0f14235a34fe0d02b2dc06f'

This commit is contained in:
mika kuns
2026-08-10 15:01:33 +02:00
8 changed files with 476 additions and 28 deletions
+62 -2
View File
@@ -3,7 +3,10 @@ using System.Text;
namespace ClaudeDo.Data.Git;
public sealed record MergePreview(bool Supported, bool Clean, IReadOnlyList<string> ConflictFiles);
// 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
{
@@ -373,7 +376,8 @@ public sealed class GitService
["merge-tree", "--write-tree", "--name-only", targetBranch, sourceBranch], ct);
if (exitCode == 0)
return new MergePreview(true, true, Array.Empty<string>());
// 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)
{
@@ -393,6 +397,62 @@ public sealed class GitService
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)