Skip to content

git: Consolidate signing UX for Commit and Tag objects, and introduce auto-sign via new plugin package#1860

Merged
pjbgf merged 14 commits intogo-git:mainfrom
pjbgf:sign-plugin
Mar 24, 2026
Merged

git: Consolidate signing UX for Commit and Tag objects, and introduce auto-sign via new plugin package#1860
pjbgf merged 14 commits intogo-git:mainfrom
pjbgf:sign-plugin

Conversation

@pjbgf
Copy link
Member

@pjbgf pjbgf commented Feb 18, 2026

Summary

These changes align the user experience, so that both Tags and Commits
share the same API.

Users can now define a signer at application-level, via new plugin package,
to auto-sign objects when tag.gpgSign=true or commit.gpgSign=true, better
aligning with Git behaviour.

Signers will be implemented as an ObjectSigner plugin out-of-tree. The go-git
project will host some options (e.g. ssh, gpg and auto) in github.com/go-git/x,
but users are recommended to implement their own signers whenever their use-case
requires it.

New plugin package

A new plugin package was introduced to provide a generic, thread-safe
registry for plugin factory functions. It enables off-tree implementations
to be registered and retrieved at runtime.

The plugin-based approach comes with two additional benefits:

  • Users can implement their own signers in line with their needs, without
    needing to make changes to the core go-git codebase.
  • External dependencies brought by such implementations won't be forced on
    go-git users that do not require those features. This is temporarily not
    true for signature verifiers, but that will change in the near future.

Existing registration mechanisms (e.g. hash.RegisterHash) will be reviewed and
potentially replaced by the new plugin package.

New signing API UX

Here are a few examples of what using the new API should look like.

Signing all tags and commits via ObjectSigner plugin

import (
	"github.com/go-git/go-git/v6/x/plugin"
	"github.com/go-git/x/plugin/objectsigner/ssh"
	// "github.com/go-git/x/plugin/objectsigner/gpg"
)

func main() {
	key, _ := getKeyFromAgent() // key can be source from memory, file, etc.

	plugin.Register(plugin.ObjectSigner(),
			func() plugin.Signer { 
				// return gpg.FromKey(key)
				return ssh.FromKey(key, ssh.SHA512)
			})
	
	...

	commit, err := w.Commit("example go-git commit", &git.CommitOptions{
		Author: &object.Signature{
			Name:  "John Doe",
			Email: "john@doe.org",
			When:  time.Now(),
		},
	})
}

Signing single commit using objectsigner/{ssh,gpg} package

import (
	"github.com/go-git/x/plugin/objectsigner/ssh"
	// "github.com/go-git/x/plugin/objectsigner/gpg"
)

func main() {
	key, _ := getKeyFromAgent() // key can be source from memory, file, etc.
	commit, err := w.Commit("example go-git commit", &git.CommitOptions{
		// Signer: gpg.FromKey(key)
		Signer: ssh.FromKey(key, ssh.SHA512),

		Author: &object.Signature{
			Name:  "John Doe",
			Email: "john@doe.org",
			When:  time.Now(),
		},
	})
}

Signing single tag using objectsigner/{ssh,gpg} package

import (
	"github.com/go-git/x/plugin/objectsigner/ssh"
	// "github.com/go-git/x/plugin/objectsigner/gpg"
)

func main() {
	key, _ := getKeyFromAgent() // key can be source from memory, file, etc.

	_, err = r.CreateTag("foobar", h.Hash(), &CreateTagOptions{
		// Signer: gpg.FromKey(key)
		Signer: ssh.FromKey(key, ssh.SHA512),

		Tagger:  defaultSignature(),
		Message: "foo bar baz qux",
	})
}

Relates to #1849.
Follow-up from #1847.
Supersedes: #1828.


⚠️ This is a breaking change as now System and Global configs are loaded from the ConfigLoader plugin as opposed to directly from disk/env - and by default both return empty configs. This provides greater control for applications that don't want to be exposed to side effects from environmental configuration. A follow-up PR will introduce an opt-in way to align with upstream Git by introducing additional ConfigLoader implementations.

Copy link
Contributor

@cedric-appdirect cedric-appdirect left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had some time to look at this PR and I see the benefit for having this plugins out-of-tree. I think we would need to add the Verifier API with trust level as I did in my draft PR to complete the design. I can work on that as a follow up to this PR if you don't have time.

I don't see any issue in using this API to degin signing or even verifying plugins once the API is added following the same principle.

@pjbgf
Copy link
Member Author

pjbgf commented Feb 27, 2026

@cedric-appdirect thanks for the review. I've created an issue to track the verification part of this feature. Feel free to start a discussion there in case you want to pick that issue up.

@pjbgf pjbgf marked this pull request as ready for review February 27, 2026 23:24
@pjbgf pjbgf requested review from Copilot and hiddeco February 27, 2026 23:24
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces a new x/plugin registry to enable application-level registration of an object signer and updates commit/tag creation to optionally auto-sign objects when commit.gpgSign=true or tag.gpgSign=true, aligning commit/tag signing UX and behavior more closely with Git.

Changes:

  • Add a generic, thread-safe plugin registry (x/plugin) and define an ObjectSigner plugin key.
  • Update commit and tag creation to select a signer from explicit options first, then fall back to the ObjectSigner plugin when config enables auto-signing.
  • Extend config support/tests for commit.gpgSign, tag.gpgSign, gpg.format, gpg "ssh".allowedSignersFile, and user.signingKey.

Reviewed changes

Copilot reviewed 10 out of 10 changed files in this pull request and generated 9 comments.

Show a summary per file
File Description
x/plugin/plugin.go Implements the plugin registry core (register/get/freeze) and test-reset hook.
x/plugin/plugin_signer.go Defines the ObjectSigner plugin key and plugin-level Signer interface.
x/plugin/plugin_test.go Adds registry behavior tests (freeze, overwrite, concurrency, etc.).
worktree_commit.go Adds commit auto-sign selection logic using config + ObjectSigner plugin.
worktree_commit_test.go Replaces PGP signing tests with signer-selection tests using a mock signer + linkname reset.
repository.go Adds tag auto-sign selection logic and updates tag signing to use Signer instead of PGP key.
repository_test.go Replaces PGP tag signing tests with signer-selection tests using the mock signer + linkname reset.
options.go Replaces CreateTagOptions.SignKey with CreateTagOptions.Signer.
config/config.go Adds config fields and marshal/unmarshal support for signing-related keys.
config/config_test.go Adds tests for new config keys and round-trip behavior.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

pjbgf and others added 12 commits March 24, 2026 07:15
Signed-off-by: Paulo Gomes <pjbgf@linux.com>
The plugin package provides a generic, thread-safe registry for plugin
factory functions. It enables off-tree implementations to be registered and
retrieved at runtime.

The first plugin type introduced is ObjectSigner. When registered, the plugin
will define a function factory which will be used when creating commits or tags.

Signed-off-by: Paulo Gomes <pjbgf@linux.com>
When no CommitOptions.Signer is provided, buildCommitObject falls back to
the globally registered ObjectSigner plugin. An explicit CommitOptions.Signer
always takes precedence over the plugin.

Signed-off-by: Paulo Gomes <pjbgf@linux.com>
These changes align the user experience, so that both Tags and Commits
share the same API.

The implementation details are now pushed back into the out-of-tree
plugins, removing external dependencies from the core go-git logic.

Signed-off-by: Paulo Gomes <pjbgf@linux.com>
Add support for GPG configuration in git config, following existing
patterns for User, Author, and Committer sections.

New configuration options:
- gpg.format: signature format ("openpgp" or "ssh")
- gpg.ssh.allowedSignersFile: path to SSH allowed signers file

This enables reading Git's gpg.ssh.allowedSignersFile setting for
SSH signature verification workflows.
Expand on sections/keys exposed by config.Config so that go-git can
implement heuristics to automatically GPG/SSH sign objects.

Added fields: tag.gpgSign, commit.gpgSign and user.signingKey.

Signed-off-by: Paulo Gomes <pjbgf@linux.com>
Auto-signing objects now respect the config values for tag.gpgSign and commit.gpgSign,
meaning that when an ObjectSigner is registered, it will only be used when either setting
is enabled.

Signed-off-by: Paulo Gomes <pjbgf@linux.com>
Signed-off-by: Paulo Gomes <pjbgf@linux.com>
Signed-off-by: Paulo Gomes <paulo@entire.io>
Signed-off-by: Paulo Gomes <paulo@entire.io>
Plain bool fields are indistinguishable from "not set" when their value
is false, because config.Merge skips zero-valued fields. This prevented
a lower-scope tag.gpgSign=false (or commit.gpgSign=false) from
overriding a higher-scope true, which broke Git's "last scope wins"
semantics and could force unexpected auto-signing.

Replace the bool fields with OptBool, a tri-state type whose zero value
(OptBoolUnset) is correctly skipped by Merge, while OptBoolFalse (1) is
non-zero and propagates through the merge chain. Also fix stale error
message strings in tests left over by the go fix pass, and make tests
resilient to host-level commit.gpgSign settings.

Signed-off-by: Paulo Gomes <paulo@entire.io>
Assisted-by: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Paulo Gomes <paulo@entire.io>
Add a ConfigSource plugin interface with a single Load(scope) method
that returns a config.ConfigStorer for the requested scope (global or
system). This decouples config loading from the host filesystem, allowing
implementations backed by environment variables, in-memory data, or
remote sources.

Key changes:
- x/plugin: Define ConfigSource interface and ConfigLoader plugin key
- x/plugin/config: Add Empty, Static, and readOnlyStorer implementations
- repository: ConfigScoped uses the registered ConfigSource plugin,
  falling back to NewEmpty when none is registered
- config: Deprecate LoadConfig in favour of the new Load function
- tests: Register a default static ConfigSource in TestMain so all
  tests are isolated from host git configuration

Signed-off-by: Paulo Gomes <paulo@entire.io>
Assisted-by: Claude Opus 4.6 <noreply@anthropic.com>
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 25 out of 25 changed files in this pull request and generated 3 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

cloneConfig: deep-copy slice fields in RemoteConfig (URLs, Fetch) and
URL (InsteadOfs) so in-place mutations do not leak across copies.
Guard against nil map values that would panic on dereference. Fully
deep-copy Raw (format/config.Config) including sections, subsections,
options, and includes instead of a shallow struct copy.

ConfigScoped: load and return the local config without requiring a
ConfigLoader plugin when scope is LocalScope, aligning with the
ConfigSource contract that Load is never called with LocalScope.

Signed-off-by: Paulo Gomes <paulo@entire.io>
Assisted-by: Claude Opus 4.6 <noreply@anthropic.com>
@pjbgf pjbgf requested a review from cedric-appdirect March 24, 2026 15:13
Copy link
Contributor

@cedric-appdirect cedric-appdirect left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's go!

@pjbgf pjbgf merged commit cd85c8c into go-git:main Mar 24, 2026
16 checks passed
@pjbgf pjbgf deleted the sign-plugin branch March 24, 2026 22:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants