diff --git a/ts/packages/git-commit-story/README.AUTOGEN.md b/ts/packages/git-commit-story/README.AUTOGEN.md index 7cdd26900e..1a21d48c08 100644 --- a/ts/packages/git-commit-story/README.AUTOGEN.md +++ b/ts/packages/git-commit-story/README.AUTOGEN.md @@ -3,7 +3,7 @@ - + # git-commit-story — AI-generated documentation @@ -30,10 +30,10 @@ External: `commander` ### Files of interest -`./src/cli.ts`, `./src/tsconfig.json`. +`./src/cli.ts`, `./src/commands/hooks.ts`, `./src/commands/init.ts`, …and 1 more under `./src/`. --- -_Auto-generated against commit `464df9d8299adf53a6c3e3afd53688fbcde439f2` on `2026-09-28T20:24:26.282Z` by `docs-generate.yml`. Links validated at that commit; the working tree may have drifted by up to 24h. Re-run `pnpm --filter git-commit-story docs:verify-links` to spot-check._ +_Auto-generated against commit `031e5113b09df01a0f6392d858d93badc14699c7` on `2026-09-29T06:06:07.744Z` by `docs-generate.yml`. Links validated at that commit; the working tree may have drifted by up to 24h. Re-run `pnpm --filter git-commit-story docs:verify-links` to spot-check._ diff --git a/ts/packages/git-commit-story/README.md b/ts/packages/git-commit-story/README.md index 550b2f7805..1902c012e4 100644 --- a/ts/packages/git-commit-story/README.md +++ b/ts/packages/git-commit-story/README.md @@ -10,10 +10,14 @@ Git runs any `git-` binary on `PATH` as `git `, so the $ cd ts/packages/git-commit-story $ pnpm build $ npm link # puts git-commit-story on PATH -$ git commit-story hello -Hello from git-commit-story +$ git commit-story init # registers Copilot CLI hooks for this repo +$ git commit-story hooks copilot user-prompt-submitted +Hello World ``` +`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. + ## Trademarks This project may contain trademarks or logos for projects, products, or services. Authorized use of Microsoft diff --git a/ts/packages/git-commit-story/src/cli.ts b/ts/packages/git-commit-story/src/cli.ts index 611bd41f15..b925035c73 100644 --- a/ts/packages/git-commit-story/src/cli.ts +++ b/ts/packages/git-commit-story/src/cli.ts @@ -5,16 +5,13 @@ // CLI entry point. On PATH as `git-commit-story`, so git also runs it as // `git commit-story `. import { Command } from "commander"; +import { hooksCommand } from "./commands/hooks.js"; +import { initCommand } from "./commands/init.js"; const program = new Command("git-commit-story") .description("Attach agent session stories to git commits") - .version("0.0.1"); - -program - .command("hello") - .description("Print a greeting") - .action(() => { - process.stdout.write("Hello from git-commit-story\n"); - }); + .version("0.0.1") + .addCommand(initCommand) + .addCommand(hooksCommand); program.parse(); diff --git a/ts/packages/git-commit-story/src/commands/hooks.ts b/ts/packages/git-commit-story/src/commands/hooks.ts new file mode 100644 index 0000000000..29e769b7e6 --- /dev/null +++ b/ts/packages/git-commit-story/src/commands/hooks.ts @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { Command } from "commander"; + +// `hooks`: agent hook handlers. Placeholder output until story capture exists. +export const hooksCommand = new Command("hooks").description( + "Agent hook handlers", +); + +hooksCommand + .command("copilot") + .description("Copilot CLI hook handlers") + .command("user-prompt-submitted") + .description("Handle the Copilot userPromptSubmitted hook") + .action(() => { + process.stdout.write("Hello World\n"); + }); diff --git a/ts/packages/git-commit-story/src/commands/init.ts b/ts/packages/git-commit-story/src/commands/init.ts new file mode 100644 index 0000000000..789cf1e21e --- /dev/null +++ b/ts/packages/git-commit-story/src/commands/init.ts @@ -0,0 +1,81 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { Command } from "commander"; +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; + +// Copilot CLI reads repo-level hooks from this file. The `.local` variant is +// per-clone, so init also adds it to `.git/info/exclude`. +const COPILOT_SETTINGS = ".github/copilot/settings.local.json"; +const PROMPT_HOOK = "git commit-story hooks copilot user-prompt-submitted"; + +// 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 { + if (Array.isArray(value)) return value.map(sortKeys); + if (value === null || typeof value !== "object") return value; + return Object.fromEntries( + Object.keys(value) + .sort() + .map((k) => [k, sortKeys((value as Record)[k])]), + ); +} + +// `init`: registers agent hooks for the current repository. +export const initCommand = new Command("init") + .description("Register agent hooks for the current repository") + .action(() => { + const git = (...args: string[]) => + execFileSync("git", args, { encoding: "utf8" }).trim(); + const root = git("rev-parse", "--show-toplevel"); + const settingsPath = path.join(root, COPILOT_SETTINGS); + let settings: { + hooks?: { userPromptSubmitted?: { bash?: string }[] }; + [key: string]: unknown; + } = {}; + if (fs.existsSync(settingsPath)) { + try { + settings = JSON.parse(fs.readFileSync(settingsPath, "utf8")); + } catch (e) { + process.stderr.write( + `Failed to parse ${settingsPath}: ${(e as Error).message}\n`, + ); + process.exitCode = 1; + return; + } + } + // Update only our hook entry; keep other keys and hooks as they are. + const hook = { + type: "command", + bash: PROMPT_HOOK, + powershell: PROMPT_HOOK, + }; + settings.hooks ??= {}; + settings.hooks.userPromptSubmitted = [ + ...(settings.hooks.userPromptSubmitted ?? []).filter( + (h) => h.bash !== PROMPT_HOOK, + ), + hook, + ]; + fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); + fs.writeFileSync( + settingsPath, + JSON.stringify(sortKeys(settings), null, 4) + "\n", + ); + + // `--git-path` is relative to the cwd (not the repo root), so resolve + // it against the cwd to work from subdirectories. + const exclude = path.resolve( + git("rev-parse", "--git-path", "info/exclude"), + ); + const lines = fs.existsSync(exclude) + ? fs.readFileSync(exclude, "utf8").split("\n") + : []; + if (!lines.includes(COPILOT_SETTINGS)) { + fs.mkdirSync(path.dirname(exclude), { recursive: true }); + fs.appendFileSync(exclude, `${COPILOT_SETTINGS}\n`); + } + process.stdout.write(`Registered Copilot hooks in ${settingsPath}\n`); + });