diff --git a/apps/stackpanel-go/internal/fileops/apply.go b/apps/stackpanel-go/internal/fileops/apply.go index 32a6886e..b88120a7 100644 --- a/apps/stackpanel-go/internal/fileops/apply.go +++ b/apps/stackpanel-go/internal/fileops/apply.go @@ -312,10 +312,16 @@ func applyBlockEntry(projectRoot string, entry Entry, summary *Summary) (stateEn // applyFullCopyEntry overwrites the target with content from a Nix store path. // If adopt="backup" and this is the first run, the existing file is backed up. +// CreatedByUs is set to true when the file did not exist before the first apply, +// so that revert knows whether to delete the file or leave it alone. func applyFullCopyEntry(projectRoot string, entry Entry, prev stateEntry, hasPrev bool, summary *Summary) (stateEntry, error) { targetPath := filepath.Join(projectRoot, entry.Path) - if !hasPrev && entry.Adopt == "backup" { - if _, err := os.Stat(targetPath); err == nil { + + createdByUs := prev.CreatedByUs // carry forward on subsequent runs + if !hasPrev { + if _, statErr := os.Stat(targetPath); os.IsNotExist(statErr) { + createdByUs = true // file didn't exist — stackpanel is creating it + } else if statErr == nil && entry.Adopt == "backup" { backupPath, wroteBackup, err := backupFile(targetPath) if err != nil { return stateEntry{}, err @@ -343,8 +349,9 @@ func applyFullCopyEntry(projectRoot string, entry Entry, prev stateEntry, hasPre } return stateEntry{ - Type: "full-copy", - BackupPath: prev.BackupPath, + Type: "full-copy", + BackupPath: prev.BackupPath, + CreatedByUs: createdByUs, }, nil } @@ -411,6 +418,23 @@ func revertStateEntry(projectRoot, path string, prev stateEntry, summary *Summar return nil case "full-copy": targetPath := filepath.Join(projectRoot, path) + if prev.BackupPath != "" { + content, err := os.ReadFile(prev.BackupPath) + if err != nil && !os.IsNotExist(err) { + return fmt.Errorf("fileops: read backup for %s: %w", path, err) + } + if err == nil { + if _, writeErr := writeBytes(targetPath, content, ""); writeErr != nil { + return fmt.Errorf("fileops: restore %s from backup: %w", path, writeErr) + } + summary.Restored = append(summary.Restored, targetPath) + return nil + } + } + if !prev.CreatedByUs { + // File predated stackpanel management; no backup — leave as-is. + return nil + } if err := os.Remove(targetPath); err != nil && !os.IsNotExist(err) { return fmt.Errorf("fileops: remove stale %s: %w", path, err) } diff --git a/apps/stackpanel-go/internal/fileops/apply_test.go b/apps/stackpanel-go/internal/fileops/apply_test.go index b61ff274..df3b9ad1 100644 --- a/apps/stackpanel-go/internal/fileops/apply_test.go +++ b/apps/stackpanel-go/internal/fileops/apply_test.go @@ -505,3 +505,88 @@ func nestedMapValue(t *testing.T, root map[string]any, path ...string) any { } return current } + +func TestFullCopyRevertDeletesFileCreatedByUs(t *testing.T) { + t.Parallel() + + projectRoot := t.TempDir() + stateDir := filepath.Join(projectRoot, ".stack", "profile") + targetPath := filepath.Join(projectRoot, "turbo.json") + + // Write content to a "store path" (simulates a Nix store file). + storePath := filepath.Join(projectRoot, "turbo.json.managed") + if err := os.WriteFile(storePath, []byte(`{"pipeline":{}}`+"\n"), 0644); err != nil { + t.Fatalf("write store path: %v", err) + } + + // File does NOT exist before first apply — stackpanel creates it. + manifest := Manifest{ + Version: 1, + Files: []Entry{ + {Path: "turbo.json", Type: "full-copy", StorePath: storePath}, + }, + } + if _, err := ApplyManifest(projectRoot, stateDir, manifest); err != nil { + t.Fatalf("first apply: %v", err) + } + if _, err := os.Stat(targetPath); err != nil { + t.Fatalf("file should exist after first apply: %v", err) + } + + // Remove the entry from the manifest — simulates turbo.enable = false. + empty := Manifest{Version: 1} + summary, err := ApplyManifest(projectRoot, stateDir, empty) + if err != nil { + t.Fatalf("revert apply: %v", err) + } + + if _, err := os.Stat(targetPath); !os.IsNotExist(err) { + t.Fatalf("file should have been deleted on revert (createdByUs=true), err=%v", err) + } + if !slices.Contains(summary.Removed, targetPath) { + t.Fatalf("expected %s in summary.Removed, got %#v", targetPath, summary) + } +} + +func TestFullCopyRevertPreservesPreExistingFile(t *testing.T) { + t.Parallel() + + projectRoot := t.TempDir() + stateDir := filepath.Join(projectRoot, ".stack", "profile") + targetPath := filepath.Join(projectRoot, "turbo.json") + + // File EXISTS before first apply — user wrote it themselves. + original := []byte(`{"pipeline":{"build":{}}}`+"\n") + if err := os.WriteFile(targetPath, original, 0644); err != nil { + t.Fatalf("write pre-existing file: %v", err) + } + + storePath := filepath.Join(projectRoot, "turbo.json.managed") + if err := os.WriteFile(storePath, []byte(`{"pipeline":{}}`+"\n"), 0644); err != nil { + t.Fatalf("write store path: %v", err) + } + + manifest := Manifest{ + Version: 1, + Files: []Entry{ + {Path: "turbo.json", Type: "full-copy", StorePath: storePath}, + }, + } + if _, err := ApplyManifest(projectRoot, stateDir, manifest); err != nil { + t.Fatalf("first apply: %v", err) + } + + // Remove the entry — stackpanel should NOT delete the pre-existing file. + empty := Manifest{Version: 1} + summary, err := ApplyManifest(projectRoot, stateDir, empty) + if err != nil { + t.Fatalf("revert apply: %v", err) + } + + if _, err := os.Stat(targetPath); err != nil { + t.Fatalf("pre-existing file should NOT have been deleted on revert: %v", err) + } + if slices.Contains(summary.Removed, targetPath) { + t.Fatalf("expected %s NOT in summary.Removed, got %#v", targetPath, summary) + } +} diff --git a/apps/stackpanel-go/internal/fileops/types.go b/apps/stackpanel-go/internal/fileops/types.go index 8230cc6f..fdd0b385 100644 --- a/apps/stackpanel-go/internal/fileops/types.go +++ b/apps/stackpanel-go/internal/fileops/types.go @@ -59,9 +59,14 @@ type stateFile struct { // stateEntry records per-file state from the previous apply. // For json-ops: OriginalJSON is the baseline before our edits, ManagedPaths // lists every JSON path we own. This lets us restore user content on removal. +// For full-copy: CreatedByUs is true when stackpanel created the file (it did +// not exist before the first apply). On revert, we only delete the file when +// CreatedByUs is true. Existing state files deserialize CreatedByUs as false, +// meaning files are left alone — the safe direction for backward compatibility. type stateEntry struct { Type string `json:"type"` BackupPath string `json:"backupPath,omitempty"` + CreatedByUs bool `json:"createdByUs,omitempty"` OriginalJSON any `json:"originalJson,omitempty"` ManagedPaths [][]string `json:"managedPaths,omitempty"` BlockLabel string `json:"blockLabel,omitempty"`