@ree-auz/nook 0.1.0 → 0.1.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.
Files changed (42) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +422 -410
  3. package/bin/nk.mjs +3 -3
  4. package/docs/setup-hooks.md +107 -107
  5. package/integrations/linear/README.md +84 -84
  6. package/integrations/linear/coding-tools.json +7 -7
  7. package/integrations/linear/open-issue.mjs +147 -147
  8. package/package.json +34 -34
  9. package/skills/nook/SKILL.md +71 -71
  10. package/src/agents.mjs +92 -92
  11. package/src/backup.mjs +74 -74
  12. package/src/ci.mjs +378 -378
  13. package/src/cli.mjs +151 -151
  14. package/src/commands/check-hooks.mjs +128 -128
  15. package/src/commands/clean.mjs +175 -175
  16. package/src/commands/doctor.mjs +100 -91
  17. package/src/commands/forget.mjs +57 -57
  18. package/src/commands/hooks.mjs +34 -27
  19. package/src/commands/init.mjs +154 -154
  20. package/src/commands/ls.mjs +107 -107
  21. package/src/commands/rm.mjs +190 -190
  22. package/src/commands/workspace.mjs +163 -163
  23. package/src/commands/write-hooks.mjs +185 -185
  24. package/src/compose-plan.mjs +228 -228
  25. package/src/compose.mjs +123 -123
  26. package/src/config.mjs +115 -115
  27. package/src/context.mjs +189 -189
  28. package/src/errors.mjs +8 -8
  29. package/src/forge.mjs +75 -75
  30. package/src/generate-hooks.mjs +367 -367
  31. package/src/git.mjs +86 -86
  32. package/src/home.mjs +48 -48
  33. package/src/hooks.mjs +135 -135
  34. package/src/names.mjs +65 -65
  35. package/src/occupancy-windows.ps1 +59 -59
  36. package/src/occupancy.mjs +131 -131
  37. package/src/ports.mjs +89 -45
  38. package/src/prompt.mjs +100 -100
  39. package/src/shared.mjs +97 -97
  40. package/src/status.mjs +30 -30
  41. package/src/tools.mjs +23 -23
  42. package/src/ui.mjs +60 -60
@@ -1,107 +1,107 @@
1
- import { NookError } from "../errors.mjs";
2
- import { baseRef, remoteUrl } from "../git.mjs";
3
- import { githubPrs } from "../forge.mjs";
4
- import { aheadBehind, changeCount } from "../status.mjs";
5
- import { useColor } from "../ui.mjs";
6
- import { branchOf, repoFromCwd, repoWorkspaces } from "../context.mjs";
7
- import { isInside } from "../home.mjs";
8
-
9
- const plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
10
-
11
- function table(rows) {
12
- const widths = rows[0].map((_, i) => Math.max(...rows.map((row) => row[i].length)));
13
- return rows.map((row) => row.map((cell, i) => (i === row.length - 1 ? cell : cell.padEnd(widths[i]))).join(" "));
14
- }
15
-
16
- /** Every workspace with its local state and, when gh can tell, its PR. */
17
- export async function collect(ctx, { repo: repoFlag, prs: lookup = githubPrs } = {}) {
18
- const only = repoFlag ?? repoFromCwd(ctx);
19
- if (only && !ctx.config.repos[only]) throw new NookError(`Unknown repository "${only}"`);
20
- const repos = Object.entries(ctx.config.repos).filter(([name]) => !only || name === only);
21
- const fetched = await Promise.all(repos.map(([, repo]) => (remoteUrl(repo.path) ? lookup(repo.path, ctx.env) : { prs: null })));
22
- const items = [];
23
- const problems = new Set();
24
- repos.forEach(([repoName, repo], i) => {
25
- const { prs, error } = fetched[i];
26
- if (error) problems.add(error);
27
- const base = baseRef(repo.path, repo.default_branch);
28
- for (const item of repoWorkspaces(repoName, repo)) {
29
- const branch = branchOf(item.name, item.ws);
30
- const pr = item.main || !branch ? null : (prs?.get(branch) ?? null);
31
- const commits = item.main || !branch ? null : aheadBehind(repo.path, branch, base);
32
- items.push({ ...item, branch, base, changes: changeCount(item.ws.path), commits, pr });
33
- }
34
- });
35
- return { items, problems: [...problems] };
36
- }
37
-
38
- const portsText = (ws) => (Number.isInteger(ws.port) ? `${ws.port}-${ws.port + 9}` : "-");
39
-
40
- function changesText(n) {
41
- if (n === null) return "missing";
42
- return n ? `${n} changed` : "clean";
43
- }
44
-
45
- function commitsText(c) {
46
- if (!c) return "";
47
- return [c.ahead && `↑${c.ahead}`, c.behind && `↓${c.behind}`].filter(Boolean).join(" ");
48
- }
49
-
50
- function prText(pr) {
51
- if (!pr) return "";
52
- return `#${pr.number} ${pr.state}${pr.ci ? ` ${pr.ci}` : ""}`;
53
- }
54
-
55
- /** nk ls [--json] */
56
- export async function listWorkspaces(ctx, { repo: repoFlag, json = false, prs } = {}) {
57
- const { items, problems } = await collect(ctx, { repo: repoFlag, prs });
58
- if (json) {
59
- const data = items.map(({ name, branch, repoName, ws, base, changes, commits, pr, main, managed }) => ({
60
- name,
61
- branch,
62
- main,
63
- managed,
64
- locked: Boolean(ws.locked),
65
- repo: repoName,
66
- path: ws.path,
67
- port: ws.port ?? null,
68
- base,
69
- changes,
70
- ahead: commits?.ahead ?? null,
71
- behind: commits?.behind ?? null,
72
- pr,
73
- }));
74
- ctx.print(JSON.stringify(data, null, 2));
75
- return 0;
76
- }
77
- if (!items.length) {
78
- ctx.info("No repositories yet. Run nk init inside one");
79
- return 0;
80
- }
81
- // Gutter as in worktrunk: ^ main checkout, @ where you are, + another worktree.
82
- const mark = (item) => (isInside(ctx.cwd, item.ws.path) && !items.some((o) => o !== item && o.ws.path.length > item.ws.path.length && isInside(ctx.cwd, o.ws.path)) ? "@" : item.main ? "^" : "+");
83
- const rows = [["", "NAME", "REPO", "PORTS", "CHANGES", "BASE", "PR", "PATH"]];
84
- for (const item of items) {
85
- const name = item.ws.locked ? `${item.name} (locked)` : item.name;
86
- rows.push([mark(item), name, item.repoName, portsText(item.ws), changesText(item.changes), commitsText(item.commits), prText(item.pr), item.ws.path]);
87
- }
88
- const lines = table(rows);
89
- // Merged rows are dimmed, as worktrunk does for branches that are safe to delete.
90
- const dim = useColor(ctx.out, ctx.env);
91
- items.forEach((item, i) => {
92
- if (dim && item.pr?.state === "merged") lines[i + 1] = `\x1b[2m${lines[i + 1]}\x1b[0m`;
93
- });
94
- ctx.print(lines.join("\n"));
95
-
96
- const workspaces = items.filter((item) => !item.main);
97
- const changed = workspaces.filter((item) => item.changes).length;
98
- const merged = workspaces.filter((item) => item.pr?.state === "merged").length;
99
- const outside = workspaces.filter((item) => !item.managed).length;
100
- const parts = [`Showing ${plural(workspaces.length, "workspace")} and the main checkout`];
101
- if (outside) parts.push(`${outside} made by other tools`);
102
- if (changed) parts.push(`${changed} with changes`);
103
- if (merged) parts.push(`${merged} merged (nk clean removes them)`);
104
- ctx.info(parts.join(", "));
105
- for (const problem of problems) ctx.warn(`No PR status: ${problem}`);
106
- return 0;
107
- }
1
+ import { NookError } from "../errors.mjs";
2
+ import { baseRef, remoteUrl } from "../git.mjs";
3
+ import { githubPrs } from "../forge.mjs";
4
+ import { aheadBehind, changeCount } from "../status.mjs";
5
+ import { useColor } from "../ui.mjs";
6
+ import { branchOf, repoFromCwd, repoWorkspaces } from "../context.mjs";
7
+ import { isInside } from "../home.mjs";
8
+
9
+ const plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
10
+
11
+ function table(rows) {
12
+ const widths = rows[0].map((_, i) => Math.max(...rows.map((row) => row[i].length)));
13
+ return rows.map((row) => row.map((cell, i) => (i === row.length - 1 ? cell : cell.padEnd(widths[i]))).join(" "));
14
+ }
15
+
16
+ /** Every workspace with its local state and, when gh can tell, its PR. */
17
+ export async function collect(ctx, { repo: repoFlag, prs: lookup = githubPrs } = {}) {
18
+ const only = repoFlag ?? repoFromCwd(ctx);
19
+ if (only && !ctx.config.repos[only]) throw new NookError(`Unknown repository "${only}"`);
20
+ const repos = Object.entries(ctx.config.repos).filter(([name]) => !only || name === only);
21
+ const fetched = await Promise.all(repos.map(([, repo]) => (remoteUrl(repo.path) ? lookup(repo.path, ctx.env) : { prs: null })));
22
+ const items = [];
23
+ const problems = new Set();
24
+ repos.forEach(([repoName, repo], i) => {
25
+ const { prs, error } = fetched[i];
26
+ if (error) problems.add(error);
27
+ const base = baseRef(repo.path, repo.default_branch);
28
+ for (const item of repoWorkspaces(repoName, repo)) {
29
+ const branch = branchOf(item.name, item.ws);
30
+ const pr = item.main || !branch ? null : (prs?.get(branch) ?? null);
31
+ const commits = item.main || !branch ? null : aheadBehind(repo.path, branch, base);
32
+ items.push({ ...item, branch, base, changes: changeCount(item.ws.path), commits, pr });
33
+ }
34
+ });
35
+ return { items, problems: [...problems] };
36
+ }
37
+
38
+ const portsText = (ws) => (Number.isInteger(ws.port) ? `${ws.port}-${ws.port + 9}` : "-");
39
+
40
+ function changesText(n) {
41
+ if (n === null) return "missing";
42
+ return n ? `${n} changed` : "clean";
43
+ }
44
+
45
+ function commitsText(c) {
46
+ if (!c) return "";
47
+ return [c.ahead && `↑${c.ahead}`, c.behind && `↓${c.behind}`].filter(Boolean).join(" ");
48
+ }
49
+
50
+ function prText(pr) {
51
+ if (!pr) return "";
52
+ return `#${pr.number} ${pr.state}${pr.ci ? ` ${pr.ci}` : ""}`;
53
+ }
54
+
55
+ /** nk ls [--json] */
56
+ export async function listWorkspaces(ctx, { repo: repoFlag, json = false, prs } = {}) {
57
+ const { items, problems } = await collect(ctx, { repo: repoFlag, prs });
58
+ if (json) {
59
+ const data = items.map(({ name, branch, repoName, ws, base, changes, commits, pr, main, managed }) => ({
60
+ name,
61
+ branch,
62
+ main,
63
+ managed,
64
+ locked: Boolean(ws.locked),
65
+ repo: repoName,
66
+ path: ws.path,
67
+ port: ws.port ?? null,
68
+ base,
69
+ changes,
70
+ ahead: commits?.ahead ?? null,
71
+ behind: commits?.behind ?? null,
72
+ pr,
73
+ }));
74
+ ctx.print(JSON.stringify(data, null, 2));
75
+ return 0;
76
+ }
77
+ if (!items.length) {
78
+ ctx.info("No repositories yet. Run nk init inside one");
79
+ return 0;
80
+ }
81
+ // Gutter as in worktrunk: ^ main checkout, @ where you are, + another worktree.
82
+ const mark = (item) => (isInside(ctx.cwd, item.ws.path) && !items.some((o) => o !== item && o.ws.path.length > item.ws.path.length && isInside(ctx.cwd, o.ws.path)) ? "@" : item.main ? "^" : "+");
83
+ const rows = [["", "NAME", "REPO", "PORTS", "CHANGES", "BASE", "PR", "PATH"]];
84
+ for (const item of items) {
85
+ const name = item.ws.locked ? `${item.name} (locked)` : item.name;
86
+ rows.push([mark(item), name, item.repoName, portsText(item.ws), changesText(item.changes), commitsText(item.commits), prText(item.pr), item.ws.path]);
87
+ }
88
+ const lines = table(rows);
89
+ // Merged rows are dimmed, as worktrunk does for branches that are safe to delete.
90
+ const dim = useColor(ctx.out, ctx.env);
91
+ items.forEach((item, i) => {
92
+ if (dim && item.pr?.state === "merged") lines[i + 1] = `\x1b[2m${lines[i + 1]}\x1b[0m`;
93
+ });
94
+ ctx.print(lines.join("\n"));
95
+
96
+ const workspaces = items.filter((item) => !item.main);
97
+ const changed = workspaces.filter((item) => item.changes).length;
98
+ const merged = workspaces.filter((item) => item.pr?.state === "merged").length;
99
+ const outside = workspaces.filter((item) => !item.managed).length;
100
+ const parts = [`Showing ${plural(workspaces.length, "workspace")} and the main checkout`];
101
+ if (outside) parts.push(`${outside} made by other tools`);
102
+ if (changed) parts.push(`${changed} with changes`);
103
+ if (merged) parts.push(`${merged} merged (nk clean removes them)`);
104
+ ctx.info(parts.join(", "));
105
+ for (const problem of problems) ctx.warn(`No PR status: ${problem}`);
106
+ return 0;
107
+ }
@@ -1,190 +1,190 @@
1
- import { rmSync } from "node:fs";
2
- import { join } from "node:path";
3
- import { NookError } from "../errors.mjs";
4
- import { exists, isInside, samePath } from "../home.mjs";
5
- import { baseRef, gitTry, listWorktrees, remoteUrl } from "../git.mjs";
6
- import { githubPrs } from "../forge.mjs";
7
- import { findHook } from "../hooks.mjs";
8
- import { backupDir, createBackup, inspectWorkspace, needsBackup } from "../backup.mjs";
9
- import { describeOccupants, findOccupants, stopProcesses } from "../occupancy.mjs";
10
- import { createPrompter } from "../prompt.mjs";
11
- import { branchOf, resolveWorkspace } from "../context.mjs";
12
- import { toName } from "../names.mjs";
13
- import { approveHooks, hook } from "./workspace.mjs";
14
- import { listFiles } from "../shared.mjs";
15
- import { setupChanges } from "./check-hooks.mjs";
16
- import { describeProject, dockerCli, findComposeProjects, removeComposeProject } from "../compose.mjs";
17
-
18
- function occupants(ctx, path, scan) {
19
- const result = scan(path);
20
- if (result.unsupported) ctx.warn(`Could not check for running programs: ${result.unsupported}`);
21
- return result.processes;
22
- }
23
-
24
- const plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
25
-
26
- /**
27
- * Backs up what would be lost, runs the remove hook, deletes the folder and
28
- * the branch, and forgets the workspace. Programs must already be stopped.
29
- */
30
- export async function deleteWorkspace(ctx, target, { state, base, backupRoot, prompter, scan = findOccupants, docker = dockerCli }) {
31
- const { repo, ws, name } = target;
32
- const branchName = branchOf(name, ws);
33
- if (needsBackup(state)) {
34
- const dir = createBackup({ repo: repo.path, path: ws.path, branch: branchName, base, state, dir: backupDir(backupRoot, name) });
35
- ctx.ok(`Backed up changes to ${dir}`);
36
- }
37
- if (state.present) {
38
- try {
39
- await hook(ctx, target, "remove", { prompter });
40
- } catch (error) {
41
- if (error instanceof NookError) error.message += "\nStopped; nothing was deleted";
42
- throw error;
43
- }
44
- }
45
- // What the workspace started with Docker Compose goes too, so no remove hook is needed for it.
46
- // Checked again after the hook, which may have removed some of it already.
47
- for (const project of findComposeProjects(ws.path, docker).projects) {
48
- try {
49
- removeComposeProject(project, docker);
50
- } catch (error) {
51
- throw new NookError(`${error.message}\nStopped; the folder was not deleted`);
52
- }
53
- ctx.ok(`Removed ${describeProject(project)}`);
54
- }
55
- if (state.present) {
56
- const after = occupants(ctx, ws.path, scan);
57
- if (after.length) throw new NookError(`Programs started running in ${ws.path}:\n${describeOccupants(after)}\nNothing was deleted`);
58
- // Changes are backed up by now, so the folder goes even when git cannot remove the worktree
59
- // (a damaged one, say); prune below then drops git's record of it.
60
- const removed = gitTry(["worktree", "remove", "--force", ws.path], { cwd: repo.path });
61
- if (!removed.ok) ctx.warn(`git worktree remove failed (${removed.err.split("\n")[0]}); deleting the folder instead`);
62
- // git can leave ignored folders behind on Windows (node_modules, .venv), even when it reports success.
63
- deleteFolder(ws.path);
64
- }
65
- gitTry(["worktree", "prune"], { cwd: repo.path });
66
- if (listWorktrees(repo.path).some((tree) => samePath(tree.path, ws.path)))
67
- throw new NookError(`git still lists ${ws.path} as a worktree. Run git worktree prune in ${repo.path}, then nk rm ${name} again`);
68
- if (branchName) {
69
- const branch = gitTry(["branch", "-D", branchName], { cwd: repo.path });
70
- if (!branch.ok && !/not found/i.test(branch.err)) ctx.warn(`Branch ${branchName} was kept: ${branch.err}`);
71
- }
72
- delete repo.workspaces[name];
73
- ctx.save();
74
- }
75
-
76
- // Folders a project rebuilds from its own files, and files nobody keeps: losing them loses nothing.
77
- const REBUILT = new Set(["node_modules", ".venv", "venv", "vendor", "dist", "build", "out", "target", "coverage", "__pycache__", ".pytest_cache", ".mypy_cache", ".ruff_cache", ".next", ".nuxt", ".svelte-kit", ".turbo", ".cache", ".parcel-cache", ".gradle", ".tox", "_build", "deps", "tmp", "log", "logs", ".idea", ".vscode", ".DS_Store"]);
78
-
79
- /** Ignored files and folders in a workspace that hold more than a rebuild or a shared copy: a local database, say. */
80
- export function ignoredData(path, sharedDir) {
81
- const listed = gitTry(["ls-files", "--others", "--ignored", "--exclude-standard", "--directory", "--no-empty-directory"], { cwd: path });
82
- if (!listed.ok) return [];
83
- const shared = new Set(listFiles(sharedDir));
84
- return listed.out
85
- .split(/\r?\n/)
86
- .filter((entry) => entry && !shared.has(entry) && !/\.(log|pyc)$/.test(entry) && !entry.split("/").some((part) => REBUILT.has(part)));
87
- }
88
-
89
- /** Deletes a folder and everything in it, retrying while Windows still holds a file open. */
90
- export function deleteFolder(path) {
91
- if (!exists(path)) return;
92
- try {
93
- rmSync(path, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 });
94
- } catch (error) {
95
- // A container that mounts the workspace and runs as root leaves files this user cannot delete.
96
- if (error.code === "EACCES" || (error.code === "EPERM" && process.platform !== "win32"))
97
- throw new NookError(`Could not delete ${path}: ${error.message}\nA container probably wrote these files as another user (often root). Delete the folder with sudo rm -rf, or have the remove hook delete them from inside the container`);
98
- throw new NookError(`Could not delete ${path}: ${error.message}\nClose what uses it and run the command again`);
99
- }
100
- if (exists(path)) throw new NookError(`Could not delete ${path}; close what uses it and run the command again`);
101
- }
102
-
103
- /** What removing one workspace involves, checked before anything changes. */
104
- async function planRemoval(ctx, input, { repoFlag, scan, prs, docker }) {
105
- const name = toName(input);
106
- const target = resolveWorkspace(ctx, name, repoFlag);
107
- const { repo, ws } = target;
108
- if (target.main) throw new NookError(`${ws.path} is the main checkout of ${target.repoName}; git cannot remove it`);
109
- if (ws.locked) throw new NookError(`${ws.path} is locked. Unlock it first: git worktree unlock ${ws.path}`);
110
- if (exists(ws.path) && isInside(ctx.cwd, ws.path))
111
- throw new NookError(`You are inside ${ws.path}. Change to another folder first, e.g. cd ${repo.path}`);
112
-
113
- const base = baseRef(repo.path, repo.default_branch);
114
- const branchName = branchOf(target.name, ws);
115
- const state = inspectWorkspace({ repo: repo.path, path: ws.path, branch: branchName ?? "", base });
116
- const backupRoot = join(ctx.paths.backup, target.repoName);
117
- const removeHook = state.present ? findHook(repo.path, "remove") : null;
118
-
119
- ctx.info(`Removing ${target.name} @ ${ws.path}${state.present ? "" : " (folder already missing)"}${!branchName ? "" : branchName === target.name ? " and its branch" : ` and its branch ${branchName}`}`);
120
- if (removeHook) ctx.info(`Runs .nook/${removeHook.name} first`);
121
- const changed = setupChanges(repo).changes.filter((what) => what.startsWith(".nook/"));
122
- if (state.present && changed.length) ctx.warn(`${changed.join(", ")} changed since nk hooks test last passed; remove may leave things behind`);
123
- const compose = findComposeProjects(ws.path, docker);
124
- for (const project of compose.projects) ctx.info(`Removes ${describeProject(project)}`);
125
- for (const name of compose.shared) ctx.warn(`Compose project ${name} also has containers outside this workspace; it is kept`);
126
- if (compose.unavailable && compose.unavailable !== "not installed") ctx.warn(`Could not check for Docker containers: ${compose.unavailable}`);
127
- if (needsBackup(state)) ctx.info(`Backs up changes to ${backupRoot}`);
128
- const lost = state.present ? ignoredData(ws.path, join(ctx.paths.shared, target.repoName)) : [];
129
- if (lost.length) ctx.warn(`Not backed up, since git ignores them: ${lost.slice(0, 8).join(", ")}${lost.length > 8 ? ` and ${lost.length - 8} more` : ""}`);
130
- if (state.changes.length) {
131
- ctx.warn(`${plural(state.changes.length, "uncommitted change")}:`);
132
- for (const change of state.changes.slice(0, 10)) ctx.item(change);
133
- if (state.changes.length > 10) ctx.item(`... ${state.changes.length - 10} more`);
134
- }
135
- if (state.unpushed.length) ctx.warn(`${plural(state.unpushed.length, "commit")} not pushed`);
136
- if (branchName && !state.merged) {
137
- // A squash merge leaves the branch outside the base; the PR says whether it was merged.
138
- const pr = remoteUrl(repo.path) ? (await prs(repo.path, ctx.env)).prs?.get(branchName) : null;
139
- if (pr?.state === "merged") ctx.info(`PR #${pr.number} is merged`);
140
- else ctx.warn(`Branch ${branchName} is not merged into ${base}`);
141
- }
142
- const running = state.present ? occupants(ctx, ws.path, scan) : [];
143
- return { target, state, base, backupRoot, removeHook, running };
144
- }
145
-
146
- /** nk rm <name>... [-n] [-y]: checks every workspace first, asks once, then removes them in turn. */
147
- export async function removeWorkspace(ctx, { name, names = [name], repo: repoFlag, dryRun = false, yes = false, scan = findOccupants, stop = stopProcesses, prompter = createPrompter(), prs = githubPrs, docker = dockerCli }) {
148
- const plans = [];
149
- for (const input of [...new Set(names.map(toName))]) plans.push(await planRemoval(ctx, input, { repoFlag, scan, prs, docker }));
150
- if (dryRun) {
151
- ctx.info("Dry run; nothing was removed");
152
- return 0;
153
- }
154
-
155
- const running = plans.flatMap((plan) => plan.running);
156
- if (running.length) {
157
- ctx.warn(`${plural(running.length, "program")} running in ${plans.length === 1 ? "the workspace" : "these workspaces"}:`);
158
- for (const item of running) ctx.item(`${item.pid} ${item.name}`);
159
- }
160
- // Hook approval comes before anything is stopped or deleted.
161
- const approved = new Set();
162
- for (const { target, removeHook } of plans)
163
- if (removeHook && !approved.has(target.repoName)) {
164
- approved.add(target.repoName);
165
- await approveHooks(ctx, target, prompter);
166
- }
167
- if (!yes) {
168
- const what = plans.map((plan) => plan.target.name).join(", ");
169
- const question = running.length ? `Stop ${running.length === 1 ? "it" : "them"} and remove ${what}?` : `Remove ${what}?`;
170
- let ok;
171
- try {
172
- ok = await prompter.confirm(question, false);
173
- } catch {
174
- throw new NookError("Confirmation needs a terminal; pass -y to remove without asking");
175
- }
176
- if (!ok) throw new NookError("Nothing was removed");
177
- }
178
- if (running.length) {
179
- await stop(running);
180
- const left = plans.flatMap((plan) => (plan.state.present ? occupants(ctx, plan.target.ws.path, scan) : []));
181
- if (left.length) throw new NookError(`Could not stop:\n${describeOccupants(left)}\nNothing was removed`);
182
- ctx.ok(`Stopped ${plural(running.length, "program")}`);
183
- }
184
-
185
- for (const { target, state, base, backupRoot } of plans) {
186
- await deleteWorkspace(ctx, target, { state, base, backupRoot, prompter, scan, docker });
187
- ctx.ok(`Removed ${target.name}`);
188
- }
189
- return 0;
190
- }
1
+ import { rmSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { NookError } from "../errors.mjs";
4
+ import { exists, isInside, samePath } from "../home.mjs";
5
+ import { baseRef, gitTry, listWorktrees, remoteUrl } from "../git.mjs";
6
+ import { githubPrs } from "../forge.mjs";
7
+ import { findHook } from "../hooks.mjs";
8
+ import { backupDir, createBackup, inspectWorkspace, needsBackup } from "../backup.mjs";
9
+ import { describeOccupants, findOccupants, stopProcesses } from "../occupancy.mjs";
10
+ import { createPrompter } from "../prompt.mjs";
11
+ import { branchOf, resolveWorkspace } from "../context.mjs";
12
+ import { toName } from "../names.mjs";
13
+ import { approveHooks, hook } from "./workspace.mjs";
14
+ import { listFiles } from "../shared.mjs";
15
+ import { setupChanges } from "./check-hooks.mjs";
16
+ import { describeProject, dockerCli, findComposeProjects, removeComposeProject } from "../compose.mjs";
17
+
18
+ function occupants(ctx, path, scan) {
19
+ const result = scan(path);
20
+ if (result.unsupported) ctx.warn(`Could not check for running programs: ${result.unsupported}`);
21
+ return result.processes;
22
+ }
23
+
24
+ const plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
25
+
26
+ /**
27
+ * Backs up what would be lost, runs the remove hook, deletes the folder and
28
+ * the branch, and forgets the workspace. Programs must already be stopped.
29
+ */
30
+ export async function deleteWorkspace(ctx, target, { state, base, backupRoot, prompter, scan = findOccupants, docker = dockerCli }) {
31
+ const { repo, ws, name } = target;
32
+ const branchName = branchOf(name, ws);
33
+ if (needsBackup(state)) {
34
+ const dir = createBackup({ repo: repo.path, path: ws.path, branch: branchName, base, state, dir: backupDir(backupRoot, name) });
35
+ ctx.ok(`Backed up changes to ${dir}`);
36
+ }
37
+ if (state.present) {
38
+ try {
39
+ await hook(ctx, target, "remove", { prompter });
40
+ } catch (error) {
41
+ if (error instanceof NookError) error.message += "\nStopped; nothing was deleted";
42
+ throw error;
43
+ }
44
+ }
45
+ // What the workspace started with Docker Compose goes too, so no remove hook is needed for it.
46
+ // Checked again after the hook, which may have removed some of it already.
47
+ for (const project of findComposeProjects(ws.path, docker).projects) {
48
+ try {
49
+ removeComposeProject(project, docker);
50
+ } catch (error) {
51
+ throw new NookError(`${error.message}\nStopped; the folder was not deleted`);
52
+ }
53
+ ctx.ok(`Removed ${describeProject(project)}`);
54
+ }
55
+ if (state.present) {
56
+ const after = occupants(ctx, ws.path, scan);
57
+ if (after.length) throw new NookError(`Programs started running in ${ws.path}:\n${describeOccupants(after)}\nNothing was deleted`);
58
+ // Changes are backed up by now, so the folder goes even when git cannot remove the worktree
59
+ // (a damaged one, say); prune below then drops git's record of it.
60
+ const removed = gitTry(["worktree", "remove", "--force", ws.path], { cwd: repo.path });
61
+ if (!removed.ok) ctx.warn(`git worktree remove failed (${removed.err.split("\n")[0]}); deleting the folder instead`);
62
+ // git can leave ignored folders behind on Windows (node_modules, .venv), even when it reports success.
63
+ deleteFolder(ws.path);
64
+ }
65
+ gitTry(["worktree", "prune"], { cwd: repo.path });
66
+ if (listWorktrees(repo.path).some((tree) => samePath(tree.path, ws.path)))
67
+ throw new NookError(`git still lists ${ws.path} as a worktree. Run git worktree prune in ${repo.path}, then nk rm ${name} again`);
68
+ if (branchName) {
69
+ const branch = gitTry(["branch", "-D", branchName], { cwd: repo.path });
70
+ if (!branch.ok && !/not found/i.test(branch.err)) ctx.warn(`Branch ${branchName} was kept: ${branch.err}`);
71
+ }
72
+ delete repo.workspaces[name];
73
+ ctx.save();
74
+ }
75
+
76
+ // Folders a project rebuilds from its own files, and files nobody keeps: losing them loses nothing.
77
+ const REBUILT = new Set(["node_modules", ".venv", "venv", "vendor", "dist", "build", "out", "target", "coverage", "__pycache__", ".pytest_cache", ".mypy_cache", ".ruff_cache", ".next", ".nuxt", ".svelte-kit", ".turbo", ".cache", ".parcel-cache", ".gradle", ".tox", "_build", "deps", "tmp", "log", "logs", ".idea", ".vscode", ".DS_Store"]);
78
+
79
+ /** Ignored files and folders in a workspace that hold more than a rebuild or a shared copy: a local database, say. */
80
+ export function ignoredData(path, sharedDir) {
81
+ const listed = gitTry(["ls-files", "--others", "--ignored", "--exclude-standard", "--directory", "--no-empty-directory"], { cwd: path });
82
+ if (!listed.ok) return [];
83
+ const shared = new Set(listFiles(sharedDir));
84
+ return listed.out
85
+ .split(/\r?\n/)
86
+ .filter((entry) => entry && !shared.has(entry) && !/\.(log|pyc)$/.test(entry) && !entry.split("/").some((part) => REBUILT.has(part)));
87
+ }
88
+
89
+ /** Deletes a folder and everything in it, retrying while Windows still holds a file open. */
90
+ export function deleteFolder(path) {
91
+ if (!exists(path)) return;
92
+ try {
93
+ rmSync(path, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 });
94
+ } catch (error) {
95
+ // A container that mounts the workspace and runs as root leaves files this user cannot delete.
96
+ if (error.code === "EACCES" || (error.code === "EPERM" && process.platform !== "win32"))
97
+ throw new NookError(`Could not delete ${path}: ${error.message}\nA container probably wrote these files as another user (often root). Delete the folder with sudo rm -rf, or have the remove hook delete them from inside the container`);
98
+ throw new NookError(`Could not delete ${path}: ${error.message}\nClose what uses it and run the command again`);
99
+ }
100
+ if (exists(path)) throw new NookError(`Could not delete ${path}; close what uses it and run the command again`);
101
+ }
102
+
103
+ /** What removing one workspace involves, checked before anything changes. */
104
+ async function planRemoval(ctx, input, { repoFlag, scan, prs, docker }) {
105
+ const name = toName(input);
106
+ const target = resolveWorkspace(ctx, name, repoFlag);
107
+ const { repo, ws } = target;
108
+ if (target.main) throw new NookError(`${ws.path} is the main checkout of ${target.repoName}; git cannot remove it`);
109
+ if (ws.locked) throw new NookError(`${ws.path} is locked. Unlock it first: git worktree unlock ${ws.path}`);
110
+ if (exists(ws.path) && isInside(ctx.cwd, ws.path))
111
+ throw new NookError(`You are inside ${ws.path}. Change to another folder first, e.g. cd ${repo.path}`);
112
+
113
+ const base = baseRef(repo.path, repo.default_branch);
114
+ const branchName = branchOf(target.name, ws);
115
+ const state = inspectWorkspace({ repo: repo.path, path: ws.path, branch: branchName ?? "", base });
116
+ const backupRoot = join(ctx.paths.backup, target.repoName);
117
+ const removeHook = state.present ? findHook(repo.path, "remove") : null;
118
+
119
+ ctx.info(`Removing ${target.name} @ ${ws.path}${state.present ? "" : " (folder already missing)"}${!branchName ? "" : branchName === target.name ? " and its branch" : ` and its branch ${branchName}`}`);
120
+ if (removeHook) ctx.info(`Runs .nook/${removeHook.name} first`);
121
+ const changed = setupChanges(repo).changes.filter((what) => what.startsWith(".nook/"));
122
+ if (state.present && changed.length) ctx.warn(`${changed.join(", ")} changed since nk hooks test last passed; remove may leave things behind`);
123
+ const compose = findComposeProjects(ws.path, docker);
124
+ for (const project of compose.projects) ctx.info(`Removes ${describeProject(project)}`);
125
+ for (const name of compose.shared) ctx.warn(`Compose project ${name} also has containers outside this workspace; it is kept`);
126
+ if (compose.unavailable && compose.unavailable !== "not installed") ctx.warn(`Could not check for Docker containers: ${compose.unavailable}`);
127
+ if (needsBackup(state)) ctx.info(`Backs up changes to ${backupRoot}`);
128
+ const lost = state.present ? ignoredData(ws.path, join(ctx.paths.shared, target.repoName)) : [];
129
+ if (lost.length) ctx.warn(`Not backed up, since git ignores them: ${lost.slice(0, 8).join(", ")}${lost.length > 8 ? ` and ${lost.length - 8} more` : ""}`);
130
+ if (state.changes.length) {
131
+ ctx.warn(`${plural(state.changes.length, "uncommitted change")}:`);
132
+ for (const change of state.changes.slice(0, 10)) ctx.item(change);
133
+ if (state.changes.length > 10) ctx.item(`... ${state.changes.length - 10} more`);
134
+ }
135
+ if (state.unpushed.length) ctx.warn(`${plural(state.unpushed.length, "commit")} not pushed`);
136
+ if (branchName && !state.merged) {
137
+ // A squash merge leaves the branch outside the base; the PR says whether it was merged.
138
+ const pr = remoteUrl(repo.path) ? (await prs(repo.path, ctx.env)).prs?.get(branchName) : null;
139
+ if (pr?.state === "merged") ctx.info(`PR #${pr.number} is merged`);
140
+ else ctx.warn(`Branch ${branchName} is not merged into ${base}`);
141
+ }
142
+ const running = state.present ? occupants(ctx, ws.path, scan) : [];
143
+ return { target, state, base, backupRoot, removeHook, running };
144
+ }
145
+
146
+ /** nk rm <name>... [-n] [-y]: checks every workspace first, asks once, then removes them in turn. */
147
+ export async function removeWorkspace(ctx, { name, names = [name], repo: repoFlag, dryRun = false, yes = false, scan = findOccupants, stop = stopProcesses, prompter = createPrompter(), prs = githubPrs, docker = dockerCli }) {
148
+ const plans = [];
149
+ for (const input of [...new Set(names.map(toName))]) plans.push(await planRemoval(ctx, input, { repoFlag, scan, prs, docker }));
150
+ if (dryRun) {
151
+ ctx.info("Dry run; nothing was removed");
152
+ return 0;
153
+ }
154
+
155
+ const running = plans.flatMap((plan) => plan.running);
156
+ if (running.length) {
157
+ ctx.warn(`${plural(running.length, "program")} running in ${plans.length === 1 ? "the workspace" : "these workspaces"}:`);
158
+ for (const item of running) ctx.item(`${item.pid} ${item.name}`);
159
+ }
160
+ // Hook approval comes before anything is stopped or deleted.
161
+ const approved = new Set();
162
+ for (const { target, removeHook } of plans)
163
+ if (removeHook && !approved.has(target.repoName)) {
164
+ approved.add(target.repoName);
165
+ await approveHooks(ctx, target, prompter);
166
+ }
167
+ if (!yes) {
168
+ const what = plans.map((plan) => plan.target.name).join(", ");
169
+ const question = running.length ? `Stop ${running.length === 1 ? "it" : "them"} and remove ${what}?` : `Remove ${what}?`;
170
+ let ok;
171
+ try {
172
+ ok = await prompter.confirm(question, false);
173
+ } catch {
174
+ throw new NookError("Confirmation needs a terminal; pass -y to remove without asking");
175
+ }
176
+ if (!ok) throw new NookError("Nothing was removed");
177
+ }
178
+ if (running.length) {
179
+ await stop(running);
180
+ const left = plans.flatMap((plan) => (plan.state.present ? occupants(ctx, plan.target.ws.path, scan) : []));
181
+ if (left.length) throw new NookError(`Could not stop:\n${describeOccupants(left)}\nNothing was removed`);
182
+ ctx.ok(`Stopped ${plural(running.length, "program")}`);
183
+ }
184
+
185
+ for (const { target, state, base, backupRoot } of plans) {
186
+ await deleteWorkspace(ctx, target, { state, base, backupRoot, prompter, scan, docker });
187
+ ctx.ok(`Removed ${target.name}`);
188
+ }
189
+ return 0;
190
+ }