From e7d7a1bd6b02dba7cc960a3a32e7cfa9ea9e02e0 Mon Sep 17 00:00:00 2001 From: julianknutsen Date: Sun, 11 Jan 2026 06:35:20 +0000 Subject: [PATCH] fix(rig): return rig root from BeadsPath() to respect redirect system BeadsPath() was incorrectly returning /mayor/rig when HasMayor was true, bypassing the redirect system at /.beads/redirect. This caused beads operations to fail when the user's repo doesn't have tracked beads. The redirect architecture is: - /.beads/redirect -> mayor/rig/.beads (when repo tracks .beads/) - /.beads/ contains local database (when repo doesn't track .beads/) By always returning the rig root, all callers now go through the redirect system which is set up by initBeads() during rig creation. Affected callers (all now work correctly): - internal/refinery/manager.go - Queue() for merge requests - internal/swarm/manager.go - swarm operations - internal/cmd/swarm.go - swarm CLI commands - internal/cmd/status.go - rig status display - internal/cmd/mq_next.go - merge queue operations - internal/cmd/mq_list.go - merge queue listing - internal/cmd/rig_dock.go - dock/undock operations Fixes #317 Co-Authored-By: Claude Opus 4.5 --- internal/rig/manager_test.go | 83 ++++++++++++++++++++ internal/rig/types.go | 15 ++-- internal/rig/types_test.go | 146 +++++++++++++++++++++++++++++++++++ 3 files changed, 238 insertions(+), 6 deletions(-) create mode 100644 internal/rig/types_test.go diff --git a/internal/rig/manager_test.go b/internal/rig/manager_test.go index ee45f415..378ac6d0 100644 --- a/internal/rig/manager_test.go +++ b/internal/rig/manager_test.go @@ -308,6 +308,89 @@ func TestEnsureGitignoreEntry_AppendsToExisting(t *testing.T) { } } +func TestInitBeads_TrackedBeads_CreatesRedirect(t *testing.T) { + t.Parallel() + // When the cloned repo has tracked beads (mayor/rig/.beads exists), + // initBeads should create a redirect file at /.beads/redirect + // pointing to mayor/rig/.beads instead of creating a local database. + rigPath := t.TempDir() + + // Simulate tracked beads in the cloned repo + mayorBeadsDir := filepath.Join(rigPath, "mayor", "rig", ".beads") + if err := os.MkdirAll(mayorBeadsDir, 0755); err != nil { + t.Fatalf("mkdir mayor beads: %v", err) + } + // Create a config file to simulate a real beads directory + if err := os.WriteFile(filepath.Join(mayorBeadsDir, "config.yaml"), []byte("prefix: gt\n"), 0644); err != nil { + t.Fatalf("write mayor config: %v", err) + } + + manager := &Manager{} + if err := manager.initBeads(rigPath, "gt"); err != nil { + t.Fatalf("initBeads: %v", err) + } + + // Verify redirect file was created + redirectPath := filepath.Join(rigPath, ".beads", "redirect") + content, err := os.ReadFile(redirectPath) + if err != nil { + t.Fatalf("reading redirect file: %v", err) + } + + expected := "mayor/rig/.beads\n" + if string(content) != expected { + t.Errorf("redirect content = %q, want %q", string(content), expected) + } + + // Verify no local database was created (no config.yaml at rig level) + rigConfigPath := filepath.Join(rigPath, ".beads", "config.yaml") + if _, err := os.Stat(rigConfigPath); !os.IsNotExist(err) { + t.Errorf("expected no config.yaml at rig level when using redirect, but it exists") + } +} + +func TestInitBeads_LocalBeads_CreatesDatabase(t *testing.T) { + // Cannot use t.Parallel() due to t.Setenv + // When the cloned repo does NOT have tracked beads (no mayor/rig/.beads), + // initBeads should create a local database at /.beads/ + rigPath := t.TempDir() + + // Create mayor/rig directory but WITHOUT .beads (no tracked beads) + mayorRigDir := filepath.Join(rigPath, "mayor", "rig") + if err := os.MkdirAll(mayorRigDir, 0755); err != nil { + t.Fatalf("mkdir mayor/rig: %v", err) + } + + // Use fake bd that succeeds + script := `#!/usr/bin/env bash +set -e +if [[ "$1" == "init" ]]; then + # Simulate successful bd init + exit 0 +fi +exit 0 +` + binDir := writeFakeBD(t, script) + t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) + + manager := &Manager{} + if err := manager.initBeads(rigPath, "gt"); err != nil { + t.Fatalf("initBeads: %v", err) + } + + // Verify NO redirect file was created + redirectPath := filepath.Join(rigPath, ".beads", "redirect") + if _, err := os.Stat(redirectPath); !os.IsNotExist(err) { + t.Errorf("expected no redirect file for local beads, but it exists") + } + + // Verify .beads directory was created + beadsDir := filepath.Join(rigPath, ".beads") + if _, err := os.Stat(beadsDir); os.IsNotExist(err) { + t.Errorf("expected .beads directory to be created") + } +} + func TestInitBeadsWritesConfigOnFailure(t *testing.T) { // Cannot use t.Parallel() due to t.Setenv rigPath := t.TempDir() diff --git a/internal/rig/types.go b/internal/rig/types.go index dfd386c2..fa69c525 100644 --- a/internal/rig/types.go +++ b/internal/rig/types.go @@ -70,13 +70,16 @@ func (r *Rig) Summary() RigSummary { } // BeadsPath returns the path to use for beads operations. -// Returns the mayor/rig clone path if available (has proper sync-branch config), -// otherwise falls back to the rig root path. -// This ensures beads commands read from a location with git-synced beads data. +// Always returns the rig root path where .beads/ contains either: +// - A local beads database (when repo doesn't track .beads/) +// - A redirect file pointing to mayor/rig/.beads (when repo tracks .beads/) +// +// The redirect is set up by initBeads() during rig creation and followed +// automatically by the bd CLI and beads.ResolveBeadsDir(). +// +// This ensures we never write to the user's repo clone (mayor/rig/) and +// all beads operations go through the redirect system. func (r *Rig) BeadsPath() string { - if r.HasMayor { - return r.Path + "/mayor/rig" - } return r.Path } diff --git a/internal/rig/types_test.go b/internal/rig/types_test.go new file mode 100644 index 00000000..7fbb4d8a --- /dev/null +++ b/internal/rig/types_test.go @@ -0,0 +1,146 @@ +package rig + +import ( + "testing" +) + +func TestBeadsPath_AlwaysReturnsRigRoot(t *testing.T) { + t.Parallel() + + // BeadsPath should always return the rig root path, regardless of HasMayor. + // The redirect system at /.beads/redirect handles finding the actual + // beads location (either local at /.beads/ or tracked at mayor/rig/.beads/). + // + // This ensures: + // 1. We don't write files to the user's repo clone (mayor/rig/) + // 2. The redirect architecture is respected + // 3. All code paths use the same beads resolution logic + + tests := []struct { + name string + rig Rig + wantPath string + }{ + { + name: "rig with mayor only", + rig: Rig{ + Name: "testrig", + Path: "/home/user/gt/testrig", + HasMayor: true, + }, + wantPath: "/home/user/gt/testrig", + }, + { + name: "rig with witness only", + rig: Rig{ + Name: "testrig", + Path: "/home/user/gt/testrig", + HasWitness: true, + }, + wantPath: "/home/user/gt/testrig", + }, + { + name: "rig with refinery only", + rig: Rig{ + Name: "testrig", + Path: "/home/user/gt/testrig", + HasRefinery: true, + }, + wantPath: "/home/user/gt/testrig", + }, + { + name: "rig with no agents", + rig: Rig{ + Name: "testrig", + Path: "/home/user/gt/testrig", + }, + wantPath: "/home/user/gt/testrig", + }, + { + name: "rig with mayor and witness", + rig: Rig{ + Name: "testrig", + Path: "/home/user/gt/testrig", + HasMayor: true, + HasWitness: true, + }, + wantPath: "/home/user/gt/testrig", + }, + { + name: "rig with mayor and refinery", + rig: Rig{ + Name: "testrig", + Path: "/home/user/gt/testrig", + HasMayor: true, + HasRefinery: true, + }, + wantPath: "/home/user/gt/testrig", + }, + { + name: "rig with witness and refinery", + rig: Rig{ + Name: "testrig", + Path: "/home/user/gt/testrig", + HasWitness: true, + HasRefinery: true, + }, + wantPath: "/home/user/gt/testrig", + }, + { + name: "rig with all agents", + rig: Rig{ + Name: "fullrig", + Path: "/tmp/gt/fullrig", + HasMayor: true, + HasWitness: true, + HasRefinery: true, + }, + wantPath: "/tmp/gt/fullrig", + }, + { + name: "rig with polecats", + rig: Rig{ + Name: "testrig", + Path: "/home/user/gt/testrig", + HasMayor: true, + Polecats: []string{"polecat1", "polecat2"}, + }, + wantPath: "/home/user/gt/testrig", + }, + { + name: "rig with crew", + rig: Rig{ + Name: "testrig", + Path: "/home/user/gt/testrig", + HasMayor: true, + Crew: []string{"crew1", "crew2"}, + }, + wantPath: "/home/user/gt/testrig", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := tt.rig.BeadsPath() + if got != tt.wantPath { + t.Errorf("BeadsPath() = %q, want %q", got, tt.wantPath) + } + }) + } +} + +func TestDefaultBranch_FallsBackToMain(t *testing.T) { + t.Parallel() + + // DefaultBranch should return "main" when config cannot be loaded + rig := Rig{ + Name: "testrig", + Path: "/nonexistent/path", + } + + got := rig.DefaultBranch() + if got != "main" { + t.Errorf("DefaultBranch() = %q, want %q", got, "main") + } +}