@ramon-sg/issue-flow 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +297 -2
  3. package/dist/ask.d.ts +28 -0
  4. package/dist/ask.js +73 -0
  5. package/dist/branch-name.d.ts +113 -0
  6. package/dist/branch-name.js +226 -0
  7. package/dist/claude-namer.d.ts +51 -0
  8. package/dist/claude-namer.js +140 -0
  9. package/dist/cli-setup.d.ts +22 -0
  10. package/dist/cli-setup.js +26 -0
  11. package/dist/cli-worktree.d.ts +74 -0
  12. package/dist/cli-worktree.js +123 -0
  13. package/dist/cli.d.ts +120 -0
  14. package/dist/cli.js +344 -0
  15. package/dist/commands/adapter.d.ts +49 -0
  16. package/dist/commands/adapter.js +231 -0
  17. package/dist/commands/claude-hooks.d.ts +37 -0
  18. package/dist/commands/claude-hooks.js +162 -0
  19. package/dist/commands/doctor.d.ts +11 -0
  20. package/dist/commands/doctor.js +90 -0
  21. package/dist/commands/init.d.ts +24 -0
  22. package/dist/commands/init.js +136 -0
  23. package/dist/commands/issue-branch.d.ts +36 -0
  24. package/dist/commands/issue-branch.js +182 -0
  25. package/dist/commands/issue-start.d.ts +11 -0
  26. package/dist/commands/issue-start.js +361 -0
  27. package/dist/commands/pr-worktree.d.ts +31 -0
  28. package/dist/commands/pr-worktree.js +122 -0
  29. package/dist/commands/session.d.ts +15 -0
  30. package/dist/commands/session.js +102 -0
  31. package/dist/commands/upgrade.d.ts +16 -0
  32. package/dist/commands/upgrade.js +85 -0
  33. package/dist/commands/worktree.d.ts +82 -0
  34. package/dist/commands/worktree.js +244 -0
  35. package/dist/config-base.d.ts +32 -0
  36. package/dist/config-base.js +47 -0
  37. package/dist/config.d.ts +63 -0
  38. package/dist/config.js +230 -0
  39. package/dist/docker.d.ts +53 -0
  40. package/dist/docker.js +153 -0
  41. package/dist/git.d.ts +130 -0
  42. package/dist/git.js +192 -0
  43. package/dist/issue-text.d.ts +128 -0
  44. package/dist/issue-text.js +210 -0
  45. package/dist/launch.d.ts +90 -0
  46. package/dist/launch.js +158 -0
  47. package/dist/output.d.ts +33 -0
  48. package/dist/output.js +37 -0
  49. package/dist/package-edits.d.ts +35 -0
  50. package/dist/package-edits.js +78 -0
  51. package/dist/plan-doc.d.ts +26 -0
  52. package/dist/plan-doc.js +93 -0
  53. package/dist/ports.d.ts +90 -0
  54. package/dist/ports.js +257 -0
  55. package/dist/propose.d.ts +42 -0
  56. package/dist/propose.js +111 -0
  57. package/dist/recipes.d.ts +43 -0
  58. package/dist/recipes.js +123 -0
  59. package/dist/session-lock.d.ts +32 -0
  60. package/dist/session-lock.js +69 -0
  61. package/dist/settings-hooks.d.ts +31 -0
  62. package/dist/settings-hooks.js +133 -0
  63. package/dist/setup-files.d.ts +48 -0
  64. package/dist/setup-files.js +91 -0
  65. package/dist/start-modes.d.ts +162 -0
  66. package/dist/start-modes.js +277 -0
  67. package/dist/start-place.d.ts +61 -0
  68. package/dist/start-place.js +301 -0
  69. package/dist/start-state.d.ts +72 -0
  70. package/dist/start-state.js +13 -0
  71. package/dist/start-summary.d.ts +42 -0
  72. package/dist/start-summary.js +217 -0
  73. package/dist/stop-apps.d.ts +14 -0
  74. package/dist/stop-apps.js +127 -0
  75. package/dist/usage.d.ts +5 -0
  76. package/dist/usage.js +54 -0
  77. package/dist/work-state.d.ts +51 -0
  78. package/dist/work-state.js +58 -0
  79. package/dist/worktree-config.d.ts +35 -0
  80. package/dist/worktree-config.js +139 -0
  81. package/dist/worktree-steps.d.ts +53 -0
  82. package/dist/worktree-steps.js +210 -0
  83. package/package.json +20 -4
  84. package/recipes/bun.json +5 -0
  85. package/recipes/direnv.json +17 -0
  86. package/recipes/docker-compose.json +30 -0
  87. package/recipes/npm.json +5 -0
  88. package/recipes/pnpm.json +5 -0
  89. package/recipes/staging-main.json +5 -0
  90. package/schema.json +281 -0
package/dist/config.js ADDED
@@ -0,0 +1,230 @@
1
+ /**
2
+ * @file The repo's config, `.agents/flow.json`. Every key is optional: without the file,
3
+ * or with a key missing, the default applies. An unknown key or a value of the wrong kind
4
+ * is an error that names the key, so a typo never silently falls back to the default.
5
+ */
6
+ import { existsSync, readFileSync } from "node:fs";
7
+ import { join } from "node:path";
8
+ import { locateRepo } from "./git.js";
9
+ import { CONFIG_PATH, ConfigError, isObject, onlyKeys, text } from "./config-base.js";
10
+ import { worktreesSection } from "./worktree-config.js";
11
+ /** The defaults, the same rules Abastio uses. */
12
+ export const DEFAULT_CONFIG = {
13
+ repo: null,
14
+ baseBranch: null,
15
+ branch: {
16
+ template: "{type}/{number}-{slug}",
17
+ namer: "claude",
18
+ claudeModel: "haiku",
19
+ types: {
20
+ labels: {
21
+ bug: "fix",
22
+ enhancement: "feat",
23
+ documentation: "docs",
24
+ spike: "spike",
25
+ },
26
+ titlePrefixes: {
27
+ "bug:": "fix",
28
+ "mejora:": "feat",
29
+ "docs:": "docs",
30
+ "spike:": "spike",
31
+ },
32
+ },
33
+ },
34
+ worktrees: { dir: ".claude/worktrees", setup: [], teardown: [], ports: null, dockerProjects: [] },
35
+ start: {
36
+ commitMessage: "chore: empieza el trabajo en la issue #{number} [skip ci]",
37
+ prBody: "{keyword} #{number}\n\nEn progreso.",
38
+ prompt: "Issue #{number}. Lee la issue con `npx @ramon-sg/github-rest issue view {number}`, resúmela en lenguaje llano y sigue la skill start-issue para elegir el flujo.",
39
+ resumePrompt: "Issue #{number}. Lee la issue con `npx @ramon-sg/github-rest issue view {number}`, resúmela en lenguaje llano y retoma el trabajo según {plan}: revisa qué tareas quedan.",
40
+ summaryPrompt: "Issue #{number}. Lee la issue con `npx @ramon-sg/github-rest issue view {number}` y resúmela en lenguaje llano. No empieces a trabajar ni a planificar: espera mi pedido.",
41
+ summaryPlanPrompt: "Issue #{number}. Lee la issue con `npx @ramon-sg/github-rest issue view {number}` y resúmela en lenguaje llano. Tiene un plan abierto en {plan}: di en una línea qué tareas quedan. No empieces a trabajar ni a planificar: espera mi pedido.",
42
+ cloudPrompt: "/start-issue {number} --yes{flags}",
43
+ },
44
+ plans: { dirs: ["docs/plans", "docs/specs"] },
45
+ colors: {
46
+ "critical-path": "red",
47
+ "P1:": "red",
48
+ "P2:": "orange",
49
+ "P3:": "yellow",
50
+ "P4:": "cyan",
51
+ },
52
+ };
53
+ /** The colors a Claude session takes with `/color`. */
54
+ export const SESSION_COLORS = ["red", "blue", "green", "yellow", "purple", "orange", "pink", "cyan"];
55
+ /** Placeholders the branch template must have. */
56
+ const TEMPLATE_PLACEHOLDERS = ["{number}", "{slug}"];
57
+ /** `owner/repo`. */
58
+ const REPO = /^[^/\s]+\/[^/\s]+$/;
59
+ /** A branch type: lowercase, as it goes in the branch name. */
60
+ const TYPE = /^[a-z0-9][a-z0-9._-]*$/;
61
+ /**
62
+ * A map of name to branch type (`{"bug": "fix"}`), merged over nothing: when the repo
63
+ * gives one, it replaces the default whole, so a label can be dropped.
64
+ * @param value - The value, or undefined.
65
+ * @param where - Its path, for the error.
66
+ * @param fallback - The default map.
67
+ * @returns The map.
68
+ * @throws ConfigError if it is not an object of valid types.
69
+ */
70
+ function typeMap(value, where, fallback) {
71
+ if (value === undefined)
72
+ return fallback;
73
+ if (!isObject(value))
74
+ throw new ConfigError(`${CONFIG_PATH}: "${where}" must be an object.`);
75
+ for (const [key, type] of Object.entries(value)) {
76
+ if (typeof type !== "string" || !TYPE.test(type))
77
+ throw new ConfigError(`${CONFIG_PATH}: "${where}.${key}" must be a lowercase branch type such as "feat".`);
78
+ }
79
+ return value;
80
+ }
81
+ /**
82
+ * The `branch` section, with its defaults.
83
+ * @param value - The section, or undefined.
84
+ * @returns The section.
85
+ * @throws ConfigError if something is invalid.
86
+ */
87
+ function branchSection(value) {
88
+ const fallback = DEFAULT_CONFIG.branch;
89
+ if (value === undefined)
90
+ return fallback;
91
+ if (!isObject(value))
92
+ throw new ConfigError(`${CONFIG_PATH}: "branch" must be an object.`);
93
+ onlyKeys(value, ["template", "namer", "claudeModel", "types"], "branch");
94
+ const template = text(value.template, "branch.template", fallback.template);
95
+ const missing = TEMPLATE_PLACEHOLDERS.filter((part) => !template.includes(part));
96
+ if (missing.length > 0)
97
+ throw new ConfigError(`${CONFIG_PATH}: "branch.template" needs ${missing.join(" and ")}.`);
98
+ const namer = value.namer ?? fallback.namer;
99
+ if (namer !== "claude" && namer !== "title")
100
+ throw new ConfigError(`${CONFIG_PATH}: "branch.namer" must be "claude" or "title".`);
101
+ const types = value.types;
102
+ if (types !== undefined && !isObject(types))
103
+ throw new ConfigError(`${CONFIG_PATH}: "branch.types" must be an object.`);
104
+ if (types !== undefined)
105
+ onlyKeys(types, ["labels", "titlePrefixes"], "branch.types");
106
+ return {
107
+ template,
108
+ namer,
109
+ claudeModel: text(value.claudeModel, "branch.claudeModel", fallback.claudeModel),
110
+ types: {
111
+ labels: typeMap(types?.labels, "branch.types.labels", fallback.types.labels),
112
+ titlePrefixes: typeMap(types?.titlePrefixes, "branch.types.titlePrefixes", fallback.types.titlePrefixes),
113
+ },
114
+ };
115
+ }
116
+ /**
117
+ * A section made only of optional strings (`start`).
118
+ * @param value - The section, or undefined.
119
+ * @param name - Its key.
120
+ * @param fallback - Its defaults.
121
+ * @returns The section.
122
+ * @throws ConfigError if something is invalid.
123
+ */
124
+ function textSection(value, name, fallback) {
125
+ if (value === undefined)
126
+ return fallback;
127
+ if (!isObject(value))
128
+ throw new ConfigError(`${CONFIG_PATH}: "${name}" must be an object.`);
129
+ onlyKeys(value, Object.keys(fallback), name);
130
+ const out = {};
131
+ for (const [key, standard] of Object.entries(fallback))
132
+ out[key] = text(value[key], `${name}.${key}`, standard);
133
+ return out;
134
+ }
135
+ /**
136
+ * The `plans` section: the folders where an issue's plan is looked for, in order of
137
+ * preference.
138
+ * @param value - The section, or undefined.
139
+ * @returns The section.
140
+ * @throws ConfigError if something is invalid.
141
+ */
142
+ function plansSection(value) {
143
+ if (value === undefined)
144
+ return DEFAULT_CONFIG.plans;
145
+ if (!isObject(value))
146
+ throw new ConfigError(`${CONFIG_PATH}: "plans" must be an object.`);
147
+ onlyKeys(value, ["dirs"], "plans");
148
+ const dirs = value.dirs ?? DEFAULT_CONFIG.plans.dirs;
149
+ const valid = Array.isArray(dirs) &&
150
+ dirs.length > 0 &&
151
+ dirs.every((dir) => typeof dir === "string" && dir.trim() !== "" && !dir.startsWith("/"));
152
+ if (!valid)
153
+ throw new ConfigError(`${CONFIG_PATH}: "plans.dirs" must be a non-empty list of folders relative to the repo.`);
154
+ return { dirs: dirs.map((dir) => dir.replace(/\/+$/, "")) };
155
+ }
156
+ /**
157
+ * The `colors` map: label prefix to session color. When the repo gives one, it replaces the
158
+ * default whole.
159
+ * @param value - The map, or undefined.
160
+ * @returns The map.
161
+ * @throws ConfigError if a color is not one Claude knows.
162
+ */
163
+ function colorsSection(value) {
164
+ if (value === undefined)
165
+ return DEFAULT_CONFIG.colors;
166
+ if (!isObject(value))
167
+ throw new ConfigError(`${CONFIG_PATH}: "colors" must be an object.`);
168
+ for (const [prefix, color] of Object.entries(value)) {
169
+ if (typeof color !== "string" || !SESSION_COLORS.includes(color))
170
+ throw new ConfigError(`${CONFIG_PATH}: "colors.${prefix}" must be one of ${SESSION_COLORS.join(", ")}.`);
171
+ }
172
+ return value;
173
+ }
174
+ /**
175
+ * Validates a parsed config and fills in the defaults.
176
+ * @param raw - The parsed JSON.
177
+ * @returns The config.
178
+ * @throws ConfigError if something is invalid.
179
+ * @example parseConfig({ branch: { namer: "title" } }).branch.template // "{type}/{number}-{slug}"
180
+ */
181
+ export function parseConfig(raw) {
182
+ if (!isObject(raw))
183
+ throw new ConfigError(`${CONFIG_PATH} must be a JSON object.`);
184
+ onlyKeys(raw, ["repo", "baseBranch", "branch", "worktrees", "start", "plans", "colors"], "");
185
+ const repo = text(raw.repo, "repo", null);
186
+ if (repo !== null && !REPO.test(repo))
187
+ throw new ConfigError(`${CONFIG_PATH}: "repo" must be owner/repo.`);
188
+ return {
189
+ repo,
190
+ baseBranch: text(raw.baseBranch, "baseBranch", null),
191
+ branch: branchSection(raw.branch),
192
+ worktrees: worktreesSection(raw.worktrees, DEFAULT_CONFIG.worktrees),
193
+ start: textSection(raw.start, "start", DEFAULT_CONFIG.start),
194
+ plans: plansSection(raw.plans),
195
+ colors: colorsSection(raw.colors),
196
+ };
197
+ }
198
+ /**
199
+ * The main checkout of the repo that holds a directory, or null outside git. The config
200
+ * is always the main checkout's, also from a worktree: a branch cannot change the steps
201
+ * its own worktree runs.
202
+ * @param cwd - The directory.
203
+ * @returns The root.
204
+ * @example repoRoot("/repo/.claude/worktrees/feat+42-x") // "/repo"
205
+ */
206
+ export function repoRoot(cwd) {
207
+ return locateRepo(cwd)?.mainRoot ?? null;
208
+ }
209
+ /**
210
+ * Reads the config of the main checkout of the repo that holds `cwd`. Outside git, or
211
+ * without the file, every default applies.
212
+ * @param cwd - A directory inside the checkout.
213
+ * @returns The config.
214
+ * @throws ConfigError if the file is not valid JSON or something in it is invalid.
215
+ * @example loadConfig(process.cwd()).branch.namer // "claude"
216
+ */
217
+ export function loadConfig(cwd) {
218
+ const root = repoRoot(cwd);
219
+ const file = root === null ? null : join(root, CONFIG_PATH);
220
+ if (file === null || !existsSync(file))
221
+ return DEFAULT_CONFIG;
222
+ let raw;
223
+ try {
224
+ raw = JSON.parse(readFileSync(file, "utf8"));
225
+ }
226
+ catch (error) {
227
+ throw new ConfigError(`${CONFIG_PATH} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
228
+ }
229
+ return parseConfig(raw);
230
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * A DNS label, like portless makes them: lowercase, `[a-z0-9-]`, no repeated dashes nor at
3
+ * the edges, and over 63 characters cut with a hash of 6.
4
+ * @param value - The text.
5
+ * @returns The label.
6
+ * @example sanitizeLabel("feat+214_Odoo") // "feat-214-odoo"
7
+ */
8
+ export declare function sanitizeLabel(value: string): string;
9
+ /**
10
+ * The Compose project of a worktree: the prefix, the folder's name and a hash of its path,
11
+ * so two clones with a worktree of the same name never share containers.
12
+ * @param prefix - The project prefix (`abastio-dev`).
13
+ * @param root - The worktree.
14
+ * @returns The project name.
15
+ * @example dockerProject("acme-dev", "/repo/.claude/worktrees/feat+42-x") // "acme-dev-feat-42-x-1a2b3c"
16
+ */
17
+ export declare function dockerProject(prefix: string, root: string): string;
18
+ /**
19
+ * A path to compare with a container's folder: no trailing slash, and its parent resolved
20
+ * (symlinks too) even if the path itself is gone, as a just-deleted worktree is.
21
+ * @param path - The path.
22
+ * @returns The path.
23
+ * @example canonicalPath("/tmp/x/") // "/private/tmp/x"
24
+ */
25
+ export declare function canonicalPath(path: string): string;
26
+ /**
27
+ * Whether a project's folder still belongs to a checkout. Compose's folder is the compose
28
+ * file's, maybe a subfolder (`infra/`), so being inside a checkout is enough, as long as the
29
+ * folder has something: a deleted worktree inside the main checkout leaves its folder
30
+ * empty, and that one finds the main repo.
31
+ * @param dir - The project's folder.
32
+ * @returns True if it is alive.
33
+ * @example projectAlive("/repo/.claude/worktrees/feat+42-x/infra")
34
+ */
35
+ export declare function projectAlive(dir: string): boolean;
36
+ /** What the cleanup did. */
37
+ export interface CleanupResult {
38
+ dockerRunning: boolean;
39
+ removed: string[];
40
+ failed: string[];
41
+ }
42
+ /**
43
+ * Deletes the Docker of worktrees that no longer exist: projects with one of the prefixes
44
+ * and a suffix, whose folder no longer belongs to a checkout. With a path, the
45
+ * projects of that folder, whether it exists or not, including the volumes and networks
46
+ * left without containers (their names come from the path). `docker ps -a`, so a deleted
47
+ * worktree with stopped containers counts too.
48
+ * @param prefixes - The project prefixes of the config.
49
+ * @param path - Only this worktree's, or null for every orphan.
50
+ * @returns What was deleted, what failed, and whether Docker runs at all.
51
+ * @example dockerCleanup(["acme-dev"], null)
52
+ */
53
+ export declare function dockerCleanup(prefixes: string[], path: string | null): CleanupResult;
package/dist/docker.js ADDED
@@ -0,0 +1,153 @@
1
+ /**
2
+ * @file The Docker of worktrees: the Compose project name of each one, and the cleanup of
3
+ * the containers, volumes and networks of worktrees that no longer exist. There is no
4
+ * registry of its own: each Compose container carries its project and its folder
5
+ * (`com.docker.compose.project.working_dir`), and a project whose folder is no longer the
6
+ * root of a checkout is an orphan. Only projects with a prefix of the config are ever
7
+ * deleted, and never one without a suffix (the main checkout's). Ported from
8
+ * `wt_project_name` in Abastio's `scripts/lib/worktree.sh` and its
9
+ * `scripts/worktree-cleanup.sh`; the names come out the same, so its existing projects
10
+ * are found.
11
+ */
12
+ import { spawnSync } from "node:child_process";
13
+ import { basename, dirname, join } from "node:path";
14
+ import { existsSync, readdirSync, realpathSync } from "node:fs";
15
+ import { checkoutRoot } from "./git.js";
16
+ import { sha256 } from "./ports.js";
17
+ /** Longest DNS label, as portless cuts them (the names were born with portless). */
18
+ const MAX_LABEL = 63;
19
+ /** Hex digits of the hashes in names. */
20
+ const HASH_LENGTH = 6;
21
+ /** How long `docker info` may take: a hung Docker cannot hold a hook up. */
22
+ const DOCKER_INFO_TIMEOUT_MS = 20_000;
23
+ /** Compose's label with the project. */
24
+ const PROJECT_LABEL = "com.docker.compose.project";
25
+ /**
26
+ * A DNS label, like portless makes them: lowercase, `[a-z0-9-]`, no repeated dashes nor at
27
+ * the edges, and over 63 characters cut with a hash of 6.
28
+ * @param value - The text.
29
+ * @returns The label.
30
+ * @example sanitizeLabel("feat+214_Odoo") // "feat-214-odoo"
31
+ */
32
+ export function sanitizeLabel(value) {
33
+ const clean = value.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "");
34
+ if (clean.length <= MAX_LABEL)
35
+ return clean;
36
+ const trimmed = clean.slice(0, MAX_LABEL - HASH_LENGTH - 1).replace(/-+$/, "");
37
+ return `${trimmed}-${sha256(clean).slice(0, HASH_LENGTH)}`;
38
+ }
39
+ /**
40
+ * The Compose project of a worktree: the prefix, the folder's name and a hash of its path,
41
+ * so two clones with a worktree of the same name never share containers.
42
+ * @param prefix - The project prefix (`abastio-dev`).
43
+ * @param root - The worktree.
44
+ * @returns The project name.
45
+ * @example dockerProject("acme-dev", "/repo/.claude/worktrees/feat+42-x") // "acme-dev-feat-42-x-1a2b3c"
46
+ */
47
+ export function dockerProject(prefix, root) {
48
+ return `${prefix}-${sanitizeLabel(basename(root))}-${sha256(root).slice(0, HASH_LENGTH)}`;
49
+ }
50
+ /**
51
+ * A path to compare with a container's folder: no trailing slash, and its parent resolved
52
+ * (symlinks too) even if the path itself is gone, as a just-deleted worktree is.
53
+ * @param path - The path.
54
+ * @returns The path.
55
+ * @example canonicalPath("/tmp/x/") // "/private/tmp/x"
56
+ */
57
+ export function canonicalPath(path) {
58
+ const clean = path.replace(/\/+$/, "") || "/";
59
+ try {
60
+ return join(realpathSync(dirname(clean)), basename(clean));
61
+ }
62
+ catch {
63
+ return clean;
64
+ }
65
+ }
66
+ /**
67
+ * Runs docker and returns its output, or null if it failed.
68
+ * @param args - The arguments.
69
+ * @param timeout - Milliseconds before giving up, or undefined.
70
+ * @returns The output, trimmed.
71
+ */
72
+ function docker(args, timeout) {
73
+ const result = spawnSync("docker", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], ...(timeout === undefined ? {} : { timeout }) });
74
+ return result.status === 0 ? result.stdout.trim() : null;
75
+ }
76
+ /**
77
+ * Deletes a project's containers, volumes and networks. It tries all three even if one
78
+ * fails (a container Docker cannot kill yet).
79
+ * @param project - The project.
80
+ * @returns Whether it had nothing, was deleted, or something failed.
81
+ */
82
+ function removeProject(project) {
83
+ const filter = ["--filter", `label=${PROJECT_LABEL}=${project}`];
84
+ let ok = true;
85
+ let found = false;
86
+ for (const [list, remove] of [
87
+ [["ps", "-aq"], ["rm", "-f"]],
88
+ [["volume", "ls", "-q"], ["volume", "rm"]],
89
+ [["network", "ls", "-q"], ["network", "rm"]],
90
+ ]) {
91
+ const ids = docker([...list, ...filter]);
92
+ if (ids === null)
93
+ ok = false;
94
+ if (ids === null || ids === "")
95
+ continue;
96
+ found = true;
97
+ if (docker([...remove, ...ids.split("\n")]) === null)
98
+ ok = false;
99
+ }
100
+ if (!ok)
101
+ return "failed";
102
+ return found ? "removed" : "none";
103
+ }
104
+ /**
105
+ * Whether a project's folder still belongs to a checkout. Compose's folder is the compose
106
+ * file's, maybe a subfolder (`infra/`), so being inside a checkout is enough, as long as the
107
+ * folder has something: a deleted worktree inside the main checkout leaves its folder
108
+ * empty, and that one finds the main repo.
109
+ * @param dir - The project's folder.
110
+ * @returns True if it is alive.
111
+ * @example projectAlive("/repo/.claude/worktrees/feat+42-x/infra")
112
+ */
113
+ export function projectAlive(dir) {
114
+ return dir !== "" && existsSync(dir) && readdirSync(dir).length > 0 && checkoutRoot(dir) !== null;
115
+ }
116
+ /**
117
+ * Deletes the Docker of worktrees that no longer exist: projects with one of the prefixes
118
+ * and a suffix, whose folder no longer belongs to a checkout. With a path, the
119
+ * projects of that folder, whether it exists or not, including the volumes and networks
120
+ * left without containers (their names come from the path). `docker ps -a`, so a deleted
121
+ * worktree with stopped containers counts too.
122
+ * @param prefixes - The project prefixes of the config.
123
+ * @param path - Only this worktree's, or null for every orphan.
124
+ * @returns What was deleted, what failed, and whether Docker runs at all.
125
+ * @example dockerCleanup(["acme-dev"], null)
126
+ */
127
+ export function dockerCleanup(prefixes, path) {
128
+ const result = { dockerRunning: false, removed: [], failed: [] };
129
+ if (prefixes.length === 0)
130
+ return { ...result, dockerRunning: true };
131
+ if (docker(["info"], DOCKER_INFO_TIMEOUT_MS) === null)
132
+ return result;
133
+ result.dockerRunning = true;
134
+ const forced = path === null ? null : canonicalPath(path);
135
+ const listed = docker(["ps", "-a", "--filter", `label=${PROJECT_LABEL}`, "--format", `{{.Label "${PROJECT_LABEL}"}}\t{{.Label "${PROJECT_LABEL}.working_dir"}}`]) ?? "";
136
+ const projects = new Set();
137
+ for (const line of listed.split("\n")) {
138
+ const [project = "", dir = ""] = line.split("\t");
139
+ if (!prefixes.some((prefix) => project.startsWith(`${prefix}-`)))
140
+ continue;
141
+ if (forced !== null ? canonicalPath(dir) === forced : !projectAlive(dir))
142
+ projects.add(project);
143
+ }
144
+ if (forced !== null)
145
+ for (const prefix of prefixes)
146
+ projects.add(dockerProject(prefix, forced));
147
+ for (const project of projects) {
148
+ const outcome = removeProject(project);
149
+ if (outcome !== "none")
150
+ result[outcome].push(project);
151
+ }
152
+ return result;
153
+ }
package/dist/git.d.ts ADDED
@@ -0,0 +1,130 @@
1
+ /** What a git command gave. */
2
+ export interface GitResult {
3
+ ok: boolean;
4
+ out: string;
5
+ err: string;
6
+ }
7
+ /**
8
+ * Runs git and returns whether it worked, with its output trimmed at the end.
9
+ * @param cwd - Where to run it.
10
+ * @param args - The arguments.
11
+ * @returns The result.
12
+ * @example git("/repo", ["rev-parse", "HEAD"]).out
13
+ */
14
+ export declare function git(cwd: string, args: string[]): GitResult;
15
+ /**
16
+ * The non-empty lines of a git command's output (none if it failed).
17
+ * @param cwd - Where to run it.
18
+ * @param args - The arguments.
19
+ * @returns The lines.
20
+ * @example gitLines("/repo", ["branch", "--format=%(refname:short)"])
21
+ */
22
+ export declare function gitLines(cwd: string, args: string[]): string[];
23
+ /**
24
+ * Whether a ref exists.
25
+ * @param cwd - The checkout.
26
+ * @param ref - The full ref (`refs/heads/x`).
27
+ * @returns True if it does.
28
+ * @example hasRef("/repo", "refs/heads/main")
29
+ */
30
+ export declare function hasRef(cwd: string, ref: string): boolean;
31
+ /**
32
+ * Whether the branch exists locally.
33
+ * @param cwd - The checkout.
34
+ * @param branch - The branch.
35
+ * @returns True if it does.
36
+ * @example hasLocal("/repo", "feat/42-x")
37
+ */
38
+ export declare const hasLocal: (cwd: string, branch: string) => boolean;
39
+ /**
40
+ * Whether the branch exists in origin, as of the last fetch.
41
+ * @param cwd - The checkout.
42
+ * @param branch - The branch.
43
+ * @returns True if it does.
44
+ * @example onOrigin("/repo", "feat/42-x")
45
+ */
46
+ export declare const onOrigin: (cwd: string, branch: string) => boolean;
47
+ /**
48
+ * Commits of the branch (local, or else in origin) that are not in `origin/<base>`.
49
+ * @param cwd - The checkout.
50
+ * @param branch - The branch.
51
+ * @param base - The base branch.
52
+ * @returns How many, 0 if the branch does not exist.
53
+ * @example ownCommits("/repo", "feat/42-x", "main") // 0
54
+ */
55
+ export declare function ownCommits(cwd: string, branch: string, base: string): number;
56
+ /** A worktree as `git worktree list --porcelain` gives it. */
57
+ export interface WorktreeEntry {
58
+ path: string;
59
+ branch: string | null;
60
+ lock: string | null;
61
+ }
62
+ /**
63
+ * The worktrees of the repo, the main checkout first.
64
+ * @param cwd - Any checkout of the repo.
65
+ * @returns Path, branch and lock reason of each.
66
+ * @example worktrees("/repo")[0].path // "/repo"
67
+ */
68
+ export declare function worktrees(cwd: string): WorktreeEntry[];
69
+ /**
70
+ * Where the branch is checked out: the main checkout or a worktree.
71
+ * @param cwd - Any checkout of the repo.
72
+ * @param branch - The branch.
73
+ * @returns The path, or null if it is not checked out.
74
+ * @example checkedOutAt("/repo", "feat/42-x")
75
+ */
76
+ export declare function checkedOutAt(cwd: string, branch: string): string | null;
77
+ /**
78
+ * Why the worktree is locked.
79
+ * @param cwd - Any checkout of the repo.
80
+ * @param path - The worktree.
81
+ * @returns The reason (empty if it has none), or null if it is not locked.
82
+ * @example worktreeLock("/repo", "/repo/.claude/worktrees/feat+42-x")
83
+ */
84
+ export declare function worktreeLock(cwd: string, path: string): string | null;
85
+ /**
86
+ * Whether the checkout has no changes. Untracked files count unless told otherwise.
87
+ * @param cwd - The checkout.
88
+ * @param untracked - Whether untracked files count.
89
+ * @returns True if it is clean.
90
+ * @example isClean("/repo")
91
+ */
92
+ export declare function isClean(cwd: string, untracked?: boolean): boolean;
93
+ /**
94
+ * The branch the checkout is on.
95
+ * @param cwd - The checkout.
96
+ * @returns The branch, or null on a detached HEAD.
97
+ * @example currentBranch("/repo") // "main"
98
+ */
99
+ export declare function currentBranch(cwd: string): string | null;
100
+ /**
101
+ * Where a directory's repo lives: its shared `.git` and the main checkout, the same from
102
+ * any worktree. The main checkout is the first one git lists, which also holds in a
103
+ * submodule or with `--separate-git-dir`, where the shared `.git` is not inside it.
104
+ * @param cwd - A directory inside the repo.
105
+ * @returns The common git dir and the main checkout, or null outside git.
106
+ * @example locateRepo(process.cwd())?.mainRoot
107
+ */
108
+ export declare function locateRepo(cwd: string): {
109
+ commonDir: string;
110
+ mainRoot: string;
111
+ } | null;
112
+ /**
113
+ * The root of the checkout a directory is in (the main one or a worktree), with its
114
+ * symlinks resolved like the other paths here.
115
+ * @param cwd - A directory inside the checkout.
116
+ * @returns The root, or null outside git.
117
+ * @example checkoutRoot("/repo/.claude/worktrees/feat+42-x/src") // "/repo/.claude/worktrees/feat+42-x"
118
+ */
119
+ export declare function checkoutRoot(cwd: string): string | null;
120
+ /**
121
+ * Whether deleting the branch loses no work: all its commits are in some branch of
122
+ * origin, or it does not go past the PR's head (the branch already deleted on GitHub, or
123
+ * a squash merge).
124
+ * @param cwd - The checkout.
125
+ * @param branch - The branch.
126
+ * @param head - The merged PR's head.
127
+ * @returns True if it is safe.
128
+ * @example safeToDelete("/repo", "feat/42-x", "")
129
+ */
130
+ export declare function safeToDelete(cwd: string, branch: string, head: string): boolean;