From 5f3ca5a8553dd753fb2e8a6fe258c97432059538 Mon Sep 17 00:00:00 2001 From: "Jason (Json)" <263060202+fuller-stack-dev@users.noreply.github.com> Date: Tue, 15 Sep 2026 23:37:31 -0600 Subject: [PATCH] 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 --- .openclaw/worktree-profiles/gateway | 23 + docs/concepts/managed-worktrees.md | 53 +- src/agents/worktrees/allocation.ts | 39 ++ src/agents/worktrees/capacity.test.ts | 30 ++ src/agents/worktrees/capacity.ts | 2 +- src/agents/worktrees/checkout-profiles.ts | 109 ++++ src/agents/worktrees/checkout.ts | 165 +++++- .../filesystem-backend.test-support.ts | 18 + .../worktrees/service.acceleration.test.ts | 298 ++++++++++- src/agents/worktrees/service.profiles.test.ts | 481 ++++++++++++++++++ .../service.sparse-isolation.test.ts | 217 ++++++++ src/agents/worktrees/service.ts | 82 +-- src/agents/worktrees/types.ts | 2 + src/cli/worktrees-cli.test.ts | 103 ++++ src/cli/worktrees-cli.ts | 33 +- src/infra/git-worker.lifecycle.test.ts | 158 +++++- src/infra/git-worker.ts | 46 +- 17 files changed, 1766 insertions(+), 93 deletions(-) create mode 100644 .openclaw/worktree-profiles/gateway create mode 100644 src/agents/worktrees/allocation.ts create mode 100644 src/agents/worktrees/checkout-profiles.ts create mode 100644 src/agents/worktrees/filesystem-backend.test-support.ts create mode 100644 src/agents/worktrees/service.profiles.test.ts create mode 100644 src/agents/worktrees/service.sparse-isolation.test.ts diff --git a/.openclaw/worktree-profiles/gateway b/.openclaw/worktree-profiles/gateway new file mode 100644 index 000000000000..0ca41f59493a --- /dev/null +++ b/.openclaw/worktree-profiles/gateway @@ -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 diff --git a/docs/concepts/managed-worktrees.md b/docs/concepts/managed-worktrees.md index 40c1d040328c..71e062aa2954 100644 --- a/docs/concepts/managed-worktrees.md +++ b/docs/concepts/managed-worktrees.md @@ -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/`. 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 [--name ] [--base-ref ] [--json] +openclaw worktrees create [--name ] [--base-ref ] [--source-profile ]... [--json] openclaw worktrees remove [--force | --if-lossless] [--json] openclaw worktrees restore [--json] openclaw worktrees gc [--json] diff --git a/src/agents/worktrees/allocation.ts b/src/agents/worktrees/allocation.ts new file mode 100644 index 000000000000..f76cbcc48d7f --- /dev/null +++ b/src/agents/worktrees/allocation.ts @@ -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( + params: { + env: NodeJS.ProcessEnv; + signal?: AbortSignal; + commitGuard?: () => void; + }, + run: (guard: WorktreeFilesystemOptions) => Promise, +): Promise { + // 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?.(); + }, + }), + ); +} diff --git a/src/agents/worktrees/capacity.test.ts b/src/agents/worktrees/capacity.test.ts index c5d6036f481f..4d80e9343781 100644 --- a/src/agents/worktrees/capacity.test.ts +++ b/src/agents/worktrees/capacity.test.ts @@ -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) => { diff --git a/src/agents/worktrees/capacity.ts b/src/agents/worktrees/capacity.ts index 9dd22e61d889..3cf0971d65f4 100644 --- a/src/agents/worktrees/capacity.ts +++ b/src/agents/worktrees/capacity.ts @@ -54,7 +54,7 @@ export function requireWorktreeDiskSpace( export async function estimateWorktreeGitBytes( repoRoot: string, ref: string, - options: Pick = {}, + options: Pick = {}, ): Promise { return await runGitWorkerOperation( { diff --git a/src/agents/worktrees/checkout-profiles.ts b/src/agents/worktrees/checkout-profiles.ts new file mode 100644 index 000000000000..1a7f769483e7 --- /dev/null +++ b/src/agents/worktrees/checkout-profiles.ts @@ -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 { + 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() }; +} diff --git a/src/agents/worktrees/checkout.ts b/src/agents/worktrees/checkout.ts index 9976c73a989f..9e13cc832407 100644 --- a/src/agents/worktrees/checkout.ts +++ b/src/agents/worktrees/checkout.ts @@ -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 { +export async function addManagedWorktree(inputOptions: CheckoutOptions): Promise { + 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>; 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 { + 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 { @@ -451,8 +592,8 @@ async function removeFailedCheckout(options: CheckoutOptions): Promise { ["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)); } } diff --git a/src/agents/worktrees/filesystem-backend.test-support.ts b/src/agents/worktrees/filesystem-backend.test-support.ts new file mode 100644 index 000000000000..967a6eced044 --- /dev/null +++ b/src/agents/worktrees/filesystem-backend.test-support.ts @@ -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 }); + }), + }; +} diff --git a/src/agents/worktrees/service.acceleration.test.ts b/src/agents/worktrees/service.acceleration.test.ts index 96cea48bc98f..280df8c63396 100644 --- a/src/agents/worktrees/service.acceleration.test.ts +++ b/src/agents/worktrees/service.acceleration.test.ts @@ -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) => { diff --git a/src/agents/worktrees/service.profiles.test.ts b/src/agents/worktrees/service.profiles.test.ts new file mode 100644 index 000000000000..60d9c71ff115 --- /dev/null +++ b/src/agents/worktrees/service.profiles.test.ts @@ -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/); + }); +}); diff --git a/src/agents/worktrees/service.sparse-isolation.test.ts b/src/agents/worktrees/service.sparse-isolation.test.ts new file mode 100644 index 000000000000..5f782494425c --- /dev/null +++ b/src/agents/worktrees/service.sparse-isolation.test.ts @@ -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 { + 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([["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); + } + }, + ); +}); diff --git a/src/agents/worktrees/service.ts b/src/agents/worktrees/service.ts index 233a6ad793e4..8b3b6dbdf766 100644 --- a/src/agents/worktrees/service.ts +++ b/src/agents/worktrees/service.ts @@ -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 & { + 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, ): Promise { - // 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( diff --git a/src/agents/worktrees/types.ts b/src/agents/worktrees/types.ts index e454a6c20911..cd777188478f 100644 --- a/src/agents/worktrees/types.ts +++ b/src/agents/worktrees/types.ts @@ -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; diff --git a/src/cli/worktrees-cli.test.ts b/src/cli/worktrees-cli.test.ts index 031ae2e506c2..d2485a3b77b0 100644 --- a/src/cli/worktrees-cli.test.ts +++ b/src/cli/worktrees-cli.test.ts @@ -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) => { diff --git a/src/cli/worktrees-cli.ts b/src/cli/worktrees-cli.ts index 8068ef61d650..1af3a631e7f5 100644 --- a/src/cli/worktrees-cli.ts +++ b/src/cli/worktrees-cli.ts @@ -67,18 +67,29 @@ export function registerWorktreesCli(program: Command): void { .argument("", "Source git checkout") .option("--name ", "Managed worktree name") .option("--base-ref ", "Git ref to branch from") + .option( + "--source-profile ", + "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") diff --git a/src/infra/git-worker.lifecycle.test.ts b/src/infra/git-worker.lifecycle.test.ts index ec6c8fc1ea3e..e5b0d0cf35b2 100644 --- a/src/infra/git-worker.lifecycle.test.ts +++ b/src/infra/git-worker.lifecycle.test.ts @@ -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[] = [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 = { + 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) => { diff --git a/src/infra/git-worker.ts b/src/infra/git-worker.ts index c243f7a83942..746c9527f43a 100644 --- a/src/infra/git-worker.ts +++ b/src/infra/git-worker.ts @@ -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; onEffect?: ( effect: GitWorktreeEffect, @@ -125,7 +130,10 @@ export async function runGitWorkerOperation( ? 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 };