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
@@ -9,7 +9,7 @@ import { spawn, execFileSync } from 'node:child_process'
9
9
  import fs from 'node:fs'
10
10
  import path from 'node:path'
11
11
 
12
- const MODES = { file: '100644', exec: '100755', symlink: '120000' }
12
+ const MODES = { file: '100644', exec: '100755', symlink: '120000', gitlink: '160000' }
13
13
  const GIT_ENV = { ...process.env, GIT_NO_REPLACE_OBJECTS: '1', GIT_CONFIG_NOSYSTEM: '1' }
14
14
 
15
15
  const git = (dir, args) => execFileSync('git', ['-C', dir, ...args], { env: GIT_ENV, maxBuffer: 256 * 1024 * 1024 }).toString().trim()
@@ -90,6 +90,9 @@ export async function exportGit (client, { q, dir, line = 'main', branch, page =
90
90
  let mark = 0
91
91
  const blobMark = new Map()
92
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
93
96
  blobMark.set(id, ++mark)
94
97
  await send(`blob\nmark :${mark}\ndata ${bytes.length}\n`); await send(bytes); await send('\n')
95
98
  }
@@ -104,6 +107,7 @@ export async function exportGit (client, { q, dir, line = 'main', branch, page =
104
107
  first = false
105
108
  for (const c of p.changes) {
106
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`
107
111
  else s += `M ${MODES[c.mode || 'file'] || MODES.file} :${blobMark.get(c.blob)} ${fiPath(c.path)}\n`
108
112
  }
109
113
  await send(s + '\n')
@@ -1,10 +1,10 @@
1
- // A fast front for src/merge3.js's mergeSmart (whose node-diff3 LCS is quadratic: seconds on a
1
+ // A fast front for merge-smart.js's mergeSmart (whose node-diff3 LCS is quadratic: seconds on a
2
2
  // 7,000-line file). A clean line merge comes from the Myers hunks in textdiff.js (`nway`, the
3
3
  // same N-way merge the conflict format uses); only a clash goes to mergeSmart's ladder, and then
4
4
  // with the lines all three versions share at the start and end cut off, so it reads only the
5
5
  // region that differs. Same answer, same `how`, as mergeSmart for a clean merge; a clash is
6
6
  // mergeSmart's own answer on the middle, put back between the shared lines.
7
- import { mergeSmart } from '../merge3.js'
7
+ import { mergeSmart } from './merge-smart.js'
8
8
  import { nway } from './conflict-format.js'
9
9
 
10
10
  export function fastMerge (base, ours, theirs) {
@@ -17,7 +17,7 @@ export const KINDS = ['commit', 'blob', 'tree', 'chunk', 'pack', 'term', 'manife
17
17
 
18
18
  /** The id of `bytes` as an object of `kind`: BLAKE3 derive-key mode, 32 bytes, as hex. */
19
19
  // Not object kinds: keys made with their own contexts so they can never equal an object id.
20
- const KEY_KINDS = ['idempotency']
20
+ const KEY_KINDS = ['idempotency', 'resolution']
21
21
 
22
22
  /** A key (not an object id) in its own context, e.g. 'idempotency' → "quilt idempotency v1". */
23
23
  export function keyOf (kind, bytes) {
@@ -0,0 +1,266 @@
1
+ // History tools (plans/quilt-source-control/08 and 12): log --follow (a file's history through its
2
+ // renames), log -S (commits that changed how often a text appears), describe (the nearest tag),
3
+ // merge-base (where two revisions last shared history), grep (lines at a revision) and bisect (the
4
+ // server narrows good..bad; whoever runs the check, the seam CLI or an agent, marks each step).
5
+ import { cleanPath, cleanPrefix, under } from './records.js'
6
+ import { isText, lineStat } from './textdiff.js'
7
+ import { QuiltError } from './service.js'
8
+
9
+ const MAX_WALK = 100000
10
+ const MAX_GREP_BYTES = 64 * 1024 * 1024
11
+ const MAX_GREP_FILE = 2 * 1024 * 1024
12
+ const MAX_SEARCH_QUILTS = 100
13
+
14
+ /** A grep pattern as a RegExp, refusing the shapes that can backtrack for ever on one line. */
15
+ export function grepPattern (pattern, { fixed = false, ignoreCase = false } = {}) {
16
+ const p = String(pattern ?? '')
17
+ if (!p) throw new QuiltError(400, 'bad_request', 'give a pattern')
18
+ if (p.length > 500) throw new QuiltError(400, 'bad_request', 'a pattern is up to 500 characters')
19
+ const src = fixed ? p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') : p
20
+ if (!fixed && (/\((?:[^()\\]|\\.)*[+*}](?:[^()\\]|\\.)*\)\s*[+*{]/.test(p) || /\\[1-9]/.test(p))) {
21
+ throw new QuiltError(400, 'bad_request', `the pattern ${p} could run for ever (a repeated group that repeats, or a back-reference): simplify it, or pass fixed for plain text`)
22
+ }
23
+ try { return new RegExp(src, ignoreCase ? 'i' : '') } catch (err) { throw new QuiltError(400, 'bad_request', `bad pattern: ${err.message}`) }
24
+ }
25
+
26
+ const count = (text, needle) => { let n = 0; let i = 0; while ((i = text.indexOf(needle, i)) >= 0) { n++; i += needle.length } return n }
27
+
28
+ export function historyOps (svc, { mergeBase: commonBase }) {
29
+ const { quiltFor, resolve, rootAt, getCommit, snapshot, reader, readBlob, commitView, store } = svc.internals
30
+ const baseLog = svc.log
31
+
32
+ async function entryAt (q, commitId, p) {
33
+ if (!commitId) return null
34
+ return reader(q).lookup(await snapshot(q, commitId), p)
35
+ }
36
+
37
+ async function textOf (q, e, cache) {
38
+ if (!e || !e.blob) return null
39
+ if (cache.has(e.blob)) return cache.get(e.blob)
40
+ const b = await readBlob(q, e.blob)
41
+ const t = isText(b) ? b.toString('utf8') : null
42
+ cache.set(e.blob, t)
43
+ return t
44
+ }
45
+
46
+ /** The path `p` had before commit `c` added it: copy_from, a deleted file with the same content, or the most alike deleted text file. */
47
+ async function renamedFrom (q, c, ch, texts) {
48
+ if (ch.copy_from?.path) return ch.copy_from.path
49
+ const parent = c.parents[0] || null
50
+ if (!parent || await entryAt(q, parent, ch.path)) return null
51
+ const gone = c.changes.filter((x) => x.op === 'delete')
52
+ if (!gone.length) return null
53
+ const before = await Promise.all(gone.map((x) => entryAt(q, parent, x.path)))
54
+ const same = gone.find((x, i) => before[i]?.blob === ch.blob)
55
+ if (same) return same.path
56
+ const now = await textOf(q, { blob: ch.blob }, texts)
57
+ if (now == null) return null
58
+ let best = null; let score = 0.5
59
+ for (let i = 0; i < gone.length; i++) {
60
+ const was = await textOf(q, before[i], texts)
61
+ if (was == null) continue
62
+ const st = lineStat(was, now)
63
+ const total = Math.max(was.split('\n').length, now.split('\n').length, 1)
64
+ const kept = 1 - Math.max(st.add, st.del) / total
65
+ if (kept >= score) { best = gone[i].path; score = kept }
66
+ }
67
+ return best
68
+ }
69
+
70
+ /** Whether commit `c` changed how many times `needle` appears in the changed files (a rename keeps the count). */
71
+ async function changesCount (q, c, needle, prefixes, texts) {
72
+ const parent = c.parents[0] || null
73
+ let before = 0; let after = 0
74
+ for (const ch of c.changes) {
75
+ if (prefixes.length && !prefixes.some((p) => under(ch.path, p))) continue
76
+ const was = await textOf(q, await entryAt(q, parent, ch.path), texts)
77
+ const now = ch.op === 'put' ? await textOf(q, { blob: ch.blob }, texts) : null
78
+ if (was) before += count(was, needle)
79
+ if (now) after += count(now, needle)
80
+ }
81
+ return before !== after
82
+ }
83
+
84
+ /**
85
+ * GET /log with follow (one file, through its renames) or search (-S): a walk over first parents.
86
+ * Without either, the indexed log (service.js).
87
+ */
88
+ async function log (me, ref, opts = {}) {
89
+ const { rev = 'main', paths = [], limit = 20, follow = false, search } = opts
90
+ if (!follow && !search) return baseLog(me, ref, opts)
91
+ const q = await quiltFor(me, ref)
92
+ if (follow && (paths || []).length !== 1) throw new QuiltError(400, 'bad_request', 'log --follow takes one file')
93
+ if (search != null && (!String(search).length || String(search).length > 500)) throw new QuiltError(400, 'bad_request', 'log -S takes a text of 1 to 500 characters')
94
+ let range = String(rev || 'main')
95
+ let stop = null
96
+ if (range.includes('..')) {
97
+ const [a, b] = range.split('..')
98
+ stop = a ? await resolve(q.id, a) : null
99
+ range = b || 'main'
100
+ }
101
+ let id = await resolve(q.id, range)
102
+ let cur = follow ? cleanPath(paths[0]) : null
103
+ const prefixes = follow ? [] : (paths || []).map(cleanPrefix)
104
+ const max = Math.min(Number(limit) || 20, 1000)
105
+ const texts = new Map()
106
+ const out = []
107
+ let walked = 0
108
+ while (id && id !== stop && out.length < max && walked++ < MAX_WALK) {
109
+ const c = await getCommit(q.id, id)
110
+ if (follow) {
111
+ const ch = c.changes.find((x) => x.path === cur)
112
+ if (ch && (search == null || await changesCount(q.id, c, String(search), [cur], texts))) out.push({ ...commitView(c, { changes: false }), path: cur })
113
+ if (ch && ch.op === 'put') { const from = await renamedFrom(q.id, c, ch, texts); if (from) cur = from }
114
+ } else {
115
+ const touched = !prefixes.length || c.changes.some((x) => prefixes.some((p) => under(x.path, p)))
116
+ if (touched && await changesCount(q.id, c, String(search), prefixes, texts)) out.push(commitView(c, { changes: false }))
117
+ }
118
+ id = c.parents[0] || null
119
+ }
120
+ return { commits: out, more: !!id && id !== stop && out.length >= max, ...(follow ? { follow: cleanPath(paths[0]) } : {}) }
121
+ }
122
+
123
+ /** Every ancestor of `id` (all parents) with its distance, nearest first, up to MAX_WALK. */
124
+ async function ancestors (q, id, { until } = {}) {
125
+ const seen = new Map()
126
+ if (!id) return seen
127
+ let frontier = [id]
128
+ seen.set(id, 0)
129
+ for (let d = 1; frontier.length && seen.size < MAX_WALK; d++) {
130
+ const next = []
131
+ for (const x of frontier) {
132
+ if (until && until(x)) continue
133
+ for (const p of (await getCommit(q, x)).parents) if (!seen.has(p)) { seen.set(p, d); next.push(p) }
134
+ }
135
+ frontier = next
136
+ }
137
+ return seen
138
+ }
139
+
140
+ async function patchOf (q, id) { return id ? ((await store.getCommit(q, id))?.patch || null) : null }
141
+
142
+ /** GET /describe?rev=: the nearest tag behind a revision, as <tag>-<commits past it>-<commit>. */
143
+ async function describe (me, ref, rev = 'main') {
144
+ const q = await quiltFor(me, ref)
145
+ const id = await resolve(q.id, rev || 'main')
146
+ if (!id) throw new QuiltError(404, 'not_found', `${rev || 'main'} has no commits yet`)
147
+ const byCommit = new Map()
148
+ for (const t of await store.tags(q.id)) {
149
+ const had = byCommit.get(t.commit)
150
+ if (!had || (t.seq || 0) > (had.seq || 0)) byCommit.set(t.commit, t)
151
+ }
152
+ const short = id.slice(0, 12)
153
+ let hit = null
154
+ if (byCommit.size) {
155
+ const dist = await ancestors(q.id, id, { until: (x) => byCommit.has(x) })
156
+ for (const [cid, d] of dist) if (byCommit.has(cid) && (!hit || d < hit.d)) hit = { d, tag: byCommit.get(cid).name }
157
+ }
158
+ if (!hit) return { commit: id, short, tag: null, distance: null, describe: short }
159
+ return { commit: id, short, tag: hit.tag, distance: hit.d, describe: hit.d ? `${hit.tag}-${hit.d}-${short}` : hit.tag }
160
+ }
161
+
162
+ /** GET /merge-base?a=&b=: where two revisions last shared history (null if never). */
163
+ async function mergeBaseOf (me, ref, a, b) {
164
+ const q = await quiltFor(me, ref)
165
+ if (!a || !b) throw new QuiltError(400, 'bad_request', 'merge-base takes two revisions (a and b)')
166
+ const { base } = await commonBase(q.id, await resolve(q.id, a), await resolve(q.id, b))
167
+ return { commit: base, short: base ? base.slice(0, 12) : null, patch: await patchOf(q.id, base) }
168
+ }
169
+
170
+ /** GET /grep: the lines matching a pattern in the files at a revision (sq:<id>:live too). */
171
+ async function grep (me, ref, { pattern, rev = 'main', paths = [], ignore_case: ignoreCase = false, fixed = false, limit = 200 } = {}) {
172
+ const q = await quiltFor(me, ref)
173
+ const re = grepPattern(pattern, { fixed, ignoreCase })
174
+ const { id, root } = await rootAt(q.id, rev || 'main')
175
+ const prefixes = (paths && paths.length ? paths : ['']).map(cleanPrefix)
176
+ const max = Math.min(Math.max(Number(limit) || 200, 1), 2000)
177
+ const matches = []
178
+ const files = []
179
+ let bytes = 0; let more = false; let skipped = 0
180
+ for (const e of await reader(q.id).list(root, prefixes)) {
181
+ if (!e.blob || e.mode === 'gitlink' || e.mode === 'symlink') continue
182
+ if ((e.size || 0) > MAX_GREP_FILE || bytes + (e.size || 0) > MAX_GREP_BYTES) { skipped++; continue }
183
+ const buf = await readBlob(q.id, e.blob)
184
+ bytes += buf.length
185
+ if (!isText(buf)) continue
186
+ const lines = buf.toString('utf8').split('\n')
187
+ let hit = false
188
+ for (let i = 0; i < lines.length; i++) {
189
+ const text = lines[i].length > 4000 ? lines[i].slice(0, 4000) : lines[i]
190
+ if (!re.test(text)) continue
191
+ if (matches.length >= max) { more = true; break }
192
+ hit = true
193
+ matches.push({ path: e.path, line: i + 1, text: text.replace(/\r$/, '') })
194
+ }
195
+ if (hit) files.push(e.path)
196
+ if (more) break
197
+ }
198
+ return { commit: id, matches, files, more, ...(skipped ? { skipped } : {}) }
199
+ }
200
+
201
+ /**
202
+ * GET /v1/search: grep across every quilt the caller can read (or the ones named), at one
203
+ * revision (main by default). A quilt without that revision is skipped; limit counts all matches.
204
+ */
205
+ async function search (me, { pattern, rev = 'main', quilts = [], paths = [], ignore_case: ignoreCase = false, fixed = false, limit = 200 } = {}) {
206
+ grepPattern(pattern, { fixed, ignoreCase })
207
+ const max = Math.min(Math.max(Number(limit) || 200, 1), 2000)
208
+ const named = (quilts || []).filter(Boolean)
209
+ const targets = named.length ? await Promise.all(named.map((ref) => quiltFor(me, ref))) : await store.quiltsFor(me.member_id)
210
+ if (targets.length > MAX_SEARCH_QUILTS) throw new QuiltError(400, 'bad_request', `search reads at most ${MAX_SEARCH_QUILTS} quilts at once: name some with quilts`)
211
+ const matches = []; const files = []; const searched = []
212
+ let more = false; let skipped = 0
213
+ for (const q of targets) {
214
+ if (matches.length >= max) { more = true; break }
215
+ let r
216
+ try { r = await grep(me, q.slug, { pattern, rev, paths, ignore_case: ignoreCase, fixed, limit: max - matches.length }) } catch (e) {
217
+ if (e instanceof QuiltError && e.status === 404) continue
218
+ throw e
219
+ }
220
+ searched.push(q.slug)
221
+ for (const m of r.matches) matches.push({ quilt: q.slug, ...m })
222
+ for (const f of r.files) files.push(`${q.slug}:${f}`)
223
+ skipped += r.skipped || 0
224
+ if (r.more) { more = true; break }
225
+ }
226
+ return { rev: rev || 'main', searched, matches, files, more, ...(skipped ? { skipped } : {}) }
227
+ }
228
+
229
+ /**
230
+ * POST /bisect { bad, good, marks }: the first-parent commits after good up to bad, narrowed by
231
+ * marks ({ <commit>: good | bad | skip }). Answers the next commit to test, or the first bad one.
232
+ */
233
+ async function bisect (me, ref, { bad = 'main', good, marks = {} } = {}) {
234
+ const q = await quiltFor(me, ref)
235
+ if (!good) throw new QuiltError(400, 'bad_request', 'bisect needs a good revision (one without the problem)')
236
+ const [b, g] = [await resolve(q.id, bad), await resolve(q.id, good)]
237
+ if (!b || !g) throw new QuiltError(404, 'not_found', 'bisect needs two revisions with commits')
238
+ const behindGood = await ancestors(q.id, g)
239
+ const list = []
240
+ let id = b
241
+ while (id && !behindGood.has(id) && list.length < MAX_WALK) { list.push(id); id = (await getCommit(q.id, id)).parents[0] || null }
242
+ if (!id || !list.length) throw new QuiltError(400, 'bad_request', `${good} is not an ancestor of ${bad}: give a good revision older than the bad one`)
243
+ list.reverse() // oldest first; the last is bad
244
+ const mark = new Map()
245
+ for (const [k, v] of Object.entries(marks || {})) {
246
+ if (!['good', 'bad', 'skip'].includes(v)) throw new QuiltError(400, 'bad_request', `a mark is good, bad or skip, not ${v}`)
247
+ const full = list.find((x) => x.startsWith(String(k).replace(/^c:/, '')))
248
+ if (full) mark.set(full, v)
249
+ }
250
+ let hi = list.length - 1
251
+ for (let i = 0; i < list.length; i++) if (mark.get(list[i]) === 'bad') { hi = i; break }
252
+ let lo = -1
253
+ for (let i = hi - 1; i >= 0; i--) if (mark.get(list[i]) === 'good') { lo = i; break }
254
+ const left = []
255
+ for (let i = lo + 1; i < hi; i++) if (mark.get(list[i]) !== 'skip') left.push(i)
256
+ const view = async (cid) => commitView(await getCommit(q.id, cid), { changes: false })
257
+ if (!left.length) {
258
+ const skipped = list.slice(lo + 1, hi)
259
+ return { done: true, first_bad: await view(list[hi]), ...(skipped.length ? { maybe: await Promise.all(skipped.map(view)) } : {}), tested: mark.size }
260
+ }
261
+ const next = left[Math.floor((left.length - 1) / 2)]
262
+ return { done: false, next: await view(list[next]), remaining: left.length, steps: Math.ceil(Math.log2(left.length + 1)) }
263
+ }
264
+
265
+ return { log, describe, mergeBaseOf, grep, search, bisect }
266
+ }
@@ -1,14 +1,16 @@
1
1
  // The local driver of a git import (plans/quilt-source-control/14): walks a repository on this
2
2
  // machine with `git cat-file --batch`, hashes blobs with BLAKE3, uploads the missing ones in
3
3
  // packs with the commit records, and sends the commits in order. Every commit on HEAD's
4
- // first-parent chain becomes a patch on main with a square of its own; a merge commit's side
5
- // commits become the commits of the merge's square (14, "Which square owns each commit").
4
+ // first-parent chain becomes a patch on main with a square of its own, and every other branch's
5
+ // on a line of its name; a merge commit's side commits become the commits of the merge's square
6
+ // (14, "Which square owns each commit"). Tags come too, submodules stay gitlink entries, LFS
7
+ // pointer files stay the pointer files they are, and a second import adds only new commits.
6
8
  import { spawn, execFileSync } from 'node:child_process'
7
9
  import { cleanCommit, commitId, commitBytes, blobId } from './records.js'
8
10
  import { uuidv7 } from './ids.js'
9
11
 
10
12
  const GIT_ENV = { ...process.env, GIT_NO_REPLACE_OBJECTS: '1' }
11
- const MODES = { 100644: 'file', 100755: 'exec', 120000: 'symlink', 100664: 'file' }
13
+ const MODES = { 100644: 'file', 100755: 'exec', 120000: 'symlink', 100664: 'file', 160000: 'gitlink' }
12
14
 
13
15
  /** A long-lived `git cat-file --batch`: get(sha) → { type, bytes }. */
14
16
  function catFile (repo) {
@@ -78,56 +80,106 @@ const agentEmail = /@agents\.noreply\.heyquilt\.com$/i
78
80
  const member = (who) => ({ member_id: `stub:${String(who.email || who.name).toLowerCase()}`, kind: agentEmail.test(who.email || '') ? 'agent' : 'person', name: who.name || who.email })
79
81
 
80
82
  /**
81
- * Imports the history of HEAD in `repo` into a new quilt `slug`. Returns { quilt, slug,
82
- * commits, patches, report, validated? }.
83
+ * The branches to import: [{ name, sha }], HEAD's branch first as main. Local branches, then
84
+ * remote-tracking ones (origin/dev → dev) no local branch has, so a fresh clone brings them all.
83
85
  */
84
- export async function importGit (client, { repo, slug, batch = 100, log = () => {}, validate = false, limit = 0 }) {
86
+ function branches (repo, head) {
87
+ let current = null
88
+ try { current = git(repo, ['symbolic-ref', '-q', '--short', 'HEAD']).toString().trim() || null } catch {}
89
+ const out = [{ name: 'main', sha: head, git: current || 'HEAD' }]
90
+ const seen = new Set([current, 'HEAD'])
91
+ const rows = git(repo, ['for-each-ref', '--format=%(refname)%00%(objectname)%00%(objecttype)', 'refs/heads', 'refs/remotes']).toString().split('\n').filter(Boolean).map((l) => l.split('\0'))
92
+ for (const local of [true, false]) {
93
+ for (const [ref, sha, type] of rows) {
94
+ if (type !== 'commit' || ref.startsWith('refs/heads/') !== local) continue
95
+ const name = local ? ref.slice(11) : ref.split('/').slice(3).join('/')
96
+ if (!name || name === 'HEAD' || seen.has(name)) continue
97
+ seen.add(name)
98
+ out.push({ name, sha, git: local ? name : ref.slice(13) })
99
+ }
100
+ }
101
+ return out
102
+ }
103
+
104
+ /** Every tag: [{ name, sha }] with sha the commit it names (an annotated tag peeled). */
105
+ function gitTags (repo) {
106
+ return git(repo, ['for-each-ref', '--format=%(refname:strip=2)%00%(objectname)%00%(*objectname)%00%(objecttype)%00%(*objecttype)', 'refs/tags']).toString().split('\n').filter(Boolean).map((l) => {
107
+ const [name, sha, peeled, type, ptype] = l.split('\0')
108
+ return { name, sha: peeled || sha, type: ptype || type }
109
+ })
110
+ }
111
+
112
+ /**
113
+ * Imports a git repository into the quilt `slug`: HEAD's branch as main, every other branch as a
114
+ * line of its own name, and every tag. Into a new quilt, or again into a quilt an import made,
115
+ * where only the commits it does not have yet are added. Returns { quilt, slug, commits,
116
+ * patches, lines, tags, report, validated? }.
117
+ */
118
+ export async function importGit (client, { repo, slug, batch = 100, log = () => {}, validate = false, limit = 0, onlyHead = false, fresh = false }) {
85
119
  const head = git(repo, ['rev-parse', 'HEAD']).toString().trim()
86
- const all = git(repo, ['rev-list', head]).toString().split('\n').filter(Boolean)
120
+ const refs = onlyHead ? [{ name: 'main', sha: head, git: 'HEAD' }] : branches(repo, head)
121
+ const all = git(repo, ['rev-list', ...new Set(refs.map((r) => r.sha))]).toString().split('\n').filter(Boolean)
87
122
  const cat = catFile(repo)
88
123
  const report = []
89
124
  try {
90
125
  // Parents and fields of every commit, read once.
91
126
  const info = new Map()
92
127
  for (const sha of all) info.set(sha, parseCommit((await cat.get(sha)).bytes))
93
- // HEAD's first-parent chain, root first.
94
- const chain = []
95
- for (let s = head; s; s = info.get(s).parents[0]) chain.push(s)
96
- chain.reverse()
97
- if (limit) chain.length = Math.min(chain.length, limit)
98
-
99
- const quilt = await client.createQuilt({ slug, name: slug, import: true })
100
- log(`Importing ${chain.length} patches (${all.length} commits in all) into ${quilt.slug}…`)
128
+
129
+ // A quilt an import made takes the new commits; otherwise a new quilt.
130
+ let quilt = null
131
+ try { quilt = await client.quilt(slug) } catch (err) { if (err.status !== 404) throw err }
132
+ if (quilt && fresh) throw new Error(`there is already a quilt ${slug}`)
133
+ const again = !!quilt
134
+ if (!quilt) quilt = await client.createQuilt({ slug, name: slug, import: true })
101
135
  const ids = new Map() // git sha → quilt commit id
136
+ if (again) {
137
+ for (let i = 0; i < all.length; i += 5000) {
138
+ const { known } = await client.importKnown(quilt.id, all.slice(i, i + 5000))
139
+ for (const [sha, id] of Object.entries(known)) ids.set(sha, id)
140
+ }
141
+ }
142
+ const lineTips = new Map((again ? await client.lines(quilt.id) : [{ name: 'main', tip: null }]).map((l) => [l.name, l.tip]))
143
+ log(again ? `Adding to ${quilt.slug}: ${all.length - ids.size} new commits of ${all.length}…` : `Importing ${all.length} commits on ${refs.length} branches into ${quilt.slug}…`)
144
+
102
145
  const blobs = new Map() // git blob sha → { id, size }
103
- const assigned = new Set()
146
+ const assigned = new Set(ids.keys())
104
147
  const items = []
148
+ const newLines = []
149
+ const written = [] // the git shas of the patches this import writes
105
150
  let pending = []
106
151
  let commits = 0; let patches = 0
152
+ const lines = []
107
153
 
108
154
  const flush = async () => {
109
- if (!items.length) return
110
- await client.upload(quilt.id, pending)
111
- const r = await client.importCommits(quilt.id, items.splice(0))
155
+ if (!items.length && !newLines.length) return
156
+ if (pending.length) await client.upload(quilt.id, pending)
157
+ const r = await client.importCommits(quilt.id, items.splice(0), newLines.splice(0))
112
158
  pending = []
113
159
  commits += r.commits; patches += r.patches
114
160
  log(` ${patches} patches, ${commits} commits`)
115
161
  }
116
162
 
117
- const emit = async (sha, square, kind) => {
163
+ const blobOf = async (sha, gitMode) => {
164
+ let b = blobs.get(sha)
165
+ if (!b) {
166
+ // A submodule has no blob in git: its entry's content is the commit sha it pins.
167
+ const bytes = gitMode === '160000' ? Buffer.from(sha) : (await cat.get(sha)).bytes
168
+ b = { id: blobId(bytes), size: bytes.length }
169
+ blobs.set(sha, b)
170
+ pending.push({ kind: 'blob', id: b.id, bytes })
171
+ }
172
+ return b
173
+ }
174
+
175
+ const emit = async (sha, square, kind, line) => {
118
176
  const g = info.get(sha)
119
177
  const changes = []
120
178
  for (const ch of diffTree(repo, g.parents[0] || null, sha)) {
121
179
  if (ch.delete) { changes.push({ path: ch.path, op: 'delete' }); continue }
122
180
  const mode = MODES[ch.gitMode]
123
- if (!mode) { report.push({ kind: ch.gitMode === '160000' ? 'submodule' : 'mode', sha, path: ch.path, note: `git mode ${ch.gitMode} left out` }); continue }
124
- let b = blobs.get(ch.sha)
125
- if (!b) {
126
- const { bytes } = await cat.get(ch.sha)
127
- b = { id: blobId(bytes), size: bytes.length }
128
- blobs.set(ch.sha, b)
129
- pending.push({ kind: 'blob', id: b.id, bytes })
130
- }
181
+ if (!mode) { report.push({ kind: 'mode', sha, path: ch.path, note: `git mode ${ch.gitMode} left out` }); continue }
182
+ const b = await blobOf(ch.sha, ch.gitMode)
131
183
  changes.push({ path: ch.path, op: 'put', blob: b.id, mode, size: b.size })
132
184
  }
133
185
  const c = cleanCommit({
@@ -138,56 +190,102 @@ export async function importGit (client, { repo, slug, batch = 100, log = () =>
138
190
  const id = commitId(c)
139
191
  ids.set(sha, id)
140
192
  pending.push({ kind: 'commit', id, bytes: commitBytes(c) })
141
- items.push({ id, commit: c, kind, square, git_sha: sha, payload: { committer: g.committer || null, author_email: g.author?.email || '' } })
193
+ if (kind === 'patch') written.push(sha)
194
+ items.push({ id, commit: c, kind, line, square, git_sha: sha, payload: { committer: g.committer || null, author_email: g.author?.email || '' } })
142
195
  if (items.length >= batch) await flush()
143
196
  }
144
197
 
145
- for (const sha of chain) {
146
- const g = info.get(sha)
147
- const title = String(g.message).split('\n')[0].slice(0, 200)
148
- const square = { id: uuidv7(g.author?.at || Date.now()), title, base: g.parents[0] ? ids.get(g.parents[0]) || null : null, holder: member(g.author || {}).member_id }
149
- if (g.parents.length > 1) {
150
- // Side commits: reachable from parents 2..n and not yet in any square, parents first.
151
- const side = []
152
- const seen = new Set()
153
- const visit = (s) => {
154
- if (!s || seen.has(s) || assigned.has(s) || !info.has(s)) return
155
- seen.add(s)
156
- for (const p of info.get(s).parents) visit(p)
157
- side.push(s)
158
- }
159
- for (const p of g.parents.slice(1)) visit(p)
160
- if (side.length) {
161
- const m = title.match(/^Merge (?:pull request #\d+ from |branch ')([^' ]+)/)
162
- if (m) square.title = m[1]
163
- for (const s of side) { assigned.add(s); await emit(s, square, 'side') }
198
+ // One branch: its first-parent commits not yet imported become patches on its line.
199
+ const importBranch = async (ref) => {
200
+ const chain = []
201
+ let s = ref.sha
202
+ for (; s && !ids.has(s); s = info.get(s).parents[0]) chain.push(s)
203
+ const base = s ? ids.get(s) : null
204
+ chain.reverse()
205
+ if (limit && ref.name === 'main') chain.length = Math.min(chain.length, limit)
206
+ if (!lineTips.has(ref.name)) {
207
+ if (!base) { report.push({ kind: 'branch', ref: ref.git, note: 'shares no history with the other branches; left out' }); return }
208
+ if (!LINE_NAME.test(ref.name) || /\.\.|\/\/|\/$/.test(ref.name)) { report.push({ kind: 'branch', ref: ref.git, note: 'its name cannot be a line; left out' }); return }
209
+ await flush() // the line's first commit is imported before the line names it
210
+ newLines.push({ name: ref.name, tip: base })
211
+ lineTips.set(ref.name, base)
212
+ } else if ((lineTips.get(ref.name) || null) !== base) {
213
+ // The line moved on in the quilt (merges since the import), or git rewrote the branch.
214
+ if (chain.length) report.push({ kind: 'branch', ref: ref.git, note: `line ${ref.name} no longer follows git's branch; its ${chain.length} new commits were left out` })
215
+ return
216
+ }
217
+ for (const sha of chain) {
218
+ const g = info.get(sha)
219
+ const title = String(g.message).split('\n')[0].slice(0, 200)
220
+ const square = { id: uuidv7(g.author?.at || Date.now()), title, base: g.parents[0] ? ids.get(g.parents[0]) || null : null, holder: member(g.author || {}).member_id }
221
+ if (g.parents.length > 1) {
222
+ // Side commits: reachable from parents 2..n and not yet in any square, parents first.
223
+ const side = []
224
+ const seen = new Set()
225
+ const visit = (x) => {
226
+ if (!x || seen.has(x) || assigned.has(x) || !info.has(x)) return
227
+ seen.add(x)
228
+ for (const p of info.get(x).parents) visit(p)
229
+ side.push(x)
230
+ }
231
+ for (const p of g.parents.slice(1)) visit(p)
232
+ if (side.length) {
233
+ const m = title.match(/^Merge (?:pull request #\d+ from |branch ')([^' ]+)/)
234
+ if (m) square.title = m[1]
235
+ for (const x of side) { assigned.add(x); await emit(x, square, 'side', ref.name) }
236
+ }
164
237
  }
238
+ assigned.add(sha)
239
+ await emit(sha, square, 'patch', ref.name)
165
240
  }
166
- assigned.add(sha)
167
- await emit(sha, square, 'patch')
241
+ lineTips.set(ref.name, chain.length ? ids.get(chain[chain.length - 1]) : base)
242
+ lines.push({ name: ref.name, git: ref.git, added: chain.length })
168
243
  }
244
+
245
+ for (const ref of refs) await importBranch(ref)
169
246
  await flush()
170
247
 
248
+ // Tags, on the commits they name. A tag is never moved: one the quilt has on another commit is reported.
249
+ const have = again ? new Map((await client.tags(quilt.id)).map((t) => [t.name, t.commit])) : new Map()
250
+ let tagged = 0
251
+ for (const t of onlyHead ? [] : gitTags(repo)) {
252
+ const id = t.type === 'commit' ? ids.get(t.sha) : null
253
+ if (!id) { report.push({ kind: 'tag', ref: t.name, note: 'names no imported commit; left out' }); continue }
254
+ if (have.has(t.name)) { if (have.get(t.name) !== id) report.push({ kind: 'tag', ref: t.name, note: 'the quilt has this tag on another commit; left as it is' }); continue }
255
+ try { await client.createTag(quilt.id, { name: t.name, rev: `c:${id}` }); tagged++ } catch (err) { report.push({ kind: 'tag', ref: t.name, note: err.message }) }
256
+ }
257
+
171
258
  let validated
172
259
  if (validate) {
173
260
  validated = 0
174
- // validate: 'all' checks every imported commit (side commits of merges too), else the patches.
175
- for (const sha of validate === 'all' ? [...ids.keys()] : chain) {
261
+ // validate: 'all' checks every commit the quilt has from git (side commits of merges too),
262
+ // else the patches this import wrote.
263
+ const check = validate === 'all' ? [...ids.keys()] : written
264
+ for (const sha of check) {
176
265
  const want = new Map()
177
266
  for (const row of git(repo, ['ls-tree', '-r', '-z', sha]).toString('utf8').split('\0').filter(Boolean)) {
178
267
  const [meta, path] = row.split('\t')
179
268
  const [mode, type, osha] = meta.split(' ')
180
- if (type !== 'blob' || !MODES[mode]) continue
181
- want.set(path, { mode: MODES[mode], blob: blobs.get(osha)?.id })
269
+ if ((type !== 'blob' && type !== 'commit') || !MODES[mode]) continue
270
+ want.set(path, { mode: MODES[mode], osha, gitMode: mode })
182
271
  }
183
272
  const t = await client.tree(quilt.id, `c:${ids.get(sha)}`)
184
- const bad = t.entries.length !== want.size || t.entries.some((e) => { const w = want.get(e.path); return !w || w.mode !== e.mode || w.blob !== e.blob })
273
+ let bad = t.entries.length !== want.size
274
+ for (const e of t.entries) {
275
+ const w = want.get(e.path)
276
+ if (bad || !w || w.mode !== e.mode) { bad = true; break }
277
+ // Blobs from an earlier import were not read this time: hash them now.
278
+ const id = blobs.get(w.osha)?.id || blobId(w.gitMode === '160000' ? Buffer.from(w.osha) : (await cat.get(w.osha)).bytes)
279
+ if (id !== e.blob) { bad = true; break }
280
+ }
185
281
  if (bad) throw new Error(`validation: the tree of git ${sha.slice(0, 12)} does not match quilt commit ${ids.get(sha).slice(0, 12)}`)
186
282
  validated++
187
283
  }
188
284
  }
189
- return { quilt: quilt.id, slug: quilt.slug, commits, patches, report, ...(validate ? { validated } : {}), tip: ids.get(chain[chain.length - 1]) }
285
+ return { quilt: quilt.id, slug: quilt.slug, again, commits, patches, lines, tags: tagged, report, ...(validate ? { validated } : {}), tip: lineTips.get('main') }
190
286
  } finally {
191
287
  cat.close()
192
288
  }
193
289
  }
290
+
291
+ const LINE_NAME = /^[A-Za-z0-9._-][A-Za-z0-9._/-]{0,99}$/