Repository navigation
chore: add upstream sync script and fork changeset - #46
Conversation
|
Session stamp
|
Review round 1 →
|
| Finding | From | Resolution |
|---|---|---|
| The sync-branch exemption was forgeable by naming a branch like a generated one | Sol (blocker), GLM | Fixed. There is no exemption now: the sync entry names its branch, and a sync PR must carry that entry. |
A conflicted CHANGESET.md was staged as resolved with markers in it |
Sol, GLM, self | Fixed. It is left unmerged, and the resolver is handed the entry. |
| A filesystem or probe failure threw past cleanup mid-merge | Sol, GLM, self | Fixed. Failures go through the state-aware fail() with the merge aborted. |
| A sync could be reported ready without its changeset entry | Sol | Fixed. Failing to record now abandons the merge. |
Race between the worktree probe and moving dev |
Sol | Fixed. The move is git fetch . <sha>:refs/heads/dev, one operation that refuses a non-FF and a branch checked out anywhere (verified); the probe is removed. |
A failed mirror push still reported SYNC_CLEAN |
Sol | Partly taken. Not fatal: the sync PR carries the same commits, and stopping would block syncs over a stale mirror. The push failure is now in every final line. |
fetch --tags was unused and a clashing tag failed every sync; SYNC_ABORT promised too much |
GLM, Sol | Fixed. Dropped --tags; the guarantee now says a fetch may update remote-tracking refs. |
| Final lines did not always state the mirror state | GLM | Fixed. Every final line does. |
| Edge tests missing: marker at end of file, missing marker, name collision | GLM | Added. A mutation showed the first test could not see trailing newlines; it now reads the file raw. |
| "HEAD is never left on it" overpromised | GLM | Reworded. |
exitCode can be null |
GLM | Fixed (?? 1). |
The sync based off local swxtch, so unpushed commits could ride into the PR unreviewed |
self | Fixed. It bases off the freshly fetched origin/swxtch; cleanup returns HEAD to where the run started. |
| The branch name and changeset date read the clock twice | self | Fixed. |
Tests: 231/231 across the script suites, typecheck and audit clean, and the new behaviors are mutation-checked.
Review round 2 →
|
| Finding | From | Resolution |
|---|---|---|
Staging the entry could fail after the write; merge --abort (reset --merge) keeps unstaged changes, leaving a dirty tree |
Sol, GLM | Fixed. Restore the file from the index, read the abort back, and have the report say when the tree is not clean. |
| Reusing an old sync's branch name passes on the entry the base already has | Sol | Fixed. The entry must be new against the base, which CI fetches via PR_BASE_SHA. |
| A wrapped sync entry failed the check; entries weren't scoped to their section | Sol, GLM | Fixed. Parse list items with their continuation lines, per section. |
| The checker runs the PR's own code, so a PR can edit it | GLM | Declined. It is a guard against forgetting, not a hostile author; every check here (including the workflow-guard audit) runs the PR's code, and an edit is in the reviewed diff. The limit is now stated in the header. |
The header and CI message didn't cover the case where CHANGESET.md itself conflicted; the mirror-state claim was overstated |
GLM, Sol | Fixed. |
Missing tests: MERGE_MSG failure, detached-start cleanup, non-numeric PR number |
GLM | Added, plus stage-failure, failed-abort, and dirty-tree tests. |
Found while fixing: the new failed-abort test showed that cleanup, with the merge still in progress, checked out swxtch (git allows it and carries the staged merge along) and deleted the sync branch. That leaves MERGE_HEAD live on swxtch, where the next ordinary commit silently merges unreviewed upstream code. Now, when the abort fails, the script stays on the sync branch and says so.
Tests: 242/242; the new checks are mutation-checked. The one surviving mutant is equivalent: a failed git show already has empty stdout.
Review round 3 →
|
| Finding | From | Resolution |
|---|---|---|
A push can update origin/dev and still exit nonzero (e.g. a rejected follow-on tag), so a later failure could claim SYNC_ABORT falsely |
Sol | Fixed. The mirror push is tag-free (--no-follow-tags), a nonzero exit is read back with ls-remote, and an unreadable outcome counts as a possible mutation (reported UNKNOWN, token SYNC_MERGE_FAILED). |
| The cleanup-time status probe ignored its exit code | Sol | Fixed. An uninspectable tree is reported as unverified. |
The SYNC_ABORT row didn't mention the failed-abort case |
GLM | Fixed. |
| A status-update PR would unexpectedly fail the check | GLM | Documented. That PR still adds its own entry. |
| Base-newness was only tested for sync entries | GLM | Test added for fork entries. |
Tests: 246/246; each new path is mutation-checked.
Review round 4 →
|
| Finding | From | Resolution |
|---|---|---|
Dropping --tags wasn't enough: a plain fetch still auto-follows tags, so an abort could leave new local tags and a clashing tag could fail the fetch |
Sol | Fixed. Both fetches use --no-tags; a test covers a new upstream tag and a clashing local one. |
readBaseChangeset read any git show failure as "no file" |
GLM (noted below its reporting bar) | Fixed. It's cheap: only a base that provably lacks the file reads as empty. |
| The base is fetched even for exempt PRs; "push it by hand" is imprecise if the remote raced ahead | GLM (below its bar) | Left. One harmless shallow fetch that a passing exempt PR never reads, and an unreachable race. |
Tests: 247/247.
Review round 5 →
|
| Finding | From | Resolution |
|---|---|---|
A CHANGESET.md that kept the marker but lost its ## Upstream syncs heading got the entry written and reported SYNC_CLEAN, then failed CI, which accepts sync entries only under that heading |
Sol (minor) | Fixed. The marker's nearest heading must be that heading, and the heading is now a shared constant between the script and the check. The end-of-file test gains a real heading, plus a negative test for a misplaced marker. |
Tests: 248/248.
Review round 6 →
|
| Finding | From | Resolution |
|---|---|---|
The push decision trusted the fetched origin/dev, which comes from origin's fetch URL; with a separate push URL, a stale push destination was reported current |
Sol | Fixed. The push destination is read directly (ls-remote on the push URL) before the push and after a failed one. A test covers separate fetch and push URLs. |
Consequence: a push destination that can't be reached is now reported as UNKNOWN, because it genuinely can't be verified. So the failed-push tests now use a reachable destination that rejects the push as a non-fast-forward.
Tests: 249/249.
Review round 7 →
|
| Finding | From | Resolution |
|---|---|---|
| A remote can have several push URLs, and git pushes to each; the round 6 fix read only the first | Sol | Fixed. Every push URL is read before and after the push. A current first URL no longer hides a stale second one. A push that lands on some URLs and is rejected by another is reported as partial and counts as a mutation. With no readable destination, a failed push is UNKNOWN rather than vacuously "pushed". Each path is tested and mutation-checked. |
Tests: 252/252. No Fable 5 pass for this PR: it's reserved for the hardest problems, and this isn't one.
Review round 8 →
|
| Finding | From | Resolution |
|---|---|---|
A configured refspec that writes a local branch (+refs/heads/dev:refs/heads/dev) could move dev during the fetch, before an abort |
Sol | Fixed. Fetch only the two needed branches, by explicit refspec into remote-tracking refs, with --refmap= so git doesn't also apply configured refspecs as opportunistic updates. A mutation dropping --refmap= confirms that alone would still move dev. It also stops fetching every upstream branch. |
| A destination unread before a rejected push was misreported as "partial" | Sol | Fixed. It's now UNKNOWN. |
Tests: 254/254.
Review round 9 →
|
| Finding | From | Resolution |
|---|---|---|
checkout -b creates the ref before switching the tree, so a checkout that fails while switching leaves behind a branch this run created, yet the report said it "was not created by this run" |
GLM | Fixed. Nothing unowned is ever deleted, but the report now says the branch exists and this run can't prove who created it (or that it was never created). Tested and mutation-checked. |
The PR description still described the pre-review design (the sync-branch exemption, the local-lag warning, basing off local swxtch, the 24-test step) |
GLM | Rewritten to match the final behavior. |
The sandbox run was re-done on this commit against the real upstream: SYNC_CLEAN, 16 commits, frozen lockfile and typecheck:script clean on the merged tree, and changeset-check accepts the sync branch. Tests: 255/255.
Review round 10 →
|
| Finding | From | Resolution |
|---|---|---|
A post-checkout hook could switch HEAD back and exit 0; the merge would then be committed onto whatever HEAD was on (possibly local swxtch) and reported SYNC_CLEAN |
Sol | Fixed. A checkout that doesn't land on the sync branch fails, and HEAD is re-checked before the commit, which touches nothing if HEAD has moved. |
SYNC_ABORT promised no local branch moved, but a failed checkout can leave behind a sync branch this run created |
Sol | Fixed. The token is decided after cleanup: a leftover sync branch makes it SYNC_MERGE_FAILED. The token docs and the PR table are updated. |
Tests: 258/258; each new guard is mutation-checked. Sol's last four rounds of findings have all been hostile hooks or unusual git configuration (multiple push URLs, custom fetch refspecs, hooks that switch branches).
Review converged at
|
Port swx-firstmate's bin/fm-sync-upstream.sh to script/sync-upstream.ts: fast-forward the dev mirror to upstream/dev, then prepare a --no-ff merge of upstream/dev onto a fresh branch off swxtch for review. Merge, never rebase. Claude-Session: https://claude.ai/code/session_01BUBanXuSck62cxd3dpZ1oA
Ports swx-firstmate's bin/fm-sync-upstream.sh to script/sync-upstream.ts. It fast-forwards the dev mirror to upstream/dev (FF only, pushed to origin) and prepares a --no-ff merge of upstream/dev onto a fresh sync-upstream-<timestamp> branch off swxtch. It never pushes swxtch, opens a PR, or resolves conflicts; conflicts stay in progress for a resolver. Merge, not rebase, for the reasons recorded in 75eced9. Differences from the original: - git is injected, so fault tests wrap the real runner instead of shimming PATH, and the primary-checkout guard is a separate option rather than a side effect of overriding the repository path. - It operates on the repository containing the cwd, so there is no "ran the primary's copy" trap. - The mirror push sends the pinned upstream commit, not the branch name. - The branch name has no slash, per this repo's branch naming. The tests run in a new CI step rather than the pinned fork-guard gate, since this is not a guard; the unit job's audit digest is updated for it. Claude-Session: https://claude.ai/code/session_01BUBanXuSck62cxd3dpZ1oA
Merging the pinned sha made git default to "Merge commit '<sha>'". Claude-Session: https://claude.ai/code/session_01BUBanXuSck62cxd3dpZ1oA
script/ is not a workspace package, so `bun typecheck` never reached it, and the root tsconfig has no Bun types. Nothing typechecked the fork's CI guards or the new sync script. script/tsconfig.json borrows Bun types from packages/script and includes only the files this fork authors or has modified. The rest of script/ is identical to upstream; fixing its 18 type errors here would only add conflicts to every upstream sync. A new typecheck.yml step runs it through `bun run typecheck:script`, and that job's audit digest is updated for it. Fixes the five errors it found in fork files, all noUncheckedIndexedAccess. Closes #47 Claude-Session: https://claude.ai/code/session_01BUBanXuSck62cxd3dpZ1oA
CHANGESET.md at the root records both halves of what swxtch is: every upstream sync merged in, and every change the fork carries on top, each with its status against upstream. Backfilled from history: the fork base, the 2026-09-22 sync (#38) with its conflict resolutions, and the 20 fork changes since. It stays current mechanically rather than by memory: - sync-upstream.ts now merges with --no-commit and adds the sync's entry (range, commit count, conflicted files) inside the sync merge itself. On conflicts the entry is staged with the in-progress merge, and the conventional message goes to MERGE_MSG so the resolver's commit uses it. - script/changeset-check.ts fails a PR against swxtch with no `- **#<number>**` entry. Dependabot and generated sync-upstream-<timestamp> branches are exempt; a hand branch cannot opt out by prefix. Both run in the unit job; its audit digest is updated. Claude-Session: https://claude.ai/code/session_01BUBanXuSck62cxd3dpZ1oA
Findings from GPT-6 Sol and GLM 5.3 (via opencode) and a self-review: - Base the sync on the freshly fetched origin/swxtch, not local swxtch, so unpushed local commits cannot ride into the sync PR unreviewed. Cleanup returns HEAD to where the run started. - A conflicted CHANGESET.md is left unmerged and the entry handed to the resolver; staging it would mark the conflict resolved with markers in it. - Failing to record the changeset entry (missing file, missing marker, unwritable file) or to write MERGE_MSG now abandons the merge through the normal state-aware report, instead of warning and committing an unrecorded sync, or throwing past cleanup mid-merge. - Fast-forward the mirror with `git fetch . <sha>:refs/heads/dev`, which refuses a non-fast-forward and a branch checked out in another worktree in one operation, closing the window after the separate worktree probe (now removed). - The sync-branch changeset exemption was forgeable by naming a branch like a generated one. There is no exemption now: the sync entry names its branch and a sync PR must carry it. - Drop `fetch --tags` (unused, and a clashing local tag failed every sync), state SYNC_ABORT's guarantee accurately, report the mirror state on every final line, read the clock once, treat a null exit code as failure. Each fix has a test; the new ones were mutation-checked. Claude-Session: https://claude.ai/code/session_01BUBanXuSck62cxd3dpZ1oA
- Staging the changeset entry could fail after it was written, and `merge --abort` (reset --merge) keeps unstaged changes, so cleanup could leave a dirty tree. The file is restored from the index first, abort is read back, and a failure report says when the tree is not clean. - A failed `merge --abort` no longer steps off the sync branch. Git lets a checkout carry the staged merge onto swxtch, where the next ordinary commit would silently merge unreviewed upstream work; a test caught the cleanup doing exactly that. It stays on the sync branch and says so. - The changeset check requires the entry to be NEW against the PR's base (fetched in CI via PR_BASE_SHA), so reusing an old sync's branch name fails; scopes each entry to its section; and joins wrapped continuation lines, so a resolver can wrap a long sync entry. - Declined: running the checker from the base instead of the PR. Like every check here, and the workflow-guard audit, it runs the PR's code; it guards against forgetting, and an edit to it is in the reviewed diff. The header now states that limit. - Header and CI message cover the CHANGESET.md-itself-conflicted case; the mirror-state claim is scoped to where it holds. - Tests: MERGE_MSG write failure, stage failure, failed abort, dirty-tree report, detached-start cleanup, non-numeric PR number, base reading. Claude-Session: https://claude.ai/code/session_01BUBanXuSck62cxd3dpZ1oA
- A push can update origin/dev and still exit nonzero (for example a rejected follow-on tag), so a failed push was not proof nothing moved, and a later failure could report SYNC_ABORT falsely. The mirror push is now tag-free, a nonzero exit is read back with ls-remote, and a push whose outcome cannot be read counts as a possible mutation (reported UNKNOWN, token SYNC_MERGE_FAILED). - The cleanup-time status probe's exit code is checked; an uninspectable tree is reported as unverified rather than implied clean. - Docs: the SYNC_ABORT row covers a failed merge --abort; CHANGESET.md says a status-update PR still adds its own entry. - Tests: push landed despite nonzero exit, push outcome unreadable, status probe failing at cleanup, fork-entry newness against the base. Claude-Session: https://claude.ai/code/session_01BUBanXuSck62cxd3dpZ1oA
- Fetch with --no-tags. Dropping --tags was not enough: a plain fetch still auto-follows tags, so an abort could leave new local tags behind and a clashing local tag could still fail the fetch. (Sol) - readBaseChangeset reads "" only for a base that provably lacks the file; any other read failure is unknown, so a reused sync branch name cannot pass against a base whose file failed to read. (GLM, noted below its reporting bar; cheap enough to close.) GLM returned NO FINDINGS for round 4. Claude-Session: https://claude.ai/code/session_01BUBanXuSck62cxd3dpZ1oA
- A CHANGESET.md that kept the marker but lost its "## Upstream syncs" heading got the entry written and reported SYNC_CLEAN, then failed the CI check, which only accepts sync entries under that heading. The marker's nearest heading must now be that heading, and the heading is a shared constant so the script and the check cannot drift. (Sol) GLM returned NO FINDINGS for round 5. Claude-Session: https://claude.ai/code/session_01BUBanXuSck62cxd3dpZ1oA
- The push decision trusted the fetched origin/dev, which comes from origin's fetch URL; with a separate push URL, a current fetch side hid a stale push destination and the run reported the mirror current. The push destination is now read directly with ls-remote on the push URL, before the push and after a failed one. (Sol) - Consequence, now tested: an unreachable push destination is reported as UNKNOWN (it genuinely cannot be verified), so the "failed push" tests use a reachable destination that rejects the push as non-FF. GLM returned NO FINDINGS for round 6 (third consecutive clean round). Claude-Session: https://claude.ai/code/session_01BUBanXuSck62cxd3dpZ1oA
- A remote can have several push URLs, and git pushes to each; the round 6 fix read only the first. Every push URL is now read before and after the push. A current first URL no longer hides a stale second one, and a push that lands on some URLs and is rejected by another is reported as partial and counts as a mutation. With no readable destination, a failed push is UNKNOWN rather than vacuously pushed. (Sol) GLM returned NO FINDINGS for round 7 (fourth consecutive clean round). Claude-Session: https://claude.ai/code/session_01BUBanXuSck62cxd3dpZ1oA
- Fetch only the two branches the run needs, by explicit refspec into remote-tracking refs, with --refmap= so git does not also apply the configured refspecs as opportunistic updates. A configured refspec that writes a local branch (+refs/heads/dev:refs/heads/dev) could otherwise move dev before an abort; a mutation dropping --refmap= confirms the opportunistic update would still do it. Also stops fetching every upstream branch. (Sol) - A destination that could not be read before a rejected push made the comparison report "partial"; it is now UNKNOWN. (Sol) GLM returned NO FINDINGS for round 8 (fifth consecutive clean round). Claude-Session: https://claude.ai/code/session_01BUBanXuSck62cxd3dpZ1oA
`git checkout -b` creates the ref before switching the tree, so a checkout that fails while switching leaves behind a branch this run did create, and the report said it "was not created by this run". Without ownership the branch is still never deleted, but the report now says it exists and this run cannot prove who created it, or that it was never created. (GLM, round 9; Sol returned NO FINDINGS.) Claude-Session: https://claude.ai/code/session_01BUBanXuSck62cxd3dpZ1oA
- A post-checkout hook could switch HEAD back and exit 0; the script then merged and committed onto whatever branch HEAD was on, possibly local swxtch, and reported SYNC_CLEAN. A checkout that did not land on the sync branch now fails, and HEAD is re-checked before the commit, which touches nothing if it has moved. (Sol, round 10) - SYNC_ABORT promised no local branch moved, but a failed checkout can leave behind a sync branch this run created. The token is now decided after cleanup, and a leftover sync branch makes it SYNC_MERGE_FAILED; the token docs say so. (Sol, round 10) GLM returned NO FINDINGS for round 10. Claude-Session: https://claude.ai/code/session_01BUBanXuSck62cxd3dpZ1oA
#49 merged ahead of this PR, so its fork-change entry lands here with the file. Claude-Session: https://claude.ai/code/session_01BUBanXuSck62cxd3dpZ1oA
8d158f6 to
72f7235
Compare
Adds
script/sync-upstream.ts, so an upstream sync is one safe command instead of a hand-run merge, plus a rootCHANGESET.mdthat records every sync and every fork change, kept current by CI. It also typechecks the fork's scripts (closes #47).What the sync script does
upstream/devandorigin/swxtch, by explicit refspec with--no-tags --refmap=, so a fetch never moves a local branch or tag.dev(a pure mirror) to the pinnedupstream/devcommit withgit fetch . <sha>:refs/heads/dev. That refuses a non-fast-forward and a branch checked out in any worktree, as one operation. It then pushesdev, reading every push URL back before and after.SYNC_UPTODATEiforigin/swxtchalready contains upstream.sync-upstream-<UTC timestamp>off the freshly fetchedorigin/swxtch, so unpushed local commits can't ride in, and merges with--no-ff --no-commit. It then writes the sync'sCHANGESET.mdentry, naming the branch, inside that merge and commits with a conventional message.It never pushes
swxtch, opens a PR, or resolves conflicts. On conflicts the merge stays in progress with the entry staged. IfCHANGESET.mditself conflicts, it's left unmerged and the entry is printed for the resolver.SYNC_CLEANSYNC_ABORTSYNC_CONFLICTSSYNC_UPTODATESYNC_MERGE_FAILEDEvery final line from the mirror step on states the mirror's state (current / pushed / failed / partial / unknown). Failure reports are read back from git rather than assumed: whether the branch was deleted, whether HEAD moved, whether the tree is clean. If
merge --abortitself fails, the script stays on the sync branch rather than letting git carry the staged merge ontoswxtch. The merge is committed only onto the sync branch: a checkout that doesn't land on it fails, and HEAD is re-checked before the commit.Merge, not rebase.
swxtchis published; a rebase would force-push it and rewrite commits that merged PRs reference (see 75eced9). A sync PR must land as a merge commit, never a squash: squashing drops theupstream/devparent, and the next run would re-merge the same commits.CHANGESET.mdscript/changeset-check.ts(the "Check the fork changeset" step) fails a PR againstswxtchthat doesn't add its own entry, compared with the PR base (fetched viaPR_BASE_SHA), in the right section.- **#<number>**under "Fork changes".Typecheck the fork's scripts (closes #47)
script/isn't a workspace package, so nothing typechecked it.script/tsconfig.json+bun run typecheck:script(a newtypecheck.ymlstep) cover the files the fork authors or modified. The rest ofscript/is identical to upstream, and fixing its errors would only add sync conflicts. It found 5 realnoUncheckedIndexedAccesserrors in fork files, fixed here.CI
The new "Test the fork maintenance scripts" and "Check the fork changeset" steps sit outside the pinned fork-guard gate. The
test.yml::unitandtypecheck.yml::typecheckdigests incheck-workflow-guards.tsare updated deliberately.Review
Ten rounds of cross-model review (GPT-6 Sol via
swx-azure, GLM 5.3 viafireworks, both through opencode), run to convergence. Each round's findings and resolutions are in the PR comments. Declined, with reasons: a failed mirror push is not fatal; the checker runs the PR's own code; the base is fetched even for exempt PRs.Verification
anomalyco/opencode, with a local bare clone as origin, so nothing was pushed to GitHub:SYNC_CLEAN: 16 upstream commits merged without conflicts, and the entry was written inside the merge.bun install --frozen-lockfileandtypecheck:script.changeset-checkaccepts the generated sync branch.🤖 Generated with Claude Code
https://claude.ai/code/session_01BUBanXuSck62cxd3dpZ1oA