Fix critical bugs: bd-169, bd-28, bd-393

- bd-169: Add -q/--quiet flag to bd init command
- bd-28: Improve error handling in RemoveDependency
  - Now checks RowsAffected and returns error if dependency doesn't exist
  - New removeDependencyIfExists() helper for collision remapping
- bd-393: CRITICAL - Fix auto-import skipping collisions
  - Auto-import was LOSING work from other workers
  - Now automatically remaps collisions to new IDs
  - Calls RemapCollisions() instead of skipping

All tests pass.

Amp-Thread-ID: https://ampcode.com/threads/T-cba86837-28db-47ce-94eb-67fade82376a
Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
Steve Yegge
2025-10-16 15:00:54 -07:00
parent 211e1cf523
commit c3e3326bba
5 changed files with 272 additions and 124 deletions

File diff suppressed because one or more lines are too long

View File

@@ -19,6 +19,8 @@ var initCmd = &cobra.Command{
and database file. Optionally specify a custom issue prefix.`,
Run: func(cmd *cobra.Command, args []string) {
prefix, _ := cmd.Flags().GetString("prefix")
quiet, _ := cmd.Flags().GetBool("quiet")
if prefix == "" {
// Auto-detect from directory name
cwd, err := os.Getwd()
@@ -66,6 +68,11 @@ and database file. Optionally specify a custom issue prefix.`,
fmt.Fprintf(os.Stderr, "Warning: failed to close database: %v\n", err)
}
// Skip output if quiet mode
if quiet {
return
}
green := color.New(color.FgGreen).SprintFunc()
cyan := color.New(color.FgCyan).SprintFunc()
@@ -79,5 +86,6 @@ and database file. Optionally specify a custom issue prefix.`,
func init() {
initCmd.Flags().StringP("prefix", "p", "", "Issue prefix (default: current directory name)")
initCmd.Flags().BoolP("quiet", "q", false, "Suppress output (quiet mode)")
rootCmd.AddCommand(initCmd)
}

View File

@@ -277,52 +277,69 @@ func autoImportIfNewer() {
return
}
// Track colliding IDs (used later to skip their dependencies too)
collidingIDs := make(map[string]bool)
// If collisions detected, warn user and skip colliding issues
// If collisions detected, auto-resolve them by remapping to new IDs
if len(collisionResult.Collisions) > 0 {
// Get all existing issues for scoring
allExistingIssues, err := store.SearchIssues(ctx, "", types.IssueFilter{})
if err != nil {
fmt.Fprintf(os.Stderr, "Auto-import failed: error getting existing issues: %v\n", err)
return
}
// Score collisions
if err := sqlite.ScoreCollisions(ctx, sqliteStore, collisionResult.Collisions, allExistingIssues); err != nil {
fmt.Fprintf(os.Stderr, "Auto-import failed: error scoring collisions: %v\n", err)
return
}
// Remap collisions
idMapping, err := sqlite.RemapCollisions(ctx, sqliteStore, collisionResult.Collisions, allExistingIssues)
if err != nil {
fmt.Fprintf(os.Stderr, "Auto-import failed: error remapping collisions: %v\n", err)
return
}
// Show concise notification
maxShow := 10
numRemapped := len(idMapping)
if numRemapped < maxShow {
maxShow = numRemapped
}
fmt.Fprintf(os.Stderr, "\nAuto-import: remapped %d colliding issue(s) to new IDs:\n", numRemapped)
i := 0
for oldID, newID := range idMapping {
if i >= maxShow {
break
}
// Find the collision detail to get title
var title string
for _, collision := range collisionResult.Collisions {
if collision.ID == oldID {
title = collision.IncomingIssue.Title
break
}
}
fmt.Fprintf(os.Stderr, " %s → %s (%s)\n", oldID, newID, title)
i++
}
if numRemapped > maxShow {
fmt.Fprintf(os.Stderr, " ... and %d more\n", numRemapped-maxShow)
}
fmt.Fprintf(os.Stderr, "\n")
// Remove colliding issues from allIssues (they were already created with new IDs by RemapCollisions)
collidingIDs := make(map[string]bool)
for _, collision := range collisionResult.Collisions {
collidingIDs[collision.ID] = true
}
// Concise warning: show first 10 collisions
maxShow := 10
if len(collisionResult.Collisions) < maxShow {
maxShow = len(collisionResult.Collisions)
}
fmt.Fprintf(os.Stderr, "\nAuto-import: skipped %d issue(s) due to local edits.\n", len(collisionResult.Collisions))
fmt.Fprintf(os.Stderr, "Conflicting issues (showing first %d): ", maxShow)
for i := 0; i < maxShow; i++ {
if i > 0 {
fmt.Fprintf(os.Stderr, ", ")
}
fmt.Fprintf(os.Stderr, "%s", collisionResult.Collisions[i].ID)
}
if len(collisionResult.Collisions) > maxShow {
fmt.Fprintf(os.Stderr, " ... and %d more", len(collisionResult.Collisions)-maxShow)
}
fmt.Fprintf(os.Stderr, "\n")
fmt.Fprintf(os.Stderr, "To merge these changes, run: bd import -i %s --resolve-collisions\n\n", jsonlPath)
// Full details under BD_DEBUG
if os.Getenv("BD_DEBUG") != "" {
fmt.Fprintf(os.Stderr, "Debug: Full collision details:\n")
for _, collision := range collisionResult.Collisions {
fmt.Fprintf(os.Stderr, " %s: %s\n", collision.ID, collision.IncomingIssue.Title)
fmt.Fprintf(os.Stderr, " Conflicting fields: %v\n", collision.ConflictingFields)
}
}
// Remove colliding issues from the import list
safeIssues := make([]*types.Issue, 0)
filteredIssues := make([]*types.Issue, 0)
for _, issue := range allIssues {
if !collidingIDs[issue.ID] {
safeIssues = append(safeIssues, issue)
filteredIssues = append(filteredIssues, issue)
}
}
allIssues = safeIssues
allIssues = filteredIssues
}
// Import non-colliding issues (exact matches + new issues)
@@ -381,13 +398,8 @@ func autoImportIfNewer() {
}
}
// Import dependencies (skip colliding issues to maintain consistency)
// Import dependencies
for _, issue := range allIssues {
// Skip if this issue was filtered out due to collision
if collidingIDs[issue.ID] {
continue
}
if len(issue.Dependencies) == 0 {
continue
}

View File

@@ -466,11 +466,10 @@ func updateDependencyReferences(ctx context.Context, s *SQLiteStorage, idMapping
// Phase 2: Apply all collected changes
for _, update := range updates {
// Remove old dependency
if err := s.RemoveDependency(ctx, update.oldIssueID, update.oldDependsOnID, "import-remap"); err != nil {
// If the dependency doesn't exist (e.g., already removed), that's okay
// This can happen if both IssueID and DependsOnID were remapped
continue
// Remove old dependency - use RemoveDependencyIfExists which doesn't error on missing deps
if err := s.removeDependencyIfExists(ctx, update.oldIssueID, update.oldDependsOnID, "import-remap"); err != nil {
return fmt.Errorf("failed to remove old dependency %s -> %s: %w",
update.oldIssueID, update.oldDependsOnID, err)
}
// Add new dependency with updated IDs

View File

@@ -271,13 +271,65 @@ func (s *SQLiteStorage) RemoveDependency(ctx context.Context, issueID, dependsOn
}
defer tx.Rollback()
_, err = tx.ExecContext(ctx, `
result, err := tx.ExecContext(ctx, `
DELETE FROM dependencies WHERE issue_id = ? AND depends_on_id = ?
`, issueID, dependsOnID)
if err != nil {
return fmt.Errorf("failed to remove dependency: %w", err)
}
// Check if dependency existed
rowsAffected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("failed to check rows affected: %w", err)
}
if rowsAffected == 0 {
return fmt.Errorf("dependency from %s to %s does not exist", issueID, dependsOnID)
}
_, err = tx.ExecContext(ctx, `
INSERT INTO events (issue_id, event_type, actor, comment)
VALUES (?, ?, ?, ?)
`, issueID, types.EventDependencyRemoved, actor,
fmt.Sprintf("Removed dependency on %s", dependsOnID))
if err != nil {
return fmt.Errorf("failed to record event: %w", err)
}
// Mark both issues as dirty for incremental export
if err := markIssuesDirtyTx(ctx, tx, []string{issueID, dependsOnID}); err != nil {
return err
}
return tx.Commit()
}
// removeDependencyIfExists removes a dependency, returning nil if it doesn't exist
// This is useful during remapping where dependencies may have been already removed
func (s *SQLiteStorage) removeDependencyIfExists(ctx context.Context, issueID, dependsOnID string, actor string) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("failed to begin transaction: %w", err)
}
defer tx.Rollback()
result, err := tx.ExecContext(ctx, `
DELETE FROM dependencies WHERE issue_id = ? AND depends_on_id = ?
`, issueID, dependsOnID)
if err != nil {
return fmt.Errorf("failed to remove dependency: %w", err)
}
// Check if dependency existed - if not, that's okay, just skip the event
rowsAffected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("failed to check rows affected: %w", err)
}
if rowsAffected == 0 {
// Dependency didn't exist, nothing to do
return nil
}
_, err = tx.ExecContext(ctx, `
INSERT INTO events (issue_id, event_type, actor, comment)
VALUES (?, ?, ?, ?)