heyquilt 0.3.122 → 0.3.140

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 (166) hide show
  1. package/RELEASES.md +279 -17
  2. package/bin/quilt.js +62 -152
  3. package/desktop/main.js +16 -2
  4. package/package.json +3 -2
  5. package/src/account.js +0 -29
  6. package/src/agent-task-workflow.js +2 -2
  7. package/src/agents/common.js +13 -0
  8. package/src/ai-vm.js +9 -1
  9. package/src/api/activity.js +5 -0
  10. package/src/api/memory-store.js +16 -38
  11. package/src/api/notify.js +134 -0
  12. package/src/api/routes/browsers.js +29 -0
  13. package/src/api/routes/relay.js +26 -3
  14. package/src/api/routes/session-migrate.js +7 -4
  15. package/src/api/routes/sessions.js +73 -1
  16. package/src/api/routes/teams.js +0 -23
  17. package/src/api/server.js +17 -25
  18. package/src/api/supabase-store.js +43 -56
  19. package/src/autowork.js +182 -16
  20. package/src/branch-view.js +6 -131
  21. package/src/connection.js +36 -9
  22. package/src/control.js +131 -48
  23. package/src/duties.js +1 -1
  24. package/src/force-date.js +27 -0
  25. package/src/fsutil.js +13 -3
  26. package/src/github-commit.js +3 -315
  27. package/src/history.js +13 -3
  28. package/src/mcp.js +93 -167
  29. package/src/merge3.js +11 -3
  30. package/src/now-summary.js +113 -0
  31. package/src/pathrules.js +12 -0
  32. package/src/protocol.js +10 -2
  33. package/src/quilt-default.js +84 -0
  34. package/src/quilt-history.js +106 -0
  35. package/src/quilt-remote.js +78 -0
  36. package/src/relay-ask.js +3 -66
  37. package/src/relay-cluster.js +3 -5
  38. package/src/relay-lease.js +6 -2
  39. package/src/relay-mcp.js +154 -432
  40. package/src/relay-prune.js +6 -145
  41. package/src/relay-turn-sync.js +1 -1
  42. package/src/release-words.js +2 -2
  43. package/src/runner.js +20 -7
  44. package/src/seam-access.js +97 -0
  45. package/src/seam-claims.js +53 -0
  46. package/src/seam-url.js +15 -0
  47. package/src/server.js +1516 -1383
  48. package/src/session-follow.js +461 -27
  49. package/src/session-layout3.js +410 -4
  50. package/src/session-migrate.js +15 -11
  51. package/src/session-quilt.js +1 -1
  52. package/src/session-seam.js +233 -20
  53. package/src/session.js +745 -3438
  54. package/src/source-live/bring-in.js +283 -0
  55. package/src/source-live/live-block.js +504 -33
  56. package/src/source-live/live-square.js +16 -5
  57. package/src/status.js +7 -58
  58. package/src/turn-sync.js +15 -2
  59. package/src/ui/agent-guide.js +1 -1
  60. package/src/ui/app.css +85 -20
  61. package/src/ui/app.js +4 -1
  62. package/src/ui/changes.js +5 -14
  63. package/src/ui/chat.js +9 -4
  64. package/src/ui/cloud-ai-model.js +13 -0
  65. package/src/ui/cloud-ai.js +10 -2
  66. package/src/ui/common.js +6 -4
  67. package/src/ui/fileview.js +20 -7
  68. package/src/ui/force-words.js +19 -0
  69. package/src/ui/git-rule.js +9 -28
  70. package/src/ui/home.js +3 -223
  71. package/src/ui/loom-model.js +15 -1
  72. package/src/ui/quiltview.js +213 -0
  73. package/src/ui/seam-model.js +251 -0
  74. package/src/ui/session.js +377 -489
  75. package/src/ui/tree.js +2 -8
  76. package/src/ui/voice-call.js +83 -14
  77. package/src/ui/voice-model.js +39 -0
  78. package/src/ui/voice.js +167 -17
  79. package/src/ui/workspaces.js +2 -122
  80. package/src/ui-server.js +81 -66
  81. package/src/update-check.js +25 -1
  82. package/src/voice.js +13 -6
  83. package/src/api/routes/github.js +0 -393
  84. package/src/branches.js +0 -194
  85. package/src/clash.js +0 -128
  86. package/src/commit-request.js +0 -59
  87. package/src/commit.js +0 -213
  88. package/src/git-hook.js +0 -48
  89. package/src/git.js +0 -147
  90. package/src/gitignore.js +0 -49
  91. package/src/gitstate.js +0 -1033
  92. package/src/quilts/AGENTS.md +0 -1
  93. package/src/quilts/CLAUDE.md +0 -1
  94. package/src/quilts/accounts.js +0 -136
  95. package/src/quilts/affected.js +0 -132
  96. package/src/quilts/archiver.js +0 -175
  97. package/src/quilts/autorevert.js +0 -49
  98. package/src/quilts/bisect-run.js +0 -31
  99. package/src/quilts/blame.js +0 -103
  100. package/src/quilts/block.js +0 -347
  101. package/src/quilts/bucket.js +0 -25
  102. package/src/quilts/cbor.js +0 -92
  103. package/src/quilts/checks.js +0 -308
  104. package/src/quilts/claims.js +0 -144
  105. package/src/quilts/cli.js +0 -536
  106. package/src/quilts/client.js +0 -137
  107. package/src/quilts/conflict-format.js +0 -190
  108. package/src/quilts/describe.js +0 -296
  109. package/src/quilts/dogfood.js +0 -82
  110. package/src/quilts/errors.js +0 -8
  111. package/src/quilts/events.js +0 -171
  112. package/src/quilts/export-git.js +0 -130
  113. package/src/quilts/export-remote.js +0 -156
  114. package/src/quilts/export.js +0 -81
  115. package/src/quilts/fastmerge.js +0 -41
  116. package/src/quilts/hash.js +0 -44
  117. package/src/quilts/history.js +0 -266
  118. package/src/quilts/ids.js +0 -12
  119. package/src/quilts/import-follow.js +0 -236
  120. package/src/quilts/import-git.js +0 -294
  121. package/src/quilts/import.js +0 -196
  122. package/src/quilts/index.js +0 -94
  123. package/src/quilts/keys.js +0 -186
  124. package/src/quilts/limits.js +0 -67
  125. package/src/quilts/lines.js +0 -49
  126. package/src/quilts/live.js +0 -50
  127. package/src/quilts/merge-smart.js +0 -114
  128. package/src/quilts/merge.js +0 -501
  129. package/src/quilts/oauth.js +0 -202
  130. package/src/quilts/objects.js +0 -144
  131. package/src/quilts/pack.js +0 -118
  132. package/src/quilts/records.js +0 -211
  133. package/src/quilts/resolutions.js +0 -69
  134. package/src/quilts/reviews.js +0 -208
  135. package/src/quilts/routes.js +0 -186
  136. package/src/quilts/runner.js +0 -95
  137. package/src/quilts/server.js +0 -144
  138. package/src/quilts/service.js +0 -963
  139. package/src/quilts/setup.js +0 -41
  140. package/src/quilts/signing.js +0 -149
  141. package/src/quilts/squares.js +0 -239
  142. package/src/quilts/store-memory.js +0 -844
  143. package/src/quilts/store-rpc.js +0 -90
  144. package/src/quilts/store-supabase.js +0 -13
  145. package/src/quilts/tags.js +0 -64
  146. package/src/quilts/textdiff.js +0 -96
  147. package/src/quilts/tokens.js +0 -66
  148. package/src/quilts/tools.js +0 -403
  149. package/src/quilts/tree.js +0 -351
  150. package/src/quilts/webhook.js +0 -116
  151. package/src/quilts/window.js +0 -121
  152. package/src/quilts/xdiff.js +0 -435
  153. package/src/relay-ci.js +0 -111
  154. package/src/relay-clash.js +0 -214
  155. package/src/relay-commit.js +0 -918
  156. package/src/relay-filekey.js +0 -46
  157. package/src/relay-git.js +0 -274
  158. package/src/relay-githttp.js +0 -166
  159. package/src/relay-github.js +0 -109
  160. package/src/relay-outbound.js +0 -1295
  161. package/src/relay-repo-moves.js +0 -43
  162. package/src/relay-sync.js +0 -795
  163. package/src/relay-upstream.js +0 -889
  164. package/src/sync-words.js +0 -42
  165. package/src/ui/branches.js +0 -111
  166. package/src/upstream.js +0 -192
@@ -1,82 +0,0 @@
1
- // The dogfood round trip (week 4, item 2): a git repository's full history into a new quilt on
2
- // whatever API the client points at, every imported commit's tree checked against git, then
3
- // `main` exported back to git and checked: one exported commit per first-parent commit, each
4
- // with git's tree and its `Quilt-Git-Sha` trailer naming that commit, and a second export from
5
- // scratch giving the same tip (the export is deterministic).
6
- import fs from 'node:fs'
7
- import os from 'node:os'
8
- import path from 'node:path'
9
- import { execFileSync } from 'node:child_process'
10
- import { importGit } from './import-git.js'
11
- import { exportGit } from './export-git.js'
12
-
13
- const git = (dir, args) => execFileSync('git', ['-C', dir, ...args], { maxBuffer: 1 << 30, env: { ...process.env, GIT_NO_REPLACE_OBJECTS: '1' } }).toString()
14
-
15
- /** `git rev-list` with one format line per commit: [[sha, field], ...]. */
16
- function revs (dir, args, format) {
17
- const out = git(dir, ['rev-list', `--format=${format}`, ...args]).split('\n')
18
- const rows = []
19
- for (let i = 0; i < out.length; i++) {
20
- if (!out[i].startsWith('commit ')) continue
21
- rows.push([out[i].slice(7), out[i + 1] || ''])
22
- }
23
- return rows
24
- }
25
-
26
- /**
27
- * Runs the round trip. `repo` is any git repository on this machine, `rev` the commit whose
28
- * history is imported (HEAD by default), `slug` the new quilt. Returns a report; throws on the
29
- * first mismatch.
30
- */
31
- export async function dogfood (client, { repo, rev = 'HEAD', slug, work, keep = false, batch = 200, log = () => {} }) {
32
- const sha = git(repo, ['rev-parse', '--verify', `${rev}^{commit}`]).trim()
33
- const dir = work || fs.mkdtempSync(path.join(os.tmpdir(), 'quilt-dogfood-'))
34
- const src = path.join(dir, 'src')
35
- const times = {}
36
- try {
37
- // A bare-ish copy whose HEAD is `rev`, so the import walks exactly that history.
38
- git(os.tmpdir(), ['clone', '-q', '--shared', '--no-checkout', repo, src])
39
- git(src, ['update-ref', '--no-deref', 'HEAD', sha])
40
-
41
- let t = Date.now()
42
- log(`Importing ${sha.slice(0, 12)} of ${repo} into ${slug}…`)
43
- const imp = await importGit(client, { repo: src, slug, batch, validate: 'all', log, onlyHead: true, fresh: true })
44
- times.import_ms = Date.now() - t
45
- log(`Imported ${imp.commits} commits (${imp.patches} patches); every commit's tree equals git's (${imp.validated}).`)
46
-
47
- t = Date.now()
48
- const out = path.join(dir, 'export.git')
49
- const exp = await exportGit(client, { q: imp.quilt, dir: out, log })
50
- times.export_ms = Date.now() - t
51
-
52
- const want = revs(src, ['--first-parent', '--reverse', sha], '%T')
53
- const got = revs(out, ['--reverse', 'refs/heads/main'], '%T')
54
- if (got.length !== want.length) throw new Error(`export: main has ${got.length} commits, git's first-parent history ${want.length}`)
55
- const trailers = revs(out, ['--reverse', 'refs/heads/main'], '%(trailers:key=Quilt-Git-Sha,valueonly,separator=)')
56
- for (let i = 0; i < want.length; i++) {
57
- if (got[i][1] !== want[i][1]) throw new Error(`export: commit ${i + 1} (git ${want[i][0].slice(0, 12)}) has tree ${got[i][1].slice(0, 12)}, git's is ${want[i][1].slice(0, 12)}`)
58
- if (trailers[i][1].trim() !== want[i][0]) throw new Error(`export: commit ${i + 1} names git ${trailers[i][1].trim() || 'nothing'} in Quilt-Git-Sha, expected ${want[i][0]}`)
59
- }
60
- log(`Exported ${exp.exported} patches; every tree and Quilt-Git-Sha matches git's first-parent history.`)
61
-
62
- const again = await exportGit(client, { q: imp.quilt, dir: path.join(dir, 'again.git'), page: 97 })
63
- if (again.sha !== exp.sha) throw new Error(`export is not deterministic: ${exp.sha} then ${again.sha}`)
64
- log(`Exported again from scratch: same tip ${exp.sha.slice(0, 12)}.`)
65
-
66
- return {
67
- quilt: imp.quilt,
68
- slug: imp.slug,
69
- rev: sha,
70
- commits: imp.commits,
71
- patches: imp.patches,
72
- validated: imp.validated,
73
- exported: exp.exported,
74
- export_sha: exp.sha,
75
- left_out: imp.report,
76
- ...times,
77
- ...(keep ? { work: dir } : {})
78
- }
79
- } finally {
80
- if (!keep && !work) fs.rmSync(dir, { recursive: true, force: true })
81
- }
82
- }
@@ -1,8 +0,0 @@
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,171 +0,0 @@
1
- // Events (plans/quilt-source-control/12): never written in the commit or merge transaction. The
2
- // events job reads the quilt's log after its cursor, derives each event and its recipients, and
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 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.
6
- import { QuiltError, squareShort } from './service.js'
7
- import { makeSubscription, deliverWebhook, RETRY_DELAYS_MS } from './webhook.js'
8
-
9
- export const QUILT_EVENT_KINDS = ['patch.merged', 'patch.reverted', 'conflict.added', 'square.handoff', 'check.requested', 'check.cancelled']
10
- const MAX_DIFF_PATHS = 200
11
-
12
- export function eventOps (svc, sq, { allowLocalWebhooks = false, fetch, delays = RETRY_DELAYS_MS, log = () => {} } = {}) {
13
- const { store, now, quiltFor, getCommit, getTerm, conflictsAt, snapshot, reader } = svc.internals
14
- const running = new Map() // quilt → the job in flight, so one process runs one job per quilt at a time
15
- const sending = new Map() // quilt + member → delivery chain, so a member's POSTs keep their order
16
-
17
- const by = (c) => ({ member_id: c.author.member_id, kind: c.author.kind, name: c.author.name, ...(c.author.tool ? { tool: c.author.tool } : {}) })
18
-
19
- async function changedPaths (qid, s) {
20
- if (!s.head) return []
21
- return (await reader(qid).diff(await snapshot(qid, s.base), await snapshot(qid, s.head))).map((e) => e.path)
22
- }
23
-
24
- /** The events one log row causes. */
25
- async function derive (qid, row) {
26
- const out = []
27
- // check.requested (13, checks.js): to the runners a line's policy names.
28
- if (svc.internals.checkRequests) out.push(...await svc.internals.checkRequests(qid, row))
29
- if (row.kind === 'merge' && row.payload?.reverts) {
30
- // patch.reverted: the reverted patch's holder (and whom they acted for) learns it is gone (07 step 4).
31
- const c = await getCommit(qid, row.commit)
32
- const gone = await store.patch(qid, row.line, Number(row.payload.reverts))
33
- const orig = gone && gone.square ? await store.getSquare(gone.square) : null
34
- const oc = gone ? await getCommit(qid, gone.commit).catch(() => null) : null
35
- const to = [orig && !orig.import ? orig.holder : null, oc && oc.on_behalf_of].filter(Boolean)
36
- if (to.length) {
37
- out.push({
38
- 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),
39
- payload: { patch_no: Number(row.payload.reverts), revert_patch_no: row.patch_no, commit: c.id, message: c.message },
40
- recipients: [...new Set(to)]
41
- })
42
- }
43
- }
44
- if (row.kind === 'merge') {
45
- // patch.merged: to the holder and writers of every other open square on the line that changed a path the patch changed.
46
- const c = await getCommit(qid, row.commit)
47
- const touched = new Set(c.changes.map((ch) => ch.path))
48
- for (const s of await store.squares(qid, { state: 'open' })) {
49
- if (s.id === row.square || s.line !== row.line) continue
50
- const overlap = (await changedPaths(qid, s)).filter((p) => touched.has(p))
51
- if (!overlap.length) continue
52
- const st = await sq.statusOf(qid, s)
53
- const wc = (st.would_conflict || []).map((w) => w.path)
54
- out.push({
55
- kind: 'patch.merged', at: row.at, cause_seq: row.seq, square: s.id, line: row.line, patch_no: row.patch_no, by: by(c), paths: overlap,
56
- ...(c.task_id ? { task: { id: c.task_id } } : {}),
57
- payload: { patch_no: row.patch_no, commit: c.id, message: c.message, author: c.author.member_id, ...(c.task_id ? { task: c.task_id } : {}), stat: c.changes.slice(0, MAX_DIFF_PATHS).map((ch) => ({ path: ch.path, op: ch.op })), would_conflict: wc.length > 0, would_conflict_paths: wc },
58
- recipients: [s.holder, ...(s.writers || [])]
59
- })
60
- }
61
- } else if (row.kind === 'commit') {
62
- // conflict.added: a term at a path that did not hold that term before this commit.
63
- const c = await getCommit(qid, row.commit)
64
- const terms = c.changes.filter((ch) => ch.op === 'conflict')
65
- if (!terms.length) return out
66
- const prev = c.predecessor || c.parents[0]
67
- const before = new Map(prev ? (await conflictsAt(qid, prev)).map((e) => [e.path, e.term]) : [])
68
- const s = await store.getSquare(row.square)
69
- const cause = row.payload?.rebased_onto ? { commit: row.payload.rebased_onto, ...((await getCommit(qid, row.payload.rebased_onto).catch(() => null))?.patch || {}) } : row.payload?.merged_from ? { commit: row.payload.merged_from } : row.payload?.folded_from ? { square: row.payload.folded_from } : null
70
- for (const ch of terms) {
71
- if (before.get(ch.path) === ch.term) continue
72
- const t = await getTerm(qid, ch.term)
73
- out.push({
74
- kind: 'conflict.added', at: row.at, cause_seq: row.seq, square: row.square, line: s && s.line, by: by(c), paths: [ch.path],
75
- payload: { path: ch.path, kind: t.kind, term: ch.term, commit: c.id, sides: t.adds.map((a) => ({ by: a.by || null, commit: a.from_commit || null, ...(a.from_square ? { square: squareShort(a.from_square) } : {}) })), ...(cause ? { caused_by: cause } : {}), resolver: s ? s.holder : null },
76
- recipients: s ? [s.holder] : []
77
- })
78
- }
79
- } else if (row.kind === 'square' && row.payload?.op === 'handoff') {
80
- out.push({
81
- kind: 'square.handoff', at: row.at, cause_seq: row.seq, square: row.square, by: { member_id: row.actor },
82
- payload: { from: row.payload.from, to: row.payload.to, ...(row.payload.context ? { note: row.payload.context } : {}) },
83
- recipients: [row.payload.from, row.payload.to]
84
- })
85
- }
86
- return out
87
- }
88
-
89
- /** Runs the events job for a quilt until it has read the whole log; returns the new events. */
90
- function pump (qid) {
91
- const prev = running.get(qid) || Promise.resolve()
92
- const job = prev.catch(() => {}).then(async () => {
93
- const made = []
94
- for (;;) {
95
- const cursor = await store.eventCursor(qid)
96
- const rows = await store.logRows(qid, { after: cursor, limit: 200 })
97
- if (!rows.length) return made
98
- let events = []
99
- for (const r of rows) events.push(...await derive(qid, r))
100
- // A new check request cancels what it supersedes (checks.js settleRuns).
101
- let cancels = []
102
- if (svc.internals.settleRuns) ({ events, cancels } = await svc.internals.settleRuns(qid, events))
103
- // A check.requested event's run record is stored first (idempotent on run_key), so a
104
- // runner polling GET /checks finds every run it is woken for.
105
- const runs = events.filter((e) => e.run).map((e) => e.run)
106
- if (runs.length) await store.addCheckRuns({ quilt_id: qid, runs })
107
- for (const e of events) delete e.run
108
- try {
109
- made.push(...await store.addEvents({ quilt_id: qid, events, expect_cursor: cursor, cursor: rows.at(-1).seq }))
110
- if (cancels.length) await svc.internals.applyCancels(qid, cancels)
111
- } catch (err) { if (err.code !== 'cursor_moved') throw err }
112
- }
113
- })
114
- running.set(qid, job)
115
- job.finally(() => { if (running.get(qid) === job) running.delete(qid) }).catch(() => {})
116
- // Deliveries run beside the job (a dead webhook's retries never hold up reading events).
117
- return job.then((made) => { const d = deliver(qid, made); inflight.add(d); d.finally(() => inflight.delete(d)); return made })
118
- }
119
- const inflight = new Set()
120
- /** Waits for every webhook POST in flight (tests, and a clean shutdown). */
121
- const eventsSettled = async () => { while (inflight.size) await Promise.all([...inflight]) }
122
-
123
- /** The event as a member sees it (12's shape; recipients are not shown). */
124
- const view = (e) => {
125
- const { recipients, quilt_id: q, ...rest } = e
126
- void recipients
127
- return { ...rest, quilt: q, ...(e.square ? { square_short: squareShort(e.square) } : {}) }
128
- }
129
-
130
- /** POSTs new events to every recipient that subscribed (one chain per member, in order). */
131
- async function deliver (qid, events) {
132
- const waits = []
133
- for (const e of events) {
134
- for (const m of new Set(e.recipients || [])) {
135
- const sub = await store.webhook(qid, m)
136
- if (!sub || !sub.events.includes(e.kind)) continue
137
- const key = `${qid}\u0000${m}`
138
- const payload = { event: e.kind, id: `${qid}:${e.event_seq}`, to: m, ts: e.at || now(), ...view(e) }
139
- const next = (sending.get(key) || Promise.resolve()).then(() => deliverWebhook(sub, payload, { delays, log, ...(fetch ? { fetch } : {}) })).catch(() => {})
140
- sending.set(key, next)
141
- waits.push(next)
142
- }
143
- }
144
- await Promise.all(waits)
145
- }
146
-
147
- /** GET /events: the caller's events after `after` (the job runs first, so none is missing). */
148
- async function events (me, ref, { after = 0, kinds, limit = 100 } = {}) {
149
- const q = await quiltFor(me, ref)
150
- await pump(q.id)
151
- const list = await store.eventsFor(q.id, me.member_id, { after: Number(after) || 0, kinds: kinds && kinds.length ? kinds : undefined, limit: Math.min(Number(limit) || 100, 1000) })
152
- return { events: list.map(view), after: list.length ? list.at(-1).event_seq : Number(after) || 0 }
153
- }
154
-
155
- /** POST /webhook: { url, secret?, events?, bearer? } subscribes the caller; { url: null } removes it. */
156
- async function subscribe (me, ref, body = {}) {
157
- const q = await quiltFor(me, ref)
158
- if (body.url === null || body.off) { await store.setWebhook({ quilt_id: q.id, member_id: me.member_id, sub: null }); return { ok: true, removed: true } }
159
- const kinds = body.events == null ? QUILT_EVENT_KINDS.slice() : body.events
160
- if (!Array.isArray(kinds) || !kinds.length || kinds.some((k) => !QUILT_EVENT_KINDS.includes(k))) throw new QuiltError(400, 'bad_request', `events: give some of ${QUILT_EVENT_KINDS.join(', ')}`)
161
- let sub
162
- try { sub = makeSubscription({ url: body.url, secret: body.secret, bearer: body.bearer }, { allowLocal: allowLocalWebhooks }) } catch (err) { throw new QuiltError(400, 'bad_request', err.message) }
163
- const row = { url: sub.url, secret: sub.secret, events: [...new Set(kinds)], since: sub.since, ...(sub.bearer ? { bearer: sub.bearer } : {}) }
164
- // Events already in the table are not news to a new subscription: the job runs first.
165
- await pump(q.id)
166
- await store.setWebhook({ quilt_id: q.id, member_id: me.member_id, sub: row })
167
- return { ok: true, url: row.url, events: row.events, ...(sub.made ? { secret: row.secret } : {}) }
168
- }
169
-
170
- return { events, subscribe, pumpEvents: pump, eventsSettled }
171
- }
@@ -1,130 +0,0 @@
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', gitlink: '160000' }
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
- // A gitlink's content is the submodule commit it pins; git writes no blob for it.
94
- if (r.patches.some((p) => p.changes.some((c) => c.blob === id && c.mode === 'gitlink'))) blobMark.set(id, bytes.toString('utf8').trim())
95
- if (blobMark.has(id)) continue
96
- blobMark.set(id, ++mark)
97
- await send(`blob\nmark :${mark}\ndata ${bytes.length}\n`); await send(bytes); await send('\n')
98
- }
99
- const commitMark = new Map()
100
- let first = true
101
- for (const p of r.patches) {
102
- commitMark.set(p.patch_no, ++mark)
103
- const msg = Buffer.from(p.message, 'utf8')
104
- let s = `commit refs/heads/${branch}\nmark :${mark}\nauthor ${ident(p.author)}\ncommitter ${ident(p.committer)}\ndata ${msg.length}\n`
105
- await send(s); await send(msg); s = '\n'
106
- if (first && sha) s += `from ${sha}\n`
107
- first = false
108
- for (const c of p.changes) {
109
- if (c.op === 'delete') s += `D ${fiPath(c.path)}\n`
110
- else if (c.mode === 'gitlink') s += `M 160000 ${blobMark.get(c.blob)} ${fiPath(c.path)}\n`
111
- else s += `M ${MODES[c.mode || 'file'] || MODES.file} :${blobMark.get(c.blob)} ${fiPath(c.path)}\n`
112
- }
113
- await send(s + '\n')
114
- }
115
- await fi.done()
116
- 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] }))
117
- fs.rmSync(marksFile, { force: true })
118
- const lines = r.patches.map((p) => `${line} ${p.patch_no} ${marks.get(commitMark.get(p.patch_no))}`)
119
- fs.appendFileSync(shaFile, lines.join('\n') + '\n')
120
- const last = r.patches[r.patches.length - 1]
121
- after = last.patch_no
122
- sha = marks.get(commitMark.get(last.patch_no))
123
- exported += r.patches.length
124
- all[key] = { quilt: quiltId, line, branch, patch_no: after, sha, version }
125
- fs.writeFileSync(stateFile, JSON.stringify(all, null, 2) + '\n')
126
- log(` ${line}/${after} → ${sha.slice(0, 12)} (${exported} exported)`)
127
- }
128
- 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}`])
129
- return { exported, patch_no: after, sha, branch }
130
- }
@@ -1,156 +0,0 @@
1
- // Export to a git remote (plans/quilt-source-control/14, "Export"): `settings.export` names a
2
- // remote (any: GitHub, GitLab, a bare repository), a credential the owner gives by name, the
3
- // lines to export (names, or `release/*`) and whether releases go out as tags. After each merge
4
- // on those lines the server writes the new patches as 14's commits (export.js and export-git.js:
5
- // deterministic, so the same patch gives the same sha on any machine) in a bare repository in
6
- // scratch and pushes them, fast-forward only: a remote that moved is refused, never forced, and
7
- // the status says which line and sha. Failures to reach the remote retry with backoff. A
8
- // quilt that an import made continues git's own history when the target is the import's source:
9
- // the first exported patch's parent is the last imported sha (14, cut-over). While its import
10
- // follows git, the export may go only to a scratch remote that is not the source.
11
- import fs from 'node:fs'
12
- import os from 'node:os'
13
- import path from 'node:path'
14
- import crypto from 'node:crypto'
15
- import { QuiltError } from './service.js'
16
- import { exportGit } from './export-git.js'
17
- import { remoteUrl, run, authArgs, localClient } from './import-follow.js'
18
-
19
- const LEASE = ' export'
20
- const matches = (patterns, line) => patterns.some((p) => p === line || (p.endsWith('/*') && line.startsWith(p.slice(0, -1))))
21
- const BACKOFF = [30_000, 60_000, 5 * 60_000, 15 * 60_000, 60 * 60_000]
22
-
23
- export function exportRemoteOps (svc, api, { scratch = path.join(os.tmpdir(), 'seam-imports'), allowLocal = false, secret = (name) => process.env[`SEAM_SECRET_${String(name).toUpperCase().replace(/[^A-Z0-9]/g, '_')}`] || null, holder = `exporter:${process.pid}:${crypto.randomBytes(4).toString('hex')}` } = {}) {
24
- const { store, now, quiltFor, fromStore } = svc.internals
25
- const running = new Map()
26
- const again = new Set() // a write came in while a pass ran: run once more after it
27
- const retryAt = new Map() // quilt id → when to try again (this server)
28
-
29
- const save = async (qid, rec, actor, logged = false) => { try { return await store.setExport({ quilt_id: qid, export: rec, logged, actor, at: now() }) } catch (err) { throw fromStore(err) } }
30
-
31
- /** PUT /v1/quilts/:q/export { target, credential?, lines?, releases?, scratch?, enabled? } (admin). */
32
- async function setExport (me, ref, { target, credential = null, lines = ['main'], releases = false, scratch: isScratch = false, enabled = true } = {}) {
33
- const q = await quiltFor(me, ref, 'admin')
34
- const url = remoteUrl(target, { allowLocal })
35
- const list = (Array.isArray(lines) ? lines : [lines]).map(String)
36
- if (!list.length || list.some((l) => !/^[A-Za-z0-9._-][A-Za-z0-9._/-]{0,99}(\/\*)?$/.test(l))) throw new QuiltError(400, 'bad_request', 'lines: line names, or a prefix such as release/*')
37
- if (credential != null && !/^[A-Za-z0-9_.-]{1,100}$/.test(String(credential))) throw new QuiltError(400, 'bad_request', 'credential: the name of a secret, not the secret')
38
- const imp = q.settings?.import
39
- if (imp && imp.follow && ['reading', 'following'].includes(imp.state)) {
40
- // 14: never push to the source while git is the authority, and only to a remote nothing pins.
41
- if (url === imp.source?.url) throw new QuiltError(409, 'export_is_source', `${url} is the source this quilt's import follows; export to a scratch remote until cut-over`)
42
- if (!isScratch) throw new QuiltError(409, 'scratch_required', 'while the import follows git, the export goes only to a scratch remote: say scratch: true (--scratch) to state that nothing pins a sha from it')
43
- }
44
- const prev = q.settings?.export || null
45
- const rec = { target: url, ...(credential ? { credential: String(credential) } : {}), lines: list, releases: !!releases, scratch: !!isScratch, enabled: !!enabled, by: { member_id: me.member_id, kind: me.kind || 'person', name: me.name || null }, status: prev && prev.target === url ? prev.status : { state: 'idle', lines: {} } }
46
- await save(q.id, rec, me.member_id, true)
47
- if (rec.enabled) kick(q.id)
48
- return view(rec)
49
- }
50
-
51
- const view = (rec) => rec
52
- async function exportStatus (me, ref) {
53
- const q = await quiltFor(me, ref, 'read')
54
- if (!q.settings?.export) throw new QuiltError(404, 'not_found', 'this quilt exports nowhere')
55
- return view(q.settings.export)
56
- }
57
-
58
- /** One pass: every exported line's new patches, pushed; then the release tags. */
59
- async function pass (qid) {
60
- const q = await store.getQuilt(qid)
61
- const rec = q?.settings?.export
62
- if (!rec || !rec.enabled) return
63
- const lease = await store.takeLease({ kind: 'line', quilt_id: qid, line: LEASE, holder, ttl_ms: 30 * 60_000, at: now() })
64
- if (!lease.held) { retryAt.set(qid, now() + 30_000); return }
65
- const status = { ...(rec.status || {}), lines: { ...(rec.status?.lines || {}) } }
66
- try {
67
- const dir = path.join(scratch, `export-${qid}.git`)
68
- if (!fs.existsSync(path.join(dir, 'HEAD'))) { fs.mkdirSync(dir, { recursive: true }); await run(['init', '-q', '--bare', dir]) }
69
- const auth = authArgs(rec.credential ? await secret(rec.credential, { quilt_id: qid }) : null)
70
- const env = { GIT_ALLOW_PROTOCOL: allowLocal ? 'https:http:ssh:git:file' : 'https:http:ssh:git' }
71
- const client = localClient(api, rec.by)
72
- const imp = q.settings?.import
73
- const stateFile = path.join(dir, 'quilt-export.json')
74
- for (const ln of await store.lines(qid)) {
75
- if (!matches(rec.lines, ln.name) || !ln.tip) continue
76
- // What the remote has now; the export only ever adds to it.
77
- const remoteRef = `refs/seam-remote/${ln.name}`
78
- await run([...auth, '-C', dir, 'fetch', '--quiet', '--no-tags', rec.target, `+refs/heads/${ln.name}:${remoteRef}`], { env }).catch(() => run(['-C', dir, 'update-ref', '-d', remoteRef]).catch(() => {}))
79
- const remoteSha = (await run(['-C', dir, 'rev-parse', '--verify', '-q', remoteRef]).catch(() => '')).trim() || null
80
- const all = fs.existsSync(stateFile) ? JSON.parse(fs.readFileSync(stateFile, 'utf8')) : {}
81
- const key = `${ln.name}→${ln.name}`
82
- // An imported quilt exporting to its source continues git's history from the import's tip.
83
- const base = imp && imp.source?.url === rec.target ? (imp.lines || []).find((l) => l.line === ln.name) : null
84
- if (!all[key] && base?.tip_sha && remoteSha === base.tip_sha) {
85
- await run(['-C', dir, 'update-ref', `refs/heads/${ln.name}`, base.tip_sha])
86
- all[key] = { quilt: qid, line: ln.name, branch: ln.name, patch_no: base.patches, sha: base.tip_sha, version: null }
87
- fs.writeFileSync(stateFile, JSON.stringify(all, null, 2) + '\n')
88
- }
89
- const r = await exportGit(client, { q: qid, dir, line: ln.name })
90
- if (remoteSha && remoteSha !== r.sha) {
91
- // Is the remote behind us (fine: fast-forward) or somewhere else (refused)?
92
- const behind = await run(['-C', dir, 'merge-base', '--is-ancestor', remoteSha, `refs/heads/${ln.name}`]).then(() => true, () => false)
93
- if (!behind) throw Object.assign(new Error(`the remote's ${ln.name} moved to ${remoteSha.slice(0, 12)}, which the export did not write: it never forces (bring the commit in with a square, or reset the remote's branch)`), { refused: true, line: ln.name, sha: remoteSha })
94
- }
95
- if (r.sha && r.sha !== remoteSha) await run([...auth, '-C', dir, 'push', '--quiet', rec.target, `refs/heads/${ln.name}:refs/heads/${ln.name}`], { env })
96
- status.lines[ln.name] = { patch_no: r.patch_no, sha: r.sha }
97
- }
98
- if (rec.releases) await pushTags(qid, dir, rec, auth, env)
99
- Object.assign(status, { state: 'ok', at: now(), attempts: 0 })
100
- delete status.error; delete status.next_at
101
- retryAt.delete(qid)
102
- } catch (err) {
103
- const attempts = (status.attempts || 0) + 1
104
- if (err.refused) Object.assign(status, { state: 'refused', error: err.message, line: err.line, remote_sha: err.sha, at: now() })
105
- else {
106
- const wait = BACKOFF[Math.min(attempts - 1, BACKOFF.length - 1)]
107
- Object.assign(status, { state: 'retrying', error: String(err.message || err), attempts, next_at: now() + wait, at: now() })
108
- retryAt.set(qid, now() + wait)
109
- }
110
- } finally {
111
- const cur = (await store.getQuilt(qid))?.settings?.export
112
- if (cur && cur.target === rec.target) await save(qid, { ...cur, status }, rec.by.member_id, cur.status?.state !== status.state).catch(() => {})
113
- await store.giveLease({ kind: 'line', quilt_id: qid, line: LEASE, holder }).catch(() => {})
114
- }
115
- }
116
-
117
- /** Tags on exported commits: a release as an annotated tag with its notes, a tag as a plain one. Never moved. */
118
- async function pushTags (qid, dir, rec, auth, env) {
119
- const shaFile = path.join(dir, 'quilt-export-shas')
120
- const shas = new Map()
121
- if (fs.existsSync(shaFile)) for (const l of fs.readFileSync(shaFile, 'utf8').split('\n')) { const [line, n, sha] = l.split(' '); if (sha) shas.set(`${line}/${n}`, sha) }
122
- const tags = await store.tags(qid)
123
- for (const t of tags) {
124
- const c = t.commit ? await store.getCommit(qid, t.commit) : null
125
- const sha = c?.patch ? shas.get(`${c.patch.line}/${c.patch.patch_no}`) : null
126
- if (!sha) continue
127
- const have = (await run(['-C', dir, 'rev-parse', '--verify', '-q', `refs/tags/${t.name}`]).catch(() => '')).trim()
128
- if (!have) {
129
- if (t.release) {
130
- // Deterministic: the tagger and date are the release's, not the clock's.
131
- const at = Math.floor(Number(t.at || 0) / 1000)
132
- await run(['-C', dir, '-c', 'user.name=Quilt', '-c', 'user.email=export@heyquilt.com', 'tag', '-a', t.name, sha, '-m', t.notes || t.name], { env: { GIT_COMMITTER_DATE: `${at} +0000` } })
133
- } else await run(['-C', dir, 'tag', t.name, sha])
134
- }
135
- }
136
- if (tags.length) await run([...auth, '-C', dir, 'push', '--quiet', rec.target, 'refs/tags/*:refs/tags/*'], { env })
137
- }
138
-
139
- function kick (qid) {
140
- if (running.has(qid)) { again.add(qid); return running.get(qid) }
141
- const p = pass(qid).catch(() => {}).finally(() => { running.delete(qid); if (again.delete(qid)) kick(qid) })
142
- running.set(qid, p)
143
- return p
144
- }
145
-
146
- /** After a write: export when the quilt exports. */
147
- async function afterWrite (qid) {
148
- const q = await store.getQuilt(qid)
149
- if (q?.settings?.export?.enabled) kick(qid)
150
- }
151
-
152
- async function exportIdle (qid) { while (running.has(qid)) await running.get(qid) }
153
- async function exportTick () { for (const [qid, at] of retryAt) if (now() >= at) kick(qid) }
154
-
155
- return { setExport, exportStatus, exportIdle, exportTick, exportAfterWrite: afterWrite, exportPass: kick }
156
- }
@@ -1,81 +0,0 @@
1
- // The server half of the one-way git export (plans/quilt-source-control/14, Export): the
2
- // patches of a line in order, each with everything the exported git commit is made from, so a
3
- // driver (export-git.js) can write the same git objects from them on any machine. The git
4
- // fields are worked out here, from the commit record and the merge row only, so the same patch
5
- // always gives the same commit id: nothing from the clock, the driver or a member's current
6
- // profile gets in (14, Determinism).
7
- import { QuiltError } from './service.js'
8
-
9
- const EXPORT_VERSION = 1
10
- const COMMITTER = { name: 'Quilt', email: 'export@heyquilt.com' }
11
-
12
- /** A member slug safe for an email local part. */
13
- const slugOf = (id) => String(id || 'unknown').replace(/^[a-z]+:/, '').toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 64) || 'unknown'
14
-
15
- /**
16
- * The git identity of a commit's author, frozen in the commit record. A stub member from a git
17
- * import keeps the email git had (it was public there); everyone else gets the noreply address
18
- * of 14 (`<slug>@people|agents.noreply.heyquilt.com`), since no member has opted in to a
19
- * public email yet.
20
- */
21
- export function exportAuthor (author = {}) {
22
- const id = String(author.member_id || '')
23
- const kind = author.kind === 'agent' ? 'agent' : 'person'
24
- const stub = id.startsWith('stub:') ? id.slice(5) : ''
25
- const email = stub.includes('@') ? stub : `${slugOf(id)}@${kind === 'agent' ? 'agents' : 'people'}.noreply.heyquilt.com`
26
- const name = String(author.name || stub || slugOf(id)).replace(/[<>\n]/g, '').trim() || slugOf(id)
27
- return { name, email, slug: slugOf(id), kind, ...(author.tool ? { tool: author.tool } : {}), ...(author.model ? { model: author.model } : {}) }
28
- }
29
-
30
- /** The commit message with 14's trailers, in their fixed order. */
31
- export function exportMessage ({ commit, line, patch_no: patchNo, git_sha: gitSha }) {
32
- const a = exportAuthor(commit.author)
33
- const trailers = [
34
- `Quilt-Commit: ${commit.id}`,
35
- `Quilt-Square: ${commit.square_id}`,
36
- `Quilt-Patch: ${line}/${patchNo}`,
37
- `Quilt-Author: ${a.slug} (${[a.kind, a.tool, a.model].filter(Boolean).join(', ')})`,
38
- ...(commit.on_behalf_of ? [`Quilt-On-Behalf-Of: ${slugOf(commit.on_behalf_of)}`] : []),
39
- ...(commit.task_id ? [`Quilt-Task: ${commit.task_id}`] : []),
40
- ...Object.keys(commit.stack || {}).sort().map((k) => `Quilt-Stack-${k}: ${commit.stack[k]}`),
41
- ...(gitSha ? [`Quilt-Git-Sha: ${gitSha}`] : [])
42
- ]
43
- const body = String(commit.message || '').replace(/\s+$/, '')
44
- return `${body || '(no message)'}\n\n${trailers.join('\n')}\n`
45
- }
46
-
47
- export function exportOps (svc) {
48
- const { quiltFor, getCommit, store } = svc.internals
49
-
50
- /**
51
- * GET /quilts/:q/export/git: the patches on `line` after `after`, oldest first, at most
52
- * `limit`: { version, line, patch_count, patches: [{ patch_no, commit, parent, author,
53
- * committer, message, changes }] }. `changes` are the patch commit's changes against its
54
- * first parent (the previous patch), which is what the exported commit changes too.
55
- */
56
- async function exportPatches (me, ref, { line = 'main', after = -1, limit = 200 } = {}) {
57
- const q = await quiltFor(me, ref)
58
- const ln = await store.getLine(q.id, line)
59
- if (!ln) throw new QuiltError(404, 'not_found', `no line ${line}`)
60
- const max = Math.max(1, Math.min(Number(limit) || 200, 1000))
61
- const rows = await store.patches(q.id, line, { after: Number(after), limit: max, desc: false })
62
- const patches = []
63
- for (const row of rows) {
64
- const c = await getCommit(q.id, row.commit)
65
- const gitSha = store.gitShaOf ? await store.gitShaOf(q.id, c.id) : null
66
- const a = exportAuthor(c.author)
67
- patches.push({
68
- patch_no: row.patch_no,
69
- commit: c.id,
70
- parent: c.parents[0] || null,
71
- author: { name: a.name, email: a.email, at: Math.floor((c.authored_at || 0) / 1000) },
72
- committer: { ...COMMITTER, at: Math.floor((row.at || 0) / 1000) },
73
- message: exportMessage({ commit: c, line, patch_no: row.patch_no, git_sha: gitSha }),
74
- changes: c.changes.map((ch) => (ch.op === 'delete' ? { path: ch.path, op: 'delete' } : { path: ch.path, op: 'put', blob: ch.blob, mode: ch.mode }))
75
- })
76
- }
77
- return { version: EXPORT_VERSION, quilt: q.id, line, patch_count: ln.patch_count, patches }
78
- }
79
-
80
- return { exportPatches }
81
- }