uniweb 1.8.0 → 1.9.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 CHANGED
@@ -334,7 +334,7 @@ cd src
334
334
  uniweb register --scope @your-org
335
335
  ```
336
336
 
337
- Real sites built on your foundation are managed in the **Uniweb apps** (web + desktop) — visual editors designed for non-technical authors. They never see git, markdown, yaml, or React. They see *your* components, with live previews and visual controls for the params you defined. The foundation becomes the editor's native vocabulary for that site: you keep creative control of the design system, they get an editor that feels custom-built for them.
337
+ Real sites built on your foundation are managed in the **Uniweb app** — a visual editor designed for non-technical authors. They never see git, markdown, yaml, or React. They see *your* components, with live previews and visual controls for the params you defined. The foundation becomes the editor's native vocabulary for that site: you keep creative control of the design system, they get an editor that feels custom-built for them.
338
338
 
339
339
  This is the best path when site content has a life independent of the foundation's release cycle — agencies, design studios, multi-client teams, or any project where content authors aren't the same people as the developers.
340
340
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uniweb",
3
- "version": "1.8.0",
3
+ "version": "1.9.0",
4
4
  "description": "Create structured Vite + React sites with content/code separation",
5
5
  "type": "module",
6
6
  "bin": {
@@ -41,15 +41,15 @@
41
41
  "js-yaml": "^4.1.0",
42
42
  "prompts": "^2.4.2",
43
43
  "tar": "^7.0.0",
44
- "@uniweb/core": "~1.2.2",
45
- "@uniweb/semantic-parser": "~1.8.1",
46
- "@uniweb/content-writer": "~1.1.2",
47
- "@uniweb/schemas": "~1.3.1"
44
+ "@uniweb/core": "~1.2.3",
45
+ "@uniweb/semantic-parser": "~1.9.0",
46
+ "@uniweb/schemas": "~1.4.0",
47
+ "@uniweb/content-writer": "~1.2.0"
48
48
  },
49
49
  "peerDependencies": {
50
- "@uniweb/semantic-parser": "~1.8.1",
51
- "@uniweb/build": "~1.3.9",
52
- "@uniweb/content-reader": "~1.4.0"
50
+ "@uniweb/build": "~1.3.11",
51
+ "@uniweb/semantic-parser": "~1.9.0",
52
+ "@uniweb/content-reader": "~1.4.1"
53
53
  },
54
54
  "peerDependenciesMeta": {
55
55
  "@uniweb/build": {
@@ -2796,8 +2796,6 @@ uniweb snapshot --compare # Several looks on one sheet; take one with it
2796
2796
  uniweb <command> --help # Per-command flags — no side effects. Prefer this over guessing.
2797
2797
  ```
2798
2798
 
2799
- **Three verbs dispatch but are deliberately not listed above.** `invite`, `handoff` and `template` are **reserved names with no implementation** — the flows they named ran against a backend the CLI no longer talks to, and the names are held so a future rebuild doesn't need a breaking change. Running any of them is not useful; their absence from this list is the answer, not an omission to fix.
2800
-
2801
2799
  ### Where a site can live
2802
2800
 
2803
2801
  **There is no lock-in, and no default you're pushed toward.** Four independent paths, and a project can change its mind:
@@ -2850,7 +2848,7 @@ Foundations have their own free path too: `uniweb add ci --target foundation` pu
2850
2848
  - **Git is the reviewed, durable record** — you `pull` content back, read it with `git diff`, and commit.
2851
2849
  - **Authors never push or pull.** For them, content simply updates, whether the change came from another author or from a developer's CLI.
2852
2850
 
2853
- **Nobody's work is overwritten without asking.** `uniweb push` is refused if the site changed on the backend since your last pull — usually an author editing in the app — and reports what changed; nothing is written. Edits to different sections never collide. **In a git repository `uniweb pull` merges, like `git pull`:** it commits the backend's content (*"The site's content on …"*) and merges that commit into your branch, against the last state of the site your history shares with the backend. Your work, committed or not, and a teammate's arriving through git are kept; a file the backend never had is never deleted; changes to different parts of a file combine silently; and only a genuine overlap leaves conflict markers and a non-zero exit — so `uniweb pull && uniweb push` never ships markers. Settle a conflict as in any merge, then `git commit`. `uniweb refresh` runs `git pull` first, then the same pull. A push is also refused when your branch does not hold what this copy last took from the backend — after `git merge --abort`, or in a fresh clone that has not pulled — so pull, then push. Outside git, `uniweb pull` takes the backend's version over your files, asking first. `--force` is the deliberate overwrite (on `push` it replaces the backend's changes, on `pull` it discards yours); don't use it to get past a refusal. `uniweb sync` is `refresh` then `push`; neither publishes.
2851
+ **Nobody's work is overwritten without asking.** `uniweb push` is refused if the site changed on the backend since your last pull — usually an author editing in the app — and reports what changed; nothing is written. Edits to different sections never collide. **In a git repository `uniweb pull` merges, like `git pull`:** it commits the backend's content (*"The site's content on …"*) and merges that commit into your branch, against the last state of the site your history shares with the backend. Your work, committed or not, and a teammate's arriving through git are kept; a file the backend never had is never deleted; changes to different parts of a file combine silently; and only a genuine overlap leaves conflict markers and a non-zero exit — so `uniweb pull && uniweb push` never ships markers. Settle a conflict as in any merge, then `git commit`; where your uncommitted changes overlap what the pull brought, the markers are theirs — settle them and `git add` each file, then `git stash drop` (git keeps them in the stash too). A push or publish is refused while a file under the site is in conflict. `uniweb refresh` runs `git pull` first, then the same pull. A push is also refused when your branch does not hold what this copy last took from the backend — after `git merge --abort`, or in a fresh clone that has not pulled — so pull, then push. Outside git — which includes a folder the repository around it ignores, such as a scratch directory — `uniweb pull` takes the backend's version over your files, asking first. `--force` is the deliberate overwrite (on `push` it replaces the backend's changes, on `pull` it discards yours); don't use it to get past a refusal. `uniweb sync` is `refresh` then `push`; neither publishes.
2854
2852
 
2855
2853
  **`sync.json` says which site this is, on each backend.** The first push to a backend records what that backend assigned — the site's id and owner, the ids of its records and uploaded files — in `sync.json` beside `site.yml`. Commit it; never edit it. **To make a new site from a copy of a project, run `uniweb forget --all` in the copy before its first push** — the copy carries the original's `sync.json`, so otherwise its push updates the original's site. When two projects in one workspace hold the same site, a push or publish from either is refused until that is done. `uniweb forget --server <url>` removes just one backend's records, such as a scratch server's. A record file's `$uuid` is its own id and stays in both cases. **`push`, `pull` and `publish` go to the backend you are logged in to** — the last `uniweb login --server <url>` — and never to one you are not logged in to. A bare `uniweb login` logs in to https://uniweb.app; for any other backend, name it — logging in is how you switch, and the backend commands take no `--server` of their own. When the project has no site on that backend but has one elsewhere, a push says so before creating a new one.
2856
2854
 
@@ -2,7 +2,7 @@
2
2
  * BackendClient — the single front door to the Uniweb backend.
3
3
  *
4
4
  * Every CLI verb that talks to the backend (register, push, pull, clone, org,
5
- * login — and, as the consolidation proceeds, publish/deploy/invite/handoff)
5
+ * login — and, as the consolidation proceeds, publish/deploy)
6
6
  * goes through one instance of this client instead of hand-rolling
7
7
  * `fetch(… Authorization: Bearer …)` against a per-command default origin. It
8
8
  * owns the three things that were previously scattered across a dozen files:
@@ -14,7 +14,16 @@
14
14
  import { writeFileSync, readFileSync, mkdirSync, existsSync, unlinkSync } from 'node:fs'
15
15
  import { join, dirname, relative, isAbsolute, basename } from 'node:path'
16
16
  import yaml from 'js-yaml'
17
- import { repoOf, readSyncPoint, writeSyncPoint, isAncestor } from '../utils/sync-point.js'
17
+ import {
18
+ repoOf,
19
+ readSyncPoint,
20
+ writeSyncPoint,
21
+ isAncestor,
22
+ contentCommitted,
23
+ unmergedPaths,
24
+ mergeInProgress
25
+ } from '../utils/sync-point.js'
26
+ import { siteContentRoots } from '../utils/git.js'
18
27
  import { humanBytes } from '../utils/bytes.js'
19
28
  import { isAuthoredPreview } from '../utils/preview.js'
20
29
  import {
@@ -898,6 +907,31 @@ export function mergeBaseVersions(siteDir, backend, versions) {
898
907
  })
899
908
  }
900
909
 
910
+ /**
911
+ * ⛔ A PUSH NEVER SENDS A FILE IN CONFLICT. Where git marks a file under the site unmerged — a
912
+ * merge stopped on it, or uncommitted work a merge could not put back — the file holds markers,
913
+ * and a push hands them to every author editing the site, then to its visitors once published.
914
+ * Refused whatever the flags: `--force` overwrites the backend's changes, it never sends
915
+ * markers. Returns the refusal, or null.
916
+ *
917
+ * ⛔ Until 2026-10-17 nothing checked it: a pull whose stash could not be put back exited 0,
918
+ * and the push after it sent the markers [flows, measured against a real backend, 1.8.0].
919
+ */
920
+ export function unsettledFiles(siteDir) {
921
+ const repo = repoOf(siteDir)
922
+ if (!repo) return null
923
+ const paths = unmergedPaths(repo).filter((p) => !repo.prefix || p.startsWith(repo.prefix))
924
+ if (!paths.length) return null
925
+ return {
926
+ headline: `Not pushed — ${paths.length} file(s) here are in conflict:`,
927
+ lines: [
928
+ ...paths.slice(0, 12).map((p) => ` ${p}`),
929
+ ...(paths.length > 12 ? [` … and ${paths.length - 12} more`] : []),
930
+ 'Settle each one and `git add` it, then push.'
931
+ ]
932
+ }
933
+ }
934
+
901
935
  /**
902
936
  * Why a gated push must not go — or null when it may.
903
937
  *
@@ -924,6 +958,13 @@ export function untakenBase(siteDir, backend) {
924
958
  if (repo?.head) {
925
959
  const point = readSyncPoint(siteDir, backend)
926
960
  if (held && point && isAncestor(repo, point)) return null
961
+ // A pull's merge waiting for its commit is not one abandoned: say which.
962
+ if (held && point && mergeInProgress(repo)) {
963
+ return {
964
+ headline: 'Not pushed — a merge is in progress here.',
965
+ lines: ['Finish it (`git commit`), then push — or abandon it (`git merge --abort`) and pull again.']
966
+ }
967
+ }
927
968
  return {
928
969
  headline: "Not pushed — your branch does not hold the backend content this copy last took.",
929
970
  lines: [
@@ -946,6 +987,26 @@ export function untakenBase(siteDir, backend) {
946
987
  }
947
988
  }
948
989
 
990
+ /**
991
+ * A push that finds nothing to send has learned that the backend holds what this commit
992
+ * sends — so the commit is a sync point, as the one a push is made from is
993
+ * (`pushSyncPackages`). Kept only where HEAD holds the site's content as the files do: with
994
+ * a change under the roots not committed, HEAD is not what was compared, and a base the
995
+ * backend's content does not descend from would read a committed change the backend lacks
996
+ * as its removal. Returns the commit kept, or null.
997
+ *
998
+ * ⛔ Until 2026-10-17 it kept nothing, so a paragraph re-wrapped and committed — which a push
999
+ * calls "nothing to push", a plain newline being a space — was merged on the next pull from
1000
+ * the commit before it, and conflicted with an author's words typed into it since.
1001
+ */
1002
+ export function holdUnchangedPush(siteDir, backend) {
1003
+ const repo = repoOf(siteDir)
1004
+ if (!repo?.head || !readBackendState(siteDir, backend).site?.uuid) return null
1005
+ if (!contentCommitted(repo, [...siteContentRoots(siteDir), 'public'])) return null
1006
+ writeSyncPoint(siteDir, backend, repo.head)
1007
+ return repo.head
1008
+ }
1009
+
949
1010
  /**
950
1011
  * Per-unit content hashes of the last synced state, kept in BOTH representations —
951
1012
  * the bases for the file-level attribution shown after a staleness refusal
@@ -99,6 +99,9 @@ function slugify(s) {
99
99
  .replace(/^-+|-+$/g, '')
100
100
  }
101
101
 
102
+ // The data schema a site's own document names (`$schema`, as the push writes it).
103
+ const SITE_MODEL = '@uniweb/site-content'
104
+
102
105
  // Tolerant single-entity document extraction (mirrors pull.js; duplicated rather
103
106
  // than imported because pull.js statically imports @uniweb/build).
104
107
  export function extractDocument(payload) {
@@ -173,10 +176,11 @@ function seedSyncJson(siteDir, origin, uuid) {
173
176
  /**
174
177
  * Start the new project's history at a sync point — the site as the backend has it.
175
178
  *
176
- * ⭐ Like `uniweb create`, a clone puts its project under git unless it is already inside a
177
- * repository; and its first commit holds the site as the backend has it, marked so a pull
178
- * merges from it (`utils/sync-point.js`). Inside a repository the clone's pull already left
179
- * one, in the commit that took the site.
179
+ * ⭐ Like `uniweb create`, a clone puts its project under git unless a repository already
180
+ * holds it — one it is inside, in a folder that repository does not ignore (`repoOf`); and
181
+ * its first commit holds the site as the backend has it, marked so a pull merges from it
182
+ * (`utils/sync-point.js`). Inside a repository the clone's pull already left one, in the
183
+ * commit that took the site.
180
184
  */
181
185
  function startHistory({ projectDir, siteDir, origin, siteUuid }) {
182
186
  const git = (args, input) =>
@@ -307,6 +311,15 @@ export async function clone(args = [], deps = {}) {
307
311
  return { exitCode: 1 }
308
312
  }
309
313
  const { foundationRef, name: siteDisplayName } = extractCloneSeeds(document)
314
+ // ⛔ An id that names another entity — a record's, say — comes back as that entity. Say
315
+ // so, rather than ask for a foundation and a name it could never have: only where it
316
+ // names another data schema AND carries neither, so a site is never turned away.
317
+ const named = typeof document.$schema === 'string' ? document.$schema : null
318
+ if (named && named !== SITE_MODEL && !foundationRef && !siteDisplayName) {
319
+ error(`${siteUuid} is not a site on ${client.origin} — it is a ${named} entity.`)
320
+ note('`uniweb clone` takes a site\'s id: `uniweb site list` shows each site with its id.')
321
+ return { exitCode: 1 }
322
+ }
310
323
  if (!foundationRef) {
311
324
  note(
312
325
  '! The pulled site declares no foundation ref — set `foundation:` in site.yml after clone.'
@@ -104,6 +104,7 @@ import {
104
104
  refuseUnsendableRecords,
105
105
  clearRemoteSyncStateIfUnbound,
106
106
  untakenBase,
107
+ unsettledFiles,
107
108
  dropSiteBoundValues,
108
109
  pushSyncPackages
109
110
  } from '../backend/site-sync.js'
@@ -321,6 +322,27 @@ export async function publish(args = []) {
321
322
  }
322
323
  }
323
324
 
325
+ // ⛔ The gated push publish rides claims a base only while the files hold it
326
+ // (`untakenBase`) — said before anything reaches the backend, the foundation's release
327
+ // included. `--force` drops the gate; a dry run does not preview it. ⛔ *Until 2026-10-17
328
+ // it ran after the bring-along and the build, so a refused publish could release a local
329
+ // foundation first.*
330
+ // ⛔ Never a file in conflict — whatever the flags (`unsettledFiles`).
331
+ const unsettled = unsettledFiles(siteDir)
332
+ if (unsettled) {
333
+ say.err(unsettled.headline)
334
+ for (const line of unsettled.lines) say.dim(line)
335
+ return { exitCode: 1 }
336
+ }
337
+ if (!dryRun && !args.includes('--force')) {
338
+ const untaken = untakenBase(siteDir, client.origin)
339
+ if (untaken) {
340
+ say.err(untaken.headline)
341
+ for (const line of untaken.lines) say.dim(line)
342
+ return { exitCode: 1 }
343
+ }
344
+ }
345
+
324
346
  // ⭐ THE WORKSPACE THIS PUBLISH WORKS IN — the one chosen with the login, unless this
325
347
  // command names another (`workspace.js`). Resolved up front: `ensureSiteExists` below
326
348
  // creates the site in it, and a site the backend keeps elsewhere is refused.
@@ -623,17 +645,6 @@ export async function publish(args = []) {
623
645
  `Dropped the previous site's ${droppedValues.join(' and ')} from site.yml.`
624
646
  )
625
647
  }
626
- // ⛔ The gated push publish rides claims a base only while the files hold it — said here,
627
- // before anything is uploaded or created (`untakenBase`). `--force` drops the gate.
628
- if (!args.includes('--force')) {
629
- const untaken = untakenBase(siteDir, client.origin)
630
- if (untaken) {
631
- say.err(untaken.headline)
632
- for (const line of untaken.lines) say.dim(line)
633
- return { exitCode: 1 }
634
- }
635
- }
636
-
637
648
  // 3b. Make sure the SITE EXISTS before a single byte is uploaded.
638
649
  //
639
650
  // Uploaded bytes are metered against an owning entity and reclaimed by
@@ -110,13 +110,17 @@ import { siteContentRoots } from '../utils/git.js'
110
110
  import {
111
111
  repoOf,
112
112
  mergeInProgress,
113
+ unmergedPaths,
114
+ ignoringRepo,
113
115
  isAncestor,
114
116
  historySyncPoint,
117
+ madeOnto,
115
118
  newestBase,
116
119
  openStandIn,
117
120
  mergeSyncPoint,
118
121
  takeSyncPoint,
119
- changesBetween,
122
+ snapshotFiles,
123
+ changedInFiles,
120
124
  readSyncPoint,
121
125
  writeSyncPoint
122
126
  } from '../utils/sync-point.js'
@@ -482,8 +486,15 @@ async function checkWorkingTree(siteDir, args) {
482
486
  const roots = siteContentRoots(siteDir)
483
487
  const written = readWritten(siteDir)
484
488
  if (filesUnder(siteDir, roots).every((f) => isPullOutput(siteDir, f, written))) return null
489
+ // A folder the repository around it ignores is outside git too — said as such, since a
490
+ // repository is right there and "not in a repository" is not what the person sees.
491
+ const ignoredBy = ignoringRepo(siteDir)
485
492
  if (isNonInteractive(args)) {
486
- error('Refusing to pull: this site is not in a git repository.')
493
+ error(
494
+ ignoredBy
495
+ ? 'Refusing to pull: this site is in a folder the repository around it ignores, so no repository holds it.'
496
+ : 'Refusing to pull: this site is not in a git repository.'
497
+ )
487
498
  note(
488
499
  'Pull rewrites pages, sections and layout from the backend and deletes what it no longer has.'
489
500
  )
@@ -491,12 +502,14 @@ async function checkWorkingTree(siteDir, args) {
491
502
  'Without version control there is no way back, and this session cannot ask.'
492
503
  )
493
504
  note(
494
- 'Re-run with --force to accept that, or put the site under git first — there a pull merges.'
505
+ ignoredBy
506
+ ? 'Re-run with --force to accept that, or give the site a repository of its own (`git init`, then commit) — there a pull merges.'
507
+ : 'Re-run with --force to accept that, or put the site under git first — there a pull merges.'
495
508
  )
496
509
  return { exitCode: 1 }
497
510
  }
498
511
  const ok = await confirm(
499
- `Pull will overwrite ${roots.length} content location(s) and this site is not in git. Continue?`
512
+ `Pull will overwrite ${roots.length} content location(s), and no git repository holds this site. Continue?`
500
513
  )
501
514
  if (!ok) {
502
515
  info('Nothing pulled.')
@@ -574,6 +587,13 @@ export async function pull(args = [], deps = {}) {
574
587
  note('Finish it (`git commit`) or abandon it (`git merge --abort`), then pull.')
575
588
  return { exitCode: 1 }
576
589
  }
590
+ const unsettled = unmergedPaths(repo)
591
+ if (unsettled.length) {
592
+ error(`${unsettled.length} file(s) here are in conflict:`)
593
+ for (const p of unsettled.slice(0, 12)) note(` ${p}`)
594
+ note('Settle them first — `git status` says how — then pull.')
595
+ return { exitCode: 1 }
596
+ }
577
597
  } else if (!repo && !dryRun && !force) {
578
598
  const blocked = await checkWorkingTree(siteDir, args)
579
599
  if (blocked) return blocked
@@ -887,6 +907,8 @@ export async function pull(args = [], deps = {}) {
887
907
  base = newestBase(repo, [
888
908
  historySyncPoint(repo, { origin: client.origin, siteUuid: siteContentUuid }),
889
909
  held,
910
+ // What a sync point the branch no longer holds — a merge abandoned — was made onto.
911
+ ...madeOnto(repo, held, { origin: client.origin, siteUuid: siteContentUuid }),
890
912
  content?.source,
891
913
  folderLane?.source
892
914
  ])
@@ -1114,13 +1136,12 @@ export async function pull(args = [], deps = {}) {
1114
1136
  siteUuid: siteContentUuid,
1115
1137
  always: force || !base
1116
1138
  })
1117
- standIn.close()
1118
- standIn = null
1119
1139
  if (!made.commit) {
1120
1140
  // The backend holds what the base holds: nothing to merge.
1121
1141
  writeSyncPoint(siteDir, client.origin, base)
1122
1142
  } else {
1123
- merge.changed = changesBetween(repo, force || !base ? repo.head : base, made.commit)
1143
+ // The files as they are, to tell what the pull changes in them from what they held.
1144
+ const files = snapshotFiles(repo)
1124
1145
  const result = force
1125
1146
  ? takeSyncPoint(repo, made.commit, { roots, message: takenFrom(client.origin) })
1126
1147
  : mergeSyncPoint(repo, made.commit, { message: mergedFrom(client.origin) })
@@ -1138,15 +1159,40 @@ export async function pull(args = [], deps = {}) {
1138
1159
  } else if (result.reason === 'merge-in-progress') {
1139
1160
  error('A merge is in progress here.')
1140
1161
  note('Finish it (`git commit`) or abandon it (`git merge --abort`), then pull.')
1162
+ } else if (result.reason === 'unmerged') {
1163
+ error(`${result.paths.length} file(s) here are in conflict:`)
1164
+ for (const p of result.paths.slice(0, 12)) note(` ${p}`)
1165
+ note('Settle them first — `git status` says how — then pull.')
1141
1166
  } else {
1142
1167
  error("Could not merge the backend's content:")
1143
1168
  for (const line of String(result.detail).split('\n').slice(0, 8)) note(` ${line}`)
1144
1169
  }
1145
1170
  } else {
1146
1171
  merge.conflicted = result.conflicted || []
1172
+ merge.stashKept = Boolean(result.stashKept)
1173
+ merge.noBase = !force && !base
1174
+ merge.changed = changedInFiles(repo, {
1175
+ from: repo.head,
1176
+ files,
1177
+ staged: merge.conflicted.length > 0 && !merge.stashKept,
1178
+ skip: merge.conflicted
1179
+ })
1147
1180
  writeSyncPoint(siteDir, client.origin, made.commit)
1148
1181
  }
1149
1182
  }
1183
+ // ⭐ What this pull wrote where the repository ignores it travels in no commit — media
1184
+ // in a folder kept out of git — so it is placed where the files lack it, once the
1185
+ // content is taken. Never over a file they have.
1186
+ if (!notTaken) {
1187
+ const placed = standIn.placeIgnored()
1188
+ if (placed.length) {
1189
+ note(`Placed ${placed.length} file(s) the repository ignores, which no commit carries:`)
1190
+ for (const p of placed.slice(0, 12)) note(` ${p}`)
1191
+ if (placed.length > 12) note(` … and ${placed.length - 12} more`)
1192
+ }
1193
+ }
1194
+ standIn.close()
1195
+ standIn = null
1150
1196
  }
1151
1197
  } finally {
1152
1198
  standIn?.close()
@@ -1190,19 +1236,36 @@ export async function pull(args = [], deps = {}) {
1190
1236
  if (notTaken || recordsNotPlaced) info(summary)
1191
1237
  else success(summary)
1192
1238
  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}:`)
1239
+ // ⭐ What the pull changed in the files — not the backend's content against the base,
1240
+ // which also lists what the files already held (`changedInFiles`).
1241
+ if (projected && merge.changed.length) {
1242
+ info(
1243
+ force
1244
+ ? `Took ${client.origin}'s version of ${merge.changed.length} file(s):`
1245
+ : `Merged ${merge.changed.length} change(s) from ${client.origin}:`
1246
+ )
1198
1247
  const label = { A: 'added', M: 'changed', D: 'removed', R: 'renamed' }
1199
1248
  for (const [s, p] of merge.changed.slice(0, 12)) note(` ${label[s] || s} ${p}`)
1200
1249
  if (merge.changed.length > 12) note(` … and ${merge.changed.length - 12} more`)
1250
+ if (force) note('What they held is in the commit before this one (`git diff HEAD^1 HEAD`), unless it was never committed.')
1251
+ } else if (!merge.conflicted.length) {
1252
+ note(`Your files already hold what ${client.origin} has.`)
1201
1253
  }
1202
1254
  if (merge.conflicted.length) {
1203
1255
  error(`${merge.conflicted.length} file(s) have conflicts to resolve:`)
1204
1256
  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.')
1257
+ if (merge.stashKept) {
1258
+ // The merge is made; what clashed is the uncommitted work put back over it.
1259
+ note('Your uncommitted changes there overlap what the pull brought.')
1260
+ note('Settle the markers and `git add` each file, then `git stash drop` — git kept those changes in the stash as well.')
1261
+ } else {
1262
+ if (merge.noBase) {
1263
+ note(`No commit in this history is one ${client.origin}'s content came from, so nothing tells`)
1264
+ note("your changes from the backend's: each file where they differ is a conflict, yours against the backend's.")
1265
+ note('Once this merge is committed, later pulls merge from it.')
1266
+ }
1267
+ note('Settle them and `git add` each, then `git commit` to finish the merge — your uncommitted changes come back with it.')
1268
+ }
1206
1269
  }
1207
1270
  }
1208
1271
  // Unresolved conflicts are a non-zero exit, the way a conflicted `git merge` is.
@@ -96,6 +96,8 @@ import {
96
96
  clearRemoteSyncStateIfUnbound,
97
97
  dropSiteBoundValues,
98
98
  untakenBase,
99
+ unsettledFiles,
100
+ holdUnchangedPush,
99
101
  pushSyncPackages
100
102
  } from '../backend/site-sync.js'
101
103
  import { resolveWorkspace, describeWorkspace, SOURCE_LABEL } from '../backend/workspace.js'
@@ -210,6 +212,26 @@ export async function push(args = [], deps = {}) {
210
212
  }
211
213
  }
212
214
 
215
+ // ⛔ A GATED PUSH CLAIMS A BASE ONLY WHILE THE FILES HOLD IT (`untakenBase`) — said before
216
+ // anything reaches the backend, the foundation's release included. A dry run previews it;
217
+ // `-o` reaches no backend, and `--force` drops the gate. ⛔ *Until 2026-10-17 it ran after
218
+ // the bring-along, so a refused push could release a local foundation first.*
219
+ // ⛔ Never a file in conflict — whatever the flags (`unsettledFiles`).
220
+ const unsettled = unsettledFiles(siteDir)
221
+ if (unsettled) {
222
+ error(unsettled.headline)
223
+ for (const line of unsettled.lines) note(line)
224
+ return { exitCode: 1 }
225
+ }
226
+ if (!output && !force) {
227
+ const untaken = untakenBase(siteDir, client.origin)
228
+ if (untaken) {
229
+ error(untaken.headline)
230
+ for (const line of untaken.lines) note(line)
231
+ return { exitCode: 1 }
232
+ }
233
+ }
234
+
213
235
  // ⚠️ Logged in to a backend where this project has no site, while it has one
214
236
  // elsewhere: following the login CREATES a second site. Say so before the owner
215
237
  // question, which otherwise arrives with no reason attached. Not for `-o` (nothing is
@@ -337,17 +359,6 @@ export async function push(args = [], deps = {}) {
337
359
  note(`Dropped the previous site's ${stale.join(' and ')} from site.yml.`)
338
360
  }
339
361
  }
340
- // ⛔ A GATED PUSH CLAIMS A BASE ONLY WHILE THE FILES HOLD IT (`untakenBase`) — said before
341
- // anything is uploaded. A dry run previews it; `-o` reaches no backend, and `--force`
342
- // drops the gate.
343
- if (!output && !force) {
344
- const untaken = untakenBase(siteDir, client.origin)
345
- if (untaken) {
346
- error(untaken.headline)
347
- for (const line of untaken.lines) note(line)
348
- return { exitCode: 1 }
349
- }
350
- }
351
362
  const priorHashes = readSyncCache(siteDir, client.origin)
352
363
  // Per-item identity, without which the backend reads every record as new and
353
364
  // recreates every page and section row.
@@ -613,6 +624,9 @@ export async function push(args = [], deps = {}) {
613
624
  success(
614
625
  `Nothing to push — ${skipped} entit${skipped === 1 ? 'y' : 'ies'} unchanged since the last push.`
615
626
  )
627
+ // ⭐ And so the backend holds what this commit sends: it is where the next pull merges
628
+ // from (`holdUnchangedPush`). Not for a preview, which keeps nothing.
629
+ if (!output && !dryRun) holdUnchangedPush(siteDir, client.origin)
616
630
  return { exitCode: 0 }
617
631
  }
618
632
  if (siteContent)
@@ -52,6 +52,7 @@ import {
52
52
  pullRemote,
53
53
  headProvenance
54
54
  } from '../utils/git.js'
55
+ import { ignoringRepo } from '../utils/sync-point.js'
55
56
  import { probeUnpushed } from '../backend/site-sync.js'
56
57
  import { resolveBackendOrigin } from '../backend/client.js'
57
58
  import { readBackendState } from '@uniweb/build/uwx'
@@ -124,20 +125,25 @@ export async function refresh(args = [], deps = {}) {
124
125
  // when they are not.
125
126
  const consulted = []
126
127
  const skipped = []
128
+ // Said at the end — and before stopping on a failure, which can follow a step skipped.
129
+ const tell = () => {
130
+ if (consulted.length) say.dim(`Checked: ${consulted.join(', ')}`)
131
+ for (const s of skipped) say.dim(`Skipped: ${s}`)
132
+ }
127
133
  let conflicts = 0
128
134
 
129
135
  // ── 1. the git remote ─────────────────────────────────────────────────────
130
136
  if (skipGit) {
131
137
  skipped.push('git (--no-git)')
132
138
  } else if (!isGitRepo(siteDir)) {
133
- skipped.push('git (not a repository)')
139
+ skipped.push(ignoringRepo(siteDir) ? 'git (the repository around this folder ignores it)' : 'git (not a repository)')
134
140
  } else if (!hasRemote(siteDir)) {
135
141
  skipped.push('git (no remote configured)')
136
142
  } else {
137
143
  say.info('Pulling from the git remote…')
138
144
  const r = pullRemote(siteDir)
139
145
  if (!r.ok) {
140
- say.err('git pull failed — resolve this before continuing.')
146
+ say.err(r.conflicts ? 'git pull left conflicts — settle them before continuing.' : 'git pull failed — resolve this before continuing.')
141
147
  for (const line of r.message.split('\n').slice(0, 6)) say.dim(line)
142
148
  // Deliberately stop. Layering the backend's content on top of an unresolved
143
149
  // git state gives the user two independent conflicts at once, and no clear
@@ -173,6 +179,7 @@ export async function refresh(args = [], deps = {}) {
173
179
  // Failed for a reason other than conflicts — say so plainly rather than
174
180
  // reporting a clean refresh over a lane that never ran.
175
181
  say.err('Could not merge the backend content.')
182
+ tell()
176
183
  return { exitCode: res.exitCode }
177
184
  }
178
185
  consulted.push('backend')
@@ -180,8 +187,7 @@ export async function refresh(args = [], deps = {}) {
180
187
 
181
188
  // ── 3. where that leaves you ──────────────────────────────────────────────
182
189
  console.log('')
183
- if (consulted.length) say.dim(`Checked: ${consulted.join(', ')}`)
184
- for (const s of skipped) say.dim(`Skipped: ${s}`)
190
+ tell()
185
191
 
186
192
  const git = headProvenance(siteDir)
187
193
  if (git)
@@ -1,21 +1,21 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-10-17T17:49:32.100Z",
3
+ "generatedAt": "2026-10-18T05:12:16.689Z",
4
4
  "packages": {
5
5
  "@uniweb/api": {
6
- "version": "1.4.0"
6
+ "version": "1.5.0"
7
7
  },
8
8
  "@uniweb/build": {
9
- "version": "1.3.9"
9
+ "version": "1.3.11"
10
10
  },
11
11
  "@uniweb/content-reader": {
12
- "version": "1.4.0"
12
+ "version": "1.4.1"
13
13
  },
14
14
  "@uniweb/content-writer": {
15
- "version": "1.1.2"
15
+ "version": "1.2.0"
16
16
  },
17
17
  "@uniweb/core": {
18
- "version": "1.2.2"
18
+ "version": "1.2.3"
19
19
  },
20
20
  "@uniweb/frame-bridge": {
21
21
  "version": "1.0.2"
@@ -24,7 +24,7 @@
24
24
  "version": "1.0.3"
25
25
  },
26
26
  "@uniweb/kit": {
27
- "version": "1.2.4"
27
+ "version": "1.3.0"
28
28
  },
29
29
  "@uniweb/loom": {
30
30
  "version": "1.1.0"
@@ -33,22 +33,22 @@
33
33
  "version": "1.1.1"
34
34
  },
35
35
  "@uniweb/projections": {
36
- "version": "1.1.1"
36
+ "version": "1.1.2"
37
37
  },
38
38
  "@uniweb/runtime": {
39
- "version": "1.1.4"
39
+ "version": "1.1.5"
40
40
  },
41
41
  "@uniweb/scene": {
42
42
  "version": "1.0.1"
43
43
  },
44
44
  "@uniweb/schemas": {
45
- "version": "1.3.1"
45
+ "version": "1.4.0"
46
46
  },
47
47
  "@uniweb/scholar": {
48
48
  "version": "1.1.0"
49
49
  },
50
50
  "@uniweb/semantic-parser": {
51
- "version": "1.8.1"
51
+ "version": "1.9.0"
52
52
  },
53
53
  "@uniweb/snapshot": {
54
54
  "version": "1.1.0"
@@ -60,7 +60,7 @@
60
60
  "version": "1.2.0"
61
61
  },
62
62
  "@uniweb/unipress": {
63
- "version": "1.1.5"
63
+ "version": "1.1.6"
64
64
  }
65
65
  },
66
66
  "templates": {
package/src/index.js CHANGED
@@ -33,8 +33,6 @@ import prompts from 'prompts'
33
33
  // Same pattern as `build` and `docs`.
34
34
  import { i18n } from './commands/i18n.js'
35
35
  import { inspect } from './commands/inspect.js'
36
- import { invite } from './commands/invite.js'
37
- import { handoff } from './commands/handoff.js'
38
36
  import { update } from './commands/update.js'
39
37
  import { clone } from './commands/clone.js'
40
38
  import { template } from './commands/template.js'
@@ -74,6 +72,7 @@ import {
74
72
  } from './utils/interactive.js'
75
73
  import { findWorkspaceRoot } from './utils/workspace.js'
76
74
  import { exitWhenDrained } from './utils/exit.js'
75
+ import { isGitRepo } from './utils/git.js'
77
76
 
78
77
  // Colors for terminal output
79
78
  const colors = {
@@ -1009,18 +1008,6 @@ async function main() {
1009
1008
  return
1010
1009
  }
1011
1010
 
1012
- // Handle invite command
1013
- if (command === 'invite') {
1014
- await invite(args.slice(1))
1015
- return
1016
- }
1017
-
1018
- // Handle handoff command
1019
- if (command === 'handoff') {
1020
- await handoff(args.slice(1))
1021
- return
1022
- }
1023
-
1024
1011
  // Handle families command — the standard section families this CLI ships with
1025
1012
  if (command === 'families') {
1026
1013
  const { families } = await import('./commands/families.js')
@@ -1327,17 +1314,10 @@ async function main() {
1327
1314
 
1328
1315
  // Initialize git repository
1329
1316
  if (!noGit) {
1330
- // Skip git init if already inside a git repo (common for monorepos/workspaces)
1331
- let insideGitRepo = false
1332
- try {
1333
- execSync('git rev-parse --is-inside-work-tree', {
1334
- cwd: projectDir,
1335
- stdio: 'ignore'
1336
- })
1337
- insideGitRepo = true
1338
- } catch {
1339
- // Not inside a git repo — proceed with init
1340
- }
1317
+ // Skip git init if a repository already holds the project (common for
1318
+ // monorepos/workspaces). ⛔ Not one that ignores its folder: that repository can never
1319
+ // hold its history, so the project gets its own (`isGitRepo`, the one rule).
1320
+ const insideGitRepo = isGitRepo(projectDir)
1341
1321
 
1342
1322
  if (insideGitRepo) {
1343
1323
  log(
@@ -1932,18 +1912,6 @@ Record files keep their \`$uuid\`: it is the record's own id, not a backend's.
1932
1912
 
1933
1913
  To duplicate a project:
1934
1914
  cp -r my-site my-site-2 && cd my-site-2 && uniweb forget --all
1935
- `,
1936
- invite: `
1937
- ${colors.cyan}${colors.bright}uniweb invite${colors.reset} ${colors.dim}— (reserved; not available on the new backend yet)${colors.reset}
1938
-
1939
- The foundation client-invite flow was retired with the legacy backend.
1940
- Invite clients to your foundation from the Uniweb app for now.
1941
- `,
1942
- handoff: `
1943
- ${colors.cyan}${colors.bright}uniweb handoff${colors.reset} ${colors.dim}— (reserved; not available on the new backend yet)${colors.reset}
1944
-
1945
- The site-handoff flow was retired with the legacy backend.
1946
- Manage client sites from the Uniweb app for now.
1947
1915
  `,
1948
1916
  families: `
1949
1917
  ${colors.cyan}${colors.bright}uniweb families${colors.reset} ${colors.dim}— The standard section families${colors.reset}
package/src/utils/git.js CHANGED
@@ -7,19 +7,25 @@
7
7
  * already solves better.
8
8
  *
9
9
  * DETECTED, NEVER REQUIRED. A project may legitimately have no repo, and — more
10
- * commonly — may live *inside* a larger one: `uniweb create` already skips
11
- * `git init` when it finds itself inside a work tree ("common for
12
- * monorepos/workspaces"), and whole scopes of real projects are versioned by a
13
- * parent repo rather than being repo roots themselves. So the question is never
14
- * "is this directory a git root" but "is this path tracked by some repo", which is
15
- * the question git itself answers. Outside one a pull checks out, asking first; in
16
- * one it merges (`utils/sync-point.js`).
10
+ * commonly — may live *inside* a larger one: `uniweb create` skips `git init` when a
11
+ * repository already holds it ("common for monorepos/workspaces"), and whole scopes of
12
+ * real projects are versioned by a parent repo rather than being repo roots
13
+ * themselves. So the question is never "is this directory a git root" but "does a
14
+ * repository HOLD this path": is it inside a work tree, in a folder that repository
15
+ * does not ignore. One rule answers it, `repoOf` in `utils/sync-point.js`. Outside
16
+ * one a pull checks out, asking first; in one it merges.
17
+ *
18
+ * ⛔ Until 2026-10-17 this said "is this path tracked by some repo" while the check
19
+ * was `git rev-parse --is-inside-work-tree` alone. That reads as if a folder the
20
+ * repository ignores were outside it, and it was not: a harness's ignored scratch
21
+ * project counted as inside the harness, so `refresh` ran `git pull` on the harness.
17
22
  */
18
23
 
19
24
  import { execFileSync } from 'node:child_process'
20
25
  import { readFileSync } from 'node:fs'
21
26
  import { join } from 'node:path'
22
27
  import yaml from 'js-yaml'
28
+ import { repoOf } from './sync-point.js'
23
29
 
24
30
  /**
25
31
  * The files and directories `uniweb pull` writes into.
@@ -69,15 +75,12 @@ function git(args, cwd) {
69
75
  }
70
76
 
71
77
  /**
72
- * Is `dir` inside a git work tree? False when git is absent, when the path isn't
73
- * tracked, or on any error — the caller treats all three the same way.
78
+ * Does a repository hold `dir` — is it inside a work tree, in a folder that repository
79
+ * does not ignore (`repoOf`)? False when git is absent, outside any work tree, in an
80
+ * ignored folder, or on any error — the caller treats them all the same way.
74
81
  */
75
82
  export function isGitRepo(dir) {
76
- try {
77
- return git(['rev-parse', '--is-inside-work-tree'], dir).trim() === 'true'
78
- } catch {
79
- return false
80
- }
83
+ return Boolean(repoOf(dir))
81
84
  }
82
85
 
83
86
  /** Does this repo have a remote configured to pull from? */
@@ -102,7 +105,7 @@ export function hasRemote(dir) {
102
105
  * ⛔ Until 2026-10-17 this was `--ff-only`, which refused once a pull had made a merge
103
106
  * commit the remote did not have.
104
107
  *
105
- * @returns {{ ok: boolean, changed: boolean, message: string }}
108
+ * @returns {{ ok: boolean, conflicts?: boolean, changed: boolean, message: string }}
106
109
  */
107
110
  export function pullRemote(dir) {
108
111
  const before = (() => {
@@ -125,6 +128,23 @@ export function pullRemote(dir) {
125
128
  return null
126
129
  }
127
130
  })()
131
+ // ⛔ `--autostash` exits 0 when the uncommitted work it set aside cannot be put back over
132
+ // what the pull brought: the files hold conflict markers and git keeps that work in the
133
+ // stash as well. That is not a clean pull — refresh must stop before the backend's
134
+ // content lands on top of it. *(Read as clean until 2026-10-17.)*
135
+ const unmerged = git(['diff', '--name-only', '--diff-filter=U'], dir).trim()
136
+ if (unmerged) {
137
+ return {
138
+ ok: false,
139
+ conflicts: true,
140
+ changed: Boolean(before && after && before !== after),
141
+ message: [
142
+ 'Your uncommitted changes overlap the commits git pull brought.',
143
+ 'Settle the markers and `git add` each file, then `git stash drop` — git kept those changes in the stash as well:',
144
+ ...unmerged.split('\n').map((p) => ` ${p}`)
145
+ ].join('\n')
146
+ }
147
+ }
128
148
  return {
129
149
  ok: true,
130
150
  changed: Boolean(before && after && before !== after),
@@ -14,9 +14,9 @@
14
14
  * foundation — `uniweb dev` with `foundation:` naming a sibling package. There,
15
15
  * the site imports `#foundation`, which `build/src/site/config.js` aliases to
16
16
  * the foundation's PACKAGE NAME, so Vite resolves it through node_modules. A
17
- * linked site (registry ref or URL) never takes that path, and neither does the
18
- * desktop app, unipress, or the edge — they load a built `dist/entry.js`, or
19
- * read a content folder directly. The check stays silent for all of them
17
+ * linked site (registry ref or URL) never takes that path, and neither does
18
+ * unipress or the edge — they load a built `dist/entry.js`, or read a content
19
+ * folder directly. The check stays silent for all of them
20
20
  * because there is no node_modules entry to look at.
21
21
  *
22
22
  * What goes wrong in that one modality: a `file:` dependency can be satisfied
@@ -10,13 +10,18 @@
10
10
  * A sync point is one of:
11
11
  * · a commit a PULL made — the backend's content written onto the last sync point's
12
12
  * tree, marked by the trailers below, and merged into the branch, so it is shared;
13
- * · the commit a PUSH was made from — what the backend then holds. This copy keeps it
14
- * (`backend-cache.json`, `syncPoint`), and the backend can name it on a pull
15
- * (`x-uniweb-source`, `backend/client.js`), for every copy that has it.
13
+ * · the commit a PUSH was made from — what the backend then holds — or one a push found
14
+ * nothing to send from. This copy keeps it (`backend-cache.json`, `syncPoint`), and the
15
+ * backend can name it on a pull (`x-uniweb-source`, `backend/client.js`), for every
16
+ * copy that has it.
16
17
  *
17
- * ⚖️ Any sync point a history holds is a safe base: both the files and the backend's
18
- * content descend from it, so neither side's change can read as the other's undoing. The
19
- * newest one is the precise base — an older one costs conflicts, never content.
18
+ * ⚖️ THE NEWEST ONE IS THE BASE; an older one is not simply less precise. Both the files and
19
+ * the backend's content descend from any of them, but between an older one and the
20
+ * newest, both sides hold the same changes — and where one side then changed a hunk back,
21
+ * the older base already reads the old text, so the change-back looks like no change and
22
+ * the merge silently takes the other side. ⛔ Until 2026-10-17 this said "an older one
23
+ * costs conflicts, never content". So no known base is dropped (`madeOnto`), and the
24
+ * backend naming its source is a matter of correctness, not only of fewer conflicts.
20
25
  *
21
26
  * ⛔ Until 2026-10-17 the ancestor was a per-machine list of the files this copy's last pull
22
27
  * wrote, by hash, found again in history by content — and HEAD where the list had no
@@ -69,8 +74,18 @@ export function gitError(err) {
69
74
  }
70
75
 
71
76
  /**
72
- * The repository a site is in — its top level, the site's path in it, the git
73
- * directory and HEAD (null on a branch with no commits) — or null outside one.
77
+ * The repository that holds a site — its top level, the site's path in it, the git
78
+ * directory and HEAD (null on a branch with no commits) — or null when none does.
79
+ *
80
+ * ⭐ HOLDS: the site's folder is inside a work tree, and that repository does not ignore
81
+ * it. A repository can never hold the history of a folder it ignores — a harness's
82
+ * scratch projects, a sandbox — so a site there is outside git, as if no repository were
83
+ * around it. One committed there anyway (`git add -f`) is held: git does not count a
84
+ * folder with tracked content as ignored.
85
+ *
86
+ * ⛔ Until 2026-10-17 any enclosing work tree counted. In a folder it ignores, a pull
87
+ * failed on git's "paths are ignored", a push recorded that repository's HEAD as its sync
88
+ * point, and `refresh` ran `git pull` on it.
74
89
  */
75
90
  export function repoOf(siteDir) {
76
91
  let top, prefix, gitDir
@@ -82,6 +97,7 @@ export function repoOf(siteDir) {
82
97
  return null
83
98
  }
84
99
  if (!top) return null
100
+ if (prefix && ignoredHere(siteDir)) return null
85
101
  let head = null
86
102
  try {
87
103
  head = gitText(top, ['rev-parse', '--verify', '-q', 'HEAD^{commit}']).trim() || null
@@ -91,6 +107,31 @@ export function repoOf(siteDir) {
91
107
  return { top, prefix, path: prefix ? prefix.replace(/\/$/, '') : '.', gitDir, head }
92
108
  }
93
109
 
110
+ /**
111
+ * The top of the repository around `dir` when that repository ignores it — and so holds none
112
+ * of it — or null. For what is said to a person: "not in a repository" is not what they see
113
+ * when one is right there.
114
+ */
115
+ export function ignoringRepo(dir) {
116
+ try {
117
+ const top = gitText(dir, ['rev-parse', '--show-toplevel']).trim()
118
+ const prefix = gitText(dir, ['rev-parse', '--show-prefix']).trim()
119
+ return top && prefix && ignoredHere(dir) ? top : null
120
+ } catch {
121
+ return null
122
+ }
123
+ }
124
+
125
+ /** Does the repository around `dir` ignore it? `git check-ignore` exits 0 for yes, 1 for no. */
126
+ function ignoredHere(dir) {
127
+ try {
128
+ git(dir, ['check-ignore', '-q', '.'])
129
+ return true
130
+ } catch {
131
+ return false
132
+ }
133
+ }
134
+
94
135
  /** Is `ancestor` in the history of `of` — or `of` itself? */
95
136
  export function isAncestor(repo, ancestor, of = 'HEAD') {
96
137
  try {
@@ -120,6 +161,15 @@ export function siteTreeIn(repo, rev) {
120
161
  }
121
162
  }
122
163
 
164
+ /** The paths in conflict here, whatever left them — a merge, or a stash put back over one. */
165
+ export function unmergedPaths(repo) {
166
+ try {
167
+ return zlist(git(repo.top, ['diff', '--name-only', '-z', '--diff-filter=U']))
168
+ } catch {
169
+ return []
170
+ }
171
+ }
172
+
123
173
  /** Is a merge waiting for its commit here? */
124
174
  export function mergeInProgress(repo) {
125
175
  try {
@@ -171,17 +221,64 @@ export function historySyncPoint(repo, { origin, siteUuid }) {
171
221
  for (const record of out.split('\x1e')) {
172
222
  const [sha = '', body = ''] = record.replace(/^\s+/, '').split('\x1f')
173
223
  if (!/^[0-9a-f]{40,64}$/.test(sha)) continue
174
- const t = trailersOf(body)
175
- if (t.origin !== origin || t.siteUuid !== siteUuid || t.path !== repo.path) continue
176
- if (siteTreeIn(repo, sha) === t.tree) return sha
224
+ if (marks(repo, sha, body, { origin, siteUuid })) return sha
177
225
  }
178
226
  return null
179
227
  }
180
228
 
229
+ // Does this commit's message mark it a sync point of this site and backend — and does it
230
+ // still hold the site tree its trailer names?
231
+ function marks(repo, sha, body, { origin, siteUuid }) {
232
+ const t = trailersOf(body)
233
+ if (t.origin !== origin || t.siteUuid !== siteUuid || t.path !== repo.path) return false
234
+ return siteTreeIn(repo, sha) === t.tree
235
+ }
236
+
237
+ /**
238
+ * The commits a sync point HEAD does not hold still vouches for: the one it was made onto
239
+ * — its first parent — and that one's, for as long as each is a sync point of this site
240
+ * HEAD does not hold either.
241
+ *
242
+ * ⛔ A merge abandoned (`git merge --abort`) leaves this copy's sync point out of the
243
+ * history, while the commit it was made onto is still there — and still a state the
244
+ * backend's content descends from. Until 2026-10-17 it was forgotten with the merge, and
245
+ * the next pull built on an older sync point, or on none: measured against a real
246
+ * backend, it conflicted on a section nobody had touched since the commit last pushed from.
247
+ */
248
+ export function madeOnto(repo, sha, { origin, siteUuid }) {
249
+ const out = []
250
+ let at = sha
251
+ for (let n = 0; n < 16 && isCommit(repo, at) && !isAncestor(repo, at); n++) {
252
+ let body
253
+ try {
254
+ body = gitText(repo.top, ['log', '-1', '--format=%B', at])
255
+ } catch {
256
+ break
257
+ }
258
+ if (!marks(repo, at, body, { origin, siteUuid })) break
259
+ let parent = null
260
+ try {
261
+ parent = gitText(repo.top, ['rev-parse', '--verify', '-q', `${at}^1`]).trim() || null
262
+ } catch {
263
+ // Made onto nothing — a history with no sync point.
264
+ }
265
+ if (!parent) break
266
+ out.push(parent)
267
+ at = parent
268
+ }
269
+ return out
270
+ }
271
+
181
272
  /**
182
273
  * The base a pull merges from: the newest of the candidates HEAD descends from. One that
183
- * descends from all the others is the newest; with none, the most recently committed —
184
- * any of them is a state both sides descend from, so the choice costs precision at worst.
274
+ * descends from all the others is the newest; with none, the most recently committed.
275
+ *
276
+ * ⚖️ Any of them is a state both sides descend from, but only the newest such state — the
277
+ * commit the backend's content last came from — merges exactly. An older one costs a
278
+ * conflict wherever one side changed what the other changed after it, and it silently
279
+ * undoes a change made after it and then changed BACK on one side: the base already
280
+ * reads the old text, so the change-back looks like no change at all. ⛔ Until
281
+ * 2026-10-17 this said an older base "costs precision at worst".
185
282
  */
186
283
  export function newestBase(repo, candidates) {
187
284
  const shas = [...new Set(candidates.filter(Boolean))].filter((s) => isCommit(repo, s) && isAncestor(repo, s))
@@ -210,12 +307,16 @@ export function newestBase(repo, candidates) {
210
307
  * ⛔ CLOSE IT. `close()` unlinks every link it made before removing the rest — a recursive
211
308
  * remove that followed one would delete the project's own files.
212
309
  *
310
+ * ⚖️ What is written under the roots where the repository ignores it — media in a folder
311
+ * kept out of git — is in no commit, so no merge brings it. `placeIgnored()` copies it
312
+ * into the project, where the project lacks it.
313
+ *
213
314
  * @param {object} repo - `repoOf`
214
315
  * @param {object} opts
215
316
  * @param {string[]} opts.roots - the site's content roots, relative to the site
216
317
  * @param {string|null} opts.rev - the commit whose content the roots are filled from;
217
318
  * null for none
218
- * @returns {{ siteDir: string, commit: Function, close: Function }}
319
+ * @returns {{ siteDir: string, commit: Function, placeIgnored: Function, close: Function }}
219
320
  */
220
321
  export function openStandIn(repo, { roots, rev }) {
221
322
  for (const r of roots) {
@@ -329,7 +430,7 @@ export function openStandIn(repo, { roots, rev }) {
329
430
  * @returns {{ commit: string|null, tree: string, siteTree: string|null }}
330
431
  */
331
432
  const commit = ({ parent, subject, origin, siteUuid, always = false }) => {
332
- const staged = full.filter((f) => present.includes(f) || existsSync(join(root, f)))
433
+ const staged = held(full.filter((f) => present.includes(f) || existsSync(join(root, f))))
333
434
  if (staged.length) at(['add', '-A', '--', ...staged])
334
435
  const tree = at(['write-tree']).toString('utf8').trim()
335
436
  const before = rev ? gitText(repo.top, ['rev-parse', `${rev}^{tree}`]).trim() : null
@@ -356,7 +457,40 @@ export function openStandIn(repo, { roots, rev }) {
356
457
  return { commit: sha, tree, siteTree }
357
458
  }
358
459
 
359
- return { siteDir, commit, close }
460
+ /**
461
+ * Copy into the project what the stand-in holds under the roots where the repository
462
+ * ignores it, wherever the project lacks it. ⛔ Never over a file the project has:
463
+ * with no history there, nothing tells its version from the author's work.
464
+ * @returns {string[]} the paths placed, relative to the site
465
+ */
466
+ const placeIgnored = () => {
467
+ const roots = full.filter((f) => existsSync(join(root, f)))
468
+ if (!roots.length) return []
469
+ const placed = []
470
+ for (const p of zlist(at(['ls-files', '--others', '--ignored', '--exclude-standard', '-z', '--', ...roots]))) {
471
+ const to = join(repo.top, p)
472
+ if (occupied(to)) continue
473
+ mkdirSync(dirname(to), { recursive: true })
474
+ copyFileSync(join(root, p), to)
475
+ placed.push(sitePrefix && p.startsWith(sitePrefix) ? p.slice(sitePrefix.length) : p)
476
+ }
477
+ return placed
478
+ }
479
+
480
+ // The roots git can hold here. ⛔ One the repository ignores whole is not named to
481
+ // `git add`, which refuses an ignored path; what is written under it is placed instead.
482
+ const held = (paths) => {
483
+ if (!paths.length) return paths
484
+ let ignored = []
485
+ try {
486
+ ignored = zlist(at(['check-ignore', '-z', '--stdin'], { input: `${paths.join('\0')}\0` }))
487
+ } catch (err) {
488
+ if (err?.status !== 1) throw err // 1: none of them is ignored
489
+ }
490
+ return paths.filter((p) => !ignored.includes(p))
491
+ }
492
+
493
+ return { siteDir, commit, placeIgnored, close }
360
494
  } catch (err) {
361
495
  close()
362
496
  throw err
@@ -366,6 +500,15 @@ export function openStandIn(repo, { roots, rev }) {
366
500
  // ── merging ─────────────────────────────────────────────────────────────────
367
501
 
368
502
  const zlist = (buf) => buf.toString('utf8').split('\0').filter(Boolean)
503
+ // Something is at `path` — a dangling link included, which `existsSync` reads as nothing.
504
+ const occupied = (path) => {
505
+ try {
506
+ lstatSync(path)
507
+ return true
508
+ } catch {
509
+ return false
510
+ }
511
+ }
369
512
 
370
513
  /**
371
514
  * Files not under git that the merge would write over: their paths, each with whether the
@@ -404,6 +547,9 @@ function untrackedInTheWay(repo, commit) {
404
547
  */
405
548
  export function mergeSyncPoint(repo, commit, { message }) {
406
549
  if (mergeInProgress(repo)) return { ok: false, reason: 'merge-in-progress' }
550
+ // Conflicts already here are not this merge's: git would refuse it, and they would read as its own.
551
+ const unsettled = unmergedPaths(repo)
552
+ if (unsettled.length) return { ok: false, reason: 'unmerged', paths: unsettled }
407
553
  const inTheWay = untrackedInTheWay(repo, commit)
408
554
  const blocking = inTheWay.filter((u) => !u.same).map((u) => u.path)
409
555
  if (blocking.length) return { ok: false, reason: 'untracked', paths: blocking }
@@ -412,7 +558,13 @@ export function mergeSyncPoint(repo, commit, { message }) {
412
558
  for (const u of inTheWay) rmSync(join(repo.top, u.path))
413
559
  try {
414
560
  git(repo.top, ['merge', '--autostash', '--no-edit', '--no-verify', '--allow-unrelated-histories', '-m', message, commit])
415
- return { ok: true, conflicted: [] }
561
+ // ⛔ THE MERGE IS MADE, AND IT MAY STILL HAVE LEFT CONFLICTS. `--autostash` exits 0 when
562
+ // the uncommitted work it set aside cannot be put back over what the merge brought: the
563
+ // files then hold conflict markers, unmerged, and git keeps that work in the stash too.
564
+ // Until 2026-10-17 that read as a clean merge — a pull that exited 0 over the markers,
565
+ // so `uniweb pull && uniweb push` would send them.
566
+ const unstashed = unmergedPaths(repo)
567
+ return { ok: true, conflicted: unstashed, stashKept: unstashed.length > 0 }
416
568
  } catch (err) {
417
569
  const conflicted = zlist(git(repo.top, ['diff', '--name-only', '-z', '--diff-filter=U']))
418
570
  if (conflicted.length) return { ok: true, conflicted }
@@ -444,26 +596,70 @@ export function takeSyncPoint(repo, commit, { roots, message }) {
444
596
  }
445
597
  }
446
598
 
447
- /** What the backend changed between two commits, under the site: `[status, path]` pairs. */
448
- export function changesBetween(repo, from, to) {
599
+ /**
600
+ * What a pull changed in the files, under the site: `[status, path]` pairs — what the
601
+ * merge, or the take, moved from `from` (HEAD before it), less what the files already
602
+ * held as it left them. `files` is the files before it (`snapshotFiles`). `staged` reads a
603
+ * merge stopped on conflicts from the index; `skip` leaves out the paths in conflict,
604
+ * which are reported as conflicts.
605
+ *
606
+ * ⛔ Until 2026-10-17 a pull listed the backend's content against the base, as "changes
607
+ * from the backend": a file a push had already written into the files read as one, so
608
+ * did a person's own edit when there was no base — and `--force` said it "took" a change
609
+ * where it undid their edit.
610
+ */
611
+ export function changedInFiles(repo, { from, files, staged = false, skip = [] }) {
612
+ const scope = ['--', ...(repo.prefix ? [repo.prefix] : [])]
449
613
  try {
450
- // No base: against the empty tree, in this repository's own hash.
451
- const base = from || gitText(repo.top, ['hash-object', '-t', 'tree', '--stdin'], { input: '' }).trim()
452
- const out = gitText(repo.top, ['diff', '--name-status', '-z', base, to, '--', ...(repo.prefix ? [repo.prefix] : [])])
453
- const parts = out.split('\0').filter(Boolean)
454
- const changes = []
614
+ const parts = zlist(git(repo.top, ['diff', '--name-status', '-z', ...(staged ? ['--cached', from] : [from, 'HEAD']), ...scope]))
615
+ const moved = []
455
616
  for (let i = 0; i < parts.length; ) {
456
617
  const status = parts[i++]
457
618
  const path = parts[i++]
458
- if (/^[RC]/.test(status)) changes.push([status[0], parts[i++]])
459
- else changes.push([status, path])
619
+ moved.push(/^[RC]/.test(status) ? [status[0], parts[i++]] : [status, path])
460
620
  }
461
- return changes.map(([s, p]) => [s, repo.prefix && p.startsWith(repo.prefix) ? p.slice(repo.prefix.length) : p])
621
+ // What the files already held as the merge left it is no change to them.
622
+ const differs = new Set(
623
+ zlist(git(repo.top, ['diff', '--name-only', '-z', '--no-renames', ...(staged ? ['--cached', files] : [files, 'HEAD']), ...scope]))
624
+ )
625
+ return moved
626
+ .filter(([s, p]) => s !== 'U' && !skip.includes(p) && differs.has(p))
627
+ .map(([s, p]) => [s, repo.prefix && p.startsWith(repo.prefix) ? p.slice(repo.prefix.length) : p])
462
628
  } catch {
463
629
  return []
464
630
  }
465
631
  }
466
632
 
633
+ /**
634
+ * The files as they are, as a commit to compare against — what is tracked, committed or
635
+ * not (`git stash create`, which touches neither the files nor any ref); HEAD when nothing
636
+ * is uncommitted.
637
+ */
638
+ export function snapshotFiles(repo) {
639
+ try {
640
+ return (
641
+ gitText(repo.top, ['stash', 'create'], {
642
+ env: { GIT_AUTHOR_NAME: 'uniweb', GIT_AUTHOR_EMAIL: 'sync@uniweb', GIT_COMMITTER_NAME: 'uniweb', GIT_COMMITTER_EMAIL: 'sync@uniweb' }
643
+ }).trim() || repo.head
644
+ )
645
+ } catch {
646
+ return repo.head
647
+ }
648
+ }
649
+
650
+ /**
651
+ * Does HEAD hold the site's content as the files do — nothing staged, changed or untracked
652
+ * under the roots? (What the repository ignores is no change.)
653
+ */
654
+ export function contentCommitted(repo, roots) {
655
+ try {
656
+ const full = roots.map((r) => `${repo.prefix}${r}`)
657
+ return git(repo.top, ['status', '--porcelain', '-z', '--untracked-files=all', '--', ...full]).length === 0
658
+ } catch {
659
+ return false
660
+ }
661
+ }
662
+
467
663
  // ── this copy's own sync point ──────────────────────────────────────────────
468
664
 
469
665
  const cachePath = (siteDir) => join(siteDir, '.uniweb', 'backend-cache.json')
@@ -1,21 +0,0 @@
1
- /**
2
- * handoff — RESERVED (not available on the new backend yet).
3
- *
4
- * The "create a site and hand it off to a client" flow ran against the legacy
5
- * platform (the PHP backend + Cloudflare Worker) the CLI no longer talks to. A
6
- * new-backend CLI equivalent isn't built; the command name is kept reserved so a
7
- * future rebuild can claim it without a breaking change.
8
- */
9
-
10
- export async function handoff() {
11
- console.error(
12
- "\x1b[31m✗\x1b[0m `uniweb handoff` isn't available on the new backend yet."
13
- )
14
- console.error(
15
- ' The legacy site-handoff flow was retired with the PHP backend.'
16
- )
17
- console.error(' Manage client sites from the Uniweb app for now.')
18
- process.exit(1)
19
- }
20
-
21
- export default handoff
@@ -1,24 +0,0 @@
1
- /**
2
- * invite — RESERVED (not available on the new backend yet).
3
- *
4
- * The foundation client-invite flow ran against the legacy platform (the PHP
5
- * backend + Cloudflare Worker) the CLI no longer talks to. Client onboarding is
6
- * handled in the Uniweb app for now; a new-backend CLI equivalent isn't built.
7
- * The command name is kept reserved so a future rebuild can claim it without a
8
- * breaking change.
9
- */
10
-
11
- export async function invite() {
12
- console.error(
13
- "\x1b[31m✗\x1b[0m `uniweb invite` isn't available on the new backend yet."
14
- )
15
- console.error(
16
- ' The legacy client-invite flow was retired with the PHP backend.'
17
- )
18
- console.error(
19
- ' Invite clients to your foundation from the Uniweb app for now.'
20
- )
21
- process.exit(1)
22
- }
23
-
24
- export default invite