Skip to content

Commit

Permalink
add support for checking out git repo to a ref via gogit
Browse files Browse the repository at this point in the history
Add a new checkout strategy that enables checking out to a Git
reference: https://git-scm.com/book/en/v2/Git-Internals-Git-References.

Signed-off-by: Sanskar Jaiswal <jaiswalsanskar078@gmail.com>
  • Loading branch information
aryan9600 committed Feb 15, 2023
1 parent 2bb3aa8 commit ca1dce0
Show file tree
Hide file tree
Showing 5 changed files with 204 additions and 2 deletions.
2 changes: 2 additions & 0 deletions git/gogit/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 != "":
Expand Down
40 changes: 40 additions & 0 deletions git/gogit/clone.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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
Expand All @@ -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},
Expand Down
152 changes: 152 additions & 0 deletions git/gogit/clone_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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) {
Expand Down
2 changes: 2 additions & 0 deletions git/libgit2/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 != "":
Expand Down
10 changes: 8 additions & 2 deletions git/repository/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down

0 comments on commit ca1dce0

Please sign in to comment.