diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ad6729..26db705 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,18 +7,19 @@ 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
` 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 read the mode through a new lib verb, `bash ~/.claude/lib/gitflow.sh push-mode`: it prints `auto`, `manual` or `invalid` (rc 0) and names an invalid value on stderr (printable characters only, 64 at most). It is the one reader a skill may call, since the `git config` read of the key is denied to Claude. Skills push nothing on their own, except the `/release-candidate` tag in auto-push mode on an explicit go. Every "on origin" or "not pushed" line they print comes from `git rev-list --count origin/
..
` read after the fact, with the complete `! git …` command when something is left for the user to push. An invalid value is named, but the lib and the git hooks still push on it as auto. Tests: `lib/gitflow-test.sh` T11b (push-mode verb) and T18m block, `lib/tests/unpushed-guard.test.sh` T10-T16, `lib/tests/push-guard.test.sh` (71 checks). +- **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
` 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 or unreadable `gitflow.autopush` value is treated as manual push mode too, and that line names it. `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 reads the mode through the same lib verb as every other reader and fails closed: an unparseable or unreadable value reads as manual, and an internal error, a missing `lib/gitflow.sh`, more than 20 distinct directory tokens in one command (capped before any token is classified), a `cd`/`-C` directory token mixing quoted and unquoted parts, or a payload jq cannot parse whose raw text looks like a push refuse the push (these pathological cases fire in auto mode too). Directory tokens are read as whole shell words, adjacent quoted segments and backslash escapes included. 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, and `🔒 push : manual (autopush bad) — ! git push` when the value is invalid. Skills read the mode through a new lib verb, `bash ~/.claude/lib/gitflow.sh push-mode`: it prints `auto`, `manual` or `invalid` (rc 0) and names an invalid value on stderr (printable characters only, 64 at most). It is the one reader a skill may call, since the `git config` read of the key is denied to Claude. Skills push nothing on their own, except the `/release-candidate` tag in auto-push mode on an explicit go. Every "on origin" or "not pushed" line they print comes from `git rev-list --count origin/
..
` read after the fact, with the complete `! git …` command when something is left for the user to push. An invalid value (anything but unset, true or false, or a read that fails) is manual push mode for every reader and is named where it is read (see Fixed). Tests: `lib/gitflow-test.sh` T11b (push-mode verb), T18m and T18q blocks, `lib/tests/unpushed-guard.test.sh` T10-T16, `lib/tests/push-guard.test.sh` (98 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). -- `/close` (`/capitalize` STEP 5C) no longer runs its own push of develop: `gitflow finish` already pushes develop in auto-push mode (BDR-095). The closing line reports the real state, read after the merge: pushed, manual push mode with the `! git push origin develop` to run, not on origin, push failed, or an invalid `gitflow.autopush` value named. A finish whose merge landed but whose branch delete failed (rc 5/2/6) still reports the push state. +- `/close` (`/capitalize` STEP 5C) no longer runs its own push of develop: `gitflow finish` already pushes develop in auto-push mode (BDR-095). The closing line reports the real state, read after the merge: pushed, manual push mode with the `! git push origin develop` to run, not on origin, push failed, or an invalid `gitflow.autopush` value named and treated as manual push mode. A finish whose merge landed but whose branch delete failed (rc 5/2/6) still reports the push state. - `/client-handover` no longer asks "Push to origin now?" and no longer pushes: the hooks had already pushed in auto-push mode, and push-guard refuses it in manual mode. The agent reads the branch's ahead count after the fix-loop commits, at the deploy pause and before each end report. A pending push is handed to the user as `! git push -u origin ` before the deploy pause, and both reports carry a `Push:` line. A branch name outside `^[A-Za-z0-9._/][A-Za-z0-9._/-]*$` is never interpolated into a command. -- `/release-candidate` STEP 6: in manual push mode, with an invalid mode value, or when main or develop is not on origin, Claude pushes nothing and prints one command for the user, `! git push --atomic origin main develop v`. The tag-push question remains for auto-push mode with both branches on origin. The version must match `^[0-9]+\.[0-9]+\.[0-9]+$` before it enters a command or tag. -- `/tour`: each summary row says `on origin` or `local only` with the `! git -C push -u origin ` to run. The tour never pushes or retries. +- `/release-candidate` STEP 6: in manual push mode, with an invalid mode value, or when main or develop is not on origin, Claude pushes nothing and prints one command for the user, `! git push --atomic origin main develop v`. The tag-push question remains for auto-push mode with both branches on origin. The version must match `^[0-9]+\.[0-9]+\.[0-9]+$` before it enters a command or tag; `release-executor` checks it too and blocks on anything else. +- `/tour`: each summary row says `on origin` or `local only` with the `! git -C "" push -u origin ` to run. The tour never pushes or retries. ### Fixed - `gitflow delete` (and `finish`) land on the base that contains the branch and drop the branch's upstream before `git branch -d`, so a branch whose upstream lags (manual-push mode) is deleted instead of refused by git (T18k). +- An invalid `gitflow.autopush` value (not a boolean, or a config read that fails) no longer pushes. The post-commit / post-merge hooks and every push site of `lib/gitflow.sh` (`start`, `finish`, the `origin/` cleanup of `delete`) read it as auto and pushed; they now push nothing and say why. Each hook run prints `gitflow post-commit: gitflow.autopush unreadable (git rc ) — NOT pushed, treated as manual push mode; fix the value by hand` (post-merge likewise), and the lib passes through the `push-mode` verb's line, `gitflow.sh push-mode: gitflow.autopush='' is not a boolean (git rc )`. A repo with its own committed `.githooks/` (onboarded projects) keeps running its old hooks, which still push on an invalid value, until a session start runs `reconcile-hooks` and rewrites them; commit that refresh so other clones get it. Tests: `lib/gitflow-test.sh` T18q block. ## [2.0.0] — 2026-10-06 diff --git a/skills/gitflow/SKILL.md b/skills/gitflow/SKILL.md index b401393..7976d64 100644 --- a/skills/gitflow/SKILL.md +++ b/skills/gitflow/SKILL.md @@ -57,8 +57,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). A `git push` Claude types is -refused by `hooks/push-guard.sh`; the user pushes with `! git push`. Hand +the `origin/` copy is left in place (T18i-T18k). An invalid value (not a +boolean, or a failed read) is manual push mode too: nothing is pushed and the +stop is named on stderr (T18q). 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. @@ -111,7 +113,8 @@ 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/
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/
left in place (manual push mode)" | Expected in manual-push mode, not a failure. Pass the printed `git push origin --delete
` 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) | +| `delete`/`finish` warning "origin/
left in place (manual push mode)" | Expected in manual-push mode or with an invalid `gitflow.autopush` (the verb's `gitflow.sh push-mode:` line precedes it), not a failure. Pass the printed `git push origin --delete
` 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) | +| Hook stderr "gitflow post-commit: gitflow.autopush unreadable (git rc ) — NOT pushed, treated as manual push mode" (post-merge likewise), or `start`/`finish`/`delete` stderr "gitflow.sh push-mode: gitflow.autopush='' is not a boolean" / "could not read gitflow.autopush" | Fail closed BY CONTRACT: the value is invalid, so nothing was pushed. Report the line to the user, who fixes the value by hand (`git config` on the key is denied to Claude); hand any pending push to the user as `! git push …`. Never retry the push | | `start`/`finish` warning " is behind origin/ 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 diff --git a/templates/settings/SETTINGS.md b/templates/settings/SETTINGS.md index 0066799..7e43059 100644 --- a/templates/settings/SETTINGS.md +++ b/templates/settings/SETTINGS.md @@ -146,7 +146,7 @@ local trace, and the brief had authorized it. What holds now, by tier: | 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, and skills read it through `gitflow.sh push-mode`. | -| 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`. | +| Pushing in manual-push mode (`gitflow.autopush false`, or any invalid value) | `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. | @@ -186,10 +186,12 @@ 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"). +fails closed: an invalid value reads as manual, and a `cd`/`-C` directory +token mixing quoted and unquoted parts, an unparseable payload that looks like +a push, a missing `lib/gitflow.sh` 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 read the mode through `bash ~/.claude/lib/gitflow.sh push-mode` (`auto`, `manual` or `invalid`, @@ -197,8 +199,13 @@ rc 0) and push nothing themselves, except the `/release-candidate` tag in auto-push mode on an explicit go. What they report as on origin or not pushed comes from `git rev-list --count origin/
..
` read afterwards, and a pending push is handed to the user as a complete `! git …` command. -An invalid value is named, but the lib and the git hooks still push on it -as auto; only push-guard fails closed on it. +An invalid value (not a boolean, or a read that fails) is manual push mode +for every reader: the hooks, `start`, `finish` and `delete` push nothing and +say why on stderr, push-guard refuses, the banner shows +`🔒 push : manual (autopush bad)` and the SessionStart line names the value. +Exception: a repo with its own committed `.githooks/` runs its old hooks, +which still push on an invalid value, until a session start refreshes them; +commit the refresh. ## managed-settings.json (enterprise)