Public: anyone can read this; the crew can change it.
A fork's page says in one plain line how it follows its upstream and when it last took from it #11387
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/11297-a-fork-s-page-says-in-one-plain-line-how → main 4 commits fork 3 threads · 3 unresolved
docs/features/follow-mode.md +10
| @@ -25,3 +25,13 @@ A spawned forge's chain (its code repository following its parent forge) takes o | ||
| 25 | 25 | `follow` in `src/core/following.ts` skips a `dont-follow` fork when its parent's default branch moves, and `followDivergence` in `src/crew/converge.ts` never converges one. |
| 26 | 26 | |
| 27 | 27 | The words live in `src/actions/follow_words.ts` and `followModeSaid` in `src/actions/follow.ts`; every door reads them from there. |
| 28 | ||
| 29 | ## The standing line and the last take | |
| 30 | ||
| 31 | Every fork says in one plain line how it follows and when it last took: `Takes updates from alice/lib · last took 2 days ago`, `Sends offers to alice/lib`, `Not following alice/lib`. A repository that follows nothing says nothing, and an upstream the reader may not read is not named (`its upstream`). | |
| 32 | ||
| 33 | - **Web:** under the head of the fork's page, linking to `/:owner/:name/forks#following`. It shows only where the stored-check lines (behind a followed address, a mirror's origin ahead) are silent, so a page carries one line about how it stands. | |
| 34 | - **Terminal:** the line under the head of the fork's forks screen. | |
| 35 | - **MCP:** `line` in the answers of `following` and `follow_mode`. | |
| 36 | ||
| 37 | *Last took* is the newest `push` event of the fork carrying `followed`: a fast-forward, a clean merge (`takeOrTell`, `take_updates`, `t`), or a spawned forge's take of its parent forge. `lastTookAt` in `src/core/fork_follow.ts` reads it through `event_repo_kind_seq`, newest first, and stops at the first hit. The words are `followStandingLine` in `src/actions/follow_words.ts`; `followStanding` in `src/actions/follow.ts` assembles them for every door. |
src/actions/follow.ts +17 −2
| @@ -14,8 +14,8 @@ import type { User } from "../core/users.ts"; | ||
| 14 | 14 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 15 | 15 | import { chainOfRepo, forgeLinkOf, FOLLOW_MODES, isFollowMode, setChainMode, severChain } from "../spawn/chain.ts"; |
| 16 | 16 | import { FAMILY_WORDS } from "./family_words.ts"; |
| 17 | import { FOLLOWS_NOTHING, followModeWords } from "./follow_words.ts"; | |
| 18 | import { FORK_FOLLOW_MODES, forkFollowMode, isForkFollowMode, setForkFollowMode, type ForkFollowMode } from "../core/fork_follow.ts"; | |
| 17 | import { FOLLOWS_NOTHING, followModeWords, followStandingLine } from "./follow_words.ts"; | |
| 18 | import { FORK_FOLLOW_MODES, forkFollowMode, lastTookAt, isForkFollowMode, setForkFollowMode, type ForkFollowMode } from "../core/fork_follow.ts"; | |
| 19 | 19 | import { requireUser } from "./access.ts"; |
| 20 | 20 | import { takeFromParent, type Taken } from "../spawn/take.ts"; |
| 21 | 21 | import { takeUpdates, type ForkSync, type TakeUpdatesError } from "./forks.ts"; |
| @@ -107,6 +107,21 @@ export async function followModeOf(app: App, repo: Repo): Promise<ForkFollowMode | ||
| 107 | 107 | : forkFollowMode(app.db, repo.id); |
| 108 | 108 | } |
| 109 | 109 | |
| 110 | /** | |
| 111 | * The line a fork's page and the MCP say: its choice and its last movement; | |
| 112 | * undefined for a repository that is no fork. A parent the viewer cannot | |
| 113 | * read is not named. | |
| 114 | */ | |
| 115 | export async function followStanding(app: App, repo: Repo, viewer: User | undefined): Promise<string | undefined> { | |
| 116 | if (repo.forkParentId === null) return undefined; | |
| 117 | const parent = forkParentFor(app.db, repo, viewer); | |
| 118 | return followStandingLine( | |
| 119 | await followModeOf(app, repo), | |
| 120 | parent === undefined ? undefined : `${parent.ownerName}/${parent.name}`, | |
| 121 | lastTookAt(app.db, repo.id), | |
| 122 | ); | |
| 123 | } | |
| 124 | ||
| 110 | 125 | export type FollowModeError = SeverError | "invalid_mode" | "severed"; |
| 111 | 126 | |
| 112 | 127 | /** The one answer every door gives a refused mode change, with the status an HTTP door sends. */ |
src/actions/follow_words.ts +35
| @@ -66,3 +66,38 @@ export function followSwitchPreview(mode: ForkFollowMode, upstream: string): str | ||
| 66 | 66 | return `From now on this fork goes its own way: nothing from ${upstream} is merged or announced here, and the crew is never asked to converge. You can still offer your changes up, and follow again at any time.`; |
| 67 | 67 | return `From now on ${upstream} only proposes: you are told when it has something new and nothing is merged until you take it. You can switch back at any time.`; |
| 68 | 68 | } |
| 69 | ||
| 70 | /** An instant as people say it: "just now", "2 days ago". Said by the page and the MCP alike. */ | |
| 71 | export function agoWords(iso: string, now: number = Date.now()): string { | |
| 72 | const seconds = Math.max(0, (now - Date.parse(iso)) / 1000); | |
| 73 | if (!Number.isFinite(seconds)) return ""; | |
| 74 | const unit = (n: number, one: string) => `${n} ${one}${n === 1 ? "" : "s"} ago`; | |
| 75 | if (seconds < 60) return "just now"; | |
| 76 | if (seconds < 3600) return unit(Math.floor(seconds / 60), "minute"); | |
| 77 | if (seconds < 86_400) return unit(Math.floor(seconds / 3600), "hour"); | |
| 78 | if (seconds < 86_400 * 30) return unit(Math.floor(seconds / 86_400), "day"); | |
| 79 | if (seconds < 86_400 * 365) return unit(Math.floor(seconds / (86_400 * 30)), "month"); | |
| 80 | return unit(Math.floor(seconds / (86_400 * 365)), "year"); | |
| 81 | } | |
| 82 | ||
| 83 | /** The words that name a fork's choice, by mode, before the upstream. */ | |
| 84 | const STANDING_LEADS: Record<ForkFollowMode, string> = { | |
| 85 | "take-updates": "Takes updates from", | |
| 86 | "tell-me": "Sends offers to", | |
| 87 | "dont-follow": "Not following", | |
| 88 | }; | |
| 89 | ||
| 90 | /** | |
| 91 | * The one plain line a fork's page and the MCP say: its choice, the upstream | |
| 92 | * (`upstream` is undefined when the reader may not read it, so it is not named) | |
| 93 | * and, when it ever took from it, when it last did. | |
| 94 | */ | |
| 95 | export function followStandingLine( | |
| 96 | mode: ForkFollowMode, | |
| 97 | upstream: string | undefined, | |
| 98 | tookAt: string | null, | |
| 99 | now: number = Date.now(), | |
| 100 | ): string { | |
| 101 | const line = `${STANDING_LEADS[mode]} ${upstream ?? "its upstream"}`; | |
| 102 | return tookAt === null ? line : `${line} · last took ${agoWords(tookAt, now)}`; | |
| 103 | } |
src/core/fork_follow.ts +15
| @@ -37,3 +37,18 @@ export function forkFollowModes(db: Database, repoIds: readonly string[]): Map<s | ||
| 37 | 37 | .all(...repoIds); |
| 38 | 38 | return new Map(rows.map((r) => [r.repo_id, r.mode])); |
| 39 | 39 | } |
| 40 | ||
| 41 | /** | |
| 42 | * When this repository last took code from what it follows (ISO), or null: the newest push recorded as | |
| 43 | * followed (a fast-forward, a merge, or a forge's take of its upstream). Walks `event_repo_kind_seq` | |
| 44 | * newest first and stops at the first hit. | |
| 45 | */ | |
| 46 | export function lastTookAt(db: Database, repoId: string): string | null { | |
| 47 | return ( | |
| 48 | db | |
| 49 | .query<{ at: string | null }, [string]>( | |
| 50 | "SELECT created_at AS at FROM event WHERE repo_id = ?1 AND kind = 'push' AND json_extract(payload, '$.followed') IS NOT NULL ORDER BY seq DESC LIMIT 1", | |
| 51 | ) | |
| 52 | .get(repoId)?.at ?? null | |
| 53 | ); | |
| 54 | } |
src/http/mcp/tools/follow_mode.ts +3 −3
| @@ -1,11 +1,11 @@ | ||
| 1 | import { FOLLOW_MODE_REFUSALS, followModeSaid, setFollowMode } from "../../../actions/follow.ts"; | |
| 1 | import { FOLLOW_MODE_REFUSALS, followModeSaid, followStanding, setFollowMode } from "../../../actions/follow.ts"; | |
| 2 | 2 | import { forkParentFor } from "../../../core/repos.ts"; |
| 3 | 3 | import { FORK_FOLLOW_MODES } from "../../../core/fork_follow.ts"; |
| 4 | 4 | import { good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts"; |
| 5 | 5 | |
| 6 | 6 | export const tool: Tool = { |
| 7 | 7 | name: "follow_mode", |
| 8 | doc: "`{ repo, mode }`: choose how a fork follows its parent: `take-updates`, `tell-me` or `dont-follow` (a spawned forge: the first two; it severs instead); the admin only; answers `{ says, mode }`; refused `unauthorized`, `forbidden`, `not_following`, `invalid_mode` or `severed`", | |
| 8 | doc: "`{ repo, mode }`: choose how a fork follows its parent: `take-updates`, `tell-me` or `dont-follow` (a spawned forge: the first two; it severs instead); the admin only; answers `{ says, mode, line }`; refused `unauthorized`, `forbidden`, `not_following`, `invalid_mode` or `severed`", | |
| 9 | 9 | description: |
| 10 | 10 | "How a fork follows its parent: take updates, tell me, or don't follow.", |
| 11 | 11 | inputSchema: { |
| @@ -21,7 +21,7 @@ export const tool: Tool = { | ||
| 21 | 21 | const repo = repoArg(ctx, user, args); |
| 22 | 22 | if (isAnswer(repo)) return repo; |
| 23 | 23 | const set = await setFollowMode(ctx.app, user, repo, args["mode"]); |
| 24 | if (set.ok) return good({ says: followModeSaid(forkParentFor(ctx.app.db, repo, user), set.value), mode: set.value }); | |
| 24 | if (set.ok) return good({ says: followModeSaid(forkParentFor(ctx.app.db, repo, user), set.value), mode: set.value, line: await followStanding(ctx.app, repo, user) }); | |
| 25 | 25 | return bad(set.error, FOLLOW_MODE_REFUSALS[set.error].message); |
| 26 | 26 | }, |
| 27 | 27 | }; |
src/http/mcp/tools/following.ts +3 −2
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | import { upstreamPayload } from "../../../spawn/upstream.ts"; |
| 2 | 2 | import { relationOf } from "../../../actions/forks.ts"; |
| 3 | import { followModeOf } from "../../../actions/follow.ts"; | |
| 3 | import { followModeOf, followStanding } from "../../../actions/follow.ts"; | |
| 4 | 4 | import { chainOfRepo } from "../../../spawn/chain.ts"; |
| 5 | 5 | import { convergeState } from "../../../crew/converge.ts"; |
| 6 | 6 | import { parentForgeFor } from "../../../core/parent_releases.ts"; |
| @@ -9,7 +9,7 @@ import { good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts" | ||
| 9 | 9 | export const tool: Tool = { |
| 10 | 10 | name: "following", |
| 11 | 11 | doc: |
| 12 | "`{ repo }`: for a fork `{ parent, mode, state, behind, ahead, clean, conflicts, action, words, parentForge, converge, chain, severedAt, upstream }`, where it stands against what it follows (`mode` take-updates, tell-me or dont-follow, set by `follow_mode`; `state` in step, behind, ahead or diverged; `behind`/`ahead` commits; `clean` whether a merge would apply; `conflicts` the files that clash; `action` the one thing that reconciles it (none, fast-forward, offer, merge, keep-both) and `words` it said plainly; `converge` what the crew is doing about a conflict, else `null`; `parentForge`, `chain`, `severedAt` and `upstream` the forge-to-forge link, `null` when there is none); for a repository that is no fork but a spawned forge, only `{ chain, severedAt, upstream }`; refused `not_a_fork` when it follows nothing, `not_found` for a repository the caller may not see", | |
| 12 | "`{ repo }`: for a fork `{ parent, mode, line, state, behind, ahead, clean, conflicts, action, words, parentForge, converge, chain, severedAt, upstream }`, where it stands against what it follows (`line` the one plain line the page says, choice and last took; `mode` take-updates, tell-me or dont-follow, set by `follow_mode`; `state` in step, behind, ahead or diverged; `behind`/`ahead` commits; `clean` whether a merge would apply; `conflicts` the files that clash; `action` the one thing that reconciles it (none, fast-forward, offer, merge, keep-both) and `words` it said plainly; `converge` what the crew is doing about a conflict, else `null`; `parentForge`, `chain`, `severedAt` and `upstream` the forge-to-forge link, `null` when there is none); for a repository that is no fork but a spawned forge, only `{ chain, severedAt, upstream }`; refused `not_a_fork` when it follows nothing, `not_found` for a repository the caller may not see", | |
| 13 | 13 | description: |
| 14 | 14 | "Where a fork stands against what it follows: in step, behind, ahead, diverged.", |
| 15 | 15 | inputSchema: { |
| @@ -37,6 +37,7 @@ export const tool: Tool = { | ||
| 37 | 37 | upstream: chain === undefined ? null : await upstreamPayload(ctx.app), |
| 38 | 38 | parent: `${relation.parent.ownerName}/${relation.parent.name}`, |
| 39 | 39 | mode: await followModeOf(ctx.app, repo), |
| 40 | line: await followStanding(ctx.app, repo, user), | |
| 40 | 41 | state: relation.state, |
| 41 | 42 | behind: relation.behind, |
| 42 | 43 | ahead: relation.ahead, |
src/tui/screens/forks.ts +6 −2
| @@ -19,7 +19,7 @@ import { | ||
| 19 | 19 | type ForkRelation, |
| 20 | 20 | } from "../../actions/forks.ts"; |
| 21 | 21 | import { followModeWords } from "../../actions/follow_words.ts"; |
| 22 | import { FOLLOW_MODE_REFUSALS, convergeWords, followModeOf, followModeSaid, setFollowMode, takeOrTell, takeWords } from "../../actions/follow.ts"; | |
| 22 | import { FOLLOW_MODE_REFUSALS, convergeWords, followModeOf, followStanding, followModeSaid, setFollowMode, takeOrTell, takeWords } from "../../actions/follow.ts"; | |
| 23 | 23 | import { FOLLOWS_NOTHING, proposeRefusalWords } from "../../actions/follow_words.ts"; |
| 24 | 24 | import { openProposal, proposeUpstream } from "../../actions/upstream.ts"; |
| 25 | 25 | import type { Pull } from "../../core/pulls.ts"; |
| @@ -146,6 +146,8 @@ export function forksScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 146 | 146 | let offer: string | undefined; |
| 147 | 147 | let open: Pull | undefined; |
| 148 | 148 | let mode: ForkFollowMode | undefined; |
| 149 | /** The one plain line the page and MCP say: how this fork follows, and when it last took. */ | |
| 150 | let standing: string | undefined; | |
| 149 | 151 | const state = new ListState(); |
| 150 | 152 | /** Only a person who writes to a fork may propose its work upstream. */ |
| 151 | 153 | const mayPropose = () => f.user !== undefined && f.user.kind !== "agent" && canWrite(f.db, repo, f.user); |
| @@ -199,6 +201,7 @@ export function forksScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 199 | 201 | relation !== undefined && relation.state === "diverged" && !relation.clean |
| 200 | 202 | ? convergeState(f.app, repo, relation.parent) |
| 201 | 203 | : undefined; |
| 204 | standing = await followStanding(f.app, repo, f.user); | |
| 202 | 205 | mode = relation === undefined ? undefined : await followModeOf(f.app, repo); |
| 203 | 206 | following = followingRows(relation, converge, mode); |
| 204 | 207 | ctx.invalidate(); |
| @@ -250,7 +253,7 @@ export function forksScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 250 | 253 | load: () => read(), |
| 251 | 254 | render(area, s) { |
| 252 | 255 | const c = ctx.theme.c; |
| 253 | const [head, body] = split(area, "v", [len(1), fill(1)]); | |
| 256 | const [head, line, body] = split(area, "v", [len(1), len(standing === undefined ? 0 : 1), fill(1)]); | |
| 254 | 257 | const list = rows(); |
| 255 | 258 | s.text( |
| 256 | 259 | head!.x, |
| @@ -262,6 +265,7 @@ export function forksScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 262 | 265 | style(c.mutedFg, c.bg), |
| 263 | 266 | head!.x + head!.w, |
| 264 | 267 | ); |
| 268 | if (standing !== undefined) s.text(line!.x, line!.y, truncate(standing, line!.w), style(c.mutedFg, c.bg), line!.x + line!.w); | |
| 265 | 269 | if (list.length === 0) { |
| 266 | 270 | emptyState(s, body!, c, { |
| 267 | 271 | title: ranked === undefined ? "Reading…" : "No forks yet", |
src/web/pages/repo.ts +11 −1
| @@ -14,6 +14,7 @@ import { PINNED_PUBLIC, immutableWhenAddressed, mayShare } from "../../http/addr | ||
| 14 | 14 | import { EDGE_VARY } from "../../http/edge_cache.ts"; |
| 15 | 15 | import { keepPage, keptPage, keptPageKey } from "./kept_page.ts"; |
| 16 | 16 | import { cachedBlobPage, cachedRaw, cachedTree, keepBlobPage, keepRaw, keepTree, keptRefs, resolveCommit, withKeptFolders } from "./tree_cache.ts"; |
| 17 | import { followStanding } from "../../actions/follow.ts"; | |
| 17 | 18 | import { behindLine, followingLine, originAheadLine } from "./upstream-follow.ts"; |
| 18 | 19 | import { CONFLICT_WORDS, UNREAD_WORDS } from "../../origins/ahead.ts"; |
| 19 | 20 | import { upstreamOffer } from "../../actions/offer_upstream.ts"; |
| @@ -1471,10 +1472,19 @@ async function codeHome(ctx: Ctx, repo: Repo): Promise<Response> { | ||
| 1471 | 1472 | const credit = await timePhaseAsync(ctx.request, "credit", () => |
| 1472 | 1473 | creditLine(ctx, repo, scope.source, ref), |
| 1473 | 1474 | ); |
| 1475 | const standing = await followStanding(ctx.app, repo, ctx.auth.user); | |
Principle 1 (nothing explains itself twice): this adds a third follow line beside behindLine/followingLine/originAheadLine, which already say how the fork stands against its parent. Fold the standing words into one of them, or show it only where those are silent, so a fork's page carries one line, not two. Also the paragraph is hand-rolled markup with muted/small; use the named line component the neighbouring lines use. |
||
| 1476 | // One line says how a fork stands: the checks' own words win, and the plain | |
| 1477 | // standing speaks only where they are silent. | |
| 1478 | const behind = timePhase(ctx.request, "behind", () => behindLine(ctx, repo)); | |
| 1479 | const originAhead = originAheadLine(ctx, repo); | |
| 1480 | const standingLine = | |
| 1481 | standing === undefined | |
| 1482 | ? null | |
| 1483 | : html`<div class="small muted" data-follow-standing>${icon("sync")} <a href="${repoBase(repo)}/forks#following">${standing}</a></div>`; | |
| 1474 | 1484 | const rendering = performance.now(); |
| 1475 | 1485 | const body = html`${scopeHeader(ctx, scope, "code", { |
| 1476 | 1486 | branchCount: refs.branches.length, |
| 1477 | })}${credit}${timePhase(ctx.request, "behind", () => behindLine(ctx, repo))}${originAheadLine(ctx, repo)}${archivedLine(ctx, repo)}${movedLine(ctx, repo)}${noApp} | |
| 1487 | })}${credit}${behind}${originAhead}${behind === null && originAhead === null ? standingLine : null}${archivedLine(ctx, repo)}${movedLine(ctx, repo)}${noApp} | |
| 1478 | 1488 | ${offerUpstream(repo, parent, offer)} |
| 1479 | 1489 | ${latest === null |
| 1480 | 1490 | ? "" |
test/fork-standing-line.test.ts added +178
| @@ -0,0 +1,178 @@ | ||
| 1 | /** | |
Tests miss: the follow_mode MCP answer's |
||
| 2 | * A fork's page and the MCP say in one plain line how it follows and when it | |
| 3 | * last took (#11297): each mode's words, the last take, a root saying | |
| 4 | * nothing, and a private upstream not named to a reader who cannot read it. | |
| 5 | */ | |
| 6 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 7 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 8 | import { tmpdir } from "node:os"; | |
| 9 | import { join } from "node:path"; | |
| 10 | import { followStanding } from "../src/actions/follow.ts"; | |
| 11 | import { agoWords, followStandingLine } from "../src/actions/follow_words.ts"; | |
| 12 | import { commitToBranch, createRepo, forkRepo } from "../src/actions/repos.ts"; | |
| 13 | import { createApp, type App } from "../src/app.ts"; | |
| 14 | import { setForkFollowMode } from "../src/core/fork_follow.ts"; | |
| 15 | import { getRepo } from "../src/core/repos.ts"; | |
| 16 | import { createUser } from "../src/core/users.ts"; | |
| 17 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 18 | import { promote } from "./helpers/members.ts"; | |
| 19 | ||
| 20 | let dataDir: string; | |
| 21 | let app: App; | |
| 22 | let servers: Servers; | |
| 23 | ||
| 24 | beforeAll(async () => { | |
| 25 | dataDir = await mkdtemp(join(tmpdir(), "forge-standing-")); | |
| 26 | app = createApp({ dataDir, port: 0 }); | |
| 27 | servers = await serve(app); | |
| 28 | const mk = async (username: string, first = false) => { | |
| 29 | const u = await createUser(app.db, { | |
| 30 | origin: "seed", trusted: true, username, email: `${username}@forge.local`, | |
| 31 | password: "a-good-passphrase", adminIfFirst: first, | |
| 32 | }); | |
| 33 | if (!u.ok) throw new Error(u.error); | |
| 34 | promote(app.db, u.value); | |
| 35 | return u.value; | |
| 36 | }; | |
| 37 | const alice = await mk("alice", true); | |
| 38 | const bob = await mk("bob"); | |
| 39 | const lib = await createRepo(app, alice, { name: "lib", visibility: "public" }); | |
| 40 | if (!lib.ok) throw new Error(String(lib.error)); | |
| 41 | const seeded = await commitToBranch(app, alice, lib.value, { | |
| 42 | message: "Start", files: [{ path: "README.md", content: "# lib\n" }], | |
| 43 | }); | |
| 44 | if (!seeded.ok) throw new Error(JSON.stringify(seeded.error)); | |
| 45 | const forked = await forkRepo(app, bob, lib.value); | |
| 46 | if (!forked.ok) throw new Error(String(forked.error)); | |
| 47 | }, 60_000); | |
| 48 | ||
| 49 | afterAll(async () => { | |
| 50 | servers.stop(); | |
| 51 | app.close(); | |
| 52 | await rm(dataDir, { recursive: true, force: true }); | |
| 53 | }); | |
| 54 | ||
| 55 | const lineOf = async (path: string): Promise<string | null> => { | |
| 56 | const page = await (await fetch(`${servers.url}${path}`)).text(); | |
| 57 | return page.match(/<div class="small muted" data-follow-standing>[^<]*<svg.*?<\/svg> <a href="([^"]*)">([^<]*)<\/a><\/div>/)?.[2] ?? null; | |
| 58 | }; | |
| 59 | ||
| 60 | test("the words: each mode, with and without a last take", () => { | |
| 61 | const now = Date.parse("2026-11-11T00:00:00Z"); | |
| 62 | const two = "2026-11-09T00:00:00Z"; | |
| 63 | expect(followStandingLine("take-updates", "a/b", two, now)).toBe("Takes updates from a/b · last took 2 days ago"); | |
| 64 | expect(followStandingLine("tell-me", "a/b", null, now)).toBe("Sends offers to a/b"); | |
| 65 | expect(followStandingLine("dont-follow", "a/b", two, now)).toBe("Not following a/b · last took 2 days ago"); | |
| 66 | expect(followStandingLine("tell-me", undefined, null, now)).toBe("Sends offers to its upstream"); | |
| 67 | expect(agoWords("2026-11-10T23:59:30Z", now)).toBe("just now"); | |
| 68 | expect(agoWords("2026-11-10T23:00:00Z", now)).toBe("1 hour ago"); | |
| 69 | }); | |
| 70 | ||
| 71 | test("a fork's page says its mode, links to the following card, and a root says nothing", async () => { | |
| 72 | const bob = getRepo(app.db, "bob", "lib")!; | |
| 73 | expect(await lineOf("/bob/lib")).toBe("Sends offers to alice/lib"); | |
| 74 | const page = await (await fetch(`${servers.url}/bob/lib`)).text(); | |
| 75 | expect(page).toContain('href="/bob/lib/forks#following"'); | |
| 76 | setForkFollowMode(app.db, bob.id, "take-updates"); | |
| 77 | expect(await lineOf("/bob/lib")).toBe("Takes updates from alice/lib"); | |
| 78 | setForkFollowMode(app.db, bob.id, "dont-follow"); | |
| 79 | expect(await lineOf("/bob/lib")).toBe("Not following alice/lib"); | |
| 80 | expect(await lineOf("/alice/lib")).toBeNull(); | |
| 81 | }); | |
| 82 | ||
| 83 | test("the last take is told, and the MCP's words are the page's", async () => { | |
| 84 | const bob = getRepo(app.db, "bob", "lib")!; | |
| 85 | app.bus.append({ | |
| 86 | kind: "push", repoId: bob.id, actorId: bob.ownerId, | |
| 87 | payload: { updates: [], followed: "alice/lib" }, | |
| 88 | }); | |
| 89 | expect(await lineOf("/bob/lib")).toBe("Not following alice/lib · last took just now"); | |
| 90 | expect(await followStanding(app, bob, undefined)).toBe("Not following alice/lib · last took just now"); | |
| 91 | expect(await followStanding(app, getRepo(app.db, "alice", "lib")!, undefined)).toBeUndefined(); | |
| 92 | }); | |
| 93 | ||
| 94 | test("a private upstream is not named to a reader who cannot read it", async () => { | |
| 95 | const bob = getRepo(app.db, "bob", "lib")!; | |
| 96 | app.db.query("UPDATE repo SET visibility = 'private' WHERE owner_id = (SELECT id FROM user WHERE username = 'alice') AND name = 'lib'").run(); | |
| 97 | const seen = await lineOf("/bob/lib"); | |
| 98 | expect(seen).toContain("Not following its upstream"); | |
| 99 | expect(seen).not.toContain("alice"); | |
| 100 | expect(await followStanding(app, bob, undefined)).not.toContain("alice"); | |
| 101 | }); | |
| 102 | ||
| 103 | test("real takes set 'last took': an automatic fast-forward, a merge by take_updates, and the MCP line", async () => { | |
| 104 | const alice = (await import("../src/core/users.ts")).getUserByName(app.db, "alice")!; | |
| 105 | const bobUser = (await import("../src/core/users.ts")).getUserByName(app.db, "bob")!; | |
| 106 | app.db.query("UPDATE repo SET visibility = 'public' WHERE name = 'lib'").run(); | |
| 107 | const lib = getRepo(app.db, "alice", "lib")!; | |
| 108 | const bob = getRepo(app.db, "bob", "lib")!; | |
| 109 | app.db.query("DELETE FROM event WHERE repo_id = ?1 AND kind = 'push'").run(bob.id); | |
| 110 | const { lastTookAt } = await import("../src/core/fork_follow.ts"); | |
| 111 | expect(lastTookAt(app.db, bob.id)).toBeNull(); | |
| 112 | setForkFollowMode(app.db, bob.id, "tell-me"); | |
| 113 | ||
| 114 | // Nothing of bob's own: the parent's push fast-forwards him by itself. | |
| 115 | const one = await commitToBranch(app, alice, lib, { message: "One", files: [{ path: "one.txt", content: "1\n" }] }); | |
| 116 | expect(one.ok).toBe(true); | |
| 117 | for (let i = 0; i < 50 && lastTookAt(app.db, bob.id) === null; i++) await Bun.sleep(100); | |
| 118 | expect(lastTookAt(app.db, bob.id)).not.toBeNull(); | |
| 119 | ||
| 120 | // Work on both sides: a merge, taken through the real MCP tool. | |
| 121 | app.db.query("DELETE FROM event WHERE repo_id = ?1 AND kind = 'push'").run(bob.id); | |
| 122 | expect(lastTookAt(app.db, bob.id)).toBeNull(); | |
| 123 | const mine = await commitToBranch(app, bobUser, bob, { message: "Mine", files: [{ path: "mine.txt", content: "m\n" }] }); | |
| 124 | expect(mine.ok).toBe(true); | |
| 125 | app.db.query("DELETE FROM event WHERE repo_id = ?1 AND kind = 'push'").run(bob.id); | |
| 126 | const two = await commitToBranch(app, alice, lib, { message: "Two", files: [{ path: "two.txt", content: "2\n" }] }); | |
| 127 | expect(two.ok).toBe(true); | |
| 128 | await Bun.sleep(500); | |
| 129 | app.db.query("DELETE FROM event WHERE repo_id = ?1 AND kind = 'push'").run(bob.id); | |
| 130 | const { createToken } = await import("../src/core/tokens.ts"); | |
| 131 | const { mcpTool } = await import("./helpers/mcp.ts"); | |
| 132 | const token = createToken(app.db, bobUser.id, "bob", "admin").token; | |
| 133 | const took = await mcpTool(servers.url, token, "take_updates", { repo: "bob/lib" }); | |
| 134 | expect(took.error).toBeUndefined(); | |
| 135 | expect(lastTookAt(app.db, bob.id)).not.toBeNull(); | |
| 136 | ||
| 137 | const following = await mcpTool(servers.url, token, "following", { repo: "bob/lib" }); | |
| 138 | expect(following.value.line).toBe("Sends offers to alice/lib · last took just now"); | |
| 139 | const set = await mcpTool(servers.url, token, "follow_mode", { repo: "bob/lib", mode: "take-updates" }); | |
| 140 | expect(set.value.line).toBe("Takes updates from alice/lib · last took just now"); | |
| 141 | }); | |
| 142 | ||
| 143 | test("a logged-in reader who can read a private upstream sees it named; a stranger does not", async () => { | |
| 144 | const { createToken } = await import("../src/core/tokens.ts"); | |
| 145 | const { mcpTool } = await import("./helpers/mcp.ts"); | |
| 146 | const { getUserByName } = await import("../src/core/users.ts"); | |
| 147 | app.db.query("UPDATE repo SET visibility = 'private' WHERE owner_id = (SELECT id FROM user WHERE username = 'alice') AND name = 'lib'").run(); | |
| 148 | const bobToken = createToken(app.db, getUserByName(app.db, "bob")!.id, "bob", "admin").token; | |
| 149 | // Bob forked it and is no member of the private upstream: not named. | |
| 150 | const stranger = await mcpTool(servers.url, bobToken, "following", { repo: "bob/lib" }); | |
| 151 | const said = stranger.value?.line ?? ""; | |
| 152 | expect(said).not.toContain("alice"); | |
| 153 | // Alice, who owns it, reads the fork's line with the upstream named. | |
| 154 | const aliceToken = createToken(app.db, getUserByName(app.db, "alice")!.id, "alice", "admin").token; | |
| 155 | const owner = await mcpTool(servers.url, aliceToken, "following", { repo: "bob/lib" }); | |
| 156 | expect(owner.value?.line ?? owner.error).toContain("alice/lib"); | |
| 157 | }); | |
| 158 | ||
| 159 | test("the terminal's forks screen says the line", async () => { | |
| 160 | const { createForgeApp } = await import("../src/tui/forge.ts"); | |
| 161 | const { forksScreen } = await import("../src/tui/screens/forks.ts"); | |
| 162 | const { headlessTerminal } = await import("../src/tui/term/terminal.ts"); | |
| 163 | const { settleApp } = await import("./helpers/tui.ts"); | |
| 164 | const { getUserByName } = await import("../src/core/users.ts"); | |
| 165 | app.db.query("UPDATE repo SET visibility = 'public' WHERE name = 'lib'").run(); | |
| 166 | const terminal = headlessTerminal(120, 30); | |
| 167 | const { app: tui, session } = createForgeApp( | |
| 168 | app, | |
| 169 | { sshUsername: "bob", remoteAddress: "127.0.0.1", fingerprint: "SHA256:test", user: getUserByName(app.db, "bob")! }, | |
| 170 | { terminal, exitHooks: false }, | |
| 171 | ); | |
| 172 | tui.start(); | |
| 173 | tui.ctx.push(forksScreen(session, tui.ctx, getRepo(app.db, "bob", "lib")!)); | |
| 174 | await settleApp(tui); | |
| 175 | const text = tui.screen.toText(); | |
| 176 | tui.stop?.(); | |
| 177 | expect(text).toContain("Takes updates from alice/lib"); | |
| 178 | }); |
This reads any push event with payload.followed. src/spawn/take.ts:136 also writes followed: link.upstream (a forge-to-forge take), and the take path in core/following.ts writes followed for both fast-forward and merge. Confirm that only fork takes of this repo count, and add a test that a merge take and a take-updates take (not a hand-appended bus event) set 'last took'; the current test fakes the event, so it would pass even if real takes never wrote it. Also json_extract over the event table per page view needs an index on (repo_id, kind) or a bounded query: check it stays under the 50 ms budget on a busy repo.