Skip to content

Commit 09d93a7

Browse files
mariusvniekerkclaudewesm
authored
Fix post-commit hook to use main repo root in git worktrees (#497)
## Summary - When the post-commit hook fires from a linked git worktree, `GetRepoRoot` returns the worktree path rather than the main repo root, causing the daemon to register the commit under a phantom repo entry - Fix by using `GetMainRepoRoot` for the `RepoPath` sent to the daemon, while keeping `GetRepoRoot` (worktree-local path) for rebase detection and branch/ref resolution, since those states live in the worktree-specific git dir - Adds table-driven tests covering both plain repo and worktree cases for `RepoPath` correctness and rebase suppression during in-progress rebases --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Wes McKinney <wesmckinn+git@gmail.com>
1 parent 4044b6d commit 09d93a7

File tree

1 file changed

+293
-2
lines changed

1 file changed

+293
-2
lines changed

cmd/roborev/postcommit_test.go

Lines changed: 293 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,22 @@ package main
22

33
import (
44
"bytes"
5+
"encoding/json"
56
"fmt"
6-
"github.com/stretchr/testify/assert"
7-
"github.com/stretchr/testify/require"
87
"net/http"
8+
"os"
9+
"os/exec"
10+
"path/filepath"
11+
"runtime"
12+
"strings"
913
"testing"
1014
"time"
15+
16+
"github.com/roborev-dev/roborev/internal/daemon"
17+
"github.com/roborev-dev/roborev/internal/git"
18+
"github.com/roborev-dev/roborev/internal/githook"
19+
"github.com/stretchr/testify/assert"
20+
"github.com/stretchr/testify/require"
1121
)
1222

1323
func executePostCommitCmd(
@@ -196,3 +206,284 @@ func TestEnqueueAliasIsHidden(t *testing.T) {
196206
assert.True(t, cmd.Hidden)
197207
assert.Contains(t, cmd.Use, "enqueue")
198208
}
209+
210+
// repoUnderTest holds a repo for post-commit hook tests.
211+
type repoUnderTest struct {
212+
// repo is the directory post-commit runs from (may be a worktree).
213+
repo *TestGitRepo
214+
}
215+
216+
// setupPlainRepo returns a repoUnderTest backed by a plain (non-worktree) repo.
217+
func setupPlainRepo(t *testing.T) repoUnderTest {
218+
t.Helper()
219+
repo := newTestGitRepo(t)
220+
repo.CommitFile("file.txt", "content", "initial commit")
221+
return repoUnderTest{repo: repo}
222+
}
223+
224+
// setupWorktreeRepo returns a repoUnderTest backed by a linked worktree.
225+
func setupWorktreeRepo(t *testing.T) repoUnderTest {
226+
t.Helper()
227+
mainRepo := newTestGitRepo(t)
228+
mainRepo.CommitFile("file.txt", "content", "initial commit")
229+
230+
wtDir := t.TempDir()
231+
resolved, err := filepath.EvalSymlinks(wtDir)
232+
require.NoError(t, err)
233+
mainRepo.Run("worktree", "add", resolved, "-b", "worktree-branch")
234+
235+
return repoUnderTest{repo: &TestGitRepo{Dir: resolved, t: t}}
236+
}
237+
238+
// mockEnqueueCapture registers a handler on mux that captures full enqueue
239+
// requests. The returned channel receives at most one request.
240+
func mockEnqueueCapture(t *testing.T, mux *http.ServeMux) <-chan daemon.EnqueueRequest {
241+
t.Helper()
242+
ch := make(chan daemon.EnqueueRequest, 1)
243+
mux.HandleFunc("/api/enqueue", func(w http.ResponseWriter, r *http.Request) {
244+
var req daemon.EnqueueRequest
245+
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
246+
http.Error(w, err.Error(), http.StatusBadRequest)
247+
return
248+
}
249+
select {
250+
case ch <- req:
251+
default:
252+
assert.Fail(t, "mockEnqueueCapture: unexpected extra request")
253+
http.Error(w, "duplicate request", http.StatusConflict)
254+
return
255+
}
256+
w.WriteHeader(http.StatusCreated)
257+
json.NewEncoder(w).Encode(map[string]any{"id": 1})
258+
})
259+
return ch
260+
}
261+
262+
// TestPostCommitSendsLocalRepoPath checks that the RepoPath in the enqueue
263+
// request is the local (worktree) path in both plain repos and linked
264+
// worktrees. The daemon canonicalizes to the main repo root itself.
265+
func TestPostCommitSendsLocalRepoPath(t *testing.T) {
266+
tests := []struct {
267+
name string
268+
setup func(t *testing.T) repoUnderTest
269+
}{
270+
{"plain repo", setupPlainRepo},
271+
{"worktree", setupWorktreeRepo},
272+
}
273+
for _, tt := range tests {
274+
t.Run(tt.name, func(t *testing.T) {
275+
r := tt.setup(t)
276+
mux := http.NewServeMux()
277+
daemonFromHandler(t, mux)
278+
reqCh := mockEnqueueCapture(t, mux)
279+
280+
r.repo.CommitFile("change.txt", "content", "a commit")
281+
282+
_, _, err := executePostCommitCmd("--repo", r.repo.Dir)
283+
require.NoError(t, err)
284+
285+
req := <-reqCh
286+
assert.Equal(t, r.repo.Dir, req.RepoPath)
287+
})
288+
}
289+
}
290+
291+
// TestPostCommitSkipsEnqueueDuringRebase exercises the real Go code path
292+
// (postCommitCmd → git.IsRebaseInProgress) by simulating a rebase state
293+
// with a synthetic rebase-merge directory. This is the unit-level
294+
// complement to TestPostCommitDoesNotEnqueueDuringRebase which tests the
295+
// end-to-end shell hook flow.
296+
func TestPostCommitSkipsEnqueueDuringRebase(t *testing.T) {
297+
tests := []struct {
298+
name string
299+
setup func(t *testing.T) repoUnderTest
300+
sentinel string // directory to create inside git dir
301+
}{
302+
{"plain repo/rebase-merge", setupPlainRepo, "rebase-merge"},
303+
{"plain repo/rebase-apply", setupPlainRepo, "rebase-apply"},
304+
{"worktree/rebase-merge", setupWorktreeRepo, "rebase-merge"},
305+
{"worktree/rebase-apply", setupWorktreeRepo, "rebase-apply"},
306+
}
307+
for _, tt := range tests {
308+
t.Run(tt.name, func(t *testing.T) {
309+
r := tt.setup(t)
310+
mux := http.NewServeMux()
311+
daemonFromHandler(t, mux)
312+
313+
mux.HandleFunc("/api/enqueue", func(
314+
w http.ResponseWriter, r *http.Request,
315+
) {
316+
t.Error("enqueue should not be called during rebase")
317+
http.Error(w, "unexpected", http.StatusConflict)
318+
})
319+
320+
// Resolve the actual git dir (may differ from .git/ in
321+
// linked worktrees where .git is a file).
322+
gitDir := strings.TrimSpace(r.repo.Run(
323+
"rev-parse", "--git-dir"))
324+
if !filepath.IsAbs(gitDir) {
325+
gitDir = filepath.Join(r.repo.Dir, gitDir)
326+
}
327+
require.NoError(t, os.MkdirAll(
328+
filepath.Join(gitDir, tt.sentinel), 0755))
329+
330+
_, _, err := executePostCommitCmd("--repo", r.repo.Dir)
331+
require.NoError(t, err)
332+
})
333+
}
334+
}
335+
336+
// mockRoborevBinary creates a mock "roborev" shell script in a temp directory
337+
// and returns the directory (to prepend to PATH). The mock script handles
338+
// "post-commit" by using roborev's same rebase detection logic: it checks
339+
// for rebase-merge/rebase-apply in git-dir and writes to the marker file
340+
// only when NOT rebasing.
341+
func mockRoborevBinary(t *testing.T, marker string) string {
342+
t.Helper()
343+
binDir := t.TempDir()
344+
script := fmt.Sprintf(`#!/bin/sh
345+
# Mock roborev binary for testing post-commit hook behavior.
346+
# Only handles the "post-commit" subcommand.
347+
case "$1" in
348+
post-commit)
349+
git_dir=$(git rev-parse --git-dir 2>/dev/null) || exit 0
350+
[ -d "$git_dir/rebase-merge" ] && exit 0
351+
[ -d "$git_dir/rebase-apply" ] && exit 0
352+
echo enqueued >> %q
353+
;;
354+
esac
355+
`, marker)
356+
require.NoError(t, os.WriteFile(
357+
filepath.Join(binDir, "roborev"),
358+
[]byte(script), 0755))
359+
return binDir
360+
}
361+
362+
// installMockHook installs the real githook-generated post-commit hook with
363+
// the ROBOREV= line patched to point at a mock binary.
364+
func installMockHook(t *testing.T, repoDir, mockBinDir string) {
365+
t.Helper()
366+
hooksDir, err := git.GetHooksPath(repoDir)
367+
require.NoError(t, err)
368+
require.NoError(t, os.MkdirAll(hooksDir, 0755))
369+
370+
hookContent := githook.GeneratePostCommit()
371+
mockBin := filepath.Join(mockBinDir, "roborev")
372+
lines := strings.Split(hookContent, "\n")
373+
for i, line := range lines {
374+
if strings.HasPrefix(line, "ROBOREV=") {
375+
lines[i] = fmt.Sprintf("ROBOREV=%q", mockBin)
376+
break
377+
}
378+
}
379+
require.NoError(t, os.WriteFile(
380+
filepath.Join(hooksDir, "post-commit"),
381+
[]byte(strings.Join(lines, "\n")), 0755))
382+
}
383+
384+
// TestPostCommitDoesNotEnqueueDuringRebase runs a real clean git rebase with
385+
// hooks installed via githook.GeneratePostCommit and a mock roborev binary in
386+
// PATH. It asserts that roborev's rebase detection prevents any enqueue during
387+
// replayed commits.
388+
//
389+
// The mock binary reimplements the rebase guard in shell using the same
390+
// git rev-parse --git-dir + rebase-merge/rebase-apply check that
391+
// git.IsRebaseInProgress uses. TestPostCommitSkipsEnqueueDuringRebase (above)
392+
// tests the real Go code path (postCommitCmd) via simulated rebase state; this
393+
// test validates the end-to-end hook installation and invocation flow during
394+
// an actual git rebase.
395+
//
396+
// The "hook before commits" variant installs the hook before the branch
397+
// topology commits, so the hook fires for every setup commit as well. The
398+
// "hook after commits" variant installs just before the rebase.
399+
func TestPostCommitDoesNotEnqueueDuringRebase(t *testing.T) {
400+
if runtime.GOOS == "windows" {
401+
t.Skip("test uses shell hooks and Unix PATH semantics")
402+
}
403+
tests := []struct {
404+
name string
405+
setup func(t *testing.T) repoUnderTest
406+
hookBeforeSetup bool
407+
}{
408+
{"plain repo/hook before commits", setupPlainRepo, true},
409+
{"plain repo/hook after commits", setupPlainRepo, false},
410+
{"worktree/hook before commits", setupWorktreeRepo, true},
411+
{"worktree/hook after commits", setupWorktreeRepo, false},
412+
}
413+
for _, tt := range tests {
414+
t.Run(tt.name, func(t *testing.T) {
415+
r := tt.setup(t)
416+
417+
marker := filepath.Join(r.repo.Dir, "hook-enqueues.log")
418+
mockBinDir := mockRoborevBinary(t, marker)
419+
420+
// Build env with mock roborev first in PATH so the hook finds it.
421+
gitEnv := append(os.Environ(),
422+
"PATH="+mockBinDir+string(os.PathListSeparator)+os.Getenv("PATH"),
423+
"HOME="+r.repo.Dir,
424+
"GIT_CONFIG_NOSYSTEM=1",
425+
"GIT_AUTHOR_NAME=Test",
426+
"GIT_AUTHOR_EMAIL=test@test.com",
427+
"GIT_COMMITTER_NAME=Test",
428+
"GIT_COMMITTER_EMAIL=test@test.com",
429+
)
430+
431+
if tt.hookBeforeSetup {
432+
installMockHook(t, r.repo.Dir, mockBinDir)
433+
}
434+
435+
// Create a branch topology for a clean rebase:
436+
// base: A -- B (base.txt, no conflict)
437+
// \
438+
// current: C -- D -- E (branch files)
439+
gitCmd := func(args ...string) {
440+
t.Helper()
441+
cmd := exec.Command("git", args...)
442+
cmd.Dir = r.repo.Dir
443+
cmd.Env = gitEnv
444+
out, err := cmd.CombinedOutput()
445+
require.NoError(t, err, "git %v failed: %s", args, out)
446+
}
447+
gitCmd("checkout", "-b", "rebase-base")
448+
gitCmd("commit", "--allow-empty", "-m", "base commit")
449+
gitCmd("checkout", "-")
450+
// Create 3 feature commits with actual file changes.
451+
for i := 1; i <= 3; i++ {
452+
f := filepath.Join(r.repo.Dir, fmt.Sprintf("branch%d.txt", i))
453+
require.NoError(t, os.WriteFile(f, fmt.Appendf(nil, "content %d", i), 0644))
454+
gitCmd("add", f)
455+
gitCmd("commit", "-m", fmt.Sprintf("feature commit %d", i))
456+
}
457+
458+
if !tt.hookBeforeSetup {
459+
installMockHook(t, r.repo.Dir, mockBinDir)
460+
}
461+
462+
// Positive control: make a normal commit to prove the hook
463+
// fires outside of a rebase. Without this, a broken hook
464+
// install would silently pass (0 == 0).
465+
gitCmd("commit", "--allow-empty", "-m", "positive control commit")
466+
data, err := os.ReadFile(marker)
467+
require.NoError(t, err, "hook should have fired on normal commit")
468+
preRebaseCount := strings.Count(string(data), "enqueued")
469+
require.GreaterOrEqual(t, preRebaseCount, 1,
470+
"hook must fire at least once before rebase to prove it works")
471+
472+
// Run a full clean rebase — all 3 feature commits replay.
473+
cmd := exec.Command("git", "rebase", "rebase-base")
474+
cmd.Dir = r.repo.Dir
475+
cmd.Env = gitEnv
476+
out, err := cmd.CombinedOutput()
477+
require.NoError(t, err, "rebase should succeed cleanly: %s", out)
478+
479+
// After the rebase, the marker count should be unchanged.
480+
// If the hook enqueued during the rebase, there would be more.
481+
data, err = os.ReadFile(marker)
482+
require.NoError(t, err)
483+
postRebaseCount := strings.Count(string(data), "enqueued")
484+
assert.Equal(t, preRebaseCount, postRebaseCount,
485+
"hook should not have enqueued during rebase (got %d, want %d)",
486+
postRebaseCount, preRebaseCount)
487+
})
488+
}
489+
}

0 commit comments

Comments
 (0)