feat(app): support git-URL recipes in 'abra app new'
Allow `abra app new <git-url>` 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=<canonical name>` 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 <noreply@anthropic.com>
This commit is contained in:
@ -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=<canonical name>
|
||||
// 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 {
|
||||
|
||||
30
pkg/app/inject_recipe_test.go
Normal file
30
pkg/app/inject_recipe_test.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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...)
|
||||
}
|
||||
|
||||
@ -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")
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
Reference in New Issue
Block a user