uniweb 1.7.4 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,26 @@ 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
+ isAncestor,
114
+ historySyncPoint,
115
+ newestBase,
116
+ openStandIn,
117
+ mergeSyncPoint,
118
+ takeSyncPoint,
119
+ changesBetween,
120
+ readSyncPoint,
121
+ writeSyncPoint
122
+ } from '../utils/sync-point.js'
112
123
  import { isNonInteractive } from '../utils/interactive.js'
113
124
  import {
114
125
  BackendClient,
115
126
  describeRequestError,
116
127
  refusalDetail,
128
+ SOURCE_HEADER,
117
129
  WorkspaceMismatchError
118
130
  } from '../backend/client.js'
119
131
  import { resolveSiteDir as defaultResolveSiteDir } from './deploy.js'
@@ -132,7 +144,8 @@ const colors = {
132
144
  green: '\x1b[32m',
133
145
  blue: '\x1b[36m'
134
146
  }
135
- const log = console.log
147
+ // Looked up at each call, so a caller that captures the console captures these too.
148
+ const log = (...a) => console.log(...a)
136
149
  const success = (m) => log(`${colors.green}✓${colors.reset} ${m}`)
137
150
  const error = (m) => console.error(`${colors.red}✗${colors.reset} ${m}`)
138
151
  const info = (m) => log(`${colors.blue}→${colors.reset} ${m}`)
@@ -213,10 +226,11 @@ function readYamlUuid(filePath) {
213
226
  function pullCachePath(siteDir) {
214
227
  return join(siteDir, '.uniweb', 'pull-cache.json')
215
228
  }
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.
229
+ // ⛔ VERSION 2 DISOWNS EVERY ETAG A VERSION-1 PULL WROTE. Before 2026-09-23 a merge that
230
+ // kept a local file whole — every one, in the default layout — still cached the ETag of
231
+ // content it never took. Every later pull then answered 304 and the file never caught up.
232
+ // Ignoring those once costs one full pull. (In a repository an ETag is echoed only while
233
+ // the sync point it was banked with is in the history — see `cacheHolds` below.)
220
234
  const PULL_CACHE_VERSION = 2
221
235
  function readPullCache(siteDir) {
222
236
  try {
@@ -455,191 +469,45 @@ function filesUnder(siteDir, roots) {
455
469
  }
456
470
 
457
471
  /**
458
- * Refuse a pull that would overwrite unsaved work. Returns a result object to
472
+ * Outside git, refuse a pull that would overwrite unsaved work. Returns a result object to
459
473
  * return from `pull`, or null to proceed.
460
474
  *
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.
475
+ * Nothing stands behind the tree there, so it asks — and in a non-interactive context
476
+ * refuses rather than assume consent — unless nothing under pull's roots is the author's:
477
+ * every file is one a pull, or the clone that made the site, wrote and nothing has changed
478
+ * since (`pull-written.js`). ⛔ Until 2026-09-26 a fresh clone's own pull, which runs with no
479
+ * terminal to ask, refused here, and `uniweb clone` could not finish.
464
480
  */
465
481
  async function checkWorkingTree(siteDir, args) {
466
482
  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?`
483
+ const written = readWritten(siteDir)
484
+ if (filesUnder(siteDir, roots).every((f) => isPullOutput(siteDir, f, written))) return null
485
+ if (isNonInteractive(args)) {
486
+ error('Refusing to pull: this site is not in a git repository.')
487
+ note(
488
+ 'Pull rewrites pages, sections and layout from the backend and deletes what it no longer has.'
491
489
  )
492
- if (!ok) {
493
- info('Nothing pulled.')
494
- return { exitCode: 0 }
495
- }
496
- return null
490
+ note(
491
+ 'Without version control there is no way back, and this session cannot ask.'
492
+ )
493
+ note(
494
+ 'Re-run with --force to accept that, or put the site under git first — there a pull merges.'
495
+ )
496
+ return { exitCode: 1 }
497
497
  }
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.`
498
+ const ok = await confirm(
499
+ `Pull will overwrite ${roots.length} content location(s) and this site is not in git. Continue?`
505
500
  )
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.'
510
- )
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) })
501
+ if (!ok) {
502
+ info('Nothing pulled.')
503
+ return { exitCode: 0 }
583
504
  }
584
- return out
505
+ return null
585
506
  }
586
507
 
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
- }
508
+ // The written-record helpers live in `utils/pull-written.js`: outside git they are how a
509
+ // pull tells its own output from the author's work (`checkWorkingTree`), and that module
510
+ // imports nothing of `@uniweb/build`.
643
511
 
644
512
  // Minimal yes/no prompt; defaults to no, because the default must not destroy.
645
513
  async function confirm(question) {
@@ -652,6 +520,24 @@ async function confirm(question) {
652
520
  }
653
521
  }
654
522
 
523
+ // Read what a file holds, or null — to put it back if a merge cannot be made.
524
+ function bytesOf(path) {
525
+ try {
526
+ return readFileSync(path)
527
+ } catch {
528
+ return null
529
+ }
530
+ }
531
+ function restore(path, bytes) {
532
+ if (bytes === null) rmSync(path, { force: true })
533
+ else writeFileSync(path, bytes)
534
+ }
535
+
536
+ // The sync point's subject line, and the merge's.
537
+ const pulledFrom = (origin) => `The site's content on ${origin}`
538
+ const mergedFrom = (origin) => `Merge the site's content from ${origin}`
539
+ const takenFrom = (origin) => `Take the site's content from ${origin}`
540
+
655
541
  export async function pull(args = [], deps = {}) {
656
542
  // See utils/flag-guard.js — an unrecognized flag is invisible to a literal scan, so
657
543
  // it silently keeps the default: a mistyped `--dry-run` would pull for real, and a
@@ -669,37 +555,26 @@ export async function pull(args = [], deps = {}) {
669
555
  const noRecords =
670
556
  args.includes('--no-records') || args.includes('--content-only')
671
557
  const force = args.includes('--force')
672
- const mergeMode = args.includes('--merge')
673
558
 
674
559
  const siteDir = await resolveSiteDir(args, 'pull')
675
560
  const fetchAssets = shouldFetchAssets(args, siteDir)
676
561
 
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
- )
562
+ // ⭐ IN A REPOSITORY A PULL MERGES; OUTSIDE ONE IT CHECKS OUT. A merge needs the state both
563
+ // sides started from, and only a history holds one (`utils/sync-point.js`). Outside git
564
+ // a pull rewrites the files, so work there that a pull did not write stops it.
565
+ const repo = repoOf(siteDir)
566
+ if (repo && !dryRun) {
567
+ if (!repo.head) {
568
+ error('This repository has no commits yet, so there is nothing for a pull to merge into.')
569
+ note('Commit the project, then pull.')
700
570
  return { exitCode: 1 }
701
571
  }
702
- } else if (!dryRun && !force) {
572
+ if (mergeInProgress(repo)) {
573
+ error('A merge is in progress here.')
574
+ note('Finish it (`git commit`) or abandon it (`git merge --abort`), then pull.')
575
+ return { exitCode: 1 }
576
+ }
577
+ } else if (!repo && !dryRun && !force) {
703
578
  const blocked = await checkWorkingTree(siteDir, args)
704
579
  if (blocked) return blocked
705
580
  }
@@ -824,15 +699,19 @@ export async function pull(args = [], deps = {}) {
824
699
  }
825
700
  try {
826
701
  const etag = res.headers?.get?.('etag') ?? null
702
+ // ⭐ The commit the backend says this content last came from — a push's, which this
703
+ // history may hold (`utils/sync-point.js`).
704
+ const source = res.headers?.get?.(SOURCE_HEADER) ?? null
827
705
  const buf = Buffer.from(await res.arrayBuffer())
828
706
  const docs = readPullDocuments(buf)
829
707
  // 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.
708
+ // the state we took. Banked by the caller once the content is taken — a lane the
709
+ // files did not take must not have its token recorded as taken. A 304 carries
710
+ // none, correctly: the tokens we hold are still current.
833
711
  return {
834
712
  docs,
835
713
  etag,
714
+ source,
836
715
  versions: readPullVersions(buf),
837
716
  itemVersions: readPullItemVersions(buf)
838
717
  }
@@ -842,8 +721,8 @@ export async function pull(args = [], deps = {}) {
842
721
  }
843
722
  }
844
723
 
845
- // Every file the projection writes, so the next pull can tell its own output
846
- // apart from the user's work (see readWritten).
724
+ // Every file the projection writes — outside git, so the next pull can tell its own
725
+ // output from the user's work (see readWritten).
847
726
  const wrote = []
848
727
  const removed = []
849
728
  let pages = 0
@@ -856,53 +735,180 @@ export async function pull(args = [], deps = {}) {
856
735
 
857
736
  // Conditional-pull cache: the last ETag seen per lane (opaque token — cached and
858
737
  // echoed verbatim, never recomputed). Lives in the gitignored `.uniweb/`.
738
+ //
739
+ // ⛔ In a repository an ETag says "the backend has not moved since the content it came
740
+ // with" — about the FILES only while that content is in their history. A merge
741
+ // abandoned, a branch reset: the backend did not move, the files did. So it is echoed
742
+ // only while the sync point it was banked with is.
859
743
  const cache = readPullCache(siteDir)
744
+ const held = repo ? readSyncPoint(siteDir, client.origin) : null
745
+ const cacheHolds = !repo || Boolean(held && isAncestor(repo, held))
860
746
  let etagContent = cache.content
861
747
  let etagFolder = cache.folder
862
748
 
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
749
  // ⛔ `--force` IS UNCONDITIONAL. It means "take the backend's version over mine", and
880
750
  // an ETag answers a different question — whether the BACKEND moved, never whether
881
751
  // the files still hold what it sent. Echoed here, a `git checkout` followed by
882
752
  // `pull --force` got a 304 and left the checked-out files in place (2026-09-23).
883
- const conditional = !force
753
+ const conditional = !force && cacheHolds
754
+
884
755
  // Lane 1 — content → config + pages/** + layout/**. The .uwx carries a single
885
756
  // entity (the site-content document). A 304 (unchanged) leaves local files as-is.
886
757
  const content = await getDocs('content', () =>
887
758
  client.pullSiteContent(siteContentUuid, { etag: conditional ? etagContent : undefined })
888
759
  )
889
760
  if (content?.refused) return { exitCode: 1 }
761
+ const siteDoc =
762
+ content && !content.notModified && content.docs
763
+ ? content.docs.find((d) => d?.info || documentSchema(d)) || content.docs[0] || null
764
+ : null
765
+ if (content?.etag) etagContent = content.etag
766
+
767
+ // Lane 2 — folder → the folder + record files, keyed by the SAME site-content uuid
768
+ // (the backend resolves the site's `@uniweb/folder` from it; the framework never
769
+ // holds a folder uuid). A 304 leaves files as-is.
770
+ let folderLane = null
771
+ if (!noRecords) {
772
+ folderLane = await getDocs('records', () =>
773
+ client.pullFolder(siteContentUuid, { etag: conditional ? etagFolder : undefined })
774
+ )
775
+ if (folderLane?.refused) return { exitCode: 1 }
776
+ if (folderLane?.etag) etagFolder = folderLane.etag
777
+ }
778
+ const folderTaken =
779
+ folderLane && !folderLane.notModified && folderLane.docs?.length
780
+ ? splitRecordsPull(folderLane.docs)
781
+ : null
782
+ const projected = Boolean(siteDoc) || Boolean(folderTaken)
783
+
784
+ // ── What the project itself says, read before anything is written ──────────
785
+ // The foundation, the Models and the scope come from the project as it is: in a
786
+ // repository the content is written into a stand-in, where none of them is read.
787
+
788
+ // ⛔ Do NOT overwrite a workspace project's `site.yml::foundation`.
789
+ //
790
+ // Same principle as restoring authored asset paths, and the same failure it prevents:
791
+ // a round trip must not mangle what the author wrote. `publish` stamps the RELEASED,
792
+ // version-pinned ref into `info.foundation` (delivery is version-pinned end to end),
793
+ // so projecting the stored value back turns `foundation: src` into `@org/x@1.2.3` —
794
+ // which the build then REFUSES to resolve, leaving the project unable to `build`,
795
+ // `dev` or `export`. It stays publishable throughout, so nothing surfaces it.
796
+ //
797
+ // ⚖️ Only suppressed when the AUTHORED value resolves to a local foundation. A project
798
+ // from `uniweb clone` has no local foundation on disk, and there the pinned ref is
799
+ // exactly what site.yml should say — so this is a question about which project shape
800
+ // we are writing into, not a blanket "never project it".
801
+ //
802
+ // Lazily imported: the resolver lives behind `@uniweb/build`'s root, which pulls the
803
+ // vite chain, and this file deliberately imports only the `uwx` leaf. Reused rather
804
+ // than reimplemented so "which foundation" cannot drift from what the build itself
805
+ // resolves.
806
+ let keepAuthoredFoundation = false
807
+ let dataModel = null
890
808
  // The Models the pulled site's queries name, as the push qualified them — the re-bank
891
- // below resolves with them too (see there).
809
+ // resolves with them too.
892
810
  let pulledQueryModels = []
811
+ if (siteDoc) {
812
+ pulledQueryModels = (Array.isArray(siteDoc.queries) ? siteDoc.queries : [])
813
+ .map((q) => q?.schema)
814
+ .filter((s) => typeof s === 'string' && /^@[^/]+\//.test(s))
815
+ try {
816
+ const { resolveLocalFoundation } = await import(
817
+ '../backend/foundation-bring-along.js'
818
+ )
819
+ const authored = yaml.load(readFileSync(siteYmlPath, 'utf8'))
820
+ keepAuthoredFoundation = Boolean(resolveLocalFoundation(siteDir, authored))
821
+ } catch {
822
+ // No site.yml, unreadable, or nothing local — project the stored value, which is
823
+ // the pre-existing behaviour and right for a fresh clone.
824
+ }
825
+ // A foundation named by catalog ref — a clone's: keep its registered version before the
826
+ // queries are written, since its section types are what type a query named for a data key.
827
+ if (!keepAuthoredFoundation) {
828
+ await ensureRegisteredFoundation({ client, siteDir, siteUuid: siteContentUuid, ref: siteDoc?.info?.foundation })
829
+ }
830
+ // A translated data block is read back by what its section's type declares it is, as the
831
+ // push translated it — after the registered foundation above, whose section types say it.
832
+ dataModel = await dataBlockModels(siteDir)
833
+ }
834
+ // ⭐ THE SCOPE THE PULLED RECORDS AND QUERIES WERE QUALIFIED WITH — the pushed site's
835
+ // FOUNDATION's: the scope of the pinned ref it carries (`info.foundation`), else the
836
+ // local foundation's name. A push qualifies `@/x` with it, so this is what undoes it,
837
+ // and a record lands back in `records/article/` rather than `records/acme/article/`.
838
+ // ⛔ It was the site owner's org, from sync.json, until 2026-09-22; a foundation may be
839
+ // registered under any org its author belongs to.
840
+ const pinned = siteDoc?.info?.foundation
841
+ const pullScope = projected
842
+ ? await siteSelfScope(siteDir, pinned !== undefined ? { foundation: pinned } : {})
843
+ : undefined
844
+ // The record lane's Models, by name — resolved up front, so `recordsToProject` keeps its
845
+ // synchronous contract. ⛔ NO QUERY CONFIG: a record's home is decided by what it IS —
846
+ // its data schema (`documentSchema`) names the pool folder — not by any query that
847
+ // happens to select it.
848
+ const declByModel = new Map()
849
+ if (folderTaken) {
850
+ for (const model of [
851
+ ...new Set(folderTaken.recordDocs.map((d) => documentSchema(d)).filter(Boolean))
852
+ ]) {
853
+ try {
854
+ declByModel.set(model, await readModel(model))
855
+ } catch (err) {
856
+ note(`! could not resolve model ${model}: ${err.message}`)
857
+ }
858
+ }
859
+ }
860
+ // ⭐ The re-bank resolves with the Models this pull read, and those its queries name — a
861
+ // query may name a schema no record uses yet. It is offline, and a site whose foundation
862
+ // is not in the project (a clone's) resolves none of them otherwise: every template clone
863
+ // said "could not re-bank" until 2026-09-25, and banked nothing.
864
+ const declarations = new Map(declByModel)
865
+ for (const model of pulledQueryModels) {
866
+ if (declarations.has(model)) continue
867
+ try {
868
+ declarations.set(model, await readModel(model))
869
+ } catch {
870
+ // Unreadable here: the re-bank says so if it needs it.
871
+ }
872
+ }
873
+ const fields = projected ? await deploymentFields({ client, siteDir }) : null
874
+
875
+ // ── Where the backend's content is written ──────────────────────────────────
876
+ // ⭐ In a repository, onto the BASE — the newest sync point HEAD's history holds — in a
877
+ // stand-in, so the result differs from the base exactly where the backend changed
878
+ // something. That result is committed, a new sync point, and merged: git's merge, with
879
+ // the base as its merge base. `--force`, and a history with no sync point, write onto
880
+ // HEAD instead: the first to take the backend's whole, the second so a file both sides
881
+ // hold alike merges without a word.
882
+ const roots = [...siteContentRoots(siteDir), 'public']
883
+ let base = null
884
+ let onto = null
885
+ let standIn = null
886
+ if (repo && projected) {
887
+ base = newestBase(repo, [
888
+ historySyncPoint(repo, { origin: client.origin, siteUuid: siteContentUuid }),
889
+ held,
890
+ content?.source,
891
+ folderLane?.source
892
+ ])
893
+ onto = force || !base ? repo.head : base
894
+ try {
895
+ standIn = openStandIn(repo, { roots, rev: onto })
896
+ } catch (err) {
897
+ error(`Could not merge the site: ${err.message}.`)
898
+ return { exitCode: 1 }
899
+ }
900
+ }
901
+ const projDir = standIn ? standIn.siteDir : siteDir
902
+
893
903
  // 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.
904
+ // document carries (`pulledItemVersions`), banked below once the content is taken.
895
905
  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)
906
+ // The record documents this pull took — the re-bank pairs their list items with the files.
907
+ let pulledRecordDocs = []
908
+ let merge = { conflicted: [], changed: [] }
909
+ let notTaken = false
910
+ try {
902
911
  if (siteDoc) {
903
- pulledQueryModels = (Array.isArray(siteDoc.queries) ? siteDoc.queries : [])
904
- .map((q) => q?.schema)
905
- .filter((s) => typeof s === 'string' && /^@[^/]+\//.test(s))
906
912
  // Re-base the page attribution. The pulled document IS the backend's own
907
913
  // representation, so it becomes the remote base directly. We deliberately
908
914
  // CLEAR the local base rather than reuse it: our source files were just
@@ -910,15 +916,15 @@ export async function pull(args = [], deps = {}) {
910
916
  // byte-identical to it, so the old local base no longer describes anything.
911
917
  // The next push re-establishes it; until then our side reports as unknown,
912
918
  // which is honest rather than wrong.
913
- writeUnitBases(siteDir, client.origin, { remote: computeUnitHashes(siteDoc), local: {} })
919
+ writeUnitBases(projDir, client.origin, { remote: computeUnitHashes(siteDoc), local: {} })
914
920
  pulledVersions = pulledItemVersions(siteDoc, content.itemVersions)
915
921
  // Per-item identity for the next push. Without it the backend reads our
916
922
  // records as new and re-mints every page and section row.
917
- writeItemUuids(siteDir, client.origin, collectUnitUuids(siteDoc))
923
+ writeItemUuids(projDir, client.origin, collectUnitUuids(siteDoc))
918
924
  // The collections section's identity has no file to live in either — same
919
925
  // reason, same remedy, keyed by name. A pull is the other route by which a
920
926
  // copy can recover it (see readQueryUuids).
921
- writeQueryUuids(siteDir, client.origin, collectQueryUuids(siteDoc))
927
+ writeQueryUuids(projDir, client.origin, collectQueryUuids(siteDoc))
922
928
  // Bring the media down BEFORE projecting: a newly-landed asset gains a map
923
929
  // entry, and the projection reads that map to put authored paths back. Run
924
930
  // after, and this pull's new assets would project as URLs and only restore
@@ -931,7 +937,7 @@ export async function pull(args = [], deps = {}) {
931
937
  if (fetchAssets) {
932
938
  const dl = await downloadMissingAssets({
933
939
  document: siteDoc,
934
- siteDir,
940
+ siteDir: projDir,
935
941
  origin: client.origin,
936
942
  onProgress: (m) => note(` ${m}`),
937
943
  warn: (m) => note(`! ${m}`)
@@ -945,61 +951,17 @@ export async function pull(args = [], deps = {}) {
945
951
  )
946
952
  }
947
953
  }
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
954
  const report = siteContentDocumentToProject({
991
955
  document: siteDoc,
992
- siteRoot: siteDir,
956
+ siteRoot: projDir,
993
957
  // Which backend's asset ids to read back as the author's own paths. Without
994
958
  // it a pull leaves every image pointing at a backend route.
995
959
  backend: client.origin,
996
960
  prune,
997
961
  keepAuthoredFoundation,
998
962
  // 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)
963
+ scope: pullScope,
964
+ dataModel
1003
965
  })
1004
966
  wrote.push(...report.pages, ...report.sections, ...report.layout)
1005
967
  removed.push(
@@ -1010,56 +972,21 @@ export async function pull(args = [], deps = {}) {
1010
972
  sections += report.sections.length
1011
973
  deleted += report.deleted.length
1012
974
  }
1013
- if (content.etag) etagContent = content.etag
1014
- }
1015
975
 
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)
976
+ if (folderTaken) {
977
+ const { folderDoc, recordDocs } = folderTaken
1035
978
  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
979
  const report = recordsToProject({
1053
980
  folderDoc,
1054
981
  recordDocs,
1055
- siteRoot: siteDir,
982
+ siteRoot: projDir,
1056
983
  opts: {
1057
984
  // Whose record map to read and extend.
1058
985
  backend: client.origin,
1059
986
  resolveDeclaration: (name) => declByModel.get(name) || null,
1060
987
  // 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)
988
+ // back in `records/article/` — see `pullScope`.
989
+ scope: pullScope
1063
990
  }
1064
991
  })
1065
992
  // ⭐ THE FOLDER'S PLACEMENT IDENTITY, from the folder this pull took — as a push banks
@@ -1067,16 +994,16 @@ export async function pull(args = [], deps = {}) {
1067
994
  // had never pushed (every clone) sent its first folder with its placements uuid-less,
1068
995
  // and the backend refused that push (`identity_required`, section `contents`) —
1069
996
  // measured on a template clone's first edit.
1070
- if (folderDoc) writeFolderItemUuids(siteDir, client.origin, collectFolderItemUuids(folderDoc))
997
+ if (folderDoc) writeFolderItemUuids(projDir, client.origin, collectFolderItemUuids(folderDoc))
1071
998
  // The folder's organization, `folder.yml` in the records directory — named as
1072
999
  // the author would write it (`records/folder.yml`, or under `paths.records`).
1073
1000
  if (report.records === 'updated') {
1074
1001
  info(`Wrote ${report.recordsFile}`)
1075
- wrote.push(resolve(siteDir, report.recordsFile))
1002
+ wrote.push(resolve(projDir, report.recordsFile))
1076
1003
  } else if (report.records === 'removed') {
1077
1004
  // The backend's folder has no sub-folders, and the file holds nothing else.
1078
1005
  info(`Removed ${report.recordsFile} — the folder has no sub-folders`)
1079
- removed.push(resolve(siteDir, report.recordsFile))
1006
+ removed.push(resolve(projDir, report.recordsFile))
1080
1007
  }
1081
1008
  records += report.placed.length + report.updated.length
1082
1009
  // ⭐ A FILE RECORD'S BYTES (`@uniweb/file`, 2026-09-28): fetched to where `folder.yml` now
@@ -1085,7 +1012,7 @@ export async function pull(args = [], deps = {}) {
1085
1012
  if (report.fileDownloads?.length) {
1086
1013
  const got = await downloadFileRecords({
1087
1014
  downloads: report.fileDownloads,
1088
- siteDir,
1015
+ siteDir: projDir,
1089
1016
  origin: client.origin,
1090
1017
  onProgress: (m) => note(` ${m}`),
1091
1018
  warn: (m) => note(`! ${m}`)
@@ -1094,7 +1021,7 @@ export async function pull(args = [], deps = {}) {
1094
1021
  wrote.push(...got.downloaded)
1095
1022
  if (got.failed.length) {
1096
1023
  report.skipped.push(
1097
- ...got.failed.map((path) => ({ slug: relative(siteDir, path), reason: 'its file could not be fetched' }))
1024
+ ...got.failed.map((path) => ({ slug: relative(projDir, path), reason: 'its file could not be fetched' }))
1098
1025
  )
1099
1026
  }
1100
1027
  }
@@ -1107,7 +1034,7 @@ export async function pull(args = [], deps = {}) {
1107
1034
  // as taken (below), and the exit is non-zero (the end).
1108
1035
  if (report.skipped.length) {
1109
1036
  recordsNotPlaced = describeUnplacedRecords({
1110
- docs: folder.docs,
1037
+ docs: folderLane.docs,
1111
1038
  taken: report.placed.length + report.updated.length + report.unchanged.length,
1112
1039
  skipped: report.skipped
1113
1040
  })
@@ -1116,108 +1043,122 @@ export async function pull(args = [], deps = {}) {
1116
1043
  }
1117
1044
  for (const w of report.warnings) note(`! ${w}`)
1118
1045
  }
1119
- if (folder?.etag) etagFolder = folder.etag
1120
- }
1121
1046
 
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)
1047
+ // Outside git: what this pull wrote, so the next one can tell its own output from the
1048
+ // author's work. Config files are rewritten every pull whether or not they changed;
1049
+ // include the ones the projector owns so a pull-then-pull doesn't trip on them either.
1050
+ if (!repo) {
1051
+ recordWritten(
1052
+ siteDir,
1053
+ [
1054
+ ...wrote,
1055
+ // The records folder's `folder.yml` is in `wrote` when this pull wrote it.
1056
+ ...['site.yml', 'theme.yml', 'head.html', 'queries.yml'].map((f) =>
1057
+ join(siteDir, f)
1058
+ )
1059
+ ],
1060
+ removed
1138
1061
  )
1139
- ],
1140
- removed
1141
- )
1062
+ }
1142
1063
 
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
- }
1064
+ // ⛔ RE-BANK THE SEND-ONLY-CHANGED HASHES OVER WHAT THE BACKEND HOLDS.
1065
+ //
1066
+ // The projection is canonical, not byte-identical to what was on disk: it moves
1067
+ // section ordering out of filename prefixes (`1-hero.md` → `hero.md` plus an
1068
+ // explicit `sections:` list) and stamps each section's `id`. Lossless, and a
1069
+ // different document — which is exactly why the `local` unit base is cleared above.
1070
+ //
1071
+ // ⚠️ That same reasoning was never carried to the hashes, so they were left STALE
1072
+ // rather than unknown, and `uniweb status` reported unpushed content immediately
1073
+ // after a pull, permanently. Measured on matinee 2026-08-29: push → pull reported
1074
+ // 1 changed of 8, with nothing edited in between.
1075
+ //
1076
+ // ⭐ Over the backend's content — the stand-in's, in a repository — never over local
1077
+ // work merged into it: a hash banked for an edit says it is already pushed.
1078
+ //
1079
+ // Best-effort: a failure here costs an unnecessary re-send on the next push, never
1080
+ // wrong content, and must not fail a pull whose content is already taken.
1081
+ if (projected) {
1082
+ try {
1083
+ await rebankSyncHashes(projDir, client.origin, {
1084
+ recordDocs: pulledRecordDocs,
1085
+ ...(declarations.size ? { declarations } : {}),
1086
+ fields
1087
+ })
1088
+ } catch (err) {
1089
+ note(`! could not re-bank the sync cache: ${err.message}`)
1090
+ note(' The next push will re-send content that is already current.')
1179
1091
  }
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
1092
  }
1189
- }
1190
1093
 
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.')
1094
+ // ── In a repository: commit the backend's content, and merge it ──────────
1095
+ if (standIn) {
1096
+ const syncFile = join(siteDir, 'sync.json')
1097
+ const cacheFile = join(siteDir, '.uniweb', 'backend-cache.json')
1098
+ const before = { sync: bytesOf(syncFile), cache: bytesOf(cacheFile) }
1099
+ // What this copy keeps, taken back from the stand-in: the identity the backend minted
1100
+ // (`sync.json`, before the merge, so a merge's stash carries it) and its caches.
1101
+ const syncNow = bytesOf(join(projDir, 'sync.json'))
1102
+ if (syncNow && !(before.sync && syncNow.equals(before.sync))) writeFileSync(syncFile, syncNow)
1103
+ const cacheNow = bytesOf(join(projDir, '.uniweb', 'backend-cache.json'))
1104
+ if (cacheNow) {
1105
+ mkdirSync(dirname(cacheFile), { recursive: true })
1106
+ writeFileSync(cacheFile, cacheNow)
1107
+ }
1108
+ // ⭐ A history with no sync point gets one even when nothing differs, so the next pull
1109
+ // has a base; so does `--force`.
1110
+ const made = standIn.commit({
1111
+ parent: base,
1112
+ subject: pulledFrom(client.origin),
1113
+ origin: client.origin,
1114
+ siteUuid: siteContentUuid,
1115
+ always: force || !base
1116
+ })
1117
+ standIn.close()
1118
+ standIn = null
1119
+ if (!made.commit) {
1120
+ // The backend holds what the base holds: nothing to merge.
1121
+ writeSyncPoint(siteDir, client.origin, base)
1122
+ } else {
1123
+ merge.changed = changesBetween(repo, force || !base ? repo.head : base, made.commit)
1124
+ const result = force
1125
+ ? takeSyncPoint(repo, made.commit, { roots, message: takenFrom(client.origin) })
1126
+ : mergeSyncPoint(repo, made.commit, { message: mergedFrom(client.origin) })
1127
+ if (!result.ok) {
1128
+ // ⛔ A merge that could not be made took nothing: the files are as they were, and
1129
+ // nothing of this pull is recorded as taken.
1130
+ notTaken = true
1131
+ restore(syncFile, before.sync)
1132
+ restore(cacheFile, before.cache)
1133
+ if (result.reason === 'untracked') {
1134
+ error(`Not merged — the backend's content would overwrite ${result.paths.length} file(s) that are not committed:`)
1135
+ for (const p of result.paths.slice(0, 12)) note(` ${p}`)
1136
+ if (result.paths.length > 12) note(` … and ${result.paths.length - 12} more`)
1137
+ note('Commit them, or move them aside, then pull again.')
1138
+ } else if (result.reason === 'merge-in-progress') {
1139
+ error('A merge is in progress here.')
1140
+ note('Finish it (`git commit`) or abandon it (`git merge --abort`), then pull.')
1141
+ } else {
1142
+ error("Could not merge the backend's content:")
1143
+ for (const line of String(result.detail).split('\n').slice(0, 8)) note(` ${line}`)
1144
+ }
1145
+ } else {
1146
+ merge.conflicted = result.conflicted || []
1147
+ writeSyncPoint(siteDir, client.origin, made.commit)
1148
+ }
1149
+ }
1205
1150
  }
1151
+ } finally {
1152
+ standIn?.close()
1206
1153
  }
1207
1154
 
1208
1155
  // ⛔ 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 {
1156
+ // banked only once the content is taken: written, merged, or in conflict for a person to
1157
+ // settle. In a repository they describe the sync point recorded above, and a push uses
1158
+ // them only while HEAD holds it (`untakenBase`, backend/site-sync.js). Measured
1159
+ // 2026-09-23, both, when they were banked for content a merge did not take: a push that
1160
+ // quietly put back a heading another writer had replaced, and pulls answered 304 forever.
1161
+ if (!notTaken) {
1221
1162
  for (const lane of [content, folderLane]) {
1222
1163
  if (!lane || lane.notModified || lane.refused) continue
1223
1164
  // Records this pull did not place were not taken, so neither is their lane.
@@ -1246,14 +1187,33 @@ export async function pull(args = [], deps = {}) {
1246
1187
  const summary =
1247
1188
  `Pulled — ${pages} page(s), ${sections} section(s), ${records} record(s)` +
1248
1189
  (deleted ? `, ${deleted} deleted` : '')
1249
- if (recordsNotPlaced) info(summary)
1190
+ if (notTaken || recordsNotPlaced) info(summary)
1250
1191
  else success(summary)
1192
+ if (repo && !notTaken) {
1193
+ if (!projected || !merge.changed.length) {
1194
+ note(`Your files already hold what ${client.origin} has.`)
1195
+ } else {
1196
+ const verb = force ? 'Took' : 'Merged'
1197
+ info(`${verb} ${merge.changed.length} change(s) from ${client.origin}:`)
1198
+ const label = { A: 'added', M: 'changed', D: 'removed', R: 'renamed' }
1199
+ for (const [s, p] of merge.changed.slice(0, 12)) note(` ${label[s] || s} ${p}`)
1200
+ if (merge.changed.length > 12) note(` … and ${merge.changed.length - 12} more`)
1201
+ }
1202
+ if (merge.conflicted.length) {
1203
+ error(`${merge.conflicted.length} file(s) have conflicts to resolve:`)
1204
+ for (const f of merge.conflicted) note(` ${f}`)
1205
+ note('Settle them, then `git commit` to finish the merge — your uncommitted changes come back with it.')
1206
+ }
1207
+ }
1251
1208
  // Unresolved conflicts are a non-zero exit, the way a conflicted `git merge` is.
1252
1209
  // 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.
1210
+ // chained `uniweb pull && uniweb push` safe by construction — without it, the
1211
+ // obvious one-liner pushes conflict markers into live content.
1255
1212
  // 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 }
1213
+ // all in the files — a chained push must not go on as if it were. And a merge that
1214
+ // could not be made took nothing.
1215
+ return {
1216
+ exitCode: merge.conflicted.length || recordsNotPlaced || notTaken ? 1 : 0,
1217
+ merge
1218
+ }
1259
1219
  }