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:
@@ -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
103
cmd/bd/dolt_autocommit.go
Normal 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, ", "))
|
||||
}
|
||||
28
cmd/bd/dolt_autocommit_config.go
Normal file
28
cmd/bd/dolt_autocommit_config.go
Normal 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)
|
||||
}
|
||||
}
|
||||
198
cmd/bd/dolt_autocommit_integration_test.go
Normal file
198
cmd/bd/dolt_autocommit_integration_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
40
cmd/bd/dolt_autocommit_test.go
Normal file
40
cmd/bd/dolt_autocommit_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
103
cmd/bd/main.go
103
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
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user