Fix: init command now respects --db flag and BEADS_DB env var
Fixes #118 - Users can now initialize databases outside project directory Changes: - Check BEADS_DB env var in init command (PersistentPreRun skipped for init) - Use global dbPath from --db flag or BEADS_DB, else default to .beads/{prefix}.db - Use canonical path comparison (filepath.Abs + Clean) instead of strings.Contains - Only create .beads/ directory when database is actually local - Ensure parent directory exists for custom database paths - Add comprehensive tests for --db flag, BEADS_DB env var, and edge cases - Fix test isolation by resetting global dbPath in test setup Tests: - Custom path with --db flag - Custom path with BEADS_DB env var - Custom path containing ".beads" substring (prevents false positive) - Flag precedence over env var - All existing tests still pass Amp-Thread-ID: https://ampcode.com/threads/T-04e2c94f-894a-4b49-8132-980450b2300d Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
@@ -21,6 +21,14 @@ and database file. Optionally specify a custom issue prefix.`,
|
||||
prefix, _ := cmd.Flags().GetString("prefix")
|
||||
quiet, _ := cmd.Flags().GetBool("quiet")
|
||||
|
||||
// Check BEADS_DB environment variable if --db flag not set
|
||||
// (PersistentPreRun doesn't run for init command)
|
||||
if dbPath == "" {
|
||||
if envDB := os.Getenv("BEADS_DB"); envDB != "" {
|
||||
dbPath = envDB
|
||||
}
|
||||
}
|
||||
|
||||
if prefix == "" {
|
||||
// Auto-detect from directory name
|
||||
cwd, err := os.Getwd()
|
||||
@@ -35,15 +43,45 @@ and database file. Optionally specify a custom issue prefix.`,
|
||||
// The hyphen is added automatically during ID generation
|
||||
prefix = strings.TrimRight(prefix, "-")
|
||||
|
||||
// Create database
|
||||
// Use global dbPath if set via --db flag or BEADS_DB env var, otherwise default to .beads/{prefix}.db
|
||||
initDBPath := dbPath
|
||||
if initDBPath == "" {
|
||||
initDBPath = filepath.Join(".beads", prefix+".db")
|
||||
}
|
||||
|
||||
// Determine if we should create .beads/ directory in CWD
|
||||
// Only create it if the database will be stored there
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: failed to get current directory: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
localBeadsDir := filepath.Join(cwd, ".beads")
|
||||
initDBDir := filepath.Dir(initDBPath)
|
||||
|
||||
// Convert both to absolute paths for comparison
|
||||
localBeadsDirAbs, err := filepath.Abs(localBeadsDir)
|
||||
if err != nil {
|
||||
localBeadsDirAbs = filepath.Clean(localBeadsDir)
|
||||
}
|
||||
initDBDirAbs, err := filepath.Abs(initDBDir)
|
||||
if err != nil {
|
||||
initDBDirAbs = filepath.Clean(initDBDir)
|
||||
}
|
||||
|
||||
useLocalBeads := filepath.Clean(initDBDirAbs) == filepath.Clean(localBeadsDirAbs)
|
||||
|
||||
if useLocalBeads {
|
||||
// Create .beads directory
|
||||
beadsDir := ".beads"
|
||||
if err := os.MkdirAll(beadsDir, 0750); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: failed to create %s directory: %v\n", beadsDir, err)
|
||||
if err := os.MkdirAll(localBeadsDir, 0750); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: failed to create .beads directory: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Create .gitignore in .beads directory
|
||||
gitignorePath := filepath.Join(beadsDir, ".gitignore")
|
||||
gitignorePath := filepath.Join(localBeadsDir, ".gitignore")
|
||||
gitignoreContent := `# SQLite databases
|
||||
*.db
|
||||
*.db-journal
|
||||
@@ -62,14 +100,19 @@ bd.db
|
||||
# Keep JSONL exports (source of truth for git)
|
||||
!*.jsonl
|
||||
`
|
||||
if err := os.WriteFile(gitignorePath, []byte(gitignoreContent), 0644); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Warning: failed to create .gitignore: %v\n", err)
|
||||
// Non-fatal - continue anyway
|
||||
if err := os.WriteFile(gitignorePath, []byte(gitignoreContent), 0644); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Warning: failed to create .gitignore: %v\n", err)
|
||||
// Non-fatal - continue anyway
|
||||
}
|
||||
}
|
||||
|
||||
// Create database
|
||||
dbPath := filepath.Join(beadsDir, prefix+".db")
|
||||
store, err := sqlite.New(dbPath)
|
||||
|
||||
// Ensure parent directory exists for the database
|
||||
if err := os.MkdirAll(initDBDir, 0750); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: failed to create database directory %s: %v\n", initDBDir, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
store, err := sqlite.New(initDBPath)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: failed to create database: %v\n", err)
|
||||
os.Exit(1)
|
||||
@@ -96,31 +139,31 @@ bd.db
|
||||
fmt.Fprintf(os.Stderr, "\n✓ Database initialized. Found %d issues in git, importing...\n", issueCount)
|
||||
}
|
||||
|
||||
if err := importFromGit(ctx, dbPath, store, jsonlPath); err != nil {
|
||||
if !quiet {
|
||||
fmt.Fprintf(os.Stderr, "Warning: auto-import failed: %v\n", err)
|
||||
fmt.Fprintf(os.Stderr, "Try manually: git show HEAD:%s | bd import -i /dev/stdin\n", jsonlPath)
|
||||
}
|
||||
// Non-fatal - continue with empty database
|
||||
} else if !quiet {
|
||||
fmt.Fprintf(os.Stderr, "✓ Successfully imported %d issues from git.\n\n", issueCount)
|
||||
if err := importFromGit(ctx, initDBPath, store, jsonlPath); err != nil {
|
||||
if !quiet {
|
||||
fmt.Fprintf(os.Stderr, "Warning: auto-import failed: %v\n", err)
|
||||
fmt.Fprintf(os.Stderr, "Try manually: git show HEAD:%s | bd import -i /dev/stdin\n", jsonlPath)
|
||||
}
|
||||
}
|
||||
// Non-fatal - continue with empty database
|
||||
} else if !quiet {
|
||||
fmt.Fprintf(os.Stderr, "✓ Successfully imported %d issues from git.\n\n", issueCount)
|
||||
}
|
||||
}
|
||||
|
||||
if err := store.Close(); err != nil {
|
||||
if err := store.Close(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Warning: failed to close database: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Skip output if quiet mode
|
||||
if quiet {
|
||||
// Skip output if quiet mode
|
||||
if quiet {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
green := color.New(color.FgGreen).SprintFunc()
|
||||
cyan := color.New(color.FgCyan).SprintFunc()
|
||||
|
||||
fmt.Printf("\n%s bd initialized successfully!\n\n", green("✓"))
|
||||
fmt.Printf(" Database: %s\n", cyan(dbPath))
|
||||
fmt.Printf(" Database: %s\n", cyan(initDBPath))
|
||||
fmt.Printf(" Issue prefix: %s\n", cyan(prefix))
|
||||
fmt.Printf(" Issues will be named: %s\n\n", cyan(prefix+"-1, "+prefix+"-2, ..."))
|
||||
fmt.Printf("Run %s to get started.\n\n", cyan("bd quickstart"))
|
||||
|
||||
@@ -47,6 +47,11 @@ func TestInitCommand(t *testing.T) {
|
||||
|
||||
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", "")
|
||||
@@ -193,6 +198,11 @@ func TestInitCommand(t *testing.T) {
|
||||
// on errors, which makes it difficult to test in a unit test context.
|
||||
|
||||
func TestInitAlreadyInitialized(t *testing.T) {
|
||||
// Reset global state
|
||||
origDBPath := dbPath
|
||||
defer func() { dbPath = origDBPath }()
|
||||
dbPath = ""
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
originalWd, err := os.Getwd()
|
||||
if err != nil {
|
||||
@@ -236,3 +246,149 @@ func TestInitAlreadyInitialized(t *testing.T) {
|
||||
t.Errorf("Expected prefix 'test', got %q", prefix)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitWithCustomDBPath(t *testing.T) {
|
||||
// Save original state
|
||||
origDBPath := dbPath
|
||||
defer func() { dbPath = origDBPath }()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
customDBDir := filepath.Join(tmpDir, "custom", "location")
|
||||
|
||||
// Change to a different directory to ensure --db flag is actually used
|
||||
workDir := filepath.Join(tmpDir, "workdir")
|
||||
if err := os.MkdirAll(workDir, 0750); err != nil {
|
||||
t.Fatalf("Failed to create work directory: %v", err)
|
||||
}
|
||||
|
||||
originalWd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get working directory: %v", err)
|
||||
}
|
||||
defer os.Chdir(originalWd)
|
||||
|
||||
if err := os.Chdir(workDir); err != nil {
|
||||
t.Fatalf("Failed to change to work directory: %v", err)
|
||||
}
|
||||
|
||||
customDBPath := filepath.Join(customDBDir, "test.db")
|
||||
|
||||
// Test with --db flag
|
||||
t.Run("init with --db flag", func(t *testing.T) {
|
||||
dbPath = "" // Reset global
|
||||
rootCmd.SetArgs([]string{"--db", customDBPath, "init", "--prefix", "custom", "--quiet"})
|
||||
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
t.Fatalf("Init with --db flag 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 := sqlite.New(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 --db flag")
|
||||
}
|
||||
})
|
||||
|
||||
// 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 := sqlite.New(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 custom path containing ".beads" doesn't create CWD/.beads
|
||||
t.Run("init with custom 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")
|
||||
rootCmd.SetArgs([]string{"--db", customPath, "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 custom path contains .beads")
|
||||
}
|
||||
})
|
||||
|
||||
// Test flag precedence over env var
|
||||
t.Run("flag takes precedence over BEADS_DB", func(t *testing.T) {
|
||||
dbPath = "" // Reset global
|
||||
flagPath := filepath.Join(tmpDir, "flag", "flag.db")
|
||||
envPath := filepath.Join(tmpDir, "env", "env.db")
|
||||
|
||||
os.Setenv("BEADS_DB", envPath)
|
||||
defer os.Unsetenv("BEADS_DB")
|
||||
|
||||
rootCmd.SetArgs([]string{"--db", flagPath, "init", "--prefix", "flagtest", "--quiet"})
|
||||
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
t.Fatalf("Init with flag precedence failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify database was created at flag location, not env location
|
||||
if _, err := os.Stat(flagPath); os.IsNotExist(err) {
|
||||
t.Errorf("Database was not created at flag path %s", flagPath)
|
||||
}
|
||||
if _, err := os.Stat(envPath); err == nil {
|
||||
t.Error("Database should not be created at BEADS_DB path when --db flag is set")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user