package cmd import ( "os" "os/exec" "path/filepath" "testing" "github.com/steveyegge/gastown/internal/beads" ) // TestDoneUsesResolveBeadsDir verifies that the done command correctly uses // beads.ResolveBeadsDir to follow redirect files when initializing beads. // This is critical for polecat/crew worktrees that use .beads/redirect to point // to the shared mayor/rig/.beads directory. // // The done.go file has two code paths that initialize beads: // - Line 181: ExitCompleted path - bd := beads.New(beads.ResolveBeadsDir(cwd)) // - Line 277: ExitPhaseComplete path - bd := beads.New(beads.ResolveBeadsDir(cwd)) // // Both must use ResolveBeadsDir to properly handle redirects. func TestDoneUsesResolveBeadsDir(t *testing.T) { // Create a temp directory structure simulating polecat worktree with redirect tmpDir := t.TempDir() // Create structure like: // gastown/ // mayor/rig/.beads/ <- shared beads directory // polecats/fixer/.beads/ <- polecat with redirect // redirect -> ../../mayor/rig/.beads mayorRigBeadsDir := filepath.Join(tmpDir, "gastown", "mayor", "rig", ".beads") polecatDir := filepath.Join(tmpDir, "gastown", "polecats", "fixer") polecatBeadsDir := filepath.Join(polecatDir, ".beads") // Create directories if err := os.MkdirAll(mayorRigBeadsDir, 0755); err != nil { t.Fatalf("mkdir mayor/rig/.beads: %v", err) } if err := os.MkdirAll(polecatBeadsDir, 0755); err != nil { t.Fatalf("mkdir polecats/fixer/.beads: %v", err) } // Create redirect file pointing to mayor/rig/.beads redirectContent := "../../mayor/rig/.beads" redirectPath := filepath.Join(polecatBeadsDir, "redirect") if err := os.WriteFile(redirectPath, []byte(redirectContent), 0644); err != nil { t.Fatalf("write redirect: %v", err) } t.Run("redirect followed from polecat directory", func(t *testing.T) { // This mirrors how done.go initializes beads at line 181 and 277 resolvedDir := beads.ResolveBeadsDir(polecatDir) // Should resolve to mayor/rig/.beads if resolvedDir != mayorRigBeadsDir { t.Errorf("ResolveBeadsDir(%s) = %s, want %s", polecatDir, resolvedDir, mayorRigBeadsDir) } // Verify the beads instance is created with the resolved path // We use the same pattern as done.go: beads.New(beads.ResolveBeadsDir(cwd)) bd := beads.New(beads.ResolveBeadsDir(polecatDir)) if bd == nil { t.Error("beads.New returned nil") } }) t.Run("redirect not present uses local beads", func(t *testing.T) { // Without redirect, should use local .beads localDir := filepath.Join(tmpDir, "gastown", "mayor", "rig") resolvedDir := beads.ResolveBeadsDir(localDir) if resolvedDir != mayorRigBeadsDir { t.Errorf("ResolveBeadsDir(%s) = %s, want %s", localDir, resolvedDir, mayorRigBeadsDir) } }) } // TestDoneBeadsInitWithoutRedirect verifies that beads initialization works // normally when no redirect file exists. func TestDoneBeadsInitWithoutRedirect(t *testing.T) { tmpDir := t.TempDir() // Create a simple .beads directory without redirect (like mayor/rig) beadsDir := filepath.Join(tmpDir, ".beads") if err := os.MkdirAll(beadsDir, 0755); err != nil { t.Fatalf("mkdir .beads: %v", err) } // ResolveBeadsDir should return the same directory when no redirect exists resolvedDir := beads.ResolveBeadsDir(tmpDir) if resolvedDir != beadsDir { t.Errorf("ResolveBeadsDir(%s) = %s, want %s", tmpDir, resolvedDir, beadsDir) } // Beads initialization should work the same way done.go does it bd := beads.New(beads.ResolveBeadsDir(tmpDir)) if bd == nil { t.Error("beads.New returned nil") } } // TestDoneBeadsInitBothCodePaths documents that both code paths in done.go // that create beads instances use ResolveBeadsDir: // - ExitCompleted (line 181): for MR creation and issue operations // - ExitPhaseComplete (line 277): for gate waiter registration // // This test verifies the pattern by demonstrating that the resolved directory // is used consistently for different operations. func TestDoneBeadsInitBothCodePaths(t *testing.T) { tmpDir := t.TempDir() // Setup: crew directory with redirect to mayor/rig/.beads mayorRigBeadsDir := filepath.Join(tmpDir, "mayor", "rig", ".beads") crewDir := filepath.Join(tmpDir, "crew", "max") crewBeadsDir := filepath.Join(crewDir, ".beads") if err := os.MkdirAll(mayorRigBeadsDir, 0755); err != nil { t.Fatalf("mkdir mayor/rig/.beads: %v", err) } if err := os.MkdirAll(crewBeadsDir, 0755); err != nil { t.Fatalf("mkdir crew/max/.beads: %v", err) } // Create redirect redirectPath := filepath.Join(crewBeadsDir, "redirect") if err := os.WriteFile(redirectPath, []byte("../../mayor/rig/.beads"), 0644); err != nil { t.Fatalf("write redirect: %v", err) } t.Run("ExitCompleted path uses ResolveBeadsDir", func(t *testing.T) { // This simulates the line 181 path in done.go: // bd := beads.New(beads.ResolveBeadsDir(cwd)) resolvedDir := beads.ResolveBeadsDir(crewDir) if resolvedDir != mayorRigBeadsDir { t.Errorf("ExitCompleted path: ResolveBeadsDir(%s) = %s, want %s", crewDir, resolvedDir, mayorRigBeadsDir) } bd := beads.New(beads.ResolveBeadsDir(crewDir)) if bd == nil { t.Error("beads.New returned nil for ExitCompleted path") } }) t.Run("ExitPhaseComplete path uses ResolveBeadsDir", func(t *testing.T) { // This simulates the line 277 path in done.go: // bd := beads.New(beads.ResolveBeadsDir(cwd)) resolvedDir := beads.ResolveBeadsDir(crewDir) if resolvedDir != mayorRigBeadsDir { t.Errorf("ExitPhaseComplete path: ResolveBeadsDir(%s) = %s, want %s", crewDir, resolvedDir, mayorRigBeadsDir) } bd := beads.New(beads.ResolveBeadsDir(crewDir)) if bd == nil { t.Error("beads.New returned nil for ExitPhaseComplete path") } }) } // TestDoneRedirectChain verifies behavior with chained redirects. // ResolveBeadsDir follows chains up to depth 3 as a safety net for legacy configs. // SetupRedirect avoids creating chains (bd CLI doesn't support them), but if // chains exist we follow them to the final destination. func TestDoneRedirectChain(t *testing.T) { tmpDir := t.TempDir() // Create chain: worktree -> intermediate -> canonical canonicalBeadsDir := filepath.Join(tmpDir, "canonical", ".beads") intermediateDir := filepath.Join(tmpDir, "intermediate") intermediateBeadsDir := filepath.Join(intermediateDir, ".beads") worktreeDir := filepath.Join(tmpDir, "worktree") worktreeBeadsDir := filepath.Join(worktreeDir, ".beads") // Create all directories for _, dir := range []string{canonicalBeadsDir, intermediateBeadsDir, worktreeBeadsDir} { if err := os.MkdirAll(dir, 0755); err != nil { t.Fatalf("mkdir %s: %v", dir, err) } } // Create redirects // intermediate -> canonical if err := os.WriteFile(filepath.Join(intermediateBeadsDir, "redirect"), []byte("../canonical/.beads"), 0644); err != nil { t.Fatalf("write intermediate redirect: %v", err) } // worktree -> intermediate if err := os.WriteFile(filepath.Join(worktreeBeadsDir, "redirect"), []byte("../intermediate/.beads"), 0644); err != nil { t.Fatalf("write worktree redirect: %v", err) } // ResolveBeadsDir follows chains up to depth 3 as a safety net. // Note: SetupRedirect avoids creating chains (bd CLI doesn't support them), // but if chains exist from legacy configs, we follow them to the final destination. resolved := beads.ResolveBeadsDir(worktreeDir) // Should resolve to canonical (follows the full chain) if resolved != canonicalBeadsDir { t.Errorf("ResolveBeadsDir should follow chain to final destination: got %s, want %s", resolved, canonicalBeadsDir) } } // TestDoneEmptyRedirectFallback verifies that an empty or whitespace-only // redirect file falls back to the local .beads directory. func TestDoneEmptyRedirectFallback(t *testing.T) { tmpDir := t.TempDir() beadsDir := filepath.Join(tmpDir, ".beads") if err := os.MkdirAll(beadsDir, 0755); err != nil { t.Fatalf("mkdir .beads: %v", err) } // Create empty redirect file redirectPath := filepath.Join(beadsDir, "redirect") if err := os.WriteFile(redirectPath, []byte(" \n"), 0644); err != nil { t.Fatalf("write empty redirect: %v", err) } // Should fall back to local .beads resolved := beads.ResolveBeadsDir(tmpDir) if resolved != beadsDir { t.Errorf("empty redirect should fallback: got %s, want %s", resolved, beadsDir) } } // TestDoneCircularRedirectProtection verifies that circular redirects // are detected and handled safely. func TestDoneCircularRedirectProtection(t *testing.T) { tmpDir := t.TempDir() beadsDir := filepath.Join(tmpDir, ".beads") if err := os.MkdirAll(beadsDir, 0755); err != nil { t.Fatalf("mkdir .beads: %v", err) } // Create circular redirect (points to itself) redirectPath := filepath.Join(beadsDir, "redirect") if err := os.WriteFile(redirectPath, []byte(".beads"), 0644); err != nil { t.Fatalf("write circular redirect: %v", err) } // Should detect circular redirect and return original resolved := beads.ResolveBeadsDir(tmpDir) if resolved != beadsDir { t.Errorf("circular redirect should return original: got %s, want %s", resolved, beadsDir) } } // TestGetIssueFromAgentHook verifies that getIssueFromAgentHook correctly // retrieves the issue ID from an agent's hook_bead field. // This is critical because branch names like "polecat/furiosa-mkb0vq9f" don't // contain the actual issue ID (test-845.1), but the agent's hook does. func TestGetIssueFromAgentHook(t *testing.T) { // Skip: bd CLI 0.47.2 has a bug where database writes don't commit // ("sql: database is closed" during auto-flush). This blocks tests // that need to create issues. See internal issue for tracking. t.Skip("bd CLI 0.47.2 bug: database writes don't commit") tests := []struct { name string agentBeadID string setupBeads func(t *testing.T, bd *beads.Beads) // setup agent bead with hook wantIssueID string }{ { name: "agent with hook_bead returns issue ID", agentBeadID: "test-testrig-polecat-furiosa", setupBeads: func(t *testing.T, bd *beads.Beads) { // Create a task that will be hooked _, err := bd.CreateWithID("test-456", beads.CreateOptions{ Title: "Task to be hooked", Type: "task", }) if err != nil { t.Fatalf("create task bead: %v", err) } // Create agent bead using CreateAgentBead // Agent ID format: --- _, err = bd.CreateAgentBead("test-testrig-polecat-furiosa", "Test polecat agent", nil) if err != nil { t.Fatalf("create agent bead: %v", err) } // Set hook_bead on agent if err := bd.SetHookBead("test-testrig-polecat-furiosa", "test-456"); err != nil { t.Fatalf("set hook bead: %v", err) } }, wantIssueID: "test-456", }, { name: "agent without hook_bead returns empty", agentBeadID: "test-testrig-polecat-idle", setupBeads: func(t *testing.T, bd *beads.Beads) { // Create agent bead without hook _, err := bd.CreateAgentBead("test-testrig-polecat-idle", "Test agent without hook", nil) if err != nil { t.Fatalf("create agent bead: %v", err) } }, wantIssueID: "", }, { name: "nonexistent agent returns empty", agentBeadID: "test-nonexistent", setupBeads: func(t *testing.T, bd *beads.Beads) {}, wantIssueID: "", }, { name: "empty agent ID returns empty", agentBeadID: "", setupBeads: func(t *testing.T, bd *beads.Beads) {}, wantIssueID: "", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { tmpDir := t.TempDir() // Initialize the beads database cmd := exec.Command("bd", "--no-daemon", "init", "--prefix", "test", "--quiet") cmd.Dir = tmpDir if output, err := cmd.CombinedOutput(); err != nil { t.Fatalf("bd init: %v\n%s", err, output) } // beads.New expects the .beads directory path beadsDir := filepath.Join(tmpDir, ".beads") bd := beads.New(beadsDir) tt.setupBeads(t, bd) got := getIssueFromAgentHook(bd, tt.agentBeadID) if got != tt.wantIssueID { t.Errorf("getIssueFromAgentHook(%q) = %q, want %q", tt.agentBeadID, got, tt.wantIssueID) } }) } } // TestIsPolecatActor verifies that isPolecatActor correctly identifies // polecat actors vs other roles based on the BD_ACTOR format. func TestIsPolecatActor(t *testing.T) { tests := []struct { actor string want bool }{ // Polecats: rigname/polecats/polecatname {"testrig/polecats/furiosa", true}, {"testrig/polecats/nux", true}, {"myrig/polecats/witness", true}, // even if named "witness", still a polecat // Non-polecats {"gastown/crew/george", false}, {"gastown/crew/max", false}, {"testrig/witness", false}, {"testrig/deacon", false}, {"testrig/mayor", false}, {"gastown/refinery", false}, // Edge cases {"", false}, {"single", false}, {"polecats/name", false}, // needs rig prefix } for _, tt := range tests { t.Run(tt.actor, func(t *testing.T) { got := isPolecatActor(tt.actor) if got != tt.want { t.Errorf("isPolecatActor(%q) = %v, want %v", tt.actor, got, tt.want) } }) } }