fix(git): bootstrap commit-less repos and survive missing git identity
A fresh 'git init' repo (unborn HEAD) failed every worktree-based flow with raw
git stderr ('ambiguous argument HEAD'). EnsureHeadCommitAsync now creates an
empty initial commit via plumbing only (mktree/commit-tree/update-ref) so the
user's index and working tree are untouched; broken HEADs get a clear error.
CommitAsync retries once with a ClaudeDo identity when the machine has none,
and RevParseHeadAsync reports 'no commits yet' instead of raw git output.
Also: git stdin is now BOM-less UTF-8 (git rejects BOM-prefixed input).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 21af231f3e7b2cf056efe475916627f0cfeeb5c6)
This commit is contained in:
@@ -25,7 +25,16 @@ public sealed class GitService
|
||||
{
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -55,6 +64,67 @@ public sealed class GitService
|
||||
return exitCode == 0 ? stdout.Trim() : null;
|
||||
}
|
||||
|
||||
/// <summary>HEAD resolves to a commit (false for a fresh `git init` repo or a broken HEAD).</summary>
|
||||
public async Task<bool> 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);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <returns>true if a bootstrap commit was created; false if HEAD already resolved.</returns>
|
||||
public async Task<bool> 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);
|
||||
@@ -135,10 +205,24 @@ public sealed class GitService
|
||||
{
|
||||
// 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<string> GetDiffAsync(
|
||||
string worktreePath, IReadOnlyList<string>? paths = null, CancellationToken ct = default)
|
||||
{
|
||||
@@ -584,7 +668,9 @@ public sealed class GitService
|
||||
CreateNoWindow = true,
|
||||
StandardOutputEncoding = Encoding.UTF8,
|
||||
StandardErrorEncoding = Encoding.UTF8,
|
||||
StandardInputEncoding = stdinData is not null ? Encoding.UTF8 : null,
|
||||
// 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);
|
||||
|
||||
Reference in New Issue
Block a user