* feat(#3588): add an opt-in commit_docs pre-commit hook Final phase of epic #2292, scope narrowed to opt-in by maintainer decision: default-on installation and the bin/install.js wiring it would have required are explicitly out of scope. Enabling is an explicit verb call. The hook is written to the repo's real hooks dir resolved via git rev-parse --git-path hooks, so a linked worktree or submodule whose .git is a FILE works rather than getting a literal .git/hooks path. It refuses rather than overwrite a foreign pre-commit, refuses to delete one it did not write, and refuses outright when core.hooksPath is already set -- a written-but-ignored hook is worse than a refusal. Ownership is detected by marker presence, not byte-equality, so a user who appends a line does not make it unrecognizable. Deliberately NOT included: teaching cmdCheckCommit the per-phase commit_docs tier. #3587 was still unmerged when this landed, and implementing precedence against helpers that did not yet exist would have meant a second copy of the resolution chain -- the divergence class this epic has spent three phases fighting. That follows as its own change now that #3587 is on next. The ordering constraint is recorded in the design doc: this must not merge before #3587, or the hook would block a commit cmdCommit itself allows. * fix(#3588): teach the commit_docs guard the per-phase tier and -z paths Part 1, deferred until #3587 merged. cmdCheckCommit read only project-level commit_docs, so once #3587 landed, a phase with phase_commit_docs true under project false was ALLOWED by query commit and BLOCKED by this guard -- and the hook shipped in this same branch shells out to it. It now derives the staged phase via the single-owner detectPhaseNumberFromFiles and resolves through #3587's own resolveCommitDocsPolicy rather than a second precedence copy. Also fixes a proven false negative in the harm direction. git diff --cached --name-only C-style-quotes any path with non-ASCII or special characters, so a staged .planning/cafe.md was emitted as a quoted string, failed startsWith('.planning/'), and slipped past the guard entirely under commit_docs:false. Reading with -z and splitting on NUL removes the quoting at the source. The f.startsWith('.planning\\') branch was dead code under that read -- git emits /-separated paths on every platform -- and is removed rather than left implying coverage it never provided. The earlier C7 test pinned the buggy behavior as intended; it now asserts the file is detected and the commit refused. Self-caught: the commit-docs-guard verb was wired into the routers by this branch's earlier pass but missing from the top-level help listing. * test(#3588): replace try/finally with t.after, add negative-routing cases Standards review findings. CONTRIBUTING bans try/finally inside a test body outright -- it masks failures -- and B8 used one for worktree cleanup. Now t.after(), assertions unchanged. The new commit-docs-guard command family had zero negative-routing coverage, which CONTRIBUTING requires for any change to command dispatch. B11-B15 cover no subcommand, unknown, empty string, whitespace-only and a flag-shaped value, each asserting non-zero exit, a structured error, no stack trace, and -- the one that matters for a command that writes into a user's repo -- that NO hook is written in any of them. Those tests were verified to fail when routeCommitDocsGuard's else-branch is neutered, so they exercise the routing guard rather than any convenient error path. Also made two error() calls' control flow explicit with a return; they were safe only because error() is typed never two files away. * chore(#3588): backfill changeset pr number to 3609 * test(#3588): skip Windows-unrepresentable fixtures on win32 CI's Windows shards caught two of my own tests: fixtures whose filenames contain a quote and a backslash. Both are illegal on Windows -- backslash is the path separator, quote is invalid on NTFS -- so fixture creation failed before any assertion ran. Test-portability defect, not a production one. Those inputs cannot exist on that platform, so the guard has nothing to detect there. Both now check process.platform FIRST, before any fs or git call, and use t.skip() rather than a bare return -- a bare return registers as a PASS and would hide the gap it is meant to record. Each carries a comment saying the input is unrepresentable rather than unverified, so nobody later re-enables it. No padding added: the cafe.md case already exercises git's C-quoting path on every platform, since non-ASCII names are legal on NTFS. This is exactly the coverage the Linux-only remote matrix cannot provide, which the PR body already stated -- CI's Windows shards are what caught it. --------- Co-authored-by: sim <sim@local>
This commit is contained in:
@@ -599,6 +599,33 @@ The prompt injection guard hook (`gsd-prompt-guard.js`) is always active and can
|
||||
|
||||
When `planning.commit_docs` is `false` and `.planning/` is listed in `.gitignore`, GSD treats planning artifacts as local-only. `planning.search_gitignored: true` ensures broad searches still include the `.planning/` directory in this configuration. See [Keep planning docs out of a shared repo](how-to/keep-planning-docs-private.md) for the full setup, including untracking files git is already tracking.
|
||||
|
||||
### `commit_docs` Pre-Commit Guard (opt-in)
|
||||
|
||||
`planning.commit_docs: false` only stops GSD's own `gsd-tools commit`/`gsd-tools state`
|
||||
write path from committing `.planning/`. It does **not** stop a plain `git add -A` +
|
||||
`git commit` run by hand, or by a script outside GSD's own tooling, from staging and
|
||||
committing `.planning/` anyway.
|
||||
|
||||
`gsd-tools commit-docs-guard enable` closes that gap by writing a `.git/hooks/pre-commit`
|
||||
hook into the **current repository** that refuses any commit staging `.planning/` files
|
||||
while `commit_docs` resolves to `false`. Resolution goes through the same
|
||||
[per-phase precedence chain](#per-phase-override-phase_commit_docs) `gsd-tools commit`/`query commit`
|
||||
use — a `phase_commit_docs.<phase-id>` override for the staged phase is honored here too, so the
|
||||
hook cannot contradict them. It is entirely opt-in — no install path wires it
|
||||
automatically:
|
||||
|
||||
```bash
|
||||
gsd-tools commit-docs-guard enable # write the hook (refuses to clobber an existing pre-commit hook)
|
||||
gsd-tools commit-docs-guard disable # remove it (refuses to remove a hook GSD didn't write)
|
||||
```
|
||||
|
||||
The hook is identified by a stable `# gsd-core:commit-docs-guard` marker line, so `enable`/
|
||||
`disable` detect it by presence of that marker rather than by byte-for-byte content — editing
|
||||
the file afterward does not make it unrecognizable. `enable` refuses (rather than silently
|
||||
writing an inert file) when `core.hooksPath` is already configured, since a hook written to
|
||||
`.git/hooks/pre-commit` would never run in that case; wire the guard into the configured hooks
|
||||
path by hand instead. See [Keep planning docs out of a shared repo](how-to/keep-planning-docs-private.md#pre-commit-guard-hook-optional) for the full walkthrough, including the linked-worktree case.
|
||||
|
||||
---
|
||||
|
||||
## Agent Skills Injection
|
||||
|
||||
@@ -134,8 +134,55 @@ Full precedence order (highest wins): `phase_commit_docs.<phase-id>` → explici
|
||||
[Configuration reference — per-phase override](../CONFIGURATION.md#per-phase-override-phase_commit_docs)
|
||||
for the complete rules, including how a non-boolean value is handled.
|
||||
|
||||
## Pre-commit guard hook (optional)
|
||||
|
||||
Steps 1-4 stop **GSD's own** commit path from writing `.planning/`. They do not stop a plain
|
||||
`git add -A` + `git commit` — run by hand, by a teammate, or by a script outside GSD's own
|
||||
tooling — from staging and committing `.planning/` anyway. `gsd-tools commit-docs-guard enable`
|
||||
closes that specific gap by installing a `.git/hooks/pre-commit` hook that refuses any commit
|
||||
staging `.planning/` files while `commit_docs` resolves to `false`. Resolution honors the full
|
||||
precedence chain above — including a `phase_commit_docs.<phase-id>` override for the phase the
|
||||
staged `.planning/` files belong to — the same resolution `gsd-tools commit`/`query commit` uses,
|
||||
so the hook never contradicts them.
|
||||
|
||||
This is opt-in only — no GSD install path wires it in for you:
|
||||
|
||||
```bash
|
||||
gsd-tools commit-docs-guard enable
|
||||
```
|
||||
|
||||
If a commit would violate `commit_docs`, the hook blocks it and names the staged files and the
|
||||
`git reset` command to unstage them, matching `gsd-tools check-commit`'s own message. Remove it
|
||||
with:
|
||||
|
||||
```bash
|
||||
gsd-tools commit-docs-guard disable
|
||||
```
|
||||
|
||||
**Enable refuses rather than guesses** in three situations, each reported with a reason:
|
||||
|
||||
- **An existing `pre-commit` hook you didn't get from GSD.** The file is left byte-for-byte
|
||||
unchanged; wire the guard into it by hand (`gsd-tools check-commit --raw` is the check to add).
|
||||
- **`core.hooksPath` is already configured.** A hook written to `.git/hooks/pre-commit` would
|
||||
never run in that case, so nothing is written; add the same check to whatever hook lives at the
|
||||
configured path instead.
|
||||
- **The current directory is not a git repository.**
|
||||
|
||||
`enable`/`disable` are idempotent and safe to script: a second `enable` is a no-op that reports
|
||||
success rather than duplicating content, and `disable` on a repo with no hook installed succeeds
|
||||
rather than erroring. The hook is identified by a stable `# gsd-core:commit-docs-guard` marker
|
||||
line inside the file, checked by presence rather than exact content — appending your own line to
|
||||
the installed hook afterward does not make GSD stop recognizing it as its own. In a linked
|
||||
worktree or submodule (where `.git` is a file, not a directory), `enable` resolves the real,
|
||||
shared hooks directory via git itself rather than assuming a literal `.git/hooks` path.
|
||||
|
||||
Windows note: the hook runs under Git Bash, same as any other git hook. GSD's own remote test
|
||||
matrix is Linux-only, so this specific behavior is verified on Linux/macOS plus code review, not
|
||||
by an automated Windows run.
|
||||
|
||||
## Related
|
||||
|
||||
- [Configuration reference — `planning.commit_docs`](../CONFIGURATION.md#planning-settings)
|
||||
- [Configuration reference — auto-detection and the tracked-files caveat](../CONFIGURATION.md#auto-detection)
|
||||
- [Configuration reference — per-phase override](../CONFIGURATION.md#per-phase-override-phase_commit_docs)
|
||||
- [Configuration reference — the pre-commit guard hook](../CONFIGURATION.md#commit_docs-pre-commit-guard-opt-in)
|
||||
|
||||
Reference in New Issue
Block a user