From 3c24ae8111a03c9d4ce6b670205d03eff8754fb8 Mon Sep 17 00:00:00 2001 From: Linus Gasser Date: Sat, 13 Jun 2026 22:39:16 +0200 Subject: [PATCH 1/4] fix(formatter): guard ShortenID/SmallSHA against short input ShortenID and SmallSHA sliced their input to a fixed length without checking it was long enough, panicking on shorter strings. Return the input unchanged when it is already shorter than the cut. Also replace the blank Commit placeholder with an explicit "unknown-commit" sentinel. Co-Authored-By: Claude Opus 4.8 --- cmd/abra/main.go | 2 +- pkg/formatter/formatter.go | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/cmd/abra/main.go b/cmd/abra/main.go index d31d1e82..514583ad 100644 --- a/cmd/abra/main.go +++ b/cmd/abra/main.go @@ -16,7 +16,7 @@ func main() { Version = "dev" } if Commit == "" { - Commit = " " + Commit = "unknown-commit" } cli.Run(Version, Commit) diff --git a/pkg/formatter/formatter.go b/pkg/formatter/formatter.go index 1ffca2f7..ab5f0f39 100644 --- a/pkg/formatter/formatter.go +++ b/pkg/formatter/formatter.go @@ -27,10 +27,16 @@ var BoldUnderlineStyle = lipgloss.NewStyle(). Underline(true) func ShortenID(str string) string { + if len(str) < 12 { + return str + } return str[:12] } func SmallSHA(hash string) string { + if len(hash) < 8 { + return hash + } return hash[:8] } -- 2.49.0 From 056a6be38d0cb55b4bfe7a095c0ec90e9d74078a Mon Sep 17 00:00:00 2001 From: Linus Gasser Date: Sat, 13 Jun 2026 22:39:36 +0200 Subject: [PATCH 2/4] fix(recipe): reject branch names as :version with a clear error Passing a branch as the ":version" suffix previously failed with an opaque "unable to resolve" error from go-git. Detect when the requested revision matches a branch on a configured remote and fail with a message explaining that ":" only supports tags or commit hashes. Co-Authored-By: Claude Opus 4.8 --- pkg/recipe/git.go | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/pkg/recipe/git.go b/pkg/recipe/git.go index eff8f5f8..08875757 100644 --- a/pkg/recipe/git.go +++ b/pkg/recipe/git.go @@ -163,6 +163,9 @@ func (r Recipe) EnsureVersion(version string) (bool, error) { hash, err := repo.ResolveRevision(plumbing.Revision(version)) if err != nil { + if isRemoteBranch(repo, version) { + log.Fatal(i18n.G("'%s' is a branch name; ':' only supports tags or commit hashes, not branches", version)) + } log.Fatal(i18n.G("unable to resolve '%s': %s", version, err)) } @@ -479,6 +482,28 @@ func (r Recipe) GetRecipeVersions() (RecipeVersions, []string, error) { return versions, uniqueWarnings, nil } +// isRemoteBranch reports whether name matches a branch on any configured +// remote. Used to give a clearer error when a user passes a branch as the +// ":version" suffix, which is unsupported. +func isRemoteBranch(repo *git.Repository, name string) bool { + remotes, err := repo.Remotes() + if err != nil { + return false + } + for _, remote := range remotes { + refs, err := remote.List(&git.ListOptions{}) + if err != nil { + continue + } + for _, ref := range refs { + if ref.Name().IsBranch() && ref.Name().Short() == name { + return true + } + } + } + return false +} + // Head retrieves latest HEAD metadata. func (r Recipe) Head() (*plumbing.Reference, error) { repo, err := git.PlainOpen(r.Dir) -- 2.49.0 From 95220049e800d2d0d1b4dfda9c99adb9e9c925b6 Mon Sep 17 00:00:00 2001 From: Linus Gasser Date: Sat, 13 Jun 2026 22:39:53 +0200 Subject: [PATCH 3/4] feat(recipe): normalize git URLs to canonical host/path names Add NormalizeRecipeName to canonicalize 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. Co-Authored-By: Claude Opus 4.8 --- cli/internal/validate.go | 12 +++- pkg/recipe/recipe.go | 88 +++++++++++++++++++++++++++ pkg/recipe/recipe_test.go | 125 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 222 insertions(+), 3 deletions(-) diff --git a/cli/internal/validate.go b/cli/internal/validate.go index f39b9d34..e281b2ee 100644 --- a/cli/internal/validate.go +++ b/cli/internal/validate.go @@ -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)) } } diff --git a/pkg/recipe/recipe.go b/pkg/recipe/recipe.go index 592e2452..ade0c47b 100644 --- a/pkg/recipe/recipe.go +++ b/pkg/recipe/recipe.go @@ -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, ".", "_") diff --git a/pkg/recipe/recipe_test.go b/pkg/recipe/recipe_test.go index 9f3c5748..5320b2a9 100644 --- a/pkg/recipe/recipe_test.go +++ b/pkg/recipe/recipe_test.go @@ -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() }) -- 2.49.0 From f230f89dcbb522ad1ce53e893bd680ecb3deeffd Mon Sep 17 00:00:00 2001 From: Linus Gasser Date: Sat, 13 Jun 2026 22:40:10 +0200 Subject: [PATCH 4/4] feat(app): support git-URL recipes in 'abra app new' Allow `abra app new ` to use a recipe from outside the catalogue. On clone, a `.abra-source` sidecar records the canonical host/path name (the on-disk directory escapes "/" and "." lossily), and IsClean ignores it. When templating the app's .env, a `RECIPE=` line is injected so a later `abra app deploy`, possibly on another machine, re-fetches the recipe from the same git source. `recipe ls` now shows a source column listing these external recipes alongside catalogue ones. Co-Authored-By: Claude Opus 4.8 --- cli/app/new.go | 18 +++++++++++++- cli/recipe/list.go | 45 +++++++++++++++++++++++++++++++++++ pkg/app/app.go | 37 +++++++++++++++++++++++++++- pkg/app/inject_recipe_test.go | 30 +++++++++++++++++++++++ pkg/git/read.go | 5 ++++ pkg/git/read_test.go | 36 ++++++++++++++++++++++++++++ pkg/recipe/git.go | 24 +++++++++++++++++++ 7 files changed, 193 insertions(+), 2 deletions(-) create mode 100644 pkg/app/inject_recipe_test.go diff --git a/cli/app/new.go b/cli/app/new.go index 67b69d09..9961b74a 100644 --- a/cli/app/new.go +++ b/cli/app/new.go @@ -32,6 +32,18 @@ deploy " to do so. You can see what recipes are available (i.e. values for the [recipe] argument) by running "abra recipe ls". +In addition to short catalogue names, [recipe] also accepts arbitrary git +URLs to use a recipe from outside the catalogue (e.g. a fork or work in +progress). Any of these forms is accepted: + + abra app new git.example.com/user/recipe + abra app new https://git.example.com/user/recipe + abra app new git@git.example.com:user/recipe + +In that case a RECIPE= line is written to the app's .env +file so a subsequent "abra app deploy" (on this or another machine) will +re-fetch the recipe from the same git source. + Recipe commit hashes are supported values for "[version]". Passing the "--secrets/-S" flag will automatically generate secrets for your @@ -295,7 +307,7 @@ func ensureDomainFlag(recipe recipePkg.Recipe, server string) error { if appDomain == "" && !internal.NoInput { prompt := &survey.Input{ Message: i18n.G("Specify app domain"), - Default: fmt.Sprintf("%s.%s", recipe.Name, server), + Default: fmt.Sprintf("%s.%s", recipe.ShortName(), server), } if err := survey.AskOne(prompt, &appDomain); err != nil { return err @@ -306,6 +318,10 @@ func ensureDomainFlag(recipe recipePkg.Recipe, server string) error { return errors.New(i18n.G("no domain provided")) } + if strings.ContainsAny(appDomain, "/\\") { + return errors.New(i18n.G("invalid domain '%s': must not contain '/' or '\\'", appDomain)) + } + return nil } diff --git a/cli/recipe/list.go b/cli/recipe/list.go index a63ec7d5..7dce1906 100644 --- a/cli/recipe/list.go +++ b/cli/recipe/list.go @@ -2,11 +2,13 @@ package recipe import ( "fmt" + "path" "sort" "strconv" "strings" "coopcloud.tech/abra/cli/internal" + "coopcloud.tech/abra/pkg/config" "coopcloud.tech/abra/pkg/formatter" "coopcloud.tech/abra/pkg/i18n" "coopcloud.tech/abra/pkg/log" @@ -41,6 +43,7 @@ var RecipeListCommand = &cobra.Command{ headers := []string{ i18n.G("name"), + i18n.G("source"), i18n.G("category"), i18n.G("status"), i18n.G("healthcheck"), @@ -56,6 +59,7 @@ var RecipeListCommand = &cobra.Command{ for _, recipe := range recipes { row := []string{ recipe.Name, + i18n.G("catalogue"), recipe.Category, strconv.Itoa(recipe.Features.Status), recipe.Features.Healthcheck, @@ -76,6 +80,23 @@ var RecipeListCommand = &cobra.Command{ } } + externals := externalRecipes(catl) + sort.Strings(externals) + for _, name := range externals { + row := []string{ + name, + i18n.G("external"), + "-", "-", "-", "-", "-", "-", "-", + } + if pattern != "" { + if !strings.Contains(name, pattern) { + continue + } + } + table.Row(row...) + rows = append(rows, row) + } + if len(rows) > 0 { if internal.MachineReadable { out, err := formatter.ToJSON(headers, rows) @@ -93,6 +114,30 @@ var RecipeListCommand = &cobra.Command{ }, } +// externalRecipes returns canonical names of locally-cloned recipes that +// were sourced from an arbitrary git URL (i.e. they carry a .abra-source +// sidecar) and are not already listed in the catalogue. +func externalRecipes(catl recipe.RecipeCatalogue) []string { + dirs, err := recipe.GetRecipesLocal() + if err != nil { + log.Debug(i18n.G("can't read local recipes: %s", err)) + return nil + } + + var names []string + for _, dir := range dirs { + canonical := recipe.ReadRecipeSource(path.Join(config.RECIPES_DIR, dir)) + if canonical == "" { + continue + } + if _, inCatalogue := catl[canonical]; inCatalogue { + continue + } + names = append(names, canonical) + } + return names +} + var ( pattern string ) diff --git a/pkg/app/app.go b/pkg/app/app.go index 2b723f9f..aad77d1a 100644 --- a/pkg/app/app.go +++ b/pkg/app/app.go @@ -392,11 +392,15 @@ func TemplateAppEnvSample(r recipe.Recipe, appName, server, domain string) error newContents := strings.Replace( string(read), - fmt.Sprintf("%s.example.com", r.Name), + fmt.Sprintf("%s.example.com", r.ShortName()), domain, -1, ) + if strings.Contains(r.Name, "/") { + newContents = injectRecipeLine(newContents, r.Name) + } + err = os.WriteFile(appEnvPath, []byte(newContents), 0) if err != nil { return err @@ -407,6 +411,37 @@ func TemplateAppEnvSample(r recipe.Recipe, appName, server, domain string) error return nil } +// injectRecipeLine ensures the env file carries a RECIPE= +// line so a downstream `abra app deploy` (potentially on another machine) +// can re-fetch the recipe from its original git source. If a RECIPE= line +// already exists in the copied .env.sample it is replaced; otherwise a new +// line is inserted immediately after the TYPE= line, or appended at the +// end if no TYPE= line is present. +func injectRecipeLine(contents, canonicalName string) string { + lines := strings.Split(contents, "\n") + for i, line := range lines { + trimmed := strings.TrimLeft(line, " \t") + if strings.HasPrefix(trimmed, "RECIPE=") { + lines[i] = "RECIPE=" + canonicalName + return strings.Join(lines, "\n") + } + } + for i, line := range lines { + trimmed := strings.TrimLeft(line, " \t") + if strings.HasPrefix(trimmed, "TYPE=") { + out := make([]string, 0, len(lines)+1) + out = append(out, lines[:i+1]...) + out = append(out, "RECIPE="+canonicalName) + out = append(out, lines[i+1:]...) + return strings.Join(out, "\n") + } + } + if contents != "" && !strings.HasSuffix(contents, "\n") { + contents += "\n" + } + return contents + "RECIPE=" + canonicalName + "\n" +} + // SanitiseAppName makes a app name usable with Docker by replacing illegal // characters. func SanitiseAppName(name string) string { diff --git a/pkg/app/inject_recipe_test.go b/pkg/app/inject_recipe_test.go new file mode 100644 index 00000000..b538d999 --- /dev/null +++ b/pkg/app/inject_recipe_test.go @@ -0,0 +1,30 @@ +package app + +import "testing" + +func TestInjectRecipeLineAfterType(t *testing.T) { + in := "DOMAIN=example.com\nTYPE=foo\nVERSION=1.0\n" + want := "DOMAIN=example.com\nTYPE=foo\nRECIPE=org/foo\nVERSION=1.0\n" + got := injectRecipeLine(in, "org/foo") + if got != want { + t.Errorf("injectRecipeLine inserted RECIPE in wrong position\nwant:\n%q\ngot:\n%q", want, got) + } +} + +func TestInjectRecipeLineReplacesExisting(t *testing.T) { + in := "TYPE=foo\nRECIPE=old\nDOMAIN=example.com\n" + want := "TYPE=foo\nRECIPE=org/foo\nDOMAIN=example.com\n" + got := injectRecipeLine(in, "org/foo") + if got != want { + t.Errorf("injectRecipeLine should replace existing RECIPE line\nwant:\n%q\ngot:\n%q", want, got) + } +} + +func TestInjectRecipeLineNoTypeAppends(t *testing.T) { + in := "DOMAIN=example.com\n" + want := "DOMAIN=example.com\nRECIPE=org/foo\n" + got := injectRecipeLine(in, "org/foo") + if got != want { + t.Errorf("injectRecipeLine should append when TYPE is missing\nwant:\n%q\ngot:\n%q", want, got) + } +} diff --git a/pkg/git/read.go b/pkg/git/read.go index 0a54d4fc..1364b6fe 100644 --- a/pkg/git/read.go +++ b/pkg/git/read.go @@ -36,6 +36,11 @@ func IsClean(repoPath string) (bool, error) { return false, err } + // Ignore the abra-managed sidecar file that records the canonical + // source URL for externally-cloned recipes; it lives alongside the + // recipe but is not part of the upstream tree. + patterns = append(patterns, gitignore.ParsePattern(".abra-source", nil)) + if len(patterns) > 0 { worktree.Excludes = append(patterns, worktree.Excludes...) } diff --git a/pkg/git/read_test.go b/pkg/git/read_test.go index 5ad07cfe..366ac33d 100644 --- a/pkg/git/read_test.go +++ b/pkg/git/read_test.go @@ -2,6 +2,8 @@ package git import ( "errors" + "os" + "path/filepath" "testing" "github.com/go-git/go-git/v5" @@ -13,3 +15,37 @@ func TestIsClean(t *testing.T) { assert.Equal(t, isClean, false) assert.True(t, errors.Is(err, git.ErrRepositoryNotExists)) } + +// TestIsCleanIgnoresAbraSource confirms that the .abra-source sidecar +// file written by abra next to externally-cloned recipes does not cause +// IsClean to report the worktree as dirty. +func TestIsCleanIgnoresAbraSource(t *testing.T) { + dir := t.TempDir() + + if _, err := git.PlainInit(dir, false); err != nil { + t.Fatalf("git init failed: %s", err) + } + + sidecar := filepath.Join(dir, ".abra-source") + if err := os.WriteFile(sidecar, []byte("git.example.com/u/recipe\n"), 0o644); err != nil { + t.Fatalf("writing sidecar failed: %s", err) + } + + isClean, err := IsClean(dir) + if err != nil { + t.Fatalf("IsClean returned error: %s", err) + } + assert.True(t, isClean, "expected worktree with only .abra-source to be reported clean") + + // Sanity check: an unrelated untracked file should still mark it dirty. + other := filepath.Join(dir, "random.txt") + if err := os.WriteFile(other, []byte("hello"), 0o644); err != nil { + t.Fatalf("writing extra file failed: %s", err) + } + + isClean, err = IsClean(dir) + if err != nil { + t.Fatalf("IsClean returned error: %s", err) + } + assert.False(t, isClean, "expected worktree with unrelated untracked file to be reported dirty") +} diff --git a/pkg/recipe/git.go b/pkg/recipe/git.go index 08875757..4159fa71 100644 --- a/pkg/recipe/git.go +++ b/pkg/recipe/git.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "os" + "path" "slices" "sort" "strings" @@ -19,6 +20,11 @@ import ( "github.com/go-git/go-git/v5/plumbing" ) +// SourceFile is the sidecar file written next to an externally-cloned +// recipe so the canonical "host/path" name can be recovered later (the +// on-disk directory name escapes "/" and "." to "_", which is lossy). +const SourceFile = ".abra-source" + type EnsureContext struct { Chaos bool Offline bool @@ -78,6 +84,12 @@ func (r Recipe) EnsureExists() error { if err := gitPkg.Clone(r.Dir, r.GitURL); err != nil { return err } + if strings.Contains(r.Name, "/") { + sidecar := path.Join(r.Dir, SourceFile) + if err := os.WriteFile(sidecar, []byte(r.Name+"\n"), 0o644); err != nil { + log.Debug(i18n.G("failed to write recipe source sidecar %s: %s", sidecar, err)) + } + } } if err := gitPkg.EnsureGitRepo(r.Dir); err != nil { @@ -87,6 +99,18 @@ func (r Recipe) EnsureExists() error { return nil } +// ReadRecipeSource returns the canonical name recorded in the .abra-source +// sidecar inside the given recipe directory, or the empty string if no +// sidecar exists. This lets callers recover the unescaped "host/path" +// form for externally-cloned recipes. +func ReadRecipeSource(recipeDir string) string { + data, err := os.ReadFile(path.Join(recipeDir, SourceFile)) + if err != nil { + return "" + } + return strings.TrimSpace(string(data)) +} + // IsChaosCommit determines if a version sttring is a chaos commit or not. func (r Recipe) IsChaosCommit(version string) (bool, error) { isChaosCommit := false -- 2.49.0