uniweb 1.11.0 → 1.13.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uniweb",
3
- "version": "1.11.0",
3
+ "version": "1.13.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/content-writer": "~1.3.0",
45
- "@uniweb/core": "~1.2.5",
46
44
  "@uniweb/schemas": "~1.5.0",
47
- "@uniweb/semantic-parser": "~1.11.0"
45
+ "@uniweb/content-writer": "~1.3.0",
46
+ "@uniweb/core": "~1.3.0",
47
+ "@uniweb/semantic-parser": "~1.12.0"
48
48
  },
49
49
  "peerDependencies": {
50
- "@uniweb/build": "~1.4.0",
51
- "@uniweb/semantic-parser": "~1.11.0",
52
- "@uniweb/content-reader": "~1.5.0"
50
+ "@uniweb/content-reader": "~1.5.2",
51
+ "@uniweb/semantic-parser": "~1.12.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,9 @@ import {
20
20
  writeSyncPoint,
21
21
  isAncestor,
22
22
  contentCommitted,
23
+ uncommittedPaths,
24
+ commitRoots,
25
+ gitError,
23
26
  unmergedPaths,
24
27
  mergeInProgress
25
28
  } from '../utils/sync-point.js'
@@ -64,7 +67,11 @@ import {
64
67
  LINK_MODEL,
65
68
  bankedFileUuids,
66
69
  existingSectionFile,
67
- layoutUnitFile
70
+ layoutUnitFile,
71
+ siteContentRemovals,
72
+ recordRemovals,
73
+ cleanedRecord,
74
+ SYNC_STORE_FILE
68
75
  } from '@uniweb/build/uwx'
69
76
 
70
77
  // First entity `$`-document out of a `.uwx` we produced or the backend served.
@@ -84,6 +91,26 @@ function entityDocFromUwx(buf) {
84
91
  return null
85
92
  }
86
93
 
94
+ // Every entity `$`-document of a `.uwx` in the order it was submitted — the manifest's, which is
95
+ // what a push's `finalized[].index` counts in. null where a listed file is missing or unreadable.
96
+ function submittedDocsFromUwx(buf) {
97
+ if (!buf || buf.length < 2 || buf[0] !== 0x50 || buf[1] !== 0x4b) return []
98
+ const files = readZip(buf)
99
+ let entries
100
+ try {
101
+ entries = JSON.parse(files.get('manifest.json')?.toString('utf8') ?? '{}').entries
102
+ } catch {
103
+ return []
104
+ }
105
+ return (Array.isArray(entries) ? entries : []).map((entry) => {
106
+ try {
107
+ return JSON.parse(files.get(entry?.file).toString('utf8'))
108
+ } catch {
109
+ return null
110
+ }
111
+ })
112
+ }
113
+
87
114
  // Every entity `$`-document of a `.uwx` — the folder lane holds the folder and its records.
88
115
  function entityDocsFromUwx(buf) {
89
116
  const docs = []
@@ -223,6 +250,10 @@ function writtenAsSent(sent, written) {
223
250
  * (`siteItemsByKey`), and it replaces the one held before:
224
251
  *
225
252
  * - an item the backend holds as we sent it → its new version;
253
+ * - an item it holds as we sent it once cleaned to the rich-text vocabulary → its new version
254
+ * too: what a backend that cleans at write removed is not someone else's content
255
+ * (`cleanedRecord`). ⛔ Until 2026-10-18 it was, so a push with a link the store removed
256
+ * said the section had changed on the backend, and kept the version for what was sent;
226
257
  * - an item we sent that it kept for someone else → the version we held, for OUR content;
227
258
  * - an item it holds that we did not send → the version we held, if we had seen it, and
228
259
  * none if we had not (a unit of it is `foreign`);
@@ -232,7 +263,7 @@ function writtenAsSent(sent, written) {
232
263
  * the backend returned it — harmless only while none of them was ever sent.
233
264
  *
234
265
  * A service is compared by its switch and its whole `config` (`sameServiceRow`), every
235
- * other item on the keys we sent (`writtenAsSent`).
266
+ * other item on the keys we sent (`writtenAsSent`), as sent or as cleaned.
236
267
  *
237
268
  * With either document missing there is nothing to compare, and with no versions
238
269
  * returned (an older backend) nothing to bank: `itemVersions` is null, and the versions
@@ -255,7 +286,8 @@ export function heldTokens({ sent, written, version = null, itemVersions = null,
255
286
  const prior = before && typeof before === 'object' ? before : {}
256
287
  const ours = siteItemsByKey(sent)
257
288
  const theirs = siteItemsByKey(written)
258
- const same = (key, a, b) => (key.startsWith('services:') ? sameServiceRow(a, b) : writtenAsSent(a, b))
289
+ const same = (key, a, b) =>
290
+ key.startsWith('services:') ? sameServiceRow(a, b) : writtenAsSent(a, b) || writtenAsSent(cleanedRecord(a), b)
259
291
  const held = []
260
292
  const kept = []
261
293
  const foreign = []
@@ -371,7 +403,10 @@ export function extractFinalized(payload) {
371
403
  d?.item_versions && typeof d.item_versions === 'object'
372
404
  ? d.item_versions
373
405
  : null,
374
- document: d?.document ?? null
406
+ document: d?.document ?? null,
407
+ // What a backend that cleans rich text at write removed from what was sent, each
408
+ // `{ $uuid, field, path, rule }` — the key absent when nothing was (`uwx/cleaned.js`).
409
+ ...(Array.isArray(d?.cleaned) && d.cleaned.length ? { cleaned: d.cleaned } : {})
375
410
  }))
376
411
  .filter((e) => Number.isInteger(e.index) && e.uuid)
377
412
  }
@@ -1002,11 +1037,119 @@ export function untakenBase(siteDir, backend) {
1002
1037
  export function holdUnchangedPush(siteDir, backend) {
1003
1038
  const repo = repoOf(siteDir)
1004
1039
  if (!repo?.head || !readBackendState(siteDir, backend).site?.uuid) return null
1005
- if (!contentCommitted(repo, [...siteContentRoots(siteDir), 'public'])) return null
1040
+ if (!contentCommitted(repo, sentRoots(siteDir))) return null
1006
1041
  writeSyncPoint(siteDir, backend, repo.head)
1007
1042
  return repo.head
1008
1043
  }
1009
1044
 
1045
+ /** What a push sends from the files: the site's content, and its media (`public/`). */
1046
+ export const sentRoots = (siteDir) => [...siteContentRoots(siteDir), 'public']
1047
+
1048
+ /**
1049
+ * ⛔ IN A REPOSITORY, A PUSH SENDS WHAT IS COMMITTED. The commit a push is made from is where
1050
+ * the next pull merges from — this copy's, and every copy the backend names it to — so what
1051
+ * the push sends must be what that commit holds. Returns the refusal, or null: outside a
1052
+ * repository nothing is kept to merge from.
1053
+ *
1054
+ * ⛔ Until 2026-10-18 a push sent uncommitted edits on top of HEAD and recorded HEAD. Measured
1055
+ * against a real backend: an edit pushed that way and undone before the next pull came back
1056
+ * with it — exit 0, no conflict, reported as the backend's change. Every such push also had
1057
+ * the next pull commit those edits as the backend's, and conflict with one changed again.
1058
+ */
1059
+ export function uncommittedContent(siteDir, { verb = 'push' } = {}) {
1060
+ const repo = repoOf(siteDir)
1061
+ if (!repo) return null
1062
+ const paths = uncommittedPaths(repo, sentRoots(siteDir))
1063
+ if (!paths?.length) return null
1064
+ const done = verb === 'publish' ? 'published' : 'pushed'
1065
+ // A merge waiting for its commit is finished with one, never by committing a part of it.
1066
+ if (mergeInProgress(repo)) {
1067
+ return {
1068
+ paths,
1069
+ merge: true,
1070
+ headline: `Not ${done} — a merge is in progress here.`,
1071
+ lines: [`Finish it (\`git commit\`), then ${verb} — or abandon it (\`git merge --abort\`) and pull again.`]
1072
+ }
1073
+ }
1074
+ return {
1075
+ paths,
1076
+ headline: `Not ${done} — ${paths.length} file(s) of the site are not committed:`,
1077
+ lines: [
1078
+ ...paths.slice(0, 12).map((p) => ` ${p}`),
1079
+ ...(paths.length > 12 ? [` … and ${paths.length - 12} more`] : []),
1080
+ `In a repository a ${verb} sends what you have committed, so a later pull can tell your changes from the backend's.`,
1081
+ `Commit them, then ${verb} again — or re-run with --commit (and -m "…") to commit them first.`
1082
+ ]
1083
+ }
1084
+ }
1085
+
1086
+ /** Says what `commitWriteBacks` committed, or that it could not. */
1087
+ export function reportWriteBacks(made, { note }) {
1088
+ if (!made) return
1089
+ if (made.error) {
1090
+ note('! Could not commit what the push wrote into your files — commit them yourself:')
1091
+ for (const line of String(made.error).split('\n').slice(0, 4)) note(` ${line}`)
1092
+ return
1093
+ }
1094
+ note(`Committed what the backend gave the site — ${made.paths.length} file(s), ${made.commit.slice(0, 7)}.`)
1095
+ }
1096
+
1097
+ /**
1098
+ * `--commit`: the site's content committed as the files hold it — the person's own commit,
1099
+ * of what a push sends and nothing else they have staged.
1100
+ *
1101
+ * @returns {{ commit: string|null, paths?: string[] } | { error: string }}
1102
+ */
1103
+ export function commitSiteContent(siteDir, { message }) {
1104
+ const repo = repoOf(siteDir)
1105
+ if (!repo) return { commit: null }
1106
+ if (mergeInProgress(repo)) return { error: 'A merge is in progress here — finish it (`git commit`) first.' }
1107
+ try {
1108
+ return commitRoots(repo, sentRoots(siteDir), { message }) || { commit: null }
1109
+ } catch (err) {
1110
+ return { error: gitError(err) }
1111
+ }
1112
+ }
1113
+
1114
+ /**
1115
+ * ⭐ A PUSH COMMITS WHAT IT WROTE. It writes what the backend gave it into the site's files —
1116
+ * the ids it minted, into each new record's file and `sync.json` — and in a repository it
1117
+ * commits those writes as uniweb's own, as a pull's merge is. The content was committed
1118
+ * before it was sent, so what changed under the site since is the push's. That commit holds
1119
+ * what the backend now holds, so once every lane landed it is this copy's sync point.
1120
+ *
1121
+ * ⛔ Until 2026-10-18 they were left to the person to commit, and a project pushed once and
1122
+ * not committed after had no `sync.json` in the history its clones got.
1123
+ *
1124
+ * @returns {{ commit: string, paths: string[] } | { error: string } | null}
1125
+ */
1126
+ export function commitWriteBacks(siteDir, backend, { complete = false } = {}) {
1127
+ const repo = repoOf(siteDir)
1128
+ if (!repo?.head || mergeInProgress(repo)) return null
1129
+ let host = 'backend'
1130
+ try {
1131
+ host = new URL(backend).host
1132
+ } catch {
1133
+ /* the name only */
1134
+ }
1135
+ const identity = {
1136
+ GIT_AUTHOR_NAME: 'uniweb',
1137
+ GIT_AUTHOR_EMAIL: `sync@${host}`,
1138
+ GIT_COMMITTER_NAME: 'uniweb',
1139
+ GIT_COMMITTER_EMAIL: `sync@${host}`
1140
+ }
1141
+ try {
1142
+ const made = commitRoots(repo, [...sentRoots(siteDir), SYNC_STORE_FILE], {
1143
+ message: `Record what ${backend} gave the site`,
1144
+ identity
1145
+ })
1146
+ if (made && complete) writeSyncPoint(siteDir, backend, made.commit)
1147
+ return made
1148
+ } catch (err) {
1149
+ return { error: gitError(err) }
1150
+ }
1151
+ }
1152
+
1010
1153
  /**
1011
1154
  * Per-unit content hashes of the last synced state, kept in BOTH representations —
1012
1155
  * the bases for the file-level attribution shown after a staleness refusal
@@ -1976,8 +2119,9 @@ const TEMPLATE_SAID = {
1976
2119
  * @param {object} params.pkg - the `emitSyncPackages` result
1977
2120
  * ({ siteContent, collections, siteContentUuid, hashes })
1978
2121
  * @param {{info,note,error,dim?:Function}} params.report - injected logging
1979
- * @returns {Promise<{ exitCode: number, boundSiteUuid?: string, finalizedTotal: number, wrote: string[] }>}
2122
+ * @returns {Promise<{ exitCode: number, boundSiteUuid?: string, finalizedTotal: number, wrote: string[], complete?: true }>}
1980
2123
  * exitCode 1 on any lane failure (already reported, cache NOT persisted); 0 on success.
2124
+ * `complete` once every lane with something to send has landed.
1981
2125
  */
1982
2126
  export async function pushSyncPackages({
1983
2127
  client,
@@ -1995,8 +2139,10 @@ export async function pushSyncPackages({
1995
2139
  let draftsNotKept = []
1996
2140
  // ⭐ THE COMMIT THIS PUSH IS MADE FROM — sent with each lane, so the backend can name it on
1997
2141
  // 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.
2142
+ // holds descends from it (`utils/sync-point.js`). In a repository it is exactly what is
2143
+ // sent: a push sends only committed content (`uncommittedContent`). ⛔ Until 2026-10-18
2144
+ // uncommitted edits rode on top of it, and this said it "stays a safe base for them" — but
2145
+ // one undone before the next pull read as no change against it, and the pull put it back.
2000
2146
  const repo = repoOf(siteDir)
2001
2147
  const source = repo?.head || null
2002
2148
 
@@ -2314,10 +2460,14 @@ export async function pushSyncPackages({
2314
2460
  }
2315
2461
  // Both grains land together, at every point the old code banked the entity one — and
2316
2462
  // with them the commit they describe.
2317
- const mergeHarvested = () => {
2463
+ // ⛔ `point: false` where a lane was refused after another landed: the commit then describes
2464
+ // one lane and not the other, and as the base it would read what the refused lane lacks as
2465
+ // the backend's removal — measured, a pull after a push whose records were refused reverted
2466
+ // the record edits, exit 0. The versions of the lane that landed are still banked.
2467
+ const mergeHarvested = ({ point = true } = {}) => {
2318
2468
  mergeBaseVersions(siteDir, client.origin, newVersions)
2319
2469
  if (heldItemVersions) writeItemBaseVersions(siteDir, client.origin, heldItemVersions)
2320
- if (source && (Object.keys(newVersions).length || heldItemVersions)) writeSyncPoint(siteDir, client.origin, source)
2470
+ if (point && source && (Object.keys(newVersions).length || heldItemVersions)) writeSyncPoint(siteDir, client.origin, source)
2321
2471
  }
2322
2472
  // ⛔ The site-content lane banks only the tokens for content this copy holds — the
2323
2473
  // document we sent against the one the backend stored (`heldTokens`, and the two
@@ -2325,6 +2475,25 @@ export async function pushSyncPackages({
2325
2475
  // collected here, to re-base attribution and to say so.
2326
2476
  const sentSiteDoc = siteContent ? entityDocFromUwx(siteContent.buffer) : null
2327
2477
  const notHeld = { kept: [], foreign: [] }
2478
+ // ⭐ WHAT THE BACKEND REMOVED, beside the file it came from. A backend that cleans each
2479
+ // rich-text write to the vocabulary answers what it took out of each item
2480
+ // (`finalized[].cleaned`). The push never writes the returned document over the file, so the
2481
+ // file still holds it, and only this says so — on every push that sends it again.
2482
+ let toldRemoved = false
2483
+ const reportRemovals = (removals) => {
2484
+ for (const r of removals) note(`! ${r.where}${r.locale ? ` (${r.locale})` : ''}: ${r.says}`)
2485
+ if (removals.length && !toldRemoved) {
2486
+ note('Your files still hold what the backend removed; a pull would write them without it.')
2487
+ toldRemoved = true
2488
+ }
2489
+ }
2490
+ const reportSiteContentRemovals = (finalized) =>
2491
+ reportRemovals(
2492
+ siteContentRemovals({ finalized, sent: sentSiteDoc }).map((r) => ({
2493
+ ...r,
2494
+ where: r.unit ? unitFilesOf(siteDir, r.unit)[0] || r.unit : 'the site'
2495
+ }))
2496
+ )
2328
2497
  const harvestSiteContent = (finalized) => {
2329
2498
  for (const f of finalized || []) {
2330
2499
  const held = heldTokens({
@@ -2438,6 +2607,7 @@ export async function pushSyncPackages({
2438
2607
  return { exitCode: 1, finalizedTotal, wrote }
2439
2608
  }
2440
2609
  harvestSiteContent(finalized)
2610
+ reportSiteContentRemovals(finalized)
2441
2611
  siteFinalizedDoc = finalized[0]?.document || null
2442
2612
  finalizedTotal += finalized.length
2443
2613
  settleSiteContent()
@@ -2472,6 +2642,7 @@ export async function pushSyncPackages({
2472
2642
  wrote.push(`recorded its owner (${createdOrg}) in sync.json`)
2473
2643
  const createdFinalized = extractFinalized(payload)
2474
2644
  harvestSiteContent(createdFinalized)
2645
+ reportSiteContentRemovals(createdFinalized)
2475
2646
  siteFinalizedDoc = createdFinalized?.[0]?.document || null
2476
2647
  finalizedTotal += createdFinalized?.length ?? 1
2477
2648
  settleSiteContent()
@@ -2496,10 +2667,17 @@ export async function pushSyncPackages({
2496
2667
  { boundUuid: boundSiteUuid }
2497
2668
  )
2498
2669
  if (!finalized) {
2499
- mergeHarvested()
2670
+ mergeHarvested({ point: false })
2500
2671
  return { exitCode: 1, finalizedTotal, wrote }
2501
2672
  }
2502
2673
  harvest(finalized)
2674
+ reportRemovals(
2675
+ recordRemovals({ finalized, sent: submittedDocsFromUwx(records.buffer) }).map((r) => {
2676
+ const entry = records.index?.[r.index]
2677
+ const file = entry?.sourceFile
2678
+ return { ...r, where: file ? (isAbsolute(file) ? relative(siteDir, file) : file) : entry?.id || 'a record' }
2679
+ })
2680
+ )
2503
2681
  const bf = backfillEntityUuids({ index: records.index, finalized })
2504
2682
  // ⭐ Record which uuid THIS backend minted for each record, keyed by the record's
2505
2683
  // own id. The file keeps that own id; the next push to this backend sends the
@@ -2597,7 +2775,7 @@ export async function pushSyncPackages({
2597
2775
  shown.join('\n ') +
2598
2776
  '\n The backend does not support `draft: true` yet. Once it does, push again with --all.'
2599
2777
  )
2600
- return { exitCode: 1, boundSiteUuid, finalizedTotal, wrote }
2778
+ return { exitCode: 1, boundSiteUuid, finalizedTotal, wrote, complete: true }
2601
2779
  }
2602
- return { exitCode: 0, boundSiteUuid, finalizedTotal, wrote }
2780
+ return { exitCode: 0, boundSiteUuid, finalizedTotal, wrote, complete: true }
2603
2781
  }
@@ -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,
@@ -121,6 +121,7 @@ import {
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,23 @@ 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 = newestBase(repo, [...known, content?.source, folderLane?.source], {
918
+ known,
919
+ // ⛔ Each lane the backend answered holds every base to what it names (`newestBase`).
920
+ lanes: [
921
+ ...(taken(content) ? [{ source: content.source, roots: laneRoots.content }] : []),
922
+ ...(taken(folderLane) ? [{ source: folderLane.source, roots: laneRoots.records }] : [])
923
+ ]
924
+ })
915
925
  onto = force || !base ? repo.head : base
916
926
  try {
917
927
  standIn = openStandIn(repo, { roots, rev: onto })
@@ -1136,6 +1146,9 @@ export async function pull(args = [], deps = {}) {
1136
1146
  siteUuid: siteContentUuid,
1137
1147
  always: force || !base
1138
1148
  })
1149
+ // The commit that holds the backend's content as the files would — the base itself when
1150
+ // they hold the same.
1151
+ merge.theirs = made.commit || base
1139
1152
  if (!made.commit) {
1140
1153
  // The backend holds what the base holds: nothing to merge.
1141
1154
  writeSyncPoint(siteDir, client.origin, base)
@@ -1249,7 +1262,16 @@ export async function pull(args = [], deps = {}) {
1249
1262
  if (merge.changed.length > 12) note(` … and ${merge.changed.length - 12} more`)
1250
1263
  if (force) note('What they held is in the commit before this one (`git diff HEAD^1 HEAD`), unless it was never committed.')
1251
1264
  } else if (!merge.conflicted.length) {
1252
- note(`Your files already hold what ${client.origin} has.`)
1265
+ // ⛔ Nothing changed in the files is not the files holding what the backend has: what
1266
+ // this copy changed since the base, the backend lacks. Until 2026-10-18 it said they
1267
+ // held it either way — measured over a revert the backend did not have yet.
1268
+ const ahead = merge.theirs ? filesDifferFrom(repo, merge.theirs, roots) : []
1269
+ if (!ahead.length) note(`Your files already hold what ${client.origin} has.`)
1270
+ else {
1271
+ note(`Nothing new from ${client.origin}. ${ahead.length} file(s) here hold changes it does not have yet — \`uniweb push\` sends them:`)
1272
+ for (const p of ahead.slice(0, 12)) note(` ${p}`)
1273
+ if (ahead.length > 12) note(` … and ${ahead.length - 12} more`)
1274
+ }
1253
1275
  }
1254
1276
  if (merge.conflicted.length) {
1255
1277
  error(`${merge.conflicted.length} file(s) have conflicts to resolve:`)
@@ -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,30 +1,30 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-10-18T19:11:13.863Z",
3
+ "generatedAt": "2026-10-19T02:42:59.650Z",
4
4
  "packages": {
5
5
  "@uniweb/api": {
6
- "version": "1.5.1"
6
+ "version": "1.5.2"
7
7
  },
8
8
  "@uniweb/build": {
9
- "version": "1.4.0"
9
+ "version": "1.5.1"
10
10
  },
11
11
  "@uniweb/content-reader": {
12
- "version": "1.5.0"
12
+ "version": "1.5.2"
13
13
  },
14
14
  "@uniweb/content-writer": {
15
15
  "version": "1.3.0"
16
16
  },
17
17
  "@uniweb/core": {
18
- "version": "1.2.5"
18
+ "version": "1.3.0"
19
19
  },
20
20
  "@uniweb/frame-bridge": {
21
21
  "version": "1.0.2"
22
22
  },
23
23
  "@uniweb/icons": {
24
- "version": "1.0.3"
24
+ "version": "1.0.4"
25
25
  },
26
26
  "@uniweb/kit": {
27
- "version": "1.3.2"
27
+ "version": "1.4.0"
28
28
  },
29
29
  "@uniweb/loom": {
30
30
  "version": "1.1.0"
@@ -33,10 +33,10 @@
33
33
  "version": "1.1.2"
34
34
  },
35
35
  "@uniweb/projections": {
36
- "version": "1.1.3"
36
+ "version": "1.1.4"
37
37
  },
38
38
  "@uniweb/runtime": {
39
- "version": "1.1.7"
39
+ "version": "1.1.8"
40
40
  },
41
41
  "@uniweb/scene": {
42
42
  "version": "1.0.1"
@@ -48,7 +48,7 @@
48
48
  "version": "1.1.0"
49
49
  },
50
50
  "@uniweb/semantic-parser": {
51
- "version": "1.11.0"
51
+ "version": "1.12.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.8"
63
+ "version": "1.1.9"
64
64
  }
65
65
  },
66
66
  "templates": {
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,
@@ -280,8 +280,24 @@ export function madeOnto(repo, sha, { origin, siteUuid }) {
280
280
  * reads the old text, so the change-back looks like no change at all. ⛔ Until
281
281
  * 2026-10-17 this said an older base "costs precision at worst".
282
282
  */
283
- export function newestBase(repo, candidates) {
284
- const shas = [...new Set(candidates.filter(Boolean))].filter((s) => isCommit(repo, s) && isAncestor(repo, s))
283
+ export function newestBase(repo, candidates, { lanes = null, known = [] } = {}) {
284
+ let shas = [...new Set(candidates.filter(Boolean))].filter((s) => isCommit(repo, s) && isAncestor(repo, s))
285
+ // ⛔ ONE MERGE TAKES ONE BASE FOR EVERY LANE, and a lane's own source says what that lane
286
+ // holds. A commit newer than it whose content differs there reads what the lane lacks — a
287
+ // push of it the backend refused while it took the other lane — as the backend's removal,
288
+ // and the merge drops it. A lane that names no source is held to the newest of the bases
289
+ // this copy knows of itself; with none, only those can be the base.
290
+ if (lanes?.length) {
291
+ const own = new Set(known.filter(Boolean))
292
+ const fallback = newestBase(repo, known)
293
+ shas = shas.filter((s) =>
294
+ lanes.every(({ source, roots }) => {
295
+ const ref = source && isCommit(repo, source) && isAncestor(repo, source) ? source : fallback
296
+ if (!ref) return own.has(s)
297
+ return s === ref || isAncestor(repo, s, ref) || !differsIn(repo, ref, s, roots)
298
+ })
299
+ )
300
+ }
285
301
  if (shas.length <= 1) return shas[0] || null
286
302
  const newest = shas.find((s) => shas.every((o) => o === s || isAncestor(repo, o, s)))
287
303
  if (newest) return newest
@@ -292,6 +308,16 @@ export function newestBase(repo, candidates) {
292
308
  }
293
309
  }
294
310
 
311
+ /** Does the content under `roots` differ between two commits? Anything but "no" counts as yes. */
312
+ function differsIn(repo, a, b, roots) {
313
+ try {
314
+ git(repo.top, ['diff', '--quiet', a, b, '--', ...roots.map((r) => `${repo.prefix}${r}`)])
315
+ return false
316
+ } catch {
317
+ return true
318
+ }
319
+ }
320
+
295
321
  // ── the stand-in ────────────────────────────────────────────────────────────
296
322
 
297
323
  /**
@@ -652,14 +678,79 @@ export function snapshotFiles(repo) {
652
678
  * under the roots? (What the repository ignores is no change.)
653
679
  */
654
680
  export function contentCommitted(repo, roots) {
681
+ return uncommittedPaths(repo, roots)?.length === 0
682
+ }
683
+
684
+ /**
685
+ * The files under the roots that HEAD does not hold as they are — staged, changed, deleted
686
+ * or untracked, and both sides of a rename — relative to the site; null when git cannot
687
+ * say. What the repository ignores is no change.
688
+ */
689
+ export function uncommittedPaths(repo, roots) {
690
+ let parts
655
691
  try {
656
- const full = roots.map((r) => `${repo.prefix}${r}`)
657
- return git(repo.top, ['status', '--porcelain', '-z', '--untracked-files=all', '--', ...full]).length === 0
692
+ parts = zlist(git(repo.top, ['status', '--porcelain', '-z', '--untracked-files=all', '--', ...roots.map((r) => `${repo.prefix}${r}`)]))
658
693
  } catch {
659
- return false
694
+ return null
695
+ }
696
+ const out = []
697
+ for (let i = 0; i < parts.length; i++) {
698
+ out.push(parts[i].slice(3))
699
+ if (/[RC]/.test(parts[i].slice(0, 2))) out.push(parts[++i]) // a rename's source follows it
660
700
  }
701
+ return [...new Set(out.map((p) => siteRelative(repo, p)))]
661
702
  }
662
703
 
704
+ /**
705
+ * The files under the roots that differ from what `rev` holds there — changed, deleted, or
706
+ * new and not ignored — relative to the site.
707
+ */
708
+ export function filesDifferFrom(repo, rev, roots) {
709
+ const full = roots.map((r) => `${repo.prefix}${r}`)
710
+ try {
711
+ const changed = zlist(git(repo.top, ['diff', '--name-only', '-z', '--no-renames', rev, '--', ...full]))
712
+ const added = zlist(git(repo.top, ['ls-files', '--others', '--exclude-standard', '-z', '--', ...full]))
713
+ return [...new Set([...changed, ...added].map((p) => siteRelative(repo, p)))]
714
+ } catch {
715
+ return []
716
+ }
717
+ }
718
+
719
+ /**
720
+ * Commit what the files hold under the roots — added, changed, deleted or renamed — and
721
+ * nothing else that is staged. With `identity` it is uniweb's commit, made as a pull's merge
722
+ * is: no hooks, unsigned. Without it, the person's own, as their `git commit` makes it.
723
+ *
724
+ * ⛔ Staged by ROOT, committed by NAME: `git add` refuses a path that is on neither the disk
725
+ * nor the index — a rename's source — and the commit takes exactly what was staged.
726
+ *
727
+ * @returns {{ commit: string, paths: string[] } | null} null when there was nothing to commit
728
+ */
729
+ export function commitRoots(repo, roots, { message, identity = null }) {
730
+ const full = roots.map((r) => `${repo.prefix}${r}`)
731
+ const present = full.filter((p) => existsSync(join(repo.top, p)) || zlist(git(repo.top, ['ls-files', '-z', '--', p])).length)
732
+ let addable = present
733
+ if (present.length) {
734
+ try {
735
+ const ignored = zlist(git(repo.top, ['check-ignore', '-z', '--stdin'], { input: `${present.join('\0')}\0` }))
736
+ addable = present.filter((p) => !ignored.includes(p))
737
+ } catch (err) {
738
+ if (err?.status !== 1) throw err // 1: none of them is ignored
739
+ }
740
+ }
741
+ if (addable.length) git(repo.top, ['--literal-pathspecs', 'add', '-A', '--', ...addable])
742
+ const staged = zlist(git(repo.top, ['diff', '--cached', '--name-only', '-z', '--no-renames', '--', ...full]))
743
+ if (!staged.length) return null
744
+ git(
745
+ repo.top,
746
+ ['--literal-pathspecs', 'commit', '-q', ...(identity ? ['--no-verify', '--no-gpg-sign'] : []), '-m', message, '--pathspec-from-file=-', '--pathspec-file-nul'],
747
+ { input: `${staged.join('\0')}\0`, ...(identity ? { env: identity } : {}) }
748
+ )
749
+ return { commit: gitText(repo.top, ['rev-parse', 'HEAD']).trim(), paths: staged.map((p) => siteRelative(repo, p)) }
750
+ }
751
+
752
+ const siteRelative = (repo, p) => (repo.prefix && p.startsWith(repo.prefix) ? p.slice(repo.prefix.length) : p)
753
+
663
754
  // ── this copy's own sync point ──────────────────────────────────────────────
664
755
 
665
756
  const cachePath = (siteDir) => join(siteDir, '.uniweb', 'backend-cache.json')