diff --git a/internal/archtest/baseline/dryrun.txt b/internal/archtest/baseline/dryrun.txt index 2184e82..6d663e2 100644 --- a/internal/archtest/baseline/dryrun.txt +++ b/internal/archtest/baseline/dryrun.txt @@ -2,32 +2,6 @@ # Each line is : # reason for a known existing violation. # Regenerate: ARCHTEST_UPDATE_BASELINE=1 go test ./internal/archtest/... -# Audited exemptions: read-only probes or helpers already gated by callers. -internal/doctor/doctor.go:50 # read-only diagnostic runner; doctor does not mutate user state -internal/doctor/doctor.go:54 # read-only diagnostic runner; doctor does not mutate user state -internal/dotfiles/dotfiles.go:260 # helper writes conflict backups only from linkWithStow after Link dry-run gate -internal/dotfiles/dotfiles.go:270 # helper restores conflict backups only after a non-dry-run stow failure -internal/dotfiles/dotfiles.go:327 # helper removes conflict files only from linkWithStow after Link dry-run gate -internal/shell/shell.go:249 # helper patches .zshrc only after RestoreFromSnapshot dry-run gate -internal/shell/shell.go:252 # helper patches .zshrc only after RestoreFromSnapshot dry-run gate -internal/shell/shell.go:253 # helper cleanup only after RestoreFromSnapshot dry-run gate -internal/snapshot/capture.go:200 # read-only package inventory probe -internal/snapshot/capture.go:239 # read-only package inventory probe -internal/snapshot/capture.go:269 # read-only macOS defaults probe -internal/snapshot/capture.go:303 # read-only git config probe -internal/snapshot/capture.go:307 # read-only git config probe -internal/snapshot/capture.go:335 # read-only developer tool version probe -internal/snapshot/capture.go:361 # read-only dotfiles remote probe -internal/sync/diff.go:275 # read-only dotfiles remote probe for diff display - -# Debt: state persistence is intentionally outside user config mutation, but -# should move to explicit dry-run-aware APIs instead of baseline-only reasoning. -internal/snapshot/local.go:22 # state persistence: creates ~/.openboot for saved snapshot -internal/snapshot/local.go:32 # state persistence: atomic snapshot temp write -internal/snapshot/local.go:35 # state persistence: atomic snapshot rename -internal/snapshot/local.go:36 # state persistence: temp-file cleanup after failed rename -internal/sync/source.go:63 # state persistence: creates ~/.openboot for sync source -internal/sync/source.go:73 # state persistence: atomic sync source temp write -internal/sync/source.go:76 # state persistence: atomic sync source rename -internal/sync/source.go:77 # state persistence: temp-file cleanup after failed rename -internal/sync/source.go:91 # state persistence: removes sync source metadata +# Audited exemptions: read-only diagnostic runners — doctor never mutates user state. +internal/doctor/doctor.go:50 # read-only diagnostic probe; doctor does not mutate user state +internal/doctor/doctor.go:54 # read-only diagnostic probe; doctor does not mutate user state diff --git a/internal/archtest/baseline/no-direct-exec.txt b/internal/archtest/baseline/no-direct-exec.txt index 32c0775..0f85b6b 100644 --- a/internal/archtest/baseline/no-direct-exec.txt +++ b/internal/archtest/baseline/no-direct-exec.txt @@ -9,8 +9,8 @@ internal/diff/compare.go:253 internal/dotfiles/dotfiles.go:23 internal/dotfiles/dotfiles.go:32 internal/dotfiles/dotfiles.go:66 -internal/dotfiles/dotfiles.go:284 -internal/dotfiles/dotfiles.go:379 +internal/dotfiles/dotfiles.go:290 +internal/dotfiles/dotfiles.go:388 internal/installer/step_system.go:85 internal/npm/npm.go:22 internal/permissions/screen_recording_cgo.go:21 diff --git a/internal/archtest/dryrun_test.go b/internal/archtest/dryrun_test.go index a6ae2e6..b23ad3f 100644 --- a/internal/archtest/dryrun_test.go +++ b/internal/archtest/dryrun_test.go @@ -24,7 +24,9 @@ var dryRunExemptPaths = []string{ // dryRunExemptFiles lists individual files exempt from the rule. var dryRunExemptFiles = []string{ - "internal/installer/state.go", // install state tracking + "internal/installer/state.go", // install state tracking + "internal/snapshot/capture.go", // read-only system probes (brew list, npm list, git config --get, etc.) + "internal/sync/diff.go", // read-only dotfiles remote probe for diff computation } // destructiveOsCalls lists os package functions that modify the filesystem. diff --git a/internal/cli/helpers.go b/internal/cli/helpers.go index c0a9308..8f97916 100644 --- a/internal/cli/helpers.go +++ b/internal/cli/helpers.go @@ -48,7 +48,7 @@ func saveSyncSourceIfRemote(c *config.Config) { Slug: c.RemoteConfig.Slug, InstalledAt: time.Now(), } - if err := syncpkg.SaveSource(source); err != nil { + if err := syncpkg.SaveSource(source, false); err != nil { ui.Warn(fmt.Sprintf("Failed to save sync source: %v", err)) } } diff --git a/internal/cli/install.go b/internal/cli/install.go index 56afc71..96a7fe9 100644 --- a/internal/cli/install.go +++ b/internal/cli/install.go @@ -145,7 +145,7 @@ func runInstallCmd(cmd *cobra.Command, args []string) error { if errors.Is(err, installer.ErrUserCancelled) { return nil } - if err == nil { + if err == nil && !installCfg.DryRun { saveSyncSourceIfRemote(installCfg) } return err @@ -307,7 +307,9 @@ func runSyncInstall(source *syncpkg.SyncSource, pickRaw string) error { //nolint missingCount := diff.TotalMissing() + diff.TotalChanged() if missingCount == 0 { ui.Success(fmt.Sprintf("Already up to date with %s.", label)) - updateSyncedAt(source, "", rc) + if !installCfg.DryRun { + updateSyncedAt(source, "", rc) + } return nil } diff --git a/internal/cli/snapshot.go b/internal/cli/snapshot.go index 08a31e0..565d343 100644 --- a/internal/cli/snapshot.go +++ b/internal/cli/snapshot.go @@ -90,7 +90,7 @@ func runSnapshot(cmd *cobra.Command) error { return nil } if localFlag { - path, err := snapshot.SaveLocal(snap) + path, err := snapshot.SaveLocal(snap, false) if err != nil { return fmt.Errorf("save snapshot: %w", err) } @@ -159,7 +159,7 @@ func interactiveSaveOrPublish(ctx context.Context, snap *snapshot.Snapshot) erro switch choice { case options[0]: - path, err := snapshot.SaveLocal(snap) + path, err := snapshot.SaveLocal(snap, false) if err != nil { return fmt.Errorf("save snapshot: %w", err) } @@ -167,7 +167,7 @@ func interactiveSaveOrPublish(ctx context.Context, snap *snapshot.Snapshot) erro case options[1]: return publishSnapshot(ctx, snap, "") case options[2]: - path, err := snapshot.SaveLocal(snap) + path, err := snapshot.SaveLocal(snap, false) if err != nil { return fmt.Errorf("save snapshot: %w", err) } diff --git a/internal/cli/snapshot_publish.go b/internal/cli/snapshot_publish.go index 7fb6e42..d1b1e18 100644 --- a/internal/cli/snapshot_publish.go +++ b/internal/cli/snapshot_publish.go @@ -101,7 +101,7 @@ func recordPublishResult(username, resultSlug, targetSlug, visibility, apiBase s InstalledAt: time.Now(), SyncedAt: time.Now(), } - if err := syncpkg.SaveSource(src); err != nil { + if err := syncpkg.SaveSource(src, false); err != nil { ui.Warn(fmt.Sprintf("Failed to save sync source: %v", err)) } } diff --git a/internal/cli/sync_helpers.go b/internal/cli/sync_helpers.go index 2b3531e..7cfce97 100644 --- a/internal/cli/sync_helpers.go +++ b/internal/cli/sync_helpers.go @@ -29,7 +29,7 @@ func updateSyncedAt(source *syncpkg.SyncSource, override string, rc *config.Remo SyncedAt: now, InstalledAt: installedAt, } - if err := syncpkg.SaveSource(updated); err != nil { + if err := syncpkg.SaveSource(updated, false); err != nil { ui.Warn(fmt.Sprintf("Failed to update sync source: %v", err)) } } diff --git a/internal/dotfiles/dotfiles.go b/internal/dotfiles/dotfiles.go index 5cacff5..2967572 100644 --- a/internal/dotfiles/dotfiles.go +++ b/internal/dotfiles/dotfiles.go @@ -252,7 +252,10 @@ func hasStowPackages(dotfilesPath string) bool { return false } -func backupFile(src, dst string) error { +func backupFile(src, dst string, dryRun bool) error { + if dryRun { + return nil + } data, err := os.ReadFile(src) if err != nil { return fmt.Errorf("read %s: %w", src, err) @@ -263,7 +266,10 @@ func backupFile(src, dst string) error { return nil } -func restoreFile(backup, original string) { +func restoreFile(backup, original string, dryRun bool) { + if dryRun { + return + } if _, err := os.Stat(backup); os.IsNotExist(err) { return } @@ -293,7 +299,10 @@ func ensureStow(dryRun bool) error { // backupConflicts walks a stow package directory and backs up any existing // regular files in targetDir that would conflict with stow. Returns the list // of backup pairs so they can be restored on failure or cleaned up on success. -func backupConflicts(pkgDir, targetDir string) ([][2]string, error) { +func backupConflicts(pkgDir, targetDir string, dryRun bool) ([][2]string, error) { + if dryRun { + return nil, nil + } var backed [][2]string err := filepath.WalkDir(pkgDir, func(path string, d os.DirEntry, err error) error { @@ -321,7 +330,7 @@ func backupConflicts(pkgDir, targetDir string) ([][2]string, error) { } backupPath := target + ".openboot.bak" - if bErr := backupFile(target, backupPath); bErr != nil { + if bErr := backupFile(target, backupPath, false); bErr != nil { return fmt.Errorf("backup %s: %w", target, bErr) } if rErr := os.Remove(target); rErr != nil { @@ -365,7 +374,7 @@ func linkWithStow(dotfilesPath string, dryRun bool) error { pkgDir := filepath.Join(dotfilesPath, pkg) // Back up any existing regular files that would conflict with stow. - backed, backupErr := backupConflicts(pkgDir, home) + backed, backupErr := backupConflicts(pkgDir, home, false) if backupErr != nil { errs = append(errs, fmt.Errorf("stow %s: %w", pkg, backupErr)) continue @@ -383,7 +392,7 @@ func linkWithStow(dotfilesPath string, dryRun bool) error { if err := cmd.Run(); err != nil { // Restore all backups so the user isn't left without their config. for _, pair := range backed { - restoreFile(pair[0], pair[1]) + restoreFile(pair[0], pair[1], false) } errs = append(errs, fmt.Errorf("stow %s: %w", pkg, err)) continue diff --git a/internal/dotfiles/dotfiles_test.go b/internal/dotfiles/dotfiles_test.go index 4a6ad6f..2865d3f 100644 --- a/internal/dotfiles/dotfiles_test.go +++ b/internal/dotfiles/dotfiles_test.go @@ -266,7 +266,7 @@ func TestBackupFile_CreatesBackup(t *testing.T) { require.NoError(t, os.WriteFile(src, []byte("hello"), 0644)) - require.NoError(t, backupFile(src, dst)) + require.NoError(t, backupFile(src, dst, false)) data, err := os.ReadFile(dst) require.NoError(t, err) @@ -275,7 +275,7 @@ func TestBackupFile_CreatesBackup(t *testing.T) { func TestBackupFile_MissingSrcReturnsError(t *testing.T) { tmpDir := t.TempDir() - err := backupFile(filepath.Join(tmpDir, "nonexistent"), filepath.Join(tmpDir, "backup")) + err := backupFile(filepath.Join(tmpDir, "nonexistent"), filepath.Join(tmpDir, "backup"), false) assert.Error(t, err) } @@ -286,7 +286,7 @@ func TestRestoreFile_MovesBackToOriginal(t *testing.T) { require.NoError(t, os.WriteFile(backup, []byte("restored"), 0644)) - restoreFile(backup, original) + restoreFile(backup, original, false) data, err := os.ReadFile(original) require.NoError(t, err) @@ -298,7 +298,7 @@ func TestRestoreFile_MovesBackToOriginal(t *testing.T) { func TestRestoreFile_NoopWhenBackupMissing(t *testing.T) { tmpDir := t.TempDir() - restoreFile(filepath.Join(tmpDir, "nonexistent.bak"), filepath.Join(tmpDir, "original")) + restoreFile(filepath.Join(tmpDir, "nonexistent.bak"), filepath.Join(tmpDir, "original"), false) } // initBareAndClone creates a bare repo with one commit and clones it into @@ -532,7 +532,7 @@ func TestBackupConflicts_BacksUpRegularFiles(t *testing.T) { require.NoError(t, os.MkdirAll(targetDir, 0755)) require.NoError(t, os.WriteFile(filepath.Join(targetDir, ".gitconfig"), []byte("original"), 0644)) - backed, err := backupConflicts(pkgDir, targetDir) + backed, err := backupConflicts(pkgDir, targetDir, false) require.NoError(t, err) assert.Len(t, backed, 1) @@ -560,7 +560,7 @@ func TestBackupConflicts_SkipsSymlinks(t *testing.T) { require.NoError(t, os.WriteFile(src, []byte("linked"), 0644)) require.NoError(t, os.Symlink(src, filepath.Join(targetDir, ".vimrc"))) - backed, err := backupConflicts(pkgDir, targetDir) + backed, err := backupConflicts(pkgDir, targetDir, false) require.NoError(t, err) assert.Len(t, backed, 0) } @@ -586,7 +586,7 @@ func TestBackupConflicts_SkipsSocketFiles(t *testing.T) { require.NoError(t, err) defer ln.Close() - backed, err := backupConflicts(pkgDir, targetDir) + backed, err := backupConflicts(pkgDir, targetDir, false) require.NoError(t, err) assert.Len(t, backed, 0, "socket files must not be backed up") } @@ -602,7 +602,7 @@ func TestBackupConflicts_SkipsMissingTargets(t *testing.T) { require.NoError(t, os.MkdirAll(targetDir, 0755)) // No .config in targetDir — nothing to back up. - backed, err := backupConflicts(pkgDir, targetDir) + backed, err := backupConflicts(pkgDir, targetDir, false) require.NoError(t, err) assert.Len(t, backed, 0) } diff --git a/internal/shell/shell.go b/internal/shell/shell.go index 3155cd7..ac9f951 100644 --- a/internal/shell/shell.go +++ b/internal/shell/shell.go @@ -218,7 +218,10 @@ var ( loosePluginsRe = regexp.MustCompile(`(?m)^plugins=\((?s:.*?)\)\n?`) ) -func patchZshrcBlock(zshrcPath, theme string, plugins []string) error { +func patchZshrcBlock(zshrcPath, theme string, plugins []string, dryRun bool) error { + if dryRun { + return nil + } raw, err := os.ReadFile(zshrcPath) if err != nil { return fmt.Errorf("read .zshrc: %w", err) @@ -380,5 +383,5 @@ source $ZSH/oh-my-zsh.sh return nil } - return patchZshrcBlock(zshrcPath, theme, plugins) + return patchZshrcBlock(zshrcPath, theme, plugins, dryRun) } diff --git a/internal/shell/shell_extra_test.go b/internal/shell/shell_extra_test.go index 7ada56a..f8e224d 100644 --- a/internal/shell/shell_extra_test.go +++ b/internal/shell/shell_extra_test.go @@ -105,7 +105,7 @@ source $ZSH/oh-my-zsh.sh ` require.NoError(t, os.WriteFile(zshrcPath, []byte(initial), 0600)) - require.NoError(t, patchZshrcBlock(zshrcPath, "agnoster", []string{"git", "docker"})) + require.NoError(t, patchZshrcBlock(zshrcPath, "agnoster", []string{"git", "docker"}, false)) content, err := os.ReadFile(zshrcPath) require.NoError(t, err) @@ -132,7 +132,7 @@ source $ZSH/oh-my-zsh.sh ` require.NoError(t, os.WriteFile(zshrcPath, []byte(initial), 0600)) - require.NoError(t, patchZshrcBlock(zshrcPath, "new-theme", []string{"git", "zsh-autosuggestions"})) + require.NoError(t, patchZshrcBlock(zshrcPath, "new-theme", []string{"git", "zsh-autosuggestions"}, false)) content, err := os.ReadFile(zshrcPath) require.NoError(t, err) @@ -151,7 +151,7 @@ func TestPatchZshrcBlock_NoTrailingNewlineHandled(t *testing.T) { // File without trailing newline. require.NoError(t, os.WriteFile(zshrcPath, []byte(`source $ZSH/oh-my-zsh.sh`), 0600)) - require.NoError(t, patchZshrcBlock(zshrcPath, "agnoster", []string{"git"})) + require.NoError(t, patchZshrcBlock(zshrcPath, "agnoster", []string{"git"}, false)) content, err := os.ReadFile(zshrcPath) require.NoError(t, err) @@ -170,7 +170,7 @@ func TestPatchZshrcBlock_InvalidIdentifierReturnsError(t *testing.T) { zshrcPath := filepath.Join(home, ".zshrc") require.NoError(t, os.WriteFile(zshrcPath, []byte("# placeholder\n"), 0600)) - err := patchZshrcBlock(zshrcPath, "theme with spaces!", []string{"git"}) + err := patchZshrcBlock(zshrcPath, "theme with spaces!", []string{"git"}, false) require.Error(t, err) } diff --git a/internal/snapshot/local.go b/internal/snapshot/local.go index 63d450d..844b4b7 100644 --- a/internal/snapshot/local.go +++ b/internal/snapshot/local.go @@ -15,7 +15,11 @@ func LocalPath() string { return filepath.Join(home, ".openboot", "snapshot.json") } -func SaveLocal(snap *Snapshot) (string, error) { +func SaveLocal(snap *Snapshot, dryRun bool) (string, error) { + if dryRun { + return "", nil + } + path := LocalPath() dir := filepath.Dir(path) diff --git a/internal/snapshot/snapshot_extra_test.go b/internal/snapshot/snapshot_extra_test.go index dcfcf59..e98e48b 100644 --- a/internal/snapshot/snapshot_extra_test.go +++ b/internal/snapshot/snapshot_extra_test.go @@ -29,7 +29,7 @@ func TestSaveLocal_CreatesFileAndDirectory(t *testing.T) { }, } - path, err := SaveLocal(snap) + path, err := SaveLocal(snap, false) require.NoError(t, err) assert.NotEmpty(t, path) assert.Contains(t, path, ".openboot") @@ -54,7 +54,7 @@ func TestSaveLocal_FileIsValidJSON(t *testing.T) { }, } - path, err := SaveLocal(snap) + path, err := SaveLocal(snap, false) require.NoError(t, err) data, readErr := os.ReadFile(path) @@ -89,7 +89,7 @@ func TestSaveLocal_LoadLocal_RoundTrip(t *testing.T) { }, } - _, err := SaveLocal(original) + _, err := SaveLocal(original, false) require.NoError(t, err) loaded, err := LoadLocal() @@ -113,7 +113,7 @@ func TestSaveLocal_AtomicWrite_TmpFileCleaned(t *testing.T) { snap := &Snapshot{Version: 1, CapturedAt: time.Now(), Hostname: "atomic"} - path, err := SaveLocal(snap) + path, err := SaveLocal(snap, false) require.NoError(t, err) // The .tmp file should not exist after a successful save. @@ -126,11 +126,11 @@ func TestSaveLocal_OverwritesPreviousSnapshot(t *testing.T) { t.Setenv("HOME", tmpDir) first := &Snapshot{Version: 1, CapturedAt: time.Now(), Hostname: "first"} - _, err := SaveLocal(first) + _, err := SaveLocal(first, false) require.NoError(t, err) second := &Snapshot{Version: 1, CapturedAt: time.Now(), Hostname: "second"} - _, err = SaveLocal(second) + _, err = SaveLocal(second, false) require.NoError(t, err) loaded, err := LoadLocal() diff --git a/internal/sync/source.go b/internal/sync/source.go index d45f0db..2ce8245 100644 --- a/internal/sync/source.go +++ b/internal/sync/source.go @@ -53,7 +53,11 @@ func LoadSource() (*SyncSource, error) { } // SaveSource persists the sync source to disk using atomic write. -func SaveSource(source *SyncSource) error { +func SaveSource(source *SyncSource, dryRun bool) error { + if dryRun { + return nil + } + path, err := SourcePath() if err != nil { return fmt.Errorf("save sync source: %w", err) @@ -82,7 +86,10 @@ func SaveSource(source *SyncSource) error { } // DeleteSource removes the sync source file. -func DeleteSource() error { +func DeleteSource(dryRun bool) error { + if dryRun { + return nil + } path, err := SourcePath() if err != nil { return fmt.Errorf("delete sync source: %w", err) diff --git a/internal/sync/source_test.go b/internal/sync/source_test.go index 97ed185..55aa15b 100644 --- a/internal/sync/source_test.go +++ b/internal/sync/source_test.go @@ -23,7 +23,7 @@ func TestSaveAndLoadSource(t *testing.T) { InstalledAt: now, } - err := SaveSource(source) + err := SaveSource(source, false) require.NoError(t, err) // Verify file exists with correct permissions @@ -60,13 +60,13 @@ func TestDeleteSource(t *testing.T) { Username: "bob", Slug: "default", } - require.NoError(t, SaveSource(source)) + require.NoError(t, SaveSource(source, false)) loaded, err := LoadSource() require.NoError(t, err) require.NotNil(t, loaded) - require.NoError(t, DeleteSource()) + require.NoError(t, DeleteSource(false)) loaded, err = LoadSource() assert.NoError(t, err) @@ -77,7 +77,7 @@ func TestDeleteSourceNotExist(t *testing.T) { tmpDir := t.TempDir() t.Setenv("HOME", tmpDir) - err := DeleteSource() + err := DeleteSource(false) assert.NoError(t, err) } @@ -99,14 +99,14 @@ func TestSaveSourceOverwrite(t *testing.T) { Username: "alice", Slug: "old", } - require.NoError(t, SaveSource(first)) + require.NoError(t, SaveSource(first, false)) second := &SyncSource{ UserSlug: "alice/new", Username: "alice", Slug: "new", } - require.NoError(t, SaveSource(second)) + require.NoError(t, SaveSource(second, false)) loaded, err := LoadSource() require.NoError(t, err) @@ -135,7 +135,7 @@ func TestSaveSourceCreatesDirectory(t *testing.T) { // Directory doesn't exist yet source := &SyncSource{UserSlug: "test/config", Username: "test", Slug: "config"} - require.NoError(t, SaveSource(source)) + require.NoError(t, SaveSource(source, false)) // Verify directory was created with correct perms info, err := os.Stat(filepath.Join(tmpDir, ".openboot"))