Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions pkg/git/checkout.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package git

import (
"fmt"

"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing"
)
Expand Down Expand Up @@ -32,3 +34,50 @@ func CheckoutTag(target, name string) error {
Branch: plumbing.ReferenceName("refs/tags/" + name),
})
}

// CheckoutRef checks out the given ref, which may be a tag, a branch
// (local or remote-tracking under origin/), or a commit hash. The ref is
// resolved to a commit hash and checked out as a detached HEAD.
func CheckoutRef(target, ref string) error {
repo, err := git.PlainOpen(target)
if err != nil {
return err
}
hash, err := resolveRef(repo, ref)
if err != nil {
return err
}
w, err := repo.Worktree()
if err != nil {
return err
}
return w.Checkout(&git.CheckoutOptions{Hash: *hash, Force: true})
}

// resolveRef resolves ref to a commit hash, trying in order: tag, local
// branch, remote-tracking branch (origin/<ref>), then a generic revision
// (which covers full and abbreviated commit hashes).
func resolveRef(repo *git.Repository, ref string) (*plumbing.Hash, error) {
if t, err := repo.Reference(plumbing.NewTagReferenceName(ref), true); err == nil {
h := t.Hash()
// annotated tags point at a tag object; dereference to its commit
if to, err := repo.TagObject(h); err == nil {
if c, err := to.Commit(); err == nil {
return &c.Hash, nil
}
}
return &h, nil
}
if b, err := repo.Reference(plumbing.NewBranchReferenceName(ref), true); err == nil {
h := b.Hash()
return &h, nil
}
if rb, err := repo.Reference(plumbing.NewRemoteReferenceName("origin", ref), true); err == nil {
h := rb.Hash()
return &h, nil
}
if h, err := repo.ResolveRevision(plumbing.Revision(ref)); err == nil {
return h, nil
}
return nil, fmt.Errorf("could not resolve git ref %q", ref)
}
82 changes: 82 additions & 0 deletions pkg/git/checkout_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package git

import (
"os"
"path/filepath"
"testing"
"time"

gogit "github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/object"
"github.com/stretchr/testify/require"
)

// makeTestRepo creates a local git repo with two commits, a lightweight tag
// "v1.0.0" on the first commit, and a branch "feature" on the first commit.
// It returns the repo dir and the two commit hashes (in order).
func makeTestRepo(t *testing.T) (string, plumbing.Hash, plumbing.Hash) {
t.Helper()
dir := t.TempDir()
repo, err := gogit.PlainInit(dir, false)
require.NoError(t, err)
w, err := repo.Worktree()
require.NoError(t, err)

commit := func(file, content string) plumbing.Hash {
require.NoError(t, os.WriteFile(filepath.Join(dir, file), []byte(content), 0644))
_, err := w.Add(file)
require.NoError(t, err)
h, err := w.Commit("commit "+file, &gogit.CommitOptions{
Author: &object.Signature{Name: "test", Email: "test@test", When: time.Unix(0, 0)},
})
require.NoError(t, err)
return h
}

first := commit("a.txt", "first")

// lightweight tag and branch on the first commit
_, err = repo.CreateTag("v1.0.0", first, nil)
require.NoError(t, err)
require.NoError(t, repo.Storer.SetReference(
plumbing.NewHashReference(plumbing.NewBranchReferenceName("feature"), first)))

second := commit("b.txt", "second")
return dir, first, second
}

func headHash(t *testing.T, dir string) plumbing.Hash {
t.Helper()
repo, err := gogit.PlainOpen(dir)
require.NoError(t, err)
head, err := repo.Head()
require.NoError(t, err)
return head.Hash()
}

func TestCheckoutRef_Tag(t *testing.T) {
dir, first, _ := makeTestRepo(t)
require.NoError(t, CheckoutRef(dir, "v1.0.0"))
require.Equal(t, first, headHash(t, dir))
}

func TestCheckoutRef_Branch(t *testing.T) {
dir, first, _ := makeTestRepo(t)
require.NoError(t, CheckoutRef(dir, "feature"))
require.Equal(t, first, headHash(t, dir))
}

func TestCheckoutRef_Commit(t *testing.T) {
dir, first, second := makeTestRepo(t)
require.NoError(t, CheckoutRef(dir, second.String()))
require.Equal(t, second, headHash(t, dir))
// also resolvable by abbreviated hash
require.NoError(t, CheckoutRef(dir, first.String()[:8]))
require.Equal(t, first, headHash(t, dir))
}

func TestCheckoutRef_Unknown(t *testing.T) {
dir, _, _ := makeTestRepo(t)
require.Error(t, CheckoutRef(dir, "does-not-exist"))
}
23 changes: 23 additions & 0 deletions pkg/repos/cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,29 @@ func (c *cache) Install(url string, version string) (string, error) {
return name, nil
}

// InstallRef installs a template from an arbitrary git url into the cache and
// checks it out at ref (a tag, branch or commit). Unlike Install, which is
// driven by the registry and only resolves version tags, InstallRef supports
// any git ref and derives a host-qualified cache name so that any host works.
func (c *cache) InstallRef(url string, ref string) (string, error) {
if ref == "" {
return "", fmt.Errorf("version is required")
}
name, err := RepoNameFromGitURL(url)
if err != nil {
return "", err
}
name = MakeRepoID(name, ref)
dst := helper.Join(c.cacheDir, name)
if err := git.CloneOrPull(url, dst); err != nil {
return "", err
}
if err := git.CheckoutRef(dst, ref); err != nil {
return "", err
}
return name, nil
}

// ListTemplates lists all templates in the cache
func (c *cache) ListCachedRepos() ([]*git.RepoInfo, error) {
// walk package dir to find a dir that contains a .git dir
Expand Down
71 changes: 71 additions & 0 deletions pkg/repos/giturl.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package repos

import (
"regexp"
"strings"

"github.com/apigear-io/cli/pkg/git"
)

// scpLike matches the scp-style git syntax (e.g. git@github.com:me/tpl.git).
// The user and host parts may not contain a slash, which keeps registry repo
// IDs (e.g. apigear-io/template-go@1.2.3) from being detected as git URLs.
var scpLike = regexp.MustCompile(`^[A-Za-z0-9._-]+@[A-Za-z0-9._-]+:`)

// IsGitURL reports whether s looks like a git URL we can clone from directly,
// as opposed to a local path or a registry repo ID.
func IsGitURL(s string) bool {
for _, prefix := range []string{"https://", "http://", "ssh://", "git://", "file://"} {
if strings.HasPrefix(s, prefix) {
return true
}
}
return scpLike.MatchString(s)
}

// SplitGitURLVersion splits a git URL of the form <url>@<version> into its url
// and version parts. The version separator is only recognised at or after the
// start of the URL path, so neither the scp user@host nor http credentials
// (user@host) are mistaken for a version. The version may itself contain a
// slash (e.g. a branch name like release/1.0). If no version is present the
// returned version is empty.
func SplitGitURLVersion(s string) (string, string) {
pathStart := 0
if i := strings.Index(s, "://"); i >= 0 {
// scheme://[user@]host/path -> path starts at the first '/' after "://"
rest := i + len("://")
if slash := strings.IndexByte(s[rest:], '/'); slash >= 0 {
pathStart = rest + slash
} else {
pathStart = len(s)
}
} else if at := strings.IndexByte(s, '@'); at >= 0 {
// scp-style user@host:path -> path starts after the ':' following host
if colon := strings.IndexByte(s[at:], ':'); colon >= 0 {
pathStart = at + colon + 1
}
}
if at := strings.IndexByte(s[pathStart:], '@'); at >= 0 {
idx := pathStart + at
return s[:idx], s[idx+1:]
}
return s, ""
}

// RepoNameFromGitURL derives a host-qualified cache name (e.g.
// github.com/me/tpl) from a git URL. The name is derived generically so that
// any host works (not only the ones a VCS-aware parser knows), and so that the
// https and scp forms of the same repo map to the same name. The url must not
// carry a @version suffix; strip it with SplitGitURLVersion first.
func RepoNameFromGitURL(rawurl string) (string, error) {
u, err := git.ParseAsUrl(rawurl)
if err != nil {
return "", err
}
name := strings.TrimSuffix(u.Path, ".git")
name = strings.Trim(name, "/")
if host := u.Hostname(); host != "" {
name = host + "/" + name
}
return name, nil
}
88 changes: 88 additions & 0 deletions pkg/repos/giturl_install_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package repos

import (
"os"
"path/filepath"
"testing"
"time"

gogit "github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing/object"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// makeSourceRepo creates a local git repo to clone from. The first commit
// writes marker.txt="v1" and is tagged "v1.0.0"; the second commit (on the
// default branch) overwrites marker.txt="v2". Checking out the tag must
// therefore yield "v1".
func makeSourceRepo(t *testing.T) string {
t.Helper()
dir := t.TempDir()
repo, err := gogit.PlainInit(dir, false)
require.NoError(t, err)
w, err := repo.Worktree()
require.NoError(t, err)

commit := func(content string) {
require.NoError(t, os.WriteFile(filepath.Join(dir, "marker.txt"), []byte(content), 0644))
_, err := w.Add("marker.txt")
require.NoError(t, err)
_, err = w.Commit("commit "+content, &gogit.CommitOptions{
Author: &object.Signature{Name: "test", Email: "test@test", When: time.Unix(0, 0)},
})
require.NoError(t, err)
}

commit("v1")
head, err := repo.Head()
require.NoError(t, err)
_, err = repo.CreateTag("v1.0.0", head.Hash(), nil)
require.NoError(t, err)
commit("v2")
return dir
}

func TestGetOrInstallTemplateFromGitURL_InstallsAndChecksOutRef(t *testing.T) {
src := makeSourceRepo(t)
withTempCache(t)

repoID, err := GetOrInstallTemplateFromGitURL(src, "v1.0.0")
require.NoError(t, err)
require.True(t, Cache.Exists(repoID))

dir, err := Cache.GetTemplateDir(repoID)
require.NoError(t, err)
marker, err := os.ReadFile(filepath.Join(dir, "marker.txt"))
require.NoError(t, err)
assert.Equal(t, "v1", string(marker))
}

func TestGetOrInstallTemplateFromGitURL_UsesCacheWithoutSource(t *testing.T) {
src := makeSourceRepo(t)
withTempCache(t)

repoID, err := GetOrInstallTemplateFromGitURL(src, "v1.0.0")
require.NoError(t, err)

// Removing the source proves the second call is served from the cache
// and does not touch the network/source again.
require.NoError(t, os.RemoveAll(src))
repoID2, err := GetOrInstallTemplateFromGitURL(src, "v1.0.0")
require.NoError(t, err)
assert.Equal(t, repoID, repoID2)
}

func TestGetOrInstallTemplateFromGitURL_RequiresVersion(t *testing.T) {
_, err := GetOrInstallTemplateFromGitURL("https://github.com/me/tpl.git", "")
require.Error(t, err)
}

// withTempCache points the package-global Cache at a throwaway directory for
// the duration of the test, restoring it afterwards.
func withTempCache(t *testing.T) {
t.Helper()
prev := Cache
Cache = New(t.TempDir())
t.Cleanup(func() { Cache = prev })
}
40 changes: 40 additions & 0 deletions pkg/repos/giturl_name_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package repos

import (
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestRepoNameFromGitURL(t *testing.T) {
tests := []struct {
label string
input string
expected string
}{
{"https github", "https://github.com/me/tpl.git", "github.com/me/tpl"},
{"scp github", "git@github.com:me/tpl.git", "github.com/me/tpl"},
{"ssh scheme github", "ssh://git@github.com/me/tpl.git", "github.com/me/tpl"},
{"self hosted", "https://git.example.com/me/tpl.git", "git.example.com/me/tpl"},
{"no dot git suffix", "https://github.com/me/tpl", "github.com/me/tpl"},
{"nested path", "https://github.com/me/sub/tpl.git", "github.com/me/sub/tpl"},
}
for _, tt := range tests {
t.Run(tt.label, func(t *testing.T) {
name, err := RepoNameFromGitURL(tt.input)
require.NoError(t, err)
assert.Equal(t, tt.expected, name)
})
}
}

// https and scp forms of the same repo must produce the same cache key so they
// share a cached clone.
func TestRepoNameFromGitURL_ProtocolEquivalence(t *testing.T) {
https, err := RepoNameFromGitURL("https://github.com/me/tpl.git")
require.NoError(t, err)
scp, err := RepoNameFromGitURL("git@github.com:me/tpl.git")
require.NoError(t, err)
assert.Equal(t, https, scp)
}
Loading
Loading