heyquilt 0.3.121 → 0.3.138
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 +292 -17
- package/bin/quilt.js +72 -5
- package/desktop/main.js +16 -2
- package/package.json +3 -1
- package/src/agent-task-workflow.js +2 -2
- package/src/agents/common.js +13 -0
- package/src/ai-vm.js +9 -1
- package/src/api/activity.js +5 -0
- package/src/api/memory-store.js +21 -10
- package/src/api/notify.js +134 -0
- package/src/api/routes/browsers.js +29 -0
- package/src/api/routes/github.js +0 -13
- package/src/api/routes/relay.js +26 -3
- package/src/api/routes/seam-connect.js +103 -0
- package/src/api/routes/session-migrate.js +39 -0
- package/src/api/routes/sessions.js +73 -1
- package/src/api/server.js +46 -12
- package/src/api/supabase-store.js +53 -4
- package/src/autowork.js +182 -16
- package/src/connection.js +39 -9
- package/src/control.js +139 -13
- package/src/force-date.js +27 -0
- package/src/fsutil.js +13 -3
- package/src/gitstate.js +38 -2
- package/src/history.js +25 -1
- package/src/mcp.js +93 -7
- package/src/merge3.js +11 -3
- package/src/now-summary.js +113 -0
- package/src/pathrules.js +12 -0
- package/src/protocol.js +10 -2
- package/src/quilt-default.js +84 -0
- package/src/quilt-history.js +106 -0
- package/src/quilt-remote.js +78 -0
- package/src/relay-commit.js +5 -0
- package/src/relay-lease.js +6 -2
- package/src/relay-mcp.js +172 -8
- package/src/relay-outbound.js +1 -1
- package/src/relay-prune.js +4 -1
- package/src/release-words.js +2 -2
- package/src/runner.js +20 -7
- package/src/seam-access.js +97 -0
- package/src/seam-claims.js +53 -0
- package/src/seam-url.js +15 -0
- package/src/server.js +1767 -33
- package/src/session-follow.js +515 -0
- package/src/session-layout3.js +466 -0
- package/src/session-migrate.js +123 -0
- package/src/session-quilt.js +145 -0
- package/src/session-seam.js +284 -0
- package/src/session.js +899 -46
- package/src/source-live/bring-in.js +283 -0
- package/src/source-live/live-block.js +507 -35
- package/src/source-live/live-square.js +17 -6
- package/src/status.js +5 -0
- package/src/ui/agent-guide.js +1 -1
- package/src/ui/app.css +85 -20
- package/src/ui/app.js +4 -1
- package/src/ui/chat.js +9 -3
- package/src/ui/cloud-ai-model.js +13 -0
- package/src/ui/cloud-ai.js +10 -2
- package/src/ui/common.js +6 -2
- package/src/ui/fileview.js +20 -7
- package/src/ui/force-words.js +19 -0
- package/src/ui/git-rule.js +9 -0
- package/src/ui/home.js +0 -147
- package/src/ui/loom-model.js +15 -1
- package/src/ui/quiltview.js +213 -0
- package/src/ui/seam-model.js +251 -0
- package/src/ui/session.js +413 -31
- package/src/ui/voice-call.js +83 -14
- package/src/ui/voice-model.js +39 -0
- package/src/ui/voice.js +167 -17
- package/src/ui-server.js +80 -28
- package/src/update-check.js +25 -1
- package/src/voice.js +13 -6
- package/src/git.js +0 -147
- package/src/quilts/AGENTS.md +0 -1
- package/src/quilts/CLAUDE.md +0 -1
- package/src/quilts/archiver.js +0 -175
- package/src/quilts/autorevert.js +0 -49
- package/src/quilts/bisect-run.js +0 -31
- package/src/quilts/blame.js +0 -103
- package/src/quilts/block.js +0 -347
- package/src/quilts/bucket.js +0 -25
- package/src/quilts/cbor.js +0 -92
- package/src/quilts/claims.js +0 -144
- package/src/quilts/cli.js +0 -449
- package/src/quilts/client.js +0 -128
- package/src/quilts/conflict-format.js +0 -190
- package/src/quilts/describe.js +0 -266
- package/src/quilts/dogfood.js +0 -82
- package/src/quilts/errors.js +0 -8
- package/src/quilts/events.js +0 -160
- package/src/quilts/export-git.js +0 -130
- package/src/quilts/export.js +0 -81
- package/src/quilts/fastmerge.js +0 -41
- package/src/quilts/hash.js +0 -44
- package/src/quilts/history.js +0 -266
- package/src/quilts/ids.js +0 -12
- package/src/quilts/import-git.js +0 -291
- package/src/quilts/import.js +0 -134
- package/src/quilts/index.js +0 -72
- package/src/quilts/limits.js +0 -67
- package/src/quilts/lines.js +0 -49
- package/src/quilts/live.js +0 -50
- package/src/quilts/merge-smart.js +0 -114
- package/src/quilts/merge.js +0 -486
- package/src/quilts/objects.js +0 -144
- package/src/quilts/pack.js +0 -118
- package/src/quilts/records.js +0 -211
- package/src/quilts/resolutions.js +0 -69
- package/src/quilts/reviews.js +0 -195
- package/src/quilts/routes.js +0 -126
- package/src/quilts/service.js +0 -923
- package/src/quilts/setup.js +0 -34
- package/src/quilts/signing.js +0 -149
- package/src/quilts/squares.js +0 -239
- package/src/quilts/store-memory.js +0 -683
- package/src/quilts/store-rpc.js +0 -79
- package/src/quilts/store-supabase.js +0 -13
- package/src/quilts/tags.js +0 -64
- package/src/quilts/textdiff.js +0 -96
- package/src/quilts/tokens.js +0 -66
- package/src/quilts/tools.js +0 -375
- package/src/quilts/tree.js +0 -351
- package/src/quilts/webhook.js +0 -116
- package/src/quilts/window.js +0 -119
- package/src/quilts/xdiff.js +0 -435
- package/src/ui/branches.js +0 -111
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
// Quilt's side of Seam: the room's switch to layout 3 (Seam's plans/quilt-source-control/16, "How a
|
|
2
|
+
// session becomes a quilt"). After the session became a quilt (session-migrate.js), each branch
|
|
3
|
+
// document is copied into its square's live document on the relay (`quilt/<square id>`,
|
|
4
|
+
// source-live/live-square.js), whose shape is an overlay: only the paths that differ from the
|
|
5
|
+
// square's head. The default branch's difference from `main` goes to an adoption square. The copy
|
|
6
|
+
// is checked against the branch document before `meta.layout = 3` is written; the branch
|
|
7
|
+
// documents themselves stay where they are, read-only, so going back to layout 2 loses nothing.
|
|
8
|
+
// Seam (src/quilts/) never imports this.
|
|
9
|
+
import crypto from 'node:crypto'
|
|
10
|
+
import path from 'node:path'
|
|
11
|
+
import * as Y from 'yjs'
|
|
12
|
+
import { blobId } from '@heyquilt/seam/lib/quilts/records.js'
|
|
13
|
+
import { liveMaps, startHead, entryOf, entryId } from './source-live/live-square.js'
|
|
14
|
+
import { makeIgnore, scopeIgnore, isIgnored, submodulePathsIn, IGNORE_FILES, MAX_TEXT_BYTES, MAX_BINARY_BYTES } from './pathrules.js'
|
|
15
|
+
import { looksBinary, sha1 } from './fsutil.js'
|
|
16
|
+
import { applyTextDiff } from './textdiff.js'
|
|
17
|
+
import { sameEditClient, asClient } from './same-edit.js'
|
|
18
|
+
import { removePath } from './branch-view.js'
|
|
19
|
+
import { mergeAuto } from './merge3.js'
|
|
20
|
+
|
|
21
|
+
export const LAYOUT_QUILT = 3
|
|
22
|
+
|
|
23
|
+
// A branch file as the switch holds it: its text, or { blob, size?, bytes? } (branchFiles).
|
|
24
|
+
const idOf = (v) => v && typeof v === 'object' ? v.blob : blobId(Buffer.from(String(v), 'utf8'))
|
|
25
|
+
const has = (o, p) => Object.prototype.hasOwnProperty.call(o || {}, p)
|
|
26
|
+
|
|
27
|
+
/** A Seam tree answer ({ entries: [{ path, blob, mode }] }) as path -> blob id (conflict terms by term id). */
|
|
28
|
+
export function treeIds (tree) {
|
|
29
|
+
const out = {}
|
|
30
|
+
for (const e of (tree && tree.entries) || []) out[e.path] = e.blob || e.term || null
|
|
31
|
+
return out
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The executable files and links of a Seam tree answer: path -> 'exec' | 'symlink'. */
|
|
35
|
+
export function treeModes (tree) {
|
|
36
|
+
const out = {}
|
|
37
|
+
for (const e of (tree && tree.entries) || []) if (e.mode === 'exec' || e.mode === 'symlink') out[e.path] = e.mode
|
|
38
|
+
return out
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** What the session never syncs (fsutil.js loadIgnore), as the ignore files among `files` (path -> text) say. */
|
|
42
|
+
export function docIgnore (files) {
|
|
43
|
+
const ig = makeIgnore()
|
|
44
|
+
// A submodule's folder is its own git's (fsutil.js loadIgnore).
|
|
45
|
+
if (typeof (files || {})['.gitmodules'] === 'string') for (const p of submodulePathsIn(files['.gitmodules'])) ig.add(`/${p}`)
|
|
46
|
+
for (const [p, t] of Object.entries(files || {})) {
|
|
47
|
+
if (typeof t !== 'string' || !IGNORE_FILES.includes(path.posix.basename(p))) continue
|
|
48
|
+
const dir = path.posix.dirname(p)
|
|
49
|
+
ig.add(scopeIgnore(t, dir === '.' ? '' : dir))
|
|
50
|
+
}
|
|
51
|
+
return (p) => isIgnored(ig, p)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* A branch document's files as the switch compares them with a square's head (`tree`, Seam's
|
|
56
|
+
* answer): path -> text, or { blob, size, bytes } for a small binary the document holds, or
|
|
57
|
+
* { blob } (the head's) for a path the document leaves as the head has it. Those are what the
|
|
58
|
+
* session never syncs (links, submodules, ignored files, files too large for it), a large file in
|
|
59
|
+
* the session's storage (the relay can't read its bytes), and in a diff document every file it
|
|
60
|
+
* has no entry for (it follows git). Any other head path the document lacks is a deletion, but
|
|
61
|
+
* with `unheld` (a square that starts at main's tip for want of the branch's own commit) only one
|
|
62
|
+
* it held and let go of: main's work after the branch left it is not the branch's to delete.
|
|
63
|
+
*/
|
|
64
|
+
export function branchFiles (doc, tree, { diff = false, unheld = false } = {}) {
|
|
65
|
+
const head = treeIds(tree)
|
|
66
|
+
const entries = new Map(((tree && tree.entries) || []).map((e) => [e.path, e]))
|
|
67
|
+
const out = {}
|
|
68
|
+
const blobs = doc.getMap('blobs')
|
|
69
|
+
for (const [p, t] of doc.getMap('files')) out[p] = t.toString()
|
|
70
|
+
for (const [p, b] of blobs) {
|
|
71
|
+
if (has(out, p) || !b) continue
|
|
72
|
+
if (typeof b.data === 'string') { const bytes = Buffer.from(b.data, 'base64'); out[p] = { blob: blobId(bytes), size: bytes.length, bytes } } else if (head[p]) out[p] = { blob: head[p] }
|
|
73
|
+
}
|
|
74
|
+
const ignored = docIgnore(out)
|
|
75
|
+
// An untracked file the branch's ignore rules or Quilt's built-ins leave out (an agent's scratch
|
|
76
|
+
// folder, logs, supabase/.temp, .claude/settings.local.json) is local to the folder that has it, never
|
|
77
|
+
// a square's (Seam's 05: ignore rules gate untracked paths; 2026-11-01: 31 of them went into one).
|
|
78
|
+
for (const p of Object.keys(out)) if (!head[p] && ignored(p)) delete out[p]
|
|
79
|
+
// Nothing inside a submodule (a gitlink of the head, or a folder .gitmodules names): a document
|
|
80
|
+
// that took its files before they were left out (fsutil.js loadIgnore) never puts them in a square.
|
|
81
|
+
const subs = [...[...entries.values()].filter((e) => e.mode === 'gitlink').map((e) => e.path), ...(typeof out['.gitmodules'] === 'string' ? submodulePathsIn(out['.gitmodules']) : [])]
|
|
82
|
+
for (const p of Object.keys(out)) if (subs.some((d) => p.startsWith(`${d}/`))) delete out[p]
|
|
83
|
+
const gone = doc.getMap('gone')
|
|
84
|
+
const unsynced = (p) => {
|
|
85
|
+
const e = entries.get(p) || {}
|
|
86
|
+
return e.mode === 'symlink' || e.mode === 'gitlink' || (Number(e.size) || 0) > MAX_TEXT_BYTES || ignored(p)
|
|
87
|
+
}
|
|
88
|
+
const held = (p) => doc.getMap('files')._map.has(p) || blobs._map.has(p)
|
|
89
|
+
for (const p of Object.keys(head)) if (!has(out, p) && head[p] && (diff ? !gone.has(p) : unsynced(p) || (unheld && !held(p)))) out[p] = { blob: head[p] }
|
|
90
|
+
return out
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The session's people (`members`: [{ key, role }]) become writers of a square its holder (or an
|
|
95
|
+
* admin) acts on through `client`, so their AIs, working for them, write there too: every person
|
|
96
|
+
* but the holder and viewers. What Seam refused: [{ member, why }] (one not in the quilt, a full square).
|
|
97
|
+
*/
|
|
98
|
+
export async function shareWithPeople (client, quilt, square, members) {
|
|
99
|
+
const s = await client.square(quilt, square)
|
|
100
|
+
const me = s && s.holder
|
|
101
|
+
const refused = []
|
|
102
|
+
for (const m of members) {
|
|
103
|
+
const id = m && m.key
|
|
104
|
+
if (!id || !String(id).startsWith('person:') || id === me || (s.writers || []).includes(id) || m.role === 'viewer') continue
|
|
105
|
+
await client.share(quilt, square, { member: id }).catch((err) => refused.push({ member: id, why: String(err.message || err).slice(0, 200) }))
|
|
106
|
+
}
|
|
107
|
+
return refused
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** git's id of a file's bytes (what `git hash-object` says), for finding them in git's history. */
|
|
111
|
+
export const gitBlobId = (buf) => crypto.createHash('sha1').update(`blob ${buf.length}\0`).update(buf).digest('hex')
|
|
112
|
+
|
|
113
|
+
/** The paths of `files` (branchFiles) that differ from `head`, with git's id of the branch's bytes: Map path -> git blob id. */
|
|
114
|
+
export function changedGitIds (files, head) {
|
|
115
|
+
const out = new Map()
|
|
116
|
+
for (const [p, v] of Object.entries(files || {})) {
|
|
117
|
+
if (head[p] && head[p] === idOf(v)) continue
|
|
118
|
+
const buf = typeof v === 'string' ? Buffer.from(v, 'utf8') : v && v.bytes ? v.bytes : null
|
|
119
|
+
if (buf) out.set(p, gitBlobId(buf))
|
|
120
|
+
}
|
|
121
|
+
return out
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* `files` without the branch's older copies of main's files: a path whose bytes the branch holds are
|
|
126
|
+
* one of main's own earlier versions of it (`history`: path -> the git blob ids main's history gave
|
|
127
|
+
* it), main's tip having moved on, is the head's (gone, when the head has no such file). For a square
|
|
128
|
+
* that starts at main's tip for want of the branch's commit, where a full document names every
|
|
129
|
+
* file. A branch that put a file back as main once had it on purpose loses that: the head's stands.
|
|
130
|
+
*/
|
|
131
|
+
export function dropOldCopies (files, head, history) {
|
|
132
|
+
const out = { ...files }
|
|
133
|
+
for (const [p, id] of changedGitIds(files, head)) {
|
|
134
|
+
if (!(history.get(p) || new Set()).has(id)) continue
|
|
135
|
+
if (head[p]) out[p] = { blob: head[p] }; else delete out[p]
|
|
136
|
+
}
|
|
137
|
+
return out
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** The small binaries in `files` that differ from `head`: what Seam must hold before a live document names them. */
|
|
141
|
+
export function binariesToUpload (files, head = {}) {
|
|
142
|
+
const out = []
|
|
143
|
+
for (const [p, v] of Object.entries(files || {})) if (v && typeof v === 'object' && v.bytes && head[p] !== v.blob) out.push({ kind: 'blob', id: v.blob, bytes: v.bytes })
|
|
144
|
+
return out
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** What turns `head` (path -> id) into `files` (path -> text): { puts: { path: text }, gone: [path] }. */
|
|
148
|
+
export function overlayFor (files, head) {
|
|
149
|
+
const puts = {}
|
|
150
|
+
for (const [p, t] of Object.entries(files || {})) if (head[p] !== idOf(t)) puts[p] = t
|
|
151
|
+
const gone = Object.keys(head || {}).filter((p) => !has(files, p)).sort()
|
|
152
|
+
return { puts, gone }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Writes an overlay into an empty live document, with `head` as its first head. */
|
|
156
|
+
export function writeOverlay (doc, { puts, gone }, head, by = '') {
|
|
157
|
+
const m = liveMaps(doc)
|
|
158
|
+
doc.transact(() => {
|
|
159
|
+
for (const [p, t] of Object.entries(puts)) {
|
|
160
|
+
if (t && typeof t === 'object') { m.blobs.set(p, { blob: t.blob, size: t.size || 0, mode: 'file' }); continue }
|
|
161
|
+
const y = new Y.Text(); y.insert(0, t); m.files.set(p, y)
|
|
162
|
+
}
|
|
163
|
+
for (const p of gone) m.gone.set(p, { by, ts: Date.now() })
|
|
164
|
+
}, 'relay-quilt')
|
|
165
|
+
if (head) startHead(doc, head, by)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* The post-copy check (16, "Dry run and check"): the live document over its head holds exactly
|
|
170
|
+
* the branch document's files, by id. Returns null, or the first difference in words.
|
|
171
|
+
*/
|
|
172
|
+
export function checkOverlay (doc, files, head) {
|
|
173
|
+
const want = {}
|
|
174
|
+
for (const [p, t] of Object.entries(files || {})) want[p] = idOf(t)
|
|
175
|
+
const have = { ...head }
|
|
176
|
+
const m = liveMaps(doc)
|
|
177
|
+
for (const p of new Set([...m.files.keys(), ...m.blobs.keys(), ...m.gone.keys()])) {
|
|
178
|
+
const e = entryOf(doc, p)
|
|
179
|
+
if (!e) continue
|
|
180
|
+
if (e.op === 'delete') delete have[p]; else have[p] = entryId(e)
|
|
181
|
+
}
|
|
182
|
+
for (const p of new Set([...Object.keys(want), ...Object.keys(have)])) {
|
|
183
|
+
if (want[p] !== have[p]) return want[p] == null ? `${p} is in the square but not in the branch document` : have[p] == null ? `${p} is in the branch document but not in the square` : `${p} differs between the branch document and the square`
|
|
184
|
+
}
|
|
185
|
+
return null
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Branch-document maps that are bookkeeping for two authorities (16's table, "Dropped"): emptied
|
|
189
|
+
// in a square's live copy. The kept branch documents still have them for a rollback.
|
|
190
|
+
export const DROPPED_DOC_MAPS = ['changes', 'fromGithub', 'published']
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* The live document as a copy of the branch document (16: "The Y.Doc is copied ..., so edit
|
|
194
|
+
* history inside it survives"), then made an overlay over `head` (path -> blob id): every text
|
|
195
|
+
* equal to head's is marked clean (the first commit drops it), a head path the branch lacks is
|
|
196
|
+
* marked gone, the branch's large-file entries and git bookkeeping leave, and `skip` paths (held
|
|
197
|
+
* by conflict terms in Seam) are taken out so head's term shows. `files` is the branch's files
|
|
198
|
+
* (branchFiles), as the check reads them; `modes` the head's (treeModes), so a changed executable
|
|
199
|
+
* stays one.
|
|
200
|
+
*/
|
|
201
|
+
export function copyIntoLive (doc, branchDoc, { files, head = {}, modes = {}, commit = null, by = '', skip = new Set() }) {
|
|
202
|
+
Y.applyUpdate(doc, Y.encodeStateAsUpdate(branchDoc), 'relay-quilt')
|
|
203
|
+
const m = liveMaps(doc)
|
|
204
|
+
doc.transact(() => {
|
|
205
|
+
// The branch's own binary entries leave (its large files, and small ones as base64); so does a
|
|
206
|
+
// binary this set before that the branch no longer changes (copied again), set again just below.
|
|
207
|
+
const changedBinary = (p) => { const v = files[p]; return !!(v && typeof v === 'object' && v.bytes && !skip.has(p) && head[p] !== v.blob) }
|
|
208
|
+
for (const p of [...m.blobs.keys()]) { const b = m.blobs.get(p); if (!b || !b.blob || !changedBinary(p)) m.blobs.delete(p) }
|
|
209
|
+
for (const name of DROPPED_DOC_MAPS) { const x = doc.getMap(name); for (const k of [...x.keys()]) x.delete(k) }
|
|
210
|
+
// A small binary the branch changed (its bytes are in Seam already: binariesToUpload).
|
|
211
|
+
for (const [p, v] of Object.entries(files)) if (changedBinary(p) && (m.blobs.get(p) || {}).blob !== v.blob) m.blobs.set(p, { blob: v.blob, size: v.size, mode: modes[p] === 'exec' ? 'exec' : 'file' })
|
|
212
|
+
// The session marks an executable file 'x'; a square says 'exec', and a text the branch changed keeps the head's mode.
|
|
213
|
+
for (const [p, v] of [...m.modes.entries()]) { if (v === 'x') m.modes.set(p, 'exec'); else if (v !== 'exec' && v !== 'symlink') m.modes.delete(p) }
|
|
214
|
+
for (const p of [...m.files.keys()]) {
|
|
215
|
+
if (skip.has(p)) { m.files.delete(p); continue }
|
|
216
|
+
// Not in the switch's files nor the head: a text the switch left out (dropOldCopies: main deleted it since), not work.
|
|
217
|
+
if (!has(files, p)) { if (!head[p]) m.files.delete(p); continue }
|
|
218
|
+
if (head[p] === idOf(files[p])) m.clean.set(p, { at: commit }); else {
|
|
219
|
+
if (m.clean.has(p)) m.clean.delete(p)
|
|
220
|
+
if (!m.modes.has(p) && modes[p] === 'exec') m.modes.set(p, 'exec')
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
for (const p of Object.keys(head)) if (!skip.has(p) && !has(files, p)) m.gone.set(p, { by, ts: Date.now() })
|
|
224
|
+
// Copied again (edits that came while the move waited): a path the branch has again is no longer gone;
|
|
225
|
+
// nor is one the head does not have (main deleted it already: as carryLive drops it after a merge).
|
|
226
|
+
for (const p of [...m.gone.keys()]) if ((has(files, p) && !skip.has(p)) || !head[p]) m.gone.delete(p)
|
|
227
|
+
}, 'relay-quilt')
|
|
228
|
+
if (commit) startHead(doc, commit, by)
|
|
229
|
+
// The head the copy is an overlay of: a write that still reaches the branch document is forwarded
|
|
230
|
+
// into the square only while the square is on it (server.js forwardToSquare, copiedHead).
|
|
231
|
+
if (commit) doc.transact(() => liveMaps(doc).live.set('copied', { commit, ts: Date.now() }), 'relay-quilt')
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* The head a square's live document was copied over from its branch document (copyIntoLive), or null.
|
|
236
|
+
* While the square is still on it, the branch document and the square agree on every file the folder
|
|
237
|
+
* did not change, so a write to the branch document is the folder's own change on the square's head too.
|
|
238
|
+
*/
|
|
239
|
+
export const copiedHead = (doc) => (doc.getMap('live').get('copied') || {}).commit || null
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* After a branch's square merged (a merged square never changes again), its next square's live
|
|
243
|
+
* document carries on from the merged one: a copy of it (edit history kept) made an overlay over
|
|
244
|
+
* `head` (path -> blob id, the merged commit's tree, `commit` its id). What the merge already
|
|
245
|
+
* has leaves, as `seal` drops it, and so do clean marks (equal to the old head, not the new one,
|
|
246
|
+
* so keeping them would undo whatever else merged); work typed after the last commit stays.
|
|
247
|
+
*/
|
|
248
|
+
export function carryLive (doc, fromDoc, { head = {}, commit, by = '' }) {
|
|
249
|
+
Y.applyUpdate(doc, Y.encodeStateAsUpdate(fromDoc), 'relay-quilt')
|
|
250
|
+
const m = liveMaps(doc)
|
|
251
|
+
doc.transact(() => {
|
|
252
|
+
const drop = (p) => { for (const k of ['files', 'blobs', 'gone', 'modes', 'clean']) m[k].delete(p) }
|
|
253
|
+
for (const p of [...m.clean.keys()]) drop(p)
|
|
254
|
+
for (const p of new Set([...m.files.keys(), ...m.blobs.keys(), ...m.gone.keys()])) {
|
|
255
|
+
const e = entryOf(doc, p)
|
|
256
|
+
if (!e || (e.op === 'delete' ? !head[p] : head[p] === entryId(e))) drop(p)
|
|
257
|
+
}
|
|
258
|
+
m.live.set('head', { commit, by, ts: Date.now() })
|
|
259
|
+
}, 'relay-quilt')
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Room meta that is bookkeeping for git as an authority (16's table, "Dropped"). At layout 3 it
|
|
263
|
+
// moves into meta.quilt.kept, and back on a rollback, so git-mode sync resumes as it was.
|
|
264
|
+
export const GIT_META = ['sync', 'upstreams', 'remoteBranches', 'viewBranches', 'ciPending', 'pullTold', 'githubToken', 'githubSync', 'largeToRelay', 'recreateDeleted']
|
|
265
|
+
const isGitMeta = (k) => GIT_META.includes(k) || k.startsWith('agentCommits')
|
|
266
|
+
|
|
267
|
+
/** Takes git's bookkeeping out of `meta`: what was there, to put back with restoreGitMeta. */
|
|
268
|
+
export function takeGitMeta (meta) {
|
|
269
|
+
const kept = {}
|
|
270
|
+
for (const k of Object.keys(meta)) if (isGitMeta(k)) { kept[k] = meta[k]; delete meta[k] }
|
|
271
|
+
return kept
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export function restoreGitMeta (meta, kept) {
|
|
275
|
+
for (const [k, v] of Object.entries(kept || {})) if (isGitMeta(k)) meta[k] = v
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Where a branch document's square starts in a quilt imported from the session's git remote: the
|
|
280
|
+
* imported commit of the ref's tip (`git:<sha>`, Seam resolves it through its translation table),
|
|
281
|
+
* a detached document's own commit (`@<sha>`), or main's tip when nothing names one (null).
|
|
282
|
+
*/
|
|
283
|
+
export function squareBase (key, meta) {
|
|
284
|
+
const at = /^@([0-9a-f]{7,64})$/.exec(String(key))
|
|
285
|
+
if (at) return `git:${at[1]}`
|
|
286
|
+
const up = meta && meta.upstreams && meta.upstreams[key]
|
|
287
|
+
if (up && /^[0-9a-f]{40,64}$/.test(String(up.sha || ''))) return `git:${up.sha}`
|
|
288
|
+
const b = meta && meta.branches && meta.branches[key]
|
|
289
|
+
if (b && /^[0-9a-f]{40,64}$/.test(String(b.base || ''))) return `git:${b.base}`
|
|
290
|
+
return null
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Applies a member's Yjs update to a square's live document as the relay (`origin`), where the
|
|
295
|
+
* guard (server.js checkChange) never looks: `may(path)` says whether they may change a path, and an
|
|
296
|
+
* update that touches one they may not is undone whole (its structs stay, deleted, so the member's
|
|
297
|
+
* later updates still fit the document). { touched: [path], refused: [path] }.
|
|
298
|
+
*/
|
|
299
|
+
export function applyChecked (doc, update, may = () => true, origin = 'relay-forward') {
|
|
300
|
+
const m = liveMaps(doc)
|
|
301
|
+
const touched = new Set()
|
|
302
|
+
const seen = (tr) => {
|
|
303
|
+
if (tr.origin !== origin) return
|
|
304
|
+
for (const [type, keys] of tr.changed) {
|
|
305
|
+
if (type === m.files || type === m.blobs || type === m.gone || type === m.modes) { for (const k of keys) if (k != null) touched.add(k) } else if (type instanceof Y.Text && type.parent === m.files && type._item) touched.add(type._item.parentSub)
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
const um = new Y.UndoManager([m.files, m.blobs, m.gone, m.modes, m.clean], { trackedOrigins: new Set([origin]), captureTimeout: 0 })
|
|
309
|
+
doc.on('afterTransaction', seen)
|
|
310
|
+
try { Y.applyUpdate(doc, update, origin) } finally { doc.off('afterTransaction', seen) }
|
|
311
|
+
const refused = [...touched].filter((p) => !may(p))
|
|
312
|
+
if (refused.length) um.undo()
|
|
313
|
+
um.destroy()
|
|
314
|
+
return { touched: [...touched], refused }
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* A write that reached a branch's kept document at layout 3 from an app not yet following the
|
|
319
|
+
* branch's square (the moment between the switch and the app hearing it): applied to the square's
|
|
320
|
+
* live document instead, which is a copy of the branch document, so the update fits it, as far as
|
|
321
|
+
* its member may change those files (`may`, applyChecked). Each path it put loses a clean or gone
|
|
322
|
+
* mark (it is the member's edit now). Returns the paths it deleted (gone only when the square's head
|
|
323
|
+
* has them: the caller looks), binaries it can't keep (the branch document's bytes are not in Seam;
|
|
324
|
+
* the folder's block takes them in when it follows), and the paths it refused.
|
|
325
|
+
*/
|
|
326
|
+
export function forwardIntoLive (doc, update, { may, origin = 'relay-forward' } = {}) {
|
|
327
|
+
const m = liveMaps(doc)
|
|
328
|
+
const { touched, refused } = applyChecked(doc, update, may, origin)
|
|
329
|
+
if (refused.length) return { deleted: [], binaries: [], refused }
|
|
330
|
+
const deleted = []; const binaries = []
|
|
331
|
+
doc.transact(() => {
|
|
332
|
+
for (const p of touched) {
|
|
333
|
+
const b = m.blobs.get(p)
|
|
334
|
+
if (b && !b.blob) { m.blobs.delete(p); binaries.push(p); continue }
|
|
335
|
+
if (m.modes.get(p) === 'x') m.modes.set(p, 'exec') // the session marks an executable 'x'; a square says 'exec' (copyIntoLive)
|
|
336
|
+
if (m.files.has(p) || b) { m.clean.delete(p); m.gone.delete(p) } else deleted.push(p)
|
|
337
|
+
}
|
|
338
|
+
}, 'relay-quilt')
|
|
339
|
+
return { deleted, binaries, refused: [] }
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Whether a Yjs update would change `doc`: content it lacks, or a deletion of what it still has.
|
|
344
|
+
* A read-only branch document's member is told only about a write that was really refused (the
|
|
345
|
+
* empty answer every app sends when it joins is no write).
|
|
346
|
+
*/
|
|
347
|
+
export function bringsNew (doc, update) {
|
|
348
|
+
const sv = Y.decodeStateVector(Y.encodeStateVector(doc))
|
|
349
|
+
const { structs, ds } = Y.decodeUpdate(update)
|
|
350
|
+
// Each struct by its own clock: Yjs's encodeStateVectorFromUpdate leaves out a client whose structs
|
|
351
|
+
// do not start at clock 0, which is every member typing on into a file the relay already has.
|
|
352
|
+
for (const s of structs) if (!(s instanceof Y.Skip) && s.id.clock + s.length > (sv.get(s.id.client) || 0)) return true
|
|
353
|
+
if (!ds || !ds.clients.size) return false
|
|
354
|
+
const have = Y.createDeleteSetFromStructStore(doc.store)
|
|
355
|
+
for (const [client, items] of ds.clients) {
|
|
356
|
+
for (const it of items) {
|
|
357
|
+
if (it.clock >= (sv.get(client) || 0)) continue
|
|
358
|
+
for (const c of [it.clock, Math.min(it.clock + it.len, sv.get(client) || 0) - 1]) if (!Y.isDeleted(have, Y.createID(client, c))) return true
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
return false
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Going back (quiltUnswitch) with a square no folder follows any more: what its branch document
|
|
366
|
+
* lacks of it. `square` is the square's files (path -> { blob, mode }), `cur` the branch's
|
|
367
|
+
* (branchFiles over the same tree, so what the session never syncs reads as the square has it).
|
|
368
|
+
* { put: [{ path, blob, mode }], gone: [path], skip: [{ path, why }] }: links and submodules are
|
|
369
|
+
* never synced, nor what the branch's ignore files leave out.
|
|
370
|
+
*/
|
|
371
|
+
export function foldBack (square, cur) {
|
|
372
|
+
const ignored = docIgnore(cur)
|
|
373
|
+
const out = { put: [], gone: [], skip: [] }
|
|
374
|
+
for (const p of [...new Set([...Object.keys(square), ...Object.keys(cur)])].sort()) {
|
|
375
|
+
const want = square[p]
|
|
376
|
+
const have = has(cur, p) ? idOf(cur[p]) : null
|
|
377
|
+
if ((want ? want.blob : null) === have) continue
|
|
378
|
+
if (want && want.term) { out.skip.push({ path: p, why: 'a conflict the square had not settled' }); continue }
|
|
379
|
+
if (want && (want.mode === 'symlink' || want.mode === 'gitlink')) { out.skip.push({ path: p, why: `a ${want.mode === 'symlink' ? 'link' : 'submodule'}, which the session never syncs` }); continue }
|
|
380
|
+
if (ignored(p)) continue
|
|
381
|
+
if (want) out.put.push({ path: p, blob: want.blob, mode: want.mode || 'file' }); else out.gone.push(p)
|
|
382
|
+
}
|
|
383
|
+
return out
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Writes what foldBack found into branch entry `e` (branchdocs.js), with `bytes` (blob id -> Buffer).
|
|
388
|
+
* A text is written as the folders write the square's work when they leave it (Session.leaveSquare,
|
|
389
|
+
* same-edit.js): under an id taken from the path, before and after, so a folder that brings the same
|
|
390
|
+
* work back later makes the very same edit and the text lands once. Returns the paths written and
|
|
391
|
+
* the ones it could not take ({ path, why }).
|
|
392
|
+
*/
|
|
393
|
+
export function writeFold (e, fold, bytes, by = '') {
|
|
394
|
+
const done = []; const left = []
|
|
395
|
+
e.doc.transact(() => {
|
|
396
|
+
for (const x of fold.put) {
|
|
397
|
+
const buf = bytes[x.blob]
|
|
398
|
+
if (!buf) { left.push({ path: x.path, why: 'Seam did not give its bytes' }); continue }
|
|
399
|
+
if (looksBinary(buf)) {
|
|
400
|
+
if (buf.length > MAX_BINARY_BYTES) { left.push({ path: x.path, why: `${Math.round(buf.length / 1048576)} MB, larger than the session keeps in a branch` }); continue }
|
|
401
|
+
e.files.delete(x.path)
|
|
402
|
+
e.blobs.set(x.path, { hash: sha1(buf), data: buf.toString('base64') })
|
|
403
|
+
} else {
|
|
404
|
+
if (buf.length > MAX_TEXT_BYTES) { left.push({ path: x.path, why: `${Math.round(buf.length / 1048576)} MB of text, larger than the session syncs` }); continue }
|
|
405
|
+
const text = buf.toString('utf8')
|
|
406
|
+
const before = e.files.has(x.path) ? e.files.get(x.path).toString() : null
|
|
407
|
+
e.blobs.delete(x.path)
|
|
408
|
+
asClient(e.doc, sameEditClient('square', x.path, before, text), () => {
|
|
409
|
+
let y = e.files.get(x.path)
|
|
410
|
+
if (!y) { y = new Y.Text(); e.files.set(x.path, y) }
|
|
411
|
+
applyTextDiff(y, text)
|
|
412
|
+
})
|
|
413
|
+
}
|
|
414
|
+
if (x.mode === 'exec') { if (e.modes.get(x.path) !== 'x') e.modes.set(x.path, 'x') } else if (e.modes.has(x.path)) e.modes.delete(x.path)
|
|
415
|
+
done.push(x.path)
|
|
416
|
+
}
|
|
417
|
+
for (const p of fold.gone) { removePath(e, p, { by }); done.push(p) }
|
|
418
|
+
}, 'relay-quilt')
|
|
419
|
+
return { done, left }
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** How far apart two texts are, line by line (lines one has that the other lacks, counted with repeats). */
|
|
423
|
+
export function lineDistance (a, b) {
|
|
424
|
+
const count = new Map()
|
|
425
|
+
for (const l of String(a).split('\n')) count.set(l, (count.get(l) || 0) + 1)
|
|
426
|
+
let n = 0
|
|
427
|
+
for (const l of String(b).split('\n')) { const c = count.get(l) || 0; if (c) count.set(l, c - 1); else n++ }
|
|
428
|
+
for (const c of count.values()) n += c
|
|
429
|
+
return n
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* A boxed file at the move (a member's unfinished work the session held while every other folder kept
|
|
434
|
+
* git's copy): the branch document holds the box holder's whole file, on the main they had when they
|
|
435
|
+
* began, which may be far behind the square's head. Carried as it is, it would undo main's newer
|
|
436
|
+
* changes to the file in the square, and in every folder that follows it. `versions` are main's
|
|
437
|
+
* earlier versions of the file (from the head's history): the nearest to the box's copy is what it
|
|
438
|
+
* started from, and only the box's change from there goes onto `head` (the head's text). Returns the
|
|
439
|
+
* text for the square, or null when the box's copy already is the head plus its change (or nothing
|
|
440
|
+
* in the history is near enough to tell: then the copy stands, as before).
|
|
441
|
+
*/
|
|
442
|
+
export function boxOntoHead (box, head, versions) {
|
|
443
|
+
let base = null; let best = Infinity
|
|
444
|
+
for (const v of versions) { if (typeof v !== 'string') continue; const d = lineDistance(box, v); if (d < best) { best = d; base = v } }
|
|
445
|
+
if (base === null || base === head) return null
|
|
446
|
+
// Not the box's starting point unless the box's copy is nearer to it than to the head.
|
|
447
|
+
if (best >= lineDistance(box, head)) return null
|
|
448
|
+
return mergeAuto(base, box, head, { oursNewer: true }).text
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* Puts `texts` (path -> the square's text, boxOntoHead) into a square's live document after the
|
|
453
|
+
* move's copy (copyIntoLive): a path whose text is the head's is clean, any other is the square's work.
|
|
454
|
+
*/
|
|
455
|
+
export function writeIntoLive (doc, texts, head = {}, commit = null) {
|
|
456
|
+
const m = liveMaps(doc)
|
|
457
|
+
doc.transact(() => {
|
|
458
|
+
for (const [p, t] of Object.entries(texts)) {
|
|
459
|
+
m.gone.delete(p); m.blobs.delete(p)
|
|
460
|
+
let y = m.files.get(p)
|
|
461
|
+
if (!y) { y = new Y.Text(); m.files.set(p, y) }
|
|
462
|
+
applyTextDiff(y, t)
|
|
463
|
+
if (head[p] === idOf(t)) m.clean.set(p, { at: commit }); else m.clean.delete(p)
|
|
464
|
+
}
|
|
465
|
+
}, 'relay-quilt')
|
|
466
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// Quilt's side of Seam: a session with no git remote becomes a quilt
|
|
2
|
+
// (Seam's plans/quilt-source-control/16, "How a session becomes a quilt", "Without history").
|
|
3
|
+
// It calls Seam through a client with the quiltClient shape (src/quilts/client.js), acting as
|
|
4
|
+
// the session's owner, who becomes the quilt's admin. Seam (src/quilts/) never imports this.
|
|
5
|
+
//
|
|
6
|
+
// 1. a quilt named after the room (slug from the room, `-2`.. when taken);
|
|
7
|
+
// 2. patch 1 on main: the default branch document's files, sealed in a square of the owner's
|
|
8
|
+
// and merged, message "Session <room> as of <date>";
|
|
9
|
+
// 3. one square per other branch document, based at patch 1, whose live changes are its
|
|
10
|
+
// difference from patch 1 (so it shows as uncommitted work).
|
|
11
|
+
//
|
|
12
|
+
// 4. each open merge record (state open or editing) as a conflict term in its square's first
|
|
13
|
+
// commit (16's merges row): `ours` the opener's text, on the migrated square; `theirs` the
|
|
14
|
+
// session's text, naming patch 1's square and commit; the record's base as the remove. A
|
|
15
|
+
// default branch's open merges get a square of their own ('(default)'), as main is patch 1.
|
|
16
|
+
// Binary records, and records whose text stayed on the opener's disk, have nothing to put
|
|
17
|
+
// in a term: they are listed in `merges_left`.
|
|
18
|
+
//
|
|
19
|
+
// Small binaries (kept in the branch document as base64) go in with the texts: patch 1 carries
|
|
20
|
+
// the default branch's, a branch's square its own. Files kept in storage (large files) have no
|
|
21
|
+
// bytes on the relay; they are listed in `left_out` and stay in the session's storage.
|
|
22
|
+
|
|
23
|
+
export const slugFor = (room) => String(room || '').toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 56) || 'session'
|
|
24
|
+
|
|
25
|
+
const b64 = (text) => Buffer.from(String(text)).toString('base64')
|
|
26
|
+
const put = (path, text) => ({ path, op: 'put', content: b64(text) })
|
|
27
|
+
|
|
28
|
+
/** The changes that turn `base` ({ path: text }) into `files`; `bins` and `baseBins` ({ path: base64 }) are the small binaries of each. */
|
|
29
|
+
export function changesFrom (base, files, bins = {}, baseBins = {}) {
|
|
30
|
+
const out = []
|
|
31
|
+
for (const [p, t] of Object.entries(files || {})) if (!base || base[p] !== t) out.push(put(p, t))
|
|
32
|
+
for (const [p, d] of Object.entries(bins || {})) if (!(p in (files || {})) && (!base || (baseBins || {})[p] !== d)) out.push({ path: p, op: 'put', content: String(d) })
|
|
33
|
+
const now = (p) => p in (files || {}) || p in (bins || {})
|
|
34
|
+
for (const p of new Set([...Object.keys(base || {}), ...(base ? Object.keys(baseBins || {}) : [])])) if (!now(p)) out.push({ path: p, op: 'delete' })
|
|
35
|
+
return out.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const taken = (e) => e && (e.status === 409 || e.code === 'conflict' || e.code === 'slug_taken' || /taken|exists|duplicate/i.test(String(e.message)))
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Makes the quilt. `defaultFiles`: { path: text } of the default branch document, `defaultBinaries`
|
|
42
|
+
* its small binaries ({ path: base64 }), `defaultModes` its executables ({ path: 'x' }); `branches`: [{ key, files: { path: text }, binaries? }] for the others; `leftOut`: paths with no text. Returns
|
|
43
|
+
* { quilt_id, slug, patch, squares: [{ from, id }], left_out }. A failure after the quilt was
|
|
44
|
+
* made throws with `quilt_id` on the error, so the caller can name it.
|
|
45
|
+
*/
|
|
46
|
+
export const DEFAULT_MERGES = '(default)'
|
|
47
|
+
const OPEN = new Set(['open', 'editing'])
|
|
48
|
+
|
|
49
|
+
/** The open merges of one branch → { terms: conflict changes, left: records with no text to carry }. */
|
|
50
|
+
export function termsFor (merges, files, { square, patchSquare, patch }) {
|
|
51
|
+
const terms = []; const left = []; const seen = new Set()
|
|
52
|
+
for (const m of merges || []) {
|
|
53
|
+
if (!m || !OPEN.has(m.state) || seen.has(m.path)) continue
|
|
54
|
+
if (m.binary || m.local || (m.ours == null && !m.oursDeleted)) { left.push(m); continue }
|
|
55
|
+
seen.add(m.path)
|
|
56
|
+
const now = files && Object.prototype.hasOwnProperty.call(files, m.path) ? files[m.path] : null
|
|
57
|
+
if (now == null && m.oursDeleted) { left.push(m); continue } // both sides gone: nothing conflicts
|
|
58
|
+
terms.push({
|
|
59
|
+
path: m.path,
|
|
60
|
+
op: 'conflict',
|
|
61
|
+
reason: 'import',
|
|
62
|
+
base: m.base != null ? { content: b64(m.base) } : null,
|
|
63
|
+
ours: m.oursDeleted ? null : { content: b64(m.ours), from_square: square, ...(m.by ? { by: String(m.by) } : {}) },
|
|
64
|
+
theirs: now == null ? null : { content: b64(now), ...(patchSquare ? { from_square: patchSquare } : {}), ...(patch ? { from_commit: patch.commit } : {}) }
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
return { terms, left }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function makeQuiltWithoutHistory ({ seam, room, defaultFiles = {}, defaultBinaries = {}, defaultModes = {}, defaultMerges = [], branches = [], leftOut = [], date = new Date() }) {
|
|
71
|
+
const base = slugFor(room)
|
|
72
|
+
let q = null
|
|
73
|
+
for (let n = 1; !q && n <= 20; n++) {
|
|
74
|
+
const slug = n === 1 ? base : `${base}-${n}`
|
|
75
|
+
try { q = await seam.createQuilt({ slug, name: room }) } catch (e) { if (!taken(e) || n === 20) throw e }
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
let patch = null
|
|
79
|
+
let patchSquare = null
|
|
80
|
+
// Executables keep their mode in patch 1 (the document marks them 'x').
|
|
81
|
+
const first = changesFrom(null, defaultFiles, defaultBinaries).map((c) => c.op === 'put' && defaultModes[c.path] === 'x' ? { ...c, mode: 'exec' } : c)
|
|
82
|
+
if (first.length) {
|
|
83
|
+
const day = new Date(date).toISOString().slice(0, 10)
|
|
84
|
+
const s = await seam.openSquare(q.id, { title: `Session ${room}` })
|
|
85
|
+
patchSquare = s.id
|
|
86
|
+
await seam.commit(q.id, s.id, { expect_head: null, message: `Session ${room} as of ${day}`, changes: first })
|
|
87
|
+
const m = await seam.merge(q.id, 'main', { square: s.id })
|
|
88
|
+
if (!m || !m.ok || !m.merged) throw Object.assign(new Error(`Seam did not merge patch 1: ${(m && ((m.refused && m.refused.detail) || m.error || m.code)) || "no answer"}`), { code: m && m.code })
|
|
89
|
+
patch = { no: m.merged.patch_no, commit: m.merged.commit }
|
|
90
|
+
}
|
|
91
|
+
const squares = []
|
|
92
|
+
const terms = []
|
|
93
|
+
const mergesLeft = []
|
|
94
|
+
// Open merges as terms in the square's first commit; the rest of its work stays live.
|
|
95
|
+
const carry = async (from, sq, merges, files) => {
|
|
96
|
+
const t = termsFor(merges, files, { square: sq, patchSquare, patch })
|
|
97
|
+
for (const m of t.left) mergesLeft.push({ from, path: m.path, id: m.id })
|
|
98
|
+
if (!t.terms.length) return new Set()
|
|
99
|
+
await seam.commit(q.id, sq, { expect_head: null, message: `Open merges from session ${room}: ${t.terms.map((x) => x.path).join(', ')}`.slice(0, 2000), changes: t.terms })
|
|
100
|
+
for (const x of t.terms) terms.push({ from, path: x.path, square: sq })
|
|
101
|
+
return new Set(t.terms.map((x) => x.path))
|
|
102
|
+
}
|
|
103
|
+
const defaultLeft = termsFor(defaultMerges, defaultFiles, {})
|
|
104
|
+
if (!defaultLeft.terms.length) for (const m of defaultLeft.left) mergesLeft.push({ from: DEFAULT_MERGES, path: m.path, id: m.id })
|
|
105
|
+
else {
|
|
106
|
+
const s = await seam.openSquare(q.id, { title: `Open merges from session ${room}` })
|
|
107
|
+
const held = await carry(DEFAULT_MERGES, s.id, defaultMerges, defaultFiles)
|
|
108
|
+
if (held.size) squares.push({ from: DEFAULT_MERGES, id: s.id })
|
|
109
|
+
}
|
|
110
|
+
for (const b of branches) {
|
|
111
|
+
const s = await seam.openSquare(q.id, { title: String(b.key).slice(0, 200) })
|
|
112
|
+
const held = await carry(b.key, s.id, b.merges, b.files)
|
|
113
|
+
const changes = changesFrom(defaultFiles, b.files, b.binaries, defaultBinaries).filter((c) => !held.has(c.path))
|
|
114
|
+
if (changes.length) await seam.setLive(q.id, s.id, { changes })
|
|
115
|
+
squares.push({ from: b.key, id: s.id })
|
|
116
|
+
}
|
|
117
|
+
return { quilt_id: q.id, slug: q.slug, patch, squares, terms, merges_left: mergesLeft, left_out: [...leftOut].sort() }
|
|
118
|
+
} catch (e) {
|
|
119
|
+
e.quilt_id = q.id
|
|
120
|
+
e.message = `${e.message} (quilt ${q.slug} was made but the session was not moved into it)`
|
|
121
|
+
throw e
|
|
122
|
+
}
|
|
123
|
+
}
|