diff --git a/cmd/bd/helpers_test.go b/cmd/bd/helpers_test.go index 428fa3b2..0cb6882d 100644 --- a/cmd/bd/helpers_test.go +++ b/cmd/bd/helpers_test.go @@ -42,14 +42,18 @@ func TestExtractPrefix(t *testing.T) { {"bd-123", "bd"}, {"custom-1", "custom"}, {"TEST-999", "TEST"}, - {"no-number", "no"}, // Has hyphen, so "no" is prefix + {"no-number", "no"}, // Has hyphen, suffix not numeric, first hyphen {"nonumber", ""}, // No hyphen {"", ""}, - // Multi-part suffixes (bd-fasa regression tests) + // Multi-part non-numeric suffixes (bd-fasa regression tests) {"vc-baseline-test", "vc"}, {"vc-92cl-gate-test", "vc"}, {"bd-multi-part-id", "bd"}, {"prefix-a-b-c-d", "prefix"}, + // Multi-part prefixes with numeric suffixes + {"beads-vscode-1", "beads-vscode"}, + {"alpha-beta-123", "alpha-beta"}, + {"my-project-42", "my-project"}, } for _, tt := range tests { diff --git a/cmd/bd/import.go b/cmd/bd/import.go index 6fe36d83..d2563d9f 100644 --- a/cmd/bd/import.go +++ b/cmd/bd/import.go @@ -16,6 +16,7 @@ import ( "github.com/steveyegge/beads/internal/debug" "github.com/steveyegge/beads/internal/storage/sqlite" "github.com/steveyegge/beads/internal/types" + "github.com/steveyegge/beads/internal/utils" "golang.org/x/term" ) @@ -335,8 +336,9 @@ NOTE: Import requires direct database access and does not work with daemon mode. // Update last_import_hash metadata to enable content-based staleness detection (bd-khnb fix) // This prevents git operations from resurrecting deleted issues by comparing content instead of mtime - // When --force is true, ALWAYS update metadata even if no changes were made - if input != "" && (result.Created > 0 || result.Updated > 0 || len(result.IDMapping) > 0 || force) { + // ALWAYS update metadata after successful import, even if no changes were made (fixes staleness check) + // This ensures that running `bd import` marks the database as fresh for staleness detection + if input != "" { if currentHash, err := computeJSONLHash(input); err == nil { if err := store.SetMetadata(ctx, "last_import_hash", currentHash); err != nil { // Non-fatal warning: Metadata update failures are intentionally non-fatal to prevent blocking @@ -694,7 +696,7 @@ func attemptAutoMerge(conflictedPath string) error { } // detectPrefixFromIssues extracts the common prefix from issue IDs -// Only considers the first hyphen, so "vc-baseline-test" -> "vc" +// Uses utils.ExtractIssuePrefix which handles multi-part prefixes correctly func detectPrefixFromIssues(issues []*types.Issue) string { if len(issues) == 0 { return "" @@ -703,10 +705,9 @@ func detectPrefixFromIssues(issues []*types.Issue) string { // Count prefix occurrences prefixCounts := make(map[string]int) for _, issue := range issues { - // Extract prefix from issue ID using first hyphen only - idx := strings.Index(issue.ID, "-") - if idx > 0 { - prefixCounts[issue.ID[:idx]]++ + prefix := utils.ExtractIssuePrefix(issue.ID) + if prefix != "" { + prefixCounts[prefix]++ } } diff --git a/cmd/bd/info.go b/cmd/bd/info.go index 9d42732f..b1385179 100644 --- a/cmd/bd/info.go +++ b/cmd/bd/info.go @@ -249,13 +249,33 @@ Examples: } // extractPrefix extracts the prefix from an issue ID (e.g., "bd-123" -> "bd") -// Only considers the first hyphen, so "vc-baseline-test" -> "vc" +// Uses the last hyphen before a numeric suffix, so "beads-vscode-1" -> "beads-vscode" func extractPrefix(issueID string) string { - idx := strings.Index(issueID, "-") - if idx <= 0 { + // Try last hyphen first (handles multi-part prefixes like "beads-vscode-1") + lastIdx := strings.LastIndex(issueID, "-") + if lastIdx <= 0 { return "" } - return issueID[:idx] + + suffix := issueID[lastIdx+1:] + // Check if suffix is numeric + if len(suffix) > 0 { + numPart := suffix + if dotIdx := strings.Index(suffix, "."); dotIdx > 0 { + numPart = suffix[:dotIdx] + } + var num int + if _, err := fmt.Sscanf(numPart, "%d", &num); err == nil { + return issueID[:lastIdx] + } + } + + // Suffix is not numeric, fall back to first hyphen + firstIdx := strings.Index(issueID, "-") + if firstIdx <= 0 { + return "" + } + return issueID[:firstIdx] } // VersionChange represents agent-relevant changes for a specific version diff --git a/cmd/bd/nodb.go b/cmd/bd/nodb.go index 31d68b95..b0118d65 100644 --- a/cmd/bd/nodb.go +++ b/cmd/bd/nodb.go @@ -177,12 +177,33 @@ func detectPrefix(_ string, memStore *memory.MemoryStorage) (string, error) { } // extractIssuePrefix extracts the prefix from an issue ID like "bd-123" -> "bd" +// Uses the last hyphen before a numeric suffix, so "beads-vscode-1" -> "beads-vscode" func extractIssuePrefix(issueID string) string { - idx := strings.Index(issueID, "-") - if idx <= 0 { + // Try last hyphen first (handles multi-part prefixes like "beads-vscode-1") + lastIdx := strings.LastIndex(issueID, "-") + if lastIdx <= 0 { return "" } - return issueID[:idx] + + suffix := issueID[lastIdx+1:] + // Check if suffix is numeric + if len(suffix) > 0 { + numPart := suffix + if dotIdx := strings.Index(suffix, "."); dotIdx > 0 { + numPart = suffix[:dotIdx] + } + var num int + if _, err := fmt.Sscanf(numPart, "%d", &num); err == nil { + return issueID[:lastIdx] + } + } + + // Suffix is not numeric, fall back to first hyphen + firstIdx := strings.Index(issueID, "-") + if firstIdx <= 0 { + return "" + } + return issueID[:firstIdx] } // writeIssuesToJSONL writes all issues from memory storage to JSONL file atomically diff --git a/cmd/bd/nodb_test.go b/cmd/bd/nodb_test.go index 8ef46f4c..8c959f17 100644 --- a/cmd/bd/nodb_test.go +++ b/cmd/bd/nodb_test.go @@ -18,7 +18,9 @@ func TestExtractIssuePrefix(t *testing.T) { {"standard ID", "bd-123", "bd"}, {"custom prefix", "myproject-456", "myproject"}, {"hash ID", "bd-abc123def", "bd"}, - {"multi-part suffix", "alpha-beta-1", "alpha"}, // Only first hyphen (bd-fasa) + {"multi-part prefix with numeric suffix", "alpha-beta-1", "alpha-beta"}, + {"multi-part non-numeric suffix", "vc-baseline-test", "vc"}, // Falls back to first hyphen + {"beads-vscode style", "beads-vscode-42", "beads-vscode"}, {"no hyphen", "nohyphen", ""}, {"empty", "", ""}, } diff --git a/internal/utils/id_parser_test.go b/internal/utils/id_parser_test.go index bb1b4606..266ba307 100644 --- a/internal/utils/id_parser_test.go +++ b/internal/utils/id_parser_test.go @@ -353,9 +353,19 @@ func TestExtractIssuePrefix(t *testing.T) { expected: "bd", }, { - name: "multi-part suffix", + name: "multi-part prefix with numeric suffix", issueID: "alpha-beta-1", - expected: "alpha", // Only first hyphen (bd-fasa) + expected: "alpha-beta", // Last hyphen before numeric suffix + }, + { + name: "multi-part non-numeric suffix", + issueID: "vc-baseline-test", + expected: "vc", // Falls back to first hyphen for non-numeric suffix + }, + { + name: "beads-vscode style prefix", + issueID: "beads-vscode-1", + expected: "beads-vscode", // Last hyphen before numeric suffix }, } diff --git a/internal/utils/issue_id.go b/internal/utils/issue_id.go index 63effe57..f3bb8591 100644 --- a/internal/utils/issue_id.go +++ b/internal/utils/issue_id.go @@ -6,13 +6,36 @@ import ( ) // ExtractIssuePrefix extracts the prefix from an issue ID like "bd-123" -> "bd" -// Only considers the first hyphen, so "vc-baseline-test" -> "vc" +// Uses the last hyphen before a numeric suffix, so "beads-vscode-1" -> "beads-vscode" +// For non-numeric suffixes like "vc-baseline-test", returns the first segment "vc" func ExtractIssuePrefix(issueID string) string { - idx := strings.Index(issueID, "-") - if idx <= 0 { + // Try last hyphen first (handles multi-part prefixes like "beads-vscode-1") + lastIdx := strings.LastIndex(issueID, "-") + if lastIdx <= 0 { return "" } - return issueID[:idx] + + suffix := issueID[lastIdx+1:] + // Check if suffix is numeric (or starts with a number for hierarchical IDs like "bd-123.1") + if len(suffix) > 0 { + // Extract just the numeric part (handle "123.1.2" -> check "123") + numPart := suffix + if dotIdx := strings.Index(suffix, "."); dotIdx > 0 { + numPart = suffix[:dotIdx] + } + var num int + if _, err := fmt.Sscanf(numPart, "%d", &num); err == nil { + // Suffix is numeric, use last hyphen + return issueID[:lastIdx] + } + } + + // Suffix is not numeric (e.g., "vc-baseline-test"), fall back to first hyphen + firstIdx := strings.Index(issueID, "-") + if firstIdx <= 0 { + return "" + } + return issueID[:firstIdx] } // ExtractIssueNumber extracts the number from an issue ID like "bd-123" -> 123