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:
committed by
Steve Yegge
parent
3298c45e4b
commit
9e639da5ba
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user