RevParseHeadAsync and CommitAsync's identity-error detection match English-only substrings against git stderr. Under a non-English OS/git locale these silently fail to match, falling through to a generic error. Force LC_ALL=C and LANGUAGE=C on the git ProcessStartInfo so git always emits English output regardless of ambient locale.
195 lines
7.9 KiB
C#
195 lines
7.9 KiB
C#
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<string> _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<InvalidOperationException>(() => _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<InvalidOperationException>(() => _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 <claudedo@local>", author);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RevParseHead_EmptyRepo_FriendlyMessage_EvenUnderNonEnglishLocale()
|
|
{
|
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
|
|
|
var dir = InitEmptyRepo();
|
|
var previousLanguage = Environment.GetEnvironmentVariable("LANGUAGE");
|
|
var previousLcAll = Environment.GetEnvironmentVariable("LC_ALL");
|
|
// Simulate a non-English OS/git locale on the *ambient* process environment.
|
|
// GitService must override it per-call so its English-only stderr matches
|
|
// (e.g. "ambiguous argument 'HEAD'") keep working regardless.
|
|
Environment.SetEnvironmentVariable("LANGUAGE", "de");
|
|
Environment.SetEnvironmentVariable("LC_ALL", "de_DE.UTF-8");
|
|
try
|
|
{
|
|
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => _git.RevParseHeadAsync(dir));
|
|
Assert.Contains("no commits yet", ex.Message);
|
|
}
|
|
finally
|
|
{
|
|
Environment.SetEnvironmentVariable("LANGUAGE", previousLanguage);
|
|
Environment.SetEnvironmentVariable("LC_ALL", previousLcAll);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CommitAsync_MissingIdentity_FallsBackToClaudeDoIdentity_EvenUnderNonEnglishLocale()
|
|
{
|
|
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");
|
|
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);
|
|
|
|
var previousLanguage = Environment.GetEnvironmentVariable("LANGUAGE");
|
|
var previousLcAll = Environment.GetEnvironmentVariable("LC_ALL");
|
|
Environment.SetEnvironmentVariable("LANGUAGE", "de");
|
|
Environment.SetEnvironmentVariable("LC_ALL", "de_DE.UTF-8");
|
|
try
|
|
{
|
|
await _git.CommitAsync(dir, "feat(test): identity fallback under non-English locale");
|
|
|
|
var author = GitRepoFixture.RunGit(dir, "log", "-1", "--format=%an <%ae>").Trim();
|
|
Assert.Equal("ClaudeDo <claudedo@local>", author);
|
|
}
|
|
finally
|
|
{
|
|
Environment.SetEnvironmentVariable("LANGUAGE", previousLanguage);
|
|
Environment.SetEnvironmentVariable("LC_ALL", previousLcAll);
|
|
}
|
|
}
|
|
|
|
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 */ }
|
|
}
|
|
}
|
|
}
|