create-wxl 3.0.0 → 3.1.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/bin/create-wxl.mjs +2 -0
- package/dist/fork-init.mjs +15 -14
- package/dist/source.json +3 -3
- package/package.json +1 -1
- package/src/archive-rules.mjs +19 -3
- package/src/argv.mjs +59 -2
- package/src/base-version.mjs +10 -0
- package/src/git-named.mjs +43 -0
- package/src/git-runner.mjs +27 -14
- package/src/git-version.mjs +5 -3
- package/src/merge-apply.mjs +156 -0
- package/src/merge-plan.mjs +660 -0
- package/src/messages.mjs +232 -8
- package/src/patterns.mjs +6 -0
- package/src/questions.mjs +3 -3
- package/src/run-shared.mjs +207 -0
- package/src/run.mjs +36 -136
- package/src/text.mjs +47 -0
- package/src/upgrade-report.mjs +134 -0
- package/src/upgrade.mjs +582 -0
|
@@ -0,0 +1,660 @@
|
|
|
1
|
+
// Step 8 (traceability.md "Step order" row 8, "Pre-checks", "Merge table"): for every path in
|
|
2
|
+
// B ∪ T ∪ tracked, decides what happens to `o` by the pre-checks and the merge table, and (for an
|
|
3
|
+
// M12 path) shells out to the real `git merge-file`. Nothing is written here -- `merge-apply.mjs`
|
|
4
|
+
// executes the plan this module returns. b and t are present only when their exact path is among
|
|
5
|
+
// the names the pristine range lists, so a case-insensitive file system cannot merge two paths (U53).
|
|
6
|
+
|
|
7
|
+
import { join } from 'node:path'
|
|
8
|
+
import { buildGitEnv } from './git-env.mjs'
|
|
9
|
+
import { runGitNamed, isIgnoredPath } from './git-named.mjs'
|
|
10
|
+
import { toSlash } from './text.mjs'
|
|
11
|
+
|
|
12
|
+
/** M12 (traceability.md: "Any other exit, a signal, or output not read in full -> UP-E11"):
|
|
13
|
+
* `git merge-file` ended outside the `0`-`127` range it can legitimately exit with (a clean merge
|
|
14
|
+
* is `0`, a conflict is `1`-`127`; anything else, including `null` for a signal, is a planning
|
|
15
|
+
* failure, not a result to write). `path` is the one `upgrade.mjs` reports. */
|
|
16
|
+
export class MergeFileError extends Error {
|
|
17
|
+
constructor(path) {
|
|
18
|
+
super(`git merge-file exited outside 0-127 at ${path}`)
|
|
19
|
+
this.name = 'MergeFileError'
|
|
20
|
+
this.path = path
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** traceability.md "Codes" UP-E11: "a read error, or `git merge-file` ... or was not read in
|
|
25
|
+
* full"): a read error of `o`, `b` or `t` (other than the absent cases
|
|
26
|
+
* `readFileOrNull` already maps to `null`), or a `runGit` rejection for `merge-file`, is a
|
|
27
|
+
* planning failure naming `path` and carrying `detail` (the underlying error's own message) --
|
|
28
|
+
* unlike `MergeFileError`, which never has a `detail` (its own fixed text already says enough). */
|
|
29
|
+
export class PlanningError extends Error {
|
|
30
|
+
constructor(path, detail) {
|
|
31
|
+
super(`step 8 planning failed at ${path}: ${detail}`)
|
|
32
|
+
this.name = 'PlanningError'
|
|
33
|
+
this.path = path
|
|
34
|
+
this.detail = detail
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const BINARY_SCAN_BYTES = 8000
|
|
39
|
+
|
|
40
|
+
function isBinary(buf) {
|
|
41
|
+
if (buf === null) return false
|
|
42
|
+
return buf.subarray(0, BINARY_SCAN_BYTES).includes(0)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** T-0027 increment 17 (U56): `0x0D 0x0A` -> `0x0A`, scanning raw bytes -- never decode to a
|
|
46
|
+
* string and back, which would corrupt a non-UTF-8 file (D4's own problem) before D4 even lands.
|
|
47
|
+
* Only ever applied to a side already classified as text (binary is decided on RAW bytes first,
|
|
48
|
+
* below, unaffected by this). */
|
|
49
|
+
function normaliseCrlf(buf) {
|
|
50
|
+
const out = Buffer.alloc(buf.length)
|
|
51
|
+
let o = 0
|
|
52
|
+
for (let i = 0; i < buf.length; i++) {
|
|
53
|
+
if (buf[i] === 0x0d && buf[i + 1] === 0x0a) continue
|
|
54
|
+
out[o++] = buf[i]
|
|
55
|
+
}
|
|
56
|
+
return out.subarray(0, o)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** `≡` (traceability.md "Vocabulary"): both absent, or both present and byte-for-byte equal for a
|
|
60
|
+
* binary pair (classified on raw, unnormalised bytes), or equal after CRLF -> LF normalisation for
|
|
61
|
+
* a text pair (T-0027 increment 17, U56: nothing in U17's own 13 branches at increment 1 needed
|
|
62
|
+
* this, so it was added here, the first row whose fixture actually does).
|
|
63
|
+
* `forceBinary`, when given, is the PATH's own binary decision (any of o/b/t present with a NUL),
|
|
64
|
+
* not just this one pair's -- `classify`, below, is the caller that needs this; every other
|
|
65
|
+
* caller (P-2's `eq(b, t)`, the anchor-file check's `eq(anchorB, anchorT)`) has only two sides of
|
|
66
|
+
* its own to look at, so it leaves `forceBinary` `undefined` and keeps the old pair-wise reading. */
|
|
67
|
+
function eq(a, b, forceBinary) {
|
|
68
|
+
if (a === null && b === null) return true
|
|
69
|
+
if (a === null || b === null) return false
|
|
70
|
+
const binary = forceBinary !== undefined ? forceBinary : isBinary(a) || isBinary(b)
|
|
71
|
+
if (binary) return a.equals(b)
|
|
72
|
+
return normaliseCrlf(a).equals(normaliseCrlf(b))
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Manual `/`-joins (not `node:path.join`), matching `git ls-files -z`'s own output and the
|
|
76
|
+
* archive's own `rest` strings -- Windows-neutral by construction. */
|
|
77
|
+
async function listFilesRecursive(fs, dir) {
|
|
78
|
+
const result = []
|
|
79
|
+
async function walk(current, rel) {
|
|
80
|
+
const names = await fs.readdir(current, { withFileTypes: true })
|
|
81
|
+
for (const name of names) {
|
|
82
|
+
const relPath = rel ? `${rel}/${name.name}` : name.name
|
|
83
|
+
const full = join(current, name.name)
|
|
84
|
+
if (name.isDirectory()) await walk(full, relPath)
|
|
85
|
+
else if (name.isFile()) result.push(relPath)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
await walk(dir, '')
|
|
89
|
+
return result
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** `b`/`t`/`o`: "p's file in B, T, or absent" (traceability.md "Vocabulary") -- a directory at
|
|
93
|
+
* that path is not a file, so reading it as one (`EISDIR`) is "absent" by the same reading as
|
|
94
|
+
* `ENOENT`, not a bug. T-0027 increment 4: this is what keeps a tracked symbolic-linked
|
|
95
|
+
* directory's own bare path (present as a real directory in B and/or T once any path nested under
|
|
96
|
+
* it differs between them) from crashing here -- P-3, below, is what then leaves a path reached
|
|
97
|
+
* THROUGH that directory untouched. T-0027 increment 15: `ENOTDIR` -- a parent component inside
|
|
98
|
+
* the range, B or T is a FILE, not a directory -- means the same thing for a nested path reached
|
|
99
|
+
* through it: nothing is there, so it reads as absent too, not a bug. */
|
|
100
|
+
async function readFileOrNull(fs, absPath) {
|
|
101
|
+
try {
|
|
102
|
+
return await fs.readFile(absPath)
|
|
103
|
+
} catch (err) {
|
|
104
|
+
if (err?.code === 'ENOENT' || err?.code === 'EISDIR' || err?.code === 'ENOTDIR') return null
|
|
105
|
+
throw err
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** P-3 (traceability.md "Pre-checks"): true when `path` itself, or any parent directory of it
|
|
110
|
+
* inside the range, is a symbolic link (by `lstat`, never followed). An absent component (and
|
|
111
|
+
* everything under it) is not a symlink -- not yet classified as a path at all; absence is left
|
|
112
|
+
* to the merge table, as every other pre-check already does. T-0027 increment 15 (deviation,
|
|
113
|
+
* recorded in the ticket): `ENOTDIR` is treated the same as `ENOENT` here too -- a parent
|
|
114
|
+
* component that is itself a FILE (not a directory) means nothing is at `path` either, by the
|
|
115
|
+
* same "ENOTDIR-as-absent" reading `readFileOrNull`, above, already applies; without this, a
|
|
116
|
+
* path reached through a kept file (U54's own file-to-directory swap) would crash here instead of
|
|
117
|
+
* reaching the merge table. */
|
|
118
|
+
async function hasSymlinkInPath(fs, rangeRoot, path) {
|
|
119
|
+
let current = rangeRoot
|
|
120
|
+
for (const part of path.split('/')) {
|
|
121
|
+
current = join(current, part)
|
|
122
|
+
let st
|
|
123
|
+
try {
|
|
124
|
+
st = await fs.lstat(current)
|
|
125
|
+
} catch (err) {
|
|
126
|
+
if (err?.code === 'ENOENT' || err?.code === 'ENOTDIR') return false
|
|
127
|
+
throw err
|
|
128
|
+
}
|
|
129
|
+
if (st.isSymbolicLink()) return true
|
|
130
|
+
}
|
|
131
|
+
return false
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** P-9 (traceability.md "Pre-checks", Vocabulary "name on disk"; D12, T-0062): true when the name
|
|
135
|
+
* the file system stores for q differs from q, where q is `path` when `o` exists by `lstat`, and
|
|
136
|
+
* otherwise `path`'s deepest proper ancestor that exists, found by `lstat` walking from the range
|
|
137
|
+
* root (it may be a regular file). When q is the range root, P-9 does not apply. The comparison
|
|
138
|
+
* takes R, the range root's native realpath as step 3 resolved it, and Q, the native realpath of
|
|
139
|
+
* q, both with `\` turned into `/`; Q must start with R followed by `/` (or R itself when R
|
|
140
|
+
* already ends in `/`, a drive root), and the rest of Q must equal q once both are NFC-normalised.
|
|
141
|
+
* An `lstat` error other than ENOENT/ENOTDIR, or a `realpath` rejection, is a planning failure
|
|
142
|
+
* naming `path` (UP-E11). This catches what P-4 cannot see: a Windows 8.3 short name, HFS+
|
|
143
|
+
* ignorable code points, or letter case in a parent directory that B, T and tracked do not name. */
|
|
144
|
+
async function hasNameAlias(fs, rangeRoot, realRangeRoot, path) {
|
|
145
|
+
const parts = path.split('/')
|
|
146
|
+
const lstatOk = async (rel) => {
|
|
147
|
+
try {
|
|
148
|
+
await fs.lstat(join(rangeRoot, ...rel))
|
|
149
|
+
return true
|
|
150
|
+
} catch (err) {
|
|
151
|
+
if (err?.code === 'ENOENT' || err?.code === 'ENOTDIR') return false
|
|
152
|
+
throw new PlanningError(path, err?.message ?? String(err))
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
let q = null
|
|
156
|
+
if (await lstatOk(parts)) {
|
|
157
|
+
q = parts
|
|
158
|
+
} else {
|
|
159
|
+
for (let i = 1; i < parts.length; i++) {
|
|
160
|
+
if (!(await lstatOk(parts.slice(0, i)))) break
|
|
161
|
+
q = parts.slice(0, i)
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
if (q === null) return false // q is the range root: P-9 does not apply
|
|
165
|
+
let realQ
|
|
166
|
+
try {
|
|
167
|
+
realQ = await fs.realpath(join(rangeRoot, ...q))
|
|
168
|
+
} catch (err) {
|
|
169
|
+
throw new PlanningError(path, err?.message ?? String(err))
|
|
170
|
+
}
|
|
171
|
+
const root = toSlash(realRangeRoot)
|
|
172
|
+
const prefix = root.endsWith('/') ? root : `${root}/`
|
|
173
|
+
const resolved = toSlash(realQ)
|
|
174
|
+
if (!resolved.startsWith(prefix)) return true
|
|
175
|
+
return resolved.slice(prefix.length).normalize('NFC') !== q.join('/').normalize('NFC')
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** P-6 (traceability.md "Pre-checks"): true when `o` exists, is reached without crossing a
|
|
179
|
+
* non-directory component (`ENOENT`/`ENOTDIR` both mean "nothing non-file is here" -- the same
|
|
180
|
+
* reading as `readFileOrNull`/`hasSymlinkInPath`, above), is not a symbolic link (already ruled
|
|
181
|
+
* out by P-3, which runs earlier in the pre-check order), and is not a regular file either. `t` is
|
|
182
|
+
* always a file or absent in `allPaths` (a bare directory path never enters it on its own), so a
|
|
183
|
+
* non-file `o` can never be `≡` to it -- "o ≢ t" is automatic once this is true, no content read
|
|
184
|
+
* needed. */
|
|
185
|
+
async function isNonFileOther(fs, rangeRoot, path) {
|
|
186
|
+
let st
|
|
187
|
+
try {
|
|
188
|
+
st = await fs.lstat(join(rangeRoot, ...path.split('/')))
|
|
189
|
+
} catch (err) {
|
|
190
|
+
if (err?.code === 'ENOENT' || err?.code === 'ENOTDIR') return false
|
|
191
|
+
throw err
|
|
192
|
+
}
|
|
193
|
+
return !st.isSymbolicLink() && !st.isFile()
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** P-10 (D24, T-0066): true when `path` exists by `lstat`; the caller checks that it is untracked. Step 4 left no untracked file that the
|
|
197
|
+
* range does not ignore, and P-5 already took the ignored ones, so an untracked `path` that exists
|
|
198
|
+
* is reached through another name on disk -- one the native `realpath` did not report (P-9). An
|
|
199
|
+
* `lstat` error other than ENOENT or ENOTDIR fails closed (UP-E11). */
|
|
200
|
+
async function existsOnDisk(fs, rangeRoot, path) {
|
|
201
|
+
try {
|
|
202
|
+
await fs.lstat(join(rangeRoot, ...path.split('/')))
|
|
203
|
+
return true
|
|
204
|
+
} catch (err) {
|
|
205
|
+
if (err?.code === 'ENOENT' || err?.code === 'ENOTDIR') return false
|
|
206
|
+
throw new PlanningError(path, err?.message ?? String(err))
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** D10 (traceability.md "Pre-checks" P-8): parses `git ls-files -s -z`'s own stdout into the set
|
|
211
|
+
* of paths whose INDEX entry is a gitlink (mode `160000`, a submodule) -- each NUL-terminated
|
|
212
|
+
* entry is `<mode> <sha1> <stage>\t<path>`, unquoted (`-z`, the same convention `ls-files -z`'s
|
|
213
|
+
* own tracked-paths call already relies on). */
|
|
214
|
+
function parseSubmodulePaths(stdout) {
|
|
215
|
+
const paths = new Set()
|
|
216
|
+
for (const entry of stdout.split('\0')) {
|
|
217
|
+
if (entry === '') continue
|
|
218
|
+
const tab = entry.indexOf('\t')
|
|
219
|
+
const mode = entry.slice(0, tab).split(' ')[0]
|
|
220
|
+
if (mode === '160000') paths.add(entry.slice(tab + 1))
|
|
221
|
+
}
|
|
222
|
+
return paths
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** P-8 (traceability.md "Pre-checks", D10): true when `path` IS a submodule, or lies under one --
|
|
226
|
+
* `submodulePaths` holds exact gitlink paths (`parseSubmodulePaths`, above); a path under one is
|
|
227
|
+
* never itself an index entry (the submodule's own files are not part of the range's index), so
|
|
228
|
+
* this is a prefix check, not a set lookup. */
|
|
229
|
+
function isUnderSubmodule(path, submodulePaths) {
|
|
230
|
+
for (const submodulePath of submodulePaths) {
|
|
231
|
+
if (path === submodulePath || path.startsWith(`${submodulePath}/`)) return true
|
|
232
|
+
}
|
|
233
|
+
return false
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** P-4 (traceability.md "Pre-checks", T-0027 increment 13): true when some OTHER path of
|
|
237
|
+
* `allPaths` differs from `path` only by NFC normalisation and ASCII/Unicode case folding --
|
|
238
|
+
* `foldedGroups` maps every path's own folded key to the list of paths sharing it, built once per
|
|
239
|
+
* run (not per path), so this is an O(1) lookup here. */
|
|
240
|
+
function hasCaseCollision(path, foldedGroups) {
|
|
241
|
+
return foldedGroups.get(path.normalize('NFC').toLowerCase()).length > 1
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** The merge table (rows checked in order; the first match applies), given `o`/`b`/`t` as
|
|
245
|
+
* `Buffer | null`, and ALREADY past P-2 (`b ≡ t`, checked by the caller before this is ever
|
|
246
|
+
* reached). Every remaining combination is total -- traceability.md's own paragraph just below
|
|
247
|
+
* the table ("with b ≢ t and o a file or absent, exactly one of M5-M12 applies"). */
|
|
248
|
+
function classify(path, o, b, t) {
|
|
249
|
+
// Decided once per path, from all three sides -- not re-derived separately (and possibly
|
|
250
|
+
// differently) for each pair `eq`, below, compares.
|
|
251
|
+
const binary = isBinary(o) || isBinary(b) || isBinary(t)
|
|
252
|
+
if (eq(o, t, binary)) return { row: 'M4' }
|
|
253
|
+
if (eq(o, b, binary)) {
|
|
254
|
+
if (t !== null) return { row: 'M5', existedBefore: o !== null }
|
|
255
|
+
return { row: 'M6' }
|
|
256
|
+
}
|
|
257
|
+
if (o === null) return { row: 'M7' }
|
|
258
|
+
if (t === null) return { row: 'M8' }
|
|
259
|
+
if (b === null) return { row: 'M9' }
|
|
260
|
+
if (path === 'pnpm-lock.yaml') return { row: 'M10' }
|
|
261
|
+
if (binary) return { row: 'M11' }
|
|
262
|
+
return { row: 'M12' }
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** M12: shells out to the real `git merge-file`, its three inputs written into the run's own
|
|
266
|
+
* merge-inputs subdirectory, created lazily the first time any path reaches this row
|
|
267
|
+
* (`mergeDirRef.current` is set once and reused, under a fresh file name, for every later M12
|
|
268
|
+
* path in the same run, so a run with no M12 path never creates this directory at all --
|
|
269
|
+
* F14's own `mkdtemp` count stays unaffected). `result.code === 0` is a clean merge
|
|
270
|
+
* (traceability.md "Merge table" M12); `1`-`127` is a conflict, written with its three labels and
|
|
271
|
+
* reported UP-A1; anything else -- including `null` for a process ended by a signal -- is a
|
|
272
|
+
* planning failure (`MergeFileError`, UP-E11; U57, D4). */
|
|
273
|
+
async function runMergeFile({ deps, guard, rangeRoot, baseVersion, targetVersion, mergeDirRef, path, o, b, t }) {
|
|
274
|
+
if (mergeDirRef.current === undefined) {
|
|
275
|
+
mergeDirRef.current = await deps.fs.mkdtemp(join(deps.tmpRoot, 'create-wxl-upgrade-merge-'))
|
|
276
|
+
}
|
|
277
|
+
const index = (mergeDirRef.count = (mergeDirRef.count ?? 0) + 1)
|
|
278
|
+
const oPath = join(mergeDirRef.current, `o-${index}`)
|
|
279
|
+
const bPath = join(mergeDirRef.current, `b-${index}`)
|
|
280
|
+
const tPath = join(mergeDirRef.current, `t-${index}`)
|
|
281
|
+
// T-0027 increment 17 (U56's own approved extra sub-case): M12 is only reached for a text pair
|
|
282
|
+
// (M11 already caught any binary one), so every input is safe to CRLF-normalise the same way
|
|
283
|
+
// the `≡` helper, above, already does -- otherwise a CRLF-only `o` would make every line look
|
|
284
|
+
// different from `b`/`t` to `merge-file` and conflict needlessly.
|
|
285
|
+
await deps.fs.writeFile(oPath, normaliseCrlf(o))
|
|
286
|
+
await deps.fs.writeFile(bPath, normaliseCrlf(b))
|
|
287
|
+
await deps.fs.writeFile(tPath, normaliseCrlf(t))
|
|
288
|
+
|
|
289
|
+
// T-0027 increment 25 (D4): `binary: true` keeps `result.stdout` as a `Buffer` -- the ordinary
|
|
290
|
+
// string-decoding `runGit` would already have replaced every invalid UTF-8 byte sequence (e.g.
|
|
291
|
+
// a non-UTF-8 text file such as Big5) with U+FFFD by the time this function ever saw it.
|
|
292
|
+
// A `runGit` rejection (not a resolved result with an unusable `code`) is also a planning
|
|
293
|
+
// failure, UP-E11 naming `path` with the rejection's own text -- not a bug.
|
|
294
|
+
let result
|
|
295
|
+
try {
|
|
296
|
+
result = await deps.runGit(
|
|
297
|
+
['-c', 'core.autocrlf=false', 'merge-file', '-p', '--diff3', '-L', 'range', '-L', baseVersion, '-L', targetVersion, oPath, bPath, tPath],
|
|
298
|
+
{ cwd: rangeRoot, env: buildGitEnv(deps.env), binary: true },
|
|
299
|
+
)
|
|
300
|
+
} catch (err) {
|
|
301
|
+
throw new PlanningError(path, err.message)
|
|
302
|
+
}
|
|
303
|
+
// A signal this process already received while that `runGit` call was in flight wins over
|
|
304
|
+
// interpreting its result -- checked the moment it resolves, BEFORE `result.code` is read, so
|
|
305
|
+
// a `merge-file` that ended with `code: null` (or any other value) because of that same signal
|
|
306
|
+
// is never reported as `UP-E11`.
|
|
307
|
+
guard.throwIfRaised()
|
|
308
|
+
|
|
309
|
+
if (result.code === 0) {
|
|
310
|
+
// T-0027 increment 19 (U58): a clean merge of a `.json` file whose merged output does not
|
|
311
|
+
// parse keeps the range's own version and reports UP-A11, instead of writing output nothing
|
|
312
|
+
// downstream can read. `JSON.parse` coerces `result.stdout` (now a `Buffer`) through its own
|
|
313
|
+
// `toString()` (UTF-8), harmless here since valid JSON is ASCII/UTF-8-safe by construction.
|
|
314
|
+
if (path.endsWith('.json')) {
|
|
315
|
+
try {
|
|
316
|
+
JSON.parse(result.stdout)
|
|
317
|
+
} catch {
|
|
318
|
+
return { action: 'none', report: 'UP-A11' }
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
return { action: 'write', content: result.stdout, report: undefined }
|
|
322
|
+
}
|
|
323
|
+
// T-0027 increment 18 (U57): `0`-`127` is the range a real `git merge-file` process can
|
|
324
|
+
// legitimately exit with (0 clean, 1-127 conflicting); anything else -- including `null` for a
|
|
325
|
+
// process ended by a signal -- is a planning failure, not a result to write.
|
|
326
|
+
if (Number.isInteger(result.code) && result.code >= 1 && result.code <= 127) {
|
|
327
|
+
return { action: 'write', content: result.stdout, report: 'UP-A1' }
|
|
328
|
+
}
|
|
329
|
+
throw new MergeFileError(path)
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** Like `Promise.all`, but waits for every operation to settle before rejecting with the first
|
|
333
|
+
* rejection in array order (PR #72's windows-latest run): a rejection must not let the run report
|
|
334
|
+
* and clean up while another `git` child still runs in the range, or a read still holds a file in a
|
|
335
|
+
* pristine directory (EBUSY removing it on Windows). */
|
|
336
|
+
async function settleAll(promises) {
|
|
337
|
+
const settled = await Promise.allSettled(promises)
|
|
338
|
+
const rejected = settled.find((result) => result.status === 'rejected')
|
|
339
|
+
if (rejected !== undefined) throw rejected.reason
|
|
340
|
+
return settled.map((result) => result.value)
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* `planMerge({ deps, guard, rangeRoot, realRangeRoot, baseDir, targetDir, baseVersion, targetVersion })` ->
|
|
345
|
+
* (`realRangeRoot`: the range root's native realpath as step 3 resolved it, for P-9) ->
|
|
346
|
+
* `{ ops, attention, mergeDir }`. `ops` is sorted by path, each `{ path, action: 'delete' |
|
|
347
|
+
* 'write', content?, existedBefore?, report? }` (`report`, when present, is the UP-A code this op
|
|
348
|
+
* also carries in `attention` -- used by the report stage to exclude an M10/M12-conflict write
|
|
349
|
+
* from the "updated"/"added" counts, traceability.md "Report" R2's own "M10 is not counted as
|
|
350
|
+
* updated"). `attention` is sorted by path, each `{ path, code }`. `mergeDir` is the lazily
|
|
351
|
+
* created merge-inputs directory's path, or `undefined` when no path reached M12.
|
|
352
|
+
*/
|
|
353
|
+
export async function planMerge({ deps, guard, rangeRoot, realRangeRoot, baseDir, targetDir, baseVersion, targetVersion }) {
|
|
354
|
+
// D10 (traceability.md "Pre-checks" P-8): a SEPARATE `git ls-files -s -z` call, kept apart from
|
|
355
|
+
// the plain `ls-files -z` call below (which lists only the TRACKED PATHS, not their index mode)
|
|
356
|
+
// -- a gitlink is also a tracked path, so it is in BOTH results; only this one can tell it apart
|
|
357
|
+
// from an ordinary file.
|
|
358
|
+
const [baseFiles, targetFiles, trackedResult, submoduleResult] = await settleAll([
|
|
359
|
+
listFilesRecursive(deps.fs, baseDir),
|
|
360
|
+
listFilesRecursive(deps.fs, targetDir),
|
|
361
|
+
// D19 (T-0062): a `runGit` rejection of either names the command in CW-E14.
|
|
362
|
+
runGitNamed(deps, guard, ['ls-files', '-z'], { cwd: rangeRoot, env: buildGitEnv(deps.env) }, 'ls-files -z'),
|
|
363
|
+
runGitNamed(deps, guard, ['ls-files', '-s', '-z'], { cwd: rangeRoot, env: buildGitEnv(deps.env) }, 'ls-files -s -z'),
|
|
364
|
+
])
|
|
365
|
+
guard.throwIfRaised()
|
|
366
|
+
if (trackedResult.code !== 0) {
|
|
367
|
+
throw new Error(`git ls-files -z failed (exit code ${trackedResult.code}): ${trackedResult.stderr}`)
|
|
368
|
+
}
|
|
369
|
+
if (submoduleResult.code !== 0) {
|
|
370
|
+
throw new Error(`git ls-files -s -z failed (exit code ${submoduleResult.code}): ${submoduleResult.stderr}`)
|
|
371
|
+
}
|
|
372
|
+
const tracked = trackedResult.stdout.split('\0').filter((entry) => entry !== '')
|
|
373
|
+
const trackedSet = new Set(tracked)
|
|
374
|
+
const submodulePaths = parseSubmodulePaths(submoduleResult.stdout)
|
|
375
|
+
// T-0027 increment 13 fix (U53): B and T are the pristine ranges listFilesRecursive already
|
|
376
|
+
// listed, above -- `b`/`t` exist at `path` only when that EXACT (case-sensitive) string is one
|
|
377
|
+
// of those listed names. Reading by joining `path` onto `baseDir`/`targetDir` and letting the
|
|
378
|
+
// file system resolve it would, on a case-insensitive file system, silently read the OTHER
|
|
379
|
+
// path's bytes for a case-only rename (`guide.md` resolving to `Guide.md`'s entry), making
|
|
380
|
+
// `b ≡ t` hold and P-2 swallow the path before P-4 ever runs.
|
|
381
|
+
const baseFileSet = new Set(baseFiles)
|
|
382
|
+
const targetFileSet = new Set(targetFiles)
|
|
383
|
+
|
|
384
|
+
const allPaths = new Set([...baseFiles, ...targetFiles, ...tracked])
|
|
385
|
+
allPaths.delete('range.json') // P-1: step 9 writes it
|
|
386
|
+
|
|
387
|
+
// P-4: grouped once per run, by every path's own NFC-normalised, case-folded key.
|
|
388
|
+
const foldedGroups = new Map()
|
|
389
|
+
for (const path of allPaths) {
|
|
390
|
+
const folded = path.normalize('NFC').toLowerCase()
|
|
391
|
+
if (!foldedGroups.has(folded)) foldedGroups.set(folded, [])
|
|
392
|
+
foldedGroups.get(folded).push(path)
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const ops = []
|
|
396
|
+
const attention = []
|
|
397
|
+
const mergeDirRef = {}
|
|
398
|
+
|
|
399
|
+
// The merge-inputs directory `runMergeFile` creates lazily, above, must be removed however
|
|
400
|
+
// this loop ends -- a read error, a `runGit` rejection, or a signal noticed by any of this
|
|
401
|
+
// loop's own `guard.throwIfRaised()` calls, not only `MergeFileError`. Wrapping the whole loop
|
|
402
|
+
// lets every one of those attach `mergeDirRef.current` (if a path has reached M12 by then) to
|
|
403
|
+
// the error it throws, for the caller (`upgrade.mjs`) to clean up -- `planMerge` itself never
|
|
404
|
+
// sees that caller's own `tmpDirs` list.
|
|
405
|
+
try {
|
|
406
|
+
for (const path of [...allPaths].sort()) {
|
|
407
|
+
// A read error of b or t other than the absent cases `readFileOrNull` already maps to
|
|
408
|
+
// `null` (e.g. `EACCES`) is a planning failure, UP-E11 naming this path -- not a bug
|
|
409
|
+
// (`CW-E14`), since it is the range's own file system that refused the read, not a
|
|
410
|
+
// programming error.
|
|
411
|
+
let b, t
|
|
412
|
+
try {
|
|
413
|
+
;[b, t] = await settleAll([
|
|
414
|
+
baseFileSet.has(path) ? readFileOrNull(deps.fs, join(baseDir, ...path.split('/'))) : null,
|
|
415
|
+
targetFileSet.has(path) ? readFileOrNull(deps.fs, join(targetDir, ...path.split('/'))) : null,
|
|
416
|
+
])
|
|
417
|
+
} catch (err) {
|
|
418
|
+
throw new PlanningError(path, err.message)
|
|
419
|
+
}
|
|
420
|
+
guard.throwIfRaised()
|
|
421
|
+
|
|
422
|
+
if (eq(b, t)) continue // P-2: leave o as it is, unreported
|
|
423
|
+
|
|
424
|
+
if (await hasSymlinkInPath(deps.fs, rangeRoot, path)) {
|
|
425
|
+
// P-3: untouched, UP-A7.
|
|
426
|
+
attention.push({ path, code: 'UP-A7' })
|
|
427
|
+
continue
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
if (hasCaseCollision(path, foldedGroups)) {
|
|
431
|
+
// P-4: untouched, UP-A8.
|
|
432
|
+
attention.push({ path, code: 'UP-A8' })
|
|
433
|
+
continue
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
if (!trackedSet.has(path) && (await isIgnoredPath(deps, guard, rangeRoot, path))) {
|
|
437
|
+
// P-5: untouched, UP-A9.
|
|
438
|
+
attention.push({ path, code: 'UP-A9' })
|
|
439
|
+
continue
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
if (await isNonFileOther(deps.fs, rangeRoot, path)) {
|
|
443
|
+
// P-6 (T-0027 increment 15): untouched, UP-A10.
|
|
444
|
+
attention.push({ path, code: 'UP-A10' })
|
|
445
|
+
continue
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// P-7 (D9): an ignore file (at any level -- the LAST path component, not only the
|
|
449
|
+
// range's root one) that upstream changed is never written, so the upgrade never leaves a
|
|
450
|
+
// missing or changed ignore file for the restore commands' `git clean -fd` to act on (D14
|
|
451
|
+
// also runs `git checkout -- .` first, T-0063). Checked in traceability.md's "Pre-checks"
|
|
452
|
+
// table order, after P-6 and before P-8. The last component is folded the way P-4 folds a whole path, so an upstream
|
|
453
|
+
// `.GITIGNORE` (or any other-cased or normalised form) is still an ignore file.
|
|
454
|
+
if (path.split('/').pop().normalize('NFC').toLowerCase() === '.gitignore') {
|
|
455
|
+
attention.push({ path, code: 'UP-A13' })
|
|
456
|
+
continue
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// P-8 (D10): `path` is a submodule, or lies under one -- its own working tree is not part
|
|
460
|
+
// of the range's index, so the restore commands cannot undo a write or delete there.
|
|
461
|
+
if (isUnderSubmodule(path, submodulePaths)) {
|
|
462
|
+
attention.push({ path, code: 'UP-A14' })
|
|
463
|
+
continue
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// P-9 (D12, T-0062): a path whose name on disk differs from `path` -- in itself, or in the
|
|
467
|
+
// deepest existing ancestor when `o` is absent -- is never read, written or deleted here.
|
|
468
|
+
// Checked after P-1 to P-8, so a symbolic link stays UP-A7 (P-3) and a collision within
|
|
469
|
+
// B ∪ T ∪ tracked stays UP-A8 (P-4); only P-10 comes after it.
|
|
470
|
+
if (await hasNameAlias(deps.fs, rangeRoot, realRangeRoot, path)) {
|
|
471
|
+
attention.push({ path, code: 'UP-A15' })
|
|
472
|
+
continue
|
|
473
|
+
}
|
|
474
|
+
guard.throwIfRaised()
|
|
475
|
+
|
|
476
|
+
// P-10 (D24, T-0066): an untracked path that exists is an alias P-9 could not see (a file
|
|
477
|
+
// system whose `realpath` does not report the stored name, such as WSL's `/mnt/c`).
|
|
478
|
+
if (!trackedSet.has(path) && (await existsOnDisk(deps.fs, rangeRoot, path))) {
|
|
479
|
+
attention.push({ path, code: 'UP-A15' })
|
|
480
|
+
continue
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// `o` is classified by `lstat` BEFORE any read -- P-3 (a symbolic link anywhere in `path`)
|
|
484
|
+
// and P-6 (a present, non-symbolic-link, non-regular-file `o`), both just above, already
|
|
485
|
+
// ran and left without ever reading `o`'s own content; only once
|
|
486
|
+
// neither applies is `o` known to be a regular file or absent, safe for an ordinary
|
|
487
|
+
// `readFile` -- otherwise a symlink to something whose read hangs or fails (a FIFO, a
|
|
488
|
+
// socket, a device) would crash or stall the whole run instead of being left untouched.
|
|
489
|
+
let o
|
|
490
|
+
try {
|
|
491
|
+
o = await readFileOrNull(deps.fs, join(rangeRoot, ...path.split('/')))
|
|
492
|
+
} catch (err) {
|
|
493
|
+
throw new PlanningError(path, err.message)
|
|
494
|
+
}
|
|
495
|
+
guard.throwIfRaised()
|
|
496
|
+
|
|
497
|
+
const { row, existedBefore } = classify(path, o, b, t)
|
|
498
|
+
|
|
499
|
+
if (row === 'M4') continue
|
|
500
|
+
if (row === 'M5') {
|
|
501
|
+
const op = { path, action: 'write', content: t, existedBefore }
|
|
502
|
+
// M5 (traceability.md "Merge table"): "new files take t's executable bit; existing ones
|
|
503
|
+
// keep their mode" -- only a brand-new path (M5-added) ever needs t's own mode here; an
|
|
504
|
+
// existing one's mode is preserved by `merge-apply.mjs` regardless of which row wrote it.
|
|
505
|
+
if (!existedBefore) {
|
|
506
|
+
const targetStat = await deps.fs.stat(join(targetDir, ...path.split('/')))
|
|
507
|
+
op.newFileExecutable = (targetStat.mode & 0o111) !== 0
|
|
508
|
+
}
|
|
509
|
+
ops.push(op)
|
|
510
|
+
continue
|
|
511
|
+
}
|
|
512
|
+
if (row === 'M6') {
|
|
513
|
+
ops.push({ path, action: 'delete' })
|
|
514
|
+
continue
|
|
515
|
+
}
|
|
516
|
+
if (row === 'M7') {
|
|
517
|
+
attention.push({ path, code: 'UP-A5' })
|
|
518
|
+
continue
|
|
519
|
+
}
|
|
520
|
+
if (row === 'M8') {
|
|
521
|
+
attention.push({ path, code: 'UP-A3' })
|
|
522
|
+
continue
|
|
523
|
+
}
|
|
524
|
+
if (row === 'M9') {
|
|
525
|
+
attention.push({ path, code: 'UP-A4' })
|
|
526
|
+
continue
|
|
527
|
+
}
|
|
528
|
+
if (row === 'M10') {
|
|
529
|
+
// `pnpm-lock.yaml` always already exists once a path reaches M10
|
|
530
|
+
// (its base and the teacher's own copy both had it) -- `existedBefore: true` is what
|
|
531
|
+
// tells `merge-apply.mjs`'s own write loop to preserve its CURRENT mode, the same "M5:
|
|
532
|
+
// existing ones keep their mode" guarantee every other existing-file write already gets.
|
|
533
|
+
ops.push({ path, action: 'write', content: t, existedBefore: true, report: 'UP-A6' })
|
|
534
|
+
attention.push({ path, code: 'UP-A6' })
|
|
535
|
+
continue
|
|
536
|
+
}
|
|
537
|
+
if (row === 'M11') {
|
|
538
|
+
attention.push({ path, code: 'UP-A2' })
|
|
539
|
+
continue
|
|
540
|
+
}
|
|
541
|
+
// M12
|
|
542
|
+
const outcome = await runMergeFile({ deps, guard, rangeRoot, baseVersion, targetVersion, mergeDirRef, path, o, b, t })
|
|
543
|
+
guard.throwIfRaised()
|
|
544
|
+
if (outcome.action === 'write') {
|
|
545
|
+
ops.push({ path, action: 'write', content: outcome.content, existedBefore: o !== null, report: outcome.report })
|
|
546
|
+
}
|
|
547
|
+
if (outcome.report !== undefined) attention.push({ path, code: outcome.report })
|
|
548
|
+
}
|
|
549
|
+
} catch (err) {
|
|
550
|
+
// Whatever throws here -- `MergeFileError`, a `SignalAbort` from
|
|
551
|
+
// `guard.throwIfRaised()`, a read error, or a `runGit` rejection -- the merge-inputs
|
|
552
|
+
// directory (if a path already reached M12 by then) is attached to it for the caller
|
|
553
|
+
// (`upgrade.mjs`) to remove; `planMerge` itself never sees that caller's own `tmpDirs` list.
|
|
554
|
+
if (mergeDirRef.current !== undefined && err.mergeDir === undefined) err.mergeDir = mergeDirRef.current
|
|
555
|
+
throw err
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// Parent check (traceability.md:153, after the merge table; T-0027 increment 16, tightened by
|
|
559
|
+
// the verification follow-up's item 3): a planned write whose path has A PARENT INSIDE THE
|
|
560
|
+
// RANGE -- any ancestor directory level, not only the immediate one -- that exists, is not a
|
|
561
|
+
// directory, and is not itself being deleted by this same plan, is dropped -- UP-A10 for the
|
|
562
|
+
// write's own path, not counted as added/updated. Deletions run before writes (step 9), so a
|
|
563
|
+
// file replaced by a directory of the same name (U54) is unaffected: its own parent IS one of
|
|
564
|
+
// this plan's own deletes. `lstat`-ing the immediate parent alone is not enough: when a
|
|
565
|
+
// SHALLOWER ancestor (e.g. `a` for a write to `a/b/x`) is the non-directory the plan keeps,
|
|
566
|
+
// `fs.lstat(rangeRoot, 'a', 'b')` fails with ENOTDIR (a component of the path prefix is not a
|
|
567
|
+
// directory) rather than naming `a` itself -- walking every ancestor from the top catches this.
|
|
568
|
+
const deletingPaths = new Set(ops.filter((op) => op.action === 'delete').map((op) => op.path))
|
|
569
|
+
const finalOps = []
|
|
570
|
+
for (const op of ops) {
|
|
571
|
+
if (op.action === 'write') {
|
|
572
|
+
const parts = op.path.split('/')
|
|
573
|
+
let blocked = false
|
|
574
|
+
for (let depth = 1; depth < parts.length && !blocked; depth++) {
|
|
575
|
+
const ancestorParts = parts.slice(0, depth)
|
|
576
|
+
const ancestorPath = ancestorParts.join('/')
|
|
577
|
+
if (deletingPaths.has(ancestorPath)) continue
|
|
578
|
+
let ancestorStat = null
|
|
579
|
+
try {
|
|
580
|
+
ancestorStat = await deps.fs.lstat(join(rangeRoot, ...ancestorParts))
|
|
581
|
+
} catch (err) {
|
|
582
|
+
// Only ENOENT (absent) and ENOTDIR (a SHALLOWER ancestor already a
|
|
583
|
+
// file -- in practice never reached here, since this loop itself would already have set
|
|
584
|
+
// `blocked` at that shallower depth first) mean "nothing to block this write at this
|
|
585
|
+
// ancestor level"; any other error (e.g. EACCES) is a planning failure, not evidence of
|
|
586
|
+
// absence -- fail closed, like every other pre-check's own lstat.
|
|
587
|
+
if (err?.code === 'ENOENT' || err?.code === 'ENOTDIR') {
|
|
588
|
+
ancestorStat = null
|
|
589
|
+
} else {
|
|
590
|
+
const planningErr = new PlanningError(op.path, err.message)
|
|
591
|
+
if (mergeDirRef.current !== undefined) planningErr.mergeDir = mergeDirRef.current
|
|
592
|
+
throw planningErr
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
if (ancestorStat !== null && !ancestorStat.isDirectory()) blocked = true
|
|
596
|
+
}
|
|
597
|
+
if (blocked) {
|
|
598
|
+
attention.push({ path: op.path, code: 'UP-A10' })
|
|
599
|
+
continue
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
finalOps.push(op)
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
finalOps.sort((a, b2) => (a.path < b2.path ? -1 : a.path > b2.path ? 1 : 0))
|
|
606
|
+
attention.sort((a, b2) => (a.path < b2.path ? -1 : a.path > b2.path ? 1 : 0))
|
|
607
|
+
|
|
608
|
+
// Fork:init-derived content (traceability.md "Fork:init-derived content", T-0027 increment 20,
|
|
609
|
+
// U59): when ANY of the nine anchor files (0004's A1-A9 plus the deploy template) differs
|
|
610
|
+
// between base and target, the report adds ONE UP-A12 line -- not one per anchor, and not tied
|
|
611
|
+
// to any single path, so it is appended after the path-sort above, with no `path` field of its
|
|
612
|
+
// own (`upgrade-report.mjs`'s own sort already places a `UP-A12` item last regardless of this
|
|
613
|
+
// append order; this is also why `attention.sort`, above, never sees it). The nine paths are
|
|
614
|
+
// written as literals here, at this one call site -- there is no shared module exporting them.
|
|
615
|
+
const ANCHOR_PATHS = [
|
|
616
|
+
'package.json',
|
|
617
|
+
'README.md',
|
|
618
|
+
'README.en.md',
|
|
619
|
+
'CONTRIBUTE.md',
|
|
620
|
+
'.vitepress/config.mts',
|
|
621
|
+
'.vitepress/challenge/shortLink.ts',
|
|
622
|
+
'platform/index.md',
|
|
623
|
+
'platform/zh-TW/index.md',
|
|
624
|
+
'scripts/templates/deploy.yml.template',
|
|
625
|
+
]
|
|
626
|
+
// T-0027 verification follow-up (item 4, approved sub-case, 2026-11-09): the same exact-name
|
|
627
|
+
// (case-sensitive) presence rule as the U53 fix, above (`baseFileSet`/`targetFileSet`), not a
|
|
628
|
+
// path read -- on a case-insensitive filesystem, joining `anchorPath` onto `targetDir` and
|
|
629
|
+
// reading it would resolve to a DIFFERENTLY-cased sibling the target pristine range actually
|
|
630
|
+
// lists (e.g. `readme.md` for the literal anchor `README.md`), wrongly reading it as present.
|
|
631
|
+
// This check's own reads are wrapped the same way the main loop's own reads, above, already
|
|
632
|
+
// are -- a read error here (other than the absent cases `readFileOrNull` already maps to
|
|
633
|
+
// `null`) is a planning failure too (traceability.md "Codes" UP-E11), naming the anchor path,
|
|
634
|
+
// with the merge-inputs directory (if any path already reached M12) attached for the caller to
|
|
635
|
+
// remove -- not a bug reaching step 9 with that directory left behind under `tmpRoot`.
|
|
636
|
+
let anchorChanged = false
|
|
637
|
+
try {
|
|
638
|
+
for (const anchorPath of ANCHOR_PATHS) {
|
|
639
|
+
let anchorB, anchorT
|
|
640
|
+
try {
|
|
641
|
+
;[anchorB, anchorT] = await settleAll([
|
|
642
|
+
baseFileSet.has(anchorPath) ? readFileOrNull(deps.fs, join(baseDir, ...anchorPath.split('/'))) : null,
|
|
643
|
+
targetFileSet.has(anchorPath) ? readFileOrNull(deps.fs, join(targetDir, ...anchorPath.split('/'))) : null,
|
|
644
|
+
])
|
|
645
|
+
} catch (err) {
|
|
646
|
+
throw new PlanningError(anchorPath, err.message)
|
|
647
|
+
}
|
|
648
|
+
if (!eq(anchorB, anchorT)) {
|
|
649
|
+
anchorChanged = true
|
|
650
|
+
break
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
} catch (err) {
|
|
654
|
+
if (mergeDirRef.current !== undefined && err.mergeDir === undefined) err.mergeDir = mergeDirRef.current
|
|
655
|
+
throw err
|
|
656
|
+
}
|
|
657
|
+
if (anchorChanged) attention.push({ code: 'UP-A12' })
|
|
658
|
+
|
|
659
|
+
return { ops: finalOps, attention, mergeDir: mergeDirRef.current }
|
|
660
|
+
}
|