uniweb 1.7.4 → 1.8.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 CHANGED
@@ -340,7 +340,7 @@ This is the best path when site content has a life independent of the foundation
340
340
 
341
341
  ### Hybrid
342
342
 
343
- Markdown in a git repo and content in the Uniweb apps can share the same site. Directional sync is available today: `uniweb push` sends local content to the backend, `uniweb pull` brings the backend's content back to local files, and `uniweb clone` bootstraps a local project from a site that already lives in the backend. Authors edit visually, devs edit in their IDE, both surfaces work on the same content. The advanced two-way-merge experience — conflict resolution, branch isolation, and review flows when both surfaces change the same content concurrently — is still evolving.
343
+ Markdown in a git repo and content in the Uniweb apps can share the same site. `uniweb push` sends local content to the backend, `uniweb pull` brings the backend's content back — in a git repository by merging it into your branch, like `git pull`, so edits made on both surfaces at once are combined and only a genuine overlap is a conflict to settle — and `uniweb clone` bootstraps a local project from a site that already lives in the backend. Authors edit visually, devs edit in their IDE, both surfaces work on the same content. Review flows and branch isolation across the two are still evolving.
344
344
 
345
345
  ### Commands at a glance
346
346
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uniweb",
3
- "version": "1.7.4",
3
+ "version": "1.8.0",
4
4
  "description": "Create structured Vite + React sites with content/code separation",
5
5
  "type": "module",
6
6
  "bin": {
@@ -42,14 +42,14 @@
42
42
  "prompts": "^2.4.2",
43
43
  "tar": "^7.0.0",
44
44
  "@uniweb/core": "~1.2.2",
45
+ "@uniweb/semantic-parser": "~1.8.1",
45
46
  "@uniweb/content-writer": "~1.1.2",
46
- "@uniweb/schemas": "~1.3.1",
47
- "@uniweb/semantic-parser": "~1.8.1"
47
+ "@uniweb/schemas": "~1.3.1"
48
48
  },
49
49
  "peerDependencies": {
50
50
  "@uniweb/semantic-parser": "~1.8.1",
51
- "@uniweb/content-reader": "~1.4.0",
52
- "@uniweb/build": "~1.3.9"
51
+ "@uniweb/build": "~1.3.9",
52
+ "@uniweb/content-reader": "~1.4.0"
53
53
  },
54
54
  "peerDependenciesMeta": {
55
55
  "@uniweb/build": {
@@ -2850,7 +2850,7 @@ Foundations have their own free path too: `uniweb add ci --target foundation` pu
2850
2850
  - **Git is the reviewed, durable record** — you `pull` content back, read it with `git diff`, and commit.
2851
2851
  - **Authors never push or pull.** For them, content simply updates, whether the change came from another author or from a developer's CLI.
2852
2852
 
2853
- **Nobody's work is overwritten without asking.** `uniweb push` is refused if the site changed on the backend since your last pull — usually an author editing in the app — and reports what changed; nothing is written. Edits to different sections never collide. `uniweb pull` overwrites rather than merges, so it refuses while you have uncommitted changes. The recovery for both is `uniweb pull --merge` (or `uniweb refresh`, which runs `git pull` first): changes to different parts of a file combine silently, and a genuine overlap leaves conflict markers and a non-zero exit — so `uniweb pull --merge && uniweb push` never ships markers. `--force` is the deliberate overwrite (on `push` it replaces the backend's changes, on `pull` it discards yours); don't use it to get past a refusal. `uniweb sync` is `refresh` then `push`; neither publishes.
2853
+ **Nobody's work is overwritten without asking.** `uniweb push` is refused if the site changed on the backend since your last pull — usually an author editing in the app — and reports what changed; nothing is written. Edits to different sections never collide. **In a git repository `uniweb pull` merges, like `git pull`:** it commits the backend's content (*"The site's content on …"*) and merges that commit into your branch, against the last state of the site your history shares with the backend. Your work, committed or not, and a teammate's arriving through git are kept; a file the backend never had is never deleted; changes to different parts of a file combine silently; and only a genuine overlap leaves conflict markers and a non-zero exit — so `uniweb pull && uniweb push` never ships markers. Settle a conflict as in any merge, then `git commit`. `uniweb refresh` runs `git pull` first, then the same pull. A push is also refused when your branch does not hold what this copy last took from the backend — after `git merge --abort`, or in a fresh clone that has not pulled — so pull, then push. Outside git, `uniweb pull` takes the backend's version over your files, asking first. `--force` is the deliberate overwrite (on `push` it replaces the backend's changes, on `pull` it discards yours); don't use it to get past a refusal. `uniweb sync` is `refresh` then `push`; neither publishes.
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
 
@@ -2878,7 +2878,7 @@ says everything about its service: a setting you remove from it is removed on th
2878
2878
  removing the entry switches the service off. `uniweb push` and `uniweb publish` send each service
2879
2879
  `site.yml` lists; a service the site has that this copy has never seen (turned on in the app since
2880
2880
  your last pull) is left as it is, and one changed both in the file and on the site since your last
2881
- pull stops the push, naming it — `uniweb pull --merge`, then push. `uniweb pull` writes what the site
2881
+ pull stops the push, naming it — `uniweb pull`, then push. `uniweb pull` writes what the site
2882
2882
  has into `services:` (an off service with no settings only where the file already names it) — pull
2883
2883
  before you edit. A change that needs payment is settled in the app: `publish` opens it. **Everything in an entry but a
2884
2884
  credential is public** — it is built into the site, except `backend`'s settings, which only your host
@@ -121,6 +121,18 @@ export const DISCOVERY_DEFAULTS = {}
121
121
  */
122
122
  export const WORKSPACE_HEADER = 'x-uniweb-workspace'
123
123
 
124
+ /**
125
+ * The header naming where a push's content came from — the commit it was made from — and
126
+ * where a pull's content last came from, as the backend recorded it.
127
+ *
128
+ * ⭐ Opaque to the backend: it stores the value a push sends, with the version that push
129
+ * produced, and returns it on every pull of that entity. A copy whose history holds that
130
+ * commit merges from it (`utils/sync-point.js`) — the precise base, for a teammate or a
131
+ * fresh clone that never saw the push. Absent on a pull, there is simply no such base.
132
+ */
133
+ export const SOURCE_HEADER = 'x-uniweb-source'
134
+ const sourceHeaders = (source) => (source ? { [SOURCE_HEADER]: source } : {})
135
+
124
136
  /**
125
137
  * Whether a request works IN a workspace — a site route, `/dev/site…`.
126
138
  *
@@ -788,29 +800,35 @@ export class BackendClient {
788
800
  })
789
801
  }
790
802
 
791
- /** POST /dev/site/content — CREATE a site from its content lane (.uwx zip). */
792
- async createSiteContent(buffer) {
803
+ /**
804
+ * POST /dev/site/content — CREATE a site from its content lane (.uwx zip). `source`: the
805
+ * commit the content came from (`SOURCE_HEADER`).
806
+ */
807
+ async createSiteContent(buffer, { source } = {}) {
793
808
  return this.request('/dev/site/content', {
794
809
  method: 'POST',
795
810
  body: buffer,
811
+ headers: sourceHeaders(source),
796
812
  query: PUSH_QUERY
797
813
  })
798
814
  }
799
815
 
800
816
  /** POST /dev/site/content/push/{uuid} — UPDATE the content lane by site uuid (.uwx zip). */
801
- async updateSiteContent(uuid, buffer) {
817
+ async updateSiteContent(uuid, buffer, { source } = {}) {
802
818
  return this.request(`/dev/site/content/push/${encodeURIComponent(uuid)}`, {
803
819
  method: 'POST',
804
820
  body: buffer,
821
+ headers: sourceHeaders(source),
805
822
  query: PUSH_QUERY
806
823
  })
807
824
  }
808
825
 
809
826
  /** POST /dev/site/folder/push/{uuid} — push the folder lane, keyed by the site uuid (.uwx zip). */
810
- async pushFolder(uuid, buffer) {
827
+ async pushFolder(uuid, buffer, { source } = {}) {
811
828
  return this.request(`/dev/site/folder/push/${encodeURIComponent(uuid)}`, {
812
829
  method: 'POST',
813
830
  body: buffer,
831
+ headers: sourceHeaders(source),
814
832
  query: PUSH_QUERY
815
833
  })
816
834
  }
@@ -14,8 +14,7 @@
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 { hasUncommittedContent } from '../utils/git.js'
18
- import { recordWritten } from '../utils/pull-written.js'
17
+ import { repoOf, readSyncPoint, writeSyncPoint, isAncestor } from '../utils/sync-point.js'
19
18
  import { humanBytes } from '../utils/bytes.js'
20
19
  import { isAuthoredPreview } from '../utils/preview.js'
21
20
  import {
@@ -301,10 +300,8 @@ export function pulledItemVersions(doc, itemVersions) {
301
300
  // ⭐ A section unit's path names it by its id (`pages/about/about.md`), and the author's
302
301
  // file may carry an ordering prefix or a child mark (`1-about.md`, `@about.md`), which a
303
302
  // pull writes back into in place. So the unit is looked up as a pull finds it
304
- // (`existingSectionFile`). ⛔ Until 2026-10-07 the id path was taken as the file: a push
305
- // recorded no file for a numbered section, and the next `pull --merge` merged against an
306
- // older version — a false conflict on a line only the other side had changed (measured
307
- // on the starter's `1-welcome.md`).
303
+ // (`existingSectionFile`). ⛔ Until 2026-10-07 the id path was taken as the file, which
304
+ // names nothing for a numbered section (measured on the starter's `1-welcome.md`).
308
305
  export function unitFilesOf(siteDir, unitPath) {
309
306
  if (unitPath === 'site.yml') return ['site.yml', 'theme.yml', 'head.html']
310
307
  let paths = {}
@@ -330,24 +327,6 @@ export function unitFilesOf(siteDir, unitPath) {
330
327
  return []
331
328
  }
332
329
 
333
- /**
334
- * Record, as synced, the files of the units a push left on the backend as we sent them.
335
- *
336
- * ⭐ WHAT A PUSH DELIVERED IS THE NEXT MERGE'S ANCESTOR. `pull --merge` finds the
337
- * version a file last synced at by the hash in `pull-written.json`. A pull records what
338
- * it wrote; this records what a push delivered — the units `heldTokens` says the
339
- * backend holds as ours, never one it kept for someone else. Without it, after a merge
340
- * and a push the ancestor was the pull's raw output, which nobody commits: the next
341
- * merge found no common version and put both whole files under conflict markers, for
342
- * edits on different lines (measured 2026-09-23).
343
- */
344
- function recordPushedUnits(siteDir, unitPaths) {
345
- const files = new Set()
346
- for (const p of unitPaths) for (const f of unitFilesOf(siteDir, p)) files.add(f)
347
- const present = [...files].map((f) => join(siteDir, f)).filter((abs) => existsSync(abs))
348
- if (present.length) recordWritten(siteDir, present)
349
- }
350
-
351
330
  // Pull the finalized entities out of the restore response. The backend returns
352
331
  // `{ report: { finalized: [ { index, uuid, changed, document }, … ] } }` — each entry
353
332
  // carries its position in the SUBMITTED sequence (`index`, the correlation key — `$id`
@@ -919,6 +898,54 @@ export function mergeBaseVersions(siteDir, backend, versions) {
919
898
  })
920
899
  }
921
900
 
901
+ /**
902
+ * Why a gated push must not go — or null when it may.
903
+ *
904
+ * ⛔ A PUSH CLAIMS A BASE ONLY WHILE ITS FILES HOLD IT. The versions it sends say "my content
905
+ * descends from the backend's at these versions". In a repository they describe one commit,
906
+ * the sync point this copy's last pull merged or its last push was made from
907
+ * (`utils/sync-point.js`), and they are true of the files only while HEAD's history holds
908
+ * it: a merge abandoned or a branch reset, and the push would put back what the backend's
909
+ * newer content replaced.
910
+ *
911
+ * ⛔ AND A SITE THE BACKEND HAS, PUSHED WITH NO VERSIONS AT ALL, GOES OVER WHATEVER CHANGED
912
+ * THERE — an entry without a token falls back to the `collision` policy, which the client
913
+ * sends as `force`. Until 2026-10-17 that was a fresh clone's first push, or any copy whose
914
+ * cache was gone.
915
+ *
916
+ * A push that creates the site has nothing to be stale against.
917
+ *
918
+ * @returns {null | { headline: string, lines: string[] }}
919
+ */
920
+ export function untakenBase(siteDir, backend) {
921
+ if (!readBackendState(siteDir, backend).site?.uuid) return null
922
+ const held = Object.keys(readBaseVersions(siteDir, backend)).length > 0
923
+ const repo = repoOf(siteDir)
924
+ if (repo?.head) {
925
+ const point = readSyncPoint(siteDir, backend)
926
+ if (held && point && isAncestor(repo, point)) return null
927
+ return {
928
+ headline: "Not pushed — your branch does not hold the backend content this copy last took.",
929
+ lines: [
930
+ point && held
931
+ ? 'The pull or push it came from is not in your history — a merge abandoned, or the branch moved.'
932
+ : 'This copy has not taken the site from this backend yet.',
933
+ "Run `uniweb pull` to merge the backend's content with yours, then push.",
934
+ 'To push over it regardless, re-run with --force (this discards changes made there).'
935
+ ]
936
+ }
937
+ }
938
+ if (held) return null
939
+ return {
940
+ headline: 'Not pushed — this copy holds no record of the backend content it last took.',
941
+ lines: [
942
+ 'A push now would go over whatever changed there.',
943
+ "`uniweb pull` takes the backend's version over your files here, outside git; under git a pull merges instead.",
944
+ 'To push over it regardless, re-run with --force (this discards changes made there).'
945
+ ]
946
+ }
947
+ }
948
+
922
949
  /**
923
950
  * Per-unit content hashes of the last synced state, kept in BOTH representations —
924
951
  * the bases for the file-level attribution shown after a staleness refusal
@@ -1905,6 +1932,12 @@ export async function pushSyncPackages({
1905
1932
  let finalizedTotal = 0
1906
1933
  // Records sent as drafts that the backend stored enabled — reported at the end.
1907
1934
  let draftsNotKept = []
1935
+ // ⭐ THE COMMIT THIS PUSH IS MADE FROM — sent with each lane, so the backend can name it on
1936
+ // a pull, and kept here as this copy's sync point once a lane lands: what the backend
1937
+ // holds descends from it (`utils/sync-point.js`). ⚖️ Uncommitted edits ride on top of it,
1938
+ // and it stays a safe base for them — both sides hold them, so a merge takes them once.
1939
+ const repo = repoOf(siteDir)
1940
+ const source = repo?.head || null
1908
1941
 
1909
1942
  // POST one lane via the client and parse the JSON response. `doRequest` is a thunk
1910
1943
  // returning the client's Response promise (so the "Pushing …" line prints before the
@@ -2067,22 +2100,14 @@ export async function pushSyncPackages({
2067
2100
  note(
2068
2101
  'Nothing was written — the whole push was refused before any change.'
2069
2102
  )
2070
- // Say what will actually work from HERE. Anyone hitting this has local
2071
- // work — it is why they pushed — and if it is uncommitted then `pull`
2072
- // refuses too, so bare "run pull" advice walks them into a dead end.
2073
- if (hasUncommittedContent(siteDir)) {
2074
- // `--merge` is the recovery that keeps both sides: most of these are two
2075
- // people editing different parts of one section, which merges silently.
2076
- // Offer it first, and keep the take-theirs route for anyone who wants it.
2077
- note(
2078
- "Commit your changes, then `uniweb pull --merge` to combine them with the backend's."
2079
- )
2080
- note(
2081
- '(Plain `uniweb pull` declines while the work is unsaved — it overwrites rather than merges.)'
2082
- )
2103
+ // Say what will actually work from HERE. Anyone hitting this has local work — it is
2104
+ // why they pushed. In a repository a pull merges it with the backend's, committed or
2105
+ // not; outside one a pull takes the backend's version over the files.
2106
+ if (repo) {
2107
+ note("Run `uniweb pull` to merge the backend's changes with yours, then push again.")
2083
2108
  } else {
2084
2109
  note(
2085
- 'Run `uniweb pull --merge` to combine the changes, or `uniweb pull` to take the backend version.'
2110
+ "Outside git, `uniweb pull` takes the backend's version over your files. Put the site under git and a pull merges instead."
2086
2111
  )
2087
2112
  }
2088
2113
  note(
@@ -2226,10 +2251,12 @@ export async function pushSyncPackages({
2226
2251
  if (f.uuid && f.version) newVersions[f.uuid] = f.version
2227
2252
  }
2228
2253
  }
2229
- // Both grains land together, at every point the old code banked the entity one.
2254
+ // Both grains land together, at every point the old code banked the entity one — and
2255
+ // with them the commit they describe.
2230
2256
  const mergeHarvested = () => {
2231
2257
  mergeBaseVersions(siteDir, client.origin, newVersions)
2232
2258
  if (heldItemVersions) writeItemBaseVersions(siteDir, client.origin, heldItemVersions)
2259
+ if (source && (Object.keys(newVersions).length || heldItemVersions)) writeSyncPoint(siteDir, client.origin, source)
2233
2260
  }
2234
2261
  // ⛔ The site-content lane banks only the tokens for content this copy holds — the
2235
2262
  // document we sent against the one the backend stored (`heldTokens`, and the two
@@ -2237,7 +2264,6 @@ export async function pushSyncPackages({
2237
2264
  // collected here, to re-base attribution and to say so.
2238
2265
  const sentSiteDoc = siteContent ? entityDocFromUwx(siteContent.buffer) : null
2239
2266
  const notHeld = { kept: [], foreign: [] }
2240
- const heldUnits = []
2241
2267
  const harvestSiteContent = (finalized) => {
2242
2268
  for (const f of finalized || []) {
2243
2269
  const held = heldTokens({
@@ -2249,7 +2275,6 @@ export async function pushSyncPackages({
2249
2275
  })
2250
2276
  harvest([{ ...f, version: held.version }])
2251
2277
  if (held.itemVersions) heldItemVersions = held.itemVersions
2252
- heldUnits.push(...held.held)
2253
2278
  notHeld.kept.push(...held.kept)
2254
2279
  notHeld.foreign.push(...held.foreign)
2255
2280
  }
@@ -2331,16 +2356,13 @@ export async function pushSyncPackages({
2331
2356
  'may refuse it. `uniweb pull` re-arms identity if that happens.'
2332
2357
  )
2333
2358
  }
2334
- // The files of the units the backend now holds as ours are the synced version — the
2335
- // ancestor the next `pull --merge` merges from.
2336
- if (heldUnits.length) recordPushedUnits(siteDir, heldUnits)
2337
2359
  }
2338
2360
  if (siteContent) {
2339
2361
  if (siteContentUuid) {
2340
2362
  const finalized = await pushLane(
2341
2363
  'site-content',
2342
2364
  () =>
2343
- client.updateSiteContent(siteContentUuid, siteContent.buffer),
2365
+ client.updateSiteContent(siteContentUuid, siteContent.buffer, { source }),
2344
2366
  () =>
2345
2367
  explainStaleSiteContent({
2346
2368
  client,
@@ -2361,7 +2383,7 @@ export async function pushSyncPackages({
2361
2383
  } else {
2362
2384
  const payload = await postLane(
2363
2385
  'site-content',
2364
- () => client.createSiteContent(siteContent.buffer),
2386
+ () => client.createSiteContent(siteContent.buffer, { source }),
2365
2387
  undefined,
2366
2388
  { reportTemplate: true }
2367
2389
  )
@@ -2408,7 +2430,7 @@ export async function pushSyncPackages({
2408
2430
  }
2409
2431
  const finalized = await pushLane(
2410
2432
  'records',
2411
- () => client.pushFolder(boundSiteUuid, records.buffer),
2433
+ () => client.pushFolder(boundSiteUuid, records.buffer, { source }),
2412
2434
  undefined,
2413
2435
  { boundUuid: boundSiteUuid }
2414
2436
  )
@@ -2498,7 +2520,7 @@ export async function pushSyncPackages({
2498
2520
  ...new Set([...notHeld.kept, ...notHeld.foreign].map((p) => (p === 'site.yml' ? p : unitFilesOf(siteDir, p)[0] || p)))
2499
2521
  ].sort()
2500
2522
  note(`Changed on the backend by someone else, not in your files yet: ${paths.join(', ')}`)
2501
- note('Take them with `uniweb refresh` (or `uniweb pull --merge`). Until then, pushing leaves them as they are.')
2523
+ note('Take them with `uniweb refresh` (or `uniweb pull`). Until then, pushing leaves them as they are.')
2502
2524
  }
2503
2525
  // ⛔ A DRAFT THE BACKEND STORED ENABLED FAILS THE PUSH, and only here, after every
2504
2526
  // piece of sync state above is banked: the push itself happened, and the next one must
@@ -39,6 +39,7 @@
39
39
  * uniweb clone <uuid> --path sites Place under sites/ (segregated layout)
40
40
  * uniweb clone <uuid> --project docs Co-located docs/site
41
41
  * uniweb clone <uuid> --no-records Pull pages only; skip records
42
+ * uniweb clone <uuid> --no-git Do not start a git repository for the new project
42
43
  * uniweb clone <uuid> --org @org Work in @org for this clone, instead of the
43
44
  * workspace chosen at `uniweb login`. A site in
44
45
  * another workspace stops it.
@@ -51,8 +52,8 @@
51
52
  */
52
53
 
53
54
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'
54
- import { join, resolve, basename, dirname } from 'node:path'
55
- import { spawnSync } from 'node:child_process'
55
+ import { join, resolve, basename, dirname, relative, sep } from 'node:path'
56
+ import { spawnSync, execFileSync } from 'node:child_process'
56
57
  import { scaffoldWorkspace, scaffoldSite } from '../utils/scaffold.js'
57
58
  import { resolvePlacement, projectConflictLines, SITE_KIND } from '../utils/placement.js'
58
59
  import { findWorkspaceRoot } from '../utils/workspace.js'
@@ -63,8 +64,8 @@ import { resolveWorkspace } from '../backend/workspace.js'
63
64
  import { isNonInteractive, getCliPrefix } from '../utils/interactive.js'
64
65
  import { extractFoundationRef } from '../utils/site-content-refs.js'
65
66
  import { readUwxDocuments } from '../utils/uwx-read.js'
66
- import { recordWritten } from '../utils/pull-written.js'
67
67
  import { checkFlags } from '../utils/flag-guard.js'
68
+ import { repoOf, writeSyncPoint, SYNC_TRAILER, SITE_TRAILER } from '../utils/sync-point.js'
68
69
 
69
70
  const colors = {
70
71
  reset: '\x1b[0m',
@@ -169,6 +170,47 @@ function seedSyncJson(siteDir, origin, uuid) {
169
170
  writeFileSync(file, JSON.stringify(doc, null, 2) + '\n')
170
171
  }
171
172
 
173
+ /**
174
+ * Start the new project's history at a sync point — the site as the backend has it.
175
+ *
176
+ * ⭐ Like `uniweb create`, a clone puts its project under git unless it is already inside a
177
+ * repository; and its first commit holds the site as the backend has it, marked so a pull
178
+ * merges from it (`utils/sync-point.js`). Inside a repository the clone's pull already left
179
+ * one, in the commit that took the site.
180
+ */
181
+ function startHistory({ projectDir, siteDir, origin, siteUuid }) {
182
+ const git = (args, input) =>
183
+ execFileSync('git', args, {
184
+ cwd: projectDir,
185
+ encoding: 'utf8',
186
+ input,
187
+ stdio: [input == null ? 'ignore' : 'pipe', 'pipe', 'pipe']
188
+ })
189
+ try {
190
+ git(['--version'])
191
+ } catch {
192
+ return // no git here: the project works without one, and a pull checks out
193
+ }
194
+ if (repoOf(projectDir)) return
195
+ try {
196
+ git(['init', '-q'])
197
+ git(['add', '-A'])
198
+ const sitePath = relative(projectDir, siteDir).split(sep).join('/')
199
+ const siteTree = git(sitePath ? ['write-tree', `--prefix=${sitePath}/`] : ['write-tree']).trim()
200
+ git(
201
+ ['commit', '-q', '-F', '-'],
202
+ `Clone the site from ${origin}\n\n${SYNC_TRAILER}: ${origin} ${siteUuid}\n${SITE_TRAILER}: ${sitePath || '.'}:${siteTree}\n`
203
+ )
204
+ writeSyncPoint(siteDir, origin, git(['rev-parse', 'HEAD']).trim())
205
+ success('Git repository initialized, its first commit the site as the backend has it')
206
+ } catch (err) {
207
+ note(
208
+ `! A git repository was started, but its first commit failed${err?.stderr ? `: ${String(err.stderr).trim().split('\n')[0]}` : ''}.`
209
+ )
210
+ note(' Commit the project before your first `uniweb pull`, so the pull has something to merge into.')
211
+ }
212
+ }
213
+
172
214
  // Build the package-manager argv to run the project-local `uniweb pull`.
173
215
  function pullExecArgv(pm, extra) {
174
216
  // npm needs `exec --` to forward flags to the binary; pnpm/yarn don't.
@@ -411,32 +453,12 @@ export async function clone(args = [], deps = {}) {
411
453
  }
412
454
  }
413
455
 
414
- // Tell the delegated pull which files WE wrote, or it refuses on them.
415
- //
416
- // Pull guards against overwriting uncommitted work under the paths it rewrites,
417
- // and `site.yml` / `theme.yml` are exactly those paths — uncommitted here
418
- // because clone scaffolded them seconds ago. Without this, clone reliably ends
419
- // in "Refusing to pull: 2 uncommitted change(s)" on a project it just created,
420
- // where there is no user work to protect. The advice it prints (`--force`) is
421
- // both unreachable — clone does not forward that flag — and wrong, since it
422
- // means "discard my changes" and the changes are ours.
423
- //
424
- // ⭐ This exempts them for the RIGHT reason rather than overriding the guard.
425
- // `recordWritten` stores a content hash, so a file the user edits between
426
- // scaffold and pull no longer matches and the refusal correctly stands. The
427
- // guard keeps its teeth; it just stops firing on machine output, which is what
428
- // it already does for pull's own writes.
429
- //
430
- // Recorded AFTER the uuid seeding above — the hash has to be of the final
431
- // bytes on disk, not of what the scaffolder first wrote.
432
- recordWritten(
433
- siteDir,
434
- ['site.yml', 'theme.yml', 'head.html', 'queries.yml', join('records', 'folder.yml')]
435
- .map((f) => join(siteDir, f))
436
- .filter((p) => existsSync(p))
437
- )
438
-
439
- const pullExtra = []
456
+ // ⭐ The delegated pull takes the backend's version whole (`--force`): every file under
457
+ // what it writes is one this clone scaffolded seconds ago — the site directory is new
458
+ // (refused above when it is not) — so there is nothing of the author's to protect.
459
+ // ⛔ Until 2026-10-17 clone recorded its scaffold as pull output instead, so the pull's
460
+ // guard let it through; a pull inside a repository merges now, and has no such guard.
461
+ const pullExtra = ['--force']
440
462
  if (noRecords) pullExtra.push('--no-records')
441
463
  // The workspace this clone was told to work in: the pull is another process, and asks the same
442
464
  // question. ⛔ Until 2026-09-26 it was not told, so it used the login's — or, with none chosen,
@@ -463,6 +485,10 @@ export async function clone(args = [], deps = {}) {
463
485
  }
464
486
  }
465
487
 
488
+ if (!deps.skipGit && !args.includes('--no-git')) {
489
+ startHistory({ projectDir, siteDir, origin: client.origin, siteUuid })
490
+ }
491
+
466
492
  log('')
467
493
  success(
468
494
  `Cloned site into ${colors.bright}${isNewWorkspace && !inPlace ? workspaceName : siteDir}${colors.reset}`
@@ -103,6 +103,7 @@ import {
103
103
  ensureSiteExists,
104
104
  refuseUnsendableRecords,
105
105
  clearRemoteSyncStateIfUnbound,
106
+ untakenBase,
106
107
  dropSiteBoundValues,
107
108
  pushSyncPackages
108
109
  } from '../backend/site-sync.js'
@@ -622,6 +623,16 @@ export async function publish(args = []) {
622
623
  `Dropped the previous site's ${droppedValues.join(' and ')} from site.yml.`
623
624
  )
624
625
  }
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
+ }
625
636
 
626
637
  // 3b. Make sure the SITE EXISTS before a single byte is uploaded.
627
638
  //