Skip to content

Commit ea05651

Browse files
authored
Merge pull request #791 from entireio/soph/add-hook-overwrite-test
capture scenario where git hooks are overwritten during a running agent prompt
2 parents 60c4294 + 75d6ee4 commit ea05651

1 file changed

Lines changed: 117 additions & 0 deletions

File tree

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
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

Comments
 (0)