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
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/8966-follow-upstream-across-forges-new-commit → main 11 commits fork
14 files
changed +807
−16
docs/features/origin-ahead.md added +31
| @@ -0,0 +1,31 @@ | ||
| 1 | # New commits upstream, on a mirror | |
| 2 | ||
| 3 | A repository brought from another forge (or any origin) shows "N new commits | |
| 4 | upstream" under its head when the origin's default branch has moved past it, | |
| 5 | with **Take them** (`POST /:owner/:name/sync/take`). | |
| 6 | ||
| 7 | - `src/origins/ahead.ts` reads the origin's tip (objects only, no ref moves), | |
| 8 | counts, and keeps the reading as an origin cursor. The worker re-reads up to | |
| 9 | four mirrors a minute, each at most every two minutes; a sync clears it. | |
| 10 | - The readings run under their own single-flight guard (`runAheadReads`, | |
| 11 | started from the same minute tick as `runSync`), never inside the sweep's: | |
| 12 | a reading stuck on a slow origin holds only the next reading, never a sync. | |
| 13 | - Only a person who may write the repository sees **Take them**; everyone who | |
| 14 | may read it sees the count, and a guest sees nothing on a private one. | |
| 15 | - At the terminal: the palette's "New commits upstream" lists the mirrors you | |
| 16 | can read that are behind (one query over the readings, not one per | |
| 17 | repository); `t` takes them under the page's own check, `requireWriter` | |
| 18 | (`src/tui/screens/originahead.ts`). | |
| 19 | - A mirror whose default branch holds commits the origin lacks shows | |
| 20 | "conflicts with commits here". Take them then moves nothing, and neither | |
| 21 | does the sweep: it leaves that mirror's git alone and says why where the | |
| 22 | last fetch is shown. **Sync now** is unchanged: the person asked for the | |
| 23 | origin's branches, so it replaces the diverged branch, and it is the way out. | |
| 24 | An origin that cannot be read just then is refused the same way: it is not | |
| 25 | known whether taking would overwrite. | |
| 26 | - A repository's readings go with it when it is deleted or linked to another | |
| 27 | origin (`forgetRepoCursors`); disconnecting an origin drops its cursors by | |
| 28 | the schema. | |
| 29 | - `test/origin-ahead.test.ts` runs two forges: commit on the origin, see the | |
| 30 | count, take them, count 0; a diverged mirror survives Take them and a sweep; | |
| 31 | a silent origin does not hold the sweep. |
src/actions/origins.ts +35 −1
| @@ -55,6 +55,7 @@ import { canAdmin, getRepoById, type Repo } from "../core/repos.ts"; | ||
| 55 | 55 | import type { User } from "../core/users.ts"; |
| 56 | 56 | import { repoDir } from "../git/repos.ts"; |
| 57 | 57 | import { bustOrigin } from "../view/cache.ts"; |
| 58 | import { readAhead } from "../origins/ahead.ts"; | |
| 58 | 59 | import { discover } from "../origins/discover.ts"; |
| 59 | 60 | import { |
| 60 | 61 | lastGuardTry, |
| @@ -1022,6 +1023,8 @@ export async function syncOriginNow( | ||
| 1022 | 1023 | /** |
| 1023 | 1024 | * Sync one mirror now. Any writer of the local repo may ask for this — it |
| 1024 | 1025 | * only pulls, so it borrows nobody's identity and grants nobody anything. |
| 1026 | * It is the one path that overwrites a default branch holding commits the | |
| 1027 | * origin lacks: the person asked for the origin's branches as they are. | |
| 1025 | 1028 | */ |
| 1026 | 1029 | export async function syncRepoNow( |
| 1027 | 1030 | app: App, |
| @@ -1032,7 +1035,38 @@ export async function syncRepoNow( | ||
| 1032 | 1035 | if (!writer.ok) return writer; |
| 1033 | 1036 | const link = originForRepo(app.db, repo.id); |
| 1034 | 1037 | if (!link) return err("not_found"); |
| 1035 | return ok(await syncOrigin(app, link.origin, { onlyRepoId: repo.id })); | |
| 1038 | return ok(await syncOrigin(app, link.origin, { onlyRepoId: repo.id, overwrite: true })); | |
| 1039 | } | |
| 1040 | ||
| 1041 | /** Taking the origin's new commits: what the sync did, or why it did not. */ | |
| 1042 | export type Taking = | |
| 1043 | | { readonly kind: "taken"; readonly count: number; readonly report: SyncReport } | |
| 1044 | | { readonly kind: "conflict"; readonly count: number } | |
| 1045 | | { readonly kind: "unread" }; | |
| 1046 | ||
| 1047 | /** | |
| 1048 | * The one action behind "N new commits upstream": a mirror takes what its | |
| 1049 | * origin has, unless its default branch holds commits the origin lacks — a | |
| 1050 | * sync would overwrite them, so that is a conflict and nothing moves. When | |
| 1051 | * the origin cannot be read, nothing moves either: it is not known whether | |
| 1052 | * the sync would overwrite. The sweep holds such a mirror too; only Sync now | |
| 1053 | * (`syncRepoNow`) overwrites, because a person asked for exactly that. | |
| 1054 | */ | |
| 1055 | export async function takeFromOrigin( | |
| 1056 | app: App, | |
| 1057 | actor: User | undefined, | |
| 1058 | repo: Repo, | |
| 1059 | ): Promise<Result<Taking, "unauthorized" | "forbidden" | "not_found">> { | |
| 1060 | const writer = requireWriter(app, actor, repo); | |
| 1061 | if (!writer.ok) return writer; | |
| 1062 | const link = originForRepo(app.db, repo.id); | |
| 1063 | if (link === undefined) return err("not_found"); | |
| 1064 | const seen = await readAhead(app, repo); | |
| 1065 | if (seen === undefined) return ok({ kind: "unread" }); | |
| 1066 | if (seen.diverged) return ok({ kind: "conflict", count: seen.count }); | |
| 1067 | const report = await syncOrigin(app, link.origin, { onlyRepoId: repo.id }); | |
| 1068 | await readAhead(app, repo); | |
| 1069 | return ok({ kind: "taken", count: seen.count, report }); | |
| 1036 | 1070 | } |
| 1037 | 1071 | |
| 1038 | 1072 | // ---- reconnecting a frozen mirror ---- |
src/core/origins.ts +17
| @@ -545,6 +545,22 @@ export function clearCursor(db: Database, originId: string, key: string): void { | ||
| 545 | 545 | ); |
| 546 | 546 | } |
| 547 | 547 | |
| 548 | /** | |
| 549 | * A repository's own cursors on every origin (`repo:<id>:…`): what its sync | |
| 550 | * and its upstream readings left. Cleared when the repository goes, and on | |
| 551 | * every origin but `keep` when it is linked afresh, so a reading never | |
| 552 | * outlives the link it was taken over. (Disconnecting an origin drops its | |
| 553 | * cursors with it, by the schema.) | |
| 554 | */ | |
| 555 | export function forgetRepoCursors(db: Database, repoId: string, keep = ""): void { | |
| 556 | const prefix = `repo:${repoId}:`; | |
| 557 | db.query("DELETE FROM origin_cursor WHERE substr(key, 1, ?2) = ?1 AND origin_id != ?3").run( | |
| 558 | prefix, | |
| 559 | prefix.length, | |
| 560 | keep, | |
| 561 | ); | |
| 562 | } | |
| 563 | ||
| 548 | 564 | // ---- the mirror link on a repo ---- |
| 549 | 565 | |
| 550 | 566 | export interface MirrorLink { |
| @@ -606,6 +622,7 @@ export function linkRepoToOrigin( | ||
| 606 | 622 | htmlUrl: string; |
| 607 | 623 | }, |
| 608 | 624 | ): void { |
| 625 | forgetRepoCursors(db, repoId, input.originId); | |
| 609 | 626 | db.query( |
| 610 | 627 | `UPDATE repo SET origin_id = ?1, origin_full_name = ?2, |
| 611 | 628 | origin_remote_id = ?3, origin_html_url = ?4, |
src/core/repos.ts +2
| @@ -23,6 +23,7 @@ import { createLog } from "../kernel/log.ts"; | ||
| 23 | 23 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 24 | 24 | import { appsNaming, isAppMember } from "./app_members.ts"; |
| 25 | 25 | import { forgetListings } from "./listings.ts"; |
| 26 | import { forgetRepoCursors } from "./origins.ts"; | |
| 26 | 27 | import { likeEscape, pageLimit } from "./issues.ts"; |
| 27 | 28 | import { |
| 28 | 29 | OWNER_STANDING, |
| @@ -959,6 +960,7 @@ export async function deleteRepo( | ||
| 959 | 960 | const removed = await deleteRepoDir(dir); |
| 960 | 961 | if (!removed.ok && present) return err("delete_failed"); |
| 961 | 962 | db.query("DELETE FROM repo WHERE id = ?1").run(repo.id); |
| 963 | forgetRepoCursors(db, repo.id); | |
| 962 | 964 | forgetListings(db); |
| 963 | 965 | if (!removed.ok) return err("delete_failed"); |
| 964 | 966 | return ok(); |
src/http/server.ts +2
| @@ -174,6 +174,7 @@ import { | ||
| 174 | 174 | repoFiles, |
| 175 | 175 | repoHome, |
| 176 | 176 | repoSyncSubmit, |
| 177 | repoTakeSubmit, | |
| 177 | 178 | treePage, |
| 178 | 179 | } from "../web/pages/repo.ts"; |
| 179 | 180 | import { mediaBlob } from "../web/pages/media.ts"; |
| @@ -1296,6 +1297,7 @@ export function buildRouter(): Router<Handler> { | ||
| 1296 | 1297 | // A mirror pulls on demand; a pin says "keep this on my machine". Both are |
| 1297 | 1298 | // ordinary repo-scoped writes, not account-level ones. |
| 1298 | 1299 | router.add("POST", "/:owner/:name/sync", repoSyncSubmit); |
| 1300 | router.add("POST", "/:owner/:name/sync/take", repoTakeSubmit); | |
| 1299 | 1301 | router.add("POST", "/:owner/:name/pin", pinSubmit); |
| 1300 | 1302 | |
| 1301 | 1303 | // Settings (owner-gated inside the handlers). |
src/main.ts +5 −1
| @@ -37,6 +37,7 @@ import { drainStreams } from "./http/sse.ts"; | ||
| 37 | 37 | import { serve } from "./http/server.ts"; |
| 38 | 38 | import { checkDue } from "./actions/upstream_follow.ts"; |
| 39 | 39 | import { runSync } from "./origins/sync.ts"; |
| 40 | import { runAheadReads } from "./origins/ahead.ts"; | |
| 40 | 41 | import { runGuardSweep } from "./origins/credential.ts"; |
| 41 | 42 | import { startForgeSsh } from "./tui/ssh.ts"; |
| 42 | 43 | import { AppsRuntime } from "./apps/runtime.ts"; |
| @@ -573,10 +574,13 @@ function positive(value: string | undefined, fallback: number): number { | ||
| 573 | 574 | // pushed to: an origin is due every ten minutes by default, and the tick only |
| 574 | 575 | // asks who is due — so this costs one indexed read a minute when nothing is |
| 575 | 576 | // connected. Like runDelivery, runSync never throws into the timer. |
| 576 | // The operator guard's sweep of stored origins rides the same tick: one | |
| 577 | // The mirrors' "N new commits upstream" readings ride the same tick under | |
| 578 | // their own guard (`origins/ahead.ts`), so a slow origin there never holds | |
| 579 | // the sweep. The operator guard's sweep of stored origins rides it too: one | |
| 577 | 580 | // origin a tick, the never-checked first (so on boot), every other daily. |
| 578 | 581 | const syncTimer = setInterval(() => { |
| 579 | 582 | runSync(app); |
| 583 | void runAheadReads(app); | |
| 580 | 584 | void runGuardSweep(app); |
| 581 | 585 | }, 60_000); |
| 582 | 586 | runSync(app); // one pass on boot: a forge that was down has fetching to do |
src/origins/ahead.ts added +204
| @@ -0,0 +1,204 @@ | ||
| 1 | /** | |
| 2 | * How far a mirror's origin has moved on: the commits on the origin's default | |
| 3 | * branch that the mirror's has not taken yet. | |
| 4 | * | |
| 5 | * A reading is one fetch of the origin's tip — objects only, no ref moves, so | |
| 6 | * nothing the mirror serves changes — and a count. It is kept as a cursor | |
| 7 | * (a cache: losing it costs one reading) and shown on the repository's head | |
| 8 | * with the one action that takes the commits. The action is the mirror's own | |
| 9 | * sync, so a mirror takes exactly what a sweep would; the difference is that | |
| 10 | * a mirror whose default branch holds commits the origin lacks is not | |
| 11 | * overwritten, it is reported as a conflict. | |
| 12 | * | |
| 13 | * Never throws: it runs from the worker's tick and from a click. | |
| 14 | */ | |
| 15 | ||
| 16 | import type { App } from "../app.ts"; | |
| 17 | import { | |
| 18 | clearCursor, | |
| 19 | getCursor, | |
| 20 | listAllOrigins, | |
| 21 | listMirrors, | |
| 22 | originForRepo, | |
| 23 | setCursor, | |
| 24 | } from "../core/origins.ts"; | |
| 25 | import { canRead, getRepoById, type Repo } from "../core/repos.ts"; | |
| 26 | import type { User } from "../core/users.ts"; | |
| 27 | import { isSafeOid } from "../git/guard.ts"; | |
| 28 | import { aheadCount, resolveOid } from "../git/merge.ts"; | |
| 29 | import { repoDir } from "../git/repos.ts"; | |
| 30 | import { providerForOrigin } from "./credential.ts"; | |
| 31 | import { fetchRefFrom } from "./git-mirror.ts"; | |
| 32 | ||
| 33 | /** How stale a reading may be before the tick takes another. */ | |
| 34 | export const AHEAD_EVERY_MS = 2 * 60_000; | |
| 35 | /** Readings one tick takes, so a slow origin cannot hold the others. */ | |
| 36 | const MAX_READINGS = 4; | |
| 37 | ||
| 38 | export interface Ahead { | |
| 39 | /** New commits on the origin's default branch. */ | |
| 40 | readonly count: number; | |
| 41 | /** The origin's tip when read. */ | |
| 42 | readonly tip: string; | |
| 43 | /** The mirror holds commits the origin does not: taking would overwrite them. */ | |
| 44 | readonly diverged: boolean; | |
| 45 | readonly at: string; | |
| 46 | } | |
| 47 | ||
| 48 | const aheadKey = (repoId: string): string => `repo:${repoId}:ahead`; | |
| 49 | ||
| 50 | /** The last reading of a mirror, or undefined when there is none or it is level. */ | |
| 51 | export function aheadOf(app: App, repo: Repo): Ahead | undefined { | |
| 52 | if (repo.origin === null) return undefined; | |
| 53 | return parse(getCursor(app.db, repo.origin.originId, aheadKey(repo.id))); | |
| 54 | } | |
| 55 | ||
| 56 | function parse(value: string | undefined): Ahead | undefined { | |
| 57 | const [count, tip, diverged, at] = (value ?? "").split(" "); | |
| 58 | const n = Number(count); | |
| 59 | if (!Number.isInteger(n) || n < 1 || !isSafeOid(tip ?? "") || at === undefined) return undefined; | |
| 60 | return { count: n, tip: tip!, diverged: diverged === "1", at }; | |
| 61 | } | |
| 62 | ||
| 63 | /** A sync took the commits: the reading is of an origin that has since moved. */ | |
| 64 | export function forgetAhead(app: App, originId: string, repoId: string): void { | |
| 65 | clearCursor(app.db, originId, aheadKey(repoId)); | |
| 66 | } | |
| 67 | ||
| 68 | /** Read one mirror against its origin now. Returns the reading, or undefined when it could not be taken. */ | |
| 69 | export async function readAhead(app: App, repo: Repo): Promise<Ahead | undefined> { | |
| 70 | try { | |
| 71 | return await read(app, repo); | |
| 72 | } catch (error) { | |
| 73 | app.log.warn("ahead reading failed", { repo: repo.id, error: String(error) }); | |
| 74 | return undefined; | |
| 75 | } | |
| 76 | } | |
| 77 | ||
| 78 | async function read(app: App, repo: Repo): Promise<Ahead | undefined> { | |
| 79 | const link = originForRepo(app.db, repo.id); | |
| 80 | if (link === undefined || link.origin.needsReauth || link.cloneUrl === "") return undefined; | |
| 81 | const provider = providerForOrigin(app, link.origin); | |
| 82 | if (!provider.ok) return undefined; | |
| 83 | const dir = repoDir(app.reposRoot, repo.ownerName, repo.name); | |
| 84 | const main = await resolveOid(dir, `refs/heads/${repo.defaultBranch}`); | |
| 85 | if (main === undefined) return undefined; | |
| 86 | const authEnv = await provider.value.gitAuthEnv(link.cloneUrl); | |
| 87 | const fetched = await fetchRefFrom(dir, link.cloneUrl, `refs/heads/${repo.defaultBranch}`, authEnv); | |
| 88 | if (!fetched.ok) return undefined; | |
| 89 | const tip = fetched.value; | |
| 90 | const count = tip === main ? 0 : await aheadCount(dir, main, tip); | |
| 91 | const key = aheadKey(repo.id); | |
| 92 | if (count === 0) { | |
| 93 | clearCursor(app.db, link.origin.id, key); | |
| 94 | return { count: 0, tip, diverged: false, at: new Date().toISOString() }; | |
| 95 | } | |
| 96 | const diverged = (await aheadCount(dir, tip, main)) > 0; | |
| 97 | const reading = { count, tip, diverged, at: new Date().toISOString() }; | |
| 98 | setCursor(app.db, link.origin.id, key, `${count} ${tip} ${diverged ? 1 : 0} ${reading.at}`); | |
| 99 | return reading; | |
| 100 | } | |
| 101 | ||
| 102 | /** | |
| 103 | * The tick's pass: the mirrors read longest ago, a few at a time. A mirror | |
| 104 | * with no reading is read first. | |
| 105 | */ | |
| 106 | export async function readAheadDue(app: App, now = Date.now()): Promise<number> { | |
| 107 | const seen: { repoId: string; at: number }[] = []; | |
| 108 | for (const origin of listAllOrigins(app.db)) { | |
| 109 | if (origin.needsReauth) continue; | |
| 110 | for (const mirror of listMirrors(app.db, origin.id)) { | |
| 111 | const last = getCursor(app.db, origin.id, `repo:${mirror.repoId}:ahead-read`); | |
| 112 | seen.push({ repoId: mirror.repoId, at: last === undefined ? 0 : Date.parse(last) }); | |
| 113 | } | |
| 114 | } | |
| 115 | let read = 0; | |
| 116 | // Each reading is one fetch bounded by FETCH_TIMEOUT_MS (git-mirror.ts), and | |
| 117 | // at most MAX_READINGS run, one after another. They run under their own | |
| 118 | // guard (runAheadReads), never the sweep's: a slow origin here delays only | |
| 119 | // the next reading, never a sync. | |
| 120 | for (const repoId of pickDue(seen, now)) { | |
| 121 | const repo = getRepoById(app.db, repoId); | |
| 122 | const link = repo === undefined ? undefined : originForRepo(app.db, repoId); | |
| 123 | if (repo === undefined || link === undefined) continue; | |
| 124 | setCursor(app.db, link.origin.id, `repo:${repoId}:ahead-read`, new Date(now).toISOString()); | |
| 125 | await readAhead(app, repo); | |
| 126 | read++; | |
| 127 | } | |
| 128 | return read; | |
| 129 | } | |
| 130 | ||
| 131 | /** | |
| 132 | * The timer's entry point, beside `runSync` and apart from it: single-flight | |
| 133 | * per forge under its own guard, so a reading stuck on a slow origin never | |
| 134 | * holds the sweep, and the sweep never holds a reading. Never throws. | |
| 135 | */ | |
| 136 | export function runAheadReads(app: App): Promise<void> { | |
| 137 | const running = reading.get(app); | |
| 138 | if (running !== undefined) return running; | |
| 139 | const run = readAheadDue(app) | |
| 140 | .then(() => undefined) | |
| 141 | .catch((error: unknown) => { | |
| 142 | app.log.error("ahead readings failed", { error: String(error) }); | |
| 143 | }) | |
| 144 | .finally(() => { | |
| 145 | reading.delete(app); | |
| 146 | }); | |
| 147 | reading.set(app, run); | |
| 148 | return run; | |
| 149 | } | |
| 150 | ||
| 151 | const reading = new WeakMap<App, Promise<void>>(); | |
| 152 | ||
| 153 | /** For tests: whether a pass of readings is running. */ | |
| 154 | export const aheadBusy = (app: App): boolean => reading.has(app); | |
| 155 | ||
| 156 | /** Which mirrors one tick reads: those not read for AHEAD_EVERY_MS, longest-ago first, at most MAX_READINGS. */ | |
| 157 | export function pickDue(seen: readonly { repoId: string; at: number }[], now: number): string[] { | |
| 158 | return seen | |
| 159 | .filter((m) => now - m.at >= AHEAD_EVERY_MS) | |
| 160 | .sort((a, b) => a.at - b.at) | |
| 161 | .slice(0, MAX_READINGS) | |
| 162 | .map((m) => m.repoId); | |
| 163 | } | |
| 164 | ||
| 165 | /** What the head says of a reading: the count, and a conflict when taking would overwrite. */ | |
| 166 | export function aheadWords(ahead: Ahead): string { | |
| 167 | const n = `${ahead.count} new commit${ahead.count === 1 ? "" : "s"} upstream`; | |
| 168 | return ahead.diverged ? `${n} · conflicts with commits here` : n; | |
| 169 | } | |
| 170 | ||
| 171 | /** | |
| 172 | * Every mirror the viewer may read whose last reading found the origin ahead: | |
| 173 | * one query for the readings that exist, so the cost is the mirrors behind, | |
| 174 | * not the repositories visible. | |
| 175 | */ | |
| 176 | export function mirrorsBehind(app: App, viewer: User | undefined): { repo: Repo; ahead: Ahead }[] { | |
| 177 | const rows = app.db | |
| 178 | .query<{ id: string; value: string }, []>( | |
| 179 | `SELECT repo.id, origin_cursor.value FROM origin_cursor | |
| 180 | JOIN repo ON repo.origin_id = origin_cursor.origin_id | |
| 181 | AND origin_cursor.key = 'repo:' || repo.id || ':ahead' | |
| 182 | ORDER BY repo.id`, | |
| 183 | ) | |
| 184 | .all(); | |
| 185 | const out: { repo: Repo; ahead: Ahead }[] = []; | |
| 186 | for (const row of rows) { | |
| 187 | const repo = getRepoById(app.db, row.id); | |
| 188 | const ahead = parse(row.value); | |
| 189 | if (repo !== undefined && ahead !== undefined && canRead(app.db, repo, viewer)) out.push({ repo, ahead }); | |
| 190 | } | |
| 191 | return out; | |
| 192 | } | |
| 193 | ||
| 194 | /** The reading of a mirror to someone who may read it; nothing otherwise. */ | |
| 195 | export function aheadFor(app: App, repo: Repo, viewer: User | undefined): Ahead | undefined { | |
| 196 | return canRead(app.db, repo, viewer) ? aheadOf(app, repo) : undefined; | |
| 197 | } | |
| 198 | ||
| 199 | export const CONFLICT_WORDS = (branch: string, count: number): string => | |
| 200 | `${branch} here holds commits the origin lacks, so taking its ${count} would overwrite them. Nothing moved; Sync now replaces ${branch} with what the origin has.`; | |
| 201 | /** Why the sweep left a mirror's git alone: shown where its last fetch is. */ | |
| 202 | export const HELD_WORDS = (branch: string): string => | |
| 203 | `${branch} here holds commits the origin lacks, so the sweep left the git alone. Sync now replaces ${branch} with what the origin has.`; | |
| 204 | export const UNREAD_WORDS = "The origin could not be read just now, so nothing was taken. Try again shortly."; |
src/origins/sync.ts +15 −1
| @@ -45,6 +45,7 @@ import { pullDiff, type Pull, getPull } from "../core/pulls.ts"; | ||
| 45 | 45 | import { repoDir } from "../git/repos.ts"; |
| 46 | 46 | import { createLog } from "../kernel/log.ts"; |
| 47 | 47 | import { bustOrigin } from "../view/cache.ts"; |
| 48 | import { aheadOf, forgetAhead, HELD_WORDS } from "./ahead.ts"; | |
| 48 | 49 | import { providerForOrigin } from "./credential.ts"; |
| 49 | 50 | import { fetchMirror } from "./git-mirror.ts"; |
| 50 | 51 | import { |
| @@ -114,6 +115,12 @@ export interface SyncOptions { | ||
| 114 | 115 | readonly initial?: boolean; |
| 115 | 116 | /** Sync only this repo (the "sync this mirror now" path). */ |
| 116 | 117 | readonly onlyRepoId?: string; |
| 118 | /** | |
| 119 | * Sync now on one mirror: a person asked for the origin's branches, so a | |
| 120 | * default branch holding commits the origin lacks is overwritten. Without | |
| 121 | * it such a mirror is held (see `origins/ahead.ts`). | |
| 122 | */ | |
| 123 | readonly overwrite?: boolean; | |
| 117 | 124 | /** Live stage reports, for a mirror job with a UI attached. */ |
| 118 | 125 | readonly onProgress?: SyncProgress; |
| 119 | 126 | } |
| @@ -354,7 +361,13 @@ async function syncMirrorUnguarded( | ||
| 354 | 361 | const runStarted = new Date().toISOString(); |
| 355 | 362 | |
| 356 | 363 | // ---- git ---- |
| 357 | if (cloneUrl !== "") { | |
| 364 | // A mirror whose default branch holds commits the origin lacks (the last | |
| 365 | // reading said so) is held: its page says taking would overwrite them, and | |
| 366 | // the sweep keeps that true. Only Sync now, a person's word, overwrites. | |
| 367 | const held = options.overwrite !== true && aheadOf(app, repo)?.diverged === true; | |
| 368 | if (held) { | |
| 369 | recordFetch(app.db, repo.id, { at: runStarted, error: HELD_WORDS(repo.defaultBranch) }); | |
| 370 | } else if (cloneUrl !== "") { | |
| 358 | 371 | report("fetching", "fetching git objects"); |
| 359 | 372 | const authEnv = await provider.gitAuthEnv(cloneUrl); |
| 360 | 373 | const fetched = await fetchMirror(dir, cloneUrl, authEnv); |
| @@ -366,6 +379,7 @@ async function syncMirrorUnguarded( | ||
| 366 | 379 | }); |
| 367 | 380 | } else { |
| 368 | 381 | recordFetch(app.db, repo.id, { at: runStarted }); |
| 382 | forgetAhead(app, origin.id, repo.id); | |
| 369 | 383 | out.refsMoved = fetched.value.changes.length; |
| 370 | 384 | if (fetched.value.changes.length > 0) { |
| 371 | 385 | // Objects were written without a push: measure again at the next check. |
src/tui/screens/entries.ts +2
| @@ -33,6 +33,7 @@ import { entry as orgTeams } from "./orgteams.ts"; | ||
| 33 | 33 | import { entry as newIssue } from "./newissue.ts"; |
| 34 | 34 | import { entry as now } from "./nowlist.ts"; |
| 35 | 35 | import { entry as repos } from "./repolist.ts"; |
| 36 | import { entry as originAhead } from "./originahead.ts"; | |
| 36 | 37 | import { entry as owner } from "./owner.ts"; |
| 37 | 38 | import { entry as people } from "./people.ts"; |
| 38 | 39 | import { entry as pinned } from "./pinned.ts"; |
| @@ -101,6 +102,7 @@ export const ENTRIES: readonly PaletteEntry[] = [ | ||
| 101 | 102 | front, |
| 102 | 103 | owner, |
| 103 | 104 | forges, |
| 105 | originAhead, | |
| 104 | 106 | profile, |
| 105 | 107 | now, |
| 106 | 108 | repos, |
src/tui/screens/originahead.ts added +84
| @@ -0,0 +1,84 @@ | ||
| 1 | /** | |
| 2 | * New commits upstream, in the terminal: the mirrors whose origin has moved | |
| 3 | * on, as the repository head shows them on the web ("N new commits upstream"). | |
| 4 | * `t` takes them through `takeFromOrigin`, the page's own action, offered only | |
| 5 | * to a person who may write the repository. A reader sees the count; a guest | |
| 6 | * sees only what is public. | |
| 7 | */ | |
| 8 | ||
| 9 | import { requireWriter } from "../../actions/access.ts"; | |
| 10 | import { takeFromOrigin } from "../../actions/origins.ts"; | |
| 11 | import type { Repo } from "../../core/repos.ts"; | |
| 12 | import { aheadWords, CONFLICT_WORDS, mirrorsBehind, UNREAD_WORDS } from "../../origins/ahead.ts"; | |
| 13 | import type { Ctx, Screen } from "../app/app.ts"; | |
| 14 | import { binding } from "../app/keys.ts"; | |
| 15 | import { style } from "../term/style.ts"; | |
| 16 | import { truncate } from "../term/width.ts"; | |
| 17 | import type { Session } from "../session.ts"; | |
| 18 | import { fill, len, split } from "../ui/layout.ts"; | |
| 19 | import { emptyState } from "../ui/widgets.ts"; | |
| 20 | import { ListState, drawList, type ListRow } from "./common.ts"; | |
| 21 | import type { PaletteEntry } from "./entries.ts"; | |
| 22 | ||
| 23 | /** The screen, and the take its last `t` started, for whoever must wait on it. */ | |
| 24 | export interface OriginAheadScreen extends Screen { | |
| 25 | taking(): Promise<void> | undefined; | |
| 26 | } | |
| 27 | ||
| 28 | export function originAheadScreen(f: Session, ctx: Ctx): OriginAheadScreen { | |
| 29 | const state = new ListState(); | |
| 30 | let rows = mirrorsBehind(f.app, f.user); | |
| 31 | let taking: Promise<void> | undefined; | |
| 32 | const selected = () => rows[state.index]; | |
| 33 | // Exactly the page's gate: the route behind Take them asks requireWriter. | |
| 34 | const mayTake = (repo: Repo | undefined): repo is Repo => | |
| 35 | repo !== undefined && requireWriter(f.app, f.user, repo).ok; | |
| 36 | ||
| 37 | return { | |
| 38 | title: "New commits upstream", | |
| 39 | keys: [binding(["j", "k"], "move cursor"), binding(["t"], "take them")], | |
| 40 | render(area, s) { | |
| 41 | const c = ctx.theme.c; | |
| 42 | const [head, body] = split(area, "v", [len(1), fill(1)]); | |
| 43 | if (rows.length === 0) { | |
| 44 | emptyState(s, body!, c, { title: "Every mirror is level with its origin." }); | |
| 45 | return; | |
| 46 | } | |
| 47 | const list: ListRow[] = rows.map((r) => ({ | |
| 48 | title: `${r.repo.ownerName}/${r.repo.name}`, | |
| 49 | secondary: aheadWords(r.ahead), | |
| 50 | meta: "", | |
| 51 | })); | |
| 52 | const line = mayTake(selected()?.repo) ? "t takes them" : "you can read this, not take it"; | |
| 53 | s.text(head!.x, head!.y, truncate(line, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w); | |
| 54 | drawList(s, body!, c, state, list); | |
| 55 | }, | |
| 56 | key(e) { | |
| 57 | if (state.key(e, rows.length)) return true; | |
| 58 | if (e.ctrl || e.alt || e.key !== "t") return false; | |
| 59 | const repo = selected()?.repo; | |
| 60 | if (!mayTake(repo)) return false; | |
| 61 | taking = takeFromOrigin(f.app, f.user, repo).then((done) => { | |
| 62 | if (!done.ok) ctx.toast("that could not be taken", "danger"); | |
| 63 | else if (done.value.kind === "conflict") | |
| 64 | ctx.toast(CONFLICT_WORDS(repo.defaultBranch, done.value.count), "danger"); | |
| 65 | else if (done.value.kind === "unread") ctx.toast(UNREAD_WORDS, "danger"); | |
| 66 | else ctx.toast("taken from the origin", "success"); | |
| 67 | rows = mirrorsBehind(f.app, f.user); | |
| 68 | state.clamp(rows.length); | |
| 69 | ctx.invalidate(); | |
| 70 | }); | |
| 71 | return true; | |
| 72 | }, | |
| 73 | taking: () => taking, | |
| 74 | }; | |
| 75 | } | |
| 76 | ||
| 77 | /** Mirrors behind their origin, as the repository head has them. */ | |
| 78 | export const entry: PaletteEntry = { | |
| 79 | id: "origin-ahead", | |
| 80 | title: "New commits upstream", | |
| 81 | hint: "mirrors whose origin has moved on: take them", | |
| 82 | offered: () => true, | |
| 83 | run: (session, ctx) => ctx.push(originAheadScreen(session, ctx)), | |
| 84 | }; |
src/web/pages/repo.ts +36 −13
| @@ -11,7 +11,8 @@ | ||
| 11 | 11 | import { recordLook } from "../../flow/valve_store.ts"; |
| 12 | 12 | import { immutableWhenAddressed } from "../../http/addressed.ts"; |
| 13 | 13 | import { cachedTree, keepTree, resolveCommit } from "./tree_cache.ts"; |
| 14 | import { behindLine, followingLine } from "./upstream-follow.ts"; | |
| 14 | import { behindLine, followingLine, originAheadLine } from "./upstream-follow.ts"; | |
| 15 | import { CONFLICT_WORDS, UNREAD_WORDS } from "../../origins/ahead.ts"; | |
| 15 | 16 | import { upstreamOffer } from "../../actions/offer_upstream.ts"; |
| 16 | 17 | import { offerUpstream } from "./offer-upstream.ts"; |
| 17 | 18 | import { NOT_A_MEMBER } from "../../core/roles.ts"; |
| @@ -38,7 +39,7 @@ import { | ||
| 38 | 39 | } from "./following.ts"; |
| 39 | 40 | import { nothingNew } from "../../actions/follow.ts"; |
| 40 | 41 | import { frozenBlock } from "./frozen.ts"; |
| 41 | import { pinRepo, syncRepoNow, unpinRepo } from "../../actions/origins.ts"; | |
| 42 | import { pinRepo, syncRepoNow, takeFromOrigin, unpinRepo } from "../../actions/origins.ts"; | |
| 42 | 43 | import { |
| 43 | 44 | canAdmin, |
| 44 | 45 | forkParentFor, |
| @@ -344,16 +345,7 @@ export async function repoSyncSubmit(ctx: Ctx): Promise<Response> { | ||
| 344 | 345 | }); |
| 345 | 346 | } |
| 346 | 347 | const synced = await syncRepoNow(ctx.app, ctx.auth.user, repo); |
| 347 | if (!synced.ok) | |
| 348 | return flashTo(ctx, repoBase(repo), { | |
| 349 | tone: "danger", | |
| 350 | message: | |
| 351 | synced.error !== "not_found" | |
| 352 | ? "You need write access to sync this mirror." | |
| 353 | : repo.detached !== null | |
| 354 | ? "This mirror is frozen: its origin was disconnected. Reconnect it first." | |
| 355 | : "This repository does not mirror an origin.", | |
| 356 | }); | |
| 348 | if (!synced.ok) return flashTo(ctx, repoBase(repo), { tone: "danger", message: mirrorRefusal(repo, synced.error) }); | |
| 357 | 349 | return flashTo(ctx, repoBase(repo), { |
| 358 | 350 | ...(synced.value.error === "" |
| 359 | 351 | ? { message: "Synced from the origin." } |
| @@ -361,6 +353,37 @@ export async function repoSyncSubmit(ctx: Ctx): Promise<Response> { | ||
| 361 | 353 | }); |
| 362 | 354 | } |
| 363 | 355 | |
| 356 | /** Why a mirror's sync or take was refused, in the words both say. */ | |
| 357 | function mirrorRefusal(repo: Repo, error: "unauthorized" | "forbidden" | "not_found"): string { | |
| 358 | return error !== "not_found" | |
| 359 | ? "You need write access to sync this mirror." | |
| 360 | : repo.detached !== null | |
| 361 | ? "This mirror is frozen: its origin was disconnected. Reconnect it first." | |
| 362 | : "This repository does not mirror an origin."; | |
| 363 | } | |
| 364 | ||
| 365 | // POST /:owner/:name/sync/take — "Take them" under "N new commits upstream": | |
| 366 | // the mirror's sync, asked first, so a default branch holding commits the | |
| 367 | // origin lacks is reported as a conflict and nothing moves. | |
| 368 | export async function repoTakeSubmit(ctx: Ctx): Promise<Response> { | |
| 369 | const repo = loadRepo(ctx); | |
| 370 | if (!repo) return notFoundPage(ctx); | |
| 371 | if (!ctx.auth.user) return signInFirst(ctx); | |
| 372 | const form = await readForm(ctx, "rw"); | |
| 373 | if (form instanceof Response) return form; | |
| 374 | const taken = await takeFromOrigin(ctx.app, ctx.auth.user, repo); | |
| 375 | if (!taken.ok) return flashTo(ctx, repoBase(repo), { tone: "danger", message: mirrorRefusal(repo, taken.error) }); | |
| 376 | const t = taken.value; | |
| 377 | if (t.kind === "conflict") | |
| 378 | return flashTo(ctx, repoBase(repo), { tone: "danger", message: CONFLICT_WORDS(repo.defaultBranch, t.count) }); | |
| 379 | if (t.kind === "unread") return flashTo(ctx, repoBase(repo), { tone: "danger", message: UNREAD_WORDS }); | |
| 380 | return flashTo(ctx, repoBase(repo), { | |
| 381 | ...(t.report.error === "" | |
| 382 | ? { message: `Took ${t.count} new commit${t.count === 1 ? "" : "s"} from the origin.` } | |
| 383 | : { tone: "danger" as const, message: t.report.error }), | |
| 384 | }); | |
| 385 | } | |
| 386 | ||
| 364 | 387 | /** |
| 365 | 388 | * POST /:owner/:name/pin — "keep this on my machine", toggled. |
| 366 | 389 | * |
| @@ -1318,7 +1341,7 @@ async function codeHome(ctx: Ctx, repo: Repo): Promise<Response> { | ||
| 1318 | 1341 | const rendering = performance.now(); |
| 1319 | 1342 | const body = html`${scopeHeader(ctx, scope, "code", { |
| 1320 | 1343 | branchCount: refs.branches.length, |
| 1321 | })}${timePhase(ctx.request, "behind", () => behindLine(ctx, repo))}${archivedLine(ctx, repo)}${noApp} | |
| 1344 | })}${timePhase(ctx.request, "behind", () => behindLine(ctx, repo))}${originAheadLine(ctx, repo)}${archivedLine(ctx, repo)}${noApp} | |
| 1322 | 1345 | ${offerUpstream(repo, parent, offer)} |
| 1323 | 1346 | ${latest === null |
| 1324 | 1347 | ? "" |
src/web/pages/upstream-follow.ts +26
| @@ -18,6 +18,8 @@ import { | ||
| 18 | 18 | upstreamLabel, |
| 19 | 19 | upstreamShort, |
| 20 | 20 | } from "../../actions/upstream_follow.ts"; |
| 21 | import { aheadFor, aheadWords } from "../../origins/ahead.ts"; | |
| 22 | import { requireWriter } from "../../actions/access.ts"; | |
| 21 | 23 | import { |
| 22 | 24 | addFollowSources, |
| 23 | 25 | checkSourcesNow, |
| @@ -83,6 +85,30 @@ export function behindLine(ctx: Ctx, repo: Repo): Html | null { | ||
| 83 | 85 | </div>`; |
| 84 | 86 | } |
| 85 | 87 | |
| 88 | /** | |
| 89 | * Under the head of a mirror: how many commits its origin has that it has | |
| 90 | * not taken, with the one action that takes them. Read from the stored | |
| 91 | * reading, never the network; null when level or unread, or when the viewer | |
| 92 | * cannot read the repository. Only writers see the action: the route behind | |
| 93 | * it asks the same. | |
| 94 | */ | |
| 95 | export function originAheadLine(ctx: Ctx, repo: Repo): Html | null { | |
| 96 | if (repo.origin === null) return null; | |
| 97 | const ahead = aheadFor(ctx.app, repo, ctx.auth.user); | |
| 98 | if (ahead === undefined) return null; | |
| 99 | const base = repoBase(repo); | |
| 100 | return html`<div class="small muted" data-origin-ahead> | |
| 101 | ${icon("sync")} | |
| 102 | ${aheadWords(ahead)} · read ${timeAgo(ahead.at)} | |
| 103 | ${when( | |
| 104 | requireWriter(ctx.app, ctx.auth.user, repo).ok, | |
| 105 | () => html`<form method="post" action="${base}/sync/take" class="inline"> | |
| 106 | ${btn("Take them", { size: "sm", icon: "sync" })} | |
| 107 | </form>`, | |
| 108 | )} | |
| 109 | </div>`; | |
| 110 | } | |
| 111 | ||
| 86 | 112 | /** The last five checks, newest first; nothing when there are none. */ |
| 87 | 113 | function historyList(ctx: Ctx, repo: Repo): Html | null { |
| 88 | 114 | const checks = listChecks(ctx.app.db, repo.id, 5); |
test/origin-ahead.test.ts added +344
| @@ -0,0 +1,344 @@ | ||
| 1 | /** | |
| 2 | * Following the origin across forges: a repository brought from another forge | |
| 3 | * shows how many commits the origin has that it has not taken, and one action | |
| 4 | * takes them. Two real forges: the origin serves git, a fake API speaks for it. | |
| 5 | */ | |
| 6 | ||
| 7 | import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from "bun:test"; | |
| 8 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 9 | import { tmpdir } from "node:os"; | |
| 10 | import { join } from "node:path"; | |
| 11 | import { createApp, type App } from "../src/app.ts"; | |
| 12 | import { takeFromOrigin } from "../src/actions/origins.ts"; | |
| 13 | import { | |
| 14 | clearCursor, | |
| 15 | createOrigin, | |
| 16 | forgetRepoCursors, | |
| 17 | getCursor, | |
| 18 | markNeedsReauth, | |
| 19 | originForRepo, | |
| 20 | setCursor, | |
| 21 | } from "../src/core/origins.ts"; | |
| 22 | import { getRepo } from "../src/core/repos.ts"; | |
| 23 | import { createUser, getUserByName } from "../src/core/users.ts"; | |
| 24 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 25 | import { settled } from "../src/origins/jobs.ts"; | |
| 26 | import { | |
| 27 | aheadBusy, | |
| 28 | aheadOf, | |
| 29 | CONFLICT_WORDS, | |
| 30 | HELD_WORDS, | |
| 31 | pickDue, | |
| 32 | readAhead, | |
| 33 | readAheadDue, | |
| 34 | runAheadReads, | |
| 35 | UNREAD_WORDS, | |
| 36 | } from "../src/origins/ahead.ts"; | |
| 37 | import { requestSyncNow, runSync, syncBusy, syncOrigin, syncsSettled } from "../src/origins/sync.ts"; | |
| 38 | import type { Ctx } from "../src/tui/app/app.ts"; | |
| 39 | import { originAheadScreen } from "../src/tui/screens/originahead.ts"; | |
| 40 | import type { Session } from "../src/tui/session.ts"; | |
| 41 | import { Screen as Surface } from "../src/tui/term/screen.ts"; | |
| 42 | import { theme } from "../src/tui/ui/theme.ts"; | |
| 43 | import { newFakeState, serveFakeForge, type FakeForge } from "./fake-origin.ts"; | |
| 44 | import { proveAll } from "./helpers/verified.ts"; | |
| 45 | import { seedAdmin } from "./helpers/admin.ts"; | |
| 46 | ||
| 47 | setDefaultTimeout(60_000); | |
| 48 | ||
| 49 | const LOGIN = "octo"; | |
| 50 | const TOKEN = "octo-ahead-token-7a21c4"; | |
| 51 | const PASSWORD = "alice-secret-5521"; | |
| 52 | const basic = (user: string, secret: string): string => | |
| 53 | `Basic ${Buffer.from(`${user}:${secret}`).toString("base64")}`; | |
| 54 | ||
| 55 | describe("new commits upstream, taken with one action", () => { | |
| 56 | const dirs: string[] = []; | |
| 57 | let app: App; | |
| 58 | let originApp: App; | |
| 59 | let servers: Servers; | |
| 60 | let originServers: Servers; | |
| 61 | let fake: FakeForge; | |
| 62 | const mine = { authorization: basic("alice", PASSWORD) }; | |
| 63 | const theirs = { "content-type": "application/json", authorization: basic(LOGIN, TOKEN) }; | |
| 64 | ||
| 65 | const commitOnOrigin = async (path: string): Promise<void> => { | |
| 66 | const made = await fetch(`${originServers.url}/api/v1/repos/octo/lib/contents`, { | |
| 67 | method: "POST", | |
| 68 | headers: theirs, | |
| 69 | body: JSON.stringify({ branch: "main", message: `add ${path}`, files: [{ path, content: `${path}\n` }] }), | |
| 70 | }); | |
| 71 | expect(made.status).toBeLessThan(300); | |
| 72 | }; | |
| 73 | const head = async (): Promise<string> => | |
| 74 | (await (await fetch(`${servers.url}/alice/lib`, { headers: mine })).text()); | |
| 75 | ||
| 76 | beforeAll(async () => { | |
| 77 | for (const prefix of ["forge-ahead-", "forge-ahead-origin-"]) | |
| 78 | dirs.push(await mkdtemp(join(tmpdir(), prefix))); | |
| 79 | originApp = createApp({ dataDir: dirs[1]!, port: 0 }); | |
| 80 | originServers = await serve(originApp); | |
| 81 | await seedAdmin(originApp, { username: LOGIN, email: "[email protected]", password: TOKEN }); | |
| 82 | proveAll(originApp); | |
| 83 | const created = await fetch(`${originServers.url}/api/v1/repos`, { | |
| 84 | method: "POST", | |
| 85 | headers: theirs, | |
| 86 | body: JSON.stringify({ name: "lib", visibility: "public" }), | |
| 87 | }); | |
| 88 | expect(created.status).toBeLessThan(300); | |
| 89 | await commitOnOrigin("README.md"); | |
| 90 | ||
| 91 | const state = newFakeState("forgejo", LOGIN, TOKEN); | |
| 92 | state.repos.push({ | |
| 93 | id: 5, | |
| 94 | fullName: "octo/lib", | |
| 95 | private: false, | |
| 96 | defaultBranch: "main", | |
| 97 | cloneUrl: `${originServers.url}/octo/lib.git`, | |
| 98 | }); | |
| 99 | fake = serveFakeForge(state); | |
| 100 | state.repos[0]!.htmlUrl = `${fake.url}/octo/lib`; | |
| 101 | app = createApp({ dataDir: dirs[0]!, port: 0 }); | |
| 102 | servers = await serve(app); | |
| 103 | await seedAdmin(app, { username: "alice", email: "[email protected]", password: PASSWORD }); | |
| 104 | proveAll(app); | |
| 105 | const made = createOrigin(app.db, app.config.dataDir, { | |
| 106 | userId: getUserByName(app.db, "alice")!.id, | |
| 107 | kind: "forgejo", | |
| 108 | apiUrl: fake.apiUrl, | |
| 109 | webUrl: fake.url, | |
| 110 | login: LOGIN, | |
| 111 | token: TOKEN, | |
| 112 | }); | |
| 113 | if (!made.ok) throw new Error(made.error); | |
| 114 | const started = await fetch(`${servers.url}/bring`, { | |
| 115 | method: "POST", | |
| 116 | redirect: "manual", | |
| 117 | headers: { ...mine, "content-type": "application/x-www-form-urlencoded" }, | |
| 118 | body: new URLSearchParams({ link: `${fake.url}/octo/lib` }).toString(), | |
| 119 | }); | |
| 120 | expect(started.status).toBe(303); | |
| 121 | const job = await settled(started.headers.get("location")!.split("/").pop()!); | |
| 122 | expect(job?.stage).toBe("done"); | |
| 123 | }, 120_000); | |
| 124 | ||
| 125 | afterAll(async () => { | |
| 126 | await syncsSettled(); | |
| 127 | fake.stop(); | |
| 128 | servers.stop(); | |
| 129 | originServers.stop(); | |
| 130 | app.close(); | |
| 131 | originApp.close(); | |
| 132 | for (const d of dirs) await rm(d, { recursive: true, force: true, maxRetries: 5 }); | |
| 133 | }, 120_000); | |
| 134 | ||
| 135 | const post = (as: Record<string, string>, path = "sync/take") => | |
| 136 | fetch(`${servers.url}/alice/lib/${path}`, { | |
| 137 | method: "POST", | |
| 138 | redirect: "manual", | |
| 139 | headers: { ...as, "content-type": "application/x-www-form-urlencoded", origin: servers.url }, | |
| 140 | body: "", | |
| 141 | }); | |
| 142 | const flashed = async (res: Response): Promise<string> => { | |
| 143 | const cookie = (res.headers.get("set-cookie") ?? "").split(";")[0]!; | |
| 144 | return (await (await fetch(`${servers.url}/alice/lib`, { headers: { ...mine, cookie } })).text()); | |
| 145 | }; | |
| 146 | const mainOid = (): string => | |
| 147 | Bun.spawnSync(["git", "rev-parse", "main"], { cwd: join(app.reposRoot, "alice", "lib.git") }).stdout.toString().trim(); | |
| 148 | ||
| 149 | test("commit on the origin, see the count, take them, the count is 0", async () => { | |
| 150 | const repo = getRepo(app.db, "alice", "lib")!; | |
| 151 | expect(aheadOf(app, repo)).toBeUndefined(); | |
| 152 | expect(await head()).not.toContain("data-origin-ahead"); | |
| 153 | ||
| 154 | await commitOnOrigin("one.txt"); | |
| 155 | await commitOnOrigin("two.txt"); | |
| 156 | expect((await readAhead(app, repo))?.count).toBe(2); | |
| 157 | expect(aheadOf(app, repo)?.count).toBe(2); | |
| 158 | const shown = await head(); | |
| 159 | expect(shown).toContain("data-origin-ahead"); | |
| 160 | expect(shown).toContain("2 new commits upstream"); | |
| 161 | ||
| 162 | const taken = await post(mine); | |
| 163 | expect(taken.status).toBe(303); | |
| 164 | expect(await flashed(taken)).toContain("Took 2 new commits from the origin."); | |
| 165 | expect(aheadOf(app, repo)).toBeUndefined(); | |
| 166 | expect((await readAhead(app, repo))?.count).toBe(0); | |
| 167 | expect(await head()).not.toContain("data-origin-ahead"); | |
| 168 | }); | |
| 169 | ||
| 170 | test("the worker's pass reads a mirror on its own", async () => { | |
| 171 | const repo = getRepo(app.db, "alice", "lib")!; | |
| 172 | await commitOnOrigin("three.txt"); | |
| 173 | expect(await readAheadDue(app, Date.now() + 3 * 60_000)).toBeGreaterThan(0); | |
| 174 | expect(aheadOf(app, repo)?.count).toBe(1); | |
| 175 | }); | |
| 176 | ||
| 177 | test("a mirror holding commits the origin lacks says conflict and moves nothing", async () => { | |
| 178 | const repo = getRepo(app.db, "alice", "lib")!; | |
| 179 | const dir = join(app.reposRoot, "alice", "lib.git"); | |
| 180 | const tree = Bun.spawnSync(["git", "rev-parse", "main^{tree}"], { cwd: dir }).stdout.toString().trim(); | |
| 181 | const main = Bun.spawnSync(["git", "rev-parse", "main"], { cwd: dir }).stdout.toString().trim(); | |
| 182 | const local = Bun.spawnSync( | |
| 183 | ["git", "-c", "user.name=t", "-c", "user.email=t@t", "commit-tree", tree, "-p", main, "-m", "local only"], | |
| 184 | { cwd: dir }, | |
| 185 | ).stdout.toString().trim(); | |
| 186 | Bun.spawnSync(["git", "update-ref", "refs/heads/main", local], { cwd: dir }); | |
| 187 | await readAhead(app, repo); | |
| 188 | expect(aheadOf(app, repo)?.diverged).toBe(true); | |
| 189 | const taken = await post(mine); | |
| 190 | expect(taken.status).toBe(303); | |
| 191 | expect(await flashed(taken)).toContain(CONFLICT_WORDS(repo.defaultBranch, aheadOf(app, repo)!.count)); | |
| 192 | expect(mainOid()).toBe(local); | |
| 193 | }); | |
| 194 | ||
| 195 | test("the sweep leaves a diverged main untouched, and says why where the last fetch is", async () => { | |
| 196 | const repo = getRepo(app.db, "alice", "lib")!; | |
| 197 | const before = mainOid(); | |
| 198 | expect(aheadOf(app, repo)?.diverged).toBe(true); | |
| 199 | await syncOrigin(app, originForRepo(app.db, repo.id)!.origin); | |
| 200 | expect(mainOid()).toBe(before); | |
| 201 | expect(getRepo(app.db, "alice", "lib")!.origin!.fetchError).toBe(HELD_WORDS(repo.defaultBranch)); | |
| 202 | expect(aheadOf(app, repo)?.diverged).toBe(true); | |
| 203 | }); | |
| 204 | ||
| 205 | test("a reading stuck on a slow origin does not hold the sweep", async () => { | |
| 206 | const repo = getRepo(app.db, "alice", "lib")!; | |
| 207 | const origin = originForRepo(app.db, repo.id)!.origin; | |
| 208 | // An origin that accepts and never answers: git waits on it. | |
| 209 | const silent = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { data() {} } }); | |
| 210 | const row = app.db | |
| 211 | .query<{ clone_url: string }, [string]>("SELECT clone_url FROM origin_repo WHERE origin_id = ?1") | |
| 212 | .get(origin.id)!; | |
| 213 | app.db.query("UPDATE origin_repo SET clone_url = ?1 WHERE origin_id = ?2") | |
| 214 | .run(`http://127.0.0.1:${silent.port}/octo/lib.git`, origin.id); | |
| 215 | clearCursor(app.db, origin.id, `repo:${repo.id}:ahead-read`); | |
| 216 | const reads = runAheadReads(app); | |
| 217 | expect(aheadBusy(app)).toBe(true); | |
| 218 | app.db.query("UPDATE origin_repo SET clone_url = ?1 WHERE origin_id = ?2").run(row.clone_url, origin.id); | |
| 219 | requestSyncNow(app, origin.id); | |
| 220 | runSync(app); | |
| 221 | expect(syncBusy(app)).toBe(true); | |
| 222 | const deadline = Date.now() + 30_000; | |
| 223 | while (syncBusy(app) && Date.now() < deadline) await Bun.sleep(10); | |
| 224 | expect(syncBusy(app)).toBe(false); | |
| 225 | expect(getCursor(app.db, origin.id, "next_sync_at") ?? "").not.toBe(new Date(0).toISOString()); | |
| 226 | expect(aheadBusy(app)).toBe(true); | |
| 227 | silent.stop(true); | |
| 228 | await reads; | |
| 229 | expect(aheadBusy(app)).toBe(false); | |
| 230 | }); | |
| 231 | ||
| 232 | test("a non-writer and a guest see the count but no Take them; a guest sees nothing on a private mirror", async () => { | |
| 233 | const repo = getRepo(app.db, "alice", "lib")!; | |
| 234 | const made = await createUser(app.db, { | |
| 235 | origin: "seed", trusted: true, username: "bob", email: "[email protected]", password: "bob-secret-5521", | |
| 236 | }); | |
| 237 | expect(made.ok).toBe(true); | |
| 238 | proveAll(app); | |
| 239 | const bob = { authorization: basic("bob", "bob-secret-5521") }; | |
| 240 | expect(await head()).toContain("Take them"); | |
| 241 | for (const as of [bob, {}]) { | |
| 242 | const page = await (await fetch(`${servers.url}/alice/lib`, { headers: as })).text(); | |
| 243 | expect(page).toContain("new commit"); | |
| 244 | expect(page).toContain("conflicts with commits here"); | |
| 245 | expect(page).not.toContain("Take them"); | |
| 246 | } | |
| 247 | expect((await post(bob)).status).not.toBe(200); | |
| 248 | expect((await post(bob, "sync")).status).not.toBe(200); | |
| 249 | app.db.query("UPDATE repo SET visibility = 'private' WHERE id = ?1").run(repo.id); | |
| 250 | const guest = await fetch(`${servers.url}/alice/lib`); | |
| 251 | expect(await guest.text()).not.toContain("data-origin-ahead"); | |
| 252 | app.db.query("UPDATE repo SET visibility = 'public' WHERE id = ?1").run(repo.id); | |
| 253 | }); | |
| 254 | ||
| 255 | test("the terminal shows the line and takes them under the page's rules", async () => { | |
| 256 | const repo = getRepo(app.db, "alice", "lib")!; | |
| 257 | const alice = getUserByName(app.db, "alice")!; | |
| 258 | const bob = getUserByName(app.db, "bob")!; | |
| 259 | const screenFor = (user: typeof alice | undefined) => { | |
| 260 | const toasts: string[] = []; | |
| 261 | const ctx = { | |
| 262 | theme: theme("dark"), invalidate() {}, push() {}, pop() {}, copy() {}, | |
| 263 | toast: (m: string) => toasts.push(m), | |
| 264 | } as unknown as Ctx; | |
| 265 | const screen = originAheadScreen({ app, db: app.db, user } as unknown as Session, ctx); | |
| 266 | const draw = (): string => { | |
| 267 | const s = new Surface(110, 20); | |
| 268 | screen.render({ x: 0, y: 0, w: 110, h: 20 }, s); | |
| 269 | return s.toText(); | |
| 270 | }; | |
| 271 | return { screen, toasts, draw }; | |
| 272 | }; | |
| 273 | expect(screenFor(alice).draw()).toContain("conflicts with commits here"); | |
| 274 | expect(screenFor(bob).draw()).toContain("not take it"); | |
| 275 | expect(screenFor(undefined).draw()).toContain("alice/lib"); | |
| 276 | const before = mainOid(); | |
| 277 | const b = screenFor(bob); | |
| 278 | expect(b.screen.key!({ key: "t" } as never)).toBe(false); | |
| 279 | expect(b.screen.taking()).toBeUndefined(); | |
| 280 | const a = screenFor(alice); | |
| 281 | expect(a.screen.key!({ key: "t" } as never)).toBe(true); | |
| 282 | await a.screen.taking(); | |
| 283 | expect(a.toasts[0]).toBe(CONFLICT_WORDS(repo.defaultBranch, 1)); | |
| 284 | expect(mainOid()).toBe(before); | |
| 285 | app.db.query("UPDATE repo SET visibility = 'private' WHERE id = ?1").run(repo.id); | |
| 286 | expect(screenFor(undefined).draw()).not.toContain("alice/lib"); | |
| 287 | app.db.query("UPDATE repo SET visibility = 'public' WHERE id = ?1").run(repo.id); | |
| 288 | }); | |
| 289 | ||
| 290 | test("Sync now is the way out: it replaces a diverged main with the origin's", async () => { | |
| 291 | const repo = getRepo(app.db, "alice", "lib")!; | |
| 292 | const tip = aheadOf(app, repo)!.tip; | |
| 293 | const res = await post(mine, "sync"); | |
| 294 | expect(await flashed(res)).toContain("Synced from the origin."); | |
| 295 | expect(mainOid()).toBe(tip); | |
| 296 | expect(aheadOf(app, repo)).toBeUndefined(); | |
| 297 | expect(getRepo(app.db, "alice", "lib")!.origin!.fetchError).toBe(""); | |
| 298 | }); | |
| 299 | ||
| 300 | test("an origin that cannot be read is refused, not synced over", async () => { | |
| 301 | const repo = getRepo(app.db, "alice", "lib")!; | |
| 302 | const alice = getUserByName(app.db, "alice")!; | |
| 303 | const origin = originForRepo(app.db, repo.id)!.origin; | |
| 304 | markNeedsReauth(app.db, origin.id, "test"); | |
| 305 | const before = mainOid(); | |
| 306 | const taken = await takeFromOrigin(app, alice, repo); | |
| 307 | expect(taken.ok && taken.value.kind).toBe("unread"); | |
| 308 | expect(mainOid()).toBe(before); | |
| 309 | const res = await post(mine); | |
| 310 | expect(await flashed(res)).toContain(UNREAD_WORDS); | |
| 311 | }); | |
| 312 | ||
| 313 | test("a repository's readings go with it, and only its own", () => { | |
| 314 | const origin = originForRepo(app.db, getRepo(app.db, "alice", "lib")!.id)!.origin; | |
| 315 | setCursor(app.db, origin.id, "repo:gone:ahead", "1 x 0 now"); | |
| 316 | setCursor(app.db, origin.id, "repo:gone:ahead-read", "now"); | |
| 317 | setCursor(app.db, origin.id, "repo:gonealso:ahead", "1 x 0 now"); | |
| 318 | forgetRepoCursors(app.db, "gone", origin.id); | |
| 319 | expect(getCursor(app.db, origin.id, "repo:gone:ahead")).toBe("1 x 0 now"); | |
| 320 | forgetRepoCursors(app.db, "gone"); | |
| 321 | expect(getCursor(app.db, origin.id, "repo:gone:ahead")).toBeUndefined(); | |
| 322 | expect(getCursor(app.db, origin.id, "repo:gone:ahead-read")).toBeUndefined(); | |
| 323 | expect(getCursor(app.db, origin.id, "repo:gonealso:ahead")).toBe("1 x 0 now"); | |
| 324 | }); | |
| 325 | }); | |
| 326 | ||
| 327 | describe("which mirrors a tick reads", () => { | |
| 328 | const MIN = 60_000; | |
| 329 | const now = 100 * MIN; | |
| 330 | test("at most four, longest-ago first, none read within two minutes", () => { | |
| 331 | const seen = [ | |
| 332 | { repoId: "a", at: now - 10 * MIN }, | |
| 333 | { repoId: "b", at: now - 9 * MIN }, | |
| 334 | { repoId: "c", at: now - 8 * MIN }, | |
| 335 | { repoId: "d", at: now - 7 * MIN }, | |
| 336 | { repoId: "e", at: now - 6 * MIN }, | |
| 337 | { repoId: "never", at: 0 }, | |
| 338 | { repoId: "fresh", at: now - 119_000 }, | |
| 339 | ]; | |
| 340 | expect(pickDue(seen, now)).toEqual(["never", "a", "b", "c"]); | |
| 341 | expect(pickDue([{ repoId: "x", at: now - 2 * MIN }], now)).toEqual(["x"]); | |
| 342 | expect(pickDue([{ repoId: "x", at: now - 2 * MIN + 1 }], now)).toEqual([]); | |
| 343 | }); | |
| 344 | }); |
test/palette-roster.test.ts +4
| @@ -131,6 +131,7 @@ test("a site-admin person is offered the whole roster", () => { | ||
| 131 | 131 | ["front", "Front page", "the apps a first visitor sees, ranked; an admin pins or hides"], |
| 132 | 132 | ["owner", "Owner's needs", "what needs the owner, snapshots, failure domains"], |
| 133 | 133 | ["forges", "Forges you sent", "each forge sent, claimed or not, and its apps"], |
| 134 | ["origin-ahead", "New commits upstream", "mirrors whose origin has moved on: take them"], | |
| 134 | 135 | ["profile", "Your profile", "display name and email"], |
| 135 | 136 | ["family", "Family", "the forge that sent this one, and the forges it sent"], |
| 136 | 137 | ["chain", "Chain", "the forges this forge's code descends from, root first"], |
| @@ -190,6 +191,7 @@ test("a site-admin agent is offered the roster without the person-only entries", | ||
| 190 | 191 | ["feed", "Feed", "what happened, ranked; r replies on a post about an issue or pull, l likes"], |
| 191 | 192 | ["front", "Front page", "the apps a first visitor sees, ranked; an admin pins or hides"], |
| 192 | 193 | ["forges", "Forges you sent", "each forge sent, claimed or not, and its apps"], |
| 194 | ["origin-ahead", "New commits upstream", "mirrors whose origin has moved on: take them"], | |
| 193 | 195 | ["family", "Family", "the forge that sent this one, and the forges it sent"], |
| 194 | 196 | ["chain", "Chain", "the forges this forge's code descends from, root first"], |
| 195 | 197 | ["quit", "Quit", "disconnect"], |
| @@ -252,6 +254,7 @@ test("a member person is offered the signed-in roster without the admin entries" | ||
| 252 | 254 | ["origins", "Connected origins", "the hosts your mirrors come from"], |
| 253 | 255 | ["feed", "Feed", "what happened, ranked; r replies on a post about an issue or pull, l likes"], |
| 254 | 256 | ["front", "Front page", "the apps a first visitor sees, ranked; an admin pins or hides"], |
| 257 | ["origin-ahead", "New commits upstream", "mirrors whose origin has moved on: take them"], | |
| 255 | 258 | ["profile", "Your profile", "display name and email"], |
| 256 | 259 | ["family", "Family", "the forge that sent this one, and the forges it sent"], |
| 257 | 260 | ["chain", "Chain", "the forges this forge's code descends from, root first"], |
| @@ -281,6 +284,7 @@ test("signed out, the palette offers sign-in and the public entries", () => { | ||
| 281 | 284 | ["bugs", "Bugs", "open bugs and the bug clock"], |
| 282 | 285 | ["audit", "Who can do what", "each app's doors from its contract.json: yes, own or no"], |
| 283 | 286 | ["front", "Front page", "the apps a first visitor sees, ranked; an admin pins or hides"], |
| 287 | ["origin-ahead", "New commits upstream", "mirrors whose origin has moved on: take them"], | |
| 284 | 288 | ["family", "Family", "the forge that sent this one, and the forges it sent"], |
| 285 | 289 | ["chain", "Chain", "the forges this forge's code descends from, root first"], |
| 286 | 290 | ["quit", "Quit", "disconnect"], |
new above
offline