Files
beads/cmd/bd/init_test.go
Steve Yegge 035b006e25 fix: Support both canonical and legacy JSONL filenames in merge driver check
The mergeDriverInstalled() function was only checking for the legacy
"beads.jsonl" filename, but installMergeDriver() writes the canonical
"issues.jsonl" filename. This caused false negatives where users with
the correct canonical configuration would be incorrectly flagged as
"not installed", potentially triggering unnecessary reinstalls.

Changes:
- Update mergeDriverInstalled() to check for both filenames
- Add test for canonical issues.jsonl filename detection
- Ensure existing correct configs are not unnecessarily overwritten

This fixes the inconsistency found during code review of bd-3sz0.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-23 20:07:26 -08:00

999 lines
29 KiB
Go

package main
import (
"bytes"
"context"
"os"
"path/filepath"
"strings"
"testing"
)
func TestInitCommand(t *testing.T) {
tests := []struct {
name string
prefix string
quiet bool
wantOutputText string
wantNoOutput bool
}{
{
name: "init with default prefix",
prefix: "",
quiet: false,
wantOutputText: "bd initialized successfully",
},
{
name: "init with custom prefix",
prefix: "myproject",
quiet: false,
wantOutputText: "myproject-1, myproject-2",
},
{
name: "init with quiet flag",
prefix: "test",
quiet: true,
wantNoOutput: true,
},
{
name: "init with prefix ending in hyphen",
prefix: "test-",
quiet: false,
wantOutputText: "test-1, test-2",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Reset global state
origDBPath := dbPath
defer func() { dbPath = origDBPath }()
dbPath = ""
// Reset Cobra command state
rootCmd.SetArgs([]string{})
initCmd.Flags().Set("prefix", "")
initCmd.Flags().Set("quiet", "false")
tmpDir := t.TempDir()
originalWd, err := os.Getwd()
if err != nil {
t.Fatalf("Failed to get working directory: %v", err)
}
defer os.Chdir(originalWd)
if err := os.Chdir(tmpDir); err != nil {
t.Fatalf("Failed to change to temp directory: %v", err)
}
// Capture output
var buf bytes.Buffer
oldStdout := os.Stdout
r, w, _ := os.Pipe()
os.Stdout = w
defer func() {
os.Stdout = oldStdout
}()
// Build command arguments
args := []string{"init"}
if tt.prefix != "" {
args = append(args, "--prefix", tt.prefix)
}
if tt.quiet {
args = append(args, "--quiet")
}
rootCmd.SetArgs(args)
// Run command
err = rootCmd.Execute()
// Restore stdout and read output
w.Close()
buf.ReadFrom(r)
os.Stdout = oldStdout
output := buf.String()
if err != nil {
t.Fatalf("init command failed: %v", err)
}
// Check output
if tt.wantNoOutput {
if output != "" {
t.Errorf("Expected no output with --quiet, got: %s", output)
}
} else if tt.wantOutputText != "" {
if !strings.Contains(output, tt.wantOutputText) {
t.Errorf("Expected output to contain %q, got: %s", tt.wantOutputText, output)
}
}
// Verify .beads directory was created
beadsDir := filepath.Join(tmpDir, ".beads")
if _, err := os.Stat(beadsDir); os.IsNotExist(err) {
t.Error(".beads directory was not created")
}
// Verify .gitignore was created with proper content
gitignorePath := filepath.Join(beadsDir, ".gitignore")
gitignoreContent, err := os.ReadFile(gitignorePath)
if err != nil {
t.Errorf(".gitignore file was not created: %v", err)
} else {
// Check for essential patterns
gitignoreStr := string(gitignoreContent)
expectedPatterns := []string{
"*.db",
"*.db?*",
"*.db-journal",
"*.db-wal",
"*.db-shm",
"daemon.log",
"daemon.pid",
"bd.sock",
"beads.base.jsonl",
"beads.left.jsonl",
"beads.right.jsonl",
"!issues.jsonl",
}
for _, pattern := range expectedPatterns {
if !strings.Contains(gitignoreStr, pattern) {
t.Errorf(".gitignore missing expected pattern: %s", pattern)
}
}
}
// Verify database was created (always beads.db now)
dbPath := filepath.Join(beadsDir, "beads.db")
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
t.Errorf("Database file was not created at %s", dbPath)
}
// Verify database has correct prefix
// Note: This database was already created by init command, just open it
store, err := openExistingTestDB(t, dbPath)
if err != nil {
t.Fatalf("Failed to open database: %v", err)
}
defer store.Close()
ctx := context.Background()
prefix, err := store.GetConfig(ctx, "issue_prefix")
if err != nil {
t.Fatalf("Failed to get issue prefix from database: %v", err)
}
expectedPrefix := tt.prefix
if expectedPrefix == "" {
expectedPrefix = filepath.Base(tmpDir)
} else {
expectedPrefix = strings.TrimRight(expectedPrefix, "-")
}
if prefix != expectedPrefix {
t.Errorf("Expected prefix %q, got %q", expectedPrefix, prefix)
}
// Verify version metadata was set
version, err := store.GetMetadata(ctx, "bd_version")
if err != nil {
t.Errorf("Failed to get bd_version metadata: %v", err)
}
if version == "" {
t.Error("bd_version metadata was not set")
}
})
}
}
// Note: Error case testing is omitted because the init command calls os.Exit()
// on errors, which makes it difficult to test in a unit test context.
func TestInitAlreadyInitialized(t *testing.T) {
// Reset global state
origDBPath := dbPath
defer func() { dbPath = origDBPath }()
dbPath = ""
tmpDir := t.TempDir()
originalWd, err := os.Getwd()
if err != nil {
t.Fatalf("Failed to get working directory: %v", err)
}
defer os.Chdir(originalWd)
if err := os.Chdir(tmpDir); err != nil {
t.Fatalf("Failed to change to temp directory: %v", err)
}
// Initialize once
rootCmd.SetArgs([]string{"init", "--prefix", "test", "--quiet"})
if err := rootCmd.Execute(); err != nil {
t.Fatalf("First init failed: %v", err)
}
// Initialize again with same prefix - should succeed (overwrites)
rootCmd.SetArgs([]string{"init", "--prefix", "test", "--quiet"})
if err := rootCmd.Execute(); err != nil {
t.Fatalf("Second init failed: %v", err)
}
// Verify database still works (always beads.db now)
dbPath := filepath.Join(tmpDir, ".beads", "beads.db")
store, err := openExistingTestDB(t, dbPath)
if err != nil {
t.Fatalf("Failed to open database: %v", err)
}
defer store.Close()
ctx := context.Background()
prefix, err := store.GetConfig(ctx, "issue_prefix")
if err != nil {
t.Fatalf("Failed to get prefix after re-init: %v", err)
}
if prefix != "test" {
t.Errorf("Expected prefix 'test', got %q", prefix)
}
}
func TestInitWithCustomDBPath(t *testing.T) {
// Save original state
origDBPath := dbPath
defer func() { dbPath = origDBPath }()
tmpDir := t.TempDir()
customDBDir := filepath.Join(tmpDir, "custom", "location")
// Change to a different directory to ensure --db flag is actually used
workDir := filepath.Join(tmpDir, "workdir")
if err := os.MkdirAll(workDir, 0750); err != nil {
t.Fatalf("Failed to create work directory: %v", err)
}
originalWd, err := os.Getwd()
if err != nil {
t.Fatalf("Failed to get working directory: %v", err)
}
defer os.Chdir(originalWd)
if err := os.Chdir(workDir); err != nil {
t.Fatalf("Failed to change to work directory: %v", err)
}
customDBPath := filepath.Join(customDBDir, "test.db")
// Test with BEADS_DB environment variable (replacing --db flag test)
t.Run("init with BEADS_DB pointing to custom path", func(t *testing.T) {
dbPath = "" // Reset global
os.Setenv("BEADS_DB", customDBPath)
defer os.Unsetenv("BEADS_DB")
rootCmd.SetArgs([]string{"init", "--prefix", "custom", "--quiet"})
if err := rootCmd.Execute(); err != nil {
t.Fatalf("Init with BEADS_DB failed: %v", err)
}
// Verify database was created at custom location
if _, err := os.Stat(customDBPath); os.IsNotExist(err) {
t.Errorf("Database was not created at custom path %s", customDBPath)
}
// Verify database works
store, err := openExistingTestDB(t, customDBPath)
if err != nil {
t.Fatalf("Failed to open database: %v", err)
}
defer store.Close()
ctx := context.Background()
prefix, err := store.GetConfig(ctx, "issue_prefix")
if err != nil {
t.Fatalf("Failed to get prefix: %v", err)
}
if prefix != "custom" {
t.Errorf("Expected prefix 'custom', got %q", prefix)
}
// Verify .beads/ directory was NOT created in work directory
if _, err := os.Stat(filepath.Join(workDir, ".beads")); err == nil {
t.Error(".beads/ directory should not be created when using BEADS_DB env var")
}
})
// Test with BEADS_DB env var
t.Run("init with BEADS_DB env var", func(t *testing.T) {
dbPath = "" // Reset global
envDBPath := filepath.Join(tmpDir, "env", "location", "env.db")
os.Setenv("BEADS_DB", envDBPath)
defer os.Unsetenv("BEADS_DB")
rootCmd.SetArgs([]string{"init", "--prefix", "envtest", "--quiet"})
if err := rootCmd.Execute(); err != nil {
t.Fatalf("Init with BEADS_DB failed: %v", err)
}
// Verify database was created at env location
if _, err := os.Stat(envDBPath); os.IsNotExist(err) {
t.Errorf("Database was not created at BEADS_DB path %s", envDBPath)
}
// Verify database works
store, err := openExistingTestDB(t, envDBPath)
if err != nil {
t.Fatalf("Failed to open database: %v", err)
}
defer store.Close()
ctx := context.Background()
prefix, err := store.GetConfig(ctx, "issue_prefix")
if err != nil {
t.Fatalf("Failed to get prefix: %v", err)
}
if prefix != "envtest" {
t.Errorf("Expected prefix 'envtest', got %q", prefix)
}
})
// Test that BEADS_DB path containing ".beads" doesn't create CWD/.beads
t.Run("init with BEADS_DB path containing .beads", func(t *testing.T) {
dbPath = "" // Reset global
// Path contains ".beads" but is outside work directory
customPath := filepath.Join(tmpDir, "storage", ".beads-backup", "test.db")
os.Setenv("BEADS_DB", customPath)
defer os.Unsetenv("BEADS_DB")
rootCmd.SetArgs([]string{"init", "--prefix", "beadstest", "--quiet"})
if err := rootCmd.Execute(); err != nil {
t.Fatalf("Init with custom .beads path failed: %v", err)
}
// Verify database was created at custom location
if _, err := os.Stat(customPath); os.IsNotExist(err) {
t.Errorf("Database was not created at custom path %s", customPath)
}
// Verify .beads/ directory was NOT created in work directory
if _, err := os.Stat(filepath.Join(workDir, ".beads")); err == nil {
t.Error(".beads/ directory should not be created in CWD when BEADS_DB path contains .beads")
}
})
// Test with multiple BEADS_DB variations
t.Run("BEADS_DB with subdirectories", func(t *testing.T) {
dbPath = "" // Reset global
envPath := filepath.Join(tmpDir, "env", "subdirs", "test.db")
os.Setenv("BEADS_DB", envPath)
defer os.Unsetenv("BEADS_DB")
rootCmd.SetArgs([]string{"init", "--prefix", "envtest2", "--quiet"})
if err := rootCmd.Execute(); err != nil {
t.Fatalf("Init with BEADS_DB subdirs failed: %v", err)
}
// Verify database was created at env location
if _, err := os.Stat(envPath); os.IsNotExist(err) {
t.Errorf("Database was not created at BEADS_DB path %s", envPath)
}
// Verify .beads/ directory was NOT created in work directory
if _, err := os.Stat(filepath.Join(workDir, ".beads")); err == nil {
t.Error(".beads/ directory should not be created in CWD when BEADS_DB is set")
}
})
}
func TestInitNoDbMode(t *testing.T) {
// Reset global state
origDBPath := dbPath
origNoDb := noDb
defer func() {
dbPath = origDBPath
noDb = origNoDb
}()
dbPath = ""
noDb = false
tmpDir := t.TempDir()
originalWd, err := os.Getwd()
if err != nil {
t.Fatalf("Failed to get working directory: %v", err)
}
defer os.Chdir(originalWd)
if err := os.Chdir(tmpDir); err != nil {
t.Fatalf("Failed to change to temp directory: %v", err)
}
// Initialize with --no-db flag
rootCmd.SetArgs([]string{"init", "--no-db", "--no-daemon", "--prefix", "test", "--quiet"})
if err := rootCmd.Execute(); err != nil {
t.Fatalf("Init with --no-db failed: %v", err)
}
// Verify issues.jsonl was created
jsonlPath := filepath.Join(tmpDir, ".beads", "issues.jsonl")
if _, err := os.Stat(jsonlPath); os.IsNotExist(err) {
t.Error("issues.jsonl was not created in --no-db mode")
}
// Verify config.yaml was created with no-db: true
configPath := filepath.Join(tmpDir, ".beads", "config.yaml")
configContent, err := os.ReadFile(configPath)
if err != nil {
t.Fatalf("Failed to read config.yaml: %v", err)
}
configStr := string(configContent)
if !strings.Contains(configStr, "no-db: true") {
t.Error("config.yaml should contain 'no-db: true' in --no-db mode")
}
// Verify subsequent command works without --no-db flag
rootCmd.SetArgs([]string{"create", "test issue", "--json"})
// Capture output to verify it worked
var buf bytes.Buffer
oldStdout := os.Stdout
r, w, _ := os.Pipe()
os.Stdout = w
err = rootCmd.Execute()
// Restore stdout and read output
w.Close()
buf.ReadFrom(r)
os.Stdout = oldStdout
if err != nil {
t.Fatalf("create command failed in no-db mode: %v", err)
}
// Verify issue was written to JSONL
jsonlContent, err := os.ReadFile(jsonlPath)
if err != nil {
t.Fatalf("Failed to read issues.jsonl: %v", err)
}
if len(jsonlContent) == 0 {
t.Error("issues.jsonl should not be empty after creating issue")
}
if !strings.Contains(string(jsonlContent), "test issue") {
t.Error("issues.jsonl should contain the created issue")
}
// Verify no SQLite database was created
dbPath := filepath.Join(tmpDir, ".beads", "beads.db")
if _, err := os.Stat(dbPath); err == nil {
t.Error("SQLite database should not be created in --no-db mode")
}
}
func TestInitMergeDriverAutoConfiguration(t *testing.T) {
t.Run("merge driver auto-configured during init", func(t *testing.T) {
// Reset global state
origDBPath := dbPath
defer func() { dbPath = origDBPath }()
dbPath = ""
tmpDir := t.TempDir()
originalWd, err := os.Getwd()
if err != nil {
t.Fatalf("Failed to get working directory: %v", err)
}
defer os.Chdir(originalWd)
if err := os.Chdir(tmpDir); err != nil {
t.Fatalf("Failed to change to temp directory: %v", err)
}
// Initialize git repo first
if err := runCommandInDir(tmpDir, "git", "init"); err != nil {
t.Fatalf("Failed to init git: %v", err)
}
// Run bd init with quiet mode
rootCmd.SetArgs([]string{"init", "--prefix", "test", "--quiet"})
if err := rootCmd.Execute(); err != nil {
t.Fatalf("Init failed: %v", err)
}
// Verify git config was set
output, err := runCommandInDirWithOutput(tmpDir, "git", "config", "merge.beads.driver")
if err != nil {
t.Fatalf("Failed to get git config: %v", err)
}
if !strings.Contains(output, "bd merge") {
t.Errorf("Expected merge driver to contain 'bd merge', got: %s", output)
}
// Verify .gitattributes was created
gitattrsPath := filepath.Join(tmpDir, ".gitattributes")
content, err := os.ReadFile(gitattrsPath)
if err != nil {
t.Fatalf("Failed to read .gitattributes: %v", err)
}
if !strings.Contains(string(content), ".beads/issues.jsonl merge=beads") {
t.Error(".gitattributes should contain merge driver configuration")
}
})
t.Run("skip merge driver with flag", func(t *testing.T) {
// Reset global state
origDBPath := dbPath
defer func() { dbPath = origDBPath }()
dbPath = ""
tmpDir := t.TempDir()
originalWd, err := os.Getwd()
if err != nil {
t.Fatalf("Failed to get working directory: %v", err)
}
defer os.Chdir(originalWd)
if err := os.Chdir(tmpDir); err != nil {
t.Fatalf("Failed to change to temp directory: %v", err)
}
// Initialize git repo first
if err := runCommandInDir(tmpDir, "git", "init"); err != nil {
t.Fatalf("Failed to init git: %v", err)
}
// Run bd init with --skip-merge-driver
rootCmd.SetArgs([]string{"init", "--prefix", "test", "--skip-merge-driver", "--quiet"})
if err := rootCmd.Execute(); err != nil {
t.Fatalf("Init failed: %v", err)
}
// Verify git config was NOT set
_, err = runCommandInDirWithOutput(tmpDir, "git", "config", "merge.beads.driver")
if err == nil {
t.Error("Expected git config to not be set with --skip-merge-driver")
}
// Verify .gitattributes was NOT created
gitattrsPath := filepath.Join(tmpDir, ".gitattributes")
if _, err := os.Stat(gitattrsPath); err == nil {
t.Error(".gitattributes should not be created with --skip-merge-driver")
}
})
t.Run("non-git repo skips merge driver silently", func(t *testing.T) {
// Reset global state
origDBPath := dbPath
defer func() { dbPath = origDBPath }()
dbPath = ""
tmpDir := t.TempDir()
originalWd, err := os.Getwd()
if err != nil {
t.Fatalf("Failed to get working directory: %v", err)
}
defer os.Chdir(originalWd)
if err := os.Chdir(tmpDir); err != nil {
t.Fatalf("Failed to change to temp directory: %v", err)
}
// DON'T initialize git repo
// Run bd init - should succeed even without git
rootCmd.SetArgs([]string{"init", "--prefix", "test", "--quiet"})
if err := rootCmd.Execute(); err != nil {
t.Fatalf("Init should succeed in non-git directory: %v", err)
}
// Verify .beads was still created
beadsDir := filepath.Join(tmpDir, ".beads")
if _, err := os.Stat(beadsDir); os.IsNotExist(err) {
t.Error(".beads directory should be created even without git")
}
})
t.Run("detect already-installed merge driver", func(t *testing.T) {
// Reset global state
origDBPath := dbPath
defer func() { dbPath = origDBPath }()
dbPath = ""
tmpDir := t.TempDir()
originalWd, err := os.Getwd()
if err != nil {
t.Fatalf("Failed to get working directory: %v", err)
}
defer os.Chdir(originalWd)
if err := os.Chdir(tmpDir); err != nil {
t.Fatalf("Failed to change to temp directory: %v", err)
}
// Initialize git repo
if err := runCommandInDir(tmpDir, "git", "init"); err != nil {
t.Fatalf("Failed to init git: %v", err)
}
// Pre-configure merge driver manually
if err := runCommandInDir(tmpDir, "git", "config", "merge.beads.driver", "bd merge %A %O %A %B"); err != nil {
t.Fatalf("Failed to set git config: %v", err)
}
// Create .gitattributes with merge driver
gitattrsPath := filepath.Join(tmpDir, ".gitattributes")
initialContent := "# Existing config\n.beads/issues.jsonl merge=beads\n"
if err := os.WriteFile(gitattrsPath, []byte(initialContent), 0644); err != nil {
t.Fatalf("Failed to create .gitattributes: %v", err)
}
// Run bd init - should detect existing config
rootCmd.SetArgs([]string{"init", "--prefix", "test", "--quiet"})
if err := rootCmd.Execute(); err != nil {
t.Fatalf("Init failed: %v", err)
}
// Verify git config still exists (not duplicated)
output, err := runCommandInDirWithOutput(tmpDir, "git", "config", "merge.beads.driver")
if err != nil {
t.Fatalf("Git config should still be set: %v", err)
}
if !strings.Contains(output, "bd merge") {
t.Errorf("Expected merge driver to contain 'bd merge', got: %s", output)
}
// Verify .gitattributes wasn't duplicated
content, err := os.ReadFile(gitattrsPath)
if err != nil {
t.Fatalf("Failed to read .gitattributes: %v", err)
}
contentStr := string(content)
// Count occurrences - should only appear once
count := strings.Count(contentStr, ".beads/issues.jsonl merge=beads")
if count != 1 {
t.Errorf("Expected .gitattributes to contain merge config exactly once, found %d times", count)
}
// Should still have the comment
if !strings.Contains(contentStr, "# Existing config") {
t.Error(".gitattributes should preserve existing content")
}
})
t.Run("append to existing .gitattributes", func(t *testing.T) {
// Reset global state
origDBPath := dbPath
defer func() { dbPath = origDBPath }()
dbPath = ""
// Reset Cobra flags
initCmd.Flags().Set("skip-merge-driver", "false")
tmpDir := t.TempDir()
originalWd, err := os.Getwd()
if err != nil {
t.Fatalf("Failed to get working directory: %v", err)
}
defer os.Chdir(originalWd)
if err := os.Chdir(tmpDir); err != nil {
t.Fatalf("Failed to change to temp directory: %v", err)
}
// Initialize git repo
if err := runCommandInDir(tmpDir, "git", "init"); err != nil {
t.Fatalf("Failed to init git: %v", err)
}
// Create .gitattributes with existing content (no newline at end)
gitattrsPath := filepath.Join(tmpDir, ".gitattributes")
existingContent := "*.txt text\n*.jpg binary"
if err := os.WriteFile(gitattrsPath, []byte(existingContent), 0644); err != nil {
t.Fatalf("Failed to create .gitattributes: %v", err)
}
// Run bd init
rootCmd.SetArgs([]string{"init", "--prefix", "test", "--quiet"})
if err := rootCmd.Execute(); err != nil {
t.Fatalf("Init failed: %v", err)
}
// Verify .gitattributes was appended to, not overwritten
content, err := os.ReadFile(gitattrsPath)
if err != nil {
t.Fatalf("Failed to read .gitattributes: %v", err)
}
contentStr := string(content)
// Should contain original content
if !strings.Contains(contentStr, "*.txt text") {
t.Error(".gitattributes should preserve original content")
}
if !strings.Contains(contentStr, "*.jpg binary") {
t.Error(".gitattributes should preserve original content")
}
// Should contain beads config
if !strings.Contains(contentStr, ".beads/issues.jsonl merge=beads") {
t.Error(".gitattributes should contain beads merge config")
}
// Beads config should come after existing content
txtIdx := strings.Index(contentStr, "*.txt")
beadsIdx := strings.Index(contentStr, ".beads/issues.jsonl")
if txtIdx >= beadsIdx {
t.Error("Beads config should be appended after existing content")
}
})
t.Run("verify git config has correct settings", func(t *testing.T) {
// Reset global state
origDBPath := dbPath
defer func() { dbPath = origDBPath }()
dbPath = ""
// Reset Cobra flags
initCmd.Flags().Set("skip-merge-driver", "false")
tmpDir := t.TempDir()
originalWd, err := os.Getwd()
if err != nil {
t.Fatalf("Failed to get working directory: %v", err)
}
defer os.Chdir(originalWd)
if err := os.Chdir(tmpDir); err != nil {
t.Fatalf("Failed to change to temp directory: %v", err)
}
// Initialize git repo
if err := runCommandInDir(tmpDir, "git", "init"); err != nil {
t.Fatalf("Failed to init git: %v", err)
}
// Run bd init
rootCmd.SetArgs([]string{"init", "--prefix", "test", "--quiet"})
if err := rootCmd.Execute(); err != nil {
t.Fatalf("Init failed: %v", err)
}
// Verify merge.beads.driver is set correctly
driver, err := runCommandInDirWithOutput(tmpDir, "git", "config", "merge.beads.driver")
if err != nil {
t.Fatalf("Failed to get merge.beads.driver: %v", err)
}
driver = strings.TrimSpace(driver)
expected := "bd merge %A %O %A %B"
if driver != expected {
t.Errorf("Expected merge.beads.driver to be %q, got %q", expected, driver)
}
// Verify merge.beads.name is set
name, err := runCommandInDirWithOutput(tmpDir, "git", "config", "merge.beads.name")
if err != nil {
t.Fatalf("Failed to get merge.beads.name: %v", err)
}
name = strings.TrimSpace(name)
if !strings.Contains(name, "bd") {
t.Errorf("Expected merge.beads.name to contain 'bd', got %q", name)
}
})
t.Run("auto-repair stale merge driver with invalid placeholders", func(t *testing.T) {
// Reset global state
origDBPath := dbPath
defer func() { dbPath = origDBPath }()
dbPath = ""
tmpDir := t.TempDir()
originalWd, err := os.Getwd()
if err != nil {
t.Fatalf("Failed to get working directory: %v", err)
}
defer os.Chdir(originalWd)
if err := os.Chdir(tmpDir); err != nil {
t.Fatalf("Failed to change to temp directory: %v", err)
}
// Initialize git repo
if err := runCommandInDir(tmpDir, "git", "init"); err != nil {
t.Fatalf("Failed to init git: %v", err)
}
// Configure stale merge driver with old invalid placeholders (%L/%R)
// This simulates a user who initialized with bd version <0.24.0
if err := runCommandInDir(tmpDir, "git", "config", "merge.beads.driver", "bd merge %L %R"); err != nil {
t.Fatalf("Failed to set stale git config: %v", err)
}
// Create .gitattributes with merge driver
gitattrsPath := filepath.Join(tmpDir, ".gitattributes")
if err := os.WriteFile(gitattrsPath, []byte(".beads/beads.jsonl merge=beads\n"), 0644); err != nil {
t.Fatalf("Failed to create .gitattributes: %v", err)
}
// Run bd init - should detect stale config and repair it
rootCmd.SetArgs([]string{"init", "--prefix", "test", "--quiet"})
if err := rootCmd.Execute(); err != nil {
t.Fatalf("Init failed: %v", err)
}
// Verify merge driver was updated to correct placeholders
driver, err := runCommandInDirWithOutput(tmpDir, "git", "config", "merge.beads.driver")
if err != nil {
t.Fatalf("Failed to get merge.beads.driver: %v", err)
}
driver = strings.TrimSpace(driver)
expected := "bd merge %A %O %A %B"
if driver != expected {
t.Errorf("Expected merge driver to be repaired to %q, got %q", expected, driver)
}
// Verify it no longer contains invalid placeholders
if strings.Contains(driver, "%L") || strings.Contains(driver, "%R") {
t.Errorf("Merge driver should not contain invalid %%L or %%R placeholders, got %q", driver)
}
})
t.Run("detect canonical issues.jsonl filename in gitattributes", func(t *testing.T) {
// Reset global state
origDBPath := dbPath
defer func() { dbPath = origDBPath }()
dbPath = ""
tmpDir := t.TempDir()
originalWd, err := os.Getwd()
if err != nil {
t.Fatalf("Failed to get working directory: %v", err)
}
defer os.Chdir(originalWd)
if err := os.Chdir(tmpDir); err != nil {
t.Fatalf("Failed to change to temp directory: %v", err)
}
// Initialize git repo
if err := runCommandInDir(tmpDir, "git", "init"); err != nil {
t.Fatalf("Failed to init git: %v", err)
}
// Pre-configure correct merge driver and canonical filename in .gitattributes
if err := runCommandInDir(tmpDir, "git", "config", "merge.beads.driver", "bd merge %A %O %A %B"); err != nil {
t.Fatalf("Failed to set git config: %v", err)
}
// Create .gitattributes with canonical filename (issues.jsonl, not beads.jsonl)
gitattrsPath := filepath.Join(tmpDir, ".gitattributes")
if err := os.WriteFile(gitattrsPath, []byte(".beads/issues.jsonl merge=beads\n"), 0644); err != nil {
t.Fatalf("Failed to create .gitattributes: %v", err)
}
// Run bd init - should detect existing correct config and NOT reinstall
rootCmd.SetArgs([]string{"init", "--prefix", "test", "--quiet"})
if err := rootCmd.Execute(); err != nil {
t.Fatalf("Init failed: %v", err)
}
// Verify merge driver is still correct (not reinstalled unnecessarily)
driver, err := runCommandInDirWithOutput(tmpDir, "git", "config", "merge.beads.driver")
if err != nil {
t.Fatalf("Failed to get merge.beads.driver: %v", err)
}
driver = strings.TrimSpace(driver)
expected := "bd merge %A %O %A %B"
if driver != expected {
t.Errorf("Expected merge driver to remain %q, got %q", expected, driver)
}
// Verify .gitattributes still has canonical filename (not overwritten)
content, err := os.ReadFile(gitattrsPath)
if err != nil {
t.Fatalf("Failed to read .gitattributes: %v", err)
}
if !strings.Contains(string(content), ".beads/issues.jsonl merge=beads") {
t.Errorf(".gitattributes should still contain canonical filename pattern")
}
})
}
// TestReadFirstIssueFromJSONL_ValidFile verifies reading first issue from valid JSONL
func TestReadFirstIssueFromJSONL_ValidFile(t *testing.T) {
tempDir := t.TempDir()
jsonlPath := filepath.Join(tempDir, "test.jsonl")
// Create test JSONL file with multiple issues
content := `{"id":"bd-1","title":"First Issue","description":"First test"}
{"id":"bd-2","title":"Second Issue","description":"Second test"}
{"id":"bd-3","title":"Third Issue","description":"Third test"}
`
if err := os.WriteFile(jsonlPath, []byte(content), 0o600); err != nil {
t.Fatalf("Failed to write test file: %v", err)
}
issue, err := readFirstIssueFromJSONL(jsonlPath)
if err != nil {
t.Fatalf("readFirstIssueFromJSONL failed: %v", err)
}
if issue == nil {
t.Fatal("Expected non-nil issue, got nil")
}
// Verify we got the FIRST issue
if issue.ID != "bd-1" {
t.Errorf("Expected ID 'bd-1', got '%s'", issue.ID)
}
if issue.Title != "First Issue" {
t.Errorf("Expected title 'First Issue', got '%s'", issue.Title)
}
if issue.Description != "First test" {
t.Errorf("Expected description 'First test', got '%s'", issue.Description)
}
}
// TestReadFirstIssueFromJSONL_EmptyLines verifies skipping empty lines
func TestReadFirstIssueFromJSONL_EmptyLines(t *testing.T) {
tempDir := t.TempDir()
jsonlPath := filepath.Join(tempDir, "test.jsonl")
// Create JSONL with empty lines before first valid issue
content := `
{"id":"bd-1","title":"First Valid Issue"}
{"id":"bd-2","title":"Second Issue"}
`
if err := os.WriteFile(jsonlPath, []byte(content), 0o600); err != nil {
t.Fatalf("Failed to write test file: %v", err)
}
issue, err := readFirstIssueFromJSONL(jsonlPath)
if err != nil {
t.Fatalf("readFirstIssueFromJSONL failed: %v", err)
}
if issue == nil {
t.Fatal("Expected non-nil issue, got nil")
}
if issue.ID != "bd-1" {
t.Errorf("Expected ID 'bd-1', got '%s'", issue.ID)
}
if issue.Title != "First Valid Issue" {
t.Errorf("Expected title 'First Valid Issue', got '%s'", issue.Title)
}
}
// TestReadFirstIssueFromJSONL_EmptyFile verifies handling of empty file
func TestReadFirstIssueFromJSONL_EmptyFile(t *testing.T) {
tempDir := t.TempDir()
jsonlPath := filepath.Join(tempDir, "empty.jsonl")
// Create empty file
if err := os.WriteFile(jsonlPath, []byte(""), 0o600); err != nil {
t.Fatalf("Failed to write test file: %v", err)
}
issue, err := readFirstIssueFromJSONL(jsonlPath)
if err != nil {
t.Fatalf("readFirstIssueFromJSONL should not error on empty file: %v", err)
}
if issue != nil {
t.Errorf("Expected nil issue for empty file, got %+v", issue)
}
}