From add77eea845827ce2d3517cdbda9094009c6e136 Mon Sep 17 00:00:00 2001 From: Julian Knutsen Date: Mon, 12 Jan 2026 07:03:47 +0000 Subject: [PATCH] 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 Co-authored-by: Claude Opus 4.5 --- internal/cmd/beads_db_init_test.go | 419 +++++++++++++++++++++++++++++ internal/rig/manager.go | 52 ++-- 2 files changed, 451 insertions(+), 20 deletions(-) create mode 100644 internal/cmd/beads_db_init_test.go diff --git a/internal/cmd/beads_db_init_test.go b/internal/cmd/beads_db_init_test.go new file mode 100644 index 00000000..cd6dec15 --- /dev/null +++ b/internal/cmd/beads_db_init_test.go @@ -0,0 +1,419 @@ +//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) + } +} diff --git a/internal/rig/manager.go b/internal/rig/manager.go index e1e332c1..034e3a1a 100644 --- a/internal/rig/manager.go +++ b/internal/rig/manager.go @@ -232,6 +232,9 @@ func (m *Manager) AddRig(opts AddRigOptions) (*Rig, error) { return nil, fmt.Errorf("directory already exists: %s", rigPath) } + // Track whether user explicitly provided --prefix (before deriving) + userProvidedPrefix := opts.BeadsPrefix != "" + // Derive defaults if opts.BeadsPrefix == "" { opts.BeadsPrefix = deriveBeadsPrefix(opts.Name) @@ -339,35 +342,44 @@ func (m *Manager) AddRig(opts AddRigOptions) (*Rig, error) { } fmt.Printf(" ✓ Created mayor clone\n") - // Check if source repo has .beads/ with its own prefix - if so, use that prefix. - // This ensures we use the project's existing beads database instead of creating a new one. - // Without this, routing would fail when trying to access existing issues because the - // rig config would have a different prefix than what the issues actually use. - sourceBeadsConfig := filepath.Join(mayorRigPath, ".beads", "config.yaml") - if _, err := os.Stat(sourceBeadsConfig); err == nil { + // Check if source repo has tracked .beads/ directory. + // If so, we need to initialize the database (beads.db is gitignored so it doesn't exist after clone). + sourceBeadsDir := filepath.Join(mayorRigPath, ".beads") + sourceBeadsDB := filepath.Join(sourceBeadsDir, "beads.db") + if _, err := os.Stat(sourceBeadsDir); err == nil { + // Tracked beads exist - try to detect prefix from existing issues + sourceBeadsConfig := filepath.Join(sourceBeadsDir, "config.yaml") if sourcePrefix := detectBeadsPrefixFromConfig(sourceBeadsConfig); sourcePrefix != "" { fmt.Printf(" Detected existing beads prefix '%s' from source repo\n", sourcePrefix) + // Only error on mismatch if user explicitly provided --prefix + if userProvidedPrefix && opts.BeadsPrefix != sourcePrefix { + return nil, fmt.Errorf("prefix mismatch: source repo uses '%s' but --prefix '%s' was provided; use --prefix %s to match existing issues", sourcePrefix, opts.BeadsPrefix, sourcePrefix) + } + // Use detected prefix (overrides derived prefix) opts.BeadsPrefix = sourcePrefix rigConfig.Beads.Prefix = sourcePrefix // Re-save rig config with detected prefix if err := m.saveRigConfig(rigPath, rigConfig); err != nil { return nil, fmt.Errorf("updating rig config with detected prefix: %w", err) } - // Initialize bd database with the detected prefix. - // beads.db is gitignored so it doesn't exist after clone - we need to create it. - // bd init --prefix will create the database and auto-import from issues.jsonl. - sourceBeadsDB := filepath.Join(mayorRigPath, ".beads", "beads.db") - if _, err := os.Stat(sourceBeadsDB); os.IsNotExist(err) { - cmd := exec.Command("bd", "init", "--prefix", sourcePrefix) // sourcePrefix validated by isValidBeadsPrefix - cmd.Dir = mayorRigPath - if output, err := cmd.CombinedOutput(); err != nil { - fmt.Printf(" Warning: Could not init bd database: %v (%s)\n", err, strings.TrimSpace(string(output))) - } - // Configure custom types for Gas Town (beads v0.46.0+) - configCmd := exec.Command("bd", "config", "set", "types.custom", constants.BeadsCustomTypes) - configCmd.Dir = mayorRigPath - _, _ = configCmd.CombinedOutput() // Ignore errors - older beads don't need this + } else { + // Detection failed (no issues yet) - use derived/provided prefix + fmt.Printf(" Using prefix '%s' for tracked beads (no existing issues to detect from)\n", opts.BeadsPrefix) + } + + // Initialize bd database if it doesn't exist. + // beads.db is gitignored so it won't exist after clone - we need to create it. + // bd init --prefix will create the database and auto-import from issues.jsonl. + if _, err := os.Stat(sourceBeadsDB); os.IsNotExist(err) { + cmd := exec.Command("bd", "init", "--prefix", opts.BeadsPrefix) // opts.BeadsPrefix validated earlier + cmd.Dir = mayorRigPath + if output, err := cmd.CombinedOutput(); err != nil { + fmt.Printf(" Warning: Could not init bd database: %v (%s)\n", err, strings.TrimSpace(string(output))) } + // Configure custom types for Gas Town (beads v0.46.0+) + configCmd := exec.Command("bd", "config", "set", "types.custom", constants.BeadsCustomTypes) + configCmd.Dir = mayorRigPath + _, _ = configCmd.CombinedOutput() // Ignore errors - older beads don't need this } }