@heyquilt/seam 0.27.0 → 0.131.0

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 (42) hide show
  1. package/bin/seam.js +118 -17
  2. package/lib/quilts/agent-tokens.js +148 -0
  3. package/lib/quilts/archiver.js +241 -0
  4. package/lib/quilts/attest.js +212 -0
  5. package/lib/quilts/bisect-run.js +3 -2
  6. package/lib/quilts/block.js +497 -55
  7. package/lib/quilts/cas.js +78 -0
  8. package/lib/quilts/cbor.js +2 -0
  9. package/lib/quilts/claims.js +409 -0
  10. package/lib/quilts/cli.js +612 -69
  11. package/lib/quilts/client.js +306 -19
  12. package/lib/quilts/conflict-format.js +266 -0
  13. package/lib/quilts/daemon.js +174 -0
  14. package/lib/quilts/describe.js +296 -38
  15. package/lib/quilts/dict.js +34 -0
  16. package/lib/quilts/exit-archive.js +184 -0
  17. package/lib/quilts/export-format.js +52 -0
  18. package/lib/quilts/export-git.js +27 -3
  19. package/lib/quilts/export-lfs.js +52 -0
  20. package/lib/quilts/export-sign.js +49 -0
  21. package/lib/quilts/fastcdc.js +186 -0
  22. package/lib/quilts/hash.js +17 -2
  23. package/lib/quilts/ignored.js +46 -0
  24. package/lib/quilts/import-git.js +54 -2
  25. package/lib/quilts/journal.js +105 -0
  26. package/lib/quilts/limits.js +197 -0
  27. package/lib/quilts/linepolicy.js +109 -0
  28. package/lib/quilts/pack.js +146 -18
  29. package/lib/quilts/persona.js +70 -0
  30. package/lib/quilts/records.js +7 -3
  31. package/lib/quilts/recover.js +37 -0
  32. package/lib/quilts/runner.js +79 -12
  33. package/lib/quilts/sealed.js +231 -0
  34. package/lib/quilts/secrets.js +86 -0
  35. package/lib/quilts/signing.js +7 -3
  36. package/lib/quilts/tarball.js +107 -0
  37. package/lib/quilts/textdiff.js +7 -5
  38. package/lib/quilts/tools.js +404 -83
  39. package/lib/quilts/tree.js +351 -0
  40. package/lib/quilts/want.js +51 -0
  41. package/lib/quilts/webhook.js +116 -0
  42. package/package.json +3 -2
@@ -3,14 +3,29 @@
3
3
  // plus the edits made in it since; it is rebuilt from the server, never repaired. Its record is
4
4
  // .seam/block.json: the quilt, the square, the paths, `at`, the head it last saw, and a
5
5
  // manifest of what was written (blob, mode, size, mtime) so status needs no server call.
6
+ import { builtinIgnored } from './ignored.js'
6
7
  import fs from 'node:fs'
8
+ import os from 'node:os'
7
9
  import path from 'node:path'
8
10
  import ignore from 'ignore'
9
11
  import { blobId, cleanPrefix, under, cmpPath, cleanCommit, commitBytes, commitId } from './records.js'
10
12
  import { unified, isText } from './textdiff.js'
13
+ import { materialiseSealed } from './sealed.js'
14
+ import { scanFiles } from './secrets.js'
15
+ import { idHasher } from './hash.js'
16
+ import { CDC_THRESHOLD } from './fastcdc.js'
17
+ import { TreeReader, decodeNode, EMPTY_TREE } from './tree.js'
18
+ import { globMatcher } from './claims.js'
19
+ import { theCas } from './cas.js'
20
+
21
+ /** How much of a large file is read at a time (10, streaming the cut). */
22
+ export const READ_PIECE = 1024 * 1024
23
+ /** A diff reads files whole; one over this is named, not diffed. */
24
+ const DIFF_MAX = 64 * 1024 * 1024
11
25
 
12
26
  /** A block folder's own folder (seam's, not the Quilt app's .quilt). */
13
27
  export const BLOCK_DIR = '.seam'
28
+ export const POINTER_FILE = '.quilt-pointer'
14
29
  export const BLOCK_FILE = path.join(BLOCK_DIR, 'block.json')
15
30
  const ALWAYS_IGNORED = ['.git', BLOCK_DIR, 'node_modules']
16
31
 
@@ -29,12 +44,37 @@ export function findBlock (start = process.cwd()) {
29
44
  export function saveBlock (dir, block) {
30
45
  fs.mkdirSync(path.join(dir, BLOCK_DIR), { recursive: true })
31
46
  const f = path.join(dir, BLOCK_FILE)
32
- fs.writeFileSync(f + '.tmp', JSON.stringify(block, null, 1))
47
+ const { _codec: _, ...kept } = block
48
+ fs.writeFileSync(f + '.tmp', JSON.stringify(kept, null, 1))
33
49
  fs.renameSync(f + '.tmp', f)
34
50
  }
35
51
 
52
+ /**
53
+ * The full stack at the block's commit (10), as `seam stack` answers it, for block.json.stack, so a
54
+ * deploy script reads the migration level from the folder it builds. Null from a server without it.
55
+ */
56
+ async function stackOf (client, quilt, commit) {
57
+ if (!commit || !client.stack) return null
58
+ return (await client.stack(quilt, `c:${commit}`).catch(() => null))?.stack || null
59
+ }
60
+
61
+ function setStack (block, stack) {
62
+ if (stack && Object.keys(stack).length) block.stack = stack
63
+ else delete block.stack
64
+ }
65
+
36
66
  const inBlock = (block, p) => block.paths.some((x) => under(p, x))
37
67
 
68
+ /**
69
+ * How a block names its files' contents: plain BLAKE3 ids, or, in a sealed quilt (11.11), the
70
+ * keyed ids of the codec the command attached as `block._codec` (sealed.js codecFor).
71
+ */
72
+ function idsOf (block) {
73
+ if (!block.sealed) return blobId
74
+ if (!block._codec) throw new Error('This block is in a sealed quilt and this computer holds no key for it.')
75
+ return block._codec.idOf
76
+ }
77
+
38
78
  /** The folders that hold committed files: they are walked even when a .gitignore names them. */
39
79
  function trackedDirs (block) {
40
80
  const out = new Set()
@@ -42,9 +82,16 @@ function trackedDirs (block) {
42
82
  return out
43
83
  }
44
84
 
45
- /** Reads `rel`/.gitignore into the stack of rules in force there (deepest last). */
85
+ /**
86
+ * Reads `rel`/.gitignore, then `rel`/.seamignore (personal, never committed, so it wins in its
87
+ * folder), into the stack of rules in force there (deepest last).
88
+ */
46
89
  function rulesIn (dir, rel, stack) {
47
- try { return [...stack, { base: rel, ig: ignore().add(fs.readFileSync(path.join(dir, rel, '.gitignore'), 'utf8')) }] } catch { return stack }
90
+ const out = [...stack]
91
+ for (const name of ['.gitignore', '.seamignore']) {
92
+ try { out.push({ base: rel, ig: ignore().add(fs.readFileSync(path.join(dir, rel, name), 'utf8')) }) } catch {}
93
+ }
94
+ return out
48
95
  }
49
96
 
50
97
  /** As git decides: the deepest .gitignore with a rule for the path wins (a `!` rule re-includes). */
@@ -76,9 +123,18 @@ function scan (dir, block) {
76
123
  const n = r.normalize('NFC')
77
124
  const isDir = d.isDirectory()
78
125
  if (ALWAYS.ignores(isDir ? r + '/' : r)) continue
79
- if (!(isDir ? dirs.has(n) : block.files[n]) && ignoredBy(stack, r, isDir)) continue
126
+ // Seam's built-ins (.env …, ignored.js) and the .gitignore/.seamignore rules leave out only
127
+ // what nobody committed: a committed .env from an imported repo keeps working.
128
+ if (!(isDir ? dirs.has(n) : block.files[n]) && (builtinIgnored(isDir ? r + '/' : r) || ignoredBy(stack, r, isDir))) continue
80
129
  // A submodule's folder stands for its gitlink entry: what is inside belongs to the submodule.
81
130
  if (isDir && block.files[n]?.mode === 'gitlink') { out.set(n, { abs: path.join(dir, r), gitlink: true }); continue }
131
+ // A pointer to another quilt (02 mode `quilt`) is a folder holding one file, .quilt-pointer:
132
+ // the folder stands for the entry, and the file's bytes are the pointer record.
133
+ if (isDir && block.files[n]?.mode === 'quilt') {
134
+ const abs = path.join(dir, r, POINTER_FILE)
135
+ try { out.set(n, { abs, stat: fs.lstatSync(abs), pointer: true }) } catch {}
136
+ continue
137
+ }
82
138
  if (isDir) { if (block.paths.some((x) => under(r, x) || under(x, r))) walk(r, rulesIn(dir, r, stack)); continue }
83
139
  if (!(d.isFile() || d.isSymbolicLink()) || !inBlock(block, r)) continue
84
140
  const abs = path.join(dir, r)
@@ -89,7 +145,20 @@ function scan (dir, block) {
89
145
  return out
90
146
  }
91
147
 
92
- function readFileEntry (abs, stat) {
148
+ /** A file's blob id read a piece at a time, never whole (10, streaming the cut). */
149
+ export function streamedBlobId (abs, piece = READ_PIECE) {
150
+ const h = idHasher('blob')
151
+ const buf = Buffer.allocUnsafe(piece)
152
+ const fd = fs.openSync(abs, 'r')
153
+ try { for (let n; (n = fs.readSync(fd, buf, 0, buf.length, null)) > 0;) h.update(buf.subarray(0, n)) } finally { fs.closeSync(fd) }
154
+ return { id: h.digest(), size: h.size }
155
+ }
156
+
157
+ /** A change's bytes whole (live sharing, which sends files in JSON): read from disk for a streamed one. */
158
+ export const bytesOf = (c) => c.bytes || (c.abs ? fs.readFileSync(c.abs) : null)
159
+
160
+ function readFileEntry (abs, stat, pointer) {
161
+ if (pointer) return { bytes: fs.readFileSync(abs), mode: 'quilt' }
93
162
  if (stat.isSymbolicLink()) return { bytes: Buffer.from(fs.readlinkSync(abs)), mode: 'symlink' }
94
163
  return { bytes: fs.readFileSync(abs), mode: (stat.mode & 0o111) && process.platform !== 'win32' ? 'exec' : 'file' }
95
164
  }
@@ -98,19 +167,53 @@ function readFileEntry (abs, stat) {
98
167
  * What changed in the folder since `at`: [{ path, status: 'added' | 'modified' | 'deleted',
99
168
  * blob?, mode?, bytes? }]. A conflicted file still carrying its term's marker is unchanged.
100
169
  */
170
+ /**
171
+ * The live-work patch for the named paths of a block folder (05 "Live work", PATCH /live): each
172
+ * changed file under them is a put or a delete, and every other file under them (back as the
173
+ * block's head has it) is `clean`, so it leaves the square's live work. Names the head it was made on.
174
+ */
175
+ export function livePatch (dir, block, paths) {
176
+ const only = paths.map((p) => cleanPrefix(path.relative(dir, path.resolve(p)).split(path.sep).join('/')))
177
+ const changed = localChanges(dir, block, { paths })
178
+ const seen = new Set(changed.map((c) => c.path))
179
+ const changes = changed.map((c) => c.status === 'deleted' ? { path: c.path, op: 'delete' } : { path: c.path, op: 'put', content: bytesOf(c).toString('base64'), mode: c.mode })
180
+ // A named path that is not a change and not tracked is `clean` only when it is gone from disk (it
181
+ // may have been shared as added); one still there is ignored or a folder, and is not sent.
182
+ const clean = new Set(only.filter((p) => p && !seen.has(p) && !Object.keys(block.files).some((f) => f !== p && under(f, p)) && (block.files[p] || !fs.existsSync(path.join(dir, p)))))
183
+ for (const f of Object.keys(block.files)) if (!seen.has(f) && only.some((x) => under(f, x))) clean.add(f)
184
+ for (const p of [...clean].sort(cmpPath)) changes.push({ path: p, op: 'clean' })
185
+ return { ...(block.head ? { head: block.head } : {}), changes }
186
+ }
187
+
101
188
  export function localChanges (dir, block, { paths = [] } = {}) {
102
189
  const only = paths.map((p) => cleanPrefix(path.relative(dir, path.resolve(p)).split(path.sep).join('/')))
103
190
  const wanted = (p) => !only.length || only.some((x) => under(p, x))
104
191
  const files = scan(dir, block)
192
+ const idOf = idsOf(block)
193
+ // A record is trusted by size and time only for a file older than the block's own file: one
194
+ // changed in the same timestamp tick it was recorded in can match both (git's "racily clean").
195
+ let savedMs = Infinity
196
+ try { savedMs = fs.statSync(path.join(dir, BLOCK_FILE)).mtimeMs } catch {}
105
197
  const out = []
106
- for (const [p, { abs, stat, gitlink }] of files) {
198
+ for (const [p, { abs, stat, gitlink, pointer }] of files) {
107
199
  if (!wanted(p) || gitlink) continue
108
200
  const known = block.files[p]
109
- if (known && known.size === stat.size && known.mtimeMs === stat.mtimeMs) continue
110
- const { bytes, mode } = readFileEntry(abs, stat)
111
- const id = blobId(bytes)
201
+ if (known && known.size === stat.size && known.mtimeMs === stat.mtimeMs && stat.mtimeMs < savedMs) continue
202
+ // A large plain file (10, streaming the cut) is hashed a piece at a time and never held: the
203
+ // upload reads it again from disk as it cuts it. A conflicted or sealed one is read whole as before.
204
+ if (!pointer && stat.isFile() && stat.size > CDC_THRESHOLD && !block.sealed && !known?.term) {
205
+ const mode = (stat.mode & 0o111) && process.platform !== 'win32' ? 'exec' : 'file'
206
+ const { id, size } = streamedBlobId(abs)
207
+ if (known && known.blob === id && known.mode === mode) continue
208
+ out.push({ path: p, status: known ? 'modified' : 'added', blob: id, mode, size, abs, open: () => fs.createReadStream(abs, { highWaterMark: READ_PIECE }) })
209
+ continue
210
+ }
211
+ const { bytes, mode } = readFileEntry(abs, stat, pointer)
212
+ const id = idOf(bytes)
112
213
  if (known && known.blob === id && known.mode === mode) continue
113
214
  if (known && known.term && bytes.includes(Buffer.from(`quilt-conflict ${known.term.slice(0, 12)}`))) continue
215
+ // A redacted blob is checked out as its tombstone (service.js), which names the blob it stands for.
216
+ if (known && known.blob && bytes.length < 200 && new RegExp(`^<redacted: blob ${known.blob.slice(0, 12)} at seq \\d+>\n$`).test(bytes.toString('utf8'))) continue
114
217
  out.push({ path: p, status: known ? 'modified' : 'added', blob: id, mode, size: bytes.length, bytes })
115
218
  }
116
219
  for (const p of Object.keys(block.files)) if (!files.has(p) && wanted(p)) out.push({ path: p, status: 'deleted' })
@@ -120,32 +223,157 @@ export function localChanges (dir, block, { paths = [] } = {}) {
120
223
  /** Writes one tree entry to the folder and returns its manifest record. */
121
224
  async function writeEntry (dir, e, content) {
122
225
  const abs = path.join(dir, ...e.path.split('/'))
226
+ // A streamed file lands in .seam/tmp first, so a read that fails leaves the folder as it was.
227
+ const tmp = content && content.fetch ? await streamToTmp(dir, e, content.fetch) : null
123
228
  fs.mkdirSync(path.dirname(abs), { recursive: true })
124
229
  try { fs.rmSync(abs, { force: true }) } catch {}
230
+ // An entry that was a quilt pointer leaves its folder: take it away before a file takes the path.
231
+ if (e.mode !== 'quilt') try { fs.rmSync(path.join(abs, POINTER_FILE), { force: true }); fs.rmdirSync(abs) } catch {}
125
232
  // A git submodule is an empty folder, as git leaves one it has not cloned.
126
233
  if (e.mode === 'gitlink') { fs.mkdirSync(abs, { recursive: true }); return { blob: e.blob, mode: 'gitlink', size: e.size, mtimeMs: 0 } }
127
- if (e.mode === 'symlink') fs.symlinkSync(content.toString('utf8'), abs)
128
- else fs.writeFileSync(abs, content, { mode: e.mode === 'exec' ? 0o755 : 0o644 })
234
+ if (e.mode === 'quilt') {
235
+ fs.mkdirSync(abs, { recursive: true })
236
+ fs.writeFileSync(path.join(abs, POINTER_FILE), content, { mode: 0o644 })
237
+ const st = fs.lstatSync(path.join(abs, POINTER_FILE))
238
+ return { blob: e.blob, mode: 'quilt', size: st.size, mtimeMs: st.mtimeMs }
239
+ }
240
+ if (tmp) fs.renameSync(tmp, abs)
241
+ else if (e.mode === 'symlink') fs.symlinkSync(content.toString('utf8'), abs)
242
+ // A path its line keeps for people is read-only in an agent's block (11.5), so its editor refuses first.
243
+ else fs.writeFileSync(abs, content, { mode: (e.mode === 'exec' ? 0o755 : 0o644) & (e.read_only ? 0o555 : 0o777) })
129
244
  const st = fs.lstatSync(abs)
130
245
  return { blob: e.mode === 'conflict' ? null : e.blob, mode: e.mode, size: st.size, mtimeMs: st.mtimeMs, ...(e.term ? { term: e.term } : {}) }
131
246
  }
132
247
 
248
+ /**
249
+ * A large file written as it streams from the server (10, streaming the cut): into a file under
250
+ * .seam/tmp, its id hashed as the bytes pass; answers that file's path once it matches (a
251
+ * redacted or damaged file's tombstone, which the server marks, is kept as it is).
252
+ */
253
+ async function streamToTmp (dir, e, fetchIt) {
254
+ const tmpDir = path.join(dir, BLOCK_DIR, 'tmp')
255
+ fs.mkdirSync(tmpDir, { recursive: true })
256
+ const tmp = path.join(tmpDir, `${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`)
257
+ const out = fs.createWriteStream(tmp, { mode: (e.mode === 'exec' ? 0o755 : 0o644) & (e.read_only ? 0o555 : 0o777) })
258
+ const done = new Promise((resolve, reject) => { out.on('finish', resolve); out.on('error', reject) })
259
+ done.catch(() => {})
260
+ try {
261
+ const r = await fetchIt()
262
+ const h = idHasher('blob')
263
+ for await (const piece of r.body) {
264
+ h.update(piece)
265
+ if (!out.write(piece)) await new Promise((resolve) => { const go = () => { out.off('drain', go); out.off('close', go); resolve() }; out.on('drain', go); out.on('close', go) })
266
+ }
267
+ out.end()
268
+ await done
269
+ if (!r.stone && h.digest() !== e.blob) throw new Error(`${e.path}: the bytes the server sent do not match its id ${String(e.blob).slice(0, 12)}`)
270
+ return tmp
271
+ } catch (err) {
272
+ out.destroy()
273
+ fs.rmSync(tmp, { force: true })
274
+ throw err
275
+ }
276
+ }
277
+
278
+ /** One want-list; a 429 (fetch_rate, 04) waits its retry_after and asks again, up to five times. */
279
+ export async function wantList (client, quilt, ids, rev) {
280
+ for (let tries = 1; ; tries++) {
281
+ try { return await (client.wantBlobs ? client.wantBlobs(quilt, ids, rev) : client.blobs(quilt, ids.map((x) => x.id || x), rev)) } catch (err) {
282
+ if (err.status !== 429 || tries >= 5) throw err
283
+ await new Promise((resolve) => setTimeout(resolve, Math.min(30, Number(err.retry_after) || 1) * 1000))
284
+ }
285
+ }
286
+ }
287
+
288
+ /**
289
+ * A commit's entries under `prefixes`, as GET /tree gives them (04 rule 8): where the server
290
+ * answers the root alone (`nodes=1`), the nodes are fetched by want-list, a level at a time,
291
+ * and kept in the machine cache (cas.js), so a later move asks only for the nodes that changed.
292
+ * Anywhere else (an older server, reads limited by path) the server's listing is used.
293
+ */
294
+ export async function listTree (client, quilt, rev, prefixes, dir) {
295
+ const t = await client.tree(quilt, rev, prefixes, { nodes: true })
296
+ if (!t.nodes || !client.want) return t
297
+ const cas = theCas()
298
+ const nodes = new Map()
299
+ const cached = (id) => cas.get('tree', id)
300
+ let frontier = t.root === EMPTY_TREE ? [] : [t.root]
301
+ while (frontier.length) {
302
+ const need = []
303
+ for (const id of frontier) { const b = cached(id); if (b) nodes.set(id, b); else need.push(id) }
304
+ for (let i = 0; i < need.length; i += 1000) {
305
+ const r = await client.want(quilt, need.slice(i, i + 1000).map((id) => ({ id, kind: 'tree' })), t.commit || rev)
306
+ if (r.missing.length || r.later.length) return client.tree(quilt, rev, prefixes)
307
+ for (const [id, o] of r.objects) { nodes.set(id, o.bytes); cas.put('tree', id, o.bytes) }
308
+ }
309
+ const next = []
310
+ for (const id of frontier) { const n = decodeNode(nodes.get(id)); if (n.level > 0) for (const e of n.entries) if (!nodes.has(e.child)) next.push(e.child) }
311
+ frontier = [...new Set(next)]
312
+ }
313
+ // A filtered snapshot's list-only entries (04 rule 6) are listed as /tree lists them: no id to fetch.
314
+ const entries = (await new TreeReader(async (id) => nodes.get(id)).list(t.root, prefixes)).map((e) => e.mode === 'unreadable' ? { path: e.path, size: e.size, unreadable: true } : e)
315
+ const isHuman = t.human_only?.length ? ((ms) => (p) => ms.some((m) => m(p)))(t.human_only.map(globMatcher)) : null
316
+ return { commit: t.commit, root: t.root, ...(t.ticket ? { ticket: t.ticket } : {}), entries: isHuman ? entries.map((e) => isHuman(e.path) ? { ...e, read_only: true } : e) : entries }
317
+ }
318
+
319
+ /** A sealed blob's plaintext; one that does not match its keyed id is reported to the server (11.11), then refused. */
320
+ async function openSealed (client, quilt, codec, id, bytes) {
321
+ try { return codec.open(id, bytes) } catch (err) {
322
+ if (err.code === 'id_mismatch' && client.reportMismatch) await client.reportMismatch(quilt, id).catch(() => {})
323
+ throw err
324
+ }
325
+ }
326
+
133
327
  /** Fetches blobs by id in batches (want-only, decision 31). */
134
- async function fetchBlobs (client, quilt, ids) {
328
+ async function fetchBlobs (client, quilt, ids, rev, codec, ticket = null) {
135
329
  const out = new Map()
136
- const list = [...new Set(ids)]
330
+ // With a ticket (04 rule 7), each file is asked for with its path and the ticket; an expired or
331
+ // refused ticket falls back to plain ids.
332
+ const ask = (slice) => ticket ? slice.map((id) => ticket.pathOf.has(id) ? { id, path: ticket.pathOf.get(id), ticket: ticket.ticket } : id) : slice
333
+ // A plain quilt's files come from the machine cache where it has them (a sealed one's ids are keyed: never cached).
334
+ const cas = codec ? null : theCas()
335
+ const list = [...new Set(ids)].filter((id) => { const b = cas?.get('blob', id); if (b) out.set(id, b); return !b })
137
336
  for (let i = 0; i < list.length; i += 200) {
138
- const { blobs } = await client.blobs(quilt, list.slice(i, i + 200))
139
- for (const [id, b64] of Object.entries(blobs)) out.set(id, Buffer.from(b64, 'base64'))
337
+ const slice = list.slice(i, i + 200)
338
+ let got
339
+ try { got = await wantList(client, quilt, ask(slice), rev) } catch (err) {
340
+ if (!ticket || (err.code !== 'ticket_expired' && err.code !== 'bad_ticket')) throw err
341
+ ticket = null
342
+ got = await wantList(client, quilt, slice, rev)
343
+ }
344
+ const { blobs, redacted = {} } = got
345
+ // A sealed quilt's blobs are opened here (a redaction's tombstone is plain text).
346
+ for (const [id, b64] of Object.entries(blobs)) {
347
+ const b = codec && !redacted[id] ? await openSealed(client, quilt, codec, id, Buffer.from(b64, 'base64')) : Buffer.from(b64, 'base64')
348
+ out.set(id, b)
349
+ if (cas && !redacted[id]) cas.put('blob', id, b)
350
+ }
140
351
  }
141
352
  return out
142
353
  }
143
354
 
144
- async function contentsFor (client, quilt, rev, entries) {
145
- const blobs = await fetchBlobs(client, quilt, entries.filter((e) => e.blob).map((e) => e.blob))
355
+ // A large plain file is fetched as a stream and written as it comes (10), not in a want-list.
356
+ const streams = (client, codec, e) => !codec && !!client.catRaw && !!e.blob && (e.mode === 'file' || e.mode === 'exec') && (e.size || 0) > CDC_THRESHOLD
357
+
358
+ /**
359
+ * TODO 0.8: the markers a conflicted file is written with in a folder. SEAM_CONFLICT_STYLE=git
360
+ * gives git's (<<<<<<< ||||||| ======= >>>>>>>); anything else, Seam's diff style (the default).
361
+ */
362
+ export const conflictStyle = () => (String(process.env.SEAM_CONFLICT_STYLE || '').toLowerCase() === 'git' ? 'git' : 'diff')
363
+
364
+ async function contentsFor (client, quilt, rev, entries, codec = null, ticket = null) {
365
+ const wanted = entries.filter((e) => e.blob && !streams(client, codec, e))
366
+ const blobs = await fetchBlobs(client, quilt, wanted.map((e) => e.blob), rev, codec, ticket ? { ticket, pathOf: new Map(wanted.map((e) => [e.blob, e.path])) } : null)
146
367
  const get = async (e) => {
368
+ if (streams(client, codec, e)) return { fetch: () => client.catRaw(quilt, rev, e.path) }
147
369
  if (e.mode !== 'conflict') return blobs.get(e.blob)
148
- const r = await client.cat(quilt, rev, e.path, { format: 'diff' })
370
+ // git's markers hold two sides: a conflict of three or more is written in the diff style.
371
+ const style = conflictStyle()
372
+ const r = await client.cat(quilt, rev, e.path, { format: style }).catch((err) => {
373
+ if (style === 'git' && (err.code === 'too_many_sides' || /too_many_sides/.test(String(err.message)))) return client.cat(quilt, rev, e.path, { format: 'diff' })
374
+ throw err
375
+ })
376
+ if (r.sealed) return materialiseSealed(r.term_record, r.term, async (id) => (await fetchBlobs(client, quilt, [id], rev, codec)).get(id))
149
377
  return Buffer.from(r.content, 'base64')
150
378
  }
151
379
  return get
@@ -155,19 +383,54 @@ async function contentsFor (client, quilt, rev, entries) {
155
383
  * Checks out `rev` (a square's head, or any revision) under `paths` into `dir` as a new block.
156
384
  * Refuses a folder that already holds files, unless it is this block's own folder.
157
385
  */
158
- export async function checkout (client, { quilt, square = null, rev, paths = [''], dir, api }) {
386
+ /**
387
+ * The server block row for a folder on a square (04, 05): live when the caller may write there,
388
+ * else read-only at the square's tip. Its id goes in block.json; a server without block routes
389
+ * (or any refusal) leaves the folder without one, and the folder works the same.
390
+ */
391
+ /** Touches the folder's block row (its last_seen); one the server closed (seven days unseen) is opened again on the same square and paths. */
392
+ async function keepRow (client, block) {
393
+ if (block.block_id && !(await client.touchBlock?.(block.quilt, block.block_id).then(() => true, () => false))) delete block.block_id
394
+ if (!block.block_id && block.square) { const id = await openRow(client, block.quilt, block.square, block.paths); if (id) block.block_id = id }
395
+ }
396
+
397
+ /** keepRow for a folder, saving block.json when the row changed: { block_id, reopened }. */
398
+ export async function touchFolder (client, dir, block) {
399
+ const before = block.block_id || null
400
+ await keepRow(client, block)
401
+ if ((block.block_id || null) !== before) saveBlock(dir, block)
402
+ return { block_id: block.block_id || null, reopened: !!(block.block_id && block.block_id !== before) }
403
+ }
404
+
405
+ async function openRow (client, quilt, squareId, paths) {
406
+ if (!client.openBlock || !squareId) return null
407
+ const o = { square: squareId, machine: os.hostname(), mode: 'sparse', paths }
408
+ try { return (await client.openBlock(quilt, o)).id || null } catch (err) {
409
+ if (err.code !== 'not_holder' && err.status !== 403) return null
410
+ try { return (await client.openBlock(quilt, { ...o, read_only: true })).id || null } catch { return null }
411
+ }
412
+ }
413
+
414
+ export async function checkout (client, { quilt, square = null, squareRow = null, rev, paths = [''], dir, api, codec = null }) {
159
415
  const prefixes = (paths.length ? paths : ['']).map(cleanPrefix)
160
416
  fs.mkdirSync(dir, { recursive: true })
161
417
  const existing = fs.readdirSync(dir).filter((n) => n !== BLOCK_DIR)
162
418
  if (existing.length) throw new Error(`${dir} is not empty; check out into an empty folder (or switch an existing block with quilt checkout inside it)`)
163
- const sq = square ? await client.square(quilt, square) : null
419
+ // squareRow: the square as the caller already has it (seam square open --dir), saving a request.
420
+ const sq = square ? (squareRow && squareRow.id === square ? squareRow : await client.square(quilt, square)) : null
164
421
  const at = sq ? (sq.head || sq.base) : rev || 'main'
165
- const t = await client.tree(quilt, at || 'main', prefixes)
166
- const get = await contentsFor(client, quilt, at || 'main', t.entries)
422
+ const t = await listTree(client, quilt, at || 'main', prefixes, dir)
423
+ // A path the quilt's protections let this caller list but not read (11.2) is not checked out.
424
+ t.entries = t.entries.filter((e) => !e.unreadable)
425
+ const get = await contentsFor(client, quilt, at || 'main', t.entries, codec, t.ticket)
167
426
  const files = {}
168
427
  for (const e of t.entries) files[e.path] = await writeEntry(dir, e, await get(e))
169
- const block = { api, quilt, square: sq ? sq.id : null, square_short: sq ? sq.short : null, line: sq ? sq.line : (rev || 'main'), paths: prefixes, at: t.commit, head: sq ? sq.head : null, files }
428
+ const block = { api, quilt, square: sq ? sq.id : null, square_short: sq ? sq.short : null, line: sq ? sq.line : (rev || 'main'), paths: prefixes, at: t.commit, head: sq ? sq.head : null, ...(sq ? { task_id: sq.task_id || null } : {}), ...(codec ? { sealed: true } : {}), files }
429
+ const blockId = sq ? await openRow(client, quilt, sq.id, prefixes) : null
430
+ if (blockId) block.block_id = blockId
431
+ setStack(block, await stackOf(client, quilt, t.commit))
170
432
  saveBlock(dir, block)
433
+ if (codec) block._codec = codec
171
434
  return { block, files: t.entries.length }
172
435
  }
173
436
 
@@ -175,45 +438,159 @@ export async function checkout (client, { quilt, square = null, rev, paths = [''
175
438
  * Moves the folder from `block.at` to commit `to`, file by file: paths changed on the server
176
439
  * are rewritten; a path changed both here and there is left as it is here and reported.
177
440
  */
178
- export async function syncTo (client, dir, block, to, { square } = {}) {
441
+ export async function syncTo (client, dir, block, to, { square, afterWrite } = {}) {
179
442
  const local = new Map(localChanges(dir, block).map((c) => [c.path, c]))
180
443
  const d = await client.diff(block.quilt, { from: block.at ? `c:${block.at}` : undefined, to: to ? `c:${to}` : 'main', paths: block.paths, format: 'names' })
181
- const t = d.files.length ? await client.tree(block.quilt, to ? `c:${to}` : 'main', block.paths) : { entries: [] }
182
- const byPath = new Map(t.entries.map((e) => [e.path, e]))
183
- const get = await contentsFor(client, block.quilt, to ? `c:${to}` : 'main', d.files.map((f) => byPath.get(f.path)).filter(Boolean))
444
+ const t = d.files.length ? await listTree(client, block.quilt, to ? `c:${to}` : 'main', block.paths, dir) : { entries: [] }
445
+ const byPath = new Map(t.entries.filter((e) => !e.unreadable).map((e) => [e.path, e]))
446
+ const get = await contentsFor(client, block.quilt, to ? `c:${to}` : 'main', d.files.map((f) => byPath.get(f.path)).filter(Boolean), block._codec, t.ticket)
184
447
  const kept = []
185
448
  let written = 0
449
+ // The plan goes to the journal first (05 "Rebuilt, never repaired"), so a move that dies part
450
+ // way is finished by rebuild instead of leaving the server's files looking like the holder's edits.
451
+ const journal = openJournal(dir, {
452
+ op: 'move', from: block.at || null, to, rev: to ? `c:${to}` : 'main',
453
+ ...(square ? { square: { id: square.id, short: square.short, head: square.head, line: square.line } } : {}),
454
+ writes: d.files.filter((f) => !local.has(f.path) && byPath.get(f.path)).map((f) => { const e = byPath.get(f.path); return { path: e.path, blob: e.blob, mode: e.mode, size: e.size, ...(e.term ? { term: e.term } : {}), ...(e.read_only ? { read_only: true } : {}), old: block.files[f.path] || null } }),
455
+ removes: d.files.filter((f) => !local.has(f.path) && !byPath.get(f.path)).map((f) => ({ path: f.path, old: block.files[f.path] || null }))
456
+ })
186
457
  for (const f of d.files) {
187
458
  if (local.has(f.path)) { kept.push(f.path); continue }
188
459
  const e = byPath.get(f.path)
189
460
  if (!e) {
190
- const abs = path.join(dir, ...f.path.split('/'))
191
- if (block.files[f.path]?.mode === 'gitlink') { try { fs.rmdirSync(abs) } catch {} } else fs.rmSync(abs, { force: true })
192
- delete block.files[f.path]; written++; continue
461
+ removeEntry(dir, f.path, block.files[f.path]?.mode)
462
+ delete block.files[f.path]; written++
463
+ } else {
464
+ block.files[f.path] = await writeEntry(dir, e, await get(e))
465
+ written++
193
466
  }
194
- block.files[f.path] = await writeEntry(dir, e, await get(e))
195
- written++
467
+ journal.done(f.path)
468
+ afterWrite?.(f.path)
196
469
  }
197
470
  block.at = to
198
- if (square) { block.square = square.id; block.square_short = square.short; block.head = square.head; block.line = square.line }
471
+ const moved = square && square.id !== block.square
472
+ if (square) { block.square = square.id; block.square_short = square.short; block.head = square.head; block.line = square.line; if ('task_id' in square) block.task_id = square.task_id || null }
473
+ // The server's block row follows the folder: touched on a move, reopened on another square.
474
+ if (moved && block.block_id) { await client.closeBlock?.(block.quilt, block.block_id).catch(() => {}); delete block.block_id }
475
+ await keepRow(client, block)
476
+ setStack(block, await stackOf(client, block.quilt, to))
199
477
  saveBlock(dir, block)
478
+ journal.close()
200
479
  return { written, kept }
201
480
  }
202
481
 
482
+ /** The write journal of a block folder: one plan line, then one `{ done }` line per path. */
483
+ export const JOURNAL_FILE = path.join(BLOCK_DIR, 'journal')
484
+
485
+ function openJournal (dir, plan) {
486
+ const f = path.join(dir, JOURNAL_FILE)
487
+ fs.mkdirSync(path.dirname(f), { recursive: true })
488
+ const fd = fs.openSync(f, 'w')
489
+ const put = (o) => { fs.writeSync(fd, JSON.stringify(o) + '\n'); fs.fsyncSync(fd) }
490
+ put({ ...plan, started: new Date().toISOString() })
491
+ let open = true
492
+ return {
493
+ done: (p) => put({ done: p }),
494
+ close: () => { if (!open) return; open = false; fs.closeSync(fd); fs.rmSync(f, { force: true }) }
495
+ }
496
+ }
497
+
498
+ function readJournal (dir) {
499
+ let text
500
+ try { text = fs.readFileSync(path.join(dir, JOURNAL_FILE), 'utf8') } catch { return null }
501
+ const lines = []
502
+ // A line cut off by the crash is the last one: what came before it stands.
503
+ for (const l of text.split('\n')) { if (!l) continue; try { lines.push(JSON.parse(l)) } catch { break } }
504
+ if (!lines.length || !lines[0].op) return null
505
+ return { plan: lines[0], done: new Set(lines.slice(1).map((l) => l.done).filter(Boolean)) }
506
+ }
507
+
508
+ /** The blob id and mode of what is at `p` on disk now (null when nothing is). */
509
+ function onDisk (dir, block, p) {
510
+ const abs = absOf(dir, p)
511
+ let stat
512
+ try { stat = fs.lstatSync(abs) } catch { return null }
513
+ if (stat.isDirectory()) {
514
+ try { const st = fs.lstatSync(path.join(abs, POINTER_FILE)); return { ...readFileEntry(path.join(abs, POINTER_FILE), st, true), stat: st } } catch { return { mode: 'dir', stat } }
515
+ }
516
+ if (stat.isFile() && stat.size > CDC_THRESHOLD && !block.sealed) return { id: streamedBlobId(abs).id, mode: (stat.mode & 0o111) && process.platform !== 'win32' ? 'exec' : 'file', stat }
517
+ const { bytes, mode } = readFileEntry(abs, stat, false)
518
+ return { id: idsOf(block)(bytes), mode, stat }
519
+ }
520
+
521
+ /**
522
+ * block.rebuild (05): finishes a move the journal shows unfinished. A planned path whose file is
523
+ * the plan's is recorded; one still as before (or gone) is written now; one that is neither is
524
+ * the holder's edit and is kept. Then block.json takes the move's commit and the journal goes.
525
+ * On a healthy block it writes nothing. Answers { finished, written, removed, kept, changes }.
526
+ */
527
+ export async function rebuild (client, dir, block) {
528
+ const j = readJournal(dir)
529
+ if (!j) return { finished: false, written: [], removed: [], kept: [], changes: localChanges(dir, block).length }
530
+ const { plan, done } = j
531
+ const same = (cur, rec) => !!(cur && rec && rec.blob && cur.id === rec.blob && cur.mode === rec.mode)
532
+ const written = []
533
+ const removed = []
534
+ const kept = []
535
+ const toWrite = []
536
+ for (const w of plan.writes || []) {
537
+ const cur = onDisk(dir, block, w.path)
538
+ if (same(cur, w)) { block.files[w.path] = { blob: w.blob, mode: w.mode, size: cur.stat.size, mtimeMs: cur.stat.mtimeMs, ...(w.term ? { term: w.term } : {}) }; continue }
539
+ if (done.has(w.path) && w.mode === 'conflict' && cur) { const st = cur.stat; block.files[w.path] = { blob: null, mode: 'conflict', size: st.size, mtimeMs: st.mtimeMs, term: w.term }; continue }
540
+ if (!done.has(w.path) && (!cur || same(cur, w.old))) { toWrite.push(w); continue }
541
+ // Written and edited since, or edited before it was written: the holder's. Its record is the
542
+ // one the folder now stands on, so the edit shows against the new commit.
543
+ kept.push(w.path)
544
+ if (done.has(w.path)) block.files[w.path] = { blob: w.blob, mode: w.mode, size: w.size, mtimeMs: 0 }
545
+ }
546
+ if (toWrite.length) {
547
+ const get = await contentsFor(client, block.quilt, plan.rev, toWrite, block._codec)
548
+ for (const w of toWrite) { block.files[w.path] = await writeEntry(dir, w, await get(w)); written.push(w.path) }
549
+ }
550
+ for (const r of plan.removes || []) {
551
+ const cur = onDisk(dir, block, r.path)
552
+ if (!cur) { delete block.files[r.path]; if (!done.has(r.path)) removed.push(r.path); continue }
553
+ if (!done.has(r.path) && same(cur, r.old)) { removeEntry(dir, r.path, r.old?.mode); pruneEmpty(dir, r.path); delete block.files[r.path]; removed.push(r.path); continue }
554
+ kept.push(r.path)
555
+ }
556
+ block.at = plan.to
557
+ if (plan.square) { block.square = plan.square.id; block.square_short = plan.square.short; block.head = plan.square.head; block.line = plan.square.line }
558
+ saveBlock(dir, block)
559
+ fs.rmSync(path.join(dir, JOURNAL_FILE), { force: true })
560
+ return { finished: true, to: plan.to, written, removed, kept: kept.sort(cmpPath), changes: localChanges(dir, block).length }
561
+ }
562
+
203
563
  /** status: the folder's changes, and the square as the server sees it. */
204
564
  export async function status (client, dir, block) {
205
- const changes = localChanges(dir, block).map(({ bytes, ...c }) => c)
565
+ const changes = localChanges(dir, block).map(({ bytes, open, abs, ...c }) => c)
206
566
  const square = block.square ? await client.square(block.quilt, block.square) : null
207
567
  const conflicted = Object.entries(block.files).filter(([, f]) => f.term).map(([p]) => p)
208
- return { dir, quilt: block.quilt, square, paths: block.paths, at: block.at, changes, conflicted, stale: !!(square && square.head !== block.head) }
568
+ return { dir, quilt: block.quilt, square, line: block.line, on_line: isLineCheckout(block), paths: block.paths, at: block.at, changes, conflicted, stale: !!(square && square.state === 'open' && square.head !== block.head), ...(square && square.state && square.state !== 'open' ? { square_closed: square.state } : {}) }
569
+ }
570
+
571
+ /**
572
+ * A sealed quilt's secret scan (11.11, 11.9): the server holds only ciphertext, so this computer
573
+ * scans the plaintext it is about to seal. Advisory: the findings join the answer's warnings
574
+ * (marked local) and never refuse; one named in allow is quiet.
575
+ */
576
+ function withLocalScan (block, changes, allow, r) {
577
+ if (!block.sealed) return r
578
+ const found = scanFiles(changes.filter((c) => c.bytes)).findings.filter((f) => !allow.includes(f.id))
579
+ if (!found.length) return r
580
+ return { ...r, warnings: [...(r.warnings || []), ...found.map(({ what: _, allowable: __, ...f }) => ({ ...f, local: true }))] }
209
581
  }
210
582
 
211
583
  /** commit: uploads the changed files' blobs (have?, then a pack) and seals them as a commit. */
212
- export async function commitFolder (client, dir, block, { message, paths = [] }) {
213
- if (!block.square) throw new Error('This folder is a read-only checkout of a line; open a square (quilt square open) to commit.')
584
+ export async function commitFolder (client, dir, block, { message, paths = [], allow = [] }) {
585
+ if (!block.square && !isLineCheckout(block)) throw new Error('This folder holds one revision, not a line; open a square (seam square open) to commit.')
214
586
  const changes = localChanges(dir, block, { paths })
215
587
  if (!changes.length) return { nothing: true }
216
- const blobs = changes.filter((c) => c.bytes).map((c) => ({ kind: 'blob', id: c.blob, bytes: c.bytes }))
588
+ // A clone (a checkout of a line): the first commit opens a square by itself, titled by the
589
+ // message and based on the commit the folder holds, so it undoes nothing main gained since.
590
+ const opened = block.square ? null : await openForCommit(client, dir, block, message)
591
+ // In a sealed quilt the server gets each file sealed; its size there is the sealed size.
592
+ for (const c of changes) if (c.bytes && block.sealed) c.stored = block._codec.seal(c.bytes)
593
+ const blobs = changes.filter((c) => c.bytes || c.open).map((c) => c.open ? { kind: 'blob', id: c.blob, size: c.size, open: c.open } : { kind: 'blob', id: c.blob, bytes: c.stored || c.bytes })
217
594
  const list = changes.map((c) => c.status === 'deleted' ? { path: c.path, op: 'delete' } : { path: c.path, op: 'put', blob: c.blob, mode: c.mode })
218
595
  // 03: the commit record is built here and travels in the pack with its blobs, so the commit
219
596
  // request names it by id. The server refuses a record that is not exactly what it would write
@@ -221,20 +598,21 @@ export async function commitFolder (client, dir, block, { message, paths = [] })
221
598
  let r
222
599
  try {
223
600
  if (!block.author || !block.quilt_id) { const info = await client.quilt(block.quilt); block.author = info.you; block.quilt_id = info.id }
224
- const sq = await client.square(block.quilt, block.square)
601
+ // The folder knows its square's base (block.at until its first commit) and task: no request.
602
+ const sq = block.task_id !== undefined && (block.head || block.at) ? { id: block.square, base: block.at, task_id: block.task_id } : await client.square(block.quilt, block.square)
225
603
  const { on_behalf_of: onBehalf, ...author } = block.author
226
604
  const record = cleanCommit({
227
605
  quilt_id: block.quilt_id, square_id: sq.id, parents: [block.head || sq.base].filter(Boolean), author,
228
606
  ...(onBehalf ? { on_behalf_of: onBehalf } : {}), ...(sq.task_id ? { task_id: sq.task_id } : {}),
229
607
  message: message || '', authored_at: Date.now(),
230
- changes: list.map((c) => c.op === 'delete' ? c : { ...c, mode: c.mode || 'file', size: changes.find((x) => x.path === c.path).bytes.length })
608
+ changes: list.map((c) => c.op === 'delete' ? c : { ...c, mode: c.mode || 'file', size: (({ stored, bytes, size }) => (stored || bytes)?.length ?? size)(changes.find((x) => x.path === c.path)) })
231
609
  })
232
610
  const id = commitId(record)
233
611
  await client.upload(block.quilt, [...blobs, { kind: 'commit', id, bytes: commitBytes(record) }])
234
- r = await client.commit(block.quilt, block.square, { expect_head: block.head, commit: id })
612
+ r = await client.commit(block.quilt, block.square, { expect_head: block.head, commit: id, ...(allow.length ? { allow_findings: allow } : {}) })
235
613
  } catch (err) {
236
- if (err.code !== 'record_mismatch') throw err
237
- r = await client.commit(block.quilt, block.square, { expect_head: block.head, message, changes: list })
614
+ if (err.code !== 'record_mismatch' || block.sealed) throw err
615
+ r = await client.commit(block.quilt, block.square, { expect_head: block.head, message, changes: list, ...(allow.length ? { allow_findings: allow } : {}) })
238
616
  }
239
617
  for (const c of changes) {
240
618
  if (c.status === 'deleted') { delete block.files[c.path]; continue }
@@ -244,17 +622,72 @@ export async function commitFolder (client, dir, block, { message, paths = [] })
244
622
  block.at = r.square.head
245
623
  block.head = r.square.head
246
624
  saveBlock(dir, block)
247
- return r
625
+ const out = withLocalScan(block, changes, allow, r)
626
+ if (opened) out.opened = opened
627
+ return out
628
+ }
629
+
630
+ /** A folder on a line (seam clone, seam checkout main, or after seam merge), not on one fixed revision. */
631
+ export function isLineCheckout (block) {
632
+ return !block.square && !!block.line && !/^(c:|sq:|patch:)|[~^@]/.test(block.line) && !/^[0-9a-f]{12,}$/.test(block.line)
633
+ }
634
+
635
+ async function openForCommit (client, dir, block, message) {
636
+ const sq = await client.openSquare(block.quilt, { title: String(message || 'untitled').split('\n')[0].slice(0, 200), line: block.line, ...(block.at ? { base: `c:${block.at}` } : {}) })
637
+ block.square = sq.id
638
+ block.square_short = sq.short
639
+ block.head = sq.head || null
640
+ block.task_id = sq.task_id || null
641
+ const blockId = await openRow(client, block.quilt, sq.id, block.paths || [''])
642
+ if (blockId) block.block_id = blockId
643
+ saveBlock(dir, block)
644
+ return { id: sq.id, short: sq.short, title: sq.title, line: sq.line }
645
+ }
646
+
647
+ /**
648
+ * seam pull: a line checkout moves to the line's tip (uncommitted edits stay; a path changed both
649
+ * here and there keeps this folder's version and is reported); a square folder rebases the square
650
+ * onto its line and moves there.
651
+ */
652
+ export async function pull (client, dir, block) {
653
+ if (block.square) {
654
+ // The square was merged (or abandoned) from elsewhere: the folder goes to its line's tip.
655
+ const sq = await client.square(block.quilt, block.square)
656
+ if (sq.state && sq.state !== 'open') {
657
+ const short = block.square_short || block.square
658
+ const tip = (await client.show(block.quilt, sq.line, { format: 'none' })).commit.id
659
+ const s = await toLineTip(client, dir, block, sq.line, tip)
660
+ return { left: { square: short, state: sq.state }, line: sq.line, at: tip, written: s.written, kept: s.kept }
661
+ }
662
+ const r = await client.rebase(block.quilt, block.square, { expect_head: block.head })
663
+ if (r.noop) return { rebased: false, noop: true, square: r.square }
664
+ const s = await syncTo(client, dir, block, r.square.head || r.square.base, { square: r.square })
665
+ return { rebased: true, square: r.square, result: r, written: s.written, kept: s.kept }
666
+ }
667
+ if (!isLineCheckout(block)) throw new Error('This folder holds one revision, not a line: seam checkout <line or square> moves it.')
668
+ const to = (await client.show(block.quilt, block.line, { format: 'none' })).commit.id
669
+ const from = block.at
670
+ if (to === from) return { noop: true, line: block.line, at: to }
671
+ const s = await syncTo(client, dir, block, to)
672
+ return { line: block.line, from, at: to, written: s.written, kept: s.kept }
673
+ }
674
+
675
+ /** After a merge: the folder leaves the merged square and stands on the line's new tip. */
676
+ export async function toLineTip (client, dir, block, line, tip) {
677
+ // The row closes first, so the move does not touch a row about to go.
678
+ if (block.block_id) { await client.closeBlock?.(block.quilt, block.block_id).catch(() => {}); delete block.block_id }
679
+ Object.assign(block, { square: null, square_short: null, head: null, task_id: null, line })
680
+ return syncTo(client, dir, block, tip)
248
681
  }
249
682
 
250
683
  /** Amends the square's last commit with the folder's changes (paths: only these) and, if given, a new message. */
251
- export async function amendFolder (client, dir, block, { message, paths = [] }) {
684
+ export async function amendFolder (client, dir, block, { message, paths = [], allow = [] }) {
252
685
  if (!block.square) throw new Error('This folder is a read-only checkout of a line; there is no commit of yours to amend.')
253
686
  const changes = localChanges(dir, block, { paths })
254
- const blobs = changes.filter((c) => c.bytes).map((c) => ({ kind: 'blob', id: c.blob, bytes: c.bytes }))
687
+ const blobs = changes.filter((c) => c.bytes || c.open).map((c) => c.open ? { kind: 'blob', id: c.blob, size: c.size, open: c.open } : { kind: 'blob', id: c.blob, bytes: block.sealed ? block._codec.seal(c.bytes) : c.bytes })
255
688
  if (blobs.length) await client.upload(block.quilt, blobs)
256
689
  const list = changes.map((c) => c.status === 'deleted' ? { path: c.path, op: 'delete' } : { path: c.path, op: 'put', blob: c.blob, mode: c.mode || 'file' })
257
- const r = await client.amend(block.quilt, block.square, { expect_head: block.head, ...(message ? { message } : {}), changes: list })
690
+ const r = await client.amend(block.quilt, block.square, { expect_head: block.head, ...(message ? { message } : {}), changes: list, ...(allow.length ? { allow_findings: allow } : {}) })
258
691
  for (const c of changes) {
259
692
  if (c.status === 'deleted') { delete block.files[c.path]; continue }
260
693
  const st = fs.lstatSync(path.join(dir, ...c.path.split('/')))
@@ -263,25 +696,33 @@ export async function amendFolder (client, dir, block, { message, paths = [] })
263
696
  block.at = r.square.head
264
697
  block.head = r.square.head
265
698
  saveBlock(dir, block)
266
- return r
699
+ return withLocalScan(block, changes, allow, r)
267
700
  }
268
701
 
269
702
  /** The folder's changes as a unified diff against `at` (diff with no revision, in a block). */
270
703
  export async function localDiff (client, dir, block, { paths = [] } = {}) {
271
704
  const changes = localChanges(dir, block, { paths })
272
- const olds = await fetchBlobs(client, block.quilt, changes.map((c) => block.files[c.path]?.blob).filter(Boolean))
705
+ // A file over DIFF_MAX on either side is named, not diffed: a diff reads both sides whole.
706
+ const tooBig = (c) => (c.size || 0) > DIFF_MAX || (block.files[c.path]?.size || 0) > DIFF_MAX
707
+ const olds = await fetchBlobs(client, block.quilt, changes.filter((c) => !tooBig(c)).map((c) => block.files[c.path]?.blob).filter(Boolean), undefined, block._codec)
273
708
  const out = []
274
709
  for (const c of changes) {
710
+ if (tooBig(c)) { out.push(`Large file ${c.path} ${c.status}\n`); continue }
275
711
  const before = block.files[c.path]?.blob ? olds.get(block.files[c.path].blob) : null
276
- const after = c.bytes || null
712
+ const after = bytesOf(c) || null
277
713
  if (!isText(before) || !isText(after)) { out.push(`Binary file ${c.path} ${c.status}\n`); continue }
278
714
  out.push(unified(c.path, before ? before.toString('utf8') : null, after ? after.toString('utf8') : null))
279
715
  }
280
- return { changes: changes.map(({ bytes, ...c }) => c), text: out.join('') }
716
+ return { changes: changes.map(({ bytes, open, abs, ...c }) => c), text: out.join('') }
281
717
  }
282
718
 
283
719
  const absOf = (dir, p) => path.join(dir, ...p.split('/'))
284
- const removeEntry = (dir, p, mode) => { const abs = absOf(dir, p); if (mode === 'gitlink') { try { fs.rmdirSync(abs) } catch {} } else fs.rmSync(abs, { force: true }) }
720
+ function removeEntry (dir, p, mode) {
721
+ const abs = absOf(dir, p)
722
+ if (mode === 'gitlink') { try { fs.rmdirSync(abs) } catch {} return }
723
+ if (mode === 'quilt') { fs.rmSync(path.join(abs, POINTER_FILE), { force: true }); try { fs.rmdirSync(abs) } catch {} return }
724
+ fs.rmSync(abs, { force: true })
725
+ }
285
726
 
286
727
  /** Takes away the folders a removed file leaves empty, up to the block folder. */
287
728
  function pruneEmpty (dir, p) {
@@ -298,7 +739,7 @@ export async function restore (client, dir, block, { paths }) {
298
739
  if (!paths?.length) throw new Error('Which files? seam restore <paths> (. for everything here)')
299
740
  const changes = localChanges(dir, block, { paths }).filter((c) => c.status !== 'added')
300
741
  const entries = changes.map((c) => ({ path: c.path, ...block.files[c.path] }))
301
- const get = await contentsFor(client, block.quilt, `c:${block.at}`, entries)
742
+ const get = await contentsFor(client, block.quilt, `c:${block.at}`, entries, block._codec)
302
743
  for (const e of entries) block.files[e.path] = { ...await writeEntry(dir, e, await get(e)), ...(e.term ? { term: e.term } : {}) }
303
744
  saveBlock(dir, block)
304
745
  return { restored: entries.map((e) => e.path) }
@@ -315,14 +756,15 @@ export async function reset (client, dir, block, { rev } = {}) {
315
756
  const r = await restore(client, dir, block, { paths: [dir] })
316
757
  return { rev: block.at, written: r.restored.length, removed: 0, changes: 0 }
317
758
  }
318
- const t = await client.tree(block.quilt, rev, block.paths)
759
+ const t = await listTree(client, block.quilt, rev, block.paths, dir)
760
+ t.entries = t.entries.filter((e) => !e.unreadable)
319
761
  const local = new Map(localChanges(dir, block).map((c) => [c.path, c]))
320
762
  const want = new Map(t.entries.map((e) => [e.path, e]))
321
763
  const toWrite = t.entries.filter((e) => {
322
764
  const known = block.files[e.path]
323
765
  return local.has(e.path) || !known || known.blob !== e.blob || known.mode !== e.mode || (e.term || null) !== (known.term || null)
324
766
  })
325
- const get = await contentsFor(client, block.quilt, rev, toWrite)
767
+ const get = await contentsFor(client, block.quilt, rev, toWrite, block._codec, t.ticket)
326
768
  let removed = 0
327
769
  for (const e of toWrite) {
328
770
  const rec = await writeEntry(dir, e, await get(e))