Skip to content

Commit ae6ddac

Browse files
wesmclaude
andauthored
Refactors post-commit hook generation to eliminate code duplication, improve security, and ensure truly silent operation. (#13)
Follow on work from code review ## Changes - **Factor out `generateHookContent()` helper** - Hook script generation was duplicated in `init` and `install-hook` commands, risking drift. Now uses a single shared function. - **Security: prefer baked path over PATH lookup** - Hook now uses the absolute path baked at install time first, only falling back to `command -v roborev` if the baked binary is missing. Prevents PATH injection attacks from repo-local or malicious binaries. - **Add stderr redirect for silence** - Hook now redirects stderr (`2>/dev/null`) so errors from `roborev enqueue --quiet` don't leak to the terminal. - **Add comprehensive tests** - `TestGenerateHookContent` verifies: - Shebang and RoboRev comment present - Baked path assignment comes before PATH fallback (security) - Enqueue line has `--quiet`, `2>/dev/null`, and `&` on same line - Baked path is properly quoted ## Hook before/after **Before:** ```sh #!/bin/sh # RoboRev post-commit hook - auto-reviews every commit roborev enqueue --quiet & ``` After: ``` #!/bin/sh # RoboRev post-commit hook - auto-reviews every commit ROBOREV="/path/to/roborev" if [ ! -x "$ROBOREV" ]; then ROBOREV=$(command -v roborev 2>/dev/null) || exit 0 [ ! -x "$ROBOREV" ] && exit 0 fi "$ROBOREV" enqueue --quiet 2>/dev/null & ``` Test plan - go test ./... passes - Run roborev install-hook --force and verify hook content - Make a commit and verify no output appears - Test with baked path removed (should fall back to PATH) --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 2015e36 commit ae6ddac

2 files changed

Lines changed: 92 additions & 31 deletions

File tree

cmd/roborev/main.go

Lines changed: 23 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -260,22 +260,7 @@ func initCmd() *cobra.Command {
260260
return fmt.Errorf("get hooks path: %w", err)
261261
}
262262
hookPath := filepath.Join(hooksDir, "post-commit")
263-
264-
// Get full path to roborev executable to avoid PATH issues in hooks
265-
roborevPath, err := exec.LookPath("roborev")
266-
if err != nil {
267-
roborevPath = "roborev" // Fallback to PATH lookup
268-
}
269-
270-
// Create hook with proper quoting and fallback for moved/upgraded binaries
271-
hookContent := fmt.Sprintf(`#!/bin/sh
272-
# RoboRev post-commit hook - auto-reviews every commit
273-
ROBOREV=%q
274-
if [ ! -x "$ROBOREV" ]; then
275-
ROBOREV=$(command -v roborev) || exit 0
276-
fi
277-
"$ROBOREV" enqueue --quiet &
278-
`, roborevPath)
263+
hookContent := generateHookContent()
279264

280265
// Ensure hooks directory exists
281266
if err := os.MkdirAll(hooksDir, 0755); err != nil {
@@ -758,21 +743,7 @@ func installHookCmd() *cobra.Command {
758743
return fmt.Errorf("create hooks directory: %w", err)
759744
}
760745

761-
// Get full path to roborev executable to avoid PATH issues in hooks
762-
roborevPath, err := exec.LookPath("roborev")
763-
if err != nil {
764-
roborevPath = "roborev" // Fallback to PATH lookup
765-
}
766-
767-
// Create hook with proper quoting and fallback for moved/upgraded binaries
768-
hookContent := fmt.Sprintf(`#!/bin/sh
769-
# RoboRev post-commit hook - auto-reviews every commit
770-
ROBOREV=%q
771-
if [ ! -x "$ROBOREV" ]; then
772-
ROBOREV=$(command -v roborev) || exit 0
773-
fi
774-
"$ROBOREV" enqueue --quiet &
775-
`, roborevPath)
746+
hookContent := generateHookContent()
776747

777748
if err := os.WriteFile(hookPath, []byte(hookContent), 0755); err != nil {
778749
return fmt.Errorf("write hook: %w", err)
@@ -1007,3 +978,24 @@ func shortRef(ref string) string {
1007978
}
1008979
return shortSHA(ref)
1009980
}
981+
982+
// generateHookContent creates the post-commit hook script content.
983+
// It prefers the baked absolute path for security, falls back to PATH if missing.
984+
func generateHookContent() string {
985+
// Get current roborev absolute path
986+
roborevPath, err := exec.LookPath("roborev")
987+
if err != nil {
988+
roborevPath = "roborev"
989+
}
990+
991+
// Prefer baked path (security), fall back to PATH only if baked is missing
992+
return fmt.Sprintf(`#!/bin/sh
993+
# RoboRev post-commit hook - auto-reviews every commit
994+
ROBOREV=%q
995+
if [ ! -x "$ROBOREV" ]; then
996+
ROBOREV=$(command -v roborev 2>/dev/null) || exit 0
997+
[ ! -x "$ROBOREV" ] && exit 0
998+
fi
999+
"$ROBOREV" enqueue --quiet 2>/dev/null &
1000+
`, roborevPath)
1001+
}

cmd/roborev/main_test.go

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -453,3 +453,72 @@ func TestInstallHookCmdCreatesHooksDirectory(t *testing.T) {
453453
t.Error("post-commit hook was not created")
454454
}
455455
}
456+
457+
func TestGenerateHookContent(t *testing.T) {
458+
content := generateHookContent()
459+
lines := strings.Split(content, "\n")
460+
461+
t.Run("has shebang", func(t *testing.T) {
462+
if !strings.HasPrefix(content, "#!/bin/sh\n") {
463+
t.Error("hook should start with #!/bin/sh")
464+
}
465+
})
466+
467+
t.Run("has roborev comment", func(t *testing.T) {
468+
if !strings.Contains(content, "# RoboRev") {
469+
t.Error("hook should contain RoboRev comment for detection")
470+
}
471+
})
472+
473+
t.Run("baked path comes first", func(t *testing.T) {
474+
// Security: baked path should be set before any PATH lookup
475+
bakedIdx := -1
476+
pathIdx := -1
477+
for i, line := range lines {
478+
if strings.HasPrefix(line, "ROBOREV=") && !strings.Contains(line, "command -v") {
479+
bakedIdx = i
480+
}
481+
if strings.Contains(line, "command -v roborev") {
482+
pathIdx = i
483+
}
484+
}
485+
if bakedIdx == -1 {
486+
t.Error("hook should have baked ROBOREV= assignment")
487+
}
488+
if pathIdx == -1 {
489+
t.Error("hook should have PATH fallback via command -v")
490+
}
491+
if bakedIdx > pathIdx {
492+
t.Error("baked path should come before PATH lookup for security")
493+
}
494+
})
495+
496+
t.Run("enqueue line has quiet and stderr redirect", func(t *testing.T) {
497+
// Must have exact enqueue line with --quiet, stderr redirect, and background
498+
found := false
499+
for _, line := range lines {
500+
if strings.Contains(line, "enqueue --quiet") &&
501+
strings.Contains(line, "2>/dev/null") &&
502+
strings.HasSuffix(strings.TrimSpace(line), "&") {
503+
found = true
504+
break
505+
}
506+
}
507+
if !found {
508+
t.Error("hook should have enqueue line with --quiet, 2>/dev/null, and & on same line")
509+
}
510+
})
511+
512+
t.Run("baked path is quoted", func(t *testing.T) {
513+
// The baked path should be properly quoted to handle spaces
514+
for _, line := range lines {
515+
if strings.HasPrefix(line, "ROBOREV=") && !strings.Contains(line, "command -v") {
516+
// Should be ROBOREV="/path/to/roborev" or ROBOREV="roborev"
517+
if !strings.Contains(line, `ROBOREV="`) {
518+
t.Errorf("baked path should be quoted, got: %s", line)
519+
}
520+
break
521+
}
522+
}
523+
})
524+
}

0 commit comments

Comments
 (0)