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.
- package/README.md +1 -1
- package/package.json +6 -6
- package/partials/agents.md +2 -2
- package/src/backend/client.js +22 -4
- package/src/backend/site-sync.js +131 -48
- package/src/commands/clone.js +68 -29
- package/src/commands/publish.js +23 -1
- package/src/commands/pull.js +476 -463
- package/src/commands/push.js +26 -0
- package/src/commands/refresh.js +17 -18
- package/src/framework-index.json +5 -5
- package/src/index.js +10 -15
- package/src/utils/flag-guard.js +20 -8
- package/src/utils/git.js +53 -226
- package/src/utils/pull-written.js +15 -28
- package/src/utils/sync-point.js +693 -0
package/src/commands/push.js
CHANGED
|
@@ -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)
|
package/src/commands/refresh.js
CHANGED
|
@@ -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
|
|
21
|
-
* the
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
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-
|
|
28
|
-
*
|
|
29
|
-
*
|
|
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
|
|
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
|
-
//
|
|
168
|
-
//
|
|
169
|
-
//
|
|
170
|
-
//
|
|
171
|
-
const res = await pull([
|
|
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/framework-index.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"generatedAt": "2026-10-
|
|
3
|
+
"generatedAt": "2026-10-17T22:35:38.030Z",
|
|
4
4
|
"packages": {
|
|
5
5
|
"@uniweb/api": {
|
|
6
|
-
"version": "1.
|
|
6
|
+
"version": "1.5.0"
|
|
7
7
|
},
|
|
8
8
|
"@uniweb/build": {
|
|
9
|
-
"version": "1.3.
|
|
9
|
+
"version": "1.3.10"
|
|
10
10
|
},
|
|
11
11
|
"@uniweb/content-reader": {
|
|
12
12
|
"version": "1.4.0"
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
"version": "1.0.3"
|
|
25
25
|
},
|
|
26
26
|
"@uniweb/kit": {
|
|
27
|
-
"version": "1.2.
|
|
27
|
+
"version": "1.2.5"
|
|
28
28
|
},
|
|
29
29
|
"@uniweb/loom": {
|
|
30
30
|
"version": "1.1.0"
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
"version": "1.0.1"
|
|
43
43
|
},
|
|
44
44
|
"@uniweb/schemas": {
|
|
45
|
-
"version": "1.
|
|
45
|
+
"version": "1.4.0"
|
|
46
46
|
},
|
|
47
47
|
"@uniweb/scholar": {
|
|
48
48
|
"version": "1.1.0"
|
package/src/index.js
CHANGED
|
@@ -74,6 +74,7 @@ import {
|
|
|
74
74
|
} from './utils/interactive.js'
|
|
75
75
|
import { findWorkspaceRoot } from './utils/workspace.js'
|
|
76
76
|
import { exitWhenDrained } from './utils/exit.js'
|
|
77
|
+
import { isGitRepo } from './utils/git.js'
|
|
77
78
|
|
|
78
79
|
// Colors for terminal output
|
|
79
80
|
const colors = {
|
|
@@ -1327,17 +1328,10 @@ async function main() {
|
|
|
1327
1328
|
|
|
1328
1329
|
// Initialize git repository
|
|
1329
1330
|
if (!noGit) {
|
|
1330
|
-
// Skip git init if already
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
cwd: projectDir,
|
|
1335
|
-
stdio: 'ignore'
|
|
1336
|
-
})
|
|
1337
|
-
insideGitRepo = true
|
|
1338
|
-
} catch {
|
|
1339
|
-
// Not inside a git repo — proceed with init
|
|
1340
|
-
}
|
|
1331
|
+
// Skip git init if a repository already holds the project (common for
|
|
1332
|
+
// monorepos/workspaces). ⛔ Not one that ignores its folder: that repository can never
|
|
1333
|
+
// hold its history, so the project gets its own (`isGitRepo`, the one rule).
|
|
1334
|
+
const insideGitRepo = isGitRepo(projectDir)
|
|
1341
1335
|
|
|
1342
1336
|
if (insideGitRepo) {
|
|
1343
1337
|
log(
|
|
@@ -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
|
|
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\`
|
|
1883
|
-
|
|
1884
|
-
|
|
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}
|
package/src/utils/flag-guard.js
CHANGED
|
@@ -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',
|
|
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
|
|
116
|
+
* `refresh` = `git pull`, then a DELEGATED `pull`.
|
|
117
117
|
*
|
|
118
|
-
* It forwards nothing of its own argv to that pull
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
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`
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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 {
|
|
22
|
-
import {
|
|
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
|
|
30
|
-
*
|
|
31
|
-
* and a stale list here means
|
|
32
|
-
* `paths:` can relocate the content roots, so the
|
|
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
|
-
*
|
|
81
|
-
*
|
|
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
|
-
|
|
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
|
|
96
|
+
* `git pull`, merging — bring in teammates' commits.
|
|
292
97
|
*
|
|
293
|
-
*
|
|
294
|
-
*
|
|
295
|
-
*
|
|
296
|
-
*
|
|
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
|
-
*
|
|
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', '--
|
|
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),
|