|
| 1 | +//go:build integration |
| 2 | + |
| 3 | +package integration |
| 4 | + |
| 5 | +import ( |
| 6 | + "os" |
| 7 | + "path/filepath" |
| 8 | + "testing" |
| 9 | + |
| 10 | + "github.com/entireio/cli/cmd/entire/cli/strategy" |
| 11 | + "github.com/stretchr/testify/assert" |
| 12 | + "github.com/stretchr/testify/require" |
| 13 | +) |
| 14 | + |
| 15 | +// TestHookOverwrite_MidTurnWipe_NextPromptRecovers simulates the scenario from |
| 16 | +// https://github.com/entireio/cli/issues/784: |
| 17 | +// |
| 18 | +// Flow: |
| 19 | +// 1. Prompt 1: agent creates files, commits via hooks → checkpoint trailer ✓ |
| 20 | +// 2. Mid-turn: third-party tool (husky/lefthook) overwrites git hooks |
| 21 | +// 3. Agent commits again (no hooks fire) → NO trailer |
| 22 | +// 4. Prompt 2 starts (user-prompt-submit) → EnsureSetup reinstalls hooks |
| 23 | +// 5. Agent commits via hooks → checkpoint trailer ✓ (hooks restored) |
| 24 | +// |
| 25 | +// The key insight: GitCommitWithShadowHooks invokes the binary directly (simulating |
| 26 | +// working hooks), while GitAdd+GitCommit uses go-git without hooks (simulating |
| 27 | +// overwritten hooks where `entire` is never called). |
| 28 | +func TestHookOverwrite_MidTurnWipe_NextPromptRecovers(t *testing.T) { |
| 29 | + t.Parallel() |
| 30 | + |
| 31 | + env := NewFeatureBranchEnv(t) |
| 32 | + hooksDir := filepath.Join(env.RepoDir, ".git", "hooks") |
| 33 | + |
| 34 | + sess := env.NewSession() |
| 35 | + |
| 36 | + // === Prompt 1: normal flow, hooks work === |
| 37 | + err := env.SimulateUserPromptSubmitWithPromptAndTranscriptPath( |
| 38 | + sess.ID, "Create files A and B", sess.TranscriptPath) |
| 39 | + require.NoError(t, err) |
| 40 | + |
| 41 | + env.WriteFile("fileA.go", "package main\n\nfunc A() {}\n") |
| 42 | + env.WriteFile("fileB.go", "package main\n\nfunc B() {}\n") |
| 43 | + |
| 44 | + sess.CreateTranscript("Create files A and B", []FileChange{ |
| 45 | + {Path: "fileA.go", Content: "package main\n\nfunc A() {}\n"}, |
| 46 | + {Path: "fileB.go", Content: "package main\n\nfunc B() {}\n"}, |
| 47 | + }) |
| 48 | + |
| 49 | + // First commit — hooks are intact, binary is invoked → trailer added |
| 50 | + env.GitCommitWithShadowHooks("Add file A", "fileA.go") |
| 51 | + cpID1 := env.GetCheckpointIDFromCommitMessage(env.GetHeadHash()) |
| 52 | + require.NotEmpty(t, cpID1, "first commit should have checkpoint trailer") |
| 53 | + |
| 54 | + // === Simulate husky/lefthook overwriting hooks mid-turn === |
| 55 | + // This is what happens when an agent runs `npm install` and husky's |
| 56 | + // `prepare` lifecycle script reinstalls its own hooks. |
| 57 | + for _, hookName := range strategy.ManagedGitHookNames() { |
| 58 | + hookPath := filepath.Join(hooksDir, hookName) |
| 59 | + huskyContent := "#!/bin/sh\n# husky - do not edit\n. \"$(dirname \"$0\")/_/husky.sh\"\n" |
| 60 | + err := os.WriteFile(hookPath, []byte(huskyContent), 0o755) |
| 61 | + require.NoError(t, err) |
| 62 | + } |
| 63 | + |
| 64 | + // Verify hooks are overwritten |
| 65 | + require.False(t, strategy.IsGitHookInstalledInDir(t.Context(), env.RepoDir), |
| 66 | + "hooks should be detected as overwritten") |
| 67 | + |
| 68 | + // Second commit — hooks are gone, use plain go-git commit (no binary invoked). |
| 69 | + // This simulates the real-world situation after husky/lefthook has overwritten |
| 70 | + // our hooks: a commit is made where git would run a third-party hook that does |
| 71 | + // not call `entire`, so from Entire's perspective no hooks run and no trailer |
| 72 | + // is added. |
| 73 | + env.GitAdd("fileB.go") |
| 74 | + env.GitCommit("Add file B") |
| 75 | + cpID2 := env.GetCheckpointIDFromCommitMessage(env.GetHeadHash()) |
| 76 | + assert.Empty(t, cpID2, |
| 77 | + "second commit should NOT have trailer (hooks were overwritten, entire never called)") |
| 78 | + |
| 79 | + // End prompt 1 |
| 80 | + err = env.SimulateStop(sess.ID, sess.TranscriptPath) |
| 81 | + require.NoError(t, err) |
| 82 | + |
| 83 | + // === Prompt 2: same session, next turn — EnsureSetup should reinstall hooks === |
| 84 | + |
| 85 | + env.WriteFile("fileC.go", "package main\n\nfunc C() {}\n") |
| 86 | + |
| 87 | + sess.CreateTranscript("Create file C", []FileChange{ |
| 88 | + {Path: "fileC.go", Content: "package main\n\nfunc C() {}\n"}, |
| 89 | + }) |
| 90 | + |
| 91 | + err = env.SimulateUserPromptSubmitWithPromptAndTranscriptPath( |
| 92 | + sess.ID, "Create file C", sess.TranscriptPath) |
| 93 | + require.NoError(t, err) |
| 94 | + |
| 95 | + // Verify hooks were reinstalled by EnsureSetup |
| 96 | + require.True(t, strategy.IsGitHookInstalledInDir(t.Context(), env.RepoDir), |
| 97 | + "hooks should be reinstalled by EnsureSetup at prompt 2 start") |
| 98 | + |
| 99 | + // Verify overwritten hooks were backed up (chaining preserved) |
| 100 | + for _, hookName := range strategy.ManagedGitHookNames() { |
| 101 | + backupPath := filepath.Join(hooksDir, hookName+".pre-entire") |
| 102 | + _, err := os.Stat(backupPath) |
| 103 | + assert.NoError(t, err, "backup %s.pre-entire should exist after reinstall", hookName) |
| 104 | + } |
| 105 | + |
| 106 | + // Third commit — hooks restored, agent commits (no TTY) → trailer added via fast path |
| 107 | + env.GitCommitWithShadowHooksAsAgent("Add file C", "fileC.go") |
| 108 | + cpID3 := env.GetCheckpointIDFromCommitMessage(env.GetHeadHash()) |
| 109 | + assert.NotEmpty(t, cpID3, |
| 110 | + "third commit should have trailer (hooks reinstalled by prompt 2)") |
| 111 | + |
| 112 | + // Checkpoint IDs should be distinct |
| 113 | + if cpID3 != "" { |
| 114 | + assert.NotEqual(t, cpID1, cpID3, |
| 115 | + "checkpoint IDs from prompt 1 and prompt 2 should be distinct") |
| 116 | + } |
| 117 | +} |
0 commit comments