Merge origin/main, fix test git init for modern git
This commit is contained in:
@@ -12,6 +12,13 @@ import (
|
||||
// This prevents fork bombs when tests call functions that execute bd subcommands.
|
||||
var ErrTestBinary = fmt.Errorf("running as test binary - cannot execute bd subcommands")
|
||||
|
||||
func newBdCmd(bdBinary string, args ...string) *exec.Cmd {
|
||||
fullArgs := append([]string{"--no-daemon"}, args...)
|
||||
cmd := exec.Command(bdBinary, fullArgs...) // #nosec G204 -- bdBinary from validated executable path
|
||||
cmd.Env = append(os.Environ(), "BEADS_NO_DAEMON=1")
|
||||
return cmd
|
||||
}
|
||||
|
||||
// getBdBinary returns the path to the bd binary to use for fix operations.
|
||||
// It prefers the current executable to avoid command injection attacks.
|
||||
// Returns ErrTestBinary if running as a test binary to prevent fork bombs.
|
||||
|
||||
@@ -3,7 +3,6 @@ package fix
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
@@ -36,7 +35,7 @@ func Daemon(path string) error {
|
||||
}
|
||||
|
||||
// Run bd daemons killall to clean up stale daemons
|
||||
cmd := exec.Command(bdBinary, "daemons", "killall") // #nosec G204 -- bdBinary from validated executable path
|
||||
cmd := newBdCmd(bdBinary, "daemons", "killall")
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
|
||||
|
||||
@@ -32,6 +32,13 @@ func DatabaseConfig(path string) error {
|
||||
|
||||
fixed := false
|
||||
|
||||
// Never treat system JSONL files as a JSONL export configuration.
|
||||
if isSystemJSONLFilename(cfg.JSONLExport) {
|
||||
fmt.Printf(" Updating jsonl_export: %s → issues.jsonl\n", cfg.JSONLExport)
|
||||
cfg.JSONLExport = "issues.jsonl"
|
||||
fixed = true
|
||||
}
|
||||
|
||||
// Check if configured JSONL exists
|
||||
if cfg.JSONLExport != "" {
|
||||
jsonlPath := cfg.JSONLPath(beadsDir)
|
||||
@@ -99,7 +106,15 @@ func findActualJSONLFile(beadsDir string) string {
|
||||
strings.Contains(lowerName, ".orig") ||
|
||||
strings.Contains(lowerName, ".bak") ||
|
||||
strings.Contains(lowerName, "~") ||
|
||||
strings.HasPrefix(lowerName, "backup_") {
|
||||
strings.HasPrefix(lowerName, "backup_") ||
|
||||
// System files are not JSONL exports.
|
||||
name == "deletions.jsonl" ||
|
||||
name == "interactions.jsonl" ||
|
||||
name == "molecules.jsonl" ||
|
||||
// Git merge conflict artifacts (e.g., issues.base.jsonl, issues.left.jsonl)
|
||||
strings.Contains(lowerName, ".base.jsonl") ||
|
||||
strings.Contains(lowerName, ".left.jsonl") ||
|
||||
strings.Contains(lowerName, ".right.jsonl") {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -121,6 +136,15 @@ func findActualJSONLFile(beadsDir string) string {
|
||||
return candidates[0]
|
||||
}
|
||||
|
||||
func isSystemJSONLFilename(name string) bool {
|
||||
switch name {
|
||||
case "deletions.jsonl", "interactions.jsonl", "molecules.jsonl":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// LegacyJSONLConfig migrates from legacy beads.jsonl to canonical issues.jsonl.
|
||||
// This renames the file, updates metadata.json, and updates .gitattributes if present.
|
||||
// bd-6xd: issues.jsonl is the canonical filename
|
||||
|
||||
@@ -220,3 +220,53 @@ func TestLegacyJSONLConfig_UpdatesGitattributes(t *testing.T) {
|
||||
t.Errorf("Expected .gitattributes to reference issues.jsonl, got: %q", string(content))
|
||||
}
|
||||
}
|
||||
|
||||
// TestFindActualJSONLFile_SkipsSystemFiles ensures system JSONL files are never treated as JSONL exports.
|
||||
func TestFindActualJSONLFile_SkipsSystemFiles(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// Only system files → no candidates.
|
||||
if err := os.WriteFile(filepath.Join(tmpDir, "interactions.jsonl"), []byte(`{"id":"x"}`), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := findActualJSONLFile(tmpDir); got != "" {
|
||||
t.Fatalf("expected empty result, got %q", got)
|
||||
}
|
||||
|
||||
// System + legacy export → legacy wins.
|
||||
if err := os.WriteFile(filepath.Join(tmpDir, "beads.jsonl"), []byte(`{"id":"x"}`), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := findActualJSONLFile(tmpDir); got != "beads.jsonl" {
|
||||
t.Fatalf("expected beads.jsonl, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatabaseConfigFix_RejectsSystemJSONLExport(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
beadsDir := filepath.Join(tmpDir, ".beads")
|
||||
if err := os.Mkdir(beadsDir, 0755); err != nil {
|
||||
t.Fatalf("Failed to create .beads dir: %v", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(filepath.Join(beadsDir, "interactions.jsonl"), []byte(`{"id":"x"}`), 0644); err != nil {
|
||||
t.Fatalf("Failed to create interactions.jsonl: %v", err)
|
||||
}
|
||||
|
||||
cfg := &configfile.Config{Database: "beads.db", JSONLExport: "interactions.jsonl"}
|
||||
if err := cfg.Save(beadsDir); err != nil {
|
||||
t.Fatalf("Failed to save config: %v", err)
|
||||
}
|
||||
|
||||
if err := DatabaseConfig(tmpDir); err != nil {
|
||||
t.Fatalf("DatabaseConfig failed: %v", err)
|
||||
}
|
||||
|
||||
updated, err := configfile.Load(beadsDir)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load updated config: %v", err)
|
||||
}
|
||||
if updated.JSONLExport != "issues.jsonl" {
|
||||
t.Fatalf("expected issues.jsonl, got %q", updated.JSONLExport)
|
||||
}
|
||||
}
|
||||
|
||||
116
cmd/bd/doctor/fix/database_integrity.go
Normal file
116
cmd/bd/doctor/fix/database_integrity.go
Normal file
@@ -0,0 +1,116 @@
|
||||
package fix
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/steveyegge/beads/internal/beads"
|
||||
"github.com/steveyegge/beads/internal/configfile"
|
||||
)
|
||||
|
||||
// DatabaseIntegrity attempts to recover from database corruption by:
|
||||
// 1. Backing up the corrupt database (and WAL/SHM if present)
|
||||
// 2. Re-initializing the database from the working tree JSONL export
|
||||
//
|
||||
// This is intentionally conservative: it will not delete JSONL, and it preserves the
|
||||
// original DB as a backup for forensic recovery.
|
||||
func DatabaseIntegrity(path string) error {
|
||||
if err := validateBeadsWorkspace(path); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
absPath, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to resolve path: %w", err)
|
||||
}
|
||||
|
||||
beadsDir := filepath.Join(absPath, ".beads")
|
||||
|
||||
// Best-effort: stop any running daemon to reduce the chance of DB file locks.
|
||||
_ = Daemon(absPath)
|
||||
|
||||
// Resolve database path (respects metadata.json database override).
|
||||
var dbPath string
|
||||
if cfg, err := configfile.Load(beadsDir); err == nil && cfg != nil && cfg.Database != "" {
|
||||
dbPath = cfg.DatabasePath(beadsDir)
|
||||
} else {
|
||||
dbPath = filepath.Join(beadsDir, beads.CanonicalDatabaseName)
|
||||
}
|
||||
|
||||
// Find JSONL source of truth.
|
||||
jsonlPath := ""
|
||||
if cfg, err := configfile.Load(beadsDir); err == nil && cfg != nil {
|
||||
if cfg.JSONLExport != "" && !isSystemJSONLFilename(cfg.JSONLExport) {
|
||||
candidate := cfg.JSONLPath(beadsDir)
|
||||
if _, err := os.Stat(candidate); err == nil {
|
||||
jsonlPath = candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
if jsonlPath == "" {
|
||||
for _, name := range []string{"issues.jsonl", "beads.jsonl"} {
|
||||
candidate := filepath.Join(beadsDir, name)
|
||||
if _, err := os.Stat(candidate); err == nil {
|
||||
jsonlPath = candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if jsonlPath == "" {
|
||||
return fmt.Errorf("cannot auto-recover: no JSONL export found in %s", beadsDir)
|
||||
}
|
||||
|
||||
// Back up corrupt DB and its sidecar files.
|
||||
ts := time.Now().UTC().Format("20060102T150405Z")
|
||||
backupDB := dbPath + "." + ts + ".corrupt.backup.db"
|
||||
if err := moveFile(dbPath, backupDB); err != nil {
|
||||
// Retry once after attempting to kill daemons again (helps on platforms with strict file locks).
|
||||
_ = Daemon(absPath)
|
||||
if err2 := moveFile(dbPath, backupDB); err2 != nil {
|
||||
// Prefer the original error (more likely root cause).
|
||||
return fmt.Errorf("failed to back up database: %w", err)
|
||||
}
|
||||
}
|
||||
for _, suffix := range []string{"-wal", "-shm", "-journal"} {
|
||||
sidecar := dbPath + suffix
|
||||
if _, err := os.Stat(sidecar); err == nil {
|
||||
_ = moveFile(sidecar, backupDB+suffix) // best effort
|
||||
}
|
||||
}
|
||||
|
||||
// Rebuild by importing from the working tree JSONL into a fresh database.
|
||||
bdBinary, err := getBdBinary()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Use import (not init) so we always hydrate from the working tree JSONL, not git-tracked blobs.
|
||||
args := []string{"--db", dbPath, "import", "-i", jsonlPath, "--force", "--no-git-history"}
|
||||
cmd := newBdCmd(bdBinary, args...)
|
||||
cmd.Dir = absPath
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
// Best-effort rollback: attempt to restore the original DB, while preserving the backup.
|
||||
failedTS := time.Now().UTC().Format("20060102T150405Z")
|
||||
if _, statErr := os.Stat(dbPath); statErr == nil {
|
||||
failedDB := dbPath + "." + failedTS + ".failed.init.db"
|
||||
_ = moveFile(dbPath, failedDB)
|
||||
for _, suffix := range []string{"-wal", "-shm", "-journal"} {
|
||||
_ = moveFile(dbPath+suffix, failedDB+suffix)
|
||||
}
|
||||
}
|
||||
_ = copyFile(backupDB, dbPath)
|
||||
for _, suffix := range []string{"-wal", "-shm", "-journal"} {
|
||||
if _, statErr := os.Stat(backupDB + suffix); statErr == nil {
|
||||
_ = copyFile(backupDB+suffix, dbPath+suffix)
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("failed to rebuild database from JSONL: %w (backup: %s)", err, backupDB)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
57
cmd/bd/doctor/fix/fs.go
Normal file
57
cmd/bd/doctor/fix/fs.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package fix
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
var (
|
||||
renameFile = os.Rename
|
||||
removeFile = os.Remove
|
||||
openFileRO = os.Open
|
||||
openFileRW = os.OpenFile
|
||||
)
|
||||
|
||||
func moveFile(src, dst string) error {
|
||||
if err := renameFile(src, dst); err == nil {
|
||||
return nil
|
||||
} else if isEXDEV(err) {
|
||||
if err := copyFile(src, dst); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := removeFile(src); err != nil {
|
||||
return fmt.Errorf("failed to remove source after copy: %w", err)
|
||||
}
|
||||
return nil
|
||||
} else {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
func copyFile(src, dst string) error {
|
||||
in, err := openFileRO(src) // #nosec G304 -- src is within the workspace
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
out, err := openFileRW(dst, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = out.Close() }()
|
||||
if _, err := io.Copy(out, in); err != nil {
|
||||
return err
|
||||
}
|
||||
return out.Close()
|
||||
}
|
||||
|
||||
func isEXDEV(err error) bool {
|
||||
var linkErr *os.LinkError
|
||||
if errors.As(err, &linkErr) {
|
||||
return errors.Is(linkErr.Err, syscall.EXDEV)
|
||||
}
|
||||
return errors.Is(err, syscall.EXDEV)
|
||||
}
|
||||
71
cmd/bd/doctor/fix/fs_test.go
Normal file
71
cmd/bd/doctor/fix/fs_test.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package fix
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMoveFile_EXDEV_FallsBackToCopy(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
src := filepath.Join(root, "src.txt")
|
||||
dst := filepath.Join(root, "dst.txt")
|
||||
if err := os.WriteFile(src, []byte("hello"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
oldRename := renameFile
|
||||
defer func() { renameFile = oldRename }()
|
||||
renameFile = func(oldpath, newpath string) error {
|
||||
return &os.LinkError{Op: "rename", Old: oldpath, New: newpath, Err: syscall.EXDEV}
|
||||
}
|
||||
|
||||
if err := moveFile(src, dst); err != nil {
|
||||
t.Fatalf("moveFile failed: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(src); !os.IsNotExist(err) {
|
||||
t.Fatalf("expected src to be removed, stat err=%v", err)
|
||||
}
|
||||
data, err := os.ReadFile(dst)
|
||||
if err != nil {
|
||||
t.Fatalf("read dst: %v", err)
|
||||
}
|
||||
if string(data) != "hello" {
|
||||
t.Fatalf("dst contents=%q", string(data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMoveFile_EXDEV_CopyFails_LeavesSource(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
src := filepath.Join(root, "src.txt")
|
||||
dst := filepath.Join(root, "dst.txt")
|
||||
if err := os.WriteFile(src, []byte("hello"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
oldRename := renameFile
|
||||
oldOpenRW := openFileRW
|
||||
defer func() {
|
||||
renameFile = oldRename
|
||||
openFileRW = oldOpenRW
|
||||
}()
|
||||
renameFile = func(oldpath, newpath string) error {
|
||||
return &os.LinkError{Op: "rename", Old: oldpath, New: newpath, Err: syscall.EXDEV}
|
||||
}
|
||||
openFileRW = func(name string, flag int, perm os.FileMode) (*os.File, error) {
|
||||
return nil, &os.PathError{Op: "open", Path: name, Err: syscall.ENOSPC}
|
||||
}
|
||||
|
||||
err := moveFile(src, dst)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error")
|
||||
}
|
||||
if !errors.Is(err, syscall.ENOSPC) {
|
||||
t.Fatalf("expected ENOSPC, got %v", err)
|
||||
}
|
||||
if _, err := os.Stat(src); err != nil {
|
||||
t.Fatalf("expected src to remain, stat err=%v", err)
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,7 @@ func GitHooks(path string) error {
|
||||
}
|
||||
|
||||
// Run bd hooks install
|
||||
cmd := exec.Command(bdBinary, "hooks", "install") // #nosec G204 -- bdBinary from validated executable path
|
||||
cmd := newBdCmd(bdBinary, "hooks", "install")
|
||||
cmd.Dir = path // Set working directory without changing process dir
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
|
||||
87
cmd/bd/doctor/fix/jsonl_integrity.go
Normal file
87
cmd/bd/doctor/fix/jsonl_integrity.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package fix
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/steveyegge/beads/internal/beads"
|
||||
"github.com/steveyegge/beads/internal/configfile"
|
||||
"github.com/steveyegge/beads/internal/utils"
|
||||
)
|
||||
|
||||
// JSONLIntegrity backs up a malformed JSONL export and regenerates it from the database.
|
||||
// This is safe only when a database exists and is readable.
|
||||
func JSONLIntegrity(path string) error {
|
||||
if err := validateBeadsWorkspace(path); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
absPath, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to resolve path: %w", err)
|
||||
}
|
||||
|
||||
beadsDir := filepath.Join(absPath, ".beads")
|
||||
|
||||
// Resolve db path.
|
||||
dbPath := filepath.Join(beadsDir, beads.CanonicalDatabaseName)
|
||||
if cfg, err := configfile.Load(beadsDir); err == nil && cfg != nil && cfg.Database != "" {
|
||||
dbPath = cfg.DatabasePath(beadsDir)
|
||||
}
|
||||
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
|
||||
return fmt.Errorf("cannot auto-repair JSONL: no database found")
|
||||
}
|
||||
|
||||
// Resolve JSONL export path.
|
||||
jsonlPath := ""
|
||||
if cfg, err := configfile.Load(beadsDir); err == nil && cfg != nil {
|
||||
if cfg.JSONLExport != "" && !isSystemJSONLFilename(cfg.JSONLExport) {
|
||||
p := cfg.JSONLPath(beadsDir)
|
||||
if _, err := os.Stat(p); err == nil {
|
||||
jsonlPath = p
|
||||
}
|
||||
}
|
||||
}
|
||||
if jsonlPath == "" {
|
||||
p := utils.FindJSONLInDir(beadsDir)
|
||||
if _, err := os.Stat(p); err == nil {
|
||||
jsonlPath = p
|
||||
}
|
||||
}
|
||||
if jsonlPath == "" {
|
||||
return fmt.Errorf("cannot auto-repair JSONL: no JSONL file found")
|
||||
}
|
||||
|
||||
// Back up the JSONL.
|
||||
ts := time.Now().UTC().Format("20060102T150405Z")
|
||||
backup := jsonlPath + "." + ts + ".corrupt.backup.jsonl"
|
||||
if err := moveFile(jsonlPath, backup); err != nil {
|
||||
return fmt.Errorf("failed to back up JSONL: %w", err)
|
||||
}
|
||||
|
||||
binary, err := getBdBinary()
|
||||
if err != nil {
|
||||
_ = moveFile(backup, jsonlPath)
|
||||
return err
|
||||
}
|
||||
|
||||
// Re-export from DB.
|
||||
cmd := newBdCmd(binary, "--db", dbPath, "export", "-o", jsonlPath, "--force")
|
||||
cmd.Dir = absPath
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
// Best-effort rollback: restore the original JSONL, but keep the backup.
|
||||
failedTS := time.Now().UTC().Format("20060102T150405Z")
|
||||
if _, statErr := os.Stat(jsonlPath); statErr == nil {
|
||||
failed := jsonlPath + "." + failedTS + ".failed.regen.jsonl"
|
||||
_ = moveFile(jsonlPath, failed)
|
||||
}
|
||||
_ = copyFile(backup, jsonlPath)
|
||||
return fmt.Errorf("failed to regenerate JSONL from database: %w (backup: %s)", err, backup)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -3,8 +3,10 @@ package fix
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/steveyegge/beads/internal/beads"
|
||||
"github.com/steveyegge/beads/internal/configfile"
|
||||
)
|
||||
|
||||
// DatabaseVersion fixes database version mismatches by running bd migrate,
|
||||
@@ -23,12 +25,15 @@ func DatabaseVersion(path string) error {
|
||||
|
||||
// Check if database exists - if not, run init instead of migrate (bd-4h9)
|
||||
beadsDir := filepath.Join(path, ".beads")
|
||||
dbPath := filepath.Join(beadsDir, "beads.db")
|
||||
dbPath := filepath.Join(beadsDir, beads.CanonicalDatabaseName)
|
||||
if cfg, err := configfile.Load(beadsDir); err == nil && cfg != nil && cfg.Database != "" {
|
||||
dbPath = cfg.DatabasePath(beadsDir)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
|
||||
// No database - this is a fresh clone, run bd init
|
||||
fmt.Println("→ No database found, running 'bd init' to hydrate from JSONL...")
|
||||
cmd := exec.Command(bdBinary, "init") // #nosec G204 -- bdBinary from validated executable path
|
||||
cmd := newBdCmd(bdBinary, "--db", dbPath, "init")
|
||||
cmd.Dir = path
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
@@ -41,8 +46,8 @@ func DatabaseVersion(path string) error {
|
||||
}
|
||||
|
||||
// Database exists - run bd migrate
|
||||
cmd := exec.Command(bdBinary, "migrate") // #nosec G204 -- bdBinary from validated executable path
|
||||
cmd.Dir = path // Set working directory without changing process dir
|
||||
cmd := newBdCmd(bdBinary, "--db", dbPath, "migrate")
|
||||
cmd.Dir = path // Set working directory without changing process dir
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ package fix
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
@@ -31,9 +30,9 @@ func readLineUnbuffered() (string, error) {
|
||||
// RepoFingerprint fixes repo fingerprint mismatches by prompting the user
|
||||
// for which action to take. This is interactive because the consequences
|
||||
// differ significantly between options:
|
||||
// 1. Update repo ID (if URL changed or bd upgraded)
|
||||
// 2. Reinitialize database (if wrong database was copied)
|
||||
// 3. Skip (do nothing)
|
||||
// 1. Update repo ID (if URL changed or bd upgraded)
|
||||
// 2. Reinitialize database (if wrong database was copied)
|
||||
// 3. Skip (do nothing)
|
||||
func RepoFingerprint(path string) error {
|
||||
// Validate workspace
|
||||
if err := validateBeadsWorkspace(path); err != nil {
|
||||
@@ -67,7 +66,7 @@ func RepoFingerprint(path string) error {
|
||||
case "1":
|
||||
// Run bd migrate --update-repo-id
|
||||
fmt.Println(" → Running 'bd migrate --update-repo-id'...")
|
||||
cmd := exec.Command(bdBinary, "migrate", "--update-repo-id") // #nosec G204 -- bdBinary from validated executable path
|
||||
cmd := newBdCmd(bdBinary, "migrate", "--update-repo-id")
|
||||
cmd.Dir = path
|
||||
cmd.Stdin = os.Stdin // Allow user to respond to migrate's confirmation prompt
|
||||
cmd.Stdout = os.Stdout
|
||||
@@ -105,7 +104,7 @@ func RepoFingerprint(path string) error {
|
||||
_ = os.Remove(dbPath + "-shm")
|
||||
|
||||
fmt.Println(" → Running 'bd init'...")
|
||||
cmd := exec.Command(bdBinary, "init", "--quiet") // #nosec G204 -- bdBinary from validated executable path
|
||||
cmd := newBdCmd(bdBinary, "init", "--quiet")
|
||||
cmd.Dir = path
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
|
||||
52
cmd/bd/doctor/fix/sqlite_open.go
Normal file
52
cmd/bd/doctor/fix/sqlite_open.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package fix
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func sqliteConnString(path string, readOnly bool) string {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
busy := 30 * time.Second
|
||||
if v := strings.TrimSpace(os.Getenv("BD_LOCK_TIMEOUT")); v != "" {
|
||||
if d, err := time.ParseDuration(v); err == nil {
|
||||
busy = d
|
||||
}
|
||||
}
|
||||
busyMs := int64(busy / time.Millisecond)
|
||||
|
||||
if strings.HasPrefix(path, "file:") {
|
||||
conn := path
|
||||
sep := "?"
|
||||
if strings.Contains(conn, "?") {
|
||||
sep = "&"
|
||||
}
|
||||
if readOnly && !strings.Contains(conn, "mode=") {
|
||||
conn += sep + "mode=ro"
|
||||
sep = "&"
|
||||
}
|
||||
if !strings.Contains(conn, "_pragma=busy_timeout") {
|
||||
conn += fmt.Sprintf("%s_pragma=busy_timeout(%d)", sep, busyMs)
|
||||
sep = "&"
|
||||
}
|
||||
if !strings.Contains(conn, "_pragma=foreign_keys") {
|
||||
conn += sep + "_pragma=foreign_keys(ON)"
|
||||
sep = "&"
|
||||
}
|
||||
if !strings.Contains(conn, "_time_format=") {
|
||||
conn += sep + "_time_format=sqlite"
|
||||
}
|
||||
return conn
|
||||
}
|
||||
|
||||
if readOnly {
|
||||
return fmt.Sprintf("file:%s?mode=ro&_pragma=foreign_keys(ON)&_pragma=busy_timeout(%d)&_time_format=sqlite", path, busyMs)
|
||||
}
|
||||
return fmt.Sprintf("file:%s?_pragma=foreign_keys(ON)&_pragma=busy_timeout(%d)&_time_format=sqlite", path, busyMs)
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
|
||||
_ "github.com/ncruces/go-sqlite3/driver"
|
||||
@@ -38,13 +37,23 @@ func DBJSONLSync(path string) error {
|
||||
|
||||
// Find JSONL file
|
||||
var jsonlPath string
|
||||
issuesJSONL := filepath.Join(beadsDir, "issues.jsonl")
|
||||
beadsJSONL := filepath.Join(beadsDir, "beads.jsonl")
|
||||
if cfg, err := configfile.Load(beadsDir); err == nil && cfg != nil {
|
||||
if cfg.JSONLExport != "" && !isSystemJSONLFilename(cfg.JSONLExport) {
|
||||
p := cfg.JSONLPath(beadsDir)
|
||||
if _, err := os.Stat(p); err == nil {
|
||||
jsonlPath = p
|
||||
}
|
||||
}
|
||||
}
|
||||
if jsonlPath == "" {
|
||||
issuesJSONL := filepath.Join(beadsDir, "issues.jsonl")
|
||||
beadsJSONL := filepath.Join(beadsDir, "beads.jsonl")
|
||||
|
||||
if _, err := os.Stat(issuesJSONL); err == nil {
|
||||
jsonlPath = issuesJSONL
|
||||
} else if _, err := os.Stat(beadsJSONL); err == nil {
|
||||
jsonlPath = beadsJSONL
|
||||
if _, err := os.Stat(issuesJSONL); err == nil {
|
||||
jsonlPath = issuesJSONL
|
||||
} else if _, err := os.Stat(beadsJSONL); err == nil {
|
||||
jsonlPath = beadsJSONL
|
||||
}
|
||||
}
|
||||
|
||||
// Check if both database and JSONL exist
|
||||
@@ -102,21 +111,36 @@ func DBJSONLSync(path string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// Run the appropriate sync command
|
||||
var cmd *exec.Cmd
|
||||
if syncDirection == "export" {
|
||||
// Export DB to JSONL file (must specify -o to write to file, not stdout)
|
||||
jsonlOutputPath := filepath.Join(beadsDir, "issues.jsonl")
|
||||
cmd = exec.Command(bdBinary, "export", "-o", jsonlOutputPath, "--force") // #nosec G204 -- bdBinary from validated executable path
|
||||
} else {
|
||||
cmd = exec.Command(bdBinary, "sync", "--import-only") // #nosec G204 -- bdBinary from validated executable path
|
||||
jsonlOutputPath := jsonlPath
|
||||
exportCmd := newBdCmd(bdBinary, "--db", dbPath, "export", "-o", jsonlOutputPath, "--force")
|
||||
exportCmd.Dir = path // Set working directory without changing process dir
|
||||
exportCmd.Stdout = os.Stdout
|
||||
exportCmd.Stderr = os.Stderr
|
||||
if err := exportCmd.Run(); err != nil {
|
||||
return fmt.Errorf("failed to export database to JSONL: %w", err)
|
||||
}
|
||||
|
||||
// Staleness check uses last_import_time. After exporting, JSONL mtime is newer,
|
||||
// so mark the DB as fresh by running a no-op import (skip existing issues).
|
||||
markFreshCmd := newBdCmd(bdBinary, "--db", dbPath, "import", "-i", jsonlOutputPath, "--force", "--skip-existing", "--no-git-history")
|
||||
markFreshCmd.Dir = path
|
||||
markFreshCmd.Stdout = os.Stdout
|
||||
markFreshCmd.Stderr = os.Stderr
|
||||
if err := markFreshCmd.Run(); err != nil {
|
||||
return fmt.Errorf("failed to mark database as fresh after export: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
cmd.Dir = path // Set working directory without changing process dir
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
importCmd := newBdCmd(bdBinary, "--db", dbPath, "sync", "--import-only")
|
||||
importCmd.Dir = path // Set working directory without changing process dir
|
||||
importCmd.Stdout = os.Stdout
|
||||
importCmd.Stderr = os.Stderr
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
if err := importCmd.Run(); err != nil {
|
||||
return fmt.Errorf("failed to sync database with JSONL: %w", err)
|
||||
}
|
||||
|
||||
@@ -125,7 +149,7 @@ func DBJSONLSync(path string) error {
|
||||
|
||||
// countDatabaseIssues counts the number of issues in the database.
|
||||
func countDatabaseIssues(dbPath string) (int, error) {
|
||||
db, err := sql.Open("sqlite3", dbPath)
|
||||
db, err := sql.Open("sqlite3", sqliteConnString(dbPath, true))
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to open database: %w", err)
|
||||
}
|
||||
|
||||
@@ -32,8 +32,7 @@ func SyncBranchConfig(path string) error {
|
||||
}
|
||||
|
||||
// Set sync.branch using bd config set
|
||||
// #nosec G204 - bdBinary is controlled by getBdBinary() which returns os.Executable()
|
||||
setCmd := exec.Command(bdBinary, "config", "set", "sync.branch", currentBranch)
|
||||
setCmd := newBdCmd(bdBinary, "config", "set", "sync.branch", currentBranch)
|
||||
setCmd.Dir = path
|
||||
if output, err := setCmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("failed to set sync.branch: %w\nOutput: %s", err, string(output))
|
||||
|
||||
@@ -233,5 +233,5 @@ func ChildParentDependencies(path string) error {
|
||||
|
||||
// openDB opens a SQLite database for read-write access
|
||||
func openDB(dbPath string) (*sql.DB, error) {
|
||||
return sql.Open("sqlite3", dbPath)
|
||||
return sql.Open("sqlite3", sqliteConnString(dbPath, false))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user