diff --git a/src/ClaudeDo.Data/Git/GitService.cs b/src/ClaudeDo.Data/Git/GitService.cs
index 5cb0fb30..eb3febbc 100644
--- a/src/ClaudeDo.Data/Git/GitService.cs
+++ b/src/ClaudeDo.Data/Git/GitService.cs
@@ -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;
}
+ /// 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);
@@ -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 GetDiffAsync(
string worktreePath, IReadOnlyList? 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);
diff --git a/tests/ClaudeDo.Worker.Tests/Runner/GitServiceBootstrapTests.cs b/tests/ClaudeDo.Worker.Tests/Runner/GitServiceBootstrapTests.cs
new file mode 100644
index 00000000..14924db9
--- /dev/null
+++ b/tests/ClaudeDo.Worker.Tests/Runner/GitServiceBootstrapTests.cs
@@ -0,0 +1,139 @@
+using ClaudeDo.Data.Git;
+using ClaudeDo.Worker.Tests.Infrastructure;
+
+namespace ClaudeDo.Worker.Tests.Runner;
+
+// Covers the empty-repo (unborn HEAD) hardening: HasHeadCommitAsync, EnsureHeadCommitAsync
+// (plumbing-only bootstrap commit), the friendly RevParseHeadAsync unborn message, and the
+// CommitAsync git-identity fallback. All tests run real git against throwaway repos.
+public class GitServiceBootstrapTests : IDisposable
+{
+ private readonly List _tempDirs = new();
+ private readonly GitService _git = new();
+
+ private static bool GitAvailable => GitRepoFixture.IsGitAvailable();
+
+ // A repo straight out of `git init` — no commits, and deliberately NO identity
+ // configured: the bootstrap must not depend on machine-level git config.
+ private string InitEmptyRepo()
+ {
+ var dir = Path.Combine(Path.GetTempPath(), $"claudedo_emptyrepo_{Guid.NewGuid():N}");
+ Directory.CreateDirectory(dir);
+ GitRepoFixture.RunGit(dir, "init", "-b", "main");
+ _tempDirs.Add(dir);
+ return dir;
+ }
+
+ [Fact]
+ public async Task HasHeadCommit_FalseOnEmptyRepo_TrueAfterCommit()
+ {
+ if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
+
+ var dir = InitEmptyRepo();
+ Assert.False(await _git.HasHeadCommitAsync(dir));
+
+ GitRepoFixture.RunGit(dir, "-c", "user.name=t", "-c", "user.email=t@t", "commit", "--allow-empty", "-m", "x");
+ Assert.True(await _git.HasHeadCommitAsync(dir));
+ }
+
+ [Fact]
+ public async Task EnsureHeadCommit_BootstrapsEmptyRepo_Once()
+ {
+ if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
+
+ var dir = InitEmptyRepo();
+
+ Assert.True(await _git.EnsureHeadCommitAsync(dir));
+
+ // HEAD now resolves and RevParseHeadAsync works — the original failure mode is gone.
+ var head = await _git.RevParseHeadAsync(dir);
+ Assert.Matches("^[0-9a-f]{40,64}$", head);
+
+ // The bootstrap commit is empty: nothing of the user's ends up in it.
+ var tree = GitRepoFixture.RunGit(dir, "ls-tree", "HEAD").Trim();
+ Assert.Equal(string.Empty, tree);
+
+ // Idempotent: a repo with a HEAD commit is left alone.
+ Assert.False(await _git.EnsureHeadCommitAsync(dir));
+ Assert.Equal(head, await _git.RevParseHeadAsync(dir));
+ }
+
+ [Fact]
+ public async Task EnsureHeadCommit_LeavesStagedFilesStaged()
+ {
+ if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
+
+ var dir = InitEmptyRepo();
+ File.WriteAllText(Path.Combine(dir, "mine.txt"), "user work\n");
+ GitRepoFixture.RunGit(dir, "add", "mine.txt");
+
+ Assert.True(await _git.EnsureHeadCommitAsync(dir));
+
+ // Still staged (index vs HEAD shows it as pending), NOT committed.
+ var staged = GitRepoFixture.RunGit(dir, "diff", "--cached", "--name-only").Trim();
+ Assert.Equal("mine.txt", staged);
+ var tree = GitRepoFixture.RunGit(dir, "ls-tree", "HEAD").Trim();
+ Assert.Equal(string.Empty, tree);
+ }
+
+ [Fact]
+ public async Task EnsureHeadCommit_CorruptHead_ThrowsClearError()
+ {
+ if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
+
+ var dir = InitEmptyRepo();
+ // Detach HEAD onto a commit that doesn't exist — not an unborn branch, a broken repo.
+ File.WriteAllText(Path.Combine(dir, ".git", "HEAD"), new string('f', 40) + "\n");
+
+ var ex = await Assert.ThrowsAsync(() => _git.EnsureHeadCommitAsync(dir));
+ Assert.Contains(dir, ex.Message);
+ }
+
+ [Fact]
+ public async Task RevParseHead_EmptyRepo_ReportsNoCommitsInsteadOfRawGitError()
+ {
+ if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
+
+ var dir = InitEmptyRepo();
+ var ex = await Assert.ThrowsAsync(() => _git.RevParseHeadAsync(dir));
+ Assert.Contains("no commits yet", ex.Message);
+ Assert.Contains(dir, ex.Message);
+ Assert.DoesNotContain("ambiguous argument", ex.Message);
+ }
+
+ [Fact]
+ public async Task CommitAsync_MissingIdentity_FallsBackToClaudeDoIdentity()
+ {
+ if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
+
+ var dir = InitEmptyRepo();
+ GitRepoFixture.RunGit(dir, "-c", "user.name=t", "-c", "user.email=t@t", "commit", "--allow-empty", "-m", "seed");
+ // Empty local identity overrides any machine-level config, so the first commit
+ // attempt fails with git's identity error no matter where this test runs.
+ GitRepoFixture.RunGit(dir, "config", "user.name", "");
+ GitRepoFixture.RunGit(dir, "config", "user.email", "");
+
+ File.WriteAllText(Path.Combine(dir, "work.txt"), "content\n");
+ await _git.AddAllAsync(dir);
+
+ await _git.CommitAsync(dir, "feat(test): identity fallback");
+
+ var author = GitRepoFixture.RunGit(dir, "log", "-1", "--format=%an <%ae>").Trim();
+ Assert.Equal("ClaudeDo ", author);
+ }
+
+ public void Dispose()
+ {
+ foreach (var dir in _tempDirs)
+ {
+ try
+ {
+ if (!Directory.Exists(dir)) continue;
+ foreach (var file in Directory.EnumerateFiles(dir, "*", SearchOption.AllDirectories))
+ File.SetAttributes(file, FileAttributes.Normal);
+ Directory.Delete(dir, true);
+ }
+ catch { /* best effort */ }
+ }
+ }
+}