From 8c933edcd0170aa15d218355da8f050a08b4feac Mon Sep 17 00:00:00 2001 From: Dominic Nguyen Date: Tue, 29 Sep 2026 14:32:08 -0700 Subject: [PATCH 1/2] git-story: register git pre-commit hook --- ts/packages/git-story/README.md | 9 +++++- ts/packages/git-story/src/commands/hooks.ts | 30 ++++++++++++++++++-- ts/packages/git-story/src/commands/init.ts | 31 +++++++++++++++++++++ 3 files changed, 67 insertions(+), 3 deletions(-) diff --git a/ts/packages/git-story/README.md b/ts/packages/git-story/README.md index eaa04e7b40..cd434daacf 100644 --- a/ts/packages/git-story/README.md +++ b/ts/packages/git-story/README.md @@ -10,14 +10,21 @@ Git runs any `git-` binary on `PATH` as `git `, so the $ cd ts/packages/git-story $ pnpm build $ npm link # puts git-story on PATH -$ git story init # registers Copilot CLI hooks for this repo +$ git story init # registers Copilot CLI and git hooks for this repo $ git story hooks copilot user-prompt-submitted Hello World +$ echo input | git story hooks git pre-commit a b +git-story pre-commit: args=["a","b"] stdin="input\n" ``` `init` writes the hooks to `.github/copilot/settings.local.json` and adds that file to `.git/info/exclude`, so it stays local to the clone. +`init` also writes a `pre-commit` script to the git hooks directory (honors +`core.hooksPath`). The script runs `exec git story hooks git pre-commit "$@"`, +so git's hook arguments and stdin reach the command unchanged. `init` does not +overwrite a hook that it did not write. + ## Trademarks This project may contain trademarks or logos for projects, products, or services. Authorized use of Microsoft diff --git a/ts/packages/git-story/src/commands/hooks.ts b/ts/packages/git-story/src/commands/hooks.ts index 29e769b7e6..3effa538c1 100644 --- a/ts/packages/git-story/src/commands/hooks.ts +++ b/ts/packages/git-story/src/commands/hooks.ts @@ -3,9 +3,19 @@ import { Command } from "commander"; -// `hooks`: agent hook handlers. Placeholder output until story capture exists. +// Reads all of stdin. Hooks get their payload here (Copilot JSON, or lines +// git pipes to hooks such as pre-push). Returns "" when stdin is a terminal. +async function readStdin(): Promise { + if (process.stdin.isTTY) return ""; + const chunks: Buffer[] = []; + for await (const chunk of process.stdin) chunks.push(chunk as Buffer); + return Buffer.concat(chunks).toString("utf8"); +} + +// `hooks`: agent and git hook handlers. Placeholder output until story +// capture exists. export const hooksCommand = new Command("hooks").description( - "Agent hook handlers", + "Agent and git hook handlers", ); hooksCommand @@ -16,3 +26,19 @@ hooksCommand .action(() => { process.stdout.write("Hello World\n"); }); + +// `hooks git [args...]`: called by the scripts `init` writes to the +// git hooks directory. Git's hook args and stdin are forwarded as-is. +// Example: `git story hooks git pre-commit` with empty stdin. +hooksCommand + .command("git") + .description("Git hook handlers") + .command("pre-commit") + .description("Handle the git pre-commit hook") + .argument("[args...]", "arguments git passed to the hook") + .action(async (args: string[]) => { + const input = await readStdin(); + process.stdout.write( + `git-story pre-commit: args=${JSON.stringify(args)} stdin=${JSON.stringify(input)}\n`, + ); + }); diff --git a/ts/packages/git-story/src/commands/init.ts b/ts/packages/git-story/src/commands/init.ts index 87ef4e9138..396c5177a1 100644 --- a/ts/packages/git-story/src/commands/init.ts +++ b/ts/packages/git-story/src/commands/init.ts @@ -11,6 +11,15 @@ import path from "node:path"; const COPILOT_SETTINGS = ".github/copilot/settings.local.json"; const PROMPT_HOOK = "git story hooks copilot user-prompt-submitted"; +// Git hooks to register. Each gets a shell script that forwards git's args +// and stdin to `git story hooks git `. `exec` hands the script's stdin +// to the command, so hooks that receive input (e.g. pre-push) keep it. +const GIT_HOOKS = ["pre-commit"]; +// Marks scripts written by init, so init never overwrites a user's own hook. +const GIT_HOOK_MARKER = "# git-story hook"; +const gitHookScript = (hook: string) => + `#!/bin/sh\n${GIT_HOOK_MARKER}\nexec git story hooks git ${hook} "$@"\n`; + // Recursively sorts object keys so the settings file has a stable order. // Array order is kept. Example: {b:1,a:{d:2,c:3}} -> {a:{c:3,d:2},b:1}. function sortKeys(value: unknown): unknown { @@ -78,4 +87,26 @@ export const initCommand = new Command("init") fs.appendFileSync(exclude, `${COPILOT_SETTINGS}\n`); } process.stdout.write(`Registered Copilot hooks in ${settingsPath}\n`); + + // `--git-path hooks/` honors `core.hooksPath` and worktrees. + for (const hook of GIT_HOOKS) { + const hookPath = path.resolve( + git("rev-parse", "--git-path", `hooks/${hook}`), + ); + if ( + fs.existsSync(hookPath) && + !fs.readFileSync(hookPath, "utf8").includes(GIT_HOOK_MARKER) + ) { + process.stderr.write( + `Skipped ${hookPath}: existing hook not owned by git-story\n`, + ); + process.exitCode = 1; + continue; + } + fs.mkdirSync(path.dirname(hookPath), { recursive: true }); + fs.writeFileSync(hookPath, gitHookScript(hook), { mode: 0o755 }); + process.stdout.write( + `Registered git ${hook} hook in ${hookPath}\n`, + ); + } }); From d11891f9da93547280704da972c9ff7cd67d2110 Mon Sep 17 00:00:00 2001 From: Dominic Nguyen Date: Tue, 29 Sep 2026 14:49:51 -0700 Subject: [PATCH 2/2] git-story: call git-story directly from pre-commit hook --- ts/packages/git-story/README.md | 2 +- ts/packages/git-story/src/commands/init.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/ts/packages/git-story/README.md b/ts/packages/git-story/README.md index cd434daacf..a1da0c5ead 100644 --- a/ts/packages/git-story/README.md +++ b/ts/packages/git-story/README.md @@ -21,7 +21,7 @@ git-story pre-commit: args=["a","b"] stdin="input\n" file to `.git/info/exclude`, so it stays local to the clone. `init` also writes a `pre-commit` script to the git hooks directory (honors -`core.hooksPath`). The script runs `exec git story hooks git pre-commit "$@"`, +`core.hooksPath`). The script runs `exec git-story hooks git pre-commit "$@"`, so git's hook arguments and stdin reach the command unchanged. `init` does not overwrite a hook that it did not write. diff --git a/ts/packages/git-story/src/commands/init.ts b/ts/packages/git-story/src/commands/init.ts index 396c5177a1..3c03003146 100644 --- a/ts/packages/git-story/src/commands/init.ts +++ b/ts/packages/git-story/src/commands/init.ts @@ -12,13 +12,13 @@ const COPILOT_SETTINGS = ".github/copilot/settings.local.json"; const PROMPT_HOOK = "git story hooks copilot user-prompt-submitted"; // Git hooks to register. Each gets a shell script that forwards git's args -// and stdin to `git story hooks git `. `exec` hands the script's stdin +// and stdin to `git-story hooks git `. `exec` hands the script's stdin // to the command, so hooks that receive input (e.g. pre-push) keep it. const GIT_HOOKS = ["pre-commit"]; // Marks scripts written by init, so init never overwrites a user's own hook. const GIT_HOOK_MARKER = "# git-story hook"; const gitHookScript = (hook: string) => - `#!/bin/sh\n${GIT_HOOK_MARKER}\nexec git story hooks git ${hook} "$@"\n`; + `#!/bin/sh\n${GIT_HOOK_MARKER}\nexec git-story hooks git ${hook} "$@"\n`; // Recursively sorts object keys so the settings file has a stable order. // Array order is kept. Example: {b:1,a:{d:2,c:3}} -> {a:{c:3,d:2},b:1}.