uniweb 1.12.0 → 1.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uniweb",
3
- "version": "1.12.0",
3
+ "version": "1.13.1",
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/semantic-parser": "~1.12.0",
45
- "@uniweb/core": "~1.3.0",
46
44
  "@uniweb/content-writer": "~1.3.0",
47
- "@uniweb/schemas": "~1.5.0"
45
+ "@uniweb/schemas": "~1.5.0",
46
+ "@uniweb/semantic-parser": "~1.12.0",
47
+ "@uniweb/core": "~1.3.0"
48
48
  },
49
49
  "peerDependencies": {
50
+ "@uniweb/content-reader": "~1.5.2",
50
51
  "@uniweb/semantic-parser": "~1.12.0",
51
- "@uniweb/content-reader": "~1.5.1",
52
- "@uniweb/build": "~1.5.0"
52
+ "@uniweb/build": "~1.5.1"
53
53
  },
54
54
  "peerDependenciesMeta": {
55
55
  "@uniweb/build": {
@@ -2848,9 +2848,9 @@ Foundations have their own free path too: `uniweb add ci --target foundation` pu
2848
2848
  - **Git is the reviewed, durable record** — you `pull` content back, read it with `git diff`, and commit.
2849
2849
  - **Authors never push or pull.** For them, content simply updates, whether the change came from another author or from a developer's CLI.
2850
2850
 
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.
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. **In a git repository a push sends what you have committed:** with the site's content not committed it is refused before anything is sent, naming the files — commit them, or run `uniweb push --commit -m "…"` (it commits the site's content only, and leaves anything else staged as it is); `publish` and `sync` take `--commit` too. That commit is where the next pull merges from, which is how the pull tells your changes from the backend's. What the push writes back into the files — the ids the backend assigned — it commits itself, as `uniweb`. When the backend has nothing new, a pull says so and lists the files where yours hold changes it lacks. `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.
2852
2852
 
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.
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`. In a git repository the push commits 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.
2854
2854
 
2855
2855
  **`services:` in `site.yml` is where a site says which services it uses** — site search, form
2856
2856
  handling, analytics, an assistant, accounts — one entry per service:
@@ -0,0 +1,49 @@
1
+ /**
2
+ * `--commit` on the verbs that send a site — `push` and `publish` (and `sync`, through its
3
+ * push). In a repository they send what is committed (`uncommittedContent`,
4
+ * backend/site-sync.js); this is the stop where it is not, and the commit that makes it so.
5
+ *
6
+ * Its own module so the flags it reads stay out of the import graph of every verb that only
7
+ * shares site-sync.js — `pull`, `status`, `refresh` take no `--commit`
8
+ * (`test/flag-guard-coverage.test.js`).
9
+ */
10
+
11
+ import { uncommittedContent, commitSiteContent } from './site-sync.js'
12
+
13
+ /**
14
+ * The stop before a push or publish sends anything, where the site's content is not
15
+ * committed — or, with `--commit`, the commit that makes it so, which a dry run only says.
16
+ * Returns `{ exitCode }` to stop, or null to go on.
17
+ */
18
+ export function commitBeforeSending(siteDir, args, { verb, dryRun, say }) {
19
+ const pending = uncommittedContent(siteDir, { verb })
20
+ if (!pending) return null
21
+ if (!args.includes('--commit') || pending.merge) {
22
+ say.error(pending.headline)
23
+ for (const line of pending.lines) say.note(line)
24
+ return { exitCode: 1 }
25
+ }
26
+ if (dryRun) {
27
+ say.info(`Dry run — would commit ${pending.paths.length} file(s) of the site first.`)
28
+ return null
29
+ }
30
+ const made = commitSiteContent(siteDir, { message: commitMessage(args) })
31
+ if (made.error) {
32
+ say.error("Could not commit the site's content:")
33
+ for (const line of String(made.error).split('\n').slice(0, 8)) say.note(` ${line}`)
34
+ return { exitCode: 1 }
35
+ }
36
+ if (made.commit) say.success(`Committed ${made.paths.length} file(s) of the site — ${made.commit.slice(0, 7)}`)
37
+ return null
38
+ }
39
+
40
+ /** `-m` / `--message` for `--commit`, as `git commit` takes it. */
41
+ function commitMessage(args) {
42
+ for (const name of ['-m', '--message']) {
43
+ const eq = args.find((a) => a.startsWith(`${name}=`))
44
+ if (eq) return eq.slice(name.length + 1)
45
+ const i = args.indexOf(name)
46
+ if (i !== -1 && args[i + 1] != null) return args[i + 1]
47
+ }
48
+ return "Update the site's content"
49
+ }
@@ -20,6 +20,10 @@ import {
20
20
  writeSyncPoint,
21
21
  isAncestor,
22
22
  contentCommitted,
23
+ uncommittedPaths,
24
+ commitRoots,
25
+ gitError,
26
+ RECORDED_TRAILER,
23
27
  unmergedPaths,
24
28
  mergeInProgress
25
29
  } from '../utils/sync-point.js'
@@ -64,7 +68,11 @@ import {
64
68
  LINK_MODEL,
65
69
  bankedFileUuids,
66
70
  existingSectionFile,
67
- layoutUnitFile
71
+ layoutUnitFile,
72
+ siteContentRemovals,
73
+ recordRemovals,
74
+ cleanedRecord,
75
+ SYNC_STORE_FILE
68
76
  } from '@uniweb/build/uwx'
69
77
 
70
78
  // First entity `$`-document out of a `.uwx` we produced or the backend served.
@@ -84,6 +92,26 @@ function entityDocFromUwx(buf) {
84
92
  return null
85
93
  }
86
94
 
95
+ // Every entity `$`-document of a `.uwx` in the order it was submitted — the manifest's, which is
96
+ // what a push's `finalized[].index` counts in. null where a listed file is missing or unreadable.
97
+ function submittedDocsFromUwx(buf) {
98
+ if (!buf || buf.length < 2 || buf[0] !== 0x50 || buf[1] !== 0x4b) return []
99
+ const files = readZip(buf)
100
+ let entries
101
+ try {
102
+ entries = JSON.parse(files.get('manifest.json')?.toString('utf8') ?? '{}').entries
103
+ } catch {
104
+ return []
105
+ }
106
+ return (Array.isArray(entries) ? entries : []).map((entry) => {
107
+ try {
108
+ return JSON.parse(files.get(entry?.file).toString('utf8'))
109
+ } catch {
110
+ return null
111
+ }
112
+ })
113
+ }
114
+
87
115
  // Every entity `$`-document of a `.uwx` — the folder lane holds the folder and its records.
88
116
  function entityDocsFromUwx(buf) {
89
117
  const docs = []
@@ -223,6 +251,10 @@ function writtenAsSent(sent, written) {
223
251
  * (`siteItemsByKey`), and it replaces the one held before:
224
252
  *
225
253
  * - an item the backend holds as we sent it → its new version;
254
+ * - an item it holds as we sent it once cleaned to the rich-text vocabulary → its new version
255
+ * too: what a backend that cleans at write removed is not someone else's content
256
+ * (`cleanedRecord`). ⛔ Until 2026-10-18 it was, so a push with a link the store removed
257
+ * said the section had changed on the backend, and kept the version for what was sent;
226
258
  * - an item we sent that it kept for someone else → the version we held, for OUR content;
227
259
  * - an item it holds that we did not send → the version we held, if we had seen it, and
228
260
  * none if we had not (a unit of it is `foreign`);
@@ -232,7 +264,7 @@ function writtenAsSent(sent, written) {
232
264
  * the backend returned it — harmless only while none of them was ever sent.
233
265
  *
234
266
  * A service is compared by its switch and its whole `config` (`sameServiceRow`), every
235
- * other item on the keys we sent (`writtenAsSent`).
267
+ * other item on the keys we sent (`writtenAsSent`), as sent or as cleaned.
236
268
  *
237
269
  * With either document missing there is nothing to compare, and with no versions
238
270
  * returned (an older backend) nothing to bank: `itemVersions` is null, and the versions
@@ -255,7 +287,8 @@ export function heldTokens({ sent, written, version = null, itemVersions = null,
255
287
  const prior = before && typeof before === 'object' ? before : {}
256
288
  const ours = siteItemsByKey(sent)
257
289
  const theirs = siteItemsByKey(written)
258
- const same = (key, a, b) => (key.startsWith('services:') ? sameServiceRow(a, b) : writtenAsSent(a, b))
290
+ const same = (key, a, b) =>
291
+ key.startsWith('services:') ? sameServiceRow(a, b) : writtenAsSent(a, b) || writtenAsSent(cleanedRecord(a), b)
259
292
  const held = []
260
293
  const kept = []
261
294
  const foreign = []
@@ -371,7 +404,10 @@ export function extractFinalized(payload) {
371
404
  d?.item_versions && typeof d.item_versions === 'object'
372
405
  ? d.item_versions
373
406
  : null,
374
- document: d?.document ?? null
407
+ document: d?.document ?? null,
408
+ // What a backend that cleans rich text at write removed from what was sent, each
409
+ // `{ $uuid, field, path, rule }` — the key absent when nothing was (`uwx/cleaned.js`).
410
+ ...(Array.isArray(d?.cleaned) && d.cleaned.length ? { cleaned: d.cleaned } : {})
375
411
  }))
376
412
  .filter((e) => Number.isInteger(e.index) && e.uuid)
377
413
  }
@@ -1002,11 +1038,136 @@ export function untakenBase(siteDir, backend) {
1002
1038
  export function holdUnchangedPush(siteDir, backend) {
1003
1039
  const repo = repoOf(siteDir)
1004
1040
  if (!repo?.head || !readBackendState(siteDir, backend).site?.uuid) return null
1005
- if (!contentCommitted(repo, [...siteContentRoots(siteDir), 'public'])) return null
1041
+ if (!contentCommitted(repo, sentRoots(siteDir))) return null
1006
1042
  writeSyncPoint(siteDir, backend, repo.head)
1007
1043
  return repo.head
1008
1044
  }
1009
1045
 
1046
+ /** What a push sends from the files: the site's content, and its media (`public/`). */
1047
+ export const sentRoots = (siteDir) => [...siteContentRoots(siteDir), 'public']
1048
+
1049
+ /**
1050
+ * ⛔ IN A REPOSITORY, A PUSH SENDS WHAT IS COMMITTED. The commit a push is made from is where
1051
+ * the next pull merges from — this copy's, and every copy the backend names it to — so what
1052
+ * the push sends must be what that commit holds. Returns the refusal, or null: outside a
1053
+ * repository nothing is kept to merge from.
1054
+ *
1055
+ * ⛔ Until 2026-10-18 a push sent uncommitted edits on top of HEAD and recorded HEAD. Measured
1056
+ * against a real backend: an edit pushed that way and undone before the next pull came back
1057
+ * with it — exit 0, no conflict, reported as the backend's change. Every such push also had
1058
+ * the next pull commit those edits as the backend's, and conflict with one changed again.
1059
+ */
1060
+ export function uncommittedContent(siteDir, { verb = 'push' } = {}) {
1061
+ const repo = repoOf(siteDir)
1062
+ if (!repo) return null
1063
+ const paths = uncommittedPaths(repo, sentRoots(siteDir))
1064
+ if (!paths?.length) return null
1065
+ const done = verb === 'publish' ? 'published' : 'pushed'
1066
+ // A merge waiting for its commit is finished with one, never by committing a part of it.
1067
+ if (mergeInProgress(repo)) {
1068
+ return {
1069
+ paths,
1070
+ merge: true,
1071
+ headline: `Not ${done} — a merge is in progress here.`,
1072
+ lines: [`Finish it (\`git commit\`), then ${verb} — or abandon it (\`git merge --abort\`) and pull again.`]
1073
+ }
1074
+ }
1075
+ // ⭐ A repository with no commit yet is still the one that will hold the site's history —
1076
+ // and holds none of it, so every file is listed as not committed. Said as that, not as
1077
+ // edits. ⛔ Not taken for a folder outside git: its first push would keep nothing to merge
1078
+ // from, and the pull after its first commit would read every difference as a conflict.
1079
+ if (!repo.head) {
1080
+ return {
1081
+ paths,
1082
+ headline: `Not ${done} — this repository has no commit yet, so none of the site's ${paths.length} file(s) is committed.`,
1083
+ lines: [
1084
+ `In a repository a ${verb} sends what you have committed, so a later pull can tell your changes from the backend's.`,
1085
+ `Commit the site, then ${verb} again — or re-run with --commit (and -m "…") to make that first commit.`
1086
+ ]
1087
+ }
1088
+ }
1089
+ return {
1090
+ paths,
1091
+ headline: `Not ${done} — ${paths.length} file(s) of the site are not committed:`,
1092
+ lines: [
1093
+ ...paths.slice(0, 12).map((p) => ` ${p}`),
1094
+ ...(paths.length > 12 ? [` … and ${paths.length - 12} more`] : []),
1095
+ `In a repository a ${verb} sends what you have committed, so a later pull can tell your changes from the backend's.`,
1096
+ `Commit them, then ${verb} again — or re-run with --commit (and -m "…") to commit them first.`
1097
+ ]
1098
+ }
1099
+ }
1100
+
1101
+ /** Says what `commitWriteBacks` committed, or that it could not. */
1102
+ export function reportWriteBacks(made, { note }) {
1103
+ if (!made) return
1104
+ if (made.error) {
1105
+ note('! Could not commit what the push wrote into your files — commit them yourself:')
1106
+ for (const line of String(made.error).split('\n').slice(0, 4)) note(` ${line}`)
1107
+ return
1108
+ }
1109
+ note(`Committed what the backend gave the site — ${made.paths.length} file(s), ${made.commit.slice(0, 7)}.`)
1110
+ }
1111
+
1112
+ /**
1113
+ * `--commit`: the site's content committed as the files hold it — the person's own commit,
1114
+ * of what a push sends and nothing else they have staged.
1115
+ *
1116
+ * @returns {{ commit: string|null, paths?: string[] } | { error: string }}
1117
+ */
1118
+ export function commitSiteContent(siteDir, { message }) {
1119
+ const repo = repoOf(siteDir)
1120
+ if (!repo) return { commit: null }
1121
+ if (mergeInProgress(repo)) return { error: 'A merge is in progress here — finish it (`git commit`) first.' }
1122
+ try {
1123
+ return commitRoots(repo, sentRoots(siteDir), { message }) || { commit: null }
1124
+ } catch (err) {
1125
+ return { error: gitError(err) }
1126
+ }
1127
+ }
1128
+
1129
+ /**
1130
+ * ⭐ A PUSH COMMITS WHAT IT WROTE. It writes what the backend gave it into the site's files —
1131
+ * the ids it minted, into each new record's file and `sync.json` — and in a repository it
1132
+ * commits those writes as uniweb's own, as a pull's merge is. The content was committed
1133
+ * before it was sent, so what changed under the site since is the push's. That commit holds
1134
+ * what the backend now holds, so once every lane landed it is this copy's sync point.
1135
+ *
1136
+ * ⛔ Until 2026-10-18 they were left to the person to commit, and a project pushed once and
1137
+ * not committed after had no `sync.json` in the history its clones got.
1138
+ *
1139
+ * @returns {{ commit: string, paths: string[] } | { error: string } | null}
1140
+ */
1141
+ export function commitWriteBacks(siteDir, backend, { complete = false } = {}) {
1142
+ const repo = repoOf(siteDir)
1143
+ if (!repo?.head || mergeInProgress(repo)) return null
1144
+ let host = 'backend'
1145
+ try {
1146
+ host = new URL(backend).host
1147
+ } catch {
1148
+ /* the name only */
1149
+ }
1150
+ const identity = {
1151
+ GIT_AUTHOR_NAME: 'uniweb',
1152
+ GIT_AUTHOR_EMAIL: `sync@${host}`,
1153
+ GIT_COMMITTER_NAME: 'uniweb',
1154
+ GIT_COMMITTER_EMAIL: `sync@${host}`
1155
+ }
1156
+ try {
1157
+ // ⭐ Marked with the backend and the site, so a pull reads the commit a lane names through
1158
+ // it (`recordedAfter`, utils/sync-point.js): the ids written back are what the backend holds.
1159
+ const site = readBackendState(siteDir, backend).site?.uuid
1160
+ const made = commitRoots(repo, [...sentRoots(siteDir), SYNC_STORE_FILE], {
1161
+ message: `Record what ${backend} gave the site${site ? `\n\n${RECORDED_TRAILER}: ${backend} ${site}` : ''}`,
1162
+ identity
1163
+ })
1164
+ if (made && complete) writeSyncPoint(siteDir, backend, made.commit)
1165
+ return made
1166
+ } catch (err) {
1167
+ return { error: gitError(err) }
1168
+ }
1169
+ }
1170
+
1010
1171
  /**
1011
1172
  * Per-unit content hashes of the last synced state, kept in BOTH representations —
1012
1173
  * the bases for the file-level attribution shown after a staleness refusal
@@ -1976,8 +2137,9 @@ const TEMPLATE_SAID = {
1976
2137
  * @param {object} params.pkg - the `emitSyncPackages` result
1977
2138
  * ({ siteContent, collections, siteContentUuid, hashes })
1978
2139
  * @param {{info,note,error,dim?:Function}} params.report - injected logging
1979
- * @returns {Promise<{ exitCode: number, boundSiteUuid?: string, finalizedTotal: number, wrote: string[] }>}
2140
+ * @returns {Promise<{ exitCode: number, boundSiteUuid?: string, finalizedTotal: number, wrote: string[], complete?: true }>}
1980
2141
  * exitCode 1 on any lane failure (already reported, cache NOT persisted); 0 on success.
2142
+ * `complete` once every lane with something to send has landed.
1981
2143
  */
1982
2144
  export async function pushSyncPackages({
1983
2145
  client,
@@ -1995,8 +2157,10 @@ export async function pushSyncPackages({
1995
2157
  let draftsNotKept = []
1996
2158
  // ⭐ THE COMMIT THIS PUSH IS MADE FROM — sent with each lane, so the backend can name it on
1997
2159
  // a pull, and kept here as this copy's sync point once a lane lands: what the backend
1998
- // holds descends from it (`utils/sync-point.js`). ⚖️ Uncommitted edits ride on top of it,
1999
- // and it stays a safe base for them — both sides hold them, so a merge takes them once.
2160
+ // holds descends from it (`utils/sync-point.js`). In a repository it is exactly what is
2161
+ // sent: a push sends only committed content (`uncommittedContent`). ⛔ Until 2026-10-18
2162
+ // uncommitted edits rode on top of it, and this said it "stays a safe base for them" — but
2163
+ // one undone before the next pull read as no change against it, and the pull put it back.
2000
2164
  const repo = repoOf(siteDir)
2001
2165
  const source = repo?.head || null
2002
2166
 
@@ -2314,10 +2478,14 @@ export async function pushSyncPackages({
2314
2478
  }
2315
2479
  // Both grains land together, at every point the old code banked the entity one — and
2316
2480
  // with them the commit they describe.
2317
- const mergeHarvested = () => {
2481
+ // ⛔ `point: false` where a lane was refused after another landed: the commit then describes
2482
+ // one lane and not the other, and as the base it would read what the refused lane lacks as
2483
+ // the backend's removal — measured, a pull after a push whose records were refused reverted
2484
+ // the record edits, exit 0. The versions of the lane that landed are still banked.
2485
+ const mergeHarvested = ({ point = true } = {}) => {
2318
2486
  mergeBaseVersions(siteDir, client.origin, newVersions)
2319
2487
  if (heldItemVersions) writeItemBaseVersions(siteDir, client.origin, heldItemVersions)
2320
- if (source && (Object.keys(newVersions).length || heldItemVersions)) writeSyncPoint(siteDir, client.origin, source)
2488
+ if (point && source && (Object.keys(newVersions).length || heldItemVersions)) writeSyncPoint(siteDir, client.origin, source)
2321
2489
  }
2322
2490
  // ⛔ The site-content lane banks only the tokens for content this copy holds — the
2323
2491
  // document we sent against the one the backend stored (`heldTokens`, and the two
@@ -2325,6 +2493,25 @@ export async function pushSyncPackages({
2325
2493
  // collected here, to re-base attribution and to say so.
2326
2494
  const sentSiteDoc = siteContent ? entityDocFromUwx(siteContent.buffer) : null
2327
2495
  const notHeld = { kept: [], foreign: [] }
2496
+ // ⭐ WHAT THE BACKEND REMOVED, beside the file it came from. A backend that cleans each
2497
+ // rich-text write to the vocabulary answers what it took out of each item
2498
+ // (`finalized[].cleaned`). The push never writes the returned document over the file, so the
2499
+ // file still holds it, and only this says so — on every push that sends it again.
2500
+ let toldRemoved = false
2501
+ const reportRemovals = (removals) => {
2502
+ for (const r of removals) note(`! ${r.where}${r.locale ? ` (${r.locale})` : ''}: ${r.says}`)
2503
+ if (removals.length && !toldRemoved) {
2504
+ note('Your files still hold what the backend removed; a pull would write them without it.')
2505
+ toldRemoved = true
2506
+ }
2507
+ }
2508
+ const reportSiteContentRemovals = (finalized) =>
2509
+ reportRemovals(
2510
+ siteContentRemovals({ finalized, sent: sentSiteDoc }).map((r) => ({
2511
+ ...r,
2512
+ where: r.unit ? unitFilesOf(siteDir, r.unit)[0] || r.unit : 'the site'
2513
+ }))
2514
+ )
2328
2515
  const harvestSiteContent = (finalized) => {
2329
2516
  for (const f of finalized || []) {
2330
2517
  const held = heldTokens({
@@ -2438,6 +2625,7 @@ export async function pushSyncPackages({
2438
2625
  return { exitCode: 1, finalizedTotal, wrote }
2439
2626
  }
2440
2627
  harvestSiteContent(finalized)
2628
+ reportSiteContentRemovals(finalized)
2441
2629
  siteFinalizedDoc = finalized[0]?.document || null
2442
2630
  finalizedTotal += finalized.length
2443
2631
  settleSiteContent()
@@ -2472,6 +2660,7 @@ export async function pushSyncPackages({
2472
2660
  wrote.push(`recorded its owner (${createdOrg}) in sync.json`)
2473
2661
  const createdFinalized = extractFinalized(payload)
2474
2662
  harvestSiteContent(createdFinalized)
2663
+ reportSiteContentRemovals(createdFinalized)
2475
2664
  siteFinalizedDoc = createdFinalized?.[0]?.document || null
2476
2665
  finalizedTotal += createdFinalized?.length ?? 1
2477
2666
  settleSiteContent()
@@ -2496,10 +2685,17 @@ export async function pushSyncPackages({
2496
2685
  { boundUuid: boundSiteUuid }
2497
2686
  )
2498
2687
  if (!finalized) {
2499
- mergeHarvested()
2688
+ mergeHarvested({ point: false })
2500
2689
  return { exitCode: 1, finalizedTotal, wrote }
2501
2690
  }
2502
2691
  harvest(finalized)
2692
+ reportRemovals(
2693
+ recordRemovals({ finalized, sent: submittedDocsFromUwx(records.buffer) }).map((r) => {
2694
+ const entry = records.index?.[r.index]
2695
+ const file = entry?.sourceFile
2696
+ return { ...r, where: file ? (isAbsolute(file) ? relative(siteDir, file) : file) : entry?.id || 'a record' }
2697
+ })
2698
+ )
2503
2699
  const bf = backfillEntityUuids({ index: records.index, finalized })
2504
2700
  // ⭐ Record which uuid THIS backend minted for each record, keyed by the record's
2505
2701
  // own id. The file keeps that own id; the next push to this backend sends the
@@ -2597,7 +2793,7 @@ export async function pushSyncPackages({
2597
2793
  shown.join('\n ') +
2598
2794
  '\n The backend does not support `draft: true` yet. Once it does, push again with --all.'
2599
2795
  )
2600
- return { exitCode: 1, boundSiteUuid, finalizedTotal, wrote }
2796
+ return { exitCode: 1, boundSiteUuid, finalizedTotal, wrote, complete: true }
2601
2797
  }
2602
- return { exitCode: 0, boundSiteUuid, finalizedTotal, wrote }
2798
+ return { exitCode: 0, boundSiteUuid, finalizedTotal, wrote, complete: true }
2603
2799
  }
@@ -37,6 +37,9 @@
37
37
  * uniweb publish --dry-run Resolve everything; POST nothing
38
38
  * uniweb publish --yes Skip confirmations (CI); never block on a prompt
39
39
  * uniweb publish --force Overwrite upstream app-side edits (drop the push gate)
40
+ * uniweb publish --commit [-m <message>]
41
+ * In a repository, commit the site's content first — a
42
+ * publish there sends only what is committed
40
43
  * uniweb publish --org @org Work in @org for this publish, instead of the
41
44
  * uniweb publish --personal workspace chosen at `uniweb login` (or your
42
45
  * personal workspace). A site it creates is created
@@ -105,9 +108,13 @@ import {
105
108
  clearRemoteSyncStateIfUnbound,
106
109
  untakenBase,
107
110
  unsettledFiles,
111
+ commitWriteBacks,
112
+ reportWriteBacks,
108
113
  dropSiteBoundValues,
109
114
  pushSyncPackages
110
115
  } from '../backend/site-sync.js'
116
+ import { repoOf } from '../utils/sync-point.js'
117
+ import { commitBeforeSending } from '../backend/commit-before-sending.js'
111
118
  import { resolveWorkspace, describeWorkspace, SOURCE_LABEL } from '../backend/workspace.js'
112
119
  import { uploadSiteMedia, describeAssetRefusal } from '../backend/site-media.js'
113
120
  import {
@@ -334,6 +341,16 @@ export async function publish(args = []) {
334
341
  for (const line of unsettled.lines) say.dim(line)
335
342
  return { exitCode: 1 }
336
343
  }
344
+ // ⛔ In a repository a publish sends what is committed (`uncommittedContent`), whatever the
345
+ // flags. `--commit` commits it first; a dry run says it would.
346
+ {
347
+ const stop = commitBeforeSending(siteDir, args, {
348
+ verb: 'publish',
349
+ dryRun,
350
+ say: { success: say.ok, info: say.info, error: say.err, note: say.dim }
351
+ })
352
+ if (stop) return stop
353
+ }
337
354
  if (!dryRun && !args.includes('--force')) {
338
355
  const untaken = untakenBase(siteDir, client.origin)
339
356
  if (untaken) {
@@ -711,7 +728,9 @@ export async function publish(args = []) {
711
728
  const rec = updateBackendMap(siteDir, client.origin, 'assets', ids, carryServed)
712
729
  if (rec.written) {
713
730
  say.dim(
714
- `${SYNC_STORE_FILE} : ${rec.added.length} asset(s) added, ${rec.changed.length} changed — commit it`
731
+ `${SYNC_STORE_FILE} : ${rec.added.length} asset(s) added, ${rec.changed.length} changed` +
732
+ // In a repository the publish commits it (`commitWriteBacks`).
733
+ (repoOf(siteDir) ? '' : ' — commit it')
715
734
  )
716
735
  }
717
736
  if (ballAssets.length) ball = rewriteSchemalessDataAssets(ball, map)
@@ -955,6 +974,8 @@ export async function publish(args = []) {
955
974
  pkg,
956
975
  report
957
976
  })
977
+ // What the push wrote into the files is committed, whatever became of the lanes.
978
+ reportWriteBacks(commitWriteBacks(siteDir, client.origin, { complete: pushResult.complete }), { note: say.dim })
958
979
  if (pushResult.exitCode !== 0) return { exitCode: pushResult.exitCode }
959
980
  const siteUuid = pushResult.boundSiteUuid
960
981
  if (!siteUuid) {
@@ -106,7 +106,7 @@ import {
106
106
  writeFolderItemUuids
107
107
  } from '../backend/site-sync.js'
108
108
  import { resolveWorkspace } from '../backend/workspace.js'
109
- import { siteContentRoots } from '../utils/git.js'
109
+ import { siteContentRoots, siteLaneRoots } from '../utils/git.js'
110
110
  import {
111
111
  repoOf,
112
112
  mergeInProgress,
@@ -115,12 +115,13 @@ import {
115
115
  isAncestor,
116
116
  historySyncPoint,
117
117
  madeOnto,
118
- newestBase,
118
+ baseFor,
119
119
  openStandIn,
120
120
  mergeSyncPoint,
121
121
  takeSyncPoint,
122
122
  snapshotFiles,
123
123
  changedInFiles,
124
+ filesDifferFrom,
124
125
  readSyncPoint,
125
126
  writeSyncPoint
126
127
  } from '../utils/sync-point.js'
@@ -904,14 +905,25 @@ export async function pull(args = [], deps = {}) {
904
905
  let onto = null
905
906
  let standIn = null
906
907
  if (repo && projected) {
907
- base = newestBase(repo, [
908
+ // The bases this copy knows of itself — then what the backend names, per lane.
909
+ const known = [
908
910
  historySyncPoint(repo, { origin: client.origin, siteUuid: siteContentUuid }),
909
911
  held,
910
912
  // What a sync point the branch no longer holds — a merge abandoned — was made onto.
911
- ...madeOnto(repo, held, { origin: client.origin, siteUuid: siteContentUuid }),
912
- content?.source,
913
- folderLane?.source
914
- ])
913
+ ...madeOnto(repo, held, { origin: client.origin, siteUuid: siteContentUuid })
914
+ ]
915
+ const laneRoots = siteLaneRoots(siteDir)
916
+ const taken = (lane) => lane && !lane.notModified && !lane.refused
917
+ base = baseFor(repo, {
918
+ known,
919
+ // ⛔ What each lane the backend answered names is a base only where every lane holds it (`baseFor`).
920
+ lanes: [
921
+ ...(taken(content) ? [{ source: content.source, roots: laneRoots.content }] : []),
922
+ ...(taken(folderLane) ? [{ source: folderLane.source, roots: laneRoots.records }] : [])
923
+ ],
924
+ origin: client.origin,
925
+ siteUuid: siteContentUuid
926
+ })
915
927
  onto = force || !base ? repo.head : base
916
928
  try {
917
929
  standIn = openStandIn(repo, { roots, rev: onto })
@@ -1136,6 +1148,9 @@ export async function pull(args = [], deps = {}) {
1136
1148
  siteUuid: siteContentUuid,
1137
1149
  always: force || !base
1138
1150
  })
1151
+ // The commit that holds the backend's content as the files would — the base itself when
1152
+ // they hold the same.
1153
+ merge.theirs = made.commit || base
1139
1154
  if (!made.commit) {
1140
1155
  // The backend holds what the base holds: nothing to merge.
1141
1156
  writeSyncPoint(siteDir, client.origin, base)
@@ -1170,6 +1185,8 @@ export async function pull(args = [], deps = {}) {
1170
1185
  } else {
1171
1186
  merge.conflicted = result.conflicted || []
1172
1187
  merge.stashKept = Boolean(result.stashKept)
1188
+ // Whether anything was set aside for the merge: the files as they were, against HEAD.
1189
+ merge.setAside = files !== repo.head
1173
1190
  merge.noBase = !force && !base
1174
1191
  merge.changed = changedInFiles(repo, {
1175
1192
  from: repo.head,
@@ -1249,7 +1266,16 @@ export async function pull(args = [], deps = {}) {
1249
1266
  if (merge.changed.length > 12) note(` … and ${merge.changed.length - 12} more`)
1250
1267
  if (force) note('What they held is in the commit before this one (`git diff HEAD^1 HEAD`), unless it was never committed.')
1251
1268
  } else if (!merge.conflicted.length) {
1252
- note(`Your files already hold what ${client.origin} has.`)
1269
+ // ⛔ Nothing changed in the files is not the files holding what the backend has: what
1270
+ // this copy changed since the base, the backend lacks. Until 2026-10-18 it said they
1271
+ // held it either way — measured over a revert the backend did not have yet.
1272
+ const ahead = merge.theirs ? filesDifferFrom(repo, merge.theirs, roots) : []
1273
+ if (!ahead.length) note(`Your files already hold what ${client.origin} has.`)
1274
+ else {
1275
+ note(`Nothing new from ${client.origin}. ${ahead.length} file(s) here hold changes it does not have yet — \`uniweb push\` sends them:`)
1276
+ for (const p of ahead.slice(0, 12)) note(` ${p}`)
1277
+ if (ahead.length > 12) note(` … and ${ahead.length - 12} more`)
1278
+ }
1253
1279
  }
1254
1280
  if (merge.conflicted.length) {
1255
1281
  error(`${merge.conflicted.length} file(s) have conflicts to resolve:`)
@@ -1264,7 +1290,9 @@ export async function pull(args = [], deps = {}) {
1264
1290
  note("your changes from the backend's: each file where they differ is a conflict, yours against the backend's.")
1265
1291
  note('Once this merge is committed, later pulls merge from it.')
1266
1292
  }
1267
- note('Settle them and `git add` each, then `git commit` to finish the merge — your uncommitted changes come back with it.')
1293
+ note(
1294
+ `Settle them and \`git add\` each, then \`git commit\` to finish the merge${merge.setAside ? ' — your uncommitted changes come back with it' : ''}.`
1295
+ )
1268
1296
  }
1269
1297
  }
1270
1298
  }
@@ -37,6 +37,8 @@
37
37
  * uniweb push --foundation <dir> Use this local foundation for the Model schema
38
38
  * uniweb push --all Send every record (bypass the changed-only cache)
39
39
  * uniweb push --force Overwrite upstream changes (drop the staleness gate)
40
+ * uniweb push --commit [-m <message>] In a repository, commit the site's content first —
41
+ * a push there sends only what is committed
40
42
  * uniweb push --no-release Send content against the foundation already
41
43
  * released; release nothing
42
44
  * uniweb push --bump Release above a newer registered version
@@ -78,6 +80,7 @@ import { confirm, isNonInteractive } from '../utils/interactive.js'
78
80
  import { announceServices, foundationSupports } from '../backend/service-request.js'
79
81
  import { guardEmptyRecords } from '../utils/records-guard.js'
80
82
  import { bringFoundationAlong } from '../backend/foundation-bring-along.js'
83
+ import { repoOf } from '../utils/sync-point.js'
81
84
  import {
82
85
  makeModelResolver,
83
86
  readSyncCache,
@@ -97,9 +100,12 @@ import {
97
100
  dropSiteBoundValues,
98
101
  untakenBase,
99
102
  unsettledFiles,
103
+ commitWriteBacks,
104
+ reportWriteBacks,
100
105
  holdUnchangedPush,
101
106
  pushSyncPackages
102
107
  } from '../backend/site-sync.js'
108
+ import { commitBeforeSending } from '../backend/commit-before-sending.js'
103
109
  import { resolveWorkspace, describeWorkspace, SOURCE_LABEL } from '../backend/workspace.js'
104
110
 
105
111
  // Re-exported for downstream importers (pull.js, push.test.js) that read these
@@ -223,6 +229,12 @@ export async function push(args = [], deps = {}) {
223
229
  for (const line of unsettled.lines) note(line)
224
230
  return { exitCode: 1 }
225
231
  }
232
+ // ⛔ In a repository a push sends what is committed (`uncommittedContent`), whatever the
233
+ // flags but `-o`, which sends nothing. `--commit` commits it first; a dry run says it would.
234
+ if (!output) {
235
+ const stop = commitBeforeSending(siteDir, args, { verb: 'push', dryRun, say: { success, info, error, note } })
236
+ if (stop) return stop
237
+ }
226
238
  if (!output && !force) {
227
239
  const untaken = untakenBase(siteDir, client.origin)
228
240
  if (untaken) {
@@ -514,8 +526,10 @@ export async function push(args = [], deps = {}) {
514
526
  // carries only the refs its content touched.
515
527
  const rec = updateBackendMap(siteDir, client.origin, 'assets', ids, carryServed)
516
528
  if (rec.written) {
529
+ // In a repository the push commits it (`commitWriteBacks`).
517
530
  note(
518
- `${SYNC_STORE_FILE}: ${rec.added.length} asset(s) added, ${rec.changed.length} changed — commit it`
531
+ `${SYNC_STORE_FILE}: ${rec.added.length} asset(s) added, ${rec.changed.length} changed` +
532
+ (repoOf(siteDir) ? '' : ' — commit it')
519
533
  )
520
534
  }
521
535
  } catch (err) {
@@ -626,7 +640,11 @@ export async function push(args = [], deps = {}) {
626
640
  )
627
641
  // ⭐ And so the backend holds what this commit sends: it is where the next pull merges
628
642
  // from (`holdUnchangedPush`). Not for a preview, which keeps nothing.
629
- if (!output && !dryRun) holdUnchangedPush(siteDir, client.origin)
643
+ if (!output && !dryRun) {
644
+ holdUnchangedPush(siteDir, client.origin)
645
+ // What it recorded on the way — a media map, an identity recovered — is committed too.
646
+ reportWriteBacks(commitWriteBacks(siteDir, client.origin), { note })
647
+ }
630
648
  return { exitCode: 0 }
631
649
  }
632
650
  if (siteContent)
@@ -683,10 +701,16 @@ export async function push(args = [], deps = {}) {
683
701
  dim: (s) => `${colors.dim}${s}${colors.reset}`
684
702
  }
685
703
  })
686
- if (result.exitCode !== 0) return { exitCode: result.exitCode }
704
+ // What the push wrote into the files is committed, whatever became of the lanes.
705
+ const written = commitWriteBacks(siteDir, client.origin, { complete: result.complete })
706
+ if (result.exitCode !== 0) {
707
+ reportWriteBacks(written, { note })
708
+ return { exitCode: result.exitCode }
709
+ }
687
710
  success(
688
711
  `Pushed ${result.finalizedTotal} entit${result.finalizedTotal === 1 ? 'y' : 'ies'}` +
689
712
  (result.wrote.length ? ` — ${result.wrote.join(', ')}` : '')
690
713
  )
714
+ reportWriteBacks(written, { note })
691
715
  return { exitCode: 0 }
692
716
  }
@@ -35,6 +35,9 @@
35
35
  * uniweb sync refresh, then push
36
36
  * uniweb sync --no-git skip the git remote half of the refresh
37
37
  * uniweb sync --force forwarded to push (overwrite upstream changes)
38
+ * uniweb sync --commit [-m <msg>]
39
+ * forwarded to push (commit the site's content first —
40
+ * in a repository a push sends only what is committed)
38
41
  */
39
42
 
40
43
  import { checkFlags } from '../utils/flag-guard.js'
@@ -73,8 +76,9 @@ export async function sync(args = [], deps = {}) {
73
76
 
74
77
  // `--force` means "overwrite upstream" and belongs to the push half only. Passing
75
78
  // it to the refresh would ask pull to DISCARD the local work this command exists
76
- // to send — the same word meaning opposite things on the two halves.
77
- const refreshArgs = args.filter((a) => a !== '--force')
79
+ // to send — the same word meaning opposite things on the two halves. `--commit` and
80
+ // its message are the push's too: the refresh merges with the work as it is.
81
+ const refreshArgs = pushOnly(args)
78
82
 
79
83
  const r = await refresh(refreshArgs, { skipFlagCheck: true })
80
84
  if (r?.exitCode) {
@@ -90,4 +94,19 @@ export async function sync(args = [], deps = {}) {
90
94
  return { exitCode: p?.exitCode ?? 0 }
91
95
  }
92
96
 
97
+ /** The argv without the push half's own flags — `--force`, `--commit`, `-m <message>`. */
98
+ function pushOnly(args) {
99
+ const out = []
100
+ for (let i = 0; i < args.length; i++) {
101
+ const a = args[i]
102
+ if (a === '--force' || a === '--commit' || a.startsWith('-m=') || a.startsWith('--message=')) continue
103
+ if (a === '-m' || a === '--message') {
104
+ i++ // its value
105
+ continue
106
+ }
107
+ out.push(a)
108
+ }
109
+ return out
110
+ }
111
+
93
112
  export default sync
@@ -1,15 +1,15 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-10-18T21:49:47.266Z",
3
+ "generatedAt": "2026-10-19T02:42:59.650Z",
4
4
  "packages": {
5
5
  "@uniweb/api": {
6
6
  "version": "1.5.2"
7
7
  },
8
8
  "@uniweb/build": {
9
- "version": "1.5.0"
9
+ "version": "1.5.1"
10
10
  },
11
11
  "@uniweb/content-reader": {
12
- "version": "1.5.1"
12
+ "version": "1.5.2"
13
13
  },
14
14
  "@uniweb/content-writer": {
15
15
  "version": "1.3.0"
package/src/index.js CHANGED
@@ -1470,6 +1470,8 @@ ${colors.bright}Options:${colors.reset}
1470
1470
  bind to, and the app cannot open such a site.
1471
1471
  --bump When the registry holds a newer version of the foundation
1472
1472
  than yours, release yours above it instead of stopping
1473
+ --commit In a git repository, commit the site's content first
1474
+ (-m <message>) — a publish there sends only what is committed
1473
1475
  --no-save Do not record this deploy in deploy.yml
1474
1476
  --no-validate Skip the content-conformance check, which stops a publish
1475
1477
  whose content does not conform to its data schemas
@@ -2105,6 +2107,8 @@ ${colors.bright}Push Options:${colors.reset}
2105
2107
  --no-release Send content against the already-released code; release nothing
2106
2108
  --bump Release above a newer registered foundation version
2107
2109
  --force Overwrite upstream changes (drop the staleness gate)
2110
+ --commit In a git repository, commit the site's content first (-m <message>);
2111
+ a push there sends only what is committed
2108
2112
  --all Send every record (bypass the changed-only cache)
2109
2113
  --org @org Work in @org for this push, not your login's workspace
2110
2114
  --personal Work in your personal workspace for this push
@@ -2115,6 +2119,7 @@ ${colors.bright}Publish Options:${colors.reset}
2115
2119
  --yes Skip confirmations (CI); never block on a prompt
2116
2120
  --no-release Ship content against the already-released code; release nothing
2117
2121
  --bump Release above a newer registered foundation version
2122
+ --commit In a git repository, commit the site's content first (-m <message>)
2118
2123
  --org @org Work in @org for this publish, not your login's workspace
2119
2124
  --personal Work in your personal workspace for this publish
2120
2125
  --no-save Do not record this deploy in deploy.yml
@@ -67,14 +67,17 @@ const VERBS = {
67
67
  '--no-verify',
68
68
  // ship content against the already-released code, releasing nothing — or
69
69
  // release above a newer registered version instead of stopping (both bring-along)
70
- '--no-release', '--bump', ...VIA_DEPLOY
70
+ '--no-release', '--bump',
71
+ // commit the site's content first, with this message (backend/site-sync.js)
72
+ '--commit', '-m', '--message', ...VIA_DEPLOY
71
73
  ],
72
74
  publish: [
73
75
  '--org', '--dry-run', '--force', '--foundation',
74
76
  '--personal',
75
77
  // read in utils/conformance.js, backend/site-sync.js, and
76
78
  // backend/foundation-bring-along.js — none appear in publish.js
77
- '--no-validate', '--yes', '--no-verify', '--no-release', '--bump', ...VIA_DEPLOY
79
+ '--no-validate', '--yes', '--no-verify', '--no-release', '--bump',
80
+ '--commit', '-m', '--message', ...VIA_DEPLOY
78
81
  ],
79
82
  pull: [
80
83
  '--content-only', '--dry-run', '--force',
package/src/utils/git.js CHANGED
@@ -66,6 +66,25 @@ export function siteContentRoots(siteDir) {
66
66
  return [...roots]
67
67
  }
68
68
 
69
+ /**
70
+ * What each lane of a push carries, as roots: the folder lane the records — their files,
71
+ * and their translations — and the content lane everything else a pull writes, media
72
+ * (`public/`) included. ⚠️ `locales` stays whole on the content side, record translations
73
+ * with it: a root too many there costs a less precise base, never a wrong one.
74
+ */
75
+ export function siteLaneRoots(siteDir) {
76
+ let records = 'records'
77
+ try {
78
+ records = yaml.load(readFileSync(join(siteDir, 'site.yml'), 'utf8'))?.paths?.records || 'records'
79
+ } catch {
80
+ /* no or unreadable site.yml — the default is right */
81
+ }
82
+ return {
83
+ content: [...siteContentRoots(siteDir), 'public'].filter((r) => r !== records),
84
+ records: [records, 'locales/records']
85
+ }
86
+ }
87
+
69
88
  function git(args, cwd) {
70
89
  return execFileSync('git', args, {
71
90
  cwd,
@@ -53,6 +53,8 @@ import { join, dirname } from 'node:path'
53
53
 
54
54
  export const SYNC_TRAILER = 'Uniweb-Sync'
55
55
  export const SITE_TRAILER = 'Uniweb-Site'
56
+ /** Marks the commit a push makes of what it wrote back: `Uniweb-Recorded: <origin> <site>`. */
57
+ export const RECORDED_TRAILER = 'Uniweb-Recorded'
56
58
 
57
59
  const MAX_BUFFER = 1024 * 1024 * 1024
58
60
 
@@ -292,6 +294,70 @@ export function newestBase(repo, candidates) {
292
294
  }
293
295
  }
294
296
 
297
+ /**
298
+ * The base a pull merges from, given the bases this copy knows of itself and what the backend
299
+ * names for each lane it answered (`x-uniweb-source`).
300
+ *
301
+ * ⭐ THIS COPY'S OWN BASES ARE EXACT BY CONSTRUCTION — a pull's commit, the commit a push was
302
+ * made from once every lane landed, one a push found nothing to send from — and are never
303
+ * second-guessed here.
304
+ *
305
+ * ⛔ ONE MERGE TAKES ONE BASE FOR EVERY LANE, and what the backend names is exact for its own
306
+ * lane only. A commit one lane names that is newer than another lane's, with that lane's files
307
+ * different, would read what that lane lacks — a push of it the backend refused while it took the
308
+ * other — as the backend's removal, and the merge would drop it. So it is not a base. A lane is
309
+ * held to the commit it names, read through the commit its push made of what it wrote back
310
+ * (`recordedAfter`), and a lane that names none to the newest base this copy knows.
311
+ *
312
+ * ⛔ Until 2026-10-19 (cli 713f62d, uniweb 1.13.0) every candidate was held to that, this copy's
313
+ * own included, and a lane to the commit it names alone. A first push writes each new record's
314
+ * `$uuid` back, so the records differed from the commit the folder lane names in every commit after
315
+ * it, and each was dropped: a pull merged from the site's first push, measured by flows against a
316
+ * real backend — a reverted change came back (A18), a re-wrap and six pushed edits conflicted.
317
+ */
318
+ export function baseFor(repo, { known = [], lanes = [], origin, siteUuid }) {
319
+ const held = (s) => Boolean(s) && isCommit(repo, s) && isAncestor(repo, s)
320
+ const fallback = newestBase(repo, known)
321
+ const refs = lanes.map(({ source, roots }) => ({
322
+ roots,
323
+ ref: held(source) ? recordedAfter(repo, source, { origin, siteUuid }) || source : fallback
324
+ }))
325
+ const named = lanes.flatMap(({ source }) => (held(source) ? [source, recordedAfter(repo, source, { origin, siteUuid })] : []))
326
+ const taken = named.filter(Boolean).filter((s) =>
327
+ refs.every(({ ref, roots }) => Boolean(ref) && (s === ref || isAncestor(repo, s, ref) || !differsIn(repo, ref, s, roots)))
328
+ )
329
+ return newestBase(repo, [...known, ...taken])
330
+ }
331
+
332
+ /**
333
+ * The commit a push made of what it wrote back, after being made from `sha` — its first parent,
334
+ * marked with the push's backend and site — when HEAD's history holds it; else null.
335
+ */
336
+ export function recordedAfter(repo, sha, { origin, siteUuid }) {
337
+ if (!sha || !origin || !siteUuid) return null
338
+ let out
339
+ try {
340
+ out = gitText(repo.top, ['log', '--fixed-strings', `--grep=${RECORDED_TRAILER}: ${origin} ${siteUuid}`, '--format=%H %P', 'HEAD'])
341
+ } catch {
342
+ return null
343
+ }
344
+ for (const line of out.split('\n')) {
345
+ const [commit, first] = line.trim().split(' ')
346
+ if (commit && first && (first === sha || first.startsWith(sha))) return commit
347
+ }
348
+ return null
349
+ }
350
+
351
+ /** Does the content under `roots` differ between two commits? Anything but "no" counts as yes. */
352
+ function differsIn(repo, a, b, roots) {
353
+ try {
354
+ git(repo.top, ['diff', '--quiet', a, b, '--', ...roots.map((r) => `${repo.prefix}${r}`)])
355
+ return false
356
+ } catch {
357
+ return true
358
+ }
359
+ }
360
+
295
361
  // ── the stand-in ────────────────────────────────────────────────────────────
296
362
 
297
363
  /**
@@ -652,14 +718,79 @@ export function snapshotFiles(repo) {
652
718
  * under the roots? (What the repository ignores is no change.)
653
719
  */
654
720
  export function contentCommitted(repo, roots) {
721
+ return uncommittedPaths(repo, roots)?.length === 0
722
+ }
723
+
724
+ /**
725
+ * The files under the roots that HEAD does not hold as they are — staged, changed, deleted
726
+ * or untracked, and both sides of a rename — relative to the site; null when git cannot
727
+ * say. What the repository ignores is no change.
728
+ */
729
+ export function uncommittedPaths(repo, roots) {
730
+ let parts
655
731
  try {
656
- const full = roots.map((r) => `${repo.prefix}${r}`)
657
- return git(repo.top, ['status', '--porcelain', '-z', '--untracked-files=all', '--', ...full]).length === 0
732
+ parts = zlist(git(repo.top, ['status', '--porcelain', '-z', '--untracked-files=all', '--', ...roots.map((r) => `${repo.prefix}${r}`)]))
658
733
  } catch {
659
- return false
734
+ return null
735
+ }
736
+ const out = []
737
+ for (let i = 0; i < parts.length; i++) {
738
+ out.push(parts[i].slice(3))
739
+ if (/[RC]/.test(parts[i].slice(0, 2))) out.push(parts[++i]) // a rename's source follows it
660
740
  }
741
+ return [...new Set(out.map((p) => siteRelative(repo, p)))]
661
742
  }
662
743
 
744
+ /**
745
+ * The files under the roots that differ from what `rev` holds there — changed, deleted, or
746
+ * new and not ignored — relative to the site.
747
+ */
748
+ export function filesDifferFrom(repo, rev, roots) {
749
+ const full = roots.map((r) => `${repo.prefix}${r}`)
750
+ try {
751
+ const changed = zlist(git(repo.top, ['diff', '--name-only', '-z', '--no-renames', rev, '--', ...full]))
752
+ const added = zlist(git(repo.top, ['ls-files', '--others', '--exclude-standard', '-z', '--', ...full]))
753
+ return [...new Set([...changed, ...added].map((p) => siteRelative(repo, p)))]
754
+ } catch {
755
+ return []
756
+ }
757
+ }
758
+
759
+ /**
760
+ * Commit what the files hold under the roots — added, changed, deleted or renamed — and
761
+ * nothing else that is staged. With `identity` it is uniweb's commit, made as a pull's merge
762
+ * is: no hooks, unsigned. Without it, the person's own, as their `git commit` makes it.
763
+ *
764
+ * ⛔ Staged by ROOT, committed by NAME: `git add` refuses a path that is on neither the disk
765
+ * nor the index — a rename's source — and the commit takes exactly what was staged.
766
+ *
767
+ * @returns {{ commit: string, paths: string[] } | null} null when there was nothing to commit
768
+ */
769
+ export function commitRoots(repo, roots, { message, identity = null }) {
770
+ const full = roots.map((r) => `${repo.prefix}${r}`)
771
+ const present = full.filter((p) => existsSync(join(repo.top, p)) || zlist(git(repo.top, ['ls-files', '-z', '--', p])).length)
772
+ let addable = present
773
+ if (present.length) {
774
+ try {
775
+ const ignored = zlist(git(repo.top, ['check-ignore', '-z', '--stdin'], { input: `${present.join('\0')}\0` }))
776
+ addable = present.filter((p) => !ignored.includes(p))
777
+ } catch (err) {
778
+ if (err?.status !== 1) throw err // 1: none of them is ignored
779
+ }
780
+ }
781
+ if (addable.length) git(repo.top, ['--literal-pathspecs', 'add', '-A', '--', ...addable])
782
+ const staged = zlist(git(repo.top, ['diff', '--cached', '--name-only', '-z', '--no-renames', '--', ...full]))
783
+ if (!staged.length) return null
784
+ git(
785
+ repo.top,
786
+ ['--literal-pathspecs', 'commit', '-q', ...(identity ? ['--no-verify', '--no-gpg-sign'] : []), '-m', message, '--pathspec-from-file=-', '--pathspec-file-nul'],
787
+ { input: `${staged.join('\0')}\0`, ...(identity ? { env: identity } : {}) }
788
+ )
789
+ return { commit: gitText(repo.top, ['rev-parse', 'HEAD']).trim(), paths: staged.map((p) => siteRelative(repo, p)) }
790
+ }
791
+
792
+ const siteRelative = (repo, p) => (repo.prefix && p.startsWith(repo.prefix) ? p.slice(repo.prefix.length) : p)
793
+
663
794
  // ── this copy's own sync point ──────────────────────────────────────────────
664
795
 
665
796
  const cachePath = (siteDir) => join(siteDir, '.uniweb', 'backend-cache.json')