@voxgig/create-sdkgen 0.30.9 → 0.31.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 +12 -1
- package/bin/create-sdkgen +25 -2
- package/dist/copymerge.d.ts +35 -0
- package/dist/copymerge.js +510 -0
- package/dist/copymerge.js.map +1 -0
- package/dist/create-sdkgen.d.ts +2 -0
- package/dist/create-sdkgen.js +30 -1
- package/dist/create-sdkgen.js.map +1 -1
- package/dist/project/standard/CreateRoot.d.ts +2 -1
- package/dist/project/standard/CreateRoot.js +8 -1
- package/dist/project/standard/CreateRoot.js.map +1 -1
- package/dist/scaffold-record.d.ts +9 -0
- package/dist/scaffold-record.js +92 -0
- package/dist/scaffold-record.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/project/standard/.sdk/package.json +3 -3
- package/src/copymerge.ts +711 -0
- package/src/create-sdkgen.ts +43 -1
- package/src/project/standard/CreateRoot.ts +7 -0
- package/src/scaffold-record.ts +119 -0
package/src/copymerge.ts
ADDED
|
@@ -0,0 +1,711 @@
|
|
|
1
|
+
/* Copyright (c) 2026 Richard Rodger, MIT License */
|
|
2
|
+
|
|
3
|
+
// The copy merge @voxgig/sdkgen runs on an add, applied to a re-scaffold.
|
|
4
|
+
|
|
5
|
+
import Path from 'node:path'
|
|
6
|
+
import { realpathSync } from 'node:fs'
|
|
7
|
+
import { execFileSync } from 'node:child_process'
|
|
8
|
+
|
|
9
|
+
import { DiffUtil } from 'jostraca'
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
// How a toolchain write treats a project file the project changed after the
|
|
13
|
+
// toolchain last wrote it.
|
|
14
|
+
// merge a three-way merge of the incoming copy into the local change
|
|
15
|
+
// overwrite the incoming copy replaces the file
|
|
16
|
+
type CopyMode = 'merge' | 'overwrite'
|
|
17
|
+
|
|
18
|
+
const MERGE: CopyMode = 'merge'
|
|
19
|
+
const OVERWRITE: CopyMode = 'overwrite'
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
type GuardSpec = {
|
|
23
|
+
fs: any
|
|
24
|
+
folder: string
|
|
25
|
+
log: any
|
|
26
|
+
mode: CopyMode
|
|
27
|
+
what: string
|
|
28
|
+
force?: boolean
|
|
29
|
+
keep?: boolean
|
|
30
|
+
dryrun?: boolean
|
|
31
|
+
|
|
32
|
+
// Relative to `folder`, with forward slashes.
|
|
33
|
+
manage: (rel: string) => boolean
|
|
34
|
+
|
|
35
|
+
// The fingerprint of what the toolchain wrote at `rel` last time, read
|
|
36
|
+
// before the run starts.
|
|
37
|
+
recorded: (rel: string) => string | undefined
|
|
38
|
+
fingerprint: (content: any) => string
|
|
39
|
+
|
|
40
|
+
basedir: string
|
|
41
|
+
conflictdir: string
|
|
42
|
+
|
|
43
|
+
// Earlier versions of a file, newest first, for a base the store lacks.
|
|
44
|
+
versions?: (abs: string) => Iterable<Buffer>
|
|
45
|
+
|
|
46
|
+
error?: (msg: string) => Error
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
type Conflict = {
|
|
51
|
+
rel: string
|
|
52
|
+
why: 'conflict' | 'nobase' | 'binary'
|
|
53
|
+
preview?: Buffer
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
type MergeOutcome = {
|
|
58
|
+
merged: string[]
|
|
59
|
+
kept: string[]
|
|
60
|
+
retained: string[]
|
|
61
|
+
spared: string[]
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
const DELETED = Symbol('deleted')
|
|
66
|
+
|
|
67
|
+
type Pending = Buffer | typeof DELETED
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
function copyMode(value: any): CopyMode {
|
|
71
|
+
return OVERWRITE === value ? OVERWRITE : MERGE
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
async function withCopyMerge<T>(
|
|
76
|
+
spec: GuardSpec, run: (fs: any) => Promise<T>,
|
|
77
|
+
): Promise<T> {
|
|
78
|
+
if (MERGE !== spec.mode || true === spec.force || true === spec.dryrun) {
|
|
79
|
+
return run(spec.fs)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const guard = new CopyGuard(spec)
|
|
83
|
+
|
|
84
|
+
let result: T
|
|
85
|
+
try {
|
|
86
|
+
result = await run(guard.fs)
|
|
87
|
+
}
|
|
88
|
+
catch (err: any) {
|
|
89
|
+
guard.rollback()
|
|
90
|
+
throw err
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
guard.finish()
|
|
94
|
+
return result
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class CopyGuard {
|
|
99
|
+
spec: GuardSpec
|
|
100
|
+
real: any
|
|
101
|
+
root: string
|
|
102
|
+
fs: any
|
|
103
|
+
|
|
104
|
+
orig = new Map<string, Buffer | null>()
|
|
105
|
+
dirs: string[] = []
|
|
106
|
+
edited = new Map<string, boolean>()
|
|
107
|
+
pending = new Map<string, Pending>()
|
|
108
|
+
touched = new Set<string>()
|
|
109
|
+
spared: string[] = []
|
|
110
|
+
|
|
111
|
+
constructor(spec: GuardSpec) {
|
|
112
|
+
this.spec = spec
|
|
113
|
+
this.real = spec.fs
|
|
114
|
+
this.root = Path.resolve(spec.folder)
|
|
115
|
+
this.fs = this.wrap()
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
wrap(): any {
|
|
120
|
+
const real = this.real
|
|
121
|
+
const gfs: any = {}
|
|
122
|
+
|
|
123
|
+
for (const key in real) {
|
|
124
|
+
const val = real[key]
|
|
125
|
+
gfs[key] = 'function' === typeof val ? val.bind(real) : val
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
gfs.writeFileSync = (path: any, data: any, opts?: any) => {
|
|
129
|
+
if (this.holds(path, toBuffer(data, opts))) {
|
|
130
|
+
return
|
|
131
|
+
}
|
|
132
|
+
this.before(path)
|
|
133
|
+
return real.writeFileSync(path, data, opts)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
gfs.appendFileSync = (path: any, data: any, opts?: any) => {
|
|
137
|
+
this.before(path)
|
|
138
|
+
return real.appendFileSync(path, data, opts)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
gfs.copyFileSync = (from: any, to: any, mode?: any) => {
|
|
142
|
+
if (this.holds(to, Buffer.from(real.readFileSync(from)))) {
|
|
143
|
+
return
|
|
144
|
+
}
|
|
145
|
+
this.before(to)
|
|
146
|
+
return real.copyFileSync(from, to, mode)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
gfs.renameSync = (from: any, to: any) => {
|
|
150
|
+
const data = this.isFile(from) ? this.read(from) : undefined
|
|
151
|
+
if (null != data && this.holds(to, data)) {
|
|
152
|
+
this.before(from)
|
|
153
|
+
real.unlinkSync(from)
|
|
154
|
+
return
|
|
155
|
+
}
|
|
156
|
+
this.before(from)
|
|
157
|
+
this.before(to)
|
|
158
|
+
return real.renameSync(from, to)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
gfs.unlinkSync = (path: any) => {
|
|
162
|
+
if (this.spares(path)) {
|
|
163
|
+
return
|
|
164
|
+
}
|
|
165
|
+
this.before(path)
|
|
166
|
+
return real.unlinkSync(path)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
gfs.mkdirSync = (path: any, opts?: any) => {
|
|
170
|
+
const missing = this.missingDirs(path)
|
|
171
|
+
const res = real.mkdirSync(path, opts)
|
|
172
|
+
this.dirs.push(...missing.filter((dir: string) => real.existsSync(dir)))
|
|
173
|
+
return res
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
gfs.readFileSync = (path: any, opts?: any) => {
|
|
177
|
+
const held = 'string' === typeof path ?
|
|
178
|
+
this.pending.get(Path.resolve(path)) : undefined
|
|
179
|
+
if (held instanceof Buffer) {
|
|
180
|
+
const encoding = 'string' === typeof opts ? opts : opts?.encoding
|
|
181
|
+
return null == encoding ? Buffer.from(held) : held.toString(encoding)
|
|
182
|
+
}
|
|
183
|
+
return real.readFileSync(path, opts)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return gfs
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
rel(path: any): string | undefined {
|
|
191
|
+
if ('string' !== typeof path) {
|
|
192
|
+
return undefined
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const rel = Path.relative(this.root, Path.resolve(path)).split(Path.sep).join('/')
|
|
196
|
+
|
|
197
|
+
return ('' === rel || '..' === rel || rel.startsWith('../') || Path.isAbsolute(rel)) ?
|
|
198
|
+
undefined : rel
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
managed(rel: string | undefined): rel is string {
|
|
203
|
+
return null != rel &&
|
|
204
|
+
!within(rel, this.spec.basedir) && !within(rel, this.spec.conflictdir) &&
|
|
205
|
+
this.spec.manage(rel)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
isFile(path: any): boolean {
|
|
210
|
+
try {
|
|
211
|
+
return true === this.real.statSync(path)?.isFile()
|
|
212
|
+
}
|
|
213
|
+
catch (err: any) {
|
|
214
|
+
return false
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
read(path: any): Buffer {
|
|
220
|
+
return Buffer.from(this.real.readFileSync(path))
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
createdHere(abs: string): boolean {
|
|
225
|
+
return this.orig.has(abs) && null === this.orig.get(abs)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
before(path: any) {
|
|
230
|
+
if ('string' !== typeof path) {
|
|
231
|
+
return
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const abs = Path.resolve(path)
|
|
235
|
+
|
|
236
|
+
if (!this.orig.has(abs)) {
|
|
237
|
+
if (this.isFile(abs)) {
|
|
238
|
+
this.orig.set(abs, this.read(abs))
|
|
239
|
+
}
|
|
240
|
+
else if (!this.real.existsSync(abs)) {
|
|
241
|
+
this.orig.set(abs, null)
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const rel = this.rel(abs)
|
|
246
|
+
if (this.managed(rel)) {
|
|
247
|
+
this.touched.add(rel)
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
// Was this file changed after the toolchain wrote it? Asked once, before
|
|
253
|
+
// the run writes it; a file the record does not hold counts as changed.
|
|
254
|
+
isEdited(rel: string, abs: string): boolean {
|
|
255
|
+
let edited = this.edited.get(rel)
|
|
256
|
+
|
|
257
|
+
if (null == edited) {
|
|
258
|
+
const print = this.spec.recorded(rel)
|
|
259
|
+
edited = null == print || print !== this.spec.fingerprint(this.read(abs))
|
|
260
|
+
this.edited.set(rel, edited)
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return edited
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
// Defers a write over a locally changed file, leaving it on disk as it is.
|
|
268
|
+
holds(path: any, data: Buffer): boolean {
|
|
269
|
+
if ('string' !== typeof path) {
|
|
270
|
+
return false
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const abs = Path.resolve(path)
|
|
274
|
+
const rel = this.rel(abs)
|
|
275
|
+
|
|
276
|
+
if (!this.managed(rel)) {
|
|
277
|
+
return false
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (this.pending.has(abs)) {
|
|
281
|
+
this.pending.set(abs, data)
|
|
282
|
+
return true
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (this.createdHere(abs) || !this.isFile(abs) || !this.isEdited(rel, abs)) {
|
|
286
|
+
return false
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (sameText(this.read(abs), data)) {
|
|
290
|
+
return false
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
this.pending.set(abs, data)
|
|
294
|
+
return true
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
// A delete of a changed file, or of one no add wrote, leaves it in place.
|
|
299
|
+
spares(path: any): boolean {
|
|
300
|
+
if ('string' !== typeof path) {
|
|
301
|
+
return false
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const abs = Path.resolve(path)
|
|
305
|
+
const rel = this.rel(abs)
|
|
306
|
+
|
|
307
|
+
if (!this.managed(rel) || this.createdHere(abs) || !this.isFile(abs)) {
|
|
308
|
+
return false
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (null == this.spec.recorded(rel)) {
|
|
312
|
+
this.spared.push(rel)
|
|
313
|
+
return true
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if (this.pending.has(abs) || this.isEdited(rel, abs)) {
|
|
317
|
+
this.pending.set(abs, DELETED)
|
|
318
|
+
return true
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
return false
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
missingDirs(path: any): string[] {
|
|
326
|
+
if ('string' !== typeof path) {
|
|
327
|
+
return []
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const missing: string[] = []
|
|
331
|
+
let dir = Path.resolve(path)
|
|
332
|
+
|
|
333
|
+
while (!this.real.existsSync(dir) && Path.dirname(dir) !== dir) {
|
|
334
|
+
missing.push(dir)
|
|
335
|
+
dir = Path.dirname(dir)
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
return missing
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
// An incoming copy the record already holds is its own base: the source
|
|
343
|
+
// did not move since the add that wrote it.
|
|
344
|
+
base(rel: string, incoming: Buffer): Buffer | undefined {
|
|
345
|
+
const print = this.spec.recorded(rel)
|
|
346
|
+
|
|
347
|
+
if (null == print) {
|
|
348
|
+
return undefined
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
if (print === this.spec.fingerprint(incoming)) {
|
|
352
|
+
return incoming
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const stored = this.storePath(this.spec.basedir, rel)
|
|
356
|
+
if (this.isFile(stored)) {
|
|
357
|
+
const base = this.read(stored)
|
|
358
|
+
if (print === this.spec.fingerprint(base)) {
|
|
359
|
+
return base
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
for (const version of this.spec.versions?.(Path.join(this.root, rel)) ?? []) {
|
|
364
|
+
if (print === this.spec.fingerprint(version)) {
|
|
365
|
+
return version
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
return undefined
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
storePath(dir: string, rel: string): string {
|
|
374
|
+
return Path.join(this.root, ...dir.split('/'), ...rel.split('/'))
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
finish(): MergeOutcome {
|
|
379
|
+
const outcome: MergeOutcome = { merged: [], kept: [], retained: [], spared: this.spared }
|
|
380
|
+
const conflicts: Conflict[] = []
|
|
381
|
+
const writes: [string, Buffer][] = []
|
|
382
|
+
const bases = new Map<string, Buffer>()
|
|
383
|
+
|
|
384
|
+
for (const [abs, incoming] of this.pending) {
|
|
385
|
+
const rel = this.rel(abs) as string
|
|
386
|
+
|
|
387
|
+
if (DELETED === incoming) {
|
|
388
|
+
outcome.retained.push(rel)
|
|
389
|
+
continue
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const local = this.read(abs)
|
|
393
|
+
const base = this.base(rel, incoming)
|
|
394
|
+
|
|
395
|
+
let conflict: Conflict | undefined
|
|
396
|
+
let merged: Buffer | undefined
|
|
397
|
+
|
|
398
|
+
if (null == base) {
|
|
399
|
+
conflict = { rel, why: 'nobase', preview: incoming }
|
|
400
|
+
}
|
|
401
|
+
else if (isBinary(incoming) || isBinary(local) || isBinary(base)) {
|
|
402
|
+
conflict = { rel, why: 'binary', preview: incoming }
|
|
403
|
+
}
|
|
404
|
+
else {
|
|
405
|
+
const res = merge3(incoming, base, local, this.spec.what)
|
|
406
|
+
if (res.conflict) {
|
|
407
|
+
conflict = { rel, why: 'conflict', preview: res.content }
|
|
408
|
+
}
|
|
409
|
+
else {
|
|
410
|
+
merged = res.content
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
if (null != conflict && true === this.spec.keep) {
|
|
415
|
+
outcome.kept.push(rel)
|
|
416
|
+
bases.set(rel, incoming)
|
|
417
|
+
}
|
|
418
|
+
else if (null != conflict) {
|
|
419
|
+
conflicts.push(conflict)
|
|
420
|
+
}
|
|
421
|
+
else {
|
|
422
|
+
writes.push([abs, merged as Buffer])
|
|
423
|
+
outcome.merged.push(rel)
|
|
424
|
+
bases.set(rel, incoming)
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
if (0 < conflicts.length) {
|
|
429
|
+
this.rollback()
|
|
430
|
+
this.previews(conflicts)
|
|
431
|
+
throw this.failure(conflicts)
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
for (const [abs, content] of writes) {
|
|
435
|
+
if (!sameText(this.read(abs), content)) {
|
|
436
|
+
this.writeAtomic(abs, content)
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
this.keepBases(bases)
|
|
441
|
+
this.clearDir(this.spec.conflictdir)
|
|
442
|
+
this.report(outcome)
|
|
443
|
+
|
|
444
|
+
return outcome
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
// The store holds a base only where the file differs from it: elsewhere
|
|
449
|
+
// the file itself, checked against the record, is the base.
|
|
450
|
+
keepBases(bases: Map<string, Buffer>) {
|
|
451
|
+
for (const rel of new Set([...this.touched, ...bases.keys()])) {
|
|
452
|
+
const at = this.storePath(this.spec.basedir, rel)
|
|
453
|
+
const base = bases.get(rel)
|
|
454
|
+
|
|
455
|
+
if (null != base) {
|
|
456
|
+
this.real.mkdirSync(Path.dirname(at), { recursive: true })
|
|
457
|
+
this.real.writeFileSync(at, base)
|
|
458
|
+
}
|
|
459
|
+
else if (this.isFile(at)) {
|
|
460
|
+
this.real.unlinkSync(at)
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
rollback() {
|
|
467
|
+
for (const [abs, content] of Array.from(this.orig).reverse()) {
|
|
468
|
+
try {
|
|
469
|
+
if (null != content) {
|
|
470
|
+
this.real.writeFileSync(abs, content)
|
|
471
|
+
}
|
|
472
|
+
else if (this.isFile(abs)) {
|
|
473
|
+
this.real.unlinkSync(abs)
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
catch (err: any) {
|
|
477
|
+
this.spec.log.warn({
|
|
478
|
+
point: 'copy-merge-rollback', file: abs, err: err?.message,
|
|
479
|
+
note: 'could not restore ' + abs + ': ' + err?.message,
|
|
480
|
+
})
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
for (const dir of [...this.dirs].reverse()) {
|
|
485
|
+
try {
|
|
486
|
+
if (0 === this.real.readdirSync(dir).length) {
|
|
487
|
+
this.real.rmdirSync(dir)
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
catch (err: any) {
|
|
491
|
+
continue
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
previews(conflicts: Conflict[]) {
|
|
498
|
+
this.clearDir(this.spec.conflictdir)
|
|
499
|
+
|
|
500
|
+
const dir = this.storePath(this.spec.conflictdir, '')
|
|
501
|
+
this.real.mkdirSync(dir, { recursive: true })
|
|
502
|
+
this.real.writeFileSync(Path.join(dir, '.gitignore'), '*\n')
|
|
503
|
+
|
|
504
|
+
for (const conflict of conflicts) {
|
|
505
|
+
const at = this.storePath(this.spec.conflictdir, conflict.rel)
|
|
506
|
+
this.real.mkdirSync(Path.dirname(at), { recursive: true })
|
|
507
|
+
this.real.writeFileSync(at, conflict.preview ?? Buffer.alloc(0))
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
clearDir(rel: string) {
|
|
513
|
+
const dir = this.storePath(rel, '')
|
|
514
|
+
if (this.real.existsSync(dir)) {
|
|
515
|
+
this.real.rmSync(dir, { recursive: true, force: true })
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
writeAtomic(abs: string, content: Buffer) {
|
|
521
|
+
const tmp = abs + '.copymerge-' + process.pid + '-' + Date.now()
|
|
522
|
+
this.real.writeFileSync(tmp, content)
|
|
523
|
+
this.real.renameSync(tmp, abs)
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
failure(conflicts: Conflict[]): Error {
|
|
528
|
+
const why = {
|
|
529
|
+
conflict: 'the incoming copy and the local change touch the same lines',
|
|
530
|
+
nobase: 'no record of what the toolchain wrote here, so nothing to merge ' +
|
|
531
|
+
'against; a base is found in git once the copies an add writes are committed',
|
|
532
|
+
binary: 'a binary file, which is never merged',
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
const shown = this.spec.conflictdir
|
|
536
|
+
|
|
537
|
+
const msg = this.spec.what + ': ' + conflicts.length +
|
|
538
|
+
' locally changed file(s) cannot take the incoming copy, so nothing was changed:\n ' +
|
|
539
|
+
conflicts.map((c: Conflict) => c.rel + ' (' + why[c.why] + ')').join('\n ') +
|
|
540
|
+
'\n\n The incoming copies are in ' + shown + '/, with conflict markers where ' +
|
|
541
|
+
'both sides changed the same lines. Then either:' +
|
|
542
|
+
'\n - fold the incoming change into the local file by hand and re-run with ' +
|
|
543
|
+
'--keep, which keeps the local files and records the incoming copies as ' +
|
|
544
|
+
'their base;' +
|
|
545
|
+
'\n - or re-run with --force, which takes the incoming copies and discards ' +
|
|
546
|
+
'the local changes.'
|
|
547
|
+
|
|
548
|
+
return null == this.spec.error ? new Error(msg) : this.spec.error(msg)
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
report(outcome: MergeOutcome) {
|
|
553
|
+
const log = this.spec.log
|
|
554
|
+
|
|
555
|
+
if (0 < outcome.merged.length) {
|
|
556
|
+
log.info({
|
|
557
|
+
point: 'copy-merge-merged', files: outcome.merged,
|
|
558
|
+
note: this.spec.what + ': merged the incoming copy into ' +
|
|
559
|
+
outcome.merged.length + ' locally changed file(s): ' + outcome.merged.join(', ')
|
|
560
|
+
})
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
if (0 < outcome.kept.length) {
|
|
564
|
+
log.warn({
|
|
565
|
+
point: 'copy-merge-kept', files: outcome.kept,
|
|
566
|
+
note: this.spec.what + ': --keep, kept ' + outcome.kept.length +
|
|
567
|
+
' local file(s) as they are: ' + outcome.kept.join(', ')
|
|
568
|
+
})
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
if (0 < outcome.retained.length) {
|
|
572
|
+
log.warn({
|
|
573
|
+
point: 'copy-merge-retained', files: outcome.retained,
|
|
574
|
+
note: this.spec.what + ': the source no longer provides ' +
|
|
575
|
+
outcome.retained.length + ' locally changed file(s), left in place: ' +
|
|
576
|
+
outcome.retained.join(', ')
|
|
577
|
+
})
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
if (0 < outcome.spared.length) {
|
|
581
|
+
log.info({
|
|
582
|
+
point: 'copy-merge-spared', files: outcome.spared,
|
|
583
|
+
note: this.spec.what + ': left ' + outcome.spared.length +
|
|
584
|
+
' file(s) no add wrote in place: ' + outcome.spared.join(', ')
|
|
585
|
+
})
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
function merge3(
|
|
592
|
+
incoming: Buffer, base: Buffer, local: Buffer, what: string,
|
|
593
|
+
): { conflict: boolean, content: Buffer } {
|
|
594
|
+
const text = (bytes: Buffer) => bytes.toString('latin1').replace(/\r\n/g, '\n')
|
|
595
|
+
|
|
596
|
+
const res: any = DiffUtil.merge(text(incoming), text(base), text(local), {
|
|
597
|
+
labels: { generated: 'INCOMING (' + what + ')', existing: 'LOCAL' },
|
|
598
|
+
})
|
|
599
|
+
|
|
600
|
+
return {
|
|
601
|
+
conflict: true === res.conflict || 'unresolved' === res.outcome,
|
|
602
|
+
content: Buffer.from(res.content, 'latin1'),
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
function within(rel: string, dir: string): boolean {
|
|
608
|
+
return rel === dir || rel.startsWith(dir + '/')
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
function toBuffer(data: any, opts?: any): Buffer {
|
|
613
|
+
if (Buffer.isBuffer(data)) {
|
|
614
|
+
return data
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
if (ArrayBuffer.isView(data)) {
|
|
618
|
+
return Buffer.from(data.buffer, data.byteOffset, data.byteLength)
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
const encoding = ('string' === typeof opts ? opts : opts?.encoding) || 'utf8'
|
|
622
|
+
return Buffer.from(String(data), encoding)
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
function sameText(a: Buffer, b: Buffer): boolean {
|
|
627
|
+
return a.toString('latin1').replace(/\r\n/g, '\n') ===
|
|
628
|
+
b.toString('latin1').replace(/\r\n/g, '\n')
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
function isBinary(bytes: Buffer): boolean {
|
|
633
|
+
return 0 <= bytes.subarray(0, 8000).indexOf(0)
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
// Committed versions of a file, newest first: the index, HEAD, then the
|
|
638
|
+
// commits that touched it. Nothing outside a git checkout.
|
|
639
|
+
function gitVersions(abs: string, limit = 20): Iterable<Buffer> {
|
|
640
|
+
return {
|
|
641
|
+
*[Symbol.iterator]() {
|
|
642
|
+
let top: string
|
|
643
|
+
let rel: string
|
|
644
|
+
|
|
645
|
+
try {
|
|
646
|
+
const dir = Path.dirname(abs)
|
|
647
|
+
top = git(dir, ['rev-parse', '--show-toplevel']).toString().trim()
|
|
648
|
+
const real = Path.join(realpathSync(dir), Path.basename(abs))
|
|
649
|
+
rel = Path.relative(top, real).split(Path.sep).join('/')
|
|
650
|
+
}
|
|
651
|
+
catch (err: any) {
|
|
652
|
+
return
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
const shown = (spec: string) => {
|
|
656
|
+
try {
|
|
657
|
+
return git(top, ['show', spec])
|
|
658
|
+
}
|
|
659
|
+
catch (err: any) {
|
|
660
|
+
return undefined
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
for (const spec of [':' + rel, 'HEAD:' + rel]) {
|
|
665
|
+
const version = shown(spec)
|
|
666
|
+
if (null != version) {
|
|
667
|
+
yield version
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
let shas: string[] = []
|
|
672
|
+
try {
|
|
673
|
+
shas = git(top, ['log', '--format=%H', '-n', String(limit), '--', rel])
|
|
674
|
+
.toString().split('\n').filter(Boolean)
|
|
675
|
+
}
|
|
676
|
+
catch (err: any) {
|
|
677
|
+
return
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
for (const sha of shas) {
|
|
681
|
+
const version = shown(sha + ':' + rel)
|
|
682
|
+
if (null != version) {
|
|
683
|
+
yield version
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
function git(cwd: string, args: string[]): Buffer {
|
|
692
|
+
return execFileSync('git', args, {
|
|
693
|
+
cwd, stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 256 * 1024 * 1024,
|
|
694
|
+
})
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
export type {
|
|
699
|
+
CopyMode,
|
|
700
|
+
GuardSpec,
|
|
701
|
+
MergeOutcome,
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
export {
|
|
705
|
+
MERGE,
|
|
706
|
+
OVERWRITE,
|
|
707
|
+
copyMode,
|
|
708
|
+
withCopyMerge,
|
|
709
|
+
gitVersions,
|
|
710
|
+
merge3,
|
|
711
|
+
}
|