Skip to content
Merged
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: 3 additions & 29 deletions internal/archtest/baseline/dryrun.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,32 +2,6 @@
# Each line is <file>:<line> # 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
4 changes: 2 additions & 2 deletions internal/archtest/baseline/no-direct-exec.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion internal/archtest/dryrun_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion internal/cli/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
}
6 changes: 4 additions & 2 deletions internal/cli/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}

Expand Down
6 changes: 3 additions & 3 deletions internal/cli/snapshot.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -159,15 +159,15 @@ 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)
}
showLocalSaveSummary(snap, path)
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)
}
Expand Down
2 changes: 1 addition & 1 deletion internal/cli/snapshot_publish.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
}
Expand Down
2 changes: 1 addition & 1 deletion internal/cli/sync_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
}
Expand Down
21 changes: 15 additions & 6 deletions internal/dotfiles/dotfiles.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
16 changes: 8 additions & 8 deletions internal/dotfiles/dotfiles_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
}

Expand All @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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)
}
Expand All @@ -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")
}
Expand All @@ -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)
}
Expand Down
7 changes: 5 additions & 2 deletions internal/shell/shell.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -380,5 +383,5 @@ source $ZSH/oh-my-zsh.sh
return nil
}

return patchZshrcBlock(zshrcPath, theme, plugins)
return patchZshrcBlock(zshrcPath, theme, plugins, dryRun)
}
8 changes: 4 additions & 4 deletions internal/shell/shell_extra_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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)
}

Expand Down
6 changes: 5 additions & 1 deletion internal/snapshot/local.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
12 changes: 6 additions & 6 deletions internal/snapshot/snapshot_extra_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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)
Expand Down Expand Up @@ -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()
Expand All @@ -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.
Expand All @@ -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()
Expand Down
Loading
Loading