When the global gitignore file is read-only (e.g., symlink to immutable location), print manual instructions instead of failing with an error.
1139 lines
33 KiB
Go
1139 lines
33 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-<hash>",
|
|
},
|
|
{
|
|
name: "init with quiet flag",
|
|
prefix: "test",
|
|
quiet: true,
|
|
wantNoOutput: true,
|
|
},
|
|
{
|
|
name: "init with prefix ending in hyphen",
|
|
prefix: "test-",
|
|
quiet: false,
|
|
wantOutputText: "test-<hash>",
|
|
},
|
|
}
|
|
|
|
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()
|
|
t.Chdir(tmpDir)
|
|
|
|
// 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
|
|
var err error
|
|
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()
|
|
t.Chdir(tmpDir)
|
|
|
|
// 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 and --force flag (bd-emg: safety guard)
|
|
// Without --force, init should refuse when database already exists
|
|
rootCmd.SetArgs([]string{"init", "--prefix", "test", "--quiet", "--force"})
|
|
|
|
if err := rootCmd.Execute(); err != nil {
|
|
t.Fatalf("Second init with --force 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)
|
|
}
|
|
|
|
t.Chdir(workDir)
|
|
|
|
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()
|
|
t.Chdir(tmpDir)
|
|
|
|
// 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()
|
|
t.Chdir(tmpDir)
|
|
|
|
// 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()
|
|
t.Chdir(tmpDir)
|
|
|
|
// 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 locally (use --local to avoid picking up global config)
|
|
_, err := runCommandInDirWithOutput(tmpDir, "git", "config", "--local", "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()
|
|
t.Chdir(tmpDir)
|
|
|
|
// 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()
|
|
t.Chdir(tmpDir)
|
|
|
|
// 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()
|
|
t.Chdir(tmpDir)
|
|
|
|
// 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()
|
|
t.Chdir(tmpDir)
|
|
|
|
// 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()
|
|
t.Chdir(tmpDir)
|
|
|
|
// 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()
|
|
t.Chdir(tmpDir)
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
// TestSetupClaudeSettings_InvalidJSON verifies that invalid JSON in existing
|
|
// settings.local.json returns an error instead of silently overwriting.
|
|
// This is a regression test for bd-5bj where user settings were lost.
|
|
func TestSetupClaudeSettings_InvalidJSON(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
t.Chdir(tmpDir)
|
|
|
|
// Create .claude directory
|
|
claudeDir := filepath.Join(tmpDir, ".claude")
|
|
if err := os.MkdirAll(claudeDir, 0755); err != nil {
|
|
t.Fatalf("Failed to create .claude directory: %v", err)
|
|
}
|
|
|
|
// Create settings.local.json with invalid JSON (array syntax in object context)
|
|
// This is the exact pattern that caused the bug in the user's file
|
|
invalidJSON := `{
|
|
"permissions": {
|
|
"allow": [
|
|
"Bash(python3:*)"
|
|
],
|
|
"deny": [
|
|
"_comment": "Add commands to block here"
|
|
]
|
|
}
|
|
}`
|
|
settingsPath := filepath.Join(claudeDir, "settings.local.json")
|
|
if err := os.WriteFile(settingsPath, []byte(invalidJSON), 0644); err != nil {
|
|
t.Fatalf("Failed to write invalid settings: %v", err)
|
|
}
|
|
|
|
// Call setupClaudeSettings - should return an error
|
|
var err error
|
|
err = setupClaudeSettings(false)
|
|
if err == nil {
|
|
t.Fatal("Expected error for invalid JSON, got nil")
|
|
}
|
|
|
|
// Verify the error message mentions invalid JSON
|
|
if !strings.Contains(err.Error(), "invalid JSON") {
|
|
t.Errorf("Expected error to mention 'invalid JSON', got: %v", err)
|
|
}
|
|
|
|
// Verify the original file was NOT modified
|
|
content, err := os.ReadFile(settingsPath)
|
|
if err != nil {
|
|
t.Fatalf("Failed to read settings file: %v", err)
|
|
}
|
|
|
|
if !strings.Contains(string(content), "permissions") {
|
|
t.Error("Original file content should be preserved")
|
|
}
|
|
|
|
if strings.Contains(string(content), "bd onboard") {
|
|
t.Error("File should NOT contain bd onboard prompt after error")
|
|
}
|
|
}
|
|
|
|
// TestSetupClaudeSettings_ValidJSON verifies that valid JSON is properly updated
|
|
func TestSetupClaudeSettings_ValidJSON(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
t.Chdir(tmpDir)
|
|
|
|
// Create .claude directory
|
|
claudeDir := filepath.Join(tmpDir, ".claude")
|
|
if err := os.MkdirAll(claudeDir, 0755); err != nil {
|
|
t.Fatalf("Failed to create .claude directory: %v", err)
|
|
}
|
|
|
|
// Create settings.local.json with valid JSON
|
|
validJSON := `{
|
|
"permissions": {
|
|
"allow": [
|
|
"Bash(python3:*)"
|
|
]
|
|
},
|
|
"hooks": {
|
|
"PreToolUse": []
|
|
}
|
|
}`
|
|
settingsPath := filepath.Join(claudeDir, "settings.local.json")
|
|
if err := os.WriteFile(settingsPath, []byte(validJSON), 0644); err != nil {
|
|
t.Fatalf("Failed to write valid settings: %v", err)
|
|
}
|
|
|
|
// Call setupClaudeSettings - should succeed
|
|
var err error
|
|
err = setupClaudeSettings(false)
|
|
if err != nil {
|
|
t.Fatalf("Expected no error for valid JSON, got: %v", err)
|
|
}
|
|
|
|
// Verify the file was updated with prompt AND preserved existing settings
|
|
content, err := os.ReadFile(settingsPath)
|
|
if err != nil {
|
|
t.Fatalf("Failed to read settings file: %v", err)
|
|
}
|
|
|
|
contentStr := string(content)
|
|
|
|
// Should contain the new prompt
|
|
if !strings.Contains(contentStr, "bd onboard") {
|
|
t.Error("File should contain bd onboard prompt")
|
|
}
|
|
|
|
// Should preserve existing permissions
|
|
if !strings.Contains(contentStr, "permissions") {
|
|
t.Error("File should preserve permissions section")
|
|
}
|
|
|
|
// Should preserve existing hooks
|
|
if !strings.Contains(contentStr, "hooks") {
|
|
t.Error("File should preserve hooks section")
|
|
}
|
|
|
|
if !strings.Contains(contentStr, "PreToolUse") {
|
|
t.Error("File should preserve PreToolUse hook")
|
|
}
|
|
}
|
|
|
|
// TestSetupClaudeSettings_NoExistingFile verifies behavior when no file exists
|
|
func TestSetupClaudeSettings_NoExistingFile(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
t.Chdir(tmpDir)
|
|
|
|
// Don't create .claude directory - setupClaudeSettings should create it
|
|
|
|
// Call setupClaudeSettings - should succeed
|
|
var err error
|
|
err = setupClaudeSettings(false)
|
|
if err != nil {
|
|
t.Fatalf("Expected no error when no file exists, got: %v", err)
|
|
}
|
|
|
|
// Verify the file was created with prompt
|
|
settingsPath := filepath.Join(tmpDir, ".claude", "settings.local.json")
|
|
content, err := os.ReadFile(settingsPath)
|
|
if err != nil {
|
|
t.Fatalf("Failed to read settings file: %v", err)
|
|
}
|
|
|
|
if !strings.Contains(string(content), "bd onboard") {
|
|
t.Error("File should contain bd onboard prompt")
|
|
}
|
|
}
|
|
|
|
// TestSetupGlobalGitIgnore_ReadOnly verifies graceful handling when the
|
|
// gitignore file cannot be written (prints manual instructions instead of failing).
|
|
func TestSetupGlobalGitIgnore_ReadOnly(t *testing.T) {
|
|
t.Run("read-only file", func(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
configDir := filepath.Join(tmpDir, ".config", "git")
|
|
if err := os.MkdirAll(configDir, 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
ignorePath := filepath.Join(configDir, "ignore")
|
|
if err := os.WriteFile(ignorePath, []byte("# existing\n"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Chmod(ignorePath, 0444); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer os.Chmod(ignorePath, 0644)
|
|
|
|
output := captureStdout(t, func() error {
|
|
return setupGlobalGitIgnore(tmpDir, "/test/project", false)
|
|
})
|
|
|
|
if !strings.Contains(output, "Unable to write") {
|
|
t.Error("expected instructions for manual addition")
|
|
}
|
|
if !strings.Contains(output, "/test/project/.beads/") {
|
|
t.Error("expected .beads pattern in output")
|
|
}
|
|
})
|
|
|
|
t.Run("symlink to read-only file", func(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
|
|
// Target file in a separate location
|
|
targetDir := filepath.Join(tmpDir, "target")
|
|
if err := os.MkdirAll(targetDir, 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
targetFile := filepath.Join(targetDir, "ignore")
|
|
if err := os.WriteFile(targetFile, []byte("# existing\n"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Chmod(targetFile, 0444); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer os.Chmod(targetFile, 0644)
|
|
|
|
// Symlink from expected location
|
|
configDir := filepath.Join(tmpDir, ".config", "git")
|
|
if err := os.MkdirAll(configDir, 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Symlink(targetFile, filepath.Join(configDir, "ignore")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
output := captureStdout(t, func() error {
|
|
return setupGlobalGitIgnore(tmpDir, "/test/project", false)
|
|
})
|
|
|
|
if !strings.Contains(output, "Unable to write") {
|
|
t.Error("expected instructions for manual addition")
|
|
}
|
|
if !strings.Contains(output, "/test/project/.beads/") {
|
|
t.Error("expected .beads pattern in output")
|
|
}
|
|
})
|
|
}
|
|
|
|
func captureStdout(t *testing.T, fn func() error) string {
|
|
t.Helper()
|
|
oldStdout := os.Stdout
|
|
r, w, _ := os.Pipe()
|
|
os.Stdout = w
|
|
|
|
err := fn()
|
|
|
|
w.Close()
|
|
var buf bytes.Buffer
|
|
buf.ReadFrom(r)
|
|
os.Stdout = oldStdout
|
|
|
|
if err != nil {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
return buf.String()
|
|
}
|