taskplane 0.0.1 → 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.
- package/LICENSE +21 -0
- package/README.md +2 -20
- package/bin/taskplane.mjs +706 -0
- package/dashboard/public/app.js +900 -0
- package/dashboard/public/index.html +92 -0
- package/dashboard/public/style.css +924 -0
- package/dashboard/server.cjs +531 -0
- package/extensions/task-orchestrator.ts +28 -0
- package/extensions/task-runner.ts +1923 -0
- package/extensions/taskplane/abort.ts +466 -0
- package/extensions/taskplane/config.ts +102 -0
- package/extensions/taskplane/discovery.ts +988 -0
- package/extensions/taskplane/engine.ts +758 -0
- package/extensions/taskplane/execution.ts +1752 -0
- package/extensions/taskplane/extension.ts +577 -0
- package/extensions/taskplane/formatting.ts +718 -0
- package/extensions/taskplane/git.ts +38 -0
- package/extensions/taskplane/index.ts +22 -0
- package/extensions/taskplane/merge.ts +795 -0
- package/extensions/taskplane/messages.ts +134 -0
- package/extensions/taskplane/persistence.ts +1121 -0
- package/extensions/taskplane/resume.ts +1092 -0
- package/extensions/taskplane/sessions.ts +92 -0
- package/extensions/taskplane/types.ts +1514 -0
- package/extensions/taskplane/waves.ts +900 -0
- package/extensions/taskplane/worktree.ts +1624 -0
- package/package.json +50 -4
- package/skills/create-taskplane-task/SKILL.md +326 -0
- package/skills/create-taskplane-task/references/context-template.md +78 -0
- package/skills/create-taskplane-task/references/prompt-template.md +246 -0
- package/templates/agents/task-merger.md +256 -0
- package/templates/agents/task-reviewer.md +81 -0
- package/templates/agents/task-worker.md +140 -0
- package/templates/config/task-orchestrator.yaml +89 -0
- package/templates/config/task-runner.yaml +99 -0
- package/templates/tasks/CONTEXT.md +31 -0
- package/templates/tasks/EXAMPLE-001-hello-world/PROMPT.md +90 -0
- package/templates/tasks/EXAMPLE-001-hello-world/STATUS.md +73 -0
|
@@ -0,0 +1,1624 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Worktree CRUD, bulk ops, branch protection, preflight
|
|
3
|
+
* @module orch/worktree
|
|
4
|
+
*/
|
|
5
|
+
import { existsSync, readdirSync, realpathSync } from "fs";
|
|
6
|
+
import { execSync } from "child_process";
|
|
7
|
+
import { join, basename, resolve } from "path";
|
|
8
|
+
|
|
9
|
+
import { execLog } from "./execution.ts";
|
|
10
|
+
import { runGit } from "./git.ts";
|
|
11
|
+
import { DEFAULT_ORCHESTRATOR_CONFIG, WorktreeError } from "./types.ts";
|
|
12
|
+
import type { BulkWorktreeError, CreateLaneWorktreesResult, CreateWorktreeOptions, OrchestratorConfig, PreflightCheck, PreflightResult, RemoveAllWorktreesResult, RemoveWorktreeOutcome, RemoveWorktreeResult, WorktreeInfo } from "./types.ts";
|
|
13
|
+
|
|
14
|
+
// ── Worktree Helpers ─────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Generate branch name per §4.4 naming convention.
|
|
18
|
+
* Format: task/lane-{N}-{batchId}
|
|
19
|
+
*
|
|
20
|
+
* @param laneNumber - Lane number (1-indexed)
|
|
21
|
+
* @param batchId - Batch ID timestamp (e.g. "20260308T111750")
|
|
22
|
+
*/
|
|
23
|
+
export function generateBranchName(laneNumber: number, batchId: string): string {
|
|
24
|
+
return `task/lane-${laneNumber}-${batchId}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Resolve the base directory where worktrees are created, based on config.
|
|
29
|
+
*
|
|
30
|
+
* Two modes (from `worktree_location` config):
|
|
31
|
+
* "sibling" → resolve(repoRoot, "..") — worktrees sit next to the repo
|
|
32
|
+
* "subdirectory" → resolve(repoRoot, ".worktrees") — worktrees inside the repo (gitignored)
|
|
33
|
+
*
|
|
34
|
+
* The returned path is the parent directory; individual worktree dirs are
|
|
35
|
+
* created as children (e.g., `<base>/{prefix}-1` → `<base>/taskplane-wt-1`).
|
|
36
|
+
*
|
|
37
|
+
* @param repoRoot - Absolute path to the main repository root
|
|
38
|
+
* @param config - Orchestrator config (reads `worktree_location`)
|
|
39
|
+
*/
|
|
40
|
+
export function resolveWorktreeBasePath(
|
|
41
|
+
repoRoot: string,
|
|
42
|
+
config: OrchestratorConfig,
|
|
43
|
+
): string {
|
|
44
|
+
const location = config.orchestrator.worktree_location;
|
|
45
|
+
if (location === "sibling") {
|
|
46
|
+
return resolve(repoRoot, "..");
|
|
47
|
+
}
|
|
48
|
+
// Default to subdirectory for any non-"sibling" value (including "subdirectory")
|
|
49
|
+
return resolve(repoRoot, ".worktrees");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Generate worktree path based on config's worktree_location setting.
|
|
54
|
+
*
|
|
55
|
+
* Naming rule: basename = {prefix}-{N}
|
|
56
|
+
* Sibling mode: ../{prefix}-{N} (e.g. ../taskplane-wt-1)
|
|
57
|
+
* Subdirectory mode: .worktrees/{prefix}-{N} (e.g. .worktrees/taskplane-wt-1)
|
|
58
|
+
*
|
|
59
|
+
* Uses path.resolve() for Windows path normalization (R002 requirement).
|
|
60
|
+
*
|
|
61
|
+
* @param prefix - Directory prefix (e.g. "taskplane-wt")
|
|
62
|
+
* @param laneNumber - Lane number (1-indexed)
|
|
63
|
+
* @param repoRoot - Absolute path to the main repository root
|
|
64
|
+
* @param config - Orchestrator config (optional; defaults to subdirectory mode)
|
|
65
|
+
*/
|
|
66
|
+
export function generateWorktreePath(
|
|
67
|
+
prefix: string,
|
|
68
|
+
laneNumber: number,
|
|
69
|
+
repoRoot: string,
|
|
70
|
+
config?: OrchestratorConfig,
|
|
71
|
+
): string {
|
|
72
|
+
const effectiveConfig = config || DEFAULT_ORCHESTRATOR_CONFIG;
|
|
73
|
+
const basePath = resolveWorktreeBasePath(repoRoot, effectiveConfig);
|
|
74
|
+
return resolve(basePath, `${prefix}-${laneNumber}`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Parse `git worktree list --porcelain` output into structured entries.
|
|
79
|
+
*
|
|
80
|
+
* Porcelain output format (one block per worktree, separated by blank lines):
|
|
81
|
+
* worktree /absolute/path
|
|
82
|
+
* HEAD <sha>
|
|
83
|
+
* branch refs/heads/<name>
|
|
84
|
+
* [detached]
|
|
85
|
+
*
|
|
86
|
+
* @param cwd - Directory to run git from (must be in a git repo)
|
|
87
|
+
*/
|
|
88
|
+
export interface ParsedWorktreeEntry {
|
|
89
|
+
path: string;
|
|
90
|
+
head: string;
|
|
91
|
+
branch: string | null; // null if detached HEAD
|
|
92
|
+
bare: boolean;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function parseWorktreeList(cwd: string): ParsedWorktreeEntry[] {
|
|
96
|
+
const result = runGit(["worktree", "list", "--porcelain"], cwd);
|
|
97
|
+
if (!result.ok) return [];
|
|
98
|
+
|
|
99
|
+
const entries: ParsedWorktreeEntry[] = [];
|
|
100
|
+
const blocks = result.stdout.split(/\n\n+/);
|
|
101
|
+
|
|
102
|
+
for (const block of blocks) {
|
|
103
|
+
if (!block.trim()) continue;
|
|
104
|
+
|
|
105
|
+
const lines = block.trim().split("\n");
|
|
106
|
+
let path = "";
|
|
107
|
+
let head = "";
|
|
108
|
+
let branch: string | null = null;
|
|
109
|
+
let bare = false;
|
|
110
|
+
|
|
111
|
+
for (const line of lines) {
|
|
112
|
+
if (line.startsWith("worktree ")) {
|
|
113
|
+
path = line.slice("worktree ".length).trim();
|
|
114
|
+
} else if (line.startsWith("HEAD ")) {
|
|
115
|
+
head = line.slice("HEAD ".length).trim();
|
|
116
|
+
} else if (line.startsWith("branch ")) {
|
|
117
|
+
// "branch refs/heads/develop" → "develop"
|
|
118
|
+
const ref = line.slice("branch ".length).trim();
|
|
119
|
+
branch = ref.replace(/^refs\/heads\//, "");
|
|
120
|
+
} else if (line.trim() === "bare") {
|
|
121
|
+
bare = true;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (path) {
|
|
126
|
+
entries.push({ path, head, branch, bare });
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return entries;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Normalize a filesystem path for reliable comparison on Windows.
|
|
135
|
+
*
|
|
136
|
+
* On Windows, paths may contain 8.3 short names (e.g., `HENRYL~1` instead
|
|
137
|
+
* of `HenryLach`). Node's `resolve()` does NOT expand these, but git
|
|
138
|
+
* always reports full long names. This causes path comparison failures.
|
|
139
|
+
*
|
|
140
|
+
* Uses `fs.realpathSync.native()` to expand 8.3 names when the path exists,
|
|
141
|
+
* falls back to `resolve()` for non-existent paths (e.g., pre-creation checks).
|
|
142
|
+
*
|
|
143
|
+
* All comparisons are also lowercased and slash-normalized.
|
|
144
|
+
*/
|
|
145
|
+
export function normalizePath(p: string): string {
|
|
146
|
+
let expanded: string;
|
|
147
|
+
try {
|
|
148
|
+
// realpathSync.native expands 8.3 short names on Windows
|
|
149
|
+
expanded = realpathSync.native(resolve(p));
|
|
150
|
+
} catch {
|
|
151
|
+
// Path doesn't exist yet — fall back to resolve()
|
|
152
|
+
expanded = resolve(p);
|
|
153
|
+
}
|
|
154
|
+
return expanded.replace(/\\/g, "/").toLowerCase();
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Check if a given path is already registered as a git worktree.
|
|
159
|
+
* Uses `git worktree list --porcelain` for reliable detection.
|
|
160
|
+
*
|
|
161
|
+
* Path comparison is case-insensitive, slash-normalized, and expands
|
|
162
|
+
* Windows 8.3 short names (e.g., HENRYL~1 → HenryLach) for reliable
|
|
163
|
+
* matching against git's long-name output.
|
|
164
|
+
*/
|
|
165
|
+
export function isRegisteredWorktree(targetPath: string, cwd: string): boolean {
|
|
166
|
+
const entries = parseWorktreeList(cwd);
|
|
167
|
+
const normalized = normalizePath(targetPath);
|
|
168
|
+
return entries.some(
|
|
169
|
+
(e) => normalizePath(e.path) === normalized,
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
// ── Worktree CRUD Operations ─────────────────────────────────────────
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Create a new git worktree for a lane.
|
|
178
|
+
*
|
|
179
|
+
* Executes `git worktree add -b <branch> <path> <baseBranch>` from the
|
|
180
|
+
* main repository root. This creates a new branch based on baseBranch
|
|
181
|
+
* and checks it out in the worktree directory.
|
|
182
|
+
*
|
|
183
|
+
* Pre-checks (R002 requirements):
|
|
184
|
+
* 1. Validates baseBranch exists (`git rev-parse --verify`)
|
|
185
|
+
* 2. Checks target path is not already a registered worktree
|
|
186
|
+
* 3. Checks target path is not a non-empty non-worktree directory
|
|
187
|
+
*
|
|
188
|
+
* Post-creation verification:
|
|
189
|
+
* - Branch points to baseBranch HEAD commit
|
|
190
|
+
* - Correct branch is checked out in the worktree
|
|
191
|
+
*
|
|
192
|
+
* @param opts - Creation options (laneNumber, batchId, baseBranch, prefix)
|
|
193
|
+
* @param repoRoot - Absolute path to the main repository root
|
|
194
|
+
* @returns - WorktreeInfo on success
|
|
195
|
+
* @throws - WorktreeError with stable error code on failure
|
|
196
|
+
*/
|
|
197
|
+
export function createWorktree(opts: CreateWorktreeOptions, repoRoot: string): WorktreeInfo {
|
|
198
|
+
const { laneNumber, batchId, baseBranch, prefix, config } = opts;
|
|
199
|
+
|
|
200
|
+
const branch = generateBranchName(laneNumber, batchId);
|
|
201
|
+
const worktreePath = generateWorktreePath(prefix, laneNumber, repoRoot, config);
|
|
202
|
+
|
|
203
|
+
// ── Pre-check 1: Validate base branch exists ─────────────────
|
|
204
|
+
const baseBranchCheck = runGit(
|
|
205
|
+
["rev-parse", "--verify", `refs/heads/${baseBranch}`],
|
|
206
|
+
repoRoot,
|
|
207
|
+
);
|
|
208
|
+
if (!baseBranchCheck.ok) {
|
|
209
|
+
throw new WorktreeError(
|
|
210
|
+
"WORKTREE_INVALID_BASE",
|
|
211
|
+
`Base branch "${baseBranch}" does not exist locally. ` +
|
|
212
|
+
`Verify the branch exists: git branch --list ${baseBranch}`,
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
const baseBranchHead = baseBranchCheck.stdout.trim();
|
|
216
|
+
|
|
217
|
+
// ── Pre-check 2: Check if path is already a registered worktree
|
|
218
|
+
if (isRegisteredWorktree(worktreePath, repoRoot)) {
|
|
219
|
+
throw new WorktreeError(
|
|
220
|
+
"WORKTREE_PATH_IS_WORKTREE",
|
|
221
|
+
`Path "${worktreePath}" is already registered as a git worktree. ` +
|
|
222
|
+
`Remove it first: git worktree remove "${worktreePath}"`,
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ── Pre-check 3: Check if path exists and is non-empty (non-worktree dir)
|
|
227
|
+
if (existsSync(worktreePath)) {
|
|
228
|
+
try {
|
|
229
|
+
const entries = readdirSync(worktreePath);
|
|
230
|
+
if (entries.length > 0) {
|
|
231
|
+
throw new WorktreeError(
|
|
232
|
+
"WORKTREE_PATH_NOT_EMPTY",
|
|
233
|
+
`Path "${worktreePath}" exists and is not empty. ` +
|
|
234
|
+
`It is not a registered git worktree. Remove or rename it before creating a worktree here.`,
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
} catch (err) {
|
|
238
|
+
if (err instanceof WorktreeError) throw err;
|
|
239
|
+
// If we can't read the path (e.g., it's a file not a directory), error
|
|
240
|
+
throw new WorktreeError(
|
|
241
|
+
"WORKTREE_PATH_NOT_EMPTY",
|
|
242
|
+
`Path "${worktreePath}" exists but cannot be read as a directory.`,
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ── Pre-check 4: Check if branch already exists ──────────────
|
|
248
|
+
const branchCheck = runGit(
|
|
249
|
+
["rev-parse", "--verify", `refs/heads/${branch}`],
|
|
250
|
+
repoRoot,
|
|
251
|
+
);
|
|
252
|
+
if (branchCheck.ok) {
|
|
253
|
+
throw new WorktreeError(
|
|
254
|
+
"WORKTREE_BRANCH_EXISTS",
|
|
255
|
+
`Branch "${branch}" already exists. ` +
|
|
256
|
+
`This may indicate a stale worktree from a previous batch. ` +
|
|
257
|
+
`Delete it: git branch -D ${branch}`,
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// ── Create worktree ──────────────────────────────────────────
|
|
262
|
+
const createResult = runGit(
|
|
263
|
+
["worktree", "add", "-b", branch, worktreePath, baseBranch],
|
|
264
|
+
repoRoot,
|
|
265
|
+
);
|
|
266
|
+
if (!createResult.ok) {
|
|
267
|
+
throw new WorktreeError(
|
|
268
|
+
"WORKTREE_GIT_ERROR",
|
|
269
|
+
`Failed to create worktree at "${worktreePath}" on branch "${branch}" ` +
|
|
270
|
+
`from "${baseBranch}": ${createResult.stderr}`,
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// ── Post-creation verification (R002 requirements) ───────────
|
|
275
|
+
// Verify 1: Correct branch is checked out
|
|
276
|
+
const headBranchResult = runGit(
|
|
277
|
+
["rev-parse", "--abbrev-ref", "HEAD"],
|
|
278
|
+
worktreePath,
|
|
279
|
+
);
|
|
280
|
+
if (!headBranchResult.ok || headBranchResult.stdout !== branch) {
|
|
281
|
+
throw new WorktreeError(
|
|
282
|
+
"WORKTREE_VERIFY_FAILED",
|
|
283
|
+
`Verification failed: expected branch "${branch}" checked out ` +
|
|
284
|
+
`in worktree, but got "${headBranchResult.stdout || "(unknown)"}".`,
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Verify 2: Branch points to baseBranch HEAD commit
|
|
289
|
+
const headCommitResult = runGit(["rev-parse", "HEAD"], worktreePath);
|
|
290
|
+
if (!headCommitResult.ok || headCommitResult.stdout !== baseBranchHead) {
|
|
291
|
+
throw new WorktreeError(
|
|
292
|
+
"WORKTREE_VERIFY_FAILED",
|
|
293
|
+
`Verification failed: worktree HEAD (${headCommitResult.stdout?.slice(0, 8) || "?"}) ` +
|
|
294
|
+
`does not match baseBranch "${baseBranch}" HEAD (${baseBranchHead.slice(0, 8)}).`,
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
return {
|
|
299
|
+
path: resolve(worktreePath),
|
|
300
|
+
branch,
|
|
301
|
+
laneNumber,
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Reset an existing worktree to point at a new target branch/commit.
|
|
307
|
+
*
|
|
308
|
+
* Used after a wave merge to update a lane's worktree to the latest
|
|
309
|
+
* develop HEAD, or any other target branch. The existing lane branch
|
|
310
|
+
* name is preserved — only its target commit changes.
|
|
311
|
+
*
|
|
312
|
+
* Strategy: `git checkout -B <laneBranch> <targetBranch>` inside the worktree.
|
|
313
|
+
* This repoints the existing lane branch to the target commit and checks it out.
|
|
314
|
+
*
|
|
315
|
+
* Precondition checks (R003 requirements):
|
|
316
|
+
* 1. Worktree path exists on disk
|
|
317
|
+
* 2. Path is a registered git worktree (via parseWorktreeList)
|
|
318
|
+
* 3. Target branch resolves (git rev-parse --verify)
|
|
319
|
+
* 4. Working tree is clean (git status --porcelain returns empty)
|
|
320
|
+
*
|
|
321
|
+
* Post-reset verification:
|
|
322
|
+
* - HEAD equals targetBranch commit
|
|
323
|
+
* - Current branch equals worktree.branch (lane branch preserved)
|
|
324
|
+
*
|
|
325
|
+
* Idempotency: Resetting to the same target commit succeeds (no-op semantically).
|
|
326
|
+
*
|
|
327
|
+
* @param worktree - WorktreeInfo returned by createWorktree()
|
|
328
|
+
* @param targetBranch - Branch name to reset to (e.g. "develop")
|
|
329
|
+
* @param repoRoot - Absolute path to the main repository root
|
|
330
|
+
* @returns - Updated WorktreeInfo (same branch/laneNumber, same path)
|
|
331
|
+
* @throws - WorktreeError with stable error code on failure
|
|
332
|
+
*/
|
|
333
|
+
export function resetWorktree(
|
|
334
|
+
worktree: WorktreeInfo,
|
|
335
|
+
targetBranch: string,
|
|
336
|
+
repoRoot: string,
|
|
337
|
+
): WorktreeInfo {
|
|
338
|
+
const { path: worktreePath, branch, laneNumber } = worktree;
|
|
339
|
+
|
|
340
|
+
// ── Pre-check 1: Worktree path exists on disk ────────────────
|
|
341
|
+
if (!existsSync(worktreePath)) {
|
|
342
|
+
throw new WorktreeError(
|
|
343
|
+
"WORKTREE_NOT_FOUND",
|
|
344
|
+
`Worktree path "${worktreePath}" does not exist on disk. ` +
|
|
345
|
+
`It may have been removed externally.`,
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// ── Pre-check 2: Path is a registered git worktree ───────────
|
|
350
|
+
if (!isRegisteredWorktree(worktreePath, repoRoot)) {
|
|
351
|
+
throw new WorktreeError(
|
|
352
|
+
"WORKTREE_NOT_REGISTERED",
|
|
353
|
+
`Path "${worktreePath}" exists but is not a registered git worktree. ` +
|
|
354
|
+
`It may have been removed from git tracking. Check: git worktree list`,
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// ── Pre-check 3: Target branch resolves ──────────────────────
|
|
359
|
+
const targetCheck = runGit(
|
|
360
|
+
["rev-parse", "--verify", `refs/heads/${targetBranch}`],
|
|
361
|
+
repoRoot,
|
|
362
|
+
);
|
|
363
|
+
if (!targetCheck.ok) {
|
|
364
|
+
throw new WorktreeError(
|
|
365
|
+
"WORKTREE_INVALID_BASE",
|
|
366
|
+
`Target branch "${targetBranch}" does not exist locally. ` +
|
|
367
|
+
`Verify the branch exists: git branch --list ${targetBranch}`,
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
const targetCommit = targetCheck.stdout.trim();
|
|
371
|
+
|
|
372
|
+
// ── Pre-check 4: Working tree is clean ───────────────────────
|
|
373
|
+
const statusCheck = runGit(["status", "--porcelain"], worktreePath);
|
|
374
|
+
if (!statusCheck.ok) {
|
|
375
|
+
throw new WorktreeError(
|
|
376
|
+
"WORKTREE_GIT_ERROR",
|
|
377
|
+
`Failed to check working tree status in "${worktreePath}": ${statusCheck.stderr}`,
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
if (statusCheck.stdout.length > 0) {
|
|
381
|
+
throw new WorktreeError(
|
|
382
|
+
"WORKTREE_DIRTY",
|
|
383
|
+
`Worktree at "${worktreePath}" has uncommitted changes. ` +
|
|
384
|
+
`Workers must commit or discard all changes before a reset can proceed. ` +
|
|
385
|
+
`Dirty files:\n${statusCheck.stdout}`,
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// ── Reset: git checkout -B <laneBranch> <targetBranch> ───────
|
|
390
|
+
const resetResult = runGit(
|
|
391
|
+
["checkout", "-B", branch, targetBranch],
|
|
392
|
+
worktreePath,
|
|
393
|
+
);
|
|
394
|
+
if (!resetResult.ok) {
|
|
395
|
+
throw new WorktreeError(
|
|
396
|
+
"WORKTREE_RESET_FAILED",
|
|
397
|
+
`Failed to reset worktree at "${worktreePath}" ` +
|
|
398
|
+
`(branch "${branch}" → "${targetBranch}"): ${resetResult.stderr}`,
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// ── Post-reset verification ──────────────────────────────────
|
|
403
|
+
// Verify 1: Current branch equals expected lane branch
|
|
404
|
+
const headBranchResult = runGit(
|
|
405
|
+
["rev-parse", "--abbrev-ref", "HEAD"],
|
|
406
|
+
worktreePath,
|
|
407
|
+
);
|
|
408
|
+
if (!headBranchResult.ok || headBranchResult.stdout !== branch) {
|
|
409
|
+
throw new WorktreeError(
|
|
410
|
+
"WORKTREE_VERIFY_FAILED",
|
|
411
|
+
`Post-reset verification failed: expected branch "${branch}" ` +
|
|
412
|
+
`checked out, but got "${headBranchResult.stdout || "(unknown)"}".`,
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// Verify 2: HEAD equals targetBranch commit
|
|
417
|
+
const headCommitResult = runGit(["rev-parse", "HEAD"], worktreePath);
|
|
418
|
+
if (!headCommitResult.ok || headCommitResult.stdout !== targetCommit) {
|
|
419
|
+
throw new WorktreeError(
|
|
420
|
+
"WORKTREE_VERIFY_FAILED",
|
|
421
|
+
`Post-reset verification failed: worktree HEAD ` +
|
|
422
|
+
`(${headCommitResult.stdout?.slice(0, 8) || "?"}) does not match ` +
|
|
423
|
+
`target "${targetBranch}" commit (${targetCommit.slice(0, 8)}).`,
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// Return updated WorktreeInfo (branch and laneNumber preserved)
|
|
428
|
+
return {
|
|
429
|
+
path: resolve(worktreePath),
|
|
430
|
+
branch,
|
|
431
|
+
laneNumber,
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Sleep for a given number of milliseconds (synchronous busy-wait).
|
|
437
|
+
*
|
|
438
|
+
* Uses execSync("ping") on Windows / ("sleep") on Unix as a synchronous
|
|
439
|
+
* sleep mechanism since this module uses synchronous git operations.
|
|
440
|
+
* The busy-wait is acceptable because retry waits are bounded (max 16s)
|
|
441
|
+
* and this function is only called during cleanup, not hot paths.
|
|
442
|
+
*
|
|
443
|
+
* @param ms - Milliseconds to sleep
|
|
444
|
+
*/
|
|
445
|
+
export function sleepSync(ms: number): void {
|
|
446
|
+
const seconds = Math.ceil(ms / 1000);
|
|
447
|
+
try {
|
|
448
|
+
// Cross-platform synchronous sleep
|
|
449
|
+
if (process.platform === "win32") {
|
|
450
|
+
execSync(`ping -n ${seconds + 1} 127.0.0.1 > nul`, { stdio: "ignore", timeout: ms + 5000 });
|
|
451
|
+
} else {
|
|
452
|
+
execSync(`sleep ${seconds}`, { stdio: "ignore", timeout: ms + 5000 });
|
|
453
|
+
}
|
|
454
|
+
} catch {
|
|
455
|
+
// Timeout or error — acceptable, we just needed a delay
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Determine if a git worktree remove error is retriable.
|
|
461
|
+
*
|
|
462
|
+
* Retriable errors are typically filesystem/lock issues on Windows
|
|
463
|
+
* where another process (antivirus, IDE, explorer) holds file handles.
|
|
464
|
+
*
|
|
465
|
+
* Terminal (non-retriable) errors are git usage errors like
|
|
466
|
+
* "not a valid worktree" or missing arguments.
|
|
467
|
+
*
|
|
468
|
+
* @param stderr - Error output from git worktree remove
|
|
469
|
+
* @returns true if the error is likely transient and worth retrying
|
|
470
|
+
*/
|
|
471
|
+
export function isRetriableRemoveError(stderr: string): boolean {
|
|
472
|
+
const lower = stderr.toLowerCase();
|
|
473
|
+
// Windows file locking patterns
|
|
474
|
+
if (lower.includes("cannot lock") || lower.includes("unable to access")) return true;
|
|
475
|
+
if (lower.includes("permission denied")) return true;
|
|
476
|
+
if (lower.includes("device or resource busy")) return true;
|
|
477
|
+
if (lower.includes("the process cannot access")) return true;
|
|
478
|
+
if (lower.includes("used by another process")) return true;
|
|
479
|
+
if (lower.includes("directory not empty")) return true;
|
|
480
|
+
if (lower.includes("failed to remove")) return true;
|
|
481
|
+
// Generic I/O errors that may be transient
|
|
482
|
+
if (lower.includes("i/o error")) return true;
|
|
483
|
+
if (lower.includes("input/output error")) return true;
|
|
484
|
+
return false;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* Remove a git worktree and clean up its associated branch.
|
|
489
|
+
*
|
|
490
|
+
* Executes `git worktree remove --force <path>` from the main repository
|
|
491
|
+
* root, then handles branch cleanup based on merge status.
|
|
492
|
+
*
|
|
493
|
+
* Branch protection (when targetBranch is provided):
|
|
494
|
+
* - If branch has unmerged commits vs targetBranch → preserves as `saved/<branch>`
|
|
495
|
+
* instead of deleting. Returns `{ branchPreserved: true, savedBranch: "saved/..." }`
|
|
496
|
+
* - If fully merged or no new commits → deletes normally
|
|
497
|
+
* - If targetBranch is missing or git error → skips deletion (safe default)
|
|
498
|
+
*
|
|
499
|
+
* Idempotent behavior:
|
|
500
|
+
* - If path is already missing AND branch is already gone → returns
|
|
501
|
+
* `{ removed: false, alreadyRemoved: true, branchDeleted: true }`
|
|
502
|
+
* - If path is already missing BUT branch has unmerged commits → preserves branch,
|
|
503
|
+
* returns `{ removed: false, alreadyRemoved: true, branchPreserved: true }`
|
|
504
|
+
*
|
|
505
|
+
* Retry policy (Windows file locking):
|
|
506
|
+
* - Up to 5 retries with exponential backoff: 1s, 2s, 4s, 8s, 16s
|
|
507
|
+
* - Only retriable errors (filesystem/lock) trigger retries
|
|
508
|
+
* - Terminal git errors (invalid worktree, bad args) fail immediately
|
|
509
|
+
* - Branch deletion is not retried (single attempt)
|
|
510
|
+
*
|
|
511
|
+
* Post-removal verification:
|
|
512
|
+
* - Path no longer exists on disk
|
|
513
|
+
* - Path no longer registered via `git worktree list --porcelain`
|
|
514
|
+
*
|
|
515
|
+
* @param worktree - WorktreeInfo returned by createWorktree()
|
|
516
|
+
* @param repoRoot - Absolute path to the main repository root
|
|
517
|
+
* @param targetBranch - Optional target branch for unmerged commit detection (e.g. "develop")
|
|
518
|
+
* @returns RemoveWorktreeResult with status flags
|
|
519
|
+
* @throws WorktreeError with WORKTREE_REMOVE_RETRY_EXHAUSTED if all retries fail
|
|
520
|
+
* @throws WorktreeError with WORKTREE_REMOVE_FAILED for terminal (non-retriable) errors
|
|
521
|
+
* @throws WorktreeError with WORKTREE_BRANCH_DELETE_FAILED if branch cleanup fails
|
|
522
|
+
*/
|
|
523
|
+
export function removeWorktree(
|
|
524
|
+
worktree: WorktreeInfo,
|
|
525
|
+
repoRoot: string,
|
|
526
|
+
targetBranch?: string,
|
|
527
|
+
): RemoveWorktreeResult {
|
|
528
|
+
const { path: worktreePath, branch } = worktree;
|
|
529
|
+
|
|
530
|
+
const pathExists = existsSync(worktreePath);
|
|
531
|
+
const isRegistered = isRegisteredWorktree(worktreePath, repoRoot);
|
|
532
|
+
|
|
533
|
+
// ── Handle already-removed states ────────────────────────────
|
|
534
|
+
if (!pathExists && !isRegistered) {
|
|
535
|
+
// Path is gone and not registered. Clean up stale branch if any.
|
|
536
|
+
const branchResult = ensureBranchDeleted(branch, repoRoot, worktreePath, targetBranch);
|
|
537
|
+
return {
|
|
538
|
+
removed: false,
|
|
539
|
+
alreadyRemoved: true,
|
|
540
|
+
branchDeleted: branchResult.deleted,
|
|
541
|
+
branchPreserved: branchResult.preserved,
|
|
542
|
+
savedBranch: branchResult.savedBranch,
|
|
543
|
+
unmergedCount: branchResult.unmergedCount,
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// If path is missing but still registered in git, prune first
|
|
548
|
+
if (!pathExists && isRegistered) {
|
|
549
|
+
// `git worktree prune` removes stale worktree entries
|
|
550
|
+
runGit(["worktree", "prune"], repoRoot);
|
|
551
|
+
const branchResult = ensureBranchDeleted(branch, repoRoot, worktreePath, targetBranch);
|
|
552
|
+
return {
|
|
553
|
+
removed: false,
|
|
554
|
+
alreadyRemoved: true,
|
|
555
|
+
branchDeleted: branchResult.deleted,
|
|
556
|
+
branchPreserved: branchResult.preserved,
|
|
557
|
+
savedBranch: branchResult.savedBranch,
|
|
558
|
+
unmergedCount: branchResult.unmergedCount,
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// ── Attempt removal with retry/backoff ───────────────────────
|
|
563
|
+
const RETRY_DELAYS_MS = [1000, 2000, 4000, 8000, 16000];
|
|
564
|
+
const MAX_ATTEMPTS = RETRY_DELAYS_MS.length + 1; // first attempt + retries
|
|
565
|
+
|
|
566
|
+
let lastError = "";
|
|
567
|
+
|
|
568
|
+
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
|
569
|
+
const removeResult = runGit(
|
|
570
|
+
["worktree", "remove", "--force", worktreePath],
|
|
571
|
+
repoRoot,
|
|
572
|
+
);
|
|
573
|
+
|
|
574
|
+
if (removeResult.ok) {
|
|
575
|
+
// Successful removal — proceed to branch cleanup
|
|
576
|
+
break;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
lastError = removeResult.stderr;
|
|
580
|
+
|
|
581
|
+
// Check if error is terminal (non-retriable)
|
|
582
|
+
if (!isRetriableRemoveError(lastError)) {
|
|
583
|
+
throw new WorktreeError(
|
|
584
|
+
"WORKTREE_REMOVE_FAILED",
|
|
585
|
+
`Failed to remove worktree at "${worktreePath}" ` +
|
|
586
|
+
`(terminal error, not retried): ${lastError}`,
|
|
587
|
+
);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// If we've exhausted all retries, throw
|
|
591
|
+
if (attempt >= MAX_ATTEMPTS) {
|
|
592
|
+
throw new WorktreeError(
|
|
593
|
+
"WORKTREE_REMOVE_RETRY_EXHAUSTED",
|
|
594
|
+
`Failed to remove worktree at "${worktreePath}" after ` +
|
|
595
|
+
`${MAX_ATTEMPTS} attempts. Last error: ${lastError}. ` +
|
|
596
|
+
`This is likely a Windows file locking issue. ` +
|
|
597
|
+
`Close any programs accessing "${worktreePath}" and try again.`,
|
|
598
|
+
);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// Wait before retrying (exponential backoff)
|
|
602
|
+
const delayMs = RETRY_DELAYS_MS[attempt - 1];
|
|
603
|
+
sleepSync(delayMs);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// ── Post-removal verification ────────────────────────────────
|
|
607
|
+
if (existsSync(worktreePath)) {
|
|
608
|
+
throw new WorktreeError(
|
|
609
|
+
"WORKTREE_VERIFY_FAILED",
|
|
610
|
+
`Post-removal verification failed: path "${worktreePath}" ` +
|
|
611
|
+
`still exists on disk after successful git worktree remove.`,
|
|
612
|
+
);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
if (isRegisteredWorktree(worktreePath, repoRoot)) {
|
|
616
|
+
// Try pruning stale entries
|
|
617
|
+
runGit(["worktree", "prune"], repoRoot);
|
|
618
|
+
if (isRegisteredWorktree(worktreePath, repoRoot)) {
|
|
619
|
+
throw new WorktreeError(
|
|
620
|
+
"WORKTREE_VERIFY_FAILED",
|
|
621
|
+
`Post-removal verification failed: path "${worktreePath}" ` +
|
|
622
|
+
`is still registered as a git worktree after removal and prune.`,
|
|
623
|
+
);
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// ── Branch cleanup (single attempt, fail loud if still present) ─
|
|
628
|
+
const branchResult = ensureBranchDeleted(branch, repoRoot, worktreePath, targetBranch);
|
|
629
|
+
|
|
630
|
+
return {
|
|
631
|
+
removed: true,
|
|
632
|
+
alreadyRemoved: false,
|
|
633
|
+
branchDeleted: branchResult.deleted,
|
|
634
|
+
branchPreserved: branchResult.preserved,
|
|
635
|
+
savedBranch: branchResult.savedBranch,
|
|
636
|
+
unmergedCount: branchResult.unmergedCount,
|
|
637
|
+
};
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* Result of ensureBranchDeleted — either deleted or preserved.
|
|
642
|
+
*/
|
|
643
|
+
export interface EnsureBranchDeletedResult {
|
|
644
|
+
/** Whether the branch was deleted */
|
|
645
|
+
deleted: boolean;
|
|
646
|
+
/** Whether the branch was preserved (unmerged commits) */
|
|
647
|
+
preserved: boolean;
|
|
648
|
+
/** Saved branch name (if preserved) */
|
|
649
|
+
savedBranch?: string;
|
|
650
|
+
/** Number of unmerged commits (if preserved) */
|
|
651
|
+
unmergedCount?: number;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/**
|
|
655
|
+
* Ensure a lane branch is deleted — or preserved if it has unmerged commits.
|
|
656
|
+
*
|
|
657
|
+
* When `targetBranch` is provided, checks for unmerged commits first:
|
|
658
|
+
* - If unmerged: preserves via `saved/<branch>` ref instead of deleting
|
|
659
|
+
* - If fully merged or no unmerged: deletes normally
|
|
660
|
+
*
|
|
661
|
+
* When `targetBranch` is omitted (backward compat), deletes unconditionally
|
|
662
|
+
* using deleteBranchBestEffort() with the original fail-loud semantics.
|
|
663
|
+
*
|
|
664
|
+
* Upgrades a persistent deletion failure into a hard WorktreeError so
|
|
665
|
+
* callers cannot silently proceed with stale lane branches.
|
|
666
|
+
*/
|
|
667
|
+
export function ensureBranchDeleted(
|
|
668
|
+
branch: string,
|
|
669
|
+
repoRoot: string,
|
|
670
|
+
worktreePath: string,
|
|
671
|
+
targetBranch?: string,
|
|
672
|
+
): EnsureBranchDeletedResult {
|
|
673
|
+
// If targetBranch provided, check for unmerged commits before deleting
|
|
674
|
+
if (targetBranch) {
|
|
675
|
+
const preserveResult = preserveBranch(branch, targetBranch, repoRoot);
|
|
676
|
+
|
|
677
|
+
switch (preserveResult.action) {
|
|
678
|
+
case "preserved":
|
|
679
|
+
case "already-preserved": {
|
|
680
|
+
// Branch had unmerged commits — saved ref exists, now delete the original
|
|
681
|
+
// This implements rename semantics: create saved + delete original
|
|
682
|
+
const sourceDeleted = deleteBranchBestEffort(branch, repoRoot);
|
|
683
|
+
return {
|
|
684
|
+
deleted: sourceDeleted,
|
|
685
|
+
preserved: true,
|
|
686
|
+
savedBranch: preserveResult.savedBranch,
|
|
687
|
+
unmergedCount: preserveResult.unmergedCount,
|
|
688
|
+
};
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
case "fully-merged":
|
|
692
|
+
case "no-branch":
|
|
693
|
+
// Safe to delete — fall through to deletion below
|
|
694
|
+
break;
|
|
695
|
+
|
|
696
|
+
case "error":
|
|
697
|
+
// Preservation check failed — log but still try to preserve by skipping deletion
|
|
698
|
+
// This is the safe default: don't delete if we can't verify merge status
|
|
699
|
+
return {
|
|
700
|
+
deleted: false,
|
|
701
|
+
preserved: false,
|
|
702
|
+
};
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// No unmerged commits (or no targetBranch) — delete normally
|
|
707
|
+
const branchDeleted = deleteBranchBestEffort(branch, repoRoot);
|
|
708
|
+
if (!branchDeleted) {
|
|
709
|
+
throw new WorktreeError(
|
|
710
|
+
"WORKTREE_BRANCH_DELETE_FAILED",
|
|
711
|
+
`Worktree "${worktreePath}" was removed, but failed to delete lane branch ` +
|
|
712
|
+
`"${branch}". Delete it manually: git branch -D ${branch}`,
|
|
713
|
+
);
|
|
714
|
+
}
|
|
715
|
+
return { deleted: true, preserved: false };
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
/**
|
|
719
|
+
* Delete a branch with best-effort semantics.
|
|
720
|
+
*
|
|
721
|
+
* Uses `git branch -D` (force delete) since lane branches are ephemeral
|
|
722
|
+
* and may not have been merged anywhere.
|
|
723
|
+
*
|
|
724
|
+
* "Branch not found" is treated as idempotent success (returns true).
|
|
725
|
+
*
|
|
726
|
+
* @param branch - Branch name to delete
|
|
727
|
+
* @param repoRoot - Repository root directory
|
|
728
|
+
* @returns true if branch was deleted or was already absent
|
|
729
|
+
*/
|
|
730
|
+
export function deleteBranchBestEffort(branch: string, repoRoot: string): boolean {
|
|
731
|
+
// Check if branch exists first
|
|
732
|
+
const branchCheck = runGit(
|
|
733
|
+
["rev-parse", "--verify", `refs/heads/${branch}`],
|
|
734
|
+
repoRoot,
|
|
735
|
+
);
|
|
736
|
+
|
|
737
|
+
if (!branchCheck.ok) {
|
|
738
|
+
// Branch doesn't exist — idempotent success
|
|
739
|
+
return true;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
// Force delete (lane branches are ephemeral, may not be merged)
|
|
743
|
+
const deleteResult = runGit(["branch", "-D", branch], repoRoot);
|
|
744
|
+
|
|
745
|
+
if (deleteResult.ok) {
|
|
746
|
+
return true;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
// If delete failed but branch is now gone (race condition), treat as success
|
|
750
|
+
const recheckResult = runGit(
|
|
751
|
+
["rev-parse", "--verify", `refs/heads/${branch}`],
|
|
752
|
+
repoRoot,
|
|
753
|
+
);
|
|
754
|
+
if (!recheckResult.ok) {
|
|
755
|
+
return true;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
// Branch still exists and delete failed — return false
|
|
759
|
+
return false;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
|
|
763
|
+
// ── Branch Protection Helpers ────────────────────────────────────────
|
|
764
|
+
|
|
765
|
+
/** Typed error codes for unmerged commit checks */
|
|
766
|
+
export type UnmergedCommitsErrorCode =
|
|
767
|
+
| "BRANCH_NOT_FOUND"
|
|
768
|
+
| "TARGET_BRANCH_MISSING"
|
|
769
|
+
| "UNMERGED_COUNT_FAILED"
|
|
770
|
+
| "UNMERGED_COUNT_PARSE_FAILED";
|
|
771
|
+
|
|
772
|
+
/**
|
|
773
|
+
* Result of checking for unmerged commits on a branch.
|
|
774
|
+
*/
|
|
775
|
+
export interface UnmergedCommitsResult {
|
|
776
|
+
/** Whether the check succeeded (git command ran without error) */
|
|
777
|
+
ok: boolean;
|
|
778
|
+
/** Number of commits on `branch` not reachable from `targetBranch` */
|
|
779
|
+
count: number;
|
|
780
|
+
/** Typed error code if check failed */
|
|
781
|
+
code?: UnmergedCommitsErrorCode;
|
|
782
|
+
/** Error message if check failed */
|
|
783
|
+
error?: string;
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
/**
|
|
787
|
+
* Check if a branch has commits not reachable from a target branch.
|
|
788
|
+
*
|
|
789
|
+
* Uses `git rev-list --count <targetBranch>..<branch>` which is
|
|
790
|
+
* Windows-safe (no shell pipes). Returns the count of unmerged commits.
|
|
791
|
+
*
|
|
792
|
+
* Pure logic with git dependency — designed so the git call can be
|
|
793
|
+
* tested in integration tests with real repos, while the decision
|
|
794
|
+
* logic is tested via the count result.
|
|
795
|
+
*
|
|
796
|
+
* @param branch - Branch to check for unmerged commits
|
|
797
|
+
* @param targetBranch - Target branch to compare against (e.g. "develop")
|
|
798
|
+
* @param repoRoot - Repository root directory
|
|
799
|
+
* @returns UnmergedCommitsResult with count and status
|
|
800
|
+
*/
|
|
801
|
+
export function hasUnmergedCommits(
|
|
802
|
+
branch: string,
|
|
803
|
+
targetBranch: string,
|
|
804
|
+
repoRoot: string,
|
|
805
|
+
): UnmergedCommitsResult {
|
|
806
|
+
// Verify branch exists
|
|
807
|
+
const branchCheck = runGit(
|
|
808
|
+
["rev-parse", "--verify", `refs/heads/${branch}`],
|
|
809
|
+
repoRoot,
|
|
810
|
+
);
|
|
811
|
+
if (!branchCheck.ok) {
|
|
812
|
+
return { ok: false, count: 0, code: "BRANCH_NOT_FOUND", error: `Branch "${branch}" does not exist` };
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
// Verify target branch exists
|
|
816
|
+
const targetCheck = runGit(
|
|
817
|
+
["rev-parse", "--verify", `refs/heads/${targetBranch}`],
|
|
818
|
+
repoRoot,
|
|
819
|
+
);
|
|
820
|
+
if (!targetCheck.ok) {
|
|
821
|
+
return { ok: false, count: 0, code: "TARGET_BRANCH_MISSING", error: `Target branch "${targetBranch}" does not exist` };
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
// Count commits on branch not reachable from target
|
|
825
|
+
const countResult = runGit(
|
|
826
|
+
["rev-list", "--count", `${targetBranch}..${branch}`],
|
|
827
|
+
repoRoot,
|
|
828
|
+
);
|
|
829
|
+
if (!countResult.ok) {
|
|
830
|
+
return { ok: false, count: 0, code: "UNMERGED_COUNT_FAILED", error: `Failed to count unmerged commits: ${countResult.stderr}` };
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
const count = parseInt(countResult.stdout.trim(), 10);
|
|
834
|
+
if (isNaN(count)) {
|
|
835
|
+
return { ok: false, count: 0, code: "UNMERGED_COUNT_PARSE_FAILED", error: `Failed to parse commit count: "${countResult.stdout}"` };
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
return { ok: true, count };
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
/**
|
|
842
|
+
* Compute the saved branch name for a given original branch.
|
|
843
|
+
*
|
|
844
|
+
* Pure function — no side effects. Maps a branch name to its saved
|
|
845
|
+
* counterpart under the `saved/` namespace.
|
|
846
|
+
*
|
|
847
|
+
* Examples:
|
|
848
|
+
* "task/lane-1-20260308T111750" → "saved/task/lane-1-20260308T111750"
|
|
849
|
+
* "feature/my-branch" → "saved/feature/my-branch"
|
|
850
|
+
*
|
|
851
|
+
* @param originalBranch - The branch name to compute a saved name for
|
|
852
|
+
* @returns The saved branch name (always prefixed with "saved/")
|
|
853
|
+
*/
|
|
854
|
+
export function computeSavedBranchName(originalBranch: string): string {
|
|
855
|
+
return `saved/${originalBranch}`;
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
/**
|
|
859
|
+
* Result of saved branch collision resolution.
|
|
860
|
+
*/
|
|
861
|
+
export interface SavedBranchResolution {
|
|
862
|
+
/** The action to take */
|
|
863
|
+
action: "create" | "keep-existing" | "create-suffixed";
|
|
864
|
+
/** The final saved branch name to use */
|
|
865
|
+
savedName: string;
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
/**
|
|
869
|
+
* Resolve a collision when a saved branch name already exists.
|
|
870
|
+
*
|
|
871
|
+
* Decision table:
|
|
872
|
+
* - saved ref absent → action: "create", use savedName
|
|
873
|
+
* - saved ref exists, same SHA → action: "keep-existing", use existing savedName
|
|
874
|
+
* - saved ref exists, different SHA → action: "create-suffixed", append timestamp
|
|
875
|
+
*
|
|
876
|
+
* Pure function — no side effects. All git state is passed in as parameters.
|
|
877
|
+
*
|
|
878
|
+
* @param savedName - The desired saved branch name (e.g. "saved/task/lane-1-...")
|
|
879
|
+
* @param existingSHA - SHA of existing saved branch (empty string if absent)
|
|
880
|
+
* @param newSHA - SHA of the branch being preserved
|
|
881
|
+
* @param timestamp - ISO timestamp for suffix (injectable for testability)
|
|
882
|
+
* @returns SavedBranchResolution with action and final name
|
|
883
|
+
*/
|
|
884
|
+
export function resolveSavedBranchCollision(
|
|
885
|
+
savedName: string,
|
|
886
|
+
existingSHA: string,
|
|
887
|
+
newSHA: string,
|
|
888
|
+
timestamp?: string,
|
|
889
|
+
): SavedBranchResolution {
|
|
890
|
+
// Saved ref doesn't exist — create it
|
|
891
|
+
if (!existingSHA) {
|
|
892
|
+
return { action: "create", savedName };
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
// Same SHA — no-op, keep existing
|
|
896
|
+
if (existingSHA === newSHA) {
|
|
897
|
+
return { action: "keep-existing", savedName };
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
// Different SHA — create with timestamp suffix
|
|
901
|
+
const ts = timestamp || new Date().toISOString().replace(/[:.]/g, "-");
|
|
902
|
+
return { action: "create-suffixed", savedName: `${savedName}-${ts}` };
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
/** Typed error codes for branch preservation */
|
|
906
|
+
export type PreserveBranchErrorCode =
|
|
907
|
+
| "TARGET_BRANCH_MISSING"
|
|
908
|
+
| "UNMERGED_COUNT_FAILED"
|
|
909
|
+
| "SAVED_BRANCH_CREATE_FAILED"
|
|
910
|
+
| "UNKNOWN_RESOLUTION";
|
|
911
|
+
|
|
912
|
+
/**
|
|
913
|
+
* Result of a branch preservation attempt.
|
|
914
|
+
*/
|
|
915
|
+
export interface PreserveBranchResult {
|
|
916
|
+
/** Whether the branch was preserved (or was already preserved / fully merged) */
|
|
917
|
+
ok: boolean;
|
|
918
|
+
/** What action was taken */
|
|
919
|
+
action: "preserved" | "already-preserved" | "fully-merged" | "no-branch" | "error";
|
|
920
|
+
/** The saved branch name (if preserved) */
|
|
921
|
+
savedBranch?: string;
|
|
922
|
+
/** Number of unmerged commits (if checked) */
|
|
923
|
+
unmergedCount?: number;
|
|
924
|
+
/** Typed error code (if action is "error") */
|
|
925
|
+
code?: PreserveBranchErrorCode;
|
|
926
|
+
/** Error message (if action is "error") */
|
|
927
|
+
error?: string;
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
/**
|
|
931
|
+
* Preserve a branch by creating a saved ref if it has unmerged commits.
|
|
932
|
+
*
|
|
933
|
+
* Orchestrates: hasUnmergedCommits → computeSavedBranchName →
|
|
934
|
+
* resolveSavedBranchCollision → git branch create/rename.
|
|
935
|
+
*
|
|
936
|
+
* Idempotent: if the saved ref already exists at the same SHA, it's a no-op.
|
|
937
|
+
* If the target branch doesn't exist, logs warning and returns gracefully.
|
|
938
|
+
*
|
|
939
|
+
* @param branch - Branch to check and potentially preserve
|
|
940
|
+
* @param targetBranch - Target branch to compare against (e.g. "develop")
|
|
941
|
+
* @param repoRoot - Repository root directory
|
|
942
|
+
* @returns PreserveBranchResult describing what was done
|
|
943
|
+
*/
|
|
944
|
+
export function preserveBranch(
|
|
945
|
+
branch: string,
|
|
946
|
+
targetBranch: string,
|
|
947
|
+
repoRoot: string,
|
|
948
|
+
): PreserveBranchResult {
|
|
949
|
+
// Check if branch exists
|
|
950
|
+
const branchCheck = runGit(
|
|
951
|
+
["rev-parse", "--verify", `refs/heads/${branch}`],
|
|
952
|
+
repoRoot,
|
|
953
|
+
);
|
|
954
|
+
if (!branchCheck.ok) {
|
|
955
|
+
return { ok: true, action: "no-branch" };
|
|
956
|
+
}
|
|
957
|
+
const branchSHA = branchCheck.stdout.trim();
|
|
958
|
+
|
|
959
|
+
// Check for unmerged commits
|
|
960
|
+
const unmergedResult = hasUnmergedCommits(branch, targetBranch, repoRoot);
|
|
961
|
+
if (!unmergedResult.ok) {
|
|
962
|
+
// Target branch missing or git error — skip preservation gracefully
|
|
963
|
+
// Map unmerged error codes to preserve error codes
|
|
964
|
+
const preserveCode: PreserveBranchErrorCode =
|
|
965
|
+
unmergedResult.code === "TARGET_BRANCH_MISSING" ? "TARGET_BRANCH_MISSING" : "UNMERGED_COUNT_FAILED";
|
|
966
|
+
return {
|
|
967
|
+
ok: false,
|
|
968
|
+
action: "error",
|
|
969
|
+
code: preserveCode,
|
|
970
|
+
error: unmergedResult.error,
|
|
971
|
+
};
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
if (unmergedResult.count === 0) {
|
|
975
|
+
return { ok: true, action: "fully-merged", unmergedCount: 0 };
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
// Branch has unmerged commits — compute saved name
|
|
979
|
+
const savedName = computeSavedBranchName(branch);
|
|
980
|
+
|
|
981
|
+
// Check for collision
|
|
982
|
+
const existingCheck = runGit(
|
|
983
|
+
["rev-parse", "--verify", `refs/heads/${savedName}`],
|
|
984
|
+
repoRoot,
|
|
985
|
+
);
|
|
986
|
+
const existingSHA = existingCheck.ok ? existingCheck.stdout.trim() : "";
|
|
987
|
+
|
|
988
|
+
const resolution = resolveSavedBranchCollision(savedName, existingSHA, branchSHA);
|
|
989
|
+
|
|
990
|
+
switch (resolution.action) {
|
|
991
|
+
case "keep-existing":
|
|
992
|
+
return {
|
|
993
|
+
ok: true,
|
|
994
|
+
action: "already-preserved",
|
|
995
|
+
savedBranch: resolution.savedName,
|
|
996
|
+
unmergedCount: unmergedResult.count,
|
|
997
|
+
};
|
|
998
|
+
|
|
999
|
+
case "create":
|
|
1000
|
+
case "create-suffixed": {
|
|
1001
|
+
// Create saved branch at same SHA
|
|
1002
|
+
const createResult = runGit(
|
|
1003
|
+
["branch", resolution.savedName, branchSHA],
|
|
1004
|
+
repoRoot,
|
|
1005
|
+
);
|
|
1006
|
+
if (!createResult.ok) {
|
|
1007
|
+
return {
|
|
1008
|
+
ok: false,
|
|
1009
|
+
action: "error",
|
|
1010
|
+
code: "SAVED_BRANCH_CREATE_FAILED",
|
|
1011
|
+
error: `Failed to create saved branch "${resolution.savedName}": ${createResult.stderr}`,
|
|
1012
|
+
unmergedCount: unmergedResult.count,
|
|
1013
|
+
};
|
|
1014
|
+
}
|
|
1015
|
+
return {
|
|
1016
|
+
ok: true,
|
|
1017
|
+
action: "preserved",
|
|
1018
|
+
savedBranch: resolution.savedName,
|
|
1019
|
+
unmergedCount: unmergedResult.count,
|
|
1020
|
+
};
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
default:
|
|
1024
|
+
return { ok: false, action: "error", code: "UNKNOWN_RESOLUTION", error: `Unknown resolution action` };
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
|
|
1029
|
+
// ── Bulk Worktree Operations ─────────────────────────────────────────
|
|
1030
|
+
|
|
1031
|
+
/**
|
|
1032
|
+
* List all orchestrator worktrees matching a prefix pattern.
|
|
1033
|
+
*
|
|
1034
|
+
* Parses `git worktree list --porcelain` via parseWorktreeList() and filters
|
|
1035
|
+
* entries whose path basename matches `{prefix}-{N}` (where N is a number).
|
|
1036
|
+
*
|
|
1037
|
+
* Naming invariant: basename = {prefix}-{N}. The prefix comes from config
|
|
1038
|
+
* (e.g. "taskplane-wt"), and the lane number is appended with a single
|
|
1039
|
+
* dash separator. No extra `-wt-` infix is added.
|
|
1040
|
+
*
|
|
1041
|
+
* Lane number is extracted from the path basename pattern. Entries with
|
|
1042
|
+
* malformed/partial data (missing path, unparseable lane number) are
|
|
1043
|
+
* silently skipped — they are not orchestrator worktrees.
|
|
1044
|
+
*
|
|
1045
|
+
* @param prefix - Worktree directory prefix (e.g. "taskplane-wt")
|
|
1046
|
+
* Full basename pattern: `{prefix}-{N}` (e.g. "taskplane-wt-1")
|
|
1047
|
+
* @param repoRoot - Absolute path to the main repository root
|
|
1048
|
+
* @returns - WorktreeInfo[] sorted by laneNumber (ascending)
|
|
1049
|
+
*/
|
|
1050
|
+
export function listWorktrees(prefix: string, repoRoot: string): WorktreeInfo[] {
|
|
1051
|
+
const entries = parseWorktreeList(repoRoot);
|
|
1052
|
+
const results: WorktreeInfo[] = [];
|
|
1053
|
+
|
|
1054
|
+
// Build regex pattern to match the worktree basename.
|
|
1055
|
+
// Naming invariant: basename = {prefix}-{N} where N is one or more digits.
|
|
1056
|
+
// Example: prefix "taskplane-wt" matches "taskplane-wt-1", "taskplane-wt-2", etc.
|
|
1057
|
+
const pattern = new RegExp(`^${escapeRegex(prefix)}-(\\d+)$`);
|
|
1058
|
+
|
|
1059
|
+
for (const entry of entries) {
|
|
1060
|
+
if (!entry.path) continue;
|
|
1061
|
+
|
|
1062
|
+
// Extract basename from the worktree path
|
|
1063
|
+
const entryBasename = basename(resolve(entry.path));
|
|
1064
|
+
const match = entryBasename.match(pattern);
|
|
1065
|
+
if (!match) continue;
|
|
1066
|
+
|
|
1067
|
+
const laneNumber = parseInt(match[1], 10);
|
|
1068
|
+
if (isNaN(laneNumber) || laneNumber < 1) continue;
|
|
1069
|
+
|
|
1070
|
+
results.push({
|
|
1071
|
+
path: resolve(entry.path),
|
|
1072
|
+
branch: entry.branch || "",
|
|
1073
|
+
laneNumber,
|
|
1074
|
+
});
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
// Sort by laneNumber ascending (deterministic output)
|
|
1078
|
+
results.sort((a, b) => a.laneNumber - b.laneNumber);
|
|
1079
|
+
|
|
1080
|
+
return results;
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
/**
|
|
1084
|
+
* Escape special regex characters in a string for safe use in RegExp constructor.
|
|
1085
|
+
*/
|
|
1086
|
+
export function escapeRegex(str: string): string {
|
|
1087
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
/**
|
|
1091
|
+
* Create multiple lane worktrees in a single batch.
|
|
1092
|
+
*
|
|
1093
|
+
* Creates `count` worktrees sequentially (lanes 1..count). Git worktree
|
|
1094
|
+
* operations are not safe to parallelize (shared lock file), so sequential
|
|
1095
|
+
* creation is the correct approach.
|
|
1096
|
+
*
|
|
1097
|
+
* Partial failure rollback:
|
|
1098
|
+
* - If lane K fails after lanes 1..(K-1) succeeded, ALL previously-created
|
|
1099
|
+
* worktrees are rolled back via removeWorktree().
|
|
1100
|
+
* - Rollback is best-effort: individual rollback failures are collected in
|
|
1101
|
+
* `rollbackErrors` but do not prevent other rollbacks from proceeding.
|
|
1102
|
+
* - On successful rollback, `worktrees` is empty (clean slate).
|
|
1103
|
+
*
|
|
1104
|
+
* @param count - Number of worktrees to create (1-indexed: lane 1..count)
|
|
1105
|
+
* @param batchId - Batch ID timestamp for branch naming
|
|
1106
|
+
* @param config - Orchestrator config (prefix, baseBranch extracted from it)
|
|
1107
|
+
* @param repoRoot - Absolute path to the main repository root
|
|
1108
|
+
* @returns - CreateLaneWorktreesResult with success flag and details
|
|
1109
|
+
*/
|
|
1110
|
+
export function createLaneWorktrees(
|
|
1111
|
+
count: number,
|
|
1112
|
+
batchId: string,
|
|
1113
|
+
config: OrchestratorConfig,
|
|
1114
|
+
repoRoot: string,
|
|
1115
|
+
): CreateLaneWorktreesResult {
|
|
1116
|
+
const prefix = config.orchestrator.worktree_prefix;
|
|
1117
|
+
const baseBranch = config.orchestrator.integration_branch;
|
|
1118
|
+
const created: WorktreeInfo[] = [];
|
|
1119
|
+
const errors: BulkWorktreeError[] = [];
|
|
1120
|
+
|
|
1121
|
+
for (let lane = 1; lane <= count; lane++) {
|
|
1122
|
+
try {
|
|
1123
|
+
const wt = createWorktree(
|
|
1124
|
+
{ laneNumber: lane, batchId, baseBranch, prefix, config },
|
|
1125
|
+
repoRoot,
|
|
1126
|
+
);
|
|
1127
|
+
created.push(wt);
|
|
1128
|
+
} catch (err: unknown) {
|
|
1129
|
+
const wtErr = err instanceof WorktreeError ? err : null;
|
|
1130
|
+
errors.push({
|
|
1131
|
+
laneNumber: lane,
|
|
1132
|
+
code: wtErr?.code || "UNKNOWN",
|
|
1133
|
+
message: wtErr?.message || String(err),
|
|
1134
|
+
});
|
|
1135
|
+
|
|
1136
|
+
// Rollback all previously-created worktrees
|
|
1137
|
+
const rollbackErrors: BulkWorktreeError[] = [];
|
|
1138
|
+
for (const wt of created) {
|
|
1139
|
+
try {
|
|
1140
|
+
removeWorktree(wt, repoRoot);
|
|
1141
|
+
} catch (rbErr: unknown) {
|
|
1142
|
+
const rbWtErr = rbErr instanceof WorktreeError ? rbErr : null;
|
|
1143
|
+
rollbackErrors.push({
|
|
1144
|
+
laneNumber: wt.laneNumber,
|
|
1145
|
+
code: rbWtErr?.code || "UNKNOWN",
|
|
1146
|
+
message: rbWtErr?.message || String(rbErr),
|
|
1147
|
+
});
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
return {
|
|
1152
|
+
success: false,
|
|
1153
|
+
worktrees: [],
|
|
1154
|
+
errors,
|
|
1155
|
+
rolledBack: rollbackErrors.length === 0,
|
|
1156
|
+
rollbackErrors,
|
|
1157
|
+
};
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
// All created successfully
|
|
1162
|
+
// Sort by laneNumber (should already be in order, but enforce)
|
|
1163
|
+
created.sort((a, b) => a.laneNumber - b.laneNumber);
|
|
1164
|
+
|
|
1165
|
+
return {
|
|
1166
|
+
success: true,
|
|
1167
|
+
worktrees: created,
|
|
1168
|
+
errors: [],
|
|
1169
|
+
rolledBack: false,
|
|
1170
|
+
rollbackErrors: [],
|
|
1171
|
+
};
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
/**
|
|
1175
|
+
* Ensure required lane worktrees exist for the current wave.
|
|
1176
|
+
*
|
|
1177
|
+
* Reuses existing worktrees when present (multi-wave behavior), resetting
|
|
1178
|
+
* them to integration HEAD before use, and only creates missing lanes.
|
|
1179
|
+
* If creation of a missing lane fails, newly-created lanes in this call are
|
|
1180
|
+
* rolled back.
|
|
1181
|
+
*
|
|
1182
|
+
* This prevents wave 2+ allocation from failing on WORKTREE_PATH_IS_WORKTREE
|
|
1183
|
+
* while still supporting wave growth (e.g., 1 lane in wave 1, 3 lanes in wave 2).
|
|
1184
|
+
*/
|
|
1185
|
+
export function ensureLaneWorktrees(
|
|
1186
|
+
laneNumbers: number[],
|
|
1187
|
+
batchId: string,
|
|
1188
|
+
config: OrchestratorConfig,
|
|
1189
|
+
repoRoot: string,
|
|
1190
|
+
): CreateLaneWorktreesResult {
|
|
1191
|
+
const prefix = config.orchestrator.worktree_prefix;
|
|
1192
|
+
const baseBranch = config.orchestrator.integration_branch;
|
|
1193
|
+
|
|
1194
|
+
const existing = listWorktrees(prefix, repoRoot);
|
|
1195
|
+
const existingByLane = new Map<number, WorktreeInfo>();
|
|
1196
|
+
for (const wt of existing) {
|
|
1197
|
+
existingByLane.set(wt.laneNumber, wt);
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
const needed = [...new Set(laneNumbers)].sort((a, b) => a - b);
|
|
1201
|
+
const selected: WorktreeInfo[] = [];
|
|
1202
|
+
const createdNow: WorktreeInfo[] = [];
|
|
1203
|
+
const errors: BulkWorktreeError[] = [];
|
|
1204
|
+
|
|
1205
|
+
for (const lane of needed) {
|
|
1206
|
+
const reused = existingByLane.get(lane);
|
|
1207
|
+
if (reused) {
|
|
1208
|
+
// Reused worktrees must be reset to integration branch HEAD before use.
|
|
1209
|
+
// This covers normal multi-wave reuse and stale leftovers from prior batches.
|
|
1210
|
+
const resetResult = safeResetWorktree(reused, baseBranch, repoRoot);
|
|
1211
|
+
if (resetResult.success) {
|
|
1212
|
+
selected.push(reused);
|
|
1213
|
+
continue;
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
// Reset failed: remove and recreate this lane worktree.
|
|
1217
|
+
try {
|
|
1218
|
+
removeWorktree(reused, repoRoot);
|
|
1219
|
+
} catch {
|
|
1220
|
+
// Best effort — creation below may still fail with a clear error.
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
try {
|
|
1225
|
+
const wt = createWorktree(
|
|
1226
|
+
{ laneNumber: lane, batchId, baseBranch, prefix, config },
|
|
1227
|
+
repoRoot,
|
|
1228
|
+
);
|
|
1229
|
+
createdNow.push(wt);
|
|
1230
|
+
selected.push(wt);
|
|
1231
|
+
} catch (err: unknown) {
|
|
1232
|
+
const wtErr = err instanceof WorktreeError ? err : null;
|
|
1233
|
+
errors.push({
|
|
1234
|
+
laneNumber: lane,
|
|
1235
|
+
code: wtErr?.code || "UNKNOWN",
|
|
1236
|
+
message: wtErr?.message || String(err),
|
|
1237
|
+
});
|
|
1238
|
+
|
|
1239
|
+
const rollbackErrors: BulkWorktreeError[] = [];
|
|
1240
|
+
for (const wt of createdNow) {
|
|
1241
|
+
try {
|
|
1242
|
+
removeWorktree(wt, repoRoot);
|
|
1243
|
+
} catch (rbErr: unknown) {
|
|
1244
|
+
const rbWtErr = rbErr instanceof WorktreeError ? rbErr : null;
|
|
1245
|
+
rollbackErrors.push({
|
|
1246
|
+
laneNumber: wt.laneNumber,
|
|
1247
|
+
code: rbWtErr?.code || "UNKNOWN",
|
|
1248
|
+
message: rbWtErr?.message || String(rbErr),
|
|
1249
|
+
});
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
return {
|
|
1254
|
+
success: false,
|
|
1255
|
+
worktrees: [],
|
|
1256
|
+
errors,
|
|
1257
|
+
rolledBack: rollbackErrors.length === 0,
|
|
1258
|
+
rollbackErrors,
|
|
1259
|
+
};
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
selected.sort((a, b) => a.laneNumber - b.laneNumber);
|
|
1264
|
+
return {
|
|
1265
|
+
success: true,
|
|
1266
|
+
worktrees: selected,
|
|
1267
|
+
errors: [],
|
|
1268
|
+
rolledBack: false,
|
|
1269
|
+
rollbackErrors: [],
|
|
1270
|
+
};
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
/**
|
|
1274
|
+
* Remove all orchestrator worktrees matching a prefix.
|
|
1275
|
+
*
|
|
1276
|
+
* Uses listWorktrees() to discover matching worktrees, then removes each
|
|
1277
|
+
* one via removeWorktree(). Best-effort: continues on per-worktree errors
|
|
1278
|
+
* (does not fail-fast).
|
|
1279
|
+
*
|
|
1280
|
+
* When `targetBranch` is provided, branches with unmerged commits are
|
|
1281
|
+
* preserved as `saved/<branch>` refs instead of being force-deleted.
|
|
1282
|
+
*
|
|
1283
|
+
* @param prefix - Worktree directory prefix (e.g. "taskplane-wt")
|
|
1284
|
+
* @param repoRoot - Absolute path to the main repository root
|
|
1285
|
+
* @param targetBranch - Optional target branch for unmerged commit detection (e.g. "develop")
|
|
1286
|
+
* @returns - RemoveAllWorktreesResult with per-worktree outcomes
|
|
1287
|
+
*/
|
|
1288
|
+
export function removeAllWorktrees(
|
|
1289
|
+
prefix: string,
|
|
1290
|
+
repoRoot: string,
|
|
1291
|
+
targetBranch?: string,
|
|
1292
|
+
): RemoveAllWorktreesResult {
|
|
1293
|
+
const worktrees = listWorktrees(prefix, repoRoot);
|
|
1294
|
+
const outcomes: RemoveWorktreeOutcome[] = [];
|
|
1295
|
+
const removed: WorktreeInfo[] = [];
|
|
1296
|
+
const failed: RemoveWorktreeOutcome[] = [];
|
|
1297
|
+
const preserved: Array<{ branch: string; savedBranch: string; laneNumber: number; unmergedCount?: number }> = [];
|
|
1298
|
+
|
|
1299
|
+
for (const wt of worktrees) {
|
|
1300
|
+
try {
|
|
1301
|
+
const result = removeWorktree(wt, repoRoot, targetBranch);
|
|
1302
|
+
const outcome: RemoveWorktreeOutcome = {
|
|
1303
|
+
worktree: wt,
|
|
1304
|
+
result,
|
|
1305
|
+
error: null,
|
|
1306
|
+
};
|
|
1307
|
+
outcomes.push(outcome);
|
|
1308
|
+
removed.push(wt);
|
|
1309
|
+
|
|
1310
|
+
// Track preserved branches for caller logging
|
|
1311
|
+
if (result.branchPreserved && result.savedBranch) {
|
|
1312
|
+
preserved.push({
|
|
1313
|
+
branch: wt.branch,
|
|
1314
|
+
savedBranch: result.savedBranch,
|
|
1315
|
+
laneNumber: wt.laneNumber,
|
|
1316
|
+
unmergedCount: result.unmergedCount,
|
|
1317
|
+
});
|
|
1318
|
+
}
|
|
1319
|
+
} catch (err: unknown) {
|
|
1320
|
+
const wtErr = err instanceof WorktreeError ? err : null;
|
|
1321
|
+
const bulkErr: BulkWorktreeError = {
|
|
1322
|
+
laneNumber: wt.laneNumber,
|
|
1323
|
+
code: wtErr?.code || "UNKNOWN",
|
|
1324
|
+
message: wtErr?.message || String(err),
|
|
1325
|
+
};
|
|
1326
|
+
const outcome: RemoveWorktreeOutcome = {
|
|
1327
|
+
worktree: wt,
|
|
1328
|
+
result: null,
|
|
1329
|
+
error: bulkErr,
|
|
1330
|
+
};
|
|
1331
|
+
outcomes.push(outcome);
|
|
1332
|
+
failed.push(outcome);
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
return {
|
|
1337
|
+
totalAttempted: worktrees.length,
|
|
1338
|
+
removed,
|
|
1339
|
+
failed,
|
|
1340
|
+
outcomes,
|
|
1341
|
+
preserved,
|
|
1342
|
+
};
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
/**
|
|
1346
|
+
* Execute a command synchronously and return { ok, stdout }.
|
|
1347
|
+
* Returns ok=false on any error (non-zero exit, command not found, etc.).
|
|
1348
|
+
*/
|
|
1349
|
+
export function execCheck(command: string): { ok: boolean; stdout: string } {
|
|
1350
|
+
try {
|
|
1351
|
+
const stdout = execSync(command, {
|
|
1352
|
+
encoding: "utf-8",
|
|
1353
|
+
timeout: 10_000,
|
|
1354
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
1355
|
+
}).trim();
|
|
1356
|
+
return { ok: true, stdout };
|
|
1357
|
+
} catch {
|
|
1358
|
+
return { ok: false, stdout: "" };
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
/**
|
|
1363
|
+
* Parse a version string like "git version 2.43.0.windows.1" or "tmux 3.3a"
|
|
1364
|
+
* into a comparable [major, minor] tuple. Returns [0, 0] on parse failure.
|
|
1365
|
+
*/
|
|
1366
|
+
export function parseVersion(raw: string): [number, number] {
|
|
1367
|
+
const match = raw.match(/(\d+)\.(\d+)/);
|
|
1368
|
+
if (!match) return [0, 0];
|
|
1369
|
+
return [parseInt(match[1], 10), parseInt(match[2], 10)];
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
/**
|
|
1373
|
+
* Check if actual version meets minimum required version.
|
|
1374
|
+
*/
|
|
1375
|
+
export function meetsMinVersion(actual: [number, number], minimum: [number, number]): boolean {
|
|
1376
|
+
if (actual[0] > minimum[0]) return true;
|
|
1377
|
+
if (actual[0] === minimum[0] && actual[1] >= minimum[1]) return true;
|
|
1378
|
+
return false;
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
/**
|
|
1382
|
+
* Run preflight checks for all orchestrator dependencies.
|
|
1383
|
+
*
|
|
1384
|
+
* Required checks (fail blocks execution):
|
|
1385
|
+
* - git version >= 2.15
|
|
1386
|
+
* - git worktree support
|
|
1387
|
+
* - pi availability
|
|
1388
|
+
*
|
|
1389
|
+
* Conditional checks (warn if spawn_mode is "subprocess", fail if "tmux"):
|
|
1390
|
+
* - tmux version >= 2.6
|
|
1391
|
+
* - tmux functional (can create/destroy sessions)
|
|
1392
|
+
*/
|
|
1393
|
+
export function runPreflight(config: OrchestratorConfig): PreflightResult {
|
|
1394
|
+
const checks: PreflightCheck[] = [];
|
|
1395
|
+
const tmuxRequired = config.orchestrator.spawn_mode === "tmux";
|
|
1396
|
+
|
|
1397
|
+
// ── Git version ──────────────────────────────────────────────
|
|
1398
|
+
const gitResult = execCheck("git --version");
|
|
1399
|
+
if (gitResult.ok) {
|
|
1400
|
+
const version = parseVersion(gitResult.stdout);
|
|
1401
|
+
const versionStr = `${version[0]}.${version[1]}`;
|
|
1402
|
+
if (meetsMinVersion(version, [2, 15])) {
|
|
1403
|
+
checks.push({
|
|
1404
|
+
name: "git",
|
|
1405
|
+
status: "pass",
|
|
1406
|
+
message: `Git ${versionStr} available`,
|
|
1407
|
+
});
|
|
1408
|
+
} else {
|
|
1409
|
+
checks.push({
|
|
1410
|
+
name: "git",
|
|
1411
|
+
status: "fail",
|
|
1412
|
+
message: `Git ${versionStr} found, but 2.15+ required for worktree support`,
|
|
1413
|
+
hint: "Upgrade Git: https://git-scm.com/downloads",
|
|
1414
|
+
});
|
|
1415
|
+
}
|
|
1416
|
+
} else {
|
|
1417
|
+
checks.push({
|
|
1418
|
+
name: "git",
|
|
1419
|
+
status: "fail",
|
|
1420
|
+
message: "Git not found",
|
|
1421
|
+
hint: "Install Git: https://git-scm.com/downloads",
|
|
1422
|
+
});
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
// ── Git worktree support ─────────────────────────────────────
|
|
1426
|
+
const worktreeResult = execCheck("git worktree list");
|
|
1427
|
+
checks.push({
|
|
1428
|
+
name: "git-worktree",
|
|
1429
|
+
status: worktreeResult.ok ? "pass" : "fail",
|
|
1430
|
+
message: worktreeResult.ok
|
|
1431
|
+
? "Worktree support available"
|
|
1432
|
+
: "Git worktree not available",
|
|
1433
|
+
hint: worktreeResult.ok ? undefined : "Upgrade Git to 2.15+",
|
|
1434
|
+
});
|
|
1435
|
+
|
|
1436
|
+
// ── TMUX availability and version ────────────────────────────
|
|
1437
|
+
const tmuxResult = execCheck("tmux -V");
|
|
1438
|
+
if (tmuxResult.ok) {
|
|
1439
|
+
const version = parseVersion(tmuxResult.stdout);
|
|
1440
|
+
const versionStr = `${version[0]}.${version[1]}`;
|
|
1441
|
+
if (meetsMinVersion(version, [2, 6])) {
|
|
1442
|
+
checks.push({
|
|
1443
|
+
name: "tmux",
|
|
1444
|
+
status: "pass",
|
|
1445
|
+
message: `TMUX ${versionStr} available`,
|
|
1446
|
+
});
|
|
1447
|
+
} else {
|
|
1448
|
+
checks.push({
|
|
1449
|
+
name: "tmux",
|
|
1450
|
+
status: tmuxRequired ? "fail" : "warn",
|
|
1451
|
+
message: `TMUX ${versionStr} found, but 2.6+ required`,
|
|
1452
|
+
hint: "Upgrade TMUX: https://github.com/tmux/tmux/wiki/Installing",
|
|
1453
|
+
});
|
|
1454
|
+
}
|
|
1455
|
+
} else {
|
|
1456
|
+
checks.push({
|
|
1457
|
+
name: "tmux",
|
|
1458
|
+
status: tmuxRequired ? "fail" : "warn",
|
|
1459
|
+
message: "TMUX not found",
|
|
1460
|
+
hint: tmuxRequired
|
|
1461
|
+
? "Install TMUX (required for tmux spawn_mode):\n" +
|
|
1462
|
+
" Linux: sudo apt install tmux\n" +
|
|
1463
|
+
" macOS: brew install tmux\n" +
|
|
1464
|
+
" Windows (MSYS2): pacman -S tmux\n" +
|
|
1465
|
+
" Or set spawn_mode: subprocess in .pi/task-orchestrator.yaml"
|
|
1466
|
+
: "TMUX not required for subprocess spawn_mode. Install for drill-down observability:\n" +
|
|
1467
|
+
" Linux: sudo apt install tmux | macOS: brew install tmux",
|
|
1468
|
+
});
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
// ── TMUX functional (only if tmux was found) ─────────────────
|
|
1472
|
+
if (tmuxResult.ok) {
|
|
1473
|
+
const testSession = "orch-preflight-test";
|
|
1474
|
+
const tmuxFunctional = execCheck(`tmux new-session -d -s ${testSession} "exit 0"`);
|
|
1475
|
+
if (tmuxFunctional.ok) {
|
|
1476
|
+
// Clean up test session
|
|
1477
|
+
execCheck(`tmux kill-session -t ${testSession}`);
|
|
1478
|
+
checks.push({
|
|
1479
|
+
name: "tmux-functional",
|
|
1480
|
+
status: "pass",
|
|
1481
|
+
message: "TMUX can create sessions",
|
|
1482
|
+
});
|
|
1483
|
+
} else {
|
|
1484
|
+
checks.push({
|
|
1485
|
+
name: "tmux-functional",
|
|
1486
|
+
status: tmuxRequired ? "fail" : "warn",
|
|
1487
|
+
message: "TMUX installed but cannot create sessions",
|
|
1488
|
+
hint: "Check TMUX server status. Try: tmux new-session -d -s test 'echo ok'",
|
|
1489
|
+
});
|
|
1490
|
+
}
|
|
1491
|
+
} else {
|
|
1492
|
+
checks.push({
|
|
1493
|
+
name: "tmux-functional",
|
|
1494
|
+
status: tmuxRequired ? "fail" : "warn",
|
|
1495
|
+
message: "Skipped — TMUX not installed",
|
|
1496
|
+
});
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
// ── Pi availability ──────────────────────────────────────────
|
|
1500
|
+
const piResult = execCheck("pi --version");
|
|
1501
|
+
if (piResult.ok) {
|
|
1502
|
+
checks.push({
|
|
1503
|
+
name: "pi",
|
|
1504
|
+
status: "pass",
|
|
1505
|
+
message: `Pi ${piResult.stdout || "available"}`,
|
|
1506
|
+
});
|
|
1507
|
+
} else {
|
|
1508
|
+
checks.push({
|
|
1509
|
+
name: "pi",
|
|
1510
|
+
status: "fail",
|
|
1511
|
+
message: "Pi not found",
|
|
1512
|
+
hint: "Install Pi: npm install -g @mariozechner/pi-coding-agent",
|
|
1513
|
+
});
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
return {
|
|
1517
|
+
passed: checks.every((c) => c.status !== "fail"),
|
|
1518
|
+
checks,
|
|
1519
|
+
};
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
/**
|
|
1523
|
+
* Format preflight results as a readable string for display.
|
|
1524
|
+
*/
|
|
1525
|
+
export function formatPreflightResults(result: PreflightResult): string {
|
|
1526
|
+
const lines: string[] = ["Preflight Check:"];
|
|
1527
|
+
|
|
1528
|
+
for (const check of result.checks) {
|
|
1529
|
+
const icon =
|
|
1530
|
+
check.status === "pass" ? "✅" :
|
|
1531
|
+
check.status === "warn" ? "⚠️ " :
|
|
1532
|
+
"❌";
|
|
1533
|
+
const nameCol = check.name.padEnd(18);
|
|
1534
|
+
lines.push(` ${icon} ${nameCol} ${check.message}`);
|
|
1535
|
+
if (check.hint && check.status !== "pass") {
|
|
1536
|
+
// Indent hint lines under the check
|
|
1537
|
+
for (const hintLine of check.hint.split("\n")) {
|
|
1538
|
+
lines.push(` ${" ".repeat(18)} ${hintLine}`);
|
|
1539
|
+
}
|
|
1540
|
+
}
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
lines.push("");
|
|
1544
|
+
if (result.passed) {
|
|
1545
|
+
lines.push("All required checks passed.");
|
|
1546
|
+
} else {
|
|
1547
|
+
const failedNames = result.checks
|
|
1548
|
+
.filter((c) => c.status === "fail")
|
|
1549
|
+
.map((c) => c.name)
|
|
1550
|
+
.join(", ");
|
|
1551
|
+
lines.push(`❌ Preflight FAILED: ${failedNames}`);
|
|
1552
|
+
lines.push("Fix the issues above before running the orchestrator.");
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
return lines.join("\n");
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
|
|
1559
|
+
// ── Worktree Reset with Safety ───────────────────────────────────────
|
|
1560
|
+
|
|
1561
|
+
/**
|
|
1562
|
+
* Reset a worktree with safety handling for dirty trees.
|
|
1563
|
+
*
|
|
1564
|
+
* For failed/stalled tasks, the worktree may have uncommitted changes.
|
|
1565
|
+
* This function first tries a clean reset, and if that fails due to dirty
|
|
1566
|
+
* tree, force-cleans it before resetting.
|
|
1567
|
+
*
|
|
1568
|
+
* @param worktree - WorktreeInfo to reset
|
|
1569
|
+
* @param targetBranch - Branch to reset to (e.g., "develop")
|
|
1570
|
+
* @param repoRoot - Main repository root
|
|
1571
|
+
* @returns { success: boolean, error?: string }
|
|
1572
|
+
*/
|
|
1573
|
+
export function safeResetWorktree(
|
|
1574
|
+
worktree: WorktreeInfo,
|
|
1575
|
+
targetBranch: string,
|
|
1576
|
+
repoRoot: string,
|
|
1577
|
+
): { success: boolean; error?: string } {
|
|
1578
|
+
try {
|
|
1579
|
+
resetWorktree(worktree, targetBranch, repoRoot);
|
|
1580
|
+
return { success: true };
|
|
1581
|
+
} catch (err: unknown) {
|
|
1582
|
+
// If it's a dirty worktree, force clean and retry
|
|
1583
|
+
if (err instanceof WorktreeError && err.code === "WORKTREE_DIRTY") {
|
|
1584
|
+
execLog("reset", `lane-${worktree.laneNumber}`, "worktree dirty — force cleaning", {
|
|
1585
|
+
path: worktree.path,
|
|
1586
|
+
});
|
|
1587
|
+
|
|
1588
|
+
// Force discard all changes
|
|
1589
|
+
const checkoutResult = runGit(["checkout", "--", "."], worktree.path);
|
|
1590
|
+
if (!checkoutResult.ok) {
|
|
1591
|
+
return {
|
|
1592
|
+
success: false,
|
|
1593
|
+
error: `git checkout -- . failed: ${checkoutResult.stderr}`,
|
|
1594
|
+
};
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1597
|
+
// Remove untracked files
|
|
1598
|
+
const cleanResult = runGit(["clean", "-fd"], worktree.path);
|
|
1599
|
+
if (!cleanResult.ok) {
|
|
1600
|
+
return {
|
|
1601
|
+
success: false,
|
|
1602
|
+
error: `git clean -fd failed: ${cleanResult.stderr}`,
|
|
1603
|
+
};
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
// Retry reset after cleaning
|
|
1607
|
+
try {
|
|
1608
|
+
resetWorktree(worktree, targetBranch, repoRoot);
|
|
1609
|
+
return { success: true };
|
|
1610
|
+
} catch (retryErr: unknown) {
|
|
1611
|
+
return {
|
|
1612
|
+
success: false,
|
|
1613
|
+
error: `Reset failed after clean: ${retryErr instanceof Error ? retryErr.message : String(retryErr)}`,
|
|
1614
|
+
};
|
|
1615
|
+
}
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
return {
|
|
1619
|
+
success: false,
|
|
1620
|
+
error: err instanceof Error ? err.message : String(err),
|
|
1621
|
+
};
|
|
1622
|
+
}
|
|
1623
|
+
}
|
|
1624
|
+
|