From eb0559a55a6c0f93547b6275a148b69e5de51259 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Sun, 1 Nov 2026 17:07:20 -0800 Subject: [PATCH] chore: refuse commits in the primary checkout A pre-commit guard refuses any commit made in the repository's primary checkout, so all work happens in a linked worktree under .agents/.worktrees/. It compares --git-dir with --git-common-dir, which are equal only in the primary checkout, so it refuses a feature branch checked out there too. CI is exempt, since runners commit from a plain clone. git commit --no-verify is the deliberate override. The guard ships in the worktrees-pnpm skill so other repos get it with the skill. AGENTS.md also stops agents offering to fast-forward the local main, which nothing here reads. --- .agents/skills/worktrees-pnpm/SKILL.md | 18 ++++++++ .../scripts/refuse-main-checkout.sh | 42 +++++++++++++++++++ .husky/pre-commit | 1 + AGENTS.md | 4 +- 4 files changed, 64 insertions(+), 1 deletion(-) create mode 100755 .agents/skills/worktrees-pnpm/scripts/refuse-main-checkout.sh diff --git a/.agents/skills/worktrees-pnpm/SKILL.md b/.agents/skills/worktrees-pnpm/SKILL.md index f0931b05..b571fce3 100644 --- a/.agents/skills/worktrees-pnpm/SKILL.md +++ b/.agents/skills/worktrees-pnpm/SKILL.md @@ -144,6 +144,24 @@ printf '{"hook_event_name":"WorktreeRemove","worktree_path":"%s"}' "$PWD/.agents **2. Add the ignores** described in the next section. Skipping this is the single most common way to end up debugging a lint failure in code you never wrote. +**3. Refuse commits in the primary checkout.** Instructions telling agents to stay out of the +main checkout are not enough on their own, so a `pre-commit` hook makes it a hard stop. +`scripts/refuse-main-checkout.sh` exits non-zero when `git rev-parse --git-dir` equals +`--git-common-dir`, which is true only in the primary checkout, never in a linked worktree. It +checks the checkout, not the branch, so a feature branch checked out in the primary tree is +refused too. It skips when `CI` is set, because a CI runner commits from a plain clone (version +PRs, review bots), and a plain clone is a primary checkout. With Husky, run it first in +`.husky/pre-commit`: + +```sh +sh .agents/skills/worktrees-pnpm/scripts/refuse-main-checkout.sh +npx lint-staged +``` + +Without Husky, call it from `.git/hooks/pre-commit`, or from whatever hook manager the repo +uses. A person who really means to commit in the primary checkout runs `git commit --no-verify`. +An agent never does that to get past this guard. + ## Every root-level tool must ignore the worktrees directory This is the one real cost of keeping worktrees in the repo, and it is not optional. A worktree is diff --git a/.agents/skills/worktrees-pnpm/scripts/refuse-main-checkout.sh b/.agents/skills/worktrees-pnpm/scripts/refuse-main-checkout.sh new file mode 100755 index 00000000..afa41c48 --- /dev/null +++ b/.agents/skills/worktrees-pnpm/scripts/refuse-main-checkout.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env sh +# +# pre-commit guard: refuse commits made in the repository's primary checkout. +# +# All work happens in a worktree under .agents/.worktrees/. The primary checkout +# is the one every agent is told never to touch, because an agent that lands +# there edits, branches and commits in the user's own working tree. Telling it +# not to has not been enough, so the commit is refused instead. +# +# The test: in a linked worktree, --git-dir is .git/worktrees/ while +# --git-common-dir is the shared .git. In the primary checkout both are the same +# directory. This does not care what branch is checked out, so it also stops a +# commit to a feature branch that someone checked out in the primary tree. +# +# Not enforced in CI: the runners commit from a plain clone (changeset version +# PRs, review actions), and a plain clone is a primary checkout. +# +# Escape hatch for a deliberate commit here: git commit --no-verify +set -eu + +if [ -n "${CI:-}" ]; then + exit 0 +fi + +dirs=$(git rev-parse --path-format=absolute --git-dir --git-common-dir) +git_dir=$(printf '%s\n' "$dirs" | sed -n 1p) +common_dir=$(printf '%s\n' "$dirs" | sed -n 2p) + +if [ "$git_dir" = "$common_dir" ]; then + top=$(git rev-parse --show-toplevel) + cat >&2 < .agents/.worktrees/ origin/main + cd .agents/.worktrees/ && pnpm install + +To commit here deliberately: git commit --no-verify +EOF + exit 1 +fi diff --git a/.husky/pre-commit b/.husky/pre-commit index 2312dc58..fac8e60d 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1 +1,2 @@ +sh .agents/skills/worktrees-pnpm/scripts/refuse-main-checkout.sh npx lint-staged diff --git a/AGENTS.md b/AGENTS.md index 9262614d..9d61f1b4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -102,7 +102,9 @@ The three rules that cause the most damage when missed: - **A worktree starts from the latest `origin/main`, checked before any work begins.** Run `git fetch origin +refs/heads/main:refs/remotes/origin/main` (the explicit refspec, for the reason above), then `git rev-list --count HEAD..origin/main`. Anything above `0` means the worktree is behind: rebase onto `origin/main` (`git rebase origin/main`) before reading code, making changes, or running `pnpm install` (a rebase can change the lockfile), unless the work was explicitly asked to start from another branch or commit. The `WorktreeCreate` hook already branches from a freshly fetched `origin/main`, but a worktree made any other way (by hand, by another tool, or by a session board) is cut from whatever the main checkout was last pulled to. One started 68 commits behind `main`, and nothing flagged it: stale code reads exactly like current code. Another started 156 behind and never saw this rule at all, because the rule landed after its base: **a stale tree carries stale conventions**, so nothing in the repo can warn a checkout that is already behind. That is why the fix belongs in whatever creates the worktree (the hook, a session board, your own hands), and why this check comes before reading anything else here. - **A worktree gets its own empty `node_modules`.** `git worktree add` is not finished until `pnpm install` has run inside it. Without that, `git commit` fails in `lint-staged` (no `prettier`/`eslint`), and every `pnpm` script fails. A missing `prettier` here once cost an agent an hour of dead-end workarounds. There is no `pnpm worktree` command; `git worktree` is the tool. -- **NEVER point an agent at the main repo path** (e.g. `/Users//code/taskless/skills`). It will `cd` there and run git commands and edits in the **main** checkout, defeating isolation. It can create and check out a branch in your working tree, silently switching your session off its own branch. Tell the agent to work in **its assigned worktree** (`$PWD`) and pass only relative paths plus GitHub identifiers (`owner/repo`). +- **NEVER point an agent at the main repo path** (e.g. `/Users//code/taskless/skills`). It will `cd` there and run git commands and edits in the **main** checkout, defeating isolation. It can create and check out a branch in your working tree, silently switching your session off its own branch. Tell the agent to work in **its assigned worktree** (`$PWD`) and pass only relative paths plus GitHub identifiers (`owner/repo`). The `pre-commit` hook enforces the commit half of this: it refuses any commit made in the primary checkout (outside CI), whatever branch is checked out there. `git commit --no-verify` is the deliberate override; an agent never uses it to get past this guard. + +**Do not offer to fast-forward or pull the local `main`.** Nothing here reads it: worktrees branch from a freshly fetched `origin/main`, and every freshness check compares against `origin/main`. So "want me to fast-forward `main`?" at the end of a task asks for a step that changes nothing. The one time it is worth mentioning is right after this session's own work has been rebased and merged to `main`, and even then mention it once, as optional, not as a closing step. ## Stacked PRs