* 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>
8.3 KiB
Keep planning docs out of a shared repo
You are working in a team repo and want GSD's .planning/ artifacts — PLAN.md, SUMMARY.md,
ROADMAP.md, STATE.md — to stay on your machine instead of appearing in commits your teammates read.
This takes four steps, and the third is the one people miss.
1. Turn off doc commits
gsd-tools config-set planning.commit_docs false
gsd-tools query commit now returns a skipped envelope instead of committing, and every workflow
that writes a planning artifact honors it.
2. Ignore the directory
Add to .gitignore:
.planning/
This is also enough on its own: when .planning/ is gitignored and config.json sets no explicit
value, GSD auto-resolves commit_docs to false. Setting it explicitly in step 1 is clearer, and
it survives someone later editing .gitignore.
3. Untrack what git is already tracking
This is the step that catches people out. .gitignore only stops git picking up new files.
It has no effect on files already committed — git keeps tracking those, so git add -A keeps
staging them even though steps 1 and 2 are both done.
Because GSD's default is commit_docs: true, most existing projects already have .planning/
in history, which makes this the common case rather than an edge case.
git rm -r --cached .planning/
git commit -m "chore: stop tracking planning docs"
--cached removes the files from the index only — your files on disk are untouched.
To check whether this applies to you before running it:
git ls-files .planning
Any output means git is still tracking those paths.
4. Keep search working
With .planning/ ignored, tools that respect .gitignore stop searching it — including GSD's own
broad searches, which is rarely what you want, since the planning docs are exactly what you want an
agent to read.
gsd-tools config-set planning.search_gitignored true
This adds --no-ignore to broad searches so .planning/ is still found locally.
Verify
gsd-tools validate health
A clean result means you are done. If you skipped step 3, you will see:
W029 .planning/ is gitignored but N file(s) are still tracked by git
Fix: git rm -r --cached .planning/ && git commit -m "chore: stop tracking planning docs"
W029 is advisory. GSD will not untrack files for you, and --repair deliberately does not act on
it — removing files from the index is destructive and the timing is yours.
Notes
- A deliberate force-add also raises
W029. If you intentionally keep one file tracked under an otherwise-ignored.planning/(git add -f .planning/decisions.md), the warning still appears. There is no reliable way to tell an intentional force-add from the accidental case, so the warning is expected there too. - Teammates who have already pulled the tracked files will see them deleted by your step-3 commit. That is the intended effect — the files leave the repo, not their working copies of your branch — but say so in the commit message or PR description so it is not a surprise.
Per-phase override
You just made the whole project local-only in step 1. If instead you want ONE phase's artifacts —
say, an architecture or ADR phase whose PLAN.md and REQUIREMENTS.md are worth sharing with the
team — committed while every other phase stays local, set a phase_commit_docs entry for that
phase's number instead of (or on top of) the project-wide switch:
gsd-tools config-set planning.commit_docs false
gsd-tools config-set phase_commit_docs.03 true
{
"planning": { "commit_docs": false },
"phase_commit_docs": { "03": true }
}
Now gsd-tools query commit commits phase 03's artifacts normally, and skips (with the
skipped_commit_docs_phase_false-or-skipped_commit_docs_false reason depending on which tier
decided) every other phase's — the project-wide setting from step 1 still governs everything
phase_commit_docs does not name.
A few things worth knowing before you rely on this:
- The phase-id form doesn't matter.
phase_commit_docs.3,phase_commit_docs.03, and (if your project uses aproject_code)phase_commit_docs.PROJ-03all resolve to the same entry — GSD normalizes the phase number before lookup, the same way it does everywhere else phase numbers are compared. - It only applies to a commit that names a phase-scoped file.
gsd-tools query commitresolves the phase from the--filespaths you pass it (via the.planning/phases/<phase-dir>/…segment). A commit that names no phase file — e.g. a bareROADMAP.mdupdate — has no phase to look up, sophase_commit_docsnever applies to it and the project-wide setting governs, same as before this feature existed. - It's per phase, not per artifact. You cannot commit a phase's ADR but suppress its SUMMARY within the same commit; the override applies to the whole phase.
- Reverse direction works too. With the project-wide default (
commit_docs: true), setphase_commit_docs.<phase-id> falseto suppress just one noisy execution phase while everything else commits normally.
Full precedence order (highest wins): phase_commit_docs.<phase-id> → explicit commit_docs /
planning.commit_docs → .gitignore auto-detect → the manifest default. See
Configuration reference — per-phase override
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:
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:
gsd-tools commit-docs-guard disable
Enable refuses rather than guesses in three situations, each reported with a reason:
- An existing
pre-commithook 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 --rawis the check to add). core.hooksPathis already configured. A hook written to.git/hooks/pre-commitwould 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.