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

An origin you connect is followed like a parent: take updates, converge and p… #12118

Merged opened this in answer to

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

Merged

mason/forge:mason/12108-an-origin-you-connect-is-followed-like-a → main 12 commits fork 4 threads · 4 unresolved

  • Merged. mason/12108-an-origin-you-connect-is-followed-like-a was merged into main as 96492c1.

Review

Sign in to review this change.
15 files changed +913 −50
docs/features/follow-an-origin.md added +23
@@ -0,0 +1,23 @@
1 # Taking updates from an origin you follow
2
3 A repository that follows an upstream address (a connected origin, a migration, a pasted link; `repo_upstream`) takes its updates the way a fork takes its parent's. The doors ask for a parent in this forge first and come to `src/actions/upstream_sync.ts` when there is none.
4
5 - **Take** fast-forwards when the repository has nothing the upstream lacks, returns to the upstream's commit ids when its own work already landed there under other ids, merges when git can, and otherwise refuses with the files in conflict.
6 - **Converge** hands those conflicting files to the crew on a converge issue, with the same outcomes as a fork's converge (`nothing`, `no_crew`, `working`, `queued`).
7
8 ## Where
9
10 - **Web:** Settings → Follow upstream carries **Take** (the one primary button) and **Converge** (outline) once an address is followed. Both post to `/:owner/:name/following`.
11 - **API / MCP:** `take_updates` and `converge` answer for a followed repository as they do for a fork; one that follows nothing is `not_a_fork`.
12 - **Terminal:** on the forks screen of a followed repository, a person who writes it presses `t` to take and `c` to converge, each after a confirm; the toast says the same sentence as the page.
13
14 ## A check moves `main` too
15
16 While following is on, every check of the upstream (the clock's, or **Check upstream now**) settles the default branch before it reads what is behind, with nobody pressing Take:
17
18 - work of this repository's that already landed upstream under other commit ids returns the branch to the upstream's ids, in every mode, since nothing is lost;
19 - in take-updates mode a branch with nothing of its own fast-forwards to the upstream's tip.
20
21 Each move is a `push` event carrying `followed`, so what follows a push follows it. Anything else (a branch with work the upstream lacks) is left for Take, a proposal or the crew.
22
23 Taking stages the upstream tip in the bare repository (no clone), writes a `push` event carrying `followed`, and needs write access.
src/actions/follow.ts +11 −4
@@ -21,6 +21,13 @@ import { getFollow, setFollowTaking } from "../core/upstream_follow.ts";
2121 import { severUpstream } from "./upstream_follow.ts";
2222 import { takeFromParent, type Taken } from "../spawn/take.ts";
2323 import { takeUpdates, type ForkSync, type TakeUpdatesError } from "./forks.ts";
24 import { convergeFollowed } from "./upstream_sync.ts";
25
26 /**
27 * What a take names of what it follows: a parent repository, or the address
28 * a repository follows (`followedAs`).
29 */
30 export type Followed = Pick<Repo, "ownerName" | "name" | "detached">;
2431
2532 export type TakeOrTell = Result<ForkSync & { merged: boolean }, TakeUpdatesError | Conflict>;
2633
@@ -40,8 +47,8 @@ export async function takeOrTell(
4047 const took = await takeUpdates(app, actor, fork);
4148 if (took.ok || typeof took.error === "string" || took.error.kind !== "conflict") return took;
4249 const parent = forkParentFor(app.db, fork, actor);
43 if (parent === undefined) return took;
44 const converge = await convergeFork(app, fork, parent, actor);
50 const converge = parent === undefined ? await convergeFollowed(app, fork, actor) : await convergeFork(app, fork, parent, actor);
51 if (converge === "not_a_fork") return took;
4552 return err({ kind: "conflict", files: took.error.files, converge });
4653 }
4754
@@ -253,7 +260,7 @@ export function convergeWords(
253260 * only of a parent that is itself up to date: a frozen one may be far
254261 * behind its own upstream, and the take only knows the frozen copy.
255262 */
256 export function nothingNew(parent: Repo): string {
263 export function nothingNew(parent: Followed): string {
257264 const name = `${parent.ownerName}/${parent.name}`;
258265 return parent.detached === null
259266 ? `Already up to date with ${name}.`
@@ -267,7 +274,7 @@ export function nothingNew(parent: Repo): string {
267274 export function takeWords(
268275 took: TakeOrTell,
269276 fork: Repo,
270 parent: Repo,
277 parent: Followed,
271278 ): { tone?: "danger"; message: string } {
272279 const parentName = `${parent.ownerName}/${parent.name}`;
273280 if (took.ok)
src/actions/forks.ts +4 −2
@@ -26,8 +26,9 @@ import { runGit } from "../git/env.ts";
2626 import { isSafeRef } from "../git/guard.ts";
2727 import { resolveOid } from "../git/merge.ts";
2828 import { repoDir } from "../git/repos.ts";
29 import { err, type Result } from "../kernel/result.ts";
29 import type { Result } from "../kernel/result.ts";
3030 import { requireWriter, type AccessError } from "./access.ts";
31 import { takeFromFollowed } from "./upstream_sync.ts";
3132
3233 export type { ForkRelation, ForkSync, TakeError } from "../core/following.ts";
3334
@@ -66,7 +67,8 @@ export async function takeUpdates(
6667 const writer = requireWriter(app, actor, fork);
6768 if (!writer.ok) return writer;
6869 const parent = parentOf(app, fork, actor);
69 if (parent === undefined) return err("not_a_fork");
70 // No parent in this forge: the address the repository follows, if it follows one.
71 if (parent === undefined) return takeFromFollowed(app, actor, fork);
7072 const who = writer.value;
7173 return take(app, fork, parent, {
7274 id: who.id,
src/actions/upstream_follow.ts +91 −32
@@ -20,8 +20,8 @@
2020
2121 import type { Database } from "bun:sqlite";
2222 import type { App } from "../app.ts";
23 import { getIssueById, setAssignee, updateIssueText } from "../core/issues.ts";
24 import { listCrewRoles } from "../core/agents.ts";
23 import { getIssueById, setAssignee, updateIssueText, type Issue } from "../core/issues.ts";
24 import { listCrewRoles, type Agent } from "../core/agents.ts";
2525 import { listRuns } from "../core/runs.ts";
2626 import { queueAgentRun } from "../crew/dispatch.ts";
2727 import { pickBuilder } from "../crew/route.ts";
@@ -75,6 +75,8 @@ import { err, ok, type Result } from "../kernel/result.ts";
7575 import { providerForOrigin } from "../origins/credential.ts";
7676 import { basicHeader, gitAuthEnv, redact, secretsIn } from "../origins/credentials.ts";
7777 import { vettedEnv } from "../origins/git-mirror.ts";
78 import { forkFollowMode } from "../core/fork_follow.ts";
79 import { settleOntoFollowed } from "../core/upstream_settle.ts";
7880 import { openIssue } from "./issues.ts";
7981 import { migrateUrl } from "./migrate.ts";
8082 import { openPull } from "./pulls.ts";
@@ -217,7 +219,16 @@ const conflictTitle = (tip: string): string => `${UPSTREAM_TITLE}conflict at ${t
217219 * check of the same head finds the issue and starts nothing; autonomy off
218220 * leaves the conflict noted on the row alone.
219221 */
220 function convergeConflict(
222 export interface ConflictHandled {
223 readonly issue: Issue;
224 /**
225 * The builder the run was queued for; undefined when this head was asked
226 * before or nobody could take it.
227 */
228 readonly agent: Agent | undefined;
229 }
230
231 export function convergeConflict(
221232 app: App,
222233 checked: Repo,
223234 by: User,
@@ -225,19 +236,24 @@ function convergeConflict(
225236 tip: string,
226237 files: readonly string[],
227238 source?: { readonly id: string; readonly host: string; readonly account: string; readonly via: readonly string[] },
228 ): void {
239 /** A person asked: the crew is started whatever the repository's autonomy. */
240 asked = false,
241 ): ConflictHandled | undefined {
229242 // The setting as it is now, not as the caller read it before the fetch.
230243 const repo = getRepoById(app.db, checked.id);
231 if (repo === undefined || repo.autonomy === "off") return;
244 if (repo === undefined || (repo.autonomy === "off" && !asked)) return undefined;
232245 // A source with a conflict still open waits for the crew: a moving head is not asked again.
233 if (source !== undefined && openConflictOf(app.db, source.id)) return;
246 if (source !== undefined && openConflictOf(app.db, source.id)) return undefined;
234247 const title = conflictTitle(tip);
235248 const seen = app.db
236 .query<{ n: number }, [string, string]>(
237 "SELECT COUNT(*) AS n FROM issue WHERE repo_id = ?1 AND kind = 'issue' AND title = ?2",
249 .query<{ id: string }, [string, string]>(
250 "SELECT id FROM issue WHERE repo_id = ?1 AND kind = 'issue' AND title = ?2 ORDER BY number DESC LIMIT 1",
238251 )
239 .get(repo.id, title)!.n;
240 if (seen > 0) return;
252 .get(repo.id, title);
253 if (seen !== null) {
254 const known = getIssueById(app.db, seen.id);
255 return known === undefined ? undefined : { issue: known, agent: undefined };
256 }
241257 const opened = openIssue(app, by, repo, {
242258 title,
243259 body: [
@@ -249,7 +265,7 @@ function convergeConflict(
249265 RESOLUTION_RULE,
250266 ].join("\n"),
251267 });
252 if (!opened.ok) return;
268 if (!opened.ok) return undefined;
253269 let issue = opened.value;
254270 if (source !== undefined) recordConflict(app.db, issue.id, { sourceId: source.id, ...source });
255271 const inFlight = new Map<string, number>();
@@ -260,14 +276,15 @@ function convergeConflict(
260276 .filter((c) => c.role === "builder")
261277 .map((c) => c.agent);
262278 const agent = pickBuilder(issue, builders, inFlight);
263 if (agent === undefined) return;
279 if (agent === undefined) return { issue, agent: undefined };
264280 // Assigned without the assignment event, which would start an ordinary run.
265281 issue = setAssignee(app.db, issue.id, agent.user.id) ?? issue;
266 queueAgentRun(app, repo, issue, agent, "converge", undefined, {
282 queueAgentRun(app, repo, issue, agent, "converge", asked ? by.id : undefined, {
267283 notBefore: new Date().toISOString(),
268284 attempt: 0,
269285 summary: `upstream moved: ${files.join(", ")}`,
270286 });
287 return { issue, agent };
271288 }
272289
273290 /** What the crew's brief says about the source a conflict issue was raised for (#9003). */
@@ -321,7 +338,7 @@ export async function carriesUpstream(
321338 }
322339
323340 /** What one check reads and writes of a followed address: the repository's upstream, or one of its sources. */
324 interface Target {
341 export interface Target {
325342 readonly url: string;
326343 readonly branch: string;
327344 readonly originId: string;
@@ -342,18 +359,21 @@ interface Target {
342359 readonly repoRowId?: string;
343360 }
344361
362 /** What a check of a repository's own upstream reads and writes. */
363 export const targetOf = (app: App, repo: Repo, follow: UpstreamFollow): Target => ({
364 ...follow,
365 repoRowId: follow.repoId,
366 staged: stagedRef(follow.branch),
367 proposal: proposalBranch,
368 note: (r) => noteCheck(app.db, repo.id, r),
369 stamp: sourceOfUrl(follow.url),
370 });
371
345372 async function checkOnce(app: App, repo: Repo): Promise<Result<Checked, CheckError>> {
346373 const follow = getFollow(app.db, repo.id);
347374 if (follow === undefined || !follow.enabled || repo.archivedAt !== null || repo.origin !== null)
348375 return err("off");
349 return checkTarget(app, repo, {
350 ...follow,
351 repoRowId: follow.repoId,
352 staged: stagedRef(follow.branch),
353 proposal: proposalBranch,
354 note: (r) => noteCheck(app.db, repo.id, r),
355 stamp: sourceOfUrl(follow.url),
356 });
376 return checkTarget(app, repo, targetOf(app, repo, follow));
357377 }
358378
359379 /** One check of one followed source (#9003), through the same claim, vet and proposal as the upstream's. */
@@ -378,13 +398,29 @@ export async function checkSource(app: App, repo: Repo, source: FollowSource): P
378398 }
379399 }
380400
381 async function checkTarget(app: App, repo: Repo, initial: Target): Promise<Result<Checked, CheckError>> {
382 let follow = initial;
383 const at = new Date().toISOString();
384 const failed = (error: CheckError, why = ""): Result<Checked, CheckError> => {
385 follow.note({ at, error: why || CHECK_REFUSALS[error] });
386 return err(error);
387 };
401 /**
402 * What staging a followed tip says when it could not: the refusal and, when
403 * git said why, its words.
404 */
405 export interface NotStaged {
406 readonly error: CheckError;
407 readonly why: string;
408 }
409
410 export interface Staged {
411 readonly by: User;
412 readonly dir: string;
413 /** The followed tip, fetched into `follow.staged`. */
414 readonly tip: string;
415 }
416
417 /**
418 * Read the followed branch's tip and fetch it into the repository's staging
419 * ref: one `ls-remote`, a fetch only when the tip moved. Nothing of ours
420 * moves. The one who turned following on must still be able to write here.
421 */
422 export async function stageTarget(app: App, repo: Repo, follow: Target): Promise<Result<Staged, NotStaged>> {
423 const failed = (error: CheckError, why = ""): Result<Staged, NotStaged> => err({ error, why });
388424 let by = getUserById(app.db, follow.byId);
389425 // A link the parent sent acts as whoever owns the repository now: the spawner may have handed it over.
390426 if ((by === undefined || !canWrite(app.db, repo, by)) && follow.via === "parent") by = followsAs(app, repo, follow.byId);
@@ -448,8 +484,21 @@ async function checkTarget(app: App, repo: Repo, initial: Target): Promise<Resul
448484 return failed("unreadable", `The upstream could not be fetched: ${said(fetched.stderr) || "no reason given"}`);
449485 storageStale(app, repo.ownerId);
450486 }
451 const staged = await resolveOid(dir, ref);
452 if (staged === undefined) return failed("git");
487 const fetchedTip = await resolveOid(dir, ref);
488 if (fetchedTip === undefined) return failed("git");
489 return ok({ by, dir, tip: fetchedTip });
490 }
491
492 async function checkTarget(app: App, repo: Repo, follow: Target): Promise<Result<Checked, CheckError>> {
493 const at = new Date().toISOString();
494 const failed = (error: CheckError, why = ""): Result<Checked, CheckError> => {
495 follow.note({ at, error: why || CHECK_REFUSALS[error] });
496 return err(error);
497 };
498 const read = await stageTarget(app, repo, follow);
499 if (!read.ok) return failed(read.error.error, read.error.why);
500 const { by, dir } = read.value;
501 const staged = read.value.tip;
453502 // A packs mirror is a copy, not a fork: its default branch is the upstream's, moved, never proposed.
454503 if (follow.sourceId === undefined && isPacksMirror(repo)) {
455504 if (
@@ -460,8 +509,18 @@ async function checkTarget(app: App, repo: Repo, initial: Target): Promise<Resul
460509 follow.note({ at, tip: staged, behind: 0, error: "" });
461510 return ok({ tip: staged, behind: 0, outcome: "none" });
462511 }
463 const main = await resolveOid(dir, `refs/heads/${repo.defaultBranch}`);
512 let main = await resolveOid(dir, `refs/heads/${repo.defaultBranch}`);
464513 if (main === undefined) return failed("git", "The default branch has no commits yet.");
514 // The repository's own default branch is kept level with what it follows: work that
515 // landed upstream returns to the upstream's commit ids, and a branch with nothing of
516 // its own fast-forwards when the repository takes updates (`settleOntoFollowed`).
517 if (follow.sourceId === undefined) {
518 const settled = await settleOntoFollowed(
519 app, repo, dir, main, staged, upstreamLabel(follow.url), follow.byId,
520 forkFollowMode(app.db, repo.id) === "take-updates",
521 );
522 main = settled.tip;
523 }
465524 const behind = await aheadCount(dir, main, staged);
466525 // What this repository holds that the upstream does not: told on the page, never acted on.
467526 const ahead = await aheadCount(dir, staged, main);
src/actions/upstream_sync.ts added +140
@@ -0,0 +1,140 @@
1 /**

proposeToFollowed has no test: no fork, push, duplicate-tip or refusal path. Each of those behaviours needs one that fails without it. The TUI screen and docs/features how-to are also missing (both doors).

2 * A repository that follows an upstream address (`repo_upstream`, the one
3 * machinery a connected origin, a migration and a pasted link all use) takes
4 * its updates, and converges with it, exactly as a fork does with its parent.
5 * The doors ask `forkParentFor` first and come here when there is no parent
6 * in this forge.
7 *
8 * Taking is plumbing in the bare repository, no clone: the followed tip is
9 * staged (`refs/upstream-follow/<branch>`), then the default branch
10 * fast-forwards when it holds nothing the tip lacks, returns to the tip's
11 * commit ids when its own work is in the tip already (`git/settle.ts`), or
12 * merges when git can.
13 */
14
15 import type { App } from "../app.ts";
16 import { requireAbility } from "./roles.ts";
17 import { mayDirectCrew } from "../crew/direct.ts";
18 import type { Converge } from "../crew/converge.ts";
19 import { listCrewRoles } from "../core/agents.ts";
20 import { commitIdentity } from "../core/commit_identity.ts";
21 import type { Repo } from "../core/repos.ts";
22 import { getFollow, type UpstreamFollow } from "../core/upstream_follow.ts";
23 import { settleOntoFollowed } from "../core/upstream_settle.ts";
24 import type { User } from "../core/users.ts";
25 import { merge, mergeable, resolveOid } from "../git/merge.ts";
26 import { err, ok, type Result } from "../kernel/result.ts";
27 import { requireWriter } from "./access.ts";
28 import type { ForkSync, TakeUpdatesError } from "./forks.ts";
29 import {
30 CHECK_REFUSALS,
31 convergeConflict,
32 stageTarget,
33 targetOf,
34 upstreamLabel,
35 } from "./upstream_follow.ts";
36
37 /** The upstream this repository follows, or undefined when it follows no address. */
38 export const followedOf = (app: App, repo: Repo): UpstreamFollow | undefined =>
39 repo.archivedAt === null ? getFollow(app.db, repo.id) : undefined;
40
41 /**
42 * The followed address as a take's words name a parent: its host and path,
43 * which read as `host/owner/name`.
44 */
45 export function followedAs(follow: UpstreamFollow): Pick<Repo, "ownerName" | "name" | "detached"> {
46 const label = upstreamLabel(follow.url);
47 const cut = label.indexOf("/");
48 return { ownerName: cut < 0 ? label : label.slice(0, cut), name: cut < 0 ? "" : label.slice(cut + 1), detached: null };
49 }
50
51 /**
52 * Take the followed upstream's updates: a fast-forward, a return to its ids,
53 * a clean merge, or the files in conflict.
54 */
55 export async function takeFromFollowed(
56 app: App,
57 actor: User | undefined,
58 repo: Repo,
59 ): Promise<Result<ForkSync & { merged: boolean }, TakeUpdatesError>> {
60 const writer = requireWriter(app, actor, repo);
61 if (!writer.ok) return writer;
62 const follow = followedOf(app, repo);
63 if (follow === undefined) return err("not_a_fork");
64 const target = targetOf(app, repo, follow);
65 const staged = await stageTarget(app, repo, target);
66 if (!staged.ok)
67 return err({ kind: "git", message: staged.error.why || CHECK_REFUSALS[staged.error.error] });
68 const { dir, tip } = staged.value;
69 const main = await resolveOid(dir, `refs/heads/${repo.defaultBranch}`);
70 if (main === undefined) return err({ kind: "git", message: "the default branch has no commits yet" });
71 const label = upstreamLabel(follow.url);
72 const at = new Date().toISOString();
73 const settled = await settleOntoFollowed(app, repo, dir, main, tip, label, writer.value.id, true);
74 const noted = (behind: number): void => target.note({ at, tip, behind, error: "" });
75 if (settled.moved !== undefined) {
76 noted(0);
77 return ok({ updated: true, merged: false, from: main, to: tip });
78 }
79 if (settled.standing?.kind !== "diverged") {
80 noted(0);
81 return ok({ updated: false, merged: false, from: main, to: main });
82 }
83 const landed = await merge(dir, {
84 baseBranch: repo.defaultBranch,
85 baseOid: main,
86 headOid: tip,
87 strategy: "merge",
88 message: `Take updates from ${label}`,
89 author: commitIdentity(app.config, writer.value),
90 });
91 if (!landed.ok) {
92 const e = landed.error;
93 if (e.kind === "conflict") {
94 target.note({ at, tip, behind: settled.standing.behind, error: "conflict", files: e.files.length });
95 return err({ kind: "conflict", files: e.files });
96 }
97 return err(e.kind === "base_moved" ? { kind: "moved" } : { kind: "git", message: e.kind === "git" ? e.message : "unsafe" });
98 }
99 noted(0);
100 app.bus.append({
101 kind: "push",
102 repoId: repo.id,
103 actorId: writer.value.id,
104 payload: {
105 updates: [{ ref: `refs/heads/${repo.defaultBranch}`, oldOid: main, newOid: landed.value.mergeOid }],
106 followed: label,
107 merged: true,
108 },
109 });
110 return ok({ updated: true, merged: true, from: main, to: landed.value.mergeOid });
111 }
112
113 /**
114 * Have the crew take the followed upstream's updates where both sides changed
115 * the same files; the same outcomes as a fork's converge. `not_a_fork` when
116 * the repository follows no address.
117 */
118 export async function convergeFollowed(
119 app: App,
120 repo: Repo,
121 actor: User | undefined,
122 ): Promise<Converge | "not_a_fork"> {
123 const follow = followedOf(app, repo);
124 if (follow === undefined) return "not_a_fork";
125 if (actor !== undefined && !mayDirectCrew(app.db, repo, actor)) return { kind: "forbidden" };
126 if (actor !== undefined && !requireAbility(app, actor, "run_crew").ok) return { kind: "not_member" };
127 const staged = await stageTarget(app, repo, targetOf(app, repo, follow));
128 if (!staged.ok) return { kind: "nothing", relation: undefined };
129 const { dir, tip, by } = staged.value;
130 const main = await resolveOid(dir, `refs/heads/${repo.defaultBranch}`);
131 if (main === undefined) return { kind: "nothing", relation: undefined };
132 const merged = await mergeable(dir, main, tip);
133 if (merged.ok || merged.error.kind !== "conflict") return { kind: "nothing", relation: undefined };
134 if (listCrewRoles(app.db, repo.id).every((c) => c.role !== "builder")) return { kind: "no_crew" };
135 const handled = convergeConflict(app, repo, actor ?? by, follow, tip, merged.error.files, undefined, true);
136 if (handled === undefined) return { kind: "nobody" };
137 return handled.agent === undefined
138 ? { kind: "working", issue: handled.issue, agent: undefined }
139 : { kind: "queued", issue: handled.issue, agent: handled.agent };
140 }
src/core/upstream_settle.ts added +52
@@ -0,0 +1,52 @@
1 /**
2 * Keeping a following repository's default branch level with the branch it
3 * follows: the move `git/settle.ts` judges safe, made once and announced as
4 * a push that followed, so what follows a push follows this.
5 */
6
7 import type { EventBus } from "./events.ts";
8 import { moveBranch, standingAgainst, type Standing } from "../git/settle.ts";
9 import type { Repo } from "./repos.ts";
10
11 export interface Settled {
12 /** What the branch did: fast-forwarded, returned to the followed ids, or stayed. */
13 readonly moved: "fast-forward" | "returned" | undefined;
14 /** Where the branch stands now against the tip (as read before the move). */
15 readonly standing: Standing | undefined;
16 /** The branch's tip now. */
17 readonly tip: string;
18 }
19
20 /**
21 * Move `repo`'s default branch onto `staged` when it holds nothing the tip
22 * lacks (`fastForward` allowing that) or all its own work is in the tip
23 * already (always: it loses nothing). Anything else is left for a merge.
24 */
25 export async function settleOntoFollowed(
26 forge: { readonly bus: Pick<EventBus, "append"> },
27 repo: Repo,
28 dir: string,
29 main: string,
30 staged: string,
31 label: string,
32 actorId: string | undefined,
33 fastForward: boolean,
34 ): Promise<Settled> {
35 const standing = await standingAgainst(dir, main, staged);
36 const stay: Settled = { moved: undefined, standing, tip: main };
37 if (standing === undefined) return stay;
38 const how =
39 standing.kind === "behind" && fastForward ? "fast-forward" : standing.kind === "landed" ? "returned" : undefined;
40 if (how === undefined || !(await moveBranch(dir, repo.defaultBranch, main, staged))) return stay;
41 forge.bus.append({
42 kind: "push",
43 repoId: repo.id,
44 ...(actorId === undefined ? {} : { actorId }),
45 payload: {
46 updates: [{ ref: `refs/heads/${repo.defaultBranch}`, oldOid: main, newOid: staged }],
47 followed: label,
48 ...(how === "returned" ? { returned: true } : {}),
49 },
50 });
51 return { moved: how, standing, tip: staged };
52 }
src/git/settle.ts added +40
@@ -0,0 +1,40 @@
1 /**
2 * Where a branch stands against the tip it follows, and the one move that
3 * keeps it honest. A branch with nothing the followed tip lacks fast-forwards.
4 * A branch whose own work is all in the followed tip already — it landed
5 * there as a squash or a merge, under other commit ids — returns to the
6 * followed tip's ids, so the next take finds the two level and never a
7 * lattice of merges. Only work the tip lacks stays the branch's own.
8 */
9
10 import { runGit } from "./env.ts";
11 import { isSafeOid, isSafeRef } from "./guard.ts";
12 import { aheadCount, mergeable, treeOf } from "./merge.ts";
13
14 export type Standing =
15 /** The branch holds the tip: nothing to take. */
16 | { readonly kind: "current"; readonly own: number }
17 /** The branch is behind the tip and has nothing of its own. */
18 | { readonly kind: "behind"; readonly behind: number }
19 /** The branch's own commits are all in the tip's tree already. */
20 | { readonly kind: "landed"; readonly behind: number; readonly own: number }
21 /** Both have work the other lacks. */
22 | { readonly kind: "diverged"; readonly behind: number; readonly own: number };
23
24 /** Where `main` stands against `tip`, read from git; undefined when git could not say. */
25 export async function standingAgainst(dir: string, main: string, tip: string): Promise<Standing | undefined> {
26 if (!isSafeOid(main) || !isSafeOid(tip)) return undefined;
27 const [behind, own] = await Promise.all([aheadCount(dir, main, tip), aheadCount(dir, tip, main)]);
28 if (behind === 0) return { kind: "current", own };
29 if (own === 0) return { kind: "behind", behind };
30 // Merging the branch into the tip adds nothing: its changes are in the tip already.
31 const merged = await mergeable(dir, tip, main);
32 if (merged.ok && merged.value.treeOid === (await treeOf(dir, tip))) return { kind: "landed", behind, own };
33 return { kind: "diverged", behind, own };
34 }
35
36 /** Move `branch` from `from` to `to`, only if it is still at `from`. */
37 export async function moveBranch(dir: string, branch: string, from: string, to: string): Promise<boolean> {
38 if (!isSafeRef(branch) || !isSafeOid(from) || !isSafeOid(to)) return false;
39 return (await runGit(["update-ref", `refs/heads/${branch}`, to, from], { cwd: dir })).ok;
40 }
src/http/api/following.ts +6 −2
@@ -9,6 +9,7 @@ import { FAMILY_WORDS } from "../../actions/family_words.ts";
99 import { FOLLOW_MODE_REFUSALS, setFollowMode, severForge, takeForgeCode, takeForgeWords } from "../../actions/follow.ts";
1010 import { relationOf } from "../../actions/forks.ts";
1111 import { forgeProposals, proposeToParentForge, proposeUpstream } from "../../actions/upstream.ts";
12 import { convergeFollowed } from "../../actions/upstream_sync.ts";
1213 import { parentForgeFor } from "../../core/parent_releases.ts";
1314 import { forkParentFor, getRepo } from "../../core/repos.ts";
1415 import { notePhase, takeSessionTime, timePhase, timePhaseAsync } from "../../core/route_timing.ts";
@@ -228,9 +229,12 @@ export function followingRoutes(router: Router<Handler>): void {
228229 if (!mayDirectCrew(ctx.app.db, repo, ctx.auth.user))
229230 return respondAccess("forbidden", 403);
230231 const parent = forkParentFor(ctx.app.db, repo, ctx.auth.user);
231 if (parent === undefined)
232 const outcome =
233 parent === undefined
234 ? await convergeFollowed(ctx.app, repo, ctx.auth.user)
235 : await convergeFork(ctx.app, repo, parent, ctx.auth.user);
236 if (outcome === "not_a_fork")
232237 return apiError("not_a_fork", 404, "this repository follows nothing");
233 const outcome = await convergeFork(ctx.app, repo, parent, ctx.auth.user);
234238 if (outcome.kind === "archived") return respondAccess("archived", 409);
235239 const status =
236240 outcome.kind === "queued"
src/http/mcp/tools/converge.ts +4 −3
@@ -1,6 +1,7 @@
11 import { NOT_A_MEMBER } from "../../../core/roles.ts";
22 import { ARCHIVED_WHY, forkParentFor } from "../../../core/repos.ts";
33 import { convergeFork } from "../../../crew/converge.ts";
4 import { convergeFollowed } from "../../../actions/upstream_sync.ts";
45 import { DIRECTION_NOTE } from "../../../crew/direct.ts";
56 import { good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts";
67
@@ -19,9 +20,9 @@ export const tool: Tool = {
1920 const repo = repoArg(ctx, user, args);
2021 if (isAnswer(repo)) return repo;
2122 const parent = forkParentFor(ctx.app.db, repo, user);
22 if (parent === undefined)
23 return bad("not_a_fork", "this repository follows nothing");
24 const outcome = await convergeFork(ctx.app, repo, parent, user);
23 const outcome =
24 parent === undefined ? await convergeFollowed(ctx.app, repo, user) : await convergeFork(ctx.app, repo, parent, user);
25 if (outcome === "not_a_fork") return bad("not_a_fork", "this repository follows nothing");
2526 if (outcome.kind === "forbidden") return bad("forbidden", DIRECTION_NOTE);
2627 if (outcome.kind === "not_member") return bad("not_member", NOT_A_MEMBER);
2728 if (outcome.kind === "archived") return bad("archived", ARCHIVED_WHY);
src/tui/screens/forks.ts +48 −3
@@ -6,7 +6,10 @@
66 * leads, read as MCP `following` reads it (`relationOf`, `convergeState`).
77 * On a fork behind its parent, a person who writes it takes the parent's
88 * updates with `t` after a confirm — `takeOrTell`, as MCP `take_updates` —
9 * and a conflict's converge issue is the line Enter then opens.
9 * and a conflict's converge issue is the line Enter then opens. A repository
10 * that follows an address instead of a parent takes it the same way, and a
11 * person who writes it hands a conflict to the crew with `c` after a confirm
12 * (`convergeFollowed`, as MCP `converge`).
1013 */
1114
1215 import {
@@ -20,6 +23,7 @@ import {
2023 } from "../../actions/forks.ts";
2124 import { followModeWords } from "../../actions/follow_words.ts";
2225 import { FOLLOW_MODE_REFUSALS, convergeWords, followModeOf, followStanding, followModeSaid, setFollowMode, takeOrTell, takeWords } from "../../actions/follow.ts";
26 import { convergeFollowed, followedAs, followedOf } from "../../actions/upstream_sync.ts";
2327 import { FOLLOWS_NOTHING, proposeRefusalWords } from "../../actions/follow_words.ts";
2428 import { openProposal, proposeUpstream } from "../../actions/upstream.ts";
2529 import type { Pull } from "../../core/pulls.ts";
@@ -144,6 +148,8 @@ export function forksScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
144148 let following: ForkRow[] = [];
145149 let relation: ForkRelation | undefined;
146150 let offer: string | undefined;
151 /** What the repository follows by address, when it has no parent here. */
152 let followed: ReturnType<typeof followedAs> | undefined;
147153 let open: Pull | undefined;
148154 let mode: ForkFollowMode | undefined;
149155 /** The one plain line the page and MCP say: how this fork follows, and when it last took. */
@@ -196,7 +202,14 @@ export function forksScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
196202 tree = forkTree(f.db, repo.id, f.user);
197203 relation = await relationOf(f.app, f.user, repo);
198204 open = relation === undefined ? undefined : openProposal(f.app, repo, relation.parent);
199 offer = takeOffer(relation, canWrite(f.db, repo, f.user), f.user?.kind === "user");
205 const address = relation === undefined ? followedOf(f.app, repo) : undefined;
206 followed = address === undefined ? undefined : followedAs(address);
207 offer =
208 followed === undefined
209 ? takeOffer(relation, canWrite(f.db, repo, f.user), f.user?.kind === "user")
210 : canWrite(f.db, repo, f.user) && f.user?.kind === "user"
211 ? `Take what ${followed.ownerName}/${followed.name} has that this repository lacks.`
212 : undefined;
200213 const converge =
201214 relation !== undefined && relation.state === "diverged" && !relation.clean
202215 ? convergeState(f.app, repo, relation.parent)
@@ -207,7 +220,7 @@ export function forksScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
207220 ctx.invalidate();
208221 };
209222 const take = (): void => {
210 const parent = relation?.parent;
223 const parent = relation?.parent ?? followed;
211224 if (offer === undefined || parent === undefined) return;
212225 ctx.push(
213226 confirmScreen(ctx, self, {
@@ -234,6 +247,33 @@ export function forksScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
234247 }),
235248 );
236249 };
250 /** Converge with a followed address: a person who writes the repository, after a confirm. */
251 const mayConverge = (): boolean =>
252 followed !== undefined && f.user?.kind === "user" && canWrite(f.db, repo, f.user);
253 const converge = (): void => {
254 const parent = followed;
255 if (parent === undefined) return;
256 const name = `${parent.ownerName}/${parent.name}`;
257 ctx.push(
258 confirmScreen(ctx, self, {
259 title: "Converge",
260 body: ["Hand the files both sides changed to the crew."],
261 actions: [{ label: "Converge" }, { label: "Cancel", variant: "ghost" }],
262 yesNo: true,
263 onChoose: (choice) => {
264 if (choice !== 0) return;
265 runAsync(ctx, async () => {
266 const outcome = await convergeFollowed(f.app, repo, f.user);
267 if (outcome === "not_a_fork") return ctx.toast(FOLLOWS_NOTHING, "danger");
268 const words = convergeWords(outcome, name);
269 await read();
270 ctx.toast(words.message, words.tone === "danger" ? "danger" : "default");
271 ctx.invalidate();
272 });
273 },
274 }),
275 );
276 };
237277 const canSetMode = (): boolean =>
238278 relation !== undefined && f.user?.kind === "user" && canAdmin(f.db, repo, f.user) && canWrite(f.db, repo, f.user);
239279 const self: Screen = {
@@ -243,6 +283,7 @@ export function forksScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
243283 binding(["j", "k"], "move"),
244284 binding(["enter"], "open"),
245285 ...(offer === undefined ? [] : [binding(["t"], "take updates")]),
286 ...(mayConverge() ? [binding(["c"], "converge")] : []),
246287 ...(relation !== undefined && mayPropose()
247288 ? [binding(["u"], open === undefined ? "propose upstream" : "open proposal")]
248289 : []),
@@ -303,6 +344,10 @@ export function forksScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
303344 take();
304345 return true;
305346 }
347 if (e.key === "c" && mayConverge()) {
348 converge();
349 return true;
350 }
306351 if (e.key === "m" && canSetMode()) {
307352 runAsync(ctx, async () => {
308353 // `m` steps through the three modes in turn.
src/web/pages/following.ts +16 −3
@@ -43,6 +43,7 @@ import { loadRepo, notFoundPage, repoBase, signInFirst } from "../chrome.ts";
4343 import { convergeWords, followModeOf, takeOrTell, takeWords } from "../../actions/follow.ts";
4444 import { FOLLOW_WORD, FOLLOWS_NOTHING, proposeRefusalWords } from "../../actions/follow_words.ts";
4545 import { openProposal, proposeUpstream } from "../../actions/upstream.ts";
46 import { convergeFollowed, followedAs, followedOf } from "../../actions/upstream_sync.ts";
4647 import { proposesViaFork } from "../../origins/fork-route.ts";
4748 import { field, readForm } from "../form.ts";
4849 import { html, when, type Html } from "../html.ts";
@@ -428,12 +429,15 @@ export async function followingSubmit(ctx: Ctx): Promise<Response> {
428429 if (form instanceof Response) return form;
429430 const base = repoBase(repo);
430431 const parent = forkParentFor(ctx.app.db, repo, ctx.auth.user);
431 if (parent === undefined)
432 const followed = followedOf(ctx.app, repo);
433 // What a repository follows by address answers the same buttons as a parent does.
434 const named = parent ?? (followed === undefined ? undefined : followedAs(followed));
435 if (named === undefined)
432436 return flashOrJson(ctx, base, {
433437 tone: "danger",
434438 message: FOLLOWS_NOTHING,
435439 });
436 const parentName = `${parent.ownerName}/${parent.name}`;
440 const parentName = `${named.ownerName}/${named.name}`;
437441
438442 if (field(form, "do") === "converge") {
439443 if (!canWrite(ctx.app.db, repo, ctx.auth.user))
@@ -441,9 +445,18 @@ export async function followingSubmit(ctx: Ctx): Promise<Response> {
441445 tone: "danger",
442446 message: "Only a writer can ask the crew to converge.",
443447 });
444 const outcome = await convergeFork(ctx.app, repo, parent, ctx.auth.user);
448 const outcome =
449 parent === undefined
450 ? await convergeFollowed(ctx.app, repo, ctx.auth.user)
451 : await convergeFork(ctx.app, repo, parent, ctx.auth.user);
452 if (outcome === "not_a_fork") return flashOrJson(ctx, base, { tone: "danger", message: FOLLOWS_NOTHING });
445453 return flashOrJson(ctx, base, convergeWords(outcome, parentName));
446454 }
455 if (parent === undefined) {
456 // Without a parent here the buttons are Take and Converge; anything else is a take.
457 const took = await takeOrTell(ctx.app, ctx.auth.user, repo);
458 return flashOrJson(ctx, base, takeWords(took, repo, named));
459 }
447460 if (field(form, "do") === "propose") {
448461 const proposed = await proposeUpstream(ctx.app, ctx.auth.user, repo);
449462 if (!proposed.ok) {
src/web/pages/upstream-follow.ts +11
@@ -205,6 +205,17 @@ export function followUpstreamCard(ctx: Ctx, repo: Repo): Html | null {
205205 ${btn("Sever", { size: "sm", variant: "outline" })}
206206 </form>`,
207207 )}
208 ${when(
209 follow !== undefined,
210 () => html`<div class="form-actions mt">

Principle 5: three primary buttons on one card put several accents on the page. Make one primary and the others outline, and shorten the labels (principle 1).

211 ${["take", "converge"].map(
212 (act) => html`<form method="post" action="${base}/following" class="inline">
213 <input type="hidden" name="do" value="${act}" />
214 ${act === "take" ? btn("Take", { size: "sm", icon: "sync" }) : btn("Converge", { size: "sm", variant: "outline" })}
215 </form>`,
216 )}
217 </div>`,
218 )}
208219 </section>`;
209220 }
210221
test/tui-forks-followed.test.ts added +153
@@ -0,0 +1,153 @@
1 /**
2 * The terminal's forks screen on a repository that follows an address, not a
3 * parent here: `t` takes its updates and `c` hands a conflict to the crew,
4 * each after a confirm and only for a person who writes the repository.
5 */
6
7 import { afterAll, beforeAll, describe, expect, 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 { addToCrew, createAgent } from "../src/actions/crew.ts";
13 import { createRepo } from "../src/actions/repos.ts";
14 import { putFollow } from "../src/core/upstream_follow.ts";
15 import { getRepoById, type Repo } from "../src/core/repos.ts";
16 import { createUser, type User } from "../src/core/users.ts";
17 import { repoDir } from "../src/git/repos.ts";
18 import { createForgeApp } from "../src/tui/forge.ts";
19 import { forksScreen } from "../src/tui/screens/forks.ts";
20 import { headlessTerminal } from "../src/tui/term/terminal.ts";
21 import { mirrorOutbound } from "./helpers/globals.ts";
22 import { settleApp } from "./helpers/tui.ts";
23
24 let dataDir = "";
25 let app: App;
26 let alice: User;
27 let bob: User;
28 let tip = "";
29 let server: ReturnType<typeof Bun.serve>;
30
31 const ident = { GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@x", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@x" };
32
33 mirrorOutbound({ allow: ["127.0.0.1"] });
34
35 beforeAll(async () => {
36 dataDir = await mkdtemp(join(tmpdir(), "forge-tui-forks-followed-"));
37 app = createApp({ dataDir, port: 0 });
38 const users: User[] = [];
39 for (const name of ["alice", "bob"]) {
40 const made = await createUser(app.db, {
41 origin: "seed",
42 trusted: true,
43 username: name,
44 email: `${name}@forge.local`,
45 password: "a-good-passphrase",
46 adminIfFirst: name === "alice",
47 });
48 if (!made.ok) throw new Error("user");
49 users.push(made.value);
50 }
51 [alice, bob] = users as [User, User];
52 const bot = await createAgent(app, alice, { username: "bot", brief: "Be brief." });
53 if (!bot.ok) throw new Error(String(bot.error));
54 // The upstream: answers `ls-remote` with whatever `tip` is.
55 server = Bun.serve({
56 port: 0,
57 hostname: "127.0.0.1",
58 fetch: (req) =>
59 new URL(req.url).pathname.endsWith("/info/refs")
60 ? new Response(`${tip}\trefs/heads/main\n`, { headers: { "content-type": "text/plain" } })
61 : new Response("no", { status: 404 }),
62 });
63 });
64 afterAll(async () => {
65 server?.stop(true);
66 app?.close();
67 await rm(dataDir, { recursive: true, force: true });
68 });
69
70 /** A repository whose main is at `mine` and whose followed upstream stands at `theirs`, staged. */
71 async function following(name: string, files: { base: string; mine: string; theirs: string }) {
72 const made = await createRepo(app, alice, { name, visibility: "public" });
73 if (!made.ok) throw new Error("repo");
74 const repo: Repo = made.value;
75 const dir = repoDir(app.reposRoot, repo.ownerName, repo.name);
76 const git = (args: string[], input?: string): string => {
77 const p = Bun.spawnSync(["git", ...args], { cwd: dir, env: { ...process.env, ...ident }, stdin: input === undefined ? undefined : Buffer.from(input) });
78 if (p.exitCode !== 0) throw new Error(`git ${args.join(" ")}: ${p.stderr.toString()}`);
79 return p.stdout.toString().trim();
80 };
81 const commit = (text: string, ...parents: string[]): string => {
82 const tree = git(["mktree"], `100644 blob ${git(["hash-object", "-w", "--stdin"], text)}\ta.txt\n`);
83 return git(["commit-tree", tree, ...parents.flatMap((p) => ["-p", p]), "-m", text.trim()]);
84 };
85 const base = commit(files.base);
86 const mine = files.mine === files.base ? base : commit(files.mine, base);
87 const theirs = commit(files.theirs, base);
88 git(["update-ref", "refs/heads/main", mine]);
89 tip = theirs;
90 git(["update-ref", "refs/upstream-follow/main", theirs]);
91 putFollow(app.db, {
92 repoId: repo.id,
93 url: `http://127.0.0.1:${server.port}/o/${name}.git`,
94 branch: "main",
95 enabled: false,
96 byId: alice.id,
97 originId: "",
98 });
99 return { repo, mine, theirs, main: () => git(["rev-parse", "refs/heads/main"]) };
100 }
101
102 async function screenOf(who: User, repo: Repo, ...keys: string[]): Promise<string[]> {
103 const terminal = headlessTerminal(120, 30);
104 const { app: tui, session } = createForgeApp(
105 app,
106 { sshUsername: who.username, remoteAddress: "127.0.0.1", fingerprint: "SHA256:test", user: who },
107 { terminal, exitHooks: false },
108 );
109 tui.start();
110 tui.ctx.push(forksScreen(session, tui.ctx, getRepoById(app.db, repo.id)!));
111 await settleApp(tui);
112 const frames: string[] = [tui.screen.toText()];
113 for (const k of keys) {
114 terminal.feed(k);
115 await settleApp(tui);
116 frames.push(tui.screen.toText());
117 }
118 tui.stop?.();
119 return frames;
120 }
121
122 describe("the forks screen of a repository that follows an address", () => {
123 test("a reader is offered neither t nor c, and pressing them moves nothing", async () => {
124 const s = await following("reader", { base: "1\n", mine: "1\n", theirs: "2\n" });
125 const [first, ...rest] = await screenOf(bob, s.repo, "t", "y", "c", "y");
126 expect(first).not.toContain("take updates");
127 expect(first).not.toContain("converge");
128 expect(rest.join("\n")).not.toContain("Take updates");
129 expect(s.main()).toBe(s.mine);
130 });
131
132 test("a cancelled take moves nothing; t and a confirm take the upstream's updates", async () => {
133 const s = await following("take", { base: "1\n", mine: "1\n", theirs: "2\n" });
134 const [first, asked, cancelled] = await screenOf(alice, s.repo, "t", "n");
135 expect(first).toContain("take updates");
136 expect(asked).toContain(`Take what 127.0.0.1:${server.port}/o/take has`);
137 expect(cancelled).not.toContain("Take what");
138 expect(s.main()).toBe(s.mine);
139 const frames = await screenOf(alice, s.repo, "t", "y");
140 expect(s.main()).toBe(s.theirs);
141 expect(frames.at(-1)).toContain("updates: main is at");
142 });
143
144 test("c and a confirm hand a conflict to the crew, said in the page's words", async () => {
145 const s = await following("conflict", { base: "1\n", mine: "mine\n", theirs: "theirs\n" });
146 expect(addToCrew(app, alice, s.repo, "bot").ok).toBe(true);
147 const [first, asked, done] = await screenOf(alice, s.repo, "c", "y");
148 expect(first).toContain("converge");
149 expect(asked).toContain("Hand the files both sides changed to the crew.");
150 expect(done).toContain("bot is converging with");
151 expect(s.main()).toBe(s.mine);
152 });
153 });
test/upstream-follow-web.test.ts +38 −1
@@ -11,7 +11,7 @@ import { commitToBranch, createRepo } from "../src/actions/repos.ts";
1111 import { checkUpstream } from "../src/actions/upstream_follow.ts";
1212 import { listEvents } from "../src/core/events.ts";
1313 import { getRepo } from "../src/core/repos.ts";
14 import { createUser } from "../src/core/users.ts";
14 import { createUser, getUserByName } from "../src/core/users.ts";
1515 import { serve, type Servers } from "../src/http/server.ts";
1616
1717 let dataDir = "";
@@ -162,6 +162,43 @@ describe("the pages", () => {
162162 expect((await read(alice)).following?.branch).toBe("dev");
163163 });
164164
165 test("a followed address offers Take as the one primary button and Converge beside it, and no offer upstream", async () => {
166 await api(alice, "PUT", "", { url: URL_, branch: "main", enabled: true });
167 const html = await settings(alice);
168 const form = (act: string) => new RegExp(`name="do" value="${act}" />\\s*<button[^>]*>`).exec(html)?.[0] ?? "";
169 expect(form("take")).not.toBe("");
170 expect(form("take")).not.toContain("outline");
171 expect(form("converge")).toContain("outline");
172 expect(html).not.toContain('value="propose"');
173 });
174
175 test("the following post answers for a followed address, and says so when it follows nothing", async () => {
176 const press = (cookie: string, act: string) =>
177 fetch(`${base}/alice/square/following`, {
178 method: "POST",
179 headers: { cookie, origin: base },
180 body: new URLSearchParams({ do: act }),
181 redirect: "manual",
182 });
183 const said = (res: Response) => decodeURIComponent(res.headers.get("set-cookie") ?? "");
184 await api(alice, "PUT", "", { url: URL_, branch: "main", enabled: true });
185 for (const act of ["take", "converge"]) {
186 const res = await press(alice, act);
187 expect(res.status).toBe(303);
188 expect(said(res)).not.toContain("follows nothing");
189 }
190 const alone = await createRepo(app, getUserByName(app.db, "alice")!, { name: "alone", visibility: "public" });
191 if (!alone.ok) throw new Error("repo");
192 const off = await fetch(`${base}/alice/alone/following`, {
193 method: "POST",
194 headers: { cookie: alice, origin: base },
195 body: new URLSearchParams({ do: "take" }),
196 redirect: "manual",
197 });
198 expect(off.status).toBe(303);
199 expect(said(off)).toContain("follows nothing");
200 });
201
165202 test("the check post is an admin's and same-origin, and says what it found", async () => {
166203 const check = (cookie: string, origin: string) =>
167204 fetch(`${base}/alice/square/settings/upstream/check`, {
test/upstream-sync.test.ts added +276
@@ -0,0 +1,276 @@
1 /**
2 * A repository that follows an upstream address takes its updates the way a
3 * fork takes its parent's: a fast-forward when it holds nothing the upstream
4 * lacks, a return to the upstream's commit ids when its own work landed there
5 * under other ids, a merge when git can, the files in conflict when it cannot.
6 */
7
8 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
9 import { mkdtemp, rm } from "node:fs/promises";
10 import { tmpdir } from "node:os";
11 import { join } from "node:path";
12 import { createApp, type App } from "../src/app.ts";
13 import { createRepo } from "../src/actions/repos.ts";
14 import { takeUpdates } from "../src/actions/forks.ts";
15 import { addToCrew, createAgent } from "../src/actions/crew.ts";
16 import { checkUpstream } from "../src/actions/upstream_follow.ts";
17 import { setForkFollowMode } from "../src/core/fork_follow.ts";
18 import { takeOrTell } from "../src/actions/follow.ts";
19 import { convergeFollowed } from "../src/actions/upstream_sync.ts";
20 import { putFollow } from "../src/core/upstream_follow.ts";
21 import { listEvents } from "../src/core/events.ts";
22 import type { Repo } from "../src/core/repos.ts";
23 import { createUser, type User } from "../src/core/users.ts";
24 import { repoDir } from "../src/git/repos.ts";
25 import { mirrorOutbound } from "./helpers/globals.ts";
26
27 let dataDir = "";
28 let app: App;
29 let alice: User;
30 /** Not promoted: a guest, who reads public repositories and writes none. */
31 let bob: User;
32 let tip = "";
33 let server: ReturnType<typeof Bun.serve>;
34
35 const ident = {
36 GIT_AUTHOR_NAME: "t",
37 GIT_AUTHOR_EMAIL: "t@x",
38 GIT_COMMITTER_NAME: "t",
39 GIT_COMMITTER_EMAIL: "t@x",
40 };
41
42 mirrorOutbound({ allow: ["127.0.0.1"] });
43
44 beforeAll(async () => {
45 dataDir = await mkdtemp(join(tmpdir(), "forge-upstream-sync-"));
46 app = createApp({ dataDir, port: 0 });
47 const made = await createUser(app.db, {
48 origin: "seed",
49 trusted: true,
50 username: "alice",
51 email: "[email protected]",
52 password: "a-good-passphrase",
53 adminIfFirst: true,
54 });
55 if (!made.ok) throw new Error("user");
56 alice = made.value;
57 const guest = await createUser(app.db, {
58 origin: "seed",
59 trusted: true,
60 username: "bob",
61 email: "[email protected]",
62 password: "a-good-passphrase",
63 });
64 if (!guest.ok) throw new Error("user");
65 bob = guest.value;
66 const bot = await createAgent(app, alice, { username: "bot", brief: "Be brief." });
67 if (!bot.ok) throw new Error(String(bot.error));
68 // The upstream: answers `ls-remote` with whatever `tip` is.
69 server = Bun.serve({
70 port: 0,
71 hostname: "127.0.0.1",
72 fetch: (req) =>
73 new URL(req.url).pathname.endsWith("/info/refs")
74 ? new Response(`${tip}\trefs/heads/main\n`, { headers: { "content-type": "text/plain" } })
75 : new Response("no", { status: 404 }),
76 });
77 });
78 afterAll(async () => {
79 server?.stop(true);
80 app?.close();
81 await rm(dataDir, { recursive: true, force: true });
82 });
83
84 /** A repository following the fake upstream, with git helpers over its bare directory. */
85 async function scenario(name: string) {
86 const made = await createRepo(app, alice, { name, visibility: "public" });
87 if (!made.ok) throw new Error("repo");
88 const repo: Repo = made.value;
89 const dir = repoDir(app.reposRoot, repo.ownerName, repo.name);
90 const git = (args: string[], input?: string): string => {
91 const p = Bun.spawnSync(["git", ...args], { cwd: dir, env: { ...process.env, ...ident }, stdin: input === undefined ? undefined : Buffer.from(input) });
92 if (p.exitCode !== 0) throw new Error(`git ${args.join(" ")}: ${p.stderr.toString()}`);
93 return p.stdout.toString().trim();
94 };
95 /** A commit whose tree holds exactly these files, on top of `parents`. */
96 const commit = (files: Record<string, string>, message: string, ...parents: string[]): string => {
97 const lines = Object.entries(files)
98 .map(([path, text]) => `100644 blob ${git(["hash-object", "-w", "--stdin"], text)}\t${path}`)
99 .join("\n");
100 const tree = git(["mktree"], `${lines}\n`);
101 return git(["commit-tree", tree, ...parents.flatMap((p) => ["-p", p]), "-m", message]);
102 };
103 const head = (ref: string): string => git(["rev-parse", ref]);
104 const mainIs = (oid: string): void => void git(["update-ref", "refs/heads/main", oid]);
105 /** The upstream stands at `oid`; its tip is already staged: the take fetches nothing. */
106 const upstreamIs = (oid: string, enabled = false): void => {
107 tip = oid;
108 git(["update-ref", "refs/upstream-follow/main", oid]);
109 putFollow(app.db, {
110 repoId: repo.id,
111 url: `http://127.0.0.1:${server.port}/o/${name}.git`,
112 branch: "main",
113 enabled,
114 byId: alice.id,
115 originId: "",
116 });
117 };
118 return { repo, git, commit, head, mainIs, upstreamIs };
119 }
120
121 describe("taking a followed upstream's updates", () => {
122 test("a fast-forward when the repository has nothing the upstream lacks", async () => {
123 const s = await scenario("ff");
124 const c1 = s.commit({ "a.txt": "1\n" }, "one");
125 const c2 = s.commit({ "a.txt": "2\n" }, "two", c1);
126 s.mainIs(c1);
127 s.upstreamIs(c2);
128 const took = await takeUpdates(app, alice, s.repo);
129 expect(took.ok && [took.value.updated, took.value.merged, took.value.to]).toEqual([true, false, c2]);
130 expect(s.head("refs/heads/main")).toBe(c2);
131 const pushes = listEvents(app.db, { repoId: s.repo.id }).filter((e) => e.kind === "push");
132 expect(JSON.stringify(pushes.at(-1)?.payload)).toContain("followed");
133 });
134
135 test("a repository level with its upstream has nothing to take", async () => {
136 const s = await scenario("level");
137 const c1 = s.commit({ "a.txt": "1\n" }, "one");
138 s.mainIs(c1);
139 s.upstreamIs(c1);
140 const took = await takeUpdates(app, alice, s.repo);
141 expect(took.ok && took.value.updated).toBe(false);
142 });
143
144 test("work that landed upstream under other commit ids returns the branch to the upstream's ids", async () => {
145 const s = await scenario("landed");
146 const c1 = s.commit({ "a.txt": "1\n", "b.txt": "b\n" }, "base");
147 // Our change, as we made it; upstream took it as one squashed commit and then moved on.
148 const own = s.commit({ "a.txt": "1 ours\n", "b.txt": "b\n" }, "our change", c1);
149 const squashed = s.commit({ "a.txt": "1 ours\n", "b.txt": "b\n" }, "Squashed: our change", c1);
150 const later = s.commit({ "a.txt": "1 ours\n", "b.txt": "b later\n" }, "someone else", squashed);
151 s.mainIs(own);
152 s.upstreamIs(later);
153 const took = await takeUpdates(app, alice, s.repo);
154 expect(took.ok && [took.value.updated, took.value.merged]).toEqual([true, false]);
155 // The branch is the upstream's own commit, not a merge of the two histories.
156 expect(s.head("refs/heads/main")).toBe(later);
157 const again = await takeUpdates(app, alice, s.repo);
158 expect(again.ok && again.value.updated).toBe(false);
159 });
160
161 test("a clean merge when both sides have work the other lacks", async () => {
162 const s = await scenario("merge");
163 const c1 = s.commit({ "a.txt": "1\n", "b.txt": "1\n" }, "base");
164 const mine = s.commit({ "a.txt": "mine\n", "b.txt": "1\n" }, "mine", c1);
165 const theirs = s.commit({ "a.txt": "1\n", "b.txt": "theirs\n" }, "theirs", c1);
166 s.mainIs(mine);
167 s.upstreamIs(theirs);
168 const took = await takeUpdates(app, alice, s.repo);
169 expect(took.ok && took.value.merged).toBe(true);
170 expect(s.git(["rev-list", "--parents", "-1", "refs/heads/main"]).split(" ").slice(1).sort()).toEqual([mine, theirs].sort());
171 expect(s.git(["show", "refs/heads/main:b.txt"])).toBe("theirs");
172 });
173
174 test("a conflict is refused with the files, and told to the crew", async () => {
175 const s = await scenario("conflict");
176 const c1 = s.commit({ "a.txt": "1\n" }, "base");
177 s.mainIs(s.commit({ "a.txt": "mine\n" }, "mine", c1));
178 s.upstreamIs(s.commit({ "a.txt": "theirs\n" }, "theirs", c1));
179 const before = s.head("refs/heads/main");
180 const took = await takeOrTell(app, alice, s.repo);
181 expect(!took.ok && typeof took.error === "object" && took.error.kind === "conflict" && [...took.error.files]).toEqual(["a.txt"]);
182 expect(s.head("refs/heads/main")).toBe(before);
183 // No crew on this repository: the answer says so, rather than starting nothing silently.
184 expect(await convergeFollowed(app, s.repo, alice)).toMatchObject({ kind: "no_crew" });
185 });
186
187 test("a repository that follows nothing is not a fork", async () => {
188 const made = await createRepo(app, alice, { name: "alone", visibility: "public" });
189 if (!made.ok) throw new Error("repo");
190 const took = await takeUpdates(app, alice, made.value);
191 expect(!took.ok && took.error).toBe("not_a_fork");
192 expect(await convergeFollowed(app, made.value, alice)).toBe("not_a_fork");
193 });
194
195 test("a reader who does not write the repository takes nothing", async () => {
196 const s = await scenario("reader");
197 const c1 = s.commit({ "a.txt": "1\n" }, "one");
198 const c2 = s.commit({ "a.txt": "2\n" }, "two", c1);
199 s.mainIs(c1);
200 s.upstreamIs(c2);
201 const took = await takeUpdates(app, bob, s.repo);
202 expect(took.ok).toBe(false);
203 expect(s.head("refs/heads/main")).toBe(c1);
204 });
205 });
206
207 describe("a check of the followed upstream moves the default branch by itself", () => {
208 test("in take-updates, a branch with nothing of its own fast-forwards with nobody pressing Take", async () => {
209 const s = await scenario("check-ff");
210 const c1 = s.commit({ "a.txt": "1\n" }, "one");
211 const c2 = s.commit({ "a.txt": "2\n" }, "two", c1);
212 s.mainIs(c1);
213 s.upstreamIs(c2, true);
214 setForkFollowMode(app.db, s.repo.id, "take-updates");
215 const checked = await checkUpstream(app, s.repo);
216 expect(checked.ok && checked.value.behind).toBe(0);
217 expect(s.head("refs/heads/main")).toBe(c2);
218 const pushes = listEvents(app.db, { repoId: s.repo.id }).filter((e) => e.kind === "push");
219 expect(JSON.stringify(pushes.at(-1)?.payload)).toContain("followed");
220 });
221
222 test("with no mode set, a branch behind stays where it is", async () => {
223 const s = await scenario("check-stay");
224 const c1 = s.commit({ "a.txt": "1\n" }, "one");
225 const c2 = s.commit({ "a.txt": "2\n" }, "two", c1);
226 s.mainIs(c1);
227 s.upstreamIs(c2, true);
228 await checkUpstream(app, s.repo);
229 expect(s.head("refs/heads/main")).toBe(c1);
230 });
231
232 test("with no mode set, work that landed upstream under other ids returns to the upstream's ids", async () => {
233 const s = await scenario("check-landed");
234 const c1 = s.commit({ "a.txt": "1\n" }, "base");
235 const own = s.commit({ "a.txt": "1 ours\n" }, "our change", c1);
236 const squashed = s.commit({ "a.txt": "1 ours\n" }, "Squashed: our change", c1);
237 s.mainIs(own);
238 s.upstreamIs(squashed, true);
239 const checked = await checkUpstream(app, s.repo);
240 expect(checked.ok && checked.value.behind).toBe(0);
241 expect(s.head("refs/heads/main")).toBe(squashed);
242 });
243 });
244
245 describe("converging with a followed upstream", () => {
246 /** Both sides changed a.txt: a decision for the crew. */
247 async function conflicted(name: string) {
248 const s = await scenario(name);
249 const c1 = s.commit({ "a.txt": "1\n" }, "base");
250 s.mainIs(s.commit({ "a.txt": "mine\n" }, "mine", c1));
251 s.upstreamIs(s.commit({ "a.txt": "theirs\n" }, "theirs", c1));
252 return s;
253 }
254
255 test("a builder on the crew is queued on a converge issue; asked again, the crew is already working on it", async () => {
256 const s = await conflicted("crewed");
257 expect(addToCrew(app, alice, s.repo, "bot").ok).toBe(true);
258 const first = await convergeFollowed(app, s.repo, alice);
259 expect(first).toMatchObject({ kind: "queued", agent: { user: { username: "bot" } } });
260 const issue = typeof first === "object" && "issue" in first ? first.issue.number : 0;
261 expect(issue).toBeGreaterThan(0);
262 expect(await convergeFollowed(app, s.repo, alice)).toMatchObject({ kind: "working", issue: { number: issue } });
263 });
264
265 test("someone who may not direct the crew is forbidden", async () => {
266 const s = await conflicted("forbidden");
267 expect(await convergeFollowed(app, s.repo, bob)).toEqual({ kind: "forbidden" });
268 });
269
270 test("a guest the repository lets direct its crew is still not a member who may run it", async () => {
271 const s = await conflicted("outsiders");
272 app.db.query("UPDATE repo SET crew_outsiders = 1 WHERE id = ?1").run(s.repo.id);
273 const repo = { ...s.repo, crewOutsiders: true };
274 expect(await convergeFollowed(app, repo, bob)).toEqual({ kind: "not_member" });
275 });
276 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs