Fix bd-166: Prevent duplicate issues with wrong prefix
Critical fix for silent data corruption where database created 173 duplicate issues with wrong prefix (beads- instead of bd-). Root cause: When issue_prefix config was missing, CreateIssue fell back to deriving prefix from database filename (beads.db → 'beads'), while auto-import imported bd- issues from git with SkipPrefixValidation. This created duplicates. Changes: 1. Removed derivePrefixFromPath() - never derive prefix from filename 2. CreateIssue/CreateIssues now REJECT if issue_prefix config missing - Fail-fast with clear error message 3. Auto-import now SETS issue_prefix from first imported issue if missing - Handles fresh clone scenario safely 4. Added newTestStore() helper that sets issue_prefix for tests 5. Updated test setup in multiple files to prevent test failures Follow-ups filed: bd-167, bd-168, bd-169 Closes bd-166 Amp-Thread-ID: https://ampcode.com/threads/T-b2ee0738-b90b-40ef-ae44-f2d93729842c Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
@@ -9,6 +9,7 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/steveyegge/beads/internal/storage"
|
||||
"github.com/steveyegge/beads/internal/types"
|
||||
@@ -167,7 +168,24 @@ func importFromGit(ctx context.Context, dbFilePath string, store storage.Storage
|
||||
return fmt.Errorf("failed to scan JSONL: %w", err)
|
||||
}
|
||||
|
||||
// CRITICAL (bd-166): Set issue_prefix from first imported issue if missing
|
||||
// This prevents derivePrefixFromPath fallback which caused duplicate issues
|
||||
if len(issues) > 0 {
|
||||
configuredPrefix, err := store.GetConfig(ctx, "issue_prefix")
|
||||
if err == nil && strings.TrimSpace(configuredPrefix) == "" {
|
||||
// Database has no prefix configured - derive from first issue
|
||||
firstPrefix := extractPrefix(issues[0].ID)
|
||||
if firstPrefix != "" {
|
||||
if err := store.SetConfig(ctx, "issue_prefix", firstPrefix); err != nil {
|
||||
return fmt.Errorf("failed to set issue_prefix from imported issues: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Use existing import logic with auto-resolve collisions
|
||||
// Note: SkipPrefixValidation allows mixed prefixes during auto-import
|
||||
// (but now we set the prefix first, so CreateIssue won't use filename fallback)
|
||||
opts := ImportOptions{
|
||||
ResolveCollisions: true,
|
||||
DryRun: false,
|
||||
|
||||
@@ -162,6 +162,14 @@ func setupTestDB(t *testing.T) (*sqlite.SQLiteStorage, func()) {
|
||||
t.Fatalf("Failed to create test database: %v", err)
|
||||
}
|
||||
|
||||
// CRITICAL (bd-166): Set issue_prefix to prevent "database not initialized" errors
|
||||
ctx := context.Background()
|
||||
if err := store.SetConfig(ctx, "issue_prefix", "bd"); err != nil {
|
||||
store.Close()
|
||||
os.RemoveAll(tmpDir)
|
||||
t.Fatalf("Failed to set issue_prefix: %v", err)
|
||||
}
|
||||
|
||||
cleanup := func() {
|
||||
store.Close()
|
||||
os.RemoveAll(tmpDir)
|
||||
|
||||
@@ -161,10 +161,7 @@ func TestDaemonIntegration(t *testing.T) {
|
||||
}
|
||||
|
||||
testDBPath := filepath.Join(dbDir, "test.db")
|
||||
testStore, err := sqlite.New(testDBPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test database: %v", err)
|
||||
}
|
||||
testStore := newTestStore(t, testDBPath)
|
||||
|
||||
oldStore := store
|
||||
oldDBPath := dbPath
|
||||
@@ -305,10 +302,7 @@ func TestDaemonRPCServerIntegration(t *testing.T) {
|
||||
}
|
||||
|
||||
testDBPath := filepath.Join(dbDir, "test.db")
|
||||
testStore, err := sqlite.New(testDBPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test database: %v", err)
|
||||
}
|
||||
testStore := newTestStore(t, testDBPath)
|
||||
defer testStore.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
@@ -342,10 +336,7 @@ func TestDaemonConcurrentOperations(t *testing.T) {
|
||||
}
|
||||
|
||||
testDBPath := filepath.Join(dbDir, "test.db")
|
||||
testStore, err := sqlite.New(testDBPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test database: %v", err)
|
||||
}
|
||||
testStore := newTestStore(t, testDBPath)
|
||||
defer testStore.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
@@ -410,10 +401,7 @@ func TestDaemonSocketCleanupOnShutdown(t *testing.T) {
|
||||
socketPath := filepath.Join(tmpDir, "test.sock")
|
||||
testDBPath := filepath.Join(tmpDir, "test.db")
|
||||
|
||||
testStore, err := sqlite.New(testDBPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test database: %v", err)
|
||||
}
|
||||
testStore := newTestStore(t, testDBPath)
|
||||
|
||||
server := newMockDaemonServer(socketPath, testStore)
|
||||
|
||||
@@ -543,10 +531,7 @@ func TestDaemonGracefulShutdown(t *testing.T) {
|
||||
socketPath := filepath.Join(tmpDir, "test.sock")
|
||||
testDBPath := filepath.Join(tmpDir, "test.db")
|
||||
|
||||
testStore, err := sqlite.New(testDBPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test database: %v", err)
|
||||
}
|
||||
testStore := newTestStore(t, testDBPath)
|
||||
|
||||
server := newMockDaemonServer(socketPath, testStore)
|
||||
|
||||
|
||||
28
cmd/bd/test_helpers_test.go
Normal file
28
cmd/bd/test_helpers_test.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/steveyegge/beads/internal/storage/sqlite"
|
||||
)
|
||||
|
||||
// newTestStore creates a SQLite store with issue_prefix configured (bd-166)
|
||||
// This prevents "database not initialized" errors in tests
|
||||
func newTestStore(t *testing.T, dbPath string) *sqlite.SQLiteStorage {
|
||||
t.Helper()
|
||||
|
||||
store, err := sqlite.New(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test database: %v", err)
|
||||
}
|
||||
|
||||
// CRITICAL (bd-166): Set issue_prefix to prevent "database not initialized" errors
|
||||
ctx := context.Background()
|
||||
if err := store.SetConfig(ctx, "issue_prefix", "bd"); err != nil {
|
||||
store.Close()
|
||||
t.Fatalf("Failed to set issue_prefix: %v", err)
|
||||
}
|
||||
|
||||
return store
|
||||
}
|
||||
Reference in New Issue
Block a user