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 */ } + } + } +}