diff --git a/pkg/build/gobuild.go b/pkg/build/gobuild.go index 172e9e3163..f1a77df2dd 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" @@ -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, dir, 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, dir string) 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, dir) + 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, dir string, buildCfg Config) ([]string, error) { var args []string - data := createTemplateData() + data := createTemplateData(ctx, dir) 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..9a07d97108 --- /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) { + 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..f283349f9d --- /dev/null +++ b/pkg/internal/git/git_test.go @@ -0,0 +1,370 @@ +// 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) { + 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() + kotesting.GitInit(t, dir) + kotesting.GitRemoteAdd(t, dir, fakeGitURL) + kotesting.GitCommit(t, dir, "commit1") + kotesting.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.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) { + dir := t.TempDir() + kotesting.GitInit(t, dir) + kotesting.GitRemoteAdd(t, dir, fakeGitURL) + kotesting.GitCommit(t, dir, "commit1") + kotesting.GitAnnotatedTag(t, dir, "v0.0.1", "first version\n\nlalalla\nlalal\nlah") + i, err := git.GetInfo(context.TODO(), dir) + 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) { + dir := t.TempDir() + kotesting.GitInit(t, dir) + kotesting.GitRemoteAdd(t, dir, fakeGitURL) + kotesting.GitCommit(t, dir, "test-branch-commit") + kotesting.GitTag(t, dir, "test-branch-tag") + kotesting.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.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) { + dir := t.TempDir() + kotesting.GitInit(t, dir) + kotesting.GitCommit(t, dir, "commit1") + kotesting.GitTag(t, dir, "v0.0.1") + i, err := git.GetInfo(context.TODO(), dir) + 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) { + dir := t.TempDir() + kotesting.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() + kotesting.GitInit(t, dir) + kotesting.GitRemoteAdd(t, dir, fakeGitURL) + kotesting.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.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) { + dir := t.TempDir() + kotesting.GitInit(t, dir) + kotesting.GitRemoteAdd(t, dir, fakeGitURL) + dummy, err := os.Create(filepath.Join(dir, "dummy")) + requireNoError(t, err) + requireNoError(t, dummy.Close()) + kotesting.GitAdd(t, dir) + kotesting.GitCommit(t, dir, "commit2") + kotesting.GitTag(t, dir, "v0.0.1") + requireNoError(t, os.WriteFile(dummy.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.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) { + dir := t.TempDir() + kotesting.GitInit(t, dir) + kotesting.GitRemoteAdd(t, dir, + "https://gitlab-ci-token:SyYhsAghYFTvMoxw7GAg@gitlab.private.com/platform/base/poc/kink.git/releases/tag/v0.1.4") + kotesting.GitAdd(t, dir) + kotesting.GitCommit(t, dir, "commit2") + kotesting.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.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) { + dir := t.TempDir() + kotesting.GitInit(t, dir) + kotesting.GitRemoteAdd(t, dir, + "https://gitlab-ci-token:SyYhsAghYFTvMoxw7GAggitlab.com/platform/base/poc/kink.git/releases/tag/v0.1.4") + kotesting.GitAdd(t, dir) + kotesting.GitCommit(t, dir, "commit2") + kotesting.GitTag(t, dir, "v0.0.1") + i, err := git.GetInfo(context.TODO(), dir) + requireError(t, err) + + tpl := i.TemplateValue() + requireEmpty(t, tpl) +} + +func TestValidState(t *testing.T) { + dir := t.TempDir() + kotesting.GitInit(t, dir) + kotesting.GitRemoteAdd(t, dir, fakeGitURL) + kotesting.GitCommit(t, dir, "commit3") + kotesting.GitTag(t, dir, "v0.0.1") + kotesting.GitTag(t, dir, "v0.0.2") + kotesting.GitCommit(t, dir, "commit4") + kotesting.GitTag(t, dir, "v0.0.3") + i, err := git.GetInfo(context.TODO(), dir) + 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..08461db7cc --- /dev/null +++ b/pkg/internal/git/info.go @@ -0,0 +1,338 @@ +// 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 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) + } + first, err := getFirstCommit(ctx, dir) + if err != nil { + return Info{}, fmt.Errorf("couldn't get first commit: %w", err) + } + date, err := getCommitDate(ctx, dir) + if err != nil { + return Info{}, fmt.Errorf("couldn't get commit date: %w", err) + } + summary, err := getSummary(ctx, dir) + if err != nil { + return Info{}, fmt.Errorf("couldn't get summary: %w", err) + } + gitURL, err := getURL(ctx, dir) + 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, dir) + + // TODO: allow exclusions. + tag, err := getTag(ctx, dir, []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, dir, tag, "contents:subject") + if err != nil { + return Info{}, fmt.Errorf("couldn't get tag subject: %w", err) + } + + contents, err := getTagWithFormat(ctx, dir, tag, "contents") + if err != nil { + return Info{}, fmt.Errorf("couldn't get tag contents: %w", err) + } + + body, err := getTagWithFormat(ctx, dir, 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 +} + +// 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 getFirstCommit(ctx context.Context, dir string) (string, error) { + return clean(run(ctx, runConfig{ + dir: dir, + args: []string{"rev-list", "--max-parents=0", "HEAD"}, + })) +} + +func getSummary(ctx context.Context, dir string) (string, error) { + return clean(run(ctx, runConfig{ + dir: dir, + args: []string{"describe", "--always", "--dirty", "--tags"}, + })) +} + +func getTagWithFormat(ctx context.Context, dir, tag, format string) (string, error) { + out, err := run(ctx, runConfig{ + dir: dir, + args: []string{"tag", "-l", "--format='%(" + format + ")'", tag}, + }) + return strings.TrimSpace(strings.TrimSuffix(strings.ReplaceAll(out, "'", ""), "\n\n")), err +} + +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 getURL(ctx context.Context, dir string) (string, error) { + return clean(run(ctx, runConfig{ + dir: dir, + args: []string{"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..3152b94e7d --- /dev/null +++ b/pkg/internal/testing/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 testing + +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/testing/git_test.go b/pkg/internal/testing/git_test.go new file mode 100644 index 0000000000..faa5d36554 --- /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) { + 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") +}