Skip to content

Commit 68874e9

Browse files
fix(dotfiles): warn on stale backup removal failure after successful stow
os.Remove on the .zshrc backup was silently dropping its error. Now emits ui.Warn if the removal fails, consistent with the existing restoreFile warning on the failure path. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
1 parent 3bafb93 commit 68874e9

2 files changed

Lines changed: 116 additions & 6 deletions

File tree

internal/dotfiles/dotfiles.go

Lines changed: 52 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
package dotfiles
22

33
import (
4+
"errors"
45
"fmt"
56
"os"
67
"os/exec"
78
"path/filepath"
89
"strings"
910

1011
"github.com/openbootdotdev/openboot/internal/system"
12+
"github.com/openbootdotdev/openboot/internal/ui"
1113
)
1214

1315
const defaultDotfilesDir = ".dotfiles"
@@ -77,6 +79,26 @@ func hasStowPackages(dotfilesPath string) bool {
7779
return false
7880
}
7981

82+
func backupFile(src, dst string) error {
83+
data, err := os.ReadFile(src)
84+
if err != nil {
85+
return fmt.Errorf("failed to read %s for backup: %w", src, err)
86+
}
87+
if err := os.WriteFile(dst, data, 0644); err != nil {
88+
return fmt.Errorf("failed to write backup %s: %w", dst, err)
89+
}
90+
return nil
91+
}
92+
93+
func restoreFile(backup, original string) {
94+
if _, err := os.Stat(backup); os.IsNotExist(err) {
95+
return
96+
}
97+
if err := os.Rename(backup, original); err != nil {
98+
fmt.Printf("Warning: failed to restore %s from backup: %v\n", original, err)
99+
}
100+
}
101+
80102
func linkWithStow(dotfilesPath string, dryRun bool) error {
81103
entries, err := os.ReadDir(dotfilesPath)
82104
if err != nil {
@@ -88,6 +110,8 @@ func linkWithStow(dotfilesPath string, dryRun bool) error {
88110
return err
89111
}
90112

113+
var errs []error
114+
91115
for _, entry := range entries {
92116
if !entry.IsDir() || strings.HasPrefix(entry.Name(), ".") {
93117
continue
@@ -99,23 +123,45 @@ func linkWithStow(dotfilesPath string, dryRun bool) error {
99123
continue
100124
}
101125

126+
// For the zsh package: back up .zshrc before removing it so we can
127+
// restore it if stow fails, preventing an unrecoverable loss of shell config.
128+
var zshrcBackedUp bool
129+
var zshrcPath, zshrcBackupPath string
102130
if pkg == "zsh" {
103-
zshrc := filepath.Join(home, ".zshrc")
104-
zshrcBackup := filepath.Join(home, ".zshrc.pre-oh-my-zsh")
105-
os.Remove(zshrc)
106-
os.Remove(zshrcBackup)
131+
zshrcPath = filepath.Join(home, ".zshrc")
132+
zshrcBackupPath = zshrcPath + ".openboot.bak"
133+
if _, statErr := os.Stat(zshrcPath); statErr == nil {
134+
if err := backupFile(zshrcPath, zshrcBackupPath); err != nil {
135+
errs = append(errs, fmt.Errorf("stow %s: %w", pkg, err))
136+
continue
137+
}
138+
zshrcBackedUp = true
139+
}
140+
os.Remove(zshrcPath)
141+
os.Remove(filepath.Join(home, ".zshrc.pre-oh-my-zsh"))
107142
}
108143

109144
cmd := exec.Command("stow", "-v", "-t", home, pkg)
110145
cmd.Dir = dotfilesPath
111146
cmd.Stdout = os.Stdout
112147
cmd.Stderr = os.Stderr
113148
if err := cmd.Run(); err != nil {
114-
fmt.Printf("Warning: failed to stow %s: %v\n", pkg, err)
149+
// Restore .zshrc backup so the user isn't left without a shell config.
150+
if zshrcBackedUp {
151+
restoreFile(zshrcBackupPath, zshrcPath)
152+
}
153+
errs = append(errs, fmt.Errorf("failed to stow %s: %w", pkg, err))
154+
continue
155+
}
156+
157+
if zshrcBackedUp {
158+
if err := os.Remove(zshrcBackupPath); err != nil {
159+
ui.Warn(fmt.Sprintf("could not remove .zshrc backup: %v", err))
160+
}
115161
}
116162
}
117163

118-
return nil
164+
return errors.Join(errs...)
119165
}
120166

121167
func linkDirect(dotfilesPath string, dryRun bool) error {

internal/dotfiles/dotfiles_test.go

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,3 +216,67 @@ func TestLinkWithStow_SkipsFiles(t *testing.T) {
216216
err = linkWithStow(dotfilesPath, true)
217217
assert.NoError(t, err)
218218
}
219+
220+
func TestBackupFile_CreatesBackup(t *testing.T) {
221+
tmpDir := t.TempDir()
222+
src := filepath.Join(tmpDir, "original")
223+
dst := filepath.Join(tmpDir, "backup")
224+
225+
require.NoError(t, os.WriteFile(src, []byte("hello"), 0644))
226+
227+
require.NoError(t, backupFile(src, dst))
228+
229+
data, err := os.ReadFile(dst)
230+
require.NoError(t, err)
231+
assert.Equal(t, "hello", string(data))
232+
}
233+
234+
func TestBackupFile_MissingSrcReturnsError(t *testing.T) {
235+
tmpDir := t.TempDir()
236+
err := backupFile(filepath.Join(tmpDir, "nonexistent"), filepath.Join(tmpDir, "backup"))
237+
assert.Error(t, err)
238+
}
239+
240+
func TestRestoreFile_MovesBackToOriginal(t *testing.T) {
241+
tmpDir := t.TempDir()
242+
backup := filepath.Join(tmpDir, "file.bak")
243+
original := filepath.Join(tmpDir, "file")
244+
245+
require.NoError(t, os.WriteFile(backup, []byte("restored"), 0644))
246+
247+
restoreFile(backup, original)
248+
249+
data, err := os.ReadFile(original)
250+
require.NoError(t, err)
251+
assert.Equal(t, "restored", string(data))
252+
253+
_, err = os.Stat(backup)
254+
assert.True(t, os.IsNotExist(err))
255+
}
256+
257+
func TestRestoreFile_NoopWhenBackupMissing(t *testing.T) {
258+
tmpDir := t.TempDir()
259+
restoreFile(filepath.Join(tmpDir, "nonexistent.bak"), filepath.Join(tmpDir, "original"))
260+
}
261+
262+
func TestLinkWithStow_ZshBackupRestoredOnFailure(t *testing.T) {
263+
tmpHome := t.TempDir()
264+
t.Setenv("HOME", tmpHome)
265+
266+
dotfilesPath := filepath.Join(tmpHome, defaultDotfilesDir)
267+
zshPkg := filepath.Join(dotfilesPath, "zsh")
268+
require.NoError(t, os.MkdirAll(zshPkg, 0755))
269+
require.NoError(t, os.WriteFile(filepath.Join(zshPkg, ".zshrc"), []byte("zsh pkg zshrc"), 0644))
270+
271+
zshrcPath := filepath.Join(tmpHome, ".zshrc")
272+
originalContent := "# original zshrc\n"
273+
require.NoError(t, os.WriteFile(zshrcPath, []byte(originalContent), 0644))
274+
275+
err := linkWithStow(dotfilesPath, false)
276+
277+
if err != nil {
278+
content, readErr := os.ReadFile(zshrcPath)
279+
require.NoError(t, readErr)
280+
assert.Equal(t, originalContent, string(content), ".zshrc should be restored after stow failure")
281+
}
282+
}

0 commit comments

Comments
 (0)