Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions ts/packages/git-commit-story/README.AUTOGEN.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

<!-- AUTOGEN:DOCS:START -->

<!-- AUTOGEN:DOCS:HASH:sha256=38ca35936c91b1a757a04be397408598c991864f63cf56c2d351be3a932305f5 -->
<!-- AUTOGEN:DOCS:HASH:sha256=9a632d8aab739dcf42534ec63a8c6bb12b8b61777f38bba74c23d061a6ead9f6 -->
<!-- AUTOGEN:DOCS:SOURCE: ./README.md (hand-written documentation; this file is the AI-generated companion) -->

# git-commit-story — AI-generated documentation
Expand All @@ -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._

<!-- AUTOGEN:DOCS:END -->
8 changes: 6 additions & 2 deletions ts/packages/git-commit-story/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,14 @@ Git runs any `git-<name>` binary on `PATH` as `git <name>`, 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
Expand Down
13 changes: 5 additions & 8 deletions ts/packages/git-commit-story/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,13 @@
// CLI entry point. On PATH as `git-commit-story`, so git also runs it as
// `git commit-story <command>`.
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();
18 changes: 18 additions & 0 deletions ts/packages/git-commit-story/src/commands/hooks.ts
Original file line number Diff line number Diff line change
@@ -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");
});
81 changes: 81 additions & 0 deletions ts/packages/git-commit-story/src/commands/init.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>)[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"),
Comment thread
datduyng marked this conversation as resolved.
);
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`);
});
Loading