mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 05:16:23 -06:00
test(agents): reuse managed worktree fixtures (#118619)
Co-authored-by: Peter Steinberger <steipete@mac-studio-sf2.local>
This commit is contained in:
committed by
GitHub
parent
a7396255d0
commit
cc940b5ff5
@@ -77,6 +77,7 @@ describe("worktree run lease", () => {
|
||||
const created = await service.create({
|
||||
repoRoot: repo,
|
||||
name: "run-lease-session",
|
||||
baseRef: "HEAD",
|
||||
ownerKind: "session",
|
||||
ownerId: "agent:main:run-lease",
|
||||
});
|
||||
|
||||
@@ -42,9 +42,13 @@ describe("ManagedWorktreeService naming", () => {
|
||||
});
|
||||
|
||||
it("uses readable defaults and numbers colliding inferred names", async () => {
|
||||
const fallback = await service.create({ repoRoot: repo });
|
||||
await service.create({ repoRoot: repo, name: "release-planning" });
|
||||
const second = await service.create({ repoRoot: repo, suggestedName: "release-planning" });
|
||||
const fallback = await service.create({ repoRoot: repo, baseRef: "HEAD" });
|
||||
await service.create({ repoRoot: repo, name: "release-planning", baseRef: "HEAD" });
|
||||
const second = await service.create({
|
||||
repoRoot: repo,
|
||||
suggestedName: "release-planning",
|
||||
baseRef: "HEAD",
|
||||
});
|
||||
|
||||
expect(fallback.name).toMatch(
|
||||
/^[a-z]+-(?:barnacle|claw|crab|crayfish|krill|langoustine|lobster|prawn|shrimp|shell)$/,
|
||||
@@ -53,19 +57,23 @@ describe("ManagedWorktreeService naming", () => {
|
||||
});
|
||||
|
||||
it("numbers inferred names around unmanaged Git and filesystem collisions", async () => {
|
||||
const anchor = await service.create({ repoRoot: repo, name: "anchor" });
|
||||
const anchor = await service.create({ repoRoot: repo, name: "anchor", baseRef: "HEAD" });
|
||||
await git(repo, "branch", "openclaw/release-planning");
|
||||
await fs.mkdir(path.join(path.dirname(anchor.path), "release-planning-2"));
|
||||
|
||||
const created = await service.create({ repoRoot: repo, suggestedName: "release-planning" });
|
||||
const created = await service.create({
|
||||
repoRoot: repo,
|
||||
suggestedName: "release-planning",
|
||||
baseRef: "HEAD",
|
||||
});
|
||||
|
||||
expect(created.name).toBe("release-planning-3");
|
||||
});
|
||||
|
||||
it("serializes concurrent inferred-name creation", async () => {
|
||||
const created = await Promise.all([
|
||||
service.create({ repoRoot: repo, suggestedName: "concurrent-task" }),
|
||||
service.create({ repoRoot: repo, suggestedName: "concurrent-task" }),
|
||||
service.create({ repoRoot: repo, suggestedName: "concurrent-task", baseRef: "HEAD" }),
|
||||
service.create({ repoRoot: repo, suggestedName: "concurrent-task", baseRef: "HEAD" }),
|
||||
]);
|
||||
|
||||
expect(created.map((record) => record.name).toSorted()).toEqual([
|
||||
@@ -77,6 +85,7 @@ describe("ManagedWorktreeService naming", () => {
|
||||
it("reuses concurrent inferred names for the same owner", async () => {
|
||||
const owner = {
|
||||
repoRoot: repo,
|
||||
baseRef: "HEAD",
|
||||
ownerKind: "session" as const,
|
||||
ownerId: "agent:main:session-1",
|
||||
};
|
||||
@@ -93,11 +102,11 @@ describe("ManagedWorktreeService naming", () => {
|
||||
});
|
||||
|
||||
it("serializes overlapping numeric suffix families", async () => {
|
||||
await service.create({ repoRoot: repo, name: "task" });
|
||||
await service.create({ repoRoot: repo, name: "task", baseRef: "HEAD" });
|
||||
|
||||
const created = await Promise.all([
|
||||
service.create({ repoRoot: repo, suggestedName: "task" }),
|
||||
service.create({ repoRoot: repo, suggestedName: "task-2" }),
|
||||
service.create({ repoRoot: repo, suggestedName: "task", baseRef: "HEAD" }),
|
||||
service.create({ repoRoot: repo, suggestedName: "task-2", baseRef: "HEAD" }),
|
||||
]);
|
||||
const names = created.map((record) => record.name);
|
||||
|
||||
|
||||
@@ -83,7 +83,7 @@ describe("ManagedWorktreeService provisioned state", () => {
|
||||
await fs.writeFile(path.join(repo, "large.local"), source);
|
||||
await addRemote(root, repo);
|
||||
|
||||
const created = await service.create({ repoRoot: repo, name: "large-local" });
|
||||
const created = await service.create({ repoRoot: repo, name: "large-local", baseRef: "HEAD" });
|
||||
await service.acquire(created.id);
|
||||
const copyPath = path.join(created.path, "large.local");
|
||||
const copy = Buffer.from(source);
|
||||
@@ -105,9 +105,21 @@ describe("ManagedWorktreeService provisioned state", () => {
|
||||
await fs.writeFile(path.join(repo, "settings.local"), "theme=source\n");
|
||||
await addRemote(root, repo);
|
||||
|
||||
const manifestRemoved = await service.create({ repoRoot: repo, name: "manifest-removed" });
|
||||
const patternRemoved = await service.create({ repoRoot: repo, name: "pattern-removed" });
|
||||
const restorable = await service.create({ repoRoot: repo, name: "manifest-restorable" });
|
||||
const manifestRemoved = await service.create({
|
||||
repoRoot: repo,
|
||||
name: "manifest-removed",
|
||||
baseRef: "HEAD",
|
||||
});
|
||||
const patternRemoved = await service.create({
|
||||
repoRoot: repo,
|
||||
name: "pattern-removed",
|
||||
baseRef: "HEAD",
|
||||
});
|
||||
const restorable = await service.create({
|
||||
repoRoot: repo,
|
||||
name: "manifest-restorable",
|
||||
baseRef: "HEAD",
|
||||
});
|
||||
await service.acquire(manifestRemoved.id);
|
||||
await service.acquire(patternRemoved.id);
|
||||
await service.acquire(restorable.id);
|
||||
@@ -177,7 +189,11 @@ describe("ManagedWorktreeService provisioned state", () => {
|
||||
await git(repo, "commit", "-m", "configure worktree provisioning");
|
||||
await fs.writeFile(path.join(repo, ".env.local"), "value=source\n");
|
||||
|
||||
const tracked = await service.create({ repoRoot: repo, name: "tracked-provisioned" });
|
||||
const tracked = await service.create({
|
||||
repoRoot: repo,
|
||||
name: "tracked-provisioned",
|
||||
baseRef: "HEAD",
|
||||
});
|
||||
await git(tracked.path, "add", "-f", ".env.local");
|
||||
await git(tracked.path, "commit", "-m", "track provisioned file");
|
||||
await expect(service.remove({ id: tracked.id, reason: "manual" })).rejects.toThrow(
|
||||
@@ -188,7 +204,11 @@ describe("ManagedWorktreeService provisioned state", () => {
|
||||
"provisioned path is tracked at HEAD",
|
||||
);
|
||||
|
||||
const unignored = await service.create({ repoRoot: repo, name: "unignored-provisioned" });
|
||||
const unignored = await service.create({
|
||||
repoRoot: repo,
|
||||
name: "unignored-provisioned",
|
||||
baseRef: "HEAD",
|
||||
});
|
||||
await fs.writeFile(path.join(unignored.path, ".gitignore"), "");
|
||||
await expect(service.remove({ id: unignored.id, reason: "manual" })).rejects.toThrow(
|
||||
"provisioned path is no longer ignored",
|
||||
@@ -212,7 +232,11 @@ describe("ManagedWorktreeService provisioned state", () => {
|
||||
await fs.writeFile(path.join(repo, wildcardName), "wildcard source\n");
|
||||
await fs.writeFile(path.join(repo, backslashName), "backslash source\n");
|
||||
|
||||
const created = await service.create({ repoRoot: repo, name: "literal-paths" });
|
||||
const created = await service.create({
|
||||
repoRoot: repo,
|
||||
name: "literal-paths",
|
||||
baseRef: "HEAD",
|
||||
});
|
||||
await fs.writeFile(path.join(created.path, wildcardName), "wildcard local\n");
|
||||
await fs.writeFile(path.join(created.path, backslashName), "backslash local\n");
|
||||
await service.remove({ id: created.id, reason: "test" });
|
||||
@@ -228,7 +252,11 @@ describe("ManagedWorktreeService provisioned state", () => {
|
||||
);
|
||||
|
||||
it("snapshots deleted skip-worktree files still included by sparse rules", async () => {
|
||||
const created = await service.create({ repoRoot: repo, name: "stale-sparse-bit" });
|
||||
const created = await service.create({
|
||||
repoRoot: repo,
|
||||
name: "stale-sparse-bit",
|
||||
baseRef: "HEAD",
|
||||
});
|
||||
await git(created.path, "sparse-checkout", "set", "--no-cone", "/*");
|
||||
await git(created.path, "update-index", "--skip-worktree", "README.md");
|
||||
await fs.rm(path.join(created.path, "README.md"));
|
||||
@@ -250,7 +278,7 @@ describe("ManagedWorktreeService provisioned state", () => {
|
||||
await git(repo, "add", "-A");
|
||||
await git(repo, "commit", "-m", "add raw path");
|
||||
|
||||
const created = await service.create({ repoRoot: repo, name: "raw-path" });
|
||||
const created = await service.create({ repoRoot: repo, name: "raw-path", baseRef: "HEAD" });
|
||||
const worktreePath = Buffer.concat([
|
||||
Buffer.from(created.path),
|
||||
Buffer.from(path.sep),
|
||||
|
||||
@@ -55,6 +55,7 @@ describe("ManagedWorktreeService removal against a live run lease", () => {
|
||||
const created = await service.create({
|
||||
repoRoot: repo,
|
||||
name: "removal-session",
|
||||
baseRef: "HEAD",
|
||||
ownerKind: "session",
|
||||
ownerId: "agent:main:removal",
|
||||
});
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { constants as fsConstants } from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { insertRegistryWorktree } from "./registry.js";
|
||||
import type { ManagedWorktreeOwnerKind, ManagedWorktreeRecord } from "./types.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
async function git(cwd: string, ...args: string[]): Promise<void> {
|
||||
await execFileAsync("git", ["-C", cwd, ...args]);
|
||||
}
|
||||
|
||||
async function copyProvisionedFiles(params: {
|
||||
repoRoot: string;
|
||||
worktreePath: string;
|
||||
provisionedPaths: readonly string[];
|
||||
}): Promise<void> {
|
||||
for (const provisionedPath of params.provisionedPaths) {
|
||||
const source = path.join(params.repoRoot, provisionedPath);
|
||||
const target = path.join(params.worktreePath, provisionedPath);
|
||||
const sourceStat = await fs.lstat(source);
|
||||
await fs.mkdir(path.dirname(target), { recursive: true });
|
||||
await fs.copyFile(source, target, fsConstants.COPYFILE_FICLONE);
|
||||
if (process.platform !== "win32") {
|
||||
await fs.chmod(target, sourceStat.mode & 0o7777);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function materializeManagedWorktreeFixture(params: {
|
||||
env: NodeJS.ProcessEnv;
|
||||
name: string;
|
||||
now: number;
|
||||
ownerKind?: ManagedWorktreeOwnerKind;
|
||||
ownerId?: string;
|
||||
provisionedPaths?: readonly string[];
|
||||
repoRoot: string;
|
||||
stateDir: string;
|
||||
}): Promise<ManagedWorktreeRecord> {
|
||||
const repoFingerprint = "downstream-fixture";
|
||||
const worktreePath = path.join(params.stateDir, "worktrees", repoFingerprint, params.name);
|
||||
const branch = `openclaw/${params.name}`;
|
||||
await fs.mkdir(path.dirname(worktreePath), { recursive: true });
|
||||
await git(params.repoRoot, "worktree", "add", "-b", branch, "--", worktreePath, "HEAD");
|
||||
const provisionedPaths = params.provisionedPaths ?? [];
|
||||
await copyProvisionedFiles({
|
||||
repoRoot: params.repoRoot,
|
||||
worktreePath,
|
||||
provisionedPaths,
|
||||
});
|
||||
const record: ManagedWorktreeRecord = {
|
||||
id: `fixture-${params.name}`,
|
||||
name: params.name,
|
||||
repoFingerprint,
|
||||
repoRoot: params.repoRoot,
|
||||
path: worktreePath,
|
||||
branch,
|
||||
baseRef: "HEAD",
|
||||
ownerKind: params.ownerKind ?? "manual",
|
||||
...(params.ownerId ? { ownerId: params.ownerId } : {}),
|
||||
createdAt: params.now,
|
||||
lastActiveAt: params.now,
|
||||
};
|
||||
insertRegistryWorktree(params.env, record, { provisionedPaths });
|
||||
return record;
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
resolveWorktreeCleanupLimits,
|
||||
SNAPSHOT_RETENTION_MS,
|
||||
} from "./service.js";
|
||||
import { materializeManagedWorktreeFixture } from "./service.test-support.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
@@ -79,6 +80,34 @@ describe("ManagedWorktreeService", () => {
|
||||
let env: NodeJS.ProcessEnv;
|
||||
let now: number;
|
||||
let service: ManagedWorktreeService;
|
||||
let caseOrdinal = 0;
|
||||
|
||||
// Snapshot/removal/GC tests need the real Git-worktree + registry boundary,
|
||||
// while create policy and provisioning composition stay covered above.
|
||||
async function materializeDownstreamFixture(
|
||||
name: string,
|
||||
params: {
|
||||
ownerKind?: "manual" | "session" | "workboard";
|
||||
ownerId?: string;
|
||||
provisionedPaths?: readonly string[];
|
||||
repoRoot?: string;
|
||||
} = {},
|
||||
) {
|
||||
return await materializeManagedWorktreeFixture({
|
||||
env,
|
||||
name,
|
||||
now,
|
||||
repoRoot: params.repoRoot ?? repo,
|
||||
stateDir,
|
||||
...params,
|
||||
});
|
||||
}
|
||||
|
||||
const materializeRunOwnedFixture = (
|
||||
name: string,
|
||||
ownerKind: "session" | "workboard",
|
||||
ownerId?: string,
|
||||
) => materializeDownstreamFixture(name, { ownerKind, ownerId });
|
||||
|
||||
beforeAll(async () => {
|
||||
const tempRoot = await fs.realpath(os.tmpdir());
|
||||
@@ -97,8 +126,8 @@ describe("ManagedWorktreeService", () => {
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
const tempRoot = await fs.realpath(os.tmpdir());
|
||||
root = await fs.mkdtemp(path.join(tempRoot, "openclaw-managed-worktrees-"));
|
||||
root = path.join(templateRoot, `case-${caseOrdinal++}`);
|
||||
await fs.mkdir(root);
|
||||
repo = path.join(root, "repo");
|
||||
await fs.cp(templateRepo, repo, {
|
||||
mode: fsConstants.COPYFILE_FICLONE,
|
||||
@@ -116,7 +145,6 @@ describe("ManagedWorktreeService", () => {
|
||||
deleteRegistryWorktree(env, record.id);
|
||||
}
|
||||
await fs.rm(path.join(stateDir, "worktrees"), { recursive: true, force: true });
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("creates from origin HEAD and returns the existing live named worktree", async () => {
|
||||
@@ -135,6 +163,7 @@ describe("ManagedWorktreeService", () => {
|
||||
const created = await service.create({
|
||||
repoRoot: repo,
|
||||
name: "session-owned",
|
||||
baseRef: "HEAD",
|
||||
ownerKind: "session",
|
||||
ownerId: "session-1",
|
||||
});
|
||||
@@ -322,6 +351,7 @@ describe("ManagedWorktreeService", () => {
|
||||
await service.create({
|
||||
repoRoot: repo,
|
||||
name: "shared-name",
|
||||
baseRef: "HEAD",
|
||||
ownerKind: "session",
|
||||
ownerId: "agent:main:dashboard:one",
|
||||
});
|
||||
@@ -329,17 +359,19 @@ describe("ManagedWorktreeService", () => {
|
||||
service.create({
|
||||
repoRoot: repo,
|
||||
name: "shared-name",
|
||||
baseRef: "HEAD",
|
||||
ownerKind: "session",
|
||||
ownerId: "agent:main:dashboard:two",
|
||||
}),
|
||||
).rejects.toThrow(/already in use by session/);
|
||||
await expect(service.create({ repoRoot: repo, name: "shared-name" })).rejects.toThrow(
|
||||
/already in use by session/,
|
||||
);
|
||||
await expect(
|
||||
service.create({ repoRoot: repo, name: "shared-name", baseRef: "HEAD" }),
|
||||
).rejects.toThrow(/already in use by session/);
|
||||
// The rightful owner still reuses its record.
|
||||
const reused = await service.create({
|
||||
repoRoot: repo,
|
||||
name: "shared-name",
|
||||
baseRef: "HEAD",
|
||||
ownerKind: "session",
|
||||
ownerId: "agent:main:dashboard:one",
|
||||
});
|
||||
@@ -377,7 +409,11 @@ describe("ManagedWorktreeService", () => {
|
||||
const linked = path.join(root, "linked-source");
|
||||
await git(repo, "worktree", "add", "-b", "linked-source", linked, "HEAD");
|
||||
const linkedRoot = await fs.realpath(linked);
|
||||
const created = await service.create({ repoRoot: linkedRoot, name: "linked-task" });
|
||||
const created = await service.create({
|
||||
repoRoot: linkedRoot,
|
||||
name: "linked-task",
|
||||
baseRef: "HEAD",
|
||||
});
|
||||
expect(created.repoRoot).toBe(repo);
|
||||
await git(repo, "worktree", "remove", "--force", linkedRoot);
|
||||
|
||||
@@ -431,7 +467,7 @@ describe("ManagedWorktreeService", () => {
|
||||
await fs.writeFile(path.join(outsideDir, "escape.txt"), "outside\n");
|
||||
await fs.symlink(outsideDir, path.join(repo, "linked-dir"));
|
||||
|
||||
const created = await service.create({ repoRoot: repo, name: "includes" });
|
||||
const created = await service.create({ repoRoot: repo, name: "includes", baseRef: "HEAD" });
|
||||
const copied = path.join(created.path, "cache", "keep.txt");
|
||||
expect(await fs.readFile(copied, "utf8")).toBe("keep\n");
|
||||
expect((await fs.stat(copied)).mode & 0o777).toBe(0o744);
|
||||
@@ -478,7 +514,7 @@ describe("ManagedWorktreeService", () => {
|
||||
'#!/bin/sh\nprintf "%s\\n%s\\n" "$OPENCLAW_SOURCE_TREE_PATH" "$OPENCLAW_WORKTREE_PATH" > setup-paths.txt\n',
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
const created = await service.create({ repoRoot: repo, name: "setup" });
|
||||
const created = await service.create({ repoRoot: repo, name: "setup", baseRef: "HEAD" });
|
||||
expect(
|
||||
(await fs.readFile(path.join(created.path, "setup-paths.txt"), "utf8")).split("\n"),
|
||||
).toEqual([repo, created.path, ""]);
|
||||
@@ -499,7 +535,12 @@ describe("ManagedWorktreeService", () => {
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
|
||||
await service.create({ repoRoot: repo, name: "no-repo-code", runSetupScript: false });
|
||||
await service.create({
|
||||
repoRoot: repo,
|
||||
name: "no-repo-code",
|
||||
baseRef: "HEAD",
|
||||
runSetupScript: false,
|
||||
});
|
||||
|
||||
await expect(fs.access(hookMarker)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
await expect(fs.access(setupMarker)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
@@ -509,9 +550,9 @@ describe("ManagedWorktreeService", () => {
|
||||
await fs.mkdir(path.join(repo, ".openclaw"));
|
||||
const script = path.join(repo, ".openclaw", "worktree-setup.sh");
|
||||
await fs.writeFile(script, "#!/bin/sh\necho setup-broke >&2\nexit 9\n", { mode: 0o755 });
|
||||
await expect(service.create({ repoRoot: repo, name: "broken-setup" })).rejects.toThrow(
|
||||
"setup-broke",
|
||||
);
|
||||
await expect(
|
||||
service.create({ repoRoot: repo, name: "broken-setup", baseRef: "HEAD" }),
|
||||
).rejects.toThrow("setup-broke");
|
||||
expect(await git(repo, "worktree", "list", "--porcelain")).not.toContain("broken-setup");
|
||||
expect(await git(repo, "branch", "--list", "openclaw/broken-setup")).toBe("");
|
||||
});
|
||||
@@ -523,7 +564,9 @@ describe("ManagedWorktreeService", () => {
|
||||
await git(repo, "commit", "-m", "configure worktree provisioning");
|
||||
await fs.writeFile(path.join(repo, "provisioned.env"), "source value\n");
|
||||
const mode = (await fs.stat(path.join(repo, "provisioned.env"))).mode & 0o7777;
|
||||
const created = await service.create({ repoRoot: repo, name: "roundtrip" });
|
||||
const created = await materializeDownstreamFixture("roundtrip", {
|
||||
provisionedPaths: ["provisioned.env"],
|
||||
});
|
||||
const originalHead = await git(created.path, "rev-parse", "HEAD");
|
||||
await fs.writeFile(path.join(created.path, "README.md"), "changed\n");
|
||||
await fs.writeFile(path.join(created.path, "untracked.txt"), "untracked\n");
|
||||
@@ -583,7 +626,7 @@ describe("ManagedWorktreeService", () => {
|
||||
await git(repo, "commit", "-m", "add executable");
|
||||
await git(repo, "config", "core.filemode", "false");
|
||||
|
||||
const created = await service.create({ repoRoot: repo, name: "filemode" });
|
||||
const created = await materializeDownstreamFixture("filemode");
|
||||
await fs.chmod(path.join(created.path, "tool.sh"), 0o644);
|
||||
await fs.writeFile(path.join(created.path, "README.md"), "changed\n");
|
||||
const removed = await service.remove({ id: created.id, reason: "test" });
|
||||
@@ -592,7 +635,7 @@ describe("ManagedWorktreeService", () => {
|
||||
});
|
||||
|
||||
it("snapshots modified tracked files marked assume-unchanged", async () => {
|
||||
const created = await service.create({ repoRoot: repo, name: "assume-unchanged" });
|
||||
const created = await materializeDownstreamFixture("assume-unchanged");
|
||||
await git(created.path, "update-index", "--assume-unchanged", "README.md");
|
||||
await fs.writeFile(path.join(created.path, "README.md"), "hidden local change\n");
|
||||
expect(await git(created.path, "status", "--porcelain")).toBe("");
|
||||
@@ -606,7 +649,7 @@ describe("ManagedWorktreeService", () => {
|
||||
});
|
||||
|
||||
it("snapshots materialized tracked files marked skip-worktree", async () => {
|
||||
const created = await service.create({ repoRoot: repo, name: "skip-worktree" });
|
||||
const created = await materializeDownstreamFixture("skip-worktree");
|
||||
await git(created.path, "update-index", "--skip-worktree", "README.md");
|
||||
await fs.writeFile(path.join(created.path, "README.md"), "hidden sparse change\n");
|
||||
expect(await git(created.path, "status", "--porcelain")).toBe("");
|
||||
@@ -620,7 +663,7 @@ describe("ManagedWorktreeService", () => {
|
||||
});
|
||||
|
||||
it("snapshots deletions hidden by skip-worktree outside sparse checkout", async () => {
|
||||
const created = await service.create({ repoRoot: repo, name: "skip-worktree-deleted" });
|
||||
const created = await materializeDownstreamFixture("skip-worktree-deleted");
|
||||
await git(created.path, "update-index", "--skip-worktree", "README.md");
|
||||
await fs.rm(path.join(created.path, "README.md"));
|
||||
expect(await git(created.path, "status", "--porcelain")).toBe("");
|
||||
@@ -634,7 +677,7 @@ describe("ManagedWorktreeService", () => {
|
||||
});
|
||||
|
||||
it("refuses to overwrite a branch recreated before restore", async () => {
|
||||
const created = await service.create({ repoRoot: repo, name: "restore-collision" });
|
||||
const created = await materializeDownstreamFixture("restore-collision");
|
||||
await service.remove({ id: created.id, reason: "test" });
|
||||
await git(repo, "branch", created.branch, "HEAD");
|
||||
const branchTip = await git(repo, "rev-parse", created.branch);
|
||||
@@ -655,7 +698,7 @@ describe("ManagedWorktreeService", () => {
|
||||
await fs.writeFile(path.join(repo, "tracked", "outer.txt"), "tracked\n");
|
||||
await git(repo, "add", "tracked/outer.txt");
|
||||
await git(repo, "commit", "-m", "add tracked parent");
|
||||
const created = await service.create({ repoRoot: repo, name: "nested-repository" });
|
||||
const created = await materializeDownstreamFixture("nested-repository");
|
||||
const nested = await initializeRepository(
|
||||
path.join(created.path, "tracked"),
|
||||
gitTemplate,
|
||||
@@ -684,6 +727,7 @@ describe("ManagedWorktreeService", () => {
|
||||
const created = await service.create({
|
||||
repoRoot: repo,
|
||||
name: "wb-card",
|
||||
baseRef: "HEAD",
|
||||
ownerKind: "workboard",
|
||||
ownerId: "card",
|
||||
});
|
||||
@@ -706,11 +750,11 @@ describe("ManagedWorktreeService", () => {
|
||||
|
||||
it("removes lossless run-end worktrees but keeps dirty and unpushed work", async () => {
|
||||
await addRemote(root, repo);
|
||||
const clean = await service.create({ repoRoot: repo, name: "clean" });
|
||||
const clean = await materializeDownstreamFixture("clean");
|
||||
await service.acquire(clean.id);
|
||||
expect(await service.removeIfLossless(clean.id)).toBe(true);
|
||||
|
||||
const dirty = await service.create({ repoRoot: repo, name: "dirty" });
|
||||
const dirty = await materializeDownstreamFixture("dirty");
|
||||
await service.acquire(dirty.id);
|
||||
await fs.writeFile(path.join(dirty.path, "dirty.txt"), "dirty\n");
|
||||
expect(await service.removeIfLossless(dirty.id)).toBe(false);
|
||||
@@ -718,7 +762,7 @@ describe("ManagedWorktreeService", () => {
|
||||
(await service.list()).find((entry) => entry.id === dirty.id)?.removedAt,
|
||||
).toBeUndefined();
|
||||
|
||||
const committed = await service.create({ repoRoot: repo, name: "committed" });
|
||||
const committed = await materializeDownstreamFixture("committed");
|
||||
await service.acquire(committed.id);
|
||||
await fs.writeFile(path.join(committed.path, "commit.txt"), "commit\n");
|
||||
await git(committed.path, "add", "commit.txt");
|
||||
@@ -734,7 +778,9 @@ describe("ManagedWorktreeService", () => {
|
||||
await fs.writeFile(path.join(repo, ".env.local"), "value=old-source\n");
|
||||
await addRemote(root, repo);
|
||||
|
||||
const rotated = await service.create({ repoRoot: repo, name: "rotated-local" });
|
||||
const rotated = await materializeDownstreamFixture("rotated-local", {
|
||||
provisionedPaths: [".env.local"],
|
||||
});
|
||||
await service.acquire(rotated.id);
|
||||
expect(await fs.readFile(path.join(rotated.path, ".env.local"), "utf8")).toBe(
|
||||
"value=old-source\n",
|
||||
@@ -748,13 +794,17 @@ describe("ManagedWorktreeService", () => {
|
||||
"value=rotated-only-copy\n",
|
||||
);
|
||||
|
||||
const rebuildable = await service.create({ repoRoot: repo, name: "rebuildable" });
|
||||
const rebuildable = await materializeDownstreamFixture("rebuildable", {
|
||||
provisionedPaths: [".env.local"],
|
||||
});
|
||||
await service.acquire(rebuildable.id);
|
||||
await fs.mkdir(path.join(rebuildable.path, "node_modules"), { recursive: true });
|
||||
await fs.writeFile(path.join(rebuildable.path, "node_modules", "cache.js"), "cache\n");
|
||||
expect(await service.removeIfLossless(rebuildable.id)).toBe(true);
|
||||
|
||||
const deleted = await service.create({ repoRoot: repo, name: "deleted-local" });
|
||||
const deleted = await materializeDownstreamFixture("deleted-local", {
|
||||
provisionedPaths: [".env.local"],
|
||||
});
|
||||
await service.acquire(deleted.id);
|
||||
const deletedCopy = path.join(deleted.path, ".env.local");
|
||||
await fs.rm(deletedCopy);
|
||||
@@ -776,7 +826,9 @@ describe("ManagedWorktreeService", () => {
|
||||
await fs.writeFile(sourcePath, "value=source\n", { mode: 0o644 });
|
||||
await addRemote(root, repo);
|
||||
|
||||
const executable = await service.create({ repoRoot: repo, name: "executable-local" });
|
||||
const executable = await materializeDownstreamFixture("executable-local", {
|
||||
provisionedPaths: [".env.local"],
|
||||
});
|
||||
await service.acquire(executable.id);
|
||||
const executableCopy = path.join(executable.path, ".env.local");
|
||||
await fs.chmod(executableCopy, 0o755);
|
||||
@@ -787,7 +839,9 @@ describe("ManagedWorktreeService", () => {
|
||||
0o755,
|
||||
);
|
||||
|
||||
const specialMode = await service.create({ repoRoot: repo, name: "special-mode-local" });
|
||||
const specialMode = await materializeDownstreamFixture("special-mode-local", {
|
||||
provisionedPaths: [".env.local"],
|
||||
});
|
||||
await service.acquire(specialMode.id);
|
||||
const specialModeCopy = path.join(specialMode.path, ".env.local");
|
||||
await fs.chmod(specialModeCopy, 0o1644);
|
||||
@@ -797,7 +851,9 @@ describe("ManagedWorktreeService", () => {
|
||||
(await fs.lstat(path.join(restoredSpecialMode.path, ".env.local"))).mode & 0o7777,
|
||||
).toBe(0o1644);
|
||||
|
||||
const linked = await service.create({ repoRoot: repo, name: "linked-local" });
|
||||
const linked = await materializeDownstreamFixture("linked-local", {
|
||||
provisionedPaths: [".env.local"],
|
||||
});
|
||||
await service.acquire(linked.id);
|
||||
const linkedCopy = path.join(linked.path, ".env.local");
|
||||
await fs.rm(linkedCopy);
|
||||
@@ -806,7 +862,9 @@ describe("ManagedWorktreeService", () => {
|
||||
expect(await service.removeIfLossless(linked.id)).toBe(false);
|
||||
expect((await fs.lstat(linkedCopy)).isSymbolicLink()).toBe(true);
|
||||
|
||||
const sourceLinked = await service.create({ repoRoot: repo, name: "source-linked-local" });
|
||||
const sourceLinked = await materializeDownstreamFixture("source-linked-local", {
|
||||
provisionedPaths: [".env.local"],
|
||||
});
|
||||
await service.acquire(sourceLinked.id);
|
||||
const outside = path.join(root, "same-local-value");
|
||||
await fs.writeFile(outside, "value=source\n");
|
||||
@@ -822,12 +880,8 @@ describe("ManagedWorktreeService", () => {
|
||||
);
|
||||
|
||||
it("exempts manual worktrees and garbage collects idle run-owned worktrees", async () => {
|
||||
const manual = await service.create({ repoRoot: repo, name: "manual-idle" });
|
||||
const created = await service.create({
|
||||
repoRoot: repo,
|
||||
name: "idle-dead",
|
||||
ownerKind: "workboard",
|
||||
});
|
||||
const manual = await materializeDownstreamFixture("manual-idle");
|
||||
const created = await materializeRunOwnedFixture("idle-dead", "workboard");
|
||||
await git(repo, "worktree", "lock", "--reason", "openclaw pid=999999", created.path);
|
||||
now += IDLE_GC_MS + 1;
|
||||
|
||||
@@ -845,10 +899,9 @@ describe("ManagedWorktreeService", () => {
|
||||
await git(repo, "commit", "-m", "configure worktree provisioning");
|
||||
await fs.writeFile(path.join(repo, ".env.local"), "value=old-source\n");
|
||||
|
||||
const created = await service.create({
|
||||
repoRoot: repo,
|
||||
name: "idle-rotated",
|
||||
const created = await materializeDownstreamFixture("idle-rotated", {
|
||||
ownerKind: "workboard",
|
||||
provisionedPaths: [".env.local"],
|
||||
});
|
||||
await fs.rm(path.join(repo, ".worktreeinclude"));
|
||||
await fs.writeFile(path.join(created.path, ".env.local"), "value=rotated-only-copy\n");
|
||||
@@ -863,18 +916,16 @@ describe("ManagedWorktreeService", () => {
|
||||
});
|
||||
|
||||
it("uses owner activity to protect only active idle session worktrees", async () => {
|
||||
const active = await service.create({
|
||||
repoRoot: repo,
|
||||
name: "active-session",
|
||||
ownerKind: "session",
|
||||
ownerId: "agent:main:active",
|
||||
});
|
||||
const inactive = await service.create({
|
||||
repoRoot: repo,
|
||||
name: "inactive-session",
|
||||
ownerKind: "session",
|
||||
ownerId: "agent:main:inactive",
|
||||
});
|
||||
const active = await materializeRunOwnedFixture(
|
||||
"active-session",
|
||||
"session",
|
||||
"agent:main:active",
|
||||
);
|
||||
const inactive = await materializeRunOwnedFixture(
|
||||
"inactive-session",
|
||||
"session",
|
||||
"agent:main:inactive",
|
||||
);
|
||||
now += IDLE_GC_MS + 1;
|
||||
const shouldProtectOwner = vi.fn(
|
||||
(_ownerKind: string, ownerId: string) => ownerId === "agent:main:active",
|
||||
@@ -890,11 +941,7 @@ describe("ManagedWorktreeService", () => {
|
||||
});
|
||||
|
||||
it("protects foreign locks during idle garbage collection", async () => {
|
||||
const created = await service.create({
|
||||
repoRoot: repo,
|
||||
name: "foreign-lock",
|
||||
ownerKind: "session",
|
||||
});
|
||||
const created = await materializeRunOwnedFixture("foreign-lock", "session");
|
||||
await git(repo, "worktree", "lock", "--reason", "other-tool", created.path);
|
||||
now += IDLE_GC_MS + 1;
|
||||
|
||||
@@ -903,17 +950,9 @@ describe("ManagedWorktreeService", () => {
|
||||
});
|
||||
|
||||
it("continues garbage collection after one worktree cannot be snapshotted", async () => {
|
||||
const removable = await service.create({
|
||||
repoRoot: repo,
|
||||
name: "removable",
|
||||
ownerKind: "workboard",
|
||||
});
|
||||
const removable = await materializeRunOwnedFixture("removable", "workboard");
|
||||
now += 1;
|
||||
const nestedRecord = await service.create({
|
||||
repoRoot: repo,
|
||||
name: "nested-idle",
|
||||
ownerKind: "workboard",
|
||||
});
|
||||
const nestedRecord = await materializeRunOwnedFixture("nested-idle", "workboard");
|
||||
await initializeRepository(nestedRecord.path, gitTemplate, "nested");
|
||||
now += IDLE_GC_MS + 1;
|
||||
|
||||
@@ -926,15 +965,12 @@ describe("ManagedWorktreeService", () => {
|
||||
|
||||
it("continues garbage collection when one repository control path is missing", async () => {
|
||||
const otherRepo = await initializeRepository(root, gitTemplate, "other-repo");
|
||||
const removable = await service.create({
|
||||
const removable = await materializeDownstreamFixture("other-removable", {
|
||||
repoRoot: otherRepo,
|
||||
name: "other-removable",
|
||||
ownerKind: "session",
|
||||
});
|
||||
now += 1;
|
||||
const broken = await service.create({
|
||||
repoRoot: repo,
|
||||
name: "missing-control",
|
||||
const broken = await materializeDownstreamFixture("missing-control", {
|
||||
ownerKind: "session",
|
||||
});
|
||||
await fs.rename(repo, path.join(root, "moved-repo"));
|
||||
@@ -962,27 +998,12 @@ describe("ManagedWorktreeService", () => {
|
||||
});
|
||||
|
||||
it("evicts the least recently active run-owned worktrees over the count limit", async () => {
|
||||
const manual = await service.create({ repoRoot: repo, name: "manual-kept" });
|
||||
const oldest = await service.create({
|
||||
repoRoot: repo,
|
||||
name: "count-oldest",
|
||||
ownerKind: "session",
|
||||
ownerId: "agent:main:oldest",
|
||||
});
|
||||
const manual = await materializeDownstreamFixture("manual-kept");
|
||||
const oldest = await materializeRunOwnedFixture("count-oldest", "session", "agent:main:oldest");
|
||||
now += 1;
|
||||
const middle = await service.create({
|
||||
repoRoot: repo,
|
||||
name: "count-middle",
|
||||
ownerKind: "workboard",
|
||||
ownerId: "card-middle",
|
||||
});
|
||||
const middle = await materializeRunOwnedFixture("count-middle", "workboard", "card-middle");
|
||||
now += 1;
|
||||
const newest = await service.create({
|
||||
repoRoot: repo,
|
||||
name: "count-newest",
|
||||
ownerKind: "session",
|
||||
ownerId: "agent:main:newest",
|
||||
});
|
||||
const newest = await materializeRunOwnedFixture("count-newest", "session", "agent:main:newest");
|
||||
|
||||
const result = await service.gc({ limits: { maxCount: 2 } });
|
||||
|
||||
@@ -994,19 +1015,13 @@ describe("ManagedWorktreeService", () => {
|
||||
});
|
||||
|
||||
it("skips active owners during count-limit eviction", async () => {
|
||||
const activeOldest = await service.create({
|
||||
repoRoot: repo,
|
||||
name: "limit-active",
|
||||
ownerKind: "session",
|
||||
ownerId: "agent:main:active",
|
||||
});
|
||||
const activeOldest = await materializeRunOwnedFixture(
|
||||
"limit-active",
|
||||
"session",
|
||||
"agent:main:active",
|
||||
);
|
||||
now += 1;
|
||||
const idle = await service.create({
|
||||
repoRoot: repo,
|
||||
name: "limit-idle",
|
||||
ownerKind: "session",
|
||||
ownerId: "agent:main:idle",
|
||||
});
|
||||
const idle = await materializeRunOwnedFixture("limit-idle", "session", "agent:main:idle");
|
||||
const shouldProtectOwner = vi.fn(
|
||||
(_ownerKind: string, ownerId: string) => ownerId === "agent:main:active",
|
||||
);
|
||||
@@ -1018,20 +1033,18 @@ describe("ManagedWorktreeService", () => {
|
||||
});
|
||||
|
||||
it("evicts oldest worktrees until total size fits the size limit", async () => {
|
||||
const oldest = await service.create({
|
||||
repoRoot: repo,
|
||||
name: "size-oldest",
|
||||
ownerKind: "session",
|
||||
ownerId: "agent:main:size-old",
|
||||
});
|
||||
const oldest = await materializeRunOwnedFixture(
|
||||
"size-oldest",
|
||||
"session",
|
||||
"agent:main:size-old",
|
||||
);
|
||||
await fs.writeFile(path.join(oldest.path, "blob.bin"), Buffer.alloc(10_000));
|
||||
now += 1;
|
||||
const newest = await service.create({
|
||||
repoRoot: repo,
|
||||
name: "size-newest",
|
||||
ownerKind: "session",
|
||||
ownerId: "agent:main:size-new",
|
||||
});
|
||||
const newest = await materializeRunOwnedFixture(
|
||||
"size-newest",
|
||||
"session",
|
||||
"agent:main:size-new",
|
||||
);
|
||||
|
||||
const result = await service.gc({ limits: { maxTotalSizeBytes: 6_000 } });
|
||||
|
||||
@@ -1044,12 +1057,11 @@ describe("ManagedWorktreeService", () => {
|
||||
if (process.getuid?.() === 0) {
|
||||
return; // chmod-based EACCES cannot be simulated as root
|
||||
}
|
||||
const unreadable = await service.create({
|
||||
repoRoot: repo,
|
||||
name: "size-unreadable",
|
||||
ownerKind: "session",
|
||||
ownerId: "agent:main:size-unreadable",
|
||||
});
|
||||
const unreadable = await materializeRunOwnedFixture(
|
||||
"size-unreadable",
|
||||
"session",
|
||||
"agent:main:size-unreadable",
|
||||
);
|
||||
await fs.writeFile(path.join(unreadable.path, "blob.bin"), Buffer.alloc(10_000));
|
||||
const locked = path.join(unreadable.path, "locked");
|
||||
await fs.mkdir(locked);
|
||||
@@ -1066,26 +1078,23 @@ describe("ManagedWorktreeService", () => {
|
||||
});
|
||||
|
||||
it("counts a competing removal instead of evicting an extra worktree", async () => {
|
||||
const oldest = await service.create({
|
||||
repoRoot: repo,
|
||||
name: "race-oldest",
|
||||
ownerKind: "session",
|
||||
ownerId: "agent:main:race-old",
|
||||
});
|
||||
const oldest = await materializeRunOwnedFixture(
|
||||
"race-oldest",
|
||||
"session",
|
||||
"agent:main:race-old",
|
||||
);
|
||||
now += 1;
|
||||
const middle = await service.create({
|
||||
repoRoot: repo,
|
||||
name: "race-middle",
|
||||
ownerKind: "session",
|
||||
ownerId: "agent:main:race-mid",
|
||||
});
|
||||
const middle = await materializeRunOwnedFixture(
|
||||
"race-middle",
|
||||
"session",
|
||||
"agent:main:race-mid",
|
||||
);
|
||||
now += 1;
|
||||
const newest = await service.create({
|
||||
repoRoot: repo,
|
||||
name: "race-newest",
|
||||
ownerKind: "session",
|
||||
ownerId: "agent:main:race-new",
|
||||
});
|
||||
const newest = await materializeRunOwnedFixture(
|
||||
"race-newest",
|
||||
"session",
|
||||
"agent:main:race-new",
|
||||
);
|
||||
const realRemove = service.remove.bind(service);
|
||||
const removeSpy = vi
|
||||
.spyOn(service, "remove")
|
||||
@@ -1107,12 +1116,7 @@ describe("ManagedWorktreeService", () => {
|
||||
});
|
||||
|
||||
it("leaves everything in place when limits are not exceeded", async () => {
|
||||
const created = await service.create({
|
||||
repoRoot: repo,
|
||||
name: "under-limit",
|
||||
ownerKind: "session",
|
||||
ownerId: "agent:main:under",
|
||||
});
|
||||
const created = await materializeRunOwnedFixture("under-limit", "session", "agent:main:under");
|
||||
|
||||
const result = await service.gc({
|
||||
limits: { maxCount: 5, maxTotalSizeBytes: 1024 ** 3 },
|
||||
@@ -1127,7 +1131,7 @@ describe("ManagedWorktreeService", () => {
|
||||
});
|
||||
|
||||
it("prunes expired snapshot refs and registry rows", async () => {
|
||||
const created = await service.create({ repoRoot: repo, name: "expired" });
|
||||
const created = await materializeDownstreamFixture("expired");
|
||||
const removed = await service.remove({ id: created.id, reason: "retention" });
|
||||
now += SNAPSHOT_RETENTION_MS + 1;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user