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:
Steve Yegge
2025-12-13 09:41:37 +11:00
parent 7949b4215c
commit ca9d306ef0
6 changed files with 754 additions and 4 deletions

101
internal/reset/backup.go Normal file
View File

@@ -0,0 +1,101 @@
package reset
import (
"fmt"
"io"
"os"
"path/filepath"
"time"
)
// CreateBackup creates a backup of the .beads directory.
// It copies .beads/ to .beads-backup-{timestamp}/ where timestamp is in YYYYMMDD-HHMMSS format.
// File permissions are preserved during the copy.
// Returns the backup path on success, or an error if the backup directory already exists.
func CreateBackup(beadsDir string) (backupPath string, err error) {
// Generate timestamp in YYYYMMDD-HHMMSS format
timestamp := time.Now().Format("20060102-150405")
// Construct backup directory path
parentDir := filepath.Dir(beadsDir)
backupPath = filepath.Join(parentDir, fmt.Sprintf(".beads-backup-%s", timestamp))
// Check if backup directory already exists
if _, err := os.Stat(backupPath); err == nil {
return "", fmt.Errorf("backup directory already exists: %s", backupPath)
}
// Create backup directory
if err := os.Mkdir(backupPath, 0755); err != nil {
return "", fmt.Errorf("failed to create backup directory: %w", err)
}
// Copy directory recursively
if err := copyDir(beadsDir, backupPath); err != nil {
// Attempt to clean up partial backup on failure
_ = os.RemoveAll(backupPath)
return "", fmt.Errorf("failed to copy directory: %w", err)
}
return backupPath, nil
}
// copyDir recursively copies a directory tree, preserving file permissions
func copyDir(src, dst string) error {
// Walk the source directory
err := filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
// Compute relative path
relPath, err := filepath.Rel(src, path)
if err != nil {
return err
}
// Construct destination path
dstPath := filepath.Join(dst, relPath)
// Handle directories and files
if info.IsDir() {
// Skip the root directory (already created)
if path == src {
return nil
}
// Create directory with same permissions
return os.Mkdir(dstPath, info.Mode())
}
// Copy file
return copyFile(path, dstPath, info.Mode())
})
return err
}
// copyFile copies a single file, preserving permissions
func copyFile(src, dst string, perm os.FileMode) error {
// #nosec G304 -- backup function only copies files within user's project
sourceFile, err := os.Open(src)
if err != nil {
return err
}
defer sourceFile.Close()
// Create destination file with preserved permissions
// #nosec G304 -- backup function only writes files within user's project
destFile, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, perm)
if err != nil {
return err
}
defer destFile.Close()
// Copy contents
if _, err := io.Copy(destFile, sourceFile); err != nil {
return err
}
// Ensure data is written to disk
return destFile.Sync()
}

View File

@@ -0,0 +1,252 @@
package reset
import (
"os"
"path/filepath"
"regexp"
"strings"
"testing"
"time"
)
func TestCreateBackup(t *testing.T) {
// Create temporary directory structure
tmpDir := t.TempDir()
beadsDir := filepath.Join(tmpDir, ".beads")
if err := os.Mkdir(beadsDir, 0755); err != nil {
t.Fatalf("failed to create test .beads directory: %v", err)
}
// Create some test files in .beads
testFiles := map[string]string{
"issues.jsonl": `{"id":"test-1","title":"Test Issue"}`,
"metadata.json": `{"version":"1.0"}`,
"config.yaml": `prefix: test`,
}
for name, content := range testFiles {
path := filepath.Join(beadsDir, name)
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
t.Fatalf("failed to create test file %s: %v", name, err)
}
}
// Create a subdirectory with a file
subDir := filepath.Join(beadsDir, "subdir")
if err := os.Mkdir(subDir, 0755); err != nil {
t.Fatalf("failed to create subdirectory: %v", err)
}
subFile := filepath.Join(subDir, "subfile.txt")
if err := os.WriteFile(subFile, []byte("subfile content"), 0644); err != nil {
t.Fatalf("failed to create subfile: %v", err)
}
// Create backup
backupPath, err := CreateBackup(beadsDir)
if err != nil {
t.Fatalf("CreateBackup failed: %v", err)
}
// Verify backup path format
expectedPattern := `\.beads-backup-\d{8}-\d{6}$`
matched, _ := regexp.MatchString(expectedPattern, backupPath)
if !matched {
t.Errorf("backup path %q doesn't match expected pattern %q", backupPath, expectedPattern)
}
// Verify backup directory exists
info, err := os.Stat(backupPath)
if err != nil {
t.Fatalf("backup directory not created: %v", err)
}
if !info.IsDir() {
t.Errorf("backup path is not a directory")
}
// Verify all files were copied
for name, expectedContent := range testFiles {
backupFilePath := filepath.Join(backupPath, name)
content, err := os.ReadFile(backupFilePath)
if err != nil {
t.Errorf("failed to read backed up file %s: %v", name, err)
continue
}
if string(content) != expectedContent {
t.Errorf("file %s content mismatch: got %q, want %q", name, content, expectedContent)
}
}
// Verify subdirectory and its file were copied
backupSubFile := filepath.Join(backupPath, "subdir", "subfile.txt")
content, err := os.ReadFile(backupSubFile)
if err != nil {
t.Errorf("failed to read backed up subfile: %v", err)
}
if string(content) != "subfile content" {
t.Errorf("subfile content mismatch: got %q, want %q", content, "subfile content")
}
}
func TestCreateBackup_PreservesPermissions(t *testing.T) {
// Create temporary directory structure
tmpDir := t.TempDir()
beadsDir := filepath.Join(tmpDir, ".beads")
if err := os.Mkdir(beadsDir, 0755); err != nil {
t.Fatalf("failed to create test .beads directory: %v", err)
}
// Create a file with specific permissions
testFile := filepath.Join(beadsDir, "test.txt")
if err := os.WriteFile(testFile, []byte("test"), 0600); err != nil {
t.Fatalf("failed to create test file: %v", err)
}
// Create backup
backupPath, err := CreateBackup(beadsDir)
if err != nil {
t.Fatalf("CreateBackup failed: %v", err)
}
// Check permissions on backed up file
backupFile := filepath.Join(backupPath, "test.txt")
info, err := os.Stat(backupFile)
if err != nil {
t.Fatalf("failed to stat backed up file: %v", err)
}
// Verify permissions (mask to ignore permission bits we don't care about)
gotPerm := info.Mode() & 0777
wantPerm := os.FileMode(0600)
if gotPerm != wantPerm {
t.Errorf("permissions not preserved: got %o, want %o", gotPerm, wantPerm)
}
}
func TestCreateBackup_ErrorIfBackupExists(t *testing.T) {
tmpDir := t.TempDir()
beadsDir := filepath.Join(tmpDir, ".beads")
if err := os.Mkdir(beadsDir, 0755); err != nil {
t.Fatalf("failed to create test .beads directory: %v", err)
}
// Create first backup
backupPath1, err := CreateBackup(beadsDir)
if err != nil {
t.Fatalf("first CreateBackup failed: %v", err)
}
// Try to create backup with same timestamp (simulate collision)
// We need to create the directory manually since timestamps differ
timestamp := time.Now().Format("20060102-150405")
existingBackup := filepath.Join(tmpDir, ".beads-backup-"+timestamp)
if err := os.Mkdir(existingBackup, 0755); err != nil {
// If the directory already exists from the first backup, use that
if !os.IsExist(err) {
t.Fatalf("failed to create existing backup directory: %v", err)
}
}
// Mock the time to ensure we get the same timestamp
// Since we can't mock time.Now(), we'll create a second backup immediately
// and verify the first one succeeded
_, err = CreateBackup(beadsDir)
if err != nil {
// Either we got an error (good) or we created a new backup with different timestamp
// The test is mainly to verify the first backup succeeded
if !strings.Contains(err.Error(), "backup directory already exists") {
// Different timestamp, that's fine - backup system works
t.Logf("Second backup got different timestamp (expected): %v", err)
}
}
// Verify first backup exists
if _, err := os.Stat(backupPath1); os.IsNotExist(err) {
t.Errorf("first backup was not created")
}
}
func TestCreateBackup_TimestampFormat(t *testing.T) {
tmpDir := t.TempDir()
beadsDir := filepath.Join(tmpDir, ".beads")
if err := os.Mkdir(beadsDir, 0755); err != nil {
t.Fatalf("failed to create test .beads directory: %v", err)
}
backupPath, err := CreateBackup(beadsDir)
if err != nil {
t.Fatalf("CreateBackup failed: %v", err)
}
// Extract timestamp from backup path
baseName := filepath.Base(backupPath)
if !strings.HasPrefix(baseName, ".beads-backup-") {
t.Errorf("backup name doesn't have expected prefix: %s", baseName)
}
timestamp := strings.TrimPrefix(baseName, ".beads-backup-")
// Verify timestamp format: YYYYMMDD-HHMMSS
expectedPattern := `^\d{8}-\d{6}$`
matched, err := regexp.MatchString(expectedPattern, timestamp)
if err != nil {
t.Fatalf("regex error: %v", err)
}
if !matched {
t.Errorf("timestamp %q doesn't match expected format YYYYMMDD-HHMMSS", timestamp)
}
// Verify timestamp is parseable and reasonable (within last day to handle timezone issues)
parsedTime, err := time.Parse("20060102-150405", timestamp)
if err != nil {
t.Errorf("failed to parse timestamp %q: %v", timestamp, err)
}
now := time.Now()
diff := now.Sub(parsedTime)
// Allow for timezone differences and clock skew (within 24 hours)
if diff < -24*time.Hour || diff > 24*time.Hour {
t.Errorf("timestamp %q is not within reasonable range (diff: %v)", timestamp, diff)
}
}
func TestCreateBackup_NonexistentSource(t *testing.T) {
tmpDir := t.TempDir()
beadsDir := filepath.Join(tmpDir, ".beads")
// Don't create the directory
_, err := CreateBackup(beadsDir)
if err == nil {
t.Error("expected error for nonexistent source directory, got nil")
}
}
func TestCreateBackup_EmptyDirectory(t *testing.T) {
tmpDir := t.TempDir()
beadsDir := filepath.Join(tmpDir, ".beads")
if err := os.Mkdir(beadsDir, 0755); err != nil {
t.Fatalf("failed to create test .beads directory: %v", err)
}
backupPath, err := CreateBackup(beadsDir)
if err != nil {
t.Fatalf("CreateBackup failed on empty directory: %v", err)
}
// Verify backup directory exists
info, err := os.Stat(backupPath)
if err != nil {
t.Fatalf("backup directory not created: %v", err)
}
if !info.IsDir() {
t.Errorf("backup path is not a directory")
}
// Verify backup is empty (only contains what filepath.Walk copies)
entries, err := os.ReadDir(backupPath)
if err != nil {
t.Fatalf("failed to read backup directory: %v", err)
}
if len(entries) != 0 {
t.Errorf("expected empty backup directory, got %d entries", len(entries))
}
}

129
internal/reset/git.go Normal file
View File

@@ -0,0 +1,129 @@
package reset
import (
"bytes"
"fmt"
"os/exec"
"path/filepath"
"strings"
)
// GitState represents the current state of the git repository
type GitState struct {
IsRepo bool // Is this a git repository?
IsDirty bool // Are there uncommitted changes?
IsDetached bool // Is HEAD detached?
Branch string // Current branch name (empty if detached)
}
// CheckGitState detects the current git repository state
func CheckGitState(beadsDir string) (*GitState, error) {
state := &GitState{}
// Check if we're in a git repository
cmd := exec.Command("git", "rev-parse", "--git-dir")
if err := cmd.Run(); err != nil {
// Not a git repo - this is OK, we'll skip git operations gracefully
state.IsRepo = false
return state, nil
}
state.IsRepo = true
// Check if there are uncommitted changes specifically in .beads/
// (not the entire repo, just the beads directory)
cmd = exec.Command("git", "status", "--porcelain", "--", beadsDir)
var statusOut bytes.Buffer
cmd.Stdout = &statusOut
if err := cmd.Run(); err != nil {
return nil, fmt.Errorf("failed to check git status: %w", err)
}
state.IsDirty = len(strings.TrimSpace(statusOut.String())) > 0
// Check if HEAD is detached and get current branch
cmd = exec.Command("git", "symbolic-ref", "-q", "HEAD")
var branchOut bytes.Buffer
cmd.Stdout = &branchOut
err := cmd.Run()
if err != nil {
// symbolic-ref fails on detached HEAD
state.IsDetached = true
state.Branch = ""
} else {
state.IsDetached = false
// Extract branch name from refs/heads/branch-name
fullRef := strings.TrimSpace(branchOut.String())
state.Branch = strings.TrimPrefix(fullRef, "refs/heads/")
}
return state, nil
}
// GitRemoveBeads uses git rm to remove the JSONL files from the index
// This prepares for a reset by staging the removal of beads files
func GitRemoveBeads(beadsDir string) error {
// Find all JSONL files in the beads directory
// We support both canonical (issues.jsonl) and legacy (beads.jsonl) names
jsonlFiles := []string{
filepath.Join(beadsDir, "issues.jsonl"),
filepath.Join(beadsDir, "beads.jsonl"),
}
// Try to remove each file (git rm ignores non-existent files with --ignore-unmatch)
for _, file := range jsonlFiles {
cmd := exec.Command("git", "rm", "--ignore-unmatch", "--quiet", file)
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("failed to git rm %s: %w\nstderr: %s", file, err, stderr.String())
}
}
return nil
}
// GitCommitReset creates a commit with the removal of beads files
// Returns nil without error if there's nothing to commit
func GitCommitReset(message string) error {
// First check if there are any staged changes
cmd := exec.Command("git", "diff", "--cached", "--quiet")
if err := cmd.Run(); err == nil {
// Exit code 0 means no staged changes - nothing to commit
return nil
}
// Exit code 1 means there are staged changes - proceed with commit
cmd = exec.Command("git", "commit", "-m", message)
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("failed to commit reset: %w\nstderr: %s", err, stderr.String())
}
return nil
}
// GitAddAndCommit stages the beads directory and creates a commit with fresh state
func GitAddAndCommit(beadsDir, message string) error {
// Add the entire beads directory (this will pick up the fresh JSONL)
cmd := exec.Command("git", "add", beadsDir)
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("failed to git add %s: %w\nstderr: %s", beadsDir, err, stderr.String())
}
// Create the commit
cmd = exec.Command("git", "commit", "-m", message)
stderr.Reset()
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("failed to commit fresh state: %w\nstderr: %s", err, stderr.String())
}
return nil
}

265
internal/reset/reset.go Normal file
View 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
}