uniweb 1.7.4 → 1.8.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.
@@ -0,0 +1,512 @@
1
+ /**
2
+ * Sync points — the site as the backend had it, kept as commits.
3
+ *
4
+ * ⭐ THE COMMON ANCESTOR OF A PULL'S MERGE IS A COMMIT. A three-way merge is only as good
5
+ * as its ancestor, and the ancestor of a pull is the last state of the site this history
6
+ * shares with the backend. Kept as a commit it travels with the history — a teammate, a
7
+ * fresh clone, CI all have it — and git merges against it whole: what was added, deleted
8
+ * or renamed on either side, not just what changed in a file both sides hold.
9
+ *
10
+ * A sync point is one of:
11
+ * · a commit a PULL made — the backend's content written onto the last sync point's
12
+ * tree, marked by the trailers below, and merged into the branch, so it is shared;
13
+ * · the commit a PUSH was made from — what the backend then holds. This copy keeps it
14
+ * (`backend-cache.json`, `syncPoint`), and the backend can name it on a pull
15
+ * (`x-uniweb-source`, `backend/client.js`), for every copy that has it.
16
+ *
17
+ * ⚖️ Any sync point a history holds is a safe base: both the files and the backend's
18
+ * content descend from it, so neither side's change can read as the other's undoing. The
19
+ * newest one is the precise base — an older one costs conflicts, never content.
20
+ *
21
+ * ⛔ Until 2026-10-17 the ancestor was a per-machine list of the files this copy's last pull
22
+ * wrote, by hash, found again in history by content — and HEAD where the list had no
23
+ * entry. Committed work outside the list was lost: a teammate's new section pruned, a
24
+ * committed record edit overwritten, a fresh clone's committed work reverted, and the
25
+ * committed half of a file edited again since reverted (`test/pull-merge-base.test.js`).
26
+ *
27
+ * Dependency-free: `clone` uses it before a project's packages are installed.
28
+ */
29
+
30
+ import { execFileSync } from 'node:child_process'
31
+ import {
32
+ copyFileSync,
33
+ cpSync,
34
+ existsSync,
35
+ lstatSync,
36
+ mkdirSync,
37
+ mkdtempSync,
38
+ readdirSync,
39
+ readFileSync,
40
+ rmSync,
41
+ statSync,
42
+ symlinkSync,
43
+ unlinkSync,
44
+ writeFileSync
45
+ } from 'node:fs'
46
+ import { tmpdir } from 'node:os'
47
+ import { join, dirname } from 'node:path'
48
+
49
+ export const SYNC_TRAILER = 'Uniweb-Sync'
50
+ export const SITE_TRAILER = 'Uniweb-Site'
51
+
52
+ const MAX_BUFFER = 1024 * 1024 * 1024
53
+
54
+ function git(cwd, args, { env, input } = {}) {
55
+ return execFileSync('git', args, {
56
+ cwd,
57
+ env: env ? { ...process.env, ...env } : process.env,
58
+ input,
59
+ maxBuffer: MAX_BUFFER,
60
+ stdio: [input == null ? 'ignore' : 'pipe', 'pipe', 'pipe']
61
+ })
62
+ }
63
+ const gitText = (cwd, args, opts) => git(cwd, args, opts).toString('utf8')
64
+
65
+ /** git's own words when a command fails — what it printed, not our paraphrase. */
66
+ export function gitError(err) {
67
+ const said = [err?.stderr, err?.stdout].map((b) => (b ? String(b).trim() : '')).filter(Boolean)
68
+ return said.join('\n') || err?.message || 'git failed'
69
+ }
70
+
71
+ /**
72
+ * The repository a site is in — its top level, the site's path in it, the git
73
+ * directory and HEAD (null on a branch with no commits) — or null outside one.
74
+ */
75
+ export function repoOf(siteDir) {
76
+ let top, prefix, gitDir
77
+ try {
78
+ top = gitText(siteDir, ['rev-parse', '--show-toplevel']).trim()
79
+ prefix = gitText(siteDir, ['rev-parse', '--show-prefix']).trim()
80
+ gitDir = gitText(siteDir, ['rev-parse', '--absolute-git-dir']).trim()
81
+ } catch {
82
+ return null
83
+ }
84
+ if (!top) return null
85
+ let head = null
86
+ try {
87
+ head = gitText(top, ['rev-parse', '--verify', '-q', 'HEAD^{commit}']).trim() || null
88
+ } catch {
89
+ head = null
90
+ }
91
+ return { top, prefix, path: prefix ? prefix.replace(/\/$/, '') : '.', gitDir, head }
92
+ }
93
+
94
+ /** Is `ancestor` in the history of `of` — or `of` itself? */
95
+ export function isAncestor(repo, ancestor, of = 'HEAD') {
96
+ try {
97
+ git(repo.top, ['merge-base', '--is-ancestor', ancestor, of])
98
+ return true
99
+ } catch {
100
+ return false
101
+ }
102
+ }
103
+
104
+ function isCommit(repo, sha) {
105
+ if (typeof sha !== 'string' || !/^[0-9a-f]{7,64}$/i.test(sha)) return false
106
+ try {
107
+ git(repo.top, ['cat-file', '-e', `${sha}^{commit}`])
108
+ return true
109
+ } catch {
110
+ return false
111
+ }
112
+ }
113
+
114
+ /** The tree of the site's directory in a commit or tree, or null. */
115
+ export function siteTreeIn(repo, rev) {
116
+ try {
117
+ return gitText(repo.top, ['rev-parse', '--verify', '-q', repo.path === '.' ? `${rev}^{tree}` : `${rev}:${repo.path}`]).trim() || null
118
+ } catch {
119
+ return null
120
+ }
121
+ }
122
+
123
+ /** Is a merge waiting for its commit here? */
124
+ export function mergeInProgress(repo) {
125
+ try {
126
+ git(repo.top, ['rev-parse', '--verify', '-q', 'MERGE_HEAD'])
127
+ return true
128
+ } catch {
129
+ return false
130
+ }
131
+ }
132
+
133
+ function trailersOf(body) {
134
+ const out = {}
135
+ for (const line of String(body).split('\n')) {
136
+ let m = line.match(/^Uniweb-Sync: (\S+) (\S+)\s*$/)
137
+ if (m) {
138
+ out.origin = m[1]
139
+ out.siteUuid = m[2]
140
+ continue
141
+ }
142
+ m = line.match(/^Uniweb-Site: (.+):([0-9a-f]{40,64})\s*$/)
143
+ if (m) {
144
+ out.path = m[1]
145
+ out.tree = m[2]
146
+ }
147
+ }
148
+ return out
149
+ }
150
+
151
+ /**
152
+ * The newest commit a pull made for this site on this backend that HEAD's history
153
+ * holds — and that still holds what that pull wrote. ⛔ A rebase or a cherry-pick copies
154
+ * the trailers onto a commit whose tree is no longer the backend's, so each one is
155
+ * checked against the site tree it names.
156
+ */
157
+ export function historySyncPoint(repo, { origin, siteUuid }) {
158
+ if (!repo?.head) return null
159
+ let out
160
+ try {
161
+ out = gitText(repo.top, [
162
+ 'log',
163
+ '--fixed-strings',
164
+ `--grep=${SYNC_TRAILER}: ${origin} ${siteUuid}`,
165
+ '--format=%H%x1f%B%x1e',
166
+ 'HEAD'
167
+ ])
168
+ } catch {
169
+ return null
170
+ }
171
+ for (const record of out.split('\x1e')) {
172
+ const [sha = '', body = ''] = record.replace(/^\s+/, '').split('\x1f')
173
+ if (!/^[0-9a-f]{40,64}$/.test(sha)) continue
174
+ const t = trailersOf(body)
175
+ if (t.origin !== origin || t.siteUuid !== siteUuid || t.path !== repo.path) continue
176
+ if (siteTreeIn(repo, sha) === t.tree) return sha
177
+ }
178
+ return null
179
+ }
180
+
181
+ /**
182
+ * The base a pull merges from: the newest of the candidates HEAD descends from. One that
183
+ * descends from all the others is the newest; with none, the most recently committed —
184
+ * any of them is a state both sides descend from, so the choice costs precision at worst.
185
+ */
186
+ export function newestBase(repo, candidates) {
187
+ const shas = [...new Set(candidates.filter(Boolean))].filter((s) => isCommit(repo, s) && isAncestor(repo, s))
188
+ if (shas.length <= 1) return shas[0] || null
189
+ const newest = shas.find((s) => shas.every((o) => o === s || isAncestor(repo, o, s)))
190
+ if (newest) return newest
191
+ try {
192
+ return gitText(repo.top, ['rev-list', '--no-walk', ...shas]).split('\n')[0].trim() || shas[0]
193
+ } catch {
194
+ return shas[0]
195
+ }
196
+ }
197
+
198
+ // ── the stand-in ────────────────────────────────────────────────────────────
199
+
200
+ /**
201
+ * A stand-in for the repository, where the backend's content is written onto a commit's
202
+ * tree without touching the working tree.
203
+ *
204
+ * ⭐ It mirrors the repository by LINKS — the foundation, a workspace extension, the
205
+ * installed packages, anything resolved by relative path, resolves as in the project —
206
+ * except the site's content `roots`, which are real directories filled from `rev`, and the
207
+ * site's `sync.json` and `.uniweb/`, which are copies (written there, and taken back by the
208
+ * caller). The stand-in lives outside the project.
209
+ *
210
+ * ⛔ CLOSE IT. `close()` unlinks every link it made before removing the rest — a recursive
211
+ * remove that followed one would delete the project's own files.
212
+ *
213
+ * @param {object} repo - `repoOf`
214
+ * @param {object} opts
215
+ * @param {string[]} opts.roots - the site's content roots, relative to the site
216
+ * @param {string|null} opts.rev - the commit whose content the roots are filled from;
217
+ * null for none
218
+ * @returns {{ siteDir: string, commit: Function, close: Function }}
219
+ */
220
+ export function openStandIn(repo, { roots, rev }) {
221
+ for (const r of roots) {
222
+ if (r.split('/').includes('..') || r.startsWith('/')) {
223
+ throw new Error(`the content root \`${r}\` is outside the site, so a pull cannot merge it`)
224
+ }
225
+ }
226
+ const root = mkdtempSync(join(tmpdir(), 'uniweb-stand-in-'))
227
+ const indexDir = mkdtempSync(join(tmpdir(), 'uniweb-stand-in-index-'))
228
+ const env = { GIT_INDEX_FILE: join(indexDir, 'index') }
229
+ const at = (args, opts = {}) =>
230
+ git(repo.top, ['--git-dir', repo.gitDir, '--work-tree', root, ...args], { ...opts, env: { ...env, ...(opts.env || {}) } })
231
+ const links = []
232
+ const close = () => {
233
+ for (const l of links.reverse()) {
234
+ try {
235
+ unlinkSync(l)
236
+ } catch {
237
+ /* already gone */
238
+ }
239
+ }
240
+ rmSync(root, { recursive: true, force: true })
241
+ rmSync(indexDir, { recursive: true, force: true })
242
+ }
243
+
244
+ try {
245
+ const sitePrefix = repo.prefix // '' or 'site/'
246
+ const full = roots.map((r) => `${sitePrefix}${r}`)
247
+ const fullSet = new Set(full)
248
+ const sitePath = sitePrefix.replace(/\/$/, '')
249
+ const inSite = (name) => (sitePath ? `${sitePath}/${name}` : name)
250
+ const mustBeReal = (rel) =>
251
+ (sitePath && (sitePath === rel || sitePath.startsWith(`${rel}/`))) || full.some((f) => f.startsWith(`${rel}/`))
252
+
253
+ const link = (real, stand, name) => {
254
+ let dir = false
255
+ try {
256
+ dir = statSync(real).isDirectory()
257
+ } catch {
258
+ return // a dangling link resolves nothing in the project either
259
+ }
260
+ // ⛔ git reads neither through a link (2.32+), so a linked one would stage what the
261
+ // project ignores, and drop its attributes.
262
+ if (!dir && (name === '.gitignore' || name === '.gitattributes')) {
263
+ copyFileSync(real, stand)
264
+ return
265
+ }
266
+ try {
267
+ symlinkSync(real, stand, dir ? 'junction' : 'file')
268
+ links.push(stand)
269
+ } catch (err) {
270
+ // A file link needs a privilege some systems withhold; a copy reads the same.
271
+ if (dir) throw err
272
+ copyFileSync(real, stand)
273
+ }
274
+ }
275
+ const mirror = (realDir, standDir, rel) => {
276
+ mkdirSync(standDir, { recursive: true })
277
+ for (const name of readdirSync(realDir)) {
278
+ const r = rel ? `${rel}/${name}` : name
279
+ const real = join(realDir, name)
280
+ const stand = join(standDir, name)
281
+ if (!rel && name === '.git') continue
282
+ if (fullSet.has(r)) continue // filled from `rev`, below
283
+ if (r === inSite('.uniweb')) {
284
+ // This copy's own state, for the projection to read and write — taken back by the
285
+ // caller. Not the rename index: it names paths in the working tree, not in `rev`.
286
+ cpSync(real, stand, {
287
+ recursive: true,
288
+ filter: (src) => src !== join(real, 'pull-index.json')
289
+ })
290
+ continue
291
+ }
292
+ if (r === inSite('sync.json')) {
293
+ copyFileSync(real, stand)
294
+ continue
295
+ }
296
+ let st
297
+ try {
298
+ st = lstatSync(real)
299
+ } catch {
300
+ continue
301
+ }
302
+ if (mustBeReal(r)) {
303
+ // ⛔ The way to a content root is never a link: the roots are written, and a
304
+ // write through a link lands in the project.
305
+ if (!st.isDirectory()) throw new Error(`\`${r}\` is a link, so a pull cannot merge the site in place`)
306
+ mirror(real, stand, r)
307
+ } else link(real, stand, name)
308
+ }
309
+ }
310
+ mirror(repo.top, root, '')
311
+ if (sitePath) mkdirSync(join(root, sitePath), { recursive: true })
312
+
313
+ // The roots, as `rev` has them.
314
+ if (rev) at(['read-tree', rev])
315
+ else at(['read-tree', '--empty'])
316
+ const present = rev
317
+ ? gitText(repo.top, ['ls-tree', '--name-only', '-z', '--full-tree', rev, '--', ...full], {})
318
+ .split('\0')
319
+ .filter(Boolean)
320
+ : []
321
+ if (present.length) at(['checkout', rev, '--', ...present])
322
+
323
+ const siteDir = sitePath ? join(root, sitePath) : root
324
+
325
+ /**
326
+ * Commit the roots as the stand-in now holds them, onto `rev`'s tree — or report that
327
+ * they are what `rev` holds (`always` commits them anyway, to mark a history that has
328
+ * no sync point yet).
329
+ * @returns {{ commit: string|null, tree: string, siteTree: string|null }}
330
+ */
331
+ const commit = ({ parent, subject, origin, siteUuid, always = false }) => {
332
+ const staged = full.filter((f) => present.includes(f) || existsSync(join(root, f)))
333
+ if (staged.length) at(['add', '-A', '--', ...staged])
334
+ const tree = at(['write-tree']).toString('utf8').trim()
335
+ const before = rev ? gitText(repo.top, ['rev-parse', `${rev}^{tree}`]).trim() : null
336
+ const siteTree = siteTreeIn(repo, tree)
337
+ if (tree === before && !always) return { commit: null, tree, siteTree }
338
+ const host = (() => {
339
+ try {
340
+ return new URL(origin).host
341
+ } catch {
342
+ return 'backend'
343
+ }
344
+ })()
345
+ const message = `${subject}\n\n${SYNC_TRAILER}: ${origin} ${siteUuid}\n${SITE_TRAILER}: ${repo.path}:${siteTree}\n`
346
+ const who = {
347
+ GIT_AUTHOR_NAME: 'uniweb',
348
+ GIT_AUTHOR_EMAIL: `sync@${host}`,
349
+ GIT_COMMITTER_NAME: 'uniweb',
350
+ GIT_COMMITTER_EMAIL: `sync@${host}`
351
+ }
352
+ const sha = gitText(repo.top, ['commit-tree', '--no-gpg-sign', tree, ...(parent ? ['-p', parent] : []), '-F', '-'], {
353
+ env: who,
354
+ input: message
355
+ }).trim()
356
+ return { commit: sha, tree, siteTree }
357
+ }
358
+
359
+ return { siteDir, commit, close }
360
+ } catch (err) {
361
+ close()
362
+ throw err
363
+ }
364
+ }
365
+
366
+ // ── merging ─────────────────────────────────────────────────────────────────
367
+
368
+ const zlist = (buf) => buf.toString('utf8').split('\0').filter(Boolean)
369
+
370
+ /**
371
+ * Files not under git that the merge would write over: their paths, each with whether the
372
+ * file already holds what `commit` has there.
373
+ */
374
+ function untrackedInTheWay(repo, commit) {
375
+ const added = zlist(git(repo.top, ['diff', '--name-only', '-z', '--diff-filter=A', 'HEAD', commit, '--', ...(repo.prefix ? [repo.prefix] : [])]))
376
+ const out = []
377
+ for (const p of added) {
378
+ const abs = join(repo.top, p)
379
+ if (!existsSync(abs)) continue
380
+ try {
381
+ git(repo.top, ['ls-files', '--error-unmatch', '--', p])
382
+ continue // in the index: the merge's own business
383
+ } catch {
384
+ /* untracked */
385
+ }
386
+ let same = false
387
+ try {
388
+ same = readFileSync(abs).equals(git(repo.top, ['cat-file', 'blob', `${commit}:${p}`]))
389
+ } catch {
390
+ same = false
391
+ }
392
+ out.push({ path: p, same })
393
+ }
394
+ return out
395
+ }
396
+
397
+ /**
398
+ * Merge a sync point into HEAD: git's three-way merge, against git's merge base — the sync
399
+ * point's parent, which HEAD descends from. Uncommitted work is stashed and put back when
400
+ * the merge is done (`--autostash`); a conflict leaves git's own state for a person to
401
+ * settle, as any merge does.
402
+ *
403
+ * @returns {{ ok: true, conflicted: string[] } | { ok: false, reason: string, paths?: string[] }}
404
+ */
405
+ export function mergeSyncPoint(repo, commit, { message }) {
406
+ if (mergeInProgress(repo)) return { ok: false, reason: 'merge-in-progress' }
407
+ const inTheWay = untrackedInTheWay(repo, commit)
408
+ const blocking = inTheWay.filter((u) => !u.same).map((u) => u.path)
409
+ if (blocking.length) return { ok: false, reason: 'untracked', paths: blocking }
410
+ // One that already holds what the merge writes there is made way for — and only once the
411
+ // merge is going ahead, or it would be gone with nothing put back.
412
+ for (const u of inTheWay) rmSync(join(repo.top, u.path))
413
+ try {
414
+ git(repo.top, ['merge', '--autostash', '--no-edit', '--no-verify', '--allow-unrelated-histories', '-m', message, commit])
415
+ return { ok: true, conflicted: [] }
416
+ } catch (err) {
417
+ const conflicted = zlist(git(repo.top, ['diff', '--name-only', '-z', '--diff-filter=U']))
418
+ if (conflicted.length) return { ok: true, conflicted }
419
+ return { ok: false, reason: 'git', detail: gitError(err) }
420
+ }
421
+ }
422
+
423
+ /**
424
+ * Take a sync point's roots whole — `pull --force`. Local changes under them are dropped
425
+ * and the branch gets a commit holding the sync point's content, with the sync point as a
426
+ * parent, so a later pull merges from it. Files outside the roots are left as they are.
427
+ *
428
+ * @returns {{ ok: true } | { ok: false, reason: string, detail?: string }}
429
+ */
430
+ export function takeSyncPoint(repo, commit, { roots, message }) {
431
+ if (mergeInProgress(repo)) return { ok: false, reason: 'merge-in-progress' }
432
+ const full = [...roots.map((r) => `${repo.prefix}${r}`)]
433
+ try {
434
+ const tracked = zlist(git(repo.top, ['ls-tree', '--name-only', '-z', '--full-tree', 'HEAD', '--', ...full]))
435
+ if (tracked.length) git(repo.top, ['checkout', 'HEAD', '--', ...tracked])
436
+ for (const u of untrackedInTheWay(repo, commit)) rmSync(join(repo.top, u.path))
437
+ const tree = gitText(repo.top, ['rev-parse', `${commit}^{tree}`]).trim()
438
+ const merged = gitText(repo.top, ['commit-tree', tree, '-p', 'HEAD', '-p', commit, '-F', '-'], { input: `${message}\n` }).trim()
439
+ git(repo.top, ['read-tree', '-m', '-u', 'HEAD', merged])
440
+ git(repo.top, ['update-ref', '-m', message, 'HEAD', merged])
441
+ return { ok: true }
442
+ } catch (err) {
443
+ return { ok: false, reason: 'git', detail: gitError(err) }
444
+ }
445
+ }
446
+
447
+ /** What the backend changed between two commits, under the site: `[status, path]` pairs. */
448
+ export function changesBetween(repo, from, to) {
449
+ try {
450
+ // No base: against the empty tree, in this repository's own hash.
451
+ const base = from || gitText(repo.top, ['hash-object', '-t', 'tree', '--stdin'], { input: '' }).trim()
452
+ const out = gitText(repo.top, ['diff', '--name-status', '-z', base, to, '--', ...(repo.prefix ? [repo.prefix] : [])])
453
+ const parts = out.split('\0').filter(Boolean)
454
+ const changes = []
455
+ for (let i = 0; i < parts.length; ) {
456
+ const status = parts[i++]
457
+ const path = parts[i++]
458
+ if (/^[RC]/.test(status)) changes.push([status[0], parts[i++]])
459
+ else changes.push([status, path])
460
+ }
461
+ return changes.map(([s, p]) => [s, repo.prefix && p.startsWith(repo.prefix) ? p.slice(repo.prefix.length) : p])
462
+ } catch {
463
+ return []
464
+ }
465
+ }
466
+
467
+ // ── this copy's own sync point ──────────────────────────────────────────────
468
+
469
+ const cachePath = (siteDir) => join(siteDir, '.uniweb', 'backend-cache.json')
470
+ const cacheKey = (origin) => {
471
+ try {
472
+ const u = new URL(origin)
473
+ return u.protocol === 'http:' || u.protocol === 'https:' ? u.origin : null
474
+ } catch {
475
+ return null
476
+ }
477
+ }
478
+
479
+ /**
480
+ * The commit whose content the versions this copy holds for a backend describe — the
481
+ * one its last pull merged, or its last push was made from — or null.
482
+ */
483
+ export function readSyncPoint(siteDir, origin) {
484
+ const key = cacheKey(origin)
485
+ if (!key) return null
486
+ try {
487
+ const file = JSON.parse(readFileSync(cachePath(siteDir), 'utf8'))
488
+ const v = file?.backends?.[key]?.syncPoint
489
+ return typeof v === 'string' && v ? v : null
490
+ } catch {
491
+ return null
492
+ }
493
+ }
494
+
495
+ /** Record it — beside the versions it describes, in `.uniweb/backend-cache.json`. */
496
+ export function writeSyncPoint(siteDir, origin, sha) {
497
+ const key = cacheKey(origin)
498
+ if (!key) return
499
+ const p = cachePath(siteDir)
500
+ let file = { backends: {} }
501
+ try {
502
+ const parsed = JSON.parse(readFileSync(p, 'utf8'))
503
+ if (parsed?.backends && typeof parsed.backends === 'object') file = parsed
504
+ } catch {
505
+ /* none yet */
506
+ }
507
+ file.backends[key] = { ...(file.backends[key] || {}), syncPoint: sha }
508
+ const sorted = {}
509
+ for (const k of Object.keys(file.backends).sort()) sorted[k] = file.backends[k]
510
+ mkdirSync(dirname(p), { recursive: true })
511
+ writeFileSync(p, JSON.stringify({ version: 1, backends: sorted }, null, 2) + '\n')
512
+ }