wave-agent-sdk 0.19.9 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/builtin/plugins/sdd/scripts/session-start.js +1 -1
- package/builtin/plugins/sdd/skills/specify/SKILL.md +3 -4
- package/builtin/skills/settings/ENV.md +15 -9
- package/builtin/skills/settings/HOOKS.md +27 -2
- package/dist/agent.js +5 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/managers/aiManager.d.ts +8 -0
- package/dist/managers/aiManager.js +20 -5
- package/dist/managers/backgroundTaskManager.d.ts +6 -0
- package/dist/managers/backgroundTaskManager.js +11 -0
- package/dist/managers/bangManager.d.ts +6 -0
- package/dist/managers/bangManager.js +11 -0
- package/dist/managers/hookManager.d.ts +8 -2
- package/dist/managers/hookManager.js +14 -4
- package/dist/managers/mcpManager.d.ts +18 -4
- package/dist/managers/mcpManager.js +40 -18
- package/dist/managers/toolManager.js +5 -0
- package/dist/services/configurationService.d.ts +21 -2
- package/dist/services/configurationService.js +72 -23
- package/dist/services/initializationService.js +14 -4
- package/dist/services/interactionService.js +35 -7
- package/dist/services/remoteSettingsService.d.ts +12 -0
- package/dist/services/remoteSettingsService.js +15 -1
- package/dist/services/taskManager.js +7 -1
- package/dist/tools/bashTool.js +1 -0
- package/dist/tools/enterWorktreeTool.js +14 -3
- package/dist/tools/exitWorktreeTool.js +11 -10
- package/dist/tools/types.d.ts +7 -0
- package/dist/types/config.d.ts +2 -0
- package/dist/types/hooks.d.ts +2 -2
- package/dist/utils/containerSetup.js +1 -1
- package/dist/utils/openaiClient.js +2 -1
- package/dist/utils/pathEncoder.js +7 -2
- package/dist/utils/worktreeUtils.d.ts +17 -0
- package/dist/utils/worktreeUtils.js +339 -1
- package/package.json +1 -1
- package/src/agent.ts +7 -2
- package/src/index.ts +1 -0
- package/src/managers/aiManager.ts +23 -5
- package/src/managers/backgroundTaskManager.ts +15 -0
- package/src/managers/bangManager.ts +15 -0
- package/src/managers/hookManager.ts +20 -5
- package/src/managers/mcpManager.ts +60 -18
- package/src/managers/toolManager.ts +7 -0
- package/src/services/configurationService.ts +84 -23
- package/src/services/initializationService.ts +17 -4
- package/src/services/interactionService.ts +49 -6
- package/src/services/remoteSettingsService.ts +16 -1
- package/src/services/taskManager.ts +10 -1
- package/src/tools/bashTool.ts +1 -0
- package/src/tools/enterWorktreeTool.ts +19 -2
- package/src/tools/exitWorktreeTool.ts +15 -12
- package/src/tools/types.ts +7 -0
- package/src/types/config.ts +2 -0
- package/src/types/hooks.ts +2 -2
- package/src/utils/containerSetup.ts +3 -1
- package/src/utils/openaiClient.ts +2 -0
- package/src/utils/pathEncoder.ts +7 -2
- package/src/utils/worktreeUtils.ts +401 -1
|
@@ -162,16 +162,8 @@ export const exitWorktreeTool: ToolPlugin = {
|
|
|
162
162
|
session.originalHeadCommit,
|
|
163
163
|
) ?? { changedFiles: 0, commits: 0 };
|
|
164
164
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
// Clear session state and restore CWD
|
|
168
|
-
const aiManager = context.aiManager;
|
|
169
|
-
if (aiManager) {
|
|
170
|
-
aiManager.setWorktreeSession(null);
|
|
171
|
-
aiManager.setWorkdir(originalCwd);
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
// Trigger WorktreeRemove hook (non-blocking)
|
|
165
|
+
// Trigger WorktreeRemove hook (non-blocking) BEFORE git removal so hooks
|
|
166
|
+
// can still read files inside the worktree to clean up external resources.
|
|
175
167
|
let hookTriggered = false;
|
|
176
168
|
if (context.hookManager) {
|
|
177
169
|
try {
|
|
@@ -182,11 +174,13 @@ export const exitWorktreeTool: ToolPlugin = {
|
|
|
182
174
|
projectDir: originalCwd,
|
|
183
175
|
timestamp: new Date(),
|
|
184
176
|
sessionId: context.sessionId ?? "",
|
|
185
|
-
transcriptPath: context.messageManager?.getTranscriptPath() ?? "",
|
|
177
|
+
transcriptPath: context.messageManager?.getTranscriptPath?.() ?? "",
|
|
186
178
|
cwd: originalCwd,
|
|
187
179
|
worktreePath,
|
|
188
180
|
env: Object.fromEntries(
|
|
189
|
-
Object.entries(process.env).filter(
|
|
181
|
+
Object.entries(context.sessionEnv ?? process.env).filter(
|
|
182
|
+
(e) => e[1] !== undefined,
|
|
183
|
+
),
|
|
190
184
|
) as Record<string, string>,
|
|
191
185
|
},
|
|
192
186
|
);
|
|
@@ -206,6 +200,15 @@ export const exitWorktreeTool: ToolPlugin = {
|
|
|
206
200
|
}
|
|
207
201
|
}
|
|
208
202
|
|
|
203
|
+
removeWorktree(worktreeInfo);
|
|
204
|
+
|
|
205
|
+
// Clear session state and restore CWD
|
|
206
|
+
const aiManager = context.aiManager;
|
|
207
|
+
if (aiManager) {
|
|
208
|
+
aiManager.setWorktreeSession(null);
|
|
209
|
+
aiManager.setWorkdir(originalCwd);
|
|
210
|
+
}
|
|
211
|
+
|
|
209
212
|
const discardParts: string[] = [];
|
|
210
213
|
if (summary.commits > 0) {
|
|
211
214
|
discardParts.push(
|
package/src/tools/types.ts
CHANGED
|
@@ -127,4 +127,11 @@ export interface ToolContext {
|
|
|
127
127
|
originalWorkdir?: string;
|
|
128
128
|
/** Workflow manager instance for workflow orchestration */
|
|
129
129
|
workflowManager?: import("../managers/workflowManager.js").WorkflowManager;
|
|
130
|
+
/**
|
|
131
|
+
* Per-session merged environment (OS env overlaid with the settings env
|
|
132
|
+
* snapshot) for this session. Tools that spawn subprocesses (Bash, hooks)
|
|
133
|
+
* should merge this on top of `process.env` so settings `env` vars reach
|
|
134
|
+
* the subprocess without polluting other sessions in one stdio process.
|
|
135
|
+
*/
|
|
136
|
+
sessionEnv?: Record<string, string>;
|
|
130
137
|
}
|
package/src/types/config.ts
CHANGED
|
@@ -12,6 +12,8 @@ export interface GatewayConfig {
|
|
|
12
12
|
defaultHeaders?: Record<string, string>;
|
|
13
13
|
fetchOptions?: OpenAI["fetchOptions"];
|
|
14
14
|
fetch?: OpenAI["fetch"];
|
|
15
|
+
/** Session identifier, sent as the `x-session-id` request header for backend correlation. */
|
|
16
|
+
sessionId?: string;
|
|
15
17
|
}
|
|
16
18
|
|
|
17
19
|
export interface ModelCapabilities {
|
package/src/types/hooks.ts
CHANGED
|
@@ -102,9 +102,9 @@ export class HookConfigurationError extends Error {
|
|
|
102
102
|
}
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
-
export type SessionStartSource = "startup" | "compact" | "clear";
|
|
105
|
+
export type SessionStartSource = "startup" | "resume" | "compact" | "clear";
|
|
106
106
|
|
|
107
|
-
export type SessionEndSource = "exit" | "stop" | "compact" | "clear";
|
|
107
|
+
export type SessionEndSource = "exit" | "resume" | "stop" | "compact" | "clear";
|
|
108
108
|
|
|
109
109
|
// Type guards for runtime validation
|
|
110
110
|
export function isValidHookEvent(event: string): event is HookEvent {
|
|
@@ -227,7 +227,9 @@ export function setupAgentContainer(
|
|
|
227
227
|
toolInput: context.toolInput,
|
|
228
228
|
planFilePath: permissionManager.getPlanFilePath(),
|
|
229
229
|
env: Object.fromEntries(
|
|
230
|
-
Object.entries(
|
|
230
|
+
Object.entries(configurationService.getMergedEnv()).filter(
|
|
231
|
+
(e) => e[1] !== undefined,
|
|
232
|
+
),
|
|
231
233
|
) as Record<string, string>,
|
|
232
234
|
});
|
|
233
235
|
|
|
@@ -97,6 +97,7 @@ export class OpenAIClient {
|
|
|
97
97
|
const {
|
|
98
98
|
baseURL,
|
|
99
99
|
apiKey,
|
|
100
|
+
sessionId,
|
|
100
101
|
defaultHeaders,
|
|
101
102
|
fetchOptions,
|
|
102
103
|
fetch: customFetch,
|
|
@@ -105,6 +106,7 @@ export class OpenAIClient {
|
|
|
105
106
|
const headers = {
|
|
106
107
|
"Content-Type": "application/json",
|
|
107
108
|
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
|
|
109
|
+
...(sessionId ? { "x-session-id": sessionId } : {}),
|
|
108
110
|
...defaultHeaders,
|
|
109
111
|
};
|
|
110
112
|
|
package/src/utils/pathEncoder.ts
CHANGED
|
@@ -212,8 +212,13 @@ export class PathEncoder {
|
|
|
212
212
|
resolvedPath = absolutePath;
|
|
213
213
|
}
|
|
214
214
|
|
|
215
|
-
// Encode the resolved path
|
|
216
|
-
|
|
215
|
+
// Encode the resolved path. Use encodeSync (pure string transform) rather
|
|
216
|
+
// than encode(): realpath was already attempted above with its own fallback,
|
|
217
|
+
// so re-resolving via encode()->resolvePath() would throw ENOENT again when
|
|
218
|
+
// the workdir no longer exists (e.g. a deleted worktree during agent
|
|
219
|
+
// shutdown). Session files live under <baseSessionDir>/<encoded>/ regardless
|
|
220
|
+
// of the workdir's existence, so encoding must not require it to exist.
|
|
221
|
+
const encodedName = this.encodeSync(resolvedPath);
|
|
217
222
|
const encodedPath = join(baseSessionDir, encodedName);
|
|
218
223
|
|
|
219
224
|
// Generate hash if encoding resulted in truncation
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
* Used by EnterWorktree and ExitWorktree tools.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { execFileSync } from "node:child_process";
|
|
6
|
+
import { execFile, execFileSync } from "node:child_process";
|
|
7
|
+
import { promisify } from "node:util";
|
|
7
8
|
import * as path from "node:path";
|
|
8
9
|
import * as fs from "node:fs";
|
|
9
10
|
import {
|
|
@@ -13,6 +14,11 @@ import {
|
|
|
13
14
|
} from "./gitUtils.js";
|
|
14
15
|
import { logger } from "./globalLogger.js";
|
|
15
16
|
|
|
17
|
+
// Post-creation setup runs inside the shared `wave --stdio` process too
|
|
18
|
+
// (desktop sessions), so use the async execFile: a synchronous git call
|
|
19
|
+
// (e.g. `git ls-files` over a large working tree) would freeze every session.
|
|
20
|
+
const execFileAsync = promisify(execFile);
|
|
21
|
+
|
|
16
22
|
export interface WorktreeInfo {
|
|
17
23
|
name: string;
|
|
18
24
|
path: string;
|
|
@@ -266,6 +272,338 @@ export function createWorktree(
|
|
|
266
272
|
}
|
|
267
273
|
}
|
|
268
274
|
|
|
275
|
+
// --- .worktreeinclude matching ----------------------------------------------
|
|
276
|
+
|
|
277
|
+
interface WorktreeIncludePattern {
|
|
278
|
+
/** Normalized pattern text (no leading "/", no trailing "/", no "!") */
|
|
279
|
+
raw: string;
|
|
280
|
+
negated: boolean;
|
|
281
|
+
/** Pattern ends with "/" — matches directories only */
|
|
282
|
+
dirOnly: boolean;
|
|
283
|
+
/** Pattern had a leading "/" — anchored to the repo root */
|
|
284
|
+
anchored: boolean;
|
|
285
|
+
/** No "/" and not anchored — matches the basename at any level */
|
|
286
|
+
anyLevel: boolean;
|
|
287
|
+
regex: RegExp;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** Translate a single gitignore glob into a RegExp (gitignore semantics). */
|
|
291
|
+
function globToRegExp(glob: string): RegExp {
|
|
292
|
+
let source = "^";
|
|
293
|
+
for (let i = 0; i < glob.length; i++) {
|
|
294
|
+
const ch = glob[i];
|
|
295
|
+
if (ch === "*") {
|
|
296
|
+
if (glob[i + 1] === "*") {
|
|
297
|
+
if (glob[i + 2] === "/") {
|
|
298
|
+
// "**/" matches zero or more leading directories
|
|
299
|
+
source += "(?:.*/)?";
|
|
300
|
+
i += 2;
|
|
301
|
+
} else {
|
|
302
|
+
source += ".*";
|
|
303
|
+
i += 1;
|
|
304
|
+
}
|
|
305
|
+
} else {
|
|
306
|
+
source += "[^/]*";
|
|
307
|
+
}
|
|
308
|
+
} else if (ch === "?") {
|
|
309
|
+
source += "[^/]";
|
|
310
|
+
} else if (ch === "[") {
|
|
311
|
+
const end = glob.indexOf("]", i + 1);
|
|
312
|
+
if (end === -1) {
|
|
313
|
+
source += "\\[";
|
|
314
|
+
} else {
|
|
315
|
+
let charClass = glob.slice(i, end + 1);
|
|
316
|
+
if (charClass.startsWith("[!")) {
|
|
317
|
+
// gitignore uses "[!...]" for a negated character class
|
|
318
|
+
charClass = "[^" + charClass.slice(2);
|
|
319
|
+
}
|
|
320
|
+
source += charClass;
|
|
321
|
+
i = end;
|
|
322
|
+
}
|
|
323
|
+
} else {
|
|
324
|
+
source += ch.replace(/[.+^${}()|\\]/g, "\\$&");
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
source += "$";
|
|
328
|
+
return new RegExp(source);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Parse .worktreeinclude content into patterns. Blank lines and "#" comments
|
|
333
|
+
* are skipped, matching gitignore conventions.
|
|
334
|
+
*/
|
|
335
|
+
function parseWorktreeIncludePatterns(
|
|
336
|
+
content: string,
|
|
337
|
+
): WorktreeIncludePattern[] {
|
|
338
|
+
const patterns: WorktreeIncludePattern[] = [];
|
|
339
|
+
for (const rawLine of content.split(/\r?\n/)) {
|
|
340
|
+
const line = rawLine.trim();
|
|
341
|
+
if (!line || line.startsWith("#")) continue;
|
|
342
|
+
let raw = line;
|
|
343
|
+
const negated = raw.startsWith("!");
|
|
344
|
+
if (negated) raw = raw.slice(1);
|
|
345
|
+
const dirOnly = raw.endsWith("/");
|
|
346
|
+
if (dirOnly) raw = raw.slice(0, -1);
|
|
347
|
+
const anchored = raw.startsWith("/");
|
|
348
|
+
if (anchored) raw = raw.slice(1);
|
|
349
|
+
if (!raw) continue;
|
|
350
|
+
patterns.push({
|
|
351
|
+
raw,
|
|
352
|
+
negated,
|
|
353
|
+
dirOnly,
|
|
354
|
+
anchored,
|
|
355
|
+
anyLevel: !anchored && !raw.includes("/"),
|
|
356
|
+
regex: globToRegExp(raw),
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
return patterns;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** Test a single pattern against one candidate path. */
|
|
363
|
+
function testPatternAgainst(
|
|
364
|
+
pattern: WorktreeIncludePattern,
|
|
365
|
+
candidate: string,
|
|
366
|
+
): boolean {
|
|
367
|
+
if (pattern.anyLevel) {
|
|
368
|
+
// Match the full path or any "/"-suffix (basename at any level)
|
|
369
|
+
if (pattern.regex.test(candidate)) return true;
|
|
370
|
+
for (let i = 0; i < candidate.length; i++) {
|
|
371
|
+
if (candidate[i] === "/" && pattern.regex.test(candidate.slice(i + 1))) {
|
|
372
|
+
return true;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
return false;
|
|
376
|
+
}
|
|
377
|
+
return pattern.regex.test(candidate);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Test a pattern against a path and every ancestor prefix. Excluding a
|
|
382
|
+
* directory excludes everything beneath it (gitignore semantics), so ancestor
|
|
383
|
+
* prefixes are always treated as directories.
|
|
384
|
+
*/
|
|
385
|
+
function patternMatchesPath(
|
|
386
|
+
pattern: WorktreeIncludePattern,
|
|
387
|
+
relPath: string,
|
|
388
|
+
isDir: boolean,
|
|
389
|
+
): boolean {
|
|
390
|
+
let candidate = relPath;
|
|
391
|
+
for (;;) {
|
|
392
|
+
const candidateIsDir = candidate === relPath ? isDir : true;
|
|
393
|
+
if (
|
|
394
|
+
!(pattern.dirOnly && !candidateIsDir) &&
|
|
395
|
+
testPatternAgainst(pattern, candidate)
|
|
396
|
+
) {
|
|
397
|
+
return true;
|
|
398
|
+
}
|
|
399
|
+
const idx = candidate.lastIndexOf("/");
|
|
400
|
+
if (idx === -1) break;
|
|
401
|
+
candidate = candidate.slice(0, idx);
|
|
402
|
+
}
|
|
403
|
+
return false;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** Last-match-wins resolution with "!" negation. */
|
|
407
|
+
function worktreeIncludeMatches(
|
|
408
|
+
patterns: WorktreeIncludePattern[],
|
|
409
|
+
relPath: string,
|
|
410
|
+
isDir: boolean,
|
|
411
|
+
): boolean {
|
|
412
|
+
let matched = false;
|
|
413
|
+
for (const pattern of patterns) {
|
|
414
|
+
if (patternMatchesPath(pattern, relPath, isDir)) {
|
|
415
|
+
matched = !pattern.negated;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
return matched;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* A positive pattern targets something inside a collapsed directory: either it
|
|
423
|
+
* literally starts with the directory path, or the directory path starts with
|
|
424
|
+
* the pattern's literal (pre-glob) prefix.
|
|
425
|
+
*/
|
|
426
|
+
function needsExpansion(
|
|
427
|
+
patterns: WorktreeIncludePattern[],
|
|
428
|
+
dir: string,
|
|
429
|
+
): boolean {
|
|
430
|
+
return patterns.some((p) => {
|
|
431
|
+
if (p.negated) return false;
|
|
432
|
+
if (p.raw.startsWith(dir + "/")) return true;
|
|
433
|
+
const globIdx = p.raw.search(/[*?[]/);
|
|
434
|
+
if (globIdx > 0 && dir.startsWith(p.raw.slice(0, globIdx))) return true;
|
|
435
|
+
return false;
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// --- Post-creation setup -----------------------------------------------------
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Copy <repoRoot>/.wave/settings.local.json into the worktree so local
|
|
443
|
+
* configuration (permissions, env, ...) carries over. Missing files are
|
|
444
|
+
* skipped silently; other failures only log a warning.
|
|
445
|
+
*/
|
|
446
|
+
async function copyLocalSettingsToWorktree(
|
|
447
|
+
repoRoot: string,
|
|
448
|
+
worktreePath: string,
|
|
449
|
+
): Promise<void> {
|
|
450
|
+
const relativePath = path.join(".wave", "settings.local.json");
|
|
451
|
+
const sourcePath = path.join(repoRoot, relativePath);
|
|
452
|
+
const destPath = path.join(worktreePath, relativePath);
|
|
453
|
+
let content: string;
|
|
454
|
+
try {
|
|
455
|
+
content = await fs.promises.readFile(sourcePath, "utf8");
|
|
456
|
+
} catch (error: unknown) {
|
|
457
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
|
458
|
+
logger.warn(`Failed to read ${sourcePath}: ${(error as Error).message}`);
|
|
459
|
+
}
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
try {
|
|
463
|
+
await fs.promises.mkdir(path.dirname(destPath), { recursive: true });
|
|
464
|
+
await fs.promises.writeFile(destPath, content);
|
|
465
|
+
} catch (error: unknown) {
|
|
466
|
+
logger.warn(
|
|
467
|
+
`Failed to copy ${relativePath} into worktree: ${(error as Error).message}`,
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Copy files listed in <repoRoot>/.worktreeinclude into the worktree. Each
|
|
474
|
+
* non-comment line is a gitignore pattern; matching gitignored files are
|
|
475
|
+
* copied at their relative paths.
|
|
476
|
+
*/
|
|
477
|
+
async function copyWorktreeIncludeFiles(
|
|
478
|
+
repoRoot: string,
|
|
479
|
+
worktreePath: string,
|
|
480
|
+
): Promise<string[]> {
|
|
481
|
+
const includePath = path.join(repoRoot, ".worktreeinclude");
|
|
482
|
+
let content: string;
|
|
483
|
+
try {
|
|
484
|
+
content = await fs.promises.readFile(includePath, "utf8");
|
|
485
|
+
} catch {
|
|
486
|
+
return [];
|
|
487
|
+
}
|
|
488
|
+
const patterns = parseWorktreeIncludePatterns(content);
|
|
489
|
+
if (patterns.length === 0) return [];
|
|
490
|
+
|
|
491
|
+
const worktreeRelPath = path
|
|
492
|
+
.relative(repoRoot, worktreePath)
|
|
493
|
+
.split(path.sep)
|
|
494
|
+
.join("/");
|
|
495
|
+
// The worktree lives under .wave/worktrees/, which is gitignored — never
|
|
496
|
+
// copy the worktree (or anything inside it) into itself.
|
|
497
|
+
const isSelf = (entry: string) =>
|
|
498
|
+
entry === worktreeRelPath || entry.startsWith(worktreeRelPath + "/");
|
|
499
|
+
|
|
500
|
+
// "--directory" collapses fully-ignored directories into single entries,
|
|
501
|
+
// which keeps the listing fast on large repos.
|
|
502
|
+
let entries: string[];
|
|
503
|
+
try {
|
|
504
|
+
const { stdout } = await execFileAsync(
|
|
505
|
+
"git",
|
|
506
|
+
[
|
|
507
|
+
"ls-files",
|
|
508
|
+
"--others",
|
|
509
|
+
"--ignored",
|
|
510
|
+
"--exclude-standard",
|
|
511
|
+
"--directory",
|
|
512
|
+
],
|
|
513
|
+
{ cwd: repoRoot, encoding: "utf8" },
|
|
514
|
+
);
|
|
515
|
+
entries = stdout.trim().split("\n").filter(Boolean);
|
|
516
|
+
} catch {
|
|
517
|
+
return [];
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
const copied: string[] = [];
|
|
521
|
+
const dirsToExpand: string[] = [];
|
|
522
|
+
|
|
523
|
+
const copyToWorktree = async (relativePath: string): Promise<void> => {
|
|
524
|
+
const srcPath = path.join(repoRoot, relativePath);
|
|
525
|
+
const destPath = path.join(worktreePath, relativePath);
|
|
526
|
+
try {
|
|
527
|
+
await fs.promises.mkdir(path.dirname(destPath), { recursive: true });
|
|
528
|
+
await fs.promises.copyFile(srcPath, destPath);
|
|
529
|
+
copied.push(relativePath);
|
|
530
|
+
} catch (error: unknown) {
|
|
531
|
+
logger.warn(
|
|
532
|
+
`Failed to copy ${relativePath} into worktree: ${(error as Error).message}`,
|
|
533
|
+
);
|
|
534
|
+
}
|
|
535
|
+
};
|
|
536
|
+
|
|
537
|
+
for (const entry of entries) {
|
|
538
|
+
if (isSelf(entry)) continue;
|
|
539
|
+
if (entry.endsWith("/")) {
|
|
540
|
+
const dir = entry.slice(0, -1);
|
|
541
|
+
if (worktreeIncludeMatches(patterns, dir, true)) {
|
|
542
|
+
// The directory itself matches — copy it wholesale
|
|
543
|
+
try {
|
|
544
|
+
await fs.promises.cp(
|
|
545
|
+
path.join(repoRoot, dir),
|
|
546
|
+
path.join(worktreePath, dir),
|
|
547
|
+
{
|
|
548
|
+
recursive: true,
|
|
549
|
+
},
|
|
550
|
+
);
|
|
551
|
+
copied.push(entry);
|
|
552
|
+
} catch (error: unknown) {
|
|
553
|
+
logger.warn(
|
|
554
|
+
`Failed to copy ${entry} into worktree: ${(error as Error).message}`,
|
|
555
|
+
);
|
|
556
|
+
}
|
|
557
|
+
} else if (needsExpansion(patterns, dir)) {
|
|
558
|
+
dirsToExpand.push(dir);
|
|
559
|
+
}
|
|
560
|
+
} else if (worktreeIncludeMatches(patterns, entry, false)) {
|
|
561
|
+
await copyToWorktree(entry);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
if (dirsToExpand.length > 0) {
|
|
566
|
+
// List the files inside collapsed dirs whose contents are targeted
|
|
567
|
+
try {
|
|
568
|
+
const { stdout } = await execFileAsync(
|
|
569
|
+
"git",
|
|
570
|
+
[
|
|
571
|
+
"ls-files",
|
|
572
|
+
"--others",
|
|
573
|
+
"--ignored",
|
|
574
|
+
"--exclude-standard",
|
|
575
|
+
"--",
|
|
576
|
+
...dirsToExpand,
|
|
577
|
+
],
|
|
578
|
+
{ cwd: repoRoot, encoding: "utf8" },
|
|
579
|
+
);
|
|
580
|
+
for (const file of stdout.trim().split("\n").filter(Boolean)) {
|
|
581
|
+
if (isSelf(file)) continue;
|
|
582
|
+
if (worktreeIncludeMatches(patterns, file, false)) {
|
|
583
|
+
await copyToWorktree(file);
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
} catch {
|
|
587
|
+
// Expansion is best-effort; the worktree is already usable without it
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
return copied;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* Set up a freshly created worktree: copy local settings and gitignored
|
|
596
|
+
* project files (via .worktreeinclude) from the main repo. Best-effort — any
|
|
597
|
+
* failure only logs a warning and never fails worktree creation.
|
|
598
|
+
*/
|
|
599
|
+
export async function performPostCreationSetup(
|
|
600
|
+
worktreePath: string,
|
|
601
|
+
repoRoot: string,
|
|
602
|
+
): Promise<void> {
|
|
603
|
+
await copyLocalSettingsToWorktree(repoRoot, worktreePath);
|
|
604
|
+
await copyWorktreeIncludeFiles(repoRoot, worktreePath);
|
|
605
|
+
}
|
|
606
|
+
|
|
269
607
|
/**
|
|
270
608
|
* Remove a git worktree and its branch.
|
|
271
609
|
*/
|
|
@@ -338,6 +676,68 @@ export function removeWorktree(info: WorktreeInfo): void {
|
|
|
338
676
|
}
|
|
339
677
|
}
|
|
340
678
|
|
|
679
|
+
/**
|
|
680
|
+
* Validate that a worktree path is safe to remove before running git removal.
|
|
681
|
+
* Aligns with Claude Code v2.1.216+ background-session checks:
|
|
682
|
+
* - rejects a path whose final component is a symlink;
|
|
683
|
+
* - rejects a path that resolves outside the repo root.
|
|
684
|
+
*
|
|
685
|
+
* A path that no longer exists (worktree already removed) is allowed so that
|
|
686
|
+
* removal stays best-effort/idempotent; its nearest existing ancestor is used
|
|
687
|
+
* for the containment check. Throws an Error on invalid paths.
|
|
688
|
+
*/
|
|
689
|
+
export function validateWorktreeRemovalPath(
|
|
690
|
+
worktreePath: string,
|
|
691
|
+
repoRoot: string,
|
|
692
|
+
): void {
|
|
693
|
+
const SYMLINK_PREFIX = "Refusing to remove worktree at symlink path:";
|
|
694
|
+
|
|
695
|
+
// Reject a symlink as the final path component.
|
|
696
|
+
try {
|
|
697
|
+
if (fs.lstatSync(worktreePath).isSymbolicLink()) {
|
|
698
|
+
throw new Error(`${SYMLINK_PREFIX} ${worktreePath}`);
|
|
699
|
+
}
|
|
700
|
+
} catch (error: unknown) {
|
|
701
|
+
if (error instanceof Error && error.message.startsWith(SYMLINK_PREFIX)) {
|
|
702
|
+
throw error;
|
|
703
|
+
}
|
|
704
|
+
// lstat failed (path missing) — the containment check below still applies.
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
const resolvedRepoRoot = fs.realpathSync(repoRoot);
|
|
708
|
+
|
|
709
|
+
// Resolve the path, following symlinks in existing components. If the path
|
|
710
|
+
// (or a parent) no longer exists, fall back to the deepest existing ancestor
|
|
711
|
+
// so the containment check still guards against traversal outside the repo.
|
|
712
|
+
let resolvedPath: string;
|
|
713
|
+
let existingAncestor = worktreePath;
|
|
714
|
+
for (;;) {
|
|
715
|
+
try {
|
|
716
|
+
resolvedPath = fs.realpathSync(existingAncestor);
|
|
717
|
+
break;
|
|
718
|
+
} catch {
|
|
719
|
+
const parent = path.dirname(existingAncestor);
|
|
720
|
+
if (parent === existingAncestor) {
|
|
721
|
+
throw new Error(`Invalid worktree path for removal: ${worktreePath}`);
|
|
722
|
+
}
|
|
723
|
+
existingAncestor = parent;
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
if (existingAncestor !== worktreePath) {
|
|
727
|
+
resolvedPath = path.join(
|
|
728
|
+
resolvedPath,
|
|
729
|
+
path.relative(existingAncestor, worktreePath),
|
|
730
|
+
);
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
const relative = path.relative(resolvedRepoRoot, resolvedPath);
|
|
734
|
+
if (relative.startsWith("..") || path.isAbsolute(relative)) {
|
|
735
|
+
throw new Error(
|
|
736
|
+
`Refusing to remove worktree outside repo root: ${worktreePath}`,
|
|
737
|
+
);
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
|
|
341
741
|
/**
|
|
342
742
|
* Count uncommitted files and new commits in a worktree.
|
|
343
743
|
* Returns null if git commands fail (fail-closed).
|