Upgrading the vendored GitFWD

vendor/gitfwd/ is a git subtree of whitehatgg/GitFWD, squashed, at the commit named in vendor/gitfwd/UPSTREAM_SHA. That one pin serves everything that speaks GFP/1 in this repo — the Expo app (client/src/gfp.js requires vendor/gitfwd/clients/web/app/gfp.js verbatim), the gateway service, the gitfwd CLI, the MCP server, the hooks, the e2e fixtures and the skills (plan D8).

tests/vendor-drift.test.ts fails the build if the tree, the pin and the subtree’s own bookkeeping disagree. Read that file’s header for what it proves; this page is the procedure that keeps it green.

The rule

git subtree pull --squash, then bump the pin. That is the only upgrade path.

Not git checkout <sha> -- clients/web/app/gfp.js. Not a copy from a local clone. Not a “small fix while we wait for upstream”. Every one of those produces a tree that the subtree trailers cannot account for, and the drift test fails on exactly that — by design. Two GFP/1 implementations that disagree on the accepted set is the failure GitFWD core 12.6 exists to prevent, and a hand-edited vendor tree is how a client and a gateway end up “both consistent and jointly wrong” with nothing in either one’s own suite able to see it.

If upstream needs a fix, fix upstream and pull it.

Procedure

GitFWD is private: you need a clone or a read token. Nothing below prints it.

cd "$(git rev-parse --show-toplevel)"
git status --porcelain -- vendor/gitfwd     # must be EMPTY before you start

NEW=<full 40-hex upstream commit>           # never a branch name, never a short id

# 1. Pull the subtree, squashed. `git subtree` reuses the upstream tree object,
#    so a clean pull is a tree-hash equality with upstream, not a re-diff.
git subtree pull --prefix vendor/gitfwd https://github.com/whitehatgg/GitFWD "$NEW" --squash \
  -m "Update vendored GitFWD to $NEW"

# 2. Move the pin, in the SAME change.
printf '%s\n' "$NEW" > vendor/gitfwd/UPSTREAM_SHA

# 3. Only if gfp.js changed: bump GFP_JS_SHA256 in tests/vendor-drift.test.ts —
#    after reading the diff. This constant is the one step that is NOT mechanical.
sha256sum vendor/gitfwd/clients/web/app/gfp.js
git diff HEAD^2^ HEAD^2 -- clients/web/app/gfp.js   # old squash → new squash; paths are root-relative there

# 4. Commit the pin (and the hash, if it moved) BEFORE running the drift test.
#    The test reads UPSTREAM_SHA from HEAD, not the working tree — an unsaved pin
#    edit must not be able to make the test agree with itself — and it requires
#    `git status -- vendor/gitfwd` to be clean. Run before this commit it fails
#    (1), the clean-tree check and the hash check, and tells you nothing.
git add vendor/gitfwd tests/vendor-drift.test.ts
git commit -m "Bump GitFWD to $NEW"

# 5. Prove it.
npx jest tests/vendor-drift.test.ts
sh vendor/gitfwd/clients/web/gateway/selftest.sh    # upstream's own conformance run (69 checks)
(cd app && npm test && npm run e2e)                 # the app against the vendored gateway

The pull creates two commits — the squash (Squashed 'vendor/gitfwd/' changes from <old>..<new>, carrying the trailers) and the merge. The pin bump is a third; land all three in one PR. A pull without a pin bump, or a pin bump without a pull, fails check (1) of the drift test until the other half lands, which is the point: the pin is not allowed to describe a tree that is not there.

CI needs the history

The squash commit is HISTORY: it is not reachable from HEAD’s tree, only from its parents. A shallow checkout — actions/checkout’s default fetch-depth: 1 — therefore has every vendored file and none of the evidence about them, and checks (1) and (2) fail with a message naming this page. Any job that runs tests/vendor-drift.test.ts must check out with fetch-depth: 0 (or git fetch --unshallow first). Verified in a --depth 1 clone; the full-history clone is green.

Why each step is what it is

StepWhy
--squashUpstream history stays upstream. Without --squash every GitFWD commit joins this repo’s history, and git log becomes unreadable for the thing this repo is. The squash commit’s git-subtree-split trailer is the record of which upstream commit was pulled; the drift test reads it.
full 40-hex shaA branch name pins nothing; a short id resolves today and stops resolving the day upstream grows a colliding prefix. The test refuses anything but 40 hex characters.
the pin lives INSIDE vendor/gitfwd/It is the one file under the prefix that is not upstream’s. The drift test tolerates exactly that path as an addition and nothing else — so a second “harmless” non-upstream file fails the build.
GFP_JS_SHA256The trailer + tree checks prove “vendor == upstream at the pin” mechanically. The hash proves a person looked at the protocol file when the pin moved. A pin bump that does not touch gfp.js leaves it untouched; one that does must be acknowledged.
selftest.sh + app e2eUpstream’s own conformance suite and this repo’s client, against the SAME commit. The drift test cannot tell you whether the new upstream is any good, only that you have all of it.

What the drift test checks, and offline

GitFWD is private, so a test that fetched upstream would skip in every CI job without a read token — and a test that always skips certifies nothing. Every assertion that CI relies on is therefore about the commits already in this repo:

  1. the newest commit stamped git-subtree-dir: vendor/gitfwd (matched on the DIR trailer — client/ is a subtree too, and its git-subtree-split names a different GitFWD commit) carries git-subtree-split: <UPSTREAM_SHA>;
  2. HEAD:vendor/gitfwd differs from that commit’s tree by UPSTREAM_SHA alone (git diff-tree -r --name-status <squash>^{tree} HEAD:vendor/gitfwd is exactly A UPSTREAM_SHA);
  3. the working tree under vendor/gitfwd is clean;
  4. gfp.js has the recorded sha256 and passes the same purity checks as upstream’s gfp.test.js §11 (no import/export/require/top-level await/Buffer/process/ atob/btoa; UMD head present).

Optionally, with GITFWD_READ_TOKEN set to a token that can read the upstream repo, it also asks GitHub for the tree hash of whitehatgg/GitFWD@UPSTREAM_SHA and compares it to the squash commit’s tree — proof that the squash was made from the real upstream, not a fork with the same commit message. Unset, that one case is reported as skipped with the reason in its name, never as a pass. GITFWD_UPSTREAM_REPO overrides the owner/repo if the upstream ever moves.

Things that look like upgrades and are not

  • Pulling a branch (git subtree pull … main). It works once, and then the pin names a commit nobody wrote down. Resolve the branch to a sha first.
  • git subtree pull without --squash. The trailer the test reads is on the squash commit; without one there is nothing to read and check (1) fails with a message saying so.
  • Editing vendor/gitfwd/** in a PR “to unblock”. Check (2) fails. The unblock is a PR upstream and a pull here — the same amount of work, in the place where it stays fixed.
  • Adding a file under vendor/gitfwd/ (a README, a patch, a note). Same as above: only UPSTREAM_SHA may live there.
  • Cherry-picking one upstream file onto an older pin. The tree is then a commit that does not exist upstream, and the gateway built at the pin will disagree with the client built from the tree. This is the exact failure the pin exists to prevent.

Until T091

At the current pin, vendor/gitfwd/clients/expo still exists (GitFWD removes it in plan T090; this repo’s client/ is that client, moved here with its history in T011). client/metro.config.js block-lists vendor/** except the clients/web/app watch folder, and the repo’s workflow-scanning tests exclude vendor/**, so the stale copy is inert. T091 bumps the pin past the removal and this section goes away with it.