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
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/12108-an-origin-you-connect-is-followed-like-a → main 12 commits fork 4 threads · 4 unresolved
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"; | ||
| 21 | 21 | import { severUpstream } from "./upstream_follow.ts"; |
| 22 | 22 | import { takeFromParent, type Taken } from "../spawn/take.ts"; |
| 23 | 23 | 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">; | |
| 24 | 31 | |
| 25 | 32 | export type TakeOrTell = Result<ForkSync & { merged: boolean }, TakeUpdatesError | Conflict>; |
| 26 | 33 | |
| @@ -40,8 +47,8 @@ export async function takeOrTell( | ||
| 40 | 47 | const took = await takeUpdates(app, actor, fork); |
| 41 | 48 | if (took.ok || typeof took.error === "string" || took.error.kind !== "conflict") return took; |
| 42 | 49 | 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; | |
| 45 | 52 | return err({ kind: "conflict", files: took.error.files, converge }); |
| 46 | 53 | } |
| 47 | 54 | |
| @@ -253,7 +260,7 @@ export function convergeWords( | ||
| 253 | 260 | * only of a parent that is itself up to date: a frozen one may be far |
| 254 | 261 | * behind its own upstream, and the take only knows the frozen copy. |
| 255 | 262 | */ |
| 256 | export function nothingNew(parent: Repo): string { | |
| 263 | export function nothingNew(parent: Followed): string { | |
| 257 | 264 | const name = `${parent.ownerName}/${parent.name}`; |
| 258 | 265 | return parent.detached === null |
| 259 | 266 | ? `Already up to date with ${name}.` |
| @@ -267,7 +274,7 @@ export function nothingNew(parent: Repo): string { | ||
| 267 | 274 | export function takeWords( |
| 268 | 275 | took: TakeOrTell, |
| 269 | 276 | fork: Repo, |
| 270 | parent: Repo, | |
| 277 | parent: Followed, | |
| 271 | 278 | ): { tone?: "danger"; message: string } { |
| 272 | 279 | const parentName = `${parent.ownerName}/${parent.name}`; |
| 273 | 280 | if (took.ok) |
src/actions/forks.ts +4 −2
| @@ -26,8 +26,9 @@ import { runGit } from "../git/env.ts"; | ||
| 26 | 26 | import { isSafeRef } from "../git/guard.ts"; |
| 27 | 27 | import { resolveOid } from "../git/merge.ts"; |
| 28 | 28 | import { repoDir } from "../git/repos.ts"; |
| 29 | import { err, type Result } from "../kernel/result.ts"; | |
| 29 | import type { Result } from "../kernel/result.ts"; | |
| 30 | 30 | import { requireWriter, type AccessError } from "./access.ts"; |
| 31 | import { takeFromFollowed } from "./upstream_sync.ts"; | |
| 31 | 32 | |
| 32 | 33 | export type { ForkRelation, ForkSync, TakeError } from "../core/following.ts"; |
| 33 | 34 | |
| @@ -66,7 +67,8 @@ export async function takeUpdates( | ||
| 66 | 67 | const writer = requireWriter(app, actor, fork); |
| 67 | 68 | if (!writer.ok) return writer; |
| 68 | 69 | 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); | |
| 70 | 72 | const who = writer.value; |
| 71 | 73 | return take(app, fork, parent, { |
| 72 | 74 | id: who.id, |
src/actions/upstream_follow.ts +91 −32
| @@ -20,8 +20,8 @@ | ||
| 20 | 20 | |
| 21 | 21 | import type { Database } from "bun:sqlite"; |
| 22 | 22 | 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"; | |
| 25 | 25 | import { listRuns } from "../core/runs.ts"; |
| 26 | 26 | import { queueAgentRun } from "../crew/dispatch.ts"; |
| 27 | 27 | import { pickBuilder } from "../crew/route.ts"; |
| @@ -75,6 +75,8 @@ import { err, ok, type Result } from "../kernel/result.ts"; | ||
| 75 | 75 | import { providerForOrigin } from "../origins/credential.ts"; |
| 76 | 76 | import { basicHeader, gitAuthEnv, redact, secretsIn } from "../origins/credentials.ts"; |
| 77 | 77 | import { vettedEnv } from "../origins/git-mirror.ts"; |
| 78 | import { forkFollowMode } from "../core/fork_follow.ts"; | |
| 79 | import { settleOntoFollowed } from "../core/upstream_settle.ts"; | |
| 78 | 80 | import { openIssue } from "./issues.ts"; |
| 79 | 81 | import { migrateUrl } from "./migrate.ts"; |
| 80 | 82 | import { openPull } from "./pulls.ts"; |
| @@ -217,7 +219,16 @@ const conflictTitle = (tip: string): string => `${UPSTREAM_TITLE}conflict at ${t | ||
| 217 | 219 | * check of the same head finds the issue and starts nothing; autonomy off |
| 218 | 220 | * leaves the conflict noted on the row alone. |
| 219 | 221 | */ |
| 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( | |
| 221 | 232 | app: App, |
| 222 | 233 | checked: Repo, |
| 223 | 234 | by: User, |
| @@ -225,19 +236,24 @@ function convergeConflict( | ||
| 225 | 236 | tip: string, |
| 226 | 237 | files: readonly string[], |
| 227 | 238 | 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 { | |
| 229 | 242 | // The setting as it is now, not as the caller read it before the fetch. |
| 230 | 243 | 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; | |
| 232 | 245 | // 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; | |
| 234 | 247 | const title = conflictTitle(tip); |
| 235 | 248 | 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", | |
| 238 | 251 | ) |
| 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 | } | |
| 241 | 257 | const opened = openIssue(app, by, repo, { |
| 242 | 258 | title, |
| 243 | 259 | body: [ |
| @@ -249,7 +265,7 @@ function convergeConflict( | ||
| 249 | 265 | RESOLUTION_RULE, |
| 250 | 266 | ].join("\n"), |
| 251 | 267 | }); |
| 252 | if (!opened.ok) return; | |
| 268 | if (!opened.ok) return undefined; | |
| 253 | 269 | let issue = opened.value; |
| 254 | 270 | if (source !== undefined) recordConflict(app.db, issue.id, { sourceId: source.id, ...source }); |
| 255 | 271 | const inFlight = new Map<string, number>(); |
| @@ -260,14 +276,15 @@ function convergeConflict( | ||
| 260 | 276 | .filter((c) => c.role === "builder") |
| 261 | 277 | .map((c) => c.agent); |
| 262 | 278 | const agent = pickBuilder(issue, builders, inFlight); |
| 263 | if (agent === undefined) return; | |
| 279 | if (agent === undefined) return { issue, agent: undefined }; | |
| 264 | 280 | // Assigned without the assignment event, which would start an ordinary run. |
| 265 | 281 | 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, { | |
| 267 | 283 | notBefore: new Date().toISOString(), |
| 268 | 284 | attempt: 0, |
| 269 | 285 | summary: `upstream moved: ${files.join(", ")}`, |
| 270 | 286 | }); |
| 287 | return { issue, agent }; | |
| 271 | 288 | } |
| 272 | 289 | |
| 273 | 290 | /** What the crew's brief says about the source a conflict issue was raised for (#9003). */ |
| @@ -321,7 +338,7 @@ export async function carriesUpstream( | ||
| 321 | 338 | } |
| 322 | 339 | |
| 323 | 340 | /** 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 { | |
| 325 | 342 | readonly url: string; |
| 326 | 343 | readonly branch: string; |
| 327 | 344 | readonly originId: string; |
| @@ -342,18 +359,21 @@ interface Target { | ||
| 342 | 359 | readonly repoRowId?: string; |
| 343 | 360 | } |
| 344 | 361 | |
| 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 | ||
| 345 | 372 | async function checkOnce(app: App, repo: Repo): Promise<Result<Checked, CheckError>> { |
| 346 | 373 | const follow = getFollow(app.db, repo.id); |
| 347 | 374 | if (follow === undefined || !follow.enabled || repo.archivedAt !== null || repo.origin !== null) |
| 348 | 375 | 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)); | |
| 357 | 377 | } |
| 358 | 378 | |
| 359 | 379 | /** 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 | ||
| 378 | 398 | } |
| 379 | 399 | } |
| 380 | 400 | |
| 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 }); | |
| 388 | 424 | let by = getUserById(app.db, follow.byId); |
| 389 | 425 | // A link the parent sent acts as whoever owns the repository now: the spawner may have handed it over. |
| 390 | 426 | 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 | ||
| 448 | 484 | return failed("unreadable", `The upstream could not be fetched: ${said(fetched.stderr) || "no reason given"}`); |
| 449 | 485 | storageStale(app, repo.ownerId); |
| 450 | 486 | } |
| 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; | |
| 453 | 502 | // A packs mirror is a copy, not a fork: its default branch is the upstream's, moved, never proposed. |
| 454 | 503 | if (follow.sourceId === undefined && isPacksMirror(repo)) { |
| 455 | 504 | if ( |
| @@ -460,8 +509,18 @@ async function checkTarget(app: App, repo: Repo, initial: Target): Promise<Resul | ||
| 460 | 509 | follow.note({ at, tip: staged, behind: 0, error: "" }); |
| 461 | 510 | return ok({ tip: staged, behind: 0, outcome: "none" }); |
| 462 | 511 | } |
| 463 | const main = await resolveOid(dir, `refs/heads/${repo.defaultBranch}`); | |
| 512 | let main = await resolveOid(dir, `refs/heads/${repo.defaultBranch}`); | |
| 464 | 513 | 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 | } | |
| 465 | 524 | const behind = await aheadCount(dir, main, staged); |
| 466 | 525 | // What this repository holds that the upstream does not: told on the page, never acted on. |
| 467 | 526 | const ahead = await aheadCount(dir, staged, main); |
src/actions/upstream_sync.ts added +140
| @@ -0,0 +1,140 @@ | ||
| 1 | /** | |
| 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"; | ||
| 9 | 9 | import { FOLLOW_MODE_REFUSALS, setFollowMode, severForge, takeForgeCode, takeForgeWords } from "../../actions/follow.ts"; |
| 10 | 10 | import { relationOf } from "../../actions/forks.ts"; |
| 11 | 11 | import { forgeProposals, proposeToParentForge, proposeUpstream } from "../../actions/upstream.ts"; |
| 12 | import { convergeFollowed } from "../../actions/upstream_sync.ts"; | |
| 12 | 13 | import { parentForgeFor } from "../../core/parent_releases.ts"; |
| 13 | 14 | import { forkParentFor, getRepo } from "../../core/repos.ts"; |
| 14 | 15 | import { notePhase, takeSessionTime, timePhase, timePhaseAsync } from "../../core/route_timing.ts"; |
| @@ -228,9 +229,12 @@ export function followingRoutes(router: Router<Handler>): void { | ||
| 228 | 229 | if (!mayDirectCrew(ctx.app.db, repo, ctx.auth.user)) |
| 229 | 230 | return respondAccess("forbidden", 403); |
| 230 | 231 | 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") | |
| 232 | 237 | return apiError("not_a_fork", 404, "this repository follows nothing"); |
| 233 | const outcome = await convergeFork(ctx.app, repo, parent, ctx.auth.user); | |
| 234 | 238 | if (outcome.kind === "archived") return respondAccess("archived", 409); |
| 235 | 239 | const status = |
| 236 | 240 | outcome.kind === "queued" |
src/http/mcp/tools/converge.ts +4 −3
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | import { NOT_A_MEMBER } from "../../../core/roles.ts"; |
| 2 | 2 | import { ARCHIVED_WHY, forkParentFor } from "../../../core/repos.ts"; |
| 3 | 3 | import { convergeFork } from "../../../crew/converge.ts"; |
| 4 | import { convergeFollowed } from "../../../actions/upstream_sync.ts"; | |
| 4 | 5 | import { DIRECTION_NOTE } from "../../../crew/direct.ts"; |
| 5 | 6 | import { good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts"; |
| 6 | 7 | |
| @@ -19,9 +20,9 @@ export const tool: Tool = { | ||
| 19 | 20 | const repo = repoArg(ctx, user, args); |
| 20 | 21 | if (isAnswer(repo)) return repo; |
| 21 | 22 | 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"); | |
| 25 | 26 | if (outcome.kind === "forbidden") return bad("forbidden", DIRECTION_NOTE); |
| 26 | 27 | if (outcome.kind === "not_member") return bad("not_member", NOT_A_MEMBER); |
| 27 | 28 | if (outcome.kind === "archived") return bad("archived", ARCHIVED_WHY); |
src/tui/screens/forks.ts +48 −3
| @@ -6,7 +6,10 @@ | ||
| 6 | 6 | * leads, read as MCP `following` reads it (`relationOf`, `convergeState`). |
| 7 | 7 | * On a fork behind its parent, a person who writes it takes the parent's |
| 8 | 8 | * 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`). | |
| 10 | 13 | */ |
| 11 | 14 | |
| 12 | 15 | import { |
| @@ -20,6 +23,7 @@ import { | ||
| 20 | 23 | } from "../../actions/forks.ts"; |
| 21 | 24 | import { followModeWords } from "../../actions/follow_words.ts"; |
| 22 | 25 | 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"; | |
| 23 | 27 | import { FOLLOWS_NOTHING, proposeRefusalWords } from "../../actions/follow_words.ts"; |
| 24 | 28 | import { openProposal, proposeUpstream } from "../../actions/upstream.ts"; |
| 25 | 29 | import type { Pull } from "../../core/pulls.ts"; |
| @@ -144,6 +148,8 @@ export function forksScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 144 | 148 | let following: ForkRow[] = []; |
| 145 | 149 | let relation: ForkRelation | undefined; |
| 146 | 150 | let offer: string | undefined; |
| 151 | /** What the repository follows by address, when it has no parent here. */ | |
| 152 | let followed: ReturnType<typeof followedAs> | undefined; | |
| 147 | 153 | let open: Pull | undefined; |
| 148 | 154 | let mode: ForkFollowMode | undefined; |
| 149 | 155 | /** 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 { | ||
| 196 | 202 | tree = forkTree(f.db, repo.id, f.user); |
| 197 | 203 | relation = await relationOf(f.app, f.user, repo); |
| 198 | 204 | 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; | |
| 200 | 213 | const converge = |
| 201 | 214 | relation !== undefined && relation.state === "diverged" && !relation.clean |
| 202 | 215 | ? convergeState(f.app, repo, relation.parent) |
| @@ -207,7 +220,7 @@ export function forksScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 207 | 220 | ctx.invalidate(); |
| 208 | 221 | }; |
| 209 | 222 | const take = (): void => { |
| 210 | const parent = relation?.parent; | |
| 223 | const parent = relation?.parent ?? followed; | |
| 211 | 224 | if (offer === undefined || parent === undefined) return; |
| 212 | 225 | ctx.push( |
| 213 | 226 | confirmScreen(ctx, self, { |
| @@ -234,6 +247,33 @@ export function forksScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 234 | 247 | }), |
| 235 | 248 | ); |
| 236 | 249 | }; |
| 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 | }; | |
| 237 | 277 | const canSetMode = (): boolean => |
| 238 | 278 | relation !== undefined && f.user?.kind === "user" && canAdmin(f.db, repo, f.user) && canWrite(f.db, repo, f.user); |
| 239 | 279 | const self: Screen = { |
| @@ -243,6 +283,7 @@ export function forksScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 243 | 283 | binding(["j", "k"], "move"), |
| 244 | 284 | binding(["enter"], "open"), |
| 245 | 285 | ...(offer === undefined ? [] : [binding(["t"], "take updates")]), |
| 286 | ...(mayConverge() ? [binding(["c"], "converge")] : []), | |
| 246 | 287 | ...(relation !== undefined && mayPropose() |
| 247 | 288 | ? [binding(["u"], open === undefined ? "propose upstream" : "open proposal")] |
| 248 | 289 | : []), |
| @@ -303,6 +344,10 @@ export function forksScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 303 | 344 | take(); |
| 304 | 345 | return true; |
| 305 | 346 | } |
| 347 | if (e.key === "c" && mayConverge()) { | |
| 348 | converge(); | |
| 349 | return true; | |
| 350 | } | |
| 306 | 351 | if (e.key === "m" && canSetMode()) { |
| 307 | 352 | runAsync(ctx, async () => { |
| 308 | 353 | // `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"; | ||
| 43 | 43 | import { convergeWords, followModeOf, takeOrTell, takeWords } from "../../actions/follow.ts"; |
| 44 | 44 | import { FOLLOW_WORD, FOLLOWS_NOTHING, proposeRefusalWords } from "../../actions/follow_words.ts"; |
| 45 | 45 | import { openProposal, proposeUpstream } from "../../actions/upstream.ts"; |
| 46 | import { convergeFollowed, followedAs, followedOf } from "../../actions/upstream_sync.ts"; | |
| 46 | 47 | import { proposesViaFork } from "../../origins/fork-route.ts"; |
| 47 | 48 | import { field, readForm } from "../form.ts"; |
| 48 | 49 | import { html, when, type Html } from "../html.ts"; |
| @@ -428,12 +429,15 @@ export async function followingSubmit(ctx: Ctx): Promise<Response> { | ||
| 428 | 429 | if (form instanceof Response) return form; |
| 429 | 430 | const base = repoBase(repo); |
| 430 | 431 | 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) | |
| 432 | 436 | return flashOrJson(ctx, base, { |
| 433 | 437 | tone: "danger", |
| 434 | 438 | message: FOLLOWS_NOTHING, |
| 435 | 439 | }); |
| 436 | const parentName = `${parent.ownerName}/${parent.name}`; | |
| 440 | const parentName = `${named.ownerName}/${named.name}`; | |
| 437 | 441 | |
| 438 | 442 | if (field(form, "do") === "converge") { |
| 439 | 443 | if (!canWrite(ctx.app.db, repo, ctx.auth.user)) |
| @@ -441,9 +445,18 @@ export async function followingSubmit(ctx: Ctx): Promise<Response> { | ||
| 441 | 445 | tone: "danger", |
| 442 | 446 | message: "Only a writer can ask the crew to converge.", |
| 443 | 447 | }); |
| 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 }); | |
| 445 | 453 | return flashOrJson(ctx, base, convergeWords(outcome, parentName)); |
| 446 | 454 | } |
| 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 | } | |
| 447 | 460 | if (field(form, "do") === "propose") { |
| 448 | 461 | const proposed = await proposeUpstream(ctx.app, ctx.auth.user, repo); |
| 449 | 462 | if (!proposed.ok) { |
src/web/pages/upstream-follow.ts +11
| @@ -205,6 +205,17 @@ export function followUpstreamCard(ctx: Ctx, repo: Repo): Html | null { | ||
| 205 | 205 | ${btn("Sever", { size: "sm", variant: "outline" })} |
| 206 | 206 | </form>`, |
| 207 | 207 | )} |
| 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 | )} | |
| 208 | 219 | </section>`; |
| 209 | 220 | } |
| 210 | 221 |
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"; | ||
| 11 | 11 | import { checkUpstream } from "../src/actions/upstream_follow.ts"; |
| 12 | 12 | import { listEvents } from "../src/core/events.ts"; |
| 13 | 13 | import { getRepo } from "../src/core/repos.ts"; |
| 14 | import { createUser } from "../src/core/users.ts"; | |
| 14 | import { createUser, getUserByName } from "../src/core/users.ts"; | |
| 15 | 15 | import { serve, type Servers } from "../src/http/server.ts"; |
| 16 | 16 | |
| 17 | 17 | let dataDir = ""; |
| @@ -162,6 +162,43 @@ describe("the pages", () => { | ||
| 162 | 162 | expect((await read(alice)).following?.branch).toBe("dev"); |
| 163 | 163 | }); |
| 164 | 164 | |
| 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 | ||
| 165 | 202 | test("the check post is an admin's and same-origin, and says what it found", async () => { |
| 166 | 203 | const check = (cookie: string, origin: string) => |
| 167 | 204 | 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 | }); |
new above
offline
proposeToFollowedhas 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).