mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 01:29:56 +00:00
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:
parent
fbf926add8
commit
5f3ca5a855
17 changed files with 1766 additions and 93 deletions
23
.openclaw/worktree-profiles/gateway
Normal file
23
.openclaw/worktree-profiles/gateway
Normal 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
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
39
src/agents/worktrees/allocation.ts
Normal file
39
src/agents/worktrees/allocation.ts
Normal 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?.();
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
{
|
||||
|
|
|
|||
109
src/agents/worktrees/checkout-profiles.ts
Normal file
109
src/agents/worktrees/checkout-profiles.ts
Normal 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() };
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
18
src/agents/worktrees/filesystem-backend.test-support.ts
Normal file
18
src/agents/worktrees/filesystem-backend.test-support.ts
Normal 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 });
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
|
@ -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) => {
|
||||
|
|
|
|||
481
src/agents/worktrees/service.profiles.test.ts
Normal file
481
src/agents/worktrees/service.profiles.test.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
217
src/agents/worktrees/service.sparse-isolation.test.ts
Normal file
217
src/agents/worktrees/service.sparse-isolation.test.ts
Normal 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);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue