fix(autoimport): prevent export to wrong JSONL file (bd-tqo)
Add FindJSONLInDir helper that correctly prefers issues.jsonl over other .jsonl files. Previously, glob patterns could return deletions.jsonl or merge artifacts (beads.base.jsonl, etc.) first alphabetically, causing issue data to be written to the wrong file. This fixes the root cause of deletions.jsonl corruption where full issue objects were written instead of deletion records, leading to all issues being purged during sync. Changes: - Add FindJSONLInDir() in internal/autoimport with proper file selection - Update AutoImportIfNewer() to use FindJSONLInDir - Update CheckStaleness() to use FindJSONLInDir - Update triggerExport() in RPC server to use FindJSONLInDir - Add comprehensive tests for FindJSONLInDir 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -67,14 +67,7 @@ func AutoImportIfNewer(ctx context.Context, store storage.Storage, dbPath string
|
||||
|
||||
// Find JSONL using database directory (same logic as beads.FindJSONLPath)
|
||||
dbDir := filepath.Dir(dbPath)
|
||||
pattern := filepath.Join(dbDir, "*.jsonl")
|
||||
matches, err := filepath.Glob(pattern)
|
||||
var jsonlPath string
|
||||
if err == nil && len(matches) > 0 {
|
||||
jsonlPath = matches[0]
|
||||
} else {
|
||||
jsonlPath = filepath.Join(dbDir, "issues.jsonl")
|
||||
}
|
||||
jsonlPath := FindJSONLInDir(dbDir)
|
||||
if jsonlPath == "" {
|
||||
notify.Debugf("auto-import skipped, JSONL not found")
|
||||
return nil
|
||||
@@ -287,19 +280,7 @@ func CheckStaleness(ctx context.Context, store storage.Storage, dbPath string) (
|
||||
|
||||
// Find JSONL using database directory
|
||||
dbDir := filepath.Dir(dbPath)
|
||||
pattern := filepath.Join(dbDir, "*.jsonl")
|
||||
matches, err := filepath.Glob(pattern)
|
||||
if err != nil {
|
||||
// Glob failed - this is abnormal
|
||||
return false, fmt.Errorf("failed to find JSONL file: %w", err)
|
||||
}
|
||||
|
||||
var jsonlPath string
|
||||
if len(matches) > 0 {
|
||||
jsonlPath = matches[0]
|
||||
} else {
|
||||
jsonlPath = filepath.Join(dbDir, "issues.jsonl")
|
||||
}
|
||||
jsonlPath := FindJSONLInDir(dbDir)
|
||||
|
||||
stat, err := os.Stat(jsonlPath)
|
||||
if err != nil {
|
||||
@@ -313,3 +294,47 @@ func CheckStaleness(ctx context.Context, store storage.Storage, dbPath string) (
|
||||
|
||||
return stat.ModTime().After(lastImportTime), nil
|
||||
}
|
||||
|
||||
// FindJSONLInDir finds the JSONL file in the given directory.
|
||||
// It prefers issues.jsonl over other .jsonl files to prevent accidentally
|
||||
// reading/writing to deletions.jsonl or merge artifacts (bd-tqo fix).
|
||||
// Returns empty string if no suitable JSONL file is found.
|
||||
func FindJSONLInDir(dbDir string) string {
|
||||
pattern := filepath.Join(dbDir, "*.jsonl")
|
||||
matches, err := filepath.Glob(pattern)
|
||||
if err != nil || len(matches) == 0 {
|
||||
// Default to issues.jsonl if glob fails or no matches
|
||||
return filepath.Join(dbDir, "issues.jsonl")
|
||||
}
|
||||
|
||||
// Prefer issues.jsonl over other .jsonl files (bd-tqo fix)
|
||||
// This prevents accidentally using deletions.jsonl or merge artifacts
|
||||
for _, match := range matches {
|
||||
if filepath.Base(match) == "issues.jsonl" {
|
||||
return match
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to beads.jsonl for legacy support
|
||||
for _, match := range matches {
|
||||
if filepath.Base(match) == "beads.jsonl" {
|
||||
return match
|
||||
}
|
||||
}
|
||||
|
||||
// Last resort: use first match (but skip deletions.jsonl and merge artifacts)
|
||||
for _, match := range matches {
|
||||
base := filepath.Base(match)
|
||||
// Skip deletions manifest and merge artifacts
|
||||
if base == "deletions.jsonl" ||
|
||||
base == "beads.base.jsonl" ||
|
||||
base == "beads.left.jsonl" ||
|
||||
base == "beads.right.jsonl" {
|
||||
continue
|
||||
}
|
||||
return match
|
||||
}
|
||||
|
||||
// If only deletions/merge files exist, default to issues.jsonl
|
||||
return filepath.Join(dbDir, "issues.jsonl")
|
||||
}
|
||||
|
||||
@@ -517,3 +517,79 @@ func TestStderrNotifier(t *testing.T) {
|
||||
notify.Infof("test info")
|
||||
})
|
||||
}
|
||||
|
||||
// TestFindJSONLInDir tests that FindJSONLInDir correctly prefers issues.jsonl
|
||||
// and avoids deletions.jsonl and merge artifacts (bd-tqo fix)
|
||||
func TestFindJSONLInDir(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
files []string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "only issues.jsonl",
|
||||
files: []string{"issues.jsonl"},
|
||||
expected: "issues.jsonl",
|
||||
},
|
||||
{
|
||||
name: "issues.jsonl and deletions.jsonl - prefers issues",
|
||||
files: []string{"deletions.jsonl", "issues.jsonl"},
|
||||
expected: "issues.jsonl",
|
||||
},
|
||||
{
|
||||
name: "issues.jsonl with merge artifacts - prefers issues",
|
||||
files: []string{"beads.base.jsonl", "beads.left.jsonl", "beads.right.jsonl", "issues.jsonl"},
|
||||
expected: "issues.jsonl",
|
||||
},
|
||||
{
|
||||
name: "beads.jsonl as legacy fallback",
|
||||
files: []string{"beads.jsonl"},
|
||||
expected: "beads.jsonl",
|
||||
},
|
||||
{
|
||||
name: "issues.jsonl preferred over beads.jsonl",
|
||||
files: []string{"beads.jsonl", "issues.jsonl"},
|
||||
expected: "issues.jsonl",
|
||||
},
|
||||
{
|
||||
name: "only deletions.jsonl - returns default issues.jsonl",
|
||||
files: []string{"deletions.jsonl"},
|
||||
expected: "issues.jsonl",
|
||||
},
|
||||
{
|
||||
name: "only merge artifacts - returns default issues.jsonl",
|
||||
files: []string{"beads.base.jsonl", "beads.left.jsonl", "beads.right.jsonl"},
|
||||
expected: "issues.jsonl",
|
||||
},
|
||||
{
|
||||
name: "no files - returns default issues.jsonl",
|
||||
files: []string{},
|
||||
expected: "issues.jsonl",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "bd-findjsonl-test-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
// Create test files
|
||||
for _, file := range tt.files {
|
||||
path := filepath.Join(tmpDir, file)
|
||||
if err := os.WriteFile(path, []byte("{}"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
result := FindJSONLInDir(tmpDir)
|
||||
got := filepath.Base(result)
|
||||
|
||||
if got != tt.expected {
|
||||
t.Errorf("FindJSONLInDir() = %q, want %q", got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -441,15 +441,9 @@ func hasUncommittedBeadsFiles(workspacePath string) bool {
|
||||
// CRITICAL: Must populate all issue data (deps, labels, comments) to prevent data loss
|
||||
func (s *Server) triggerExport(ctx context.Context, store storage.Storage, dbPath string) error {
|
||||
// Find JSONL path using database directory
|
||||
// Use FindJSONLInDir to prefer issues.jsonl over other .jsonl files (bd-tqo fix)
|
||||
dbDir := filepath.Dir(dbPath)
|
||||
pattern := filepath.Join(dbDir, "*.jsonl")
|
||||
matches, err := filepath.Glob(pattern)
|
||||
var jsonlPath string
|
||||
if err == nil && len(matches) > 0 {
|
||||
jsonlPath = matches[0]
|
||||
} else {
|
||||
jsonlPath = filepath.Join(dbDir, "issues.jsonl")
|
||||
}
|
||||
jsonlPath := autoimport.FindJSONLInDir(dbDir)
|
||||
|
||||
// Get all issues from storage
|
||||
sqliteStore, ok := store.(*sqlite.SQLiteStorage)
|
||||
|
||||
Reference in New Issue
Block a user