diff --git a/CLAUDE.md b/CLAUDE.md index e22e1ac..4f952e0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -357,6 +357,12 @@ Non-obvious, load-bearing design points: leaving a host-configurable default unspecified. `WithUntrackedFiles(...)` replaces the default rather than joining it, so the vector never carries two `--untracked-files` values. +15. **`Log()` passes `--no-show-signature`.** With `log.showSignature=true`, which is common among + people who sign commits, git prints each signed commit's verification to standard output ahead + of the record even under a custom `--format`, and the parser reads it as part of the commit id. + `Commit()` reuses the `Log()` vector for its read-back, so without the flag a signed commit that + git did make is reported to the caller as a failure, and a retry makes a duplicate. + **Hosting layer.** `GitProvider` is an abstract base with two implementations: `GitHubProvider` over Octokit, and `AzureDevOpsProvider` over a raw `HttpClient` — Azure DevOps has no client library this library uses (see the dependency note below). Both go through the same shape: every request-issuing diff --git a/GitIntegration.Test/Builders/GitCommitBuilderTests.cs b/GitIntegration.Test/Builders/GitCommitBuilderTests.cs index 7360cf3..0496962 100644 --- a/GitIntegration.Test/Builders/GitCommitBuilderTests.cs +++ b/GitIntegration.Test/Builders/GitCommitBuilderTests.cs @@ -153,6 +153,21 @@ public async Task TheReadBackUsesThePinnedLogFormatAndAsksForOneCommitAsync() CollectionAssert.Contains(readBack, "-z"); } + [TestMethod] + public async Task TheReadBackSuppressesSignatureOutputAsync() + { + // With log.showSignature=true git prints the new commit's signature check ahead of the record, + // so a signed commit that git made would be reported to the caller as a failure. + ScriptedGitProcessRunner runner = new ScriptedGitProcessRunner() + .Then(standardOutput: "[main 9429d20] subject here\n") + .Then(standardOutput: ReadBack); + GitCommitBuilder builder = new(runner, TestPaths.Root, Message); + + _ = await builder.ExecuteAsync(TestContext.CancellationTokenSource.Token).ConfigureAwait(false); + + CollectionAssert.Contains(runner.Invocations[1].ToArray(), "--no-show-signature"); + } + [TestMethod] public async Task ThrowsNothingToCommitWhenTheTreeIsCleanAsync() { diff --git a/GitIntegration.Test/Builders/GitLogBuilderTests.cs b/GitIntegration.Test/Builders/GitLogBuilderTests.cs index 7ca2661..d3cd7d9 100644 --- a/GitIntegration.Test/Builders/GitLogBuilderTests.cs +++ b/GitIntegration.Test/Builders/GitLogBuilderTests.cs @@ -29,6 +29,7 @@ public void BuildsTheDefaultLogVector() "-c", "core.quotepath=false", "-c", "color.ui=false", "log", + "--no-show-signature", "-z", ExpectedFormat, ]; diff --git a/GitIntegration.Test/Integration/GitRoundTripTests.cs b/GitIntegration.Test/Integration/GitRoundTripTests.cs index 5492edd..5366994 100644 --- a/GitIntegration.Test/Integration/GitRoundTripTests.cs +++ b/GitIntegration.Test/Integration/GitRoundTripTests.cs @@ -111,6 +111,58 @@ public async Task AddAndCommitProduceAReadableCommitAsync() Assert.AreEqual(40, commit.Sha.WeakString.Length); } + [TestMethod] + public async Task LogAndCommitIgnoreTheHostsShowSignatureSettingAsync() + { + // log.showSignature is common among people who sign commits, and with it git prints each signed + // commit's verification ahead of the record, custom --format or not. The signature here is + // forged rather than made, so the test needs no key: git still hands it to gpg and prints what + // gpg says about it, which is all it takes to put text where the parser expects a commit id. + CancellationToken cancellationToken = TestContext.CancellationTokenSource.Token; + await IntegrationGitFixture.RequireGitAsync(cancellationToken).ConfigureAwait(false); + + using TemporaryRepository temporary = new(); + GitRepository repository = await InitializeAsync(temporary, cancellationToken).ConfigureAwait(false); + + temporary.WriteFile("a.txt", "one\n"); + _ = await repository.Add().All().ExecuteAsync(cancellationToken).ConfigureAwait(false); + _ = await repository.Commit("c1".As()).ExecuteAsync(cancellationToken).ConfigureAwait(false); + + IGitProcessRunner runner = repository.ProcessRunner!; + string unsigned = await new GitTextBuilder(runner, repository.LocalPath, "cat-file", "commit", "HEAD") + .ExecuteAsync(cancellationToken).ConfigureAwait(false); + + // The header block ends at the first blank line; gpgsig goes last in it, as git writes it. + int headerEnd = unsigned.IndexOf("\n\n", StringComparison.Ordinal); + string signed = unsigned[..headerEnd] + + "\ngpgsig -----BEGIN PGP SIGNATURE-----\n \n iQEzBAABCAAdFiEEAAAAAAAAAAAAAAAAAAAAAAAAAAAFAmYAAAAACgkQAAAAAAAAAAAA\n" + + " =AAAA\n -----END PGP SIGNATURE-----" + + unsigned[headerEnd..] + "\n"; + + // Written inside .git so the forged object never shows up as working-tree content. + temporary.WriteFile(".git/forged-commit", signed); + string forgedSha = await new GitTextBuilder( + runner, repository.LocalPath, "hash-object", "-t", "commit", "-w", ".git/forged-commit") + .ExecuteAsync(cancellationToken).ConfigureAwait(false); + _ = await new GitTextBuilder(runner, repository.LocalPath, "update-ref", "HEAD", forgedSha) + .ExecuteAsync(cancellationToken).ConfigureAwait(false); + _ = await new GitTextBuilder(runner, repository.LocalPath, "config", "log.showSignature", "true") + .ExecuteAsync(cancellationToken).ConfigureAwait(false); + + IReadOnlyList history = await repository.Log().ExecuteAsync(cancellationToken).ConfigureAwait(false); + + Assert.AreEqual(1, history.Count); + Assert.AreEqual(forgedSha, history[0].Sha.WeakString); + + temporary.WriteFile("b.txt", "two\n"); + _ = await repository.Add().All().ExecuteAsync(cancellationToken).ConfigureAwait(false); + GitCommit second = await repository.Commit("c2".As()) + .ExecuteAsync(cancellationToken).ConfigureAwait(false); + + Assert.AreEqual("c2", second.Subject); + Assert.AreEqual(forgedSha, second.ParentShas.Single().WeakString); + } + [TestMethod] public async Task CommittingWithNothingStagedThrowsTheDedicatedExceptionAsync() { diff --git a/GitIntegration/Builders/GitLogBuilder.cs b/GitIntegration/Builders/GitLogBuilder.cs index bc1305a..eb7bcbc 100644 --- a/GitIntegration/Builders/GitLogBuilder.cs +++ b/GitIntegration/Builders/GitLogBuilder.cs @@ -159,6 +159,11 @@ protected override void AppendVerbArguments(ICollection arguments) Ensure.NotNull(arguments); arguments.Add("log"); + + // log.showSignature makes git print each commit's signature check to standard output ahead of + // the record, custom --format or not, which the parser would read as part of the commit id. + // Commit() reuses this vector for its read-back, so the same flag keeps it working too. + arguments.Add("--no-show-signature"); arguments.Add("-z"); arguments.Add("--format=" + GitOutputFormats.LogFormat);