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.

Commit

The g1t plugin is 0.2.8: sessions in a shared working copy no longer claim each other's files, a session started from VS Code is titled with what was typed, and files are named from the repository's root. Overlaps: every heartbeat (each turn's Stop), updates and acknowledge let the CLI report every uncommitted file in the working copy as the session's own, so several Claude Code sessions in one checkout each claimed all of them and were told they overlapped (three did on flagon-io/g1t today, editing unrelated files). The plugin now runs the CLI with G1T_TOUCH=0, and gives Claude's own shell the same through Claude Code's env file; it says what the session wrote itself: what Claude edits or writes, as before, and what a shell command changes while it runs (sed, a script, a formatter), from the uncommitted files before it (PreToolUse) and after it (PostToolUse) (dirtyFiles, shellChanges), never what was uncommitted already. Titles: VS Code puts <ide_opened_file>…</ide_opened_file> ahead of the prompt, which read as a turn Claude Code starts itself, so the session took its next prompt as its title; editor context blocks (<ide_*>, <system-reminder>) are skipped first. Paths: a file was named from Claude Code's current directory, so after cd apps/web, apps/web/app/x.ts was recorded as app/x.ts; it is named from the working copy's top (git rev-parse --show-toplevel, asked once per directory). Checked: node --test plugins/claude-code/test/hooks.test.mjs (58; new: a shell command's writes and not what was uncommitted before, no CLI call left to guess (checked after every working-copy test), IDE-prefixed titles, paths from the root); scripts/plugins-release.test.mjs (8).

G1t-Session:
ses_01m4zzpc96ect8d41mffgr1a45
syntaqxcommitted Parent577bc43Browse files
6 files+137−100/6 viewed
+1−1
77 "name": "g1t",
88 "source": "./plugins/claude-code",
99 "description": "Work on g1t issues from Claude Code: records your sessions on g1t, loads an issue's work context, claims and hands off work, and connects g1t's MCP server.",
10− "version": "0.2.7",
10+ "version": "0.2.8",
1111 "category": "development"
1212 }
1313 ]
+1−1
9696 | You send a prompt that says what it is for, while the session has no title | `g1t session heartbeat --json`, then, if the session has no title, `g1t session attach --title INTENT` | Its title: what the prompt is for, as its first line or sentence without pasted text, image placeholders (`[Image #1]`), code blocks or markdown, up to 80 characters, cut on a word with `…`. A slash command, or a prompt that is only pasted text or images, gives no title, and the next prompt titles the session. A title the session already has (one Claude, you or an earlier prompt set) is never replaced. Not sent with prompts kept back. |
9797 | Claude uses a tool | `g1t session log --kind tool_call TEXT` | The tool's name and one line on what it was called with: the command, file, pattern or address, up to 200 characters, with anything that looks like a token or password replaced by `…`. |
9898 | Claude uses a skill, or you run one (`/g1t:work-on-issue 42`) | `g1t session log --kind skill NAME` | The skill's name, nothing more, once in a turn. A plugin's skill or command you type as `/plugin:name` is recorded even with prompts kept back. |
99−| Claude writes a file, makes a branch or a commit, or claims an issue | `g1t session touch --file PATH` (or `--branch`, `--commit`, `--issue`) | What it touched: the path from the repository's root, the branch, the commit, the issue. g1t ties the session to the pull requests and issues these lead to, and tells two sessions that touch the same file. |
99+| Claude writes a file, makes a branch or a commit, or claims an issue | `g1t session touch --file PATH` (or `--branch`, `--commit`, `--issue`) | What it touched: the path from the repository's root, the branch, the commit, the issue. A file is one Claude edited or wrote, or one a shell command changed while it ran (the uncommitted files before and after it), never what was uncommitted already, so sessions sharing a working copy do not claim each other's work. g1t ties the session to the pull requests and issues these lead to, and tells two sessions that touch the same file. |
100100 | A `git push` reaches the remote | `g1t api update_session -f id=ID -f head_commit=SHA` and `g1t session touch --commit SHA --branch BRANCH` | The commit you pushed and its branch, so the session's entries line up with the code and it is tied to the branch's pull request. |
101101 | Claude starts a subagent | `g1t session start --parent SESSION --agent-type TYPE --task TASK --prompt PROMPT` | A session of its own for the subagent, under this one, with its kind and the task it was given (its prompt's first 4,000 characters). See [Subagents](#subagents). |
102102 | A subagent finishes | `g1t session end --result MESSAGE` | Its final message, up to 8,000 characters: what it reported to Claude. |
+1−1
11 {
22 "name": "g1t",
3− "version": "0.2.7",
3+ "version": "0.2.8",
44 "description": "Work on g1t issues from Claude Code: records your sessions on g1t, loads an issue's work context, claims and hands off work, and connects g1t's MCP server.",
55 "author": { "name": "Flagon, Inc.", "url": "https://g1t.sh" },
66 "homepage": "https://docs.g1t.sh/guides/claude-code-plugin/",
+89−5
119119 }
120120
121121 /** A path as the repository names it: relative to its root, with forward slashes; null outside it. */
122−export function repoPath(path, cwd) {
122+export function repoPath(path, cwd, root = repoRoot(cwd)) {
123123 if (typeof path !== "string" || !path) return null;
124− if (!isAbsolute(path)) return path.replaceAll("\\", "/").replace(/^\.\//, "");
125− if (!cwd) return null;
126− const rel = relative(cwd, path);
124+ // From the working copy's top, wherever the shell has moved to since
125+ // (`cd apps/web` leaves Claude Code's cwd there); from cwd outside one.
126+ const base = root || cwd;
127+ if (!isAbsolute(path)) {
128+ if (!cwd || !root) return path.replaceAll("\\", "/").replace(/^\.\//, "");
129+ path = resolve(cwd, path);
130+ }
131+ if (!base) return null;
132+ const rel = relative(base, path);
127133 if (!rel || rel.startsWith("..") || isAbsolute(rel)) return null;
128134 return rel.replaceAll("\\", "/");
129135 }
218224 /** A turn the harness starts itself, as a tag: `<command-name>`, `<task-notification>`, one with attributes. */
219225 const HARNESS_TAG = /^\s*<[a-z][\w-]*(?:\s[^>]*)?>/i;
220226
227+/** What an editor adds ahead of a prompt, a whole block at a time: `<ide_opened_file>…</ide_opened_file>`. */
228+const IDE_CONTEXT = /^\s*<(ide_[a-z_]+|system-reminder)\b[^>]*>[\s\S]*?<\/\1>/i;
229+
221230 /**
222231 * What a prompt is for, as a session's title: its first line or sentence,
223232 * without code blocks or markdown, whitespace collapsed, clipped at
234243 text = text
235244 .replace(/<pasted_content\b[^>]*>[\s\S]*?(?:<\/pasted_content[^>]*>|$)/gi, "\n")
236245 .replace(/\[Image(?: #\d+)?(?::[^\]\n]*)?\]/g, " ");
246+ // Context an editor puts before what was typed (VS Code's
247+ // <ide_opened_file>, <ide_selection>) is not a turn the harness started.
248+ while (IDE_CONTEXT.test(text)) text = text.replace(IDE_CONTEXT, "");
237249 if (/^\s*\//.test(text) || HARNESS_TAG.test(text)) return null;
238250 text = text
239251 .replace(/```[\s\S]*?(```|$)/g, "\n")
363375 }
364376 }
365377 if (SHELL_TOOLS.has(tool)) {
378+ // What the command wrote (sed, a script, a formatter): the files it
379+ // changed, against what PreToolUse saw before it ran.
380+ if (event.session_id && event.tool_use_id) {
381+ const kept = sessionFiles(event.session_id, env).shell(event.tool_use_id);
382+ const before = readJson(kept, null);
383+ rmSync(kept, { force: true });
384+ for (const file of shellChanges(before, before ? dirtyFiles(cwd) : null).slice(0, 100)) touch.push(`--file=${file}`);
385+ }
366386 const issue = issueFromCommand(input.command);
367387 if (issue) {
368388 job.issue = issue;
406426 return out.status === 0 ? out.stdout.trim() : null;
407427 }
408428
429+/** The most uncommitted files compared around one shell command. */
430+const MAX_DIRTY = 2000;
431+
432+/**
433+ * The working copy's uncommitted files (modified, added, deleted, renamed
434+ * by their new name, untracked; never ignored), each with what it is now:
435+ * its modified time and size, or "gone". Paths from the top of the working
436+ * copy. Null outside one.
437+ */
438+export function dirtyFiles(cwd) {
439+ const root = repoRoot(cwd);
440+ if (!root) return null;
441+ const out = spawnSync("git", ["status", "--porcelain=v1", "-z", "--untracked-files=all"], { cwd: root, encoding: "utf8", windowsHide: true, timeout: 3000 });
442+ if (out.status !== 0) return null;
443+ const files = {};
444+ const records = out.stdout.split("\0");
445+ let count = 0;
446+ for (let i = 0; i < records.length && count < MAX_DIRTY; i++) {
447+ const record = records[i];
448+ if (record.length < 4) continue;
449+ // A rename or copy names its new path, then its old one.
450+ if (record[0] === "R" || record[0] === "C") i++;
451+ const path = record.slice(3);
452+ try {
453+ const stat = statSync(join(root, path));
454+ files[path] = `${stat.mtimeMs}:${stat.size}`;
455+ } catch {
456+ files[path] = "gone";
457+ }
458+ count++;
459+ }
460+ return files;
461+}
462+
463+/**
464+ * What a shell command wrote, from the uncommitted files before it and
465+ * after: each one new since, or changed since. One it committed or put
466+ * back is not a write. Others writing in the same working copy during the
467+ * command are counted too, but only then, never what they wrote before.
468+ */
469+export function shellChanges(before, after) {
470+ if (!before || !after) return [];
471+ return Object.keys(after).filter((path) => before[path] !== after[path]);
472+}
473+
474+const roots = new Map();
475+/** The top of the working copy `cwd` is in, asked once per directory; null outside one. */
476+function repoRoot(cwd) {
477+ if (!cwd) return null;
478+ if (!roots.has(cwd)) roots.set(cwd, git(cwd, ["rev-parse", "--show-toplevel"]) || null);
479+ return roots.get(cwd);
480+}
481+
409482 /** The g1t CLI on PATH, without running it. G1T_BIN names another. */
410483 export function findCli(env = process.env) {
411484 if (env.G1T_BIN) return existsSync(env.G1T_BIN) ? env.G1T_BIN : null;
429502 encoding: "utf8",
430503 windowsHide: true,
431504 ...options,
505+ // The hooks say what this session wrote (see shellChanges); the CLI's
506+ // own guess, every uncommitted file in the working copy, would claim
507+ // what other sessions sharing it wrote too.
508+ env: { ...(options?.env ?? process.env), G1T_TOUCH: "0" },
432509 });
433510 }
434511
482559 turn: join(dir, `${safe}.turn`),
483560 titled: join(dir, `${safe}.titled`),
484561 task: (key) => join(dir, `${safe}.task.${safeName(key)}.json`),
562+ shell: (toolUseId) => join(dir, `${safe}.shell.${safeName(toolUseId)}.json`),
485563 unrecorded: join(dir, `${safe}.unrecorded.json`),
486564 agent: (agentId) => join(dir, `${safe}.agent.${safeName(String(agentId).replace(/^agent-/, ""))}.id`),
487565 };
810888 // command, so a command before this (rare: it is the first second)
811889 // uses the working copy's remembered session.
812890 try {
813− writeFileSync(job.envFile, `export G1T_SESSION=${id}\n`, { flag: "a" });
891+ // G1T_TOUCH=0: they say what they wrote through the hooks, as cli() does.
892+ writeFileSync(job.envFile, `export G1T_SESSION=${id}\nexport G1T_TOUCH=0\n`, { flag: "a" });
814893 } catch {}
815894 }
816895 }
10561135 const name = event?.hook_event_name;
10571136 if (name === "PreToolUse") {
10581137 if (!event.session_id || !existsSync(sessionFiles(event.session_id, env).marker)) return { output: null, job: null };
1138+ // The uncommitted files as a shell command finds them, so PostToolUse can say which it wrote.
1139+ if (SHELL_TOOLS.has(event.tool_name) && event.tool_use_id) {
1140+ const before = dirtyFiles(event.cwd || process.cwd());
1141+ if (before) write(sessionFiles(event.session_id, env).shell(event.tool_use_id), JSON.stringify(before));
1142+ }
10591143 const output = preToolUse(event, env);
10601144 const context = updateContext(event, env, { throttle: true });
10611145 if (!context) return { output, job: null };
+3−1
7575
7676 function main() {
7777 const args = process.argv.slice(2);
78− appendFileSync(process.env.FAKE_G1T_LOG, `${JSON.stringify({ args, session: process.env.G1T_SESSION ?? null })}\n`);
78+ // `touch` only when the caller left the CLI to guess touched files from the whole working copy.
79+ const guesses = process.env.G1T_TOUCH === "0" ? {} : { touch: process.env.G1T_TOUCH ?? "unset" };
80+ appendFileSync(process.env.FAKE_G1T_LOG, `${JSON.stringify({ args, session: process.env.G1T_SESSION ?? null, ...guesses })}\n`);
7981 const refused = refusal(args);
8082 if (refused) {
8183 process.stderr.write(`g1t: ${refused} See --help.\n`);
+42−1
1919 hasTitle,
2020 hook,
2121 intentTitle,
22+ repoPath,
2223 MIN_CLI,
2324 TITLE_LIMIT,
2425 oldNode,
8889 assert.equal(intentTitle("[Image #1] the PR link is missing on Home"), "The PR link is missing on Home");
8990 assert.equal(intentTitle("[Image: source: C:\\tmp\\1.png]\nmake it render"), "Make it render");
9091 assert.equal(intentTitle('<pasted_content id="1">only a paste'), null);
92+ // What VS Code puts ahead of what was typed is not a turn Claude Code started.
93+ assert.equal(
94+ intentTitle("<ide_opened_file>The user opened the file c:\\a\\Dockerfile in the IDE.</ide_opened_file>\nWhether it's the desktop app or the page, it crashes"),
95+ "Whether it's the desktop app or the page, it crashes",
96+ );
97+ assert.equal(intentTitle("<ide_selection>const a = 1;</ide_selection>\n<ide_opened_file>a.ts</ide_opened_file>\nrename this"), "Rename this");
98+ assert.equal(intentTitle("<ide_opened_file>a.ts</ide_opened_file>\n/clear"), null);
9199 // Not intents: slash commands, turns Claude Code starts, nothing to say.
92100 for (const prompt of ["/clear", "/compact keep the plan", "/g1t:status 4", "<command-name>/clear</command-name>", '<local-command-stdout lang="en">ok</local-command-stdout>', "```\ncode only\n```", "?", "", null]) {
93101 assert.equal(intentTitle(prompt), null, String(prompt));
288296 test("say what a tool was called with, in one short line", () => {
289297 assert.equal(summarizeTool("Edit", { file_path: "/work/repo/src/a.ts", old_string: "x", new_string: "y" }, "/work/repo"), "Edit: src/a.ts");
290298 assert.equal(summarizeTool("Grep", { pattern: "TODO", path: "src" }, "/w"), "Grep: TODO in src");
299+ // From the top of the working copy, wherever the shell has moved to.
300+ assert.equal(repoPath("/work/repo/apps/web/app/a.ts", "/work/repo/apps/web", "/work/repo"), "apps/web/app/a.ts");
301+ assert.equal(repoPath("app/a.ts", "/work/repo/apps/web", "/work/repo"), "apps/web/app/a.ts");
302+ assert.equal(repoPath("/elsewhere/a.ts", "/work/repo/apps/web", "/work/repo"), null);
303+ assert.equal(repoPath("/work/repo/src/a.ts", "/work/repo", null), "src/a.ts");
291304 assert.equal(summarizeTool("WebFetch", { url: "https://example.com", prompt: "p" }), "WebFetch: https://example.com");
292305 assert.equal(summarizeTool("Bash", { command: "echo one\necho two" }), "Bash: echo one …");
293306 assert.equal(summarizeTool("mcp__g1t__issue", { action: "get", number: 4 }), "mcp__g1t__issue: get");
360373 afterEach(() => {
361374 // Every call the hooks made is one the real CLI takes (the stand-in refuses the rest).
362375 const refused = allCalls().filter((c) => refusal(c.args));
376+ // And none let the CLI claim every uncommitted file as this session's (the stand-in logs `touch` then).
377+ const guessing = allCalls().filter((c) => c.touch !== undefined);
363378 rmSync(dir, { recursive: true, force: true, maxRetries: 20, retryDelay: 100 });
364379 assert.deepEqual(refused, [], "the CLI refuses these calls");
380+ assert.deepEqual(guessing, [], "these calls let the CLI guess what was touched");
365381 });
366382
367383 test("SessionStart gives Claude the branch's issue's work context", () => {
386402 assert.equal(readFileSync(sessionFiles(base.session_id, env).id, "utf8"), "ses_fake01");
387403 });
388404
405+ test("a shell command's writes are this session's; what was uncommitted before it is not", () => {
406+ const { job } = hook({ ...base, cwd: dir, hook_event_name: "SessionStart" }, env);
407+ work(job, env);
408+ // Another session's edit, uncommitted in the same working copy.
409+ writeFileSync(join(dir, "theirs.md"), "theirs");
410+ const command = { ...base, cwd: dir, tool_name: "Bash", tool_use_id: "tu_1", tool_input: { command: "sed -i s/a/b/ mine.md" } };
411+ hook({ ...command, hook_event_name: "PreToolUse" }, env);
412+ writeFileSync(join(dir, "mine.md"), "mine");
413+ const after = hook({ ...command, hook_event_name: "PostToolUse" }, env).job;
414+ const touched = after.steps.filter((step) => step.args?.[1] === "touch").flatMap((step) => step.args.filter((arg) => arg.startsWith("--file=")));
415+ assert.ok(touched.includes("--file=mine.md"), String(touched));
416+ assert.ok(!touched.includes("--file=theirs.md"), String(touched));
417+ // Read once: a command run again under the same id finds nothing kept.
418+ assert.equal(existsSync(sessionFiles(base.session_id, env).shell("tu_1")), false);
419+ });
420+
421+ test("the CLI never guesses touched files from the whole working copy", () => {
422+ const { job } = hook({ ...base, cwd: dir, hook_event_name: "SessionStart" }, env);
423+ work(job, env);
424+ work(plan({ ...base, cwd: dir, hook_event_name: "Stop" }, env), env);
425+ const heartbeat = calls().find((call) => call.args[0] === "session" && call.args[1] === "heartbeat");
426+ assert.ok(heartbeat, "a heartbeat was sent");
427+ assert.equal(heartbeat.touch, undefined);
428+ });
429+
389430 test("background retrieval reaches the next tool without auto-acknowledging", () => {
390431 // A CLI that acknowledges: an older one is told to update instead (below).
391432 env.FAKE_G1T_VERSION = "0.3.3";
808849 assert.ok(!existsSync(envFile), "nothing is written before the session's id is known");
809850 work(job, env);
810851 // A plain assignment: Claude Code gives it to PowerShell commands too.
811− assert.equal(readFileSync(envFile, "utf8"), "export G1T_SESSION=ses_fake01\n");
852+ assert.equal(readFileSync(envFile, "utf8"), "export G1T_SESSION=ses_fake01\nexport G1T_TOUCH=0\n");
812853 });
813854
814855 test("on a Node older than 18 nothing is recorded, and that is said once", () => {

Comments

Sign in to comment.