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
415 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 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#!/usr/bin/env node
2// g1t's Claude Code hooks. Claude Code runs this file for SessionStart,
3// UserPromptSubmit, PostToolUse, Stop and SessionEnd, with the event as JSON
4// on stdin. In a working copy of a g1t repository it records the session on
5// g1t through the `g1t` CLI:
6//
7// SessionStart g1t session start --harness claude_code --harness-session-id ID
8// and, when the branch or the session names an issue,
9// g1t work context N, given to Claude as context
10// UserPromptSubmit g1t session log --kind prompt TEXT (first 2,000 characters)
11// PostToolUse g1t session log --kind tool_call "Tool: what it was called with"
12// and after a git push that reached the remote,
13// g1t api update_session -f id=SESSION -f head_commit=SHA
14// Stop g1t session heartbeat
15// SessionEnd g1t session end --reason REASON
16//
17// What a tool returned (tool_response) is never read or sent. Prompts are
18// not sent when G1T_RECORD_PROMPTS=0, and nothing is when G1T_RECORD=0.
19//
20// Every hook returns at once: the CLI calls run in a detached process of
21// their own, so a slow network never holds Claude up. If `g1t` is not
22// installed or not signed in, nothing is recorded and Claude Code says so
23// once. Nothing is ever installed. No dependencies; Node 18 or later.
24
25import { spawn, spawnSync } from "node:child_process";
26import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
27import { homedir } from "node:os";
28import { delimiter, dirname, isAbsolute, join, relative, resolve } from "node:path";
29import { fileURLToPath } from "node:url";
30
31/** The most of a prompt that is sent. */
32export const PROMPT_LIMIT = 2000;
33/** The most of a tool call's summary that is sent. */
34export const SUMMARY_LIMIT = 200;
35/** The most of an issue's work context given to Claude. */
36export const CONTEXT_LIMIT = 8000;
37/** How long SessionStart waits for an issue's work context. */
38const CONTEXT_WAIT_MS = 900;
39/** Exit code the g1t CLI gives when signing in is needed. */
40const EXIT_AUTH = 4;
41
42const SELF = fileURLToPath(import.meta.url);
43
44// --- what to send ------------------------------------------------------
45
46export function clip(text, limit) {
47 const value = String(text ?? "");
48 return value.length > limit ? `${value.slice(0, limit - 1)}…` : value;
49}
50
51/** Hides what looks like a secret in a command line before it is sent. */
52export function redact(text) {
53 return String(text)
54 .replace(/\b(g1t|ghp|gho|ghs|github_pat|glpat|xox[abpr]|sk-ant|sk|rk)[-_][A-Za-z0-9_-]{8,}/g, "$1_…")
55 .replace(/(authorization:\s*(?:bearer|basic|token)\s+)[^\s"']+/gi, "$1…")
56 .replace(/(\b[A-Za-z_]*(?:TOKEN|SECRET|PASSWORD|PASSWD|API_?KEY|PRIVATE_KEY)[A-Za-z_]*=)("[^"]*"|'[^']*'|\S+)/gi, "$1…")
57 .replace(/(--(?:token|password|secret)[= ])\S+/gi, "$1…")
58 .replace(/(https?:\/\/)[^\s/:@]+:[^\s/@]+@/g, "$1…@");
59}
60
61/** One line on what a tool was called with: never what it returned. */
62export function summarizeTool(name, input, cwd) {
63 const i = input && typeof input === "object" ? input : {};
64 const path = (p) => {
65 if (typeof p !== "string") return "";
66 if (cwd && isAbsolute(p)) {
67 const rel = relative(cwd, p);
68 if (rel && !rel.startsWith("..") && !isAbsolute(rel)) return rel.replaceAll("\\", "/");
69 }
70 return p;
71 };
72 let what = "";
73 if (typeof i.command === "string") what = i.command.split("\n")[0] + (i.command.includes("\n") ? " …" : "");
74 else if (i.file_path || i.notebook_path) what = path(i.file_path ?? i.notebook_path);
75 else if (typeof i.pattern === "string") what = `${i.pattern}${i.path ? ` in ${path(i.path)}` : ""}`;
76 else if (typeof i.url === "string") what = i.url;
77 else if (typeof i.query === "string") what = i.query;
78 else if (typeof i.description === "string") what = i.description;
79 else if (typeof i.action === "string") what = i.action;
80 const line = what ? `${name}: ${redact(what)}` : String(name);
81 return clip(line, SUMMARY_LIMIT);
82}
83
84/** The issue a branch is for, when its name holds the number: 42-fix, issue-42, fix/42, g1t/42-x. */
85export function issueFromBranch(branch) {
86 if (!branch || branch === "HEAD") return null;
87 if (/^(release|releases|hotfix|v\d)/i.test(branch)) return null;
88 const match = /(?:^|[/_-])(?:issues?-?|#)?(\d{1,7})(?=$|[/_-])/i.exec(branch);
89 if (!match) return null;
90 const n = Number(match[1]);
91 return n > 0 ? n : null;
92}
93
94/** The issue a shell command claims or attaches the session to, if any. */
95export function issueFromCommand(command) {
96 if (typeof command !== "string") return null;
97 const patterns = [
98 /\bg1t\s+work\s+claim\s+#?(\d+)/,
99 /\bg1t\s+session\s+(?:start|attach)\b[^|;&\n]*--issue[= ]#?(\d+)/,
100 /\bg1t\s+pr\s+create\b[^|;&\n]*--issue[= ]#?(\d+)/,
101 ];
102 for (const pattern of patterns) {
103 const match = pattern.exec(command);
104 if (match) return Number(match[1]);
105 }
106 return null;
107}
108
109export const isGitPush = (command) =>
110 typeof command === "string" && /(^|[\s;&|(])git(\s+-\S+(\s+[^-\s]\S*)?)*\s+push\b/.test(command);
111
112/**
113 * What a hook event asks for: the CLI calls to run in the background
114 * (`steps`), and whether to start the session first. Pure, so the tests
115 * can check it without a CLI.
116 */
117export function plan(event, env = process.env) {
118 const name = event?.hook_event_name;
119 const sid = event?.session_id;
120 if (!name || !sid || env.G1T_RECORD === "0") return null;
121 const cwd = event.cwd || process.cwd();
122 const job = { cwd, sid, steps: [] };
123 switch (name) {
124 case "SessionStart":
125 job.start = true;
126 return job;
127 case "UserPromptSubmit":
128 if (env.G1T_RECORD_PROMPTS === "0" || typeof event.prompt !== "string" || !event.prompt.trim()) return null;
129 job.steps.push({ args: ["session", "log", "--kind", "prompt", "--", clip(event.prompt, PROMPT_LIMIT)] });
130 return job;
131 case "PostToolUse": {
132 if (!event.tool_name) return null;
133 const input = event.tool_input ?? {};
134 job.steps.push({ args: ["session", "log", "--kind", "tool_call", "--", summarizeTool(event.tool_name, input, cwd)] });
135 if (event.tool_name === "Bash") {
136 const issue = issueFromCommand(input.command);
137 if (issue) job.issue = issue;
138 if (isGitPush(input.command)) job.steps.push({ headCommit: true });
139 }
140 return job;
141 }
142 case "Stop":
143 job.steps.push({ args: ["session", "heartbeat"] });
144 return job;
145 case "SessionEnd":
146 job.steps.push({ args: ["session", "end", "--reason", String(event.reason || "other")] });
147 job.end = true;
148 return job;
149 default:
150 return null;
151 }
152}
153
154// --- the machine -------------------------------------------------------
155
156function git(cwd, args) {
157 const out = spawnSync("git", args, { cwd, encoding: "utf8", windowsHide: true, timeout: 3000 });
158 return out.status === 0 ? out.stdout.trim() : null;
159}
160
161/** The g1t CLI on PATH, without running it. G1T_BIN names another. */
162export function findCli(env = process.env) {
163 if (env.G1T_BIN) return existsSync(env.G1T_BIN) ? env.G1T_BIN : null;
164 const names = process.platform === "win32" ? ["g1t.exe", "g1t"] : ["g1t"];
165 for (const dir of (env.PATH ?? env.Path ?? "").split(delimiter)) {
166 if (!dir) continue;
167 for (const name of names) {
168 const candidate = join(dir.replace(/^"|"$/g, ""), name);
169 try {
170 if (statSync(candidate).isFile()) return candidate;
171 } catch {}
172 }
173 }
174 return null;
175}
176
177/** Runs the CLI. A .js or .mjs G1T_BIN (the tests' stand-in) runs under Node. */
178function cli(bin, args, options) {
179 const script = /\.m?js$/.test(bin);
180 return spawnSync(script ? process.execPath : bin, script ? [bin, ...args] : args, {
181 encoding: "utf8",
182 windowsHide: true,
183 ...options,
184 });
185}
186
187/** Where the plugin keeps its state: Claude Code's data folder for it. */
188export function dataDir(env = process.env) {
189 return env.CLAUDE_PLUGIN_DATA || join(homedir(), ".g1t", "claude-code");
190}
191
192function readJson(path, fallback) {
193 try {
194 return JSON.parse(readFileSync(path, "utf8"));
195 } catch {
196 return fallback;
197 }
198}
199
200function write(path, text) {
201 try {
202 mkdirSync(dirname(path), { recursive: true });
203 writeFileSync(path, text);
204 } catch {}
205}
206
207const readText = (path) => {
208 try {
209 return readFileSync(path, "utf8").trim() || null;
210 } catch {
211 return null;
212 }
213};
214
215/** This session's files: its marker (it is in a g1t working copy), its g1t session id and its issue. */
216export function sessionFiles(sid, env = process.env) {
217 const dir = join(dataDir(env), "sessions");
218 const safe = String(sid).replace(/[^A-Za-z0-9_-]/g, "_");
219 return { dir, marker: join(dir, `${safe}.json`), id: join(dir, `${safe}.id`), issue: join(dir, `${safe}.issue`) };
220}
221
222/** Forgets sessions untouched for a week (ones that never saw SessionEnd). */
223function prune(dir) {
224 try {
225 const cutoff = Date.now() - 7 * 24 * 3600 * 1000;
226 for (const name of readdirSync(dir)) {
227 const path = join(dir, name);
228 if (statSync(path).mtimeMs < cutoff) rmSync(path, { force: true });
229 }
230 } catch {}
231}
232
233// --- saying so once ----------------------------------------------------
234
235const MESSAGES = {
236 missing:
237 "g1t: this session is not being recorded on g1t, because the g1t CLI is not installed. Install it (https://docs.g1t.sh/guides/cli/) and run `g1t auth login`. This is said once.",
238 signed_out:
239 "g1t: this session is not being recorded on g1t, because the g1t CLI is not signed in. Run `g1t auth login`. This is said once.",
240};
241
242/** Remembers a problem (missing, signed_out), or that there is none (null). */
243export function setProblem(env, problem) {
244 const path = join(dataDir(env), "notice.json");
245 const notice = readJson(path, {});
246 if ((notice.problem ?? null) === problem) return;
247 write(path, JSON.stringify({ problem, shown: false }));
248}
249
250/** The message for a problem not shown yet, marking it shown. */
251export function takeNotice(env) {
252 const path = join(dataDir(env), "notice.json");
253 const notice = readJson(path, {});
254 if (!notice.problem || notice.shown || !MESSAGES[notice.problem]) return null;
255 write(path, JSON.stringify({ ...notice, shown: true }));
256 return MESSAGES[notice.problem];
257}
258
259/** Whether a working copy's remotes are on g1t (g1t.sh, or G1T_HOST's host). */
260export function isG1tRemote(urls, env = process.env) {
261 if (env.G1T_REPO) return true;
262 const hosts = ["g1t.sh", env.G1T_HOST].filter(Boolean).map((h) => h.toLowerCase());
263 return urls.some((url) => hosts.some((host) => url.toLowerCase().includes(host)));
264}
265
266// --- the background half -----------------------------------------------
267
268/** Hands a job to a detached copy of this script and returns at once. */
269function background(job, env = process.env) {
270 const payload = Buffer.from(JSON.stringify(job)).toString("base64url");
271 try {
272 const child = spawn(process.execPath, [SELF, "--worker", payload], {
273 cwd: job.cwd,
274 detached: true,
275 stdio: "ignore",
276 windowsHide: true,
277 env,
278 });
279 child.on("error", () => {});
280 child.unref();
281 } catch {}
282}
283
284/** Starts the session if it needs to, then runs the job's steps in order. */
285export function work(job, env = process.env) {
286 const bin = findCli(env);
287 if (!bin) return setProblem(env, "missing");
288 const files = sessionFiles(job.sid, env);
289 if (job.issue) write(files.issue, String(job.issue));
290 const opts = { cwd: job.cwd, timeout: 20000, env: { ...env } };
291 let id = readText(files.id);
292 if (job.start || !id) {
293 const issue = job.issue ?? readText(files.issue);
294 const args = ["session", "start", "--harness", "claude_code", "--harness-session-id", job.sid];
295 if (issue) args.push("--issue", String(issue));
296 args.push("--json");
297 const out = cli(bin, args, opts);
298 if (out.status === EXIT_AUTH) return setProblem(env, "signed_out");
299 if (out.status !== 0) return;
300 setProblem(env, null);
301 try {
302 id = JSON.parse(out.stdout).id;
303 } catch {
304 id = /\bses_[A-Za-z0-9]+/.exec(out.stdout ?? "")?.[0] ?? null;
305 }
306 if (!id) return;
307 write(files.id, id);
308 }
309 opts.env.G1T_SESSION = id;
310 for (const step of job.steps) {
311 if (step.args) {
312 const out = cli(bin, step.args, opts);
313 if (out.status === EXIT_AUTH) return setProblem(env, "signed_out");
314 } else if (step.headCommit) {
315 // Only once the push reached the remote: HEAD is on a remote branch.
316 const head = git(job.cwd, ["rev-parse", "HEAD"]);
317 const remote = git(job.cwd, ["branch", "-r", "--contains", "HEAD"]);
318 if (head && remote) cli(bin, ["api", "update_session", "-f", `id=${id}`, "-f", `head_commit=${head}`, "--silent"], opts);
319 }
320 }
321 if (job.end) for (const path of [files.marker, files.id, files.issue]) rmSync(path, { force: true });
322}
323
324// --- the hooks ---------------------------------------------------------
325
326/**
327 * SessionStart: marks the session as one in a g1t working copy, and
328 * returns what to print: context for Claude, and a notice for you, once.
329 * Null outside a g1t working copy, where nothing is recorded.
330 */
331export function sessionStart(event, env = process.env, bin = findCli(env)) {
332 const cwd = event.cwd || process.cwd();
333 const gitDir = git(cwd, ["rev-parse", "--absolute-git-dir"]);
334 if (!gitDir) return null;
335 // The branch, from HEAD itself: also on a branch with no commits yet.
336 const branch = /^ref: refs\/heads\/(.+)$/m.exec(readText(join(gitDir, "HEAD")) ?? "")?.[1] ?? null;
337 const urls = (git(cwd, ["config", "--get-regexp", "^remote\\..*\\.url$"]) ?? "").split(/\r?\n/);
338 if (!isG1tRemote(urls, env)) return null;
339
340 const files = sessionFiles(event.session_id, env);
341 prune(files.dir);
342 write(files.marker, JSON.stringify({ cwd, git_dir: gitDir }));
343
344 const output = {};
345 if (!bin) setProblem(env, "missing");
346 const notice = takeNotice(env);
347 if (notice) output.systemMessage = notice;
348 if (!bin) return output;
349
350 if (env.CLAUDE_ENV_FILE) {
351 // Claude's own `g1t` commands then act in this session, not in another
352 // one in the same working copy. Read as each command runs, since the
353 // session's id arrives after this hook has returned.
354 const p = files.id.replaceAll("\\", "/");
355 try {
356 writeFileSync(env.CLAUDE_ENV_FILE, `[ -f "${p}" ] && export G1T_SESSION="$(cat "${p}")"\n`, { flag: "a" });
357 } catch {}
358 }
359
360 const remembered = Number(readText(files.issue)) || null;
361 const issue = remembered ?? issueFromBranch(branch);
362 let context;
363 if (issue) {
364 const where = remembered ? "this session's claim" : `the branch ${branch}`;
365 const out = cli(bin, ["work", "context", String(issue)], { cwd, timeout: CONTEXT_WAIT_MS, env });
366 if (out.status === 0 && out.stdout.trim()) {
367 context = `g1t: this session is working on issue #${issue} (from ${where}). Its work context on g1t:\n\n${clip(out.stdout.trim(), CONTEXT_LIMIT)}\n\nIf it is not claimed by this session yet, claim it with \`g1t work claim ${issue}\` before you start.`;
368 } else if (out.status === EXIT_AUTH) {
369 setProblem(env, "signed_out");
370 const now = takeNotice(env);
371 if (now) output.systemMessage = now;
372 } else {
373 context = `g1t: ${where} points at issue #${issue}. Load what is known about it with \`g1t work context ${issue}\`, and claim it with \`g1t work claim ${issue}\` before you start.`;
374 }
375 } else {
376 context =
377 "g1t: this is a g1t repository. Before starting on an issue, load it with `g1t work context N` and claim it with `g1t work claim N`, so the people and agents also working here know.";
378 }
379 if (context) output.hookSpecificOutput = { hookEventName: "SessionStart", additionalContext: context };
380 return output;
381}
382
383/** Runs one hook: what it prints, and the job it hands to the background. */
384export function hook(event, env = process.env) {
385 const job = plan(event, env);
386 if (!job) return { output: null, job: null };
387 if (event.hook_event_name === "SessionStart") {
388 const bin = findCli(env);
389 const output = sessionStart(event, env, bin);
390 if (!output) return { output: null, job: null };
391 return { output: Object.keys(output).length ? output : null, job: bin ? job : null };
392 }
393 // Only sessions SessionStart found in a g1t working copy are recorded.
394 if (!existsSync(sessionFiles(event.session_id, env).marker) || !findCli(env)) return { output: null, job: null };
395 const notice = event.hook_event_name === "UserPromptSubmit" ? takeNotice(env) : null;
396 return { output: notice ? { systemMessage: notice } : null, job };
397}
398
399async function main() {
400 if (process.argv[2] === "--worker") {
401 work(JSON.parse(Buffer.from(process.argv[3], "base64url").toString("utf8")));
402 return;
403 }
404 let raw = "";
405 for await (const chunk of process.stdin) raw += chunk;
406 const { output, job } = hook(JSON.parse(raw || "{}"));
407 if (output) process.stdout.write(JSON.stringify(output));
408 if (job) background(job);
409}
410
411if (process.argv[1] && resolve(process.argv[1]) === SELF) {
412 main()
413 .catch(() => {})
414 .finally(() => process.exit(0));
415}

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