uniweb 1.7.4 → 1.8.1

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,693 @@
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 — or one a push found
14
+ * nothing to send from. This copy keeps it (`backend-cache.json`, `syncPoint`), and the
15
+ * backend can name it on a pull (`x-uniweb-source`, `backend/client.js`), for every
16
+ * copy that has it.
17
+ *
18
+ * ⚖️ THE NEWEST ONE IS THE BASE; an older one is not simply less precise. Both the files and
19
+ * the backend's content descend from any of them, but between an older one and the
20
+ * newest, both sides hold the same changes — and where one side then changed a hunk back,
21
+ * the older base already reads the old text, so the change-back looks like no change and
22
+ * the merge silently takes the other side. ⛔ Until 2026-10-17 this said "an older one
23
+ * costs conflicts, never content". So no known base is dropped (`madeOnto`), and the
24
+ * backend naming its source is a matter of correctness, not only of fewer conflicts.
25
+ *
26
+ * ⛔ Until 2026-10-17 the ancestor was a per-machine list of the files this copy's last pull
27
+ * wrote, by hash, found again in history by content — and HEAD where the list had no
28
+ * entry. Committed work outside the list was lost: a teammate's new section pruned, a
29
+ * committed record edit overwritten, a fresh clone's committed work reverted, and the
30
+ * committed half of a file edited again since reverted (`test/pull-merge-base.test.js`).
31
+ *
32
+ * Dependency-free: `clone` uses it before a project's packages are installed.
33
+ */
34
+
35
+ import { execFileSync } from 'node:child_process'
36
+ import {
37
+ copyFileSync,
38
+ cpSync,
39
+ existsSync,
40
+ lstatSync,
41
+ mkdirSync,
42
+ mkdtempSync,
43
+ readdirSync,
44
+ readFileSync,
45
+ rmSync,
46
+ statSync,
47
+ symlinkSync,
48
+ unlinkSync,
49
+ writeFileSync
50
+ } from 'node:fs'
51
+ import { tmpdir } from 'node:os'
52
+ import { join, dirname } from 'node:path'
53
+
54
+ export const SYNC_TRAILER = 'Uniweb-Sync'
55
+ export const SITE_TRAILER = 'Uniweb-Site'
56
+
57
+ const MAX_BUFFER = 1024 * 1024 * 1024
58
+
59
+ function git(cwd, args, { env, input } = {}) {
60
+ return execFileSync('git', args, {
61
+ cwd,
62
+ env: env ? { ...process.env, ...env } : process.env,
63
+ input,
64
+ maxBuffer: MAX_BUFFER,
65
+ stdio: [input == null ? 'ignore' : 'pipe', 'pipe', 'pipe']
66
+ })
67
+ }
68
+ const gitText = (cwd, args, opts) => git(cwd, args, opts).toString('utf8')
69
+
70
+ /** git's own words when a command fails — what it printed, not our paraphrase. */
71
+ export function gitError(err) {
72
+ const said = [err?.stderr, err?.stdout].map((b) => (b ? String(b).trim() : '')).filter(Boolean)
73
+ return said.join('\n') || err?.message || 'git failed'
74
+ }
75
+
76
+ /**
77
+ * The repository that holds a site — its top level, the site's path in it, the git
78
+ * directory and HEAD (null on a branch with no commits) — or null when none does.
79
+ *
80
+ * ⭐ HOLDS: the site's folder is inside a work tree, and that repository does not ignore
81
+ * it. A repository can never hold the history of a folder it ignores — a harness's
82
+ * scratch projects, a sandbox — so a site there is outside git, as if no repository were
83
+ * around it. One committed there anyway (`git add -f`) is held: git does not count a
84
+ * folder with tracked content as ignored.
85
+ *
86
+ * ⛔ Until 2026-10-17 any enclosing work tree counted. In a folder it ignores, a pull
87
+ * failed on git's "paths are ignored", a push recorded that repository's HEAD as its sync
88
+ * point, and `refresh` ran `git pull` on it.
89
+ */
90
+ export function repoOf(siteDir) {
91
+ let top, prefix, gitDir
92
+ try {
93
+ top = gitText(siteDir, ['rev-parse', '--show-toplevel']).trim()
94
+ prefix = gitText(siteDir, ['rev-parse', '--show-prefix']).trim()
95
+ gitDir = gitText(siteDir, ['rev-parse', '--absolute-git-dir']).trim()
96
+ } catch {
97
+ return null
98
+ }
99
+ if (!top) return null
100
+ if (prefix && ignoredHere(siteDir)) return null
101
+ let head = null
102
+ try {
103
+ head = gitText(top, ['rev-parse', '--verify', '-q', 'HEAD^{commit}']).trim() || null
104
+ } catch {
105
+ head = null
106
+ }
107
+ return { top, prefix, path: prefix ? prefix.replace(/\/$/, '') : '.', gitDir, head }
108
+ }
109
+
110
+ /** Does the repository around `dir` ignore it? `git check-ignore` exits 0 for yes, 1 for no. */
111
+ function ignoredHere(dir) {
112
+ try {
113
+ git(dir, ['check-ignore', '-q', '.'])
114
+ return true
115
+ } catch {
116
+ return false
117
+ }
118
+ }
119
+
120
+ /** Is `ancestor` in the history of `of` — or `of` itself? */
121
+ export function isAncestor(repo, ancestor, of = 'HEAD') {
122
+ try {
123
+ git(repo.top, ['merge-base', '--is-ancestor', ancestor, of])
124
+ return true
125
+ } catch {
126
+ return false
127
+ }
128
+ }
129
+
130
+ function isCommit(repo, sha) {
131
+ if (typeof sha !== 'string' || !/^[0-9a-f]{7,64}$/i.test(sha)) return false
132
+ try {
133
+ git(repo.top, ['cat-file', '-e', `${sha}^{commit}`])
134
+ return true
135
+ } catch {
136
+ return false
137
+ }
138
+ }
139
+
140
+ /** The tree of the site's directory in a commit or tree, or null. */
141
+ export function siteTreeIn(repo, rev) {
142
+ try {
143
+ return gitText(repo.top, ['rev-parse', '--verify', '-q', repo.path === '.' ? `${rev}^{tree}` : `${rev}:${repo.path}`]).trim() || null
144
+ } catch {
145
+ return null
146
+ }
147
+ }
148
+
149
+ /** The paths in conflict here, whatever left them — a merge, or a stash put back over one. */
150
+ export function unmergedPaths(repo) {
151
+ try {
152
+ return zlist(git(repo.top, ['diff', '--name-only', '-z', '--diff-filter=U']))
153
+ } catch {
154
+ return []
155
+ }
156
+ }
157
+
158
+ /** Is a merge waiting for its commit here? */
159
+ export function mergeInProgress(repo) {
160
+ try {
161
+ git(repo.top, ['rev-parse', '--verify', '-q', 'MERGE_HEAD'])
162
+ return true
163
+ } catch {
164
+ return false
165
+ }
166
+ }
167
+
168
+ function trailersOf(body) {
169
+ const out = {}
170
+ for (const line of String(body).split('\n')) {
171
+ let m = line.match(/^Uniweb-Sync: (\S+) (\S+)\s*$/)
172
+ if (m) {
173
+ out.origin = m[1]
174
+ out.siteUuid = m[2]
175
+ continue
176
+ }
177
+ m = line.match(/^Uniweb-Site: (.+):([0-9a-f]{40,64})\s*$/)
178
+ if (m) {
179
+ out.path = m[1]
180
+ out.tree = m[2]
181
+ }
182
+ }
183
+ return out
184
+ }
185
+
186
+ /**
187
+ * The newest commit a pull made for this site on this backend that HEAD's history
188
+ * holds — and that still holds what that pull wrote. ⛔ A rebase or a cherry-pick copies
189
+ * the trailers onto a commit whose tree is no longer the backend's, so each one is
190
+ * checked against the site tree it names.
191
+ */
192
+ export function historySyncPoint(repo, { origin, siteUuid }) {
193
+ if (!repo?.head) return null
194
+ let out
195
+ try {
196
+ out = gitText(repo.top, [
197
+ 'log',
198
+ '--fixed-strings',
199
+ `--grep=${SYNC_TRAILER}: ${origin} ${siteUuid}`,
200
+ '--format=%H%x1f%B%x1e',
201
+ 'HEAD'
202
+ ])
203
+ } catch {
204
+ return null
205
+ }
206
+ for (const record of out.split('\x1e')) {
207
+ const [sha = '', body = ''] = record.replace(/^\s+/, '').split('\x1f')
208
+ if (!/^[0-9a-f]{40,64}$/.test(sha)) continue
209
+ if (marks(repo, sha, body, { origin, siteUuid })) return sha
210
+ }
211
+ return null
212
+ }
213
+
214
+ // Does this commit's message mark it a sync point of this site and backend — and does it
215
+ // still hold the site tree its trailer names?
216
+ function marks(repo, sha, body, { origin, siteUuid }) {
217
+ const t = trailersOf(body)
218
+ if (t.origin !== origin || t.siteUuid !== siteUuid || t.path !== repo.path) return false
219
+ return siteTreeIn(repo, sha) === t.tree
220
+ }
221
+
222
+ /**
223
+ * The commits a sync point HEAD does not hold still vouches for: the one it was made onto
224
+ * — its first parent — and that one's, for as long as each is a sync point of this site
225
+ * HEAD does not hold either.
226
+ *
227
+ * ⛔ A merge abandoned (`git merge --abort`) leaves this copy's sync point out of the
228
+ * history, while the commit it was made onto is still there — and still a state the
229
+ * backend's content descends from. Until 2026-10-17 it was forgotten with the merge, and
230
+ * the next pull built on an older sync point, or on none: measured against a real
231
+ * backend, it conflicted on a section nobody had touched since the commit last pushed from.
232
+ */
233
+ export function madeOnto(repo, sha, { origin, siteUuid }) {
234
+ const out = []
235
+ let at = sha
236
+ for (let n = 0; n < 16 && isCommit(repo, at) && !isAncestor(repo, at); n++) {
237
+ let body
238
+ try {
239
+ body = gitText(repo.top, ['log', '-1', '--format=%B', at])
240
+ } catch {
241
+ break
242
+ }
243
+ if (!marks(repo, at, body, { origin, siteUuid })) break
244
+ let parent = null
245
+ try {
246
+ parent = gitText(repo.top, ['rev-parse', '--verify', '-q', `${at}^1`]).trim() || null
247
+ } catch {
248
+ // Made onto nothing — a history with no sync point.
249
+ }
250
+ if (!parent) break
251
+ out.push(parent)
252
+ at = parent
253
+ }
254
+ return out
255
+ }
256
+
257
+ /**
258
+ * The base a pull merges from: the newest of the candidates HEAD descends from. One that
259
+ * descends from all the others is the newest; with none, the most recently committed.
260
+ *
261
+ * ⚖️ Any of them is a state both sides descend from, but only the newest such state — the
262
+ * commit the backend's content last came from — merges exactly. An older one costs a
263
+ * conflict wherever one side changed what the other changed after it, and it silently
264
+ * undoes a change made after it and then changed BACK on one side: the base already
265
+ * reads the old text, so the change-back looks like no change at all. ⛔ Until
266
+ * 2026-10-17 this said an older base "costs precision at worst".
267
+ */
268
+ export function newestBase(repo, candidates) {
269
+ const shas = [...new Set(candidates.filter(Boolean))].filter((s) => isCommit(repo, s) && isAncestor(repo, s))
270
+ if (shas.length <= 1) return shas[0] || null
271
+ const newest = shas.find((s) => shas.every((o) => o === s || isAncestor(repo, o, s)))
272
+ if (newest) return newest
273
+ try {
274
+ return gitText(repo.top, ['rev-list', '--no-walk', ...shas]).split('\n')[0].trim() || shas[0]
275
+ } catch {
276
+ return shas[0]
277
+ }
278
+ }
279
+
280
+ // ── the stand-in ────────────────────────────────────────────────────────────
281
+
282
+ /**
283
+ * A stand-in for the repository, where the backend's content is written onto a commit's
284
+ * tree without touching the working tree.
285
+ *
286
+ * ⭐ It mirrors the repository by LINKS — the foundation, a workspace extension, the
287
+ * installed packages, anything resolved by relative path, resolves as in the project —
288
+ * except the site's content `roots`, which are real directories filled from `rev`, and the
289
+ * site's `sync.json` and `.uniweb/`, which are copies (written there, and taken back by the
290
+ * caller). The stand-in lives outside the project.
291
+ *
292
+ * ⛔ CLOSE IT. `close()` unlinks every link it made before removing the rest — a recursive
293
+ * remove that followed one would delete the project's own files.
294
+ *
295
+ * ⚖️ What is written under the roots where the repository ignores it — media in a folder
296
+ * kept out of git — is in no commit, so no merge brings it. `placeIgnored()` copies it
297
+ * into the project, where the project lacks it.
298
+ *
299
+ * @param {object} repo - `repoOf`
300
+ * @param {object} opts
301
+ * @param {string[]} opts.roots - the site's content roots, relative to the site
302
+ * @param {string|null} opts.rev - the commit whose content the roots are filled from;
303
+ * null for none
304
+ * @returns {{ siteDir: string, commit: Function, placeIgnored: Function, close: Function }}
305
+ */
306
+ export function openStandIn(repo, { roots, rev }) {
307
+ for (const r of roots) {
308
+ if (r.split('/').includes('..') || r.startsWith('/')) {
309
+ throw new Error(`the content root \`${r}\` is outside the site, so a pull cannot merge it`)
310
+ }
311
+ }
312
+ const root = mkdtempSync(join(tmpdir(), 'uniweb-stand-in-'))
313
+ const indexDir = mkdtempSync(join(tmpdir(), 'uniweb-stand-in-index-'))
314
+ const env = { GIT_INDEX_FILE: join(indexDir, 'index') }
315
+ const at = (args, opts = {}) =>
316
+ git(repo.top, ['--git-dir', repo.gitDir, '--work-tree', root, ...args], { ...opts, env: { ...env, ...(opts.env || {}) } })
317
+ const links = []
318
+ const close = () => {
319
+ for (const l of links.reverse()) {
320
+ try {
321
+ unlinkSync(l)
322
+ } catch {
323
+ /* already gone */
324
+ }
325
+ }
326
+ rmSync(root, { recursive: true, force: true })
327
+ rmSync(indexDir, { recursive: true, force: true })
328
+ }
329
+
330
+ try {
331
+ const sitePrefix = repo.prefix // '' or 'site/'
332
+ const full = roots.map((r) => `${sitePrefix}${r}`)
333
+ const fullSet = new Set(full)
334
+ const sitePath = sitePrefix.replace(/\/$/, '')
335
+ const inSite = (name) => (sitePath ? `${sitePath}/${name}` : name)
336
+ const mustBeReal = (rel) =>
337
+ (sitePath && (sitePath === rel || sitePath.startsWith(`${rel}/`))) || full.some((f) => f.startsWith(`${rel}/`))
338
+
339
+ const link = (real, stand, name) => {
340
+ let dir = false
341
+ try {
342
+ dir = statSync(real).isDirectory()
343
+ } catch {
344
+ return // a dangling link resolves nothing in the project either
345
+ }
346
+ // ⛔ git reads neither through a link (2.32+), so a linked one would stage what the
347
+ // project ignores, and drop its attributes.
348
+ if (!dir && (name === '.gitignore' || name === '.gitattributes')) {
349
+ copyFileSync(real, stand)
350
+ return
351
+ }
352
+ try {
353
+ symlinkSync(real, stand, dir ? 'junction' : 'file')
354
+ links.push(stand)
355
+ } catch (err) {
356
+ // A file link needs a privilege some systems withhold; a copy reads the same.
357
+ if (dir) throw err
358
+ copyFileSync(real, stand)
359
+ }
360
+ }
361
+ const mirror = (realDir, standDir, rel) => {
362
+ mkdirSync(standDir, { recursive: true })
363
+ for (const name of readdirSync(realDir)) {
364
+ const r = rel ? `${rel}/${name}` : name
365
+ const real = join(realDir, name)
366
+ const stand = join(standDir, name)
367
+ if (!rel && name === '.git') continue
368
+ if (fullSet.has(r)) continue // filled from `rev`, below
369
+ if (r === inSite('.uniweb')) {
370
+ // This copy's own state, for the projection to read and write — taken back by the
371
+ // caller. Not the rename index: it names paths in the working tree, not in `rev`.
372
+ cpSync(real, stand, {
373
+ recursive: true,
374
+ filter: (src) => src !== join(real, 'pull-index.json')
375
+ })
376
+ continue
377
+ }
378
+ if (r === inSite('sync.json')) {
379
+ copyFileSync(real, stand)
380
+ continue
381
+ }
382
+ let st
383
+ try {
384
+ st = lstatSync(real)
385
+ } catch {
386
+ continue
387
+ }
388
+ if (mustBeReal(r)) {
389
+ // ⛔ The way to a content root is never a link: the roots are written, and a
390
+ // write through a link lands in the project.
391
+ if (!st.isDirectory()) throw new Error(`\`${r}\` is a link, so a pull cannot merge the site in place`)
392
+ mirror(real, stand, r)
393
+ } else link(real, stand, name)
394
+ }
395
+ }
396
+ mirror(repo.top, root, '')
397
+ if (sitePath) mkdirSync(join(root, sitePath), { recursive: true })
398
+
399
+ // The roots, as `rev` has them.
400
+ if (rev) at(['read-tree', rev])
401
+ else at(['read-tree', '--empty'])
402
+ const present = rev
403
+ ? gitText(repo.top, ['ls-tree', '--name-only', '-z', '--full-tree', rev, '--', ...full], {})
404
+ .split('\0')
405
+ .filter(Boolean)
406
+ : []
407
+ if (present.length) at(['checkout', rev, '--', ...present])
408
+
409
+ const siteDir = sitePath ? join(root, sitePath) : root
410
+
411
+ /**
412
+ * Commit the roots as the stand-in now holds them, onto `rev`'s tree — or report that
413
+ * they are what `rev` holds (`always` commits them anyway, to mark a history that has
414
+ * no sync point yet).
415
+ * @returns {{ commit: string|null, tree: string, siteTree: string|null }}
416
+ */
417
+ const commit = ({ parent, subject, origin, siteUuid, always = false }) => {
418
+ const staged = held(full.filter((f) => present.includes(f) || existsSync(join(root, f))))
419
+ if (staged.length) at(['add', '-A', '--', ...staged])
420
+ const tree = at(['write-tree']).toString('utf8').trim()
421
+ const before = rev ? gitText(repo.top, ['rev-parse', `${rev}^{tree}`]).trim() : null
422
+ const siteTree = siteTreeIn(repo, tree)
423
+ if (tree === before && !always) return { commit: null, tree, siteTree }
424
+ const host = (() => {
425
+ try {
426
+ return new URL(origin).host
427
+ } catch {
428
+ return 'backend'
429
+ }
430
+ })()
431
+ const message = `${subject}\n\n${SYNC_TRAILER}: ${origin} ${siteUuid}\n${SITE_TRAILER}: ${repo.path}:${siteTree}\n`
432
+ const who = {
433
+ GIT_AUTHOR_NAME: 'uniweb',
434
+ GIT_AUTHOR_EMAIL: `sync@${host}`,
435
+ GIT_COMMITTER_NAME: 'uniweb',
436
+ GIT_COMMITTER_EMAIL: `sync@${host}`
437
+ }
438
+ const sha = gitText(repo.top, ['commit-tree', '--no-gpg-sign', tree, ...(parent ? ['-p', parent] : []), '-F', '-'], {
439
+ env: who,
440
+ input: message
441
+ }).trim()
442
+ return { commit: sha, tree, siteTree }
443
+ }
444
+
445
+ /**
446
+ * Copy into the project what the stand-in holds under the roots where the repository
447
+ * ignores it, wherever the project lacks it. ⛔ Never over a file the project has:
448
+ * with no history there, nothing tells its version from the author's work.
449
+ * @returns {string[]} the paths placed, relative to the site
450
+ */
451
+ const placeIgnored = () => {
452
+ const roots = full.filter((f) => existsSync(join(root, f)))
453
+ if (!roots.length) return []
454
+ const placed = []
455
+ for (const p of zlist(at(['ls-files', '--others', '--ignored', '--exclude-standard', '-z', '--', ...roots]))) {
456
+ const to = join(repo.top, p)
457
+ if (occupied(to)) continue
458
+ mkdirSync(dirname(to), { recursive: true })
459
+ copyFileSync(join(root, p), to)
460
+ placed.push(sitePrefix && p.startsWith(sitePrefix) ? p.slice(sitePrefix.length) : p)
461
+ }
462
+ return placed
463
+ }
464
+
465
+ // The roots git can hold here. ⛔ One the repository ignores whole is not named to
466
+ // `git add`, which refuses an ignored path; what is written under it is placed instead.
467
+ const held = (paths) => {
468
+ if (!paths.length) return paths
469
+ let ignored = []
470
+ try {
471
+ ignored = zlist(at(['check-ignore', '-z', '--stdin'], { input: `${paths.join('\0')}\0` }))
472
+ } catch (err) {
473
+ if (err?.status !== 1) throw err // 1: none of them is ignored
474
+ }
475
+ return paths.filter((p) => !ignored.includes(p))
476
+ }
477
+
478
+ return { siteDir, commit, placeIgnored, close }
479
+ } catch (err) {
480
+ close()
481
+ throw err
482
+ }
483
+ }
484
+
485
+ // ── merging ─────────────────────────────────────────────────────────────────
486
+
487
+ const zlist = (buf) => buf.toString('utf8').split('\0').filter(Boolean)
488
+ // Something is at `path` — a dangling link included, which `existsSync` reads as nothing.
489
+ const occupied = (path) => {
490
+ try {
491
+ lstatSync(path)
492
+ return true
493
+ } catch {
494
+ return false
495
+ }
496
+ }
497
+
498
+ /**
499
+ * Files not under git that the merge would write over: their paths, each with whether the
500
+ * file already holds what `commit` has there.
501
+ */
502
+ function untrackedInTheWay(repo, commit) {
503
+ const added = zlist(git(repo.top, ['diff', '--name-only', '-z', '--diff-filter=A', 'HEAD', commit, '--', ...(repo.prefix ? [repo.prefix] : [])]))
504
+ const out = []
505
+ for (const p of added) {
506
+ const abs = join(repo.top, p)
507
+ if (!existsSync(abs)) continue
508
+ try {
509
+ git(repo.top, ['ls-files', '--error-unmatch', '--', p])
510
+ continue // in the index: the merge's own business
511
+ } catch {
512
+ /* untracked */
513
+ }
514
+ let same = false
515
+ try {
516
+ same = readFileSync(abs).equals(git(repo.top, ['cat-file', 'blob', `${commit}:${p}`]))
517
+ } catch {
518
+ same = false
519
+ }
520
+ out.push({ path: p, same })
521
+ }
522
+ return out
523
+ }
524
+
525
+ /**
526
+ * Merge a sync point into HEAD: git's three-way merge, against git's merge base — the sync
527
+ * point's parent, which HEAD descends from. Uncommitted work is stashed and put back when
528
+ * the merge is done (`--autostash`); a conflict leaves git's own state for a person to
529
+ * settle, as any merge does.
530
+ *
531
+ * @returns {{ ok: true, conflicted: string[] } | { ok: false, reason: string, paths?: string[] }}
532
+ */
533
+ export function mergeSyncPoint(repo, commit, { message }) {
534
+ if (mergeInProgress(repo)) return { ok: false, reason: 'merge-in-progress' }
535
+ // Conflicts already here are not this merge's: git would refuse it, and they would read as its own.
536
+ const unsettled = unmergedPaths(repo)
537
+ if (unsettled.length) return { ok: false, reason: 'unmerged', paths: unsettled }
538
+ const inTheWay = untrackedInTheWay(repo, commit)
539
+ const blocking = inTheWay.filter((u) => !u.same).map((u) => u.path)
540
+ if (blocking.length) return { ok: false, reason: 'untracked', paths: blocking }
541
+ // One that already holds what the merge writes there is made way for — and only once the
542
+ // merge is going ahead, or it would be gone with nothing put back.
543
+ for (const u of inTheWay) rmSync(join(repo.top, u.path))
544
+ try {
545
+ git(repo.top, ['merge', '--autostash', '--no-edit', '--no-verify', '--allow-unrelated-histories', '-m', message, commit])
546
+ // ⛔ THE MERGE IS MADE, AND IT MAY STILL HAVE LEFT CONFLICTS. `--autostash` exits 0 when
547
+ // the uncommitted work it set aside cannot be put back over what the merge brought: the
548
+ // files then hold conflict markers, unmerged, and git keeps that work in the stash too.
549
+ // Until 2026-10-17 that read as a clean merge — a pull that exited 0 over the markers,
550
+ // so `uniweb pull && uniweb push` would send them.
551
+ const unstashed = unmergedPaths(repo)
552
+ return { ok: true, conflicted: unstashed, stashKept: unstashed.length > 0 }
553
+ } catch (err) {
554
+ const conflicted = zlist(git(repo.top, ['diff', '--name-only', '-z', '--diff-filter=U']))
555
+ if (conflicted.length) return { ok: true, conflicted }
556
+ return { ok: false, reason: 'git', detail: gitError(err) }
557
+ }
558
+ }
559
+
560
+ /**
561
+ * Take a sync point's roots whole — `pull --force`. Local changes under them are dropped
562
+ * and the branch gets a commit holding the sync point's content, with the sync point as a
563
+ * parent, so a later pull merges from it. Files outside the roots are left as they are.
564
+ *
565
+ * @returns {{ ok: true } | { ok: false, reason: string, detail?: string }}
566
+ */
567
+ export function takeSyncPoint(repo, commit, { roots, message }) {
568
+ if (mergeInProgress(repo)) return { ok: false, reason: 'merge-in-progress' }
569
+ const full = [...roots.map((r) => `${repo.prefix}${r}`)]
570
+ try {
571
+ const tracked = zlist(git(repo.top, ['ls-tree', '--name-only', '-z', '--full-tree', 'HEAD', '--', ...full]))
572
+ if (tracked.length) git(repo.top, ['checkout', 'HEAD', '--', ...tracked])
573
+ for (const u of untrackedInTheWay(repo, commit)) rmSync(join(repo.top, u.path))
574
+ const tree = gitText(repo.top, ['rev-parse', `${commit}^{tree}`]).trim()
575
+ const merged = gitText(repo.top, ['commit-tree', tree, '-p', 'HEAD', '-p', commit, '-F', '-'], { input: `${message}\n` }).trim()
576
+ git(repo.top, ['read-tree', '-m', '-u', 'HEAD', merged])
577
+ git(repo.top, ['update-ref', '-m', message, 'HEAD', merged])
578
+ return { ok: true }
579
+ } catch (err) {
580
+ return { ok: false, reason: 'git', detail: gitError(err) }
581
+ }
582
+ }
583
+
584
+ /**
585
+ * What a pull changed in the files, under the site: `[status, path]` pairs — what the
586
+ * merge, or the take, moved from `from` (HEAD before it), less what the files already
587
+ * held as it left them. `files` is the files before it (`snapshotFiles`). `staged` reads a
588
+ * merge stopped on conflicts from the index; `skip` leaves out the paths in conflict,
589
+ * which are reported as conflicts.
590
+ *
591
+ * ⛔ Until 2026-10-17 a pull listed the backend's content against the base, as "changes
592
+ * from the backend": a file a push had already written into the files read as one, so
593
+ * did a person's own edit when there was no base — and `--force` said it "took" a change
594
+ * where it undid their edit.
595
+ */
596
+ export function changedInFiles(repo, { from, files, staged = false, skip = [] }) {
597
+ const scope = ['--', ...(repo.prefix ? [repo.prefix] : [])]
598
+ try {
599
+ const parts = zlist(git(repo.top, ['diff', '--name-status', '-z', ...(staged ? ['--cached', from] : [from, 'HEAD']), ...scope]))
600
+ const moved = []
601
+ for (let i = 0; i < parts.length; ) {
602
+ const status = parts[i++]
603
+ const path = parts[i++]
604
+ moved.push(/^[RC]/.test(status) ? [status[0], parts[i++]] : [status, path])
605
+ }
606
+ // What the files already held as the merge left it is no change to them.
607
+ const differs = new Set(
608
+ zlist(git(repo.top, ['diff', '--name-only', '-z', '--no-renames', ...(staged ? ['--cached', files] : [files, 'HEAD']), ...scope]))
609
+ )
610
+ return moved
611
+ .filter(([s, p]) => s !== 'U' && !skip.includes(p) && differs.has(p))
612
+ .map(([s, p]) => [s, repo.prefix && p.startsWith(repo.prefix) ? p.slice(repo.prefix.length) : p])
613
+ } catch {
614
+ return []
615
+ }
616
+ }
617
+
618
+ /**
619
+ * The files as they are, as a commit to compare against — what is tracked, committed or
620
+ * not (`git stash create`, which touches neither the files nor any ref); HEAD when nothing
621
+ * is uncommitted.
622
+ */
623
+ export function snapshotFiles(repo) {
624
+ try {
625
+ return (
626
+ gitText(repo.top, ['stash', 'create'], {
627
+ env: { GIT_AUTHOR_NAME: 'uniweb', GIT_AUTHOR_EMAIL: 'sync@uniweb', GIT_COMMITTER_NAME: 'uniweb', GIT_COMMITTER_EMAIL: 'sync@uniweb' }
628
+ }).trim() || repo.head
629
+ )
630
+ } catch {
631
+ return repo.head
632
+ }
633
+ }
634
+
635
+ /**
636
+ * Does HEAD hold the site's content as the files do — nothing staged, changed or untracked
637
+ * under the roots? (What the repository ignores is no change.)
638
+ */
639
+ export function contentCommitted(repo, roots) {
640
+ try {
641
+ const full = roots.map((r) => `${repo.prefix}${r}`)
642
+ return git(repo.top, ['status', '--porcelain', '-z', '--untracked-files=all', '--', ...full]).length === 0
643
+ } catch {
644
+ return false
645
+ }
646
+ }
647
+
648
+ // ── this copy's own sync point ──────────────────────────────────────────────
649
+
650
+ const cachePath = (siteDir) => join(siteDir, '.uniweb', 'backend-cache.json')
651
+ const cacheKey = (origin) => {
652
+ try {
653
+ const u = new URL(origin)
654
+ return u.protocol === 'http:' || u.protocol === 'https:' ? u.origin : null
655
+ } catch {
656
+ return null
657
+ }
658
+ }
659
+
660
+ /**
661
+ * The commit whose content the versions this copy holds for a backend describe — the
662
+ * one its last pull merged, or its last push was made from — or null.
663
+ */
664
+ export function readSyncPoint(siteDir, origin) {
665
+ const key = cacheKey(origin)
666
+ if (!key) return null
667
+ try {
668
+ const file = JSON.parse(readFileSync(cachePath(siteDir), 'utf8'))
669
+ const v = file?.backends?.[key]?.syncPoint
670
+ return typeof v === 'string' && v ? v : null
671
+ } catch {
672
+ return null
673
+ }
674
+ }
675
+
676
+ /** Record it — beside the versions it describes, in `.uniweb/backend-cache.json`. */
677
+ export function writeSyncPoint(siteDir, origin, sha) {
678
+ const key = cacheKey(origin)
679
+ if (!key) return
680
+ const p = cachePath(siteDir)
681
+ let file = { backends: {} }
682
+ try {
683
+ const parsed = JSON.parse(readFileSync(p, 'utf8'))
684
+ if (parsed?.backends && typeof parsed.backends === 'object') file = parsed
685
+ } catch {
686
+ /* none yet */
687
+ }
688
+ file.backends[key] = { ...(file.backends[key] || {}), syncPoint: sha }
689
+ const sorted = {}
690
+ for (const k of Object.keys(file.backends).sort()) sorted[k] = file.backends[k]
691
+ mkdirSync(dirname(p), { recursive: true })
692
+ writeFileSync(p, JSON.stringify({ version: 1, backends: sorted }, null, 2) + '\n')
693
+ }