Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions cmd/meta.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,13 @@ var pushMetaCmd = &cobra.Command{
var pullMetaCmd = &cobra.Command{
Use: "pull-meta",
Short: "Pull and merge stackr metadata from the remote",
Long: "Fetches the shared branch graph, config, and PR metadata from the remote and merges with local state.",
Long: "Fetches the shared branch graph, config, and PR metadata from the remote and merges with local state.\n\n" +
"Works in an uninitialized clone too: git does not fetch stackr's metadata ref, so this is how a " +
"fresh clone of an sr-managed repo bootstraps itself.",
RunE: func(cmd *cobra.Command, args []string) error {
if err := ctx.RequireInit(); err != nil {
return err
}
// Deliberately no RequireInit: pull-meta is the way OUT of the
// uninitialized state for a fresh clone, so gating it on being
// initialized would be a bootstrap catch-22.
return engine.PullMeta(ctx)
},
}
Expand Down
32 changes: 27 additions & 5 deletions internal/engine/meta.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,21 +30,43 @@ func PushMeta(c *context.Context) error {
}

// PullMeta fetches stackr metadata from the remote and merges.
//
// It deliberately works in an uninitialized clone: git does not fetch custom
// refs, so a fresh clone of an sr-managed repo has no local metadata — and the
// config naming the remote lives inside that metadata. Bootstrapping therefore
// resolves the remote from the repo itself: the sole configured remote when
// unambiguous, else "origin".
func PullMeta(c *context.Context) error {
rs, ok := c.Store.(*store.RefStore)
if !ok {
return fmt.Errorf("metadata pull requires ref-based store — run `sr migrate` to upgrade")
}
cfg, err := c.Store.ReadConfig()
if err != nil {
return err

remote := "origin"
if rs.Exists() {
cfg, err := c.Store.ReadConfig()
if err != nil {
return err
}
remote = cfg.Remote
} else if remotes, err := c.Git.ListRemotes(); err == nil && len(remotes) == 1 {
remote = remotes[0]
}

if !c.Quiet {
fmt.Printf("Pulling stackr metadata from %s...\n", cfg.Remote)
fmt.Printf("Pulling stackr metadata from %s...\n", remote)
}
if err := rs.Pull(cfg.Remote); err != nil {
if err := rs.Pull(remote); err != nil {
return fmt.Errorf("failed to pull metadata: %w", err)
}
if !rs.Exists() {
return fmt.Errorf("%s has no stackr metadata — run `sr init` to start fresh", remote)
}
// A bootstrap pull adopted shared state but never ran init; create the
// local scaffolding (undo/rollback dirs) the rest of stackr expects.
if err := rs.Init(); err != nil {
return err
}
if !c.Quiet {
fmt.Println("Metadata synced")
}
Expand Down
134 changes: 134 additions & 0 deletions internal/engine/meta_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
package engine

import (
"strings"
"testing"

"github.com/amustafa/stackr/internal/context"
"github.com/amustafa/stackr/internal/git"
"github.com/amustafa/stackr/internal/graph"
"github.com/amustafa/stackr/internal/store"
)

// setupSeededRemote creates a bare remote holding stackr metadata (as if a
// collaborator initialized and pushed), plus a fresh clone that — like every
// real clone — has no refs/stackr/data and no .stackr dir.
func setupSeededRemote(t *testing.T) (fresh *context.Context) {
t.Helper()

remoteDir := t.TempDir()
remote := &git.Runner{Dir: remoteDir}
if _, err := remote.RunGitCapture("init", "--bare"); err != nil {
t.Fatalf("git init bare: %v", err)
}

// Seeder clone: initialize metadata and push it.
seedDir := t.TempDir()
seeder := &git.Runner{Dir: seedDir}
if _, err := seeder.RunGitCapture("clone", remoteDir, "."); err != nil {
t.Fatalf("clone seeder: %v", err)
}
seeder.RunGitCapture("config", "user.email", "seed@test.com")
seeder.RunGitCapture("config", "user.name", "Seeder")
seedGitDir, _ := seeder.GitCommonDir()
seedStore := store.NewRefStore(seeder, seedGitDir)
if err := seedStore.WriteConfig(&store.Config{Trunk: "main", Remote: "origin"}); err != nil {
t.Fatalf("seed WriteConfig: %v", err)
}
g := graph.New()
g.AddTrunk("main", "aaa")
g.AddBranch("feat-a", "main", "aaa", "bbb")
if err := seedStore.WriteGraph(g); err != nil {
t.Fatalf("seed WriteGraph: %v", err)
}
if err := seedStore.Push("origin"); err != nil {
t.Fatalf("seed Push: %v", err)
}

// Fresh clone: what a new collaborator actually has.
freshDir := t.TempDir()
runner := &git.Runner{Dir: freshDir}
if _, err := runner.RunGitCapture("clone", remoteDir, "."); err != nil {
t.Fatalf("clone fresh: %v", err)
}
runner.RunGitCapture("config", "user.email", "new@test.com")
runner.RunGitCapture("config", "user.name", "Newcomer")
gitDir, _ := runner.GitCommonDir()

return &context.Context{
Git: runner,
Store: store.NewRefStore(runner, gitDir),
Quiet: true,
}
}

// A fresh clone of an sr-managed repo must be able to bootstrap itself with
// pull-meta alone — requiring init first was a catch-22, and running init
// would shadow the shared graph with a blank one.
func TestPullMeta_BootstrapsUninitializedClone(t *testing.T) {
c := setupSeededRemote(t)

if c.Store.Exists() {
t.Fatal("precondition: fresh clone must start uninitialized")
}

if err := PullMeta(c); err != nil {
t.Fatalf("PullMeta on uninitialized clone: %v", err)
}

if !c.Store.Exists() {
t.Fatal("store must exist after bootstrap pull")
}
g, err := c.Store.ReadGraph()
if err != nil {
t.Fatalf("ReadGraph after bootstrap: %v", err)
}
if !g.Has("main") || !g.Has("feat-a") {
t.Fatalf("shared graph not adopted; branches: %v", g.Branches)
}
cfg, err := c.Store.ReadConfig()
if err != nil {
t.Fatalf("ReadConfig after bootstrap: %v", err)
}
if cfg.Trunk != "main" {
t.Fatalf("trunk = %q, want main", cfg.Trunk)
}
// Local scaffolding (undo/rollback) must exist too — pull replaces init.
if rs, ok := c.Store.(*store.RefStore); ok {
if !rs.Exists() {
t.Fatal("RefStore.Exists false after bootstrap")
}
}
}

// A clone whose remote has no stackr metadata cannot bootstrap; pull-meta must
// say so and point at sr init instead of reporting a silent success.
func TestPullMeta_UninitializedWithEmptyRemoteErrors(t *testing.T) {
remoteDir := t.TempDir()
remote := &git.Runner{Dir: remoteDir}
if _, err := remote.RunGitCapture("init", "--bare"); err != nil {
t.Fatalf("git init bare: %v", err)
}
dir := t.TempDir()
runner := &git.Runner{Dir: dir}
if _, err := runner.RunGitCapture("clone", remoteDir, "."); err != nil {
t.Fatalf("clone: %v", err)
}
gitDir, _ := runner.GitCommonDir()
c := &context.Context{
Git: runner,
Store: store.NewRefStore(runner, gitDir),
Quiet: true,
}

err := PullMeta(c)
if err == nil {
t.Fatal("PullMeta must fail when neither clone nor remote has metadata")
}
if !strings.Contains(err.Error(), "sr init") {
t.Fatalf("error should point at sr init, got: %v", err)
}
if c.Store.Exists() {
t.Fatal("failed bootstrap must not leave a half-initialized store")
}
}
3 changes: 2 additions & 1 deletion internal/errors/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ import (
// Sentinel errors for common failure modes.
var (
ErrNotARepo = errors.New("not a git repository")
ErrNotInitialized = errors.New("stackr not initialized — run `sr init`")
ErrNotInitialized = errors.New("stackr not initialized — run `sr init` " +
"(or `sr pull-meta` if this repo already uses stackr: fresh clones don't fetch its metadata ref)")
ErrDirtyWorktree = errors.New("working tree has uncommitted changes")
ErrOnTrunk = errors.New("cannot perform this operation on the trunk branch")
ErrBranchNotFound = errors.New("branch not found in stack graph")
Expand Down