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

A second commit to a change becomes its head (take two, from the branch that exists) #252

Merged opened this

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

Merged

scout/forge:scout/234-a-second-commit-to-a-change-becomes-its → main 1 commit fork

  • Merged. scout/234-a-second-commit-to-a-change-becomes-its was merged into main as 3f4ae2d.

Review

Sign in to review this change.
5 files changed +136 −1
src/actions/contents.ts +6 −1
@@ -15,7 +15,7 @@
1515
1616 import type { App } from "../app.ts";
1717 import { canAdmin, type Repo } from "../core/repos.ts";
18 import type { Pull } from "../core/pulls.ts";
18 import { trackPullHead, type Pull } from "../core/pulls.ts";
1919 import type { User } from "../core/users.ts";
2020 import { isSafeTreePath } from "../git/guard.ts";
2121 import { repoDir } from "../git/repos.ts";
@@ -215,6 +215,11 @@ export async function editFiles(
215215 return committed;
216216 }
217217
218 // A same-repo pull whose head is this branch follows the commit before
219 // anything hears about it, so a check or a deploy triggered below never
220 // reads a row the branch has already moved past.
221 trackPullHead(app.db, repo.id, branch, committed.value.commitOid);
222
218223 // `update-ref` runs no hooks, so the push is announced here — webhooks,
219224 // SSE and CI see it exactly as they would a `git push`.
220225 app.bus.append({
src/actions/repos.ts +5
@@ -39,6 +39,7 @@ import {
3939 type FileWrite,
4040 } from "../git/write.ts";
4141 import { err, ok, type Result } from "../kernel/result.ts";
42 import { trackPullHead } from "../core/pulls.ts";
4243 import {
4344 requireParticipant,
4445 requireRepoAdmin,
@@ -340,6 +341,10 @@ export async function commitToBranch(
340341 },
341342 );
342343 if (!committed.ok) return committed;
344 // A same-repo pull whose head is this branch follows the commit before
345 // anything hears about it, so a check or a deploy triggered below never
346 // reads a row the branch has already moved past.
347 trackPullHead(app.db, repo.id, branch, committed.value.commitOid);
343348 app.bus.append({
344349 kind: "push",
345350 repoId: repo.id,
src/core/pulls.ts +31
@@ -206,6 +206,37 @@ export function pullForBranch(
206206 return row ? getPull(db, repoId, row.number) : undefined;
207207 }
208208
209 /**
210 * Bring a same-repo pull's stored head up to its branch's new tip. Every seam
211 * that lands a commit on a branch — a git push, the web editor, the API —
212 * calls this one function, so `pull.headOid` never names a commit the branch
213 * has moved past. The cross-repo counterpart is `refreshPullHead`
214 * (`core/following.ts`), which fetches from another repo; here the commit is
215 * already local, so this is a plain write. Called before the landing is
216 * announced on the bus, so no listener — a check, a deploy — can read the
217 * row before it catches up.
218 */
219 export function trackPullHead(
220 db: Database,
221 repoId: string,
222 branch: string,
223 newOid: string,
224 ): void {
225 const pull = pullForBranch(db, repoId, branch);
226 if (
227 pull === undefined ||
228 pull.crossRepo ||
229 pull.mergedAt !== null ||
230 pull.state === "closed" ||
231 pull.headOid === newOid
232 )
233 return;
234 db.query("UPDATE pull SET head_oid = ?2 WHERE issue_id = ?1").run(
235 pull.id,
236 newOid,
237 );
238 }
239
209240 /**
210241 * The pull that already reverts this one, if taking it back has already
211242 * been done — open, or merged, either one counts as "already taken back".
src/http/server.ts +16
@@ -6,6 +6,7 @@
66 */
77
88 import { canRead, canWrite, getRepo, getRepoById } from "../core/repos.ts";
9 import { trackPullHead } from "../core/pulls.ts";
910 import { createRepo } from "../actions/repos.ts";
1011 import { sessionUser } from "../core/sessions.ts";
1112 import { scopeAllows, verifyToken } from "../core/tokens.ts";
@@ -1039,6 +1040,21 @@ async function handleHook(app: App, request: Request): Promise<Response> {
10391040 return new Response("");
10401041 }
10411042
1043 // post-receive: a same-repo pull whose head is one of these branches
1044 // follows the push before anything hears about it — the same call the web
1045 // editor and the API make, so a real `git push` lands on the identical
1046 // updater rather than a second one that could drift from it.
1047 for (const update of input.updates) {
1048 if (!update.ref.startsWith("refs/heads/") || ZERO_OID.test(update.newOid))
1049 continue;
1050 trackPullHead(
1051 app.db,
1052 repo.id,
1053 update.ref.slice("refs/heads/".length),
1054 update.newOid,
1055 );
1056 }
1057
10421058 // post-receive: the push is durable — record it and wake listeners.
10431059 app.bus.append({
10441060 kind: "push",
test/web-editor.test.ts +78
@@ -19,12 +19,16 @@ import { tmpdir } from "node:os";
1919 import { join } from "node:path";
2020 import { createApp, type App } from "../src/app.ts";
2121 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
22 import { enableApp } from "../src/actions/apps.ts";
2223 import { createToken, signUp } from "../src/actions/users.ts";
2324 import { createSession } from "../src/core/sessions.ts";
2425 import { getRepo, updateRepoSettings } from "../src/core/repos.ts";
2526 import type { User } from "../src/core/users.ts";
2627 import { listIssues } from "../src/core/issues.ts";
28 import { getPull } from "../src/core/pulls.ts";
29 import { recordLook } from "../src/core/looks.ts";
2730 import { listRefs } from "../src/git/read.ts";
31 import { resolveOid } from "../src/git/merge.ts";
2832 import { repoDir } from "../src/git/repos.ts";
2933 import { serve, type Servers } from "../src/http/server.ts";
3034 import { setLogLevel } from "../src/kernel/log.ts";
@@ -259,6 +263,80 @@ describe("editing files on the web", () => {
259263 expect(page).toContain("Because fog.");
260264 });
261265
266 test("a second commit to a change's branch, through the editor, becomes its head", async () => {
267 const made = await createRepo(app, aliceUser, {
268 name: "lighthouse",
269 visibility: "public",
270 });
271 if (!made.ok) throw new Error("repo failed");
272 const repo = made.value;
273 await commitToBranch(app, aliceUser, repo, {
274 message: "first",
275 files: [{ path: "README.md", content: "# lighthouse\n" }],
276 });
277 const enabled = enableApp(app, aliceUser, repo, { slug: "lighthouse" });
278 if (!enabled.ok) throw new Error("enable failed");
279
280 const opened = await post(
281 "/alice/lighthouse/edit",
282 {
283 ref: "main",
284 path: "README.md",
285 content: "# lighthouse\n\nfirst edit\n",
286 message: "First edit",
287 description: "",
288 target: "new",
289 branch: "alice/edit-readme",
290 pull: "1",
291 pullTitle: "Edit the readme",
292 pullBody: "",
293 },
294 { cookie: alice },
295 );
296 expect(opened.status).toBe(303);
297 const to = opened.headers.get("location") ?? "";
298 const number = Number(to.split("/").pop());
299
300 // The most ordinary act there is: fixing something and committing again,
301 // on the same branch the first commit opened the change on.
302 const again = await post(
303 "/alice/lighthouse/edit",
304 {
305 ref: "alice/edit-readme",
306 path: "README.md",
307 content: "# lighthouse\n\nsecond edit\n",
308 message: "Second edit",
309 description: "",
310 target: "current",
311 branch: "",
312 },
313 { cookie: alice },
314 );
315 expect(again.status).toBe(303);
316
317 const tip = await resolveOid(
318 repoDir(app.reposRoot, repo.ownerName, repo.name),
319 "refs/heads/alice/edit-readme",
320 );
321 if (tip === undefined) throw new Error("branch has no tip");
322
323 // The pull's stored head is the second commit, not the first.
324 const pull = getPull(app.db, repo.id, number)!;
325 expect(pull.headOid).toBe(tip);
326
327 // A look taken of the true, current head is not called stale.
328 recordLook(app.db, {
329 appId: enabled.value.id,
330 env: `pull-${number}`,
331 commitOid: tip,
332 kind: "phone",
333 path: "does/not/exist.png",
334 });
335 const page = await text(to, alice);
336 expect(page).toContain(tip.slice(0, 7));
337 expect(page).not.toContain("the head has moved");
338 });
339
262340 test("a new file arrives with the path the person typed", async () => {
263341 const res = await post(
264342 "/alice/beacon/edit",

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs