Files
gastown/internal/cmd/beads_db_init_test.go
Julian Knutsen add77eea84 fix(beads): init db for tracked beads after clone (#376)
When a repo with tracked .beads/ is added as a rig, the beads.db file
doesn't exist because it's gitignored. Previously, bd init was only run
if prefix detection succeeded. If there were no issues in issues.jsonl,
detection failed and bd init was never run, causing "Error: no beads
database found" when running bd commands.

Changes:
- Always run bd init when tracked beads exist but db is missing
- Detect prefix from existing issues in issues.jsonl
- Only error on prefix mismatch if user explicitly passed --prefix
- If no issues exist, use the derived/provided prefix

Fixes #72

Co-authored-by: julianknutsen <julianknutsen@users.noreply.github>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 23:03:47 -08:00

420 lines
14 KiB
Go

//go:build integration
// Package cmd contains integration tests for beads db initialization after clone.
//
// Run with: go test -tags=integration ./internal/cmd -run TestBeadsDbInitAfterClone -v
//
// Bug: GitHub Issue #72
// When a repo with tracked .beads/ is added as a rig, beads.db doesn't exist
// (it's gitignored) and bd operations fail because no one runs `bd init`.
package cmd
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
// createTrackedBeadsRepoWithIssues creates a git repo with .beads/ tracked that contains existing issues.
// This simulates a clone of a repo that has tracked beads with issues exported to issues.jsonl.
// The beads.db is NOT included (gitignored), so prefix must be detected from issues.jsonl.
func createTrackedBeadsRepoWithIssues(t *testing.T, path, prefix string, numIssues int) {
t.Helper()
// Create directory
if err := os.MkdirAll(path, 0755); err != nil {
t.Fatalf("mkdir repo: %v", err)
}
// Initialize git repo with explicit main branch
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 = path
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git %v: %v\n%s", args, err, out)
}
}
// Create initial file and commit (so we have something before beads)
readmePath := filepath.Join(path, "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 = path
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git %v: %v\n%s", args, err, out)
}
}
// Initialize beads
beadsDir := filepath.Join(path, ".beads")
if err := os.MkdirAll(beadsDir, 0755); err != nil {
t.Fatalf("mkdir .beads: %v", err)
}
// Run bd init
cmd := exec.Command("bd", "--no-daemon", "init", "--prefix", prefix)
cmd.Dir = path
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("bd init failed: %v\nOutput: %s", err, output)
}
// Create issues
for i := 1; i <= numIssues; i++ {
cmd = exec.Command("bd", "--no-daemon", "-q", "create",
"--type", "task", "--title", fmt.Sprintf("Test issue %d", i))
cmd.Dir = path
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("bd create issue %d failed: %v\nOutput: %s", i, err, output)
}
}
// Add .beads to git (simulating tracked beads)
cmd = exec.Command("git", "add", ".beads")
cmd.Dir = path
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git add .beads: %v\n%s", err, out)
}
cmd = exec.Command("git", "commit", "-m", "Add beads with issues")
cmd.Dir = path
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git commit beads: %v\n%s", err, out)
}
// Remove beads.db to simulate what a clone would look like
// (beads.db is gitignored, so cloned repos don't have it)
dbPath := filepath.Join(beadsDir, "beads.db")
if err := os.Remove(dbPath); err != nil {
t.Fatalf("remove beads.db: %v", err)
}
}
// TestBeadsDbInitAfterClone tests that when a tracked beads repo is added as a rig,
// the beads database is properly initialized even though beads.db doesn't exist.
func TestBeadsDbInitAfterClone(t *testing.T) {
// Skip if bd is not available
if _, err := exec.LookPath("bd"); err != nil {
t.Skip("bd not installed, skipping test")
}
tmpDir := t.TempDir()
gtBinary := buildGT(t)
t.Run("TrackedRepoWithExistingPrefix", func(t *testing.T) {
// GitHub Issue #72: gt rig add should detect existing prefix from tracked beads
// https://github.com/steveyegge/gastown/issues/72
//
// This tests that when a tracked beads repo has existing issues in issues.jsonl,
// gt rig add can detect the prefix from those issues WITHOUT --prefix flag.
townRoot := filepath.Join(tmpDir, "town-prefix-test")
reposDir := filepath.Join(tmpDir, "repos")
os.MkdirAll(reposDir, 0755)
// Create a repo with existing beads prefix "existing-prefix" AND issues
// This creates issues.jsonl with issues like "existing-prefix-1", etc.
existingRepo := filepath.Join(reposDir, "existing-repo")
createTrackedBeadsRepoWithIssues(t, existingRepo, "existing-prefix", 3)
// Install town
cmd := exec.Command(gtBinary, "install", townRoot, "--name", "prefix-test")
cmd.Env = append(os.Environ(), "HOME="+tmpDir)
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("gt install failed: %v\nOutput: %s", err, output)
}
// Add rig WITHOUT specifying --prefix - should detect "existing-prefix" from issues.jsonl
cmd = exec.Command(gtBinary, "rig", "add", "myrig", existingRepo)
cmd.Dir = townRoot
cmd.Env = append(os.Environ(), "HOME="+tmpDir)
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("gt rig add failed: %v\nOutput: %s", err, output)
}
// Verify routes.jsonl has the prefix
routesContent, err := os.ReadFile(filepath.Join(townRoot, ".beads", "routes.jsonl"))
if err != nil {
t.Fatalf("read routes.jsonl: %v", err)
}
if !strings.Contains(string(routesContent), `"prefix":"existing-prefix-"`) {
t.Errorf("routes.jsonl should contain existing-prefix-, got:\n%s", routesContent)
}
// NOW TRY TO USE bd - this is the key test for the bug
// Without the fix, beads.db doesn't exist and bd operations fail
rigPath := filepath.Join(townRoot, "myrig", "mayor", "rig")
cmd = exec.Command("bd", "--no-daemon", "--json", "-q", "create",
"--type", "task", "--title", "test-from-rig")
cmd.Dir = rigPath
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("bd create failed (bug!): %v\nOutput: %s\n\nThis is the bug: beads.db doesn't exist after clone because bd init was never run", err, output)
}
var result struct {
ID string `json:"id"`
}
if err := json.Unmarshal(output, &result); err != nil {
t.Fatalf("parse output: %v", err)
}
if !strings.HasPrefix(result.ID, "existing-prefix-") {
t.Errorf("expected existing-prefix- prefix, got %s", result.ID)
}
})
t.Run("TrackedRepoWithNoIssuesRequiresPrefix", func(t *testing.T) {
// Regression test: When a tracked beads repo has NO issues (fresh init),
// gt rig add must use the --prefix flag since there's nothing to detect from.
townRoot := filepath.Join(tmpDir, "town-no-issues")
reposDir := filepath.Join(tmpDir, "repos-no-issues")
os.MkdirAll(reposDir, 0755)
// Create a tracked beads repo with NO issues (just bd init)
emptyRepo := filepath.Join(reposDir, "empty-repo")
createTrackedBeadsRepoWithNoIssues(t, emptyRepo, "empty-prefix")
// Install town
cmd := exec.Command(gtBinary, "install", townRoot, "--name", "no-issues-test")
cmd.Env = append(os.Environ(), "HOME="+tmpDir)
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("gt install failed: %v\nOutput: %s", err, output)
}
// Add rig WITH --prefix since we can't detect from empty issues.jsonl
cmd = exec.Command(gtBinary, "rig", "add", "emptyrig", emptyRepo, "--prefix", "empty-prefix")
cmd.Dir = townRoot
cmd.Env = append(os.Environ(), "HOME="+tmpDir)
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("gt rig add with --prefix failed: %v\nOutput: %s", err, output)
}
// Verify routes.jsonl has the prefix
routesContent, err := os.ReadFile(filepath.Join(townRoot, ".beads", "routes.jsonl"))
if err != nil {
t.Fatalf("read routes.jsonl: %v", err)
}
if !strings.Contains(string(routesContent), `"prefix":"empty-prefix-"`) {
t.Errorf("routes.jsonl should contain empty-prefix-, got:\n%s", routesContent)
}
// Verify bd operations work with the configured prefix
rigPath := filepath.Join(townRoot, "emptyrig", "mayor", "rig")
cmd = exec.Command("bd", "--no-daemon", "--json", "-q", "create",
"--type", "task", "--title", "test-from-empty-repo")
cmd.Dir = rigPath
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("bd create failed: %v\nOutput: %s", err, output)
}
var result struct {
ID string `json:"id"`
}
if err := json.Unmarshal(output, &result); err != nil {
t.Fatalf("parse output: %v", err)
}
if !strings.HasPrefix(result.ID, "empty-prefix-") {
t.Errorf("expected empty-prefix- prefix, got %s", result.ID)
}
})
t.Run("TrackedRepoWithPrefixMismatchErrors", func(t *testing.T) {
// Test that when --prefix is explicitly provided but doesn't match
// the prefix detected from existing issues, gt rig add fails with an error.
townRoot := filepath.Join(tmpDir, "town-mismatch")
reposDir := filepath.Join(tmpDir, "repos-mismatch")
os.MkdirAll(reposDir, 0755)
// Create a repo with existing beads prefix "real-prefix" with issues
mismatchRepo := filepath.Join(reposDir, "mismatch-repo")
createTrackedBeadsRepoWithIssues(t, mismatchRepo, "real-prefix", 2)
// Install town
cmd := exec.Command(gtBinary, "install", townRoot, "--name", "mismatch-test")
cmd.Env = append(os.Environ(), "HOME="+tmpDir)
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("gt install failed: %v\nOutput: %s", err, output)
}
// Add rig with WRONG --prefix - should fail
cmd = exec.Command(gtBinary, "rig", "add", "mismatchrig", mismatchRepo, "--prefix", "wrong-prefix")
cmd.Dir = townRoot
cmd.Env = append(os.Environ(), "HOME="+tmpDir)
output, err := cmd.CombinedOutput()
// Should fail
if err == nil {
t.Fatalf("gt rig add should have failed with prefix mismatch, but succeeded.\nOutput: %s", output)
}
// Verify error message mentions the mismatch
outputStr := string(output)
if !strings.Contains(outputStr, "prefix mismatch") {
t.Errorf("expected 'prefix mismatch' in error, got:\n%s", outputStr)
}
if !strings.Contains(outputStr, "real-prefix") {
t.Errorf("expected 'real-prefix' (detected) in error, got:\n%s", outputStr)
}
if !strings.Contains(outputStr, "wrong-prefix") {
t.Errorf("expected 'wrong-prefix' (provided) in error, got:\n%s", outputStr)
}
})
t.Run("TrackedRepoWithNoIssuesFallsBackToDerivedPrefix", func(t *testing.T) {
// Test the fallback behavior: when a tracked beads repo has NO issues
// and NO --prefix is provided, gt rig add should derive prefix from rig name.
townRoot := filepath.Join(tmpDir, "town-derived")
reposDir := filepath.Join(tmpDir, "repos-derived")
os.MkdirAll(reposDir, 0755)
// Create a tracked beads repo with NO issues
derivedRepo := filepath.Join(reposDir, "derived-repo")
createTrackedBeadsRepoWithNoIssues(t, derivedRepo, "original-prefix")
// Install town
cmd := exec.Command(gtBinary, "install", townRoot, "--name", "derived-test")
cmd.Env = append(os.Environ(), "HOME="+tmpDir)
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("gt install failed: %v\nOutput: %s", err, output)
}
// Add rig WITHOUT --prefix - should derive from rig name "testrig"
// deriveBeadsPrefix("testrig") should produce some abbreviation
cmd = exec.Command(gtBinary, "rig", "add", "testrig", derivedRepo)
cmd.Dir = townRoot
cmd.Env = append(os.Environ(), "HOME="+tmpDir)
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("gt rig add (no --prefix) failed: %v\nOutput: %s", err, output)
}
// The output should mention "Using prefix" since detection failed
if !strings.Contains(string(output), "Using prefix") {
t.Logf("Output: %s", output)
}
// Verify bd operations work - the key test is that beads.db was initialized
rigPath := filepath.Join(townRoot, "testrig", "mayor", "rig")
cmd = exec.Command("bd", "--no-daemon", "--json", "-q", "create",
"--type", "task", "--title", "test-derived-prefix")
cmd.Dir = rigPath
output, err = cmd.CombinedOutput()
if err != nil {
t.Fatalf("bd create failed (beads.db not initialized?): %v\nOutput: %s", err, output)
}
var result struct {
ID string `json:"id"`
}
if err := json.Unmarshal(output, &result); err != nil {
t.Fatalf("parse output: %v", err)
}
// The ID should have SOME prefix (derived from "testrig")
// We don't care exactly what it is, just that bd works
if result.ID == "" {
t.Error("expected non-empty issue ID")
}
t.Logf("Created issue with derived prefix: %s", result.ID)
})
}
// createTrackedBeadsRepoWithNoIssues creates a git repo with .beads/ tracked but NO issues.
// This simulates a fresh bd init that was committed before any issues were created.
func createTrackedBeadsRepoWithNoIssues(t *testing.T, path, prefix string) {
t.Helper()
// Create directory
if err := os.MkdirAll(path, 0755); err != nil {
t.Fatalf("mkdir repo: %v", err)
}
// Initialize git repo with explicit main branch
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 = path
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(path, "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 = path
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git %v: %v\n%s", args, err, out)
}
}
// Initialize beads
beadsDir := filepath.Join(path, ".beads")
if err := os.MkdirAll(beadsDir, 0755); err != nil {
t.Fatalf("mkdir .beads: %v", err)
}
// Run bd init (creates beads.db but no issues)
cmd := exec.Command("bd", "--no-daemon", "init", "--prefix", prefix)
cmd.Dir = path
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("bd init failed: %v\nOutput: %s", err, output)
}
// Add .beads to git (simulating tracked beads)
cmd = exec.Command("git", "add", ".beads")
cmd.Dir = path
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git add .beads: %v\n%s", err, out)
}
cmd = exec.Command("git", "commit", "-m", "Add beads (no issues)")
cmd.Dir = path
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git commit beads: %v\n%s", err, out)
}
// Remove beads.db to simulate what a clone would look like
dbPath := filepath.Join(beadsDir, "beads.db")
if err := os.Remove(dbPath); err != nil {
t.Fatalf("remove beads.db: %v", err)
}
}