Skip to content

Commit 25cd14e

Browse files
konardclaude
andcommitted
test(hooks): add tests for AI directory auto-staging and setup script
- Tests pre-commit hook logic: stages .gemini, .claude, .codex when present - Tests setup-pre-commit-hook.js: creates hook, sets permissions, configures core.hooksPath - Tests idempotency of setup script - Verifies .gitignore does not block AI config directories - Verifies committed hook file has correct shebang and staging snippet All tests use isolated temp git repos for clean, repeatable execution. Addresses review feedback requesting test coverage for hook logic. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 30263e9 commit 25cd14e

1 file changed

Lines changed: 233 additions & 0 deletions

File tree

Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
// CHANGE: add tests for pre-commit hook AI directory auto-staging and setup script
2+
// WHY: guarantees that .gemini, .claude, .codex are auto-staged and setup configures hooks correctly
3+
// REF: issue-170
4+
// PURITY: SHELL (tests filesystem + git operations in isolated temp repos)
5+
6+
import { execFileSync } from "node:child_process"
7+
import fs from "node:fs"
8+
import os from "node:os"
9+
import path from "node:path"
10+
import { fileURLToPath } from "node:url"
11+
import { afterEach, beforeEach, describe, expect, it } from "vitest"
12+
13+
const currentDir = path.dirname(fileURLToPath(import.meta.url))
14+
const repoRoot = path.resolve(currentDir, "../../../..")
15+
16+
// Resolve absolute binary paths to satisfy sonarjs/no-os-command-from-path
17+
const GIT_BIN = execFileSync("/usr/bin/which", ["git"], { encoding: "utf8" }).trim()
18+
const NODE_BIN = process.execPath
19+
20+
/**
21+
* Creates an isolated git repo in a temp directory for testing
22+
*
23+
* @returns path to the temp repo root
24+
* @pure false — creates temp directory and initializes git repo
25+
*/
26+
const createTempRepo = (): string => {
27+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "hook-test-"))
28+
execFileSync(GIT_BIN, ["init"], { cwd: tmpDir, stdio: "pipe" })
29+
execFileSync(GIT_BIN, ["config", "user.email", "test@test.com"], { cwd: tmpDir, stdio: "pipe" })
30+
execFileSync(GIT_BIN, ["config", "user.name", "Test"], { cwd: tmpDir, stdio: "pipe" })
31+
fs.writeFileSync(path.join(tmpDir, "README.md"), "init")
32+
execFileSync(GIT_BIN, ["add", "README.md"], { cwd: tmpDir, stdio: "pipe" })
33+
execFileSync(GIT_BIN, ["commit", "-m", "init"], { cwd: tmpDir, stdio: "pipe" })
34+
return tmpDir
35+
}
36+
37+
/**
38+
* Runs the AI directory staging logic (mirrors pre-commit hook behavior) in a given repo
39+
*
40+
* @param cwd - the git repo directory
41+
* @pure false — stages files via git add
42+
*/
43+
const runAiDirStaging = (cwd: string): void => {
44+
for (const aiDir of [".gemini", ".claude", ".codex"]) {
45+
const dirPath = path.join(cwd, aiDir)
46+
if (fs.existsSync(dirPath) && fs.statSync(dirPath).isDirectory()) {
47+
execFileSync(GIT_BIN, ["add", "-A", "--", aiDir], { cwd, stdio: "pipe" })
48+
}
49+
}
50+
}
51+
52+
/**
53+
* Returns list of staged file names in a given repo
54+
*
55+
* @param cwd - the git repo directory
56+
* @returns array of staged file paths
57+
* @pure false — reads git index
58+
*/
59+
const getStagedFiles = (cwd: string): ReadonlyArray<string> => {
60+
const output = execFileSync(GIT_BIN, ["diff", "--cached", "--name-only"], {
61+
cwd,
62+
encoding: "utf8"
63+
}).trim()
64+
return output ? output.split("\n") : []
65+
}
66+
67+
/**
68+
* Copies setup script into a temp repo and runs it
69+
*
70+
* @param repoDir - target git repo
71+
* @pure false — copies file, executes script, modifies git config
72+
*/
73+
const runSetupScript = (repoDir: string): void => {
74+
const scriptsDir = path.join(repoDir, "scripts")
75+
fs.mkdirSync(scriptsDir, { recursive: true })
76+
const srcScript = path.resolve(repoRoot, "scripts/setup-pre-commit-hook.js")
77+
fs.copyFileSync(srcScript, path.join(scriptsDir, "setup-pre-commit-hook.js"))
78+
execFileSync(NODE_BIN, ["scripts/setup-pre-commit-hook.js"], {
79+
cwd: repoDir,
80+
encoding: "utf8",
81+
stdio: "pipe"
82+
})
83+
}
84+
85+
/**
86+
* Reads the generated hook content from a temp repo
87+
*
88+
* @param repoDir - target git repo
89+
* @returns hook file content
90+
* @pure false — reads filesystem
91+
*/
92+
const readGeneratedHook = (repoDir: string): string =>
93+
fs.readFileSync(path.join(repoDir, ".githooks", "pre-commit"), "utf8")
94+
95+
const AI_DIR_STAGING_SNIPPET = `for ai_dir in .gemini .claude .codex; do
96+
if [ -d "$ai_dir" ]; then
97+
git add -A -- "$ai_dir"
98+
fi
99+
done`
100+
101+
// Tests that require an isolated temp git repo
102+
describe("pre-commit hook (isolated repo)", () => {
103+
let repoDir: string
104+
105+
beforeEach(() => {
106+
repoDir = createTempRepo()
107+
})
108+
afterEach(() => {
109+
fs.rmSync(repoDir, { recursive: true, force: true })
110+
})
111+
112+
describe("AI directory auto-staging logic", () => {
113+
// INVARIANT: ∀ dir ∈ {.gemini, .claude, .codex}: exists(dir) → staged(dir/*)
114+
it("stages .gemini, .claude, .codex directories when they exist", () => {
115+
for (const dir of [".gemini", ".claude", ".codex"]) {
116+
fs.mkdirSync(path.join(repoDir, dir), { recursive: true })
117+
fs.writeFileSync(path.join(repoDir, dir, "config.json"), `{"dir":"${dir}"}`)
118+
}
119+
120+
runAiDirStaging(repoDir)
121+
const stagedFiles = getStagedFiles(repoDir)
122+
123+
expect(stagedFiles).toContain(".gemini/config.json")
124+
expect(stagedFiles).toContain(".claude/config.json")
125+
expect(stagedFiles).toContain(".codex/config.json")
126+
})
127+
128+
// INVARIANT: ¬exists(dir) → no_error ∧ no_staging
129+
it("skips non-existent AI directories without error", () => {
130+
fs.mkdirSync(path.join(repoDir, ".gemini"), { recursive: true })
131+
fs.writeFileSync(path.join(repoDir, ".gemini", "settings.txt"), "test")
132+
133+
runAiDirStaging(repoDir)
134+
const stagedFiles = getStagedFiles(repoDir)
135+
136+
expect(stagedFiles).toContain(".gemini/settings.txt")
137+
expect(stagedFiles.some((f) => f.startsWith(".claude/"))).toBe(false)
138+
expect(stagedFiles.some((f) => f.startsWith(".codex/"))).toBe(false)
139+
})
140+
141+
// INVARIANT: ∀ f ∈ dir/*: staged(f) (recursive staging)
142+
it("stages nested files within AI directories", () => {
143+
fs.mkdirSync(path.join(repoDir, ".claude", "memory"), { recursive: true })
144+
fs.writeFileSync(path.join(repoDir, ".claude", "memory", "context.md"), "# Context")
145+
fs.writeFileSync(path.join(repoDir, ".claude", "settings.json"), "{}")
146+
147+
runAiDirStaging(repoDir)
148+
const stagedFiles = getStagedFiles(repoDir)
149+
150+
expect(stagedFiles).toContain(".claude/memory/context.md")
151+
expect(stagedFiles).toContain(".claude/settings.json")
152+
})
153+
154+
// INVARIANT: empty_dir → no_staging ∧ no_error
155+
it("handles empty AI directories gracefully", () => {
156+
fs.mkdirSync(path.join(repoDir, ".codex"), { recursive: true })
157+
158+
runAiDirStaging(repoDir)
159+
160+
expect(getStagedFiles(repoDir)).toHaveLength(0)
161+
})
162+
})
163+
164+
describe("setup-pre-commit-hook.js", () => {
165+
// INVARIANT: ∃ .githooks/pre-commit after setup ∧ executable(pre-commit)
166+
it("creates .githooks/pre-commit with correct permissions", () => {
167+
runSetupScript(repoDir)
168+
169+
const hookPath = path.join(repoDir, ".githooks", "pre-commit")
170+
expect(fs.existsSync(hookPath)).toBe(true)
171+
172+
const stats = fs.statSync(hookPath)
173+
expect(stats.mode & 0o111).toBeGreaterThan(0)
174+
})
175+
176+
// INVARIANT: hook_content contains AI dir staging logic
177+
it("generated hook includes AI directory auto-staging for .gemini, .claude, .codex", () => {
178+
runSetupScript(repoDir)
179+
const hookContent = readGeneratedHook(repoDir)
180+
181+
expect(hookContent).toContain(".gemini")
182+
expect(hookContent).toContain(".claude")
183+
expect(hookContent).toContain(".codex")
184+
expect(hookContent).toContain(AI_DIR_STAGING_SNIPPET)
185+
})
186+
187+
// INVARIANT: core.hooksPath = ".githooks" after setup
188+
it("configures git core.hooksPath to .githooks", () => {
189+
runSetupScript(repoDir)
190+
191+
const hooksPath = execFileSync(GIT_BIN, ["config", "core.hooksPath"], {
192+
cwd: repoDir,
193+
encoding: "utf8"
194+
}).trim()
195+
196+
expect(hooksPath).toBe(".githooks")
197+
})
198+
199+
// INVARIANT: idempotent(setup) — running twice produces same result
200+
it("is idempotent — running setup twice produces the same result", () => {
201+
runSetupScript(repoDir)
202+
const firstContent = readGeneratedHook(repoDir)
203+
204+
runSetupScript(repoDir)
205+
const secondContent = readGeneratedHook(repoDir)
206+
207+
expect(firstContent).toBe(secondContent)
208+
})
209+
})
210+
})
211+
212+
// Tests that verify the committed repo files directly (no temp repo needed)
213+
describe("committed hook files", () => {
214+
// INVARIANT: ∀ dir ∈ {.claude, .gemini, .codex}: dir ∉ gitignore_entries
215+
it(".gitignore does not ignore .claude, .gemini, or .codex directories", () => {
216+
const content = fs.readFileSync(path.resolve(repoRoot, ".gitignore"), "utf8")
217+
const lines = content.split("\n").map((line) => line.trim())
218+
219+
for (const dir of [".claude", ".gemini", ".codex"]) {
220+
expect(lines).not.toContain(dir)
221+
expect(lines).not.toContain(`${dir}/`)
222+
}
223+
})
224+
225+
// INVARIANT: .githooks/pre-commit contains AI staging logic with correct structure
226+
it("pre-commit hook has AI staging logic, correct shebang, and strict mode", () => {
227+
const content = fs.readFileSync(path.resolve(repoRoot, ".githooks/pre-commit"), "utf8")
228+
229+
expect(content).toContain(AI_DIR_STAGING_SNIPPET)
230+
expect(content.startsWith("#!/usr/bin/env bash\n")).toBe(true)
231+
expect(content).toContain("set -euo pipefail")
232+
})
233+
})

0 commit comments

Comments
 (0)