diff --git a/cmd/bd/deletion_tracking.go b/cmd/bd/deletion_tracking.go index 27ffdf81..58cf5898 100644 --- a/cmd/bd/deletion_tracking.go +++ b/cmd/bd/deletion_tracking.go @@ -12,8 +12,16 @@ import ( "github.com/steveyegge/beads/internal/storage" ) -// isIssueNotFoundError checks if the error indicates the issue doesn't exist -// This is OK during merge - the issue may already be deleted/tombstoned +// isIssueNotFoundError checks if the error indicates the issue doesn't exist in the database. +// +// During 3-way merge, we try to delete issues that were removed remotely. However, the issue +// may already be gone from the local database due to: +// - Already tombstoned by a previous sync/import +// - Never existed locally (multi-repo scenarios, partial clones) +// - Deleted by user between export and import phases +// +// In all these cases, "issue not found" is success from the merge's perspective - the goal +// is to ensure the issue is deleted, and it already is. We only fail on actual database errors. func isIssueNotFoundError(err error) bool { if err == nil { return false @@ -84,13 +92,22 @@ func merge3WayAndPruneDeletions(ctx context.Context, store storage.Storage, json return false, fmt.Errorf("failed to compute accepted deletions: %w", err) } - // Prune accepted deletions from the database - // "Issue not found" errors are OK - the issue may already be deleted/tombstoned + // Prune accepted deletions from the database. + // + // "Accepted deletions" are issues that: + // 1. Existed in the base snapshot (last successful import) + // 2. Were NOT modified locally (still in left snapshot, unchanged) + // 3. Are NOT in the merged result (deleted remotely) + // + // We tolerate "issue not found" errors because the issue may already be gone: + // - Tombstoned by auto-import's git-history-backfill + // - Deleted manually by the user + // - Never existed in this clone (multi-repo, partial history) + // The goal is ensuring deletion, so already-deleted is success. var deletionErrors []error var alreadyGone int for _, id := range acceptedDeletions { if err := store.DeleteIssue(ctx, id); err != nil { - // If issue is already gone (tombstoned or never existed locally), that's fine if isIssueNotFoundError(err) { alreadyGone++ continue diff --git a/internal/importer/utils.go b/internal/importer/utils.go index 470d277e..b6828730 100644 --- a/internal/importer/utils.go +++ b/internal/importer/utils.go @@ -139,7 +139,19 @@ func (fc *fieldComparator) checkFieldChanged(key string, existing *types.Issue, } } -// RenameImportedIssuePrefixes renames all issues and their references to match the target prefix +// RenameImportedIssuePrefixes renames all issues and their references to match the target prefix. +// +// This function handles three ID formats: +// - Sequential numeric IDs: "old-123" → "new-123" +// - Hash-based IDs: "old-abc1" → "new-abc1" +// - Hierarchical IDs: "old-abc1.2.3" → "new-abc1.2.3" +// +// The suffix (everything after "prefix-") is preserved during rename, only the prefix changes. +// This preserves issue identity across prefix renames while maintaining parent-child relationships +// in hierarchical IDs (dots denote subtask nesting, e.g., bd-abc1.2 is child 2 of bd-abc1). +// +// All text references to old IDs in issue fields (title, description, notes, etc.) and +// dependency relationships are updated to use the new IDs. func RenameImportedIssuePrefixes(issues []*types.Issue, targetPrefix string) error { // Build a mapping of old IDs to new IDs idMapping := make(map[string]string) @@ -270,9 +282,18 @@ func isBoundary(c byte) bool { return c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == ',' || c == '.' || c == '!' || c == '?' || c == ':' || c == ';' || c == '(' || c == ')' || c == '[' || c == ']' || c == '{' || c == '}' } -// isValidIDSuffix checks if a string is a valid issue ID suffix -// Accepts: digits (0-9), lowercase letters (a-z), and dots (.) for hierarchy -// Examples: "123", "abc1", "6we", "6we.2", "abc.1.2" +// isValidIDSuffix validates the suffix portion of an issue ID (everything after "prefix-"). +// +// Beads supports three ID formats, all of which this function must accept: +// - Sequential numeric: "123", "999" (legacy format) +// - Hash-based (base36): "abc1", "6we", "zzz" (current format, content-addressed) +// - Hierarchical: "abc1.2", "6we.2.3" (subtasks, dot-separated child counters) +// +// The dot separator in hierarchical IDs represents parent-child relationships: +// "bd-abc1.2" means child #2 of parent "bd-abc1". Maximum depth is 3 levels. +// +// Rejected: uppercase letters, hyphens (would be confused with prefix separator), +// and special characters. func isValidIDSuffix(s string) bool { if len(s) == 0 { return false