From 879f71639b121c19f6e79bc609442e9b67340742 Mon Sep 17 00:00:00 2001 From: Nathan Mittler Date: Sat, 11 May 2024 11:16:38 -0700 Subject: [PATCH] feat: Add template params for git This includes a number of template parameters supported by [goreleaser](https://goreleaser.com/customization/templates/). Specifically, the build date information and the majority of the Git params. Majority of the code is copied from goreleaser. I've added the MIT license from goreleaser at the top of the files. Fixes #493 Signed-off-by: Nathan Mittler --- docs/configuration.md | 25 ++- pkg/build/gobuild.go | 57 ++++--- pkg/build/gobuild_test.go | 100 +++++++++++ pkg/internal/git/errors.go | 64 +++++++ pkg/internal/git/git.go | 103 +++++++++++ pkg/internal/git/info.go | 231 +++++++++++++++++++++++++ pkg/internal/git/info_test.go | 255 ++++++++++++++++++++++++++++ pkg/internal/gittesting/git.go | 160 +++++++++++++++++ pkg/internal/gittesting/git_test.go | 48 ++++++ 9 files changed, 1020 insertions(+), 23 deletions(-) create mode 100644 pkg/internal/git/errors.go create mode 100644 pkg/internal/git/git.go create mode 100644 pkg/internal/git/info.go create mode 100644 pkg/internal/git/info_test.go create mode 100644 pkg/internal/gittesting/git.go create mode 100644 pkg/internal/gittesting/git_test.go diff --git a/docs/configuration.md b/docs/configuration.md index 0e7a089d07..323052d335 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -77,10 +77,27 @@ of the `ko` process. The `ldflags` default value is `[]`. -> 💡 **Note:** Even though the configuration section is similar to the -[GoReleaser `builds` section](https://goreleaser.com/customization/build/), -only the `env`, `flags` and `ldflags` fields are currently supported. Also, the -templating support is currently limited to using environment variables only. +### Templating support + +The `ko` builds supports templating of `flags` and `ldflags`, similar to the +[GoReleaser `builds` section](https://goreleaser.com/customization/build/). + +The table below lists the supported template parameters. + +| Template param | Description | +|-----------------------|-------------------------------------------------------| +| `Env` | Map of system environment variables from `os.Environ` | +| `Date` | The UTC build date in RFC 3339 format | +| `Timestamp` | The UTC build date as Unix epoc seconds | +| `Git.Branch` | The current git branch | +| `Git.Tag` | The current git tag | +| `Git.ShortCommit` | The git commit short hash | +| `Git.FullCommit` | The git commit full hash | +| `Git.CommitDate` | The UTC commit date in RFC 3339 format | +| `Git.CommitTimestamp` | The UTC commit date in Unix format | +| `Git.IsDirty` | Whether or not current git state is dirty | +| `Git.IsClean` | Whether or not current git state is clean. | +| `Git.TreeState` | Either `clean` or `dirty` | ### Setting default platforms diff --git a/pkg/build/gobuild.go b/pkg/build/gobuild.go index f7ff43dfb1..98cbf85362 100644 --- a/pkg/build/gobuild.go +++ b/pkg/build/gobuild.go @@ -31,6 +31,7 @@ import ( "strconv" "strings" "text/template" + "time" "github.com/google/go-containerregistry/pkg/name" v1 "github.com/google/go-containerregistry/pkg/v1" @@ -40,6 +41,7 @@ import ( "github.com/google/go-containerregistry/pkg/v1/types" "github.com/google/ko/internal/sbom" "github.com/google/ko/pkg/caps" + "github.com/google/ko/pkg/internal/git" specsv1 "github.com/opencontainers/image-spec/specs-go/v1" "github.com/sigstore/cosign/v2/pkg/oci" ocimutate "github.com/sigstore/cosign/v2/pkg/oci/mutate" @@ -63,11 +65,12 @@ type GetBase func(context.Context, string) (name.Reference, Result, error) // buildContext provides parameters for a builder function. type buildContext struct { - ip string - dir string - env []string - platform v1.Platform - config Config + creationTime v1.Time + ip string + dir string + env []string + platform v1.Platform + config Config } type builder func(context.Context, buildContext) (string, error) @@ -264,7 +267,7 @@ func getGoBinary() string { } func build(ctx context.Context, buildCtx buildContext) (string, error) { - buildArgs, err := createBuildArgs(buildCtx.config) + buildArgs, err := createBuildArgs(ctx, buildCtx) if err != nil { return "", err } @@ -721,7 +724,7 @@ func (g *gobuild) tarKoData(ref reference, platform *v1.Platform) (*bytes.Buffer return buf, walkRecursive(tw, root, chroot, creationTime, platform) } -func createTemplateData() map[string]interface{} { +func createTemplateData(ctx context.Context, buildCtx buildContext) map[string]interface{} { envVars := map[string]string{ "LDFLAGS": "", } @@ -730,8 +733,23 @@ func createTemplateData() map[string]interface{} { envVars[kv[0]] = kv[1] } + // Get the git information, if available. + info, err := git.GetInfo(ctx, buildCtx.dir) + if err != nil { + log.Printf("%v", err) + } + + // Use the creation time as the build date, if provided. + date := buildCtx.creationTime.Time + if date.IsZero() { + date = time.Now() + } + return map[string]interface{}{ - "Env": envVars, + "Env": envVars, + "Git": info.TemplateValue(), + "Date": date.Format(time.RFC3339), + "Timestamp": date.UTC().Unix(), } } @@ -754,13 +772,13 @@ func applyTemplating(list []string, data map[string]interface{}) ([]string, erro return result, nil } -func createBuildArgs(buildCfg Config) ([]string, error) { +func createBuildArgs(ctx context.Context, buildCtx buildContext) ([]string, error) { var args []string - data := createTemplateData() + data := createTemplateData(ctx, buildCtx) - if len(buildCfg.Flags) > 0 { - flags, err := applyTemplating(buildCfg.Flags, data) + if len(buildCtx.config.Flags) > 0 { + flags, err := applyTemplating(buildCtx.config.Flags, data) if err != nil { return nil, err } @@ -768,8 +786,8 @@ func createBuildArgs(buildCfg Config) ([]string, error) { args = append(args, flags...) } - if len(buildCfg.Ldflags) > 0 { - ldflags, err := applyTemplating(buildCfg.Ldflags, data) + if len(buildCtx.config.Ldflags) > 0 { + ldflags, err := applyTemplating(buildCtx.config.Ldflags, data) if err != nil { return nil, err } @@ -850,11 +868,12 @@ func (g *gobuild) buildOne(ctx context.Context, refStr string, base v1.Image, pl // Do the build into a temporary file. config := g.configForImportPath(ref.Path()) file, err := g.build(ctx, buildContext{ - ip: ref.Path(), - dir: g.dir, - env: g.env, - platform: *platform, - config: config, + creationTime: g.creationTime, + ip: ref.Path(), + dir: g.dir, + env: g.env, + platform: *platform, + config: config, }) if err != nil { return nil, fmt.Errorf("build: %w", err) diff --git a/pkg/build/gobuild_test.go b/pkg/build/gobuild_test.go index 6dd278b59b..8764386608 100644 --- a/pkg/build/gobuild_test.go +++ b/pkg/build/gobuild_test.go @@ -37,6 +37,7 @@ import ( "github.com/google/go-containerregistry/pkg/v1/mutate" "github.com/google/go-containerregistry/pkg/v1/random" "github.com/google/go-containerregistry/pkg/v1/types" + "github.com/google/ko/pkg/internal/gittesting" specsv1 "github.com/opencontainers/image-spec/specs-go/v1" "github.com/sigstore/cosign/v2/pkg/oci" ) @@ -313,6 +314,98 @@ func TestBuildEnv(t *testing.T) { } } +func TestCreateTemplateData(t *testing.T) { + t.Run("env", func(t *testing.T) { + t.Setenv("FOO", "bar") + params := createTemplateData(context.TODO(), buildContext{}) + vars := params["Env"].(map[string]string) + if vars["FOO"] != "bar" { + t.Fatalf("vars[FOO]=%q, want %q", vars["FOO"], "bar") + } + }) + + t.Run("empty creation time", func(t *testing.T) { + params := createTemplateData(context.TODO(), buildContext{}) + + // Make sure the date was set to time.Now(). + actualDateStr := params["Date"].(string) + actualDate, err := time.Parse(time.RFC3339, actualDateStr) + if err != nil { + t.Fatal(err) + } + if time.Since(actualDate) > time.Minute { + t.Fatalf("expected date to be now, but was %v", actualDate) + } + + // Check the timestamp. + actualTimestampSec := params["Timestamp"].(int64) + actualTimestamp := time.Unix(actualTimestampSec, 0) + expectedTimestamp := actualDate.Truncate(time.Second) + if !actualTimestamp.Equal(expectedTimestamp) { + t.Fatalf("expected timestamp %v, but was %v", + expectedTimestamp, actualTimestamp) + } + }) + + t.Run("creation time", func(t *testing.T) { + // Create a reference time for use as a creation time. + expectedTime, err := time.Parse(time.RFC3339, "2012-11-01T22:08:41+00:00") + if err != nil { + t.Fatal(err) + } + + params := createTemplateData(context.TODO(), buildContext{ + creationTime: v1.Time{Time: expectedTime}, + }) + + // Check the date. + actualDateStr := params["Date"].(string) + actualDate, err := time.Parse(time.RFC3339, actualDateStr) + if err != nil { + t.Fatal(err) + } + if !actualDate.Equal(expectedTime) { + t.Fatalf("expected date to be %v, but was %v", expectedTime, actualDate) + } + + // Check the timestamp. + actualTimestampSec := params["Timestamp"].(int64) + actualTimestamp := time.Unix(actualTimestampSec, 0) + if !actualTimestamp.Equal(expectedTime) { + t.Fatalf("expected timestamp to be %v, but was %v", expectedTime, actualTimestamp) + } + }) + + t.Run("no git available", func(t *testing.T) { + dir := t.TempDir() + params := createTemplateData(context.TODO(), buildContext{dir: dir}) + gitParams := params["Git"].(map[string]interface{}) + + requireEqual(t, "", gitParams["Branch"]) + requireEqual(t, "", gitParams["Tag"]) + requireEqual(t, "", gitParams["ShortCommit"]) + requireEqual(t, "", gitParams["FullCommit"]) + requireEqual(t, "clean", gitParams["TreeState"]) + }) + + t.Run("git", func(t *testing.T) { + // Create a fake git structure under the test temp dir. + const fakeGitURL = "git@github.com:foo/bar.git" + dir := t.TempDir() + gittesting.GitInit(t, dir) + gittesting.GitRemoteAdd(t, dir, fakeGitURL) + gittesting.GitCommit(t, dir, "commit1") + gittesting.GitTag(t, dir, "v0.0.1") + + params := createTemplateData(context.TODO(), buildContext{dir: dir}) + gitParams := params["Git"].(map[string]interface{}) + + requireEqual(t, "main", gitParams["Branch"]) + requireEqual(t, "v0.0.1", gitParams["Tag"]) + requireEqual(t, "clean", gitParams["TreeState"]) + }) +} + func TestBuildConfig(t *testing.T) { tests := []struct { description string @@ -1248,3 +1341,10 @@ func TestGoBuildConsistentMediaTypes(t *testing.T) { }) } } + +func requireEqual(t *testing.T, expected any, actual any) { + t.Helper() + if diff := cmp.Diff(expected, actual); diff != "" { + t.Fatalf("%T differ (-got, +want): %s", expected, diff) + } +} diff --git a/pkg/internal/git/errors.go b/pkg/internal/git/errors.go new file mode 100644 index 0000000000..9ac5f5c44d --- /dev/null +++ b/pkg/internal/git/errors.go @@ -0,0 +1,64 @@ +// Copyright 2024 ko Build Authors All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// MIT License +// +// Copyright (c) 2016-2022 Carlos Alexandro Becker +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package git + +import ( + "errors" + "fmt" +) + +var ( + // ErrNoTag happens if the underlying git repository doesn't contain any tags + // but no snapshot-release was requested. + ErrNoTag = errors.New("git doesn't contain any tags. Tag info will not be available") + + // ErrNotRepository happens if you try to run ko against a folder + // which is not a git repository. + ErrNotRepository = errors.New("current folder is not a git repository. Git info will not be available") + + // ErrNoGit happens when git is not present in PATH. + ErrNoGit = errors.New("git not present in PATH. Git info will not be available") +) + +// ErrDirty happens when the repo has uncommitted/unstashed changes. +type ErrDirty struct { + status string +} + +func (e ErrDirty) Error() string { + return fmt.Sprintf("git is in a dirty state\nPlease check in your pipeline what can be changing the following files:\n%v\n", e.status) +} diff --git a/pkg/internal/git/git.go b/pkg/internal/git/git.go new file mode 100644 index 0000000000..208f74670f --- /dev/null +++ b/pkg/internal/git/git.go @@ -0,0 +1,103 @@ +// Copyright 2024 ko Build Authors All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// MIT License +// +// Copyright (c) 2016-2022 Carlos Alexandro Becker +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package git + +import ( + "bytes" + "context" + "errors" + "os/exec" + "strings" +) + +type runConfig struct { + dir string + env []string + args []string +} + +// run a git command and returns its output or errors. +func run(ctx context.Context, cfg runConfig) (string, error) { + extraArgs := []string{ + "-c", "log.showSignature=false", + } + cfg.args = append(extraArgs, cfg.args...) + /* #nosec */ + cmd := exec.CommandContext(ctx, "git", cfg.args...) + cmd.Dir = cfg.dir + + stdout := bytes.Buffer{} + stderr := bytes.Buffer{} + + cmd.Stdout = &stdout + cmd.Stderr = &stderr + cmd.Env = append(cmd.Env, cfg.env...) + + err := cmd.Run() + + if err != nil { + return "", errors.New(stderr.String()) + } + + return stdout.String(), nil +} + +// clean the output. +func clean(output string, err error) (string, error) { + output = strings.ReplaceAll(strings.Split(output, "\n")[0], "'", "") + if err != nil { + err = errors.New(strings.TrimSuffix(err.Error(), "\n")) + } + return output, err +} + +// cleanAllLines returns all the non-empty lines of the output, cleaned up. +func cleanAllLines(output string, err error) ([]string, error) { + result := make([]string, 0) + for _, line := range strings.Split(output, "\n") { + l := strings.TrimSpace(strings.ReplaceAll(line, "'", "")) + if l == "" { + continue + } + result = append(result, l) + } + // TODO: maybe check for exec.ExitError only? + if err != nil { + err = errors.New(strings.TrimSuffix(err.Error(), "\n")) + } + return result, err +} diff --git a/pkg/internal/git/info.go b/pkg/internal/git/info.go new file mode 100644 index 0000000000..cd2325ecca --- /dev/null +++ b/pkg/internal/git/info.go @@ -0,0 +1,231 @@ +// Copyright 2024 ko Build Authors All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// MIT License +// +// Copyright (c) 2016-2022 Carlos Alexandro Becker +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package git + +import ( + "context" + "errors" + "fmt" + "os/exec" + "strconv" + "strings" + "time" +) + +// Info includes tags and diffs used in some point. +type Info struct { + Branch string + Tag string + ShortCommit string + FullCommit string + CommitDate time.Time + Dirty bool +} + +// TemplateValue converts this Info into a map for use in golang templates. +func (i Info) TemplateValue() map[string]interface{} { + treeState := "clean" + if i.Dirty { + treeState = "dirty" + } + + return map[string]interface{}{ + "Branch": i.Branch, + "Tag": i.Tag, + "ShortCommit": i.ShortCommit, + "FullCommit": i.FullCommit, + "CommitDate": i.CommitDate.UTC().Format(time.RFC3339), + "CommitTimestamp": i.CommitDate.UTC().Unix(), + "IsDirty": i.Dirty, + "IsClean": !i.Dirty, + "TreeState": treeState, + } +} + +// GetInfo returns git information for the given directory +func GetInfo(ctx context.Context, dir string) (Info, error) { + if _, err := exec.LookPath("git"); err != nil { + return Info{}, ErrNoGit + } + + if !isRepo(ctx, dir) { + return Info{}, ErrNotRepository + } + + branch, err := getBranch(ctx, dir) + if err != nil { + return Info{}, fmt.Errorf("couldn't get current branch: %w", err) + } + short, err := getShortCommit(ctx, dir) + if err != nil { + return Info{}, fmt.Errorf("couldn't get current commit: %w", err) + } + full, err := getFullCommit(ctx, dir) + if err != nil { + return Info{}, fmt.Errorf("couldn't get current commit: %w", err) + } + date, err := getCommitDate(ctx, dir) + if err != nil { + return Info{}, fmt.Errorf("couldn't get commit date: %w", err) + } + + dirty := checkDirty(ctx, dir) + + // TODO: allow exclusions. + tag, err := getTag(ctx, dir, []string{}) + if err != nil { + return Info{ + Branch: branch, + FullCommit: full, + ShortCommit: short, + CommitDate: date, + Tag: "v0.0.0", + Dirty: dirty != nil, + }, errors.Join(ErrNoTag, dirty) + } + + return Info{ + Branch: branch, + Tag: tag, + FullCommit: full, + ShortCommit: short, + CommitDate: date, + Dirty: dirty != nil, + }, dirty +} + +// isRepo returns true if current folder is a git repository. +func isRepo(ctx context.Context, dir string) bool { + out, err := run(ctx, runConfig{ + dir: dir, + args: []string{"rev-parse", "--is-inside-work-tree"}, + }) + return err == nil && strings.TrimSpace(out) == "true" +} + +// checkDirty returns an error if the current git repository is dirty. +func checkDirty(ctx context.Context, dir string) error { + out, err := run(ctx, runConfig{ + dir: dir, + args: []string{"status", "--porcelain"}, + }) + if strings.TrimSpace(out) != "" || err != nil { + return ErrDirty{status: out} + } + return nil +} + +func getBranch(ctx context.Context, dir string) (string, error) { + return clean(run(ctx, runConfig{ + dir: dir, + args: []string{"rev-parse", "--abbrev-ref", "HEAD", "--quiet"}, + })) +} + +func getCommitDate(ctx context.Context, dir string) (time.Time, error) { + ct, err := clean(run(ctx, runConfig{ + dir: dir, + args: []string{"show", "--format='%ct'", "HEAD", "--quiet"}, + })) + if err != nil { + return time.Time{}, err + } + if ct == "" { + return time.Time{}, nil + } + i, err := strconv.ParseInt(ct, 10, 64) + if err != nil { + return time.Time{}, err + } + t := time.Unix(i, 0).UTC() + return t, nil +} + +func getShortCommit(ctx context.Context, dir string) (string, error) { + return clean(run(ctx, runConfig{ + dir: dir, + args: []string{"show", "--format=%h", "HEAD", "--quiet"}, + })) +} + +func getFullCommit(ctx context.Context, dir string) (string, error) { + return clean(run(ctx, runConfig{ + dir: dir, + args: []string{"show", "--format=%H", "HEAD", "--quiet"}, + })) +} + +func getTag(ctx context.Context, dir string, excluding []string) (string, error) { + // this will get the last tag, even if it wasn't made against the + // last commit... + tags, err := cleanAllLines(gitDescribe(ctx, dir, "HEAD", excluding)) + if err != nil { + return "", err + } + tag := filterOut(tags, excluding) + return tag, err +} + +func gitDescribe(ctx context.Context, dir, ref string, excluding []string) (string, error) { + args := []string{ + "describe", + "--tags", + "--abbrev=0", + ref, + } + for _, exclude := range excluding { + args = append(args, "--exclude="+exclude) + } + return clean(run(ctx, runConfig{ + dir: dir, + args: args, + })) +} + +func filterOut(tags []string, exclude []string) string { + if len(exclude) == 0 && len(tags) > 0 { + return tags[0] + } + for _, tag := range tags { + for _, exl := range exclude { + if exl != tag { + return tag + } + } + } + return "" +} diff --git a/pkg/internal/git/info_test.go b/pkg/internal/git/info_test.go new file mode 100644 index 0000000000..767116d0dd --- /dev/null +++ b/pkg/internal/git/info_test.go @@ -0,0 +1,255 @@ +// Copyright 2024 ko Build Authors All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// MIT License +// +// Copyright (c) 2016-2022 Carlos Alexandro Becker +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package git_test + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/ko/pkg/internal/git" + "github.com/google/ko/pkg/internal/gittesting" +) + +const fakeGitURL = "git@github.com:foo/bar.git" + +func TestNotAGitFolder(t *testing.T) { + dir := t.TempDir() + i, err := git.GetInfo(context.TODO(), dir) + requireErrorIs(t, err, git.ErrNotRepository) + + tpl := i.TemplateValue() + requireEmpty(t, tpl) +} + +func TestSingleCommit(t *testing.T) { + dir := t.TempDir() + gittesting.GitInit(t, dir) + gittesting.GitRemoteAdd(t, dir, fakeGitURL) + gittesting.GitCommit(t, dir, "commit1") + gittesting.GitTag(t, dir, "v0.0.1") + i, err := git.GetInfo(context.TODO(), dir) + requireNoError(t, err) + + tpl := i.TemplateValue() + requireEqual(t, "main", tpl["Branch"]) + requireEqual(t, "v0.0.1", tpl["Tag"]) + requireNotEmpty(t, tpl["ShortCommit"].(string)) + requireNotEmpty(t, tpl["FullCommit"].(string)) + requireNotEmpty(t, tpl["CommitDate"].(string)) + requireNotZero(t, tpl["CommitTimestamp"].(int64)) + requireFalse(t, tpl["IsDirty"].(bool)) + requireTrue(t, tpl["IsClean"].(bool)) + requireEqual(t, "clean", tpl["TreeState"]) +} + +func TestBranch(t *testing.T) { + dir := t.TempDir() + gittesting.GitInit(t, dir) + gittesting.GitRemoteAdd(t, dir, fakeGitURL) + gittesting.GitCommit(t, dir, "test-branch-commit") + gittesting.GitTag(t, dir, "test-branch-tag") + gittesting.GitCheckoutBranch(t, dir, "test-branch") + i, err := git.GetInfo(context.TODO(), dir) + requireNoError(t, err) + + tpl := i.TemplateValue() + requireEqual(t, "test-branch", tpl["Branch"]) + requireEqual(t, "test-branch-tag", tpl["Tag"]) + requireNotEmpty(t, tpl["ShortCommit"].(string)) + requireNotEmpty(t, tpl["FullCommit"].(string)) + requireNotEmpty(t, tpl["CommitDate"].(string)) + requireNotZero(t, tpl["CommitTimestamp"].(int64)) + requireFalse(t, tpl["IsDirty"].(bool)) + requireTrue(t, tpl["IsClean"].(bool)) + requireEqual(t, "clean", tpl["TreeState"]) +} + +func TestNewRepository(t *testing.T) { + dir := t.TempDir() + gittesting.GitInit(t, dir) + i, err := git.GetInfo(context.TODO(), dir) + // TODO: improve this error handling + requireErrorContains(t, err, `fatal: ambiguous argument 'HEAD'`) + + tpl := i.TemplateValue() + requireEmpty(t, tpl) +} + +func TestNoTags(t *testing.T) { + dir := t.TempDir() + gittesting.GitInit(t, dir) + gittesting.GitRemoteAdd(t, dir, fakeGitURL) + gittesting.GitCommit(t, dir, "first") + i, err := git.GetInfo(context.TODO(), dir) + requireErrorIs(t, err, git.ErrNoTag) + + tpl := i.TemplateValue() + requireEqual(t, "main", tpl["Branch"]) + requireEqual(t, "v0.0.0", tpl["Tag"]) + requireNotEmpty(t, tpl["ShortCommit"].(string)) + requireNotEmpty(t, tpl["FullCommit"].(string)) + requireNotEmpty(t, tpl["CommitDate"].(string)) + requireNotZero(t, tpl["CommitTimestamp"].(int64)) + requireFalse(t, tpl["IsDirty"].(bool)) + requireTrue(t, tpl["IsClean"].(bool)) + requireEqual(t, "clean", tpl["TreeState"]) +} + +func TestDirty(t *testing.T) { + dir := t.TempDir() + gittesting.GitInit(t, dir) + gittesting.GitRemoteAdd(t, dir, fakeGitURL) + testFile, err := os.Create(filepath.Join(dir, "testFile")) + requireNoError(t, err) + requireNoError(t, testFile.Close()) + gittesting.GitAdd(t, dir) + gittesting.GitCommit(t, dir, "commit2") + gittesting.GitTag(t, dir, "v0.0.1") + requireNoError(t, os.WriteFile(testFile.Name(), []byte("lorem ipsum"), 0o644)) + i, err := git.GetInfo(context.TODO(), dir) + requireErrorContains(t, err, "git is in a dirty state") + + tpl := i.TemplateValue() + requireEqual(t, "main", tpl["Branch"]) + requireEqual(t, "v0.0.1", tpl["Tag"]) + requireNotEmpty(t, tpl["ShortCommit"].(string)) + requireNotEmpty(t, tpl["FullCommit"].(string)) + requireNotEmpty(t, tpl["CommitDate"].(string)) + requireNotZero(t, tpl["CommitTimestamp"].(int64)) + requireTrue(t, tpl["IsDirty"].(bool)) + requireFalse(t, tpl["IsClean"].(bool)) + requireEqual(t, "dirty", tpl["TreeState"]) +} + +func TestValidState(t *testing.T) { + dir := t.TempDir() + gittesting.GitInit(t, dir) + gittesting.GitRemoteAdd(t, dir, fakeGitURL) + gittesting.GitCommit(t, dir, "commit3") + gittesting.GitTag(t, dir, "v0.0.1") + gittesting.GitTag(t, dir, "v0.0.2") + gittesting.GitCommit(t, dir, "commit4") + gittesting.GitTag(t, dir, "v0.0.3") + i, err := git.GetInfo(context.TODO(), dir) + requireNoError(t, err) + requireEqual(t, "v0.0.3", i.Tag) + requireFalse(t, i.Dirty) +} + +func TestGitNotInPath(t *testing.T) { + t.Setenv("PATH", "") + i, err := git.GetInfo(context.TODO(), "") + requireErrorIs(t, err, git.ErrNoGit) + + tpl := i.TemplateValue() + requireEmpty(t, tpl) +} + +func requireEmpty(t *testing.T, tpl map[string]interface{}) { + requireEqual(t, "", tpl["Branch"]) + requireEqual(t, "", tpl["Tag"]) + requireEqual(t, "", tpl["ShortCommit"]) + requireEqual(t, "", tpl["FullCommit"]) + requireFalse(t, tpl["IsDirty"].(bool)) + requireTrue(t, tpl["IsClean"].(bool)) + requireEqual(t, "clean", tpl["TreeState"]) +} + +func requireEqual(t *testing.T, expected any, actual any) { + t.Helper() + if diff := cmp.Diff(expected, actual); diff != "" { + t.Fatalf("%T differ (-got, +want): %s", expected, diff) + } +} + +func requireTrue(t *testing.T, val bool) { + t.Helper() + requireEqual(t, true, val) +} + +func requireFalse(t *testing.T, val bool) { + t.Helper() + requireEqual(t, false, val) +} + +func requireNoError(t *testing.T, err error) { + t.Helper() + if err != nil { + t.Fatalf("unexpected error: %s", err) + } +} + +func requireError(t *testing.T, err error) { + t.Helper() + if err == nil { + t.Fatal("expected error") + } +} + +func requireErrorIs(t *testing.T, err error, target error) { + t.Helper() + if !errors.Is(err, target) { + t.Fatalf("expected error to be %v, got %v", target, err) + } +} + +func requireErrorContains(t *testing.T, err error, target string) { + t.Helper() + requireError(t, err) + if !strings.Contains(err.Error(), target) { + t.Fatalf("expected error to contain %q, got %q", target, err) + } +} + +func requireNotEmpty(t *testing.T, val string) { + t.Helper() + if len(val) == 0 { + t.Fatalf("value should not be empty") + } +} + +func requireNotZero(t *testing.T, val int64) { + t.Helper() + if val == 0 { + t.Fatalf("value should not be zero") + } +} diff --git a/pkg/internal/gittesting/git.go b/pkg/internal/gittesting/git.go new file mode 100644 index 0000000000..d2397d1692 --- /dev/null +++ b/pkg/internal/gittesting/git.go @@ -0,0 +1,160 @@ +// Copyright 2024 ko Build Authors All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// MIT License +// +// Copyright (c) 2016-2022 Carlos Alexandro Becker +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package gittesting + +import ( + "bytes" + "errors" + "os/exec" + "strings" + "testing" +) + +// GitInit inits a new git project. +func GitInit(t *testing.T, dir string) { + t.Helper() + out, err := fakeGit(dir, "init") + requireNoError(t, err) + requireContains(t, out, "Initialized empty Git repository", "") + requireNoError(t, err) + GitCheckoutBranch(t, dir, "main") + _, _ = fakeGit("branch", "-D", "master") +} + +// GitRemoteAdd adds the given url as remote. +func GitRemoteAdd(t *testing.T, dir, url string) { + t.Helper() + out, err := fakeGit(dir, "remote", "add", "origin", url) + requireNoError(t, err) + requireEmpty(t, out) +} + +// GitCommit creates a git commits. +func GitCommit(t *testing.T, dir, msg string) { + t.Helper() + out, err := fakeGit(dir, "commit", "--allow-empty", "-m", msg) + requireNoError(t, err) + requireContains(t, out, "main", msg) +} + +// GitTag creates a git tag. +func GitTag(t *testing.T, dir, tag string) { + t.Helper() + out, err := fakeGit(dir, "tag", tag) + requireNoError(t, err) + requireEmpty(t, out) +} + +// GitAnnotatedTag creates an annotated tag. +func GitAnnotatedTag(t *testing.T, dir, tag, message string) { + t.Helper() + out, err := fakeGit(dir, "tag", "-a", tag, "-m", message) + requireNoError(t, err) + requireEmpty(t, out) +} + +// GitAdd adds all files to stage. +func GitAdd(t *testing.T, dir string) { + t.Helper() + out, err := fakeGit(dir, "add", "-A") + requireNoError(t, err) + requireEmpty(t, out) +} + +func fakeGit(dir string, args ...string) (string, error) { + allArgs := []string{ + "-c", "user.name='GoReleaser'", + "-c", "user.email='test@goreleaser.github.com'", + "-c", "commit.gpgSign=false", + "-c", "tag.gpgSign=false", + "-c", "log.showSignature=false", + } + allArgs = append(allArgs, args...) + return gitRun(dir, allArgs...) +} + +// GitCheckoutBranch allows us to change the active branch that we're using. +func GitCheckoutBranch(t *testing.T, dir, name string) { + t.Helper() + out, err := fakeGit(dir, "checkout", "-b", name) + requireNoError(t, err) + requireEmpty(t, out) +} + +func gitRun(dir string, args ...string) (string, error) { + cmd := exec.Command("git", args...) + cmd.Dir = dir + + stdout := bytes.Buffer{} + stderr := bytes.Buffer{} + + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + + if err != nil { + return "", errors.New(stderr.String()) + } + + return stdout.String(), nil +} + +func requireNoError(t *testing.T, err error) { + t.Helper() + if err != nil { + t.Fatalf("unexpected error: %s", err) + } +} + +func requireContains(t *testing.T, val, expected, msg string) { + t.Helper() + if !strings.Contains(val, expected) { + if len(msg) > 0 { + t.Fatalf("%s: expected value %s missing from value %s", msg, expected, val) + } else { + t.Fatalf("expected value %s missing from value %s", expected, val) + } + } +} + +func requireEmpty(t *testing.T, val string) { + t.Helper() + if len(val) > 0 { + t.Fatalf("%s: expected empty string", val) + } +} diff --git a/pkg/internal/gittesting/git_test.go b/pkg/internal/gittesting/git_test.go new file mode 100644 index 0000000000..66d9e3e75b --- /dev/null +++ b/pkg/internal/gittesting/git_test.go @@ -0,0 +1,48 @@ +// Copyright 2024 ko Build Authors All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// MIT License +// +// Copyright (c) 2016-2022 Carlos Alexandro Becker +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package gittesting + +import "testing" + +func TestGit(t *testing.T) { + dir := t.TempDir() + GitInit(t, dir) + GitAdd(t, dir) + GitCommit(t, dir, "commit1") + GitRemoteAdd(t, dir, "git@github.com:goreleaser/nope.git") + GitTag(t, dir, "v1.0.0") +}