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.
- package/README.md +1 -1
- package/package.json +5 -5
- package/partials/agents.md +2 -2
- package/src/backend/client.js +22 -4
- package/src/backend/site-sync.js +70 -48
- package/src/commands/clone.js +55 -29
- package/src/commands/publish.js +11 -0
- package/src/commands/pull.js +423 -463
- package/src/commands/push.js +12 -0
- package/src/commands/refresh.js +16 -17
- package/src/index.js +5 -4
- package/src/utils/flag-guard.js +20 -8
- package/src/utils/git.js +20 -213
- package/src/utils/pull-written.js +15 -28
- package/src/utils/sync-point.js +512 -0
package/src/commands/pull.js
CHANGED
|
@@ -12,34 +12,41 @@
|
|
|
12
12
|
* pages/**, layout/**), and
|
|
13
13
|
* - folder lane → `recordsToProject` (the folder + record files).
|
|
14
14
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
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
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
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
|
|
39
|
-
* uniweb pull --no-records
|
|
40
|
-
* uniweb pull --no-delete
|
|
41
|
-
* uniweb pull --
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
218
|
-
//
|
|
219
|
-
//
|
|
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
|
-
*
|
|
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
|
-
*
|
|
462
|
-
*
|
|
463
|
-
*
|
|
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
|
-
|
|
468
|
-
|
|
469
|
-
if (
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
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
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
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
|
-
|
|
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
|
-
|
|
507
|
-
|
|
508
|
-
|
|
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
|
|
505
|
+
return null
|
|
585
506
|
}
|
|
586
507
|
|
|
587
|
-
//
|
|
588
|
-
//
|
|
589
|
-
//
|
|
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
|
-
//
|
|
678
|
-
//
|
|
679
|
-
//
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
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
|
-
|
|
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
|
|
831
|
-
//
|
|
832
|
-
//
|
|
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
|
|
846
|
-
//
|
|
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
|
-
//
|
|
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
|
|
904
|
+
// document carries (`pulledItemVersions`), banked below once the content is taken.
|
|
895
905
|
let pulledVersions = null
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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:
|
|
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:
|
|
1000
|
-
|
|
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
|
-
|
|
1017
|
-
|
|
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:
|
|
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 `
|
|
1062
|
-
scope:
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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:
|
|
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
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
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
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
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
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
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
|
|
1210
|
-
//
|
|
1211
|
-
//
|
|
1212
|
-
//
|
|
1213
|
-
//
|
|
1214
|
-
|
|
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
|
|
1254
|
-
//
|
|
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
|
-
|
|
1258
|
-
return {
|
|
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
|
}
|