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
This commit is contained in:
265
internal/reset/reset.go
Normal file
265
internal/reset/reset.go
Normal file
@@ -0,0 +1,265 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user