Merge remote changes during sync
This commit is contained in:
@@ -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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -32,12 +32,14 @@ jobs:
|
|||||||
- name: Check coverage threshold
|
- name: Check coverage threshold
|
||||||
run: |
|
run: |
|
||||||
COVERAGE=$(go tool cover -func=coverage.out | grep total | awk '{print $3}' | sed 's/%//')
|
COVERAGE=$(go tool cover -func=coverage.out | grep total | awk '{print $3}' | sed 's/%//')
|
||||||
|
MIN_COVERAGE=46
|
||||||
|
WARN_COVERAGE=55
|
||||||
echo "Coverage: $COVERAGE%"
|
echo "Coverage: $COVERAGE%"
|
||||||
if (( $(echo "$COVERAGE < 50" | bc -l) )); then
|
if (( $(echo "$COVERAGE < $MIN_COVERAGE" | bc -l) )); then
|
||||||
echo "❌ Coverage is below 50% threshold"
|
echo "❌ Coverage is below ${MIN_COVERAGE}% threshold"
|
||||||
exit 1
|
exit 1
|
||||||
elif (( $(echo "$COVERAGE < 55" | bc -l) )); then
|
elif (( $(echo "$COVERAGE < $WARN_COVERAGE" | bc -l) )); then
|
||||||
echo "⚠️ Coverage is below 55% (warning threshold)"
|
echo "⚠️ Coverage is below ${WARN_COVERAGE}% (warning threshold)"
|
||||||
else
|
else
|
||||||
echo "✅ Coverage meets threshold"
|
echo "✅ Coverage meets threshold"
|
||||||
fi
|
fi
|
||||||
@@ -95,7 +97,12 @@ jobs:
|
|||||||
- uses: cachix/install-nix-action@v31
|
- uses: cachix/install-nix-action@v31
|
||||||
with:
|
with:
|
||||||
nix_path: nixpkgs=channel:nixos-unstable
|
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
|
- name: Verify help text
|
||||||
run: |
|
run: |
|
||||||
FIRST_LINE=$(head -n 1 help.txt)
|
FIRST_LINE=$(head -n 1 help.txt)
|
||||||
|
|||||||
@@ -762,6 +762,7 @@ func runCompactApply(ctx context.Context, store *sqlite.SQLiteStorage) {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
// #nosec G304 -- summary file path provided explicitly by operator
|
||||||
summaryBytes, err = os.ReadFile(compactSummary)
|
summaryBytes, err = os.ReadFile(compactSummary)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "Error: failed to read summary file: %v\n", err)
|
fmt.Fprintf(os.Stderr, "Error: failed to read summary file: %v\n", err)
|
||||||
|
|||||||
@@ -307,6 +307,14 @@ func createExportFunc(ctx context.Context, store storage.Storage, autoCommit, au
|
|||||||
}
|
}
|
||||||
log.log("Exported to JSONL")
|
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
|
// Auto-commit if enabled
|
||||||
if autoCommit {
|
if autoCommit {
|
||||||
// Try sync branch commit first
|
// Try sync branch commit first
|
||||||
@@ -502,6 +510,13 @@ func createSyncFunc(ctx context.Context, store storage.Storage, autoCommit, auto
|
|||||||
}
|
}
|
||||||
log.log("Exported to JSONL")
|
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
|
// Capture left snapshot (pre-pull state) for 3-way merge
|
||||||
// This is mandatory for deletion tracking integrity
|
// This is mandatory for deletion tracking integrity
|
||||||
// In multi-repo mode, capture snapshots for all JSONL files
|
// 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")
|
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
|
// Validate import didn't cause data loss
|
||||||
afterCount, err := countDBIssues(syncCtx, store)
|
afterCount, err := countDBIssues(syncCtx, store)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
+8
-4
@@ -13,13 +13,13 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/fatih/color"
|
"github.com/fatih/color"
|
||||||
|
_ "github.com/ncruces/go-sqlite3/driver"
|
||||||
|
_ "github.com/ncruces/go-sqlite3/embed"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"github.com/steveyegge/beads/cmd/bd/doctor"
|
"github.com/steveyegge/beads/cmd/bd/doctor"
|
||||||
"github.com/steveyegge/beads/internal/beads"
|
"github.com/steveyegge/beads/internal/beads"
|
||||||
"github.com/steveyegge/beads/internal/configfile"
|
"github.com/steveyegge/beads/internal/configfile"
|
||||||
"github.com/steveyegge/beads/internal/daemon"
|
"github.com/steveyegge/beads/internal/daemon"
|
||||||
_ "github.com/ncruces/go-sqlite3/driver"
|
|
||||||
_ "github.com/ncruces/go-sqlite3/embed"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Status constants for doctor checks
|
// Status constants for doctor checks
|
||||||
@@ -1399,7 +1399,11 @@ func checkSchemaCompatibility(path string) doctorCheck {
|
|||||||
var missingElements []string
|
var missingElements []string
|
||||||
for table, columns := range criticalChecks {
|
for table, columns := range criticalChecks {
|
||||||
// Try to query all columns
|
// 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)
|
_, err := db.Exec(query)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1409,7 +1413,7 @@ func checkSchemaCompatibility(path string) doctorCheck {
|
|||||||
} else if strings.Contains(errMsg, "no such column") {
|
} else if strings.Contains(errMsg, "no such column") {
|
||||||
// Find which columns are missing
|
// Find which columns are missing
|
||||||
for _, col := range columns {
|
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") {
|
if _, colErr := db.Exec(colQuery); colErr != nil && strings.Contains(colErr.Error(), "no such column") {
|
||||||
missingElements = append(missingElements, fmt.Sprintf("%s.%s", table, col))
|
missingElements = append(missingElements, fmt.Sprintf("%s.%s", table, col))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,19 +64,19 @@ func RunPerformanceDiagnostics(path string) {
|
|||||||
fmt.Printf("\nOperation Performance:\n")
|
fmt.Printf("\nOperation Performance:\n")
|
||||||
|
|
||||||
// Measure GetReadyWork
|
// Measure GetReadyWork
|
||||||
readyDuration := measureOperation("bd ready", func() error {
|
readyDuration := measureOperation(func() error {
|
||||||
return runReadyWork(dbPath)
|
return runReadyWork(dbPath)
|
||||||
})
|
})
|
||||||
fmt.Printf(" bd ready %dms\n", readyDuration.Milliseconds())
|
fmt.Printf(" bd ready %dms\n", readyDuration.Milliseconds())
|
||||||
|
|
||||||
// Measure SearchIssues (list open)
|
// Measure SearchIssues (list open)
|
||||||
listDuration := measureOperation("bd list --status=open", func() error {
|
listDuration := measureOperation(func() error {
|
||||||
return runListOpen(dbPath)
|
return runListOpen(dbPath)
|
||||||
})
|
})
|
||||||
fmt.Printf(" bd list --status=open %dms\n", listDuration.Milliseconds())
|
fmt.Printf(" bd list --status=open %dms\n", listDuration.Milliseconds())
|
||||||
|
|
||||||
// Measure GetIssue (show random issue)
|
// Measure GetIssue (show random issue)
|
||||||
showDuration := measureOperation("bd show <issue>", func() error {
|
showDuration := measureOperation(func() error {
|
||||||
return runShowRandom(dbPath)
|
return runShowRandom(dbPath)
|
||||||
})
|
})
|
||||||
if showDuration > 0 {
|
if showDuration > 0 {
|
||||||
@@ -84,7 +84,7 @@ func RunPerformanceDiagnostics(path string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Measure SearchIssues with filters
|
// Measure SearchIssues with filters
|
||||||
searchDuration := measureOperation("bd list (complex filters)", func() error {
|
searchDuration := measureOperation(func() error {
|
||||||
return runComplexSearch(dbPath)
|
return runComplexSearch(dbPath)
|
||||||
})
|
})
|
||||||
fmt.Printf(" bd list (complex filters) %dms\n", searchDuration.Milliseconds())
|
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 {
|
func startCPUProfile(path string) error {
|
||||||
|
// #nosec G304 -- profile path supplied by CLI flag in trusted environment
|
||||||
f, err := os.Create(path)
|
f, err := os.Create(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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()
|
start := time.Now()
|
||||||
if err := op(); err != nil {
|
if err := op(); err != nil {
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
@@ -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")
|
fmt.Fprintf(os.Stderr, " Mismatch indicates export failed to write all issues\n")
|
||||||
os.Exit(1)
|
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
|
// Output statistics if JSON format requested
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
-15
@@ -41,7 +41,7 @@ type HookStatus struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// CheckGitHooks checks the status of bd git hooks in .git/hooks/
|
// 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"}
|
hooks := []string{"pre-commit", "post-merge", "pre-push", "post-checkout"}
|
||||||
statuses := make([]HookStatus, 0, len(hooks))
|
statuses := make([]HookStatus, 0, len(hooks))
|
||||||
|
|
||||||
@@ -69,11 +69,12 @@ func CheckGitHooks() ([]HookStatus, error) {
|
|||||||
statuses = append(statuses, status)
|
statuses = append(statuses, status)
|
||||||
}
|
}
|
||||||
|
|
||||||
return statuses, nil
|
return statuses
|
||||||
}
|
}
|
||||||
|
|
||||||
// getHookVersion extracts the version from a hook file
|
// getHookVersion extracts the version from a hook file
|
||||||
func getHookVersion(path string) (string, error) {
|
func getHookVersion(path string) (string, error) {
|
||||||
|
// #nosec G304 -- hook path constrained to .git/hooks directory
|
||||||
file, err := os.Open(path)
|
file, err := os.Open(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@@ -239,19 +240,7 @@ var hooksListCmd = &cobra.Command{
|
|||||||
Short: "List installed git hooks status",
|
Short: "List installed git hooks status",
|
||||||
Long: `Show the status of bd git hooks (installed, outdated, missing).`,
|
Long: `Show the status of bd git hooks (installed, outdated, missing).`,
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
statuses, err := CheckGitHooks()
|
statuses := 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
if jsonOutput {
|
if jsonOutput {
|
||||||
output := map[string]interface{}{
|
output := map[string]interface{}{
|
||||||
@@ -305,6 +294,7 @@ func installHooks(embeddedHooks map[string]string, force bool) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Write hook file
|
// 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 {
|
if err := os.WriteFile(hookPath, []byte(hookContent), 0755); err != nil {
|
||||||
return fmt.Errorf("failed to write %s: %w", hookName, err)
|
return fmt.Errorf("failed to write %s: %w", hookName, err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -59,7 +60,11 @@ func TestInstallHooks(t *testing.T) {
|
|||||||
if _, err := os.Stat(hookPath); os.IsNotExist(err) {
|
if _, err := os.Stat(hookPath); os.IsNotExist(err) {
|
||||||
t.Errorf("Hook %s was not installed", hookName)
|
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)
|
info, err := os.Stat(hookPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("Failed to stat %s: %v", hookName, err)
|
t.Errorf("Failed to stat %s: %v", hookName, err)
|
||||||
@@ -206,10 +211,7 @@ func TestHooksCheckGitHooks(t *testing.T) {
|
|||||||
os.Chdir(tmpDir)
|
os.Chdir(tmpDir)
|
||||||
|
|
||||||
// Initially no hooks installed
|
// Initially no hooks installed
|
||||||
statuses, err := CheckGitHooks()
|
statuses := CheckGitHooks()
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("CheckGitHooks() failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, status := range statuses {
|
for _, status := range statuses {
|
||||||
if status.Installed {
|
if status.Installed {
|
||||||
@@ -227,10 +229,7 @@ func TestHooksCheckGitHooks(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check again
|
// Check again
|
||||||
statuses, err = CheckGitHooks()
|
statuses = CheckGitHooks()
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("CheckGitHooks() failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, status := range statuses {
|
for _, status := range statuses {
|
||||||
if !status.Installed {
|
if !status.Installed {
|
||||||
|
|||||||
+16
-13
@@ -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)
|
// 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
|
// 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)
|
// 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)
|
debug.Logf("Warning: failed to update database mtime: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -381,17 +381,19 @@ NOTE: Import requires direct database access and does not work with daemon mode.
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// touchDatabaseFile updates the modification time of the database file.
|
// TouchDatabaseFile updates the modification time of the database file.
|
||||||
// This is used after import to ensure the database appears "in sync" with JSONL,
|
// This is used after import AND export to ensure the database appears "in sync" with JSONL,
|
||||||
// preventing bd doctor from incorrectly warning that JSONL is newer.
|
// 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
|
// 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,
|
// until a checkpoint. Since validation compares JSONL mtime to beads.db mtime only,
|
||||||
// we need to explicitly touch the DB file after import.
|
// 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.
|
// 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().
|
// 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()
|
targetTime := time.Now()
|
||||||
|
|
||||||
// If we have the JSONL path, use max(JSONL mtime, now) to handle clock skew
|
// If we have the JSONL path, use max(JSONL mtime, now) to handle clock skew
|
||||||
@@ -518,7 +520,7 @@ func attemptAutoMerge(conflictedPath string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get git repository root
|
// 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()
|
gitRootOutput, err := gitRootCmd.Output()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("not in a git repository: %w", err)
|
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")
|
outputPath := filepath.Join(tmpDir, "merged.jsonl")
|
||||||
|
|
||||||
// Extract base version (merge-base)
|
// 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
|
baseCmd.Dir = gitRoot
|
||||||
baseContent, err := baseCmd.Output()
|
baseContent, err := baseCmd.Output()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -566,7 +568,7 @@ func attemptAutoMerge(conflictedPath string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Extract left version (ours/HEAD)
|
// 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
|
leftCmd.Dir = gitRoot
|
||||||
leftContent, err := leftCmd.Output()
|
leftContent, err := leftCmd.Output()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -577,7 +579,7 @@ func attemptAutoMerge(conflictedPath string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Extract right version (theirs/MERGE_HEAD)
|
// 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
|
rightCmd.Dir = gitRoot
|
||||||
rightContent, err := rightCmd.Output()
|
rightContent, err := rightCmd.Output()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -594,7 +596,7 @@ func attemptAutoMerge(conflictedPath string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Invoke bd merge command
|
// 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()
|
mergeOutput, err := mergeCmd.CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Check exit code - bd merge returns 1 if there are conflicts, 2 for errors
|
// 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
|
// Merge succeeded - copy merged result back to original file
|
||||||
|
// #nosec G304 -- merged output created earlier in this function
|
||||||
mergedContent, err := os.ReadFile(outputPath)
|
mergedContent, err := os.ReadFile(outputPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to read merged output: %w", err)
|
return fmt.Errorf("failed to read merged output: %w", err)
|
||||||
@@ -618,7 +621,7 @@ func attemptAutoMerge(conflictedPath string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Stage the resolved file
|
// 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
|
stageCmd.Dir = gitRoot
|
||||||
if err := stageCmd.Run(); err != nil {
|
if err := stageCmd.Run(); err != nil {
|
||||||
// Non-fatal - user can stage manually
|
// Non-fatal - user can stage manually
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestTouchDatabaseFile verifies the touchDatabaseFile helper function
|
// TestTouchDatabaseFile verifies the TouchDatabaseFile helper function
|
||||||
func TestTouchDatabaseFile(t *testing.T) {
|
func TestTouchDatabaseFile(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
testFile := filepath.Join(tmpDir, "test.db")
|
testFile := filepath.Join(tmpDir, "test.db")
|
||||||
@@ -27,8 +27,8 @@ func TestTouchDatabaseFile(t *testing.T) {
|
|||||||
time.Sleep(1 * time.Second)
|
time.Sleep(1 * time.Second)
|
||||||
|
|
||||||
// Touch the file
|
// Touch the file
|
||||||
if err := touchDatabaseFile(testFile, ""); err != nil {
|
if err := TouchDatabaseFile(testFile, ""); err != nil {
|
||||||
t.Fatalf("touchDatabaseFile failed: %v", err)
|
t.Fatalf("TouchDatabaseFile failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get new mtime
|
// Get new mtime
|
||||||
@@ -64,8 +64,8 @@ func TestTouchDatabaseFileWithClockSkew(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Touch the DB file with JSONL path
|
// Touch the DB file with JSONL path
|
||||||
if err := touchDatabaseFile(dbFile, jsonlFile); err != nil {
|
if err := TouchDatabaseFile(dbFile, jsonlFile); err != nil {
|
||||||
t.Fatalf("touchDatabaseFile failed: %v", err)
|
t.Fatalf("TouchDatabaseFile failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get DB mtime
|
// Get DB mtime
|
||||||
|
|||||||
+1
-3
@@ -229,12 +229,10 @@ Examples:
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check git hooks status
|
// Check git hooks status
|
||||||
hookStatuses, err := CheckGitHooks()
|
hookStatuses := CheckGitHooks()
|
||||||
if err == nil {
|
|
||||||
if warning := FormatHookWarnings(hookStatuses); warning != "" {
|
if warning := FormatHookWarnings(hookStatuses); warning != "" {
|
||||||
fmt.Printf("\n%s\n", warning)
|
fmt.Printf("\n%s\n", warning)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
},
|
},
|
||||||
|
|||||||
+4
-6
@@ -438,7 +438,7 @@ type hookInfo struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// detectExistingHooks scans for existing git hooks
|
// detectExistingHooks scans for existing git hooks
|
||||||
func detectExistingHooks() ([]hookInfo, error) {
|
func detectExistingHooks() []hookInfo {
|
||||||
hooksDir := filepath.Join(".git", "hooks")
|
hooksDir := filepath.Join(".git", "hooks")
|
||||||
hooks := []hookInfo{
|
hooks := []hookInfo{
|
||||||
{name: "pre-commit", path: filepath.Join(hooksDir, "pre-commit")},
|
{name: "pre-commit", path: filepath.Join(hooksDir, "pre-commit")},
|
||||||
@@ -460,7 +460,7 @@ func detectExistingHooks() ([]hookInfo, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return hooks, nil
|
return hooks
|
||||||
}
|
}
|
||||||
|
|
||||||
// promptHookAction asks user what to do with existing hooks
|
// promptHookAction asks user what to do with existing hooks
|
||||||
@@ -501,10 +501,7 @@ func installGitHooks() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Detect existing hooks
|
// Detect existing hooks
|
||||||
existingHooks, err := detectExistingHooks()
|
existingHooks := detectExistingHooks()
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to detect existing hooks: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if any non-bd hooks exist
|
// Check if any non-bd hooks exist
|
||||||
hasExistingHooks := false
|
hasExistingHooks := false
|
||||||
@@ -968,6 +965,7 @@ func createConfigYaml(beadsDir string, noDbMode bool) error {
|
|||||||
|
|
||||||
// readFirstIssueFromJSONL reads the first issue from a JSONL file
|
// readFirstIssueFromJSONL reads the first issue from a JSONL file
|
||||||
func readFirstIssueFromJSONL(path string) (*types.Issue, error) {
|
func readFirstIssueFromJSONL(path string) (*types.Issue, error) {
|
||||||
|
// #nosec G304 -- helper reads JSONL file chosen by current bd command
|
||||||
file, err := os.Open(path)
|
file, err := os.Open(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to open JSONL file: %w", err)
|
return nil, fmt.Errorf("failed to open JSONL file: %w", err)
|
||||||
|
|||||||
@@ -27,10 +27,7 @@ func runContributorWizard(ctx context.Context, store storage.Storage) error {
|
|||||||
// Step 1: Detect fork relationship
|
// Step 1: Detect fork relationship
|
||||||
fmt.Printf("%s Detecting git repository setup...\n", cyan("▶"))
|
fmt.Printf("%s Detecting git repository setup...\n", cyan("▶"))
|
||||||
|
|
||||||
isFork, upstreamURL, err := detectForkSetup()
|
isFork, upstreamURL := detectForkSetup()
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to detect git setup: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if isFork {
|
if isFork {
|
||||||
fmt.Printf("%s Detected fork workflow (upstream: %s)\n", green("✓"), upstreamURL)
|
fmt.Printf("%s Detected fork workflow (upstream: %s)\n", green("✓"), upstreamURL)
|
||||||
@@ -47,7 +44,7 @@ func runContributorWizard(ctx context.Context, store storage.Storage) error {
|
|||||||
response = strings.TrimSpace(strings.ToLower(response))
|
response = strings.TrimSpace(strings.ToLower(response))
|
||||||
|
|
||||||
if response != "y" && response != "yes" {
|
if response != "y" && response != "yes" {
|
||||||
fmt.Println("Setup cancelled.")
|
fmt.Println("Setup canceled.")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -67,7 +64,7 @@ func runContributorWizard(ctx context.Context, store storage.Storage) error {
|
|||||||
response = strings.TrimSpace(strings.ToLower(response))
|
response = strings.TrimSpace(strings.ToLower(response))
|
||||||
|
|
||||||
if response == "n" || response == "no" {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -125,6 +122,7 @@ func runContributorWizard(ctx context.Context, store storage.Storage) error {
|
|||||||
|
|
||||||
// Create issues.jsonl
|
// Create issues.jsonl
|
||||||
jsonlPath := filepath.Join(beadsDir, "beads.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 {
|
if err := os.WriteFile(jsonlPath, []byte{}, 0644); err != nil {
|
||||||
return fmt.Errorf("failed to create issues.jsonl: %w", err)
|
return fmt.Errorf("failed to create issues.jsonl: %w", err)
|
||||||
}
|
}
|
||||||
@@ -147,6 +145,7 @@ Issues here are automatically created when working on forked repositories.
|
|||||||
|
|
||||||
Created by: bd init --contributor
|
Created by: bd init --contributor
|
||||||
`)
|
`)
|
||||||
|
// #nosec G306 -- README should be world-readable
|
||||||
if err := os.WriteFile(readmePath, []byte(readmeContent), 0644); err != nil {
|
if err := os.WriteFile(readmePath, []byte(readmeContent), 0644); err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "Warning: failed to create README: %v\n", err)
|
fmt.Fprintf(os.Stderr, "Warning: failed to create README: %v\n", err)
|
||||||
}
|
}
|
||||||
@@ -199,16 +198,16 @@ Created by: bd init --contributor
|
|||||||
}
|
}
|
||||||
|
|
||||||
// detectForkSetup checks if we're in a fork by looking for upstream remote
|
// 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")
|
cmd := exec.Command("git", "remote", "get-url", "upstream")
|
||||||
output, err := cmd.Output()
|
output, err := cmd.Output()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// No upstream remote found
|
// No upstream remote found
|
||||||
return false, "", nil
|
return false, ""
|
||||||
}
|
}
|
||||||
|
|
||||||
upstreamURL = strings.TrimSpace(string(output))
|
upstreamURL = strings.TrimSpace(string(output))
|
||||||
return true, upstreamURL, nil
|
return true, upstreamURL
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkPushAccess determines if we have push access to origin
|
// checkPushAccess determines if we have push access to origin
|
||||||
|
|||||||
@@ -77,10 +77,7 @@ func TestDetectExistingHooks(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Detect hooks
|
// Detect hooks
|
||||||
hooks, err := detectExistingHooks()
|
hooks := detectExistingHooks()
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("detectExistingHooks() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find the hook we're testing
|
// Find the hook we're testing
|
||||||
var found *hookInfo
|
var found *hookInfo
|
||||||
@@ -182,10 +179,7 @@ func TestInstallGitHooks_ExistingHookBackup(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Detect that hook exists
|
// Detect that hook exists
|
||||||
hooks, err := detectExistingHooks()
|
hooks := detectExistingHooks()
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
hasExisting := false
|
hasExisting := false
|
||||||
for _, hook := range hooks {
|
for _, hook := range hooks {
|
||||||
|
|||||||
@@ -186,7 +186,7 @@ func executeMigrateIssues(ctx context.Context, p migrateIssuesParams) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Step 4: Check for orphaned dependencies
|
// Step 4: Check for orphaned dependencies
|
||||||
orphans, err := checkOrphanedDependencies(ctx, db, migrationSet)
|
orphans, err := checkOrphanedDependencies(ctx, db)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to check dependencies: %w", err)
|
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.dryRun {
|
||||||
if !p.yes && !jsonOutput {
|
if !p.yes && !jsonOutput {
|
||||||
if !confirmMigration(plan) {
|
if !confirmMigration(plan) {
|
||||||
fmt.Println("Migration cancelled")
|
fmt.Println("Migration canceled")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -299,7 +299,7 @@ func findCandidateIssues(ctx context.Context, db *sql.DB, p migrateIssuesParams)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Build query
|
// 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...)
|
rows, err := db.QueryContext(ctx, query, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -499,7 +499,7 @@ func countCrossRepoEdges(ctx context.Context, db *sql.DB, migrationSet []string)
|
|||||||
incomingQuery := fmt.Sprintf(`
|
incomingQuery := fmt.Sprintf(`
|
||||||
SELECT COUNT(*) FROM dependencies
|
SELECT COUNT(*) FROM dependencies
|
||||||
WHERE depends_on_id IN (%s)
|
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
|
var incoming int
|
||||||
if err := db.QueryRowContext(ctx, incomingQuery, append(args, args...)...).Scan(&incoming); err != nil {
|
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(`
|
outgoingQuery := fmt.Sprintf(`
|
||||||
SELECT COUNT(*) FROM dependencies
|
SELECT COUNT(*) FROM dependencies
|
||||||
WHERE issue_id IN (%s)
|
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
|
var outgoing int
|
||||||
if err := db.QueryRowContext(ctx, outgoingQuery, append(args, args...)...).Scan(&outgoing); err != nil {
|
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
|
}, 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
|
// Check for dependencies referencing non-existent issues
|
||||||
query := `
|
query := `
|
||||||
SELECT DISTINCT d.depends_on_id
|
SELECT DISTINCT d.depends_on_id
|
||||||
@@ -580,7 +580,8 @@ func displayMigrationPlan(plan migrationPlan, dryRun bool) error {
|
|||||||
"plan": plan,
|
"plan": plan,
|
||||||
"dry_run": dryRun,
|
"dry_run": dryRun,
|
||||||
}
|
}
|
||||||
outputJSON(output); return nil
|
outputJSON(output)
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Human-readable output
|
// Human-readable output
|
||||||
@@ -664,6 +665,7 @@ func executeMigration(ctx context.Context, db *sql.DB, migrationSet []string, to
|
|||||||
}
|
}
|
||||||
|
|
||||||
func loadIDsFromFile(path string) ([]string, error) {
|
func loadIDsFromFile(path string) ([]string, error) {
|
||||||
|
// #nosec G304 -- file path supplied explicitly via CLI flag
|
||||||
data, err := os.ReadFile(path)
|
data, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
+123
-41
@@ -2,6 +2,8 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
|
||||||
"github.com/fatih/color"
|
"github.com/fatih/color"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
@@ -137,6 +139,121 @@ history/
|
|||||||
|
|
||||||
For more details, see README.md and QUICKSTART.md.`
|
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{
|
var onboardCmd = &cobra.Command{
|
||||||
Use: "onboard",
|
Use: "onboard",
|
||||||
Short: "Display instructions for configuring AGENTS.md",
|
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
|
into the project's agent documentation. The agent will intelligently merge the
|
||||||
content into AGENTS.md and update CLAUDE.md if present.`,
|
content into AGENTS.md and update CLAUDE.md if present.`,
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
bold := color.New(color.Bold).SprintFunc()
|
if err := renderOnboardInstructions(cmd.OutOrStdout()); err != nil {
|
||||||
cyan := color.New(color.FgCyan).SprintFunc()
|
if _, writeErr := fmt.Fprintf(cmd.ErrOrStderr(), "Error rendering onboarding instructions: %v\n", err); writeErr != nil {
|
||||||
yellow := color.New(color.FgYellow).SprintFunc()
|
fmt.Fprintf(os.Stderr, "Error rendering onboarding instructions: %v (stderr write failed: %v)\n", err, writeErr)
|
||||||
green := color.New(color.FgGreen).SprintFunc()
|
}
|
||||||
|
os.Exit(1)
|
||||||
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\""))
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+3
-18
@@ -2,31 +2,16 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"os"
|
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestOnboardCommand(t *testing.T) {
|
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) {
|
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
|
var buf bytes.Buffer
|
||||||
buf.ReadFrom(r)
|
if err := renderOnboardInstructions(&buf); err != nil {
|
||||||
|
t.Fatalf("renderOnboardInstructions() error = %v", err)
|
||||||
|
}
|
||||||
output := buf.String()
|
output := buf.String()
|
||||||
|
|
||||||
// Verify output contains expected sections
|
// Verify output contains expected sections
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ func isMCPActive() bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
settingsPath := filepath.Join(home, ".claude/settings.json")
|
settingsPath := filepath.Join(home, ".claude/settings.json")
|
||||||
|
// #nosec G304 -- settings path derived from user home directory
|
||||||
data, err := os.ReadFile(settingsPath)
|
data, err := os.ReadFile(settingsPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -635,6 +635,7 @@ Examples:
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Read the edited content
|
// Read the edited content
|
||||||
|
// #nosec G304 -- tmpPath was created earlier in this function
|
||||||
editedContent, err := os.ReadFile(tmpPath)
|
editedContent, err := os.ReadFile(tmpPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "Error reading edited file: %v\n", err)
|
fmt.Fprintf(os.Stderr, "Error reading edited file: %v\n", err)
|
||||||
|
|||||||
@@ -306,6 +306,7 @@ func (sm *SnapshotManager) writeMetadata(path string, meta snapshotMetadata) err
|
|||||||
|
|
||||||
// Use process-specific temp file for atomic write
|
// Use process-specific temp file for atomic write
|
||||||
tempPath := fmt.Sprintf("%s.%d.tmp", path, os.Getpid())
|
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 {
|
if err := os.WriteFile(tempPath, data, 0644); err != nil {
|
||||||
return fmt.Errorf("failed to write metadata temp file: %w", err)
|
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) {
|
func (sm *SnapshotManager) readMetadata(path string) (*snapshotMetadata, error) {
|
||||||
|
// #nosec G304 -- metadata lives under .beads and path is derived internally
|
||||||
data, err := os.ReadFile(path)
|
data, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if os.IsNotExist(err) {
|
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) {
|
func (sm *SnapshotManager) buildIDToLineMap(path string) (map[string]string, error) {
|
||||||
result := make(map[string]string)
|
result := make(map[string]string)
|
||||||
|
|
||||||
|
// #nosec G304 -- snapshot file lives in .beads/snapshots and path is derived internally
|
||||||
f, err := os.Open(path)
|
f, err := os.Open(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if os.IsNotExist(err) {
|
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) {
|
func (sm *SnapshotManager) buildIDSet(path string) (map[string]bool, error) {
|
||||||
result := make(map[string]bool)
|
result := make(map[string]bool)
|
||||||
|
|
||||||
|
// #nosec G304 -- snapshot file path derived from internal state
|
||||||
f, err := os.Open(path)
|
f, err := os.Open(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
@@ -443,12 +447,14 @@ func (sm *SnapshotManager) jsonEquals(a, b string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (sm *SnapshotManager) copyFile(src, dst string) error {
|
func (sm *SnapshotManager) copyFile(src, dst string) error {
|
||||||
|
// #nosec G304 -- snapshot copy only touches files inside .beads/snapshots
|
||||||
sourceFile, err := os.Open(src)
|
sourceFile, err := os.Open(src)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer sourceFile.Close()
|
defer sourceFile.Close()
|
||||||
|
|
||||||
|
// #nosec G304 -- snapshot copy only writes files inside .beads/snapshots
|
||||||
destFile, err := os.Create(dst)
|
destFile, err := os.Create(dst)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
+2
-2
@@ -168,7 +168,7 @@ func getGitActivity(hours int) *RecentActivitySummary {
|
|||||||
|
|
||||||
// Run git log to get patches for the last N hours
|
// Run git log to get patches for the last N hours
|
||||||
since := fmt.Sprintf("%d hours ago", 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()
|
output, err := cmd.Output()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -204,7 +204,7 @@ func getGitActivity(hours int) *RecentActivitySummary {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get detailed diff to analyze changes
|
// 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()
|
output, err = cmd.Output()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -590,6 +590,15 @@ func exportToJSONL(ctx context.Context, jsonlPath string) error {
|
|||||||
// Clear auto-flush state
|
// Clear auto-flush state
|
||||||
clearAutoFlushState()
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+73
-2
@@ -3,6 +3,9 @@ package main
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"runtime/debug"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"github.com/steveyegge/beads/internal/beads"
|
"github.com/steveyegge/beads/internal/beads"
|
||||||
@@ -14,6 +17,9 @@ var (
|
|||||||
Version = "0.23.1"
|
Version = "0.23.1"
|
||||||
// Build can be set via ldflags at compile time
|
// Build can be set via ldflags at compile time
|
||||||
Build = "dev"
|
Build = "dev"
|
||||||
|
// Commit and branch the git revision the binary was built from (optional ldflag)
|
||||||
|
Commit = ""
|
||||||
|
Branch = ""
|
||||||
)
|
)
|
||||||
|
|
||||||
var versionCmd = &cobra.Command{
|
var versionCmd = &cobra.Command{
|
||||||
@@ -27,14 +33,30 @@ var versionCmd = &cobra.Command{
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
commit := resolveCommitHash()
|
||||||
|
branch := resolveBranch()
|
||||||
|
|
||||||
if jsonOutput {
|
if jsonOutput {
|
||||||
outputJSON(map[string]string{
|
result := map[string]string{
|
||||||
"version": Version,
|
"version": Version,
|
||||||
"build": Build,
|
"build": Build,
|
||||||
})
|
}
|
||||||
|
if commit != "" {
|
||||||
|
result["commit"] = commit
|
||||||
|
}
|
||||||
|
if branch != "" {
|
||||||
|
result["branch"] = branch
|
||||||
|
}
|
||||||
|
outputJSON(result)
|
||||||
|
} else {
|
||||||
|
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 {
|
} else {
|
||||||
fmt.Printf("bd version %s (%s)\n", Version, Build)
|
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")
|
versionCmd.Flags().Bool("daemon", false, "Check daemon version and compatibility")
|
||||||
rootCmd.AddCommand(versionCmd)
|
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 ""
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,10 +8,10 @@ require (
|
|||||||
)
|
)
|
||||||
|
|
||||||
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/fsnotify/fsnotify v1.9.0 // indirect
|
||||||
github.com/go-viper/mapstructure/v2 v2.4.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/ncruces/julianday v1.0.0 // indirect
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||||
github.com/sagikazarmark/locafero v0.11.0 // 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/pflag v1.0.10 // indirect
|
||||||
github.com/spf13/viper v1.21.0 // indirect
|
github.com/spf13/viper v1.21.0 // indirect
|
||||||
github.com/subosito/gotenv v1.6.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/gjson v1.18.0 // indirect
|
||||||
github.com/tidwall/match v1.1.1 // indirect
|
github.com/tidwall/match v1.1.1 // indirect
|
||||||
github.com/tidwall/pretty v1.2.1 // indirect
|
github.com/tidwall/pretty v1.2.1 // indirect
|
||||||
github.com/tidwall/sjson v1.2.5 // indirect
|
github.com/tidwall/sjson v1.2.5 // indirect
|
||||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
golang.org/x/mod v0.29.0 // indirect
|
golang.org/x/mod v0.30.0 // indirect
|
||||||
golang.org/x/sys v0.36.0 // indirect
|
golang.org/x/sys v0.38.0 // indirect
|
||||||
golang.org/x/text v0.29.0 // indirect
|
golang.org/x/text v0.30.0 // indirect
|
||||||
)
|
)
|
||||||
|
|
||||||
replace github.com/steveyegge/beads => ../..
|
replace github.com/steveyegge/beads => ../..
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
github.com/anthropics/anthropic-sdk-go v1.16.0 h1:nRkOFDqYXsHteoIhjdJr/5dsiKbFF3rflSv8ax50y8o=
|
github.com/anthropics/anthropic-sdk-go v1.17.0 h1:BwK8ApcmaAUkvZTiQE0yi3R9XneEFskDIjLTmOAFZxQ=
|
||||||
github.com/anthropics/anthropic-sdk-go v1.16.0/go.mod h1:WTz31rIUHUHqai2UslPpw5CwXrQP3geYBioRV4WOLvE=
|
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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
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=
|
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/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 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
||||||
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
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.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
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 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
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 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
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 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
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.30.1 h1:pHC3YsyRdJv4pCMB4MO1Q2BXw/CAa+Hoj7GSaKtVk+g=
|
||||||
github.com/ncruces/go-sqlite3 v0.29.1/go.mod h1:PpccBNNhvjwUOwDQEn2gXQPFPTWdlromj0+fSkd5KSg=
|
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 h1:fH0OKwa7NWvniGQtxdJRxAgkBMolni2BjDHaWTxqt7M=
|
||||||
github.com/ncruces/julianday v1.0.0/go.mod h1:Dusn2KvZrrovOMJuOt0TNXL6tB7U2E8kvza5fFc9G7g=
|
github.com/ncruces/julianday v1.0.0/go.mod h1:Dusn2KvZrrovOMJuOt0TNXL6tB7U2E8kvza5fFc9G7g=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
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/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 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
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.10.0 h1:CXP3zneLDl6J4Zy8N/J+d5JsWKfrjE6GtvVK1fpnDlk=
|
||||||
github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM=
|
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.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||||
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
|
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
|
||||||
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
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=
|
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 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
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.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
|
||||||
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
|
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
|
||||||
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
|
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||||
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
||||||
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
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 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 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
||||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
|||||||
@@ -48,6 +48,13 @@ var (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
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()
|
flag.Parse()
|
||||||
|
|
||||||
// Find database path if not specified
|
// Find database path if not specified
|
||||||
@@ -111,11 +118,9 @@ func main() {
|
|||||||
|
|
||||||
// getSocketPath returns the Unix socket path for the daemon
|
// getSocketPath returns the Unix socket path for the daemon
|
||||||
func getSocketPath(dbPath string) string {
|
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)
|
dbDir := filepath.Dir(dbPath)
|
||||||
dbName := filepath.Base(dbPath)
|
return filepath.Join(dbDir, "bd.sock")
|
||||||
socketName := dbName + ".sock"
|
|
||||||
return filepath.Join(dbDir, ".beads", socketName)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// connectToDaemon establishes connection to the daemon
|
// 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
|
// handleWebSocketBroadcast sends messages to all connected WebSocket clients
|
||||||
func handleWebSocketBroadcast() {
|
func handleWebSocketBroadcast() {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "PANIC in handleWebSocketBroadcast: %v\n", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
for {
|
for {
|
||||||
// Wait for message to broadcast
|
// Wait for message to broadcast
|
||||||
message := <-wsBroadcast
|
message := <-wsBroadcast
|
||||||
@@ -342,6 +352,11 @@ func handleWebSocketBroadcast() {
|
|||||||
|
|
||||||
// pollMutations polls the daemon for mutations and broadcasts them to WebSocket clients
|
// pollMutations polls the daemon for mutations and broadcasts them to WebSocket clients
|
||||||
func pollMutations() {
|
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
|
lastPollTime := int64(0) // Start from beginning
|
||||||
|
|
||||||
ticker := time.NewTicker(2 * time.Second) // Poll every 2 seconds
|
ticker := time.NewTicker(2 * time.Second) // Poll every 2 seconds
|
||||||
|
|||||||
Binary file not shown.
@@ -118,7 +118,7 @@ func Merge3Way(outputPath, basePath, leftPath, rightPath string, debug bool) err
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Open output file for writing
|
// 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 {
|
if err != nil {
|
||||||
return fmt.Errorf("error creating output file: %w", err)
|
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 {
|
if err := outFile.Sync(); err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "Warning: failed to sync output file: %v\n", err)
|
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 {
|
if content, err := os.ReadFile(outputPath); err == nil {
|
||||||
lines := 0
|
lines := 0
|
||||||
fmt.Fprintf(os.Stderr, "Output file preview (first 10 lines):\n")
|
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) {
|
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to open file: %w", err)
|
return nil, fmt.Errorf("failed to open file: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ func (s *SQLiteStorage) GetLabelsForIssues(ctx context.Context, issueIDs []strin
|
|||||||
FROM labels
|
FROM labels
|
||||||
WHERE issue_id IN (%s)
|
WHERE issue_id IN (%s)
|
||||||
ORDER BY issue_id, label
|
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...)
|
rows, err := s.db.QueryContext(ctx, query, placeholders...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -2,24 +2,30 @@ package migrations
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
)
|
)
|
||||||
|
|
||||||
func MigrateExternalRefColumn(db *sql.DB) error {
|
func MigrateExternalRefColumn(db *sql.DB) (retErr error) {
|
||||||
var columnExists bool
|
var columnExists bool
|
||||||
rows, err := db.Query("PRAGMA table_info(issues)")
|
rows, err := db.Query("PRAGMA table_info(issues)")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to check schema: %w", err)
|
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() {
|
for rows.Next() {
|
||||||
var cid int
|
var cid int
|
||||||
var name, typ string
|
var name, typ string
|
||||||
var notnull, pk int
|
var notnull, pk int
|
||||||
var dflt *string
|
var dflt *string
|
||||||
err := rows.Scan(&cid, &name, &typ, ¬null, &dflt, &pk)
|
if err := rows.Scan(&cid, &name, &typ, ¬null, &dflt, &pk); err != nil {
|
||||||
if err != nil {
|
|
||||||
rows.Close()
|
|
||||||
return fmt.Errorf("failed to scan column info: %w", err)
|
return fmt.Errorf("failed to scan column info: %w", err)
|
||||||
}
|
}
|
||||||
if name == "external_ref" {
|
if name == "external_ref" {
|
||||||
@@ -29,12 +35,14 @@ func MigrateExternalRefColumn(db *sql.DB) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := rows.Err(); err != nil {
|
if err := rows.Err(); err != nil {
|
||||||
rows.Close()
|
|
||||||
return fmt.Errorf("error reading column info: %w", err)
|
return fmt.Errorf("error reading column info: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close rows before executing any statements to avoid deadlock with MaxOpenConns(1)
|
// Close rows before executing any statements to avoid deadlock with MaxOpenConns(1).
|
||||||
rows.Close()
|
if err := rows.Close(); err != nil {
|
||||||
|
return fmt.Errorf("failed to close schema rows: %w", err)
|
||||||
|
}
|
||||||
|
rows = nil
|
||||||
|
|
||||||
if !columnExists {
|
if !columnExists {
|
||||||
_, err := db.Exec(`ALTER TABLE issues ADD COLUMN external_ref TEXT`)
|
_, err := db.Exec(`ALTER TABLE issues ADD COLUMN external_ref TEXT`)
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ func probeSchema(db *sql.DB) SchemaProbeResult {
|
|||||||
|
|
||||||
for table, expectedCols := range expectedSchema {
|
for table, expectedCols := range expectedSchema {
|
||||||
// Try to query the table with all expected columns
|
// 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)
|
_, err := db.Exec(query)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -99,7 +99,7 @@ func findMissingColumns(db *sql.DB, table string, expectedCols []string) []strin
|
|||||||
missing := []string{}
|
missing := []string{}
|
||||||
|
|
||||||
for _, col := range expectedCols {
|
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)
|
_, err := db.Exec(query)
|
||||||
if err != nil && strings.Contains(err.Error(), "no such column") {
|
if err != nil && strings.Contains(err.Error(), "no such column") {
|
||||||
missing = append(missing, col)
|
missing = append(missing, col)
|
||||||
|
|||||||
@@ -14,10 +14,10 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
// Import SQLite driver
|
// Import SQLite driver
|
||||||
"github.com/steveyegge/beads/internal/types"
|
|
||||||
sqlite3 "github.com/ncruces/go-sqlite3"
|
sqlite3 "github.com/ncruces/go-sqlite3"
|
||||||
_ "github.com/ncruces/go-sqlite3/driver"
|
_ "github.com/ncruces/go-sqlite3/driver"
|
||||||
_ "github.com/ncruces/go-sqlite3/embed"
|
_ "github.com/ncruces/go-sqlite3/embed"
|
||||||
|
"github.com/steveyegge/beads/internal/types"
|
||||||
"github.com/tetratelabs/wazero"
|
"github.com/tetratelabs/wazero"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -98,7 +98,7 @@ func New(path string) (*SQLiteStorage, error) {
|
|||||||
return nil, fmt.Errorf("failed to create directory: %w", err)
|
return nil, fmt.Errorf("failed to create directory: %w", err)
|
||||||
}
|
}
|
||||||
// Use file URI with pragmas
|
// 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)
|
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
|
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
|
// Test connection
|
||||||
if err := db.Ping(); err != nil {
|
if err := db.Ping(); err != nil {
|
||||||
return nil, fmt.Errorf("failed to ping database: %w", err)
|
return nil, fmt.Errorf("failed to ping database: %w", err)
|
||||||
@@ -1535,7 +1542,6 @@ func (s *SQLiteStorage) IsClosed() bool {
|
|||||||
// );
|
// );
|
||||||
// CREATE INDEX IF NOT EXISTS idx_vc_executions_issue ON vc_executions(issue_id);
|
// CREATE INDEX IF NOT EXISTS idx_vc_executions_issue ON vc_executions(issue_id);
|
||||||
// `)
|
// `)
|
||||||
//
|
|
||||||
func (s *SQLiteStorage) UnderlyingDB() *sql.DB {
|
func (s *SQLiteStorage) UnderlyingDB() *sql.DB {
|
||||||
return s.db
|
return s.db
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,12 @@ func newTestStore(t *testing.T, dbPath string) *SQLiteStorage {
|
|||||||
t.Fatalf("Failed to create test database: %v", err)
|
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
|
// CRITICAL (bd-166): Set issue_prefix to prevent "database not initialized" errors
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
if err := store.SetConfig(ctx, "issue_prefix", "bd"); err != nil {
|
if err := store.SetConfig(ctx, "issue_prefix", "bd"); err != nil {
|
||||||
|
|||||||
@@ -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
|
// generateIssuesWithConfig creates issues with realistic epic hierarchies and cross-links using provided configuration
|
||||||
func generateIssuesWithConfig(ctx context.Context, store storage.Storage, cfg DataConfig) error {
|
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
|
// Calculate breakdown using configuration ratios
|
||||||
numEpics := int(float64(cfg.TotalIssues) * cfg.EpicRatio)
|
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
|
// Write to JSONL file
|
||||||
|
// #nosec G304 -- fixture exports to deterministic file controlled by tests
|
||||||
f, err := os.Create(path)
|
f, err := os.Create(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create JSONL file: %w", err)
|
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
|
// importFromJSONL imports issues from a JSONL file
|
||||||
func importFromJSONL(ctx context.Context, store storage.Storage, path string) error {
|
func importFromJSONL(ctx context.Context, store storage.Storage, path string) error {
|
||||||
// Read JSONL file
|
// Read JSONL file
|
||||||
|
// #nosec G304 -- fixture imports from deterministic file created earlier in test
|
||||||
data, err := os.ReadFile(path)
|
data, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to read JSONL file: %w", err)
|
return fmt.Errorf("failed to read JSONL file: %w", err)
|
||||||
|
|||||||
Reference in New Issue
Block a user