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
|
@@ -0,0 +1,693 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sync points — the site as the backend had it, kept as commits.
|
|
3
|
+
*
|
|
4
|
+
* ⭐ THE COMMON ANCESTOR OF A PULL'S MERGE IS A COMMIT. A three-way merge is only as good
|
|
5
|
+
* as its ancestor, and the ancestor of a pull is the last state of the site this history
|
|
6
|
+
* shares with the backend. Kept as a commit it travels with the history — a teammate, a
|
|
7
|
+
* fresh clone, CI all have it — and git merges against it whole: what was added, deleted
|
|
8
|
+
* or renamed on either side, not just what changed in a file both sides hold.
|
|
9
|
+
*
|
|
10
|
+
* A sync point is one of:
|
|
11
|
+
* · a commit a PULL made — the backend's content written onto the last sync point's
|
|
12
|
+
* tree, marked by the trailers below, and merged into the branch, so it is shared;
|
|
13
|
+
* · the commit a PUSH was made from — what the backend then holds — or one a push found
|
|
14
|
+
* nothing to send from. This copy keeps it (`backend-cache.json`, `syncPoint`), and the
|
|
15
|
+
* backend can name it on a pull (`x-uniweb-source`, `backend/client.js`), for every
|
|
16
|
+
* copy that has it.
|
|
17
|
+
*
|
|
18
|
+
* ⚖️ THE NEWEST ONE IS THE BASE; an older one is not simply less precise. Both the files and
|
|
19
|
+
* the backend's content descend from any of them, but between an older one and the
|
|
20
|
+
* newest, both sides hold the same changes — and where one side then changed a hunk back,
|
|
21
|
+
* the older base already reads the old text, so the change-back looks like no change and
|
|
22
|
+
* the merge silently takes the other side. ⛔ Until 2026-10-17 this said "an older one
|
|
23
|
+
* costs conflicts, never content". So no known base is dropped (`madeOnto`), and the
|
|
24
|
+
* backend naming its source is a matter of correctness, not only of fewer conflicts.
|
|
25
|
+
*
|
|
26
|
+
* ⛔ Until 2026-10-17 the ancestor was a per-machine list of the files this copy's last pull
|
|
27
|
+
* wrote, by hash, found again in history by content — and HEAD where the list had no
|
|
28
|
+
* entry. Committed work outside the list was lost: a teammate's new section pruned, a
|
|
29
|
+
* committed record edit overwritten, a fresh clone's committed work reverted, and the
|
|
30
|
+
* committed half of a file edited again since reverted (`test/pull-merge-base.test.js`).
|
|
31
|
+
*
|
|
32
|
+
* Dependency-free: `clone` uses it before a project's packages are installed.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { execFileSync } from 'node:child_process'
|
|
36
|
+
import {
|
|
37
|
+
copyFileSync,
|
|
38
|
+
cpSync,
|
|
39
|
+
existsSync,
|
|
40
|
+
lstatSync,
|
|
41
|
+
mkdirSync,
|
|
42
|
+
mkdtempSync,
|
|
43
|
+
readdirSync,
|
|
44
|
+
readFileSync,
|
|
45
|
+
rmSync,
|
|
46
|
+
statSync,
|
|
47
|
+
symlinkSync,
|
|
48
|
+
unlinkSync,
|
|
49
|
+
writeFileSync
|
|
50
|
+
} from 'node:fs'
|
|
51
|
+
import { tmpdir } from 'node:os'
|
|
52
|
+
import { join, dirname } from 'node:path'
|
|
53
|
+
|
|
54
|
+
export const SYNC_TRAILER = 'Uniweb-Sync'
|
|
55
|
+
export const SITE_TRAILER = 'Uniweb-Site'
|
|
56
|
+
|
|
57
|
+
const MAX_BUFFER = 1024 * 1024 * 1024
|
|
58
|
+
|
|
59
|
+
function git(cwd, args, { env, input } = {}) {
|
|
60
|
+
return execFileSync('git', args, {
|
|
61
|
+
cwd,
|
|
62
|
+
env: env ? { ...process.env, ...env } : process.env,
|
|
63
|
+
input,
|
|
64
|
+
maxBuffer: MAX_BUFFER,
|
|
65
|
+
stdio: [input == null ? 'ignore' : 'pipe', 'pipe', 'pipe']
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
const gitText = (cwd, args, opts) => git(cwd, args, opts).toString('utf8')
|
|
69
|
+
|
|
70
|
+
/** git's own words when a command fails — what it printed, not our paraphrase. */
|
|
71
|
+
export function gitError(err) {
|
|
72
|
+
const said = [err?.stderr, err?.stdout].map((b) => (b ? String(b).trim() : '')).filter(Boolean)
|
|
73
|
+
return said.join('\n') || err?.message || 'git failed'
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The repository that holds a site — its top level, the site's path in it, the git
|
|
78
|
+
* directory and HEAD (null on a branch with no commits) — or null when none does.
|
|
79
|
+
*
|
|
80
|
+
* ⭐ HOLDS: the site's folder is inside a work tree, and that repository does not ignore
|
|
81
|
+
* it. A repository can never hold the history of a folder it ignores — a harness's
|
|
82
|
+
* scratch projects, a sandbox — so a site there is outside git, as if no repository were
|
|
83
|
+
* around it. One committed there anyway (`git add -f`) is held: git does not count a
|
|
84
|
+
* folder with tracked content as ignored.
|
|
85
|
+
*
|
|
86
|
+
* ⛔ Until 2026-10-17 any enclosing work tree counted. In a folder it ignores, a pull
|
|
87
|
+
* failed on git's "paths are ignored", a push recorded that repository's HEAD as its sync
|
|
88
|
+
* point, and `refresh` ran `git pull` on it.
|
|
89
|
+
*/
|
|
90
|
+
export function repoOf(siteDir) {
|
|
91
|
+
let top, prefix, gitDir
|
|
92
|
+
try {
|
|
93
|
+
top = gitText(siteDir, ['rev-parse', '--show-toplevel']).trim()
|
|
94
|
+
prefix = gitText(siteDir, ['rev-parse', '--show-prefix']).trim()
|
|
95
|
+
gitDir = gitText(siteDir, ['rev-parse', '--absolute-git-dir']).trim()
|
|
96
|
+
} catch {
|
|
97
|
+
return null
|
|
98
|
+
}
|
|
99
|
+
if (!top) return null
|
|
100
|
+
if (prefix && ignoredHere(siteDir)) return null
|
|
101
|
+
let head = null
|
|
102
|
+
try {
|
|
103
|
+
head = gitText(top, ['rev-parse', '--verify', '-q', 'HEAD^{commit}']).trim() || null
|
|
104
|
+
} catch {
|
|
105
|
+
head = null
|
|
106
|
+
}
|
|
107
|
+
return { top, prefix, path: prefix ? prefix.replace(/\/$/, '') : '.', gitDir, head }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Does the repository around `dir` ignore it? `git check-ignore` exits 0 for yes, 1 for no. */
|
|
111
|
+
function ignoredHere(dir) {
|
|
112
|
+
try {
|
|
113
|
+
git(dir, ['check-ignore', '-q', '.'])
|
|
114
|
+
return true
|
|
115
|
+
} catch {
|
|
116
|
+
return false
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Is `ancestor` in the history of `of` — or `of` itself? */
|
|
121
|
+
export function isAncestor(repo, ancestor, of = 'HEAD') {
|
|
122
|
+
try {
|
|
123
|
+
git(repo.top, ['merge-base', '--is-ancestor', ancestor, of])
|
|
124
|
+
return true
|
|
125
|
+
} catch {
|
|
126
|
+
return false
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function isCommit(repo, sha) {
|
|
131
|
+
if (typeof sha !== 'string' || !/^[0-9a-f]{7,64}$/i.test(sha)) return false
|
|
132
|
+
try {
|
|
133
|
+
git(repo.top, ['cat-file', '-e', `${sha}^{commit}`])
|
|
134
|
+
return true
|
|
135
|
+
} catch {
|
|
136
|
+
return false
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** The tree of the site's directory in a commit or tree, or null. */
|
|
141
|
+
export function siteTreeIn(repo, rev) {
|
|
142
|
+
try {
|
|
143
|
+
return gitText(repo.top, ['rev-parse', '--verify', '-q', repo.path === '.' ? `${rev}^{tree}` : `${rev}:${repo.path}`]).trim() || null
|
|
144
|
+
} catch {
|
|
145
|
+
return null
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** The paths in conflict here, whatever left them — a merge, or a stash put back over one. */
|
|
150
|
+
export function unmergedPaths(repo) {
|
|
151
|
+
try {
|
|
152
|
+
return zlist(git(repo.top, ['diff', '--name-only', '-z', '--diff-filter=U']))
|
|
153
|
+
} catch {
|
|
154
|
+
return []
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Is a merge waiting for its commit here? */
|
|
159
|
+
export function mergeInProgress(repo) {
|
|
160
|
+
try {
|
|
161
|
+
git(repo.top, ['rev-parse', '--verify', '-q', 'MERGE_HEAD'])
|
|
162
|
+
return true
|
|
163
|
+
} catch {
|
|
164
|
+
return false
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function trailersOf(body) {
|
|
169
|
+
const out = {}
|
|
170
|
+
for (const line of String(body).split('\n')) {
|
|
171
|
+
let m = line.match(/^Uniweb-Sync: (\S+) (\S+)\s*$/)
|
|
172
|
+
if (m) {
|
|
173
|
+
out.origin = m[1]
|
|
174
|
+
out.siteUuid = m[2]
|
|
175
|
+
continue
|
|
176
|
+
}
|
|
177
|
+
m = line.match(/^Uniweb-Site: (.+):([0-9a-f]{40,64})\s*$/)
|
|
178
|
+
if (m) {
|
|
179
|
+
out.path = m[1]
|
|
180
|
+
out.tree = m[2]
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return out
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* The newest commit a pull made for this site on this backend that HEAD's history
|
|
188
|
+
* holds — and that still holds what that pull wrote. ⛔ A rebase or a cherry-pick copies
|
|
189
|
+
* the trailers onto a commit whose tree is no longer the backend's, so each one is
|
|
190
|
+
* checked against the site tree it names.
|
|
191
|
+
*/
|
|
192
|
+
export function historySyncPoint(repo, { origin, siteUuid }) {
|
|
193
|
+
if (!repo?.head) return null
|
|
194
|
+
let out
|
|
195
|
+
try {
|
|
196
|
+
out = gitText(repo.top, [
|
|
197
|
+
'log',
|
|
198
|
+
'--fixed-strings',
|
|
199
|
+
`--grep=${SYNC_TRAILER}: ${origin} ${siteUuid}`,
|
|
200
|
+
'--format=%H%x1f%B%x1e',
|
|
201
|
+
'HEAD'
|
|
202
|
+
])
|
|
203
|
+
} catch {
|
|
204
|
+
return null
|
|
205
|
+
}
|
|
206
|
+
for (const record of out.split('\x1e')) {
|
|
207
|
+
const [sha = '', body = ''] = record.replace(/^\s+/, '').split('\x1f')
|
|
208
|
+
if (!/^[0-9a-f]{40,64}$/.test(sha)) continue
|
|
209
|
+
if (marks(repo, sha, body, { origin, siteUuid })) return sha
|
|
210
|
+
}
|
|
211
|
+
return null
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Does this commit's message mark it a sync point of this site and backend — and does it
|
|
215
|
+
// still hold the site tree its trailer names?
|
|
216
|
+
function marks(repo, sha, body, { origin, siteUuid }) {
|
|
217
|
+
const t = trailersOf(body)
|
|
218
|
+
if (t.origin !== origin || t.siteUuid !== siteUuid || t.path !== repo.path) return false
|
|
219
|
+
return siteTreeIn(repo, sha) === t.tree
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* The commits a sync point HEAD does not hold still vouches for: the one it was made onto
|
|
224
|
+
* — its first parent — and that one's, for as long as each is a sync point of this site
|
|
225
|
+
* HEAD does not hold either.
|
|
226
|
+
*
|
|
227
|
+
* ⛔ A merge abandoned (`git merge --abort`) leaves this copy's sync point out of the
|
|
228
|
+
* history, while the commit it was made onto is still there — and still a state the
|
|
229
|
+
* backend's content descends from. Until 2026-10-17 it was forgotten with the merge, and
|
|
230
|
+
* the next pull built on an older sync point, or on none: measured against a real
|
|
231
|
+
* backend, it conflicted on a section nobody had touched since the commit last pushed from.
|
|
232
|
+
*/
|
|
233
|
+
export function madeOnto(repo, sha, { origin, siteUuid }) {
|
|
234
|
+
const out = []
|
|
235
|
+
let at = sha
|
|
236
|
+
for (let n = 0; n < 16 && isCommit(repo, at) && !isAncestor(repo, at); n++) {
|
|
237
|
+
let body
|
|
238
|
+
try {
|
|
239
|
+
body = gitText(repo.top, ['log', '-1', '--format=%B', at])
|
|
240
|
+
} catch {
|
|
241
|
+
break
|
|
242
|
+
}
|
|
243
|
+
if (!marks(repo, at, body, { origin, siteUuid })) break
|
|
244
|
+
let parent = null
|
|
245
|
+
try {
|
|
246
|
+
parent = gitText(repo.top, ['rev-parse', '--verify', '-q', `${at}^1`]).trim() || null
|
|
247
|
+
} catch {
|
|
248
|
+
// Made onto nothing — a history with no sync point.
|
|
249
|
+
}
|
|
250
|
+
if (!parent) break
|
|
251
|
+
out.push(parent)
|
|
252
|
+
at = parent
|
|
253
|
+
}
|
|
254
|
+
return out
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* The base a pull merges from: the newest of the candidates HEAD descends from. One that
|
|
259
|
+
* descends from all the others is the newest; with none, the most recently committed.
|
|
260
|
+
*
|
|
261
|
+
* ⚖️ Any of them is a state both sides descend from, but only the newest such state — the
|
|
262
|
+
* commit the backend's content last came from — merges exactly. An older one costs a
|
|
263
|
+
* conflict wherever one side changed what the other changed after it, and it silently
|
|
264
|
+
* undoes a change made after it and then changed BACK on one side: the base already
|
|
265
|
+
* reads the old text, so the change-back looks like no change at all. ⛔ Until
|
|
266
|
+
* 2026-10-17 this said an older base "costs precision at worst".
|
|
267
|
+
*/
|
|
268
|
+
export function newestBase(repo, candidates) {
|
|
269
|
+
const shas = [...new Set(candidates.filter(Boolean))].filter((s) => isCommit(repo, s) && isAncestor(repo, s))
|
|
270
|
+
if (shas.length <= 1) return shas[0] || null
|
|
271
|
+
const newest = shas.find((s) => shas.every((o) => o === s || isAncestor(repo, o, s)))
|
|
272
|
+
if (newest) return newest
|
|
273
|
+
try {
|
|
274
|
+
return gitText(repo.top, ['rev-list', '--no-walk', ...shas]).split('\n')[0].trim() || shas[0]
|
|
275
|
+
} catch {
|
|
276
|
+
return shas[0]
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// ── the stand-in ────────────────────────────────────────────────────────────
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* A stand-in for the repository, where the backend's content is written onto a commit's
|
|
284
|
+
* tree without touching the working tree.
|
|
285
|
+
*
|
|
286
|
+
* ⭐ It mirrors the repository by LINKS — the foundation, a workspace extension, the
|
|
287
|
+
* installed packages, anything resolved by relative path, resolves as in the project —
|
|
288
|
+
* except the site's content `roots`, which are real directories filled from `rev`, and the
|
|
289
|
+
* site's `sync.json` and `.uniweb/`, which are copies (written there, and taken back by the
|
|
290
|
+
* caller). The stand-in lives outside the project.
|
|
291
|
+
*
|
|
292
|
+
* ⛔ CLOSE IT. `close()` unlinks every link it made before removing the rest — a recursive
|
|
293
|
+
* remove that followed one would delete the project's own files.
|
|
294
|
+
*
|
|
295
|
+
* ⚖️ What is written under the roots where the repository ignores it — media in a folder
|
|
296
|
+
* kept out of git — is in no commit, so no merge brings it. `placeIgnored()` copies it
|
|
297
|
+
* into the project, where the project lacks it.
|
|
298
|
+
*
|
|
299
|
+
* @param {object} repo - `repoOf`
|
|
300
|
+
* @param {object} opts
|
|
301
|
+
* @param {string[]} opts.roots - the site's content roots, relative to the site
|
|
302
|
+
* @param {string|null} opts.rev - the commit whose content the roots are filled from;
|
|
303
|
+
* null for none
|
|
304
|
+
* @returns {{ siteDir: string, commit: Function, placeIgnored: Function, close: Function }}
|
|
305
|
+
*/
|
|
306
|
+
export function openStandIn(repo, { roots, rev }) {
|
|
307
|
+
for (const r of roots) {
|
|
308
|
+
if (r.split('/').includes('..') || r.startsWith('/')) {
|
|
309
|
+
throw new Error(`the content root \`${r}\` is outside the site, so a pull cannot merge it`)
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
const root = mkdtempSync(join(tmpdir(), 'uniweb-stand-in-'))
|
|
313
|
+
const indexDir = mkdtempSync(join(tmpdir(), 'uniweb-stand-in-index-'))
|
|
314
|
+
const env = { GIT_INDEX_FILE: join(indexDir, 'index') }
|
|
315
|
+
const at = (args, opts = {}) =>
|
|
316
|
+
git(repo.top, ['--git-dir', repo.gitDir, '--work-tree', root, ...args], { ...opts, env: { ...env, ...(opts.env || {}) } })
|
|
317
|
+
const links = []
|
|
318
|
+
const close = () => {
|
|
319
|
+
for (const l of links.reverse()) {
|
|
320
|
+
try {
|
|
321
|
+
unlinkSync(l)
|
|
322
|
+
} catch {
|
|
323
|
+
/* already gone */
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
rmSync(root, { recursive: true, force: true })
|
|
327
|
+
rmSync(indexDir, { recursive: true, force: true })
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
try {
|
|
331
|
+
const sitePrefix = repo.prefix // '' or 'site/'
|
|
332
|
+
const full = roots.map((r) => `${sitePrefix}${r}`)
|
|
333
|
+
const fullSet = new Set(full)
|
|
334
|
+
const sitePath = sitePrefix.replace(/\/$/, '')
|
|
335
|
+
const inSite = (name) => (sitePath ? `${sitePath}/${name}` : name)
|
|
336
|
+
const mustBeReal = (rel) =>
|
|
337
|
+
(sitePath && (sitePath === rel || sitePath.startsWith(`${rel}/`))) || full.some((f) => f.startsWith(`${rel}/`))
|
|
338
|
+
|
|
339
|
+
const link = (real, stand, name) => {
|
|
340
|
+
let dir = false
|
|
341
|
+
try {
|
|
342
|
+
dir = statSync(real).isDirectory()
|
|
343
|
+
} catch {
|
|
344
|
+
return // a dangling link resolves nothing in the project either
|
|
345
|
+
}
|
|
346
|
+
// ⛔ git reads neither through a link (2.32+), so a linked one would stage what the
|
|
347
|
+
// project ignores, and drop its attributes.
|
|
348
|
+
if (!dir && (name === '.gitignore' || name === '.gitattributes')) {
|
|
349
|
+
copyFileSync(real, stand)
|
|
350
|
+
return
|
|
351
|
+
}
|
|
352
|
+
try {
|
|
353
|
+
symlinkSync(real, stand, dir ? 'junction' : 'file')
|
|
354
|
+
links.push(stand)
|
|
355
|
+
} catch (err) {
|
|
356
|
+
// A file link needs a privilege some systems withhold; a copy reads the same.
|
|
357
|
+
if (dir) throw err
|
|
358
|
+
copyFileSync(real, stand)
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
const mirror = (realDir, standDir, rel) => {
|
|
362
|
+
mkdirSync(standDir, { recursive: true })
|
|
363
|
+
for (const name of readdirSync(realDir)) {
|
|
364
|
+
const r = rel ? `${rel}/${name}` : name
|
|
365
|
+
const real = join(realDir, name)
|
|
366
|
+
const stand = join(standDir, name)
|
|
367
|
+
if (!rel && name === '.git') continue
|
|
368
|
+
if (fullSet.has(r)) continue // filled from `rev`, below
|
|
369
|
+
if (r === inSite('.uniweb')) {
|
|
370
|
+
// This copy's own state, for the projection to read and write — taken back by the
|
|
371
|
+
// caller. Not the rename index: it names paths in the working tree, not in `rev`.
|
|
372
|
+
cpSync(real, stand, {
|
|
373
|
+
recursive: true,
|
|
374
|
+
filter: (src) => src !== join(real, 'pull-index.json')
|
|
375
|
+
})
|
|
376
|
+
continue
|
|
377
|
+
}
|
|
378
|
+
if (r === inSite('sync.json')) {
|
|
379
|
+
copyFileSync(real, stand)
|
|
380
|
+
continue
|
|
381
|
+
}
|
|
382
|
+
let st
|
|
383
|
+
try {
|
|
384
|
+
st = lstatSync(real)
|
|
385
|
+
} catch {
|
|
386
|
+
continue
|
|
387
|
+
}
|
|
388
|
+
if (mustBeReal(r)) {
|
|
389
|
+
// ⛔ The way to a content root is never a link: the roots are written, and a
|
|
390
|
+
// write through a link lands in the project.
|
|
391
|
+
if (!st.isDirectory()) throw new Error(`\`${r}\` is a link, so a pull cannot merge the site in place`)
|
|
392
|
+
mirror(real, stand, r)
|
|
393
|
+
} else link(real, stand, name)
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
mirror(repo.top, root, '')
|
|
397
|
+
if (sitePath) mkdirSync(join(root, sitePath), { recursive: true })
|
|
398
|
+
|
|
399
|
+
// The roots, as `rev` has them.
|
|
400
|
+
if (rev) at(['read-tree', rev])
|
|
401
|
+
else at(['read-tree', '--empty'])
|
|
402
|
+
const present = rev
|
|
403
|
+
? gitText(repo.top, ['ls-tree', '--name-only', '-z', '--full-tree', rev, '--', ...full], {})
|
|
404
|
+
.split('\0')
|
|
405
|
+
.filter(Boolean)
|
|
406
|
+
: []
|
|
407
|
+
if (present.length) at(['checkout', rev, '--', ...present])
|
|
408
|
+
|
|
409
|
+
const siteDir = sitePath ? join(root, sitePath) : root
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Commit the roots as the stand-in now holds them, onto `rev`'s tree — or report that
|
|
413
|
+
* they are what `rev` holds (`always` commits them anyway, to mark a history that has
|
|
414
|
+
* no sync point yet).
|
|
415
|
+
* @returns {{ commit: string|null, tree: string, siteTree: string|null }}
|
|
416
|
+
*/
|
|
417
|
+
const commit = ({ parent, subject, origin, siteUuid, always = false }) => {
|
|
418
|
+
const staged = held(full.filter((f) => present.includes(f) || existsSync(join(root, f))))
|
|
419
|
+
if (staged.length) at(['add', '-A', '--', ...staged])
|
|
420
|
+
const tree = at(['write-tree']).toString('utf8').trim()
|
|
421
|
+
const before = rev ? gitText(repo.top, ['rev-parse', `${rev}^{tree}`]).trim() : null
|
|
422
|
+
const siteTree = siteTreeIn(repo, tree)
|
|
423
|
+
if (tree === before && !always) return { commit: null, tree, siteTree }
|
|
424
|
+
const host = (() => {
|
|
425
|
+
try {
|
|
426
|
+
return new URL(origin).host
|
|
427
|
+
} catch {
|
|
428
|
+
return 'backend'
|
|
429
|
+
}
|
|
430
|
+
})()
|
|
431
|
+
const message = `${subject}\n\n${SYNC_TRAILER}: ${origin} ${siteUuid}\n${SITE_TRAILER}: ${repo.path}:${siteTree}\n`
|
|
432
|
+
const who = {
|
|
433
|
+
GIT_AUTHOR_NAME: 'uniweb',
|
|
434
|
+
GIT_AUTHOR_EMAIL: `sync@${host}`,
|
|
435
|
+
GIT_COMMITTER_NAME: 'uniweb',
|
|
436
|
+
GIT_COMMITTER_EMAIL: `sync@${host}`
|
|
437
|
+
}
|
|
438
|
+
const sha = gitText(repo.top, ['commit-tree', '--no-gpg-sign', tree, ...(parent ? ['-p', parent] : []), '-F', '-'], {
|
|
439
|
+
env: who,
|
|
440
|
+
input: message
|
|
441
|
+
}).trim()
|
|
442
|
+
return { commit: sha, tree, siteTree }
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Copy into the project what the stand-in holds under the roots where the repository
|
|
447
|
+
* ignores it, wherever the project lacks it. ⛔ Never over a file the project has:
|
|
448
|
+
* with no history there, nothing tells its version from the author's work.
|
|
449
|
+
* @returns {string[]} the paths placed, relative to the site
|
|
450
|
+
*/
|
|
451
|
+
const placeIgnored = () => {
|
|
452
|
+
const roots = full.filter((f) => existsSync(join(root, f)))
|
|
453
|
+
if (!roots.length) return []
|
|
454
|
+
const placed = []
|
|
455
|
+
for (const p of zlist(at(['ls-files', '--others', '--ignored', '--exclude-standard', '-z', '--', ...roots]))) {
|
|
456
|
+
const to = join(repo.top, p)
|
|
457
|
+
if (occupied(to)) continue
|
|
458
|
+
mkdirSync(dirname(to), { recursive: true })
|
|
459
|
+
copyFileSync(join(root, p), to)
|
|
460
|
+
placed.push(sitePrefix && p.startsWith(sitePrefix) ? p.slice(sitePrefix.length) : p)
|
|
461
|
+
}
|
|
462
|
+
return placed
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// The roots git can hold here. ⛔ One the repository ignores whole is not named to
|
|
466
|
+
// `git add`, which refuses an ignored path; what is written under it is placed instead.
|
|
467
|
+
const held = (paths) => {
|
|
468
|
+
if (!paths.length) return paths
|
|
469
|
+
let ignored = []
|
|
470
|
+
try {
|
|
471
|
+
ignored = zlist(at(['check-ignore', '-z', '--stdin'], { input: `${paths.join('\0')}\0` }))
|
|
472
|
+
} catch (err) {
|
|
473
|
+
if (err?.status !== 1) throw err // 1: none of them is ignored
|
|
474
|
+
}
|
|
475
|
+
return paths.filter((p) => !ignored.includes(p))
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
return { siteDir, commit, placeIgnored, close }
|
|
479
|
+
} catch (err) {
|
|
480
|
+
close()
|
|
481
|
+
throw err
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// ── merging ─────────────────────────────────────────────────────────────────
|
|
486
|
+
|
|
487
|
+
const zlist = (buf) => buf.toString('utf8').split('\0').filter(Boolean)
|
|
488
|
+
// Something is at `path` — a dangling link included, which `existsSync` reads as nothing.
|
|
489
|
+
const occupied = (path) => {
|
|
490
|
+
try {
|
|
491
|
+
lstatSync(path)
|
|
492
|
+
return true
|
|
493
|
+
} catch {
|
|
494
|
+
return false
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* Files not under git that the merge would write over: their paths, each with whether the
|
|
500
|
+
* file already holds what `commit` has there.
|
|
501
|
+
*/
|
|
502
|
+
function untrackedInTheWay(repo, commit) {
|
|
503
|
+
const added = zlist(git(repo.top, ['diff', '--name-only', '-z', '--diff-filter=A', 'HEAD', commit, '--', ...(repo.prefix ? [repo.prefix] : [])]))
|
|
504
|
+
const out = []
|
|
505
|
+
for (const p of added) {
|
|
506
|
+
const abs = join(repo.top, p)
|
|
507
|
+
if (!existsSync(abs)) continue
|
|
508
|
+
try {
|
|
509
|
+
git(repo.top, ['ls-files', '--error-unmatch', '--', p])
|
|
510
|
+
continue // in the index: the merge's own business
|
|
511
|
+
} catch {
|
|
512
|
+
/* untracked */
|
|
513
|
+
}
|
|
514
|
+
let same = false
|
|
515
|
+
try {
|
|
516
|
+
same = readFileSync(abs).equals(git(repo.top, ['cat-file', 'blob', `${commit}:${p}`]))
|
|
517
|
+
} catch {
|
|
518
|
+
same = false
|
|
519
|
+
}
|
|
520
|
+
out.push({ path: p, same })
|
|
521
|
+
}
|
|
522
|
+
return out
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* Merge a sync point into HEAD: git's three-way merge, against git's merge base — the sync
|
|
527
|
+
* point's parent, which HEAD descends from. Uncommitted work is stashed and put back when
|
|
528
|
+
* the merge is done (`--autostash`); a conflict leaves git's own state for a person to
|
|
529
|
+
* settle, as any merge does.
|
|
530
|
+
*
|
|
531
|
+
* @returns {{ ok: true, conflicted: string[] } | { ok: false, reason: string, paths?: string[] }}
|
|
532
|
+
*/
|
|
533
|
+
export function mergeSyncPoint(repo, commit, { message }) {
|
|
534
|
+
if (mergeInProgress(repo)) return { ok: false, reason: 'merge-in-progress' }
|
|
535
|
+
// Conflicts already here are not this merge's: git would refuse it, and they would read as its own.
|
|
536
|
+
const unsettled = unmergedPaths(repo)
|
|
537
|
+
if (unsettled.length) return { ok: false, reason: 'unmerged', paths: unsettled }
|
|
538
|
+
const inTheWay = untrackedInTheWay(repo, commit)
|
|
539
|
+
const blocking = inTheWay.filter((u) => !u.same).map((u) => u.path)
|
|
540
|
+
if (blocking.length) return { ok: false, reason: 'untracked', paths: blocking }
|
|
541
|
+
// One that already holds what the merge writes there is made way for — and only once the
|
|
542
|
+
// merge is going ahead, or it would be gone with nothing put back.
|
|
543
|
+
for (const u of inTheWay) rmSync(join(repo.top, u.path))
|
|
544
|
+
try {
|
|
545
|
+
git(repo.top, ['merge', '--autostash', '--no-edit', '--no-verify', '--allow-unrelated-histories', '-m', message, commit])
|
|
546
|
+
// ⛔ THE MERGE IS MADE, AND IT MAY STILL HAVE LEFT CONFLICTS. `--autostash` exits 0 when
|
|
547
|
+
// the uncommitted work it set aside cannot be put back over what the merge brought: the
|
|
548
|
+
// files then hold conflict markers, unmerged, and git keeps that work in the stash too.
|
|
549
|
+
// Until 2026-10-17 that read as a clean merge — a pull that exited 0 over the markers,
|
|
550
|
+
// so `uniweb pull && uniweb push` would send them.
|
|
551
|
+
const unstashed = unmergedPaths(repo)
|
|
552
|
+
return { ok: true, conflicted: unstashed, stashKept: unstashed.length > 0 }
|
|
553
|
+
} catch (err) {
|
|
554
|
+
const conflicted = zlist(git(repo.top, ['diff', '--name-only', '-z', '--diff-filter=U']))
|
|
555
|
+
if (conflicted.length) return { ok: true, conflicted }
|
|
556
|
+
return { ok: false, reason: 'git', detail: gitError(err) }
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* Take a sync point's roots whole — `pull --force`. Local changes under them are dropped
|
|
562
|
+
* and the branch gets a commit holding the sync point's content, with the sync point as a
|
|
563
|
+
* parent, so a later pull merges from it. Files outside the roots are left as they are.
|
|
564
|
+
*
|
|
565
|
+
* @returns {{ ok: true } | { ok: false, reason: string, detail?: string }}
|
|
566
|
+
*/
|
|
567
|
+
export function takeSyncPoint(repo, commit, { roots, message }) {
|
|
568
|
+
if (mergeInProgress(repo)) return { ok: false, reason: 'merge-in-progress' }
|
|
569
|
+
const full = [...roots.map((r) => `${repo.prefix}${r}`)]
|
|
570
|
+
try {
|
|
571
|
+
const tracked = zlist(git(repo.top, ['ls-tree', '--name-only', '-z', '--full-tree', 'HEAD', '--', ...full]))
|
|
572
|
+
if (tracked.length) git(repo.top, ['checkout', 'HEAD', '--', ...tracked])
|
|
573
|
+
for (const u of untrackedInTheWay(repo, commit)) rmSync(join(repo.top, u.path))
|
|
574
|
+
const tree = gitText(repo.top, ['rev-parse', `${commit}^{tree}`]).trim()
|
|
575
|
+
const merged = gitText(repo.top, ['commit-tree', tree, '-p', 'HEAD', '-p', commit, '-F', '-'], { input: `${message}\n` }).trim()
|
|
576
|
+
git(repo.top, ['read-tree', '-m', '-u', 'HEAD', merged])
|
|
577
|
+
git(repo.top, ['update-ref', '-m', message, 'HEAD', merged])
|
|
578
|
+
return { ok: true }
|
|
579
|
+
} catch (err) {
|
|
580
|
+
return { ok: false, reason: 'git', detail: gitError(err) }
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* What a pull changed in the files, under the site: `[status, path]` pairs — what the
|
|
586
|
+
* merge, or the take, moved from `from` (HEAD before it), less what the files already
|
|
587
|
+
* held as it left them. `files` is the files before it (`snapshotFiles`). `staged` reads a
|
|
588
|
+
* merge stopped on conflicts from the index; `skip` leaves out the paths in conflict,
|
|
589
|
+
* which are reported as conflicts.
|
|
590
|
+
*
|
|
591
|
+
* ⛔ Until 2026-10-17 a pull listed the backend's content against the base, as "changes
|
|
592
|
+
* from the backend": a file a push had already written into the files read as one, so
|
|
593
|
+
* did a person's own edit when there was no base — and `--force` said it "took" a change
|
|
594
|
+
* where it undid their edit.
|
|
595
|
+
*/
|
|
596
|
+
export function changedInFiles(repo, { from, files, staged = false, skip = [] }) {
|
|
597
|
+
const scope = ['--', ...(repo.prefix ? [repo.prefix] : [])]
|
|
598
|
+
try {
|
|
599
|
+
const parts = zlist(git(repo.top, ['diff', '--name-status', '-z', ...(staged ? ['--cached', from] : [from, 'HEAD']), ...scope]))
|
|
600
|
+
const moved = []
|
|
601
|
+
for (let i = 0; i < parts.length; ) {
|
|
602
|
+
const status = parts[i++]
|
|
603
|
+
const path = parts[i++]
|
|
604
|
+
moved.push(/^[RC]/.test(status) ? [status[0], parts[i++]] : [status, path])
|
|
605
|
+
}
|
|
606
|
+
// What the files already held as the merge left it is no change to them.
|
|
607
|
+
const differs = new Set(
|
|
608
|
+
zlist(git(repo.top, ['diff', '--name-only', '-z', '--no-renames', ...(staged ? ['--cached', files] : [files, 'HEAD']), ...scope]))
|
|
609
|
+
)
|
|
610
|
+
return moved
|
|
611
|
+
.filter(([s, p]) => s !== 'U' && !skip.includes(p) && differs.has(p))
|
|
612
|
+
.map(([s, p]) => [s, repo.prefix && p.startsWith(repo.prefix) ? p.slice(repo.prefix.length) : p])
|
|
613
|
+
} catch {
|
|
614
|
+
return []
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* The files as they are, as a commit to compare against — what is tracked, committed or
|
|
620
|
+
* not (`git stash create`, which touches neither the files nor any ref); HEAD when nothing
|
|
621
|
+
* is uncommitted.
|
|
622
|
+
*/
|
|
623
|
+
export function snapshotFiles(repo) {
|
|
624
|
+
try {
|
|
625
|
+
return (
|
|
626
|
+
gitText(repo.top, ['stash', 'create'], {
|
|
627
|
+
env: { GIT_AUTHOR_NAME: 'uniweb', GIT_AUTHOR_EMAIL: 'sync@uniweb', GIT_COMMITTER_NAME: 'uniweb', GIT_COMMITTER_EMAIL: 'sync@uniweb' }
|
|
628
|
+
}).trim() || repo.head
|
|
629
|
+
)
|
|
630
|
+
} catch {
|
|
631
|
+
return repo.head
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/**
|
|
636
|
+
* Does HEAD hold the site's content as the files do — nothing staged, changed or untracked
|
|
637
|
+
* under the roots? (What the repository ignores is no change.)
|
|
638
|
+
*/
|
|
639
|
+
export function contentCommitted(repo, roots) {
|
|
640
|
+
try {
|
|
641
|
+
const full = roots.map((r) => `${repo.prefix}${r}`)
|
|
642
|
+
return git(repo.top, ['status', '--porcelain', '-z', '--untracked-files=all', '--', ...full]).length === 0
|
|
643
|
+
} catch {
|
|
644
|
+
return false
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// ── this copy's own sync point ──────────────────────────────────────────────
|
|
649
|
+
|
|
650
|
+
const cachePath = (siteDir) => join(siteDir, '.uniweb', 'backend-cache.json')
|
|
651
|
+
const cacheKey = (origin) => {
|
|
652
|
+
try {
|
|
653
|
+
const u = new URL(origin)
|
|
654
|
+
return u.protocol === 'http:' || u.protocol === 'https:' ? u.origin : null
|
|
655
|
+
} catch {
|
|
656
|
+
return null
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/**
|
|
661
|
+
* The commit whose content the versions this copy holds for a backend describe — the
|
|
662
|
+
* one its last pull merged, or its last push was made from — or null.
|
|
663
|
+
*/
|
|
664
|
+
export function readSyncPoint(siteDir, origin) {
|
|
665
|
+
const key = cacheKey(origin)
|
|
666
|
+
if (!key) return null
|
|
667
|
+
try {
|
|
668
|
+
const file = JSON.parse(readFileSync(cachePath(siteDir), 'utf8'))
|
|
669
|
+
const v = file?.backends?.[key]?.syncPoint
|
|
670
|
+
return typeof v === 'string' && v ? v : null
|
|
671
|
+
} catch {
|
|
672
|
+
return null
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/** Record it — beside the versions it describes, in `.uniweb/backend-cache.json`. */
|
|
677
|
+
export function writeSyncPoint(siteDir, origin, sha) {
|
|
678
|
+
const key = cacheKey(origin)
|
|
679
|
+
if (!key) return
|
|
680
|
+
const p = cachePath(siteDir)
|
|
681
|
+
let file = { backends: {} }
|
|
682
|
+
try {
|
|
683
|
+
const parsed = JSON.parse(readFileSync(p, 'utf8'))
|
|
684
|
+
if (parsed?.backends && typeof parsed.backends === 'object') file = parsed
|
|
685
|
+
} catch {
|
|
686
|
+
/* none yet */
|
|
687
|
+
}
|
|
688
|
+
file.backends[key] = { ...(file.backends[key] || {}), syncPoint: sha }
|
|
689
|
+
const sorted = {}
|
|
690
|
+
for (const k of Object.keys(file.backends).sort()) sorted[k] = file.backends[k]
|
|
691
|
+
mkdirSync(dirname(p), { recursive: true })
|
|
692
|
+
writeFileSync(p, JSON.stringify({ version: 1, backends: sorted }, null, 2) + '\n')
|
|
693
|
+
}
|