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:
101
internal/reset/backup.go
Normal file
101
internal/reset/backup.go
Normal 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()
|
||||
}
|
||||
252
internal/reset/backup_test.go
Normal file
252
internal/reset/backup_test.go
Normal 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
129
internal/reset/git.go
Normal 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
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