From 0ee020ed7675a3758f48d7bd04e91a1ee6b0c367 Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Thu, 22 Jan 2026 20:52:20 -0800 Subject: [PATCH] feat(dolt): auto-commit write commands and set explicit commit authors (#1270) Adds Dolt auto-commit functionality for write commands and sets explicit commit authors. Includes fix for race condition in commandDidWrite (converted to atomic.Bool). Original PR: #1267 by @coffeegoddd Co-authored-by: Dustin Brown --- cmd/bd/autoflush.go | 18 +- cmd/bd/dolt_autocommit.go | 103 +++++++++++ cmd/bd/dolt_autocommit_config.go | 28 +++ cmd/bd/dolt_autocommit_integration_test.go | 198 +++++++++++++++++++++ cmd/bd/dolt_autocommit_test.go | 40 +++++ cmd/bd/hook.go | 4 + cmd/bd/import.go | 8 +- cmd/bd/main.go | 103 +++++++++-- cmd/bd/sync.go | 2 + cmd/bd/sync_import.go | 12 ++ cmd/bd/tips.go | 41 ++++- cmd/bd/vc.go | 2 + docs/CONFIG.md | 26 +++ docs/GIT_INTEGRATION.md | 5 +- docs/QUICKSTART.md | 5 +- docs/WORKTREES.md | 5 +- internal/config/config.go | 15 +- internal/storage/dolt/store.go | 26 ++- 18 files changed, 596 insertions(+), 45 deletions(-) create mode 100644 cmd/bd/dolt_autocommit.go create mode 100644 cmd/bd/dolt_autocommit_config.go create mode 100644 cmd/bd/dolt_autocommit_integration_test.go create mode 100644 cmd/bd/dolt_autocommit_test.go diff --git a/cmd/bd/autoflush.go b/cmd/bd/autoflush.go index d0ad516e..db6201d4 100644 --- a/cmd/bd/autoflush.go +++ b/cmd/bd/autoflush.go @@ -435,9 +435,12 @@ func autoImportIfNewer() { // Flush-on-exit guarantee: PersistentPostRun calls flushManager.Shutdown() which // performs a final flush before the command exits, ensuring no data is lost. // -// Thread-safe: Safe to call from multiple goroutines (no shared mutable state). +// Thread-safe: Safe to call from multiple goroutines (uses atomic.Bool). // No-op if auto-flush is disabled via --no-auto-flush flag. func markDirtyAndScheduleFlush() { + // Track that this command performed a write (atomic to avoid data races). + commandDidWrite.Store(true) + // Use FlushManager if available // No FlushManager means sandbox mode or test without flush setup - no-op is correct if flushManager != nil { @@ -447,6 +450,9 @@ func markDirtyAndScheduleFlush() { // markDirtyAndScheduleFullExport marks DB as needing a full export (for ID-changing operations) func markDirtyAndScheduleFullExport() { + // Track that this command performed a write (atomic to avoid data races). + commandDidWrite.Store(true) + // Use FlushManager if available // No FlushManager means sandbox mode or test without flush setup - no-op is correct if flushManager != nil { @@ -472,11 +478,11 @@ func clearAutoFlushState() { // // Atomic write pattern: // -// 1. Create temp file with PID suffix: issues.jsonl.tmp.12345 -// 2. Write all issues as JSONL to temp file -// 3. Close temp file -// 4. Atomic rename: temp → target -// 5. Set file permissions to 0644 +// 1. Create temp file with PID suffix: issues.jsonl.tmp.12345 +// 2. Write all issues as JSONL to temp file +// 3. Close temp file +// 4. Atomic rename: temp → target +// 5. Set file permissions to 0644 // // Error handling: Returns error on any failure. Cleanup is guaranteed via defer. // Thread-safe: No shared state access. Safe to call from multiple goroutines. diff --git a/cmd/bd/dolt_autocommit.go b/cmd/bd/dolt_autocommit.go new file mode 100644 index 00000000..cca4cfe2 --- /dev/null +++ b/cmd/bd/dolt_autocommit.go @@ -0,0 +1,103 @@ +package main + +import ( + "context" + "fmt" + "slices" + "strings" + + "github.com/steveyegge/beads/internal/storage" +) + +type doltAutoCommitParams struct { + // Command is the top-level bd command name (e.g., "create", "update"). + Command string + // IssueIDs are the primary issue IDs affected by the command (optional). + IssueIDs []string + // MessageOverride, if non-empty, is used verbatim. + MessageOverride string +} + +// maybeAutoCommit creates a Dolt commit after a successful write command when enabled. +// +// Semantics: +// - Only applies when dolt auto-commit is enabled (on) AND the active store is versioned (Dolt). +// - Uses Dolt's "commit all" behavior under the hood (DOLT_COMMIT -Am). +// - Treats "nothing to commit" as a no-op. +func maybeAutoCommit(ctx context.Context, p doltAutoCommitParams) error { + mode, err := getDoltAutoCommitMode() + if err != nil { + return err + } + if mode != doltAutoCommitOn { + return nil + } + + st := getStore() + vs, ok := storage.AsVersioned(st) + if !ok { + return nil + } + + msg := p.MessageOverride + if strings.TrimSpace(msg) == "" { + msg = formatDoltAutoCommitMessage(p.Command, getActor(), p.IssueIDs) + } + + if err := vs.Commit(ctx, msg); err != nil { + if isDoltNothingToCommit(err) { + return nil + } + return err + } + return nil +} + +func isDoltNothingToCommit(err error) bool { + if err == nil { + return false + } + s := strings.ToLower(err.Error()) + // Dolt commonly reports "nothing to commit". + if strings.Contains(s, "nothing to commit") { + return true + } + // Some versions/paths may report "no changes". + if strings.Contains(s, "no changes") && strings.Contains(s, "commit") { + return true + } + return false +} + +func formatDoltAutoCommitMessage(cmd string, actor string, issueIDs []string) string { + cmd = strings.TrimSpace(cmd) + if cmd == "" { + cmd = "write" + } + actor = strings.TrimSpace(actor) + if actor == "" { + actor = "unknown" + } + + ids := make([]string, 0, len(issueIDs)) + seen := make(map[string]bool, len(issueIDs)) + for _, id := range issueIDs { + id = strings.TrimSpace(id) + if id == "" || seen[id] { + continue + } + seen[id] = true + ids = append(ids, id) + } + slices.Sort(ids) + + const maxIDs = 5 + if len(ids) > maxIDs { + ids = ids[:maxIDs] + } + + if len(ids) == 0 { + return fmt.Sprintf("bd: %s (auto-commit) by %s", cmd, actor) + } + return fmt.Sprintf("bd: %s (auto-commit) by %s [%s]", cmd, actor, strings.Join(ids, ", ")) +} diff --git a/cmd/bd/dolt_autocommit_config.go b/cmd/bd/dolt_autocommit_config.go new file mode 100644 index 00000000..2ba47b61 --- /dev/null +++ b/cmd/bd/dolt_autocommit_config.go @@ -0,0 +1,28 @@ +package main + +import ( + "fmt" + "strings" +) + +type doltAutoCommitMode string + +const ( + doltAutoCommitOff doltAutoCommitMode = "off" + doltAutoCommitOn doltAutoCommitMode = "on" +) + +func getDoltAutoCommitMode() (doltAutoCommitMode, error) { + mode := strings.TrimSpace(strings.ToLower(doltAutoCommit)) + if mode == "" { + mode = string(doltAutoCommitOn) + } + switch doltAutoCommitMode(mode) { + case doltAutoCommitOff: + return doltAutoCommitOff, nil + case doltAutoCommitOn: + return doltAutoCommitOn, nil + default: + return "", fmt.Errorf("invalid --dolt-auto-commit=%q (valid: off, on)", doltAutoCommit) + } +} diff --git a/cmd/bd/dolt_autocommit_integration_test.go b/cmd/bd/dolt_autocommit_integration_test.go new file mode 100644 index 00000000..ab48a5ec --- /dev/null +++ b/cmd/bd/dolt_autocommit_integration_test.go @@ -0,0 +1,198 @@ +//go:build integration +// +build integration + +package main + +import ( + "encoding/json" + "fmt" + "io/fs" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func doltHeadCommit(t *testing.T, dir string, env []string) string { + t.Helper() + out, err := runBDExecAllowErrorWithEnv(t, dir, env, "--json", "vc", "status") + if err != nil { + t.Fatalf("bd vc status failed: %v\n%s", err, out) + } + var m map[string]any + if err := json.Unmarshal([]byte(out), &m); err != nil { + // Some commands can emit warnings; try from first '{' + if idx := strings.Index(out, "{"); idx >= 0 { + if err2 := json.Unmarshal([]byte(out[idx:]), &m); err2 != nil { + t.Fatalf("failed to parse vc status JSON: %v\n%s", err2, out) + } + } else { + t.Fatalf("failed to parse vc status JSON: %v\n%s", err, out) + } + } + commit, _ := m["commit"].(string) + if commit == "" { + t.Fatalf("missing commit in vc status output:\n%s", out) + } + return commit +} + +func runCommandInDirCombinedOutput(dir string, name string, args ...string) (string, error) { + cmd := exec.Command(name, args...) // #nosec G204 -- test helper executes trusted binaries + cmd.Dir = dir + out, err := cmd.CombinedOutput() + return strings.TrimSpace(string(out)), err +} + +func findDoltRepoDir(t *testing.T, dir string) string { + t.Helper() + + // Embedded driver may create either: + // - a dolt repo directly at .beads/dolt/ + // - a dolt environment at .beads/dolt/ with a db subdir containing .dolt/ + base := filepath.Join(dir, ".beads", "dolt") + candidates := []string{ + base, + filepath.Join(base, "beads"), + } + for _, c := range candidates { + if _, err := os.Stat(filepath.Join(c, ".dolt")); err == nil { + return c + } + } + + var found string + _ = filepath.WalkDir(base, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return nil + } + if d.IsDir() && d.Name() == ".dolt" { + found = filepath.Dir(path) + return fs.SkipDir + } + return nil + }) + if found == "" { + t.Fatalf("could not find Dolt repo dir under %s", base) + } + return found +} + +func doltHeadAuthor(t *testing.T, dir string) string { + t.Helper() + + doltDir := findDoltRepoDir(t, dir) + out, err := runCommandInDirCombinedOutput(doltDir, "dolt", "log", "-n", "1") + if err != nil { + t.Fatalf("dolt log failed: %v\n%s", err, out) + } + for _, line := range strings.Split(out, "\n") { + if strings.HasPrefix(line, "Author:") { + return strings.TrimSpace(strings.TrimPrefix(line, "Author:")) + } + } + t.Fatalf("missing Author in dolt log output:\n%s", out) + return "" +} + +func TestDoltAutoCommit_On_WritesAdvanceHead(t *testing.T) { + if testing.Short() { + t.Skip("skipping slow integration test in short mode") + } + if runtime.GOOS == windowsOS { + t.Skip("dolt integration test not supported on windows") + } + + tmpDir := createTempDirWithCleanup(t) + setupGitRepoForIntegration(t, tmpDir) + + env := []string{ + "BEADS_TEST_MODE=1", + "BEADS_NO_DAEMON=1", + } + + initOut, initErr := runBDExecAllowErrorWithEnv(t, tmpDir, env, "init", "--backend", "dolt", "--prefix", "test", "--quiet") + if initErr != nil { + if isDoltBackendUnavailable(initOut) { + t.Skipf("dolt backend not available: %s", initOut) + } + t.Fatalf("bd init --backend dolt failed: %v\n%s", initErr, initOut) + } + + before := doltHeadCommit(t, tmpDir, env) + + // A write command should create a new Dolt commit (auto-commit default is on). + out, err := runBDExecAllowErrorWithEnv(t, tmpDir, env, "create", "Auto-commit test", "--json") + if err != nil { + t.Fatalf("bd create failed: %v\n%s", err, out) + } + + after := doltHeadCommit(t, tmpDir, env) + if after == before { + t.Fatalf("expected Dolt HEAD to change after write; before=%s after=%s", before, after) + } + + // Commit author should be deterministic (not the authenticated SQL user like root@%). + expectedName := os.Getenv("GIT_AUTHOR_NAME") + if expectedName == "" { + expectedName = "beads" + } + expectedEmail := os.Getenv("GIT_AUTHOR_EMAIL") + if expectedEmail == "" { + expectedEmail = "beads@local" + } + expectedAuthor := fmt.Sprintf("%s <%s>", expectedName, expectedEmail) + if got := doltHeadAuthor(t, tmpDir); got != expectedAuthor { + t.Fatalf("expected Dolt commit author %q, got %q", expectedAuthor, got) + } + + // A read-only command should not create another commit. + out, err = runBDExecAllowErrorWithEnv(t, tmpDir, env, "list") + if err != nil { + t.Fatalf("bd list failed: %v\n%s", err, out) + } + afterList := doltHeadCommit(t, tmpDir, env) + if afterList != after { + t.Fatalf("expected Dolt HEAD unchanged after read command; before=%s after=%s", after, afterList) + } +} + +func TestDoltAutoCommit_Off_DoesNotAdvanceHead(t *testing.T) { + if testing.Short() { + t.Skip("skipping slow integration test in short mode") + } + if runtime.GOOS == windowsOS { + t.Skip("dolt integration test not supported on windows") + } + + tmpDir := createTempDirWithCleanup(t) + setupGitRepoForIntegration(t, tmpDir) + + env := []string{ + "BEADS_TEST_MODE=1", + "BEADS_NO_DAEMON=1", + } + + initOut, initErr := runBDExecAllowErrorWithEnv(t, tmpDir, env, "init", "--backend", "dolt", "--prefix", "test", "--quiet") + if initErr != nil { + if isDoltBackendUnavailable(initOut) { + t.Skipf("dolt backend not available: %s", initOut) + } + t.Fatalf("bd init --backend dolt failed: %v\n%s", initErr, initOut) + } + + before := doltHeadCommit(t, tmpDir, env) + + // Disable auto-commit via persistent flag (must come before subcommand). + out, err := runBDExecAllowErrorWithEnv(t, tmpDir, env, "--dolt-auto-commit", "off", "create", "Auto-commit off", "--json") + if err != nil { + t.Fatalf("bd create failed: %v\n%s", err, out) + } + + after := doltHeadCommit(t, tmpDir, env) + if after != before { + t.Fatalf("expected Dolt HEAD unchanged with auto-commit off; before=%s after=%s", before, after) + } +} diff --git a/cmd/bd/dolt_autocommit_test.go b/cmd/bd/dolt_autocommit_test.go new file mode 100644 index 00000000..519b9099 --- /dev/null +++ b/cmd/bd/dolt_autocommit_test.go @@ -0,0 +1,40 @@ +package main + +import ( + "errors" + "testing" +) + +func TestFormatDoltAutoCommitMessage(t *testing.T) { + msg := formatDoltAutoCommitMessage("update", "alice", []string{"bd-2", "bd-1", "bd-2", "", "bd-3"}) + if msg != "bd: update (auto-commit) by alice [bd-1, bd-2, bd-3]" { + t.Fatalf("unexpected message: %q", msg) + } + + // Caps IDs (max 5) and sorts + msg = formatDoltAutoCommitMessage("create", "bob", []string{"z-9", "a-1", "m-3", "b-2", "c-4", "d-5", "e-6"}) + if msg != "bd: create (auto-commit) by bob [a-1, b-2, c-4, d-5, e-6]" { + t.Fatalf("unexpected capped message: %q", msg) + } + + // Empty command/actor fallbacks + msg = formatDoltAutoCommitMessage("", "", nil) + if msg != "bd: write (auto-commit) by unknown" { + t.Fatalf("unexpected fallback message: %q", msg) + } +} + +func TestIsDoltNothingToCommit(t *testing.T) { + if isDoltNothingToCommit(nil) { + t.Fatal("nil error should not be treated as nothing-to-commit") + } + if !isDoltNothingToCommit(errors.New("nothing to commit")) { + t.Fatal("expected nothing-to-commit to be detected") + } + if !isDoltNothingToCommit(errors.New("No changes to commit")) { + t.Fatal("expected no-changes-to-commit to be detected") + } + if isDoltNothingToCommit(errors.New("permission denied")) { + t.Fatal("unexpected classification") + } +} diff --git a/cmd/bd/hook.go b/cmd/bd/hook.go index 5bf5c0e3..b19cd3cd 100644 --- a/cmd/bd/hook.go +++ b/cmd/bd/hook.go @@ -680,6 +680,8 @@ func hookPostMergeDolt(beadsDir string) int { } // Commit changes on import branch + // This hook flow commits to Dolt explicitly; avoid redundant auto-commit in PersistentPostRun. + commandDidExplicitDoltCommit = true if err := doltStore.Commit(ctx, "Import from JSONL"); err != nil { fmt.Fprintf(os.Stderr, "Warning: could not commit import: %v\n", err) } @@ -702,6 +704,8 @@ func hookPostMergeDolt(beadsDir string) int { } // Commit the merge + // Still part of explicit hook commit flow. + commandDidExplicitDoltCommit = true if err := doltStore.Commit(ctx, "Merge JSONL import"); err != nil { // May fail if nothing to commit (fast-forward merge) // This is expected, not an error diff --git a/cmd/bd/import.go b/cmd/bd/import.go index 6157a4d6..0bceff51 100644 --- a/cmd/bd/import.go +++ b/cmd/bd/import.go @@ -407,6 +407,12 @@ NOTE: Import requires direct database access and does not work with daemon mode. fmt.Fprintf(os.Stderr, "\nAll text and dependency references have been updated.\n") } + // Mark this command as having performed a write if it changed anything. + // This enables Dolt auto-commit in PersistentPostRun. + if result.Created > 0 || result.Updated > 0 || len(result.IDMapping) > 0 { + commandDidWrite.Store(true) + } + // Flush immediately after import (no debounce) to ensure daemon sees changes // Without this, daemon FileWatcher won't detect the import for up to 30s // Only flush if there were actual changes to avoid unnecessary I/O @@ -589,7 +595,7 @@ func checkUncommittedChanges(filePath string, result *ImportResult) { // Get line counts for context workingTreeLines := countLines(filePath) headLines := countLinesInGitHEAD(filePath, workDir) - + fmt.Fprintf(os.Stderr, "\n⚠️ Warning: %s has uncommitted changes\n", filePath) fmt.Fprintf(os.Stderr, " Working tree: %d lines\n", workingTreeLines) if headLines > 0 { diff --git a/cmd/bd/main.go b/cmd/bd/main.go index 7444583f..11795c18 100644 --- a/cmd/bd/main.go +++ b/cmd/bd/main.go @@ -12,6 +12,7 @@ import ( "slices" "strings" "sync" + "sync/atomic" "syscall" "time" @@ -71,19 +72,41 @@ var ( upgradeAcknowledged = false // Set to true after showing upgrade notification once per session ) var ( - noAutoFlush bool - noAutoImport bool - sandboxMode bool - allowStale bool // Use --allow-stale: skip staleness check (emergency escape hatch) - noDb bool // Use --no-db mode: load from JSONL, write back after each command + noAutoFlush bool + noAutoImport bool + sandboxMode bool + allowStale bool // Use --allow-stale: skip staleness check (emergency escape hatch) + noDb bool // Use --no-db mode: load from JSONL, write back after each command readonlyMode bool // Read-only mode: block write operations (for worker sandboxes) storeIsReadOnly bool // Track if store was opened read-only (for staleness checks) lockTimeout time.Duration // SQLite busy_timeout (default 30s, 0 = fail immediately) - profileEnabled bool - profileFile *os.File - traceFile *os.File - verboseFlag bool // Enable verbose/debug output - quietFlag bool // Suppress non-essential output + profileEnabled bool + profileFile *os.File + traceFile *os.File + verboseFlag bool // Enable verbose/debug output + quietFlag bool // Suppress non-essential output + + // Dolt auto-commit policy (flag/config). Values: off | on + doltAutoCommit string + + // commandDidWrite is set when a command performs a write that should trigger + // auto-flush. Used to decide whether to auto-commit Dolt after the command completes. + // Thread-safe via atomic.Bool to avoid data races in concurrent flush operations. + commandDidWrite atomic.Bool + + // commandDidExplicitDoltCommit is set when a command already created a Dolt commit + // explicitly (e.g., bd sync in dolt-native mode, hook flows, bd vc commit). + // This prevents a redundant auto-commit attempt in PersistentPostRun. + commandDidExplicitDoltCommit bool + + // commandDidWriteTipMetadata is set when a command records a tip as "shown" by writing + // metadata (tip_*_last_shown). This will be used to create a separate Dolt commit for + // tip writes, even when the main command is read-only. + commandDidWriteTipMetadata bool + + // commandTipIDsShown tracks which tip IDs were shown in this command (deduped). + // This is used for tip-commit message formatting. + commandTipIDsShown map[string]struct{} ) // readOnlyCommands lists commands that only read from the database. @@ -189,6 +212,7 @@ func init() { rootCmd.PersistentFlags().BoolVar(&allowStale, "allow-stale", false, "Allow operations on potentially stale data (skip staleness check)") rootCmd.PersistentFlags().BoolVar(&noDb, "no-db", false, "Use no-db mode: load from JSONL, no SQLite") rootCmd.PersistentFlags().BoolVar(&readonlyMode, "readonly", false, "Read-only mode: block write operations (for worker sandboxes)") + rootCmd.PersistentFlags().StringVar(&doltAutoCommit, "dolt-auto-commit", "", "Dolt backend: auto-commit after write commands (off|on). Default from config key dolt.auto-commit") rootCmd.PersistentFlags().DurationVar(&lockTimeout, "lock-timeout", 30*time.Second, "SQLite busy timeout (0 = fail immediately if locked)") rootCmd.PersistentFlags().BoolVar(&profileEnabled, "profile", false, "Generate CPU profile for performance analysis") rootCmd.PersistentFlags().BoolVarP(&verboseFlag, "verbose", "v", false, "Enable verbose/debug output") @@ -231,6 +255,12 @@ var rootCmd = &cobra.Command{ // Initialize CommandContext to hold runtime state (replaces scattered globals) initCommandContext() + // Reset per-command write tracking (used by Dolt auto-commit). + commandDidWrite.Store(false) + commandDidExplicitDoltCommit = false + commandDidWriteTipMetadata = false + commandTipIDsShown = make(map[string]struct{}) + // Set up signal-aware context for graceful cancellation rootCtx, rootCancel = signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) @@ -321,6 +351,14 @@ var rootCmd = &cobra.Command{ WasSet bool }{actor, true} } + if !cmd.Flags().Changed("dolt-auto-commit") && strings.TrimSpace(doltAutoCommit) == "" { + doltAutoCommit = config.GetString("dolt.auto-commit") + } else if cmd.Flags().Changed("dolt-auto-commit") { + flagOverrides["dolt-auto-commit"] = struct { + Value interface{} + WasSet bool + }{doltAutoCommit, true} + } // Check for and log configuration overrides (only in verbose mode) if verboseFlag { @@ -330,6 +368,12 @@ var rootCmd = &cobra.Command{ } } + // Validate Dolt auto-commit mode early so all commands fail fast on invalid config. + if _, err := getDoltAutoCommitMode(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + // GH#1093: Check noDbCommands BEFORE expensive operations (ensureForkProtection, // signalOrchestratorActivity) to avoid spawning git subprocesses for simple commands // like "bd version" that don't need database access. @@ -930,6 +974,45 @@ var rootCmd = &cobra.Command{ } } + // Dolt auto-commit: after a successful write command (and after final flush), + // create a Dolt commit so changes don't remain only in the working set. + if commandDidWrite.Load() && !commandDidExplicitDoltCommit { + if err := maybeAutoCommit(rootCtx, doltAutoCommitParams{Command: cmd.Name()}); err != nil { + fmt.Fprintf(os.Stderr, "Error: dolt auto-commit failed: %v\n", err) + os.Exit(1) + } + } + + // Tip metadata auto-commit: if a tip was shown, create a separate Dolt commit for the + // tip_*_last_shown metadata updates. This may happen even for otherwise read-only commands. + if commandDidWriteTipMetadata && len(commandTipIDsShown) > 0 { + // Only applies when dolt auto-commit is enabled and backend is versioned (Dolt). + if mode, err := getDoltAutoCommitMode(); err != nil { + fmt.Fprintf(os.Stderr, "Error: dolt tip auto-commit failed: %v\n", err) + os.Exit(1) + } else if mode == doltAutoCommitOn { + // Apply tip metadata writes now (deferred in recordTipShown for Dolt). + for tipID := range commandTipIDsShown { + key := fmt.Sprintf("tip_%s_last_shown", tipID) + value := time.Now().Format(time.RFC3339) + if err := store.SetMetadata(rootCtx, key, value); err != nil { + fmt.Fprintf(os.Stderr, "Error: dolt tip auto-commit failed: %v\n", err) + os.Exit(1) + } + } + + ids := make([]string, 0, len(commandTipIDsShown)) + for tipID := range commandTipIDsShown { + ids = append(ids, tipID) + } + msg := formatDoltAutoCommitMessage("tip", getActor(), ids) + if err := maybeAutoCommit(rootCtx, doltAutoCommitParams{Command: "tip", MessageOverride: msg}); err != nil { + fmt.Fprintf(os.Stderr, "Error: dolt tip auto-commit failed: %v\n", err) + os.Exit(1) + } + } + } + // Signal that store is closing (prevents background flush from accessing closed store) storeMutex.Lock() storeActive = false diff --git a/cmd/bd/sync.go b/cmd/bd/sync.go index cb449656..2b965b0c 100644 --- a/cmd/bd/sync.go +++ b/cmd/bd/sync.go @@ -742,6 +742,8 @@ func doExportSync(ctx context.Context, jsonlPath string, force, dryRun bool) err fmt.Println("⚠ Dolt remote not available, falling back to JSONL-only") } else { fmt.Println("→ Committing to Dolt...") + // We are explicitly creating a Dolt commit inside sync; avoid redundant auto-commit in PersistentPostRun. + commandDidExplicitDoltCommit = true if err := rs.Commit(ctx, "bd sync: auto-commit"); err != nil { // Ignore "nothing to commit" errors if !strings.Contains(err.Error(), "nothing to commit") { diff --git a/cmd/bd/sync_import.go b/cmd/bd/sync_import.go index 4897e52e..1618f832 100644 --- a/cmd/bd/sync_import.go +++ b/cmd/bd/sync_import.go @@ -136,6 +136,18 @@ func importFromJSONLInline(ctx context.Context, jsonlPath string, renameOnImport return fmt.Errorf("import failed: %w", err) } + // Mark command as having performed a write when the import changed anything. + // This enables Dolt auto-commit in PersistentPostRun. + if result.Created > 0 || result.Updated > 0 || len(result.IDMapping) > 0 { + commandDidWrite.Store(true) + } + + // Mark command as having performed a write when the import changed anything. + // This enables Dolt auto-commit in PersistentPostRun for single-process backends. + if result.Created > 0 || result.Updated > 0 || len(result.IDMapping) > 0 { + commandDidWrite.Store(true) + } + // Update staleness metadata (same as import.go lines 386-411) // This is critical: without this, CheckStaleness will still report stale if currentHash, hashErr := computeJSONLHash(jsonlPath); hashErr == nil { diff --git a/cmd/bd/tips.go b/cmd/bd/tips.go index e0cac50b..7fc67c4b 100644 --- a/cmd/bd/tips.go +++ b/cmd/bd/tips.go @@ -153,9 +153,36 @@ func getLastShown(store storage.Storage, tipID string) time.Time { // recordTipShown records the timestamp when a tip was shown func recordTipShown(store storage.Storage, tipID string) { + if store == nil || tipID == "" { + return + } + + // If we're on a versioned store (Dolt) and dolt auto-commit is enabled, defer the + // metadata write so it can be committed as a separate Dolt commit in PostRun. + // This avoids tip metadata getting bundled into the main command commit. + if _, ok := storage.AsVersioned(store); ok { + if mode, err := getDoltAutoCommitMode(); err == nil && mode == doltAutoCommitOn { + commandDidWriteTipMetadata = true + if commandTipIDsShown == nil { + commandTipIDsShown = make(map[string]struct{}) + } + commandTipIDsShown[tipID] = struct{}{} + return + } + } + key := fmt.Sprintf("tip_%s_last_shown", tipID) value := time.Now().Format(time.RFC3339) - _ = store.SetMetadata(context.Background(), key, value) // Non-critical metadata, ok to fail silently + + // Non-critical metadata, ok to fail silently. + // If it succeeds, track the write for tip auto-commit behavior. + if err := store.SetMetadata(context.Background(), key, value); err == nil { + commandDidWriteTipMetadata = true + if commandTipIDsShown == nil { + commandTipIDsShown = make(map[string]struct{}) + } + commandTipIDsShown[tipID] = struct{}{} + } } // InjectTip adds a dynamic tip to the registry at runtime. @@ -347,9 +374,9 @@ func initDefaultTips() { InjectTip( "claude_setup", "Install the beads plugin for automatic workflow context, or run 'bd setup claude' for CLI-only mode", - 100, // Highest priority - this is important for Claude users - 24*time.Hour, // Daily minimum gap - 0.6, // 60% chance when eligible (~4 times per week) + 100, // Highest priority - this is important for Claude users + 24*time.Hour, // Daily minimum gap + 0.6, // 60% chance when eligible (~4 times per week) func() bool { return isClaudeDetected() && !isClaudeSetupComplete() }, @@ -360,9 +387,9 @@ func initDefaultTips() { InjectTip( "sync_conflict", "Run 'bd sync' to resolve sync conflict", - 200, // Higher than Claude setup - sync issues are urgent - 0, // No frequency limit - always show when applicable - 1.0, // 100% probability - always show when condition is true + 200, // Higher than Claude setup - sync issues are urgent + 0, // No frequency limit - always show when applicable + 1.0, // 100% probability - always show when condition is true syncConflictCondition, ) } diff --git a/cmd/bd/vc.go b/cmd/bd/vc.go index 0c81fc87..7778e982 100644 --- a/cmd/bd/vc.go +++ b/cmd/bd/vc.go @@ -131,6 +131,8 @@ Examples: FatalErrorRespectJSON("commit requires Dolt backend (current backend does not support versioning)") } + // We are explicitly creating a Dolt commit; avoid redundant auto-commit in PersistentPostRun. + commandDidExplicitDoltCommit = true if err := vs.Commit(ctx, vcCommitMessage); err != nil { FatalErrorRespectJSON("failed to commit: %v", err) } diff --git a/docs/CONFIG.md b/docs/CONFIG.md index de754ce1..7f49ce27 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -41,6 +41,7 @@ Tool-level settings you can configure: | `conflict.strategy` | - | `BD_CONFLICT_STRATEGY` | `newest` | Conflict resolution: `newest`, `ours`, `theirs`, `manual` | | `federation.remote` | - | `BD_FEDERATION_REMOTE` | (none) | Dolt remote URL for federation | | `federation.sovereignty` | - | `BD_FEDERATION_SOVEREIGNTY` | (none) | Data sovereignty tier: `T1`, `T2`, `T3`, `T4` | +| `dolt.auto-commit` | `--dolt-auto-commit` | `BD_DOLT_AUTO_COMMIT` | `on` | (Dolt backend) Automatically create a Dolt commit after successful write commands | | `create.require-description` | - | `BD_CREATE_REQUIRE_DESCRIPTION` | `false` | Require description when creating issues | | `validation.on-create` | - | `BD_VALIDATION_ON_CREATE` | `none` | Template validation on create: `none`, `warn`, `error` | | `validation.on-sync` | - | `BD_VALIDATION_ON_SYNC` | `none` | Template validation before sync: `none`, `warn`, `error` | @@ -61,6 +62,31 @@ Tool-level settings you can configure: - **SQLite** supports daemon mode and auto-start. - **Dolt (embedded)** is treated as **single-process-only**. Daemon mode and auto-start are disabled; `auto-start-daemon` has no effect. If you need daemon mode, use the SQLite backend (`bd init --backend sqlite`). +### Dolt Auto-Commit (SQL commit vs Dolt commit) + +When using the **Dolt backend**, there are two different kinds of “commit”: + +- **SQL transaction commit**: what happens when a `bd` command updates tables successfully (durable in the Dolt *working set*). +- **Dolt version-control commit**: what records those changes into Dolt’s *history* (visible in `bd vc log`, push/pull/merge workflows). + +By default, `bd` is configured to **auto-commit Dolt history after each successful write command**: + +- **Default**: `dolt.auto-commit: on` +- **Disable for a single command**: + +```bash +bd --dolt-auto-commit off create "No commit for this one" +``` + +- **Disable in config** (`.beads/config.yaml` or `~/.config/bd/config.yaml`): + +```yaml +dolt: + auto-commit: off +``` + +**Caveat:** enabling this creates **more Dolt commits** over time (one per write command). This is intentional so changes are not left only in the working set. + ### Actor Identity Resolution The actor name (used for `created_by` in issues and audit trails) is resolved in this order: diff --git a/docs/GIT_INTEGRATION.md b/docs/GIT_INTEGRATION.md index 350f0713..e49daecc 100644 --- a/docs/GIT_INTEGRATION.md +++ b/docs/GIT_INTEGRATION.md @@ -456,11 +456,14 @@ See [MULTI_REPO_MIGRATION.md](MULTI_REPO_MIGRATION.md) for complete guide. ### Automatic Sync (Default) -**With daemon running:** +**With daemon running (SQLite backend):** - Export to JSONL: 30-second debounce after changes - Import from JSONL: when file is newer than DB - Commit/push: configurable via `--auto-commit` / `--auto-push` +**Note:** `--auto-commit` here refers to **git commits** (typically to the sync branch). +For the Dolt backend, use `dolt.auto-commit` / `--dolt-auto-commit` to control **Dolt history commits**. + **30-second debounce provides transaction window:** - Multiple changes within 30s get batched - Single JSONL export/commit for the batch diff --git a/docs/QUICKSTART.md b/docs/QUICKSTART.md index 87e45d04..5e9d674c 100644 --- a/docs/QUICKSTART.md +++ b/docs/QUICKSTART.md @@ -42,10 +42,7 @@ Notes: - SQLite backend stores data in `.beads/beads.db`. - Dolt backend stores data in `.beads/dolt/` and records `"database": "dolt"` in `.beads/metadata.json`. - Dolt backend runs **single-process-only**; daemon mode is disabled. - -Notes: -- SQLite backend stores data in `.beads/beads.db`. -- Dolt backend stores data in `.beads/dolt/` and records `"database": "dolt"` in `.beads/metadata.json`. +- Dolt backend **auto-commits** after each successful write command by default (`dolt.auto-commit: on`). Disable with `bd --dolt-auto-commit off ...` or config. ## Your First Issues diff --git a/docs/WORKTREES.md b/docs/WORKTREES.md index 7efacdab..517083ab 100644 --- a/docs/WORKTREES.md +++ b/docs/WORKTREES.md @@ -358,8 +358,9 @@ export BEADS_DB=/path/to/specific/.beads/beads.db ```bash # Configure sync behavior bd config set sync.branch beads-sync # Use separate sync branch -bd config set sync.auto_commit true # Auto-commit changes -bd config set sync.auto_push true # Auto-push changes + +# For git-portable workflows, enable daemon auto-commit/push (SQLite backend only): +bd daemon start --auto-commit --auto-push ``` ## Performance Considerations diff --git a/internal/config/config.go b/internal/config/config.go index 4c88b667..e3665322 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -111,6 +111,11 @@ func Initialize() error { v.SetDefault("identity", "") v.SetDefault("remote-sync-interval", "30s") + // Dolt configuration defaults + // Controls whether beads should automatically create Dolt commits after write commands. + // Values: off | on + v.SetDefault("dolt.auto-commit", "on") + // Routing configuration defaults v.SetDefault("routing.mode", "") v.SetDefault("routing.default", ".") @@ -122,16 +127,16 @@ func Initialize() error { // Sync mode configuration (hq-ew1mbr.3) // See docs/CONFIG.md for detailed documentation - v.SetDefault("sync.mode", SyncModeGitPortable) // git-portable | realtime | dolt-native | belt-and-suspenders - v.SetDefault("sync.export_on", SyncTriggerPush) // push | change - v.SetDefault("sync.import_on", SyncTriggerPull) // pull | change + v.SetDefault("sync.mode", SyncModeGitPortable) // git-portable | realtime | dolt-native | belt-and-suspenders + v.SetDefault("sync.export_on", SyncTriggerPush) // push | change + v.SetDefault("sync.import_on", SyncTriggerPull) // pull | change // Conflict resolution configuration v.SetDefault("conflict.strategy", ConflictStrategyNewest) // newest | ours | theirs | manual // Federation configuration (optional Dolt remote) - v.SetDefault("federation.remote", "") // e.g., dolthub://org/beads, gs://bucket/beads, s3://bucket/beads - v.SetDefault("federation.sovereignty", "") // T1 | T2 | T3 | T4 (empty = no restriction) + v.SetDefault("federation.remote", "") // e.g., dolthub://org/beads, gs://bucket/beads, s3://bucket/beads + v.SetDefault("federation.sovereignty", "") // T1 | T2 | T3 | T4 (empty = no restriction) // Push configuration defaults v.SetDefault("no-push", false) diff --git a/internal/storage/dolt/store.go b/internal/storage/dolt/store.go index 71b2210c..2fed6d7e 100644 --- a/internal/storage/dolt/store.go +++ b/internal/storage/dolt/store.go @@ -36,12 +36,12 @@ import ( // DoltStore implements the Storage interface using Dolt type DoltStore struct { - db *sql.DB - dbPath string // Path to Dolt database directory - closed atomic.Bool // Tracks whether Close() has been called - connStr string // Connection string for reconnection - mu sync.RWMutex // Protects concurrent access - readOnly bool // True if opened in read-only mode + db *sql.DB + dbPath string // Path to Dolt database directory + closed atomic.Bool // Tracks whether Close() has been called + connStr string // Connection string for reconnection + mu sync.RWMutex // Protects concurrent access + readOnly bool // True if opened in read-only mode // Version control config committerName string @@ -457,9 +457,15 @@ func (s *DoltStore) UnderlyingConn(ctx context.Context) (*sql.Conn, error) { // Version Control Operations (Dolt-specific extensions) // ============================================================================= +func (s *DoltStore) commitAuthorString() string { + return fmt.Sprintf("%s <%s>", s.committerName, s.committerEmail) +} + // Commit creates a Dolt commit with the given message func (s *DoltStore) Commit(ctx context.Context, message string) error { - _, err := s.db.ExecContext(ctx, "CALL DOLT_COMMIT('-Am', ?)", message) + // NOTE: In SQL procedure mode, Dolt defaults author to the authenticated SQL user + // (e.g. root@localhost). Always pass an explicit author for deterministic history. + _, err := s.db.ExecContext(ctx, "CALL DOLT_COMMIT('-Am', ?, '--author', ?)", message, s.commitAuthorString()) if err != nil { return fmt.Errorf("failed to commit: %w", err) } @@ -506,7 +512,8 @@ func (s *DoltStore) Checkout(ctx context.Context, branch string) error { // Merge merges the specified branch into the current branch. // Returns any merge conflicts if present. Implements storage.VersionedStorage. func (s *DoltStore) Merge(ctx context.Context, branch string) ([]storage.Conflict, error) { - _, err := s.db.ExecContext(ctx, "CALL DOLT_MERGE(?)", branch) + // DOLT_MERGE may create a merge commit; pass explicit author for determinism. + _, err := s.db.ExecContext(ctx, "CALL DOLT_MERGE('--author', ?, ?)", s.commitAuthorString(), branch) if err != nil { // Check if the error is due to conflicts conflicts, conflictErr := s.GetConflicts(ctx) @@ -522,7 +529,8 @@ func (s *DoltStore) Merge(ctx context.Context, branch string) ([]storage.Conflic // This is needed for initial federation sync between independently initialized towns. // Returns any merge conflicts if present. func (s *DoltStore) MergeAllowUnrelated(ctx context.Context, branch string) ([]storage.Conflict, error) { - _, err := s.db.ExecContext(ctx, "CALL DOLT_MERGE('--allow-unrelated-histories', ?)", branch) + // DOLT_MERGE may create a merge commit; pass explicit author for determinism. + _, err := s.db.ExecContext(ctx, "CALL DOLT_MERGE('--allow-unrelated-histories', '--author', ?, ?)", s.commitAuthorString(), branch) if err != nil { // Check if the error is due to conflicts conflicts, conflictErr := s.GetConflicts(ctx)