Files
gastown/internal/cmd/rig_integration_test.go
goose 52533c354d fix: Use --initial-branch=main in rig integration tests
The test helper createTestGitRepo was using plain `git init` which
creates a branch based on the system's init.defaultBranch config.
When AddRig tries to detect and checkout the default branch, it
falls back to "main" if detection fails, causing "pathspec 'main'
did not match" errors in CI where the system default is "master".

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 07:23:06 -08:00

603 lines
16 KiB
Go

//go:build integration
// Package cmd contains integration tests for the rig command.
//
// Run with: go test -tags=integration ./internal/cmd -run TestRigAdd -v
package cmd
import (
"encoding/json"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"github.com/steveyegge/gastown/internal/beads"
"github.com/steveyegge/gastown/internal/config"
"github.com/steveyegge/gastown/internal/git"
"github.com/steveyegge/gastown/internal/rig"
)
// createTestGitRepo creates a minimal git repository for testing.
// Returns the path to the bare repo URL (suitable for cloning).
func createTestGitRepo(t *testing.T, name string) string {
t.Helper()
// Create a regular repo with initial commit
repoDir := filepath.Join(t.TempDir(), name)
if err := os.MkdirAll(repoDir, 0755); err != nil {
t.Fatalf("mkdir repo: %v", err)
}
// Initialize git repo with explicit main branch
// (system default may vary, causing checkout failures)
cmds := [][]string{
{"git", "init", "--initial-branch=main"},
{"git", "config", "user.email", "test@test.com"},
{"git", "config", "user.name", "Test User"},
}
for _, args := range cmds {
cmd := exec.Command(args[0], args[1:]...)
cmd.Dir = repoDir
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git %v: %v\n%s", args, err, out)
}
}
// Create initial file and commit
readmePath := filepath.Join(repoDir, "README.md")
if err := os.WriteFile(readmePath, []byte("# Test Repo\n"), 0644); err != nil {
t.Fatalf("write README: %v", err)
}
commitCmds := [][]string{
{"git", "add", "."},
{"git", "commit", "-m", "Initial commit"},
}
for _, args := range commitCmds {
cmd := exec.Command(args[0], args[1:]...)
cmd.Dir = repoDir
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git %v: %v\n%s", args, err, out)
}
}
// Return the path as a file:// URL
return repoDir
}
// setupTestTown creates a minimal Gas Town workspace for testing.
// Returns townRoot and a cleanup function.
func setupTestTown(t *testing.T) string {
t.Helper()
townRoot := t.TempDir()
// Create mayor directory (required for rigs.json)
mayorDir := filepath.Join(townRoot, "mayor")
if err := os.MkdirAll(mayorDir, 0755); err != nil {
t.Fatalf("mkdir mayor: %v", err)
}
// Create empty rigs.json
rigsPath := filepath.Join(mayorDir, "rigs.json")
rigsConfig := &config.RigsConfig{
Version: 1,
Rigs: make(map[string]config.RigEntry),
}
if err := config.SaveRigsConfig(rigsPath, rigsConfig); err != nil {
t.Fatalf("save rigs.json: %v", err)
}
// Create .beads directory for routes
beadsDir := filepath.Join(townRoot, ".beads")
if err := os.MkdirAll(beadsDir, 0755); err != nil {
t.Fatalf("mkdir .beads: %v", err)
}
return townRoot
}
// mockBdCommand creates a fake bd binary that simulates bd behavior.
// This avoids needing bd installed for tests.
func mockBdCommand(t *testing.T) {
t.Helper()
binDir := t.TempDir()
bdPath := filepath.Join(binDir, "bd")
// Create a script that simulates bd init and other commands
script := `#!/bin/sh
# Mock bd for testing
case "$1" in
init)
# Create .beads directory and config.yaml
mkdir -p .beads
prefix="gt"
for arg in "$@"; do
case "$arg" in
--prefix=*) prefix="${arg#--prefix=}" ;;
--prefix)
# Next arg is the prefix
shift
if [ -n "$1" ] && [ "$1" != "--"* ]; then
prefix="$1"
fi
;;
esac
shift
done
# Handle positional --prefix VALUE
shift # skip 'init'
while [ $# -gt 0 ]; do
case "$1" in
--prefix)
shift
prefix="$1"
;;
esac
shift
done
echo "prefix: $prefix" > .beads/config.yaml
exit 0
;;
migrate)
exit 0
;;
show)
echo '{"error":"not found"}' >&2
exit 1
;;
create)
# Return minimal JSON for agent bead creation
echo '{}'
exit 0
;;
mol|list)
exit 0
;;
*)
exit 0
;;
esac
`
if err := os.WriteFile(bdPath, []byte(script), 0755); err != nil {
t.Fatalf("write mock bd: %v", err)
}
// Prepend to PATH
t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
}
// TestRigAddCreatesCorrectStructure verifies that gt rig add creates
// the expected directory structure.
func TestRigAddCreatesCorrectStructure(t *testing.T) {
mockBdCommand(t)
townRoot := setupTestTown(t)
gitURL := createTestGitRepo(t, "testproject")
// Load rigs config
rigsPath := filepath.Join(townRoot, "mayor", "rigs.json")
rigsConfig, err := config.LoadRigsConfig(rigsPath)
if err != nil {
t.Fatalf("load rigs.json: %v", err)
}
// Create rig manager and add rig
g := git.NewGit(townRoot)
mgr := rig.NewManager(townRoot, rigsConfig, g)
_, err = mgr.AddRig(rig.AddRigOptions{
Name: "testrig",
GitURL: gitURL,
BeadsPrefix: "tr",
})
if err != nil {
t.Fatalf("AddRig: %v", err)
}
rigPath := filepath.Join(townRoot, "testrig")
// Verify directory structure
expectedDirs := []string{
"", // rig root
"mayor", // mayor container
"mayor/rig", // mayor clone
"refinery", // refinery container
"refinery/rig", // refinery worktree
"witness", // witness dir
"polecats", // polecats dir
"crew", // crew dir
".beads", // beads dir
"plugins", // plugins dir
}
for _, dir := range expectedDirs {
path := filepath.Join(rigPath, dir)
info, err := os.Stat(path)
if err != nil {
t.Errorf("expected directory %s to exist: %v", dir, err)
continue
}
if !info.IsDir() {
t.Errorf("expected %s to be a directory", dir)
}
}
// Verify config.json exists
configPath := filepath.Join(rigPath, "config.json")
if _, err := os.Stat(configPath); err != nil {
t.Errorf("config.json not found: %v", err)
}
// Verify .repo.git (bare repo) exists
bareRepoPath := filepath.Join(rigPath, ".repo.git")
if _, err := os.Stat(bareRepoPath); err != nil {
t.Errorf(".repo.git not found: %v", err)
}
// Verify mayor/rig is a git repo
mayorRigPath := filepath.Join(rigPath, "mayor", "rig")
gitDirPath := filepath.Join(mayorRigPath, ".git")
if _, err := os.Stat(gitDirPath); err != nil {
t.Errorf("mayor/rig/.git not found: %v", err)
}
// Verify refinery/rig is a git worktree (has .git file pointing to bare repo)
refineryRigPath := filepath.Join(rigPath, "refinery", "rig")
refineryGitPath := filepath.Join(refineryRigPath, ".git")
info, err := os.Stat(refineryGitPath)
if err != nil {
t.Errorf("refinery/rig/.git not found: %v", err)
} else if info.IsDir() {
t.Errorf("refinery/rig/.git should be a file (worktree), not a directory")
}
}
// TestRigAddInitializesBeads verifies that beads is initialized with
// the correct prefix.
func TestRigAddInitializesBeads(t *testing.T) {
mockBdCommand(t)
townRoot := setupTestTown(t)
gitURL := createTestGitRepo(t, "beadstest")
rigsPath := filepath.Join(townRoot, "mayor", "rigs.json")
rigsConfig, err := config.LoadRigsConfig(rigsPath)
if err != nil {
t.Fatalf("load rigs.json: %v", err)
}
g := git.NewGit(townRoot)
mgr := rig.NewManager(townRoot, rigsConfig, g)
newRig, err := mgr.AddRig(rig.AddRigOptions{
Name: "beadstest",
GitURL: gitURL,
BeadsPrefix: "bt",
})
if err != nil {
t.Fatalf("AddRig: %v", err)
}
// Verify rig config has correct prefix
if newRig.Config == nil {
t.Fatal("rig.Config is nil")
}
if newRig.Config.Prefix != "bt" {
t.Errorf("rig.Config.Prefix = %q, want %q", newRig.Config.Prefix, "bt")
}
// Verify .beads directory was created
beadsDir := filepath.Join(townRoot, "beadstest", ".beads")
if _, err := os.Stat(beadsDir); err != nil {
t.Errorf(".beads directory not found: %v", err)
}
// Verify config.yaml exists with correct prefix
configPath := filepath.Join(beadsDir, "config.yaml")
if _, err := os.Stat(configPath); err != nil {
t.Errorf(".beads/config.yaml not found: %v", err)
} else {
content, err := os.ReadFile(configPath)
if err != nil {
t.Errorf("reading config.yaml: %v", err)
} else if !strings.Contains(string(content), "prefix: bt") && !strings.Contains(string(content), "prefix:bt") {
t.Errorf("config.yaml doesn't contain expected prefix, got: %s", string(content))
}
}
}
// TestRigAddUpdatesRoutes verifies that routes.jsonl is updated
// with the new rig's route.
func TestRigAddUpdatesRoutes(t *testing.T) {
mockBdCommand(t)
townRoot := setupTestTown(t)
gitURL := createTestGitRepo(t, "routetest")
rigsPath := filepath.Join(townRoot, "mayor", "rigs.json")
rigsConfig, err := config.LoadRigsConfig(rigsPath)
if err != nil {
t.Fatalf("load rigs.json: %v", err)
}
g := git.NewGit(townRoot)
mgr := rig.NewManager(townRoot, rigsConfig, g)
newRig, err := mgr.AddRig(rig.AddRigOptions{
Name: "routetest",
GitURL: gitURL,
BeadsPrefix: "rt",
})
if err != nil {
t.Fatalf("AddRig: %v", err)
}
// Append route to routes.jsonl (this is done by the CLI command, not AddRig)
// The CLI command in runRigAdd calls beads.AppendRoute after AddRig succeeds
if newRig.Config != nil && newRig.Config.Prefix != "" {
route := beads.Route{
Prefix: newRig.Config.Prefix + "-",
Path: "routetest",
}
if err := beads.AppendRoute(townRoot, route); err != nil {
t.Fatalf("AppendRoute: %v", err)
}
}
// Save rigs config (normally done by the command)
if err := config.SaveRigsConfig(rigsPath, rigsConfig); err != nil {
t.Fatalf("save rigs.json: %v", err)
}
// Load routes and verify the new route exists
townBeadsDir := filepath.Join(townRoot, ".beads")
routes, err := beads.LoadRoutes(townBeadsDir)
if err != nil {
t.Fatalf("LoadRoutes: %v", err)
}
// Find route for our rig
var foundRoute *beads.Route
for _, r := range routes {
if r.Prefix == "rt-" {
foundRoute = &r
break
}
}
if foundRoute == nil {
t.Error("route with prefix 'rt-' not found in routes.jsonl")
t.Logf("routes: %+v", routes)
} else {
// The path should point to the rig (or mayor/rig if .beads is tracked in source)
if !strings.HasPrefix(foundRoute.Path, "routetest") {
t.Errorf("route path = %q, want prefix 'routetest'", foundRoute.Path)
}
}
}
// TestRigAddUpdatesRigsJson verifies that rigs.json is updated
// with the new rig entry.
func TestRigAddUpdatesRigsJson(t *testing.T) {
mockBdCommand(t)
townRoot := setupTestTown(t)
gitURL := createTestGitRepo(t, "jsontest")
rigsPath := filepath.Join(townRoot, "mayor", "rigs.json")
rigsConfig, err := config.LoadRigsConfig(rigsPath)
if err != nil {
t.Fatalf("load rigs.json: %v", err)
}
g := git.NewGit(townRoot)
mgr := rig.NewManager(townRoot, rigsConfig, g)
_, err = mgr.AddRig(rig.AddRigOptions{
Name: "jsontest",
GitURL: gitURL,
BeadsPrefix: "jt",
})
if err != nil {
t.Fatalf("AddRig: %v", err)
}
// Save rigs config (normally done by the command)
if err := config.SaveRigsConfig(rigsPath, rigsConfig); err != nil {
t.Fatalf("save rigs.json: %v", err)
}
// Reload and verify
rigsConfig2, err := config.LoadRigsConfig(rigsPath)
if err != nil {
t.Fatalf("reload rigs.json: %v", err)
}
entry, ok := rigsConfig2.Rigs["jsontest"]
if !ok {
t.Error("rig 'jsontest' not found in rigs.json")
t.Logf("rigs: %+v", rigsConfig2.Rigs)
} else {
if entry.GitURL != gitURL {
t.Errorf("GitURL = %q, want %q", entry.GitURL, gitURL)
}
if entry.BeadsConfig == nil {
t.Error("BeadsConfig is nil")
} else if entry.BeadsConfig.Prefix != "jt" {
t.Errorf("BeadsConfig.Prefix = %q, want %q", entry.BeadsConfig.Prefix, "jt")
}
}
}
// TestRigAddDerivesPrefix verifies that when no prefix is specified,
// one is derived from the rig name.
func TestRigAddDerivesPrefix(t *testing.T) {
mockBdCommand(t)
townRoot := setupTestTown(t)
gitURL := createTestGitRepo(t, "myproject")
rigsPath := filepath.Join(townRoot, "mayor", "rigs.json")
rigsConfig, err := config.LoadRigsConfig(rigsPath)
if err != nil {
t.Fatalf("load rigs.json: %v", err)
}
g := git.NewGit(townRoot)
mgr := rig.NewManager(townRoot, rigsConfig, g)
newRig, err := mgr.AddRig(rig.AddRigOptions{
Name: "myproject",
GitURL: gitURL,
// No BeadsPrefix - should be derived
})
if err != nil {
t.Fatalf("AddRig: %v", err)
}
// For a single-word name like "myproject", the prefix should be first 2 chars
if newRig.Config.Prefix != "my" {
t.Errorf("derived prefix = %q, want %q", newRig.Config.Prefix, "my")
}
}
// TestRigAddCreatesRigConfig verifies that config.json contains
// the correct rig configuration.
func TestRigAddCreatesRigConfig(t *testing.T) {
mockBdCommand(t)
townRoot := setupTestTown(t)
gitURL := createTestGitRepo(t, "configtest")
rigsPath := filepath.Join(townRoot, "mayor", "rigs.json")
rigsConfig, err := config.LoadRigsConfig(rigsPath)
if err != nil {
t.Fatalf("load rigs.json: %v", err)
}
g := git.NewGit(townRoot)
mgr := rig.NewManager(townRoot, rigsConfig, g)
_, err = mgr.AddRig(rig.AddRigOptions{
Name: "configtest",
GitURL: gitURL,
BeadsPrefix: "ct",
})
if err != nil {
t.Fatalf("AddRig: %v", err)
}
// Read and verify config.json
configPath := filepath.Join(townRoot, "configtest", "config.json")
data, err := os.ReadFile(configPath)
if err != nil {
t.Fatalf("reading config.json: %v", err)
}
var rigCfg rig.RigConfig
if err := json.Unmarshal(data, &rigCfg); err != nil {
t.Fatalf("parsing config.json: %v", err)
}
if rigCfg.Type != "rig" {
t.Errorf("Type = %q, want 'rig'", rigCfg.Type)
}
if rigCfg.Name != "configtest" {
t.Errorf("Name = %q, want 'configtest'", rigCfg.Name)
}
if rigCfg.GitURL != gitURL {
t.Errorf("GitURL = %q, want %q", rigCfg.GitURL, gitURL)
}
if rigCfg.Beads == nil {
t.Error("Beads config is nil")
} else if rigCfg.Beads.Prefix != "ct" {
t.Errorf("Beads.Prefix = %q, want 'ct'", rigCfg.Beads.Prefix)
}
if rigCfg.DefaultBranch == "" {
t.Error("DefaultBranch is empty")
}
}
// TestRigAddCreatesAgentDirs verifies that agent state files are created.
func TestRigAddCreatesAgentDirs(t *testing.T) {
mockBdCommand(t)
townRoot := setupTestTown(t)
gitURL := createTestGitRepo(t, "agenttest")
rigsPath := filepath.Join(townRoot, "mayor", "rigs.json")
rigsConfig, err := config.LoadRigsConfig(rigsPath)
if err != nil {
t.Fatalf("load rigs.json: %v", err)
}
g := git.NewGit(townRoot)
mgr := rig.NewManager(townRoot, rigsConfig, g)
_, err = mgr.AddRig(rig.AddRigOptions{
Name: "agenttest",
GitURL: gitURL,
BeadsPrefix: "at",
})
if err != nil {
t.Fatalf("AddRig: %v", err)
}
rigPath := filepath.Join(townRoot, "agenttest")
// Verify agent directories exist (state.json files are no longer created)
expectedDirs := []string{
"witness",
"refinery",
"mayor",
}
for _, dir := range expectedDirs {
path := filepath.Join(rigPath, dir)
info, err := os.Stat(path)
if err != nil {
t.Errorf("expected directory %s to exist: %v", dir, err)
} else if !info.IsDir() {
t.Errorf("expected %s to be a directory", dir)
}
}
}
// TestRigAddRejectsInvalidNames verifies that rig names with invalid
// characters are rejected.
func TestRigAddRejectsInvalidNames(t *testing.T) {
mockBdCommand(t)
townRoot := setupTestTown(t)
gitURL := createTestGitRepo(t, "validname")
rigsPath := filepath.Join(townRoot, "mayor", "rigs.json")
rigsConfig, err := config.LoadRigsConfig(rigsPath)
if err != nil {
t.Fatalf("load rigs.json: %v", err)
}
g := git.NewGit(townRoot)
mgr := rig.NewManager(townRoot, rigsConfig, g)
// Characters that break agent ID parsing (hyphens, dots, spaces)
// Note: underscores are allowed
invalidNames := []string{
"my-rig", // hyphens break agent ID parsing
"my.rig", // dots break parsing
"my rig", // spaces are invalid
"my-multi-rig", // multiple hyphens
}
for _, name := range invalidNames {
t.Run(name, func(t *testing.T) {
_, err := mgr.AddRig(rig.AddRigOptions{
Name: name,
GitURL: gitURL,
})
if err == nil {
t.Errorf("AddRig(%q) should have failed", name)
} else if !strings.Contains(err.Error(), "invalid characters") {
t.Errorf("AddRig(%q) error = %v, want 'invalid characters'", name, err)
}
})
}
}