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
266 lines
6.8 KiB
Go
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
|
|
}
|