@@ -2,12 +2,22 @@ package main
22
33import (
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
1323func 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