bd sync: 2025-12-27 15:56:42

This commit is contained in:
Steve Yegge
2025-12-27 15:56:42 -08:00
parent 87f535a65e
commit c8b912cbe6
179 changed files with 3051 additions and 10283 deletions

View File

@@ -12,13 +12,6 @@ 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.

View File

@@ -3,6 +3,7 @@ package fix
import (
"fmt"
"os"
"os/exec"
"path/filepath"
)
@@ -35,7 +36,7 @@ func Daemon(path string) error {
}
// Run bd daemons killall to clean up stale daemons
cmd := newBdCmd(bdBinary, "daemons", "killall")
cmd := exec.Command(bdBinary, "daemons", "killall") // #nosec G204 -- bdBinary from validated executable path
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr

View File

@@ -32,13 +32,6 @@ 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)
@@ -106,15 +99,7 @@ func findActualJSONLFile(beadsDir string) string {
strings.Contains(lowerName, ".orig") ||
strings.Contains(lowerName, ".bak") ||
strings.Contains(lowerName, "~") ||
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") {
strings.HasPrefix(lowerName, "backup_") {
continue
}
@@ -136,15 +121,6 @@ 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

View File

@@ -220,53 +220,3 @@ 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)
}
}

View File

@@ -1,116 +0,0 @@
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
}

View File

@@ -1,57 +0,0 @@
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)
}

View File

@@ -1,71 +0,0 @@
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)
}
}

View File

@@ -28,7 +28,7 @@ func GitHooks(path string) error {
}
// Run bd hooks install
cmd := newBdCmd(bdBinary, "hooks", "install")
cmd := exec.Command(bdBinary, "hooks", "install") // #nosec G204 -- bdBinary from validated executable path
cmd.Dir = path // Set working directory without changing process dir
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr

View File

@@ -1,87 +0,0 @@
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
}

View File

@@ -3,10 +3,8 @@ 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,
@@ -25,15 +23,12 @@ 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.CanonicalDatabaseName)
if cfg, err := configfile.Load(beadsDir); err == nil && cfg != nil && cfg.Database != "" {
dbPath = cfg.DatabasePath(beadsDir)
}
dbPath := filepath.Join(beadsDir, "beads.db")
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 := newBdCmd(bdBinary, "--db", dbPath, "init")
cmd := exec.Command(bdBinary, "init") // #nosec G204 -- bdBinary from validated executable path
cmd.Dir = path
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
@@ -46,8 +41,8 @@ func DatabaseVersion(path string) error {
}
// Database exists - run bd migrate
cmd := newBdCmd(bdBinary, "--db", dbPath, "migrate")
cmd.Dir = path // Set working directory without changing process dir
cmd := exec.Command(bdBinary, "migrate") // #nosec G204 -- bdBinary from validated executable path
cmd.Dir = path // Set working directory without changing process dir
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr

View File

@@ -1,81 +0,0 @@
package fix
import (
"fmt"
"os"
"os/exec"
"path/filepath"
)
// DatabaseCorruptionRecovery recovers a corrupted database from JSONL backup.
// It backs up the corrupted database, deletes it, and re-imports from JSONL.
func DatabaseCorruptionRecovery(path string) error {
// Validate workspace
if err := validateBeadsWorkspace(path); err != nil {
return err
}
beadsDir := filepath.Join(path, ".beads")
dbPath := filepath.Join(beadsDir, "beads.db")
// Check if database exists
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
return fmt.Errorf("no database to recover")
}
// Find JSONL file
jsonlPath := findJSONLPath(beadsDir)
if jsonlPath == "" {
return fmt.Errorf("no JSONL backup found - cannot recover (try restoring from git history)")
}
// Count issues in JSONL
issueCount, err := countJSONLIssues(jsonlPath)
if err != nil {
return fmt.Errorf("failed to read JSONL: %w", err)
}
if issueCount == 0 {
return fmt.Errorf("JSONL is empty - cannot recover (try restoring from git history)")
}
// Backup corrupted database
backupPath := dbPath + ".corrupt"
fmt.Printf(" Backing up corrupted database to %s\n", filepath.Base(backupPath))
if err := os.Rename(dbPath, backupPath); err != nil {
return fmt.Errorf("failed to backup corrupted database: %w", err)
}
// Get bd binary path
bdBinary, err := getBdBinary()
if err != nil {
// Restore corrupted database on failure
_ = os.Rename(backupPath, dbPath)
return err
}
// Run bd import with --rename-on-import to handle prefix mismatches
fmt.Printf(" Recovering %d issues from %s\n", issueCount, filepath.Base(jsonlPath))
cmd := exec.Command(bdBinary, "import", "-i", jsonlPath, "--rename-on-import") // #nosec G204
cmd.Dir = path
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
// Keep backup on failure
fmt.Printf(" Warning: recovery failed, corrupted database preserved at %s\n", filepath.Base(backupPath))
return fmt.Errorf("failed to import from JSONL: %w", err)
}
// Run migrate to set version metadata
migrateCmd := exec.Command(bdBinary, "migrate") // #nosec G204
migrateCmd.Dir = path
migrateCmd.Stdout = os.Stdout
migrateCmd.Stderr = os.Stderr
if err := migrateCmd.Run(); err != nil {
// Non-fatal - import succeeded, version just won't be set
fmt.Printf(" Warning: migration failed (non-fatal): %v\n", err)
}
fmt.Printf(" Recovered %d issues from JSONL backup\n", issueCount)
return nil
}

View File

@@ -3,6 +3,7 @@ package fix
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
)
@@ -30,9 +31,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 {
@@ -66,7 +67,7 @@ func RepoFingerprint(path string) error {
case "1":
// Run bd migrate --update-repo-id
fmt.Println(" → Running 'bd migrate --update-repo-id'...")
cmd := newBdCmd(bdBinary, "migrate", "--update-repo-id")
cmd := exec.Command(bdBinary, "migrate", "--update-repo-id") // #nosec G204 -- bdBinary from validated executable path
cmd.Dir = path
cmd.Stdin = os.Stdin // Allow user to respond to migrate's confirmation prompt
cmd.Stdout = os.Stdout
@@ -104,7 +105,7 @@ func RepoFingerprint(path string) error {
_ = os.Remove(dbPath + "-shm")
fmt.Println(" → Running 'bd init'...")
cmd := newBdCmd(bdBinary, "init", "--quiet")
cmd := exec.Command(bdBinary, "init", "--quiet") // #nosec G204 -- bdBinary from validated executable path
cmd.Dir = path
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr

View File

@@ -1,52 +0,0 @@
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)
}

View File

@@ -6,6 +6,7 @@ import (
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
_ "github.com/ncruces/go-sqlite3/driver"
@@ -37,23 +38,13 @@ func DBJSONLSync(path string) error {
// Find JSONL file
var jsonlPath string
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")
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
@@ -111,36 +102,21 @@ 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 := 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
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
}
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
cmd.Dir = path // Set working directory without changing process dir
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := importCmd.Run(); err != nil {
if err := cmd.Run(); err != nil {
return fmt.Errorf("failed to sync database with JSONL: %w", err)
}
@@ -149,7 +125,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", sqliteConnString(dbPath, true))
db, err := sql.Open("sqlite3", dbPath)
if err != nil {
return 0, fmt.Errorf("failed to open database: %w", err)
}

View File

@@ -32,7 +32,8 @@ func SyncBranchConfig(path string) error {
}
// Set sync.branch using bd config set
setCmd := newBdCmd(bdBinary, "config", "set", "sync.branch", currentBranch)
// #nosec G204 - bdBinary is controlled by getBdBinary() which returns os.Executable()
setCmd := exec.Command(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))

View File

@@ -180,14 +180,11 @@ func ChildParentDependencies(path string) error {
}
defer db.Close()
// Find child→parent BLOCKING dependencies where issue_id starts with depends_on_id + "."
// Only matches blocking types (blocks, conditional-blocks, waits-for) that cause deadlock.
// Excludes 'parent-child' type which is a legitimate structural hierarchy relationship.
// Find child→parent dependencies where issue_id starts with depends_on_id + "."
query := `
SELECT d.issue_id, d.depends_on_id, d.type
SELECT d.issue_id, d.depends_on_id
FROM dependencies d
WHERE d.issue_id LIKE d.depends_on_id || '.%'
AND d.type IN ('blocks', 'conditional-blocks', 'waits-for')
`
rows, err := db.Query(query)
if err != nil {
@@ -198,13 +195,12 @@ func ChildParentDependencies(path string) error {
type badDep struct {
issueID string
dependsOnID string
depType string
}
var badDeps []badDep
for rows.Next() {
var d badDep
if err := rows.Scan(&d.issueID, &d.dependsOnID, &d.depType); err == nil {
if err := rows.Scan(&d.issueID, &d.dependsOnID); err == nil {
badDeps = append(badDeps, d)
}
}
@@ -214,10 +210,10 @@ func ChildParentDependencies(path string) error {
return nil
}
// Delete child→parent blocking dependencies (preserving parent-child type)
// Delete child→parent dependencies
for _, d := range badDeps {
_, err := db.Exec("DELETE FROM dependencies WHERE issue_id = ? AND depends_on_id = ? AND type = ?",
d.issueID, d.dependsOnID, d.depType)
_, err := db.Exec("DELETE FROM dependencies WHERE issue_id = ? AND depends_on_id = ?",
d.issueID, d.dependsOnID)
if err != nil {
fmt.Printf(" Warning: failed to remove %s→%s: %v\n", d.issueID, d.dependsOnID, err)
} else {
@@ -233,5 +229,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", sqliteConnString(dbPath, false))
return sql.Open("sqlite3", dbPath)
}

View File

@@ -138,66 +138,3 @@ func TestChildParentDependencies_FixesBadDeps(t *testing.T) {
t.Errorf("Expected 2 dirty issues (unique issue_ids from removed deps), got %d", dirtyCount)
}
}
// TestChildParentDependencies_PreservesParentChildType verifies that legitimate
// parent-child type dependencies are NOT removed (only blocking types are removed).
// Regression test for GitHub issue #750.
func TestChildParentDependencies_PreservesParentChildType(t *testing.T) {
// Set up test database with both 'blocks' and 'parent-child' type deps
dir := t.TempDir()
beadsDir := filepath.Join(dir, ".beads")
if err := os.MkdirAll(beadsDir, 0755); err != nil {
t.Fatal(err)
}
dbPath := filepath.Join(beadsDir, "beads.db")
db, err := openDB(dbPath)
if err != nil {
t.Fatal(err)
}
// Create schema with both 'blocks' (anti-pattern) and 'parent-child' (legitimate) deps
_, err = db.Exec(`
CREATE TABLE issues (id TEXT PRIMARY KEY);
CREATE TABLE dependencies (issue_id TEXT, depends_on_id TEXT, type TEXT);
CREATE TABLE dirty_issues (issue_id TEXT PRIMARY KEY);
INSERT INTO issues (id) VALUES ('bd-abc'), ('bd-abc.1'), ('bd-abc.2');
INSERT INTO dependencies (issue_id, depends_on_id, type) VALUES
('bd-abc.1', 'bd-abc', 'parent-child'),
('bd-abc.2', 'bd-abc', 'parent-child'),
('bd-abc.1', 'bd-abc', 'blocks');
`)
if err != nil {
t.Fatal(err)
}
db.Close()
// Run fix
err = ChildParentDependencies(dir)
if err != nil {
t.Fatalf("ChildParentDependencies failed: %v", err)
}
// Verify only 'blocks' type was removed, 'parent-child' preserved
db, _ = openDB(dbPath)
defer db.Close()
var blocksCount int
db.QueryRow("SELECT COUNT(*) FROM dependencies WHERE type = 'blocks'").Scan(&blocksCount)
if blocksCount != 0 {
t.Errorf("Expected 0 'blocks' dependencies after fix, got %d", blocksCount)
}
var parentChildCount int
db.QueryRow("SELECT COUNT(*) FROM dependencies WHERE type = 'parent-child'").Scan(&parentChildCount)
if parentChildCount != 2 {
t.Errorf("Expected 2 'parent-child' dependencies preserved, got %d", parentChildCount)
}
// Verify only 1 dirty issue (the one with 'blocks' dep removed)
var dirtyCount int
db.QueryRow("SELECT COUNT(*) FROM dirty_issues").Scan(&dirtyCount)
if dirtyCount != 1 {
t.Errorf("Expected 1 dirty issue, got %d", dirtyCount)
}
}