fix(rig): return rig root from BeadsPath() to respect redirect system

BeadsPath() was incorrectly returning <rig>/mayor/rig when HasMayor was
true, bypassing the redirect system at <rig>/.beads/redirect. This caused
beads operations to fail when the user's repo doesn't have tracked beads.

The redirect architecture is:
- <rig>/.beads/redirect -> mayor/rig/.beads (when repo tracks .beads/)
- <rig>/.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 <noreply@anthropic.com>
This commit is contained in:
julianknutsen
2026-01-11 06:35:20 +00:00
parent 982ce6c5d1
commit e7d7a1bd6b
3 changed files with 238 additions and 6 deletions

View File

@@ -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 <rig>/.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 <rig>/.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()

View File

@@ -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
}

146
internal/rig/types_test.go Normal file
View File

@@ -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 <rig>/.beads/redirect handles finding the actual
// beads location (either local at <rig>/.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")
}
}