Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh
473 linesCodeBlameRaw

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 branch 'worktree-agent-ad52ad80841a37960'1// Tests for the plugin's hooks: node --test plugins/claude-code/test/
2import assert from "node:assert/strict";
3import { spawnSync } from "node:child_process";
4import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
5import { tmpdir } from "node:os";
6import { join } from "node:path";
7import { afterEach, beforeEach, describe, test } from "node:test";
8import { fileURLToPath } from "node:url";
9
10import {
11 PROMPT_LIMIT,
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.12 RESULT_LIMIT,
13 agentResult,
14 branchFromCommand,
15 g1tTool,
Merge branch 'worktree-agent-ad52ad80841a37960'16 hook,
17 isG1tRemote,
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.18 isGitCommit,
Merge branch 'worktree-agent-ad52ad80841a37960'19 isGitPush,
20 issueFromBranch,
21 issueFromCommand,
22 plan,
23 redact,
24 sessionFiles,
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.25 subagentHarnessId,
Merge branch 'worktree-agent-ad52ad80841a37960'26 summarizeTool,
27 work,
28} from "../hooks/g1t.mjs";
29
30const HERE = fileURLToPath(new URL(".", import.meta.url));
31const SCRIPT = join(HERE, "..", "hooks", "g1t.mjs");
32const FAKE = join(HERE, "fake-g1t.mjs");
33
34const base = { session_id: "abc-123", cwd: "/work/repo", transcript_path: "/t.jsonl", permission_mode: "default" };
35
36describe("plan", () => {
37 test("SessionStart starts the session", () => {
38 const job = plan({ ...base, hook_event_name: "SessionStart", source: "startup" }, {});
39 assert.equal(job.start, true);
40 assert.deepEqual(job.steps, []);
41 });
42
43 test("UserPromptSubmit logs the prompt, cut to the limit", () => {
44 const prompt = "x".repeat(PROMPT_LIMIT + 500);
45 const job = plan({ ...base, hook_event_name: "UserPromptSubmit", prompt }, {});
46 const args = job.steps[0].args;
47 assert.deepEqual(args.slice(0, 5), ["session", "log", "--kind", "prompt", "--"]);
48 assert.equal(args[5].length, PROMPT_LIMIT);
49 assert.ok(args[5].endsWith("…"));
50 });
51
52 test("G1T_RECORD_PROMPTS=0 keeps prompts back, and G1T_RECORD=0 everything", () => {
53 assert.equal(plan({ ...base, hook_event_name: "UserPromptSubmit", prompt: "hi" }, { G1T_RECORD_PROMPTS: "0" }), null);
54 assert.ok(plan({ ...base, hook_event_name: "Stop" }, { G1T_RECORD_PROMPTS: "0" }));
55 for (const hook_event_name of ["SessionStart", "UserPromptSubmit", "PostToolUse", "Stop", "SessionEnd"]) {
56 assert.equal(plan({ ...base, hook_event_name, prompt: "hi", tool_name: "Bash" }, { G1T_RECORD: "0" }), null);
57 }
58 });
59
60 test("PostToolUse sends the tool and its input's summary, never its response", () => {
61 const event = {
62 ...base,
63 hook_event_name: "PostToolUse",
64 tool_name: "Bash",
65 tool_input: { command: "npm test", description: "Run the tests" },
66 tool_response: { stdout: "SECRET OUTPUT 1234", stderr: "", interrupted: false },
67 tool_use_id: "toolu_1",
68 };
69 const job = plan(event, {});
70 assert.deepEqual(job.steps, [{ args: ["session", "log", "--kind", "tool_call", "--", "Bash: npm test"] }]);
71 assert.ok(!JSON.stringify(job).includes("SECRET OUTPUT"));
72 });
73
74 test("PostToolUse after a git push updates the head commit", () => {
75 const job = plan({ ...base, hook_event_name: "PostToolUse", tool_name: "Bash", tool_input: { command: "git push -u origin 42-fix" } }, {});
76 assert.deepEqual(job.steps[1], { headCommit: true });
77 const other = plan({ ...base, hook_event_name: "PostToolUse", tool_name: "Bash", tool_input: { command: "git status" } }, {});
78 assert.equal(other.steps.length, 1);
79 });
80
81 test("PostToolUse remembers the issue a command claims", () => {
82 const job = plan({ ...base, hook_event_name: "PostToolUse", tool_name: "Bash", tool_input: { command: "g1t work claim 42 --note x" } }, {});
83 assert.equal(job.issue, 42);
84 });
85
86 test("Stop sends a heartbeat and SessionEnd ends the session with its reason", () => {
87 assert.deepEqual(plan({ ...base, hook_event_name: "Stop", stop_hook_active: false }, {}).steps, [{ args: ["session", "heartbeat"] }]);
88 const end = plan({ ...base, hook_event_name: "SessionEnd", reason: "prompt_input_exit" }, {});
89 assert.deepEqual(end.steps, [{ args: ["session", "end", "--reason", "prompt_input_exit"] }]);
90 assert.equal(end.end, true);
91 });
92
93 test("events without a session, and other events, do nothing", () => {
94 assert.equal(plan({ hook_event_name: "Stop" }, {}), null);
95 assert.equal(plan({ ...base, hook_event_name: "PreToolUse", tool_name: "Bash" }, {}), null);
96 });
97});
98
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.99// Payloads as Claude Code sends them (code.claude.com/docs/en/hooks).
100const fixtures = {
101 subagentStart: {
102 session_id: "abc-123",
103 transcript_path: "/t.jsonl",
104 cwd: "/work/repo",
105 hook_event_name: "SubagentStart",
106 agent_id: "agent-def456",
107 agent_type: "Explore",
108 },
109 subagentStop: {
110 session_id: "abc-123",
111 transcript_path: "/t.jsonl",
112 cwd: "/work/repo",
113 permission_mode: "default",
114 hook_event_name: "SubagentStop",
115 stop_hook_active: false,
116 agent_id: "def456",
117 agent_type: "Explore",
118 agent_transcript_path: "/t/abc-123/subagents/agent-def456.jsonl",
119 last_assistant_message: "Analysis complete. Found 3 potential issues...",
120 background_tasks: [],
121 session_crons: [],
122 },
123 agentCompleted: {
124 ...base,
125 hook_event_name: "PostToolUse",
126 tool_name: "Agent",
127 tool_input: { description: "Find API endpoints", prompt: "Find all API endpoints", subagent_type: "Explore" },
128 tool_response: {
129 status: "completed",
130 agentId: "a4d2c8f1e0b3a297",
131 content: [{ type: "text", text: "Found 12 endpoints in src/api." }],
132 resolvedModel: "claude-sonnet-4-5",
133 totalTokens: 12450,
134 totalDurationMs: 48211,
135 totalToolUseCount: 7,
136 usage: { input_tokens: 8320, output_tokens: 410 },
137 },
138 tool_use_id: "toolu_9",
139 },
140 agentLaunched: {
141 ...base,
142 hook_event_name: "PostToolUse",
143 tool_name: "Agent",
144 tool_input: { description: "Review the diff", prompt: "Review it", subagent_type: "code-reviewer", run_in_background: true },
145 tool_response: { status: "async_launched", agentId: "b5", description: "Review the diff", prompt: "Review it", outputFile: "/tmp/b5.out", resolvedModel: "claude-sonnet-4-5" },
146 },
147 inSubagent: {
148 ...base,
149 hook_event_name: "PostToolUse",
150 agent_id: "agent-def456",
151 agent_type: "Explore",
152 tool_name: "Edit",
153 tool_input: { file_path: "/work/repo/src/api/routes.ts", old_string: "a", new_string: "b" },
154 tool_response: { filePath: "/work/repo/src/api/routes.ts", structuredPatch: "SECRET PATCH" },
155 },
156 skill: { ...base, hook_event_name: "PostToolUse", tool_name: "Skill", tool_input: { skill: "work-on-issue", args: "42" }, tool_response: { success: true, commandName: "work-on-issue" } },
157};
158
159describe("subagents", () => {
160 test("SubagentStart starts a session of its own for the subagent", () => {
161 const job = plan(fixtures.subagentStart, {});
162 assert.deepEqual(job.agent, { id: "agent-def456", type: "Explore", start: true });
163 assert.deepEqual(job.steps, []);
164 assert.equal(subagentHarnessId("abc-123", "agent-def456"), "abc-123/agent/def456");
165 assert.equal(subagentHarnessId("abc-123", "def456"), "abc-123/agent/def456", "the Agent tool's id names the same subagent");
166 });
167
168 test("SubagentStop ends it with its final message, cut to the limit", () => {
169 const job = plan(fixtures.subagentStop, {});
170 assert.deepEqual(job.agent, { id: "def456", type: "Explore" });
171 assert.deepEqual(job.steps, [{ args: ["session", "end", "--reason", "done", "--result", "Analysis complete. Found 3 potential issues..."] }]);
172 const long = plan({ ...fixtures.subagentStop, last_assistant_message: "z".repeat(RESULT_LIMIT * 2) }, {});
173 assert.equal(long.steps[0].args.at(-1).length, RESULT_LIMIT);
174 });
175
176 test("an Agent call gives its subagent the task, and once finished its final message, and nothing else of its answer", () => {
177 const job = plan(fixtures.agentCompleted, {});
178 assert.deepEqual(job.subagent, {
179 id: "a4d2c8f1e0b3a297",
180 type: "Explore",
181 task: "Find API endpoints",
182 prompt: "Find all API endpoints",
183 model: "claude-sonnet-4-5",
184 result: "Found 12 endpoints in src/api.",
185 });
186 assert.deepEqual(job.steps, [{ args: ["session", "log", "--kind", "tool_call", "--", "Agent: Find API endpoints"] }]);
187 assert.ok(!JSON.stringify(job).includes("12450") && !JSON.stringify(job).includes("8320"));
188 const launched = plan(fixtures.agentLaunched, {});
189 assert.equal(launched.subagent.id, "b5");
190 assert.equal(launched.subagent.result, null, "a background subagent reports at SubagentStop");
191 assert.equal(plan(fixtures.agentCompleted, { G1T_RECORD_PROMPTS: "0" }).subagent.prompt, null);
192 assert.equal(agentResult({ content: "plain" }), "plain");
193 });
194
195 test("what a subagent does is its own session's, and the files it writes are touched", () => {
196 const job = plan(fixtures.inSubagent, {});
197 assert.deepEqual(job.agent, { id: "agent-def456", type: "Explore" });
198 assert.deepEqual(job.steps, [
199 { args: ["session", "log", "--kind", "tool_call", "--", "Edit: src/api/routes.ts"] },
200 { args: ["session", "touch", "--file", "src/api/routes.ts"] },
201 ]);
202 assert.ok(!JSON.stringify(job).includes("SECRET PATCH"));
203 });
204
205 test("a skill is its name only", () => {
206 assert.deepEqual(plan(fixtures.skill, {}).steps, [{ args: ["session", "log", "--kind", "skill", "--", "work-on-issue"] }]);
207 });
208
209 test("branches made, commits, claims and g1t's MCP tools are touched", () => {
210 const bash = (command) => plan({ ...base, hook_event_name: "PostToolUse", tool_name: "Bash", tool_input: { command } }, {});
211 assert.deepEqual(bash("git checkout -b 42-fix").steps.at(-1), { args: ["session", "touch", "--branch", "42-fix"] });
212 assert.deepEqual(bash('git commit -m "x" --trailer "G1t-Session: $G1T_SESSION"').steps.at(-1), { touchHead: true });
213 assert.deepEqual(bash("g1t work claim 42").steps.at(-1), { args: ["session", "touch", "--issue", "42"] });
214 const mcp = plan({ ...base, hook_event_name: "PostToolUse", tool_name: "mcp__plugin_g1t_g1t__issue", tool_input: { action: "claim", repo: "a/b", number: 7 } }, {});
215 assert.equal(mcp.issue, 7);
216 assert.equal(branchFromCommand("git switch -c feat/x && echo"), "feat/x");
217 assert.equal(branchFromCommand("git branch -d old"), null);
218 assert.equal(isGitCommit("git -C repo commit -am x"), true);
219 assert.equal(g1tTool("mcp__g1t__session"), "session");
220 assert.equal(g1tTool("mcp__github__issue"), null);
221 });
222
223 test("a hand-off's issue comes with the session", () => {
224 assert.equal(plan({ ...base, hook_event_name: "SessionStart" }, { G1T_ISSUE: "12" }).issue, 12);
225 });
226});
227
Merge branch 'worktree-agent-ad52ad80841a37960'228describe("summaries", () => {
229 test("say what a tool was called with, in one short line", () => {
230 assert.equal(summarizeTool("Edit", { file_path: "/work/repo/src/a.ts", old_string: "x", new_string: "y" }, "/work/repo"), "Edit: src/a.ts");
231 assert.equal(summarizeTool("Grep", { pattern: "TODO", path: "src" }, "/w"), "Grep: TODO in src");
232 assert.equal(summarizeTool("WebFetch", { url: "https://example.com", prompt: "p" }), "WebFetch: https://example.com");
233 assert.equal(summarizeTool("Bash", { command: "echo one\necho two" }), "Bash: echo one …");
234 assert.equal(summarizeTool("mcp__g1t__issue", { action: "get", number: 4 }), "mcp__g1t__issue: get");
235 assert.ok(summarizeTool("Bash", { command: "y".repeat(1000) }).length <= 200);
236 });
237
238 test("hide what looks like a secret", () => {
239 const line = redact('curl -H "Authorization: Bearer g1t_abcdefghijklmnop" https://u:pw@x.com GITHUB_TOKEN=ghp_123456789012 --token abc');
240 assert.ok(!line.includes("abcdefghijklmnop"));
241 assert.ok(!line.includes("ghp_123456789012"));
242 assert.ok(!line.includes("u:pw@"));
243 assert.ok(!line.includes("--token abc"));
244 });
245
246 test("find the issue in a branch name", () => {
247 assert.equal(issueFromBranch("42-fix-login"), 42);
248 assert.equal(issueFromBranch("issue-7"), 7);
249 assert.equal(issueFromBranch("fix/123-thing"), 123);
250 assert.equal(issueFromBranch("main"), null);
251 assert.equal(issueFromBranch("release/1.2"), null);
252 assert.equal(issueFromBranch("HEAD"), null);
253 assert.equal(issueFromCommand("g1t session attach --issue 9"), 9);
254 assert.equal(issueFromCommand("g1t pr create --issue=12 --draft"), 12);
255 assert.equal(isGitPush("cd x && git -C repo push"), true);
256 assert.equal(isGitPush("git pushd"), false);
257 assert.equal(isG1tRemote(["remote.origin.url https://g1t.sh/a/b.git"], {}), true);
258 assert.equal(isG1tRemote(["remote.origin.url https://example.com/a/b.git"], {}), false);
259 assert.equal(isG1tRemote(["remote.origin.url https://git.acme.dev/a/b.git"], { G1T_HOST: "git.acme.dev" }), true);
260 });
261});
262
263describe("the plugin's files", () => {
264 const root = join(HERE, "..", "..", "..");
265 test("parse as JSON", () => {
266 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"]) {
267 assert.doesNotThrow(() => JSON.parse(readFileSync(join(root, file), "utf8")), file);
268 }
269 });
270
271 test("the AGENTS.md snippet is the one in the docs", () => {
272 const snippet = readFileSync(join(root, "plugins/other-harnesses/AGENTS.snippet.md"), "utf8").replaceAll("\r\n", "\n");
273 const guide = readFileSync(join(root, "apps/docs/src/content/docs/guides/claude-code-plugin.md"), "utf8").replaceAll("\r\n", "\n");
274 const block = /```md title="AGENTS.md"\n([\s\S]*?)```/.exec(guide)?.[1];
275 assert.equal(block, snippet);
276 });
277});
278
279describe("with a working copy", () => {
280 let dir, env, log;
281
282 const git = (...args) => spawnSync("git", args, { cwd: dir, encoding: "utf8" });
283 const calls = () =>
284 existsSync(log)
285 ? readFileSync(log, "utf8")
286 .trim()
287 .split("\n")
288 .filter(Boolean)
289 .map((l) => JSON.parse(l))
290 : [];
291
292 beforeEach(() => {
293 dir = mkdtempSync(join(tmpdir(), "g1t-hook-"));
294 log = join(dir, "calls.jsonl");
295 git("init", "-q", "-b", "42-fix-login");
296 git("remote", "add", "origin", "https://g1t.sh/acme/app.git");
297 env = { PATH: process.env.PATH, G1T_BIN: FAKE, FAKE_G1T_LOG: log, CLAUDE_PLUGIN_DATA: join(dir, ".data") };
298 });
299 afterEach(() => rmSync(dir, { recursive: true, force: true, maxRetries: 20, retryDelay: 100 }));
300
301 test("SessionStart gives Claude the branch's issue's work context", () => {
302 const { output, job } = hook({ ...base, cwd: dir, hook_event_name: "SessionStart", source: "startup" }, env);
303 const context = output.hookSpecificOutput.additionalContext;
304 assert.equal(output.hookSpecificOutput.hookEventName, "SessionStart");
305 assert.match(context, /issue #42/);
306 assert.match(context, /Definition of done: it works/);
307 assert.deepEqual(calls()[0].args, ["work", "context", "42"]);
308 assert.equal(job.start, true);
309 });
310
311 test("the background half starts the session once, then logs into it", () => {
312 work({ cwd: dir, sid: base.session_id, start: true, steps: [] }, env);
313 const { job } = hook({ ...base, cwd: dir, hook_event_name: "UserPromptSubmit", prompt: "fix the login" }, env);
314 // No SessionStart marked this session as one in a g1t working copy.
315 assert.equal(job, null);
316 work(plan({ ...base, cwd: dir, hook_event_name: "UserPromptSubmit", prompt: "fix the login" }, env), env);
317 const [start, logged] = calls();
318 assert.deepEqual(start.args, ["session", "start", "--harness", "claude_code", "--harness-session-id", "abc-123", "--json"]);
319 assert.deepEqual(logged, { args: ["session", "log", "--kind", "prompt", "--", "fix the login"], session: "ses_fake01" });
320 assert.equal(readFileSync(sessionFiles(base.session_id, env).id, "utf8"), "ses_fake01");
321 });
322
323 test("hooks after SessionStart record; SessionEnd forgets the session", () => {
324 hook({ ...base, cwd: dir, hook_event_name: "SessionStart", source: "startup" }, env);
325 const files = sessionFiles(base.session_id, env);
326 assert.ok(existsSync(files.marker));
327 const { job } = hook({ ...base, cwd: dir, hook_event_name: "Stop" }, env);
328 work(job, env);
329 work(plan({ ...base, cwd: dir, hook_event_name: "SessionEnd", reason: "clear" }, env), env);
330 const args = calls().map((c) => c.args.join(" "));
331 assert.ok(args.includes("session heartbeat"));
332 assert.ok(args.includes("session end --reason clear"));
333 assert.ok(!existsSync(files.marker) && !existsSync(files.id));
334 });
335
336 test("a push that reached the remote updates the head commit", () => {
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.337 git("-c", "user.name=t", "-c", "user.email=t@t", "-c", "commit.gpgsign=false", "commit", "-q", "--allow-empty", "-m", "one");
Merge branch 'worktree-agent-ad52ad80841a37960'338 git("update-ref", "refs/remotes/origin/42-fix-login", "HEAD");
339 const head = git("rev-parse", "HEAD").stdout.trim();
340 work(plan({ ...base, cwd: dir, hook_event_name: "PostToolUse", tool_name: "Bash", tool_input: { command: "git push" } }, env), env);
341 const update = calls().find((c) => c.args[0] === "api");
342 assert.deepEqual(update.args, ["api", "update_session", "-f", "id=ses_fake01", "-f", `head_commit=${head}`, "--silent"]);
343 });
344
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.345 test("each subagent is a session of its own under this one, whatever order its hooks arrive in", () => {
346 const at = (event) => ({ ...event, cwd: dir });
347 hook(at({ ...base, hook_event_name: "SessionStart", source: "startup" }), env);
348 work({ cwd: dir, sid: base.session_id, start: true, steps: [] }, env);
349 // Claude calls Agent; its task waits for the subagent.
350 const pre = hook(at({ ...base, hook_event_name: "PreToolUse", tool_name: "Agent", tool_input: fixtures.agentCompleted.tool_input }), env);
351 assert.equal(pre.output, null);
352 const started = hook(at(fixtures.subagentStart), env);
353 assert.match(started.output.hookSpecificOutput.additionalContext, /session of your own/);
354 assert.match(started.output.hookSpecificOutput.additionalContext, /g1t session list --issue 42 --subagents|g1t session list --subagents/);
355 assert.equal(started.job.agent.task, "Find API endpoints");
356 work(started.job, env);
357 // What it does goes to its own session.
358 const edited = { ...fixtures.inSubagent.tool_input, file_path: join(dir, "src", "api", "routes.ts") };
359 work(hook(at({ ...fixtures.inSubagent, tool_input: edited }), env).job, env);
360 // Its g1t commands act as it.
361 const bash = hook(at({ ...base, hook_event_name: "PreToolUse", agent_id: "agent-def456", agent_type: "Explore", tool_name: "Bash", tool_input: { command: "g1t work claim 42" } }), env);
362 assert.equal(bash.output.hookSpecificOutput.updatedInput.command, "export G1T_SESSION=ses_sub_def456; g1t work claim 42");
363 const mcp = hook(at({ ...base, hook_event_name: "PreToolUse", agent_id: "agent-def456", tool_name: "mcp__plugin_g1t_g1t__issue", tool_input: { action: "claim", repo: "acme/app", number: 42 } }), env);
364 assert.equal(mcp.output.hookSpecificOutput.updatedInput.session_id, "ses_sub_def456");
365 // Outside a subagent, a command is left alone.
366 assert.equal(hook(at({ ...base, hook_event_name: "PreToolUse", tool_name: "Bash", tool_input: { command: "g1t work claim 42" } }), env).output, null);
367 work(hook(at(fixtures.subagentStop), env).job, env);
368 // A late SubagentStart starts it again, which g1t answers with the same, ended, session.
369 work(hook(at(fixtures.subagentStart), env).job, env);
370 const log = calls();
371 const childStarts = log.filter((c) => c.args[1] === "start" && c.args.includes("--parent"));
372 assert.ok(childStarts.length >= 2);
373 const first = childStarts[0].args;
374 assert.deepEqual(first.slice(0, 8), ["session", "start", "--harness", "claude_code", "--harness-session-id", "abc-123/agent/def456", "--parent", "ses_fake01"]);
375 assert.ok(first.includes("--no-remember"));
376 assert.equal(first[first.indexOf("--agent-type") + 1], "Explore");
377 assert.equal(first[first.indexOf("--task") + 1], "Find API endpoints");
378 const edit = log.find((c) => c.args[2] === "--kind" && c.args.at(-1) === "Edit: src/api/routes.ts");
379 assert.equal(edit.session, "ses_sub_def456");
380 const touched = log.find((c) => c.args[1] === "touch");
381 assert.deepEqual(touched, { args: ["session", "touch", "--file", "src/api/routes.ts"], session: "ses_sub_def456" });
382 const ended = log.find((c) => c.args[1] === "end");
383 assert.deepEqual(ended, { args: ["session", "end", "--reason", "done", "--result", "Analysis complete. Found 3 potential issues..."], session: "ses_sub_def456" });
384 });
385
386 test("a subagent whose stop arrives first is started, then ended", () => {
387 hook({ ...base, cwd: dir, hook_event_name: "SessionStart" }, env);
388 work(hook({ ...fixtures.subagentStop, cwd: dir }, env).job, env);
389 const [, start, end] = calls().filter((c) => c.args[0] === "session");
390 assert.ok(start.args.includes("--parent"));
391 assert.equal(end.args[1], "end");
392 assert.equal(end.session, "ses_sub_def456");
393 });
394
395 test("an Agent call that finished ends its subagent with what it reported", () => {
396 hook({ ...base, cwd: dir, hook_event_name: "SessionStart" }, env);
397 work(hook({ ...fixtures.agentCompleted, cwd: dir }, env).job, env);
398 const log = calls().filter((c) => c.args[0] === "session");
399 assert.equal(log.find((c) => c.args[1] === "log").session, "ses_fake01", "the Agent call is the parent's");
400 const end = log.find((c) => c.args[1] === "end");
401 assert.deepEqual(end, { args: ["session", "end", "--reason", "done", "--result", "Found 12 endpoints in src/api."], session: "ses_sub_a4d2c8f1e0b3a297" });
402 });
403
404 test("a hand-off names its issue at the start", () => {
405 git("checkout", "-q", "-b", "main");
406 const { output, job } = hook({ ...base, cwd: dir, hook_event_name: "SessionStart" }, { ...env, G1T_ISSUE: "12" });
407 assert.match(output.hookSpecificOutput.additionalContext, /issue #12 \(from the work handed to it\)/);
408 assert.match(output.hookSpecificOutput.additionalContext, /G1t-Session/);
409 work(job, env);
410 const start = calls().find((c) => c.args[1] === "start");
411 assert.ok(start.args.includes("--issue") && start.args.includes("12"));
412 });
413
Merge branch 'worktree-agent-ad52ad80841a37960'414 test("a missing CLI records nothing and says so once", () => {
415 const missing = { ...env, G1T_BIN: join(dir, "no-such-g1t") };
416 const first = hook({ ...base, cwd: dir, hook_event_name: "SessionStart" }, missing);
417 assert.match(first.output.systemMessage, /not installed/);
418 assert.equal(first.job, null);
419 const second = hook({ ...base, cwd: dir, hook_event_name: "SessionStart" }, missing);
420 assert.equal(second.output, null);
421 assert.equal(hook({ ...base, cwd: dir, hook_event_name: "Stop" }, missing).job, null);
422 assert.deepEqual(calls(), []);
423 });
424
425 test("a signed-out CLI is said once, at the next prompt", () => {
426 hook({ ...base, cwd: dir, hook_event_name: "SessionStart" }, { ...env, CLAUDE_PLUGIN_DATA: env.CLAUDE_PLUGIN_DATA });
427 work({ cwd: dir, sid: base.session_id, start: true, steps: [] }, { ...env, FAKE_G1T_EXIT: "4" });
428 const first = hook({ ...base, cwd: dir, hook_event_name: "UserPromptSubmit", prompt: "hi" }, env);
429 assert.match(first.output.systemMessage, /not signed in/);
430 const second = hook({ ...base, cwd: dir, hook_event_name: "UserPromptSubmit", prompt: "hi" }, env);
431 assert.equal(second.output, null);
432 });
433
434 test("outside a g1t repository nothing happens", () => {
435 git("remote", "set-url", "origin", "https://example.com/acme/app.git");
436 const { output, job } = hook({ ...base, cwd: dir, hook_event_name: "SessionStart" }, env);
437 assert.equal(output, null);
438 assert.equal(job, null);
439 assert.equal(hook({ ...base, cwd: dir, hook_event_name: "Stop" }, env).job, null);
440 assert.deepEqual(calls(), []);
441 });
442
443 test("the script answers SessionStart on stdin quickly, as JSON", async () => {
444 const started = Date.now();
445 const out = spawnSync(process.execPath, [SCRIPT], {
446 input: JSON.stringify({ ...base, cwd: dir, hook_event_name: "SessionStart", source: "startup" }),
447 encoding: "utf8",
448 env: { ...process.env, ...env },
449 });
450 const took = Date.now() - started;
451 assert.equal(out.status, 0);
452 assert.match(JSON.parse(out.stdout).hookSpecificOutput.additionalContext, /issue #42/);
453 assert.ok(took < 3000, `took ${took} ms`);
454 // The detached half starts the session after the hook has returned.
455 const id = sessionFiles(base.session_id, env).id;
456 for (let i = 0; i < 100 && !existsSync(id); i++) await new Promise((done) => setTimeout(done, 50));
457 assert.equal(readFileSync(id, "utf8"), "ses_fake01");
458 await new Promise((done) => setTimeout(done, 200));
459 });
460
461 test("the script prints nothing for a prompt, and never fails on bad input", () => {
462 const prompt = spawnSync(process.execPath, [SCRIPT], {
463 input: JSON.stringify({ ...base, cwd: dir, hook_event_name: "UserPromptSubmit", prompt: "hello" }),
464 encoding: "utf8",
465 env: { ...process.env, ...env },
466 });
467 assert.equal(prompt.status, 0);
468 assert.equal(prompt.stdout, "");
469 const bad = spawnSync(process.execPath, [SCRIPT], { input: "not json", encoding: "utf8", env: { ...process.env, ...env } });
470 assert.equal(bad.status, 0);
471 assert.equal(bad.stdout, "");
472 });
473});

This file's history is long; its oldest lines are credited to the oldest commit read.