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 <dustin@dolthub.com>
This commit is contained in:
Steve Yegge
2026-01-22 20:52:20 -08:00
committed by GitHub
parent 1f94b4b363
commit ab61b0956b
18 changed files with 596 additions and 45 deletions

View File

@@ -435,9 +435,12 @@ func autoImportIfNewer() {
// Flush-on-exit guarantee: PersistentPostRun calls flushManager.Shutdown() which // Flush-on-exit guarantee: PersistentPostRun calls flushManager.Shutdown() which
// performs a final flush before the command exits, ensuring no data is lost. // 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. // No-op if auto-flush is disabled via --no-auto-flush flag.
func markDirtyAndScheduleFlush() { func markDirtyAndScheduleFlush() {
// Track that this command performed a write (atomic to avoid data races).
commandDidWrite.Store(true)
// Use FlushManager if available // Use FlushManager if available
// No FlushManager means sandbox mode or test without flush setup - no-op is correct // No FlushManager means sandbox mode or test without flush setup - no-op is correct
if flushManager != nil { if flushManager != nil {
@@ -447,6 +450,9 @@ func markDirtyAndScheduleFlush() {
// markDirtyAndScheduleFullExport marks DB as needing a full export (for ID-changing operations) // markDirtyAndScheduleFullExport marks DB as needing a full export (for ID-changing operations)
func markDirtyAndScheduleFullExport() { func markDirtyAndScheduleFullExport() {
// Track that this command performed a write (atomic to avoid data races).
commandDidWrite.Store(true)
// Use FlushManager if available // Use FlushManager if available
// No FlushManager means sandbox mode or test without flush setup - no-op is correct // No FlushManager means sandbox mode or test without flush setup - no-op is correct
if flushManager != nil { if flushManager != nil {
@@ -472,11 +478,11 @@ func clearAutoFlushState() {
// //
// Atomic write pattern: // Atomic write pattern:
// //
// 1. Create temp file with PID suffix: issues.jsonl.tmp.12345 // 1. Create temp file with PID suffix: issues.jsonl.tmp.12345
// 2. Write all issues as JSONL to temp file // 2. Write all issues as JSONL to temp file
// 3. Close temp file // 3. Close temp file
// 4. Atomic rename: temp → target // 4. Atomic rename: temp → target
// 5. Set file permissions to 0644 // 5. Set file permissions to 0644
// //
// Error handling: Returns error on any failure. Cleanup is guaranteed via defer. // Error handling: Returns error on any failure. Cleanup is guaranteed via defer.
// Thread-safe: No shared state access. Safe to call from multiple goroutines. // Thread-safe: No shared state access. Safe to call from multiple goroutines.

103
cmd/bd/dolt_autocommit.go Normal file
View File

@@ -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, ", "))
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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")
}
}

View File

@@ -680,6 +680,8 @@ func hookPostMergeDolt(beadsDir string) int {
} }
// Commit changes on import branch // 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 { if err := doltStore.Commit(ctx, "Import from JSONL"); err != nil {
fmt.Fprintf(os.Stderr, "Warning: could not commit import: %v\n", err) fmt.Fprintf(os.Stderr, "Warning: could not commit import: %v\n", err)
} }
@@ -702,6 +704,8 @@ func hookPostMergeDolt(beadsDir string) int {
} }
// Commit the merge // Commit the merge
// Still part of explicit hook commit flow.
commandDidExplicitDoltCommit = true
if err := doltStore.Commit(ctx, "Merge JSONL import"); err != nil { if err := doltStore.Commit(ctx, "Merge JSONL import"); err != nil {
// May fail if nothing to commit (fast-forward merge) // May fail if nothing to commit (fast-forward merge)
// This is expected, not an error // This is expected, not an error

View File

@@ -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") 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 // Flush immediately after import (no debounce) to ensure daemon sees changes
// Without this, daemon FileWatcher won't detect the import for up to 30s // 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 // 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 // Get line counts for context
workingTreeLines := countLines(filePath) workingTreeLines := countLines(filePath)
headLines := countLinesInGitHEAD(filePath, workDir) headLines := countLinesInGitHEAD(filePath, workDir)
fmt.Fprintf(os.Stderr, "\n⚠ Warning: %s has uncommitted changes\n", filePath) fmt.Fprintf(os.Stderr, "\n⚠ Warning: %s has uncommitted changes\n", filePath)
fmt.Fprintf(os.Stderr, " Working tree: %d lines\n", workingTreeLines) fmt.Fprintf(os.Stderr, " Working tree: %d lines\n", workingTreeLines)
if headLines > 0 { if headLines > 0 {

View File

@@ -12,6 +12,7 @@ import (
"slices" "slices"
"strings" "strings"
"sync" "sync"
"sync/atomic"
"syscall" "syscall"
"time" "time"
@@ -71,19 +72,41 @@ var (
upgradeAcknowledged = false // Set to true after showing upgrade notification once per session upgradeAcknowledged = false // Set to true after showing upgrade notification once per session
) )
var ( var (
noAutoFlush bool noAutoFlush bool
noAutoImport bool noAutoImport bool
sandboxMode bool sandboxMode bool
allowStale bool // Use --allow-stale: skip staleness check (emergency escape hatch) 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 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) readonlyMode bool // Read-only mode: block write operations (for worker sandboxes)
storeIsReadOnly bool // Track if store was opened read-only (for staleness checks) storeIsReadOnly bool // Track if store was opened read-only (for staleness checks)
lockTimeout time.Duration // SQLite busy_timeout (default 30s, 0 = fail immediately) lockTimeout time.Duration // SQLite busy_timeout (default 30s, 0 = fail immediately)
profileEnabled bool profileEnabled bool
profileFile *os.File profileFile *os.File
traceFile *os.File traceFile *os.File
verboseFlag bool // Enable verbose/debug output verboseFlag bool // Enable verbose/debug output
quietFlag bool // Suppress non-essential 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. // 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(&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(&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().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().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().BoolVar(&profileEnabled, "profile", false, "Generate CPU profile for performance analysis")
rootCmd.PersistentFlags().BoolVarP(&verboseFlag, "verbose", "v", false, "Enable verbose/debug output") 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) // Initialize CommandContext to hold runtime state (replaces scattered globals)
initCommandContext() 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 // Set up signal-aware context for graceful cancellation
rootCtx, rootCancel = signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) rootCtx, rootCancel = signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
@@ -321,6 +351,14 @@ var rootCmd = &cobra.Command{
WasSet bool WasSet bool
}{actor, true} }{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) // Check for and log configuration overrides (only in verbose mode)
if verboseFlag { 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, // GH#1093: Check noDbCommands BEFORE expensive operations (ensureForkProtection,
// signalOrchestratorActivity) to avoid spawning git subprocesses for simple commands // signalOrchestratorActivity) to avoid spawning git subprocesses for simple commands
// like "bd version" that don't need database access. // 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) // Signal that store is closing (prevents background flush from accessing closed store)
storeMutex.Lock() storeMutex.Lock()
storeActive = false storeActive = false

View File

@@ -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") fmt.Println("⚠ Dolt remote not available, falling back to JSONL-only")
} else { } else {
fmt.Println("→ Committing to Dolt...") 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 { if err := rs.Commit(ctx, "bd sync: auto-commit"); err != nil {
// Ignore "nothing to commit" errors // Ignore "nothing to commit" errors
if !strings.Contains(err.Error(), "nothing to commit") { if !strings.Contains(err.Error(), "nothing to commit") {

View File

@@ -136,6 +136,18 @@ func importFromJSONLInline(ctx context.Context, jsonlPath string, renameOnImport
return fmt.Errorf("import failed: %w", err) 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) // Update staleness metadata (same as import.go lines 386-411)
// This is critical: without this, CheckStaleness will still report stale // This is critical: without this, CheckStaleness will still report stale
if currentHash, hashErr := computeJSONLHash(jsonlPath); hashErr == nil { if currentHash, hashErr := computeJSONLHash(jsonlPath); hashErr == nil {

View File

@@ -153,9 +153,36 @@ func getLastShown(store storage.Storage, tipID string) time.Time {
// recordTipShown records the timestamp when a tip was shown // recordTipShown records the timestamp when a tip was shown
func recordTipShown(store storage.Storage, tipID string) { 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) key := fmt.Sprintf("tip_%s_last_shown", tipID)
value := time.Now().Format(time.RFC3339) 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. // InjectTip adds a dynamic tip to the registry at runtime.
@@ -347,9 +374,9 @@ func initDefaultTips() {
InjectTip( InjectTip(
"claude_setup", "claude_setup",
"Install the beads plugin for automatic workflow context, or run 'bd setup claude' for CLI-only mode", "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 100, // Highest priority - this is important for Claude users
24*time.Hour, // Daily minimum gap 24*time.Hour, // Daily minimum gap
0.6, // 60% chance when eligible (~4 times per week) 0.6, // 60% chance when eligible (~4 times per week)
func() bool { func() bool {
return isClaudeDetected() && !isClaudeSetupComplete() return isClaudeDetected() && !isClaudeSetupComplete()
}, },
@@ -360,9 +387,9 @@ func initDefaultTips() {
InjectTip( InjectTip(
"sync_conflict", "sync_conflict",
"Run 'bd sync' to resolve sync conflict", "Run 'bd sync' to resolve sync conflict",
200, // Higher than Claude setup - sync issues are urgent 200, // Higher than Claude setup - sync issues are urgent
0, // No frequency limit - always show when applicable 0, // No frequency limit - always show when applicable
1.0, // 100% probability - always show when condition is true 1.0, // 100% probability - always show when condition is true
syncConflictCondition, syncConflictCondition,
) )
} }

View File

@@ -131,6 +131,8 @@ Examples:
FatalErrorRespectJSON("commit requires Dolt backend (current backend does not support versioning)") 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 { if err := vs.Commit(ctx, vcCommitMessage); err != nil {
FatalErrorRespectJSON("failed to commit: %v", err) FatalErrorRespectJSON("failed to commit: %v", err)
} }

View File

@@ -41,6 +41,7 @@ Tool-level settings you can configure:
| `conflict.strategy` | - | `BD_CONFLICT_STRATEGY` | `newest` | Conflict resolution: `newest`, `ours`, `theirs`, `manual` | | `conflict.strategy` | - | `BD_CONFLICT_STRATEGY` | `newest` | Conflict resolution: `newest`, `ours`, `theirs`, `manual` |
| `federation.remote` | - | `BD_FEDERATION_REMOTE` | (none) | Dolt remote URL for federation | | `federation.remote` | - | `BD_FEDERATION_REMOTE` | (none) | Dolt remote URL for federation |
| `federation.sovereignty` | - | `BD_FEDERATION_SOVEREIGNTY` | (none) | Data sovereignty tier: `T1`, `T2`, `T3`, `T4` | | `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 | | `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-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` | | `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. - **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 (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 Dolts *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 ### Actor Identity Resolution
The actor name (used for `created_by` in issues and audit trails) is resolved in this order: The actor name (used for `created_by` in issues and audit trails) is resolved in this order:

View File

@@ -456,11 +456,14 @@ See [MULTI_REPO_MIGRATION.md](MULTI_REPO_MIGRATION.md) for complete guide.
### Automatic Sync (Default) ### Automatic Sync (Default)
**With daemon running:** **With daemon running (SQLite backend):**
- Export to JSONL: 30-second debounce after changes - Export to JSONL: 30-second debounce after changes
- Import from JSONL: when file is newer than DB - Import from JSONL: when file is newer than DB
- Commit/push: configurable via `--auto-commit` / `--auto-push` - 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:** **30-second debounce provides transaction window:**
- Multiple changes within 30s get batched - Multiple changes within 30s get batched
- Single JSONL export/commit for the batch - Single JSONL export/commit for the batch

View File

@@ -42,10 +42,7 @@ Notes:
- SQLite backend stores data in `.beads/beads.db`. - 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 stores data in `.beads/dolt/` and records `"database": "dolt"` in `.beads/metadata.json`.
- Dolt backend runs **single-process-only**; daemon mode is disabled. - Dolt backend runs **single-process-only**; daemon mode is disabled.
- Dolt backend **auto-commits** after each successful write command by default (`dolt.auto-commit: on`). Disable with `bd --dolt-auto-commit off ...` or config.
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 ## Your First Issues

View File

@@ -358,8 +358,9 @@ export BEADS_DB=/path/to/specific/.beads/beads.db
```bash ```bash
# Configure sync behavior # Configure sync behavior
bd config set sync.branch beads-sync # Use separate sync branch 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 ## Performance Considerations

View File

@@ -111,6 +111,11 @@ func Initialize() error {
v.SetDefault("identity", "") v.SetDefault("identity", "")
v.SetDefault("remote-sync-interval", "30s") 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 // Routing configuration defaults
v.SetDefault("routing.mode", "") v.SetDefault("routing.mode", "")
v.SetDefault("routing.default", ".") v.SetDefault("routing.default", ".")
@@ -122,16 +127,16 @@ func Initialize() error {
// Sync mode configuration (hq-ew1mbr.3) // Sync mode configuration (hq-ew1mbr.3)
// See docs/CONFIG.md for detailed documentation // See docs/CONFIG.md for detailed documentation
v.SetDefault("sync.mode", SyncModeGitPortable) // git-portable | realtime | dolt-native | belt-and-suspenders v.SetDefault("sync.mode", SyncModeGitPortable) // git-portable | realtime | dolt-native | belt-and-suspenders
v.SetDefault("sync.export_on", SyncTriggerPush) // push | change v.SetDefault("sync.export_on", SyncTriggerPush) // push | change
v.SetDefault("sync.import_on", SyncTriggerPull) // pull | change v.SetDefault("sync.import_on", SyncTriggerPull) // pull | change
// Conflict resolution configuration // Conflict resolution configuration
v.SetDefault("conflict.strategy", ConflictStrategyNewest) // newest | ours | theirs | manual v.SetDefault("conflict.strategy", ConflictStrategyNewest) // newest | ours | theirs | manual
// Federation configuration (optional Dolt remote) // Federation configuration (optional Dolt remote)
v.SetDefault("federation.remote", "") // e.g., dolthub://org/beads, gs://bucket/beads, s3://bucket/beads 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.sovereignty", "") // T1 | T2 | T3 | T4 (empty = no restriction)
// Push configuration defaults // Push configuration defaults
v.SetDefault("no-push", false) v.SetDefault("no-push", false)

View File

@@ -36,12 +36,12 @@ import (
// DoltStore implements the Storage interface using Dolt // DoltStore implements the Storage interface using Dolt
type DoltStore struct { type DoltStore struct {
db *sql.DB db *sql.DB
dbPath string // Path to Dolt database directory dbPath string // Path to Dolt database directory
closed atomic.Bool // Tracks whether Close() has been called closed atomic.Bool // Tracks whether Close() has been called
connStr string // Connection string for reconnection connStr string // Connection string for reconnection
mu sync.RWMutex // Protects concurrent access mu sync.RWMutex // Protects concurrent access
readOnly bool // True if opened in read-only mode readOnly bool // True if opened in read-only mode
// Version control config // Version control config
committerName string committerName string
@@ -457,9 +457,15 @@ func (s *DoltStore) UnderlyingConn(ctx context.Context) (*sql.Conn, error) {
// Version Control Operations (Dolt-specific extensions) // 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 // Commit creates a Dolt commit with the given message
func (s *DoltStore) Commit(ctx context.Context, message string) error { 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 { if err != nil {
return fmt.Errorf("failed to commit: %w", err) 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. // Merge merges the specified branch into the current branch.
// Returns any merge conflicts if present. Implements storage.VersionedStorage. // Returns any merge conflicts if present. Implements storage.VersionedStorage.
func (s *DoltStore) Merge(ctx context.Context, branch string) ([]storage.Conflict, error) { 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 { if err != nil {
// Check if the error is due to conflicts // Check if the error is due to conflicts
conflicts, conflictErr := s.GetConflicts(ctx) 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. // This is needed for initial federation sync between independently initialized towns.
// Returns any merge conflicts if present. // Returns any merge conflicts if present.
func (s *DoltStore) MergeAllowUnrelated(ctx context.Context, branch string) ([]storage.Conflict, error) { 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 { if err != nil {
// Check if the error is due to conflicts // Check if the error is due to conflicts
conflicts, conflictErr := s.GetConflicts(ctx) conflicts, conflictErr := s.GetConflicts(ctx)