Add BD_ACTOR check at start of runDone() to prevent non-polecat roles (crew, deacon, witness, etc.) from calling gt done. Only polecats are ephemeral workers that self-destruct after completing work. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
380 lines
13 KiB
Go
380 lines
13 KiB
Go
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: <prefix>-<rig>-<role>-<name>
|
|
_, 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)
|
|
}
|
|
})
|
|
}
|
|
}
|