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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/234-a-second-commit-to-a-change-becomes-its → main 1 commit fork
5 files
changed +136
−1
src/actions/contents.ts +6 −1
| @@ -15,7 +15,7 @@ | ||
| 15 | 15 | |
| 16 | 16 | import type { App } from "../app.ts"; |
| 17 | 17 | 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"; | |
| 19 | 19 | import type { User } from "../core/users.ts"; |
| 20 | 20 | import { isSafeTreePath } from "../git/guard.ts"; |
| 21 | 21 | import { repoDir } from "../git/repos.ts"; |
| @@ -215,6 +215,11 @@ export async function editFiles( | ||
| 215 | 215 | return committed; |
| 216 | 216 | } |
| 217 | 217 | |
| 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 | ||
| 218 | 223 | // `update-ref` runs no hooks, so the push is announced here — webhooks, |
| 219 | 224 | // SSE and CI see it exactly as they would a `git push`. |
| 220 | 225 | app.bus.append({ |
src/actions/repos.ts +5
| @@ -39,6 +39,7 @@ import { | ||
| 39 | 39 | type FileWrite, |
| 40 | 40 | } from "../git/write.ts"; |
| 41 | 41 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 42 | import { trackPullHead } from "../core/pulls.ts"; | |
| 42 | 43 | import { |
| 43 | 44 | requireParticipant, |
| 44 | 45 | requireRepoAdmin, |
| @@ -340,6 +341,10 @@ export async function commitToBranch( | ||
| 340 | 341 | }, |
| 341 | 342 | ); |
| 342 | 343 | 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); | |
| 343 | 348 | app.bus.append({ |
| 344 | 349 | kind: "push", |
| 345 | 350 | repoId: repo.id, |
src/core/pulls.ts +31
| @@ -206,6 +206,37 @@ export function pullForBranch( | ||
| 206 | 206 | return row ? getPull(db, repoId, row.number) : undefined; |
| 207 | 207 | } |
| 208 | 208 | |
| 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 | ||
| 209 | 240 | /** |
| 210 | 241 | * The pull that already reverts this one, if taking it back has already |
| 211 | 242 | * been done — open, or merged, either one counts as "already taken back". |
src/http/server.ts +16
| @@ -6,6 +6,7 @@ | ||
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | 8 | import { canRead, canWrite, getRepo, getRepoById } from "../core/repos.ts"; |
| 9 | import { trackPullHead } from "../core/pulls.ts"; | |
| 9 | 10 | import { createRepo } from "../actions/repos.ts"; |
| 10 | 11 | import { sessionUser } from "../core/sessions.ts"; |
| 11 | 12 | import { scopeAllows, verifyToken } from "../core/tokens.ts"; |
| @@ -1039,6 +1040,21 @@ async function handleHook(app: App, request: Request): Promise<Response> { | ||
| 1039 | 1040 | return new Response(""); |
| 1040 | 1041 | } |
| 1041 | 1042 | |
| 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 | ||
| 1042 | 1058 | // post-receive: the push is durable — record it and wake listeners. |
| 1043 | 1059 | app.bus.append({ |
| 1044 | 1060 | kind: "push", |
test/web-editor.test.ts +78
| @@ -19,12 +19,16 @@ import { tmpdir } from "node:os"; | ||
| 19 | 19 | import { join } from "node:path"; |
| 20 | 20 | import { createApp, type App } from "../src/app.ts"; |
| 21 | 21 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; |
| 22 | import { enableApp } from "../src/actions/apps.ts"; | |
| 22 | 23 | import { createToken, signUp } from "../src/actions/users.ts"; |
| 23 | 24 | import { createSession } from "../src/core/sessions.ts"; |
| 24 | 25 | import { getRepo, updateRepoSettings } from "../src/core/repos.ts"; |
| 25 | 26 | import type { User } from "../src/core/users.ts"; |
| 26 | 27 | import { listIssues } from "../src/core/issues.ts"; |
| 28 | import { getPull } from "../src/core/pulls.ts"; | |
| 29 | import { recordLook } from "../src/core/looks.ts"; | |
| 27 | 30 | import { listRefs } from "../src/git/read.ts"; |
| 31 | import { resolveOid } from "../src/git/merge.ts"; | |
| 28 | 32 | import { repoDir } from "../src/git/repos.ts"; |
| 29 | 33 | import { serve, type Servers } from "../src/http/server.ts"; |
| 30 | 34 | import { setLogLevel } from "../src/kernel/log.ts"; |
| @@ -259,6 +263,80 @@ describe("editing files on the web", () => { | ||
| 259 | 263 | expect(page).toContain("Because fog."); |
| 260 | 264 | }); |
| 261 | 265 | |
| 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 | ||
| 262 | 340 | test("a new file arrives with the path the person typed", async () => { |
| 263 | 341 | const res = await post( |
| 264 | 342 | "/alice/beacon/edit", |
new above
offline