|
| 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