uniweb 1.8.0 → 1.8.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.8.0",
3
+ "version": "1.8.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/content-writer": "~1.1.2",
44
45
  "@uniweb/core": "~1.2.2",
45
46
  "@uniweb/semantic-parser": "~1.8.1",
46
- "@uniweb/content-writer": "~1.1.2",
47
- "@uniweb/schemas": "~1.3.1"
47
+ "@uniweb/schemas": "~1.4.0"
48
48
  },
49
49
  "peerDependencies": {
50
- "@uniweb/semantic-parser": "~1.8.1",
51
- "@uniweb/build": "~1.3.9",
52
- "@uniweb/content-reader": "~1.4.0"
50
+ "@uniweb/build": "~1.3.10",
51
+ "@uniweb/content-reader": "~1.4.0",
52
+ "@uniweb/semantic-parser": "~1.8.1"
53
53
  },
54
54
  "peerDependenciesMeta": {
55
55
  "@uniweb/build": {
@@ -2850,7 +2850,7 @@ Foundations have their own free path too: `uniweb add ci --target foundation` pu
2850
2850
  - **Git is the reviewed, durable record** — you `pull` content back, read it with `git diff`, and commit.
2851
2851
  - **Authors never push or pull.** For them, content simply updates, whether the change came from another author or from a developer's CLI.
2852
2852
 
2853
- **Nobody's work is overwritten without asking.** `uniweb push` is refused if the site changed on the backend since your last pull — usually an author editing in the app — and reports what changed; nothing is written. Edits to different sections never collide. **In a git repository `uniweb pull` merges, like `git pull`:** it commits the backend's content (*"The site's content on …"*) and merges that commit into your branch, against the last state of the site your history shares with the backend. Your work, committed or not, and a teammate's arriving through git are kept; a file the backend never had is never deleted; changes to different parts of a file combine silently; and only a genuine overlap leaves conflict markers and a non-zero exit — so `uniweb pull && uniweb push` never ships markers. Settle a conflict as in any merge, then `git commit`. `uniweb refresh` runs `git pull` first, then the same pull. A push is also refused when your branch does not hold what this copy last took from the backend — after `git merge --abort`, or in a fresh clone that has not pulled — so pull, then push. Outside git, `uniweb pull` takes the backend's version over your files, asking first. `--force` is the deliberate overwrite (on `push` it replaces the backend's changes, on `pull` it discards yours); don't use it to get past a refusal. `uniweb sync` is `refresh` then `push`; neither publishes.
2853
+ **Nobody's work is overwritten without asking.** `uniweb push` is refused if the site changed on the backend since your last pull — usually an author editing in the app — and reports what changed; nothing is written. Edits to different sections never collide. **In a git repository `uniweb pull` merges, like `git pull`:** it commits the backend's content (*"The site's content on …"*) and merges that commit into your branch, against the last state of the site your history shares with the backend. Your work, committed or not, and a teammate's arriving through git are kept; a file the backend never had is never deleted; changes to different parts of a file combine silently; and only a genuine overlap leaves conflict markers and a non-zero exit — so `uniweb pull && uniweb push` never ships markers. Settle a conflict as in any merge, then `git commit`; where your uncommitted changes overlap what the pull brought, the markers are theirs — settle them and `git add` each file, then `git stash drop` (git keeps them in the stash too). A push or publish is refused while a file under the site is in conflict. `uniweb refresh` runs `git pull` first, then the same pull. A push is also refused when your branch does not hold what this copy last took from the backend — after `git merge --abort`, or in a fresh clone that has not pulled — so pull, then push. Outside git — which includes a folder the repository around it ignores, such as a scratch directory — `uniweb pull` takes the backend's version over your files, asking first. `--force` is the deliberate overwrite (on `push` it replaces the backend's changes, on `pull` it discards yours); don't use it to get past a refusal. `uniweb sync` is `refresh` then `push`; neither publishes.
2854
2854
 
2855
2855
  **`sync.json` says which site this is, on each backend.** The first push to a backend records what that backend assigned — the site's id and owner, the ids of its records and uploaded files — in `sync.json` beside `site.yml`. Commit it; never edit it. **To make a new site from a copy of a project, run `uniweb forget --all` in the copy before its first push** — the copy carries the original's `sync.json`, so otherwise its push updates the original's site. When two projects in one workspace hold the same site, a push or publish from either is refused until that is done. `uniweb forget --server <url>` removes just one backend's records, such as a scratch server's. A record file's `$uuid` is its own id and stays in both cases. **`push`, `pull` and `publish` go to the backend you are logged in to** — the last `uniweb login --server <url>` — and never to one you are not logged in to. A bare `uniweb login` logs in to https://uniweb.app; for any other backend, name it — logging in is how you switch, and the backend commands take no `--server` of their own. When the project has no site on that backend but has one elsewhere, a push says so before creating a new one.
2856
2856
 
@@ -14,7 +14,16 @@
14
14
  import { writeFileSync, readFileSync, mkdirSync, existsSync, unlinkSync } from 'node:fs'
15
15
  import { join, dirname, relative, isAbsolute, basename } from 'node:path'
16
16
  import yaml from 'js-yaml'
17
- import { repoOf, readSyncPoint, writeSyncPoint, isAncestor } from '../utils/sync-point.js'
17
+ import {
18
+ repoOf,
19
+ readSyncPoint,
20
+ writeSyncPoint,
21
+ isAncestor,
22
+ contentCommitted,
23
+ unmergedPaths,
24
+ mergeInProgress
25
+ } from '../utils/sync-point.js'
26
+ import { siteContentRoots } from '../utils/git.js'
18
27
  import { humanBytes } from '../utils/bytes.js'
19
28
  import { isAuthoredPreview } from '../utils/preview.js'
20
29
  import {
@@ -898,6 +907,31 @@ export function mergeBaseVersions(siteDir, backend, versions) {
898
907
  })
899
908
  }
900
909
 
910
+ /**
911
+ * ⛔ A PUSH NEVER SENDS A FILE IN CONFLICT. Where git marks a file under the site unmerged — a
912
+ * merge stopped on it, or uncommitted work a merge could not put back — the file holds markers,
913
+ * and a push hands them to every author editing the site, then to its visitors once published.
914
+ * Refused whatever the flags: `--force` overwrites the backend's changes, it never sends
915
+ * markers. Returns the refusal, or null.
916
+ *
917
+ * ⛔ Until 2026-10-17 nothing checked it: a pull whose stash could not be put back exited 0,
918
+ * and the push after it sent the markers [flows, measured against a real backend, 1.8.0].
919
+ */
920
+ export function unsettledFiles(siteDir) {
921
+ const repo = repoOf(siteDir)
922
+ if (!repo) return null
923
+ const paths = unmergedPaths(repo).filter((p) => !repo.prefix || p.startsWith(repo.prefix))
924
+ if (!paths.length) return null
925
+ return {
926
+ headline: `Not pushed — ${paths.length} file(s) here are in conflict:`,
927
+ lines: [
928
+ ...paths.slice(0, 12).map((p) => ` ${p}`),
929
+ ...(paths.length > 12 ? [` … and ${paths.length - 12} more`] : []),
930
+ 'Settle each one and `git add` it, then push.'
931
+ ]
932
+ }
933
+ }
934
+
901
935
  /**
902
936
  * Why a gated push must not go — or null when it may.
903
937
  *
@@ -924,6 +958,13 @@ export function untakenBase(siteDir, backend) {
924
958
  if (repo?.head) {
925
959
  const point = readSyncPoint(siteDir, backend)
926
960
  if (held && point && isAncestor(repo, point)) return null
961
+ // A pull's merge waiting for its commit is not one abandoned: say which.
962
+ if (held && point && mergeInProgress(repo)) {
963
+ return {
964
+ headline: 'Not pushed — a merge is in progress here.',
965
+ lines: ['Finish it (`git commit`), then push — or abandon it (`git merge --abort`) and pull again.']
966
+ }
967
+ }
927
968
  return {
928
969
  headline: "Not pushed — your branch does not hold the backend content this copy last took.",
929
970
  lines: [
@@ -946,6 +987,26 @@ export function untakenBase(siteDir, backend) {
946
987
  }
947
988
  }
948
989
 
990
+ /**
991
+ * A push that finds nothing to send has learned that the backend holds what this commit
992
+ * sends — so the commit is a sync point, as the one a push is made from is
993
+ * (`pushSyncPackages`). Kept only where HEAD holds the site's content as the files do: with
994
+ * a change under the roots not committed, HEAD is not what was compared, and a base the
995
+ * backend's content does not descend from would read a committed change the backend lacks
996
+ * as its removal. Returns the commit kept, or null.
997
+ *
998
+ * ⛔ Until 2026-10-17 it kept nothing, so a paragraph re-wrapped and committed — which a push
999
+ * calls "nothing to push", a plain newline being a space — was merged on the next pull from
1000
+ * the commit before it, and conflicted with an author's words typed into it since.
1001
+ */
1002
+ export function holdUnchangedPush(siteDir, backend) {
1003
+ const repo = repoOf(siteDir)
1004
+ if (!repo?.head || !readBackendState(siteDir, backend).site?.uuid) return null
1005
+ if (!contentCommitted(repo, [...siteContentRoots(siteDir), 'public'])) return null
1006
+ writeSyncPoint(siteDir, backend, repo.head)
1007
+ return repo.head
1008
+ }
1009
+
949
1010
  /**
950
1011
  * Per-unit content hashes of the last synced state, kept in BOTH representations —
951
1012
  * the bases for the file-level attribution shown after a staleness refusal
@@ -99,6 +99,9 @@ function slugify(s) {
99
99
  .replace(/^-+|-+$/g, '')
100
100
  }
101
101
 
102
+ // The data schema a site's own document names (`$schema`, as the push writes it).
103
+ const SITE_MODEL = '@uniweb/site-content'
104
+
102
105
  // Tolerant single-entity document extraction (mirrors pull.js; duplicated rather
103
106
  // than imported because pull.js statically imports @uniweb/build).
104
107
  export function extractDocument(payload) {
@@ -173,10 +176,11 @@ function seedSyncJson(siteDir, origin, uuid) {
173
176
  /**
174
177
  * Start the new project's history at a sync point — the site as the backend has it.
175
178
  *
176
- * ⭐ Like `uniweb create`, a clone puts its project under git unless it is already inside a
177
- * repository; and its first commit holds the site as the backend has it, marked so a pull
178
- * merges from it (`utils/sync-point.js`). Inside a repository the clone's pull already left
179
- * one, in the commit that took the site.
179
+ * ⭐ Like `uniweb create`, a clone puts its project under git unless a repository already
180
+ * holds it — one it is inside, in a folder that repository does not ignore (`repoOf`); and
181
+ * its first commit holds the site as the backend has it, marked so a pull merges from it
182
+ * (`utils/sync-point.js`). Inside a repository the clone's pull already left one, in the
183
+ * commit that took the site.
180
184
  */
181
185
  function startHistory({ projectDir, siteDir, origin, siteUuid }) {
182
186
  const git = (args, input) =>
@@ -307,6 +311,15 @@ export async function clone(args = [], deps = {}) {
307
311
  return { exitCode: 1 }
308
312
  }
309
313
  const { foundationRef, name: siteDisplayName } = extractCloneSeeds(document)
314
+ // ⛔ An id that names another entity — a record's, say — comes back as that entity. Say
315
+ // so, rather than ask for a foundation and a name it could never have: only where it
316
+ // names another data schema AND carries neither, so a site is never turned away.
317
+ const named = typeof document.$schema === 'string' ? document.$schema : null
318
+ if (named && named !== SITE_MODEL && !foundationRef && !siteDisplayName) {
319
+ error(`${siteUuid} is not a site on ${client.origin} — it is a ${named} entity.`)
320
+ note('`uniweb clone` takes a site\'s id: `uniweb site list` shows each site with its id.')
321
+ return { exitCode: 1 }
322
+ }
310
323
  if (!foundationRef) {
311
324
  note(
312
325
  '! The pulled site declares no foundation ref — set `foundation:` in site.yml after clone.'
@@ -104,6 +104,7 @@ import {
104
104
  refuseUnsendableRecords,
105
105
  clearRemoteSyncStateIfUnbound,
106
106
  untakenBase,
107
+ unsettledFiles,
107
108
  dropSiteBoundValues,
108
109
  pushSyncPackages
109
110
  } from '../backend/site-sync.js'
@@ -321,6 +322,27 @@ export async function publish(args = []) {
321
322
  }
322
323
  }
323
324
 
325
+ // ⛔ The gated push publish rides claims a base only while the files hold it
326
+ // (`untakenBase`) — said before anything reaches the backend, the foundation's release
327
+ // included. `--force` drops the gate; a dry run does not preview it. ⛔ *Until 2026-10-17
328
+ // it ran after the bring-along and the build, so a refused publish could release a local
329
+ // foundation first.*
330
+ // ⛔ Never a file in conflict — whatever the flags (`unsettledFiles`).
331
+ const unsettled = unsettledFiles(siteDir)
332
+ if (unsettled) {
333
+ say.err(unsettled.headline)
334
+ for (const line of unsettled.lines) say.dim(line)
335
+ return { exitCode: 1 }
336
+ }
337
+ if (!dryRun && !args.includes('--force')) {
338
+ const untaken = untakenBase(siteDir, client.origin)
339
+ if (untaken) {
340
+ say.err(untaken.headline)
341
+ for (const line of untaken.lines) say.dim(line)
342
+ return { exitCode: 1 }
343
+ }
344
+ }
345
+
324
346
  // ⭐ THE WORKSPACE THIS PUBLISH WORKS IN — the one chosen with the login, unless this
325
347
  // command names another (`workspace.js`). Resolved up front: `ensureSiteExists` below
326
348
  // creates the site in it, and a site the backend keeps elsewhere is refused.
@@ -623,17 +645,6 @@ export async function publish(args = []) {
623
645
  `Dropped the previous site's ${droppedValues.join(' and ')} from site.yml.`
624
646
  )
625
647
  }
626
- // ⛔ The gated push publish rides claims a base only while the files hold it — said here,
627
- // before anything is uploaded or created (`untakenBase`). `--force` drops the gate.
628
- if (!args.includes('--force')) {
629
- const untaken = untakenBase(siteDir, client.origin)
630
- if (untaken) {
631
- say.err(untaken.headline)
632
- for (const line of untaken.lines) say.dim(line)
633
- return { exitCode: 1 }
634
- }
635
- }
636
-
637
648
  // 3b. Make sure the SITE EXISTS before a single byte is uploaded.
638
649
  //
639
650
  // Uploaded bytes are metered against an owning entity and reclaimed by
@@ -110,13 +110,16 @@ import { siteContentRoots } from '../utils/git.js'
110
110
  import {
111
111
  repoOf,
112
112
  mergeInProgress,
113
+ unmergedPaths,
113
114
  isAncestor,
114
115
  historySyncPoint,
116
+ madeOnto,
115
117
  newestBase,
116
118
  openStandIn,
117
119
  mergeSyncPoint,
118
120
  takeSyncPoint,
119
- changesBetween,
121
+ snapshotFiles,
122
+ changedInFiles,
120
123
  readSyncPoint,
121
124
  writeSyncPoint
122
125
  } from '../utils/sync-point.js'
@@ -574,6 +577,13 @@ export async function pull(args = [], deps = {}) {
574
577
  note('Finish it (`git commit`) or abandon it (`git merge --abort`), then pull.')
575
578
  return { exitCode: 1 }
576
579
  }
580
+ const unsettled = unmergedPaths(repo)
581
+ if (unsettled.length) {
582
+ error(`${unsettled.length} file(s) here are in conflict:`)
583
+ for (const p of unsettled.slice(0, 12)) note(` ${p}`)
584
+ note('Settle them first — `git status` says how — then pull.')
585
+ return { exitCode: 1 }
586
+ }
577
587
  } else if (!repo && !dryRun && !force) {
578
588
  const blocked = await checkWorkingTree(siteDir, args)
579
589
  if (blocked) return blocked
@@ -887,6 +897,8 @@ export async function pull(args = [], deps = {}) {
887
897
  base = newestBase(repo, [
888
898
  historySyncPoint(repo, { origin: client.origin, siteUuid: siteContentUuid }),
889
899
  held,
900
+ // What a sync point the branch no longer holds — a merge abandoned — was made onto.
901
+ ...madeOnto(repo, held, { origin: client.origin, siteUuid: siteContentUuid }),
890
902
  content?.source,
891
903
  folderLane?.source
892
904
  ])
@@ -1114,13 +1126,12 @@ export async function pull(args = [], deps = {}) {
1114
1126
  siteUuid: siteContentUuid,
1115
1127
  always: force || !base
1116
1128
  })
1117
- standIn.close()
1118
- standIn = null
1119
1129
  if (!made.commit) {
1120
1130
  // The backend holds what the base holds: nothing to merge.
1121
1131
  writeSyncPoint(siteDir, client.origin, base)
1122
1132
  } else {
1123
- merge.changed = changesBetween(repo, force || !base ? repo.head : base, made.commit)
1133
+ // The files as they are, to tell what the pull changes in them from what they held.
1134
+ const files = snapshotFiles(repo)
1124
1135
  const result = force
1125
1136
  ? takeSyncPoint(repo, made.commit, { roots, message: takenFrom(client.origin) })
1126
1137
  : mergeSyncPoint(repo, made.commit, { message: mergedFrom(client.origin) })
@@ -1138,15 +1149,40 @@ export async function pull(args = [], deps = {}) {
1138
1149
  } else if (result.reason === 'merge-in-progress') {
1139
1150
  error('A merge is in progress here.')
1140
1151
  note('Finish it (`git commit`) or abandon it (`git merge --abort`), then pull.')
1152
+ } else if (result.reason === 'unmerged') {
1153
+ error(`${result.paths.length} file(s) here are in conflict:`)
1154
+ for (const p of result.paths.slice(0, 12)) note(` ${p}`)
1155
+ note('Settle them first — `git status` says how — then pull.')
1141
1156
  } else {
1142
1157
  error("Could not merge the backend's content:")
1143
1158
  for (const line of String(result.detail).split('\n').slice(0, 8)) note(` ${line}`)
1144
1159
  }
1145
1160
  } else {
1146
1161
  merge.conflicted = result.conflicted || []
1162
+ merge.stashKept = Boolean(result.stashKept)
1163
+ merge.noBase = !force && !base
1164
+ merge.changed = changedInFiles(repo, {
1165
+ from: repo.head,
1166
+ files,
1167
+ staged: merge.conflicted.length > 0 && !merge.stashKept,
1168
+ skip: merge.conflicted
1169
+ })
1147
1170
  writeSyncPoint(siteDir, client.origin, made.commit)
1148
1171
  }
1149
1172
  }
1173
+ // ⭐ What this pull wrote where the repository ignores it travels in no commit — media
1174
+ // in a folder kept out of git — so it is placed where the files lack it, once the
1175
+ // content is taken. Never over a file they have.
1176
+ if (!notTaken) {
1177
+ const placed = standIn.placeIgnored()
1178
+ if (placed.length) {
1179
+ note(`Placed ${placed.length} file(s) the repository ignores, which no commit carries:`)
1180
+ for (const p of placed.slice(0, 12)) note(` ${p}`)
1181
+ if (placed.length > 12) note(` … and ${placed.length - 12} more`)
1182
+ }
1183
+ }
1184
+ standIn.close()
1185
+ standIn = null
1150
1186
  }
1151
1187
  } finally {
1152
1188
  standIn?.close()
@@ -1190,19 +1226,36 @@ export async function pull(args = [], deps = {}) {
1190
1226
  if (notTaken || recordsNotPlaced) info(summary)
1191
1227
  else success(summary)
1192
1228
  if (repo && !notTaken) {
1193
- if (!projected || !merge.changed.length) {
1194
- note(`Your files already hold what ${client.origin} has.`)
1195
- } else {
1196
- const verb = force ? 'Took' : 'Merged'
1197
- info(`${verb} ${merge.changed.length} change(s) from ${client.origin}:`)
1229
+ // ⭐ What the pull changed in the files — not the backend's content against the base,
1230
+ // which also lists what the files already held (`changedInFiles`).
1231
+ if (projected && merge.changed.length) {
1232
+ info(
1233
+ force
1234
+ ? `Took ${client.origin}'s version of ${merge.changed.length} file(s):`
1235
+ : `Merged ${merge.changed.length} change(s) from ${client.origin}:`
1236
+ )
1198
1237
  const label = { A: 'added', M: 'changed', D: 'removed', R: 'renamed' }
1199
1238
  for (const [s, p] of merge.changed.slice(0, 12)) note(` ${label[s] || s} ${p}`)
1200
1239
  if (merge.changed.length > 12) note(` … and ${merge.changed.length - 12} more`)
1240
+ if (force) note('What they held is in the commit before this one (`git diff HEAD^1 HEAD`), unless it was never committed.')
1241
+ } else if (!merge.conflicted.length) {
1242
+ note(`Your files already hold what ${client.origin} has.`)
1201
1243
  }
1202
1244
  if (merge.conflicted.length) {
1203
1245
  error(`${merge.conflicted.length} file(s) have conflicts to resolve:`)
1204
1246
  for (const f of merge.conflicted) note(` ${f}`)
1205
- note('Settle them, then `git commit` to finish the merge — your uncommitted changes come back with it.')
1247
+ if (merge.stashKept) {
1248
+ // The merge is made; what clashed is the uncommitted work put back over it.
1249
+ note('Your uncommitted changes there overlap what the pull brought.')
1250
+ note('Settle the markers and `git add` each file, then `git stash drop` — git kept those changes in the stash as well.')
1251
+ } else {
1252
+ if (merge.noBase) {
1253
+ note(`No commit in this history is one ${client.origin}'s content came from, so nothing tells`)
1254
+ note("your changes from the backend's: each file where they differ is a conflict, yours against the backend's.")
1255
+ note('Once this merge is committed, later pulls merge from it.')
1256
+ }
1257
+ note('Settle them and `git add` each, then `git commit` to finish the merge — your uncommitted changes come back with it.')
1258
+ }
1206
1259
  }
1207
1260
  }
1208
1261
  // Unresolved conflicts are a non-zero exit, the way a conflicted `git merge` is.
@@ -96,6 +96,8 @@ import {
96
96
  clearRemoteSyncStateIfUnbound,
97
97
  dropSiteBoundValues,
98
98
  untakenBase,
99
+ unsettledFiles,
100
+ holdUnchangedPush,
99
101
  pushSyncPackages
100
102
  } from '../backend/site-sync.js'
101
103
  import { resolveWorkspace, describeWorkspace, SOURCE_LABEL } from '../backend/workspace.js'
@@ -210,6 +212,26 @@ export async function push(args = [], deps = {}) {
210
212
  }
211
213
  }
212
214
 
215
+ // ⛔ A GATED PUSH CLAIMS A BASE ONLY WHILE THE FILES HOLD IT (`untakenBase`) — said before
216
+ // anything reaches the backend, the foundation's release included. A dry run previews it;
217
+ // `-o` reaches no backend, and `--force` drops the gate. ⛔ *Until 2026-10-17 it ran after
218
+ // the bring-along, so a refused push could release a local foundation first.*
219
+ // ⛔ Never a file in conflict — whatever the flags (`unsettledFiles`).
220
+ const unsettled = unsettledFiles(siteDir)
221
+ if (unsettled) {
222
+ error(unsettled.headline)
223
+ for (const line of unsettled.lines) note(line)
224
+ return { exitCode: 1 }
225
+ }
226
+ if (!output && !force) {
227
+ const untaken = untakenBase(siteDir, client.origin)
228
+ if (untaken) {
229
+ error(untaken.headline)
230
+ for (const line of untaken.lines) note(line)
231
+ return { exitCode: 1 }
232
+ }
233
+ }
234
+
213
235
  // ⚠️ Logged in to a backend where this project has no site, while it has one
214
236
  // elsewhere: following the login CREATES a second site. Say so before the owner
215
237
  // question, which otherwise arrives with no reason attached. Not for `-o` (nothing is
@@ -337,17 +359,6 @@ export async function push(args = [], deps = {}) {
337
359
  note(`Dropped the previous site's ${stale.join(' and ')} from site.yml.`)
338
360
  }
339
361
  }
340
- // ⛔ A GATED PUSH CLAIMS A BASE ONLY WHILE THE FILES HOLD IT (`untakenBase`) — said before
341
- // anything is uploaded. A dry run previews it; `-o` reaches no backend, and `--force`
342
- // drops the gate.
343
- if (!output && !force) {
344
- const untaken = untakenBase(siteDir, client.origin)
345
- if (untaken) {
346
- error(untaken.headline)
347
- for (const line of untaken.lines) note(line)
348
- return { exitCode: 1 }
349
- }
350
- }
351
362
  const priorHashes = readSyncCache(siteDir, client.origin)
352
363
  // Per-item identity, without which the backend reads every record as new and
353
364
  // recreates every page and section row.
@@ -613,6 +624,9 @@ export async function push(args = [], deps = {}) {
613
624
  success(
614
625
  `Nothing to push — ${skipped} entit${skipped === 1 ? 'y' : 'ies'} unchanged since the last push.`
615
626
  )
627
+ // ⭐ And so the backend holds what this commit sends: it is where the next pull merges
628
+ // from (`holdUnchangedPush`). Not for a preview, which keeps nothing.
629
+ if (!output && !dryRun) holdUnchangedPush(siteDir, client.origin)
616
630
  return { exitCode: 0 }
617
631
  }
618
632
  if (siteContent)
@@ -137,7 +137,7 @@ export async function refresh(args = [], deps = {}) {
137
137
  say.info('Pulling from the git remote…')
138
138
  const r = pullRemote(siteDir)
139
139
  if (!r.ok) {
140
- say.err('git pull failed — resolve this before continuing.')
140
+ say.err(r.conflicts ? 'git pull left conflicts — settle them before continuing.' : 'git pull failed — resolve this before continuing.')
141
141
  for (const line of r.message.split('\n').slice(0, 6)) say.dim(line)
142
142
  // Deliberately stop. Layering the backend's content on top of an unresolved
143
143
  // git state gives the user two independent conflicts at once, and no clear
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-10-17T17:49:32.100Z",
3
+ "generatedAt": "2026-10-17T22:35:38.030Z",
4
4
  "packages": {
5
5
  "@uniweb/api": {
6
- "version": "1.4.0"
6
+ "version": "1.5.0"
7
7
  },
8
8
  "@uniweb/build": {
9
- "version": "1.3.9"
9
+ "version": "1.3.10"
10
10
  },
11
11
  "@uniweb/content-reader": {
12
12
  "version": "1.4.0"
@@ -24,7 +24,7 @@
24
24
  "version": "1.0.3"
25
25
  },
26
26
  "@uniweb/kit": {
27
- "version": "1.2.4"
27
+ "version": "1.2.5"
28
28
  },
29
29
  "@uniweb/loom": {
30
30
  "version": "1.1.0"
@@ -42,7 +42,7 @@
42
42
  "version": "1.0.1"
43
43
  },
44
44
  "@uniweb/schemas": {
45
- "version": "1.3.1"
45
+ "version": "1.4.0"
46
46
  },
47
47
  "@uniweb/scholar": {
48
48
  "version": "1.1.0"
package/src/index.js CHANGED
@@ -74,6 +74,7 @@ import {
74
74
  } from './utils/interactive.js'
75
75
  import { findWorkspaceRoot } from './utils/workspace.js'
76
76
  import { exitWhenDrained } from './utils/exit.js'
77
+ import { isGitRepo } from './utils/git.js'
77
78
 
78
79
  // Colors for terminal output
79
80
  const colors = {
@@ -1327,17 +1328,10 @@ async function main() {
1327
1328
 
1328
1329
  // Initialize git repository
1329
1330
  if (!noGit) {
1330
- // Skip git init if already inside a git repo (common for monorepos/workspaces)
1331
- let insideGitRepo = false
1332
- try {
1333
- execSync('git rev-parse --is-inside-work-tree', {
1334
- cwd: projectDir,
1335
- stdio: 'ignore'
1336
- })
1337
- insideGitRepo = true
1338
- } catch {
1339
- // Not inside a git repo — proceed with init
1340
- }
1331
+ // Skip git init if a repository already holds the project (common for
1332
+ // monorepos/workspaces). ⛔ Not one that ignores its folder: that repository can never
1333
+ // hold its history, so the project gets its own (`isGitRepo`, the one rule).
1334
+ const insideGitRepo = isGitRepo(projectDir)
1341
1335
 
1342
1336
  if (insideGitRepo) {
1343
1337
  log(
package/src/utils/git.js CHANGED
@@ -7,19 +7,25 @@
7
7
  * already solves better.
8
8
  *
9
9
  * DETECTED, NEVER REQUIRED. A project may legitimately have no repo, and — more
10
- * commonly — may live *inside* a larger one: `uniweb create` already skips
11
- * `git init` when it finds itself inside a work tree ("common for
12
- * monorepos/workspaces"), and whole scopes of real projects are versioned by a
13
- * parent repo rather than being repo roots themselves. So the question is never
14
- * "is this directory a git root" but "is this path tracked by some repo", which is
15
- * the question git itself answers. Outside one a pull checks out, asking first; in
16
- * one it merges (`utils/sync-point.js`).
10
+ * commonly — may live *inside* a larger one: `uniweb create` skips `git init` when a
11
+ * repository already holds it ("common for monorepos/workspaces"), and whole scopes of
12
+ * real projects are versioned by a parent repo rather than being repo roots
13
+ * themselves. So the question is never "is this directory a git root" but "does a
14
+ * repository HOLD this path": is it inside a work tree, in a folder that repository
15
+ * does not ignore. One rule answers it, `repoOf` in `utils/sync-point.js`. Outside
16
+ * one a pull checks out, asking first; in one it merges.
17
+ *
18
+ * ⛔ Until 2026-10-17 this said "is this path tracked by some repo" while the check
19
+ * was `git rev-parse --is-inside-work-tree` alone. That reads as if a folder the
20
+ * repository ignores were outside it, and it was not: a harness's ignored scratch
21
+ * project counted as inside the harness, so `refresh` ran `git pull` on the harness.
17
22
  */
18
23
 
19
24
  import { execFileSync } from 'node:child_process'
20
25
  import { readFileSync } from 'node:fs'
21
26
  import { join } from 'node:path'
22
27
  import yaml from 'js-yaml'
28
+ import { repoOf } from './sync-point.js'
23
29
 
24
30
  /**
25
31
  * The files and directories `uniweb pull` writes into.
@@ -69,15 +75,12 @@ function git(args, cwd) {
69
75
  }
70
76
 
71
77
  /**
72
- * Is `dir` inside a git work tree? False when git is absent, when the path isn't
73
- * tracked, or on any error — the caller treats all three the same way.
78
+ * Does a repository hold `dir` — is it inside a work tree, in a folder that repository
79
+ * does not ignore (`repoOf`)? False when git is absent, outside any work tree, in an
80
+ * ignored folder, or on any error — the caller treats them all the same way.
74
81
  */
75
82
  export function isGitRepo(dir) {
76
- try {
77
- return git(['rev-parse', '--is-inside-work-tree'], dir).trim() === 'true'
78
- } catch {
79
- return false
80
- }
83
+ return Boolean(repoOf(dir))
81
84
  }
82
85
 
83
86
  /** Does this repo have a remote configured to pull from? */
@@ -102,7 +105,7 @@ export function hasRemote(dir) {
102
105
  * ⛔ Until 2026-10-17 this was `--ff-only`, which refused once a pull had made a merge
103
106
  * commit the remote did not have.
104
107
  *
105
- * @returns {{ ok: boolean, changed: boolean, message: string }}
108
+ * @returns {{ ok: boolean, conflicts?: boolean, changed: boolean, message: string }}
106
109
  */
107
110
  export function pullRemote(dir) {
108
111
  const before = (() => {
@@ -125,6 +128,23 @@ export function pullRemote(dir) {
125
128
  return null
126
129
  }
127
130
  })()
131
+ // ⛔ `--autostash` exits 0 when the uncommitted work it set aside cannot be put back over
132
+ // what the pull brought: the files hold conflict markers and git keeps that work in the
133
+ // stash as well. That is not a clean pull — refresh must stop before the backend's
134
+ // content lands on top of it. *(Read as clean until 2026-10-17.)*
135
+ const unmerged = git(['diff', '--name-only', '--diff-filter=U'], dir).trim()
136
+ if (unmerged) {
137
+ return {
138
+ ok: false,
139
+ conflicts: true,
140
+ changed: Boolean(before && after && before !== after),
141
+ message: [
142
+ 'Your uncommitted changes overlap the commits git pull brought.',
143
+ 'Settle the markers and `git add` each file, then `git stash drop` — git kept those changes in the stash as well:',
144
+ ...unmerged.split('\n').map((p) => ` ${p}`)
145
+ ].join('\n')
146
+ }
147
+ }
128
148
  return {
129
149
  ok: true,
130
150
  changed: Boolean(before && after && before !== after),
@@ -10,13 +10,18 @@
10
10
  * A sync point is one of:
11
11
  * · a commit a PULL made — the backend's content written onto the last sync point's
12
12
  * tree, marked by the trailers below, and merged into the branch, so it is shared;
13
- * · the commit a PUSH was made from — what the backend then holds. This copy keeps it
14
- * (`backend-cache.json`, `syncPoint`), and the backend can name it on a pull
15
- * (`x-uniweb-source`, `backend/client.js`), for every copy that has it.
13
+ * · the commit a PUSH was made from — what the backend then holds — or one a push found
14
+ * nothing to send from. This copy keeps it (`backend-cache.json`, `syncPoint`), and the
15
+ * backend can name it on a pull (`x-uniweb-source`, `backend/client.js`), for every
16
+ * copy that has it.
16
17
  *
17
- * ⚖️ Any sync point a history holds is a safe base: both the files and the backend's
18
- * content descend from it, so neither side's change can read as the other's undoing. The
19
- * newest one is the precise base — an older one costs conflicts, never content.
18
+ * ⚖️ THE NEWEST ONE IS THE BASE; an older one is not simply less precise. Both the files and
19
+ * the backend's content descend from any of them, but between an older one and the
20
+ * newest, both sides hold the same changes — and where one side then changed a hunk back,
21
+ * the older base already reads the old text, so the change-back looks like no change and
22
+ * the merge silently takes the other side. ⛔ Until 2026-10-17 this said "an older one
23
+ * costs conflicts, never content". So no known base is dropped (`madeOnto`), and the
24
+ * backend naming its source is a matter of correctness, not only of fewer conflicts.
20
25
  *
21
26
  * ⛔ Until 2026-10-17 the ancestor was a per-machine list of the files this copy's last pull
22
27
  * wrote, by hash, found again in history by content — and HEAD where the list had no
@@ -69,8 +74,18 @@ export function gitError(err) {
69
74
  }
70
75
 
71
76
  /**
72
- * The repository a site is in — its top level, the site's path in it, the git
73
- * directory and HEAD (null on a branch with no commits) — or null outside one.
77
+ * The repository that holds a site — its top level, the site's path in it, the git
78
+ * directory and HEAD (null on a branch with no commits) — or null when none does.
79
+ *
80
+ * ⭐ HOLDS: the site's folder is inside a work tree, and that repository does not ignore
81
+ * it. A repository can never hold the history of a folder it ignores — a harness's
82
+ * scratch projects, a sandbox — so a site there is outside git, as if no repository were
83
+ * around it. One committed there anyway (`git add -f`) is held: git does not count a
84
+ * folder with tracked content as ignored.
85
+ *
86
+ * ⛔ Until 2026-10-17 any enclosing work tree counted. In a folder it ignores, a pull
87
+ * failed on git's "paths are ignored", a push recorded that repository's HEAD as its sync
88
+ * point, and `refresh` ran `git pull` on it.
74
89
  */
75
90
  export function repoOf(siteDir) {
76
91
  let top, prefix, gitDir
@@ -82,6 +97,7 @@ export function repoOf(siteDir) {
82
97
  return null
83
98
  }
84
99
  if (!top) return null
100
+ if (prefix && ignoredHere(siteDir)) return null
85
101
  let head = null
86
102
  try {
87
103
  head = gitText(top, ['rev-parse', '--verify', '-q', 'HEAD^{commit}']).trim() || null
@@ -91,6 +107,16 @@ export function repoOf(siteDir) {
91
107
  return { top, prefix, path: prefix ? prefix.replace(/\/$/, '') : '.', gitDir, head }
92
108
  }
93
109
 
110
+ /** Does the repository around `dir` ignore it? `git check-ignore` exits 0 for yes, 1 for no. */
111
+ function ignoredHere(dir) {
112
+ try {
113
+ git(dir, ['check-ignore', '-q', '.'])
114
+ return true
115
+ } catch {
116
+ return false
117
+ }
118
+ }
119
+
94
120
  /** Is `ancestor` in the history of `of` — or `of` itself? */
95
121
  export function isAncestor(repo, ancestor, of = 'HEAD') {
96
122
  try {
@@ -120,6 +146,15 @@ export function siteTreeIn(repo, rev) {
120
146
  }
121
147
  }
122
148
 
149
+ /** The paths in conflict here, whatever left them — a merge, or a stash put back over one. */
150
+ export function unmergedPaths(repo) {
151
+ try {
152
+ return zlist(git(repo.top, ['diff', '--name-only', '-z', '--diff-filter=U']))
153
+ } catch {
154
+ return []
155
+ }
156
+ }
157
+
123
158
  /** Is a merge waiting for its commit here? */
124
159
  export function mergeInProgress(repo) {
125
160
  try {
@@ -171,17 +206,64 @@ export function historySyncPoint(repo, { origin, siteUuid }) {
171
206
  for (const record of out.split('\x1e')) {
172
207
  const [sha = '', body = ''] = record.replace(/^\s+/, '').split('\x1f')
173
208
  if (!/^[0-9a-f]{40,64}$/.test(sha)) continue
174
- const t = trailersOf(body)
175
- if (t.origin !== origin || t.siteUuid !== siteUuid || t.path !== repo.path) continue
176
- if (siteTreeIn(repo, sha) === t.tree) return sha
209
+ if (marks(repo, sha, body, { origin, siteUuid })) return sha
177
210
  }
178
211
  return null
179
212
  }
180
213
 
214
+ // Does this commit's message mark it a sync point of this site and backend — and does it
215
+ // still hold the site tree its trailer names?
216
+ function marks(repo, sha, body, { origin, siteUuid }) {
217
+ const t = trailersOf(body)
218
+ if (t.origin !== origin || t.siteUuid !== siteUuid || t.path !== repo.path) return false
219
+ return siteTreeIn(repo, sha) === t.tree
220
+ }
221
+
222
+ /**
223
+ * The commits a sync point HEAD does not hold still vouches for: the one it was made onto
224
+ * — its first parent — and that one's, for as long as each is a sync point of this site
225
+ * HEAD does not hold either.
226
+ *
227
+ * ⛔ A merge abandoned (`git merge --abort`) leaves this copy's sync point out of the
228
+ * history, while the commit it was made onto is still there — and still a state the
229
+ * backend's content descends from. Until 2026-10-17 it was forgotten with the merge, and
230
+ * the next pull built on an older sync point, or on none: measured against a real
231
+ * backend, it conflicted on a section nobody had touched since the commit last pushed from.
232
+ */
233
+ export function madeOnto(repo, sha, { origin, siteUuid }) {
234
+ const out = []
235
+ let at = sha
236
+ for (let n = 0; n < 16 && isCommit(repo, at) && !isAncestor(repo, at); n++) {
237
+ let body
238
+ try {
239
+ body = gitText(repo.top, ['log', '-1', '--format=%B', at])
240
+ } catch {
241
+ break
242
+ }
243
+ if (!marks(repo, at, body, { origin, siteUuid })) break
244
+ let parent = null
245
+ try {
246
+ parent = gitText(repo.top, ['rev-parse', '--verify', '-q', `${at}^1`]).trim() || null
247
+ } catch {
248
+ // Made onto nothing — a history with no sync point.
249
+ }
250
+ if (!parent) break
251
+ out.push(parent)
252
+ at = parent
253
+ }
254
+ return out
255
+ }
256
+
181
257
  /**
182
258
  * The base a pull merges from: the newest of the candidates HEAD descends from. One that
183
- * descends from all the others is the newest; with none, the most recently committed —
184
- * any of them is a state both sides descend from, so the choice costs precision at worst.
259
+ * descends from all the others is the newest; with none, the most recently committed.
260
+ *
261
+ * ⚖️ Any of them is a state both sides descend from, but only the newest such state — the
262
+ * commit the backend's content last came from — merges exactly. An older one costs a
263
+ * conflict wherever one side changed what the other changed after it, and it silently
264
+ * undoes a change made after it and then changed BACK on one side: the base already
265
+ * reads the old text, so the change-back looks like no change at all. ⛔ Until
266
+ * 2026-10-17 this said an older base "costs precision at worst".
185
267
  */
186
268
  export function newestBase(repo, candidates) {
187
269
  const shas = [...new Set(candidates.filter(Boolean))].filter((s) => isCommit(repo, s) && isAncestor(repo, s))
@@ -210,12 +292,16 @@ export function newestBase(repo, candidates) {
210
292
  * ⛔ CLOSE IT. `close()` unlinks every link it made before removing the rest — a recursive
211
293
  * remove that followed one would delete the project's own files.
212
294
  *
295
+ * ⚖️ What is written under the roots where the repository ignores it — media in a folder
296
+ * kept out of git — is in no commit, so no merge brings it. `placeIgnored()` copies it
297
+ * into the project, where the project lacks it.
298
+ *
213
299
  * @param {object} repo - `repoOf`
214
300
  * @param {object} opts
215
301
  * @param {string[]} opts.roots - the site's content roots, relative to the site
216
302
  * @param {string|null} opts.rev - the commit whose content the roots are filled from;
217
303
  * null for none
218
- * @returns {{ siteDir: string, commit: Function, close: Function }}
304
+ * @returns {{ siteDir: string, commit: Function, placeIgnored: Function, close: Function }}
219
305
  */
220
306
  export function openStandIn(repo, { roots, rev }) {
221
307
  for (const r of roots) {
@@ -329,7 +415,7 @@ export function openStandIn(repo, { roots, rev }) {
329
415
  * @returns {{ commit: string|null, tree: string, siteTree: string|null }}
330
416
  */
331
417
  const commit = ({ parent, subject, origin, siteUuid, always = false }) => {
332
- const staged = full.filter((f) => present.includes(f) || existsSync(join(root, f)))
418
+ const staged = held(full.filter((f) => present.includes(f) || existsSync(join(root, f))))
333
419
  if (staged.length) at(['add', '-A', '--', ...staged])
334
420
  const tree = at(['write-tree']).toString('utf8').trim()
335
421
  const before = rev ? gitText(repo.top, ['rev-parse', `${rev}^{tree}`]).trim() : null
@@ -356,7 +442,40 @@ export function openStandIn(repo, { roots, rev }) {
356
442
  return { commit: sha, tree, siteTree }
357
443
  }
358
444
 
359
- return { siteDir, commit, close }
445
+ /**
446
+ * Copy into the project what the stand-in holds under the roots where the repository
447
+ * ignores it, wherever the project lacks it. ⛔ Never over a file the project has:
448
+ * with no history there, nothing tells its version from the author's work.
449
+ * @returns {string[]} the paths placed, relative to the site
450
+ */
451
+ const placeIgnored = () => {
452
+ const roots = full.filter((f) => existsSync(join(root, f)))
453
+ if (!roots.length) return []
454
+ const placed = []
455
+ for (const p of zlist(at(['ls-files', '--others', '--ignored', '--exclude-standard', '-z', '--', ...roots]))) {
456
+ const to = join(repo.top, p)
457
+ if (occupied(to)) continue
458
+ mkdirSync(dirname(to), { recursive: true })
459
+ copyFileSync(join(root, p), to)
460
+ placed.push(sitePrefix && p.startsWith(sitePrefix) ? p.slice(sitePrefix.length) : p)
461
+ }
462
+ return placed
463
+ }
464
+
465
+ // The roots git can hold here. ⛔ One the repository ignores whole is not named to
466
+ // `git add`, which refuses an ignored path; what is written under it is placed instead.
467
+ const held = (paths) => {
468
+ if (!paths.length) return paths
469
+ let ignored = []
470
+ try {
471
+ ignored = zlist(at(['check-ignore', '-z', '--stdin'], { input: `${paths.join('\0')}\0` }))
472
+ } catch (err) {
473
+ if (err?.status !== 1) throw err // 1: none of them is ignored
474
+ }
475
+ return paths.filter((p) => !ignored.includes(p))
476
+ }
477
+
478
+ return { siteDir, commit, placeIgnored, close }
360
479
  } catch (err) {
361
480
  close()
362
481
  throw err
@@ -366,6 +485,15 @@ export function openStandIn(repo, { roots, rev }) {
366
485
  // ── merging ─────────────────────────────────────────────────────────────────
367
486
 
368
487
  const zlist = (buf) => buf.toString('utf8').split('\0').filter(Boolean)
488
+ // Something is at `path` — a dangling link included, which `existsSync` reads as nothing.
489
+ const occupied = (path) => {
490
+ try {
491
+ lstatSync(path)
492
+ return true
493
+ } catch {
494
+ return false
495
+ }
496
+ }
369
497
 
370
498
  /**
371
499
  * Files not under git that the merge would write over: their paths, each with whether the
@@ -404,6 +532,9 @@ function untrackedInTheWay(repo, commit) {
404
532
  */
405
533
  export function mergeSyncPoint(repo, commit, { message }) {
406
534
  if (mergeInProgress(repo)) return { ok: false, reason: 'merge-in-progress' }
535
+ // Conflicts already here are not this merge's: git would refuse it, and they would read as its own.
536
+ const unsettled = unmergedPaths(repo)
537
+ if (unsettled.length) return { ok: false, reason: 'unmerged', paths: unsettled }
407
538
  const inTheWay = untrackedInTheWay(repo, commit)
408
539
  const blocking = inTheWay.filter((u) => !u.same).map((u) => u.path)
409
540
  if (blocking.length) return { ok: false, reason: 'untracked', paths: blocking }
@@ -412,7 +543,13 @@ export function mergeSyncPoint(repo, commit, { message }) {
412
543
  for (const u of inTheWay) rmSync(join(repo.top, u.path))
413
544
  try {
414
545
  git(repo.top, ['merge', '--autostash', '--no-edit', '--no-verify', '--allow-unrelated-histories', '-m', message, commit])
415
- return { ok: true, conflicted: [] }
546
+ // ⛔ THE MERGE IS MADE, AND IT MAY STILL HAVE LEFT CONFLICTS. `--autostash` exits 0 when
547
+ // the uncommitted work it set aside cannot be put back over what the merge brought: the
548
+ // files then hold conflict markers, unmerged, and git keeps that work in the stash too.
549
+ // Until 2026-10-17 that read as a clean merge — a pull that exited 0 over the markers,
550
+ // so `uniweb pull && uniweb push` would send them.
551
+ const unstashed = unmergedPaths(repo)
552
+ return { ok: true, conflicted: unstashed, stashKept: unstashed.length > 0 }
416
553
  } catch (err) {
417
554
  const conflicted = zlist(git(repo.top, ['diff', '--name-only', '-z', '--diff-filter=U']))
418
555
  if (conflicted.length) return { ok: true, conflicted }
@@ -444,26 +581,70 @@ export function takeSyncPoint(repo, commit, { roots, message }) {
444
581
  }
445
582
  }
446
583
 
447
- /** What the backend changed between two commits, under the site: `[status, path]` pairs. */
448
- export function changesBetween(repo, from, to) {
584
+ /**
585
+ * What a pull changed in the files, under the site: `[status, path]` pairs — what the
586
+ * merge, or the take, moved from `from` (HEAD before it), less what the files already
587
+ * held as it left them. `files` is the files before it (`snapshotFiles`). `staged` reads a
588
+ * merge stopped on conflicts from the index; `skip` leaves out the paths in conflict,
589
+ * which are reported as conflicts.
590
+ *
591
+ * ⛔ Until 2026-10-17 a pull listed the backend's content against the base, as "changes
592
+ * from the backend": a file a push had already written into the files read as one, so
593
+ * did a person's own edit when there was no base — and `--force` said it "took" a change
594
+ * where it undid their edit.
595
+ */
596
+ export function changedInFiles(repo, { from, files, staged = false, skip = [] }) {
597
+ const scope = ['--', ...(repo.prefix ? [repo.prefix] : [])]
449
598
  try {
450
- // No base: against the empty tree, in this repository's own hash.
451
- const base = from || gitText(repo.top, ['hash-object', '-t', 'tree', '--stdin'], { input: '' }).trim()
452
- const out = gitText(repo.top, ['diff', '--name-status', '-z', base, to, '--', ...(repo.prefix ? [repo.prefix] : [])])
453
- const parts = out.split('\0').filter(Boolean)
454
- const changes = []
599
+ const parts = zlist(git(repo.top, ['diff', '--name-status', '-z', ...(staged ? ['--cached', from] : [from, 'HEAD']), ...scope]))
600
+ const moved = []
455
601
  for (let i = 0; i < parts.length; ) {
456
602
  const status = parts[i++]
457
603
  const path = parts[i++]
458
- if (/^[RC]/.test(status)) changes.push([status[0], parts[i++]])
459
- else changes.push([status, path])
604
+ moved.push(/^[RC]/.test(status) ? [status[0], parts[i++]] : [status, path])
460
605
  }
461
- return changes.map(([s, p]) => [s, repo.prefix && p.startsWith(repo.prefix) ? p.slice(repo.prefix.length) : p])
606
+ // What the files already held as the merge left it is no change to them.
607
+ const differs = new Set(
608
+ zlist(git(repo.top, ['diff', '--name-only', '-z', '--no-renames', ...(staged ? ['--cached', files] : [files, 'HEAD']), ...scope]))
609
+ )
610
+ return moved
611
+ .filter(([s, p]) => s !== 'U' && !skip.includes(p) && differs.has(p))
612
+ .map(([s, p]) => [s, repo.prefix && p.startsWith(repo.prefix) ? p.slice(repo.prefix.length) : p])
462
613
  } catch {
463
614
  return []
464
615
  }
465
616
  }
466
617
 
618
+ /**
619
+ * The files as they are, as a commit to compare against — what is tracked, committed or
620
+ * not (`git stash create`, which touches neither the files nor any ref); HEAD when nothing
621
+ * is uncommitted.
622
+ */
623
+ export function snapshotFiles(repo) {
624
+ try {
625
+ return (
626
+ gitText(repo.top, ['stash', 'create'], {
627
+ env: { GIT_AUTHOR_NAME: 'uniweb', GIT_AUTHOR_EMAIL: 'sync@uniweb', GIT_COMMITTER_NAME: 'uniweb', GIT_COMMITTER_EMAIL: 'sync@uniweb' }
628
+ }).trim() || repo.head
629
+ )
630
+ } catch {
631
+ return repo.head
632
+ }
633
+ }
634
+
635
+ /**
636
+ * Does HEAD hold the site's content as the files do — nothing staged, changed or untracked
637
+ * under the roots? (What the repository ignores is no change.)
638
+ */
639
+ export function contentCommitted(repo, roots) {
640
+ try {
641
+ const full = roots.map((r) => `${repo.prefix}${r}`)
642
+ return git(repo.top, ['status', '--porcelain', '-z', '--untracked-files=all', '--', ...full]).length === 0
643
+ } catch {
644
+ return false
645
+ }
646
+ }
647
+
467
648
  // ── this copy's own sync point ──────────────────────────────────────────────
468
649
 
469
650
  const cachePath = (siteDir) => join(siteDir, '.uniweb', 'backend-cache.json')