@ramon-sg/issue-flow 0.0.0-stage → 0.1.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 (92) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +304 -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 +132 -0
  14. package/dist/cli.js +348 -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 +10 -0
  20. package/dist/commands/doctor.js +124 -0
  21. package/dist/commands/init.d.ts +27 -0
  22. package/dist/commands/init.js +213 -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 +115 -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/guard-config.d.ts +16 -0
  44. package/dist/guard-config.js +38 -0
  45. package/dist/issue-text.d.ts +128 -0
  46. package/dist/issue-text.js +210 -0
  47. package/dist/launch.d.ts +90 -0
  48. package/dist/launch.js +158 -0
  49. package/dist/output.d.ts +33 -0
  50. package/dist/output.js +37 -0
  51. package/dist/package-edits.d.ts +35 -0
  52. package/dist/package-edits.js +78 -0
  53. package/dist/plan-doc.d.ts +26 -0
  54. package/dist/plan-doc.js +93 -0
  55. package/dist/ports.d.ts +90 -0
  56. package/dist/ports.js +257 -0
  57. package/dist/propose.d.ts +42 -0
  58. package/dist/propose.js +111 -0
  59. package/dist/recipes.d.ts +43 -0
  60. package/dist/recipes.js +123 -0
  61. package/dist/session-lock.d.ts +32 -0
  62. package/dist/session-lock.js +69 -0
  63. package/dist/settings-hooks.d.ts +31 -0
  64. package/dist/settings-hooks.js +133 -0
  65. package/dist/setup-files.d.ts +89 -0
  66. package/dist/setup-files.js +138 -0
  67. package/dist/start-modes.d.ts +162 -0
  68. package/dist/start-modes.js +277 -0
  69. package/dist/start-place.d.ts +61 -0
  70. package/dist/start-place.js +301 -0
  71. package/dist/start-state.d.ts +72 -0
  72. package/dist/start-state.js +13 -0
  73. package/dist/start-summary.d.ts +42 -0
  74. package/dist/start-summary.js +217 -0
  75. package/dist/stop-apps.d.ts +14 -0
  76. package/dist/stop-apps.js +127 -0
  77. package/dist/usage.d.ts +5 -0
  78. package/dist/usage.js +54 -0
  79. package/dist/work-state.d.ts +51 -0
  80. package/dist/work-state.js +58 -0
  81. package/dist/worktree-config.d.ts +35 -0
  82. package/dist/worktree-config.js +139 -0
  83. package/dist/worktree-steps.d.ts +53 -0
  84. package/dist/worktree-steps.js +210 -0
  85. package/package.json +20 -4
  86. package/recipes/bun.json +5 -0
  87. package/recipes/direnv.json +17 -0
  88. package/recipes/docker-compose.json +30 -0
  89. package/recipes/npm.json +5 -0
  90. package/recipes/pnpm.json +5 -0
  91. package/recipes/staging-main.json +5 -0
  92. package/schema.json +281 -0
@@ -0,0 +1,277 @@
1
+ /**
2
+ * @file The ways of `issue start` that do not open Claude in a worktree of their own:
3
+ * `--adopt` (a worktree another tool made, like Orca or Paseo), `--here` (the checkout of a
4
+ * cloud session), `--cloud` (opens a cloud session that starts the issue with `--here`)
5
+ * and the cloud session noted in the PR, which `--teleport` brings back. Ported from
6
+ * `choose_adopted`, `adopt_place`, `place_here`, `place_cloud`, `prepare_branch_here`,
7
+ * `note_session_in_pr` and `launch_cloud_session` in Abastio's `scripts/start-issue.sh`.
8
+ */
9
+ import { spawnSync } from "node:child_process";
10
+ import { getPr, updatePr } from "@ramon-sg/github-rest";
11
+ import { forgetTypedAhead } from "./ask.js";
12
+ import { checkedOutAt, currentBranch, git, hasLocal, isClean, onOrigin, ownCommits } from "./git.js";
13
+ import { cloudSessionOf, fill, issueOfBranch, sameCloudSession, withCloudSession } from "./issue-text.js";
14
+ import { inPath } from "./launch.js";
15
+ import { warn } from "./output.js";
16
+ import { again, StartError } from "./start-state.js";
17
+ /** A branch name safe to put in the cloud session's prompt, which a skill may run. */
18
+ const PLAIN_BRANCH = /^[A-Za-z0-9._/-]+$/;
19
+ /** Refspec that brings every branch of origin, whatever the clone's config says. */
20
+ const ALL_HEADS = "+refs/heads/*:refs/remotes/origin/*";
21
+ /**
22
+ * The fetch of origin. In a cloud session the clone has one branch, so `--here` passes
23
+ * the refspec: without it neither the base nor the issue's branch would come.
24
+ * @param mainRoot - The main checkout.
25
+ * @param here - Whether it runs with `--here`.
26
+ * @returns True if it worked.
27
+ * @example fetchOrigin(root, false)
28
+ */
29
+ export function fetchOrigin(mainRoot, here) {
30
+ return git(mainRoot, ["fetch", "-q", "--prune", "origin", ...(here ? [ALL_HEADS] : [])]).ok;
31
+ }
32
+ /**
33
+ * The branch of the worktree `--adopt` runs in, and whether it is fresh: not in origin and
34
+ * with no commits of its own, so the tool that made the worktree just made it.
35
+ * @param startRoot - The checkout where it runs.
36
+ * @param mainRoot - The main checkout.
37
+ * @param base - The base branch.
38
+ * @returns The branch and whether it is fresh.
39
+ * @throws StartError in the main checkout or on a detached HEAD.
40
+ * @example readAdopted(wt, root, "main") // { current: "ramon/x", fresh: true }
41
+ */
42
+ export function readAdopted(startRoot, mainRoot, base) {
43
+ if (startRoot === mainRoot)
44
+ throw new StartError("--adopt runs inside a worktree, not in the main checkout.");
45
+ const current = currentBranch(startRoot);
46
+ if (current === null)
47
+ throw new StartError(`the worktree ${startRoot} is not on a branch; not adopting it.`);
48
+ return { current, fresh: !onOrigin(mainRoot, current) && ownCommits(mainRoot, current, base) === 0 };
49
+ }
50
+ /**
51
+ * The issue's work without the adopted branch when it is fresh: it is neither earlier work
52
+ * nor a used name.
53
+ * @param work - The work.
54
+ * @param adopted - The adopted branch.
55
+ * @returns The work.
56
+ * @example withoutFresh(work, { current: "ramon/x", fresh: true })
57
+ */
58
+ export function withoutFresh(work, adopted) {
59
+ if (!adopted.fresh)
60
+ return work;
61
+ const other = (name) => name !== adopted.current;
62
+ return { ...work, known: work.known.filter(other), candidates: work.candidates.filter(other) };
63
+ }
64
+ /**
65
+ * Which branch `--adopt` takes. A live branch of the issue that the worktree is on is
66
+ * resumed. A fresh branch with the issue's number is the new branch as it is, with other
67
+ * branches or without (Paseo lets you edit the name; Orca makes one without the number).
68
+ * Otherwise, with no live branches the branch is renamed to the proposed one, and with
69
+ * some it stops: the issue goes on elsewhere.
70
+ * @param mainRoot - The main checkout.
71
+ * @param work - The issue's work.
72
+ * @param adopted - The adopted branch.
73
+ * @param number - The issue.
74
+ * @returns The branch resumed, or the new one already named, or neither (propose one).
75
+ * @throws StartError if the issue has live branches elsewhere.
76
+ * @example chooseAdopted(root, work, adopted, 214) // { found: null, named: "fix/214-x" }
77
+ */
78
+ export function chooseAdopted(mainRoot, work, adopted, number) {
79
+ const { candidates } = work;
80
+ if (candidates.includes(adopted.current))
81
+ return { found: adopted.current, named: null };
82
+ if (adopted.fresh && issueOfBranch(adopted.current) === number)
83
+ return { found: null, named: adopted.current };
84
+ const [only] = candidates;
85
+ if (only === undefined)
86
+ return { found: null, named: null };
87
+ const after = "continue there with";
88
+ if (candidates.length > 1)
89
+ throw new StartError(`the issue already has ${candidates.length} branches (${candidates.join(", ")}): ${after} '${again(number)}' and delete this worktree.`);
90
+ const at = checkedOutAt(mainRoot, only);
91
+ const where = at !== null ? `at ${at}` : hasLocal(mainRoot, only) ? "local, without a worktree" : "only in origin";
92
+ throw new StartError(`the issue already has branch ${only} (${where}): ${after} '${again(number)}' and delete this worktree.`);
93
+ }
94
+ /**
95
+ * The place of `--adopt`: the worktree where it runs. If it is already on the branch there
96
+ * is nothing to check (an `--adopt` repeated or resumed); if not, it must be fresh and
97
+ * without uncommitted changes, so a branch with other work is never renamed. Untracked
98
+ * files do not count: the tool may leave its own.
99
+ * @param state - What it knows: the branch, the start checkout and the adopted branch.
100
+ * @returns The mode and the folder.
101
+ * @throws StartError if the worktree is not fresh.
102
+ * @example placeAdopted({ branch, startRoot, adopted })
103
+ */
104
+ export function placeAdopted(state) {
105
+ const { branch, startRoot, adopted } = state;
106
+ const place = { mode: "worktree", workDir: startRoot };
107
+ if (branch === adopted.current)
108
+ return place;
109
+ if (!adopted.fresh)
110
+ throw new StartError(`branch ${adopted.current} is in origin or has commits of its own: only a just-made worktree is adopted.`);
111
+ if (!isClean(startRoot, false))
112
+ throw new StartError(`the worktree ${startRoot} has uncommitted changes: only a just-made worktree is adopted.`);
113
+ return place;
114
+ }
115
+ /**
116
+ * Renames the adopted worktree's branch to the issue's (to the same name, it does
117
+ * nothing). The tool that made the worktree knows it by its path, so the rename does not
118
+ * unlink it.
119
+ * @param state - The start.
120
+ * @throws StartError if git cannot.
121
+ * @example renameAdopted(state)
122
+ */
123
+ export function renameAdopted(state) {
124
+ const current = state.adopted?.current;
125
+ if (current === undefined)
126
+ return;
127
+ if (!git(state.startRoot, ["branch", "-m", state.branch]).ok)
128
+ throw new StartError(`could not rename ${current} to ${state.branch}.`);
129
+ }
130
+ /**
131
+ * The place of `--here`: the checkout where it runs. Stops if the branch is checked out in
132
+ * another one, or this one has uncommitted changes (untracked files do not count: the
133
+ * cloud's hook leaves its own, and git says if one is in the way).
134
+ * @param mainRoot - The main checkout.
135
+ * @param startRoot - The checkout where it runs.
136
+ * @param branch - The branch.
137
+ * @param number - The issue.
138
+ * @returns The mode and the folder.
139
+ * @throws StartError if the place is taken.
140
+ * @example placeHere(root, root, "feat/42-x", 42)
141
+ */
142
+ export function placeHere(mainRoot, startRoot, branch, number) {
143
+ const at = checkedOutAt(mainRoot, branch);
144
+ if (at !== null && at !== startRoot)
145
+ throw new StartError(`branch ${branch} is already at ${at}: continue there with '${again(number)}'.`);
146
+ if (!isClean(startRoot, false))
147
+ throw new StartError("this checkout has uncommitted changes; commit or stash them first. No stash is made.");
148
+ return { mode: "here", workDir: startRoot };
149
+ }
150
+ /**
151
+ * The place of `--cloud`: the cloud session does the work, launched from the main
152
+ * checkout. Stops if the branch is checked out, or exists only locally, where the cloud
153
+ * would not see it.
154
+ * @param mainRoot - The main checkout.
155
+ * @param branch - The branch.
156
+ * @param found - The branch being resumed, or null.
157
+ * @param number - The issue.
158
+ * @returns The mode and the folder.
159
+ * @throws StartError if the cloud cannot take the branch.
160
+ * @example placeCloud(root, "feat/42-x", null, 42)
161
+ */
162
+ export function placeCloud(mainRoot, branch, found, number) {
163
+ const at = checkedOutAt(mainRoot, branch);
164
+ if (at !== null)
165
+ throw new StartError(`branch ${branch} is already at ${at}: continue there with '${again(number)}', without --cloud.`);
166
+ if (found !== null && !onOrigin(mainRoot, branch))
167
+ throw new StartError(`branch ${branch} is only local: push it or go on without --cloud.`);
168
+ return { mode: "cloud", workDir: mainRoot };
169
+ }
170
+ /**
171
+ * With `--here`, leaves the checkout on the branch without needing a local base: a new one
172
+ * comes from `origin/<base>`, an existing one from `origin/<branch>` (the fetch of every
173
+ * branch already brought it). A branch missing from origin is only local, somewhere else;
174
+ * a local branch with commits origin lacks stops it, so `-B` does not overwrite them.
175
+ * @param state - The start.
176
+ * @throws StartError if git cannot or the branch cannot be brought.
177
+ * @example prepareHere(state)
178
+ */
179
+ export function prepareHere(state) {
180
+ const { workDir, branch, base, mainRoot } = state;
181
+ const checkout = (args) => {
182
+ if (!git(workDir, ["checkout", "-q", ...args]).ok)
183
+ throw new StartError(`could not leave ${branch} in this checkout.`);
184
+ };
185
+ if (state.found === null)
186
+ return checkout(["--no-track", "-B", branch, `origin/${base}`]);
187
+ if (!onOrigin(mainRoot, branch))
188
+ throw new StartError(`branch ${branch} is only local (on another machine or checkout): push it from there and run this again.`);
189
+ if (hasLocal(mainRoot, branch) && git(mainRoot, ["rev-list", "--count", branch, "--not", `origin/${branch}`]).out !== "0")
190
+ throw new StartError(`the local branch ${branch} has commits that are not in origin; push them or sort it out by hand.`);
191
+ checkout(["-B", branch, `origin/${branch}`]);
192
+ }
193
+ /**
194
+ * The cloud session noted in the PR's body, or null (no PR, none noted, or the PR cannot
195
+ * be read).
196
+ * @param repo - The repo.
197
+ * @param pr - The PR, or null.
198
+ * @returns The id, or null.
199
+ * @example await prCloudSession("acme/widgets", 7)
200
+ */
201
+ export async function prCloudSession(repo, pr) {
202
+ if (pr === null)
203
+ return null;
204
+ const found = await getPr(repo, pr).catch(() => null);
205
+ return found === null ? null : cloudSessionOf(found.body);
206
+ }
207
+ /**
208
+ * The end of `--here`: notes this cloud session in the PR (if it runs in one and the PR
209
+ * does not have it yet), so `--teleport` finds it from any machine, and prints the
210
+ * `Prompt:` line the skill follows. It does not open Claude: it already runs in one.
211
+ * @param state - The start.
212
+ * @param pr - The PR.
213
+ * @param prompt - What the session goes on with.
214
+ * @example await finishHere(state, 900, prompt)
215
+ */
216
+ export async function finishHere(state, pr, prompt) {
217
+ const { out, options } = state;
218
+ const id = options.remoteSession;
219
+ if (id !== null && !sameCloudSession(state.cloudSession, id)) {
220
+ try {
221
+ const found = await getPr(options.repo, pr);
222
+ if (found === null)
223
+ throw new Error(`PR #${pr} is gone`);
224
+ await updatePr(options.repo, pr, { body: withCloudSession(found.body, id) });
225
+ }
226
+ catch {
227
+ warn(out, `could not note the session in PR #${pr}.`);
228
+ }
229
+ }
230
+ out.stdout(`Ready: issue #${options.number} is on branch ${state.branch}, with its draft PR.\nPrompt: ${prompt}\n`);
231
+ }
232
+ /**
233
+ * The flags with which the cloud session picks the branch: `--new` if the issue has live
234
+ * branches and a new one was chosen here (`--new`, or `n` in the terminal), `--resume
235
+ * <branch>` if one of several was chosen. With one live branch resumed, none: the session
236
+ * resumes it.
237
+ * @param state - The start.
238
+ * @returns The flags, with a space before each.
239
+ * @throws StartError if the branch has characters a shell would read (anyone who can push
240
+ * names branches).
241
+ * @example cloudFlags(state) // " --resume fix/214-old"
242
+ */
243
+ export function cloudFlags(state) {
244
+ const { found, work } = state;
245
+ if (found === null && work.candidates.length > 0)
246
+ return " --new";
247
+ if (work.candidates.length <= 1 || found === null)
248
+ return "";
249
+ if (!PLAIN_BRANCH.test(found))
250
+ throw new StartError(`branch ${JSON.stringify(found)} has characters the cloud session's prompt cannot carry; rename it.`);
251
+ return ` --resume ${found}`;
252
+ }
253
+ /**
254
+ * `--cloud` after the yes: opens the cloud session with the skill that starts the issue
255
+ * there, in the foreground and with the terminal (`claude --cloud` needs one), from the
256
+ * main checkout. Without `claude` or with `--no-launch`, says how to open it.
257
+ * @param state - The start.
258
+ * @param out - Where to write.
259
+ * @returns The exit code.
260
+ * @throws StartError if the session does not open.
261
+ * @example launchCloud(state, out)
262
+ */
263
+ export function launchCloud(state, out) {
264
+ const { options, mainRoot, base } = state;
265
+ if (!options.launch || !inPath("claude")) {
266
+ out.stdout(`Ready. To open the cloud session: ${again(options.number, "--cloud")}\n`);
267
+ return 0;
268
+ }
269
+ if (currentBranch(mainRoot) !== base)
270
+ warn(out, `the main checkout is not on ${base}: the session clones its current branch.`);
271
+ const prompt = fill(options.config.start.cloudPrompt, { number: String(options.number), flags: cloudFlags(state) });
272
+ forgetTypedAhead();
273
+ if (spawnSync("claude", ["--cloud", prompt], { cwd: mainRoot, stdio: "inherit" }).status !== 0)
274
+ throw new StartError("could not open the cloud session.");
275
+ out.stdout(`To bring it here: ${again(options.number, "--teleport")}\n`);
276
+ return 0;
277
+ }
@@ -0,0 +1,61 @@
1
+ import { type StartMode } from "./issue-text.js";
2
+ import { type Output } from "./output.js";
3
+ import type { Lock } from "./session-lock.js";
4
+ import { type StartState } from "./start-state.js";
5
+ /** The file listing what a new worktree copies from the main checkout. */
6
+ export declare const WORKTREE_INCLUDE = ".worktreeinclude";
7
+ /**
8
+ * Where the work goes. If the branch is checked out somewhere, there; if not, in a new
9
+ * worktree or, with `--branch`, in the main checkout, which must be clean.
10
+ * @param mainRoot - The main checkout.
11
+ * @param branch - The branch.
12
+ * @param branchMode - Whether `--branch` was given.
13
+ * @param worktreesDir - The config's folder for worktrees.
14
+ * @returns The mode and the folder.
15
+ * @throws StartError if the place is taken.
16
+ * @example choosePlace(root, "feat/42-x", false, ".claude/worktrees")
17
+ */
18
+ export declare function choosePlace(mainRoot: string, branch: string, branchMode: boolean, worktreesDir: string): {
19
+ mode: StartMode;
20
+ workDir: string;
21
+ };
22
+ /**
23
+ * Leaves the branch in its place: a new worktree (from the base, or on the existing
24
+ * branch), or the main checkout on it. A new branch in the main checkout needs it on the
25
+ * base and in step with origin.
26
+ * @param state - The start.
27
+ * @throws StartError if git cannot, with what to run in a terminal if a sandbox stops it.
28
+ * @example prepareBranch(state)
29
+ */
30
+ export declare function prepareBranch(state: StartState): void;
31
+ /**
32
+ * The start commit, the push, the draft PR, the reopening of a closed one and the
33
+ * assignment, as far as they are missing. The commit takes nothing staged (`--only`). It
34
+ * runs before copying the included files and opening Claude, so the public trace is there
35
+ * even if what follows fails.
36
+ * @param state - The start.
37
+ * @returns The PR's number.
38
+ * @throws StartError if the commit, the push or the PR fail.
39
+ * @example await publish(state)
40
+ */
41
+ export declare function publish(state: StartState): Promise<number>;
42
+ /**
43
+ * Copies to a new worktree the files of `.worktreeinclude` it lacks, like `claude
44
+ * --worktree` does and `git worktree add` does not: only files git ignores (the ones a
45
+ * checkout cannot bring, like credentials), keeping their permissions and never
46
+ * overwriting.
47
+ * @param from - The main checkout.
48
+ * @param to - The worktree.
49
+ * @param out - Where a failed copy is reported.
50
+ * @example copyWorktreeIncludes(root, wt, out)
51
+ */
52
+ export declare function copyWorktreeIncludes(from: string, to: string, out: Output): void;
53
+ /**
54
+ * After Claude: if the PR was merged and nothing would be lost, offers to delete the
55
+ * worktree and the branch (or, in the main checkout, to go back to the base); if not,
56
+ * says how to resume.
57
+ * @param state - The start.
58
+ * @param lock - The worktree lock (shared: another session uses it).
59
+ * @example await afterSession(state, lock)
60
+ */
61
+ export declare function afterSession(state: StartState, lock: Lock): Promise<void>;
@@ -0,0 +1,301 @@
1
+ /**
2
+ * @file Where the work of an issue happens and what `issue start` changes: the worktree or
3
+ * the main checkout on the branch, the start commit, the push, the draft PR, the
4
+ * assignment, the files of `.worktreeinclude`, and the cleanup after a merged PR. Ported
5
+ * from `choose_place`, `prepare_branch`, `publish` and `after_session` in Abastio's
6
+ * `scripts/start-issue.sh`.
7
+ */
8
+ import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync } from "node:fs";
9
+ import { dirname, join, relative } from "node:path";
10
+ import { assign, createPr, findPr, getPr, updatePr } from "@ramon-sg/github-rest";
11
+ import { confirm } from "./ask.js";
12
+ import { checkedOutAt, currentBranch, git, gitLines, hasLocal, isClean, onOrigin, safeToDelete } from "./git.js";
13
+ import { bodyToPartOf, dirName, fill, prBody } from "./issue-text.js";
14
+ import { forgetSession } from "./launch.js";
15
+ import { warn } from "./output.js";
16
+ import { again, StartError } from "./start-state.js";
17
+ import { needsStartCommit, stepPlaceOf } from "./start-summary.js";
18
+ import { teardownWorktree } from "./worktree-steps.js";
19
+ import { openPrsElsewhere } from "./work-state.js";
20
+ /** The file listing what a new worktree copies from the main checkout. */
21
+ export const WORKTREE_INCLUDE = ".worktreeinclude";
22
+ /** Characters that make a path segment a pattern. */
23
+ const GLOB = /[*?[]/;
24
+ /**
25
+ * Where the work goes. If the branch is checked out somewhere, there; if not, in a new
26
+ * worktree or, with `--branch`, in the main checkout, which must be clean.
27
+ * @param mainRoot - The main checkout.
28
+ * @param branch - The branch.
29
+ * @param branchMode - Whether `--branch` was given.
30
+ * @param worktreesDir - The config's folder for worktrees.
31
+ * @returns The mode and the folder.
32
+ * @throws StartError if the place is taken.
33
+ * @example choosePlace(root, "feat/42-x", false, ".claude/worktrees")
34
+ */
35
+ export function choosePlace(mainRoot, branch, branchMode, worktreesDir) {
36
+ const at = checkedOutAt(mainRoot, branch);
37
+ if (at !== null) {
38
+ const mode = at === mainRoot ? "branch" : "worktree";
39
+ if (branchMode && mode === "worktree")
40
+ throw new StartError(`branch ${branch} is already in the worktree ${at}; run without --branch.`);
41
+ return { mode, workDir: at };
42
+ }
43
+ if (branchMode) {
44
+ if (!isClean(mainRoot))
45
+ throw new StartError("the main checkout has uncommitted changes; commit or stash them first. No stash is made.");
46
+ return { mode: "branch", workDir: mainRoot };
47
+ }
48
+ const workDir = join(mainRoot, worktreesDir, dirName(branch));
49
+ if (existsSync(workDir))
50
+ throw new StartError(`${workDir} already exists and is not a worktree of ${branch}; check it by hand.`);
51
+ return { mode: "worktree", workDir };
52
+ }
53
+ /**
54
+ * Runs git and stops with the message if it fails.
55
+ * @param cwd - Where.
56
+ * @param args - The arguments.
57
+ * @param what - What failed, for the message.
58
+ * @throws StartError if git fails.
59
+ */
60
+ function mustGit(cwd, args, what) {
61
+ const result = git(cwd, args);
62
+ if (!result.ok)
63
+ throw new StartError(`${what}${result.err === "" ? "" : `: ${result.err}`}`);
64
+ }
65
+ /**
66
+ * Leaves the branch in its place: a new worktree (from the base, or on the existing
67
+ * branch), or the main checkout on it. A new branch in the main checkout needs it on the
68
+ * base and in step with origin.
69
+ * @param state - The start.
70
+ * @throws StartError if git cannot, with what to run in a terminal if a sandbox stops it.
71
+ * @example prepareBranch(state)
72
+ */
73
+ export function prepareBranch(state) {
74
+ const { mainRoot, branch, base, found } = state;
75
+ if (state.mode === "worktree") {
76
+ if (existsSync(state.workDir))
77
+ return;
78
+ const args = found === null ? ["-b", branch, `origin/${base}`] : [branch];
79
+ const result = git(mainRoot, ["worktree", "add", "-q", state.workDir, ...args]);
80
+ if (!result.ok)
81
+ throw new StartError(`could not create the worktree: ${result.err}. If a sandbox stopped it, run in a terminal: ${again(state.options.number, "--yes")}`);
82
+ return;
83
+ }
84
+ if (currentBranch(mainRoot) === branch)
85
+ return;
86
+ if (!isClean(mainRoot))
87
+ throw new StartError("the main checkout has uncommitted changes; commit or stash them first. No stash is made.");
88
+ if (found !== null)
89
+ return mustGit(mainRoot, ["checkout", "-q", branch], `could not check out ${branch}`);
90
+ if (currentBranch(mainRoot) !== base)
91
+ throw new StartError(`the main checkout is not on ${base}.`);
92
+ if (git(mainRoot, ["rev-list", "--count", `origin/${base}..${base}`]).out !== "0")
93
+ throw new StartError(`local ${base} has commits that are not in origin/${base}; sort it out by hand.`);
94
+ mustGit(mainRoot, ["merge", "-q", "--ff-only", `origin/${base}`], `local ${base} diverged from origin/${base}`);
95
+ mustGit(mainRoot, ["checkout", "-q", "-b", branch], `could not create ${branch}`);
96
+ }
97
+ /**
98
+ * Turns into `Part of` the open PRs of other branches that closed the issue, because it
99
+ * now has more than one branch. Never stops: a PR it cannot edit gets a warning.
100
+ * @param state - The start.
101
+ */
102
+ async function markOthersPartOf(state) {
103
+ const { repo, number } = state.options;
104
+ for (const other of openPrsElsewhere(state.work.prs, state.branch)) {
105
+ try {
106
+ const pr = await getPr(repo, other);
107
+ if (pr === null)
108
+ continue;
109
+ const body = bodyToPartOf(pr.body, number);
110
+ if (body !== pr.body)
111
+ await updatePr(repo, other, { body });
112
+ }
113
+ catch {
114
+ warn(state.out, `could not turn PR #${other} into Part of #${number}; edit it by hand.`);
115
+ }
116
+ }
117
+ }
118
+ /**
119
+ * The start commit, the push, the draft PR, the reopening of a closed one and the
120
+ * assignment, as far as they are missing. The commit takes nothing staged (`--only`). It
121
+ * runs before copying the included files and opening Claude, so the public trace is there
122
+ * even if what follows fails.
123
+ * @param state - The start.
124
+ * @returns The PR's number.
125
+ * @throws StartError if the commit, the push or the PR fail.
126
+ * @example await publish(state)
127
+ */
128
+ export async function publish(state) {
129
+ const { workDir, branch, options } = state;
130
+ const commit = needsStartCommit(state);
131
+ if (commit) {
132
+ const message = fill(options.config.start.commitMessage, { number: String(options.number) });
133
+ mustGit(workDir, ["commit", "-q", "--allow-empty", "--only", "-m", message], "could not make the start commit");
134
+ }
135
+ if (commit || !onOrigin(workDir, branch))
136
+ mustGit(workDir, ["push", "-q", "-u", "origin", branch], `could not push ${branch}`);
137
+ if (!git(workDir, ["rev-parse", "-q", "--verify", "@{u}"]).ok)
138
+ git(workDir, ["branch", "-q", `--set-upstream-to=origin/${branch}`, branch]);
139
+ let number = state.pr?.number ?? null;
140
+ if (number === null) {
141
+ const created = await createPr(options.repo, {
142
+ head: branch,
143
+ base: state.base,
144
+ title: state.issue.title,
145
+ body: prBody(options.config.start.prBody, options.number, state.kind),
146
+ draft: true,
147
+ });
148
+ state.out.stdout(`${created.url}\n`);
149
+ number = created.number;
150
+ if (state.kind === "part")
151
+ await markOthersPartOf(state);
152
+ }
153
+ if (state.pr?.state === "CLOSED")
154
+ await updatePr(options.repo, number, { state: "open" });
155
+ if (state.issue.assignees.length === 0) {
156
+ try {
157
+ await assign(options.repo, options.number, ["@me"]);
158
+ }
159
+ catch {
160
+ warn(state.out, `could not assign issue #${options.number} to you.`);
161
+ }
162
+ }
163
+ return number;
164
+ }
165
+ /**
166
+ * The paths that match a pattern of `.worktreeinclude`, relative to the folder. Each
167
+ * segment may have `*`, `?` or `[…]`, like the shell; names starting with `.` match only
168
+ * a pattern that starts with `.`.
169
+ * @param root - The folder.
170
+ * @param pattern - The pattern.
171
+ * @returns The paths that exist.
172
+ */
173
+ function expand(root, pattern) {
174
+ let paths = [""];
175
+ for (const segment of pattern.split("/").filter((part) => part !== "")) {
176
+ if (!GLOB.test(segment)) {
177
+ paths = paths.map((path) => join(path, segment)).filter((path) => existsSync(join(root, path)));
178
+ continue;
179
+ }
180
+ const source = segment.replace(/[.+^${}()|\\]/g, "\\$&").replaceAll("*", ".*").replaceAll("?", ".");
181
+ const matcher = new RegExp(`^${source}$`);
182
+ paths = paths.flatMap((path) => {
183
+ const dir = join(root, path);
184
+ if (!existsSync(dir))
185
+ return [];
186
+ return readdirSync(dir)
187
+ .filter((name) => matcher.test(name) && (!name.startsWith(".") || segment.startsWith(".")))
188
+ .map((name) => join(path, name));
189
+ });
190
+ }
191
+ return paths.filter((path) => path !== "");
192
+ }
193
+ /**
194
+ * Copies to a new worktree the files of `.worktreeinclude` it lacks, like `claude
195
+ * --worktree` does and `git worktree add` does not: only files git ignores (the ones a
196
+ * checkout cannot bring, like credentials), keeping their permissions and never
197
+ * overwriting.
198
+ * @param from - The main checkout.
199
+ * @param to - The worktree.
200
+ * @param out - Where a failed copy is reported.
201
+ * @example copyWorktreeIncludes(root, wt, out)
202
+ */
203
+ export function copyWorktreeIncludes(from, to, out) {
204
+ const list = join(from, WORKTREE_INCLUDE);
205
+ if (!existsSync(list))
206
+ return;
207
+ for (const raw of readFileSync(list, "utf8").split("\n")) {
208
+ const pattern = raw.replace(/\r$/, "").trim();
209
+ if (pattern === "" || pattern.startsWith("#"))
210
+ continue;
211
+ let files;
212
+ try {
213
+ files = expand(from, pattern);
214
+ }
215
+ catch {
216
+ warn(out, `skipping "${pattern}" of ${WORKTREE_INCLUDE}: it is not a valid pattern.`);
217
+ continue;
218
+ }
219
+ for (const file of files) {
220
+ if (existsSync(join(to, file)) || !git(from, ["check-ignore", "-q", file]).ok)
221
+ continue;
222
+ try {
223
+ mkdirSync(dirname(join(to, file)), { recursive: true });
224
+ cpSync(join(from, file), join(to, file), { recursive: true });
225
+ }
226
+ catch {
227
+ warn(out, `could not copy ${file} to the worktree.`);
228
+ }
229
+ }
230
+ }
231
+ }
232
+ /** Ignored files named at most in the question before deleting a worktree. */
233
+ const MAX_LISTED = 5;
234
+ /**
235
+ * The ignored files of a worktree, which `git worktree remove` deletes and a clean status
236
+ * does not show: local notes, a local database, `settings.local.json`.
237
+ * @param workDir - The worktree.
238
+ * @returns Their paths (a folder counts once).
239
+ */
240
+ function ignoredFiles(workDir) {
241
+ return gitLines(workDir, ["ls-files", "--others", "--ignored", "--exclude-standard", "--directory"]);
242
+ }
243
+ /**
244
+ * The reason not to clean up after a merged PR, or null if it can be.
245
+ * @param state - The start.
246
+ * @param lock - The worktree lock.
247
+ * @param head - The merged PR's head.
248
+ * @returns The message, or null.
249
+ */
250
+ function keepReason(state, lock, head) {
251
+ if (lock.shared)
252
+ return "The PR was merged, but another session uses the worktree: not offering to delete it.";
253
+ if (!isClean(state.workDir))
254
+ return `The PR was merged, but ${state.workDir} has uncommitted changes: deleting nothing.`;
255
+ if (!safeToDelete(state.mainRoot, state.branch, head))
256
+ return `The PR was merged, but ${state.branch} has commits that are not in origin: deleting nothing.`;
257
+ return null;
258
+ }
259
+ /**
260
+ * After Claude: if the PR was merged and nothing would be lost, offers to delete the
261
+ * worktree and the branch (or, in the main checkout, to go back to the base); if not,
262
+ * says how to resume.
263
+ * @param state - The start.
264
+ * @param lock - The worktree lock (shared: another session uses it).
265
+ * @example await afterSession(state, lock)
266
+ */
267
+ export async function afterSession(state, lock) {
268
+ const { out, mainRoot, branch, commonDir, base } = state;
269
+ const pr = await findPr(state.options.repo, branch).catch(() => null);
270
+ if (pr?.state !== "MERGED") {
271
+ out.stdout(`The session is saved. To resume it: ${again(state.options.number)}\n`);
272
+ return;
273
+ }
274
+ if (!hasLocal(mainRoot, branch)) {
275
+ forgetSession(commonDir, branch);
276
+ out.stdout(`The PR was merged and the local branch ${branch} no longer exists: nothing to delete.\n`);
277
+ return;
278
+ }
279
+ const reason = keepReason(state, lock, pr.head);
280
+ if (reason !== null)
281
+ return out.stdout(`${reason}\n`);
282
+ if (state.mode === "worktree") {
283
+ const where = relative(mainRoot, state.workDir);
284
+ const lost = ignoredFiles(state.workDir);
285
+ const also = lost.length === 0 ? "" : ` Its ignored files go too: ${lost.slice(0, MAX_LISTED).join(", ")}${lost.length > MAX_LISTED ? ", …" : ""}.`;
286
+ if (!(await confirm(out, `The PR was merged. Delete the worktree ${where} and the local branch ${branch}?${also}`)))
287
+ return;
288
+ await teardownWorktree(stepPlaceOf(state));
289
+ mustGit(mainRoot, ["worktree", "remove", state.workDir], `could not delete ${where} (its teardown already ran)`);
290
+ }
291
+ else {
292
+ if (!(await confirm(out, `The PR was merged. Go back to ${base} and delete the local branch ${branch}?`)))
293
+ return;
294
+ mustGit(mainRoot, ["checkout", "-q", base], `could not check out ${base}`);
295
+ const updated = git(mainRoot, ["fetch", "-q", "origin"]).ok && git(mainRoot, ["merge", "-q", "--ff-only", `origin/${base}`]).ok;
296
+ if (!updated)
297
+ warn(out, `could not update ${base}; do it by hand.`);
298
+ }
299
+ mustGit(mainRoot, ["branch", "-q", "-D", branch], `could not delete ${branch}`);
300
+ forgetSession(commonDir, branch);
301
+ }