taskplane 0.1.15 → 0.1.17
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/bin/taskplane.mjs +317 -5
- package/extensions/taskplane/abort.ts +461 -466
- package/extensions/taskplane/config.ts +17 -12
- package/extensions/taskplane/discovery.ts +168 -32
- package/extensions/taskplane/engine.ts +22 -12
- package/extensions/taskplane/execution.ts +175 -48
- package/extensions/taskplane/extension.ts +780 -693
- package/extensions/taskplane/index.ts +23 -22
- package/extensions/taskplane/messages.ts +146 -134
- package/extensions/taskplane/resume.ts +9 -3
- package/extensions/taskplane/types.ts +238 -1
- package/extensions/taskplane/workspace.ts +382 -0
- package/extensions/taskplane/worktree.ts +107 -6
- package/package.json +1 -1
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workspace configuration loading and validation.
|
|
3
|
+
*
|
|
4
|
+
* Detects workspace mode by checking for `.pi/taskplane-workspace.yaml`.
|
|
5
|
+
* When the file is absent, the orchestrator runs in repo mode (default).
|
|
6
|
+
* When the file is present, it must be valid — invalid files are fatal.
|
|
7
|
+
*
|
|
8
|
+
* Validation order (deterministic, fail-fast):
|
|
9
|
+
* 1. File existence check → absent = repo mode (return null)
|
|
10
|
+
* 2. File read → WORKSPACE_FILE_READ_ERROR
|
|
11
|
+
* 3. YAML parse → WORKSPACE_FILE_PARSE_ERROR
|
|
12
|
+
* 4. Top-level schema → WORKSPACE_SCHEMA_INVALID
|
|
13
|
+
* 5. repos map non-empty → WORKSPACE_MISSING_REPOS
|
|
14
|
+
* 6. Per-repo validation (sorted key order):
|
|
15
|
+
* a. path present → WORKSPACE_REPO_PATH_MISSING
|
|
16
|
+
* b. path exists on disk → WORKSPACE_REPO_PATH_NOT_FOUND
|
|
17
|
+
* c. path is git repo → WORKSPACE_REPO_NOT_GIT
|
|
18
|
+
* 7. Duplicate repo paths → WORKSPACE_DUPLICATE_REPO_PATH
|
|
19
|
+
* 8. routing.tasks_root present → WORKSPACE_MISSING_TASKS_ROOT
|
|
20
|
+
* 9. routing.tasks_root exists → WORKSPACE_TASKS_ROOT_NOT_FOUND
|
|
21
|
+
* 10. routing.default_repo present → WORKSPACE_MISSING_DEFAULT_REPO
|
|
22
|
+
* 11. routing.default_repo valid → WORKSPACE_DEFAULT_REPO_NOT_FOUND
|
|
23
|
+
*
|
|
24
|
+
* Path normalization rules:
|
|
25
|
+
* - Relative paths are resolved against workspaceRoot.
|
|
26
|
+
* - Existing paths are canonicalized via `fs.realpathSync.native()` to
|
|
27
|
+
* expand Windows 8.3 short names and resolve symlinks.
|
|
28
|
+
* - All paths are forward-slash normalized and lowercased for comparison.
|
|
29
|
+
* - This matches the precedent in `worktree.ts:normalizePath()`.
|
|
30
|
+
*
|
|
31
|
+
* Git repo validation:
|
|
32
|
+
* - Uses `git rev-parse --git-dir` run inside the repo path.
|
|
33
|
+
* - The path must be the repo root (not a subdirectory).
|
|
34
|
+
* We verify by checking that `git rev-parse --show-toplevel` matches
|
|
35
|
+
* the canonicalized path.
|
|
36
|
+
*
|
|
37
|
+
* @module orch/workspace
|
|
38
|
+
*/
|
|
39
|
+
import { readFileSync, existsSync, realpathSync } from "fs";
|
|
40
|
+
import { resolve } from "path";
|
|
41
|
+
import { parse as yamlParse } from "yaml";
|
|
42
|
+
|
|
43
|
+
import { runGit } from "./git.ts";
|
|
44
|
+
import {
|
|
45
|
+
WorkspaceConfigError,
|
|
46
|
+
workspaceConfigPath,
|
|
47
|
+
type WorkspaceConfig,
|
|
48
|
+
type WorkspaceRepoConfig,
|
|
49
|
+
type WorkspaceRoutingConfig,
|
|
50
|
+
} from "./types.ts";
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
// ── Path Canonicalization ────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Canonicalize a filesystem path for comparison and storage.
|
|
57
|
+
*
|
|
58
|
+
* Reuses the normalization pattern from `worktree.ts:normalizePath()`:
|
|
59
|
+
* - `realpathSync.native()` expands Windows 8.3 short names when the path exists.
|
|
60
|
+
* - Falls back to `resolve()` for non-existent paths.
|
|
61
|
+
* - Forward-slash normalized and lowercased for platform-safe comparison.
|
|
62
|
+
*
|
|
63
|
+
* @param p - Path to canonicalize (absolute or relative)
|
|
64
|
+
* @param base - Base directory for resolving relative paths
|
|
65
|
+
* @returns Canonical absolute path (forward-slash, lowercased)
|
|
66
|
+
*/
|
|
67
|
+
export function canonicalizePath(p: string, base: string): string {
|
|
68
|
+
const resolved = resolve(base, p);
|
|
69
|
+
let expanded: string;
|
|
70
|
+
try {
|
|
71
|
+
expanded = realpathSync.native(resolved);
|
|
72
|
+
} catch {
|
|
73
|
+
// Path doesn't exist yet — fall back to resolve()
|
|
74
|
+
expanded = resolved;
|
|
75
|
+
}
|
|
76
|
+
return expanded.replace(/\\/g, "/").toLowerCase();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Canonicalize a path for storage (absolute, native separators, resolved symlinks).
|
|
81
|
+
* Unlike canonicalizePath(), this preserves original case for display/config output.
|
|
82
|
+
*
|
|
83
|
+
* @param p - Path to resolve (absolute or relative)
|
|
84
|
+
* @param base - Base directory for resolving relative paths
|
|
85
|
+
* @returns Absolute resolved path (native separators preserved)
|
|
86
|
+
*/
|
|
87
|
+
function resolveAbsolutePath(p: string, base: string): string {
|
|
88
|
+
const resolved = resolve(base, p);
|
|
89
|
+
try {
|
|
90
|
+
return realpathSync.native(resolved);
|
|
91
|
+
} catch {
|
|
92
|
+
return resolved;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
// ── Workspace Config Loading ─────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Load and validate workspace configuration from `.pi/taskplane-workspace.yaml`.
|
|
101
|
+
*
|
|
102
|
+
* Mode determination rules:
|
|
103
|
+
* 1. No config file → return null (repo mode, non-fatal, silent).
|
|
104
|
+
* 2. Config file present + invalid → throw WorkspaceConfigError (fatal).
|
|
105
|
+
* 3. Config file present + valid → return WorkspaceConfig (workspace mode).
|
|
106
|
+
*
|
|
107
|
+
* @param workspaceRoot - Absolute path to the workspace root directory
|
|
108
|
+
* @returns WorkspaceConfig if workspace mode, null if repo mode
|
|
109
|
+
* @throws WorkspaceConfigError when config file is present but invalid
|
|
110
|
+
*/
|
|
111
|
+
export function loadWorkspaceConfig(workspaceRoot: string): WorkspaceConfig | null {
|
|
112
|
+
const configFile = workspaceConfigPath(workspaceRoot);
|
|
113
|
+
|
|
114
|
+
// ── 1. File existence check ──────────────────────────────────
|
|
115
|
+
if (!existsSync(configFile)) {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ── 2. File read ─────────────────────────────────────────────
|
|
120
|
+
let rawContent: string;
|
|
121
|
+
try {
|
|
122
|
+
rawContent = readFileSync(configFile, "utf-8");
|
|
123
|
+
} catch (err: unknown) {
|
|
124
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
125
|
+
throw new WorkspaceConfigError(
|
|
126
|
+
"WORKSPACE_FILE_READ_ERROR",
|
|
127
|
+
`Cannot read workspace config file: ${msg}`,
|
|
128
|
+
undefined,
|
|
129
|
+
configFile,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ── 3. YAML parse ────────────────────────────────────────────
|
|
134
|
+
let parsed: unknown;
|
|
135
|
+
try {
|
|
136
|
+
parsed = yamlParse(rawContent);
|
|
137
|
+
} catch (err: unknown) {
|
|
138
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
139
|
+
throw new WorkspaceConfigError(
|
|
140
|
+
"WORKSPACE_FILE_PARSE_ERROR",
|
|
141
|
+
`Invalid YAML in workspace config: ${msg}`,
|
|
142
|
+
undefined,
|
|
143
|
+
configFile,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ── 4. Top-level schema validation ───────────────────────────
|
|
148
|
+
if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
149
|
+
throw new WorkspaceConfigError(
|
|
150
|
+
"WORKSPACE_SCHEMA_INVALID",
|
|
151
|
+
"Workspace config must be a YAML mapping (object), not a scalar or sequence.",
|
|
152
|
+
undefined,
|
|
153
|
+
configFile,
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
const doc = parsed as Record<string, unknown>;
|
|
157
|
+
|
|
158
|
+
if (!doc.repos || typeof doc.repos !== "object" || Array.isArray(doc.repos)) {
|
|
159
|
+
throw new WorkspaceConfigError(
|
|
160
|
+
"WORKSPACE_SCHEMA_INVALID",
|
|
161
|
+
"Workspace config must contain a 'repos' mapping.",
|
|
162
|
+
undefined,
|
|
163
|
+
configFile,
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
if (!doc.routing || typeof doc.routing !== "object" || Array.isArray(doc.routing)) {
|
|
167
|
+
throw new WorkspaceConfigError(
|
|
168
|
+
"WORKSPACE_SCHEMA_INVALID",
|
|
169
|
+
"Workspace config must contain a 'routing' mapping.",
|
|
170
|
+
undefined,
|
|
171
|
+
configFile,
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ── 5. Repos map non-empty ───────────────────────────────────
|
|
176
|
+
const rawRepos = doc.repos as Record<string, unknown>;
|
|
177
|
+
const repoKeys = Object.keys(rawRepos).sort(); // deterministic order
|
|
178
|
+
if (repoKeys.length === 0) {
|
|
179
|
+
throw new WorkspaceConfigError(
|
|
180
|
+
"WORKSPACE_MISSING_REPOS",
|
|
181
|
+
"Workspace config must define at least one repo under 'repos'.",
|
|
182
|
+
undefined,
|
|
183
|
+
configFile,
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ── 6. Per-repo validation ───────────────────────────────────
|
|
188
|
+
const repos = new Map<string, WorkspaceRepoConfig>();
|
|
189
|
+
const normalizedPaths = new Map<string, string>(); // normalized → repoId (for duplicate detection)
|
|
190
|
+
|
|
191
|
+
for (const repoId of repoKeys) {
|
|
192
|
+
const rawRepo = rawRepos[repoId];
|
|
193
|
+
if (rawRepo == null || typeof rawRepo !== "object" || Array.isArray(rawRepo)) {
|
|
194
|
+
throw new WorkspaceConfigError(
|
|
195
|
+
"WORKSPACE_SCHEMA_INVALID",
|
|
196
|
+
`Repo '${repoId}' must be a YAML mapping with at least a 'path' field.`,
|
|
197
|
+
repoId,
|
|
198
|
+
configFile,
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
const repoEntry = rawRepo as Record<string, unknown>;
|
|
202
|
+
|
|
203
|
+
// 6a. path present and non-empty
|
|
204
|
+
const rawPath = repoEntry.path;
|
|
205
|
+
if (!rawPath || typeof rawPath !== "string" || rawPath.trim() === "") {
|
|
206
|
+
throw new WorkspaceConfigError(
|
|
207
|
+
"WORKSPACE_REPO_PATH_MISSING",
|
|
208
|
+
`Repo '${repoId}' is missing a 'path' field.`,
|
|
209
|
+
repoId,
|
|
210
|
+
configFile,
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// 6b. path exists on disk
|
|
215
|
+
const absolutePath = resolveAbsolutePath(rawPath.trim(), workspaceRoot);
|
|
216
|
+
const normalizedPath = canonicalizePath(rawPath.trim(), workspaceRoot);
|
|
217
|
+
if (!existsSync(absolutePath)) {
|
|
218
|
+
throw new WorkspaceConfigError(
|
|
219
|
+
"WORKSPACE_REPO_PATH_NOT_FOUND",
|
|
220
|
+
`Repo '${repoId}' path does not exist: ${absolutePath}`,
|
|
221
|
+
repoId,
|
|
222
|
+
absolutePath,
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// 6c. path is a git repo root
|
|
227
|
+
const gitDirCheck = runGit(["rev-parse", "--git-dir"], absolutePath);
|
|
228
|
+
if (!gitDirCheck.ok) {
|
|
229
|
+
throw new WorkspaceConfigError(
|
|
230
|
+
"WORKSPACE_REPO_NOT_GIT",
|
|
231
|
+
`Repo '${repoId}' path is not a git repository: ${absolutePath}`,
|
|
232
|
+
repoId,
|
|
233
|
+
absolutePath,
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
// Verify we're at the root, not a subdirectory
|
|
237
|
+
const toplevelCheck = runGit(["rev-parse", "--show-toplevel"], absolutePath);
|
|
238
|
+
if (toplevelCheck.ok) {
|
|
239
|
+
const toplevelNormalized = canonicalizePath(toplevelCheck.stdout.trim(), "");
|
|
240
|
+
if (toplevelNormalized !== normalizedPath) {
|
|
241
|
+
throw new WorkspaceConfigError(
|
|
242
|
+
"WORKSPACE_REPO_NOT_GIT",
|
|
243
|
+
`Repo '${repoId}' path is a subdirectory of a git repo, not the repo root. Expected root: ${toplevelCheck.stdout.trim()}, got: ${absolutePath}`,
|
|
244
|
+
repoId,
|
|
245
|
+
absolutePath,
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// 7. Collect for duplicate detection (checked after loop)
|
|
251
|
+
if (normalizedPaths.has(normalizedPath)) {
|
|
252
|
+
throw new WorkspaceConfigError(
|
|
253
|
+
"WORKSPACE_DUPLICATE_REPO_PATH",
|
|
254
|
+
`Repos '${normalizedPaths.get(normalizedPath)}' and '${repoId}' share the same path: ${absolutePath}`,
|
|
255
|
+
repoId,
|
|
256
|
+
absolutePath,
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
normalizedPaths.set(normalizedPath, repoId);
|
|
260
|
+
|
|
261
|
+
// Build repo config
|
|
262
|
+
const defaultBranch = typeof repoEntry.default_branch === "string" && repoEntry.default_branch.trim()
|
|
263
|
+
? repoEntry.default_branch.trim()
|
|
264
|
+
: undefined;
|
|
265
|
+
|
|
266
|
+
repos.set(repoId, {
|
|
267
|
+
id: repoId,
|
|
268
|
+
path: absolutePath,
|
|
269
|
+
defaultBranch,
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// ── 8–11. Routing validation ─────────────────────────────────
|
|
274
|
+
const rawRouting = doc.routing as Record<string, unknown>;
|
|
275
|
+
|
|
276
|
+
// 8. routing.tasks_root present
|
|
277
|
+
const rawTasksRoot = rawRouting.tasks_root;
|
|
278
|
+
if (!rawTasksRoot || typeof rawTasksRoot !== "string" || rawTasksRoot.trim() === "") {
|
|
279
|
+
throw new WorkspaceConfigError(
|
|
280
|
+
"WORKSPACE_MISSING_TASKS_ROOT",
|
|
281
|
+
"Workspace config 'routing.tasks_root' is missing or empty.",
|
|
282
|
+
undefined,
|
|
283
|
+
configFile,
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// 9. routing.tasks_root exists on disk
|
|
288
|
+
const tasksRootAbsolute = resolveAbsolutePath(rawTasksRoot.trim(), workspaceRoot);
|
|
289
|
+
if (!existsSync(tasksRootAbsolute)) {
|
|
290
|
+
throw new WorkspaceConfigError(
|
|
291
|
+
"WORKSPACE_TASKS_ROOT_NOT_FOUND",
|
|
292
|
+
`routing.tasks_root path does not exist: ${tasksRootAbsolute}`,
|
|
293
|
+
undefined,
|
|
294
|
+
tasksRootAbsolute,
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// 10. routing.default_repo present
|
|
299
|
+
const rawDefaultRepo = rawRouting.default_repo;
|
|
300
|
+
if (!rawDefaultRepo || typeof rawDefaultRepo !== "string" || rawDefaultRepo.trim() === "") {
|
|
301
|
+
throw new WorkspaceConfigError(
|
|
302
|
+
"WORKSPACE_MISSING_DEFAULT_REPO",
|
|
303
|
+
"Workspace config 'routing.default_repo' is missing or empty.",
|
|
304
|
+
undefined,
|
|
305
|
+
configFile,
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// 11. routing.default_repo references a valid repo ID
|
|
310
|
+
const defaultRepoId = rawDefaultRepo.trim();
|
|
311
|
+
if (!repos.has(defaultRepoId)) {
|
|
312
|
+
throw new WorkspaceConfigError(
|
|
313
|
+
"WORKSPACE_DEFAULT_REPO_NOT_FOUND",
|
|
314
|
+
`routing.default_repo '${defaultRepoId}' does not match any repo ID. Available repos: ${Array.from(repos.keys()).join(", ")}`,
|
|
315
|
+
undefined,
|
|
316
|
+
configFile,
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// ── Build routing config ─────────────────────────────────────
|
|
321
|
+
const routing: WorkspaceRoutingConfig = {
|
|
322
|
+
tasksRoot: tasksRootAbsolute,
|
|
323
|
+
defaultRepo: defaultRepoId,
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
// ── Build and return WorkspaceConfig ─────────────────────────
|
|
327
|
+
return {
|
|
328
|
+
mode: "workspace",
|
|
329
|
+
repos,
|
|
330
|
+
routing,
|
|
331
|
+
configPath: configFile,
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
// ── Execution Context Builder ────────────────────────────────────────
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Build an ExecutionContext from the current working directory.
|
|
340
|
+
*
|
|
341
|
+
* This is the top-level entry point for Step 2 (wire orchestrator startup).
|
|
342
|
+
* It loads all configs, detects workspace mode, and returns a unified context.
|
|
343
|
+
*
|
|
344
|
+
* @param cwd - Current working directory
|
|
345
|
+
* @param loadOrchConfig - Orchestrator config loader (for testability)
|
|
346
|
+
* @param loadTaskConfig - Task runner config loader (for testability)
|
|
347
|
+
* @returns ExecutionContext ready for orchestrator consumption
|
|
348
|
+
* @throws WorkspaceConfigError if workspace config is present but invalid
|
|
349
|
+
*/
|
|
350
|
+
export function buildExecutionContext(
|
|
351
|
+
cwd: string,
|
|
352
|
+
loadOrchConfig: (root: string) => import("./types.ts").OrchestratorConfig,
|
|
353
|
+
loadTaskConfig: (root: string) => import("./types.ts").TaskRunnerConfig,
|
|
354
|
+
): import("./types.ts").ExecutionContext {
|
|
355
|
+
const orchestratorConfig = loadOrchConfig(cwd);
|
|
356
|
+
const taskRunnerConfig = loadTaskConfig(cwd);
|
|
357
|
+
|
|
358
|
+
const workspaceConfig = loadWorkspaceConfig(cwd);
|
|
359
|
+
|
|
360
|
+
if (workspaceConfig === null) {
|
|
361
|
+
// Repo mode: cwd is both workspace root and repo root
|
|
362
|
+
return {
|
|
363
|
+
workspaceRoot: cwd,
|
|
364
|
+
repoRoot: cwd,
|
|
365
|
+
mode: "repo",
|
|
366
|
+
workspaceConfig: null,
|
|
367
|
+
taskRunnerConfig,
|
|
368
|
+
orchestratorConfig,
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Workspace mode: workspace root is cwd, repo root is the default repo
|
|
373
|
+
const defaultRepo = workspaceConfig.repos.get(workspaceConfig.routing.defaultRepo)!;
|
|
374
|
+
return {
|
|
375
|
+
workspaceRoot: cwd,
|
|
376
|
+
repoRoot: defaultRepo.path,
|
|
377
|
+
mode: "workspace",
|
|
378
|
+
workspaceConfig,
|
|
379
|
+
taskRunnerConfig,
|
|
380
|
+
orchestratorConfig,
|
|
381
|
+
};
|
|
382
|
+
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Worktree CRUD, bulk ops, branch protection, preflight
|
|
3
3
|
* @module orch/worktree
|
|
4
4
|
*/
|
|
5
|
-
import { existsSync, readdirSync, realpathSync } from "fs";
|
|
5
|
+
import { existsSync, readdirSync, realpathSync, rmSync } from "fs";
|
|
6
6
|
import { execSync } from "child_process";
|
|
7
7
|
import { join, basename, resolve } from "path";
|
|
8
8
|
|
|
@@ -1595,13 +1595,36 @@ export function safeResetWorktree(
|
|
|
1595
1595
|
};
|
|
1596
1596
|
}
|
|
1597
1597
|
|
|
1598
|
-
// Remove untracked files
|
|
1598
|
+
// Remove untracked files.
|
|
1599
|
+
// git clean may warn about files it can't delete (e.g., Windows reserved
|
|
1600
|
+
// names like "nul", "con", "aux") but still clean everything else.
|
|
1601
|
+
// We treat this as non-fatal: check porcelain status afterward instead
|
|
1602
|
+
// of failing on the exit code.
|
|
1599
1603
|
const cleanResult = runGit(["clean", "-fd"], worktree.path);
|
|
1600
1604
|
if (!cleanResult.ok) {
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
+
execLog("reset", `lane-${worktree.laneNumber}`, "git clean -fd returned non-zero (may be partial)", {
|
|
1606
|
+
stderr: cleanResult.stderr.slice(0, 200),
|
|
1607
|
+
});
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
// Check if the worktree is clean enough to proceed.
|
|
1611
|
+
// If git status --porcelain shows no tracked changes, the reset can work
|
|
1612
|
+
// even if some untracked files couldn't be deleted.
|
|
1613
|
+
const statusCheck = runGit(["status", "--porcelain"], worktree.path);
|
|
1614
|
+
if (statusCheck.ok && statusCheck.stdout.length > 0) {
|
|
1615
|
+
// Still dirty after cleaning — check if only untracked files remain
|
|
1616
|
+
const lines = statusCheck.stdout.split("\n").filter(l => l.trim());
|
|
1617
|
+
const onlyUntracked = lines.every(l => l.startsWith("??"));
|
|
1618
|
+
if (!onlyUntracked) {
|
|
1619
|
+
return {
|
|
1620
|
+
success: false,
|
|
1621
|
+
error: `Worktree still dirty after clean: ${statusCheck.stdout.slice(0, 200)}`,
|
|
1622
|
+
};
|
|
1623
|
+
}
|
|
1624
|
+
// Only untracked files remain (e.g., undeletable "nul") — safe to proceed
|
|
1625
|
+
execLog("reset", `lane-${worktree.laneNumber}`, "untracked files remain after clean (non-blocking)", {
|
|
1626
|
+
files: lines.map(l => l.slice(3)).join(", "),
|
|
1627
|
+
});
|
|
1605
1628
|
}
|
|
1606
1629
|
|
|
1607
1630
|
// Retry reset after cleaning
|
|
@@ -1623,3 +1646,81 @@ export function safeResetWorktree(
|
|
|
1623
1646
|
}
|
|
1624
1647
|
}
|
|
1625
1648
|
|
|
1649
|
+
|
|
1650
|
+
// ── Force Cleanup ────────────────────────────────────────────────────
|
|
1651
|
+
|
|
1652
|
+
/**
|
|
1653
|
+
* Last-resort worktree cleanup: force-remove the directory and prune git state.
|
|
1654
|
+
*
|
|
1655
|
+
* Used when both `safeResetWorktree()` and `removeWorktree()` fail — typically
|
|
1656
|
+
* because undeletable files (e.g., Windows reserved names like "nul", "con")
|
|
1657
|
+
* block `git clean` and `git worktree remove`, leaving git in an inconsistent state.
|
|
1658
|
+
*
|
|
1659
|
+
* Recovery steps:
|
|
1660
|
+
* 1. Force-remove the worktree directory (`rm -rf` equivalent)
|
|
1661
|
+
* 2. Prune stale git worktree references (`git worktree prune`)
|
|
1662
|
+
* 3. Delete the lane branch if it exists (`git branch -D`)
|
|
1663
|
+
*
|
|
1664
|
+
* This allows the next wave to recreate the worktree from scratch.
|
|
1665
|
+
*
|
|
1666
|
+
* @param worktree - WorktreeInfo for the failed worktree
|
|
1667
|
+
* @param repoRoot - Main repository root
|
|
1668
|
+
* @param batchId - Batch ID for logging context
|
|
1669
|
+
*/
|
|
1670
|
+
export function forceCleanupWorktree(
|
|
1671
|
+
worktree: WorktreeInfo,
|
|
1672
|
+
repoRoot: string,
|
|
1673
|
+
batchId: string,
|
|
1674
|
+
): void {
|
|
1675
|
+
const { path: worktreePath, branch, laneNumber } = worktree;
|
|
1676
|
+
|
|
1677
|
+
// Step 1: Force-remove the directory
|
|
1678
|
+
if (existsSync(worktreePath)) {
|
|
1679
|
+
try {
|
|
1680
|
+
// On Windows, undeletable reserved-name files (nul, con, aux) need
|
|
1681
|
+
// special handling. Try rmSync first, then fall back to OS-specific
|
|
1682
|
+
// removal for stubborn files.
|
|
1683
|
+
rmSync(worktreePath, { recursive: true, force: true });
|
|
1684
|
+
execLog("cleanup", `lane-${laneNumber}`, `force-removed worktree directory`, { path: worktreePath });
|
|
1685
|
+
} catch (rmErr: unknown) {
|
|
1686
|
+
// If Node's rmSync fails (e.g., Windows reserved names), try platform-specific
|
|
1687
|
+
const rmMsg = rmErr instanceof Error ? rmErr.message : String(rmErr);
|
|
1688
|
+
execLog("cleanup", `lane-${laneNumber}`, `rmSync failed, trying OS-level removal`, { error: rmMsg });
|
|
1689
|
+
|
|
1690
|
+
try {
|
|
1691
|
+
if (process.platform === "win32") {
|
|
1692
|
+
// rd /s /q handles Windows reserved names that Node.js cannot delete
|
|
1693
|
+
execSync(`rd /s /q "${worktreePath}"`, { stdio: "pipe", timeout: 30_000 });
|
|
1694
|
+
} else {
|
|
1695
|
+
execSync(`rm -rf "${worktreePath}"`, { stdio: "pipe", timeout: 30_000 });
|
|
1696
|
+
}
|
|
1697
|
+
execLog("cleanup", `lane-${laneNumber}`, `OS-level removal succeeded`, { path: worktreePath });
|
|
1698
|
+
} catch (osErr: unknown) {
|
|
1699
|
+
const osMsg = osErr instanceof Error ? osErr.message : String(osErr);
|
|
1700
|
+
execLog("cleanup", `lane-${laneNumber}`, `OS-level removal also failed — manual cleanup needed`, {
|
|
1701
|
+
path: worktreePath,
|
|
1702
|
+
error: osMsg,
|
|
1703
|
+
});
|
|
1704
|
+
}
|
|
1705
|
+
}
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1708
|
+
// Step 2: Prune stale worktree references
|
|
1709
|
+
runGit(["worktree", "prune"], repoRoot);
|
|
1710
|
+
execLog("cleanup", `lane-${laneNumber}`, `pruned stale worktree references`);
|
|
1711
|
+
|
|
1712
|
+
// Step 3: Delete the lane branch if it still exists
|
|
1713
|
+
const branchCheck = runGit(["rev-parse", "--verify", `refs/heads/${branch}`], repoRoot);
|
|
1714
|
+
if (branchCheck.ok) {
|
|
1715
|
+
const deleteResult = runGit(["branch", "-D", branch], repoRoot);
|
|
1716
|
+
if (deleteResult.ok) {
|
|
1717
|
+
execLog("cleanup", `lane-${laneNumber}`, `deleted stale lane branch`, { branch });
|
|
1718
|
+
} else {
|
|
1719
|
+
execLog("cleanup", `lane-${laneNumber}`, `could not delete lane branch`, {
|
|
1720
|
+
branch,
|
|
1721
|
+
error: deleteResult.stderr,
|
|
1722
|
+
});
|
|
1723
|
+
}
|
|
1724
|
+
}
|
|
1725
|
+
}
|
|
1726
|
+
|