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