From 8c933edcd0170aa15d218355da8f050a08b4feac Mon Sep 17 00:00:00 2001 From: Dominic Nguyen Date: Tue, 29 Sep 2026 14:32:08 -0700 Subject: [PATCH 1/3] 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/3] 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}. From 6fe6995f437bcf66ab6cd0e45d5fff8aa789653a Mon Sep 17 00:00:00 2001 From: Dominic Nguyen Date: Tue, 29 Sep 2026 14:49:56 -0700 Subject: [PATCH 3/3] git-story: add pre-commit hook end-to-end test --- ts/packages/git-story/jest.config.cjs | 4 ++ ts/packages/git-story/package.json | 5 +++ ts/packages/git-story/test/hooks.spec.ts | 56 ++++++++++++++++++++++++ ts/packages/git-story/test/tsconfig.json | 14 ++++++ ts/packages/git-story/tsconfig.json | 3 ++ ts/pnpm-lock.yaml | 6 +++ 6 files changed, 88 insertions(+) create mode 100644 ts/packages/git-story/jest.config.cjs create mode 100644 ts/packages/git-story/test/hooks.spec.ts create mode 100644 ts/packages/git-story/test/tsconfig.json diff --git a/ts/packages/git-story/jest.config.cjs b/ts/packages/git-story/jest.config.cjs new file mode 100644 index 0000000000..25456e93bb --- /dev/null +++ b/ts/packages/git-story/jest.config.cjs @@ -0,0 +1,4 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +module.exports = require("../../jest.config.js"); diff --git a/ts/packages/git-story/package.json b/ts/packages/git-story/package.json index cdd1919a60..991e05419f 100644 --- a/ts/packages/git-story/package.json +++ b/ts/packages/git-story/package.json @@ -18,15 +18,20 @@ "scripts": { "build": "npm run tsc", "clean": "rimraf --glob dist *.tsbuildinfo *.done.build.log", + "jest-esm": "node --no-warnings --experimental-vm-modules ./node_modules/jest/bin/jest.js", "prettier": "prettier --check . --ignore-path ../../.prettierignore", "prettier:fix": "prettier --write . --ignore-path ../../.prettierignore", + "test": "npm run test:local", + "test:local": "pnpm run jest-esm --testPathPattern=\".*[.]spec[.]js\"", "tsc": "tsc -b" }, "dependencies": { "commander": "^12.1.0" }, "devDependencies": { + "@types/jest": "^29.5.7", "@types/node": "^20.10.0", + "jest": "^29.7.0", "rimraf": "^6.0.1", "typescript": "~5.4.5" } diff --git a/ts/packages/git-story/test/hooks.spec.ts b/ts/packages/git-story/test/hooks.spec.ts new file mode 100644 index 0000000000..cfe900aa7d --- /dev/null +++ b/ts/packages/git-story/test/hooks.spec.ts @@ -0,0 +1,56 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const CLI = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../cli.js", +); + +// End to end: `init` writes the hook, then a real `git commit` runs it +// through git's own sh (Git Bash on Windows). An extensionless sh shim puts +// `git-story` on PATH, same as `npm link` does on every OS. +test("pre-commit hook forwards args and stdin to git-story", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "git-story-")); + const bin = path.join(dir, "bin"); + const repo = path.join(dir, "repo"); + fs.mkdirSync(bin); + const cli = CLI.replace(/\\/g, "/"); + fs.writeFileSync( + path.join(bin, "git-story"), + `#!/bin/sh\nexec node "${cli}" "$@"\n`, + { mode: 0o755 }, + ); + const env = { + ...process.env, + PATH: `${bin}${path.delimiter}${process.env.PATH}`, + GIT_CONFIG_GLOBAL: path.join(dir, "gitconfig"), + GIT_CONFIG_NOSYSTEM: "1", + }; + // Returns stdout + stderr; git sends hook output to stderr. + const run = (cmd: string, args: string[], input = "") => { + const r = spawnSync(cmd, args, { + cwd: repo, + env, + input, + encoding: "utf8", + }); + expect(r.status).toBe(0); + return r.stdout + r.stderr; + }; + fs.mkdirSync(repo); + run("git", ["init", "-q"]); + run("node", [CLI, "init"]); + const commit = ["-c", "user.name=t", "-c", "user.email=t@t"]; + const out = run("git", [...commit, "commit", "--allow-empty", "-m", "x"]); + expect(out).toContain('git-story pre-commit: args=[] stdin=""'); + + const hook = path.join(repo, ".git/hooks/pre-commit"); + const direct = run("sh", [hook, "a"], "piped\n"); + expect(direct).toContain('args=["a"] stdin="piped\\n"'); +}); diff --git a/ts/packages/git-story/test/tsconfig.json b/ts/packages/git-story/test/tsconfig.json new file mode 100644 index 0000000000..0e71ed8c2d --- /dev/null +++ b/ts/packages/git-story/test/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "rootDir": ".", + "outDir": "../dist/test", + "types": ["node", "jest"] + }, + "include": ["./**/*"], + "ts-node": { + "esm": true + }, + "references": [{ "path": "../src" }] +} diff --git a/ts/packages/git-story/tsconfig.json b/ts/packages/git-story/tsconfig.json index 2db9c8ce31..3a5bec7aab 100644 --- a/ts/packages/git-story/tsconfig.json +++ b/ts/packages/git-story/tsconfig.json @@ -7,6 +7,9 @@ "references": [ { "path": "./src" + }, + { + "path": "./test" } ], "ts-node": { diff --git a/ts/pnpm-lock.yaml b/ts/pnpm-lock.yaml index a05e8d2519..2e6d65b03e 100644 --- a/ts/pnpm-lock.yaml +++ b/ts/pnpm-lock.yaml @@ -5295,9 +5295,15 @@ importers: specifier: ^12.1.0 version: 12.1.0 devDependencies: + '@types/jest': + specifier: ^29.5.7 + version: 29.5.14 '@types/node': specifier: ^20.10.0 version: 20.19.43 + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@20.19.43)(ts-node@10.9.2(@types/node@20.19.43)(typescript@5.4.5)) rimraf: specifier: ^6.0.1 version: 6.0.1