diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json
index e0ee1b8..800181a 100644
--- a/.claude-plugin/plugin.json
+++ b/.claude-plugin/plugin.json
@@ -1,7 +1,7 @@
{
"name": "forge-workflow",
"displayName": "Forge",
- "version": "1.0.0",
+ "version": "1.1.0",
"description": "Gated spec-to-ship lifecycle for Claude Code. One command determines where a project stands and does the next thing: requirements discovery to 95 percent confidence, toolchain and repository bootstrap, then test-driven implementation with enforced resumability, documentation drift gates, and tagged releases.",
"author": {
"name": "Dailen Gunter",
diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml
index 1fdd46e..2ecd7b7 100644
--- a/.github/workflows/validate.yml
+++ b/.github/workflows/validate.yml
@@ -19,24 +19,34 @@ jobs:
- name: Manifests parse, hooks references resolve, guide present
run: node .github/scripts/manifest-check.js
- - name: node --check every hook script
+ - name: node --check every hook script and shipped template
run: |
set -e
shopt -s nullglob
found=0
- for f in scripts/*.js; do
+ for f in scripts/*.js templates/*.js; do
found=1
echo "checking $f"
node --check "$f"
done
if [ "$found" -eq 0 ]; then
- echo "no scripts found under scripts/"
+ echo "no scripts found under scripts/ or templates/"
exit 1
fi
- name: Typography check (ASCII only)
run: node .github/scripts/typography-check.js
+ # The protection tests push against disposable bare repositories in a
+ # temp directory, so git needs an identity and nothing leaves the runner.
+ - name: Configure git identity for the disposable-remote tests
+ run: |
+ git config --global user.email "ci@example.invalid"
+ git config --global user.name "Forge CI"
+
+ - name: Tests
+ run: node --test
+
# The Claude Code CLI is installable in CI as an npm package, so plugin
# validation runs as a real gate here. If that ever stops being true,
# replace this step with JSON Schema validation against
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5ace9b0..9cad822 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,13 +7,76 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+## [1.1.0] - 2026-08-02
+
+Capability-based default-branch protection, so a free-tier account with a
+private repository is no longer blocked at bootstrap.
+
+Phase 2 previously required a GitHub ruleset on `main`. GitHub reserves that
+for paid plans on private personal repositories and answers `Upgrade to GitHub
+Pro or make this repository public to enable this feature`, which left a solo
+developer on a free plan with a bootstrap gate they could only pass by paying
+or by publishing a private repository. Neither is an acceptable price for a
+lifecycle gate.
+
+### Added
+
+- `templates/branch-protection.js`: a provider-neutral protection tool.
+ `detect`, `apply`, `verify`, `selftest`, `gate`, `migrate`, `report`, and
+ `status` subcommands, with adapters for GitHub (rulesets, falling back to
+ classic branch protection on older Enterprise Server), GitLab (protected
+ branches), and an explicit fallback for self-hosted or unrecognised hosts. It
+ takes the strongest tier the host and account actually support and records
+ the provider, mechanism, and verification evidence in
+ `.forge/protection.json`.
+- `templates/history-guard.js`: a managed `pre-push` history-integrity guard.
+ It reads the ref-update records git writes to a pre-push hook's stdin and
+ refuses deletion of the protected branch and non-fast-forward updates to it,
+ while allowing fast-forward pushes and initial branch creation. It fails
+ closed, with a message naming the fix, when it cannot see the ref records.
+- `branch-protection.js selftest`, which proves the guard end to end against
+ disposable repositories in a temp directory. Every recursive delete goes
+ through a check that refuses anything that is not a directory the tool itself
+ created under the system temp directory with its own prefix.
+- A test suite under `tests/`, run with `node --test tests/` and wired into CI.
+
### Changed
+- Phase 2's protection step is now capability based. The gate item is
+ "default-branch history protection verified", satisfied by either verified
+ server-side enforcement or verified managed local enforcement with its
+ narrower trust boundary recorded. An unavailable paid hosting feature is no
+ longer a fatal bootstrap failure.
+- `templates/lefthook.yml` runs the history check first, with `use_stdin: true`
+ so lefthook forwards git's ref records to it, and `piped: true` so the secret
+ scan, lint, build, and test commands do not run after it has already refused
+ the push. The command is named `00_history` because lefthook orders commands
+ by priority, then by the leading number in the name, then alphabetically,
+ never by their position in the file.
+- `verify` inspects rather than installs. An earlier form called the installer,
+ which meant a hook the user had deleted was silently recreated and then
+ reported as verified. It now also confirms the hook is somewhere git will
+ actually run it, honouring `core.hooksPath`, and that `lefthook install` has
+ been run rather than trusting `lefthook.yml` alone.
+- The recorded `protections` list reflects what was verified rather than being
+ written unconditionally, so the gate's coverage check is live.
+- `forge-standards` states the protection policy once, behaviourally and
+ without naming a host, alongside its trust boundary.
+- The always-strict repository visibility gate now covers later changes to
+ visibility as well as the initial choice. A hosting feature that is only
+ available on public repositories is never a reason to change it, and the
+ tool refuses to issue a visibility mutation at all.
- The hosted guide gains a "Which edition to install" section (new section 03, later
sections renumbered) explaining the two editions and which Claude generation each
- targets, plus a 1.0.0 update notice and a version stamp in the title block. Readers
- landing on the install section are now told which edition those commands install.
- Guide only, so no version bump.
+ targets, plus a version stamp in the title block. Readers landing on the install
+ section are now told which edition those commands install.
+
+### Migration
+
+A project whose environment phase stalled on a paid-plan ruleset resumes with
+`node .forge/branch-protection.js migrate`, which re-detects provider
+capability, installs and verifies the fallback, and names exactly which
+recorded blocker to clear. Unrelated blockers are preserved.
## [1.0.0] - 2026-07-27
@@ -82,6 +145,7 @@ Initial public release.
- Illustrated user guide hosted on GitHub Pages.
- Marketplace distribution via the `dailen` marketplace, plus a manual skills-directory install path.
-[Unreleased]: https://github.com/DailenG/forge-workflow/compare/v1.0.0...HEAD
+[Unreleased]: https://github.com/DailenG/forge-workflow/compare/v1.1.0...HEAD
+[1.1.0]: https://github.com/DailenG/forge-workflow/compare/v1.0.0...v1.1.0
[1.0.0]: https://github.com/DailenG/forge-workflow/compare/v0.1.0...v1.0.0
[0.1.0]: https://github.com/DailenG/forge-workflow/releases/tag/v0.1.0
diff --git a/CLAUDE.md b/CLAUDE.md
index c0239f7..7055e78 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -64,10 +64,19 @@ skills/ the five skills (forge, forge-spec, forge-env, forge-code
hooks/hooks.json SessionStart, PreToolUse, PostToolUse, Stop wiring
scripts/*.js the four Node hook scripts
templates/ project-file templates forge writes into user projects
+ (branch-protection.js and history-guard.js are real,
+ tested programs, not fill-in-the-blank scaffolds)
+tests/ node:test suites, run with `node --test`
docs/index.html the hosted guide (GitHub Pages, main branch /docs)
.github/ CI workflow, issue and PR templates, CI helper scripts
```
+Most of this repo is prompt text, which cannot be unit tested. Two shipped
+programs are the exception: `templates/branch-protection.js` and
+`templates/history-guard.js` run in the user's project rather than in Claude's
+context, so they have real tests under `tests/`. Changing either without
+running `node --test` is how a guard silently stops guarding.
+
---
## Local development loop
@@ -111,9 +120,15 @@ What is hot vs what needs a reload:
Validate before committing anything:
```powershell
+node --test
claude plugin validate --strict .
```
+The disposable-remote tests create bare repositories under the system temp
+directory and push to them. They never touch a real remote, and every cleanup
+is refused unless the path is a directory the tool itself created with its own
+prefix.
+
---
## Publishing changes to users
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 480ff0c..c4cbc1f 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -4,7 +4,15 @@ Thanks for helping improve Forge. This is a Claude Code plugin (skills plus hook
## Test a change locally before opening a pull request
-1. **Validate the manifests.** From the repository root:
+1. **Run the test suite.** The shipped scripts under `templates/` have real tests. No dependencies, no install step:
+
+ ```
+ node --test
+ ```
+
+ The disposable-remote tests create bare repositories under the system temp directory and push to them. They never touch a real remote, and every cleanup goes through a check that refuses any path that is not a directory the tool itself created with its own prefix. Git needs `user.name` and `user.email` set for them to pass.
+
+2. **Validate the manifests.** From the repository root:
```
claude plugin validate --strict .
@@ -12,7 +20,7 @@ Thanks for helping improve Forge. This is a Claude Code plugin (skills plus hook
This checks `marketplace.json` and `plugin.json`, the skill frontmatter, and `hooks/hooks.json`. It must pass with no errors before you open a pull request.
-2. **Load your working copy.** Point Claude Code at your checkout rather than the published marketplace so you test the exact files you edited. This repository's `marketplace.json` is named `dailen-dev` (the published catalog is the separate `dailen` marketplace), so install from `@dailen-dev`:
+3. **Load your working copy.** Point Claude Code at your checkout rather than the published marketplace so you test the exact files you edited. This repository's `marketplace.json` is named `dailen-dev` (the published catalog is the separate `dailen` marketplace), so install from `@dailen-dev`:
```
claude plugin marketplace add ./
@@ -21,7 +29,7 @@ Thanks for helping improve Forge. This is a Claude Code plugin (skills plus hook
On Linux and macOS, make the hook scripts executable first: `chmod +x scripts/*.js`.
-3. **Reload after hook or script changes.** Edits to `skills/**/SKILL.md` apply on the next turn, but anything under `hooks/` or `scripts/` needs a reload before it takes effect:
+4. **Reload after hook or script changes.** Edits to `skills/**/SKILL.md` apply on the next turn, but anything under `hooks/` or `scripts/` needs a reload before it takes effect:
```
/reload-plugins
@@ -29,7 +37,7 @@ Thanks for helping improve Forge. This is a Claude Code plugin (skills plus hook
A full restart of Claude Code works too. If you only edited a `SKILL.md`, no reload is needed.
-4. **Exercise the path you changed.** Run `/forge` (or the specific phase command) in a scratch project and confirm the behavior. For hook changes, confirm the SessionStart and Stop hooks still fire by watching for the injected `CONTINUE.md` state block.
+5. **Exercise the path you changed.** Run `/forge` (or the specific phase command) in a scratch project and confirm the behavior. For hook changes, confirm the SessionStart and Stop hooks still fire by watching for the injected `CONTINUE.md` state block.
## Bump the version on any behavior change
diff --git a/README.md b/README.md
index 948e49d..580975f 100644
--- a/README.md
+++ b/README.md
@@ -77,7 +77,7 @@ On Linux and macOS, make the hook scripts executable: `chmod +x ~/.claude/skills
## The three phases and their gates
1. **Spec.** Requirements discovery. Claude asks questions in small batches and scores its understanding across ten areas, reporting the lowest score rather than the average. It keeps going until the lowest reaches 95. The phase ends with a written `docs/SRS.md` and a full stop: **Claude will not approve its own spec.** You read it and approve. This gate is always strict.
-2. **Env.** Toolchain and repository bootstrap. It inventories the machine, installs only what is genuinely missing, sets up the repo, testing harness, git hooks, and CI, and proves the test runner actually reports failures rather than silently passing.
+2. **Env.** Toolchain and repository bootstrap. It inventories the machine, installs only what is genuinely missing, sets up the repo, testing harness, git hooks, and CI, and proves the test runner actually reports failures rather than silently passing. Default-branch protection is capability based: server-side enforcement where the host and account provide it, a proven local `pre-push` history guard where they do not. That is why a free-tier account with a private repository is not blocked here, and **a private repository is never made public to satisfy a gate.**
3. **Code.** Test-driven implementation, one short-lived branch per vertical slice. Tests are written first and watched to fail before they pass. A pre-push hook runs build, tests, lint, and a secret scan, and it is the only automated gate before `main`, so `--no-verify` is prohibited. When the milestone backlog empties and the release gates pass, Forge switches to a strict mode and proposes a tagged release.
Default mode is **FLOW** (proceed between slices without asking, report after each). **STRICT** engages automatically as a release comes into reach, and some gates are always strict regardless of mode: SRS approval, repository visibility, anything needing elevation, discarding uncommitted work, tagging or publishing, adding a dependency not named in the SRS, and any discrepancy between the record and the repository.
@@ -90,7 +90,10 @@ Default mode is **FLOW** (proceed between slices without asking, report after ea
| `TODO.md` | Work needed, in progress, completed |
| `docs/SRS.md` | The specification. Living, amended by change log only |
| `docs/DECISIONS.md` | Dated decision record |
-| `docs/ENVIRONMENT.md` | Machine profile, tool versions, manual steps performed |
+| `docs/ENVIRONMENT.md` | Machine profile, tool versions, manual steps performed, and which default-branch protection tier is in force |
+| `.forge/branch-protection.js` | Provider-neutral protection tool: detect, apply, verify, gate, migrate |
+| `.forge/history-guard.js` | Managed `pre-push` guard refusing default-branch deletion and non-fast-forward pushes |
+| `.forge/protection.json` | Recorded provider, tier, mechanism, trust boundary, and verification evidence |
| `docs/traceability.md` | Requirement to test mapping. v1.0.0 cannot be tagged until it is complete |
| `docs/docs-manifest.yml` | Doc page to symbol map, drives the CI drift gate |
| `docs/images/MANIFEST.md` | Screenshot inventory and capture state |
diff --git a/docs/index.html b/docs/index.html
index f9bef61..987f169 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -751,6 +751,33 @@
About that "pre-push check"
force its way past it.
+
+
+
Protecting the main line of work
+
+ The main copy of your project must not be deletable, and must not accept a save that throws
+ away history. Forge sets that up in whichever of two ways your hosting account actually
+ allows.
+
+
+ Best case, the host enforces it. The rule lives on the server, so it applies
+ to everything and everyone, including changes made through the website.
+
+
+ Otherwise, your machine enforces it. Some hosting plans reserve that server
+ setting for paid accounts. GitHub free personal accounts, for example, answer "Upgrade to
+ GitHub Pro or make this repository public to enable this feature" on a private project.
+ Forge does not buy anything and never makes a private project public to get
+ around it. It installs a local guard instead, which refuses a history-destroying save from
+ this computer, and tells you once what that does not cover: a copy of the project on another
+ machine that was never set up, a change made through the website, a guard someone deleted,
+ or somebody who has your password. Then it carries on.
+
+
+ Either way, Forge proves the guard works before trusting it, using throwaway practice copies
+ of a project rather than your real one, and writes down which of the two is in force.
+
+
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..32925bb
--- /dev/null
+++ b/package.json
@@ -0,0 +1,10 @@
+{
+ "name": "forge-workflow",
+ "version": "1.1.0",
+ "private": true,
+ "description": "Development-time test harness for the forge-workflow Claude Code plugin. Not published, and not part of the installed plugin.",
+ "scripts": {
+ "test": "node --test"
+ },
+ "license": "MIT"
+}
diff --git a/skills/forge-env/SKILL.md b/skills/forge-env/SKILL.md
index 67dba51..eab9164 100644
--- a/skills/forge-env/SKILL.md
+++ b/skills/forge-env/SKILL.md
@@ -88,7 +88,7 @@ Only after the toolchain smoke test passes:
- Create the project directory if needed and run `git init`
- Configure `user.name` and `user.email` if unset. Ask for the values
-- Write `.gitignore` **before the first commit**, covering the stack, IDE files, OS files, build output, `.env`, coverage output, and `.codegraph/`. A secret committed once lives in history forever, so this ordering is not negotiable
+- Write `.gitignore` **before the first commit**, covering the stack, IDE files, OS files, build output, `.env`, coverage output, and `.codegraph/`. A secret committed once lives in history forever, so this ordering is not negotiable. Do not ignore `.forge/`: the protection guard has to be committed, or a fresh clone has no guard
- Write `.gitattributes` with sane line ending handling
- Create the directory skeleton the SRS implies, including the test directory layout
- Do not scaffold application code; that is Phase 3
@@ -106,12 +106,57 @@ Only after the toolchain smoke test passes:
Set up the guards that make the branch-per-slice workflow safe, then explain it (see "Explain the workflow" below).
- Install lefthook, or the stack's equivalent hook manager
+- Copy `templates/lefthook.yml` and fill in the stack's real commands
- Configure a **pre-push** hook that runs build, tests, lint, and a secret scan, refusing the push if any fail. This is the only automated gate before code reaches main, since there is no pull request review
- Add gitleaks or equivalent to the pre-push hook
- **Prove the hook blocks.** Make a deliberate failure, attempt a push, and confirm it is refused. A hook that silently does not run is worse than no hook
-- Configure a GitHub ruleset on `main` blocking force pushes and deletions
- Record in `docs/DECISIONS.md` that `--no-verify` is prohibited
+## Step 9a: Default-branch history protection
+
+The policy, stated without reference to any host: **the default branch must not be deletable and must not accept a non-fast-forward update.** Ordinary fast-forward pushes and this workflow's `--no-ff` merges must keep working.
+
+Two mechanisms satisfy that. Take the strongest one this repository and account actually support, and record which.
+
+**Tier 1, server side.** The git host enforces it for every writer. Preferred whenever available.
+
+**Tier 2, managed local.** A pre-push guard enforces the same two rules in every clone configured with it. Used when the host has no such feature, the plan withholds it, or the token lacks the permission.
+
+Tier 2 exists because server-side protection of a private repository is a paid feature on some hosts. GitHub personal free accounts answer `Upgrade to GitHub Pro or make this repository public to enable this feature`. **A paid plan is not a baseline requirement for forge, and a repository is never made public to satisfy a gate.** Repository visibility is only ever what the user chose in Step 8.
+
+Copy `templates/branch-protection.js` and `templates/history-guard.js` into `.forge/` in the project, commit them, then:
+
+```powershell
+node .forge/branch-protection.js detect
+node .forge/branch-protection.js apply
+```
+
+`apply` probes the provider (GitHub, GitLab, self-hosted, or unrecognised), takes tier 1 if the host grants it, otherwise installs and proves tier 2, and writes `.forge/protection.json` with the provider, the mechanism, and the verification evidence. It never issues a visibility change.
+
+With lefthook, three things have to be true, and `templates/lefthook.yml` already does all three:
+
+- The guard declares `use_stdin: true`. That is what forwards git's ref-update records to it; without them the guard fails closed and blocks every push.
+- The guard sorts first. lefthook orders commands by `priority`, then by the leading number in the command name, then alphabetically, **never** by their position in the file, which is why it is named `00_history`.
+- The hook sets `piped: true`, so the build and test commands do not run after the history check has already refused.
+
+`lefthook install` must also have been run, or nothing in `lefthook.yml` executes at all. Confirm all of it with:
+
+```powershell
+node .forge/branch-protection.js verify
+```
+
+Verification uses disposable repositories in a temp directory and proves a fast-forward push is accepted, a protected-branch deletion is refused, a non-fast-forward push is refused, and the quality checks still run. **Never test a destructive push against the project's real remote.**
+
+### Say the limitation once
+
+When tier 2 is selected, tell the user plainly, once, using the reason `apply` actually reported rather than assuming it was the plan:
+
+> This account's plan does not allow server-side branch protection on a private repository. I have installed a local pre-push guard instead, which blocks deleting or rewriting `main` from this clone. It does not stop a push from an unconfigured clone, a change made through the web UI or API, a deleted hook, or someone with your credentials. Making the repository public would enable the server-side version; I have not done that and will not without you asking.
+
+In FLOW mode, proceed. Ask for a decision only when the SRS actually requires server-side enforcement, or when more than one writer has push access. Do not raise it again, and do not repeat the upgrade suggestion.
+
+Record in `docs/DECISIONS.md`: the provider, which tier is in force, the mechanism, why tier 1 was unavailable if it was, that visibility was unchanged, and the trust boundary.
+
## Step 10: Code intelligence layer
The workflow uses a local code-intelligence layer for impact analysis before edits and as the source for architecture documentation. **CodeGraph (colbymchenry) is the default because it is MIT licensed**, not because it is uniquely capable.
@@ -198,6 +243,7 @@ Before finishing, explain the lifecycle to the user in plain language, once. The
- Why each slice gets its own branch, and that a bad slice gets deleted rather than reverted
- What the pre-push hook will do, that it will sometimes block them, and that `--no-verify` is off limits
+- What protects `main` from being deleted or rewritten, which tier is in force here, and what that tier does not cover
- What conventional commit prefixes are for, and that the changelog is generated from them
- What `--no-ff` merges buy: one identifiable unit on main per slice
- What tags and releases mean here, and that tags never move
@@ -216,6 +262,7 @@ Write `docs/ENVIRONMENT.md`:
- Every manual step the user performed, so it can be reproduced
- Toolchain smoke test commands and output, **including the deliberate-failure verification**
- Pre-push hook verification evidence
+- The default-branch protection section, from `node .forge/branch-protection.js report`. It carries the provider, the tier, the mechanism, the trust boundary, and the case-by-case evidence
- CodeGraph verbatim command list and MCP tool list
- Known gaps, workarounds, anything needing revisiting
@@ -223,6 +270,8 @@ Append version choices to `docs/DECISIONS.md`.
## Gate
-Stop once the toolchain smoke test passes in both directions, the GitHub repo exists with an initial commit pushed, the pre-push hook is proven to block, CI is green, CodeGraph is verified, and the state files are committed.
+Stop once the toolchain smoke test passes in both directions, the remote repo exists with an initial commit pushed, the pre-push hook is proven to block, **default-branch history protection is verified at either tier** (`node .forge/branch-protection.js gate` exits 0), CI is green, CodeGraph is verified, and the state files are committed.
+
+A host that withholds server-side protection behind a paid plan is not a failed gate. Verified local enforcement with its trust boundary recorded satisfies it.
Print the summary table. Do not begin implementation. Tell the user to run `/forge` when ready; it will detect that bootstrap is complete and move to the build phase.
diff --git a/skills/forge-standards/SKILL.md b/skills/forge-standards/SKILL.md
index c03e601..b3700e6 100644
--- a/skills/forge-standards/SKILL.md
+++ b/skills/forge-standards/SKILL.md
@@ -29,7 +29,7 @@ STRICT engages automatically regardless of recorded mode when a release is in re
**Always-strict gates, in every mode.** These are the reason the phased design exists. Never pass one autonomously:
- SRS approval
-- Whether the GitHub repository is public or private
+- Whether the remote repository is public or private, including any later change to it
- Any command requiring elevation
- Discarding, stashing, or destroying uncommitted work
- Deleting a branch with unmerged commits
@@ -75,6 +75,15 @@ Branch per slice, commit freely on the branch, merge to main with `--no-ff`, tag
- Never use `--no-verify`. The pre-push hook is the only automated gate before main; if it blocks you, fix what it caught or tell the user why it is blocking.
- Commit the lockfile.
+### Default-branch protection
+
+The requirement is behavioural, not a named product feature: **the default branch must not be deletable and must not accept a non-fast-forward update**, while ordinary fast-forward pushes and `--no-ff` merges keep working. Phase 2 satisfies it at the strongest tier the host and account actually support, and records which.
+
+- **Tier 1, server side.** The host enforces it for every writer, including web UI and API writes. Preferred whenever available.
+- **Tier 2, managed local.** A pre-push guard enforces the same two rules. Its trust boundary is narrower and must be written down where it is used: it protects configured clones only, and not an unconfigured clone, a host API or web UI write, a deleted or edited hook, or an attacker holding valid credentials.
+
+Neither tier may be weakened or skipped, and neither may be traded for the other silently. Some hosts reserve tier 1 for paid plans on private repositories; that is a reason to use tier 2 and say so, never a reason to require a paid plan or to make a private repository public. Repository visibility changes only when the user asks for it.
+
## Secrets and safety
- No secrets in the repository, ever, including test fixtures and example configs. Ship `.env.example` with dummy values.
diff --git a/skills/forge/SKILL.md b/skills/forge/SKILL.md
index 512a795..634b4d1 100644
--- a/skills/forge/SKILL.md
+++ b/skills/forge/SKILL.md
@@ -85,7 +85,26 @@ Walk these in order. Stop at the first match. That is the current phase.
| 9 | Backlog empty, a release gate fails | Release blocked | **STRICT.** Report exactly which gate and what is needed |
| 10 | All requirements closed and released | Complete | Report status, ask what is next |
-The Phase 2 gate (row 4) is met only when all of these hold: toolchain smoke test passed in both directions, GitHub repo exists with an initial commit pushed, the pre-push hook is proven to block, CI has gone green at least once, CodeGraph is verified, and the state files are committed.
+The Phase 2 gate (row 4) is met only when all of these hold: toolchain smoke test passed in both directions, the remote repo exists with an initial commit pushed, the pre-push hook is proven to block, **default-branch history protection verified**, CI has gone green at least once, CodeGraph is verified, and the state files are committed.
+
+**Default-branch history protection verified** is satisfied by either of two things, and `node .forge/branch-protection.js gate` decides which:
+
+- verified server-side enforcement, or
+- verified managed local enforcement, with its narrower trust boundary recorded
+
+A host that reserves branch protection for paid plans is not a fatal bootstrap failure. Forge takes the local fallback, records what it does not cover, and moves on. Never resolve this by making a private repository public.
+
+### Resuming a project blocked on a paid ruleset
+
+Older forge runs treated a GitHub ruleset on `main` as mandatory, so a project on a free personal plan with a private repository could stall at Phase 2 with a blocker it could not clear. When the record shows that, run:
+
+```powershell
+node .forge/branch-protection.js migrate
+```
+
+It re-detects provider capability, installs and verifies the fallback if server-side enforcement is still unavailable, and hands back exactly which recorded blocker to clear. Then, with the Edit tool: clear only that blocker from `CONTINUE.md`, leave every other blocker alone, append the decision to `docs/DECISIONS.md`, refresh the protection section of `docs/ENVIRONMENT.md`, and resume Phase 2 at the first remaining unmet gate item.
+
+If `.forge/branch-protection.js` is not in the project (it predates this), copy it and `history-guard.js` from the plugin's `templates/` first.
## Step 4: Report, then act according to mode
@@ -118,7 +137,7 @@ Once a release is in reach, stay strict through the release and resume FLOW afte
These are the gates the whole design exists to protect. Never pass one autonomously:
- SRS approval, at the Phase 1 to 2 boundary
-- Whether the GitHub repository is public or private
+- Whether the remote repository is public or private. Changing it later is the same gate, and a hosting feature that is only available on public repositories is never a reason to change it
- Any command requiring elevation
- Discarding, stashing, or destroying uncommitted work
- Deleting a branch that has unmerged commits
diff --git a/templates/branch-protection.js b/templates/branch-protection.js
new file mode 100644
index 0000000..5330be1
--- /dev/null
+++ b/templates/branch-protection.js
@@ -0,0 +1,1858 @@
+#!/usr/bin/env node
+"use strict";
+
+/*
+ * Forge default-branch protection, capability based and provider neutral.
+ *
+ * THE POLICY, stated without reference to any host:
+ *
+ * The default branch must not be deletable, and must not accept a
+ * non-fast-forward update. Ordinary fast-forward pushes, including the
+ * --no-ff merge commits this workflow puts on the default branch, must keep
+ * working.
+ *
+ * There are two ways to satisfy that policy, and forge takes the strongest one
+ * the repository and account actually support.
+ *
+ * Tier 1, server side. The git host enforces it for every writer. Preferred
+ * whenever available. Adapters live in the PROVIDERS table below.
+ *
+ * Tier 2, managed local. A pre-push hook (.forge/history-guard.js) enforces
+ * the same two rules in every clone that is configured with it. Used when
+ * the host has no such feature, the account's plan withholds it, or the
+ * token lacks the permission.
+ *
+ * Tier 2 exists because server-side protection of a PRIVATE repository is a
+ * paid feature on some hosts (GitHub personal free accounts answer "Upgrade to
+ * GitHub Pro or make this repository public to enable this feature"), and
+ * neither buying a plan nor publishing a private repository is an acceptable
+ * price for a lifecycle gate. Repository visibility is never changed by this
+ * tool; runProvider below refuses to issue a visibility mutation at all.
+ *
+ * TRUST BOUNDARY of tier 2. It protects configured development clones. It does
+ * not protect against an unconfigured clone, a write through the host's API or
+ * web UI, a hook that was deleted or edited, or an attacker holding valid
+ * credentials. That is a real reduction in guarantee and it is recorded in the
+ * state file and the environment report rather than papered over.
+ *
+ * Subcommands:
+ * detect report provider, repository, and protection capability
+ * apply take the strongest available tier and record it
+ * verify prove the recorded tier is actually in force
+ * selftest prove the local guard works, using disposable repositories only
+ * gate answer the bootstrap gate question, exit 0 when satisfied
+ * migrate re-run detection for a project blocked on a paid-plan ruleset
+ * report emit the docs/ENVIRONMENT.md section for the recorded protection
+ * status print the recorded state
+ *
+ * Add --json to any subcommand for machine-readable output.
+ */
+
+const fs = require("fs");
+const os = require("os");
+const path = require("path");
+const { spawnSync } = require("child_process");
+
+const RULESET_NAME = "forge-default-branch-history";
+const GUARD_FILENAME = "history-guard.js";
+const DEFAULT_GUARD_REL = ".forge/" + GUARD_FILENAME;
+const DEFAULT_STATE_REL = ".forge/protection.json";
+const SELFTEST_PREFIX = "forge-protection-selftest-";
+const STATE_SCHEMA = 1;
+
+const REQUIRED_PROTECTIONS = ["deletion", "non-fast-forward"];
+
+const TRUST_BOUNDARY_LOCAL =
+ "Local enforcement only. The managed pre-push guard protects clones " +
+ "configured with it. It cannot stop a push from an unconfigured clone, a " +
+ "write through the git host API or web UI, a hook that was deleted or " +
+ "edited, or an attacker holding valid credentials.";
+
+const TRUST_BOUNDARY_REMOTE =
+ "Server-side enforcement. The git host applies the rule to every writer, " +
+ "including API and web UI writes, independently of any local clone.";
+
+/* ------------------------------------------------------------------ *
+ * Disposable path handling
+ *
+ * Every recursive delete in this file goes through removeDisposable, which
+ * refuses anything that is not a directory this process created under the
+ * system temp directory with the selftest prefix. No unresolved path, no
+ * workspace root, no home directory, no broad parent.
+ * ------------------------------------------------------------------ */
+
+function tempBase() {
+ return fs.realpathSync(os.tmpdir());
+}
+
+function createDisposableRoot() {
+ return fs.realpathSync(fs.mkdtempSync(path.join(tempBase(), SELFTEST_PREFIX)));
+}
+
+function disposableRejection(target) {
+ if (typeof target !== "string" || target.trim() === "") {
+ return "path is empty";
+ }
+ const base = tempBase();
+ let real;
+ try {
+ real = fs.realpathSync(path.resolve(target));
+ } catch (err) {
+ return "path does not resolve";
+ }
+ if (real === base) return "path is the temp root itself";
+ try {
+ if (real === fs.realpathSync(os.homedir())) return "path is the home directory";
+ } catch (err) {
+ // No resolvable home directory. The checks below still apply.
+ }
+ if (real === fs.realpathSync(process.cwd())) return "path is the working directory";
+ const rel = path.relative(base, real);
+ if (rel === "" || rel.startsWith("..") || path.isAbsolute(rel)) {
+ return "path is outside the system temp directory";
+ }
+ const firstSegment = rel.split(path.sep)[0];
+ if (firstSegment.indexOf(SELFTEST_PREFIX) !== 0) {
+ return "path is not a forge selftest directory (prefix " + SELFTEST_PREFIX + ")";
+ }
+ return null;
+}
+
+function removeDisposable(target) {
+ const rejection = disposableRejection(target);
+ if (rejection !== null) {
+ throw new Error("refusing recursive delete: " + rejection + ": " + target);
+ }
+ fs.rmSync(fs.realpathSync(path.resolve(target)), {
+ recursive: true,
+ force: true,
+ });
+}
+
+/* ------------------------------------------------------------------ *
+ * Remote URL parsing and provider identification
+ * ------------------------------------------------------------------ */
+
+function parseRemoteUrl(url) {
+ if (typeof url !== "string" || url.trim() === "") return null;
+ const raw = url.trim();
+
+ // scp-like: git@host:owner/repo.git
+ let m = /^[A-Za-z0-9._-]+@([^:/]+):(.+)$/.exec(raw);
+ if (m) return normalizeRemote(m[1], m[2]);
+
+ // scheme://[user@]host[:port]/path
+ m = /^[A-Za-z][A-Za-z0-9+.-]*:\/\/(?:[^@/]+@)?([^/:]+)(?::\d+)?\/(.+)$/.exec(raw);
+ if (m) return normalizeRemote(m[1], m[2]);
+
+ // file path or bare local remote
+ return { host: null, slug: null, local: true, url: raw };
+}
+
+function normalizeRemote(host, repoPath) {
+ const cleaned = repoPath.replace(/^\/+/, "").replace(/\.git$/i, "").replace(/\/+$/, "");
+ return {
+ host: host.toLowerCase(),
+ slug: cleaned || null,
+ local: false,
+ url: null,
+ };
+}
+
+function identifyProvider(host) {
+ if (!host) return "none";
+ const h = host.toLowerCase();
+ if (h === "github.com" || h === "www.github.com" || /(^|\.)github\./.test(h)) {
+ return "github";
+ }
+ if (h === "gitlab.com" || /(^|\.)gitlab[.-]/.test(h) || /(^|\.)gitlab\./.test(h)) {
+ return "gitlab";
+ }
+ return "unknown";
+}
+
+/* ------------------------------------------------------------------ *
+ * Failure classification
+ *
+ * A paid-plan refusal, a missing permission, and a host that has no such
+ * feature all end in the same place (tier 2) but must be reported differently,
+ * because only one of them is worth a user decision.
+ * ------------------------------------------------------------------ */
+
+function classifyRemoteFailure(status, stderr) {
+ const text = String(stderr || "");
+ if (/upgrade to github pro/i.test(text) || /make this repository public/i.test(text)) {
+ return {
+ kind: "plan",
+ message:
+ "The host withheld default-branch protection on this repository " +
+ "because of the account plan.",
+ };
+ }
+ if (/upgrade (your |the )?(plan|subscription)/i.test(text) || /premium|ultimate feature/i.test(text)) {
+ return { kind: "plan", message: "The host withheld the feature because of the account plan." };
+ }
+ if (/HTTP 401/i.test(text) || /gh auth login/i.test(text) || /not logged in/i.test(text) || /401 unauthorized/i.test(text)) {
+ return { kind: "auth", message: "Not authenticated to the host." };
+ }
+ if (/HTTP 403/i.test(text) || /must have admin/i.test(text) || /resource not accessible/i.test(text)) {
+ return { kind: "permission", message: "The token lacks permission to configure protection." };
+ }
+ if (status === 127) {
+ return { kind: "tooling", message: "The provider CLI is not installed or not on PATH." };
+ }
+ if (/HTTP 404/i.test(text) || /not found/i.test(text)) {
+ return {
+ kind: "unsupported-api",
+ message: "The host does not expose this protection endpoint.",
+ };
+ }
+ return { kind: "unknown", message: text.trim() || "The host refused without an explanation." };
+}
+
+/* ------------------------------------------------------------------ *
+ * lefthook wiring inspection
+ *
+ * The guard must run BEFORE the expensive quality commands, and lefthook only
+ * forwards git's ref records to a command that declares use_stdin: true. A
+ * guard wired without it receives nothing, fails closed, and blocks every
+ * push; catching that here turns a confusing outage into a clear message.
+ * ------------------------------------------------------------------ */
+
+function parseLefthookPrePush(text) {
+ const lines = String(text || "").split(/\r?\n/);
+ let inPrePush = false;
+ let commandsIndent = null;
+ let commandIndent = null;
+ let current = null;
+ const commands = [];
+ const hook = { parallel: false, piped: false };
+
+ function indentOf(line) {
+ const m = /^(\s*)/.exec(line);
+ return m[1].length;
+ }
+
+ for (const line of lines) {
+ if (/^\s*(#.*)?$/.test(line)) continue;
+ const indent = indentOf(line);
+ const body = line.trim();
+
+ if (indent === 0) {
+ inPrePush = /^pre-push\s*:/.test(body);
+ commandsIndent = null;
+ commandIndent = null;
+ current = null;
+ continue;
+ }
+ if (!inPrePush) continue;
+
+ if (commandsIndent === null) {
+ if (/^parallel\s*:/.test(body)) hook.parallel = /true/i.test(body);
+ if (/^piped\s*:/.test(body)) hook.piped = /true/i.test(body);
+ if (/^commands\s*:/.test(body)) commandsIndent = indent;
+ continue;
+ }
+ if (indent <= commandsIndent) {
+ // Left the commands block while still inside pre-push.
+ commandsIndent = null;
+ commandIndent = null;
+ current = null;
+ if (/^parallel\s*:/.test(body)) hook.parallel = /true/i.test(body);
+ if (/^piped\s*:/.test(body)) hook.piped = /true/i.test(body);
+ if (/^commands\s*:/.test(body)) commandsIndent = indent;
+ continue;
+ }
+ if (commandIndent === null) commandIndent = indent;
+
+ if (indent === commandIndent && /^[A-Za-z0-9_.-]+\s*:\s*$/.test(body)) {
+ current = {
+ key: body.replace(/\s*:\s*$/, ""),
+ run: "",
+ useStdin: false,
+ priority: null,
+ conditional: false,
+ };
+ commands.push(current);
+ continue;
+ }
+ if (current === null) continue;
+
+ // Only direct children of the command key. A nested mapping such as
+ // skip: with its own run: must not overwrite the command's own fields.
+ if (indent !== commandIndent + 2) continue;
+
+ if (/^run\s*:/.test(body)) {
+ current.run = body.replace(/^run\s*:\s*/, "");
+ } else if (/^use_stdin\s*:/.test(body)) {
+ current.useStdin = /true/i.test(body);
+ } else if (/^priority\s*:/.test(body)) {
+ const n = parseInt(body.replace(/^priority\s*:\s*/, ""), 10);
+ current.priority = isNaN(n) ? null : n;
+ } else if (/^(skip|only)\s*:/.test(body)) {
+ current.conditional = true;
+ }
+ }
+
+ return Object.assign(commands, { hook: hook });
+}
+
+/*
+ * lefthook does not run commands in the order they appear in the file. It
+ * sorts by explicit priority when set, then by the leading number in the
+ * command name, then alphabetically. Checking file order would both accept a
+ * guard that actually runs last and reject one that actually runs first.
+ */
+function lefthookExecutionOrder(commands) {
+ return commands
+ .map((c, i) => Object.assign({}, c, { declared: i }))
+ .sort(function (a, b) {
+ const ap = a.priority === null ? Infinity : a.priority;
+ const bp = b.priority === null ? Infinity : b.priority;
+ if (ap !== bp) return ap - bp;
+ const an = /^(\d+)/.exec(a.key);
+ const bn = /^(\d+)/.exec(b.key);
+ if (an && bn && Number(an[1]) !== Number(bn[1])) return Number(an[1]) - Number(bn[1]);
+ if (an && !bn) return -1;
+ if (!an && bn) return 1;
+ if (a.key !== b.key) return a.key < b.key ? -1 : 1;
+ return a.declared - b.declared;
+ });
+}
+
+function checkLefthookWiring(text, guardRef) {
+ const parsed = parseLefthookPrePush(text);
+ const hook = parsed.hook || { parallel: false, piped: false };
+ const ordered = lefthookExecutionOrder(parsed);
+ const needle = guardRef || GUARD_FILENAME;
+ const index = ordered.findIndex((c) => c.run.indexOf(needle) !== -1);
+ if (index === -1) {
+ return {
+ found: false,
+ useStdin: false,
+ runsFirst: false,
+ ok: false,
+ commands: ordered.map((c) => c.key),
+ problem:
+ "no pre-push command runs " +
+ needle +
+ ". The history check must be wired into the hook manager.",
+ };
+ }
+ const cmd = ordered[index];
+ const problems = [];
+ if (!cmd.useStdin) {
+ problems.push(
+ "the '" +
+ cmd.key +
+ "' command is missing use_stdin: true, so lefthook will not " +
+ "forward git's ref-update records to it"
+ );
+ }
+ if (hook.parallel) {
+ problems.push(
+ "the pre-push hook sets parallel: true, which leaves the order of the " +
+ "history check relative to the quality checks undefined"
+ );
+ }
+ if (index !== 0) {
+ problems.push(
+ "lefthook runs '" +
+ cmd.key +
+ "' after " +
+ index +
+ " other command(s) (it orders by priority, then by the leading number " +
+ "in the name, then alphabetically); the history check must run before " +
+ "the expensive quality checks"
+ );
+ }
+ if (!hook.piped) {
+ problems.push(
+ "the pre-push hook is missing piped: true, so the quality checks still " +
+ "run after the history check has already refused the push"
+ );
+ }
+ if (cmd.conditional) {
+ problems.push(
+ "the '" + cmd.key + "' command carries a skip or only condition, so it " +
+ "will not always run"
+ );
+ }
+ return {
+ found: true,
+ key: cmd.key,
+ useStdin: cmd.useStdin,
+ runsFirst: index === 0,
+ piped: hook.piped,
+ parallel: hook.parallel,
+ ok: problems.length === 0,
+ commands: ordered.map((c) => c.key),
+ problem: problems.length === 0 ? null : problems.join("; "),
+ };
+}
+
+const LEFTHOOK_SNIPPET = [
+ "pre-push:",
+ " parallel: false",
+ " piped: true",
+ " commands:",
+ " 00_history:",
+ " # The leading 00 is what puts this first: lefthook orders by priority,",
+ " # then by the leading number in the name, not by position in the file.",
+ " # use_stdin is what feeds it git's ref records.",
+ " use_stdin: true",
+ " run: node .forge/history-guard.js",
+ ' fail_text: "Refused: this push would delete or rewrite default-branch history."',
+].join("\n");
+
+const PLAIN_HOOK_MARKER = "forge managed history-integrity guard";
+
+function plainHookBody(guardRel) {
+ return [
+ "#!/bin/sh",
+ "# " + PLAIN_HOOK_MARKER,
+ "# git writes one ref-update record per line to this hook's stdin. Keep the",
+ "# guard first so a history-destroying push is refused before anything",
+ "# expensive runs. Do not consume stdin before it.",
+ 'node "' + guardRel + '" "$@" || exit $?',
+ "exit 0",
+ "",
+ ].join("\n");
+}
+
+/* ------------------------------------------------------------------ *
+ * Visibility mutation interlock
+ *
+ * Making a private repository public would "solve" a paid-plan refusal, and
+ * that trade is never forge's to make. Rather than trusting every code path
+ * not to do it, the single choke point through which provider calls run
+ * refuses to issue one at all.
+ * ------------------------------------------------------------------ */
+
+const VISIBILITY_MUTATIONS = [
+ /--visibility\b/i,
+ /\brepo\s+edit\b[\s\S]*--(public|private|internal)\b/i,
+ /"(visibility|private)"\s*:/i,
+ /(^|[\s&?=])(visibility|private)=/i,
+];
+
+/*
+ * Deliberately unconditional. An earlier version skipped the check for calls
+ * it believed were GETs, which was wrong twice over: gh accepts -X as well as
+ * --method, and gh silently upgrades to POST or PATCH whenever a body is
+ * present. Both gaps let a visibility mutation through. No read this tool
+ * issues contains any of these patterns, so there is nothing to gain from a
+ * fast path and a real hole in having one.
+ */
+function isVisibilityMutation(bin, args, input) {
+ const argv = Array.isArray(args) ? args : [];
+ const probe = [bin].concat(argv).join(" ") + " " + String(input || "");
+ return VISIBILITY_MUTATIONS.some((re) => re.test(probe));
+}
+
+/* ------------------------------------------------------------------ *
+ * The tool
+ * ------------------------------------------------------------------ */
+
+function createTool(options) {
+ const opts = options || {};
+ const cwd = opts.cwd || process.cwd();
+ const env = opts.env || process.env;
+ const guardRel = opts.guardRel || DEFAULT_GUARD_REL;
+ const stateRel = opts.stateRel || DEFAULT_STATE_REL;
+ const now = opts.now || (() => new Date().toISOString());
+
+ // Provider and context calls are injectable so a test can model a host
+ // without a network. The selftest deliberately is not: its whole purpose is
+ // to observe what real git really does with a real hook, and a stub there
+ // would prove nothing.
+ const run = opts.run || defaultRun;
+ const realRun = defaultRun;
+
+ function defaultRun(bin, args, runOpts) {
+ const o = runOpts || {};
+ const candidates =
+ process.platform === "win32" ? [bin, bin + ".exe", bin + ".cmd"] : [bin];
+ let last = null;
+ for (const candidate of candidates) {
+ const res = spawnSync(candidate, args, {
+ cwd: o.cwd || cwd,
+ encoding: "utf8",
+ input: o.input,
+ env: o.env || env,
+ windowsHide: true,
+ });
+ if (res.error && res.error.code === "ENOENT") {
+ last = { status: 127, stdout: "", stderr: String(res.error.message) };
+ continue;
+ }
+ if (res.error) {
+ return { status: 127, stdout: "", stderr: String(res.error.message) };
+ }
+ return {
+ status: res.status === null ? 1 : res.status,
+ stdout: String(res.stdout || ""),
+ stderr: String(res.stderr || ""),
+ };
+ }
+ return last || { status: 127, stdout: "", stderr: bin + " not found" };
+ }
+
+ /*
+ * Every provider call funnels through here, so a visibility mutation is
+ * blocked at the point of execution rather than merely avoided by
+ * convention. See isVisibilityMutation at module scope.
+ */
+ function runProvider(bin, args, runOpts) {
+ if (isVisibilityMutation(bin, args, runOpts && runOpts.input)) {
+ throw new Error(
+ "blocked: this call would change repository visibility. Forge never " +
+ "changes visibility to satisfy a protection gate."
+ );
+ }
+ return run(bin, args, runOpts);
+ }
+
+ function git(args, runOpts) {
+ return run("git", args, runOpts);
+ }
+
+ function gitValue(args) {
+ const res = git(args);
+ if (res.status !== 0) return null;
+ const value = res.stdout.trim();
+ return value === "" ? null : value;
+ }
+
+ function abs(rel) {
+ return path.resolve(cwd, rel);
+ }
+
+ function readJson(file) {
+ try {
+ return JSON.parse(fs.readFileSync(file, "utf8"));
+ } catch (err) {
+ return null;
+ }
+ }
+
+ /* ---------------- state ---------------- */
+
+ function readState() {
+ return readJson(abs(stateRel));
+ }
+
+ function writeState(state) {
+ const file = abs(stateRel);
+ fs.mkdirSync(path.dirname(file), { recursive: true });
+ fs.writeFileSync(file, JSON.stringify(state, null, 2) + "\n", "utf8");
+ return file;
+ }
+
+ /* ---------------- detection ---------------- */
+
+ function localDefaultBranch() {
+ const remoteHead = gitValue([
+ "symbolic-ref",
+ "--short",
+ "refs/remotes/origin/HEAD",
+ ]);
+ if (remoteHead) return remoteHead.replace(/^origin\//, "");
+ const current = gitValue(["branch", "--show-current"]);
+ if (current) return current;
+ const configured = gitValue(["config", "--get", "init.defaultBranch"]);
+ return configured || "main";
+ }
+
+ function detect(detectOpts) {
+ const remoteName = (detectOpts && detectOpts.remote) || "origin";
+ const url = gitValue(["remote", "get-url", remoteName]);
+ const parsed = parseRemoteUrl(url);
+ const host = parsed ? parsed.host : null;
+ const provider = url === null ? "none" : identifyProvider(host);
+
+ const base = {
+ provider: provider,
+ host: host,
+ remote: remoteName,
+ remoteUrl: url,
+ repository: parsed ? parsed.slug : null,
+ defaultBranch: localDefaultBranch(),
+ visibility: null,
+ serverSide: "no",
+ mechanism: null,
+ reason: null,
+ cli: null,
+ };
+
+ const adapter = ADAPTERS[provider] || ADAPTERS.unknown;
+ return adapter.probe(base);
+ }
+
+ /* ---------------- GitHub adapter ---------------- */
+
+ function ghJson(args) {
+ const res = runProvider("gh", args);
+ if (res.status !== 0) {
+ return { ok: false, res: res, failure: classifyRemoteFailure(res.status, res.stderr || res.stdout) };
+ }
+ try {
+ return { ok: true, res: res, json: JSON.parse(res.stdout) };
+ } catch (err) {
+ return {
+ ok: false,
+ res: res,
+ failure: { kind: "unknown", message: "provider returned unparsable JSON" },
+ };
+ }
+ }
+
+ const ADAPTERS = {
+ github: {
+ probe: function (base) {
+ const out = Object.assign({}, base, { cli: "gh" });
+ const version = runProvider("gh", ["--version"]);
+ if (version.status !== 0) {
+ out.serverSide = "unknown";
+ out.reason = "the gh CLI is not installed, so GitHub capability cannot be determined";
+ return out;
+ }
+ if (!out.repository) {
+ out.serverSide = "unknown";
+ out.reason = "the remote URL does not name an owner/repo pair";
+ return out;
+ }
+ const repo = ghJson(["api", "repos/" + out.repository]);
+ if (!repo.ok) {
+ out.serverSide = repo.failure.kind === "permission" || repo.failure.kind === "auth" ? "no" : "unknown";
+ out.reason = repo.failure.message;
+ out.failureKind = repo.failure.kind;
+ return out;
+ }
+ const json = repo.json || {};
+ out.visibility = json.private ? "private" : "public";
+ out.defaultBranch = json.default_branch || out.defaultBranch;
+ out.ownerType = json.owner && json.owner.type ? json.owner.type : null;
+ out.mechanism = "github-ruleset";
+
+ const admin = json.permissions && json.permissions.admin === true;
+ if (json.permissions && !admin) {
+ out.serverSide = "no";
+ out.failureKind = "permission";
+ out.reason = "the authenticated account is not an admin of this repository";
+ return out;
+ }
+
+ // A private repository on a personal free plan is the known refusal.
+ // It is a hint, not a verdict: apply() still asks the host.
+ if (out.visibility === "private" && out.ownerType === "User") {
+ const user = ghJson(["api", "user"]);
+ const plan = user.ok && user.json && user.json.plan ? user.json.plan.name : null;
+ out.planHint = plan;
+ if (plan && /^free$/i.test(plan)) {
+ out.serverSide = "unlikely";
+ out.reason =
+ "private repository on a personal free plan; GitHub reserves " +
+ "rulesets and protected branches for paid plans there";
+ return out;
+ }
+ }
+ out.serverSide = "likely";
+ out.reason = "GitHub rulesets endpoint is available to this account";
+ return out;
+ },
+
+ apply: function (capability) {
+ const slug = capability.repository;
+ const payload = JSON.stringify({
+ name: RULESET_NAME,
+ target: "branch",
+ enforcement: "active",
+ conditions: { ref_name: { include: ["~DEFAULT_BRANCH"], exclude: [] } },
+ rules: [{ type: "deletion" }, { type: "non_fast_forward" }],
+ });
+
+ // includes_parents=false: an org or enterprise ruleset that happens to
+ // share this name is not ours, and treating it as ours would make the
+ // follow-up read of repos/{slug}/rulesets/{id} 404 and send us down
+ // the classic-protection path on a false premise.
+ const existing = ghJson([
+ "api",
+ "--paginate",
+ "repos/" + slug + "/rulesets?includes_parents=false",
+ ]);
+ let existingId = null;
+ if (existing.ok && Array.isArray(existing.json)) {
+ const match = existing.json.find((r) => r && r.name === RULESET_NAME);
+ if (match) existingId = match.id;
+ }
+
+ const args = existingId
+ ? ["api", "--method", "PUT", "repos/" + slug + "/rulesets/" + existingId, "--input", "-"]
+ : ["api", "--method", "POST", "repos/" + slug + "/rulesets", "--input", "-"];
+ const res = runProvider("gh", args, { input: payload });
+
+ if (res.status === 0) {
+ let id = existingId;
+ try {
+ const created = JSON.parse(res.stdout);
+ if (created && created.id) id = created.id;
+ } catch (err) {
+ // Keep the id we already had; verify reads by name if it is null.
+ }
+ return { applied: true, mechanism: "github-ruleset", rulesetId: id };
+ }
+
+ const failure = classifyRemoteFailure(res.status, res.stderr || res.stdout);
+ if (failure.kind !== "unsupported-api") {
+ return { applied: false, failure: failure, mechanism: "github-ruleset" };
+ }
+
+ // Older GitHub Enterprise Server has no rulesets. Try the classic
+ // branch protection endpoint before giving up on tier 1.
+ const legacy = runProvider(
+ "gh",
+ [
+ "api",
+ "--method",
+ "PUT",
+ "repos/" + slug + "/branches/" + encodeURIComponent(capability.defaultBranch) + "/protection",
+ "--input",
+ "-",
+ ],
+ {
+ input: JSON.stringify({
+ required_status_checks: null,
+ enforce_admins: true,
+ required_pull_request_reviews: null,
+ restrictions: null,
+ allow_force_pushes: false,
+ allow_deletions: false,
+ }),
+ }
+ );
+ if (legacy.status === 0) {
+ return { applied: true, mechanism: "github-branch-protection" };
+ }
+ return {
+ applied: false,
+ failure: classifyRemoteFailure(legacy.status, legacy.stderr || legacy.stdout),
+ mechanism: "github-branch-protection",
+ };
+ },
+
+ verify: function (capability, applied) {
+ const slug = capability.repository;
+ if (applied.mechanism === "github-branch-protection") {
+ const res = ghJson([
+ "api",
+ "repos/" + slug + "/branches/" + encodeURIComponent(capability.defaultBranch) + "/protection",
+ ]);
+ if (!res.ok) return { verified: false, detail: res.failure.message };
+ const j = res.json || {};
+ const deletionBlocked = j.allow_deletions && j.allow_deletions.enabled === false;
+ const forceBlocked = j.allow_force_pushes && j.allow_force_pushes.enabled === false;
+ return {
+ verified: Boolean(deletionBlocked && forceBlocked),
+ detail:
+ "branch protection: allow_deletions=" +
+ String(j.allow_deletions && j.allow_deletions.enabled) +
+ " allow_force_pushes=" +
+ String(j.allow_force_pushes && j.allow_force_pushes.enabled),
+ };
+ }
+
+ const res = ghJson([
+ "api",
+ "--paginate",
+ "repos/" + slug + "/rulesets?includes_parents=false",
+ ]);
+ if (!res.ok) return { verified: false, detail: res.failure.message };
+ const list = Array.isArray(res.json) ? res.json : [];
+ const summary = list.find((r) => r && r.name === RULESET_NAME);
+ if (!summary) {
+ return { verified: false, detail: "no ruleset named " + RULESET_NAME + " on the repository" };
+ }
+ const full = ghJson(["api", "repos/" + slug + "/rulesets/" + summary.id]);
+ if (!full.ok) return { verified: false, detail: full.failure.message };
+ const j = full.json || {};
+ const types = (j.rules || []).map((r) => r && r.type);
+ const active = String(j.enforcement || "").toLowerCase() === "active";
+ const hasDeletion = types.indexOf("deletion") !== -1;
+ const hasNonFf = types.indexOf("non_fast_forward") !== -1;
+ return {
+ verified: Boolean(active && hasDeletion && hasNonFf),
+ detail:
+ "ruleset " +
+ summary.id +
+ " enforcement=" +
+ String(j.enforcement) +
+ " rules=[" +
+ types.join(", ") +
+ "]",
+ };
+ },
+ },
+
+ gitlab: {
+ probe: function (base) {
+ const out = Object.assign({}, base, { cli: "glab", mechanism: "gitlab-protected-branch" });
+ const version = runProvider("glab", ["--version"]);
+ if (version.status !== 0) {
+ out.serverSide = "unknown";
+ out.reason = "the glab CLI is not installed, so GitLab capability cannot be determined";
+ return out;
+ }
+ if (!out.repository) {
+ out.serverSide = "unknown";
+ out.reason = "the remote URL does not name a project path";
+ return out;
+ }
+ const project = ghJsonLike("glab", ["api", "projects/" + encodeURIComponent(out.repository)]);
+ if (!project.ok) {
+ out.serverSide = "unknown";
+ out.reason = project.failure.message;
+ out.failureKind = project.failure.kind;
+ return out;
+ }
+ const j = project.json || {};
+ out.visibility = j.visibility || null;
+ out.defaultBranch = j.default_branch || out.defaultBranch;
+ out.serverSide = "likely";
+ out.reason = "GitLab protected branches are available on this project";
+ return out;
+ },
+
+ apply: function (capability) {
+ const project = encodeURIComponent(capability.repository);
+ const branch = encodeURIComponent(capability.defaultBranch);
+ const entry = "projects/" + project + "/protected_branches/" + branch;
+
+ // Explicit access levels. Left unset, GitLab defaults push and merge to
+ // Maintainer, which would stop every Developer pushing to the default
+ // branch at all. The policy is about deletion and force pushes, not
+ // about who may push, so 30 (Developer) preserves ordinary pushes.
+ const settings =
+ "?name=" +
+ branch +
+ "&allow_force_push=false&push_access_level=30&merge_access_level=30";
+
+ // Already protected is not a failure. GitLab answers 409 on a repeat
+ // POST, and treating that as "server-side unavailable" would downgrade
+ // a correctly protected project to tier 2 and record a false reason.
+ const existing = ghJsonLike("glab", ["api", entry]);
+ if (existing.ok) {
+ const j = existing.json || {};
+ if (j.allow_force_push === false) {
+ return { applied: true, mechanism: "gitlab-protected-branch", preexisting: true };
+ }
+ const patched = runProvider("glab", [
+ "api",
+ "--method",
+ "PATCH",
+ entry + "?allow_force_push=false",
+ ]);
+ if (patched.status === 0) {
+ return { applied: true, mechanism: "gitlab-protected-branch" };
+ }
+ return {
+ applied: false,
+ mechanism: "gitlab-protected-branch",
+ failure: classifyRemoteFailure(patched.status, patched.stderr || patched.stdout),
+ };
+ }
+
+ const res = runProvider("glab", [
+ "api",
+ "--method",
+ "POST",
+ "projects/" + project + "/protected_branches" + settings,
+ ]);
+ if (res.status === 0) {
+ return { applied: true, mechanism: "gitlab-protected-branch" };
+ }
+ return {
+ applied: false,
+ mechanism: "gitlab-protected-branch",
+ failure: classifyRemoteFailure(res.status, res.stderr || res.stdout),
+ };
+ },
+
+ verify: function (capability) {
+ const project = encodeURIComponent(capability.repository);
+ const res = ghJsonLike("glab", [
+ "api",
+ "projects/" + project + "/protected_branches/" + encodeURIComponent(capability.defaultBranch),
+ ]);
+ if (!res.ok) return { verified: false, detail: res.failure.message };
+ const j = res.json || {};
+
+ // allow_force_push already defaults to false, so asserting only that
+ // would pass for any protected-branch entry created by anyone. Require
+ // the entry to name this branch, to block force pushes, and to leave
+ // pushing possible, which is what deletion protection on GitLab rides
+ // on: a protected branch cannot be deleted.
+ const named = j.name === capability.defaultBranch;
+ const forceBlocked = j.allow_force_push === false;
+ const pushable =
+ Array.isArray(j.push_access_levels) && j.push_access_levels.length > 0;
+ return {
+ verified: Boolean(named && forceBlocked && pushable),
+ detail:
+ "protected branch " +
+ String(j.name) +
+ " allow_force_push=" +
+ String(j.allow_force_push) +
+ " push_access_levels=" +
+ (Array.isArray(j.push_access_levels)
+ ? j.push_access_levels.map((a) => a && a.access_level).join(",")
+ : "none"),
+ };
+ },
+ },
+
+ unknown: {
+ probe: function (base) {
+ const out = Object.assign({}, base);
+ out.serverSide = "no";
+ out.failureKind = "unsupported";
+ out.reason =
+ base.provider === "none"
+ ? "no git remote is configured, so there is no server to enforce protection"
+ : "no adapter recognises the host " +
+ String(base.host) +
+ ", so server-side capability is unknown and cannot be relied on";
+ return out;
+ },
+ apply: function () {
+ return {
+ applied: false,
+ mechanism: null,
+ failure: {
+ kind: "unsupported",
+ message: "no server-side protection adapter for this provider",
+ },
+ };
+ },
+ verify: function () {
+ return { verified: false, detail: "no server-side mechanism to verify" };
+ },
+ },
+ };
+
+ ADAPTERS.none = ADAPTERS.unknown;
+
+ function ghJsonLike(bin, args) {
+ const res = runProvider(bin, args);
+ if (res.status !== 0) {
+ return { ok: false, res: res, failure: classifyRemoteFailure(res.status, res.stderr || res.stdout) };
+ }
+ try {
+ return { ok: true, res: res, json: JSON.parse(res.stdout) };
+ } catch (err) {
+ return { ok: false, res: res, failure: { kind: "unknown", message: "provider returned unparsable JSON" } };
+ }
+ }
+
+ function adapterFor(provider) {
+ return ADAPTERS[provider] || ADAPTERS.unknown;
+ }
+
+ /* ---------------- tier 2 installation ---------------- */
+
+ function hookManager() {
+ for (const name of ["lefthook.yml", "lefthook.yaml", ".lefthook.yml", ".lefthook.yaml"]) {
+ const file = abs(name);
+ if (fs.existsSync(file)) return { manager: "lefthook", file: file, name: name };
+ }
+ return { manager: "none", file: null, name: null };
+ }
+
+ /*
+ * Where git will actually look for hooks. core.hooksPath moves it, and a
+ * hook written to .git/hooks when core.hooksPath points elsewhere is a file
+ * git never runs. Checking this is the difference between "a hook exists"
+ * and "a hook will fire".
+ */
+ function hooksDir() {
+ const configured = gitValue(["config", "--get", "core.hooksPath"]);
+ if (configured) return path.resolve(cwd, configured);
+ return path.join(gitDir(), "hooks");
+ }
+
+ /*
+ * Read-only. Reports whether the guard is wired and live, without writing
+ * anything. verify() uses this rather than installLocal, because an install
+ * that repairs what it is checking can never observe a deleted hook.
+ */
+ function inspectLocal() {
+ const guardFile = abs(guardRel);
+ if (!fs.existsSync(guardFile)) {
+ return {
+ installed: false,
+ manager: null,
+ problem:
+ "the managed guard is missing at " +
+ guardRel +
+ ". Copy templates/history-guard.js there before wiring the hook.",
+ };
+ }
+
+ const hookPath = path.join(hooksDir(), "pre-push");
+ const hookExists = fs.existsSync(hookPath);
+ const hookBody = hookExists ? fs.readFileSync(hookPath, "utf8") : "";
+
+ const manager = hookManager();
+ if (manager.manager === "lefthook") {
+ const wiring = checkLefthookWiring(fs.readFileSync(manager.file, "utf8"), GUARD_FILENAME);
+ const dispatches = hookExists && /lefthook/i.test(hookBody);
+ const problems = [];
+ if (!wiring.ok) problems.push(wiring.problem);
+ if (!dispatches) {
+ problems.push(
+ "no pre-push hook at " +
+ hookPath +
+ " dispatches to lefthook, so nothing in lefthook.yml runs. Run " +
+ "lefthook install"
+ );
+ }
+ return {
+ installed: problems.length === 0,
+ manager: "lefthook",
+ managerFile: manager.name,
+ hookPath: hookPath,
+ wiring: wiring,
+ problem: problems.length === 0 ? null : problems.join("; "),
+ requiredSnippet: wiring.ok ? null : LEFTHOOK_SNIPPET,
+ };
+ }
+
+ if (!hookExists) {
+ return {
+ installed: false,
+ manager: "git",
+ hookPath: hookPath,
+ problem: "no pre-push hook at " + hookPath,
+ requiredSnippet: plainHookBody(guardRel),
+ };
+ }
+ if (hookBody.indexOf(PLAIN_HOOK_MARKER) === -1) {
+ return {
+ installed: false,
+ manager: "git",
+ hookPath: hookPath,
+ problem:
+ "the pre-push hook at " +
+ hookPath +
+ " was not written by forge and does not reference the guard",
+ requiredSnippet: plainHookBody(guardRel),
+ };
+ }
+ return { installed: true, manager: "git", hookPath: hookPath, problem: null };
+ }
+
+ function installLocal() {
+ const guardFile = abs(guardRel);
+ if (!fs.existsSync(guardFile)) {
+ return {
+ installed: false,
+ problem:
+ "the managed guard is missing at " +
+ guardRel +
+ ". Copy templates/history-guard.js there before wiring the hook.",
+ };
+ }
+
+ const manager = hookManager();
+ if (manager.manager === "lefthook") {
+ // Never rewrite someone's lefthook.yml. Report what it needs.
+ return inspectLocal();
+ }
+
+ const hookPath = path.join(hooksDir(), "pre-push");
+ if (fs.existsSync(hookPath)) {
+ const existing = fs.readFileSync(hookPath, "utf8");
+ if (existing.indexOf(PLAIN_HOOK_MARKER) === -1) {
+ return {
+ installed: false,
+ manager: "git",
+ problem:
+ "a pre-push hook already exists at " +
+ hookPath +
+ " and was not written by forge. Merge the guard into it by hand " +
+ "rather than losing whatever it does.",
+ requiredSnippet: plainHookBody(guardRel),
+ };
+ }
+ }
+ fs.mkdirSync(path.dirname(hookPath), { recursive: true });
+ fs.writeFileSync(hookPath, plainHookBody(guardRel), "utf8");
+ try {
+ fs.chmodSync(hookPath, 0o755);
+ } catch (err) {
+ // Windows filesystems without POSIX modes. git runs the hook regardless.
+ }
+ return { installed: true, manager: "git", hookPath: hookPath, problem: null };
+ }
+
+ function gitDir() {
+ const dir = gitValue(["rev-parse", "--git-dir"]);
+ if (!dir) return path.join(cwd, ".git");
+ return path.isAbsolute(dir) ? dir : path.resolve(cwd, dir);
+ }
+
+ /* ---------------- selftest ---------------- *
+ *
+ * Proves the guard on throwaway repositories. Nothing here touches the
+ * project's own remote: a bare repository in the system temp directory
+ * stands in for the server, and both it and the working clone are removed
+ * through removeDisposable when the run ends.
+ */
+
+ function selftest() {
+ const guardFile = abs(guardRel);
+ if (!fs.existsSync(guardFile)) {
+ return {
+ ok: false,
+ evidence: [],
+ problem: "the managed guard is missing at " + guardRel,
+ };
+ }
+
+ const root = createDisposableRoot();
+ const evidence = [];
+ try {
+ const remoteDir = path.join(root, "remote.git");
+ const workDir = path.join(root, "work");
+ const markerFile = path.join(root, "quality-ran.log");
+
+ sh("git", ["-c", "init.defaultBranch=main", "init", "--bare", remoteDir], root);
+
+ // Park the bare remote's HEAD off main. git itself refuses to delete the
+ // branch HEAD points at, and that refusal would mask a guard that did
+ // nothing: the delete case has to fail because of the hook, not because
+ // of the server.
+ sh("git", ["symbolic-ref", "HEAD", "refs/heads/forge-selftest-parking"], remoteDir);
+
+ sh("git", ["-c", "init.defaultBranch=main", "init", workDir], root);
+ sh("git", ["config", "user.email", "forge-selftest@example.invalid"], workDir);
+ sh("git", ["config", "user.name", "Forge Selftest"], workDir);
+ sh("git", ["config", "commit.gpgsign", "false"], workDir);
+ sh("git", ["config", "core.hooksPath", ".git/hooks"], workDir);
+ sh("git", ["remote", "add", "origin", remoteDir], workDir);
+
+ fs.mkdirSync(path.join(workDir, ".forge"), { recursive: true });
+ fs.copyFileSync(guardFile, path.join(workDir, ".forge", GUARD_FILENAME));
+
+ // The guard reads the protected branch from this file first, exactly as
+ // it does in a real project.
+ fs.writeFileSync(
+ path.join(workDir, ".forge", "protection.json"),
+ JSON.stringify({ schema: STATE_SCHEMA, defaultBranch: "main" }, null, 2) + "\n",
+ "utf8"
+ );
+
+ // Stands in for the expensive quality commands. It must run on an
+ // allowed push and must NOT run when the guard refuses, which is what
+ // proves the ordering.
+ fs.writeFileSync(
+ path.join(workDir, ".forge", "quality-check.js"),
+ [
+ '"use strict";',
+ 'require("fs").appendFileSync(' + JSON.stringify(markerFile) + ', "ran\\n");',
+ "process.exit(0);",
+ "",
+ ].join("\n"),
+ "utf8"
+ );
+
+ const hookDir = path.join(workDir, ".git", "hooks");
+ fs.mkdirSync(hookDir, { recursive: true });
+ const hookFile = path.join(hookDir, "pre-push");
+ fs.writeFileSync(
+ hookFile,
+ [
+ "#!/bin/sh",
+ "# " + PLAIN_HOOK_MARKER,
+ 'node ".forge/history-guard.js" "$@" || exit $?',
+ 'node ".forge/quality-check.js" || exit $?',
+ "exit 0",
+ "",
+ ].join("\n"),
+ "utf8"
+ );
+ try {
+ fs.chmodSync(hookFile, 0o755);
+ } catch (err) {
+ // No POSIX modes here. git still executes the hook.
+ }
+
+ function markerCount() {
+ try {
+ return fs.readFileSync(markerFile, "utf8").split("\n").filter(Boolean).length;
+ } catch (err) {
+ return 0;
+ }
+ }
+
+ function commit(name, body) {
+ fs.writeFileSync(path.join(workDir, name), body, "utf8");
+ sh("git", ["add", "-A"], workDir);
+ sh("git", ["commit", "-m", "chore: " + name], workDir);
+ return sh("git", ["rev-parse", "HEAD"], workDir).stdout.trim();
+ }
+
+ const oidA = commit("a.txt", "a\n");
+ const push1 = realRun("git", ["push", "origin", "main"], { cwd: workDir });
+ record(evidence, "initial branch creation", "accepted", push1.status === 0, push1);
+ record(
+ evidence,
+ "quality checks run on an accepted push",
+ "1 run",
+ markerCount() === 1,
+ null,
+ markerCount() + " run(s)"
+ );
+
+ const oidB = commit("b.txt", "b\n");
+ const push2 = realRun("git", ["push", "origin", "main"], { cwd: workDir });
+ record(evidence, "fast-forward push", "accepted", push2.status === 0, push2);
+ record(
+ evidence,
+ "quality checks run again",
+ "2 runs",
+ markerCount() === 2,
+ null,
+ markerCount() + " run(s)"
+ );
+
+ const del = realRun("git", ["push", "origin", "--delete", "main"], { cwd: workDir });
+ record(evidence, "protected branch deletion", "refused, non-zero exit", del.status !== 0, del);
+ record(
+ evidence,
+ "quality checks skipped when the guard refuses a deletion",
+ "still 2 runs",
+ markerCount() === 2,
+ null,
+ markerCount() + " run(s)"
+ );
+
+ sh("git", ["reset", "--hard", oidA], workDir);
+ const oidC = commit("c.txt", "c\n");
+ const force = realRun("git", ["push", "--force", "origin", "main"], { cwd: workDir });
+ record(evidence, "non-fast-forward push", "refused, non-zero exit", force.status !== 0, force);
+ record(
+ evidence,
+ "quality checks skipped when the guard refuses a rewrite",
+ "still 2 runs",
+ markerCount() === 2,
+ null,
+ markerCount() + " run(s)"
+ );
+
+ const remoteTip = sh("git", ["rev-parse", "refs/heads/main"], remoteDir).stdout.trim();
+ record(
+ evidence,
+ "remote history survived both refusals",
+ "remote tip is still the last accepted commit",
+ remoteTip === oidB && remoteTip !== oidC,
+ null,
+ remoteTip
+ );
+
+ const starved = realRun("node", [path.join(workDir, ".forge", GUARD_FILENAME)], {
+ cwd: workDir,
+ input: "",
+ });
+ record(
+ evidence,
+ "guard with no ref records on stdin",
+ "fails closed, exit 2",
+ starved.status === 2,
+ starved
+ );
+
+ const ok = evidence.every((e) => e.pass);
+ return { ok: ok, evidence: evidence, root: root, problem: ok ? null : "one or more selftest cases failed" };
+ } finally {
+ removeDisposable(root);
+ }
+ }
+
+ function sh(bin, args, dir) {
+ const res = realRun(bin, args, { cwd: dir });
+ if (res.status !== 0) {
+ throw new Error(
+ "selftest setup failed: " + bin + " " + args.join(" ") + "\n" + res.stderr + res.stdout
+ );
+ }
+ return res;
+ }
+
+ function record(evidence, name, expectation, pass, res, observed) {
+ evidence.push({
+ case: name,
+ expectation: expectation,
+ observed:
+ observed !== undefined && observed !== null
+ ? String(observed)
+ : "exit " + String(res && res.status),
+ pass: Boolean(pass),
+ });
+ }
+
+ /* ---------------- apply / verify ---------------- */
+
+ function apply(applyOpts) {
+ const options2 = applyOpts || {};
+ const capability = options2.capability || detect(options2);
+ const adapter = adapterFor(capability.provider);
+ const attempts = [];
+
+ let applied = null;
+ if (capability.serverSide !== "no") {
+ applied = adapter.apply(capability);
+ attempts.push({
+ tier: "remote",
+ mechanism: applied.mechanism,
+ applied: applied.applied,
+ failure: applied.failure || null,
+ });
+ if (applied.applied) {
+ const verified = adapter.verify(capability, applied);
+ attempts[attempts.length - 1].verified = verified.verified;
+ attempts[attempts.length - 1].detail = verified.detail;
+ if (verified.verified) {
+ // Tier 1 is in force, but the local guard usually stays wired as a
+ // cheap second line. Report its wiring: a guard wired without
+ // use_stdin fails closed and would block every push, and nothing
+ // else in the tier 1 path would notice.
+ const localGuard = installLocal();
+ // The guard is optional at tier 1, so its absence is not a failure.
+ // A guard that is present but cannot receive ref records is.
+ const guardPresent = fs.existsSync(abs(guardRel));
+ return finish(capability, {
+ tier: "remote",
+ mechanism: applied.mechanism,
+ verified: true,
+ trustBoundary: TRUST_BOUNDARY_REMOTE,
+ fallbackReason: null,
+ localGuard: {
+ wired: localGuard.installed,
+ manager: localGuard.manager || null,
+ problem: localGuard.problem || null,
+ },
+ evidence: [
+ {
+ case: "server-side protection read back from the host",
+ expectation: "deletion and non-fast-forward blocked",
+ observed: verified.detail,
+ pass: true,
+ },
+ {
+ case: "local guard wiring, defence in depth",
+ expectation: guardPresent
+ ? "wired and able to receive ref records"
+ : "optional at this tier",
+ observed: localGuard.installed
+ ? "wired via " + localGuard.manager
+ : String(localGuard.problem),
+ pass: localGuard.installed || !guardPresent,
+ },
+ ],
+ attempts: attempts,
+ });
+ }
+ }
+ } else {
+ attempts.push({
+ tier: "remote",
+ mechanism: null,
+ applied: false,
+ failure: { kind: capability.failureKind || "unsupported", message: capability.reason },
+ });
+ }
+
+ const remoteFailure =
+ (applied && applied.failure) ||
+ (attempts[0] && attempts[0].failure) || {
+ kind: "unknown",
+ message: "server-side protection could not be established",
+ };
+
+ const install = installLocal();
+ if (!install.installed) {
+ return finish(capability, {
+ tier: "local",
+ mechanism: "managed-pre-push-guard",
+ verified: false,
+ trustBoundary: TRUST_BOUNDARY_LOCAL,
+ fallbackReason: remoteFailure.kind + ": " + remoteFailure.message,
+ evidence: [],
+ attempts: attempts,
+ problem: install.problem,
+ requiredSnippet: install.requiredSnippet || null,
+ });
+ }
+
+ // Always proven, never asserted. There is deliberately no switch that
+ // records verified: true without running the proof.
+ const proof = selftest();
+ return finish(capability, {
+ tier: "local",
+ mechanism: "managed-pre-push-guard",
+ hookManager: install.manager,
+ verified: proof.ok,
+ trustBoundary: TRUST_BOUNDARY_LOCAL,
+ fallbackReason: remoteFailure.kind + ": " + remoteFailure.message,
+ evidence: proof.evidence,
+ attempts: attempts,
+ problem: proof.ok ? null : proof.problem,
+ });
+ }
+
+ function finish(capability, result) {
+ const state = {
+ schema: STATE_SCHEMA,
+ generatedBy: "forge branch-protection",
+ recordedAt: now(),
+ provider: capability.provider,
+ host: capability.host,
+ repository: capability.repository,
+ defaultBranch: capability.defaultBranch,
+ visibility: capability.visibility,
+ visibilityChanged: false,
+ tier: result.tier,
+ mechanism: result.mechanism,
+ hookManager: result.hookManager || null,
+ localGuard: result.localGuard || null,
+ // Only claim coverage that was actually established. Recording the two
+ // behaviours unconditionally would make the gate's coverage check dead
+ // code on every state this tool writes.
+ protections: result.verified ? REQUIRED_PROTECTIONS.slice() : [],
+ verified: Boolean(result.verified),
+ trustBoundary: result.trustBoundary,
+ fallbackReason: result.fallbackReason,
+ attempts: result.attempts,
+ evidence: result.evidence,
+ };
+ if (result.problem) state.problem = result.problem;
+ if (result.requiredSnippet) state.requiredSnippet = result.requiredSnippet;
+ writeState(state);
+ return state;
+ }
+
+ function verify(verifyOpts) {
+ const state = readState();
+ if (!state) {
+ return { verified: false, reason: "no protection state recorded at " + stateRel };
+ }
+ if (state.tier === "remote") {
+ const capability = (verifyOpts && verifyOpts.capability) || detect(verifyOpts);
+ const adapter = adapterFor(capability.provider);
+ const res = adapter.verify(capability, { mechanism: state.mechanism });
+ const updated = Object.assign({}, state, {
+ verified: res.verified,
+ protections: res.verified ? REQUIRED_PROTECTIONS.slice() : [],
+ recordedAt: now(),
+ evidence: [
+ {
+ case: "server-side protection read back from the host",
+ expectation: "deletion and non-fast-forward blocked",
+ observed: res.detail,
+ pass: res.verified,
+ },
+ ],
+ });
+ writeState(updated);
+ return { verified: res.verified, reason: res.detail, state: updated };
+ }
+
+ // inspectLocal, not installLocal: a verify that repairs what it is
+ // checking can never observe a hook someone deleted.
+ const install = inspectLocal();
+ if (!install.installed) {
+ const updated = Object.assign({}, state, {
+ verified: false,
+ protections: [],
+ recordedAt: now(),
+ problem: install.problem,
+ });
+ writeState(updated);
+ return { verified: false, reason: install.problem, state: updated };
+ }
+ const proof = selftest();
+ const updated = Object.assign({}, state, {
+ verified: proof.ok,
+ protections: proof.ok ? REQUIRED_PROTECTIONS.slice() : [],
+ hookManager: install.manager,
+ recordedAt: now(),
+ evidence: proof.evidence,
+ });
+ delete updated.problem;
+ if (!proof.ok) updated.problem = proof.problem;
+ writeState(updated);
+ return { verified: proof.ok, reason: proof.problem, state: updated };
+ }
+
+ /* ---------------- gate ---------------- */
+
+ function gateStatus(state) {
+ const s = state === undefined ? readState() : state;
+ if (!s) {
+ return {
+ satisfied: false,
+ tier: null,
+ reason: "no protection state recorded; run branch-protection apply",
+ };
+ }
+ if (!s.verified) {
+ return {
+ satisfied: false,
+ tier: s.tier,
+ reason: s.problem || "protection is recorded but not verified",
+ };
+ }
+ const missing = REQUIRED_PROTECTIONS.filter(
+ (p) => (s.protections || []).indexOf(p) === -1
+ );
+ if (missing.length > 0) {
+ return {
+ satisfied: false,
+ tier: s.tier,
+ reason: "protection does not cover: " + missing.join(", "),
+ };
+ }
+ if (s.tier === "remote") {
+ return {
+ satisfied: true,
+ tier: "remote",
+ reason: "server-side enforcement verified via " + s.mechanism,
+ };
+ }
+ if (s.tier === "local") {
+ if (!s.trustBoundary || String(s.trustBoundary).trim() === "") {
+ return {
+ satisfied: false,
+ tier: "local",
+ reason:
+ "local enforcement is verified but its narrower trust boundary is " +
+ "not recorded; the gate needs the limitation written down",
+ };
+ }
+ return {
+ satisfied: true,
+ tier: "local",
+ reason:
+ "managed local enforcement verified, with the narrower trust " +
+ "boundary recorded",
+ };
+ }
+ return { satisfied: false, tier: s.tier, reason: "unrecognised protection tier: " + String(s.tier) };
+ }
+
+ /* ---------------- migration ---------------- *
+ *
+ * For a project whose environment phase stalled on "private repository
+ * rulesets need a paid plan". Re-detects, stands up the fallback, and hands
+ * back a plan naming exactly which recorded blocker to clear. It edits the
+ * state file it owns and nothing else: CONTINUE.md and DECISIONS.md are the
+ * lifecycle's files, and forge edits those with the Edit tool.
+ */
+
+ const RULESET_BLOCKER = /(ruleset|branch protection|protected branch|force[- ]push|branch deletion)/i;
+ const PLAN_MARKER = /(github pro|paid|upgrade|plan|private repositor|billing)/i;
+
+ function classifyBlockers(text) {
+ const clear = [];
+ const preserve = [];
+ const lines = String(text || "").split(/\r?\n/);
+ let inBlocked = false;
+ for (const line of lines) {
+ if (/^#{1,6}\s/.test(line)) {
+ inBlocked = /blocked on me/i.test(line);
+ continue;
+ }
+ if (!inBlocked) continue;
+ const item = /^\s*(?:[-*+]|\d+\.)\s+(.*\S)\s*$/.exec(line);
+ if (!item) continue;
+ const body = item[1];
+ if (RULESET_BLOCKER.test(body) && PLAN_MARKER.test(body)) {
+ clear.push(body);
+ } else {
+ preserve.push(body);
+ }
+ }
+ return { clear: clear, preserve: preserve };
+ }
+
+ function migrate(migrateOpts) {
+ const options2 = migrateOpts || {};
+ const continuePath = abs(options2.continuePath || "CONTINUE.md");
+ let continueText = "";
+ try {
+ continueText = fs.readFileSync(continuePath, "utf8");
+ } catch (err) {
+ continueText = "";
+ }
+ const blockers = classifyBlockers(continueText);
+ const previous = readState();
+ const state = apply(options2);
+ const gate = gateStatus(state);
+
+ return {
+ previousTier: previous ? previous.tier : null,
+ state: state,
+ gate: gate,
+ blockers: blockers,
+ recordUpdates: {
+ continueMd: {
+ clear: blockers.clear,
+ preserve: blockers.preserve,
+ note:
+ gate.satisfied && state.tier === "local"
+ ? "Default-branch history protection is now satisfied by managed " +
+ "local enforcement. Record the narrower trust boundary alongside it."
+ : null,
+ },
+ decisionsMd:
+ state.tier === "local"
+ ? decisionEntry(state)
+ : "Server-side default-branch protection is now in force via " +
+ String(state.mechanism) +
+ ". Supersedes the earlier blocked ruleset attempt.",
+ environmentMd: report(state),
+ },
+ resumeAt:
+ gate.satisfied
+ ? "Phase 2 gate item 'default-branch history protection verified' is " +
+ "satisfied. Continue the environment phase from the next unmet item."
+ : "Protection is still unsatisfied: " + gate.reason,
+ };
+ }
+
+ function decisionEntry(state) {
+ return [
+ "Default-branch protection: managed local enforcement (tier 2).",
+ "",
+ "Server-side protection was not available: " + String(state.fallbackReason) + ".",
+ "Repository visibility was left as " + String(state.visibility) + " and was not changed.",
+ "",
+ "Mechanism: " + String(state.mechanism) + " via " + String(state.hookManager || "git hook") + ".",
+ "Trust boundary: " + TRUST_BOUNDARY_LOCAL,
+ ].join("\n");
+ }
+
+ /* ---------------- report ---------------- */
+
+ function report(state) {
+ const s = state === undefined ? readState() : state;
+ if (!s) return "## Default-branch protection\n\nNot yet configured.\n";
+ const gate = gateStatus(s);
+ const lines = [];
+ lines.push("## Default-branch protection");
+ lines.push("");
+ lines.push("| Field | Value |");
+ lines.push("|---|---|");
+ lines.push("| Provider | " + String(s.provider) + " (" + String(s.host) + ") |");
+ lines.push("| Repository | " + String(s.repository) + " |");
+ lines.push("| Default branch | " + String(s.defaultBranch) + " |");
+ lines.push("| Visibility | " + String(s.visibility) + ", unchanged by forge |");
+ lines.push("| Tier | " + (s.tier === "remote" ? "1, server side" : "2, managed local") + " |");
+ lines.push("| Mechanism | " + String(s.mechanism) + " |");
+ lines.push("| Protects against | " + (s.protections || []).join(", ") + " |");
+ lines.push("| Verified | " + (s.verified ? "yes" : "no") + " |");
+ lines.push("| Gate | " + (gate.satisfied ? "satisfied" : "NOT satisfied") + ", " + gate.reason + " |");
+ lines.push("");
+ if (s.fallbackReason) {
+ lines.push("Server-side enforcement was not used: " + s.fallbackReason);
+ lines.push("");
+ }
+ lines.push("Trust boundary: " + String(s.trustBoundary));
+ lines.push("");
+ lines.push("### Verification evidence");
+ lines.push("");
+ lines.push("| Case | Expected | Observed | Result |");
+ lines.push("|---|---|---|---|");
+ for (const e of s.evidence || []) {
+ lines.push(
+ "| " +
+ e.case +
+ " | " +
+ e.expectation +
+ " | " +
+ String(e.observed) +
+ " | " +
+ (e.pass ? "pass" : "FAIL") +
+ " |"
+ );
+ }
+ lines.push("");
+ return lines.join("\n");
+ }
+
+ return {
+ cwd: cwd,
+ guardRel: guardRel,
+ stateRel: stateRel,
+ detect: detect,
+ apply: apply,
+ verify: verify,
+ selftest: selftest,
+ gateStatus: gateStatus,
+ migrate: migrate,
+ classifyBlockers: classifyBlockers,
+ report: report,
+ readState: readState,
+ writeState: writeState,
+ runProvider: runProvider,
+ installLocal: installLocal,
+ hookManager: hookManager,
+ adapters: ADAPTERS,
+ };
+}
+
+/* ------------------------------------------------------------------ *
+ * CLI
+ * ------------------------------------------------------------------ */
+
+function main(argv, io) {
+ const out = (io && io.out) || ((s) => process.stdout.write(s));
+ const err = (io && io.err) || ((s) => process.stderr.write(s));
+ const command = argv[0] || "status";
+ const json = argv.indexOf("--json") !== -1;
+ const tool = createTool({ cwd: (io && io.cwd) || process.cwd() });
+
+ function emit(value, human) {
+ if (json) {
+ out(JSON.stringify(value, null, 2) + "\n");
+ } else {
+ out(human + "\n");
+ }
+ }
+
+ try {
+ if (command === "detect") {
+ const c = tool.detect();
+ emit(
+ c,
+ [
+ "provider: " + c.provider + " (" + String(c.host) + ")",
+ "repository: " + String(c.repository),
+ "default branch: " + c.defaultBranch,
+ "visibility: " + String(c.visibility),
+ "server-side: " + c.serverSide,
+ "reason: " + String(c.reason),
+ ].join("\n")
+ );
+ return 0;
+ }
+
+ if (command === "apply") {
+ const state = tool.apply();
+ const gate = tool.gateStatus(state);
+ emit(
+ { state: state, gate: gate },
+ [
+ "tier: " + (state.tier === "remote" ? "1, server side" : "2, managed local"),
+ "mechanism: " + String(state.mechanism),
+ "verified: " + String(state.verified),
+ "gate: " + (gate.satisfied ? "satisfied" : "NOT satisfied") + ", " + gate.reason,
+ state.fallbackReason ? "fallback: " + state.fallbackReason : "",
+ state.problem ? "problem: " + state.problem : "",
+ state.requiredSnippet ? "\nRequired wiring:\n" + state.requiredSnippet : "",
+ ]
+ .filter(Boolean)
+ .join("\n")
+ );
+ return gate.satisfied ? 0 : 1;
+ }
+
+ if (command === "verify") {
+ const res = tool.verify();
+ emit(res, (res.verified ? "verified" : "NOT verified") + ": " + String(res.reason));
+ return res.verified ? 0 : 1;
+ }
+
+ if (command === "selftest") {
+ const res = tool.selftest();
+ emit(
+ res,
+ res.evidence
+ .map((e) => (e.pass ? "pass " : "FAIL ") + e.case + " -> " + String(e.observed))
+ .join("\n") || String(res.problem)
+ );
+ return res.ok ? 0 : 1;
+ }
+
+ if (command === "gate") {
+ const g = tool.gateStatus();
+ emit(g, (g.satisfied ? "GATE SATISFIED" : "GATE NOT SATISFIED") + ": " + g.reason);
+ return g.satisfied ? 0 : 1;
+ }
+
+ if (command === "migrate") {
+ const res = tool.migrate();
+ emit(
+ res,
+ [
+ "previous tier: " + String(res.previousTier),
+ "new tier: " + String(res.state.tier),
+ "gate: " + (res.gate.satisfied ? "satisfied" : "NOT satisfied"),
+ "",
+ "clear these blockers from CONTINUE.md:",
+ ...(res.blockers.clear.length ? res.blockers.clear.map((b) => " - " + b) : [" (none)"]),
+ "",
+ "preserve these blockers:",
+ ...(res.blockers.preserve.length ? res.blockers.preserve.map((b) => " - " + b) : [" (none)"]),
+ "",
+ res.resumeAt,
+ ].join("\n")
+ );
+ return res.gate.satisfied ? 0 : 1;
+ }
+
+ if (command === "report") {
+ out(tool.report() + "\n");
+ return 0;
+ }
+
+ if (command === "status") {
+ const state = tool.readState();
+ const gate = tool.gateStatus(state);
+ emit({ state: state, gate: gate }, state ? tool.report(state) : "no protection state recorded");
+ return gate.satisfied ? 0 : 1;
+ }
+
+ err("unknown subcommand: " + command + "\n");
+ return 2;
+ } catch (e) {
+ err("branch-protection: " + (e && e.message ? e.message : String(e)) + "\n");
+ return 2;
+ }
+}
+
+module.exports = {
+ RULESET_NAME: RULESET_NAME,
+ SELFTEST_PREFIX: SELFTEST_PREFIX,
+ TRUST_BOUNDARY_LOCAL: TRUST_BOUNDARY_LOCAL,
+ TRUST_BOUNDARY_REMOTE: TRUST_BOUNDARY_REMOTE,
+ REQUIRED_PROTECTIONS: REQUIRED_PROTECTIONS,
+ LEFTHOOK_SNIPPET: LEFTHOOK_SNIPPET,
+ parseRemoteUrl: parseRemoteUrl,
+ identifyProvider: identifyProvider,
+ classifyRemoteFailure: classifyRemoteFailure,
+ parseLefthookPrePush: parseLefthookPrePush,
+ checkLefthookWiring: checkLefthookWiring,
+ isVisibilityMutation: isVisibilityMutation,
+ disposableRejection: disposableRejection,
+ removeDisposable: removeDisposable,
+ createDisposableRoot: createDisposableRoot,
+ plainHookBody: plainHookBody,
+ createTool: createTool,
+ main: main,
+};
+
+if (require.main === module) {
+ process.exit(main(process.argv.slice(2)));
+}
diff --git a/templates/history-guard.js b/templates/history-guard.js
new file mode 100644
index 0000000..6ef8b42
--- /dev/null
+++ b/templates/history-guard.js
@@ -0,0 +1,375 @@
+#!/usr/bin/env node
+"use strict";
+
+/*
+ * Forge managed history-integrity guard (Tier 2 default-branch protection).
+ *
+ * Installed into a project as .forge/history-guard.js and wired as a pre-push
+ * hook. It reads the ref-update records git writes to a pre-push hook's stdin,
+ * one per line:
+ *
+ *
+ *
+ * and refuses two things on the protected branch:
+ *
+ * - deletion (local oid is the null oid)
+ * - non-fast-forward (remote oid is not an ancestor of the local oid)
+ *
+ * Ordinary fast-forward pushes pass, including the --no-ff merge commits the
+ * forge workflow puts on the default branch, because a merge commit still has
+ * the previous tip as an ancestor. Creating the branch for the first time
+ * passes, because there is no remote history to lose.
+ *
+ * TRUST BOUNDARY. This is a local guard. It protects clones that are
+ * configured with it. It cannot stop a push from an unconfigured clone, a
+ * write through the git host's API or web UI, a deleted or edited hook, or an
+ * attacker holding valid credentials. Only server-side protection does that.
+ * Tier 1 in .forge/branch-protection.js is preferred wherever the host and
+ * account support it; this exists so a plan that forbids server-side
+ * protection does not leave the default branch with nothing at all.
+ *
+ * Exit codes:
+ * 0 every proposed update is allowed
+ * 1 at least one update is refused by policy
+ * 2 the guard could not do its job (no ref records on stdin, malformed
+ * input, or an object it cannot resolve). Fails closed on purpose: a
+ * guard that passes when it cannot see the refs is not a guard.
+ *
+ * Never bypass this with --no-verify. If it blocks you, it is describing a
+ * history-destroying push; fix the push, do not silence the check.
+ */
+
+const fs = require("fs");
+const path = require("path");
+const { spawnSync } = require("child_process");
+
+const EXIT_ALLOW = 0;
+const EXIT_REJECT = 1;
+const EXIT_MISCONFIGURED = 2;
+
+const NULL_OID = /^0+$/;
+
+function isNullOid(oid) {
+ return typeof oid === "string" && oid.length > 0 && NULL_OID.test(oid);
+}
+
+/*
+ * Parse the pre-push stdin payload. Returns { updates, malformed }.
+ * A line git did not produce is malformed rather than ignorable: the guard
+ * must not silently skip a record it failed to understand.
+ */
+function parseRefLines(raw) {
+ const updates = [];
+ const malformed = [];
+ const lines = String(raw == null ? "" : raw).split(/\r?\n/);
+ for (const line of lines) {
+ const trimmed = line.trim();
+ if (trimmed === "") continue;
+ const parts = trimmed.split(/\s+/);
+ if (parts.length !== 4) {
+ malformed.push(trimmed);
+ continue;
+ }
+ updates.push({
+ localRef: parts[0],
+ localOid: parts[1],
+ remoteRef: parts[2],
+ remoteOid: parts[3],
+ });
+ }
+ return { updates, malformed };
+}
+
+/*
+ * Decide one ref update.
+ *
+ * isAncestor(ancestor, descendant) must return "yes", "no", or "unknown".
+ * "unknown" means git could not resolve one of the objects, which is treated
+ * as a refusal: an unverifiable update is not a safe update.
+ */
+function decide(update, protectedRef, isAncestor) {
+ if (update.remoteRef !== protectedRef) {
+ return { action: "allow", reason: "not-protected-ref" };
+ }
+ if (isNullOid(update.localOid)) {
+ return { action: "reject", reason: "deletion" };
+ }
+ if (isNullOid(update.remoteOid)) {
+ return { action: "allow", reason: "creation" };
+ }
+ if (update.localOid === update.remoteOid) {
+ return { action: "allow", reason: "no-change" };
+ }
+ const verdict = isAncestor(update.remoteOid, update.localOid);
+ if (verdict === "yes") {
+ return { action: "allow", reason: "fast-forward" };
+ }
+ if (verdict === "no") {
+ return { action: "reject", reason: "non-fast-forward" };
+ }
+ return { action: "unverifiable", reason: "unresolved-object" };
+}
+
+function evaluate(raw, protectedBranch, isAncestor) {
+ const protectedRef = "refs/heads/" + protectedBranch;
+ const { updates, malformed } = parseRefLines(raw);
+ const results = [];
+ for (const update of updates) {
+ results.push({ update, verdict: decide(update, protectedRef, isAncestor) });
+ }
+ return { updates, malformed, results, protectedRef };
+}
+
+function gitAncestorProbe(cwd) {
+ return function isAncestor(ancestor, descendant) {
+ const res = spawnSync(
+ "git",
+ ["merge-base", "--is-ancestor", ancestor, descendant],
+ { cwd: cwd, encoding: "utf8" }
+ );
+ if (res.error) return "unknown";
+ if (res.status === 0) return "yes";
+ if (res.status === 1) return "no";
+ return "unknown";
+ };
+}
+
+function gitCapture(cwd, args) {
+ const res = spawnSync("git", args, { cwd: cwd, encoding: "utf8" });
+ if (res.error || res.status !== 0) return null;
+ return String(res.stdout || "").trim();
+}
+
+/*
+ * Protected branch resolution, most explicit source first. The recorded
+ * default branch in .forge/protection.json is authoritative when present,
+ * because that is the branch the protection decision was actually made about.
+ *
+ * init.defaultBranch deliberately ranks below an existing branch: it states
+ * what git would name a branch it creates next, not what this repository's
+ * default branch is. A machine configured with init.defaultBranch=master and
+ * a repository whose default branch is main is a real and common combination,
+ * and trusting the config there would silently protect a branch that does not
+ * exist while leaving the real one open.
+ */
+function resolveProtectedBranch(options) {
+ const opts = options || {};
+ const cwd = opts.cwd || process.cwd();
+ const env = opts.env || process.env;
+
+ if (opts.branch) return { branch: opts.branch, source: "argument" };
+ if (env.FORGE_PROTECTED_BRANCH) {
+ return { branch: env.FORGE_PROTECTED_BRANCH, source: "environment" };
+ }
+
+ const statePath = opts.statePath || ".forge/protection.json";
+ try {
+ const state = JSON.parse(
+ fs.readFileSync(path.resolve(cwd, statePath), "utf8")
+ );
+ if (state && state.defaultBranch) {
+ return { branch: state.defaultBranch, source: "protection-state" };
+ }
+ } catch (err) {
+ // No recorded state. Fall through to git.
+ }
+
+ const configured = gitCapture(cwd, ["config", "--get", "forge.protectedBranch"]);
+ if (configured) return { branch: configured, source: "forge.protectedBranch" };
+
+ const remote = opts.remote || "origin";
+ const head = gitCapture(cwd, [
+ "symbolic-ref",
+ "--short",
+ "refs/remotes/" + remote + "/HEAD",
+ ]);
+ if (head) {
+ // Plain string trim, not a regex: a remote may legitimately be named
+ // "up.stream" or "fork(a)", and interpolating that into a RegExp would
+ // either mis-strip or throw.
+ const prefix = remote + "/";
+ const short = head.indexOf(prefix) === 0 ? head.slice(prefix.length) : head;
+ if (short) return { branch: short, source: "remote-head" };
+ }
+
+ for (const candidate of ["main", "master"]) {
+ const found = spawnSync(
+ "git",
+ ["show-ref", "--verify", "--quiet", "refs/heads/" + candidate],
+ { cwd: cwd, encoding: "utf8" }
+ );
+ if (!found.error && found.status === 0) {
+ return { branch: candidate, source: "existing-branch" };
+ }
+ }
+
+ const initDefault = gitCapture(cwd, ["config", "--get", "init.defaultBranch"]);
+ if (initDefault) return { branch: initDefault, source: "init.defaultBranch" };
+
+ return { branch: "main", source: "default" };
+}
+
+function readStdin() {
+ if (process.stdin.isTTY) {
+ return { ok: false, reason: "tty" };
+ }
+ try {
+ return { ok: true, data: fs.readFileSync(0, "utf8") };
+ } catch (err) {
+ return { ok: false, reason: "unreadable", error: err };
+ }
+}
+
+/*
+ * git invokes a pre-push hook as: pre-push . The
+ * first positional is therefore the remote actually being pushed to, which
+ * matters when a repository has more than one remote with different default
+ * branches. --branch and --remote override it for manual invocation.
+ */
+function parseArgs(argv) {
+ const out = { branch: null, remote: null };
+ const positional = [];
+ for (let i = 0; i < argv.length; i++) {
+ if (argv[i] === "--branch" && argv[i + 1]) {
+ out.branch = argv[++i];
+ } else if (argv[i] === "--remote" && argv[i + 1]) {
+ out.remote = argv[++i];
+ } else if (argv[i].indexOf("-") !== 0) {
+ positional.push(argv[i]);
+ }
+ }
+ if (out.remote === null && positional.length > 0) out.remote = positional[0];
+ return out;
+}
+
+const MISCONFIGURED_HELP = [
+ "forge history guard: NO REF UPDATES ON STDIN",
+ "",
+ "git hands a pre-push hook one line per ref it is about to update. This",
+ "guard received none, so it cannot tell a fast-forward from a force push",
+ "and is refusing the push rather than waving it through.",
+ "",
+ "Almost always this is hook wiring, not your push. Check:",
+ "",
+ " lefthook the pre-push command running this guard needs use_stdin: true",
+ " plain hook .git/hooks/pre-push must exec this script and pass stdin",
+ " through, not consume it first",
+ "",
+ "Verify the wiring with: node .forge/branch-protection.js verify",
+];
+
+function main(argv, io) {
+ const out = (io && io.out) || ((s) => process.stdout.write(s));
+ const err = (io && io.err) || ((s) => process.stderr.write(s));
+ const cwd = (io && io.cwd) || process.cwd();
+
+ const args = parseArgs(argv);
+ const resolved = resolveProtectedBranch({
+ cwd: cwd,
+ branch: args.branch,
+ remote: args.remote,
+ });
+
+ const stdin = (io && io.readStdin ? io.readStdin : readStdin)();
+ if (!stdin.ok) {
+ err(MISCONFIGURED_HELP.join("\n") + "\n");
+ return EXIT_MISCONFIGURED;
+ }
+
+ const isAncestor = (io && io.isAncestor) || gitAncestorProbe(cwd);
+ const evaluated = evaluate(stdin.data, resolved.branch, isAncestor);
+
+ if (evaluated.updates.length === 0 && evaluated.malformed.length === 0) {
+ err(MISCONFIGURED_HELP.join("\n") + "\n");
+ return EXIT_MISCONFIGURED;
+ }
+
+ if (evaluated.malformed.length > 0) {
+ err(
+ [
+ "forge history guard: MALFORMED REF RECORD",
+ "",
+ "Expected four whitespace separated fields per line. Got:",
+ ]
+ .concat(evaluated.malformed.map((l) => " " + l))
+ .join("\n") + "\n"
+ );
+ return EXIT_MISCONFIGURED;
+ }
+
+ const rejected = evaluated.results.filter((r) => r.verdict.action === "reject");
+ const unverifiable = evaluated.results.filter(
+ (r) => r.verdict.action === "unverifiable"
+ );
+
+ if (rejected.length === 0 && unverifiable.length === 0) {
+ out(
+ "forge history guard: ok, " +
+ evaluated.results.length +
+ " ref update(s) checked against " +
+ evaluated.protectedRef +
+ "\n"
+ );
+ return EXIT_ALLOW;
+ }
+
+ const lines = [];
+ for (const r of rejected) {
+ if (r.verdict.reason === "deletion") {
+ lines.push("forge history guard: PUSH REFUSED, branch deletion");
+ lines.push("");
+ lines.push(
+ " " + r.update.remoteRef + " is the protected default branch."
+ );
+ lines.push(" Deleting it on the remote would take its history with it.");
+ } else {
+ lines.push("forge history guard: PUSH REFUSED, non-fast-forward");
+ lines.push("");
+ lines.push(" " + r.update.remoteRef);
+ lines.push(" remote is at " + r.update.remoteOid);
+ lines.push(" you are pushing " + r.update.localOid);
+ lines.push(
+ " The remote commit is not an ancestor of yours, so this push would"
+ );
+ lines.push(" drop commits that exist on the remote.");
+ lines.push("");
+ lines.push(" Rebase or merge the remote tip in and push a fast-forward.");
+ }
+ lines.push("");
+ }
+ for (const r of unverifiable) {
+ lines.push("forge history guard: PUSH REFUSED, cannot verify");
+ lines.push("");
+ lines.push(" " + r.update.remoteRef);
+ lines.push(
+ " git could not resolve " +
+ r.update.remoteOid +
+ " locally, so whether this is a"
+ );
+ lines.push(
+ " fast-forward is unknown. Run git fetch and try again. An update that"
+ );
+ lines.push(" cannot be verified is not allowed through.");
+ lines.push("");
+ }
+ lines.push("--no-verify is prohibited by project standards. Fix the push.");
+ err(lines.join("\n") + "\n");
+
+ return rejected.length > 0 ? EXIT_REJECT : EXIT_MISCONFIGURED;
+}
+
+module.exports = {
+ EXIT_ALLOW: EXIT_ALLOW,
+ EXIT_REJECT: EXIT_REJECT,
+ EXIT_MISCONFIGURED: EXIT_MISCONFIGURED,
+ isNullOid: isNullOid,
+ parseRefLines: parseRefLines,
+ decide: decide,
+ evaluate: evaluate,
+ resolveProtectedBranch: resolveProtectedBranch,
+ main: main,
+};
+
+if (require.main === module) {
+ process.exit(main(process.argv.slice(2)));
+}
diff --git a/templates/lefthook.yml b/templates/lefthook.yml
index 88cf11b..f49814e 100644
--- a/templates/lefthook.yml
+++ b/templates/lefthook.yml
@@ -10,7 +10,33 @@
pre-push:
parallel: false
+ # piped stops the sequence at the first failure. Without it the build and
+ # test commands still run after the history check has already refused.
+ piped: true
commands:
+ # Default-branch history integrity. Refuses a push that would delete the
+ # default branch or rewrite its history. Runs FIRST so a history-destroying
+ # push is stopped in milliseconds instead of after a full build and test
+ # run. The leading 00 is what puts it first: lefthook orders commands by
+ # priority, then by the leading number in the name, then alphabetically,
+ # never by their position in this file.
+ #
+ # use_stdin: true is load bearing. git writes one ref-update record per
+ # line to a pre-push hook's stdin, and without this lefthook does not
+ # forward them. The guard fails closed when it receives nothing, so a
+ # missing use_stdin blocks every push rather than silently disabling the
+ # check. Verify the wiring with:
+ #
+ # node .forge/branch-protection.js verify
+ #
+ # When the git host enforces this server side (tier 1), this stays wired
+ # anyway: it costs nothing and it catches a bad push before it leaves the
+ # machine.
+ 00_history:
+ use_stdin: true
+ run: node .forge/history-guard.js
+ fail_text: "Refused: this push would delete or rewrite default-branch history."
+
01_secrets:
run: gitleaks detect --no-banner --redact --exit-code 1
fail_text: "Secret detected. Do not commit it, do not bypass this. Rotate anything exposed."
diff --git a/tests/disposable-remote.test.js b/tests/disposable-remote.test.js
new file mode 100644
index 0000000..5075c35
--- /dev/null
+++ b/tests/disposable-remote.test.js
@@ -0,0 +1,123 @@
+"use strict";
+
+/*
+ * Requirement 11: end to end proof against a disposable remote.
+ *
+ * Nothing here touches a real remote. A bare repository created by mkdtemp
+ * under the system temp directory stands in for the server, and every removal
+ * goes through the tool's own guarded delete.
+ */
+
+const test = require("node:test");
+const assert = require("node:assert");
+const fs = require("fs");
+const os = require("os");
+const path = require("path");
+const { spawnSync } = require("child_process");
+
+const bp = require("../templates/branch-protection.js");
+const { makeSandbox, cleanup, TOOL_SRC } = require("./helpers/sandbox.js");
+
+test("requirement 11: the guard is proven on throwaway repositories", () => {
+ const root = makeSandbox();
+ try {
+ const tool = bp.createTool({ cwd: root });
+ const result = tool.selftest();
+
+ const byCase = {};
+ for (const e of result.evidence) byCase[e.case] = e;
+
+ // The four decisions the policy names, observed through a real git push.
+ assert.equal(byCase["initial branch creation"].pass, true);
+ assert.equal(byCase["fast-forward push"].pass, true);
+ assert.equal(byCase["protected branch deletion"].pass, true);
+ assert.equal(byCase["non-fast-forward push"].pass, true);
+
+ // git must actually refuse, not merely print something.
+ assert.notEqual(byCase["protected branch deletion"].observed, "exit 0");
+ assert.notEqual(byCase["non-fast-forward push"].observed, "exit 0");
+
+ // The quality commands still run on an accepted push, and are skipped when
+ // the history check refuses, which is what proves the ordering.
+ assert.equal(byCase["quality checks run on an accepted push"].pass, true);
+ assert.equal(byCase["quality checks run again"].pass, true);
+ assert.equal(byCase["quality checks skipped when the guard refuses a deletion"].pass, true);
+ assert.equal(byCase["quality checks skipped when the guard refuses a rewrite"].pass, true);
+
+ assert.equal(byCase["remote history survived both refusals"].pass, true);
+ assert.equal(byCase["guard with no ref records on stdin"].pass, true);
+
+ assert.equal(result.ok, true);
+ assert.equal(result.problem, null);
+
+ // Requirement: the temporary repositories are safely removed.
+ assert.equal(fs.existsSync(result.root), false, "the disposable root must be gone");
+ } finally {
+ cleanup(root);
+ }
+});
+
+test("requirement 11: the same proof runs from the command line", () => {
+ const root = makeSandbox();
+ try {
+ const res = spawnSync(process.execPath, [TOOL_SRC, "selftest"], {
+ cwd: root,
+ encoding: "utf8",
+ });
+ assert.equal(res.status, 0, res.stdout + res.stderr);
+ assert.match(res.stdout, /pass {2}protected branch deletion/);
+ assert.match(res.stdout, /pass {2}non-fast-forward push/);
+ assert.doesNotMatch(res.stdout, /FAIL/);
+ } finally {
+ cleanup(root);
+ }
+});
+
+/* ---------------- the delete guard around the delete ---------------- */
+
+test("recursive deletion is refused for anything that is not a disposable root", () => {
+ const cases = [
+ ["", "path is empty"],
+ [os.tmpdir(), "temp root"],
+ [os.homedir(), "home directory"],
+ [process.cwd(), "working directory"],
+ [path.join(os.tmpdir(), "definitely-not-created-by-this-test"), "does not resolve"],
+ ];
+ for (const entry of cases) {
+ const rejection = bp.disposableRejection(entry[0]);
+ assert.notEqual(rejection, null, "expected " + entry[0] + " to be refused");
+ assert.throws(() => bp.removeDisposable(entry[0]), /refusing recursive delete/);
+ }
+});
+
+test("a temp directory without the forge prefix is still refused", () => {
+ const stranger = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), "unrelated-"));
+ try {
+ assert.match(bp.disposableRejection(stranger), /not a forge selftest directory/);
+ assert.throws(() => bp.removeDisposable(stranger), /refusing recursive delete/);
+ assert.equal(fs.existsSync(stranger), true, "the refusal must leave it alone");
+ } finally {
+ fs.rmSync(stranger, { recursive: true, force: true });
+ }
+});
+
+test("a disposable root is accepted, removed, and only removed once", () => {
+ const root = bp.createDisposableRoot();
+ fs.writeFileSync(path.join(root, "f.txt"), "x", "utf8");
+ assert.equal(bp.disposableRejection(root), null);
+ bp.removeDisposable(root);
+ assert.equal(fs.existsSync(root), false);
+ assert.throws(() => bp.removeDisposable(root), /refusing recursive delete/);
+});
+
+test("a path that escapes the temp directory through a parent reference is refused", () => {
+ const root = bp.createDisposableRoot();
+ try {
+ assert.throws(
+ () => bp.removeDisposable(path.join(root, "..", "..")),
+ /refusing recursive delete/
+ );
+ } finally {
+ bp.removeDisposable(root);
+ }
+});
diff --git a/tests/gate-and-migration.test.js b/tests/gate-and-migration.test.js
new file mode 100644
index 0000000..d928102
--- /dev/null
+++ b/tests/gate-and-migration.test.js
@@ -0,0 +1,358 @@
+"use strict";
+
+/*
+ * The bootstrap gate, the records it reads, and the migration for a project
+ * that stalled on a paid-plan ruleset.
+ *
+ * The gate question is "is default-branch history protection verified", and it
+ * has two acceptable answers. What it must never do is accept an unverified
+ * claim, or accept local enforcement without its narrower trust boundary
+ * written down.
+ */
+
+const test = require("node:test");
+const assert = require("node:assert");
+const fs = require("fs");
+const path = require("path");
+const { spawnSync } = require("child_process");
+
+const bp = require("../templates/branch-protection.js");
+const {
+ makeSandbox,
+ cleanup,
+ recordingRunner,
+ gitContextHandlers,
+ json,
+ TOOL_SRC,
+} = require("./helpers/sandbox.js");
+
+const PLAN_REFUSAL =
+ "gh: Upgrade to GitHub Pro or make this repository public to enable this feature. (HTTP 403)";
+
+function planRefusalHandlers() {
+ return [
+ { match: (bin, args) => bin === "gh" && args[0] === "--version", reply: { status: 0, stdout: "gh 2\n" } },
+ {
+ match: (bin, args) => bin === "gh" && args.join(" ") === "api repos/acme/widget",
+ reply: () =>
+ json({
+ private: true,
+ default_branch: "main",
+ owner: { type: "User" },
+ permissions: { admin: true },
+ }),
+ },
+ { match: (bin, args) => bin === "gh" && args.join(" ") === "api user", reply: () => json({ plan: { name: "free" } }) },
+ {
+ match: (bin, args) =>
+ bin === "gh" && /repos\/acme\/widget\/rulesets\?includes_parents=false$/.test(args.join(" ")),
+ reply: () => json([]),
+ },
+ {
+ match: (bin, args) => bin === "gh" && args.indexOf("--method") !== -1,
+ reply: { status: 1, stdout: "", stderr: PLAN_REFUSAL },
+ },
+ ];
+}
+
+function planRefusalTool(root) {
+ const run = recordingRunner(gitContextHandlers().concat(planRefusalHandlers()));
+ return bp.createTool({ cwd: root, run: run, now: () => "2026-08-02T00:00:00.000Z" });
+}
+
+function state(overrides) {
+ return Object.assign(
+ {
+ schema: 1,
+ provider: "github",
+ defaultBranch: "main",
+ protections: ["deletion", "non-fast-forward"],
+ tier: "remote",
+ mechanism: "github-ruleset",
+ verified: true,
+ trustBoundary: bp.TRUST_BOUNDARY_REMOTE,
+ evidence: [],
+ },
+ overrides || {}
+ );
+}
+
+/* ---------------- requirement 12: either tier satisfies the gate ---------- */
+
+test("requirement 12: verified server-side enforcement satisfies the gate", () => {
+ const tool = bp.createTool({ cwd: process.cwd() });
+ const gate = tool.gateStatus(state());
+ assert.equal(gate.satisfied, true);
+ assert.equal(gate.tier, "remote");
+ assert.match(gate.reason, /server-side enforcement verified/);
+});
+
+test("requirement 12: verified local enforcement with a recorded trust boundary also satisfies it", () => {
+ const tool = bp.createTool({ cwd: process.cwd() });
+ const gate = tool.gateStatus(
+ state({
+ tier: "local",
+ mechanism: "managed-pre-push-guard",
+ trustBoundary: bp.TRUST_BOUNDARY_LOCAL,
+ fallbackReason: "plan: the host withheld the feature",
+ })
+ );
+ assert.equal(gate.satisfied, true);
+ assert.equal(gate.tier, "local");
+ assert.match(gate.reason, /trust boundary recorded/);
+});
+
+test("an unavailable paid feature is not by itself a failed gate", () => {
+ // The whole point: a free-plan refusal must not read as a fatal bootstrap
+ // failure when the local fallback is valid and verified.
+ const tool = bp.createTool({ cwd: process.cwd() });
+ const gate = tool.gateStatus(
+ state({
+ tier: "local",
+ mechanism: "managed-pre-push-guard",
+ trustBoundary: bp.TRUST_BOUNDARY_LOCAL,
+ fallbackReason: "plan: Upgrade to GitHub Pro",
+ })
+ );
+ assert.equal(gate.satisfied, true);
+});
+
+test("local enforcement without its trust boundary recorded does NOT satisfy the gate", () => {
+ const tool = bp.createTool({ cwd: process.cwd() });
+ const gate = tool.gateStatus(state({ tier: "local", trustBoundary: "" }));
+ assert.equal(gate.satisfied, false);
+ assert.match(gate.reason, /trust boundary/);
+});
+
+test("an unverified claim never satisfies the gate, at either tier", () => {
+ const tool = bp.createTool({ cwd: process.cwd() });
+ assert.equal(tool.gateStatus(state({ verified: false })).satisfied, false);
+ assert.equal(
+ tool.gateStatus(state({ tier: "local", trustBoundary: bp.TRUST_BOUNDARY_LOCAL, verified: false })).satisfied,
+ false
+ );
+});
+
+test("protection that misses one of the two required behaviours fails the gate", () => {
+ const tool = bp.createTool({ cwd: process.cwd() });
+ const gate = tool.gateStatus(state({ protections: ["deletion"] }));
+ assert.equal(gate.satisfied, false);
+ assert.match(gate.reason, /non-fast-forward/);
+});
+
+test("no recorded protection at all fails the gate", () => {
+ const tool = bp.createTool({ cwd: process.cwd() });
+ const gate = tool.gateStatus(null);
+ assert.equal(gate.satisfied, false);
+ assert.match(gate.reason, /no protection state recorded/);
+});
+
+/* ---------------- requirement 13: state file and environment report ------- */
+
+test("requirement 13: apply writes a state file that records the tier and the evidence", () => {
+ const root = makeSandbox();
+ try {
+ const recorded = planRefusalTool(root).apply();
+ const onDisk = JSON.parse(fs.readFileSync(path.join(root, ".forge", "protection.json"), "utf8"));
+
+ assert.deepEqual(onDisk, recorded, "the returned state is the state that was written");
+ assert.equal(onDisk.schema, 1);
+ assert.equal(onDisk.provider, "github");
+ assert.equal(onDisk.repository, "acme/widget");
+ assert.equal(onDisk.defaultBranch, "main");
+ assert.equal(onDisk.visibility, "private");
+ assert.equal(onDisk.visibilityChanged, false);
+ assert.equal(onDisk.tier, "local");
+ assert.equal(onDisk.mechanism, "managed-pre-push-guard");
+ assert.deepEqual(onDisk.protections, ["deletion", "non-fast-forward"]);
+ assert.equal(onDisk.verified, true);
+ assert.equal(onDisk.trustBoundary, bp.TRUST_BOUNDARY_LOCAL);
+ assert.match(onDisk.fallbackReason, /^plan: /);
+
+ // The rejected tier-1 attempt is kept, so the record says what was tried.
+ assert.equal(onDisk.attempts[0].tier, "remote");
+ assert.equal(onDisk.attempts[0].applied, false);
+ assert.equal(onDisk.attempts[0].failure.kind, "plan");
+
+ assert.ok(onDisk.evidence.length >= 8, "the local tier records its proof");
+ assert.ok(onDisk.evidence.every((e) => e.pass));
+ } finally {
+ cleanup(root);
+ }
+});
+
+test("requirement 13: the environment report states the tier, the boundary, and the evidence", () => {
+ const root = makeSandbox();
+ try {
+ const tool = planRefusalTool(root);
+ const recorded = tool.apply();
+ const markdown = tool.report(recorded);
+
+ assert.match(markdown, /## Default-branch protection/);
+ assert.match(markdown, /\| Tier \| 2, managed local \|/);
+ assert.match(markdown, /\| Mechanism \| managed-pre-push-guard \|/);
+ assert.match(markdown, /\| Visibility \| private, unchanged by forge \|/);
+ assert.match(markdown, /\| Protects against \| deletion, non-fast-forward \|/);
+ assert.match(markdown, /\| Gate \| satisfied/);
+ assert.match(markdown, /Server-side enforcement was not used: plan:/);
+ assert.match(markdown, /Trust boundary: Local enforcement only/);
+ assert.match(markdown, /### Verification evidence/);
+ assert.match(markdown, /\| protected branch deletion \|/);
+ assert.doesNotMatch(markdown, /FAIL/);
+ } finally {
+ cleanup(root);
+ }
+});
+
+test("requirement 13: a tier 1 report names server-side enforcement instead", () => {
+ const root = makeSandbox();
+ try {
+ const tool = bp.createTool({ cwd: root });
+ const markdown = tool.report(state({ visibility: "public", evidence: [] }));
+ assert.match(markdown, /\| Tier \| 1, server side \|/);
+ assert.match(markdown, /Trust boundary: Server-side enforcement/);
+ } finally {
+ cleanup(root);
+ }
+});
+
+/* ---------------- requirement 14: migrating a blocked project ------------- */
+
+const BLOCKED_CONTINUE = [
+ "# Continue Here",
+ "",
+ "Phase: 2",
+ "Gate: IN_PROGRESS",
+ "Mode: FLOW",
+ "",
+ "## Blocked on me",
+ "",
+ "- GitHub refused the ruleset on main: private repository rulesets require a paid plan (Upgrade to GitHub Pro).",
+ "- Need the staging database credentials before slice 4 can be built.",
+ "- Waiting on a decision about whether to support Windows 10.",
+ "",
+ "## Notes for the next session",
+ "",
+ "- The ruleset attempt is recorded in docs/DECISIONS.md.",
+].join("\n");
+
+test("requirement 14: only the ruleset blocker is classified for clearing", () => {
+ const tool = bp.createTool({ cwd: process.cwd() });
+ const blockers = tool.classifyBlockers(BLOCKED_CONTINUE);
+ assert.equal(blockers.clear.length, 1);
+ assert.match(blockers.clear[0], /ruleset/);
+ assert.equal(blockers.preserve.length, 2);
+ assert.match(blockers.preserve[0], /staging database credentials/);
+ assert.match(blockers.preserve[1], /Windows 10/);
+});
+
+test("a blocker that merely mentions a plan is preserved, not cleared", () => {
+ const tool = bp.createTool({ cwd: process.cwd() });
+ const blockers = tool.classifyBlockers(
+ ["## Blocked on me", "", "- The client has to upgrade their plan before SSO can be enabled."].join("\n")
+ );
+ assert.deepEqual(blockers.clear, []);
+ assert.equal(blockers.preserve.length, 1);
+});
+
+test("blockers outside the Blocked on me section are not touched", () => {
+ const tool = bp.createTool({ cwd: process.cwd() });
+ const blockers = tool.classifyBlockers(
+ [
+ "## Notes for the next session",
+ "",
+ "- The ruleset needs a paid plan, which is why we stopped.",
+ ].join("\n")
+ );
+ assert.deepEqual(blockers, { clear: [], preserve: [] });
+});
+
+test("requirement 14: a project blocked on a paid ruleset resumes on the local tier", () => {
+ const root = makeSandbox({
+ protectionState: false,
+ files: { "CONTINUE.md": BLOCKED_CONTINUE },
+ });
+ try {
+ const result = planRefusalTool(root).migrate();
+
+ assert.equal(result.previousTier, null, "this project predates the protection state file");
+ assert.equal(result.state.tier, "local");
+ assert.equal(result.state.verified, true);
+ assert.equal(result.state.visibility, "private", "visibility is preserved through migration");
+ assert.equal(result.state.visibilityChanged, false);
+ assert.equal(result.gate.satisfied, true);
+
+ assert.equal(result.blockers.clear.length, 1);
+ assert.equal(result.blockers.preserve.length, 2);
+ assert.deepEqual(result.recordUpdates.continueMd.clear, result.blockers.clear);
+ assert.deepEqual(result.recordUpdates.continueMd.preserve, result.blockers.preserve);
+ assert.match(result.recordUpdates.continueMd.note, /narrower trust boundary/);
+
+ assert.match(result.recordUpdates.decisionsMd, /managed local enforcement/);
+ assert.match(result.recordUpdates.decisionsMd, /was not changed/);
+ assert.match(result.recordUpdates.decisionsMd, /Trust boundary:/);
+ assert.match(result.recordUpdates.environmentMd, /## Default-branch protection/);
+
+ assert.match(result.resumeAt, /satisfied/);
+
+ // Migration touches the file it owns and no lifecycle file.
+ assert.equal(fs.readFileSync(path.join(root, "CONTINUE.md"), "utf8"), BLOCKED_CONTINUE);
+ assert.ok(fs.existsSync(path.join(root, ".forge", "protection.json")));
+ } finally {
+ cleanup(root);
+ }
+});
+
+test("requirement 14: a project that previously recorded a failed remote attempt migrates too", () => {
+ const root = makeSandbox({
+ protectionState: {
+ tier: "remote",
+ mechanism: "github-ruleset",
+ verified: false,
+ problem: "ruleset creation refused: Upgrade to GitHub Pro",
+ },
+ files: { "CONTINUE.md": BLOCKED_CONTINUE },
+ });
+ try {
+ const result = planRefusalTool(root).migrate();
+ assert.equal(result.previousTier, "remote");
+ assert.equal(result.state.tier, "local");
+ assert.equal(result.gate.satisfied, true);
+ assert.equal(result.state.problem, undefined, "the stale failure must not survive the migration");
+ } finally {
+ cleanup(root);
+ }
+});
+
+/* ---------------- the gate as a command ---------------- */
+
+test("the gate subcommand exits zero only when the gate is satisfied", () => {
+ const satisfied = makeSandbox({
+ protectionState: {
+ tier: "local",
+ mechanism: "managed-pre-push-guard",
+ protections: ["deletion", "non-fast-forward"],
+ verified: true,
+ trustBoundary: bp.TRUST_BOUNDARY_LOCAL,
+ },
+ });
+ const unsatisfied = makeSandbox({
+ protectionState: {
+ tier: "local",
+ protections: ["deletion", "non-fast-forward"],
+ verified: false,
+ },
+ });
+ try {
+ const good = spawnSync(process.execPath, [TOOL_SRC, "gate"], { cwd: satisfied, encoding: "utf8" });
+ assert.equal(good.status, 0, good.stdout + good.stderr);
+ assert.match(good.stdout, /GATE SATISFIED/);
+
+ const bad = spawnSync(process.execPath, [TOOL_SRC, "gate"], { cwd: unsatisfied, encoding: "utf8" });
+ assert.equal(bad.status, 1);
+ assert.match(bad.stdout, /GATE NOT SATISFIED/);
+ } finally {
+ cleanup(satisfied);
+ cleanup(unsatisfied);
+ }
+});
diff --git a/tests/helpers/sandbox.js b/tests/helpers/sandbox.js
new file mode 100644
index 0000000..aa8ddc5
--- /dev/null
+++ b/tests/helpers/sandbox.js
@@ -0,0 +1,161 @@
+"use strict";
+
+/*
+ * Disposable working directories for the protection tests.
+ *
+ * Every path here comes from fs.mkdtemp under the system temp directory with
+ * the forge selftest prefix, and every removal goes through the tool's own
+ * removeDisposable, which refuses anything that is not such a directory. No
+ * test ever names a directory to delete.
+ */
+
+const fs = require("fs");
+const path = require("path");
+
+const REPO_ROOT = path.resolve(__dirname, "..", "..");
+const GUARD_SRC = path.join(REPO_ROOT, "templates", "history-guard.js");
+const TOOL_SRC = path.join(REPO_ROOT, "templates", "branch-protection.js");
+const LEFTHOOK_SRC = path.join(REPO_ROOT, "templates", "lefthook.yml");
+
+const tool = require(TOOL_SRC);
+
+/*
+ * A stand-in project: .forge/history-guard.js present, protection state
+ * naming main as the default branch, and nothing else. Callers add what a
+ * particular case needs.
+ */
+function makeSandbox(options) {
+ const opts = options || {};
+ const root = tool.createDisposableRoot();
+ fs.mkdirSync(path.join(root, ".forge"), { recursive: true });
+ if (opts.guard !== false) {
+ fs.copyFileSync(GUARD_SRC, path.join(root, ".forge", "history-guard.js"));
+ }
+ if (opts.protectionState !== false) {
+ fs.writeFileSync(
+ path.join(root, ".forge", "protection.json"),
+ JSON.stringify(
+ Object.assign({ schema: 1, defaultBranch: "main" }, opts.protectionState || {}),
+ null,
+ 2
+ ) + "\n",
+ "utf8"
+ );
+ }
+ if (opts.lefthook) {
+ fs.copyFileSync(LEFTHOOK_SRC, path.join(root, "lefthook.yml"));
+ // `lefthook install` writes a dispatcher into .git/hooks. Without it
+ // nothing in lefthook.yml runs, so the default sandbox has one and a test
+ // that cares about its absence opts out.
+ if (opts.lefthookInstalled !== false) {
+ const hookDir = path.join(root, ".git", "hooks");
+ fs.mkdirSync(hookDir, { recursive: true });
+ fs.writeFileSync(
+ path.join(hookDir, "pre-push"),
+ "#!/bin/sh\nlefthook run pre-push \"$@\"\n",
+ "utf8"
+ );
+ }
+ }
+ if (opts.files) {
+ for (const rel of Object.keys(opts.files)) {
+ const file = path.join(root, rel);
+ fs.mkdirSync(path.dirname(file), { recursive: true });
+ fs.writeFileSync(file, opts.files[rel], "utf8");
+ }
+ }
+ return root;
+}
+
+function cleanup(root) {
+ tool.removeDisposable(root);
+}
+
+function guardPath(root) {
+ return path.join(root, ".forge", "history-guard.js");
+}
+
+/*
+ * A recording stub for the tool's command runner. Handlers are tried in
+ * order; the first whose match returns true supplies the reply. An unmatched
+ * call is a loud failure rather than a silent empty success, so a test cannot
+ * pass because a command it forgot to model quietly did nothing.
+ */
+function recordingRunner(handlers) {
+ const calls = [];
+ function run(bin, args, opts) {
+ const call = { bin: bin, args: args.slice(), input: (opts && opts.input) || null };
+ calls.push(call);
+ for (const handler of handlers) {
+ if (handler.match(bin, args, opts)) {
+ const reply = handler.reply;
+ const value = typeof reply === "function" ? reply(bin, args, opts) : reply;
+ return Object.assign({ status: 0, stdout: "", stderr: "" }, value);
+ }
+ }
+ return {
+ status: 1,
+ stdout: "",
+ stderr: "test stub: unmodelled call: " + bin + " " + args.join(" "),
+ };
+ }
+ run.calls = calls;
+ run.find = function (predicate) {
+ return calls.filter(predicate);
+ };
+ return run;
+}
+
+function argsAre(expected) {
+ return function (bin, args) {
+ return args.join(" ") === expected;
+ };
+}
+
+function argsStartWith(bin, expected) {
+ return function (actualBin, args) {
+ return actualBin === bin && args.join(" ").indexOf(expected) === 0;
+ };
+}
+
+function json(value) {
+ return { status: 0, stdout: JSON.stringify(value), stderr: "" };
+}
+
+/*
+ * The git calls the tool makes while establishing local context. Modelled once
+ * so each test only declares the provider behaviour it actually cares about.
+ */
+function gitContextHandlers(overrides) {
+ const o = overrides || {};
+ return [
+ {
+ match: argsAre("remote get-url origin"),
+ reply: { status: 0, stdout: (o.remoteUrl || "https://github.com/acme/widget.git") + "\n" },
+ },
+ {
+ match: argsAre("symbolic-ref --short refs/remotes/origin/HEAD"),
+ reply: { status: 1, stdout: "", stderr: "not a symbolic ref" },
+ },
+ { match: argsAre("branch --show-current"), reply: { status: 0, stdout: "main\n" } },
+ { match: argsAre("config --get init.defaultBranch"), reply: { status: 1, stdout: "" } },
+ { match: argsAre("config --get core.hooksPath"), reply: { status: 1, stdout: "" } },
+ { match: argsAre("rev-parse --git-dir"), reply: { status: 0, stdout: ".git\n" } },
+ ];
+}
+
+module.exports = {
+ REPO_ROOT: REPO_ROOT,
+ GUARD_SRC: GUARD_SRC,
+ TOOL_SRC: TOOL_SRC,
+ LEFTHOOK_SRC: LEFTHOOK_SRC,
+ tool: tool,
+ makeSandbox: makeSandbox,
+ cleanup: cleanup,
+ guardPath: guardPath,
+ recordingRunner: recordingRunner,
+ argsAre: argsAre,
+ argsStartWith: argsStartWith,
+ json: json,
+ gitContextHandlers: gitContextHandlers,
+};
diff --git a/tests/history-guard.test.js b/tests/history-guard.test.js
new file mode 100644
index 0000000..8a6a493
--- /dev/null
+++ b/tests/history-guard.test.js
@@ -0,0 +1,264 @@
+"use strict";
+
+/*
+ * The managed local guard: what it lets through and what it refuses.
+ *
+ * Covers the four decision cases the policy names (fast-forward accepted,
+ * protected-branch deletion refused, non-fast-forward refused, initial branch
+ * creation accepted), plus the two ways it is asked to fail closed.
+ */
+
+const test = require("node:test");
+const assert = require("node:assert");
+const fs = require("fs");
+const path = require("path");
+const { spawnSync } = require("child_process");
+
+const guard = require("../templates/history-guard.js");
+const { makeSandbox, cleanup, guardPath } = require("./helpers/sandbox.js");
+
+const ZERO = "0".repeat(40);
+const OID_A = "1111111111111111111111111111111111111111";
+const OID_B = "2222222222222222222222222222222222222222";
+
+function alwaysAncestor(verdict) {
+ return function () {
+ return verdict;
+ };
+}
+
+function runGuard(cwd, input, args) {
+ return spawnSync(process.execPath, [guardPath(cwd)].concat(args || []), {
+ cwd: cwd,
+ input: input,
+ encoding: "utf8",
+ });
+}
+
+function gitIn(cwd, args) {
+ const res = spawnSync("git", args, { cwd: cwd, encoding: "utf8" });
+ if (res.status !== 0) {
+ throw new Error("git " + args.join(" ") + " failed: " + res.stderr + res.stdout);
+ }
+ return res.stdout.trim();
+}
+
+/* ---------------- ref record parsing ---------------- */
+
+test("parses the four field ref records git writes to stdin", () => {
+ const { updates, malformed } = guard.parseRefLines(
+ "refs/heads/main " + OID_B + " refs/heads/main " + OID_A + "\n\n"
+ );
+ assert.equal(malformed.length, 0);
+ assert.deepEqual(updates, [
+ {
+ localRef: "refs/heads/main",
+ localOid: OID_B,
+ remoteRef: "refs/heads/main",
+ remoteOid: OID_A,
+ },
+ ]);
+});
+
+test("a record that is not four fields is malformed, not ignored", () => {
+ const { updates, malformed } = guard.parseRefLines("refs/heads/main " + OID_B + "\n");
+ assert.equal(updates.length, 0);
+ assert.deepEqual(malformed, ["refs/heads/main " + OID_B]);
+});
+
+test("the null object id is recognised at both sha1 and sha256 widths", () => {
+ assert.equal(guard.isNullOid("0".repeat(40)), true);
+ assert.equal(guard.isNullOid("0".repeat(64)), true);
+ assert.equal(guard.isNullOid(OID_A), false);
+ assert.equal(guard.isNullOid(""), false);
+});
+
+/* ---------------- the four decisions ---------------- */
+
+test("requirement 5: a fast-forward update of the protected branch is allowed", () => {
+ const verdict = guard.decide(
+ { localRef: "refs/heads/main", localOid: OID_B, remoteRef: "refs/heads/main", remoteOid: OID_A },
+ "refs/heads/main",
+ alwaysAncestor("yes")
+ );
+ assert.deepEqual(verdict, { action: "allow", reason: "fast-forward" });
+});
+
+test("requirement 6: deleting the protected branch is rejected", () => {
+ const verdict = guard.decide(
+ { localRef: "(delete)", localOid: ZERO, remoteRef: "refs/heads/main", remoteOid: OID_A },
+ "refs/heads/main",
+ alwaysAncestor("yes")
+ );
+ assert.deepEqual(verdict, { action: "reject", reason: "deletion" });
+});
+
+test("requirement 7: a non-fast-forward update is rejected", () => {
+ const verdict = guard.decide(
+ { localRef: "refs/heads/main", localOid: OID_B, remoteRef: "refs/heads/main", remoteOid: OID_A },
+ "refs/heads/main",
+ alwaysAncestor("no")
+ );
+ assert.deepEqual(verdict, { action: "reject", reason: "non-fast-forward" });
+});
+
+test("requirement 8: creating the protected branch for the first time is allowed", () => {
+ const verdict = guard.decide(
+ { localRef: "refs/heads/main", localOid: OID_A, remoteRef: "refs/heads/main", remoteOid: ZERO },
+ "refs/heads/main",
+ alwaysAncestor("no")
+ );
+ assert.deepEqual(verdict, { action: "allow", reason: "creation" });
+});
+
+test("an update git cannot resolve is unverifiable, and unverifiable is not allowed", () => {
+ const verdict = guard.decide(
+ { localRef: "refs/heads/main", localOid: OID_B, remoteRef: "refs/heads/main", remoteOid: OID_A },
+ "refs/heads/main",
+ alwaysAncestor("unknown")
+ );
+ assert.equal(verdict.action, "unverifiable");
+});
+
+test("other branches are not this guard's business", () => {
+ const verdict = guard.decide(
+ { localRef: "refs/heads/slice/x", localOid: ZERO, remoteRef: "refs/heads/slice/x", remoteOid: OID_A },
+ "refs/heads/main",
+ alwaysAncestor("no")
+ );
+ assert.deepEqual(verdict, { action: "allow", reason: "not-protected-ref" });
+});
+
+test("a batch containing one bad update fails the whole push", () => {
+ const raw = [
+ "refs/heads/slice/x " + OID_B + " refs/heads/slice/x " + OID_A,
+ "refs/heads/main " + ZERO + " refs/heads/main " + OID_A,
+ ].join("\n");
+ const evaluated = guard.evaluate(raw, "main", alwaysAncestor("yes"));
+ assert.equal(evaluated.results.filter((r) => r.verdict.action === "reject").length, 1);
+});
+
+/* ---------------- protected branch resolution ---------------- */
+
+test("the recorded protection state names the protected branch", () => {
+ const root = makeSandbox({ protectionState: { defaultBranch: "trunk" } });
+ try {
+ const resolved = guard.resolveProtectedBranch({ cwd: root });
+ assert.equal(resolved.branch, "trunk");
+ assert.equal(resolved.source, "protection-state");
+ } finally {
+ cleanup(root);
+ }
+});
+
+test("an explicit branch argument outranks the recorded state", () => {
+ const root = makeSandbox({ protectionState: { defaultBranch: "trunk" } });
+ try {
+ const resolved = guard.resolveProtectedBranch({ cwd: root, branch: "release" });
+ assert.equal(resolved.branch, "release");
+ assert.equal(resolved.source, "argument");
+ } finally {
+ cleanup(root);
+ }
+});
+
+test("init.defaultBranch never outranks a branch that actually exists", () => {
+ // A workstation configured with init.defaultBranch=master and a repository
+ // whose default branch is main is common. Trusting the config there would
+ // protect a branch that does not exist and leave the real one open.
+ const root = makeSandbox({ protectionState: false });
+ try {
+ gitIn(root, ["-c", "init.defaultBranch=main", "init", "."]);
+ gitIn(root, ["config", "user.email", "t@example.invalid"]);
+ gitIn(root, ["config", "user.name", "T"]);
+ gitIn(root, ["config", "init.defaultBranch", "master"]);
+ fs.writeFileSync(path.join(root, "f.txt"), "x\n", "utf8");
+ gitIn(root, ["add", "-A"]);
+ gitIn(root, ["-c", "commit.gpgsign=false", "commit", "-m", "chore: init"]);
+
+ const resolved = guard.resolveProtectedBranch({ cwd: root });
+ assert.equal(resolved.branch, "main");
+ assert.equal(resolved.source, "existing-branch");
+ } finally {
+ cleanup(root);
+ }
+});
+
+/* ---------------- process level, exit codes git acts on ---------------- */
+
+test("requirement 6, end to end: a deletion record makes the guard exit non-zero", () => {
+ const root = makeSandbox();
+ try {
+ const res = runGuard(root, "(delete) " + ZERO + " refs/heads/main " + OID_A + "\n");
+ assert.equal(res.status, 1);
+ assert.match(res.stderr, /PUSH REFUSED, branch deletion/);
+ } finally {
+ cleanup(root);
+ }
+});
+
+test("requirement 8, end to end: a creation record exits zero", () => {
+ const root = makeSandbox();
+ try {
+ const res = runGuard(root, "refs/heads/main " + OID_A + " refs/heads/main " + ZERO + "\n");
+ assert.equal(res.status, 0);
+ } finally {
+ cleanup(root);
+ }
+});
+
+test("requirements 5 and 7, end to end against real git objects", () => {
+ const root = makeSandbox();
+ try {
+ gitIn(root, ["-c", "init.defaultBranch=main", "init", "."]);
+ gitIn(root, ["config", "user.email", "t@example.invalid"]);
+ gitIn(root, ["config", "user.name", "T"]);
+ gitIn(root, ["config", "commit.gpgsign", "false"]);
+
+ fs.writeFileSync(path.join(root, "a.txt"), "a\n", "utf8");
+ gitIn(root, ["add", "-A"]);
+ gitIn(root, ["commit", "-m", "chore: a"]);
+ const first = gitIn(root, ["rev-parse", "HEAD"]);
+
+ fs.writeFileSync(path.join(root, "b.txt"), "b\n", "utf8");
+ gitIn(root, ["add", "-A"]);
+ gitIn(root, ["commit", "-m", "chore: b"]);
+ const second = gitIn(root, ["rev-parse", "HEAD"]);
+
+ const forward = runGuard(
+ root,
+ "refs/heads/main " + second + " refs/heads/main " + first + "\n"
+ );
+ assert.equal(forward.status, 0, forward.stderr);
+
+ const backward = runGuard(
+ root,
+ "refs/heads/main " + first + " refs/heads/main " + second + "\n"
+ );
+ assert.equal(backward.status, 1);
+ assert.match(backward.stderr, /non-fast-forward/);
+ } finally {
+ cleanup(root);
+ }
+});
+
+test("a malformed record fails closed rather than being skipped", () => {
+ const root = makeSandbox();
+ try {
+ const res = runGuard(root, "refs/heads/main " + OID_A + "\n");
+ assert.equal(res.status, 2);
+ assert.match(res.stderr, /MALFORMED REF RECORD/);
+ } finally {
+ cleanup(root);
+ }
+});
+
+test("the refusal message forbids working around it with --no-verify", () => {
+ const root = makeSandbox();
+ try {
+ const res = runGuard(root, "(delete) " + ZERO + " refs/heads/main " + OID_A + "\n");
+ assert.match(res.stderr, /--no-verify is prohibited/);
+ } finally {
+ cleanup(root);
+ }
+});
diff --git a/tests/hook-wiring.test.js b/tests/hook-wiring.test.js
new file mode 100644
index 0000000..43602af
--- /dev/null
+++ b/tests/hook-wiring.test.js
@@ -0,0 +1,418 @@
+"use strict";
+
+/*
+ * Hook wiring: the part that is easy to get wrong and silent when you do.
+ *
+ * lefthook only forwards git's ref-update records to a command that declares
+ * use_stdin: true. A guard wired without it sees nothing. This suite proves
+ * the shipped template declares it, that the checker catches a template that
+ * does not, and that a starved guard fails closed instead of waving pushes
+ * through.
+ */
+
+const test = require("node:test");
+const assert = require("node:assert");
+const fs = require("fs");
+const path = require("path");
+const { spawnSync } = require("child_process");
+
+const bp = require("../templates/branch-protection.js");
+const {
+ makeSandbox,
+ cleanup,
+ guardPath,
+ LEFTHOOK_SRC,
+ recordingRunner,
+ argsAre,
+} = require("./helpers/sandbox.js");
+
+const ZERO = "0".repeat(40);
+const OID_A = "1111111111111111111111111111111111111111";
+const DELETE_RECORD = "(delete) " + ZERO + " refs/heads/main " + OID_A + "\n";
+
+const SHIPPED_LEFTHOOK = fs.readFileSync(LEFTHOOK_SRC, "utf8");
+
+/* ---------------- the shipped template ---------------- */
+
+test("the shipped lefthook template runs the history check first, with use_stdin", () => {
+ const wiring = bp.checkLefthookWiring(SHIPPED_LEFTHOOK, "history-guard.js");
+ assert.equal(wiring.found, true);
+ assert.equal(wiring.useStdin, true, "use_stdin: true is what feeds the guard git's ref records");
+ assert.equal(wiring.runsFirst, true, "the history check must precede the expensive quality checks");
+ assert.equal(wiring.ok, true);
+ assert.equal(wiring.problem, null);
+});
+
+test("the quality commands survive alongside the history check", () => {
+ const commands = bp.parseLefthookPrePush(SHIPPED_LEFTHOOK);
+ const keys = commands.map((c) => c.key);
+ assert.equal(keys[0], "00_history");
+ for (const expected of ["01_secrets", "02_lint", "03_build", "04_test"]) {
+ assert.ok(keys.indexOf(expected) > 0, "expected the " + expected + " command to remain wired");
+ }
+});
+
+/* ---------------- requirement 10: wiring that cannot deliver input ---------- */
+
+test("requirement 10: a guard wired without use_stdin is reported, not accepted", () => {
+ const broken = SHIPPED_LEFTHOOK.replace(/^\s*use_stdin: true\s*$/m, "");
+ const wiring = bp.checkLefthookWiring(broken, "history-guard.js");
+ assert.equal(wiring.found, true);
+ assert.equal(wiring.useStdin, false);
+ assert.equal(wiring.ok, false);
+ assert.match(wiring.problem, /use_stdin/);
+});
+
+test("a history check placed after the quality commands is reported", () => {
+ const yaml = [
+ "pre-push:",
+ " parallel: false",
+ " commands:",
+ " 01_test:",
+ " run: npm test",
+ " 02_history:",
+ " use_stdin: true",
+ " run: node .forge/history-guard.js",
+ ].join("\n");
+ const wiring = bp.checkLefthookWiring(yaml, "history-guard.js");
+ assert.equal(wiring.ok, false);
+ assert.match(wiring.problem, /must run before the expensive/);
+});
+
+test("a lefthook file with no history command at all is reported", () => {
+ const yaml = ["pre-push:", " commands:", " 01_test:", " run: npm test"].join("\n");
+ const wiring = bp.checkLefthookWiring(yaml, "history-guard.js");
+ assert.equal(wiring.found, false);
+ assert.match(wiring.problem, /no pre-push command runs/);
+});
+
+test("a pre-commit block cannot be mistaken for the pre-push block", () => {
+ const yaml = [
+ "pre-commit:",
+ " commands:",
+ " 00_history:",
+ " use_stdin: true",
+ " run: node .forge/history-guard.js",
+ "pre-push:",
+ " commands:",
+ " 01_test:",
+ " run: npm test",
+ ].join("\n");
+ assert.equal(bp.checkLefthookWiring(yaml, "history-guard.js").found, false);
+});
+
+/* ---------------- requirement 9: stdin propagation ---------------- */
+
+test("requirement 9: a manager that forwards stdin lets the guard see the ref records", () => {
+ const root = makeSandbox();
+ try {
+ // Stands in for lefthook with use_stdin: true. It inherits its own stdin,
+ // which is exactly what forwarding the records means.
+ fs.writeFileSync(
+ path.join(root, "forwarding-manager.js"),
+ [
+ '"use strict";',
+ 'const { spawnSync } = require("child_process");',
+ "const res = spawnSync(process.execPath, [" +
+ JSON.stringify(guardPath(root)) +
+ '], { stdio: "inherit" });',
+ "process.exit(res.status === null ? 1 : res.status);",
+ "",
+ ].join("\n"),
+ "utf8"
+ );
+
+ const res = spawnSync(process.execPath, [path.join(root, "forwarding-manager.js")], {
+ cwd: root,
+ input: DELETE_RECORD,
+ encoding: "utf8",
+ });
+ assert.equal(res.status, 1, "the guard saw the deletion record and refused");
+ assert.match(res.stderr, /PUSH REFUSED, branch deletion/);
+ } finally {
+ cleanup(root);
+ }
+});
+
+test("requirement 10: a manager that swallows stdin makes the guard fail closed", () => {
+ const root = makeSandbox();
+ try {
+ // Stands in for lefthook WITHOUT use_stdin: the child gets an empty pipe.
+ fs.writeFileSync(
+ path.join(root, "swallowing-manager.js"),
+ [
+ '"use strict";',
+ 'const { spawnSync } = require("child_process");',
+ "const res = spawnSync(process.execPath, [" +
+ JSON.stringify(guardPath(root)) +
+ '], { input: "", encoding: "utf8" });',
+ "process.stderr.write(res.stderr);",
+ "process.exit(res.status === null ? 1 : res.status);",
+ "",
+ ].join("\n"),
+ "utf8"
+ );
+
+ const res = spawnSync(process.execPath, [path.join(root, "swallowing-manager.js")], {
+ cwd: root,
+ input: DELETE_RECORD,
+ encoding: "utf8",
+ });
+ assert.equal(res.status, 2, "a guard that cannot see the refs must not report success");
+ assert.match(res.stderr, /NO REF UPDATES ON STDIN/);
+ assert.match(res.stderr, /use_stdin: true/, "the message must name the actual fix");
+ } finally {
+ cleanup(root);
+ }
+});
+
+/* ---------------- installation ---------------- */
+
+function localToolFor(root) {
+ const run = recordingRunner([
+ { match: argsAre("rev-parse --git-dir"), reply: { status: 0, stdout: ".git\n" } },
+ ]);
+ return bp.createTool({ cwd: root, run: run });
+}
+
+test("with no hook manager present, a plain pre-push hook is written", () => {
+ const root = makeSandbox();
+ try {
+ const result = localToolFor(root).installLocal();
+ assert.equal(result.installed, true);
+ assert.equal(result.manager, "git");
+ const body = fs.readFileSync(path.join(root, ".git", "hooks", "pre-push"), "utf8");
+ assert.match(body, /forge managed history-integrity guard/);
+ assert.match(body, /history-guard\.js/);
+ assert.match(body, /"\$@"/, "the hook must pass git's arguments through");
+ } finally {
+ cleanup(root);
+ }
+});
+
+test("an existing hook someone else wrote is never overwritten", () => {
+ const root = makeSandbox();
+ try {
+ const hookDir = path.join(root, ".git", "hooks");
+ fs.mkdirSync(hookDir, { recursive: true });
+ const existing = "#!/bin/sh\necho someone elses hook\n";
+ fs.writeFileSync(path.join(hookDir, "pre-push"), existing, "utf8");
+
+ const result = localToolFor(root).installLocal();
+ assert.equal(result.installed, false);
+ assert.match(result.problem, /already exists/);
+ assert.equal(fs.readFileSync(path.join(hookDir, "pre-push"), "utf8"), existing);
+ assert.ok(result.requiredSnippet, "the caller needs the snippet to merge by hand");
+ } finally {
+ cleanup(root);
+ }
+});
+
+test("when lefthook manages hooks, the wiring is checked rather than rewritten", () => {
+ const root = makeSandbox({ lefthook: true });
+ try {
+ const before = fs.readFileSync(path.join(root, "lefthook.yml"), "utf8");
+ const hookBefore = fs.readFileSync(path.join(root, ".git", "hooks", "pre-push"), "utf8");
+ const result = localToolFor(root).installLocal();
+ assert.equal(result.manager, "lefthook");
+ assert.equal(result.installed, true);
+ assert.equal(fs.readFileSync(path.join(root, "lefthook.yml"), "utf8"), before);
+ assert.equal(
+ fs.readFileSync(path.join(root, ".git", "hooks", "pre-push"), "utf8"),
+ hookBefore,
+ "the lefthook dispatcher must not be overwritten"
+ );
+ } finally {
+ cleanup(root);
+ }
+});
+
+test("a correct lefthook.yml that was never installed is not reported as protected", () => {
+ // lefthook.yml alone does nothing. Without the dispatcher in .git/hooks,
+ // git never invokes lefthook and the guard never runs.
+ const root = makeSandbox({ lefthook: true, lefthookInstalled: false });
+ try {
+ const result = localToolFor(root).installLocal();
+ assert.equal(result.installed, false);
+ assert.match(result.problem, /lefthook install/);
+ } finally {
+ cleanup(root);
+ }
+});
+
+test("a hook written where core.hooksPath does not point is not a live hook", () => {
+ const root = makeSandbox();
+ try {
+ const run = recordingRunner([
+ { match: argsAre("config --get core.hooksPath"), reply: { status: 0, stdout: ".githooks\n" } },
+ { match: argsAre("rev-parse --git-dir"), reply: { status: 0, stdout: ".git\n" } },
+ ]);
+ const result = bp.createTool({ cwd: root, run: run }).installLocal();
+ assert.equal(result.installed, true);
+ assert.equal(
+ fs.existsSync(path.join(root, ".githooks", "pre-push")),
+ true,
+ "the hook must be written where git will actually look for it"
+ );
+ assert.equal(fs.existsSync(path.join(root, ".git", "hooks", "pre-push")), false);
+ } finally {
+ cleanup(root);
+ }
+});
+
+test("verify does not resurrect a hook someone deleted", () => {
+ const root = makeSandbox({
+ protectionState: {
+ defaultBranch: "main",
+ tier: "local",
+ mechanism: "managed-pre-push-guard",
+ protections: ["deletion", "non-fast-forward"],
+ verified: true,
+ trustBoundary: bp.TRUST_BOUNDARY_LOCAL,
+ },
+ });
+ try {
+ const tool = localToolFor(root);
+ assert.equal(tool.installLocal().installed, true);
+
+ // The user disables the guard by deleting the hook. verify must notice.
+ fs.rmSync(path.join(root, ".git", "hooks", "pre-push"));
+
+ const result = tool.verify();
+ assert.equal(result.verified, false, "a verify that reinstalls can never observe a missing hook");
+ assert.match(result.reason, /no pre-push hook/);
+ assert.equal(fs.existsSync(path.join(root, ".git", "hooks", "pre-push")), false);
+ assert.equal(tool.gateStatus().satisfied, false);
+ } finally {
+ cleanup(root);
+ }
+});
+
+/* ---------------- lefthook's real execution order ---------------- */
+
+test("execution order follows lefthook's rules, not the order of the file", () => {
+ // lefthook sorts by priority, then by the leading number in the command
+ // name, then alphabetically. A guard listed first but named 99_ runs last.
+ const yaml = [
+ "pre-push:",
+ " parallel: false",
+ " piped: true",
+ " commands:",
+ " 99_history:",
+ " use_stdin: true",
+ " run: node .forge/history-guard.js",
+ " 01_build:",
+ " run: npm run build",
+ ].join("\n");
+ const wiring = bp.checkLefthookWiring(yaml, "history-guard.js");
+ assert.equal(wiring.ok, false, "listed first, but lefthook runs 01_build before it");
+ assert.match(wiring.problem, /after 1 other command/);
+});
+
+test("a guard listed last but named 00_ is correctly accepted", () => {
+ const yaml = [
+ "pre-push:",
+ " parallel: false",
+ " piped: true",
+ " commands:",
+ " 01_build:",
+ " run: npm run build",
+ " 00_history:",
+ " use_stdin: true",
+ " run: node .forge/history-guard.js",
+ ].join("\n");
+ const wiring = bp.checkLefthookWiring(yaml, "history-guard.js");
+ assert.equal(wiring.ok, true, "lefthook orders by name, so 00_history runs first");
+});
+
+test("an explicit priority is honoured over the name", () => {
+ const yaml = [
+ "pre-push:",
+ " parallel: false",
+ " piped: true",
+ " commands:",
+ " 00_history:",
+ " use_stdin: true",
+ " run: node .forge/history-guard.js",
+ " 01_build:",
+ " priority: 1",
+ " run: npm run build",
+ ].join("\n");
+ const wiring = bp.checkLefthookWiring(yaml, "history-guard.js");
+ assert.equal(wiring.ok, false);
+ assert.match(wiring.problem, /after 1 other command/);
+});
+
+test("parallel: true makes the ordering guarantee meaningless and is reported", () => {
+ const yaml = [
+ "pre-push:",
+ " parallel: true",
+ " piped: true",
+ " commands:",
+ " 00_history:",
+ " use_stdin: true",
+ " run: node .forge/history-guard.js",
+ ].join("\n");
+ const wiring = bp.checkLefthookWiring(yaml, "history-guard.js");
+ assert.equal(wiring.ok, false);
+ assert.match(wiring.problem, /parallel: true/);
+});
+
+test("without piped: true the expensive checks still run after a refusal", () => {
+ const yaml = [
+ "pre-push:",
+ " parallel: false",
+ " commands:",
+ " 00_history:",
+ " use_stdin: true",
+ " run: node .forge/history-guard.js",
+ " 01_test:",
+ " run: npm test",
+ ].join("\n");
+ const wiring = bp.checkLefthookWiring(yaml, "history-guard.js");
+ assert.equal(wiring.ok, false);
+ assert.match(wiring.problem, /piped: true/);
+});
+
+test("a nested skip block cannot masquerade as the command's own run", () => {
+ const yaml = [
+ "pre-push:",
+ " parallel: false",
+ " piped: true",
+ " commands:",
+ " 00_history:",
+ " use_stdin: true",
+ " run: node .forge/history-guard.js",
+ " skip:",
+ " - run: git rev-parse --abbrev-ref HEAD | grep wip",
+ ].join("\n");
+ const wiring = bp.checkLefthookWiring(yaml, "history-guard.js");
+ assert.equal(wiring.found, true, "the nested run: must not overwrite the command's own");
+ assert.equal(wiring.ok, false, "a conditional guard does not always run");
+ assert.match(wiring.problem, /skip or only condition/);
+});
+
+test("a broken lefthook wiring blocks installation and hands back the snippet", () => {
+ const root = makeSandbox({ lefthook: true });
+ try {
+ const file = path.join(root, "lefthook.yml");
+ fs.writeFileSync(file, fs.readFileSync(file, "utf8").replace(/^\s*use_stdin: true\s*$/m, ""), "utf8");
+ const result = localToolFor(root).installLocal();
+ assert.equal(result.installed, false);
+ assert.match(result.problem, /use_stdin/);
+ assert.match(result.requiredSnippet, /use_stdin: true/);
+ } finally {
+ cleanup(root);
+ }
+});
+
+test("a missing guard file is a problem, not a silently empty install", () => {
+ const root = makeSandbox({ guard: false });
+ try {
+ const result = localToolFor(root).installLocal();
+ assert.equal(result.installed, false);
+ assert.match(result.problem, /missing at \.forge\/history-guard\.js/);
+ } finally {
+ cleanup(root);
+ }
+});
diff --git a/tests/protection-capability.test.js b/tests/protection-capability.test.js
new file mode 100644
index 0000000..7e16220
--- /dev/null
+++ b/tests/protection-capability.test.js
@@ -0,0 +1,526 @@
+"use strict";
+
+/*
+ * Capability detection and tier selection.
+ *
+ * The tool must take server-side enforcement whenever the host and account
+ * actually provide it, fall back to the managed local guard when they do not,
+ * and never reach for the third option a paid-plan refusal dangles in front of
+ * it, which is publishing a private repository.
+ */
+
+const test = require("node:test");
+const assert = require("node:assert");
+const fs = require("fs");
+const path = require("path");
+
+const bp = require("../templates/branch-protection.js");
+const {
+ makeSandbox,
+ cleanup,
+ recordingRunner,
+ argsAre,
+ json,
+ gitContextHandlers,
+} = require("./helpers/sandbox.js");
+
+const PLAN_REFUSAL =
+ "gh: Upgrade to GitHub Pro or make this repository public to enable this feature. (HTTP 403)";
+
+function githubRepo(overrides) {
+ return Object.assign(
+ {
+ full_name: "acme/widget",
+ private: false,
+ default_branch: "main",
+ owner: { type: "Organization" },
+ permissions: { admin: true },
+ },
+ overrides || {}
+ );
+}
+
+/*
+ * A GitHub stub that keeps its ruleset list, so create and read-back are the
+ * same object rather than two unrelated canned answers.
+ */
+function githubHandlers(options) {
+ const opts = options || {};
+ const rulesets = opts.rulesets || [];
+ const state = { rulesets: rulesets.slice() };
+ return {
+ state: state,
+ handlers: [
+ { match: (bin, args) => bin === "gh" && args[0] === "--version", reply: { status: 0, stdout: "gh 2.0\n" } },
+ {
+ match: (bin, args) => bin === "gh" && args.join(" ") === "api repos/acme/widget",
+ reply: () =>
+ opts.repoFailure
+ ? opts.repoFailure
+ : json(githubRepo(opts.repo)),
+ },
+ {
+ match: (bin, args) => bin === "gh" && args.join(" ") === "api user",
+ reply: () => json({ plan: { name: opts.plan || "pro" } }),
+ },
+ {
+ match: (bin, args) =>
+ bin === "gh" && /repos\/acme\/widget\/rulesets\?includes_parents=false$/.test(args.join(" ")),
+ reply: () => json(state.rulesets),
+ },
+ {
+ match: (bin, args) =>
+ bin === "gh" &&
+ args.indexOf("--method") !== -1 &&
+ /rulesets(\/\d+)?$/.test(args[args.indexOf("--method") + 2] || ""),
+ reply: () => {
+ if (opts.createFailure) return opts.createFailure;
+ const created = { id: 42, name: bp.RULESET_NAME };
+ state.rulesets.push(created);
+ return json(created);
+ },
+ },
+ {
+ match: (bin, args) => bin === "gh" && /^api repos\/acme\/widget\/rulesets\/\d+$/.test(args.join(" ")),
+ reply: () =>
+ json({
+ id: 42,
+ name: bp.RULESET_NAME,
+ enforcement: opts.enforcement || "active",
+ rules: opts.rules || [{ type: "deletion" }, { type: "non_fast_forward" }],
+ }),
+ },
+ ],
+ };
+}
+
+function toolFor(root, handlers, remoteUrl) {
+ const run = recordingRunner(gitContextHandlers({ remoteUrl: remoteUrl }).concat(handlers));
+ const tool = bp.createTool({
+ cwd: root,
+ run: run,
+ now: () => "2026-08-02T00:00:00.000Z",
+ });
+ return { tool: tool, run: run };
+}
+
+/* ---------------- URL and provider identification ---------------- */
+
+test("remote URLs are parsed in every shape git accepts", () => {
+ assert.deepEqual(bp.parseRemoteUrl("https://github.com/acme/widget.git"), {
+ host: "github.com",
+ slug: "acme/widget",
+ local: false,
+ url: null,
+ });
+ assert.deepEqual(bp.parseRemoteUrl("git@github.com:acme/widget.git"), {
+ host: "github.com",
+ slug: "acme/widget",
+ local: false,
+ url: null,
+ });
+ assert.equal(bp.parseRemoteUrl("ssh://git@gitlab.example.net:2222/team/sub/widget.git").slug, "team/sub/widget");
+ assert.equal(bp.parseRemoteUrl("/srv/git/widget.git").local, true);
+ assert.equal(bp.parseRemoteUrl(""), null);
+});
+
+test("providers are identified by host, including self-hosted instances", () => {
+ assert.equal(bp.identifyProvider("github.com"), "github");
+ assert.equal(bp.identifyProvider("github.acme-corp.net"), "github");
+ assert.equal(bp.identifyProvider("gitlab.com"), "gitlab");
+ assert.equal(bp.identifyProvider("gitlab.internal.example"), "gitlab");
+ assert.equal(bp.identifyProvider("git.internal.example.net"), "unknown");
+ assert.equal(bp.identifyProvider("mygithub.example.net"), "unknown");
+ assert.equal(bp.identifyProvider(null), "none");
+});
+
+/* ---------------- failure classification ---------------- */
+
+test("a paid-plan refusal is classified apart from a permission refusal", () => {
+ assert.equal(bp.classifyRemoteFailure(1, PLAN_REFUSAL).kind, "plan");
+ assert.equal(
+ bp.classifyRemoteFailure(1, "gh: Resource not accessible by integration (HTTP 403)").kind,
+ "permission"
+ );
+ assert.equal(bp.classifyRemoteFailure(1, "gh: Not Found (HTTP 404)").kind, "unsupported-api");
+ assert.equal(bp.classifyRemoteFailure(1, "gh auth login required (HTTP 401)").kind, "auth");
+ assert.equal(bp.classifyRemoteFailure(127, "gh not found").kind, "tooling");
+});
+
+/* ---------------- requirement 1: server-side selection ---------------- */
+
+test("requirement 1: when the host supports it, server-side protection is selected and verified", () => {
+ const root = makeSandbox();
+ try {
+ const gh = githubHandlers({});
+ const { tool, run } = toolFor(root, gh.handlers);
+
+ const capability = tool.detect();
+ assert.equal(capability.provider, "github");
+ assert.equal(capability.repository, "acme/widget");
+ assert.equal(capability.serverSide, "likely");
+
+ const state = tool.apply({ capability: capability });
+ assert.equal(state.tier, "remote");
+ assert.equal(state.mechanism, "github-ruleset");
+ assert.equal(state.verified, true);
+ assert.deepEqual(state.protections, ["deletion", "non-fast-forward"]);
+ assert.equal(state.trustBoundary, bp.TRUST_BOUNDARY_REMOTE);
+ assert.equal(state.fallbackReason, null);
+
+ // The ruleset that was actually sent blocks exactly the two things the
+ // policy names, and nothing that would break ordinary pushes or --no-ff
+ // merges.
+ const post = run.calls.find((c) => c.args.indexOf("--method") !== -1);
+ const payload = JSON.parse(post.input);
+ assert.equal(payload.enforcement, "active");
+ assert.deepEqual(
+ payload.rules.map((r) => r.type).sort(),
+ ["deletion", "non_fast_forward"]
+ );
+ assert.deepEqual(payload.conditions.ref_name.include, ["~DEFAULT_BRANCH"]);
+ } finally {
+ cleanup(root);
+ }
+});
+
+test("tier 1 still reports the local guard's wiring, because a broken one blocks every push", () => {
+ const root = makeSandbox({ lefthook: true });
+ try {
+ // Server-side protection is in force, but the guard is wired without
+ // use_stdin. It would fail closed on every push and tier 1 alone would
+ // never notice.
+ const file = path.join(root, "lefthook.yml");
+ fs.writeFileSync(file, fs.readFileSync(file, "utf8").replace(/^\s*use_stdin: true\s*$/m, ""), "utf8");
+
+ const { tool } = toolFor(root, githubHandlers({}).handlers);
+ const state = tool.apply();
+
+ assert.equal(state.tier, "remote", "the server-side tier still satisfies the policy");
+ assert.equal(state.verified, true);
+ assert.equal(state.localGuard.wired, false);
+ assert.match(state.localGuard.problem, /use_stdin/);
+ const wiring = state.evidence.find((e) => e.case === "local guard wiring, defence in depth");
+ assert.equal(wiring.pass, false, "a present but unusable guard must be surfaced");
+ } finally {
+ cleanup(root);
+ }
+});
+
+test("a tier 1 project with no local guard file is not penalised for it", () => {
+ const root = makeSandbox({ guard: false });
+ try {
+ const { tool } = toolFor(root, githubHandlers({}).handlers);
+ const state = tool.apply();
+ assert.equal(state.tier, "remote");
+ const wiring = state.evidence.find((e) => e.case === "local guard wiring, defence in depth");
+ assert.equal(wiring.pass, true, "the local guard is optional when the host enforces the rule");
+ assert.equal(tool.gateStatus(state).satisfied, true);
+ } finally {
+ cleanup(root);
+ }
+});
+
+test("an existing forge ruleset is updated rather than duplicated", () => {
+ const root = makeSandbox();
+ try {
+ const gh = githubHandlers({ rulesets: [{ id: 42, name: bp.RULESET_NAME }] });
+ const { tool, run } = toolFor(root, gh.handlers);
+ const state = tool.apply();
+ assert.equal(state.tier, "remote");
+ const mutation = run.calls.find((c) => c.args.indexOf("--method") !== -1);
+ assert.equal(mutation.args[mutation.args.indexOf("--method") + 1], "PUT");
+ assert.match(mutation.args.join(" "), /rulesets\/42/);
+ } finally {
+ cleanup(root);
+ }
+});
+
+test("a ruleset that reads back missing a rule is not accepted as verified", () => {
+ const root = makeSandbox();
+ try {
+ const gh = githubHandlers({ rules: [{ type: "deletion" }] });
+ const { tool } = toolFor(root, gh.handlers);
+ const state = tool.apply();
+ assert.equal(state.tier, "local", "an unverifiable remote claim must not satisfy tier 1");
+ } finally {
+ cleanup(root);
+ }
+});
+
+/* ---------------- requirement 2: plan and permission rejection ---------------- */
+
+test("requirement 2: a GitHub paid-plan refusal falls back without changing visibility", () => {
+ const root = makeSandbox();
+ try {
+ const gh = githubHandlers({
+ repo: { private: true, owner: { type: "User" }, permissions: { admin: true } },
+ plan: "free",
+ createFailure: { status: 1, stdout: "", stderr: PLAN_REFUSAL },
+ });
+ const { tool, run } = toolFor(root, gh.handlers);
+
+ const capability = tool.detect();
+ assert.equal(capability.visibility, "private");
+ assert.equal(capability.serverSide, "unlikely", "a free personal plan is a hint, not a verdict");
+
+ const state = tool.apply();
+ assert.equal(state.tier, "local");
+ assert.equal(state.mechanism, "managed-pre-push-guard");
+ assert.match(state.fallbackReason, /^plan: /);
+ assert.equal(state.trustBoundary, bp.TRUST_BOUNDARY_LOCAL);
+
+ // Requirement 4, at the point it matters most: the one moment the host
+ // suggests making the repository public.
+ assert.equal(state.visibility, "private");
+ assert.equal(state.visibilityChanged, false);
+ const visibilityCalls = run.calls.filter((c) =>
+ /visibility|--public|--private/i.test(c.args.join(" ") + String(c.input || ""))
+ );
+ assert.deepEqual(visibilityCalls, [], "no call may touch repository visibility");
+ } finally {
+ cleanup(root);
+ }
+});
+
+test("requirement 2: a missing admin permission is reported as permission, not plan", () => {
+ const root = makeSandbox();
+ try {
+ const gh = githubHandlers({ repo: { permissions: { admin: false } } });
+ const { tool } = toolFor(root, gh.handlers);
+
+ const capability = tool.detect();
+ assert.equal(capability.serverSide, "no");
+ assert.equal(capability.failureKind, "permission");
+
+ const state = tool.apply();
+ assert.equal(state.tier, "local");
+ assert.match(state.fallbackReason, /^permission: /);
+ } finally {
+ cleanup(root);
+ }
+});
+
+/* ---------------- requirement 3: unknown providers ---------------- */
+
+test("requirement 3: an unrecognised host falls back rather than guessing", () => {
+ const root = makeSandbox();
+ try {
+ const { tool, run } = toolFor(root, [], "https://git.internal.example.net/team/widget.git");
+ const capability = tool.detect();
+ assert.equal(capability.provider, "unknown");
+ assert.equal(capability.serverSide, "no");
+
+ const state = tool.apply();
+ assert.equal(state.tier, "local");
+ assert.match(state.fallbackReason, /^unsupported: /);
+ assert.equal(
+ run.calls.filter((c) => c.bin !== "git").length,
+ 0,
+ "no provider CLI should be invoked for a host with no adapter"
+ );
+ } finally {
+ cleanup(root);
+ }
+});
+
+test("a repository with no remote at all still gets local protection", () => {
+ const root = makeSandbox();
+ try {
+ const run = recordingRunner([
+ { match: argsAre("remote get-url origin"), reply: { status: 1, stdout: "", stderr: "no such remote" } },
+ { match: argsAre("symbolic-ref --short refs/remotes/origin/HEAD"), reply: { status: 1 } },
+ { match: argsAre("branch --show-current"), reply: { status: 0, stdout: "main\n" } },
+ { match: argsAre("rev-parse --git-dir"), reply: { status: 0, stdout: ".git\n" } },
+ ]);
+ const tool = bp.createTool({ cwd: root, run: run, now: () => "2026-08-02T00:00:00.000Z" });
+ const state = tool.apply();
+ assert.equal(state.provider, "none");
+ assert.equal(state.tier, "local");
+ } finally {
+ cleanup(root);
+ }
+});
+
+test("GitLab is probed through its own adapter and falls back when glab is absent", () => {
+ const root = makeSandbox();
+ try {
+ const { tool } = toolFor(
+ root,
+ [{ match: (bin) => bin === "glab", reply: { status: 127, stdout: "", stderr: "glab not found" } }],
+ "https://gitlab.com/team/widget.git"
+ );
+ const capability = tool.detect();
+ assert.equal(capability.provider, "gitlab");
+ assert.equal(capability.serverSide, "unknown");
+ assert.match(capability.reason, /glab CLI is not installed/);
+
+ const state = tool.apply();
+ assert.equal(state.tier, "local");
+ } finally {
+ cleanup(root);
+ }
+});
+
+/*
+ * The GitLab project stub keeps state, because apply reads the protected
+ * branch entry before creating it and verify reads it afterwards. A stub that
+ * answered the same way both times would hide the ordering.
+ */
+function gitlabHandlers(options) {
+ const opts = options || {};
+ const project = encodeURIComponent("team/widget");
+ const entry = "api projects/" + project + "/protected_branches/main";
+ const state = { branch: opts.existing || null };
+ return {
+ state: state,
+ handlers: [
+ { match: (bin, args) => bin === "glab" && args[0] === "--version", reply: { status: 0, stdout: "glab 1\n" } },
+ {
+ match: (bin, args) => bin === "glab" && args.join(" ") === "api projects/" + project,
+ reply: () => json({ visibility: "private", default_branch: "main" }),
+ },
+ {
+ match: (bin, args) => bin === "glab" && args.join(" ") === entry,
+ reply: () =>
+ state.branch
+ ? json(state.branch)
+ : { status: 1, stdout: "", stderr: "404 Not Found" },
+ },
+ {
+ match: (bin, args) => bin === "glab" && args.indexOf("--method") !== -1,
+ reply: (bin, args) => {
+ const query = args[args.length - 1];
+ state.branch = {
+ name: "main",
+ allow_force_push: /allow_force_push=false/.test(query) ? false : true,
+ push_access_levels: [{ access_level: 30 }],
+ };
+ return json(state.branch);
+ },
+ },
+ ],
+ };
+}
+
+test("GitLab protected branches are used when glab is available", () => {
+ const root = makeSandbox();
+ try {
+ const gl = gitlabHandlers({});
+ const { tool, run } = toolFor(root, gl.handlers, "https://gitlab.com/team/widget.git");
+ const state = tool.apply();
+ assert.equal(state.tier, "remote");
+ assert.equal(state.mechanism, "gitlab-protected-branch");
+ assert.equal(state.visibility, "private", "a private GitLab project stays private");
+
+ // Left unset, GitLab defaults push access to Maintainer, which would stop
+ // Developers pushing at all. The policy is about force pushes and
+ // deletion, not about who may push.
+ const create = run.calls.find((c) => c.bin === "glab" && c.args.indexOf("POST") !== -1);
+ assert.match(create.args.join(" "), /push_access_level=30/);
+ assert.match(create.args.join(" "), /allow_force_push=false/);
+ } finally {
+ cleanup(root);
+ }
+});
+
+test("a GitLab branch that is already correctly protected is not treated as a failure", () => {
+ const root = makeSandbox();
+ try {
+ const gl = gitlabHandlers({
+ existing: { name: "main", allow_force_push: false, push_access_levels: [{ access_level: 30 }] },
+ });
+ const { tool, run } = toolFor(root, gl.handlers, "https://gitlab.com/team/widget.git");
+ const state = tool.apply();
+ assert.equal(state.tier, "remote", "a repeat POST would 409; that is not a reason to downgrade");
+ assert.equal(state.fallbackReason, null);
+ assert.equal(
+ run.calls.filter((c) => c.bin === "glab" && c.args.indexOf("POST") !== -1).length,
+ 0
+ );
+ } finally {
+ cleanup(root);
+ }
+});
+
+test("a GitLab entry that only exists is not accepted as verified", () => {
+ const root = makeSandbox();
+ try {
+ // allow_force_push already defaults to false, so an entry with no push
+ // access levels must not read as proof that pushes still work.
+ const gl = gitlabHandlers({
+ existing: { name: "main", allow_force_push: false, push_access_levels: [] },
+ });
+ const { tool } = toolFor(root, gl.handlers, "https://gitlab.com/team/widget.git");
+ const state = tool.apply();
+ assert.equal(state.tier, "local");
+ } finally {
+ cleanup(root);
+ }
+});
+
+/* ---------------- requirement 4: visibility is never forge's to change --------- */
+
+test("requirement 4: the provider choke point refuses to issue a visibility change", () => {
+ assert.equal(bp.isVisibilityMutation("gh", ["repo", "edit", "acme/widget", "--visibility", "public"]), true);
+ assert.equal(bp.isVisibilityMutation("gh", ["repo", "edit", "acme/widget", "--public"]), true);
+ assert.equal(
+ bp.isVisibilityMutation("gh", ["api", "--method", "PATCH", "repos/acme/widget", "--input", "-"], '{"private":false}'),
+ true
+ );
+ assert.equal(
+ bp.isVisibilityMutation("glab", ["api", "--method", "PUT", "projects/1?visibility=public"]),
+ true
+ );
+ assert.equal(
+ bp.isVisibilityMutation("gh", ["api", "--method", "POST", "repos/acme/widget/rulesets", "--input", "-"], "{}"),
+ false
+ );
+ assert.equal(bp.isVisibilityMutation("gh", ["api", "repos/acme/widget"]), false);
+ assert.equal(
+ bp.isVisibilityMutation("gh", ["api", "--paginate", "repos/acme/widget/rulesets?includes_parents=false"]),
+ false
+ );
+
+ // gh accepts -X as the short form of --method, and silently upgrades to
+ // POST or PATCH whenever a body is present. An interlock that only looked
+ // at --method let both of these through.
+ assert.equal(bp.isVisibilityMutation("gh", ["api", "-X", "PATCH", "repos/acme/widget", "-f", "private=true"]), true);
+ assert.equal(
+ bp.isVisibilityMutation("gh", ["api", "repos/acme/widget", "--input", "-"], '{"private": true}'),
+ true
+ );
+ assert.equal(bp.isVisibilityMutation("gh", ["api", "repos/acme/widget", "-f", "visibility=public"]), true);
+});
+
+test("requirement 4: a runner that is handed a visibility change never executes it", () => {
+ const root = makeSandbox();
+ try {
+ let executed = false;
+ const run = recordingRunner([
+ {
+ match: () => true,
+ reply: () => {
+ executed = true;
+ return { status: 0, stdout: "{}" };
+ },
+ },
+ ]);
+ const tool = bp.createTool({ cwd: root, run: run });
+ // runProvider is the single choke point every adapter call goes through.
+ assert.throws(
+ () => tool.runProvider("gh", ["repo", "edit", "acme/widget", "--visibility", "public"]),
+ /never changes visibility/
+ );
+ assert.throws(
+ () =>
+ tool.runProvider("gh", ["api", "--method", "PATCH", "repos/acme/widget", "--input", "-"], {
+ input: '{"private": false}',
+ }),
+ /never changes visibility/
+ );
+ assert.equal(executed, false, "the blocked call must not reach the runner");
+ } finally {
+ cleanup(root);
+ }
+});
diff --git a/tests/repo-standards.test.js b/tests/repo-standards.test.js
new file mode 100644
index 0000000..9d3ce66
--- /dev/null
+++ b/tests/repo-standards.test.js
@@ -0,0 +1,230 @@
+"use strict";
+
+/*
+ * Requirement 15: the existing repository checks still hold.
+ *
+ * The build, lint, documentation, traceability, and secret checks this repo
+ * actually runs are the CI scripts plus plugin validation. This suite runs the
+ * ones that do not need a network, and adds the structural invariants the new
+ * protection code introduces.
+ */
+
+const test = require("node:test");
+const assert = require("node:assert");
+const fs = require("fs");
+const path = require("path");
+const { spawnSync } = require("child_process");
+
+const { REPO_ROOT } = require("./helpers/sandbox.js");
+
+function node(args, options) {
+ return spawnSync(process.execPath, args,
+ Object.assign({ cwd: REPO_ROOT, encoding: "utf8" }, options || {}));
+}
+
+function read(rel) {
+ return fs.readFileSync(path.join(REPO_ROOT, rel), "utf8");
+}
+
+/* ---------------- the checks CI already ran ---------------- */
+
+test("the manifest and hook reference check still passes", () => {
+ const res = node([path.join(".github", "scripts", "manifest-check.js")]);
+ assert.equal(res.status, 0, res.stdout + res.stderr);
+ assert.match(res.stdout, /All manifest checks passed/);
+});
+
+test("the typography check still passes over tracked files", () => {
+ const res = node([path.join(".github", "scripts", "typography-check.js")]);
+ assert.equal(res.status, 0, res.stdout + res.stderr);
+});
+
+test("every hook script and shipped template parses", () => {
+ const files = [];
+ for (const dir of ["scripts", "templates", "tests", path.join(".github", "scripts")]) {
+ const full = path.join(REPO_ROOT, dir);
+ for (const entry of fs.readdirSync(full)) {
+ if (entry.endsWith(".js")) files.push(path.join(dir, entry));
+ }
+ }
+ assert.ok(files.length >= 8, "expected the script and template set to be non-empty");
+ for (const file of files) {
+ const res = node(["--check", file]);
+ assert.equal(res.status, 0, file + ": " + res.stderr);
+ }
+});
+
+/*
+ * typography-check.js reads `git ls-files`, so it cannot see a file that is
+ * not committed yet. This repeats the rule over the working tree so a new file
+ * cannot slip an em dash in before its first commit.
+ */
+test("the ASCII typography rule holds over the working tree, committed or not", () => {
+ const FORBIDDEN = {
+ "em dash": 0x2014,
+ "en dash": 0x2013,
+ "left single quote": 0x2018,
+ "right single quote": 0x2019,
+ "left double quote": 0x201c,
+ "right double quote": 0x201d,
+ ellipsis: 0x2026,
+ "non-breaking space": 0x00a0,
+ "unicode minus": 0x2212,
+ };
+ const byChar = new Map(
+ Object.keys(FORBIDDEN).map((name) => [String.fromCharCode(FORBIDDEN[name]), name])
+ );
+
+ const roots = ["scripts", "templates", "tests", "skills", "hooks", ".github"];
+ const violations = [];
+
+ function walk(rel) {
+ const full = path.join(REPO_ROOT, rel);
+ for (const entry of fs.readdirSync(full, { withFileTypes: true })) {
+ const childRel = path.join(rel, entry.name);
+ if (entry.isDirectory()) {
+ walk(childRel);
+ continue;
+ }
+ if (!/\.(js|json|md|ya?ml|toml)$/i.test(entry.name)) continue;
+ const text = fs.readFileSync(path.join(REPO_ROOT, childRel), "utf8");
+ text.split(/\r?\n/).forEach((line, i) => {
+ for (const ch of line) {
+ const name = byChar.get(ch);
+ if (name) violations.push(childRel + ":" + (i + 1) + ": " + name);
+ }
+ });
+ }
+ }
+
+ for (const root of roots) walk(root);
+ for (const file of ["README.md", "CHANGELOG.md", "CONTRIBUTING.md", "CLAUDE.md"]) {
+ const text = read(file);
+ text.split(/\r?\n/).forEach((line, i) => {
+ for (const ch of line) {
+ const name = byChar.get(ch);
+ if (name) violations.push(file + ":" + (i + 1) + ": " + name);
+ }
+ });
+ }
+ assert.deepEqual(violations, []);
+});
+
+test("no PowerShell block in the skills or docs uses &&", () => {
+ const offenders = [];
+ for (const rel of ["skills", "templates"]) {
+ const dir = path.join(REPO_ROOT, rel);
+ const stack = [dir];
+ while (stack.length) {
+ const current = stack.pop();
+ for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
+ const full = path.join(current, entry.name);
+ if (entry.isDirectory()) {
+ stack.push(full);
+ continue;
+ }
+ if (!/\.md$/i.test(entry.name)) continue;
+ const text = fs.readFileSync(full, "utf8");
+ const inPowerShell = /```powershell([\s\S]*?)```/gi;
+ let match;
+ while ((match = inPowerShell.exec(text)) !== null) {
+ if (match[1].indexOf("&&") !== -1) {
+ offenders.push(path.relative(REPO_ROOT, full));
+ }
+ }
+ }
+ }
+ }
+ assert.deepEqual(offenders, []);
+});
+
+/* ---------------- structural invariants of the new code ---------------- */
+
+test("every provider call goes through the visibility interlock", () => {
+ const source = read(path.join("templates", "branch-protection.js"));
+ const direct = source.match(/(? {
+ const source = read(path.join("templates", "branch-protection.js"));
+ const removals = source.match(/fs\.rmSync\([^)]*/g) || [];
+ assert.equal(removals.length, 1, "expected exactly one rmSync, inside removeDisposable");
+ assert.match(source, /function removeDisposable[\s\S]{0,400}refusing recursive delete/);
+});
+
+test("the guard still forbids working around it", () => {
+ const guard = read(path.join("templates", "history-guard.js"));
+ assert.match(guard, /--no-verify is prohibited/);
+ assert.match(guard, /TRUST BOUNDARY/);
+});
+
+test("the shipped templates the environment phase copies all exist", () => {
+ for (const file of [
+ "branch-protection.js",
+ "history-guard.js",
+ "lefthook.yml",
+ "ci.yml",
+ "CONTINUE.md",
+ "TODO.md",
+ "traceability.md",
+ "docs-manifest.yml",
+ "images-manifest.md",
+ "cliff.toml",
+ ]) {
+ assert.ok(
+ fs.existsSync(path.join(REPO_ROOT, "templates", file)),
+ "missing template: " + file
+ );
+ }
+});
+
+/* ---------------- the release standards this repo enforces ---------------- */
+
+test("the plugin version was bumped past the release that had no fallback", () => {
+ const manifest = JSON.parse(read(path.join(".claude-plugin", "plugin.json")));
+ const parts = manifest.version.split(".").map(Number);
+ assert.ok(
+ parts[0] > 1 || (parts[0] === 1 && parts[1] >= 1),
+ "a behaviour change to skills or templates needs a version bump, got " + manifest.version
+ );
+});
+
+test("the changelog records the protection change", () => {
+ const changelog = read("CHANGELOG.md");
+ assert.match(changelog, /## \[Unreleased\]|## \[1\.1\.0\]/);
+ assert.match(changelog, /protection/i);
+});
+
+/* ---------------- the skills say what the code does ---------------- */
+
+test("the environment phase describes both protection tiers", () => {
+ const skill = read(path.join("skills", "forge-env", "SKILL.md"));
+ assert.match(skill, /branch-protection\.js/);
+ assert.match(skill, /history-guard\.js/);
+ assert.match(skill, /trust boundary/i);
+ assert.doesNotMatch(
+ skill,
+ /Configure a GitHub ruleset on `main` blocking force pushes and deletions/,
+ "the GitHub-only instruction must be gone"
+ );
+});
+
+test("the lifecycle gate accepts either tier", () => {
+ const skill = read(path.join("skills", "forge", "SKILL.md"));
+ assert.match(skill, /default-branch history protection verified/i);
+ assert.match(skill, /server-side|managed local/i);
+});
+
+test("the standards state the protection policy once, provider neutrally", () => {
+ const standards = read(path.join("skills", "forge-standards", "SKILL.md"));
+ assert.match(standards, /default branch/i);
+ assert.match(standards, /trust boundary/i);
+ assert.doesNotMatch(standards, /Upgrade to GitHub Pro/);
+});