forked from bchanot/claude
docs(gitflow): push-guard — SETTINGS push discipline + guardrails table, gitflow skill, ARCHITECTURE, README, CHANGELOG
This commit is contained in:
+1
-1
@@ -20,7 +20,7 @@ claude-config/
|
||||
├── update-all.sh # One-command update for all components
|
||||
├── Makefile # Unified entry point: make install / doctor / update / test (make help)
|
||||
├── plugins.lock.json # Version pinning for non-marketplace dependencies and vendored skills
|
||||
├── hooks/ # Claude Code hooks: session start, statusline, RTK rewrite, ctx7 + design-toolchain reminders, attention notify, unpushed-work guard
|
||||
├── hooks/ # Claude Code hooks: session start, statusline, RTK rewrite, ctx7 + design-toolchain reminders, attention notify, unpushed-work guard, manual-mode push guard
|
||||
├── githooks/ # Generated git hooks (pre-commit, post-commit, post-merge, reference-transaction), git's global core.hooksPath
|
||||
├── .githooks/ # This repo's own copy of the same hooks
|
||||
├── rules/ # Rule files deployed to ~/.claude/rules (path-scoped or always-on)
|
||||
|
||||
+2
-1
@@ -7,9 +7,10 @@ Format follows [Keep a Changelog](https://keepachangelog.com/) and this project
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- **Manual-push mode**: `git config gitflow.autopush false` (human-set) now stops every push the gitflow lib makes, not only the post-commit / post-merge hooks. `gitflow start` and `finish` branch, commit and merge locally and push nothing; `gitflow delete` leaves the `origin/` copy in place and prints `git push origin --delete <br>` for the user to run. `hooks/unpushed-guard.sh` stays silent at turn end in this mode and opens each session with one `ℹ manual push mode:` line counting the commits no remote holds across every local branch; an unparseable `gitflow.autopush` value is named and treated as auto. Skills that push on their own do not honour the mode yet. Tests: `lib/gitflow-test.sh` T18m block, `lib/tests/unpushed-guard.test.sh` T10-T16.
|
||||
- **Manual-push mode**: `git config gitflow.autopush false` (human-set) now stops every push the gitflow lib makes, not only the post-commit / post-merge hooks. `gitflow start` and `finish` branch, commit and merge locally and push nothing; `gitflow delete` leaves the `origin/` copy in place and prints `git push origin --delete <br>` for the user to run. `hooks/unpushed-guard.sh` stays silent at turn end in this mode and opens each session with one `ℹ manual push mode:` line counting the commits no remote holds across every local branch; an unparseable `gitflow.autopush` value is named and treated as auto. `hooks/push-guard.sh` (PreToolUse, `Bash|Monitor`) refuses any `git push` Claude types while `gitflow.autopush` reads false in the session cwd or in a literal `-C`/`cd` directory the command names (global config counts outside a repo); the refusal tells the user to run it with `! git push`. It fails closed: for this hook a non-boolean value reads as manual, and a git failure while reading the key, an internal error or more than 20 distinct directory tokens in one command refuse the push (these pathological cases fire in auto mode too). In manual mode it over-blocks any command where a `push` word follows a `git` token; the misses listed in its header fall to a new `autoMode.soft_deny` rule that no request in the turn clears. The session banner adds `🔒 push : manual (autopush=false) — ! git push` when the key reads false. Skills that push on their own do not honour the mode yet. Tests: `lib/gitflow-test.sh` T18m block, `lib/tests/unpushed-guard.test.sh` T10-T16, `lib/tests/push-guard.test.sh` (71 checks).
|
||||
|
||||
### Changed
|
||||
- `settings.json` denies every write form of the human-only `gitflow.*` keys (18 entries): any `git … config` spelling, section remove/rename, `git -c`, the git config env overrides, and Edit/Write of `.git/config`, `.gitconfig` and `~/.config/git/config`. Side effect: Claude can no longer read `gitflow.autopush` through `git config` either; hooks and `lib/gitflow.sh` still read it. The `hard_deny` rule on routing around a guardrail now names PreToolUse hook refusals.
|
||||
- `gitflow start` and `finish` warn on stderr when a base is behind origin and cannot fast-forward, instead of a silent `git pull --ff-only || true` (T18l, T18n).
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -18,7 +18,9 @@ Not a collection of prompts — an operating layer on top of Claude Code:
|
||||
- **Hooks and permissions** are deterministic guardrails: gitflow enforced
|
||||
by a pre-commit hook in every repo (`make link` points git's global
|
||||
`core.hooksPath` at `~/.claude/githooks`), every commit pushed by
|
||||
post-commit and post-merge hooks, `main`/`develop` undeletable by a reference-transaction hook,
|
||||
post-commit and post-merge hooks (nothing pushed in a repo the user puts
|
||||
in manual-push mode, where a PreToolUse hook also refuses Claude's own
|
||||
`git push`), `main`/`develop` undeletable by a reference-transaction hook,
|
||||
deny-first permission rules, secrets kept in `~/.claude/.env` and
|
||||
never in config files.
|
||||
- **Templates and memory** seed every project with persistent registries
|
||||
|
||||
@@ -56,9 +56,10 @@ and keeps the branch. The `origin/` copy is removed right after, once ITS
|
||||
tip passes the same check; a remote tip holding commits the bases lack is
|
||||
kept, loudly (T24). In manual-push mode (`git config gitflow.autopush
|
||||
false`, human-set) nothing is pushed: `start` and `finish` stay local, and
|
||||
the `origin/` copy is left in place (T18i-T18k). Hand `git branch -d` is
|
||||
denied — with an auto-pushed upstream it checks the wrong thing (T22a). A
|
||||
`reference-transaction` hook vetoes any deletion or rename of
|
||||
the `origin/` copy is left in place (T18i-T18k). A `git push` Claude types is
|
||||
refused by `hooks/push-guard.sh`; the user pushes with `! git push`. Hand
|
||||
`git branch -d` is denied — with an auto-pushed upstream it checks the wrong
|
||||
thing (T22a). A `reference-transaction` hook vetoes any deletion or rename of
|
||||
`main`/`develop` at the ref layer, in every repo.
|
||||
|
||||
## The finish gate — merge ONLY on an explicit human signal
|
||||
@@ -109,7 +110,7 @@ stays human-gated.
|
||||
| `delete`/`finish` rc=5 — branch not merged into develop or main | The branch still holds unmerged work: KEEP it, report it, never fall back to `git branch -d`/`-D`. Merge first (human gate), then re-run |
|
||||
| `delete` rc=6 — protected base | `main`/`develop` are never deleted. Stop; the request itself is the defect to report |
|
||||
| `delete`/`finish` warning "remote copy KEPT" or "NOT removed" | Non-fatal BY CONTRACT (remote cleanup is best-effort). KEPT = origin/<br> has a tip the bases lack: fetch, look, merge or leave it — never `git push --delete` by hand. NOT removed = origin unreachable or refused: report the printed command to the user |
|
||||
| `delete`/`finish` warning "origin/<br> left in place (manual push mode)" | Expected in manual-push mode, not a failure. Pass the printed `git push origin --delete <br>` to the user; never run it (manual mode: no push unless the user asks, and `push --delete` is denied by settings) |
|
||||
| `delete`/`finish` warning "origin/<br> left in place (manual push mode)" | Expected in manual-push mode, not a failure. Pass the printed `git push origin --delete <br>` to the user; never run it (manual mode: Claude never pushes, even when asked in the turn; the user runs it with `!`. `push --delete` is also denied by settings) |
|
||||
| `start`/`finish` warning "<base> is behind origin/<base> by N and cannot fast-forward" | Non-fatal BY CONTRACT: the branch is still created and the merge still runs on the local base. The base has diverged from origin: report it to the user, who reconciles (`git pull`, then push). Never rebase or force-push a base |
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
@@ -145,6 +145,8 @@ local trace, and the brief had authorized it. What holds now, by tier:
|
||||
| `chmod`/`chown -R`, `sudo`/`doas`/`pkexec`, disk tools (`dd`, `mkfs`, `shred`…), `chattr` | `permissions.deny` | The user runs them by hand. |
|
||||
| Docker volume drops, `system prune`, `compose down -v`, `--privileged`, the docker socket, `-v /:` | `permissions.deny` | Promoted from `soft_deny`: no in-session clearance for data drops. |
|
||||
| Git history destruction (`push --delete`/`--mirror`/`:ref`/`--force-with-lease`, `branch -D`, `filter-branch`, `reflog expire`, `stash clear`/`drop`, `clean -f`), `--no-verify`, `core.hooksPath` | `permissions.deny` | A remote is the backup; nothing rewrites or deletes what it holds. |
|
||||
| Writing the human-only `gitflow.*` toggles: any `git … config` spelling, section remove/rename, `git -c`, the git config env overrides, Edit/Write of git config files | `permissions.deny` | Claude never flips the mode that binds it. Side effect: the trailing glob also matches the bare read, so Claude cannot read `gitflow.autopush` through `git config`; hooks and `lib/gitflow.sh` still do. |
|
||||
| Pushing in manual-push mode (`gitflow.autopush false`) | `hooks/push-guard.sh` (PreToolUse) + `autoMode.soft_deny` | `ask` is inert under auto mode. The hook denies the direct forms; the soft_deny covers scripted, aliased, subshell and sub-agent pushes, and a request in the turn does not clear it: the user types `! git push`. |
|
||||
| Destructive tool against a local path (variable, `~`, `..`, wildcard, outside cwd/tmp), even as a trace or a rehearsal a brief allows | `autoMode.hard_deny` | A pattern cannot express "the target resolves outside the project"; the classifier can. A sub-agent brief carries no user authority. |
|
||||
| `docker rm -f`, bind mount outside cwd; discarding uncommitted work | `autoMode.soft_deny` | Recoverable or user-intended in the turn. |
|
||||
|
||||
@@ -152,7 +154,8 @@ Rules apply to sub-agents (auto mode is inherited) and to each segment of
|
||||
a compound command; a tool nested in another command (`docker compose run …
|
||||
lftp`) is not matched by a static rule. The PreToolUse guard hook that scans
|
||||
the whole command, its executable spec in `lib/tests/guard-bash.test.sh`,
|
||||
is not shipped yet (BLK-022).
|
||||
is not shipped yet (BLK-022). `hooks/push-guard.sh` scans the command text
|
||||
for `git push` only, in manual-push mode (see below).
|
||||
|
||||
Push discipline lives in `lib/gitflow.sh`: `start` pushes the branch,
|
||||
`finish` pushes each merge target, and the post-commit / post-merge hooks
|
||||
@@ -178,6 +181,18 @@ setting and the generated dir. `hooks/unpushed-guard.sh` reports a branch
|
||||
ahead of its upstream at session start and at each turn end; in manual-push
|
||||
mode it stays silent at turn end and gives one `ℹ manual push mode:` line at
|
||||
session start, counting unpushed commits across every local branch.
|
||||
In manual-push mode `hooks/push-guard.sh` (PreToolUse, `Bash|Monitor`) also
|
||||
refuses any `git push` Claude types, when the key reads false in the session
|
||||
cwd or in a literal `-C`/`cd` directory the command names (global config
|
||||
counts outside a repo). The refusal tells the user to run the push with
|
||||
`! git push`, and the session banner adds a `🔒 push : manual` line. The hook
|
||||
fails closed: a non-boolean value reads as manual, and a git failure while
|
||||
reading the key or more than 20 directory tokens in one command refuses the
|
||||
push, in auto mode too. In manual mode it over-blocks any command where a
|
||||
`push` word follows a `git` token (`git stash push`, a grep for "git push").
|
||||
The misses listed in its header fall to an `autoMode.soft_deny` rule that no
|
||||
request in the turn clears. Skills that push on their own are not adapted
|
||||
yet.
|
||||
|
||||
## managed-settings.json (enterprise)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user