* fix(sync): read sync.mode from yaml first, then database bd config set sync.mode writes to config.yaml (because sync.* is a yaml-only prefix), but GetSyncMode() only read from the database. This caused dolt-native mode to be ignored - JSONL export still happened because the database had no sync.mode value. Now GetSyncMode() checks config.yaml first (via config.GetSyncMode()), falling back to database for backward compatibility. Fixes: oss-5ca279 * fix(init): respect BEADS_DIR environment variable Problem: - `bd init` ignored BEADS_DIR when checking for existing data - `bd init` created database at CWD/.beads instead of BEADS_DIR - Contributor wizard used ~/.beads-planning as default, ignoring BEADS_DIR Solution: - Add BEADS_DIR check in checkExistingBeadsData() (matches FindBeadsDir pattern) - Compute beadsDirForInit early, before initDBPath determination - Use BEADS_DIR as default in contributor wizard when set - Preserve precedence: --db > BEADS_DB > BEADS_DIR > default Impact: - Users with BEADS_DIR set now get consistent behavior across all bd commands - ACF-style fork tracking (external .beads directory) now works correctly Fixes: steveyegge/beads#??? * fix(doctor): respect BEADS_DIR environment variable Also updates documentation to reflect BEADS_DIR support in init and doctor. Changes: - doctor.go: Check BEADS_DIR before falling back to CWD - doctor_test.go: Add tests for BEADS_DIR path resolution - WORKTREES.md: Document simplified BEADS_DIR+init workflow - CONTRIBUTOR_NAMESPACE_ISOLATION.md: Note init/doctor BEADS_DIR support * test(init): add BEADS_DB > BEADS_DIR precedence test Verifies that BEADS_DB env var takes precedence over BEADS_DIR when both are set, ensuring the documented precedence order: --db > BEADS_DB > BEADS_DIR > default * chore: fill in GH#1277 placeholder in sync_mode comment
2054 lines
63 KiB
Go
2054 lines
63 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/steveyegge/beads/internal/beads"
|
|
"github.com/steveyegge/beads/internal/config"
|
|
"github.com/steveyegge/beads/internal/git"
|
|
"github.com/steveyegge/beads/internal/storage/sqlite"
|
|
)
|
|
|
|
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",
|
|
"Do NOT add negation patterns", // Comment explaining fork protection
|
|
}
|
|
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.
|
|
// GH#807: Rejection of main/master as sync branch is tested at unit level in
|
|
// internal/syncbranch/syncbranch_test.go (TestValidateSyncBranchName, TestSet).
|
|
|
|
// TestInitWithSyncBranch verifies that --branch flag correctly sets sync.branch
|
|
// GH#807: Also verifies that valid sync branches work (rejection is tested at unit level)
|
|
func TestInitWithSyncBranch(t *testing.T) {
|
|
// Reset global state
|
|
origDBPath := dbPath
|
|
defer func() { dbPath = origDBPath }()
|
|
dbPath = ""
|
|
|
|
// Reset Cobra flags
|
|
initCmd.Flags().Set("branch", "")
|
|
|
|
tmpDir := t.TempDir()
|
|
t.Chdir(tmpDir)
|
|
|
|
// Initialize git repo first (needed for sync branch to make sense)
|
|
if err := runCommandInDir(tmpDir, "git", "init", "--initial-branch=dev"); err != nil {
|
|
t.Fatalf("Failed to init git: %v", err)
|
|
}
|
|
|
|
// Run bd init with --branch flag
|
|
rootCmd.SetArgs([]string{"init", "--prefix", "test", "--branch", "beads-sync", "--quiet"})
|
|
if err := rootCmd.Execute(); err != nil {
|
|
t.Fatalf("Init with --branch failed: %v", err)
|
|
}
|
|
|
|
// Verify database was created
|
|
dbFilePath := filepath.Join(tmpDir, ".beads", "beads.db")
|
|
store, err := openExistingTestDB(t, dbFilePath)
|
|
if err != nil {
|
|
t.Fatalf("Failed to open database: %v", err)
|
|
}
|
|
defer store.Close()
|
|
|
|
// Verify sync.branch was set correctly
|
|
ctx := context.Background()
|
|
syncBranch, err := store.GetConfig(ctx, "sync.branch")
|
|
if err != nil {
|
|
t.Fatalf("Failed to get sync.branch from database: %v", err)
|
|
}
|
|
if syncBranch != "beads-sync" {
|
|
t.Errorf("Expected sync.branch 'beads-sync', got %q", syncBranch)
|
|
}
|
|
}
|
|
|
|
// TestInitWithSyncBranchSetsGitExclude verifies that init with --branch sets up
|
|
// .git/info/exclude to hide untracked JSONL files from git status.
|
|
// This fixes the issue where fresh clones show .beads/issues.jsonl as modified.
|
|
func TestInitWithSyncBranchSetsGitExclude(t *testing.T) {
|
|
// Reset global state
|
|
origDBPath := dbPath
|
|
defer func() { dbPath = origDBPath }()
|
|
dbPath = ""
|
|
|
|
// Reset Cobra flags
|
|
initCmd.Flags().Set("branch", "")
|
|
|
|
tmpDir := t.TempDir()
|
|
t.Chdir(tmpDir)
|
|
|
|
// Initialize git repo
|
|
if err := runCommandInDir(tmpDir, "git", "init", "--initial-branch=dev"); err != nil {
|
|
t.Fatalf("Failed to init git: %v", err)
|
|
}
|
|
// Configure git user for commits
|
|
_ = runCommandInDir(tmpDir, "git", "config", "user.email", "test@test.com")
|
|
_ = runCommandInDir(tmpDir, "git", "config", "user.name", "Test")
|
|
|
|
// Run bd init with --branch flag
|
|
rootCmd.SetArgs([]string{"init", "--prefix", "test", "--branch", "beads-sync", "--quiet"})
|
|
if err := rootCmd.Execute(); err != nil {
|
|
t.Fatalf("Init with --branch failed: %v", err)
|
|
}
|
|
|
|
// Verify .git/info/exclude contains the JSONL patterns
|
|
// (On fresh init, files are untracked so they go to exclude instead of index flags)
|
|
// Note: issues.jsonl only exists after first export, but interactions.jsonl is always created
|
|
excludePath := filepath.Join(tmpDir, ".git", "info", "exclude")
|
|
content, err := os.ReadFile(excludePath)
|
|
if err != nil {
|
|
t.Fatalf("Failed to read .git/info/exclude: %v", err)
|
|
}
|
|
|
|
excludeContent := string(content)
|
|
if !strings.Contains(excludeContent, ".beads/interactions.jsonl") {
|
|
t.Errorf("Expected .git/info/exclude to contain '.beads/interactions.jsonl', got:\n%s", excludeContent)
|
|
}
|
|
}
|
|
|
|
// TestInitWithExistingSyncBranchConfig verifies that init without --branch flag
|
|
// still sets git index flags when sync-branch is already configured in config.yaml.
|
|
// This is the "fresh clone" scenario where config.yaml exists from the clone.
|
|
func TestInitWithExistingSyncBranchConfig(t *testing.T) {
|
|
// Reset global state
|
|
origDBPath := dbPath
|
|
defer func() { dbPath = origDBPath }()
|
|
dbPath = ""
|
|
|
|
// Reset Cobra flags
|
|
initCmd.Flags().Set("branch", "")
|
|
|
|
tmpDir := t.TempDir()
|
|
t.Chdir(tmpDir)
|
|
|
|
// Initialize git repo
|
|
if err := runCommandInDir(tmpDir, "git", "init", "--initial-branch=dev"); err != nil {
|
|
t.Fatalf("Failed to init git: %v", err)
|
|
}
|
|
_ = runCommandInDir(tmpDir, "git", "config", "user.email", "test@test.com")
|
|
_ = runCommandInDir(tmpDir, "git", "config", "user.name", "Test")
|
|
|
|
// Create .beads directory with config.yaml containing sync-branch (simulating a clone)
|
|
beadsDir := filepath.Join(tmpDir, ".beads")
|
|
if err := os.MkdirAll(beadsDir, 0755); err != nil {
|
|
t.Fatalf("Failed to create .beads dir: %v", err)
|
|
}
|
|
configYaml := `sync-branch: "beads-sync"
|
|
`
|
|
if err := os.WriteFile(filepath.Join(beadsDir, "config.yaml"), []byte(configYaml), 0644); err != nil {
|
|
t.Fatalf("Failed to write config.yaml: %v", err)
|
|
}
|
|
// Create interactions.jsonl (normally exists in cloned repos)
|
|
if err := os.WriteFile(filepath.Join(beadsDir, "interactions.jsonl"), []byte{}, 0644); err != nil {
|
|
t.Fatalf("Failed to write interactions.jsonl: %v", err)
|
|
}
|
|
|
|
// Run bd init WITHOUT --branch flag (sync-branch already in config.yaml)
|
|
rootCmd.SetArgs([]string{"init", "--prefix", "test", "--quiet", "--force"})
|
|
if err := rootCmd.Execute(); err != nil {
|
|
t.Fatalf("Init failed: %v", err)
|
|
}
|
|
|
|
// Verify .git/info/exclude contains the JSONL patterns
|
|
excludePath := filepath.Join(tmpDir, ".git", "info", "exclude")
|
|
content, err := os.ReadFile(excludePath)
|
|
if err != nil {
|
|
t.Fatalf("Failed to read .git/info/exclude: %v", err)
|
|
}
|
|
|
|
excludeContent := string(content)
|
|
if !strings.Contains(excludeContent, ".beads/interactions.jsonl") {
|
|
t.Errorf("Expected .git/info/exclude to contain '.beads/interactions.jsonl' when sync-branch is in config.yaml, got:\n%s", excludeContent)
|
|
}
|
|
}
|
|
|
|
// TestInitWithoutBranchFlag verifies that sync.branch is NOT auto-set when --branch is omitted
|
|
// GH#807: This was the root cause - init was auto-detecting current branch (e.g., main)
|
|
func TestInitWithoutBranchFlag(t *testing.T) {
|
|
// Reset global state
|
|
origDBPath := dbPath
|
|
defer func() { dbPath = origDBPath }()
|
|
dbPath = ""
|
|
|
|
// Reset Cobra flags
|
|
initCmd.Flags().Set("branch", "")
|
|
|
|
tmpDir := t.TempDir()
|
|
t.Chdir(tmpDir)
|
|
|
|
// Initialize git repo on 'main' branch
|
|
if err := runCommandInDir(tmpDir, "git", "init", "--initial-branch=main"); err != nil {
|
|
t.Fatalf("Failed to init git: %v", err)
|
|
}
|
|
|
|
// Run bd init WITHOUT --branch flag
|
|
rootCmd.SetArgs([]string{"init", "--prefix", "test", "--quiet"})
|
|
if err := rootCmd.Execute(); err != nil {
|
|
t.Fatalf("Init failed: %v", err)
|
|
}
|
|
|
|
// Verify database was created
|
|
dbFilePath := filepath.Join(tmpDir, ".beads", "beads.db")
|
|
store, err := openExistingTestDB(t, dbFilePath)
|
|
if err != nil {
|
|
t.Fatalf("Failed to open database: %v", err)
|
|
}
|
|
defer store.Close()
|
|
|
|
// Verify sync.branch was NOT set (empty = use current branch directly)
|
|
ctx := context.Background()
|
|
syncBranch, err := store.GetConfig(ctx, "sync.branch")
|
|
if err != nil {
|
|
t.Fatalf("Failed to get sync.branch from database: %v", err)
|
|
}
|
|
if syncBranch != "" {
|
|
t.Errorf("Expected sync.branch to be empty (not auto-detected), got %q", syncBranch)
|
|
}
|
|
}
|
|
|
|
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
|
|
|
|
// Reset Cobra flags - critical for --no-db to work correctly
|
|
rootCmd.PersistentFlags().Set("no-db", "false")
|
|
|
|
tmpDir := t.TempDir()
|
|
t.Chdir(tmpDir)
|
|
|
|
// Set BEADS_DIR to prevent git repo detection from finding project's .beads
|
|
origBeadsDir := os.Getenv("BEADS_DIR")
|
|
os.Setenv("BEADS_DIR", filepath.Join(tmpDir, ".beads"))
|
|
// Reset caches so RepoContext picks up new BEADS_DIR and CWD
|
|
beads.ResetCaches()
|
|
git.ResetCaches()
|
|
defer func() {
|
|
if origBeadsDir != "" {
|
|
os.Setenv("BEADS_DIR", origBeadsDir)
|
|
} else {
|
|
os.Unsetenv("BEADS_DIR")
|
|
}
|
|
// Reset caches on cleanup too
|
|
beads.ResetCaches()
|
|
git.ResetCaches()
|
|
}()
|
|
|
|
// Initialize with --no-db flag
|
|
rootCmd.SetArgs([]string{"init", "--no-db", "--no-daemon", "--prefix", "test", "--quiet"})
|
|
|
|
t.Logf("DEBUG: noDb before Execute=%v", noDb)
|
|
|
|
if err := rootCmd.Execute(); err != nil {
|
|
t.Fatalf("Init with --no-db failed: %v", err)
|
|
}
|
|
|
|
t.Logf("DEBUG: noDb after Execute=%v", noDb)
|
|
|
|
// Debug: Check where files were created
|
|
beadsDirEnv := os.Getenv("BEADS_DIR")
|
|
t.Logf("DEBUG: tmpDir=%s", tmpDir)
|
|
t.Logf("DEBUG: BEADS_DIR=%s", beadsDirEnv)
|
|
t.Logf("DEBUG: CWD=%s", func() string { cwd, _ := os.Getwd(); return cwd }())
|
|
|
|
// Check what files exist in tmpDir
|
|
entries, _ := os.ReadDir(tmpDir)
|
|
t.Logf("DEBUG: entries in tmpDir: %v", entries)
|
|
if beadsDirEnv != "" {
|
|
beadsEntries, err := os.ReadDir(beadsDirEnv)
|
|
t.Logf("DEBUG: entries in BEADS_DIR: %v (err: %v)", beadsEntries, err)
|
|
}
|
|
|
|
// Verify issues.jsonl was created
|
|
jsonlPath := filepath.Join(tmpDir, ".beads", "issues.jsonl")
|
|
if _, err := os.Stat(jsonlPath); os.IsNotExist(err) {
|
|
// Also check at BEADS_DIR directly
|
|
beadsDirJsonlPath := filepath.Join(beadsDirEnv, "issues.jsonl")
|
|
if _, err2 := os.Stat(beadsDirJsonlPath); err2 == nil {
|
|
t.Logf("DEBUG: issues.jsonl found at BEADS_DIR path: %s", beadsDirJsonlPath)
|
|
}
|
|
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")
|
|
}
|
|
if !strings.Contains(configStr, "issue-prefix:") {
|
|
t.Error("config.yaml should contain issue-prefix in --no-db mode")
|
|
}
|
|
|
|
// Reset config so it picks up the newly created config.yaml
|
|
// (simulates a new process invocation which would load fresh config)
|
|
config.ResetForTesting()
|
|
if err := config.Initialize(); err != nil {
|
|
t.Fatalf("Failed to reinitialize config: %v", err)
|
|
}
|
|
|
|
// Verify config has correct values
|
|
if !config.GetBool("no-db") {
|
|
t.Error("config should have no-db=true after init --no-db")
|
|
}
|
|
if config.GetString("issue-prefix") != "test" {
|
|
t.Errorf("config should have issue-prefix='test', got %q", config.GetString("issue-prefix"))
|
|
}
|
|
|
|
// NOTE: Testing subsequent command execution in the same process is complex
|
|
// due to cobra's flag caching and global state. The key functionality
|
|
// (init creating proper config.yaml for no-db mode) is verified above.
|
|
// Real-world usage works correctly since each command is a fresh process.
|
|
|
|
// 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")
|
|
}
|
|
}
|
|
|
|
// TestInitBranchPersistsToConfigYaml verifies that --branch flag persists to config.yaml
|
|
// GH#927 Bug 3: The --branch flag sets sync.branch in database but NOT in config.yaml.
|
|
// This matters because config.yaml is version-controlled and shared across clones,
|
|
// while the database is local and gitignored.
|
|
func TestInitBranchPersistsToConfigYaml(t *testing.T) {
|
|
// Reset global state
|
|
origDBPath := dbPath
|
|
defer func() { dbPath = origDBPath }()
|
|
dbPath = ""
|
|
|
|
// Reset Cobra flags
|
|
initCmd.Flags().Set("branch", "")
|
|
|
|
tmpDir := t.TempDir()
|
|
t.Chdir(tmpDir)
|
|
|
|
// Initialize git repo first (needed for sync branch)
|
|
if err := runCommandInDir(tmpDir, "git", "init", "--initial-branch=dev"); err != nil {
|
|
t.Fatalf("Failed to init git: %v", err)
|
|
}
|
|
|
|
// Run bd init with --branch flag
|
|
rootCmd.SetArgs([]string{"init", "--prefix", "test", "--branch", "beads-sync", "--quiet"})
|
|
if err := rootCmd.Execute(); err != nil {
|
|
t.Fatalf("Init with --branch failed: %v", err)
|
|
}
|
|
|
|
// Read config.yaml and verify sync-branch is uncommented
|
|
configPath := filepath.Join(tmpDir, ".beads", "config.yaml")
|
|
content, err := os.ReadFile(configPath)
|
|
if err != nil {
|
|
t.Fatalf("Failed to read config.yaml: %v", err)
|
|
}
|
|
|
|
configStr := string(content)
|
|
|
|
// The bug: sync-branch remains commented as "# sync-branch:" instead of "sync-branch:"
|
|
// This test should FAIL on the current codebase to prove the bug exists
|
|
if strings.Contains(configStr, "# sync-branch:") && !strings.Contains(configStr, "\nsync-branch:") {
|
|
t.Errorf("BUG: --branch flag did not persist to config.yaml\n" +
|
|
"Expected uncommented 'sync-branch: \"beads-sync\"'\n" +
|
|
"Got commented '# sync-branch:' (only set in database, not config.yaml)")
|
|
}
|
|
|
|
// Verify the uncommented line exists with correct value
|
|
if !strings.Contains(configStr, "sync-branch: \"beads-sync\"") {
|
|
t.Errorf("config.yaml should contain 'sync-branch: \"beads-sync\"', got:\n%s", configStr)
|
|
}
|
|
}
|
|
|
|
// TestInitReinitWithBranch verifies that --branch flag works on reinit
|
|
// GH#927: When reinitializing with --branch, config.yaml should be updated even if it exists
|
|
func TestInitReinitWithBranch(t *testing.T) {
|
|
// Reset global state
|
|
origDBPath := dbPath
|
|
defer func() { dbPath = origDBPath }()
|
|
dbPath = ""
|
|
|
|
// Reset Cobra flags
|
|
initCmd.Flags().Set("branch", "")
|
|
initCmd.Flags().Set("force", "false")
|
|
|
|
tmpDir := t.TempDir()
|
|
t.Chdir(tmpDir)
|
|
|
|
// Initialize git repo first
|
|
if err := runCommandInDir(tmpDir, "git", "init", "--initial-branch=dev"); err != nil {
|
|
t.Fatalf("Failed to init git: %v", err)
|
|
}
|
|
|
|
// First init WITHOUT --branch (creates config.yaml with commented sync-branch)
|
|
rootCmd.SetArgs([]string{"init", "--prefix", "test", "--quiet"})
|
|
if err := rootCmd.Execute(); err != nil {
|
|
t.Fatalf("First init failed: %v", err)
|
|
}
|
|
|
|
// Verify config.yaml has commented sync-branch initially
|
|
configPath := filepath.Join(tmpDir, ".beads", "config.yaml")
|
|
content, err := os.ReadFile(configPath)
|
|
if err != nil {
|
|
t.Fatalf("Failed to read config.yaml: %v", err)
|
|
}
|
|
if !strings.Contains(string(content), "# sync-branch:") {
|
|
t.Errorf("Initial config.yaml should have commented sync-branch")
|
|
}
|
|
|
|
// Reset Cobra flags for reinit
|
|
initCmd.Flags().Set("branch", "")
|
|
initCmd.Flags().Set("force", "false")
|
|
|
|
// Reinit WITH --branch (should update existing config.yaml)
|
|
rootCmd.SetArgs([]string{"init", "--prefix", "test", "--branch", "beads-sync", "--force", "--quiet"})
|
|
if err := rootCmd.Execute(); err != nil {
|
|
t.Fatalf("Reinit with --branch failed: %v", err)
|
|
}
|
|
|
|
// Verify config.yaml now has uncommented sync-branch
|
|
content, err = os.ReadFile(configPath)
|
|
if err != nil {
|
|
t.Fatalf("Failed to read config.yaml after reinit: %v", err)
|
|
}
|
|
|
|
configStr := string(content)
|
|
if !strings.Contains(configStr, "sync-branch: \"beads-sync\"") {
|
|
t.Errorf("After reinit with --branch, config.yaml should contain uncommented 'sync-branch: \"beads-sync\"', got:\n%s", configStr)
|
|
}
|
|
}
|
|
|
|
// setupIsolatedGitConfig creates an empty git config in tmpDir and sets GIT_CONFIG_GLOBAL
|
|
// to prevent tests from using the real user's global git config.
|
|
func setupIsolatedGitConfig(t *testing.T, tmpDir string) {
|
|
t.Helper()
|
|
gitConfigPath := filepath.Join(tmpDir, ".gitconfig")
|
|
if err := os.WriteFile(gitConfigPath, []byte(""), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Setenv("GIT_CONFIG_GLOBAL", gitConfigPath)
|
|
}
|
|
|
|
// 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) {
|
|
if runtime.GOOS == "darwin" {
|
|
t.Skip("macOS allows file owner to write to read-only (0444) files")
|
|
}
|
|
tmpDir := t.TempDir()
|
|
setupIsolatedGitConfig(t, tmpDir)
|
|
|
|
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) {
|
|
if runtime.GOOS == "darwin" {
|
|
t.Skip("macOS allows file owner to write to read-only (0444) files")
|
|
}
|
|
tmpDir := t.TempDir()
|
|
setupIsolatedGitConfig(t, tmpDir)
|
|
|
|
// 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()
|
|
}
|
|
|
|
// TestInitWithRedirect verifies that bd init creates the database in the redirect target,
|
|
// not in the local .beads directory. (GH#bd-0qel)
|
|
func TestInitWithRedirect(t *testing.T) {
|
|
// Reset global state
|
|
origDBPath := dbPath
|
|
defer func() { dbPath = origDBPath }()
|
|
dbPath = ""
|
|
|
|
// Clear BEADS_DIR to ensure we test the tree search path
|
|
origBeadsDir := os.Getenv("BEADS_DIR")
|
|
os.Unsetenv("BEADS_DIR")
|
|
defer func() {
|
|
if origBeadsDir != "" {
|
|
os.Setenv("BEADS_DIR", origBeadsDir)
|
|
}
|
|
}()
|
|
|
|
// Reset Cobra flags
|
|
initCmd.Flags().Set("prefix", "")
|
|
initCmd.Flags().Set("quiet", "false")
|
|
|
|
tmpDir := t.TempDir()
|
|
|
|
// Create project directory (where we'll run from)
|
|
projectDir := filepath.Join(tmpDir, "project")
|
|
if err := os.MkdirAll(projectDir, 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Create local .beads with redirect file pointing to target
|
|
localBeadsDir := filepath.Join(projectDir, ".beads")
|
|
if err := os.MkdirAll(localBeadsDir, 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Create target .beads directory (the redirect destination)
|
|
targetBeadsDir := filepath.Join(tmpDir, "canonical", ".beads")
|
|
if err := os.MkdirAll(targetBeadsDir, 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Write redirect file - use relative path
|
|
redirectPath := filepath.Join(localBeadsDir, beads.RedirectFileName)
|
|
// Relative path from project/.beads to canonical/.beads is ../canonical/.beads
|
|
if err := os.WriteFile(redirectPath, []byte("../canonical/.beads\n"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Change to project directory
|
|
t.Chdir(projectDir)
|
|
|
|
// Run bd init
|
|
rootCmd.SetArgs([]string{"init", "--prefix", "redirect-test", "--quiet"})
|
|
if err := rootCmd.Execute(); err != nil {
|
|
t.Fatalf("Init with redirect failed: %v", err)
|
|
}
|
|
|
|
// Verify database was created in TARGET directory, not local
|
|
targetDBPath := filepath.Join(targetBeadsDir, "beads.db")
|
|
if _, err := os.Stat(targetDBPath); os.IsNotExist(err) {
|
|
t.Errorf("Database was NOT created in redirect target: %s", targetDBPath)
|
|
}
|
|
|
|
// Verify database was NOT created in local directory
|
|
localDBPath := filepath.Join(localBeadsDir, "beads.db")
|
|
if _, err := os.Stat(localDBPath); err == nil {
|
|
t.Errorf("Database was incorrectly created in local .beads: %s (should be in redirect target)", localDBPath)
|
|
}
|
|
|
|
// Verify the database is functional
|
|
store, err := openExistingTestDB(t, targetDBPath)
|
|
if err != nil {
|
|
t.Fatalf("Failed to open database in redirect target: %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)
|
|
}
|
|
if prefix != "redirect-test" {
|
|
t.Errorf("Expected prefix 'redirect-test', got %q", prefix)
|
|
}
|
|
}
|
|
|
|
// TestInitWithRedirectToExistingDatabase verifies that bd init errors when the redirect
|
|
// target already has a database, preventing accidental overwrites. (GH#bd-0qel)
|
|
func TestInitWithRedirectToExistingDatabase(t *testing.T) {
|
|
// Reset global state
|
|
origDBPath := dbPath
|
|
defer func() { dbPath = origDBPath }()
|
|
dbPath = ""
|
|
|
|
// Clear BEADS_DIR to ensure we test the tree search path
|
|
origBeadsDir := os.Getenv("BEADS_DIR")
|
|
os.Unsetenv("BEADS_DIR")
|
|
defer func() {
|
|
if origBeadsDir != "" {
|
|
os.Setenv("BEADS_DIR", origBeadsDir)
|
|
}
|
|
}()
|
|
|
|
// Reset Cobra flags
|
|
initCmd.Flags().Set("prefix", "")
|
|
initCmd.Flags().Set("quiet", "false")
|
|
initCmd.Flags().Set("force", "false")
|
|
|
|
tmpDir := t.TempDir()
|
|
|
|
// Create canonical .beads directory with EXISTING database
|
|
canonicalDir := filepath.Join(tmpDir, "canonical")
|
|
canonicalBeadsDir := filepath.Join(canonicalDir, ".beads")
|
|
if err := os.MkdirAll(canonicalBeadsDir, 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Create an existing database in canonical location
|
|
canonicalDBPath := filepath.Join(canonicalBeadsDir, "beads.db")
|
|
store, err := sqlite.New(context.Background(), canonicalDBPath)
|
|
if err != nil {
|
|
t.Fatalf("Failed to create canonical database: %v", err)
|
|
}
|
|
if err := store.SetConfig(context.Background(), "issue_prefix", "existing"); err != nil {
|
|
t.Fatalf("Failed to set prefix in canonical database: %v", err)
|
|
}
|
|
store.Close()
|
|
|
|
// Create project directory with redirect to canonical
|
|
projectDir := filepath.Join(tmpDir, "project")
|
|
projectBeadsDir := filepath.Join(projectDir, ".beads")
|
|
if err := os.MkdirAll(projectBeadsDir, 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Write redirect file pointing to canonical
|
|
redirectPath := filepath.Join(projectBeadsDir, beads.RedirectFileName)
|
|
if err := os.WriteFile(redirectPath, []byte("../canonical/.beads\n"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Test checkExistingBeadsData directly since init uses os.Exit(1) which terminates tests
|
|
// Change to project directory first
|
|
origWd, _ := os.Getwd()
|
|
if err := os.Chdir(projectDir); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer os.Chdir(origWd)
|
|
|
|
// Call checkExistingBeadsData directly - should return error
|
|
err = checkExistingBeadsData("new-prefix")
|
|
if err == nil {
|
|
t.Fatal("Expected checkExistingBeadsData to return error when redirect target already has database")
|
|
}
|
|
|
|
errorMsg := err.Error()
|
|
if !strings.Contains(errorMsg, "redirect target already has database") {
|
|
t.Errorf("Expected error about redirect target having database, got: %s", errorMsg)
|
|
}
|
|
|
|
// Verify canonical database was NOT modified
|
|
store, err = openExistingTestDB(t, canonicalDBPath)
|
|
if err != nil {
|
|
t.Fatalf("Failed to reopen canonical 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 from canonical database: %v", err)
|
|
}
|
|
if prefix != "existing" {
|
|
t.Errorf("Canonical database prefix should still be 'existing', got %q (was overwritten!)", prefix)
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// BEADS_DIR Tests
|
|
// =============================================================================
|
|
// These tests verify that bd init respects the BEADS_DIR environment variable
|
|
// for both safety checks and database creation.
|
|
|
|
// TestCheckExistingBeadsData_WithBEADS_DIR verifies that checkExistingBeadsData
|
|
// uses BEADS_DIR instead of CWD when the environment variable is set.
|
|
// This tests requirements FR-001, FR-004.
|
|
func TestCheckExistingBeadsData_WithBEADS_DIR(t *testing.T) {
|
|
// Save and restore BEADS_DIR
|
|
origBeadsDir := os.Getenv("BEADS_DIR")
|
|
defer func() {
|
|
if origBeadsDir != "" {
|
|
os.Setenv("BEADS_DIR", origBeadsDir)
|
|
} else {
|
|
os.Unsetenv("BEADS_DIR")
|
|
}
|
|
beads.ResetCaches()
|
|
git.ResetCaches()
|
|
}()
|
|
|
|
t.Run("TC-002: BEADS_DIR set, no existing DB", func(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
|
|
// Create BEADS_DIR location (no database)
|
|
beadsDirPath := filepath.Join(tmpDir, "external", ".beads")
|
|
os.MkdirAll(beadsDirPath, 0755)
|
|
|
|
os.Setenv("BEADS_DIR", beadsDirPath)
|
|
beads.ResetCaches()
|
|
|
|
// Should succeed because BEADS_DIR has no database
|
|
err := checkExistingBeadsData("test")
|
|
if err != nil {
|
|
t.Errorf("Expected no error when BEADS_DIR has no database, got: %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("TC-003: BEADS_DIR set, CWD has .beads, should ignore CWD", func(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
|
|
// Create CWD with existing database (should be ignored)
|
|
cwdBeadsDir := filepath.Join(tmpDir, "cwd", ".beads")
|
|
os.MkdirAll(cwdBeadsDir, 0755)
|
|
cwdDBPath := filepath.Join(cwdBeadsDir, beads.CanonicalDatabaseName)
|
|
store, err := sqlite.New(context.Background(), cwdDBPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
store.Close()
|
|
|
|
// Create BEADS_DIR location (no database)
|
|
beadsDirPath := filepath.Join(tmpDir, "external", ".beads")
|
|
os.MkdirAll(beadsDirPath, 0755)
|
|
|
|
// Set BEADS_DIR - should check external, not CWD
|
|
os.Setenv("BEADS_DIR", beadsDirPath)
|
|
beads.ResetCaches()
|
|
|
|
// Change to CWD with database
|
|
origWd, _ := os.Getwd()
|
|
os.Chdir(filepath.Join(tmpDir, "cwd"))
|
|
defer os.Chdir(origWd)
|
|
|
|
// Should succeed because BEADS_DIR has no database (CWD ignored)
|
|
err = checkExistingBeadsData("test")
|
|
if err != nil {
|
|
t.Errorf("Expected no error when BEADS_DIR has no database (CWD should be ignored), got: %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("TC-004: BEADS_DIR set, target exists with DB, should error", func(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
|
|
// Create BEADS_DIR with existing database
|
|
beadsDirPath := filepath.Join(tmpDir, "external", ".beads")
|
|
os.MkdirAll(beadsDirPath, 0755)
|
|
dbPath := filepath.Join(beadsDirPath, beads.CanonicalDatabaseName)
|
|
store, err := sqlite.New(context.Background(), dbPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
store.Close()
|
|
|
|
os.Setenv("BEADS_DIR", beadsDirPath)
|
|
beads.ResetCaches()
|
|
|
|
// Should error because BEADS_DIR already has database
|
|
err = checkExistingBeadsData("test")
|
|
if err == nil {
|
|
t.Error("Expected error when BEADS_DIR already has database")
|
|
}
|
|
// FR-005: Error message should reference the BEADS_DIR path
|
|
if !strings.Contains(err.Error(), beadsDirPath) {
|
|
t.Errorf("Expected error to mention BEADS_DIR path %s, got: %v", beadsDirPath, err)
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestInit_WithBEADS_DIR verifies that bd init creates the database at BEADS_DIR
|
|
// when the environment variable is set.
|
|
// This tests requirements FR-002.
|
|
func TestInit_WithBEADS_DIR(t *testing.T) {
|
|
// Skip on Windows - init has platform-specific behaviors
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("Skipping BEADS_DIR test on Windows")
|
|
}
|
|
|
|
// Reset global state
|
|
origDBPath := dbPath
|
|
defer func() { dbPath = origDBPath }()
|
|
dbPath = ""
|
|
|
|
// Save and restore BEADS_DIR
|
|
origBeadsDir := os.Getenv("BEADS_DIR")
|
|
defer func() {
|
|
if origBeadsDir != "" {
|
|
os.Setenv("BEADS_DIR", origBeadsDir)
|
|
} else {
|
|
os.Unsetenv("BEADS_DIR")
|
|
}
|
|
beads.ResetCaches()
|
|
git.ResetCaches()
|
|
}()
|
|
|
|
// Reset Cobra flags
|
|
initCmd.Flags().Set("prefix", "")
|
|
initCmd.Flags().Set("quiet", "false")
|
|
initCmd.Flags().Set("backend", "")
|
|
|
|
tmpDir := t.TempDir()
|
|
|
|
// Create external BEADS_DIR location
|
|
beadsDirPath := filepath.Join(tmpDir, "external", ".beads")
|
|
os.MkdirAll(filepath.Dir(beadsDirPath), 0755) // Create parent, not .beads itself
|
|
|
|
os.Setenv("BEADS_DIR", beadsDirPath)
|
|
beads.ResetCaches()
|
|
git.ResetCaches()
|
|
|
|
// Change to a different working directory
|
|
cwdPath := filepath.Join(tmpDir, "workdir")
|
|
os.MkdirAll(cwdPath, 0755)
|
|
t.Chdir(cwdPath)
|
|
|
|
// Run bd init with quiet flag
|
|
rootCmd.SetArgs([]string{"init", "--prefix", "beadsdir-test", "--quiet"})
|
|
if err := rootCmd.Execute(); err != nil {
|
|
t.Fatalf("Init with BEADS_DIR failed: %v", err)
|
|
}
|
|
|
|
// Verify database was created at BEADS_DIR, not CWD
|
|
expectedDBPath := filepath.Join(beadsDirPath, beads.CanonicalDatabaseName)
|
|
if _, err := os.Stat(expectedDBPath); os.IsNotExist(err) {
|
|
t.Errorf("Database was not created at BEADS_DIR path: %s", expectedDBPath)
|
|
}
|
|
|
|
// Verify database was NOT created at CWD
|
|
cwdDBPath := filepath.Join(cwdPath, ".beads", beads.CanonicalDatabaseName)
|
|
if _, err := os.Stat(cwdDBPath); err == nil {
|
|
t.Errorf("Database should NOT have been created at CWD: %s", cwdDBPath)
|
|
}
|
|
|
|
// Verify database has correct prefix
|
|
store, err := openExistingTestDB(t, expectedDBPath)
|
|
if err != nil {
|
|
t.Fatalf("Failed to open database at BEADS_DIR: %v", err)
|
|
}
|
|
defer store.Close()
|
|
|
|
ctx := context.Background()
|
|
prefix, err := store.GetConfig(ctx, "issue_prefix")
|
|
if err != nil {
|
|
t.Fatalf("Failed to get prefix from database: %v", err)
|
|
}
|
|
if prefix != "beadsdir-test" {
|
|
t.Errorf("Expected prefix 'beadsdir-test', got %q", prefix)
|
|
}
|
|
}
|
|
|
|
// TestInit_WithBEADS_DIR_DoltBackend verifies that bd init with Dolt backend
|
|
// creates the database at BEADS_DIR when the environment variable is set.
|
|
// This tests requirements FR-002 for Dolt backend.
|
|
func TestInit_WithBEADS_DIR_DoltBackend(t *testing.T) {
|
|
// Skip on Windows
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("Skipping BEADS_DIR Dolt test on Windows")
|
|
}
|
|
|
|
// Check if dolt is available
|
|
if _, err := exec.LookPath("dolt"); err != nil {
|
|
t.Skip("Dolt not installed, skipping Dolt backend test")
|
|
}
|
|
|
|
// Reset global state
|
|
origDBPath := dbPath
|
|
defer func() { dbPath = origDBPath }()
|
|
dbPath = ""
|
|
|
|
// Save and restore BEADS_DIR
|
|
origBeadsDir := os.Getenv("BEADS_DIR")
|
|
defer func() {
|
|
if origBeadsDir != "" {
|
|
os.Setenv("BEADS_DIR", origBeadsDir)
|
|
} else {
|
|
os.Unsetenv("BEADS_DIR")
|
|
}
|
|
beads.ResetCaches()
|
|
git.ResetCaches()
|
|
}()
|
|
|
|
// Reset Cobra flags
|
|
initCmd.Flags().Set("prefix", "")
|
|
initCmd.Flags().Set("quiet", "false")
|
|
initCmd.Flags().Set("backend", "")
|
|
|
|
tmpDir := t.TempDir()
|
|
|
|
// Create external BEADS_DIR location
|
|
beadsDirPath := filepath.Join(tmpDir, "external", ".beads")
|
|
os.MkdirAll(filepath.Dir(beadsDirPath), 0755)
|
|
|
|
os.Setenv("BEADS_DIR", beadsDirPath)
|
|
beads.ResetCaches()
|
|
git.ResetCaches()
|
|
|
|
// Change to a different working directory
|
|
cwdPath := filepath.Join(tmpDir, "workdir")
|
|
os.MkdirAll(cwdPath, 0755)
|
|
t.Chdir(cwdPath)
|
|
|
|
// Run bd init with Dolt backend
|
|
rootCmd.SetArgs([]string{"init", "--prefix", "dolt-test", "--backend", "dolt", "--quiet"})
|
|
if err := rootCmd.Execute(); err != nil {
|
|
t.Fatalf("Init with BEADS_DIR and Dolt backend failed: %v", err)
|
|
}
|
|
|
|
// Verify Dolt database was created at BEADS_DIR
|
|
expectedDoltPath := filepath.Join(beadsDirPath, "dolt")
|
|
if info, err := os.Stat(expectedDoltPath); os.IsNotExist(err) {
|
|
t.Errorf("Dolt database was not created at BEADS_DIR path: %s", expectedDoltPath)
|
|
} else if !info.IsDir() {
|
|
t.Errorf("Expected Dolt path to be a directory: %s", expectedDoltPath)
|
|
}
|
|
|
|
// Verify database was NOT created at CWD
|
|
cwdDoltPath := filepath.Join(cwdPath, ".beads", "dolt")
|
|
if _, err := os.Stat(cwdDoltPath); err == nil {
|
|
t.Errorf("Dolt database should NOT have been created at CWD: %s", cwdDoltPath)
|
|
}
|
|
}
|
|
|
|
// TestInit_WithoutBEADS_DIR_NoBehaviorChange verifies that existing behavior
|
|
// is unchanged when BEADS_DIR is not set.
|
|
// This tests requirement NFR-001.
|
|
func TestInit_WithoutBEADS_DIR_NoBehaviorChange(t *testing.T) {
|
|
// Reset global state
|
|
origDBPath := dbPath
|
|
defer func() { dbPath = origDBPath }()
|
|
dbPath = ""
|
|
|
|
// Ensure BEADS_DIR is not set
|
|
origBeadsDir := os.Getenv("BEADS_DIR")
|
|
os.Unsetenv("BEADS_DIR")
|
|
defer func() {
|
|
if origBeadsDir != "" {
|
|
os.Setenv("BEADS_DIR", origBeadsDir)
|
|
}
|
|
beads.ResetCaches()
|
|
git.ResetCaches()
|
|
}()
|
|
|
|
beads.ResetCaches()
|
|
git.ResetCaches()
|
|
|
|
// Reset Cobra flags
|
|
initCmd.Flags().Set("prefix", "")
|
|
initCmd.Flags().Set("quiet", "false")
|
|
initCmd.Flags().Set("backend", "")
|
|
|
|
tmpDir := t.TempDir()
|
|
t.Chdir(tmpDir)
|
|
|
|
// Run bd init
|
|
rootCmd.SetArgs([]string{"init", "--prefix", "no-beadsdir", "--quiet"})
|
|
if err := rootCmd.Execute(); err != nil {
|
|
t.Fatalf("Init without BEADS_DIR failed: %v", err)
|
|
}
|
|
|
|
// Verify database was created at CWD/.beads (default behavior)
|
|
expectedDBPath := filepath.Join(tmpDir, ".beads", beads.CanonicalDatabaseName)
|
|
if _, err := os.Stat(expectedDBPath); os.IsNotExist(err) {
|
|
t.Errorf("Database was not created at default CWD/.beads path: %s", expectedDBPath)
|
|
}
|
|
|
|
// Verify database has correct prefix
|
|
store, err := openExistingTestDB(t, expectedDBPath)
|
|
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 from database: %v", err)
|
|
}
|
|
if prefix != "no-beadsdir" {
|
|
t.Errorf("Expected prefix 'no-beadsdir', got %q", prefix)
|
|
}
|
|
}
|
|
|
|
// TestInit_BEADS_DB_OverridesBEADS_DIR verifies precedence: BEADS_DB > BEADS_DIR
|
|
// This ensures that explicit database path env var takes precedence over directory env var.
|
|
func TestInit_BEADS_DB_OverridesBEADS_DIR(t *testing.T) {
|
|
// Reset global state
|
|
origDBPath := dbPath
|
|
defer func() { dbPath = origDBPath }()
|
|
dbPath = ""
|
|
|
|
beads.ResetCaches()
|
|
git.ResetCaches()
|
|
|
|
// Reset Cobra flags
|
|
initCmd.Flags().Set("prefix", "")
|
|
initCmd.Flags().Set("quiet", "false")
|
|
initCmd.Flags().Set("backend", "")
|
|
|
|
// Create two target locations
|
|
beadsDirTarget := t.TempDir() // Where BEADS_DIR points (should be ignored)
|
|
beadsDBTarget := t.TempDir() // Where BEADS_DB points (should be used)
|
|
|
|
beadsDirBeads := filepath.Join(beadsDirTarget, ".beads")
|
|
if err := os.MkdirAll(beadsDirBeads, 0750); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
beadsDBPath := filepath.Join(beadsDBTarget, "override.db")
|
|
|
|
// Set both env vars - BEADS_DB should take precedence
|
|
t.Setenv("BEADS_DIR", beadsDirBeads)
|
|
t.Setenv("BEADS_DB", beadsDBPath)
|
|
|
|
tmpDir := t.TempDir()
|
|
t.Chdir(tmpDir)
|
|
|
|
// Run bd init
|
|
rootCmd.SetArgs([]string{"init", "--prefix", "precedence", "--quiet"})
|
|
if err := rootCmd.Execute(); err != nil {
|
|
t.Fatalf("Init with BEADS_DB + BEADS_DIR failed: %v", err)
|
|
}
|
|
|
|
// Verify database was created at BEADS_DB location (not BEADS_DIR)
|
|
if _, err := os.Stat(beadsDBPath); os.IsNotExist(err) {
|
|
t.Errorf("Database was NOT created at BEADS_DB path: %s", beadsDBPath)
|
|
}
|
|
|
|
// Verify database was NOT created at BEADS_DIR location
|
|
beadsDirDBPath := filepath.Join(beadsDirBeads, beads.CanonicalDatabaseName)
|
|
if _, err := os.Stat(beadsDirDBPath); err == nil {
|
|
t.Errorf("Database was incorrectly created at BEADS_DIR path: %s (BEADS_DB should override)", beadsDirDBPath)
|
|
}
|
|
|
|
// Verify the database has correct prefix
|
|
store, err := openExistingTestDB(t, beadsDBPath)
|
|
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 from database: %v", err)
|
|
}
|
|
if prefix != "precedence" {
|
|
t.Errorf("Expected prefix 'precedence', got %q", prefix)
|
|
}
|
|
}
|