diff --git a/GitIntegration.Test/Integration/GitRoundTripTests.cs b/GitIntegration.Test/Integration/GitRoundTripTests.cs index ea7218a..ab2b568 100644 --- a/GitIntegration.Test/Integration/GitRoundTripTests.cs +++ b/GitIntegration.Test/Integration/GitRoundTripTests.cs @@ -927,6 +927,59 @@ public async Task RevParseRefusesARevisionThatNamesNoCommitAsync() Assert.IsFalse(result.Success); } + [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; + } }