Add config.json support for database path configuration (bd-163)
- Create internal/configfile package for config.json handling - bd init now creates .beads/config.json with database, version, and jsonl_export fields - Database discovery checks config.json first, falls back to beads.db - Update .gitignore to not ignore config.json (part of repo state) - Update test to expect beads.db and config.json - Backward compatible with existing beads.db-only setups
This commit is contained in:
13
beads.go
13
beads.go
@@ -13,6 +13,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/steveyegge/beads/internal/configfile"
|
||||||
"github.com/steveyegge/beads/internal/storage"
|
"github.com/steveyegge/beads/internal/storage"
|
||||||
"github.com/steveyegge/beads/internal/storage/sqlite"
|
"github.com/steveyegge/beads/internal/storage/sqlite"
|
||||||
"github.com/steveyegge/beads/internal/types"
|
"github.com/steveyegge/beads/internal/types"
|
||||||
@@ -163,7 +164,7 @@ type DatabaseInfo struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// findDatabaseInTree walks up the directory tree looking for .beads/*.db
|
// findDatabaseInTree walks up the directory tree looking for .beads/*.db
|
||||||
// Prefers beads.db and returns an error (via stderr warning) if multiple .db files exist
|
// Prefers config.json, falls back to beads.db, and returns an error if multiple .db files exist
|
||||||
func findDatabaseInTree() string {
|
func findDatabaseInTree() string {
|
||||||
dir, err := os.Getwd()
|
dir, err := os.Getwd()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -174,7 +175,15 @@ func findDatabaseInTree() string {
|
|||||||
for {
|
for {
|
||||||
beadsDir := filepath.Join(dir, ".beads")
|
beadsDir := filepath.Join(dir, ".beads")
|
||||||
if info, err := os.Stat(beadsDir); err == nil && info.IsDir() {
|
if info, err := os.Stat(beadsDir); err == nil && info.IsDir() {
|
||||||
// Check for canonical beads.db first
|
// Check for config.json first (single source of truth)
|
||||||
|
if cfg, err := configfile.Load(beadsDir); err == nil && cfg != nil {
|
||||||
|
dbPath := cfg.DatabasePath(beadsDir)
|
||||||
|
if _, err := os.Stat(dbPath); err == nil {
|
||||||
|
return dbPath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to canonical beads.db for backward compatibility
|
||||||
canonicalDB := filepath.Join(beadsDir, "beads.db")
|
canonicalDB := filepath.Join(beadsDir, "beads.db")
|
||||||
if _, err := os.Stat(canonicalDB); err == nil {
|
if _, err := os.Stat(canonicalDB); err == nil {
|
||||||
return canonicalDB
|
return canonicalDB
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
|
|
||||||
"github.com/fatih/color"
|
"github.com/fatih/color"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/steveyegge/beads/internal/configfile"
|
||||||
"github.com/steveyegge/beads/internal/storage/sqlite"
|
"github.com/steveyegge/beads/internal/storage/sqlite"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -104,8 +105,9 @@ bd.sock
|
|||||||
db.sqlite
|
db.sqlite
|
||||||
bd.db
|
bd.db
|
||||||
|
|
||||||
# Keep JSONL exports (source of truth for git)
|
# Keep JSONL exports and config (source of truth for git)
|
||||||
!*.jsonl
|
!*.jsonl
|
||||||
|
!config.json
|
||||||
`
|
`
|
||||||
if err := os.WriteFile(gitignorePath, []byte(gitignoreContent), 0600); err != nil {
|
if err := os.WriteFile(gitignorePath, []byte(gitignoreContent), 0600); err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "Warning: failed to create .gitignore: %v\n", err)
|
fmt.Fprintf(os.Stderr, "Warning: failed to create .gitignore: %v\n", err)
|
||||||
@@ -139,6 +141,15 @@ bd.db
|
|||||||
// Non-fatal - continue anyway
|
// Non-fatal - continue anyway
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create config.json for explicit configuration
|
||||||
|
if useLocalBeads {
|
||||||
|
cfg := configfile.DefaultConfig(Version)
|
||||||
|
if err := cfg.Save(localBeadsDir); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Warning: failed to create config.json: %v\n", err)
|
||||||
|
// Non-fatal - continue anyway
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Check if git has existing issues to import (fresh clone scenario)
|
// Check if git has existing issues to import (fresh clone scenario)
|
||||||
issueCount, jsonlPath := checkGitForIssues()
|
issueCount, jsonlPath := checkGitForIssues()
|
||||||
if issueCount > 0 {
|
if issueCount > 0 {
|
||||||
|
|||||||
4
cmd/bd/testdata/init.txt
vendored
4
cmd/bd/testdata/init.txt
vendored
@@ -1,7 +1,8 @@
|
|||||||
# Test bd init command
|
# Test bd init command
|
||||||
bd init --prefix test
|
bd init --prefix test
|
||||||
stdout 'initialized successfully'
|
stdout 'initialized successfully'
|
||||||
exists .beads/test.db
|
exists .beads/beads.db
|
||||||
|
exists .beads/config.json
|
||||||
exists .beads/.gitignore
|
exists .beads/.gitignore
|
||||||
grep '^\*\.db$' .beads/.gitignore
|
grep '^\*\.db$' .beads/.gitignore
|
||||||
grep '^\*\.db-journal$' .beads/.gitignore
|
grep '^\*\.db-journal$' .beads/.gitignore
|
||||||
@@ -11,3 +12,4 @@ grep '^daemon\.log$' .beads/.gitignore
|
|||||||
grep '^daemon\.pid$' .beads/.gitignore
|
grep '^daemon\.pid$' .beads/.gitignore
|
||||||
grep '^bd\.sock$' .beads/.gitignore
|
grep '^bd\.sock$' .beads/.gitignore
|
||||||
grep '^!\*\.jsonl$' .beads/.gitignore
|
grep '^!\*\.jsonl$' .beads/.gitignore
|
||||||
|
grep '^!config\.json$' .beads/.gitignore
|
||||||
|
|||||||
73
internal/configfile/configfile.go
Normal file
73
internal/configfile/configfile.go
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
package configfile
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
const ConfigFileName = "config.json"
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Database string `json:"database"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
JSONLExport string `json:"jsonl_export,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func DefaultConfig(version string) *Config {
|
||||||
|
return &Config{
|
||||||
|
Database: "beads.db",
|
||||||
|
Version: version,
|
||||||
|
JSONLExport: "beads.jsonl",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ConfigPath(beadsDir string) string {
|
||||||
|
return filepath.Join(beadsDir, ConfigFileName)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Load(beadsDir string) (*Config, error) {
|
||||||
|
configPath := ConfigPath(beadsDir)
|
||||||
|
|
||||||
|
data, err := os.ReadFile(configPath)
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("reading config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var cfg Config
|
||||||
|
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Config) Save(beadsDir string) error {
|
||||||
|
configPath := ConfigPath(beadsDir)
|
||||||
|
|
||||||
|
data, err := json.MarshalIndent(c, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshaling config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(configPath, data, 0644); err != nil {
|
||||||
|
return fmt.Errorf("writing config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Config) DatabasePath(beadsDir string) string {
|
||||||
|
return filepath.Join(beadsDir, c.Database)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Config) JSONLPath(beadsDir string) string {
|
||||||
|
if c.JSONLExport == "" {
|
||||||
|
return filepath.Join(beadsDir, "beads.jsonl")
|
||||||
|
}
|
||||||
|
return filepath.Join(beadsDir, c.JSONLExport)
|
||||||
|
}
|
||||||
128
internal/configfile/configfile_test.go
Normal file
128
internal/configfile/configfile_test.go
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
package configfile
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDefaultConfig(t *testing.T) {
|
||||||
|
cfg := DefaultConfig("0.17.5")
|
||||||
|
|
||||||
|
if cfg.Database != "beads.db" {
|
||||||
|
t.Errorf("Database = %q, want beads.db", cfg.Database)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Version != "0.17.5" {
|
||||||
|
t.Errorf("Version = %q, want 0.17.5", cfg.Version)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.JSONLExport != "beads.jsonl" {
|
||||||
|
t.Errorf("JSONLExport = %q, want beads.jsonl", cfg.JSONLExport)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadSaveRoundtrip(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
beadsDir := filepath.Join(tmpDir, ".beads")
|
||||||
|
if err := os.MkdirAll(beadsDir, 0750); err != nil {
|
||||||
|
t.Fatalf("failed to create .beads directory: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := DefaultConfig("0.17.5")
|
||||||
|
|
||||||
|
if err := cfg.Save(beadsDir); err != nil {
|
||||||
|
t.Fatalf("Save() failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
loaded, err := Load(beadsDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load() failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if loaded == nil {
|
||||||
|
t.Fatal("Load() returned nil config")
|
||||||
|
}
|
||||||
|
|
||||||
|
if loaded.Database != cfg.Database {
|
||||||
|
t.Errorf("Database = %q, want %q", loaded.Database, cfg.Database)
|
||||||
|
}
|
||||||
|
|
||||||
|
if loaded.Version != cfg.Version {
|
||||||
|
t.Errorf("Version = %q, want %q", loaded.Version, cfg.Version)
|
||||||
|
}
|
||||||
|
|
||||||
|
if loaded.JSONLExport != cfg.JSONLExport {
|
||||||
|
t.Errorf("JSONLExport = %q, want %q", loaded.JSONLExport, cfg.JSONLExport)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadNonexistent(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
cfg, err := Load(tmpDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load() returned error for nonexistent config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg != nil {
|
||||||
|
t.Errorf("Load() = %v, want nil for nonexistent config", cfg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDatabasePath(t *testing.T) {
|
||||||
|
beadsDir := "/home/user/project/.beads"
|
||||||
|
cfg := &Config{Database: "beads.db"}
|
||||||
|
|
||||||
|
got := cfg.DatabasePath(beadsDir)
|
||||||
|
want := filepath.Join(beadsDir, "beads.db")
|
||||||
|
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("DatabasePath() = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJSONLPath(t *testing.T) {
|
||||||
|
beadsDir := "/home/user/project/.beads"
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
cfg *Config
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "default",
|
||||||
|
cfg: &Config{JSONLExport: "beads.jsonl"},
|
||||||
|
want: filepath.Join(beadsDir, "beads.jsonl"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "custom",
|
||||||
|
cfg: &Config{JSONLExport: "custom.jsonl"},
|
||||||
|
want: filepath.Join(beadsDir, "custom.jsonl"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty falls back to default",
|
||||||
|
cfg: &Config{JSONLExport: ""},
|
||||||
|
want: filepath.Join(beadsDir, "beads.jsonl"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := tt.cfg.JSONLPath(beadsDir)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("JSONLPath() = %q, want %q", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfigPath(t *testing.T) {
|
||||||
|
beadsDir := "/home/user/project/.beads"
|
||||||
|
got := ConfigPath(beadsDir)
|
||||||
|
want := filepath.Join(beadsDir, "config.json")
|
||||||
|
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("ConfigPath() = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user