Skip to content

Commit 3bafb93

Browse files
fix(shell): make RestoreFromSnapshot idempotent via OpenBoot-Restore sentinel block
Replaces scatter-point ZSH_THEME/plugins= regex replacements with a single guarded block (# >>> OpenBoot-Restore ... # <<< OpenBoot-Restore). On first run, loose theme/plugin lines are stripped before the block is appended. On subsequent runs, only the block is replaced in-place, preventing file growth on repeated restores. Also ensures a trailing newline before the block to avoid comment corruption. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
1 parent d0b5ae8 commit 3bafb93

2 files changed

Lines changed: 157 additions & 39 deletions

File tree

internal/shell/shell.go

Lines changed: 72 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -48,14 +48,9 @@ func InstallOhMyZsh(dryRun bool) error {
4848
return nil
4949
}
5050

51-
func ConfigureZshrc(dryRun bool) error {
52-
home, err := system.HomeDir()
53-
if err != nil {
54-
return err
55-
}
56-
zshrcPath := filepath.Join(home, ".zshrc")
51+
const openbootZshrcSentinel = "# OpenBoot additions"
5752

58-
additions := `
53+
const openbootZshrcBlock = `
5954
# OpenBoot additions
6055
# Homebrew (must come before /usr/bin)
6156
if [ -f /opt/homebrew/bin/brew ]; then
@@ -85,9 +80,21 @@ eval "$(zoxide init zsh)"
8580
[ -f ~/.fzf.zsh ] && source ~/.fzf.zsh
8681
`
8782

83+
func ConfigureZshrc(dryRun bool) error {
84+
home, err := system.HomeDir()
85+
if err != nil {
86+
return err
87+
}
88+
zshrcPath := filepath.Join(home, ".zshrc")
89+
8890
if dryRun {
8991
fmt.Println("[DRY-RUN] Would add to .zshrc:")
90-
fmt.Println(additions)
92+
fmt.Print(openbootZshrcBlock)
93+
return nil
94+
}
95+
96+
existing, _ := os.ReadFile(zshrcPath)
97+
if strings.Contains(string(existing), openbootZshrcSentinel) {
9198
return nil
9299
}
93100

@@ -97,7 +104,7 @@ eval "$(zoxide init zsh)"
97104
}
98105
defer f.Close()
99106

100-
if _, err := f.WriteString(additions); err != nil {
107+
if _, err := f.WriteString(openbootZshrcBlock); err != nil {
101108
return fmt.Errorf("failed to write to .zshrc: %w", err)
102109
}
103110

@@ -122,6 +129,59 @@ func SetDefaultShell(dryRun bool) error {
122129
return cmd.Run()
123130
}
124131

132+
const restoreBlockStart = "# >>> OpenBoot-Restore"
133+
const restoreBlockEnd = "# <<< OpenBoot-Restore"
134+
135+
var restoreBlockRe = regexp.MustCompile(`(?s)# >>> OpenBoot-Restore\n.*?# <<< OpenBoot-Restore\n?`)
136+
137+
func buildRestoreBlock(theme string, plugins []string) string {
138+
var sb strings.Builder
139+
sb.WriteString(restoreBlockStart + "\n")
140+
if theme != "" {
141+
sb.WriteString(fmt.Sprintf("ZSH_THEME=\"%s\"\n", theme))
142+
}
143+
if len(plugins) > 0 {
144+
sb.WriteString(fmt.Sprintf("plugins=(%s)\n", strings.Join(plugins, " ")))
145+
}
146+
sb.WriteString(restoreBlockEnd + "\n")
147+
return sb.String()
148+
}
149+
150+
var (
151+
looseThemeRe = regexp.MustCompile(`(?m)^ZSH_THEME="[^"]*"\n?`)
152+
loosePluginsRe = regexp.MustCompile(`(?m)^plugins=\([^)]*\)\n?`)
153+
)
154+
155+
func patchZshrcBlock(zshrcPath, theme string, plugins []string) error {
156+
raw, err := os.ReadFile(zshrcPath)
157+
if err != nil {
158+
return fmt.Errorf("failed to read .zshrc: %w", err)
159+
}
160+
161+
block := buildRestoreBlock(theme, plugins)
162+
content := string(raw)
163+
164+
if restoreBlockRe.MatchString(content) {
165+
content = restoreBlockRe.ReplaceAllString(content, block)
166+
} else {
167+
if theme != "" {
168+
content = looseThemeRe.ReplaceAllString(content, "")
169+
}
170+
if len(plugins) > 0 {
171+
content = loosePluginsRe.ReplaceAllString(content, "")
172+
}
173+
if len(content) > 0 && content[len(content)-1] != '\n' {
174+
content = content + "\n"
175+
}
176+
content = content + block
177+
}
178+
179+
if err := os.WriteFile(zshrcPath, []byte(content), 0644); err != nil {
180+
return fmt.Errorf("failed to write .zshrc: %w", err)
181+
}
182+
return nil
183+
}
184+
125185
func RestoreFromSnapshot(ohMyZsh bool, theme string, plugins []string, dryRun bool) error {
126186
if !ohMyZsh {
127187
return nil
@@ -169,36 +229,9 @@ source $ZSH/oh-my-zsh.sh
169229
return nil
170230
}
171231

172-
content, err := os.ReadFile(zshrcPath)
173-
if err != nil {
174-
return fmt.Errorf("failed to read .zshrc: %w", err)
175-
}
176-
177-
updated := string(content)
178-
179-
if theme != "" {
180-
themeRe := regexp.MustCompile(`ZSH_THEME="[^"]*"`)
181-
newTheme := fmt.Sprintf(`ZSH_THEME="%s"`, theme)
182-
if themeRe.MatchString(updated) {
183-
updated = themeRe.ReplaceAllString(updated, newTheme)
184-
} else {
185-
updated = newTheme + "\n" + updated
186-
}
187-
}
188-
189-
if len(plugins) > 0 {
190-
pluginsRe := regexp.MustCompile(`plugins=\([^)]*\)`)
191-
newPlugins := fmt.Sprintf("plugins=(%s)", strings.Join(plugins, " "))
192-
if pluginsRe.MatchString(updated) {
193-
updated = pluginsRe.ReplaceAllString(updated, newPlugins)
194-
} else {
195-
updated = newPlugins + "\n" + updated
196-
}
197-
}
198-
199-
if err := os.WriteFile(zshrcPath, []byte(updated), 0644); err != nil {
200-
return fmt.Errorf("failed to write .zshrc: %w", err)
232+
if theme == "" && len(plugins) == 0 {
233+
return nil
201234
}
202235

203-
return nil
236+
return patchZshrcBlock(zshrcPath, theme, plugins)
204237
}

internal/shell/shell_test.go

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package shell
33
import (
44
"os"
55
"path/filepath"
6+
"strings"
67
"testing"
78

89
"github.com/stretchr/testify/assert"
@@ -267,3 +268,87 @@ source $ZSH/oh-my-zsh.sh
267268
assert.Contains(t, string(result), `ZSH_THEME="robbyrussell"`)
268269
assert.Contains(t, string(result), `plugins=(git)`)
269270
}
271+
272+
func TestConfigureZshrc_Idempotent(t *testing.T) {
273+
tmpHome := t.TempDir()
274+
t.Setenv("HOME", tmpHome)
275+
276+
require.NoError(t, ConfigureZshrc(false))
277+
require.NoError(t, ConfigureZshrc(false))
278+
require.NoError(t, ConfigureZshrc(false))
279+
280+
zshrcPath := filepath.Join(tmpHome, ".zshrc")
281+
content, err := os.ReadFile(zshrcPath)
282+
require.NoError(t, err)
283+
284+
count := strings.Count(string(content), openbootZshrcSentinel)
285+
assert.Equal(t, 1, count, "OpenBoot block should appear exactly once after multiple calls")
286+
}
287+
288+
func TestRestoreFromSnapshot_NoTrailingNewline(t *testing.T) {
289+
home := t.TempDir()
290+
t.Setenv("HOME", home)
291+
292+
zshrcPath := filepath.Join(home, ".zshrc")
293+
require.NoError(t, os.WriteFile(zshrcPath, []byte(`source $ZSH/oh-my-zsh.sh`), 0644))
294+
require.NoError(t, os.MkdirAll(filepath.Join(home, ".oh-my-zsh"), 0755))
295+
296+
require.NoError(t, RestoreFromSnapshot(true, "agnoster", []string{"git"}, false))
297+
298+
content, err := os.ReadFile(zshrcPath)
299+
require.NoError(t, err)
300+
301+
assert.Contains(t, string(content), restoreBlockStart)
302+
lines := strings.Split(string(content), "\n")
303+
for i, line := range lines {
304+
if strings.HasSuffix(line, "oh-my-zsh.sh") {
305+
assert.NotContains(t, line, restoreBlockStart,
306+
"block sentinel must not be joined to previous line (line %d)", i)
307+
break
308+
}
309+
}
310+
}
311+
312+
func TestRestoreFromSnapshot_Idempotent(t *testing.T) {
313+
home := t.TempDir()
314+
t.Setenv("HOME", home)
315+
316+
zshrcPath := filepath.Join(home, ".zshrc")
317+
initial := `export ZSH="$HOME/.oh-my-zsh"
318+
ZSH_THEME="robbyrussell"
319+
plugins=(git)
320+
source $ZSH/oh-my-zsh.sh
321+
`
322+
require.NoError(t, os.WriteFile(zshrcPath, []byte(initial), 0644))
323+
require.NoError(t, os.MkdirAll(filepath.Join(home, ".oh-my-zsh"), 0755))
324+
325+
require.NoError(t, RestoreFromSnapshot(true, "agnoster", []string{"git", "docker"}, false))
326+
require.NoError(t, RestoreFromSnapshot(true, "agnoster", []string{"git", "docker"}, false))
327+
require.NoError(t, RestoreFromSnapshot(true, "agnoster", []string{"git", "docker"}, false))
328+
329+
content, err := os.ReadFile(zshrcPath)
330+
require.NoError(t, err)
331+
332+
assert.Equal(t, 1, strings.Count(string(content), restoreBlockStart),
333+
"restore block should appear exactly once after repeated calls")
334+
assert.Contains(t, string(content), `ZSH_THEME="agnoster"`)
335+
assert.NotContains(t, string(content), `ZSH_THEME="robbyrussell"`)
336+
}
337+
338+
func TestConfigureZshrc_IdempotentPreservesExisting(t *testing.T) {
339+
tmpHome := t.TempDir()
340+
t.Setenv("HOME", tmpHome)
341+
342+
zshrcPath := filepath.Join(tmpHome, ".zshrc")
343+
existing := "# My existing config\nexport EDITOR=vim\n"
344+
require.NoError(t, os.WriteFile(zshrcPath, []byte(existing), 0644))
345+
346+
require.NoError(t, ConfigureZshrc(false))
347+
require.NoError(t, ConfigureZshrc(false))
348+
349+
content, err := os.ReadFile(zshrcPath)
350+
require.NoError(t, err)
351+
352+
assert.Contains(t, string(content), "My existing config")
353+
assert.Equal(t, 1, strings.Count(string(content), openbootZshrcSentinel))
354+
}

0 commit comments

Comments
 (0)