diff --git a/apps/api/schema.sql b/apps/api/schema.sql
index 3950a2d..dfbc96e 100644
--- a/apps/api/schema.sql
+++ b/apps/api/schema.sql
@@ -262,9 +262,30 @@ create table if not exists public.lesson_pull_requests (
resolved_at timestamptz,
-- Recorded so an admin can later ban the address, as for lessons and comments.
author_ip text,
- created_at timestamptz not null default now()
+ created_at timestamptz not null default now(),
+ -- The branch of the fork this was proposed from, for display. A proposer can
+ -- work on a variation of their fork (see /web-app/lesson-variations) and offer
+ -- that, so "which one is this?" is a question the queue has to be able to
+ -- answer. Null on a proposal opened before this, and on one from the default
+ -- branch, where naming it would say nothing.
+ head_ref text,
+ -- A proposal can be updated while it is open, and each upload is a revision.
+ -- `revision` counts them from 1; `previous_head` is the commit the proposal
+ -- pointed at before the most recent one, which is what makes "what changed in
+ -- this update" answerable — both commits are in the stored pack, because an
+ -- update may only move the proposer's branch forward.
+ revision integer not null default 1,
+ previous_head text,
+ updated_at timestamptz
);
+-- Columns added after the table shipped. Safe to re-run, and safe on a database
+-- that already has them.
+alter table public.lesson_pull_requests add column if not exists head_ref text;
+alter table public.lesson_pull_requests add column if not exists revision integer not null default 1;
+alter table public.lesson_pull_requests add column if not exists previous_head text;
+alter table public.lesson_pull_requests add column if not exists updated_at timestamptz;
+
-- The lesson page and the editor both ask "the open proposals on this lesson,
-- newest first"; index the filter + sort key.
create index if not exists lesson_pull_requests_lesson_idx
diff --git a/apps/api/src/lib/cors.js b/apps/api/src/lib/cors.js
index 91db094..b785d49 100644
--- a/apps/api/src/lib/cors.js
+++ b/apps/api/src/lib/cors.js
@@ -33,13 +33,18 @@ export function corsHeaders(request, allowed) {
headers.set('Access-Control-Allow-Origin', origin);
headers.set('Vary', 'Origin');
headers.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
- // X-Git-Head and X-Git-Parent carry a pushed history's tip and the head it
- // expects to replace (the compare-and-swap in routes/git.js), and the same
- // tip when a pull request's pack is uploaded. Neither is a safelisted
- // header, so without naming them here the browser's preflight refuses the
- // upload — invisibly, whenever the app is served from a different origin
- // than the API. Same-origin deploys never preflight and so never noticed.
- headers.set('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Git-Head, X-Git-Parent');
+ // The X-Git-* headers carry a pushed history's tips and the tips it expects
+ // to replace (the compare-and-swap in routes/git.js) — X-Git-Head and
+ // X-Git-Parent for the lesson itself, and Refs/Expected/Deletes for its
+ // variations. The same tip travels on a pull request's pack upload. None is
+ // a safelisted header, so without naming them here the browser's preflight
+ // refuses the upload — invisibly, whenever the app is served from a
+ // different origin than the API. Same-origin deploys never preflight and so
+ // never noticed.
+ headers.set(
+ 'Access-Control-Allow-Headers',
+ 'Content-Type, Authorization, X-Git-Head, X-Git-Parent, X-Git-Refs, X-Git-Expected, X-Git-Deletes',
+ );
headers.set('Access-Control-Max-Age', '86400');
}
}
diff --git a/apps/api/src/lib/lessonGit.js b/apps/api/src/lib/lessonGit.js
index 5d0ebb9..0f433c7 100644
--- a/apps/api/src/lib/lessonGit.js
+++ b/apps/api/src/lib/lessonGit.js
@@ -6,7 +6,14 @@
// here. Two objects per lesson:
//
// git//pack the packfile bytes
-// git//refs.json { head, size, updatedAt }
+// git//refs.json { head, refs, size, updatedAt }
+//
+// `head` is the lesson itself — the default branch — and `refs` maps every branch
+// the repository holds to its tip, one per *variation* its author is trying out
+// (see /web-app/lesson-variations). `head` came first and is kept as its own
+// field rather than being read out of the map: everything that wants "the
+// lesson" wants exactly that one branch, and a reader written before variations
+// existed still gets the right answer from it.
//
// A pull request — someone proposing changes to a lesson they don't own — is a
// packfile too, snapshotted when the request was opened, under its own key:
@@ -28,8 +35,19 @@ import { supabaseHeaders } from './supabase.js';
// A lesson id is a UUID — pinned down because it's interpolated into an R2 key.
export const LESSON_ID_RE = /^[0-9a-fA-F-]{36}$/;
-// A commit oid is a 40-char lowercase hex SHA-1.
-export const OID_RE = /^[0-9a-f]{40}$/;
+// A commit oid is a 40-char lowercase hex SHA-1, and a branch name is what the
+// editor is allowed to create. Both rules come from the shared module so the
+// Worker validates a push against exactly what the client validated it against —
+// the same arrangement as the pull-request length limits.
+export {
+ DEFAULT_BRANCH,
+ OID_RE,
+ MAX_BRANCHES,
+ MAX_BRANCH_NAME,
+ isBranchName,
+ parseRefMap,
+ serializeRefMap,
+} from '@spelling-creator/core/git/refs';
// Cap one repository's history. Packs hold only JSON (images are referenced by
// hash and live in the images bucket, not in the repo), so even a long-lived
diff --git a/apps/api/src/routes/git.js b/apps/api/src/routes/git.js
index ede3717..28c8b37 100644
--- a/apps/api/src/routes/git.js
+++ b/apps/api/src/routes/git.js
@@ -6,8 +6,9 @@
// it here the way git itself moves history: as a packfile holding every object
// reachable from the lesson's branch, plus the commit its branch points at.
//
-// GET /git/:lessonId/refs public* -> { head, size, updatedAt } | 404
-// GET /git/:lessonId/pack public* -> the packfile (X-Git-Head names its tip)
+// GET /git/:lessonId/refs public* -> { head, refs, size, updatedAt } | 404
+// GET /git/:lessonId/pack public* -> the packfile (X-Git-Head names its tip,
+// X-Git-Refs every branch it holds)
// PUT /git/:lessonId/pack Bearer -> store it (the author, or a trusted collaborator)
//
// A pull request's proposed history is a packfile too, but it belongs to the
@@ -24,11 +25,24 @@
//
// Two R2 objects per lesson, mirroring the /images routes' use of the bucket:
// git//pack the packfile bytes
-// git//refs.json { head, size, updatedAt }
+// git//refs.json { head, refs, size, updatedAt }
//
-// The pack carries its own tip in customMetadata (and in the X-Git-Head response
-// header). A clone therefore reads the head from the *same object* as the bytes,
-// so it can never pair a new refs.json with a stale pack.
+// The pack carries its own tip *and* its branch map in customMetadata (echoed in
+// the X-Git-Head and X-Git-Refs response headers). A clone therefore reads both
+// from the *same object* as the bytes, so it can never pair a new refs.json with
+// a stale pack.
+//
+// ---- More than one branch ---------------------------------------------------
+//
+// `head` is the lesson: the default branch, and the only one a reader, a forker
+// or the lesson's own page ever asks for. `refs` maps every branch the repository
+// holds to its tip — one per *variation* its author is trying out, kept here so a
+// variation follows the lesson between the author's devices rather than living
+// only in the browser it was started in.
+//
+// A variation is exactly as public as the lesson it belongs to: it is in the same
+// pack, and the pack of a published lesson is public so that forking is. See
+// /web-app/lesson-variations, which says so where an author can read it.
//
// ---- Who may push, and why it can't lose work -------------------------------
//
@@ -48,20 +62,113 @@
// with 409 and the client must fetch, merge, and retry. Since the client only
// pushes a history that already *contains* the head it merged, an accepted push
// can only ever move the lesson forward.
+//
+// The same rule runs one level down for the variations, per branch:
+//
+// X-Git-Refs the branches to set, `{ "": "" }`
+// X-Git-Expected what the client believes we hold for each name it touches,
+// with "" meaning "I believe this one does not exist yet"
+// X-Git-Deletes the branches to remove, comma-separated
+//
+// It is all-or-nothing, which costs nothing to arrange: refs.json is a single
+// object and already the commit point, so every branch advances or none does. A
+// branch the client doesn't mention is left exactly as it is — that is what stops
+// a device which has never heard of a new variation from deleting it by omission,
+// and it is why a delete has to be asked for by name rather than inferred.
import { bearerToken } from '../lib/auth.js';
import { bannedResponse } from '../lib/bans.js';
import { canReadLesson, fetchLessonRow, isTrustedCollaborator } from '../lib/lesson.js';
-import { LESSON_ID_RE, MAX_PACK_BYTES, OID_RE, isPackfile, packKey, refsKey } from '../lib/lessonGit.js';
+import {
+ DEFAULT_BRANCH,
+ LESSON_ID_RE,
+ MAX_BRANCHES,
+ MAX_PACK_BYTES,
+ OID_RE,
+ isBranchName,
+ isPackfile,
+ packKey,
+ parseRefMap,
+ refsKey,
+ serializeRefMap,
+} from '../lib/lessonGit.js';
import { supabaseBase, supabaseConfigured, verifySupabaseUser } from '../lib/supabase.js';
import { textResponse, jsonResponse } from '../lib/http.js';
-/** The head we currently hold for a lesson, or null if it has no history yet. */
-async function storedHead(env, lessonId) {
+/** What we currently hold for a lesson: `{ head, refs }`, or null with no history. */
+async function stored(env, lessonId) {
const object = await env.LESSON_GIT.get(refsKey(lessonId));
if (!object) return null;
- const refs = await object.json().catch(() => null);
- return refs && refs.head ? refs.head : null;
+ const value = await object.json().catch(() => null);
+ if (!value || !value.head) return null;
+
+ // A lesson stored before variations existed has no map; the one branch it has
+ // is its head, and saying so here means the rest of this file has one shape to
+ // reason about rather than two.
+ //
+ // A map that is *present but unreadable* is a different thing entirely, and must
+ // not be flattened into the same answer. Both of parseRefMap's limits — the
+ // branch count and the name rules — can be tightened later, and if either is,
+ // every lesson past the new limit would read as single-branch here: the push
+ // path would skip its old-client guard and write the variations away. So say we
+ // don't know, and let the caller refuse.
+ const refs = value.refs === undefined ? { [DEFAULT_BRANCH]: value.head } : parseRefMap(value.refs);
+ if (!refs) return { head: value.head, refs: null, unreadable: true };
+ return { head: value.head, refs };
+}
+
+/**
+ * Apply a push's ref instructions to what we hold, or explain why we won't.
+ *
+ * The rule is the one that has always guarded this endpoint, applied per branch
+ * rather than to the lesson as a whole: a client may only move a branch it has
+ * already seen the current state of. `expected` is what it believes, and a branch
+ * it doesn't mention at all is left untouched — which is what stops a device that
+ * has never heard of somebody's new variation from deleting it by omission.
+ *
+ * @returns {{ refs: object } | { error: string, status: number }}
+ */
+export function applyRefs(current, { refs, deletes, expected }) {
+ const next = { ...current };
+
+ const believes = (name) => (Object.hasOwn(expected, name) ? expected[name] : null);
+ const mismatch = (name) => {
+ const believed = believes(name);
+ // Nothing claimed about this branch: only safe when it is new to us. Moving
+ // one we already hold without saying what we hold is exactly the overwrite
+ // the compare-and-swap exists to refuse.
+ if (believed === null) return Boolean(current[name]);
+ return (current[name] || '') !== believed;
+ };
+
+ // A name in both halves is a request that contradicts itself, and we cannot know
+ // which half was meant. Refusing is the only honest answer — applying them in
+ // order would silently let the delete win, which is how a branch that is alive
+ // on the client disappears from the hub.
+ for (const name of deletes) {
+ if (Object.hasOwn(refs, name)) {
+ return { error: 'That push asks to both keep and remove the same version.', status: 400 };
+ }
+ }
+
+ for (const name of Object.keys(refs)) {
+ if (mismatch(name)) return { error: 'moved', status: 409 };
+ next[name] = refs[name];
+ }
+ for (const name of deletes) {
+ if (!isBranchName(name)) return { error: 'That is not a branch name.', status: 400 };
+ if (mismatch(name)) return { error: 'moved', status: 409 };
+ delete next[name];
+ }
+
+ // The lesson has to still be there afterwards. Deleting the branch that *is*
+ // the lesson would leave a row whose history advertises a tip nothing points
+ // at, and no client asks for that.
+ if (!next[DEFAULT_BRANCH]) return { error: 'The lesson’s own history cannot be removed.', status: 400 };
+ if (Object.keys(next).length > MAX_BRANCHES) {
+ return { error: `A lesson can have at most ${MAX_BRANCHES} versions.`, status: 400 };
+ }
+ return { refs: next };
}
/**
@@ -128,8 +235,13 @@ export async function handleGit(request, env, lessonId, rest, cors) {
headers.set('Cache-Control', 'no-store');
const head = object.customMetadata?.head || '';
if (head) headers.set('X-Git-Head', head);
- // The SPA reads X-Git-Head cross-origin, which needs it explicitly exposed.
- headers.set('Access-Control-Expose-Headers', 'X-Git-Head');
+ // The branch map comes off the *same object* as the bytes, for the reason the
+ // head does: a map read from refs.json a moment later could name a tip this
+ // pack doesn't contain.
+ const refs = object.customMetadata?.refs || '';
+ if (refs) headers.set('X-Git-Refs', refs);
+ // The SPA reads these cross-origin, which needs them explicitly exposed.
+ headers.set('Access-Control-Expose-Headers', 'X-Git-Head, X-Git-Refs');
if (object.httpEtag) headers.set('ETag', object.httpEtag);
return new Response(object.body, { status: 200, headers });
}
@@ -162,12 +274,43 @@ export async function handleGit(request, env, lessonId, rest, cors) {
return textResponse('Missing or invalid X-Git-Head.', 400, cors);
}
+ // What the client wants to happen to the lesson's branches. A client that
+ // sends none of this is one written before a lesson could have more than one
+ // branch: it means "move the lesson, leave everything else alone", and the
+ // two X-Git-Head/X-Git-Parent headers already say that.
+ const requested = parseRefMap(request.headers.get('X-Git-Refs'));
+ const believed = parseRefMap(request.headers.get('X-Git-Expected'));
+ if (request.headers.get('X-Git-Refs') && !requested) {
+ return textResponse('Invalid X-Git-Refs.', 400, cors);
+ }
+ if (request.headers.get('X-Git-Expected') && !believed) {
+ return textResponse('Invalid X-Git-Expected.', 400, cors);
+ }
+ // A ref being *set* must name a real commit; only X-Git-Expected may say ""
+ // (meaning "I believe this branch does not exist yet").
+ if (requested && Object.values(requested).some((oid) => !OID_RE.test(oid))) {
+ return textResponse('Invalid X-Git-Refs.', 400, cors);
+ }
+
+ const deletes = (request.headers.get('X-Git-Deletes') || '')
+ .split(',')
+ .map((name) => name.trim())
+ .filter(Boolean);
+
// Compare-and-swap. `parent` is the head the client merged before building
// this pack; if the lesson has moved on since (someone else pushed), we
// refuse — accepting would drop their commits. 409 tells the client to
// fetch, merge and try again.
const parent = (request.headers.get('X-Git-Parent') || '').trim();
- const current = await storedHead(env, lessonId);
+ const held = await stored(env, lessonId);
+ const current = held?.head || null;
+
+ // We hold a branch map we can't read. Every path below decides what to keep
+ // and what to drop by comparing against it, so none of them can run safely.
+ if (held?.unreadable) {
+ return textResponse('This lesson’s stored history could not be read. Please report this rather than saving over it.', 409, cors);
+ }
+
if (current && parent !== current) {
return textResponse(
'This lesson’s history has moved on since you last synced. Merge the latest changes, then save again.',
@@ -175,11 +318,47 @@ export async function handleGit(request, env, lessonId, rest, cors) {
cors,
);
}
- // Already there — the client is re-pushing a history we hold. Nothing to do.
- if (current && current === head) {
+
+ // A pack holds every object every branch it advertises needs. An old client
+ // packs only the lesson's own branch, so accepting its pack while keeping a
+ // variation's tip in the map would leave that tip pointing at objects the
+ // stored pack no longer contains — a history that can't be cloned. Rather
+ // than silently drop the variation, refuse and say why.
+ const heldBranches = Object.keys(held?.refs || {});
+ if (!requested && heldBranches.length > 1) {
+ return textResponse(
+ 'This lesson has more than one version, which this client cannot save without dropping them. Please update it, or save from the web editor.',
+ 409,
+ cors,
+ );
+ }
+
+ const applied = applyRefs(held?.refs || {}, {
+ refs: requested || { [DEFAULT_BRANCH]: head },
+ deletes,
+ expected: believed || (current ? { [DEFAULT_BRANCH]: current } : {}),
+ });
+ if (applied.error) {
+ return textResponse(
+ applied.status === 409
+ ? 'One of this lesson’s versions has moved on since you last synced. Merge the latest changes, then save again.'
+ : applied.error,
+ applied.status,
+ cors,
+ );
+ }
+ // The lesson's own tip and the map have to agree — they are two readings of
+ // one fact, and a client that disagrees with itself is one we can't apply.
+ if (applied.refs[DEFAULT_BRANCH] !== head) {
+ return textResponse('X-Git-Head and X-Git-Refs disagree about this lesson.', 400, cors);
+ }
+
+ // Already there — the client is re-pushing exactly what we hold, branches and
+ // all. Nothing to write.
+ if (current && current === head && serializeRefMap(applied.refs) === serializeRefMap(held.refs)) {
const object = await env.LESSON_GIT.get(refsKey(lessonId));
- const refs = object ? await object.json().catch(() => null) : null;
- if (refs) return jsonResponse(refs, 200, cors);
+ const existing = object ? await object.json().catch(() => null) : null;
+ if (existing) return jsonResponse(existing, 200, cors);
}
const declared = Number(request.headers.get('Content-Length') || 0);
@@ -201,11 +380,12 @@ export async function handleGit(request, env, lessonId, rest, cors) {
// Write the pack before the refs. The refs object is the commit point: if the
// pack write fails, refs still names the previous (complete) pack, and a
// clone of the old history beats a clone of a half-written one.
+ const map = serializeRefMap(applied.refs);
await env.LESSON_GIT.put(packKey(lessonId), bytes, {
httpMetadata: { contentType: 'application/x-git-packfile' },
- customMetadata: { head },
+ customMetadata: { head, refs: map },
});
- const refs = { head, size: bytes.byteLength, updatedAt: new Date().toISOString() };
+ const refs = { head, refs: applied.refs, size: bytes.byteLength, updatedAt: new Date().toISOString() };
await env.LESSON_GIT.put(refsKey(lessonId), JSON.stringify(refs), {
httpMetadata: { contentType: 'application/json' },
});
diff --git a/apps/api/src/routes/git.test.js b/apps/api/src/routes/git.test.js
new file mode 100644
index 0000000..2a563b6
--- /dev/null
+++ b/apps/api/src/routes/git.test.js
@@ -0,0 +1,91 @@
+// The per-branch compare-and-swap.
+//
+// This is the rule that decides whether a push loses somebody's work, and it is
+// pure — so it is worth testing directly rather than through R2. The cases below
+// are the ones that actually happen: two of the author's devices, an old client
+// that knows nothing about variations, and a delete racing a rename.
+
+import { describe, expect, it } from 'vitest';
+import { applyRefs } from './git.js';
+
+const A = 'a'.repeat(40);
+const B = 'b'.repeat(40);
+const C = 'c'.repeat(40);
+
+const push = (current, instructions) => applyRefs(current, { refs: {}, deletes: [], expected: {}, ...instructions });
+
+describe('applyRefs', () => {
+ it('moves a branch the client had seen the current state of', () => {
+ const result = push({ main: A }, { refs: { main: B }, expected: { main: A } });
+ expect(result.refs).toEqual({ main: B });
+ });
+
+ it('refuses to move a branch the client is out of date on', () => {
+ const result = push({ main: B }, { refs: { main: C }, expected: { main: A } });
+ expect(result.status).toBe(409);
+ });
+
+ it('refuses to move a branch the client says nothing about', () => {
+ // Silence means "I believe this is new". It is not, so the push would
+ // overwrite whatever moved it.
+ const result = push({ main: A }, { refs: { main: B } });
+ expect(result.status).toBe(409);
+ });
+
+ it('accepts a branch the client correctly believes is new', () => {
+ const result = push({ main: A }, { refs: { main: A, 'Year-3': B }, expected: { main: A, 'Year-3': '' } });
+ expect(result.refs).toEqual({ main: A, 'Year-3': B });
+ });
+
+ it('refuses a new branch whose name somebody else has already used', () => {
+ const result = push({ main: A, 'Year-3': B }, { refs: { main: A, 'Year-3': C }, expected: { main: A, 'Year-3': '' } });
+ expect(result.status).toBe(409);
+ });
+
+ it('leaves branches the push never mentions exactly as they were', () => {
+ // The case this exists for: one device saves without ever having heard of a
+ // variation another device made. It must not disappear.
+ const result = push({ main: A, 'Year-3': B }, { refs: { main: C }, expected: { main: A } });
+ expect(result.refs).toEqual({ main: C, 'Year-3': B });
+ });
+
+ it('removes a branch when asked by name, and only at the tip it was asked for', () => {
+ expect(push({ main: A, 'Year-3': B }, { refs: { main: A }, deletes: ['Year-3'], expected: { main: A, 'Year-3': B } }).refs).toEqual({
+ main: A,
+ });
+
+ // Somebody added to the variation after we decided to delete it.
+ expect(push({ main: A, 'Year-3': C }, { refs: { main: A }, deletes: ['Year-3'], expected: { main: A, 'Year-3': B } }).status).toBe(409);
+ });
+
+ it('refuses a push that both sets and deletes one branch', () => {
+ // Reachable from a delete-then-recreate on the client: the request
+ // contradicts itself, and applying it in order would let the delete win.
+ const result = push(
+ { main: A, 'Year-3': B },
+ { refs: { main: A, 'Year-3': C }, deletes: ['Year-3'], expected: { main: A, 'Year-3': B } },
+ );
+ expect(result.status).toBe(400);
+ });
+
+ it('will not delete the lesson itself', () => {
+ const result = push({ main: A }, { deletes: ['main'], expected: { main: A } });
+ expect(result.status).toBe(400);
+ });
+
+ it('rejects a delete that is not a branch name at all', () => {
+ const result = push({ main: A }, { refs: { main: A }, deletes: ['../evil'], expected: { main: A } });
+ expect(result.status).toBe(400);
+ });
+
+ it('caps how many branches a lesson can end up with', () => {
+ const current = { main: A };
+ const refs = { main: A };
+ const expected = { main: A };
+ for (let i = 0; i < 20; i++) {
+ refs[`v${i}`] = B;
+ expected[`v${i}`] = '';
+ }
+ expect(push(current, { refs, expected }).status).toBe(400);
+ });
+});
diff --git a/apps/api/src/routes/pulls.js b/apps/api/src/routes/pulls.js
index 212a79a..a6af9de 100644
--- a/apps/api/src/routes/pulls.js
+++ b/apps/api/src/routes/pulls.js
@@ -48,13 +48,23 @@
import { authorFromUser, bearerToken, clientIp, displayNameOf, isModeratorRole, verifyUserAndRole } from '../lib/auth.js';
import { bannedResponse } from '../lib/bans.js';
import { canReadLesson, fetchLessonRow, isTrustedCollaborator } from '../lib/lesson.js';
-import { LESSON_ID_RE, MAX_PACK_BYTES, OID_RE, deletePullGit, isPackfile, pullPackKey, refsKey } from '../lib/lessonGit.js';
+import {
+ DEFAULT_BRANCH,
+ LESSON_ID_RE,
+ MAX_PACK_BYTES,
+ OID_RE,
+ deletePullGit,
+ isBranchName,
+ isPackfile,
+ pullPackKey,
+ refsKey,
+} from '../lib/lessonGit.js';
import { profanityFilter } from '../lib/profanity.js';
import { supabaseBase, supabaseConfigured, supabaseHeaders, verifySupabaseUser } from '../lib/supabase.js';
import { textResponse, jsonResponse } from '../lib/http.js';
import { createNotification } from './notifications.js';
// The same limits the submission form counts against, so the two can't drift.
-import { PULL_BODY_MAX, PULL_TITLE_MAX } from '@spelling-creator/core/pulls';
+import { MAX_PULL_REVISIONS, PULL_BODY_MAX, PULL_TITLE_MAX } from '@spelling-creator/core/pulls';
// How many proposals one person may have open against one lesson at a time.
// High enough that nobody splitting real work across several requests will ever
@@ -62,7 +72,7 @@ import { PULL_BODY_MAX, PULL_TITLE_MAX } from '@spelling-creator/core/pulls';
const MAX_OPEN_PER_AUTHOR = 5;
const PULL_COLUMNS =
- 'id,lesson_id,source_lesson_id,author_id,author,title,body,head,base,ready,status,merge_commit,resolved_by,resolved_at,created_at';
+ 'id,lesson_id,source_lesson_id,author_id,author,title,body,head,head_ref,base,ready,status,merge_commit,resolved_by,resolved_at,created_at,revision,previous_head,updated_at';
/** Map a Supabase `lesson_pull_requests` row to the camelCase shape the frontend expects. */
function rowToPull(row) {
@@ -80,6 +90,14 @@ function rowToPull(row) {
// The commit the stored pack points at, and the lesson tip it was built on.
head: row.head,
base: row.base || null,
+ // Which branch of the fork this is, when it isn't the fork's own lesson.
+ headRef: row.head_ref || null,
+ // How many times the proposer has uploaded, and what the proposal pointed
+ // at before the most recent time — enough to show "updated, and here is
+ // what that update changed" without keeping a pack per revision.
+ revision: row.revision || 1,
+ previousHead: row.previous_head || null,
+ updatedAt: row.updated_at || null,
// False until the packfile landed — see the note at the top of this file.
ready: Boolean(row.ready),
status: row.status,
@@ -306,6 +324,8 @@ async function openPull(request, env, base, lessonId, cors) {
// is dropped rather than rejected — the pack is what carries the changes.
//
// Resolved before the own-lesson check below, which turns on it.
+ const headRef = typeof body.headRef === 'string' ? body.headRef.trim() : '';
+
let sourceLessonId = null;
// Whether that source is a fork *of this lesson*, which is a stricter thing and
// the only one that may unlock a self-proposal below.
@@ -368,6 +388,10 @@ async function openPull(request, env, base, lessonId, cors) {
title: parsed.title,
body: parsed.text || null,
head,
+ // Which branch of their fork this is, when it isn't the fork itself. Purely
+ // for display, and validated as a branch name because it is shown verbatim
+ // in the review queue.
+ head_ref: isBranchName(headRef) && headRef !== DEFAULT_BRANCH ? headRef : null,
base: OID_RE.test(baseOid) ? baseOid : null,
ready: false,
status: 'open',
@@ -395,13 +419,78 @@ async function openPull(request, env, base, lessonId, cors) {
return jsonResponse({ pull: rowToPull(rows[0]) }, 201, cors);
}
+/**
+ * What an upload to a proposal should do — or why it shouldn't.
+ *
+ * The first upload completes the two-step open, so its tip has to be the one the
+ * row was created with. A later one is an update, and must actually move:
+ * re-sending the commit we already hold is a no-op dressed as a new revision.
+ *
+ * `expect` is the conditional the row write carries. Including `head` in it is
+ * what makes two of the proposer's own uploads racing safe — the loser finds the
+ * row no longer where it read it, and says so rather than overwriting.
+ *
+ * @returns {{ error: string, status: number } | { updating: boolean, patch: object, expect: object }}
+ */
+export function planPullUpload(pull, head, now = new Date().toISOString()) {
+ const updating = Boolean(pull.ready);
+
+ if (!updating) {
+ if (head !== pull.head) {
+ return {
+ error: 'These changes don’t match the proposal they were opened with. Start the proposal again.',
+ status: 409,
+ };
+ }
+ return { updating, patch: { ready: true }, expect: { status: 'open', ready: false, head: pull.head } };
+ }
+
+ if (head === pull.head) {
+ return { error: 'These changes are already what this proposal contains.', status: 409 };
+ }
+ const revision = pull.revision || 1;
+ if (revision >= MAX_PULL_REVISIONS) {
+ return {
+ error: `This proposal has been updated ${MAX_PULL_REVISIONS} times. Close it and open a new one.`,
+ status: 409,
+ };
+ }
+ return {
+ updating,
+ patch: { head, previous_head: pull.head, revision: revision + 1, updated_at: now },
+ expect: { status: 'open', ready: true, head: pull.head },
+ };
+}
+
/**
* PUT /lessons/:id/pulls/:prId/pack — upload the proposal's packfile.
*
- * The proposer's only, once, while the request is open: the tip must match the
- * head the row was opened with, and a request that is already ready is not
- * re-writable. Together those are what make a proposal a snapshot — there is no
- * way to swap the contents out from under a reviewer who has already read it.
+ * The proposer's only, while the request is open.
+ *
+ * ---- Updating an open proposal ----------------------------------------------
+ *
+ * This used to be a once-only write, so that a reviewer could not have the
+ * contents swapped out from under them. That reasoning is right and is kept — but
+ * the consequence was that changing a proposal meant closing it and opening a new
+ * one, which threw away the conversation attached to it.
+ *
+ * So an upload to an already-ready proposal is allowed, and *recorded*: the
+ * revision number goes up, the head it used to point at is kept, and the row's
+ * `updated_at` says when. A reviewer is never silently shown different bytes; they
+ * are shown that it changed, and what the change was. The stability that mattered
+ * was never immutability, it was that nothing moves without saying so.
+ *
+ * One pack per proposal, not one per revision. The proposer's branch only moves
+ * forward, so the commit the previous revision pointed at is still reachable in
+ * the new pack — which is what lets "what changed in this update" be answered
+ * from the pack we already store.
+ *
+ * That forward-only rule is enforced by the client, not here: verifying it means
+ * walking the commit graph, and this Worker holds a proposal's history as an
+ * opaque packfile with no filesystem to index one into — the same limit that
+ * stops the merge endpoint checking ancestry (see mergePull below). The exposure
+ * is bounded the same way, too: the only proposal a proposer can rewrite is their
+ * own, which they could always have closed and reopened instead.
*/
async function putPullPack(request, env, base, lessonId, pullId, cors) {
if (!env.LESSON_GIT) return textResponse('Lesson history is not configured.', 500, cors);
@@ -416,13 +505,13 @@ async function putPullPack(request, env, base, lessonId, pullId, cors) {
if (!pull) return textResponse('Proposal not found.', 404, cors);
if (pull.author_id !== user.id) return textResponse('You can only upload changes for your own proposal.', 403, cors);
if (pull.status !== 'open') return textResponse('This proposal is no longer open.', 409, cors);
- if (pull.ready) return textResponse('This proposal’s changes have already been uploaded.', 409, cors);
const head = (request.headers.get('X-Git-Head') || '').trim();
if (!OID_RE.test(head)) return textResponse('Missing or invalid X-Git-Head.', 400, cors);
- if (head !== pull.head) {
- return textResponse('These changes don’t match the proposal they were opened with. Start the proposal again.', 409, cors);
- }
+
+ const plan = planPullUpload(pull, head);
+ if (plan.error) return textResponse(plan.error, plan.status, cors);
+ const { updating } = plan;
const declared = Number(request.headers.get('Content-Length') || 0);
if (declared > MAX_PACK_BYTES) return textResponse('This proposal has too much history to store.', 413, cors);
@@ -440,15 +529,33 @@ async function putPullPack(request, env, base, lessonId, pullId, cors) {
// The pack is stored before the row is flipped: `ready` means "there is
// something to review", so it must never be true ahead of the bytes.
//
- // Conditional on the request still being open and still unready. The status
- // was checked above, but the row can be closed between that read and this
- // write — and a close deletes the pack, so an unconditional flip would leave
- // a resolved proposal advertising bytes that were just swept, or bytes we
- // wrote back after the sweep. When the transition doesn't take, the pack we
- // just stored is ours to clean up.
- const updated = await patchPull(env, base, pullId, { ready: true }, { status: 'open', ready: false });
+ // Conditional on the request still being open, and on it still pointing where
+ // we read it pointing. The status was checked above, but the row can be closed
+ // between that read and this write — and a close deletes the pack, so an
+ // unconditional write would leave a resolved proposal advertising bytes that
+ // were just swept, or bytes we wrote back after the sweep. The head condition
+ // does the same job for two of the proposer's own uploads racing. When the
+ // transition doesn't take, the pack we just stored is ours to clean up.
+ const updated = await patchPull(env, base, pullId, plan.patch, plan.expect);
if (!updated) {
- await deletePullGit(env, pullId);
+ // The write didn't take, and which pack we just orphaned depends on why.
+ //
+ // A first upload always owns its bytes: the proposal never became ready, so
+ // nothing else can be pointing at them.
+ //
+ // An update has two possible losers, and only re-reading the row tells them
+ // apart. Against a proposal that has since been *resolved*, closePull already
+ // deleted the pack and we have just written one back after the sweep — that
+ // one is ours to remove, or it stays in the bucket for ever. Against one still
+ // open, we lost a race with the proposer's own other upload: the pack there is
+ // theirs, it is a superset, and getPullPack serves the row's head regardless,
+ // so leaving it is both harmless and the only safe choice.
+ if (!updating) {
+ await deletePullGit(env, pullId);
+ } else {
+ const now = await fetchPull(env, base, lessonId, pullId);
+ if (!now || now.status !== 'open') await deletePullGit(env, pullId);
+ }
return textResponse('This proposal is no longer open.', 409, cors);
}
@@ -464,10 +571,17 @@ async function putPullPack(request, env, base, lessonId, pullId, cors) {
const lesson = await fetchLessonRow(env, base, lessonId);
if (lesson) {
const own = lesson.author_id === user.id;
+ const who = authorFromUser(user);
await createNotification(env, base, {
userId: lesson.author_id,
type: 'pull_request',
- title: own ? 'Changes are waiting for your review' : `${authorFromUser(user)} proposed changes to your lesson`,
+ title: updating
+ ? own
+ ? 'Changes waiting for your review were updated'
+ : `${who} updated the changes they proposed`
+ : own
+ ? 'Changes are waiting for your review'
+ : `${who} proposed changes to your lesson`,
body: pull.title,
link: `/hub/${lessonId}/proposals/${pullId}`,
}).catch(() => {});
@@ -504,7 +618,14 @@ async function getPullPack(request, env, base, lessonId, pullId, cors) {
// A proposal's pack never changes once uploaded, but it is deleted when the
// proposal is resolved — so it may be cached briefly, never indefinitely.
headers.set('Cache-Control', 'private, max-age=60');
- const head = object.customMetadata?.head || pull.head || '';
+ // The *row* names the tip, in preference to the object's own metadata — the
+ // reverse of how a lesson's pack works, and for a reason particular to
+ // proposals. An update writes the pack before it moves the row, so between
+ // those two the stored bytes are ahead of the record. The bytes are a superset
+ // either way (a proposal only ever moves forward), so serving the row's head
+ // with them hands a reviewer exactly the revision the proposal claims to be,
+ // whichever way that write went.
+ const head = pull.head || object.customMetadata?.head || '';
if (head) headers.set('X-Git-Head', head);
// The SPA reads X-Git-Head cross-origin, which needs it explicitly exposed.
headers.set('Access-Control-Expose-Headers', 'X-Git-Head');
diff --git a/apps/api/src/routes/pulls.test.js b/apps/api/src/routes/pulls.test.js
new file mode 100644
index 0000000..488611b
--- /dev/null
+++ b/apps/api/src/routes/pulls.test.js
@@ -0,0 +1,66 @@
+// What an upload to a proposal is allowed to do.
+//
+// A proposal used to be write-once, which made this decision trivial and made
+// changing a proposal impossible. Now it has three outcomes — complete the open,
+// record a new revision, or refuse — and getting the refusals wrong is how a
+// reviewer ends up reading one thing and merging another. Pure, so tested here
+// rather than through R2.
+
+import { describe, expect, it } from 'vitest';
+import { planPullUpload } from './pulls.js';
+import { MAX_PULL_REVISIONS } from '@spelling-creator/core/pulls';
+
+const A = 'a'.repeat(40);
+const B = 'b'.repeat(40);
+
+const opened = (over = {}) => ({ head: A, ready: false, revision: 1, ...over });
+const live = (over = {}) => ({ head: A, ready: true, revision: 1, ...over });
+
+describe('planPullUpload', () => {
+ it('completes the two-step open when the tip is the one the row was created with', () => {
+ const plan = planPullUpload(opened(), A);
+ expect(plan.updating).toBe(false);
+ expect(plan.patch).toEqual({ ready: true });
+ // The row must still be open, still unready, and still where we read it.
+ expect(plan.expect).toEqual({ status: 'open', ready: false, head: A });
+ });
+
+ it('refuses a first upload that does not match the proposal it belongs to', () => {
+ expect(planPullUpload(opened(), B).status).toBe(409);
+ });
+
+ it('records a revision when an open proposal is given something new', () => {
+ const plan = planPullUpload(live(), B, 'NOW');
+ expect(plan.updating).toBe(true);
+ expect(plan.patch).toEqual({
+ head: B,
+ previous_head: A,
+ revision: 2,
+ updated_at: 'NOW',
+ });
+ // Conditional on the head we read: two of the proposer's own uploads racing
+ // must not both win.
+ expect(plan.expect).toEqual({ status: 'open', ready: true, head: A });
+ });
+
+ it('keeps the commit it moved from, which is what makes the update readable', () => {
+ expect(planPullUpload(live({ head: A, revision: 3 }), B).patch).toMatchObject({
+ previous_head: A,
+ revision: 4,
+ });
+ });
+
+ it('refuses an update that changes nothing', () => {
+ expect(planPullUpload(live(), A).status).toBe(409);
+ });
+
+ it('stops once a proposal has been rewritten enough times', () => {
+ expect(planPullUpload(live({ revision: MAX_PULL_REVISIONS - 1 }), B).updating).toBe(true);
+ expect(planPullUpload(live({ revision: MAX_PULL_REVISIONS }), B).status).toBe(409);
+ });
+
+ it('treats a row with no revision recorded as the first one', () => {
+ const plan = planPullUpload({ head: A, ready: true }, B);
+ expect(plan.patch.revision).toBe(2);
+ });
+});
diff --git a/apps/docs/docs/.vitepress/config.mts b/apps/docs/docs/.vitepress/config.mts
index 2812b2c..690b28f 100644
--- a/apps/docs/docs/.vitepress/config.mts
+++ b/apps/docs/docs/.vitepress/config.mts
@@ -109,6 +109,10 @@ export default defineConfig({
{ text: "Search images", link: "/web-app/search-images" },
{ text: "Save to Google Docs", link: "/web-app/save-to-google-docs" },
{ text: "Live collaboration", link: "/web-app/live-collaboration" },
+ {
+ text: "Variations (trying something out)",
+ link: "/web-app/lesson-variations",
+ },
{
text: "Pull requests (proposing changes)",
link: "/web-app/pull-requests",
diff --git a/apps/docs/docs/mcp-server/tools.md b/apps/docs/docs/mcp-server/tools.md
index 78c94da..675ae82 100644
--- a/apps/docs/docs/mcp-server/tools.md
+++ b/apps/docs/docs/mcp-server/tools.md
@@ -55,8 +55,13 @@ Some mechanics worth knowing:
happened.
- **A proposal is one commit**, made when it's opened, holding the fork as it then
stands. Intermediate `patch_lesson` calls aren't separate commits, so make all the
- edits first. Proposing again after further edits opens a second, separate proposal
- (at most 5 open against one lesson).
+ edits first.
+- **Proposing again updates the proposal already open** from that fork, rather than
+ stacking a second one beside it — same request, same discussion, new contents, with
+ the version number recorded. That's what you want after the human asks for a change;
+ the `title` and `body` passed are then ignored, since the ones already there are what
+ they have been reading. `updated` in the result says which happened. (At most 5 open
+ against one lesson, and at most 20 updates to one proposal.)
- **Images aren't copied.** Blocks reference them by content hash and the bytes are
already stored, so forking is cheap.
- **Forks are private drafts** and count against the draft cap, so `delete_lesson` the
diff --git a/apps/docs/docs/monorepo/version-history.md b/apps/docs/docs/monorepo/version-history.md
index 974ccaa..d107ac3 100644
--- a/apps/docs/docs/monorepo/version-history.md
+++ b/apps/docs/docs/monorepo/version-history.md
@@ -88,13 +88,58 @@ tree (unchanged blocks resolve to oids git already has) and compare its oid with
The editor shows this as a chip — _"Version saved 2 minutes ago"_, or _"3 unsaved
changes"_ — which opens the history.
-## Restoring
+## Restoring, and undoing
Restoring an old version is an ordinary **forward** commit whose tree happens to
equal an older one. History is never rewritten: the version you restored _away
from_ stays in the timeline, so the restore itself can be undone by restoring
again.
+Restoring is the blunt instrument, though — it takes the whole document back and
+drops everything since. **Undo** is the precise one: put back what _that one
+version_ changed, and keep the rest. It is the same three-way merge as everything
+else here, with the sides pointed backwards:
+
+| Merge argument | Undoing commit C |
+| -------------- | --------------------------------- |
+| base | the document as C left it |
+| ours | the document now |
+| theirs | the document immediately before C |
+
+Every rule then falls out without a line of new logic, including the field-level
+one: a block C changed differs between base and theirs, so theirs wins and it goes
+back; a block changed since differs between base and ours, so ours wins and is
+kept; a block in both _but in different fields_ merges field by field, with both
+surviving. Only a block where the same field was changed on both sides is a genuine
+conflict — the change being undone has been built on in the very place it touched,
+and only the author can say what they meant — so that is what reaches the dialog. The result is a forward
+commit with one parent, so an undo can itself be undone.
+
+The history view asks two questions about a selected version, because they have
+different answers the moment anything has happened since: _what changed here_
+(history) and _difference from now_ (the decision you are about to make).
+
+## More than one branch
+
+A lesson's repository holds a branch per **variation** — an alternative version of
+the lesson its author is trying out, kept apart from the one people are reading.
+The default branch (`main`) is the lesson; the rest are drafts of what it might
+become.
+
+Which one is being edited is `HEAD`, a symbolic ref, exactly as in git. That is
+not just tidiness: `HEAD` lives inside the gitdir, so it survives the two places a
+repository is copied wholesale — publishing a draft (`adoptDraftRepo`) and forking
+a lesson locally (`copyRepo`) — neither of which knows branches exist.
+
+Everything on this page is per branch as a result. A commit moves whatever `HEAD`
+points at; the history view reads the branch being edited unless given a ref; the
+pack carries every branch, and the push compare-and-swaps each one separately.
+What doesn't change is what "the lesson" means: `main`, and only `main`, is what a
+reader sees, what a fork clones, and what a proposal is offered against.
+
+See [Variations](/web-app/lesson-variations) for what an author sees, why a
+variation is as public as its lesson, and how a deletion travels.
+
## Forking is cloning
For someone else to fork a lesson, its repository has to travel. It travels the
@@ -119,8 +164,9 @@ so a pack is pure JSON and stays small — a few KB for a typical lesson.
### Worker endpoints
```
-GET /git/:lessonId/refs public* -> { head, size, updatedAt } (404 = no history)
-GET /git/:lessonId/pack public* -> the packfile (X-Git-Head names its tip)
+GET /git/:lessonId/refs public* -> { head, refs, updatedAt } (404 = no history)
+GET /git/:lessonId/pack public* -> the packfile (X-Git-Head names its tip,
+ X-Git-Refs every branch it holds)
PUT /git/:lessonId/pack Bearer -> store it (the author, or a trusted collaborator)
```
@@ -129,13 +175,14 @@ open [pull request](/web-app/pull-requests), which is a packfile too:
```
git//pack the packfile bytes
-git//refs.json { head, size, updatedAt }
+git//refs.json { head, refs, size, updatedAt }
git/pulls//pack a proposal's snapshot (no refs.json: its tip is fixed)
```
-The pack carries its own tip in R2 `customMetadata`, echoed in the `X-Git-Head`
-response header — so a clone reads the bytes and the ref they belong to from the
-_same object_, and can never pair a fresh ref with a stale pack.
+The pack carries its own tip **and its branch map** in R2 `customMetadata`, echoed
+in the `X-Git-Head` and `X-Git-Refs` response headers — so a clone reads the bytes
+and the refs they belong to from the _same object_, and can never pair a fresh ref
+with a stale pack.
`GET` is public because forking a published lesson is public; a private draft's
history (like the draft itself) 404s to everyone but its author, a trusted
@@ -183,6 +230,13 @@ one to adjudicate, so a reorder on both sides resolves to ours.
The result is committed with **two parents**, which genuinely joins the two
histories — so the next merge can find _this_ commit as its base.
+Unless it needn't be. When our side _is_ the merge base — their history already
+contains ours and we have added nothing to it, neither commits nor uncommitted
+edits — the merge is a **fast-forward**: our branch moves to their commit and no
+merge commit is written. There is nothing for one to record, and manufacturing it
+would put an entry in the lesson's timeline saying a decision was made when none
+was.
+
## Merging a fork back in (pull requests)
Anyone can fork a lesson and pull the original's later changes in. Going the other
@@ -290,6 +344,7 @@ in the browser, in Node and inside the Worker:
| Module | Purpose |
| -------- | ------------------------------------------------------------------ |
| `doc` | Pure doc helpers: canonical JSON, manifest, block map. No git. |
+| `refs` | Branch names, limits, and the ref map's wire format. No git. |
| `ops` | Diff two docs into operations; render commit messages. No git. |
| `merge` | Three-way merge by block id, field-level. No git. |
| `layout` | Document ⇄ git tree (one file per block). |
@@ -318,18 +373,19 @@ thing to it and uploads the result. See
App-bound (`apps/web/src/lib/git/`) — what cannot leave the bundle:
-| File | Purpose |
-| ----------------------- | ---------------------------------------------------------- |
-| `engine.js` + `load.js` | The git engine, behind one dynamic import. |
-| `useLessonGit.js` | The editor's controller: setup, periodic commits, history. |
+| File | Purpose |
+| ----------------------- | ---------------------------------------------------------------------- |
+| `engine.js` + `load.js` | The git engine, behind one dynamic import. |
+| `useLessonGit.js` | The editor's controller: setup, periodic commits, history, variations. |
`repo` and friends take their filesystem through `repoCtx` rather than opening
one, which is exactly what lets the same commit/merge/restore logic run against
LightningFS in the browser, and `memfs` in Node, in the Worker and in tests.
A repo tracks remotes in git's own vocabulary: `origin` (this lesson's own
-published history, which a trusted collaborator may have moved on without us),
-`upstream` (the lesson it was forked from), and, while one is being reviewed,
+published history, which a trusted collaborator may have moved on without us —
+one `refs/remotes/origin/` per branch the hub holds), `upstream` (the
+lesson it was forked from), and, while one is being reviewed,
`refs/remotes/pull/` — one ref per proposal, so two open ones can't overwrite
each other's tip.
diff --git a/apps/docs/docs/web-app/lesson-variations.md b/apps/docs/docs/web-app/lesson-variations.md
new file mode 100644
index 0000000..5b1125f
--- /dev/null
+++ b/apps/docs/docs/web-app/lesson-variations.md
@@ -0,0 +1,204 @@
+---
+title: Variations (trying something without breaking the lesson)
+---
+
+# Variations (trying something without breaking the lesson)
+
+A **variation** is a separate copy of a lesson you can change freely. The lesson
+everybody else reads doesn't move while you work on one, and nothing you do to a
+variation reaches it until you say so.
+
+It exists for the thing authors were doing the hard way: rewriting half a lesson
+to see whether the rewrite is better. Before this, the only way to do that
+without risking the original was to fork it into a whole second lesson and open a
+proposal against yourself. A variation is the same idea at the right size.
+
+## What it looks like
+
+The editor shows which copy you're on, next to the "Version saved" chip:
+
+```text
+ Version saved 2 minutes ago Main lesson ▾
+ Version saved just now Simpler for Year 3 ▾ <- on a variation
+```
+
+Clicking it opens the list. From there you can start one, switch between them,
+rename one, delete one, and — the point of the whole thing — **bring one into the
+main lesson**.
+
+Each variation says how much work is sitting on it ("3 changes that aren't in the
+main lesson"), which is the only number an author needs before deciding whether
+to open it.
+
+### Bringing one in
+
+This runs the same block-by-block merge as everything else in
+[Version history](/monorepo/version-history): the main lesson and the variation
+are lined up against the commit they last agreed on, and only a block both sides
+changed _in the same field_ reaches a dialog. Usually nothing does, and the merge
+summary just says what it settled.
+
+The order matters and is fixed: the editor **switches to the main lesson first**,
+then merges the variation into it. That is what makes the result the lesson with
+your changes folded in, rather than the variation with the lesson folded in —
+which is the same commit and the opposite meaning. Anything you had unsaved is
+committed to the variation on the way out, so nothing in flight is carried across
+by accident.
+
+Afterwards the variation is still there, now reading "0 changes that aren't in the
+main lesson". Keep it and carry on, or delete it.
+
+### Trying somebody else's proposal in one
+
+A variation is also where a reviewer can put a [proposal](./pull-requests.md)
+they aren't sure about — "Try it in a variation" on its page — and read the whole
+lesson with the change in it before deciding. The lesson doesn't move and the
+proposal stays open.
+
+## Variations are as public as the lesson
+
+They travel with the lesson, so a variation you start on your laptop is there when
+you open the lesson on your phone. That is the point — but it means a variation
+lives in the same packfile as the lesson, and **a published lesson's packfile is
+public**, because that is what makes forking work.
+
+So: anyone who can open the lesson can read its variations. On a private draft
+that's you (and anyone you trust); on a published lesson that's everyone. The
+dialog says so, in those words, where an author will see it.
+
+If you want to try something genuinely privately, fork the lesson into a new
+private draft instead — see [Pull requests](./pull-requests.md).
+
+## What it is underneath
+
+A variation is a **branch** of the lesson's git repository, and switching between
+them is a checkout. None of that vocabulary appears in the app, deliberately: an
+author isn't doing version control, they're trying something and keeping the
+original safe while they do.
+
+The mapping is exact, though, and everything on this page falls out of it.
+
+| In the app | In the repository |
+| --------------------------- | --------------------------------------------------- |
+| The main lesson | `refs/heads/main` — the default branch |
+| A variation | `refs/heads/` |
+| Which one you're editing | `HEAD`, a symbolic ref |
+| Switching | Writing `HEAD`, and adopting the doc at the new tip |
+| Bringing one in | A merge commit on `main` — or a fast-forward\* |
+| "3 changes that aren't in…" | Commits on the branch not reachable from `main` |
+
+\* When `main` is the merge base and the editor has nothing uncommitted, the
+branch simply moves: there is nothing for a merge commit to record. Otherwise it
+is a two-parent commit, and only blocks changed on both sides _in the same field_
+reach a dialog — a caption edited here and a width edited there merge field by
+field, with both kept.
+
+Recording the current variation in `HEAD` rather than beside the repository is
+what makes it survive a reload, a second tab, and the two places a repository gets
+copied wholesale — publishing a draft (`adoptDraftRepo`) and forking a lesson
+locally (`copyRepo`), neither of which knows branches exist.
+
+### Names
+
+Git bounds what a branch may be called, so what an author types is converted:
+spaces become hyphens, anything git reserves is dropped, and the result is capped
+at 32 characters. It is read back the other way for display, so "Simpler for Year
+3" round-trips. The rules live in `@spelling-creator/core/git/refs` and are
+imported by both the editor and the Worker, so what the app offers and what the
+server accepts can't drift apart.
+
+A lesson may have at most **12 branches**. That ceiling isn't taste: the branch
+map rides in the R2 object's `customMetadata` alongside the pack it belongs to
+(so a reader can never pair one moment's bytes with another moment's refs), and R2
+caps that metadata at 2 KB.
+
+## How they travel
+
+The lesson's stored `refs.json` gained a map, and kept `head` meaning exactly what
+it always did — the default branch, which is what a reader, a forker and the
+lesson's own page ask for:
+
+```json
+{
+ "head": "",
+ "refs": {
+ "main": "",
+ "Simpler-for-Year-3": ""
+ },
+ "size": 41203,
+ "updatedAt": "..."
+}
+```
+
+The pack holds every object reachable from _any_ branch. That costs almost
+nothing: branches of one lesson share nearly all of their objects, and the packer
+dedupes by oid, so a second variation adds only the commits unique to it.
+
+A **fork** takes the default branch alone. Somebody else's half-finished ideas
+aren't part of what was forked, and adopting them as branches of the fork would
+claim they were.
+
+A **proposal** carries exactly one branch: the one you were working on when you
+proposed it. Both halves matter. Offering the branch you are looking at is the
+only reading of "propose these changes" that isn't a trap — work an idea up on a
+variation, propose, and you would otherwise have sent the untouched lesson and
+been told it worked. Offering _only_ that one is what keeps the rest to yourself.
+See [Pull requests](./pull-requests.md).
+
+### Pushing more than one branch
+
+The compare-and-swap that has always guarded a push now runs per branch. Three
+headers describe what a push wants, and the Worker applies all of it or none:
+
+```text
+X-Git-Refs the branches to set, { "": "" }
+X-Git-Expected what the client believes the hub holds for each name it touches,
+ with "" meaning "I believe this one does not exist yet"
+X-Git-Deletes the branches to remove, comma-separated
+```
+
+Atomicity is free: `refs.json` is a single R2 object and already the commit point,
+so every branch advances or none does.
+
+The rule that makes this safe with two devices is that **a branch a push doesn't
+mention is left exactly as it is**. Otherwise a device that had never heard of a
+variation would delete it simply by not knowing about it. A client sending neither
+`X-Git-Refs` nor `X-Git-Expected` — one written before any of this — therefore
+still means "move the lesson, leave everything else alone", and keeps working.
+
+### Deleting has to be asked for
+
+Which leaves a gap: if a push only ever _adds_, how does a deletion travel? It
+can't be inferred, for the reason above — "I don't have it" and "I deleted it"
+look identical from a ref map.
+
+So a delete leaves a marker in the repository (`refs/deleted/`, holding the
+tip it pointed at), the next push turns that into an explicit `X-Git-Deletes`
+instruction compare-and-swapped against that tip, and only a push that actually
+landed clears the marker. Cleared any earlier and the variation would be gone
+locally, alive on the hub, and back on the next device that opened the lesson.
+
+The same marker is why fetching doesn't undo a delete: a branch on the hub that we
+hold a marker for is not adopted back. And reusing the name clears it — a name
+used again is not the deleted variation returning, and a marker left behind would
+make the next push ask to create and remove one name in a single request. The
+Worker refuses that request rather than picking a half.
+
+Fetching prunes in the other direction too. A branch the hub no longer has, which
+we still hold at exactly the tip it last told us about, holds nothing that isn't
+already gone, so it goes — otherwise a deletion made on one device would be
+undone by another that still had the branch. One that has _moved_ holds unpushed
+work, and that is the author's to keep: it goes back up, and they can delete it
+again.
+
+## Where it lives
+
+| Piece | What it does |
+| ---------------------------------------------- | ------------------------------------------------------------------ |
+| `@spelling-creator/core/git/refs` | Name rules, limits, and the ref map's wire format. No git. |
+| `@spelling-creator/core/git/repo` | `currentBranch`, create / checkout / rename / delete, `aheadCount` |
+| `@spelling-creator/core/git/pack` | Packing every branch; a clone writing them back |
+| `@spelling-creator/core/browser/git/sync` | Per-branch push, adopting the hub's branches, `prepareBranchMerge` |
+| `apps/api/src/routes/git.js` | The per-branch compare-and-swap (`applyRefs`) |
+| `apps/web/src/lib/git/useLessonGit.js` | The editor's variation state and actions |
+| `apps/web/src/components/VariationsDialog.jsx` | The list, and everything you can do from it |
diff --git a/apps/docs/docs/web-app/pull-requests.md b/apps/docs/docs/web-app/pull-requests.md
index 9e2ee4c..13cb404 100644
--- a/apps/docs/docs/web-app/pull-requests.md
+++ b/apps/docs/docs/web-app/pull-requests.md
@@ -31,8 +31,8 @@ lesson ───────▶ your copy ────────────
uploads it with a title and an optional note. Nothing in the original changes.
4. The lesson's author sees it on the lesson's **Proposals** tab
(`/hub/:id/proposals`), and gets a notification. Each proposal also has a page
- of its own at `/hub/:id/proposals/:prId` — read-only, because merging needs
- the git objects and those live in the editor. They (or a trusted collaborator)
+ of its own at `/hub/:id/proposals/:prId`, which shows **what it changes** and
+ whether it would merge cleanly (see below). They (or a trusted collaborator)
hit **Review & merge**.
5. That opens the lesson in _their_ editor (`?pull=&lesson=` — the
link names both, so the review waits for the lesson it belongs to rather than
@@ -50,15 +50,50 @@ visible); only its stored changes are dropped.
## A proposal is a snapshot
-What a pull request actually contains is a **git packfile** — your whole
-repository as it stood the moment you opened the request — stored in R2 under
+What a pull request actually contains is a **git packfile** — your fork's lesson
+as it stood the moment you opened the request — stored in R2 under
`git/pulls//pack`, beside the lessons' own packs.
+The lesson, and not your [variations](./lesson-variations.md) of it: a variation is
+an idea you are still turning over, and offering one to somebody else to merge,
+unasked and unmentioned, is not what "propose changes" means.
+
+Which version of your fork? **The one you were working on.** If you developed the
+idea on a [variation](./lesson-variations.md), that is what gets proposed — and
+only that one, so the rest of your variations stay yours. The proposal records
+which branch it came from, and the review queue says so.
+
Snapshotting is deliberate. You carry on editing your fork after proposing, and a
request that silently tracked your branch would mean the reviewer reading one
-thing and merging another. So the pack is written once and never rewritten: the
-Worker refuses a second upload, and refuses any upload whose tip isn't the commit
-the request was opened with.
+thing and merging another.
+
+## Updating a proposal
+
+The pack used to be written once and never rewritten, which made "silently" moot
+by making _any_ change impossible — so being asked for a tweak meant closing the
+proposal and opening another, throwing away the discussion attached to it.
+
+Proposing again from the same fork now **updates** the proposal you already have
+open, and records that it happened: the version number goes up, the commit it used
+to point at is kept, and the page shows _"Version 3 · updated 4 March"_ along with
+what that last update changed. Nothing moves silently; what was actually being
+protected was never immutability, it was that nothing changes without saying so.
+
+A proposal may only move **forward** — the new tip has to contain the one the
+proposal already points at. That is what keeps one pack per proposal honest: the
+previous version's commit is still reachable in the new pack, which is how "what
+the last update changed" is answerable without storing a pack per version. It is
+checked by the client, because the Worker holds a proposal's history as an opaque
+packfile it cannot walk — the same limit that stops the merge endpoint verifying
+ancestry, bounded the same way, since the only proposal you can rewrite is your
+own and you could always have closed and reopened it.
+
+There is a ceiling of **20 updates**. Past a couple of dozen rewrites it is a
+different change, and the thread attached to it has stopped being about what the
+proposal now contains.
+
+An assistant working over MCP follows the same rule: `propose_changes` from a fork
+that already has one open updates it rather than stacking a second one beside it.
Because a fork is a real clone, that pack shares object ids with the lesson's own
history. The reviewer indexes it into the lesson's repository, where its objects
@@ -73,6 +108,71 @@ reviewer shouldn't have to tell a half-finished submission from a real one. If t
upload fails, the client withdraws the empty request rather than leaving it in the
author's queue.
+## Reading one without merging it
+
+A proposal's own page shows its changes — block by block, in the same summary the
+history view renders — and whether merging it would ask the reviewer for anything:
+
+- _"This merges cleanly — there's nothing to decide."_
+- _"2 blocks have been changed here and in the lesson, so merging will ask you
+ which to keep."_
+- _"These changes are already part of the lesson."_
+
+That is computed on the page, from the git objects themselves. Both packs are
+public exactly as far as the lesson is, so the browser indexes the proposal's pack
+beside the lesson's, finds the commit the two diverged at, and diffs against
+**that** — not against the lesson's current tip, which would show the author's own
+later edits as though the proposer had made them, reversed.
+
+Merging still happens in the editor, and that split is deliberate rather than a
+limitation: merging commits to the lesson's history and pushes it under the
+reviewer's credentials, which needs the editor's repository. Reading needs none of
+it. What changed is that nobody has to start a merge to find out whether they want
+one.
+
+The git engine is ~200 KB and is fetched on demand when this page opens, so the
+proposal's title, author and note render first and the diff arrives after. If it
+can't be read at all, the page says so and the rest of it still works.
+
+## Trying it before deciding
+
+Reading a diff tells you what changed. It doesn't tell you whether the lesson
+still works with the change in it — whether the new question fits where it was
+put, whether the rewritten section still reads in order.
+
+So there is a third answer between merging and declining: **try it in a
+variation**. That lands the proposal on a [variation](./lesson-variations.md) of
+the reviewer's own, where they can click through the whole lesson with the change
+in place. The lesson everyone reads is untouched, and the proposal stays open —
+nothing about it is recorded, because nothing has been decided.
+
+Mechanically it is the two features meeting and needing almost nothing new: a
+merge commits to whatever branch is checked out, so the editor starts a variation
+named after the proposal and checks it out _before_ preparing the merge. What it
+must not do is record the proposal as being reviewed, since that is what makes the
+confirm push and mark it merged — a try-out deliberately does neither.
+
+Trying the same proposal twice returns to the variation the first attempt made,
+rather than refusing on the name, so whatever the reviewer did to it last time is
+still there.
+
+## Landing it without a merge commit
+
+When the lesson hasn't moved since the proposal was opened, the merge is a
+**fast-forward**: the lesson's branch simply moves to the proposal's commit. No
+merge commit is written, because there is nothing for one to record — no decision
+was made and no content changed that the proposal's own commits don't already
+describe.
+
+Two conditions, and both are needed. The lesson's tip must be the merge base (the
+commit-graph half), and the reviewer must have nothing uncommitted in their editor
+(the half the graph can't see — skipping it would drop whatever they had typed but
+not yet paused long enough to commit).
+
+The proposal is still recorded as merged against the commit the lesson now points
+at, which is the proposal's own head. If anything that is easier to verify than
+before.
+
## Who can do what
| You are | Open | Merge | Close |
@@ -184,14 +284,14 @@ which is why the submission dialog says so plainly before you send it.
## Worker endpoints
-| Method & path | Auth | What it does |
-| ------------------------------------- | ----------------------- | ---------------------------------------------------------------------------------------------- |
-| `GET /lessons/:id/pulls` | none, unless a draft\* | `{ "pulls": [...], "canReview": bool }` — newest first; unready rows only for their own author |
-| `POST /lessons/:id/pulls` | `Bearer ` | Opens a proposal (`{ title, body, head, base, sourceLessonId }`); the author only from a fork |
-| `PUT /lessons/:id/pulls/:prId/pack` | `Bearer ` | Uploads its packfile (`X-Git-Head` must match). The proposer's, once |
-| `GET /lessons/:id/pulls/:prId/pack` | none, unless a draft\* | The packfile; `X-Git-Head` names its tip |
-| `POST /lessons/:id/pulls/:prId/merge` | `Bearer ` | Records the merge (`{ mergeCommit }`); author or trusted collaborator only |
-| `POST /lessons/:id/pulls/:prId/close` | `Bearer ` | Closes it; proposer, author, trusted collaborator, or moderator |
+| Method & path | Auth | What it does |
+| ------------------------------------- | ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
+| `GET /lessons/:id/pulls` | none, unless a draft\* | `{ "pulls": [...], "canReview": bool }` — newest first; unready rows only for their own author |
+| `POST /lessons/:id/pulls` | `Bearer ` | Opens a proposal (`{ title, body, head, headRef, base, sourceLessonId }`); anyone signed in — the lesson's own author only from a fork they own |
+| `PUT /lessons/:id/pulls/:prId/pack` | `Bearer ` | Uploads its packfile — the proposer's only. The first must match the head the row was opened with; a later one records a revision |
+| `GET /lessons/:id/pulls/:prId/pack` | none, unless a draft\* | The packfile; `X-Git-Head` names its tip |
+| `POST /lessons/:id/pulls/:prId/merge` | `Bearer ` | Records the merge (`{ mergeCommit }`); author or trusted collaborator only |
+| `POST /lessons/:id/pulls/:prId/close` | `Bearer ` | Closes it; proposer, author, trusted collaborator, or moderator |
\* Reads follow the target lesson's own visibility — the single `canReadLesson`
rule in `apps/api/src/lib/lesson.js` that also gates `GET /lessons/:id`, its
@@ -202,20 +302,21 @@ frontend can surface `res.text()` directly.
## Where it lives
-| Piece | What it does |
-| -------------------------------------------------- | ------------------------------------------------------------------- |
-| `apps/api/src/routes/pulls.js` | The endpoints above, and every permission rule |
-| `apps/api/schema.sql` | `lesson_pull_requests` |
-| `apps/api/src/lib/lessonGit.js` | R2 key layout (`git/pulls//pack`) and the sweeps that delete it |
-| `@spelling-creator/core/pulls` | The browser client, and the shared length limits |
-| `@spelling-creator/core/browser/git/sync` | `submitPullRequest` (propose) and `preparePullMerge` (review) |
-| `apps/mcp/src/git.js` | The same two steps for an AI assistant — fork, then propose |
-| `apps/web/src/components/ProposeChangesDialog.jsx` | The submission form |
-| `apps/web/src/pages/lesson/LessonProposals.jsx` | The Proposals tab |
-| `apps/web/src/pages/lesson/LessonProposal.jsx` | One proposal, read-only, with the hand-off into the editor |
-| `apps/web/src/components/PullRequestsSection.jsx` | The list on a lesson's page |
-| `apps/web/src/pages/EditorPage.jsx` | `?pull=&lesson=` — the review + merge flow |
-| `apps/web/src/components/MergeDialog.jsx` | Settling conflicts, shared with the fork-sync direction |
+| Piece | What it does |
+| -------------------------------------------------- | ----------------------------------------------------------------------------------------- |
+| `apps/api/src/routes/pulls.js` | The endpoints above, and every permission rule |
+| `apps/api/schema.sql` | `lesson_pull_requests` |
+| `apps/api/src/lib/lessonGit.js` | R2 key layout (`git/pulls//pack`) and the sweeps that delete it |
+| `@spelling-creator/core/pulls` | The browser client, and the shared length limits |
+| `@spelling-creator/core/browser/git/sync` | `submitPullRequest` (propose), `prepareProposalReview` (read), `preparePullMerge` (merge) |
+| `apps/mcp/src/git.js` | The same two steps for an AI assistant — fork, then propose |
+| `apps/web/src/components/ProposeChangesDialog.jsx` | The submission form |
+| `apps/web/src/pages/lesson/LessonProposals.jsx` | The Proposals tab |
+| `apps/web/src/pages/lesson/LessonProposal.jsx` | One proposal: its changes, its mergeability, and the hand-off into the editor |
+| `apps/web/src/components/ChangeSummary.jsx` | The change chips and operation list, shared with the history view |
+| `apps/web/src/components/PullRequestsSection.jsx` | The list on a lesson's page |
+| `apps/web/src/pages/EditorPage.jsx` | `?pull=&lesson=` — the review + merge flow |
+| `apps/web/src/components/MergeDialog.jsx` | Settling conflicts, shared with the fork-sync direction |
A pack is swept when its proposal is **closed** — nothing there will ever be
merged — and when the lesson is deleted, before the row goes, since the cascade
diff --git a/apps/mcp/manifest.json b/apps/mcp/manifest.json
index f806079..e933eb7 100644
--- a/apps/mcp/manifest.json
+++ b/apps/mcp/manifest.json
@@ -2,7 +2,7 @@
"manifest_version": "0.3",
"name": "spelling-creator-hub",
"display_name": "Spelling Creator Hub",
- "version": "0.3.0",
+ "version": "0.5.0",
"description": "Author and publish spelling lessons to the Spelling Creator hub from your AI assistant.",
"long_description": "Connects your AI assistant to the Spelling Creator hub so it can compose and publish spelling lessons for you. You describe the lesson; the assistant builds it (reading passages with ALL-CAPS spelling words, explicit word lists, and quiz questions) and saves it to the hub through the same API the web app uses — with all the usual validation and author attribution. Lessons default to private drafts; publish them when you're ready. Requires a Supabase refresh token (run the bundled `login` helper, or copy one from the web app) and a display name set on your account.",
"author": {
diff --git a/apps/mcp/package.json b/apps/mcp/package.json
index 2011206..272fb4a 100644
--- a/apps/mcp/package.json
+++ b/apps/mcp/package.json
@@ -1,6 +1,6 @@
{
"name": "@spelling-creator/mcp",
- "version": "0.3.0",
+ "version": "0.5.0",
"private": true,
"description": "MCP server for the Spelling Creator hub — lets any MCP-capable AI assistant author and publish spelling lessons.",
"license": "AGPL-3.0-only",
diff --git a/apps/mcp/src/api.js b/apps/mcp/src/api.js
index 0d0f2d5..52f8b72 100644
--- a/apps/mcp/src/api.js
+++ b/apps/mcp/src/api.js
@@ -7,6 +7,11 @@
// token. On a 401 we transparently refresh the token once and retry, so a
// long-lived server survives the access token expiring between calls.
+import {
+ DEFAULT_BRANCH,
+ parseRefMap,
+ serializeRefMap,
+} from "@spelling-creator/core/git/refs";
import { sha256Hex, extFromMime } from "./images.js";
/**
@@ -88,7 +93,11 @@ export function createApi(config, auth) {
* tip travels in X-Git-Head so the bytes and the ref they belong to can never
* be paired from two different moments (see core/git/remote.js).
*/
- async function putPack(url, { packfile, head, parent }, badStatusMessage) {
+ async function putPack(
+ url,
+ { packfile, head, parent, refs, expected },
+ badStatusMessage,
+ ) {
const headers = {
"Content-Type": "application/x-git-packfile",
"X-Git-Head": head,
@@ -96,6 +105,12 @@ export function createApi(config, auth) {
// The compare-and-swap: the head we believe the lesson points at. Omitted
// when it has no history yet.
if (parent) headers["X-Git-Parent"] = parent;
+ // And the same claim for every branch the pack carries. A lesson can hold a
+ // branch per variation its author is trying out, and packRepo sends all of
+ // them — so the push has to name them all, or the hub would be left
+ // advertising tips whose objects the stored pack no longer contains.
+ if (refs) headers["X-Git-Refs"] = serializeRefMap(refs);
+ if (expected) headers["X-Git-Expected"] = serializeRefMap(expected);
const res = await request(url, { method: "PUT", headers, body: packfile });
if (!res.ok) throw await readError(res, badStatusMessage);
@@ -116,7 +131,13 @@ export function createApi(config, auth) {
if (!head) return null; // a pack with no tip is unusable
const packfile = new Uint8Array(await res.arrayBuffer());
if (packfile.byteLength === 0) return null;
- return { packfile, head };
+ // The branch map comes off the same response as the bytes, so the two can
+ // never be paired from different moments. A lesson stored before variations
+ // existed sends none, which reads as the one branch it has.
+ const refs = parseRefMap(res.headers.get("X-Git-Refs")) || {
+ [DEFAULT_BRANCH]: head,
+ };
+ return { packfile, head, refs };
}
return {
@@ -249,10 +270,10 @@ export function createApi(config, auth) {
* the Worker refuses the push if the lesson has moved on since, which is
* what stops two writers erasing each other.
*/
- async pushLessonPack(lessonId, { packfile, head, parent }) {
+ async pushLessonPack(lessonId, { packfile, head, parent, refs, expected }) {
return putPack(
gitUrl(lessonId, "/pack"),
- { packfile, head, parent },
+ { packfile, head, parent, refs, expected },
"Could not save the lesson history.",
);
},
diff --git a/apps/mcp/src/git.js b/apps/mcp/src/git.js
index bf296f9..13a240d 100644
--- a/apps/mcp/src/git.js
+++ b/apps/mcp/src/git.js
@@ -35,9 +35,11 @@ import { memRepo } from "@spelling-creator/core/git/memfs";
import { describeOp } from "@spelling-creator/core/git/ops";
import {
cloneFromPack,
+ contains,
fetchRemotePack,
packRepo,
} from "@spelling-creator/core/git/pack";
+import { DEFAULT_BRANCH } from "@spelling-creator/core/git/refs";
import {
UPSTREAM_REF,
authorFrom,
@@ -99,10 +101,19 @@ export function proposalBody(body, client) {
* Build an in-memory repository holding a lesson's history, cloned from its
* stored pack. The clone carries the original's commit oids, so the fork shares
* ancestry with it — which is what a reviewer's three-way merge needs.
+ *
+ * `keepVariations` says whether the lesson's other branches come too. Cloning our
+ * own lesson to push it back again, they must — dropping one would delete it. But
+ * a fork takes the lesson and not its author's half-finished ideas, so forking
+ * asks for the default branch alone.
*/
-async function cloneRepo(pack) {
+async function cloneRepo(pack, { keepVariations = true } = {}) {
const ctx = memRepo();
- await cloneFromPack({ ...ctx, ...pack });
+ await cloneFromPack({
+ ...ctx,
+ ...pack,
+ refs: keepVariations ? pack.refs : null,
+ });
return ctx;
}
@@ -156,7 +167,7 @@ export async function forkLesson(api, { lessonId, title }) {
const author = await commitAuthor(api);
let ctx;
if (pack) {
- ctx = await cloneRepo(pack);
+ ctx = await cloneRepo(pack, { keepVariations: false });
// Record where we came from, so a later sync has a base before it fetches
// anything new.
await fetchRemotePack({ ...ctx, ...pack, ref: UPSTREAM_REF });
@@ -185,11 +196,14 @@ export async function forkLesson(api, { lessonId, title }) {
const packed = await packRepo(ctx);
try {
// A brand-new lesson has no history, so there is nothing to compare and swap
- // against.
+ // against — every branch we are sending is new to it, which is what an empty
+ // `expected` says.
await api.pushLessonPack(lesson.id, {
packfile: packed.packfile,
head: packed.head,
parent: null,
+ refs: packed.refs,
+ expected: {},
});
} catch (err) {
// The row exists but has no history behind it, which is the one state the
@@ -285,8 +299,68 @@ export async function proposeChanges(
);
}
+ // Two packs, because they answer two different questions. The proposal carries
+ // the lesson as this fork has it and nothing else — a variation its author is
+ // still turning over is not part of what is being offered. The fork's own
+ // history, pushed further down, carries everything, because leaving a branch
+ // out of that one would delete it.
+ const proposed = await packRepo({ ...ctx, only: [DEFAULT_BRANCH] });
const packed = await packRepo(ctx);
+ // Is there already one open from this fork? An assistant asked for a further
+ // change should update the proposal a human is already reading, not stack a
+ // second one beside it — the review queue would then hold two overlapping
+ // proposals and any discussion would be attached to the wrong one.
+ //
+ // Matched on the fork, not merely on the account: proposing to one lesson from
+ // two different forks is legitimate, and those are not updates of each other.
+ const existing = await api
+ .listPulls(target)
+ .then(({ pulls }) =>
+ pulls.find(
+ (p) =>
+ p.status === "open" && p.ready && p.sourceLessonId === forkLessonId,
+ ),
+ )
+ .catch(() => null);
+
+ if (existing) {
+ // An update may only move the proposal *forward*: the new tip has to contain
+ // the one it already points at. Usually it does — the fork's history is one
+ // branch advancing — but not always. If an earlier proposal's history push
+ // failed (it is best-effort, see pushForkHistory), this call cloned a pack
+ // without that commit and built a sibling instead. Uploading it would drop
+ // `previous_head` out of the history the pack carries, which is exactly the
+ // invariant that lets one pack per proposal answer "what changed in this
+ // update". The Worker cannot check this — it holds the pack opaquely — so it
+ // is checked here.
+ const forward = await contains({
+ ...ctx,
+ oid: proposed.head,
+ ancestor: existing.head,
+ });
+ if (!forward) {
+ throw new Error(
+ `This fork's history no longer builds on proposal ${existing.id}, so updating it would replace ` +
+ "what the reviewer has been reading rather than adding to it. Close that proposal in the web app " +
+ "and call propose_changes again to open a fresh one.",
+ );
+ }
+
+ const updated = await api.uploadPullPack(target, existing.id, {
+ packfile: proposed.packfile,
+ head: proposed.head,
+ });
+ return {
+ pull: updated || existing,
+ lessonId: target,
+ commit: commit.oid,
+ changes: ops.map(describeOp),
+ historyPushed: await pushForkHistory(api, forkLessonId, packed, forkPack),
+ updated: true,
+ };
+ }
+
// The target's tip as it stands, recorded on the request so a reviewer can see
// what it was built against. Informational: the merge finds its own base from
// the shared ancestry.
@@ -295,7 +369,7 @@ export async function proposeChanges(
const pull = await api.createPull(target, {
title: clamp(title, PULL_TITLE_MAX),
body: proposalBody(body, client),
- head: packed.head,
+ head: proposed.head,
base,
sourceLessonId: forkLessonId,
});
@@ -303,8 +377,8 @@ export async function proposeChanges(
let ready;
try {
ready = await api.uploadPullPack(target, pull.id, {
- packfile: packed.packfile,
- head: packed.head,
+ packfile: proposed.packfile,
+ head: proposed.head,
});
} catch (err) {
await api.closePull(target, pull.id).catch(() => {});
@@ -321,16 +395,12 @@ export async function proposeChanges(
// operations and refuse — the changes safe but unproposable without making a
// further edit. Pushing last, a failed proposal leaves the fork exactly as it
// was and the retry simply works.
- let historyPushed = true;
- try {
- await api.pushLessonPack(forkLessonId, {
- packfile: packed.packfile,
- head: packed.head,
- parent: forkPack.head,
- });
- } catch {
- historyPushed = false;
- }
+ const historyPushed = await pushForkHistory(
+ api,
+ forkLessonId,
+ packed,
+ forkPack,
+ );
return {
pull: ready || pull,
@@ -338,5 +408,32 @@ export async function proposeChanges(
commit: commit.oid,
changes: ops.map(describeOp),
historyPushed,
+ updated: false,
};
}
+
+/**
+ * Advance the fork's own stored history, and never let it fail the call.
+ *
+ * The proposal does not depend on it — its pack is stored separately, and that is
+ * what a reviewer merges — so this is bookkeeping: it keeps the fork's History tab
+ * honest and gives the next proposal this commit to build on.
+ */
+async function pushForkHistory(api, forkLessonId, packed, forkPack) {
+ try {
+ await api.pushLessonPack(forkLessonId, {
+ packfile: packed.packfile,
+ head: packed.head,
+ parent: forkPack.head,
+ // The fork may hold variations its author started in the editor. They came
+ // down in the pack we cloned and are going back up in the one we packed, so
+ // they are named here too — a push that mentioned only the lesson's own
+ // branch would leave the hub advertising tips this pack no longer carries.
+ refs: packed.refs,
+ expected: forkPack.refs,
+ });
+ return true;
+ } catch {
+ return false;
+ }
+}
diff --git a/apps/mcp/src/tools.js b/apps/mcp/src/tools.js
index 688697e..f9d77fe 100644
--- a/apps/mcp/src/tools.js
+++ b/apps/mcp/src/tools.js
@@ -618,8 +618,11 @@ export function registerTools(server, ctx) {
"returned `url` — that is the page where they read the diff and decide. Their answer is theirs to give: " +
"don't tell them it is done, and don't try to merge it yourself.\n\n" +
"The proposal carries ONE commit holding the fork as it now stands, so make all your edits before calling " +
- "this. You may propose again after further edits; each proposal is a separate request (at most 5 open " +
- "against one lesson).\n\n" +
+ "this.\n\n" +
+ "Calling it AGAIN from the same fork while a proposal is still open UPDATES that proposal rather than " +
+ "opening another — same request, same discussion, new contents — which is what you want after the human " +
+ "asks for a change. The title and body you pass are then ignored, since the ones already there are what " +
+ "they have been reading. `updated` in the result says which happened.\n\n" +
"Write the title and body for the reviewer, not for the log: say what changed and why it is an improvement, " +
"so someone who has not read the diff can judge it.",
inputSchema: {
@@ -656,6 +659,7 @@ export function registerTools(server, ctx) {
commit,
changes,
historyPushed,
+ updated,
} = await proposeChanges(api, {
forkLessonId,
lessonId,
@@ -673,9 +677,14 @@ export function registerTools(server, ctx) {
commit,
changes,
url: proposalUrl(target, pull.id),
+ revision: pull.revision,
note:
- "Proposal opened. Nothing has changed in the lesson itself — give the user the `url` so they can read " +
- "the diff and merge or decline it. Poll list_lesson_proposals if you need to know what they decided." +
+ (updated
+ ? "This fork already had a proposal open, so it was UPDATED rather than duplicated — same proposal, " +
+ "same discussion, new contents. Nothing has changed in the lesson itself."
+ : "Proposal opened. Nothing has changed in the lesson itself.") +
+ " Give the user the `url` so they can read the diff and merge or decline it. Poll " +
+ "list_lesson_proposals if you need to know what they decided." +
// The proposal is complete either way — its changes are stored with it.
// This only means the fork's own history didn't catch up.
(historyPushed
diff --git a/apps/mcp/test/fork.test.js b/apps/mcp/test/fork.test.js
index 6ced3c9..c2046a4 100644
--- a/apps/mcp/test/fork.test.js
+++ b/apps/mcp/test/fork.test.js
@@ -117,12 +117,36 @@ function fakeHub() {
return clone(pull);
},
+ async listPulls(lessonId) {
+ return {
+ pulls: pulls.filter((p) => p.lessonId === lessonId).map(clone),
+ canReview: false,
+ };
+ },
+
+ // Mirrors the Worker's planPullUpload: the first upload has to match the head
+ // the row was opened with; a later one records a revision.
async uploadPullPack(lessonId, pullId, { packfile, head }) {
const pull = pulls.find((p) => p.id === pullId);
if (!pull) throw new Error("Proposal not found.");
- assert.equal(head, pull.head, "the pack must match the head opened with");
+ // The Worker refuses an upload to a proposal that is no longer open, and a
+ // fake that accepted one would hide a caller that had stopped checking.
+ if (pull.status !== "open")
+ throw new Error("This proposal is no longer open.");
+ if (pull.ready) {
+ assert.notEqual(head, pull.head, "an update has to actually move");
+ pull.previousHead = pull.head;
+ pull.head = head;
+ pull.revision = (pull.revision || 1) + 1;
+ } else {
+ assert.equal(
+ head,
+ pull.head,
+ "the pack must match the head opened with",
+ );
+ pull.ready = true;
+ }
pullPacks.set(pullId, { packfile, head });
- pull.ready = true;
return clone(pull);
},
@@ -358,7 +382,7 @@ test("proposing sends a pack that shares ancestry with the target lesson", async
assert.equal(hub.packs.get(fork.id).head, uploaded.head);
});
-test("proposing twice stacks on the first proposal instead of colliding", async () => {
+test("proposing twice updates the proposal already open, rather than stacking one beside it", async () => {
const hub = fakeHub();
const source = await seedLesson(hub, {
title: "Volcanoes",
@@ -371,6 +395,7 @@ test("proposing twice stacks on the first proposal instead of colliding", async
forkLessonId: fork.id,
title: "First pass",
});
+ assert.equal(first.updated, false);
hub.lessons.get(fork.id).doc.sections[0].blocks[0].text = "Second revision.";
const second = await proposeChanges(hub.api, {
@@ -378,11 +403,21 @@ test("proposing twice stacks on the first proposal instead of colliding", async
title: "Second pass",
});
+ assert.equal(
+ second.updated,
+ true,
+ "the second call updates rather than opens",
+ );
+ assert.equal(hub.pulls.length, 1, "the reviewer sees one proposal, not two");
+ assert.equal(second.pull.id, first.pull.id);
+ assert.equal(second.pull.revision, 2);
+ // Its title is the one the reviewer has been reading; an update doesn't rewrite
+ // the conversation it belongs to.
+ assert.equal(second.pull.title, "First pass");
assert.notEqual(second.commit, first.commit);
- assert.equal(hub.pulls.length, 2, "each proposal is its own request");
- // The second builds on the first, which is what the compare-and-swap in
- // pushLessonPack would have refused otherwise.
+ // The update builds on what the proposal already contained, which is the rule
+ // that keeps one pack per proposal enough — the earlier commit is still in it.
const ctx = memRepo("review");
await cloneFromPack({ ...ctx, ...hub.pullPacks.get(second.pull.id) });
assert.equal(
@@ -391,6 +426,32 @@ test("proposing twice stacks on the first proposal instead of colliding", async
);
});
+test("a proposal that has been resolved is not updated — the next one is its own", async () => {
+ const hub = fakeHub();
+ const source = await seedLesson(hub, {
+ title: "Volcanoes",
+ text: "A volcano ERUPTS.",
+ });
+ const { lesson: fork } = await forkLesson(hub.api, { lessonId: source.id });
+
+ hub.lessons.get(fork.id).doc.sections[0].blocks[0].text = "First revision.";
+ const first = await proposeChanges(hub.api, {
+ forkLessonId: fork.id,
+ title: "First pass",
+ });
+ await hub.api.closePull(source.id, first.pull.id);
+
+ hub.lessons.get(fork.id).doc.sections[0].blocks[0].text = "Second revision.";
+ const second = await proposeChanges(hub.api, {
+ forkLessonId: fork.id,
+ title: "Second pass",
+ });
+
+ assert.equal(second.updated, false);
+ assert.notEqual(second.pull.id, first.pull.id);
+ assert.equal(second.pull.title, "Second pass");
+});
+
test("proposing an unchanged fork is refused, and opens nothing", async () => {
const hub = fakeHub();
const source = await seedLesson(hub, {
diff --git a/apps/web/src/components/ChangeSummary.jsx b/apps/web/src/components/ChangeSummary.jsx
new file mode 100644
index 0000000..b8b5c71
--- /dev/null
+++ b/apps/web/src/components/ChangeSummary.jsx
@@ -0,0 +1,79 @@
+// What a set of block operations changed, rendered the same way everywhere it is
+// asked.
+//
+// Three places ask: a commit in the version history, a proposal's own page, and
+// (through the first) a restore preview. They were never going to stay in step as
+// three copies, and "what changed" is exactly the thing a reader compares between
+// views — a proposal that reads differently from the commit it becomes would be
+// worse than no summary at all.
+//
+// Both pieces take ops in the shape ops.js produces, and neither needs git: the
+// operations are already derived by the time they get here, which is what lets
+// the proposal page draw this without the engine loaded.
+
+import { useTranslation } from "react-i18next";
+import { Badge } from "./ui/badge.jsx";
+import { describeOp } from "@spelling-creator/core/git/ops";
+
+// The counts badged against a change. Section and title operations are
+// deliberately not tallied — they are structure, they appear in the list below,
+// and a chip saying "1 changed" for a renamed section next to "1 changed" for a
+// rewritten question would flatten a distinction that matters.
+function tally(ops) {
+ const counts = { added: 0, edited: 0, removed: 0, moved: 0 };
+ for (const op of ops) {
+ if (op.op === "block.add") counts.added++;
+ else if (op.op === "block.edit") counts.edited++;
+ else if (op.op === "block.remove") counts.removed++;
+ else if (op.op === "block.move") counts.moved++;
+ }
+ return counts;
+}
+
+// Chip colors, mapped from MUI's semantic palette onto this app's tokens —
+// success/destructive already exist; "changed" borrows --primary for the
+// same blue-ish "info" read, "moved" stays neutral (MUI's "default").
+const CHIP_STYLES = {
+ success: "border-success/40 bg-success/10 text-success",
+ info: "border-primary/40 bg-primary/10 text-primary",
+ error: "border-destructive/40 bg-destructive/10 text-destructive",
+ default: "border-border bg-transparent text-muted-foreground",
+};
+
+/** "3 added · 1 changed · 2 removed" — the shape of a change, at a glance. */
+export function ChangeChips({ ops, className = "mt-1.5" }) {
+ const { t } = useTranslation("editorTools");
+ const counts = tally(ops);
+ const chips = [
+ ["added", counts.added, "success"],
+ ["changed", counts.edited, "info"],
+ ["removed", counts.removed, "error"],
+ ["moved", counts.moved, "default"],
+ ].filter(([, n]) => n > 0);
+
+ if (chips.length === 0) return null;
+ return (
+
- );
-}
-
/**
* @param {object} props.git The useLessonGit controller.
* @param {Function} props.onRestore Called with the restored doc; the editor adopts it.
+ * @param {Function} props.onUndo Called with a commit oid to undo just that
+ * change. The editor owns it because it may
+ * need the conflict dialog.
*/
-export default function HistoryDialog({ open, onClose, git, onRestore }) {
+export default function HistoryDialog({
+ open,
+ onClose,
+ git,
+ onRestore,
+ onUndo,
+}) {
const { t } = useTranslation("editorTools");
const [commits, setCommits] = useState(null); // null = still loading
const [selected, setSelected] = useState(null); // oid
@@ -112,7 +82,13 @@ export default function HistoryDialog({ open, onClose, git, onRestore }) {
const [restoring, setRestoring] = useState(false);
const [error, setError] = useState(null);
- const { loadHistory, diffFor, restore, pending } = git;
+ const { loadHistory, diffFor, diffAgainstCurrent, restore, pending } = git;
+
+ // Which question the right-hand panel is answering. "What changed in this
+ // version" is history; "what would I get back" is the decision someone is
+ // actually about to make, and the two have different answers the moment
+ // anything has happened since.
+ const [mode, setMode] = useState("changed");
// Re-read the history each time the dialog opens: the editor has very likely
// committed since it was last closed.
@@ -124,6 +100,7 @@ export default function HistoryDialog({ open, onClose, git, onRestore }) {
setSelected(null);
setDetail(null);
setError(null);
+ setMode("changed");
loadHistory().then((list) => {
if (cancelled) return;
@@ -136,20 +113,21 @@ export default function HistoryDialog({ open, onClose, git, onRestore }) {
};
}, [open, loadHistory]);
- // What the selected commit changed, against its first parent.
+ // What the selected commit changed, or how it differs from the document now.
useEffect(() => {
if (!open || !selected) return;
let cancelled = false;
setDetail(null);
- diffFor(selected).then((ops) => {
+ const ask = mode === "current" ? diffAgainstCurrent : diffFor;
+ ask(selected).then((ops) => {
if (!cancelled) setDetail(ops);
});
return () => {
cancelled = true;
};
- }, [open, selected, diffFor]);
+ }, [open, selected, mode, diffFor, diffAgainstCurrent]);
const handleRestore = useCallback(async () => {
if (!selected) return;
@@ -167,6 +145,10 @@ export default function HistoryDialog({ open, onClose, git, onRestore }) {
}, [selected, restore, onRestore, onClose, t]);
const isCurrent = commits && selected === commits[0]?.oid;
+ // The first commit has nothing before it, so there is no "before" to put back —
+ // undoing it would mean emptying the lesson, which is a different request.
+ const canUndo =
+ commits && selected && commits[commits.length - 1]?.oid !== selected;
return (
+ {/* A proposal can be updated while it is open, and a reviewer coming back to
+ one needs to know that happened before they read it again. */}
+ {(pull.revision > 1 || pull.headRef) && (
+
@@ -189,15 +319,71 @@ export default function LessonProposal() {
)}
+ {/* What it changes, and whether it would land cleanly. Drawn for a closed
+ or merged proposal too — "what did that one do?" is a question people
+ ask most often about the ones already resolved. */}
+ {pull.ready && (
+
+
+ ) : (
+ <>
+
+
+
+ >
+ )}
+
+ {/* What the most recent update did, for a reviewer who has read this
+ before. Only drawn when there has been one and its commits are
+ still readable. */}
+ {changes?.updateOps?.length > 0 && (
+
+
+ {t("pulls.changes.sinceUpdate")}
+
+
+
+
+ )}
+
+ {/* Whether a reviewer would have anything to decide. Worth saying
+ before they open the editor, because the usual answer is "no". */}
+ {changes && isOpen && }
+
+ )}
+
{isOpen && pull.ready && canReview && (
-
-
- {t("pulls.review")}
-
+
+ review()}>
+
+ {t("pulls.review")}
+
+ {/* The middle option, which used to not exist: put it somewhere you
+ can look at it. Reading a diff tells you what changed; it doesn't
+ tell you whether the lesson still works. */}
+ review({ tryIt: true })}>
+
+ {t("pulls.tryIt")}
+
+
{t("pulls.reviewHint")}
+
+ {t("pulls.tryItHint")}
+
)}
diff --git a/packages/core/package.json b/packages/core/package.json
index c4da43f..a245ce4 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -35,6 +35,7 @@
"./git/merge": "./src/git/merge.js",
"./git/ops": "./src/git/ops.js",
"./git/pack": "./src/git/pack.js",
+ "./git/refs": "./src/git/refs.js",
"./git/remote": "./src/git/remote.js",
"./git/repo": "./src/git/repo.js",
"./id": "./src/id.js",
diff --git a/packages/core/src/browser/git/sync.js b/packages/core/src/browser/git/sync.js
index 9f4df1a..5c5a76f 100644
--- a/packages/core/src/browser/git/sync.js
+++ b/packages/core/src/browser/git/sync.js
@@ -32,6 +32,7 @@ import { newId } from "../../id.js";
import { preserveLocalFields } from "../../git/doc.js";
import { DRAFT_REPO, copyRepo, deleteRepo, repoCtx } from "./fs.js";
import { applyResolutions, mergeDocs } from "../../git/merge.js";
+import { diffDocs } from "../../git/ops.js";
import {
cloneFromPack,
contains,
@@ -47,9 +48,15 @@ import {
uploadPullPack,
} from "../../pulls.js";
import {
+ BRANCH,
ORIGIN_REF,
UPSTREAM_REF,
+ branchRef,
+ clearDeletedBranch,
commitDoc,
+ currentBranch,
+ currentBranchRef,
+ deletedBranches,
headOid,
pullRef,
readDocAt,
@@ -73,17 +80,59 @@ const EMPTY_AUTO = { merged: [], tookTheirs: [], added: [], removed: [] };
*/
export async function remoteStatus({ repoId, lessonId, ref = ORIGIN_REF }) {
const ctx = repoCtx(repoId);
+ const branch = await currentBranch(ctx);
const ours = await headOid(ctx);
const pack = await fetchPack(lessonId).catch(() => null);
- if (!pack) return { state: "fresh", ours, theirs: null, needsMerge: false };
+ if (!pack) {
+ return {
+ state: "fresh",
+ ours,
+ theirs: null,
+ branch,
+ hasRemote: false,
+ needsMerge: false,
+ };
+ }
- await fetchRemotePack({ ...ctx, ...pack, ref });
- const theirs = pack.head;
+ await fetchRemotePack({
+ ...ctx,
+ ...pack,
+ ref,
+ refs: pack.refs,
+ remote: "origin",
+ });
+ await syncRemoteBranches(ctx, pack.refs);
+
+ // Compare like with like: the branch we are on against the hub's copy of *that*
+ // branch, not against the lesson. A variation and the lesson are supposed to
+ // differ — that is what a variation is — so measuring one against the other
+ // would report a conflict on every save.
+ const theirs = pack.refs?.[branch] || (branch === BRANCH ? pack.head : null);
+
+ // The hub has never seen this branch, so there is nothing there to overwrite.
+ if (!theirs) {
+ return {
+ state: "fresh",
+ ours,
+ theirs: null,
+ branch,
+ hasRemote: true,
+ needsMerge: false,
+ };
+ }
// Nothing committed locally: whatever the hub has, take it.
if (!ours) {
- return { state: "behind", ours, theirs, base: null, needsMerge: true };
+ return {
+ state: "behind",
+ ours,
+ theirs,
+ base: null,
+ branch,
+ hasRemote: true,
+ needsMerge: true,
+ };
}
if (ours === theirs) {
return {
@@ -91,6 +140,8 @@ export async function remoteStatus({ repoId, lessonId, ref = ORIGIN_REF }) {
ours,
theirs,
base: theirs,
+ branch,
+ hasRemote: true,
needsMerge: false,
};
}
@@ -99,13 +150,97 @@ export async function remoteStatus({ repoId, lessonId, ref = ORIGIN_REF }) {
// We already contain their tip, so pushing can only move the lesson forward.
if (await contains({ ...ctx, oid: ours, ancestor: theirs })) {
- return { state: "ahead", ours, theirs, base, needsMerge: false };
+ return {
+ state: "ahead",
+ ours,
+ theirs,
+ base,
+ branch,
+ hasRemote: true,
+ needsMerge: false,
+ };
}
// They contain ours: someone pushed commits we don't have.
if (await contains({ ...ctx, oid: theirs, ancestor: ours })) {
- return { state: "behind", ours, theirs, base, needsMerge: true };
+ return {
+ state: "behind",
+ ours,
+ theirs,
+ base,
+ branch,
+ hasRemote: true,
+ needsMerge: true,
+ };
+ }
+ return {
+ state: "diverged",
+ ours,
+ theirs,
+ base,
+ branch,
+ hasRemote: true,
+ needsMerge: true,
+ };
+}
+
+/**
+ * Bring our idea of the hub's branches in line with what it just told us.
+ *
+ * Two halves, and they are both about the next push being able to say something
+ * true. Creating local branches for the hub's is what makes a variation started
+ * on one device turn up on another — the objects are already here, off the pack
+ * we have just indexed, so it costs a ref write. Dropping remote-tracking refs
+ * for branches the hub no longer has is what stops us claiming a tip for a branch
+ * that isn't there, which the compare-and-swap would refuse for ever.
+ *
+ * A branch we deliberately deleted is not adopted back: its marker is still
+ * waiting to be pushed, and undoing a delete on the way to reporting it would be
+ * the opposite of what the author asked for.
+ *
+ * Every write here is best-effort. This is bookkeeping so that the *next* push can
+ * say something true, and one branch that won't adopt is no reason to fail the
+ * save it is riding along with — including the lesson's own.
+ */
+async function syncRemoteBranches(ctx, refs) {
+ if (!refs) return;
+ const deleted = await deletedBranches(ctx);
+
+ for (const [name, oid] of Object.entries(refs)) {
+ if (!oid || deleted[name]) continue;
+ if (await headOid({ ...ctx, ref: branchRef(name) })) continue;
+ await git
+ .writeRef({ ...ctx, ref: branchRef(name), value: oid, force: false })
+ .catch(() => {});
+ }
+
+ // The other direction: a branch the hub no longer has, which we do.
+ //
+ // Dropping only the remote-tracking ref would leave the local branch to be
+ // pushed back as new on the next save — so a variation deleted on one device
+ // would quietly reappear, put there by another device that still had it. What
+ // makes the delete safe is the tracking ref itself: it is the last thing the hub
+ // told us this branch was, so a local branch still sitting exactly there holds
+ // nothing that isn't already gone. One that has moved holds unpushed work, and
+ // that is the author's to keep — it goes back up, and they can delete it again.
+ const tracked = await git
+ .listRefs({ ...ctx, filepath: "refs/remotes/origin" })
+ .catch(() => []);
+
+ for (const name of tracked) {
+ if (refs[name]) continue;
+
+ const trackedOid = await headOid({
+ ...ctx,
+ ref: `refs/remotes/origin/${name}`,
+ });
+ const localOid = await headOid({ ...ctx, ref: branchRef(name) });
+ if (name !== BRANCH && localOid && localOid === trackedOid) {
+ await git.deleteRef({ ...ctx, ref: branchRef(name) }).catch(() => {});
+ }
+ await git
+ .deleteRef({ ...ctx, ref: `refs/remotes/origin/${name}` })
+ .catch(() => {});
}
- return { state: "diverged", ours, theirs, base, needsMerge: true };
}
/**
@@ -131,10 +266,11 @@ export async function prepareMerge({
lessonId,
doc,
ref = UPSTREAM_REF,
+ theirs,
}) {
const pack = await fetchPack(lessonId).catch(() => null);
if (!pack) return null;
- return mergeAgainstPack({ repoId, pack, doc, ref });
+ return mergeAgainstPack({ repoId, pack, doc, ref, theirs });
}
/**
@@ -145,13 +281,29 @@ export async function prepareMerge({
* The objects land in our own store, so the commits the two sides share are
* literally the same objects and the merge base below is a real answer.
*/
-async function mergeAgainstPack({ repoId, pack, doc, ref }) {
- const ctx = repoCtx(repoId);
+async function mergeAgainstPack({ repoId, pack, doc, ref, theirs }) {
+ await fetchRemotePack({ ...repoCtx(repoId), ...pack, ref });
+ // `theirs` names which of the pack's commits to merge. It defaults to the
+ // lesson itself, which is what pulling an original's changes or reviewing a
+ // proposal means — but catching up with our own hub while editing a variation
+ // has to merge the hub's copy of *that* variation, or we would fold the whole
+ // lesson into the variation and call it a sync.
+ return mergeAgainstCommit({ repoId, theirs: theirs || pack.head, doc });
+}
- await fetchRemotePack({ ...ctx, ...pack, ref });
+/**
+ * Merge one commit into the branch we are on — the part of a merge that has
+ * nothing to do with where the other side came from.
+ *
+ * Downloaded from the hub, unpacked from a proposal, or simply another branch of
+ * this same repository: by the time we are here it is an oid whose objects we
+ * hold, and the answer is the same three-way merge against the commit the two
+ * sides last agreed on.
+ */
+async function mergeAgainstCommit({ repoId, theirs, doc }) {
+ const ctx = repoCtx(repoId);
const ours = await headOid(ctx);
- const theirs = pack.head;
if (!ours) return null;
const identical = ours === theirs;
@@ -182,6 +334,18 @@ async function mergeAgainstPack({ repoId, pack, doc, ref }) {
// uncommitted edits must not be silently dropped by a merge.
const result = mergeDocs(baseDoc, doc, theirDoc);
+ // A fast-forward: their history already contains ours (we are the merge base),
+ // and we have nothing of our own on top — no commits, and nothing uncommitted
+ // in the editor either. Then "merging" is only moving our branch to theirs, and
+ // manufacturing a merge commit for it would put an entry in the lesson's
+ // timeline that records no decision and changes no content.
+ //
+ // Both halves matter. `base === ours` is the commit-graph half; diffing the
+ // live document against ours is the half the graph can't see, and skipping it
+ // would drop whatever the reviewer had typed but not yet paused long enough to
+ // commit.
+ const fastForward = base === ours && diffDocs(baseDoc, doc).length === 0;
+
return {
doc: preserveLocalFields(result.doc, doc),
conflicts: result.conflicts,
@@ -191,10 +355,119 @@ async function mergeAgainstPack({ repoId, pack, doc, ref }) {
base,
identical: false,
ahead: false,
+ fastForward,
+ // The branch this merge is *for*. A fast-forward moves a ref, and it must move
+ // the one the merge was computed against — not whatever HEAD happens to point
+ // at when the user finally confirms, which a variation switch in between would
+ // have changed.
+ ref: await currentBranchRef(ctx),
upToDate: false,
};
}
+/**
+ * Undo one commit, leaving everything since it alone.
+ *
+ * `restoreCommit` (repo.js) is the blunt instrument: it takes the whole document
+ * back to a point and drops every change after it. This is the precise one — undo
+ * *that* change, keep the rest — and it is the same three-way merge as everything
+ * else here, with the sides pointed backwards:
+ *
+ * base the document as that commit left it
+ * ours the document now
+ * theirs the document as it was immediately before
+ *
+ * Every rule then falls out correctly without a line of new logic. A block the
+ * commit changed differs between base and theirs, so theirs wins and it goes
+ * back. A block changed since differs between base and ours, so ours wins and is
+ * kept. A block in both is a genuine conflict — the change being undone has been
+ * built on, and only the author can say what they meant — so it reaches the usual
+ * dialog.
+ *
+ * The result is an ordinary forward commit with one parent, like a restore:
+ * history is never rewritten, so an undo can itself be undone.
+ *
+ * @returns {Promise