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
253 lines
7.5 KiB
Go
253 lines
7.5 KiB
Go
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))
|
|
}
|
|
}
|