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 John Ogle
parent 9fd0ea2c67
commit 0ee020ed76
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
// 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.

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
// 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

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")
}
// 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 {

View File

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

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

View File

@@ -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 {

View File

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

View File

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