Merge bd-dvw8-rictus: GH#505 reset

This commit is contained in:
Steve Yegge
2025-12-16 01:18:38 -08:00
8 changed files with 10285 additions and 12 deletions

9590
.beads/deletions.jsonl Normal file

File diff suppressed because it is too large Load Diff

349
cmd/bd/reset.go Normal file
View File

@@ -0,0 +1,349 @@
package main
import (
"bufio"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/fatih/color"
"github.com/spf13/cobra"
"github.com/steveyegge/beads/internal/git"
)
var resetCmd = &cobra.Command{
Use: "reset [--confirm <remote-url>]",
Short: "Completely remove beads from this repository",
Long: `Completely remove beads from this repository, including all issue data.
This command:
1. Stops any running daemon
2. Removes git hooks installed by beads
3. Removes the merge driver configuration
4. Removes beads entry from .gitattributes
5. Deletes the .beads directory (ALL ISSUE DATA)
6. Removes the sync worktree (if exists)
WARNING: This permanently deletes all issue data. Consider backing up first:
cp .beads/issues.jsonl ~/beads-backup-$(date +%Y%m%d).jsonl
SAFETY: You must pass --confirm with the git remote URL to confirm.
EXAMPLES:
# Preview what would be removed
bd reset --dry-run
# Actually reset (requires confirmation)
bd reset --confirm origin
# Or with the full remote URL
bd reset --confirm git@github.com:user/repo.git
After reset, you can reinitialize with:
bd init`,
Run: runReset,
}
var (
resetConfirm string
resetDryRun bool
resetForce bool
)
func init() {
resetCmd.Flags().StringVar(&resetConfirm, "confirm", "", "Remote name or URL to confirm reset (required)")
resetCmd.Flags().BoolVar(&resetDryRun, "dry-run", false, "Preview what would be removed without making changes")
resetCmd.Flags().BoolVar(&resetForce, "force", false, "Skip confirmation prompts")
rootCmd.AddCommand(resetCmd)
}
func runReset(cmd *cobra.Command, args []string) {
// Check if we're in a beads repository
beadsDir := findBeadsDir()
if beadsDir == "" {
fmt.Fprintln(os.Stderr, "Error: No .beads directory found - nothing to reset")
os.Exit(1)
}
// Get git root
gitRoot, err := git.GetMainRepoRoot()
if err != nil {
fmt.Fprintf(os.Stderr, "Error: Not in a git repository: %v\n", err)
os.Exit(1)
}
// Verify confirmation unless dry-run or force
if !resetDryRun && !resetForce {
if resetConfirm == "" {
fmt.Fprintln(os.Stderr, color.RedString("Error: --confirm flag required"))
fmt.Fprintln(os.Stderr, "")
fmt.Fprintln(os.Stderr, "This command permanently deletes all issue data.")
fmt.Fprintln(os.Stderr, "To confirm, pass the remote name or URL:")
fmt.Fprintln(os.Stderr, "")
fmt.Fprintln(os.Stderr, " bd reset --confirm origin")
fmt.Fprintln(os.Stderr, "")
fmt.Fprintln(os.Stderr, "Or use --dry-run to preview what would be removed:")
fmt.Fprintln(os.Stderr, " bd reset --dry-run")
os.Exit(1)
}
// Verify the confirmation matches a remote
if !verifyResetConfirmation(resetConfirm) {
fmt.Fprintf(os.Stderr, color.RedString("Error: '%s' does not match any git remote\n"), resetConfirm)
fmt.Fprintln(os.Stderr, "")
fmt.Fprintln(os.Stderr, "Available remotes:")
listRemotes()
os.Exit(1)
}
}
if resetDryRun {
fmt.Println(color.YellowString("DRY RUN - no changes will be made"))
fmt.Println()
}
// Track what we'll do/did
var actions []string
// 1. Stop daemon
fmt.Println("Checking for running daemon...")
if resetDryRun {
actions = append(actions, "Would stop daemon (if running)")
} else {
if err := stopDaemonForReset(); err != nil {
fmt.Fprintf(os.Stderr, "Warning: %v\n", err)
} else {
actions = append(actions, "Stopped daemon")
}
}
// 2. Uninstall hooks
fmt.Println("Checking git hooks...")
if resetDryRun {
hooks := CheckGitHooks()
for _, h := range hooks {
if h.Installed {
actions = append(actions, fmt.Sprintf("Would remove hook: %s", h.Name))
}
}
} else {
if err := uninstallHooks(); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to uninstall hooks: %v\n", err)
} else {
actions = append(actions, "Removed git hooks")
}
}
// 3. Remove merge driver config
fmt.Println("Checking merge driver config...")
if resetDryRun {
actions = append(actions, "Would remove merge driver config (git config)")
} else {
if err := removeMergeDriverConfig(); err != nil {
fmt.Fprintf(os.Stderr, "Warning: %v\n", err)
} else {
actions = append(actions, "Removed merge driver config")
}
}
// 4. Remove .gitattributes entry
fmt.Println("Checking .gitattributes...")
gitattributes := filepath.Join(gitRoot, ".gitattributes")
if resetDryRun {
if _, err := os.Stat(gitattributes); err == nil {
actions = append(actions, "Would remove beads entry from .gitattributes")
}
} else {
if err := removeBeadsFromGitattributes(gitattributes); err != nil {
fmt.Fprintf(os.Stderr, "Warning: %v\n", err)
} else {
actions = append(actions, "Removed beads entry from .gitattributes")
}
}
// 5. Remove .beads directory
fmt.Println("Checking .beads directory...")
if resetDryRun {
// Count files
fileCount := 0
_ = filepath.Walk(beadsDir, func(path string, info os.FileInfo, err error) error {
if err == nil && !info.IsDir() {
fileCount++
}
return nil
})
actions = append(actions, fmt.Sprintf("Would delete .beads directory (%d files)", fileCount))
} else {
if err := os.RemoveAll(beadsDir); err != nil {
fmt.Fprintf(os.Stderr, "Error: failed to remove .beads directory: %v\n", err)
os.Exit(1)
}
actions = append(actions, "Deleted .beads directory")
}
// 6. Remove sync worktree
gitDir, _ := git.GetGitDir()
worktreePath := filepath.Join(gitDir, "beads-worktrees")
if _, err := os.Stat(worktreePath); err == nil {
fmt.Println("Checking sync worktree...")
if resetDryRun {
actions = append(actions, "Would remove sync worktree")
} else {
// First try to remove the git worktree properly
_ = exec.Command("git", "worktree", "remove", "--force", filepath.Join(worktreePath, "beads-sync")).Run()
// Then remove the directory
if err := os.RemoveAll(worktreePath); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to remove worktree: %v\n", err)
} else {
actions = append(actions, "Removed sync worktree")
}
}
}
// Summary
fmt.Println()
if resetDryRun {
fmt.Println(color.YellowString("Actions that would be taken:"))
} else {
fmt.Println(color.GreenString("Reset complete!"))
}
for _, action := range actions {
fmt.Printf(" %s %s\n", color.GreenString("✓"), action)
}
if !resetDryRun {
fmt.Println()
fmt.Println("To reinitialize beads, run:")
fmt.Println(" bd init")
}
}
// verifyResetConfirmation checks if the provided confirmation matches a remote
func verifyResetConfirmation(confirm string) bool {
// Get list of remotes
output, err := exec.Command("git", "remote", "-v").Output()
if err != nil {
return false
}
lines := strings.Split(string(output), "\n")
for _, line := range lines {
parts := strings.Fields(line)
if len(parts) >= 2 {
remoteName := parts[0]
remoteURL := parts[1]
// Match against remote name or URL
if confirm == remoteName || confirm == remoteURL {
return true
}
// Also match partial URLs (e.g., user/repo)
if strings.Contains(remoteURL, confirm) {
return true
}
}
}
return false
}
// listRemotes prints available git remotes
func listRemotes() {
output, err := exec.Command("git", "remote", "-v").Output()
if err != nil {
fmt.Fprintln(os.Stderr, " (unable to list remotes)")
return
}
// Dedupe (git remote -v shows each twice for fetch/push)
seen := make(map[string]bool)
lines := strings.Split(string(output), "\n")
for _, line := range lines {
parts := strings.Fields(line)
if len(parts) >= 2 {
key := parts[0] + " " + parts[1]
if !seen[key] {
seen[key] = true
fmt.Printf(" %s\t%s\n", parts[0], parts[1])
}
}
}
}
// stopDaemonForReset stops the daemon for this repository
func stopDaemonForReset() error {
// Try to stop daemon via the daemon command
cmd := exec.Command("bd", "daemon", "--stop")
_ = cmd.Run() // Ignore errors - daemon might not be running
// Also try killall
cmd = exec.Command("bd", "daemons", "killall")
_ = cmd.Run()
return nil
}
// removeMergeDriverConfig removes the beads merge driver from git config
func removeMergeDriverConfig() error {
// Remove merge driver settings
_ = exec.Command("git", "config", "--unset", "merge.beads.driver").Run()
_ = exec.Command("git", "config", "--unset", "merge.beads.name").Run()
return nil
}
// removeBeadsFromGitattributes removes beads entries from .gitattributes
func removeBeadsFromGitattributes(path string) error {
// Check if file exists
if _, err := os.Stat(path); os.IsNotExist(err) {
return nil // Nothing to do
}
// Read the file
// #nosec G304 -- path comes from gitRoot which is validated
content, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("failed to read .gitattributes: %w", err)
}
// Filter out beads-related lines
var newLines []string
inBeadsSection := false
scanner := bufio.NewScanner(strings.NewReader(string(content)))
for scanner.Scan() {
line := scanner.Text()
// Skip beads comment header
if strings.Contains(line, "Use bd merge for beads") {
inBeadsSection = true
continue
}
// Skip beads merge attribute lines
if strings.Contains(line, "merge=beads") {
inBeadsSection = false
continue
}
// Skip empty lines immediately after beads section
if inBeadsSection && strings.TrimSpace(line) == "" {
inBeadsSection = false
continue
}
inBeadsSection = false
newLines = append(newLines, line)
}
// If file would be empty (or just whitespace), remove it
newContent := strings.Join(newLines, "\n")
if strings.TrimSpace(newContent) == "" {
return os.Remove(path)
}
// Write back
// #nosec G306 -- .gitattributes should be readable
return os.WriteFile(path, []byte(newContent+"\n"), 0644)
}

99
cmd/bd/reset_test.go Normal file
View File

@@ -0,0 +1,99 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestRemoveBeadsFromGitattributes(t *testing.T) {
t.Run("removes beads entry", func(t *testing.T) {
tmpDir := t.TempDir()
gitattributes := filepath.Join(tmpDir, ".gitattributes")
content := `*.png binary
# Use bd merge for beads JSONL files
.beads/issues.jsonl merge=beads
*.jpg binary
`
if err := os.WriteFile(gitattributes, []byte(content), 0644); err != nil {
t.Fatalf("failed to write test file: %v", err)
}
if err := removeBeadsFromGitattributes(gitattributes); err != nil {
t.Fatalf("removeBeadsFromGitattributes failed: %v", err)
}
result, err := os.ReadFile(gitattributes)
if err != nil {
t.Fatalf("failed to read result: %v", err)
}
if strings.Contains(string(result), "merge=beads") {
t.Error("beads merge entry should have been removed")
}
if strings.Contains(string(result), "Use bd merge") {
t.Error("beads comment should have been removed")
}
if !strings.Contains(string(result), "*.png binary") {
t.Error("other entries should be preserved")
}
if !strings.Contains(string(result), "*.jpg binary") {
t.Error("other entries should be preserved")
}
})
t.Run("removes file if only beads entry", func(t *testing.T) {
tmpDir := t.TempDir()
gitattributes := filepath.Join(tmpDir, ".gitattributes")
content := `# Use bd merge for beads JSONL files
.beads/issues.jsonl merge=beads
`
if err := os.WriteFile(gitattributes, []byte(content), 0644); err != nil {
t.Fatalf("failed to write test file: %v", err)
}
if err := removeBeadsFromGitattributes(gitattributes); err != nil {
t.Fatalf("removeBeadsFromGitattributes failed: %v", err)
}
if _, err := os.Stat(gitattributes); !os.IsNotExist(err) {
t.Error("file should have been deleted when only beads entries present")
}
})
t.Run("handles non-existent file", func(t *testing.T) {
tmpDir := t.TempDir()
gitattributes := filepath.Join(tmpDir, ".gitattributes")
// File doesn't exist - should not error
if err := removeBeadsFromGitattributes(gitattributes); err != nil {
t.Fatalf("should not error on non-existent file: %v", err)
}
})
}
func TestVerifyResetConfirmation(t *testing.T) {
// This test depends on git being available and a remote being configured
// Skip if not in a git repo
if _, err := os.Stat(".git"); os.IsNotExist(err) {
t.Skip("not in a git repository")
}
t.Run("accepts origin", func(t *testing.T) {
// Most repos have an "origin" remote
// If not, this test will just pass since we can't reliably test this
result := verifyResetConfirmation("origin")
// Don't assert - just make sure it doesn't panic
_ = result
})
t.Run("rejects invalid remote", func(t *testing.T) {
result := verifyResetConfirmation("nonexistent-remote-12345")
if result {
t.Error("should reject non-existent remote")
}
})
}

View File

@@ -32,6 +32,27 @@ func validateBatchIssuesWithCustomStatuses(issues []*types.Issue, customStatuses
issue.UpdatedAt = now
}
// Defensive fix for closed_at invariant (GH#523): older versions of bd could
// close issues without setting closed_at. Fix by using max(created_at, updated_at) + 1s.
if issue.Status == types.StatusClosed && issue.ClosedAt == nil {
maxTime := issue.CreatedAt
if issue.UpdatedAt.After(maxTime) {
maxTime = issue.UpdatedAt
}
closedAt := maxTime.Add(time.Second)
issue.ClosedAt = &closedAt
}
// Defensive fix for deleted_at invariant: tombstones must have deleted_at
if issue.Status == types.StatusTombstone && issue.DeletedAt == nil {
maxTime := issue.CreatedAt
if issue.UpdatedAt.After(maxTime) {
maxTime = issue.UpdatedAt
}
deletedAt := maxTime.Add(time.Second)
issue.DeletedAt = &deletedAt
}
if err := issue.ValidateWithCustomStatuses(customStatuses); err != nil {
return fmt.Errorf("validation failed for issue %d: %w", i, err)
}

View File

@@ -480,3 +480,119 @@ func TestBulkOperations(t *testing.T) {
}
})
}
// TestDefensiveClosedAtFix tests GH#523 - closed issues without closed_at timestamp
// from older versions of bd should be automatically fixed during import.
func TestDefensiveClosedAtFix(t *testing.T) {
t.Run("sets closed_at for closed issues missing it", func(t *testing.T) {
now := time.Now()
pastTime := now.Add(-24 * time.Hour)
issues := []*types.Issue{
{
Title: "Closed issue without closed_at",
Priority: 1,
IssueType: "task",
Status: "closed",
CreatedAt: pastTime,
UpdatedAt: pastTime.Add(time.Hour),
// ClosedAt intentionally NOT set - simulating old bd data
},
}
err := validateBatchIssues(issues)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// closed_at should be set to max(created_at, updated_at) + 1 second
if issues[0].ClosedAt == nil {
t.Fatal("closed_at should have been set")
}
expectedClosedAt := pastTime.Add(time.Hour).Add(time.Second)
if !issues[0].ClosedAt.Equal(expectedClosedAt) {
t.Errorf("closed_at mismatch: want %v, got %v", expectedClosedAt, *issues[0].ClosedAt)
}
})
t.Run("preserves existing closed_at", func(t *testing.T) {
now := time.Now()
pastTime := now.Add(-24 * time.Hour)
closedTime := pastTime.Add(2 * time.Hour)
issues := []*types.Issue{
{
Title: "Closed issue with closed_at",
Priority: 1,
IssueType: "task",
Status: "closed",
CreatedAt: pastTime,
UpdatedAt: pastTime.Add(time.Hour),
ClosedAt: &closedTime,
},
}
err := validateBatchIssues(issues)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// closed_at should be preserved
if !issues[0].ClosedAt.Equal(closedTime) {
t.Errorf("closed_at should be preserved: want %v, got %v", closedTime, *issues[0].ClosedAt)
}
})
t.Run("does not set closed_at for open issues", func(t *testing.T) {
issues := []*types.Issue{
{
Title: "Open issue",
Priority: 1,
IssueType: "task",
Status: "open",
},
}
err := validateBatchIssues(issues)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if issues[0].ClosedAt != nil {
t.Error("closed_at should remain nil for open issues")
}
})
t.Run("sets deleted_at for tombstones missing it", func(t *testing.T) {
now := time.Now()
pastTime := now.Add(-24 * time.Hour)
issues := []*types.Issue{
{
Title: "Tombstone without deleted_at",
Priority: 1,
IssueType: "task",
Status: "tombstone",
CreatedAt: pastTime,
UpdatedAt: pastTime.Add(time.Hour),
// DeletedAt intentionally NOT set
},
}
err := validateBatchIssues(issues)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// deleted_at should be set to max(created_at, updated_at) + 1 second
if issues[0].DeletedAt == nil {
t.Fatal("deleted_at should have been set")
}
expectedDeletedAt := pastTime.Add(time.Hour).Add(time.Second)
if !issues[0].DeletedAt.Equal(expectedDeletedAt) {
t.Errorf("deleted_at mismatch: want %v, got %v", expectedDeletedAt, *issues[0].DeletedAt)
}
})
}

View File

@@ -227,6 +227,27 @@ func (s *SQLiteStorage) importJSONLFile(ctx context.Context, jsonlPath, sourceRe
// upsertIssueInTx inserts or updates an issue within a transaction.
// Uses INSERT OR REPLACE to handle both new and existing issues.
func (s *SQLiteStorage) upsertIssueInTx(ctx context.Context, tx *sql.Tx, issue *types.Issue, customStatuses []string) error {
// Defensive fix for closed_at invariant (GH#523): older versions of bd could
// close issues without setting closed_at. Fix by using max(created_at, updated_at) + 1s.
if issue.Status == types.StatusClosed && issue.ClosedAt == nil {
maxTime := issue.CreatedAt
if issue.UpdatedAt.After(maxTime) {
maxTime = issue.UpdatedAt
}
closedAt := maxTime.Add(time.Second)
issue.ClosedAt = &closedAt
}
// Defensive fix for deleted_at invariant: tombstones must have deleted_at
if issue.Status == types.StatusTombstone && issue.DeletedAt == nil {
maxTime := issue.CreatedAt
if issue.UpdatedAt.After(maxTime) {
maxTime = issue.UpdatedAt
}
deletedAt := maxTime.Add(time.Second)
issue.DeletedAt = &deletedAt
}
// Validate issue (with custom status support, bd-1pj6)
if err := issue.ValidateWithCustomStatuses(customStatuses); err != nil {
return fmt.Errorf("validation failed: %w", err)

View File

@@ -50,16 +50,41 @@ func (s *SQLiteStorage) CreateIssue(ctx context.Context, issue *types.Issue, act
return fmt.Errorf("failed to get custom statuses: %w", err)
}
// Set timestamps first so defensive fixes can use them
now := time.Now()
if issue.CreatedAt.IsZero() {
issue.CreatedAt = now
}
if issue.UpdatedAt.IsZero() {
issue.UpdatedAt = now
}
// Defensive fix for closed_at invariant (GH#523): older versions of bd could
// close issues without setting closed_at. Fix by using max(created_at, updated_at) + 1s.
if issue.Status == types.StatusClosed && issue.ClosedAt == nil {
maxTime := issue.CreatedAt
if issue.UpdatedAt.After(maxTime) {
maxTime = issue.UpdatedAt
}
closedAt := maxTime.Add(time.Second)
issue.ClosedAt = &closedAt
}
// Defensive fix for deleted_at invariant: tombstones must have deleted_at
if issue.Status == types.StatusTombstone && issue.DeletedAt == nil {
maxTime := issue.CreatedAt
if issue.UpdatedAt.After(maxTime) {
maxTime = issue.UpdatedAt
}
deletedAt := maxTime.Add(time.Second)
issue.DeletedAt = &deletedAt
}
// Validate issue before creating (with custom status support)
if err := issue.ValidateWithCustomStatuses(customStatuses); err != nil {
return fmt.Errorf("validation failed: %w", err)
}
// Set timestamps
now := time.Now()
issue.CreatedAt = now
issue.UpdatedAt = now
// Compute content hash (bd-95)
if issue.ContentHash == "" {
issue.ContentHash = issue.ComputeContentHash()

View File

@@ -97,16 +97,41 @@ func (t *sqliteTxStorage) CreateIssue(ctx context.Context, issue *types.Issue, a
return fmt.Errorf("failed to get custom statuses: %w", err)
}
// Set timestamps first so defensive fixes can use them
now := time.Now()
if issue.CreatedAt.IsZero() {
issue.CreatedAt = now
}
if issue.UpdatedAt.IsZero() {
issue.UpdatedAt = now
}
// Defensive fix for closed_at invariant (GH#523): older versions of bd could
// close issues without setting closed_at. Fix by using max(created_at, updated_at) + 1s.
if issue.Status == types.StatusClosed && issue.ClosedAt == nil {
maxTime := issue.CreatedAt
if issue.UpdatedAt.After(maxTime) {
maxTime = issue.UpdatedAt
}
closedAt := maxTime.Add(time.Second)
issue.ClosedAt = &closedAt
}
// Defensive fix for deleted_at invariant: tombstones must have deleted_at
if issue.Status == types.StatusTombstone && issue.DeletedAt == nil {
maxTime := issue.CreatedAt
if issue.UpdatedAt.After(maxTime) {
maxTime = issue.UpdatedAt
}
deletedAt := maxTime.Add(time.Second)
issue.DeletedAt = &deletedAt
}
// Validate issue before creating (with custom status support)
if err := issue.ValidateWithCustomStatuses(customStatuses); err != nil {
return fmt.Errorf("validation failed: %w", err)
}
// Set timestamps
now := time.Now()
issue.CreatedAt = now
issue.UpdatedAt = now
// Compute content hash (bd-95)
if issue.ContentHash == "" {
issue.ContentHash = issue.ComputeContentHash()
@@ -184,11 +209,38 @@ func (t *sqliteTxStorage) CreateIssues(ctx context.Context, issues []*types.Issu
// Validate and prepare all issues first (with custom status support)
now := time.Now()
for _, issue := range issues {
// Set timestamps first so defensive fixes can use them
if issue.CreatedAt.IsZero() {
issue.CreatedAt = now
}
if issue.UpdatedAt.IsZero() {
issue.UpdatedAt = now
}
// Defensive fix for closed_at invariant (GH#523): older versions of bd could
// close issues without setting closed_at. Fix by using max(created_at, updated_at) + 1s.
if issue.Status == types.StatusClosed && issue.ClosedAt == nil {
maxTime := issue.CreatedAt
if issue.UpdatedAt.After(maxTime) {
maxTime = issue.UpdatedAt
}
closedAt := maxTime.Add(time.Second)
issue.ClosedAt = &closedAt
}
// Defensive fix for deleted_at invariant: tombstones must have deleted_at
if issue.Status == types.StatusTombstone && issue.DeletedAt == nil {
maxTime := issue.CreatedAt
if issue.UpdatedAt.After(maxTime) {
maxTime = issue.UpdatedAt
}
deletedAt := maxTime.Add(time.Second)
issue.DeletedAt = &deletedAt
}
if err := issue.ValidateWithCustomStatuses(customStatuses); err != nil {
return fmt.Errorf("validation failed for issue: %w", err)
}
issue.CreatedAt = now
issue.UpdatedAt = now
if issue.ContentHash == "" {
issue.ContentHash = issue.ComputeContentHash()
}