From aa9d11d9197f25d408843e3dbc66d4b4cf5a266e Mon Sep 17 00:00:00 2001 From: Nathan Mittler Date: Sat, 11 May 2024 11:16:38 -0700 Subject: [PATCH] feat: Add git common build template params 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. Fixes #493 Signed-off-by: Nathan Mittler --- pkg/build/gobuild.go | 27 +- pkg/internal/git/errors.go | 64 +++++ pkg/internal/git/git.go | 106 ++++++++ pkg/internal/git/git_test.go | 368 ++++++++++++++++++++++++++++ pkg/internal/git/info.go | 298 ++++++++++++++++++++++ pkg/internal/testing/git.go | 159 ++++++++++++ pkg/internal/testing/git_test.go | 48 ++++ pkg/internal/testing/mktemp.go | 56 +++++ pkg/internal/testing/mktemp_test.go | 47 ++++ 9 files changed, 1168 insertions(+), 5 deletions(-) create mode 100644 pkg/internal/git/errors.go create mode 100644 pkg/internal/git/git.go create mode 100644 pkg/internal/git/git_test.go create mode 100644 pkg/internal/git/info.go create mode 100644 pkg/internal/testing/git.go create mode 100644 pkg/internal/testing/git_test.go create mode 100644 pkg/internal/testing/mktemp.go create mode 100644 pkg/internal/testing/mktemp_test.go diff --git a/pkg/build/gobuild.go b/pkg/build/gobuild.go index 172e9e3163..e3a39b499d 100644 --- a/pkg/build/gobuild.go +++ b/pkg/build/gobuild.go @@ -20,6 +20,7 @@ import ( "context" "errors" "fmt" + "github.com/google/ko/pkg/internal/git" gb "go/build" "io" "log" @@ -31,6 +32,7 @@ import ( "strconv" "strings" "text/template" + "time" "github.com/google/go-containerregistry/pkg/name" v1 "github.com/google/go-containerregistry/pkg/v1" @@ -56,6 +58,11 @@ const ( defaultGoBin = "go" // defaults to first go binary found in PATH goBinPathEnv = "KO_GO_PATH" // env lookup for optional relative or full go binary path + + envTemplateKey = "Env" + gitTemplateKey = "Git" + dateTemplateKey = "Date" + timestampTemplateKey = "Timestamp" ) // GetBase takes an importpath and returns a base image reference and base image (or index). @@ -252,7 +259,7 @@ func getGoBinary() string { } func build(ctx context.Context, ip string, dir string, platform v1.Platform, config Config) (string, error) { - buildArgs, err := createBuildArgs(config) + buildArgs, err := createBuildArgs(ctx, config) if err != nil { return "", err } @@ -708,7 +715,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) map[string]interface{} { envVars := map[string]string{ "LDFLAGS": "", } @@ -717,8 +724,18 @@ func createTemplateData() map[string]interface{} { envVars[kv[0]] = kv[1] } + // Get the git information, if available. + info, err := git.GetInfo(ctx) + if err != nil { + log.Printf("%v", err) + } + + date := time.Now() return map[string]interface{}{ - "Env": envVars, + envTemplateKey: envVars, + gitTemplateKey: info.TemplateValue(), + dateTemplateKey: date.Format(time.RFC3339), + timestampTemplateKey: date.UTC().Unix(), } } @@ -741,10 +758,10 @@ func applyTemplating(list []string, data map[string]interface{}) ([]string, erro return result, nil } -func createBuildArgs(buildCfg Config) ([]string, error) { +func createBuildArgs(ctx context.Context, buildCfg Config) ([]string, error) { var args []string - data := createTemplateData() + data := createTemplateData(ctx) if len(buildCfg.Flags) > 0 { flags, err := applyTemplating(buildCfg.Flags, data) 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..2a80b35bff --- /dev/null +++ b/pkg/internal/git/git.go @@ -0,0 +1,106 @@ +// 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" +) + +// isRepo returns true if current folder is a git repository. +func isRepo(ctx context.Context) bool { + out, err := run(ctx, "rev-parse", "--is-inside-work-tree") + return err == nil && strings.TrimSpace(out) == "true" +} + +func runWithEnv(ctx context.Context, env []string, args ...string) (string, error) { + extraArgs := []string{ + "-c", "log.showSignature=false", + } + args = append(extraArgs, args...) + /* #nosec */ + cmd := exec.CommandContext(ctx, "git", args...) + + stdout := bytes.Buffer{} + stderr := bytes.Buffer{} + + cmd.Stdout = &stdout + cmd.Stderr = &stderr + cmd.Env = append(cmd.Env, env...) + + err := cmd.Run() + + if err != nil { + return "", errors.New(stderr.String()) + } + + return stdout.String(), nil +} + +// run runs a git command and returns its output or errors. +func run(ctx context.Context, args ...string) (string, error) { + return runWithEnv(ctx, []string{}, args...) +} + +// 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) { + var result []string + 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/git_test.go b/pkg/internal/git/git_test.go new file mode 100644 index 0000000000..607672a2ae --- /dev/null +++ b/pkg/internal/git/git_test.go @@ -0,0 +1,368 @@ +// 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" + kotesting "github.com/google/ko/pkg/internal/testing" +) + +const fakeGitURL = "git@github.com:foo/bar.git" + +func TestNotAGitFolder(t *testing.T) { + kotesting.Mktmp(t) + i, err := git.GetInfo(context.TODO()) + requireErrorIs(t, err, git.ErrNotRepository) + + tpl := i.TemplateValue() + requireEmpty(t, tpl) +} + +func TestSingleCommit(t *testing.T) { + kotesting.Mktmp(t) + kotesting.GitInit(t) + kotesting.GitRemoteAdd(t, fakeGitURL) + kotesting.GitCommit(t, "commit1") + kotesting.GitTag(t, "v0.0.1") + i, err := git.GetInfo(context.TODO()) + requireNoError(t, err) + + tpl := i.TemplateValue() + requireEqual(t, "main", tpl.Branch) + requireEqual(t, "v0.0.1", tpl.CurrentTag) + requireNotEmpty(t, tpl.ShortCommit) + requireNotEmpty(t, tpl.FullCommit) + requireNotEmpty(t, tpl.FirstCommit) + requireNotEmpty(t, tpl.CommitDate) + requireNotZero(t, tpl.CommitTimestamp) + requireEqual(t, fakeGitURL, tpl.URL) + requireEqual(t, "v0.0.1", tpl.Summary) + requireEqual(t, "commit1", tpl.TagSubject) + requireEqual(t, "commit1", tpl.TagContents) + requireEqual(t, "", tpl.TagBody) + requireFalse(t, tpl.IsDirty) + requireTrue(t, tpl.IsClean) + requireEqual(t, "clean", tpl.TreeState) +} + +func TestAnnotatedTags(t *testing.T) { + kotesting.Mktmp(t) + kotesting.GitInit(t) + kotesting.GitRemoteAdd(t, fakeGitURL) + kotesting.GitCommit(t, "commit1") + kotesting.GitAnnotatedTag(t, "v0.0.1", "first version\n\nlalalla\nlalal\nlah") + i, err := git.GetInfo(context.TODO()) + requireNoError(t, err) + + tpl := i.TemplateValue() + requireEqual(t, "main", tpl.Branch) + requireEqual(t, "v0.0.1", tpl.CurrentTag) + requireNotEmpty(t, tpl.ShortCommit) + requireNotEmpty(t, tpl.FullCommit) + requireNotEmpty(t, tpl.FirstCommit) + requireNotEmpty(t, tpl.CommitDate) + requireNotZero(t, tpl.CommitTimestamp) + requireEqual(t, fakeGitURL, tpl.URL) + requireEqual(t, "v0.0.1", tpl.Summary) + requireEqual(t, "first version", tpl.TagSubject) + requireEqual(t, "first version\n\nlalalla\nlalal\nlah", tpl.TagContents) + requireEqual(t, "lalalla\nlalal\nlah", tpl.TagBody) + requireFalse(t, tpl.IsDirty) + requireTrue(t, tpl.IsClean) + requireEqual(t, "clean", tpl.TreeState) +} + +func TestBranch(t *testing.T) { + kotesting.Mktmp(t) + kotesting.GitInit(t) + kotesting.GitRemoteAdd(t, fakeGitURL) + kotesting.GitCommit(t, "test-branch-commit") + kotesting.GitTag(t, "test-branch-tag") + kotesting.GitCheckoutBranch(t, "test-branch") + i, err := git.GetInfo(context.TODO()) + requireNoError(t, err) + + tpl := i.TemplateValue() + requireEqual(t, "test-branch", tpl.Branch) + requireEqual(t, "test-branch-tag", tpl.CurrentTag) + requireNotEmpty(t, tpl.ShortCommit) + requireNotEmpty(t, tpl.FullCommit) + requireNotEmpty(t, tpl.FirstCommit) + requireNotEmpty(t, tpl.CommitDate) + requireNotZero(t, tpl.CommitTimestamp) + requireEqual(t, fakeGitURL, tpl.URL) + requireEqual(t, "test-branch-tag", tpl.Summary) + requireEqual(t, "test-branch-commit", tpl.TagSubject) + requireEqual(t, "test-branch-commit", tpl.TagContents) + requireEqual(t, "", tpl.TagBody) + requireFalse(t, tpl.IsDirty) + requireTrue(t, tpl.IsClean) + requireEqual(t, "clean", tpl.TreeState) +} + +func TestNoRemote(t *testing.T) { + kotesting.Mktmp(t) + kotesting.GitInit(t) + kotesting.GitCommit(t, "commit1") + kotesting.GitTag(t, "v0.0.1") + i, err := git.GetInfo(context.TODO()) + requireErrorContains(t, err, "couldn't get remote URL: fatal: No remote configured to list refs from.") + + tpl := i.TemplateValue() + requireEmpty(t, tpl) +} + +func TestNewRepository(t *testing.T) { + kotesting.Mktmp(t) + kotesting.GitInit(t) + i, err := git.GetInfo(context.TODO()) + // TODO: improve this error handling + requireErrorContains(t, err, `fatal: ambiguous argument 'HEAD'`) + + tpl := i.TemplateValue() + requireEmpty(t, tpl) +} + +func TestNoTags(t *testing.T) { + kotesting.Mktmp(t) + kotesting.GitInit(t) + kotesting.GitRemoteAdd(t, fakeGitURL) + kotesting.GitCommit(t, "first") + i, err := git.GetInfo(context.TODO()) + requireErrorIs(t, err, git.ErrNoTag) + + tpl := i.TemplateValue() + requireEqual(t, "main", tpl.Branch) + requireEqual(t, "v0.0.0", tpl.CurrentTag) + requireNotEmpty(t, tpl.ShortCommit) + requireNotEmpty(t, tpl.FullCommit) + requireNotEmpty(t, tpl.FirstCommit) + requireNotEmpty(t, tpl.CommitDate) + requireNotZero(t, tpl.CommitTimestamp) + requireEqual(t, fakeGitURL, tpl.URL) + requireNotEmpty(t, tpl.Summary) + requireEqual(t, "", tpl.TagSubject) + requireEqual(t, "", tpl.TagContents) + requireEqual(t, "", tpl.TagBody) + requireFalse(t, tpl.IsDirty) + requireTrue(t, tpl.IsClean) + requireEqual(t, "clean", tpl.TreeState) +} + +func TestDirty(t *testing.T) { + folder := kotesting.Mktmp(t) + kotesting.GitInit(t) + kotesting.GitRemoteAdd(t, fakeGitURL) + dummy, err := os.Create(filepath.Join(folder, "dummy")) + requireNoError(t, err) + requireNoError(t, dummy.Close()) + kotesting.GitAdd(t) + kotesting.GitCommit(t, "commit2") + kotesting.GitTag(t, "v0.0.1") + requireNoError(t, os.WriteFile(dummy.Name(), []byte("lorem ipsum"), 0o644)) + i, err := git.GetInfo(context.TODO()) + requireErrorContains(t, err, "git is in a dirty state") + + tpl := i.TemplateValue() + requireEqual(t, "main", tpl.Branch) + requireEqual(t, "v0.0.1", tpl.CurrentTag) + requireNotEmpty(t, tpl.ShortCommit) + requireNotEmpty(t, tpl.FullCommit) + requireNotEmpty(t, tpl.FirstCommit) + requireNotEmpty(t, tpl.CommitDate) + requireNotZero(t, tpl.CommitTimestamp) + requireEqual(t, fakeGitURL, tpl.URL) + requireNotEmpty(t, tpl.Summary) + requireEqual(t, "commit2", tpl.TagSubject) + requireEqual(t, "commit2", tpl.TagContents) + requireEqual(t, "", tpl.TagBody) + requireTrue(t, tpl.IsDirty) + requireFalse(t, tpl.IsClean) + requireEqual(t, "dirty", tpl.TreeState) +} + +func TestRemoteURLContainsWithUsernameAndToken(t *testing.T) { + kotesting.Mktmp(t) + kotesting.GitInit(t) + kotesting.GitRemoteAdd(t, "https://gitlab-ci-token:SyYhsAghYFTvMoxw7GAg@gitlab.private.com/platform/base/poc/kink.git/releases/tag/v0.1.4") + kotesting.GitAdd(t) + kotesting.GitCommit(t, "commit2") + kotesting.GitTag(t, "v0.0.1") + i, err := git.GetInfo(context.TODO()) + requireNoError(t, err) + + tpl := i.TemplateValue() + requireEqual(t, "main", tpl.Branch) + requireEqual(t, "v0.0.1", tpl.CurrentTag) + requireNotEmpty(t, tpl.ShortCommit) + requireNotEmpty(t, tpl.FullCommit) + requireNotEmpty(t, tpl.FirstCommit) + requireNotEmpty(t, tpl.CommitDate) + requireNotZero(t, tpl.CommitTimestamp) + requireEqual(t, "https://gitlab.private.com/platform/base/poc/kink.git/releases/tag/v0.1.4", tpl.URL) + requireNotEmpty(t, tpl.Summary) + requireEqual(t, "commit2", tpl.TagSubject) + requireEqual(t, "commit2", tpl.TagContents) + requireEqual(t, "", tpl.TagBody) + requireFalse(t, tpl.IsDirty) + requireTrue(t, tpl.IsClean) + requireEqual(t, "clean", tpl.TreeState) +} + +func TestRemoteURLContainsWithUsernameAndTokenWithInvalidURL(t *testing.T) { + kotesting.Mktmp(t) + kotesting.GitInit(t) + kotesting.GitRemoteAdd(t, "https://gitlab-ci-token:SyYhsAghYFTvMoxw7GAggitlab.com/platform/base/poc/kink.git/releases/tag/v0.1.4") + kotesting.GitAdd(t) + kotesting.GitCommit(t, "commit2") + kotesting.GitTag(t, "v0.0.1") + i, err := git.GetInfo(context.TODO()) + requireError(t, err) + + tpl := i.TemplateValue() + requireEmpty(t, tpl) +} + +func TestValidState(t *testing.T) { + kotesting.Mktmp(t) + kotesting.GitInit(t) + kotesting.GitRemoteAdd(t, fakeGitURL) + kotesting.GitCommit(t, "commit3") + kotesting.GitTag(t, "v0.0.1") + kotesting.GitTag(t, "v0.0.2") + kotesting.GitCommit(t, "commit4") + kotesting.GitTag(t, "v0.0.3") + i, err := git.GetInfo(context.TODO()) + requireNoError(t, err) + requireEqual(t, "v0.0.3", i.CurrentTag) + requireEqual(t, fakeGitURL, i.URL) + requireNotEmpty(t, i.FirstCommit) + 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 git.TemplateValue) { + requireEqual(t, "", tpl.Branch) + requireEqual(t, "", tpl.CurrentTag) + requireEqual(t, "", tpl.ShortCommit) + requireEqual(t, "", tpl.FullCommit) + requireEqual(t, "", tpl.FirstCommit) + requireEqual(t, "", tpl.URL) + requireEqual(t, "", tpl.Summary) + requireEqual(t, "", tpl.TagSubject) + requireEqual(t, "", tpl.TagContents) + requireEqual(t, "", tpl.TagBody) + requireFalse(t, tpl.IsDirty) + requireTrue(t, tpl.IsClean) + 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/git/info.go b/pkg/internal/git/info.go new file mode 100644 index 0000000000..b890b64850 --- /dev/null +++ b/pkg/internal/git/info.go @@ -0,0 +1,298 @@ +// 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" + "net/url" + "os/exec" + "strconv" + "strings" + "time" +) + +// Info includes tags and diffs used in some point. +type Info struct { + Branch string + CurrentTag string + ShortCommit string + FullCommit string + FirstCommit string + CommitDate time.Time + URL string + Summary string + TagSubject string + TagContents string + TagBody string + Dirty bool +} + +// TemplateValue is the Info formatted for use as a template value. +type TemplateValue struct { + Branch string + CurrentTag string + ShortCommit string + FullCommit string + FirstCommit string + CommitDate string + CommitTimestamp int64 + URL string + Summary string + TagSubject string + TagContents string + TagBody string + IsDirty bool + IsClean bool + TreeState string +} + +// TemplateValue converts this Info into a TemplateValue. +func (i Info) TemplateValue() TemplateValue { + treeState := "clean" + if i.Dirty { + treeState = "dirty" + } + + return TemplateValue{ + Branch: i.Branch, + CurrentTag: i.CurrentTag, + ShortCommit: i.ShortCommit, + FullCommit: i.FullCommit, + FirstCommit: i.FirstCommit, + CommitDate: i.CommitDate.UTC().Format(time.RFC3339), + CommitTimestamp: i.CommitDate.UTC().Unix(), + URL: i.URL, + Summary: i.Summary, + TagSubject: i.TagSubject, + TagContents: i.TagContents, + TagBody: i.TagBody, + IsDirty: i.Dirty, + IsClean: !i.Dirty, + TreeState: treeState, + } +} + +// GetInfo views the current directory +func GetInfo(ctx context.Context) (Info, error) { + if _, err := exec.LookPath("git"); err != nil { + return Info{}, ErrNoGit + } + + if !isRepo(ctx) { + return Info{}, ErrNotRepository + } + + branch, err := getBranch(ctx) + if err != nil { + return Info{}, fmt.Errorf("couldn't get current branch: %w", err) + } + short, err := getShortCommit(ctx) + if err != nil { + return Info{}, fmt.Errorf("couldn't get current commit: %w", err) + } + full, err := getFullCommit(ctx) + if err != nil { + return Info{}, fmt.Errorf("couldn't get current commit: %w", err) + } + first, err := getFirstCommit(ctx) + if err != nil { + return Info{}, fmt.Errorf("couldn't get first commit: %w", err) + } + date, err := getCommitDate(ctx) + if err != nil { + return Info{}, fmt.Errorf("couldn't get commit date: %w", err) + } + summary, err := getSummary(ctx) + if err != nil { + return Info{}, fmt.Errorf("couldn't get summary: %w", err) + } + gitURL, err := getURL(ctx) + if err != nil { + return Info{}, fmt.Errorf("couldn't get remote URL: %w", err) + } + + if strings.HasPrefix(gitURL, "https://") { + u, err := url.Parse(gitURL) + if err != nil { + return Info{}, fmt.Errorf("couldn't parse remote URL: %w", err) + } + u.User = nil + gitURL = u.String() + } + + dirty := checkDirty(ctx) + + // TODO: allow exclusions. + tag, err := getTag(ctx, []string{}) + if err != nil { + return Info{ + Branch: branch, + FullCommit: full, + ShortCommit: short, + FirstCommit: first, + CommitDate: date, + URL: gitURL, + CurrentTag: "v0.0.0", + Summary: summary, + Dirty: dirty != nil, + }, errors.Join(ErrNoTag, dirty) + } + + subject, err := getTagWithFormat(ctx, tag, "contents:subject") + if err != nil { + return Info{}, fmt.Errorf("couldn't get tag subject: %w", err) + } + + contents, err := getTagWithFormat(ctx, tag, "contents") + if err != nil { + return Info{}, fmt.Errorf("couldn't get tag contents: %w", err) + } + + body, err := getTagWithFormat(ctx, tag, "contents:body") + if err != nil { + return Info{}, fmt.Errorf("couldn't get tag content body: %w", err) + } + + return Info{ + Branch: branch, + CurrentTag: tag, + FullCommit: full, + ShortCommit: short, + FirstCommit: first, + CommitDate: date, + URL: gitURL, + Summary: summary, + TagSubject: subject, + TagContents: contents, + TagBody: body, + Dirty: dirty != nil, + }, dirty +} + +// checkDirty returns an error if the current git repository is dirty. +func checkDirty(ctx context.Context) error { + out, err := run(ctx, "status", "--porcelain") + if strings.TrimSpace(out) != "" || err != nil { + return ErrDirty{status: out} + } + return nil +} + +func getBranch(ctx context.Context) (string, error) { + return clean(run(ctx, "rev-parse", "--abbrev-ref", "HEAD", "--quiet")) +} + +func getCommitDate(ctx context.Context) (time.Time, error) { + ct, err := clean(run(ctx, "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) (string, error) { + return clean(run(ctx, "show", "--format=%h", "HEAD", "--quiet")) +} + +func getFullCommit(ctx context.Context) (string, error) { + return clean(run(ctx, "show", "--format=%H", "HEAD", "--quiet")) +} + +func getFirstCommit(ctx context.Context) (string, error) { + return clean(run(ctx, "rev-list", "--max-parents=0", "HEAD")) +} + +func getSummary(ctx context.Context) (string, error) { + return clean(run(ctx, "describe", "--always", "--dirty", "--tags")) +} + +func getTagWithFormat(ctx context.Context, tag, format string) (string, error) { + out, err := run(ctx, "tag", "-l", "--format='%("+format+")'", tag) + return strings.TrimSpace(strings.TrimSuffix(strings.ReplaceAll(out, "'", ""), "\n\n")), err +} + +func getTag(ctx context.Context, 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, "HEAD", excluding)) + if err != nil { + return "", err + } + tag := filterOut(tags, excluding) + return tag, err +} + +func gitDescribe(ctx context.Context, 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, args...)) +} + +func getURL(ctx context.Context) (string, error) { + return clean(run(ctx, "ls-remote", "--get-url")) +} + +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/testing/git.go b/pkg/internal/testing/git.go new file mode 100644 index 0000000000..ccdfe819a4 --- /dev/null +++ b/pkg/internal/testing/git.go @@ -0,0 +1,159 @@ +// 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 testing + +import ( + "bytes" + "errors" + "os/exec" + "strings" + "testing" +) + +// GitInit inits a new git project. +func GitInit(t *testing.T) { + t.Helper() + out, err := fakeGit("init") + requireNoError(t, err) + requireContains(t, out, "Initialized empty Git repository", "") + requireNoError(t, err) + GitCheckoutBranch(t, "main") + _, _ = fakeGit("branch", "-D", "master") +} + +// GitRemoteAdd adds the given url as remote. +func GitRemoteAdd(t *testing.T, url string) { + t.Helper() + out, err := fakeGit("remote", "add", "origin", url) + requireNoError(t, err) + requireEmpty(t, out) +} + +// GitCommit creates a git commits. +func GitCommit(t *testing.T, msg string) { + t.Helper() + out, err := fakeGit("commit", "--allow-empty", "-m", msg) + requireNoError(t, err) + requireContains(t, out, "main", msg) +} + +// GitTag creates a git tag. +func GitTag(t *testing.T, tag string) { + t.Helper() + out, err := fakeGit("tag", tag) + requireNoError(t, err) + requireEmpty(t, out) +} + +// GitAnnotatedTag creates an annotated tag. +func GitAnnotatedTag(t *testing.T, tag, message string) { + t.Helper() + out, err := fakeGit("tag", "-a", tag, "-m", message) + requireNoError(t, err) + requireEmpty(t, out) +} + +// GitAdd adds all files to stage. +func GitAdd(t *testing.T) { + t.Helper() + out, err := fakeGit("add", "-A") + requireNoError(t, err) + requireEmpty(t, out) +} + +func fakeGit(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(allArgs...) +} + +// GitCheckoutBranch allows us to change the active branch that we're using. +func GitCheckoutBranch(t *testing.T, name string) { + t.Helper() + out, err := fakeGit("checkout", "-b", name) + requireNoError(t, err) + requireEmpty(t, out) +} + +func gitRun(args ...string) (string, error) { + cmd := exec.Command("git", args...) + + 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/testing/git_test.go b/pkg/internal/testing/git_test.go new file mode 100644 index 0000000000..2090724ebb --- /dev/null +++ b/pkg/internal/testing/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 testing + +import "testing" + +func TestGit(t *testing.T) { + TestMkTemp(t) + GitInit(t) + GitAdd(t) + GitCommit(t, "commit1") + GitRemoteAdd(t, "git@github.com:goreleaser/nope.git") + GitTag(t, "v1.0.0") +} diff --git a/pkg/internal/testing/mktemp.go b/pkg/internal/testing/mktemp.go new file mode 100644 index 0000000000..b3e65dfef9 --- /dev/null +++ b/pkg/internal/testing/mktemp.go @@ -0,0 +1,56 @@ +// 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 testing + +import ( + "os" + "testing" +) + +// Mktmp creates a new tempdir, cd into it and provides a back function that +// cd into the previous directory. +func Mktmp(t *testing.T) string { + t.Helper() + folder := t.TempDir() + current, err := os.Getwd() + requireNoError(t, err) + requireNoError(t, os.Chdir(folder)) + t.Cleanup(func() { + requireNoError(t, os.Chdir(current)) + }) + return folder +} diff --git a/pkg/internal/testing/mktemp_test.go b/pkg/internal/testing/mktemp_test.go new file mode 100644 index 0000000000..d45d53f2d7 --- /dev/null +++ b/pkg/internal/testing/mktemp_test.go @@ -0,0 +1,47 @@ +// 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 testing + +import ( + "testing" +) + +func TestMkTemp(t *testing.T) { + if Mktmp(t) == "" { + t.Fatalf("mktemp returned an empty string") + } +}