feat(worktrees): select repository-defined source checkouts (#149581)

* fix(worktrees): preserve caller-owned branches during allocation

* feat(worktrees): select repository-defined source profiles

* fix(worktrees): preserve caller Git policy in capacity worker

* style(git-worker): match pinned formatter callback layout

* fix(worktrees): correct profile CLI and regression setup

* fix(worktrees): normalize checkout options without parameter reassignment

* fix(worktrees): satisfy profile lint and split isolation fixtures

* fix(worktrees): avoid spreading profile path strings
This commit is contained in:
Jason (Json) 2026-09-15 23:37:31 -06:00 • committed by GitHub
parent fbf926add8
commit 5f3ca5a855
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 1766 additions and 93 deletions

View file

@ -0,0 +1,23 @@
.agents
.claude
.github
.openclaw/worktree-profiles
.vscode
CHANGELOG
apps/shared/OpenClawKit/Sources/OpenClawKit/Resources
config
custodian-skills
deploy
docs
examples
extensions
git-hooks
packages
patches
qa
scripts
security
skills
src
test
ui

View file

@ -62,6 +62,57 @@ The fast path is limited to OpenClaw's private source-only templates; do not cus
ReFS cloning can take longer than native Git checkout for repositories with many small files because each file needs independent metadata and Git refreshes its index. Use `worktreeAcceleration: false` if checkout latency matters more than source storage savings.
## Repository source profiles
Full source remains the default. To select a repository-owned sparse source
profile when creating a **new** worktree:
```bash
openclaw worktrees create /path/to/repo --name gateway-task --source-profile gateway
openclaw worktrees create /path/to/repo --name combined-task --source-profile gateway --source-profile tooling
```
Track plain UTF-8 cone-directory lists in
`.openclaw/worktree-profiles/<name>`. Names use lowercase letters, digits and
hyphens (up to 64 characters, starting with a letter or digit). Each nonempty
line names a tracked repository-relative directory; do not use comments, globs,
absolute paths or parent traversal. Selected lists compose as a sorted union.
Git cone mode also retains files at the root and ancestor directories. OpenClaw
includes the definition directory so the selection remains inspectable.
Definitions are read from the immutable checkout commit, not uncommitted source
files. A default-remote retry loads the definitions again from its fallback
commit. Selection finishes before ignored-file provisioning and setup. It does
not request a dependency install or build, and an existing repository setup
script retains its independent policy. Profiles cannot reshrink reused or
restored worktrees; choose a new name.
The existing `--profile` option still selects runtime state; `--source-profile`
only selects repository source:
```bash
openclaw --profile work worktrees create /path/to/repo --name task --source-profile gateway
```
Expand intentionally before native work or whole-repository checks:
```bash
git -C /path/to/worktree sparse-checkout disable
```
If sparse materialization fails after registration, keep the partial checkout
and Git registration for recovery. A retry with the same name does not shrink
that partial state; inspect it before choosing a new worktree name.
Selected profiles currently use ordinary Git checkout. Git enables shared
per-worktree configuration when setting sparse rules, so subsequent full
checkouts of that repository also use Git fallback, including after a native
full expansion. Existing checkouts retain their own source and indexes.
Then run the separately requested dependency/build preparation. PR
whole-repository gates still require full source. Sparse checkout changes source
materialization, not shared Git objects or history; it is not shallow cloning.
## Layout and names
Each worktree lives at:
@ -210,7 +261,7 @@ A branch at a shallow history boundary can still be snapshotted and restored. If
```bash
openclaw worktrees list [--json]
openclaw worktrees create <repo-root> [--name <name>] [--base-ref <ref>] [--json]
openclaw worktrees create <repo-root> [--name <name>] [--base-ref <ref>] [--source-profile <name>]... [--json]
openclaw worktrees remove <id> [--force | --if-lossless] [--json]
openclaw worktrees restore <id> [--json]
openclaw worktrees gc [--json]

View file

@ -0,0 +1,39 @@
import { withOpenClawStateLease } from "../../state/openclaw-state-lease.js";
import type { WorktreeFilesystemOptions } from "./filesystem-backend.types.js";
const WORKTREE_CREATE_LEASE_SCOPE = "core:managed-worktrees:create";
const WORKTREE_CREATE_LEASE_MS = 60_000;
const WORKTREE_CREATE_LEASE_WAIT_MS = 10 * 60_000;
/** Hold the existing shared capacity lease for managed or caller-owned creation. */
export async function withWorktreeAllocationLease<T>(
params: {
env: NodeJS.ProcessEnv;
signal?: AbortSignal;
commitGuard?: () => void;
},
run: (guard: WorktreeFilesystemOptions) => Promise<T>,
): Promise<T> {
// Disk headroom is shared across repositories. Hold one renewable lease
// through checkout, setup, snapshots, and publication, including CLI processes.
return await withOpenClawStateLease(
{
scope: WORKTREE_CREATE_LEASE_SCOPE,
key: "capacity",
database: { scope: "shared", options: { env: params.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?.();
},
}),
);
}

View file

@ -72,6 +72,36 @@ describe("worktree Git size estimates", () => {
return { root, source, origin, clone, commit, missing };
}
it("preserves admitted caller ownership config through text and buffered sizing without changing defaults", async () => {
const root = tempDirs.make("openclaw-capacity-caller-git-");
const repo = path.join(root, "repo");
await git(root, "init", "--template=", "-b", "main", repo);
await git(repo, "config", "user.name", "OpenClaw Test");
await git(repo, "config", "user.email", "openclaw-test@example.invalid");
await git(repo, "config", "commit.gpgSign", "false");
await fs.writeFile(path.join(repo, "README.md"), "capacity\n");
await git(repo, "add", "README.md");
await git(repo, "commit", "-m", "initial");
vi.stubEnv("GIT_TEST_ASSUME_DIFFERENT_OWNER", "1");
vi.stubEnv("GIT_CONFIG_NOSYSTEM", "1");
vi.stubEnv("GIT_CONFIG_GLOBAL", gitExec.gitNullConfigPath());
vi.stubEnv("GIT_CONFIG_COUNT", "1");
vi.stubEnv("GIT_CONFIG_KEY_0", "safe.directory");
vi.stubEnv("GIT_CONFIG_VALUE_0", await fs.realpath(repo));
// Managed Git intentionally replaces caller config with its hook/fsmonitor policy.
await expect(estimateWorktreeGitBytes(repo, "HEAD")).rejects.toThrow("dubious ownership");
const pending = estimateWorktreeGitBytes(repo, "HEAD", {
git: {
text: gitExec.executeGitCommandBytes,
buffered: gitExec.executeGitCommandBuffered,
},
});
// Later caller changes must not replace the environment captured at admission.
vi.stubEnv("GIT_CONFIG_VALUE_0", path.join(root, "not-the-repository"));
await expect(pending).resolves.toBe(4096);
await expect(estimateWorktreeGitBytes(repo, "HEAD")).rejects.toThrow("dubious ownership");
});
it.each(["remote promisor", "partialclone extension"])(
"prefetches missing blobs once from the %s and skips fetching local objects",
async (remoteConfig) => {

View file

@ -54,7 +54,7 @@ export function requireWorktreeDiskSpace(
export async function estimateWorktreeGitBytes(
repoRoot: string,
ref: string,
options: Pick<GitWorkerOperationOptions, "signal" | "assertCurrent"> = {},
options: Pick<GitWorkerOperationOptions, "signal" | "assertCurrent" | "git"> = {},
): Promise<number> {
return await runGitWorkerOperation(
{

View file

@ -0,0 +1,109 @@
import type { WorktreeFilesystemOptions } from "./filesystem-backend.types.js";
import { requireGit, requireGitBuffer } from "./git.js";
const PROFILE_DIRECTORY = ".openclaw/worktree-profiles";
const PROFILE_NAME = /^[a-z0-9][a-z0-9-]{0,63}$/u;
const PROFILE_MAX_BYTES = 64 * 1024;
export type WorktreeSourceProfile = {
commit: string;
directories: readonly string[];
};
/** Resolve repository-owned cone lists without consulting mutable checkout files. */
export async function resolveWorktreeSourceProfile(
repoRoot: string,
base: string,
names: readonly string[],
options: WorktreeFilesystemOptions,
): Promise<WorktreeSourceProfile> {
if (
names.length === 0 ||
names.some((name) => typeof name !== "string" || !PROFILE_NAME.test(name))
) {
throw new Error(
"Select a worktree profile using a lowercase name of up to 64 letters, digits, or hyphens.",
);
}
const assertOwned = () => {
options.signal?.throwIfAborted();
options.commitGuard();
};
assertOwned();
const gitOptions = {
signal: options.signal,
beforeRun: assertOwned,
killProcessTree: true,
};
const commit = await requireGit(
repoRoot,
["rev-parse", "--verify", "--end-of-options", `${base}^{commit}`],
gitOptions,
);
const directories = new Set([PROFILE_DIRECTORY]);
for (const name of [...new Set(names)].toSorted()) {
const definition = `${PROFILE_DIRECTORY}/${name}`;
const entry = await requireGit(
repoRoot,
["ls-tree", "-z", commit, "--", `:(literal)${definition}`],
{ ...gitOptions, maxOutputBytes: 4096, terminateOnOutputLimit: true },
);
const match = /^(?:100644|100755) blob ([a-f0-9]+)\t[^\0]+\0$/u.exec(entry);
if (!match) {
throw new Error(
`Worktree profile ${definition} must be a tracked regular file at ${commit}.`,
);
}
const contents = await requireGitBuffer(repoRoot, ["cat-file", "blob", match[1]!], {
...gitOptions,
maxOutputBytes: PROFILE_MAX_BYTES,
});
const text = new TextDecoder("utf-8", { fatal: true }).decode(contents);
for (const directory of text.split(/\r?\n/u)) {
if (!directory) {
continue;
}
const invalidComponent = directory
.split("/")
.some(
(part) =>
!part ||
part !== part.trim() ||
part === "." ||
part === ".." ||
part.toLowerCase() === ".git",
);
// These are literal cone directories, not patterns, commands or C-quoted paths.
let hasControlCharacter = false;
for (let index = 0; index < directory.length; index += 1) {
const code = directory.charCodeAt(index);
if (code < 0x20 || code === 0x7f) {
hasControlCharacter = true;
break;
}
}
if (invalidComponent || hasControlCharacter || /[\\:*?[\]!"<>|]/u.test(directory)) {
throw new Error(
`Worktree profile ${definition} contains an invalid cone directory: ${JSON.stringify(directory)}.`,
);
}
directories.add(directory);
}
}
for (const directory of directories) {
// Query only the selected entry. A truncated whole-tree inventory must not
// silently validate a prefix, and a symlink/submodule is not a cone tree.
const entry = await requireGitBuffer(
repoRoot,
["ls-tree", "-d", "-z", commit, "--", `:(literal)${directory}`],
{ ...gitOptions, maxOutputBytes: PROFILE_MAX_BYTES },
);
if (!/^040000 tree [a-f0-9]+\t[^\0]+\0$/u.test(entry.toString("utf8"))) {
throw new Error(
`Worktree profile directory ${directory} is not a tracked directory at ${commit}.`,
);
}
}
assertOwned();
return { commit, directories: [...directories].toSorted() };
}

View file

@ -4,6 +4,7 @@ import fs from "node:fs/promises";
import path from "node:path";
import { normalizeGitPathForFilesystem } from "../../infra/git-exec.js";
import { createSubsystemLogger } from "../../logging/subsystem.js";
import type { WorktreeSourceProfile } from "./checkout-profiles.js";
import { detectWorktreeFilesystemBackend } from "./filesystem-backend.js";
import type { WorktreeFilesystemOptions } from "./filesystem-backend.types.js";
import {
@ -27,7 +28,9 @@ import {
const log = createSubsystemLogger("agents/worktrees");
export const WORKTREE_TEMPLATE_DIRECTORY = ".templates";
type CheckoutOptions = WorktreeFilesystemOptions & {
type WorktreeCheckoutBranch = { mode: "create"; name: string } | { mode: "existing"; name: string };
export type CheckoutOptions = WorktreeFilesystemOptions & {
env: NodeJS.ProcessEnv;
now: () => number;
enabled: boolean;
@ -36,13 +39,14 @@ type CheckoutOptions = WorktreeFilesystemOptions & {
worktreeRoot: string;
destination: string;
base: string;
branch?: string;
branch?: WorktreeCheckoutBranch;
sourceProfile?: WorktreeSourceProfile;
/** Restore reuses a warm template, or materializes its snapshot after registration. */
deferGitCheckout?: boolean;
requireSpace: (cloneBytes?: number) => void;
};
type CheckoutResult = GitResult & { templateCloned?: true };
export type CheckoutResult = GitResult & { templateCloned?: true };
function assertOwned(options: WorktreeFilesystemOptions) {
options.signal?.throwIfAborted();
@ -301,10 +305,86 @@ async function prepareTemplate(options: CheckoutOptions) {
}
/** Git owns registration, branches and indexes; the backend only materializes files. */
export async function addManagedWorktree(options: CheckoutOptions): Promise<CheckoutResult> {
export async function addManagedWorktree(inputOptions: CheckoutOptions): Promise<CheckoutResult> {
let options = inputOptions;
const branch = options.branch;
const existingRef = branch?.mode === "existing" ? `refs/heads/${branch.name}` : undefined;
// A caller-owned branch is an immutable seed, not permission to create/reset it.
// Validate before cache allocation; repeat at registration and return boundaries.
const existingCommit = existingRef
? await requireGit(
options.repoRoot,
["rev-parse", "--verify", `${options.base}^{commit}`],
gitOptions(options),
)
: undefined;
const assertExistingSeed = async (stage: "before" | "registered" = "before") => {
if (!existingRef) {
return;
}
const actual = await requireGit(
options.repoRoot,
["rev-parse", "--verify", existingRef],
gitOptions(options),
);
if (actual !== existingCommit) {
throw new Error(
"Caller-owned worktree branch moved; preserve it and retry with its current commit.",
);
}
if (stage === "registered") {
const head = await requireGit(
options.destination,
["rev-parse", "HEAD"],
gitOptions(options),
);
const ref = await requireGit(
options.destination,
["symbolic-ref", "HEAD"],
gitOptions(options),
);
if (head !== existingCommit || ref !== existingRef) {
throw new Error("Caller-owned worktree HEAD changed; preserve the checkout for recovery.");
}
} else if (stage === "before") {
const worktrees = await requireGit(
options.repoRoot,
["worktree", "list", "--porcelain", "-z"],
gitOptions(options),
);
if (worktrees.split("\0").includes(`branch ${existingRef}`)) {
throw new Error(
"Caller-owned branch is already checked out; preserve its existing worktree.",
);
}
}
};
if (existingRef) {
await requireGit(options.repoRoot, ["check-ref-format", existingRef], gitOptions(options));
await assertExistingSeed();
// Pin acceleration and all materialization to the same verified seed.
options = { ...options, base: existingCommit! };
}
const profile = options.sourceProfile;
if (profile) {
if (options.deferGitCheckout || (await worktreePathExists(options.destination))) {
throw new Error(
"Source profiles require a fresh destination; preserve existing work and choose a new path.",
);
}
const commit = await requireGit(
options.repoRoot,
["rev-parse", "--verify", `${options.base}^{commit}`],
gitOptions(options),
);
if (commit !== profile.commit) {
throw new Error("Worktree source profile does not match the checkout commit.");
}
}
let template: Awaited<ReturnType<typeof prepareTemplate>>;
let cloneBytes: number | undefined;
if (options.enabled) {
// Sparse templates are unsupported; retain the full-checkout cache guards.
if (options.enabled && !profile) {
try {
template = await prepareTemplate(options);
cloneBytes = template ? await estimateTemplateCloneBytes(template) : undefined;
@ -327,16 +407,17 @@ export async function addManagedWorktree(options: CheckoutOptions): Promise<Chec
template = undefined;
cloneBytes = undefined;
}
await assertExistingSeed();
const added = await runGit(
options.repoRoot,
[
"worktree",
"add",
...(template || options.deferGitCheckout ? ["--no-checkout"] : []),
...(options.branch ? ["-b", options.branch] : ["--detach"]),
...(template || profile || options.deferGitCheckout ? ["--no-checkout"] : []),
...(branch?.mode === "create" ? ["-b", branch.name] : branch ? [] : ["--detach"]),
"--",
options.destination,
options.base,
branch?.mode === "existing" ? branch.name : options.base,
],
{
...gitOptions(options),
@ -347,7 +428,56 @@ export async function addManagedWorktree(options: CheckoutOptions): Promise<Chec
timeoutMs: WORKTREE_CHECKOUT_TIMEOUT_MS,
},
);
if (added.code !== 0 || !template) {
if (added.code !== 0) {
return added;
}
// A raced seed is retained, never reset or deleted to repair the postcondition.
await assertExistingSeed("registered");
if (profile) {
// Only this attempt's fresh, unprovisioned registration can be narrowed.
// Reuse, restoration and partial preparation never enter this path.
assertOwned(options);
const entries = await fs.readdir(options.destination);
if (entries.length !== 1 || entries[0] !== ".git") {
throw new Error(
"Source profile target is no longer unprepared; preserve it and choose a new path.",
);
}
const head = await requireGit(options.destination, ["rev-parse", "HEAD"], gitOptions(options));
if (head !== profile.commit) {
throw new Error(
"Worktree source commit changed before sparse materialization; preserve it for recovery.",
);
}
// Do not roll back a failed sparse materialization: it may already contain
// partial state. A retry must not mistake that target for a fresh checkout.
await requireGit(
options.destination,
["sparse-checkout", "set", "--cone", "--no-sparse-index", "--stdin"],
{
...gitOptions(options),
input: `${profile.directories.join("\n")}\n`,
beforeRun: () => {
assertOwned(options);
options.requireSpace();
},
timeoutMs: WORKTREE_CHECKOUT_TIMEOUT_MS,
},
);
// --no-checkout starts without an index. Git materializes the pinned source
// without moving HEAD, before any provisioning or repository setup.
await requireGit(options.destination, ["read-tree", "--reset", "-u", profile.commit], {
...gitOptions(options),
beforeRun: () => {
assertOwned(options);
options.requireSpace();
},
timeoutMs: WORKTREE_CHECKOUT_TIMEOUT_MS,
});
await assertExistingSeed("registered");
return added;
}
if (!template) {
return added;
}
const markerPath = path.join(options.destination, ".git");
@ -405,11 +535,19 @@ export async function addManagedWorktree(options: CheckoutOptions): Promise<Chec
...gitOptions(options),
timeoutMs: WORKTREE_CHECKOUT_TIMEOUT_MS,
});
return { ...added, templateCloned: true };
} catch (error) {
// A stale allocator cannot roll back a checkout after lease takeover.
// Preserve Git's registration for recovery if authority was revoked.
assertOwned(options);
// The shared allocation lease does not exclude caller Git operations. A
// check before destructive fallback cannot protect edits made during it.
// Keep the original registration and partial files for caller-owned recovery.
if (existingRef) {
throw new Error(
"Caller-owned worktree clone failed; preserve its registration and partial checkout for recovery.",
{ cause: error },
);
}
if (marker) {
await fs.rm(options.destination, { recursive: true, force: true });
assertOwned(options);
@ -442,6 +580,9 @@ export async function addManagedWorktree(options: CheckoutOptions): Promise<Chec
}
return checkout.code === 0 ? added : checkout;
}
// Caller-state postconditions never enter clone cleanup or reset-and-retry.
await assertExistingSeed("registered");
return { ...added, templateCloned: true };
}
async function removeFailedCheckout(options: CheckoutOptions): Promise<void> {
@ -451,8 +592,8 @@ async function removeFailedCheckout(options: CheckoutOptions): Promise<void> {
["worktree", "remove", "--force", options.destination],
gitOptions(options),
);
if (options.branch) {
if (options.branch?.mode === "create") {
assertOwned(options);
await requireGit(options.repoRoot, ["branch", "-D", options.branch], gitOptions(options));
await requireGit(options.repoRoot, ["branch", "-D", options.branch.name], gitOptions(options));
}
}

View file

@ -0,0 +1,18 @@
import fs from "node:fs/promises";
import { vi } from "vitest";
import type { WorktreeFilesystemBackend } from "./filesystem-backend.types.js";
export function createCopyWorktreeBackend(): WorktreeFilesystemBackend {
return {
id: "btrfs",
estimateCloneBytes: (_entries, indexBytes) => 16 * 1024 ** 2 + 2 * indexBytes,
createTemplate: vi.fn(async (destination, options) => {
options.commitGuard();
await fs.mkdir(destination);
}),
cloneTemplate: vi.fn(async (source, destination, options) => {
options.commitGuard();
await fs.cp(source, destination, { recursive: true, verbatimSymlinks: true });
}),
};
}

View file

@ -14,7 +14,10 @@ import {
openOpenClawStateDatabase,
} from "../../state/openclaw-state-db.js";
import * as stateLease from "../../state/openclaw-state-lease.js";
import { withWorktreeAllocationLease } from "./allocation.js";
import { addManagedWorktree, type CheckoutOptions } from "./checkout.js";
import { detectWorktreeFilesystemBackend } from "./filesystem-backend.js";
import { createCopyWorktreeBackend } from "./filesystem-backend.test-support.js";
import type { WorktreeFilesystemBackend } from "./filesystem-backend.types.js";
import { IDLE_GC_MS, ManagedWorktreeService, SNAPSHOT_RETENTION_MS } from "./service.js";
import { useManagedWorktreeTestRepository } from "./service.test-support.js";
@ -59,18 +62,7 @@ describe("ManagedWorktreeService filesystem acceleration", () => {
acceleration = undefined;
// Real Git and the production lifecycle run on every host; only native
// subvolume operations are replaced with independent directory copies.
backend = {
id: "btrfs",
estimateCloneBytes: (_entries, indexBytes) => 16 * 1024 ** 2 + 2 * indexBytes,
createTemplate: vi.fn(async (destination, options) => {
options.commitGuard();
await fs.mkdir(destination);
}),
cloneTemplate: vi.fn(async (source, destination, options) => {
options.commitGuard();
await fs.cp(source, destination, { recursive: true, verbatimSymlinks: true });
}),
};
backend = createCopyWorktreeBackend();
vi.mocked(detectWorktreeFilesystemBackend).mockReset().mockResolvedValue(backend);
service = new ManagedWorktreeService({
env,
@ -79,6 +71,288 @@ describe("ManagedWorktreeService filesystem acceleration", () => {
});
});
// PR callers own the ref and lifetime; exercise the exported seam without
// adopting their checkouts into the managed lifecycle registry.
async function externalCheckout(
name: string,
branch: CheckoutOptions["branch"],
base: string,
commitGuard: () => void = () => undefined,
) {
const root = path.join(env.OPENCLAW_STATE_DIR!, "external");
await fs.mkdir(root, { recursive: true });
const destination = path.join(root, name);
const commonDir = await git(repo, "rev-parse", "--path-format=absolute", "--git-common-dir");
const result = await withWorktreeAllocationLease({ env, commitGuard }, async (guard) =>
addManagedWorktree({
...guard,
env,
now: () => now,
enabled: acceleration !== false,
repoRoot: repo,
commonDir,
worktreeRoot: root,
destination,
base,
branch,
requireSpace: () => guard.commitGuard(),
}),
);
return { result, destination };
}
it.each([true, false])(
"keeps existing, new and detached branch intent with acceleration=%s",
async (enabled) => {
acceleration = enabled;
const seed = await git(repo, "rev-parse", "HEAD");
await git(repo, "branch", "temp/pr-fixture", seed);
const outputs = [];
for (const [name, branch] of [
["existing", { mode: "existing", name: "temp/pr-fixture" }],
["created", { mode: "create", name: "openclaw/new-fixture" }],
["detached", undefined],
] as const) {
const output = await externalCheckout(name, branch, seed);
expect(output.result.code).toBe(0);
expect(output.result.templateCloned === true).toBe(enabled);
expect(await git(output.destination, "rev-parse", "HEAD")).toBe(seed);
expect(await git(output.destination, "status", "--porcelain")).toBe("");
if (branch) {
expect(await git(output.destination, "symbolic-ref", "HEAD")).toBe(
`refs/heads/${branch.name}`,
);
} else {
await expect(git(output.destination, "symbolic-ref", "HEAD")).rejects.toThrow();
}
outputs.push(output);
}
expect(await git(repo, "rev-parse", "refs/heads/temp/pr-fixture")).toBe(seed);
expect(service.listRegistryRecords()).toEqual([]);
const indexes = await Promise.all(
outputs.map(({ destination }) =>
git(destination, "rev-parse", "--path-format=absolute", "--git-path", "index"),
),
);
expect(new Set(indexes).size).toBe(3);
await fs.writeFile(path.join(outputs[0]!.destination, "README.md"), "caller edit\n");
for (const target of [
repo,
outputs[1]!.destination,
outputs[2]!.destination,
...listTemplates(env).map((t) => t.path),
]) {
expect(await fs.readFile(path.join(target, "README.md"), "utf8")).toBe("base\n");
}
},
);
it.each([
{ enabled: false, phase: "registration", head: "detached" },
{ enabled: true, phase: "registration", head: "redirected" },
{ enabled: true, phase: "clone", head: "detached" },
{ enabled: true, phase: "clone", head: "redirected" },
])(
"preserves a $head HEAD and foreign edit during $phase with acceleration=$enabled",
async ({ enabled, phase, head }) => {
acceleration = enabled;
const seed = await git(repo, "rev-parse", "HEAD");
await git(repo, "branch", "temp/pr-fixture", seed);
await git(repo, "branch", "temp/other-owner", seed);
let registration: string | undefined;
const changeHead = async (destination: string) => {
if (!registration) {
throw new Error("test did not observe target registration");
}
if (head === "detached") {
await git(repo, "--git-dir", registration, "update-ref", "--no-deref", "HEAD", seed);
} else {
await git(
repo,
"--git-dir",
registration,
"symbolic-ref",
"HEAD",
"refs/heads/temp/other-owner",
);
}
await fs.writeFile(
path.join(destination, "README.md"),
"foreign edit after registration\n",
);
};
vi.spyOn(commandExec, "runCommandWithTimeout").mockImplementation(async (argv, options) => {
const result = await realRunCommand(argv, options);
if (
argv[0] === "git" &&
argv.includes("worktree") &&
argv.includes("add") &&
argv.at(-1) === "temp/pr-fixture" &&
result.code === 0
) {
const destination = argv.at(-2)!;
registration = await git(destination, "rev-parse", "--absolute-git-dir");
if (phase === "registration") {
await changeHead(destination);
}
}
return result;
});
if (phase === "clone") {
vi.mocked(backend.cloneTemplate).mockImplementationOnce(async (source, destination) => {
await fs.cp(source, destination, { recursive: true, verbatimSymlinks: true });
await changeHead(destination);
});
}
await expect(
externalCheckout("head-race", { mode: "existing", name: "temp/pr-fixture" }, seed),
).rejects.toThrow();
const destination = path.join(env.OPENCLAW_STATE_DIR!, "external/head-race");
expect(await fs.readFile(path.join(destination, "README.md"), "utf8")).toBe(
"foreign edit after registration\n",
);
expect(await git(repo, "rev-parse", "refs/heads/temp/pr-fixture")).toBe(seed);
expect(await git(repo, "rev-parse", "refs/heads/temp/other-owner")).toBe(seed);
expect(await git(destination, "rev-parse", "HEAD")).toBe(seed);
if (head === "detached") {
await expect(git(destination, "symbolic-ref", "HEAD")).rejects.toThrow();
} else {
expect(await git(destination, "symbolic-ref", "HEAD")).toBe("refs/heads/temp/other-owner");
}
expect(await git(repo, "worktree", "list", "--porcelain")).toContain(destination);
},
);
it.each(["missing", "moved", "checked-out"] as const)(
"rejects %s caller seed before preparing a template",
async (state) => {
const seed = await git(repo, "rev-parse", "HEAD");
const name = state === "checked-out" ? "main" : "temp/pr-fixture";
if (state === "moved") {
await git(repo, "commit", "--allow-empty", "-m", "later");
await git(repo, "branch", name, "HEAD");
}
const refs = await git(repo, "show-ref");
const registered = await git(repo, "worktree", "list", "--porcelain");
await expect(
externalCheckout("rejected", { mode: "existing", name }, seed),
).rejects.toThrow();
expect(backend.createTemplate).not.toHaveBeenCalled();
expect(listTemplates(env)).toEqual([]);
expect(await git(repo, "show-ref")).toBe(refs);
expect(await git(repo, "worktree", "list", "--porcelain")).toBe(registered);
await expect(
fs.access(path.join(env.OPENCLAW_STATE_DIR!, "external/rejected")),
).rejects.toMatchObject({ code: "ENOENT" });
},
);
it.each(["current", "revoked"] as const)(
"preserves caller files and registration when clone failure leaves authority %s",
async (authority) => {
const seed = await git(repo, "rev-parse", "HEAD");
await git(repo, "branch", "temp/pr-fixture", seed);
let owned = true;
const revoked = new Error("allocation authority revoked");
const cloneFailure = new Error("clone unavailable");
vi.mocked(backend.cloneTemplate).mockImplementationOnce(async (_source, destination) => {
await fs.mkdir(destination);
await fs.writeFile(path.join(destination, "partial"), "caller recovery evidence");
if (authority === "revoked") {
owned = false;
}
throw cloneFailure;
});
const outcome = await externalCheckout(
"fallback",
{ mode: "existing", name: "temp/pr-fixture" },
seed,
() => {
if (!owned) {
throw revoked;
}
},
).then(
() => undefined,
(error: unknown) => error,
);
const destination = path.join(env.OPENCLAW_STATE_DIR!, "external/fallback");
// The old check-then-delete recovery removed this evidence before resetting
// the checkout, even though the caller owns its lifetime and branch.
expect(await fs.readFile(path.join(destination, "partial"), "utf8")).toBe(
"caller recovery evidence",
);
expect(await git(repo, "rev-parse", "refs/heads/temp/pr-fixture")).toBe(seed);
const registered = await git(repo, "worktree", "list", "--porcelain");
expect(registered).toContain(destination);
expect(registered).toContain("branch refs/heads/temp/pr-fixture");
expect(service.listRegistryRecords()).toEqual([]);
if (authority === "revoked") {
expect(outcome).toBe(revoked);
} else {
expect(outcome).toMatchObject({
message: expect.stringContaining("preserve"),
cause: cloneFailure,
});
}
},
);
it.each([
{ enabled: false, phase: "registration" },
{ enabled: true, phase: "registration" },
{ enabled: true, phase: "clone" },
])(
"preserves a seed moved during $phase with acceleration=$enabled",
async ({ enabled, phase }) => {
acceleration = enabled;
const seed = await git(repo, "rev-parse", "HEAD");
await git(repo, "commit", "--allow-empty", "-m", "new seed");
const moved = await git(repo, "rev-parse", "HEAD");
await git(repo, "branch", "temp/pr-fixture", seed);
let registered = false;
if (phase === "clone") {
vi.mocked(backend.cloneTemplate).mockImplementationOnce(async (source, destination) => {
await fs.cp(source, destination, { recursive: true, verbatimSymlinks: true });
await git(repo, "update-ref", "refs/heads/temp/pr-fixture", moved, seed);
});
}
const mutations: string[][] = [];
vi.spyOn(commandExec, "runCommandWithTimeout").mockImplementation(async (argv, options) => {
if (
argv[0] === "git" &&
(argv.includes("reset") || argv.includes("branch") || argv.includes("remove"))
) {
mutations.push([...argv]);
}
const result = await realRunCommand(argv, options);
if (
argv[0] === "git" &&
argv.includes("worktree") &&
argv.includes("add") &&
argv.at(-1) === "temp/pr-fixture" &&
result.code === 0
) {
registered = true;
if (phase === "registration") {
await git(repo, "update-ref", "refs/heads/temp/pr-fixture", moved, seed);
}
}
return result;
});
await expect(
externalCheckout("raced", { mode: "existing", name: "temp/pr-fixture" }, seed),
).rejects.toThrow(/branch moved/);
expect(registered).toBe(true);
expect(await git(repo, "rev-parse", "refs/heads/temp/pr-fixture")).toBe(moved);
expect(await git(repo, "worktree", "list", "--porcelain")).toContain(
"branch refs/heads/temp/pr-fixture",
);
expect(backend.cloneTemplate).toHaveBeenCalledTimes(phase === "clone" ? 1 : 0);
expect(mutations).toEqual([]);
},
);
it.each(["warm", "small", "restore", "cold", "disabled", "invalid", "fallback"])(
"admits only reusable source clones under disk pressure (%s)",
async (mode) => {

View file

@ -0,0 +1,481 @@
import { execFile } from "node:child_process";
import fs from "node:fs/promises";
import path from "node:path";
import { promisify } from "node:util";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useAutoCleanupTempDirTracker } from "../../../test/helpers/temp-dir.js";
import * as commandExec from "../../process/exec.js";
import { closeOpenClawStateDatabaseForTest } from "../../state/openclaw-state-db.js";
import * as baseRefs from "./base-ref.js";
import { resolveWorktreeSourceProfile } from "./checkout-profiles.js";
import { addManagedWorktree } from "./checkout.js";
import { ManagedWorktreeService } from "./service.js";
import { useManagedWorktreeTestRepository } from "./service.test-support.js";
const execFileAsync = promisify(execFile);
const realRunCommand = commandExec.runCommandWithTimeout;
async function git(cwd: string, ...args: string[]) {
return (await execFileAsync("git", ["-C", cwd, ...args])).stdout.trim();
}
describe("repository source profile creation", () => {
const initializeRepository = useManagedWorktreeTestRepository();
const roots = useAutoCleanupTempDirTracker((cleanup) =>
afterEach(() => {
vi.restoreAllMocks();
closeOpenClawStateDatabaseForTest();
cleanup();
}),
);
let repo: string;
let service: ManagedWorktreeService;
let env: NodeJS.ProcessEnv;
let commit: string;
async function write(file: string, content: string) {
const target = path.join(repo, file);
await fs.mkdir(path.dirname(target), { recursive: true });
await fs.writeFile(target, content);
}
async function save() {
await git(repo, "add", ".");
await git(repo, "commit", "-m", "profile inputs");
return await git(repo, "rev-parse", "HEAD");
}
beforeEach(async () => {
const root = roots.make("openclaw-source-profiles-");
repo = await initializeRepository(root);
await write("alpha/source.txt", "alpha\n");
await write("beta/source.txt", "beta\n");
await write("excluded/source.txt", "full-only\n");
await write(".openclaw/worktree-profiles/alpha", "alpha\n");
await write(".openclaw/worktree-profiles/both", "beta\nalpha\n");
commit = await save();
env = { ...process.env, OPENCLAW_STATE_DIR: path.join(root, "state") };
service = new ManagedWorktreeService({
env,
getConfig: () => ({ worktreeAcceleration: false }),
});
});
it("composes pinned definitions and keeps full default, shared history and independent writes", async () => {
await write(".openclaw/worktree-profiles/alpha", "excluded\n");
const sparse = await service.create({
repoRoot: repo,
name: "sparse",
baseRef: commit,
profiles: ["both", "alpha", "both"],
});
expect(await git(sparse.path, "sparse-checkout", "list")).toBe(
".openclaw/worktree-profiles\nalpha\nbeta",
);
expect(await fs.readFile(path.join(sparse.path, "alpha/source.txt"), "utf8")).toBe("alpha\n");
await expect(fs.access(path.join(sparse.path, "excluded/source.txt"))).rejects.toMatchObject({
code: "ENOENT",
});
expect(await git(sparse.path, "status", "--porcelain")).toBe("");
expect(await git(sparse.path, "rev-parse", "HEAD")).toBe(commit);
expect(await git(sparse.path, "rev-parse", "--is-shallow-repository")).toBe("false");
expect(await git(sparse.path, "merge-base", "HEAD", "main")).toBe(commit);
expect(await git(sparse.path, "rev-parse", "--path-format=absolute", "--git-common-dir")).toBe(
await git(repo, "rev-parse", "--path-format=absolute", "--git-common-dir"),
);
const full = await service.create({ repoRoot: repo, name: "full", baseRef: commit });
expect(await fs.readFile(path.join(full.path, "excluded/source.txt"), "utf8")).toBe(
"full-only\n",
);
expect(
await git(sparse.path, "rev-parse", "--path-format=absolute", "--git-path", "index"),
).not.toBe(await git(full.path, "rev-parse", "--path-format=absolute", "--git-path", "index"));
await fs.writeFile(path.join(sparse.path, "alpha/source.txt"), "task edit\n");
expect(await fs.readFile(path.join(full.path, "alpha/source.txt"), "utf8")).toBe("alpha\n");
expect(await fs.readFile(path.join(repo, "alpha/source.txt"), "utf8")).toBe("alpha\n");
await git(sparse.path, "sparse-checkout", "disable");
expect(await fs.readFile(path.join(sparse.path, "excluded/source.txt"), "utf8")).toBe(
"full-only\n",
);
expect(await fs.readFile(path.join(sparse.path, "alpha/source.txt"), "utf8")).toBe(
"task edit\n",
);
});
it.each(["../excluded\n", "/excluded\n", "alpha/source.txt\n", "missing\n", "alpha/*\n"])(
"rejects invalid cone data before target or branch registration: %j",
async (definition) => {
await write(".openclaw/worktree-profiles/bad", definition);
await save();
await expect(
service.create({
repoRoot: repo,
name: "invalid",
baseRef: "HEAD",
profiles: ["bad"],
}),
).rejects.toThrow(/directory/);
expect(await git(repo, "branch", "--list", "openclaw/invalid")).toBe("");
expect(service.listRegistryRecords()).toEqual([]);
expect(await git(repo, "worktree", "list", "--porcelain")).not.toContain("/invalid");
},
);
it("applies sparse source before ignored provisioning and never reshrinks on named reuse", async () => {
await write(".gitignore", "excluded/sentinel\n");
await write(".worktreeinclude", "excluded/sentinel\n");
await save();
await write("excluded/sentinel", "provisioned bytes\n");
const sparse = await service.create({
repoRoot: repo,
name: "prepared",
baseRef: "HEAD",
profiles: ["alpha"],
});
expect(await fs.readFile(path.join(sparse.path, "excluded/sentinel"), "utf8")).toBe(
"provisioned bytes\n",
);
await expect(fs.access(path.join(sparse.path, "excluded/source.txt"))).rejects.toMatchObject({
code: "ENOENT",
});
const before = service.listRegistryRecords();
await expect(
service.create({
repoRoot: repo,
name: "prepared",
profiles: ["both"],
}),
).rejects.toThrow(/new worktree/);
expect(service.listRegistryRecords()).toEqual(before);
expect(await fs.readFile(path.join(sparse.path, "excluded/sentinel"), "utf8")).toBe(
"provisioned bytes\n",
);
expect((await service.create({ repoRoot: repo, name: "prepared" })).id).toBe(sparse.id);
await git(sparse.path, "sparse-checkout", "disable");
expect(await fs.readFile(path.join(sparse.path, "excluded/sentinel"), "utf8")).toBe(
"provisioned bytes\n",
);
expect(await fs.readFile(path.join(sparse.path, "excluded/source.txt"), "utf8")).toBe(
"full-only\n",
);
});
it("does not reshrink a partially provisioned target after failed setup and failed cleanup", async () => {
await write(".gitignore", "excluded/sentinel\nexcluded/setup-state\n");
await write(".worktreeinclude", "excluded/sentinel\n");
await write(".openclaw/worktree-setup.sh", "#!/bin/sh\nexit 0\n");
await fs.chmod(path.join(repo, ".openclaw/worktree-setup.sh"), 0o755);
await save();
await write("excluded/sentinel", "retained provisioning\n");
let target = "";
let sparseCalls = 0;
let setupCalls = 0;
const failed = (stderr: string) => ({
stdout: "",
stderr,
code: 1,
signal: null,
killed: false,
termination: "exit" as const,
});
vi.spyOn(commandExec, "runCommandWithTimeout").mockImplementation(async (argv, options) => {
if (argv[0] === path.join(repo, ".openclaw/worktree-setup.sh")) {
setupCalls++;
if (typeof options === "number" || !options.cwd) {
throw new Error("setup command must name its working directory");
}
target = options.cwd;
// Inspect real Git state and real copied bytes at the hook boundary.
expect(await git(target, "sparse-checkout", "list")).toBe(
".openclaw/worktree-profiles\nalpha",
);
expect(await fs.readFile(path.join(target, "excluded/sentinel"), "utf8")).toBe(
"retained provisioning\n",
);
await expect(fs.access(path.join(target, "excluded/source.txt"))).rejects.toMatchObject({
code: "ENOENT",
});
await fs.writeFile(path.join(target, "excluded/setup-state"), "partial setup\n");
return failed("injected setup failure");
}
if (argv[0] === "git" && argv.includes("sparse-checkout") && argv.includes("set")) {
sparseCalls++;
}
if (argv[0] === "git" && argv.includes("worktree") && argv.includes("remove")) {
return failed("injected cleanup failure retains recovery");
}
return await realRunCommand(argv, options);
});
const params = { repoRoot: repo, name: "partial", baseRef: "HEAD", profiles: ["alpha"] };
await expect(service.create(params)).rejects.toThrow(/setup failure/);
expect(target).not.toBe("");
expect(service.listRegistryRecords()).toEqual([]);
await expect(service.create({ ...params, profiles: ["both"] })).rejects.toThrow();
expect(sparseCalls).toBe(1);
expect(setupCalls).toBe(1);
expect(await git(target, "sparse-checkout", "list")).toBe(".openclaw/worktree-profiles\nalpha");
expect(await fs.readFile(path.join(target, "excluded/sentinel"), "utf8")).toBe(
"retained provisioning\n",
);
expect(await fs.readFile(path.join(target, "excluded/setup-state"), "utf8")).toBe(
"partial setup\n",
);
});
it("rejects selected owner reuse and snapshot restore before changing ignored state", async () => {
await write(".gitignore", "excluded/sentinel\n");
await write(".worktreeinclude", "excluded/sentinel\n");
await write("excluded/sentinel", "provisioned bytes\n");
await save();
const params = {
repoRoot: repo,
name: "owned",
baseRef: "HEAD",
ownerId: "owner",
ownerKind: "session" as const,
};
const full = await service.create(params);
await fs.writeFile(path.join(full.path, "excluded/sentinel"), "owned ignored bytes\n");
await expect(
service.create({ ...params, name: "different", profiles: ["alpha"] }),
).rejects.toThrow(/new worktree/);
expect(await fs.readFile(path.join(full.path, "excluded/sentinel"), "utf8")).toBe(
"owned ignored bytes\n",
);
await service.remove({ id: full.id, reason: "archive" });
const before = service.listRegistryRecords();
expect(before[0]?.snapshotRef).toBeTruthy();
await expect(service.create({ ...params, profiles: ["alpha"] })).rejects.toThrow(
/new worktree/,
);
expect(service.listRegistryRecords()).toEqual(before);
const restored = await service.restore({ id: full.id });
expect(await fs.readFile(path.join(restored.path, "excluded/sentinel"), "utf8")).toBe(
"owned ignored bytes\n",
);
expect(await fs.readFile(path.join(restored.path, "excluded/source.txt"), "utf8")).toBe(
"full-only\n",
);
});
it.each(["existing", "restore"])(
"rejects an unsafe low-level profile target before registration: %s",
async (mode) => {
const destination = path.join(roots.make("openclaw-profile-target-"), "target");
if (mode === "existing") {
await fs.mkdir(destination);
await fs.writeFile(path.join(destination, "sentinel"), "preserve\n");
}
const sourceProfile = await resolveWorktreeSourceProfile(repo, commit, ["alpha"], {
commitGuard: () => undefined,
});
const requireSpace = vi.fn();
await expect(
addManagedWorktree({
env,
now: Date.now,
enabled: false,
repoRoot: repo,
commonDir: await git(repo, "rev-parse", "--path-format=absolute", "--git-common-dir"),
worktreeRoot: path.dirname(destination),
destination,
base: commit,
sourceProfile,
deferGitCheckout: mode === "restore",
requireSpace,
commitGuard: () => undefined,
}),
).rejects.toThrow(/fresh destination/);
expect(requireSpace).not.toHaveBeenCalled();
expect(await git(repo, "worktree", "list", "--porcelain")).not.toContain(destination);
if (mode === "existing") {
expect(await fs.readFile(path.join(destination, "sentinel"), "utf8")).toBe("preserve\n");
}
},
);
it("preserves unexpected content appearing after registration instead of shrinking or rolling it back", async () => {
const destination = path.join(roots.make("openclaw-profile-race-"), "target");
const sourceProfile = await resolveWorktreeSourceProfile(repo, commit, ["alpha"], {
commitGuard: () => undefined,
});
let sparseCalls = 0;
vi.spyOn(commandExec, "runCommandWithTimeout").mockImplementation(async (argv, options) => {
if (argv[0] === "git" && argv.includes("sparse-checkout")) {
sparseCalls++;
}
const result = await realRunCommand(argv, options);
if (
argv[0] === "git" &&
argv.includes("worktree") &&
argv.includes("add") &&
argv.includes(destination) &&
result.code === 0
) {
await fs.writeFile(path.join(destination, "sentinel"), "interrupted preparation\n");
}
return result;
});
await expect(
addManagedWorktree({
env,
now: Date.now,
enabled: false,
repoRoot: repo,
commonDir: await git(repo, "rev-parse", "--path-format=absolute", "--git-common-dir"),
worktreeRoot: path.dirname(destination),
destination,
base: commit,
sourceProfile,
requireSpace: () => undefined,
commitGuard: () => undefined,
}),
).rejects.toThrow(/no longer unprepared/);
expect(sparseCalls).toBe(0);
expect(await fs.readFile(path.join(destination, "sentinel"), "utf8")).toBe(
"interrupted preparation\n",
);
expect(await git(repo, "worktree", "list", "--porcelain")).toContain(destination);
});
it("preserves partial sparse materialization and refuses to shrink it on retry", async () => {
await write(".gitignore", "excluded/sentinel\n");
await save();
let target = "";
let sparseCalls = 0;
vi.spyOn(commandExec, "runCommandWithTimeout").mockImplementation(async (argv, options) => {
if (argv[0] === "git" && argv.includes("sparse-checkout") && argv.includes("set")) {
sparseCalls++;
}
if (argv[0] === "git" && argv.includes("read-tree") && argv.includes("--reset")) {
// Git's executor selects its worktree with -C, not the process cwd.
const directoryFlag = argv.indexOf("-C");
const directory = directoryFlag >= 0 ? argv[directoryFlag + 1] : undefined;
if (!directory) {
throw new Error("Git materialization must select its worktree with -C");
}
target = directory;
await fs.mkdir(path.join(target, "excluded"), { recursive: true });
await fs.writeFile(path.join(target, "excluded/sentinel"), "partial recovery evidence\n");
return {
stdout: "",
stderr: "injected partial materialization",
code: 1,
signal: null,
killed: false,
termination: "exit" as const,
};
}
return await realRunCommand(argv, options);
});
const params = { repoRoot: repo, name: "partial-source", baseRef: "HEAD", profiles: ["alpha"] };
await expect(service.create(params)).rejects.toThrow(/partial materialization/);
expect(target).not.toBe("");
expect(await fs.readFile(path.join(target, "excluded/sentinel"), "utf8")).toBe(
"partial recovery evidence\n",
);
expect(await git(repo, "worktree", "list", "--porcelain")).toContain(target);
expect(service.listRegistryRecords()).toEqual([]);
await expect(service.create({ ...params, profiles: ["both"] })).rejects.toThrow(
/branch already exists/,
);
expect(sparseCalls).toBe(1);
expect(await fs.readFile(path.join(target, "excluded/sentinel"), "utf8")).toBe(
"partial recovery evidence\n",
);
});
it.each(["../alpha", "Alpha", "", "alpha/beta", "--alpha", "alpha\n"])(
"rejects unsafe profile names before source registration: %j",
async (name) => {
await expect(
service.create({
repoRoot: repo,
name: "invalid-name",
baseRef: commit,
profiles: [name],
}),
).rejects.toThrow(/lowercase name/);
expect(await git(repo, "branch", "--list", "openclaw/invalid-name")).toBe("");
expect(service.listRegistryRecords()).toEqual([]);
},
);
it("rejects invalid UTF-8 and oversized definitions instead of reading a valid prefix", async () => {
for (const contents of [Buffer.from([0xff]), Buffer.from("alpha\n" + "\n".repeat(64 * 1024))]) {
await fs.writeFile(path.join(repo, ".openclaw/worktree-profiles/bad"), contents);
const pinned = await save();
await expect(
resolveWorktreeSourceProfile(repo, pinned, ["bad"], {
commitGuard: () => undefined,
}),
).rejects.toThrow();
}
});
it.each([false, true])("reloads profiles at fallback HEAD; missing=%s", async (missing) => {
if (missing) {
await git(repo, "rm", ".openclaw/worktree-profiles/alpha");
} else {
await write(".openclaw/worktree-profiles/alpha", "beta\n");
}
const fallback = await save();
vi.spyOn(baseRefs, "resolveWorktreeBase").mockResolvedValue({
commit,
gitOperand: commit,
recordRef: "origin/main",
remote: true,
});
let attempts = 0;
vi.spyOn(commandExec, "runCommandWithTimeout").mockImplementation(async (argv, options) => {
if (argv[0] === "git" && argv.includes("worktree") && argv.includes("add")) {
attempts++;
if (attempts === 1) {
return {
stdout: "",
stderr: "remote checkout failed",
code: 1,
signal: null,
killed: false,
termination: "exit",
};
}
}
return await realRunCommand(argv, options);
});
const result = service.create({ repoRoot: repo, name: "retry", profiles: ["alpha"] });
if (missing) {
await expect(result).rejects.toThrow(/tracked regular file/);
expect(attempts).toBe(1);
expect(service.listRegistryRecords()).toEqual([]);
expect(await git(repo, "branch", "--list", "openclaw/retry")).toBe("");
} else {
const target = await result;
expect(attempts).toBe(2);
expect(await git(target.path, "rev-parse", "HEAD")).toBe(fallback);
expect(await git(target.path, "sparse-checkout", "list")).toBe(
".openclaw/worktree-profiles\nbeta",
);
await expect(fs.access(path.join(target.path, "alpha/source.txt"))).rejects.toMatchObject({
code: "ENOENT",
});
expect(target.baseRef).toBe("HEAD");
}
});
it("refuses symlink profile definitions", async () => {
// Git mode injection avoids host symlink privilege requirements.
const blob = await git(repo, "rev-parse", commit + ":.openclaw/worktree-profiles/alpha");
await git(
repo,
"update-index",
"--add",
"--cacheinfo",
"120000," + blob + ",.openclaw/worktree-profiles/link",
);
await git(repo, "commit", "-m", "symlink profile");
await expect(
resolveWorktreeSourceProfile(repo, "HEAD", ["link"], {
commitGuard: () => undefined,
}),
).rejects.toThrow(/tracked regular file/);
});
});

View file

@ -0,0 +1,217 @@
import { execFile } from "node:child_process";
import { createHash } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { promisify } from "node:util";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useAutoCleanupTempDirTracker } from "../../../test/helpers/temp-dir.js";
import { closeOpenClawStateDatabaseForTest } from "../../state/openclaw-state-db.js";
import { detectWorktreeFilesystemBackend } from "./filesystem-backend.js";
import { createCopyWorktreeBackend } from "./filesystem-backend.test-support.js";
import type { WorktreeFilesystemBackend } from "./filesystem-backend.types.js";
import { ManagedWorktreeService } from "./service.js";
import { useManagedWorktreeTestRepository } from "./service.test-support.js";
import { listTemplates } from "./template-registry.js";
vi.mock("./filesystem-backend.js", () => ({ detectWorktreeFilesystemBackend: vi.fn() }));
const execFileAsync = promisify(execFile);
async function git(cwd: string, ...args: string[]): Promise<string> {
return (await execFileAsync("git", ["-C", cwd, ...args])).stdout.trim();
}
describe("ManagedWorktreeService sparse isolation", () => {
const initializeRepository = useManagedWorktreeTestRepository();
const tempDirs = useAutoCleanupTempDirTracker((cleanup) =>
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllEnvs();
closeOpenClawStateDatabaseForTest();
cleanup();
}),
);
let repo: string;
let env: NodeJS.ProcessEnv;
let service: ManagedWorktreeService;
let backend: WorktreeFilesystemBackend;
beforeEach(async () => {
vi.stubEnv("GIT_CONFIG_NOSYSTEM", "1");
vi.stubEnv("GIT_ATTR_NOSYSTEM", "1");
const root = tempDirs.make("openclaw-worktree-sparse-isolation-");
repo = await initializeRepository(root);
env = { ...process.env, OPENCLAW_STATE_DIR: path.join(root, "state") };
const now = Date.now();
backend = createCopyWorktreeBackend();
vi.mocked(detectWorktreeFilesystemBackend).mockReset().mockResolvedValue(backend);
service = new ManagedWorktreeService({ env, now: () => now, getConfig: () => ({}) });
});
it.each([false, true])(
"preserves isolated source and guarded fallback through sparse/disable order (companion-before-disable=%s)",
async (companionBeforeDisable) => {
await fs.mkdir(path.join(repo, ".openclaw/worktree-profiles"), { recursive: true });
await fs.mkdir(path.join(repo, "selected"), { recursive: true });
await fs.mkdir(path.join(repo, "excluded"), { recursive: true });
await fs.writeFile(path.join(repo, "selected/source.txt"), "selected\n");
await fs.writeFile(path.join(repo, "excluded/source.txt"), "excluded\n");
await fs.writeFile(path.join(repo, ".gitignore"), "excluded/ignored.txt\n");
await fs.writeFile(path.join(repo, ".openclaw/worktree-profiles/selected"), "selected\n");
await git(repo, "add", ".");
await git(repo, "commit", "-m", "source profile");
const commit = await git(repo, "rev-parse", "HEAD");
const targets = new Map<string, string>([["repository", repo]]);
const backends: { name: string; actual: string; expected: string }[] = [];
const trace: { phase: string; targets: unknown[] }[] = [];
const gitPath = (target: string, name: string) =>
git(target, "rev-parse", "--path-format=absolute", "--git-path", name);
const optional = async (file: string) => {
try {
return await fs.readFile(file, "utf8");
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return null;
}
throw error;
}
};
const observe = async (phase: string) => {
for (const name of targets.keys()) {
if (name.startsWith("template-")) {
targets.delete(name);
}
}
for (const template of listTemplates(env)) {
targets.set(`template-${template.id}`, template.path);
}
const samples = [];
for (const [name, target] of targets) {
const indexPath = await gitPath(target, "index");
const headPath = await gitPath(target, "HEAD");
const worktreeConfigPath = await gitPath(target, "config.worktree");
const patternPath = await gitPath(target, "info/sparse-checkout");
samples.push({
name,
target,
headPath,
headFile: await fs.readFile(headPath, "utf8"),
commit: await git(target, "rev-parse", "HEAD"),
indexPath,
indexSha256: createHash("sha256")
.update(await fs.readFile(indexPath))
.digest("hex"),
indexEntries: await git(target, "ls-files", "-t"),
commonDir: await git(target, "rev-parse", "--path-format=absolute", "--git-common-dir"),
effectiveConfig: await git(target, "config", "--show-origin", "--show-scope", "--list"),
commonConfig: await fs.readFile(await gitPath(target, "config"), "utf8"),
worktreeConfigPath,
worktreeConfig: await optional(worktreeConfigPath),
patternPath,
patterns: await optional(patternPath),
});
}
trace.push({ phase, targets: samples });
expect(new Set(samples.map((s) => s.indexPath)).size).toBe(samples.length);
expect(samples.every((s) => s.commit === commit)).toBe(true);
expect(new Set(samples.map((s) => s.commonDir)).size).toBe(1);
};
const create = async (name: string, profiles?: string[]) => {
const siblings = await Promise.all(
[...targets.entries()]
.filter(([label]) => !label.startsWith("template-"))
.map(async ([label, target]) => ({
label,
index: await gitPath(target, "index"),
head: await gitPath(target, "HEAD"),
indexBytes: await fs.readFile(await gitPath(target, "index")),
headBytes: await fs.readFile(await gitPath(target, "HEAD")),
})),
);
const calls = vi.mocked(backend.cloneTemplate).mock.calls.length;
const created = await service.create({ repoRoot: repo, name, baseRef: commit, profiles });
backends.push({
name,
actual:
vi.mocked(backend.cloneTemplate).mock.calls.length > calls ? "fixture-clone" : "git",
expected: name === "full-cold" || name === "full-warm" ? "fixture-clone" : "git",
});
for (const sibling of siblings) {
expect(await fs.readFile(sibling.index), sibling.label).toEqual(sibling.indexBytes);
expect(await fs.readFile(sibling.head), sibling.label).toEqual(sibling.headBytes);
}
targets.set(name, created.path);
await observe(name);
return created;
};
// Original order: cold full -> warm full -> sparse -> native disable ->
// subsequent full -> sparse/reuse -> full. Companion adds a full create
// while the first sparse checkout is still sparse to catch contamination.
const cold = await create("full-cold");
const warm = await create("full-warm");
const sparseA = await create("sparse-a", ["selected"]);
await fs.writeFile(path.join(sparseA.path, "selected/source.txt"), "sparse edit\n");
await fs.mkdir(path.join(sparseA.path, "excluded"), { recursive: true });
await fs.writeFile(path.join(sparseA.path, "excluded/ignored.txt"), "retained ignored\n");
const companion = companionBeforeDisable ? await create("full-companion") : undefined;
await git(sparseA.path, "sparse-checkout", "disable");
await observe("native-disable-a");
const fullB = await create("full-after-disable");
const sparseC = await create("sparse-c", ["selected"]);
await fs.writeFile(path.join(sparseC.path, "selected/source.txt"), "sparse reuse edit\n");
expect((await service.create({ repoRoot: repo, name: "sparse-c", baseRef: commit })).id).toBe(
sparseC.id,
);
await expect(
service.create({
repoRoot: repo,
name: "sparse-c",
baseRef: commit,
profiles: ["selected"],
}),
).rejects.toThrow(/new worktree/);
await observe("sparse-c-reuse");
const fullD = await create("full-with-sparse-c");
for (const target of [
cold.path,
warm.path,
fullB.path,
fullD.path,
...(companion ? [companion.path] : []),
...listTemplates(env).map((t) => t.path),
]) {
expect(await fs.readFile(path.join(target, "selected/source.txt"), "utf8")).toBe(
"selected\n",
);
expect(await fs.readFile(path.join(target, "excluded/source.txt"), "utf8")).toBe(
"excluded\n",
);
expect(await git(target, "ls-files", "-t")).not.toMatch(/^S /m);
expect(await git(target, "config", "--show-origin", "--show-scope", "--list")).not.toMatch(
/core\.sparsecheckout=true/,
);
}
expect(await fs.readFile(path.join(sparseA.path, "selected/source.txt"), "utf8")).toBe(
"sparse edit\n",
);
expect(await fs.readFile(path.join(sparseA.path, "excluded/ignored.txt"), "utf8")).toBe(
"retained ignored\n",
);
expect(await fs.readFile(path.join(sparseA.path, "excluded/source.txt"), "utf8")).toBe(
"excluded\n",
);
expect(await fs.readFile(path.join(sparseC.path, "selected/source.txt"), "utf8")).toBe(
"sparse reuse edit\n",
);
await expect(fs.access(path.join(sparseC.path, "excluded/source.txt"))).rejects.toMatchObject(
{ code: "ENOENT" },
);
expect(await git(sparseC.path, "ls-files", "-t")).toMatch(/^S excluded\/source.txt$/m);
expect(await git(sparseC.path, "merge-base", "HEAD", "main")).toBe(commit);
expect(await git(sparseC.path, "rev-parse", "--is-shallow-repository")).toBe("false");
// Git enables shared worktreeConfig for sparse state. The existing guard
// deliberately keeps later full creates on Git, even after native disable.
// Collect all states before comparing the documented fallback sequence.
for (const row of backends) {
expect(row.actual, JSON.stringify(trace, null, 2)).toBe(row.expected);
}
},
);
});

View file

@ -12,8 +12,8 @@ import { isPathInside } from "../../infra/path-guards.js";
import { createSubsystemLogger } from "../../logging/subsystem.js";
import { createCommandError } from "../../process/command-error.js";
import { runCommandWithTimeout } from "../../process/exec.js";
import { withOpenClawStateLease } from "../../state/openclaw-state-lease.js";
import { createCrustaceanSlug } from "../session-slug.js";
import { withWorktreeAllocationLease } from "./allocation.js";
import { resolveWorktreeBase } from "./base-ref.js";
import {
directorySizeBytes,
@ -22,6 +22,7 @@ import {
requireWorktreeDiskSpace,
WORKTREE_SETUP_HEADROOM_BYTES,
} from "./capacity.js";
import { resolveWorktreeSourceProfile } from "./checkout-profiles.js";
import {
addManagedWorktree,
collectWorktreeTemplates,
@ -94,9 +95,6 @@ export const SNAPSHOT_RETENTION_MS = 30 * 24 * 60 * 60 * 1000; // Snapshot refs
export const WORKTREE_GC_INTERVAL_MS = 60 * 60 * 1000;
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 = 60_000;
const WORKTREE_CREATE_LEASE_WAIT_MS = 10 * 60_000;
/** Removal aborted because snapshot loss was not permitted. */
export class WorktreeSnapshotError extends Error {
@ -434,7 +432,10 @@ export class ManagedWorktreeService {
}
async createEmpty(
params: Omit<CreateManagedWorktreeParams, "repoRoot" | "baseRef" | "checkoutCommit"> & {
params: Omit<
CreateManagedWorktreeParams,
"repoRoot" | "baseRef" | "checkoutCommit" | "profiles"
> & {
ownerKind: "session";
ownerId: string;
},
@ -491,6 +492,9 @@ export class ManagedWorktreeService {
params.ownerKind ?? "manual",
params.ownerId,
);
if (existing && params.profiles?.length) {
throw new Error("Source profiles require a new worktree; use a new owner and name.");
}
if (existing && (await worktreePathExists(existing.path))) {
const validated = await this.rebindLiveRepository(existing, params);
if (validated.repoRoot !== repository.repoRoot) {
@ -517,28 +521,7 @@ export class ManagedWorktreeService {
params: WorktreeMutationGuard,
run: (guard: WorktreeMutationGuard) => Promise<T>,
): Promise<T> {
// Disk headroom is shared across repositories. Hold one renewable lease
// through checkout, setup, snapshots, and publication, including CLI processes.
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?.();
},
}),
);
return await withWorktreeAllocationLease({ ...params, env: this.env }, run);
}
private requireAllocationSpace(target: string, repository: ResolvedRepository, bytes = 0) {
@ -566,6 +549,9 @@ export class ManagedWorktreeService {
const existing = suppliedName
? findWorktreeByName(this.env, repository.fingerprint, suppliedName)
: undefined;
if (existing && params.profiles?.length) {
throw new Error("Source profiles require a new worktree; choose an unused --name.");
}
// Name reuse only ever adopts the caller's own record. Without this guard a
// caller-chosen name could bind a new owner to another session's or a
// manual checkout and run inside it.
@ -627,6 +613,7 @@ export class ManagedWorktreeService {
}
const base = params.checkoutCommit
? {
commit: params.checkoutCommit,
gitOperand: params.checkoutCommit,
recordRef: params.baseRef ?? params.checkoutCommit,
remote: false,
@ -637,7 +624,7 @@ export class ManagedWorktreeService {
params.signal,
params.commitGuard,
);
const gitBytes = Math.max(
let gitBytes = Math.max(
await estimateWorktreeGitBytes(repository.repoRoot, base.gitOperand, {
signal: params.signal,
assertCurrent: params.commitGuard,
@ -673,13 +660,32 @@ export class ManagedWorktreeService {
: 0;
params.signal?.throwIfAborted();
params.commitGuard?.();
await fs.mkdir(root, { recursive: true });
params.signal?.throwIfAborted();
params.commitGuard?.();
let gitBase = base.gitOperand;
let gitBase = params.profiles?.length ? base.commit : base.gitOperand;
let recordBase = base.recordRef;
const addCheckout = () =>
addManagedWorktree({
const addCheckout = async () => {
// Resolve on every attempt, including the remote-base fallback to local HEAD.
// Never pair one commit's source selection with another commit's checkout.
const sourceProfile = params.profiles?.length
? await resolveWorktreeSourceProfile(repository.repoRoot, gitBase, params.profiles, {
signal: params.signal,
commitGuard: () => params.commitGuard?.(),
})
: undefined;
if (sourceProfile) {
// A fallback HEAD can advance after its first size inventory.
// Admission must cover the exact commit selected for this attempt.
gitBytes = Math.max(
gitBytes,
await estimateWorktreeGitBytes(repository.repoRoot, sourceProfile.commit, {
signal: params.signal,
assertCurrent: params.commitGuard,
}),
);
}
params.signal?.throwIfAborted();
params.commitGuard?.();
await fs.mkdir(root, { recursive: true });
return await addManagedWorktree({
env: this.env,
now: this.now,
enabled: this.getConfig?.().worktreeAcceleration !== false,
@ -687,8 +693,9 @@ export class ManagedWorktreeService {
commonDir: repository.commonDir,
worktreeRoot: path.dirname(root),
destination: worktreePath,
branch,
base: gitBase,
branch: { mode: "create", name: branch },
base: sourceProfile?.commit ?? gitBase,
sourceProfile,
requireSpace: (cloneBytes) =>
this.requireAllocationSpace(
worktreePath,
@ -698,6 +705,7 @@ export class ManagedWorktreeService {
signal: params.signal,
commitGuard: () => params.commitGuard?.(),
});
};
let added = await addCheckout();
if (added.code !== 0 && base.remote) {
if (!(await canResetFailedWorktreeAdd(repository.repoRoot, worktreePath, branch, added))) {
@ -1175,7 +1183,7 @@ export class ManagedWorktreeService {
worktreeRoot: path.dirname(path.dirname(record.path)),
destination: record.path,
base: parent,
branch: record.branch,
branch: record.branch ? { mode: "create", name: record.branch } : undefined,
deferGitCheckout: true,
requireSpace: (cloneBytes) =>
this.requireAllocationSpace(

View file

@ -43,6 +43,8 @@ export type CreateManagedWorktreeParams = {
/** Derived default name; collisions receive a stable numeric suffix. */
suggestedName?: string;
baseRef?: string;
/** Repository-owned source cone lists; selection never requests dependency setup. */
profiles?: string[];
/** Verified immutable checkout point when baseRef retains the publication target. */
checkoutCommit?: string;
ownerKind?: ManagedWorktreeOwnerKind;

View file

@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { managedWorktrees } from "../agents/worktrees/service.js";
import { resetConfigRuntimeState, setRuntimeConfigSnapshot } from "../config/config.js";
import { defaultRuntime } from "../runtime.js";
import { parseCliProfileArgs } from "./profile.js";
import { registerWorktreesCli } from "./worktrees-cli.js";
afterEach(() => {
@ -11,6 +12,108 @@ afterEach(() => {
});
describe("worktrees cli", () => {
it.each([
{ runtime: [], selected: [], profiles: undefined, state: null },
{ runtime: [], selected: ["--source-profile", "alpha"], profiles: ["alpha"], state: null },
{
runtime: ["--profile", "work"],
selected: ["--source-profile", "alpha", "--source-profile=beta"],
profiles: ["alpha", "beta"],
state: "work",
},
{ runtime: ["--dev"], selected: ["--source-profile=alpha"], profiles: ["alpha"], state: "dev" },
{
runtime: [],
selected: ["--source-profile=alpha", "--profile", "work"],
profiles: ["alpha"],
state: "work",
},
])(
"passes source profiles through early parsing without changing runtime state: $state $selected",
async ({ runtime, selected, profiles, state }) => {
const create = vi.spyOn(managedWorktrees, "create").mockResolvedValue({
id: "created",
name: "task",
repoFingerprint: "fingerprint",
repoRoot: "/repo",
path: "/state/task",
branch: "openclaw/task",
baseRef: "HEAD",
ownerKind: "manual",
createdAt: 1,
lastActiveAt: 1,
});
vi.spyOn(defaultRuntime, "writeJson").mockImplementation(() => undefined);
const parsed = parseCliProfileArgs([
"node",
"openclaw",
...runtime,
"worktrees",
"create",
"/repo",
"--name",
"task",
"--json",
...selected,
]);
if (!parsed.ok) {
throw new Error(parsed.error);
}
expect(parsed.profile).toBe(state);
const program = new Command().name("openclaw").exitOverride();
registerWorktreesCli(program);
await program.parseAsync(parsed.argv);
expect(create).toHaveBeenCalledWith({
repoRoot: "/repo",
name: "task",
baseRef: undefined,
ownerKind: "manual",
...(profiles ? { profiles } : {}),
});
},
);
it("leaves missing source profile values to Commander and performs no creation", async () => {
const create = vi.spyOn(managedWorktrees, "create");
const parsed = parseCliProfileArgs([
"node",
"openclaw",
"worktrees",
"create",
"/repo",
"--source-profile",
]);
if (!parsed.ok) {
throw new Error(parsed.error);
}
const program = new Command()
.name("openclaw")
.exitOverride()
.configureOutput({ writeErr: () => undefined });
registerWorktreesCli(program);
await expect(program.parseAsync(parsed.argv)).rejects.toThrow(/argument missing/);
expect(create).not.toHaveBeenCalled();
});
it.each(["create", "list", "remove", "restore", "gc"])(
"preserves late runtime --profile on worktrees %s",
(command) => {
const parsed = parseCliProfileArgs([
"node",
"openclaw",
"worktrees",
command,
"--profile",
"work",
]);
expect(parsed).toEqual({
ok: true,
profile: "work",
argv: ["node", "openclaw", "worktrees", command],
});
},
);
it.each([false, true])(
"reports the existing lossless owner outcome, removed=%s",
async (removed) => {

View file

@ -67,18 +67,29 @@ export function registerWorktreesCli(program: Command): void {
.argument("<repoRoot>", "Source git checkout")
.option("--name <name>", "Managed worktree name")
.option("--base-ref <ref>", "Git ref to branch from")
.option(
"--source-profile <name>",
"Repository source profile; repeat to combine (default: full source)",
(value: string, previous: string[] | undefined) => [...(previous ?? []), value],
)
.option("--json", "Output JSON", false)
.action(async (repoRoot: string, opts: JsonOption & { name?: string; baseRef?: string }) => {
printRecord(
await managedWorktrees.create({
repoRoot,
name: opts.name,
baseRef: opts.baseRef,
ownerKind: "manual",
}),
opts.json === true,
);
});
.action(
async (
repoRoot: string,
opts: JsonOption & { name?: string; baseRef?: string; sourceProfile?: string[] },
) => {
printRecord(
await managedWorktrees.create({
repoRoot,
name: opts.name,
baseRef: opts.baseRef,
...(opts.sourceProfile?.length ? { profiles: opts.sourceProfile } : {}),
ownerKind: "manual",
}),
opts.json === true,
);
},
);
worktrees
.command("remove")

View file

@ -534,9 +534,14 @@ describe("Git operation host lifecycle", () => {
},
);
it.each(["abort", "close"] as const)(
"joins the real Git fetch before %s settles",
async (ending) => {
it.each([
{ ending: "abort", transport: "managed" },
{ ending: "close", transport: "managed" },
{ ending: "abort", transport: "caller" },
{ ending: "close", transport: "caller" },
] as const)(
"joins the real Git fetch before $ending settles with $transport transport",
async ({ ending, transport }) => {
const root = tempDirs.make("openclaw-git-worker-child-");
const { clone, commit } = await partialClone(root);
const connected = createDeferredCore();
@ -564,7 +569,16 @@ describe("Git operation host lifecycle", () => {
const pending = settle(
runGitWorkerOperation(
{ type: "worktree.git-size", input: { repoRoot: clone, ref: commit } },
{ signal: abort.signal },
{
signal: abort.signal,
git:
transport === "caller"
? {
text: gitExec.executeGitCommandBytes,
buffered: gitExec.executeGitCommandBuffered,
}
: undefined,
},
),
);
let gitPid: number | undefined;
@ -612,6 +626,142 @@ describe("Git operation host lifecycle", () => {
},
);
it.each(["text", "buffered"] as const)(
"captures caller %s policy before a queued sizing request can be changed",
async (transport) => {
const root = tempDirs.make("openclaw-caller-git-admission-");
const repo = await repository(root);
const entered = createDeferredCore();
const release = createDeferredCore();
let held = false;
const first = settle(
runGitWorkerOperation(
{ type: "worktree.git-size", input: { repoRoot: repo, ref: "HEAD" } },
{
git: {
text: async (cwd, args, options) => {
if (!held) {
held = true;
entered.resolve();
await release.promise;
}
return await gitExec.executeGitCommandBytes(cwd, args, options);
},
buffered: gitExec.executeGitCommandBuffered,
},
},
),
);
const pending: Promise<unknown>[] = [first];
try {
await within(
Promise.race([
entered.promise,
first.then(() => {
throw new Error("Sizing ended before the predecessor was held");
}),
]),
);
const calls = { text: 0, buffered: 0 };
const executors: NonNullable<GitWorkerOperationOptions["git"]> = {
text: async (cwd, args, options) => {
calls.text++;
return await gitExec.executeGitCommandBytes(cwd, args, options);
},
buffered: async (cwd, args, options) => {
calls.buffered++;
return await gitExec.executeGitCommandBuffered(cwd, args, options);
},
};
const second = settle(
runGitWorkerOperation(
{ type: "worktree.git-size", input: { repoRoot: repo, ref: "HEAD" } },
{ git: executors },
),
);
pending.push(second);
executors[transport] = async () => {
throw new Error("queued caller replaced Git policy");
};
expect(calls).toEqual({ text: 0, buffered: 0 });
release.resolve();
expect(await within(first)).toEqual({ rejected: false, value: 4096 });
expect(await within(second)).toEqual({ rejected: false, value: 4096 });
expect(calls.text).toBeGreaterThan(0);
expect(calls.buffered).toBeGreaterThan(0);
} finally {
release.resolve();
await Promise.all(pending);
}
},
);
it.each(["text", "buffered"] as const)(
"revalidates authority before caller %s execution and preserves the host error",
async (transport) => {
const root = tempDirs.make("openclaw-caller-git-authority-");
const repo = await repository(root);
const entered = createDeferredCore();
const release = createDeferredCore();
const revoked = new Error("caller Git authority revoked");
let current = true;
const trace = path.join(root, "git-trace.jsonl");
vi.stubEnv("GIT_TRACE2_EVENT", trace);
const waitForRevocation = async () => {
entered.resolve();
await release.promise;
};
const pending = settle(
runGitWorkerOperation(
{ type: "worktree.git-size", input: { repoRoot: repo, ref: "HEAD" } },
{
assertCurrent: () => {
if (!current) {
throw revoked;
}
},
git: {
text: async (cwd, args, options) => {
if (transport === "text") {
await waitForRevocation();
}
return await gitExec.executeGitCommandBytes(cwd, args, options);
},
buffered: async (cwd, args, options) => {
if (transport === "buffered") {
await waitForRevocation();
}
return await gitExec.executeGitCommandBuffered(cwd, args, options);
},
},
},
),
);
try {
await within(
Promise.race([
entered.promise,
pending.then(() => {
throw new Error("Sizing ended before caller Git was held");
}),
]),
);
current = false;
release.resolve();
const result = await within(pending);
expect(result.rejected && result.error).toBe(revoked);
const starts = (await exists(trace))
? await traceStarts(trace, transport === "text" ? "rev-parse" : "rev-list")
: [];
expect(starts).toHaveLength(0);
} finally {
current = false;
release.resolve();
await pending;
}
},
);
it.each(["worker-error", "cancel"] as const)(
"removes only its snapshot temporary directory after %s",
async (ending) => {

View file

@ -98,6 +98,11 @@ export type GitWorkerOperationOptions = {
transferList?: (command: GitWorkerCommand) => readonly Transferable[];
signal?: AbortSignal;
assertCurrent?: () => void;
/** Host-owned Git policy; the broker still owns authority and process settlement. */
git?: {
text: typeof runGitBytes;
buffered: typeof runGitBuffered;
};
onInventoryChunk?: (bytes: Uint8Array, context: { signal: AbortSignal }) => Promise<void>;
onEffect?: (
effect: GitWorktreeEffect,
@ -125,7 +130,10 @@ export async function runGitWorkerOperation<Command extends GitWorkerCommand>(
? structuredClone(command, { transfer: [...new Set(transferList)] })
: structuredClone(command);
const baseEnv = { ...process.env };
const operation = executeOperation(poolFor(state, admitted), admitted, baseEnv, { ...options });
const operation = executeOperation(poolFor(state, admitted), admitted, baseEnv, {
...options,
git: options.git ? { text: options.git.text, buffered: options.git.buffered } : undefined,
});
state.pending.add(operation);
void operation.then(
() => state.pending.delete(operation),
@ -156,25 +164,33 @@ async function executeOperation(
let result: unknown;
const transferList: Transferable[] = [];
if (effect.type === "git.text") {
const output = await runGitBytes(effect.input.cwd, effect.input.args, {
...effect.input.options,
baseEnv,
signal,
beforeRun: options.assertCurrent,
killProcessTree: true,
});
const output = await (options.git?.text ?? runGitBytes)(
effect.input.cwd,
effect.input.args,
{
...effect.input.options,
baseEnv,
signal,
beforeRun: options.assertCurrent,
killProcessTree: true,
},
);
const stdout = ownedGitWorkerBytes(output.stdout);
const stderr = ownedGitWorkerBytes(output.stderr);
result = { ...output, stdout, stderr };
transferList.push(stdout.buffer, stderr.buffer);
} else if (effect.type === "git.buffer") {
const output = await runGitBuffered(effect.input.cwd, effect.input.args, {
...effect.input.options,
baseEnv,
signal,
beforeRun: options.assertCurrent,
killProcessTree: true,
});
const output = await (options.git?.buffered ?? runGitBuffered)(
effect.input.cwd,
effect.input.args,
{
...effect.input.options,
baseEnv,
signal,
beforeRun: options.assertCurrent,
killProcessTree: true,
},
);
const stdout = ownedGitWorkerBytes(output.stdout);
const stderr = ownedGitWorkerBytes(output.stderr);
result = { ...output, stdout, stderr };