Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 28 additions & 4 deletions apps/stackpanel-go/internal/fileops/apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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)
}
Expand Down
85 changes: 85 additions & 0 deletions apps/stackpanel-go/internal/fileops/apply_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
5 changes: 5 additions & 0 deletions apps/stackpanel-go/internal/fileops/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down