Files
beads/internal/reset/reset.go
Steve Yegge ca9d306ef0 feat(reset): implement core reset package for bd reset command
Phase 1 implementation of bd reset (GitHub #479):

- internal/reset/reset.go: Core reset logic with ResetOptions, ResetResult,
  ImpactSummary structs. Handles daemon killing, backup, file removal,
  git operations, and re-initialization.

- internal/reset/backup.go: CreateBackup() for timestamped .beads/ backups
  with permission preservation.

- internal/reset/git.go: Git state detection and operations for --hard mode.
  CheckGitState(), GitRemoveBeads(), GitCommitReset(), GitAddAndCommit().

- cmd/bd/doctor/gitignore.go: Add .beads-backup-*/ to gitignore template.

Code review fixes applied:
- Git rm now runs BEFORE file deletion (was backwards)
- Removed stderr output from core package (CLI-agnostic)
- IsDirty now checks only .beads/ changes, not entire repo
- GitCommitReset handles nothing to commit gracefully
2025-12-13 09:47:26 +11:00

266 lines
6.8 KiB
Go

// Package reset provides core reset functionality for cleaning beads state.
// This package is CLI-agnostic and returns errors for the CLI to handle.
package reset
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/steveyegge/beads/internal/beads"
"github.com/steveyegge/beads/internal/daemon"
"github.com/steveyegge/beads/internal/storage/sqlite"
"github.com/steveyegge/beads/internal/types"
)
// ResetOptions configures the reset operation
type ResetOptions struct {
Hard bool // Include git operations (git rm, commit)
Backup bool // Create backup before reset
DryRun bool // Preview only, don't execute
SkipInit bool // Don't re-initialize after reset
}
// ResetResult contains the results of a reset operation
type ResetResult struct {
IssuesDeleted int
TombstonesDeleted int
BackupPath string // if backup was created
DaemonsKilled int
}
// ImpactSummary describes what will be affected by a reset
type ImpactSummary struct {
IssueCount int
OpenCount int
ClosedCount int
TombstoneCount int
HasUncommitted bool // git dirty state in .beads/
}
// ValidateState checks if .beads/ directory exists and is valid for reset
func ValidateState() error {
beadsDir := beads.FindBeadsDir()
if beadsDir == "" {
return fmt.Errorf("no .beads directory found - nothing to reset")
}
// Verify it's a directory
info, err := os.Stat(beadsDir)
if err != nil {
return fmt.Errorf("failed to stat .beads directory: %w", err)
}
if !info.IsDir() {
return fmt.Errorf(".beads exists but is not a directory")
}
return nil
}
// CountImpact analyzes what will be deleted by a reset operation
func CountImpact() (*ImpactSummary, error) {
beadsDir := beads.FindBeadsDir()
if beadsDir == "" {
return nil, fmt.Errorf("no .beads directory found")
}
summary := &ImpactSummary{}
// Try to open database and count issues
dbPath := beads.FindDatabasePath()
if dbPath != "" {
ctx := context.Background()
store, err := sqlite.New(ctx, dbPath)
if err == nil {
defer store.Close()
// Count all issues including tombstones
allIssues, err := store.SearchIssues(ctx, "", types.IssueFilter{IncludeTombstones: true})
if err == nil {
summary.IssueCount = len(allIssues)
for _, issue := range allIssues {
if issue.IsTombstone() {
summary.TombstoneCount++
} else {
switch issue.Status {
case types.StatusOpen, types.StatusInProgress, types.StatusBlocked:
summary.OpenCount++
case types.StatusClosed:
summary.ClosedCount++
}
}
}
}
}
}
// Check git dirty state for .beads/
summary.HasUncommitted = hasUncommittedBeadsFiles()
return summary, nil
}
// Reset performs the core reset logic
func Reset(opts ResetOptions) (*ResetResult, error) {
// Validate state first
if err := ValidateState(); err != nil {
return nil, err
}
beadsDir := beads.FindBeadsDir()
result := &ResetResult{}
// Dry run: just count what would be affected
if opts.DryRun {
summary, err := CountImpact()
if err != nil {
return nil, err
}
result.IssuesDeleted = summary.IssueCount - summary.TombstoneCount
result.TombstonesDeleted = summary.TombstoneCount
return result, nil
}
// Step 1: Kill all daemons
daemons, err := daemon.DiscoverDaemons(nil)
if err == nil {
killResults := daemon.KillAllDaemons(daemons, true)
result.DaemonsKilled = killResults.Stopped
}
// Step 2: Count issues before deletion (for result reporting)
summary, _ := CountImpact()
if summary != nil {
result.IssuesDeleted = summary.IssueCount - summary.TombstoneCount
result.TombstonesDeleted = summary.TombstoneCount
}
// Step 3: Create backup if requested
if opts.Backup {
backupPath, err := createBackup(beadsDir)
if err != nil {
return nil, fmt.Errorf("failed to create backup: %w", err)
}
result.BackupPath = backupPath
}
// Step 4: Hard mode - git rm BEFORE deleting files
// (must happen while files still exist for git to track the removal)
if opts.Hard {
if err := gitRemoveBeads(); err != nil {
return nil, fmt.Errorf("git rm failed: %w", err)
}
}
// Step 5: Remove .beads directory
if err := os.RemoveAll(beadsDir); err != nil {
return nil, fmt.Errorf("failed to remove .beads directory: %w", err)
}
// Step 6: Re-initialize unless SkipInit is set
if !opts.SkipInit {
if err := reinitializeBeads(); err != nil {
return nil, fmt.Errorf("re-initialization failed: %w", err)
}
}
return result, nil
}
// createBackup creates a timestamped backup of the .beads directory
func createBackup(beadsDir string) (string, error) {
return CreateBackup(beadsDir)
}
// gitRemoveBeads performs git rm on .beads directory and commits
func gitRemoveBeads() error {
beadsDir := beads.FindBeadsDir()
if beadsDir == "" {
return nil
}
// Check git state
gitState, err := CheckGitState(beadsDir)
if err != nil {
return err
}
// Skip if not a git repo
if !gitState.IsRepo {
return nil
}
// Remove JSONL files from git
if err := GitRemoveBeads(beadsDir); err != nil {
return err
}
// Commit the reset
commitMsg := "Reset beads workspace\n\nRemoved .beads/ directory to start fresh."
return GitCommitReset(commitMsg)
}
// reinitializeBeads calls bd init logic to recreate the workspace
func reinitializeBeads() error {
// Get the current directory name for prefix auto-detection
cwd, err := os.Getwd()
if err != nil {
return fmt.Errorf("failed to get current directory: %w", err)
}
// Create .beads directory
beadsDir := filepath.Join(cwd, ".beads")
if err := os.MkdirAll(beadsDir, 0750); err != nil {
return fmt.Errorf("failed to create .beads directory: %w", err)
}
// Determine prefix from directory name
prefix := filepath.Base(cwd)
prefix = strings.TrimRight(prefix, "-")
// Create database
dbPath := filepath.Join(beadsDir, beads.CanonicalDatabaseName)
ctx := context.Background()
store, err := sqlite.New(ctx, dbPath)
if err != nil {
return fmt.Errorf("failed to create database: %w", err)
}
// Set issue prefix in config
if err := store.SetConfig(ctx, "issue_prefix", prefix); err != nil {
_ = store.Close()
return fmt.Errorf("failed to set issue prefix: %w", err)
}
// Set sync.branch if in git repo (non-fatal if it fails)
gitState, err := CheckGitState(beadsDir)
if err == nil && gitState.IsRepo && gitState.Branch != "" && !gitState.IsDetached {
// Ignore error - sync.branch is optional and CLI can set it later
_ = store.SetConfig(ctx, "sync.branch", gitState.Branch)
}
// Close the database
if err := store.Close(); err != nil {
return fmt.Errorf("failed to close database: %w", err)
}
return nil
}
// hasUncommittedBeadsFiles checks if .beads directory has uncommitted changes
func hasUncommittedBeadsFiles() bool {
beadsDir := beads.FindBeadsDir()
if beadsDir == "" {
return false
}
gitState, err := CheckGitState(beadsDir)
if err != nil || !gitState.IsRepo {
return false
}
return gitState.IsDirty
}