Files
beads/internal/reset/backup.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

102 lines
2.7 KiB
Go

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()
}