diff --git a/cmd/init.go b/cmd/init.go index fa14996..2d1d251 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -7,6 +7,7 @@ import ( "path/filepath" srctx "github.com/amustafa/stackr/internal/context" + "github.com/amustafa/stackr/internal/engine" srerr "github.com/amustafa/stackr/internal/errors" "github.com/amustafa/stackr/internal/git" "github.com/amustafa/stackr/internal/graph" @@ -92,6 +93,21 @@ func runInit(cmd *cobra.Command, args []string) error { return fmt.Errorf("stackr already initialized (use --reset to re-initialize)") } + // A remote that already has stackr metadata means another clone initialized + // this repo — creating blank state here would silently shadow the shared + // graph. Adopt the remote's state instead. --reset keeps meaning "start + // over deliberately", so it skips the adoption. + if !initFlagReset { + if remote, ok := remoteWithStackrMeta(c); ok { + fmt.Printf("Found existing stackr metadata on %s — adopting it instead of initializing fresh.\n", remote) + if err := engine.PullMeta(c); err != nil { + return err + } + maybeOfferClaudeInstall(c) + return nil + } + } + trunk := initFlagTrunk if trunk == "" { trunk, err = c.Git.DefaultBranch() @@ -149,6 +165,27 @@ func runInit(cmd *cobra.Command, args []string) error { return nil } +// remoteWithStackrMeta reports which remote, if any, already holds stackr +// metadata. Best-effort by design: it probes the network, and a remote that +// is unreachable is treated the same as one without metadata — init then +// proceeds fresh, which is also what an offline `sr init` needs. +func remoteWithStackrMeta(c *srctx.Context) (string, bool) { + rs, ok := c.Store.(*store.RefStore) + if !ok { + return "", false + } + remotes, err := c.Git.ListRemotes() + if err != nil { + return "", false + } + for _, remote := range remotes { + if has, err := c.Git.RemoteHasRef(remote, rs.Ref()); err == nil && has { + return remote, true + } + } + return "", false +} + // maybeOfferClaudeInstall checks whether the stackr Claude Code prompt is // already reachable for this repo — in its own CLAUDE.md or the user's global // one — and, if not, offers to run the equivalent of `sr claude install`. diff --git a/cmd/init_test.go b/cmd/init_test.go index 6c71fb6..68b8a22 100644 --- a/cmd/init_test.go +++ b/cmd/init_test.go @@ -5,7 +5,9 @@ import ( "path/filepath" "testing" + srctx "github.com/amustafa/stackr/internal/context" "github.com/amustafa/stackr/internal/git" + "github.com/amustafa/stackr/internal/store" "github.com/amustafa/stackr/internal/ui" ) @@ -256,3 +258,63 @@ func TestApplyFormResultCustomBranch(t *testing.T) { t.Fatal("should have created a commit") } } + +// seedRemoteWithMeta creates a bare remote carrying refs/stackr/data and +// returns a fresh clone's context — the "new collaborator" starting state. +func seedRemoteWithMeta(t *testing.T, seed bool) *srctx.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) + } + + if seed { + 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") + gitDir, _ := seeder.GitCommonDir() + st := store.NewRefStore(seeder, gitDir) + if err := st.WriteConfig(&store.Config{Trunk: "main", Remote: "origin"}); err != nil { + t.Fatalf("seed WriteConfig: %v", err) + } + if err := st.Push("origin"); err != nil { + t.Fatalf("seed Push: %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() + return &srctx.Context{ + Git: runner, + Store: store.NewRefStore(runner, gitDir), + Quiet: true, + } +} + +func TestRemoteWithStackrMeta_FindsSeededRemote(t *testing.T) { + c := seedRemoteWithMeta(t, true) + remote, ok := remoteWithStackrMeta(c) + if !ok { + t.Fatal("must detect stackr metadata on the seeded remote") + } + if remote != "origin" { + t.Fatalf("remote = %q, want origin", remote) + } +} + +func TestRemoteWithStackrMeta_EmptyRemote(t *testing.T) { + c := seedRemoteWithMeta(t, false) + if remote, ok := remoteWithStackrMeta(c); ok { + t.Fatalf("must not detect metadata on an empty remote, got %q", remote) + } +} diff --git a/internal/git/remote.go b/internal/git/remote.go index 350a529..3062d3a 100644 --- a/internal/git/remote.go +++ b/internal/git/remote.go @@ -142,6 +142,18 @@ func (r *Runner) FetchPrune(remote string) error { return r.RunGit("fetch", "--prune", remote) } +// RemoteHasRef asks the remote itself (via ls-remote) whether ref exists. +// Unlike RemoteBranchExists it does not depend on a prior fetch — custom refs +// like refs/stackr/data are never fetched by default — at the cost of a +// network round-trip. +func (r *Runner) RemoteHasRef(remote, ref string) (bool, error) { + out, err := r.RunGitCapture("ls-remote", remote, ref) + if err != nil { + return false, err + } + return strings.TrimSpace(out) != "", nil +} + // RemoteBranchExists checks if a branch exists on the remote. func (r *Runner) RemoteBranchExists(remote, branch string) (bool, error) { _, err := r.RunGitCapture("rev-parse", "--verify", "refs/remotes/"+remote+"/"+branch)