Files
beads/cmd/bd/import_shared.go
Rod Davenport 6985ea94e5 fix(sync): protect local issues from git-history-backfill during sync (#485)
Fix sync bug where newly created issues were incorrectly tombstoned during bd sync.

The root cause was git-history-backfill finding issues in local commits on the sync branch, then tombstoning them when they weren't in the merged JSONL. The fix protects issues from the left snapshot (local export) from git-history-backfill.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2025-12-12 13:28:48 -08:00

274 lines
9.4 KiB
Go

package main
import (
"context"
"github.com/steveyegge/beads/internal/importer"
"github.com/steveyegge/beads/internal/storage"
"github.com/steveyegge/beads/internal/types"
)
// fieldComparator handles comparison logic for a specific field type
type fieldComparator struct {
// Helper to safely extract string from interface (handles string and *string)
strFrom func(v interface{}) (string, bool)
// Helper to safely extract int from interface
intFrom func(v interface{}) (int64, bool)
}
func newFieldComparator() *fieldComparator {
fc := &fieldComparator{}
fc.strFrom = func(v interface{}) (string, bool) {
switch t := v.(type) {
case string:
return t, true
case *string:
if t == nil {
return "", true
}
return *t, true
case nil:
return "", true
default:
return "", false
}
}
fc.intFrom = func(v interface{}) (int64, bool) {
switch t := v.(type) {
case int:
return int64(t), true
case int32:
return int64(t), true
case int64:
return t, true
case float64:
// Only accept whole numbers
if t == float64(int64(t)) {
return int64(t), true
}
return 0, false
default:
return 0, false
}
}
return fc
}
// equalStr compares string field (treats empty and nil as equal)
func (fc *fieldComparator) equalStr(existingVal string, newVal interface{}) bool {
s, ok := fc.strFrom(newVal)
if !ok {
return false // Type mismatch means changed
}
return existingVal == s
}
// equalPtrStr compares *string field (treats empty and nil as equal)
func (fc *fieldComparator) equalPtrStr(existing *string, newVal interface{}) bool {
s, ok := fc.strFrom(newVal)
if !ok {
return false // Type mismatch means changed
}
if existing == nil {
return s == ""
}
return *existing == s
}
// equalStatus compares Status field
func (fc *fieldComparator) equalStatus(existing types.Status, newVal interface{}) bool {
switch t := newVal.(type) {
case types.Status:
return existing == t
case string:
return string(existing) == t
default:
return false // Unknown type means changed
}
}
// equalIssueType compares IssueType field
func (fc *fieldComparator) equalIssueType(existing types.IssueType, newVal interface{}) bool {
switch t := newVal.(type) {
case types.IssueType:
return existing == t
case string:
return string(existing) == t
default:
return false // Unknown type means changed
}
}
// equalPriority compares priority field
func (fc *fieldComparator) equalPriority(existing int, newVal interface{}) bool {
p, ok := fc.intFrom(newVal)
if !ok {
return false
}
return existing == int(p)
}
// checkFieldChanged checks if a specific field has changed
func (fc *fieldComparator) checkFieldChanged(key string, existing *types.Issue, newVal interface{}) bool {
switch key {
case "title":
return !fc.equalStr(existing.Title, newVal)
case "description":
return !fc.equalStr(existing.Description, newVal)
case "status":
return !fc.equalStatus(existing.Status, newVal)
case "priority":
return !fc.equalPriority(existing.Priority, newVal)
case "issue_type":
return !fc.equalIssueType(existing.IssueType, newVal)
case "design":
return !fc.equalStr(existing.Design, newVal)
case "acceptance_criteria":
return !fc.equalStr(existing.AcceptanceCriteria, newVal)
case "notes":
return !fc.equalStr(existing.Notes, newVal)
case "assignee":
return !fc.equalStr(existing.Assignee, newVal)
case "external_ref":
return !fc.equalPtrStr(existing.ExternalRef, newVal)
default:
// Unknown field - treat as changed to be conservative
// This prevents skipping updates when new fields are added
return true
}
}
// issueDataChanged checks if any fields in the updates map differ from the existing issue
// Returns true if any field changed, false if all fields match
func issueDataChanged(existing *types.Issue, updates map[string]interface{}) bool {
fc := newFieldComparator()
// Check each field in updates map
for key, newVal := range updates {
if fc.checkFieldChanged(key, existing, newVal) {
return true
}
}
return false // No changes detected
}
// ImportOptions configures how the import behaves
type ImportOptions struct {
DryRun bool // Preview changes without applying them
SkipUpdate bool // Skip updating existing issues (create-only mode)
Strict bool // Fail on any error (dependencies, labels, etc.)
RenameOnImport bool // Rename imported issues to match database prefix
SkipPrefixValidation bool // Skip prefix validation (for auto-import)
ClearDuplicateExternalRefs bool // Clear duplicate external_ref values instead of erroring
OrphanHandling string // Orphan handling mode: strict/resurrect/skip/allow (empty = use config)
NoGitHistory bool // Skip git history backfill for deletions (prevents spurious deletion during JSONL migrations)
IgnoreDeletions bool // Import issues even if they're in the deletions manifest
ProtectLocalExportIDs map[string]bool // IDs from left snapshot to protect from git-history-backfill (bd-sync-deletion fix)
}
// ImportResult contains statistics about the import operation
type ImportResult struct {
Created int // New issues created
Updated int // Existing issues updated
Unchanged int // Existing issues that matched exactly (idempotent)
Skipped int // Issues skipped (duplicates, errors)
Collisions int // Collisions detected
IDMapping map[string]string // Mapping of remapped IDs (old -> new)
CollisionIDs []string // IDs that collided
PrefixMismatch bool // Prefix mismatch detected
ExpectedPrefix string // Database configured prefix
MismatchPrefixes map[string]int // Map of mismatched prefixes to count
SkippedDependencies []string // Dependencies skipped due to FK constraint violations
Purged int // Issues purged from DB (found in deletions manifest)
PurgedIDs []string // IDs that were purged
SkippedDeleted int // Issues skipped because they're in deletions manifest
SkippedDeletedIDs []string // IDs that were skipped due to deletions manifest
PreservedLocalExport int // Issues preserved because they were in local export (bd-sync-deletion fix)
PreservedLocalIDs []string // IDs that were preserved from local export
}
// importIssuesCore handles the core import logic used by both manual and auto-import.
// This function:
// - Opens a direct SQLite connection if needed (daemon mode)
// - Detects and handles collisions
// - Imports issues, dependencies, and labels
// - Returns detailed results
//
// The caller is responsible for:
// - Reading and parsing JSONL into issues slice
// - Displaying results to the user
// - Setting metadata (e.g., last_import_hash)
func importIssuesCore(ctx context.Context, dbPath string, store storage.Storage, issues []*types.Issue, opts ImportOptions) (*ImportResult, error) {
// Determine orphan handling: flag > config > default (allow)
orphanHandling := opts.OrphanHandling
if orphanHandling == "" && store != nil {
// Read from config if flag not specified
configValue, err := store.GetConfig(ctx, "import.missing_parents")
if err == nil && configValue != "" {
orphanHandling = configValue
} else {
// Default to allow (most permissive)
orphanHandling = "allow"
}
} else if orphanHandling == "" {
// No store available, default to allow
orphanHandling = "allow"
}
// Convert ImportOptions to importer.Options
importerOpts := importer.Options{
DryRun: opts.DryRun,
SkipUpdate: opts.SkipUpdate,
Strict: opts.Strict,
RenameOnImport: opts.RenameOnImport,
SkipPrefixValidation: opts.SkipPrefixValidation,
ClearDuplicateExternalRefs: opts.ClearDuplicateExternalRefs,
OrphanHandling: importer.OrphanHandling(orphanHandling),
NoGitHistory: opts.NoGitHistory,
IgnoreDeletions: opts.IgnoreDeletions,
ProtectLocalExportIDs: opts.ProtectLocalExportIDs,
}
// Delegate to the importer package
result, err := importer.ImportIssues(ctx, dbPath, store, issues, importerOpts)
if err != nil {
return nil, err
}
// Convert importer.Result to ImportResult
return &ImportResult{
Created: result.Created,
Updated: result.Updated,
Unchanged: result.Unchanged,
Skipped: result.Skipped,
Collisions: result.Collisions,
IDMapping: result.IDMapping,
CollisionIDs: result.CollisionIDs,
PrefixMismatch: result.PrefixMismatch,
ExpectedPrefix: result.ExpectedPrefix,
MismatchPrefixes: result.MismatchPrefixes,
SkippedDependencies: result.SkippedDependencies,
Purged: result.Purged,
PurgedIDs: result.PurgedIDs,
SkippedDeleted: result.SkippedDeleted,
SkippedDeletedIDs: result.SkippedDeletedIDs,
PreservedLocalExport: result.PreservedLocalExport,
PreservedLocalIDs: result.PreservedLocalIDs,
}, nil
}
// isNumeric returns true if the string contains only digits
func isNumeric(s string) bool {
for i := 0; i < len(s); i++ {
if s[i] < '0' || s[i] > '9' {
return false
}
}
return true
}