fix(create): allow creating issues with explicit ID that matches tombstone (bd-0gm4r)

When using `bd create --id=<id>` where the ID matches an existing
tombstone (from `bd delete --hard --force`), the creation now succeeds
by first deleting the tombstone and all related records.

This enables use cases like polecat respawn where a worker needs to
recreate an issue with the same ID.

Changes:
- queries.go: Check for tombstone before insert, delete it if found
  (cleans up events, labels, dependencies, comments, dirty_issues)
- tombstone_test.go: Add regression test

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

Executed-By: beads/crew/dave
Rig: beads
Role: crew
This commit is contained in:
beads/crew/dave
2026-01-14 20:36:47 -08:00
committed by Steve Yegge
parent 3298c45e4b
commit 9e639da5ba
2 changed files with 102 additions and 0 deletions

View File

@@ -216,6 +216,40 @@ func (s *SQLiteStorage) CreateIssue(ctx context.Context, issue *types.Issue, act
}
}
// bd-0gm4r: Handle tombstone collision for explicit IDs
// If the user explicitly specifies an ID that matches an existing tombstone,
// delete the tombstone first so the new issue can be created.
// This enables re-creating issues after hard deletion (e.g., polecat respawn).
if issue.ID != "" {
var existingStatus string
err := conn.QueryRowContext(ctx, `SELECT status FROM issues WHERE id = ?`, issue.ID).Scan(&existingStatus)
if err == nil && existingStatus == string(types.StatusTombstone) {
// Delete the tombstone record to allow re-creation
// Also clean up related tables (events, labels, dependencies, comments, dirty_issues)
if _, err := conn.ExecContext(ctx, `DELETE FROM events WHERE issue_id = ?`, issue.ID); err != nil {
return fmt.Errorf("failed to delete tombstone events: %w", err)
}
if _, err := conn.ExecContext(ctx, `DELETE FROM labels WHERE issue_id = ?`, issue.ID); err != nil {
return fmt.Errorf("failed to delete tombstone labels: %w", err)
}
if _, err := conn.ExecContext(ctx, `DELETE FROM dependencies WHERE issue_id = ? OR depends_on_id = ?`, issue.ID, issue.ID); err != nil {
return fmt.Errorf("failed to delete tombstone dependencies: %w", err)
}
if _, err := conn.ExecContext(ctx, `DELETE FROM comments WHERE issue_id = ?`, issue.ID); err != nil {
return fmt.Errorf("failed to delete tombstone comments: %w", err)
}
if _, err := conn.ExecContext(ctx, `DELETE FROM dirty_issues WHERE issue_id = ?`, issue.ID); err != nil {
return fmt.Errorf("failed to delete tombstone dirty marker: %w", err)
}
if _, err := conn.ExecContext(ctx, `DELETE FROM issues WHERE id = ?`, issue.ID); err != nil {
return fmt.Errorf("failed to delete tombstone: %w", err)
}
// Note: Tombstone is now gone, proceed with normal creation
} else if err != nil && err != sql.ErrNoRows {
return fmt.Errorf("failed to check for existing tombstone: %w", err)
}
}
// Insert issue using strict mode (fails on duplicates)
// GH#956: Use insertIssueStrict instead of insertIssue to prevent FK constraint errors
// from silent INSERT OR IGNORE failures under concurrent load.

View File

@@ -409,6 +409,74 @@ func TestDeleteIssuesCreatesTombstones(t *testing.T) {
}
})
t.Run("create issue with explicit ID replaces tombstone (bd-0gm4r)", func(t *testing.T) {
// Regression test: bd delete --hard --force creates tombstones that blocked
// bd create --id=<same-id> with UNIQUE constraint error.
// Fix: CreateIssue now deletes tombstones when explicit ID matches.
store := newTestStore(t, "file::memory:?mode=memory&cache=private")
// Create an issue
issue := &types.Issue{
ID: "bd-respawn-1",
Title: "Original Issue",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
}
if err := store.CreateIssue(ctx, issue, "test"); err != nil {
t.Fatalf("Failed to create initial issue: %v", err)
}
// Delete it (creates tombstone)
result, err := store.DeleteIssues(ctx, []string{"bd-respawn-1"}, false, true, false)
if err != nil {
t.Fatalf("DeleteIssues failed: %v", err)
}
if result.DeletedCount != 1 {
t.Fatalf("Expected 1 deletion, got %d", result.DeletedCount)
}
// Verify tombstone exists
tombstone, err := store.GetIssue(ctx, "bd-respawn-1")
if err != nil {
t.Fatalf("Failed to get tombstone: %v", err)
}
if tombstone == nil || tombstone.Status != types.StatusTombstone {
t.Fatalf("Expected tombstone, got %v", tombstone)
}
// Now create a NEW issue with the SAME explicit ID
// This should succeed (tombstone is deleted first)
newIssue := &types.Issue{
ID: "bd-respawn-1", // Same ID as tombstone
Title: "Respawned Issue",
Status: types.StatusOpen,
Priority: 2,
IssueType: types.TypeBug,
}
if err := store.CreateIssue(ctx, newIssue, "test"); err != nil {
t.Fatalf("CreateIssue with explicit ID should succeed after tombstone: %v", err)
}
// Verify new issue replaced tombstone
created, err := store.GetIssue(ctx, "bd-respawn-1")
if err != nil {
t.Fatalf("Failed to get created issue: %v", err)
}
if created == nil {
t.Fatal("Issue should exist")
}
if created.Status != types.StatusOpen {
t.Errorf("Expected status open, got %s", created.Status)
}
if created.Title != "Respawned Issue" {
t.Errorf("Expected title 'Respawned Issue', got %s", created.Title)
}
if created.IssueType != types.TypeBug {
t.Errorf("Expected type bug, got %s", created.IssueType)
}
})
t.Run("dependencies removed from tombstones", func(t *testing.T) {
store := newTestStore(t, "file::memory:?mode=memory&cache=private")