diff --git a/cmd/meta.go b/cmd/meta.go index 8bbed88..b7753d8 100644 --- a/cmd/meta.go +++ b/cmd/meta.go @@ -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) }, } diff --git a/internal/engine/meta.go b/internal/engine/meta.go index 5f1f4b7..429493f 100644 --- a/internal/engine/meta.go +++ b/internal/engine/meta.go @@ -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") } diff --git a/internal/engine/meta_test.go b/internal/engine/meta_test.go new file mode 100644 index 0000000..76fa1ab --- /dev/null +++ b/internal/engine/meta_test.go @@ -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") + } +} diff --git a/internal/errors/errors.go b/internal/errors/errors.go index 9677a20..784a134 100644 --- a/internal/errors/errors.go +++ b/internal/errors/errors.go @@ -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")