@heyquilt/seam 0.0.0-stage → 0.1.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.
@@ -0,0 +1,254 @@
1
+ // `seam <verb>`, Quilt source control's own command (the @heyquilt/seam package, seam/): git's
2
+ // command names and flags (plans/quilt-source-control/12), answered by the server. Inside a block
3
+ // folder (.seam/block.json) the quilt and square come from the folder; elsewhere name them with
4
+ // --quilt and --square. It is not part of the Quilt app's `quilt` command.
5
+ import path from 'node:path'
6
+ import fs from 'node:fs'
7
+ 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'
10
+
11
+ export const API_URL = 'https://api.heyquilt.com'
12
+
13
+ /** 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'])
15
+
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'])
17
+
18
+ export function parse (argv) {
19
+ const pos = []; const flags = {}; const paths = []
20
+ let rest = false
21
+ for (let i = 0; i < argv.length; i++) {
22
+ const a = argv[i]
23
+ if (rest) { paths.push(a); continue }
24
+ if (a === '--') { rest = true; continue }
25
+ const eq = a.match(/^(--[\w-]+)=(.*)$/)
26
+ if (eq) { flags[eq[1]] = eq[2]; continue }
27
+ if (VALUE_FLAGS.has(a)) { flags[a] = argv[++i]; continue }
28
+ if (/^-n\d+$/.test(a)) { flags['-n'] = a.slice(2); continue }
29
+ if (a.startsWith('-') && a.length > 1) { flags[a] = true; continue }
30
+ pos.push(a)
31
+ }
32
+ return { pos, flags, paths }
33
+ }
34
+
35
+ /** The client for a block (or none): SEAM_API_URL / SEAM_TOKEN first, else the block's server and `token` (seam's own sign-in). */
36
+ export function connect (block, token) {
37
+ const api = process.env.SEAM_API_URL || block?.api || API_URL
38
+ const key = process.env.SEAM_TOKEN || token
39
+ if (!key) throw new Error('Not signed in: run seam login, or set SEAM_TOKEN to an agent key.')
40
+ return { client: quiltClient({ api, token: key }), api }
41
+ }
42
+
43
+ /** Runs one verb; returns the text to print (or JSON with --json). `token` is the signed-in person's, when there is one. */
44
+ export async function runSeamCommand (cmd, argv, { cwd = process.cwd(), token, out = (s) => process.stdout.write(s.endsWith('\n') ? s : s + '\n') } = {}) {
45
+ const { pos, flags, paths } = parse(argv)
46
+ const found = findBlock(cwd)
47
+ const block = found?.block
48
+ const { client, api } = connect(block, token)
49
+ const quilt = flags['--quilt'] || block?.quilt
50
+ const needQuilt = () => { if (!quilt) throw new Error('Name the quilt with --quilt <slug or id> (or run this inside a block folder).'); return quilt }
51
+ const needSquare = () => { const s = flags['--square'] || block?.square; if (!s) throw new Error('No square here: run this in a block folder, or pass --square sq:<id>.'); return s }
52
+ const json = !!flags['--json']
53
+ const print = (data, text) => out(json ? JSON.stringify(data, null, 2) : text)
54
+ const message = flags['-m'] || flags['--message']
55
+
56
+ switch (cmd) {
57
+ case 'quilts': {
58
+ if (pos[0] === 'new') { const q = await client.createQuilt({ slug: pos[1], name: flags['--title'] }); return print(q, `Made quilt ${q.slug} (${q.id}).`) }
59
+ if (pos[0] === 'add') { const r = await client.addMember(needQuilt(), { member_id: pos[1], level: flags['--level'] || 'write' }); return print(r, `${r.member_id}: ${r.level || 'removed'}`) }
60
+ if (pos[0] === 'show') { const q = await client.quilt(pos[1] || needQuilt()); return print(q, `${q.slug} (${q.id})\n${q.lines.map((l) => ` ${l.name}: ${l.patch_count} patches, tip ${l.tip ? l.tip.slice(0, 12) : '(empty)'}`).join('\n')}`) }
61
+ const list = await client.quilts()
62
+ return print(list, list.length ? list.map((q) => `${q.slug} ${q.id} (${q.level})`).join('\n') : 'No quilts yet.')
63
+ }
64
+ case 'events': {
65
+ // seam events [--after n] [--kinds a,b] | --webhook <url> [--secret s] [--kinds a,b] | --webhook off
66
+ const kinds = flags['--kinds'] ? String(flags['--kinds']).split(',') : undefined
67
+ if (flags['--webhook']) {
68
+ const off = flags['--webhook'] === 'off'
69
+ const r = await client.subscribe(needQuilt(), off ? { url: null } : { url: flags['--webhook'], secret: flags['--secret'], events: kinds })
70
+ return print(r, off ? 'Webhook removed.' : `Events (${r.events.join(', ')}) now POST to ${r.url}${r.secret ? `\nSigning secret (shown once): ${r.secret}` : ''}`)
71
+ }
72
+ const r = await client.events(needQuilt(), { after: flags['--after'], kinds })
73
+ return print(r, describeEvents(r))
74
+ }
75
+ case 'square': {
76
+ const sub = pos[0] || 'list'
77
+ 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'] })
79
+ let text = describeSquare(sq)
80
+ if (flags['--dir']) {
81
+ const dir = path.resolve(cwd, flags['--dir'])
82
+ await checkout(client, { quilt: needQuilt(), square: sq.id, paths, dir, api })
83
+ text += `\nChecked out into ${dir}.`
84
+ }
85
+ return print(sq, text)
86
+ }
87
+ // seam square handoff [sq] --to <member> [--note text] | abandon [sq] | reopen [sq]
88
+ if (sub === 'handoff') { const sq = await client.handoff(needQuilt(), pos[1] || needSquare(), { to: flags['--to'], note: flags['--note'] }); return print(sq, `Handed ${sq.short || sq.id} to ${sq.holder}.`) }
89
+ if (sub === 'abandon') { const sq = await client.abandon(needQuilt(), pos[1] || needSquare()); return print(sq, `Abandoned ${sq.short || sq.id}; seam square reopen brings it back.`) }
90
+ if (sub === 'reopen') { const sq = await client.reopen(needQuilt(), pos[1] || needSquare()); return print(sq, `Reopened ${sq.short || sq.id}; you hold it.`) }
91
+ // seam square fold [sq] --into sq:<id> | split [sq] <paths,...> <paths,...> [--title t]
92
+ if (sub === 'fold') {
93
+ if (!flags['--into']) throw new Error('Fold into which square? seam square fold [sq:from] --into sq:<id>')
94
+ const r = await client.fold(needQuilt(), pos[1] || needSquare(), { into: flags['--into'], message })
95
+ return print(r, describeWrite(r, `Folded ${r.folded.short} into ${r.square.short}:`))
96
+ }
97
+ if (sub === 'split') {
98
+ const own = pos[1] && pos[1].startsWith('sq:')
99
+ const groups = pos.slice(own ? 2 : 1).map((g) => g.split(',').filter(Boolean))
100
+ if (!groups.length) throw new Error('Split by which paths? seam square split [sq] docs,README.md src (one group per new square)')
101
+ const r = await client.split(needQuilt(), own ? pos[1] : needSquare(), { by: 'paths', groups, ...(flags['--title'] ? { titles: [flags['--title']] } : {}) })
102
+ const text = [`Kept in ${r.square.short}: ${r.kept.join(', ') || '(nothing)'}`, ...r.squares.map((q) => `New ${describeSquare(q)}: ${q.paths.join(', ')}`)].join('\n')
103
+ if (found && block.square === r.square.id) { const st = await syncTo(client, found.dir, block, r.square.head, { square: r.square }); return print(r, `${text}\n${st.written} file(s) updated in the folder.`) }
104
+ return print(r, text)
105
+ }
106
+ if (sub === 'show') { const sq = await client.square(needQuilt(), pos[1] || needSquare()); return print(sq, describeSquareStatus(sq)) }
107
+ const list = await client.squares(needQuilt(), { state: flags['--all'] ? undefined : (flags['--state'] || 'open'), mine: !!flags['--mine'] })
108
+ return print(list, list.length ? list.map(describeSquare).join('\n') : 'No open squares.')
109
+ }
110
+ case 'checkout': {
111
+ const target = pos[0]
112
+ if (!target) throw new Error('seam checkout <sq:id | revision> [--dir folder] [-- paths]')
113
+ const isSquare = target.startsWith('sq:') && !/:(base|live)$|~\d+$/.test(target)
114
+ if (found && !flags['--dir']) {
115
+ // Switch this block to another square or revision: only from a clean folder.
116
+ const st = await blockStatus(client, found.dir, block)
117
+ if (st.changes.length) throw new Error(`This folder has ${st.changes.length} uncommitted change(s); commit them first.`)
118
+ const sq = isSquare ? await client.square(block.quilt, target) : null
119
+ const to = sq ? (sq.head || sq.base) : (await client.show(block.quilt, target, { format: 'none' })).commit.id
120
+ const r = await syncTo(client, found.dir, block, to, { square: sq })
121
+ if (!sq) { block.square = null; block.square_short = null; block.head = null; saveBlock(found.dir, block) }
122
+ return print(r, `Switched to ${sq ? `${sq.short} "${sq.title}"` : target} (${r.written} file(s) written).`)
123
+ }
124
+ const dir = path.resolve(cwd, flags['--dir'] || (isSquare ? target.replace(':', '-') : 'seam-checkout'))
125
+ const r = await checkout(client, { quilt: needQuilt(), ...(isSquare ? { square: target } : { rev: target }), paths, dir, api })
126
+ return print({ dir, files: r.files, block: { ...r.block, files: undefined } }, `Checked out ${r.files} file(s) of ${isSquare ? target : target} under ${r.block.paths.map((p) => p || '/').join(', ')} into ${dir}.`)
127
+ }
128
+ case 'status': {
129
+ if (!found) throw new Error('Not in a block folder (no .seam/block.json here or above).')
130
+ const st = await blockStatus(client, found.dir, block)
131
+ return print(st, describeStatus(st))
132
+ }
133
+ case 'commit': {
134
+ if (!found) {
135
+ throw new Error('seam commit seals a block folder\'s changes; run it in a folder made by seam checkout.')
136
+ }
137
+ if (!message) throw new Error('Say what changed: seam commit -m "message" [paths...]')
138
+ const r = await commitFolder(client, found.dir, block, { message, paths: [...pos, ...paths].map((p) => path.resolve(cwd, p)) })
139
+ if (r.nothing) return print(r, 'Nothing to commit.')
140
+ return print(r, describeWrite(r, 'Committed'))
141
+ }
142
+ case 'log': {
143
+ 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 })
145
+ return print(r, describeLog(r, { oneline: !!flags['--oneline'] }))
146
+ }
147
+ case 'show': {
148
+ const rev = pos[0] || (block?.square ? block.square_short : 'main')
149
+ const format = flags['--stat'] ? 'stat' : flags['--name-only'] || flags['--name-status'] ? 'names' : 'unified'
150
+ const r = await client.show(needQuilt(), rev, { format, paths })
151
+ return print(r, describeShow(r, { format }))
152
+ }
153
+ case 'diff': {
154
+ const format = flags['--stat'] ? 'stat' : flags['--name-only'] || flags['--name-status'] ? 'names' : 'unified'
155
+ if (!pos.length && found) {
156
+ const d = await localDiff(client, found.dir, block, { paths: paths.map((p) => path.resolve(cwd, p)) })
157
+ return print(d, format === 'unified' ? (d.text || '') : d.changes.map((c) => `${c.status[0].toUpperCase()}\t${c.path}`).join('\n'))
158
+ }
159
+ const [from, to] = pos.length === 1 && pos[0].includes('..') ? pos[0].split('..') : pos.length === 1 ? [pos[0], block?.square ? block.square_short : 'main'] : pos
160
+ const r = await client.diff(needQuilt(), { from, to, paths, format })
161
+ return print(r, describeDiff(r, { format }))
162
+ }
163
+ case 'merge': {
164
+ let into = flags['--into'] || (pos.length ? null : (block?.line || 'main'))
165
+ // git merge <other>: in a block on a square, merges that square (or revision) into this one.
166
+ if (!into && block?.square) into = block.square_short || block.square
167
+ if (!into) throw new Error('Merge into what? seam merge sq:<id> --into main (a line) or --into sq:<id> (a square)')
168
+ if (/^sq:/.test(into)) {
169
+ const target = into
170
+ const from = pos[0] || needSquare()
171
+ const mine = found && (target === block.square_short || target === block.square)
172
+ const r = await client.mergeSquare(needQuilt(), target, { from, ...(mine ? { expect_head: block.head } : {}), message })
173
+ let text = r.noop ? `Already up to date: ${from} is in ${target}.` : describeWrite(r, `Merged ${from} into ${target}:`)
174
+ if (mine && !r.noop) { const st = await syncTo(client, found.dir, block, r.square.head, { square: r.square }); text += `\n${st.written} file(s) updated in the folder.` }
175
+ return print(r, text)
176
+ }
177
+ const sqId = pos[0] && pos[0].startsWith('sq:') ? pos[0] : needSquare()
178
+ const r = await client.merge(needQuilt(), into, { square: sqId, ...(found && sqId === block.square ? { expect_head: block.head } : {}), message, dry_run: !!flags['--dry-run'], on_conflict: flags['--on-conflict'], expect_tip: flags['--expect-tip'] })
179
+ let text = describeMerge(r)
180
+ // The refused merge left the square rebased onto the tip with its conflicts: bring the folder there.
181
+ if (found && sqId === block.square && !r.ok && r.head && r.head !== block.head) {
182
+ const s = await syncTo(client, found.dir, block, r.head, { square: r.square })
183
+ text += `\nThe folder now holds the rebased files (${s.written} written)${s.kept.length ? `; kept your own version of ${s.kept.join(', ')}` : ''}.`
184
+ }
185
+ return print(r, text)
186
+ }
187
+ case 'rebase': {
188
+ const r = await client.rebase(needQuilt(), needSquare(), { ...(found ? { expect_head: block.head } : {}), onto: flags['--onto'] })
189
+ let text = r.noop ? 'Already on the tip.' : describeWrite(r, 'Rebased')
190
+ if (found && !r.noop) { const s = await syncTo(client, found.dir, block, r.square.head || r.square.base, { square: r.square }); text += `\n${s.written} file(s) updated in the folder.` }
191
+ return print(r, text)
192
+ }
193
+ case 'cherry-pick': {
194
+ if (!pos[0]) throw new Error('seam cherry-pick <revision>')
195
+ const r = await client.cherryPick(needQuilt(), needSquare(), { from: pos[0], ...(found ? { expect_head: block.head } : {}) })
196
+ let text = describeWrite(r, 'Picked')
197
+ 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.` }
198
+ return print(r, text)
199
+ }
200
+ case 'revert': {
201
+ if (!pos[0]) throw new Error('seam revert <patch:n | revision>')
202
+ const r = await client.revert(needQuilt(), { rev: pos[0], line: flags['--line'] })
203
+ let text = describeWrite(r, 'Reverted in a new square') + `\nMerge it with: seam merge ${r.square.short} --into ${r.square.line}`
204
+ if (flags['--dir']) { const dir = path.resolve(cwd, flags['--dir']); await checkout(client, { quilt: needQuilt(), square: r.square.id, dir, api }); text += `\nChecked out into ${dir}.` }
205
+ return print(r, text)
206
+ }
207
+ case 'resolve': {
208
+ 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)')
210
+ 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 })) })
212
+ let text = describeWrite(r, 'Resolved')
213
+ 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
+ return print(r, text)
215
+ }
216
+ case 'blame': {
217
+ const arg = pos[0]
218
+ if (!arg) throw new Error('seam blame <path>[@revision]')
219
+ const at = arg.lastIndexOf('@')
220
+ const [p, rev] = at > 0 ? [arg.slice(0, at), arg.slice(at + 1)] : [arg, pos[1] || (block?.square ? block.square_short : 'main')]
221
+ const rel = found && at < 0 ? path.relative(found.dir, path.resolve(cwd, p)).split(path.sep).join('/') : p
222
+ const r = await client.blame(needQuilt(), rev, rel)
223
+ return print(r, describeBlame(r))
224
+ }
225
+ case 'cat': {
226
+ const arg = pos[0] || ''
227
+ const at = arg.lastIndexOf('@')
228
+ const [p, rev] = at > 0 ? [arg.slice(0, at), arg.slice(at + 1)] : [arg, 'main']
229
+ const r = await client.cat(needQuilt(), rev, p, { format: flags['--format'] || 'diff' })
230
+ if (json) return print(r, '')
231
+ process.stdout.write(Buffer.from(r.content, 'base64'))
232
+ return
233
+ }
234
+ case 'ls': {
235
+ const r = await client.tree(needQuilt(), pos[0] || 'main', pos.slice(1).concat(paths))
236
+ 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
+ }
238
+ case 'import': {
239
+ if (pos[0] !== 'git' || !pos[1]) throw new Error('seam import git <path> [--into <new-quilt-slug>]')
240
+ const { importGit } = await import('./import-git.js')
241
+ const repo = path.resolve(cwd, pos[1])
242
+ if (!fs.existsSync(path.join(repo, '.git')) && !fs.existsSync(path.join(repo, 'HEAD'))) throw new Error(`${repo} is not a git repository`)
243
+ 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.` : ''}`)
245
+ }
246
+ case 'export': {
247
+ if (pos[0] !== 'git' || !pos[1]) throw new Error('seam export git <dir> [--line main] [--branch <name>]')
248
+ const { exportGit } = await import('./export-git.js')
249
+ const r = await exportGit(client, { q: needQuilt(), dir: path.resolve(cwd, pos[1]), line: flags['--line'] || 'main', branch: flags['--branch'] || undefined, log: (s) => { if (!json) out(s) } })
250
+ return print(r, r.exported ? `Exported ${r.exported} patches to ${r.branch} in ${pos[1]}: ${r.branch} is ${r.sha} (patch ${r.patch_no}).` : `Nothing new: ${r.branch} is ${r.sha || 'empty'} (patch ${r.patch_no}).`)
251
+ }
252
+ }
253
+ throw new Error(`unknown seam command ${cmd}`)
254
+ }
@@ -0,0 +1,92 @@
1
+ // The quilt API from a client (the CLI, the local MCP, the importer): one function per route.
2
+ // A refusal throws an Error with the server's `code`, `status` and fields (head, terms, ...).
3
+ import { buildPack } from './pack.js'
4
+
5
+ const enc = encodeURIComponent
6
+ const qs = (o) => {
7
+ const p = Object.entries(o).filter(([, v]) => v !== undefined && v !== null && v !== '' && !(Array.isArray(v) && !v.length))
8
+ return p.length ? '?' + p.map(([k, v]) => `${k}=${enc(Array.isArray(v) ? v.join(',') : v)}`).join('&') : ''
9
+ }
10
+ const pathPart = (p) => String(p).split('/').map(enc).join('/')
11
+
12
+ export function quiltClient ({ api, token, fetch: fetchImpl = globalThis.fetch, timeoutMs = 120_000 }) {
13
+ const base = String(api || '').replace(/\/+$/, '')
14
+ if (!base) throw new Error('No quilt server: set QUILT_API (or sign in with `quilt login`).')
15
+ const auth = token ? (token.startsWith('QuiltPass ') ? token : `Bearer ${token}`) : null
16
+
17
+ async function call (method, route, body, { raw = false } = {}) {
18
+ let res
19
+ try {
20
+ res = await fetchImpl(base + route, {
21
+ method,
22
+ headers: { ...(auth ? { authorization: auth } : {}), ...(body !== undefined ? { 'content-type': raw ? 'application/octet-stream' : 'application/json' } : {}) },
23
+ body: body === undefined ? undefined : raw ? body : JSON.stringify(body),
24
+ signal: AbortSignal.timeout(timeoutMs)
25
+ })
26
+ } catch (err) {
27
+ throw new Error(`Couldn't reach the quilt server at ${base} (${err?.cause?.code || err?.name || 'no answer'}).`)
28
+ }
29
+ const data = await res.json().catch(() => null)
30
+ if (!res.ok && !(data && data.refused)) {
31
+ const { error, ...extra } = data || {}
32
+ throw Object.assign(new Error(error || `The quilt server answered ${res.status}.`), { status: res.status, ...extra })
33
+ }
34
+ return data
35
+ }
36
+
37
+ const Q = (q) => `/v1/quilts/${enc(q)}`
38
+ return {
39
+ call,
40
+ quilts: async () => (await call('GET', '/v1/quilts')).quilts,
41
+ createQuilt: (b) => call('POST', '/v1/quilts', b),
42
+ quilt: (q) => call('GET', Q(q)),
43
+ addMember: (q, b) => call('POST', `${Q(q)}/members`, b),
44
+ squares: async (q, f = {}) => (await call('GET', `${Q(q)}/squares${qs({ state: f.state, mine: f.mine ? 1 : undefined, task: f.task })}`)).squares,
45
+ openSquare: (q, b) => call('POST', `${Q(q)}/squares`, b),
46
+ square: (q, s) => call('GET', `${Q(q)}/squares/${enc(s)}`),
47
+ commit: (q, s, b) => call('POST', `${Q(q)}/squares/${enc(s)}/commit`, b),
48
+ handoff: (q, s, b) => call('POST', `${Q(q)}/squares/${enc(s)}/handoff`, b),
49
+ abandon: (q, s, b = {}) => call('POST', `${Q(q)}/squares/${enc(s)}/abandon`, b),
50
+ reopen: (q, s) => call('POST', `${Q(q)}/squares/${enc(s)}/reopen`, {}),
51
+ mergeSquare: (q, s, b) => call('POST', `${Q(q)}/squares/${enc(s)}/merge`, b),
52
+ fold: (q, s, b) => call('POST', `${Q(q)}/squares/${enc(s)}/fold`, b),
53
+ split: (q, s, b) => call('POST', `${Q(q)}/squares/${enc(s)}/split`, b),
54
+ rebase: (q, s, b) => call('POST', `${Q(q)}/squares/${enc(s)}/rebase`, b),
55
+ cherryPick: (q, s, b) => call('POST', `${Q(q)}/squares/${enc(s)}/cherry-pick`, b),
56
+ resolve: (q, s, b) => call('POST', `${Q(q)}/squares/${enc(s)}/resolve`, b),
57
+ merge: (q, line, b) => call('POST', `${Q(q)}/lines/${enc(line)}/merge`, b),
58
+ revert: (q, b) => call('POST', `${Q(q)}/revert`, b),
59
+ events: (q, o = {}) => call('GET', `${Q(q)}/events${qs(o)}`),
60
+ subscribe: (q, b) => call('POST', `${Q(q)}/webhook`, b),
61
+ show: (q, rev, o = {}) => call('GET', `${Q(q)}/show/${enc(rev)}${qs(o)}`),
62
+ exportPatches: (q, o = {}) => call('GET', `${Q(q)}/export/git${qs(o)}`),
63
+ log: (q, o = {}) => call('GET', `${Q(q)}/log${qs(o)}`),
64
+ linePatches: (q, line = 'main', o = {}) => call('GET', `${Q(q)}/lines/${encodeURIComponent(line)}/patches${qs(o)}`),
65
+ diff: (q, o = {}) => call('GET', `${Q(q)}/diff${qs(o)}`),
66
+ tree: (q, rev, paths) => call('GET', `${Q(q)}/tree/${enc(rev || 'main')}${qs({ paths })}`),
67
+ cat: (q, rev, path, o = {}) => call('GET', `${Q(q)}/blob/${enc(rev || 'main')}/${pathPart(path)}${qs(o)}`),
68
+ blame: (q, rev, path) => call('GET', `${Q(q)}/blame/${enc(rev || 'main')}/${pathPart(path)}`),
69
+ have: (q, ids) => call('POST', `${Q(q)}/have`, { ids }),
70
+ blobs: (q, ids) => call('POST', `${Q(q)}/blobs`, { ids }),
71
+ uploadPack: (q, bytes) => call('POST', `${Q(q)}/packs`, bytes, { raw: true }),
72
+ importCommits: (q, items) => call('POST', `${Q(q)}/import/commits`, { items }),
73
+
74
+ /**
75
+ * Uploads objects the server does not have yet ([{ kind, id, bytes }]), in packs of at most
76
+ * ~48 MiB, after asking `have` (04: want-only, one pack per commit for a typical commit).
77
+ */
78
+ async upload (q, objs) {
79
+ const missing = new Set()
80
+ for (let i = 0; i < objs.length; i += 10000) for (const id of (await call('POST', `${Q(q)}/have`, { ids: objs.slice(i, i + 10000).map((o) => o.id) })).missing) missing.add(id)
81
+ const todo = objs.filter((o) => missing.has(o.id))
82
+ let batch = []; let size = 0; let packs = 0
83
+ const flush = async () => { if (!batch.length) return; await call('POST', `${Q(q)}/packs`, buildPack(batch).bytes, { raw: true }); packs++; batch = []; size = 0 }
84
+ for (const o of todo) {
85
+ if (size + o.bytes.length > 48 * 1024 * 1024) await flush()
86
+ batch.push(o); size += o.bytes.length
87
+ }
88
+ await flush()
89
+ return { uploaded: todo.length, packs }
90
+ }
91
+ }
92
+ }
@@ -0,0 +1,120 @@
1
+ // The server's records as plain English (plans/quilt-source-control/12, "Every write returns the
2
+ // server's view"): one rendering for the CLI and both MCPs, so every tool says the same thing.
3
+
4
+ const when = (ms) => (ms ? new Date(ms).toISOString().replace('T', ' ').slice(0, 16) + ' UTC' : '')
5
+ const first = (m) => String(m || '').split('\n')[0]
6
+ const patchName = (p) => (p ? `patch ${p.line}:${p.patch_no}` : '')
7
+
8
+ export function describeSquare (s) {
9
+ const bits = [`${s.short} "${s.title}"`, s.state]
10
+ if (s.state === 'merged' && s.merged) bits.push(`as ${patchName(s.merged)}`)
11
+ if (s.state === 'open') {
12
+ bits.push(s.head ? `head ${s.head.slice(0, 12)}` : 'no commits yet')
13
+ if (s.behind_by) bits.push(`${s.behind_by} patch${s.behind_by === 1 ? '' : 'es'} behind ${s.line}`)
14
+ if (s.conflicted_paths) bits.push(`${s.conflicted_paths} conflicted path${s.conflicted_paths === 1 ? '' : 's'}`)
15
+ }
16
+ bits.push(`held by ${s.holder}`)
17
+ return bits.join(' · ')
18
+ }
19
+
20
+ export function describeLog (r, { oneline = false } = {}) {
21
+ if (!r.commits.length) return 'No commits.'
22
+ 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'))
25
+ if (r.more) lines.push(oneline ? '…' : '(more: seam log -n <count>)')
26
+ return lines.join('\n')
27
+ }
28
+
29
+ export function describeDiff (d, { format = 'unified' } = {}) {
30
+ if (!d.files.length) return 'No differences.'
31
+ if (format === 'names') return d.files.map((f) => `${f.status[0].toUpperCase()}\t${f.path}`).join('\n')
32
+ if (format === 'stat') {
33
+ const w = Math.max(...d.files.map((f) => f.path.length))
34
+ return [...d.files.map((f) => ` ${f.path.padEnd(w)} | ${f.binary ? 'Bin' : `${(f.add || 0) + (f.del || 0)} ${'+'.repeat(Math.min(f.add || 0, 40))}${'-'.repeat(Math.min(f.del || 0, 40))}`}`), ` ${d.files.length} file${d.files.length === 1 ? '' : 's'} changed, ${d.add || 0} insertions(+), ${d.del || 0} deletions(-)`].join('\n')
35
+ }
36
+ return d.files.map((f) => f.binary ? `Binary file ${f.path} ${f.status}\n` : f.patch || `${f.path} ${f.status}\n`).join('')
37
+ }
38
+
39
+ export function describeShow (r, { format = 'unified' } = {}) {
40
+ const c = r.commit
41
+ const head = [`commit ${c.id}${c.patch ? ` (${patchName(c.patch)})` : ''}`]
42
+ if (c.parents.length > 1) head.push(`Merge: ${c.parents.map((p) => p.slice(0, 12)).join(' ')}`)
43
+ head.push(`Square: ${c.square}${r.square ? ` "${r.square.title}" (${r.square.state})` : ''}`)
44
+ head.push(`Author: ${c.author.name} <${c.author.member_id}>${c.author.tool ? ` via ${c.author.tool}` : ''}`)
45
+ head.push(`Date: ${when(c.authored_at)}`)
46
+ if (c.predecessor) head.push(`Rewrites: ${c.predecessor.slice(0, 12)}`)
47
+ if (c.conflicted) head.push(`Conflicted paths in this commit: ${c.conflicted}`)
48
+ head.push('', ...String(c.message || '').split('\n').map((l) => ` ${l}`), '')
49
+ return head.join('\n') + (r.diff ? '\n' + describeDiff(r.diff, { format }) : '')
50
+ }
51
+
52
+ export function describeBlame (r) {
53
+ const w = Math.max(...r.lines.map((l) => (l.author || '').length), 4)
54
+ return r.lines.map((l) => `${(l.short || '?').slice(0, 10)} ${l.patch ? `${l.patch.line}:${l.patch.patch_no}`.padEnd(9) : (l.square || '').padEnd(9)} (${(l.author || '?').padEnd(w)} ${when(l.at).slice(0, 10)} ${String(l.line).padStart(4)}) ${l.text}`).join('\n')
55
+ }
56
+
57
+ /** A square with 06's status fields: what merged under it and what would conflict on merge. */
58
+ export function describeSquareStatus (s, { prefix = '' } = {}) {
59
+ const lines = [prefix + describeSquare(s)]
60
+ if (s.folded_into) lines.push(`Folded into ${s.folded_into}.`)
61
+ const under = s.merged_under_you || []
62
+ if (under.length) {
63
+ lines.push(`Merged under you (patches since your base that changed your files)${s.merged_under_you_more ? ', newest 50' : ''}:`)
64
+ for (const p of under) lines.push(` patch ${p.patch_no} by ${p.author}: ${p.message} (${p.paths.join(', ')})`)
65
+ }
66
+ const wc = s.would_conflict || []
67
+ if (wc.length) lines.push(`Would conflict if merged now: ${wc.map((w) => `${w.path}${w.patch_no ? ` (with patch ${w.patch_no} by ${w.author})` : ''}`).join(', ')}. Rebase (seam rebase) to settle it in your square first.`)
68
+ else if (under.length) lines.push('Would merge cleanly now.')
69
+ return lines.join('\n')
70
+ }
71
+
72
+ export function describeStatus (st) {
73
+ const lines = []
74
+ if (st.square) lines.push(describeSquareStatus(st.square, { prefix: 'On square ' }))
75
+ else lines.push(`A read-only checkout of ${st.at ? st.at.slice(0, 12) : 'an empty line'}`)
76
+ lines.push(`Block: ${st.paths.map((p) => p || '/').join(', ')} at ${st.at ? st.at.slice(0, 12) : '(empty)'}`)
77
+ if (st.stale) lines.push('The square moved on since this folder last looked: run seam checkout ' + st.square.short + ' after committing.')
78
+ if (st.conflicted.length) lines.push(`Conflicted (edit and commit without the markers, or seam resolve --ours|--theirs): ${st.conflicted.join(', ')}`)
79
+ if (!st.changes.length) lines.push('Nothing changed since the last commit.')
80
+ else lines.push('Changes not yet committed:', ...st.changes.map((c) => ` ${c.status.padEnd(9)} ${c.path}`))
81
+ return lines.join('\n')
82
+ }
83
+
84
+ export function describeWrite (r, verb = 'Done') {
85
+ const c = r.result
86
+ const bits = [`${verb}${c && c.id ? ` ${c.short}` : ''}${c && c.message ? ` "${first(c.message)}"` : ''}`]
87
+ if (r.square) bits.push(`square ${describeSquare(r.square)}`)
88
+ if (r.conflicted) bits.push(`${r.conflicted} conflicted path${r.conflicted === 1 ? '' : 's'}: ${(r.terms || []).map((t) => `${t.path} (${t.kind})`).join(', ')}`)
89
+ return bits.join('\n')
90
+ }
91
+
92
+ export function describeMerge (r) {
93
+ if (r.dry_run) return r.ok ? `Would merge as patch ${r.would_merge.line}:${r.would_merge.patch_no}${r.would_merge.fast ? ' (no file touched on both sides)' : ''}.` : `Would be refused: ${r.refused.detail}.`
94
+ if (r.ok) {
95
+ 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
+ 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(', ')}.`)
98
+ return lines.join('\n')
99
+ }
100
+ const lines = [`Not merged: ${r.refused ? r.refused.detail : r.error}.`]
101
+ if (r.terms && r.terms.length) {
102
+ lines.push('Conflicts, now kept in the square:')
103
+ for (const t of r.terms) lines.push(` ${t.path} (${t.kind})${t.sides ? `: ${t.sides.map((s) => s.by).join(' vs ')}` : ''}${t.suggestion ? ' (a word-level merge is offered: seam resolve --suggestion, or choose suggestion)' : ''}`)
104
+ lines.push('Edit those files and commit them without the markers (or seam resolve <path> --ours|--theirs), then merge again.')
105
+ }
106
+ return lines.join('\n')
107
+ }
108
+
109
+ /** Events as one line each (12's inbox line: kind, who, square, paths, patch, would_conflict). */
110
+ export function describeEvents (r) {
111
+ if (!r.events.length) return `No new events (cursor ${r.after}).`
112
+ const line = (e) => {
113
+ const p = e.payload || {}
114
+ if (e.kind === 'patch.merged') return `#${e.event_seq} patch.merged: patch ${e.patch_no} by ${e.by.member_id} "${first(p.message || '')}" changed ${e.paths.join(', ')} in your square ${e.square_short}${p.would_conflict ? `; would conflict in ${p.would_conflict_paths.join(', ')}` : '; merges cleanly'}`
115
+ if (e.kind === 'conflict.added') return `#${e.event_seq} conflict.added: ${p.path} (${p.kind}) in ${e.square_short}${p.caused_by && p.caused_by.patch_no ? `, against patch ${p.caused_by.patch_no}` : ''}; settle it and merge again`
116
+ if (e.kind === 'square.handoff') return `#${e.event_seq} square.handoff: ${e.square_short} from ${p.from} to ${p.to}${p.note ? `: ${p.note}` : ''}`
117
+ return `#${e.event_seq} ${e.kind}`
118
+ }
119
+ return [...r.events.map(line), `(next: after=${r.after})`].join('\n')
120
+ }
@@ -0,0 +1,126 @@
1
+ // The local driver of the one-way git export (plans/quilt-source-control/14, Export): reads a
2
+ // line's patches from the server (GET /quilts/:q/export/git) and writes each as one commit on a
3
+ // branch of a plain git repository with `git fast-import`, so CI and deploys that expect git
4
+ // keep working. The commit fields come from the server whole, so the same patch always gives
5
+ // the same commit id, on any machine, in any number of runs. A run picks up after the last
6
+ // patch it exported (state in the repository's `quilt-export.json`), and refuses a branch that
7
+ // moved under it: the export is one way, never a merge.
8
+ import { spawn, execFileSync } from 'node:child_process'
9
+ import fs from 'node:fs'
10
+ import path from 'node:path'
11
+
12
+ const MODES = { file: '100644', exec: '100755', symlink: '120000' }
13
+ const GIT_ENV = { ...process.env, GIT_NO_REPLACE_OBJECTS: '1', GIT_CONFIG_NOSYSTEM: '1' }
14
+
15
+ const git = (dir, args) => execFileSync('git', ['-C', dir, ...args], { env: GIT_ENV, maxBuffer: 256 * 1024 * 1024 }).toString().trim()
16
+ const tryGit = (dir, args) => { try { return git(dir, args) } catch { return null } }
17
+
18
+ /** A path as fast-import reads it: C-quoted when it would otherwise be misread. */
19
+ const fiPath = (p) => (/^"|[\n"\\]/.test(p) ? '"' + p.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n') + '"' : p)
20
+ const ident = (who) => `${who.name} <${who.email}> ${who.at} +0000`
21
+
22
+ /** The repository at `dir`, made bare when it does not exist yet: its git dir. */
23
+ function openRepo (dir) {
24
+ if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); git(dir, ['init', '-q', '--bare']) }
25
+ const gd = tryGit(dir, ['rev-parse', '--absolute-git-dir'])
26
+ if (!gd) throw new Error(`${dir} is not a git repository`)
27
+ return gd
28
+ }
29
+
30
+ /** fast-import as a writable stream with backpressure; done() → its exit. */
31
+ function fastImport (dir, marks) {
32
+ const p = spawn('git', ['-C', dir, 'fast-import', '--quiet', '--date-format=raw', `--export-marks=${marks}`], { env: GIT_ENV, stdio: ['pipe', 'ignore', 'pipe'] })
33
+ let err = ''
34
+ p.stderr.on('data', (d) => { err += d })
35
+ const exit = new Promise((resolve, reject) => p.on('close', (code) => (code === 0 ? resolve() : reject(new Error(`git fast-import failed: ${err.trim()}`)))))
36
+ exit.catch(() => {})
37
+ return {
38
+ write (chunk) { return p.stdin.write(chunk) ? null : new Promise((resolve) => p.stdin.once('drain', resolve)) },
39
+ async done () { p.stdin.end(); await exit }
40
+ }
41
+ }
42
+
43
+ /** Every blob in `ids`, as Buffers, in as many requests as the server's size cap needs. */
44
+ async function fetchBlobs (client, q, ids) {
45
+ const out = new Map()
46
+ let todo = [...new Set(ids)]
47
+ while (todo.length) {
48
+ const r = await client.blobs(q, todo.slice(0, 2000))
49
+ const got = Object.entries(r.blobs || {})
50
+ if (!got.length) throw new Error(`the server returned none of ${todo.length} blobs`)
51
+ for (const [id, b64] of got) out.set(id, Buffer.from(b64, 'base64'))
52
+ todo = todo.filter((id) => !out.has(id))
53
+ }
54
+ return out
55
+ }
56
+
57
+ /**
58
+ * Exports `line` of quilt `q` to `branch` of the repository at `dir` (created bare when
59
+ * missing). Returns { exported, patch_no, sha, branch }.
60
+ */
61
+ export async function exportGit (client, { q, dir, line = 'main', branch, page = 200, log = () => {} }) {
62
+ branch = branch || line
63
+ const gitDir = openRepo(dir)
64
+ const stateFile = path.join(gitDir, 'quilt-export.json')
65
+ const shaFile = path.join(gitDir, 'quilt-export-shas')
66
+ const all = fs.existsSync(stateFile) ? JSON.parse(fs.readFileSync(stateFile, 'utf8')) : {}
67
+ const key = `${line}→${branch}`
68
+ const state = all[key] || null
69
+ const tip = tryGit(dir, ['rev-parse', '--verify', '-q', `refs/heads/${branch}`])
70
+ if (state && state.quilt !== undefined && state.quilt !== null) {
71
+ const info = await client.quilt(q)
72
+ if (info.id !== state.quilt) throw new Error(`${dir} holds the export of another quilt (${state.quilt})`)
73
+ }
74
+ if (state ? tip !== state.sha : tip) throw new Error(`branch ${branch} in ${dir} moved since the last export (${tip || 'gone'}, expected ${state ? state.sha : 'no branch'}): the export never merges or forces; point it at a new branch or repository`)
75
+
76
+ let after = state ? state.patch_no : -1
77
+ let sha = state ? state.sha : null
78
+ let exported = 0
79
+ let version = state ? state.version : null
80
+ let quiltId = state ? state.quilt : null
81
+ for (;;) {
82
+ const r = await client.exportPatches(q, { line, after, limit: page })
83
+ if (version !== null && version !== r.version) throw new Error(`export format ${r.version} differs from the ${version} this branch was exported with: export to a new branch`)
84
+ version = r.version; quiltId = r.quilt
85
+ if (!r.patches.length) break
86
+ const blobs = await fetchBlobs(client, q, r.patches.flatMap((p) => p.changes.filter((c) => c.op === 'put').map((c) => c.blob)))
87
+ const marksFile = path.join(gitDir, 'quilt-export.marks')
88
+ const fi = fastImport(dir, marksFile)
89
+ const send = async (s) => { const w = fi.write(s); if (w) await w }
90
+ let mark = 0
91
+ const blobMark = new Map()
92
+ for (const [id, bytes] of blobs) {
93
+ blobMark.set(id, ++mark)
94
+ await send(`blob\nmark :${mark}\ndata ${bytes.length}\n`); await send(bytes); await send('\n')
95
+ }
96
+ const commitMark = new Map()
97
+ let first = true
98
+ for (const p of r.patches) {
99
+ commitMark.set(p.patch_no, ++mark)
100
+ const msg = Buffer.from(p.message, 'utf8')
101
+ let s = `commit refs/heads/${branch}\nmark :${mark}\nauthor ${ident(p.author)}\ncommitter ${ident(p.committer)}\ndata ${msg.length}\n`
102
+ await send(s); await send(msg); s = '\n'
103
+ if (first && sha) s += `from ${sha}\n`
104
+ first = false
105
+ for (const c of p.changes) {
106
+ if (c.op === 'delete') s += `D ${fiPath(c.path)}\n`
107
+ else s += `M ${MODES[c.mode || 'file'] || MODES.file} :${blobMark.get(c.blob)} ${fiPath(c.path)}\n`
108
+ }
109
+ await send(s + '\n')
110
+ }
111
+ await fi.done()
112
+ const marks = new Map(fs.readFileSync(marksFile, 'utf8').split('\n').filter(Boolean).map((l) => { const [m, h] = l.split(' '); return [Number(m.slice(1)), h] }))
113
+ fs.rmSync(marksFile, { force: true })
114
+ const lines = r.patches.map((p) => `${line} ${p.patch_no} ${marks.get(commitMark.get(p.patch_no))}`)
115
+ fs.appendFileSync(shaFile, lines.join('\n') + '\n')
116
+ const last = r.patches[r.patches.length - 1]
117
+ after = last.patch_no
118
+ sha = marks.get(commitMark.get(last.patch_no))
119
+ exported += r.patches.length
120
+ all[key] = { quilt: quiltId, line, branch, patch_no: after, sha, version }
121
+ fs.writeFileSync(stateFile, JSON.stringify(all, null, 2) + '\n')
122
+ log(` ${line}/${after} → ${sha.slice(0, 12)} (${exported} exported)`)
123
+ }
124
+ if (sha && !tryGit(dir, ['symbolic-ref', '-q', 'HEAD'])?.endsWith(`/${branch}`) && !tryGit(dir, ['rev-parse', '--verify', '-q', 'HEAD'])) git(dir, ['symbolic-ref', 'HEAD', `refs/heads/${branch}`])
125
+ return { exported, patch_no: after, sha, branch }
126
+ }
@@ -0,0 +1,44 @@
1
+ // Ids for Quilt source control (plans/quilt-source-control/03): BLAKE3-256 with a
2
+ // domain-separation context per kind of object, so bytes of one kind can never be presented
3
+ // as another. Ids travel as 64 hex characters.
4
+ import { blake3 } from '@noble/hashes/blake3.js'
5
+
6
+ const enc = new TextEncoder()
7
+ const contexts = new Map()
8
+
9
+ /** The context bytes for a kind and format version, e.g. 'commit' → "quilt commit v1". */
10
+ function context (kind) {
11
+ let c = contexts.get(kind)
12
+ if (!c) { c = enc.encode(`quilt ${kind} v1`); contexts.set(kind, c) }
13
+ return c
14
+ }
15
+
16
+ export const KINDS = ['commit', 'blob', 'tree', 'chunk', 'pack', 'term', 'manifest']
17
+
18
+ /** The id of `bytes` as an object of `kind`: BLAKE3 derive-key mode, 32 bytes, as hex. */
19
+ // Not object kinds: keys made with their own contexts so they can never equal an object id.
20
+ const KEY_KINDS = ['idempotency']
21
+
22
+ /** A key (not an object id) in its own context, e.g. 'idempotency' → "quilt idempotency v1". */
23
+ export function keyOf (kind, bytes) {
24
+ if (!KEY_KINDS.includes(kind)) throw new Error(`unknown key kind ${kind}`)
25
+ return Buffer.from(blake3(bytes, { context: context(kind) })).toString('hex')
26
+ }
27
+
28
+ export function idOf (kind, bytes) {
29
+ if (!KINDS.includes(kind)) throw new Error(`unknown object kind ${kind}`)
30
+ return Buffer.from(blake3(bytes, { context: context(kind) })).toString('hex')
31
+ }
32
+
33
+ export const ID = /^[0-9a-f]{64}$/
34
+
35
+ /** Whether `s` looks like an id (64 lowercase hex). */
36
+ export const isId = (s) => typeof s === 'string' && ID.test(s)
37
+
38
+ /** An id as its 32 bytes. */
39
+ export const idBytes = (hex) => Buffer.from(hex, 'hex')
40
+
41
+ /** A quilt's shard (03): blake3 of its id's 16 bytes (plain hash mode), first byte mod 64. */
42
+ export function shardOf (quiltId) {
43
+ return blake3(Buffer.from(String(quiltId).replace(/-/g, ''), 'hex'))[0] % 64
44
+ }