uniweb 1.7.4 → 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/package.json +5 -5
- package/partials/agents.md +2 -2
- package/src/backend/client.js +22 -4
- package/src/backend/site-sync.js +70 -48
- package/src/commands/clone.js +55 -29
- package/src/commands/publish.js +11 -0
- package/src/commands/pull.js +423 -463
- package/src/commands/push.js +12 -0
- package/src/commands/refresh.js +16 -17
- package/src/index.js +5 -4
- package/src/utils/flag-guard.js +20 -8
- package/src/utils/git.js +20 -213
- package/src/utils/pull-written.js +15 -28
- package/src/utils/sync-point.js +512 -0
|
@@ -0,0 +1,512 @@
|
|
|
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. This copy keeps it
|
|
14
|
+
* (`backend-cache.json`, `syncPoint`), and the backend can name it on a pull
|
|
15
|
+
* (`x-uniweb-source`, `backend/client.js`), for every copy that has it.
|
|
16
|
+
*
|
|
17
|
+
* ⚖️ Any sync point a history holds is a safe base: both the files and the backend's
|
|
18
|
+
* content descend from it, so neither side's change can read as the other's undoing. The
|
|
19
|
+
* newest one is the precise base — an older one costs conflicts, never content.
|
|
20
|
+
*
|
|
21
|
+
* ⛔ Until 2026-10-17 the ancestor was a per-machine list of the files this copy's last pull
|
|
22
|
+
* wrote, by hash, found again in history by content — and HEAD where the list had no
|
|
23
|
+
* entry. Committed work outside the list was lost: a teammate's new section pruned, a
|
|
24
|
+
* committed record edit overwritten, a fresh clone's committed work reverted, and the
|
|
25
|
+
* committed half of a file edited again since reverted (`test/pull-merge-base.test.js`).
|
|
26
|
+
*
|
|
27
|
+
* Dependency-free: `clone` uses it before a project's packages are installed.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { execFileSync } from 'node:child_process'
|
|
31
|
+
import {
|
|
32
|
+
copyFileSync,
|
|
33
|
+
cpSync,
|
|
34
|
+
existsSync,
|
|
35
|
+
lstatSync,
|
|
36
|
+
mkdirSync,
|
|
37
|
+
mkdtempSync,
|
|
38
|
+
readdirSync,
|
|
39
|
+
readFileSync,
|
|
40
|
+
rmSync,
|
|
41
|
+
statSync,
|
|
42
|
+
symlinkSync,
|
|
43
|
+
unlinkSync,
|
|
44
|
+
writeFileSync
|
|
45
|
+
} from 'node:fs'
|
|
46
|
+
import { tmpdir } from 'node:os'
|
|
47
|
+
import { join, dirname } from 'node:path'
|
|
48
|
+
|
|
49
|
+
export const SYNC_TRAILER = 'Uniweb-Sync'
|
|
50
|
+
export const SITE_TRAILER = 'Uniweb-Site'
|
|
51
|
+
|
|
52
|
+
const MAX_BUFFER = 1024 * 1024 * 1024
|
|
53
|
+
|
|
54
|
+
function git(cwd, args, { env, input } = {}) {
|
|
55
|
+
return execFileSync('git', args, {
|
|
56
|
+
cwd,
|
|
57
|
+
env: env ? { ...process.env, ...env } : process.env,
|
|
58
|
+
input,
|
|
59
|
+
maxBuffer: MAX_BUFFER,
|
|
60
|
+
stdio: [input == null ? 'ignore' : 'pipe', 'pipe', 'pipe']
|
|
61
|
+
})
|
|
62
|
+
}
|
|
63
|
+
const gitText = (cwd, args, opts) => git(cwd, args, opts).toString('utf8')
|
|
64
|
+
|
|
65
|
+
/** git's own words when a command fails — what it printed, not our paraphrase. */
|
|
66
|
+
export function gitError(err) {
|
|
67
|
+
const said = [err?.stderr, err?.stdout].map((b) => (b ? String(b).trim() : '')).filter(Boolean)
|
|
68
|
+
return said.join('\n') || err?.message || 'git failed'
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The repository a site is in — its top level, the site's path in it, the git
|
|
73
|
+
* directory and HEAD (null on a branch with no commits) — or null outside one.
|
|
74
|
+
*/
|
|
75
|
+
export function repoOf(siteDir) {
|
|
76
|
+
let top, prefix, gitDir
|
|
77
|
+
try {
|
|
78
|
+
top = gitText(siteDir, ['rev-parse', '--show-toplevel']).trim()
|
|
79
|
+
prefix = gitText(siteDir, ['rev-parse', '--show-prefix']).trim()
|
|
80
|
+
gitDir = gitText(siteDir, ['rev-parse', '--absolute-git-dir']).trim()
|
|
81
|
+
} catch {
|
|
82
|
+
return null
|
|
83
|
+
}
|
|
84
|
+
if (!top) return null
|
|
85
|
+
let head = null
|
|
86
|
+
try {
|
|
87
|
+
head = gitText(top, ['rev-parse', '--verify', '-q', 'HEAD^{commit}']).trim() || null
|
|
88
|
+
} catch {
|
|
89
|
+
head = null
|
|
90
|
+
}
|
|
91
|
+
return { top, prefix, path: prefix ? prefix.replace(/\/$/, '') : '.', gitDir, head }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Is `ancestor` in the history of `of` — or `of` itself? */
|
|
95
|
+
export function isAncestor(repo, ancestor, of = 'HEAD') {
|
|
96
|
+
try {
|
|
97
|
+
git(repo.top, ['merge-base', '--is-ancestor', ancestor, of])
|
|
98
|
+
return true
|
|
99
|
+
} catch {
|
|
100
|
+
return false
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function isCommit(repo, sha) {
|
|
105
|
+
if (typeof sha !== 'string' || !/^[0-9a-f]{7,64}$/i.test(sha)) return false
|
|
106
|
+
try {
|
|
107
|
+
git(repo.top, ['cat-file', '-e', `${sha}^{commit}`])
|
|
108
|
+
return true
|
|
109
|
+
} catch {
|
|
110
|
+
return false
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The tree of the site's directory in a commit or tree, or null. */
|
|
115
|
+
export function siteTreeIn(repo, rev) {
|
|
116
|
+
try {
|
|
117
|
+
return gitText(repo.top, ['rev-parse', '--verify', '-q', repo.path === '.' ? `${rev}^{tree}` : `${rev}:${repo.path}`]).trim() || null
|
|
118
|
+
} catch {
|
|
119
|
+
return null
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Is a merge waiting for its commit here? */
|
|
124
|
+
export function mergeInProgress(repo) {
|
|
125
|
+
try {
|
|
126
|
+
git(repo.top, ['rev-parse', '--verify', '-q', 'MERGE_HEAD'])
|
|
127
|
+
return true
|
|
128
|
+
} catch {
|
|
129
|
+
return false
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function trailersOf(body) {
|
|
134
|
+
const out = {}
|
|
135
|
+
for (const line of String(body).split('\n')) {
|
|
136
|
+
let m = line.match(/^Uniweb-Sync: (\S+) (\S+)\s*$/)
|
|
137
|
+
if (m) {
|
|
138
|
+
out.origin = m[1]
|
|
139
|
+
out.siteUuid = m[2]
|
|
140
|
+
continue
|
|
141
|
+
}
|
|
142
|
+
m = line.match(/^Uniweb-Site: (.+):([0-9a-f]{40,64})\s*$/)
|
|
143
|
+
if (m) {
|
|
144
|
+
out.path = m[1]
|
|
145
|
+
out.tree = m[2]
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return out
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* The newest commit a pull made for this site on this backend that HEAD's history
|
|
153
|
+
* holds — and that still holds what that pull wrote. ⛔ A rebase or a cherry-pick copies
|
|
154
|
+
* the trailers onto a commit whose tree is no longer the backend's, so each one is
|
|
155
|
+
* checked against the site tree it names.
|
|
156
|
+
*/
|
|
157
|
+
export function historySyncPoint(repo, { origin, siteUuid }) {
|
|
158
|
+
if (!repo?.head) return null
|
|
159
|
+
let out
|
|
160
|
+
try {
|
|
161
|
+
out = gitText(repo.top, [
|
|
162
|
+
'log',
|
|
163
|
+
'--fixed-strings',
|
|
164
|
+
`--grep=${SYNC_TRAILER}: ${origin} ${siteUuid}`,
|
|
165
|
+
'--format=%H%x1f%B%x1e',
|
|
166
|
+
'HEAD'
|
|
167
|
+
])
|
|
168
|
+
} catch {
|
|
169
|
+
return null
|
|
170
|
+
}
|
|
171
|
+
for (const record of out.split('\x1e')) {
|
|
172
|
+
const [sha = '', body = ''] = record.replace(/^\s+/, '').split('\x1f')
|
|
173
|
+
if (!/^[0-9a-f]{40,64}$/.test(sha)) continue
|
|
174
|
+
const t = trailersOf(body)
|
|
175
|
+
if (t.origin !== origin || t.siteUuid !== siteUuid || t.path !== repo.path) continue
|
|
176
|
+
if (siteTreeIn(repo, sha) === t.tree) return sha
|
|
177
|
+
}
|
|
178
|
+
return null
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* The base a pull merges from: the newest of the candidates HEAD descends from. One that
|
|
183
|
+
* descends from all the others is the newest; with none, the most recently committed —
|
|
184
|
+
* any of them is a state both sides descend from, so the choice costs precision at worst.
|
|
185
|
+
*/
|
|
186
|
+
export function newestBase(repo, candidates) {
|
|
187
|
+
const shas = [...new Set(candidates.filter(Boolean))].filter((s) => isCommit(repo, s) && isAncestor(repo, s))
|
|
188
|
+
if (shas.length <= 1) return shas[0] || null
|
|
189
|
+
const newest = shas.find((s) => shas.every((o) => o === s || isAncestor(repo, o, s)))
|
|
190
|
+
if (newest) return newest
|
|
191
|
+
try {
|
|
192
|
+
return gitText(repo.top, ['rev-list', '--no-walk', ...shas]).split('\n')[0].trim() || shas[0]
|
|
193
|
+
} catch {
|
|
194
|
+
return shas[0]
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ── the stand-in ────────────────────────────────────────────────────────────
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* A stand-in for the repository, where the backend's content is written onto a commit's
|
|
202
|
+
* tree without touching the working tree.
|
|
203
|
+
*
|
|
204
|
+
* ⭐ It mirrors the repository by LINKS — the foundation, a workspace extension, the
|
|
205
|
+
* installed packages, anything resolved by relative path, resolves as in the project —
|
|
206
|
+
* except the site's content `roots`, which are real directories filled from `rev`, and the
|
|
207
|
+
* site's `sync.json` and `.uniweb/`, which are copies (written there, and taken back by the
|
|
208
|
+
* caller). The stand-in lives outside the project.
|
|
209
|
+
*
|
|
210
|
+
* ⛔ CLOSE IT. `close()` unlinks every link it made before removing the rest — a recursive
|
|
211
|
+
* remove that followed one would delete the project's own files.
|
|
212
|
+
*
|
|
213
|
+
* @param {object} repo - `repoOf`
|
|
214
|
+
* @param {object} opts
|
|
215
|
+
* @param {string[]} opts.roots - the site's content roots, relative to the site
|
|
216
|
+
* @param {string|null} opts.rev - the commit whose content the roots are filled from;
|
|
217
|
+
* null for none
|
|
218
|
+
* @returns {{ siteDir: string, commit: Function, close: Function }}
|
|
219
|
+
*/
|
|
220
|
+
export function openStandIn(repo, { roots, rev }) {
|
|
221
|
+
for (const r of roots) {
|
|
222
|
+
if (r.split('/').includes('..') || r.startsWith('/')) {
|
|
223
|
+
throw new Error(`the content root \`${r}\` is outside the site, so a pull cannot merge it`)
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
const root = mkdtempSync(join(tmpdir(), 'uniweb-stand-in-'))
|
|
227
|
+
const indexDir = mkdtempSync(join(tmpdir(), 'uniweb-stand-in-index-'))
|
|
228
|
+
const env = { GIT_INDEX_FILE: join(indexDir, 'index') }
|
|
229
|
+
const at = (args, opts = {}) =>
|
|
230
|
+
git(repo.top, ['--git-dir', repo.gitDir, '--work-tree', root, ...args], { ...opts, env: { ...env, ...(opts.env || {}) } })
|
|
231
|
+
const links = []
|
|
232
|
+
const close = () => {
|
|
233
|
+
for (const l of links.reverse()) {
|
|
234
|
+
try {
|
|
235
|
+
unlinkSync(l)
|
|
236
|
+
} catch {
|
|
237
|
+
/* already gone */
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
rmSync(root, { recursive: true, force: true })
|
|
241
|
+
rmSync(indexDir, { recursive: true, force: true })
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
try {
|
|
245
|
+
const sitePrefix = repo.prefix // '' or 'site/'
|
|
246
|
+
const full = roots.map((r) => `${sitePrefix}${r}`)
|
|
247
|
+
const fullSet = new Set(full)
|
|
248
|
+
const sitePath = sitePrefix.replace(/\/$/, '')
|
|
249
|
+
const inSite = (name) => (sitePath ? `${sitePath}/${name}` : name)
|
|
250
|
+
const mustBeReal = (rel) =>
|
|
251
|
+
(sitePath && (sitePath === rel || sitePath.startsWith(`${rel}/`))) || full.some((f) => f.startsWith(`${rel}/`))
|
|
252
|
+
|
|
253
|
+
const link = (real, stand, name) => {
|
|
254
|
+
let dir = false
|
|
255
|
+
try {
|
|
256
|
+
dir = statSync(real).isDirectory()
|
|
257
|
+
} catch {
|
|
258
|
+
return // a dangling link resolves nothing in the project either
|
|
259
|
+
}
|
|
260
|
+
// ⛔ git reads neither through a link (2.32+), so a linked one would stage what the
|
|
261
|
+
// project ignores, and drop its attributes.
|
|
262
|
+
if (!dir && (name === '.gitignore' || name === '.gitattributes')) {
|
|
263
|
+
copyFileSync(real, stand)
|
|
264
|
+
return
|
|
265
|
+
}
|
|
266
|
+
try {
|
|
267
|
+
symlinkSync(real, stand, dir ? 'junction' : 'file')
|
|
268
|
+
links.push(stand)
|
|
269
|
+
} catch (err) {
|
|
270
|
+
// A file link needs a privilege some systems withhold; a copy reads the same.
|
|
271
|
+
if (dir) throw err
|
|
272
|
+
copyFileSync(real, stand)
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
const mirror = (realDir, standDir, rel) => {
|
|
276
|
+
mkdirSync(standDir, { recursive: true })
|
|
277
|
+
for (const name of readdirSync(realDir)) {
|
|
278
|
+
const r = rel ? `${rel}/${name}` : name
|
|
279
|
+
const real = join(realDir, name)
|
|
280
|
+
const stand = join(standDir, name)
|
|
281
|
+
if (!rel && name === '.git') continue
|
|
282
|
+
if (fullSet.has(r)) continue // filled from `rev`, below
|
|
283
|
+
if (r === inSite('.uniweb')) {
|
|
284
|
+
// This copy's own state, for the projection to read and write — taken back by the
|
|
285
|
+
// caller. Not the rename index: it names paths in the working tree, not in `rev`.
|
|
286
|
+
cpSync(real, stand, {
|
|
287
|
+
recursive: true,
|
|
288
|
+
filter: (src) => src !== join(real, 'pull-index.json')
|
|
289
|
+
})
|
|
290
|
+
continue
|
|
291
|
+
}
|
|
292
|
+
if (r === inSite('sync.json')) {
|
|
293
|
+
copyFileSync(real, stand)
|
|
294
|
+
continue
|
|
295
|
+
}
|
|
296
|
+
let st
|
|
297
|
+
try {
|
|
298
|
+
st = lstatSync(real)
|
|
299
|
+
} catch {
|
|
300
|
+
continue
|
|
301
|
+
}
|
|
302
|
+
if (mustBeReal(r)) {
|
|
303
|
+
// ⛔ The way to a content root is never a link: the roots are written, and a
|
|
304
|
+
// write through a link lands in the project.
|
|
305
|
+
if (!st.isDirectory()) throw new Error(`\`${r}\` is a link, so a pull cannot merge the site in place`)
|
|
306
|
+
mirror(real, stand, r)
|
|
307
|
+
} else link(real, stand, name)
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
mirror(repo.top, root, '')
|
|
311
|
+
if (sitePath) mkdirSync(join(root, sitePath), { recursive: true })
|
|
312
|
+
|
|
313
|
+
// The roots, as `rev` has them.
|
|
314
|
+
if (rev) at(['read-tree', rev])
|
|
315
|
+
else at(['read-tree', '--empty'])
|
|
316
|
+
const present = rev
|
|
317
|
+
? gitText(repo.top, ['ls-tree', '--name-only', '-z', '--full-tree', rev, '--', ...full], {})
|
|
318
|
+
.split('\0')
|
|
319
|
+
.filter(Boolean)
|
|
320
|
+
: []
|
|
321
|
+
if (present.length) at(['checkout', rev, '--', ...present])
|
|
322
|
+
|
|
323
|
+
const siteDir = sitePath ? join(root, sitePath) : root
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Commit the roots as the stand-in now holds them, onto `rev`'s tree — or report that
|
|
327
|
+
* they are what `rev` holds (`always` commits them anyway, to mark a history that has
|
|
328
|
+
* no sync point yet).
|
|
329
|
+
* @returns {{ commit: string|null, tree: string, siteTree: string|null }}
|
|
330
|
+
*/
|
|
331
|
+
const commit = ({ parent, subject, origin, siteUuid, always = false }) => {
|
|
332
|
+
const staged = full.filter((f) => present.includes(f) || existsSync(join(root, f)))
|
|
333
|
+
if (staged.length) at(['add', '-A', '--', ...staged])
|
|
334
|
+
const tree = at(['write-tree']).toString('utf8').trim()
|
|
335
|
+
const before = rev ? gitText(repo.top, ['rev-parse', `${rev}^{tree}`]).trim() : null
|
|
336
|
+
const siteTree = siteTreeIn(repo, tree)
|
|
337
|
+
if (tree === before && !always) return { commit: null, tree, siteTree }
|
|
338
|
+
const host = (() => {
|
|
339
|
+
try {
|
|
340
|
+
return new URL(origin).host
|
|
341
|
+
} catch {
|
|
342
|
+
return 'backend'
|
|
343
|
+
}
|
|
344
|
+
})()
|
|
345
|
+
const message = `${subject}\n\n${SYNC_TRAILER}: ${origin} ${siteUuid}\n${SITE_TRAILER}: ${repo.path}:${siteTree}\n`
|
|
346
|
+
const who = {
|
|
347
|
+
GIT_AUTHOR_NAME: 'uniweb',
|
|
348
|
+
GIT_AUTHOR_EMAIL: `sync@${host}`,
|
|
349
|
+
GIT_COMMITTER_NAME: 'uniweb',
|
|
350
|
+
GIT_COMMITTER_EMAIL: `sync@${host}`
|
|
351
|
+
}
|
|
352
|
+
const sha = gitText(repo.top, ['commit-tree', '--no-gpg-sign', tree, ...(parent ? ['-p', parent] : []), '-F', '-'], {
|
|
353
|
+
env: who,
|
|
354
|
+
input: message
|
|
355
|
+
}).trim()
|
|
356
|
+
return { commit: sha, tree, siteTree }
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
return { siteDir, commit, close }
|
|
360
|
+
} catch (err) {
|
|
361
|
+
close()
|
|
362
|
+
throw err
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// ── merging ─────────────────────────────────────────────────────────────────
|
|
367
|
+
|
|
368
|
+
const zlist = (buf) => buf.toString('utf8').split('\0').filter(Boolean)
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Files not under git that the merge would write over: their paths, each with whether the
|
|
372
|
+
* file already holds what `commit` has there.
|
|
373
|
+
*/
|
|
374
|
+
function untrackedInTheWay(repo, commit) {
|
|
375
|
+
const added = zlist(git(repo.top, ['diff', '--name-only', '-z', '--diff-filter=A', 'HEAD', commit, '--', ...(repo.prefix ? [repo.prefix] : [])]))
|
|
376
|
+
const out = []
|
|
377
|
+
for (const p of added) {
|
|
378
|
+
const abs = join(repo.top, p)
|
|
379
|
+
if (!existsSync(abs)) continue
|
|
380
|
+
try {
|
|
381
|
+
git(repo.top, ['ls-files', '--error-unmatch', '--', p])
|
|
382
|
+
continue // in the index: the merge's own business
|
|
383
|
+
} catch {
|
|
384
|
+
/* untracked */
|
|
385
|
+
}
|
|
386
|
+
let same = false
|
|
387
|
+
try {
|
|
388
|
+
same = readFileSync(abs).equals(git(repo.top, ['cat-file', 'blob', `${commit}:${p}`]))
|
|
389
|
+
} catch {
|
|
390
|
+
same = false
|
|
391
|
+
}
|
|
392
|
+
out.push({ path: p, same })
|
|
393
|
+
}
|
|
394
|
+
return out
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Merge a sync point into HEAD: git's three-way merge, against git's merge base — the sync
|
|
399
|
+
* point's parent, which HEAD descends from. Uncommitted work is stashed and put back when
|
|
400
|
+
* the merge is done (`--autostash`); a conflict leaves git's own state for a person to
|
|
401
|
+
* settle, as any merge does.
|
|
402
|
+
*
|
|
403
|
+
* @returns {{ ok: true, conflicted: string[] } | { ok: false, reason: string, paths?: string[] }}
|
|
404
|
+
*/
|
|
405
|
+
export function mergeSyncPoint(repo, commit, { message }) {
|
|
406
|
+
if (mergeInProgress(repo)) return { ok: false, reason: 'merge-in-progress' }
|
|
407
|
+
const inTheWay = untrackedInTheWay(repo, commit)
|
|
408
|
+
const blocking = inTheWay.filter((u) => !u.same).map((u) => u.path)
|
|
409
|
+
if (blocking.length) return { ok: false, reason: 'untracked', paths: blocking }
|
|
410
|
+
// One that already holds what the merge writes there is made way for — and only once the
|
|
411
|
+
// merge is going ahead, or it would be gone with nothing put back.
|
|
412
|
+
for (const u of inTheWay) rmSync(join(repo.top, u.path))
|
|
413
|
+
try {
|
|
414
|
+
git(repo.top, ['merge', '--autostash', '--no-edit', '--no-verify', '--allow-unrelated-histories', '-m', message, commit])
|
|
415
|
+
return { ok: true, conflicted: [] }
|
|
416
|
+
} catch (err) {
|
|
417
|
+
const conflicted = zlist(git(repo.top, ['diff', '--name-only', '-z', '--diff-filter=U']))
|
|
418
|
+
if (conflicted.length) return { ok: true, conflicted }
|
|
419
|
+
return { ok: false, reason: 'git', detail: gitError(err) }
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Take a sync point's roots whole — `pull --force`. Local changes under them are dropped
|
|
425
|
+
* and the branch gets a commit holding the sync point's content, with the sync point as a
|
|
426
|
+
* parent, so a later pull merges from it. Files outside the roots are left as they are.
|
|
427
|
+
*
|
|
428
|
+
* @returns {{ ok: true } | { ok: false, reason: string, detail?: string }}
|
|
429
|
+
*/
|
|
430
|
+
export function takeSyncPoint(repo, commit, { roots, message }) {
|
|
431
|
+
if (mergeInProgress(repo)) return { ok: false, reason: 'merge-in-progress' }
|
|
432
|
+
const full = [...roots.map((r) => `${repo.prefix}${r}`)]
|
|
433
|
+
try {
|
|
434
|
+
const tracked = zlist(git(repo.top, ['ls-tree', '--name-only', '-z', '--full-tree', 'HEAD', '--', ...full]))
|
|
435
|
+
if (tracked.length) git(repo.top, ['checkout', 'HEAD', '--', ...tracked])
|
|
436
|
+
for (const u of untrackedInTheWay(repo, commit)) rmSync(join(repo.top, u.path))
|
|
437
|
+
const tree = gitText(repo.top, ['rev-parse', `${commit}^{tree}`]).trim()
|
|
438
|
+
const merged = gitText(repo.top, ['commit-tree', tree, '-p', 'HEAD', '-p', commit, '-F', '-'], { input: `${message}\n` }).trim()
|
|
439
|
+
git(repo.top, ['read-tree', '-m', '-u', 'HEAD', merged])
|
|
440
|
+
git(repo.top, ['update-ref', '-m', message, 'HEAD', merged])
|
|
441
|
+
return { ok: true }
|
|
442
|
+
} catch (err) {
|
|
443
|
+
return { ok: false, reason: 'git', detail: gitError(err) }
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/** What the backend changed between two commits, under the site: `[status, path]` pairs. */
|
|
448
|
+
export function changesBetween(repo, from, to) {
|
|
449
|
+
try {
|
|
450
|
+
// No base: against the empty tree, in this repository's own hash.
|
|
451
|
+
const base = from || gitText(repo.top, ['hash-object', '-t', 'tree', '--stdin'], { input: '' }).trim()
|
|
452
|
+
const out = gitText(repo.top, ['diff', '--name-status', '-z', base, to, '--', ...(repo.prefix ? [repo.prefix] : [])])
|
|
453
|
+
const parts = out.split('\0').filter(Boolean)
|
|
454
|
+
const changes = []
|
|
455
|
+
for (let i = 0; i < parts.length; ) {
|
|
456
|
+
const status = parts[i++]
|
|
457
|
+
const path = parts[i++]
|
|
458
|
+
if (/^[RC]/.test(status)) changes.push([status[0], parts[i++]])
|
|
459
|
+
else changes.push([status, path])
|
|
460
|
+
}
|
|
461
|
+
return changes.map(([s, p]) => [s, repo.prefix && p.startsWith(repo.prefix) ? p.slice(repo.prefix.length) : p])
|
|
462
|
+
} catch {
|
|
463
|
+
return []
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// ── this copy's own sync point ──────────────────────────────────────────────
|
|
468
|
+
|
|
469
|
+
const cachePath = (siteDir) => join(siteDir, '.uniweb', 'backend-cache.json')
|
|
470
|
+
const cacheKey = (origin) => {
|
|
471
|
+
try {
|
|
472
|
+
const u = new URL(origin)
|
|
473
|
+
return u.protocol === 'http:' || u.protocol === 'https:' ? u.origin : null
|
|
474
|
+
} catch {
|
|
475
|
+
return null
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* The commit whose content the versions this copy holds for a backend describe — the
|
|
481
|
+
* one its last pull merged, or its last push was made from — or null.
|
|
482
|
+
*/
|
|
483
|
+
export function readSyncPoint(siteDir, origin) {
|
|
484
|
+
const key = cacheKey(origin)
|
|
485
|
+
if (!key) return null
|
|
486
|
+
try {
|
|
487
|
+
const file = JSON.parse(readFileSync(cachePath(siteDir), 'utf8'))
|
|
488
|
+
const v = file?.backends?.[key]?.syncPoint
|
|
489
|
+
return typeof v === 'string' && v ? v : null
|
|
490
|
+
} catch {
|
|
491
|
+
return null
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/** Record it — beside the versions it describes, in `.uniweb/backend-cache.json`. */
|
|
496
|
+
export function writeSyncPoint(siteDir, origin, sha) {
|
|
497
|
+
const key = cacheKey(origin)
|
|
498
|
+
if (!key) return
|
|
499
|
+
const p = cachePath(siteDir)
|
|
500
|
+
let file = { backends: {} }
|
|
501
|
+
try {
|
|
502
|
+
const parsed = JSON.parse(readFileSync(p, 'utf8'))
|
|
503
|
+
if (parsed?.backends && typeof parsed.backends === 'object') file = parsed
|
|
504
|
+
} catch {
|
|
505
|
+
/* none yet */
|
|
506
|
+
}
|
|
507
|
+
file.backends[key] = { ...(file.backends[key] || {}), syncPoint: sha }
|
|
508
|
+
const sorted = {}
|
|
509
|
+
for (const k of Object.keys(file.backends).sort()) sorted[k] = file.backends[k]
|
|
510
|
+
mkdirSync(dirname(p), { recursive: true })
|
|
511
|
+
writeFileSync(p, JSON.stringify({ version: 1, backends: sorted }, null, 2) + '\n')
|
|
512
|
+
}
|