diff --git a/pkg/recipe/git.go b/pkg/recipe/git.go index 4159fa71..4b7ff523 100644 --- a/pkg/recipe/git.go +++ b/pkg/recipe/git.go @@ -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; ':' 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) diff --git a/pkg/recipe/git_test.go b/pkg/recipe/git_test.go index 0f54f12b..5f200fad 100644 --- a/pkg/recipe/git_test.go +++ b/pkg/recipe/git_test.go @@ -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()) +} diff --git a/pkg/test/test.go b/pkg/test/test.go index 302934f6..36d3b1a9 100644 --- a/pkg/test/test.go +++ b/pkg/test/test.go @@ -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))