diff --git a/cmd/bd/integrity.go b/cmd/bd/integrity.go index 2c54ad7e..77ecd4c1 100644 --- a/cmd/bd/integrity.go +++ b/cmd/bd/integrity.go @@ -12,23 +12,34 @@ import ( "github.com/steveyegge/beads/internal/types" ) +// isJSONLNewer checks if JSONL file is newer than database file. +// Returns true if JSONL is newer, false otherwise. +func isJSONLNewer(jsonlPath string) bool { + jsonlInfo, jsonlStatErr := os.Stat(jsonlPath) + if jsonlStatErr != nil { + return false + } + + beadsDir := filepath.Dir(jsonlPath) + dbPath := filepath.Join(beadsDir, "beads.db") + dbInfo, dbStatErr := os.Stat(dbPath) + if dbStatErr != nil { + return false + } + + return jsonlInfo.ModTime().After(dbInfo.ModTime()) +} + // validatePreExport performs integrity checks before exporting database to JSONL. // Returns error if critical issues found that would cause data loss. func validatePreExport(ctx context.Context, store storage.Storage, jsonlPath string) error { // Check if JSONL is newer than database - if so, must import first - jsonlInfo, jsonlStatErr := os.Stat(jsonlPath) - if jsonlStatErr == nil { - beadsDir := filepath.Dir(jsonlPath) - dbPath := filepath.Join(beadsDir, "beads.db") - dbInfo, dbStatErr := os.Stat(dbPath) - if dbStatErr == nil { - // If JSONL is newer, refuse export - caller must import first - if jsonlInfo.ModTime().After(dbInfo.ModTime()) { - return fmt.Errorf("refusing to export: JSONL is newer than database (import first to avoid data loss)") - } - } + if isJSONLNewer(jsonlPath) { + return fmt.Errorf("refusing to export: JSONL is newer than database (import first to avoid data loss)") } + jsonlInfo, jsonlStatErr := os.Stat(jsonlPath) + // Get database issue count (fast path with COUNT(*) if available) dbCount, err := countDBIssuesFast(ctx, store) if err != nil { diff --git a/cmd/bd/list.go b/cmd/bd/list.go index 8436560d..62718549 100644 --- a/cmd/bd/list.go +++ b/cmd/bd/list.go @@ -339,18 +339,19 @@ var listCmd = &cobra.Command{ } if jsonOutput { - // Populate labels for JSON output - for _, issue := range issues { - issue.Labels, _ = store.GetLabels(ctx, issue.ID) - } - - // Get dependency counts in bulk (single query instead of N queries) + // Get labels and dependency counts in bulk (single query instead of N queries) issueIDs := make([]string, len(issues)) for i, issue := range issues { issueIDs[i] = issue.ID } + labelsMap, _ := store.GetLabelsForIssues(ctx, issueIDs) depCounts, _ := store.GetDependencyCounts(ctx, issueIDs) + // Populate labels for JSON output + for _, issue := range issues { + issue.Labels = labelsMap[issue.ID] + } + // Build response with counts issuesWithCounts := make([]*types.IssueWithCounts, len(issues)) for i, issue := range issues { @@ -368,12 +369,18 @@ var listCmd = &cobra.Command{ return } + // Load labels in bulk for display + issueIDs := make([]string, len(issues)) + for i, issue := range issues { + issueIDs[i] = issue.ID + } + labelsMap, _ := store.GetLabelsForIssues(ctx, issueIDs) + if longFormat { // Long format: multi-line with details fmt.Printf("\nFound %d issues:\n\n", len(issues)) for _, issue := range issues { - // Load labels for display - labels, _ := store.GetLabels(ctx, issue.ID) + labels := labelsMap[issue.ID] fmt.Printf("%s [P%d] [%s] %s\n", issue.ID, issue.Priority, issue.IssueType, issue.Status) fmt.Printf(" %s\n", issue.Title) @@ -388,8 +395,7 @@ var listCmd = &cobra.Command{ } else { // Compact format: one line per issue for _, issue := range issues { - // Load labels for display - labels, _ := store.GetLabels(ctx, issue.ID) + labels := labelsMap[issue.ID] labelsStr := "" if len(labels) > 0 { diff --git a/cmd/bd/sync.go b/cmd/bd/sync.go index bbb751d4..07586456 100644 --- a/cmd/bd/sync.go +++ b/cmd/bd/sync.go @@ -126,6 +126,16 @@ Use --merge to merge the sync branch back to main branch.`, if dryRun { fmt.Println("→ [DRY RUN] Would export pending changes to JSONL") } else { + // Smart conflict resolution: if JSONL is newer, auto-import first + if isJSONLNewer(jsonlPath) { + fmt.Println("→ JSONL is newer than database, importing first...") + if err := importFromJSONL(ctx, jsonlPath, renameOnImport); err != nil { + fmt.Fprintf(os.Stderr, "Error auto-importing: %v\n", err) + os.Exit(1) + } + fmt.Println("✓ Auto-import complete") + } + // Pre-export integrity checks if err := ensureStoreActive(); err == nil && store != nil { if err := validatePreExport(ctx, store, jsonlPath); err != nil { diff --git a/internal/storage/memory/memory.go b/internal/storage/memory/memory.go index 3d9432a5..7926c406 100644 --- a/internal/storage/memory/memory.go +++ b/internal/storage/memory/memory.go @@ -791,6 +791,19 @@ func (m *MemoryStorage) GetLabels(ctx context.Context, issueID string) ([]string return m.labels[issueID], nil } +func (m *MemoryStorage) GetLabelsForIssues(ctx context.Context, issueIDs []string) (map[string][]string, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + result := make(map[string][]string) + for _, issueID := range issueIDs { + if labels, exists := m.labels[issueID]; exists { + result[issueID] = labels + } + } + return result, nil +} + func (m *MemoryStorage) GetIssuesByLabel(ctx context.Context, label string) ([]*types.Issue, error) { m.mu.RLock() defer m.mu.RUnlock() diff --git a/internal/storage/storage.go b/internal/storage/storage.go index 3f5d50fd..7974cc8f 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -35,6 +35,7 @@ type Storage interface { AddLabel(ctx context.Context, issueID, label, actor string) error RemoveLabel(ctx context.Context, issueID, label, actor string) error GetLabels(ctx context.Context, issueID string) ([]string, error) + GetLabelsForIssues(ctx context.Context, issueIDs []string) (map[string][]string, error) GetIssuesByLabel(ctx context.Context, label string) ([]*types.Issue, error) // Ready Work & Blocking