fix: bd repo commands write to YAML and cleanup on remove (#683)
- bd repo add/remove now writes to .beads/config.yaml instead of database - bd repo remove deletes hydrated issues from the removed repo - Added internal/config/repos.go for YAML config manipulation - Added DeleteIssuesBySourceRepo for cleanup on remove Fixes config disconnect where bd repo add wrote to DB but hydration read from YAML. Breaking change: bd repo add no longer accepts optional alias argument. Co-authored-by: Dylan Conlin <dylan.conlin@gmail.com> 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -388,6 +388,124 @@ func (s *SQLiteStorage) upsertIssueInTx(ctx context.Context, tx *sql.Tx, issue *
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteIssuesBySourceRepo permanently removes all issues from a specific source repository.
|
||||
// This is used when a repo is removed from the multi-repo configuration.
|
||||
// It also cleans up related data: dependencies, labels, comments, events, and dirty markers.
|
||||
// Returns the number of issues deleted.
|
||||
func (s *SQLiteStorage) DeleteIssuesBySourceRepo(ctx context.Context, sourceRepo string) (int, error) {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to begin transaction: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
// Get the list of issue IDs to delete
|
||||
rows, err := tx.QueryContext(ctx, `SELECT id FROM issues WHERE source_repo = ?`, sourceRepo)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to query issues: %w", err)
|
||||
}
|
||||
var issueIDs []string
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
rows.Close()
|
||||
return 0, fmt.Errorf("failed to scan issue ID: %w", err)
|
||||
}
|
||||
issueIDs = append(issueIDs, id)
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
return 0, fmt.Errorf("failed to iterate issues: %w", err)
|
||||
}
|
||||
|
||||
if len(issueIDs) == 0 {
|
||||
if err := tx.Commit(); err != nil {
|
||||
return 0, fmt.Errorf("failed to commit empty transaction: %w", err)
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// Delete dependencies (both directions) for all affected issues
|
||||
for _, id := range issueIDs {
|
||||
_, err = tx.ExecContext(ctx, `DELETE FROM dependencies WHERE issue_id = ? OR depends_on_id = ?`, id, id)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to delete dependencies for %s: %w", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Delete events for all affected issues
|
||||
for _, id := range issueIDs {
|
||||
_, err = tx.ExecContext(ctx, `DELETE FROM events WHERE issue_id = ?`, id)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to delete events for %s: %w", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Delete comments for all affected issues
|
||||
for _, id := range issueIDs {
|
||||
_, err = tx.ExecContext(ctx, `DELETE FROM comments WHERE issue_id = ?`, id)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to delete comments for %s: %w", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Delete labels for all affected issues
|
||||
for _, id := range issueIDs {
|
||||
_, err = tx.ExecContext(ctx, `DELETE FROM labels WHERE issue_id = ?`, id)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to delete labels for %s: %w", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Delete dirty markers for all affected issues
|
||||
for _, id := range issueIDs {
|
||||
_, err = tx.ExecContext(ctx, `DELETE FROM dirty_issues WHERE issue_id = ?`, id)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to delete dirty marker for %s: %w", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Delete the issues themselves
|
||||
result, err := tx.ExecContext(ctx, `DELETE FROM issues WHERE source_repo = ?`, sourceRepo)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to delete issues: %w", err)
|
||||
}
|
||||
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to check rows affected: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return 0, fmt.Errorf("failed to commit transaction: %w", err)
|
||||
}
|
||||
|
||||
return int(rowsAffected), nil
|
||||
}
|
||||
|
||||
// ClearRepoMtime removes the mtime cache entry for a repository.
|
||||
// This is used when a repo is removed from the multi-repo configuration.
|
||||
func (s *SQLiteStorage) ClearRepoMtime(ctx context.Context, repoPath string) error {
|
||||
// Expand tilde in path to match how it's stored
|
||||
expandedPath, err := expandTilde(repoPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to expand path: %w", err)
|
||||
}
|
||||
|
||||
// Get absolute path to match how it's stored in repo_mtimes
|
||||
absRepoPath, err := filepath.Abs(expandedPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get absolute path: %w", err)
|
||||
}
|
||||
|
||||
_, err = s.db.ExecContext(ctx, `DELETE FROM repo_mtimes WHERE repo_path = ?`, absRepoPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete mtime cache: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// expandTilde expands ~ in a file path to the user's home directory.
|
||||
func expandTilde(path string) (string, error) {
|
||||
if !strings.HasPrefix(path, "~") {
|
||||
|
||||
@@ -500,6 +500,263 @@ func TestImportJSONLFileOutOfOrderDeps(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestDeleteIssuesBySourceRepo(t *testing.T) {
|
||||
t.Run("deletes all issues from specified repo", func(t *testing.T) {
|
||||
store, cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create issues with different source_repos
|
||||
issue1 := &types.Issue{
|
||||
ID: "bd-repo1-1",
|
||||
Title: "Repo1 Issue 1",
|
||||
Status: types.StatusOpen,
|
||||
Priority: 1,
|
||||
IssueType: types.TypeTask,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
SourceRepo: "~/test-repo",
|
||||
}
|
||||
issue1.ContentHash = issue1.ComputeContentHash()
|
||||
|
||||
issue2 := &types.Issue{
|
||||
ID: "bd-repo1-2",
|
||||
Title: "Repo1 Issue 2",
|
||||
Status: types.StatusOpen,
|
||||
Priority: 1,
|
||||
IssueType: types.TypeTask,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
SourceRepo: "~/test-repo",
|
||||
}
|
||||
issue2.ContentHash = issue2.ComputeContentHash()
|
||||
|
||||
issue3 := &types.Issue{
|
||||
ID: "bd-primary-1",
|
||||
Title: "Primary Issue",
|
||||
Status: types.StatusOpen,
|
||||
Priority: 1,
|
||||
IssueType: types.TypeTask,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
SourceRepo: ".",
|
||||
}
|
||||
issue3.ContentHash = issue3.ComputeContentHash()
|
||||
|
||||
// Insert all issues
|
||||
if err := store.CreateIssue(ctx, issue1, "test"); err != nil {
|
||||
t.Fatalf("failed to create issue1: %v", err)
|
||||
}
|
||||
if err := store.CreateIssue(ctx, issue2, "test"); err != nil {
|
||||
t.Fatalf("failed to create issue2: %v", err)
|
||||
}
|
||||
if err := store.CreateIssue(ctx, issue3, "test"); err != nil {
|
||||
t.Fatalf("failed to create issue3: %v", err)
|
||||
}
|
||||
|
||||
// Delete issues from ~/test-repo
|
||||
deletedCount, err := store.DeleteIssuesBySourceRepo(ctx, "~/test-repo")
|
||||
if err != nil {
|
||||
t.Fatalf("DeleteIssuesBySourceRepo() error = %v", err)
|
||||
}
|
||||
if deletedCount != 2 {
|
||||
t.Errorf("expected 2 issues deleted, got %d", deletedCount)
|
||||
}
|
||||
|
||||
// Verify ~/test-repo issues are gone
|
||||
// GetIssue returns (nil, nil) when issue doesn't exist
|
||||
issue1After, err := store.GetIssue(ctx, "bd-repo1-1")
|
||||
if issue1After != nil || err != nil {
|
||||
t.Errorf("expected bd-repo1-1 to be deleted, got issue=%v, err=%v", issue1After, err)
|
||||
}
|
||||
issue2After, err := store.GetIssue(ctx, "bd-repo1-2")
|
||||
if issue2After != nil || err != nil {
|
||||
t.Errorf("expected bd-repo1-2 to be deleted, got issue=%v, err=%v", issue2After, err)
|
||||
}
|
||||
|
||||
// Verify primary issue still exists
|
||||
primary, err := store.GetIssue(ctx, "bd-primary-1")
|
||||
if err != nil {
|
||||
t.Fatalf("primary issue should still exist: %v", err)
|
||||
}
|
||||
if primary.Title != "Primary Issue" {
|
||||
t.Errorf("expected 'Primary Issue', got %q", primary.Title)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("returns 0 when no issues match", func(t *testing.T) {
|
||||
store, cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create an issue with a different source_repo
|
||||
issue := &types.Issue{
|
||||
ID: "bd-other-1",
|
||||
Title: "Other Issue",
|
||||
Status: types.StatusOpen,
|
||||
Priority: 1,
|
||||
IssueType: types.TypeTask,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
SourceRepo: ".",
|
||||
}
|
||||
issue.ContentHash = issue.ComputeContentHash()
|
||||
|
||||
if err := store.CreateIssue(ctx, issue, "test"); err != nil {
|
||||
t.Fatalf("failed to create issue: %v", err)
|
||||
}
|
||||
|
||||
// Delete from non-existent repo
|
||||
deletedCount, err := store.DeleteIssuesBySourceRepo(ctx, "~/nonexistent")
|
||||
if err != nil {
|
||||
t.Fatalf("DeleteIssuesBySourceRepo() error = %v", err)
|
||||
}
|
||||
if deletedCount != 0 {
|
||||
t.Errorf("expected 0 issues deleted, got %d", deletedCount)
|
||||
}
|
||||
|
||||
// Verify original issue still exists
|
||||
_, err = store.GetIssue(ctx, "bd-other-1")
|
||||
if err != nil {
|
||||
t.Errorf("issue should still exist: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("cleans up related data", func(t *testing.T) {
|
||||
store, cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create an issue with labels and comments
|
||||
issue := &types.Issue{
|
||||
ID: "bd-cleanup-1",
|
||||
Title: "Cleanup Test Issue",
|
||||
Status: types.StatusOpen,
|
||||
Priority: 1,
|
||||
IssueType: types.TypeTask,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
SourceRepo: "~/cleanup-repo",
|
||||
Labels: []string{"test", "cleanup"},
|
||||
}
|
||||
issue.ContentHash = issue.ComputeContentHash()
|
||||
|
||||
if err := store.CreateIssue(ctx, issue, "test"); err != nil {
|
||||
t.Fatalf("failed to create issue: %v", err)
|
||||
}
|
||||
|
||||
// Add a comment
|
||||
_, err := store.AddIssueComment(ctx, "bd-cleanup-1", "test", "Test comment")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to add comment: %v", err)
|
||||
}
|
||||
|
||||
// Delete the repo
|
||||
deletedCount, err := store.DeleteIssuesBySourceRepo(ctx, "~/cleanup-repo")
|
||||
if err != nil {
|
||||
t.Fatalf("DeleteIssuesBySourceRepo() error = %v", err)
|
||||
}
|
||||
if deletedCount != 1 {
|
||||
t.Errorf("expected 1 issue deleted, got %d", deletedCount)
|
||||
}
|
||||
|
||||
// Verify issue is gone
|
||||
// GetIssue returns (nil, nil) when issue doesn't exist
|
||||
issueAfter, err := store.GetIssue(ctx, "bd-cleanup-1")
|
||||
if issueAfter != nil || err != nil {
|
||||
t.Errorf("expected issue to be deleted, got issue=%v, err=%v", issueAfter, err)
|
||||
}
|
||||
|
||||
// Verify labels are gone (query directly to check)
|
||||
var labelCount int
|
||||
err = store.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM labels WHERE issue_id = ?`, "bd-cleanup-1").Scan(&labelCount)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to query labels: %v", err)
|
||||
}
|
||||
if labelCount != 0 {
|
||||
t.Errorf("expected 0 labels, got %d", labelCount)
|
||||
}
|
||||
|
||||
// Verify comments are gone
|
||||
var commentCount int
|
||||
err = store.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM comments WHERE issue_id = ?`, "bd-cleanup-1").Scan(&commentCount)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to query comments: %v", err)
|
||||
}
|
||||
if commentCount != 0 {
|
||||
t.Errorf("expected 0 comments, got %d", commentCount)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestClearRepoMtime(t *testing.T) {
|
||||
t.Run("clears mtime cache for repo", func(t *testing.T) {
|
||||
store, cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Insert a mtime cache entry directly
|
||||
tmpDir := t.TempDir()
|
||||
jsonlPath := filepath.Join(tmpDir, "issues.jsonl")
|
||||
|
||||
// Create a dummy JSONL file for the mtime
|
||||
f, err := os.Create(jsonlPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create JSONL: %v", err)
|
||||
}
|
||||
f.Close()
|
||||
|
||||
_, err = store.db.ExecContext(ctx, `
|
||||
INSERT INTO repo_mtimes (repo_path, jsonl_path, mtime_ns, last_checked)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`, tmpDir, jsonlPath, 12345, time.Now())
|
||||
if err != nil {
|
||||
t.Fatalf("failed to insert mtime cache: %v", err)
|
||||
}
|
||||
|
||||
// Verify it exists
|
||||
var count int
|
||||
err = store.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM repo_mtimes WHERE repo_path = ?`, tmpDir).Scan(&count)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to query mtime cache: %v", err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("expected 1 mtime cache entry, got %d", count)
|
||||
}
|
||||
|
||||
// Clear it
|
||||
if err := store.ClearRepoMtime(ctx, tmpDir); err != nil {
|
||||
t.Fatalf("ClearRepoMtime() error = %v", err)
|
||||
}
|
||||
|
||||
// Verify it's gone
|
||||
err = store.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM repo_mtimes WHERE repo_path = ?`, tmpDir).Scan(&count)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to query mtime cache: %v", err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Errorf("expected 0 mtime cache entries, got %d", count)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("handles non-existent repo gracefully", func(t *testing.T) {
|
||||
store, cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Clear a repo that doesn't exist in cache - should not error
|
||||
err := store.ClearRepoMtime(ctx, "/nonexistent/path")
|
||||
if err != nil {
|
||||
t.Errorf("ClearRepoMtime() should not error for non-existent path: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestExportToMultiRepo(t *testing.T) {
|
||||
t.Run("returns nil in single-repo mode", func(t *testing.T) {
|
||||
store, cleanup := setupTestDB(t)
|
||||
|
||||
Reference in New Issue
Block a user