lintlane 3.0.1 → 4.0.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.
@@ -1,110 +0,0 @@
1
- // Interactive "commit with steps" prompt (the Commitizen-style flow).
2
- // Plain Node, readline only — no dependencies.
3
- import { createInterface } from "node:readline/promises";
4
- import { buildMessage, resolveOptions } from "./commit-lint.mjs";
5
-
6
- const supportsColor = (stream) => stream.isTTY && !process.env.NO_COLOR;
7
-
8
- /**
9
- * Ask the questions and return the finished message, or null if the user
10
- * chose not to commit. Throws if the input ends (Ctrl+C / Ctrl+D).
11
- */
12
- export async function promptCommit({
13
- input = process.stdin,
14
- output = process.stderr,
15
- options,
16
- } = {}) {
17
- const { types: TYPES, maxHeader: MAX_HEADER } = resolveOptions(options);
18
- const typeWidth = Math.max(...TYPES.map(([type]) => type.length));
19
- const rl = createInterface({ input, output });
20
- const c = supportsColor(output)
21
- ? {
22
- b: (s) => `\x1b[1m${s}\x1b[0m`,
23
- d: (s) => `\x1b[2m${s}\x1b[0m`,
24
- g: (s) => `\x1b[32m${s}\x1b[0m`,
25
- r: (s) => `\x1b[31m${s}\x1b[0m`,
26
- }
27
- : { b: (s) => s, d: (s) => s, g: (s) => s, r: (s) => s };
28
- const say = (text = "") => output.write(`${text}\n`);
29
-
30
- // Queue incoming lines so input that arrives early (pasted or piped) isn't
31
- // lost between questions, and so a closed input ends the prompt instead of
32
- // leaving it waiting forever.
33
- const lines = rl[Symbol.asyncIterator]();
34
- const ask = async (question) => {
35
- rl.setPrompt(question);
36
- rl.prompt();
37
- const { value, done } = await lines.next();
38
- if (done) throw new Error("input closed");
39
- return value.trim();
40
- };
41
-
42
- try {
43
- say(c.b("\nCommit — step 1/7: type of change"));
44
- TYPES.forEach(([type, description], i) => {
45
- say(` ${String(i + 1).padStart(2)}. ${c.g(type.padEnd(typeWidth))} ${c.d(description)}`);
46
- });
47
- let type;
48
- while (!type) {
49
- const answer = (await ask("Choose a type (number or name): ")).toLowerCase();
50
- type = TYPES.find(([name], i) => name === answer || String(i + 1) === answer)?.[0];
51
- if (!type) say(c.r(" Pick one of the listed types."));
52
- }
53
-
54
- say(c.b("\nStep 2/7: scope") + c.d(" (optional — the area you changed, e.g. auth, api, ui)"));
55
- let scope = "";
56
- for (;;) {
57
- scope = await ask("Scope (Enter to skip): ");
58
- if (!/[()]/.test(scope)) break;
59
- say(c.r(" Scope can't contain parentheses."));
60
- }
61
-
62
- say(c.b("\nStep 3/7: subject") + c.d(" (short, imperative: “add login form”, no period)"));
63
- let subject = "";
64
- for (;;) {
65
- subject = await ask("Subject: ");
66
- const headerLength = `${type}${scope ? `(${scope})` : ""}: ${subject}`.length;
67
- if (!subject) say(c.r(" The subject is required."));
68
- else if (subject.endsWith(".")) say(c.r(" Remove the trailing period."));
69
- else if (MAX_HEADER > 0 && headerLength > MAX_HEADER)
70
- say(c.r(` Too long (${headerLength}/${MAX_HEADER}). Shorten it.`));
71
- else break;
72
- }
73
-
74
- say(c.b("\nStep 4/7: body") + c.d(" (optional — why, not what. Empty line to finish)"));
75
- const bodyLines = [];
76
- for (;;) {
77
- const line = await ask(bodyLines.length === 0 ? "Body (Enter to skip): " : " …: ");
78
- if (line === "") break;
79
- bodyLines.push(line);
80
- }
81
-
82
- say(c.b("\nStep 5/7: breaking change?"));
83
- let breaking = "";
84
- if ((await ask("Does this break existing behaviour? (y/N): ")).toLowerCase().startsWith("y")) {
85
- while (!breaking) {
86
- breaking = await ask("Describe what breaks and how to migrate: ");
87
- }
88
- }
89
-
90
- say(c.b("\nStep 6/7: footer") + c.d(" (optional — issues this closes, e.g. “Closes #123”)"));
91
- const footer = await ask("Footer (Enter to skip): ");
92
-
93
- const message = buildMessage({
94
- type,
95
- scope,
96
- subject,
97
- body: bodyLines.join("\n"),
98
- breaking,
99
- footer,
100
- });
101
- say(c.b("\nStep 7/7: confirm"));
102
- say(c.d("─".repeat(40)));
103
- say(message);
104
- say(c.d("─".repeat(40)));
105
- const confirm = (await ask("Use this message? (Y/n): ")).toLowerCase();
106
- return confirm === "" || confirm.startsWith("y") ? message : null;
107
- } finally {
108
- rl.close();
109
- }
110
- }
@@ -1,55 +0,0 @@
1
- #!/usr/bin/env node
2
- // git `pre-commit` hook entry: formats and lints the staged files with Biome
3
- // without ever staging anything the developer didn't stage (see staged.mjs).
4
- //
5
- // node pre-commit.mjs run the checks (what the hook does)
6
- // node pre-commit.mjs --restore recover working files after an interrupted run
7
- import { resolveBiome, runProcess } from "./proc.mjs";
8
- import { restoreFromBackup, runStagedChecks, StaleBackupError } from "./staged.mjs";
9
-
10
- const out = (text) => process.stdout.write(text);
11
- const err = (text) => process.stderr.write(text);
12
-
13
- const top = await runProcess("git", ["rev-parse", "--show-toplevel"]);
14
- if (top.code !== 0) {
15
- err("lintlane: not inside a git repository.\n");
16
- process.exit(1);
17
- }
18
- const cwd = top.stdout.toString().trim();
19
- process.chdir(cwd);
20
-
21
- if (process.argv.includes("--restore")) {
22
- const { restored, kept, found, backupDir } = await restoreFromBackup(cwd);
23
- if (!found) out("lintlane: nothing to restore.\n");
24
- for (const path of restored) out(`restored ${path}\n`);
25
- for (const path of kept) err(`kept ${path}: it changed since the backup (see ${backupDir})\n`);
26
- process.exit(kept.length > 0 ? 1 : 0);
27
- }
28
-
29
- const biome = resolveBiome(cwd);
30
- if (!biome) {
31
- err("lintlane: Biome isn't installed. Run your package manager's install, then commit again.\n");
32
- process.exit(1);
33
- }
34
-
35
- try {
36
- const result = await runStagedChecks({
37
- cwd,
38
- biome,
39
- colors: process.stderr.isTTY ? "force" : "off",
40
- log: { out, err },
41
- });
42
- for (const warning of result.warnings) err(`lintlane: ${warning}\n`);
43
- process.exit(result.ok ? 0 : 1);
44
- } catch (error) {
45
- if (error instanceof StaleBackupError) {
46
- err(
47
- `\nlintlane: an earlier run was interrupted before it could restore your files.\n` +
48
- ` Your working copies are saved in ${error.backupDir}\n` +
49
- ` Put them back with: node node_modules/lintlane/bin/pre-commit.mjs --restore\n\n`,
50
- );
51
- } else {
52
- err(`lintlane: ${error.message}\n`);
53
- }
54
- process.exit(1);
55
- }
@@ -1,48 +0,0 @@
1
- // git `pre-push` hook entry: checks every commit that's about to be pushed.
2
- // This is the safety net for commits that skipped the commit-msg hook
3
- // (`git commit --no-verify`, a GUI client, a clone where hooks weren't installed).
4
- // Plain Node on purpose — see commit-lint.mjs.
5
- import { readFileSync } from "node:fs";
6
- import { describeInvalid, findInvalidCommits, listCommits } from "./check-commit.mjs";
7
- import { loadCommitConfig } from "./commit-config.mjs";
8
-
9
- const [remote = "origin"] = process.argv.slice(2);
10
- const ZERO = /^0+$/;
11
-
12
- let input = "";
13
- try {
14
- input = readFileSync(0, "utf8");
15
- } catch {}
16
-
17
- const options = loadCommitConfig();
18
- const invalid = [];
19
- const seen = new Set();
20
-
21
- for (const line of input.split("\n")) {
22
- const [localRef, localSha, , remoteSha] = line.trim().split(/\s+/);
23
- if (!localRef || !localSha || ZERO.test(localSha)) continue; // deleting a branch
24
- if (localRef.startsWith("refs/tags/")) continue;
25
-
26
- // New branch: everything not already on a remote. Existing: just the new commits.
27
- const logArgs = ZERO.test(remoteSha ?? "0")
28
- ? [localSha, "--not", `--remotes=${remote}`]
29
- : [`${remoteSha}..${localSha}`];
30
- try {
31
- for (const bad of findInvalidCommits(listCommits(logArgs), options)) {
32
- if (!seen.has(bad.hash)) {
33
- seen.add(bad.hash);
34
- invalid.push(bad);
35
- }
36
- }
37
- } catch {
38
- // Can't inspect the range (e.g. the remote's commit isn't local). Don't block the push.
39
- }
40
- }
41
-
42
- if (invalid.length > 0) {
43
- process.stderr.write(
44
- `\n✘ Push blocked: ${invalid.length} commit message(s) don't follow Conventional Commits:\n\n${describeInvalid(invalid)}\n\n` +
45
- " Reword them with `git rebase -i` (or `git commit --amend` for the latest), then push again.\n\n",
46
- );
47
- process.exit(1);
48
- }
@@ -1,46 +0,0 @@
1
- // git `prepare-commit-msg` hook entry: when you run a plain `git commit` (no -m,
2
- // no template, not a merge), walk through the guided prompt and pre-fill the
3
- // message — the same flow as `lintlane commit`.
4
- // Plain Node on purpose — see commit-lint.mjs.
5
- import { closeSync, openSync, readFileSync, writeFileSync } from "node:fs";
6
- import { ReadStream } from "node:tty";
7
- import { loadCommitConfig } from "./commit-config.mjs";
8
- import { promptCommit } from "./commit-prompt.mjs";
9
-
10
- const [file, source] = process.argv.slice(2);
11
-
12
- // source is set when the message came from -m/-F, a template, a merge, a
13
- // squash, or an amend. Only a plain `git commit` leaves it empty.
14
- if (!file || source || process.env.CI || process.env.LINTLANE_NO_PROMPT) process.exit(0);
15
-
16
- // Git runs hooks with stdin detached; talk to the terminal directly. If there
17
- // isn't one (a GUI client, CI, a script), stay out of the way.
18
- let fd;
19
- try {
20
- fd = openSync("/dev/tty", "r");
21
- } catch {
22
- process.exit(0);
23
- }
24
-
25
- try {
26
- const message = await promptCommit({
27
- input: new ReadStream(fd),
28
- output: process.stderr,
29
- options: loadCommitConfig(),
30
- });
31
- if (message === null) {
32
- process.stderr.write("\nCommit cancelled.\n");
33
- process.exit(1);
34
- }
35
- const existing = readFileSync(file, "utf8");
36
- writeFileSync(file, `${message}\n${existing}`);
37
- // The open terminal handle would otherwise keep this process (and git) waiting.
38
- process.exit(0);
39
- } catch {
40
- process.stderr.write("\nCommit cancelled.\n");
41
- process.exit(1);
42
- } finally {
43
- try {
44
- closeSync(fd);
45
- } catch {}
46
- }
package/bin/staged.mjs DELETED
@@ -1,333 +0,0 @@
1
- // Run Biome on the files staged for commit without ever touching anything the
2
- // developer didn't stage.
3
- //
4
- // The old hook ran `biome check --write --staged` and then
5
- // `git update-index --again`. Biome rewrites files in the working tree (unstaged
6
- // hunks included) and `--again` re-stages whatever is in the working tree, so
7
- // unstaged hunks of partially staged files silently joined the commit.
8
- //
9
- // This module instead:
10
- // 1. reads the exact staged content (the index blob) of every staged file;
11
- // 2. for files that also have unstaged changes, saves the developer's working
12
- // copy to a backup and temporarily makes the working file equal the staged
13
- // content, so Biome sees exactly what will be committed;
14
- // 3. runs Biome on the real files (same ignore rules, config and exit codes
15
- // as before);
16
- // 4. on any failure, puts every file back byte for byte; the index is never
17
- // touched;
18
- // 5. on success, writes the fixed content into the index entry of each staged
19
- // path (git hash-object + git update-index --index-info; never `git add`),
20
- // and merges Biome's changes back into the developer's working copy with a
21
- // three-way merge, so their unstaged hunks survive. If the merge would
22
- // conflict, the working copy is left exactly as the developer wrote it.
23
- //
24
- // Plain Node (no Bun APIs, no dependencies): this runs on every teammate's machine.
25
- import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
26
- import { mkdtemp, rm, writeFile } from "node:fs/promises";
27
- import { tmpdir } from "node:os";
28
- import { join, resolve } from "node:path";
29
- import { runProcess } from "./proc.mjs";
30
-
31
- const SKIPPED_MODES = new Set(["120000", "160000"]); // symlinks, submodules
32
- const BIOME_CHUNK = 100;
33
- const CONCURRENCY = 8;
34
-
35
- export class StaleBackupError extends Error {
36
- constructor(backupDir) {
37
- super(`An earlier lintlane run was interrupted. Your files are saved in ${backupDir}`);
38
- this.name = "StaleBackupError";
39
- this.backupDir = backupDir;
40
- }
41
- }
42
-
43
- async function git(args, { cwd, input } = {}) {
44
- const result = await runProcess("git", args, { cwd, input });
45
- if (result.code !== 0) {
46
- const detail =
47
- result.stderr.toString().trim() || result.error?.message || `exit ${result.code}`;
48
- throw new Error(`git ${args[0]} failed: ${detail}`);
49
- }
50
- return result.stdout;
51
- }
52
-
53
- async function mapLimit(items, limit, fn) {
54
- const results = new Array(items.length);
55
- let next = 0;
56
- const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
57
- for (let i = next++; i < items.length; i = next++) results[i] = await fn(items[i], i);
58
- });
59
- await Promise.all(workers);
60
- return results;
61
- }
62
-
63
- /**
64
- * Staged files that are added, copied or modified (not deleted, not symlinks or
65
- * submodules). Renames are reported as a delete plus an add, so the new path is
66
- * included and the old one is not.
67
- */
68
- export async function listStagedFiles(cwd) {
69
- const out = await git(
70
- ["diff", "--cached", "--raw", "-z", "--no-renames", "--no-abbrev", "--diff-filter=ACM"],
71
- { cwd },
72
- );
73
- const parts = out.toString("utf8").split("\0");
74
- const files = [];
75
- for (let i = 0; i + 1 < parts.length; i += 2) {
76
- const [, mode, , sha] = parts[i].slice(1).split(" ");
77
- const path = parts[i + 1];
78
- if (!SKIPPED_MODES.has(mode)) files.push({ path, mode, sha });
79
- }
80
- return files;
81
- }
82
-
83
- async function backupDirFor(cwd) {
84
- const rel = (await git(["rev-parse", "--git-path", "lintlane-backup"], { cwd }))
85
- .toString()
86
- .trim();
87
- return resolve(cwd, rel);
88
- }
89
-
90
- /** Three-way merge of Biome's changes into the developer's working copy. */
91
- async function mergeThreeWay({ base, ours, theirs }) {
92
- const dir = await mkdtemp(join(tmpdir(), "lintlane-merge-"));
93
- try {
94
- await Promise.all([
95
- writeFile(join(dir, "ours"), ours),
96
- writeFile(join(dir, "base"), base),
97
- writeFile(join(dir, "theirs"), theirs),
98
- ]);
99
- const result = await runProcess(
100
- "git",
101
- ["merge-file", "-p", "-L", "ours", "-L", "base", "-L", "theirs", "ours", "base", "theirs"],
102
- { cwd: dir },
103
- );
104
- // Exit code = number of conflicts (0 = clean); anything else is an error.
105
- return result.code === 0
106
- ? { merged: result.stdout, conflict: false }
107
- : { merged: null, conflict: true };
108
- } finally {
109
- await rm(dir, { recursive: true, force: true });
110
- }
111
- }
112
-
113
- function readIfExists(path) {
114
- try {
115
- return readFileSync(path);
116
- } catch {
117
- return null;
118
- }
119
- }
120
-
121
- /**
122
- * Put files back the way they were before the run. A file is only overwritten
123
- * if it still holds what *we* left in it (the staged content or Biome's output);
124
- * if the developer saved something else in the meantime, it is left alone and
125
- * reported, and the backup is kept.
126
- */
127
- function restoreOriginals(cwd, entries) {
128
- const kept = [];
129
- for (const entry of entries) {
130
- const original = entry.kind === "partial" ? entry.working : entry.blob;
131
- const abs = join(cwd, entry.path);
132
- const current = readIfExists(abs);
133
- if (current?.equals(original)) continue;
134
- const ours = [entry.blob, entry.after].filter(Boolean);
135
- if (current === null || ours.some((buffer) => buffer.equals(current))) {
136
- writeFileSync(abs, original);
137
- } else {
138
- kept.push(entry.path);
139
- }
140
- }
141
- return kept;
142
- }
143
-
144
- /**
145
- * Check and fix the staged files. Resolves with { ok, changed, skipped, warnings }.
146
- * `biome` is an argv prefix (see resolveBiome). `log` receives user-facing text.
147
- */
148
- export async function runStagedChecks({ cwd, biome, log = {}, colors = "off" }) {
149
- const say = log.out ?? (() => {});
150
- const complain = log.err ?? (() => {});
151
- const result = { ok: true, changed: [], skipped: [], warnings: [] };
152
-
153
- const staged = await listStagedFiles(cwd);
154
- if (staged.length === 0) return result;
155
-
156
- const backupDir = await backupDirFor(cwd);
157
- if (existsSync(backupDir) && readdirSync(backupDir).length > 0) {
158
- throw new StaleBackupError(backupDir);
159
- }
160
-
161
- // 1. Snapshot the exact staged content and the working copy of every file.
162
- const entries = [];
163
- await mapLimit(staged, CONCURRENCY, async (file) => {
164
- const blob = await git(["cat-file", "blob", file.sha], { cwd });
165
- const working = readIfExists(join(cwd, file.path));
166
- if (working === null) {
167
- result.skipped.push(file.path); // deleted in the working tree: leave the index alone
168
- return;
169
- }
170
- const kind = working.equals(blob) ? "clean" : "partial";
171
- entries.push({ ...file, blob, working, kind, after: null });
172
- });
173
- entries.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
174
- if (entries.length === 0) return result;
175
-
176
- // 2. Make the working copy of partially staged files equal the staged content,
177
- // after saving the developer's version somewhere durable.
178
- const partial = entries.filter((entry) => entry.kind === "partial");
179
- if (partial.length > 0) {
180
- mkdirSync(backupDir, { recursive: true });
181
- writeFileSync(
182
- join(backupDir, "manifest.json"),
183
- JSON.stringify(
184
- {
185
- version: 1,
186
- createdAt: new Date().toISOString(),
187
- files: partial.map((entry, i) => ({ id: i, path: entry.path, blob: entry.sha })),
188
- },
189
- null,
190
- 2,
191
- ),
192
- );
193
- for (const [i, entry] of partial.entries()) {
194
- writeFileSync(join(backupDir, String(i)), entry.working);
195
- }
196
- }
197
-
198
- let finished = false;
199
- const onSignal = (signal) => {
200
- if (finished) return;
201
- restoreOriginals(cwd, entries);
202
- rmSync(backupDir, { recursive: true, force: true });
203
- process.exit(signal === "SIGINT" ? 130 : 143);
204
- };
205
- const signals = ["SIGINT", "SIGTERM", "SIGHUP"];
206
- for (const signal of signals) process.on(signal, onSignal);
207
-
208
- try {
209
- for (const entry of partial) writeFileSync(join(cwd, entry.path), entry.blob);
210
-
211
- // 3. Run Biome on the real files, exactly as the old hook did.
212
- const paths = entries.map((entry) =>
213
- entry.path.startsWith("-") ? `./${entry.path}` : entry.path,
214
- );
215
- let biomeOk = true;
216
- for (let i = 0; i < paths.length; i += BIOME_CHUNK) {
217
- const run = await runProcess(
218
- biome[0],
219
- [
220
- ...biome.slice(1),
221
- "check",
222
- "--write",
223
- "--no-errors-on-unmatched",
224
- "--files-ignore-unknown=true",
225
- `--colors=${colors}`,
226
- ...paths.slice(i, i + BIOME_CHUNK),
227
- ],
228
- { cwd },
229
- );
230
- if (run.error) {
231
- complain(`lintlane: couldn't run Biome: ${run.error.message}\n`);
232
- biomeOk = false;
233
- } else {
234
- if (run.stdout.length) say(run.stdout.toString());
235
- if (run.stderr.length) complain(run.stderr.toString());
236
- if (run.code !== 0) biomeOk = false;
237
- }
238
- }
239
- for (const entry of entries) entry.after = readIfExists(join(cwd, entry.path));
240
-
241
- // 4. Failure: put everything back. The index was never touched.
242
- if (!biomeOk) {
243
- const kept = restoreOriginals(cwd, entries);
244
- result.ok = false;
245
- result.warnings.push(
246
- ...kept.map((path) => `${path} changed while the hook ran; left as saved`),
247
- );
248
- if (kept.length === 0) rmSync(backupDir, { recursive: true, force: true });
249
- return result;
250
- }
251
-
252
- // 5. Success: record Biome's changes in the index, entry by entry...
253
- const changed = entries.filter((entry) => entry.after && !entry.after.equals(entry.blob));
254
- if (changed.length > 0) {
255
- const shas = await mapLimit(changed, CONCURRENCY, async (entry) =>
256
- (await git(["hash-object", "-w", "--no-filters", "--stdin"], { cwd, input: entry.after }))
257
- .toString()
258
- .trim(),
259
- );
260
- const records = changed
261
- .map((entry, i) => `${entry.mode} ${shas[i]} 0\t${entry.path}\0`)
262
- .join("");
263
- await git(["update-index", "-z", "--index-info"], { cwd, input: Buffer.from(records) });
264
- result.changed = changed.map((entry) => entry.path);
265
- }
266
-
267
- // ...and bring the developer's working copy of partially staged files up to
268
- // date without losing their unstaged edits.
269
- const kept = [];
270
- for (const entry of partial) {
271
- const abs = join(cwd, entry.path);
272
- const current = readIfExists(abs);
273
- if (current === null || !(entry.after ?? entry.blob).equals(current)) {
274
- kept.push(entry.path); // changed under us; never overwrite
275
- continue;
276
- }
277
- let next = entry.working;
278
- if (entry.after && !entry.after.equals(entry.blob)) {
279
- const { merged, conflict } = await mergeThreeWay({
280
- base: entry.blob,
281
- ours: entry.working,
282
- theirs: entry.after,
283
- });
284
- if (!conflict) next = merged;
285
- else
286
- result.warnings.push(
287
- `${entry.path}: your unstaged edits overlap the formatting changes; left as you wrote it`,
288
- );
289
- }
290
- writeFileSync(abs, next);
291
- }
292
- result.warnings.push(
293
- ...kept.map((path) => `${path} changed while the hook ran; left as saved`),
294
- );
295
- if (kept.length === 0) rmSync(backupDir, { recursive: true, force: true });
296
- return result;
297
- } catch (error) {
298
- restoreOriginals(cwd, entries);
299
- rmSync(backupDir, { recursive: true, force: true });
300
- throw error;
301
- } finally {
302
- finished = true;
303
- for (const signal of signals) process.removeListener(signal, onSignal);
304
- }
305
- }
306
-
307
- /**
308
- * Recovery for a run that was killed before it could clean up (SIGKILL, power
309
- * loss): put the saved working copies back, unless the file changed since.
310
- */
311
- export async function restoreFromBackup(cwd) {
312
- const backupDir = await backupDirFor(cwd);
313
- const manifestPath = join(backupDir, "manifest.json");
314
- if (!existsSync(manifestPath)) return { restored: [], kept: [], found: false };
315
- const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
316
- const restored = [];
317
- const kept = [];
318
- for (const file of manifest.files) {
319
- const abs = join(cwd, file.path);
320
- const saved = readFileSync(join(backupDir, String(file.id)));
321
- const blob = await git(["cat-file", "blob", file.blob], { cwd });
322
- const current = readIfExists(abs);
323
- // Still holding the staged content we swapped in (or already gone): safe to restore.
324
- if (current === null || current.equals(blob) || current.equals(saved)) {
325
- writeFileSync(abs, saved);
326
- restored.push(file.path);
327
- } else {
328
- kept.push(file.path);
329
- }
330
- }
331
- if (kept.length === 0) rmSync(backupDir, { recursive: true, force: true });
332
- return { restored, kept, found: true, backupDir };
333
- }