diff --git a/internal/autoimport/autoimport.go b/internal/autoimport/autoimport.go index 74d140e2..f82bf6bf 100644 --- a/internal/autoimport/autoimport.go +++ b/internal/autoimport/autoimport.go @@ -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") +} diff --git a/internal/autoimport/autoimport_test.go b/internal/autoimport/autoimport_test.go index 70d02964..d1f42f88 100644 --- a/internal/autoimport/autoimport_test.go +++ b/internal/autoimport/autoimport_test.go @@ -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) + } + }) + } +} diff --git a/internal/rpc/server_export_import_auto.go b/internal/rpc/server_export_import_auto.go index c859937c..1c7018d8 100644 --- a/internal/rpc/server_export_import_auto.go +++ b/internal/rpc/server_export_import_auto.go @@ -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)