uniweb 1.8.0 → 1.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/package.json +8 -8
- package/partials/agents.md +1 -3
- package/src/backend/client.js +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 +76 -13
- package/src/commands/push.js +25 -11
- package/src/commands/refresh.js +10 -4
- package/src/framework-index.json +12 -12
- package/src/index.js +5 -37
- package/src/utils/git.js +35 -15
- package/src/utils/install-integrity.js +3 -3
- package/src/utils/sync-point.js +223 -27
- package/src/commands/handoff.js +0 -21
- package/src/commands/invite.js +0 -24
package/README.md
CHANGED
|
@@ -334,7 +334,7 @@ cd src
|
|
|
334
334
|
uniweb register --scope @your-org
|
|
335
335
|
```
|
|
336
336
|
|
|
337
|
-
Real sites built on your foundation are managed in the **Uniweb
|
|
337
|
+
Real sites built on your foundation are managed in the **Uniweb app** — a visual editor designed for non-technical authors. They never see git, markdown, yaml, or React. They see *your* components, with live previews and visual controls for the params you defined. The foundation becomes the editor's native vocabulary for that site: you keep creative control of the design system, they get an editor that feels custom-built for them.
|
|
338
338
|
|
|
339
339
|
This is the best path when site content has a life independent of the foundation's release cycle — agencies, design studios, multi-client teams, or any project where content authors aren't the same people as the developers.
|
|
340
340
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "uniweb",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.9.0",
|
|
4
4
|
"description": "Create structured Vite + React sites with content/code separation",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -41,15 +41,15 @@
|
|
|
41
41
|
"js-yaml": "^4.1.0",
|
|
42
42
|
"prompts": "^2.4.2",
|
|
43
43
|
"tar": "^7.0.0",
|
|
44
|
-
"@uniweb/core": "~1.2.
|
|
45
|
-
"@uniweb/semantic-parser": "~1.
|
|
46
|
-
"@uniweb/
|
|
47
|
-
"@uniweb/
|
|
44
|
+
"@uniweb/core": "~1.2.3",
|
|
45
|
+
"@uniweb/semantic-parser": "~1.9.0",
|
|
46
|
+
"@uniweb/schemas": "~1.4.0",
|
|
47
|
+
"@uniweb/content-writer": "~1.2.0"
|
|
48
48
|
},
|
|
49
49
|
"peerDependencies": {
|
|
50
|
-
"@uniweb/
|
|
51
|
-
"@uniweb/
|
|
52
|
-
"@uniweb/content-reader": "~1.4.
|
|
50
|
+
"@uniweb/build": "~1.3.11",
|
|
51
|
+
"@uniweb/semantic-parser": "~1.9.0",
|
|
52
|
+
"@uniweb/content-reader": "~1.4.1"
|
|
53
53
|
},
|
|
54
54
|
"peerDependenciesMeta": {
|
|
55
55
|
"@uniweb/build": {
|
package/partials/agents.md
CHANGED
|
@@ -2796,8 +2796,6 @@ uniweb snapshot --compare # Several looks on one sheet; take one with it
|
|
|
2796
2796
|
uniweb <command> --help # Per-command flags — no side effects. Prefer this over guessing.
|
|
2797
2797
|
```
|
|
2798
2798
|
|
|
2799
|
-
**Three verbs dispatch but are deliberately not listed above.** `invite`, `handoff` and `template` are **reserved names with no implementation** — the flows they named ran against a backend the CLI no longer talks to, and the names are held so a future rebuild doesn't need a breaking change. Running any of them is not useful; their absence from this list is the answer, not an omission to fix.
|
|
2800
|
-
|
|
2801
2799
|
### Where a site can live
|
|
2802
2800
|
|
|
2803
2801
|
**There is no lock-in, and no default you're pushed toward.** Four independent paths, and a project can change its mind:
|
|
@@ -2850,7 +2848,7 @@ Foundations have their own free path too: `uniweb add ci --target foundation` pu
|
|
|
2850
2848
|
- **Git is the reviewed, durable record** — you `pull` content back, read it with `git diff`, and commit.
|
|
2851
2849
|
- **Authors never push or pull.** For them, content simply updates, whether the change came from another author or from a developer's CLI.
|
|
2852
2850
|
|
|
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
|
|
2851
|
+
**Nobody's work is overwritten without asking.** `uniweb push` is refused if the site changed on the backend since your last pull — usually an author editing in the app — and reports what changed; nothing is written. Edits to different sections never collide. **In a git repository `uniweb pull` merges, like `git pull`:** it commits the backend's content (*"The site's content on …"*) and merges that commit into your branch, against the last state of the site your history shares with the backend. Your work, committed or not, and a teammate's arriving through git are kept; a file the backend never had is never deleted; changes to different parts of a file combine silently; and only a genuine overlap leaves conflict markers and a non-zero exit — so `uniweb pull && uniweb push` never ships markers. Settle a conflict as in any merge, then `git commit`; where your uncommitted changes overlap what the pull brought, the markers are theirs — settle them and `git add` each file, then `git stash drop` (git keeps them in the stash too). A push or publish is refused while a file under the site is in conflict. `uniweb refresh` runs `git pull` first, then the same pull. A push is also refused when your branch does not hold what this copy last took from the backend — after `git merge --abort`, or in a fresh clone that has not pulled — so pull, then push. Outside git — which includes a folder the repository around it ignores, such as a scratch directory — `uniweb pull` takes the backend's version over your files, asking first. `--force` is the deliberate overwrite (on `push` it replaces the backend's changes, on `pull` it discards yours); don't use it to get past a refusal. `uniweb sync` is `refresh` then `push`; neither publishes.
|
|
2854
2852
|
|
|
2855
2853
|
**`sync.json` says which site this is, on each backend.** The first push to a backend records what that backend assigned — the site's id and owner, the ids of its records and uploaded files — in `sync.json` beside `site.yml`. Commit it; never edit it. **To make a new site from a copy of a project, run `uniweb forget --all` in the copy before its first push** — the copy carries the original's `sync.json`, so otherwise its push updates the original's site. When two projects in one workspace hold the same site, a push or publish from either is refused until that is done. `uniweb forget --server <url>` removes just one backend's records, such as a scratch server's. A record file's `$uuid` is its own id and stays in both cases. **`push`, `pull` and `publish` go to the backend you are logged in to** — the last `uniweb login --server <url>` — and never to one you are not logged in to. A bare `uniweb login` logs in to https://uniweb.app; for any other backend, name it — logging in is how you switch, and the backend commands take no `--server` of their own. When the project has no site on that backend but has one elsewhere, a push says so before creating a new one.
|
|
2856
2854
|
|
package/src/backend/client.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* BackendClient — the single front door to the Uniweb backend.
|
|
3
3
|
*
|
|
4
4
|
* Every CLI verb that talks to the backend (register, push, pull, clone, org,
|
|
5
|
-
* login — and, as the consolidation proceeds, publish/deploy
|
|
5
|
+
* login — and, as the consolidation proceeds, publish/deploy)
|
|
6
6
|
* goes through one instance of this client instead of hand-rolling
|
|
7
7
|
* `fetch(… Authorization: Bearer …)` against a per-command default origin. It
|
|
8
8
|
* owns the three things that were previously scattered across a dozen files:
|
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,17 @@ import { siteContentRoots } from '../utils/git.js'
|
|
|
110
110
|
import {
|
|
111
111
|
repoOf,
|
|
112
112
|
mergeInProgress,
|
|
113
|
+
unmergedPaths,
|
|
114
|
+
ignoringRepo,
|
|
113
115
|
isAncestor,
|
|
114
116
|
historySyncPoint,
|
|
117
|
+
madeOnto,
|
|
115
118
|
newestBase,
|
|
116
119
|
openStandIn,
|
|
117
120
|
mergeSyncPoint,
|
|
118
121
|
takeSyncPoint,
|
|
119
|
-
|
|
122
|
+
snapshotFiles,
|
|
123
|
+
changedInFiles,
|
|
120
124
|
readSyncPoint,
|
|
121
125
|
writeSyncPoint
|
|
122
126
|
} from '../utils/sync-point.js'
|
|
@@ -482,8 +486,15 @@ async function checkWorkingTree(siteDir, args) {
|
|
|
482
486
|
const roots = siteContentRoots(siteDir)
|
|
483
487
|
const written = readWritten(siteDir)
|
|
484
488
|
if (filesUnder(siteDir, roots).every((f) => isPullOutput(siteDir, f, written))) return null
|
|
489
|
+
// A folder the repository around it ignores is outside git too — said as such, since a
|
|
490
|
+
// repository is right there and "not in a repository" is not what the person sees.
|
|
491
|
+
const ignoredBy = ignoringRepo(siteDir)
|
|
485
492
|
if (isNonInteractive(args)) {
|
|
486
|
-
error(
|
|
493
|
+
error(
|
|
494
|
+
ignoredBy
|
|
495
|
+
? 'Refusing to pull: this site is in a folder the repository around it ignores, so no repository holds it.'
|
|
496
|
+
: 'Refusing to pull: this site is not in a git repository.'
|
|
497
|
+
)
|
|
487
498
|
note(
|
|
488
499
|
'Pull rewrites pages, sections and layout from the backend and deletes what it no longer has.'
|
|
489
500
|
)
|
|
@@ -491,12 +502,14 @@ async function checkWorkingTree(siteDir, args) {
|
|
|
491
502
|
'Without version control there is no way back, and this session cannot ask.'
|
|
492
503
|
)
|
|
493
504
|
note(
|
|
494
|
-
|
|
505
|
+
ignoredBy
|
|
506
|
+
? 'Re-run with --force to accept that, or give the site a repository of its own (`git init`, then commit) — there a pull merges.'
|
|
507
|
+
: 'Re-run with --force to accept that, or put the site under git first — there a pull merges.'
|
|
495
508
|
)
|
|
496
509
|
return { exitCode: 1 }
|
|
497
510
|
}
|
|
498
511
|
const ok = await confirm(
|
|
499
|
-
`Pull will overwrite ${roots.length} content location(s) and
|
|
512
|
+
`Pull will overwrite ${roots.length} content location(s), and no git repository holds this site. Continue?`
|
|
500
513
|
)
|
|
501
514
|
if (!ok) {
|
|
502
515
|
info('Nothing pulled.')
|
|
@@ -574,6 +587,13 @@ export async function pull(args = [], deps = {}) {
|
|
|
574
587
|
note('Finish it (`git commit`) or abandon it (`git merge --abort`), then pull.')
|
|
575
588
|
return { exitCode: 1 }
|
|
576
589
|
}
|
|
590
|
+
const unsettled = unmergedPaths(repo)
|
|
591
|
+
if (unsettled.length) {
|
|
592
|
+
error(`${unsettled.length} file(s) here are in conflict:`)
|
|
593
|
+
for (const p of unsettled.slice(0, 12)) note(` ${p}`)
|
|
594
|
+
note('Settle them first — `git status` says how — then pull.')
|
|
595
|
+
return { exitCode: 1 }
|
|
596
|
+
}
|
|
577
597
|
} else if (!repo && !dryRun && !force) {
|
|
578
598
|
const blocked = await checkWorkingTree(siteDir, args)
|
|
579
599
|
if (blocked) return blocked
|
|
@@ -887,6 +907,8 @@ export async function pull(args = [], deps = {}) {
|
|
|
887
907
|
base = newestBase(repo, [
|
|
888
908
|
historySyncPoint(repo, { origin: client.origin, siteUuid: siteContentUuid }),
|
|
889
909
|
held,
|
|
910
|
+
// What a sync point the branch no longer holds — a merge abandoned — was made onto.
|
|
911
|
+
...madeOnto(repo, held, { origin: client.origin, siteUuid: siteContentUuid }),
|
|
890
912
|
content?.source,
|
|
891
913
|
folderLane?.source
|
|
892
914
|
])
|
|
@@ -1114,13 +1136,12 @@ export async function pull(args = [], deps = {}) {
|
|
|
1114
1136
|
siteUuid: siteContentUuid,
|
|
1115
1137
|
always: force || !base
|
|
1116
1138
|
})
|
|
1117
|
-
standIn.close()
|
|
1118
|
-
standIn = null
|
|
1119
1139
|
if (!made.commit) {
|
|
1120
1140
|
// The backend holds what the base holds: nothing to merge.
|
|
1121
1141
|
writeSyncPoint(siteDir, client.origin, base)
|
|
1122
1142
|
} else {
|
|
1123
|
-
|
|
1143
|
+
// The files as they are, to tell what the pull changes in them from what they held.
|
|
1144
|
+
const files = snapshotFiles(repo)
|
|
1124
1145
|
const result = force
|
|
1125
1146
|
? takeSyncPoint(repo, made.commit, { roots, message: takenFrom(client.origin) })
|
|
1126
1147
|
: mergeSyncPoint(repo, made.commit, { message: mergedFrom(client.origin) })
|
|
@@ -1138,15 +1159,40 @@ export async function pull(args = [], deps = {}) {
|
|
|
1138
1159
|
} else if (result.reason === 'merge-in-progress') {
|
|
1139
1160
|
error('A merge is in progress here.')
|
|
1140
1161
|
note('Finish it (`git commit`) or abandon it (`git merge --abort`), then pull.')
|
|
1162
|
+
} else if (result.reason === 'unmerged') {
|
|
1163
|
+
error(`${result.paths.length} file(s) here are in conflict:`)
|
|
1164
|
+
for (const p of result.paths.slice(0, 12)) note(` ${p}`)
|
|
1165
|
+
note('Settle them first — `git status` says how — then pull.')
|
|
1141
1166
|
} else {
|
|
1142
1167
|
error("Could not merge the backend's content:")
|
|
1143
1168
|
for (const line of String(result.detail).split('\n').slice(0, 8)) note(` ${line}`)
|
|
1144
1169
|
}
|
|
1145
1170
|
} else {
|
|
1146
1171
|
merge.conflicted = result.conflicted || []
|
|
1172
|
+
merge.stashKept = Boolean(result.stashKept)
|
|
1173
|
+
merge.noBase = !force && !base
|
|
1174
|
+
merge.changed = changedInFiles(repo, {
|
|
1175
|
+
from: repo.head,
|
|
1176
|
+
files,
|
|
1177
|
+
staged: merge.conflicted.length > 0 && !merge.stashKept,
|
|
1178
|
+
skip: merge.conflicted
|
|
1179
|
+
})
|
|
1147
1180
|
writeSyncPoint(siteDir, client.origin, made.commit)
|
|
1148
1181
|
}
|
|
1149
1182
|
}
|
|
1183
|
+
// ⭐ What this pull wrote where the repository ignores it travels in no commit — media
|
|
1184
|
+
// in a folder kept out of git — so it is placed where the files lack it, once the
|
|
1185
|
+
// content is taken. Never over a file they have.
|
|
1186
|
+
if (!notTaken) {
|
|
1187
|
+
const placed = standIn.placeIgnored()
|
|
1188
|
+
if (placed.length) {
|
|
1189
|
+
note(`Placed ${placed.length} file(s) the repository ignores, which no commit carries:`)
|
|
1190
|
+
for (const p of placed.slice(0, 12)) note(` ${p}`)
|
|
1191
|
+
if (placed.length > 12) note(` … and ${placed.length - 12} more`)
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
standIn.close()
|
|
1195
|
+
standIn = null
|
|
1150
1196
|
}
|
|
1151
1197
|
} finally {
|
|
1152
1198
|
standIn?.close()
|
|
@@ -1190,19 +1236,36 @@ export async function pull(args = [], deps = {}) {
|
|
|
1190
1236
|
if (notTaken || recordsNotPlaced) info(summary)
|
|
1191
1237
|
else success(summary)
|
|
1192
1238
|
if (repo && !notTaken) {
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1239
|
+
// ⭐ What the pull changed in the files — not the backend's content against the base,
|
|
1240
|
+
// which also lists what the files already held (`changedInFiles`).
|
|
1241
|
+
if (projected && merge.changed.length) {
|
|
1242
|
+
info(
|
|
1243
|
+
force
|
|
1244
|
+
? `Took ${client.origin}'s version of ${merge.changed.length} file(s):`
|
|
1245
|
+
: `Merged ${merge.changed.length} change(s) from ${client.origin}:`
|
|
1246
|
+
)
|
|
1198
1247
|
const label = { A: 'added', M: 'changed', D: 'removed', R: 'renamed' }
|
|
1199
1248
|
for (const [s, p] of merge.changed.slice(0, 12)) note(` ${label[s] || s} ${p}`)
|
|
1200
1249
|
if (merge.changed.length > 12) note(` … and ${merge.changed.length - 12} more`)
|
|
1250
|
+
if (force) note('What they held is in the commit before this one (`git diff HEAD^1 HEAD`), unless it was never committed.')
|
|
1251
|
+
} else if (!merge.conflicted.length) {
|
|
1252
|
+
note(`Your files already hold what ${client.origin} has.`)
|
|
1201
1253
|
}
|
|
1202
1254
|
if (merge.conflicted.length) {
|
|
1203
1255
|
error(`${merge.conflicted.length} file(s) have conflicts to resolve:`)
|
|
1204
1256
|
for (const f of merge.conflicted) note(` ${f}`)
|
|
1205
|
-
|
|
1257
|
+
if (merge.stashKept) {
|
|
1258
|
+
// The merge is made; what clashed is the uncommitted work put back over it.
|
|
1259
|
+
note('Your uncommitted changes there overlap what the pull brought.')
|
|
1260
|
+
note('Settle the markers and `git add` each file, then `git stash drop` — git kept those changes in the stash as well.')
|
|
1261
|
+
} else {
|
|
1262
|
+
if (merge.noBase) {
|
|
1263
|
+
note(`No commit in this history is one ${client.origin}'s content came from, so nothing tells`)
|
|
1264
|
+
note("your changes from the backend's: each file where they differ is a conflict, yours against the backend's.")
|
|
1265
|
+
note('Once this merge is committed, later pulls merge from it.')
|
|
1266
|
+
}
|
|
1267
|
+
note('Settle them and `git add` each, then `git commit` to finish the merge — your uncommitted changes come back with it.')
|
|
1268
|
+
}
|
|
1206
1269
|
}
|
|
1207
1270
|
}
|
|
1208
1271
|
// 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
|
@@ -52,6 +52,7 @@ import {
|
|
|
52
52
|
pullRemote,
|
|
53
53
|
headProvenance
|
|
54
54
|
} from '../utils/git.js'
|
|
55
|
+
import { ignoringRepo } from '../utils/sync-point.js'
|
|
55
56
|
import { probeUnpushed } from '../backend/site-sync.js'
|
|
56
57
|
import { resolveBackendOrigin } from '../backend/client.js'
|
|
57
58
|
import { readBackendState } from '@uniweb/build/uwx'
|
|
@@ -124,20 +125,25 @@ export async function refresh(args = [], deps = {}) {
|
|
|
124
125
|
// when they are not.
|
|
125
126
|
const consulted = []
|
|
126
127
|
const skipped = []
|
|
128
|
+
// Said at the end — and before stopping on a failure, which can follow a step skipped.
|
|
129
|
+
const tell = () => {
|
|
130
|
+
if (consulted.length) say.dim(`Checked: ${consulted.join(', ')}`)
|
|
131
|
+
for (const s of skipped) say.dim(`Skipped: ${s}`)
|
|
132
|
+
}
|
|
127
133
|
let conflicts = 0
|
|
128
134
|
|
|
129
135
|
// ── 1. the git remote ─────────────────────────────────────────────────────
|
|
130
136
|
if (skipGit) {
|
|
131
137
|
skipped.push('git (--no-git)')
|
|
132
138
|
} else if (!isGitRepo(siteDir)) {
|
|
133
|
-
skipped.push('git (not a repository)')
|
|
139
|
+
skipped.push(ignoringRepo(siteDir) ? 'git (the repository around this folder ignores it)' : 'git (not a repository)')
|
|
134
140
|
} else if (!hasRemote(siteDir)) {
|
|
135
141
|
skipped.push('git (no remote configured)')
|
|
136
142
|
} else {
|
|
137
143
|
say.info('Pulling from the git remote…')
|
|
138
144
|
const r = pullRemote(siteDir)
|
|
139
145
|
if (!r.ok) {
|
|
140
|
-
say.err('git pull failed — resolve this before continuing.')
|
|
146
|
+
say.err(r.conflicts ? 'git pull left conflicts — settle them before continuing.' : 'git pull failed — resolve this before continuing.')
|
|
141
147
|
for (const line of r.message.split('\n').slice(0, 6)) say.dim(line)
|
|
142
148
|
// Deliberately stop. Layering the backend's content on top of an unresolved
|
|
143
149
|
// git state gives the user two independent conflicts at once, and no clear
|
|
@@ -173,6 +179,7 @@ export async function refresh(args = [], deps = {}) {
|
|
|
173
179
|
// Failed for a reason other than conflicts — say so plainly rather than
|
|
174
180
|
// reporting a clean refresh over a lane that never ran.
|
|
175
181
|
say.err('Could not merge the backend content.')
|
|
182
|
+
tell()
|
|
176
183
|
return { exitCode: res.exitCode }
|
|
177
184
|
}
|
|
178
185
|
consulted.push('backend')
|
|
@@ -180,8 +187,7 @@ export async function refresh(args = [], deps = {}) {
|
|
|
180
187
|
|
|
181
188
|
// ── 3. where that leaves you ──────────────────────────────────────────────
|
|
182
189
|
console.log('')
|
|
183
|
-
|
|
184
|
-
for (const s of skipped) say.dim(`Skipped: ${s}`)
|
|
190
|
+
tell()
|
|
185
191
|
|
|
186
192
|
const git = headProvenance(siteDir)
|
|
187
193
|
if (git)
|
package/src/framework-index.json
CHANGED
|
@@ -1,21 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"generatedAt": "2026-10-
|
|
3
|
+
"generatedAt": "2026-10-18T05:12:16.689Z",
|
|
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.11"
|
|
10
10
|
},
|
|
11
11
|
"@uniweb/content-reader": {
|
|
12
|
-
"version": "1.4.
|
|
12
|
+
"version": "1.4.1"
|
|
13
13
|
},
|
|
14
14
|
"@uniweb/content-writer": {
|
|
15
|
-
"version": "1.
|
|
15
|
+
"version": "1.2.0"
|
|
16
16
|
},
|
|
17
17
|
"@uniweb/core": {
|
|
18
|
-
"version": "1.2.
|
|
18
|
+
"version": "1.2.3"
|
|
19
19
|
},
|
|
20
20
|
"@uniweb/frame-bridge": {
|
|
21
21
|
"version": "1.0.2"
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
"version": "1.0.3"
|
|
25
25
|
},
|
|
26
26
|
"@uniweb/kit": {
|
|
27
|
-
"version": "1.
|
|
27
|
+
"version": "1.3.0"
|
|
28
28
|
},
|
|
29
29
|
"@uniweb/loom": {
|
|
30
30
|
"version": "1.1.0"
|
|
@@ -33,22 +33,22 @@
|
|
|
33
33
|
"version": "1.1.1"
|
|
34
34
|
},
|
|
35
35
|
"@uniweb/projections": {
|
|
36
|
-
"version": "1.1.
|
|
36
|
+
"version": "1.1.2"
|
|
37
37
|
},
|
|
38
38
|
"@uniweb/runtime": {
|
|
39
|
-
"version": "1.1.
|
|
39
|
+
"version": "1.1.5"
|
|
40
40
|
},
|
|
41
41
|
"@uniweb/scene": {
|
|
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"
|
|
49
49
|
},
|
|
50
50
|
"@uniweb/semantic-parser": {
|
|
51
|
-
"version": "1.
|
|
51
|
+
"version": "1.9.0"
|
|
52
52
|
},
|
|
53
53
|
"@uniweb/snapshot": {
|
|
54
54
|
"version": "1.1.0"
|
|
@@ -60,7 +60,7 @@
|
|
|
60
60
|
"version": "1.2.0"
|
|
61
61
|
},
|
|
62
62
|
"@uniweb/unipress": {
|
|
63
|
-
"version": "1.1.
|
|
63
|
+
"version": "1.1.6"
|
|
64
64
|
}
|
|
65
65
|
},
|
|
66
66
|
"templates": {
|
package/src/index.js
CHANGED
|
@@ -33,8 +33,6 @@ import prompts from 'prompts'
|
|
|
33
33
|
// Same pattern as `build` and `docs`.
|
|
34
34
|
import { i18n } from './commands/i18n.js'
|
|
35
35
|
import { inspect } from './commands/inspect.js'
|
|
36
|
-
import { invite } from './commands/invite.js'
|
|
37
|
-
import { handoff } from './commands/handoff.js'
|
|
38
36
|
import { update } from './commands/update.js'
|
|
39
37
|
import { clone } from './commands/clone.js'
|
|
40
38
|
import { template } from './commands/template.js'
|
|
@@ -74,6 +72,7 @@ import {
|
|
|
74
72
|
} from './utils/interactive.js'
|
|
75
73
|
import { findWorkspaceRoot } from './utils/workspace.js'
|
|
76
74
|
import { exitWhenDrained } from './utils/exit.js'
|
|
75
|
+
import { isGitRepo } from './utils/git.js'
|
|
77
76
|
|
|
78
77
|
// Colors for terminal output
|
|
79
78
|
const colors = {
|
|
@@ -1009,18 +1008,6 @@ async function main() {
|
|
|
1009
1008
|
return
|
|
1010
1009
|
}
|
|
1011
1010
|
|
|
1012
|
-
// Handle invite command
|
|
1013
|
-
if (command === 'invite') {
|
|
1014
|
-
await invite(args.slice(1))
|
|
1015
|
-
return
|
|
1016
|
-
}
|
|
1017
|
-
|
|
1018
|
-
// Handle handoff command
|
|
1019
|
-
if (command === 'handoff') {
|
|
1020
|
-
await handoff(args.slice(1))
|
|
1021
|
-
return
|
|
1022
|
-
}
|
|
1023
|
-
|
|
1024
1011
|
// Handle families command — the standard section families this CLI ships with
|
|
1025
1012
|
if (command === 'families') {
|
|
1026
1013
|
const { families } = await import('./commands/families.js')
|
|
@@ -1327,17 +1314,10 @@ async function main() {
|
|
|
1327
1314
|
|
|
1328
1315
|
// Initialize git repository
|
|
1329
1316
|
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
|
-
}
|
|
1317
|
+
// Skip git init if a repository already holds the project (common for
|
|
1318
|
+
// monorepos/workspaces). ⛔ Not one that ignores its folder: that repository can never
|
|
1319
|
+
// hold its history, so the project gets its own (`isGitRepo`, the one rule).
|
|
1320
|
+
const insideGitRepo = isGitRepo(projectDir)
|
|
1341
1321
|
|
|
1342
1322
|
if (insideGitRepo) {
|
|
1343
1323
|
log(
|
|
@@ -1932,18 +1912,6 @@ Record files keep their \`$uuid\`: it is the record's own id, not a backend's.
|
|
|
1932
1912
|
|
|
1933
1913
|
To duplicate a project:
|
|
1934
1914
|
cp -r my-site my-site-2 && cd my-site-2 && uniweb forget --all
|
|
1935
|
-
`,
|
|
1936
|
-
invite: `
|
|
1937
|
-
${colors.cyan}${colors.bright}uniweb invite${colors.reset} ${colors.dim}— (reserved; not available on the new backend yet)${colors.reset}
|
|
1938
|
-
|
|
1939
|
-
The foundation client-invite flow was retired with the legacy backend.
|
|
1940
|
-
Invite clients to your foundation from the Uniweb app for now.
|
|
1941
|
-
`,
|
|
1942
|
-
handoff: `
|
|
1943
|
-
${colors.cyan}${colors.bright}uniweb handoff${colors.reset} ${colors.dim}— (reserved; not available on the new backend yet)${colors.reset}
|
|
1944
|
-
|
|
1945
|
-
The site-handoff flow was retired with the legacy backend.
|
|
1946
|
-
Manage client sites from the Uniweb app for now.
|
|
1947
1915
|
`,
|
|
1948
1916
|
families: `
|
|
1949
1917
|
${colors.cyan}${colors.bright}uniweb families${colors.reset} ${colors.dim}— The standard section families${colors.reset}
|
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),
|
|
@@ -14,9 +14,9 @@
|
|
|
14
14
|
* foundation — `uniweb dev` with `foundation:` naming a sibling package. There,
|
|
15
15
|
* the site imports `#foundation`, which `build/src/site/config.js` aliases to
|
|
16
16
|
* the foundation's PACKAGE NAME, so Vite resolves it through node_modules. A
|
|
17
|
-
* linked site (registry ref or URL) never takes that path, and neither does
|
|
18
|
-
*
|
|
19
|
-
*
|
|
17
|
+
* linked site (registry ref or URL) never takes that path, and neither does
|
|
18
|
+
* unipress or the edge — they load a built `dist/entry.js`, or read a content
|
|
19
|
+
* folder directly. The check stays silent for all of them
|
|
20
20
|
* because there is no node_modules entry to look at.
|
|
21
21
|
*
|
|
22
22
|
* What goes wrong in that one modality: a `file:` dependency can be satisfied
|
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,31 @@ export function repoOf(siteDir) {
|
|
|
91
107
|
return { top, prefix, path: prefix ? prefix.replace(/\/$/, '') : '.', gitDir, head }
|
|
92
108
|
}
|
|
93
109
|
|
|
110
|
+
/**
|
|
111
|
+
* The top of the repository around `dir` when that repository ignores it — and so holds none
|
|
112
|
+
* of it — or null. For what is said to a person: "not in a repository" is not what they see
|
|
113
|
+
* when one is right there.
|
|
114
|
+
*/
|
|
115
|
+
export function ignoringRepo(dir) {
|
|
116
|
+
try {
|
|
117
|
+
const top = gitText(dir, ['rev-parse', '--show-toplevel']).trim()
|
|
118
|
+
const prefix = gitText(dir, ['rev-parse', '--show-prefix']).trim()
|
|
119
|
+
return top && prefix && ignoredHere(dir) ? top : null
|
|
120
|
+
} catch {
|
|
121
|
+
return null
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Does the repository around `dir` ignore it? `git check-ignore` exits 0 for yes, 1 for no. */
|
|
126
|
+
function ignoredHere(dir) {
|
|
127
|
+
try {
|
|
128
|
+
git(dir, ['check-ignore', '-q', '.'])
|
|
129
|
+
return true
|
|
130
|
+
} catch {
|
|
131
|
+
return false
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
94
135
|
/** Is `ancestor` in the history of `of` — or `of` itself? */
|
|
95
136
|
export function isAncestor(repo, ancestor, of = 'HEAD') {
|
|
96
137
|
try {
|
|
@@ -120,6 +161,15 @@ export function siteTreeIn(repo, rev) {
|
|
|
120
161
|
}
|
|
121
162
|
}
|
|
122
163
|
|
|
164
|
+
/** The paths in conflict here, whatever left them — a merge, or a stash put back over one. */
|
|
165
|
+
export function unmergedPaths(repo) {
|
|
166
|
+
try {
|
|
167
|
+
return zlist(git(repo.top, ['diff', '--name-only', '-z', '--diff-filter=U']))
|
|
168
|
+
} catch {
|
|
169
|
+
return []
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
123
173
|
/** Is a merge waiting for its commit here? */
|
|
124
174
|
export function mergeInProgress(repo) {
|
|
125
175
|
try {
|
|
@@ -171,17 +221,64 @@ export function historySyncPoint(repo, { origin, siteUuid }) {
|
|
|
171
221
|
for (const record of out.split('\x1e')) {
|
|
172
222
|
const [sha = '', body = ''] = record.replace(/^\s+/, '').split('\x1f')
|
|
173
223
|
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
|
|
224
|
+
if (marks(repo, sha, body, { origin, siteUuid })) return sha
|
|
177
225
|
}
|
|
178
226
|
return null
|
|
179
227
|
}
|
|
180
228
|
|
|
229
|
+
// Does this commit's message mark it a sync point of this site and backend — and does it
|
|
230
|
+
// still hold the site tree its trailer names?
|
|
231
|
+
function marks(repo, sha, body, { origin, siteUuid }) {
|
|
232
|
+
const t = trailersOf(body)
|
|
233
|
+
if (t.origin !== origin || t.siteUuid !== siteUuid || t.path !== repo.path) return false
|
|
234
|
+
return siteTreeIn(repo, sha) === t.tree
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* The commits a sync point HEAD does not hold still vouches for: the one it was made onto
|
|
239
|
+
* — its first parent — and that one's, for as long as each is a sync point of this site
|
|
240
|
+
* HEAD does not hold either.
|
|
241
|
+
*
|
|
242
|
+
* ⛔ A merge abandoned (`git merge --abort`) leaves this copy's sync point out of the
|
|
243
|
+
* history, while the commit it was made onto is still there — and still a state the
|
|
244
|
+
* backend's content descends from. Until 2026-10-17 it was forgotten with the merge, and
|
|
245
|
+
* the next pull built on an older sync point, or on none: measured against a real
|
|
246
|
+
* backend, it conflicted on a section nobody had touched since the commit last pushed from.
|
|
247
|
+
*/
|
|
248
|
+
export function madeOnto(repo, sha, { origin, siteUuid }) {
|
|
249
|
+
const out = []
|
|
250
|
+
let at = sha
|
|
251
|
+
for (let n = 0; n < 16 && isCommit(repo, at) && !isAncestor(repo, at); n++) {
|
|
252
|
+
let body
|
|
253
|
+
try {
|
|
254
|
+
body = gitText(repo.top, ['log', '-1', '--format=%B', at])
|
|
255
|
+
} catch {
|
|
256
|
+
break
|
|
257
|
+
}
|
|
258
|
+
if (!marks(repo, at, body, { origin, siteUuid })) break
|
|
259
|
+
let parent = null
|
|
260
|
+
try {
|
|
261
|
+
parent = gitText(repo.top, ['rev-parse', '--verify', '-q', `${at}^1`]).trim() || null
|
|
262
|
+
} catch {
|
|
263
|
+
// Made onto nothing — a history with no sync point.
|
|
264
|
+
}
|
|
265
|
+
if (!parent) break
|
|
266
|
+
out.push(parent)
|
|
267
|
+
at = parent
|
|
268
|
+
}
|
|
269
|
+
return out
|
|
270
|
+
}
|
|
271
|
+
|
|
181
272
|
/**
|
|
182
273
|
* 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
|
-
*
|
|
274
|
+
* descends from all the others is the newest; with none, the most recently committed.
|
|
275
|
+
*
|
|
276
|
+
* ⚖️ Any of them is a state both sides descend from, but only the newest such state — the
|
|
277
|
+
* commit the backend's content last came from — merges exactly. An older one costs a
|
|
278
|
+
* conflict wherever one side changed what the other changed after it, and it silently
|
|
279
|
+
* undoes a change made after it and then changed BACK on one side: the base already
|
|
280
|
+
* reads the old text, so the change-back looks like no change at all. ⛔ Until
|
|
281
|
+
* 2026-10-17 this said an older base "costs precision at worst".
|
|
185
282
|
*/
|
|
186
283
|
export function newestBase(repo, candidates) {
|
|
187
284
|
const shas = [...new Set(candidates.filter(Boolean))].filter((s) => isCommit(repo, s) && isAncestor(repo, s))
|
|
@@ -210,12 +307,16 @@ export function newestBase(repo, candidates) {
|
|
|
210
307
|
* ⛔ CLOSE IT. `close()` unlinks every link it made before removing the rest — a recursive
|
|
211
308
|
* remove that followed one would delete the project's own files.
|
|
212
309
|
*
|
|
310
|
+
* ⚖️ What is written under the roots where the repository ignores it — media in a folder
|
|
311
|
+
* kept out of git — is in no commit, so no merge brings it. `placeIgnored()` copies it
|
|
312
|
+
* into the project, where the project lacks it.
|
|
313
|
+
*
|
|
213
314
|
* @param {object} repo - `repoOf`
|
|
214
315
|
* @param {object} opts
|
|
215
316
|
* @param {string[]} opts.roots - the site's content roots, relative to the site
|
|
216
317
|
* @param {string|null} opts.rev - the commit whose content the roots are filled from;
|
|
217
318
|
* null for none
|
|
218
|
-
* @returns {{ siteDir: string, commit: Function, close: Function }}
|
|
319
|
+
* @returns {{ siteDir: string, commit: Function, placeIgnored: Function, close: Function }}
|
|
219
320
|
*/
|
|
220
321
|
export function openStandIn(repo, { roots, rev }) {
|
|
221
322
|
for (const r of roots) {
|
|
@@ -329,7 +430,7 @@ export function openStandIn(repo, { roots, rev }) {
|
|
|
329
430
|
* @returns {{ commit: string|null, tree: string, siteTree: string|null }}
|
|
330
431
|
*/
|
|
331
432
|
const commit = ({ parent, subject, origin, siteUuid, always = false }) => {
|
|
332
|
-
const staged = full.filter((f) => present.includes(f) || existsSync(join(root, f)))
|
|
433
|
+
const staged = held(full.filter((f) => present.includes(f) || existsSync(join(root, f))))
|
|
333
434
|
if (staged.length) at(['add', '-A', '--', ...staged])
|
|
334
435
|
const tree = at(['write-tree']).toString('utf8').trim()
|
|
335
436
|
const before = rev ? gitText(repo.top, ['rev-parse', `${rev}^{tree}`]).trim() : null
|
|
@@ -356,7 +457,40 @@ export function openStandIn(repo, { roots, rev }) {
|
|
|
356
457
|
return { commit: sha, tree, siteTree }
|
|
357
458
|
}
|
|
358
459
|
|
|
359
|
-
|
|
460
|
+
/**
|
|
461
|
+
* Copy into the project what the stand-in holds under the roots where the repository
|
|
462
|
+
* ignores it, wherever the project lacks it. ⛔ Never over a file the project has:
|
|
463
|
+
* with no history there, nothing tells its version from the author's work.
|
|
464
|
+
* @returns {string[]} the paths placed, relative to the site
|
|
465
|
+
*/
|
|
466
|
+
const placeIgnored = () => {
|
|
467
|
+
const roots = full.filter((f) => existsSync(join(root, f)))
|
|
468
|
+
if (!roots.length) return []
|
|
469
|
+
const placed = []
|
|
470
|
+
for (const p of zlist(at(['ls-files', '--others', '--ignored', '--exclude-standard', '-z', '--', ...roots]))) {
|
|
471
|
+
const to = join(repo.top, p)
|
|
472
|
+
if (occupied(to)) continue
|
|
473
|
+
mkdirSync(dirname(to), { recursive: true })
|
|
474
|
+
copyFileSync(join(root, p), to)
|
|
475
|
+
placed.push(sitePrefix && p.startsWith(sitePrefix) ? p.slice(sitePrefix.length) : p)
|
|
476
|
+
}
|
|
477
|
+
return placed
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// The roots git can hold here. ⛔ One the repository ignores whole is not named to
|
|
481
|
+
// `git add`, which refuses an ignored path; what is written under it is placed instead.
|
|
482
|
+
const held = (paths) => {
|
|
483
|
+
if (!paths.length) return paths
|
|
484
|
+
let ignored = []
|
|
485
|
+
try {
|
|
486
|
+
ignored = zlist(at(['check-ignore', '-z', '--stdin'], { input: `${paths.join('\0')}\0` }))
|
|
487
|
+
} catch (err) {
|
|
488
|
+
if (err?.status !== 1) throw err // 1: none of them is ignored
|
|
489
|
+
}
|
|
490
|
+
return paths.filter((p) => !ignored.includes(p))
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
return { siteDir, commit, placeIgnored, close }
|
|
360
494
|
} catch (err) {
|
|
361
495
|
close()
|
|
362
496
|
throw err
|
|
@@ -366,6 +500,15 @@ export function openStandIn(repo, { roots, rev }) {
|
|
|
366
500
|
// ── merging ─────────────────────────────────────────────────────────────────
|
|
367
501
|
|
|
368
502
|
const zlist = (buf) => buf.toString('utf8').split('\0').filter(Boolean)
|
|
503
|
+
// Something is at `path` — a dangling link included, which `existsSync` reads as nothing.
|
|
504
|
+
const occupied = (path) => {
|
|
505
|
+
try {
|
|
506
|
+
lstatSync(path)
|
|
507
|
+
return true
|
|
508
|
+
} catch {
|
|
509
|
+
return false
|
|
510
|
+
}
|
|
511
|
+
}
|
|
369
512
|
|
|
370
513
|
/**
|
|
371
514
|
* Files not under git that the merge would write over: their paths, each with whether the
|
|
@@ -404,6 +547,9 @@ function untrackedInTheWay(repo, commit) {
|
|
|
404
547
|
*/
|
|
405
548
|
export function mergeSyncPoint(repo, commit, { message }) {
|
|
406
549
|
if (mergeInProgress(repo)) return { ok: false, reason: 'merge-in-progress' }
|
|
550
|
+
// Conflicts already here are not this merge's: git would refuse it, and they would read as its own.
|
|
551
|
+
const unsettled = unmergedPaths(repo)
|
|
552
|
+
if (unsettled.length) return { ok: false, reason: 'unmerged', paths: unsettled }
|
|
407
553
|
const inTheWay = untrackedInTheWay(repo, commit)
|
|
408
554
|
const blocking = inTheWay.filter((u) => !u.same).map((u) => u.path)
|
|
409
555
|
if (blocking.length) return { ok: false, reason: 'untracked', paths: blocking }
|
|
@@ -412,7 +558,13 @@ export function mergeSyncPoint(repo, commit, { message }) {
|
|
|
412
558
|
for (const u of inTheWay) rmSync(join(repo.top, u.path))
|
|
413
559
|
try {
|
|
414
560
|
git(repo.top, ['merge', '--autostash', '--no-edit', '--no-verify', '--allow-unrelated-histories', '-m', message, commit])
|
|
415
|
-
|
|
561
|
+
// ⛔ THE MERGE IS MADE, AND IT MAY STILL HAVE LEFT CONFLICTS. `--autostash` exits 0 when
|
|
562
|
+
// the uncommitted work it set aside cannot be put back over what the merge brought: the
|
|
563
|
+
// files then hold conflict markers, unmerged, and git keeps that work in the stash too.
|
|
564
|
+
// Until 2026-10-17 that read as a clean merge — a pull that exited 0 over the markers,
|
|
565
|
+
// so `uniweb pull && uniweb push` would send them.
|
|
566
|
+
const unstashed = unmergedPaths(repo)
|
|
567
|
+
return { ok: true, conflicted: unstashed, stashKept: unstashed.length > 0 }
|
|
416
568
|
} catch (err) {
|
|
417
569
|
const conflicted = zlist(git(repo.top, ['diff', '--name-only', '-z', '--diff-filter=U']))
|
|
418
570
|
if (conflicted.length) return { ok: true, conflicted }
|
|
@@ -444,26 +596,70 @@ export function takeSyncPoint(repo, commit, { roots, message }) {
|
|
|
444
596
|
}
|
|
445
597
|
}
|
|
446
598
|
|
|
447
|
-
/**
|
|
448
|
-
|
|
599
|
+
/**
|
|
600
|
+
* What a pull changed in the files, under the site: `[status, path]` pairs — what the
|
|
601
|
+
* merge, or the take, moved from `from` (HEAD before it), less what the files already
|
|
602
|
+
* held as it left them. `files` is the files before it (`snapshotFiles`). `staged` reads a
|
|
603
|
+
* merge stopped on conflicts from the index; `skip` leaves out the paths in conflict,
|
|
604
|
+
* which are reported as conflicts.
|
|
605
|
+
*
|
|
606
|
+
* ⛔ Until 2026-10-17 a pull listed the backend's content against the base, as "changes
|
|
607
|
+
* from the backend": a file a push had already written into the files read as one, so
|
|
608
|
+
* did a person's own edit when there was no base — and `--force` said it "took" a change
|
|
609
|
+
* where it undid their edit.
|
|
610
|
+
*/
|
|
611
|
+
export function changedInFiles(repo, { from, files, staged = false, skip = [] }) {
|
|
612
|
+
const scope = ['--', ...(repo.prefix ? [repo.prefix] : [])]
|
|
449
613
|
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 = []
|
|
614
|
+
const parts = zlist(git(repo.top, ['diff', '--name-status', '-z', ...(staged ? ['--cached', from] : [from, 'HEAD']), ...scope]))
|
|
615
|
+
const moved = []
|
|
455
616
|
for (let i = 0; i < parts.length; ) {
|
|
456
617
|
const status = parts[i++]
|
|
457
618
|
const path = parts[i++]
|
|
458
|
-
|
|
459
|
-
else changes.push([status, path])
|
|
619
|
+
moved.push(/^[RC]/.test(status) ? [status[0], parts[i++]] : [status, path])
|
|
460
620
|
}
|
|
461
|
-
|
|
621
|
+
// What the files already held as the merge left it is no change to them.
|
|
622
|
+
const differs = new Set(
|
|
623
|
+
zlist(git(repo.top, ['diff', '--name-only', '-z', '--no-renames', ...(staged ? ['--cached', files] : [files, 'HEAD']), ...scope]))
|
|
624
|
+
)
|
|
625
|
+
return moved
|
|
626
|
+
.filter(([s, p]) => s !== 'U' && !skip.includes(p) && differs.has(p))
|
|
627
|
+
.map(([s, p]) => [s, repo.prefix && p.startsWith(repo.prefix) ? p.slice(repo.prefix.length) : p])
|
|
462
628
|
} catch {
|
|
463
629
|
return []
|
|
464
630
|
}
|
|
465
631
|
}
|
|
466
632
|
|
|
633
|
+
/**
|
|
634
|
+
* The files as they are, as a commit to compare against — what is tracked, committed or
|
|
635
|
+
* not (`git stash create`, which touches neither the files nor any ref); HEAD when nothing
|
|
636
|
+
* is uncommitted.
|
|
637
|
+
*/
|
|
638
|
+
export function snapshotFiles(repo) {
|
|
639
|
+
try {
|
|
640
|
+
return (
|
|
641
|
+
gitText(repo.top, ['stash', 'create'], {
|
|
642
|
+
env: { GIT_AUTHOR_NAME: 'uniweb', GIT_AUTHOR_EMAIL: 'sync@uniweb', GIT_COMMITTER_NAME: 'uniweb', GIT_COMMITTER_EMAIL: 'sync@uniweb' }
|
|
643
|
+
}).trim() || repo.head
|
|
644
|
+
)
|
|
645
|
+
} catch {
|
|
646
|
+
return repo.head
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
/**
|
|
651
|
+
* Does HEAD hold the site's content as the files do — nothing staged, changed or untracked
|
|
652
|
+
* under the roots? (What the repository ignores is no change.)
|
|
653
|
+
*/
|
|
654
|
+
export function contentCommitted(repo, roots) {
|
|
655
|
+
try {
|
|
656
|
+
const full = roots.map((r) => `${repo.prefix}${r}`)
|
|
657
|
+
return git(repo.top, ['status', '--porcelain', '-z', '--untracked-files=all', '--', ...full]).length === 0
|
|
658
|
+
} catch {
|
|
659
|
+
return false
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
467
663
|
// ── this copy's own sync point ──────────────────────────────────────────────
|
|
468
664
|
|
|
469
665
|
const cachePath = (siteDir) => join(siteDir, '.uniweb', 'backend-cache.json')
|
package/src/commands/handoff.js
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* handoff — RESERVED (not available on the new backend yet).
|
|
3
|
-
*
|
|
4
|
-
* The "create a site and hand it off to a client" flow ran against the legacy
|
|
5
|
-
* platform (the PHP backend + Cloudflare Worker) the CLI no longer talks to. A
|
|
6
|
-
* new-backend CLI equivalent isn't built; the command name is kept reserved so a
|
|
7
|
-
* future rebuild can claim it without a breaking change.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
export async function handoff() {
|
|
11
|
-
console.error(
|
|
12
|
-
"\x1b[31m✗\x1b[0m `uniweb handoff` isn't available on the new backend yet."
|
|
13
|
-
)
|
|
14
|
-
console.error(
|
|
15
|
-
' The legacy site-handoff flow was retired with the PHP backend.'
|
|
16
|
-
)
|
|
17
|
-
console.error(' Manage client sites from the Uniweb app for now.')
|
|
18
|
-
process.exit(1)
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export default handoff
|
package/src/commands/invite.js
DELETED
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* invite — RESERVED (not available on the new backend yet).
|
|
3
|
-
*
|
|
4
|
-
* The foundation client-invite flow ran against the legacy platform (the PHP
|
|
5
|
-
* backend + Cloudflare Worker) the CLI no longer talks to. Client onboarding is
|
|
6
|
-
* handled in the Uniweb app for now; a new-backend CLI equivalent isn't built.
|
|
7
|
-
* The command name is kept reserved so a future rebuild can claim it without a
|
|
8
|
-
* breaking change.
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
export async function invite() {
|
|
12
|
-
console.error(
|
|
13
|
-
"\x1b[31m✗\x1b[0m `uniweb invite` isn't available on the new backend yet."
|
|
14
|
-
)
|
|
15
|
-
console.error(
|
|
16
|
-
' The legacy client-invite flow was retired with the PHP backend.'
|
|
17
|
-
)
|
|
18
|
-
console.error(
|
|
19
|
-
' Invite clients to your foundation from the Uniweb app for now.'
|
|
20
|
-
)
|
|
21
|
-
process.exit(1)
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export default invite
|