diff --git a/git/gogit/client.go b/git/gogit/client.go index 8cd8830f..096837b5 100644 --- a/git/gogit/client.go +++ b/git/gogit/client.go @@ -226,6 +226,8 @@ func (g *Client) Clone(ctx context.Context, url string, cloneOpts repository.Clo switch { case checkoutStrat.Commit != "": return g.cloneCommit(ctx, url, checkoutStrat.Commit, cloneOpts) + case checkoutStrat.RefName != "": + return g.cloneRefName(ctx, url, checkoutStrat.RefName, cloneOpts) case checkoutStrat.Tag != "": return g.cloneTag(ctx, url, checkoutStrat.Tag, cloneOpts) case checkoutStrat.SemVer != "": diff --git a/git/gogit/clone.go b/git/gogit/clone.go index a8334d40..3ebe6d41 100644 --- a/git/gogit/clone.go +++ b/git/gogit/clone.go @@ -55,6 +55,7 @@ func (g *Client) cloneBranch(ctx context.Context, url, branch string, opts repos return nil, err } if head != "" && head == lastObserved { + // Construct a non-concrete commit with the existing information. c := &git.Commit{ Hash: git.ExtractHashFromRevision(head), Reference: plumbing.NewBranchReferenceName(branch).String(), @@ -134,6 +135,7 @@ func (g *Client) cloneTag(ctx context.Context, url, tag string, opts repository. return nil, err } if head != "" && head == lastObserved { + // Construct a non-concrete commit with the existing information. c := &git.Commit{ Hash: git.ExtractHashFromRevision(head), Reference: ref.String(), @@ -354,6 +356,39 @@ func (g *Client) cloneSemVer(ctx context.Context, url, semverTag string, opts re return buildCommitWithRef(cc, ref) } +func (g *Client) cloneRefName(ctx context.Context, url string, refName string, cloneOpts repository.CloneOptions) (*git.Commit, error) { + if g.authOpts == nil { + return nil, fmt.Errorf("unable to checkout repo with an empty set of auth options") + } + authMethod, err := transportAuth(g.authOpts, g.useDefaultKnownHosts) + if err != nil { + return nil, fmt.Errorf("unable to construct auth method with options: %w", err) + } + head, err := getRemoteHEAD(ctx, url, plumbing.ReferenceName(refName), g.authOpts, authMethod) + if err != nil { + return nil, err + } + if head == "" { + return nil, fmt.Errorf("unable to resolve ref '%s' to a specific commit", refName) + } + + hash := git.ExtractHashFromRevision(head) + // check if previous revision has changed before attempting to clone + if lastObserved := git.TransformRevision(cloneOpts.LastObservedCommit); lastObserved != "" { + if hash.Digest() != "" && hash.Digest() == lastObserved { + // Construct a non-concrete commit with the existing information. + // We exclude the reference here to ensure compatibility with the format + // of the Commit object returned by cloneCommit(). + c := &git.Commit{ + Hash: hash, + } + return c, nil + } + } + + return g.cloneCommit(ctx, url, hash.String(), cloneOpts) +} + func recurseSubmodules(recurse bool) extgogit.SubmoduleRescursivity { if recurse { return extgogit.DefaultSubmoduleRecursionDepth @@ -363,6 +398,11 @@ func recurseSubmodules(recurse bool) extgogit.SubmoduleRescursivity { func getRemoteHEAD(ctx context.Context, url string, ref plumbing.ReferenceName, authOpts *git.AuthOptions, authMethod transport.AuthMethod) (string, error) { + // ref: https://git-scm.com/docs/git-check-ref-format#_description; point no. 6 + if strings.HasPrefix(ref.String(), "/") || strings.HasSuffix(ref.String(), "/") { + return "", fmt.Errorf("ref %s is invalid; Git refs cannot begin or end with a slash '/'", ref.String()) + } + remoteCfg := &config.RemoteConfig{ Name: git.DefaultRemote, URLs: []string{url}, diff --git a/git/gogit/clone_test.go b/git/gogit/clone_test.go index 394f112c..88f54405 100644 --- a/git/gogit/clone_test.go +++ b/git/gogit/clone_test.go @@ -32,6 +32,7 @@ import ( "time" extgogit "github.com/fluxcd/go-git/v5" + "github.com/fluxcd/go-git/v5/config" "github.com/fluxcd/go-git/v5/plumbing" "github.com/fluxcd/go-git/v5/plumbing/cache" "github.com/fluxcd/go-git/v5/plumbing/object" @@ -493,6 +494,147 @@ func TestClone_cloneSemVer(t *testing.T) { } } +func TestClone_cloneRefName(t *testing.T) { + g := NewWithT(t) + + server, err := gittestserver.NewTempGitServer() + g.Expect(err).ToNot(HaveOccurred()) + defer os.RemoveAll(server.Root()) + err = server.StartHTTP() + g.Expect(err).ToNot(HaveOccurred()) + defer server.StopHTTP() + + repoPath := "test.git" + err = server.InitRepo("../testdata/git/repo", git.DefaultBranch, repoPath) + g.Expect(err).ToNot(HaveOccurred()) + repoURL := server.HTTPAddress() + "/" + repoPath + repo, err := extgogit.PlainClone(t.TempDir(), false, &extgogit.CloneOptions{ + URL: repoURL, + }) + g.Expect(err).ToNot(HaveOccurred()) + + // head is the current HEAD on master + head, err := repo.Head() + g.Expect(err).ToNot(HaveOccurred()) + err = createBranch(repo, "test") + g.Expect(err).ToNot(HaveOccurred()) + err = repo.Push(&extgogit.PushOptions{}) + g.Expect(err).ToNot(HaveOccurred()) + + // create a new branch for testing tags in order to avoid disturbing the state + // of the current branch that's used for testing branches later. + err = createBranch(repo, "tag-testing") + g.Expect(err).ToNot(HaveOccurred()) + hash, err := commitFile(repo, "bar.txt", "this is the way", time.Now()) + g.Expect(err).ToNot(HaveOccurred()) + err = repo.Push(&extgogit.PushOptions{}) + g.Expect(err).ToNot(HaveOccurred()) + _, err = tag(repo, hash, false, "v0.1.0", time.Now()) + g.Expect(err).ToNot(HaveOccurred()) + err = repo.Push(&extgogit.PushOptions{ + RefSpecs: []config.RefSpec{ + config.RefSpec("+refs/tags/v0.1.0" + ":refs/tags/v0.1.0"), + }, + }) + g.Expect(err).ToNot(HaveOccurred()) + + // set a custom reference, in the format of GitHub PRs. + err = repo.Storer.SetReference(plumbing.NewHashReference(plumbing.ReferenceName("/refs/pull/1/head"), hash)) + g.Expect(err).ToNot(HaveOccurred()) + err = repo.Push(&extgogit.PushOptions{ + RefSpecs: []config.RefSpec{ + config.RefSpec("+refs/pull/1/head" + ":refs/pull/1/head"), + }, + }) + g.Expect(err).ToNot(HaveOccurred()) + + tests := []struct { + name string + refName string + filesCreated map[string]string + lastRevision string + expectedCommit string + expectedConcreteCommit bool + expectedErr string + }{ + { + name: "ref name pointing to a branch", + refName: "refs/heads/master", + filesCreated: map[string]string{"foo.txt": "test file\n"}, + expectedCommit: git.Hash(head.Hash().String()).Digest(), + expectedConcreteCommit: true, + }, + { + name: "skip clone if LastRevision is unchanged", + refName: "refs/heads/master", + lastRevision: git.Hash(head.Hash().String()).Digest(), + expectedCommit: git.Hash(head.Hash().String()).Digest(), + expectedConcreteCommit: false, + }, + { + name: "skip clone if LastRevision is unchanged even if the reference changes", + refName: "refs/heads/test", + lastRevision: git.Hash(head.Hash().String()).Digest(), + expectedCommit: git.Hash(head.Hash().String()).Digest(), + expectedConcreteCommit: false, + }, + { + name: "ref name pointing to a tag", + refName: "refs/tags/v0.1.0", + filesCreated: map[string]string{"bar.txt": "this is the way"}, + lastRevision: git.Hash(head.Hash().String()).Digest(), + expectedCommit: git.Hash(hash.String()).Digest(), + expectedConcreteCommit: true, + }, + { + name: "ref name pointing to a pull request", + refName: "refs/pull/1/head", + filesCreated: map[string]string{"bar.txt": "this is the way"}, + expectedCommit: git.Hash(hash.String()).Digest(), + expectedConcreteCommit: true, + }, + { + name: "non existing ref", + refName: "refs/tags/v0.2.0", + expectedErr: "unable to resolve ref 'refs/tags/v0.2.0' to a specific commit", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + tmpDir := t.TempDir() + ggc, err := NewClient(tmpDir, &git.AuthOptions{Transport: git.HTTP}) + g.Expect(err).ToNot(HaveOccurred()) + + cc, err := ggc.Clone(context.TODO(), repoURL, repository.CloneOptions{ + CheckoutStrategy: repository.CheckoutStrategy{ + RefName: tt.refName, + }, + LastObservedCommit: tt.lastRevision, + }) + + if tt.expectedErr != "" { + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring(tt.expectedErr)) + g.Expect(cc).To(BeNil()) + return + } + + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(cc.String()).To(Equal(tt.expectedCommit)) + g.Expect(git.IsConcreteCommit(*cc)).To(Equal(tt.expectedConcreteCommit)) + + for k, v := range tt.filesCreated { + g.Expect(filepath.Join(tmpDir, k)).To(BeARegularFile()) + content, err := os.ReadFile(filepath.Join(tmpDir, k)) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(string(content)).To(Equal(v)) + } + }) + } +} + func Test_cloneSubmodule(t *testing.T) { g := NewWithT(t) @@ -997,6 +1139,16 @@ func Test_getRemoteHEAD(t *testing.T) { head, err = getRemoteHEAD(context.TODO(), path, ref, &git.AuthOptions{}, nil) g.Expect(err).ToNot(HaveOccurred()) g.Expect(head).To(Equal(fmt.Sprintf("%s@%s", "v0.1.0", git.Hash(cc.String()).Digest()))) + + ref = plumbing.ReferenceName("/refs/heads/main") + head, err = getRemoteHEAD(context.TODO(), path, ref, &git.AuthOptions{}, nil) + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(Equal(fmt.Sprintf("ref %s is invalid; Git refs cannot begin or end with a slash '/'", ref.String()))) + + ref = plumbing.ReferenceName("refs/heads/main/") + head, err = getRemoteHEAD(context.TODO(), path, ref, &git.AuthOptions{}, nil) + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(Equal(fmt.Sprintf("ref %s is invalid; Git refs cannot begin or end with a slash '/'", ref.String()))) } func TestClone_CredentialsOverHttp(t *testing.T) { diff --git a/git/libgit2/client.go b/git/libgit2/client.go index c2445286..fb8e3e69 100644 --- a/git/libgit2/client.go +++ b/git/libgit2/client.go @@ -193,6 +193,8 @@ func (l *Client) Clone(ctx context.Context, url string, cloneOpts repository.Clo switch { case checkoutStrat.Commit != "": return l.cloneCommit(ctx, url, checkoutStrat.Commit, cloneOpts) + case checkoutStrat.RefName != "": + return nil, errors.New("unable to use RefName: client does not support this strategy") case checkoutStrat.Tag != "": return l.cloneTag(ctx, url, checkoutStrat.Tag, cloneOpts) case checkoutStrat.SemVer != "": diff --git a/git/repository/options.go b/git/repository/options.go index 740f43c9..9e218f68 100644 --- a/git/repository/options.go +++ b/git/repository/options.go @@ -62,10 +62,16 @@ type CheckoutStrategy struct { // Tag to checkout, takes precedence over Branch. Tag string - // SemVer tag expression to checkout, takes precedence over Tag. + // SemVer tag expression to checkout, takes precedence over Branch and Tag. SemVer string `json:"semver,omitempty"` - // Commit SHA1 to checkout, takes precedence over Tag and SemVer. + // RefName is the reference to checkout to. It must conform to the + // Git reference format: https://git-scm.com/book/en/v2/Git-Internals-Git-References + // Examples: "refs/heads/main", "refs/pull/420/head", "refs/tags/v0.1.0" + // It takes precedence over Branch, Tag and SemVer. + RefName string + + // Commit SHA1 to checkout, takes precedence over all the other options. // If supported by the client, it can be combined with Branch. Commit string }