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.
- package/RELEASES.md +21 -0
- package/package.json +1 -1
- package/src/api/server.js +10 -1
- package/src/quilts/archiver.js +8 -1
- package/src/quilts/autorevert.js +49 -0
- package/src/quilts/bisect-run.js +31 -0
- package/src/quilts/block.js +133 -14
- package/src/quilts/bucket.js +25 -0
- package/src/quilts/claims.js +144 -0
- package/src/quilts/cli.js +206 -11
- package/src/quilts/client.js +37 -1
- package/src/quilts/describe.js +149 -3
- package/src/quilts/dogfood.js +1 -1
- package/src/quilts/errors.js +8 -0
- package/src/quilts/events.js +19 -5
- package/src/quilts/export-git.js +5 -1
- package/src/quilts/fastmerge.js +2 -2
- package/src/quilts/hash.js +1 -1
- package/src/quilts/history.js +266 -0
- package/src/quilts/import-git.js +154 -56
- package/src/quilts/import.js +56 -20
- package/src/quilts/index.js +34 -2
- package/src/quilts/lines.js +49 -0
- package/src/quilts/live.js +50 -0
- package/src/quilts/merge-smart.js +114 -0
- package/src/quilts/merge.js +75 -27
- package/src/quilts/objects.js +17 -12
- package/src/quilts/records.js +2 -1
- package/src/quilts/resolutions.js +69 -0
- package/src/quilts/reviews.js +195 -0
- package/src/quilts/routes.js +51 -14
- package/src/quilts/service.js +133 -25
- package/src/quilts/setup.js +2 -2
- package/src/quilts/signing.js +149 -0
- package/src/quilts/store-memory.js +152 -0
- package/src/quilts/store-rpc.js +16 -1
- package/src/quilts/tags.js +64 -0
- package/src/quilts/tokens.js +66 -0
- package/src/quilts/tools.js +195 -19
- package/src/quilts/webhook.js +116 -0
- package/src/server.js +2 -2
- package/src/{quilts → source-live}/live-block.js +4 -3
- package/src/{quilts → source-live}/live-square.js +2 -1
package/src/quilts/import.js
CHANGED
|
@@ -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
|
|
4
|
-
// with its own square; a merge commit's side commits
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
|
77
|
-
|
|
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
|
}
|
package/src/quilts/index.js
CHANGED
|
@@ -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
|
+
}
|
package/src/quilts/merge.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
297
|
-
|
|
298
|
-
|
|
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
|
})
|