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.
- package/RELEASES.md +21 -0
- package/package.json +1 -1
- package/src/api/server.js +10 -1
- package/src/quilts/archiver.js +8 -1
- package/src/quilts/autorevert.js +49 -0
- package/src/quilts/bisect-run.js +31 -0
- package/src/quilts/block.js +133 -14
- package/src/quilts/bucket.js +25 -0
- package/src/quilts/claims.js +144 -0
- package/src/quilts/cli.js +206 -11
- package/src/quilts/client.js +37 -1
- package/src/quilts/describe.js +149 -3
- package/src/quilts/dogfood.js +1 -1
- package/src/quilts/errors.js +8 -0
- package/src/quilts/events.js +19 -5
- package/src/quilts/export-git.js +5 -1
- package/src/quilts/fastmerge.js +2 -2
- package/src/quilts/hash.js +1 -1
- package/src/quilts/history.js +266 -0
- package/src/quilts/import-git.js +154 -56
- package/src/quilts/import.js +56 -20
- package/src/quilts/index.js +34 -2
- package/src/quilts/lines.js +49 -0
- package/src/quilts/live.js +50 -0
- package/src/quilts/merge-smart.js +114 -0
- package/src/quilts/merge.js +75 -27
- package/src/quilts/objects.js +17 -12
- package/src/quilts/records.js +2 -1
- package/src/quilts/resolutions.js +69 -0
- package/src/quilts/reviews.js +195 -0
- package/src/quilts/routes.js +51 -14
- package/src/quilts/service.js +133 -25
- package/src/quilts/setup.js +2 -2
- package/src/quilts/signing.js +149 -0
- package/src/quilts/store-memory.js +152 -0
- package/src/quilts/store-rpc.js +16 -1
- package/src/quilts/tags.js +64 -0
- package/src/quilts/tokens.js +66 -0
- package/src/quilts/tools.js +195 -19
- package/src/quilts/webhook.js +116 -0
- package/src/server.js +2 -2
- package/src/{quilts → source-live}/live-block.js +4 -3
- package/src/{quilts → source-live}/live-square.js +2 -1
package/src/quilts/export-git.js
CHANGED
|
@@ -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')
|
package/src/quilts/fastmerge.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
// A fast front for
|
|
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 '
|
|
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) {
|
package/src/quilts/hash.js
CHANGED
|
@@ -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
|
+
}
|
package/src/quilts/import-git.js
CHANGED
|
@@ -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
|
|
5
|
-
// commits become the commits of the merge's square
|
|
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
|
-
*
|
|
82
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
if (
|
|
98
|
-
|
|
99
|
-
|
|
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
|
|
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:
|
|
124
|
-
|
|
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
|
-
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
const
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
167
|
-
|
|
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
|
|
175
|
-
|
|
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],
|
|
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
|
-
|
|
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:
|
|
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}$/
|