@cjhyy/code-shell-core 0.6.0-rc.9 → 0.7.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/THIRD_PARTY_NOTICES.md +206 -0
- package/dist/automation/scheduler.d.ts +13 -7
- package/dist/automation/scheduler.js +116 -37
- package/dist/capability-control/service.d.ts +2 -0
- package/dist/capability-control/service.js +4 -2
- package/dist/cc-orchestrator/agent-adapter.d.ts +4 -0
- package/dist/cc-orchestrator/agent-adapter.js +11 -1
- package/dist/cc-orchestrator/codex-session-history.d.ts +14 -1
- package/dist/cc-orchestrator/codex-session-history.js +65 -5
- package/dist/cc-orchestrator/cwd-normalize.d.ts +2 -0
- package/dist/cc-orchestrator/cwd-normalize.js +19 -0
- package/dist/cc-orchestrator/external-agent-bindings.d.ts +27 -0
- package/dist/cc-orchestrator/external-agent-bindings.js +150 -0
- package/dist/cc-orchestrator/external-agent-changes.js +22 -5
- package/dist/cc-orchestrator/external-agent-driver.d.ts +1 -0
- package/dist/cc-orchestrator/external-agent-driver.js +265 -50
- package/dist/cc-orchestrator/external-agent-session-store.d.ts +23 -0
- package/dist/cc-orchestrator/external-agent-session-store.js +146 -0
- package/dist/cc-orchestrator/session-history.d.ts +35 -0
- package/dist/cc-orchestrator/session-history.js +98 -15
- package/dist/cli/agent-server-stdio.js +9 -2
- package/dist/context/compaction.d.ts +8 -1
- package/dist/context/compaction.js +49 -4
- package/dist/context/manager.d.ts +16 -2
- package/dist/context/manager.js +103 -19
- package/dist/credentials/access.d.ts +57 -0
- package/dist/credentials/access.js +185 -0
- package/dist/credentials/index.d.ts +3 -1
- package/dist/credentials/index.js +2 -0
- package/dist/credentials/inject-credential-tool.d.ts +3 -1
- package/dist/credentials/inject-credential-tool.js +30 -11
- package/dist/credentials/oauth.d.ts +20 -0
- package/dist/credentials/oauth.js +114 -0
- package/dist/credentials/store.d.ts +1 -0
- package/dist/credentials/store.js +3 -1
- package/dist/credentials/types.d.ts +49 -3
- package/dist/credentials/use-credential-tool.d.ts +9 -1
- package/dist/credentials/use-credential-tool.js +58 -45
- package/dist/engine/engine.d.ts +23 -3
- package/dist/engine/engine.js +380 -255
- package/dist/engine/goal.d.ts +17 -0
- package/dist/engine/goal.js +16 -6
- package/dist/engine/image-policy.d.ts +6 -0
- package/dist/engine/image-policy.js +17 -6
- package/dist/engine/input-attachments.d.ts +13 -0
- package/dist/engine/input-attachments.js +398 -0
- package/dist/engine/model-facade.d.ts +5 -2
- package/dist/engine/model-facade.js +4 -4
- package/dist/engine/parse-task.d.ts +10 -0
- package/dist/engine/parse-task.js +5 -0
- package/dist/engine/run-image-input.d.ts +22 -0
- package/dist/engine/run-image-input.js +195 -0
- package/dist/engine/steer-queue.d.ts +3 -1
- package/dist/engine/steer-queue.js +10 -2
- package/dist/engine/streaming-tool-queue.d.ts +11 -7
- package/dist/engine/streaming-tool-queue.js +11 -7
- package/dist/engine/turn-loop.d.ts +37 -2
- package/dist/engine/turn-loop.js +228 -43
- package/dist/engine/types.d.ts +8 -0
- package/dist/git/worktree/crud.d.ts +69 -0
- package/dist/git/worktree/crud.js +206 -0
- package/dist/git/worktree/diff.d.ts +14 -0
- package/dist/git/worktree/diff.js +82 -0
- package/dist/git/worktree/git-exec.d.ts +7 -0
- package/dist/git/worktree/git-exec.js +51 -0
- package/dist/git/worktree/index.d.ts +5 -0
- package/dist/git/worktree/index.js +5 -0
- package/dist/git/worktree/query.d.ts +42 -0
- package/dist/git/worktree/query.js +121 -0
- package/dist/git/worktree/slug.d.ts +11 -0
- package/dist/git/worktree/slug.js +58 -0
- package/dist/git/worktree.d.ts +1 -84
- package/dist/git/worktree.js +5 -230
- package/dist/hooks/goal-stop-hook.d.ts +33 -1
- package/dist/hooks/goal-stop-hook.js +202 -34
- package/dist/index.d.ts +31 -28
- package/dist/index.js +27 -25
- package/dist/logging/logger.js +6 -6
- package/dist/logging/sanitize-messages.d.ts +10 -2
- package/dist/logging/sanitize-messages.js +21 -6
- package/dist/plugins/installer/checkUpdate.d.ts +4 -1
- package/dist/plugins/installer/checkUpdate.js +4 -2
- package/dist/plugins/installer/install.js +2 -0
- package/dist/plugins/installer/installFromSource.js +9 -1
- package/dist/plugins/installer/parseSource.d.ts +4 -1
- package/dist/plugins/installer/parseSource.js +28 -10
- package/dist/plugins/installer/sourcePath.d.ts +9 -0
- package/dist/plugins/installer/sourcePath.js +50 -0
- package/dist/plugins/installer/update.d.ts +4 -1
- package/dist/plugins/installer/update.js +5 -3
- package/dist/plugins/parseMarketplaceInput.d.ts +4 -1
- package/dist/plugins/parseMarketplaceInput.js +4 -3
- package/dist/plugins/pluginInstaller.js +24 -22
- package/dist/preset/index.d.ts +1 -0
- package/dist/preset/index.js +30 -13
- package/dist/prompt/sections/base.md +1 -1
- package/dist/protocol/chat-session-manager.d.ts +2 -0
- package/dist/protocol/chat-session-manager.js +38 -2
- package/dist/protocol/chat-session.d.ts +3 -0
- package/dist/protocol/chat-session.js +1 -0
- package/dist/protocol/client.d.ts +9 -4
- package/dist/protocol/client.js +18 -1
- package/dist/protocol/server.d.ts +30 -0
- package/dist/protocol/server.js +247 -47
- package/dist/protocol/types.d.ts +49 -0
- package/dist/protocol/types.js +6 -0
- package/dist/run/FileRunStore.js +10 -1
- package/dist/run/Heartbeat.js +12 -0
- package/dist/run/RunApprovalBackend.d.ts +3 -0
- package/dist/run/RunApprovalBackend.js +41 -6
- package/dist/run/RunLock.js +2 -0
- package/dist/run/RunManager.d.ts +2 -0
- package/dist/run/RunManager.js +64 -24
- package/dist/run/ids.d.ts +2 -0
- package/dist/run/ids.js +23 -0
- package/dist/runtime/background-shell.d.ts +1 -0
- package/dist/runtime/background-shell.js +23 -13
- package/dist/runtime/spawn-common.js +10 -0
- package/dist/services/auto-dream.d.ts +15 -4
- package/dist/services/auto-dream.js +20 -20
- package/dist/services/dream-consolidation.d.ts +2 -3
- package/dist/services/dream-consolidation.js +65 -15
- package/dist/services/extract-memories.d.ts +14 -5
- package/dist/services/extract-memories.js +20 -3
- package/dist/services/global-dream-promotion.d.ts +23 -0
- package/dist/services/global-dream-promotion.js +112 -0
- package/dist/services/memory-orchestrator.js +347 -34
- package/dist/session/memory.d.ts +61 -18
- package/dist/session/memory.js +342 -79
- package/dist/session/session-manager.d.ts +35 -1
- package/dist/session/session-manager.js +224 -4
- package/dist/session/transcript.d.ts +1 -1
- package/dist/session/transcript.js +17 -5
- package/dist/settings/manager.d.ts +1 -0
- package/dist/settings/manager.js +87 -37
- package/dist/settings/schema-export.d.ts +2 -3
- package/dist/settings/schema-export.js +2 -3
- package/dist/settings/schema.d.ts +21 -0
- package/dist/settings/schema.js +12 -0
- package/dist/skills/scanner.d.ts +3 -2
- package/dist/skills/scanner.js +12 -9
- package/dist/tool-system/builtin/agent-notifications.d.ts +11 -4
- package/dist/tool-system/builtin/agent-notifications.js +19 -7
- package/dist/tool-system/builtin/background-jobs.d.ts +35 -6
- package/dist/tool-system/builtin/background-jobs.js +116 -7
- package/dist/tool-system/builtin/background-work.d.ts +17 -18
- package/dist/tool-system/builtin/background-work.js +51 -5
- package/dist/tool-system/builtin/config.d.ts +2 -1
- package/dist/tool-system/builtin/config.js +16 -11
- package/dist/tool-system/builtin/cron-list.definition.d.ts +3 -0
- package/dist/tool-system/builtin/cron-list.definition.js +6 -0
- package/dist/tool-system/builtin/cron.d.ts +1 -2
- package/dist/tool-system/builtin/cron.js +9 -7
- package/dist/tool-system/builtin/drive-claude-code.d.ts +23 -2
- package/dist/tool-system/builtin/drive-claude-code.js +589 -48
- package/dist/tool-system/builtin/edit.js +5 -2
- package/dist/tool-system/builtin/generate-video.d.ts +1 -0
- package/dist/tool-system/builtin/generate-video.js +13 -4
- package/dist/tool-system/builtin/index.d.ts +8 -3
- package/dist/tool-system/builtin/index.js +50 -22
- package/dist/tool-system/builtin/lsp.d.ts +2 -1
- package/dist/tool-system/builtin/lsp.js +6 -3
- package/dist/tool-system/builtin/memory.js +40 -8
- package/dist/tool-system/builtin/notebook-edit.js +5 -2
- package/dist/tool-system/builtin/powershell.d.ts +5 -2
- package/dist/tool-system/builtin/powershell.js +11 -7
- package/dist/tool-system/builtin/read.js +118 -6
- package/dist/tool-system/builtin/sleep.d.ts +1 -2
- package/dist/tool-system/builtin/sleep.definition.d.ts +8 -0
- package/dist/tool-system/builtin/sleep.definition.js +28 -0
- package/dist/tool-system/builtin/sleep.js +1 -22
- package/dist/tool-system/builtin/view-image.d.ts +2 -2
- package/dist/tool-system/builtin/view-image.js +109 -19
- package/dist/tool-system/builtin/worktree.d.ts +4 -4
- package/dist/tool-system/builtin/worktree.js +297 -74
- package/dist/tool-system/builtin/write.js +5 -3
- package/dist/tool-system/context.d.ts +41 -1
- package/dist/tool-system/executor.d.ts +1 -5
- package/dist/tool-system/executor.js +94 -115
- package/dist/tool-system/mcp-manager.d.ts +31 -6
- package/dist/tool-system/mcp-manager.js +193 -78
- package/dist/tool-system/path-policy.d.ts +2 -0
- package/dist/tool-system/path-policy.js +41 -12
- package/dist/tool-system/permission.d.ts +28 -7
- package/dist/tool-system/permission.js +130 -49
- package/dist/tool-system/registry.js +11 -4
- package/dist/tool-system/tool-result-redaction.d.ts +7 -0
- package/dist/tool-system/tool-result-redaction.js +48 -0
- package/dist/tool-system/workspace-bridge.d.ts +11 -0
- package/dist/tool-system/workspace-bridge.js +1 -0
- package/dist/types.d.ts +78 -11
- package/dist/utils/toolDisplay.js +1 -1
- package/package.json +4 -3
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { SandboxBackend } from "../../tool-system/sandbox/index.js";
|
|
2
|
+
export interface WorktreeSession {
|
|
3
|
+
originalCwd: string;
|
|
4
|
+
worktreePath: string;
|
|
5
|
+
worktreeName: string;
|
|
6
|
+
worktreeBranch: string;
|
|
7
|
+
originalBranch?: string;
|
|
8
|
+
sessionId: string;
|
|
9
|
+
createdAt: number;
|
|
10
|
+
}
|
|
11
|
+
/** Per-platform setup/cleanup scripts (a project's localEnvironment). */
|
|
12
|
+
export interface PlatformScripts {
|
|
13
|
+
default?: string;
|
|
14
|
+
macos?: string;
|
|
15
|
+
linux?: string;
|
|
16
|
+
windows?: string;
|
|
17
|
+
}
|
|
18
|
+
export interface CreateWorktreeOptions {
|
|
19
|
+
prefix?: string;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Pick the setup/cleanup script for the running platform, falling back to
|
|
23
|
+
* `default`. Empty/whitespace-only scripts are treated as absent so a project
|
|
24
|
+
* can leave a platform key blank without spawning an empty shell. `platform`
|
|
25
|
+
* defaults to `process.platform` so callers usually omit it; tests pass a
|
|
26
|
+
* fixed value.
|
|
27
|
+
*/
|
|
28
|
+
export declare function selectPlatformScript(scripts: PlatformScripts | undefined, platform?: NodeJS.Platform): string | undefined;
|
|
29
|
+
/**
|
|
30
|
+
* Create an isolated git worktree for an agent session.
|
|
31
|
+
*/
|
|
32
|
+
export declare function createWorktree(cwd: string, slug: string, sessionId: string, opts?: CreateWorktreeOptions): Promise<WorktreeSession>;
|
|
33
|
+
export interface WorktreeSetupResult {
|
|
34
|
+
/** True if no setup script was configured for this platform (nothing ran). */
|
|
35
|
+
skipped: boolean;
|
|
36
|
+
/** True if a script ran and exited 0. */
|
|
37
|
+
ok: boolean;
|
|
38
|
+
/** Combined stdout/stderr, for surfacing to the user on failure. */
|
|
39
|
+
output: string;
|
|
40
|
+
/** Exit code when a script ran; undefined when skipped. */
|
|
41
|
+
exitCode?: number | null;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Run a project's `localEnvironment.setupScripts` once, in the freshly-created
|
|
45
|
+
* worktree's root, right after `git worktree add`.
|
|
46
|
+
*/
|
|
47
|
+
export declare function runWorktreeSetup(worktreePath: string, script: string | undefined, opts?: {
|
|
48
|
+
sandbox?: SandboxBackend;
|
|
49
|
+
shellEnv?: Record<string, string>;
|
|
50
|
+
timeoutMs?: number;
|
|
51
|
+
signal?: AbortSignal;
|
|
52
|
+
}): Promise<WorktreeSetupResult>;
|
|
53
|
+
export interface RemoveWorktreeResult {
|
|
54
|
+
/** True once `git worktree remove` completed and the directory is gone. */
|
|
55
|
+
dirRemoved: boolean;
|
|
56
|
+
/** The branch targeted for deletion when removeBranch=true. */
|
|
57
|
+
branch?: string;
|
|
58
|
+
/** True when removeBranch=true and branch deletion completed. */
|
|
59
|
+
branchDeleted?: boolean;
|
|
60
|
+
/** Non-empty when the worktree directory is gone but branch deletion failed. */
|
|
61
|
+
branchError?: string;
|
|
62
|
+
}
|
|
63
|
+
export interface RemoveWorktreeOptions {
|
|
64
|
+
prefix?: string;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Remove a worktree and optionally its branch.
|
|
68
|
+
*/
|
|
69
|
+
export declare function removeWorktree(worktreePath: string, removeBranch?: boolean, opts?: RemoveWorktreeOptions): RemoveWorktreeResult;
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { existsSync, lstatSync, mkdirSync, readdirSync, symlinkSync } from "node:fs";
|
|
2
|
+
import { dirname, join, resolve } from "node:path";
|
|
3
|
+
import { safeSpawnShell } from "../../runtime/safe-spawn.js";
|
|
4
|
+
import { buildSandboxEnv, defaultShellBinary, mergeShellEnv } from "../../runtime/spawn-common.js";
|
|
5
|
+
import { execGit, execGitSync, gitErrorMessage } from "./git-exec.js";
|
|
6
|
+
import { assertBranchNotCheckedOut, currentBranch, findGitRoot } from "./query.js";
|
|
7
|
+
import { applyPrefix, isManagedWorktreeBranch, validateWorktreeSlug } from "./slug.js";
|
|
8
|
+
/**
|
|
9
|
+
* Pick the setup/cleanup script for the running platform, falling back to
|
|
10
|
+
* `default`. Empty/whitespace-only scripts are treated as absent so a project
|
|
11
|
+
* can leave a platform key blank without spawning an empty shell. `platform`
|
|
12
|
+
* defaults to `process.platform` so callers usually omit it; tests pass a
|
|
13
|
+
* fixed value.
|
|
14
|
+
*/
|
|
15
|
+
export function selectPlatformScript(scripts, platform = process.platform) {
|
|
16
|
+
if (!scripts)
|
|
17
|
+
return undefined;
|
|
18
|
+
const key = platform === "darwin" ? "macos" : platform === "win32" ? "windows" : "linux";
|
|
19
|
+
// A blank platform key shouldn't shadow a real `default` — fall through.
|
|
20
|
+
const platformScript = scripts[key]?.trim() ? scripts[key] : undefined;
|
|
21
|
+
const candidate = platformScript ?? scripts.default;
|
|
22
|
+
const trimmed = candidate?.trim();
|
|
23
|
+
return trimmed ? candidate : undefined;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Create an isolated git worktree for an agent session.
|
|
27
|
+
*/
|
|
28
|
+
export async function createWorktree(cwd, slug, sessionId, opts = {}) {
|
|
29
|
+
validateWorktreeSlug(slug);
|
|
30
|
+
const gitRoot = await findGitRoot(cwd);
|
|
31
|
+
const branchName = applyPrefix(opts.prefix, slug, sessionId);
|
|
32
|
+
const worktreePath = resolve(gitRoot, "..", `.worktrees/${slug}-${sessionId.slice(0, 8)}`);
|
|
33
|
+
await assertBranchNotCheckedOut(gitRoot, branchName);
|
|
34
|
+
const originalBranch = await currentBranch(gitRoot);
|
|
35
|
+
// The argv form keeps branchName/worktreePath as literal positional
|
|
36
|
+
// arguments even if a future caller bypasses validateWorktreeSlug.
|
|
37
|
+
await execGit(gitRoot, ["worktree", "add", "-b", branchName, worktreePath], 30000);
|
|
38
|
+
// Symlink large directories to avoid disk bloat.
|
|
39
|
+
symlinkLargeDirectories(gitRoot, worktreePath);
|
|
40
|
+
return {
|
|
41
|
+
originalCwd: cwd,
|
|
42
|
+
worktreePath,
|
|
43
|
+
worktreeName: slug,
|
|
44
|
+
worktreeBranch: branchName,
|
|
45
|
+
originalBranch,
|
|
46
|
+
sessionId,
|
|
47
|
+
createdAt: Date.now(),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Run a project's `localEnvironment.setupScripts` once, in the freshly-created
|
|
52
|
+
* worktree's root, right after `git worktree add`.
|
|
53
|
+
*/
|
|
54
|
+
export async function runWorktreeSetup(worktreePath, script, opts = {}) {
|
|
55
|
+
const trimmed = script?.trim();
|
|
56
|
+
if (!trimmed)
|
|
57
|
+
return { skipped: true, ok: true, output: "" };
|
|
58
|
+
const shell = defaultShellBinary();
|
|
59
|
+
const backend = opts.sandbox;
|
|
60
|
+
const baseEnv = backend && backend.name !== "off" ? buildSandboxEnv() : { ...process.env };
|
|
61
|
+
const env = mergeShellEnv(baseEnv, opts.shellEnv);
|
|
62
|
+
const result = await safeSpawnShell(trimmed, {
|
|
63
|
+
cwd: worktreePath,
|
|
64
|
+
env,
|
|
65
|
+
timeoutMs: opts.timeoutMs ?? 120_000,
|
|
66
|
+
maxOutputBytes: 1024 * 1024,
|
|
67
|
+
sandbox: backend,
|
|
68
|
+
shell,
|
|
69
|
+
signal: opts.signal,
|
|
70
|
+
});
|
|
71
|
+
const parts = [];
|
|
72
|
+
if (result.stdout)
|
|
73
|
+
parts.push(result.stdout);
|
|
74
|
+
if (result.stderr)
|
|
75
|
+
parts.push(result.stderr);
|
|
76
|
+
const output = parts.join("\n").trim();
|
|
77
|
+
if (result.aborted)
|
|
78
|
+
return { skipped: false, ok: false, output: output || "setup aborted" };
|
|
79
|
+
if (result.timedOut)
|
|
80
|
+
return { skipped: false, ok: false, output: output || "setup timed out" };
|
|
81
|
+
if (result.spawnFailed) {
|
|
82
|
+
return { skipped: false, ok: false, output: result.error ?? "failed to spawn setup script" };
|
|
83
|
+
}
|
|
84
|
+
return { skipped: false, ok: result.exitCode === 0, output, exitCode: result.exitCode };
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Remove a worktree and optionally its branch.
|
|
88
|
+
*/
|
|
89
|
+
export function removeWorktree(worktreePath, removeBranch = false, opts = {}) {
|
|
90
|
+
// The MAIN repo root, not the worktree's own toplevel. `git rev-parse
|
|
91
|
+
// --show-toplevel` from inside a worktree returns the worktree path, which
|
|
92
|
+
// is about to be deleted; the branch-delete must run from the main repo,
|
|
93
|
+
// which outlives the worktree. Derive it from the common git dir.
|
|
94
|
+
let mainRoot;
|
|
95
|
+
try {
|
|
96
|
+
const commonDir = execGitSync(worktreePath, ["rev-parse", "--path-format=absolute", "--git-common-dir"], 5000).trim();
|
|
97
|
+
mainRoot = dirname(commonDir);
|
|
98
|
+
}
|
|
99
|
+
catch (err) {
|
|
100
|
+
throw new Error(`failed to inspect worktree ${worktreePath}: ${gitErrorMessage(err)}`, {
|
|
101
|
+
cause: err,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
// Capture the worktree's branch BEFORE removing the worktree — afterwards
|
|
105
|
+
// the directory is gone and `git branch --show-current` in it would fail.
|
|
106
|
+
let branch = "";
|
|
107
|
+
if (removeBranch) {
|
|
108
|
+
try {
|
|
109
|
+
branch = execGitSync(worktreePath, ["branch", "--show-current"], 5000).trim();
|
|
110
|
+
}
|
|
111
|
+
catch (err) {
|
|
112
|
+
throw new Error(`failed to determine branch for worktree ${worktreePath}: ${gitErrorMessage(err)}`, { cause: err });
|
|
113
|
+
}
|
|
114
|
+
if (!branch) {
|
|
115
|
+
throw new Error(`failed to determine branch for worktree ${worktreePath}`);
|
|
116
|
+
}
|
|
117
|
+
if (!isManagedWorktreeBranch(branch, opts.prefix)) {
|
|
118
|
+
throw new Error(`refusing to delete non-CodeShell worktree branch ${branch}`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
try {
|
|
122
|
+
execGitSync(mainRoot, ["worktree", "remove", worktreePath, "--force"], 30000);
|
|
123
|
+
}
|
|
124
|
+
catch (err) {
|
|
125
|
+
throw new Error(`failed to remove worktree ${worktreePath}: ${gitErrorMessage(err)}`, {
|
|
126
|
+
cause: err,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
if (removeBranch) {
|
|
130
|
+
try {
|
|
131
|
+
execGitSync(mainRoot, ["branch", "-D", branch], 10000);
|
|
132
|
+
}
|
|
133
|
+
catch (err) {
|
|
134
|
+
return {
|
|
135
|
+
dirRemoved: true,
|
|
136
|
+
branch,
|
|
137
|
+
branchDeleted: false,
|
|
138
|
+
branchError: gitErrorMessage(err),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return removeBranch ? { dirRemoved: true, branch, branchDeleted: true } : { dirRemoved: true };
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Symlink large directories (node_modules, .venv, etc.) from main repo to
|
|
146
|
+
* worktree. Also links each monorepo workspace package's private
|
|
147
|
+
* `node_modules` (e.g. `packages/desktop/node_modules`, where bun keeps
|
|
148
|
+
* un-hoisted deps like electron) so a fresh worktree can build/run the whole
|
|
149
|
+
* workspace, not just the packages whose deps happen to be hoisted to the root.
|
|
150
|
+
*/
|
|
151
|
+
function symlinkLargeDirectories(sourceRoot, worktreePath) {
|
|
152
|
+
const largeDirs = ["node_modules", ".venv", "vendor", ".pnpm-store"];
|
|
153
|
+
for (const dir of largeDirs) {
|
|
154
|
+
linkDir(join(sourceRoot, dir), join(worktreePath, dir));
|
|
155
|
+
}
|
|
156
|
+
// Monorepo workspace packages keep private node_modules that are NOT hoisted
|
|
157
|
+
// to the root. Link `<pkg>/node_modules` for each package under `packages/`.
|
|
158
|
+
const packagesDir = join(sourceRoot, "packages");
|
|
159
|
+
if (!dirExists(packagesDir))
|
|
160
|
+
return;
|
|
161
|
+
let pkgNames;
|
|
162
|
+
try {
|
|
163
|
+
pkgNames = readdirSync(packagesDir);
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
for (const pkg of pkgNames) {
|
|
169
|
+
const source = join(packagesDir, pkg, "node_modules");
|
|
170
|
+
if (!dirExists(source))
|
|
171
|
+
continue;
|
|
172
|
+
const targetPkgDir = join(worktreePath, "packages", pkg);
|
|
173
|
+
// The worktree only has package dirs that exist in this branch's tree; a
|
|
174
|
+
// package present in the main repo but not checked out here is skipped.
|
|
175
|
+
if (!dirExists(targetPkgDir)) {
|
|
176
|
+
try {
|
|
177
|
+
mkdirSync(targetPkgDir, { recursive: true });
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
linkDir(source, join(targetPkgDir, "node_modules"));
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
function dirExists(path) {
|
|
187
|
+
try {
|
|
188
|
+
return lstatSync(path).isDirectory();
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
return false;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
function linkDir(source, target) {
|
|
195
|
+
// Windows directory symlinks need admin/Developer Mode and throw EPERM for a
|
|
196
|
+
// normal user; NTFS junctions don't and behave the same for our purpose.
|
|
197
|
+
const linkType = process.platform === "win32" ? "junction" : "dir";
|
|
198
|
+
if (dirExists(source) && !existsSync(target)) {
|
|
199
|
+
try {
|
|
200
|
+
symlinkSync(source, target, linkType);
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
// Symlink/junction might fail on some systems — non-fatal.
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export interface WorktreeDiffSummary {
|
|
2
|
+
/** Best-effort base used for the branch comparison, usually main/master. */
|
|
3
|
+
baseRef?: string;
|
|
4
|
+
/** Unique files changed either by commits ahead of base or by uncommitted changes. */
|
|
5
|
+
changedFiles: number;
|
|
6
|
+
/** Commits reachable from HEAD but not from baseRef. */
|
|
7
|
+
aheadCommits: number;
|
|
8
|
+
/** True when `git status --porcelain` reports local changes. */
|
|
9
|
+
hasUncommittedChanges: boolean;
|
|
10
|
+
}
|
|
11
|
+
export declare function getWorktreeDiff(worktreePath: string, baseRef?: string): Promise<WorktreeDiffSummary>;
|
|
12
|
+
export declare function worktreeHasUncommittedChanges(worktreePath: string): Promise<boolean>;
|
|
13
|
+
export declare function worktreeHasUncommittedOrAheadChanges(worktreePath: string, baseRef?: string): Promise<boolean>;
|
|
14
|
+
export declare function findComparisonBaseRef(cwd: string): Promise<string | undefined>;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { gitOutput } from "./git-exec.js";
|
|
2
|
+
export async function getWorktreeDiff(worktreePath, baseRef) {
|
|
3
|
+
const comparisonBase = baseRef && (await commitRefExists(worktreePath, baseRef))
|
|
4
|
+
? baseRef
|
|
5
|
+
: await findComparisonBaseRef(worktreePath);
|
|
6
|
+
return diffSummary(worktreePath, comparisonBase);
|
|
7
|
+
}
|
|
8
|
+
export async function worktreeHasUncommittedChanges(worktreePath) {
|
|
9
|
+
const out = await gitOutput(worktreePath, ["status", "--porcelain"], 10000);
|
|
10
|
+
return (out?.trim().length ?? 0) > 0;
|
|
11
|
+
}
|
|
12
|
+
export async function worktreeHasUncommittedOrAheadChanges(worktreePath, baseRef) {
|
|
13
|
+
if (await worktreeHasUncommittedChanges(worktreePath))
|
|
14
|
+
return true;
|
|
15
|
+
const comparisonBase = baseRef && (await commitRefExists(worktreePath, baseRef))
|
|
16
|
+
? baseRef
|
|
17
|
+
: await findComparisonBaseRef(worktreePath);
|
|
18
|
+
return comparisonBase ? (await aheadCommitCount(worktreePath, comparisonBase)) > 0 : false;
|
|
19
|
+
}
|
|
20
|
+
export async function findComparisonBaseRef(cwd) {
|
|
21
|
+
for (const ref of ["main", "master", "origin/main", "origin/master"]) {
|
|
22
|
+
if (await commitRefExists(cwd, ref))
|
|
23
|
+
return ref;
|
|
24
|
+
}
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
async function commitRefExists(cwd, ref) {
|
|
28
|
+
const out = await gitOutput(cwd, ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`], 5000);
|
|
29
|
+
return out !== undefined;
|
|
30
|
+
}
|
|
31
|
+
async function diffSummary(cwd, baseRef) {
|
|
32
|
+
const [dirtyFiles, committedFiles, aheadCommits] = await Promise.all([
|
|
33
|
+
statusFileSet(cwd),
|
|
34
|
+
baseRef ? changedFilesSinceBase(cwd, baseRef) : Promise.resolve(new Set()),
|
|
35
|
+
baseRef ? aheadCommitCount(cwd, baseRef) : Promise.resolve(0),
|
|
36
|
+
]);
|
|
37
|
+
const changedFiles = new Set([...dirtyFiles, ...committedFiles]);
|
|
38
|
+
return {
|
|
39
|
+
...(baseRef ? { baseRef } : {}),
|
|
40
|
+
changedFiles: changedFiles.size,
|
|
41
|
+
aheadCommits,
|
|
42
|
+
hasUncommittedChanges: dirtyFiles.size > 0,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
async function changedFilesSinceBase(cwd, baseRef) {
|
|
46
|
+
const out = await gitOutput(cwd, ["diff", "--name-only", `${baseRef}...HEAD`]);
|
|
47
|
+
const raw = out ?? (await gitOutput(cwd, ["diff", "--name-only", `${baseRef}..HEAD`])) ?? "";
|
|
48
|
+
return new Set(raw
|
|
49
|
+
.split("\n")
|
|
50
|
+
.map((line) => line.trim())
|
|
51
|
+
.filter(Boolean));
|
|
52
|
+
}
|
|
53
|
+
async function aheadCommitCount(cwd, baseRef) {
|
|
54
|
+
const out = await gitOutput(cwd, ["rev-list", "--count", `${baseRef}..HEAD`]);
|
|
55
|
+
const n = Number.parseInt(out?.trim() ?? "0", 10);
|
|
56
|
+
return Number.isFinite(n) ? n : 0;
|
|
57
|
+
}
|
|
58
|
+
async function statusFileSet(cwd) {
|
|
59
|
+
const raw = (await gitOutput(cwd, ["status", "--porcelain=v1"])) ?? "";
|
|
60
|
+
const files = new Set();
|
|
61
|
+
for (const line of raw.split("\n")) {
|
|
62
|
+
if (!line.trim())
|
|
63
|
+
continue;
|
|
64
|
+
const path = line.slice(3).trim();
|
|
65
|
+
if (!path)
|
|
66
|
+
continue;
|
|
67
|
+
const renameTarget = path.includes(" -> ") ? path.split(" -> ").at(-1) : path;
|
|
68
|
+
if (renameTarget)
|
|
69
|
+
files.add(unquoteGitPath(renameTarget));
|
|
70
|
+
}
|
|
71
|
+
return files;
|
|
72
|
+
}
|
|
73
|
+
function unquoteGitPath(path) {
|
|
74
|
+
if (!path.startsWith('"') || !path.endsWith('"'))
|
|
75
|
+
return path;
|
|
76
|
+
try {
|
|
77
|
+
return JSON.parse(path);
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return path.slice(1, -1);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export declare const GIT_BIN: string;
|
|
2
|
+
export declare function execGit(cwd: string, args: string[], timeout?: number): Promise<string>;
|
|
3
|
+
export declare function execGitSync(cwd: string, args: string[], timeout?: number): string;
|
|
4
|
+
export declare function gitOutput(cwd: string, args: string[], timeout?: number): Promise<string | undefined>;
|
|
5
|
+
export declare function gitOutputSync(cwd: string, args: string[], timeout?: number): string | undefined;
|
|
6
|
+
export declare function gitErrorMessage(err: unknown): string;
|
|
7
|
+
export declare function normalizeBranchName(branch: string): string;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { execFile, execFileSync } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
import { resolveExecutable } from "../../utils/exec.js";
|
|
4
|
+
// git resolved via PATH×PATHEXT on Windows (.cmd/.exe shim); no-op on POSIX.
|
|
5
|
+
export const GIT_BIN = resolveExecutable("git");
|
|
6
|
+
const execFileAsync = promisify(execFile);
|
|
7
|
+
export async function execGit(cwd, args, timeout = 10000) {
|
|
8
|
+
const { stdout } = await execFileAsync(GIT_BIN, args, {
|
|
9
|
+
cwd,
|
|
10
|
+
encoding: "utf-8",
|
|
11
|
+
timeout,
|
|
12
|
+
});
|
|
13
|
+
return stdout;
|
|
14
|
+
}
|
|
15
|
+
export function execGitSync(cwd, args, timeout = 10000) {
|
|
16
|
+
return execFileSync(GIT_BIN, args, {
|
|
17
|
+
cwd,
|
|
18
|
+
encoding: "utf-8",
|
|
19
|
+
timeout,
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
export async function gitOutput(cwd, args, timeout = 10000) {
|
|
23
|
+
try {
|
|
24
|
+
return await execGit(cwd, args, timeout);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
export function gitOutputSync(cwd, args, timeout = 10000) {
|
|
31
|
+
try {
|
|
32
|
+
return execGitSync(cwd, args, timeout);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
export function gitErrorMessage(err) {
|
|
39
|
+
const stderr = err.stderr;
|
|
40
|
+
if (Buffer.isBuffer(stderr)) {
|
|
41
|
+
const msg = stderr.toString("utf-8").trim();
|
|
42
|
+
if (msg)
|
|
43
|
+
return msg;
|
|
44
|
+
}
|
|
45
|
+
if (typeof stderr === "string" && stderr.trim())
|
|
46
|
+
return stderr.trim();
|
|
47
|
+
return err instanceof Error ? err.message : String(err);
|
|
48
|
+
}
|
|
49
|
+
export function normalizeBranchName(branch) {
|
|
50
|
+
return branch.replace(/^refs\/heads\//, "");
|
|
51
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { SessionWorkspace } from "../../types.js";
|
|
2
|
+
import { type WorktreeDiffSummary } from "./diff.js";
|
|
3
|
+
export interface WorktreeWorkspaceOwner {
|
|
4
|
+
sessionId: string;
|
|
5
|
+
workspace?: SessionWorkspace;
|
|
6
|
+
}
|
|
7
|
+
export interface ListWorktreesFastOptions {
|
|
8
|
+
currentSessionId?: string;
|
|
9
|
+
workspaceOwners?: WorktreeWorkspaceOwner[];
|
|
10
|
+
owners?: WorktreeWorkspaceOwner[];
|
|
11
|
+
prefix?: string;
|
|
12
|
+
}
|
|
13
|
+
export interface ListWorktreesOptions extends ListWorktreesFastOptions {
|
|
14
|
+
includeDiffSummary?: boolean;
|
|
15
|
+
}
|
|
16
|
+
export interface WorktreeInfo {
|
|
17
|
+
path: string;
|
|
18
|
+
branch: string;
|
|
19
|
+
head: string;
|
|
20
|
+
isMain?: boolean;
|
|
21
|
+
isManaged?: boolean;
|
|
22
|
+
diff?: WorktreeDiffSummary;
|
|
23
|
+
occupiedBySessionIds?: string[];
|
|
24
|
+
occupiedByOtherSession?: boolean;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Find the canonical git root (resolves worktree -> main repo).
|
|
28
|
+
*/
|
|
29
|
+
export declare function findGitRoot(cwd: string): Promise<string>;
|
|
30
|
+
export declare function findMainWorktreeRoot(cwd: string): Promise<string>;
|
|
31
|
+
export declare function findWorktreeForBranch(cwd: string, branch: string): Promise<string | undefined>;
|
|
32
|
+
export declare function assertBranchNotCheckedOut(cwd: string, branch: string): Promise<void>;
|
|
33
|
+
export declare function branchExists(cwd: string, branch: string): Promise<boolean>;
|
|
34
|
+
export declare function isGitWorktreeRoot(cwd: string): Promise<boolean>;
|
|
35
|
+
export declare function currentBranch(cwd: string): Promise<string | undefined>;
|
|
36
|
+
export declare function listWorktreesFast(cwd: string, opts?: ListWorktreesFastOptions): Promise<WorktreeInfo[]>;
|
|
37
|
+
/**
|
|
38
|
+
* List active worktrees. Kept for internal/test compatibility; use
|
|
39
|
+
* listWorktreesFast for UI lists that should not block on diff work.
|
|
40
|
+
*/
|
|
41
|
+
export declare function listWorktrees(cwd: string, opts?: ListWorktreesOptions): Promise<WorktreeInfo[]>;
|
|
42
|
+
export declare function ownersForWorktree(path: string, owners: WorktreeWorkspaceOwner[]): string[];
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { dirname, join, resolve } from "node:path";
|
|
3
|
+
import { getWorktreeDiff } from "./diff.js";
|
|
4
|
+
import { execGit, gitOutput, normalizeBranchName } from "./git-exec.js";
|
|
5
|
+
import { isManagedWorktreeBranch } from "./slug.js";
|
|
6
|
+
/**
|
|
7
|
+
* Find the canonical git root (resolves worktree -> main repo).
|
|
8
|
+
*/
|
|
9
|
+
export async function findGitRoot(cwd) {
|
|
10
|
+
return await findMainWorktreeRoot(cwd);
|
|
11
|
+
}
|
|
12
|
+
export async function findMainWorktreeRoot(cwd) {
|
|
13
|
+
const commonDir = (await execGit(cwd, ["rev-parse", "--path-format=absolute", "--git-common-dir"], 5000)).trim();
|
|
14
|
+
return dirname(commonDir);
|
|
15
|
+
}
|
|
16
|
+
export async function findWorktreeForBranch(cwd, branch) {
|
|
17
|
+
const normalized = normalizeBranchName(branch);
|
|
18
|
+
return (await listWorktreesFast(cwd)).find((entry) => entry.branch === normalized)?.path;
|
|
19
|
+
}
|
|
20
|
+
export async function assertBranchNotCheckedOut(cwd, branch) {
|
|
21
|
+
const existingPath = await findWorktreeForBranch(cwd, branch);
|
|
22
|
+
if (existingPath) {
|
|
23
|
+
throw new Error(`branch ${normalizeBranchName(branch)} already checked out at ${existingPath}`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export async function branchExists(cwd, branch) {
|
|
27
|
+
const out = await gitOutput(cwd, ["branch", "--list", normalizeBranchName(branch)], 10000);
|
|
28
|
+
return (out?.trim().length ?? 0) > 0;
|
|
29
|
+
}
|
|
30
|
+
export async function isGitWorktreeRoot(cwd) {
|
|
31
|
+
if (!existsSync(join(cwd, ".git")))
|
|
32
|
+
return false;
|
|
33
|
+
try {
|
|
34
|
+
const inside = (await execGit(cwd, ["rev-parse", "--is-inside-work-tree"], 5000)).trim();
|
|
35
|
+
if (inside !== "true")
|
|
36
|
+
return false;
|
|
37
|
+
const topLevel = (await execGit(cwd, ["rev-parse", "--path-format=absolute", "--show-toplevel"], 5000)).trim();
|
|
38
|
+
return resolve(topLevel) === resolve(cwd);
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
export async function currentBranch(cwd) {
|
|
45
|
+
const branch = (await gitOutput(cwd, ["branch", "--show-current"], 5000))?.trim();
|
|
46
|
+
return branch || undefined;
|
|
47
|
+
}
|
|
48
|
+
export async function listWorktreesFast(cwd, opts = {}) {
|
|
49
|
+
let raw;
|
|
50
|
+
try {
|
|
51
|
+
raw = (await execGit(cwd, ["worktree", "list", "--porcelain"], 10000)).trim();
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return [];
|
|
55
|
+
}
|
|
56
|
+
if (!raw)
|
|
57
|
+
return [];
|
|
58
|
+
const entries = [];
|
|
59
|
+
let current = { path: "", branch: "", head: "" };
|
|
60
|
+
for (const line of raw.split("\n")) {
|
|
61
|
+
if (line.startsWith("worktree ")) {
|
|
62
|
+
if (current.path)
|
|
63
|
+
entries.push(current);
|
|
64
|
+
current = { path: line.slice(9), branch: "", head: "" };
|
|
65
|
+
}
|
|
66
|
+
else if (line.startsWith("HEAD ")) {
|
|
67
|
+
current.head = line.slice(5, 13);
|
|
68
|
+
}
|
|
69
|
+
else if (line.startsWith("branch ")) {
|
|
70
|
+
current.branch = normalizeBranchName(line.slice(7));
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (current.path)
|
|
74
|
+
entries.push(current);
|
|
75
|
+
let mainRoot = "";
|
|
76
|
+
try {
|
|
77
|
+
mainRoot = await findMainWorktreeRoot(cwd);
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
mainRoot = entries[0]?.path ?? "";
|
|
81
|
+
}
|
|
82
|
+
const owners = opts.workspaceOwners ?? opts.owners ?? [];
|
|
83
|
+
return entries.map((entry) => {
|
|
84
|
+
const ownerIds = ownersForWorktree(entry.path, owners);
|
|
85
|
+
return {
|
|
86
|
+
...entry,
|
|
87
|
+
...(mainRoot ? { isMain: resolve(entry.path) === resolve(mainRoot) } : {}),
|
|
88
|
+
isManaged: Boolean(entry.branch) && isManagedWorktreeBranch(entry.branch, opts.prefix),
|
|
89
|
+
...(ownerIds.length > 0
|
|
90
|
+
? {
|
|
91
|
+
occupiedBySessionIds: ownerIds,
|
|
92
|
+
occupiedByOtherSession: ownerIds.some((id) => id !== opts.currentSessionId),
|
|
93
|
+
}
|
|
94
|
+
: {}),
|
|
95
|
+
};
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* List active worktrees. Kept for internal/test compatibility; use
|
|
100
|
+
* listWorktreesFast for UI lists that should not block on diff work.
|
|
101
|
+
*/
|
|
102
|
+
export async function listWorktrees(cwd, opts = {}) {
|
|
103
|
+
const entries = await listWorktreesFast(cwd, opts);
|
|
104
|
+
if (!opts.includeDiffSummary)
|
|
105
|
+
return entries;
|
|
106
|
+
return Promise.all(entries.map(async (entry) => ({
|
|
107
|
+
...entry,
|
|
108
|
+
diff: await getWorktreeDiff(entry.path),
|
|
109
|
+
})));
|
|
110
|
+
}
|
|
111
|
+
export function ownersForWorktree(path, owners) {
|
|
112
|
+
const target = resolve(path);
|
|
113
|
+
return owners
|
|
114
|
+
.filter((owner) => {
|
|
115
|
+
const workspace = owner.workspace;
|
|
116
|
+
if (!workspace)
|
|
117
|
+
return false;
|
|
118
|
+
return resolve(workspace.root) === target;
|
|
119
|
+
})
|
|
120
|
+
.map((owner) => owner.sessionId);
|
|
121
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export declare const DEFAULT_WORKTREE_BRANCH_PREFIX = "worktree/";
|
|
2
|
+
export declare const HISTORICAL_WORKTREE_BRANCH_PREFIX = "worktree/";
|
|
3
|
+
/**
|
|
4
|
+
* Validate worktree slug to prevent path traversal attacks.
|
|
5
|
+
*/
|
|
6
|
+
export declare function validateWorktreeSlug(slug: string): void;
|
|
7
|
+
export declare function normalizeWorktreeBranchPrefix(prefix?: string | undefined): string;
|
|
8
|
+
export declare function isValidWorktreeBranchPrefix(prefix: string): boolean;
|
|
9
|
+
export declare function applyPrefix(prefix: string | undefined, slug: string, sessionId: string): string;
|
|
10
|
+
export declare function managedBranchPrefixes(prefix?: string): string[];
|
|
11
|
+
export declare function isManagedWorktreeBranch(branch: string, prefix?: string): boolean;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
export const DEFAULT_WORKTREE_BRANCH_PREFIX = "worktree/";
|
|
2
|
+
export const HISTORICAL_WORKTREE_BRANCH_PREFIX = "worktree/";
|
|
3
|
+
/**
|
|
4
|
+
* Validate worktree slug to prevent path traversal attacks.
|
|
5
|
+
*/
|
|
6
|
+
export function validateWorktreeSlug(slug) {
|
|
7
|
+
if (slug.trim().length === 0)
|
|
8
|
+
throw new Error("Worktree slug cannot be empty");
|
|
9
|
+
if (slug.length > 64)
|
|
10
|
+
throw new Error("Worktree slug too long (max 64 chars)");
|
|
11
|
+
if (/[^a-zA-Z0-9._-]/.test(slug))
|
|
12
|
+
throw new Error("Worktree slug contains invalid characters");
|
|
13
|
+
if (slug.startsWith(".") || slug.includes(".."))
|
|
14
|
+
throw new Error("Worktree slug cannot start with '.' or contain '..'");
|
|
15
|
+
}
|
|
16
|
+
export function normalizeWorktreeBranchPrefix(prefix = DEFAULT_WORKTREE_BRANCH_PREFIX) {
|
|
17
|
+
const raw = (prefix ?? DEFAULT_WORKTREE_BRANCH_PREFIX).trim();
|
|
18
|
+
if (!isValidWorktreeBranchPrefix(raw)) {
|
|
19
|
+
throw new Error(`Invalid worktree branch prefix: ${prefix ?? ""}`);
|
|
20
|
+
}
|
|
21
|
+
return raw.endsWith("/") ? raw : `${raw}/`;
|
|
22
|
+
}
|
|
23
|
+
export function isValidWorktreeBranchPrefix(prefix) {
|
|
24
|
+
const raw = prefix.trim();
|
|
25
|
+
if (!raw)
|
|
26
|
+
return false;
|
|
27
|
+
if (raw.startsWith("/") || raw.includes("//"))
|
|
28
|
+
return false;
|
|
29
|
+
if (raw.includes("..") || raw.includes("@{"))
|
|
30
|
+
return false;
|
|
31
|
+
if (/[\x00-\x20~^:?*[\]\\]/.test(raw))
|
|
32
|
+
return false;
|
|
33
|
+
const withoutTrailingSlash = raw.endsWith("/") ? raw.slice(0, -1) : raw;
|
|
34
|
+
if (!withoutTrailingSlash)
|
|
35
|
+
return false;
|
|
36
|
+
return withoutTrailingSlash.split("/").every((part) => {
|
|
37
|
+
if (!part)
|
|
38
|
+
return false;
|
|
39
|
+
if (part.startsWith(".") || part.endsWith("."))
|
|
40
|
+
return false;
|
|
41
|
+
if (part.endsWith(".lock"))
|
|
42
|
+
return false;
|
|
43
|
+
return true;
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
export function applyPrefix(prefix, slug, sessionId) {
|
|
47
|
+
validateWorktreeSlug(slug);
|
|
48
|
+
return `${normalizeWorktreeBranchPrefix(prefix)}${slug}-${sessionId.slice(0, 8)}`;
|
|
49
|
+
}
|
|
50
|
+
export function managedBranchPrefixes(prefix) {
|
|
51
|
+
const configured = normalizeWorktreeBranchPrefix(prefix);
|
|
52
|
+
return configured === HISTORICAL_WORKTREE_BRANCH_PREFIX
|
|
53
|
+
? [configured]
|
|
54
|
+
: [configured, HISTORICAL_WORKTREE_BRANCH_PREFIX];
|
|
55
|
+
}
|
|
56
|
+
export function isManagedWorktreeBranch(branch, prefix) {
|
|
57
|
+
return managedBranchPrefixes(prefix).some((candidate) => branch.startsWith(candidate));
|
|
58
|
+
}
|