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
| Step | Why |
|---|---|
--squash | Upstream 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 sha | A 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_SHA256 | The 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 e2e | Upstream’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:
- the newest commit stamped
git-subtree-dir: vendor/gitfwd(matched on the DIR trailer —client/is a subtree too, and itsgit-subtree-splitnames a different GitFWD commit) carriesgit-subtree-split: <UPSTREAM_SHA>; HEAD:vendor/gitfwddiffers from that commit’s tree byUPSTREAM_SHAalone (git diff-tree -r --name-status <squash>^{tree} HEAD:vendor/gitfwdis exactlyA UPSTREAM_SHA);- the working tree under
vendor/gitfwdis clean; gfp.jshas the recorded sha256 and passes the same purity checks as upstream’sgfp.test.js§11 (noimport/export/require/top-levelawait/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 pullwithout--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: onlyUPSTREAM_SHAmay 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.