Fix bd-143: Prevent daemon auto-sync from wiping out issues.jsonl with empty database

- Added safety check to exportToJSONLWithStore (daemon path)
- Refuses to export 0 issues over non-empty JSONL file
- Added --force flag to override safety check when intentional
- Added test coverage for empty database export protection
- Prevents data loss when daemon has wrong/empty database

Amp-Thread-ID: https://ampcode.com/threads/T-de18e0ad-bd17-46ec-994b-0581e257dcde
Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
Steve Yegge
2025-10-25 16:36:18 -07:00
parent 3241b7fbfc
commit de03466da9
10 changed files with 858 additions and 41 deletions

View File

@@ -14,6 +14,31 @@ import (
"github.com/steveyegge/beads/internal/types"
)
// countIssuesInJSONL counts the number of issues in a JSONL file
func countIssuesInJSONL(path string) (int, error) {
file, err := os.Open(path)
if err != nil {
return 0, err
}
defer file.Close()
count := 0
decoder := json.NewDecoder(file)
for {
var issue types.Issue
if err := decoder.Decode(&issue); err != nil {
if err.Error() == "EOF" {
break
}
// If we hit a decode error, stop counting but return what we have
// This handles partially corrupt files
break
}
count++
}
return count, nil
}
// validateExportPath checks if the output path is safe to write to
func validateExportPath(path string) error {
// Get absolute path to normalize it
@@ -57,6 +82,7 @@ Output to stdout by default, or use -o flag for file output.`,
format, _ := cmd.Flags().GetString("format")
output, _ := cmd.Flags().GetString("output")
statusFilter, _ := cmd.Flags().GetString("status")
force, _ := cmd.Flags().GetBool("force")
if format != "jsonl" {
fmt.Fprintf(os.Stderr, "Error: only 'jsonl' format is currently supported\n")
@@ -95,6 +121,43 @@ Output to stdout by default, or use -o flag for file output.`,
os.Exit(1)
}
// Safety check: prevent exporting empty database over non-empty JSONL
if len(issues) == 0 && output != "" && !force {
existingCount, err := countIssuesInJSONL(output)
if err != nil {
// If we can't read the file, it might not exist yet, which is fine
if !os.IsNotExist(err) {
fmt.Fprintf(os.Stderr, "Warning: failed to read existing JSONL: %v\n", err)
}
} else if existingCount > 0 {
fmt.Fprintf(os.Stderr, "Error: refusing to export empty database over non-empty JSONL file\n")
fmt.Fprintf(os.Stderr, " Database has 0 issues, JSONL has %d issues\n", existingCount)
fmt.Fprintf(os.Stderr, " This would result in data loss!\n")
fmt.Fprintf(os.Stderr, "Hint: Use --force to override this safety check, or delete the JSONL file first:\n")
fmt.Fprintf(os.Stderr, " bd export -o %s --force\n", output)
fmt.Fprintf(os.Stderr, " rm %s\n", output)
os.Exit(1)
}
}
// Warning: check if export would lose >50% of issues
if output != "" {
existingCount, err := countIssuesInJSONL(output)
if err == nil && existingCount > 0 {
lossPercent := float64(existingCount-len(issues)) / float64(existingCount) * 100
if lossPercent > 50 {
fmt.Fprintf(os.Stderr, "WARNING: Export would lose %.1f%% of issues!\n", lossPercent)
fmt.Fprintf(os.Stderr, " Existing JSONL: %d issues\n", existingCount)
fmt.Fprintf(os.Stderr, " Database: %d issues\n", len(issues))
fmt.Fprintf(os.Stderr, " This suggests database staleness or corruption.\n")
fmt.Fprintf(os.Stderr, "Press Ctrl+C to abort, or Enter to continue: ")
// Read a line from stdin to wait for user confirmation
var response string
fmt.Scanln(&response)
}
}
}
// Sort by ID for consistent output
sort.Slice(issues, func(i, j int) bool {
return issues[i].ID < issues[j].ID
@@ -206,5 +269,6 @@ func init() {
exportCmd.Flags().StringP("format", "f", "jsonl", "Export format (jsonl)")
exportCmd.Flags().StringP("output", "o", "", "Output file (default: stdout)")
exportCmd.Flags().StringP("status", "s", "", "Filter by status")
exportCmd.Flags().Bool("force", false, "Force export even if database is empty")
rootCmd.AddCommand(exportCmd)
}