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
37 changes: 37 additions & 0 deletions cmd/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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`.
Expand Down
62 changes: 62 additions & 0 deletions cmd/init_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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)
}
}
12 changes: 12 additions & 0 deletions internal/git/remote.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down