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
9 changes: 8 additions & 1 deletion ts/packages/git-story/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,21 @@ Git runs any `git-<name>` binary on `PATH` as `git <name>`, 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
Expand Down
30 changes: 28 additions & 2 deletions ts/packages/git-story/src/commands/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
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
Expand All @@ -16,3 +26,19 @@ hooksCommand
.action(() => {
process.stdout.write("Hello World\n");
});

// `hooks git <hook> [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`,
);
});
31 changes: 31 additions & 0 deletions ts/packages/git-story/src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <hook>`. `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 {
Expand Down Expand Up @@ -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/<hook>` 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`,
);
}
});
Loading