@loucompanion/forge-bridge 0.1.3-dev.f2b8822ce953 → 0.1.3-dev.fbe15023cdfe
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/README.md +78 -0
- package/dist/bridge/bridge.base.d.ts +6 -1
- package/dist/bridge/bridge.service.d.ts +60 -3
- package/dist/bridge/bridge.service.js +970 -24
- package/dist/bridge/bridge.types.d.ts +172 -1
- package/dist/cleanup/cleanup.service.d.ts +13 -2
- package/dist/cleanup/cleanup.service.js +435 -3
- package/dist/cleanup/cleanup.types.d.ts +10 -0
- package/dist/cleanup/index.d.ts +1 -1
- package/dist/cleanup/index.js +1 -1
- package/dist/cleanup/internals.d.ts +2 -2
- package/dist/cleanup/internals.js +1 -1
- package/dist/cli/cli.help.js +47 -1
- package/dist/cli/cli.service.d.ts +42 -2
- package/dist/cli/cli.service.js +351 -22
- package/dist/cli/local-config.d.ts +6 -0
- package/dist/cli/local-config.js +47 -3
- package/dist/runtime/attempt-node-runtime-launcher.d.ts +17 -0
- package/dist/runtime/attempt-node-runtime-launcher.js +101 -0
- package/dist/runtime/attempt-process-supervisor-registry.d.ts +20 -0
- package/dist/runtime/attempt-process-supervisor-registry.js +64 -0
- package/dist/runtime/attempt-process-supervisor.d.ts +86 -0
- package/dist/runtime/attempt-process-supervisor.js +350 -0
- package/dist/runtime/generated-output-reset.d.ts +10 -0
- package/dist/runtime/generated-output-reset.js +103 -0
- package/dist/runtime/host-listener-inspector.d.ts +44 -0
- package/dist/runtime/host-listener-inspector.js +324 -0
- package/dist/runtime/host-port-broker.d.ts +76 -0
- package/dist/runtime/host-port-broker.js +333 -0
- package/dist/runtime/host-runtime-contract.d.ts +47 -0
- package/dist/runtime/host-runtime-contract.js +700 -0
- package/dist/runtime/host-runtime-lease.d.ts +133 -0
- package/dist/runtime/host-runtime-lease.js +1460 -0
- package/dist/runtime/host-runtime-promotion.d.ts +23 -0
- package/dist/runtime/host-runtime-promotion.js +159 -0
- package/dist/runtime/node-host-runtime-adapter.d.ts +51 -0
- package/dist/runtime/node-host-runtime-adapter.js +268 -0
- package/dist/runtime/playwright-host-runtime-adapter.d.ts +20 -0
- package/dist/runtime/playwright-host-runtime-adapter.js +220 -0
- package/dist/runtime/runtime-isolation-lease.d.ts +67 -0
- package/dist/runtime/runtime-isolation-lease.js +158 -0
- package/dist/sandbox/command-runner.d.ts +4 -0
- package/dist/sandbox/command-runner.js +47 -0
- package/dist/sandbox/contract.d.ts +5 -0
- package/dist/sandbox/contract.js +276 -0
- package/dist/sandbox/event-budget.d.ts +11 -0
- package/dist/sandbox/event-budget.js +27 -0
- package/dist/sandbox/image-policy.d.ts +35 -0
- package/dist/sandbox/image-policy.js +247 -0
- package/dist/sandbox/index.d.ts +5 -0
- package/dist/sandbox/index.js +5 -0
- package/dist/sandbox/lease-watchdog.d.ts +18 -0
- package/dist/sandbox/lease-watchdog.js +67 -0
- package/dist/sandbox/preflight.d.ts +54 -0
- package/dist/sandbox/preflight.js +335 -0
- package/dist/sandbox/process.d.ts +20 -0
- package/dist/sandbox/process.js +209 -0
- package/dist/sandbox/provider-auth/codex.d.ts +26 -0
- package/dist/sandbox/provider-auth/codex.js +212 -0
- package/dist/sandbox/repository-lock.d.ts +5 -0
- package/dist/sandbox/repository-lock.js +119 -0
- package/dist/sandbox/runtime.d.ts +297 -0
- package/dist/sandbox/runtime.js +2338 -0
- package/dist/sandbox/sandbox.types.d.ts +86 -0
- package/dist/sandbox/sandbox.types.js +1 -0
- package/dist/sandbox/smoke.d.ts +76 -0
- package/dist/sandbox/smoke.js +380 -0
- package/dist/sandbox/worker-supervisor.d.ts +13 -0
- package/dist/sandbox/worker-supervisor.js +36 -0
- package/dist/sandbox/worker.d.ts +1 -0
- package/dist/sandbox/worker.js +225 -0
- package/dist/sandbox/workspace.service.d.ts +13 -0
- package/dist/sandbox/workspace.service.js +72 -0
- package/dist/session/managed-attempt-tools.d.ts +12 -0
- package/dist/session/managed-attempt-tools.js +561 -0
- package/dist/session/phase-completion.artifacts.js +32 -4
- package/dist/session/phase-completion.d.ts +4 -2
- package/dist/session/phase-completion.helpers.js +27 -3
- package/dist/session/phase-completion.js +12 -3
- package/dist/session/phase-completion.prompts.d.ts +3 -3
- package/dist/session/phase-completion.prompts.js +20 -6
- package/dist/session/phase-completion.runtime.d.ts +12 -1
- package/dist/session/phase-completion.runtime.js +129 -7
- package/dist/session/phase-completion.schemas.js +132 -5
- package/dist/session/phase-completion.validation.d.ts +4 -1
- package/dist/session/phase-completion.validation.js +106 -2
- package/dist/session/provider-cli/ci-repair-readiness.d.ts +8 -0
- package/dist/session/provider-cli/ci-repair-readiness.js +90 -0
- package/dist/session/provider-cli/provider-cli.artifacts.d.ts +1 -1
- package/dist/session/provider-cli/provider-cli.artifacts.js +28 -4
- package/dist/session/provider-cli/provider-cli.base.d.ts +1 -0
- package/dist/session/provider-cli/provider-cli.deterministic.d.ts +2 -0
- package/dist/session/provider-cli/provider-cli.deterministic.js +66 -9
- package/dist/session/provider-cli/provider-cli.process.d.ts +17 -0
- package/dist/session/provider-cli/provider-cli.process.js +223 -14
- package/dist/session/provider-cli/provider-cli.prompts.d.ts +17 -2
- package/dist/session/provider-cli/provider-cli.prompts.js +226 -18
- package/dist/session/provider-cli/provider-cli.qa-sidecars.js +1 -0
- package/dist/session/provider-cli/provider-cli.runtime.d.ts +1 -1
- package/dist/session/provider-cli/provider-cli.runtime.js +3 -2
- package/dist/session/provider-cli/provider-cli.service.d.ts +19 -3
- package/dist/session/provider-cli/provider-cli.service.js +503 -98
- package/dist/session/provider-cli/provider-cli.types.d.ts +6 -0
- package/dist/session/provider-cli/provider-output-state.d.ts +2 -0
- package/dist/session/provider-cli/provider-output-state.js +30 -2
- package/dist/session/session-redaction.d.ts +3 -0
- package/dist/session/session-redaction.js +23 -0
- package/dist/session/session.base.d.ts +6 -1
- package/dist/session/session.service.d.ts +8 -2
- package/dist/session/session.service.js +463 -58
- package/dist/session/session.types.d.ts +144 -1
- package/dist/session/session.types.js +279 -3
- package/dist/shared/path/path.js +3 -1
- package/dist/shared/process/host-process-recovery.d.ts +12 -0
- package/dist/shared/process/host-process-recovery.js +78 -0
- package/dist/shared/process/host-process-supervisor.d.ts +57 -0
- package/dist/shared/process/host-process-supervisor.js +282 -0
- package/dist/shared/process/process.d.ts +1 -0
- package/dist/shared/process/process.js +1 -0
- package/dist/shared/process/windows-launch.d.ts +1 -1
- package/dist/shared/process/windows-launch.js +6 -3
- package/dist/shared/provider-capabilities.d.ts +1 -0
- package/dist/shared/provider-capabilities.js +3 -0
- package/dist/shared/redaction/redaction.js +10 -3
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worktree/artifact-injector.d.ts +3 -2
- package/dist/worktree/artifact-injector.js +43 -4
- package/dist/worktree/dependency-warmer.d.ts +21 -1
- package/dist/worktree/dependency-warmer.js +138 -5
- package/dist/worktree/index.d.ts +1 -0
- package/dist/worktree/index.js +1 -0
- package/dist/worktree/prior-phase-artifact-hydrator.d.ts +2 -0
- package/dist/worktree/prior-phase-artifact-hydrator.js +136 -0
- package/dist/worktree/worktree-paths.d.ts +4 -0
- package/dist/worktree/worktree-paths.js +30 -0
- package/dist/worktree/worktree.base.d.ts +3 -0
- package/dist/worktree/worktree.service.d.ts +7 -2
- package/dist/worktree/worktree.service.js +72 -44
- package/dist/worktree/worktree.types.d.ts +3 -1
- package/package.json +6 -3
- package/runtime/windows-job-supervisor.ps1 +356 -0
- package/runtime/windows-listener-owner.ps1 +33 -0
|
@@ -1,7 +1,33 @@
|
|
|
1
|
-
import type { RunOnceResultPacket, RunnerDispatchEvent } from "../session/session.types.js";
|
|
1
|
+
import type { ApiKeySecretGrant, DispatchSandboxResourceRequest, RunOnceResultPacket, RunnerDispatchEvent } from "../session/session.types.js";
|
|
2
|
+
import type { SandboxFeatureMatrix } from "../sandbox/sandbox.types.js";
|
|
3
|
+
import type { HostRuntimeCapacitySnapshot } from "../runtime/host-runtime-lease.js";
|
|
2
4
|
export interface BridgeServiceConfig {
|
|
3
5
|
readonly serverUrl: string;
|
|
4
6
|
readonly runnerId: string;
|
|
7
|
+
readonly runnerInstanceId: string;
|
|
8
|
+
readonly trustDomainId: string;
|
|
9
|
+
readonly maxConcurrentSandboxes: number;
|
|
10
|
+
readonly maxConcurrentSandboxBuilds: number;
|
|
11
|
+
readonly sandboxPidCapacity: number;
|
|
12
|
+
readonly dockerEnabled: boolean;
|
|
13
|
+
readonly allowOpenPublicSandbox: boolean;
|
|
14
|
+
readonly sandboxPublicationMode?: "bridge_push" | "shared_filesystem";
|
|
15
|
+
readonly dockerExecutable: string;
|
|
16
|
+
readonly dockerContext: string;
|
|
17
|
+
readonly sandboxStateRoot: string;
|
|
18
|
+
readonly requireRootlessDocker: boolean;
|
|
19
|
+
readonly diskHighWatermarkPercent: number;
|
|
20
|
+
readonly diskLowWatermarkPercent: number;
|
|
21
|
+
readonly minimumWorkspaceBytes: number;
|
|
22
|
+
readonly sandboxHostMemoryReserveBytes?: number;
|
|
23
|
+
readonly defaultSandboxWorkerMemoryLimitBytes: number;
|
|
24
|
+
readonly defaultSandboxServiceMemoryLimitBytes: number;
|
|
25
|
+
readonly sandboxRuntimeOverheadBytes: number;
|
|
26
|
+
readonly sandboxMemorySampleIntervalMs: number;
|
|
27
|
+
readonly imageRetentionCount: number;
|
|
28
|
+
readonly imageRetentionTtlMs: number;
|
|
29
|
+
readonly auditRetentionCount: number;
|
|
30
|
+
readonly auditRetentionTtlMs: number;
|
|
5
31
|
readonly deviceToken: string;
|
|
6
32
|
readonly deviceName: string;
|
|
7
33
|
readonly defaultRepoRoot: string;
|
|
@@ -17,6 +43,8 @@ export interface BridgeServiceConfig {
|
|
|
17
43
|
readonly supportsManagedForgeArtifacts: boolean;
|
|
18
44
|
readonly supportsRunnerAuthoredPhaseArtifacts: boolean;
|
|
19
45
|
readonly supportsDraftRunOnce: boolean;
|
|
46
|
+
readonly supportsDraftAttachments: boolean;
|
|
47
|
+
readonly supportsDraftImageAttachments: boolean;
|
|
20
48
|
readonly version: string;
|
|
21
49
|
readonly protocolVersion: number;
|
|
22
50
|
readonly minServerVersion?: string | null;
|
|
@@ -35,6 +63,7 @@ export interface QaAppLaunchTarget {
|
|
|
35
63
|
readonly command?: string | null;
|
|
36
64
|
readonly working_directory?: string | null;
|
|
37
65
|
readonly ready_url?: string | null;
|
|
66
|
+
readonly managed_runtime_port?: string | null;
|
|
38
67
|
readonly routes?: readonly string[] | null;
|
|
39
68
|
readonly notes?: string | null;
|
|
40
69
|
}
|
|
@@ -52,17 +81,64 @@ export interface BridgeCapabilities {
|
|
|
52
81
|
readonly repoMappings?: readonly BridgeRepoMapping[] | null;
|
|
53
82
|
readonly os?: string | null;
|
|
54
83
|
readonly sandboxProfiles?: readonly string[] | null;
|
|
84
|
+
readonly runnerInstanceId?: string | null;
|
|
85
|
+
readonly trustDomainId?: string | null;
|
|
86
|
+
readonly sandboxRuntimes?: readonly string[] | null;
|
|
87
|
+
readonly sandboxRuntimeVersion?: string | null;
|
|
88
|
+
readonly sandboxFeatures?: SandboxFeatureMatrix | null;
|
|
89
|
+
readonly allowsOpenPublicSandbox?: boolean | null;
|
|
90
|
+
readonly sandboxPublicationModes?: readonly string[] | null;
|
|
91
|
+
readonly sandboxAuthModes?: Readonly<Record<string, readonly string[]>> | null;
|
|
92
|
+
readonly maxConcurrentSandboxes?: number | null;
|
|
93
|
+
readonly reservedSandboxCount?: number | null;
|
|
94
|
+
readonly activeSandboxCount?: number | null;
|
|
95
|
+
readonly startingSandboxCount?: number | null;
|
|
96
|
+
readonly degradedReservationCount?: number | null;
|
|
97
|
+
readonly availableMemoryBytes?: number | null;
|
|
98
|
+
readonly availableCpuMillis?: number | null;
|
|
99
|
+
readonly availableWorkspaceBytes?: number | null;
|
|
100
|
+
readonly diskLow?: boolean | null;
|
|
101
|
+
readonly preflightGeneration?: number | null;
|
|
102
|
+
readonly sandboxUnavailableCode?: string | null;
|
|
103
|
+
readonly sandboxUnavailableReason?: string | null;
|
|
104
|
+
readonly sandboxMemoryCapacity?: SandboxMemoryCapacityDiagnostics | null;
|
|
105
|
+
readonly hostRuntime?: HostRuntimeCapacitySnapshot | null;
|
|
55
106
|
readonly supportsApiSecretGrant?: boolean | null;
|
|
56
107
|
readonly supportsManagedWorktrees?: boolean | null;
|
|
57
108
|
readonly supportsManagedForgeArtifacts?: boolean | null;
|
|
58
109
|
readonly supportsRunnerAuthoredPhaseArtifacts?: boolean | null;
|
|
59
110
|
readonly supportsDraftRunOnce?: boolean | null;
|
|
111
|
+
readonly supportsDraftAttachments?: boolean | null;
|
|
112
|
+
readonly supportsDraftImageAttachments?: boolean | null;
|
|
113
|
+
}
|
|
114
|
+
export interface SandboxMemoryCapacityDiagnostics {
|
|
115
|
+
readonly defaultWorkerMemoryLimitBytes: number;
|
|
116
|
+
readonly defaultServiceMemoryLimitBytes: number;
|
|
117
|
+
readonly runtimeOverheadBytes: number;
|
|
118
|
+
readonly hostReserveBytes: number;
|
|
119
|
+
readonly activeReservationBytes: number;
|
|
120
|
+
readonly daemonTotalBytes: number;
|
|
121
|
+
readonly daemonAvailableBytes: number;
|
|
122
|
+
readonly availableForAdmissionBytes: number;
|
|
60
123
|
}
|
|
61
124
|
export interface BridgeHelloRequest {
|
|
62
125
|
readonly version: string;
|
|
63
126
|
readonly protocolVersion: number;
|
|
64
127
|
readonly capabilities: BridgeCapabilities;
|
|
65
128
|
readonly minServerVersion?: string | null;
|
|
129
|
+
readonly runnerInstanceId: string;
|
|
130
|
+
}
|
|
131
|
+
export interface BridgePublishCredential {
|
|
132
|
+
readonly username: string;
|
|
133
|
+
readonly secret: string;
|
|
134
|
+
readonly remoteUrl: string;
|
|
135
|
+
readonly expiresAt: string;
|
|
136
|
+
}
|
|
137
|
+
export interface BridgePublishGrantRedemptionResponse {
|
|
138
|
+
readonly accepted: boolean;
|
|
139
|
+
readonly code?: string | null;
|
|
140
|
+
readonly message?: string | null;
|
|
141
|
+
readonly credential?: BridgePublishCredential | null;
|
|
66
142
|
}
|
|
67
143
|
export interface BridgeHeartbeatRequest {
|
|
68
144
|
readonly status?: string | null;
|
|
@@ -78,17 +154,112 @@ export interface BridgeCallbackAck {
|
|
|
78
154
|
export interface BridgeDispatchEventCallback {
|
|
79
155
|
readonly leaseId: string;
|
|
80
156
|
readonly fencingToken: string;
|
|
157
|
+
readonly dispatchAttemptId?: string | null;
|
|
158
|
+
readonly runnerInstanceId?: string | null;
|
|
81
159
|
readonly event: RunnerDispatchEvent;
|
|
82
160
|
}
|
|
83
161
|
export interface BridgeDispatchResultCallback {
|
|
84
162
|
readonly leaseId: string;
|
|
85
163
|
readonly fencingToken: string;
|
|
164
|
+
readonly dispatchAttemptId?: string | null;
|
|
165
|
+
readonly runnerInstanceId?: string | null;
|
|
86
166
|
readonly result: RunOnceResultPacket;
|
|
87
167
|
}
|
|
168
|
+
export interface BridgeOfferDecisionCallback {
|
|
169
|
+
readonly dispatchAttemptId: string;
|
|
170
|
+
readonly leaseId: string;
|
|
171
|
+
readonly fencingToken: string;
|
|
172
|
+
readonly runnerInstanceId: string;
|
|
173
|
+
readonly accepted: boolean;
|
|
174
|
+
readonly code?: string | null;
|
|
175
|
+
readonly message?: string | null;
|
|
176
|
+
readonly reservedUntil?: string | null;
|
|
177
|
+
readonly reservedResources?: DispatchSandboxResourceRequest | null;
|
|
178
|
+
readonly sandboxConfigCommitSha?: string | null;
|
|
179
|
+
}
|
|
180
|
+
export interface BridgeDispatchStartedCallback {
|
|
181
|
+
readonly dispatchAttemptId: string;
|
|
182
|
+
readonly leaseId: string;
|
|
183
|
+
readonly fencingToken: string;
|
|
184
|
+
readonly runnerInstanceId: string;
|
|
185
|
+
}
|
|
186
|
+
export interface BridgeSecretGrantRedemptionRequest {
|
|
187
|
+
readonly dispatchAttemptId: string;
|
|
188
|
+
readonly leaseId: string;
|
|
189
|
+
readonly fencingToken: string;
|
|
190
|
+
readonly runnerInstanceId: string;
|
|
191
|
+
readonly grantId: string;
|
|
192
|
+
}
|
|
193
|
+
export interface BridgeSecretGrantRedemptionResponse extends BridgeCallbackAck {
|
|
194
|
+
readonly grant?: ApiKeySecretGrant | null;
|
|
195
|
+
}
|
|
196
|
+
export interface BridgeLeaseUpdateRequest {
|
|
197
|
+
readonly dispatchAttemptId: string;
|
|
198
|
+
readonly leaseId: string;
|
|
199
|
+
readonly fencingToken: string;
|
|
200
|
+
readonly runnerInstanceId: string;
|
|
201
|
+
readonly expiresAt: string;
|
|
202
|
+
}
|
|
88
203
|
export interface BridgeCancelRequest {
|
|
89
204
|
readonly leaseId: string;
|
|
90
205
|
readonly fencingToken: string;
|
|
91
206
|
readonly reason?: string | null;
|
|
92
207
|
}
|
|
208
|
+
export interface BridgeReapRequest {
|
|
209
|
+
readonly runnerId: string;
|
|
210
|
+
readonly runnerInstanceId: string;
|
|
211
|
+
readonly requestedAt: string;
|
|
212
|
+
readonly reason?: string | null;
|
|
213
|
+
}
|
|
214
|
+
export interface BridgeAttemptSnapshotItem {
|
|
215
|
+
readonly dispatchAttemptId: string;
|
|
216
|
+
readonly fencingToken: string;
|
|
217
|
+
readonly runnerInstanceId: string;
|
|
218
|
+
readonly status: string;
|
|
219
|
+
readonly expiresAt: string;
|
|
220
|
+
}
|
|
221
|
+
export interface BridgeAttemptSnapshotResponse extends BridgeCallbackAck {
|
|
222
|
+
readonly attempts?: readonly BridgeAttemptSnapshotItem[] | null;
|
|
223
|
+
readonly generatedAt?: string | null;
|
|
224
|
+
}
|
|
93
225
|
export type BridgeDispatchHandler = (dispatch: unknown) => Promise<void>;
|
|
226
|
+
export type BridgeOfferHandler = (dispatch: unknown) => Promise<void>;
|
|
227
|
+
export type BridgeStartHandler = (dispatch: unknown) => Promise<void>;
|
|
228
|
+
export type BridgeLeaseUpdateHandler = (request: BridgeLeaseUpdateRequest) => Promise<void>;
|
|
94
229
|
export type BridgeCancelHandler = (request: BridgeCancelRequest) => Promise<void>;
|
|
230
|
+
export type BridgeReapHandler = (request: BridgeReapRequest) => Promise<void>;
|
|
231
|
+
export type BridgeReadinessProbeHandler = (request: unknown) => Promise<void>;
|
|
232
|
+
export interface BridgeRuntimeReadinessRequest {
|
|
233
|
+
readonly schemaVersion: 1;
|
|
234
|
+
readonly requestId: string;
|
|
235
|
+
readonly fencingToken: string;
|
|
236
|
+
readonly userId: string;
|
|
237
|
+
readonly projectId: string;
|
|
238
|
+
readonly repoId: string;
|
|
239
|
+
readonly runnerInstanceId: string;
|
|
240
|
+
readonly attempt: number;
|
|
241
|
+
readonly repoName: string;
|
|
242
|
+
readonly remoteUrl: string;
|
|
243
|
+
readonly defaultBranch: string;
|
|
244
|
+
readonly cloneOnDemand: boolean;
|
|
245
|
+
readonly runnerRepoLocalPath: string;
|
|
246
|
+
readonly revision: string;
|
|
247
|
+
readonly expiresAt: string;
|
|
248
|
+
}
|
|
249
|
+
export interface BridgeRuntimeReadinessResult {
|
|
250
|
+
readonly schemaVersion: 1;
|
|
251
|
+
readonly requestId: string;
|
|
252
|
+
readonly fencingToken: string;
|
|
253
|
+
readonly runnerInstanceId: string;
|
|
254
|
+
readonly status: "parallel_ready" | "serialized" | "failed";
|
|
255
|
+
readonly runtimeContractFingerprint?: string | null;
|
|
256
|
+
readonly runtimeContractSource?: string | null;
|
|
257
|
+
readonly reasonCode?: string | null;
|
|
258
|
+
readonly reasonMessage?: string | null;
|
|
259
|
+
readonly qaAppLaunchTarget?: BridgeRuntimeReadinessQaLaunchTarget | null;
|
|
260
|
+
readonly checkedAt: string;
|
|
261
|
+
}
|
|
262
|
+
export interface BridgeRuntimeReadinessQaLaunchTarget {
|
|
263
|
+
readonly managedRuntimePort: string;
|
|
264
|
+
readonly routes: readonly string[];
|
|
265
|
+
}
|
|
@@ -1,5 +1,16 @@
|
|
|
1
|
+
import { GitClient } from "../shared/git/git.js";
|
|
2
|
+
import type { WorktreeLifecycleCoordinator } from "../worktree/worktree.base.js";
|
|
1
3
|
import type { CleanupService } from "./cleanup.base.js";
|
|
2
4
|
import type { CleanupRequest, CleanupResult } from "./cleanup.types.js";
|
|
3
|
-
export declare class
|
|
4
|
-
|
|
5
|
+
export declare class ManagedWorktreeCleanupService implements CleanupService {
|
|
6
|
+
private readonly stateRoot;
|
|
7
|
+
private readonly git;
|
|
8
|
+
private readonly coordinator;
|
|
9
|
+
constructor(stateRoot?: string, git?: GitClient, coordinator?: WorktreeLifecycleCoordinator);
|
|
10
|
+
run(request: CleanupRequest): Promise<CleanupResult>;
|
|
11
|
+
private runLocked;
|
|
12
|
+
private listWorktrees;
|
|
13
|
+
private scanManagedCandidates;
|
|
14
|
+
private readRuntimeLeases;
|
|
15
|
+
private worktreeMetadataRoot;
|
|
5
16
|
}
|
|
@@ -1,5 +1,437 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { constants as fsConstants } from "node:fs";
|
|
3
|
+
import fs from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { RepositoryCoordinatorLock } from "../sandbox/repository-lock.js";
|
|
6
|
+
import { GitClient } from "../shared/git/git.js";
|
|
7
|
+
import { isSubPath, resolveExistingDirectoryPath } from "../shared/path/path.js";
|
|
8
|
+
import { validRuntimeIsolationLease } from "../runtime/runtime-isolation-lease.js";
|
|
9
|
+
import { defaultWorktreeCoordinatorStateRoot, resolveManagedWorktreeRootForRepo, } from "../worktree/worktree-paths.js";
|
|
10
|
+
export class ManagedWorktreeCleanupService {
|
|
11
|
+
stateRoot;
|
|
12
|
+
git;
|
|
13
|
+
coordinator;
|
|
14
|
+
constructor(stateRoot = defaultWorktreeCoordinatorStateRoot(), git = new GitClient(), coordinator = new RepositoryCoordinatorLock(stateRoot)) {
|
|
15
|
+
this.stateRoot = stateRoot;
|
|
16
|
+
this.git = git;
|
|
17
|
+
this.coordinator = coordinator;
|
|
4
18
|
}
|
|
19
|
+
async run(request) {
|
|
20
|
+
const repoPath = await resolveExistingDirectoryPath(request.repoRoot);
|
|
21
|
+
const worktreeRoot = resolveManagedWorktreeRootForRepo(repoPath, request.allowedRepoRoot);
|
|
22
|
+
return await this.coordinator.withLock(repoPath, async () => {
|
|
23
|
+
return await this.runLocked(repoPath, worktreeRoot, request);
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
async runLocked(repoPath, worktreeRoot, request) {
|
|
27
|
+
if (request.mode === "merged") {
|
|
28
|
+
if (request.force)
|
|
29
|
+
await this.git.run(repoPath, ["worktree", "prune"]);
|
|
30
|
+
return emptyResult(worktreeRoot, request.force);
|
|
31
|
+
}
|
|
32
|
+
const safeRoot = await resolveSafeManagedRoot(worktreeRoot);
|
|
33
|
+
if (!safeRoot) {
|
|
34
|
+
if (request.force)
|
|
35
|
+
await this.git.run(repoPath, ["worktree", "prune"]);
|
|
36
|
+
return emptyResult(worktreeRoot, request.force);
|
|
37
|
+
}
|
|
38
|
+
const [listedWorktrees, runtimeLeases] = await Promise.all([
|
|
39
|
+
this.listWorktrees(repoPath),
|
|
40
|
+
this.readRuntimeLeases(),
|
|
41
|
+
]);
|
|
42
|
+
const metadataRoot = await this.worktreeMetadataRoot(repoPath);
|
|
43
|
+
const activeWorktrees = activePaths(listedWorktrees);
|
|
44
|
+
const activeRuntimePaths = runtimeLeases
|
|
45
|
+
.filter(lease => lease.state !== "released")
|
|
46
|
+
.map(lease => path.resolve(lease.worktreePath));
|
|
47
|
+
if (request.mode === "all") {
|
|
48
|
+
const protectedPaths = uniquePaths([
|
|
49
|
+
...activeWorktrees.filter(activePath => isSubPath(safeRoot, activePath)),
|
|
50
|
+
...activeRuntimePaths.filter(activePath => isSubPath(safeRoot, activePath)),
|
|
51
|
+
]);
|
|
52
|
+
if (protectedPaths.length > 0) {
|
|
53
|
+
throw new Error(`Refusing to remove managed worktree root ${safeRoot}; ${protectedPaths.length} active worktree or runtime lease(s) remain.`);
|
|
54
|
+
}
|
|
55
|
+
const scan = await this.scanManagedCandidates(metadataRoot, safeRoot, activeWorktrees, activeRuntimePaths);
|
|
56
|
+
if (request.force && !scan.rootRemovalSafe) {
|
|
57
|
+
throw new Error(`Refusing to remove managed worktree root ${safeRoot}; unowned content remains (${scan.skippedUnownedDirectories} skipped director${scan.skippedUnownedDirectories === 1 ? "y" : "ies"}).`);
|
|
58
|
+
}
|
|
59
|
+
if (request.force) {
|
|
60
|
+
await quarantineAndRemoveRoot(safeRoot);
|
|
61
|
+
await this.git.run(repoPath, ["worktree", "prune"]);
|
|
62
|
+
}
|
|
63
|
+
return {
|
|
64
|
+
worktreeRoot: safeRoot,
|
|
65
|
+
removedWorktrees: request.force ? scan.candidates.length : 0,
|
|
66
|
+
candidateWorktrees: scan.candidates.length,
|
|
67
|
+
protectedWorktrees: scan.protectedWorktrees,
|
|
68
|
+
skippedUnownedDirectories: scan.skippedUnownedDirectories,
|
|
69
|
+
removedRoot: request.force,
|
|
70
|
+
dryRun: !request.force,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
const scan = await this.scanManagedCandidates(metadataRoot, safeRoot, activeWorktrees, activeRuntimePaths);
|
|
74
|
+
if (!request.force) {
|
|
75
|
+
return {
|
|
76
|
+
worktreeRoot: safeRoot,
|
|
77
|
+
removedWorktrees: 0,
|
|
78
|
+
candidateWorktrees: scan.candidates.length,
|
|
79
|
+
protectedWorktrees: scan.protectedWorktrees,
|
|
80
|
+
skippedUnownedDirectories: scan.skippedUnownedDirectories,
|
|
81
|
+
removedRoot: false,
|
|
82
|
+
dryRun: true,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
let removedWorktrees = 0;
|
|
86
|
+
for (const candidate of scan.candidates) {
|
|
87
|
+
const [currentWorktrees, currentLeases] = await Promise.all([
|
|
88
|
+
this.listWorktrees(repoPath),
|
|
89
|
+
this.readRuntimeLeases(),
|
|
90
|
+
]);
|
|
91
|
+
const currentlyProtected = [
|
|
92
|
+
...activePaths(currentWorktrees),
|
|
93
|
+
...currentLeases.filter(lease => lease.state !== "released").map(lease => path.resolve(lease.worktreePath)),
|
|
94
|
+
];
|
|
95
|
+
if (worktreePathState(candidate, currentlyProtected) !== "none") {
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
const candidateIdentity = await forgeManagedCandidateIdentity(metadataRoot, candidate);
|
|
99
|
+
if (!candidateIdentity) {
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (await quarantineAndRemoveCandidate(safeRoot, candidate, candidateIdentity)) {
|
|
103
|
+
removedWorktrees += 1;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
await this.git.run(repoPath, ["worktree", "prune"]);
|
|
107
|
+
return {
|
|
108
|
+
worktreeRoot: safeRoot,
|
|
109
|
+
removedWorktrees,
|
|
110
|
+
candidateWorktrees: scan.candidates.length,
|
|
111
|
+
protectedWorktrees: scan.protectedWorktrees,
|
|
112
|
+
skippedUnownedDirectories: scan.skippedUnownedDirectories,
|
|
113
|
+
removedRoot: false,
|
|
114
|
+
dryRun: false,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
async listWorktrees(repoPath) {
|
|
118
|
+
const output = await this.git.run(repoPath, ["worktree", "list", "--porcelain", "-z"]);
|
|
119
|
+
return parseWorktreeListing(repoPath, output);
|
|
120
|
+
}
|
|
121
|
+
async scanManagedCandidates(metadataRoot, worktreeRoot, activeWorktrees, runtimePaths) {
|
|
122
|
+
const candidates = [];
|
|
123
|
+
let protectedWorktrees = 0;
|
|
124
|
+
let skippedUnownedDirectories = 0;
|
|
125
|
+
const visit = async (currentPath, root = false) => {
|
|
126
|
+
const entries = await fs.readdir(currentPath, { withFileTypes: true }).catch(error => {
|
|
127
|
+
if (isEnoent(error))
|
|
128
|
+
return [];
|
|
129
|
+
throw error;
|
|
130
|
+
});
|
|
131
|
+
let containsManagedDirectory = false;
|
|
132
|
+
let containsUnownedContent = false;
|
|
133
|
+
for (const entry of entries) {
|
|
134
|
+
if (entry.name === ".forge-cleanup-quarantine") {
|
|
135
|
+
containsUnownedContent = true;
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
const entryPath = path.join(currentPath, entry.name);
|
|
139
|
+
const stat = await fs.lstat(entryPath).catch(error => {
|
|
140
|
+
if (isEnoent(error))
|
|
141
|
+
return null;
|
|
142
|
+
throw error;
|
|
143
|
+
});
|
|
144
|
+
if (!stat)
|
|
145
|
+
continue;
|
|
146
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) {
|
|
147
|
+
containsUnownedContent = true;
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
const realEntry = await fs.realpath(entryPath).catch(error => {
|
|
151
|
+
if (isEnoent(error))
|
|
152
|
+
return null;
|
|
153
|
+
throw error;
|
|
154
|
+
});
|
|
155
|
+
if (!realEntry)
|
|
156
|
+
continue;
|
|
157
|
+
if (!isSubPath(worktreeRoot, realEntry)) {
|
|
158
|
+
throw new Error(`Managed worktree cleanup path escaped ${worktreeRoot}: ${realEntry}.`);
|
|
159
|
+
}
|
|
160
|
+
const activeState = worktreePathState(realEntry, activeWorktrees);
|
|
161
|
+
const runtimeState = worktreePathState(realEntry, runtimePaths);
|
|
162
|
+
if (activeState === "active" || runtimeState === "active") {
|
|
163
|
+
protectedWorktrees += 1;
|
|
164
|
+
containsManagedDirectory = true;
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
if (activeState === "ancestor" || runtimeState === "ancestor") {
|
|
168
|
+
containsManagedDirectory = await visit(realEntry) || containsManagedDirectory;
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
if (await forgeManagedCandidateIdentity(metadataRoot, realEntry)) {
|
|
172
|
+
candidates.push(realEntry);
|
|
173
|
+
containsManagedDirectory = true;
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
if (await visit(realEntry)) {
|
|
177
|
+
containsManagedDirectory = true;
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
skippedUnownedDirectories += 1;
|
|
181
|
+
containsUnownedContent = true;
|
|
182
|
+
}
|
|
183
|
+
return !containsUnownedContent && (root || containsManagedDirectory);
|
|
184
|
+
};
|
|
185
|
+
const rootRemovalSafe = await visit(worktreeRoot, true);
|
|
186
|
+
return { candidates, protectedWorktrees, skippedUnownedDirectories, rootRemovalSafe };
|
|
187
|
+
}
|
|
188
|
+
async readRuntimeLeases() {
|
|
189
|
+
const ledgerRoot = path.join(this.stateRoot, "runtime-ledger", "host");
|
|
190
|
+
const entries = await fs.readdir(ledgerRoot, { withFileTypes: true }).catch(error => {
|
|
191
|
+
if (isEnoent(error))
|
|
192
|
+
return [];
|
|
193
|
+
throw error;
|
|
194
|
+
});
|
|
195
|
+
const leases = [];
|
|
196
|
+
for (const entry of entries) {
|
|
197
|
+
if (!entry.name.endsWith(".json"))
|
|
198
|
+
continue;
|
|
199
|
+
if (!entry.isFile() || entry.isSymbolicLink()) {
|
|
200
|
+
throw new Error(`Refusing managed worktree cleanup because runtime ledger ${entry.name} is not a regular file.`);
|
|
201
|
+
}
|
|
202
|
+
const target = path.join(ledgerRoot, entry.name);
|
|
203
|
+
const value = await readRuntimeLedger(target, entry.name);
|
|
204
|
+
const runtimeLease = isRecord(value) ? value.runtimeLease : null;
|
|
205
|
+
if (!validRuntimeIsolationLease(runtimeLease) || runtimeLease.mode !== "host") {
|
|
206
|
+
throw new Error(`Refusing managed worktree cleanup because runtime ledger ${entry.name} has no trustworthy host lease.`);
|
|
207
|
+
}
|
|
208
|
+
leases.push(runtimeLease);
|
|
209
|
+
}
|
|
210
|
+
return leases;
|
|
211
|
+
}
|
|
212
|
+
async worktreeMetadataRoot(repoPath) {
|
|
213
|
+
const commonDirRaw = (await this.git.run(repoPath, ["rev-parse", "--git-common-dir"])).trim();
|
|
214
|
+
const commonDirPath = path.isAbsolute(commonDirRaw) ? commonDirRaw : path.resolve(repoPath, commonDirRaw);
|
|
215
|
+
return path.join(await fs.realpath(commonDirPath), "worktrees");
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
async function readRuntimeLedger(target, entryName) {
|
|
219
|
+
const pathStat = await fs.lstat(target).catch(error => {
|
|
220
|
+
if (isEnoent(error))
|
|
221
|
+
return null;
|
|
222
|
+
throw error;
|
|
223
|
+
});
|
|
224
|
+
if (!pathStat) {
|
|
225
|
+
throw new Error(`Refusing managed worktree cleanup because runtime ledger ${entryName} disappeared.`);
|
|
226
|
+
}
|
|
227
|
+
validateRuntimeLedgerStat(entryName, pathStat);
|
|
228
|
+
let handle;
|
|
229
|
+
try {
|
|
230
|
+
handle = await fs.open(target, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
|
|
231
|
+
}
|
|
232
|
+
catch (error) {
|
|
233
|
+
if (isEnoent(error) || errorCode(error) === "ELOOP") {
|
|
234
|
+
throw new Error(`Refusing managed worktree cleanup because runtime ledger ${entryName} is not a regular file.`, { cause: error });
|
|
235
|
+
}
|
|
236
|
+
throw new Error(`Refusing managed worktree cleanup because runtime ledger ${entryName} could not be opened safely.`, { cause: error });
|
|
237
|
+
}
|
|
238
|
+
try {
|
|
239
|
+
const openedStat = await handle.stat();
|
|
240
|
+
const reopenedPathStat = await fs.lstat(target);
|
|
241
|
+
validateRuntimeLedgerStat(entryName, openedStat);
|
|
242
|
+
validateRuntimeLedgerStat(entryName, reopenedPathStat);
|
|
243
|
+
validateSameRuntimeLedgerFile(entryName, pathStat, openedStat);
|
|
244
|
+
validateSameRuntimeLedgerFile(entryName, openedStat, reopenedPathStat);
|
|
245
|
+
const content = await handle.readFile("utf8");
|
|
246
|
+
const afterStat = await handle.stat();
|
|
247
|
+
const afterPathStat = await fs.lstat(target);
|
|
248
|
+
validateRuntimeLedgerStat(entryName, afterStat);
|
|
249
|
+
validateRuntimeLedgerStat(entryName, afterPathStat);
|
|
250
|
+
validateSameRuntimeLedgerFile(entryName, openedStat, afterStat);
|
|
251
|
+
validateSameRuntimeLedgerFile(entryName, afterStat, afterPathStat);
|
|
252
|
+
if (openedStat.size !== afterStat.size || Buffer.byteLength(content, "utf8") !== afterStat.size) {
|
|
253
|
+
throw new Error(`Refusing managed worktree cleanup because runtime ledger ${entryName} changed while being read.`);
|
|
254
|
+
}
|
|
255
|
+
try {
|
|
256
|
+
return JSON.parse(content);
|
|
257
|
+
}
|
|
258
|
+
catch (error) {
|
|
259
|
+
throw new Error(`Refusing managed worktree cleanup because runtime ledger ${entryName} is invalid.`, { cause: error });
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
finally {
|
|
263
|
+
await handle.close();
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
function validateRuntimeLedgerStat(entryName, stat) {
|
|
267
|
+
if (stat.isSymbolicLink() || !stat.isFile()) {
|
|
268
|
+
throw new Error(`Refusing managed worktree cleanup because runtime ledger ${entryName} is not a regular file.`);
|
|
269
|
+
}
|
|
270
|
+
if (stat.size > 256 * 1024) {
|
|
271
|
+
throw new Error(`Refusing managed worktree cleanup because runtime ledger ${entryName} is oversized.`);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
function validateSameRuntimeLedgerFile(entryName, before, after) {
|
|
275
|
+
if (before.dev !== 0 && before.ino !== 0 && after.dev !== 0 && after.ino !== 0
|
|
276
|
+
&& (before.dev !== after.dev || before.ino !== after.ino)) {
|
|
277
|
+
throw new Error(`Refusing managed worktree cleanup because runtime ledger ${entryName} changed while being read.`);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
function errorCode(error) {
|
|
281
|
+
return typeof error === "object" && error !== null && "code" in error
|
|
282
|
+
? String(error.code)
|
|
283
|
+
: undefined;
|
|
284
|
+
}
|
|
285
|
+
function emptyResult(worktreeRoot, force) {
|
|
286
|
+
return {
|
|
287
|
+
worktreeRoot: path.resolve(worktreeRoot),
|
|
288
|
+
removedWorktrees: 0,
|
|
289
|
+
candidateWorktrees: 0,
|
|
290
|
+
protectedWorktrees: 0,
|
|
291
|
+
skippedUnownedDirectories: 0,
|
|
292
|
+
removedRoot: false,
|
|
293
|
+
dryRun: !force,
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
async function resolveSafeManagedRoot(worktreeRoot) {
|
|
297
|
+
const rootPath = path.resolve(worktreeRoot);
|
|
298
|
+
const rootStat = await fs.lstat(rootPath).catch(error => {
|
|
299
|
+
if (isEnoent(error))
|
|
300
|
+
return null;
|
|
301
|
+
throw error;
|
|
302
|
+
});
|
|
303
|
+
if (!rootStat)
|
|
304
|
+
return null;
|
|
305
|
+
if (rootStat.isSymbolicLink() || !rootStat.isDirectory()) {
|
|
306
|
+
throw new Error(`Refusing cleanup for redirected or non-directory managed worktree root ${rootPath}.`);
|
|
307
|
+
}
|
|
308
|
+
const parentPath = path.dirname(rootPath);
|
|
309
|
+
const parentStat = await fs.lstat(parentPath);
|
|
310
|
+
if (parentStat.isSymbolicLink() || !parentStat.isDirectory()) {
|
|
311
|
+
throw new Error(`Refusing cleanup for redirected managed worktree root parent ${parentPath}.`);
|
|
312
|
+
}
|
|
313
|
+
const [realParent, realRoot] = await Promise.all([fs.realpath(parentPath), fs.realpath(rootPath)]);
|
|
314
|
+
if (!samePath(path.join(realParent, path.basename(rootPath)), realRoot)) {
|
|
315
|
+
throw new Error(`Refusing cleanup for redirected managed worktree root ${rootPath}.`);
|
|
316
|
+
}
|
|
317
|
+
return realRoot;
|
|
318
|
+
}
|
|
319
|
+
async function forgeManagedCandidateIdentity(metadataRoot, candidatePath) {
|
|
320
|
+
const gitFile = path.join(candidatePath, ".git");
|
|
321
|
+
const stat = await fs.lstat(gitFile).catch(error => {
|
|
322
|
+
if (isEnoent(error))
|
|
323
|
+
return null;
|
|
324
|
+
throw error;
|
|
325
|
+
});
|
|
326
|
+
if (!stat || stat.isSymbolicLink() || !stat.isFile() || stat.size > 16 * 1024)
|
|
327
|
+
return null;
|
|
328
|
+
const contents = (await fs.readFile(gitFile, "utf8")).trim();
|
|
329
|
+
if (!contents.toLowerCase().startsWith("gitdir:"))
|
|
330
|
+
return null;
|
|
331
|
+
const gitDir = contents.slice("gitdir:".length).trim();
|
|
332
|
+
if (!gitDir)
|
|
333
|
+
return null;
|
|
334
|
+
const candidateGitDir = path.resolve(candidatePath, gitDir);
|
|
335
|
+
return !samePath(metadataRoot, candidateGitDir) && isSubPath(metadataRoot, candidateGitDir)
|
|
336
|
+
? contents
|
|
337
|
+
: null;
|
|
338
|
+
}
|
|
339
|
+
async function quarantineAndRemoveCandidate(worktreeRoot, candidatePath, expectedIdentity) {
|
|
340
|
+
const quarantineRoot = path.join(worktreeRoot, ".forge-cleanup-quarantine");
|
|
341
|
+
await ensureSafeQuarantineRoot(worktreeRoot, quarantineRoot);
|
|
342
|
+
const quarantinePath = path.join(quarantineRoot, randomUUID());
|
|
343
|
+
try {
|
|
344
|
+
await fs.rename(candidatePath, quarantinePath);
|
|
345
|
+
}
|
|
346
|
+
catch (error) {
|
|
347
|
+
if (isEnoent(error))
|
|
348
|
+
return false;
|
|
349
|
+
throw error;
|
|
350
|
+
}
|
|
351
|
+
const quarantinedIdentity = await readGitFileIdentity(quarantinePath);
|
|
352
|
+
if (quarantinedIdentity !== expectedIdentity) {
|
|
353
|
+
await fs.rename(quarantinePath, candidatePath).catch(() => undefined);
|
|
354
|
+
throw new Error(`Refusing cleanup because managed worktree ownership changed while quarantining ${candidatePath}.`);
|
|
355
|
+
}
|
|
356
|
+
await fs.rm(quarantinePath, { recursive: true, force: true });
|
|
357
|
+
await fs.rmdir(quarantineRoot).catch(error => {
|
|
358
|
+
if (!isEnoent(error) && error.code !== "ENOTEMPTY")
|
|
359
|
+
throw error;
|
|
360
|
+
});
|
|
361
|
+
return true;
|
|
362
|
+
}
|
|
363
|
+
async function readGitFileIdentity(candidatePath) {
|
|
364
|
+
const gitFile = path.join(candidatePath, ".git");
|
|
365
|
+
const stat = await fs.lstat(gitFile).catch(error => {
|
|
366
|
+
if (isEnoent(error))
|
|
367
|
+
return null;
|
|
368
|
+
throw error;
|
|
369
|
+
});
|
|
370
|
+
if (!stat || stat.isSymbolicLink() || !stat.isFile() || stat.size > 16 * 1024)
|
|
371
|
+
return null;
|
|
372
|
+
return (await fs.readFile(gitFile, "utf8")).trim();
|
|
373
|
+
}
|
|
374
|
+
async function ensureSafeQuarantineRoot(worktreeRoot, quarantineRoot) {
|
|
375
|
+
await fs.mkdir(quarantineRoot, { recursive: true, mode: 0o700 });
|
|
376
|
+
const stat = await fs.lstat(quarantineRoot);
|
|
377
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) {
|
|
378
|
+
throw new Error(`Refusing cleanup through redirected quarantine root ${quarantineRoot}.`);
|
|
379
|
+
}
|
|
380
|
+
const realQuarantine = await fs.realpath(quarantineRoot);
|
|
381
|
+
if (!isSubPath(worktreeRoot, realQuarantine)) {
|
|
382
|
+
throw new Error(`Cleanup quarantine escaped managed worktree root ${worktreeRoot}.`);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
async function quarantineAndRemoveRoot(worktreeRoot) {
|
|
386
|
+
const parent = path.dirname(worktreeRoot);
|
|
387
|
+
const quarantinePath = path.join(parent, `.forge-cleanup-${path.basename(worktreeRoot)}-${randomUUID()}`);
|
|
388
|
+
await fs.rename(worktreeRoot, quarantinePath);
|
|
389
|
+
await fs.rm(quarantinePath, { recursive: true, force: true });
|
|
390
|
+
}
|
|
391
|
+
function parseWorktreeListing(repoPath, output) {
|
|
392
|
+
const worktrees = [];
|
|
393
|
+
let current = null;
|
|
394
|
+
for (const field of output.split("\0")) {
|
|
395
|
+
if (field.startsWith("worktree ")) {
|
|
396
|
+
if (current)
|
|
397
|
+
worktrees.push(current);
|
|
398
|
+
const rawPath = field.slice("worktree ".length);
|
|
399
|
+
current = {
|
|
400
|
+
worktreePath: path.resolve(path.isAbsolute(rawPath) ? rawPath : path.join(repoPath, rawPath)),
|
|
401
|
+
prunable: false,
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
else if (field.startsWith("prunable") && current) {
|
|
405
|
+
current.prunable = true;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
if (current)
|
|
409
|
+
worktrees.push(current);
|
|
410
|
+
return worktrees;
|
|
411
|
+
}
|
|
412
|
+
function activePaths(worktrees) {
|
|
413
|
+
return worktrees.filter(entry => !entry.prunable).map(entry => entry.worktreePath);
|
|
414
|
+
}
|
|
415
|
+
function worktreePathState(candidate, activePathsToCheck) {
|
|
416
|
+
if (activePathsToCheck.some(activePath => samePath(activePath, candidate)))
|
|
417
|
+
return "active";
|
|
418
|
+
if (activePathsToCheck.some(activePath => isSubPath(candidate, activePath)))
|
|
419
|
+
return "ancestor";
|
|
420
|
+
return "none";
|
|
421
|
+
}
|
|
422
|
+
function uniquePaths(values) {
|
|
423
|
+
return values.filter((value, index) => values.findIndex(candidate => samePath(candidate, value)) === index);
|
|
424
|
+
}
|
|
425
|
+
function samePath(left, right) {
|
|
426
|
+
const normalizedLeft = path.resolve(left);
|
|
427
|
+
const normalizedRight = path.resolve(right);
|
|
428
|
+
return process.platform === "win32"
|
|
429
|
+
? normalizedLeft.toLowerCase() === normalizedRight.toLowerCase()
|
|
430
|
+
: normalizedLeft === normalizedRight;
|
|
431
|
+
}
|
|
432
|
+
function isEnoent(error) {
|
|
433
|
+
return error?.code === "ENOENT";
|
|
434
|
+
}
|
|
435
|
+
function isRecord(value) {
|
|
436
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
5
437
|
}
|