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.
@@ -95,6 +95,7 @@ import {
95
95
  ensureSiteExists,
96
96
  clearRemoteSyncStateIfUnbound,
97
97
  dropSiteBoundValues,
98
+ untakenBase,
98
99
  pushSyncPackages
99
100
  } from '../backend/site-sync.js'
100
101
  import { resolveWorkspace, describeWorkspace, SOURCE_LABEL } from '../backend/workspace.js'
@@ -336,6 +337,17 @@ export async function push(args = [], deps = {}) {
336
337
  note(`Dropped the previous site's ${stale.join(' and ')} from site.yml.`)
337
338
  }
338
339
  }
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
+ }
339
351
  const priorHashes = readSyncCache(siteDir, client.origin)
340
352
  // Per-item identity, without which the backend reads every record as new and
341
353
  // recreates every page and section row.
@@ -17,17 +17,16 @@
17
17
  * which is the whole point of a start-of-day command. A version that also pushed
18
18
  * would be one you had to think about first, and so one people would stop running.
19
19
  *
20
- * Order is deliberate: git first, then the backend. Teammates' commits are then in
21
- * the files when the backend's content arrives, and `pull --merge` merges a
22
- * teammate's change to a file the last pull wrote just as it merges your own edits:
23
- * against the version that pull wrote, found in the repo's history (`pull.js`,
24
- * `captureLocalWork`). And if git itself conflicts, refresh stops there — one source
25
- * of conflict at a time.
20
+ * Order is deliberate: git first, then the backend. Teammates' commits — and the sync
21
+ * points their pulls merged — are then in the history when the backend's content
22
+ * arrives, so the pull merges against the newest state of the site this history shares
23
+ * with the backend (`utils/sync-point.js`), and a teammate's change is merged like your
24
+ * own. And if git itself conflicts, refresh stops there — one source of conflict at a
25
+ * time.
26
26
  *
27
- * ⛔ Until 2026-10-16 this said the merge's ancestor was the COMMITTED version of each
28
- * file, so taking teammates' commits first made it fresher. That was HEAD — the rule
29
- * `pull --merge` dropped on 2026-09-23, because HEAD stops being the version a pull
30
- * wrote the moment an edit is committed on top of it.
27
+ * ⛔ Until 2026-10-17 the merge's ancestor was what this copy's last pull wrote, per file,
28
+ * from a per-machine record — so a teammate's committed work outside that record was
29
+ * overwritten or pruned, and a fresh clone's was all outside it.
31
30
  *
32
31
  * Exits NON-ZERO when a merge left conflicts, the way a conflicted `git merge`
33
32
  * does. That is what makes `uniweb refresh && uniweb push` correct by
@@ -35,7 +34,7 @@
35
34
  * content. If a `sync` verb is ever wanted, it is that chain.
36
35
  *
37
36
  * Usage:
38
- * uniweb refresh git pull, then merge the backend's content
37
+ * uniweb refresh git pull, then pull: merge the backend's content
39
38
  * uniweb refresh --no-git skip the git remote; backend only
40
39
  * uniweb refresh --no-backend skip the backend; git only
41
40
  *
@@ -99,7 +98,7 @@ function siteContentUuid(siteDir, backend) {
99
98
  export async function refresh(args = [], deps = {}) {
100
99
  // An unrecognized flag is invisible to a literal scan, so it silently keeps the
101
100
  // default — a mistyped `--no-git` would still pull from the git remote. And this is
102
- // the only check refresh's argv gets: the pull it runs is handed `--merge` alone
101
+ // the only check refresh's argv gets: the pull it runs is handed nothing of it
103
102
  // (below). A backend flag or `--token` is refused here too, naming `uniweb login`
104
103
  // (`utils/flag-guard.js`). *(This explained the check by a mistyped `--backend`
105
104
  // until 2026-10-16; the verb has taken no backend flag since 2026-09-21.)*
@@ -164,11 +163,11 @@ export async function refresh(args = [], deps = {}) {
164
163
  } else {
165
164
  say.info("Merging the backend's content…")
166
165
  const pull = deps.pull || (await import('./pull.js')).pull
167
- // `--merge` rather than a plain pull: an author editing a different part of the
168
- // same section is not a conflict, and should not be presented as one.
169
- // Nothing of refresh's own argv is forwarded: the pull goes to the backend you are
170
- // logged in to, as refresh does, and authenticates the same way.
171
- const res = await pull(['--merge'])
166
+ // A pull merges: an author editing a different part of the same section is not a
167
+ // conflict, and is not presented as one. Nothing of refresh's own argv is forwarded:
168
+ // the pull goes to the backend you are logged in to, as refresh does, and
169
+ // authenticates the same way.
170
+ const res = await pull([])
172
171
  conflicts = res?.merge?.conflicted?.length ?? 0
173
172
  if (res?.exitCode && !conflicts) {
174
173
  // Failed for a reason other than conflicts — say so plainly rather than
package/src/index.js CHANGED
@@ -1870,7 +1870,8 @@ ${colors.bright}Usage:${colors.reset}
1870
1870
  A developer on a synced site has TWO external sources: the git remote (teammates'
1871
1871
  commits) and the backend (content authors' edits in the app). Run only \`git pull\`
1872
1872
  and you miss every app edit; run only \`uniweb pull\` and you miss your teammates.
1873
- This does both — git first, then \`pull --merge\`.
1873
+ This does both — git first (merging, never rebasing), then \`uniweb pull\`, which in
1874
+ a repository merges the backend's content with yours.
1874
1875
 
1875
1876
  ${colors.bright}READ-ONLY.${colors.reset} It never pushes and cannot ship anything, so it is safe to run
1876
1877
  reflexively. Exits non-zero if a merge leaves conflicts.
@@ -1879,9 +1880,9 @@ ${colors.bright}Options:${colors.reset}
1879
1880
  --no-git Skip the git remote; backend only
1880
1881
  --no-backend Skip the backend; git only
1881
1882
 
1882
- \`--force\` and \`--merge\` are NOT accepted here: this verb builds the delegated
1883
- pull's arguments itself, and to pull \`--force\` means "discard my local work".
1884
- Use \`uniweb sync\` when you also mean to share.
1883
+ \`--force\` is NOT accepted here: this verb builds the delegated pull's arguments
1884
+ itself, and to pull \`--force\` means "discard my local work". Use \`uniweb sync\`
1885
+ when you also mean to share.
1885
1886
  `,
1886
1887
  sync: `
1887
1888
  ${colors.cyan}${colors.bright}uniweb sync${colors.reset} ${colors.dim}— Catch up, then share${colors.reset}
@@ -77,7 +77,7 @@ const VERBS = {
77
77
  '--no-validate', '--yes', '--no-verify', '--no-release', '--bump', ...VIA_DEPLOY
78
78
  ],
79
79
  pull: [
80
- '--content-only', '--dry-run', '--force', '--merge',
80
+ '--content-only', '--dry-run', '--force',
81
81
  '--no-assets',
82
82
  '--no-records', '--no-delete', '--no-prune',
83
83
  // via backend/site-sync.js (the owner resolver) and utils/conformance.js
@@ -85,7 +85,7 @@ const VERBS = {
85
85
  ],
86
86
  clone: [
87
87
  '--content-only', '--no-assets', '--no-records', '--path',
88
- '--project', '--org', '--personal'
88
+ '--project', '--org', '--personal', '--no-git'
89
89
  ],
90
90
  register: [
91
91
  '--dry-run', '--json', '--output', '-o',
@@ -113,13 +113,12 @@ const VERBS = {
113
113
  '--no-release', '--bump', ...VIA_DEPLOY
114
114
  ],
115
115
  /**
116
- * `refresh` = `git pull`, then a DELEGATED `pull --merge`.
116
+ * `refresh` = `git pull`, then a DELEGATED `pull`.
117
117
  *
118
- * It forwards nothing of its own argv to that pull — it builds `['--merge']`
119
- * itself. So pull's own flags (`--merge`, `--force`, `--no-delete`, `--no-prune`,
120
- * `--content-only`, …) are NOT reachable from a `refresh` argv and
121
- * are deliberately absent here. ⚠️ `--force` especially: `refresh` is read-only by
122
- * design, and forwarding it would ask pull to DISCARD local work.
118
+ * It forwards nothing of its own argv to that pull. So pull's own flags (`--force`,
119
+ * `--no-delete`, `--no-prune`, `--content-only`, …) are NOT reachable from a `refresh`
120
+ * argv and are deliberately absent here. ⚠️ `--force` especially: `refresh` is
121
+ * read-only by design, and forwarding it would ask pull to DISCARD local work.
123
122
  *
124
123
  * The trailing group is inert on this verb but reachable through the
125
124
  * `resolveSiteDir` / `probeUnpushed` imports — listed rather than filtered, per
@@ -238,6 +237,19 @@ export function checkFlags(verb, args = []) {
238
237
  message: `\`--as-org\` is retired — use \`--org @org\` (e.g. \`uniweb ${verb} --org @acme\`).`
239
238
  }
240
239
  }
240
+ // ⭐ `pull --merge` is RETIRED (2026-10-17): in a repository a pull always merges — from the
241
+ // newest sync point the history holds (`utils/sync-point.js`) — and outside one there is
242
+ // nothing to merge against.
243
+ if (flag === '--merge' && verb === 'pull') {
244
+ return {
245
+ flag,
246
+ suggestion: null,
247
+ message: [
248
+ '`uniweb pull` has no `--merge`: in a git repository a pull always merges.',
249
+ ' Run `uniweb pull`. Outside git it takes the backend\'s version, asking first.'
250
+ ].join('\n')
251
+ }
252
+ }
241
253
  const suggestion = didYouMean(flag, all)
242
254
  const lines = [`Unknown flag \`${flag}\` for \`uniweb ${verb}\`.`]
243
255
  if (suggestion) lines.push(` Did you mean \`${suggestion}\`?`)
package/src/utils/git.js CHANGED
@@ -12,25 +12,23 @@
12
12
  * monorepos/workspaces"), and whole scopes of real projects are versioned by a
13
13
  * parent repo rather than being repo roots themselves. So the question is never
14
14
  * "is this directory a git root" but "is this path tracked by some repo", which is
15
- * the question git itself answers. Callers degrade to an explicit confirmation
16
- * when the answer is no; sync already requires an account, and a second hard
17
- * dependency for a safety net we can provide anyway is a bad trade.
15
+ * the question git itself answers. Outside one a pull checks out, asking first; in
16
+ * one it merges (`utils/sync-point.js`).
18
17
  */
19
18
 
20
19
  import { execFileSync } from 'node:child_process'
21
- import { createHash } from 'node:crypto'
22
- import { readFileSync, realpathSync } from 'node:fs'
23
- import { join, relative } from 'node:path'
20
+ import { readFileSync } from 'node:fs'
21
+ import { join } from 'node:path'
24
22
  import yaml from 'js-yaml'
25
23
 
26
24
  /**
27
25
  * The files and directories `uniweb pull` writes into.
28
26
  *
29
- * ONE definition, shared by the guard that blocks a pull and the message that
30
- * tells someone how to recover from a refused push — a second copy would drift,
31
- * and a stale list here means either a guard that misses or advice that is wrong.
32
- * `paths:` can relocate the content roots, so the local site.yml is consulted
33
- * rather than assuming the defaults.
27
+ * ONE definition, shared by the guard that stops a pull outside git and the merge a
28
+ * pull makes in a repository, whose sync points hold exactly these (and `public/`, where
29
+ * the media land) — a second copy would drift, and a stale list here means a guard that
30
+ * misses or a merge that drops a file. `paths:` can relocate the content roots, so the
31
+ * local site.yml is consulted rather than assuming the defaults.
34
32
  */
35
33
  export function siteContentRoots(siteDir) {
36
34
  // ⚠️ `queries.yml` IS AT THE SITE ROOT, and that is why it must be named here.
@@ -62,12 +60,6 @@ export function siteContentRoots(siteDir) {
62
60
  return [...roots]
63
61
  }
64
62
 
65
- /** Is there uncommitted work where a pull would write? False outside a repo. */
66
- export function hasUncommittedContent(siteDir) {
67
- const dirty = uncommittedUnder(siteDir, siteContentRoots(siteDir))
68
- return Array.isArray(dirty) && dirty.length > 0
69
- }
70
-
71
63
  function git(args, cwd) {
72
64
  return execFileSync('git', args, {
73
65
  cwd,
@@ -88,196 +80,6 @@ export function isGitRepo(dir) {
88
80
  }
89
81
  }
90
82
 
91
- /**
92
- * Uncommitted work under `relPaths`, relative to `dir`.
93
- *
94
- * Counts modified, staged, deleted AND untracked files. Untracked matters as much
95
- * as modified here: a section file that exists only locally is not on the backend,
96
- * so a pruning pull deletes it — losing work that was never committed anywhere.
97
- *
98
- * ⛔ **Returned relative to `dir`, which is NOT what git prints.** `--porcelain`
99
- * paths are always relative to the REPOSITORY ROOT, whatever directory it runs
100
- * in, so they only coincide with `dir`-relative when the site *is* the repo root.
101
- * Both callers do `join(siteDir, rel)` with the result, so the un-normalized form
102
- * was wrong for every nested layout — a site at `myproject/site/` inside a repo.
103
- *
104
- * What that cost, measured 2026-08-12: pull exempts its own previous output from
105
- * the uncommitted-work guard by looking each dirty path up in a record keyed
106
- * `dir`-relative (`utils/pull-written.js`). Given `myproject/site/site.yml` the
107
- * lookup missed, so **pull refused on files pull itself had written** — the exact
108
- * false alarm that record exists to prevent, and the one its own comment warns
109
- * teaches people to reach for `--force`. `captureLocalWork` had the same defect
110
- * one door along: it reads each path back with `join(siteDir, rel)`.
111
- *
112
- * The earlier `@returns` said "repo-relative-ish paths as git reports them" while
113
- * the summary above said "relative to `dir`" — the ambiguity was noticed and left,
114
- * and both callers had picked the other reading.
115
- *
116
- * @returns {string[]|null} paths relative to `dir`, or `null` when this isn't a
117
- * git work tree (distinct from `[]`, which means "clean").
118
- */
119
- export function uncommittedUnder(dir, relPaths) {
120
- if (!isGitRepo(dir)) return null
121
- // ⚠️ Both sides must be REAL paths before `relative` can compare them.
122
- // `rev-parse --show-toplevel` resolves symlinks; the caller's `dir` usually has
123
- // not. On macOS that alone breaks it — `/var` is a symlink to `/private/var`,
124
- // so a repo under the temp dir yields a root of `/private/var/…` against a dir
125
- // of `/var/…`, and `relative` walks all the way up and back down. The result is
126
- // a valid-looking path that matches nothing, i.e. the same silent miss this
127
- // normalization exists to fix. Any symlinked checkout does it, not just tmp.
128
- let root
129
- let base
130
- try {
131
- root = git(['rev-parse', '--show-toplevel'], dir).trim()
132
- base = realpathSync(dir)
133
- } catch {
134
- return null
135
- }
136
- try {
137
- const out = git(
138
- ['status', '--porcelain', '--untracked-files=all', '--', ...relPaths],
139
- dir
140
- )
141
- return (
142
- out
143
- .split('\n')
144
- .filter(Boolean)
145
- // porcelain v1: XY<space>path, and a rename is "orig -> new".
146
- .map((line) => line.slice(3).trim())
147
- .map((p) => (p.includes(' -> ') ? p.split(' -> ')[1] : p))
148
- // Porcelain paths are repo-root-relative; callers want them relative to
149
- // `dir`. Quoted paths (git quotes names with spaces or non-ASCII when
150
- // `core.quotePath` is on) are left alone rather than half-decoded — a
151
- // wrong path is worse than an unmatched one here, since an unmatched
152
- // path merely stays "dirty" and the guard errs toward refusing.
153
- .map((p) => (p.startsWith('"') ? p : relative(base, join(root, p))))
154
- .filter(Boolean)
155
- )
156
- } catch {
157
- // A path git doesn't know (e.g. none of the roots exist yet) is not an error
158
- // worth failing a pull over.
159
- return []
160
- }
161
- }
162
-
163
- /**
164
- * A file's content at HEAD — the ancestor of a three-way merge for a file no pull
165
- * wrote. For one a pull wrote, the ancestor is the version it wrote, found in history
166
- * by `findCommitted` below: HEAD stops being that version the moment an edit is
167
- * committed on top of it.
168
- *
169
- * Either way the ancestor is on this side, which is the point: the backend keeps no
170
- * per-version item content, and retaining some so it could feed a merge running on
171
- * the client would put a permanent second write on its hottest table. It doesn't
172
- * have to — the tool that already knows how to merge holds the history.
173
- *
174
- * ⛔ Until 2026-10-16 this called HEAD's content THE common ancestor ("`uniweb pull`
175
- * writes the backend's content into these files, so the committed version IS the
176
- * state both sides diverged from") — the rule `pull --merge` dropped on 2026-09-23.
177
- *
178
- * @returns {Buffer|null} null when the path isn't in HEAD — a file added locally and
179
- * never committed has no ancestor; when the backend holds it too, `pull --merge`
180
- * merges the two versions two-way.
181
- */
182
- export function showAtHead(dir, relPath) {
183
- return showAt(dir, 'HEAD', relPath)
184
- }
185
-
186
- /**
187
- * A file's content at a revision, by a path relative to `dir`.
188
- *
189
- * ⛔ THE `./` IS WHAT MAKES THE PATH RELATIVE TO `dir`. In `<rev>:<path>` a bare path
190
- * is read from the REPOSITORY ROOT, whatever the cwd. With the repo at the project
191
- * root and the site in `site/` — the default layout — `HEAD:pages/home/hero.md`
192
- * named nothing, every lookup failed, and `pull --merge` "kept yours" for every file:
193
- * the backend's changes never merged in, while the pull recorded them as taken
194
- * (2026-09-23). Every merge test ran with the repo AT the site directory, the one
195
- * layout where a bare path happens to work.
196
- *
197
- * @returns {Buffer|null} null when the path isn't in that revision
198
- */
199
- export function showAt(dir, rev, relPath) {
200
- try {
201
- return execFileSync('git', ['show', `${rev}:./${relPath}`], {
202
- cwd: dir,
203
- stdio: ['ignore', 'pipe', 'ignore'],
204
- maxBuffer: 64 * 1024 * 1024
205
- })
206
- } catch {
207
- return null
208
- }
209
- }
210
-
211
- /**
212
- * The most recent committed version of a file whose content hashes to `sha256` —
213
- * how the version a pull wrote is found again, as the ancestor of a merge.
214
- *
215
- * HEAD is that version only until the user commits an edit on top of it. After that
216
- * HEAD is THEIR edit, and a merge against it reads their change as the starting
217
- * point: the backend's version wins whole and the committed edit is reverted in the
218
- * working tree. The pull recorded the hash of what it wrote (`pull-written.json`),
219
- * so the version is recoverable from history by content, wherever it was committed.
220
- *
221
- * @param {number} [limit] - how many commits touching the file to search
222
- * @returns {Buffer|null} null when no committed version matches
223
- */
224
- export function findCommitted(dir, relPath, sha256, { limit = 50 } = {}) {
225
- let revs
226
- try {
227
- revs = git(['log', `-n${limit}`, '--format=%H', '--', `./${relPath}`], dir)
228
- .split('\n')
229
- .filter(Boolean)
230
- } catch {
231
- return null
232
- }
233
- for (const rev of revs) {
234
- const buf = showAt(dir, rev, relPath)
235
- if (buf && createHash('sha256').update(buf).digest('hex') === sha256) return buf
236
- }
237
- return null
238
- }
239
-
240
- /**
241
- * Three-way merge, in place: `minePath` is rewritten with the merged result.
242
- *
243
- * Delegates to `git merge-file`, which is the same machinery git uses for a merge —
244
- * text that only one side touched is taken silently, and only genuine overlaps get
245
- * conflict markers. Building our own would be a worse version of a solved problem.
246
- *
247
- * @returns {{ merged: boolean, conflicted: boolean }} `merged:false` means the merge
248
- * could not be attempted at all, which is different from attempting it and finding
249
- * conflicts — the caller must not conflate them.
250
- */
251
- export function mergeFile(dir, minePath, basePath, theirsPath, labels = {}) {
252
- try {
253
- execFileSync(
254
- 'git',
255
- [
256
- 'merge-file',
257
- '-L',
258
- labels.mine || 'yours (local)',
259
- '-L',
260
- labels.base || 'common ancestor',
261
- '-L',
262
- labels.theirs || 'theirs (backend)',
263
- minePath,
264
- basePath,
265
- theirsPath
266
- ],
267
- { cwd: dir, stdio: ['ignore', 'ignore', 'ignore'] }
268
- )
269
- return { merged: true, conflicted: false }
270
- } catch (err) {
271
- // A positive status is the CONFLICT COUNT and the file still holds a valid
272
- // merge with markers. Anything else (git missing, unreadable input) means no
273
- // merge happened and the caller must fall back rather than trust the file.
274
- if (typeof err?.status === 'number' && err.status > 0 && err.status < 128) {
275
- return { merged: true, conflicted: true }
276
- }
277
- return { merged: false, conflicted: false }
278
- }
279
- }
280
-
281
83
  /** Does this repo have a remote configured to pull from? */
282
84
  export function hasRemote(dir) {
283
85
  try {
@@ -288,12 +90,17 @@ export function hasRemote(dir) {
288
90
  }
289
91
 
290
92
  /**
291
- * `git pull --ff-only` — bring in teammates' commits.
93
+ * `git pull`, merging — bring in teammates' commits.
94
+ *
95
+ * ⭐ A MERGE, NOT A REBASE. A pull of the backend's content merges a sync point into the
96
+ * branch (`utils/sync-point.js`), and a rebase would replay that commit onto another
97
+ * base — no longer the backend's content, and no longer a base for the next merge. So the
98
+ * branch is merged with its remote too, whatever `pull.rebase` says, with uncommitted
99
+ * work stashed and put back (`--autostash`). A conflict is git's, settled with git's
100
+ * vocabulary; refresh reports it and steps back.
292
101
  *
293
- * Fast-forward only, deliberately. A refresh should never silently create a merge
294
- * commit or drop the user into a rebase they didn't ask for; if the branches have
295
- * genuinely diverged, that is a git problem the user should handle in git, with
296
- * git's own vocabulary. We report and step back.
102
+ * ⛔ Until 2026-10-17 this was `--ff-only`, which refused once a pull had made a merge
103
+ * commit the remote did not have.
297
104
  *
298
105
  * @returns {{ ok: boolean, changed: boolean, message: string }}
299
106
  */
@@ -306,7 +113,7 @@ export function pullRemote(dir) {
306
113
  }
307
114
  })()
308
115
  try {
309
- const out = execFileSync('git', ['pull', '--ff-only'], {
116
+ const out = execFileSync('git', ['pull', '--no-rebase', '--no-edit', '--autostash'], {
310
117
  cwd: dir,
311
118
  encoding: 'utf8',
312
119
  stdio: ['ignore', 'pipe', 'pipe']
@@ -1,37 +1,24 @@
1
1
  /**
2
- * The record of what a machine wrote into a site project — pull's own output.
3
- *
4
- * ⭐ And, since 2026-09-23, what a push delivered: the files of the units the backend
5
- * now holds as they were sent (`recordPushedUnits`, site-sync.js). Both are "the last
6
- * version this file synced at", which is what `pull --merge` needs as its ancestor —
7
- * found again in the repo's history by this hash (`findCommitted`, utils/git.js).
2
+ * The record of what a pull wrote into a site project — outside git.
8
3
  *
9
4
  * ## What it is for
10
5
  *
11
- * `uniweb pull` refuses to run when there are uncommitted changes under the files
12
- * it rewrites, because it reconciles the working tree to the backend and would
13
- * overwrite them. That guard needs one distinction to be useful: **a file the
14
- * user edited** versus **a file a previous pull wrote and nobody has touched
15
- * since**. Without it the guard cries wolf — pull rewrites the tree, so the next
16
- * pull sees its own output as uncommitted work and refuses, listing files the
17
- * user never touched. A guard that fires on nothing teaches people to reach for
18
- * `--force`, which is the destructive option.
19
- *
20
- * So each write is recorded with a content hash, and a dirty path whose hash
21
- * still matches is not user work.
22
- *
23
- * ## Why it lives here rather than in `pull.js`
6
+ * Outside git, `uniweb pull` rewrites the files under its roots from the backend, and
7
+ * nothing stands behind them — so it asks before it does, unless nothing there is the
8
+ * author's. That needs one distinction: **a file the author edited** versus **a file a
9
+ * previous pull wrote and nobody has touched since**. Without it the question comes
10
+ * every time — pull rewrites the tree, so the next pull sees its own output as the
11
+ * author's — and an unattended pull refuses over files nobody touched, which teaches
12
+ * people to reach for `--force`, the destructive option.
24
13
  *
25
- * `uniweb clone` has the same claim to make and cannot make it from there.
26
- * `pull.js` statically imports `@uniweb/build`, which resolves from the
27
- * *project's* `node_modules` — and `clone` runs before a project exists (see
28
- * `utils/uwx-read.js` for the same constraint on the `.uwx` reader).
14
+ * So each write is recorded with a content hash, and a path whose hash still matches is
15
+ * not the author's work.
29
16
  *
30
- * Clone scaffolds `site.yml` and `theme.yml` and then delegates to `pull`, so
31
- * without this the delegated pull sees clone's own scaffolding as uncommitted
32
- * user work and refuses — on a project created seconds earlier, where there is no
33
- * user work to protect. Clone records what it wrote; the guard then exempts those
34
- * files **for the right reason** rather than being overridden.
17
+ * ⛔ In a repository there is no such record: a pull merges, from a sync point
18
+ * (`utils/sync-point.js`). Until 2026-10-17 this record was also a merge's ancestor —
19
+ * the version each file last synced at, found again in the repository by its hash — and
20
+ * committed work outside it was lost; and `clone` recorded its scaffold here, before a
21
+ * delegated pull that now takes the backend's version with `--force`.
35
22
  *
36
23
  * Nothing here imports `@uniweb/build`, and nothing here may start to.
37
24
  */