@heyquilt/seam 0.0.0-stage → 0.1.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/README.md +25 -2
- package/bin/seam.js +80 -0
- package/lib/account.js +120 -0
- package/lib/quilts/block.js +228 -0
- package/lib/quilts/cbor.js +92 -0
- package/lib/quilts/cli.js +254 -0
- package/lib/quilts/client.js +92 -0
- package/lib/quilts/describe.js +120 -0
- package/lib/quilts/export-git.js +126 -0
- package/lib/quilts/hash.js +44 -0
- package/lib/quilts/ids.js +12 -0
- package/lib/quilts/import-git.js +193 -0
- package/lib/quilts/pack.js +118 -0
- package/lib/quilts/records.js +210 -0
- package/lib/quilts/textdiff.js +96 -0
- package/lib/quilts/tools.js +201 -0
- package/lib/quilts/xdiff.js +435 -0
- package/package.json +27 -4
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Ids shared by the quilt server and seam's client (the git import makes its squares' ids).
|
|
2
|
+
import crypto from 'node:crypto'
|
|
3
|
+
|
|
4
|
+
/** A UUIDv7: 48 bits of milliseconds, then random (README §3.3: time-sortable, never collides). */
|
|
5
|
+
export function uuidv7 (ms = Date.now()) {
|
|
6
|
+
const b = crypto.randomBytes(16)
|
|
7
|
+
b.writeUIntBE(ms, 0, 6)
|
|
8
|
+
b[6] = (b[6] & 0x0f) | 0x70
|
|
9
|
+
b[8] = (b[8] & 0x3f) | 0x80
|
|
10
|
+
const h = b.toString('hex')
|
|
11
|
+
return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`
|
|
12
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// The local driver of a git import (plans/quilt-source-control/14): walks a repository on this
|
|
2
|
+
// machine with `git cat-file --batch`, hashes blobs with BLAKE3, uploads the missing ones in
|
|
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").
|
|
6
|
+
import { spawn, execFileSync } from 'node:child_process'
|
|
7
|
+
import { cleanCommit, commitId, commitBytes, blobId } from './records.js'
|
|
8
|
+
import { uuidv7 } from './ids.js'
|
|
9
|
+
|
|
10
|
+
const GIT_ENV = { ...process.env, GIT_NO_REPLACE_OBJECTS: '1' }
|
|
11
|
+
const MODES = { 100644: 'file', 100755: 'exec', 120000: 'symlink', 100664: 'file' }
|
|
12
|
+
|
|
13
|
+
/** A long-lived `git cat-file --batch`: get(sha) → { type, bytes }. */
|
|
14
|
+
function catFile (repo) {
|
|
15
|
+
const p = spawn('git', ['-C', repo, 'cat-file', '--batch'], { env: GIT_ENV, stdio: ['pipe', 'pipe', 'inherit'] })
|
|
16
|
+
let buf = Buffer.alloc(0)
|
|
17
|
+
const waiting = []
|
|
18
|
+
const pump = () => {
|
|
19
|
+
while (waiting.length) {
|
|
20
|
+
const nl = buf.indexOf(10)
|
|
21
|
+
if (nl < 0) return
|
|
22
|
+
const head = buf.subarray(0, nl).toString()
|
|
23
|
+
const [sha, type, size] = head.split(' ')
|
|
24
|
+
if (type === 'missing') { buf = buf.subarray(nl + 1); waiting.shift().reject(new Error(`git object ${sha} is missing`)); continue }
|
|
25
|
+
const n = Number(size)
|
|
26
|
+
if (buf.length < nl + 1 + n + 1) return
|
|
27
|
+
const bytes = Buffer.from(buf.subarray(nl + 1, nl + 1 + n))
|
|
28
|
+
buf = buf.subarray(nl + 1 + n + 1)
|
|
29
|
+
waiting.shift().resolve({ type, bytes })
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
p.stdout.on('data', (d) => { buf = buf.length ? Buffer.concat([buf, d]) : d; pump() })
|
|
33
|
+
return {
|
|
34
|
+
get (sha) { return new Promise((resolve, reject) => { waiting.push({ resolve, reject }); p.stdin.write(sha + '\n') }) },
|
|
35
|
+
close () { p.stdin.end(); p.kill() }
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A git commit object's fields. */
|
|
40
|
+
function parseCommit (bytes) {
|
|
41
|
+
const s = bytes.toString('utf8')
|
|
42
|
+
const end = s.indexOf('\n\n')
|
|
43
|
+
const headers = s.slice(0, end < 0 ? s.length : end).split('\n')
|
|
44
|
+
const message = end < 0 ? '' : s.slice(end + 2)
|
|
45
|
+
const out = { parents: [], message }
|
|
46
|
+
for (const h of headers) {
|
|
47
|
+
if (h.startsWith('parent ')) out.parents.push(h.slice(7))
|
|
48
|
+
else if (h.startsWith('tree ')) out.tree = h.slice(5)
|
|
49
|
+
else if (h.startsWith('author ') || h.startsWith('committer ')) {
|
|
50
|
+
const m = h.match(/^(\w+) (.*) <([^>]*)> (\d+) ([+-]\d{4})$/)
|
|
51
|
+
if (m) out[m[1]] = { name: m[2], email: m[3], at: Number(m[4]) * 1000, tz: m[5] }
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return out
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function git (repo, args) {
|
|
58
|
+
return execFileSync('git', ['-C', repo, ...args], { env: GIT_ENV, maxBuffer: 1024 * 1024 * 1024 })
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The changes of `sha` against its first parent: [{ path, mode, sha } | { path, delete }]. */
|
|
62
|
+
function diffTree (repo, parent, sha) {
|
|
63
|
+
const out = git(repo, parent ? ['diff-tree', '-r', '--no-renames', '--no-commit-id', '-z', '--raw', parent, sha] : ['diff-tree', '-r', '--no-renames', '--no-commit-id', '-z', '--raw', '--root', sha])
|
|
64
|
+
const parts = out.toString('utf8').split('\0')
|
|
65
|
+
const changes = []
|
|
66
|
+
for (let i = 0; i + 1 < parts.length; i += 2) {
|
|
67
|
+
const meta = parts[i]
|
|
68
|
+
if (!meta.startsWith(':')) { i--; continue }
|
|
69
|
+
const [, newMode, , newSha, status] = meta.slice(1).split(' ')
|
|
70
|
+
const path = parts[i + 1]
|
|
71
|
+
if (status === 'D') changes.push({ path, delete: true })
|
|
72
|
+
else changes.push({ path, gitMode: newMode, sha: newSha })
|
|
73
|
+
}
|
|
74
|
+
return changes
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const agentEmail = /@agents\.noreply\.heyquilt\.com$/i
|
|
78
|
+
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
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Imports the history of HEAD in `repo` into a new quilt `slug`. Returns { quilt, slug,
|
|
82
|
+
* commits, patches, report, validated? }.
|
|
83
|
+
*/
|
|
84
|
+
export async function importGit (client, { repo, slug, batch = 100, log = () => {}, validate = false, limit = 0 }) {
|
|
85
|
+
const head = git(repo, ['rev-parse', 'HEAD']).toString().trim()
|
|
86
|
+
const all = git(repo, ['rev-list', head]).toString().split('\n').filter(Boolean)
|
|
87
|
+
const cat = catFile(repo)
|
|
88
|
+
const report = []
|
|
89
|
+
try {
|
|
90
|
+
// Parents and fields of every commit, read once.
|
|
91
|
+
const info = new Map()
|
|
92
|
+
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}…`)
|
|
101
|
+
const ids = new Map() // git sha → quilt commit id
|
|
102
|
+
const blobs = new Map() // git blob sha → { id, size }
|
|
103
|
+
const assigned = new Set()
|
|
104
|
+
const items = []
|
|
105
|
+
let pending = []
|
|
106
|
+
let commits = 0; let patches = 0
|
|
107
|
+
|
|
108
|
+
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))
|
|
112
|
+
pending = []
|
|
113
|
+
commits += r.commits; patches += r.patches
|
|
114
|
+
log(` ${patches} patches, ${commits} commits`)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const emit = async (sha, square, kind) => {
|
|
118
|
+
const g = info.get(sha)
|
|
119
|
+
const changes = []
|
|
120
|
+
for (const ch of diffTree(repo, g.parents[0] || null, sha)) {
|
|
121
|
+
if (ch.delete) { changes.push({ path: ch.path, op: 'delete' }); continue }
|
|
122
|
+
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
|
+
}
|
|
131
|
+
changes.push({ path: ch.path, op: 'put', blob: b.id, mode, size: b.size })
|
|
132
|
+
}
|
|
133
|
+
const c = cleanCommit({
|
|
134
|
+
quilt_id: quilt.id, square_id: square.id,
|
|
135
|
+
parents: g.parents.filter((p) => ids.has(p)).map((p) => ids.get(p)),
|
|
136
|
+
author: member(g.author || {}), message: g.message, authored_at: g.author?.at || 0, changes
|
|
137
|
+
})
|
|
138
|
+
const id = commitId(c)
|
|
139
|
+
ids.set(sha, id)
|
|
140
|
+
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 || '' } })
|
|
142
|
+
if (items.length >= batch) await flush()
|
|
143
|
+
}
|
|
144
|
+
|
|
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') }
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
assigned.add(sha)
|
|
167
|
+
await emit(sha, square, 'patch')
|
|
168
|
+
}
|
|
169
|
+
await flush()
|
|
170
|
+
|
|
171
|
+
let validated
|
|
172
|
+
if (validate) {
|
|
173
|
+
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) {
|
|
176
|
+
const want = new Map()
|
|
177
|
+
for (const row of git(repo, ['ls-tree', '-r', '-z', sha]).toString('utf8').split('\0').filter(Boolean)) {
|
|
178
|
+
const [meta, path] = row.split('\t')
|
|
179
|
+
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 })
|
|
182
|
+
}
|
|
183
|
+
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 })
|
|
185
|
+
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
|
+
validated++
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return { quilt: quilt.id, slug: quilt.slug, commits, patches, report, ...(validate ? { validated } : {}), tip: ids.get(chain[chain.length - 1]) }
|
|
190
|
+
} finally {
|
|
191
|
+
cat.close()
|
|
192
|
+
}
|
|
193
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// Packs (plans/quilt-source-control/03, "Pack format"): the immutable upload container for a
|
|
2
|
+
// commit's objects. Magic `QPK1`, then entries { kind u8, id [32], len u32, bytes }, then a
|
|
3
|
+
// trailer { entry_count u32, index [entry], trailer_len u32 }. Each index entry is
|
|
4
|
+
// (kind u8, id [32], offset u32, len u32, flags u8): offset and len name the stored bytes of
|
|
5
|
+
// the entry, so a reader holding a row does one ranged read. The pack id is the BLAKE3 of the
|
|
6
|
+
// trailer, so it names every entry's location and id.
|
|
7
|
+
import zlib from 'node:zlib'
|
|
8
|
+
import { idOf } from './hash.js'
|
|
9
|
+
|
|
10
|
+
export const MAGIC = Buffer.from('QPK1')
|
|
11
|
+
export const MAX_PACK = 64 * 1024 * 1024
|
|
12
|
+
|
|
13
|
+
/** Entry kinds, and the id context each one's (decompressed) bytes hash under. */
|
|
14
|
+
export const KIND = { blob: 1, delta: 2, chunked: 3, chunk: 4, manifest: 5, tree: 6, term: 7, commit: 8 }
|
|
15
|
+
const NAME = Object.fromEntries(Object.entries(KIND).map(([k, v]) => [v, k]))
|
|
16
|
+
const CONTEXT = { blob: 'blob', delta: 'blob', chunked: 'blob', chunk: 'chunk', manifest: 'manifest', tree: 'tree', term: 'term', commit: 'commit' }
|
|
17
|
+
|
|
18
|
+
// Compression, in the entry's flags. zstd when this Node has it (≥ 22.15), else deflate.
|
|
19
|
+
export const RAW = 0
|
|
20
|
+
export const DEFLATE = 1
|
|
21
|
+
export const ZSTD = 2
|
|
22
|
+
const HAS_ZSTD = typeof zlib.zstdCompressSync === 'function'
|
|
23
|
+
|
|
24
|
+
function squeeze (bytes) {
|
|
25
|
+
if (bytes.length < 64) return { flags: RAW, data: bytes }
|
|
26
|
+
const data = HAS_ZSTD ? zlib.zstdCompressSync(bytes) : zlib.deflateRawSync(bytes, { level: 6 })
|
|
27
|
+
// Stored raw unless compression saves at least 5% (03's rule for chunks, used for everything).
|
|
28
|
+
if (data.length > bytes.length * 0.95) return { flags: RAW, data: bytes }
|
|
29
|
+
return { flags: HAS_ZSTD ? ZSTD : DEFLATE, data }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The bytes of a stored entry, decompressed by its flags. */
|
|
33
|
+
export function unsqueeze (flags, data) {
|
|
34
|
+
if (flags === RAW) return Buffer.from(data)
|
|
35
|
+
if (flags === DEFLATE) return zlib.inflateRawSync(data)
|
|
36
|
+
if (flags === ZSTD) {
|
|
37
|
+
if (!HAS_ZSTD) throw new Error('this pack entry is zstd and this Node cannot read zstd (needs Node 22.15 or later)')
|
|
38
|
+
return zlib.zstdDecompressSync(data)
|
|
39
|
+
}
|
|
40
|
+
throw new Error(`unknown pack entry flags ${flags}`)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const ENTRY_HEAD = 1 + 32 + 4
|
|
44
|
+
const INDEX_ENTRY = 1 + 32 + 4 + 4 + 1
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Builds a pack from [{ kind: 'blob' | 'tree' | 'term' | 'commit' | ..., id, bytes }]
|
|
48
|
+
* (duplicate ids are written once). Returns { id, bytes, index: [{ kind, id, offset, len, flags }] }.
|
|
49
|
+
*/
|
|
50
|
+
export function buildPack (objects) {
|
|
51
|
+
const parts = [MAGIC]
|
|
52
|
+
let at = MAGIC.length
|
|
53
|
+
const index = []
|
|
54
|
+
const seen = new Set()
|
|
55
|
+
for (const o of objects) {
|
|
56
|
+
if (seen.has(o.id)) continue
|
|
57
|
+
seen.add(o.id)
|
|
58
|
+
const kind = KIND[o.kind]
|
|
59
|
+
if (!kind) throw new Error(`unknown pack entry kind ${o.kind}`)
|
|
60
|
+
const { flags, data } = squeeze(Buffer.from(o.bytes))
|
|
61
|
+
const h = Buffer.alloc(ENTRY_HEAD)
|
|
62
|
+
h[0] = kind; Buffer.from(o.id, 'hex').copy(h, 1); h.writeUInt32BE(data.length, 33)
|
|
63
|
+
parts.push(h, data)
|
|
64
|
+
index.push({ kind: o.kind, id: o.id, offset: at + ENTRY_HEAD, len: data.length, flags })
|
|
65
|
+
at += ENTRY_HEAD + data.length
|
|
66
|
+
}
|
|
67
|
+
const trailer = trailerBytes(index)
|
|
68
|
+
parts.push(trailer)
|
|
69
|
+
const bytes = Buffer.concat(parts)
|
|
70
|
+
if (bytes.length > MAX_PACK) throw Object.assign(new Error(`pack over 64 MiB (${bytes.length} bytes); split the commit`), { code: 'pack_too_large' })
|
|
71
|
+
return { id: idOf('pack', trailer), bytes, index }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function trailerBytes (index) {
|
|
75
|
+
const t = Buffer.alloc(4 + index.length * INDEX_ENTRY + 4)
|
|
76
|
+
t.writeUInt32BE(index.length, 0)
|
|
77
|
+
let p = 4
|
|
78
|
+
for (const e of index) {
|
|
79
|
+
t[p] = KIND[e.kind]; Buffer.from(e.id, 'hex').copy(t, p + 1)
|
|
80
|
+
t.writeUInt32BE(e.offset, p + 33); t.writeUInt32BE(e.len, p + 37); t[p + 41] = e.flags
|
|
81
|
+
p += INDEX_ENTRY
|
|
82
|
+
}
|
|
83
|
+
t.writeUInt32BE(t.length, p)
|
|
84
|
+
return t
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The index of a whole pack, read from its trailer, and the pack id it implies. */
|
|
88
|
+
export function readIndex (bytes) {
|
|
89
|
+
const b = Buffer.from(bytes)
|
|
90
|
+
if (!b.subarray(0, 4).equals(MAGIC)) throw new Error('not a Quilt pack')
|
|
91
|
+
const tlen = b.readUInt32BE(b.length - 4)
|
|
92
|
+
const t = b.subarray(b.length - tlen)
|
|
93
|
+
const n = t.readUInt32BE(0)
|
|
94
|
+
if (tlen !== 4 + n * INDEX_ENTRY + 4) throw new Error('pack trailer is damaged')
|
|
95
|
+
const index = []
|
|
96
|
+
for (let i = 0, p = 4; i < n; i++, p += INDEX_ENTRY) {
|
|
97
|
+
index.push({ kind: NAME[t[p]], id: t.subarray(p + 1, p + 33).toString('hex'), offset: t.readUInt32BE(p + 33), len: t.readUInt32BE(p + 37), flags: t[p + 41] })
|
|
98
|
+
}
|
|
99
|
+
return { id: idOf('pack', t), index }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The object `e` (an index entry) of a pack, from its stored bytes, checked against its id. */
|
|
103
|
+
export function readEntry (e, stored) {
|
|
104
|
+
const bytes = unsqueeze(e.flags, stored)
|
|
105
|
+
if (idOf(CONTEXT[e.kind], bytes) !== e.id) throw Object.assign(new Error(`pack entry ${e.id} does not match its id`), { code: 'pack_corrupt', id: e.id })
|
|
106
|
+
return bytes
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* What sealing checks before any row is written (03, "Sealing a pack"): the pack id is the
|
|
111
|
+
* trailer's, and every entry decompresses to bytes whose id is the one claimed. Returns the index.
|
|
112
|
+
*/
|
|
113
|
+
export function verifyPack (bytes, expectId) {
|
|
114
|
+
const { id, index } = readIndex(bytes)
|
|
115
|
+
if (expectId && id !== expectId) throw Object.assign(new Error('pack id does not match its trailer'), { code: 'pack_corrupt' })
|
|
116
|
+
for (const e of index) readEntry(e, bytes.subarray(e.offset, e.offset + e.len))
|
|
117
|
+
return { id, index }
|
|
118
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
// The immutable records of Quilt source control (plans/quilt-source-control/README §3.1):
|
|
2
|
+
// commits, their file changes and conflict terms, as JSON for the API and as canonical CBOR
|
|
3
|
+
// for their ids. A commit's id depends only on what its author made: no tree, no server time,
|
|
4
|
+
// no sequence number, no git hash.
|
|
5
|
+
import { encode, decode } from './cbor.js'
|
|
6
|
+
import { idOf, isId, idBytes } from './hash.js'
|
|
7
|
+
|
|
8
|
+
export const MODES = ['file', 'exec', 'symlink', 'quilt']
|
|
9
|
+
export const TERM_KINDS = ['content', 'order', 'edit_vs_delete', 'name', 'mode', 'square_divergence']
|
|
10
|
+
export const MAX_FILES_PER_COMMIT = 20000
|
|
11
|
+
export const MAX_MESSAGE = 64 * 1024
|
|
12
|
+
|
|
13
|
+
const hex = (b) => Buffer.from(b).toString('hex')
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* A path as the quilt keeps it: NFC, posix, relative, no empty, '.' or '..' segments, no
|
|
17
|
+
* trailing slash, never inside .git. Throws on anything else.
|
|
18
|
+
*/
|
|
19
|
+
export function cleanPath (p) {
|
|
20
|
+
if (typeof p !== 'string' || !p) throw new Error('a path is required')
|
|
21
|
+
const s = p.normalize('NFC')
|
|
22
|
+
if (s.includes('\\') || s.includes('\0')) throw new Error(`bad path ${JSON.stringify(p)}: use / and no NUL`)
|
|
23
|
+
const parts = s.split('/')
|
|
24
|
+
if (parts.some((x) => !x || x === '.' || x === '..')) throw new Error(`bad path ${JSON.stringify(p)}`)
|
|
25
|
+
if (parts[0] === '.git') throw new Error(`bad path ${JSON.stringify(p)}: .git is not part of a quilt`)
|
|
26
|
+
return s
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** A path prefix (a block's directory): '' for the root, else a clean path. */
|
|
30
|
+
export function cleanPrefix (p) {
|
|
31
|
+
const s = String(p || '').replace(/^\/+|\/+$/g, '')
|
|
32
|
+
return s ? cleanPath(s) : ''
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Whether `path` lies under `prefix` (a directory, or the file itself). */
|
|
36
|
+
export const under = (path, prefix) => !prefix || path === prefix || path.startsWith(prefix + '/')
|
|
37
|
+
|
|
38
|
+
function side (s) {
|
|
39
|
+
return {
|
|
40
|
+
...(s.blob ? { b: idBytes(s.blob) } : {}),
|
|
41
|
+
...(s.mode ? { m: s.mode } : {}),
|
|
42
|
+
...(s.from_commit ? { fc: idBytes(s.from_commit) } : {}),
|
|
43
|
+
...(s.from_square ? { fs: s.from_square } : {}),
|
|
44
|
+
...(s.by ? { by: s.by } : {})
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
const unside = (s) => ({
|
|
48
|
+
...(s.b ? { blob: hex(s.b) } : {}),
|
|
49
|
+
...(s.m ? { mode: s.m } : {}),
|
|
50
|
+
...(s.fc ? { from_commit: hex(s.fc) } : {}),
|
|
51
|
+
...(s.fs ? { from_square: s.fs } : {}),
|
|
52
|
+
...(s.by ? { by: s.by } : {})
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
/** A conflict term's canonical bytes (kind, N adds, N−1 removes, origin). */
|
|
56
|
+
export function termBytes (t) {
|
|
57
|
+
if (!TERM_KINDS.includes(t.kind)) throw new Error(`bad conflict kind ${t.kind}`)
|
|
58
|
+
if (!Array.isArray(t.adds) || !Array.isArray(t.removes) || t.adds.length !== t.removes.length + 1) throw new Error('a conflict term has N adds and N-1 removes')
|
|
59
|
+
return encode({
|
|
60
|
+
k: t.kind,
|
|
61
|
+
ad: t.adds.map(side),
|
|
62
|
+
rm: t.removes.map(side),
|
|
63
|
+
...(t.origin ? { or: { r: t.origin.reason, ...(t.origin.against ? { ag: idBytes(t.origin.against) } : {}) } } : {}),
|
|
64
|
+
// 07's suggestion: a ladder step that may not merge by itself (`words`) and the text it made.
|
|
65
|
+
...(t.suggestion ? { sg: { h: t.suggestion.how, b: idBytes(t.suggestion.blob) } } : {}),
|
|
66
|
+
// A name term's other paths (a file on a directory, or one case-folded name): kept side wins the name.
|
|
67
|
+
...(t.clash && t.clash.length ? { cl: [...t.clash].sort() } : {})
|
|
68
|
+
})
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function decodeTerm (bytes) {
|
|
72
|
+
const r = decode(bytes)
|
|
73
|
+
return {
|
|
74
|
+
kind: r.k,
|
|
75
|
+
adds: r.ad.map(unside),
|
|
76
|
+
removes: r.rm.map(unside),
|
|
77
|
+
...(r.or ? { origin: { reason: r.or.r, ...(r.or.ag ? { against: hex(r.or.ag) } : {}) } } : {}),
|
|
78
|
+
...(r.sg ? { suggestion: { how: r.sg.h, blob: hex(r.sg.b) } } : {}),
|
|
79
|
+
...(r.cl ? { clash: r.cl } : {})
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export const termId = (t) => idOf('term', termBytes(t))
|
|
84
|
+
|
|
85
|
+
/** Checks and normalises one FileChange (JSON form). */
|
|
86
|
+
export function cleanChange (c) {
|
|
87
|
+
const path = cleanPath(c.path)
|
|
88
|
+
if (c.op === 'delete') return { path, op: 'delete' }
|
|
89
|
+
if (c.op === 'put') {
|
|
90
|
+
if (!isId(c.blob)) throw new Error(`${path}: put needs a blob id`)
|
|
91
|
+
const mode = c.mode || 'file'
|
|
92
|
+
if (!MODES.includes(mode)) throw new Error(`${path}: bad mode ${mode}`)
|
|
93
|
+
if (!Number.isSafeInteger(c.size) || c.size < 0) throw new Error(`${path}: put needs a size`)
|
|
94
|
+
const out = { path, op: 'put', blob: c.blob, mode, size: c.size }
|
|
95
|
+
if (c.copy_from) out.copy_from = { path: cleanPath(c.copy_from.path), commit: c.copy_from.commit }
|
|
96
|
+
return out
|
|
97
|
+
}
|
|
98
|
+
if (c.op === 'conflict') {
|
|
99
|
+
if (!isId(c.term)) throw new Error(`${path}: conflict needs a term id`)
|
|
100
|
+
return { path, op: 'conflict', term: c.term }
|
|
101
|
+
}
|
|
102
|
+
throw new Error(`${path}: bad op ${c.op}`)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Sorts and checks a commit's changes: by path (bytewise, as the tree sorts), no duplicates. */
|
|
106
|
+
export function cleanChanges (changes) {
|
|
107
|
+
if (!Array.isArray(changes)) throw new Error('changes must be a list')
|
|
108
|
+
if (changes.length > MAX_FILES_PER_COMMIT) throw Object.assign(new Error(`too many files in one commit (${changes.length}; at most ${MAX_FILES_PER_COMMIT})`), { code: 'too_many_files' })
|
|
109
|
+
const out = changes.map(cleanChange).sort((a, b) => cmpPath(a.path, b.path))
|
|
110
|
+
for (let i = 1; i < out.length; i++) if (out[i].path === out[i - 1].path) throw new Error(`${out[i].path} is changed twice`)
|
|
111
|
+
return out
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Bytewise order of UTF-8 paths, the order of every tree and change list. */
|
|
115
|
+
export function cmpPath (a, b) {
|
|
116
|
+
return a === b ? 0 : Buffer.compare(Buffer.from(a), Buffer.from(b))
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function changeCbor (c) {
|
|
120
|
+
if (c.op === 'delete') return { p: c.path, o: 'delete' }
|
|
121
|
+
if (c.op === 'conflict') return { p: c.path, o: 'conflict', t: idBytes(c.term) }
|
|
122
|
+
return {
|
|
123
|
+
p: c.path, o: 'put', b: idBytes(c.blob), m: c.mode, z: c.size,
|
|
124
|
+
...(c.copy_from ? { cf: { p: c.copy_from.path, c: idBytes(c.copy_from.commit) } } : {})
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
function unchange (c) {
|
|
128
|
+
if (c.o === 'delete') return { path: c.p, op: 'delete' }
|
|
129
|
+
if (c.o === 'conflict') return { path: c.p, op: 'conflict', term: hex(c.t) }
|
|
130
|
+
return { path: c.p, op: 'put', blob: hex(c.b), mode: c.m, size: c.z, ...(c.cf ? { copy_from: { path: c.cf.p, commit: hex(c.cf.c) } } : {}) }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* A commit in its JSON form, checked and normalised: { quilt_id, square_id, parents,
|
|
135
|
+
* predecessor?, author: { member_id, kind, name, tool?, model? }, on_behalf_of?, task_id?,
|
|
136
|
+
* session_room?, message, authored_at, changes, stack, conflicted }.
|
|
137
|
+
*/
|
|
138
|
+
export function cleanCommit (c) {
|
|
139
|
+
if (!c.quilt_id || !c.square_id) throw new Error('a commit names its quilt and square')
|
|
140
|
+
const parents = c.parents || []
|
|
141
|
+
if (!parents.every(isId)) throw new Error('parents must be commit ids')
|
|
142
|
+
if (c.predecessor != null && !isId(c.predecessor)) throw new Error('predecessor must be a commit id')
|
|
143
|
+
const a = c.author || {}
|
|
144
|
+
if (!a.member_id || !['person', 'agent', 'system'].includes(a.kind)) throw new Error('author needs member_id and kind')
|
|
145
|
+
const message = String(c.message ?? '')
|
|
146
|
+
if (Buffer.byteLength(message) > MAX_MESSAGE) throw new Error('commit message over 64 KiB')
|
|
147
|
+
const changes = cleanChanges(c.changes || [])
|
|
148
|
+
const stack = {}
|
|
149
|
+
for (const [k, v] of Object.entries(c.stack || {})) if (v != null) stack[k] = String(v)
|
|
150
|
+
return {
|
|
151
|
+
quilt_id: String(c.quilt_id),
|
|
152
|
+
square_id: String(c.square_id),
|
|
153
|
+
parents: [...parents],
|
|
154
|
+
...(c.predecessor ? { predecessor: c.predecessor } : {}),
|
|
155
|
+
author: { member_id: String(a.member_id), kind: a.kind, name: String(a.name || ''), ...(a.tool ? { tool: String(a.tool) } : {}), ...(a.model ? { model: String(a.model) } : {}) },
|
|
156
|
+
...(c.on_behalf_of ? { on_behalf_of: String(c.on_behalf_of) } : {}),
|
|
157
|
+
...(c.task_id ? { task_id: String(c.task_id) } : {}),
|
|
158
|
+
...(c.session_room ? { session_room: String(c.session_room) } : {}),
|
|
159
|
+
message,
|
|
160
|
+
authored_at: Math.trunc(Number(c.authored_at) || 0),
|
|
161
|
+
changes,
|
|
162
|
+
stack,
|
|
163
|
+
conflicted: changes.filter((x) => x.op === 'conflict').length
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** A commit's canonical bytes. */
|
|
168
|
+
export function commitBytes (c) {
|
|
169
|
+
const a = c.author
|
|
170
|
+
return encode({
|
|
171
|
+
q: c.quilt_id,
|
|
172
|
+
s: c.square_id,
|
|
173
|
+
p: c.parents.map(idBytes),
|
|
174
|
+
pr: c.predecessor ? idBytes(c.predecessor) : undefined,
|
|
175
|
+
a: { m: a.member_id, k: a.kind, n: a.name, t: a.tool, md: a.model },
|
|
176
|
+
ob: c.on_behalf_of,
|
|
177
|
+
tk: c.task_id,
|
|
178
|
+
sr: c.session_room,
|
|
179
|
+
msg: c.message,
|
|
180
|
+
at: c.authored_at,
|
|
181
|
+
c: c.changes.map(changeCbor),
|
|
182
|
+
st: Object.keys(c.stack).length ? c.stack : undefined,
|
|
183
|
+
cn: c.conflicted
|
|
184
|
+
})
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function decodeCommit (bytes) {
|
|
188
|
+
const r = decode(bytes)
|
|
189
|
+
return {
|
|
190
|
+
quilt_id: r.q,
|
|
191
|
+
square_id: r.s,
|
|
192
|
+
parents: r.p.map(hex),
|
|
193
|
+
...(r.pr ? { predecessor: hex(r.pr) } : {}),
|
|
194
|
+
author: { member_id: r.a.m, kind: r.a.k, name: r.a.n, ...(r.a.t ? { tool: r.a.t } : {}), ...(r.a.md ? { model: r.a.md } : {}) },
|
|
195
|
+
...(r.ob ? { on_behalf_of: r.ob } : {}),
|
|
196
|
+
...(r.tk ? { task_id: r.tk } : {}),
|
|
197
|
+
...(r.sr ? { session_room: r.sr } : {}),
|
|
198
|
+
message: r.msg,
|
|
199
|
+
authored_at: r.at,
|
|
200
|
+
changes: r.c.map(unchange),
|
|
201
|
+
stack: r.st || {},
|
|
202
|
+
conflicted: r.cn
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** The commit's id, from its canonical bytes. */
|
|
207
|
+
export const commitId = (c) => idOf('commit', commitBytes(c))
|
|
208
|
+
|
|
209
|
+
/** A blob's id: BLAKE3 of the raw file bytes under the blob context. */
|
|
210
|
+
export const blobId = (bytes) => idOf('blob', bytes)
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// Line diffs for quilts: unified text for `diff` and `show`, and the line mapping `blame` walks.
|
|
2
|
+
|
|
3
|
+
import { xdiff } from './xdiff.js'
|
|
4
|
+
|
|
5
|
+
const MAX_TEXT = 8 * 1024 * 1024
|
|
6
|
+
|
|
7
|
+
/** Whether bytes read as text: valid UTF-8, no NUL, at most 8 MiB (07's max_merge_text_bytes). */
|
|
8
|
+
export function isText (buf) {
|
|
9
|
+
if (!buf) return true
|
|
10
|
+
if (buf.length > MAX_TEXT || buf.includes(0)) return false
|
|
11
|
+
try { new TextDecoder('utf-8', { fatal: true }).decode(buf); return true } catch { return false }
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** A text's lines, split on \n, without the empty string after a final newline. */
|
|
15
|
+
export function splitLines (s) {
|
|
16
|
+
if (!s) return []
|
|
17
|
+
const ls = s.split('\n')
|
|
18
|
+
if (ls[ls.length - 1] === '') ls.pop()
|
|
19
|
+
return ls
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Lines back to bytes, each ended by \n (what `hunks` diffs). */
|
|
23
|
+
function linesBuf (ls) { return Buffer.from(ls.length ? ls.join('\n') + '\n' : '', 'utf8') }
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The hunks between two line lists: [{ a: [start, len], b: [start, len] }], chosen the way git's
|
|
27
|
+
* xdiff chooses them (its Myers variant, record cleanup, and sliding with the indent heuristic),
|
|
28
|
+
* so `diff`, `show` and `blame` agree with git's.
|
|
29
|
+
*/
|
|
30
|
+
export function hunks (a, b) {
|
|
31
|
+
return xdiff(linesBuf(a), linesBuf(b))
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** +added −removed counts between two texts. */
|
|
35
|
+
export function lineStat (a, b) {
|
|
36
|
+
let add = 0; let del = 0
|
|
37
|
+
for (const h of hunks(splitLines(a), splitLines(b))) { del += h.a[1]; add += h.b[1] }
|
|
38
|
+
return { add, del }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* A unified diff of one file (git's format, 3 lines of context). `before`/`after` are strings,
|
|
43
|
+
* or null when the file is added or deleted.
|
|
44
|
+
*/
|
|
45
|
+
export function unified (path, before, after, { context = 3, fromPath = path } = {}) {
|
|
46
|
+
const a = splitLines(before ?? '')
|
|
47
|
+
const b = splitLines(after ?? '')
|
|
48
|
+
const head = [`--- ${before == null ? '/dev/null' : `a/${fromPath}`}`, `+++ ${after == null ? '/dev/null' : `b/${path}`}`]
|
|
49
|
+
const hs = hunks(a, b)
|
|
50
|
+
if (!hs.length) return ''
|
|
51
|
+
const out = [...head]
|
|
52
|
+
// Group hunks whose context overlaps.
|
|
53
|
+
let i = 0
|
|
54
|
+
while (i < hs.length) {
|
|
55
|
+
let j = i
|
|
56
|
+
while (j + 1 < hs.length && hs[j + 1].a[0] - (hs[j].a[0] + hs[j].a[1]) <= context * 2) j++
|
|
57
|
+
const aStart = Math.max(0, hs[i].a[0] - context)
|
|
58
|
+
const aEnd = Math.min(a.length, hs[j].a[0] + hs[j].a[1] + context)
|
|
59
|
+
const bStart = Math.max(0, hs[i].b[0] - context)
|
|
60
|
+
const bEnd = Math.min(b.length, hs[j].b[0] + hs[j].b[1] + context)
|
|
61
|
+
const body = []
|
|
62
|
+
let ap = aStart
|
|
63
|
+
for (let k = i; k <= j; k++) {
|
|
64
|
+
const h = hs[k]
|
|
65
|
+
while (ap < h.a[0]) body.push(' ' + a[ap++])
|
|
66
|
+
for (let x = 0; x < h.a[1]; x++) body.push('-' + a[h.a[0] + x])
|
|
67
|
+
for (let x = 0; x < h.b[1]; x++) body.push('+' + b[h.b[0] + x])
|
|
68
|
+
ap = h.a[0] + h.a[1]
|
|
69
|
+
}
|
|
70
|
+
while (ap < aEnd) body.push(' ' + a[ap++])
|
|
71
|
+
const range = (s, n) => `${n === 0 ? s : s + 1}${n === 1 ? '' : `,${n}`}`
|
|
72
|
+
out.push(`@@ -${range(aStart, aEnd - aStart)} +${range(bStart, bEnd - bStart)} @@`, ...body)
|
|
73
|
+
i = j + 1
|
|
74
|
+
}
|
|
75
|
+
return out.join('\n') + '\n'
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* For each line of `b`, its line in `a` when the diff kept it, else -1 (it was added or
|
|
80
|
+
* changed in b). What blame follows from a commit back to its parent. Given Buffers (the files'
|
|
81
|
+
* bytes) it diffs exactly as `git blame` does, final newline and common-tail trim included.
|
|
82
|
+
*/
|
|
83
|
+
export function lineMap (a, b) {
|
|
84
|
+
const bytes = Buffer.isBuffer(a)
|
|
85
|
+
const hs = bytes ? xdiff(a, b, { trimTail: true }) : hunks(a, b)
|
|
86
|
+
const n = bytes ? splitLines(b.toString('utf8')).length : b.length
|
|
87
|
+
const map = new Array(n).fill(-1)
|
|
88
|
+
let ai = 0; let bi = 0
|
|
89
|
+
for (const h of hs) {
|
|
90
|
+
while (bi < h.b[0]) map[bi++] = ai++
|
|
91
|
+
ai = h.a[0] + h.a[1]
|
|
92
|
+
bi = h.b[0] + h.b[1]
|
|
93
|
+
}
|
|
94
|
+
while (bi < n) map[bi++] = ai++
|
|
95
|
+
return map
|
|
96
|
+
}
|