uniweb 1.13.0 → 1.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uniweb",
3
- "version": "1.13.0",
3
+ "version": "1.13.1",
4
4
  "description": "Create structured Vite + React sites with content/code separation",
5
5
  "type": "module",
6
6
  "bin": {
@@ -41,10 +41,10 @@
41
41
  "js-yaml": "^4.1.0",
42
42
  "prompts": "^2.4.2",
43
43
  "tar": "^7.0.0",
44
- "@uniweb/schemas": "~1.5.0",
45
44
  "@uniweb/content-writer": "~1.3.0",
46
- "@uniweb/core": "~1.3.0",
47
- "@uniweb/semantic-parser": "~1.12.0"
45
+ "@uniweb/schemas": "~1.5.0",
46
+ "@uniweb/semantic-parser": "~1.12.0",
47
+ "@uniweb/core": "~1.3.0"
48
48
  },
49
49
  "peerDependencies": {
50
50
  "@uniweb/content-reader": "~1.5.2",
@@ -23,6 +23,7 @@ import {
23
23
  uncommittedPaths,
24
24
  commitRoots,
25
25
  gitError,
26
+ RECORDED_TRAILER,
26
27
  unmergedPaths,
27
28
  mergeInProgress
28
29
  } from '../utils/sync-point.js'
@@ -1071,6 +1072,20 @@ export function uncommittedContent(siteDir, { verb = 'push' } = {}) {
1071
1072
  lines: [`Finish it (\`git commit\`), then ${verb} — or abandon it (\`git merge --abort\`) and pull again.`]
1072
1073
  }
1073
1074
  }
1075
+ // ⭐ A repository with no commit yet is still the one that will hold the site's history —
1076
+ // and holds none of it, so every file is listed as not committed. Said as that, not as
1077
+ // edits. ⛔ Not taken for a folder outside git: its first push would keep nothing to merge
1078
+ // from, and the pull after its first commit would read every difference as a conflict.
1079
+ if (!repo.head) {
1080
+ return {
1081
+ paths,
1082
+ headline: `Not ${done} — this repository has no commit yet, so none of the site's ${paths.length} file(s) is committed.`,
1083
+ lines: [
1084
+ `In a repository a ${verb} sends what you have committed, so a later pull can tell your changes from the backend's.`,
1085
+ `Commit the site, then ${verb} again — or re-run with --commit (and -m "…") to make that first commit.`
1086
+ ]
1087
+ }
1088
+ }
1074
1089
  return {
1075
1090
  paths,
1076
1091
  headline: `Not ${done} — ${paths.length} file(s) of the site are not committed:`,
@@ -1139,8 +1154,11 @@ export function commitWriteBacks(siteDir, backend, { complete = false } = {}) {
1139
1154
  GIT_COMMITTER_EMAIL: `sync@${host}`
1140
1155
  }
1141
1156
  try {
1157
+ // ⭐ Marked with the backend and the site, so a pull reads the commit a lane names through
1158
+ // it (`recordedAfter`, utils/sync-point.js): the ids written back are what the backend holds.
1159
+ const site = readBackendState(siteDir, backend).site?.uuid
1142
1160
  const made = commitRoots(repo, [...sentRoots(siteDir), SYNC_STORE_FILE], {
1143
- message: `Record what ${backend} gave the site`,
1161
+ message: `Record what ${backend} gave the site${site ? `\n\n${RECORDED_TRAILER}: ${backend} ${site}` : ''}`,
1144
1162
  identity
1145
1163
  })
1146
1164
  if (made && complete) writeSyncPoint(siteDir, backend, made.commit)
@@ -115,7 +115,7 @@ import {
115
115
  isAncestor,
116
116
  historySyncPoint,
117
117
  madeOnto,
118
- newestBase,
118
+ baseFor,
119
119
  openStandIn,
120
120
  mergeSyncPoint,
121
121
  takeSyncPoint,
@@ -914,13 +914,15 @@ export async function pull(args = [], deps = {}) {
914
914
  ]
915
915
  const laneRoots = siteLaneRoots(siteDir)
916
916
  const taken = (lane) => lane && !lane.notModified && !lane.refused
917
- base = newestBase(repo, [...known, content?.source, folderLane?.source], {
917
+ base = baseFor(repo, {
918
918
  known,
919
- // ⛔ Each lane the backend answered holds every base to what it names (`newestBase`).
919
+ // ⛔ What each lane the backend answered names is a base only where every lane holds it (`baseFor`).
920
920
  lanes: [
921
921
  ...(taken(content) ? [{ source: content.source, roots: laneRoots.content }] : []),
922
922
  ...(taken(folderLane) ? [{ source: folderLane.source, roots: laneRoots.records }] : [])
923
- ]
923
+ ],
924
+ origin: client.origin,
925
+ siteUuid: siteContentUuid
924
926
  })
925
927
  onto = force || !base ? repo.head : base
926
928
  try {
@@ -1183,6 +1185,8 @@ export async function pull(args = [], deps = {}) {
1183
1185
  } else {
1184
1186
  merge.conflicted = result.conflicted || []
1185
1187
  merge.stashKept = Boolean(result.stashKept)
1188
+ // Whether anything was set aside for the merge: the files as they were, against HEAD.
1189
+ merge.setAside = files !== repo.head
1186
1190
  merge.noBase = !force && !base
1187
1191
  merge.changed = changedInFiles(repo, {
1188
1192
  from: repo.head,
@@ -1286,7 +1290,9 @@ export async function pull(args = [], deps = {}) {
1286
1290
  note("your changes from the backend's: each file where they differ is a conflict, yours against the backend's.")
1287
1291
  note('Once this merge is committed, later pulls merge from it.')
1288
1292
  }
1289
- note('Settle them and `git add` each, then `git commit` to finish the merge — your uncommitted changes come back with it.')
1293
+ note(
1294
+ `Settle them and \`git add\` each, then \`git commit\` to finish the merge${merge.setAside ? ' — your uncommitted changes come back with it' : ''}.`
1295
+ )
1290
1296
  }
1291
1297
  }
1292
1298
  }
@@ -53,6 +53,8 @@ import { join, dirname } from 'node:path'
53
53
 
54
54
  export const SYNC_TRAILER = 'Uniweb-Sync'
55
55
  export const SITE_TRAILER = 'Uniweb-Site'
56
+ /** Marks the commit a push makes of what it wrote back: `Uniweb-Recorded: <origin> <site>`. */
57
+ export const RECORDED_TRAILER = 'Uniweb-Recorded'
56
58
 
57
59
  const MAX_BUFFER = 1024 * 1024 * 1024
58
60
 
@@ -280,24 +282,8 @@ export function madeOnto(repo, sha, { origin, siteUuid }) {
280
282
  * reads the old text, so the change-back looks like no change at all. ⛔ Until
281
283
  * 2026-10-17 this said an older base "costs precision at worst".
282
284
  */
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
+ export function newestBase(repo, candidates) {
286
+ const shas = [...new Set(candidates.filter(Boolean))].filter((s) => isCommit(repo, s) && isAncestor(repo, s))
301
287
  if (shas.length <= 1) return shas[0] || null
302
288
  const newest = shas.find((s) => shas.every((o) => o === s || isAncestor(repo, o, s)))
303
289
  if (newest) return newest
@@ -308,6 +294,60 @@ export function newestBase(repo, candidates, { lanes = null, known = [] } = {})
308
294
  }
309
295
  }
310
296
 
297
+ /**
298
+ * The base a pull merges from, given the bases this copy knows of itself and what the backend
299
+ * names for each lane it answered (`x-uniweb-source`).
300
+ *
301
+ * ⭐ THIS COPY'S OWN BASES ARE EXACT BY CONSTRUCTION — a pull's commit, the commit a push was
302
+ * made from once every lane landed, one a push found nothing to send from — and are never
303
+ * second-guessed here.
304
+ *
305
+ * ⛔ ONE MERGE TAKES ONE BASE FOR EVERY LANE, and what the backend names is exact for its own
306
+ * lane only. A commit one lane names that is newer than another lane's, with that lane's files
307
+ * different, would read what that lane lacks — a push of it the backend refused while it took the
308
+ * other — as the backend's removal, and the merge would drop it. So it is not a base. A lane is
309
+ * held to the commit it names, read through the commit its push made of what it wrote back
310
+ * (`recordedAfter`), and a lane that names none to the newest base this copy knows.
311
+ *
312
+ * ⛔ Until 2026-10-19 (cli 713f62d, uniweb 1.13.0) every candidate was held to that, this copy's
313
+ * own included, and a lane to the commit it names alone. A first push writes each new record's
314
+ * `$uuid` back, so the records differed from the commit the folder lane names in every commit after
315
+ * it, and each was dropped: a pull merged from the site's first push, measured by flows against a
316
+ * real backend — a reverted change came back (A18), a re-wrap and six pushed edits conflicted.
317
+ */
318
+ export function baseFor(repo, { known = [], lanes = [], origin, siteUuid }) {
319
+ const held = (s) => Boolean(s) && isCommit(repo, s) && isAncestor(repo, s)
320
+ const fallback = newestBase(repo, known)
321
+ const refs = lanes.map(({ source, roots }) => ({
322
+ roots,
323
+ ref: held(source) ? recordedAfter(repo, source, { origin, siteUuid }) || source : fallback
324
+ }))
325
+ const named = lanes.flatMap(({ source }) => (held(source) ? [source, recordedAfter(repo, source, { origin, siteUuid })] : []))
326
+ const taken = named.filter(Boolean).filter((s) =>
327
+ refs.every(({ ref, roots }) => Boolean(ref) && (s === ref || isAncestor(repo, s, ref) || !differsIn(repo, ref, s, roots)))
328
+ )
329
+ return newestBase(repo, [...known, ...taken])
330
+ }
331
+
332
+ /**
333
+ * The commit a push made of what it wrote back, after being made from `sha` — its first parent,
334
+ * marked with the push's backend and site — when HEAD's history holds it; else null.
335
+ */
336
+ export function recordedAfter(repo, sha, { origin, siteUuid }) {
337
+ if (!sha || !origin || !siteUuid) return null
338
+ let out
339
+ try {
340
+ out = gitText(repo.top, ['log', '--fixed-strings', `--grep=${RECORDED_TRAILER}: ${origin} ${siteUuid}`, '--format=%H %P', 'HEAD'])
341
+ } catch {
342
+ return null
343
+ }
344
+ for (const line of out.split('\n')) {
345
+ const [commit, first] = line.trim().split(' ')
346
+ if (commit && first && (first === sha || first.startsWith(sha))) return commit
347
+ }
348
+ return null
349
+ }
350
+
311
351
  /** Does the content under `roots` differ between two commits? Anything but "no" counts as yes. */
312
352
  function differsIn(repo, a, b, roots) {
313
353
  try {