Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Merge main into stream A: coordination meets the merge queue's decisions and the CLI. get_work gives the issue's decision records newest first and its latest integration_failure decision as last_integration_failure, a merge queue failure noted in a pull request's session is noted in the live harness sessions on it or its issue too, the CLI's coordination commands use the generated operations now that the API describes them (its pending list is empty) and show get_work's definition of done, and the session, decision and coordination events sit together in the catalog, the webhook form and the docs. | 1 | // Tests for the plugin's hooks: node --test plugins/claude-code/test/ |
| 2 | import assert from "node:assert/strict"; | |
| 3 | import { spawnSync } from "node:child_process"; | |
| 4 | import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; | |
| 5 | import { tmpdir } from "node:os"; | |
| 6 | import { join } from "node:path"; | |
| 7 | import { afterEach, beforeEach, describe, test } from "node:test"; | |
| 8 | import { fileURLToPath } from "node:url"; | |
| 9 | ||
| 10 | import { | |
| 11 | PROMPT_LIMIT, | |
| 12 | hook, | |
| 13 | isG1tRemote, | |
| 14 | isGitPush, | |
| 15 | issueFromBranch, | |
| 16 | issueFromCommand, | |
| 17 | plan, | |
| 18 | redact, | |
| 19 | sessionFiles, | |
| 20 | summarizeTool, | |
| 21 | work, | |
| 22 | } from "../hooks/g1t.mjs"; | |
| 23 | ||
| 24 | const HERE = fileURLToPath(new URL(".", import.meta.url)); | |
| 25 | const SCRIPT = join(HERE, "..", "hooks", "g1t.mjs"); | |
| 26 | const FAKE = join(HERE, "fake-g1t.mjs"); | |
| 27 | ||
| 28 | const base = { session_id: "abc-123", cwd: "/work/repo", transcript_path: "/t.jsonl", permission_mode: "default" }; | |
| 29 | ||
| 30 | describe("plan", () => { | |
| 31 | test("SessionStart starts the session", () => { | |
| 32 | const job = plan({ ...base, hook_event_name: "SessionStart", source: "startup" }, {}); | |
| 33 | assert.equal(job.start, true); | |
| 34 | assert.deepEqual(job.steps, []); | |
| 35 | }); | |
| 36 | ||
| 37 | test("UserPromptSubmit logs the prompt, cut to the limit", () => { | |
| 38 | const prompt = "x".repeat(PROMPT_LIMIT + 500); | |
| 39 | const job = plan({ ...base, hook_event_name: "UserPromptSubmit", prompt }, {}); | |
| 40 | const args = job.steps[0].args; | |
| 41 | assert.deepEqual(args.slice(0, 5), ["session", "log", "--kind", "prompt", "--"]); | |
| 42 | assert.equal(args[5].length, PROMPT_LIMIT); | |
| 43 | assert.ok(args[5].endsWith("…")); | |
| 44 | }); | |
| 45 | ||
| 46 | test("G1T_RECORD_PROMPTS=0 keeps prompts back, and G1T_RECORD=0 everything", () => { | |
| 47 | assert.equal(plan({ ...base, hook_event_name: "UserPromptSubmit", prompt: "hi" }, { G1T_RECORD_PROMPTS: "0" }), null); | |
| 48 | assert.ok(plan({ ...base, hook_event_name: "Stop" }, { G1T_RECORD_PROMPTS: "0" })); | |
| 49 | for (const hook_event_name of ["SessionStart", "UserPromptSubmit", "PostToolUse", "Stop", "SessionEnd"]) { | |
| 50 | assert.equal(plan({ ...base, hook_event_name, prompt: "hi", tool_name: "Bash" }, { G1T_RECORD: "0" }), null); | |
| 51 | } | |
| 52 | }); | |
| 53 | ||
| 54 | test("PostToolUse sends the tool and its input's summary, never its response", () => { | |
| 55 | const event = { | |
| 56 | ...base, | |
| 57 | hook_event_name: "PostToolUse", | |
| 58 | tool_name: "Bash", | |
| 59 | tool_input: { command: "npm test", description: "Run the tests" }, | |
| 60 | tool_response: { stdout: "SECRET OUTPUT 1234", stderr: "", interrupted: false }, | |
| 61 | tool_use_id: "toolu_1", | |
| 62 | }; | |
| 63 | const job = plan(event, {}); | |
| 64 | assert.deepEqual(job.steps, [{ args: ["session", "log", "--kind", "tool_call", "--", "Bash: npm test"] }]); | |
| 65 | assert.ok(!JSON.stringify(job).includes("SECRET OUTPUT")); | |
| 66 | }); | |
| 67 | ||
| 68 | test("PostToolUse after a git push updates the head commit", () => { | |
| 69 | const job = plan({ ...base, hook_event_name: "PostToolUse", tool_name: "Bash", tool_input: { command: "git push -u origin 42-fix" } }, {}); | |
| 70 | assert.deepEqual(job.steps[1], { headCommit: true }); | |
| 71 | const other = plan({ ...base, hook_event_name: "PostToolUse", tool_name: "Bash", tool_input: { command: "git status" } }, {}); | |
| 72 | assert.equal(other.steps.length, 1); | |
| 73 | }); | |
| 74 | ||
| 75 | test("PostToolUse remembers the issue a command claims", () => { | |
| 76 | const job = plan({ ...base, hook_event_name: "PostToolUse", tool_name: "Bash", tool_input: { command: "g1t work claim 42 --note x" } }, {}); | |
| 77 | assert.equal(job.issue, 42); | |
| 78 | }); | |
| 79 | ||
| 80 | test("Stop sends a heartbeat and SessionEnd ends the session with its reason", () => { | |
| 81 | assert.deepEqual(plan({ ...base, hook_event_name: "Stop", stop_hook_active: false }, {}).steps, [{ args: ["session", "heartbeat"] }]); | |
| 82 | const end = plan({ ...base, hook_event_name: "SessionEnd", reason: "prompt_input_exit" }, {}); | |
| 83 | assert.deepEqual(end.steps, [{ args: ["session", "end", "--reason", "prompt_input_exit"] }]); | |
| 84 | assert.equal(end.end, true); | |
| 85 | }); | |
| 86 | ||
| 87 | test("events without a session, and other events, do nothing", () => { | |
| 88 | assert.equal(plan({ hook_event_name: "Stop" }, {}), null); | |
| 89 | assert.equal(plan({ ...base, hook_event_name: "PreToolUse", tool_name: "Bash" }, {}), null); | |
| 90 | }); | |
| 91 | }); | |
| 92 | ||
| 93 | describe("summaries", () => { | |
| 94 | test("say what a tool was called with, in one short line", () => { | |
| 95 | assert.equal(summarizeTool("Edit", { file_path: "/work/repo/src/a.ts", old_string: "x", new_string: "y" }, "/work/repo"), "Edit: src/a.ts"); | |
| 96 | assert.equal(summarizeTool("Grep", { pattern: "TODO", path: "src" }, "/w"), "Grep: TODO in src"); | |
| 97 | assert.equal(summarizeTool("WebFetch", { url: "https://example.com", prompt: "p" }), "WebFetch: https://example.com"); | |
| 98 | assert.equal(summarizeTool("Bash", { command: "echo one\necho two" }), "Bash: echo one …"); | |
| 99 | assert.equal(summarizeTool("mcp__g1t__issue", { action: "get", number: 4 }), "mcp__g1t__issue: get"); | |
| 100 | assert.ok(summarizeTool("Bash", { command: "y".repeat(1000) }).length <= 200); | |
| 101 | }); | |
| 102 | ||
| 103 | test("hide what looks like a secret", () => { | |
| 104 | const line = redact('curl -H "Authorization: Bearer g1t_abcdefghijklmnop" https://u:pw@x.com GITHUB_TOKEN=ghp_123456789012 --token abc'); | |
| 105 | assert.ok(!line.includes("abcdefghijklmnop")); | |
| 106 | assert.ok(!line.includes("ghp_123456789012")); | |
| 107 | assert.ok(!line.includes("u:pw@")); | |
| 108 | assert.ok(!line.includes("--token abc")); | |
| 109 | }); | |
| 110 | ||
| 111 | test("find the issue in a branch name", () => { | |
| 112 | assert.equal(issueFromBranch("42-fix-login"), 42); | |
| 113 | assert.equal(issueFromBranch("issue-7"), 7); | |
| 114 | assert.equal(issueFromBranch("fix/123-thing"), 123); | |
| 115 | assert.equal(issueFromBranch("main"), null); | |
| 116 | assert.equal(issueFromBranch("release/1.2"), null); | |
| 117 | assert.equal(issueFromBranch("HEAD"), null); | |
| 118 | assert.equal(issueFromCommand("g1t session attach --issue 9"), 9); | |
| 119 | assert.equal(issueFromCommand("g1t pr create --issue=12 --draft"), 12); | |
| 120 | assert.equal(isGitPush("cd x && git -C repo push"), true); | |
| 121 | assert.equal(isGitPush("git pushd"), false); | |
| 122 | assert.equal(isG1tRemote(["remote.origin.url https://g1t.sh/a/b.git"], {}), true); | |
| 123 | assert.equal(isG1tRemote(["remote.origin.url https://example.com/a/b.git"], {}), false); | |
| 124 | assert.equal(isG1tRemote(["remote.origin.url https://git.acme.dev/a/b.git"], { G1T_HOST: "git.acme.dev" }), true); | |
| 125 | }); | |
| 126 | }); | |
| 127 | ||
| 128 | describe("the plugin's files", () => { | |
| 129 | const root = join(HERE, "..", "..", ".."); | |
| 130 | test("parse as JSON", () => { | |
| 131 | for (const file of [".claude-plugin/marketplace.json", "plugins/claude-code/.claude-plugin/plugin.json", "plugins/claude-code/.mcp.json", "plugins/claude-code/hooks/hooks.json"]) { | |
| 132 | assert.doesNotThrow(() => JSON.parse(readFileSync(join(root, file), "utf8")), file); | |
| 133 | } | |
| 134 | }); | |
| 135 | ||
| 136 | test("the AGENTS.md snippet is the one in the docs", () => { | |
| 137 | const snippet = readFileSync(join(root, "plugins/other-harnesses/AGENTS.snippet.md"), "utf8").replaceAll("\r\n", "\n"); | |
| 138 | const guide = readFileSync(join(root, "apps/docs/src/content/docs/guides/claude-code-plugin.md"), "utf8").replaceAll("\r\n", "\n"); | |
| 139 | const block = /```md title="AGENTS.md"\n([\s\S]*?)```/.exec(guide)?.[1]; | |
| 140 | assert.equal(block, snippet); | |
| 141 | }); | |
| 142 | }); | |
| 143 | ||
| 144 | describe("with a working copy", () => { | |
| 145 | let dir, env, log; | |
| 146 | ||
| 147 | const git = (...args) => spawnSync("git", args, { cwd: dir, encoding: "utf8" }); | |
| 148 | const calls = () => | |
| 149 | existsSync(log) | |
| 150 | ? readFileSync(log, "utf8") | |
| 151 | .trim() | |
| 152 | .split("\n") | |
| 153 | .filter(Boolean) | |
| 154 | .map((l) => JSON.parse(l)) | |
| 155 | : []; | |
| 156 | ||
| 157 | beforeEach(() => { | |
| 158 | dir = mkdtempSync(join(tmpdir(), "g1t-hook-")); | |
| 159 | log = join(dir, "calls.jsonl"); | |
| 160 | git("init", "-q", "-b", "42-fix-login"); | |
| 161 | git("remote", "add", "origin", "https://g1t.sh/acme/app.git"); | |
| 162 | env = { PATH: process.env.PATH, G1T_BIN: FAKE, FAKE_G1T_LOG: log, CLAUDE_PLUGIN_DATA: join(dir, ".data") }; | |
| 163 | }); | |
| 164 | afterEach(() => rmSync(dir, { recursive: true, force: true, maxRetries: 20, retryDelay: 100 })); | |
| 165 | ||
| 166 | test("SessionStart gives Claude the branch's issue's work context", () => { | |
| 167 | const { output, job } = hook({ ...base, cwd: dir, hook_event_name: "SessionStart", source: "startup" }, env); | |
| 168 | const context = output.hookSpecificOutput.additionalContext; | |
| 169 | assert.equal(output.hookSpecificOutput.hookEventName, "SessionStart"); | |
| 170 | assert.match(context, /issue #42/); | |
| 171 | assert.match(context, /Definition of done: it works/); | |
| 172 | assert.deepEqual(calls()[0].args, ["work", "context", "42"]); | |
| 173 | assert.equal(job.start, true); | |
| 174 | }); | |
| 175 | ||
| 176 | test("the background half starts the session once, then logs into it", () => { | |
| 177 | work({ cwd: dir, sid: base.session_id, start: true, steps: [] }, env); | |
| 178 | const { job } = hook({ ...base, cwd: dir, hook_event_name: "UserPromptSubmit", prompt: "fix the login" }, env); | |
| 179 | // No SessionStart marked this session as one in a g1t working copy. | |
| 180 | assert.equal(job, null); | |
| 181 | work(plan({ ...base, cwd: dir, hook_event_name: "UserPromptSubmit", prompt: "fix the login" }, env), env); | |
| 182 | const [start, logged] = calls(); | |
| 183 | assert.deepEqual(start.args, ["session", "start", "--harness", "claude_code", "--harness-session-id", "abc-123", "--json"]); | |
| 184 | assert.deepEqual(logged, { args: ["session", "log", "--kind", "prompt", "--", "fix the login"], session: "ses_fake01" }); | |
| 185 | assert.equal(readFileSync(sessionFiles(base.session_id, env).id, "utf8"), "ses_fake01"); | |
| 186 | }); | |
| 187 | ||
| 188 | test("hooks after SessionStart record; SessionEnd forgets the session", () => { | |
| 189 | hook({ ...base, cwd: dir, hook_event_name: "SessionStart", source: "startup" }, env); | |
| 190 | const files = sessionFiles(base.session_id, env); | |
| 191 | assert.ok(existsSync(files.marker)); | |
| 192 | const { job } = hook({ ...base, cwd: dir, hook_event_name: "Stop" }, env); | |
| 193 | work(job, env); | |
| 194 | work(plan({ ...base, cwd: dir, hook_event_name: "SessionEnd", reason: "clear" }, env), env); | |
| 195 | const args = calls().map((c) => c.args.join(" ")); | |
| 196 | assert.ok(args.includes("session heartbeat")); | |
| 197 | assert.ok(args.includes("session end --reason clear")); | |
| 198 | assert.ok(!existsSync(files.marker) && !existsSync(files.id)); | |
| 199 | }); | |
| 200 | ||
| 201 | test("a push that reached the remote updates the head commit", () => { | |
| 202 | git("-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q", "--allow-empty", "-m", "one"); | |
| 203 | git("update-ref", "refs/remotes/origin/42-fix-login", "HEAD"); | |
| 204 | const head = git("rev-parse", "HEAD").stdout.trim(); | |
| 205 | work(plan({ ...base, cwd: dir, hook_event_name: "PostToolUse", tool_name: "Bash", tool_input: { command: "git push" } }, env), env); | |
| 206 | const update = calls().find((c) => c.args[0] === "api"); | |
| 207 | assert.deepEqual(update.args, ["api", "update_session", "-f", "id=ses_fake01", "-f", `head_commit=${head}`, "--silent"]); | |
| 208 | }); | |
| 209 | ||
| 210 | test("a missing CLI records nothing and says so once", () => { | |
| 211 | const missing = { ...env, G1T_BIN: join(dir, "no-such-g1t") }; | |
| 212 | const first = hook({ ...base, cwd: dir, hook_event_name: "SessionStart" }, missing); | |
| 213 | assert.match(first.output.systemMessage, /not installed/); | |
| 214 | assert.equal(first.job, null); | |
| 215 | const second = hook({ ...base, cwd: dir, hook_event_name: "SessionStart" }, missing); | |
| 216 | assert.equal(second.output, null); | |
| 217 | assert.equal(hook({ ...base, cwd: dir, hook_event_name: "Stop" }, missing).job, null); | |
| 218 | assert.deepEqual(calls(), []); | |
| 219 | }); | |
| 220 | ||
| 221 | test("a signed-out CLI is said once, at the next prompt", () => { | |
| 222 | hook({ ...base, cwd: dir, hook_event_name: "SessionStart" }, { ...env, CLAUDE_PLUGIN_DATA: env.CLAUDE_PLUGIN_DATA }); | |
| 223 | work({ cwd: dir, sid: base.session_id, start: true, steps: [] }, { ...env, FAKE_G1T_EXIT: "4" }); | |
| 224 | const first = hook({ ...base, cwd: dir, hook_event_name: "UserPromptSubmit", prompt: "hi" }, env); | |
| 225 | assert.match(first.output.systemMessage, /not signed in/); | |
| 226 | const second = hook({ ...base, cwd: dir, hook_event_name: "UserPromptSubmit", prompt: "hi" }, env); | |
| 227 | assert.equal(second.output, null); | |
| 228 | }); | |
| 229 | ||
| 230 | test("outside a g1t repository nothing happens", () => { | |
| 231 | git("remote", "set-url", "origin", "https://example.com/acme/app.git"); | |
| 232 | const { output, job } = hook({ ...base, cwd: dir, hook_event_name: "SessionStart" }, env); | |
| 233 | assert.equal(output, null); | |
| 234 | assert.equal(job, null); | |
| 235 | assert.equal(hook({ ...base, cwd: dir, hook_event_name: "Stop" }, env).job, null); | |
| 236 | assert.deepEqual(calls(), []); | |
| 237 | }); | |
| 238 | ||
| 239 | test("the script answers SessionStart on stdin quickly, as JSON", async () => { | |
| 240 | const started = Date.now(); | |
| 241 | const out = spawnSync(process.execPath, [SCRIPT], { | |
| 242 | input: JSON.stringify({ ...base, cwd: dir, hook_event_name: "SessionStart", source: "startup" }), | |
| 243 | encoding: "utf8", | |
| 244 | env: { ...process.env, ...env }, | |
| 245 | }); | |
| 246 | const took = Date.now() - started; | |
| 247 | assert.equal(out.status, 0); | |
| 248 | assert.match(JSON.parse(out.stdout).hookSpecificOutput.additionalContext, /issue #42/); | |
| 249 | assert.ok(took < 3000, `took ${took} ms`); | |
| 250 | // The detached half starts the session after the hook has returned. | |
| 251 | const id = sessionFiles(base.session_id, env).id; | |
| 252 | for (let i = 0; i < 100 && !existsSync(id); i++) await new Promise((done) => setTimeout(done, 50)); | |
| 253 | assert.equal(readFileSync(id, "utf8"), "ses_fake01"); | |
| 254 | await new Promise((done) => setTimeout(done, 200)); | |
| 255 | }); | |
| 256 | ||
| 257 | test("the script prints nothing for a prompt, and never fails on bad input", () => { | |
| 258 | const prompt = spawnSync(process.execPath, [SCRIPT], { | |
| 259 | input: JSON.stringify({ ...base, cwd: dir, hook_event_name: "UserPromptSubmit", prompt: "hello" }), | |
| 260 | encoding: "utf8", | |
| 261 | env: { ...process.env, ...env }, | |
| 262 | }); | |
| 263 | assert.equal(prompt.status, 0); | |
| 264 | assert.equal(prompt.stdout, ""); | |
| 265 | const bad = spawnSync(process.execPath, [SCRIPT], { input: "not json", encoding: "utf8", env: { ...process.env, ...env } }); | |
| 266 | assert.equal(bad.status, 0); | |
| 267 | assert.equal(bad.stdout, ""); | |
| 268 | }); | |
| 269 | }); |
This file's history is long; its oldest lines are credited to the oldest commit read.