feat(recipe): normalize git URLs to canonical host/path names

Add NormalizeRecipeName to unify the various ways a recipe can be
referenced - https/http/ssh URLs, SCP-style git@host:path, already-
canonical host/path, and short catalogue names - to a single stable
"host/path" form, preserving any ":version" suffix. Wire it into
recipe.Get and ValidateRecipe so every entry point accepts git URLs, and
add Recipe.ShortName to recover the bare recipe name from a prefixed one.
This commit is contained in:
Linus Gasser
2026-10-22 18:56:00 +02:00
parent 540842b76a
commit 6d9896fc46
3 changed files with 222 additions and 3 deletions
+9 -3
View File
@@ -59,9 +59,15 @@ func ValidateRecipe(args []string, cmdName string) recipe.Recipe {
log.Fatal(i18n.G("no recipe name provided"))
}
if _, ok := knownRecipes[recipeName]; !ok {
if !strings.Contains(recipeName, "/") {
log.Fatal(i18n.G("no recipe '%s' exists?", recipeName))
recipeName = recipe.NormalizeRecipeName(recipeName)
lookupName := recipeName
if i := strings.LastIndex(lookupName, ":"); i >= 0 {
lookupName = lookupName[:i]
}
if _, ok := knownRecipes[lookupName]; !ok {
if !strings.Contains(lookupName, "/") {
log.Fatal(i18n.G("no recipe '%s' exists? pass a git URL (e.g. https://git.example.com/user/recipe) to use a recipe outside the catalogue", lookupName))
}
}
+88
View File
@@ -8,6 +8,7 @@ import (
"net/url"
"os"
"path"
"regexp"
"sort"
"strconv"
"strings"
@@ -121,7 +122,84 @@ type Features struct {
SSO string `json:"sso"`
}
// scpURLPattern matches SCP-style git URLs like git@host:path or git@host:port/path.
// Captures: 1=host(:port)?, 2=path.
var scpURLPattern = regexp.MustCompile(`^[\w.-]+@([\w.-]+(?::\d+)?):(.+)$`)
// NormalizeRecipeName canonicalizes a recipe identifier to a stable
// "host/path" form (or returns short catalog names unchanged). Accepts:
//
// - https://host/path[.git][:version]
// - http://host/path[.git][:version]
// - ssh://git@host[:port]/path[.git][:version]
// - git@host:path[.git][:version] (SCP-style)
// - host/path[.git][:version] (already canonical)
// - short-name[:version] (catalog recipe, pass through)
//
// The optional trailing :version suffix is preserved verbatim. The .git
// suffix and trailing slashes on the path are stripped so the four URL
// forms of the same repository collapse to one canonical value.
func NormalizeRecipeName(input string) string {
input = strings.TrimSpace(input)
// Split off the version suffix first, but only if it's not part of a
// scheme (https://) or SCP-style git@host: prefix. The simplest way is
// to detect those prefixes and treat the rest as the path-with-version.
var (
body = input
version string
)
switch {
case strings.HasPrefix(input, "https://") || strings.HasPrefix(input, "http://") || strings.HasPrefix(input, "ssh://"):
u, err := url.Parse(input)
if err != nil {
return input
}
host := u.Hostname() // strip port to keep canonical form colon-free
p := strings.TrimPrefix(u.Path, "/")
p, version = splitVersion(p)
body = host + "/" + p
case scpURLPattern.MatchString(input):
m := scpURLPattern.FindStringSubmatch(input)
host := m[1]
if i := strings.Index(host, ":"); i >= 0 {
host = host[:i] // strip port
}
p, v := splitVersion(m[2])
body = host + "/" + p
version = v
default:
body, version = splitVersion(input)
}
body = strings.TrimSuffix(body, "/")
body = strings.TrimSuffix(body, ".git")
if version != "" {
return body + ":" + version
}
return body
}
// splitVersion separates a trailing ":version" suffix from a recipe path.
// It only treats the final colon-separated segment as a version when it is
// non-empty and contains no slash (a slash means the colon belonged to the
// path, e.g. a host:port or scheme separator).
func splitVersion(s string) (string, string) {
idx := strings.LastIndex(s, ":")
if idx < 0 {
return s, ""
}
candidate := s[idx+1:]
if candidate == "" || strings.Contains(candidate, "/") {
return s, ""
}
return s[:idx], candidate
}
func Get(name string) Recipe {
name = NormalizeRecipeName(name)
version := ""
versionRaw := ""
if strings.Contains(name, ":") {
@@ -209,6 +287,16 @@ func (r Recipe) String() string {
return out
}
// ShortName returns the final path segment of the recipe name, i.e. the
// bare recipe name without any "host/org/" prefix carried by externally
// sourced (git URL) recipes. For catalogue recipes it returns Name unchanged.
func (r Recipe) ShortName() string {
if i := strings.LastIndex(r.Name, "/"); i >= 0 {
return r.Name[i+1:]
}
return r.Name
}
func escapeRecipeName(recipeName string) string {
recipeName = strings.ReplaceAll(recipeName, "/", "_")
recipeName = strings.ReplaceAll(recipeName, ".", "_")
+125
View File
@@ -88,6 +88,88 @@ func TestGet(t *testing.T) {
AbraShPath: path.Join(cfg.GetAbraDir(), "recipes/mygit_org_myorg_cool-recipe/abra.sh"),
},
},
{
name: "https://mygit.org/myorg/cool-recipe",
recipe: Recipe{
Name: "mygit.org/myorg/cool-recipe",
Dir: path.Join(cfg.GetAbraDir(), "/recipes/mygit_org_myorg_cool-recipe"),
GitURL: "https://mygit.org/myorg/cool-recipe.git",
SSHURL: "ssh://git@mygit.org/myorg/cool-recipe.git",
ComposePath: path.Join(cfg.GetAbraDir(), "recipes/mygit_org_myorg_cool-recipe/compose.yml"),
ReadmePath: path.Join(cfg.GetAbraDir(), "recipes/mygit_org_myorg_cool-recipe/README.md"),
SampleEnvPath: path.Join(cfg.GetAbraDir(), "recipes/mygit_org_myorg_cool-recipe/.env.sample"),
AbraShPath: path.Join(cfg.GetAbraDir(), "recipes/mygit_org_myorg_cool-recipe/abra.sh"),
},
},
{
name: "https://mygit.org/myorg/cool-recipe.git",
recipe: Recipe{
Name: "mygit.org/myorg/cool-recipe",
Dir: path.Join(cfg.GetAbraDir(), "/recipes/mygit_org_myorg_cool-recipe"),
GitURL: "https://mygit.org/myorg/cool-recipe.git",
SSHURL: "ssh://git@mygit.org/myorg/cool-recipe.git",
ComposePath: path.Join(cfg.GetAbraDir(), "recipes/mygit_org_myorg_cool-recipe/compose.yml"),
ReadmePath: path.Join(cfg.GetAbraDir(), "recipes/mygit_org_myorg_cool-recipe/README.md"),
SampleEnvPath: path.Join(cfg.GetAbraDir(), "recipes/mygit_org_myorg_cool-recipe/.env.sample"),
AbraShPath: path.Join(cfg.GetAbraDir(), "recipes/mygit_org_myorg_cool-recipe/abra.sh"),
},
},
{
name: "https://mygit.org/myorg/cool-recipe.git:1.2.4",
recipe: Recipe{
Name: "mygit.org/myorg/cool-recipe",
EnvVersion: "1.2.4",
EnvVersionRaw: "1.2.4",
Dir: path.Join(cfg.GetAbraDir(), "/recipes/mygit_org_myorg_cool-recipe"),
GitURL: "https://mygit.org/myorg/cool-recipe.git",
SSHURL: "ssh://git@mygit.org/myorg/cool-recipe.git",
ComposePath: path.Join(cfg.GetAbraDir(), "recipes/mygit_org_myorg_cool-recipe/compose.yml"),
ReadmePath: path.Join(cfg.GetAbraDir(), "recipes/mygit_org_myorg_cool-recipe/README.md"),
SampleEnvPath: path.Join(cfg.GetAbraDir(), "recipes/mygit_org_myorg_cool-recipe/.env.sample"),
AbraShPath: path.Join(cfg.GetAbraDir(), "recipes/mygit_org_myorg_cool-recipe/abra.sh"),
},
},
{
name: "ssh://git@mygit.org/myorg/cool-recipe.git",
recipe: Recipe{
Name: "mygit.org/myorg/cool-recipe",
Dir: path.Join(cfg.GetAbraDir(), "/recipes/mygit_org_myorg_cool-recipe"),
GitURL: "https://mygit.org/myorg/cool-recipe.git",
SSHURL: "ssh://git@mygit.org/myorg/cool-recipe.git",
ComposePath: path.Join(cfg.GetAbraDir(), "recipes/mygit_org_myorg_cool-recipe/compose.yml"),
ReadmePath: path.Join(cfg.GetAbraDir(), "recipes/mygit_org_myorg_cool-recipe/README.md"),
SampleEnvPath: path.Join(cfg.GetAbraDir(), "recipes/mygit_org_myorg_cool-recipe/.env.sample"),
AbraShPath: path.Join(cfg.GetAbraDir(), "recipes/mygit_org_myorg_cool-recipe/abra.sh"),
},
},
{
name: "git@mygit.org:myorg/cool-recipe.git",
recipe: Recipe{
Name: "mygit.org/myorg/cool-recipe",
Dir: path.Join(cfg.GetAbraDir(), "/recipes/mygit_org_myorg_cool-recipe"),
GitURL: "https://mygit.org/myorg/cool-recipe.git",
SSHURL: "ssh://git@mygit.org/myorg/cool-recipe.git",
ComposePath: path.Join(cfg.GetAbraDir(), "recipes/mygit_org_myorg_cool-recipe/compose.yml"),
ReadmePath: path.Join(cfg.GetAbraDir(), "recipes/mygit_org_myorg_cool-recipe/README.md"),
SampleEnvPath: path.Join(cfg.GetAbraDir(), "recipes/mygit_org_myorg_cool-recipe/.env.sample"),
AbraShPath: path.Join(cfg.GetAbraDir(), "recipes/mygit_org_myorg_cool-recipe/abra.sh"),
},
},
{
name: "git@mygit.org:myorg/cool-recipe:1.2.4",
recipe: Recipe{
Name: "mygit.org/myorg/cool-recipe",
EnvVersion: "1.2.4",
EnvVersionRaw: "1.2.4",
Dir: path.Join(cfg.GetAbraDir(), "/recipes/mygit_org_myorg_cool-recipe"),
GitURL: "https://mygit.org/myorg/cool-recipe.git",
SSHURL: "ssh://git@mygit.org/myorg/cool-recipe.git",
ComposePath: path.Join(cfg.GetAbraDir(), "recipes/mygit_org_myorg_cool-recipe/compose.yml"),
ReadmePath: path.Join(cfg.GetAbraDir(), "recipes/mygit_org_myorg_cool-recipe/README.md"),
SampleEnvPath: path.Join(cfg.GetAbraDir(), "recipes/mygit_org_myorg_cool-recipe/.env.sample"),
AbraShPath: path.Join(cfg.GetAbraDir(), "recipes/mygit_org_myorg_cool-recipe/abra.sh"),
},
},
}
for _, tc := range testcases {
@@ -101,6 +183,49 @@ func TestGet(t *testing.T) {
}
}
func TestNormalizeRecipeName(t *testing.T) {
cases := []struct {
in, want string
}{
// catalog short names pass through
{"foo", "foo"},
{"foo:1.2.3", "foo:1.2.3"},
// bare host/path form
{"mygit.org/myorg/cool-recipe", "mygit.org/myorg/cool-recipe"},
{"mygit.org/myorg/cool-recipe.git", "mygit.org/myorg/cool-recipe"},
{"mygit.org/myorg/cool-recipe.git:1.2.3", "mygit.org/myorg/cool-recipe:1.2.3"},
{"mygit.org/myorg/cool-recipe/", "mygit.org/myorg/cool-recipe"},
// https://
{"https://mygit.org/myorg/cool-recipe", "mygit.org/myorg/cool-recipe"},
{"https://mygit.org/myorg/cool-recipe.git", "mygit.org/myorg/cool-recipe"},
{"https://mygit.org/myorg/cool-recipe.git:1.2.3", "mygit.org/myorg/cool-recipe:1.2.3"},
{"http://mygit.org/myorg/cool-recipe", "mygit.org/myorg/cool-recipe"},
// ssh://, with and without port
{"ssh://git@mygit.org/myorg/cool-recipe.git", "mygit.org/myorg/cool-recipe"},
{"ssh://git@mygit.org:2222/myorg/cool-recipe.git", "mygit.org/myorg/cool-recipe"},
// SCP-style git@host:path
{"git@mygit.org:myorg/cool-recipe", "mygit.org/myorg/cool-recipe"},
{"git@mygit.org:myorg/cool-recipe.git", "mygit.org/myorg/cool-recipe"},
{"git@mygit.org:myorg/cool-recipe:1.2.3", "mygit.org/myorg/cool-recipe:1.2.3"},
// whitespace
{" https://mygit.org/myorg/cool-recipe ", "mygit.org/myorg/cool-recipe"},
}
for _, tc := range cases {
t.Run(tc.in, func(t *testing.T) {
got := NormalizeRecipeName(tc.in)
if got != tc.want {
t.Errorf("NormalizeRecipeName(%q) = %q, want %q", tc.in, got, tc.want)
}
})
}
}
func TestGetVersionLabelLocalDoesNotUseTimeoutLabel(t *testing.T) {
test.Setup()
t.Cleanup(func() { test.Teardown() })