uniweb 1.7.4 → 1.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -95,6 +95,9 @@ import {
95
95
  ensureSiteExists,
96
96
  clearRemoteSyncStateIfUnbound,
97
97
  dropSiteBoundValues,
98
+ untakenBase,
99
+ unsettledFiles,
100
+ holdUnchangedPush,
98
101
  pushSyncPackages
99
102
  } from '../backend/site-sync.js'
100
103
  import { resolveWorkspace, describeWorkspace, SOURCE_LABEL } from '../backend/workspace.js'
@@ -209,6 +212,26 @@ export async function push(args = [], deps = {}) {
209
212
  }
210
213
  }
211
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
+
212
235
  // ⚠️ Logged in to a backend where this project has no site, while it has one
213
236
  // elsewhere: following the login CREATES a second site. Say so before the owner
214
237
  // question, which otherwise arrives with no reason attached. Not for `-o` (nothing is
@@ -601,6 +624,9 @@ export async function push(args = [], deps = {}) {
601
624
  success(
602
625
  `Nothing to push — ${skipped} entit${skipped === 1 ? 'y' : 'ies'} unchanged since the last push.`
603
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)
604
630
  return { exitCode: 0 }
605
631
  }
606
632
  if (siteContent)
@@ -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.)*
@@ -138,7 +137,7 @@ export async function refresh(args = [], deps = {}) {
138
137
  say.info('Pulling from the git remote…')
139
138
  const r = pullRemote(siteDir)
140
139
  if (!r.ok) {
141
- say.err('git pull failed — resolve this before continuing.')
140
+ say.err(r.conflicts ? 'git pull left conflicts — settle them before continuing.' : 'git pull failed — resolve this before continuing.')
142
141
  for (const line of r.message.split('\n').slice(0, 6)) say.dim(line)
143
142
  // Deliberately stop. Layering the backend's content on top of an unresolved
144
143
  // git state gives the user two independent conflicts at once, and no clear
@@ -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
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-10-17T17:49:32.100Z",
3
+ "generatedAt": "2026-10-17T22:35:38.030Z",
4
4
  "packages": {
5
5
  "@uniweb/api": {
6
- "version": "1.4.0"
6
+ "version": "1.5.0"
7
7
  },
8
8
  "@uniweb/build": {
9
- "version": "1.3.9"
9
+ "version": "1.3.10"
10
10
  },
11
11
  "@uniweb/content-reader": {
12
12
  "version": "1.4.0"
@@ -24,7 +24,7 @@
24
24
  "version": "1.0.3"
25
25
  },
26
26
  "@uniweb/kit": {
27
- "version": "1.2.4"
27
+ "version": "1.2.5"
28
28
  },
29
29
  "@uniweb/loom": {
30
30
  "version": "1.1.0"
@@ -42,7 +42,7 @@
42
42
  "version": "1.0.1"
43
43
  },
44
44
  "@uniweb/schemas": {
45
- "version": "1.3.1"
45
+ "version": "1.4.0"
46
46
  },
47
47
  "@uniweb/scholar": {
48
48
  "version": "1.1.0"
package/src/index.js CHANGED
@@ -74,6 +74,7 @@ import {
74
74
  } from './utils/interactive.js'
75
75
  import { findWorkspaceRoot } from './utils/workspace.js'
76
76
  import { exitWhenDrained } from './utils/exit.js'
77
+ import { isGitRepo } from './utils/git.js'
77
78
 
78
79
  // Colors for terminal output
79
80
  const colors = {
@@ -1327,17 +1328,10 @@ async function main() {
1327
1328
 
1328
1329
  // Initialize git repository
1329
1330
  if (!noGit) {
1330
- // Skip git init if already inside a git repo (common for monorepos/workspaces)
1331
- let insideGitRepo = false
1332
- try {
1333
- execSync('git rev-parse --is-inside-work-tree', {
1334
- cwd: projectDir,
1335
- stdio: 'ignore'
1336
- })
1337
- insideGitRepo = true
1338
- } catch {
1339
- // Not inside a git repo — proceed with init
1340
- }
1331
+ // Skip git init if a repository already holds the project (common for
1332
+ // monorepos/workspaces). ⛔ Not one that ignores its folder: that repository can never
1333
+ // hold its history, so the project gets its own (`isGitRepo`, the one rule).
1334
+ const insideGitRepo = isGitRepo(projectDir)
1341
1335
 
1342
1336
  if (insideGitRepo) {
1343
1337
  log(
@@ -1870,7 +1864,8 @@ ${colors.bright}Usage:${colors.reset}
1870
1864
  A developer on a synced site has TWO external sources: the git remote (teammates'
1871
1865
  commits) and the backend (content authors' edits in the app). Run only \`git pull\`
1872
1866
  and you miss every app edit; run only \`uniweb pull\` and you miss your teammates.
1873
- This does both — git first, then \`pull --merge\`.
1867
+ This does both — git first (merging, never rebasing), then \`uniweb pull\`, which in
1868
+ a repository merges the backend's content with yours.
1874
1869
 
1875
1870
  ${colors.bright}READ-ONLY.${colors.reset} It never pushes and cannot ship anything, so it is safe to run
1876
1871
  reflexively. Exits non-zero if a merge leaves conflicts.
@@ -1879,9 +1874,9 @@ ${colors.bright}Options:${colors.reset}
1879
1874
  --no-git Skip the git remote; backend only
1880
1875
  --no-backend Skip the backend; git only
1881
1876
 
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.
1877
+ \`--force\` is NOT accepted here: this verb builds the delegated pull's arguments
1878
+ itself, and to pull \`--force\` means "discard my local work". Use \`uniweb sync\`
1879
+ when you also mean to share.
1885
1880
  `,
1886
1881
  sync: `
1887
1882
  ${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
@@ -7,30 +7,34 @@
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` already skips
11
- * `git init` when it finds itself inside a work tree ("common for
12
- * monorepos/workspaces"), and whole scopes of real projects are versioned by a
13
- * parent repo rather than being repo roots themselves. So the question is never
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.
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.
18
22
  */
19
23
 
20
24
  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'
25
+ import { readFileSync } from 'node:fs'
26
+ import { join } from 'node:path'
24
27
  import yaml from 'js-yaml'
28
+ import { repoOf } from './sync-point.js'
25
29
 
26
30
  /**
27
31
  * The files and directories `uniweb pull` writes into.
28
32
  *
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.
33
+ * ONE definition, shared by the guard that stops a pull outside git and the merge a
34
+ * pull makes in a repository, whose sync points hold exactly these (and `public/`, where
35
+ * the media land) — a second copy would drift, and a stale list here means a guard that
36
+ * misses or a merge that drops a file. `paths:` can relocate the content roots, so the
37
+ * local site.yml is consulted rather than assuming the defaults.
34
38
  */
35
39
  export function siteContentRoots(siteDir) {
36
40
  // ⚠️ `queries.yml` IS AT THE SITE ROOT, and that is why it must be named here.
@@ -62,12 +66,6 @@ export function siteContentRoots(siteDir) {
62
66
  return [...roots]
63
67
  }
64
68
 
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
69
  function git(args, cwd) {
72
70
  return execFileSync('git', args, {
73
71
  cwd,
@@ -77,205 +75,12 @@ function git(args, cwd) {
77
75
  }
78
76
 
79
77
  /**
80
- * Is `dir` inside a git work tree? False when git is absent, when the path isn't
81
- * tracked, or on any error — the caller treats all three the same way.
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.
82
81
  */
83
82
  export function isGitRepo(dir) {
84
- try {
85
- return git(['rev-parse', '--is-inside-work-tree'], dir).trim() === 'true'
86
- } catch {
87
- return false
88
- }
89
- }
90
-
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
- }
83
+ return Boolean(repoOf(dir))
279
84
  }
280
85
 
281
86
  /** Does this repo have a remote configured to pull from? */
@@ -288,14 +93,19 @@ export function hasRemote(dir) {
288
93
  }
289
94
 
290
95
  /**
291
- * `git pull --ff-only` — bring in teammates' commits.
96
+ * `git pull`, merging — bring in teammates' commits.
292
97
  *
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.
98
+ * ⭐ A MERGE, NOT A REBASE. A pull of the backend's content merges a sync point into the
99
+ * branch (`utils/sync-point.js`), and a rebase would replay that commit onto another
100
+ * base — no longer the backend's content, and no longer a base for the next merge. So the
101
+ * branch is merged with its remote too, whatever `pull.rebase` says, with uncommitted
102
+ * work stashed and put back (`--autostash`). A conflict is git's, settled with git's
103
+ * vocabulary; refresh reports it and steps back.
297
104
  *
298
- * @returns {{ ok: boolean, changed: boolean, message: string }}
105
+ * ⛔ Until 2026-10-17 this was `--ff-only`, which refused once a pull had made a merge
106
+ * commit the remote did not have.
107
+ *
108
+ * @returns {{ ok: boolean, conflicts?: boolean, changed: boolean, message: string }}
299
109
  */
300
110
  export function pullRemote(dir) {
301
111
  const before = (() => {
@@ -306,7 +116,7 @@ export function pullRemote(dir) {
306
116
  }
307
117
  })()
308
118
  try {
309
- const out = execFileSync('git', ['pull', '--ff-only'], {
119
+ const out = execFileSync('git', ['pull', '--no-rebase', '--no-edit', '--autostash'], {
310
120
  cwd: dir,
311
121
  encoding: 'utf8',
312
122
  stdio: ['ignore', 'pipe', 'pipe']
@@ -318,6 +128,23 @@ export function pullRemote(dir) {
318
128
  return null
319
129
  }
320
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
+ }
321
148
  return {
322
149
  ok: true,
323
150
  changed: Boolean(before && after && before !== after),