@yagni-app/code-staging 1.1.1-staging.1338.1 → 1.1.1-staging.1340.1

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/dist/cli.d.ts CHANGED
@@ -55,15 +55,47 @@ interface SessionWorktreeModule {
55
55
  /** Optional so a stale bundled extension (without the exit prompt support) degrades to the durable summary. */
56
56
  removeSessionWorktree?: (worktreePath: string, branch: string, deps: {
57
57
  repoCwd: string;
58
+ discardIgnored?: string[];
58
59
  }) => Promise<{
59
60
  removed: boolean;
60
61
  branchRemoved: boolean;
61
62
  note?: string;
62
63
  branchUnmerged?: boolean;
64
+ ignoredDiscarded?: string[];
65
+ }>;
66
+ /** Optional pre-prompt scan of ignored content, so the prompt can name what removal discards. */
67
+ inspectIgnoredContent?: (worktreePath: string, deps: {
68
+ repoCwd: string;
69
+ }) => Promise<{
70
+ disposable: string[];
71
+ keepworthy: string[];
72
+ error?: string;
63
73
  }>;
64
74
  }
65
- /** Seams `handleWorktreeExit` needs; the real one lives in the extension. */
75
+ /** Seams `handleWorktreeExit` needs; the real ones live in the extension. */
66
76
  type WorktreeRemover = NonNullable<SessionWorktreeModule["removeSessionWorktree"]>;
77
+ type WorktreeIgnoredInspector = NonNullable<SessionWorktreeModule["inspectIgnoredContent"]>;
78
+ /**
79
+ * Pure: quoted, bounded rendering of an ignored-path list for one output line.
80
+ * Deliberately mirrors the extension's own helper rather than importing it: the
81
+ * launcher must be able to render this even against a stale bundled extension.
82
+ */
83
+ export declare function previewIgnoredPaths(paths: string[]): string;
84
+ /**
85
+ * Pure: how the Remove option and its warning read given the pre-prompt scan.
86
+ * With nothing at risk (the normal case: dependencies, build output, and env
87
+ * files copied from the main checkout), Remove is unadorned and removal is a
88
+ * single keypress. With entries that exist only here, the option names the
89
+ * count and a note names them, so discarding is always a deliberate choice.
90
+ * "Entries", not "files": a trailing-slash entry is a whole directory.
91
+ */
92
+ export declare function removeOptionCopy(report: {
93
+ keepworthy: string[];
94
+ error?: string;
95
+ }): {
96
+ removeLabel?: string;
97
+ notes: string[];
98
+ };
67
99
  /** Minimal stream shapes for testability (process.stdin/stderr satisfy these). */
68
100
  interface WriteOnlyStream {
69
101
  write(chunk: string): unknown;
@@ -91,6 +123,7 @@ export declare function handleWorktreeExit(deps: {
91
123
  stderr: WriteOnlyStream;
92
124
  cwd: string;
93
125
  removeWorktree?: WorktreeRemover;
126
+ inspectIgnored?: WorktreeIgnoredInspector;
94
127
  prompt?: typeof promptKeepOrRemoveWorktree;
95
128
  }): Promise<void>;
96
129
  export declare const HELP_TEXT: string;
package/dist/cli.js CHANGED
@@ -504,9 +504,47 @@ async function runWorktreeLaunch(passthroughArgs, worktreeName, loadSessionWorkt
504
504
  stderr: process.stderr,
505
505
  cwd: process.cwd(),
506
506
  removeWorktree: sessionWorktree.removeSessionWorktree,
507
+ inspectIgnored: sessionWorktree.inspectIgnoredContent,
507
508
  });
508
509
  return exitCode;
509
510
  }
511
+ /** How many ignored paths a prompt note or summary line names before "+N more". */
512
+ const IGNORED_PREVIEW = 5;
513
+ /**
514
+ * Pure: quoted, bounded rendering of an ignored-path list for one output line.
515
+ * Deliberately mirrors the extension's own helper rather than importing it: the
516
+ * launcher must be able to render this even against a stale bundled extension.
517
+ */
518
+ export function previewIgnoredPaths(paths) {
519
+ const preview = paths.slice(0, IGNORED_PREVIEW).map((path) => JSON.stringify(path)).join(", ");
520
+ return paths.length > IGNORED_PREVIEW ? `${preview} (+${paths.length - IGNORED_PREVIEW} more)` : preview;
521
+ }
522
+ /**
523
+ * Pure: how the Remove option and its warning read given the pre-prompt scan.
524
+ * With nothing at risk (the normal case: dependencies, build output, and env
525
+ * files copied from the main checkout), Remove is unadorned and removal is a
526
+ * single keypress. With entries that exist only here, the option names the
527
+ * count and a note names them, so discarding is always a deliberate choice.
528
+ * "Entries", not "files": a trailing-slash entry is a whole directory.
529
+ */
530
+ export function removeOptionCopy(report) {
531
+ if (report.error !== undefined) {
532
+ // Never let "I could not look" render as the same clean prompt as "I looked
533
+ // and there is nothing to lose". Removal re-checks and refuses on its own.
534
+ return { notes: [`could not check what removal would discard: ${report.error.replace(/\s+/g, " ").trim()}`] };
535
+ }
536
+ const count = report.keepworthy.length;
537
+ if (count === 0)
538
+ return { notes: [] };
539
+ const noun = count === 1 ? "entry" : "entries";
540
+ return {
541
+ removeLabel: `Remove worktree and ${count} ignored ${noun}`,
542
+ notes: [
543
+ `${count} ignored ${noun} ${count === 1 ? "exists" : "exist"} only here and would be lost: ` +
544
+ previewIgnoredPaths(report.keepworthy),
545
+ ],
546
+ };
547
+ }
510
548
  /** Pure: the one-line worktree summary for the kept path. */
511
549
  export function worktreeKeptSummary(worktreePath, branch, existed) {
512
550
  return (`[yagni] worktree ${existed ? "resumed" : "created"}: ${worktreePath}\n` +
@@ -521,10 +559,25 @@ export function worktreeKeptSummary(worktreePath, branch, existed) {
521
559
  * worktree in place with the durable resume summary printed.
522
560
  */
523
561
  export async function handleWorktreeExit(deps) {
524
- const { worktreePath, branch, existed, outputFormat, remainingArgs, stdin, stderr, cwd, removeWorktree, } = deps;
562
+ const { worktreePath, branch, existed, outputFormat, remainingArgs, stdin, stderr, cwd, removeWorktree, inspectIgnored, } = deps;
525
563
  const prompt = deps.prompt ?? promptKeepOrRemoveWorktree;
526
564
  const canPrompt = canPromptWorktreeCleanup(outputFormat, remainingArgs, stdin.isTTY === true) &&
527
565
  removeWorktree !== undefined;
566
+ // Scan before asking, so the prompt itself can say what removal would discard.
567
+ // A scan that fails says so in the prompt; a missing one (stale extension)
568
+ // leaves the prompt unadorned. Either way the remover runs the same check
569
+ // again and refuses on its own if anything unique is in there.
570
+ let report = { keepworthy: [] };
571
+ if (canPrompt && inspectIgnored) {
572
+ try {
573
+ report = await inspectIgnored(worktreePath, { repoCwd: cwd });
574
+ }
575
+ catch (err) {
576
+ report = { keepworthy: [], error: err instanceof Error ? err.message : String(err) };
577
+ }
578
+ }
579
+ const keepworthy = report.error === undefined ? report.keepworthy : [];
580
+ const { removeLabel, notes } = removeOptionCopy(report);
528
581
  let choice;
529
582
  if (canPrompt) {
530
583
  try {
@@ -532,6 +585,8 @@ export async function handleWorktreeExit(deps) {
532
585
  input: stdin,
533
586
  output: stderr,
534
587
  label: `${worktreePath} — keep or remove?`,
588
+ ...(removeLabel ? { removeLabel } : {}),
589
+ ...(notes.length > 0 ? { notes } : {}),
535
590
  });
536
591
  }
537
592
  catch (err) {
@@ -548,7 +603,12 @@ export async function handleWorktreeExit(deps) {
548
603
  }
549
604
  let outcome;
550
605
  try {
551
- outcome = await removeWorktree(worktreePath, branch, { repoCwd: cwd });
606
+ // The user chose Remove while looking at `keepworthy`, so pass exactly that
607
+ // list as acknowledged; anything that appeared since still blocks.
608
+ outcome = await removeWorktree(worktreePath, branch, {
609
+ repoCwd: cwd,
610
+ ...(keepworthy.length > 0 ? { discardIgnored: keepworthy } : {}),
611
+ });
552
612
  }
553
613
  catch (err) {
554
614
  // The remover throwing (vs returning removed:false) still keeps the work;
@@ -565,8 +625,20 @@ export async function handleWorktreeExit(deps) {
565
625
  return;
566
626
  }
567
627
  stderr.write(`[yagni] worktree removed\n` +
628
+ ignoredDiscardedMessage(outcome.ignoredDiscarded ?? []) +
568
629
  branchKeptMessage(outcome, branch, worktreePath));
569
630
  }
631
+ /**
632
+ * The "and this went with it" line. Ignored content is deleted silently by
633
+ * `git worktree remove`, so removal says what it took: dependencies and build
634
+ * state are regenerable, but the user should still see the list, not guess.
635
+ */
636
+ function ignoredDiscardedMessage(discarded) {
637
+ if (discarded.length === 0)
638
+ return "";
639
+ const noun = discarded.length === 1 ? "entry" : "entries";
640
+ return `[yagni] discarded ${discarded.length} ignored ${noun}: ${previewIgnoredPaths(discarded)}\n`;
641
+ }
570
642
  /**
571
643
  * The branch-status tail of the removed summary. The extension's safe `-d`
572
644
  * (never `-D`) keeps an unmerged branch on disk when its worktree is removed,
@@ -189,7 +189,7 @@ export async function maybeOfferMiningBeat(ctx, opts) {
189
189
  return { offered: true, accepted: true, banked: 0 };
190
190
  const banked = typeof body.banked === "number" ? body.banked : 0;
191
191
  const noun = banked === 1 ? "decision" : "decisions";
192
- ctx.ui.notify(`Banked ${banked} ${noun} read from this repo's docs. Review them: ${opts.baseUrl}/knowledge/decisions`, "info");
192
+ ctx.ui.notify(`Banked ${banked} ${noun} read from this repo's docs. Review them: ${opts.baseUrl}/decisions`, "info");
193
193
  return { offered: true, accepted: true, banked };
194
194
  }
195
195
  catch {
@@ -25,6 +25,39 @@
25
25
  * (both gitignored in-repo). PR refs (`#N`, GitHub PR URLs) map to `pr-<N>` and
26
26
  * base on `FETCH_HEAD`.
27
27
  */
28
+ /** What an ignored-content scan found in a worktree that is up for removal. */
29
+ export interface IgnoredContentReport {
30
+ /** Safe to delete: regenerable build state, or a copy the main checkout still has. */
31
+ disposable: string[];
32
+ /** Exists ONLY here: deleting it loses something git cannot give back. */
33
+ keepworthy: string[];
34
+ /** Set when the scan itself failed; both lists are then empty. */
35
+ error?: string;
36
+ }
37
+ /**
38
+ * True when an ignored path is dependency/build state something regenerates.
39
+ *
40
+ * `git ls-files --directory` collapses a fully-ignored directory to
41
+ * `node_modules/`, but a partially-ignored tree yields deeper paths, so every
42
+ * DIRECTORY segment is checked (a file merely named `out` is not build output).
43
+ * Generic build names need `hasPackageManifest` to confirm a build tool owns
44
+ * the directory they sit in, the repo root included; without the seam every
45
+ * generic name answers false, which is the safe direction.
46
+ */
47
+ export declare function isRegenerableIgnoredPath(path: string, hasPackageManifest?: (relDir: string) => boolean): boolean;
48
+ /** Render an ignored-path list for a user-facing note: quoted and bounded. */
49
+ export declare function previewIgnoredPaths(paths: string[]): string;
50
+ /**
51
+ * Scan a worktree's ignored content and split it into what cleanup may delete
52
+ * on its own and what it must be told to discard first.
53
+ *
54
+ * Exported so the launcher can run it BEFORE the Keep/Remove prompt and name
55
+ * the at-risk files in the prompt itself, instead of refusing after the fact.
56
+ */
57
+ export declare function inspectIgnoredContent(worktreePath: string, deps: {
58
+ repoCwd: string;
59
+ gitImpl?: SessionGit;
60
+ }): Promise<IgnoredContentReport>;
28
61
  export interface RemoveSessionWorktreeResult {
29
62
  /** True when the worktree directory is gone from disk. */
30
63
  removed: boolean;
@@ -36,6 +69,9 @@ export interface RemoveSessionWorktreeResult {
36
69
  * case the recovery hint describes); false when it was kept for another
37
70
  * reason (checked out elsewhere, git error) — the note carries that. */
38
71
  branchUnmerged?: boolean;
72
+ /** Ignored entries (dependency/build state, copied env files) deleted with the
73
+ * worktree, so the caller can report what went with it. Empty when nothing was. */
74
+ ignoredDiscarded?: string[];
39
75
  }
40
76
  export interface SessionWorktreeResult {
41
77
  /** Absolute destination (under `<mainRepo>/.worktrees/<slug>`). */
@@ -83,13 +119,19 @@ export declare function createOrResume(name: string | undefined, deps: CreateOrR
83
119
  *
84
120
  * - Require the original branch: removing a clean detached HEAD would discard
85
121
  * its only reference and reflog, even without `--force`.
86
- * - Refuse ignored content, which Git's dirty check does not protect. This
87
- * includes dependency directories and local env files; review those manually.
122
+ * - Sort ignored content (which Git's dirty check does not cover) into what a
123
+ * bootstrap regenerates or the main checkout still holds a copy of, and what
124
+ * exists only here. The first kind goes with the worktree; the second kind
125
+ * blocks cleanup unless the caller passes it in `discardIgnored`, which is
126
+ * how the launcher forwards an explicit "and discard these" from the user.
88
127
  * - `git worktree remove` without `--force` refuses tracked changes and
89
128
  * non-ignored untracked files. `git branch -d` retains branches Git considers
90
129
  * unmerged (against their upstream, or the main checkout's HEAD).
91
130
  *
92
131
  * These checks do not lock out concurrent edits by other sessions or Git clients.
132
+ * A keepworthy PATH that appears after the caller's own scan is unacknowledged
133
+ * and still blocks, but acknowledging a directory entry (`scratch/`) covers
134
+ * whatever it holds when the removal runs, not only what the scan saw in it.
93
135
  *
94
136
  * `repoCwd` is only used to locate the repo (resolved to the main repo root
95
137
  * before any git op, same as `createOrResume`): the user may have launched
@@ -99,5 +141,6 @@ export declare function createOrResume(name: string | undefined, deps: CreateOrR
99
141
  export declare function removeSessionWorktree(worktreePath: string, branch: string, deps: {
100
142
  repoCwd: string;
101
143
  gitImpl?: SessionGit;
144
+ discardIgnored?: string[];
102
145
  }): Promise<RemoveSessionWorktreeResult>;
103
146
  //# sourceMappingURL=sessionWorktree.d.ts.map
@@ -26,7 +26,7 @@
26
26
  * base on `FETCH_HEAD`.
27
27
  */
28
28
  import { execFile } from "node:child_process";
29
- import { existsSync, mkdirSync } from "node:fs";
29
+ import { existsSync, lstatSync, mkdirSync, readFileSync, statSync } from "node:fs";
30
30
  import { basename, dirname, isAbsolute, join } from "node:path";
31
31
  import { bootstrapWorktree } from "./worktree.js";
32
32
  /** Cap on the slug half (keeps refs & dirs readable). */
@@ -40,6 +40,158 @@ const GIT_NO_PROMPT_ENV = {
40
40
  GIT_TERMINAL_PROMPT: "0",
41
41
  GIT_ASKPASS: "",
42
42
  };
43
+ /**
44
+ * Directory names that mean "a tool put this here" wherever they appear. A
45
+ * bootstrapped worktree is FULL of these, so treating all ignored content as
46
+ * precious made "Remove worktree" refuse essentially every real worktree.
47
+ */
48
+ const TOOL_DIRS = new Set([
49
+ "node_modules",
50
+ ".pnpm-store",
51
+ ".yarn",
52
+ ".npm",
53
+ ".bun",
54
+ ".turbo",
55
+ ".next",
56
+ ".nuxt",
57
+ ".svelte-kit",
58
+ ".astro",
59
+ ".vite",
60
+ ".parcel-cache",
61
+ ".cache",
62
+ ".venv",
63
+ "venv",
64
+ "__pycache__",
65
+ ".pytest_cache",
66
+ ".mypy_cache",
67
+ ".ruff_cache",
68
+ ".gradle",
69
+ "playwright-report",
70
+ "test-results",
71
+ ]);
72
+ /**
73
+ * Build-output names generic enough to be someone's own folder ("research/out"),
74
+ * so they only count as regenerable NEXT TO A PACKAGE MANIFEST, the repo root
75
+ * included: that is what makes a build tool, rather than a person, the author.
76
+ * `tmp` is deliberately absent — a scratch directory is where people put things
77
+ * they would hate to lose quietly, whatever sits beside it.
78
+ */
79
+ const BUILD_DIRS = new Set(["dist", "build", "out", "coverage", "target"]);
80
+ /** Manifests that mark a directory as something a build tool writes output into. */
81
+ const PACKAGE_MANIFESTS = [
82
+ "package.json",
83
+ "Cargo.toml",
84
+ "go.mod",
85
+ "pyproject.toml",
86
+ "setup.py",
87
+ "build.gradle",
88
+ "build.gradle.kts",
89
+ "pom.xml",
90
+ ];
91
+ /** Ignored file names/extensions that are equally regenerable. */
92
+ const REGENERABLE_FILES = /(?:^|\/)(?:\.DS_Store|\.eslintcache|Thumbs\.db)$|\.(?:log|tsbuildinfo|pid)$/;
93
+ /** Above this, comparing a file against the main checkout is not worth the read. */
94
+ const MIRROR_MAX_BYTES = 4 * 1024 * 1024;
95
+ /** How many ignored entries a user-facing note names before it says "+N more". */
96
+ const IGNORED_PREVIEW = 5;
97
+ /**
98
+ * True when an ignored path is dependency/build state something regenerates.
99
+ *
100
+ * `git ls-files --directory` collapses a fully-ignored directory to
101
+ * `node_modules/`, but a partially-ignored tree yields deeper paths, so every
102
+ * DIRECTORY segment is checked (a file merely named `out` is not build output).
103
+ * Generic build names need `hasPackageManifest` to confirm a build tool owns
104
+ * the directory they sit in, the repo root included; without the seam every
105
+ * generic name answers false, which is the safe direction.
106
+ */
107
+ export function isRegenerableIgnoredPath(path, hasPackageManifest = () => false) {
108
+ const isDirEntry = path.endsWith("/");
109
+ const segments = path.split("/").filter(Boolean);
110
+ const dirSegments = isDirEntry ? segments : segments.slice(0, -1);
111
+ for (const [index, segment] of dirSegments.entries()) {
112
+ if (TOOL_DIRS.has(segment))
113
+ return true;
114
+ if (BUILD_DIRS.has(segment) && hasPackageManifest(dirSegments.slice(0, index).join("/")))
115
+ return true;
116
+ }
117
+ return !isDirEntry && REGENERABLE_FILES.test(path);
118
+ }
119
+ /**
120
+ * True when the main checkout still holds a byte-identical copy of this file.
121
+ * That is how `.env` and friends reach a worktree in the first place (copied in
122
+ * by `worktree-setup.sh` or the bootstrap), and deleting a copy loses nothing.
123
+ *
124
+ * The main-checkout side is `lstat`ed: a symlink there could point back INTO
125
+ * the worktree, which would make a file look like a copy of itself.
126
+ */
127
+ function isMirroredInMainRepo(repoRoot, worktreePath, rel) {
128
+ if (rel.endsWith("/"))
129
+ return false; // a directory, not a copied file
130
+ if (repoRoot === worktreePath)
131
+ return false; // nothing to mirror against but itself
132
+ try {
133
+ const here = statSync(join(worktreePath, rel));
134
+ const there = lstatSync(join(repoRoot, rel));
135
+ if (!here.isFile() || !there.isFile())
136
+ return false;
137
+ if (here.size !== there.size || here.size > MIRROR_MAX_BYTES)
138
+ return false;
139
+ return readFileSync(join(worktreePath, rel)).equals(readFileSync(join(repoRoot, rel)));
140
+ }
141
+ catch {
142
+ return false; // no counterpart in the main checkout: treat as unique
143
+ }
144
+ }
145
+ /** Render an ignored-path list for a user-facing note: quoted and bounded. */
146
+ export function previewIgnoredPaths(paths) {
147
+ const preview = paths.slice(0, IGNORED_PREVIEW).map((path) => JSON.stringify(path)).join(", ");
148
+ return paths.length > IGNORED_PREVIEW ? `${preview} (+${paths.length - IGNORED_PREVIEW} more)` : preview;
149
+ }
150
+ /**
151
+ * Scan a worktree's ignored content and split it into what cleanup may delete
152
+ * on its own and what it must be told to discard first.
153
+ *
154
+ * Exported so the launcher can run it BEFORE the Keep/Remove prompt and name
155
+ * the at-risk files in the prompt itself, instead of refusing after the fact.
156
+ */
157
+ export async function inspectIgnoredContent(worktreePath, deps) {
158
+ const gitImpl = deps.gitImpl ?? defaultGit;
159
+ try {
160
+ const repoRoot = await resolveMainRepo(gitImpl, deps.repoCwd);
161
+ return await scanIgnoredContent(gitImpl, worktreePath, repoRoot);
162
+ }
163
+ catch (err) {
164
+ return { disposable: [], keepworthy: [], error: err instanceof Error ? err.message : String(err) };
165
+ }
166
+ }
167
+ /** The shared scan behind {@link inspectIgnoredContent} and the removal guard. */
168
+ async function scanIgnoredContent(gitImpl, worktreePath, repoRoot) {
169
+ // `--no-empty-directory`: an empty ignored dir holds nothing to lose, and
170
+ // reporting one as at-risk content is exactly the false alarm this fixes.
171
+ const ignored = await gitImpl(["ls-files", "--others", "--ignored", "--exclude-standard", "--directory", "--no-empty-directory", "-z"], worktreePath);
172
+ const manifestCache = new Map();
173
+ const hasPackageManifest = (relDir) => {
174
+ const cached = manifestCache.get(relDir);
175
+ if (cached !== undefined)
176
+ return cached;
177
+ const dir = relDir === "" ? worktreePath : join(worktreePath, relDir);
178
+ const found = PACKAGE_MANIFESTS.some((manifest) => existsSync(join(dir, manifest)));
179
+ manifestCache.set(relDir, found);
180
+ return found;
181
+ };
182
+ const disposable = [];
183
+ const keepworthy = [];
184
+ for (const path of ignored.split("\0").filter(Boolean)) {
185
+ if (isRegenerableIgnoredPath(path, hasPackageManifest) ||
186
+ isMirroredInMainRepo(repoRoot, worktreePath, path)) {
187
+ disposable.push(path);
188
+ }
189
+ else {
190
+ keepworthy.push(path);
191
+ }
192
+ }
193
+ return { disposable, keepworthy };
194
+ }
43
195
  /** Turn arbitrary name text into a git-ref-safe slug. Empty input → "worktree". */
44
196
  export function slugify(name) {
45
197
  const slug = name
@@ -232,13 +384,19 @@ export async function createOrResume(name, deps) {
232
384
  *
233
385
  * - Require the original branch: removing a clean detached HEAD would discard
234
386
  * its only reference and reflog, even without `--force`.
235
- * - Refuse ignored content, which Git's dirty check does not protect. This
236
- * includes dependency directories and local env files; review those manually.
387
+ * - Sort ignored content (which Git's dirty check does not cover) into what a
388
+ * bootstrap regenerates or the main checkout still holds a copy of, and what
389
+ * exists only here. The first kind goes with the worktree; the second kind
390
+ * blocks cleanup unless the caller passes it in `discardIgnored`, which is
391
+ * how the launcher forwards an explicit "and discard these" from the user.
237
392
  * - `git worktree remove` without `--force` refuses tracked changes and
238
393
  * non-ignored untracked files. `git branch -d` retains branches Git considers
239
394
  * unmerged (against their upstream, or the main checkout's HEAD).
240
395
  *
241
396
  * These checks do not lock out concurrent edits by other sessions or Git clients.
397
+ * A keepworthy PATH that appears after the caller's own scan is unacknowledged
398
+ * and still blocks, but acknowledging a directory entry (`scratch/`) covers
399
+ * whatever it holds when the removal runs, not only what the scan saw in it.
242
400
  *
243
401
  * `repoCwd` is only used to locate the repo (resolved to the main repo root
244
402
  * before any git op, same as `createOrResume`): the user may have launched
@@ -250,18 +408,22 @@ export async function removeSessionWorktree(worktreePath, branch, deps) {
250
408
  // Resolve the MAIN repo root first: every later git op must run from a cwd
251
409
  // that survives the removal (repoCwd may be the worktree being removed).
252
410
  const repoRoot = await resolveMainRepo(gitImpl, deps.repoCwd);
411
+ let ignoredDiscarded;
253
412
  try {
254
- const ignored = await gitImpl(["ls-files", "--others", "--ignored", "--exclude-standard", "--directory", "-z"], worktreePath);
255
- if (ignored) {
256
- const paths = ignored.split("\0").filter(Boolean);
257
- const preview = paths.slice(0, 5).map((path) => JSON.stringify(path)).join(", ");
258
- const more = paths.length > 5 ? ` (+${paths.length - 5} more)` : "";
413
+ const ignored = await scanIgnoredContent(gitImpl, worktreePath, repoRoot);
414
+ const acknowledged = new Set(deps.discardIgnored ?? []);
415
+ const unacknowledged = ignored.keepworthy.filter((path) => !acknowledged.has(path));
416
+ if (unacknowledged.length > 0) {
259
417
  return {
260
418
  removed: false,
261
419
  branchRemoved: false,
262
- note: `worktree contains ignored content: ${preview}${more}; review and remove it manually before retrying cleanup`,
420
+ note: `worktree contains ignored files the main checkout does not have: ${previewIgnoredPaths(unacknowledged)}; ` +
421
+ `move them out, or confirm discarding them, before retrying cleanup`,
263
422
  };
264
423
  }
424
+ // Keepworthy first: the bounded preview must spend its slots on the entries
425
+ // that mattered, not on the dozen node_modules trees that always lead.
426
+ ignoredDiscarded = [...ignored.keepworthy, ...ignored.disposable];
265
427
  const headRef = await gitImpl(["rev-parse", "--symbolic-full-name", "HEAD"], worktreePath);
266
428
  if (headRef !== `refs/heads/${branch}`) {
267
429
  return {
@@ -285,7 +447,7 @@ export async function removeSessionWorktree(worktreePath, branch, deps) {
285
447
  // recoverable on the branch — the directory is gone but the ref is not.
286
448
  try {
287
449
  await gitImpl(["branch", "-d", branch], repoRoot);
288
- return { removed: true, branchRemoved: true };
450
+ return { removed: true, branchRemoved: true, ignoredDiscarded };
289
451
  }
290
452
  catch (err) {
291
453
  // Distinguish the benign case (unmerged branch, deliberately kept) from
@@ -296,6 +458,7 @@ export async function removeSessionWorktree(worktreePath, branch, deps) {
296
458
  removed: true,
297
459
  branchRemoved: false,
298
460
  branchUnmerged: unmerged,
461
+ ignoredDiscarded,
299
462
  ...(unmerged ? {} : { note: message }),
300
463
  };
301
464
  }
@@ -3,12 +3,18 @@
3
3
  *
4
4
  * Claude Code parity: a two-option list navigated with the up/down arrows and
5
5
  * confirmed with Enter — not a y/N question. Keep is the default (highlighted
6
- * first), and every escape hatch — Esc, q, Ctrl+C, EOF, a non-TTY stream —
7
- * fails safe to Keep (resolves `undefined`, which callers treat as keep).
6
+ * first), navigation is directional so no single keystroke can move onto the
7
+ * destructive option unless it means "down", and every escape hatch — Esc, q,
8
+ * Ctrl+C, EOF, a non-TTY stream — fails safe to Keep (resolves `undefined`,
9
+ * which callers treat as keep).
8
10
  *
9
11
  * This module owns the raw-TTY interaction only. The gating (which launches
10
12
  * may prompt at all) lives in `canPromptWorktreeCleanup` (worktreeArgs.ts),
11
13
  * and the git behavior lives in the extension's `removeSessionWorktree`.
14
+ *
15
+ * The caller may rename the Remove option and print notes above the list (the
16
+ * launcher uses both to name ignored files that removal would discard). Notes
17
+ * survive the choice: only the two option lines are erased.
12
18
  */
13
19
  export type WorktreeExitChoice = "keep" | "remove";
14
20
  /** Minimal stream shape the prompt needs (satisfied by process.stdin/stderr). */
@@ -22,6 +28,9 @@ export interface ExitPromptKey {
22
28
  ctrl?: boolean;
23
29
  }
24
30
  export type ExitPromptAction = {
31
+ type: "select";
32
+ index: 0 | 1;
33
+ } | {
25
34
  type: "toggle";
26
35
  } | {
27
36
  type: "confirm";
@@ -31,10 +40,12 @@ export type ExitPromptAction = {
31
40
  type: "noop";
32
41
  };
33
42
  /**
34
- * Map a keypress to its prompt action (pure, unit-tested). Arrows (plus
35
- * k/j/tab) toggle between the two options; Enter or Space confirms; Esc, q,
36
- * Ctrl+C, and Ctrl+D abort (fail-safe to Keep); anything else is ignored so
37
- * stray keys can neither confirm nor abort by accident.
43
+ * Map a keypress to its prompt action (pure, unit-tested). Arrows (and k/j)
44
+ * are DIRECTIONAL, never a toggle: up always lands on Keep, down always on
45
+ * Remove. That matters because the toggle made typing "keep" (k, then space)
46
+ * select Remove and confirm it in two keystrokes. Tab still cycles; Enter or
47
+ * Space confirms; Esc, q, Ctrl+C, and Ctrl+D abort (fail-safe to Keep);
48
+ * anything else is ignored so stray keys can neither confirm nor abort.
38
49
  */
39
50
  export declare function exitPromptAction(key: ExitPromptKey): ExitPromptAction;
40
51
  /**
@@ -50,5 +61,9 @@ export declare function promptKeepOrRemoveWorktree(opts: {
50
61
  };
51
62
  output: ExitPromptStream;
52
63
  label: string;
64
+ /** Overrides the Remove option text so it can name what else goes with it. */
65
+ removeLabel?: string;
66
+ /** Lines printed under the header, before the options (e.g. what removal discards). */
67
+ notes?: string[];
53
68
  }): Promise<WorktreeExitChoice | undefined>;
54
69
  //# sourceMappingURL=worktreeExitPrompt.d.ts.map
@@ -3,30 +3,40 @@
3
3
  *
4
4
  * Claude Code parity: a two-option list navigated with the up/down arrows and
5
5
  * confirmed with Enter — not a y/N question. Keep is the default (highlighted
6
- * first), and every escape hatch — Esc, q, Ctrl+C, EOF, a non-TTY stream —
7
- * fails safe to Keep (resolves `undefined`, which callers treat as keep).
6
+ * first), navigation is directional so no single keystroke can move onto the
7
+ * destructive option unless it means "down", and every escape hatch — Esc, q,
8
+ * Ctrl+C, EOF, a non-TTY stream — fails safe to Keep (resolves `undefined`,
9
+ * which callers treat as keep).
8
10
  *
9
11
  * This module owns the raw-TTY interaction only. The gating (which launches
10
12
  * may prompt at all) lives in `canPromptWorktreeCleanup` (worktreeArgs.ts),
11
13
  * and the git behavior lives in the extension's `removeSessionWorktree`.
14
+ *
15
+ * The caller may rename the Remove option and print notes above the list (the
16
+ * launcher uses both to name ignored files that removal would discard). Notes
17
+ * survive the choice: only the two option lines are erased.
12
18
  */
13
19
  import { emitKeypressEvents } from "node:readline";
14
20
  const OPTION_KEEP = "Keep worktree";
15
21
  const OPTION_REMOVE = "Remove worktree";
16
22
  /**
17
- * Map a keypress to its prompt action (pure, unit-tested). Arrows (plus
18
- * k/j/tab) toggle between the two options; Enter or Space confirms; Esc, q,
19
- * Ctrl+C, and Ctrl+D abort (fail-safe to Keep); anything else is ignored so
20
- * stray keys can neither confirm nor abort by accident.
23
+ * Map a keypress to its prompt action (pure, unit-tested). Arrows (and k/j)
24
+ * are DIRECTIONAL, never a toggle: up always lands on Keep, down always on
25
+ * Remove. That matters because the toggle made typing "keep" (k, then space)
26
+ * select Remove and confirm it in two keystrokes. Tab still cycles; Enter or
27
+ * Space confirms; Esc, q, Ctrl+C, and Ctrl+D abort (fail-safe to Keep);
28
+ * anything else is ignored so stray keys can neither confirm nor abort.
21
29
  */
22
30
  export function exitPromptAction(key) {
23
31
  if (key.ctrl && (key.name === "c" || key.name === "d"))
24
32
  return { type: "abort" };
25
33
  switch (key.name) {
26
34
  case "up":
27
- case "down":
28
35
  case "k":
36
+ return { type: "select", index: 0 };
37
+ case "down":
29
38
  case "j":
39
+ return { type: "select", index: 1 };
30
40
  case "tab":
31
41
  return { type: "toggle" };
32
42
  case "return":
@@ -40,10 +50,10 @@ export function exitPromptAction(key) {
40
50
  return { type: "noop" };
41
51
  }
42
52
  }
43
- function renderOptions(output, index) {
53
+ function renderOptions(output, index, removeLabel) {
44
54
  const marker = (i) => (i === index ? "❯ " : " ");
45
55
  output.write(`\r\x1b[2K${marker(0)}${OPTION_KEEP}\n`);
46
- output.write(`\r\x1b[2K${marker(1)}${OPTION_REMOVE}\n`);
56
+ output.write(`\r\x1b[2K${marker(1)}${removeLabel}\n`);
47
57
  }
48
58
  /** Erase the two option lines, leaving the cursor just below where they were. */
49
59
  function eraseOptions(output) {
@@ -58,12 +68,15 @@ function eraseOptions(output) {
58
68
  */
59
69
  export async function promptKeepOrRemoveWorktree(opts) {
60
70
  const { input, output, label } = opts;
71
+ const removeLabel = opts.removeLabel ?? OPTION_REMOVE;
61
72
  // Fail safe when the terminal cannot render an interactive list.
62
73
  if (input.isTTY !== true || output.isTTY !== true)
63
74
  return undefined;
64
75
  let index = 0; // Keep is the default
65
76
  output.write(`\n[yagni] ${label}\n`);
66
- renderOptions(output, index);
77
+ for (const note of opts.notes ?? [])
78
+ output.write(`[yagni] ${note}\n`);
79
+ renderOptions(output, index, removeLabel);
67
80
  const canRawMode = typeof input.setRawMode === "function";
68
81
  const wasRaw = canRawMode ? input.isRaw === true : false;
69
82
  if (canRawMode)
@@ -80,10 +93,13 @@ export async function promptKeepOrRemoveWorktree(opts) {
80
93
  const action = exitPromptAction(key ?? {});
81
94
  if (action.type === "noop")
82
95
  return;
83
- if (action.type === "toggle") {
84
- index = index === 0 ? 1 : 0;
96
+ if (action.type === "select" || action.type === "toggle") {
97
+ const next = action.type === "select" ? action.index : index === 0 ? 1 : 0;
98
+ if (next === index)
99
+ return; // already there: no flicker, no re-render
100
+ index = next;
85
101
  eraseOptions(output);
86
- renderOptions(output, index);
102
+ renderOptions(output, index, removeLabel);
87
103
  return;
88
104
  }
89
105
  cleanup();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yagni-app/code-staging",
3
- "version": "1.1.1-staging.1338.1",
3
+ "version": "1.1.1-staging.1340.1",
4
4
  "description": "YAGNI Code: a terminal coding agent that already knows your company. One YAGNI login routes the model and grounds the agent in your team's context.",
5
5
  "license": "SEE LICENSE IN LICENSE.md",
6
6
  "author": "YAGNI, Inc. <jack@yagni.app> (https://yagni.app)",
@@ -58,5 +58,5 @@
58
58
  "turndown": "^7.2.4",
59
59
  "typebox": "^1.3.15"
60
60
  },
61
- "yagniSourceSha": "da5698917cce69e1ffcf27803c8209bd41bf48a0"
61
+ "yagniSourceSha": "f7830683d5c8212894c4b2e53ed7f3d122701251"
62
62
  }