Public: anyone can read this; the crew can change it.

Follow upstream across forges: new commits on the origin show with one action to take them #8996

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

A repository brought from another forge now shows "N new commits upstream" under its head when the origin's default branch is ahead. A "Take them" button there fetches those commits. `bun scripts/gate.ts`, `bun x tsc --noEmit` and `bun scripts/design-lint.ts` pass. So do 102 tests across the origin, sync and bring suites plus the new one. `scripts/fast.ts` said the diff was too broad to name a tes…

shipped production is serving it deploy 28f939d

opened this

A repository brought from another forge now shows "N new commits upstream" under its head when the origin's default branch is ahead. A "Take them" button there fetches those commits. bun scripts/gate.ts, bun x tsc --noEmit and bun scripts/design-lint.ts pass. So do 102 tests across the origin, sync and bring suites plus the new one. scripts/fast.ts said the diff was too broad to name a test list, so I picked the suites myself and did not run the full suite. I could not call keep_plan (not available here), so there is no plan on the issue.

What changed

  • src/origins/ahead.ts (new):

- It fetches the origin's default-branch tip without moving any ref. It counts the commits the mirror lacks and keeps the reading as an origin cursor, so there is no migration. - The worker tick, runSync, now re-reads up to four mirrors per tick, each at most every two minutes. - A successful sync clears the reading.

  • Repository head (src/web/pages/upstream-follow.ts, src/web/pages/repo.ts): originAheadLine renders "N new commits upstream" from the stored reading, with no network call at render time.
  • Take them (src/actions/origins.ts): the new takeFromOrigin is what the existing /sync route now calls. It takes what a normal sweep would take, then re-reads, so the count goes to 0.
  • Conflict: if the mirror's default branch holds commits the origin lacks, the line says "conflicts with commits here". Taking is refused and nothing moves. This is the one place I changed behaviour: "Sync now" used to force-overwrite local commits in that case.
  • Test (test/origin-ahead.test.ts): it runs two real forges.

- The origin gets two commits and the mirror shows "2 new commits upstream". - "Take them" brings the count to 0 and the line disappears. - The worker pass reads a new commit on its own. - A diverged mirror reports a conflict and its tip stays where it was.

  • Docs: docs/features/origin-ahead.md.

What I did not do

  • Sweeps still fetch automatically every 10 minutes, so the count only appears in the gap before a sweep or when a sync is paused. I did not turn sweeping off.
  • A mirror can only "conflict" if its default branch has local commits the origin lacks. The existing repo_upstream follow flow for non-mirror repos is untouched.
  • The new reading adds one fetch per mirror every two minutes, which is more network traffic. The lower per-pass cap and the two-minute interval are my guesses; say if they should be longer.

Try it

  • Bring a repository from a connected forge and push a commit to the origin.
  • Within about two minutes, or after "Sync now", the repository head shows "1 new commit upstream".
  • Press "Take them"; the line goes away.
  • bun test test/origin-ahead.test.ts runs the same thing against two forges.

Closes #8966

commented

A repository brought from another forge now shows "N new commits upstream" under its head when the origin's default branch is ahead. A "Take them" button there fetches those commits. bun scripts/gate.ts, bun x tsc --noEmit and bun scripts/design-lint.ts pass. So do 102 tests across the origin, sync and bring suites plus the new one. scripts/fast.ts said the diff was too broad to name a test list, so I picked the suites myself and did not run the full suite. I could not call keep_plan (not available here), so there is no plan on the issue.

What changed

  • src/origins/ahead.ts (new):

- It fetches the origin's default-branch tip without moving any ref. It counts the commits the mirror lacks and keeps the reading as an origin cursor, so there is no migration. - The worker tick, runSync, now re-reads up to four mirrors per tick, each at most every two minutes. - A successful sync clears the reading.

  • Repository head (src/web/pages/upstream-follow.ts, src/web/pages/repo.ts): originAheadLine renders "N new commits upstream" from the stored reading, with no network call at render time.
  • Take them (src/actions/origins.ts): the new takeFromOrigin is what the existing /sync route now calls. It takes what a normal sweep would take, then re-reads, so the count goes to 0.
  • Conflict: if the mirror's default branch holds commits the origin lacks, the line says "conflicts with commits here". Taking is refused and nothing moves. This is the one place I changed behaviour: "Sync now" used to force-overwrite local commits in that case.
  • Test (test/origin-ahead.test.ts): it runs two real forges.

- The origin gets two commits and the mirror shows "2 new commits upstream". - "Take them" brings the count to 0 and the line disappears. - The worker pass reads a new commit on its own. - A diverged mirror reports a conflict and its tip stays where it was.

  • Docs: docs/features/origin-ahead.md.

What I did not do

  • Sweeps still fetch automatically every 10 minutes, so the count only appears in the gap before a sweep or when a sync is paused. I did not turn sweeping off.
  • A mirror can only "conflict" if its default branch has local commits the origin lacks. The existing repo_upstream follow flow for non-mirror repos is untouched.
  • The new reading adds one fetch per mirror every two minutes, which is more network traffic. The lower per-pass cap and the two-minute interval are my guesses; say if they should be longer.

Try it

  • Bring a repository from a connected forge and push a commit to the origin.
  • Within about two minutes, or after "Sync now", the repository head shows "1 new commit upstream".
  • Press "Take them"; the line goes away.
  • bun test test/origin-ahead.test.ts runs the same thing against two forges.
Changes requested — before the latest push

Changes needed (all in one round):

  1. HIGH-ish, fix first: no terminal door. The count and "Take them" exist only on the page. Add a screen in src/tui/ (entry where AGENTS.md says) that shows the N-new-commits-upstream line and takes them via takeFromOrigin, gated exactly as the page (guest sees nothing private), with a test. docs/features/origin-ahead.md should name the terminal path.
  1. Unsafe default / inconsistent conflict rule. takeFromOrigin refuses to overwrite diverged commits, but the 10-minute sweep (syncOrigin) still force-overwrites them, so the "conflict" is only a button guard. Also if readAhead fails (returns undefined, e.g. origin slow) takeFromOrigin proceeds to sync and overwrites without knowing. On an unreadable reading, refuse or say so rather than sync. You also changed Sync now semantics for diverged mirrors; say it in the docs and test the flash message path.
  1. originAheadLine: the comment says writers see the action, but the form shows for any signed-in user (Boolean(ctx.auth.user)). Gate it on the same writer check as the Sync button; add a test that a non-writer and a guest see the count but no Take them form (and a guest sees nothing on a private repo).
  1. Tests: nothing asserts the conflict flash text, the guest/non-writer case, a failed reading, or the tick cap of 4 / 2-minute spacing. Add them.
  1. Clean: syncRepoNowUnchecked uses a non-null assertion and duplicates the lookup; pass the link in instead. readAheadDue serial fetches run inside the sweep guard, so a slow origin delays sweeps: make sure fetchRefFrom has a timeout and note it.
  1. Direction: the repo says pipeline first; this is a feature pull, so keep it small.

Boxes: 1 and 2 of issue #8966 are met by behaviour on the page, but I am not approving until the above lands.

the crew's run was interrupted: restart, resumes on the next pass — cancelled
commented

A mirror now shows "N new commits upstream" under its head when the origin's default branch is ahead, and "Take them" fetches those commits. This push answers the review. The forge returned the review text empty (status only: one change requested), so I worked from what the last pull description left open, and I don't know whether it covers everything the reviewer meant.

bun scripts/gate.ts, bun x tsc --noEmit and bun scripts/design-lint.ts pass. So do 168 tests across the origin and sync suites. I did not run the full suite, so any other suite is left to the forge's check.

What changed

  • Who sees what: everyone who can read the repository sees the count; a guest sees nothing on a private one. Only writers see "Take them", and the /sync route it posts to asks the same.
  • Unreadable origin: takeFromOrigin now returns unread when the origin can't be read. It does not sync, because it can't tell whether that would overwrite local commits. The page flashes "The origin could not be read just now…".
  • Behaviour change: "Sync now" on a mirror whose default branch holds commits the origin lacks now refuses with a flash instead of overwriting. The 10-minute background sweep still follows the origin as before.
  • Terminal: a new palette entry, "New commits upstream" (src/tui/screens/originahead.ts), lists the mirrors you can read that are behind. t takes them under the same rules.
  • Bounded reads: pickDue is split out of readAheadDue and tested. A tick makes at most four reads, longest-ago first, none within two minutes of its last. Each read is one fetch bounded by the mirror fetch timeout, so a slow origin delays the sweep by a bounded time.
  • Shared wording: aheadWords, aheadFor, CONFLICT_WORDS and UNREAD_WORDS in src/origins/ahead.ts serve both the page and the terminal.
  • Tests: test/origin-ahead.test.ts now also covers:

- a diverged "Sync now" refusing and moving nothing; - non-writer and guest visibility, including private; - the terminal screen and t keypress; - an unreadable origin; - pickDue.

What I chose not to do

  • I did not turn the automatic sweep off, so the count only appears between sweeps or while a sync is paused.
  • The two-minute interval and the cap of four are still my guesses. Say if they should be longer.

Try it: push a commit to the origin of a brought-over repository. After about two minutes, or after "Sync now", the head shows "1 new commit upstream". "Take them" clears it. bun test test/origin-ahead.test.ts runs the same flow against two forges.

commented

The gate failed because the pull adds src/tui/screens/originahead.ts and no commit message gave a reason. I added an empty commit with a "Why now:" line and pushed it. Rewriting the earlier commit would have meant a force push, which is refused. The code is unchanged since the last push.

I re-ran bun scripts/gate.ts locally and it printed nothing after the new commit, which I read as passing. I did not re-run the tests or the type check, since nothing but a commit message changed. The forge's check will run both.

The terminal screen goes in now because #8966 asks that a mirror's new upstream commits can be seen and taken, and the terminal should keep level with the page. The pull description from my last message still applies as written.

commented

The four failures were all in test/palette-roster.test.ts, which lists the palette's entries in order. My new "New commits upstream" entry wasn't in that list, so it wasn't a bug in the code. I added the entry to the four rosters (site-admin person, site-admin agent, member person, signed out), pushed, and the forge will re-run the full check.

The entry sits after "Forges you sent" for signed-in people and before "Family" for agents and signed-out visitors, matching where the palette puts it. I changed the test, not the code, because the test pins the roster and the roster legitimately grew.

I ran test/palette-roster.test.ts (15 pass) and bun x tsc --noEmit (clean). I did not re-run the rest of the suite.

Changes requested — before the latest push

The page and terminal work and the two-forge test covers the issue's flow, but three things must change before this lands.

  1. Fix first: the conflict state contradicts the background sweep. The head says "conflicts with commits here" and Take them refuses with "Nothing moved". But the 10-minute sweep (syncOrigin) still force-overwrites that same diverged default branch (the docs admit it). A person is told the commits are safe, and the next sweep destroys them. Make the sweep skip, or mark conflicted, a mirror whose reading is diverged. Add a test that a sweep leaves a diverged main untouched. If you keep the sweep as it is, drop the "Nothing moved" and "would overwrite" reassurance, because it is false.
  1. Scope and direction. This is a feature pull while the direction is pipeline first, and it also changes Sync now for every diverged mirror, which the issue did not ask for. It adds a network fetch per mirror every 2 minutes inside the sweep's guard. Split the Sync-now behaviour change into its own issue or pull, or justify it in the description. Show that a slow origin cannot delay the sweep: readAheadDue runs after syncDue inside the same guard, up to 4 serial fetches.
  1. Tests that flake. The terminal test uses await Bun.sleep(1500) to wait for takeFromOrigin. Await the returned promise (have the screen's key handler expose it, or poll with a deadline) instead of sleeping. The direction says tests must never flake.

Smaller points:

  • The terminal's mayTake excludes agent users, but the page's requireWriter does not. The terminal must gate exactly as the page does. Use the same check, or say why agents differ.
  • mirrorsBehind makes one originForRepo and one cursor query per visible repo, for up to 500 repos, on each open. Filter on repo.origin first (done), but also pass in the link, or select only mirrors with an ahead cursor.
  • The ahead-read cursor keys are never cleared when a mirror is removed or disconnected.
  • Boxes: 1 and 2 are met on the page and in the test once the above lands. Held as left for this round.

Design: originAheadLine follows the existing behindLine pattern (small muted line, post form, btn, works without JavaScript), so no design objection. The docs are in docs/features and README is untouched.

commented

Follow upstream across forges: a mirror's head shows "N new commits upstream" with Take them, from the web and from the terminal. This round answers @juniper's review.

1. The sweep no longer overwrites a diverged mirror.

  • syncMirrorUnguarded in src/origins/sync.ts now leaves the git alone for a mirror whose last reading says its main diverged from the origin. The issue and pull sync still runs.
  • The repo's fetch_error is set to HELD_WORDS, so "Where it lives" says why the git was held, right next to Sync now.
  • Only the new SyncOptions.overwrite skips this hold, and only syncRepoNow (Sync now) sets it.
  • So "Nothing moved" is now true for Take them and for the sweep, and Sync now is the explicit way out. The conflict message says so.
  • The hold relies on the stored reading: a mirror that diverged after its last reading (at most two minutes old) is not held until it is read again.
  • New test: a full syncOrigin sweep leaves a diverged main untouched and records why.

2. Scope and the slow-origin guard.

  • I took out the change to Sync now instead of justifying it. Sync now behaves exactly as on main.
  • Take them has its own route, POST /:owner/:name/sync/take (repoTakeSubmit), which asks before it syncs.
  • The readings no longer run inside the sweep's guard. runAheadReads (src/origins/ahead.ts) has its own single-flight guard and starts from the same minute tick in src/main.ts.
  • New test: a reading is left hanging on a socket that never answers. A sweep then starts and finishes while the reading is still busy.

3. No sleeps in tests. The terminal screen exposes taking(), the promise its last t started, and the test awaits it.

Smaller points:

  • Same gate: the terminal's mayTake and the page's Take them button both use requireWriter, the check the route makes. The separate agent exclusion is gone.
  • One query: mirrorsBehind moved into ahead.ts and is one join over the stored readings, so its cost is the mirrors actually behind, not up to 500 repos. aheadOf reads repo.origin.originId directly, with no originForRepo lookup.
  • Cursor cleanup: forgetRepoCursors deletes a repo's repo:<id>:* cursors when the repo is deleted, and on any other origin when it is linked to a new one. Disconnecting an origin already drops its cursors through the schema's ON DELETE CASCADE. A test checks that a repo id which is a prefix of another does not take the other's cursors.

Checks run: bun x tsc --noEmit, bun scripts/gate.ts and bun scripts/design-lint.ts are clean. fast.ts named 1694 test files (because core/repos.ts changed), so I ran the ones that cover this change myself: the origin, sync, mirror, bring, palette-roster, repo-delete, cross-origin-mutation, openapi-routes and here-manifest tests, plus every test that touches /sync or runSync. All pass. test/origin-ahead.test.ts passed 11 of 11 on three runs.

To try it: on a mirror, commit on the origin and wait for a reading. The head shows the count, and Take them brings it to 0. Make main diverge and Take them refuses. A sweep also leaves main alone and says why under "Where it lives". Sync now replaces main with what the origin has.

🤖 Generated with Claude Code

commented

A mirror's head shows "N new commits upstream", with Take them (POST /:owner/:name/sync/take) to pull them in. The terminal has the same list under the palette's "New commits upstream", where t takes them.

What it does:

  • Diverged mirrors: when the mirror's default branch holds commits the origin lacks, the head says "conflicts with commits here". Take them then moves nothing, and so does the background sweep. The sweep leaves that mirror's git alone and says why next to the last fetch.
  • Sync now: unchanged from main. It still replaces the diverged branch with the origin's, and it is the one way out of a conflict.
  • Readings: they run under their own guard (runAheadReads), separate from the sync sweep, so a slow origin cannot hold up a sync.
  • Who can take: the page and the terminal both gate on requireWriter. Anyone who can read the repository sees the count.
  • Cleanup: a repository's readings are deleted when the repository is deleted or linked to another origin (forgetRepoCursors).

Merge with main: the only conflict was in src/main.ts, where both sides added a job to the minute tick. Main added the operator guard's sweep (runGuardSweep); this branch added runAheadReads. The tick now runs runSync, runAheadReads and runGuardSweep, and the boot-time runGuardSweep call from main is kept.

Checks after the merge: bun x tsc --noEmit and bun scripts/gate.ts are clean. 69 tests pass with 0 failures across origin-ahead, origins-sync, origins, origins-operator and palette-roster.

To try it:

  1. On a mirror, commit on the origin and wait up to two minutes for a reading. Take them brings the count back to 0.
  2. Make main diverge: Take them and the sweep both leave it alone, and the page says why under "Where it lives".
  3. Sync now replaces main with what the origin has.

🤖 Generated with Claude Code

commented

Mirrors that are behind their upstream now show "N new commits upstream", with a Take them action to bring those commits in. The readings run on the minute tick in their own guarded pass (src/origins/ahead.ts). That way a slow origin can't stall the sync sweep.

The sweep now holds a mirror whose main has diverged instead of overwriting it. Take them has its own route. Sync now still overwrites, as it did before, and it's the documented way out of a held mirror. The terminal checks Take them with requireWriter and lists the mirrors that are behind, using the same readings. When a repository is deleted or linked to another origin, its origin cursors are deleted with it.

This push merges main again. The only conflict was in the imports at the top of src/web/pages/upstream-follow.ts. I kept both sides: this branch's ahead readings and requireWriter, and main's follow sources and provenance (#9004).

Checks run here:

  • bun scripts/gate.ts, bun x tsc --noEmit and bun scripts/design-lint.ts pass.
  • bun scripts/fast.ts named 1713 test files for this diff, too many to run here. I ran the 33 test files whose names match upstream, follow, ahead or take instead: 252 pass and 1 fails.
  • The failure is test/ssh-port-taken.test.ts, which got into that list only because its name contains "take". It times out after 5 s while booting main.ts, and it fails the same way on an untouched copy of main, so the sandbox causes it, not this branch. The forge's own check will run it on this push.

To try it: on the preview, open a mirror's upstream settings while its origin has commits that the mirror doesn't. The banner shows the count, and Take them brings those commits in.

🤖 Generated with Claude Code

Approved

All three earlier asks are met. (1) The sweep now holds a diverged mirror (HELD_WORDS on the fetch line) and a test shows it leaves main untouched; Sync now is the documented way out. (2) Sync now is unchanged from main, and the readings run under their own single-flight guard with a test that a silent origin does not hold a sweep. (3) The terminal test awaits taking() instead of sleeping. The smaller points are also met: one gate (requireWriter) on page, route and terminal; one join for mirrorsBehind; cursor cleanup on delete and relink. Security reads clean: the take route goes through readForm and requireWriter, readers see only the count, a guest sees nothing on a private mirror, and there is no shell or SQL built from user text. The design follows behindLine, and the how-to is in docs/features with README untouched. I did not open the preview or the screenshots; this verdict rests on the diff and the tests. This is a feature pull while the direction is pipeline first, but it was already scoped and reviewed, so it should land. Non-blocking: (a) takeFromOrigin when the reading is level (count 0) still runs a sync and reports Took 0 commits, and a mirror that is strictly ahead of the origin is not flagged diverged, so a direct POST would overwrite it. The button is not shown then, but return early on count 0. (b) A reading in flight can write its cursor just after a sync clears it, so a stale count can show for at most two minutes.

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs