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

take-updates: a forge in take-updates merges its parent's new code when it applies cleanly, and opens the converge issue when it does not #2640

Merged opened this

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

Merged

scout/forge:scout/2634-take-updates-a-forge-in-take-updates-mer → main 3 commits fork 1 thread · 1 unresolved

  • Merged. scout/2634-take-updates-a-forge-in-take-updates-mer was merged into main as 6228a2d.

Review

Sign in to review this change.
6 files changed +288 −4
README.md +5 −2
@@ -381,14 +381,17 @@ taking a release is not done here. This forge publishes its own
381381 forge can be a parent. The terminal shows the count, not the release names.
382382
383383 A spawned forge's own code is a fork of its parent's (`<org>/forge`, chosen
384 `followMode` `tell-me` or `take-updates`; recorded: the mode does not yet apply anything). Once an hour, and
384 `followMode` `tell-me` or `take-updates`; in `take-updates` the hourly check merges what the parent gained). Once an hour, and
385385 on demand, a forge that follows and has not severed fetches its parent's code into
386386 `refs/upstream/*` of its own `<org>/forge` (no branch of the fork moves) and keeps
387387 `{ behind, parentTip, checkedAt, error }` in `upstream.json` under `FORGE_APP_DATA`;
388388 the count shows on the chain page, in the terminal's **Chain** (`c` checks now), and as
389389 `upstream` in the API and MCP payloads. `POST /:owner/forge/following/check` (a writer,
390390 same-origin) checks on demand; a fetch is cut at 60 s and a failure is recorded, not
391 thrown. In `tell-me`, a new parent tip makes one inbox card for the owner (an issue on the fork assigned to them, "Your parent forge has N new commits", linking the chain page); `toldTip` in `upstream.json` keeps it to one per tip, and `take-updates` or a severed link makes none. Its chain
391 thrown. In `take-updates` the check then fast-forwards or cleanly merges `refs/upstream/HEAD`
392 into the fork's main (`spawn/take.ts`), records it as `taken` in `upstream.json` (shown on the chain line) and as a
393 `forge.updates_taken` event; on a conflict main is untouched and one open issue,
394 "Take updates from <parent>", names the files. It never deploys. In `tell-me`, a new parent tip makes one inbox card for the owner (an issue on the fork assigned to them, "Your parent forge has N new commits", linking the chain page); `toldTip` in `upstream.json` keeps it to one per tip, and `take-updates` or a severed link makes none. Its chain
392395 to the root is at `/:owner/forge/following`, in `chain` of `GET
393396 /api/v1/repos/:owner/forge/following` and the MCP `following` tool, and in the
394397 terminal's **Chain** command; the owner severs it with the page's Sever,
src/spawn/take.ts added +111
@@ -0,0 +1,111 @@
1 /**
2 * Taking the parent's code, for a forge that follows in `take-updates`
3 * mode. Once the hourly fetch has put the parent's tip at
4 * `refs/upstream/HEAD`, this moves the forge's own default branch: a
5 * fast-forward, or a merge commit when git merges the two without conflict.
6 * A conflict changes nothing; it opens (or finds) one issue naming the files
7 * for the people here to settle. It never deploys: production follows main
8 * as it always did.
9 */
10
11 import type { App } from "../app.ts";
12 import { commitIdentity, forgeHost, noreplyAddress } from "../core/commit_identity.ts";
13 import { createIssue, listIssues } from "../core/issues.ts";
14 import { getRepo } from "../core/repos.ts";
15 import { getUserById } from "../core/users.ts";
16 import { runGit } from "../git/env.ts";
17 import { mergeable, mergeBase, merge, resolveOid } from "../git/merge.ts";
18 import { repoDir } from "../git/repos.ts";
19 import type { ForgeLink } from "./chain.ts";
20
21 /** What the last check did with the parent's new commits; absent when it did nothing. */
22 export type Taken =
23 | { readonly kind: "took"; readonly commits: number; readonly merged: boolean; readonly to: string }
24 | { readonly kind: "conflict"; readonly files: readonly string[]; readonly issue: number | null };
25
26 export const takeTitle = (upstream: string): string => `Take updates from ${upstream}`;
27
28 /** Take what `refs/upstream/HEAD` holds into the forge's default branch, or tell. Never throws. */
29 export async function takeFromParent(app: App, link: ForgeLink): Promise<Taken | undefined> {
30 const [o, n] = link.repo.split("/") as [string, string];
31 const repo = getRepo(app.db, o, n);
32 if (repo === undefined) return undefined;
33 const dir = repoDir(app.reposRoot, o, n);
34 const branch = repo.defaultBranch;
35 const head = await resolveOid(dir, `refs/heads/${branch}`);
36 const up = await resolveOid(dir, "refs/upstream/HEAD");
37 if (head === undefined || up === undefined || head === up) return undefined;
38 const base = await mergeBase(dir, head, up);
39 if (base === undefined || base === up) return undefined; // the parent has nothing we lack
40 const count = await runGit(["rev-list", "--count", `${head}..${up}`], { cwd: dir });
41 const commits = count.ok ? Number(count.value.trim()) || 0 : 0;
42
43 let to = up;
44 let merged = false;
45 if (base === head) {
46 const moved = await runGit(["update-ref", `refs/heads/${branch}`, up, head], { cwd: dir });
47 if (!moved.ok) return undefined; // pushed to meanwhile: the next check looks again
48 } else {
49 const tree = await mergeable(dir, head, up);
50 if (!tree.ok) {
51 if (tree.error.kind !== "conflict") return undefined;
52 return { kind: "conflict", files: tree.error.files, issue: tell(app, repo, link, tree.error.files) };
53 }
54 const owner = getUserById(app.db, repo.ownerId);
55 const author = {
56 name: "forge",
57 email: noreplyAddress("forge", forgeHost(app.config)),
58 };
59 const landed = await merge(dir, {
60 baseBranch: branch,
61 baseOid: head,
62 headOid: up,
63 strategy: "merge",
64 message: takeTitle(link.upstream),
65 author: owner === undefined ? author : commitIdentity(app.config, owner),
66 committer: author,
67 treeOid: tree.value.treeOid,
68 });
69 if (!landed.ok) return undefined;
70 to = landed.value.mergeOid;
71 merged = true;
72 }
73 const taken: Taken = { kind: "took", commits, merged, to };
74 app.bus.append({
75 kind: "push",
76 repoId: repo.id,
77 payload: {
78 updates: [{ ref: `refs/heads/${branch}`, oldOid: head, newOid: to }],
79 followed: link.upstream,
80 merged,
81 },
82 });
83 app.bus.append({
84 kind: "forge.updates_taken",
85 repoId: repo.id,
86 payload: { repo: link.repo, upstream: link.upstream, from: head, to, commits, merged },
87 });
88 return taken;
89 }
90
91 /** The one open issue naming the conflicting files; the number, or null when nobody may open it. */
92 function tell(app: App, repo: NonNullable<ReturnType<typeof getRepo>>, link: ForgeLink, files: readonly string[]): number | null {
93 const title = takeTitle(link.upstream);
94 const open = listIssues(app.db, repo.id, { sealed: true, state: "open", limit: 200 }).find((i) => i.title === title);
95 if (open !== undefined) return open.number;
96 const owner = getUserById(app.db, repo.ownerId);
97 if (owner === undefined || owner.kind !== "user") return null;
98 const made = createIssue(app.db, {
99 repoId: repo.id,

Only an open issue is found here, so closing it without resolving the conflict reopens a new one on the next hourly check. Key on the parent tip you already hold, so it opens once per parent change.

100 authorId: owner.id,
101 title,
102 body: [
103 `This forge takes updates from ${link.upstream}, and they do not merge cleanly: ${files.length === 1 ? "one file" : `${files.length} files`} changed on both sides: ${files.join(", ")}.`,
104 "",
105 "Nothing on the default branch was changed. The parent's code is fetched at `refs/upstream/HEAD`; merge it into a branch, settle those files, and propose the result as a pull.",
106 ].join("\n"),
107 });
108 if (!made.ok) return null;
109 app.bus.append({ kind: "issue.opened", repoId: repo.id, actorId: owner.id, payload: { number: made.value.number } });
110 return made.value.number;
111 }
src/spawn/upstream.ts +33
@@ -20,6 +20,7 @@ import { getUserById, getUserByName } from "../core/users.ts";
2020 import { runGit } from "../git/env.ts";
2121 import { repoDir } from "../git/repos.ts";
2222 import { forgeLinkOf, originOf } from "./chain.ts";
23 import { takeFromParent, type Taken } from "./take.ts";
2324
2425 export const CHECK_MS = 60 * 60_000;
2526 export const FETCH_TIMEOUT_MS = 60_000;
@@ -34,6 +35,8 @@ export interface UpstreamState {
3435 readonly checkedAt: number;
3536 /** One line; "" when the last attempt worked. */
3637 readonly error: string;
38 /** What `take-updates` did with the parent's new commits at that check; absent when nothing. */
39 readonly taken?: Taken;
3740 /** The parent tip the owner was last told about; "" when never. */
3841 readonly toldTip: string;
3942 }
@@ -51,6 +54,7 @@ export async function upstreamState(app: App): Promise<UpstreamState | undefined
5154 parentTip: typeof d["parentTip"] === "string" && TIP.test(d["parentTip"]) ? d["parentTip"] : "",
5255 checkedAt: d["checkedAt"],
5356 error: typeof d["error"] === "string" ? d["error"].slice(0, 200) : "",
57 ...readTaken(d["taken"]),
5458 toldTip: typeof d["toldTip"] === "string" && TIP.test(d["toldTip"]) ? d["toldTip"] : "",
5559 };
5660 } catch {
@@ -58,6 +62,22 @@ export async function upstreamState(app: App): Promise<UpstreamState | undefined
5862 }
5963 }
6064
65 function readTaken(v: unknown): { taken?: Taken } {
66 if (typeof v !== "object" || v === null) return {};
67 const t = v as Record<string, unknown>;
68 if (t["kind"] === "took" && typeof t["to"] === "string" && TIP.test(t["to"]) && typeof t["commits"] === "number")
69 return { taken: { kind: "took", to: t["to"], commits: t["commits"], merged: t["merged"] === true } };
70 if (t["kind"] === "conflict" && Array.isArray(t["files"]))
71 return {
72 taken: {
73 kind: "conflict",
74 files: t["files"].filter((f): f is string => typeof f === "string").slice(0, 20).map((f) => f.slice(0, 200)),
75 issue: typeof t["issue"] === "number" ? t["issue"] : null,
76 },
77 };
78 return {};
79 }
80
6181 async function keep(app: App, s: UpstreamState): Promise<void> {
6282 try {
6383 await Bun.write(`${statePath(app)}.tmp`, JSON.stringify(s));
@@ -129,6 +149,15 @@ async function run(app: App, options: { remote?: string; now?: number }): Promis
129149 toldTip: (await upstreamState(app))?.toldTip ?? "",
130150 };
131151 }
152 // The parent is ahead and this forge takes updates: merge it or tell.
153 if (state.error === "" && state.behind !== null && state.behind > 0 && link.mode === "take-updates") {
154 const taken = await takeFromParent(app, link);
155 if (taken !== undefined) {
156 const left = await runGit(["rev-list", "--count", "HEAD..refs/upstream/HEAD"], { cwd: dir });
157 const behind = left.ok ? Number(left.value.trim()) : NaN;
158 state = { ...state, behind: Number.isSafeInteger(behind) ? behind : state.behind, taken };
159 }
160 }
132161 const repo = getRepo(app.db, o, n);
133162 // Remember the tip before the card, so a crash between them can lose a card but never repeat one.
134163 const tell = repo !== undefined && link.mode === "tell-me" && state.error === "" && state.parentTip !== "" &&
@@ -183,6 +212,10 @@ export function upstreamWords(s: UpstreamState | undefined, now = Date.now()): s
183212 const mins = Math.max(0, Math.floor((now - s.checkedAt) / 60_000));
184213 const when = mins < 1 ? "just now" : mins < 60 ? `${mins} min ago` : `${Math.floor(mins / 60)} h ago`;
185214 if (s.error !== "") return `could not check your parent: ${s.error} (${when})`;
215 if (s.taken?.kind === "took")
216 return `took ${s.taken.commits} new commit${s.taken.commits === 1 ? "" : "s"} from your parent${s.taken.merged ? " in a merge" : ""} (checked ${when})`;
217 if (s.taken?.kind === "conflict")
218 return `your parent's new code conflicts in ${s.taken.files.join(", ")}${s.taken.issue === null ? "" : `; see issue #${s.taken.issue}`} (checked ${when})`;
186219 if (s.behind === null) return `checked ${when}`;
187220 if (s.behind === 0) return `up to date (checked ${when})`;
188221 return `your parent has ${s.behind} new commit${s.behind === 1 ? "" : "s"} (checked ${when})`;
src/web/pages/chain.ts +1 −1
@@ -19,7 +19,7 @@ import { flashOrJson, page, redirect } from "../layout.ts";
1919 import { btn, card } from "../ui.ts";
2020
2121 /** The events that change what the chain page says. */
22 export const CHAIN_LIVE = "forge.severed,forge.proposal,forge.follow_mode,forge.upstream";
22 export const CHAIN_LIVE = "forge.severed,forge.proposal,forge.follow_mode,forge.upstream,forge.updates_taken";
2323
2424 const MODE_LABELS: Record<FollowMode, string> = {
2525 "tell-me": "Tell me when the parent has something new",
test/following.test.ts +1 −1
@@ -1092,7 +1092,7 @@ describe("an open chain page hears a sever", () => {
10921092 test("the page carries a live region for forge.severed, scoped to its repository", async () => {
10931093 const page = await chainPage();
10941094 expect(page).toContain('id="live-chain"');
1095 expect(page).toContain('data-live="forge.severed,forge.proposal,forge.follow_mode,forge.upstream"');
1095 expect(page).toContain('data-live="forge.severed,forge.proposal,forge.follow_mode,forge.upstream,forge.updates_taken"');
10961096 expect(page).toContain('data-live-repo="kid/forge"');
10971097 expect(page).not.toContain("follows nothing since");
10981098 });
test/spawn-take.test.ts added +137
@@ -0,0 +1,137 @@
1 /**
2 * Taking the parent's code (#2634): local bare repositories, no network.
3 * A fast-forward and a clean merge move main and say so; a conflict leaves
4 * main alone and opens one issue; `tell-me` never touches main.
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 { commitToBranch, createRepo } from "../src/actions/repos.ts";
12 import { createApp, type App } from "../src/app.ts";
13 import { listEvents } from "../src/core/events.ts";
14 import { listIssues } from "../src/core/issues.ts";
15 import type { Repo } from "../src/core/repos.ts";
16 import { createUser, type User } from "../src/core/users.ts";
17 import { runGit } from "../src/git/env.ts";
18 import { repoDir } from "../src/git/repos.ts";
19 import { checkUpstream, upstreamWords } from "../src/spawn/upstream.ts";
20 import { quietLogs } from "./helpers/globals.ts";
21
22 quietLogs();
23
24 const git = async (cwd: string, ...args: string[]) => {
25 const r = await runGit(["-c", "user.name=t", "-c", "[email protected]", ...args], { cwd });
26 if (!r.ok) throw new Error(r.error.stderr);
27 return r.value.trim();
28 };
29
30 describe("taking the parent's code", () => {
31 let dir = "";
32 let app: App;
33 let kid: User;
34 let repo: Repo;
35 let child = "";
36 let parent = "";
37 let work = "";
38
39 const setMode = (mode: string) =>
40 Bun.write(
41 join(app.config.dataDir, "parent.json"),
42 JSON.stringify({
43 upstream: "https://parent.test",
44 forgeCode: { repo: "kid/forge", upstream: "root/forge", mode, tip: "a".repeat(40), chain: [] },
45 }),
46 );
47 const mine = async (path: string, content: string) => {
48 const r = await commitToBranch(app, kid, repo, { message: `kid: ${path}`, files: [{ path, content }] });
49 if (!r.ok) throw new Error(JSON.stringify(r.error));
50 };
51 const theirs = async (path: string, content: string) => {
52 await Bun.write(join(work, path), content);
53 await git(work, "add", ".");
54 await git(work, "commit", "-m", `parent: ${path}`);
55 await git(work, "push", "-q", parent, "HEAD:refs/heads/main");
56 };
57 const main = () => git(child, "rev-parse", "refs/heads/main");
58 const taken = () => listEvents(app.db, { repoId: repo.id, kind: "forge.updates_taken" });
59
60 beforeAll(async () => {
61 dir = await mkdtemp(join(tmpdir(), "forge-take-"));
62 app = createApp({ dataDir: join(dir, "data"), url: "https://child.test", port: 0 });
63 const u = await createUser(app.db, {
64 origin: "seed",
65 trusted: true,
66 username: "kid",
67 email: "[email protected]",
68 password: "a-good-passphrase",
69 adminIfFirst: true,
70 });
71 if (!u.ok) throw new Error(u.error);
72 kid = u.value;
73 const made = await createRepo(app, kid, { name: "forge", visibility: "public" });
74 if (!made.ok) throw new Error(String(made.error));
75 repo = made.value;
76 child = repoDir(app.reposRoot, "kid", "forge");
77 await mine("a.txt", "base\n");
78 parent = join(dir, "parent.git");
79 work = join(dir, "work");
80 await git(dir, "clone", "-q", "--bare", child, parent);
81 await git(dir, "clone", "-q", parent, work);
82 await setMode("take-updates");
83 }, 30_000);
84 afterAll(async () => {
85 app.close();
86 await rm(dir, { recursive: true, force: true });
87 });
88
89 test("tell-me fetches and changes nothing on main", async () => {
90 await setMode("tell-me");
91 const before = await main();
92 await theirs("p0.txt", "0\n");
93 const s = await checkUpstream(app, { remote: parent });
94 expect(s?.behind).toBe(1);
95 expect(s?.taken).toBeUndefined();
96 expect(await main()).toBe(before);
97 await setMode("take-updates");
98 });
99
100 test("a fast-forward is taken and recorded", async () => {
101 const s = await checkUpstream(app, { remote: parent });
102 expect(s?.taken).toMatchObject({ kind: "took", commits: 1, merged: false });
103 expect(s?.behind).toBe(0);
104 expect(await main()).toBe(await git(parent, "rev-parse", "refs/heads/main"));
105 expect(taken()).toHaveLength(1);
106 expect(upstreamWords(s, s!.checkedAt)).toContain("took 1 new commit from your parent");
107 });
108
109 test("a clean merge makes a merge commit", async () => {
110 await mine("mine.txt", "mine\n");
111 await theirs("p1.txt", "1\n");
112 const before = await main();
113 const s = await checkUpstream(app, { remote: parent });
114 expect(s?.taken).toMatchObject({ kind: "took", commits: 1, merged: true });
115 expect(await main()).not.toBe(before);
116 expect((await git(child, "rev-list", "--parents", "-n1", "refs/heads/main")).split(" ")).toHaveLength(3);
117 expect(await git(child, "show", "refs/heads/main:p1.txt")).toBe("1");
118 expect(await git(child, "show", "refs/heads/main:mine.txt")).toBe("mine");
119 expect(taken()).toHaveLength(2);
120 });
121
122 test("a conflict changes nothing and opens one issue", async () => {
123 await mine("a.txt", "kid's\n");
124 await theirs("a.txt", "parent's\n");
125 const before = await main();
126 const s = await checkUpstream(app, { remote: parent });
127 expect(s?.taken).toMatchObject({ kind: "conflict", files: ["a.txt"] });
128 expect(await main()).toBe(before);
129 await checkUpstream(app, { remote: parent });
130 expect(await main()).toBe(before);
131 const issues = listIssues(app.db, repo.id, { sealed: true }).filter((i) => i.title === "Take updates from root/forge");
132 expect(issues).toHaveLength(1);
133 expect(issues[0]!.body).toContain("a.txt");
134 expect(taken()).toHaveLength(2);
135 });
136
137 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs