@nestledjs/upgrades 0.3.1 → 0.3.2

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.
@@ -0,0 +1,624 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.RunChanges = void 0;
4
+ exports.dirtyPaths = dirtyPaths;
5
+ exports.ignoredPaths = ignoredPaths;
6
+ exports.patchPaths = patchPaths;
7
+ exports.listFiles = listFiles;
8
+ exports.snapshotFiles = snapshotFiles;
9
+ exports.restoreChangedFiles = restoreChangedFiles;
10
+ exports.commitPaths = commitPaths;
11
+ const node_fs_1 = require("node:fs");
12
+ const node_path_1 = require("node:path");
13
+ const git_1 = require("./git");
14
+ /**
15
+ * Bookkeeping for which paths an apply run changes, so that undoing the run touches those paths and
16
+ * nothing else. A run may start on a dirty tree (`--allow-dirty`); whatever the user had uncommitted
17
+ * there is theirs, and no rollback may revert it.
18
+ *
19
+ * Paths here are relative to the repository root, the form `git status --porcelain` reports.
20
+ */
21
+ /** `git --literal-pathspecs`: a path containing `*` or `?` is a file name, never a glob. */
22
+ const LITERAL = '--literal-pathspecs';
23
+ /** Keep argument lists well under any platform's argv limit. */
24
+ const CHUNK = 100;
25
+ function chunks(items) {
26
+ const result = [];
27
+ for (let i = 0; i < items.length; i += CHUNK)
28
+ result.push(items.slice(i, i + CHUNK));
29
+ return result;
30
+ }
31
+ function isNestledPath(path) {
32
+ return path === '.nestled' || path.startsWith('.nestled/');
33
+ }
34
+ /**
35
+ * Every path with uncommitted changes at this moment — staged, unstaged, or untracked (each file of an
36
+ * untracked directory individually) — excluding our own `.nestled/` bookkeeping, the same exclusion
37
+ * `hasUncommittedChanges` makes. Both sides of a staged rename are included.
38
+ */
39
+ function dirtyPaths(cwd) {
40
+ const result = (0, git_1.git)(cwd, ['status', '--porcelain=v1', '-z', '--untracked-files=all', '--ignore-submodules=none']);
41
+ // Never fall back to an empty set: that would treat the user's uncommitted work as not there.
42
+ if (result.status !== 0)
43
+ throw new Error(`Unable to list uncommitted changes: ${result.stderr || result.stdout}`);
44
+ const paths = new Set();
45
+ const entries = result.stdout.split('\0');
46
+ for (let i = 0; i < entries.length; i++) {
47
+ const entry = entries[i];
48
+ if (entry.length < 4)
49
+ continue;
50
+ paths.add(entry.slice(3));
51
+ // `XY new\0old\0`: a rename or copy carries its source as the next entry.
52
+ if (/[RC]/.test(entry.slice(0, 2)) && entries[i + 1])
53
+ paths.add(entries[++i]);
54
+ }
55
+ return new Set([...paths].filter((path) => !isNestledPath(path)));
56
+ }
57
+ /** How many files `ignoredPaths` lists inside collapsed ignored directories before giving up on one. */
58
+ const IGNORED_SCAN_LIMIT = 20000;
59
+ /**
60
+ * Ignored paths that exist right now, as `git status --ignored` reports them, as a test of whether a
61
+ * path existed (as an ignored file) at this moment. Git collapses an ignored directory to one entry,
62
+ * so its files are listed here directly, up to a limit; inside a directory too large to list (a
63
+ * `node_modules`), every path counts as pre-existing, which can only ever leave a file behind.
64
+ */
65
+ function ignoredPaths(cwd) {
66
+ // --untracked-files: ignored files are only listed while untracked ones are, whatever the config says.
67
+ const result = (0, git_1.git)(cwd, ['status', '--porcelain=v1', '-z', '--ignored=traditional', '--untracked-files=normal']);
68
+ if (result.status !== 0)
69
+ throw new Error(`Unable to list ignored files: ${result.stderr || result.stdout}`);
70
+ const entries = result.stdout
71
+ .split('\0')
72
+ .filter((entry) => entry.startsWith('!! '))
73
+ .map((entry) => entry.slice(3));
74
+ const files = new Set(entries.filter((entry) => !entry.endsWith('/')));
75
+ const unlisted = [];
76
+ let budget = IGNORED_SCAN_LIMIT;
77
+ for (const dir of entries.filter((entry) => entry.endsWith('/'))) {
78
+ const found = budget > 0 ? listFiles(cwd, dir, budget) : null;
79
+ if (!found) {
80
+ // Out of allowance (or over it here): this and every later directory get the cautious answer.
81
+ budget = 0;
82
+ unlisted.push(dir);
83
+ continue;
84
+ }
85
+ budget -= found.length;
86
+ found.forEach((path) => files.add(path));
87
+ }
88
+ return (path) => files.has(path) || unlisted.some((dir) => path.startsWith(dir));
89
+ }
90
+ /** C-style unquoting of a path git quoted (`"a/\303\274.txt"`); octal escapes are UTF-8 bytes. */
91
+ function unquote(raw) {
92
+ var _a;
93
+ if (!raw.startsWith('"'))
94
+ return raw;
95
+ const bytes = [];
96
+ const simple = { a: 7, b: 8, t: 9, n: 10, v: 11, f: 12, r: 13, '"': 34, '\\': 92 };
97
+ for (let i = 1; i < raw.length; i++) {
98
+ // Whole code points: with core.quotePath=false an emoji arrives literally, as a surrogate pair.
99
+ const ch = String.fromCodePoint(raw.codePointAt(i));
100
+ if (ch === '"')
101
+ break;
102
+ if (ch !== '\\') {
103
+ bytes.push(...Buffer.from(ch, 'utf8'));
104
+ i += ch.length - 1;
105
+ continue;
106
+ }
107
+ const next = raw[++i];
108
+ if (/[0-7]/.test(next)) {
109
+ const octal = raw.slice(i, i + 3);
110
+ bytes.push(parseInt(octal, 8));
111
+ i += octal.length - 1;
112
+ }
113
+ else {
114
+ bytes.push((_a = simple[next]) !== null && _a !== void 0 ? _a : next.charCodeAt(0));
115
+ }
116
+ }
117
+ return Buffer.from(bytes).toString('utf8');
118
+ }
119
+ /** Drop the first path component, as `git apply` does by default (`-p1`): `a/x`, `old/x` -> `x`. */
120
+ function stripComponent(path) {
121
+ const slash = path.indexOf('/');
122
+ return slash > 0 && slash < path.length - 1 ? path.slice(slash + 1) : null;
123
+ }
124
+ /** The path on a `---`/`+++`/`rename from` line: unquoted, `/dev/null` dropped, first component stripped. */
125
+ function headerPath(raw, stripPrefix) {
126
+ const value = raw.startsWith('"') ? unquote(raw) : raw.replace(/\t.*$/, '');
127
+ if (value === '/dev/null' || !value)
128
+ return null;
129
+ return stripPrefix ? stripComponent(value) : value;
130
+ }
131
+ /** `diff --git <p>/x <p>/x` when the section has no other path line (mode-only, binary). */
132
+ function gitHeaderPaths(rest) {
133
+ const quoted = rest.match(/^("(?:[^"\\]|\\.)*"|\S+) ("(?:[^"\\]|\\.)*"|\S+)$/);
134
+ if (quoted && (quoted[1].startsWith('"') || quoted[2].startsWith('"'))) {
135
+ return [quoted[1], quoted[2]].map((part) => headerPath(part, true)).filter((p) => !!p);
136
+ }
137
+ // Unquoted, and the same path on both sides (there is no rename without rename lines): find the
138
+ // space that splits the header into two sides that agree once their prefixes are stripped.
139
+ for (let i = rest.indexOf(' '); i > 0; i = rest.indexOf(' ', i + 1)) {
140
+ const left = stripComponent(rest.slice(0, i));
141
+ if (left && left === stripComponent(rest.slice(i + 1)))
142
+ return [left];
143
+ }
144
+ return [];
145
+ }
146
+ function globToRegExp(glob) {
147
+ let source = '';
148
+ for (let i = 0; i < glob.length; i++) {
149
+ const ch = glob[i];
150
+ if (ch === '*' && glob[i + 1] === '*') {
151
+ source += '.*';
152
+ i++;
153
+ }
154
+ else if (ch === '*')
155
+ source += '[^/]*';
156
+ else if (ch === '?')
157
+ source += '[^/]';
158
+ else
159
+ source += ch.replace(/[.+^${}()|[\]\\]/g, '\\$&');
160
+ }
161
+ return new RegExp(`^${source}$`);
162
+ }
163
+ /**
164
+ * Every path a unified diff would create, modify, delete or rename (both sides), relative to the
165
+ * directory `git apply` runs in, minus paths matching `excludes` (the `--exclude` globs passed to
166
+ * `git apply`). Hunk bodies are skipped by their line counts, so a removed line that happens to read
167
+ * `-- a/x` is never mistaken for a header.
168
+ */
169
+ function patchPaths(diffText, excludes = []) {
170
+ var _a, _b;
171
+ const lines = diffText.split('\n');
172
+ const paths = new Set();
173
+ let section = [];
174
+ let gitHeader = null;
175
+ const flush = () => {
176
+ const found = section.length ? section : gitHeader ? gitHeaderPaths(gitHeader) : [];
177
+ found.forEach((path) => paths.add(path));
178
+ section = [];
179
+ gitHeader = null;
180
+ };
181
+ for (let i = 0; i < lines.length; i++) {
182
+ const line = lines[i];
183
+ if (line.startsWith('diff --git ')) {
184
+ flush();
185
+ gitHeader = line.slice('diff --git '.length);
186
+ }
187
+ else if (line.startsWith('@@ ')) {
188
+ const counts = line.match(/^@@ -\d+(?:,(\d+))? \+\d+(?:,(\d+))? @@/);
189
+ let oldLeft = counts ? Number((_a = counts[1]) !== null && _a !== void 0 ? _a : 1) : 0;
190
+ let newLeft = counts ? Number((_b = counts[2]) !== null && _b !== void 0 ? _b : 1) : 0;
191
+ while ((oldLeft > 0 || newLeft > 0) && i + 1 < lines.length) {
192
+ const body = lines[++i];
193
+ if (body.startsWith('\\'))
194
+ continue;
195
+ if (body.startsWith('-'))
196
+ oldLeft--;
197
+ else if (body.startsWith('+'))
198
+ newLeft--;
199
+ else {
200
+ oldLeft--;
201
+ newLeft--;
202
+ }
203
+ }
204
+ }
205
+ else if (line.startsWith('--- ') || line.startsWith('+++ ')) {
206
+ const path = headerPath(line.slice(4), true);
207
+ if (path)
208
+ section.push(path);
209
+ }
210
+ else {
211
+ const moved = line.match(/^(?:rename|copy) (?:from|to) (.+)$/);
212
+ if (moved && gitHeader !== null) {
213
+ const path = headerPath(moved[1], false);
214
+ if (path)
215
+ section.push(path);
216
+ }
217
+ }
218
+ }
219
+ flush();
220
+ const excluded = excludes.map(globToRegExp);
221
+ return [...paths].filter((path) => !excluded.some((pattern) => pattern.test(path)));
222
+ }
223
+ function lstatOrNull(absolute) {
224
+ try {
225
+ return (0, node_fs_1.lstatSync)(absolute);
226
+ }
227
+ catch (_a) {
228
+ return null;
229
+ }
230
+ }
231
+ /** `other` for directories here; `ancestorState` is what tells a directory from a link above a path. */
232
+ function readState(absolute) {
233
+ const stat = lstatOrNull(absolute);
234
+ if (!stat)
235
+ return { kind: 'absent' };
236
+ if (stat.isSymbolicLink())
237
+ return { kind: 'link', target: (0, node_fs_1.readlinkSync)(absolute) };
238
+ if (stat.isFile())
239
+ return { kind: 'file', contents: (0, node_fs_1.readFileSync)(absolute), mode: stat.mode & 0o777 };
240
+ return { kind: 'other' };
241
+ }
242
+ function sameState(a, b) {
243
+ if (a.kind === 'file' && b.kind === 'file')
244
+ return a.mode === b.mode && a.contents.equals(b.contents);
245
+ if (a.kind === 'link' && b.kind === 'link')
246
+ return a.target === b.target;
247
+ return a.kind === b.kind;
248
+ }
249
+ /** A directory reads as `other`; anything else in a directory's place reads as what it is. */
250
+ function ancestorState(absolute) {
251
+ const stat = lstatOrNull(absolute);
252
+ if (!stat)
253
+ return { kind: 'absent' };
254
+ if (stat.isSymbolicLink())
255
+ return { kind: 'link', target: (0, node_fs_1.readlinkSync)(absolute) };
256
+ if (stat.isDirectory())
257
+ return { kind: 'other', mode: stat.mode & 0o7777 };
258
+ return { kind: 'file', contents: Buffer.alloc(0), mode: 0 };
259
+ }
260
+ function capture(root, path, state = readState((0, node_path_1.join)(root, path))) {
261
+ const ancestors = [];
262
+ let absolute = root;
263
+ for (const part of path.split('/').slice(0, -1)) {
264
+ absolute = (0, node_path_1.join)(absolute, part);
265
+ ancestors.push({ absolute, state: ancestorState(absolute) });
266
+ }
267
+ return { state, ancestors };
268
+ }
269
+ /**
270
+ * Whether every parent is as captured. A parent that did not exist may since have become a real
271
+ * directory (the run creating `new-dir/file`), but never a link or a file.
272
+ */
273
+ function ancestorsIntact(captured) {
274
+ return captured.ancestors.every(({ absolute, state }) => {
275
+ const now = ancestorState(absolute);
276
+ if (state.kind === 'absent')
277
+ return now.kind === 'absent' || now.kind === 'other';
278
+ return state.kind === 'file' ? now.kind === 'file' : sameState(state, now);
279
+ });
280
+ }
281
+ /**
282
+ * Put back each directory or directory link above a captured path that a step replaced, top down,
283
+ * before anything below it is touched: otherwise a link a step put in place of a directory would send
284
+ * the restore somewhere else entirely. A link the user already had stays. Returns false when the
285
+ * path had no real parent when captured, so there is nothing to restore it into.
286
+ */
287
+ function restoreAncestors(captured) {
288
+ for (const { absolute, state } of captured.ancestors) {
289
+ if (state.kind === 'absent' || state.kind === 'file')
290
+ return false;
291
+ const now = ancestorState(absolute);
292
+ if (sameState(state, now)) {
293
+ // Same directory, but a step may have made it unwritable (or otherwise changed its mode).
294
+ if (state.kind === 'other' && now.kind === 'other' && state.mode !== undefined && state.mode !== now.mode) {
295
+ (0, node_fs_1.chmodSync)(absolute, state.mode);
296
+ }
297
+ continue;
298
+ }
299
+ if (now.kind !== 'absent')
300
+ (0, node_fs_1.rmSync)(absolute, { recursive: now.kind === 'other', force: true });
301
+ if (state.kind === 'link')
302
+ (0, node_fs_1.symlinkSync)(state.target, absolute);
303
+ else
304
+ (0, node_fs_1.mkdirSync)(absolute);
305
+ }
306
+ return true;
307
+ }
308
+ /** Whether `captured` still describes what is at `absolute` and above it. */
309
+ function unchanged(absolute, captured) {
310
+ return ancestorsIntact(captured) && sameState(captured.state, readState(absolute));
311
+ }
312
+ /**
313
+ * Put `captured` back at `absolute`. An absent path is only cleared while its parents are as they were
314
+ * (through a replaced parent, the same name means some other file); a directory there is left alone.
315
+ */
316
+ function restoreCaptured(absolute, captured) {
317
+ const { state } = captured;
318
+ if (state.kind === 'other')
319
+ return;
320
+ if (state.kind === 'absent') {
321
+ const stat = ancestorsIntact(captured) ? lstatOrNull(absolute) : null;
322
+ if (stat && !stat.isDirectory())
323
+ (0, node_fs_1.rmSync)(absolute, { force: true });
324
+ return;
325
+ }
326
+ if (!restoreAncestors(captured))
327
+ return;
328
+ const stat = lstatOrNull(absolute);
329
+ if (stat)
330
+ (0, node_fs_1.rmSync)(absolute, { recursive: stat.isDirectory(), force: true });
331
+ if (state.kind === 'link')
332
+ (0, node_fs_1.symlinkSync)(state.target, absolute);
333
+ else {
334
+ (0, node_fs_1.writeFileSync)(absolute, state.contents);
335
+ // Exactly: a mode passed to writeFileSync is filtered through the process umask.
336
+ (0, node_fs_1.chmodSync)(absolute, state.mode);
337
+ }
338
+ }
339
+ /**
340
+ * The paths one apply run changes, each with how it looked before the run first touched it, so a
341
+ * rollback can put back exactly those paths and leave every other file alone.
342
+ *
343
+ * Call `willTouch` before writing a path. Its state at that moment is the pre-run state, because
344
+ * the run never writes a path it has not declared, and a path the user had uncommitted is refused
345
+ * before it gets here.
346
+ */
347
+ class RunChanges {
348
+ /** A pre-existing ignored file a step surfaced: the user's, so no later note may write it. */
349
+ isPreserved(path) {
350
+ return this.preserved.has(path);
351
+ }
352
+ /** `root` is the repository root; `startCommit` is HEAD before the run ('' on an unborn branch). */
353
+ constructor(root, startCommit) {
354
+ this.root = root;
355
+ this.startCommit = startCommit;
356
+ this.prior = new Map();
357
+ /** Pre-existing ignored files a step surfaced: restored on rollback, never committed as the run's. */
358
+ this.preserved = new Set();
359
+ }
360
+ get paths() {
361
+ return [...this.prior.keys()];
362
+ }
363
+ has(path) {
364
+ return this.prior.has(path);
365
+ }
366
+ willTouch(paths) {
367
+ const fresh = [...new Set(paths)].filter((path) => path && !this.prior.has(path));
368
+ if (!fresh.length)
369
+ return;
370
+ const tracked = this.trackedAtStart(fresh);
371
+ for (const path of fresh) {
372
+ // Anything not tracked is either absent or a file the user has that git ignores: keep a copy.
373
+ this.prior.set(path, tracked.has(path) ? { kind: 'tracked' } : { kind: 'captured', captured: capture(this.root, path) });
374
+ }
375
+ }
376
+ /**
377
+ * Record paths something the run did has already changed (an install's or a hook's side effects).
378
+ * Only paths that were clean before that step may be passed. Each was tracked at the start commit,
379
+ * did not exist, or existed but was ignored (`existedIgnored`): a step that changes an ignore rule,
380
+ * or force-adds and commits, can surface a file it never wrote. That last kind is the user's: it is
381
+ * kept as it is now and, after a rollback, put back the same way, unstaged. Returns the paths among
382
+ * `paths` that the run now owns, which never include the user's.
383
+ */
384
+ didChange(paths, existedIgnored = () => false) {
385
+ const unique = [...new Set(paths)].filter(Boolean);
386
+ const fresh = unique.filter((path) => !this.prior.has(path));
387
+ const tracked = this.trackedAtStart(fresh);
388
+ for (const path of fresh) {
389
+ if (tracked.has(path))
390
+ this.prior.set(path, { kind: 'tracked' });
391
+ else if (existedIgnored(path)) {
392
+ this.prior.set(path, { kind: 'captured', captured: capture(this.root, path) });
393
+ this.preserved.add(path);
394
+ }
395
+ else {
396
+ this.prior.set(path, { kind: 'captured', captured: capture(this.root, path, { kind: 'absent' }) });
397
+ }
398
+ }
399
+ return unique.filter((path) => this.prior.has(path) && !this.preserved.has(path));
400
+ }
401
+ /**
402
+ * Put `paths` (default: every path this run touched) back to their pre-run state, in both the
403
+ * index and the working tree. Nothing outside them is read or written.
404
+ */
405
+ restore(paths = this.prior.keys()) {
406
+ const targets = [...new Set(paths)].filter((path) => this.prior.has(path));
407
+ const tracked = targets.filter((path) => { var _a; return ((_a = this.prior.get(path)) === null || _a === void 0 ? void 0 : _a.kind) === 'tracked'; });
408
+ const others = targets.filter((path) => { var _a; return ((_a = this.prior.get(path)) === null || _a === void 0 ? void 0 : _a.kind) !== 'tracked'; });
409
+ const must = (args) => {
410
+ const result = (0, git_1.git)(this.root, args);
411
+ if (result.status !== 0)
412
+ throw new Error(`Rollback incomplete: git ${args.join(' ')} failed: ${result.stderr}`);
413
+ };
414
+ for (const chunk of chunks(tracked)) {
415
+ // Hook-free: a post-checkout hook could rewrite the user's uncommitted files mid-rollback.
416
+ must([...git_1.NO_HOOKS, LITERAL, 'checkout', this.startCommit, '--', ...chunk]);
417
+ }
418
+ // -f: the index entry may match neither HEAD nor the file on disk (staged by us, then rewritten).
419
+ for (const chunk of chunks(others)) {
420
+ // Only exact index entries: a path that is now a directory must not match what lies under it.
421
+ const indexed = new Set((0, git_1.git)(this.root, [LITERAL, 'ls-files', '-z', '--', ...chunk]).stdout.split('\0'));
422
+ const staged = chunk.filter((path) => indexed.has(path));
423
+ if (staged.length)
424
+ must([LITERAL, 'rm', '--cached', '-f', '-q', '--', ...staged]);
425
+ }
426
+ for (const path of others) {
427
+ const prior = this.prior.get(path);
428
+ if ((prior === null || prior === void 0 ? void 0 : prior.kind) !== 'captured')
429
+ continue;
430
+ restoreCaptured((0, node_path_1.join)(this.root, path), prior.captured);
431
+ if (prior.captured.state.kind === 'absent')
432
+ this.pruneEmptyParents(path);
433
+ }
434
+ }
435
+ /** Paths of `paths` present in the start commit's tree. */
436
+ trackedAtStart(paths) {
437
+ const tracked = new Set();
438
+ if (!this.startCommit)
439
+ return tracked;
440
+ for (const chunk of chunks(paths)) {
441
+ const args = [LITERAL, 'ls-tree', '-r', '-z', '--name-only', '--full-tree', this.startCommit, '--', ...chunk];
442
+ (0, git_1.git)(this.root, args)
443
+ .stdout.split('\0')
444
+ .filter(Boolean)
445
+ .forEach((path) => tracked.add(path));
446
+ }
447
+ return tracked;
448
+ }
449
+ /** Remove directories a created file left empty, up to (never including) the repository root. */
450
+ pruneEmptyParents(path) {
451
+ let dir = (0, node_path_1.dirname)(path);
452
+ while (dir && dir !== '.') {
453
+ const absolute = (0, node_path_1.join)(this.root, dir);
454
+ try {
455
+ if ((0, node_fs_1.readdirSync)(absolute).length)
456
+ return;
457
+ (0, node_fs_1.rmdirSync)(absolute);
458
+ }
459
+ catch (_a) {
460
+ return;
461
+ }
462
+ dir = (0, node_path_1.dirname)(dir);
463
+ }
464
+ }
465
+ }
466
+ exports.RunChanges = RunChanges;
467
+ /**
468
+ * Every file and link under `dir` (repository-root-relative, ending in `/`), as root-relative paths,
469
+ * without following directory links. With `limit`, null once more than that many are found.
470
+ */
471
+ function listFiles(root, dir, limit = Infinity) {
472
+ const result = [];
473
+ const walk = (relative) => {
474
+ let entries;
475
+ try {
476
+ entries = (0, node_fs_1.readdirSync)((0, node_path_1.join)(root, relative), { withFileTypes: true });
477
+ }
478
+ catch (_a) {
479
+ // Unreadable (or gone): an incomplete listing must not pass for a complete one.
480
+ return false;
481
+ }
482
+ for (const entry of entries) {
483
+ const path = `${relative}${entry.name}`;
484
+ if (entry.isDirectory()) {
485
+ if (!walk(`${path}/`))
486
+ return false;
487
+ }
488
+ else {
489
+ result.push(path);
490
+ if (result.length > limit)
491
+ return false;
492
+ }
493
+ }
494
+ return true;
495
+ };
496
+ return walk(dir) ? result : null;
497
+ }
498
+ /** Index entries (`mode sha stage`, one per stage) of each of `paths`; a path with none is not in the index. */
499
+ function indexEntries(root, paths) {
500
+ var _a, _b;
501
+ const entries = new Map();
502
+ for (const chunk of chunks(paths)) {
503
+ const out = (0, git_1.git)(root, [LITERAL, 'ls-files', '-s', '-z', '--', ...chunk]).stdout;
504
+ for (const record of out.split('\0').filter(Boolean)) {
505
+ const tab = record.indexOf('\t');
506
+ const path = record.slice(tab + 1);
507
+ entries.set(path, [...((_a = entries.get(path)) !== null && _a !== void 0 ? _a : []), record.slice(0, tab)]);
508
+ }
509
+ // `ls-files -s` shows an intent-to-add entry (`git add -N`) as a staged empty blob; mark it.
510
+ const status = (0, git_1.git)(root, [LITERAL, 'status', '--porcelain=v2', '-z', '--untracked-files=no', '--', ...chunk]);
511
+ for (const record of status.stdout.split('\0')) {
512
+ const fields = record.split(' ');
513
+ if (fields[0] !== '1' || fields[1] !== '.A')
514
+ continue;
515
+ const path = fields.slice(8).join(' ');
516
+ entries.set(path, ((_b = entries.get(path)) !== null && _b !== void 0 ? _b : []).map((entry) => `${entry} ${INTENT_TO_ADD}`));
517
+ }
518
+ }
519
+ return entries;
520
+ }
521
+ const INTENT_TO_ADD = 'intent-to-add';
522
+ /** Capture `paths` as they are now, on disk and in the index. */
523
+ function snapshotFiles(root, paths) {
524
+ const list = [...new Set(paths)];
525
+ const files = new Map();
526
+ for (const path of list)
527
+ files.set(path, capture(root, path));
528
+ return { files, index: indexEntries(root, list) };
529
+ }
530
+ /** Write what could not be restored under `.git/nestled-recovery/`, so it is never lost. */
531
+ function saveForRecovery(root, path, contents, suffix = '') {
532
+ const gitDir = (0, git_1.gitOutput)(root, ['rev-parse', '--absolute-git-dir']) || (0, node_path_1.join)(root, '.git');
533
+ const target = (0, node_path_1.join)(gitDir, 'nestled-recovery', `${path}${suffix}`);
534
+ (0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(target), { recursive: true });
535
+ (0, node_fs_1.writeFileSync)(target, contents);
536
+ return target;
537
+ }
538
+ function restoreChangedFiles(root, snapshot) {
539
+ var _a, _b, _c;
540
+ const restored = new Set();
541
+ const unrecovered = [];
542
+ for (const [path, before] of snapshot.files) {
543
+ const absolute = (0, node_path_1.join)(root, path);
544
+ if (before.state.kind === 'other' || unchanged(absolute, before))
545
+ continue;
546
+ // A directory where the user had nothing was not theirs; leave it.
547
+ if (before.state.kind === 'absent' && ((_a = lstatOrNull(absolute)) === null || _a === void 0 ? void 0 : _a.isDirectory()))
548
+ continue;
549
+ try {
550
+ restoreCaptured(absolute, before);
551
+ }
552
+ catch (_d) {
553
+ // Keep the user's work somewhere safe, and say where.
554
+ const { state } = before;
555
+ const saved = state.kind === 'file' ? state.contents : state.kind === 'link' ? `symlink -> ${state.target}\n` : '(absent)\n';
556
+ unrecovered.push({ path, savedTo: saveForRecovery(root, path, saved) });
557
+ }
558
+ restored.add(path);
559
+ }
560
+ const now = indexEntries(root, [...snapshot.files.keys()]);
561
+ const changed = [...snapshot.files.keys()].filter((path) => { var _a, _b; return ((_a = snapshot.index.get(path)) !== null && _a !== void 0 ? _a : []).join('\n') !== ((_b = now.get(path)) !== null && _b !== void 0 ? _b : []).join('\n'); });
562
+ if (changed.length) {
563
+ let ok = true;
564
+ for (const chunk of chunks(changed)) {
565
+ ok = (0, git_1.git)(root, [LITERAL, 'update-index', '--force-remove', '--', ...chunk]).status === 0 && ok;
566
+ }
567
+ const intents = changed.filter((path) => { var _a; return (_a = snapshot.index.get(path)) === null || _a === void 0 ? void 0 : _a.some((entry) => entry.endsWith(INTENT_TO_ADD)); });
568
+ const info = changed
569
+ .filter((path) => !intents.includes(path))
570
+ .flatMap((path) => { var _a; return ((_a = snapshot.index.get(path)) !== null && _a !== void 0 ? _a : []).map((entry) => `${entry}\t${path}\0`); });
571
+ if (info.length)
572
+ ok = (0, git_1.git)(root, ['update-index', '-z', '--index-info'], info.join('')).status === 0 && ok;
573
+ for (const chunk of chunks(intents))
574
+ ok = (0, git_1.git)(root, [LITERAL, 'add', '-N', '--', ...chunk]).status === 0 && ok;
575
+ // Verify rather than trust: whatever still differs keeps its saved entries in a recovery file.
576
+ const after = ok ? indexEntries(root, changed) : new Map();
577
+ for (const path of changed) {
578
+ const saved = ((_b = snapshot.index.get(path)) !== null && _b !== void 0 ? _b : []).join('\n');
579
+ if (ok && saved === ((_c = after.get(path)) !== null && _c !== void 0 ? _c : []).join('\n'))
580
+ continue;
581
+ unrecovered.push({ path, savedTo: saveForRecovery(root, path, `${saved}\n`, '.index') });
582
+ }
583
+ changed.forEach((path) => restored.add(path));
584
+ }
585
+ return { restored: [...restored], unrecovered };
586
+ }
587
+ /**
588
+ * Commit only `paths`, leaving anything else the user has staged or modified out of the commit and
589
+ * exactly as it was. Returns the new HEAD (short), the unchanged HEAD when none of `paths` changed,
590
+ * or '' when staging or committing failed (a commit hook rejecting it, say), like `commitAll`.
591
+ */
592
+ function commitPaths(root, message, paths) {
593
+ const unique = [...new Set(paths)];
594
+ for (const chunk of chunks(unique)) {
595
+ // `add -A` of a path that is neither on disk nor in the index fails the whole call; skip those.
596
+ const known = new Set((0, git_1.git)(root, [LITERAL, 'ls-files', '-z', '--', ...chunk])
597
+ .stdout.split('\0')
598
+ .filter(Boolean));
599
+ const addable = chunk.filter((path) => known.has(path) || lstatOrNull((0, node_path_1.join)(root, path)));
600
+ if (addable.length && (0, git_1.git)(root, [LITERAL, 'add', '-A', '--', ...addable]).status !== 0)
601
+ return '';
602
+ }
603
+ // Exactly the paths with something staged: a path that ended up unchanged would fail `--only`.
604
+ // --no-renames: a staged rename must list its source too, or `--only` leaves the deletion behind.
605
+ const staged = chunks(unique).flatMap((chunk) => (0, git_1.git)(root, [LITERAL, 'diff', '--cached', '--no-renames', '--name-only', '-z', '--', ...chunk])
606
+ .stdout.split('\0')
607
+ .filter(Boolean));
608
+ if (staged.length) {
609
+ const args = [
610
+ 'commit',
611
+ '--no-gpg-sign',
612
+ '-q',
613
+ '-m',
614
+ message,
615
+ '--only',
616
+ '--pathspec-from-file=-',
617
+ '--pathspec-file-nul',
618
+ ];
619
+ if ((0, git_1.git)(root, [LITERAL, ...args], `${staged.join('\0')}\0`).status !== 0)
620
+ return '';
621
+ }
622
+ return (0, git_1.gitOutput)(root, ['rev-parse', '--short', 'HEAD']);
623
+ }
624
+ //# sourceMappingURL=changes.js.map