@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.
@@ -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)}`
@@ -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')
@@ -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) {
@@ -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}$/
@@ -5,7 +5,8 @@
5
5
  import { encode, decode } from './cbor.js'
6
6
  import { idOf, isId, idBytes } from './hash.js'
7
7
 
8
- export const MODES = ['file', 'exec', 'symlink', 'quilt']
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