heyquilt 0.3.119 → 0.3.120

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/RELEASES.md +21 -0
  2. package/package.json +1 -1
  3. package/src/api/server.js +10 -1
  4. package/src/quilts/archiver.js +8 -1
  5. package/src/quilts/autorevert.js +49 -0
  6. package/src/quilts/bisect-run.js +31 -0
  7. package/src/quilts/block.js +133 -14
  8. package/src/quilts/bucket.js +25 -0
  9. package/src/quilts/claims.js +144 -0
  10. package/src/quilts/cli.js +206 -11
  11. package/src/quilts/client.js +37 -1
  12. package/src/quilts/describe.js +149 -3
  13. package/src/quilts/dogfood.js +1 -1
  14. package/src/quilts/errors.js +8 -0
  15. package/src/quilts/events.js +19 -5
  16. package/src/quilts/export-git.js +5 -1
  17. package/src/quilts/fastmerge.js +2 -2
  18. package/src/quilts/hash.js +1 -1
  19. package/src/quilts/history.js +266 -0
  20. package/src/quilts/import-git.js +154 -56
  21. package/src/quilts/import.js +56 -20
  22. package/src/quilts/index.js +34 -2
  23. package/src/quilts/lines.js +49 -0
  24. package/src/quilts/live.js +50 -0
  25. package/src/quilts/merge-smart.js +114 -0
  26. package/src/quilts/merge.js +75 -27
  27. package/src/quilts/objects.js +17 -12
  28. package/src/quilts/records.js +2 -1
  29. package/src/quilts/resolutions.js +69 -0
  30. package/src/quilts/reviews.js +195 -0
  31. package/src/quilts/routes.js +51 -14
  32. package/src/quilts/service.js +133 -25
  33. package/src/quilts/setup.js +2 -2
  34. package/src/quilts/signing.js +149 -0
  35. package/src/quilts/store-memory.js +152 -0
  36. package/src/quilts/store-rpc.js +16 -1
  37. package/src/quilts/tags.js +64 -0
  38. package/src/quilts/tokens.js +66 -0
  39. package/src/quilts/tools.js +195 -19
  40. package/src/quilts/webhook.js +116 -0
  41. package/src/server.js +2 -2
  42. package/src/{quilts → source-live}/live-block.js +4 -3
  43. package/src/{quilts → source-live}/live-square.js +2 -1
@@ -1,25 +1,47 @@
1
1
  // The server side of a git import (plans/quilt-source-control/14): the importer (import-git.js,
2
2
  // run where the repository is) uploads packs of blobs and commit records, then sends the
3
- // commits in order through importBatch. Each commit on main's first-parent chain becomes a patch
4
- // with its own square; a merge commit's side commits become the commits of the merge's square.
3
+ // commits in order through importBatch. Each commit on a branch's first-parent chain becomes a
4
+ // patch on its line (HEAD's branch is main) with its own square; a merge commit's side commits
5
+ // become the commits of the merge's square. importKnown lets a second import add only new commits.
5
6
  // The snapshot of every patch is derived here, eagerly, as for any merge into a line.
6
7
  import { cleanCommit, commitId } from './records.js'
7
8
  import { QuiltError } from './service.js'
8
9
 
9
10
  const MAX_BATCH = 500
11
+ const MAX_KNOWN = 5000
12
+ // A git branch keeps its name as a line: git's own characters, no `..`, no trailing slash.
13
+ const BRANCH = /^[A-Za-z0-9._-][A-Za-z0-9._/-]{0,99}$/
14
+
15
+ export function importLineName (raw) {
16
+ const s = String(raw || '')
17
+ if (!BRANCH.test(s) || /\.\.|\/\/|\/$|\.lock$/.test(s) || s === 'main') throw new QuiltError(400, 'bad_request', `a git branch named ${s} cannot be a line`)
18
+ return s
19
+ }
10
20
 
11
21
  export function importOps (svc) {
12
- const { store, now, quiltFor, getCommit, treeGrower, seal, snapshot } = svc.internals
22
+ const { store, now, quiltFor, getCommit, treeGrower, seal, snapshot, fromStore } = svc.internals
13
23
 
14
- /**
15
- * items: [{ id, commit (JSON record), kind: 'patch' | 'side', square: { id, title, base,
16
- * holder }, git_sha, payload }], in an order where parents come first.
17
- */
18
- async function importBatch (me, ref, { items = [] } = {}) {
24
+ /** The quilt, if an import made it: only those take imported commits. */
25
+ async function importQuilt (me, ref) {
19
26
  const q = await quiltFor(me, ref, 'admin')
20
27
  const quilt = await store.getQuilt(q.id)
21
- if (!quilt.settings?.importing) throw new QuiltError(409, 'bad_request', 'this quilt was not made for an import (quilt import git makes a new quilt)')
28
+ if (!quilt.settings?.importing) throw new QuiltError(409, 'bad_request', 'this quilt was not made by an import (seam import git makes a new quilt, or adds to one it made)')
29
+ return q
30
+ }
31
+
32
+ /**
33
+ * lines: [{ name, tip }], new lines made first, each at a commit already imported (a git branch).
34
+ * items: [{ id, commit (JSON record), kind: 'patch' | 'side', line (default main), square: { id,
35
+ * title, base, holder }, git_sha, payload }], in an order where parents come first.
36
+ */
37
+ async function importBatch (me, ref, { items = [], lines = [] } = {}) {
38
+ const q = await importQuilt(me, ref)
22
39
  if (items.length > MAX_BATCH) throw new QuiltError(400, 'bad_request', `at most ${MAX_BATCH} commits a batch`)
40
+ for (const l of lines) {
41
+ const name = importLineName(l.name)
42
+ if (!l.tip || !(await store.getCommit(q.id, l.tip))) throw new QuiltError(422, 'bad_request', `line ${name} starts at a commit that is not imported`)
43
+ try { await store.createLine({ quilt_id: q.id, name, tip: l.tip, actor: me.member_id, at: now() }) } catch (err) { throw fromStore(err) }
44
+ }
23
45
  // Check everything first: ids, sealed records and blobs.
24
46
  const prepared = []
25
47
  for (const it of items) {
@@ -35,19 +57,24 @@ export function importOps (svc) {
35
57
  prepared.push({ it, c, id })
36
58
  }
37
59
  // Snapshots of the patches, in order, sealed in one server pack before any row names them.
38
- const line = await store.getLine(q.id, 'main')
39
- let tip = line.tip
40
- let root = await snapshot(q.id, tip)
60
+ const heads = new Map() // line → { tip, root }
41
61
  const grow = treeGrower(q.id)
42
62
  const nodes = []
43
63
  for (const p of prepared) {
44
64
  if (p.done || p.it.kind !== 'patch') continue
45
- if ((p.c.parents[0] || null) !== (tip || null)) throw new QuiltError(422, 'bad_request', `patch ${p.id.slice(0, 12)} does not follow main's tip`)
46
- const t = await grow(root, p.c.changes)
47
- root = t.root
65
+ const ln = p.it.line || 'main'
66
+ if (!heads.has(ln)) {
67
+ const row = await store.getLine(q.id, ln)
68
+ if (!row) throw new QuiltError(404, 'not_found', `no line ${ln}`)
69
+ heads.set(ln, { tip: row.tip, root: await snapshot(q.id, row.tip) })
70
+ }
71
+ const h = heads.get(ln)
72
+ if ((p.c.parents[0] || null) !== (h.tip || null)) throw new QuiltError(422, 'bad_request', `patch ${p.id.slice(0, 12)} does not follow ${ln}'s tip`)
73
+ const t = await grow(h.root, p.c.changes)
74
+ h.root = t.root
48
75
  nodes.push(...t.objects)
49
76
  p.root = t.root
50
- tip = p.id
77
+ h.tip = p.id
51
78
  }
52
79
  await seal(q.id, nodes)
53
80
  // Stub members for the authors (14): one row per distinct stub id, before any commit names it.
@@ -67,14 +94,15 @@ export function importOps (svc) {
67
94
  const at = now()
68
95
  const sq = await store.getSquare(p.it.square.id)
69
96
  if (!sq) {
70
- await store.openSquare({ square: { id: p.it.square.id, quilt_id: q.id, title: String(p.it.square.title || '').slice(0, 200), line: 'main', base: p.it.square.base || null, holder: p.it.square.holder || me.member_id, opened_by: me.member_id, writers: [], import: true }, at })
97
+ await store.openSquare({ square: { id: p.it.square.id, quilt_id: q.id, title: String(p.it.square.title || '').slice(0, 200), line: p.it.line || 'main', base: p.it.square.base || null, holder: p.it.square.holder || me.member_id, opened_by: me.member_id, writers: [], import: true }, at })
71
98
  }
72
99
  await store.stageFiles({ quilt_id: q.id, commit_id: p.id, files: p.c.changes })
73
100
  const record = { ...p.c, id: p.id, at }
74
101
  const payload = { ...(p.it.payload || {}), ...(p.it.git_sha ? { git_sha: p.it.git_sha } : {}) }
75
102
  if (p.it.kind === 'patch') {
76
- const cur = await store.getLine(q.id, 'main')
77
- await store.merge({ commit: record, line: 'main', expected_tip: cur.tip, expect_head: null, snapshot_root: p.root, actor: me.member_id, at, payload, import: true })
103
+ const ln = p.it.line || 'main'
104
+ const cur = await store.getLine(q.id, ln)
105
+ await store.merge({ commit: record, line: ln, expected_tip: cur.tip, expect_head: null, snapshot_root: p.root, actor: me.member_id, at, payload, import: true })
78
106
  await store.fillLinePaths({ quilt_id: q.id })
79
107
  patches++
80
108
  } else {
@@ -87,6 +115,14 @@ export function importOps (svc) {
87
115
  return { ok: true, commits, patches, tip: after.tip, patch_count: after.patch_count }
88
116
  }
89
117
 
118
+ /** POST /import/known { shas }: which git commits this quilt already has → { known: { sha: commit id } }. */
119
+ async function importKnown (me, ref, { shas = [] } = {}) {
120
+ const q = await importQuilt(me, ref)
121
+ if (!Array.isArray(shas) || shas.length > MAX_KNOWN) throw new QuiltError(400, 'bad_request', `at most ${MAX_KNOWN} shas a request`)
122
+ const list = shas.map((s) => String(s).toLowerCase()).filter((s) => /^[0-9a-f]{40,64}$/.test(s))
123
+ return { known: list.length ? await store.gitShas(q.id, list) : {} }
124
+ }
125
+
90
126
  /** Marks the import finished: the quilt takes ordinary merges from now on. */
91
127
  async function importDone (me, ref) {
92
128
  const q = await quiltFor(me, ref, 'admin')
@@ -94,5 +130,5 @@ export function importOps (svc) {
94
130
  return { ok: true, tip: line.tip, patch_count: line.patch_count, tip_commit: line.tip ? (await getCommit(q.id, line.tip)).id : null }
95
131
  }
96
132
 
97
- return { importBatch, importDone }
133
+ return { importBatch, importDone, importKnown }
98
134
  }
@@ -8,16 +8,29 @@ import { eventOps } from './events.js'
8
8
  import { exportOps } from './export.js'
9
9
  import { createArchiver } from './archiver.js'
10
10
  import { windowOps } from './window.js'
11
+ import { autoRevertOps } from './autorevert.js'
12
+ import { QuiltError } from './service.js'
13
+ import { lineOps } from './lines.js'
14
+ import { tagOps } from './tags.js'
15
+ import { claimOps } from './claims.js'
16
+ import { reviewOps } from './reviews.js'
17
+ import { resolutionOps } from './resolutions.js'
18
+ import { liveOps } from './live.js'
19
+ import { historyOps } from './history.js'
20
+ import { signingOps } from './signing.js'
21
+ import { tokenOps } from './tokens.js'
11
22
  import { createLimiter } from './limits.js'
12
23
 
13
24
  // Writes after which the events job runs (in the background; GET /events runs it too).
14
- const WRITES = ['commit', 'mergeIntoLine', 'rebase', 'cherryPick', 'revert', 'resolve', 'handoff', 'abandon', 'reopen', 'mergeSquare', 'fold', 'split']
25
+ const WRITES = ['commit', 'amend', 'mergeIntoLine', 'rebase', 'cherryPick', 'revert', 'resolve', 'handoff', 'abandon', 'reopen', 'mergeSquare', 'fold', 'split', 'newLine', 'freezeLine', 'unfreezeLine', 'createTag', 'createRelease', 'reportCheck', 'review', 'setLinePolicy', 'reportSquareCheck']
15
26
 
16
27
  export function createQuilts ({ store, objects, now = Date.now, events: eventOpts = {}, archive: archiveOpts = {}, mergeWindow: windowOpts = {}, rateLimits = {} }) {
17
28
  const svc = createQuiltService({ store, objects, now })
18
29
  // The merge window (07): `mergeWindow: false` turns the check off, `{ max }` sets the default.
19
30
  const win = windowOps(svc, windowOpts || {})
20
31
  if (windowOpts !== false) svc.internals.mergeWindow = win.checkWindow
32
+ const { guardReview, ...reviewApi } = reviewOps(svc)
33
+ svc.internals.guardReview = guardReview
21
34
  const merges = mergeOps(svc)
22
35
  const { mergeInternals, ...mergeApi } = merges
23
36
  const squares = squareOps(svc, mergeInternals)
@@ -27,7 +40,26 @@ export function createQuilts ({ store, objects, now = Date.now, events: eventOpt
27
40
  const { checkWindow, ...windowApi } = win
28
41
  // Per-token rate limits (04), applied by the HTTP routes: `rateLimits: false` turns them off.
29
42
  const rateLimiter = rateLimits === false ? null : createLimiter({ now, ...rateLimits })
30
- const api = { archiver, rateLimiter, ...windowApi, ...svc, ...mergeApi, ...blameOps(svc), ...importOps(svc), ...exportOps(svc), ...squares, ...ev }
43
+ const api = { archiver, rateLimiter, ...windowApi, ...svc, ...mergeApi, ...blameOps(svc), ...importOps(svc), ...exportOps(svc), ...lineOps(svc), ...tagOps(svc), ...claimOps(svc), ...reviewApi, ...resolutionOps(svc), ...tokenOps(svc), ...liveOps(svc), ...historyOps(svc, { mergeBase: squares.mergeBase }), ...squares, ...ev }
44
+ // Signed commits (signing.js): log { show_signature } checks each commit's signature.
45
+ const { withSignatures, ...signApi } = signingOps(svc)
46
+ Object.assign(api, signApi)
47
+ const plainLog = api.log
48
+ api.log = async (me, ref, opts = {}) => {
49
+ const { show_signature: show, ...rest } = opts
50
+ const r = await plainLog(me, ref, rest)
51
+ return show ? withSignatures(me, ref, r) : r
52
+ }
53
+ // A red check that names the patch it failed at reverts that patch by itself (07, autorevert.js).
54
+ const auto = autoRevertOps(svc, api)
55
+ const report = api.reportCheck
56
+ api.reportCheck = async (me, ref, line, body = {}) => {
57
+ const n = body.patch == null ? null : Number(body.patch)
58
+ if (n != null && (!Number.isInteger(n) || n < 1)) throw new QuiltError(400, 'bad_request', 'patch: the number of the patch on the line the check failed at')
59
+ const w = await report(me, ref, line, body)
60
+ if (n == null || body.status !== 'red') return w
61
+ return { ...w, revert: await auto.revertCulprit(me, ref, line, String(body.check).trim(), n) }
62
+ }
31
63
  for (const name of WRITES) {
32
64
  const fn = api[name]
33
65
  api[name] = async (me, ref, ...rest) => {
@@ -0,0 +1,49 @@
1
+ // Lines (plans/quilt-source-control/06): main, and release/<name> lines made from any revision.
2
+ // A square opens from any line and merges into any line; a frozen line refuses merges with
3
+ // line_frozen until it is unfrozen. Anyone who can write makes a line; an admin freezes one.
4
+ import { QuiltError } from './service.js'
5
+
6
+ const RELEASE = /^release\/[A-Za-z0-9._-]{1,100}$/
7
+
8
+ /** `1.0` or `release/1.0` → `release/1.0`; anything else is refused. */
9
+ export function lineName (raw) {
10
+ const s = String(raw || '').trim()
11
+ const name = s === 'main' || s.startsWith('release/') ? s : `release/${s}`
12
+ if (name !== 'main' && !RELEASE.test(name)) throw new QuiltError(400, 'bad_request', 'a new line is release/<name>: letters, digits, dots, dashes and underscores')
13
+ return name
14
+ }
15
+
16
+ export function lineOps (svc) {
17
+ const { store, now, quiltFor, resolve, fromStore } = svc.internals
18
+
19
+ const view = (l) => ({ name: l.name, tip: l.tip, patch_count: l.patch_count, frozen: !!l.frozen })
20
+
21
+ /** GET /lines: every line of the quilt, main first. */
22
+ async function lines (me, ref) {
23
+ const q = await quiltFor(me, ref, 'read')
24
+ const all = (await store.lines(q.id)).map(view)
25
+ return { lines: [...all.filter((l) => l.name === 'main'), ...all.filter((l) => l.name !== 'main')] }
26
+ }
27
+
28
+ /** POST /lines { name, from? }: a new line at a revision (default the tip of main). */
29
+ async function newLine (me, ref, { name, from } = {}) {
30
+ const q = await quiltFor(me, ref, 'write')
31
+ const n = lineName(name)
32
+ if (n === 'main') throw new QuiltError(409, 'line_exists', 'there is already a line main')
33
+ const tip = await resolve(q.id, from || 'main')
34
+ if (!tip) throw new QuiltError(400, 'bad_request', `${from || 'main'} has no commits to start a line from`)
35
+ try { return view(await store.createLine({ quilt_id: q.id, name: n, tip, actor: me.member_id, at: now() })) } catch (err) { throw fromStore(err) }
36
+ }
37
+
38
+ async function setFrozen (me, ref, line, frozen) {
39
+ const q = await quiltFor(me, ref, 'admin')
40
+ try { return view(await store.setLineFrozen({ quilt_id: q.id, name: String(line), frozen, actor: me.member_id, at: now() })) } catch (err) { throw fromStore(err) }
41
+ }
42
+
43
+ return {
44
+ lines,
45
+ newLine,
46
+ freezeLine: (me, ref, line) => setFrozen(me, ref, line, true),
47
+ unfreezeLine: (me, ref, line) => setFrozen(me, ref, line, false)
48
+ }
49
+ }
@@ -0,0 +1,50 @@
1
+ // A square's live work (02, sq:<id>:live): the uncommitted changes its holder shares on the server,
2
+ // so others (people, agents, CI) can read work in progress before it is committed. It is whole
3
+ // files (each put or delete replaces what the square's head has), replaced at once by each PUT,
4
+ // and never history: a commit is still how work is kept. Any host can feed it, a block folder
5
+ // (`seam live share`), an editor or a live-editing service, through the same route.
6
+ import { QuiltError, squareShort } from './service.js'
7
+ import { guardClaims } from './claims.js'
8
+
9
+ export function liveOps (svc) {
10
+ const { store, now, quiltFor, squareByRef, writable, prepareChanges, seal, snapshot, reader, fromStore } = svc.internals
11
+
12
+ // Only what still differs from the square's head: a change committed since drops out.
13
+ async function view (q, sq, row) {
14
+ const head = sq.head || sq.base
15
+ const root = await snapshot(q, head)
16
+ const changes = []
17
+ for (const c of row?.changes || []) {
18
+ const e = await reader(q).lookup(root, c.path)
19
+ if (c.op === 'delete' ? !e : (e && e.blob === c.blob && e.mode === c.mode)) continue
20
+ changes.push({ path: c.path, op: c.op, ...(c.op === 'put' ? { blob: c.blob, mode: c.mode, size: c.size, status: e ? 'modified' : 'added' } : { status: 'deleted' }) })
21
+ }
22
+ return { square: squareShort(sq.id), square_id: sq.id, head, made_on: row?.head || null, changes, by: row?.actor || null, at: row?.at || null }
23
+ }
24
+
25
+ /** GET /squares/:id/live: the square's shared uncommitted changes. */
26
+ async function live (me, ref, sqRef) {
27
+ const q = await quiltFor(me, ref, 'read')
28
+ const sq = await squareByRef(q.id, sqRef)
29
+ return view(q.id, sq, await store.getLive(q.id, sq.id))
30
+ }
31
+
32
+ /** PUT /squares/:id/live { changes: [{ path, op: 'put', content (base64) | blob, mode? } | { path, op: 'delete' }] }: replaces it; [] clears. */
33
+ async function setLive (me, ref, sqRef, body = {}) {
34
+ const q = await quiltFor(me, ref, 'write')
35
+ const sq = await writable(me, q, sqRef)
36
+ if (sq.state !== 'open') throw new QuiltError(409, 'square_closed', `the square is ${sq.state}`)
37
+ if (!Array.isArray(body.changes || [])) throw new QuiltError(400, 'bad_request', 'changes is a list')
38
+ if ((body.changes || []).length > 5000) throw new QuiltError(400, 'bad_request', 'up to 5000 changes')
39
+ const prep = await prepareChanges(q.id, sq, body.changes || [])
40
+ await guardClaims(store, q.id, sq, prep.changes.map((x) => x.path))
41
+ if (prep.blobs.length) await seal(q.id, prep.blobs, { by: me.member_id })
42
+ const changes = prep.changes.map(({ copy_from: _, ...c }) => c)
43
+ try {
44
+ const row = await store.setLive({ quilt_id: q.id, square_id: sq.id, head: sq.head || sq.base, changes, actor: me.member_id, at: now() })
45
+ return view(q.id, sq, row)
46
+ } catch (err) { throw fromStore(err) }
47
+ }
48
+
49
+ return { live, setLive }
50
+ }
@@ -0,0 +1,114 @@
1
+ // The merge ladder seam settles a clash with (plans/quilt-source-control/07, steps 1 and 2): line
2
+ // by line as git does, then two inserts at one place, a one-line import both sides changed,
3
+ // whitespace-only changes, and word by word within the clashing lines. Seam's own copy, so the
4
+ // Quilt app's merges can change without changing how a quilt merges.
5
+ import { diff3Merge } from 'node-diff3'
6
+
7
+ const lines = (s) => s.split('\n')
8
+
9
+ /**
10
+ * A text's line endings: 'crlf' (every line ends \r\n), 'lf' (none does), 'mixed', or null (one line:
11
+ * nothing to tell).
12
+ */
13
+ function eolOf (s) {
14
+ if (typeof s !== 'string' || !s.includes('\n')) return null
15
+ if (!s.includes('\r\n')) return 'lf'
16
+ return s.replace(/\r\n/g, '').includes('\n') ? 'mixed' : 'crlf'
17
+ }
18
+
19
+ /**
20
+ * The merges compare lines, so a side saved with Windows line endings (\r\n) against a base and other
21
+ * side with \n differs on every line: diff3 then reads it as "ours deleted the whole file and inserted
22
+ * it again", the delete loses to theirs' edits and the insert stays, and the file comes out as both
23
+ * versions one after the other (task 7b480c8b07c4fa02: web/lib/docs.js, CRLF old + LF new, every
24
+ * export twice). When the sides' line endings differ, all three are merged with \n and the result is
25
+ * written in the line endings of the side that changed them from the base (ours when both did, or
26
+ * when there is no base to tell), as any other edit to the file would be kept.
27
+ */
28
+ function eolAware (merge) {
29
+ return function (base, ours, theirs, ...rest) {
30
+ const eols = [base, ours, theirs].map(eolOf)
31
+ const seen = new Set(eols.filter(Boolean))
32
+ if (!seen.has('crlf') || seen.size < 2) return merge(base, ours, theirs, ...rest)
33
+ const [eb, eo, et] = eols
34
+ const target = (eo && eo !== eb) ? eo : (et && et !== eb) ? et : (eo || et || eb)
35
+ const lf = (s) => typeof s === 'string' ? s.replace(/\r\n/g, '\n') : s
36
+ const back = (t) => target === 'crlf' && typeof t === 'string' ? t.replace(/\n/g, '\r\n') : t
37
+ const r = merge(lf(base), lf(ours), lf(theirs), ...rest)
38
+ return typeof r === 'string' ? back(r) : { ...r, text: back(r.text) }
39
+ }
40
+ }
41
+
42
+ /** Two blocks inserted at one place, as one: shared leading and trailing lines once, the middles in order. */
43
+ function addedBoth (a, b) {
44
+ let pre = 0
45
+ while (pre < a.length && pre < b.length && a[pre] === b[pre]) pre++
46
+ let post = 0
47
+ while (post < a.length - pre && post < b.length - pre && a[a.length - 1 - post] === b[b.length - 1 - post]) post++
48
+ return [...a.slice(0, pre), ...a.slice(pre, a.length - post), ...b.slice(pre, b.length - post), ...a.slice(a.length - post)]
49
+ }
50
+
51
+ const IMPORT = /^(\s*import\s*\{)([^}]*)(\}\s*from\s*(['"][^'"]+['"]).*)$/
52
+
53
+ /** Both sides changed the same one-line import of the same module: the union of its names, or null. */
54
+ function sameImport (o, a, b) {
55
+ if (o.length !== 1 || a.length !== 1 || b.length !== 1) return null
56
+ const [mo, ma, mb] = [IMPORT.exec(o[0]), IMPORT.exec(a[0]), IMPORT.exec(b[0])]
57
+ if (!mo || !ma || !mb || mo[4] !== ma[4] || ma[4] !== mb[4]) return null
58
+ const names = (m) => m[2].split(',').map((s) => s.trim()).filter(Boolean)
59
+ const [no, na, nb] = [names(mo), names(ma), names(mb)]
60
+ const gone = new Set(no.filter((n) => !na.includes(n) || !nb.includes(n)))
61
+ const all = [...na, ...nb.filter((n) => !na.includes(n))].filter((n) => !gone.has(n))
62
+ return [`${ma[1]} ${all.join(', ')} ${ma[3]}`]
63
+ }
64
+
65
+ const normWs = (l) => l.replace(/\s+/g, ' ').trim()
66
+ const sameIgnoringWs = (a, b) => a.length === b.length && a.every((l, i) => normWs(l) === normWs(b[i]))
67
+ const TOKEN = /\s+|\w+|[^\w\s]/g
68
+
69
+ /** Two blocks of lines merged word by word against their base, or null when the same words changed on both sides. */
70
+ function mergeWords (o, a, b) {
71
+ const tok = (ls) => ls.join('\n').match(TOKEN) || []
72
+ const regions = diff3Merge(tok(a), tok(o), tok(b), { excludeFalseConflicts: true })
73
+ if (regions.some((r) => !r.ok)) return null
74
+ return regions.map((r) => r.ok.join('')).join('').split('\n')
75
+ }
76
+
77
+ /**
78
+ * The clash ladder's mechanical and structural steps (plan §7.3, steps 1 and 2): mergeAdditive, then
79
+ * each region still clashing is tried again ignoring whitespace (a side that only reindented loses
80
+ * to the side that changed something), then word by word within the clashing lines (two edits to
81
+ * different words of one line both land); a file marked merge=union takes both sides, ours first.
82
+ * Returns { text, conflicts, how } with `how` the step that settled it: 'lines', 'whitespace',
83
+ * 'words' or 'union'; `conflicts` lists what no step could settle (text then holds ours there).
84
+ */
85
+ export const mergeSmart = eolAware(function mergeSmart (base, ours, theirs, { union = false } = {}) {
86
+ const regions = diff3Merge(lines(ours), lines(base), lines(theirs), { excludeFalseConflicts: true })
87
+ const out = []
88
+ const conflicts = []
89
+ let how = 'lines'
90
+ const rank = { lines: 0, whitespace: 1, words: 2, union: 3 }
91
+ const step = (h) => { if (rank[h] > rank[how]) how = h }
92
+ for (const r of regions) {
93
+ if (r.ok) { out.push(...r.ok); continue }
94
+ const c = r.conflict
95
+ const settled = ladder(c, { union })
96
+ if (settled) { out.push(...settled.lines); step(settled.how); continue }
97
+ conflicts.push({ base: c.o, ours: c.a, theirs: c.b })
98
+ out.push(...c.a)
99
+ }
100
+ return { text: out.join('\n'), conflicts, how }
101
+ })
102
+
103
+ /** mergeSmart's steps for one region both sides changed ({ o, a, b }): { lines, how }, or null when none settles it. */
104
+ function ladder (c, { union = false } = {}) {
105
+ if (union) return { lines: [...c.a, ...c.b], how: 'union' }
106
+ const both = c.o.length === 0 ? addedBoth(c.a, c.b) : sameImport(c.o, c.a, c.b)
107
+ if (both) return { lines: both, how: 'lines' }
108
+ if (sameIgnoringWs(c.a, c.b)) return { lines: c.a, how: 'whitespace' } // the same change, spaced differently
109
+ if (sameIgnoringWs(c.a, c.o)) return { lines: c.b, how: 'whitespace' } // ours only reindented
110
+ if (sameIgnoringWs(c.b, c.o)) return { lines: c.a, how: 'whitespace' } // theirs only reindented
111
+ const words = mergeWords(c.o, c.a, c.b)
112
+ if (words) return { lines: words, how: 'words' }
113
+ return null
114
+ }
@@ -3,6 +3,7 @@
3
3
  // by compare-and-swap; a lost race is retried here, never by the client. A textual conflict
4
4
  // never reaches a line: the merge is refused and, by default, the conflicted rebase becomes the
5
5
  // square's head with the conflicts kept as data (conflict terms), so the square carries on.
6
+ import { recall, remember } from './resolutions.js'
6
7
  import { fastMerge as mergeSmart } from './fastmerge.js' // mergeSmart (src/merge3.js) behind a fast clean path
7
8
  import { cleanCommit, commitBytes, commitId, blobId, termBytes, termId, cleanPath } from './records.js'
8
9
  import { sameEntry } from './tree.js'
@@ -82,8 +83,15 @@ export function mergeOps (svc) {
82
83
  for (const p of oursPaths.keys()) for (const t of theirsPaths.keys()) if (p !== t && (t.startsWith(p + '/') || p.startsWith(t + '/'))) dirClash.push([p, t])
83
84
 
84
85
  const side = (e, s) => ({ ...(e && e.blob ? { blob: e.blob, mode: e.mode } : {}), from_commit: s.commit, ...(s.square ? { from_square: s.square } : {}), ...(s.by ? { by: s.by } : {}) })
85
- const conflict = (p, kind, o, a, b, suggestion = null, clash = null) => {
86
+ const conflict = async (p, kind, o, a, b, suggestion = null, clash = null) => {
86
87
  const term = { kind, adds: [side(a, sides.ours), side(b, sides.theirs)], removes: [side(o, sides.base || {})], origin, ...(suggestion ? { suggestion } : {}), ...(clash ? { clash } : {}) }
88
+ // The same conflict settled before (07, D12): apply the remembered answer and say so.
89
+ const known = !(a && a.mode === 'conflict') && !(b && b.mode === 'conflict') ? await recall(store, q, p, term) : null
90
+ if (known) {
91
+ finals.set(p, known.blob ? { path: p, mode: known.mode || 'file', blob: known.blob, size: known.size } : null)
92
+ combined.push({ path: p, how: 'reuse', by: known.by })
93
+ return
94
+ }
87
95
  const id = termId(term)
88
96
  terms.push({ kind: 'term', id, bytes: termBytes(term) })
89
97
  finals.set(p, { path: p, mode: 'conflict', term: id })
@@ -95,26 +103,26 @@ export function mergeOps (svc) {
95
103
  const a = oursPaths.get(p).after || null
96
104
  const b = theirsPaths.get(p).after || null
97
105
  if (sameOrBoth(a, b)) { finals.set(p, a); continue }
98
- if (!a || !b) { conflict(p, 'edit_vs_delete', o, a, b); continue }
99
- if (a.mode === 'conflict' || b.mode === 'conflict') { conflict(p, 'content', o, a, b); continue }
106
+ if (!a || !b) { await conflict(p, 'edit_vs_delete', o, a, b); continue }
107
+ if (a.mode === 'conflict' || b.mode === 'conflict') { await conflict(p, 'content', o, a, b); continue }
100
108
  // Modes: the side that changed it wins; both changed it differently is a mode term.
101
109
  const mode = a.mode === b.mode ? a.mode : (o && a.mode === o.mode) ? b.mode : (o && b.mode === o.mode) ? a.mode : null
102
- if (a.blob === b.blob) { if (mode) finals.set(p, { ...a, mode }); else conflict(p, 'mode', o, a, b); continue }
110
+ if (a.blob === b.blob) { if (mode) finals.set(p, { ...a, mode }); else await conflict(p, 'mode', o, a, b); continue }
103
111
  const [ob, ab, bb] = [o && o.blob ? await readBlob(q, o.blob) : Buffer.alloc(0), await readBlob(q, a.blob), await readBlob(q, b.blob)]
104
- if (!isText(ob) || !isText(ab) || !isText(bb)) { conflict(p, 'content', o, a, b); continue }
112
+ if (!isText(ob) || !isText(ab) || !isText(bb)) { await conflict(p, 'content', o, a, b); continue }
105
113
  const m = mergeSmart(ob.toString('utf8'), ab.toString('utf8'), bb.toString('utf8'))
106
114
  if (!m.conflicts.length && m.how === 'words') {
107
115
  // 07: a word merge is a suggestion only; the term carries the text it made.
108
116
  const sb = Buffer.from(m.text, 'utf8')
109
117
  const sid = blobId(sb)
110
118
  blobs.push({ kind: 'blob', id: sid, bytes: sb })
111
- conflict(p, 'content', o, a, b, { how: 'words', blob: sid })
119
+ await conflict(p, 'content', o, a, b, { how: 'words', blob: sid })
112
120
  continue
113
121
  }
114
122
  // Both sides inserted at one place the base had nothing and no step settles it: `order`.
115
- if (m.conflicts.length && o && m.conflicts.every((c) => c.base.length === 0)) { conflict(p, 'order', o, a, b); continue }
116
- if (m.conflicts.length || !AUTO.has(m.how)) { conflict(p, o ? 'content' : 'name', o, a, b); continue }
117
- if (!mode) { conflict(p, 'mode', o, a, b); continue }
123
+ if (m.conflicts.length && o && m.conflicts.every((c) => c.base.length === 0)) { await conflict(p, 'order', o, a, b); continue }
124
+ if (m.conflicts.length || !AUTO.has(m.how)) { await conflict(p, o ? 'content' : 'name', o, a, b); continue }
125
+ if (!mode) { await conflict(p, 'mode', o, a, b); continue }
118
126
  const bytes = Buffer.from(m.text, 'utf8')
119
127
  const id = blobId(bytes)
120
128
  blobs.push({ kind: 'blob', id, bytes })
@@ -125,7 +133,7 @@ export function mergeOps (svc) {
125
133
  for (const [p, t] of dirClash) clashes.set(p, [...(clashes.get(p) || []), t])
126
134
  for (const [p, others] of clashes) {
127
135
  const e = finals.get(p)
128
- if (e && e.mode !== 'conflict') conflict(p, 'name', oursPaths.get(p)?.before || theirsPaths.get(p)?.before || null, oursPaths.get(p)?.after || null, theirsPaths.get(p)?.after || null, null, others)
136
+ if (e && e.mode !== 'conflict') await conflict(p, 'name', oursPaths.get(p)?.before || theirsPaths.get(p)?.before || null, oursPaths.get(p)?.after || null, theirsPaths.get(p)?.after || null, null, others)
129
137
  }
130
138
  return { finals, blobs, terms, combined, conflicts, oursPaths, theirsPaths, overlap }
131
139
  }
@@ -185,7 +193,7 @@ export function mergeOps (svc) {
185
193
  * Merge a square into a line (07's MergeRequest): { square, expect_head, message?,
186
194
  * on_conflict: 'rebase' | 'leave', dry_run, expect_tip? }.
187
195
  */
188
- async function mergeIntoLine (me, ref, lineName, body) {
196
+ async function mergeIntoLine (me, ref, lineName, body, { reverts } = {}) {
189
197
  const q = await quiltFor(me, ref, 'write')
190
198
  let sq = await writable(me, q, body.square || body.square_id)
191
199
  // A square left 'merging' by a server that died mid-merge goes back to 'open' once its lease lapsed (07).
@@ -196,9 +204,13 @@ export function mergeOps (svc) {
196
204
  const ln = await store.getLine(q.id, line)
197
205
  if (!ln) throw new QuiltError(404, 'not_found', `no line ${line}`)
198
206
  if (ln.frozen) throw new QuiltError(423, 'line_frozen', `${line} is frozen`)
207
+ const parent = await openParent(sq)
208
+ if (parent) throw new QuiltError(409, 'parent_open', `this square is stacked on ${squareShort(parent.id)}, which has not merged: merge it first, or fold this square into it`, { parent: squareShort(parent.id) })
199
209
  if (sq.state !== 'open') throw new QuiltError(409, sq.state === 'merging' ? 'square_merging' : 'square_closed', `the square is ${sq.state}`)
200
210
  if ((sq.head || null) !== (expect || null)) throw new QuiltError(409, 'head_moved', 'the square moved on since you looked', { head: sq.head })
201
211
  if (!sq.head) throw new QuiltError(400, 'bad_request', 'the square has no commits to merge')
212
+ // The line's review rule (13); an automatic revert of a red patch does not wait for approvals.
213
+ if (!reverts && svc.internals.guardReview) await svc.internals.guardReview(q.id, sq, line)
202
214
  const held = await conflictsAt(q.id, sq.head)
203
215
  if (held.length) throw new QuiltError(422, 'conflict', `the square holds ${held.length} conflicted path${held.length === 1 ? '' : 's'} (${held.map((e) => e.path).join(', ')}); resolve them first`, { head: sq.head, paths: held.map((e) => e.path) })
204
216
  const message = body.message || await defaultMessage(q.id, sq)
@@ -249,10 +261,11 @@ export function mergeOps (svc) {
249
261
  await seal(q.id, r.objects, { by: me.member_id })
250
262
  await store.stageFiles({ quilt_id: q.id, commit_id: r.made.id, files: r.made.record.changes })
251
263
  try {
252
- const m = await store.merge({ commit: { ...r.made.record, id: r.made.id, at: now() }, line, expected_tip: tip, expect_head: sq.head, snapshot_root: r.root, actor: me.member_id, at: now(), payload: { combined: r.combined } })
264
+ const m = await store.merge({ commit: { ...r.made.record, id: r.made.id, at: now() }, line, expected_tip: tip, expect_head: sq.head, snapshot_root: r.root, actor: me.member_id, at: now(), payload: { combined: r.combined, ...(reverts ? { reverts } : {}) } })
253
265
  // 03's second short transaction; if it fails, readers still see the patch as pending.
254
266
  await store.fillLinePaths({ quilt_id: q.id }).catch(() => {})
255
- return { ok: true, seq: m.seq, merged: { line, patch_no: m.patch_no, commit: r.made.id, seq: m.seq }, retries, combined: r.combined, fast: r.fast, warnings, square: await squareView(await store.getSquare(sq.id)) }
267
+ const restacked = await restackChildren(q, sq, r.made.id, me)
268
+ return { ok: true, seq: m.seq, merged: { line, patch_no: m.patch_no, commit: r.made.id, seq: m.seq }, retries, combined: r.combined, fast: r.fast, warnings, ...(restacked.length ? { restacked } : {}), square: await squareView(await store.getSquare(sq.id)) }
256
269
  } catch (err) {
257
270
  if (err.code !== 'tip_moved') throw err
258
271
  retries++
@@ -286,29 +299,60 @@ export function mergeOps (svc) {
286
299
  return n
287
300
  }
288
301
 
289
- /** Rebase a square onto its line's tip (or `onto`): conflicts become terms, never a stop (07). */
302
+ /** Rebase a square onto its line's tip, its open parent square's head when stacked, or `onto`: conflicts become terms, never a stop (07). */
290
303
  async function rebase (me, ref, sqRef, body = {}) {
291
304
  const q = await quiltFor(me, ref, 'write')
292
305
  const sq = await writable(me, q, sqRef)
293
306
  const expect = body.expect_head === undefined ? sq.head : body.expect_head
294
307
  return once('rebase', sq.id, expect, body, async () => {
295
308
  if (sq.state !== 'open') throw new QuiltError(409, sq.state === 'merging' ? 'square_merging' : 'square_closed', `the square is ${sq.state}`)
296
- const onto = body.onto ? await resolve(q.id, body.onto) : (await store.getLine(q.id, sq.line)).tip
297
- if (onto === sq.base) return { ok: true, noop: true, square: await squareView(sq) }
298
- if (!sq.head) {
299
- const s = await store.setSquare({ square_id: sq.id, patch: { base: onto }, op: 'rebase', actor: me.member_id, at: now() })
300
- return { ok: true, square: await squareView(s), conflicted: 0 }
301
- }
302
- const r = await rebaseCommit(q.id, sq, sq.head, onto, {})
303
- await seal(q.id, r.objects, { by: me.member_id })
304
- await store.stageFiles({ quilt_id: q.id, commit_id: r.made.id, files: r.made.record.changes })
305
- try {
306
- const row = await store.commit({ commit: { ...r.made.record, id: r.made.id, at: now() }, expect_head: expect, actor: me.member_id, at: now(), payload: { rebased_onto: onto }, square_patch: { base: onto } })
307
- return { ok: true, seq: row.seq, result: commitView({ ...row, changes: r.made.record.changes }), conflicted: r.conflicts.length, terms: r.conflicts, combined: r.combined, square: await squareView(await store.getSquare(sq.id)) }
308
- } catch (err) { throw fromStore(err) }
309
+ const parent = await openParent(sq)
310
+ const onto = body.onto ? await resolve(q.id, body.onto) : parent ? (parent.head || parent.base) : (await store.getLine(q.id, sq.line)).tip
311
+ return rebaseSquare(q, sq, onto, me, expect, {})
309
312
  })
310
313
  }
311
314
 
315
+ /** A stacked square's parent while it is still open or merging (06), else null. */
316
+ async function openParent (sq) {
317
+ if (!sq.parent_square) return null
318
+ const p = await store.getSquare(sq.parent_square)
319
+ return p && (p.state === 'open' || p.state === 'merging') ? p : null
320
+ }
321
+
322
+ async function rebaseSquare (q, sq, onto, me, expect, payload) {
323
+ if (onto === sq.base) return { ok: true, noop: true, square: await squareView(sq) }
324
+ if (!sq.head) {
325
+ const s = await store.setSquare({ square_id: sq.id, patch: { base: onto }, op: 'rebase', actor: me.member_id, at: now() })
326
+ return { ok: true, square: await squareView(s), conflicted: 0 }
327
+ }
328
+ const r = await rebaseCommit(q.id, sq, sq.head, onto, {})
329
+ await seal(q.id, r.objects, { by: me.member_id })
330
+ await store.stageFiles({ quilt_id: q.id, commit_id: r.made.id, files: r.made.record.changes })
331
+ try {
332
+ const row = await store.commit({ commit: { ...r.made.record, id: r.made.id, at: now() }, expect_head: expect, actor: me.member_id, at: now(), payload: { rebased_onto: onto, ...payload }, square_patch: { base: onto } })
333
+ return { ok: true, seq: row.seq, result: commitView({ ...row, changes: r.made.record.changes }), conflicted: r.conflicts.length, terms: r.conflicts, combined: r.combined, square: await squareView(await store.getSquare(sq.id)) }
334
+ } catch (err) { throw fromStore(err) }
335
+ }
336
+
337
+ /**
338
+ * Restack (06, D14): when a square merges, the open squares stacked on it are rebased onto the
339
+ * line's new tip, conflicts kept as terms in their heads. A child whose head moved meanwhile is
340
+ * left for its holder to rebase; the answer lists what happened to each.
341
+ */
342
+ async function restackChildren (q, parent, tip, me) {
343
+ const kids = (await store.squares(q.id, { state: 'open' })).filter((s) => s.parent_square === parent.id)
344
+ const out = []
345
+ for (const k of kids) {
346
+ try {
347
+ const r = await rebaseSquare(q, k, tip, me, k.head, { restacked: { parent_square: parent.id } })
348
+ out.push({ square: squareShort(k.id), holder: k.holder, conflicted: r.conflicted || 0, ...(r.terms && r.terms.length ? { terms: r.terms } : {}) })
349
+ } catch (err) {
350
+ out.push({ square: squareShort(k.id), holder: k.holder, error: err.code || 'failed' })
351
+ }
352
+ }
353
+ return out
354
+ }
355
+
312
356
  /**
313
357
  * Applies one commit's change to a square as a single-parent commit (07): three-way with the
314
358
  * picked commit's first parent as base. `invert` is revert: base and theirs swap.
@@ -385,6 +429,7 @@ export function mergeOps (svc) {
385
429
  const cur = await entryMap(q.id, sq.head, (body.entries || []).map((e) => cleanPath(e.path)))
386
430
  const changes = []
387
431
  const resolved = []
432
+ const picks = []
388
433
  for (const e of body.entries || []) {
389
434
  const p = cleanPath(e.path)
390
435
  const at = cur.get(p)
@@ -414,6 +459,7 @@ export function mergeOps (svc) {
414
459
  for (const other of t.clash) if (await reader(q.id).lookup(root, other) && !changes.some((x) => x.path === other)) { changes.push({ path: other, op: 'delete' }); dropped.push(other) }
415
460
  }
416
461
  resolved.push({ path: p, term: at.term, how: typeof c === 'object' ? 'blob' : String(c), ...(dropped.length ? { dropped } : {}) })
462
+ picks.push({ path: p, term: at.term, pick, how: typeof c === 'object' ? 'blob' : String(c) })
417
463
  }
418
464
  if (!changes.length) throw new QuiltError(400, 'bad_request', 'name the paths to resolve')
419
465
  const head = await getCommit(q.id, sq.head)
@@ -422,6 +468,8 @@ export function mergeOps (svc) {
422
468
  await store.stageFiles({ quilt_id: q.id, commit_id: made.id, files: made.record.changes })
423
469
  try {
424
470
  const row = await store.commit({ commit: { ...made.record, id: made.id, at: now() }, expect_head: expect, actor: me.member_id, at: now(), payload: { resolved } })
471
+ // Settled once, settled for good (07, D12), unless the resolver says remember: false.
472
+ if (body.remember !== false) await remember(store, getTerm, q.id, picks, me, now())
425
473
  return { ok: true, seq: row.seq, result: commitView({ ...row, changes: made.record.changes }), conflicted: (await conflictsAt(q.id, made.id)).length, square: await squareView(await store.getSquare(sq.id)) }
426
474
  } catch (err) { throw fromStore(err) }
427
475
  })