heyquilt 0.3.119 → 0.3.120

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/RELEASES.md +21 -0
  2. package/package.json +1 -1
  3. package/src/api/server.js +10 -1
  4. package/src/quilts/archiver.js +8 -1
  5. package/src/quilts/autorevert.js +49 -0
  6. package/src/quilts/bisect-run.js +31 -0
  7. package/src/quilts/block.js +133 -14
  8. package/src/quilts/bucket.js +25 -0
  9. package/src/quilts/claims.js +144 -0
  10. package/src/quilts/cli.js +206 -11
  11. package/src/quilts/client.js +37 -1
  12. package/src/quilts/describe.js +149 -3
  13. package/src/quilts/dogfood.js +1 -1
  14. package/src/quilts/errors.js +8 -0
  15. package/src/quilts/events.js +19 -5
  16. package/src/quilts/export-git.js +5 -1
  17. package/src/quilts/fastmerge.js +2 -2
  18. package/src/quilts/hash.js +1 -1
  19. package/src/quilts/history.js +266 -0
  20. package/src/quilts/import-git.js +154 -56
  21. package/src/quilts/import.js +56 -20
  22. package/src/quilts/index.js +34 -2
  23. package/src/quilts/lines.js +49 -0
  24. package/src/quilts/live.js +50 -0
  25. package/src/quilts/merge-smart.js +114 -0
  26. package/src/quilts/merge.js +75 -27
  27. package/src/quilts/objects.js +17 -12
  28. package/src/quilts/records.js +2 -1
  29. package/src/quilts/resolutions.js +69 -0
  30. package/src/quilts/reviews.js +195 -0
  31. package/src/quilts/routes.js +51 -14
  32. package/src/quilts/service.js +133 -25
  33. package/src/quilts/setup.js +2 -2
  34. package/src/quilts/signing.js +149 -0
  35. package/src/quilts/store-memory.js +152 -0
  36. package/src/quilts/store-rpc.js +16 -1
  37. package/src/quilts/tags.js +64 -0
  38. package/src/quilts/tokens.js +66 -0
  39. package/src/quilts/tools.js +195 -19
  40. package/src/quilts/webhook.js +116 -0
  41. package/src/server.js +2 -2
  42. package/src/{quilts → source-live}/live-block.js +4 -3
  43. package/src/{quilts → source-live}/live-square.js +2 -1
package/src/quilts/cli.js CHANGED
@@ -4,16 +4,18 @@
4
4
  // --quilt and --square. It is not part of the Quilt app's `quilt` command.
5
5
  import path from 'node:path'
6
6
  import fs from 'node:fs'
7
+ import os from 'node:os'
7
8
  import { quiltClient } from './client.js'
8
- import { findBlock, checkout, status as blockStatus, commitFolder, syncTo, localDiff, saveBlock } from './block.js'
9
- import { describeLog, describeShow, describeDiff, describeBlame, describeStatus, describeMerge, describeWrite, describeSquare, describeSquareStatus, describeEvents } from './describe.js'
9
+ import { findBlock, checkout, status as blockStatus, commitFolder, amendFolder, syncTo, localDiff, saveBlock, restore, reset, clean, localChanges } from './block.js'
10
+ import { describeLog, describeShow, describeDiff, describeBlame, describeStatus, describeMerge, describeWrite, describeSquare, describeSquareStatus, describeEvents, describeTag, describeRelease, describeImport, describeRestore, describeReset, describeClean, describeLive, describeClaims, describeResolutions, describeLiveSet, describeMergeBase, describeGrep, describeBisect, describeCheck, describeReviews, describeReviewed, describeSquareCheck, describePolicy, describeKeys, describeTokens, describeNewToken } from './describe.js'
11
+ import { signWithLocalKey, localKey, makeLocalKey, signingKeyFile } from './signing.js'
10
12
 
11
13
  export const API_URL = 'https://api.heyquilt.com'
12
14
 
13
15
  /** The verbs `seam` answers. */
14
- export const SEAM_VERBS = new Set(['quilts', 'square', 'checkout', 'status', 'commit', 'log', 'show', 'diff', 'merge', 'rebase', 'revert', 'cherry-pick', 'blame', 'resolve', 'cat', 'ls', 'import', 'export', 'events'])
16
+ export const SEAM_VERBS = new Set(['quilts', 'square', 'checkout', 'status', 'commit', 'log', 'show', 'diff', 'merge', 'rebase', 'revert', 'cherry-pick', 'blame', 'resolve', 'cat', 'ls', 'import', 'export', 'events', 'tag', 'release', 'line', 'restore', 'reset', 'clean', 'live', 'bisect', 'grep', 'describe', 'merge-base', 'claim', 'unclaim', 'check', 'resolutions', 'review', 'sign', 'key', 'token'])
15
17
 
16
- const VALUE_FLAGS = new Set(['-m', '--message', '-n', '--max-count', '--into', '--onto', '--on-conflict', '--quilt', '--square', '--dir', '--line', '--task', '--level', '--title', '--slug', '--format', '--expect-tip', '--side', '--file', '--batch', '--to', '--note', '--after', '--kinds', '--webhook', '--secret', '--branch'])
18
+ const VALUE_FLAGS = new Set(['-m', '--quilts', '--message', '-n', '--max-count', '--into', '--onto', '--on-conflict', '--quilt', '--square', '--dir', '--line', '--task', '--level', '--title', '--slug', '--format', '--expect-tip', '--side', '--file', '--batch', '--to', '--note', '--after', '--kinds', '--webhook', '--secret', '--branch', '--from', '-S', '--run', '--marks', '--patch', '--on', '--path', '--checks', '--name', '--public-key'])
17
19
 
18
20
  export function parse (argv) {
19
21
  const pos = []; const flags = {}; const paths = []
@@ -61,6 +63,43 @@ export async function runSeamCommand (cmd, argv, { cwd = process.cwd(), token, o
61
63
  const list = await client.quilts()
62
64
  return print(list, list.length ? list.map((q) => `${q.slug} ${q.id} (${q.level})`).join('\n') : 'No quilts yet.')
63
65
  }
66
+ case 'line': {
67
+ // seam line [list] | new release/<name> [--from <rev>] | freeze <line> | unfreeze <line>
68
+ const sub = pos[0] || 'list'
69
+ const describeLine = (l) => `${l.name}: ${l.patch_count} patches, tip ${l.tip ? l.tip.slice(0, 12) : '(empty)'}${l.frozen ? ', frozen' : ''}`
70
+ if (sub === 'list') { const ls = await client.lines(needQuilt()); return print(ls, ls.map(describeLine).join('\n')) }
71
+ if (sub === 'new') {
72
+ if (!pos[1]) throw new Error('Name the line: seam line new release/<name> [--from <rev>]')
73
+ const l = await client.newLine(needQuilt(), { name: pos[1], from: flags['--from'] })
74
+ return print(l, `Made line ${describeLine(l)}.\nOpen a square on it with: seam square open "<title>" --line ${l.name}`)
75
+ }
76
+ if (sub === 'freeze' || sub === 'unfreeze') {
77
+ if (!pos[1]) throw new Error(`Which line? seam line ${sub} <line>`)
78
+ const l = await (sub === 'freeze' ? client.freezeLine : client.unfreezeLine)(needQuilt(), pos[1])
79
+ return print(l, sub === 'freeze' ? `${l.name} is frozen: merges into it are refused until seam line unfreeze ${l.name}.` : `${l.name} takes merges again.`)
80
+ }
81
+ if (sub === 'require') {
82
+ if (!pos[1]) throw new Error('Which line? seam line require <line> [<approvals>] [--checks ci,lint]')
83
+ const checks = flags['--checks'] === undefined ? undefined : String(flags['--checks']).split(',').map((c) => c.trim()).filter(Boolean)
84
+ if (pos[2] === undefined && checks === undefined) { const p = await client.linePolicy(needQuilt(), pos[1]); return print(p, describePolicy(p)) }
85
+ const p = await client.setLinePolicy(needQuilt(), pos[1], { ...(pos[2] !== undefined ? { approvals: Number(pos[2]) } : {}), ...(checks ? { checks } : {}) })
86
+ return print(p, describePolicy(p))
87
+ }
88
+ throw new Error('seam line [list] | new release/<name> [--from <rev>] | freeze <line> | unfreeze <line> | require <line> [<approvals>] [--checks ci,lint]')
89
+ }
90
+ case 'tag':
91
+ case 'release': {
92
+ // seam tag [<name> [rev]] | seam release [<name> [rev] -m <notes>]: no name lists them.
93
+ const where = (x) => `${x.short}${x.patch ? ` (${x.patch.line}:${x.patch.patch_no})` : ''}`
94
+ if (!pos[0]) {
95
+ if (cmd === 'tag') { const ts = await client.tags(needQuilt()); return print(ts, ts.length ? ts.map(describeTag).join('\n') : 'No tags yet.') }
96
+ const rs = await client.releases(needQuilt())
97
+ return print(rs, rs.length ? rs.map(describeRelease).join('\n') : 'No releases yet.')
98
+ }
99
+ if (cmd === 'tag') { const x = await client.createTag(needQuilt(), { name: pos[0], rev: pos[1] }); return print(x, `Tagged ${x.name} at ${where(x)}. Read it as tag:${x.name}.`) }
100
+ const x = await client.createRelease(needQuilt(), { name: pos[0], rev: pos[1], notes: flags['-m'] ?? flags['--message'] ?? '' })
101
+ return print(x, `Released ${x.name} at ${where(x)}. Read it as tag:${x.name}.`)
102
+ }
64
103
  case 'events': {
65
104
  // seam events [--after n] [--kinds a,b] | --webhook <url> [--secret s] [--kinds a,b] | --webhook off
66
105
  const kinds = flags['--kinds'] ? String(flags['--kinds']).split(',') : undefined
@@ -75,7 +114,7 @@ export async function runSeamCommand (cmd, argv, { cwd = process.cwd(), token, o
75
114
  case 'square': {
76
115
  const sub = pos[0] || 'list'
77
116
  if (sub === 'open') {
78
- const sq = await client.openSquare(needQuilt(), { title: pos.slice(1).join(' ') || flags['--title'] || 'untitled', line: flags['--line'], task_id: flags['--task'] })
117
+ const sq = await client.openSquare(needQuilt(), { title: pos.slice(1).join(' ') || flags['--title'] || 'untitled', line: flags['--line'], task_id: flags['--task'], ...(flags['--on'] ? { on: flags['--on'] } : {}) })
79
118
  let text = describeSquare(sq)
80
119
  if (flags['--dir']) {
81
120
  const dir = path.resolve(cwd, flags['--dir'])
@@ -130,20 +169,166 @@ export async function runSeamCommand (cmd, argv, { cwd = process.cwd(), token, o
130
169
  const st = await blockStatus(client, found.dir, block)
131
170
  return print(st, describeStatus(st))
132
171
  }
172
+ case 'review': {
173
+ // seam review [sq] (the reviews and where the square stands)
174
+ // seam review approve|request-changes|comment|dismiss [sq] [-m <text>] [--path <file> [--line <n>]]
175
+ const VERDICT = { approve: 'approve', 'request-changes': 'request_changes', comment: 'comment', dismiss: 'dismiss' }
176
+ const kind = VERDICT[pos[0]]
177
+ const sq = (kind ? pos[1] : pos[0]) || needSquare()
178
+ if (!kind) { const r = await client.reviews(needQuilt(), sq); return print(r, describeReviews(r)) }
179
+ const r = await client.review(needQuilt(), sq, { kind, body: message || '', ...(flags['--path'] ? { path: flags['--path'] } : {}), ...(flags['--line'] ? { line: Number(flags['--line']) } : {}) })
180
+ return print(r, describeReviewed(r))
181
+ }
182
+ case 'claim': {
183
+ // seam claim (lists the quilt's claims) | seam claim <pattern>... [--note <why>] (claims them for your square)
184
+ if (!pos.length) { const cs = await client.claims(needQuilt(), flags['--square']); return print(cs, describeClaims(cs)) }
185
+ const sq = needSquare()
186
+ const made = []
187
+ for (const p of pos) made.push(await client.claim(needQuilt(), sq, { pattern: p, note: flags['--note'] || message }))
188
+ return print(made, made.map((c) => `Claimed ${c.pattern} for ${c.square}: writes to it from other squares on ${c.line} are refused until the square merges or you run seam unclaim ${c.pattern}.`).join('\n'))
189
+ }
190
+ case 'check': {
191
+ // seam check <name> red|green|flaky [--line main] [--patch n] | seam check <name> green|red --square <sq>
192
+ const [name, status] = pos
193
+ if (!name || !['red', 'green', 'flaky'].includes(status)) throw new Error('seam check <name> red|green|flaky [--line <line>] [--patch <n>]')
194
+ if (flags['--square']) {
195
+ // seam check <name> green|red --square <sq>: a result for a square's latest commit (a line may require it before a merge)
196
+ const r = await client.reportSquareCheck(needQuilt(), flags['--square'], { check: name, status })
197
+ return print(r, describeSquareCheck(r))
198
+ }
199
+ const r = await client.reportCheck(needQuilt(), flags['--line'] || 'main', { check: name, status, ...(flags['--patch'] != null ? { patch: Number(flags['--patch']) } : {}) })
200
+ return print(r, describeCheck(r))
201
+ }
202
+ case 'unclaim': {
203
+ // seam unclaim <pattern|claim id>...
204
+ if (!pos.length) throw new Error('Which claim? seam unclaim <pattern>')
205
+ const sq = needSquare()
206
+ const out = []
207
+ for (const p of pos) out.push(await client.unclaim(needQuilt(), sq, p))
208
+ return print(out, out.map((r) => `Let go of ${r.released.pattern}.`).join('\n'))
209
+ }
210
+ case 'live': {
211
+ // seam live [sq] | live share (a block folder's uncommitted edits) | live clear
212
+ const sub = pos[0]
213
+ if (sub === 'share' || sub === 'clear') {
214
+ const sqRef = flags['--square'] || block?.square
215
+ if (!sqRef) throw new Error(`seam live ${sub} works in a block folder on a square (or pass --square sq:<id>).`)
216
+ if (sub === 'share' && !found) throw new Error('seam live share sends a block folder\'s uncommitted edits: run it in a folder made by seam checkout.')
217
+ const changes = sub === 'clear' ? [] : localChanges(found.dir, block).map((c) => c.status === 'deleted' ? { path: c.path, op: 'delete' } : { path: c.path, op: 'put', content: c.bytes.toString('base64'), mode: c.mode })
218
+ const r = await client.setLive(needQuilt(), sqRef, { changes })
219
+ return print(r, describeLiveSet(r))
220
+ }
221
+ const r = await client.live(needQuilt(), sub || needSquare())
222
+ return print(r, describeLive(r))
223
+ }
224
+ case 'restore':
225
+ case 'reset':
226
+ case 'clean': {
227
+ if (!found) throw new Error(`seam ${cmd} works on a block folder; run it in a folder made by seam checkout.`)
228
+ const files = [...(cmd === 'reset' ? pos.slice(1) : pos), ...paths].map((p) => path.resolve(cwd, p))
229
+ if (cmd === 'restore') { const r = await restore(client, found.dir, block, { paths: files }); return print(r, describeRestore(r)) }
230
+ if (cmd === 'reset') { const r = await reset(client, found.dir, block, { rev: pos[0] }); return print(r, describeReset(r, block)) }
231
+ const r = clean(found.dir, block, { paths: files.length ? files : [cwd], dryRun: !flags['-f'] && !flags['--force'] })
232
+ return print(r, describeClean(r, 'Run it again as seam clean -f to remove them.'))
233
+ }
133
234
  case 'commit': {
134
235
  if (!found) {
135
236
  throw new Error('seam commit seals a block folder\'s changes; run it in a folder made by seam checkout.')
136
237
  }
238
+ // --sign: signs the new commit with this computer's key (seam key add).
239
+ const signed = async (r) => {
240
+ if (!flags['--sign'] || !r?.result?.id) return ''
241
+ const s = await signWithLocalKey(client, needQuilt(), r.result.id)
242
+ r.signature = s.signature
243
+ return `\nSigned with ${s.signature.key}.`
244
+ }
245
+ if (flags['--amend']) {
246
+ const r = await amendFolder(client, found.dir, block, { message, paths: [...pos, ...paths].map((p) => path.resolve(cwd, p)) })
247
+ const s = await signed(r)
248
+ return print(r, describeWrite(r, `Amended ${r.amended.slice(0, 12)} as`) + s)
249
+ }
137
250
  if (!message) throw new Error('Say what changed: seam commit -m "message" [paths...]')
138
251
  const r = await commitFolder(client, found.dir, block, { message, paths: [...pos, ...paths].map((p) => path.resolve(cwd, p)) })
139
252
  if (r.nothing) return print(r, 'Nothing to commit.')
140
- return print(r, describeWrite(r, 'Committed'))
253
+ const s = await signed(r)
254
+ return print(r, describeWrite(r, 'Committed') + s)
255
+ }
256
+ case 'sign': {
257
+ // seam sign [rev]: signs a commit you made (default: your square's head, or main's tip).
258
+ const r = await signWithLocalKey(client, needQuilt(), pos[0] || (block?.square ? block.square_short : block?.line) || 'main')
259
+ return print(r, `Signed ${r.short} with ${r.signature.key}.`)
260
+ }
261
+ case 'token': {
262
+ // seam token [list] | new <name> [--level read|write] | revoke <id or name>
263
+ const sub = pos[0] || 'list'
264
+ if (sub === 'list') { const r = await client.tokens(needQuilt()); return print(r, describeTokens(r)) }
265
+ if (sub === 'new') {
266
+ if (!pos[1]) throw new Error('seam token new <name> [--level read|write]')
267
+ const r = await client.createToken(needQuilt(), { name: pos[1], level: flags['--level'] || 'read' })
268
+ return print(r, describeNewToken(r))
269
+ }
270
+ if (sub === 'revoke') {
271
+ if (!pos[1]) throw new Error('seam token revoke <id or name>')
272
+ const r = await client.revokeToken(needQuilt(), pos[1])
273
+ return print(r, `Revoked token ${r.name} (${r.id}): it no longer reaches the quilt.`)
274
+ }
275
+ throw new Error('seam token [list] | new <name> [--level read|write] | revoke <id or name>')
276
+ }
277
+ case 'key': {
278
+ // seam key [list [member]] | add [--name <n>] [--public-key <file>] | remove <key>
279
+ const sub = pos[0] || 'list'
280
+ if (sub === 'list') { const r = await client.signingKeys({ member: pos[1] }); return print(r, describeKeys(r)) }
281
+ if (sub === 'remove') {
282
+ if (!pos[1]) throw new Error('seam key remove <key>')
283
+ const r = await client.removeKey(pos[1])
284
+ return print(r, `Removed ${r.key}: commits it signed now show "Signature by a removed key".`)
285
+ }
286
+ if (sub !== 'add') throw new Error('seam key [list [member]] | add [--name <name>] [--public-key <file>] | remove <key>')
287
+ let pub = flags['--public-key'] ? fs.readFileSync(path.resolve(cwd, flags['--public-key']), 'utf8') : null
288
+ let made = false
289
+ if (!pub) { const k = localKey(); if (k) pub = k.public_key; else { pub = makeLocalKey(); made = true } }
290
+ const r = await client.addKey({ public_key: pub, name: flags['--name'] || os.hostname() })
291
+ return print(r, `${made ? `Made a signing key at ${signingKeyFile()} and added` : 'Added'} ${r.key}. Sign with seam commit --sign or seam sign <rev>.`)
141
292
  }
142
293
  case 'log': {
143
294
  const rev = pos[0] || (block?.square ? block.square_short : block?.line) || 'main'
144
- const r = await client.log(needQuilt(), { rev, paths, limit: flags['-n'] || flags['--max-count'] || 20 })
295
+ const r = await client.log(needQuilt(), { rev, paths, limit: flags['-n'] || flags['--max-count'] || 20, follow: flags['--follow'] ? true : undefined, show_signature: flags['--show-signature'] ? true : undefined, search: typeof flags['-S'] === 'string' ? flags['-S'] : undefined })
145
296
  return print(r, describeLog(r, { oneline: !!flags['--oneline'] }))
146
297
  }
298
+ case 'describe': {
299
+ const r = await client.describe(needQuilt(), pos[0] || (block?.square ? block.square_short : block?.line) || 'main')
300
+ return print(r, r.describe)
301
+ }
302
+ case 'merge-base': {
303
+ if (pos.length !== 2) throw new Error('seam merge-base <revision> <revision>')
304
+ const r = await client.mergeBase(needQuilt(), pos[0], pos[1])
305
+ return print(r, describeMergeBase(r))
306
+ }
307
+ case 'grep': {
308
+ // seam grep <pattern> [revision] [-- paths]: -i ignores case, -F takes the pattern as plain text, -l lists files.
309
+ if (!pos[0]) throw new Error('seam grep <pattern> [revision] [-i] [-F] [-l] [--all] [-- paths]')
310
+ if (flags['--all']) {
311
+ // --all: every quilt you can read (or --quilts a,b), at the revision (default main).
312
+ const r = await client.search({ pattern: pos[0], rev: pos[1] || 'main', quilts: flags['--quilts'] ? String(flags['--quilts']).split(',') : undefined, paths, ignore_case: flags['-i'] ? 1 : undefined, fixed: flags['-F'] ? 1 : undefined, limit: flags['--max-count'] })
313
+ return print(r, describeGrep(r, { filesOnly: !!flags['-l'] }))
314
+ }
315
+ const rev = pos[1] || (block?.square ? block.square_short : block?.line) || 'main'
316
+ const rel = (p) => found ? path.relative(found.dir, path.resolve(cwd, p)).split(path.sep).join('/') : p
317
+ const r = await client.grep(needQuilt(), { pattern: pos[0], rev, paths: paths.map(rel), ignore_case: flags['-i'] ? 1 : undefined, fixed: flags['-F'] ? 1 : undefined, limit: flags['--max-count'] })
318
+ return print(r, describeGrep(r, { filesOnly: !!flags['-l'] }))
319
+ }
320
+ case 'bisect': {
321
+ // seam bisect <bad> <good> --run "<check>": exit 0 good, 125 skip, else bad. Without --run, one step (--marks id:good,id:bad).
322
+ if (pos.length !== 2) throw new Error('seam bisect <bad> <good> --run "<check command>" (or --marks <commit>:good,<commit>:bad for one step)')
323
+ const marks = Object.fromEntries(String(flags['--marks'] || '').split(',').filter(Boolean).map((x) => x.split(':')))
324
+ if (typeof flags['--run'] === 'string') {
325
+ const { bisectRun } = await import('./bisect-run.js')
326
+ const r = await bisectRun(client, { quilt: needQuilt(), bad: pos[0], good: pos[1], run: flags['--run'], marks, api, log: (s) => { if (!json) out(s) } })
327
+ return print(r, describeBisect(r))
328
+ }
329
+ const r = await client.bisect(needQuilt(), { bad: pos[0], good: pos[1], marks })
330
+ return print(r, describeBisect(r) + (r.done ? '' : `\nThen: seam bisect ${pos[0]} ${pos[1]} --marks ${[...Object.entries(marks).map(([k, v]) => `${k}:${v}`), `${r.next.short}:<good|bad|skip>`].join(',')}`))
331
+ }
147
332
  case 'show': {
148
333
  const rev = pos[0] || (block?.square ? block.square_short : 'main')
149
334
  const format = flags['--stat'] ? 'stat' : flags['--name-only'] || flags['--name-status'] ? 'names' : 'unified'
@@ -206,13 +391,23 @@ export async function runSeamCommand (cmd, argv, { cwd = process.cwd(), token, o
206
391
  }
207
392
  case 'resolve': {
208
393
  const choose = flags['--ours'] ? 'ours' : flags['--theirs'] ? 'theirs' : flags['--base'] ? 'base' : flags['--delete'] ? 'delete' : flags['--suggestion'] ? 'suggestion' : flags['--side'] != null ? Number(flags['--side']) : null
209
- if (choose === null || !pos.length) throw new Error('seam resolve <path>... --ours | --theirs | --base | --suggestion | --side n | --delete (or edit the file and commit it without the markers)')
394
+ if (choose === null || !pos.length) throw new Error('seam resolve <path>... --ours | --theirs | --base | --suggestion | --side n | --delete [--no-remember] (or edit the file and commit it without the markers)')
210
395
  const rel = (p) => found ? path.relative(found.dir, path.resolve(cwd, p)).split(path.sep).join('/') : p
211
- const r = await client.resolve(needQuilt(), needSquare(), { ...(found ? { expect_head: block.head } : {}), entries: pos.map((p) => ({ path: rel(p), choose })) })
396
+ const r = await client.resolve(needQuilt(), needSquare(), { ...(found ? { expect_head: block.head } : {}), entries: pos.map((p) => ({ path: rel(p), choose })), ...(flags['--no-remember'] ? { remember: false } : {}) })
212
397
  let text = describeWrite(r, 'Resolved')
213
398
  if (found) { const s = await syncTo(client, found.dir, block, r.square.head, { square: r.square }); text += `\n${s.written} file(s) updated in the folder.` }
214
399
  return print(r, text)
215
400
  }
401
+ case 'resolutions': {
402
+ // seam resolutions [path] (the remembered answers) | seam resolutions forget <key>
403
+ if (pos[0] === 'forget') {
404
+ if (!pos[1]) throw new Error('Which one? seam resolutions forget <key>')
405
+ const r = await client.forgetResolution(needQuilt(), pos[1])
406
+ return print(r, `Forgot ${r.forgotten.short} (${r.forgotten.path}): that conflict will be asked about again.`)
407
+ }
408
+ const rows = await client.resolutions(needQuilt(), pos[0])
409
+ return print(rows, describeResolutions(rows))
410
+ }
216
411
  case 'blame': {
217
412
  const arg = pos[0]
218
413
  if (!arg) throw new Error('seam blame <path>[@revision]')
@@ -236,12 +431,12 @@ export async function runSeamCommand (cmd, argv, { cwd = process.cwd(), token, o
236
431
  return print(r, r.entries.map((e) => `${e.mode}\t${e.blob ? e.blob.slice(0, 12) : e.term.slice(0, 12)}\t${e.path}`).join('\n'))
237
432
  }
238
433
  case 'import': {
239
- if (pos[0] !== 'git' || !pos[1]) throw new Error('seam import git <path> [--into <new-quilt-slug>]')
434
+ if (pos[0] !== 'git' || !pos[1]) throw new Error('seam import git <path> [--into <quilt-slug>] [--validate]')
240
435
  const { importGit } = await import('./import-git.js')
241
436
  const repo = path.resolve(cwd, pos[1])
242
437
  if (!fs.existsSync(path.join(repo, '.git')) && !fs.existsSync(path.join(repo, 'HEAD'))) throw new Error(`${repo} is not a git repository`)
243
438
  const r = await importGit(client, { repo, slug: flags['--into'] || path.basename(repo).toLowerCase().replace(/[^a-z0-9-]/g, '-'), log: (s) => { if (!json) out(s) }, validate: !!flags['--validate'], batch: Number(flags['--batch']) || 100 })
244
- return print(r, `Imported ${r.commits} commits (${r.patches} patches on main) into ${r.slug} (${r.quilt}).${r.validated != null ? ` Validated ${r.validated} trees against git.` : ''}`)
439
+ return print(r, describeImport(r))
245
440
  }
246
441
  case 'export': {
247
442
  if (pos[0] !== 'git' || !pos[1]) throw new Error('seam export git <dir> [--line main] [--branch <name>]')
@@ -45,6 +45,7 @@ export function quiltClient ({ api, token, fetch: fetchImpl = globalThis.fetch,
45
45
  openSquare: (q, b) => call('POST', `${Q(q)}/squares`, b),
46
46
  square: (q, s) => call('GET', `${Q(q)}/squares/${enc(s)}`),
47
47
  commit: (q, s, b) => call('POST', `${Q(q)}/squares/${enc(s)}/commit`, b),
48
+ amend: (q, s, b) => call('POST', `${Q(q)}/squares/${enc(s)}/amend`, b),
48
49
  handoff: (q, s, b) => call('POST', `${Q(q)}/squares/${enc(s)}/handoff`, b),
49
50
  abandon: (q, s, b = {}) => call('POST', `${Q(q)}/squares/${enc(s)}/abandon`, b),
50
51
  reopen: (q, s) => call('POST', `${Q(q)}/squares/${enc(s)}/reopen`, {}),
@@ -54,6 +55,16 @@ export function quiltClient ({ api, token, fetch: fetchImpl = globalThis.fetch,
54
55
  rebase: (q, s, b) => call('POST', `${Q(q)}/squares/${enc(s)}/rebase`, b),
55
56
  cherryPick: (q, s, b) => call('POST', `${Q(q)}/squares/${enc(s)}/cherry-pick`, b),
56
57
  resolve: (q, s, b) => call('POST', `${Q(q)}/squares/${enc(s)}/resolve`, b),
58
+ lines: async (q) => (await call('GET', `${Q(q)}/lines`)).lines,
59
+ newLine: (q, b) => call('POST', `${Q(q)}/lines`, b),
60
+ reportCheck: (q, line, b) => call('POST', `${Q(q)}/lines/${enc(line)}/checks`, b),
61
+ freezeLine: (q, line) => call('POST', `${Q(q)}/lines/${enc(line)}/freeze`, {}),
62
+ unfreezeLine: (q, line) => call('POST', `${Q(q)}/lines/${enc(line)}/unfreeze`, {}),
63
+ tags: async (q) => (await call('GET', `${Q(q)}/tags`)).tags,
64
+ tag: (q, name) => call('GET', `${Q(q)}/tags/${name.split('/').map(enc).join('/')}`),
65
+ createTag: (q, b) => call('POST', `${Q(q)}/tags`, b),
66
+ releases: async (q) => (await call('GET', `${Q(q)}/releases`)).releases,
67
+ createRelease: (q, b) => call('POST', `${Q(q)}/releases`, b),
57
68
  merge: (q, line, b) => call('POST', `${Q(q)}/lines/${enc(line)}/merge`, b),
58
69
  revert: (q, b) => call('POST', `${Q(q)}/revert`, b),
59
70
  events: (q, o = {}) => call('GET', `${Q(q)}/events${qs(o)}`),
@@ -61,15 +72,40 @@ export function quiltClient ({ api, token, fetch: fetchImpl = globalThis.fetch,
61
72
  show: (q, rev, o = {}) => call('GET', `${Q(q)}/show/${enc(rev)}${qs(o)}`),
62
73
  exportPatches: (q, o = {}) => call('GET', `${Q(q)}/export/git${qs(o)}`),
63
74
  log: (q, o = {}) => call('GET', `${Q(q)}/log${qs(o)}`),
75
+ sign: (q, rev, b) => call('POST', `${Q(q)}/commits/${enc(rev)}/signatures`, b),
76
+ tokens: (q) => call('GET', `${Q(q)}/tokens`),
77
+ createToken: (q, b) => call('POST', `${Q(q)}/tokens`, b),
78
+ revokeToken: (q, id) => call('DELETE', `${Q(q)}/tokens/${enc(id)}`),
79
+ signingKeys: (o = {}) => call('GET', `/v1/signing-keys${qs(o)}`),
80
+ addKey: (b) => call('POST', '/v1/signing-keys', b),
81
+ removeKey: (key) => call('DELETE', `/v1/signing-keys/${enc(key)}`),
82
+ describe: (q, rev) => call('GET', `${Q(q)}/describe${qs({ rev })}`),
83
+ mergeBase: (q, a, b) => call('GET', `${Q(q)}/merge-base${qs({ a, b })}`),
84
+ grep: (q, o = {}) => call('GET', `${Q(q)}/grep${qs(o)}`),
85
+ search: (o = {}) => call('GET', `/v1/search${qs(o)}`),
86
+ bisect: (q, b) => call('POST', `${Q(q)}/bisect`, b),
64
87
  linePatches: (q, line = 'main', o = {}) => call('GET', `${Q(q)}/lines/${encodeURIComponent(line)}/patches${qs(o)}`),
65
88
  diff: (q, o = {}) => call('GET', `${Q(q)}/diff${qs(o)}`),
89
+ resolutions: async (q, p) => (await call('GET', `${Q(q)}/resolutions${p ? `?path=${enc(p)}` : ''}`)).resolutions,
90
+ forgetResolution: (q, key) => call('DELETE', `${Q(q)}/resolutions/${enc(key)}`),
91
+ reviews: (q, sq) => call('GET', `${Q(q)}/squares/${enc(sq)}/reviews`),
92
+ review: (q, sq, b) => call('POST', `${Q(q)}/squares/${enc(sq)}/reviews`, b),
93
+ reportSquareCheck: (q, sq, b) => call('POST', `${Q(q)}/squares/${enc(sq)}/checks`, b),
94
+ linePolicy: (q, line) => call('GET', `${Q(q)}/lines/${enc(line)}/policy`),
95
+ setLinePolicy: (q, line, b) => call('PUT', `${Q(q)}/lines/${enc(line)}/policy`, b),
96
+ claims: async (q, square) => (await call('GET', `${Q(q)}/claims${square ? `?square=${enc(square)}` : ''}`)).claims,
97
+ claim: (q, sq, b) => call('POST', `${Q(q)}/squares/${enc(sq)}/claims`, b),
98
+ unclaim: (q, sq, which) => call('DELETE', `${Q(q)}/squares/${enc(sq)}/claims/${enc(which)}`),
99
+ live: (q, sq) => call('GET', `${Q(q)}/squares/${enc(sq)}/live`),
100
+ setLive: (q, sq, body) => call('PUT', `${Q(q)}/squares/${enc(sq)}/live`, body),
66
101
  tree: (q, rev, paths) => call('GET', `${Q(q)}/tree/${enc(rev || 'main')}${qs({ paths })}`),
67
102
  cat: (q, rev, path, o = {}) => call('GET', `${Q(q)}/blob/${enc(rev || 'main')}/${pathPart(path)}${qs(o)}`),
68
103
  blame: (q, rev, path) => call('GET', `${Q(q)}/blame/${enc(rev || 'main')}/${pathPart(path)}`),
69
104
  have: (q, ids) => call('POST', `${Q(q)}/have`, { ids }),
70
105
  blobs: (q, ids) => call('POST', `${Q(q)}/blobs`, { ids }),
71
106
  uploadPack: (q, bytes) => call('POST', `${Q(q)}/packs`, bytes, { raw: true }),
72
- importCommits: (q, items) => call('POST', `${Q(q)}/import/commits`, { items }),
107
+ importCommits: (q, items, lines = []) => call('POST', `${Q(q)}/import/commits`, { items, ...(lines.length ? { lines } : {}) }),
108
+ importKnown: (q, shas) => call('POST', `${Q(q)}/import/known`, { shas }),
73
109
 
74
110
  /**
75
111
  * Uploads objects the server does not have yet ([{ kind, id, bytes }]), in packs of at most
@@ -10,6 +10,7 @@ export function describeSquare (s) {
10
10
  if (s.state === 'merged' && s.merged) bits.push(`as ${patchName(s.merged)}`)
11
11
  if (s.state === 'open') {
12
12
  bits.push(s.head ? `head ${s.head.slice(0, 12)}` : 'no commits yet')
13
+ if (s.on) bits.push(`stacked on ${s.on}`)
13
14
  if (s.behind_by) bits.push(`${s.behind_by} patch${s.behind_by === 1 ? '' : 'es'} behind ${s.line}`)
14
15
  if (s.conflicted_paths) bits.push(`${s.conflicted_paths} conflicted path${s.conflicted_paths === 1 ? '' : 's'}`)
15
16
  }
@@ -20,8 +21,8 @@ export function describeSquare (s) {
20
21
  export function describeLog (r, { oneline = false } = {}) {
21
22
  if (!r.commits.length) return 'No commits.'
22
23
  const lines = r.commits.map((c) => oneline
23
- ? `${c.short} ${c.patch ? `[${c.patch.line}:${c.patch.patch_no}] ` : ''}${first(c.message)}`
24
- : [`commit ${c.id}${c.patch ? ` (${patchName(c.patch)})` : ''}`, `Square: ${c.square}`, `Author: ${c.author.name} <${c.author.member_id}>${c.author.tool ? ` via ${c.author.tool}` : ''}`, `Date: ${when(c.authored_at)}`, '', ...String(c.message || '').split('\n').map((l) => ` ${l}`), ''].join('\n'))
24
+ ? `${c.short} ${c.patch ? `[${c.patch.line}:${c.patch.patch_no}] ` : ''}${first(c.message)}${r.follow && c.path && c.path !== r.follow ? ` (${c.path})` : ''}${c.signature ? ` (signature: ${c.signature.status})` : ''}`
25
+ : [`commit ${c.id}${c.patch ? ` (${patchName(c.patch)})` : ''}`, `Square: ${c.square}`, ...(r.follow && c.path && c.path !== r.follow ? [`Path: ${c.path}`] : []), `Author: ${c.author.name} <${c.author.member_id}>${c.author.tool ? ` via ${c.author.tool}` : ''}`, ...(c.signature ? [describeSignature(c.signature)] : []), `Date: ${when(c.authored_at)}`, '', ...String(c.message || '').split('\n').map((l) => ` ${l}`), ''].join('\n'))
25
26
  if (r.more) lines.push(oneline ? '…' : '(more: seam log -n <count>)')
26
27
  return lines.join('\n')
27
28
  }
@@ -86,6 +87,8 @@ export function describeWrite (r, verb = 'Done') {
86
87
  const bits = [`${verb}${c && c.id ? ` ${c.short}` : ''}${c && c.message ? ` "${first(c.message)}"` : ''}`]
87
88
  if (r.square) bits.push(`square ${describeSquare(r.square)}`)
88
89
  if (r.conflicted) bits.push(`${r.conflicted} conflicted path${r.conflicted === 1 ? '' : 's'}: ${(r.terms || []).map((t) => `${t.path} (${t.kind})`).join(', ')}`)
90
+ const reused = (r.combined || []).filter((c) => c.how === 'reuse')
91
+ if (reused.length) bits.push(`Settled the way it was settled before: ${reused.map((c) => `${c.path} (by ${c.by})`).join(', ')}`)
89
92
  return bits.join('\n')
90
93
  }
91
94
 
@@ -94,7 +97,8 @@ export function describeMerge (r) {
94
97
  if (r.ok) {
95
98
  const lines = [`Merged as ${patchName(r.merged)} (${r.merged.commit.slice(0, 12)})${r.retries ? ` after ${r.retries} lost race${r.retries === 1 ? '' : 's'}` : ''}.`]
96
99
  const combined = (r.combined || []).filter((c) => c.how !== 'lines' || true)
97
- if (combined.length) lines.push(`Combined with changes already on ${r.merged.line}: ${combined.map((c) => `${c.path} (${c.how})`).join(', ')}.`)
100
+ if (combined.length) lines.push(`Combined with changes already on ${r.merged.line}: ${combined.map((c) => c.how === 'reuse' ? `${c.path} (settled as ${c.by} settled it before)` : `${c.path} (${c.how})`).join(', ')}.`)
101
+ for (const k of r.restacked || []) lines.push(k.error ? `${k.square} is stacked on it and could not be moved (${k.error}): its holder rebases it.` : `Moved ${k.square}, stacked on it, onto ${r.merged.line}${k.conflicted ? ` with ${k.conflicted} conflicted path${k.conflicted === 1 ? '' : 's'}` : ''}.`)
98
102
  return lines.join('\n')
99
103
  }
100
104
  const lines = [`Not merged: ${r.refused ? r.refused.detail : r.error}.`]
@@ -118,3 +122,145 @@ export function describeEvents (r) {
118
122
  }
119
123
  return [...r.events.map(line), `(next: after=${r.after})`].join('\n')
120
124
  }
125
+
126
+ /** One tag per line: name, commit, patch, and "release" when it is one. */
127
+ export const describeTag = (x) => `${x.name} ${x.short}${x.patch ? ` ${x.patch.line}:${x.patch.patch_no}` : ''}${x.release ? ' release' : ''}`
128
+ /** A release and its notes, indented under it. */
129
+ export const describeRelease = (x) => `${x.name} ${x.short}${x.patch ? ` ${x.patch.line}:${x.patch.patch_no}` : ''} ${String(x.at && new Date(x.at).toISOString()).slice(0, 10)} ${x.by}` +
130
+ (x.notes ? '\n' + x.notes.split('\n').map((l) => ` ${l}`).join('\n') : '')
131
+
132
+ /** A git import's answer, the same words from the CLI and seam_import_git. */
133
+ export function describeImport (r) {
134
+ const said = [`${r.again ? 'Added' : 'Imported'} ${r.commits} commits (${r.patches} patches) into ${r.slug}.`]
135
+ for (const l of r.lines) said.push(` ${l.name}${l.name !== l.git ? ` (git ${l.git})` : ''}: ${l.added} new patches`)
136
+ if (r.tags) said.push(` tags: ${r.tags}`)
137
+ for (const x of r.report) said.push(` left out: ${x.ref || x.path || x.sha.slice(0, 12)}: ${x.note}`)
138
+ if (r.validated != null) said.push(`Validated ${r.validated} trees against git.`)
139
+ return said.join('\n')
140
+ }
141
+
142
+ export function describeRestore (r) {
143
+ return r.restored.length ? `Restored ${r.restored.length} file(s) to the last commit: ${r.restored.join(', ')}` : 'Nothing to restore: those files have no uncommitted edits.'
144
+ }
145
+
146
+ export function describeReset (r, block) {
147
+ const n = r.written + r.removed
148
+ const where = block.square ? 'the square' : block.line
149
+ return `Reset ${n} file(s) to ${String(r.rev).slice(0, 12)}.` + (r.changes ? `\n${r.changes} change(s) from ${where} show as uncommitted (seam status); commit them to keep them, or seam reset to go back.` : '')
150
+ }
151
+
152
+ export function describeClean (r, again) {
153
+ if (!r.removed.length) return 'Nothing to clean.'
154
+ if (r.dry_run) return r.removed.map((p) => `Would remove ${p}`).join('\n') + `\n${again}`
155
+ return `Removed ${r.removed.length} file(s): ${r.removed.join(', ')}`
156
+ }
157
+
158
+ export function describeLive (r) {
159
+ if (!r.changes.length) return `No uncommitted work shared for ${r.square}: ${r.square}:live is its head.`
160
+ return [`Uncommitted work in ${r.square}${r.by ? ` (shared by ${r.by})` : ''}, read as ${r.square}:live:`, ...r.changes.map((c) => ` ${c.status.padEnd(9)} ${c.path}`)].join('\n')
161
+ }
162
+
163
+ export function describeLiveSet (r) {
164
+ return r.changes.length ? `Shared ${r.changes.length} uncommitted change(s) of ${r.square}: read them as ${r.square}:live.` : `Cleared the live work of ${r.square}.`
165
+ }
166
+
167
+ const at = (c) => `${c.short || String(c.id || c.commit).slice(0, 12)}${c.patch ? ` (${c.patch.line}:${c.patch.patch_no})` : ''}`
168
+
169
+ export function describeMergeBase (r) {
170
+ return r.commit ? `${r.commit}${r.patch ? ` (${r.patch.line}:${r.patch.patch_no})` : ''}` : 'No common history.'
171
+ }
172
+
173
+ export function describeGrep (r, { filesOnly = false } = {}) {
174
+ if (!r.matches.length) return 'No matches.'
175
+ const lines = filesOnly ? r.files : r.matches.map((m) => `${m.quilt ? m.quilt + ':' : ''}${m.path}:${m.line}:${m.text}`)
176
+ if (r.more) lines.push('… (more: raise the limit, or narrow by paths)')
177
+ if (r.skipped) lines.push(`(${r.skipped} large file(s) not searched)`)
178
+ return lines.join('\n')
179
+ }
180
+
181
+ export function describeBisect (r) {
182
+ if (r.done) {
183
+ const said = [`First bad: ${at(r.first_bad)} ${first(r.first_bad.message)}`]
184
+ if (r.maybe) said.push(`Or one of the skipped commits before it: ${r.maybe.map(at).join(', ')}`)
185
+ return said.join('\n')
186
+ }
187
+ return `Test ${at(r.next)} ${first(r.next.message)}: ${r.remaining} commit(s) left, about ${r.steps} step(s). Mark it good, bad or skip and ask again.`
188
+ }
189
+
190
+ /** A list of claims, one per line. */
191
+ export function describeClaims (claims) {
192
+ if (!claims.length) return 'No claims.'
193
+ return claims.map((c) => `${c.pattern} ${c.square} (${c.line}) ${c.holder}${c.note ? `: ${c.note}` : ''}`).join('\n')
194
+ }
195
+
196
+ export function describeCheck (r) {
197
+ const lines = [`${r.line}: merge window ${r.window} an hour${r.red.length ? ` (red: ${r.red.join(', ')})` : ''}.`]
198
+ const v = r.revert
199
+ if (v && v.reverted) lines.push(`Reverted patch ${r.line}:${v.patch_no} as patch ${r.line}:${v.revert_patch_no}.${v.fix_square ? ` The change is again in square ${v.fix_square.short}, held by ${v.fix_square.holder}: fix it there, commit, merge.` : ''}`)
200
+ else if (v) lines.push(`Did not revert patch ${r.line}:${v.patch_no}: ${(v.refused && (v.refused.detail || v.refused.code)) || 'refused'}.${v.square ? ` The revert waits in square ${v.square.short}.` : ''}`)
201
+ return lines.join('\n')
202
+ }
203
+
204
+ /** Remembered resolutions, one per line. */
205
+ export function describeResolutions (rows) {
206
+ if (!rows.length) return 'No remembered resolutions.'
207
+ return rows.map((r) => `${r.short} ${r.path} ${r.answer === 'delete' ? 'deleted' : `kept ${r.blob.slice(0, 12)}`} (${r.how}, ${r.by})`).join('\n')
208
+ }
209
+
210
+ /** Where a square stands in review: approvals against what its line needs. */
211
+ function describeStanding (r) {
212
+ const parts = [`${r.have} of ${r.need} approval${r.need === 1 ? '' : 's'}${r.approved_by.length ? ` (${r.approved_by.join(', ')})` : ''}`]
213
+ if (r.changes_requested_by.length) parts.push(`changes asked for by ${r.changes_requested_by.join(', ')}`)
214
+ for (const c of r.checks || []) if (c.required || c.status !== 'missing') parts.push(`check ${c.name} ${c.status}`)
215
+ parts.push(r.ready ? 'ready to merge' : 'not ready to merge')
216
+ return parts.join('; ')
217
+ }
218
+
219
+ /** A square's reviews, oldest first, then where it stands. */
220
+ export function describeReviews (r) {
221
+ const rows = r.reviews.map((x) => {
222
+ const what = { approve: 'approved', request_changes: 'asked for changes', dismiss: 'dismissed their verdict', comment: 'commented' }[x.kind]
223
+ const where = x.path ? ` on ${x.path}${x.line ? `:${x.line}` : ''}` : ''
224
+ return `${x.author} ${what}${where}${x.outdated ? ' (an earlier commit)' : ''}${x.body ? `: ${x.body}` : ''}`
225
+ })
226
+ return `${r.square} on ${r.line}: ${describeStanding(r)}${rows.length ? '\n' + rows.join('\n') : '\nNo reviews yet.'}`
227
+ }
228
+
229
+ /** One review just given. */
230
+ export function describeReviewed (r) {
231
+ const x = r.review
232
+ const what = { approve: 'Approved', request_changes: 'Asked for changes on', dismiss: 'Dismissed your verdict on', comment: 'Commented on' }[x.kind]
233
+ return `${what} ${x.square}${x.path ? ` at ${x.path}${x.line ? `:${x.line}` : ''}` : ''}. ${describeStanding(r)}.`
234
+ }
235
+
236
+ /** What a line needs to take a merge. */
237
+ export function describePolicy (p) {
238
+ const parts = []
239
+ if (p.approvals) parts.push(`${p.approvals} approval${p.approvals === 1 ? '' : 's'} of a square's latest commit from someone other than its holder`)
240
+ if (p.checks?.length) parts.push(`check${p.checks.length === 1 ? '' : 's'} ${p.checks.join(', ')} green on it`)
241
+ return parts.length ? `${p.line} needs ${parts.join(' and ')} to take a merge.` : `${p.line} takes merges without approvals or checks.`
242
+ }
243
+
244
+ /** A check reported on a square. */
245
+ export function describeSquareCheck (r) {
246
+ const req = r.checks.filter((c) => c.required)
247
+ const left = req.filter((c) => c.status !== 'green').map((c) => `${c.name} ${c.status}`)
248
+ return `${r.check} is ${r.status} on ${r.square} (${r.head.slice(0, 12)}).${req.length ? (left.length ? ` Its line still needs: ${left.join(', ')}.` : ' Every check its line requires is green.') : ''}`
249
+ }
250
+
251
+ const SIGNATURE_WORDS = { good: 'Good signature', bad: 'BAD signature', removed_key: 'Signature by a removed key', none: 'No signature' }
252
+ export function describeSignature (s) {
253
+ return `Signed: ${SIGNATURE_WORDS[s.status] || s.status}${s.key ? ` from ${s.signer} (${s.key})` : ''}`
254
+ }
255
+
256
+ export function describeKeys (r) {
257
+ if (!r.keys.length) return `${r.member} has no signing keys.`
258
+ return r.keys.map((k) => `${k.key}${k.name ? ` ${k.name}` : ''}${k.removed_at ? ` (removed ${when(k.removed_at)})` : ` added ${when(k.added_at)}`}`).join('\n')
259
+ }
260
+
261
+ export function describeTokens (r) {
262
+ if (!r.tokens.length) return `${r.quilt} has no tokens. Make one with seam token new <name> [--level read|write].`
263
+ return r.tokens.map((t) => `${t.id} ${t.name} ${t.level}${t.revoked_at ? ` (revoked ${when(t.revoked_at)})` : ` made ${when(t.created_at)} by ${t.created_by}`}`).join('\n')
264
+ }
265
+
266
+ export const describeNewToken = (t) => `Made token ${t.name} (${t.level} on ${t.quilt}, id ${t.id}). Its secret, shown only this once:\n\n ${t.token}\n\nSet it as SEAM_TOKEN (or send it as Authorization: Bearer <token>).`
@@ -40,7 +40,7 @@ export async function dogfood (client, { repo, rev = 'HEAD', slug, work, keep =
40
40
 
41
41
  let t = Date.now()
42
42
  log(`Importing ${sha.slice(0, 12)} of ${repo} into ${slug}…`)
43
- const imp = await importGit(client, { repo: src, slug, batch, validate: 'all', log })
43
+ const imp = await importGit(client, { repo: src, slug, batch, validate: 'all', log, onlyHead: true, fresh: true })
44
44
  times.import_ms = Date.now() - t
45
45
  log(`Imported ${imp.commits} commits (${imp.patches} patches); every commit's tree equals git's (${imp.validated}).`)
46
46
 
@@ -0,0 +1,8 @@
1
+ // What both the server and the seam client need without loading the server (service.js).
2
+ export class QuiltError extends Error {
3
+ /** `code` is the wire name (04/07/12): head_moved, tip_moved, conflict, not_found, ... */
4
+ constructor (status, code, message, extra = {}) { super(message); this.status = status; this.code = code; this.extra = extra }
5
+ }
6
+
7
+ /** A square's short handle: sq: + the last 8 hex of its uuid (02). */
8
+ export const squareShort = (id) => `sq:${String(id).replace(/-/g, '').slice(-8)}`
@@ -1,13 +1,12 @@
1
1
  // Events (plans/quilt-source-control/12): never written in the commit or merge transaction. The
2
2
  // events job reads the quilt's log after its cursor, derives each event and its recipients, and
3
3
  // writes them with the cursor moved in one transaction (store.addEvents); then it POSTs each one
4
- // to the recipients' webhooks, signed and retried the way the session's webhooks are
5
- // (webhooks.js). Members read theirs over HTTP (GET /v1/quilts/:q/events?after=), the CLI
6
- // (`quilt events`) and the MCP tool quilt_events, so an agent in any tool is woken the same way.
4
+ // to the recipients' webhooks, signed and retried by seam's own webhook.js. Members read theirs
5
+ // over HTTP (GET /v1/quilts/:q/events?after=), the CLI (`seam events`) and the MCP tool seam_events, so an agent in any tool is woken the same way.
7
6
  import { QuiltError, squareShort } from './service.js'
8
- import { makeSubscription, deliverWebhook, RETRY_DELAYS_MS } from '../webhooks.js'
7
+ import { makeSubscription, deliverWebhook, RETRY_DELAYS_MS } from './webhook.js'
9
8
 
10
- export const QUILT_EVENT_KINDS = ['patch.merged', 'conflict.added', 'square.handoff']
9
+ export const QUILT_EVENT_KINDS = ['patch.merged', 'patch.reverted', 'conflict.added', 'square.handoff']
11
10
  const MAX_DIFF_PATHS = 200
12
11
 
13
12
  export function eventOps (svc, sq, { allowLocalWebhooks = false, fetch, delays = RETRY_DELAYS_MS, log = () => {} } = {}) {
@@ -25,6 +24,21 @@ export function eventOps (svc, sq, { allowLocalWebhooks = false, fetch, delays =
25
24
  /** The events one log row causes. */
26
25
  async function derive (qid, row) {
27
26
  const out = []
27
+ if (row.kind === 'merge' && row.payload?.reverts) {
28
+ // patch.reverted: the reverted patch's holder (and whom they acted for) learns it is gone (07 step 4).
29
+ const c = await getCommit(qid, row.commit)
30
+ const gone = await store.patch(qid, row.line, Number(row.payload.reverts))
31
+ const orig = gone && gone.square ? await store.getSquare(gone.square) : null
32
+ const oc = gone ? await getCommit(qid, gone.commit).catch(() => null) : null
33
+ const to = [orig && !orig.import ? orig.holder : null, oc && oc.on_behalf_of].filter(Boolean)
34
+ if (to.length) {
35
+ out.push({
36
+ kind: 'patch.reverted', at: row.at, cause_seq: row.seq, square: orig.id, line: row.line, patch_no: Number(row.payload.reverts), by: by(c), paths: c.changes.map((ch) => ch.path).slice(0, MAX_DIFF_PATHS),
37
+ payload: { patch_no: Number(row.payload.reverts), revert_patch_no: row.patch_no, commit: c.id, message: c.message },
38
+ recipients: [...new Set(to)]
39
+ })
40
+ }
41
+ }
28
42
  if (row.kind === 'merge') {
29
43
  // patch.merged: to the holder and writers of every other open square on the line that changed a path the patch changed.
30
44
  const c = await getCommit(qid, row.commit)