diff --git a/Taskfile.yml b/Taskfile.yml index 2dc4381..cea54d9 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -38,6 +38,12 @@ tasks: cmds: - go mod tidy + test: + desc: Run all of the tests + cmds: + - task: unit-test + - task: docker-test + unit-test: desc: Run the unit tests vars: @@ -47,6 +53,14 @@ tasks: cmds: - go test {{.TEST_OPTIONS}} -covermode=atomic -coverprofile={{.COVER_PROFILE}} {{.TEST_FORMAT}} ./... + docker-test: + desc: Run the docker-based tests + cmds: + - docker build . -t gitz-test:latest -f testdata/Dockerfile --build-arg test=tagsigned + - docker build . -t gitz-test:latest -f testdata/Dockerfile --build-arg test=tagsigningkey + - docker build . -t gitz-test:latest -f testdata/Dockerfile --build-arg test=commitsigned + - docker build . -t gitz-test:latest -f testdata/Dockerfile --build-arg test=commitsigningkey + lint: desc: Lint the code using golangci-lint vars: diff --git a/commit.go b/commit.go index 3f7d00f..36fdb47 100644 --- a/commit.go +++ b/commit.go @@ -22,10 +22,133 @@ SOFTWARE. package git -import "fmt" +import ( + "fmt" + "strings" +) + +// CommitOption provides a way for setting specific options during a commit +// operation. Each supported option can customize the way the commit is +// created against the current repository (working directory) +type CommitOption func(*commitOptions) + +type commitOptions struct { + AllowEmpty bool + ForceNoSigned bool + Signed bool + SigningKey string +} + +// WithAllowEmpty allows a commit to be created without having to track +// any changes. This bypasses the default protection by git, preventing +// a commit from having the exact same tree as its parent +func WithAllowEmpty() CommitOption { + return func(opts *commitOptions) { + opts.AllowEmpty = true + } +} + +// WithGpgSign will create a GPG-signed commit using the GPG key associated +// with the committers email address. Overriding this behavior is possible +// through the user.signingkey config setting. This option does not need +// to be explicitly called if the commit.gpgSign config setting is set to +// true +func WithGpgSign() CommitOption { + return func(opts *commitOptions) { + opts.Signed = true + } +} + +// WithGpgSigningKey will create a GPG-signed commit using the provided GPG +// key ID, overridding any default GPG key set by the user.signingKey git +// config setting +func WithGpgSigningKey(key string) CommitOption { + return func(opts *commitOptions) { + opts.Signed = true + opts.SigningKey = strings.TrimSpace(key) + } +} + +// WithNoGpgSign ensures the created commit will not be GPG signed +// regardless of the value assigned to the repositories commit.gpgSign +// git config setting +func WithNoGpgSign() CommitOption { + return func(opts *commitOptions) { + opts.ForceNoSigned = true + } +} // Commit a snapshot of changes within the current repository (working directory) -// and describe those changes with a given log message -func (c *Client) Commit(msg string) (string, error) { - return c.exec(fmt.Sprintf("git commit -m '%s'", msg)) +// and describe those changes with a given log message. Commit behavior can be +// customized through the use of options +func (c *Client) Commit(msg string, opts ...CommitOption) (string, error) { + options := &commitOptions{} + for _, opt := range opts { + opt(options) + } + + var commitCmd strings.Builder + commitCmd.WriteString("git commit") + + if options.AllowEmpty { + commitCmd.WriteString(" --allow-empty") + } + + if options.Signed { + commitCmd.WriteString(" -S") + } + + if options.SigningKey != "" { + commitCmd.WriteString(" --gpg-sign=" + options.SigningKey) + } + + if options.ForceNoSigned { + commitCmd.WriteString(" --no-gpg-sign") + } + + commitCmd.WriteString(fmt.Sprintf(" -m '%s'", msg)) + return c.exec(commitCmd.String()) +} + +const ( + authorPrefix = "author " + committerPrefix = "committer " + emailEnd = '>' +) + +// CommitVerification contains details about a GPG signed commit +type CommitVerification struct { + Sha string + Author Author + Committer Author + Fingerprint string + SignedBy *Author +} + +// VerifyCommit validates that a given commit has a valid GPG signature +// and returns details about that signature +func (c *Client) VerifyCommit(sha string) (*CommitVerification, error) { + out, err := c.exec("git verify-commit -v " + sha) + if err != nil { + return nil, err + } + + author := chompUntil(out[strings.Index(out, authorPrefix)+len(authorPrefix):], emailEnd) + committer := chompUntil(out[strings.Index(out, committerPrefix)+len(committerPrefix):], emailEnd) + fingerprint := chompCRLF(out[strings.Index(out, fingerprintPrefix)+len(fingerprintPrefix):]) + + var signedByAuthor *Author + if strings.Contains(out, signedByPrefix) { + signedBy := chompUntil(out[strings.Index(out, signedByPrefix)+len(signedByPrefix):], '"') + author := parseAuthor(signedBy) + signedByAuthor = &author + } + + return &CommitVerification{ + Sha: sha, + Author: parseAuthor(author), + Committer: parseAuthor(committer), + Fingerprint: fingerprint, + SignedBy: signedByAuthor, + }, nil } diff --git a/commit_test.go b/commit_test.go index f0832b2..48bd391 100644 --- a/commit_test.go +++ b/commit_test.go @@ -45,15 +45,22 @@ func TestCommit(t *testing.T) { assert.Equal(t, lastCommit.Message, "this is an example commit message") } -func TestCommitCleanWorkingTreeError(t *testing.T) { +func TestCommitWithAllowEmpty(t *testing.T) { gittest.InitRepository(t) client, _ := git.NewClient() - _, err := client.Commit("this is an example commit message") + _, err := client.Commit("this will be a an empty commit", git.WithAllowEmpty()) + + require.NoError(t, err) +} - var errGit git.ErrGitExecCommand - require.ErrorAs(t, err, &errGit) +func TestCommitWithNoGpgSign(t *testing.T) { + gittest.InitRepository(t, gittest.WithFiles("test.txt")) + gittest.ConfigSet(t, "user.signingkey", "DOES-NOT-EXIST", "commit.gpgsign", "true") + gittest.StageFile(t, "test.txt") - assert.Equal(t, "git commit -m 'this is an example commit message'", errGit.Cmd) - assert.Contains(t, errGit.Out, "nothing to commit, working tree clean") + client, _ := git.NewClient() + _, err := client.Commit("this will be a regular commit", git.WithNoGpgSign()) + + require.NoError(t, err) } diff --git a/go.mod b/go.mod index c23dc9d..93cdf91 100644 --- a/go.mod +++ b/go.mod @@ -11,7 +11,8 @@ require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect golang.org/x/sync v0.1.0 // indirect - golang.org/x/sys v0.3.0 // indirect - golang.org/x/term v0.3.0 // indirect + golang.org/x/sys v0.7.0 // indirect + golang.org/x/term v0.5.0 // indirect + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 11f3946..25278df 100644 --- a/go.sum +++ b/go.sum @@ -4,7 +4,10 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/frankban/quicktest v1.14.4 h1:g2rn0vABPOOXmZUj+vbmUp0lPoXEMuhTpIluN0XL9UY= github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -18,12 +21,13 @@ github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sys v0.3.0 h1:w8ZOecv6NaNa/zC8944JTU3vz4u6Lagfk4RPQxv92NQ= -golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/term v0.3.0 h1:qoo4akIqOcDME5bhc/NgxUdovd6BSS2uMsVjB56q1xI= -golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +golang.org/x/sys v0.7.0 h1:3jlCCIQZPdOYu1h8BkNvLz8Kgwtae2cagcG/VamtZRU= +golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.5.0 h1:n2a8QNdAb0sZNpU9R1ALUXBbY+w51fCQDN+7EdxNBsY= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/tag.go b/tag.go index 5f51c41..e86f88a 100644 --- a/tag.go +++ b/tag.go @@ -75,7 +75,10 @@ func (k SortKey) String() string { type CreateTagOption func(*createTagOptions) type createTagOptions struct { - Annotation string + Annotation string + ForceNoSigned bool + Signed bool + SigningKey string } // WithAnnotation ensures the created tag is annotated with the provided @@ -89,6 +92,43 @@ func WithAnnotation(message string) CreateTagOption { } } +// WithSigned will create a GPG-signed tag using the GPG key associated +// with the taggers email address. Overriding this behavior is possible +// through the user.signingkey config setting. This option does not need +// to be explicitly called if the tag.gpgSign config setting is set to +// true. An annotated tag is mandatory when signing. A default annotation +// will be assigned, unless overridden with the [WithAnnotation] option: +// +// created tag 0.1.0 +func WithSigned() CreateTagOption { + return func(opts *createTagOptions) { + opts.Signed = true + } +} + +// WithSigningKey will create a GPG-signed tag using the provided GPG +// key ID, overridding any default GPG key set by the user.signingKey +// config setting. An annotated tag is mandatory when signing. A default +// annotation will be assigned, unless overridden with the [WithAnnotation] +// option: +// +// created tag 0.1.0 +func WithSigningKey(key string) CreateTagOption { + return func(opts *createTagOptions) { + opts.Signed = true + opts.SigningKey = strings.TrimSpace(key) + } +} + +// WithSkipSigning ensures the created tag will not be GPG signed +// regardless of the value assigned to the repositories tag.gpgSign +// git config setting +func WithSkipSigning() CreateTagOption { + return func(opts *createTagOptions) { + opts.ForceNoSigned = true + } +} + // Tag a specific point within a repositories history and push it to the // configured remote. Tagging comes in two flavours: // - A lightweight tag, which points to a specific commit within @@ -106,12 +146,27 @@ func (c *Client) Tag(tag string, opts ...CreateTagOption) (string, error) { // Build command based on the provided options var tagCmd strings.Builder - tagCmd.WriteString("git tag ") + tagCmd.WriteString("git tag") + + if options.Signed { + if options.Annotation == "" { + options.Annotation = "created tag " + tag + } + tagCmd.WriteString(" -s") + } + + if options.SigningKey != "" { + tagCmd.WriteString(" -u " + options.SigningKey) + } + + if options.ForceNoSigned { + tagCmd.WriteString(" --no-sign") + } if options.Annotation != "" { - tagCmd.WriteString(fmt.Sprintf("-m '%s' -a ", options.Annotation)) + tagCmd.WriteString(fmt.Sprintf(" -a -m '%s'", options.Annotation)) } - tagCmd.WriteString(fmt.Sprintf("'%s'", tag)) + tagCmd.WriteString(fmt.Sprintf(" '%s'", tag)) if out, err := c.exec(tagCmd.String()); err != nil { return out, err @@ -270,3 +325,82 @@ func filterTags(tags []string, filters []TagFilter) []string { return filtered } + +const ( + taggerPrefix = "tagger " + taggerEnd = ">" + fingerprintPrefix = "using RSA key " + signedByPrefix = "Good signature from \"" + noSigningPublicKey = "Can't check signature: No public key" +) + +// TagVerification contains details about a GPG signed tag +type TagVerification struct { + Ref string + Tagger Author + Fingerprint string + SignedBy *Author +} + +// Author contains details about the user whom made or +// uploaded a specific change to the remote repository +type Author struct { + Name string + Email string +} + +func parseAuthor(str string) Author { + name, email, found := strings.Cut(str, "<") + if !found { + return Author{} + } + + return Author{ + Name: strings.TrimSuffix(name, " "), + Email: strings.TrimSuffix(email, ">"), + } +} + +// VerifyTag validates that a given tag has a valid GPG signature +// and returns details about that signature +func (c *Client) VerifyTag(ref string) (*TagVerification, error) { + out, err := c.exec("git tag -v " + ref) + if err != nil { + return nil, err + } + + tagger := out[strings.Index(out, taggerPrefix)+len(taggerPrefix) : strings.Index(out, taggerEnd)+1] + fingerprint := chompCRLF(out[strings.Index(out, fingerprintPrefix)+len(fingerprintPrefix):]) + + var signedByAuthor *Author + if strings.Contains(out, signedByPrefix) { + signedBy := chompUntil(out[strings.Index(out, signedByPrefix)+len(signedByPrefix):], '"') + author := parseAuthor(signedBy) + signedByAuthor = &author + } + + return &TagVerification{ + Ref: ref, + Tagger: parseAuthor(tagger), + Fingerprint: fingerprint, + SignedBy: signedByAuthor, + }, nil +} + +func chompCRLF(str string) string { + if idx := strings.Index(str, "\r"); idx > 1 { + return str[:idx] + } + + if idx := strings.Index(str, "\n"); idx > 1 { + return str[:idx] + } + return str +} + +func chompUntil(str string, until byte) string { + if idx := strings.IndexByte(str, until); idx > -1 { + return str[:idx] + } + return str +} diff --git a/tag_test.go b/tag_test.go index 990ed43..561fb8e 100644 --- a/tag_test.go +++ b/tag_test.go @@ -72,33 +72,14 @@ func TestTagWithAnnotation(t *testing.T) { assert.Contains(t, out, "created tag 0.1.0") } -func TestTagWithAnnotationIgnores(t *testing.T) { - tests := []struct { - name string - message string - }{ - { - name: "EmptyString", - message: "", - }, - { - name: "StringWithOnlyWhitespace", - message: " ", - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - gittest.InitRepository(t) - - client, _ := git.NewClient() - _, err := client.Tag("0.1.0", git.WithAnnotation(tt.message)) +func TestTagWithSkipSigning(t *testing.T) { + gittest.InitRepository(t) + gittest.ConfigSet(t, "user.signingkey", "DOES-NOT-EXIST", "tag.gpgsign", "true") - require.NoError(t, err) + client, _ := git.NewClient() + _, err := client.Tag("0.1.0", git.WithSkipSigning()) - out := gittest.Show(t, "0.1.0") - assert.NotContains(t, out, fmt.Sprintf("Tagger: %s", gittest.DefaultAuthorLog)) - }) - } + require.NoError(t, err) } func TestDeleteTag(t *testing.T) { diff --git a/testdata/Dockerfile b/testdata/Dockerfile new file mode 100644 index 0000000..0eaaaea --- /dev/null +++ b/testdata/Dockerfile @@ -0,0 +1,56 @@ +# syntax = docker/dockerfile:1.4 +FROM golang:1.19-alpine3.17 AS build + +WORKDIR /work +COPY . /work + +ARG test + +WORKDIR /work/testdata +COPY <<-EOF go.mod +module ${test} + +go 1.19 + +replace github.com/purpleclay/gitz => ../ + +require ( + golang.org/x/sync v0.1.0 // indirect + golang.org/x/sys v0.6.0 // indirect + golang.org/x/term v0.5.0 // indirect + mvdan.cc/sh/v3 v3.6.0 // indirect +) +EOF + +RUN <