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 +4 -4
- package/src/backend/site-sync.js +19 -1
- package/src/commands/pull.js +11 -5
- package/src/utils/sync-point.js +58 -18
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "uniweb",
|
|
3
|
-
"version": "1.13.
|
|
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/
|
|
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",
|
package/src/backend/site-sync.js
CHANGED
|
@@ -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)
|
package/src/commands/pull.js
CHANGED
|
@@ -115,7 +115,7 @@ import {
|
|
|
115
115
|
isAncestor,
|
|
116
116
|
historySyncPoint,
|
|
117
117
|
madeOnto,
|
|
118
|
-
|
|
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 =
|
|
917
|
+
base = baseFor(repo, {
|
|
918
918
|
known,
|
|
919
|
-
// ⛔
|
|
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(
|
|
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
|
}
|
package/src/utils/sync-point.js
CHANGED
|
@@ -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
|
|
284
|
-
|
|
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 {
|