feat(recipe): allow branch names in RECIPE=<name>:<version>
Fetch and resolve a branch name on demand (into refs/remotes/origin/*, never refs/heads/*, to avoid corrupting default-branch detection), checking it out detached at its tip as a chaos commit. Resolution order stays tag -> hash -> branch so existing pins are unaffected. Adds EnsureVersionCtx so offline mode fails clearly instead of hanging or erroring opaquely when a branch hasn't been fetched yet. This supersedes the previous behavior of rejecting branch names with a clear error. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y6PrSttbx61PCQTie7HzLg
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
613205d714
commit
17c608e0ca
+72
-28
@@ -17,6 +17,7 @@ import (
|
||||
"coopcloud.tech/tagcmp"
|
||||
"github.com/distribution/reference"
|
||||
"github.com/go-git/go-git/v5"
|
||||
gitCfg "github.com/go-git/go-git/v5/config"
|
||||
"github.com/go-git/go-git/v5/plumbing"
|
||||
)
|
||||
|
||||
@@ -64,7 +65,7 @@ func (r Recipe) Ensure(ctx EnsureContext) error {
|
||||
return errors.New(i18n.G(`cannot redeploy previous chaos version (%s), did you mean to use "--chaos"?`))
|
||||
}
|
||||
|
||||
if _, err := r.EnsureVersion(r.EnvVersion); err != nil {
|
||||
if _, err := r.EnsureVersionCtx(ctx, r.EnvVersion); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -148,6 +149,13 @@ func (r Recipe) IsChaosCommit(version string) (bool, error) {
|
||||
|
||||
// EnsureVersion checks whether a specific version exists for a recipe.
|
||||
func (r Recipe) EnsureVersion(version string) (bool, error) {
|
||||
return r.EnsureVersionCtx(EnsureContext{}, version)
|
||||
}
|
||||
|
||||
// EnsureVersionCtx is like EnsureVersion but is aware of the surrounding
|
||||
// EnsureContext, so that e.g. offline mode can skip network fetches when
|
||||
// resolving a branch name that isn't already available locally.
|
||||
func (r Recipe) EnsureVersionCtx(ctx EnsureContext, version string) (bool, error) {
|
||||
isChaosCommit := false
|
||||
|
||||
if err := gitPkg.EnsureGitRepo(r.Dir); err != nil {
|
||||
@@ -182,33 +190,46 @@ func (r Recipe) EnsureVersion(version string) (bool, error) {
|
||||
}
|
||||
|
||||
var opts *git.CheckoutOptions
|
||||
var checkedOutRef string
|
||||
if tagRef.String() == "" {
|
||||
log.Debug(i18n.G("attempting to checkout '%s' as chaos commit", version))
|
||||
|
||||
hash, err := repo.ResolveRevision(plumbing.Revision(version))
|
||||
if err != nil {
|
||||
if isRemoteBranch(repo, version) {
|
||||
log.Fatal(i18n.G("'%s' is a branch name; ':<version>' only supports tags or commit hashes, not branches", version))
|
||||
hash, hashErr := repo.ResolveRevision(plumbing.Revision(version))
|
||||
if hashErr != nil {
|
||||
branchHash, found, branchErr := resolveBranch(repo, version, ctx.Offline)
|
||||
if errors.Is(branchErr, errOfflineBranchUnresolved) {
|
||||
return isChaosCommit, errors.New(i18n.G("'%s' is not available locally and cannot be fetched in offline mode; retry without --offline", version))
|
||||
}
|
||||
if branchErr != nil {
|
||||
return isChaosCommit, branchErr
|
||||
}
|
||||
if !found {
|
||||
return isChaosCommit, errors.New(i18n.G("unable to resolve '%s': %s", version, hashErr))
|
||||
}
|
||||
log.Fatal(i18n.G("unable to resolve '%s': %s", version, err))
|
||||
}
|
||||
|
||||
opts = &git.CheckoutOptions{Hash: *hash, Create: false, Force: true}
|
||||
isChaosCommit = true
|
||||
opts = &git.CheckoutOptions{Hash: branchHash, Create: false, Force: true}
|
||||
isChaosCommit = true
|
||||
checkedOutRef = version
|
||||
} else {
|
||||
opts = &git.CheckoutOptions{Hash: *hash, Create: false, Force: true}
|
||||
isChaosCommit = true
|
||||
checkedOutRef = hash.String()
|
||||
}
|
||||
} else {
|
||||
opts = &git.CheckoutOptions{Branch: tagRef, Create: false, Force: true}
|
||||
checkedOutRef = tagRef.Short()
|
||||
}
|
||||
|
||||
worktree, err := repo.Worktree()
|
||||
if err != nil {
|
||||
return isChaosCommit, nil
|
||||
return isChaosCommit, err
|
||||
}
|
||||
|
||||
if err := worktree.Checkout(opts); err != nil {
|
||||
return isChaosCommit, nil
|
||||
return isChaosCommit, err
|
||||
}
|
||||
|
||||
log.Debug(i18n.G("successfully checked %s out to %s in %s", r.Name, tagRef.Short(), r.Dir))
|
||||
log.Debug(i18n.G("successfully checked %s out to %s in %s", r.Name, checkedOutRef, r.Dir))
|
||||
|
||||
return isChaosCommit, nil
|
||||
}
|
||||
@@ -506,28 +527,51 @@ 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{})
|
||||
// resolveBranch resolves name as a branch on the "origin" remote, fetching
|
||||
// it on demand into a remote-tracking ref (never into refs/heads/*, which
|
||||
// would corrupt GetDefaultBranch's local-branch-name heuristic). It returns
|
||||
// (hash, true, nil) if name is a branch, (_, false, nil) if name is
|
||||
// confirmed not to be a branch, and a non-nil error for any other failure
|
||||
// (e.g. network/auth issues) that should not be silently treated as "not a
|
||||
// branch". When offline is true, no fetch is attempted and only an
|
||||
// already-fetched remote-tracking ref is consulted.
|
||||
func resolveBranch(repo *git.Repository, name string, offline bool) (plumbing.Hash, bool, error) {
|
||||
remoteTrackingRef := plumbing.NewRemoteReferenceName("origin", name)
|
||||
|
||||
if !offline {
|
||||
refSpec := gitCfg.RefSpec(fmt.Sprintf("+refs/heads/%s:%s", name, remoteTrackingRef))
|
||||
err := repo.Fetch(&git.FetchOptions{
|
||||
RemoteName: "origin",
|
||||
RefSpecs: []gitCfg.RefSpec{refSpec},
|
||||
Force: true,
|
||||
})
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, ref := range refs {
|
||||
if ref.Name().IsBranch() && ref.Name().Short() == name {
|
||||
return true
|
||||
var noMatch git.NoMatchingRefSpecError
|
||||
switch {
|
||||
case errors.Is(err, git.NoErrAlreadyUpToDate):
|
||||
case errors.As(err, &noMatch):
|
||||
return plumbing.ZeroHash, false, nil
|
||||
default:
|
||||
return plumbing.ZeroHash, false, errors.New(i18n.G("unable to fetch branch '%s': %s", name, err))
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
|
||||
ref, err := repo.Reference(remoteTrackingRef, true)
|
||||
if err != nil {
|
||||
if offline {
|
||||
return plumbing.ZeroHash, false, errOfflineBranchUnresolved
|
||||
}
|
||||
return plumbing.ZeroHash, false, errors.New(i18n.G("unable to resolve fetched branch '%s': %s", name, err))
|
||||
}
|
||||
|
||||
return ref.Hash(), true, nil
|
||||
}
|
||||
|
||||
// errOfflineBranchUnresolved is returned by resolveBranch when offline mode
|
||||
// prevents fetching a branch that isn't already available locally.
|
||||
var errOfflineBranchUnresolved = errors.New(i18n.G("not fetched and cannot resolve offline"))
|
||||
|
||||
// Head retrieves latest HEAD metadata.
|
||||
func (r Recipe) Head() (*plumbing.Reference, error) {
|
||||
repo, err := git.PlainOpen(r.Dir)
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"testing"
|
||||
|
||||
"coopcloud.tech/abra/pkg/test"
|
||||
"github.com/go-git/go-git/v5"
|
||||
"github.com/go-git/go-git/v5/plumbing"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
@@ -36,3 +38,122 @@ func TestIsDirty(t *testing.T) {
|
||||
|
||||
assert.True(t, dirty)
|
||||
}
|
||||
|
||||
func TestEnsureVersionBranch(t *testing.T) {
|
||||
test.SetupWithUpstream()
|
||||
t.Cleanup(func() { test.Teardown() })
|
||||
|
||||
upstream, err := git.PlainOpen(test.UpstreamRecipeDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
featureRef, err := upstream.Reference(plumbing.NewBranchReferenceName(test.FeatureBranch), true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
r := Get(test.RecipeName)
|
||||
|
||||
isChaosCommit, err := r.EnsureVersion(test.FeatureBranch)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assert.True(t, isChaosCommit)
|
||||
|
||||
head, err := r.Head()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assert.Equal(t, featureRef.Hash(), head.Hash())
|
||||
}
|
||||
|
||||
func TestEnsureVersionBranchRefetchesOnRedeploy(t *testing.T) {
|
||||
test.SetupWithUpstream()
|
||||
t.Cleanup(func() { test.Teardown() })
|
||||
|
||||
r := Get(test.RecipeName)
|
||||
|
||||
if _, err := r.EnsureVersion(test.FeatureBranch); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := test.CreateBranchWithCommit(test.UpstreamRecipeDir, test.FeatureBranch, "feature2.txt", "more feature work"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
upstream, err := git.PlainOpen(test.UpstreamRecipeDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
featureRef, err := upstream.Reference(plumbing.NewBranchReferenceName(test.FeatureBranch), true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := r.EnsureVersion(test.FeatureBranch); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
head, err := r.Head()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assert.Equal(t, featureRef.Hash(), head.Hash())
|
||||
}
|
||||
|
||||
func TestEnsureVersionOfflineUnfetchedBranch(t *testing.T) {
|
||||
test.SetupWithUpstream()
|
||||
t.Cleanup(func() { test.Teardown() })
|
||||
|
||||
r := Get(test.RecipeName)
|
||||
|
||||
headBefore, err := r.Head()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err = r.EnsureVersionCtx(EnsureContext{Offline: true}, test.FeatureBranch)
|
||||
assert.ErrorContains(t, err, "offline")
|
||||
|
||||
headAfter, err := r.Head()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assert.Equal(t, headBefore.Hash(), headAfter.Hash())
|
||||
}
|
||||
|
||||
func TestEnsureVersionUnknownNameErrors(t *testing.T) {
|
||||
test.SetupWithUpstream()
|
||||
t.Cleanup(func() { test.Teardown() })
|
||||
|
||||
r := Get(test.RecipeName)
|
||||
|
||||
_, err := r.EnsureVersion("does-not-exist-anywhere")
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestEnsureBranchPinnedRecipe(t *testing.T) {
|
||||
test.SetupWithUpstream()
|
||||
t.Cleanup(func() { test.Teardown() })
|
||||
|
||||
upstream, err := git.PlainOpen(test.UpstreamRecipeDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
featureRef, err := upstream.Reference(plumbing.NewBranchReferenceName(test.FeatureBranch), true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
r := Get(test.RecipeName + ":" + test.FeatureBranch)
|
||||
|
||||
if err := r.Ensure(EnsureContext{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
head, err := r.Head()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assert.Equal(t, featureRef.Hash(), head.Hash())
|
||||
}
|
||||
|
||||
@@ -8,6 +8,9 @@ import (
|
||||
|
||||
gitPkg "coopcloud.tech/abra/pkg/git"
|
||||
"git.coopcloud.tech/toolshed/godotenv"
|
||||
"github.com/go-git/go-git/v5"
|
||||
"github.com/go-git/go-git/v5/plumbing"
|
||||
"github.com/go-git/go-git/v5/plumbing/object"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -76,6 +79,126 @@ func Setup() {
|
||||
}
|
||||
}
|
||||
|
||||
// UpstreamRecipeDir is the "remote" repo used by SetupWithUpstream, kept
|
||||
// outside recipes/ so Teardown's blanket removal of $ABRA_DIR still cleans
|
||||
// it up.
|
||||
var UpstreamRecipeDir = os.ExpandEnv("$ABRA_DIR/_upstream/test_recipe")
|
||||
|
||||
// FeatureBranch is the non-default branch created by SetupWithUpstream.
|
||||
const FeatureBranch = "feature"
|
||||
|
||||
// SetupWithUpstream is like Setup, but the recipe under $ABRA_DIR/recipes
|
||||
// is a real clone of a separate "upstream" repo (UpstreamRecipeDir) rather
|
||||
// than a git-inited copy of the fixture. The upstream repo additionally has
|
||||
// a FeatureBranch with one extra commit not present on main, so tests can
|
||||
// exercise resolving/fetching a non-default branch as a recipe :version.
|
||||
func SetupWithUpstream() {
|
||||
Teardown()
|
||||
|
||||
if err := os.Mkdir(os.ExpandEnv("$ABRA_DIR"), 0764); err != nil {
|
||||
if !os.IsExist(err) {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := os.Mkdir(os.ExpandEnv("$ABRA_DIR/servers"), 0700); err != nil {
|
||||
if !os.IsExist(err) {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := os.Mkdir(os.ExpandEnv("$ABRA_DIR/recipes"), 0764); err != nil {
|
||||
if !os.IsExist(err) {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
serverSrcDir := os.ExpandEnv("$PWD/../../tests/resources/test_server")
|
||||
serverDestDir := os.ExpandEnv("$ABRA_DIR/servers/test_server")
|
||||
if err := os.CopyFS(serverDestDir, os.DirFS(serverSrcDir)); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(path.Dir(UpstreamRecipeDir), 0764); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
recipeSrcDir := os.ExpandEnv("$PWD/../../tests/resources/test_recipe")
|
||||
if err := os.CopyFS(UpstreamRecipeDir, os.DirFS(recipeSrcDir)); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(path.Join(UpstreamRecipeDir, ".env.sample"), []byte("TYPE=test_recipe\n"), 0o644); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
if err := gitPkg.Init(UpstreamRecipeDir, true, "tester", "helo@coopcloud.tech"); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
if err := CreateBranchWithCommit(UpstreamRecipeDir, FeatureBranch, "feature.txt", "feature work"); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
recipeDestDir := os.ExpandEnv("$ABRA_DIR/recipes/test_recipe")
|
||||
if err := gitPkg.Clone(recipeDestDir, UpstreamRecipeDir); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// CreateBranchWithCommit checks out branchName in repoPath (creating it off
|
||||
// the current HEAD if it doesn't exist yet, or extending it if it does),
|
||||
// writes fileName with contents, commits it, then returns to the repo's
|
||||
// default branch (leaving the new commit reachable only via the branch's
|
||||
// ref, matching how a contributor would push a feature branch upstream
|
||||
// without switching the repo's HEAD away from main).
|
||||
func CreateBranchWithCommit(repoPath, branchName, fileName, contents string) error {
|
||||
repo, err := git.PlainOpen(repoPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
head, err := repo.Head()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
worktree, err := repo.Worktree()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
branchRef := plumbing.NewBranchReferenceName(branchName)
|
||||
create := true
|
||||
if _, err := repo.Reference(branchRef, true); err == nil {
|
||||
create = false
|
||||
}
|
||||
|
||||
checkoutOpts := &git.CheckoutOptions{Branch: branchRef, Create: create}
|
||||
if create {
|
||||
checkoutOpts.Hash = head.Hash()
|
||||
}
|
||||
if err := worktree.Checkout(checkoutOpts); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := os.WriteFile(path.Join(repoPath, fileName), []byte(contents), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := worktree.Add(fileName); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := worktree.Commit(fileName, &git.CommitOptions{
|
||||
Author: &object.Signature{Name: "tester", Email: "helo@coopcloud.tech"},
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return worktree.Checkout(&git.CheckoutOptions{Branch: head.Name()})
|
||||
}
|
||||
|
||||
func AddEnv(envKey, envValue string) error {
|
||||
filePath := os.ExpandEnv(fmt.Sprintf("$ABRA_DIR/servers/%s/%s.env", ServerName, AppName))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user