Skip to content

Commit a64ff49

Browse files
wesmclaude
andauthored
Fix post-commit hook backgrounding and add hook upgrade on init (#192)
## Summary - Remove `&` from post-commit hook enqueue command — backgrounding can kill the process before it completes when agents spawn git commits - Add version marker (`v2`) to hook template so `roborev init` can detect and upgrade outdated hooks while preserving user content - Warn about outdated hooks on daemon startup and in `roborev status` ## Test plan - [x] `go test ./...` passes - [x] Run `roborev init` in a repo with an old hook — verify it prints "Upgraded post-commit hook" - [x] Run `roborev init` again — verify it prints "Hook already installed" - [x] Run `roborev status` in a repo with an old hook — verify warning is shown 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 11b0925 commit a64ff49

File tree

3 files changed

+210
-9
lines changed

3 files changed

+210
-9
lines changed

cmd/roborev/hook_test.go

Lines changed: 121 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -244,19 +244,24 @@ func TestGenerateHookContent(t *testing.T) {
244244
}
245245
})
246246

247-
t.Run("enqueue line has quiet and stderr redirect", func(t *testing.T) {
248-
// Must have exact enqueue line with --quiet, stderr redirect, and background
247+
t.Run("enqueue line has quiet and stderr redirect without background", func(t *testing.T) {
249248
found := false
250249
for _, line := range lines {
251250
if strings.Contains(line, "enqueue --quiet") &&
252251
strings.Contains(line, "2>/dev/null") &&
253-
strings.HasSuffix(strings.TrimSpace(line), "&") {
252+
!strings.HasSuffix(strings.TrimSpace(line), "&") {
254253
found = true
255254
break
256255
}
257256
}
258257
if !found {
259-
t.Error("hook should have enqueue line with --quiet, 2>/dev/null, and & on same line")
258+
t.Error("hook should have enqueue line with --quiet and 2>/dev/null but no trailing &")
259+
}
260+
})
261+
262+
t.Run("has version marker", func(t *testing.T) {
263+
if !strings.Contains(content, "hook v2") {
264+
t.Error("hook should contain version marker 'hook v2'")
260265
}
261266
})
262267

@@ -291,3 +296,115 @@ func TestGenerateHookContent(t *testing.T) {
291296
}
292297
})
293298
}
299+
300+
func TestInitCmdUpgradesOutdatedHook(t *testing.T) {
301+
if runtime.GOOS == "windows" {
302+
t.Skip("test uses shell script stub, skipping on Windows")
303+
}
304+
305+
tmpHome := t.TempDir()
306+
origHome := os.Getenv("HOME")
307+
os.Setenv("HOME", tmpHome)
308+
defer os.Setenv("HOME", origHome)
309+
310+
repo := testutil.NewTestRepo(t)
311+
312+
// Write an old-style hook (no version marker, has &)
313+
oldHook := "#!/bin/sh\n# roborev post-commit hook - auto-reviews every commit\nROBOREV=\"/usr/local/bin/roborev\"\n\"$ROBOREV\" enqueue --quiet 2>/dev/null &\n"
314+
repo.WriteHook(oldHook)
315+
316+
defer testutil.MockBinaryInPath(t, "roborev", "#!/bin/sh\nexit 0\n")()
317+
defer repo.Chdir()()
318+
319+
initCommand := initCmd()
320+
initCommand.SetArgs([]string{"--agent", "test"})
321+
err := initCommand.Execute()
322+
if err != nil {
323+
t.Fatalf("init command failed: %v", err)
324+
}
325+
326+
content, err := os.ReadFile(repo.HookPath)
327+
if err != nil {
328+
t.Fatalf("Failed to read hook: %v", err)
329+
}
330+
331+
contentStr := string(content)
332+
if !strings.Contains(contentStr, "hook v2") {
333+
t.Error("upgraded hook should contain version marker")
334+
}
335+
if strings.Contains(contentStr, "2>/dev/null &") {
336+
t.Error("upgraded hook should not have backgrounded enqueue")
337+
}
338+
}
339+
340+
func TestInitCmdPreservesOtherHooksOnUpgrade(t *testing.T) {
341+
if runtime.GOOS == "windows" {
342+
t.Skip("test uses shell script stub, skipping on Windows")
343+
}
344+
345+
tmpHome := t.TempDir()
346+
origHome := os.Getenv("HOME")
347+
os.Setenv("HOME", tmpHome)
348+
defer os.Setenv("HOME", origHome)
349+
350+
repo := testutil.NewTestRepo(t)
351+
352+
oldHook := "#!/bin/sh\necho 'my custom hook'\n# roborev post-commit hook - auto-reviews every commit\nROBOREV=\"/usr/local/bin/roborev\"\n\"$ROBOREV\" enqueue --quiet 2>/dev/null &\n"
353+
repo.WriteHook(oldHook)
354+
355+
defer testutil.MockBinaryInPath(t, "roborev", "#!/bin/sh\nexit 0\n")()
356+
defer repo.Chdir()()
357+
358+
initCommand := initCmd()
359+
initCommand.SetArgs([]string{"--agent", "test"})
360+
err := initCommand.Execute()
361+
if err != nil {
362+
t.Fatalf("init command failed: %v", err)
363+
}
364+
365+
content, err := os.ReadFile(repo.HookPath)
366+
if err != nil {
367+
t.Fatalf("Failed to read hook: %v", err)
368+
}
369+
370+
contentStr := string(content)
371+
if !strings.Contains(contentStr, "echo 'my custom hook'") {
372+
t.Error("upgrade should preserve non-roborev lines")
373+
}
374+
if !strings.Contains(contentStr, "hook v2") {
375+
t.Error("upgrade should contain version marker")
376+
}
377+
}
378+
379+
func TestHookNeedsUpgrade(t *testing.T) {
380+
t.Run("outdated hook", func(t *testing.T) {
381+
repo := testutil.NewTestRepo(t)
382+
repo.WriteHook("#!/bin/sh\n# roborev post-commit hook\nroborev enqueue\n")
383+
if !hookNeedsUpgrade(repo.Root) {
384+
t.Error("should detect outdated hook")
385+
}
386+
})
387+
388+
t.Run("current hook", func(t *testing.T) {
389+
repo := testutil.NewTestRepo(t)
390+
repo.WriteHook("#!/bin/sh\n# roborev post-commit hook v2 - auto-reviews every commit\nroborev enqueue\n")
391+
if hookNeedsUpgrade(repo.Root) {
392+
t.Error("should not flag current hook as outdated")
393+
}
394+
})
395+
396+
t.Run("no hook", func(t *testing.T) {
397+
repo := testutil.NewTestRepo(t)
398+
if hookNeedsUpgrade(repo.Root) {
399+
t.Error("should not flag missing hook as outdated")
400+
}
401+
})
402+
403+
t.Run("non-roborev hook", func(t *testing.T) {
404+
repo := testutil.NewTestRepo(t)
405+
repo.WriteHook("#!/bin/sh\necho hello\n")
406+
if hookNeedsUpgrade(repo.Root) {
407+
t.Error("should not flag non-roborev hook as outdated")
408+
}
409+
})
410+
}

cmd/roborev/main.go

Lines changed: 61 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -337,12 +337,41 @@ func initCmd() *cobra.Command {
337337

338338
// Check for existing hook
339339
if existing, err := os.ReadFile(hookPath); err == nil {
340-
if !strings.Contains(string(existing), "roborev") {
340+
existingStr := string(existing)
341+
if !strings.Contains(strings.ToLower(existingStr), "roborev") {
341342
// Append to existing hook
342-
hookContent = string(existing) + "\n" + hookContent
343-
} else {
343+
hookContent = existingStr + "\n" + hookContent
344+
} else if strings.Contains(existingStr, hookVersionMarker) {
344345
fmt.Println(" Hook already installed")
345346
goto startDaemon
347+
} else {
348+
// Upgrade: strip old roborev lines, append new content
349+
lines := strings.Split(existingStr, "\n")
350+
var kept []string
351+
for _, line := range lines {
352+
if !strings.Contains(strings.ToLower(line), "roborev") {
353+
kept = append(kept, line)
354+
}
355+
}
356+
// Remove trailing empty lines
357+
for len(kept) > 0 && strings.TrimSpace(kept[len(kept)-1]) == "" {
358+
kept = kept[:len(kept)-1]
359+
}
360+
// Strip shebang from new content if prepending to existing content
361+
newHook := hookContent
362+
if len(kept) > 0 {
363+
newHook = strings.TrimPrefix(newHook, "#!/bin/sh\n")
364+
}
365+
if len(kept) > 0 {
366+
hookContent = strings.Join(kept, "\n") + "\n" + newHook
367+
} else {
368+
hookContent = newHook
369+
}
370+
if err := os.WriteFile(hookPath, []byte(hookContent), 0755); err != nil {
371+
return fmt.Errorf("upgrade hook: %w", err)
372+
}
373+
fmt.Println(" Upgraded post-commit hook")
374+
goto startDaemon
346375
}
347376
}
348377

@@ -1179,6 +1208,14 @@ func statusCmd() *cobra.Command {
11791208
w.Flush()
11801209
}
11811210

1211+
// Check for outdated hook in current repo
1212+
if root, err := git.GetRepoRoot("."); err == nil {
1213+
if hookNeedsUpgrade(root) {
1214+
fmt.Println()
1215+
fmt.Println("Warning: post-commit hook is outdated -- run 'roborev init' to upgrade")
1216+
}
1217+
}
1218+
11821219
return nil
11831220
},
11841221
}
@@ -2657,6 +2694,25 @@ func resolveReasoningWithFast(reasoning string, fast bool, reasoningExplicitlySe
26572694
return reasoning
26582695
}
26592696

2697+
// hookVersionMarker is the string that identifies the current hook version.
2698+
// Bump this when the hook template changes to trigger upgrade warnings.
2699+
const hookVersionMarker = "post-commit hook v2"
2700+
2701+
// hookNeedsUpgrade checks whether a repo's post-commit hook contains roborev
2702+
// but is outdated (missing the current version marker).
2703+
func hookNeedsUpgrade(repoPath string) bool {
2704+
hooksDir, err := git.GetHooksPath(repoPath)
2705+
if err != nil {
2706+
return false
2707+
}
2708+
content, err := os.ReadFile(filepath.Join(hooksDir, "post-commit"))
2709+
if err != nil {
2710+
return false
2711+
}
2712+
s := string(content)
2713+
return strings.Contains(strings.ToLower(s), "roborev") && !strings.Contains(s, hookVersionMarker)
2714+
}
2715+
26602716
// generateHookContent creates the post-commit hook script content.
26612717
// It bakes the path to the currently running binary for consistency.
26622718
// Falls back to PATH lookup if the baked path becomes unavailable.
@@ -2678,12 +2734,12 @@ func generateHookContent() string {
26782734

26792735
// Prefer baked path (security), fall back to PATH only if baked is missing
26802736
return fmt.Sprintf(`#!/bin/sh
2681-
# roborev post-commit hook - auto-reviews every commit
2737+
# roborev post-commit hook v2 - auto-reviews every commit
26822738
ROBOREV=%q
26832739
if [ ! -x "$ROBOREV" ]; then
26842740
ROBOREV=$(command -v roborev 2>/dev/null)
26852741
[ -z "$ROBOREV" ] || [ ! -x "$ROBOREV" ] && exit 0
26862742
fi
2687-
"$ROBOREV" enqueue --quiet 2>/dev/null &
2743+
"$ROBOREV" enqueue --quiet 2>/dev/null
26882744
`, roborevPath)
26892745
}

internal/daemon/server.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import (
88
"fmt"
99
"log"
1010
"net/http"
11+
"os"
12+
"path/filepath"
1113
"strings"
1214
"sync"
1315
"time"
@@ -132,6 +134,15 @@ func (s *Server) Start(ctx context.Context) error {
132134
// Start worker pool
133135
s.workerPool.Start()
134136

137+
// Check for outdated hooks in registered repos
138+
if repos, err := s.db.ListRepos(); err == nil {
139+
for _, repo := range repos {
140+
if hookNeedsUpgrade(repo.RootPath) {
141+
log.Printf("Warning: outdated post-commit hook in %s -- run 'roborev init' to upgrade", repo.RootPath)
142+
}
143+
}
144+
}
145+
135146
// Start HTTP server
136147
log.Printf("Starting HTTP server on %s", addr)
137148
if err := s.httpServer.ListenAndServe(); err != http.ErrServerClosed {
@@ -142,6 +153,23 @@ func (s *Server) Start(ctx context.Context) error {
142153
return nil
143154
}
144155

156+
// hookVersionMarker identifies the current hook version.
157+
const hookVersionMarker = "post-commit hook v2"
158+
159+
// hookNeedsUpgrade checks whether a repo's post-commit hook is outdated.
160+
func hookNeedsUpgrade(repoPath string) bool {
161+
hooksDir, err := git.GetHooksPath(repoPath)
162+
if err != nil {
163+
return false
164+
}
165+
content, err := os.ReadFile(filepath.Join(hooksDir, "post-commit"))
166+
if err != nil {
167+
return false
168+
}
169+
s := string(content)
170+
return strings.Contains(strings.ToLower(s), "roborev") && !strings.Contains(s, hookVersionMarker)
171+
}
172+
145173
// Stop gracefully shuts down the server
146174
func (s *Server) Stop() error {
147175
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)

0 commit comments

Comments
 (0)