uniweb 0.64.1 → 0.66.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.
@@ -28,9 +28,9 @@
28
28
  * three-way merge local work with what the backend sends, instead of choosing
29
29
  * between them. Most "conflicts" are two people editing different paragraphs of one
30
30
  * section, which merges silently; only genuine overlaps get conflict markers. The
31
- * common ancestor is the COMMITTED version of each file — the backend keeps no
32
- * per-version history and does not need to — so `--merge` requires a repo, and the
33
- * merge itself is `git merge-file`.
31
+ * common ancestor is the version the last pull wrote, found in the repo's history —
32
+ * the backend keeps no per-version history and does not need to — so `--merge`
33
+ * requires a repo, and the merge itself is `git merge-file`.
34
34
  *
35
35
  * `uniweb login && uniweb pull`. Run from a site, or a workspace with one site.
36
36
  *
@@ -76,7 +76,8 @@ import {
76
76
  readZip,
77
77
  computeUnitHashes,
78
78
  collectUnitUuids,
79
- collectQueryUuids
79
+ collectQueryUuids,
80
+ documentSchema
80
81
  } from '@uniweb/build/uwx'
81
82
  import {
82
83
  readWritten,
@@ -90,15 +91,14 @@ import {
90
91
  mergeBaseVersions,
91
92
  mergeItemBaseVersions,
92
93
  writeUnitBases,
93
- writeItemUuids,
94
- nameSiteWorkspace,
95
- readSiteWorkspace
94
+ writeItemUuids
96
95
  } from '../backend/site-sync.js'
97
- import { readOrgFlag } from '../utils/args.js'
96
+ import { resolveWorkspace } from '../backend/workspace.js'
98
97
  import {
99
98
  uncommittedUnder,
100
99
  siteContentRoots,
101
100
  showAtHead,
101
+ findCommitted,
102
102
  mergeFile
103
103
  } from '../utils/git.js'
104
104
  import { isNonInteractive } from '../utils/interactive.js'
@@ -106,7 +106,6 @@ import {
106
106
  BackendClient,
107
107
  describeRequestError,
108
108
  refusalDetail,
109
- workspaceHandle,
110
109
  WorkspaceMismatchError
111
110
  } from '../backend/client.js'
112
111
  import { resolveSiteDir as defaultResolveSiteDir } from './deploy.js'
@@ -206,10 +205,15 @@ function readYamlUuid(filePath) {
206
205
  function pullCachePath(siteDir) {
207
206
  return join(siteDir, '.uniweb', 'pull-cache.json')
208
207
  }
208
+ // ⛔ VERSION 2 DISOWNS EVERY ETAG A VERSION-1 PULL WROTE. Before 2026-09-23 a
209
+ // `--merge` that kept a local file whole — every one, in the default layout (see
210
+ // `showAt`) — still cached the ETag of content it never took. Every later pull then
211
+ // answered 304 and the file never caught up. Ignoring those once costs one full pull.
212
+ const PULL_CACHE_VERSION = 2
209
213
  function readPullCache(siteDir) {
210
214
  try {
211
215
  const obj = JSON.parse(readFileSync(pullCachePath(siteDir), 'utf8'))
212
- return obj && typeof obj === 'object' ? obj : {}
216
+ return obj && typeof obj === 'object' && obj.version >= PULL_CACHE_VERSION ? obj : {}
213
217
  } catch {
214
218
  return {}
215
219
  }
@@ -219,7 +223,7 @@ function writePullCache(siteDir, { content, folder }) {
219
223
  mkdirSync(dirname(p), { recursive: true })
220
224
  writeFileSync(
221
225
  p,
222
- JSON.stringify({ version: 1, content, folder }, null, 2) + '\n'
226
+ JSON.stringify({ version: PULL_CACHE_VERSION, content, folder }, null, 2) + '\n'
223
227
  )
224
228
  }
225
229
 
@@ -227,7 +231,10 @@ function writePullCache(siteDir, { content, folder }) {
227
231
  // document, or a `{ document }` / `{ entity }` envelope. (Adjust at live e2e.)
228
232
  export function extractDocument(payload) {
229
233
  if (!payload || typeof payload !== 'object') return null
230
- if (payload.$model || payload.$id || payload.info) return payload
234
+ // ⛔ `$uuid` counts: every stored entity has one. Without it, a document naming no
235
+ // `$schema` stopped being a document at all — it vanished from the pull, which then
236
+ // reported "0 record(s)" and succeeded (2026-09-24, when `$model` left this test).
237
+ if (payload.$schema || payload.$uuid || payload.$id || payload.info) return payload
231
238
  return payload.document || payload.entity || null
232
239
  }
233
240
 
@@ -247,8 +254,59 @@ export function splitRecordsPull(payload) {
247
254
  if (!list) return { folderDoc: null, recordDocs: [] }
248
255
  const docs = list.map(extractDocument).filter(Boolean)
249
256
  return {
250
- folderDoc: docs.find((d) => d.$model === FOLDER_MODEL) || null,
251
- recordDocs: docs.filter((d) => d.$model !== FOLDER_MODEL)
257
+ folderDoc: docs.find((d) => documentSchema(d) === FOLDER_MODEL) || null,
258
+ recordDocs: docs.filter((d) => documentSchema(d) !== FOLDER_MODEL)
259
+ }
260
+ }
261
+
262
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
263
+
264
+ /**
265
+ * Say why a pull did not place every record it was sent: a headline for an error
266
+ * and the lines under it.
267
+ *
268
+ * ⛔ A data schema named by id, or not named at all, is said first, and instead of the
269
+ * per-record reasons. The folder is found by its data schema's scoped name
270
+ * (`splitRecordsPull`) and a record is placed in `records/<schema>/` by its data
271
+ * schema's scoped name, so a response naming them by id — or naming none — places
272
+ * nothing, and every record is then skipped as "not in the folder", which hides the
273
+ * cause. Measured 2026-09-23: a backend answered the folder lane with ids for the
274
+ * folder and every record, under the key `$schema` replaced (`$model`).
275
+ *
276
+ * @param {{ docs: object[], taken: number, skipped: object[] }} lane - every document
277
+ * the lane carried, how many records were written or already current, and the
278
+ * projection's skips
279
+ * @returns {{ headline: string, lines: string[] }}
280
+ */
281
+ export function describeUnplacedRecords({ docs, taken, skipped }) {
282
+ const none = taken === 0
283
+ const lead = none ? 'No record' : 'Not every record'
284
+ const placedBy =
285
+ "A record is placed in records/<schema>/ by its data schema's scoped name, so these cannot be placed."
286
+ const byId = [
287
+ ...new Set((docs || []).map((d) => documentSchema(d)).filter((m) => m && UUID_RE.test(m)))
288
+ ]
289
+ if (byId.length) {
290
+ return {
291
+ headline: `${lead} could be placed — the backend named their data schemas by id, not by scoped name.`,
292
+ lines: [
293
+ `Named by id: ${byId.slice(0, 3).join(', ')}${byId.length > 3 ? ', …' : ''}`,
294
+ placedBy,
295
+ 'Your record files are unchanged.'
296
+ ]
297
+ }
298
+ }
299
+ const unnamed = (docs || []).filter((d) => !documentSchema(d)).length
300
+ if (unnamed) {
301
+ return {
302
+ headline: `${lead} could be placed — ${unnamed} of the backend's documents name no data schema (\`$schema\`).`,
303
+ lines: [placedBy, 'Your record files are unchanged.']
304
+ }
305
+ }
306
+ const n = skipped.length
307
+ return {
308
+ headline: `${n} record${n === 1 ? '' : 's'} could not be placed — ${n === 1 ? 'its file is' : 'their files are'} unchanged.`,
309
+ lines: skipped.map((s) => `↷ ${s.slug ?? s.uuid ?? '(record)'}: ${s.reason}`)
252
310
  }
253
311
  }
254
312
 
@@ -441,39 +499,66 @@ async function checkWorkingTree(siteDir, args) {
441
499
  * merging afterwards — so the projector stays a plain writer and needs no hook, and
442
500
  * nothing is lost in between because "mine" is already in memory.
443
501
  *
444
- * Requires a repo, and that is not a mandate creeping in: the ancestor IS the
502
+ * Requires a repo, and that is not a mandate creeping in: the ancestor is a
445
503
  * committed version, and the merge is git's. There is nothing to merge against
446
504
  * without one.
447
505
  *
448
- * @returns {Map<string, {content: Buffer, inHead: boolean}>|null} captured work, or
449
- * null when merging isn't possible here (the caller reports and stops).
506
+ * ⭐ THE ANCESTOR IS THE VERSION THE LAST PULL WROTE — found in history by the hash
507
+ * the pull recorded (`findCommitted`), HEAD's only for a file no pull ever wrote.
508
+ * HEAD stops being that version the moment someone commits an edit on top of it,
509
+ * and a merge against it read their edit as the starting point: the backend's
510
+ * version won whole, reverting the committed edit (measured 2026-09-23 — commit,
511
+ * push refused, `uniweb refresh`, the edit gone from the working tree). The same
512
+ * reason brings in work that is COMMITTED but was never pushed: clean to git, so a
513
+ * dirty scan cannot see it, yet the projection would overwrite it.
514
+ *
515
+ * @returns {Map<string, {content: Buffer, base: Buffer|null}>|null} captured work,
516
+ * or null when merging isn't possible here (the caller reports and stops).
450
517
  */
451
518
  function captureLocalWork(siteDir) {
452
519
  const dirty = uncommittedUnder(siteDir, siteContentRoots(siteDir))
453
520
  if (dirty === null) return null
454
521
  const written = readWritten(siteDir)
522
+ const ancestorOf = (rel) => {
523
+ const recorded = written.files[rel]
524
+ return (recorded && findCommitted(siteDir, rel, recorded)) || showAtHead(siteDir, rel)
525
+ }
455
526
  const out = new Map()
456
527
  for (const rel of dirty) {
457
528
  if (isPullOutput(siteDir, rel, written)) continue // pull's own output, not work
458
529
  try {
459
- out.set(rel, {
460
- content: readFileSync(join(siteDir, rel)),
461
- inHead: showAtHead(siteDir, rel) !== null
462
- })
530
+ out.set(rel, { content: readFileSync(join(siteDir, rel)), base: ancestorOf(rel) })
463
531
  } catch {
464
532
  // Locally deleted. Pull will restore the backend's copy, which is the
465
533
  // sensible reading of "I removed this and then asked for their version".
466
534
  }
467
535
  }
536
+ // Committed work: a file a pull wrote, edited and committed since. Its ancestor is
537
+ // only ever the version the pull wrote — HEAD is the edit itself — and when that is
538
+ // not in history there is none, which the merge treats as such.
539
+ for (const [rel, recorded] of Object.entries(written.files)) {
540
+ if (out.has(rel) || dirty.includes(rel)) continue
541
+ let content
542
+ try {
543
+ content = readFileSync(join(siteDir, rel))
544
+ } catch {
545
+ continue
546
+ }
547
+ if (createHash('sha256').update(content).digest('hex') === recorded) continue // untouched
548
+ out.set(rel, { content, base: findCommitted(siteDir, rel, recorded) })
549
+ }
468
550
  return out
469
551
  }
470
552
 
471
- // Merge captured work back over what the projection just wrote. Returns a report.
553
+ // Merge captured work back over what the projection just wrote. Returns a report;
554
+ // `unavailable` lists files whose merge could not run at all — they did NOT take the
555
+ // backend's version, so the caller must not record this pull as taken.
472
556
  function mergeLocalWork(siteDir, captured) {
473
557
  const clean = []
474
558
  const conflicted = []
475
559
  const kept = []
476
- for (const [rel, { content: mine, inHead }] of captured) {
560
+ const unavailable = []
561
+ for (const [rel, { content: mine, base }] of captured) {
477
562
  const abs = join(siteDir, rel)
478
563
  let theirs = null
479
564
  try {
@@ -493,27 +578,23 @@ function mergeLocalWork(siteDir, captured) {
493
578
  }
494
579
  if (theirs.equals(mine)) continue // both sides agree already
495
580
 
496
- const base = inHead ? showAtHead(siteDir, rel) : null
497
- if (!base) {
498
- // No committed ancestor — a file added locally and never committed. There is
499
- // no third input, so a three-way merge is not defined. Keep ours rather than
500
- // guess, and say so.
501
- writeFileSync(abs, mine)
502
- kept.push(`${rel} (no committed ancestor, kept yours)`)
503
- continue
504
- }
505
-
581
+ // ⛔ NO ANCESTOR IS A TWO-WAY MERGE, NOT "KEEP OURS". Both sides hold the file and
582
+ // no common version is on record — added on both sides, or a pull's version that
583
+ // was never committed. Keeping ours hid the backend's version entirely, and the
584
+ // pull then recorded it as taken. An empty ancestor is git's add/add: both
585
+ // versions, under conflict markers, for a person to settle.
506
586
  const dir = mkdtempSync(join(tmpdir(), 'uniweb-merge-'))
507
587
  try {
508
588
  const basePath = join(dir, 'base')
509
589
  const theirsPath = join(dir, 'theirs')
510
- writeFileSync(basePath, base)
590
+ writeFileSync(basePath, base || '')
511
591
  writeFileSync(theirsPath, theirs)
512
592
  writeFileSync(abs, mine) // merge-file rewrites this in place
513
593
  const r = mergeFile(siteDir, abs, basePath, theirsPath)
514
594
  if (!r.merged) {
515
595
  writeFileSync(abs, mine) // merge couldn't run — never leave a half-state
516
596
  kept.push(`${rel} (merge unavailable, kept yours)`)
597
+ unavailable.push(rel)
517
598
  } else if (r.conflicted) {
518
599
  conflicted.push(rel)
519
600
  } else {
@@ -523,7 +604,7 @@ function mergeLocalWork(siteDir, captured) {
523
604
  rmSync(dir, { recursive: true, force: true })
524
605
  }
525
606
  }
526
- return { clean, conflicted, kept }
607
+ return { clean, conflicted, kept, unavailable }
527
608
  }
528
609
 
529
610
  // Minimal yes/no prompt; defaults to no, because the default must not destroy.
@@ -601,15 +682,6 @@ export async function pull(args = [], deps = {}) {
601
682
  args,
602
683
  command: 'Pulling'
603
684
  })
604
- // The workspace these requests name: `--org` explicitly, else the one this project
605
- // recorded for this backend — adopted afresh from the backend's answer when stale.
606
- const orgFlag = readOrgFlag(args)
607
- nameSiteWorkspace(client, {
608
- siteDir,
609
- workspace: orgFlag ? workspaceHandle(orgFlag) : readSiteWorkspace(siteDir, client.origin),
610
- explicit: Boolean(orgFlag),
611
- note
612
- })
613
685
 
614
686
  // ⭐ No scope check any more — and none is needed. This project's identity on
615
687
  // `client.origin` is read from that origin's own section of sync.json, so a
@@ -664,6 +736,17 @@ export async function pull(args = [], deps = {}) {
664
736
  return { exitCode: 0 }
665
737
  }
666
738
 
739
+ // The workspace this pull works in — the one chosen with the login, unless this
740
+ // command names another (`workspace.js`). Resolved at the first request, not before:
741
+ // a pull with nothing to pull never needs one. A site kept elsewhere stops it.
742
+ const ws = await resolveWorkspace({ client, args })
743
+ if (ws.refused) {
744
+ error('This pull works in one workspace, and none is chosen.')
745
+ note(ws.reason)
746
+ return { exitCode: 2 }
747
+ }
748
+ client.setWorkspace(ws.workspace, { source: ws.source })
749
+
667
750
  // GET a pull lane via the client and return `{ docs, etag }` from its `.uwx` (ZIP)
668
751
  // body — `readPullDocuments` reads the entity files out of it (JSON fallback). A
669
752
  // conditional request whose ETag matches returns `{ notModified: true }` (304, empty
@@ -705,12 +788,16 @@ export async function pull(args = [], deps = {}) {
705
788
  const etag = res.headers?.get?.('etag') ?? null
706
789
  const buf = Buffer.from(await res.arrayBuffer())
707
790
  const docs = readPullDocuments(buf)
708
- // Bank the per-entity staleness tokens this lane carried, so the next push
709
- // is gated against the state we just took. A 304 skips this — correctly:
710
- // unchanged upstream means the token we already hold is still current.
711
- mergeBaseVersions(siteDir, client.origin, readPullVersions(buf))
712
- mergeItemBaseVersions(siteDir, client.origin, readPullItemVersions(buf))
713
- return { docs, etag }
791
+ // The staleness tokens this lane carried, so the next push is gated against
792
+ // the state we took. Banked by the caller once local work is merged back — a
793
+ // file the merge did not take must not have its token recorded as taken. A 304
794
+ // carries none, correctly: the tokens we hold are still current.
795
+ return {
796
+ docs,
797
+ etag,
798
+ versions: readPullVersions(buf),
799
+ itemVersions: readPullItemVersions(buf)
800
+ }
714
801
  } catch (err) {
715
802
  error(`Could not read the ${label} response: ${err.message}`)
716
803
  return null
@@ -725,6 +812,9 @@ export async function pull(args = [], deps = {}) {
725
812
  let sections = 0
726
813
  let records = 0
727
814
  let deleted = 0
815
+ // Set when the folder lane carried records this pull did not place — see the ⛔
816
+ // where it is set.
817
+ let recordsNotPlaced = null
728
818
 
729
819
  // Conditional-pull cache: the last ETag seen per lane (opaque token — cached and
730
820
  // echoed verbatim, never recomputed). Lives in the gitignored `.uniweb/`.
@@ -748,16 +838,21 @@ export async function pull(args = [], deps = {}) {
748
838
  return pullScope
749
839
  }
750
840
 
841
+ // ⛔ `--force` IS UNCONDITIONAL. It means "take the backend's version over mine", and
842
+ // an ETag answers a different question — whether the BACKEND moved, never whether
843
+ // the files still hold what it sent. Echoed here, a `git checkout` followed by
844
+ // `pull --force` got a 304 and left the checked-out files in place (2026-09-23).
845
+ const conditional = !force
751
846
  // Lane 1 — content → config + pages/** + layout/**. The .uwx carries a single
752
847
  // entity (the site-content document). A 304 (unchanged) leaves local files as-is.
753
848
  const content = await getDocs('content', () =>
754
- client.pullSiteContent(siteContentUuid, { etag: etagContent })
849
+ client.pullSiteContent(siteContentUuid, { etag: conditional ? etagContent : undefined })
755
850
  )
756
851
  if (content?.refused) return { exitCode: 1 }
757
852
  if (content && !content.notModified) {
758
853
  const siteDoc =
759
854
  content.docs &&
760
- (content.docs.find((d) => d?.info || d?.$model) ||
855
+ (content.docs.find((d) => d?.info || documentSchema(d)) ||
761
856
  content.docs[0] ||
762
857
  null)
763
858
  if (siteDoc) {
@@ -866,17 +961,19 @@ export async function pull(args = [], deps = {}) {
866
961
  // (the backend resolves the site's `@uniweb/folder` from it; the framework never
867
962
  // holds a folder uuid). Models are resolved by name (async) up front, so
868
963
  // recordsToProject keeps its synchronous contract. A 304 leaves files as-is.
964
+ let folderLane = null
869
965
  if (!noRecords) {
870
966
  const folder = await getDocs('records', () =>
871
- client.pullFolder(siteContentUuid, { etag: etagFolder })
967
+ client.pullFolder(siteContentUuid, { etag: conditional ? etagFolder : undefined })
872
968
  )
969
+ folderLane = folder
873
970
  if (folder?.refused) return { exitCode: 1 }
874
971
  if (folder && !folder.notModified && folder.docs?.length) {
875
972
  const { folderDoc, recordDocs } = splitRecordsPull(folder.docs)
876
973
  const resolveModel = makeModelResolver({ client })
877
974
  const declByModel = new Map()
878
975
  for (const model of [
879
- ...new Set(recordDocs.map((d) => d.$model).filter(Boolean))
976
+ ...new Set(recordDocs.map((d) => documentSchema(d)).filter(Boolean))
880
977
  ]) {
881
978
  try {
882
979
  declByModel.set(model, await resolveModel(model))
@@ -884,8 +981,8 @@ export async function pull(args = [], deps = {}) {
884
981
  note(`! could not resolve model ${model}: ${err.message}`)
885
982
  }
886
983
  }
887
- // ⛔ NO QUERY CONFIG. A record's home is decided by what it IS — its
888
- // `$model` names the pool folder — not by any query that happens to select
984
+ // ⛔ NO QUERY CONFIG. A record's home is decided by what it IS — its data
985
+ // schema (`documentSchema`) names the pool folder — not by any query that happens to select
889
986
  // it. Handed the foundation's scope (`scopeFor`), `recordsToProject` places a
890
987
  // `@/x` model the producer resolved to `@acme/x` back where the author wrote it.
891
988
  const report = recordsToProject({
@@ -912,34 +1009,36 @@ export async function pull(args = [], deps = {}) {
912
1009
  removed.push(resolve(siteDir, report.recordsFile))
913
1010
  }
914
1011
  records += report.placed.length + report.updated.length
915
- for (const s of report.skipped)
916
- note(`↷ ${s.slug ?? s.uuid ?? '(record)'}: ${s.reason}`)
1012
+ // ⛔ A PULL THAT DID NOT PLACE A RECORD HAS NOT TAKEN THE BACKEND'S RECORDS.
1013
+ // Until 2026-09-23 each skip was a dim note, the pull said "✓ Pulled … 0
1014
+ // record(s)" and exited 0, and it banked the lane's ETag and tokens. So the next
1015
+ // pull would hear 304 and never retry, and a `refresh && push` would go on to
1016
+ // push — and a push sends the folder whole (`utils/records-guard.js`), so the
1017
+ // skipped records would leave it. It is an error now, the lane is not recorded
1018
+ // as taken (below), and the exit is non-zero (the end).
1019
+ if (report.skipped.length) {
1020
+ recordsNotPlaced = describeUnplacedRecords({
1021
+ docs: folder.docs,
1022
+ taken: report.placed.length + report.updated.length + report.unchanged.length,
1023
+ skipped: report.skipped
1024
+ })
1025
+ error(recordsNotPlaced.headline)
1026
+ for (const line of recordsNotPlaced.lines) note(` ${line}`)
1027
+ }
917
1028
  for (const w of report.warnings) note(`! ${w}`)
918
1029
  }
919
1030
  if (folder?.etag) etagFolder = folder.etag
920
1031
  }
921
1032
 
922
- // Persist the ETags so the next pull is conditional (304 when unchanged).
923
- writePullCache(siteDir, { content: etagContent, folder: etagFolder })
1033
+ // ⛔ WHAT THIS PULL WROTE, AND THE HASHES A PUSH DIFFS AGAINST, DESCRIBE THE
1034
+ // BACKEND'S CONTENT — so both are taken over the projection, BEFORE local work is
1035
+ // merged back. Taken after it, as they were until 2026-09-23, they recorded the
1036
+ // merged-in local edits as pull output and as already pushed: the next push said
1037
+ // "Nothing to push", `status` said in sync, and the edit never left this machine —
1038
+ // while a later plain pull would have overwritten it as its own output.
1039
+ //
924
1040
  // Config files are rewritten every pull whether or not they changed; include the
925
1041
  // ones the projector owns so a pull-then-pull doesn't trip on them either.
926
- // Merge captured local work back over what the projection just wrote. Runs after
927
- // BOTH lanes, so a file either lane produced is merged the same way.
928
- if (captured && captured.size) {
929
- mergeReport = mergeLocalWork(siteDir, captured)
930
- const r = mergeReport
931
- if (r.clean.length) {
932
- info(`Merged your changes into ${r.clean.length} file(s):`)
933
- for (const f of r.clean) note(` ${f}`)
934
- }
935
- for (const f of r.kept) note(`\u21b7 ${f}`)
936
- if (r.conflicted.length) {
937
- error(`${r.conflicted.length} file(s) have conflicts to resolve:`)
938
- for (const f of r.conflicted) note(` ${f}`)
939
- note('Conflict markers are in place. Resolve them, then commit and push.')
940
- }
941
- }
942
-
943
1042
  recordWritten(
944
1043
  siteDir,
945
1044
  [
@@ -979,14 +1078,66 @@ export async function pull(args = [], deps = {}) {
979
1078
  }
980
1079
  }
981
1080
 
982
- success(
1081
+ // Merge captured local work back over what the projection just wrote. Runs after
1082
+ // BOTH lanes, so a file either lane produced is merged the same way.
1083
+ if (captured && captured.size) {
1084
+ mergeReport = mergeLocalWork(siteDir, captured)
1085
+ const r = mergeReport
1086
+ if (r.clean.length) {
1087
+ info(`Merged your changes into ${r.clean.length} file(s):`)
1088
+ for (const f of r.clean) note(` ${f}`)
1089
+ }
1090
+ for (const f of r.kept) note(`\u21b7 ${f}`)
1091
+ if (r.conflicted.length) {
1092
+ error(`${r.conflicted.length} file(s) have conflicts to resolve:`)
1093
+ for (const f of r.conflicted) note(` ${f}`)
1094
+ note('Conflict markers are in place. Resolve them, then commit and push.')
1095
+ }
1096
+ }
1097
+
1098
+ // ⛔ THE TOKENS AND ETAGS SAY "THIS COPY HOLDS THE BACKEND'S VERSION" — so they are
1099
+ // banked only once every file has taken it, merged or in conflict. A token banked
1100
+ // for a file that kept ours makes the next push of it read as "only you changed it"
1101
+ // and overwrite the backend's version; an ETag banked for it makes every later pull
1102
+ // answer 304, so the file never catches up. Measured 2026-09-23, both: a refused
1103
+ // push, `uniweb refresh`, and a push that quietly put back a heading another writer
1104
+ // had replaced. When a merge could not run, nothing of this pull is recorded as
1105
+ // taken, and the next pull fetches it again.
1106
+ const unavailable = mergeReport?.unavailable || []
1107
+ if (unavailable.length) {
1108
+ note("! Not recorded as taken — a merge could not run, so those files keep yours.")
1109
+ note(' Pushing them is refused until a pull merges them; fix git, then `uniweb pull --merge` again.')
1110
+ } else {
1111
+ for (const lane of [content, folderLane]) {
1112
+ if (!lane || lane.notModified || lane.refused) continue
1113
+ // Records this pull did not place were not taken, so neither is their lane.
1114
+ if (lane === folderLane && recordsNotPlaced) continue
1115
+ mergeBaseVersions(siteDir, client.origin, lane.versions)
1116
+ mergeItemBaseVersions(siteDir, client.origin, lane.itemVersions)
1117
+ }
1118
+ // Persist the ETags so the next pull is conditional (304 when unchanged). The
1119
+ // folder's is DROPPED when records were not placed, so the next pull fetches
1120
+ // them again rather than hearing 304 about a state it never took.
1121
+ writePullCache(siteDir, {
1122
+ content: etagContent,
1123
+ folder: recordsNotPlaced ? null : etagFolder
1124
+ })
1125
+ }
1126
+ if (recordsNotPlaced) {
1127
+ note('Records not recorded as taken — the next pull fetches them again.')
1128
+ }
1129
+
1130
+ const summary =
983
1131
  `Pulled — ${pages} page(s), ${sections} section(s), ${records} record(s)` +
984
- (deleted ? `, ${deleted} deleted` : '')
985
- )
1132
+ (deleted ? `, ${deleted} deleted` : '')
1133
+ if (recordsNotPlaced) info(summary)
1134
+ else success(summary)
986
1135
  // Unresolved conflicts are a non-zero exit, the way a conflicted `git merge` is.
987
1136
  // The pull itself worked; there is work left for a human. This is what makes a
988
1137
  // chained `uniweb pull --merge && uniweb push` safe by construction — without it,
989
1138
  // the obvious one-liner pushes conflict markers into live content.
1139
+ // Records it could not place are the same: the pull ran, and what it was sent is not
1140
+ // all in the files — a chained push must not go on as if it were.
990
1141
  const conflicts = mergeReport?.conflicted?.length ?? 0
991
- return { exitCode: conflicts ? 1 : 0, merge: mergeReport }
1142
+ return { exitCode: conflicts || recordsNotPlaced ? 1 : 0, merge: mergeReport }
992
1143
  }