staysfixed 0.6.2 → 0.7.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.
@@ -61,6 +61,89 @@ const alive = new Map();
61
61
  /** @param {number} ms */
62
62
  const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
63
63
 
64
+ // ---------------------------------------------------------------------------
65
+ // Saying out loud that something was opened
66
+ // ---------------------------------------------------------------------------
67
+
68
+ /**
69
+ * What this tool opened, and enough about it to be able to look after it.
70
+ *
71
+ * The screen is the reason this exists. A check that starts a desktop app has to be
72
+ * able to say, afterwards and to something outside this file: THAT application, with
73
+ * THAT unix id, is mine — I opened it, I will close it, and while it is up it does
74
+ * not get to keep taking the screen from whoever is using this machine. Nothing else
75
+ * on the machine can work that out for itself: a scratch copy of an app and the
76
+ * person's own copy of the same app are indistinguishable from the outside.
77
+ *
78
+ * @typedef {object} OpenedApp
79
+ * @property {string} name The application's name, as macOS reports it.
80
+ * @property {number} pid
81
+ * @property {string} binary What was actually run.
82
+ * @property {string} label Which build it is, in plain English.
83
+ */
84
+
85
+ /**
86
+ * Everybody who wants to be told. A Set rather than a single hook because a check, a
87
+ * panel and a test can all reasonably want to know at once.
88
+ * @type {Set<(app: OpenedApp) => void>}
89
+ */
90
+ const told = new Set();
91
+
92
+ /**
93
+ * Be told whenever this tool opens a desktop app.
94
+ *
95
+ * Called before anything is started, and the listener runs the moment the process
96
+ * exists — before the app has drawn a window, which is what makes it early enough to
97
+ * be useful. A listener that throws is ignored: nothing that merely wants to WATCH a
98
+ * run may break one.
99
+ *
100
+ * @param {(app: OpenedApp) => void} listener
101
+ * @returns {() => void} stop being told
102
+ */
103
+ export function onAppStarted(listener) {
104
+ told.add(listener);
105
+ return () => {
106
+ told.delete(listener);
107
+ };
108
+ }
109
+
110
+ /**
111
+ * @param {OpenedApp} app
112
+ * @returns {void}
113
+ */
114
+ function announce(app) {
115
+ for (const listener of [...told]) {
116
+ try {
117
+ listener(app);
118
+ } catch {
119
+ // Watching is never allowed to be the reason a run fails.
120
+ }
121
+ }
122
+ }
123
+
124
+ /**
125
+ * The application name macOS will use for this binary.
126
+ *
127
+ * A Mac app is run through the executable buried inside its bundle —
128
+ * `Foo.app/Contents/MacOS/Foo` — but everything that talks about windows and the
129
+ * foreground talks about "Foo". The bundle is what to read: the executable inside it
130
+ * is often called something else entirely, and for a development build it is called
131
+ * `Electron`.
132
+ *
133
+ * @param {string} binary
134
+ * @returns {string}
135
+ */
136
+ export function appNameFor(binary) {
137
+ const text = String(binary ?? '');
138
+ const parts = text.split(path.sep);
139
+ for (let i = parts.length - 1; i >= 0; i -= 1) {
140
+ if (parts[i].toLowerCase().endsWith('.app')) return parts[i].slice(0, -4);
141
+ }
142
+ const base = path.basename(text);
143
+ const dot = base.lastIndexOf('.');
144
+ return dot > 0 ? base.slice(0, dot) : base;
145
+ }
146
+
64
147
  // ---------------------------------------------------------------------------
65
148
  // The shapes
66
149
  // ---------------------------------------------------------------------------
@@ -516,6 +599,12 @@ export function startIsolated(isolation, opts) {
516
599
  // A quit later on must never take the tool down with an unhandled error event.
517
600
  child.on('error', () => {});
518
601
 
602
+ // Said out loud the moment the process exists, and before it has drawn anything.
603
+ // Whoever is looking after the screen during this check needs to know that this
604
+ // application belongs to the tool BEFORE it appears, or its first appearance is
605
+ // read as the person choosing it.
606
+ announce({ name: appNameFor(opts.binary), pid: child.pid ?? -1, binary: opts.binary, label: isolation.label });
607
+
519
608
  return {
520
609
  child,
521
610
  pid: child.pid ?? -1,
package/src/v2/cause.js CHANGED
@@ -58,6 +58,11 @@ const run = promisify(execFile);
58
58
  * @property {string} [why] Why it could not be tested, when it could not.
59
59
  * @property {ChangedHunk[]} [candidates] Hunks it could have tested, when it could not choose.
60
60
  * @property {string} [worktree] Where it ran, when `keep` was asked for.
61
+ * @property {string} [leftBehind] Set when the scratch checkout could not be removed. It says
62
+ * exactly what is still on disk and how to get rid of it,
63
+ * because a cleanup that fails in silence leaves a stale
64
+ * entry in the real repository's worktree list and the next
65
+ * `git status` there is frightening for no reason.
61
66
  */
62
67
 
63
68
  // EVERY difference in the finding is re-checked, and there is deliberately no ceiling.
@@ -99,6 +104,15 @@ export async function proveCause(finding, opts) {
99
104
 
100
105
  const changed = opts.changed ?? (await whatChanged(opts.cwd));
101
106
  if (!changed.ok) return cannot(changed.why ?? 'The working tree could not be read.', null);
107
+ // "git could not hand over the diff" and "there is no diff" both left `hunks` empty, and
108
+ // the sentence below was said about both. Telling somebody their tree is clean when it is
109
+ // not is worse than telling them nothing, because they act on it.
110
+ if (changed.patchUnread === true && changed.hunks.length === 0) {
111
+ return cannot(
112
+ `${changed.patchUnreadWhy ?? 'The working diff could not be read.'} That is not the same as nothing having changed — ${changed.files.length} tracked ${changed.files.length === 1 ? 'file has' : 'files have'} edits in ${changed.files.length === 1 ? 'it' : 'them'} — so no change could be undone and nothing here is proved either way.`,
113
+ null,
114
+ );
115
+ }
102
116
  if (changed.hunks.length === 0 && changed.untracked.length === 0) {
103
117
  return cannot('Nothing in the working tree has changed, so there is no change to undo.', null);
104
118
  }
@@ -133,6 +147,19 @@ export async function proveCause(finding, opts) {
133
147
  }
134
148
 
135
149
  let checkedOut = false;
150
+ // The answer is held rather than only returned, so the tidy-up below can write on it. A
151
+ // cleanup that fails after the verdict is decided must still reach whoever reads the
152
+ // verdict; `gitQuiet` hands back whether it worked and nothing used to look.
153
+ /** @type {{proof: CauseProof|null}} */
154
+ const held = { proof: null };
155
+ /**
156
+ * @param {CauseProof} p
157
+ * @returns {CauseProof}
158
+ */
159
+ const done = (p) => {
160
+ held.proof = p;
161
+ return p;
162
+ };
136
163
  try {
137
164
  await gitOrThrow(['worktree', 'add', '--detach', tree, 'HEAD'], changed.root);
138
165
  checkedOut = true;
@@ -148,9 +175,22 @@ export async function proveCause(finding, opts) {
148
175
  // by hand — all of them except the suspect, which is left out, because
149
176
  // leaving a file out IS undoing the change that added it.
150
177
  const suspectIsNew = changed.untracked.includes(hunk.file);
178
+ /** @type {string[]} */
179
+ const notCarried = [];
151
180
  for (const file of changed.untracked) {
152
181
  if (file === hunk.file) continue;
153
- await copyInto(changed.root, tree, file);
182
+ const copied = await copyInto(changed.root, tree, file);
183
+ if (!copied.ok) notCarried.push(`${file} (${copied.why})`);
184
+ }
185
+ // A new file that did not make it into the scratch checkout is a file the re-walk runs
186
+ // WITHOUT. The difference then disappears because the file is missing, not because the
187
+ // suspect change caused it — and the proof comes back "caused by that change", stamped,
188
+ // machine-checked and wrong. That is a verdict built on a silence, so there is no verdict.
189
+ if (notCarried.length > 0) {
190
+ return done(cannot(
191
+ `${notCarried.length} new ${notCarried.length === 1 ? 'file' : 'files'} could not be copied into the scratch checkout: ${notCarried.join('; ')}. Anything that went away in a copy missing those files would have gone away for the wrong reason, so nothing is claimed here.`,
192
+ hunk,
193
+ ));
154
194
  }
155
195
 
156
196
  // And now undo the one change under suspicion.
@@ -165,10 +205,10 @@ export async function proveCause(finding, opts) {
165
205
  undone = await gitQuiet(['apply', '--reverse', '--recount', '--whitespace=nowarn', suspectPatch], tree);
166
206
  }
167
207
  if (!undone.ok) {
168
- return cannot(
208
+ return done(cannot(
169
209
  `That change could not be undone on its own: ${undone.why}. It probably overlaps another change in the same place.`,
170
210
  hunk,
171
- );
211
+ ));
172
212
  }
173
213
  }
174
214
 
@@ -221,15 +261,83 @@ export async function proveCause(finding, opts) {
221
261
  };
222
262
  if (opts.keep === true) result.worktree = tree;
223
263
  if (events) events.emit({ type: 'proof:done', at: events.elapsed(), message: result.what });
224
- return result;
264
+ return done(result);
225
265
  } catch (e) {
226
- return cannot(messageOf(e), hunk);
266
+ return done(cannot(messageOf(e), hunk));
227
267
  } finally {
228
268
  // Even when it throws. A leftover worktree makes the next `git status`
229
- // confusing and the one after that frightening.
230
- if (checkedOut && opts.keep !== true) await gitQuiet(['worktree', 'remove', '--force', tree], changed.root);
231
- await gitQuiet(['worktree', 'prune'], changed.root);
232
- if (opts.keep !== true) await fsp.rm(base, { recursive: true, force: true }).catch(() => {});
269
+ // confusing and the one after that frightening — and until 2026-08-30, if the removal
270
+ // itself failed, it failed in complete silence and left one behind anyway.
271
+ const mess = await tidyUp({ tree, base, root: changed.root, checkedOut, keep: opts.keep === true });
272
+ if (mess) {
273
+ if (held.proof) held.proof.leftBehind = mess;
274
+ if (opts.events) opts.events.emit({ type: 'note', at: opts.events.elapsed(), message: mess });
275
+ }
276
+ }
277
+ }
278
+
279
+ /**
280
+ * Put the scratch checkout away, and say so plainly when it will not go.
281
+ *
282
+ * `git worktree remove` fails for ordinary reasons — a file still open, a folder the run
283
+ * itself is sitting in, a permission. When it does, the folder is deleted directly and the
284
+ * registration is pruned, which is the same outcome by another road. If even that does not
285
+ * work, the one thing left worth doing is saying exactly what is on disk and the one command
286
+ * that clears it, rather than leaving the person to find it themselves in a month.
287
+ *
288
+ * @param {{tree: string, base: string, root: string, checkedOut: boolean, keep: boolean}} where
289
+ * @returns {Promise<string>} empty when everything was cleared away
290
+ */
291
+ async function tidyUp(where) {
292
+ if (where.keep) return '';
293
+ /** @type {string[]} */
294
+ const problems = [];
295
+
296
+ if (where.checkedOut) {
297
+ const removed = await gitQuiet(['worktree', 'remove', '--force', where.tree], where.root);
298
+ if (!removed.ok && (await stillThere(where.tree))) {
299
+ if (!(await wipe(where.tree))) problems.push(`the scratch checkout is still at ${where.tree} (${removed.why})`);
300
+ }
301
+ }
302
+ const pruned = await gitQuiet(['worktree', 'prune'], where.root);
303
+ if (!pruned.ok) {
304
+ problems.push(`the stale worktree entry could not be pruned from ${where.root} (${pruned.why}), so \`git worktree list\` there may still name a folder that is gone`);
305
+ }
306
+ if (!(await wipe(where.base)) && (await stillThere(where.base))) {
307
+ problems.push(`the temporary folder ${where.base} could not be deleted`);
308
+ }
309
+
310
+ if (problems.length === 0) return '';
311
+ return (
312
+ `Proving the cause left something behind: ${problems.join('; ')}. ` +
313
+ `Nothing of yours was touched — all of it is inside a temporary folder — but it will not clear itself. ` +
314
+ `To remove it: git -C ${where.root} worktree prune && rm -rf ${where.base}`
315
+ );
316
+ }
317
+
318
+ /**
319
+ * @param {string} dir
320
+ * @returns {Promise<boolean>} true when it was removed or was never there
321
+ */
322
+ async function wipe(dir) {
323
+ try {
324
+ await fsp.rm(dir, { recursive: true, force: true });
325
+ return true;
326
+ } catch {
327
+ return false;
328
+ }
329
+ }
330
+
331
+ /**
332
+ * @param {string} dir
333
+ * @returns {Promise<boolean>}
334
+ */
335
+ async function stillThere(dir) {
336
+ try {
337
+ await fsp.stat(dir);
338
+ return true;
339
+ } catch {
340
+ return false;
233
341
  }
234
342
  }
235
343
 
@@ -317,21 +425,52 @@ function cannot(why, hunk) {
317
425
 
318
426
  /**
319
427
  * Copy one file from the real project into the scratch checkout, folders and all.
428
+ *
429
+ * A file that vanished between git listing it and this copying it really is nothing to fail
430
+ * a proof over — it is not in the working tree any more either, so the scratch checkout is
431
+ * right to be without it. Every OTHER failure is different in kind: the file IS there, the
432
+ * scratch copy does not have it, and the re-walk is therefore running a different product.
433
+ * Those two used to be the same empty catch.
434
+ *
320
435
  * @param {string} from
321
436
  * @param {string} to
322
437
  * @param {string} file repo-relative
438
+ * @returns {Promise<{ok: boolean, why: string}>}
323
439
  */
324
440
  async function copyInto(from, to, file) {
325
441
  const source = path.resolve(from, file);
326
442
  const target = path.resolve(to, file);
327
443
  // A path that climbs out of the scratch tree is never copied.
328
- if (!target.startsWith(path.resolve(to))) return;
444
+ if (!target.startsWith(path.resolve(to) + path.sep)) {
445
+ return { ok: false, why: 'its path points outside the scratch checkout' };
446
+ }
329
447
  try {
330
448
  await fsp.mkdir(path.dirname(target), { recursive: true });
331
449
  await fsp.copyFile(source, target);
450
+ return { ok: true, why: '' };
451
+ } catch (e) {
452
+ const code = /** @type {{code?: string}} */ (e).code;
453
+ if (code === 'ENOENT' && !(await exists(source))) return { ok: true, why: '' };
454
+ if (code === 'EISDIR') {
455
+ // git only lists a bare directory when it is asked for untracked directories rather
456
+ // than untracked files, which is not how this asks — but if it ever happens, a folder
457
+ // silently not copied is a whole subtree the re-walk does not have.
458
+ return { ok: false, why: 'it is a folder, not a file' };
459
+ }
460
+ return { ok: false, why: e instanceof Error ? e.message : String(e) };
461
+ }
462
+ }
463
+
464
+ /**
465
+ * @param {string} file
466
+ * @returns {Promise<boolean>}
467
+ */
468
+ async function exists(file) {
469
+ try {
470
+ await fsp.stat(file);
471
+ return true;
332
472
  } catch {
333
- // A file that vanished between listing it and copying it is not worth
334
- // failing a proof over.
473
+ return false;
335
474
  }
336
475
  }
337
476