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 +6 -6
- package/partials/agents.md +1 -1
- package/src/backend/site-sync.js +62 -1
- package/src/commands/clone.js +17 -4
- package/src/commands/publish.js +22 -11
- package/src/commands/pull.js +63 -10
- package/src/commands/push.js +25 -11
- package/src/commands/refresh.js +1 -1
- package/src/framework-index.json +5 -5
- package/src/index.js +5 -11
- package/src/utils/git.js +35 -15
- package/src/utils/sync-point.js +208 -27
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "uniweb",
|
|
3
|
-
"version": "1.8.
|
|
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/
|
|
47
|
-
"@uniweb/schemas": "~1.3.1"
|
|
47
|
+
"@uniweb/schemas": "~1.4.0"
|
|
48
48
|
},
|
|
49
49
|
"peerDependencies": {
|
|
50
|
-
"@uniweb/
|
|
51
|
-
"@uniweb/
|
|
52
|
-
"@uniweb/
|
|
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": {
|
package/partials/agents.md
CHANGED
|
@@ -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
|
|
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
|
|
package/src/backend/site-sync.js
CHANGED
|
@@ -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 {
|
|
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
|
package/src/commands/clone.js
CHANGED
|
@@ -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
|
|
177
|
-
*
|
|
178
|
-
*
|
|
179
|
-
*
|
|
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.'
|
package/src/commands/publish.js
CHANGED
|
@@ -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
|
package/src/commands/pull.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
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
|
-
|
|
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.
|
package/src/commands/push.js
CHANGED
|
@@ -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)
|
package/src/commands/refresh.js
CHANGED
|
@@ -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
|
package/src/framework-index.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"generatedAt": "2026-10-
|
|
3
|
+
"generatedAt": "2026-10-17T22:35:38.030Z",
|
|
4
4
|
"packages": {
|
|
5
5
|
"@uniweb/api": {
|
|
6
|
-
"version": "1.
|
|
6
|
+
"version": "1.5.0"
|
|
7
7
|
},
|
|
8
8
|
"@uniweb/build": {
|
|
9
|
-
"version": "1.3.
|
|
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.
|
|
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.
|
|
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
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
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`
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* one it merges
|
|
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
|
-
*
|
|
73
|
-
*
|
|
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
|
-
|
|
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),
|
package/src/utils/sync-point.js
CHANGED
|
@@ -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
|
|
14
|
-
* (`backend-cache.json`, `syncPoint`), and the
|
|
15
|
-
* (`x-uniweb-source`, `backend/client.js`), for every
|
|
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
|
-
* ⚖️
|
|
18
|
-
* content descend from
|
|
19
|
-
* newest
|
|
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
|
|
73
|
-
* directory and HEAD (null on a branch with no commits) — or null
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
448
|
-
|
|
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
|
-
|
|
451
|
-
const
|
|
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
|
-
|
|
459
|
-
else changes.push([status, path])
|
|
604
|
+
moved.push(/^[RC]/.test(status) ? [status[0], parts[i++]] : [status, path])
|
|
460
605
|
}
|
|
461
|
-
|
|
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')
|