From b4f23581b979eb1e419c1de4aac18c83005c8e8b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 18:28:28 +0000 Subject: [PATCH] Return an empty Log() on a repository with no commits yet [patch] On an unborn HEAD, git log exits 128 ("does not have any commits yet", or "unknown revision 'HEAD'" once ExcludingRemoteTrackingRefs names HEAD), while IncludingAllRefs on the same repository exits 0 with no output. So Log() returned an empty list or threw depending only on the options chosen. When log fails and the caller named no revision of their own, Log() now probes `rev-parse --verify -q HEAD`. Exit 1 (HEAD does not resolve) means unborn, and the result is an empty list. Any other failure, and any explicit ForRevision, still throws as before. The probe runs only after a failure, so the common case still costs one invocation. Fixes #233 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018CRjG9vX7hK9TShFL3iJh3 --- .../Integration/GitRoundTripTests.cs | 53 +++++++++++++++ GitIntegration/Builders/GitLogBuilder.cs | 67 +++++++++++++++++++ 2 files changed, 120 insertions(+) diff --git a/GitIntegration.Test/Integration/GitRoundTripTests.cs b/GitIntegration.Test/Integration/GitRoundTripTests.cs index 24bddf4..7aef273 100644 --- a/GitIntegration.Test/Integration/GitRoundTripTests.cs +++ b/GitIntegration.Test/Integration/GitRoundTripTests.cs @@ -824,6 +824,59 @@ public async Task CheckoutResolvesATagRatherThanAFileOfTheSameNameAsync() Assert.IsTrue(status.IsDetached); } + [TestMethod] + public async Task LogReturnsAnEmptyListBeforeTheFirstCommitAsync() + { + // An unborn HEAD makes git log exit 128, with different text depending on whether HEAD was + // implied or named, while --all on the same repository exits 0 with no output. A history + // view opened on a fresh repository should see no commits whichever options it chose. + CancellationToken cancellationToken = TestContext.CancellationTokenSource.Token; + await IntegrationGitFixture.RequireGitAsync(cancellationToken).ConfigureAwait(false); + + using TemporaryRepository temporary = new(); + GitRepository repository = await InitializeAsync(temporary, cancellationToken).ConfigureAwait(false); + + IGitLogBuilder[] builders = + [ + repository.Log(), + repository.Log().ForPath("a.txt".As()), + repository.Log().ExcludingRemoteTrackingRefs(), + repository.Log().IncludingAllRefs(), + ]; + + foreach (IGitLogBuilder builder in builders) + { + IReadOnlyList commits = await builder.ExecuteAsync(cancellationToken).ConfigureAwait(false); + Assert.AreEqual(0, commits.Count); + } + + GitResult> result = + await repository.Log().TryExecuteAsync(cancellationToken).ConfigureAwait(false); + Assert.IsTrue(result.Success); + Assert.AreEqual(0, result.Value!.Count); + } + + [TestMethod] + public async Task LogStillRefusesAnUnknownRevisionBeforeTheFirstCommitAsync() + { + // Only the implied HEAD is forgiven. A revision the caller named is a question about that + // revision, and on an empty repository it does not exist. + CancellationToken cancellationToken = TestContext.CancellationTokenSource.Token; + await IntegrationGitFixture.RequireGitAsync(cancellationToken).ConfigureAwait(false); + + using TemporaryRepository temporary = new(); + GitRepository repository = await InitializeAsync(temporary, cancellationToken).ConfigureAwait(false); + + await Assert.ThrowsExactlyAsync( + async () => await repository.Log().ForRevision("does-not-exist".As()) + .ExecuteAsync(cancellationToken).ConfigureAwait(false)) + .ConfigureAwait(false); + + GitResult> result = await repository.Log().ForRevision("does-not-exist".As()) + .TryExecuteAsync(cancellationToken).ConfigureAwait(false); + Assert.IsFalse(result.Success); + } + [TestMethod] public async Task DiffReportsLineCountsFromARealRepositoryAsync() { diff --git a/GitIntegration/Builders/GitLogBuilder.cs b/GitIntegration/Builders/GitLogBuilder.cs index f66b843..347cd53 100644 --- a/GitIntegration/Builders/GitLogBuilder.cs +++ b/GitIntegration/Builders/GitLogBuilder.cs @@ -5,6 +5,8 @@ namespace ktsu.GitIntegration; using System; using System.Collections.Generic; using System.Globalization; +using System.Threading; +using System.Threading.Tasks; using ktsu.Semantics.Paths; @@ -246,4 +248,69 @@ protected override void AppendVerbArguments(ICollection arguments) /// protected override IReadOnlyList ParseResult(GitProcessResult result) => GitLogParser.Parse(Ensure.NotNull(result).StandardOutput); + + /// + public override async Task> ExecuteAsync(CancellationToken cancellationToken = default) + { + try + { + return await base.ExecuteAsync(cancellationToken).ConfigureAwait(false); + } + catch (GitCommandException) when (ReadsFromHead) + { + if (await IsHeadUnbornAsync(cancellationToken).ConfigureAwait(false)) + { + return []; + } + + throw; + } + } + + /// + public override async Task>> TryExecuteAsync(CancellationToken cancellationToken = default) + { + GitResult> result = await base.TryExecuteAsync(cancellationToken).ConfigureAwait(false); + + return !result.Success && ReadsFromHead && await IsHeadUnbornAsync(cancellationToken).ConfigureAwait(false) + ? GitResult>.FromValue([]) + : result; + } + + /// + /// Whether the log walks from HEAD because the caller named no starting point of their own. + /// + /// + /// walks every reference instead, and git already answers that + /// with an empty log on a repository with no commits. An explicit is + /// left alone so a revision that does not exist still fails. + /// + private bool ReadsFromHead => _revision is null && !_includingAllRefs; + + /// + /// Asks git whether HEAD names a branch with no commits yet, as it does straight after + /// init. + /// + /// + /// + /// Taken only after log has already failed, so the common case still costs one + /// invocation. On an unborn HEAD log exits 128 with "does not have any commits yet", or + /// with "unknown revision" once names HEAD explicitly, + /// so the failure alone does not say which it was, and matching both texts would be brittle. + /// + /// + /// rev-parse --verify -q HEAD exits 1 with no output when HEAD does not resolve, and 128 + /// for everything else that can go wrong, such as the path not being a repository at all, so + /// only exit code 1 is read as unborn. Verified against git 2.43. + /// + /// + /// Cancels the probe. + /// when HEAD has no commit to resolve to. + private async Task IsHeadUnbornAsync(CancellationToken cancellationToken) + { + GitResult probe = await new GitTextBuilder(Runner, RepositoryPath, "rev-parse", "--verify", "-q", "HEAD") + .TryExecuteAsync(cancellationToken).ConfigureAwait(false); + + return !probe.Success && probe.Error?.ExitCode == 1; + } }