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
|
// 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
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
|
// 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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
103
cmd/bd/main.go
103
cmd/bd/main.go
@@ -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
|
||||||
|
|||||||
@@ -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") {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 Dolt’s *history* (visible in `bd vc log`, push/pull/merge workflows).
|
||||||
|
|
||||||
|
By default, `bd` is configured to **auto-commit Dolt history after each successful write command**:
|
||||||
|
|
||||||
|
- **Default**: `dolt.auto-commit: on`
|
||||||
|
- **Disable for a single command**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bd --dolt-auto-commit off create "No commit for this one"
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Disable in config** (`.beads/config.yaml` or `~/.config/bd/config.yaml`):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
dolt:
|
||||||
|
auto-commit: off
|
||||||
|
```
|
||||||
|
|
||||||
|
**Caveat:** enabling this creates **more Dolt commits** over time (one per write command). This is intentional so changes are not left only in the working set.
|
||||||
|
|
||||||
### Actor Identity Resolution
|
### 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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user