/{website,internal,docs,cmd}: integration tests and more split backend fixes

This commit is contained in:
coffeegoddd☕️✨
2026-01-20 13:39:04 -08:00
parent ba432847e0
commit 422bc838ed
21 changed files with 892 additions and 156 deletions

View File

@@ -270,6 +270,7 @@ func sanitizeMetadataKey(key string) string {
// 1. The worst case is "JSONL content has changed" error on next export
// 2. User can fix by running 'bd import' (safe, no data loss)
// 3. Current approach is simple and doesn't require complex WAL or format changes
//
// Future: Consider defensive checks on startup if this becomes a common issue.
func updateExportMetadata(ctx context.Context, store storage.Storage, jsonlPath string, log daemonLogger, keySuffix string) {
// Sanitize keySuffix to handle Windows paths with colons
@@ -456,11 +457,14 @@ func performExport(ctx context.Context, store storage.Storage, autoCommit, autoP
// Update database mtime to be >= JSONL mtime (fixes #278, #301, #321)
// This prevents validatePreExport from incorrectly blocking on next export
// with "JSONL is newer than database" after daemon auto-export
// Dolt backend does not have a SQLite DB file; mtime touch is SQLite-only.
if _, ok := store.(*sqlite.SQLiteStorage); ok {
dbPath := filepath.Join(beadsDir, "beads.db")
if err := TouchDatabaseFile(dbPath, jsonlPath); err != nil {
log.log("Warning: failed to update database mtime: %v", err)
}
}
}
// Auto-commit if enabled (skip in git-free mode)
if autoCommit && !skipGit {
@@ -755,10 +759,13 @@ func performSync(ctx context.Context, store storage.Storage, autoCommit, autoPus
// Update database mtime to be >= JSONL mtime
// This prevents validatePreExport from incorrectly blocking on next export
// Dolt backend does not have a SQLite DB file; mtime touch is SQLite-only.
if _, ok := store.(*sqlite.SQLiteStorage); ok {
if err := TouchDatabaseFile(dbPath, jsonlPath); err != nil {
log.log("Warning: failed to update database mtime: %v", err)
}
}
}
// Skip git operations, snapshot capture, deletion tracking, and import in local-only mode
// Local-only sync is export-only since there's no remote to sync with
@@ -872,9 +879,12 @@ func performSync(ctx context.Context, store storage.Storage, autoCommit, autoPus
// Update database mtime after import (fixes #278, #301, #321)
// Sync branch import can update JSONL timestamp, so ensure DB >= JSONL
// Dolt backend does not have a SQLite DB file; mtime touch is SQLite-only.
if _, ok := store.(*sqlite.SQLiteStorage); ok {
if err := TouchDatabaseFile(dbPath, jsonlPath); err != nil {
log.log("Warning: failed to update database mtime: %v", err)
}
}
// Validate import didn't cause data loss
afterCount, err := countDBIssues(syncCtx, store)

19
cmd/bd/doctor/backend.go Normal file
View File

@@ -0,0 +1,19 @@
package doctor
import (
"path/filepath"
"github.com/steveyegge/beads/internal/configfile"
)
// getBackendAndBeadsDir resolves the effective .beads directory (following redirects)
// and returns the configured storage backend ("sqlite" by default, or "dolt").
func getBackendAndBeadsDir(repoPath string) (backend string, beadsDir string) {
beadsDir = resolveBeadsDir(filepath.Join(repoPath, ".beads"))
cfg, err := configfile.Load(beadsDir)
if err != nil || cfg == nil {
return configfile.BackendSQLite, beadsDir
}
return cfg.GetBackend(), beadsDir
}

View File

@@ -309,9 +309,20 @@ func checkMetadataConfigValues(repoPath string) []string {
if strings.Contains(cfg.Database, string(os.PathSeparator)) || strings.Contains(cfg.Database, "/") {
issues = append(issues, fmt.Sprintf("metadata.json database: %q should be a filename, not a path", cfg.Database))
}
backend := cfg.GetBackend()
if backend == configfile.BackendSQLite {
if !strings.HasSuffix(cfg.Database, ".db") && !strings.HasSuffix(cfg.Database, ".sqlite") && !strings.HasSuffix(cfg.Database, ".sqlite3") {
issues = append(issues, fmt.Sprintf("metadata.json database: %q has unusual extension (expected .db, .sqlite, or .sqlite3)", cfg.Database))
}
} else if backend == configfile.BackendDolt {
// Dolt is directory-backed; `database` should point to a directory (typically "dolt").
if strings.HasSuffix(cfg.Database, ".db") || strings.HasSuffix(cfg.Database, ".sqlite") || strings.HasSuffix(cfg.Database, ".sqlite3") {
issues = append(issues, fmt.Sprintf("metadata.json database: %q looks like a SQLite file, but backend is dolt (expected a directory like %q)", cfg.Database, "dolt"))
}
if cfg.Database == beads.CanonicalDatabaseName {
issues = append(issues, fmt.Sprintf("metadata.json database: %q is misleading for dolt backend (expected %q)", cfg.Database, "dolt"))
}
}
}
// Validate jsonl_export filename
@@ -345,12 +356,17 @@ func checkDatabaseConfigValues(repoPath string) []string {
return issues // No .beads directory, nothing to check
}
// Get database path
// Get database path (backend-aware)
dbPath := filepath.Join(beadsDir, beads.CanonicalDatabaseName)
// Check metadata.json for custom database name
if cfg, err := configfile.Load(beadsDir); err == nil && cfg != nil && cfg.Database != "" {
if cfg, err := configfile.Load(beadsDir); err == nil && cfg != nil {
// For Dolt, cfg.DatabasePath() is a directory and sqlite checks are not applicable.
if cfg.GetBackend() == configfile.BackendDolt {
return issues
}
if cfg.Database != "" {
dbPath = cfg.DatabasePath(beadsDir)
}
}
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
return issues // No database, nothing to check

View File

@@ -182,6 +182,22 @@ func TestCheckMetadataConfigValues(t *testing.T) {
}
})
t.Run("valid dolt metadata", func(t *testing.T) {
metadataContent := `{
"database": "dolt",
"jsonl_export": "issues.jsonl",
"backend": "dolt"
}`
if err := os.WriteFile(filepath.Join(beadsDir, "metadata.json"), []byte(metadataContent), 0644); err != nil {
t.Fatalf("failed to write metadata.json: %v", err)
}
issues := checkMetadataConfigValues(tmpDir)
if len(issues) > 0 {
t.Errorf("expected no issues, got: %v", issues)
}
})
// Test with path in database field
t.Run("path in database field", func(t *testing.T) {
metadataContent := `{

View File

@@ -10,7 +10,7 @@ import (
"github.com/steveyegge/beads/internal/daemon"
"github.com/steveyegge/beads/internal/git"
"github.com/steveyegge/beads/internal/rpc"
"github.com/steveyegge/beads/internal/storage/sqlite"
"github.com/steveyegge/beads/internal/storage/factory"
"github.com/steveyegge/beads/internal/syncbranch"
)
@@ -167,7 +167,7 @@ func CheckGitSyncSetup(path string) DoctorCheck {
// CheckDaemonAutoSync checks if daemon has auto-commit/auto-push enabled when
// sync-branch is configured. Missing auto-sync slows down agent workflows.
func CheckDaemonAutoSync(path string) DoctorCheck {
beadsDir := filepath.Join(path, ".beads")
_, beadsDir := getBackendAndBeadsDir(path)
socketPath := filepath.Join(beadsDir, "bd.sock")
// Check if daemon is running
@@ -181,8 +181,7 @@ func CheckDaemonAutoSync(path string) DoctorCheck {
// Check if sync-branch is configured
ctx := context.Background()
dbPath := filepath.Join(beadsDir, "beads.db")
store, err := sqlite.New(ctx, dbPath)
store, err := factory.NewFromConfigWithOptions(ctx, beadsDir, factory.Options{ReadOnly: true})
if err != nil {
return DoctorCheck{
Name: "Daemon Auto-Sync",
@@ -249,11 +248,10 @@ func CheckDaemonAutoSync(path string) DoctorCheck {
// CheckLegacyDaemonConfig checks for deprecated daemon config options and
// encourages migration to the unified daemon.auto-sync setting.
func CheckLegacyDaemonConfig(path string) DoctorCheck {
beadsDir := filepath.Join(path, ".beads")
dbPath := filepath.Join(beadsDir, "beads.db")
_, beadsDir := getBackendAndBeadsDir(path)
ctx := context.Background()
store, err := sqlite.New(ctx, dbPath)
store, err := factory.NewFromConfigWithOptions(ctx, beadsDir, factory.Options{ReadOnly: true})
if err != nil {
return DoctorCheck{
Name: "Daemon Config",

View File

@@ -2,6 +2,7 @@ package doctor
import (
"bufio"
"context"
"database/sql"
"encoding/json"
"fmt"
@@ -16,6 +17,7 @@ import (
"github.com/steveyegge/beads/cmd/bd/doctor/fix"
"github.com/steveyegge/beads/internal/beads"
"github.com/steveyegge/beads/internal/configfile"
storagefactory "github.com/steveyegge/beads/internal/storage/factory"
"gopkg.in/yaml.v3"
)
@@ -27,8 +29,85 @@ type localConfig struct {
// CheckDatabaseVersion checks the database version and migration status
func CheckDatabaseVersion(path string, cliVersion string) DoctorCheck {
// Follow redirect to resolve actual beads directory
beadsDir := resolveBeadsDir(filepath.Join(path, ".beads"))
backend, beadsDir := getBackendAndBeadsDir(path)
// Dolt backend: directory-backed store; version lives in metadata table.
if backend == configfile.BackendDolt {
doltPath := filepath.Join(beadsDir, "dolt")
if _, err := os.Stat(doltPath); os.IsNotExist(err) {
// If JSONL exists, treat as fresh clone / needs init.
issuesJSONL := filepath.Join(beadsDir, "issues.jsonl")
beadsJSONL := filepath.Join(beadsDir, "beads.jsonl")
_, issuesErr := os.Stat(issuesJSONL)
_, beadsErr := os.Stat(beadsJSONL)
if issuesErr == nil || beadsErr == nil {
return DoctorCheck{
Name: "Database",
Status: StatusWarning,
Message: "Fresh clone detected (no dolt database)",
Detail: "Storage: Dolt",
Fix: "Run 'bd init --backend dolt' to create and hydrate the dolt database",
}
}
return DoctorCheck{
Name: "Database",
Status: StatusError,
Message: "No dolt database found",
Detail: "Storage: Dolt",
Fix: "Run 'bd init --backend dolt' to create database",
}
}
ctx := context.Background()
store, err := storagefactory.NewFromConfigWithOptions(ctx, beadsDir, storagefactory.Options{ReadOnly: true})
if err != nil {
return DoctorCheck{
Name: "Database",
Status: StatusError,
Message: "Unable to open database",
Detail: fmt.Sprintf("Storage: Dolt\n\nError: %v", err),
Fix: "Run 'bd init --backend dolt' (or remove and re-init .beads/dolt if corrupted)",
}
}
defer func() { _ = store.Close() }()
dbVersion, err := store.GetMetadata(ctx, "bd_version")
if err != nil {
return DoctorCheck{
Name: "Database",
Status: StatusError,
Message: "Unable to read database version",
Detail: fmt.Sprintf("Storage: Dolt\n\nError: %v", err),
Fix: "Database may be corrupted. Try re-initializing the dolt database with 'bd init --backend dolt'",
}
}
if dbVersion == "" {
return DoctorCheck{
Name: "Database",
Status: StatusWarning,
Message: "Database missing version metadata",
Detail: "Storage: Dolt",
Fix: "Run 'bd migrate' or re-run 'bd init --backend dolt' to set version metadata",
}
}
if dbVersion != cliVersion {
return DoctorCheck{
Name: "Database",
Status: StatusWarning,
Message: fmt.Sprintf("version %s (CLI: %s)", dbVersion, cliVersion),
Detail: "Storage: Dolt",
Fix: "Update bd CLI and re-run (dolt metadata will be updated automatically by the daemon)",
}
}
return DoctorCheck{
Name: "Database",
Status: StatusOK,
Message: fmt.Sprintf("version %s", dbVersion),
Detail: "Storage: Dolt",
}
}
// Check metadata.json first for custom database name
var dbPath string
@@ -137,8 +216,48 @@ func CheckDatabaseVersion(path string, cliVersion string) DoctorCheck {
// CheckSchemaCompatibility checks if all required tables and columns are present
func CheckSchemaCompatibility(path string) DoctorCheck {
// Follow redirect to resolve actual beads directory
beadsDir := resolveBeadsDir(filepath.Join(path, ".beads"))
backend, beadsDir := getBackendAndBeadsDir(path)
// Dolt backend: no SQLite schema probe. Instead, run a lightweight query sanity check.
if backend == configfile.BackendDolt {
if info, err := os.Stat(filepath.Join(beadsDir, "dolt")); err != nil || !info.IsDir() {
return DoctorCheck{
Name: "Schema Compatibility",
Status: StatusOK,
Message: "N/A (no database)",
}
}
ctx := context.Background()
store, err := storagefactory.NewFromConfigWithOptions(ctx, beadsDir, storagefactory.Options{ReadOnly: true})
if err != nil {
return DoctorCheck{
Name: "Schema Compatibility",
Status: StatusError,
Message: "Failed to open database",
Detail: fmt.Sprintf("Storage: Dolt\n\nError: %v", err),
}
}
defer func() { _ = store.Close() }()
// Exercise core tables/views.
if _, err := store.GetStatistics(ctx); err != nil {
return DoctorCheck{
Name: "Schema Compatibility",
Status: StatusError,
Message: "Database schema is incomplete or incompatible",
Detail: fmt.Sprintf("Storage: Dolt\n\nError: %v", err),
Fix: "Re-run 'bd init --backend dolt' or remove and re-initialize .beads/dolt if corrupted",
}
}
return DoctorCheck{
Name: "Schema Compatibility",
Status: StatusOK,
Message: "Basic queries succeeded",
Detail: "Storage: Dolt",
}
}
// Check metadata.json first for custom database name
var dbPath string
@@ -227,8 +346,57 @@ func CheckSchemaCompatibility(path string) DoctorCheck {
// CheckDatabaseIntegrity runs SQLite's PRAGMA integrity_check
func CheckDatabaseIntegrity(path string) DoctorCheck {
// Follow redirect to resolve actual beads directory
beadsDir := resolveBeadsDir(filepath.Join(path, ".beads"))
backend, beadsDir := getBackendAndBeadsDir(path)
// Dolt backend: SQLite PRAGMA integrity_check doesn't apply.
// We do a lightweight read-only sanity check instead.
if backend == configfile.BackendDolt {
if info, err := os.Stat(filepath.Join(beadsDir, "dolt")); err != nil || !info.IsDir() {
return DoctorCheck{
Name: "Database Integrity",
Status: StatusOK,
Message: "N/A (no database)",
}
}
ctx := context.Background()
store, err := storagefactory.NewFromConfigWithOptions(ctx, beadsDir, storagefactory.Options{ReadOnly: true})
if err != nil {
return DoctorCheck{
Name: "Database Integrity",
Status: StatusError,
Message: "Failed to open database",
Detail: fmt.Sprintf("Storage: Dolt\n\nError: %v", err),
Fix: "Re-run 'bd init --backend dolt' or remove and re-initialize .beads/dolt if corrupted",
}
}
defer func() { _ = store.Close() }()
// Minimal checks: metadata + statistics. If these work, the store is at least readable.
if _, err := store.GetMetadata(ctx, "bd_version"); err != nil {
return DoctorCheck{
Name: "Database Integrity",
Status: StatusError,
Message: "Basic query failed",
Detail: fmt.Sprintf("Storage: Dolt\n\nError: %v", err),
}
}
if _, err := store.GetStatistics(ctx); err != nil {
return DoctorCheck{
Name: "Database Integrity",
Status: StatusError,
Message: "Basic query failed",
Detail: fmt.Sprintf("Storage: Dolt\n\nError: %v", err),
}
}
return DoctorCheck{
Name: "Database Integrity",
Status: StatusOK,
Message: "Basic query check passed",
Detail: "Storage: Dolt (no SQLite integrity_check equivalent)",
}
}
// Get database path (same logic as CheckSchemaCompatibility)
var dbPath string
@@ -340,8 +508,46 @@ func CheckDatabaseIntegrity(path string) DoctorCheck {
// CheckDatabaseJSONLSync checks if database and JSONL are in sync
func CheckDatabaseJSONLSync(path string) DoctorCheck {
// Follow redirect to resolve actual beads directory
beadsDir := resolveBeadsDir(filepath.Join(path, ".beads"))
backend, beadsDir := getBackendAndBeadsDir(path)
// Dolt backend: JSONL is a derived compatibility artifact (export-only today).
// The SQLite-style import/export divergence checks don't apply.
if backend == configfile.BackendDolt {
// Find JSONL file (respects metadata.json override when set).
jsonlPath := ""
if cfg, err := configfile.Load(beadsDir); err == nil && cfg != nil {
if cfg.JSONLExport != "" && !isSystemJSONLFilename(cfg.JSONLExport) {
p := cfg.JSONLPath(beadsDir)
if _, err := os.Stat(p); err == nil {
jsonlPath = p
}
}
}
if jsonlPath == "" {
for _, name := range []string{"issues.jsonl", "beads.jsonl"} {
testPath := filepath.Join(beadsDir, name)
if _, err := os.Stat(testPath); err == nil {
jsonlPath = testPath
break
}
}
}
if jsonlPath == "" {
return DoctorCheck{
Name: "DB-JSONL Sync",
Status: StatusOK,
Message: "N/A (no JSONL file)",
}
}
return DoctorCheck{
Name: "DB-JSONL Sync",
Status: StatusOK,
Message: "N/A (dolt backend)",
Detail: "JSONL is derived from Dolt (export-only); import-only sync checks do not apply",
}
}
// Resolve database path (respects metadata.json override).
dbPath := filepath.Join(beadsDir, beads.CanonicalDatabaseName)
@@ -795,8 +1001,7 @@ func isNoDbModeConfigured(beadsDir string) bool {
// irreversible. The user must make an explicit decision to delete their
// closed issue history. We only provide guidance, never action.
func CheckDatabaseSize(path string) DoctorCheck {
// Follow redirect to resolve actual beads directory
beadsDir := resolveBeadsDir(filepath.Join(path, ".beads"))
_, beadsDir := getBackendAndBeadsDir(path)
// Get database path
var dbPath string

View File

@@ -2,6 +2,7 @@ package doctor
import (
"bufio"
"context"
"database/sql"
"fmt"
"os"
@@ -16,6 +17,7 @@ import (
"github.com/steveyegge/beads/internal/beads"
"github.com/steveyegge/beads/internal/configfile"
"github.com/steveyegge/beads/internal/git"
storagefactory "github.com/steveyegge/beads/internal/storage/factory"
)
// CheckIDFormat checks whether issues use hash-based or sequential IDs
@@ -404,9 +406,98 @@ func CheckDeletionsManifest(path string) DoctorCheck {
// This detects when a .beads directory was copied from another repo or when
// the git remote URL changed. A mismatch can cause data loss during sync.
func CheckRepoFingerprint(path string) DoctorCheck {
// Follow redirect to resolve actual beads directory (bd-tvus fix)
beadsDir := resolveBeadsDir(filepath.Join(path, ".beads"))
backend, beadsDir := getBackendAndBeadsDir(path)
// Backend-aware existence check
switch backend {
case configfile.BackendDolt:
if info, err := os.Stat(filepath.Join(beadsDir, "dolt")); err != nil || !info.IsDir() {
return DoctorCheck{
Name: "Repo Fingerprint",
Status: StatusOK,
Message: "N/A (no database)",
}
}
default:
// SQLite backend: needs a .db file
var dbPath string
if cfg, err := configfile.Load(beadsDir); err == nil && cfg != nil && cfg.Database != "" {
dbPath = cfg.DatabasePath(beadsDir)
} else {
dbPath = filepath.Join(beadsDir, beads.CanonicalDatabaseName)
}
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
return DoctorCheck{
Name: "Repo Fingerprint",
Status: StatusOK,
Message: "N/A (no database)",
}
}
}
// For Dolt, read fingerprint from storage metadata (no sqlite assumptions).
if backend == configfile.BackendDolt {
ctx := context.Background()
store, err := storagefactory.NewFromConfigWithOptions(ctx, beadsDir, storagefactory.Options{ReadOnly: true})
if err != nil {
return DoctorCheck{
Name: "Repo Fingerprint",
Status: StatusWarning,
Message: "Unable to open database",
Detail: err.Error(),
}
}
defer func() { _ = store.Close() }()
storedRepoID, err := store.GetMetadata(ctx, "repo_id")
if err != nil {
return DoctorCheck{
Name: "Repo Fingerprint",
Status: StatusWarning,
Message: "Unable to read repo fingerprint",
Detail: err.Error(),
}
}
// If missing, warn (not the legacy sqlite messaging).
if storedRepoID == "" {
return DoctorCheck{
Name: "Repo Fingerprint",
Status: StatusWarning,
Message: "Missing repo fingerprint metadata",
Detail: "Storage: Dolt",
Fix: "Run 'bd migrate --update-repo-id' to add fingerprint metadata",
}
}
currentRepoID, err := beads.ComputeRepoID()
if err != nil {
return DoctorCheck{
Name: "Repo Fingerprint",
Status: StatusWarning,
Message: "Unable to compute current repo ID",
Detail: err.Error(),
}
}
if storedRepoID != currentRepoID {
return DoctorCheck{
Name: "Repo Fingerprint",
Status: StatusError,
Message: "Database belongs to different repository",
Detail: fmt.Sprintf("stored: %s, current: %s", storedRepoID[:8], currentRepoID[:8]),
Fix: "Run 'bd migrate --update-repo-id' if URL changed, or 'rm -rf .beads && bd init --backend dolt' if wrong database",
}
}
return DoctorCheck{
Name: "Repo Fingerprint",
Status: StatusOK,
Message: fmt.Sprintf("Verified (%s)", currentRepoID[:8]),
}
}
// SQLite path (existing behavior)
// Get database path
var dbPath string
if cfg, err := configfile.Load(beadsDir); err == nil && cfg != nil && cfg.Database != "" {

View File

@@ -368,8 +368,7 @@ func CheckDatabaseConfig(repoPath string) DoctorCheck {
// CheckFreshClone detects if this is a fresh clone that needs 'bd init'.
// A fresh clone has JSONL with issues but no database file.
func CheckFreshClone(repoPath string) DoctorCheck {
// Follow redirect to resolve actual beads directory
beadsDir := resolveBeadsDir(filepath.Join(repoPath, ".beads"))
backend, beadsDir := getBackendAndBeadsDir(repoPath)
// Check if .beads/ exists
if _, err := os.Stat(beadsDir); os.IsNotExist(err) {
@@ -401,7 +400,19 @@ func CheckFreshClone(repoPath string) DoctorCheck {
}
}
// Check if database exists
// Check if database exists (backend-aware)
switch backend {
case configfile.BackendDolt:
// Dolt is directory-backed: treat .beads/dolt as the DB existence signal.
if info, err := os.Stat(filepath.Join(beadsDir, "dolt")); err == nil && info.IsDir() {
return DoctorCheck{
Name: "Fresh Clone",
Status: "ok",
Message: "Database exists",
}
}
default:
// SQLite (default): check configured .db file path.
var dbPath string
if cfg, err := configfile.Load(beadsDir); err == nil && cfg != nil && cfg.Database != "" {
dbPath = cfg.DatabasePath(beadsDir)
@@ -409,8 +420,6 @@ func CheckFreshClone(repoPath string) DoctorCheck {
// Fall back to canonical database name
dbPath = filepath.Join(beadsDir, beads.CanonicalDatabaseName)
}
// If database exists, not a fresh clone
if _, err := os.Stat(dbPath); err == nil {
return DoctorCheck{
Name: "Fresh Clone",
@@ -418,6 +427,7 @@ func CheckFreshClone(repoPath string) DoctorCheck {
Message: "Database exists",
}
}
}
// Check if JSONL has any issues (empty JSONL = not really a fresh clone)
issueCount, prefix := countJSONLIssuesAndPrefix(jsonlPath)
@@ -434,6 +444,12 @@ func CheckFreshClone(repoPath string) DoctorCheck {
if prefix != "" {
fixCmd = fmt.Sprintf("bd init --prefix %s", prefix)
}
if backend == configfile.BackendDolt {
fixCmd = "bd init --backend dolt"
if prefix != "" {
fixCmd = fmt.Sprintf("bd init --backend dolt --prefix %s", prefix)
}
}
return DoctorCheck{
Name: "Fresh Clone",

View File

@@ -54,6 +54,11 @@ func CheckSyncDivergence(path string) DoctorCheck {
}
}
backend := configfile.BackendSQLite
if cfg, err := configfile.Load(beadsDir); err == nil && cfg != nil {
backend = cfg.GetBackend()
}
var issues []SyncDivergenceIssue
// Check 1: JSONL differs from git HEAD
@@ -62,11 +67,14 @@ func CheckSyncDivergence(path string) DoctorCheck {
issues = append(issues, *jsonlIssue)
}
// Check 2: SQLite last_import_time vs JSONL mtime
// Check 2: SQLite last_import_time vs JSONL mtime (SQLite only).
// Dolt backend does not maintain SQLite metadata and does not support import-only sync.
if backend == configfile.BackendSQLite {
mtimeIssue := checkSQLiteMtimeDivergence(path, beadsDir)
if mtimeIssue != nil {
issues = append(issues, *mtimeIssue)
}
}
// Check 3: Uncommitted .beads/ changes
uncommittedIssue := checkUncommittedBeadsChanges(path, beadsDir)
@@ -75,10 +83,14 @@ func CheckSyncDivergence(path string) DoctorCheck {
}
if len(issues) == 0 {
msg := "JSONL, SQLite, and git are in sync"
if backend == configfile.BackendDolt {
msg = "JSONL, Dolt, and git are in sync"
}
return DoctorCheck{
Name: "Sync Divergence",
Status: StatusOK,
Message: "JSONL, SQLite, and git are in sync",
Message: msg,
Category: CategoryData,
}
}
@@ -256,10 +268,16 @@ func checkUncommittedBeadsChanges(path, beadsDir string) *SyncDivergenceIssue {
}
}
fixCmd := "bd sync"
// For dolt backend, bd sync/import-only workflows don't apply; recommend a plain git commit.
if cfg, err := configfile.Load(beadsDir); err == nil && cfg != nil && cfg.GetBackend() == configfile.BackendDolt {
fixCmd = "git add .beads/ && git commit -m 'sync beads'"
}
return &SyncDivergenceIssue{
Type: "uncommitted_beads",
Description: fmt.Sprintf("Uncommitted .beads/ changes (%d file(s))", fileCount),
FixCommand: "bd sync",
FixCommand: fixCmd,
}
}

View File

@@ -0,0 +1,120 @@
//go:build integration
// +build integration
package main
import (
"encoding/json"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
)
func runBDExecAllowErrorWithEnv(t *testing.T, dir string, extraEnv []string, args ...string) (string, error) {
t.Helper()
cmd := exec.Command(testBD, args...)
cmd.Dir = dir
// Start from a clean-ish environment, then apply overrides.
// NOTE: we keep os.Environ() so PATH etc still work for git/dolt.
env := append([]string{}, os.Environ()...)
env = append(env, extraEnv...)
cmd.Env = env
out, err := cmd.CombinedOutput()
return string(out), err
}
func TestDoltDaemonAutostart_NoTimeoutOnCreate(t *testing.T) {
if testing.Short() {
t.Skip("skipping slow integration test in short mode")
}
if runtime.GOOS == windowsOS {
t.Skip("dolt daemon integration test not supported on windows")
}
tmpDir := createTempDirWithCleanup(t)
// Set up a real git repo so daemon autostart is allowed.
if err := runCommandInDir(tmpDir, "git", "init"); err != nil {
t.Fatalf("git init failed: %v", err)
}
_ = runCommandInDir(tmpDir, "git", "config", "user.email", "test@example.com")
_ = runCommandInDir(tmpDir, "git", "config", "user.name", "Test User")
socketPath := filepath.Join(tmpDir, ".beads", "bd.sock")
env := []string{
"BEADS_TEST_MODE=1",
"BEADS_AUTO_START_DAEMON=true",
"BEADS_NO_DAEMON=0",
"BD_SOCKET=" + socketPath,
}
// Init dolt backend.
initOut, initErr := runBDExecAllowErrorWithEnv(t, tmpDir, env, "init", "--backend", "dolt", "--prefix", "test", "--quiet")
if initErr != nil {
// If dolt backend isn't available in this build, skip rather than fail.
// (Some environments may build without dolt support.)
lower := strings.ToLower(initOut)
if strings.Contains(lower, "dolt") && (strings.Contains(lower, "not supported") || strings.Contains(lower, "not available") || strings.Contains(lower, "unknown")) {
t.Skipf("dolt backend not available: %s", initOut)
}
t.Fatalf("bd init --backend dolt failed: %v\n%s", initErr, initOut)
}
// Always stop daemon on cleanup (best effort) so temp dir can be removed.
t.Cleanup(func() {
_, _ = runBDExecAllowErrorWithEnv(t, tmpDir, env, "daemon", "stop")
// Give the daemon a moment to release any locks/files.
time.Sleep(200 * time.Millisecond)
})
// Create should auto-start daemon and should NOT fall back with a timeout warning.
createOut, createErr := runBDExecAllowErrorWithEnv(t, tmpDir, env, "create", "dolt daemon autostart test", "--json")
if createErr != nil {
t.Fatalf("bd create failed: %v\n%s", createErr, createOut)
}
if strings.Contains(createOut, "Daemon took too long to start") || strings.Contains(createOut, "Running in direct mode") {
t.Fatalf("unexpected daemon fallback on dolt create; output:\n%s", createOut)
}
// Verify daemon reports running (via JSON output).
statusOut, statusErr := runBDExecAllowErrorWithEnv(t, tmpDir, env, "daemon", "status", "--json")
if statusErr != nil {
t.Fatalf("bd daemon status failed: %v\n%s", statusErr, statusOut)
}
// We accept either the legacy DaemonStatusResponse shape (daemon_lifecycle.go)
// or the newer DaemonStatusReport shape (daemon_status.go), depending on flags/routes.
// Here we just assert it isn't obviously "not_running".
var m map[string]any
if err := json.Unmarshal([]byte(statusOut), &m); err != nil {
// Sometimes status may print warnings before JSON; try from first '{'.
if idx := strings.Index(statusOut, "{"); idx >= 0 {
if err2 := json.Unmarshal([]byte(statusOut[idx:]), &m); err2 != nil {
t.Fatalf("failed to parse daemon status JSON: %v\n%s", err2, statusOut)
}
} else {
t.Fatalf("failed to parse daemon status JSON: %v\n%s", err, statusOut)
}
}
// Check "running" boolean (legacy) or "status" string (new).
if runningVal, ok := m["running"]; ok {
if b, ok := runningVal.(bool); ok && !b {
t.Fatalf("expected daemon running=true, got: %s", statusOut)
}
} else if statusVal, ok := m["status"]; ok {
if s, ok := statusVal.(string); ok && (s == "not_running" || s == "stale" || s == "unresponsive") {
t.Fatalf("expected daemon to be running/healthy, got status=%q; full: %s", s, statusOut)
}
} else {
// If schema changes again, this will fail loudly and force an update.
t.Fatalf("unexpected daemon status JSON shape (missing running/status): %s", statusOut)
}
}

View File

@@ -0,0 +1,90 @@
//go:build integration
// +build integration
package main
import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
)
func TestDoltDoctor_NoSQLiteWarningsAfterInitAndCreate(t *testing.T) {
if testing.Short() {
t.Skip("skipping slow integration test in short mode")
}
if runtime.GOOS == windowsOS {
t.Skip("dolt doctor integration test not supported on windows")
}
tmpDir := createTempDirWithCleanup(t)
// Set up a real git repo so init/create/doctor behave normally.
if err := runCommandInDir(tmpDir, "git", "init"); err != nil {
t.Fatalf("git init failed: %v", err)
}
_ = runCommandInDir(tmpDir, "git", "config", "user.email", "test@example.com")
_ = runCommandInDir(tmpDir, "git", "config", "user.name", "Test User")
socketPath := filepath.Join(tmpDir, ".beads", "bd.sock")
env := []string{
"BEADS_TEST_MODE=1",
"BEADS_AUTO_START_DAEMON=true",
"BEADS_NO_DAEMON=0",
"BD_SOCKET=" + socketPath,
}
// Init dolt backend.
initOut, initErr := runBDExecAllowErrorWithEnv(t, tmpDir, env, "init", "--backend", "dolt", "--prefix", "test", "--quiet")
if initErr != nil {
// If dolt backend isn't available in this build, skip rather than fail.
lower := strings.ToLower(initOut)
if strings.Contains(lower, "dolt") && (strings.Contains(lower, "not supported") || strings.Contains(lower, "not available") || strings.Contains(lower, "unknown")) {
t.Skipf("dolt backend not available: %s", initOut)
}
t.Fatalf("bd init --backend dolt failed: %v\n%s", initErr, initOut)
}
// Ensure daemon cleanup so temp dir removal doesn't flake.
t.Cleanup(func() {
_, _ = runBDExecAllowErrorWithEnv(t, tmpDir, env, "daemon", "stop")
time.Sleep(200 * time.Millisecond)
})
// Create one issue so the store is definitely initialized.
createOut, createErr := runBDExecAllowErrorWithEnv(t, tmpDir, env, "create", "doctor dolt smoke", "--json")
if createErr != nil {
t.Fatalf("bd create failed: %v\n%s", createErr, createOut)
}
// Run doctor; it may return non-zero for unrelated warnings (upstream, claude, etc),
// but it should NOT include SQLite-only failures on dolt.
doctorOut, _ := runBDExecAllowErrorWithEnv(t, tmpDir, env, "doctor")
// Also include stderr-like output if doctor wrote it to stdout in some modes.
// (CombinedOutput already captures both.)
for _, forbidden := range []string{
"No beads.db found",
"Unable to read database version",
"Legacy database",
} {
if strings.Contains(doctorOut, forbidden) {
t.Fatalf("bd doctor printed sqlite-specific warning %q in dolt mode; output:\n%s", forbidden, doctorOut)
}
}
// Sanity check: doctor should mention dolt somewhere so we know we exercised the right path.
if !strings.Contains(strings.ToLower(doctorOut), "dolt") {
// Some doctor output is terse depending on flags; don't be too strict, but
// if it's completely missing, that usually means we didn't use dolt config.
t.Fatalf("bd doctor output did not mention dolt; output:\n%s", doctorOut)
}
// Regression check: dolt init must NOT create a SQLite database file.
if _, err := os.Stat(filepath.Join(tmpDir, ".beads", "beads.db")); err == nil {
t.Fatalf("unexpected sqlite database created in dolt mode: %s", filepath.Join(tmpDir, ".beads", "beads.db"))
}
}

View File

@@ -547,6 +547,8 @@ Examples:
// Only do this when exporting to default JSONL path (not arbitrary outputs)
// This prevents validatePreExport from incorrectly blocking on next export
if output == "" || output == findJSONLPath() {
// Dolt backend does not have a SQLite DB file, so only touch mtime for SQLite.
if _, ok := store.(*sqlite.SQLiteStorage); ok {
beadsDir := filepath.Dir(finalPath)
dbPath := filepath.Join(beadsDir, "beads.db")
if err := TouchDatabaseFile(dbPath, finalPath); err != nil {
@@ -555,6 +557,7 @@ Examples:
}
}
}
}
// Output statistics if JSON format requested
if jsonOutput {

View File

@@ -131,18 +131,28 @@ With --stealth: configures per-repository git settings for invisible beads usage
// 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/beads.db
// Determine storage path.
//
// IMPORTANT: In Dolt mode, we must NOT create a SQLite database file.
// `initDBPath` is used for SQLite-specific tasks (migration, import helpers, etc),
// so in Dolt mode it should point to the Dolt directory instead.
//
// Use global dbPath if set via --db flag or BEADS_DB env var (SQLite-only),
// otherwise default to `.beads/beads.db` for SQLite.
initDBPath := dbPath
if initDBPath == "" {
if backend == configfile.BackendDolt {
initDBPath = filepath.Join(".beads", "dolt")
} else if initDBPath == "" {
initDBPath = filepath.Join(".beads", beads.CanonicalDatabaseName)
}
// Migrate old database files if they exist
// Migrate old SQLite database files if they exist (SQLite backend only).
if backend == configfile.BackendSQLite {
if err := migrateOldDatabases(initDBPath, quiet); err != nil {
fmt.Fprintf(os.Stderr, "Error during database migration: %v\n", err)
os.Exit(1)
}
}
// Determine if we should create .beads/ directory in CWD or main repo root
// For worktrees, .beads should always be in the main repository root
@@ -285,9 +295,10 @@ With --stealth: configures per-repository git settings for invisible beads usage
}
}
// Ensure parent directory exists for the database
// Ensure parent directory exists for the storage backend.
// For SQLite: parent of .beads/beads.db. For Dolt: parent of .beads/dolt.
if err := os.MkdirAll(initDBDir, 0750); err != nil {
fmt.Fprintf(os.Stderr, "Error: failed to create database directory %s: %v\n", initDBDir, err)
fmt.Fprintf(os.Stderr, "Error: failed to create storage directory %s: %v\n", initDBDir, err)
os.Exit(1)
}
@@ -389,6 +400,14 @@ With --stealth: configures per-repository git settings for invisible beads usage
if backend != configfile.BackendSQLite {
cfg.Backend = backend
}
// In Dolt mode, metadata.json.database should point to the Dolt directory (not beads.db).
// Backward-compat: older dolt setups left this as "beads.db", which is misleading and
// can trigger SQLite-only code paths.
if backend == configfile.BackendDolt {
if cfg.Database == "" || cfg.Database == beads.CanonicalDatabaseName {
cfg.Database = "dolt"
}
}
if err := cfg.Save(beadsDir); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to create metadata.json: %v\n", err)
@@ -429,6 +448,11 @@ With --stealth: configures per-repository git settings for invisible beads usage
}
}
// Import issues on init:
// - SQLite backend: import from git history or local JSONL (existing behavior).
// - Dolt backend: do NOT run SQLite import code. Dolt bootstraps itself from
// `.beads/issues.jsonl` on first open (factory_dolt.go) when present.
if backend == configfile.BackendSQLite {
// Check if git has existing issues to import (fresh clone scenario)
// With --from-jsonl: import from local file instead of git history
if fromJSONL {
@@ -466,6 +490,7 @@ With --stealth: configures per-repository git settings for invisible beads usage
}
}
}
}
// Run contributor wizard if --contributor flag is set
if contributor {
@@ -676,7 +701,6 @@ func migrateOldDatabases(targetPath string, quiet bool) error {
return nil
}
// readFirstIssueFromJSONL reads the first issue from a JSONL file
func readFirstIssueFromJSONL(path string) (*types.Issue, error) {
// #nosec G304 -- helper reads JSONL file chosen by current bd command
@@ -744,7 +768,6 @@ func readFirstIssueFromGit(jsonlPath, gitRef string) (*types.Issue, error) {
return nil, nil
}
// checkExistingBeadsData checks for existing database files
// and returns an error if found (safety guard for bd-emg)
//
@@ -781,7 +804,29 @@ func checkExistingBeadsData(prefix string) error {
return nil // No .beads directory, safe to init
}
// Check for existing database file
// Check for existing database (SQLite or Dolt)
//
// NOTE: For Dolt backend, the "database" is a directory at `.beads/dolt/`.
// We prefer metadata.json as the single source of truth, but we also keep a
// conservative fallback for legacy SQLite setups.
if cfg, err := configfile.Load(beadsDir); err == nil && cfg != nil && cfg.GetBackend() == configfile.BackendDolt {
doltPath := filepath.Join(beadsDir, "dolt")
if info, err := os.Stat(doltPath); err == nil && info.IsDir() {
return fmt.Errorf(`
%s Found existing Dolt database: %s
This workspace is already initialized.
To use the existing database:
Just run bd commands normally (e.g., %s)
To completely reinitialize (data loss warning):
rm -rf .beads && bd init --backend dolt --prefix %s
Aborting.`, ui.RenderWarn("⚠"), doltPath, ui.RenderAccent("bd list"), prefix)
}
}
dbPath := filepath.Join(beadsDir, beads.CanonicalDatabaseName)
if _, err := os.Stat(dbPath); err == nil {
return fmt.Errorf(`

View File

@@ -12,6 +12,7 @@ import (
"github.com/steveyegge/beads/internal/config"
"github.com/steveyegge/beads/internal/rpc"
"github.com/steveyegge/beads/internal/storage/sqlite"
"github.com/steveyegge/beads/internal/types"
"github.com/steveyegge/beads/internal/ui"
"github.com/steveyegge/beads/internal/validation"
@@ -86,8 +87,11 @@ func finalizeExport(ctx context.Context, result *ExportResult) {
}
// Update database mtime to be >= JSONL mtime (fixes #278, #301, #321)
// This prevents validatePreExport from incorrectly blocking on next export
// This prevents validatePreExport from incorrectly blocking on next export.
//
// Dolt backend does not use a SQLite DB file, so this check is SQLite-only.
if result.JSONLPath != "" {
if _, ok := store.(*sqlite.SQLiteStorage); ok {
beadsDir := filepath.Dir(result.JSONLPath)
dbPath := filepath.Join(beadsDir, "beads.db")
if err := TouchDatabaseFile(dbPath, result.JSONLPath); err != nil {
@@ -95,6 +99,7 @@ func finalizeExport(ctx context.Context, result *ExportResult) {
fmt.Fprintf(os.Stderr, "Warning: failed to update database mtime: %v\n", err)
}
}
}
}
// exportToJSONL exports the database to JSONL format.

View File

@@ -18,6 +18,9 @@ First time in a repository:
# Basic setup
bd init
# Dolt backend (version-controlled SQL database)
bd init --backend dolt
# OSS contributor (fork workflow with separate planning repo)
bd init --contributor
@@ -35,6 +38,10 @@ The wizard will:
- Prompt to configure git merge driver (recommended)
- Auto-start daemon for sync
Notes:
- SQLite backend stores data in `.beads/beads.db`.
- Dolt backend stores data in `.beads/dolt/` and records `"database": "dolt"` in `.beads/metadata.json`.
## Your First Issues
```bash

View File

@@ -218,6 +218,7 @@ func findLocalBeadsDir() string {
// 1. Check metadata.json first (single source of truth)
// - For SQLite backend: returns path to .db file
// - For Dolt backend: returns path to dolt/ directory
//
// 2. Fall back to canonical beads.db
// 3. Search for *.db files, filtering out backups and vc.db
//
@@ -231,8 +232,8 @@ func findDatabaseInBeadsDir(beadsDir string, warnOnIssues bool) string {
if cfg, err := configfile.Load(beadsDir); err == nil && cfg != nil {
backend := cfg.GetBackend()
if backend == configfile.BackendDolt {
// For Dolt, check if the dolt directory exists
doltPath := filepath.Join(beadsDir, "dolt")
// For Dolt, check if the configured database directory exists
doltPath := cfg.DatabasePath(beadsDir)
if info, err := os.Stat(doltPath); err == nil && info.IsDir() {
return doltPath
}

View File

@@ -5,6 +5,7 @@ import (
"fmt"
"os"
"path/filepath"
"strings"
)
const ConfigFileName = "metadata.json"
@@ -94,7 +95,34 @@ func (c *Config) Save(beadsDir string) error {
}
func (c *Config) DatabasePath(beadsDir string) string {
return filepath.Join(beadsDir, c.Database)
backend := c.GetBackend()
// Treat Database as the on-disk storage location:
// - SQLite: filename (default: beads.db)
// - Dolt: directory name (default: dolt)
//
// Backward-compat: early dolt configs wrote "beads.db" even when Backend=dolt.
// In that case, treat it as "dolt".
if backend == BackendDolt {
db := strings.TrimSpace(c.Database)
if db == "" || db == "beads.db" {
db = "dolt"
}
if filepath.IsAbs(db) {
return db
}
return filepath.Join(beadsDir, db)
}
// SQLite (default)
db := strings.TrimSpace(c.Database)
if db == "" {
db = "beads.db"
}
if filepath.IsAbs(db) {
return db
}
return filepath.Join(beadsDir, db)
}
func (c *Config) JSONLPath(beadsDir string) string {

View File

@@ -75,6 +75,28 @@ func TestDatabasePath(t *testing.T) {
}
}
func TestDatabasePath_Dolt(t *testing.T) {
beadsDir := "/home/user/project/.beads"
t.Run("explicit dolt dir", func(t *testing.T) {
cfg := &Config{Database: "dolt", Backend: BackendDolt}
got := cfg.DatabasePath(beadsDir)
want := filepath.Join(beadsDir, "dolt")
if got != want {
t.Errorf("DatabasePath() = %q, want %q", got, want)
}
})
t.Run("backward compat: dolt backend with beads.db field", func(t *testing.T) {
cfg := &Config{Database: "beads.db", Backend: BackendDolt}
got := cfg.DatabasePath(beadsDir)
want := filepath.Join(beadsDir, "dolt")
if got != want {
t.Errorf("DatabasePath() = %q, want %q", got, want)
}
})
}
func TestJSONLPath(t *testing.T) {
beadsDir := "/home/user/project/.beads"

View File

@@ -122,11 +122,33 @@ func GetGitCommonDir() (string, error) {
// and live in the common git directory (e.g., /repo/.git/hooks), not in
// the worktree-specific directory (e.g., /repo/.git/worktrees/feature/hooks).
func GetGitHooksDir() (string, error) {
commonDir, err := GetGitCommonDir()
ctx, err := getGitContext()
if err != nil {
return "", err
}
return filepath.Join(commonDir, "hooks"), nil
// Respect core.hooksPath if configured.
// This is used by beads' Dolt backend (hooks installed to .beads/hooks/).
cmd := exec.Command("git", "config", "--get", "core.hooksPath")
cmd.Dir = ctx.repoRoot
if out, err := cmd.Output(); err == nil {
hooksPath := strings.TrimSpace(string(out))
if hooksPath != "" {
if filepath.IsAbs(hooksPath) {
return hooksPath, nil
}
// Git treats relative core.hooksPath as relative to the repo root in common usage.
// (e.g., ".beads/hooks", ".githooks").
p := filepath.Join(ctx.repoRoot, hooksPath)
if abs, err := filepath.Abs(p); err == nil {
return abs, nil
}
return p, nil
}
}
// Default: hooks are stored in the common git directory.
return filepath.Join(ctx.commonDir, "hooks"), nil
}
// GetGitRefsDir returns the path to the Git refs directory.

View File

@@ -4,7 +4,6 @@ package factory
import (
"context"
"fmt"
"path/filepath"
"time"
"github.com/steveyegge/beads/internal/configfile"
@@ -84,9 +83,7 @@ func NewFromConfigWithOptions(ctx context.Context, beadsDir string, opts Options
case configfile.BackendSQLite:
return NewWithOptions(ctx, backend, cfg.DatabasePath(beadsDir), opts)
case configfile.BackendDolt:
// For Dolt, use a subdirectory to store the Dolt database
doltPath := filepath.Join(beadsDir, "dolt")
return NewWithOptions(ctx, backend, doltPath, opts)
return NewWithOptions(ctx, backend, cfg.DatabasePath(beadsDir), opts)
default:
return nil, fmt.Errorf("unknown storage backend in config: %s", backend)
}

View File

@@ -16,6 +16,9 @@ First time in a repository:
# Basic setup
bd init
# Dolt backend (version-controlled SQL database)
bd init --backend dolt
# For AI agents (non-interactive)
bd init --quiet
@@ -36,6 +39,10 @@ The wizard will:
- Prompt to configure git merge driver (recommended)
- Auto-start daemon for sync
Notes:
- SQLite backend stores data in `.beads/beads.db`.
- Dolt backend stores data in `.beads/dolt/` and records `"database": "dolt"` in `.beads/metadata.json`.
## Your First Issues
```bash