@heyquilt/seam 0.1.1 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/seam.js +18 -4
- package/lib/quilts/bisect-run.js +31 -0
- package/lib/quilts/block.js +133 -14
- package/lib/quilts/cli.js +206 -11
- package/lib/quilts/client.js +37 -1
- package/lib/quilts/describe.js +149 -3
- package/lib/quilts/errors.js +8 -0
- package/lib/quilts/export-git.js +5 -1
- package/lib/quilts/hash.js +1 -1
- package/lib/quilts/import-git.js +154 -56
- package/lib/quilts/records.js +2 -1
- package/lib/quilts/signing.js +149 -0
- package/lib/quilts/tools.js +195 -19
- package/package.json +1 -1
package/lib/quilts/describe.js
CHANGED
|
@@ -10,6 +10,7 @@ export function describeSquare (s) {
|
|
|
10
10
|
if (s.state === 'merged' && s.merged) bits.push(`as ${patchName(s.merged)}`)
|
|
11
11
|
if (s.state === 'open') {
|
|
12
12
|
bits.push(s.head ? `head ${s.head.slice(0, 12)}` : 'no commits yet')
|
|
13
|
+
if (s.on) bits.push(`stacked on ${s.on}`)
|
|
13
14
|
if (s.behind_by) bits.push(`${s.behind_by} patch${s.behind_by === 1 ? '' : 'es'} behind ${s.line}`)
|
|
14
15
|
if (s.conflicted_paths) bits.push(`${s.conflicted_paths} conflicted path${s.conflicted_paths === 1 ? '' : 's'}`)
|
|
15
16
|
}
|
|
@@ -20,8 +21,8 @@ export function describeSquare (s) {
|
|
|
20
21
|
export function describeLog (r, { oneline = false } = {}) {
|
|
21
22
|
if (!r.commits.length) return 'No commits.'
|
|
22
23
|
const lines = r.commits.map((c) => oneline
|
|
23
|
-
? `${c.short} ${c.patch ? `[${c.patch.line}:${c.patch.patch_no}] ` : ''}${first(c.message)}`
|
|
24
|
-
: [`commit ${c.id}${c.patch ? ` (${patchName(c.patch)})` : ''}`, `Square: ${c.square}`, `Author: ${c.author.name} <${c.author.member_id}>${c.author.tool ? ` via ${c.author.tool}` : ''}`, `Date: ${when(c.authored_at)}`, '', ...String(c.message || '').split('\n').map((l) => ` ${l}`), ''].join('\n'))
|
|
24
|
+
? `${c.short} ${c.patch ? `[${c.patch.line}:${c.patch.patch_no}] ` : ''}${first(c.message)}${r.follow && c.path && c.path !== r.follow ? ` (${c.path})` : ''}${c.signature ? ` (signature: ${c.signature.status})` : ''}`
|
|
25
|
+
: [`commit ${c.id}${c.patch ? ` (${patchName(c.patch)})` : ''}`, `Square: ${c.square}`, ...(r.follow && c.path && c.path !== r.follow ? [`Path: ${c.path}`] : []), `Author: ${c.author.name} <${c.author.member_id}>${c.author.tool ? ` via ${c.author.tool}` : ''}`, ...(c.signature ? [describeSignature(c.signature)] : []), `Date: ${when(c.authored_at)}`, '', ...String(c.message || '').split('\n').map((l) => ` ${l}`), ''].join('\n'))
|
|
25
26
|
if (r.more) lines.push(oneline ? '…' : '(more: seam log -n <count>)')
|
|
26
27
|
return lines.join('\n')
|
|
27
28
|
}
|
|
@@ -86,6 +87,8 @@ export function describeWrite (r, verb = 'Done') {
|
|
|
86
87
|
const bits = [`${verb}${c && c.id ? ` ${c.short}` : ''}${c && c.message ? ` "${first(c.message)}"` : ''}`]
|
|
87
88
|
if (r.square) bits.push(`square ${describeSquare(r.square)}`)
|
|
88
89
|
if (r.conflicted) bits.push(`${r.conflicted} conflicted path${r.conflicted === 1 ? '' : 's'}: ${(r.terms || []).map((t) => `${t.path} (${t.kind})`).join(', ')}`)
|
|
90
|
+
const reused = (r.combined || []).filter((c) => c.how === 'reuse')
|
|
91
|
+
if (reused.length) bits.push(`Settled the way it was settled before: ${reused.map((c) => `${c.path} (by ${c.by})`).join(', ')}`)
|
|
89
92
|
return bits.join('\n')
|
|
90
93
|
}
|
|
91
94
|
|
|
@@ -94,7 +97,8 @@ export function describeMerge (r) {
|
|
|
94
97
|
if (r.ok) {
|
|
95
98
|
const lines = [`Merged as ${patchName(r.merged)} (${r.merged.commit.slice(0, 12)})${r.retries ? ` after ${r.retries} lost race${r.retries === 1 ? '' : 's'}` : ''}.`]
|
|
96
99
|
const combined = (r.combined || []).filter((c) => c.how !== 'lines' || true)
|
|
97
|
-
if (combined.length) lines.push(`Combined with changes already on ${r.merged.line}: ${combined.map((c) => `${c.path} (${c.how})`).join(', ')}.`)
|
|
100
|
+
if (combined.length) lines.push(`Combined with changes already on ${r.merged.line}: ${combined.map((c) => c.how === 'reuse' ? `${c.path} (settled as ${c.by} settled it before)` : `${c.path} (${c.how})`).join(', ')}.`)
|
|
101
|
+
for (const k of r.restacked || []) lines.push(k.error ? `${k.square} is stacked on it and could not be moved (${k.error}): its holder rebases it.` : `Moved ${k.square}, stacked on it, onto ${r.merged.line}${k.conflicted ? ` with ${k.conflicted} conflicted path${k.conflicted === 1 ? '' : 's'}` : ''}.`)
|
|
98
102
|
return lines.join('\n')
|
|
99
103
|
}
|
|
100
104
|
const lines = [`Not merged: ${r.refused ? r.refused.detail : r.error}.`]
|
|
@@ -118,3 +122,145 @@ export function describeEvents (r) {
|
|
|
118
122
|
}
|
|
119
123
|
return [...r.events.map(line), `(next: after=${r.after})`].join('\n')
|
|
120
124
|
}
|
|
125
|
+
|
|
126
|
+
/** One tag per line: name, commit, patch, and "release" when it is one. */
|
|
127
|
+
export const describeTag = (x) => `${x.name} ${x.short}${x.patch ? ` ${x.patch.line}:${x.patch.patch_no}` : ''}${x.release ? ' release' : ''}`
|
|
128
|
+
/** A release and its notes, indented under it. */
|
|
129
|
+
export const describeRelease = (x) => `${x.name} ${x.short}${x.patch ? ` ${x.patch.line}:${x.patch.patch_no}` : ''} ${String(x.at && new Date(x.at).toISOString()).slice(0, 10)} ${x.by}` +
|
|
130
|
+
(x.notes ? '\n' + x.notes.split('\n').map((l) => ` ${l}`).join('\n') : '')
|
|
131
|
+
|
|
132
|
+
/** A git import's answer, the same words from the CLI and seam_import_git. */
|
|
133
|
+
export function describeImport (r) {
|
|
134
|
+
const said = [`${r.again ? 'Added' : 'Imported'} ${r.commits} commits (${r.patches} patches) into ${r.slug}.`]
|
|
135
|
+
for (const l of r.lines) said.push(` ${l.name}${l.name !== l.git ? ` (git ${l.git})` : ''}: ${l.added} new patches`)
|
|
136
|
+
if (r.tags) said.push(` tags: ${r.tags}`)
|
|
137
|
+
for (const x of r.report) said.push(` left out: ${x.ref || x.path || x.sha.slice(0, 12)}: ${x.note}`)
|
|
138
|
+
if (r.validated != null) said.push(`Validated ${r.validated} trees against git.`)
|
|
139
|
+
return said.join('\n')
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function describeRestore (r) {
|
|
143
|
+
return r.restored.length ? `Restored ${r.restored.length} file(s) to the last commit: ${r.restored.join(', ')}` : 'Nothing to restore: those files have no uncommitted edits.'
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function describeReset (r, block) {
|
|
147
|
+
const n = r.written + r.removed
|
|
148
|
+
const where = block.square ? 'the square' : block.line
|
|
149
|
+
return `Reset ${n} file(s) to ${String(r.rev).slice(0, 12)}.` + (r.changes ? `\n${r.changes} change(s) from ${where} show as uncommitted (seam status); commit them to keep them, or seam reset to go back.` : '')
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function describeClean (r, again) {
|
|
153
|
+
if (!r.removed.length) return 'Nothing to clean.'
|
|
154
|
+
if (r.dry_run) return r.removed.map((p) => `Would remove ${p}`).join('\n') + `\n${again}`
|
|
155
|
+
return `Removed ${r.removed.length} file(s): ${r.removed.join(', ')}`
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function describeLive (r) {
|
|
159
|
+
if (!r.changes.length) return `No uncommitted work shared for ${r.square}: ${r.square}:live is its head.`
|
|
160
|
+
return [`Uncommitted work in ${r.square}${r.by ? ` (shared by ${r.by})` : ''}, read as ${r.square}:live:`, ...r.changes.map((c) => ` ${c.status.padEnd(9)} ${c.path}`)].join('\n')
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function describeLiveSet (r) {
|
|
164
|
+
return r.changes.length ? `Shared ${r.changes.length} uncommitted change(s) of ${r.square}: read them as ${r.square}:live.` : `Cleared the live work of ${r.square}.`
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const at = (c) => `${c.short || String(c.id || c.commit).slice(0, 12)}${c.patch ? ` (${c.patch.line}:${c.patch.patch_no})` : ''}`
|
|
168
|
+
|
|
169
|
+
export function describeMergeBase (r) {
|
|
170
|
+
return r.commit ? `${r.commit}${r.patch ? ` (${r.patch.line}:${r.patch.patch_no})` : ''}` : 'No common history.'
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function describeGrep (r, { filesOnly = false } = {}) {
|
|
174
|
+
if (!r.matches.length) return 'No matches.'
|
|
175
|
+
const lines = filesOnly ? r.files : r.matches.map((m) => `${m.quilt ? m.quilt + ':' : ''}${m.path}:${m.line}:${m.text}`)
|
|
176
|
+
if (r.more) lines.push('… (more: raise the limit, or narrow by paths)')
|
|
177
|
+
if (r.skipped) lines.push(`(${r.skipped} large file(s) not searched)`)
|
|
178
|
+
return lines.join('\n')
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function describeBisect (r) {
|
|
182
|
+
if (r.done) {
|
|
183
|
+
const said = [`First bad: ${at(r.first_bad)} ${first(r.first_bad.message)}`]
|
|
184
|
+
if (r.maybe) said.push(`Or one of the skipped commits before it: ${r.maybe.map(at).join(', ')}`)
|
|
185
|
+
return said.join('\n')
|
|
186
|
+
}
|
|
187
|
+
return `Test ${at(r.next)} ${first(r.next.message)}: ${r.remaining} commit(s) left, about ${r.steps} step(s). Mark it good, bad or skip and ask again.`
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** A list of claims, one per line. */
|
|
191
|
+
export function describeClaims (claims) {
|
|
192
|
+
if (!claims.length) return 'No claims.'
|
|
193
|
+
return claims.map((c) => `${c.pattern} ${c.square} (${c.line}) ${c.holder}${c.note ? `: ${c.note}` : ''}`).join('\n')
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function describeCheck (r) {
|
|
197
|
+
const lines = [`${r.line}: merge window ${r.window} an hour${r.red.length ? ` (red: ${r.red.join(', ')})` : ''}.`]
|
|
198
|
+
const v = r.revert
|
|
199
|
+
if (v && v.reverted) lines.push(`Reverted patch ${r.line}:${v.patch_no} as patch ${r.line}:${v.revert_patch_no}.${v.fix_square ? ` The change is again in square ${v.fix_square.short}, held by ${v.fix_square.holder}: fix it there, commit, merge.` : ''}`)
|
|
200
|
+
else if (v) lines.push(`Did not revert patch ${r.line}:${v.patch_no}: ${(v.refused && (v.refused.detail || v.refused.code)) || 'refused'}.${v.square ? ` The revert waits in square ${v.square.short}.` : ''}`)
|
|
201
|
+
return lines.join('\n')
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Remembered resolutions, one per line. */
|
|
205
|
+
export function describeResolutions (rows) {
|
|
206
|
+
if (!rows.length) return 'No remembered resolutions.'
|
|
207
|
+
return rows.map((r) => `${r.short} ${r.path} ${r.answer === 'delete' ? 'deleted' : `kept ${r.blob.slice(0, 12)}`} (${r.how}, ${r.by})`).join('\n')
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Where a square stands in review: approvals against what its line needs. */
|
|
211
|
+
function describeStanding (r) {
|
|
212
|
+
const parts = [`${r.have} of ${r.need} approval${r.need === 1 ? '' : 's'}${r.approved_by.length ? ` (${r.approved_by.join(', ')})` : ''}`]
|
|
213
|
+
if (r.changes_requested_by.length) parts.push(`changes asked for by ${r.changes_requested_by.join(', ')}`)
|
|
214
|
+
for (const c of r.checks || []) if (c.required || c.status !== 'missing') parts.push(`check ${c.name} ${c.status}`)
|
|
215
|
+
parts.push(r.ready ? 'ready to merge' : 'not ready to merge')
|
|
216
|
+
return parts.join('; ')
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** A square's reviews, oldest first, then where it stands. */
|
|
220
|
+
export function describeReviews (r) {
|
|
221
|
+
const rows = r.reviews.map((x) => {
|
|
222
|
+
const what = { approve: 'approved', request_changes: 'asked for changes', dismiss: 'dismissed their verdict', comment: 'commented' }[x.kind]
|
|
223
|
+
const where = x.path ? ` on ${x.path}${x.line ? `:${x.line}` : ''}` : ''
|
|
224
|
+
return `${x.author} ${what}${where}${x.outdated ? ' (an earlier commit)' : ''}${x.body ? `: ${x.body}` : ''}`
|
|
225
|
+
})
|
|
226
|
+
return `${r.square} on ${r.line}: ${describeStanding(r)}${rows.length ? '\n' + rows.join('\n') : '\nNo reviews yet.'}`
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** One review just given. */
|
|
230
|
+
export function describeReviewed (r) {
|
|
231
|
+
const x = r.review
|
|
232
|
+
const what = { approve: 'Approved', request_changes: 'Asked for changes on', dismiss: 'Dismissed your verdict on', comment: 'Commented on' }[x.kind]
|
|
233
|
+
return `${what} ${x.square}${x.path ? ` at ${x.path}${x.line ? `:${x.line}` : ''}` : ''}. ${describeStanding(r)}.`
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** What a line needs to take a merge. */
|
|
237
|
+
export function describePolicy (p) {
|
|
238
|
+
const parts = []
|
|
239
|
+
if (p.approvals) parts.push(`${p.approvals} approval${p.approvals === 1 ? '' : 's'} of a square's latest commit from someone other than its holder`)
|
|
240
|
+
if (p.checks?.length) parts.push(`check${p.checks.length === 1 ? '' : 's'} ${p.checks.join(', ')} green on it`)
|
|
241
|
+
return parts.length ? `${p.line} needs ${parts.join(' and ')} to take a merge.` : `${p.line} takes merges without approvals or checks.`
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** A check reported on a square. */
|
|
245
|
+
export function describeSquareCheck (r) {
|
|
246
|
+
const req = r.checks.filter((c) => c.required)
|
|
247
|
+
const left = req.filter((c) => c.status !== 'green').map((c) => `${c.name} ${c.status}`)
|
|
248
|
+
return `${r.check} is ${r.status} on ${r.square} (${r.head.slice(0, 12)}).${req.length ? (left.length ? ` Its line still needs: ${left.join(', ')}.` : ' Every check its line requires is green.') : ''}`
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const SIGNATURE_WORDS = { good: 'Good signature', bad: 'BAD signature', removed_key: 'Signature by a removed key', none: 'No signature' }
|
|
252
|
+
export function describeSignature (s) {
|
|
253
|
+
return `Signed: ${SIGNATURE_WORDS[s.status] || s.status}${s.key ? ` from ${s.signer} (${s.key})` : ''}`
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export function describeKeys (r) {
|
|
257
|
+
if (!r.keys.length) return `${r.member} has no signing keys.`
|
|
258
|
+
return r.keys.map((k) => `${k.key}${k.name ? ` ${k.name}` : ''}${k.removed_at ? ` (removed ${when(k.removed_at)})` : ` added ${when(k.added_at)}`}`).join('\n')
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export function describeTokens (r) {
|
|
262
|
+
if (!r.tokens.length) return `${r.quilt} has no tokens. Make one with seam token new <name> [--level read|write].`
|
|
263
|
+
return r.tokens.map((t) => `${t.id} ${t.name} ${t.level}${t.revoked_at ? ` (revoked ${when(t.revoked_at)})` : ` made ${when(t.created_at)} by ${t.created_by}`}`).join('\n')
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export const describeNewToken = (t) => `Made token ${t.name} (${t.level} on ${t.quilt}, id ${t.id}). Its secret, shown only this once:\n\n ${t.token}\n\nSet it as SEAM_TOKEN (or send it as Authorization: Bearer <token>).`
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// What both the server and the seam client need without loading the server (service.js).
|
|
2
|
+
export class QuiltError extends Error {
|
|
3
|
+
/** `code` is the wire name (04/07/12): head_moved, tip_moved, conflict, not_found, ... */
|
|
4
|
+
constructor (status, code, message, extra = {}) { super(message); this.status = status; this.code = code; this.extra = extra }
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/** A square's short handle: sq: + the last 8 hex of its uuid (02). */
|
|
8
|
+
export const squareShort = (id) => `sq:${String(id).replace(/-/g, '').slice(-8)}`
|
package/lib/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/lib/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) {
|
package/lib/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}$/
|
package/lib/quilts/records.js
CHANGED
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
import { encode, decode } from './cbor.js'
|
|
6
6
|
import { idOf, isId, idBytes } from './hash.js'
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
// gitlink: a git submodule, kept as git has it: its blob is the submodule commit's sha as text.
|
|
9
|
+
export const MODES = ['file', 'exec', 'symlink', 'quilt', 'gitlink']
|
|
9
10
|
export const TERM_KINDS = ['content', 'order', 'edit_vs_delete', 'name', 'mode', 'square_divergence']
|
|
10
11
|
export const MAX_FILES_PER_COMMIT = 20000
|
|
11
12
|
export const MAX_MESSAGE = 64 * 1024
|