diff --git a/pkg/git/checkout.go b/pkg/git/checkout.go index c376ed9b..eb27af48 100644 --- a/pkg/git/checkout.go +++ b/pkg/git/checkout.go @@ -1,6 +1,8 @@ package git import ( + "fmt" + "github.com/go-git/go-git/v5" "github.com/go-git/go-git/v5/plumbing" ) @@ -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/), 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) +} diff --git a/pkg/git/checkout_test.go b/pkg/git/checkout_test.go new file mode 100644 index 00000000..f5a82ae1 --- /dev/null +++ b/pkg/git/checkout_test.go @@ -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")) +} diff --git a/pkg/repos/cache.go b/pkg/repos/cache.go index fdf67e7e..97ee7468 100644 --- a/pkg/repos/cache.go +++ b/pkg/repos/cache.go @@ -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 diff --git a/pkg/repos/giturl.go b/pkg/repos/giturl.go new file mode 100644 index 00000000..8af97fe6 --- /dev/null +++ b/pkg/repos/giturl.go @@ -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 @ 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 +} diff --git a/pkg/repos/giturl_install_test.go b/pkg/repos/giturl_install_test.go new file mode 100644 index 00000000..fc3f482e --- /dev/null +++ b/pkg/repos/giturl_install_test.go @@ -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 }) +} diff --git a/pkg/repos/giturl_name_test.go b/pkg/repos/giturl_name_test.go new file mode 100644 index 00000000..f1d6e69c --- /dev/null +++ b/pkg/repos/giturl_name_test.go @@ -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) +} diff --git a/pkg/repos/giturl_test.go b/pkg/repos/giturl_test.go new file mode 100644 index 00000000..3e66c81f --- /dev/null +++ b/pkg/repos/giturl_test.go @@ -0,0 +1,60 @@ +package repos + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestIsGitURL(t *testing.T) { + tests := []struct { + label string + input string + expected bool + }{ + {"https", "https://github.com/me/tpl.git", true}, + {"http", "http://github.com/me/tpl.git", true}, + {"ssh scheme", "ssh://git@github.com/me/tpl.git", true}, + {"git scheme", "git://github.com/me/tpl.git", true}, + {"file scheme", "file:///tmp/tpl.git", true}, + {"scp style", "git@github.com:me/tpl.git", true}, + {"https with version", "https://github.com/me/tpl.git@v1.2.0", true}, + {"registry id", "apigear-io/template-go", false}, + {"registry id with version", "apigear-io/template-go@1.2.3", false}, + {"relative path", "./tpl", false}, + {"parent path", "../templates/foo", false}, + {"bare name", "foo", false}, + {"empty", "", false}, + } + for _, tt := range tests { + t.Run(tt.label, func(t *testing.T) { + assert.Equal(t, tt.expected, IsGitURL(tt.input)) + }) + } +} + +func TestSplitGitURLVersion(t *testing.T) { + tests := []struct { + label string + input string + expectedURL string + expectedVersion string + }{ + {"https with tag", "https://github.com/me/tpl.git@v1.2.0", "https://github.com/me/tpl.git", "v1.2.0"}, + {"https no version", "https://github.com/me/tpl.git", "https://github.com/me/tpl.git", ""}, + {"scp with branch", "git@github.com:me/tpl.git@main", "git@github.com:me/tpl.git", "main"}, + {"scp no version", "git@github.com:me/tpl.git", "git@github.com:me/tpl.git", ""}, + {"https with credentials and version", "https://user@host.com/me/tpl.git@v1", "https://user@host.com/me/tpl.git", "v1"}, + {"https with credentials no version", "https://user@host.com/me/tpl.git", "https://user@host.com/me/tpl.git", ""}, + {"branch with slash", "https://github.com/me/tpl.git@release/1.0", "https://github.com/me/tpl.git", "release/1.0"}, + {"ssh scheme with commit", "ssh://git@github.com/me/tpl.git@abc1234", "ssh://git@github.com/me/tpl.git", "abc1234"}, + {"git scheme with version", "git://github.com/me/tpl.git@v1", "git://github.com/me/tpl.git", "v1"}, + } + for _, tt := range tests { + t.Run(tt.label, func(t *testing.T) { + url, version := SplitGitURLVersion(tt.input) + assert.Equal(t, tt.expectedURL, url) + assert.Equal(t, tt.expectedVersion, version) + }) + } +} diff --git a/pkg/repos/install.go b/pkg/repos/install.go index baf93ae8..919ccf5f 100644 --- a/pkg/repos/install.go +++ b/pkg/repos/install.go @@ -1,5 +1,28 @@ package repos +import "fmt" + +// GetOrInstallTemplateFromGitURL resolves a template from a direct git url and +// version (a tag, branch or commit). The repo is cloned into the cache and +// checked out at the given version. If a matching clone already exists in the +// cache it is reused without touching the network. +func GetOrInstallTemplateFromGitURL(url string, version string) (string, error) { + if version == "" { + return "", fmt.Errorf("template git url %q requires a version (url@version)", url) + } + name, err := RepoNameFromGitURL(url) + if err != nil { + return "", err + } + repoID := MakeRepoID(name, version) + if Cache.Exists(repoID) { + log.Info().Msgf("template %s already installed", repoID) + return repoID, nil + } + log.Info().Msgf("installing template %s from %s", repoID, url) + return Cache.InstallRef(url, version) +} + // InstallTemplateFromFQN tries to install a template // from a fully qualified name (e.g. name@version) func GetOrInstallTemplateFromRepoID(repoID string) (string, error) { diff --git a/pkg/spec/schema/apigear.solution.schema.json b/pkg/spec/schema/apigear.solution.schema.json index 3b10717b..f8131a59 100644 --- a/pkg/spec/schema/apigear.solution.schema.json +++ b/pkg/spec/schema/apigear.solution.schema.json @@ -55,7 +55,7 @@ "type": "string" }, "template": { - "description": "Path to the template which can be either template package name (e.g. apigear-io/template-cpp) or a template folder with a rules document (../\u003ctemplate_folder\u003e).", + "description": "The template to use. It can be a local template folder with a rules document (../\u003ctemplate_folder\u003e), a registry template package name (e.g. apigear-io/template-cpp), or a direct git url with a ref (tag, branch or commit) appended as @\u003cref\u003e (e.g. https://github.com/me/template.git@v1.2.0 or git@github.com:me/template.git@main).", "type": "string" } }, diff --git a/pkg/spec/schema/apigear.solution.schema.yaml b/pkg/spec/schema/apigear.solution.schema.yaml index 2401aa67..67275905 100644 --- a/pkg/spec/schema/apigear.solution.schema.yaml +++ b/pkg/spec/schema/apigear.solution.schema.yaml @@ -74,7 +74,7 @@ definitions: description: "Meta data about the target which will be passed on to the template." template: type: string - description: "Path to the template which can be either template package name (e.g. apigear-io/template-cpp) or a template folder with a rules document (../)." + description: "The template to use. It can be a local template folder with a rules document (../), a registry template package name (e.g. apigear-io/template-cpp), or a direct git url with a ref (tag, branch or commit) appended as @ (e.g. https://github.com/me/template.git@v1.2.0 or git@github.com:me/template.git@main)." features: type: array items: diff --git a/pkg/spec/soltarget.go b/pkg/spec/soltarget.go index 3f1018ed..7d97509f 100644 --- a/pkg/spec/soltarget.go +++ b/pkg/spec/soltarget.go @@ -91,15 +91,28 @@ func (l *SolutionTarget) compute(doc *SolutionDoc) error { if l.computed { return nil } - // compute template dir + // compute template dir. A template can be resolved three ways, in order: + // 1. a local template folder (relative to the solution root dir) + // 2. a direct git url with a ref (e.g. https://host/me/tpl.git@v1.0.0) + // 3. a registry template package name (e.g. apigear-io/template-cpp) + // Git urls are detected before the registry so they never reach the + // registry repo-id parser, which is not git-url aware. tplDir := helper.Join(doc.RootDir, l.Template) if helper.IsDir(tplDir) { l.TemplateDir = tplDir l.TemplatesDir = helper.Join(tplDir, "templates") l.RulesFile = helper.Join(tplDir, "rules.yaml") } else { - // try to find the template dir in the templates dir - repoId, err := repos.GetOrInstallTemplateFromRepoID(l.Template) + var repoId string + var err error + if repos.IsGitURL(l.Template) { + // clone directly from the given git url@version + url, version := repos.SplitGitURLVersion(l.Template) + repoId, err = repos.GetOrInstallTemplateFromGitURL(url, version) + } else { + // resolve the template from the registry + repoId, err = repos.GetOrInstallTemplateFromRepoID(l.Template) + } if err != nil { log.Err(err).Msgf("failed to get template %s", l.Template) return err diff --git a/pkg/spec/soltarget_giturl_test.go b/pkg/spec/soltarget_giturl_test.go new file mode 100644 index 00000000..c740815f --- /dev/null +++ b/pkg/spec/soltarget_giturl_test.go @@ -0,0 +1,131 @@ +package spec + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/apigear-io/cli/pkg/repos" + 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" +) + +// makeTemplateRepo creates a local git repo shaped like a template package: +// a templates/ directory and a rules.yaml file. The first commit (tagged +// "v1.0.0") writes marker.txt="v1"; a second commit on the default branch +// overwrites it with "v2", so checking out the tag must yield "v1". +func makeTemplateRepo(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) + require.NoError(t, os.MkdirAll(filepath.Join(dir, "templates"), 0755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "templates", "keep.txt"), []byte("x"), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "rules.yaml"), []byte("features: []\n"), 0644)) + + commit := func(content string) { + require.NoError(t, os.WriteFile(filepath.Join(dir, "marker.txt"), []byte(content), 0644)) + _, err := w.Add(".") + 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 +} + +// withHermeticRepos points the repos package globals at throwaway directories +// so resolution never touches the real cache, registry, or the network. The +// registry is seeded empty so the registry lookup fails cleanly (offline), +// letting the direct git-url path take over. It returns the temp cache dir. +func withHermeticRepos(t *testing.T) string { + t.Helper() + prevCache := repos.Cache + prevReg := repos.Registry + cacheDir := t.TempDir() + regDir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(regDir, "registry.json"), []byte(`{"entries":[]}`), 0644)) + repos.Cache = repos.New(cacheDir) + repos.Registry = repos.NewRegistry(regDir, "") + t.Cleanup(func() { + repos.Cache = prevCache + repos.Registry = prevReg + }) + return cacheDir +} + +func TestSolutionTargetValidate_GitURLTemplate(t *testing.T) { + src := makeTemplateRepo(t) + withHermeticRepos(t) + + doc := &SolutionDoc{ + Version: "1.0.0", + Name: "solution", + RootDir: t.TempDir(), + } + target := &SolutionTarget{ + Name: "go-sdk", + Template: "file://" + src + "@v1.0.0", + Output: "./out", + } + + require.NoError(t, target.Validate(doc)) + + // the template field is rewritten to the resolved cache repo id + assert.Contains(t, target.Template, "@v1.0.0") + // resolved paths point into the cache and exist + assert.True(t, isDir(target.TemplateDir)) + assert.True(t, isDir(target.TemplatesDir)) + assert.True(t, isFile(target.RulesFile)) + // the tagged ref was checked out (v1, not the later v2) + marker, err := os.ReadFile(filepath.Join(target.TemplateDir, "marker.txt")) + require.NoError(t, err) + assert.Equal(t, "v1", string(marker)) +} + +// An scp-style git url (git@host:repo.git@ref) has two '@'. It must be routed +// straight to the git-url resolver and must never reach the registry repo-id +// parser, which os.Exit()s on such input. We pre-seed the cache so the git-url +// resolver short-circuits without any network access. +func TestSolutionTargetValidate_ScpGitURLDoesNotCrash(t *testing.T) { + cacheDir := withHermeticRepos(t) + + // pre-seed the cache entry the git-url resolver will look for + repoID := "github.com/me/tpl@main" + tplDir := filepath.Join(cacheDir, "github.com/me/tpl@main") + require.NoError(t, os.MkdirAll(filepath.Join(tplDir, "templates"), 0755)) + require.NoError(t, os.WriteFile(filepath.Join(tplDir, "rules.yaml"), []byte("features: []\n"), 0644)) + + doc := &SolutionDoc{Version: "1.0.0", Name: "solution", RootDir: t.TempDir()} + target := &SolutionTarget{ + Name: "go-sdk", + Template: "git@github.com:me/tpl.git@main", + Output: "./out", + } + + require.NoError(t, target.Validate(doc)) + assert.Equal(t, repoID, target.Template) +} + +func isDir(p string) bool { + info, err := os.Stat(p) + return err == nil && info.IsDir() +} + +func isFile(p string) bool { + info, err := os.Stat(p) + return err == nil && !info.IsDir() +}