@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 +34 -1
- package/dist/cli.js +74 -2
- package/dist/extension/mineBeat.js +1 -1
- package/dist/extension/pipeline/sessionWorktree.d.ts +45 -2
- package/dist/extension/pipeline/sessionWorktree.js +173 -10
- package/dist/worktreeExitPrompt.d.ts +21 -6
- package/dist/worktreeExitPrompt.js +29 -13
- package/package.json +2 -2
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
|
|
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
|
-
|
|
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}/
|
|
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
|
-
* -
|
|
87
|
-
*
|
|
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
|
-
* -
|
|
236
|
-
*
|
|
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
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
|
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),
|
|
7
|
-
*
|
|
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 (
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
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),
|
|
7
|
-
*
|
|
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 (
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
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)}${
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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": "
|
|
61
|
+
"yagniSourceSha": "f7830683d5c8212894c4b2e53ed7f3d122701251"
|
|
62
62
|
}
|