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.
@@ -12,34 +12,41 @@
12
12
  * pages/**, layout/**), and
13
13
  * - folder lane → `recordsToProject` (the folder + record files).
14
14
  *
15
- * Pull is a CHECKOUT, not a merge — the "git-pull-like" it used to claim here was
16
- * misleading. It reconciles the working tree to the backend: section bodies are
17
- * rewritten from the fetched document, and pages/sections the backend no longer has
18
- * are DELETED (toggle off with `--no-delete`). The deletion is guarded so an
19
- * empty/partial payload never wipes the tree.
15
+ * ⭐ IN A GIT REPOSITORY, A PULL IS A MERGE — of the backend's content, as a commit, into
16
+ * the branch. The backend's documents are written onto the BASE: the newest sync point
17
+ * the history holds, the last state of the site this history shares with the backend
18
+ * (`utils/sync-point.js`). That happens in a stand-in, never in the working tree, so the
19
+ * result differs from the base exactly where the backend changed something; it is
20
+ * committed — a new sync point — and merged with git's own merge, whose merge base is
21
+ * the base. So what either side added, changed, deleted or renamed is merged as git
22
+ * merges it: edits to different parts of a file combine silently, only a genuine overlap
23
+ * gets conflict markers (and a non-zero exit), and a file the base never had — a
24
+ * teammate's new section, your uncommitted one — is never pruned. Uncommitted work is
25
+ * stashed for the merge and put back (`--autostash`).
20
26
  *
21
- * Because it overwrites rather than merges, it REFUSES when there is uncommitted
22
- * work under the directories it owns — that was the one remaining way the CLI could
23
- * destroy a user's work without them agreeing to it. Commit or stash first, or pass
24
- * `--force`. Outside a git repository there is nothing to fall back on, so it asks
25
- * instead (and refuses when it cannot).
27
+ * ⛔ Until 2026-10-17 a pull here was a checkout that refused over uncommitted work, and
28
+ * `--merge` three-way merged each file against the version this copy's last pull wrote —
29
+ * a per-machine record, with HEAD where it had none. Committed work outside it was
30
+ * lost: a teammate's new section pruned, a committed record edit overwritten, a fresh
31
+ * clone's committed work reverted (`test/pull-merge-base.test.js`).
26
32
  *
27
- * `--merge` is the third option, and usually the one you want after a refused push:
28
- * three-way merge local work with what the backend sends, instead of choosing
29
- * between them. Most "conflicts" are two people editing different paragraphs of one
30
- * section, which merges silently; only genuine overlaps get conflict markers. The
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`.
33
+ * A history with no sync point — a project synced before there were any, or one whose
34
+ * sync merges were rebased away — merges with no base: a file both sides hold alike
35
+ * merges without a word, and every difference is a conflict to settle. `--force` takes
36
+ * the backend's version whole, and leaves a sync point.
37
+ *
38
+ * Outside a repository there is nothing to merge against: a pull reconciles the files to
39
+ * the backend — section bodies rewritten, pages and sections the backend no longer has
40
+ * DELETED (`--no-delete` keeps them; an empty or partial payload never wipes the tree) —
41
+ * and asks first, unless nothing there is the author's (`pull-written.js`).
34
42
  *
35
43
  * `uniweb login && uniweb pull`. Run from a site, or a workspace with one site.
36
44
  *
37
45
  * Usage:
38
- * uniweb pull GET both lanes, project to files, prune orphans
39
- * uniweb pull --no-records Pull pages only; skip the folder (records) lane
40
- * uniweb pull --no-delete Project, but keep files with no backend item
41
- * uniweb pull --merge Three-way merge local changes with the backend's
42
- * uniweb pull --force Pull over uncommitted local changes (discards them)
46
+ * uniweb pull GET both lanes; in git merge them, else write them
47
+ * uniweb pull --no-records Pull pages only; skip the folder (records) lane
48
+ * uniweb pull --no-delete Keep files with no backend item
49
+ * uniweb pull --force Take the backend's version, discarding local changes
43
50
  * uniweb pull --dry-run Report what it would GET; write nothing
44
51
  *
45
52
  * The content + folder pull lanes are both keyed by the site's uuid on that backend
@@ -59,13 +66,10 @@ import {
59
66
  readFileSync,
60
67
  writeFileSync,
61
68
  mkdirSync,
62
- mkdtempSync,
63
69
  rmSync,
64
70
  readdirSync,
65
71
  statSync
66
72
  } from 'node:fs'
67
- import { tmpdir } from 'node:os'
68
- import { createHash } from 'node:crypto'
69
73
  import { createInterface } from 'node:readline/promises'
70
74
  import { join, dirname, relative, resolve } from 'node:path'
71
75
  import yaml from 'js-yaml'
@@ -102,18 +106,29 @@ import {
102
106
  writeFolderItemUuids
103
107
  } from '../backend/site-sync.js'
104
108
  import { resolveWorkspace } from '../backend/workspace.js'
109
+ import { siteContentRoots } from '../utils/git.js'
105
110
  import {
106
- uncommittedUnder,
107
- siteContentRoots,
108
- showAtHead,
109
- findCommitted,
110
- mergeFile
111
- } from '../utils/git.js'
111
+ repoOf,
112
+ mergeInProgress,
113
+ unmergedPaths,
114
+ isAncestor,
115
+ historySyncPoint,
116
+ madeOnto,
117
+ newestBase,
118
+ openStandIn,
119
+ mergeSyncPoint,
120
+ takeSyncPoint,
121
+ snapshotFiles,
122
+ changedInFiles,
123
+ readSyncPoint,
124
+ writeSyncPoint
125
+ } from '../utils/sync-point.js'
112
126
  import { isNonInteractive } from '../utils/interactive.js'
113
127
  import {
114
128
  BackendClient,
115
129
  describeRequestError,
116
130
  refusalDetail,
131
+ SOURCE_HEADER,
117
132
  WorkspaceMismatchError
118
133
  } from '../backend/client.js'
119
134
  import { resolveSiteDir as defaultResolveSiteDir } from './deploy.js'
@@ -132,7 +147,8 @@ const colors = {
132
147
  green: '\x1b[32m',
133
148
  blue: '\x1b[36m'
134
149
  }
135
- const log = console.log
150
+ // Looked up at each call, so a caller that captures the console captures these too.
151
+ const log = (...a) => console.log(...a)
136
152
  const success = (m) => log(`${colors.green}✓${colors.reset} ${m}`)
137
153
  const error = (m) => console.error(`${colors.red}✗${colors.reset} ${m}`)
138
154
  const info = (m) => log(`${colors.blue}→${colors.reset} ${m}`)
@@ -213,10 +229,11 @@ function readYamlUuid(filePath) {
213
229
  function pullCachePath(siteDir) {
214
230
  return join(siteDir, '.uniweb', 'pull-cache.json')
215
231
  }
216
- // ⛔ VERSION 2 DISOWNS EVERY ETAG A VERSION-1 PULL WROTE. Before 2026-09-23 a
217
- // `--merge` that kept a local file whole — every one, in the default layout (see
218
- // `showAt`) — still cached the ETag of content it never took. Every later pull then
219
- // answered 304 and the file never caught up. Ignoring those once costs one full pull.
232
+ // ⛔ VERSION 2 DISOWNS EVERY ETAG A VERSION-1 PULL WROTE. Before 2026-09-23 a merge that
233
+ // kept a local file whole — every one, in the default layout — still cached the ETag of
234
+ // content it never took. Every later pull then answered 304 and the file never caught up.
235
+ // Ignoring those once costs one full pull. (In a repository an ETag is echoed only while
236
+ // the sync point it was banked with is in the history — see `cacheHolds` below.)
220
237
  const PULL_CACHE_VERSION = 2
221
238
  function readPullCache(siteDir) {
222
239
  try {
@@ -455,191 +472,45 @@ function filesUnder(siteDir, roots) {
455
472
  }
456
473
 
457
474
  /**
458
- * Refuse a pull that would overwrite unsaved work. Returns a result object to
475
+ * Outside git, refuse a pull that would overwrite unsaved work. Returns a result object to
459
476
  * return from `pull`, or null to proceed.
460
477
  *
461
- * With a repo: uncommitted work under pull's roots blocks, naming the files.
462
- * Without one: there is nothing standing behind the tree, so ask — and in a
463
- * non-interactive context refuse rather than assume consent.
478
+ * Nothing stands behind the tree there, so it asks — and in a non-interactive context
479
+ * refuses rather than assume consent — unless nothing under pull's roots is the author's:
480
+ * every file is one a pull, or the clone that made the site, wrote and nothing has changed
481
+ * since (`pull-written.js`). ⛔ Until 2026-09-26 a fresh clone's own pull, which runs with no
482
+ * terminal to ask, refused here, and `uniweb clone` could not finish.
464
483
  */
465
484
  async function checkWorkingTree(siteDir, args) {
466
485
  const roots = siteContentRoots(siteDir)
467
- let dirty = uncommittedUnder(siteDir, roots)
468
-
469
- if (dirty === null) {
470
- // Not a git work tree. Nothing to fall back on if this goes wrong — unless nothing here is the
471
- // author's: every file under pull's roots is one a pull, or the clone that made this site, wrote
472
- // and nothing has changed since (`pull-written.js`). ⛔ Until 2026-09-26 a fresh clone's own pull,
473
- // which runs with no terminal to ask, refused here, and `uniweb clone` could not finish.
474
- const written = readWritten(siteDir)
475
- if (filesUnder(siteDir, roots).every((f) => isPullOutput(siteDir, f, written))) return null
476
- if (isNonInteractive(args)) {
477
- error('Refusing to pull: this site is not in a git repository.')
478
- note(
479
- 'Pull rewrites pages, sections and layout from the backend and deletes what it no longer has.'
480
- )
481
- note(
482
- 'Without version control there is no way back, and this session cannot ask.'
483
- )
484
- note(
485
- 'Re-run with --force to accept that, or put the site under git first.'
486
- )
487
- return { exitCode: 1 }
488
- }
489
- const ok = await confirm(
490
- `Pull will overwrite ${roots.length} content location(s) and this site is not in git. Continue?`
486
+ const written = readWritten(siteDir)
487
+ if (filesUnder(siteDir, roots).every((f) => isPullOutput(siteDir, f, written))) return null
488
+ if (isNonInteractive(args)) {
489
+ error('Refusing to pull: this site is not in a git repository.')
490
+ note(
491
+ 'Pull rewrites pages, sections and layout from the backend and deletes what it no longer has.'
491
492
  )
492
- if (!ok) {
493
- info('Nothing pulled.')
494
- return { exitCode: 0 }
495
- }
496
- return null
493
+ note(
494
+ 'Without version control there is no way back, and this session cannot ask.'
495
+ )
496
+ note(
497
+ 'Re-run with --force to accept that, or put the site under git first — there a pull merges.'
498
+ )
499
+ return { exitCode: 1 }
497
500
  }
498
-
499
- const written = readWritten(siteDir)
500
- dirty = dirty.filter((f) => !isPullOutput(siteDir, f, written))
501
- if (!dirty.length) return null
502
-
503
- error(
504
- `Refusing to pull: ${dirty.length} uncommitted change(s) under the files pull rewrites.`
505
- )
506
- for (const f of dirty.slice(0, 12)) note(` ${f}`)
507
- if (dirty.length > 12) note(` … and ${dirty.length - 12} more`)
508
- note(
509
- 'Pull reconciles the working tree to the backend, so these would be overwritten or deleted.'
501
+ const ok = await confirm(
502
+ `Pull will overwrite ${roots.length} content location(s) and this site is not in git. Continue?`
510
503
  )
511
- note(
512
- 'Commit or stash them first — then pull, and your work is still in git either way.'
513
- )
514
- note('To discard them and take the backend version, re-run with --force.')
515
- return { exitCode: 1 }
516
- }
517
-
518
- // The written-record helpers live in `utils/pull-written.js` so `clone` can share
519
- // them: clone scaffolds site.yml/theme.yml and then delegates here, and this
520
- // module cannot be imported from there — it statically imports `@uniweb/build`,
521
- // which resolves from a project that does not exist yet when clone runs.
522
-
523
- /**
524
- * `--merge`: keep local work instead of refusing, by three-way merging it with what
525
- * the backend sends.
526
- *
527
- * Without this the recovery from a same-section conflict is commit → pull (which
528
- * overwrites your version) → re-apply by hand → push. Yet most such "conflicts"
529
- * aren't: two people edited different paragraphs of one section, and a real merge
530
- * takes both. Only genuine overlaps need a human.
531
- *
532
- * Works by capturing local content BEFORE the projection overwrites it, then
533
- * merging afterwards — so the projector stays a plain writer and needs no hook, and
534
- * nothing is lost in between because "mine" is already in memory.
535
- *
536
- * Requires a repo, and that is not a mandate creeping in: the ancestor is a
537
- * committed version, and the merge is git's. There is nothing to merge against
538
- * without one.
539
- *
540
- * ⭐ THE ANCESTOR IS THE VERSION THE LAST PULL WROTE — found in history by the hash
541
- * the pull recorded (`findCommitted`), HEAD's only for a file no pull ever wrote.
542
- * HEAD stops being that version the moment someone commits an edit on top of it,
543
- * and a merge against it read their edit as the starting point: the backend's
544
- * version won whole, reverting the committed edit (measured 2026-09-23 — commit,
545
- * push refused, `uniweb refresh`, the edit gone from the working tree). The same
546
- * reason brings in work that is COMMITTED but was never pushed: clean to git, so a
547
- * dirty scan cannot see it, yet the projection would overwrite it.
548
- *
549
- * @returns {Map<string, {content: Buffer, base: Buffer|null}>|null} captured work,
550
- * or null when merging isn't possible here (the caller reports and stops).
551
- */
552
- function captureLocalWork(siteDir) {
553
- const dirty = uncommittedUnder(siteDir, siteContentRoots(siteDir))
554
- if (dirty === null) return null
555
- const written = readWritten(siteDir)
556
- const ancestorOf = (rel) => {
557
- const recorded = written.files[rel]
558
- return (recorded && findCommitted(siteDir, rel, recorded)) || showAtHead(siteDir, rel)
559
- }
560
- const out = new Map()
561
- for (const rel of dirty) {
562
- if (isPullOutput(siteDir, rel, written)) continue // pull's own output, not work
563
- try {
564
- out.set(rel, { content: readFileSync(join(siteDir, rel)), base: ancestorOf(rel) })
565
- } catch {
566
- // Locally deleted. Pull will restore the backend's copy, which is the
567
- // sensible reading of "I removed this and then asked for their version".
568
- }
569
- }
570
- // Committed work: a file a pull wrote, edited and committed since. Its ancestor is
571
- // only ever the version the pull wrote — HEAD is the edit itself — and when that is
572
- // not in history there is none, which the merge treats as such.
573
- for (const [rel, recorded] of Object.entries(written.files)) {
574
- if (out.has(rel) || dirty.includes(rel)) continue
575
- let content
576
- try {
577
- content = readFileSync(join(siteDir, rel))
578
- } catch {
579
- continue
580
- }
581
- if (createHash('sha256').update(content).digest('hex') === recorded) continue // untouched
582
- out.set(rel, { content, base: findCommitted(siteDir, rel, recorded) })
504
+ if (!ok) {
505
+ info('Nothing pulled.')
506
+ return { exitCode: 0 }
583
507
  }
584
- return out
508
+ return null
585
509
  }
586
510
 
587
- // Merge captured work back over what the projection just wrote. Returns a report;
588
- // `unavailable` lists files whose merge could not run at all — they did NOT take the
589
- // backend's version, so the caller must not record this pull as taken.
590
- function mergeLocalWork(siteDir, captured) {
591
- const clean = []
592
- const conflicted = []
593
- const kept = []
594
- const unavailable = []
595
- for (const [rel, { content: mine, base }] of captured) {
596
- const abs = join(siteDir, rel)
597
- let theirs = null
598
- try {
599
- theirs = readFileSync(abs)
600
- } catch {
601
- /* pruned by the pull */
602
- }
603
-
604
- if (theirs === null) {
605
- // The backend no longer has it, but we changed it. Restoring is the
606
- // non-destructive reading; the alternative silently discards local work to
607
- // honour a deletion the user never saw.
608
- mkdirSync(dirname(abs), { recursive: true })
609
- writeFileSync(abs, mine)
610
- kept.push(`${rel} (removed upstream, kept yours)`)
611
- continue
612
- }
613
- if (theirs.equals(mine)) continue // both sides agree already
614
-
615
- // ⛔ NO ANCESTOR IS A TWO-WAY MERGE, NOT "KEEP OURS". Both sides hold the file and
616
- // no common version is on record — added on both sides, or a pull's version that
617
- // was never committed. Keeping ours hid the backend's version entirely, and the
618
- // pull then recorded it as taken. An empty ancestor is git's add/add: both
619
- // versions, under conflict markers, for a person to settle.
620
- const dir = mkdtempSync(join(tmpdir(), 'uniweb-merge-'))
621
- try {
622
- const basePath = join(dir, 'base')
623
- const theirsPath = join(dir, 'theirs')
624
- writeFileSync(basePath, base || '')
625
- writeFileSync(theirsPath, theirs)
626
- writeFileSync(abs, mine) // merge-file rewrites this in place
627
- const r = mergeFile(siteDir, abs, basePath, theirsPath)
628
- if (!r.merged) {
629
- writeFileSync(abs, mine) // merge couldn't run — never leave a half-state
630
- kept.push(`${rel} (merge unavailable, kept yours)`)
631
- unavailable.push(rel)
632
- } else if (r.conflicted) {
633
- conflicted.push(rel)
634
- } else {
635
- clean.push(rel)
636
- }
637
- } finally {
638
- rmSync(dir, { recursive: true, force: true })
639
- }
640
- }
641
- return { clean, conflicted, kept, unavailable }
642
- }
511
+ // The written-record helpers live in `utils/pull-written.js`: outside git they are how a
512
+ // pull tells its own output from the author's work (`checkWorkingTree`), and that module
513
+ // imports nothing of `@uniweb/build`.
643
514
 
644
515
  // Minimal yes/no prompt; defaults to no, because the default must not destroy.
645
516
  async function confirm(question) {
@@ -652,6 +523,24 @@ async function confirm(question) {
652
523
  }
653
524
  }
654
525
 
526
+ // Read what a file holds, or null — to put it back if a merge cannot be made.
527
+ function bytesOf(path) {
528
+ try {
529
+ return readFileSync(path)
530
+ } catch {
531
+ return null
532
+ }
533
+ }
534
+ function restore(path, bytes) {
535
+ if (bytes === null) rmSync(path, { force: true })
536
+ else writeFileSync(path, bytes)
537
+ }
538
+
539
+ // The sync point's subject line, and the merge's.
540
+ const pulledFrom = (origin) => `The site's content on ${origin}`
541
+ const mergedFrom = (origin) => `Merge the site's content from ${origin}`
542
+ const takenFrom = (origin) => `Take the site's content from ${origin}`
543
+
655
544
  export async function pull(args = [], deps = {}) {
656
545
  // See utils/flag-guard.js — an unrecognized flag is invisible to a literal scan, so
657
546
  // it silently keeps the default: a mistyped `--dry-run` would pull for real, and a
@@ -669,37 +558,33 @@ export async function pull(args = [], deps = {}) {
669
558
  const noRecords =
670
559
  args.includes('--no-records') || args.includes('--content-only')
671
560
  const force = args.includes('--force')
672
- const mergeMode = args.includes('--merge')
673
561
 
674
562
  const siteDir = await resolveSiteDir(args, 'pull')
675
563
  const fetchAssets = shouldFetchAssets(args, siteDir)
676
564
 
677
- // Don't overwrite work that isn't saved anywhere. Pull reconciles the working
678
- // tree to the backend — it rewrites section bodies from the fetched document and
679
- // prunes what the backend doesn't have — so uncommitted work under the
680
- // directories it owns is destroyed with no way back. That is the one place the
681
- // CLI could still lose a user's work without them agreeing to it.
682
- //
683
- // Checked coarsely, by root, on purpose: pull rewrites essentially every file
684
- // under those roots, so an exact per-file intersection would refuse the same
685
- // cases while being able to miss one.
686
- // `--merge` keeps local work instead of refusing it: capture what is dirty now,
687
- // let the projection write the backend's version, then merge ours back over it.
688
- let captured = null
689
- let mergeReport = null
690
- if (!dryRun && mergeMode) {
691
- captured = captureLocalWork(siteDir)
692
- if (captured === null) {
693
- error('Cannot merge: this site is not in a git repository.')
694
- note(
695
- 'The common ancestor a merge needs is in git history: the version of each file your last pull wrote.'
696
- )
697
- note(
698
- 'Without a repo there is nothing to merge against — use `uniweb pull --force` to take the backend version.'
699
- )
565
+ // ⭐ IN A REPOSITORY A PULL MERGES; OUTSIDE ONE IT CHECKS OUT. A merge needs the state both
566
+ // sides started from, and only a history holds one (`utils/sync-point.js`). Outside git
567
+ // a pull rewrites the files, so work there that a pull did not write stops it.
568
+ const repo = repoOf(siteDir)
569
+ if (repo && !dryRun) {
570
+ if (!repo.head) {
571
+ error('This repository has no commits yet, so there is nothing for a pull to merge into.')
572
+ note('Commit the project, then pull.')
573
+ return { exitCode: 1 }
574
+ }
575
+ if (mergeInProgress(repo)) {
576
+ error('A merge is in progress here.')
577
+ note('Finish it (`git commit`) or abandon it (`git merge --abort`), then pull.')
578
+ return { exitCode: 1 }
579
+ }
580
+ const unsettled = unmergedPaths(repo)
581
+ if (unsettled.length) {
582
+ error(`${unsettled.length} file(s) here are in conflict:`)
583
+ for (const p of unsettled.slice(0, 12)) note(` ${p}`)
584
+ note('Settle them first — `git status` says how — then pull.')
700
585
  return { exitCode: 1 }
701
586
  }
702
- } else if (!dryRun && !force) {
587
+ } else if (!repo && !dryRun && !force) {
703
588
  const blocked = await checkWorkingTree(siteDir, args)
704
589
  if (blocked) return blocked
705
590
  }
@@ -824,15 +709,19 @@ export async function pull(args = [], deps = {}) {
824
709
  }
825
710
  try {
826
711
  const etag = res.headers?.get?.('etag') ?? null
712
+ // ⭐ The commit the backend says this content last came from — a push's, which this
713
+ // history may hold (`utils/sync-point.js`).
714
+ const source = res.headers?.get?.(SOURCE_HEADER) ?? null
827
715
  const buf = Buffer.from(await res.arrayBuffer())
828
716
  const docs = readPullDocuments(buf)
829
717
  // The staleness tokens this lane carried, so the next push is gated against
830
- // the state we took. Banked by the caller once local work is merged back — a
831
- // file the merge did not take must not have its token recorded as taken. A 304
832
- // carries none, correctly: the tokens we hold are still current.
718
+ // the state we took. Banked by the caller once the content is taken — a lane the
719
+ // files did not take must not have its token recorded as taken. A 304 carries
720
+ // none, correctly: the tokens we hold are still current.
833
721
  return {
834
722
  docs,
835
723
  etag,
724
+ source,
836
725
  versions: readPullVersions(buf),
837
726
  itemVersions: readPullItemVersions(buf)
838
727
  }
@@ -842,8 +731,8 @@ export async function pull(args = [], deps = {}) {
842
731
  }
843
732
  }
844
733
 
845
- // Every file the projection writes, so the next pull can tell its own output
846
- // apart from the user's work (see readWritten).
734
+ // Every file the projection writes — outside git, so the next pull can tell its own
735
+ // output from the user's work (see readWritten).
847
736
  const wrote = []
848
737
  const removed = []
849
738
  let pages = 0
@@ -856,53 +745,182 @@ export async function pull(args = [], deps = {}) {
856
745
 
857
746
  // Conditional-pull cache: the last ETag seen per lane (opaque token — cached and
858
747
  // echoed verbatim, never recomputed). Lives in the gitignored `.uniweb/`.
748
+ //
749
+ // ⛔ In a repository an ETag says "the backend has not moved since the content it came
750
+ // with" — about the FILES only while that content is in their history. A merge
751
+ // abandoned, a branch reset: the backend did not move, the files did. So it is echoed
752
+ // only while the sync point it was banked with is.
859
753
  const cache = readPullCache(siteDir)
754
+ const held = repo ? readSyncPoint(siteDir, client.origin) : null
755
+ const cacheHolds = !repo || Boolean(held && isAncestor(repo, held))
860
756
  let etagContent = cache.content
861
757
  let etagFolder = cache.folder
862
758
 
863
- // ⭐ THE SCOPE THE PULLED RECORDS AND QUERIES WERE QUALIFIED WITH — the pushed site's
864
- // FOUNDATION's: the scope of the pinned ref it carries (`info.foundation`), else the
865
- // local foundation's name. A push qualifies `@/x` with it, so this is what undoes it,
866
- // and a record lands back in `records/article/` rather than `records/acme/article/`.
867
- // Resolved once, up front — it may load main.js — and handed to the synchronous
868
- // projections, as Models are. ⛔ It was the site owner's org, from sync.json, until
869
- // 2026-09-22; a foundation may be registered under any org its author belongs to.
870
- let pullScope
871
- const scopeFor = async (doc) => {
872
- if (pullScope === undefined) {
873
- const pinned = doc?.info?.foundation
874
- pullScope = await siteSelfScope(siteDir, pinned !== undefined ? { foundation: pinned } : {})
875
- }
876
- return pullScope
877
- }
878
-
879
759
  // ⛔ `--force` IS UNCONDITIONAL. It means "take the backend's version over mine", and
880
760
  // an ETag answers a different question — whether the BACKEND moved, never whether
881
761
  // the files still hold what it sent. Echoed here, a `git checkout` followed by
882
762
  // `pull --force` got a 304 and left the checked-out files in place (2026-09-23).
883
- const conditional = !force
763
+ const conditional = !force && cacheHolds
764
+
884
765
  // Lane 1 — content → config + pages/** + layout/**. The .uwx carries a single
885
766
  // entity (the site-content document). A 304 (unchanged) leaves local files as-is.
886
767
  const content = await getDocs('content', () =>
887
768
  client.pullSiteContent(siteContentUuid, { etag: conditional ? etagContent : undefined })
888
769
  )
889
770
  if (content?.refused) return { exitCode: 1 }
771
+ const siteDoc =
772
+ content && !content.notModified && content.docs
773
+ ? content.docs.find((d) => d?.info || documentSchema(d)) || content.docs[0] || null
774
+ : null
775
+ if (content?.etag) etagContent = content.etag
776
+
777
+ // Lane 2 — folder → the folder + record files, keyed by the SAME site-content uuid
778
+ // (the backend resolves the site's `@uniweb/folder` from it; the framework never
779
+ // holds a folder uuid). A 304 leaves files as-is.
780
+ let folderLane = null
781
+ if (!noRecords) {
782
+ folderLane = await getDocs('records', () =>
783
+ client.pullFolder(siteContentUuid, { etag: conditional ? etagFolder : undefined })
784
+ )
785
+ if (folderLane?.refused) return { exitCode: 1 }
786
+ if (folderLane?.etag) etagFolder = folderLane.etag
787
+ }
788
+ const folderTaken =
789
+ folderLane && !folderLane.notModified && folderLane.docs?.length
790
+ ? splitRecordsPull(folderLane.docs)
791
+ : null
792
+ const projected = Boolean(siteDoc) || Boolean(folderTaken)
793
+
794
+ // ── What the project itself says, read before anything is written ──────────
795
+ // The foundation, the Models and the scope come from the project as it is: in a
796
+ // repository the content is written into a stand-in, where none of them is read.
797
+
798
+ // ⛔ Do NOT overwrite a workspace project's `site.yml::foundation`.
799
+ //
800
+ // Same principle as restoring authored asset paths, and the same failure it prevents:
801
+ // a round trip must not mangle what the author wrote. `publish` stamps the RELEASED,
802
+ // version-pinned ref into `info.foundation` (delivery is version-pinned end to end),
803
+ // so projecting the stored value back turns `foundation: src` into `@org/x@1.2.3` —
804
+ // which the build then REFUSES to resolve, leaving the project unable to `build`,
805
+ // `dev` or `export`. It stays publishable throughout, so nothing surfaces it.
806
+ //
807
+ // ⚖️ Only suppressed when the AUTHORED value resolves to a local foundation. A project
808
+ // from `uniweb clone` has no local foundation on disk, and there the pinned ref is
809
+ // exactly what site.yml should say — so this is a question about which project shape
810
+ // we are writing into, not a blanket "never project it".
811
+ //
812
+ // Lazily imported: the resolver lives behind `@uniweb/build`'s root, which pulls the
813
+ // vite chain, and this file deliberately imports only the `uwx` leaf. Reused rather
814
+ // than reimplemented so "which foundation" cannot drift from what the build itself
815
+ // resolves.
816
+ let keepAuthoredFoundation = false
817
+ let dataModel = null
890
818
  // The Models the pulled site's queries name, as the push qualified them — the re-bank
891
- // below resolves with them too (see there).
819
+ // resolves with them too.
892
820
  let pulledQueryModels = []
821
+ if (siteDoc) {
822
+ pulledQueryModels = (Array.isArray(siteDoc.queries) ? siteDoc.queries : [])
823
+ .map((q) => q?.schema)
824
+ .filter((s) => typeof s === 'string' && /^@[^/]+\//.test(s))
825
+ try {
826
+ const { resolveLocalFoundation } = await import(
827
+ '../backend/foundation-bring-along.js'
828
+ )
829
+ const authored = yaml.load(readFileSync(siteYmlPath, 'utf8'))
830
+ keepAuthoredFoundation = Boolean(resolveLocalFoundation(siteDir, authored))
831
+ } catch {
832
+ // No site.yml, unreadable, or nothing local — project the stored value, which is
833
+ // the pre-existing behaviour and right for a fresh clone.
834
+ }
835
+ // A foundation named by catalog ref — a clone's: keep its registered version before the
836
+ // queries are written, since its section types are what type a query named for a data key.
837
+ if (!keepAuthoredFoundation) {
838
+ await ensureRegisteredFoundation({ client, siteDir, siteUuid: siteContentUuid, ref: siteDoc?.info?.foundation })
839
+ }
840
+ // A translated data block is read back by what its section's type declares it is, as the
841
+ // push translated it — after the registered foundation above, whose section types say it.
842
+ dataModel = await dataBlockModels(siteDir)
843
+ }
844
+ // ⭐ THE SCOPE THE PULLED RECORDS AND QUERIES WERE QUALIFIED WITH — the pushed site's
845
+ // FOUNDATION's: the scope of the pinned ref it carries (`info.foundation`), else the
846
+ // local foundation's name. A push qualifies `@/x` with it, so this is what undoes it,
847
+ // and a record lands back in `records/article/` rather than `records/acme/article/`.
848
+ // ⛔ It was the site owner's org, from sync.json, until 2026-09-22; a foundation may be
849
+ // registered under any org its author belongs to.
850
+ const pinned = siteDoc?.info?.foundation
851
+ const pullScope = projected
852
+ ? await siteSelfScope(siteDir, pinned !== undefined ? { foundation: pinned } : {})
853
+ : undefined
854
+ // The record lane's Models, by name — resolved up front, so `recordsToProject` keeps its
855
+ // synchronous contract. ⛔ NO QUERY CONFIG: a record's home is decided by what it IS —
856
+ // its data schema (`documentSchema`) names the pool folder — not by any query that
857
+ // happens to select it.
858
+ const declByModel = new Map()
859
+ if (folderTaken) {
860
+ for (const model of [
861
+ ...new Set(folderTaken.recordDocs.map((d) => documentSchema(d)).filter(Boolean))
862
+ ]) {
863
+ try {
864
+ declByModel.set(model, await readModel(model))
865
+ } catch (err) {
866
+ note(`! could not resolve model ${model}: ${err.message}`)
867
+ }
868
+ }
869
+ }
870
+ // ⭐ The re-bank resolves with the Models this pull read, and those its queries name — a
871
+ // query may name a schema no record uses yet. It is offline, and a site whose foundation
872
+ // is not in the project (a clone's) resolves none of them otherwise: every template clone
873
+ // said "could not re-bank" until 2026-09-25, and banked nothing.
874
+ const declarations = new Map(declByModel)
875
+ for (const model of pulledQueryModels) {
876
+ if (declarations.has(model)) continue
877
+ try {
878
+ declarations.set(model, await readModel(model))
879
+ } catch {
880
+ // Unreadable here: the re-bank says so if it needs it.
881
+ }
882
+ }
883
+ const fields = projected ? await deploymentFields({ client, siteDir }) : null
884
+
885
+ // ── Where the backend's content is written ──────────────────────────────────
886
+ // ⭐ In a repository, onto the BASE — the newest sync point HEAD's history holds — in a
887
+ // stand-in, so the result differs from the base exactly where the backend changed
888
+ // something. That result is committed, a new sync point, and merged: git's merge, with
889
+ // the base as its merge base. `--force`, and a history with no sync point, write onto
890
+ // HEAD instead: the first to take the backend's whole, the second so a file both sides
891
+ // hold alike merges without a word.
892
+ const roots = [...siteContentRoots(siteDir), 'public']
893
+ let base = null
894
+ let onto = null
895
+ let standIn = null
896
+ if (repo && projected) {
897
+ base = newestBase(repo, [
898
+ historySyncPoint(repo, { origin: client.origin, siteUuid: siteContentUuid }),
899
+ held,
900
+ // What a sync point the branch no longer holds — a merge abandoned — was made onto.
901
+ ...madeOnto(repo, held, { origin: client.origin, siteUuid: siteContentUuid }),
902
+ content?.source,
903
+ folderLane?.source
904
+ ])
905
+ onto = force || !base ? repo.head : base
906
+ try {
907
+ standIn = openStandIn(repo, { roots, rev: onto })
908
+ } catch (err) {
909
+ error(`Could not merge the site: ${err.message}.`)
910
+ return { exitCode: 1 }
911
+ }
912
+ }
913
+ const projDir = standIn ? standIn.siteDir : siteDir
914
+
893
915
  // The versions this copy holds after the pull: every site-content item the pulled
894
- // document carries (`pulledItemVersions`), banked below once the files have taken it.
916
+ // document carries (`pulledItemVersions`), banked below once the content is taken.
895
917
  let pulledVersions = null
896
- if (content && !content.notModified) {
897
- const siteDoc =
898
- content.docs &&
899
- (content.docs.find((d) => d?.info || documentSchema(d)) ||
900
- content.docs[0] ||
901
- null)
918
+ // The record documents this pull took — the re-bank pairs their list items with the files.
919
+ let pulledRecordDocs = []
920
+ let merge = { conflicted: [], changed: [] }
921
+ let notTaken = false
922
+ try {
902
923
  if (siteDoc) {
903
- pulledQueryModels = (Array.isArray(siteDoc.queries) ? siteDoc.queries : [])
904
- .map((q) => q?.schema)
905
- .filter((s) => typeof s === 'string' && /^@[^/]+\//.test(s))
906
924
  // Re-base the page attribution. The pulled document IS the backend's own
907
925
  // representation, so it becomes the remote base directly. We deliberately
908
926
  // CLEAR the local base rather than reuse it: our source files were just
@@ -910,15 +928,15 @@ export async function pull(args = [], deps = {}) {
910
928
  // byte-identical to it, so the old local base no longer describes anything.
911
929
  // The next push re-establishes it; until then our side reports as unknown,
912
930
  // which is honest rather than wrong.
913
- writeUnitBases(siteDir, client.origin, { remote: computeUnitHashes(siteDoc), local: {} })
931
+ writeUnitBases(projDir, client.origin, { remote: computeUnitHashes(siteDoc), local: {} })
914
932
  pulledVersions = pulledItemVersions(siteDoc, content.itemVersions)
915
933
  // Per-item identity for the next push. Without it the backend reads our
916
934
  // records as new and re-mints every page and section row.
917
- writeItemUuids(siteDir, client.origin, collectUnitUuids(siteDoc))
935
+ writeItemUuids(projDir, client.origin, collectUnitUuids(siteDoc))
918
936
  // The collections section's identity has no file to live in either — same
919
937
  // reason, same remedy, keyed by name. A pull is the other route by which a
920
938
  // copy can recover it (see readQueryUuids).
921
- writeQueryUuids(siteDir, client.origin, collectQueryUuids(siteDoc))
939
+ writeQueryUuids(projDir, client.origin, collectQueryUuids(siteDoc))
922
940
  // Bring the media down BEFORE projecting: a newly-landed asset gains a map
923
941
  // entry, and the projection reads that map to put authored paths back. Run
924
942
  // after, and this pull's new assets would project as URLs and only restore
@@ -931,7 +949,7 @@ export async function pull(args = [], deps = {}) {
931
949
  if (fetchAssets) {
932
950
  const dl = await downloadMissingAssets({
933
951
  document: siteDoc,
934
- siteDir,
952
+ siteDir: projDir,
935
953
  origin: client.origin,
936
954
  onProgress: (m) => note(` ${m}`),
937
955
  warn: (m) => note(`! ${m}`)
@@ -945,61 +963,17 @@ export async function pull(args = [], deps = {}) {
945
963
  )
946
964
  }
947
965
  }
948
-
949
- // ⛔ Do NOT overwrite a workspace project's `site.yml::foundation`.
950
- //
951
- // Same principle as restoring authored asset paths above, and the same
952
- // failure it prevents: a round trip must not mangle what the author wrote.
953
- // `publish` stamps the RELEASED, version-pinned ref into `info.foundation`
954
- // (delivery is version-pinned end to end), so projecting the stored value
955
- // back turns `foundation: src` into `@org/x@1.2.3` — which the build then
956
- // REFUSES to resolve, leaving the project unable to `build`, `dev` or
957
- // `export`. It stays publishable throughout, so nothing surfaces it.
958
- //
959
- // ⚖️ Only suppressed when the AUTHORED value resolves to a local foundation.
960
- // A project from `uniweb clone` has no local foundation on disk, and there
961
- // the pinned ref is exactly what site.yml should say — so this is a
962
- // question about which project shape we are writing into, not a blanket
963
- // "never project it".
964
- //
965
- // Lazily imported: the resolver lives behind `@uniweb/build`'s root, which
966
- // pulls the vite chain, and this file deliberately imports only the `uwx`
967
- // leaf. Reused rather than reimplemented so "which foundation" cannot drift
968
- // from what the build itself resolves.
969
- let keepAuthoredFoundation = false
970
- try {
971
- const { resolveLocalFoundation } = await import(
972
- '../backend/foundation-bring-along.js'
973
- )
974
- const authored = yaml.load(
975
- readFileSync(join(siteDir, 'site.yml'), 'utf8')
976
- )
977
- keepAuthoredFoundation = Boolean(
978
- resolveLocalFoundation(siteDir, authored)
979
- )
980
- } catch {
981
- // No site.yml, unreadable, or nothing local — project the stored value,
982
- // which is the pre-existing behaviour and right for a fresh clone.
983
- }
984
-
985
- // A foundation named by catalog ref — a clone's: keep its registered version before the
986
- // queries are written, since its section types are what type a query named for a data key.
987
- if (!keepAuthoredFoundation) {
988
- await ensureRegisteredFoundation({ client, siteDir, siteUuid: siteContentUuid, ref: siteDoc?.info?.foundation })
989
- }
990
966
  const report = siteContentDocumentToProject({
991
967
  document: siteDoc,
992
- siteRoot: siteDir,
968
+ siteRoot: projDir,
993
969
  // Which backend's asset ids to read back as the author's own paths. Without
994
970
  // it a pull leaves every image pointing at a backend route.
995
971
  backend: client.origin,
996
972
  prune,
997
973
  keepAuthoredFoundation,
998
974
  // So a query's `@acme/member` comes back as the author's `@/member`.
999
- scope: await scopeFor(siteDoc),
1000
- // A translated data block is read back by what its section's type declares it is, as the
1001
- // push translated it — after the registered foundation above, whose section types say it.
1002
- dataModel: await dataBlockModels(siteDir)
975
+ scope: pullScope,
976
+ dataModel
1003
977
  })
1004
978
  wrote.push(...report.pages, ...report.sections, ...report.layout)
1005
979
  removed.push(
@@ -1010,56 +984,21 @@ export async function pull(args = [], deps = {}) {
1010
984
  sections += report.sections.length
1011
985
  deleted += report.deleted.length
1012
986
  }
1013
- if (content.etag) etagContent = content.etag
1014
- }
1015
987
 
1016
- // Lane 2 — folder → the folder + record files, keyed by the SAME site-content uuid
1017
- // (the backend resolves the site's `@uniweb/folder` from it; the framework never
1018
- // holds a folder uuid). Models are resolved by name (async) up front, so
1019
- // recordsToProject keeps its synchronous contract. A 304 leaves files as-is.
1020
- let folderLane = null
1021
- // The record documents this pull took — the re-bank below pairs their list items with the
1022
- // files just written.
1023
- let pulledRecordDocs = []
1024
- // The Models this pull read from the backend, by name — the re-bank below resolves with
1025
- // them, since a clone's foundation is not in the project to resolve them offline.
1026
- let pulledDeclarations = null
1027
- if (!noRecords) {
1028
- const folder = await getDocs('records', () =>
1029
- client.pullFolder(siteContentUuid, { etag: conditional ? etagFolder : undefined })
1030
- )
1031
- folderLane = folder
1032
- if (folder?.refused) return { exitCode: 1 }
1033
- if (folder && !folder.notModified && folder.docs?.length) {
1034
- const { folderDoc, recordDocs } = splitRecordsPull(folder.docs)
988
+ if (folderTaken) {
989
+ const { folderDoc, recordDocs } = folderTaken
1035
990
  pulledRecordDocs = recordDocs
1036
- const resolveModel = readModel
1037
- const declByModel = new Map()
1038
- pulledDeclarations = declByModel
1039
- for (const model of [
1040
- ...new Set(recordDocs.map((d) => documentSchema(d)).filter(Boolean))
1041
- ]) {
1042
- try {
1043
- declByModel.set(model, await resolveModel(model))
1044
- } catch (err) {
1045
- note(`! could not resolve model ${model}: ${err.message}`)
1046
- }
1047
- }
1048
- // ⛔ NO QUERY CONFIG. A record's home is decided by what it IS — its data
1049
- // schema (`documentSchema`) names the pool folder — not by any query that happens to select
1050
- // it. Handed the foundation's scope (`scopeFor`), `recordsToProject` places a
1051
- // `@/x` model the producer resolved to `@acme/x` back where the author wrote it.
1052
991
  const report = recordsToProject({
1053
992
  folderDoc,
1054
993
  recordDocs,
1055
- siteRoot: siteDir,
994
+ siteRoot: projDir,
1056
995
  opts: {
1057
996
  // Whose record map to read and extend.
1058
997
  backend: client.origin,
1059
998
  resolveDeclaration: (name) => declByModel.get(name) || null,
1060
999
  // The scope a model's `@/x` was qualified with, so `@acme/article` is placed
1061
- // back in `records/article/` — see `scopeFor`.
1062
- scope: await scopeFor(null)
1000
+ // back in `records/article/` — see `pullScope`.
1001
+ scope: pullScope
1063
1002
  }
1064
1003
  })
1065
1004
  // ⭐ THE FOLDER'S PLACEMENT IDENTITY, from the folder this pull took — as a push banks
@@ -1067,16 +1006,16 @@ export async function pull(args = [], deps = {}) {
1067
1006
  // had never pushed (every clone) sent its first folder with its placements uuid-less,
1068
1007
  // and the backend refused that push (`identity_required`, section `contents`) —
1069
1008
  // measured on a template clone's first edit.
1070
- if (folderDoc) writeFolderItemUuids(siteDir, client.origin, collectFolderItemUuids(folderDoc))
1009
+ if (folderDoc) writeFolderItemUuids(projDir, client.origin, collectFolderItemUuids(folderDoc))
1071
1010
  // The folder's organization, `folder.yml` in the records directory — named as
1072
1011
  // the author would write it (`records/folder.yml`, or under `paths.records`).
1073
1012
  if (report.records === 'updated') {
1074
1013
  info(`Wrote ${report.recordsFile}`)
1075
- wrote.push(resolve(siteDir, report.recordsFile))
1014
+ wrote.push(resolve(projDir, report.recordsFile))
1076
1015
  } else if (report.records === 'removed') {
1077
1016
  // The backend's folder has no sub-folders, and the file holds nothing else.
1078
1017
  info(`Removed ${report.recordsFile} — the folder has no sub-folders`)
1079
- removed.push(resolve(siteDir, report.recordsFile))
1018
+ removed.push(resolve(projDir, report.recordsFile))
1080
1019
  }
1081
1020
  records += report.placed.length + report.updated.length
1082
1021
  // ⭐ A FILE RECORD'S BYTES (`@uniweb/file`, 2026-09-28): fetched to where `folder.yml` now
@@ -1085,7 +1024,7 @@ export async function pull(args = [], deps = {}) {
1085
1024
  if (report.fileDownloads?.length) {
1086
1025
  const got = await downloadFileRecords({
1087
1026
  downloads: report.fileDownloads,
1088
- siteDir,
1027
+ siteDir: projDir,
1089
1028
  origin: client.origin,
1090
1029
  onProgress: (m) => note(` ${m}`),
1091
1030
  warn: (m) => note(`! ${m}`)
@@ -1094,7 +1033,7 @@ export async function pull(args = [], deps = {}) {
1094
1033
  wrote.push(...got.downloaded)
1095
1034
  if (got.failed.length) {
1096
1035
  report.skipped.push(
1097
- ...got.failed.map((path) => ({ slug: relative(siteDir, path), reason: 'its file could not be fetched' }))
1036
+ ...got.failed.map((path) => ({ slug: relative(projDir, path), reason: 'its file could not be fetched' }))
1098
1037
  )
1099
1038
  }
1100
1039
  }
@@ -1107,7 +1046,7 @@ export async function pull(args = [], deps = {}) {
1107
1046
  // as taken (below), and the exit is non-zero (the end).
1108
1047
  if (report.skipped.length) {
1109
1048
  recordsNotPlaced = describeUnplacedRecords({
1110
- docs: folder.docs,
1049
+ docs: folderLane.docs,
1111
1050
  taken: report.placed.length + report.updated.length + report.unchanged.length,
1112
1051
  skipped: report.skipped
1113
1052
  })
@@ -1116,108 +1055,146 @@ export async function pull(args = [], deps = {}) {
1116
1055
  }
1117
1056
  for (const w of report.warnings) note(`! ${w}`)
1118
1057
  }
1119
- if (folder?.etag) etagFolder = folder.etag
1120
- }
1121
1058
 
1122
- // ⛔ WHAT THIS PULL WROTE, AND THE HASHES A PUSH DIFFS AGAINST, DESCRIBE THE
1123
- // BACKEND'S CONTENT — so both are taken over the projection, BEFORE local work is
1124
- // merged back. Taken after it, as they were until 2026-09-23, they recorded the
1125
- // merged-in local edits as pull output and as already pushed: the next push said
1126
- // "Nothing to push", `status` said in sync, and the edit never left this machine —
1127
- // while a later plain pull would have overwritten it as its own output.
1128
- //
1129
- // Config files are rewritten every pull whether or not they changed; include the
1130
- // ones the projector owns so a pull-then-pull doesn't trip on them either.
1131
- recordWritten(
1132
- siteDir,
1133
- [
1134
- ...wrote,
1135
- // The records folder's `folder.yml` is in `wrote` when this pull wrote it.
1136
- ...['site.yml', 'theme.yml', 'head.html', 'queries.yml'].map((f) =>
1137
- join(siteDir, f)
1059
+ // Outside git: what this pull wrote, so the next one can tell its own output from the
1060
+ // author's work. Config files are rewritten every pull whether or not they changed;
1061
+ // include the ones the projector owns so a pull-then-pull doesn't trip on them either.
1062
+ if (!repo) {
1063
+ recordWritten(
1064
+ siteDir,
1065
+ [
1066
+ ...wrote,
1067
+ // The records folder's `folder.yml` is in `wrote` when this pull wrote it.
1068
+ ...['site.yml', 'theme.yml', 'head.html', 'queries.yml'].map((f) =>
1069
+ join(siteDir, f)
1070
+ )
1071
+ ],
1072
+ removed
1138
1073
  )
1139
- ],
1140
- removed
1141
- )
1074
+ }
1142
1075
 
1143
- // ⛔ RE-BANK THE SEND-ONLY-CHANGED HASHES OVER WHAT WE JUST WROTE.
1144
- //
1145
- // The projection above is canonical, not byte-identical to what was on disk: it
1146
- // moves section ordering out of filename prefixes (`1-hero.md` → `hero.md` plus an
1147
- // explicit `sections:` list) and stamps each section's `id`. Lossless, and a
1148
- // different document — which is exactly why the `local` unit base is cleared above.
1149
- //
1150
- // ⚠️ That same reasoning was never carried to the hashes, so they were left STALE
1151
- // rather than unknown, and `uniweb status` reported unpushed content immediately
1152
- // after a pull, permanently. Measured on matinee 2026-08-29: push → pull reported
1153
- // 1 changed of 8, with nothing edited in between.
1154
- //
1155
- // Re-banking rather than clearing, because after a pull the on-disk state IS the
1156
- // agreed state — it came from the backend, so a push with no edits should send
1157
- // nothing. Clearing would make it send everything.
1158
- //
1159
- // Best-effort: a failure here costs an unnecessary re-send on the next push, never
1160
- // wrong content, and must not fail a pull whose files are already written.
1161
- // Nothing projected — both lanes unchanged (304) — leaves the banked state describing the
1162
- // files as they are, so there is nothing to re-bank. Re-banking anyway only failed, noisily,
1163
- // where the Models cannot be resolved offline (a clone's).
1164
- const projected = Boolean(content && !content.notModified) || Boolean(folderLane && !folderLane.notModified)
1165
- if (!dryRun && projected) {
1166
- try {
1167
- // ⭐ With the Models this pull read, and those its queries name — a query may name a
1168
- // schema no record uses yet. The re-bank is offline, and a site whose foundation is not
1169
- // in the project (a clone's) resolves none of them otherwise: every template clone
1170
- // said "could not re-bank" until 2026-09-25, and banked nothing.
1171
- const declarations = pulledDeclarations || new Map()
1172
- for (const model of pulledQueryModels) {
1173
- if (declarations.has(model)) continue
1174
- try {
1175
- declarations.set(model, await readModel(model))
1176
- } catch {
1177
- // Unreadable here: the re-bank says so if it needs it.
1178
- }
1076
+ // ⛔ RE-BANK THE SEND-ONLY-CHANGED HASHES OVER WHAT THE BACKEND HOLDS.
1077
+ //
1078
+ // The projection is canonical, not byte-identical to what was on disk: it moves
1079
+ // section ordering out of filename prefixes (`1-hero.md` → `hero.md` plus an
1080
+ // explicit `sections:` list) and stamps each section's `id`. Lossless, and a
1081
+ // different document — which is exactly why the `local` unit base is cleared above.
1082
+ //
1083
+ // ⚠️ That same reasoning was never carried to the hashes, so they were left STALE
1084
+ // rather than unknown, and `uniweb status` reported unpushed content immediately
1085
+ // after a pull, permanently. Measured on matinee 2026-08-29: push → pull reported
1086
+ // 1 changed of 8, with nothing edited in between.
1087
+ //
1088
+ // ⭐ Over the backend's content — the stand-in's, in a repository — never over local
1089
+ // work merged into it: a hash banked for an edit says it is already pushed.
1090
+ //
1091
+ // Best-effort: a failure here costs an unnecessary re-send on the next push, never
1092
+ // wrong content, and must not fail a pull whose content is already taken.
1093
+ if (projected) {
1094
+ try {
1095
+ await rebankSyncHashes(projDir, client.origin, {
1096
+ recordDocs: pulledRecordDocs,
1097
+ ...(declarations.size ? { declarations } : {}),
1098
+ fields
1099
+ })
1100
+ } catch (err) {
1101
+ note(`! could not re-bank the sync cache: ${err.message}`)
1102
+ note(' The next push will re-send content that is already current.')
1179
1103
  }
1180
- await rebankSyncHashes(siteDir, client.origin, {
1181
- recordDocs: pulledRecordDocs,
1182
- ...(declarations.size ? { declarations } : {}),
1183
- fields: await deploymentFields({ client, siteDir })
1184
- })
1185
- } catch (err) {
1186
- note(`! could not re-bank the sync cache: ${err.message}`)
1187
- note(' The next push will re-send content that is already current.')
1188
1104
  }
1189
- }
1190
1105
 
1191
- // Merge captured local work back over what the projection just wrote. Runs after
1192
- // BOTH lanes, so a file either lane produced is merged the same way.
1193
- if (captured && captured.size) {
1194
- mergeReport = mergeLocalWork(siteDir, captured)
1195
- const r = mergeReport
1196
- if (r.clean.length) {
1197
- info(`Merged your changes into ${r.clean.length} file(s):`)
1198
- for (const f of r.clean) note(` ${f}`)
1199
- }
1200
- for (const f of r.kept) note(`\u21b7 ${f}`)
1201
- if (r.conflicted.length) {
1202
- error(`${r.conflicted.length} file(s) have conflicts to resolve:`)
1203
- for (const f of r.conflicted) note(` ${f}`)
1204
- note('Conflict markers are in place. Resolve them, then commit and push.')
1106
+ // ── In a repository: commit the backend's content, and merge it ──────────
1107
+ if (standIn) {
1108
+ const syncFile = join(siteDir, 'sync.json')
1109
+ const cacheFile = join(siteDir, '.uniweb', 'backend-cache.json')
1110
+ const before = { sync: bytesOf(syncFile), cache: bytesOf(cacheFile) }
1111
+ // What this copy keeps, taken back from the stand-in: the identity the backend minted
1112
+ // (`sync.json`, before the merge, so a merge's stash carries it) and its caches.
1113
+ const syncNow = bytesOf(join(projDir, 'sync.json'))
1114
+ if (syncNow && !(before.sync && syncNow.equals(before.sync))) writeFileSync(syncFile, syncNow)
1115
+ const cacheNow = bytesOf(join(projDir, '.uniweb', 'backend-cache.json'))
1116
+ if (cacheNow) {
1117
+ mkdirSync(dirname(cacheFile), { recursive: true })
1118
+ writeFileSync(cacheFile, cacheNow)
1119
+ }
1120
+ // ⭐ A history with no sync point gets one even when nothing differs, so the next pull
1121
+ // has a base; so does `--force`.
1122
+ const made = standIn.commit({
1123
+ parent: base,
1124
+ subject: pulledFrom(client.origin),
1125
+ origin: client.origin,
1126
+ siteUuid: siteContentUuid,
1127
+ always: force || !base
1128
+ })
1129
+ if (!made.commit) {
1130
+ // The backend holds what the base holds: nothing to merge.
1131
+ writeSyncPoint(siteDir, client.origin, base)
1132
+ } else {
1133
+ // The files as they are, to tell what the pull changes in them from what they held.
1134
+ const files = snapshotFiles(repo)
1135
+ const result = force
1136
+ ? takeSyncPoint(repo, made.commit, { roots, message: takenFrom(client.origin) })
1137
+ : mergeSyncPoint(repo, made.commit, { message: mergedFrom(client.origin) })
1138
+ if (!result.ok) {
1139
+ // ⛔ A merge that could not be made took nothing: the files are as they were, and
1140
+ // nothing of this pull is recorded as taken.
1141
+ notTaken = true
1142
+ restore(syncFile, before.sync)
1143
+ restore(cacheFile, before.cache)
1144
+ if (result.reason === 'untracked') {
1145
+ error(`Not merged — the backend's content would overwrite ${result.paths.length} file(s) that are not committed:`)
1146
+ for (const p of result.paths.slice(0, 12)) note(` ${p}`)
1147
+ if (result.paths.length > 12) note(` … and ${result.paths.length - 12} more`)
1148
+ note('Commit them, or move them aside, then pull again.')
1149
+ } else if (result.reason === 'merge-in-progress') {
1150
+ error('A merge is in progress here.')
1151
+ note('Finish it (`git commit`) or abandon it (`git merge --abort`), then pull.')
1152
+ } else if (result.reason === 'unmerged') {
1153
+ error(`${result.paths.length} file(s) here are in conflict:`)
1154
+ for (const p of result.paths.slice(0, 12)) note(` ${p}`)
1155
+ note('Settle them first — `git status` says how — then pull.')
1156
+ } else {
1157
+ error("Could not merge the backend's content:")
1158
+ for (const line of String(result.detail).split('\n').slice(0, 8)) note(` ${line}`)
1159
+ }
1160
+ } else {
1161
+ merge.conflicted = result.conflicted || []
1162
+ merge.stashKept = Boolean(result.stashKept)
1163
+ merge.noBase = !force && !base
1164
+ merge.changed = changedInFiles(repo, {
1165
+ from: repo.head,
1166
+ files,
1167
+ staged: merge.conflicted.length > 0 && !merge.stashKept,
1168
+ skip: merge.conflicted
1169
+ })
1170
+ writeSyncPoint(siteDir, client.origin, made.commit)
1171
+ }
1172
+ }
1173
+ // ⭐ What this pull wrote where the repository ignores it travels in no commit — media
1174
+ // in a folder kept out of git — so it is placed where the files lack it, once the
1175
+ // content is taken. Never over a file they have.
1176
+ if (!notTaken) {
1177
+ const placed = standIn.placeIgnored()
1178
+ if (placed.length) {
1179
+ note(`Placed ${placed.length} file(s) the repository ignores, which no commit carries:`)
1180
+ for (const p of placed.slice(0, 12)) note(` ${p}`)
1181
+ if (placed.length > 12) note(` … and ${placed.length - 12} more`)
1182
+ }
1183
+ }
1184
+ standIn.close()
1185
+ standIn = null
1205
1186
  }
1187
+ } finally {
1188
+ standIn?.close()
1206
1189
  }
1207
1190
 
1208
1191
  // ⛔ THE TOKENS AND ETAGS SAY "THIS COPY HOLDS THE BACKEND'S VERSION" — so they are
1209
- // banked only once every file has taken it, merged or in conflict. A token banked
1210
- // for a file that kept ours makes the next push of it read as "only you changed it"
1211
- // and overwrite the backend's version; an ETag banked for it makes every later pull
1212
- // answer 304, so the file never catches up. Measured 2026-09-23, both: a refused
1213
- // push, `uniweb refresh`, and a push that quietly put back a heading another writer
1214
- // had replaced. When a merge could not run, nothing of this pull is recorded as
1215
- // taken, and the next pull fetches it again.
1216
- const unavailable = mergeReport?.unavailable || []
1217
- if (unavailable.length) {
1218
- note("! Not recorded as taken — a merge could not run, so those files keep yours.")
1219
- note(' Pushing them is refused until a pull merges them; fix git, then `uniweb pull --merge` again.')
1220
- } else {
1192
+ // banked only once the content is taken: written, merged, or in conflict for a person to
1193
+ // settle. In a repository they describe the sync point recorded above, and a push uses
1194
+ // them only while HEAD holds it (`untakenBase`, backend/site-sync.js). Measured
1195
+ // 2026-09-23, both, when they were banked for content a merge did not take: a push that
1196
+ // quietly put back a heading another writer had replaced, and pulls answered 304 forever.
1197
+ if (!notTaken) {
1221
1198
  for (const lane of [content, folderLane]) {
1222
1199
  if (!lane || lane.notModified || lane.refused) continue
1223
1200
  // Records this pull did not place were not taken, so neither is their lane.
@@ -1246,14 +1223,50 @@ export async function pull(args = [], deps = {}) {
1246
1223
  const summary =
1247
1224
  `Pulled — ${pages} page(s), ${sections} section(s), ${records} record(s)` +
1248
1225
  (deleted ? `, ${deleted} deleted` : '')
1249
- if (recordsNotPlaced) info(summary)
1226
+ if (notTaken || recordsNotPlaced) info(summary)
1250
1227
  else success(summary)
1228
+ if (repo && !notTaken) {
1229
+ // ⭐ What the pull changed in the files — not the backend's content against the base,
1230
+ // which also lists what the files already held (`changedInFiles`).
1231
+ if (projected && merge.changed.length) {
1232
+ info(
1233
+ force
1234
+ ? `Took ${client.origin}'s version of ${merge.changed.length} file(s):`
1235
+ : `Merged ${merge.changed.length} change(s) from ${client.origin}:`
1236
+ )
1237
+ const label = { A: 'added', M: 'changed', D: 'removed', R: 'renamed' }
1238
+ for (const [s, p] of merge.changed.slice(0, 12)) note(` ${label[s] || s} ${p}`)
1239
+ if (merge.changed.length > 12) note(` … and ${merge.changed.length - 12} more`)
1240
+ if (force) note('What they held is in the commit before this one (`git diff HEAD^1 HEAD`), unless it was never committed.')
1241
+ } else if (!merge.conflicted.length) {
1242
+ note(`Your files already hold what ${client.origin} has.`)
1243
+ }
1244
+ if (merge.conflicted.length) {
1245
+ error(`${merge.conflicted.length} file(s) have conflicts to resolve:`)
1246
+ for (const f of merge.conflicted) note(` ${f}`)
1247
+ if (merge.stashKept) {
1248
+ // The merge is made; what clashed is the uncommitted work put back over it.
1249
+ note('Your uncommitted changes there overlap what the pull brought.')
1250
+ note('Settle the markers and `git add` each file, then `git stash drop` — git kept those changes in the stash as well.')
1251
+ } else {
1252
+ if (merge.noBase) {
1253
+ note(`No commit in this history is one ${client.origin}'s content came from, so nothing tells`)
1254
+ note("your changes from the backend's: each file where they differ is a conflict, yours against the backend's.")
1255
+ note('Once this merge is committed, later pulls merge from it.')
1256
+ }
1257
+ note('Settle them and `git add` each, then `git commit` to finish the merge — your uncommitted changes come back with it.')
1258
+ }
1259
+ }
1260
+ }
1251
1261
  // Unresolved conflicts are a non-zero exit, the way a conflicted `git merge` is.
1252
1262
  // The pull itself worked; there is work left for a human. This is what makes a
1253
- // chained `uniweb pull --merge && uniweb push` safe by construction — without it,
1254
- // the obvious one-liner pushes conflict markers into live content.
1263
+ // chained `uniweb pull && uniweb push` safe by construction — without it, the
1264
+ // obvious one-liner pushes conflict markers into live content.
1255
1265
  // Records it could not place are the same: the pull ran, and what it was sent is not
1256
- // all in the files — a chained push must not go on as if it were.
1257
- const conflicts = mergeReport?.conflicted?.length ?? 0
1258
- return { exitCode: conflicts || recordsNotPlaced ? 1 : 0, merge: mergeReport }
1266
+ // all in the files — a chained push must not go on as if it were. And a merge that
1267
+ // could not be made took nothing.
1268
+ return {
1269
+ exitCode: merge.conflicted.length || recordsNotPlaced || notTaken ? 1 : 0,
1270
+ merge
1271
+ }
1259
1272
  }