Merge remote changes during sync

This commit is contained in:
Steve Yegge
2025-11-20 12:47:03 -05:00
38 changed files with 1409 additions and 3835 deletions
@@ -0,0 +1,59 @@
---
description: How to resolve merge conflicts in .beads/beads.jsonl
---
# Resolving `beads.jsonl` Merge Conflicts
If you encounter a merge conflict in `.beads/beads.jsonl` that doesn't have standard git conflict markers (or if `bd merge` failed automatically), follow this procedure.
## 1. Identify the Conflict
Check if `beads.jsonl` is in conflict:
```powershell
git status
```
## 2. Extract the 3 Versions
Git stores three versions of conflicted files in its index:
1. Base (common ancestor)
2. Ours (current branch)
3. Theirs (incoming branch)
Extract them to temporary files:
```powershell
git show :1:.beads/beads.jsonl > beads.base.jsonl
git show :2:.beads/beads.jsonl > beads.ours.jsonl
git show :3:.beads/beads.jsonl > beads.theirs.jsonl
```
## 3. Run `bd merge` Manually
Run the `bd merge` tool manually with the `--debug` flag to see what's happening.
Syntax: `bd merge <output> <base> <ours> <theirs>`
```powershell
bd merge beads.merged.jsonl beads.base.jsonl beads.ours.jsonl beads.theirs.jsonl --debug
```
## 4. Verify the Result
Check the output of the command.
- **Exit Code 0**: Success. `beads.merged.jsonl` contains the clean merge.
- **Exit Code 1**: Conflicts remain. `beads.merged.jsonl` will contain conflict markers. You must edit it manually to resolve them.
Optionally, verify the content (e.g., check for missing IDs if you suspect data loss).
## 5. Apply the Merge
Overwrite the conflicted file with the resolved version:
```powershell
cp beads.merged.jsonl .beads/beads.jsonl
```
## 6. Cleanup and Continue
Stage the resolved file and continue the merge:
```powershell
git add .beads/beads.jsonl
git merge --continue
```
## 7. Cleanup Temporary Files
```powershell
rm beads.base.jsonl beads.ours.jsonl beads.theirs.jsonl beads.merged.jsonl
```
+53 -2518
View File
File diff suppressed because one or more lines are too long
-484
View File
File diff suppressed because one or more lines are too long
+12 -5
View File
@@ -32,12 +32,14 @@ jobs:
- name: Check coverage threshold
run: |
COVERAGE=$(go tool cover -func=coverage.out | grep total | awk '{print $3}' | sed 's/%//')
MIN_COVERAGE=46
WARN_COVERAGE=55
echo "Coverage: $COVERAGE%"
if (( $(echo "$COVERAGE < 50" | bc -l) )); then
echo "❌ Coverage is below 50% threshold"
if (( $(echo "$COVERAGE < $MIN_COVERAGE" | bc -l) )); then
echo "❌ Coverage is below ${MIN_COVERAGE}% threshold"
exit 1
elif (( $(echo "$COVERAGE < 55" | bc -l) )); then
echo "⚠️ Coverage is below 55% (warning threshold)"
elif (( $(echo "$COVERAGE < $WARN_COVERAGE" | bc -l) )); then
echo "⚠️ Coverage is below ${WARN_COVERAGE}% (warning threshold)"
else
echo "✅ Coverage meets threshold"
fi
@@ -95,7 +97,12 @@ jobs:
- uses: cachix/install-nix-action@v31
with:
nix_path: nixpkgs=channel:nixos-unstable
- run: nix run .#default > help.txt
- name: Run bd help via Nix
run: |
export BEADS_DB="$PWD/.ci-beads/beads.db"
mkdir -p "$(dirname "$BEADS_DB")"
nix run .#default -- --db "$BEADS_DB" init --quiet --prefix ci
nix run .#default -- --db "$BEADS_DB" > help.txt
- name: Verify help text
run: |
FIRST_LINE=$(head -n 1 help.txt)
+15 -14
View File
@@ -14,20 +14,20 @@ import (
)
var (
compactDryRun bool
compactTier int
compactAll bool
compactID string
compactForce bool
compactBatch int
compactWorkers int
compactStats bool
compactAnalyze bool
compactApply bool
compactAuto bool
compactSummary string
compactActor string
compactLimit int
compactDryRun bool
compactTier int
compactAll bool
compactID string
compactForce bool
compactBatch int
compactWorkers int
compactStats bool
compactAnalyze bool
compactApply bool
compactAuto bool
compactSummary string
compactActor string
compactLimit int
)
var compactCmd = &cobra.Command{
@@ -762,6 +762,7 @@ func runCompactApply(ctx context.Context, store *sqlite.SQLiteStorage) {
os.Exit(1)
}
} else {
// #nosec G304 -- summary file path provided explicitly by operator
summaryBytes, err = os.ReadFile(compactSummary)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: failed to read summary file: %v\n", err)
+21
View File
@@ -307,6 +307,14 @@ func createExportFunc(ctx context.Context, store storage.Storage, autoCommit, au
}
log.log("Exported to JSONL")
// Update database mtime to be >= JSONL mtime (fixes #278, #301, #321)
// This prevents validatePreExport from incorrectly blocking on next export
// with "JSONL is newer than database" after daemon auto-export
dbPath := filepath.Join(beadsDir, "beads.db")
if err := TouchDatabaseFile(dbPath, jsonlPath); err != nil {
log.log("Warning: failed to update database mtime: %v", err)
}
// Auto-commit if enabled
if autoCommit {
// Try sync branch commit first
@@ -502,6 +510,13 @@ func createSyncFunc(ctx context.Context, store storage.Storage, autoCommit, auto
}
log.log("Exported to JSONL")
// Update database mtime to be >= JSONL mtime (fixes #278, #301, #321)
// This prevents validatePreExport from incorrectly blocking on next export
dbPath := filepath.Join(beadsDir, "beads.db")
if err := TouchDatabaseFile(dbPath, jsonlPath); err != nil {
log.log("Warning: failed to update database mtime: %v", err)
}
// Capture left snapshot (pre-pull state) for 3-way merge
// This is mandatory for deletion tracking integrity
// In multi-repo mode, capture snapshots for all JSONL files
@@ -597,6 +612,12 @@ func createSyncFunc(ctx context.Context, store storage.Storage, autoCommit, auto
}
log.log("Imported from JSONL")
// Update database mtime after import (fixes #278, #301, #321)
// Sync branch import can update JSONL timestamp, so ensure DB >= JSONL
if err := TouchDatabaseFile(dbPath, jsonlPath); err != nil {
log.log("Warning: failed to update database mtime: %v", err)
}
// Validate import didn't cause data loss
afterCount, err := countDBIssues(syncCtx, store)
if err != nil {
+21 -17
View File
@@ -13,13 +13,13 @@ import (
"time"
"github.com/fatih/color"
_ "github.com/ncruces/go-sqlite3/driver"
_ "github.com/ncruces/go-sqlite3/embed"
"github.com/spf13/cobra"
"github.com/steveyegge/beads/cmd/bd/doctor"
"github.com/steveyegge/beads/internal/beads"
"github.com/steveyegge/beads/internal/configfile"
"github.com/steveyegge/beads/internal/daemon"
_ "github.com/ncruces/go-sqlite3/driver"
_ "github.com/ncruces/go-sqlite3/embed"
)
// Status constants for doctor checks
@@ -148,7 +148,7 @@ func applyFixes(result doctorResult) {
}
}
func runDiagnostics(path string) doctorResult{
func runDiagnostics(path string) doctorResult {
result := doctorResult{
Path: path,
CLIVersion: Version,
@@ -293,7 +293,7 @@ func checkInstallation(path string) doctorCheck {
func checkDatabaseVersion(path string) doctorCheck {
beadsDir := filepath.Join(path, ".beads")
// Check metadata.json first for custom database name
var dbPath string
if cfg, err := configfile.Load(beadsDir); err == nil && cfg != nil && cfg.Database != "" {
@@ -379,7 +379,7 @@ func checkDatabaseVersion(path string) doctorCheck {
func checkIDFormat(path string) doctorCheck {
beadsDir := filepath.Join(path, ".beads")
// Check metadata.json first for custom database name
var dbPath string
if cfg, err := configfile.Load(beadsDir); err == nil && cfg != nil && cfg.Database != "" {
@@ -781,7 +781,7 @@ func printDiagnostics(result doctorResult) {
func checkMultipleDatabases(path string) doctorCheck {
beadsDir := filepath.Join(path, ".beads")
// Find all .db files (excluding backups and vc.db)
files, err := filepath.Glob(filepath.Join(beadsDir, "*.db"))
if err != nil {
@@ -1145,7 +1145,7 @@ func countJSONLIssues(jsonlPath string) (int, map[string]int, error) {
func checkPermissions(path string) doctorCheck {
beadsDir := filepath.Join(path, ".beads")
// Check if .beads/ is writable
testFile := filepath.Join(beadsDir, ".doctor-test-write")
if err := os.WriteFile(testFile, []byte("test"), 0600); err != nil {
@@ -1303,9 +1303,9 @@ func checkGitHooks(path string) doctorCheck {
// Recommended hooks and their purposes
recommendedHooks := map[string]string{
"pre-commit": "Flushes pending bd changes to JSONL before commit",
"post-merge": "Imports updated JSONL after git pull/merge",
"pre-push": "Exports database to JSONL before push",
"pre-commit": "Flushes pending bd changes to JSONL before commit",
"post-merge": "Imports updated JSONL after git pull/merge",
"pre-push": "Exports database to JSONL before push",
}
hooksDir := filepath.Join(gitDir, "hooks")
@@ -1353,7 +1353,7 @@ func checkGitHooks(path string) doctorCheck {
func checkSchemaCompatibility(path string) doctorCheck {
beadsDir := filepath.Join(path, ".beads")
// Check metadata.json first for custom database name
var dbPath string
if cfg, err := configfile.Load(beadsDir); err == nil && cfg != nil && cfg.Database != "" {
@@ -1390,18 +1390,22 @@ func checkSchemaCompatibility(path string) doctorCheck {
// This is a simplified version since we can't import the internal package directly
// Check all critical tables and columns
criticalChecks := map[string][]string{
"issues": {"id", "title", "content_hash", "external_ref", "compacted_at"},
"dependencies": {"issue_id", "depends_on_id", "type"},
"issues": {"id", "title", "content_hash", "external_ref", "compacted_at"},
"dependencies": {"issue_id", "depends_on_id", "type"},
"child_counters": {"parent_id", "last_child"},
"export_hashes": {"issue_id", "content_hash"},
"export_hashes": {"issue_id", "content_hash"},
}
var missingElements []string
for table, columns := range criticalChecks {
// Try to query all columns
query := fmt.Sprintf("SELECT %s FROM %s LIMIT 0", strings.Join(columns, ", "), table)
query := fmt.Sprintf(
"SELECT %s FROM %s LIMIT 0",
strings.Join(columns, ", "),
table,
) // #nosec G201 -- table/column names sourced from hardcoded map
_, err := db.Exec(query)
if err != nil {
errMsg := err.Error()
if strings.Contains(errMsg, "no such table") {
@@ -1409,7 +1413,7 @@ func checkSchemaCompatibility(path string) doctorCheck {
} else if strings.Contains(errMsg, "no such column") {
// Find which columns are missing
for _, col := range columns {
colQuery := fmt.Sprintf("SELECT %s FROM %s LIMIT 0", col, table)
colQuery := fmt.Sprintf("SELECT %s FROM %s LIMIT 0", col, table) // #nosec G201 -- names come from static schema definition
if _, colErr := db.Exec(colQuery); colErr != nil && strings.Contains(colErr.Error(), "no such column") {
missingElements = append(missingElements, fmt.Sprintf("%s.%s", table, col))
}
+6 -5
View File
@@ -64,19 +64,19 @@ func RunPerformanceDiagnostics(path string) {
fmt.Printf("\nOperation Performance:\n")
// Measure GetReadyWork
readyDuration := measureOperation("bd ready", func() error {
readyDuration := measureOperation(func() error {
return runReadyWork(dbPath)
})
fmt.Printf(" bd ready %dms\n", readyDuration.Milliseconds())
// Measure SearchIssues (list open)
listDuration := measureOperation("bd list --status=open", func() error {
listDuration := measureOperation(func() error {
return runListOpen(dbPath)
})
fmt.Printf(" bd list --status=open %dms\n", listDuration.Milliseconds())
// Measure GetIssue (show random issue)
showDuration := measureOperation("bd show <issue>", func() error {
showDuration := measureOperation(func() error {
return runShowRandom(dbPath)
})
if showDuration > 0 {
@@ -84,7 +84,7 @@ func RunPerformanceDiagnostics(path string) {
}
// Measure SearchIssues with filters
searchDuration := measureOperation("bd list (complex filters)", func() error {
searchDuration := measureOperation(func() error {
return runComplexSearch(dbPath)
})
fmt.Printf(" bd list (complex filters) %dms\n", searchDuration.Milliseconds())
@@ -188,6 +188,7 @@ func collectDatabaseStats(dbPath string) map[string]string {
}
func startCPUProfile(path string) error {
// #nosec G304 -- profile path supplied by CLI flag in trusted environment
f, err := os.Create(path)
if err != nil {
return err
@@ -205,7 +206,7 @@ func stopCPUProfile() {
}
}
func measureOperation(name string, op func() error) time.Duration {
func measureOperation(op func() error) time.Duration {
start := time.Now()
if err := op(); err != nil {
return 0
+12
View File
@@ -385,6 +385,18 @@ Output to stdout by default, or use -o flag for file output.`,
fmt.Fprintf(os.Stderr, " Mismatch indicates export failed to write all issues\n")
os.Exit(1)
}
// Update database mtime to be >= JSONL mtime (fixes #278, #301, #321)
// Only do this when exporting to default JSONL path (not arbitrary outputs)
// This prevents validatePreExport from incorrectly blocking on next export
if output == "" || output == findJSONLPath() {
beadsDir := filepath.Dir(finalPath)
dbPath := filepath.Join(beadsDir, "beads.db")
if err := TouchDatabaseFile(dbPath, finalPath); err != nil {
// Log warning but don't fail export
fmt.Fprintf(os.Stderr, "Warning: failed to update database mtime: %v\n", err)
}
}
}
// Output statistics if JSON format requested
+242
View File
@@ -0,0 +1,242 @@
package main
import (
"context"
"os"
"path/filepath"
"testing"
"time"
"github.com/steveyegge/beads/internal/storage/sqlite"
"github.com/steveyegge/beads/internal/types"
)
// TestExportUpdatesDatabaseMtime verifies that export updates database mtime
// to be >= JSONL mtime, fixing issues #278, #301, #321
func TestExportUpdatesDatabaseMtime(t *testing.T) {
if testing.Short() {
t.Skip("skipping slow test in short mode")
}
tmpDir := t.TempDir()
beadsDir := filepath.Join(tmpDir, ".beads")
if err := os.Mkdir(beadsDir, 0750); err != nil {
t.Fatal(err)
}
dbPath := filepath.Join(beadsDir, "beads.db")
jsonlPath := filepath.Join(beadsDir, "issues.jsonl")
// Create and populate database
store, err := sqlite.New(dbPath)
if err != nil {
t.Fatalf("Failed to create store: %v", err)
}
defer store.Close()
ctx := context.Background()
// Initialize database with issue_prefix
if err := store.SetConfig(ctx, "issue_prefix", "test"); err != nil {
t.Fatalf("Failed to set issue_prefix: %v", err)
}
// Create a test issue
issue := &types.Issue{
ID: "test-1",
Title: "Test Issue",
Status: types.StatusOpen,
Priority: 2,
IssueType: types.TypeTask,
}
if err := store.CreateIssue(ctx, issue, "test-actor"); err != nil {
t.Fatalf("Failed to create issue: %v", err)
}
// Wait a bit to ensure mtime difference
time.Sleep(1 * time.Second)
// Export to JSONL (simulates daemon export)
if err := exportToJSONLWithStore(ctx, store, jsonlPath); err != nil {
t.Fatalf("Export failed: %v", err)
}
// Get JSONL mtime
jsonlInfo, err := os.Stat(jsonlPath)
if err != nil {
t.Fatalf("Failed to stat JSONL after export: %v", err)
}
// WITHOUT the fix, JSONL would be newer than DB here
// Simulating the old buggy behavior before calling TouchDatabaseFile
dbInfoAfterExport, err := os.Stat(dbPath)
if err != nil {
t.Fatalf("Failed to stat database after export: %v", err)
}
// In old buggy behavior, JSONL mtime > DB mtime
t.Logf("Before TouchDatabaseFile: DB mtime=%v, JSONL mtime=%v",
dbInfoAfterExport.ModTime(), jsonlInfo.ModTime())
// Now apply the fix
if err := TouchDatabaseFile(dbPath, jsonlPath); err != nil {
t.Fatalf("TouchDatabaseFile failed: %v", err)
}
// Get final database mtime
dbInfoAfterTouch, err := os.Stat(dbPath)
if err != nil {
t.Fatalf("Failed to stat database after touch: %v", err)
}
t.Logf("After TouchDatabaseFile: DB mtime=%v, JSONL mtime=%v",
dbInfoAfterTouch.ModTime(), jsonlInfo.ModTime())
// VERIFY: Database mtime should be >= JSONL mtime
if dbInfoAfterTouch.ModTime().Before(jsonlInfo.ModTime()) {
t.Errorf("Database mtime should be >= JSONL mtime after export")
t.Errorf("DB mtime: %v, JSONL mtime: %v",
dbInfoAfterTouch.ModTime(), jsonlInfo.ModTime())
}
// VERIFY: validatePreExport should now pass (not block on next export)
if err := validatePreExport(ctx, store, jsonlPath); err != nil {
t.Errorf("validatePreExport should pass after TouchDatabaseFile, but got error: %v", err)
}
}
// TestDaemonExportScenario simulates the full daemon auto-export workflow
// that was causing issue #278 (daemon shutting down after export)
func TestDaemonExportScenario(t *testing.T) {
if testing.Short() {
t.Skip("skipping slow test in short mode")
}
tmpDir := t.TempDir()
beadsDir := filepath.Join(tmpDir, ".beads")
if err := os.Mkdir(beadsDir, 0750); err != nil {
t.Fatal(err)
}
dbPath := filepath.Join(beadsDir, "beads.db")
jsonlPath := filepath.Join(beadsDir, "issues.jsonl")
// Create and populate database
store, err := sqlite.New(dbPath)
if err != nil {
t.Fatalf("Failed to create store: %v", err)
}
defer store.Close()
ctx := context.Background()
// Initialize database with issue_prefix
if err := store.SetConfig(ctx, "issue_prefix", "bd"); err != nil {
t.Fatalf("Failed to set issue_prefix: %v", err)
}
// Step 1: User creates an issue (e.g., bd close bd-123)
now := time.Now()
issue := &types.Issue{
ID: "bd-123",
Title: "User created issue",
Status: types.StatusClosed,
Priority: 1,
IssueType: types.TypeTask,
ClosedAt: &now,
}
if err := store.CreateIssue(ctx, issue, "test-user"); err != nil {
t.Fatalf("Failed to create issue: %v", err)
}
// Database is now newer than JSONL (JSONL doesn't exist yet)
time.Sleep(1 * time.Second)
// Step 2: Daemon auto-exports after delay (30s-4min in real scenario)
// This simulates the daemon's export cycle
if err := exportToJSONLWithStore(ctx, store, jsonlPath); err != nil {
t.Fatalf("Daemon export failed: %v", err)
}
// THIS IS THE FIX: daemon now calls TouchDatabaseFile after export
if err := TouchDatabaseFile(dbPath, jsonlPath); err != nil {
t.Fatalf("TouchDatabaseFile failed: %v", err)
}
// Step 3: User runs bd sync shortly after
// WITHOUT the fix, this would fail with "JSONL is newer than database"
// WITH the fix, this should succeed
if err := validatePreExport(ctx, store, jsonlPath); err != nil {
t.Errorf("Daemon export scenario failed: validatePreExport blocked after daemon export")
t.Errorf("This is the bug from issue #278/#301/#321: %v", err)
}
// Verify we can export again (simulates bd sync)
jsonlPathTemp := jsonlPath + ".sync"
if err := exportToJSONLWithStore(ctx, store, jsonlPathTemp); err != nil {
t.Errorf("Second export (bd sync) failed: %v", err)
}
os.Remove(jsonlPathTemp)
}
// TestMultipleExportCycles verifies repeated export cycles don't cause issues
func TestMultipleExportCycles(t *testing.T) {
if testing.Short() {
t.Skip("skipping slow test in short mode")
}
tmpDir := t.TempDir()
beadsDir := filepath.Join(tmpDir, ".beads")
if err := os.Mkdir(beadsDir, 0750); err != nil {
t.Fatal(err)
}
dbPath := filepath.Join(beadsDir, "beads.db")
jsonlPath := filepath.Join(beadsDir, "issues.jsonl")
// Create and populate database
store, err := sqlite.New(dbPath)
if err != nil {
t.Fatalf("Failed to create store: %v", err)
}
defer store.Close()
ctx := context.Background()
// Initialize database with issue_prefix
if err := store.SetConfig(ctx, "issue_prefix", "test"); err != nil {
t.Fatalf("Failed to set issue_prefix: %v", err)
}
// Run multiple export cycles
for i := 0; i < 5; i++ {
// Add an issue
issue := &types.Issue{
ID: "test-" + string(rune('a'+i)),
Title: "Test Issue " + string(rune('A'+i)),
Status: types.StatusOpen,
Priority: 2,
IssueType: types.TypeTask,
}
if err := store.CreateIssue(ctx, issue, "test-actor"); err != nil {
t.Fatalf("Cycle %d: Failed to create issue: %v", i, err)
}
time.Sleep(100 * time.Millisecond)
// Export (with fix)
if err := exportToJSONLWithStore(ctx, store, jsonlPath); err != nil {
t.Fatalf("Cycle %d: Export failed: %v", i, err)
}
// Apply fix
if err := TouchDatabaseFile(dbPath, jsonlPath); err != nil {
t.Fatalf("Cycle %d: TouchDatabaseFile failed: %v", i, err)
}
// Verify validation passes
if err := validatePreExport(ctx, store, jsonlPath); err != nil {
t.Errorf("Cycle %d: validatePreExport failed: %v", i, err)
}
}
}
+33 -43
View File
@@ -18,7 +18,7 @@ var hooksFS embed.FS
func getEmbeddedHooks() (map[string]string, error) {
hooks := make(map[string]string)
hookNames := []string{"pre-commit", "post-merge", "pre-push", "post-checkout"}
for _, name := range hookNames {
content, err := hooksFS.ReadFile("templates/hooks/" + name)
if err != nil {
@@ -26,7 +26,7 @@ func getEmbeddedHooks() (map[string]string, error) {
}
hooks[name] = string(content)
}
return hooks, nil
}
@@ -41,7 +41,7 @@ type HookStatus struct {
}
// CheckGitHooks checks the status of bd git hooks in .git/hooks/
func CheckGitHooks() ([]HookStatus, error) {
func CheckGitHooks() []HookStatus {
hooks := []string{"pre-commit", "post-merge", "pre-push", "post-checkout"}
statuses := make([]HookStatus, 0, len(hooks))
@@ -59,7 +59,7 @@ func CheckGitHooks() ([]HookStatus, error) {
} else {
status.Installed = true
status.Version = version
// Check if outdated (compare to current bd version)
if version != "" && version != Version {
status.Outdated = true
@@ -69,11 +69,12 @@ func CheckGitHooks() ([]HookStatus, error) {
statuses = append(statuses, status)
}
return statuses, nil
return statuses
}
// getHookVersion extracts the version from a hook file
func getHookVersion(path string) (string, error) {
// #nosec G304 -- hook path constrained to .git/hooks directory
file, err := os.Open(path)
if err != nil {
return "", err
@@ -99,10 +100,10 @@ func getHookVersion(path string) (string, error) {
// FormatHookWarnings returns a formatted warning message if hooks are outdated
func FormatHookWarnings(statuses []HookStatus) string {
var warnings []string
missingCount := 0
outdatedCount := 0
for _, status := range statuses {
if !status.Installed {
missingCount++
@@ -110,21 +111,21 @@ func FormatHookWarnings(statuses []HookStatus) string {
outdatedCount++
}
}
if missingCount > 0 {
warnings = append(warnings, fmt.Sprintf("⚠️ Git hooks not installed (%d missing)", missingCount))
warnings = append(warnings, " Run: bd hooks install")
}
if outdatedCount > 0 {
warnings = append(warnings, fmt.Sprintf("⚠️ Git hooks are outdated (%d hooks)", outdatedCount))
warnings = append(warnings, " Run: bd hooks install")
}
if len(warnings) > 0 {
return strings.Join(warnings, "\n")
}
return ""
}
@@ -157,7 +158,7 @@ Installed hooks:
- post-checkout: Import JSONL after branch checkout`,
Run: func(cmd *cobra.Command, args []string) {
force, _ := cmd.Flags().GetBool("force")
embeddedHooks, err := getEmbeddedHooks()
if err != nil {
if jsonOutput {
@@ -171,7 +172,7 @@ Installed hooks:
}
os.Exit(1)
}
if err := installHooks(embeddedHooks, force); err != nil {
if jsonOutput {
output := map[string]interface{}{
@@ -184,7 +185,7 @@ Installed hooks:
}
os.Exit(1)
}
if jsonOutput {
output := map[string]interface{}{
"success": true,
@@ -220,7 +221,7 @@ var hooksUninstallCmd = &cobra.Command{
}
os.Exit(1)
}
if jsonOutput {
output := map[string]interface{}{
"success": true,
@@ -239,20 +240,8 @@ var hooksListCmd = &cobra.Command{
Short: "List installed git hooks status",
Long: `Show the status of bd git hooks (installed, outdated, missing).`,
Run: func(cmd *cobra.Command, args []string) {
statuses, err := CheckGitHooks()
if err != nil {
if jsonOutput {
output := map[string]interface{}{
"error": err.Error(),
}
jsonBytes, _ := json.MarshalIndent(output, "", " ")
fmt.Println(string(jsonBytes))
} else {
fmt.Fprintf(os.Stderr, "Error checking hooks: %v\n", err)
}
os.Exit(1)
}
statuses := CheckGitHooks()
if jsonOutput {
output := map[string]interface{}{
"hooks": statuses,
@@ -265,7 +254,7 @@ var hooksListCmd = &cobra.Command{
if !status.Installed {
fmt.Printf(" ✗ %s: not installed\n", status.Name)
} else if status.Outdated {
fmt.Printf(" ⚠ %s: installed (version %s, current: %s) - outdated\n",
fmt.Printf(" ⚠ %s: installed (version %s, current: %s) - outdated\n",
status.Name, status.Version, Version)
} else {
fmt.Printf(" ✓ %s: installed (version %s)\n", status.Name, status.Version)
@@ -281,18 +270,18 @@ func installHooks(embeddedHooks map[string]string, force bool) error {
if _, err := os.Stat(gitDir); os.IsNotExist(err) {
return fmt.Errorf("not a git repository (no .git directory found)")
}
hooksDir := filepath.Join(gitDir, "hooks")
// Create hooks directory if it doesn't exist
if err := os.MkdirAll(hooksDir, 0755); err != nil {
return fmt.Errorf("failed to create hooks directory: %w", err)
}
// Install each hook
for hookName, hookContent := range embeddedHooks {
hookPath := filepath.Join(hooksDir, hookName)
// Check if hook already exists
if _, err := os.Stat(hookPath); err == nil {
// Hook exists - back it up unless force is set
@@ -303,33 +292,34 @@ func installHooks(embeddedHooks map[string]string, force bool) error {
}
}
}
// Write hook file
// #nosec G306 -- git hooks must be executable for Git to run them
if err := os.WriteFile(hookPath, []byte(hookContent), 0755); err != nil {
return fmt.Errorf("failed to write %s: %w", hookName, err)
}
}
return nil
}
func uninstallHooks() error {
hooksDir := filepath.Join(".git", "hooks")
hookNames := []string{"pre-commit", "post-merge", "pre-push", "post-checkout"}
for _, hookName := range hookNames {
hookPath := filepath.Join(hooksDir, hookName)
// Check if hook exists
if _, err := os.Stat(hookPath); os.IsNotExist(err) {
continue
}
// Remove hook
if err := os.Remove(hookPath); err != nil {
return fmt.Errorf("failed to remove %s: %w", hookName, err)
}
// Restore backup if exists
backupPath := hookPath + ".backup"
if _, err := os.Stat(backupPath); err == nil {
@@ -339,16 +329,16 @@ func uninstallHooks() error {
}
}
}
return nil
}
func init() {
hooksInstallCmd.Flags().Bool("force", false, "Overwrite existing hooks without backup")
hooksCmd.AddCommand(hooksInstallCmd)
hooksCmd.AddCommand(hooksUninstallCmd)
hooksCmd.AddCommand(hooksListCmd)
rootCmd.AddCommand(hooksCmd)
}
+8 -9
View File
@@ -3,6 +3,7 @@ package main
import (
"os"
"path/filepath"
"runtime"
"testing"
)
@@ -59,7 +60,11 @@ func TestInstallHooks(t *testing.T) {
if _, err := os.Stat(hookPath); os.IsNotExist(err) {
t.Errorf("Hook %s was not installed", hookName)
}
// Check it's executable
// Windows does not support POSIX executable bits, so skip the check there.
if runtime.GOOS == "windows" {
continue
}
info, err := os.Stat(hookPath)
if err != nil {
t.Errorf("Failed to stat %s: %v", hookName, err)
@@ -206,10 +211,7 @@ func TestHooksCheckGitHooks(t *testing.T) {
os.Chdir(tmpDir)
// Initially no hooks installed
statuses, err := CheckGitHooks()
if err != nil {
t.Fatalf("CheckGitHooks() failed: %v", err)
}
statuses := CheckGitHooks()
for _, status := range statuses {
if status.Installed {
@@ -227,10 +229,7 @@ func TestHooksCheckGitHooks(t *testing.T) {
}
// Check again
statuses, err = CheckGitHooks()
if err != nil {
t.Fatalf("CheckGitHooks() failed: %v", err)
}
statuses = CheckGitHooks()
for _, status := range statuses {
if !status.Installed {
+96 -93
View File
@@ -42,14 +42,14 @@ NOTE: Import requires direct database access and does not work with daemon mode.
fmt.Fprintf(os.Stderr, "Error: failed to create database directory: %v\n", err)
os.Exit(1)
}
// Import requires direct database access due to complex transaction handling
// and collision detection. Force direct mode regardless of daemon state.
if daemonClient != nil {
debug.Logf("Debug: import command forcing direct mode (closes daemon connection)\n")
_ = daemonClient.Close()
daemonClient = nil
var err error
store, err = sqlite.New(dbPath)
if err != nil {
@@ -58,7 +58,7 @@ NOTE: Import requires direct database access and does not work with daemon mode.
}
defer func() { _ = store.Close() }()
}
// We'll check if database needs initialization after reading the JSONL
// so we can detect the prefix from the imported issues
@@ -96,78 +96,78 @@ NOTE: Import requires direct database access and does not work with daemon mode.
lineNum := 0
for scanner.Scan() {
lineNum++
rawLine := scanner.Bytes()
line := string(rawLine)
lineNum++
rawLine := scanner.Bytes()
line := string(rawLine)
// Skip empty lines
if line == "" {
continue
}
// Detect git conflict markers in raw bytes (before JSON decoding)
// This prevents false positives when issue content contains these strings
trimmed := bytes.TrimSpace(rawLine)
if bytes.HasPrefix(trimmed, []byte("<<<<<<< ")) ||
bytes.Equal(trimmed, []byte("=======")) ||
bytes.HasPrefix(trimmed, []byte(">>>>>>> ")) {
fmt.Fprintf(os.Stderr, "Git conflict markers detected in JSONL file (line %d)\n", lineNum)
fmt.Fprintf(os.Stderr, "→ Attempting automatic 3-way merge...\n\n")
// Attempt automatic merge using bd merge command
if err := attemptAutoMerge(input); err != nil {
fmt.Fprintf(os.Stderr, "Error: Automatic merge failed: %v\n\n", err)
fmt.Fprintf(os.Stderr, "To resolve manually:\n")
fmt.Fprintf(os.Stderr, " git checkout --ours .beads/issues.jsonl && bd import -i .beads/issues.jsonl\n")
fmt.Fprintf(os.Stderr, " git checkout --theirs .beads/issues.jsonl && bd import -i .beads/issues.jsonl\n\n")
fmt.Fprintf(os.Stderr, "For advanced field-level merging, see: https://github.com/neongreen/mono/tree/main/beads-merge\n")
os.Exit(1)
// Skip empty lines
if line == "" {
continue
}
fmt.Fprintf(os.Stderr, "✓ Automatic merge successful\n")
fmt.Fprintf(os.Stderr, "→ Restarting import with merged JSONL...\n\n")
// Detect git conflict markers in raw bytes (before JSON decoding)
// This prevents false positives when issue content contains these strings
trimmed := bytes.TrimSpace(rawLine)
if bytes.HasPrefix(trimmed, []byte("<<<<<<< ")) ||
bytes.Equal(trimmed, []byte("=======")) ||
bytes.HasPrefix(trimmed, []byte(">>>>>>> ")) {
fmt.Fprintf(os.Stderr, "Git conflict markers detected in JSONL file (line %d)\n", lineNum)
fmt.Fprintf(os.Stderr, "→ Attempting automatic 3-way merge...\n\n")
// Re-open the input file to read the merged content
if input != "" {
// Close current file handle
if in != os.Stdin {
_ = in.Close()
}
// Re-open the merged file
// #nosec G304 - user-provided file path is intentional
f, err := os.Open(input)
if err != nil {
fmt.Fprintf(os.Stderr, "Error reopening merged file: %v\n", err)
// Attempt automatic merge using bd merge command
if err := attemptAutoMerge(input); err != nil {
fmt.Fprintf(os.Stderr, "Error: Automatic merge failed: %v\n\n", err)
fmt.Fprintf(os.Stderr, "To resolve manually:\n")
fmt.Fprintf(os.Stderr, " git checkout --ours .beads/issues.jsonl && bd import -i .beads/issues.jsonl\n")
fmt.Fprintf(os.Stderr, " git checkout --theirs .beads/issues.jsonl && bd import -i .beads/issues.jsonl\n\n")
fmt.Fprintf(os.Stderr, "For advanced field-level merging, see: https://github.com/neongreen/mono/tree/main/beads-merge\n")
os.Exit(1)
}
defer func() {
if err := f.Close(); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to close input file: %v\n", err)
fmt.Fprintf(os.Stderr, "✓ Automatic merge successful\n")
fmt.Fprintf(os.Stderr, "→ Restarting import with merged JSONL...\n\n")
// Re-open the input file to read the merged content
if input != "" {
// Close current file handle
if in != os.Stdin {
_ = in.Close()
}
}()
in = f
scanner = bufio.NewScanner(in)
allIssues = nil // Reset issues list
lineNum = 0 // Reset line counter
continue // Restart parsing from beginning
} else {
// Can't retry stdin - should not happen since git conflicts only in files
fmt.Fprintf(os.Stderr, "Error: Cannot retry merge from stdin\n")
// Re-open the merged file
// #nosec G304 - user-provided file path is intentional
f, err := os.Open(input)
if err != nil {
fmt.Fprintf(os.Stderr, "Error reopening merged file: %v\n", err)
os.Exit(1)
}
defer func() {
if err := f.Close(); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to close input file: %v\n", err)
}
}()
in = f
scanner = bufio.NewScanner(in)
allIssues = nil // Reset issues list
lineNum = 0 // Reset line counter
continue // Restart parsing from beginning
} else {
// Can't retry stdin - should not happen since git conflicts only in files
fmt.Fprintf(os.Stderr, "Error: Cannot retry merge from stdin\n")
os.Exit(1)
}
}
// Parse JSON
var issue types.Issue
if err := json.Unmarshal([]byte(line), &issue); err != nil {
fmt.Fprintf(os.Stderr, "Error parsing line %d: %v\n", lineNum, err)
os.Exit(1)
}
}
// Parse JSON
var issue types.Issue
if err := json.Unmarshal([]byte(line), &issue); err != nil {
fmt.Fprintf(os.Stderr, "Error parsing line %d: %v\n", lineNum, err)
os.Exit(1)
allIssues = append(allIssues, &issue)
}
allIssues = append(allIssues, &issue)
}
if err := scanner.Err(); err != nil {
fmt.Fprintf(os.Stderr, "Error reading input: %v\n", err)
os.Exit(1)
@@ -190,12 +190,12 @@ NOTE: Import requires direct database access and does not work with daemon mode.
detectedPrefix = filepath.Base(cwd)
}
detectedPrefix = strings.TrimRight(detectedPrefix, "-")
if err := store.SetConfig(initCtx, "issue_prefix", detectedPrefix); err != nil {
fmt.Fprintf(os.Stderr, "Error: failed to set issue prefix: %v\n", err)
os.Exit(1)
}
fmt.Fprintf(os.Stderr, "✓ Initialized database with prefix '%s' (detected from issues)\n", detectedPrefix)
}
@@ -233,7 +233,7 @@ NOTE: Import requires direct database access and does not work with daemon mode.
fmt.Fprintf(os.Stderr, "\nOr use 'bd rename-prefix' after import to fix the database.\n")
os.Exit(1)
}
// Check if it's a collision error
if result != nil && len(result.CollisionIDs) > 0 {
// Print collision report before exiting
@@ -259,7 +259,7 @@ NOTE: Import requires direct database access and does not work with daemon mode.
}
fmt.Fprintf(os.Stderr, "\nUse --rename-on-import to automatically fix prefixes during import.\n")
}
if result.Collisions > 0 {
fmt.Fprintf(os.Stderr, "\n=== Collision Detection Report ===\n")
fmt.Fprintf(os.Stderr, "COLLISIONS DETECTED: %d\n", result.Collisions)
@@ -314,7 +314,7 @@ NOTE: Import requires direct database access and does not work with daemon mode.
// 2. Without mtime update, bd sync refuses to export (thinks JSONL is newer)
// 3. This can happen after git pull updates JSONL mtime but content is identical
// Fix for: refusing to export: JSONL is newer than database (import first to avoid data loss)
if err := touchDatabaseFile(dbPath, input); err != nil {
if err := TouchDatabaseFile(dbPath, input); err != nil {
debug.Logf("Warning: failed to update database mtime: %v", err)
}
@@ -381,19 +381,21 @@ NOTE: Import requires direct database access and does not work with daemon mode.
},
}
// touchDatabaseFile updates the modification time of the database file.
// This is used after import to ensure the database appears "in sync" with JSONL,
// preventing bd doctor from incorrectly warning that JSONL is newer.
// TouchDatabaseFile updates the modification time of the database file.
// This is used after import AND export to ensure the database appears "in sync" with JSONL,
// preventing bd doctor and validatePreExport from incorrectly warning that JSONL is newer.
//
// In SQLite WAL mode, writes go to beads.db-wal and beads.db mtime may not update
// until a checkpoint. Since bd doctor compares JSONL mtime to beads.db mtime only,
// we need to explicitly touch the DB file after import.
// until a checkpoint. Since validation compares JSONL mtime to beads.db mtime only,
// we need to explicitly touch the DB file after both import and export operations.
//
// The function sets DB mtime to max(JSONL mtime, now) + 1ns to handle clock skew.
// If jsonlPath is empty or can't be read, falls back to time.Now().
func touchDatabaseFile(dbPath, jsonlPath string) error {
//
// Fixes issues #278, #301, #321: daemon export leaving JSONL newer than DB.
func TouchDatabaseFile(dbPath, jsonlPath string) error {
targetTime := time.Now()
// If we have the JSONL path, use max(JSONL mtime, now) to handle clock skew
if jsonlPath != "" {
if info, err := os.Stat(jsonlPath); err == nil {
@@ -403,7 +405,7 @@ func touchDatabaseFile(dbPath, jsonlPath string) error {
}
}
}
// Best-effort touch - don't fail import if this doesn't work
return os.Chtimes(dbPath, targetTime, targetTime)
}
@@ -418,17 +420,17 @@ func checkUncommittedChanges(filePath string, result *ImportResult) {
// Get the directory containing the file to use as git working directory
workDir := filepath.Dir(filePath)
// Use git diff to check if working tree differs from HEAD
cmd := fmt.Sprintf("git diff --quiet HEAD %s", filePath)
exitCode, _ := runGitCommand(cmd, workDir)
// Exit code 0 = no changes, 1 = changes exist, >1 = error
if exitCode == 1 {
// Get line counts for context
workingTreeLines := countLines(filePath)
headLines := countLinesInGitHEAD(filePath, workDir)
fmt.Fprintf(os.Stderr, "\n⚠️ Warning: .beads/issues.jsonl has uncommitted changes\n")
fmt.Fprintf(os.Stderr, " Working tree: %d lines\n", workingTreeLines)
if headLines > 0 {
@@ -466,7 +468,7 @@ func countLines(filePath string) int {
return 0
}
defer func() { _ = f.Close() }()
scanner := bufio.NewScanner(f)
lines := 0
for scanner.Scan() {
@@ -484,24 +486,24 @@ func countLinesInGitHEAD(filePath string, workDir string) int {
return 0
}
gitRoot := strings.TrimSpace(gitRootOutput)
// Make filePath relative to git root
absPath, err := filepath.Abs(filePath)
if err != nil {
return 0
}
relPath, err := filepath.Rel(gitRoot, absPath)
if err != nil {
return 0
}
cmd := fmt.Sprintf("git show HEAD:%s 2>/dev/null | wc -l", relPath)
exitCode, output := runGitCommand(cmd, workDir)
if exitCode != 0 {
return 0
}
var lines int
_, err = fmt.Sscanf(strings.TrimSpace(output), "%d", &lines)
if err != nil {
@@ -518,7 +520,7 @@ func attemptAutoMerge(conflictedPath string) error {
}
// Get git repository root
gitRootCmd := exec.Command("git", "rev-parse", "--show-toplevel")
gitRootCmd := exec.Command("git", "rev-parse", "--show-toplevel") // #nosec G204 -- fixed git invocation for repo root discovery
gitRootOutput, err := gitRootCmd.Output()
if err != nil {
return fmt.Errorf("not in a git repository: %w", err)
@@ -553,7 +555,7 @@ func attemptAutoMerge(conflictedPath string) error {
outputPath := filepath.Join(tmpDir, "merged.jsonl")
// Extract base version (merge-base)
baseCmd := exec.Command("git", "show", fmt.Sprintf(":1:%s", relPath))
baseCmd := exec.Command("git", "show", fmt.Sprintf(":1:%s", relPath)) // #nosec G204 -- relPath limited to files tracked in current repo
baseCmd.Dir = gitRoot
baseContent, err := baseCmd.Output()
if err != nil {
@@ -566,7 +568,7 @@ func attemptAutoMerge(conflictedPath string) error {
}
// Extract left version (ours/HEAD)
leftCmd := exec.Command("git", "show", fmt.Sprintf(":2:%s", relPath))
leftCmd := exec.Command("git", "show", fmt.Sprintf(":2:%s", relPath)) // #nosec G204 -- relPath limited to files tracked in current repo
leftCmd.Dir = gitRoot
leftContent, err := leftCmd.Output()
if err != nil {
@@ -577,7 +579,7 @@ func attemptAutoMerge(conflictedPath string) error {
}
// Extract right version (theirs/MERGE_HEAD)
rightCmd := exec.Command("git", "show", fmt.Sprintf(":3:%s", relPath))
rightCmd := exec.Command("git", "show", fmt.Sprintf(":3:%s", relPath)) // #nosec G204 -- relPath limited to files tracked in current repo
rightCmd.Dir = gitRoot
rightContent, err := rightCmd.Output()
if err != nil {
@@ -594,7 +596,7 @@ func attemptAutoMerge(conflictedPath string) error {
}
// Invoke bd merge command
mergeCmd := exec.Command(exe, "merge", outputPath, basePath, leftPath, rightPath)
mergeCmd := exec.Command(exe, "merge", outputPath, basePath, leftPath, rightPath) // #nosec G204 -- executes current bd binary for deterministic merge
mergeOutput, err := mergeCmd.CombinedOutput()
if err != nil {
// Check exit code - bd merge returns 1 if there are conflicts, 2 for errors
@@ -608,6 +610,7 @@ func attemptAutoMerge(conflictedPath string) error {
}
// Merge succeeded - copy merged result back to original file
// #nosec G304 -- merged output created earlier in this function
mergedContent, err := os.ReadFile(outputPath)
if err != nil {
return fmt.Errorf("failed to read merged output: %w", err)
@@ -618,7 +621,7 @@ func attemptAutoMerge(conflictedPath string) error {
}
// Stage the resolved file
stageCmd := exec.Command("git", "add", relPath)
stageCmd := exec.Command("git", "add", relPath) // #nosec G204 -- relPath constrained to file within current repo
stageCmd.Dir = gitRoot
if err := stageCmd.Run(); err != nil {
// Non-fatal - user can stage manually
@@ -634,7 +637,7 @@ func detectPrefixFromIssues(issues []*types.Issue) string {
if len(issues) == 0 {
return ""
}
// Count prefix occurrences
prefixCounts := make(map[string]int)
for _, issue := range issues {
@@ -644,7 +647,7 @@ func detectPrefixFromIssues(issues []*types.Issue) string {
prefixCounts[issue.ID[:idx]]++
}
}
// Find most common prefix
maxCount := 0
commonPrefix := ""
@@ -654,7 +657,7 @@ func detectPrefixFromIssues(issues []*types.Issue) string {
commonPrefix = prefix
}
}
return commonPrefix
}
+5 -5
View File
@@ -7,7 +7,7 @@ import (
"time"
)
// TestTouchDatabaseFile verifies the touchDatabaseFile helper function
// TestTouchDatabaseFile verifies the TouchDatabaseFile helper function
func TestTouchDatabaseFile(t *testing.T) {
tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "test.db")
@@ -27,8 +27,8 @@ func TestTouchDatabaseFile(t *testing.T) {
time.Sleep(1 * time.Second)
// Touch the file
if err := touchDatabaseFile(testFile, ""); err != nil {
t.Fatalf("touchDatabaseFile failed: %v", err)
if err := TouchDatabaseFile(testFile, ""); err != nil {
t.Fatalf("TouchDatabaseFile failed: %v", err)
}
// Get new mtime
@@ -64,8 +64,8 @@ func TestTouchDatabaseFileWithClockSkew(t *testing.T) {
}
// Touch the DB file with JSONL path
if err := touchDatabaseFile(dbFile, jsonlFile); err != nil {
t.Fatalf("touchDatabaseFile failed: %v", err)
if err := TouchDatabaseFile(dbFile, jsonlFile); err != nil {
t.Fatalf("TouchDatabaseFile failed: %v", err)
}
// Get DB mtime
+15 -17
View File
@@ -100,12 +100,12 @@ Examples:
// Save current daemon state
wasDaemon := daemonClient != nil
var tempErr error
if wasDaemon {
// Temporarily switch to direct mode to read config
tempErr = ensureDirectMode("info: reading config")
}
if store != nil {
ctx := context.Background()
configMap, err := store.GetAllConfig(ctx)
@@ -113,7 +113,7 @@ Examples:
info["config"] = configMap
}
}
// Note: We don't restore daemon mode since info is a read-only command
// and the process will exit immediately after this
_ = tempErr // silence unused warning
@@ -121,23 +121,23 @@ Examples:
// Add schema information if requested
if schemaFlag && store != nil {
ctx := context.Background()
// Get schema version
schemaVersion, err := store.GetMetadata(ctx, "bd_version")
if err != nil {
schemaVersion = "unknown"
}
// Get tables
tables := []string{"issues", "dependencies", "labels", "config", "metadata"}
// Get config
configMap := make(map[string]string)
prefix, _ := store.GetConfig(ctx, "issue_prefix")
if prefix != "" {
configMap["issue_prefix"] = prefix
}
// Get sample issue IDs
filter := types.IssueFilter{}
issues, err := store.SearchIssues(ctx, "", filter)
@@ -157,13 +157,13 @@ Examples:
detectedPrefix = extractPrefix(issues[0].ID)
}
}
info["schema"] = map[string]interface{}{
"tables": tables,
"schema_version": schemaVersion,
"config": configMap,
"tables": tables,
"schema_version": schemaVersion,
"config": configMap,
"sample_issue_ids": sampleIDs,
"detected_prefix": detectedPrefix,
"detected_prefix": detectedPrefix,
}
}
@@ -229,11 +229,9 @@ Examples:
}
// Check git hooks status
hookStatuses, err := CheckGitHooks()
if err == nil {
if warning := FormatHookWarnings(hookStatuses); warning != "" {
fmt.Printf("\n%s\n", warning)
}
hookStatuses := CheckGitHooks()
if warning := FormatHookWarnings(hookStatuses); warning != "" {
fmt.Printf("\n%s\n", warning)
}
fmt.Println()
+303 -305
View File
@@ -68,7 +68,7 @@ With --no-db: creates .beads/ directory and issues.jsonl file instead of SQLite
}
}
}
// auto-detect prefix from directory name
if prefix == "" {
// Auto-detect from directory name
@@ -88,108 +88,108 @@ With --no-db: creates .beads/ directory and issues.jsonl file instead of SQLite
// Use global dbPath if set via --db flag or BEADS_DB env var, otherwise default to .beads/beads.db
initDBPath := dbPath
if initDBPath == "" {
initDBPath = filepath.Join(".beads", beads.CanonicalDatabaseName)
initDBPath = filepath.Join(".beads", beads.CanonicalDatabaseName)
}
// Migrate old database files if they exist
if err := migrateOldDatabases(initDBPath, quiet); err != nil {
fmt.Fprintf(os.Stderr, "Error during database migration: %v\n", err)
os.Exit(1)
}
// Determine if we should create .beads/ directory in CWD
// Only create it if the database will be stored there
cwd, err := os.Getwd()
if err != nil {
fmt.Fprintf(os.Stderr, "Error: failed to get current directory: %v\n", err)
os.Exit(1)
}
// Prevent nested .beads directories
// Check if current working directory is inside a .beads directory
if strings.Contains(filepath.Clean(cwd), string(filepath.Separator)+".beads"+string(filepath.Separator)) ||
strings.HasSuffix(filepath.Clean(cwd), string(filepath.Separator)+".beads") {
fmt.Fprintf(os.Stderr, "Error: cannot initialize bd inside a .beads directory\n")
fmt.Fprintf(os.Stderr, "Current directory: %s\n", cwd)
fmt.Fprintf(os.Stderr, "Please run 'bd init' from outside the .beads directory.\n")
os.Exit(1)
}
localBeadsDir := filepath.Join(cwd, ".beads")
initDBDir := filepath.Dir(initDBPath)
// Convert both to absolute paths for comparison
localBeadsDirAbs, err := filepath.Abs(localBeadsDir)
if err != nil {
localBeadsDirAbs = filepath.Clean(localBeadsDir)
}
initDBDirAbs, err := filepath.Abs(initDBDir)
if err != nil {
initDBDirAbs = filepath.Clean(initDBDir)
}
useLocalBeads := filepath.Clean(initDBDirAbs) == filepath.Clean(localBeadsDirAbs)
if useLocalBeads {
// Create .beads directory
if err := os.MkdirAll(localBeadsDir, 0750); err != nil {
fmt.Fprintf(os.Stderr, "Error: failed to create .beads directory: %v\n", err)
if err := migrateOldDatabases(initDBPath, quiet); err != nil {
fmt.Fprintf(os.Stderr, "Error during database migration: %v\n", err)
os.Exit(1)
}
// Handle --no-db mode: create issues.jsonl file instead of database
if noDb {
// Create empty issues.jsonl file
jsonlPath := filepath.Join(localBeadsDir, "issues.jsonl")
if _, err := os.Stat(jsonlPath); os.IsNotExist(err) {
// nolint:gosec // G306: JSONL file needs to be readable by other tools
if err := os.WriteFile(jsonlPath, []byte{}, 0644); err != nil {
fmt.Fprintf(os.Stderr, "Error: failed to create issues.jsonl: %v\n", err)
os.Exit(1)
// Determine if we should create .beads/ directory in CWD
// Only create it if the database will be stored there
cwd, err := os.Getwd()
if err != nil {
fmt.Fprintf(os.Stderr, "Error: failed to get current directory: %v\n", err)
os.Exit(1)
}
// Prevent nested .beads directories
// Check if current working directory is inside a .beads directory
if strings.Contains(filepath.Clean(cwd), string(filepath.Separator)+".beads"+string(filepath.Separator)) ||
strings.HasSuffix(filepath.Clean(cwd), string(filepath.Separator)+".beads") {
fmt.Fprintf(os.Stderr, "Error: cannot initialize bd inside a .beads directory\n")
fmt.Fprintf(os.Stderr, "Current directory: %s\n", cwd)
fmt.Fprintf(os.Stderr, "Please run 'bd init' from outside the .beads directory.\n")
os.Exit(1)
}
localBeadsDir := filepath.Join(cwd, ".beads")
initDBDir := filepath.Dir(initDBPath)
// Convert both to absolute paths for comparison
localBeadsDirAbs, err := filepath.Abs(localBeadsDir)
if err != nil {
localBeadsDirAbs = filepath.Clean(localBeadsDir)
}
initDBDirAbs, err := filepath.Abs(initDBDir)
if err != nil {
initDBDirAbs = filepath.Clean(initDBDir)
}
useLocalBeads := filepath.Clean(initDBDirAbs) == filepath.Clean(localBeadsDirAbs)
if useLocalBeads {
// Create .beads directory
if err := os.MkdirAll(localBeadsDir, 0750); err != nil {
fmt.Fprintf(os.Stderr, "Error: failed to create .beads directory: %v\n", err)
os.Exit(1)
}
// Handle --no-db mode: create issues.jsonl file instead of database
if noDb {
// Create empty issues.jsonl file
jsonlPath := filepath.Join(localBeadsDir, "issues.jsonl")
if _, err := os.Stat(jsonlPath); os.IsNotExist(err) {
// nolint:gosec // G306: JSONL file needs to be readable by other tools
if err := os.WriteFile(jsonlPath, []byte{}, 0644); err != nil {
fmt.Fprintf(os.Stderr, "Error: failed to create issues.jsonl: %v\n", err)
os.Exit(1)
}
}
// Create metadata.json for --no-db mode
cfg := configfile.DefaultConfig()
if err := cfg.Save(localBeadsDir); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to create metadata.json: %v\n", err)
// Non-fatal - continue anyway
}
// Create config.yaml with no-db: true
if err := createConfigYaml(localBeadsDir, true); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to create config.yaml: %v\n", err)
// Non-fatal - continue anyway
}
if !quiet {
green := color.New(color.FgGreen).SprintFunc()
cyan := color.New(color.FgCyan).SprintFunc()
fmt.Printf("\n%s bd initialized successfully in --no-db mode!\n\n", green("✓"))
fmt.Printf(" Mode: %s\n", cyan("no-db (JSONL-only)"))
fmt.Printf(" Issues file: %s\n", cyan(jsonlPath))
fmt.Printf(" Issue prefix: %s\n", cyan(prefix))
fmt.Printf(" Issues will be named: %s\n\n", cyan(prefix+"-1, "+prefix+"-2, ..."))
fmt.Printf("Run %s to get started.\n\n", cyan("bd --no-db quickstart"))
}
return
}
// Create metadata.json for --no-db mode
cfg := configfile.DefaultConfig()
if err := cfg.Save(localBeadsDir); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to create metadata.json: %v\n", err)
// Create/update .gitignore in .beads directory (idempotent - always update to latest)
gitignorePath := filepath.Join(localBeadsDir, ".gitignore")
if err := os.WriteFile(gitignorePath, []byte(doctor.GitignoreTemplate), 0600); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to create/update .gitignore: %v\n", err)
// Non-fatal - continue anyway
}
// Create config.yaml with no-db: true
if err := createConfigYaml(localBeadsDir, true); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to create config.yaml: %v\n", err)
// Non-fatal - continue anyway
}
if !quiet {
green := color.New(color.FgGreen).SprintFunc()
cyan := color.New(color.FgCyan).SprintFunc()
fmt.Printf("\n%s bd initialized successfully in --no-db mode!\n\n", green("✓"))
fmt.Printf(" Mode: %s\n", cyan("no-db (JSONL-only)"))
fmt.Printf(" Issues file: %s\n", cyan(jsonlPath))
fmt.Printf(" Issue prefix: %s\n", cyan(prefix))
fmt.Printf(" Issues will be named: %s\n\n", cyan(prefix+"-1, "+prefix+"-2, ..."))
fmt.Printf("Run %s to get started.\n\n", cyan("bd --no-db quickstart"))
}
return
}
// Create/update .gitignore in .beads directory (idempotent - always update to latest)
gitignorePath := filepath.Join(localBeadsDir, ".gitignore")
if err := os.WriteFile(gitignorePath, []byte(doctor.GitignoreTemplate), 0600); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to create/update .gitignore: %v\n", err)
// Non-fatal - continue anyway
}
}
// Ensure parent directory exists for the database
if err := os.MkdirAll(initDBDir, 0750); err != nil {
fmt.Fprintf(os.Stderr, "Error: failed to create database directory %s: %v\n", initDBDir, err)
os.Exit(1)
fmt.Fprintf(os.Stderr, "Error: failed to create database directory %s: %v\n", initDBDir, err)
os.Exit(1)
}
store, err := sqlite.New(initDBPath)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: failed to create database: %v\n", err)
@@ -199,192 +199,192 @@ With --no-db: creates .beads/ directory and issues.jsonl file instead of SQLite
// Set the issue prefix in config
ctx := context.Background()
if err := store.SetConfig(ctx, "issue_prefix", prefix); err != nil {
fmt.Fprintf(os.Stderr, "Error: failed to set issue prefix: %v\n", err)
_ = store.Close()
os.Exit(1)
}
// Set sync.branch if specified
if branch != "" {
if err := syncbranch.Set(ctx, store, branch); err != nil {
fmt.Fprintf(os.Stderr, "Error: failed to set sync branch: %v\n", err)
fmt.Fprintf(os.Stderr, "Error: failed to set issue prefix: %v\n", err)
_ = store.Close()
os.Exit(1)
}
if !quiet {
fmt.Printf(" Sync branch: %s\n", branch)
// Set sync.branch if specified
if branch != "" {
if err := syncbranch.Set(ctx, store, branch); err != nil {
fmt.Fprintf(os.Stderr, "Error: failed to set sync branch: %v\n", err)
_ = store.Close()
os.Exit(1)
}
if !quiet {
fmt.Printf(" Sync branch: %s\n", branch)
}
}
}
// Store the bd version in metadata (for version mismatch detection)
if err := store.SetMetadata(ctx, "bd_version", Version); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to store version metadata: %v\n", err)
// Non-fatal - continue anyway
}
// Compute and store repository fingerprint
repoID, err := beads.ComputeRepoID()
if err != nil {
if !quiet {
fmt.Fprintf(os.Stderr, "Warning: could not compute repository ID: %v\n", err)
}
} else {
if err := store.SetMetadata(ctx, "repo_id", repoID); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to set repo_id: %v\n", err)
} else if !quiet {
fmt.Printf(" Repository ID: %s\n", repoID[:8])
}
}
// Store clone-specific ID
cloneID, err := beads.GetCloneID()
if err != nil {
if !quiet {
fmt.Fprintf(os.Stderr, "Warning: could not compute clone ID: %v\n", err)
}
} else {
if err := store.SetMetadata(ctx, "clone_id", cloneID); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to set clone_id: %v\n", err)
} else if !quiet {
fmt.Printf(" Clone ID: %s\n", cloneID)
}
}
// Create metadata.json for database metadata
if useLocalBeads {
cfg := configfile.DefaultConfig()
if err := cfg.Save(localBeadsDir); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to create metadata.json: %v\n", err)
fmt.Fprintf(os.Stderr, "Warning: failed to store version metadata: %v\n", err)
// Non-fatal - continue anyway
}
// Create config.yaml template
if err := createConfigYaml(localBeadsDir, false); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to create config.yaml: %v\n", err)
// Non-fatal - continue anyway
}
}
// Check if git has existing issues to import (fresh clone scenario)
issueCount, jsonlPath := checkGitForIssues()
if issueCount > 0 {
if !quiet {
fmt.Fprintf(os.Stderr, "\n✓ Database initialized. Found %d issues in git, importing...\n", issueCount)
}
if err := importFromGit(ctx, initDBPath, store, jsonlPath); err != nil {
// Compute and store repository fingerprint
repoID, err := beads.ComputeRepoID()
if err != nil {
if !quiet {
fmt.Fprintf(os.Stderr, "Warning: auto-import failed: %v\n", err)
fmt.Fprintf(os.Stderr, "Try manually: git show HEAD:%s | bd import -i /dev/stdin\n", jsonlPath)
fmt.Fprintf(os.Stderr, "Warning: could not compute repository ID: %v\n", err)
}
} else {
if err := store.SetMetadata(ctx, "repo_id", repoID); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to set repo_id: %v\n", err)
} else if !quiet {
fmt.Printf(" Repository ID: %s\n", repoID[:8])
}
// Non-fatal - continue with empty database
} else if !quiet {
fmt.Fprintf(os.Stderr, "✓ Successfully imported %d issues from git.\n\n", issueCount)
}
}
// Run contributor wizard if --contributor flag is set
if contributor {
if err := runContributorWizard(ctx, store); err != nil {
fmt.Fprintf(os.Stderr, "Error running contributor wizard: %v\n", err)
_ = store.Close()
os.Exit(1)
// Store clone-specific ID
cloneID, err := beads.GetCloneID()
if err != nil {
if !quiet {
fmt.Fprintf(os.Stderr, "Warning: could not compute clone ID: %v\n", err)
}
} else {
if err := store.SetMetadata(ctx, "clone_id", cloneID); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to set clone_id: %v\n", err)
} else if !quiet {
fmt.Printf(" Clone ID: %s\n", cloneID)
}
}
}
// Run team wizard if --team flag is set
if team {
if err := runTeamWizard(ctx, store); err != nil {
fmt.Fprintf(os.Stderr, "Error running team wizard: %v\n", err)
_ = store.Close()
os.Exit(1)
// Create metadata.json for database metadata
if useLocalBeads {
cfg := configfile.DefaultConfig()
if err := cfg.Save(localBeadsDir); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to create metadata.json: %v\n", err)
// Non-fatal - continue anyway
}
// Create config.yaml template
if err := createConfigYaml(localBeadsDir, false); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to create config.yaml: %v\n", err)
// Non-fatal - continue anyway
}
}
}
if err := store.Close(); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to close database: %v\n", err)
}
// Check if git has existing issues to import (fresh clone scenario)
issueCount, jsonlPath := checkGitForIssues()
if issueCount > 0 {
if !quiet {
fmt.Fprintf(os.Stderr, "\n✓ Database initialized. Found %d issues in git, importing...\n", issueCount)
}
// Check if we're in a git repo and hooks aren't installed
// Do this BEFORE quiet mode return so hooks get installed for agents
if isGitRepo() && !hooksInstalled() {
if quiet {
// Auto-install hooks silently in quiet mode (best default for agents)
_ = installGitHooks() // Ignore errors in quiet mode
} else {
// Defer to interactive prompt below
}
}
if err := importFromGit(ctx, initDBPath, store, jsonlPath); err != nil {
if !quiet {
fmt.Fprintf(os.Stderr, "Warning: auto-import failed: %v\n", err)
fmt.Fprintf(os.Stderr, "Try manually: git show HEAD:%s | bd import -i /dev/stdin\n", jsonlPath)
}
// Non-fatal - continue with empty database
} else if !quiet {
fmt.Fprintf(os.Stderr, "✓ Successfully imported %d issues from git.\n\n", issueCount)
}
}
// Check if we're in a git repo and merge driver isn't configured
// Do this BEFORE quiet mode return so merge driver gets configured for agents
if !skipMergeDriver && isGitRepo() && !mergeDriverInstalled() {
if quiet {
// Auto-install merge driver silently in quiet mode (best default for agents)
_ = installMergeDriver() // Ignore errors in quiet mode
} else {
// Defer to interactive prompt below
}
}
// Run contributor wizard if --contributor flag is set
if contributor {
if err := runContributorWizard(ctx, store); err != nil {
fmt.Fprintf(os.Stderr, "Error running contributor wizard: %v\n", err)
_ = store.Close()
os.Exit(1)
}
}
// Skip output if quiet mode
if quiet {
return
}
// Run team wizard if --team flag is set
if team {
if err := runTeamWizard(ctx, store); err != nil {
fmt.Fprintf(os.Stderr, "Error running team wizard: %v\n", err)
_ = store.Close()
os.Exit(1)
}
}
if err := store.Close(); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to close database: %v\n", err)
}
// Check if we're in a git repo and hooks aren't installed
// Do this BEFORE quiet mode return so hooks get installed for agents
if isGitRepo() && !hooksInstalled() {
if quiet {
// Auto-install hooks silently in quiet mode (best default for agents)
_ = installGitHooks() // Ignore errors in quiet mode
} else {
// Defer to interactive prompt below
}
}
// Check if we're in a git repo and merge driver isn't configured
// Do this BEFORE quiet mode return so merge driver gets configured for agents
if !skipMergeDriver && isGitRepo() && !mergeDriverInstalled() {
if quiet {
// Auto-install merge driver silently in quiet mode (best default for agents)
_ = installMergeDriver() // Ignore errors in quiet mode
} else {
// Defer to interactive prompt below
}
}
// Skip output if quiet mode
if quiet {
return
}
green := color.New(color.FgGreen).SprintFunc()
cyan := color.New(color.FgCyan).SprintFunc()
yellow := color.New(color.FgYellow).SprintFunc()
yellow := color.New(color.FgYellow).SprintFunc()
fmt.Printf("\n%s bd initialized successfully!\n\n", green("✓"))
fmt.Printf(" Database: %s\n", cyan(initDBPath))
fmt.Printf(" Issue prefix: %s\n", cyan(prefix))
fmt.Printf(" Issues will be named: %s\n\n", cyan(prefix+"-1, "+prefix+"-2, ..."))
// Interactive git hooks prompt for humans
if isGitRepo() && !hooksInstalled() {
fmt.Printf("%s Git hooks not installed\n", yellow("⚠"))
fmt.Printf(" Install git hooks to prevent race conditions between commits and auto-flush.\n")
fmt.Printf(" Run: %s\n\n", cyan("./examples/git-hooks/install.sh"))
// Prompt to install
fmt.Printf("Install git hooks now? [Y/n] ")
var response string
_, _ = fmt.Scanln(&response) // ignore EOF on empty input
response = strings.ToLower(strings.TrimSpace(response))
if response == "" || response == "y" || response == "yes" {
if err := installGitHooks(); err != nil {
fmt.Fprintf(os.Stderr, "Error installing hooks: %v\n", err)
fmt.Printf("You can install manually with: %s\n\n", cyan("./examples/git-hooks/install.sh"))
} else {
fmt.Printf("%s Git hooks installed successfully!\n\n", green("✓"))
// Interactive git hooks prompt for humans
if isGitRepo() && !hooksInstalled() {
fmt.Printf("%s Git hooks not installed\n", yellow("⚠"))
fmt.Printf(" Install git hooks to prevent race conditions between commits and auto-flush.\n")
fmt.Printf(" Run: %s\n\n", cyan("./examples/git-hooks/install.sh"))
// Prompt to install
fmt.Printf("Install git hooks now? [Y/n] ")
var response string
_, _ = fmt.Scanln(&response) // ignore EOF on empty input
response = strings.ToLower(strings.TrimSpace(response))
if response == "" || response == "y" || response == "yes" {
if err := installGitHooks(); err != nil {
fmt.Fprintf(os.Stderr, "Error installing hooks: %v\n", err)
fmt.Printf("You can install manually with: %s\n\n", cyan("./examples/git-hooks/install.sh"))
} else {
fmt.Printf("%s Git hooks installed successfully!\n\n", green("✓"))
}
}
}
}
// Interactive git merge driver prompt for humans
if !skipMergeDriver && isGitRepo() && !mergeDriverInstalled() {
fmt.Printf("%s Git merge driver not configured\n", yellow("⚠"))
fmt.Printf(" bd merge provides intelligent JSONL merging to prevent conflicts.\n")
fmt.Printf(" This will configure git to use 'bd merge' for .beads/beads.jsonl\n\n")
// Prompt to install
fmt.Printf("Configure git merge driver now? [Y/n] ")
var response string
_, _ = fmt.Scanln(&response) // ignore EOF on empty input
response = strings.ToLower(strings.TrimSpace(response))
if response == "" || response == "y" || response == "yes" {
if err := installMergeDriver(); err != nil {
fmt.Fprintf(os.Stderr, "Error configuring merge driver: %v\n", err)
} else {
fmt.Printf("%s Git merge driver configured successfully!\n\n", green("✓"))
// Interactive git merge driver prompt for humans
if !skipMergeDriver && isGitRepo() && !mergeDriverInstalled() {
fmt.Printf("%s Git merge driver not configured\n", yellow("⚠"))
fmt.Printf(" bd merge provides intelligent JSONL merging to prevent conflicts.\n")
fmt.Printf(" This will configure git to use 'bd merge' for .beads/beads.jsonl\n\n")
// Prompt to install
fmt.Printf("Configure git merge driver now? [Y/n] ")
var response string
_, _ = fmt.Scanln(&response) // ignore EOF on empty input
response = strings.ToLower(strings.TrimSpace(response))
if response == "" || response == "y" || response == "yes" {
if err := installMergeDriver(); err != nil {
fmt.Fprintf(os.Stderr, "Error configuring merge driver: %v\n", err)
} else {
fmt.Printf("%s Git merge driver configured successfully!\n\n", green("✓"))
}
}
}
}
fmt.Printf("Run %s to get started.\n\n", cyan("bd quickstart"))
fmt.Printf("Run %s to get started.\n\n", cyan("bd quickstart"))
},
}
@@ -402,50 +402,50 @@ func init() {
func hooksInstalled() bool {
preCommit := filepath.Join(".git", "hooks", "pre-commit")
postMerge := filepath.Join(".git", "hooks", "post-merge")
// Check if both hooks exist
_, err1 := os.Stat(preCommit)
_, err2 := os.Stat(postMerge)
if err1 != nil || err2 != nil {
return false
}
// Verify they're bd hooks by checking for signature comment
// #nosec G304 - controlled path from git directory
preCommitContent, err := os.ReadFile(preCommit)
if err != nil || !strings.Contains(string(preCommitContent), "bd (beads) pre-commit hook") {
return false
}
// #nosec G304 - controlled path from git directory
postMergeContent, err := os.ReadFile(postMerge)
if err != nil || !strings.Contains(string(postMergeContent), "bd (beads) post-merge hook") {
return false
}
return true
}
// hookInfo contains information about an existing hook
type hookInfo struct {
name string
path string
exists bool
isBdHook bool
isPreCommit bool
content string
name string
path string
exists bool
isBdHook bool
isPreCommit bool
content string
}
// detectExistingHooks scans for existing git hooks
func detectExistingHooks() ([]hookInfo, error) {
func detectExistingHooks() []hookInfo {
hooksDir := filepath.Join(".git", "hooks")
hooks := []hookInfo{
{name: "pre-commit", path: filepath.Join(hooksDir, "pre-commit")},
{name: "post-merge", path: filepath.Join(hooksDir, "post-merge")},
{name: "pre-push", path: filepath.Join(hooksDir, "pre-push")},
}
for i := range hooks {
content, err := os.ReadFile(hooks[i].path)
if err == nil {
@@ -459,14 +459,14 @@ func detectExistingHooks() ([]hookInfo, error) {
}
}
}
return hooks, nil
return hooks
}
// promptHookAction asks user what to do with existing hooks
func promptHookAction(existingHooks []hookInfo) string {
yellow := color.New(color.FgYellow).SprintFunc()
fmt.Printf("\n%s Found existing git hooks:\n", yellow("⚠"))
for _, hook := range existingHooks {
if hook.exists && !hook.isBdHook {
@@ -477,35 +477,32 @@ func promptHookAction(existingHooks []hookInfo) string {
fmt.Printf(" - %s (%s)\n", hook.name, hookType)
}
}
fmt.Printf("\nHow should bd proceed?\n")
fmt.Printf(" [1] Chain with existing hooks (recommended)\n")
fmt.Printf(" [2] Overwrite existing hooks\n")
fmt.Printf(" [3] Skip git hooks installation\n")
fmt.Printf("Choice [1-3]: ")
var response string
_, _ = fmt.Scanln(&response)
response = strings.TrimSpace(response)
return response
}
// installGitHooks installs git hooks inline (no external dependencies)
func installGitHooks() error {
hooksDir := filepath.Join(".git", "hooks")
// Ensure hooks directory exists
if err := os.MkdirAll(hooksDir, 0750); err != nil {
return fmt.Errorf("failed to create hooks directory: %w", err)
}
// Detect existing hooks
existingHooks, err := detectExistingHooks()
if err != nil {
return fmt.Errorf("failed to detect existing hooks: %w", err)
}
existingHooks := detectExistingHooks()
// Check if any non-bd hooks exist
hasExistingHooks := false
for _, hook := range existingHooks {
@@ -514,7 +511,7 @@ func installGitHooks() error {
break
}
}
// Determine installation mode
chainHooks := false
if hasExistingHooks {
@@ -543,11 +540,11 @@ func installGitHooks() error {
return fmt.Errorf("invalid choice: %s", choice)
}
}
// pre-commit hook
preCommitPath := filepath.Join(hooksDir, "pre-commit")
var preCommitContent string
if chainHooks {
// Find existing pre-commit hook
var existingPreCommit string
@@ -562,7 +559,7 @@ func installGitHooks() error {
break
}
}
preCommitContent = `#!/bin/sh
#
# bd (beads) pre-commit hook (chained)
@@ -641,11 +638,11 @@ fi
exit 0
`
}
// post-merge hook
postMergePath := filepath.Join(hooksDir, "post-merge")
var postMergeContent string
if chainHooks {
// Find existing post-merge hook
var existingPostMerge string
@@ -660,7 +657,7 @@ exit 0
break
}
}
postMergeContent = `#!/bin/sh
#
# bd (beads) post-merge hook (chained)
@@ -737,24 +734,24 @@ fi
exit 0
`
}
// Write pre-commit hook (executable scripts need 0700)
// #nosec G306 - git hooks must be executable
if err := os.WriteFile(preCommitPath, []byte(preCommitContent), 0700); err != nil {
return fmt.Errorf("failed to write pre-commit hook: %w", err)
}
// Write post-merge hook (executable scripts need 0700)
// #nosec G306 - git hooks must be executable
if err := os.WriteFile(postMergePath, []byte(postMergeContent), 0700); err != nil {
return fmt.Errorf("failed to write post-merge hook: %w", err)
}
if chainHooks {
green := color.New(color.FgGreen).SprintFunc()
fmt.Printf("%s Chained bd hooks with existing hooks\n", green("✓"))
}
return nil
}
@@ -766,17 +763,17 @@ func mergeDriverInstalled() bool {
if err != nil || len(output) == 0 {
return false
}
// Check if .gitattributes has the merge driver configured
gitattributesPath := ".gitattributes"
content, err := os.ReadFile(gitattributesPath)
if err != nil {
return false
}
// Look for beads JSONL merge attribute
return strings.Contains(string(content), ".beads/beads.jsonl") &&
strings.Contains(string(content), "merge=beads")
return strings.Contains(string(content), ".beads/beads.jsonl") &&
strings.Contains(string(content), "merge=beads")
}
// installMergeDriver configures git to use bd merge for JSONL files
@@ -786,44 +783,44 @@ func installMergeDriver() error {
if output, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("failed to configure git merge driver: %w\n%s", err, output)
}
cmd = exec.Command("git", "config", "merge.beads.name", "bd JSONL merge driver")
if output, err := cmd.CombinedOutput(); err != nil {
// Non-fatal, the name is just descriptive
fmt.Fprintf(os.Stderr, "Warning: failed to set merge driver name: %v\n%s", err, output)
}
// Create or update .gitattributes
gitattributesPath := ".gitattributes"
// Read existing .gitattributes if it exists
var existingContent string
content, err := os.ReadFile(gitattributesPath)
if err == nil {
existingContent = string(content)
}
// Check if beads merge driver is already configured
hasBeadsMerge := strings.Contains(existingContent, ".beads/beads.jsonl") &&
strings.Contains(existingContent, "merge=beads")
strings.Contains(existingContent, "merge=beads")
if !hasBeadsMerge {
// Append beads merge driver configuration
beadsMergeAttr := "\n# Use bd merge for beads JSONL files\n.beads/beads.jsonl merge=beads\n"
newContent := existingContent
if !strings.HasSuffix(newContent, "\n") && len(newContent) > 0 {
newContent += "\n"
}
newContent += beadsMergeAttr
// Write updated .gitattributes (0644 is standard for .gitattributes)
// #nosec G306 - .gitattributes needs to be readable
if err := os.WriteFile(gitattributesPath, []byte(newContent), 0644); err != nil {
return fmt.Errorf("failed to update .gitattributes: %w", err)
}
}
return nil
}
@@ -831,24 +828,24 @@ func installMergeDriver() error {
func migrateOldDatabases(targetPath string, quiet bool) error {
targetDir := filepath.Dir(targetPath)
targetName := filepath.Base(targetPath)
// If target already exists, no migration needed
if _, err := os.Stat(targetPath); err == nil {
return nil
}
// Create .beads directory if it doesn't exist
if err := os.MkdirAll(targetDir, 0750); err != nil {
return fmt.Errorf("failed to create .beads directory: %w", err)
}
// Look for existing .db files in the .beads directory
pattern := filepath.Join(targetDir, "*.db")
matches, err := filepath.Glob(pattern)
if err != nil {
return fmt.Errorf("failed to search for existing databases: %w", err)
}
// Filter out the target file name and any backup files
var oldDBs []string
for _, match := range matches {
@@ -857,50 +854,50 @@ func migrateOldDatabases(targetPath string, quiet bool) error {
oldDBs = append(oldDBs, match)
}
}
if len(oldDBs) == 0 {
// No old databases to migrate
return nil
}
if len(oldDBs) > 1 {
// Multiple databases found - ambiguous, require manual intervention
return fmt.Errorf("multiple database files found in %s: %v\nPlease manually rename the correct database to %s and remove others",
targetDir, oldDBs, targetName)
}
// Migrate the single old database
oldDB := oldDBs[0]
if !quiet {
fmt.Fprintf(os.Stderr, "→ Migrating database: %s → %s\n", filepath.Base(oldDB), targetName)
}
// Rename the old database to the new canonical name
if err := os.Rename(oldDB, targetPath); err != nil {
return fmt.Errorf("failed to migrate database %s to %s: %w", oldDB, targetPath, err)
}
if !quiet {
fmt.Fprintf(os.Stderr, "✓ Database migration complete\n\n")
}
return nil
}
// createConfigYaml creates the config.yaml template in the specified directory
func createConfigYaml(beadsDir string, noDbMode bool) error {
configYamlPath := filepath.Join(beadsDir, "config.yaml")
// Skip if already exists
if _, err := os.Stat(configYamlPath); err == nil {
return nil
}
noDbLine := "# no-db: false"
if noDbMode {
noDbLine = "no-db: true # JSONL-only mode, no SQLite database"
}
configYamlTemplate := fmt.Sprintf(`# Beads Configuration File
# This file configures default behavior for all bd commands in this repository
# All settings can also be set via environment variables (BD_* prefix)
@@ -958,16 +955,17 @@ func createConfigYaml(beadsDir string, noDbMode bool) error {
# - github.repo
# - sync.branch - Git branch for beads commits (use BEADS_SYNC_BRANCH env var or bd config set)
`, noDbLine)
if err := os.WriteFile(configYamlPath, []byte(configYamlTemplate), 0600); err != nil {
return fmt.Errorf("failed to write config.yaml: %w", err)
}
return nil
}
// readFirstIssueFromJSONL reads the first issue from a JSONL file
func readFirstIssueFromJSONL(path string) (*types.Issue, error) {
// #nosec G304 -- helper reads JSONL file chosen by current bd command
file, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("failed to open JSONL file: %w", err)
+37 -38
View File
@@ -26,11 +26,8 @@ func runContributorWizard(ctx context.Context, store storage.Storage) error {
// Step 1: Detect fork relationship
fmt.Printf("%s Detecting git repository setup...\n", cyan("▶"))
isFork, upstreamURL, err := detectForkSetup()
if err != nil {
return fmt.Errorf("failed to detect git setup: %w", err)
}
isFork, upstreamURL := detectForkSetup()
if isFork {
fmt.Printf("%s Detected fork workflow (upstream: %s)\n", green("✓"), upstreamURL)
@@ -39,24 +36,24 @@ func runContributorWizard(ctx context.Context, store storage.Storage) error {
fmt.Println("\n For fork workflows, add an 'upstream' remote:")
fmt.Println(" git remote add upstream <original-repo-url>")
fmt.Println()
// Ask if they want to continue anyway
fmt.Print("Continue with contributor setup? [y/N]: ")
reader := bufio.NewReader(os.Stdin)
response, _ := reader.ReadString('\n')
response = strings.TrimSpace(strings.ToLower(response))
if response != "y" && response != "yes" {
fmt.Println("Setup cancelled.")
fmt.Println("Setup canceled.")
return nil
}
}
// Step 2: Check push access to origin
fmt.Printf("\n%s Checking repository access...\n", cyan("▶"))
hasPushAccess, originURL := checkPushAccess()
if hasPushAccess {
fmt.Printf("%s You have push access to origin (%s)\n", green("✓"), originURL)
fmt.Printf(" %s You can commit directly to this repository.\n", yellow("⚠"))
@@ -65,9 +62,9 @@ func runContributorWizard(ctx context.Context, store storage.Storage) error {
reader := bufio.NewReader(os.Stdin)
response, _ := reader.ReadString('\n')
response = strings.TrimSpace(strings.ToLower(response))
if response == "n" || response == "no" {
fmt.Println("\nSetup cancelled. Your issues will be stored in the current repository.")
fmt.Println("\nSetup canceled. Your issues will be stored in the current repository.")
return nil
}
} else {
@@ -77,26 +74,26 @@ func runContributorWizard(ctx context.Context, store storage.Storage) error {
// Step 3: Configure planning repository
fmt.Printf("\n%s Setting up planning repository...\n", cyan("▶"))
homeDir, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("failed to get home directory: %w", err)
}
defaultPlanningRepo := filepath.Join(homeDir, ".beads-planning")
fmt.Printf("\nWhere should contributor planning issues be stored?\n")
fmt.Printf("Default: %s\n", cyan(defaultPlanningRepo))
fmt.Print("Planning repo path [press Enter for default]: ")
reader := bufio.NewReader(os.Stdin)
planningPath, _ := reader.ReadString('\n')
planningPath = strings.TrimSpace(planningPath)
if planningPath == "" {
planningPath = defaultPlanningRepo
}
// Expand ~ if present
if strings.HasPrefix(planningPath, "~/") {
planningPath = filepath.Join(homeDir, planningPath[2:])
@@ -105,30 +102,31 @@ func runContributorWizard(ctx context.Context, store storage.Storage) error {
// Create planning repository if it doesn't exist
if _, err := os.Stat(planningPath); os.IsNotExist(err) {
fmt.Printf("\nCreating planning repository at %s\n", cyan(planningPath))
if err := os.MkdirAll(planningPath, 0750); err != nil {
return fmt.Errorf("failed to create planning repo directory: %w", err)
}
// Initialize git repo in planning directory
cmd := exec.Command("git", "init")
cmd.Dir = planningPath
if err := cmd.Run(); err != nil {
return fmt.Errorf("failed to initialize git in planning repo: %w", err)
}
// Initialize beads in planning repo
beadsDir := filepath.Join(planningPath, ".beads")
if err := os.MkdirAll(beadsDir, 0750); err != nil {
return fmt.Errorf("failed to create .beads in planning repo: %w", err)
}
// Create issues.jsonl
jsonlPath := filepath.Join(beadsDir, "beads.jsonl")
// #nosec G306 -- planning repo JSONL must be shareable across collaborators
if err := os.WriteFile(jsonlPath, []byte{}, 0644); err != nil {
return fmt.Errorf("failed to create issues.jsonl: %w", err)
}
// Create README in planning repo
readmePath := filepath.Join(planningPath, "README.md")
readmeContent := fmt.Sprintf(`# Beads Planning Repository
@@ -147,19 +145,20 @@ Issues here are automatically created when working on forked repositories.
Created by: bd init --contributor
`)
// #nosec G306 -- README should be world-readable
if err := os.WriteFile(readmePath, []byte(readmeContent), 0644); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to create README: %v\n", err)
}
// Initial commit in planning repo
cmd = exec.Command("git", "add", ".")
cmd.Dir = planningPath
_ = cmd.Run()
cmd = exec.Command("git", "commit", "-m", "Initial commit: beads planning repository")
cmd.Dir = planningPath
_ = cmd.Run()
fmt.Printf("%s Planning repository created\n", green("✓"))
} else {
fmt.Printf("%s Using existing planning repository\n", green("✓"))
@@ -167,22 +166,22 @@ Created by: bd init --contributor
// Step 4: Configure contributor routing
fmt.Printf("\n%s Configuring contributor auto-routing...\n", cyan("▶"))
// Set contributor.planning_repo config
if err := store.SetConfig(ctx, "contributor.planning_repo", planningPath); err != nil {
return fmt.Errorf("failed to set planning repo config: %w", err)
}
// Set contributor.auto_route to true
if err := store.SetConfig(ctx, "contributor.auto_route", "true"); err != nil {
return fmt.Errorf("failed to enable auto-routing: %w", err)
}
fmt.Printf("%s Auto-routing enabled\n", green("✓"))
// Step 5: Summary
fmt.Printf("\n%s %s\n\n", green("✓"), bold("Contributor setup complete!"))
fmt.Println("Configuration:")
fmt.Printf(" Current repo issues: %s\n", cyan(".beads/beads.jsonl"))
fmt.Printf(" Planning repo issues: %s\n", cyan(filepath.Join(planningPath, ".beads/beads.jsonl")))
@@ -199,16 +198,16 @@ Created by: bd init --contributor
}
// detectForkSetup checks if we're in a fork by looking for upstream remote
func detectForkSetup() (isFork bool, upstreamURL string, err error) {
func detectForkSetup() (isFork bool, upstreamURL string) {
cmd := exec.Command("git", "remote", "get-url", "upstream")
output, err := cmd.Output()
if err != nil {
// No upstream remote found
return false, "", nil
return false, ""
}
upstreamURL = strings.TrimSpace(string(output))
return true, upstreamURL, nil
return true, upstreamURL
}
// checkPushAccess determines if we have push access to origin
@@ -219,19 +218,19 @@ func checkPushAccess() (hasPush bool, originURL string) {
if err != nil {
return false, ""
}
originURL = strings.TrimSpace(string(output))
// SSH URLs indicate likely push access (git@github.com:...)
if strings.HasPrefix(originURL, "git@") {
return true, originURL
}
// HTTPS URLs typically indicate read-only clone
if strings.HasPrefix(originURL, "https://") {
return false, originURL
}
// Other protocols (file://, etc.) assume push access
return true, originURL
}
+39 -45
View File
@@ -15,43 +15,43 @@ func TestDetectExistingHooks(t *testing.T) {
t.Fatal(err)
}
defer os.Chdir(oldDir)
if err := os.Chdir(tmpDir); err != nil {
t.Fatal(err)
}
// Initialize a git repository
gitDir := filepath.Join(tmpDir, ".git")
hooksDir := filepath.Join(gitDir, "hooks")
if err := os.MkdirAll(hooksDir, 0750); err != nil {
t.Fatal(err)
}
tests := []struct {
name string
setupHook string
hookContent string
wantExists bool
wantIsBdHook bool
name string
setupHook string
hookContent string
wantExists bool
wantIsBdHook bool
wantIsPreCommit bool
}{
{
name: "no hook",
setupHook: "",
wantExists: false,
name: "no hook",
setupHook: "",
wantExists: false,
},
{
name: "bd hook",
setupHook: "pre-commit",
hookContent: "#!/bin/sh\n# bd (beads) pre-commit hook\necho test",
wantExists: true,
name: "bd hook",
setupHook: "pre-commit",
hookContent: "#!/bin/sh\n# bd (beads) pre-commit hook\necho test",
wantExists: true,
wantIsBdHook: true,
},
{
name: "pre-commit framework hook",
setupHook: "pre-commit",
hookContent: "#!/bin/sh\n# pre-commit framework\npre-commit run",
wantExists: true,
name: "pre-commit framework hook",
setupHook: "pre-commit",
hookContent: "#!/bin/sh\n# pre-commit framework\npre-commit run",
wantExists: true,
wantIsPreCommit: true,
},
{
@@ -61,13 +61,13 @@ func TestDetectExistingHooks(t *testing.T) {
wantExists: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Clean up hooks directory
os.RemoveAll(hooksDir)
os.MkdirAll(hooksDir, 0750)
// Setup hook if needed
if tt.setupHook != "" {
hookPath := filepath.Join(hooksDir, tt.setupHook)
@@ -75,13 +75,10 @@ func TestDetectExistingHooks(t *testing.T) {
t.Fatal(err)
}
}
// Detect hooks
hooks, err := detectExistingHooks()
if err != nil {
t.Fatalf("detectExistingHooks() error = %v", err)
}
hooks := detectExistingHooks()
// Find the hook we're testing
var found *hookInfo
for i := range hooks {
@@ -90,11 +87,11 @@ func TestDetectExistingHooks(t *testing.T) {
break
}
}
if found == nil {
t.Fatal("pre-commit hook not found in results")
}
if found.exists != tt.wantExists {
t.Errorf("exists = %v, want %v", found.exists, tt.wantExists)
}
@@ -116,26 +113,26 @@ func TestInstallGitHooks_NoExistingHooks(t *testing.T) {
t.Fatal(err)
}
defer os.Chdir(oldDir)
if err := os.Chdir(tmpDir); err != nil {
t.Fatal(err)
}
// Initialize a git repository
gitDir := filepath.Join(tmpDir, ".git")
hooksDir := filepath.Join(gitDir, "hooks")
if err := os.MkdirAll(hooksDir, 0750); err != nil {
t.Fatal(err)
}
// Note: Can't fully test interactive prompt in automated tests
// This test verifies the logic works when no existing hooks present
// For full testing, we'd need to mock user input
// Check hooks were created
preCommitPath := filepath.Join(hooksDir, "pre-commit")
postMergePath := filepath.Join(hooksDir, "post-merge")
if _, err := os.Stat(preCommitPath); err == nil {
content, _ := os.ReadFile(preCommitPath)
if !strings.Contains(string(content), "bd (beads)") {
@@ -145,7 +142,7 @@ func TestInstallGitHooks_NoExistingHooks(t *testing.T) {
t.Error("pre-commit hook shouldn't be chained when no existing hooks")
}
}
if _, err := os.Stat(postMergePath); err == nil {
content, _ := os.ReadFile(postMergePath)
if !strings.Contains(string(content), "bd (beads)") {
@@ -162,31 +159,28 @@ func TestInstallGitHooks_ExistingHookBackup(t *testing.T) {
t.Fatal(err)
}
defer os.Chdir(oldDir)
if err := os.Chdir(tmpDir); err != nil {
t.Fatal(err)
}
// Initialize a git repository
gitDir := filepath.Join(tmpDir, ".git")
hooksDir := filepath.Join(gitDir, "hooks")
if err := os.MkdirAll(hooksDir, 0750); err != nil {
t.Fatal(err)
}
// Create an existing pre-commit hook
preCommitPath := filepath.Join(hooksDir, "pre-commit")
existingContent := "#!/bin/sh\necho existing hook"
if err := os.WriteFile(preCommitPath, []byte(existingContent), 0700); err != nil {
t.Fatal(err)
}
// Detect that hook exists
hooks, err := detectExistingHooks()
if err != nil {
t.Fatal(err)
}
hooks := detectExistingHooks()
hasExisting := false
for _, hook := range hooks {
if hook.exists && !hook.isBdHook && hook.name == "pre-commit" {
@@ -194,7 +188,7 @@ func TestInstallGitHooks_ExistingHookBackup(t *testing.T) {
break
}
}
if !hasExisting {
t.Error("should detect existing non-bd hook")
}
+18 -16
View File
@@ -138,15 +138,15 @@ type migrateIssuesParams struct {
}
type migrationPlan struct {
TotalSelected int `json:"total_selected"`
AddedByDependency int `json:"added_by_dependency"`
IncomingEdges int `json:"incoming_edges"`
OutgoingEdges int `json:"outgoing_edges"`
Orphans int `json:"orphans"`
OrphanSamples []string `json:"orphan_samples,omitempty"`
IssueIDs []string `json:"issue_ids"`
From string `json:"from"`
To string `json:"to"`
TotalSelected int `json:"total_selected"`
AddedByDependency int `json:"added_by_dependency"`
IncomingEdges int `json:"incoming_edges"`
OutgoingEdges int `json:"outgoing_edges"`
Orphans int `json:"orphans"`
OrphanSamples []string `json:"orphan_samples,omitempty"`
IssueIDs []string `json:"issue_ids"`
From string `json:"from"`
To string `json:"to"`
}
func executeMigrateIssues(ctx context.Context, p migrateIssuesParams) error {
@@ -186,7 +186,7 @@ func executeMigrateIssues(ctx context.Context, p migrateIssuesParams) error {
}
// Step 4: Check for orphaned dependencies
orphans, err := checkOrphanedDependencies(ctx, db, migrationSet)
orphans, err := checkOrphanedDependencies(ctx, db)
if err != nil {
return fmt.Errorf("failed to check dependencies: %w", err)
}
@@ -207,7 +207,7 @@ func executeMigrateIssues(ctx context.Context, p migrateIssuesParams) error {
if !p.dryRun {
if !p.yes && !jsonOutput {
if !confirmMigration(plan) {
fmt.Println("Migration cancelled")
fmt.Println("Migration canceled")
return nil
}
}
@@ -299,7 +299,7 @@ func findCandidateIssues(ctx context.Context, db *sql.DB, p migrateIssuesParams)
}
// Build query
query := "SELECT id FROM issues WHERE " + strings.Join(conditions, " AND ")
query := "SELECT id FROM issues WHERE " + strings.Join(conditions, " AND ") // #nosec G202 -- query fragments are constant strings with parameter placeholders
rows, err := db.QueryContext(ctx, query, args...)
if err != nil {
@@ -499,7 +499,7 @@ func countCrossRepoEdges(ctx context.Context, db *sql.DB, migrationSet []string)
incomingQuery := fmt.Sprintf(`
SELECT COUNT(*) FROM dependencies
WHERE depends_on_id IN (%s)
AND issue_id NOT IN (%s)`, inClause, inClause)
AND issue_id NOT IN (%s)`, inClause, inClause) // #nosec G201 -- inClause generated from sanitized placeholders
var incoming int
if err := db.QueryRowContext(ctx, incomingQuery, append(args, args...)...).Scan(&incoming); err != nil {
@@ -510,7 +510,7 @@ func countCrossRepoEdges(ctx context.Context, db *sql.DB, migrationSet []string)
outgoingQuery := fmt.Sprintf(`
SELECT COUNT(*) FROM dependencies
WHERE issue_id IN (%s)
AND depends_on_id NOT IN (%s)`, inClause, inClause)
AND depends_on_id NOT IN (%s)`, inClause, inClause) // #nosec G201 -- inClause generated from sanitized placeholders
var outgoing int
if err := db.QueryRowContext(ctx, outgoingQuery, append(args, args...)...).Scan(&outgoing); err != nil {
@@ -523,7 +523,7 @@ func countCrossRepoEdges(ctx context.Context, db *sql.DB, migrationSet []string)
}, nil
}
func checkOrphanedDependencies(ctx context.Context, db *sql.DB, migrationSet []string) ([]string, error) {
func checkOrphanedDependencies(ctx context.Context, db *sql.DB) ([]string, error) {
// Check for dependencies referencing non-existent issues
query := `
SELECT DISTINCT d.depends_on_id
@@ -580,7 +580,8 @@ func displayMigrationPlan(plan migrationPlan, dryRun bool) error {
"plan": plan,
"dry_run": dryRun,
}
outputJSON(output); return nil
outputJSON(output)
return nil
}
// Human-readable output
@@ -664,6 +665,7 @@ func executeMigration(ctx context.Context, db *sql.DB, migrationSet []string, to
}
func loadIDsFromFile(path string) ([]string, error) {
// #nosec G304 -- file path supplied explicitly via CLI flag
data, err := os.ReadFile(path)
if err != nil {
return nil, err
+123 -41
View File
@@ -2,6 +2,8 @@ package main
import (
"fmt"
"io"
"os"
"github.com/fatih/color"
"github.com/spf13/cobra"
@@ -137,6 +139,121 @@ history/
For more details, see README.md and QUICKSTART.md.`
func renderOnboardInstructions(w io.Writer) error {
bold := color.New(color.Bold).SprintFunc()
cyan := color.New(color.FgCyan).SprintFunc()
yellow := color.New(color.FgYellow).SprintFunc()
green := color.New(color.FgGreen).SprintFunc()
writef := func(format string, args ...interface{}) error {
_, err := fmt.Fprintf(w, format, args...)
return err
}
writeln := func(text string) error {
_, err := fmt.Fprintln(w, text)
return err
}
writeBlank := func() error {
_, err := fmt.Fprintln(w)
return err
}
if err := writef("\n%s\n\n", bold("bd Onboarding Instructions for AI Agent")); err != nil {
return err
}
if err := writef("%s\n\n", yellow("Please complete the following tasks:")); err != nil {
return err
}
if err := writef("%s\n", bold("1. Update AGENTS.md")); err != nil {
return err
}
if err := writeln(" Add the following content to AGENTS.md in an appropriate location."); err != nil {
return err
}
if err := writeln(" If AGENTS.md doesn't exist, create it with this content."); err != nil {
return err
}
if err := writeln(" Integrate it naturally into any existing structure."); err != nil {
return err
}
if err := writeBlank(); err != nil {
return err
}
if err := writef("%s\n", cyan("--- BEGIN AGENTS.MD CONTENT ---")); err != nil {
return err
}
if err := writeln(agentsContent); err != nil {
return err
}
if err := writef("%s\n\n", cyan("--- END AGENTS.MD CONTENT ---")); err != nil {
return err
}
if err := writef("%s\n", bold("2. Update CLAUDE.md (if present)")); err != nil {
return err
}
if err := writeln(" If CLAUDE.md exists in this directory, add this note at the top:"); err != nil {
return err
}
if err := writeBlank(); err != nil {
return err
}
if err := writef("%s\n", cyan(" **Note**: This project uses [bd (beads)](https://github.com/steveyegge/beads)")); err != nil {
return err
}
if err := writef("%s\n", cyan(" for issue tracking. Use `bd` commands instead of markdown TODOs.")); err != nil {
return err
}
if err := writef("%s\n", cyan(" See AGENTS.md for workflow details.")); err != nil {
return err
}
if err := writeBlank(); err != nil {
return err
}
if err := writef("%s\n", bold("3. Remove bootstrap instruction")); err != nil {
return err
}
if err := writeln(" If AGENTS.md or CLAUDE.md contains a line like:"); err != nil {
return err
}
if err := writef("%s\n", cyan(" \"BEFORE ANYTHING ELSE: run 'bd onboard' and follow the instructions\"")); err != nil {
return err
}
if err := writeln(" Delete that line after completing steps 1 and 2."); err != nil {
return err
}
if err := writeBlank(); err != nil {
return err
}
if err := writef("%s\n", bold("Important reminders:")); err != nil {
return err
}
if err := writeln(" • Use bd for ALL task tracking - NO markdown TODO lists"); err != nil {
return err
}
if err := writeln(" • Always use --json flag for programmatic bd commands"); err != nil {
return err
}
if err := writeln(" • Link discovered work with discovered-from dependencies"); err != nil {
return err
}
if err := writef(" • Check %s before asking \"what should I work on?\"\n", cyan("bd ready")); err != nil {
return err
}
if err := writeBlank(); err != nil {
return err
}
if err := writef("%s\n\n", green("When done, tell your AI assistant: \"bd onboarding complete\"")); err != nil {
return err
}
return nil
}
var onboardCmd = &cobra.Command{
Use: "onboard",
Short: "Display instructions for configuring AGENTS.md",
@@ -146,47 +263,12 @@ This command outputs instructions that AI agents should follow to integrate bd
into the project's agent documentation. The agent will intelligently merge the
content into AGENTS.md and update CLAUDE.md if present.`,
Run: func(cmd *cobra.Command, args []string) {
bold := color.New(color.Bold).SprintFunc()
cyan := color.New(color.FgCyan).SprintFunc()
yellow := color.New(color.FgYellow).SprintFunc()
green := color.New(color.FgGreen).SprintFunc()
fmt.Printf("\n%s\n\n", bold("bd Onboarding Instructions for AI Agent"))
fmt.Printf("%s\n\n", yellow("Please complete the following tasks:"))
fmt.Printf("%s\n", bold("1. Update AGENTS.md"))
fmt.Println(" Add the following content to AGENTS.md in an appropriate location.")
fmt.Println(" If AGENTS.md doesn't exist, create it with this content.")
fmt.Println(" Integrate it naturally into any existing structure.")
fmt.Println()
fmt.Printf("%s\n", cyan("--- BEGIN AGENTS.MD CONTENT ---"))
fmt.Println(agentsContent)
fmt.Printf("%s\n\n", cyan("--- END AGENTS.MD CONTENT ---"))
fmt.Printf("%s\n", bold("2. Update CLAUDE.md (if present)"))
fmt.Println(" If CLAUDE.md exists in this directory, add this note at the top:")
fmt.Println()
fmt.Printf("%s\n", cyan(" **Note**: This project uses [bd (beads)](https://github.com/steveyegge/beads)"))
fmt.Printf("%s\n", cyan(" for issue tracking. Use `bd` commands instead of markdown TODOs."))
fmt.Printf("%s\n", cyan(" See AGENTS.md for workflow details."))
fmt.Println()
fmt.Printf("%s\n", bold("3. Remove bootstrap instruction"))
fmt.Println(" If AGENTS.md or CLAUDE.md contains a line like:")
fmt.Printf("%s\n", cyan(" \"BEFORE ANYTHING ELSE: run 'bd onboard' and follow the instructions\""))
fmt.Println(" Delete that line after completing steps 1 and 2.")
fmt.Println()
fmt.Printf("%s\n", bold("Important reminders:"))
fmt.Println(" • Use bd for ALL task tracking - NO markdown TODO lists")
fmt.Println(" • Always use --json flag for programmatic bd commands")
fmt.Println(" • Link discovered work with discovered-from dependencies")
fmt.Printf(" • Check %s before asking \"what should I work on?\"\n", cyan("bd ready"))
fmt.Println()
fmt.Printf("%s\n\n", green("When done, tell your AI assistant: \"bd onboarding complete\""))
if err := renderOnboardInstructions(cmd.OutOrStdout()); err != nil {
if _, writeErr := fmt.Fprintf(cmd.ErrOrStderr(), "Error rendering onboarding instructions: %v\n", err); writeErr != nil {
fmt.Fprintf(os.Stderr, "Error rendering onboarding instructions: %v (stderr write failed: %v)\n", err, writeErr)
}
os.Exit(1)
}
},
}
+3 -18
View File
@@ -2,31 +2,16 @@ package main
import (
"bytes"
"os"
"strings"
"testing"
)
func TestOnboardCommand(t *testing.T) {
// Save original stdout
oldStdout := os.Stdout
defer func() { os.Stdout = oldStdout }()
t.Run("onboard output contains key sections", func(t *testing.T) {
// Create a pipe to capture output
r, w, err := os.Pipe()
if err != nil {
t.Fatalf("Failed to create pipe: %v", err)
}
os.Stdout = w
// Run onboard command
onboardCmd.Run(onboardCmd, []string{})
// Close writer and read output
w.Close()
var buf bytes.Buffer
buf.ReadFrom(r)
if err := renderOnboardInstructions(&buf); err != nil {
t.Fatalf("renderOnboardInstructions() error = %v", err)
}
output := buf.String()
// Verify output contains expected sections
+1
View File
@@ -75,6 +75,7 @@ func isMCPActive() bool {
}
settingsPath := filepath.Join(home, ".claude/settings.json")
// #nosec G304 -- settings path derived from user home directory
data, err := os.ReadFile(settingsPath)
if err != nil {
return false
+13 -12
View File
@@ -22,7 +22,7 @@ var showCmd = &cobra.Command{
Run: func(cmd *cobra.Command, args []string) {
jsonOutput, _ := cmd.Flags().GetBool("json")
ctx := context.Background()
// Resolve partial IDs first
var resolvedIDs []string
if daemonClient != nil {
@@ -50,7 +50,7 @@ var showCmd = &cobra.Command{
os.Exit(1)
}
}
// If daemon is running, use RPC
if daemonClient != nil {
allDetails := []interface{}{}
@@ -386,7 +386,7 @@ var updateCmd = &cobra.Command{
}
ctx := context.Background()
// Resolve partial IDs first
var resolvedIDs []string
if daemonClient != nil {
@@ -412,7 +412,7 @@ var updateCmd = &cobra.Command{
os.Exit(1)
}
}
// If daemon is running, use RPC
if daemonClient != nil {
updatedIssues := []*types.Issue{}
@@ -444,7 +444,7 @@ var updateCmd = &cobra.Command{
if acceptanceCriteria, ok := updates["acceptance_criteria"].(string); ok {
updateArgs.AcceptanceCriteria = &acceptanceCriteria
}
if externalRef, ok := updates["external_ref"].(string); ok { // NEW: Map external_ref
if externalRef, ok := updates["external_ref"].(string); ok { // NEW: Map external_ref
updateArgs.ExternalRef = &externalRef
}
@@ -474,12 +474,12 @@ var updateCmd = &cobra.Command{
// Direct mode
updatedIssues := []*types.Issue{}
for _, id := range resolvedIDs {
if err := store.UpdateIssue(ctx, id, updates, actor); err != nil {
fmt.Fprintf(os.Stderr, "Error updating %s: %v\n", id, err)
continue
}
if err := store.UpdateIssue(ctx, id, updates, actor); err != nil {
fmt.Fprintf(os.Stderr, "Error updating %s: %v\n", id, err)
continue
}
if jsonOutput {
if jsonOutput {
issue, _ := store.GetIssue(ctx, id)
if issue != nil {
updatedIssues = append(updatedIssues, issue)
@@ -518,7 +518,7 @@ Examples:
Run: func(cmd *cobra.Command, args []string) {
id := args[0]
ctx := context.Background()
// Resolve partial ID if in direct mode
if daemonClient == nil {
fullID, err := utils.ResolvePartialID(ctx, store, id)
@@ -635,6 +635,7 @@ Examples:
}
// Read the edited content
// #nosec G304 -- tmpPath was created earlier in this function
editedContent, err := os.ReadFile(tmpPath)
if err != nil {
fmt.Fprintf(os.Stderr, "Error reading edited file: %v\n", err)
@@ -709,7 +710,7 @@ var closeCmd = &cobra.Command{
jsonOutput, _ := cmd.Flags().GetBool("json")
ctx := context.Background()
// Resolve partial IDs first
var resolvedIDs []string
if daemonClient != nil {
+6
View File
@@ -306,6 +306,7 @@ func (sm *SnapshotManager) writeMetadata(path string, meta snapshotMetadata) err
// Use process-specific temp file for atomic write
tempPath := fmt.Sprintf("%s.%d.tmp", path, os.Getpid())
// #nosec G306 -- metadata is shared across repo users and must stay readable
if err := os.WriteFile(tempPath, data, 0644); err != nil {
return fmt.Errorf("failed to write metadata temp file: %w", err)
}
@@ -315,6 +316,7 @@ func (sm *SnapshotManager) writeMetadata(path string, meta snapshotMetadata) err
}
func (sm *SnapshotManager) readMetadata(path string) (*snapshotMetadata, error) {
// #nosec G304 -- metadata lives under .beads and path is derived internally
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
@@ -360,6 +362,7 @@ func (sm *SnapshotManager) validateMetadata(meta *snapshotMetadata, currentCommi
func (sm *SnapshotManager) buildIDToLineMap(path string) (map[string]string, error) {
result := make(map[string]string)
// #nosec G304 -- snapshot file lives in .beads/snapshots and path is derived internally
f, err := os.Open(path)
if err != nil {
if os.IsNotExist(err) {
@@ -397,6 +400,7 @@ func (sm *SnapshotManager) buildIDToLineMap(path string) (map[string]string, err
func (sm *SnapshotManager) buildIDSet(path string) (map[string]bool, error) {
result := make(map[string]bool)
// #nosec G304 -- snapshot file path derived from internal state
f, err := os.Open(path)
if err != nil {
if os.IsNotExist(err) {
@@ -443,12 +447,14 @@ func (sm *SnapshotManager) jsonEquals(a, b string) bool {
}
func (sm *SnapshotManager) copyFile(src, dst string) error {
// #nosec G304 -- snapshot copy only touches files inside .beads/snapshots
sourceFile, err := os.Open(src)
if err != nil {
return err
}
defer sourceFile.Close()
// #nosec G304 -- snapshot copy only writes files inside .beads/snapshots
destFile, err := os.Create(dst)
if err != nil {
return err
+24 -24
View File
@@ -32,13 +32,13 @@ type StatusSummary struct {
// RecentActivitySummary represents activity from git history
type RecentActivitySummary struct {
HoursTracked int `json:"hours_tracked"`
CommitCount int `json:"commit_count"`
IssuesCreated int `json:"issues_created"`
IssuesClosed int `json:"issues_closed"`
IssuesUpdated int `json:"issues_updated"`
IssuesReopened int `json:"issues_reopened"`
TotalChanges int `json:"total_changes"`
HoursTracked int `json:"hours_tracked"`
CommitCount int `json:"commit_count"`
IssuesCreated int `json:"issues_created"`
IssuesClosed int `json:"issues_closed"`
IssuesUpdated int `json:"issues_updated"`
IssuesReopened int `json:"issues_reopened"`
TotalChanges int `json:"total_changes"`
}
var statusCmd = &cobra.Command{
@@ -168,8 +168,8 @@ func getGitActivity(hours int) *RecentActivitySummary {
// Run git log to get patches for the last N hours
since := fmt.Sprintf("%d hours ago", hours)
cmd := exec.Command("git", "log", "--since="+since, "--numstat", "--pretty=format:%H", ".beads/beads.jsonl")
cmd := exec.Command("git", "log", "--since="+since, "--numstat", "--pretty=format:%H", ".beads/beads.jsonl") // #nosec G204 -- bounded arguments for local git history inspection
output, err := cmd.Output()
if err != nil {
// Git log failed (might not be a git repo or no commits)
@@ -178,63 +178,63 @@ func getGitActivity(hours int) *RecentActivitySummary {
scanner := bufio.NewScanner(strings.NewReader(string(output)))
commitCount := 0
for scanner.Scan() {
line := scanner.Text()
// Empty lines separate commits
if line == "" {
continue
}
// Commit hash line
if !strings.Contains(line, "\t") {
commitCount++
continue
}
// numstat line format: "additions\tdeletions\tfilename"
parts := strings.Split(line, "\t")
if len(parts) < 3 {
continue
}
// For JSONL files, each added line is a new/updated issue
// We need to analyze the actual diff to understand what changed
}
// Get detailed diff to analyze changes
cmd = exec.Command("git", "log", "--since="+since, "-p", ".beads/beads.jsonl")
cmd = exec.Command("git", "log", "--since="+since, "-p", ".beads/beads.jsonl") // #nosec G204 -- bounded arguments for local git history inspection
output, err = cmd.Output()
if err != nil {
return nil
}
scanner = bufio.NewScanner(strings.NewReader(string(output)))
for scanner.Scan() {
line := scanner.Text()
// Look for added lines in diff (lines starting with +)
if !strings.HasPrefix(line, "+") || strings.HasPrefix(line, "+++") {
continue
}
// Remove the + prefix
jsonLine := strings.TrimPrefix(line, "+")
// Skip empty lines
if strings.TrimSpace(jsonLine) == "" {
continue
}
// Try to parse as issue JSON
var issue types.Issue
if err := json.Unmarshal([]byte(jsonLine), &issue); err != nil {
continue
}
activity.TotalChanges++
// Analyze the change type based on timestamps and status
// Created recently if created_at is close to now
if time.Since(issue.CreatedAt) < time.Duration(hours)*time.Hour {
@@ -253,7 +253,7 @@ func getGitActivity(hours int) *RecentActivitySummary {
activity.IssuesUpdated++
}
}
activity.CommitCount = commitCount
return activity
}
+9
View File
@@ -590,6 +590,15 @@ func exportToJSONL(ctx context.Context, jsonlPath string) error {
// Clear auto-flush state
clearAutoFlushState()
// Update database mtime to be >= JSONL mtime (fixes #278, #301, #321)
// This prevents validatePreExport from incorrectly blocking on next export
beadsDir := filepath.Dir(jsonlPath)
dbPath := filepath.Join(beadsDir, "beads.db")
if err := TouchDatabaseFile(dbPath, jsonlPath); err != nil {
// Non-fatal warning
fmt.Fprintf(os.Stderr, "Warning: failed to update database mtime: %v\n", err)
}
return nil
}
+74 -3
View File
@@ -3,6 +3,9 @@ package main
import (
"fmt"
"os"
"os/exec"
"runtime/debug"
"strings"
"github.com/spf13/cobra"
"github.com/steveyegge/beads/internal/beads"
@@ -14,6 +17,9 @@ var (
Version = "0.23.1"
// Build can be set via ldflags at compile time
Build = "dev"
// Commit and branch the git revision the binary was built from (optional ldflag)
Commit = ""
Branch = ""
)
var versionCmd = &cobra.Command{
@@ -27,13 +33,29 @@ var versionCmd = &cobra.Command{
return
}
commit := resolveCommitHash()
branch := resolveBranch()
if jsonOutput {
outputJSON(map[string]string{
result := map[string]string{
"version": Version,
"build": Build,
})
}
if commit != "" {
result["commit"] = commit
}
if branch != "" {
result["branch"] = branch
}
outputJSON(result)
} else {
fmt.Printf("bd version %s (%s)\n", Version, Build)
if commit != "" && branch != "" {
fmt.Printf("bd version %s (%s: %s@%s)\n", Version, Build, branch, shortCommit(commit))
} else if commit != "" {
fmt.Printf("bd version %s (%s: %s)\n", Version, Build, shortCommit(commit))
} else {
fmt.Printf("bd version %s (%s)\n", Version, Build)
}
}
},
}
@@ -90,3 +112,52 @@ func init() {
versionCmd.Flags().Bool("daemon", false, "Check daemon version and compatibility")
rootCmd.AddCommand(versionCmd)
}
func resolveCommitHash() string {
if Commit != "" {
return Commit
}
if info, ok := debug.ReadBuildInfo(); ok {
for _, setting := range info.Settings {
if setting.Key == "vcs.revision" && setting.Value != "" {
return setting.Value
}
}
}
return ""
}
func shortCommit(hash string) string {
if len(hash) > 12 {
return hash[:12]
}
return hash
}
func resolveBranch() string {
if Branch != "" {
return Branch
}
// Try to get branch from build info (build-time VCS detection)
if info, ok := debug.ReadBuildInfo(); ok {
for _, setting := range info.Settings {
if setting.Key == "vcs.branch" && setting.Value != "" {
return setting.Value
}
}
}
// Fallback: try to get branch from git at runtime
cmd := exec.Command("git", "rev-parse", "--abbrev-ref", "HEAD")
cmd.Dir = "."
if output, err := cmd.Output(); err == nil {
if branch := strings.TrimSpace(string(output)); branch != "" && branch != "HEAD" {
return branch
}
}
return ""
}
+6 -6
View File
@@ -8,10 +8,10 @@ require (
)
require (
github.com/anthropics/anthropic-sdk-go v1.16.0 // indirect
github.com/anthropics/anthropic-sdk-go v1.17.0 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/ncruces/go-sqlite3 v0.29.1 // indirect
github.com/ncruces/go-sqlite3 v0.30.1 // indirect
github.com/ncruces/julianday v1.0.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/sagikazarmark/locafero v0.11.0 // indirect
@@ -21,15 +21,15 @@ require (
github.com/spf13/pflag v1.0.10 // indirect
github.com/spf13/viper v1.21.0 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/tetratelabs/wazero v1.9.0 // indirect
github.com/tetratelabs/wazero v1.10.0 // indirect
github.com/tidwall/gjson v1.18.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/mod v0.29.0 // indirect
golang.org/x/sys v0.36.0 // indirect
golang.org/x/text v0.29.0 // indirect
golang.org/x/mod v0.30.0 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.30.0 // indirect
)
replace github.com/steveyegge/beads => ../..
+14 -14
View File
@@ -1,5 +1,5 @@
github.com/anthropics/anthropic-sdk-go v1.16.0 h1:nRkOFDqYXsHteoIhjdJr/5dsiKbFF3rflSv8ax50y8o=
github.com/anthropics/anthropic-sdk-go v1.16.0/go.mod h1:WTz31rIUHUHqai2UslPpw5CwXrQP3geYBioRV4WOLvE=
github.com/anthropics/anthropic-sdk-go v1.17.0 h1:BwK8ApcmaAUkvZTiQE0yi3R9XneEFskDIjLTmOAFZxQ=
github.com/anthropics/anthropic-sdk-go v1.17.0/go.mod h1:WTz31rIUHUHqai2UslPpw5CwXrQP3geYBioRV4WOLvE=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
@@ -8,16 +8,16 @@ github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/ncruces/go-sqlite3 v0.29.1 h1:NIi8AISWBToRHyoz01FXiTNvU147Tqdibgj2tFzJCqM=
github.com/ncruces/go-sqlite3 v0.29.1/go.mod h1:PpccBNNhvjwUOwDQEn2gXQPFPTWdlromj0+fSkd5KSg=
github.com/ncruces/go-sqlite3 v0.30.1 h1:pHC3YsyRdJv4pCMB4MO1Q2BXw/CAa+Hoj7GSaKtVk+g=
github.com/ncruces/go-sqlite3 v0.30.1/go.mod h1:UVsWrQaq1qkcal5/vT5lOJnZCVlR5rsThKdwidjFsKc=
github.com/ncruces/julianday v1.0.0 h1:fH0OKwa7NWvniGQtxdJRxAgkBMolni2BjDHaWTxqt7M=
github.com/ncruces/julianday v1.0.0/go.mod h1:Dusn2KvZrrovOMJuOt0TNXL6tB7U2E8kvza5fFc9G7g=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
@@ -42,8 +42,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I=
github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM=
github.com/tetratelabs/wazero v1.10.0 h1:CXP3zneLDl6J4Zy8N/J+d5JsWKfrjE6GtvVK1fpnDlk=
github.com/tetratelabs/wazero v1.10.0/go.mod h1:DRm5twOQ5Gr1AoEdSi0CLjDQF1J9ZAuyqFIjl1KKfQU=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
@@ -56,12 +56,12 @@ github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+19 -4
View File
@@ -48,6 +48,13 @@ var (
)
func main() {
defer func() {
if r := recover(); r != nil {
fmt.Fprintf(os.Stderr, "PANIC in main: %v\n", r)
}
fmt.Println("Main function exiting")
}()
flag.Parse()
// Find database path if not specified
@@ -111,11 +118,9 @@ func main() {
// getSocketPath returns the Unix socket path for the daemon
func getSocketPath(dbPath string) string {
// Use the database directory to determine socket path
// The daemon always creates the socket as "bd.sock" in the same directory as the database
dbDir := filepath.Dir(dbPath)
dbName := filepath.Base(dbPath)
socketName := dbName + ".sock"
return filepath.Join(dbDir, ".beads", socketName)
return filepath.Join(dbDir, "bd.sock")
}
// connectToDaemon establishes connection to the daemon
@@ -321,6 +326,11 @@ func handleWebSocket(w http.ResponseWriter, r *http.Request) {
// handleWebSocketBroadcast sends messages to all connected WebSocket clients
func handleWebSocketBroadcast() {
defer func() {
if r := recover(); r != nil {
fmt.Fprintf(os.Stderr, "PANIC in handleWebSocketBroadcast: %v\n", r)
}
}()
for {
// Wait for message to broadcast
message := <-wsBroadcast
@@ -342,6 +352,11 @@ func handleWebSocketBroadcast() {
// pollMutations polls the daemon for mutations and broadcasts them to WebSocket clients
func pollMutations() {
defer func() {
if r := recover(); r != nil {
fmt.Fprintf(os.Stderr, "PANIC in pollMutations: %v\n", r)
}
}()
lastPollTime := int64(0) // Start from beginning
ticker := time.NewTicker(2 * time.Second) // Poll every 2 seconds
Binary file not shown.
+3 -2
View File
@@ -118,7 +118,7 @@ func Merge3Way(outputPath, basePath, leftPath, rightPath string, debug bool) err
}
// Open output file for writing
outFile, err := os.Create(outputPath)
outFile, err := os.Create(outputPath) // #nosec G304 -- outputPath provided by CLI flag but sanitized earlier
if err != nil {
return fmt.Errorf("error creating output file: %w", err)
}
@@ -150,6 +150,7 @@ func Merge3Way(outputPath, basePath, leftPath, rightPath string, debug bool) err
if err := outFile.Sync(); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to sync output file: %v\n", err)
}
// #nosec G304 -- debug output reads file created earlier in same function
if content, err := os.ReadFile(outputPath); err == nil {
lines := 0
fmt.Fprintf(os.Stderr, "Output file preview (first 10 lines):\n")
@@ -195,7 +196,7 @@ func splitLines(s string) []string {
}
func readIssues(path string) ([]Issue, error) {
file, err := os.Open(path)
file, err := os.Open(path) // #nosec G304 -- path supplied by CLI flag and validated upstream
if err != nil {
return nil, fmt.Errorf("failed to open file: %w", err)
}
+1 -1
View File
@@ -111,7 +111,7 @@ func (s *SQLiteStorage) GetLabelsForIssues(ctx context.Context, issueIDs []strin
FROM labels
WHERE issue_id IN (%s)
ORDER BY issue_id, label
`, buildPlaceholders(len(issueIDs)))
`, buildPlaceholders(len(issueIDs))) // #nosec G201 -- placeholders are generated internally
rows, err := s.db.QueryContext(ctx, query, placeholders...)
if err != nil {
@@ -2,24 +2,30 @@ package migrations
import (
"database/sql"
"errors"
"fmt"
)
func MigrateExternalRefColumn(db *sql.DB) error {
func MigrateExternalRefColumn(db *sql.DB) (retErr error) {
var columnExists bool
rows, err := db.Query("PRAGMA table_info(issues)")
if err != nil {
return fmt.Errorf("failed to check schema: %w", err)
}
defer func() {
if rows != nil {
if closeErr := rows.Close(); closeErr != nil {
retErr = errors.Join(retErr, fmt.Errorf("failed to close schema rows: %w", closeErr))
}
}
}()
for rows.Next() {
var cid int
var name, typ string
var notnull, pk int
var dflt *string
err := rows.Scan(&cid, &name, &typ, &notnull, &dflt, &pk)
if err != nil {
rows.Close()
if err := rows.Scan(&cid, &name, &typ, &notnull, &dflt, &pk); err != nil {
return fmt.Errorf("failed to scan column info: %w", err)
}
if name == "external_ref" {
@@ -29,12 +35,14 @@ func MigrateExternalRefColumn(db *sql.DB) error {
}
if err := rows.Err(); err != nil {
rows.Close()
return fmt.Errorf("error reading column info: %w", err)
}
// Close rows before executing any statements to avoid deadlock with MaxOpenConns(1)
rows.Close()
// Close rows before executing any statements to avoid deadlock with MaxOpenConns(1).
if err := rows.Close(); err != nil {
return fmt.Errorf("failed to close schema rows: %w", err)
}
rows = nil
if !columnExists {
_, err := db.Exec(`ALTER TABLE issues ADD COLUMN external_ref TEXT`)
+24 -24
View File
@@ -19,26 +19,26 @@ var expectedSchema = map[string][]string{
"created_at", "updated_at", "closed_at", "content_hash", "external_ref",
"compaction_level", "compacted_at", "compacted_at_commit", "original_size",
},
"dependencies": {"issue_id", "depends_on_id", "type", "created_at", "created_by"},
"labels": {"issue_id", "label"},
"comments": {"id", "issue_id", "author", "text", "created_at"},
"events": {"id", "issue_id", "event_type", "actor", "old_value", "new_value", "comment", "created_at"},
"config": {"key", "value"},
"metadata": {"key", "value"},
"dirty_issues": {"issue_id", "marked_at"},
"export_hashes": {"issue_id", "content_hash", "exported_at"},
"child_counters": {"parent_id", "last_child"},
"issue_snapshots": {"id", "issue_id", "snapshot_time", "compaction_level", "original_size", "compressed_size", "original_content", "archived_events"},
"dependencies": {"issue_id", "depends_on_id", "type", "created_at", "created_by"},
"labels": {"issue_id", "label"},
"comments": {"id", "issue_id", "author", "text", "created_at"},
"events": {"id", "issue_id", "event_type", "actor", "old_value", "new_value", "comment", "created_at"},
"config": {"key", "value"},
"metadata": {"key", "value"},
"dirty_issues": {"issue_id", "marked_at"},
"export_hashes": {"issue_id", "content_hash", "exported_at"},
"child_counters": {"parent_id", "last_child"},
"issue_snapshots": {"id", "issue_id", "snapshot_time", "compaction_level", "original_size", "compressed_size", "original_content", "archived_events"},
"compaction_snapshots": {"id", "issue_id", "compaction_level", "snapshot_json", "created_at"},
"repo_mtimes": {"repo_path", "jsonl_path", "mtime_ns", "last_checked"},
"repo_mtimes": {"repo_path", "jsonl_path", "mtime_ns", "last_checked"},
}
// SchemaProbeResult contains the results of a schema compatibility check
type SchemaProbeResult struct {
Compatible bool
MissingTables []string
MissingColumns map[string][]string // table -> missing columns
ErrorMessage string
Compatible bool
MissingTables []string
MissingColumns map[string][]string // table -> missing columns
ErrorMessage string
}
// probeSchema verifies all expected tables and columns exist
@@ -52,19 +52,19 @@ func probeSchema(db *sql.DB) SchemaProbeResult {
for table, expectedCols := range expectedSchema {
// Try to query the table with all expected columns
query := fmt.Sprintf("SELECT %s FROM %s LIMIT 0", strings.Join(expectedCols, ", "), table)
query := fmt.Sprintf("SELECT %s FROM %s LIMIT 0", strings.Join(expectedCols, ", "), table) // #nosec G201 -- table/column names sourced from hardcoded schema
_, err := db.Exec(query)
if err != nil {
errMsg := err.Error()
// Check if table doesn't exist
if strings.Contains(errMsg, "no such table") {
result.Compatible = false
result.MissingTables = append(result.MissingTables, table)
continue
}
// Check if column doesn't exist
if strings.Contains(errMsg, "no such column") {
result.Compatible = false
@@ -97,25 +97,25 @@ func probeSchema(db *sql.DB) SchemaProbeResult {
// findMissingColumns determines which columns are missing from a table
func findMissingColumns(db *sql.DB, table string, expectedCols []string) []string {
missing := []string{}
for _, col := range expectedCols {
query := fmt.Sprintf("SELECT %s FROM %s LIMIT 0", col, table)
query := fmt.Sprintf("SELECT %s FROM %s LIMIT 0", col, table) // #nosec G201 -- table/column names sourced from hardcoded schema
_, err := db.Exec(query)
if err != nil && strings.Contains(err.Error(), "no such column") {
missing = append(missing, col)
}
}
return missing
}
// verifySchemaCompatibility runs schema probe and returns detailed error on failure
func verifySchemaCompatibility(db *sql.DB) error {
result := probeSchema(db)
if !result.Compatible {
return fmt.Errorf("%w: %s", ErrSchemaIncompatible, result.ErrorMessage)
}
return nil
}
+38 -32
View File
@@ -14,10 +14,10 @@ import (
"time"
// Import SQLite driver
"github.com/steveyegge/beads/internal/types"
sqlite3 "github.com/ncruces/go-sqlite3"
_ "github.com/ncruces/go-sqlite3/driver"
_ "github.com/ncruces/go-sqlite3/embed"
"github.com/steveyegge/beads/internal/types"
"github.com/tetratelabs/wazero"
)
@@ -98,7 +98,7 @@ func New(path string) (*SQLiteStorage, error) {
return nil, fmt.Errorf("failed to create directory: %w", err)
}
// Use file URI with pragmas
connStr = "file:" + path + "?_pragma=journal_mode(WAL)&_pragma=foreign_keys(ON)&_pragma=busy_timeout(30000)&_time_format=sqlite"
connStr = "file:" + path + "?_pragma=foreign_keys(ON)&_pragma=busy_timeout(30000)&_time_format=sqlite"
}
db, err := sql.Open("sqlite3", connStr)
@@ -125,6 +125,13 @@ func New(path string) (*SQLiteStorage, error) {
db.SetConnMaxLifetime(0) // SQLite doesn't need connection recycling
}
// For file-based databases, enable WAL mode once after opening the connection.
if !isInMemory {
if _, err := db.Exec("PRAGMA journal_mode=WAL"); err != nil {
return nil, fmt.Errorf("failed to enable WAL mode: %w", err)
}
}
// Test connection
if err := db.Ping(); err != nil {
return nil, fmt.Errorf("failed to ping database: %w", err)
@@ -147,7 +154,7 @@ func New(path string) (*SQLiteStorage, error) {
if retryErr := RunMigrations(db); retryErr != nil {
return nil, fmt.Errorf("migration retry failed after schema probe failure: %w (original: %v)", retryErr, err)
}
// Probe again after retry
if err := verifySchemaCompatibility(db); err != nil {
// Still failing - return fatal error with clear message
@@ -267,22 +274,22 @@ func (s *SQLiteStorage) CreateIssue(ctx context.Context, issue *types.Issue, act
if err := ValidateIssueIDPrefix(issue.ID, prefix); err != nil {
return err
}
// For hierarchical IDs (bd-a3f8e9.1), ensure parent exists
if strings.Contains(issue.ID, ".") {
// Try to resurrect entire parent chain if any parents are missing
// Use the conn-based version to participate in the same transaction
resurrected, err := s.tryResurrectParentChainWithConn(ctx, conn, issue.ID)
if err != nil {
return fmt.Errorf("failed to resurrect parent chain for %s: %w", issue.ID, err)
// Try to resurrect entire parent chain if any parents are missing
// Use the conn-based version to participate in the same transaction
resurrected, err := s.tryResurrectParentChainWithConn(ctx, conn, issue.ID)
if err != nil {
return fmt.Errorf("failed to resurrect parent chain for %s: %w", issue.ID, err)
}
if !resurrected {
// Parent(s) not found in JSONL history - cannot proceed
lastDot := strings.LastIndex(issue.ID, ".")
parentID := issue.ID[:lastDot]
return fmt.Errorf("parent issue %s does not exist and could not be resurrected from JSONL history", parentID)
}
}
if !resurrected {
// Parent(s) not found in JSONL history - cannot proceed
lastDot := strings.LastIndex(issue.ID, ".")
parentID := issue.ID[:lastDot]
return fmt.Errorf("parent issue %s does not exist and could not be resurrected from JSONL history", parentID)
}
}
}
// Insert issue
@@ -498,14 +505,14 @@ func determineEventType(oldIssue *types.Issue, updates map[string]interface{}) t
// manageClosedAt automatically manages the closed_at field based on status changes
func manageClosedAt(oldIssue *types.Issue, updates map[string]interface{}, setClauses []string, args []interface{}) ([]string, []interface{}) {
statusVal, hasStatus := updates["status"]
// If closed_at is explicitly provided in updates, it's already in setClauses/args
// and we should not override it (important for import operations that preserve timestamps)
_, hasExplicitClosedAt := updates["closed_at"]
if hasExplicitClosedAt {
return setClauses, args
}
if !hasStatus {
return setClauses, args
}
@@ -1365,7 +1372,7 @@ func (s *SQLiteStorage) GetOrphanHandling(ctx context.Context) OrphanHandling {
if err != nil || value == "" {
return OrphanAllow // Default
}
switch OrphanHandling(value) {
case OrphanStrict, OrphanResurrect, OrphanSkip, OrphanAllow:
return OrphanHandling(value)
@@ -1494,26 +1501,26 @@ func (s *SQLiteStorage) IsClosed() bool {
// IMPORTANT SAFETY RULES:
//
// 1. DO NOT call Close() on the returned *sql.DB
// - The SQLiteStorage owns the connection lifecycle
// - Closing it will break all storage operations
// - Use storage.Close() to close the database
// - The SQLiteStorage owns the connection lifecycle
// - Closing it will break all storage operations
// - Use storage.Close() to close the database
//
// 2. DO NOT modify connection pool settings
// - Avoid SetMaxOpenConns, SetMaxIdleConns, SetConnMaxLifetime, etc.
// - The storage has already configured these for optimal performance
// - Avoid SetMaxOpenConns, SetMaxIdleConns, SetConnMaxLifetime, etc.
// - The storage has already configured these for optimal performance
//
// 3. DO NOT change SQLite PRAGMAs
// - The database is configured with WAL mode, foreign keys, and busy timeout
// - Changing these (e.g., journal_mode, synchronous, locking_mode) can cause corruption
// - The database is configured with WAL mode, foreign keys, and busy timeout
// - Changing these (e.g., journal_mode, synchronous, locking_mode) can cause corruption
//
// 4. Expect errors after storage.Close()
// - Check storage.IsClosed() before long-running operations if needed
// - Pass contexts with timeouts to prevent hanging on closed connections
// - Check storage.IsClosed() before long-running operations if needed
// - Pass contexts with timeouts to prevent hanging on closed connections
//
// 5. Keep write transactions SHORT
// - SQLite has a single-writer lock even in WAL mode
// - Long-running write transactions will block core storage operations
// - Use read transactions (BEGIN DEFERRED) when possible
// - SQLite has a single-writer lock even in WAL mode
// - Long-running write transactions will block core storage operations
// - Use read transactions (BEGIN DEFERRED) when possible
//
// GOOD PRACTICES:
//
@@ -1535,7 +1542,6 @@ func (s *SQLiteStorage) IsClosed() bool {
// );
// CREATE INDEX IF NOT EXISTS idx_vc_executions_issue ON vc_executions(issue_id);
// `)
//
func (s *SQLiteStorage) UnderlyingDB() *sql.DB {
return s.db
}
+10 -4
View File
@@ -19,24 +19,30 @@ import (
// - For shared memory (not recommended): ":memory:"
func newTestStore(t *testing.T, dbPath string) *SQLiteStorage {
t.Helper()
// Default to temp file for test isolation
// File-based databases are more reliable than in-memory for connection pool scenarios
if dbPath == "" {
dbPath = t.TempDir() + "/test.db"
}
store, err := New(dbPath)
if err != nil {
t.Fatalf("Failed to create test database: %v", err)
}
t.Cleanup(func() {
if cerr := store.Close(); cerr != nil {
t.Fatalf("Failed to close test database: %v", cerr)
}
})
// CRITICAL (bd-166): Set issue_prefix to prevent "database not initialized" errors
ctx := context.Background()
if err := store.SetConfig(ctx, "issue_prefix", "bd"); err != nil {
_ = store.Close()
t.Fatalf("Failed to set issue_prefix: %v", err)
}
return store
}
+31 -29
View File
@@ -90,47 +90,47 @@ var taskTitles = []string{
// DataConfig controls the distribution and characteristics of generated test data
type DataConfig struct {
TotalIssues int // total number of issues to generate
EpicRatio float64 // percentage of issues that are epics (e.g., 0.1 for 10%)
FeatureRatio float64 // percentage of issues that are features (e.g., 0.3 for 30%)
OpenRatio float64 // percentage of issues that are open (e.g., 0.5 for 50%)
CrossLinkRatio float64 // percentage of tasks with cross-epic blocking dependencies (e.g., 0.2 for 20%)
MaxEpicAgeDays int // maximum age in days for epics (e.g., 180)
MaxFeatureAgeDays int // maximum age in days for features (e.g., 150)
MaxTaskAgeDays int // maximum age in days for tasks (e.g., 120)
MaxClosedAgeDays int // maximum days since closure (e.g., 30)
RandSeed int64 // random seed for reproducibility
TotalIssues int // total number of issues to generate
EpicRatio float64 // percentage of issues that are epics (e.g., 0.1 for 10%)
FeatureRatio float64 // percentage of issues that are features (e.g., 0.3 for 30%)
OpenRatio float64 // percentage of issues that are open (e.g., 0.5 for 50%)
CrossLinkRatio float64 // percentage of tasks with cross-epic blocking dependencies (e.g., 0.2 for 20%)
MaxEpicAgeDays int // maximum age in days for epics (e.g., 180)
MaxFeatureAgeDays int // maximum age in days for features (e.g., 150)
MaxTaskAgeDays int // maximum age in days for tasks (e.g., 120)
MaxClosedAgeDays int // maximum days since closure (e.g., 30)
RandSeed int64 // random seed for reproducibility
}
// DefaultLargeConfig returns configuration for 10K issue dataset
func DefaultLargeConfig() DataConfig {
return DataConfig{
TotalIssues: 10000,
EpicRatio: 0.1,
FeatureRatio: 0.3,
OpenRatio: 0.5,
CrossLinkRatio: 0.2,
MaxEpicAgeDays: 180,
TotalIssues: 10000,
EpicRatio: 0.1,
FeatureRatio: 0.3,
OpenRatio: 0.5,
CrossLinkRatio: 0.2,
MaxEpicAgeDays: 180,
MaxFeatureAgeDays: 150,
MaxTaskAgeDays: 120,
MaxClosedAgeDays: 30,
RandSeed: 42,
MaxTaskAgeDays: 120,
MaxClosedAgeDays: 30,
RandSeed: 42,
}
}
// DefaultXLargeConfig returns configuration for 20K issue dataset
func DefaultXLargeConfig() DataConfig {
return DataConfig{
TotalIssues: 20000,
EpicRatio: 0.1,
FeatureRatio: 0.3,
OpenRatio: 0.5,
CrossLinkRatio: 0.2,
MaxEpicAgeDays: 180,
TotalIssues: 20000,
EpicRatio: 0.1,
FeatureRatio: 0.3,
OpenRatio: 0.5,
CrossLinkRatio: 0.2,
MaxEpicAgeDays: 180,
MaxFeatureAgeDays: 150,
MaxTaskAgeDays: 120,
MaxClosedAgeDays: 30,
RandSeed: 43,
MaxTaskAgeDays: 120,
MaxClosedAgeDays: 30,
RandSeed: 43,
}
}
@@ -162,7 +162,7 @@ func XLargeFromJSONL(ctx context.Context, store storage.Storage, tempDir string)
// generateIssuesWithConfig creates issues with realistic epic hierarchies and cross-links using provided configuration
func generateIssuesWithConfig(ctx context.Context, store storage.Storage, cfg DataConfig) error {
rng := rand.New(rand.NewSource(cfg.RandSeed))
rng := rand.New(rand.NewSource(cfg.RandSeed)) // #nosec G404 -- deterministic math/rand used for repeatable fixture data
// Calculate breakdown using configuration ratios
numEpics := int(float64(cfg.TotalIssues) * cfg.EpicRatio)
@@ -403,6 +403,7 @@ func exportToJSONL(ctx context.Context, store storage.Storage, path string) erro
}
// Write to JSONL file
// #nosec G304 -- fixture exports to deterministic file controlled by tests
f, err := os.Create(path)
if err != nil {
return fmt.Errorf("failed to create JSONL file: %w", err)
@@ -422,6 +423,7 @@ func exportToJSONL(ctx context.Context, store storage.Storage, path string) erro
// importFromJSONL imports issues from a JSONL file
func importFromJSONL(ctx context.Context, store storage.Storage, path string) error {
// Read JSONL file
// #nosec G304 -- fixture imports from deterministic file created earlier in test
data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("failed to read JSONL file: %w", err)