@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.
- package/bin/seam.js +118 -17
- package/lib/quilts/agent-tokens.js +148 -0
- package/lib/quilts/archiver.js +241 -0
- package/lib/quilts/attest.js +212 -0
- package/lib/quilts/bisect-run.js +3 -2
- package/lib/quilts/block.js +497 -55
- package/lib/quilts/cas.js +78 -0
- package/lib/quilts/cbor.js +2 -0
- package/lib/quilts/claims.js +409 -0
- package/lib/quilts/cli.js +612 -69
- package/lib/quilts/client.js +306 -19
- package/lib/quilts/conflict-format.js +266 -0
- package/lib/quilts/daemon.js +174 -0
- package/lib/quilts/describe.js +296 -38
- package/lib/quilts/dict.js +34 -0
- package/lib/quilts/exit-archive.js +184 -0
- package/lib/quilts/export-format.js +52 -0
- package/lib/quilts/export-git.js +27 -3
- package/lib/quilts/export-lfs.js +52 -0
- package/lib/quilts/export-sign.js +49 -0
- package/lib/quilts/fastcdc.js +186 -0
- package/lib/quilts/hash.js +17 -2
- package/lib/quilts/ignored.js +46 -0
- package/lib/quilts/import-git.js +54 -2
- package/lib/quilts/journal.js +105 -0
- package/lib/quilts/limits.js +197 -0
- package/lib/quilts/linepolicy.js +109 -0
- package/lib/quilts/pack.js +146 -18
- package/lib/quilts/persona.js +70 -0
- package/lib/quilts/records.js +7 -3
- package/lib/quilts/recover.js +37 -0
- package/lib/quilts/runner.js +79 -12
- package/lib/quilts/sealed.js +231 -0
- package/lib/quilts/secrets.js +86 -0
- package/lib/quilts/signing.js +7 -3
- package/lib/quilts/tarball.js +107 -0
- package/lib/quilts/textdiff.js +7 -5
- package/lib/quilts/tools.js +404 -83
- package/lib/quilts/tree.js +351 -0
- package/lib/quilts/want.js +51 -0
- package/lib/quilts/webhook.js +116 -0
- package/package.json +3 -2
package/lib/quilts/block.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
111
|
-
|
|
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 === '
|
|
128
|
-
|
|
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
|
-
|
|
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
|
|
139
|
-
|
|
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
|
-
|
|
145
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
166
|
-
|
|
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
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
195
|
-
|
|
467
|
+
journal.done(f.path)
|
|
468
|
+
afterWrite?.(f.path)
|
|
196
469
|
}
|
|
197
470
|
block.at = to
|
|
198
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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) =>
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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))
|