agent-orchestrator/packages/integration-tests/src/workspace-worktree.integrat...

534 lines
22 KiB
TypeScript

import { execFile } from "node:child_process";
import { mkdtemp, rm, realpath, writeFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { promisify } from "node:util";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import worktreePlugin from "@aoagents/ao-plugin-workspace-worktree";
import type { ProjectConfig, WorkspaceInfo } from "@aoagents/ao-core";
const execFileAsync = promisify(execFile);
async function git(cwd: string, ...args: string[]): Promise<string> {
const { stdout } = await execFileAsync("git", args, { cwd });
return stdout.trimEnd();
}
async function createCommit(cwd: string, fileName: string, content: string): Promise<string> {
await writeFile(join(cwd, fileName), content);
await git(cwd, "add", fileName);
await git(cwd, "commit", "-m", `update ${fileName}`);
return git(cwd, "rev-parse", "HEAD");
}
async function createRepoClone(): Promise<{
bareDir: string;
cloneParent: string;
repoDir: string;
}> {
const rawBare = await mkdtemp(join(tmpdir(), "ao-inttest-wt-origin-"));
const bareDir = await realpath(rawBare);
await git(bareDir, "init", "--bare");
const rawParent = await mkdtemp(join(tmpdir(), "ao-inttest-wt-clone-parent-"));
const cloneParent = await realpath(rawParent);
const repoDir = join(cloneParent, "repo");
await execFileAsync("git", ["clone", bareDir, repoDir]);
await git(repoDir, "config", "user.email", "test@test.com");
await git(repoDir, "config", "user.name", "Test");
return { bareDir, cloneParent, repoDir };
}
describe("workspace-worktree (integration)", () => {
let repoDir: string;
let worktreeBaseDir: string;
let workspace: ReturnType<typeof worktreePlugin.create>;
let project: ProjectConfig;
let createdInfo: WorkspaceInfo;
beforeAll(async () => {
// Create a temp repo with initial commit
const rawRepo = await mkdtemp(join(tmpdir(), "ao-inttest-wt-repo-"));
repoDir = await realpath(rawRepo);
await git(repoDir, "init", "-b", "main");
await git(repoDir, "config", "user.email", "test@test.com");
await git(repoDir, "config", "user.name", "Test");
await execFileAsync("sh", ["-c", "echo hello > README.md"], { cwd: repoDir });
await git(repoDir, "add", ".");
await git(repoDir, "commit", "-m", "initial commit");
// Add "origin" pointing at itself so the plugin's fetch succeeds
await git(repoDir, "remote", "add", "origin", repoDir);
await git(repoDir, "fetch", "origin");
// Create worktree base dir
const rawBase = await mkdtemp(join(tmpdir(), "ao-inttest-wt-base-"));
worktreeBaseDir = await realpath(rawBase);
workspace = worktreePlugin.create({ worktreeDir: worktreeBaseDir });
project = {
name: "inttest",
repo: "test/inttest",
path: repoDir,
defaultBranch: "main",
sessionPrefix: "test",
};
}, 30_000);
afterAll(async () => {
// Clean up worktrees first (must be done before removing repo)
try {
await git(repoDir, "worktree", "prune");
} catch {
/* best-effort cleanup */
}
if (repoDir) await rm(repoDir, { recursive: true, force: true }).catch(() => {});
if (worktreeBaseDir)
await rm(worktreeBaseDir, { recursive: true, force: true }).catch(() => {});
}, 30_000);
it("creates a worktree workspace", async () => {
createdInfo = await workspace.create({
projectId: "inttest",
sessionId: "session-1",
project,
branch: "feat/test-branch",
});
expect(createdInfo.path).toContain("session-1");
expect(createdInfo.branch).toBe("feat/test-branch");
expect(createdInfo.sessionId).toBe("session-1");
expect(createdInfo.projectId).toBe("inttest");
expect(existsSync(createdInfo.path)).toBe(true);
});
it("worktree is on the correct branch", async () => {
const branch = await git(createdInfo.path, "branch", "--show-current");
expect(branch).toBe("feat/test-branch");
});
it("worktree has the files from main", async () => {
expect(existsSync(join(createdInfo.path, "README.md"))).toBe(true);
});
it("lists the worktree", async () => {
const list = await workspace.list("inttest");
expect(list.length).toBeGreaterThanOrEqual(1);
const found = list.find((w: { sessionId: string }) => w.sessionId === "session-1");
expect(found).toBeDefined();
expect(found!.branch).toBe("feat/test-branch");
});
it("rejects invalid projectId", async () => {
await expect(
workspace.create({
projectId: "../escape",
sessionId: "ok",
project,
branch: "feat/x",
}),
).rejects.toThrow("Invalid projectId");
});
it("rejects invalid sessionId", async () => {
await expect(
workspace.create({
projectId: "inttest",
sessionId: "bad/id",
project,
branch: "feat/x",
}),
).rejects.toThrow("Invalid sessionId");
});
it("destroys the worktree", async () => {
const pathToDestroy = createdInfo.path;
await workspace.destroy(pathToDestroy);
expect(existsSync(pathToDestroy)).toBe(false);
});
it("list returns empty after destroy", async () => {
const list = await workspace.list("inttest");
const found = list.find((w: { sessionId: string }) => w.sessionId === "session-1");
expect(found).toBeUndefined();
});
it("resets a stale session branch when defaultBranch changes", async () => {
const { bareDir, cloneParent, repoDir: isolatedRepoDir } = await createRepoClone();
const rawBase = await mkdtemp(join(tmpdir(), "ao-inttest-wt-stale-default-"));
const isolatedWorktreeBaseDir = await realpath(rawBase);
try {
await git(isolatedRepoDir, "switch", "-c", "develop");
const developSha = await createCommit(isolatedRepoDir, "base.txt", "develop\n");
await git(isolatedRepoDir, "push", "-u", "origin", "develop");
await git(isolatedRepoDir, "switch", "-c", "reset");
const resetSha = await createCommit(isolatedRepoDir, "reset.txt", "reset\n");
await git(isolatedRepoDir, "push", "-u", "origin", "reset");
const isolatedWorkspace = worktreePlugin.create({ worktreeDir: isolatedWorktreeBaseDir });
const developProject: ProjectConfig = {
name: "stale-default",
repo: "test/stale-default",
path: isolatedRepoDir,
defaultBranch: "develop",
sessionPrefix: "meg",
};
const firstInfo = await isolatedWorkspace.create({
projectId: "stale-default",
sessionId: "meg-1",
project: developProject,
branch: "session/meg-1",
});
expect(await git(firstInfo.path, "rev-parse", "HEAD")).toBe(developSha);
await isolatedWorkspace.destroy(firstInfo.path);
const resetProject = { ...developProject, defaultBranch: "reset" };
const secondInfo = await isolatedWorkspace.create({
projectId: "stale-default",
sessionId: "meg-1",
project: resetProject,
branch: "session/meg-1",
});
expect(await git(secondInfo.path, "rev-parse", "HEAD")).toBe(resetSha);
expect(await git(isolatedRepoDir, "rev-parse", "refs/heads/session/meg-1")).toBe(resetSha);
} finally {
await rm(isolatedWorktreeBaseDir, { recursive: true, force: true }).catch(() => {});
await rm(cloneParent, { recursive: true, force: true }).catch(() => {});
await rm(bareDir, { recursive: true, force: true }).catch(() => {});
}
}, 30_000);
// Regression for https://github.com/ComposioHQ/agent-orchestrator/issues/1741.
// After a clean destroy(), the local session branch is intentionally kept so
// the user's commits aren't lost. restore() must re-attach that branch
// without recreating it (-b) or force-resetting it (-B), so the session's
// HEAD survives.
it("restore re-attaches existing session branch and preserves its commits", async () => {
const { bareDir, cloneParent, repoDir: isolatedRepoDir } = await createRepoClone();
const rawBase = await mkdtemp(join(tmpdir(), "ao-inttest-wt-restore-preserve-"));
const isolatedWorktreeBaseDir = await realpath(rawBase);
try {
await git(isolatedRepoDir, "switch", "-c", "main");
const mainSha = await createCommit(isolatedRepoDir, "base.txt", "main\n");
await git(isolatedRepoDir, "push", "-u", "origin", "main");
const isolatedWorkspace = worktreePlugin.create({ worktreeDir: isolatedWorktreeBaseDir });
const proj: ProjectConfig = {
name: "restore-preserve",
repo: "test/restore-preserve",
path: isolatedRepoDir,
defaultBranch: "main",
sessionPrefix: "ao",
};
const created = await isolatedWorkspace.create({
projectId: "restore-preserve",
sessionId: "ao-1",
project: proj,
branch: "session/ao-1",
});
// Simulate session work with a commit on the session branch.
await git(created.path, "config", "user.email", "test@test.com");
await git(created.path, "config", "user.name", "Test");
const sessionSha = await createCommit(created.path, "session.txt", "session work\n");
expect(sessionSha).not.toBe(mainSha);
// Tear down the worktree the way AO does — branch is preserved.
await isolatedWorkspace.destroy(created.path);
expect(existsSync(created.path)).toBe(false);
expect(await git(isolatedRepoDir, "rev-parse", "refs/heads/session/ao-1")).toBe(sessionSha);
// Restore — must re-attach session/ao-1 with its existing HEAD intact.
const restored = await isolatedWorkspace.restore!(
{
projectId: "restore-preserve",
sessionId: "ao-1",
project: proj,
branch: "session/ao-1",
},
created.path,
);
expect(restored.branch).toBe("session/ao-1");
expect(await git(restored.path, "rev-parse", "HEAD")).toBe(sessionSha);
expect(await git(isolatedRepoDir, "rev-parse", "refs/heads/session/ao-1")).toBe(sessionSha);
} finally {
await rm(isolatedWorktreeBaseDir, { recursive: true, force: true }).catch(() => {});
await rm(cloneParent, { recursive: true, force: true }).catch(() => {});
await rm(bareDir, { recursive: true, force: true }).catch(() => {});
}
}, 30_000);
// Same regression — direct repro of the failure surface in #1741. We force
// the first `git worktree add <path> <branch>` to fail by leaving a stale
// registered worktree at the same path, then verify restore recovers
// without using -b (which would fail with "branch already exists") or -B
// (which would discard the session's commits).
it("restore recovers when a stale worktree registration conflicts with the path", async () => {
const { bareDir, cloneParent, repoDir: isolatedRepoDir } = await createRepoClone();
const rawBase = await mkdtemp(join(tmpdir(), "ao-inttest-wt-restore-stale-"));
const isolatedWorktreeBaseDir = await realpath(rawBase);
try {
await git(isolatedRepoDir, "switch", "-c", "main");
const mainSha = await createCommit(isolatedRepoDir, "base.txt", "main\n");
await git(isolatedRepoDir, "push", "-u", "origin", "main");
const isolatedWorkspace = worktreePlugin.create({ worktreeDir: isolatedWorktreeBaseDir });
const proj: ProjectConfig = {
name: "restore-stale",
repo: "test/restore-stale",
path: isolatedRepoDir,
defaultBranch: "main",
sessionPrefix: "ao",
};
const created = await isolatedWorkspace.create({
projectId: "restore-stale",
sessionId: "ao-1",
project: proj,
branch: "session/ao-1",
});
await git(created.path, "config", "user.email", "test@test.com");
await git(created.path, "config", "user.name", "Test");
const sessionSha = await createCommit(created.path, "session.txt", "session work\n");
expect(sessionSha).not.toBe(mainSha);
// Simulate a dirty teardown: rmSync the dir but leave the worktree
// entry registered (this is the failure mode from #1562 that triggers
// the buggy fallback path in #1741).
await rm(created.path, { recursive: true, force: true });
// Worktree registration is still present — branch is still considered
// checked out at that (now-missing) path. Restore must handle this.
const restored = await isolatedWorkspace.restore!(
{
projectId: "restore-stale",
sessionId: "ao-1",
project: proj,
branch: "session/ao-1",
},
created.path,
);
expect(restored.branch).toBe("session/ao-1");
// Most importantly, the session commit must survive — anything that
// touched -B would have reset the branch back to mainSha.
expect(await git(restored.path, "rev-parse", "HEAD")).toBe(sessionSha);
expect(await git(isolatedRepoDir, "rev-parse", "refs/heads/session/ao-1")).toBe(sessionSha);
} finally {
await rm(isolatedWorktreeBaseDir, { recursive: true, force: true }).catch(() => {});
await rm(cloneParent, { recursive: true, force: true }).catch(() => {});
await rm(bareDir, { recursive: true, force: true }).catch(() => {});
}
}, 30_000);
// Direct repro of the user-reported failure on PR #1742: the workspace dir
// physically exists on disk but is no longer a valid git working tree
// (workspace.exists() returned false because rev-parse failed). The first
// `git worktree add <path> <branch>` fails with `'<path>' already exists`,
// so restore must rmSync the stale dir before retrying. Without this, my
// first fix attempt cleaned the registry but left the dir, so the retry
// failed identically.
it("restore recovers when a stale (non-worktree) directory exists at the path", async () => {
const { bareDir, cloneParent, repoDir: isolatedRepoDir } = await createRepoClone();
const rawBase = await mkdtemp(join(tmpdir(), "ao-inttest-wt-restore-junkdir-"));
const isolatedWorktreeBaseDir = await realpath(rawBase);
try {
await git(isolatedRepoDir, "switch", "-c", "main");
const mainSha = await createCommit(isolatedRepoDir, "base.txt", "main\n");
await git(isolatedRepoDir, "push", "-u", "origin", "main");
const isolatedWorkspace = worktreePlugin.create({ worktreeDir: isolatedWorktreeBaseDir });
const proj: ProjectConfig = {
name: "restore-junkdir",
repo: "test/restore-junkdir",
path: isolatedRepoDir,
defaultBranch: "main",
sessionPrefix: "ao",
};
const created = await isolatedWorkspace.create({
projectId: "restore-junkdir",
sessionId: "ao-1",
project: proj,
branch: "session/ao-1",
});
await git(created.path, "config", "user.email", "test@test.com");
await git(created.path, "config", "user.name", "Test");
const sessionSha = await createCommit(created.path, "session.txt", "session work\n");
expect(sessionSha).not.toBe(mainSha);
// Clean teardown — registry and dir both gone, branch preserved.
await isolatedWorkspace.destroy(created.path);
expect(existsSync(created.path)).toBe(false);
// Now simulate a partially-restored or hand-mucked state: the dir
// exists at workspacePath but is just leftover files, not a working
// tree. workspace.exists() will return false (rev-parse fails), so
// restore is invoked, and its first `worktree add` will fail with
// `'<path>' already exists`.
await execFileAsync("mkdir", ["-p", created.path]);
await writeFile(join(created.path, "stale.txt"), "junk\n");
expect(existsSync(created.path)).toBe(true);
const restored = await isolatedWorkspace.restore!(
{
projectId: "restore-junkdir",
sessionId: "ao-1",
project: proj,
branch: "session/ao-1",
},
created.path,
);
expect(restored.branch).toBe("session/ao-1");
// Junk file must be gone (restore rmSync'd the stale dir before retry).
expect(existsSync(join(created.path, "stale.txt"))).toBe(false);
// Session commit must survive — anything using -B would have lost it.
expect(await git(restored.path, "rev-parse", "HEAD")).toBe(sessionSha);
expect(await git(isolatedRepoDir, "rev-parse", "refs/heads/session/ao-1")).toBe(sessionSha);
} finally {
await rm(isolatedWorktreeBaseDir, { recursive: true, force: true }).catch(() => {});
await rm(cloneParent, { recursive: true, force: true }).catch(() => {});
await rm(bareDir, { recursive: true, force: true }).catch(() => {});
}
}, 30_000);
// Coverage for the createBranchFromBase recovery path (Copilot review on
// PR #1742): when the LOCAL branch is missing (only origin/<branch>
// exists) AND `workspacePath` has stale state, the -b fallback must also
// run the cleanup. Without it, `git worktree add -b ...` fails with
// `'<path>' already exists` exactly like the re-attach path used to.
it("restore recovers when local branch is missing and stale dir exists at the path", async () => {
const { bareDir, cloneParent, repoDir: isolatedRepoDir } = await createRepoClone();
const rawBase = await mkdtemp(join(tmpdir(), "ao-inttest-wt-restore-missing-branch-"));
const isolatedWorktreeBaseDir = await realpath(rawBase);
try {
await git(isolatedRepoDir, "switch", "-c", "main");
await createCommit(isolatedRepoDir, "base.txt", "main\n");
await git(isolatedRepoDir, "push", "-u", "origin", "main");
// Manually push a session branch to origin without keeping it locally,
// simulating a session whose local branch was pruned but origin still
// has it (e.g. fetched after a remote-only force-update).
await git(isolatedRepoDir, "switch", "-c", "session/ao-1");
const sessionSha = await createCommit(isolatedRepoDir, "session.txt", "session work\n");
await git(isolatedRepoDir, "push", "-u", "origin", "session/ao-1");
// Switch off session/ao-1 then delete the local branch — only origin has it now.
await git(isolatedRepoDir, "switch", "main");
await git(isolatedRepoDir, "branch", "-D", "session/ao-1");
const isolatedWorkspace = worktreePlugin.create({ worktreeDir: isolatedWorktreeBaseDir });
const proj: ProjectConfig = {
name: "restore-missing-branch",
repo: "test/restore-missing-branch",
path: isolatedRepoDir,
defaultBranch: "main",
sessionPrefix: "ao",
};
const workspacePath = join(isolatedWorktreeBaseDir, "restore-missing-branch", "ao-1");
// Plant a stale junk directory at workspacePath. workspace.exists()
// will return false (not a working tree) and restore is invoked.
// The first `worktree add` will fail with `'<path>' already exists`,
// refExists for refs/heads/session/ao-1 returns false (we deleted it),
// so createBranchFromBase runs and must clean the stale dir first.
await execFileAsync("mkdir", ["-p", workspacePath]);
await writeFile(join(workspacePath, "stale.txt"), "junk\n");
expect(existsSync(workspacePath)).toBe(true);
const restored = await isolatedWorkspace.restore!(
{
projectId: "restore-missing-branch",
sessionId: "ao-1",
project: proj,
branch: "session/ao-1",
},
workspacePath,
);
expect(restored.branch).toBe("session/ao-1");
// Junk file gone — cleanup ran before -b add.
expect(existsSync(join(workspacePath, "stale.txt"))).toBe(false);
// Local branch was recreated from origin/session/ao-1, preserving the
// session's commit (which only existed remotely before restore).
expect(await git(restored.path, "rev-parse", "HEAD")).toBe(sessionSha);
expect(await git(isolatedRepoDir, "rev-parse", "refs/heads/session/ao-1")).toBe(sessionSha);
} finally {
await rm(isolatedWorktreeBaseDir, { recursive: true, force: true }).catch(() => {});
await rm(cloneParent, { recursive: true, force: true }).catch(() => {});
await rm(bareDir, { recursive: true, force: true }).catch(() => {});
}
}, 30_000);
it("resets a stale session branch when origin default branch advances", async () => {
const { bareDir, cloneParent, repoDir: isolatedRepoDir } = await createRepoClone();
const rawBase = await mkdtemp(join(tmpdir(), "ao-inttest-wt-stale-origin-"));
const isolatedWorktreeBaseDir = await realpath(rawBase);
const rawExternalParent = await mkdtemp(join(tmpdir(), "ao-inttest-wt-external-parent-"));
const externalParent = await realpath(rawExternalParent);
const externalRepoDir = join(externalParent, "repo");
try {
await git(isolatedRepoDir, "switch", "-c", "main");
const oldMainSha = await createCommit(isolatedRepoDir, "base.txt", "main one\n");
await git(isolatedRepoDir, "push", "-u", "origin", "main");
const isolatedWorkspace = worktreePlugin.create({ worktreeDir: isolatedWorktreeBaseDir });
const mainProject: ProjectConfig = {
name: "stale-origin",
repo: "test/stale-origin",
path: isolatedRepoDir,
defaultBranch: "main",
sessionPrefix: "meg",
};
const firstInfo = await isolatedWorkspace.create({
projectId: "stale-origin",
sessionId: "meg-1",
project: mainProject,
branch: "session/meg-1",
});
expect(await git(firstInfo.path, "rev-parse", "HEAD")).toBe(oldMainSha);
await isolatedWorkspace.destroy(firstInfo.path);
await execFileAsync("git", ["clone", bareDir, externalRepoDir]);
await git(externalRepoDir, "config", "user.email", "test@test.com");
await git(externalRepoDir, "config", "user.name", "Test");
await git(externalRepoDir, "switch", "main");
const newMainSha = await createCommit(externalRepoDir, "base.txt", "main two\n");
await git(externalRepoDir, "push", "origin", "main");
const secondInfo = await isolatedWorkspace.create({
projectId: "stale-origin",
sessionId: "meg-1",
project: mainProject,
branch: "session/meg-1",
});
expect(await git(secondInfo.path, "rev-parse", "HEAD")).toBe(newMainSha);
expect(await git(isolatedRepoDir, "rev-parse", "refs/heads/session/meg-1")).toBe(newMainSha);
} finally {
await rm(isolatedWorktreeBaseDir, { recursive: true, force: true }).catch(() => {});
await rm(externalParent, { recursive: true, force: true }).catch(() => {});
await rm(cloneParent, { recursive: true, force: true }).catch(() => {});
await rm(bareDir, { recursive: true, force: true }).catch(() => {});
}
}, 30_000);
});