openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
1,632 lines • 63.2 kB
JavaScript
import { r as truncateUtf16Safe } from "./utf16-slice-D_ngcYKd.js";
import { n as getRuntimeConfig } from "./io.runtime-B9iJRs3w.js";
import { r as isMissingPathError } from "./errno-CkbDOfLk.js";
import { w as root } from "./fs-safe-B6pvPGnf.js";
import { r as isPathInside } from "./path-guards-Cp-mGr3-.js";
import { w as resolveStateDir } from "./paths-D2sRr1a_.js";
import { t as formatErrorMessage } from "./errors-Db3Ymjlb.js";
import { t as createSubsystemLogger } from "./subsystem-Dy2tqXOS.js";
import "./config-Cs0XXL3x.js";
import { r as runCommandWithTimeout } from "./exec-BIE-3oLG.js";
import { n as withOpenClawStateLease } from "./openclaw-state-lease-Ciyr2uVu.js";
import { t as createCrustaceanSlug } from "./session-slug-DUj8bbny.js";
import { t as createCommandError } from "./command-error-D_Bz4MBL.js";
import { a as insideGitCheckout, c as requireGitBuffer, d as worktreePathExists, l as requireGitRaw, o as listGitWorktrees, s as requireGit, t as commandError, u as runGit } from "./git-BuLvhVpD.js";
import { r as createStagedInputPathMatcher, t as STAGED_INPUT_GIT_PATHSPEC } from "./staged-inputs-CJwnNq26.js";
import { a as hasLiveWorktreeRunLease, c as lockWorktreeForProcess, i as finalizeWorktreeRemoval, l as unlockWorktree, r as claimWorktreeRemoval, s as lockState, t as abortWorktreeRemoval } from "./run-lease-DzaY2Oqv.js";
import { C as updateRegistryWorktree, _ as insertRegistryWorktree, a as clearRegistryWorktreeProvisionedChunks, d as getRegistryWorktree, f as getRegistryWorktreeProvisionedChunk, l as findLiveRegistryWorktreeByOwner, m as getRegistryWorktreeProvisionedState, o as deleteRegistryWorktree, p as getRegistryWorktreeProvisionedPaths, t as WorktreeRemovalContentionError, u as findLiveRegistryWorktreeByPath, v as insertRegistryWorktreeProvisionedChunk, y as listRegistryWorktrees } from "./registry-Dvi_fTvk.js";
import { n as formatDiskSpaceBytes, r as tryReadDiskSpace } from "./disk-space-CtKhSv74.js";
import { constants, statSync } from "node:fs";
import path from "node:path";
import fs$1 from "node:fs/promises";
import os from "node:os";
import { createHash, randomUUID } from "node:crypto";
//#region src/agents/worktrees/base-ref.ts
async function resolveWorktreeBase(repoRoot, baseRef, signal) {
if (baseRef) {
let gitOperand = baseRef;
if (baseRef !== "-" && baseRef.startsWith("-")) {
const symbolic = await runGit(repoRoot, [
"-c",
"core.warnAmbiguousRefs=true",
"rev-parse",
"--symbolic-full-name",
"--verify",
"--end-of-options",
baseRef
]);
const fullRef = symbolic.stdout.trim();
if (symbolic.code !== 0) throw commandError("git rev-parse --symbolic-full-name --verify", symbolic);
if (fullRef) {
if (!fullRef.startsWith("refs/") || fullRef.includes("\n")) throw commandError("git rev-parse --symbolic-full-name --verify", symbolic);
gitOperand = fullRef;
} else {
if (symbolic.stderr.trim()) throw commandError("git rev-parse --symbolic-full-name --verify", symbolic);
gitOperand = await requireGit(repoRoot, [
"rev-parse",
"--verify",
"--end-of-options",
`${baseRef}^{commit}`
]);
}
}
return {
gitOperand,
recordRef: baseRef,
remote: false
};
}
const fetched = await runGit(repoRoot, ["fetch", "origin"], { signal });
signal?.throwIfAborted();
if (fetched.code === 0) {
const remoteHead = await runGit(repoRoot, [
"symbolic-ref",
"--quiet",
"--short",
"refs/remotes/origin/HEAD"
]);
if (remoteHead.code === 0 && remoteHead.stdout.trim()) {
const remoteRef = remoteHead.stdout.trim();
return {
gitOperand: remoteRef,
recordRef: remoteRef,
remote: true
};
}
}
return {
gitOperand: "HEAD",
recordRef: "HEAD",
remote: false
};
}
//#endregion
//#region src/agents/worktrees/capacity.ts
const GiB = 1024 ** 3;
const WORKTREE_SETUP_HEADROOM_BYTES = 4 * GiB;
/** Admission estimates allocations, not a quota on arbitrary repository scripts or other writers. */
function requireWorktreeDiskSpace(demands, purpose, snapshot = false) {
const volumes = /* @__PURE__ */ new Map();
for (const demand of demands) {
const space = tryReadDiskSpace(demand.path);
if (!space || space.totalBytes === null) throw new Error(`Cannot determine disk space near ${demand.path}; check the volume and retry ${purpose}.`);
const device = statSync(space.checkedPath).dev;
const existing = volumes.get(device);
if (existing) {
existing.available = Math.min(existing.available, space.availableBytes);
existing.bytes += demand.bytes;
} else volumes.set(device, {
path: space.checkedPath,
available: space.availableBytes,
total: space.totalBytes,
bytes: demand.bytes
});
}
for (const volume of volumes.values()) {
const required = (snapshot ? 128 * 1024 ** 2 : Math.max(4 * GiB, Math.min(volume.total / 10, 16 * GiB))) + volume.bytes;
if (!Number.isSafeInteger(Math.ceil(required)) || volume.available < required) throw new Error(`Insufficient disk space near ${volume.path} for ${purpose}: ${formatDiskSpaceBytes(volume.available)} available; approximately ${formatDiskSpaceBytes(required)} required including safety reserve. Free caches or archive/remove unused worktrees, then retry.`);
}
}
async function estimateWorktreeGitBytes(repoRoot, ref) {
const commit = await requireGit(repoRoot, [
"rev-parse",
"--verify",
"--end-of-options",
`${ref === "-" ? "@{-1}" : ref}^{commit}`
]);
const sizes = await requireGit(repoRoot, [
"ls-tree",
"-r",
"--format=%(objectsize)",
commit,
"--"
]);
let bytes = 0;
for (const size of sizes.split("\n")) {
if (!size || size === "-") continue;
const value = Number(size);
if (!Number.isSafeInteger(value) || value < 0) throw new Error("Cannot estimate worktree checkout size; inspect the repository objects and retry.");
bytes += Math.max(4096, Math.ceil(value / 4096) * 4096);
}
return bytes;
}
/** Measure without following links; unreadable trees must never be counted as empty. */
async function directorySizeBytes(root, excludeGit = false) {
let entries;
try {
entries = await fs$1.readdir(root, { withFileTypes: true });
} catch (error) {
if (isMissingPathError(error)) return 0;
throw error;
}
let total = 0;
for (const entry of entries) {
if (excludeGit && entry.name === ".git") continue;
const child = path.join(root, entry.name);
if (entry.isDirectory() && !entry.isSymbolicLink()) total += await directorySizeBytes(child, excludeGit);
else try {
total += (await fs$1.lstat(child)).size;
} catch (error) {
if (!isMissingPathError(error)) throw error;
}
}
return total;
}
//#endregion
//#region src/agents/worktrees/owner.ts
function worktreeOwnerMatches(record, params) {
return record.ownerKind === (params.ownerKind ?? "manual") && (record.ownerId ?? void 0) === (params.ownerId ?? void 0);
}
//#endregion
//#region src/agents/worktrees/provisioned-files.ts
function normalizeRelativePath(relativePath) {
if (path.isAbsolute(relativePath)) return;
const segments = relativePath.split("/");
if (segments.length === 0 || segments.some((segment) => !segment || segment === "." || segment === "..")) return;
return segments.join("/");
}
function resolveGitPath(root, relativePath) {
return path.join(root, ...relativePath.split("/"));
}
async function hasSafeParentDirectories(root, relativePath) {
const segments = relativePath.split("/");
let current = root;
for (const segment of segments.slice(0, -1)) {
current = path.join(current, segment);
try {
const stat = await fs$1.lstat(current);
if (stat.isSymbolicLink() || !stat.isDirectory()) return false;
} catch (error) {
if (error.code !== "ENOENT") throw error;
}
}
return true;
}
async function lstatIfExists(target) {
try {
return await fs$1.lstat(target);
} catch (error) {
if (error.code === "ENOENT") return;
throw error;
}
}
async function copyProvisionedFile(params) {
const normalized = normalizeRelativePath(params.relativePath);
if (!normalized || !await hasSafeParentDirectories(params.repoRoot, normalized) || !await hasSafeParentDirectories(params.worktreePath, normalized)) return false;
const source = resolveGitPath(params.repoRoot, normalized);
const destination = resolveGitPath(params.worktreePath, normalized);
const sourceStat = await fs$1.lstat(source).catch(() => void 0);
if (!sourceStat?.isFile() || sourceStat.isSymbolicLink()) return false;
await fs$1.mkdir(path.dirname(destination), { recursive: true });
try {
await fs$1.copyFile(source, destination, constants.COPYFILE_EXCL);
} catch (error) {
if (error.code === "EEXIST") return false;
throw error;
}
await fs$1.chmod(destination, sourceStat.mode);
return true;
}
async function includedFilePaths(repoRoot) {
const includePath = path.join(repoRoot, ".worktreeinclude");
if (!await worktreePathExists(includePath)) return [];
const candidatesRaw = await requireGitRaw(repoRoot, [
"ls-files",
"--others",
"--ignored",
"--exclude-standard",
"-z"
]);
const includedRaw = await requireGitRaw(repoRoot, [
"ls-files",
"--others",
"--ignored",
`--exclude-from=${includePath}`,
"-z"
]);
const included = new Set(includedRaw.split("\0").filter(Boolean));
return candidatesRaw.split("\0").filter((relativePath) => included.has(relativePath));
}
async function estimateProvisionedFileBytes(repoRoot) {
let bytes = 0;
for (const relativePath of await includedFilePaths(repoRoot)) {
const normalized = normalizeRelativePath(relativePath);
if (!normalized || !await hasSafeParentDirectories(repoRoot, normalized)) continue;
const stat = await lstatIfExists(resolveGitPath(repoRoot, normalized));
if (stat?.isFile()) bytes += Math.max(4096, stat.size);
}
return bytes;
}
/** Copies the current manifest matches and returns only paths this call actually created. */
async function provisionIncludedFiles(repoRoot, worktreePath) {
const provisioned = [];
for (const relativePath of await includedFilePaths(repoRoot)) {
const normalized = normalizeRelativePath(relativePath);
if (normalized && await copyProvisionedFile({
repoRoot,
worktreePath,
relativePath: normalized
})) provisioned.push(normalized);
}
return provisioned.toSorted();
}
const SNAPSHOT_CHUNK_BYTES = 1048576;
async function captureParentDirectoryIdentities(root, relativePath) {
const segments = relativePath.split("/");
const directories = [root];
let current = root;
for (const segment of segments.slice(0, -1)) {
current = path.join(current, segment);
directories.push(current);
}
const identities = [];
for (const directory of directories) {
const stat = await fs$1.lstat(directory);
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error(`unsafe provisioned parent directory: ${directory}`);
identities.push({
path: directory,
dev: stat.dev,
ino: stat.ino
});
}
return identities;
}
async function validateDirectoryIdentities(identities) {
for (const identity of identities) {
const stat = await fs$1.lstat(identity.path);
if (!stat.isDirectory() || stat.isSymbolicLink() || stat.dev !== identity.dev || stat.ino !== identity.ino) throw new Error(`provisioned parent directory changed: ${identity.path}`);
}
}
async function inspectProvisionedFiles(worktreePath, provisionedPaths) {
if (provisionedPaths === void 0) return;
const files = [];
for (const relativePath of provisionedPaths) {
const normalized = normalizeRelativePath(relativePath);
if (!normalized || !await hasSafeParentDirectories(worktreePath, normalized)) throw new Error(`unsafe provisioned path: ${relativePath}`);
const target = resolveGitPath(worktreePath, normalized);
const stat = await lstatIfExists(target);
if (!stat) {
files.push({
path: normalized,
target,
mode: null
});
continue;
}
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error(`provisioned path is no longer a regular file: ${relativePath}`);
files.push({
path: normalized,
target,
mode: stat.mode & 4095
});
}
return files.toSorted((a, b) => a.path.localeCompare(b.path));
}
async function hasUnsnapshotableProvisionedFiles(worktreePath, provisionedPaths) {
try {
return await inspectProvisionedFiles(worktreePath, provisionedPaths) === void 0;
} catch {
return true;
}
}
function sameFileState(left, right) {
return left.dev === right.dev && left.ino === right.ino && left.mode === right.mode && left.size === right.size && left.mtimeMs === right.mtimeMs && left.ctimeMs === right.ctimeMs;
}
/** Stores provisioned bytes outside Git so ignored credentials never enter its object database. */
async function snapshotProvisionedFiles(env, worktreeId, worktreePath, provisionedPaths, commitGuard) {
const files = await inspectProvisionedFiles(worktreePath, provisionedPaths);
if (files === void 0) throw new Error("provisioned path ledger is unavailable");
const ignoredUntracked = new Set((await requireGitRaw(worktreePath, [
"ls-files",
"--others",
"--ignored",
"--exclude-standard",
"-z"
])).split("\0").filter(Boolean));
const currentTracked = new Set((await requireGitRaw(worktreePath, [
"ls-files",
"--cached",
"-z"
])).split("\0").filter(Boolean));
const trackedAtHead = new Set((await requireGitBuffer(worktreePath, [
"ls-tree",
"-r",
"--name-only",
"-z",
"HEAD"
])).toString("utf8").split("\0").filter(Boolean));
commitGuard?.();
clearRegistryWorktreeProvisionedChunks(env, worktreeId);
const states = [];
try {
for (const file of files) {
if (file.mode === null) {
states.push({
path: file.path,
mode: null,
chunks: 0
});
continue;
}
if (currentTracked.has(file.path)) throw new Error(`provisioned path is now tracked: ${file.path}`);
if (trackedAtHead.has(file.path)) throw new Error(`provisioned path is tracked at HEAD: ${file.path}`);
if (!ignoredUntracked.has(file.path)) throw new Error(`provisioned path is no longer ignored: ${file.path}`);
const parentIdentities = await captureParentDirectoryIdentities(worktreePath, file.path);
const handle = await fs$1.open(file.target, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
try {
await validateDirectoryIdentities(parentIdentities);
const before = await handle.stat();
const buffer = Buffer.allocUnsafe(SNAPSHOT_CHUNK_BYTES);
let chunkIndex = 0;
let offset = 0;
while (offset < before.size) {
const { bytesRead } = await handle.read(buffer, 0, Math.min(buffer.byteLength, before.size - offset), offset);
if (bytesRead === 0) throw new Error(`provisioned file changed while snapshotting: ${file.path}`);
commitGuard?.();
insertRegistryWorktreeProvisionedChunk(env, {
worktreeId,
path: file.path,
chunkIndex,
data: buffer.subarray(0, bytesRead)
});
offset += bytesRead;
chunkIndex += 1;
}
const [after, current] = await Promise.all([handle.stat(), fs$1.lstat(file.target)]);
await validateDirectoryIdentities(parentIdentities);
if (!sameFileState(before, after) || !sameFileState(before, current)) throw new Error(`provisioned file changed while snapshotting: ${file.path}`);
states.push({
path: file.path,
mode: before.mode & 4095,
chunks: chunkIndex
});
} finally {
await handle.close();
}
}
return states;
} catch (error) {
clearRegistryWorktreeProvisionedChunks(env, worktreeId);
throw error;
}
}
async function writeAll(handle, data) {
let offset = 0;
while (offset < data.byteLength) {
const { bytesWritten } = await handle.write(data, offset, data.byteLength - offset);
if (bytesWritten === 0) throw new Error("provisioned snapshot write made no progress");
offset += bytesWritten;
}
}
/** Restores provisioned bytes and modes from SQLite, never from the mutable source checkout. */
async function restoreProvisionedFiles(env, worktreeId, worktreePath, states) {
for (const state of states) {
const normalized = normalizeRelativePath(state.path);
if (!normalized || !await hasSafeParentDirectories(worktreePath, normalized)) throw new Error(`unsafe provisioned path: ${state.path}`);
const target = resolveGitPath(worktreePath, normalized);
if (state.mode === null) {
if (await lstatIfExists(target)) throw new Error(`snapshot expected provisioned path to be absent: ${state.path}`);
continue;
}
await fs$1.mkdir(path.dirname(target), { recursive: true });
const parentIdentities = await captureParentDirectoryIdentities(worktreePath, normalized);
const handle = await fs$1.open(target, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | (constants.O_NOFOLLOW ?? 0), state.mode);
try {
await validateDirectoryIdentities(parentIdentities);
for (let chunkIndex = 0; chunkIndex < state.chunks; chunkIndex += 1) {
const chunk = getRegistryWorktreeProvisionedChunk(env, {
worktreeId,
path: state.path,
chunkIndex
});
if (!chunk) throw new Error(`provisioned snapshot chunk missing: ${state.path}:${chunkIndex}`);
await writeAll(handle, chunk);
}
await handle.chmod(state.mode);
await validateDirectoryIdentities(parentIdentities);
} finally {
await handle.close();
}
}
}
//#endregion
//#region src/agents/worktrees/service.ts
const IDLE_GC_MS = 6048e5;
const SNAPSHOT_RETENTION_MS = 2592e6;
const WORKTREE_GC_INTERVAL_MS = 36e5;
const NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/;
const WORKTREE_CREATE_LEASE_SCOPE = "core:managed-worktrees:create";
const WORKTREE_CREATE_LEASE_MS = 6e4;
const WORKTREE_CREATE_LEASE_WAIT_MS = 3e5;
const WORKTREE_CHECKOUT_TIMEOUT_MS = 3e5;
/** Removal aborted because snapshot loss was not permitted. */
var WorktreeSnapshotError = class extends Error {
constructor(snapshotError, options) {
super(`worktree snapshot failed; removal aborted: ${snapshotError}`, options);
this.snapshotError = snapshotError;
}
};
var WorktreeRemovalLockError = class extends Error {
constructor(kind, message) {
super(message);
this.kind = kind;
this.name = "WorktreeRemovalLockError";
}
};
function classifyWorktreeRemovalError(error) {
if (error instanceof WorktreeRemovalContentionError) return "busy";
if (error instanceof WorktreeRemovalLockError) return error.kind;
if (error instanceof WorktreeSnapshotError) return "snapshot-failed";
return "cleanup-failed";
}
var WorktreeRepositoryError = class extends Error {};
const SNAPSHOT_REF_PREFIX = "refs/openclaw/snapshots";
const log = createSubsystemLogger("agents/worktrees");
const WORKTREE_CLEANUP_TARGET = 100;
/** A bounded default; manual and actively used worktrees remain protected. */
function resolveWorktreeCleanupLimits() {
return { maxCount: WORKTREE_CLEANUP_TARGET };
}
function validateName(name) {
if (!NAME_PATTERN.test(name)) throw new Error("worktree name must match [a-z0-9][a-z0-9-]{0,63}");
return name;
}
function findWorktreeByName(env, fingerprint, name) {
return listRegistryWorktrees(env).find((record) => record.repoFingerprint === fingerprint && record.name === name);
}
async function nameIsUnavailable(env, repoRoot, fingerprint, root, name, owner) {
const worktreePath = path.join(root, name);
const registered = findWorktreeByName(env, fingerprint, name);
if (owner.ownerId && registered && registered.removedAt === void 0 && worktreeOwnerMatches(registered, owner)) return false;
if (registered || await worktreePathExists(worktreePath)) return true;
const branch = `openclaw/${name}`;
const branchExists = await runGit(repoRoot, [
"show-ref",
"--quiet",
"--verify",
`refs/heads/${branch}`
]);
if (branchExists.code === 0) return true;
if (branchExists.code !== 1) throw commandError("git show-ref --verify", branchExists);
return (await listGitWorktrees(repoRoot)).some((entry) => path.resolve(entry.path) === path.resolve(worktreePath));
}
function appendNameOrdinal(name, ordinal) {
const suffix = `-${ordinal}`;
return `${name.slice(0, 64 - suffix.length).replace(/-+$/g, "")}${suffix}`;
}
async function generateName(env, repoRoot, fingerprint, root, owner, suggestedName) {
validateName(suggestedName);
for (let ordinal = 1; ordinal <= 1e3; ordinal += 1) {
const candidate = ordinal === 1 ? suggestedName : appendNameOrdinal(suggestedName, ordinal);
if (!await nameIsUnavailable(env, repoRoot, fingerprint, root, candidate, owner)) return candidate;
}
throw new Error(`no available worktree name for ${suggestedName}`);
}
async function resolveRepositoryFromRealPath(requested, requestedLabel) {
const rootResult = await runGit(requested, ["rev-parse", "--show-toplevel"]);
if (rootResult.code !== 0) {
if (insideGitCheckout(requested)) throw new Error(`Git metadata is unavailable for ${requested}; checkout preserved. Restore the original repository metadata, then use git worktree repair from that repository. Do not recreate its index or delete the checkout to bypass recovery.`);
throw new WorktreeRepositoryError(`not a git checkout: ${requestedLabel}`);
}
const sourceRoot = await fs$1.realpath(rootResult.stdout.trim());
if ((await runGit(sourceRoot, [
"rev-parse",
"--verify",
"HEAD^{commit}"
])).code !== 0) throw new WorktreeRepositoryError(`git checkout has no commits: ${requestedLabel}`);
const commonRaw = await requireGit(sourceRoot, ["rev-parse", "--git-common-dir"]);
const commonDir = await fs$1.realpath(path.isAbsolute(commonRaw) ? commonRaw : path.resolve(sourceRoot, commonRaw));
const primary = (await listGitWorktrees(sourceRoot))[0]?.path ?? sourceRoot;
const canonicalRoot = await fs$1.realpath(primary);
const origin = await runGit(canonicalRoot, [
"config",
"--get",
"remote.origin.url"
]);
const originUrl = origin.code === 0 ? origin.stdout.trim() : "";
return {
repoRoot: canonicalRoot,
sourceRoot,
commonDir,
originUrl,
fingerprint: createHash("sha256").update(`${commonDir}\n${originUrl}`).digest("hex").slice(0, 16)
};
}
async function resolveRepository(repoRoot) {
return await resolveRepositoryFromRealPath(await fs$1.realpath(repoRoot).catch(() => {
throw new Error(`repository does not exist: ${repoRoot}`);
}), repoRoot);
}
async function canonicalPathKey(target) {
const canonical = await fs$1.realpath(target);
return process.platform === "win32" ? canonical.toLowerCase() : canonical;
}
async function shouldPreserveOrphanCandidate(target, managedPaths, customRoots) {
const targetKey = await canonicalPathKey(target);
if (managedPaths.has(targetKey) || [...customRoots].some((root) => isPathInside(root, targetKey) || isPathInside(targetKey, root))) return true;
return await worktreePathExists(path.join(target, ".git"));
}
async function cleanupFailedCreate(repoRoot, worktreePath, branch) {
const removed = await runGit(repoRoot, [
"worktree",
"remove",
"--force",
worktreePath
]);
const deletedBranch = await runGit(repoRoot, [
"branch",
"-D",
branch
]);
await runGit(repoRoot, ["worktree", "prune"]);
if (removed.code !== 0 || deletedBranch.code !== 0) {
const failure = removed.code !== 0 ? commandError("git worktree remove", removed) : commandError("git branch -D", deletedBranch);
throw new Error(`failed to clean up worktree creation: ${failure.message}`);
}
}
async function resetFailedWorktreeAdd(repoRoot, worktreePath, branch) {
if ((await listGitWorktrees(repoRoot)).some((entry) => path.resolve(entry.path) === path.resolve(worktreePath))) {
const removed = await runGit(repoRoot, [
"worktree",
"remove",
"--force",
worktreePath
]);
if (removed.code !== 0) throw commandError("git worktree remove", removed);
} else if (await worktreePathExists(worktreePath)) await fs$1.rm(worktreePath, {
recursive: true,
force: true
});
if ((await runGit(repoRoot, [
"show-ref",
"--quiet",
"--verify",
`refs/heads/${branch}`
])).code === 0) await requireGit(repoRoot, [
"branch",
"-D",
branch
]);
await requireGit(repoRoot, ["worktree", "prune"]);
}
async function canResetFailedWorktreeAdd(repoRoot, worktreePath, branch, failure) {
const message = (failure.stderr || failure.stdout).trim().split("\n").slice(-12).join("\n");
const createdBranch = message.includes(`Preparing worktree (new branch '${branch}')`);
if (message.includes("unable to checkout working tree") || createdBranch) return true;
if ((await listGitWorktrees(repoRoot)).some((entry) => path.resolve(entry.path) === path.resolve(worktreePath)) || await worktreePathExists(worktreePath)) return false;
return (await runGit(repoRoot, [
"show-ref",
"--quiet",
"--verify",
`refs/heads/${branch}`
])).code === 1;
}
async function runSetupScript(repoRoot, worktreePath, params) {
const setupScript = path.join(repoRoot, ".openclaw", "worktree-setup.sh");
const stat = await fs$1.stat(setupScript).catch(() => void 0);
if (!stat?.isFile() || (stat.mode & 73) === 0) return;
const timeoutMs = 12e4;
params.onProgress?.("setup");
params.signal?.throwIfAborted();
params.commitGuard?.();
const result = await runCommandWithTimeout([setupScript], {
timeoutMs,
cwd: worktreePath,
signal: params.signal,
killProcessTree: true,
env: {
OPENCLAW_SOURCE_TREE_PATH: repoRoot,
OPENCLAW_WORKTREE_PATH: worktreePath
}
});
params.signal?.throwIfAborted();
if (result.code !== 0) throw createCommandError("worktree setup", result, { timeoutMs });
}
function splitNullBuffer(input) {
const fields = [];
let start = 0;
for (let index = 0; index < input.length; index += 1) {
if (input[index] !== 0) continue;
if (index > start) fields.push(input.subarray(start, index));
start = index + 1;
}
if (start < input.length) fields.push(input.subarray(start));
return fields;
}
function gitPathKey(gitPath) {
return gitPath.toString("hex");
}
function checkoutPathFromGitBytes(checkoutRoot, gitPath) {
if (process.platform === "win32") return path.join(checkoutRoot, ...gitPath.toString("utf8").split("/"));
return Buffer.concat([
Buffer.from(checkoutRoot),
Buffer.from(path.sep),
gitPath
]);
}
async function rawPathExists(target) {
try {
await fs$1.lstat(target);
return true;
} catch (error) {
if (isMissingPathError(error)) return false;
throw error;
}
}
async function containsSnapshotGitMarker(checkoutRoot, snapshotPaths) {
let visiblePaths = snapshotPaths ? [...snapshotPaths] : [];
if (!snapshotPaths) {
if (splitNullBuffer(await requireGitBuffer(checkoutRoot, [
"ls-files",
"--stage",
"-z"
])).some((entry) => entry.subarray(0, 7).toString() === "160000 ")) return true;
visiblePaths = splitNullBuffer(await requireGitBuffer(checkoutRoot, [
"ls-files",
"-z",
"--cached",
"--others",
"--exclude-standard"
]));
}
const checked = /* @__PURE__ */ new Set();
const ignoredPaths = splitNullBuffer(await requireGitBuffer(checkoutRoot, [
"ls-files",
"-z",
"--others",
"--ignored",
"--exclude-standard"
]));
for (const gitPath of [...visiblePaths, ...ignoredPaths]) for (let end = gitPath.indexOf(47); end !== -1; end = gitPath.indexOf(47, end + 1)) {
const directory = gitPath.subarray(0, end);
const key = gitPathKey(directory);
if (checked.has(key)) continue;
checked.add(key);
if (await rawPathExists(checkoutPathFromGitBytes(checkoutRoot, Buffer.concat([directory, Buffer.from("/.git")])))) return true;
}
return false;
}
async function snapshotWorktree(stateEnv, record, reason, provisionedPaths, commitGuard) {
commitGuard?.();
if (!provisionedPaths) throw new Error("provisioned path ledger is unavailable");
const tempDir = await fs$1.mkdtemp(path.join(os.tmpdir(), "openclaw-worktree-index-"));
const indexPath = path.join(tempDir, "index");
const snapshotRef = `${SNAPSHOT_REF_PREFIX}/${record.id}`;
const filemodeArgs = process.platform === "win32" ? [] : ["-c", "core.filemode=true"];
const env = {
GIT_INDEX_FILE: indexPath,
GIT_AUTHOR_NAME: "OpenClaw",
GIT_AUTHOR_EMAIL: "openclaw@localhost",
GIT_COMMITTER_NAME: "OpenClaw",
GIT_COMMITTER_EMAIL: "openclaw@localhost"
};
try {
const provisioned = new Set(provisionedPaths.map((entry) => gitPathKey(Buffer.from(entry))));
const snapshotPaths = /* @__PURE__ */ new Map();
const addSnapshotPath = (entry) => {
const key = gitPathKey(entry);
if (!provisioned.has(key)) snapshotPaths.set(key, entry);
};
const sparseConfig = await runGit(record.path, [
"config",
"--bool",
"core.sparseCheckout"
]);
if (sparseConfig.code !== 0 && sparseConfig.code !== 1) throw commandError("git config --bool core.sparseCheckout", sparseConfig);
const sparseCheckout = sparseConfig.code === 0 && sparseConfig.stdout.trim() === "true";
const sparseCandidates = [];
for (const entry of splitNullBuffer(await requireGitBuffer(record.path, [
"ls-files",
"-v",
"-z"
]))) {
if (entry.length < 3) continue;
const tag = String.fromCharCode(entry[0] ?? 0).toUpperCase();
const trackedPath = entry.subarray(2);
if (tag !== "S" || await rawPathExists(checkoutPathFromGitBytes(record.path, trackedPath)) || !sparseCheckout) addSnapshotPath(trackedPath);
else sparseCandidates.push(trackedPath);
}
if (sparseCandidates.length > 0) {
const included = await requireGitBuffer(record.path, [
"sparse-checkout",
"check-rules",
"-z"
], { input: Buffer.concat(sparseCandidates.flatMap((entry) => [entry, Buffer.from([0])])) });
for (const entry of splitNullBuffer(included)) addSnapshotPath(entry);
}
for (const args of [[
"diff-index",
"--cached",
"--name-only",
"-z",
"HEAD",
"--"
], [
"ls-files",
"-z",
"--others",
"--exclude-standard"
]]) for (const entry of splitNullBuffer(await requireGitBuffer(record.path, args))) addSnapshotPath(entry);
const isStagedInput = createStagedInputPathMatcher(await root(record.path));
for (const entry of splitNullBuffer(await requireGitBuffer(record.path, [
"ls-files",
"-z",
"--others",
"--ignored",
"--exclude-standard",
"--",
STAGED_INPUT_GIT_PATHSPEC
]))) if (await isStagedInput(entry.toString("utf8"))) addSnapshotPath(entry);
if (await containsSnapshotGitMarker(record.path, snapshotPaths.values())) throw new Error("nested git repositories cannot be snapshotted losslessly");
commitGuard?.();
await prepareSnapshotIndex(stateEnv, record, snapshotPaths, provisionedPaths, env);
const provisionedState = await snapshotProvisionedFiles(stateEnv, record.id, record.path, provisionedPaths, commitGuard);
commitGuard?.();
await requireGit(record.path, [
...filemodeArgs,
"update-index",
"--add",
"--remove",
"-z",
"--stdin"
], {
env,
input: snapshotPaths.size > 0 ? Buffer.concat([...snapshotPaths.values()].flatMap((entry) => [entry, Buffer.from([0])])) : Buffer.alloc(0)
});
commitGuard?.();
const tree = await requireGit(record.path, [...filemodeArgs, "write-tree"], { env });
for (const provisionedPath of provisionedPaths) if (await requireGit(record.path, [
"--literal-pathspecs",
"ls-tree",
"-r",
"--name-only",
tree,
"--",
provisionedPath
])) throw new Error(`provisioned path entered Git snapshot: ${provisionedPath}`);
if ((await requireGit(record.path, [
"ls-tree",
"-r",
tree
])).split("\n").some((entry) => entry.startsWith("160000 "))) throw new Error("nested git repositories cannot be snapshotted losslessly");
const parent = await requireGit(record.path, ["rev-parse", "HEAD"]);
commitGuard?.();
const commit = await requireGit(record.path, [
...filemodeArgs,
"commit-tree",
tree,
"-p",
parent,
"-m",
`OpenClaw worktree snapshot: ${reason}`
], { env });
commitGuard?.();
await requireGit(record.repoRoot, [
"update-ref",
snapshotRef,
commit
]);
return {
snapshotRef,
provisionedState
};
} finally {
await fs$1.rm(tempDir, {
recursive: true,
force: true
});
}
}
async function prepareSnapshotIndex(env, record, snapshotPaths, provisioned, indexEnv) {
const headPaths = splitNullBuffer(await requireGitBuffer(record.path, [
"ls-tree",
"-r",
"--name-only",
"-z",
"HEAD"
]));
const metadataBytes = [...headPaths, ...snapshotPaths.values()].reduce((total, entry) => total + 512 + 2 * entry.length, 0);
requireWorktreeDiskSpace([{
path: os.tmpdir(),
bytes: 2 * metadataBytes
}], "worktree safety snapshot index", true);
await requireGit(record.path, ["read-tree", "HEAD"], { env: indexEnv });
const changed = new Set(splitNullBuffer(await requireGitBuffer(record.path, [
"-c",
"diff.autoRefreshIndex=true",
"diff",
"--no-ext-diff",
"--no-textconv",
"--no-renames",
"--name-only",
"-z",
"--"
], { env: indexEnv })).map(gitPathKey));
const tracked = new Set(headPaths.map(gitPathKey));
const unique = new Map([...snapshotPaths].filter(([key]) => changed.has(key) || !tracked.has(key)));
for (const value of provisioned) unique.set(gitPathKey(Buffer.from(value)), Buffer.from(value));
const provisionedKeys = new Set(provisioned.map((value) => gitPathKey(Buffer.from(value))));
let gitBytes = 0, provisionedBytes = 0;
for (const [key, value] of unique) try {
const stat = await fs$1.lstat(checkoutPathFromGitBytes(record.path, value));
if (provisionedKeys.has(key)) provisionedBytes += stat.size;
else gitBytes += stat.size;
} catch (error) {
if (!isMissingPathError(error)) throw error;
}
const commonDir = await requireGit(record.repoRoot, ["rev-parse", "--git-common-dir"]);
requireWorktreeDiskSpace([
{
path: path.resolve(record.repoRoot, commonDir),
bytes: 2 * gitBytes + metadataBytes
},
{
path: resolveStateDir(env),
bytes: 2 * provisionedBytes
},
{
path: os.tmpdir(),
bytes: 2 * metadataBytes
}
], "worktree safety snapshot", true);
}
var ManagedWorktreeService = class {
constructor(options = {}) {
this.env = options.env ?? process.env;
this.now = options.now ?? Date.now;
this.getConfig = options.getConfig;
}
async worktreesRoot() {
const root = this.getConfig?.().worktreeRoot ?? path.join(resolveStateDir(this.env), "worktrees");
await fs$1.mkdir(root, { recursive: true });
return await fs$1.realpath(root);
}
async create(params) {
params.signal?.throwIfAborted();
const repository = await resolveRepository(params.repoRoot);
return await this.withAllocationLease(params, async (guard) => {
if (params.ownerId) {
const existing = findLiveRegistryWorktreeByOwner(this.env, params.ownerKind ?? "manual", params.ownerId);
if (existing && await worktreePathExists(existing.path)) {
const validated = await this.rebindLiveRepository(existing, guard);
if (validated.repoRoot !== repository.repoRoot) throw new Error(`worktree owner ${params.ownerKind ?? "manual"} ${params.ownerId} is already bound to another repository`);
guard.commitGuard?.();
return validated;
}
if (existing) {
guard.commitGuard?.();
updateRegistryWorktree(this.env, existing.id, { removedAt: this.now() });
}
}
return await this.createForRepository({
...params,
...guard
}, repository, params.name ?? params.suggestedName ?? createCrustaceanSlug());
});
}
async withAllocationLease(params, run) {
return await withOpenClawStateLease({
scope: WORKTREE_CREATE_LEASE_SCOPE,
key: "capacity",
database: {
scope: "shared",
options: { env: this.env }
},
leaseMs: WORKTREE_CREATE_LEASE_MS,
waitMs: WORKTREE_CREATE_LEASE_WAIT_MS,
leaseLabel: "managed worktree allocation lease",
operationLabel: "agents.worktrees.allocation",
signal: params.signal
}, async (lease) => await run({
signal: lease.signal,
commitGuard: () => {
lease.assertOwned();
params.commitGuard?.();
}
}));
}
requireAllocationSpace(target, repository, bytes = 0) {
requireWorktreeDiskSpace([
{
path: target,
bytes
},
{
path: repository.commonDir,
bytes: 0
},
{
path: repository.sourceRoot,
bytes: 0
},
{
path: resolveStateDir(this.env),
bytes: 0
}
], "worktree allocation");
}
async createForRepository(params, repository, inferredName) {
params.signal?.throwIfAborted();
params.onProgress?.("checkout");
const suppliedName = params.name === void 0 ? void 0 : validateName(params.name);
const existing = suppliedName ? findWorktreeByName(this.env, repository.fingerprint, suppliedName) : void 0;
if (existing && !existing.removedAt && !worktreeOwnerMatches(existing, params)) throw new Error(`worktree name is already in use by ${existing.ownerKind}${existing.ownerId ? ` ${existing.ownerId}` : ""}: ${suppliedName}`);
if (existing && existing.removedAt === void 0) {
if (await worktreePathExists(existing.path)) return await this.rebindLiveRepository(existing, params);
updateRegistryWorktree(this.env, existing.id, { removedAt: this.now() });
}
if (existing && existing.removedAt !== void 0 && existing.snapshotRef) {
if (!worktreeOwnerMatches(existing, params)) throw new Error(`worktree name is already in use by ${existing.ownerKind}${existing.ownerId ? ` ${existing.ownerId}` : ""}: ${suppliedName}`);
return await this.restoreWithAllocation({
id: existing.id,
signal: params.signal,
commitGuard: params.commitGuard
});
}
const root = path.join(await this.worktreesRoot(), repository.fingerprint);
const name = suppliedName ?? await generateName(this.env, repository.repoRoot, repository.fingerprint, root, params, params.suggestedName ?? inferredName);
const worktreePath = path.join(root, name);
const branch = `openclaw/${name}`;
const branchExists = await runGit(repository.repoRoot, [
"show-ref",
"--quiet",
"--verify",
`refs/heads/${branch}`
]);
if (branchExists.code === 0) throw new Error(`branch already exists: ${branch}`);
if (branchExists.code !== 1) throw commandError("git show-ref --verify", branchExists);
params.signal?.throwIfAborted();
params.commitGuard?.();
this.requireAllocationSpace(worktreePath, repository);
params.commitGuard?.();
const base = await resolveWorktreeBase(repository.repoRoot, params.baseRef, params.signal);
const gitBytes = Math.max(await estimateWorktreeGitBytes(repository.repoRoot, base.gitOperand), base.remote ? await estimateWorktreeGitBytes(repository.repoRoot, "HEAD") : 0);
const provisionedBytes = await estimateProvisionedFileBytes(repository.sourceRoot);
const setupStat = params.runSetupScript === false ? void 0 : await fs$1.stat(path.join(repository.sourceRoot, ".openclaw", "worktree-setup.sh")).catch(() => void 0);
const runRepositorySetup = setupStat?.isFile() === true && (setupStat.mode & 73) !== 0;
const setupBytes = runRepositorySetup ? Math.max(WORKTREE_SETUP_HEADROOM_BYTES, await directorySizeBytes(repository.sourceRoot, true)) : 0;
this.requireAllocationSpace(worktreePath, repository, 2 * (gitBytes + provisionedBytes) + setupBytes);
params.signal?.throwIfAborted();
params.commitGuard?.();
await fs$1.mkdir(root, { recursive: true });
params.signal?.throwIfAborted();
params.commitGuard?.();
let gitBase = base.gitOperand;
let recordBase = base.recordRef;
const worktreeAddArgs = () => [
"worktree",
"add",
"-b",
branch,
"--",
worktreePath,
gitBase
];
let added = await runGit(repository.repoRoot, worktreeAddArgs(), {
timeoutMs: WORKTREE_CHECKOUT_TIMEOUT_MS,
signal: params.signal
});
if (added.code !== 0 && base.remote) {
if (!await canResetFailedWorktreeAdd(repository.repoRoot, worktreePath, branch, added)) throw commandError("git worktree add", added);
await resetFailedWorktreeAdd(repository.repoRoot, worktreePath, branch);
params.signal?.throwIfAborted();
params.commitGuard?.();
gitBase = "HEAD";
recordBase = "HEAD";
added = await runGit(repository.repoRoot, worktreeAddArgs(), {
timeoutMs: WORKTREE_CHECKOUT_TIMEOUT_MS,
signal: params.signal
});
}
if (added.code !== 0) throw commandError("git worktree add", added);
let provisionedPaths;
try {
params.signal?.throwIfAborted();
params.commitGuard?.();
this.requireAllocationSpace(worktreePath, repository, 2 * provisionedBytes + setupBytes);
provisionedPaths = await provisionIncludedFiles(repository.sourceRoot, worktreePath);
if (runRepositorySetup) {
this.requireAllocationSpace(worktreePath, repository, setupBytes);
await runSetupScript(repository.sourceRoot, worktreePath, params);
}
params.signal?.throwIfAborted();
params.commitGuard?.();
this.requireAllocationSpace(worktreePath, repository);
} catch (error) {
try {
await cleanupFailedCreate(repository.repoRoot, worktreePath, branch);
} catch (cleanupError) {
throw new Error(`${String(error)}\n${String(cleanupError)}`, { cause: cleanupError });
}
throw error;
}
const createdAt = this.now();
const record = {
id: randomUUID(),
name,
repoFingerprint: repository.fingerprint,
repoRoot: repository.repoRoot,
path: worktreePath,
branch,
baseRef: recordBase,
ownerKind: params.ownerKind ?? "manual",
...params.ownerId ? { ownerId: params.ownerId } : {},
createdAt,
lastActiveAt: createdAt
};
insertRegistryWorktree(this.env, record, { provisionedPaths });
return record;
}
async list() {
const records = listRegistryWorktrees(this.env);
for (const record of records) if (record.removedAt === void 0 && !await worktreePathExists(record.path)) {
const removedAt = this.now();
updateRegistryWorktree(this.env, record.id, { removedAt });
record.removedAt = removedAt;
}
return records.filter((record) => record.removedAt === void 0 || record.snapshotRef);
}
/** Returns persisted worktree facts without probing paths or mutating lifecycle state. */
listRegistryRecords() {
return listRegistryWorktrees(this.env);
}
findLiveByOwner(ownerKind, ownerId) {
return findLiveRegistryWorktreeByOwner(this.env, ownerKind, ownerId);
}
findLiveById(id) {
const record = getRegistryWorktree(this.env, id);
return record?.removedAt === void 0 ? record : void 0;
}
/** Resolves the canonical registry root and the caller's own checkout root. */
async resolveRepositoryPaths(repoRoot) {
const resolved = await resolveRepository(repoRoot);
return {
canonicalRoot: resolved.repoRoot,
sourceRoot: resolved.sourceRoot
};
}
/** Resolves the repository facts shared by managed worktrees and project discovery. */
async resolveRepositoryIdentity(repoRoot) {
const resolved = await resolveRepository(repoRoot);
return {
checkoutRoot: resolved.sourceRoot,
repoRoot: resolved.repoRoot,
originUrl: resolved.originUrl,
fingerprint: resolved.fingerprint
};
}
/**
* Lists selectable base refs for a repository without touching the network.
* Base-ref pickers must stay snappy; resolveWorktreeBase() still fetches on create
* when no explicit ref is chosen.
*/
async listRepositoryBranches(repoRoot, options = {}) {
let repository;
if (options.includeRepositoryStatus) try {
const requested = await fs$1.realpath(repoRoot);
if (!(await fs$1.stat(requested)).isDirectory()) return {
branches: [],
repositoryStatus: "unavailable"
};
if (!insideGitCheckout(requested)) return {
branches: [],
repositoryStatus: "not_git"
};
repository = await resolveRepositoryFromRealPath(requested, repoRoot);
} catch {
return {
branches: [],
repositoryStatus: "unavailable"
};
}
else repository = await resolveRepository(repoRoot);
const branches = /* @__PURE__ */ new Map();
const remoteRaw = await runGit(repository.repoRoot, [
"for-each-ref",
"--format=%(refname)",
"refs/remotes"
]);
if (remoteRaw.code === 0) for (const refname of remoteRaw.stdout.split("\n")) {
const trimmed = refname.trim();
if (!trimmed.startsWith("refs/remotes/")) continue;
const withoutPrefix = trimmed.slice(13);
const slash = withoutPrefix.indexOf("/");
if (slash <= 0) continue;
const shortName = withoutPrefix.slice(slash + 1);
if (!shortName || shortName === "HEAD") continue;
branches.set(shortName, {
name: withoutPrefix,
kind: "remote"
});
}
const localRaw = await runGit(repository.repoRoot, [
"for-each-ref",
"--format=%(refname:short)",
"refs/heads"
]);
if (localRaw.code === 0) for (const line of localRaw.stdout.split("\n")) {
const name = line.trim();
if (name) branches.set(name, {
name,
kind: "local"
});
}
const remoteHead = await runGit(repository.repoRoot, [
"symbolic-ref",
"--quiet",
"--short",
"refs/remotes/origin/HEAD"
]);
const defaultShort = remoteHead.code === 0 ? remoteHead.stdout.trim().replace(/^origin\//, "") || void 0 : void 0;
const head = await runGit(repository.repoRoot, [
"symbolic-ref",
"--quiet",
"--short",
"HEAD"
]);
const headBranch = head.code === 0 ? head.stdout.trim() || void 0 : void 0;
const defaultBranch = defaultShort ? branches.get(defaultShort)?.name ?? defaultShort : void 0;
const rank = (shortName) => shortName === defaultShort ? 0 : shortName === headBranch ? 1 : 2;
return {
branches: [...branches.entries()].toSorted(([aShort, a], [bShort, b]) => rank(aShort) - rank(bShort) || a.name.localeCompare(b.name)).map(([, branch]) => branch),
...defaultBranch ? { defaultBranch } : {},
...headBranch ? { headBranch } : {},
...options.includeRepositoryStatus ? { repositoryStatus: "git" } : {}
};
}
async acquire(id) {
const record = this.requireLiveRecord(id);
await lockWorktreeForProcess(record);
const lastActiveAt = this.now();
updateRegistryWorktree(this.env, id, { lastActiveAt });
return {
...record,
lastActiveAt
};
}
async release(id) {
const record = getRegistryWorktree(this.env, id);
if (!record || record.removedAt !== void 0 || !await worktreePathExists(record.path)) return;
const state = await lockState(record);
if (state.kind === "live" && state.pid !== process.pid) return;
if (state.kind === "foreign") return;
if (state.kind !== "none") await unlockWorktree(record);
}
async remove(params) {
return await this.withAllocationLease(params, async (guard) => await this.removeWithAllocation({
...params,
...guard
}));
}
async removeWithAllocation(params) {
params.signal?.throwIfAborted();
params.commitGuard?.();
let record = this.requireLiveRecord(params.id);
const claimToken = params.claimToken ?? randomUUID();
claimWorktreeRemoval(this.env, {
worktreeId: record.id,
token: claimToken
});
try {
record = await this.rebindLiveRepository(record, params);
const state = await lockState(record);
if (state.kind === "live" || state.kind === "foreign") throw new WorktreeRemovalLockError(state.kind === "live" ? "busy" : "foreign-lock", state.kind === "live" ? `worktree is locked by live OpenClaw pid ${state.pid}` : `worktree has a foreign lock${state.reason ? `: ${state.reason}` : ""}`);
if (state.kind !== "none") {
params.commitGuard?.();
await requireGit(record.repoRoot, [
"worktree",
"unlock",
record.path
]);
}
let snapshotRef = record.snapshotRef;
let snapshotError;
try {
const snapshot = await snapshotWorktree(this.env, record, params.reason, getRegistryWorktreeProvisionedPaths(this.env, record.id), params.commitGuard);
snapshotRef = snapshot.snapshotRef;
params.commitGuard?.();
updateRegistryWorktree(this.env, record.id, {
snapshotRef,
provisionedState: snapshot.provisionedState
});
} catch (error) {
snapshotError = error instanceof Error ? error.message : String(error);
try {
clearRegistryWorktreeProvisionedChunks(this.env, record.id);
} catch (cleanupError) {
throw new WorktreeSnapshotError(`${snapshotError}; provisioned snapshot cleanup failed: ${String(cleanupError)}`, { cause: cleanupError });
}
if (!params.allowSnapshotLoss) throw new WorktreeSnapshotError(snapshotError, { cause: error });
}
params.signal?.throwIfAborted();
params.commitGuard?.();
const removed = await runGit(record.repoRoot, [
"worktree",
"remove",
"--force",
record.path
]);
if (removed.code !== 0) throw commandError("git worktree remove", removed);
params.commitGuard?.();
const branchDelete = await runGit(record.repoRoot, [
"branch",
"-D",
record.branch
]);
if (branchDelete.code !== 0) throw commandError("git branch -D", branchDelete);
await requireGit(record.repoRoot, ["worktree", "prune"]);
await fs$1.rmdir(path.dirname(record.path)).catch(() => void 0);
params.commitGuard?.();
const removedAt = this.now();
updateRegistryWorktree(this.env, record.id, {
removedAt,
snapshotRef,
...params.runEndCleanup ? { runEndCleanup: params.runEndCleanup } : {}
});
finalizeWorktreeRemoval(this.env, record.id);
return {
removed: true,
...snapshotRef ? { snapshotRef } : {},
...snapshotError ? { snapshotError } : {}
};
} catch (error) {
abortWorktreeRemoval(this.env, record.id, claimToken);
throw error;
}
}
async restore(params) {
return await this.withAllocationLease(params, async (guard) => await this.restoreWithAllocation({
...params,
...guard
}));
}
async restoreWithAllocation(params) {
params.signal?.throwIfAborted();
params.commitGuard?.();
const record = getRegistryWorktree(this.env, params.id);
if (!record?.snapshotRef || record.removedAt === void 0) throw new Error(`worktree ${params.id} is not restorable`);
if (!await worktreePathExists(record.repoRoot)) throw new Error(`source repository no longer exists: ${record.repoRoot}`);
const repository = await resolveRepository(record.repoRoot);
this.requireAllocationSpace(record.path, repository);
const provisionedState = getRegistryWorktreeProvisionedState(this.env, record.id);
if (provisionedState === void 0) throw new Error(`worktree ${record.id} snapshot lacks provisioned file metadata`);
const provisionedBytes = provisionedState.reduce((sum, entry) => sum + entry.chunks * SNAPSHOT_CHUNK_BYTES, 0);
const gitBytes = await estimateWorktreeGitBytes(record.repoRoot, record.snapshotRef);
this.requireAllocationSpace(record.path, repository, 2 * (gitBytes + provisionedBytes));
const parent = await requireGit(record.repoRoot, ["rev-parse", `${record.snapshotRef}^`]);
params.commitGuard?.();
await fs$1.mkdir(path.dirname(record.path), { recursive: true });
params.commitGuard?.();
await requireGit(record.repoRoot, [
"worktree",
"add",
"--detach",
record.path,
record.snapshotRef
], {
timeoutMs: WORKTREE_CHECKOUT_TIMEOUT_MS,
signal: params.signal
});
let branchCreated = false;
let restoredProvisionedPaths;
try {
params.commitGuard?.();
await requireGit(record.repoRoot, [
"branch",
record.branch,
parent
]);
branchCreated = true;
params.commitGuard?.();
await requireGit(record.path, [
"symbolic-ref",
"HEAD",
`refs/heads/${record.branch}`
]);
params.commitGuard?.();
await requireGit(record.path, ["reset"]);
params.commitGuard?.();
this.requireAllocationSpace(record.path, repository, 2 * provisionedBytes);
await restoreProvisionedFiles(this.env, record.id, record.path, provisionedState);
params.commitGuard?.();
this.requireAllocationSpace(record.path, repository);
restoredProvisionedPaths = provisionedState.map((state) => state.path);
} catch (error) {
const removed = await runGit(record.repoRoot, [
"worktree",
"remove",
"--force",
record.path
]);
const branchDeleted = branchCreated ? await runGit(record.repoRoot, [
"branch",
"-D",
record.branch
]) : void 0;
if (removed.code !== 0 || branchDeleted && branchDeleted.code !== 0) {
const failure = branchDeleted && removed.code === 0 ? commandError("git branch -D", branchDeleted) : commandError("git worktree remove", removed);
throw new Error(`${String(error)}\nrestore cleanup failed: ${failure.message}`, { cause: error });
}
throw error;
}
const lastActiveAt = Math.max(this.now(), record.lastActiveAt + 1);
updateRegistryWorktree(this.env, params.id, {
removedAt: void 0,
lastActiveAt,
provisionedPaths: restoredProvisionedPaths,
runEndCleanup: void 0
});
finalizeWorktreeRemoval(this.env, params.id);
const restored = {
...record,
lastActiveAt
};
delete restored.removedAt;
delete restored.runEndCleanup;
return restored;
}
async removeIfLossless(id) {
let record = this.requireLiveRecord(id);
const claimToken = randomUUID();
const recordOutcome = (outcome, error) => {
updateRegistryWorktree(this.env, id, { runEndCleanup: {
outcome,
at: this.now(),
...outcome === "failed" ? { reason: truncateUtf16Safe(formatErrorMessage(error), 500) } : {}
} }, {
onlyIfLive: true,
onlyIfActiveAt: record.lastActiveAt
});
};
try {
claimWorktreeRemoval(this.env, {
worktreeId: id,
token: claimToken
});
} catch (error) {
if (error instanceof WorktreeRemovalContentionError) {
if (error.kind === "finalized") return false;
recordOutcome("retained-busy");
return false;
}
try {
recordOutcome("failed", error);
} catch {}
throw error;
}
try {
record = await this.rebindLiveRepository(record);
const status = await requireGit(record.path, ["status", "--porcelain"]);
const unpushed = await requireGit(record.path, [
"log",
"HEAD",
"--not",
"--remotes",
"--oneline"
]);
const ignoredDrift = await hasUnsnapshotableProvisionedFiles(record.path, getRegistryWorktreeProvisionedPaths(this.env, record.id));
const retainedOutcome = status ? "retained-dirty" : unpushed ? "retained-unpushed" : ignoredDrift ? "retained-provisioned-drift" : await containsSnapshotGitMarker(record.path) ? "retained-dirty" : void 0;
if (retainedOutcome) {
abortWorktreeRemoval(this.env, id, claimToken);
recordOutcome(retainedOutcome);
return false;
}
} catch (error) {
abortWorktreeRemoval(this.env, id, claimToken);
recordOutcome("failed", error);
throw error;
}
try {
await this.release(id);
await this.remove({
id,
reason: "run-end",
claimToken,
runEndCleanup: {
outcome: "removed-lossless",
at: this.now()
}
});
} catch (error) {
abortWorktreeRemoval(this.env, id, claimToken);
recordOutcome("failed", error);
throw error;
}
return true;
}
async removeIfLosslessByPath(worktreePath, owner) {
const record = findLiveRegistryWorktreeByPath(this.env, worktreePath);
if (!record || !worktreeOwnerMatches(record, owner)) return false;
return await this.removeIfLossless(record.id);
}
async releaseByPath(worktreePath) {
const record = findLiveRegistryWorktreeByPath(this.env, worktreePath);
if (record) await this.release(record.id);
}
async gc(params = {}) {
const now = this.now();
let removed = [];
const records = listRegistryWorktrees(this.env);
for (const record of records) try {
if (record.removedAt === void 0 && !await worktreePathExists(record.path)) {
updateRegistryWorktree(this.env, record.id, { removedAt: now });
record.removedAt = now;
}
const expiresWhenIdle = record.ownerKind === "workboard" || record.ownerKind === "session";
const retiredOwner = record.ownerId !== void 0 && params.shouldRemoveOwner?.(record.ownerKind, record.ownerId) === true;
if (record.removedAt === void 0 && expiresWhenIdle && (retiredOwner || now - record.lastActiveAt > 6048e5)) {
if (await this.isProtectedFromAutoRemoval(record, params.shouldProtectOwner)) continue;
await this.remove({
id: record.id,
reason: retiredOwner ? "owner-gc" : "idle-gc",
commitGuard: () => this.assertOwnerAllowsCleanup(record, params, retiredOwner)
});
removed.push(record.id);
}
} catch (error) {
log.warn(`idle cleanup failed for ${record.id}: ${String(error)}`);
}
removed = removed.concat(await this.enforceCleanupLimits(params));
const orphansDeleted = await this.reconcileOrphans(records);
let snapshotsPruned = 0;
for (const record of listRegistryWorktrees(this.env)) {
if (record.removedAt === void 0 || now - record.removedAt <= 2592e6) continue;
try {
if (record.snapshotRef && await worktreePathExists(record.repoRoot)) await requireGit(record.repoRoot, [
"update-ref",
"-d",
record.snapshotRef
]);
deleteRegistryWorktree(this.env, record.id);
snapshotsPruned += 1;
} catch (error) {
log.warn(`snapshot retention failed for ${record.id}: ${String(error)}`);
}
}
return {
removed,
orphansDeleted,
snapshotsPruned
};
}
/**
* Shared auto-removal guard: owners, leases, nested repositories, and live or
* foreign Git locks veto removal; a dead lock is cleared.
*/
async isProtectedFromAutoRemoval(record, shouldProtectOwner) {
if (record.ownerId !== void 0 && shouldProtectOwner?.(record.ownerKind, record.ownerId) === true) return true;
if (hasLiveWorktreeRunLease(this.env, record.id)) return true;
if (await hasUnsnapshotableProvisionedFiles(record.path, getRegistryWorktreeProvisionedPaths(this.env, record.id))) return true;
const state = await lockState(record);
if (state.kind === "live" || state.kind === "foreign") return true;
if (state.kind === "dead") await requireGit(record.repoRoot, [
"worktree",
"unlock",
record.path
]);
return await containsSnapshotGitMarker(record.path);
}
/**
* Enforces optional count/size retention across all live managed worktrees.
* Manual worktrees count toward the totals but are never limit-evicted, so a
* limit can stay exceeded when only protected worktrees remain.
*/
async enforceCleanupLimits(params) {
const limits = params.limits ?? resolveWorktreeCleanupLimits();
if (limits.maxCount === void 0 && limits.maxTotalSizeBytes === void 0) return [];
const live = listRegistryWorktrees(this.env).filter((record) => record.removedAt === void 0);
const sizes = /* @__PURE__ */ new Map();
let totalBytes = 0;
if (limits.maxTotalSizeBytes !== void 0) for (const record of live) try {
const bytes = await directorySizeBytes(record.path);
sizes.set(record.id, bytes);
totalBytes += bytes;
} catch (error) {
log.warn(`worktree size measurement failed for ${record.id}: ${String(error)}`);
}
let liveCount = live.length;
const overLimit = () => limits.maxCount !== void 0 && liveCount > limits.maxCount || limits.maxTotalSizeBytes !== void 0 && totalBytes > limits.maxTotalSizeBytes;
if (!overLimit()) return [];
const refreshTotals = () => {
const liveIds = new Set(listRegistryWorktrees(this.env).filter((record) => record.removedAt === void 0).map((record) => record.id));
liveCount = liveIds.size;
if (limits.maxTotalSizeBytes !== void 0) {
totalBytes = 0;
for (const [id, bytes] of sizes) if (liveIds.has(id)) totalBytes += bytes;
}
return liveIds;
};
const removed = [];
const candidates = live.filter((record) => record.ownerKind === "workboard" || record.ownerKind === "session").toSorted((a, b) => a.lastActiveAt - b.lastActiveAt);
for (const record of candidates) {
const liveIds = refreshTotals();
if (!overLimit()) break;
if (!liveIds.has(record.id)) continue;
try {
if (await this.isProtectedFromAutoRemoval(record, params.shouldProtectOwner)) continue;
await this.remove({
id: record.id,
reason: "limit-gc",
commitGuard: () => this.assertOwnerAllowsCleanup(record, params)
});
} catch (error) {
log.warn(`cleanup limit removal failed for ${record.id}: ${String(error)}`);
continue;
}
removed.push(record.id);
}
refreshTotals();
if (overLimit()) log.warn(`worktree cleanup limits still exceeded after evicting ${removed.length}; remaining worktrees are protected or manual`);
return removed;
}
assertOwnerAllowsCleanup(record, params, retiredOwner = false) {
if (record.ownerId !== void 0 && (params.shouldProtectOwner?.(record.ownerKind, record.ownerId) === true || retiredOwner && params.shouldRemoveOwner?.(record.ownerKind, record.ownerId) !== true)) throw new WorktreeRemovalLockError("busy", "worktree owner became active during cleanup");
}
requireLiveRecord(id) {
const record = getRegistryWorktree(this.env, id);
if (!record || record.removedAt !== void 0) throw new Error(`unknown active worktree: ${id}`);
return record;
}
async rebindLiveRepository(record, guard = {}) {
const worktreePath = await fs$1.realpath(record.path);
const repository = await resolveRepositoryFromRealPath(worktreePath, record.path);
if (repository.sourceRoot !== worktreePath) throw new WorktreeRepositoryError(`repository does not own worktree: ${record.path}`);
if ((await resolveRepository(record.repoRoot)).originUrl !== repository.originUrl) throw new WorktreeRepositoryError(`repository origin does not match: ${record.path}`);
guard.signal?.throwIfAborted();
guard.commitGuard?.();
updateRegistryWorktree(this.env, record.id, { repositoryIdentity: {
repoRoot: repository.repoRoot,
repoFingerprint: repository.fingerprint
} });
return {
...record,
repoRoot: repository.repoRoot,
repoFingerprint: repository.fingerprint
};
}
async reconcileOrphans(records) {
const managedPaths = /* @__PURE__ */ new Set();
for (const record of records) try {
managedPaths.add(await canonicalPathKey(record.path));
} catch (error) {
if (!isMissingPathError(error)) throw error;
}
const worktreesRoot = path.join(resolveStateDir(this.env), "worktrees");
const fingerprints = await fs$1.readdir(worktreesRoot, { withFileTypes: true }).catch(() => []);
if (fingerprints.length === 0) return 0;
const defaultRoot = await canonicalPathKey(worktreesRoot);
const customRoots = /* @__PURE__ */ new Set();
for (const root of [this.getConfig?.().worktreeRoot, ...records.map((record) => path.dirname(path.dirname(record.path)))]) {
if (!root) continue;
try {
const canonical = await canonicalPathKey(root);
if (canonical !== defaultRoot) customRoots.add(canonical);
} catch (error) {
if (!isMissingPathError(error)) throw error;
}
}
let deleted = 0;
for (const fingerprint of fingerprints) {
if (!fingerprint.isDirectory()) continue;
const fingerprintPath = path.join(worktreesRoot, fingerprint.name);
if (await shouldPreserveOrphanCandidate(fingerprintPath, managedPaths, customRoots)) continue;
const names = await fs$1.readdir(fingerprintPath, { withFileTypes: true }).catch(() => []);
for (const name of names) {
if (!name.isDirectory()) continue;
const candidate = path.join(fingerprintPath, name.name);
if (await shouldPreserveOrphanCandidate(candidate, managedPaths, customRoots)) continue;
await fs$1.rm(candidate, {
recursive: true,
force: true
});
deleted += 1;
}
await fs$1.rmdir(fingerprintPath).catch(() => void 0);
}
return deleted;
}
};
const managedWorktrees = new ManagedWorktreeService({ getConfig: getRuntimeConfig });
//#endregion
export { WorktreeRemovalLockError as a, classifyWorktreeRemovalError as c, WORKTREE_GC_INTERVAL_MS as i, managedWorktrees as l, ManagedWorktreeService as n, WorktreeRepositoryError as o, SNAPSHOT_RETENTION_MS as r, WorktreeSnapshotError as s, IDLE_GC_MS as t, resolveWorktreeCleanupLimits as u };