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

Merged opened this

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

Merged

mason/forge:mason/11297-a-fork-s-page-says-in-one-plain-line-how → main 4 commits fork 3 threads · 3 unresolved

  • Merged. mason/11297-a-fork-s-page-says-in-one-plain-line-how was merged into main as 6e97bb1.

Review

Sign in to review this change.
9 files changed +278 −10
docs/features/follow-mode.md +10
@@ -25,3 +25,13 @@ A spawned forge's chain (its code repository following its parent forge) takes o
2525 `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.
2626
2727 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";
1414 import { err, ok, type Result } from "../kernel/result.ts";
1515 import { chainOfRepo, forgeLinkOf, FOLLOW_MODES, isFollowMode, setChainMode, severChain } from "../spawn/chain.ts";
1616 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";
1919 import { requireUser } from "./access.ts";
2020 import { takeFromParent, type Taken } from "../spawn/take.ts";
2121 import { takeUpdates, type ForkSync, type TakeUpdatesError } from "./forks.ts";
@@ -107,6 +107,21 @@ export async function followModeOf(app: App, repo: Repo): Promise<ForkFollowMode
107107 : forkFollowMode(app.db, repo.id);
108108 }
109109
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
110125 export type FollowModeError = SeverError | "invalid_mode" | "severed";
111126
112127 /** 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
6666 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.`;
6767 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.`;
6868 }
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
3737 .all(...repoIds);
3838 return new Map(rows.map((r) => [r.repo_id, r.mode]));
3939 }
40
41 /**
42 * When this repository last took code from what it follows (ISO), or null: the newest push recorded as
outdated

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.

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";
22 import { forkParentFor } from "../../../core/repos.ts";
33 import { FORK_FOLLOW_MODES } from "../../../core/fork_follow.ts";
44 import { good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts";
55
66 export const tool: Tool = {
77 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`",
99 description:
1010 "How a fork follows its parent: take updates, tell me, or don't follow.",
1111 inputSchema: {
@@ -21,7 +21,7 @@ export const tool: Tool = {
2121 const repo = repoArg(ctx, user, args);
2222 if (isAnswer(repo)) return repo;
2323 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) });
2525 return bad(set.error, FOLLOW_MODE_REFUSALS[set.error].message);
2626 },
2727 };
src/http/mcp/tools/following.ts +3 −2
@@ -1,6 +1,6 @@
11 import { upstreamPayload } from "../../../spawn/upstream.ts";
22 import { relationOf } from "../../../actions/forks.ts";
3 import { followModeOf } from "../../../actions/follow.ts";
3 import { followModeOf, followStanding } from "../../../actions/follow.ts";
44 import { chainOfRepo } from "../../../spawn/chain.ts";
55 import { convergeState } from "../../../crew/converge.ts";
66 import { parentForgeFor } from "../../../core/parent_releases.ts";
@@ -9,7 +9,7 @@ import { good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts"
99 export const tool: Tool = {
1010 name: "following",
1111 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",
1313 description:
1414 "Where a fork stands against what it follows: in step, behind, ahead, diverged.",
1515 inputSchema: {
@@ -37,6 +37,7 @@ export const tool: Tool = {
3737 upstream: chain === undefined ? null : await upstreamPayload(ctx.app),
3838 parent: `${relation.parent.ownerName}/${relation.parent.name}`,
3939 mode: await followModeOf(ctx.app, repo),
40 line: await followStanding(ctx.app, repo, user),
4041 state: relation.state,
4142 behind: relation.behind,
4243 ahead: relation.ahead,
src/tui/screens/forks.ts +6 −2
@@ -19,7 +19,7 @@ import {
1919 type ForkRelation,
2020 } from "../../actions/forks.ts";
2121 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";
2323 import { FOLLOWS_NOTHING, proposeRefusalWords } from "../../actions/follow_words.ts";
2424 import { openProposal, proposeUpstream } from "../../actions/upstream.ts";
2525 import type { Pull } from "../../core/pulls.ts";
@@ -146,6 +146,8 @@ export function forksScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
146146 let offer: string | undefined;
147147 let open: Pull | undefined;
148148 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;
149151 const state = new ListState();
150152 /** Only a person who writes to a fork may propose its work upstream. */
151153 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 {
199201 relation !== undefined && relation.state === "diverged" && !relation.clean
200202 ? convergeState(f.app, repo, relation.parent)
201203 : undefined;
204 standing = await followStanding(f.app, repo, f.user);
202205 mode = relation === undefined ? undefined : await followModeOf(f.app, repo);
203206 following = followingRows(relation, converge, mode);
204207 ctx.invalidate();
@@ -250,7 +253,7 @@ export function forksScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
250253 load: () => read(),
251254 render(area, s) {
252255 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)]);
254257 const list = rows();
255258 s.text(
256259 head!.x,
@@ -262,6 +265,7 @@ export function forksScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
262265 style(c.mutedFg, c.bg),
263266 head!.x + head!.w,
264267 );
268 if (standing !== undefined) s.text(line!.x, line!.y, truncate(standing, line!.w), style(c.mutedFg, c.bg), line!.x + line!.w);
265269 if (list.length === 0) {
266270 emptyState(s, body!, c, {
267271 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
1414 import { EDGE_VARY } from "../../http/edge_cache.ts";
1515 import { keepPage, keptPage, keptPageKey } from "./kept_page.ts";
1616 import { cachedBlobPage, cachedRaw, cachedTree, keepBlobPage, keepRaw, keepTree, keptRefs, resolveCommit, withKeptFolders } from "./tree_cache.ts";
17 import { followStanding } from "../../actions/follow.ts";
1718 import { behindLine, followingLine, originAheadLine } from "./upstream-follow.ts";
1819 import { CONFLICT_WORDS, UNREAD_WORDS } from "../../origins/ahead.ts";
1920 import { upstreamOffer } from "../../actions/offer_upstream.ts";
@@ -1471,10 +1472,19 @@ async function codeHome(ctx: Ctx, repo: Repo): Promise<Response> {
14711472 const credit = await timePhaseAsync(ctx.request, "credit", () =>
14721473 creditLine(ctx, repo, scope.source, ref),
14731474 );
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>`;
14741484 const rendering = performance.now();
14751485 const body = html`${scopeHeader(ctx, scope, "code", {
14761486 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}
14781488 ${offerUpstream(repo, parent, offer)}
14791489 ${latest === null
14801490 ? ""
test/fork-standing-line.test.ts added +178
@@ -0,0 +1,178 @@
1 /**

Tests miss: the follow_mode MCP answer's line, the following MCP tool's line over the real tool (only followStanding is called, not the tool), a stranger vs the owner on a private upstream through the page with a logged-in reader who can read it, and the TUI line once added. Add them.

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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs