mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-28 20:22:45 +00:00
feat(worktrees): let plugins choose creation options and placement (#8117)
* feat(worktrees): let plugins choose creation options and placement Await the worktree creation request hook before Git source planning so plugins can change placement, branch and base without undoing a fetch. Manage arbitrary destinations through marker and registry ownership, and restore them at their recorded paths. Co-authored-by: Matt Cowger <matt@cowger.us> * fix(worktrees): compare repository paths across Windows aliases Deletion must recognize the same repository through Git native and long path spellings. Reuse ownership path equivalence instead of comparing canonical strings, and let the recovery fixture accept Git platform line endings. Co-authored-by: Matt Cowger <matt@cowger.us> * fix(worktrees): respect recorded ownership and creation intent Keep branch checkout distinct from branch-off, validate plugin placement once at the daemon boundary, and share default path collision resolution. A restored external worktree's marker must never override its recorded non-ownership. Co-authored-by: Matt Cowger <matt@cowger.us> * test(worktrees): record ownership in session archive fixture The fixture creates a Paseo-owned worktree, so its durable record must say so. Archive now treats a recorded false value as authoritative instead of replacing it with filesystem discovery. Co-authored-by: Matt Cowger <matt@cowger.us> * test(plugins): retry temp cleanup after worktree archive on Windows Windows releases directory handles after the daemon closes, so rmdir on the source repository can hit EBUSY. Retry like the other git temp-dir tests. * test(plugins): compare plugin-chosen worktree paths in canonical spelling Windows temp directories come back as 8.3 aliases while the daemon records the resolved spelling, so normalize the expected root the same way. --------- Co-authored-by: Matt Cowger <matt@cowger.us>
This commit is contained in:
co-authored by
Matt Cowger
parent
41582eb5fd
commit
9fb7808191
@@ -11,6 +11,7 @@ Voice settings now let you configure speech providers and download local models
|
||||
### Added
|
||||
|
||||
- Copy gitignored files selected by `.worktreeinclude` into new worktrees before setup and agents, using the built-in `worktree-include` plugin ([#8100](https://github.com/getpaseo/paseo/pull/8100))
|
||||
- Let plugins choose worktree paths and creation options through `worktree.create.before`, with listing, archive, deletion, and recovery at custom locations; includes a sibling-directory placement example ([#8117](https://github.com/getpaseo/paseo/pull/8117))
|
||||
- Let plugins give tools to specific agents ([#7938](https://github.com/getpaseo/paseo/pull/7938))
|
||||
- Added collapse and expand controls for completed conversation turns ([#7785](https://github.com/getpaseo/paseo/pull/7785), [#7990](https://github.com/getpaseo/paseo/pull/7990), [#8026](https://github.com/getpaseo/paseo/pull/8026) by [@akaready](https://github.com/akaready), [@Dey11](https://github.com/Dey11), [@Sebeb](https://github.com/Sebeb), [@Waishnav](https://github.com/Waishnav))
|
||||
- Let plugins add MCP servers whenever an agent session opens ([#7983](https://github.com/getpaseo/paseo/pull/7983))
|
||||
|
||||
@@ -447,6 +447,12 @@ owns payloads, ordering, failure behavior, and examples. `agent.create.before` e
|
||||
configuration; `agent.open.before` contributes launch-only `env`, `tools`, and `mcpServers` on
|
||||
every open. Executable tool definitions stay in the plugin process; only metadata crosses the boundary.
|
||||
|
||||
Worktree placement is resolved before Git source planning, so a hook can change the branch and base
|
||||
without undoing fetched refs. Restores use the recorded destination and bypass creation hooks.
|
||||
Workspace records decide ownership, including explicit non-ownership after recovery. Without a record,
|
||||
ownership checks prefer the marker-verified worktree root over the legacy
|
||||
path shape: a plugin can place a worktree several directories below the default worktrees root.
|
||||
|
||||
Emit from the operation owner, not a client subscription. Provider history replay must not trigger
|
||||
live hooks. Notifications must not be awaited inside agent mutations: a handler can send a prompt
|
||||
or answer a permission through its own daemon session. Awaiting it there deadlocks that command.
|
||||
|
||||
@@ -255,6 +255,24 @@ describe("lifecycle hook SDK types", () => {
|
||||
typeErrors(`
|
||||
import type { PluginServerContext, PluginHooks, PluginHookPayload, PluginHookResult } from "@getpaseo/plugin/server";
|
||||
export default function contribute(server: PluginServerContext) {
|
||||
server.addHook("worktree.create.before", async ({ request }) => {
|
||||
if (request.kind === "checkout-branch") {
|
||||
// @ts-expect-error Branch checkout has no branch-off base.
|
||||
request.baseRef;
|
||||
} else {
|
||||
const base: string = request.baseRef;
|
||||
}
|
||||
const branch: string = request.branch;
|
||||
// @ts-expect-error Repository identity is read-only.
|
||||
request.repoRoot = "/other";
|
||||
// @ts-expect-error Source checkout identity is read-only.
|
||||
request.sourceCheckout = "/other";
|
||||
if (request.kind === "checkout-change-request") {
|
||||
const number: number = request.changeRequestNumber;
|
||||
}
|
||||
return { ...request, branch: "feature", baseRef: "main", slug: "feature", path: "/work/feature" };
|
||||
});
|
||||
server.addHook("worktree.create.before", async () => {});
|
||||
server.addHook("worktree.create.after", async ({ worktree }) => {
|
||||
const path: string = worktree.path;
|
||||
const branch: string = worktree.branch;
|
||||
|
||||
@@ -21,6 +21,7 @@ export type {
|
||||
PluginHookContext,
|
||||
PluginHookWorkspace,
|
||||
PluginHookWorktree,
|
||||
PluginWorktreeCreateRequest,
|
||||
PluginHookAgent,
|
||||
PluginSessionOpenRequest,
|
||||
PluginTurnOutcome,
|
||||
|
||||
@@ -27,6 +27,40 @@ export interface PluginHookWorktree {
|
||||
sourceCheckout: string;
|
||||
}
|
||||
|
||||
/** Resolved creation options before Git fetches refs or chooses a unique branch name. */
|
||||
export type PluginWorktreeCreateRequest = {
|
||||
readonly repoRoot: string;
|
||||
readonly sourceCheckout: string;
|
||||
branch: string;
|
||||
slug: string;
|
||||
path: string;
|
||||
} & (
|
||||
| { kind: "branch-off"; baseRef: string }
|
||||
| { kind: "checkout-branch" }
|
||||
| {
|
||||
kind: "checkout-change-request";
|
||||
baseRef: string;
|
||||
forge: string;
|
||||
changeRequestNumber: number;
|
||||
headRef: string;
|
||||
headRepositoryOwner?: string;
|
||||
headRepository?: string;
|
||||
checkoutRefs?: { remoteName?: string; remoteRef: string }[];
|
||||
pushRemoteUrl?: string;
|
||||
trackOriginHead?: boolean;
|
||||
}
|
||||
| {
|
||||
kind: "checkout-github-pr";
|
||||
baseRef: string;
|
||||
githubPrNumber: number;
|
||||
headRef: string;
|
||||
headRepositoryOwner?: string;
|
||||
checkoutRefs?: { remoteName?: string; remoteRef: string }[];
|
||||
pushRemoteUrl?: string;
|
||||
trackOriginHead?: boolean;
|
||||
}
|
||||
);
|
||||
|
||||
export interface PluginHookAgent {
|
||||
id: string;
|
||||
workspaceId: string | null;
|
||||
@@ -81,6 +115,7 @@ export interface PluginHookEvents {
|
||||
}
|
||||
|
||||
export interface PluginHookRequests {
|
||||
"worktree.create.before": PluginWorktreeCreateRequest;
|
||||
"agent.create.before": { config: AgentSessionConfig; env?: Record<string, string> };
|
||||
"agent.open.before": PluginSessionOpenRequest;
|
||||
"workspace.create.before": Omit<WorkspaceCreateRequest, "type" | "requestId">;
|
||||
@@ -140,6 +175,7 @@ export const pluginHookRegistry = {
|
||||
"workspace.create.before": { waits: true },
|
||||
"workspace.create.after": { waits: false },
|
||||
"workspace.archive.after": { waits: false },
|
||||
"worktree.create.before": { waits: true },
|
||||
"worktree.create.after": { waits: true },
|
||||
"agent.create.before": { waits: true },
|
||||
"agent.create.after": { waits: false },
|
||||
|
||||
@@ -60,7 +60,7 @@ export interface AttemptFirstAgentBranchAutoNameResult {
|
||||
}
|
||||
|
||||
export interface CreatePaseoWorktreeDeps extends CreateWorktreeCoreDeps {
|
||||
lifecycle?: Pick<PluginLifecycle, "wait">;
|
||||
lifecycle?: Pick<PluginLifecycle, "wait" | "before">;
|
||||
workspaceGitService: WorkspaceGitService;
|
||||
workspaceProvisioning: Pick<WorkspaceProvisioningService, "createWorkspaceForWorktree">;
|
||||
}
|
||||
|
||||
@@ -463,7 +463,7 @@ ${options.hooks ?? ""}
|
||||
|
||||
test("a clean daemon shutdown leaves a completed plugin-provider agent without an error", async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "plugin-provider-shutdown-"));
|
||||
onTestFinished(() => rm(root, { recursive: true, force: true }));
|
||||
onTestFinished(() => rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }));
|
||||
const pluginDirectory = await createProviderPlugin(root);
|
||||
const workspace = path.join(root, "workspace");
|
||||
await mkdir(workspace, { recursive: true });
|
||||
@@ -507,7 +507,7 @@ test("a clean daemon shutdown leaves a completed plugin-provider agent without a
|
||||
|
||||
test("reloading a plugin leaves a completed plugin-provider agent without an error", async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "plugin-provider-reload-"));
|
||||
onTestFinished(() => rm(root, { recursive: true, force: true }));
|
||||
onTestFinished(() => rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }));
|
||||
const pluginDirectory = await createProviderPlugin(root);
|
||||
const workspace = path.join(root, "workspace");
|
||||
await mkdir(workspace, { recursive: true });
|
||||
@@ -556,7 +556,7 @@ test("reloading a plugin leaves a completed plugin-provider agent without an err
|
||||
|
||||
test("a provider that never acknowledges session.close does not hold the daemon open", async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "plugin-provider-stuck-close-"));
|
||||
onTestFinished(() => rm(root, { recursive: true, force: true }));
|
||||
onTestFinished(() => rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }));
|
||||
const pluginDirectory = await createProviderPlugin(root, { acknowledgeClose: false });
|
||||
const workspace = path.join(root, "workspace");
|
||||
await mkdir(workspace, { recursive: true });
|
||||
@@ -752,9 +752,36 @@ test.each([false, true])(
|
||||
],
|
||||
{ cwd: repository, stdio: "pipe" },
|
||||
);
|
||||
execFileSync("git", ["checkout", "-b", "plugin-base"], { cwd: repository, stdio: "pipe" });
|
||||
await writeFile(path.join(repository, "from-plugin-base.txt"), "base");
|
||||
execFileSync("git", ["add", "."], { cwd: repository, stdio: "pipe" });
|
||||
execFileSync(
|
||||
"git",
|
||||
[
|
||||
"-c",
|
||||
"user.name=Test",
|
||||
"-c",
|
||||
"user.email=test@example.com",
|
||||
"-c",
|
||||
"commit.gpgsign=false",
|
||||
"commit",
|
||||
"-m",
|
||||
"plugin base",
|
||||
],
|
||||
{ cwd: repository, stdio: "pipe" },
|
||||
);
|
||||
execFileSync("git", ["checkout", "main"], { cwd: repository, stdio: "pipe" });
|
||||
const orderFile = path.join(root, "order.jsonl");
|
||||
const pluginDirectory = await createProviderPlugin(root, {
|
||||
acknowledgeClose: true,
|
||||
hooks: `
|
||||
server.addHook("worktree.create.before", async ({ request }) => {
|
||||
const { appendFile } = await import("node:fs/promises");
|
||||
await appendFile(${JSON.stringify(orderFile)}, JSON.stringify({ hook: "worktree.create.before", request }) + "\\n");
|
||||
return { ...request, path: ${JSON.stringify(path.join(root, "elsewhere"))} + "/" + request.slug,
|
||||
branch: request.branch === "accept-hook" ? "plugin-branch" : request.branch,
|
||||
baseRef: request.branch === "accept-hook" ? "plugin-base" : request.baseRef };
|
||||
});
|
||||
server.addHook("worktree.create.after", async ({ worktree }) => {
|
||||
const { appendFile, writeFile } = await import("node:fs/promises");
|
||||
await appendFile(${JSON.stringify(orderFile)}, JSON.stringify({ hook: "worktree.create.after", worktree }) + "\\n");
|
||||
@@ -778,8 +805,11 @@ test.each([false, true])(
|
||||
return request;
|
||||
});`,
|
||||
});
|
||||
const daemon = await createTestPaseoDaemon();
|
||||
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
|
||||
let daemon = await createTestPaseoDaemon({
|
||||
paseoHomeRoot: path.join(root, "state"),
|
||||
cleanup: false,
|
||||
});
|
||||
let client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
|
||||
try {
|
||||
await client.connect();
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
@@ -805,6 +835,23 @@ test.each([false, true])(
|
||||
});
|
||||
expect(created.error).toBeNull();
|
||||
const cwd = created.workspace!.workspaceDirectory;
|
||||
expect(created.workspace!.gitRuntime?.currentBranch).toBe("plugin-branch");
|
||||
const persisted = JSON.parse(
|
||||
await readFile(path.join(daemon.paseoHome, "projects", "workspaces.json"), "utf8"),
|
||||
);
|
||||
expect(persisted).toContainEqual(
|
||||
expect.objectContaining({
|
||||
workspaceId: created.workspace!.id,
|
||||
branch: "plugin-branch",
|
||||
baseBranch: "refs/heads/plugin-base",
|
||||
worktreeRoot: cwd,
|
||||
}),
|
||||
);
|
||||
expect(cwd).toBe(path.join(await realpath(root), "elsewhere", "accept-hook"));
|
||||
expect(await readFile(path.join(cwd, "from-plugin-base.txt"), "utf8")).toBe("base");
|
||||
expect(
|
||||
execFileSync("git", ["branch", "--show-current"], { cwd, encoding: "utf8" }).trim(),
|
||||
).toBe("plugin-branch");
|
||||
expect(await readFile(path.join(cwd, "plugin-ready.txt"), "utf8")).toBe("ready");
|
||||
const firstAgent = withFirstAgentContext
|
||||
? created.agent!
|
||||
@@ -835,18 +882,40 @@ test.each([false, true])(
|
||||
.split("\n")
|
||||
.map((line) => JSON.parse(line));
|
||||
expect(records.map((record) => record.hook)).toEqual([
|
||||
"worktree.create.before",
|
||||
"worktree.create.after",
|
||||
"workspace.create.after",
|
||||
"agent.create.before",
|
||||
]);
|
||||
expect({
|
||||
...records[0].worktree,
|
||||
sourceCheckout: await realpath(records[0].worktree.sourceCheckout),
|
||||
...records[1].worktree,
|
||||
sourceCheckout: await realpath(records[1].worktree.sourceCheckout),
|
||||
}).toEqual({
|
||||
path: cwd,
|
||||
branch: "accept-hook",
|
||||
branch: "plugin-branch",
|
||||
sourceCheckout: await realpath(repository),
|
||||
});
|
||||
expect((await client.fetchWorkspaces()).entries.map((workspace) => workspace.id)).toContain(
|
||||
created.workspace!.id,
|
||||
);
|
||||
await client.archiveWorkspace(created.workspace!.id);
|
||||
await expect(stat(cwd)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
const hookRecordsBeforeRestore = await readFile(orderFile, "utf8");
|
||||
await client.close();
|
||||
await daemon.close();
|
||||
daemon = await createTestPaseoDaemon({
|
||||
paseoHomeRoot: path.join(root, "state"),
|
||||
cleanup: false,
|
||||
pluginsEnabled: true,
|
||||
plugins: {
|
||||
"shutdown-provider-plugin": { source: "directory", path: pluginDirectory, enabled: true },
|
||||
},
|
||||
});
|
||||
client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
|
||||
await client.connect();
|
||||
await client.restoreWorkspace(created.workspace!.id);
|
||||
expect(await readFile(path.join(cwd, "from-plugin-base.txt"), "utf8")).toBe("base");
|
||||
expect(await readFile(orderFile, "utf8")).toBe(hookRecordsBeforeRestore);
|
||||
const beforeFailure = await client.fetchWorkspaces();
|
||||
const worktreesBeforeFailure = execFileSync("git", ["worktree", "list", "--porcelain"], {
|
||||
cwd: repository,
|
||||
@@ -882,12 +951,176 @@ test.each([false, true])(
|
||||
} finally {
|
||||
await client.close();
|
||||
await daemon.close();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
await rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
|
||||
}
|
||||
},
|
||||
60_000,
|
||||
);
|
||||
|
||||
test("worktree before hook rejects unsafe destinations with plugin attribution before creating branches", async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "paseo-worktree-before-invalid-"));
|
||||
const repository = path.join(root, "repository");
|
||||
execFileSync("git", ["init", "-b", "main", repository], { stdio: "pipe" });
|
||||
execFileSync(
|
||||
"git",
|
||||
[
|
||||
"-c",
|
||||
"user.name=Test",
|
||||
"-c",
|
||||
"user.email=test@example.com",
|
||||
"-c",
|
||||
"commit.gpgsign=false",
|
||||
"commit",
|
||||
"--allow-empty",
|
||||
"-m",
|
||||
"initial",
|
||||
],
|
||||
{ cwd: repository, stdio: "pipe" },
|
||||
);
|
||||
const occupied = path.join(root, "occupied");
|
||||
await mkdir(occupied);
|
||||
const destinations = [
|
||||
occupied,
|
||||
"relative/path",
|
||||
repository,
|
||||
path.join(repository, ".git", "worktree"),
|
||||
root,
|
||||
path.parse(root).root,
|
||||
(await import("node:os")).homedir(),
|
||||
];
|
||||
const pluginDirectory = await createProviderPlugin(root, {
|
||||
acknowledgeClose: true,
|
||||
hooks: `
|
||||
server.addHook("worktree.create.before", async ({ request }) => {
|
||||
if (request.branch === "throws") throw new Error("creation denied");
|
||||
if (request.branch === "readonly") return { ...request, repoRoot: "/different-repository" };
|
||||
return { ...request, path: ${JSON.stringify(destinations)}[Number(request.branch.slice(4))] };
|
||||
});`,
|
||||
});
|
||||
const daemon = await createTestPaseoDaemon();
|
||||
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
|
||||
try {
|
||||
await client.connect();
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installDirectoryPlugin(pluginDirectory);
|
||||
for (const branchName of [
|
||||
...destinations.map((_, index) => `bad-${index}`),
|
||||
"throws",
|
||||
"readonly",
|
||||
]) {
|
||||
const created = await client.createWorkspace({
|
||||
source: { kind: "worktree", cwd: repository, branchName, worktreeSlug: branchName },
|
||||
});
|
||||
expect(created.workspace).toBeNull();
|
||||
expect(created.error).toContain(
|
||||
"Plugin shutdown-provider-plugin worktree.create.before failed:",
|
||||
);
|
||||
expect(
|
||||
execFileSync("git", ["branch", "--format=%(refname:short)"], {
|
||||
cwd: repository,
|
||||
encoding: "utf8",
|
||||
}).trim(),
|
||||
).toBe("main");
|
||||
expect(
|
||||
execFileSync("git", ["worktree", "list", "--porcelain"], {
|
||||
cwd: repository,
|
||||
encoding: "utf8",
|
||||
}).match(/^worktree /gm),
|
||||
).toHaveLength(1);
|
||||
}
|
||||
expect((await client.fetchWorkspaces()).entries).toEqual([]);
|
||||
} finally {
|
||||
await client.close();
|
||||
await daemon.close();
|
||||
await rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("the placement example creates a sibling worktree, opens its first agent there, lists and archives it", async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "paseo-placement-example-"));
|
||||
const repository = path.join(root, "repository");
|
||||
execFileSync("git", ["init", "-b", "main", repository], { stdio: "pipe" });
|
||||
execFileSync(
|
||||
"git",
|
||||
[
|
||||
"-c",
|
||||
"user.name=Test",
|
||||
"-c",
|
||||
"user.email=test@example.com",
|
||||
"-c",
|
||||
"commit.gpgsign=false",
|
||||
"commit",
|
||||
"--allow-empty",
|
||||
"-m",
|
||||
"initial",
|
||||
],
|
||||
{ cwd: repository, stdio: "pipe" },
|
||||
);
|
||||
const providerDirectory = await createProviderPlugin(root, {
|
||||
acknowledgeClose: true,
|
||||
reportOpen: true,
|
||||
});
|
||||
const daemon = await createTestPaseoDaemon();
|
||||
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
|
||||
try {
|
||||
await client.connect();
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installDirectoryPlugin(providerDirectory);
|
||||
const installed = await client.installDirectoryPlugin(
|
||||
fileURLToPath(new URL("../../../../../plugin-examples/worktree-placement", import.meta.url)),
|
||||
);
|
||||
expect(installed.status).toBe("running");
|
||||
const created = await client.createWorkspace({
|
||||
source: {
|
||||
kind: "worktree",
|
||||
cwd: repository,
|
||||
branchName: "feature/x",
|
||||
worktreeSlug: "feature-x",
|
||||
},
|
||||
});
|
||||
expect(created.error).toBeNull();
|
||||
const cwd = path.join(await realpath(root), "repository.feature-x");
|
||||
expect(created.workspace!.workspaceDirectory).toBe(cwd);
|
||||
expect((await stat(cwd)).isDirectory()).toBe(true);
|
||||
expect((await client.fetchWorkspaces()).entries.map((workspace) => workspace.id)).toContain(
|
||||
created.workspace!.id,
|
||||
);
|
||||
const worktrees = await client.getPaseoWorktreeList({ cwd: repository });
|
||||
expect(worktrees.error).toBeNull();
|
||||
expect(worktrees.worktrees).toContainEqual(
|
||||
expect.objectContaining({ worktreePath: cwd, branchName: "feature/x" }),
|
||||
);
|
||||
const collision = await client.createWorkspace({
|
||||
source: {
|
||||
kind: "worktree",
|
||||
cwd: repository,
|
||||
branchName: "feature/x",
|
||||
worktreeSlug: "another-slug",
|
||||
},
|
||||
});
|
||||
expect(collision.workspace).toBeNull();
|
||||
expect(collision.error).toContain("Plugin worktree-placement worktree.create.before failed:");
|
||||
expect(collision.error).toContain("already exists");
|
||||
const agent = await client.createAgent({
|
||||
provider: PROVIDER_ID,
|
||||
cwd,
|
||||
workspaceId: created.workspace!.id,
|
||||
});
|
||||
expect(agent.cwd).toBe(cwd);
|
||||
const logs = await client.getPluginLogs("shutdown-provider-plugin");
|
||||
const opened = logs
|
||||
.filter((entry) => entry.message.startsWith('{"hook":"provider.open"'))
|
||||
.map((entry) => JSON.parse(entry.message));
|
||||
expect(opened.map((entry) => entry.config.cwd)).toContain(cwd);
|
||||
await client.archiveWorkspace(created.workspace!.id);
|
||||
await expect(stat(cwd)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
} finally {
|
||||
await client.close();
|
||||
await daemon.close();
|
||||
await rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("worktree includes reach the first agent without copying tracked or unignored files", async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "paseo-worktree-include-"));
|
||||
const repository = path.join(root, "repository");
|
||||
@@ -1071,6 +1304,6 @@ test("worktree includes reach the first agent without copying tracked or unignor
|
||||
} finally {
|
||||
await client.close();
|
||||
await daemon.close();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
await rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { expect, test } from "vitest";
|
||||
import { createPaseoApi } from "@getpaseo/client";
|
||||
import { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { PluginHookHandlers } from "./index.js";
|
||||
import { validateBeforeResult, PluginHookHandlers } from "./index.js";
|
||||
|
||||
const paseo = createPaseoApi(
|
||||
new DaemonClient({ url: "ws://127.0.0.1:1/ws", clientId: "lifecycle-unit" }),
|
||||
@@ -239,3 +239,17 @@ test("a waiting after hook propagates failures and stops later handlers", async
|
||||
).rejects.toThrow("cannot initialize worktree");
|
||||
expect(received).toEqual(["/worktree"]);
|
||||
});
|
||||
|
||||
test("checkout-branch hooks reject a branch-off baseRef", async () => {
|
||||
const request = {
|
||||
kind: "checkout-branch" as const,
|
||||
repoRoot: "/repo",
|
||||
sourceCheckout: "/repo",
|
||||
branch: "feature",
|
||||
slug: "feature",
|
||||
path: "/work/feature",
|
||||
};
|
||||
await expect(
|
||||
validateBeforeResult("worktree.create.before", request, { ...request, baseRef: "main" }),
|
||||
).rejects.toThrow(/baseRef/);
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { validateBranchSlug } from "@getpaseo/protocol/branch-slug";
|
||||
import {
|
||||
createLifecycleRegistration,
|
||||
pluginHookRegistry,
|
||||
@@ -37,7 +38,56 @@ export type PluginBeforeResults = PluginWireBeforeRequests & {
|
||||
"agent.open.before": SessionOpenWireRequest & { mcpServerOwners?: Record<string, string> };
|
||||
};
|
||||
|
||||
const worktreeRequestFields = {
|
||||
repoRoot: z.string(),
|
||||
sourceCheckout: z.string(),
|
||||
branch: z.string().min(1),
|
||||
slug: z.string().refine((value) => validateBranchSlug(value).valid, "Invalid worktree slug"),
|
||||
path: z.string().min(1),
|
||||
};
|
||||
const changeRequestFields = {
|
||||
headRef: z.string().min(1),
|
||||
headRepositoryOwner: z.string().optional(),
|
||||
checkoutRefs: z
|
||||
.array(z.object({ remoteName: z.string().optional(), remoteRef: z.string() }))
|
||||
.optional(),
|
||||
pushRemoteUrl: z.string().optional(),
|
||||
trackOriginHead: z.boolean().optional(),
|
||||
};
|
||||
const worktreeRequestSchema = z.discriminatedUnion("kind", [
|
||||
z
|
||||
.object({ ...worktreeRequestFields, kind: z.literal("branch-off"), baseRef: z.string().min(1) })
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
...worktreeRequestFields,
|
||||
kind: z.literal("checkout-branch"),
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
...worktreeRequestFields,
|
||||
...changeRequestFields,
|
||||
kind: z.literal("checkout-change-request"),
|
||||
baseRef: z.string().min(1),
|
||||
forge: z.string(),
|
||||
changeRequestNumber: z.number().int().positive(),
|
||||
headRepository: z.string().optional(),
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
...worktreeRequestFields,
|
||||
...changeRequestFields,
|
||||
kind: z.literal("checkout-github-pr"),
|
||||
baseRef: z.string().min(1),
|
||||
githubPrNumber: z.number().int().positive(),
|
||||
})
|
||||
.strict(),
|
||||
]);
|
||||
|
||||
const beforeSchemas = {
|
||||
"worktree.create.before": worktreeRequestSchema,
|
||||
"agent.create.before": CreateAgentRequestMessageSchema.pick({
|
||||
config: true,
|
||||
env: true,
|
||||
@@ -162,11 +212,11 @@ export function publishAgentStream(
|
||||
}
|
||||
}
|
||||
|
||||
export function validateBeforeResult<Name extends keyof PluginWireBeforeRequests>(
|
||||
export async function validateBeforeResult<Name extends keyof PluginWireBeforeRequests>(
|
||||
name: Name,
|
||||
input: PluginWireBeforeRequests[Name],
|
||||
output: unknown,
|
||||
): PluginWireBeforeRequests[Name] {
|
||||
): Promise<PluginWireBeforeRequests[Name]> {
|
||||
const result = validateBeforeRequest(name, output);
|
||||
if (name === "agent.open.before") {
|
||||
const previous = beforeSchemas["agent.open.before"].parse(input);
|
||||
@@ -191,6 +241,13 @@ export function validateBeforeResult<Name extends keyof PluginWireBeforeRequests
|
||||
throw new Error("agent.create.before hooks cannot change the workspace directory");
|
||||
}
|
||||
}
|
||||
if (name === "worktree.create.before") {
|
||||
const previous = beforeSchemas["worktree.create.before"].parse(input);
|
||||
const next = beforeSchemas["worktree.create.before"].parse(result);
|
||||
if (previous.repoRoot !== next.repoRoot || previous.sourceCheckout !== next.sourceCheckout) {
|
||||
throw new Error("worktree.create.before hooks cannot change repoRoot or sourceCheckout");
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -244,7 +301,7 @@ export class PluginHookHandlers implements PluginLifecycleRegistration {
|
||||
{ request: structuredClone(request) },
|
||||
{ paseo, signal: controller.signal },
|
||||
);
|
||||
if (result !== undefined) request = validateBeforeResult(hookName, request, result);
|
||||
if (result !== undefined) request = await validateBeforeResult(hookName, request, result);
|
||||
}
|
||||
return request;
|
||||
}
|
||||
@@ -283,7 +340,10 @@ export class PluginHookHandlers implements PluginLifecycleRegistration {
|
||||
if (result === undefined) continue;
|
||||
const local = sessionOpenLocalResultSchema.parse(result);
|
||||
const metadata = local.tools.map(serializeAgentTool);
|
||||
request = validateBeforeResult("agent.open.before", request, { ...local, tools: metadata });
|
||||
request = await validateBeforeResult("agent.open.before", request, {
|
||||
...local,
|
||||
tools: metadata,
|
||||
});
|
||||
tools = local.tools;
|
||||
}
|
||||
context.signal.throwIfAborted();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { validateWorktreePath } from "../../utils/worktree-path.js";
|
||||
import type { PluginHookPayload } from "@getpaseo/plugin/server";
|
||||
import type { PluginWireBeforeRequests, PluginBeforeResults } from "./lifecycle/index.js";
|
||||
import type { SessionOpenWireRequest, AttachedAgentTool } from "./agent-tools.js";
|
||||
@@ -582,7 +583,14 @@ export class PluginRuntime {
|
||||
name,
|
||||
input: name === "agent.open.before" ? { ...request, tools: [], mcpServers: {} } : request,
|
||||
});
|
||||
request = validateBeforeResult(name, request, output);
|
||||
request = await validateBeforeResult(name, request, output);
|
||||
if (name === "worktree.create.before") {
|
||||
const worktree = request as PluginWireBeforeRequests["worktree.create.before"];
|
||||
worktree.path = await validateWorktreePath({
|
||||
candidate: worktree.path,
|
||||
repoPath: worktree.repoRoot,
|
||||
});
|
||||
}
|
||||
if (name === "agent.open.before") {
|
||||
const opened = request as SessionOpenWireRequest;
|
||||
this.collectMcpServers(mcpServers, mcpServerOwners, loaded, opened.mcpServers);
|
||||
|
||||
@@ -5991,6 +5991,9 @@ test("archive_workspace_request archives a worktree-kind workspace and removes t
|
||||
projectId,
|
||||
cwd: worktree.worktreePath,
|
||||
kind: "worktree",
|
||||
worktreeRoot: worktree.worktreePath,
|
||||
mainRepoRoot: repoDir,
|
||||
isPaseoOwnedWorktree: true,
|
||||
displayName: "worktree-kind-archive",
|
||||
createdAt: "2026-03-01T12:00:00.000Z",
|
||||
updatedAt: "2026-03-01T12:00:00.000Z",
|
||||
|
||||
+41
@@ -4,6 +4,7 @@ import {
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
realpathSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
@@ -476,3 +477,43 @@ test("restore rejects a branch checked out elsewhere without inventing another b
|
||||
}).trim(),
|
||||
).toBe("* feature");
|
||||
});
|
||||
|
||||
test("restores a user-created worktree at its recorded path outside Paseo's root", async () => {
|
||||
const { tempDir, repoDir } = createGitRepository();
|
||||
const worktreeRoot = join(realpathSync(tempDir), "external-worktree");
|
||||
execFileSync("git", ["worktree", "add", "-b", "external-feature", worktreeRoot], {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
const workspace = createWorkspace({
|
||||
cwd: worktreeRoot,
|
||||
worktreeRoot,
|
||||
mainRepoRoot: repoDir,
|
||||
branch: "external-feature",
|
||||
baseBranch: "main",
|
||||
isPaseoOwnedWorktree: false,
|
||||
});
|
||||
execFileSync("git", ["worktree", "remove", worktreeRoot], { cwd: repoDir, stdio: "pipe" });
|
||||
const unarchived: string[] = [];
|
||||
const service = createWorkspaceRecoveryService({
|
||||
paseoHome: join(tempDir, "paseo-home"),
|
||||
getWorkspace: async () => workspace,
|
||||
getProject: async () => createProject({ rootPath: repoDir }),
|
||||
isDirectory: async (target) => existsSync(target) && statSync(target).isDirectory(),
|
||||
unarchiveWorkspace: async (record) => {
|
||||
unarchived.push(record.workspaceId);
|
||||
},
|
||||
});
|
||||
await expect(service.restore(workspace.workspaceId)).resolves.toEqual({
|
||||
workspaceId: workspace.workspaceId,
|
||||
action: "restore",
|
||||
});
|
||||
expect(readFileSync(join(worktreeRoot, "README.md"), "utf8").trim()).toBe("initial");
|
||||
expect(
|
||||
execFileSync("git", ["branch", "--show-current"], {
|
||||
cwd: worktreeRoot,
|
||||
encoding: "utf8",
|
||||
}).trim(),
|
||||
).toBe("external-feature");
|
||||
expect(unarchived).toEqual([workspace.workspaceId]);
|
||||
});
|
||||
|
||||
@@ -166,6 +166,7 @@ export function createWorkspaceRecoveryService(deps: {
|
||||
}
|
||||
|
||||
let previousWorktreePath = workspace.worktreeRoot;
|
||||
let hasRecordedWorktreePath = Boolean(previousWorktreePath);
|
||||
if (!previousWorktreePath) {
|
||||
// COMPAT(worktreeRestoreMissingWorktreeRoot): records created before v0.1.110
|
||||
// lack durable backing placement; remove filesystem discovery after 2027-01-17.
|
||||
@@ -176,6 +177,7 @@ export function createWorkspaceRecoveryService(deps: {
|
||||
previousWorktreePath = ownership.allowed
|
||||
? (ownership.worktreePath ?? workspace.cwd)
|
||||
: workspace.cwd;
|
||||
hasRecordedWorktreePath = ownership.allowed;
|
||||
}
|
||||
|
||||
let recreatedWorktreePath: string;
|
||||
@@ -187,6 +189,7 @@ export function createWorkspaceRecoveryService(deps: {
|
||||
|
||||
paseoHome: deps.paseoHome,
|
||||
worktreesRoot: deps.worktreesRoot,
|
||||
...(hasRecordedWorktreePath ? { worktreePath: previousWorktreePath } : {}),
|
||||
});
|
||||
recreatedWorktreePath = result.worktreePath;
|
||||
} catch (error) {
|
||||
|
||||
@@ -7,7 +7,9 @@ import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import type { ForgeService } from "../services/forge-service.js";
|
||||
import { createRealpathAwarePathMatcher } from "../utils/path.js";
|
||||
import { createWorktree, type WorktreeConfig } from "../utils/worktree.js";
|
||||
import { getPaseoWorktreeMetadataPath } from "../utils/worktree-metadata.js";
|
||||
import { archiveCommand } from "./worktree/commands.js";
|
||||
import { createWorktree, listPaseoWorktrees, type WorktreeConfig } from "../utils/worktree.js";
|
||||
import type { ManagedAgent } from "./agent/agent-manager.js";
|
||||
import type { AgentStorage, StoredAgentRecord } from "./agent/agent-storage.js";
|
||||
import type { WorkspaceGitService } from "./workspace-git-service.js";
|
||||
@@ -190,6 +192,54 @@ function assertArchiveResult(
|
||||
}
|
||||
|
||||
describe("archiveByScope", () => {
|
||||
test.each(["workspace", "worktree"] as const)(
|
||||
"%s archive preserves a restored external worktree despite its marker",
|
||||
async (kind) => {
|
||||
const { tempDir, repoDir } = createGitRepo();
|
||||
const paseoHome = path.join(tempDir, ".paseo");
|
||||
const recordedPath = path.join(tempDir, "external");
|
||||
execFileSync("git", ["worktree", "add", "-b", "external", recordedPath], {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
execFileSync("git", ["worktree", "remove", recordedPath], { cwd: repoDir, stdio: "pipe" });
|
||||
const restored = await createWorktree({
|
||||
cwd: repoDir,
|
||||
paseoHome,
|
||||
worktreeSlug: "external",
|
||||
worktreePath: recordedPath,
|
||||
source: { kind: "restore", branchName: "external", baseRef: "main" },
|
||||
});
|
||||
expect(existsSync(await getPaseoWorktreeMetadataPath(restored.worktreePath))).toBe(true);
|
||||
const workspaceId = "external-workspace";
|
||||
const result = await archiveByScope(
|
||||
createArchiveDeps({
|
||||
paseoHome,
|
||||
activeWorkspaces: [
|
||||
{
|
||||
workspaceId,
|
||||
cwd: restored.worktreePath,
|
||||
kind: "worktree",
|
||||
worktreeRoot: restored.worktreePath,
|
||||
mainRepoRoot: repoDir,
|
||||
isPaseoOwnedWorktree: false,
|
||||
},
|
||||
],
|
||||
}),
|
||||
{
|
||||
scope:
|
||||
kind === "workspace"
|
||||
? { kind, workspaceId }
|
||||
: { kind, targetPath: restored.worktreePath },
|
||||
requestId: "archive-external",
|
||||
},
|
||||
);
|
||||
expect(result.removedDirectory).toBe(false);
|
||||
expect(existsSync(restored.worktreePath)).toBe(true);
|
||||
expect(result.archivedWorkspaceIds).toEqual([workspaceId]);
|
||||
},
|
||||
);
|
||||
|
||||
test("workspace scope archives the record and removes the directory on last reference", async () => {
|
||||
const { tempDir, repoDir } = createGitRepo();
|
||||
const paseoHome = path.join(tempDir, ".paseo");
|
||||
@@ -220,6 +270,120 @@ describe("archiveByScope", () => {
|
||||
expect(existsSync(worktree.worktreePath)).toBe(false);
|
||||
});
|
||||
|
||||
test("workspace scope archives the record but leaves a plugin-placed directory whose marker is gone", async () => {
|
||||
const { tempDir, repoDir } = createGitRepo();
|
||||
const paseoHome = path.join(tempDir, ".paseo");
|
||||
const worktree = await createWorktree({
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "unproven-workspace",
|
||||
source: { kind: "branch-off", baseBranch: "main", branchName: "unproven-workspace" },
|
||||
paseoHome,
|
||||
worktreePath: path.join(tempDir, "placed-worktree"),
|
||||
});
|
||||
// Remove the ownership marker: no proof remains for this path.
|
||||
rmSync(await getPaseoWorktreeMetadataPath(worktree.worktreePath), { force: true });
|
||||
const workspaceId = "ws-unproven";
|
||||
|
||||
const logs: Array<{ targetPath: string; msg: string }> = [];
|
||||
const dependencies = createArchiveDeps({
|
||||
paseoHome,
|
||||
activeWorkspaces: [{ workspaceId, cwd: worktree.worktreePath, kind: "worktree" }],
|
||||
});
|
||||
dependencies.sessionLogger = pino(
|
||||
{ level: "warn" },
|
||||
{
|
||||
write(message) {
|
||||
logs.push(JSON.parse(message));
|
||||
},
|
||||
},
|
||||
);
|
||||
const result = await archiveByScope(dependencies, {
|
||||
scope: { kind: "workspace", workspaceId },
|
||||
requestId: "req-unproven-workspace",
|
||||
});
|
||||
|
||||
assertArchiveResult(result, { archivedWorkspaceIds: [workspaceId], removedDirectory: false });
|
||||
expect(result.directoryLeftBehind?.path).toBe(worktree.worktreePath);
|
||||
expect(result.directoryLeftBehind?.reason).toContain("no ownership proof");
|
||||
expect(existsSync(worktree.worktreePath)).toBe(true);
|
||||
expect(logs).toContainEqual(
|
||||
expect.objectContaining({
|
||||
targetPath: worktree.worktreePath,
|
||||
msg: expect.stringContaining("no ownership proof"),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test("archives a plugin-placed worktree by its discovered directory name", async () => {
|
||||
const { tempDir, repoDir } = createGitRepo();
|
||||
const paseoHome = path.join(tempDir, ".paseo");
|
||||
const worktreePath = path.join(tempDir, "sibling-feature");
|
||||
await createWorktree({
|
||||
cwd: repoDir,
|
||||
paseoHome,
|
||||
worktreePath,
|
||||
worktreeSlug: "original-slug",
|
||||
source: { kind: "branch-off", branchName: "feature", baseBranch: "main" },
|
||||
});
|
||||
const dependencies = createArchiveDeps({ paseoHome, activeWorkspaces: [] });
|
||||
const result = await archiveCommand(
|
||||
{
|
||||
...dependencies,
|
||||
workspaceGitService: {
|
||||
...dependencies.workspaceGitService,
|
||||
listWorktrees: (cwd) => listPaseoWorktrees({ cwd, paseoHome }),
|
||||
},
|
||||
},
|
||||
{
|
||||
repoRoot: repoDir,
|
||||
worktreeSlug: "sibling-feature",
|
||||
scope: "worktree",
|
||||
requestId: "slug-archive",
|
||||
},
|
||||
);
|
||||
expect(result).toEqual({ ok: true, removedAgents: [] });
|
||||
expect(existsSync(worktreePath)).toBe(false);
|
||||
});
|
||||
|
||||
test("workspace scope deletes a plugin-placed directory on registry proof even with the marker gone", async () => {
|
||||
const { tempDir, repoDir } = createGitRepo();
|
||||
const paseoHome = path.join(tempDir, ".paseo");
|
||||
const worktree = await createWorktree({
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "registry-proven-workspace",
|
||||
source: {
|
||||
kind: "branch-off",
|
||||
baseBranch: "main",
|
||||
branchName: "registry-proven-workspace",
|
||||
},
|
||||
paseoHome,
|
||||
worktreePath: path.join(tempDir, "placed-worktree"),
|
||||
});
|
||||
rmSync(await getPaseoWorktreeMetadataPath(worktree.worktreePath), { force: true });
|
||||
const workspaceId = "ws-registry-proven";
|
||||
|
||||
const result = await archiveByScope(
|
||||
createArchiveDeps({
|
||||
paseoHome,
|
||||
activeWorkspaces: [
|
||||
{
|
||||
workspaceId,
|
||||
cwd: worktree.worktreePath,
|
||||
kind: "worktree",
|
||||
isPaseoOwnedWorktree: true,
|
||||
worktreeRoot: worktree.worktreePath,
|
||||
mainRepoRoot: repoDir,
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ scope: { kind: "workspace", workspaceId }, requestId: "req-registry-proven-workspace" },
|
||||
);
|
||||
|
||||
assertArchiveResult(result, { archivedWorkspaceIds: [workspaceId], removedDirectory: true });
|
||||
expect(result.directoryLeftBehind).toBeUndefined();
|
||||
expect(existsSync(worktree.worktreePath)).toBe(false);
|
||||
});
|
||||
|
||||
test("workspace scope runs teardown while keeping a directory referenced by a sibling", async () => {
|
||||
const { tempDir, repoDir } = createGitRepo();
|
||||
writeFileSync(
|
||||
|
||||
@@ -69,6 +69,10 @@ export interface ArchiveResult {
|
||||
archivedAgentIds: string[];
|
||||
archivedWorkspaceIds: string[];
|
||||
removedDirectory: boolean;
|
||||
// Set when the record archived but its worktree directory was left on
|
||||
// disk because no ownership proof held for it. The directory is never
|
||||
// deleted in that case; the path and reason surface here and in the log.
|
||||
directoryLeftBehind?: { path: string; reason: string };
|
||||
}
|
||||
|
||||
export interface ArchiveByScopeRequest {
|
||||
@@ -92,6 +96,15 @@ export async function requireActiveWorkspaceForArchive(
|
||||
interface BackingDirectory {
|
||||
path: string;
|
||||
isPaseoOwnedWorktree: boolean;
|
||||
// True when the directory backs a worktree workspace (or a worktree-scope
|
||||
// archive), so a failed ownership proof means a Paseo worktree directory
|
||||
// is being left behind rather than a user's local checkout being ignored.
|
||||
expectsPaseoOwnership: boolean;
|
||||
// True only when this backing came from a workspace registry record
|
||||
// matched to this path (the record is itself an ownership proof, passed
|
||||
// through to deletePaseoWorktree). Disk-derived backings stay false:
|
||||
// they must prove ownership on disk.
|
||||
registryProven: boolean;
|
||||
mainRepoRoot: string | null;
|
||||
paseoWorktreesRoot: string | null;
|
||||
}
|
||||
@@ -141,6 +154,7 @@ async function archiveByScopeWithPriority(
|
||||
}
|
||||
|
||||
let removedDirectory = false;
|
||||
let directoryLeftBehind: ArchiveResult["directoryLeftBehind"];
|
||||
|
||||
try {
|
||||
if (targetWorkspaceIds.length > 0) {
|
||||
@@ -168,18 +182,21 @@ async function archiveByScopeWithPriority(
|
||||
}
|
||||
|
||||
if (target.backing !== null) {
|
||||
removedDirectory = await maybeRemoveDirectory(
|
||||
const removal = await maybeRemoveDirectory(
|
||||
dependencies,
|
||||
request,
|
||||
target,
|
||||
archivedWorkspaceIds,
|
||||
);
|
||||
removedDirectory = removal.removedDirectory;
|
||||
directoryLeftBehind = removal.directoryLeftBehind;
|
||||
}
|
||||
|
||||
return {
|
||||
archivedAgentIds: Array.from(archivedAgents),
|
||||
archivedWorkspaceIds,
|
||||
removedDirectory,
|
||||
...(directoryLeftBehind ? { directoryLeftBehind } : {}),
|
||||
};
|
||||
} finally {
|
||||
if (targetWorkspaceIds.length > 0) {
|
||||
@@ -226,14 +243,16 @@ async function resolveArchiveTarget(
|
||||
}),
|
||||
)
|
||||
).filter((workspace): workspace is ActiveWorkspaceRef => workspace !== null);
|
||||
const persistedMainRepoRoot = targetWorkspaces.find(
|
||||
(workspace) => workspace.mainRepoRoot,
|
||||
)?.mainRepoRoot;
|
||||
const recordedId = await dependencies.findWorkspaceIdForCwd(backing.path);
|
||||
const recorded = recordedId ? await dependencies.getWorkspace?.(recordedId) : null;
|
||||
const record =
|
||||
targetWorkspaces.find((workspace) => workspace.kind === "worktree") ??
|
||||
(recorded && matchesBackingDirectory(recorded.worktreeRoot ?? recorded.cwd)
|
||||
? recorded
|
||||
: null) ??
|
||||
targetWorkspaces[0];
|
||||
return {
|
||||
backing: {
|
||||
...backing,
|
||||
mainRepoRoot: persistedMainRepoRoot ?? backing.mainRepoRoot,
|
||||
},
|
||||
backing: record ? await resolveWorkspaceBackingDirectory(record, dependencies) : backing,
|
||||
teardownTargets:
|
||||
targetWorkspaces.length > 0
|
||||
? targetWorkspaces.map((workspace) => ({
|
||||
@@ -271,10 +290,22 @@ async function resolveWorkspaceBackingDirectory(
|
||||
workspace: ActiveWorkspaceRef,
|
||||
dependencies: Pick<ArchiveDependencies, "paseoHome" | "paseoWorktreesBaseRoot">,
|
||||
): Promise<BackingDirectory> {
|
||||
if (workspace.isPaseoOwnedWorktree === false) {
|
||||
return {
|
||||
path: resolve(workspace.worktreeRoot ?? workspace.cwd),
|
||||
isPaseoOwnedWorktree: false,
|
||||
expectsPaseoOwnership: false,
|
||||
registryProven: false,
|
||||
mainRepoRoot: workspace.mainRepoRoot ?? null,
|
||||
paseoWorktreesRoot: null,
|
||||
};
|
||||
}
|
||||
if (workspace.isPaseoOwnedWorktree && workspace.worktreeRoot && workspace.mainRepoRoot) {
|
||||
return {
|
||||
path: resolve(workspace.worktreeRoot),
|
||||
isPaseoOwnedWorktree: true,
|
||||
expectsPaseoOwnership: true,
|
||||
registryProven: true,
|
||||
mainRepoRoot: workspace.mainRepoRoot,
|
||||
paseoWorktreesRoot: null,
|
||||
};
|
||||
@@ -283,6 +314,8 @@ async function resolveWorkspaceBackingDirectory(
|
||||
return {
|
||||
path: resolve(workspace.cwd),
|
||||
isPaseoOwnedWorktree: false,
|
||||
expectsPaseoOwnership: false,
|
||||
registryProven: false,
|
||||
mainRepoRoot: workspace.mainRepoRoot ?? null,
|
||||
paseoWorktreesRoot: null,
|
||||
};
|
||||
@@ -309,6 +342,8 @@ async function resolveBackingDirectory(
|
||||
return {
|
||||
path: resolve(ownership.allowed && ownership.worktreePath ? ownership.worktreePath : cwd),
|
||||
isPaseoOwnedWorktree: ownership.allowed,
|
||||
expectsPaseoOwnership: true,
|
||||
registryProven: false,
|
||||
mainRepoRoot: ownership.repoRoot ?? null,
|
||||
paseoWorktreesRoot: ownership.worktreeRoot ?? null,
|
||||
};
|
||||
@@ -352,10 +387,28 @@ async function maybeRemoveDirectory(
|
||||
request: Pick<ArchiveByScopeRequest, "requestId">,
|
||||
target: ArchiveTarget,
|
||||
archivedWorkspaceIds: string[],
|
||||
): Promise<boolean> {
|
||||
): Promise<{
|
||||
removedDirectory: boolean;
|
||||
directoryLeftBehind?: { path: string; reason: string };
|
||||
}> {
|
||||
const backing = target.backing;
|
||||
if (!backing?.isPaseoOwnedWorktree) {
|
||||
return false;
|
||||
// Missing ownership proof for a worktree-backed directory: the record
|
||||
// still archives, but the directory is left in place and surfaced.
|
||||
// Never delete an unproven directory.
|
||||
if (backing?.expectsPaseoOwnership) {
|
||||
const reason =
|
||||
"no ownership proof (not under the Paseo worktrees root, no paseo/worktree.json marker, no registry record)";
|
||||
dependencies.sessionLogger?.warn(
|
||||
{ targetPath: backing.path, requestId: request.requestId },
|
||||
`Worktree directory left in place during archive: ${reason}`,
|
||||
);
|
||||
return {
|
||||
removedDirectory: false,
|
||||
directoryLeftBehind: { path: backing.path, reason },
|
||||
};
|
||||
}
|
||||
return { removedDirectory: false };
|
||||
}
|
||||
|
||||
const archivedWorkspaceIdSet = new Set(archivedWorkspaceIds);
|
||||
@@ -382,7 +435,7 @@ async function maybeRemoveDirectory(
|
||||
{ err: error, targetPath: backing.path, requestId: request.requestId },
|
||||
"Worktree teardown failed during archive; workspace already archived",
|
||||
);
|
||||
return false;
|
||||
return { removedDirectory: false };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
@@ -396,7 +449,7 @@ async function maybeRemoveDirectory(
|
||||
dependencies,
|
||||
))
|
||||
) {
|
||||
return false;
|
||||
return { removedDirectory: false };
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -407,16 +460,17 @@ async function maybeRemoveDirectory(
|
||||
worktreesRoot: backing.paseoWorktreesRoot ?? undefined,
|
||||
paseoHome: dependencies.paseoHome,
|
||||
worktreesBaseRoot: dependencies.paseoWorktreesBaseRoot,
|
||||
registryProven: backing.registryProven,
|
||||
});
|
||||
dependencies.workspaceGitService.invalidateWorktrees();
|
||||
dependencies.github.invalidate({ cwd: backing.path });
|
||||
return true;
|
||||
return { removedDirectory: true };
|
||||
} catch (error) {
|
||||
dependencies.sessionLogger?.warn(
|
||||
{ err: error, targetPath: backing.path, requestId: request.requestId },
|
||||
"Worktree disk removal failed during archive; workspace already archived",
|
||||
);
|
||||
return false;
|
||||
return { removedDirectory: false };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
getPaseoWorktreeMetadataPath,
|
||||
readPaseoWorktreeMetadata,
|
||||
} from "../utils/worktree-metadata.js";
|
||||
import { validateBeforeRequest, validateBeforeResult } from "./plugins/lifecycle/index.js";
|
||||
import { UnknownBranchError } from "../utils/worktree.js";
|
||||
import { createWorktreeCore as createCoreWorktree } from "./worktree-core.js";
|
||||
import { isPlatform } from "../test-utils/platform.js";
|
||||
@@ -419,6 +420,21 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => {
|
||||
expect(existsSync(result.worktree.worktreePath)).toBe(true);
|
||||
});
|
||||
|
||||
test("keeps default placement suffixes when the default destination already exists", async () => {
|
||||
const { tempDir, repoDir, paseoHome } = createGitRepo();
|
||||
cleanupPaths.push(tempDir);
|
||||
const first = await createCoreWorktree(
|
||||
{ cwd: repoDir, paseoHome, branchName: "first", worktreeSlug: "same" },
|
||||
createCoreDeps(),
|
||||
);
|
||||
const second = await createCoreWorktree(
|
||||
{ cwd: repoDir, paseoHome, branchName: "second", worktreeSlug: "same" },
|
||||
createCoreDeps(),
|
||||
);
|
||||
expect(second.worktree.worktreePath).toBe(first.worktree.worktreePath + "-1");
|
||||
expect(existsSync(first.worktree.worktreePath)).toBe(true);
|
||||
});
|
||||
|
||||
test("creates branch-off worktrees from origin main without tracking origin main", async () => {
|
||||
const { tempDir, repoDir, paseoHome } = createGitRepoWithOriginMain();
|
||||
cleanupPaths.push(tempDir);
|
||||
@@ -488,6 +504,42 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => {
|
||||
expect(result.worktree.branchName).toBe("feature/review-pr");
|
||||
});
|
||||
|
||||
test("round trips every resolved change-request field through an unchanged before-hook result", async () => {
|
||||
const { tempDir, repoDir, paseoHome } = createGitHubPrRemoteRepo();
|
||||
cleanupPaths.push(tempDir);
|
||||
const deps = createCoreDeps();
|
||||
const requests: unknown[] = [];
|
||||
const result = await createCoreWorktree(
|
||||
{ cwd: repoDir, githubPrNumber: 123, refName: "feature/review-pr", paseoHome },
|
||||
{
|
||||
...deps,
|
||||
lifecycle: {
|
||||
before: async (name, request) => {
|
||||
requests.push(request);
|
||||
return validateBeforeResult(name, validateBeforeRequest(name, request), request);
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(requests).toEqual([
|
||||
{
|
||||
kind: "checkout-change-request",
|
||||
forge: "github",
|
||||
changeRequestNumber: 123,
|
||||
headRef: "feature/review-pr",
|
||||
baseRef: "main",
|
||||
branch: "feature/review-pr",
|
||||
checkoutRefs: [{ remoteName: "origin", remoteRef: "refs/pull/123/head" }],
|
||||
trackOriginHead: true,
|
||||
repoRoot: repoDir,
|
||||
sourceCheckout: repoDir,
|
||||
slug: "feature-review-pr",
|
||||
path: result.worktree.worktreePath,
|
||||
},
|
||||
]);
|
||||
expect(result.worktree.branchName).toBe("feature/review-pr");
|
||||
});
|
||||
|
||||
test("uses the PR head ref as the default slug when no slug is supplied", async () => {
|
||||
const { tempDir, repoDir, paseoHome } = createGitHubPrRemoteRepo();
|
||||
cleanupPaths.push(tempDir);
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import type { PluginWorktreeCreateRequest } from "@getpaseo/plugin/server";
|
||||
import type { PluginLifecycle } from "./plugins/lifecycle/index.js";
|
||||
import { createNameId } from "mnemonic-id";
|
||||
|
||||
import type { ForgeService } from "../services/forge-service.js";
|
||||
import {
|
||||
createWorktree,
|
||||
resolveDefaultWorktreePath,
|
||||
slugify,
|
||||
validateBranchSlug,
|
||||
type CreatedWorktree,
|
||||
@@ -15,7 +18,7 @@ import {
|
||||
} from "./resolve-worktree-creation-intent.js";
|
||||
import type { ChangeRequestCheckoutSource, FirstAgentContext } from "@getpaseo/protocol/messages";
|
||||
import type { WorkspaceGitService } from "./workspace-git-service.js";
|
||||
import { runWithGitCommandPriority } from "../utils/run-git-command.js";
|
||||
import { runGitCommand, runWithGitCommandPriority } from "../utils/run-git-command.js";
|
||||
|
||||
export interface CreateWorktreeCoreInput {
|
||||
cwd: string;
|
||||
@@ -31,6 +34,7 @@ export interface CreateWorktreeCoreInput {
|
||||
}
|
||||
|
||||
export interface CreateWorktreeCoreDeps {
|
||||
lifecycle?: Pick<PluginLifecycle, "before">;
|
||||
github: ForgeService;
|
||||
workspaceGitService?: Pick<
|
||||
WorkspaceGitService,
|
||||
@@ -90,7 +94,7 @@ async function createWorktreeCoreWithPriority(
|
||||
}
|
||||
|
||||
const forge = await resolveForge(repoRoot, deps, intentInput);
|
||||
const intent = await resolveWorktreeCreationIntent(intentInput, repoRoot, {
|
||||
let intent = await resolveWorktreeCreationIntent(intentInput, repoRoot, {
|
||||
forge: forge.forge,
|
||||
forgeService: forge.service,
|
||||
resolveDefaultBranch: (root) => resolveDefaultBranch(root, deps),
|
||||
@@ -114,10 +118,33 @@ async function createWorktreeCoreWithPriority(
|
||||
}
|
||||
}
|
||||
|
||||
const defaultPath = await resolveDefaultWorktreePath({
|
||||
cwd: repoRoot,
|
||||
paseoHome: input.paseoHome,
|
||||
worktreesRoot: input.worktreesRoot,
|
||||
worktreeSlug: normalizedSlug,
|
||||
});
|
||||
const { stdout: sourceRoot } = await runGitCommand(["rev-parse", "--show-toplevel"], {
|
||||
cwd: input.cwd,
|
||||
});
|
||||
const sourceCheckout = sourceRoot.trim();
|
||||
const request = describeCreationRequest(intent, {
|
||||
repoRoot,
|
||||
sourceCheckout,
|
||||
slug: normalizedSlug,
|
||||
path: defaultPath,
|
||||
});
|
||||
const transformed = deps.lifecycle
|
||||
? await deps.lifecycle.before("worktree.create.before", request)
|
||||
: request;
|
||||
normalizedSlug = validateWorktreeSlug(transformed.slug);
|
||||
intent = creationIntentFromRequest(transformed, intent);
|
||||
|
||||
return {
|
||||
worktree: await createWorktree({
|
||||
cwd: repoRoot,
|
||||
worktreeSlug: normalizedSlug,
|
||||
worktreePath: transformed.path,
|
||||
source: intent,
|
||||
paseoHome: input.paseoHome,
|
||||
worktreesRoot: input.worktreesRoot,
|
||||
@@ -128,6 +155,61 @@ async function createWorktreeCoreWithPriority(
|
||||
};
|
||||
}
|
||||
|
||||
function describeCreationRequest(
|
||||
intent: WorktreeCreationIntent,
|
||||
placement: Pick<PluginWorktreeCreateRequest, "repoRoot" | "sourceCheckout" | "slug" | "path">,
|
||||
): PluginWorktreeCreateRequest {
|
||||
if (intent.kind === "branch-off")
|
||||
return {
|
||||
...placement,
|
||||
kind: intent.kind,
|
||||
branch: intent.branchName,
|
||||
baseRef: intent.baseBranch,
|
||||
};
|
||||
if (intent.kind === "checkout-branch")
|
||||
return { ...placement, kind: intent.kind, branch: intent.branchName };
|
||||
const { localBranchName, baseRefName, ...facts } = intent;
|
||||
return {
|
||||
...placement,
|
||||
...facts,
|
||||
branch: localBranchName ?? intent.headRef,
|
||||
baseRef: baseRefName,
|
||||
};
|
||||
}
|
||||
|
||||
function creationIntentFromRequest(
|
||||
request: PluginWorktreeCreateRequest,
|
||||
original: WorktreeCreationIntent,
|
||||
): WorktreeCreationIntent {
|
||||
if (request.kind === "branch-off") {
|
||||
return { kind: request.kind, branchName: request.branch, baseBranch: request.baseRef };
|
||||
}
|
||||
if (request.kind === "checkout-branch") {
|
||||
return {
|
||||
kind: request.kind,
|
||||
branchName: request.branch,
|
||||
};
|
||||
}
|
||||
const {
|
||||
repoRoot: _repoRoot,
|
||||
sourceCheckout: _checkout,
|
||||
slug: _slug,
|
||||
path: _path,
|
||||
branch,
|
||||
baseRef,
|
||||
...facts
|
||||
} = request;
|
||||
const keepLocalBranchName =
|
||||
branch !== request.headRef ||
|
||||
((original.kind === "checkout-change-request" || original.kind === "checkout-github-pr") &&
|
||||
original.localBranchName !== undefined);
|
||||
return {
|
||||
...facts,
|
||||
...(keepLocalBranchName ? { localBranchName: branch } : {}),
|
||||
baseRefName: baseRef,
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveForge(
|
||||
repoRoot: string,
|
||||
deps: CreateWorktreeCoreDeps,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { join } from "node:path";
|
||||
import { basename } from "node:path";
|
||||
|
||||
import { getPaseoWorktreesRoot, isPaseoOwnedWorktreeCwd } from "../../utils/worktree.js";
|
||||
import { isPaseoOwnedWorktreeCwd } from "../../utils/worktree.js";
|
||||
import {
|
||||
archiveByScope,
|
||||
resolveWorkspaceIdAtPath,
|
||||
@@ -208,10 +208,18 @@ async function resolveWorktreeSlugPath(
|
||||
repoRoot: string,
|
||||
worktreeSlug: string,
|
||||
): Promise<string> {
|
||||
const worktreesRoot = await getPaseoWorktreesRoot(
|
||||
repoRoot,
|
||||
dependencies.paseoHome,
|
||||
dependencies.paseoWorktreesBaseRoot,
|
||||
);
|
||||
return join(worktreesRoot, worktreeSlug);
|
||||
// Worktrees can live anywhere (legacy root or wherever
|
||||
// a plugin placed them), so a slug resolves against the
|
||||
// discovered worktree list for the repo, never a guessed path.
|
||||
const worktrees = await dependencies.workspaceGitService.listWorktrees(repoRoot);
|
||||
const match = worktrees.find((entry) => basename(entry.path) === worktreeSlug);
|
||||
if (!match) {
|
||||
const found = worktrees.map((entry) => basename(entry.path));
|
||||
throw new Error(
|
||||
found.length > 0
|
||||
? `Paseo worktree not found for slug ${worktreeSlug}: found ${found.join(", ")}`
|
||||
: `Paseo worktree not found for slug ${worktreeSlug}`,
|
||||
);
|
||||
}
|
||||
return match.path;
|
||||
}
|
||||
|
||||
@@ -45,6 +45,8 @@ import { unquoteGitPath } from "./git-quoted-path.js";
|
||||
import { isPaseoOwnedWorktreeCwd, resolvePaseoWorktreesBaseRoot } from "./worktree.js";
|
||||
import {
|
||||
branchNameFromRef,
|
||||
findPaseoWorktreeRootForCwd,
|
||||
hasPaseoWorktreeMetadata,
|
||||
getPaseoWorktreeChangeRequestHintForBranch,
|
||||
type PaseoWorktreeMetadata,
|
||||
readPaseoWorktreeMetadata,
|
||||
@@ -1126,9 +1128,17 @@ async function getMainRepoRootFromCommonDir(
|
||||
},
|
||||
);
|
||||
const worktrees = parseWorktreeList(worktreeOut);
|
||||
const paseoPaths = new Set(
|
||||
(
|
||||
await Promise.all(
|
||||
worktrees.map(async (wt) => ((await hasPaseoWorktreeMetadata(wt.path)) ? wt.path : null)),
|
||||
)
|
||||
).filter((p) => p !== null),
|
||||
);
|
||||
const nonBareNonPaseo = worktrees.filter(
|
||||
(wt) =>
|
||||
!wt.isBare &&
|
||||
!paseoPaths.has(wt.path) &&
|
||||
!isPaseoWorktreePath(wt.path, {
|
||||
paseoHome: context?.paseoHome,
|
||||
worktreesRoot: context?.worktreesRoot,
|
||||
@@ -1257,7 +1267,7 @@ async function getPaseoWorktreeForCwd(
|
||||
options: PaseoWorktreeLookupOptions = {},
|
||||
): Promise<PaseoWorktreeForCwd> {
|
||||
// Fast-path reject: non-worktree paths do not need expensive ownership checks.
|
||||
if (!/[\\/]worktrees[\\/]/.test(cwd)) {
|
||||
if (!/[\\/]worktrees[\\/]/.test(cwd) && (await findPaseoWorktreeRootForCwd(cwd)) === null) {
|
||||
return { isPaseoOwnedWorktree: false };
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { mkdir, open, realpath, rename, stat, writeFile } from "node:fs/promises
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { constants } from "node:fs";
|
||||
import pLimit from "p-limit";
|
||||
import { isAbsolute, join, resolve } from "path";
|
||||
import { dirname, isAbsolute, join, resolve } from "path";
|
||||
import { z } from "zod";
|
||||
|
||||
const MAX_WORKTREE_METADATA_BYTES = 64 * 1024;
|
||||
@@ -329,6 +329,39 @@ export async function markPaseoWorktreeFirstAgentBranchAutoNameAttempted(
|
||||
});
|
||||
}
|
||||
|
||||
export async function hasPaseoWorktreeMetadata(worktreeRoot: string): Promise<boolean> {
|
||||
try {
|
||||
return (await readPaseoWorktreeMetadata(worktreeRoot)) !== null;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Filesystem-only search for a Paseo worktree root at or above cwd: the
|
||||
* first directory containing a .git entry wins, and it only counts when
|
||||
* its git admin dir holds the paseo/worktree.json marker. Lets callers
|
||||
* cheaply rule out plain directories before running git ownership checks.
|
||||
*/
|
||||
export async function findPaseoWorktreeRootForCwd(cwd: string): Promise<string | null> {
|
||||
let current = resolve(cwd);
|
||||
for (;;) {
|
||||
if (
|
||||
await stat(join(current, ".git")).then(
|
||||
() => true,
|
||||
() => false,
|
||||
)
|
||||
) {
|
||||
return (await hasPaseoWorktreeMetadata(current)) ? current : null;
|
||||
}
|
||||
const parent = dirname(current);
|
||||
if (parent === current) {
|
||||
return null;
|
||||
}
|
||||
current = parent;
|
||||
}
|
||||
}
|
||||
|
||||
export async function readPaseoWorktreeMetadata(
|
||||
worktreeRoot: string,
|
||||
): Promise<PaseoWorktreeMetadata | null> {
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { realpath, lstat } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import { basename, dirname, isAbsolute, join, parse, resolve, sep } from "node:path";
|
||||
import { getRelativePathInsideRoot } from "./path.js";
|
||||
|
||||
async function normalizeAbsolute(path: string): Promise<string> {
|
||||
try {
|
||||
return await realpath(path);
|
||||
} catch {
|
||||
return resolve(path);
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve a path that does not exist yet (the normal case for a worktree
|
||||
// destination): realpath the nearest existing ancestor, then append the
|
||||
// missing tail. A plain resolve() would let a symlinked parent smuggle
|
||||
// the destination past the denylist — e.g. <repo>/alias -> <repo>/.git.
|
||||
async function normalizeCandidatePath(path: string): Promise<string> {
|
||||
let current = resolve(path);
|
||||
const missing: string[] = [];
|
||||
while (!(await pathExists(current))) {
|
||||
missing.unshift(basename(current));
|
||||
const parent = dirname(current);
|
||||
if (parent === current) {
|
||||
break;
|
||||
}
|
||||
current = parent;
|
||||
}
|
||||
const resolvedAncestor = await normalizeAbsolute(current);
|
||||
return missing.length > 0 ? join(resolvedAncestor, ...missing) : resolvedAncestor;
|
||||
}
|
||||
|
||||
async function pathExists(path: string): Promise<boolean> {
|
||||
try {
|
||||
await lstat(path);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a worktree destination. Throws
|
||||
* with the reason on any violation; returns the normalized absolute path.
|
||||
*/
|
||||
export async function validateWorktreePath(options: {
|
||||
candidate: string;
|
||||
repoPath: string;
|
||||
}): Promise<string> {
|
||||
const { candidate, repoPath } = options;
|
||||
if (!isAbsolute(candidate)) {
|
||||
throw new Error(
|
||||
`Worktree hook returned a relative path '${candidate}'; it must be an absolute path`,
|
||||
);
|
||||
}
|
||||
const normalized = await normalizeCandidatePath(candidate);
|
||||
const normalizedRepoPath = await normalizeAbsolute(repoPath);
|
||||
|
||||
if (normalized === parse(normalized).root) {
|
||||
throw new Error("Worktree hook returned the filesystem root, which is not allowed");
|
||||
}
|
||||
if (normalized === (await normalizeAbsolute(homedir()))) {
|
||||
throw new Error("Worktree hook returned the user's home directory, which is not allowed");
|
||||
}
|
||||
if (normalized === normalizedRepoPath) {
|
||||
throw new Error("Worktree hook returned the repository root itself, which is not allowed");
|
||||
}
|
||||
if (
|
||||
[normalized, resolve(candidate)].some((path) =>
|
||||
path
|
||||
.split(sep)
|
||||
.some((part) => (process.platform === "win32" ? part.toLowerCase() : part) === ".git"),
|
||||
)
|
||||
) {
|
||||
throw new Error(`Worktree hook returned a path inside a .git directory: ${normalized}`);
|
||||
}
|
||||
const relativeToCandidate = getRelativePathInsideRoot(normalized, normalizedRepoPath);
|
||||
if (relativeToCandidate !== null && relativeToCandidate !== "") {
|
||||
throw new Error(`Worktree hook returned an ancestor of the repository: ${normalized}`);
|
||||
}
|
||||
if ((await pathExists(normalized)) || (await pathExists(candidate))) {
|
||||
throw new Error(
|
||||
`Worktree hook returned a path that already exists: ${normalized}. Paseo creates the returned path.`,
|
||||
);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
// POSIX-only: git worktree and teardown shell fixtures
|
||||
/* eslint-disable max-nested-callbacks */
|
||||
import { validateWorktreePath } from "./worktree-path.js";
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import {
|
||||
createWorktree as createWorktreePrimitive,
|
||||
@@ -159,7 +160,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
|
||||
).resolves.toMatchObject({ allowed: true, worktreeRoot: join(worktreesRoot, projectHash) });
|
||||
await expect(
|
||||
isPaseoOwnedWorktreeCwd(result.worktreePath, { paseoHome }),
|
||||
).resolves.toMatchObject({ allowed: false });
|
||||
).resolves.toMatchObject({ allowed: true, worktreePath: result.worktreePath });
|
||||
|
||||
const worktrees = await listPaseoWorktrees({ cwd: repoDir, paseoHome, worktreesRoot });
|
||||
expect(worktrees.map((entry) => entry.path)).toContain(result.worktreePath);
|
||||
@@ -1380,6 +1381,200 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("worktrees at explicit paths", () => {
|
||||
let tempDir: string;
|
||||
let repoDir: string;
|
||||
let paseoHome: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = realpathSync(mkdtempSync(join(tmpdir(), "worktree-explicit-test-")));
|
||||
repoDir = join(tempDir, "test-repo");
|
||||
paseoHome = join(tempDir, "paseo-home");
|
||||
|
||||
mkdirSync(repoDir, { recursive: true });
|
||||
execFileSync("git", ["init", "-b", "main"], { cwd: repoDir });
|
||||
execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: repoDir });
|
||||
execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir });
|
||||
writeFileSync(join(repoDir, "file.txt"), "hello\n");
|
||||
execFileSync("git", ["add", "."], { cwd: repoDir });
|
||||
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "initial"], {
|
||||
cwd: repoDir,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("places, owns, lists, and deletes a worktree outside the legacy root", async () => {
|
||||
// Sibling layout: <repo-parent>/wt-<slug>
|
||||
|
||||
const result = await createWorktreePrimitive({
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "sibling-one",
|
||||
source: { kind: "branch-off", baseBranch: "main", branchName: "feature/sibling" },
|
||||
paseoHome,
|
||||
worktreePath: join(tempDir, "wt-sibling-one"),
|
||||
});
|
||||
|
||||
expect(result.worktreePath).toBe(join(tempDir, "wt-sibling-one"));
|
||||
expect(existsSync(join(result.worktreePath, "file.txt"))).toBe(true);
|
||||
expect(existsSync(await getPaseoWorktreeMetadataPath(result.worktreePath))).toBe(true);
|
||||
|
||||
const ownership = await isPaseoOwnedWorktreeCwd(result.worktreePath, { paseoHome });
|
||||
expect(ownership.allowed).toBe(true);
|
||||
expect(ownership.worktreePath).toBe(result.worktreePath);
|
||||
// A subdirectory of the worktree resolves to the worktree root.
|
||||
mkdirSync(join(result.worktreePath, "sub"), { recursive: true });
|
||||
const nested = await isPaseoOwnedWorktreeCwd(join(result.worktreePath, "sub"), {
|
||||
paseoHome,
|
||||
});
|
||||
expect(nested.worktreePath).toBe(result.worktreePath);
|
||||
|
||||
const listed = await listPaseoWorktrees({ cwd: repoDir, paseoHome });
|
||||
expect(listed.map((entry) => entry.path)).toContain(result.worktreePath);
|
||||
|
||||
await deletePaseoWorktree({ cwd: repoDir, worktreePath: result.worktreePath, paseoHome });
|
||||
expect(existsSync(result.worktreePath)).toBe(false);
|
||||
});
|
||||
|
||||
it("removes branches fetched by the source plan when git worktree add fails", async () => {
|
||||
// A branch that exists only on the remote: resolving a
|
||||
// checkout-branch plan fetches it into a local branch. A script
|
||||
// failure must not leave that branch behind, or the next attempt
|
||||
// would uniquify to a different branch name.
|
||||
const remoteDir = join(tempDir, "remote.git");
|
||||
execFileSync("git", ["clone", "--bare", repoDir, remoteDir], { cwd: tempDir });
|
||||
execFileSync("git", ["remote", "add", "origin", remoteDir], { cwd: repoDir });
|
||||
execFileSync("git", ["push", "origin", "main"], { cwd: repoDir });
|
||||
execFileSync("git", ["branch", "feature/remote-check"], { cwd: repoDir });
|
||||
execFileSync("git", ["push", "origin", "feature/remote-check"], { cwd: repoDir });
|
||||
execFileSync("git", ["branch", "-D", "feature/remote-check"], { cwd: repoDir });
|
||||
execFileSync("git", ["fetch", "origin"], { cwd: repoDir });
|
||||
|
||||
mkdirSync(join(tempDir, "occupied"));
|
||||
writeFileSync(join(tempDir, "occupied", "keep"), "keep");
|
||||
await expect(
|
||||
createWorktreePrimitive({
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "remote-check",
|
||||
source: { kind: "checkout-branch", branchName: "feature/remote-check" },
|
||||
paseoHome,
|
||||
worktreePath: join(tempDir, "occupied"),
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
|
||||
const localBranches = execFileSync("git", ["branch", "--list", "feature/remote-check"], {
|
||||
cwd: repoDir,
|
||||
encoding: "utf8",
|
||||
});
|
||||
expect(localBranches.trim()).toBe("");
|
||||
});
|
||||
|
||||
it("deletes only the registered destination nested beneath the legacy root", async () => {
|
||||
const parent = join(paseoHome, "worktrees", "acme", "widgets");
|
||||
const worktreePath = join(parent, "topic");
|
||||
const siblingPath = join(parent, "sibling");
|
||||
await createWorktreePrimitive({
|
||||
cwd: repoDir,
|
||||
paseoHome,
|
||||
worktreeSlug: "topic",
|
||||
worktreePath,
|
||||
source: { kind: "branch-off", branchName: "topic", baseBranch: "main" },
|
||||
});
|
||||
await createWorktreePrimitive({
|
||||
cwd: repoDir,
|
||||
paseoHome,
|
||||
worktreeSlug: "sibling",
|
||||
worktreePath: siblingPath,
|
||||
source: { kind: "branch-off", branchName: "sibling", baseBranch: "main" },
|
||||
});
|
||||
mkdirSync(join(worktreePath, "nested"));
|
||||
expect(
|
||||
await isPaseoOwnedWorktreeCwd(join(worktreePath, "nested"), { paseoHome }),
|
||||
).toMatchObject({ allowed: true, worktreePath });
|
||||
await deletePaseoWorktree({ cwd: repoDir, paseoHome, worktreePath, registryProven: true });
|
||||
expect(existsSync(worktreePath)).toBe(false);
|
||||
expect(readFileSync(join(siblingPath, "file.txt"), "utf8")).toBe("hello\n");
|
||||
});
|
||||
|
||||
it("rejects a destination under a symlink into .git", async () => {
|
||||
const alias = join(tempDir, "git-alias");
|
||||
symlinkSync(join(repoDir, ".git"), alias, "dir");
|
||||
await expect(
|
||||
validateWorktreePath({ candidate: join(alias, "new", "worktree"), repoPath: repoDir }),
|
||||
).rejects.toThrow("inside a .git directory");
|
||||
expect(existsSync(join(repoDir, ".git", "new"))).toBe(false);
|
||||
});
|
||||
|
||||
it("uses an explicit worktreePath verbatim", async () => {
|
||||
const explicitPath = join(tempDir, "explicit-wt");
|
||||
|
||||
const result = await createWorktreePrimitive({
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "explicit-one",
|
||||
source: { kind: "branch-off", baseBranch: "main", branchName: "feature/explicit" },
|
||||
paseoHome,
|
||||
worktreePath: explicitPath,
|
||||
});
|
||||
|
||||
expect(result.worktreePath).toBe(explicitPath);
|
||||
expect(existsSync(join(explicitPath, "file.txt"))).toBe(true);
|
||||
});
|
||||
|
||||
it("restore recreates at the recorded path", async () => {
|
||||
const created = await createWorktreePrimitive({
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "restorable",
|
||||
source: { kind: "branch-off", baseBranch: "main", branchName: "feature/restorable" },
|
||||
paseoHome,
|
||||
worktreePath: join(tempDir, "wt-sibling-one"),
|
||||
});
|
||||
await deletePaseoWorktree({ cwd: repoDir, worktreePath: created.worktreePath, paseoHome });
|
||||
expect(existsSync(created.worktreePath)).toBe(false);
|
||||
|
||||
const restored = await createWorktreePrimitive({
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "restorable",
|
||||
source: { kind: "restore", branchName: "feature/restorable", baseRef: "main" },
|
||||
paseoHome,
|
||||
worktreePath: created.worktreePath,
|
||||
});
|
||||
|
||||
expect(restored.worktreePath).toBe(created.worktreePath);
|
||||
expect(existsSync(join(restored.worktreePath, "file.txt"))).toBe(true);
|
||||
});
|
||||
|
||||
it("does not treat a copied marker in a non-worktree directory as ownership", async () => {
|
||||
const created = await createWorktreePrimitive({
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "marker-source",
|
||||
source: { kind: "branch-off", baseBranch: "main", branchName: "feature/marker" },
|
||||
paseoHome,
|
||||
worktreePath: join(tempDir, "wt-sibling-one"),
|
||||
});
|
||||
const metadata = await readPaseoWorktreeMetadata(created.worktreePath);
|
||||
expect(metadata).not.toBeNull();
|
||||
|
||||
// A plain directory with a .git file pointing at a copied admin dir
|
||||
// that holds a paseo/worktree.json. Git does not list it as a
|
||||
// worktree, so the copied marker must not prove ownership.
|
||||
const fakeDir = join(tempDir, "fake-worktree");
|
||||
const fakeAdmin = join(tempDir, "fake-admin");
|
||||
mkdirSync(join(fakeAdmin, "paseo"), { recursive: true });
|
||||
mkdirSync(fakeDir, { recursive: true });
|
||||
writeFileSync(join(fakeAdmin, "paseo", "worktree.json"), JSON.stringify(metadata));
|
||||
writeFileSync(join(fakeDir, ".git"), `gitdir: ${fakeAdmin}\n`);
|
||||
|
||||
const ownership = await isPaseoOwnedWorktreeCwd(fakeDir, { paseoHome });
|
||||
expect(ownership.allowed).toBe(false);
|
||||
await expect(
|
||||
deletePaseoWorktree({ cwd: repoDir, worktreePath: fakeDir, paseoHome }),
|
||||
).rejects.toThrow("Refusing to delete non-Paseo worktree");
|
||||
expect(existsSync(fakeDir)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("paseo worktree manager", () => {
|
||||
let tempDir: string;
|
||||
let repoDir: string;
|
||||
|
||||
@@ -302,6 +302,22 @@ describe("paseo worktree manager", () => {
|
||||
expect(existsSync(created.worktreePath)).toBe(false);
|
||||
});
|
||||
|
||||
it("deletes a worktree when Git reports its source through native and long path spellings", async () => {
|
||||
const created = await createLegacyWorktreeForTest({
|
||||
branchName: "identity-delete",
|
||||
cwd: repoDir,
|
||||
baseBranch: "main",
|
||||
worktreeSlug: "identity-delete",
|
||||
paseoHome,
|
||||
});
|
||||
await deletePaseoWorktree({
|
||||
cwd: realpathSync.native(repoDir),
|
||||
worktreePath: created.worktreePath,
|
||||
paseoHome,
|
||||
});
|
||||
expect(existsSync(created.worktreePath)).toBe(false);
|
||||
});
|
||||
|
||||
it("is idempotent: deleting an already-absent worktree succeeds", async () => {
|
||||
const created = await createLegacyWorktreeForTest({
|
||||
branchName: "idempotent-delete-branch",
|
||||
|
||||
@@ -24,6 +24,8 @@ export {
|
||||
import { type PaseoConfig } from "@getpaseo/protocol/paseo-config-schema";
|
||||
import {
|
||||
createPaseoWorktreeChangeRequestHint,
|
||||
hasPaseoWorktreeMetadata,
|
||||
findPaseoWorktreeRootForCwd,
|
||||
normalizeBaseRefName,
|
||||
isQualifiedRef,
|
||||
type PaseoWorktreeChangeRequestHint,
|
||||
@@ -218,6 +220,7 @@ export interface CreateWorktreeOptions {
|
||||
source: WorktreeSource;
|
||||
paseoHome?: string;
|
||||
worktreesRoot?: string;
|
||||
worktreePath?: string;
|
||||
}
|
||||
|
||||
export class BranchAlreadyCheckedOutError extends Error {
|
||||
@@ -964,6 +967,24 @@ export async function isPaseoOwnedWorktreeCwd(
|
||||
}
|
||||
}
|
||||
|
||||
// Worktrees placed at plugin-chosen paths are
|
||||
// proven by the marker createWorktree writes unconditionally: a
|
||||
// paseo/worktree.json in the worktree's git admin dir, with the path
|
||||
// still registered in the repo's `git worktree list`. The marker alone
|
||||
// is not proof — it lives in a git admin dir a user can copy into a
|
||||
// hand-made directory that git does not know about.
|
||||
if (options?.knownGitCommonDir !== null) {
|
||||
const metadataProof = await resolveMetadataOwnedWorktree(cwd, repoRoot);
|
||||
if (metadataProof) {
|
||||
return {
|
||||
allowed: true,
|
||||
...(repoRoot !== undefined ? { repoRoot } : {}),
|
||||
worktreeRoot: metadataProof.worktreeRoot,
|
||||
worktreePath: metadataProof.worktreePath,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const worktreesBaseRoot = resolvePaseoWorktreesBaseRoot(options);
|
||||
const relativePath = await getOwnershipRelativePath(worktreesBaseRoot, resolvedCwd);
|
||||
|
||||
@@ -971,32 +992,51 @@ export async function isPaseoOwnedWorktreeCwd(
|
||||
// The <hash>/<slug> prefix is Paseo-private — nothing else writes there — so the
|
||||
// path shape alone is sufficient proof of ownership, even when git has already
|
||||
// forgotten about the worktree.
|
||||
if (relativePath === null) {
|
||||
return {
|
||||
allowed: false,
|
||||
...(repoRoot !== undefined ? { repoRoot } : {}),
|
||||
worktreePath: resolvedCwd,
|
||||
};
|
||||
if (relativePath !== null) {
|
||||
const parts = relativePath.split(sep).filter((part) => part.length > 0);
|
||||
if (parts.length >= 2) {
|
||||
const worktreesRoot = join(worktreesBaseRoot, parts[0]);
|
||||
return {
|
||||
allowed: true,
|
||||
...(repoRoot !== undefined ? { repoRoot } : {}),
|
||||
worktreeRoot: worktreesRoot,
|
||||
worktreePath: join(worktreesRoot, parts[1]),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const parts = relativePath.split(sep).filter((part) => part.length > 0);
|
||||
if (parts.length < 2) {
|
||||
return {
|
||||
allowed: false,
|
||||
...(repoRoot !== undefined ? { repoRoot } : {}),
|
||||
worktreePath: resolvedCwd,
|
||||
};
|
||||
}
|
||||
|
||||
const worktreesRoot = join(worktreesBaseRoot, parts[0]);
|
||||
return {
|
||||
allowed: true,
|
||||
allowed: false,
|
||||
...(repoRoot !== undefined ? { repoRoot } : {}),
|
||||
worktreeRoot: worktreesRoot,
|
||||
worktreePath: join(worktreesRoot, parts[1]),
|
||||
worktreePath: resolvedCwd,
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveMetadataOwnedWorktree(
|
||||
cwd: string,
|
||||
repoRoot: string | undefined,
|
||||
): Promise<{ worktreePath: string; worktreeRoot: string } | null> {
|
||||
try {
|
||||
const root = await findPaseoWorktreeRootForCwd(cwd);
|
||||
if (!root) return null;
|
||||
const candidate = await normalizePathForOwnership(root);
|
||||
if (repoRoot !== undefined && candidate === (await normalizePathForOwnership(repoRoot)))
|
||||
return null;
|
||||
const { stdout: listOutput } = await runGitCommand(["worktree", "list", "--porcelain"], {
|
||||
cwd: candidate,
|
||||
envOverlay: READ_ONLY_GIT_ENV,
|
||||
});
|
||||
for (const entry of parseWorktreeList(listOutput)) {
|
||||
if ((await normalizePathForOwnership(entry.path)) === candidate) {
|
||||
return { worktreePath: candidate, worktreeRoot: dirname(candidate) };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
type ParsedPaseoWorktreeInfo = Omit<PaseoWorktreeInfo, "createdAt">;
|
||||
|
||||
function parseWorktreeList(output: string): ParsedPaseoWorktreeInfo[] {
|
||||
@@ -1063,12 +1103,17 @@ export async function listPaseoWorktrees({
|
||||
envOverlay: READ_ONLY_GIT_ENV,
|
||||
});
|
||||
|
||||
const mainCheckoutPath = await resolveRepoRootFromGitCommonDir(await getGitCommonDir(cwd));
|
||||
const roots = await ownershipPathVariants(projectWorktreesRoot);
|
||||
const worktrees: PaseoWorktreeInfo[] = [];
|
||||
// Inspect foreign paths sequentially; a large worktree list must not consume the FS pool.
|
||||
for (const entry of parseWorktreeList(stdout)) {
|
||||
const path = await normalizePathForOwnership(entry.path);
|
||||
if (!roots.some((root) => getRelativePathInsideRoot(root, path) !== null)) continue;
|
||||
if (
|
||||
!roots.some((root) => getRelativePathInsideRoot(root, path) !== null) &&
|
||||
!(path !== mainCheckoutPath && (await hasPaseoWorktreeMetadata(path)))
|
||||
)
|
||||
continue;
|
||||
worktrees.push({ ...entry, path, createdAt: await resolveWorktreeCreatedAtIso(path) });
|
||||
}
|
||||
return worktrees;
|
||||
@@ -1082,6 +1127,30 @@ export interface DeletePaseoWorktreeOptions {
|
||||
worktreesRoot?: string;
|
||||
paseoHome?: string;
|
||||
worktreesBaseRoot?: string;
|
||||
// The caller has a workspace registry record backing worktreePath (the
|
||||
// record's cwd is this path). Registry membership is an ownership proof
|
||||
// in its own right: it covers worktrees whose git admin dir is already
|
||||
// gone, where the marker proof can no longer be verified. Only pass
|
||||
// this when the record was matched against the path by the caller.
|
||||
registryProven?: boolean;
|
||||
}
|
||||
|
||||
// Project scoping for delete: a proof for some other repo's worktree must
|
||||
// not make this repo's delete remove it. Only refuses on a positive
|
||||
// mismatch — when git has forgotten either side, the ownership proof the
|
||||
// caller already established stands.
|
||||
async function assertWorktreeBelongsToRepo(cwd: string, worktreePath: string): Promise<void> {
|
||||
let callerRepoRoot: string;
|
||||
let targetRepoRoot: string;
|
||||
try {
|
||||
callerRepoRoot = await resolveRepoRootFromGitCommonDir(await getGitCommonDir(cwd));
|
||||
targetRepoRoot = await resolveRepoRootFromGitCommonDir(await getGitCommonDir(worktreePath));
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if ((await getOwnershipRelativePath(callerRepoRoot, targetRepoRoot)) !== "") {
|
||||
throw new Error("Refusing to delete non-Paseo worktree");
|
||||
}
|
||||
}
|
||||
|
||||
export async function deletePaseoWorktree({
|
||||
@@ -1092,6 +1161,7 @@ export async function deletePaseoWorktree({
|
||||
worktreesRoot,
|
||||
paseoHome,
|
||||
worktreesBaseRoot,
|
||||
registryProven,
|
||||
}: DeletePaseoWorktreeOptions): Promise<void> {
|
||||
if (!worktreePath && !worktreeSlug) {
|
||||
throw new Error("worktreePath or worktreeSlug is required");
|
||||
@@ -1110,22 +1180,27 @@ export async function deletePaseoWorktree({
|
||||
}
|
||||
|
||||
const requestedPath = worktreePath ?? join(resolvedWorktreesRoot, worktreeSlug!);
|
||||
const resolvedRequested = await normalizePathForOwnership(requestedPath);
|
||||
const ownership = await isPaseoOwnedWorktreeCwd(requestedPath, {
|
||||
paseoHome,
|
||||
worktreesRoot: worktreesBaseRoot,
|
||||
});
|
||||
const resolvedWorktree =
|
||||
ownership.allowed && ownership.worktreePath ? ownership.worktreePath : resolvedRequested;
|
||||
|
||||
const relativeWorktreePath = await getOwnershipRelativePath(
|
||||
resolvedWorktreesRoot,
|
||||
resolvedWorktree,
|
||||
);
|
||||
if (relativeWorktreePath === null || relativeWorktreePath === "") {
|
||||
// Deletion is gated on the ownership proof set (legacy shape, the
|
||||
// paseo/worktree.json marker verified against `git worktree list`, or a
|
||||
// caller-asserted registry record), not on living under the per-project
|
||||
// hashed root: plugin-placed worktrees live anywhere.
|
||||
let resolvedWorktree: string;
|
||||
if (registryProven) {
|
||||
resolvedWorktree = await normalizePathForOwnership(requestedPath);
|
||||
} else if (ownership.allowed && ownership.worktreePath) {
|
||||
resolvedWorktree = ownership.worktreePath;
|
||||
} else {
|
||||
throw new Error("Refusing to delete non-Paseo worktree");
|
||||
}
|
||||
|
||||
if (cwd) {
|
||||
await assertWorktreeBelongsToRepo(cwd, resolvedWorktree);
|
||||
}
|
||||
|
||||
if (await pathExists(resolvedWorktree)) {
|
||||
for (const teardownCwd of teardownCwds ?? [resolvedWorktree]) {
|
||||
await runWorktreeTeardownCommands({
|
||||
@@ -1222,6 +1297,67 @@ async function removeDirectoryWithRetries(path: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function listLocalBranchNames(cwd: string): Promise<Set<string>> {
|
||||
const { stdout } = await runGitCommand(
|
||||
["for-each-ref", "--format=%(refname:short)", "refs/heads"],
|
||||
{ cwd },
|
||||
);
|
||||
return new Set(
|
||||
stdout
|
||||
.split("\n")
|
||||
.map((name) => name.trim())
|
||||
.filter((name) => name.length > 0),
|
||||
);
|
||||
}
|
||||
|
||||
// The one local branch a source plan can create while resolving: a
|
||||
// checkout-branch plan fetches refs/heads/<source.branchName>, and a
|
||||
// change-request/PR plan fetches refs/heads/<localBranchName> (which is
|
||||
// the plan's branchName). branch-off creates no local branch. Only this
|
||||
// ref may be rolled back when creation fails — never any other
|
||||
// branch that appeared in the meantime; other creators own those.
|
||||
function planCreatedLocalBranch(
|
||||
source: WorktreeSource,
|
||||
sourcePlan: WorktreeSourcePlan,
|
||||
): string | null {
|
||||
switch (source.kind) {
|
||||
case "checkout-branch":
|
||||
return source.branchName;
|
||||
case "checkout-change-request":
|
||||
case "checkout-github-pr":
|
||||
return sourcePlan.branchName;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function readLocalBranchTip(cwd: string, branchName: string): Promise<string | null> {
|
||||
try {
|
||||
const { stdout } = await runGitCommand(
|
||||
["rev-parse", "--verify", `refs/heads/${branchName}^{commit}`],
|
||||
{ cwd },
|
||||
);
|
||||
const tip = stdout.trim();
|
||||
return tip.length > 0 ? tip : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve the default placement once, before presenting it to creation hooks. */
|
||||
export async function resolveDefaultWorktreePath(
|
||||
options: Pick<CreateWorktreeOptions, "cwd" | "paseoHome" | "worktreesRoot" | "worktreeSlug">,
|
||||
): Promise<string> {
|
||||
const original = join(
|
||||
await getPaseoWorktreesRoot(options.cwd, options.paseoHome, options.worktreesRoot),
|
||||
options.worktreeSlug,
|
||||
);
|
||||
let candidate = original;
|
||||
let suffix = 1;
|
||||
while (await pathExists(candidate)) candidate = `${original}-${suffix++}`;
|
||||
return candidate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a git worktree with proper naming conventions
|
||||
*/
|
||||
@@ -1231,56 +1367,73 @@ export const createWorktree = async ({
|
||||
worktreeSlug,
|
||||
paseoHome,
|
||||
worktreesRoot,
|
||||
worktreePath: explicitWorktreePath,
|
||||
}: CreateWorktreeOptions): Promise<CreatedWorktree> => {
|
||||
const branchesBefore =
|
||||
source.kind === "checkout-branch" ||
|
||||
source.kind === "checkout-change-request" ||
|
||||
source.kind === "checkout-github-pr"
|
||||
? await listLocalBranchNames(cwd)
|
||||
: new Set<string>();
|
||||
const sourcePlan = await resolveWorktreeSourcePlan({ cwd, source, desiredSlug: worktreeSlug });
|
||||
let worktreePath = join(await getPaseoWorktreesRoot(cwd, paseoHome, worktreesRoot), worktreeSlug);
|
||||
await mkdir(dirname(worktreePath), { recursive: true });
|
||||
const fetchedBranch = planCreatedLocalBranch(source, sourcePlan);
|
||||
const fetchedTip =
|
||||
fetchedBranch && !branchesBefore.has(fetchedBranch)
|
||||
? await readLocalBranchTip(cwd, fetchedBranch)
|
||||
: null;
|
||||
try {
|
||||
let worktreePath =
|
||||
explicitWorktreePath ??
|
||||
(await resolveDefaultWorktreePath({ cwd, paseoHome, worktreesRoot, worktreeSlug }));
|
||||
await mkdir(dirname(worktreePath), { recursive: true });
|
||||
|
||||
// Also handle worktree path collision
|
||||
let finalWorktreePath = worktreePath;
|
||||
let pathSuffix = 1;
|
||||
while (await pathExists(finalWorktreePath)) {
|
||||
finalWorktreePath = `${worktreePath}-${pathSuffix}`;
|
||||
pathSuffix++;
|
||||
}
|
||||
|
||||
// Primitive owner for `git worktree add`; callers route through createWorktreeCore.
|
||||
await runGitCommand(["worktree", "add", finalWorktreePath, ...sourcePlan.addArguments], {
|
||||
cwd,
|
||||
timeout: 120_000,
|
||||
});
|
||||
worktreePath = await normalizePathForOwnership(finalWorktreePath);
|
||||
|
||||
if (sourcePlan.pushRemote) {
|
||||
await configureWorktreePushRemote({
|
||||
// Primitive owner for `git worktree add`; callers route through createWorktreeCore.
|
||||
await runGitCommand(["worktree", "add", worktreePath, ...sourcePlan.addArguments], {
|
||||
cwd,
|
||||
branchName: sourcePlan.branchName,
|
||||
remote: sourcePlan.pushRemote,
|
||||
timeout: 120_000,
|
||||
});
|
||||
}
|
||||
if (sourcePlan.trackingRemote) {
|
||||
await configureWorktreeTrackingRemote({
|
||||
cwd,
|
||||
branchName: sourcePlan.branchName,
|
||||
remote: sourcePlan.trackingRemote,
|
||||
worktreePath = await normalizePathForOwnership(worktreePath);
|
||||
|
||||
if (sourcePlan.pushRemote) {
|
||||
await configureWorktreePushRemote({
|
||||
cwd,
|
||||
branchName: sourcePlan.branchName,
|
||||
remote: sourcePlan.pushRemote,
|
||||
});
|
||||
}
|
||||
if (sourcePlan.trackingRemote) {
|
||||
await configureWorktreeTrackingRemote({
|
||||
cwd,
|
||||
branchName: sourcePlan.branchName,
|
||||
remote: sourcePlan.trackingRemote,
|
||||
});
|
||||
}
|
||||
|
||||
await writePaseoWorktreeMetadata(worktreePath, {
|
||||
baseRefName: sourcePlan.metadataBaseRefName,
|
||||
...(sourcePlan.metadataBaseRef ? { baseRef: sourcePlan.metadataBaseRef } : {}),
|
||||
...(sourcePlan.changeRequestLookupTarget
|
||||
? { changeRequestLookupTarget: sourcePlan.changeRequestLookupTarget }
|
||||
: {}),
|
||||
});
|
||||
|
||||
await seedPaseoConfigFile({ sourceCwd: cwd, targetCwd: worktreePath });
|
||||
|
||||
return {
|
||||
branchName: sourcePlan.branchName,
|
||||
worktreePath,
|
||||
comparisonBaseRef: sourcePlan.metadataBaseRef ?? sourcePlan.metadataBaseRefName,
|
||||
};
|
||||
} catch (error) {
|
||||
if (
|
||||
fetchedBranch &&
|
||||
fetchedTip &&
|
||||
(await readLocalBranchTip(cwd, fetchedBranch)) === fetchedTip
|
||||
) {
|
||||
await runGitCommand(["branch", "-D", fetchedBranch], { cwd }).catch(() => {});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
await writePaseoWorktreeMetadata(worktreePath, {
|
||||
baseRefName: sourcePlan.metadataBaseRefName,
|
||||
...(sourcePlan.metadataBaseRef ? { baseRef: sourcePlan.metadataBaseRef } : {}),
|
||||
...(sourcePlan.changeRequestLookupTarget
|
||||
? { changeRequestLookupTarget: sourcePlan.changeRequestLookupTarget }
|
||||
: {}),
|
||||
});
|
||||
|
||||
await seedPaseoConfigFile({ sourceCwd: cwd, targetCwd: worktreePath });
|
||||
|
||||
return {
|
||||
branchName: sourcePlan.branchName,
|
||||
worktreePath,
|
||||
comparisonBaseRef: sourcePlan.metadataBaseRef ?? sourcePlan.metadataBaseRefName,
|
||||
};
|
||||
};
|
||||
|
||||
interface ResolveWorktreeSourcePlanOptions {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildDocsNavTree, getDoc, getDocs, getLegacyDocsRedirect } from "./docs";
|
||||
import { buildDocsNavTree, getDoc, getDocs, getLegacyDocsRedirect, parseHeadings } from "./docs";
|
||||
|
||||
describe("plugin documentation", () => {
|
||||
it("groups current guides by task without version navigation", () => {
|
||||
@@ -20,6 +20,14 @@ describe("plugin documentation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["en", "zh-CN"] as const)("resolves worktree placement hook links in %s", (locale) => {
|
||||
const hooks = getDoc("plugins/hooks", locale)!;
|
||||
expect(parseHeadings(hooks.content).map((heading) => heading.id)).toContain(
|
||||
"worktreecreatebefore",
|
||||
);
|
||||
expect(hooks.content).toContain("plugin-examples/worktree-placement");
|
||||
});
|
||||
|
||||
it.each(["v0.7", "v0.8"])("redirects %s links to existing current pages", (version) => {
|
||||
for (const page of ["", "/index", "/reference", "/providers", "/migration"]) {
|
||||
const target = `/docs/plugins${page === "/index" ? "" : page}`;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Lifecycle logger
|
||||
|
||||
Registers all eleven server lifecycle hooks and logs their data as JSON. It observes agent creation,
|
||||
Registers agent, workspace, and worktree hooks and logs their data as JSON. It observes agent creation,
|
||||
session opening, turns, permissions, archive, and workspace creation/archive. Before hooks return
|
||||
the request unchanged. Environment values are redacted; prompts and timeline content are logged.
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
# Worktree placement
|
||||
|
||||
Place worktrees next to their source checkout, using the repository directory name and branch.
|
||||
For `/home/me/src/paseo` on branch `feature/x`, the new worktree is
|
||||
`/home/me/src/paseo.feature-x`. Slashes in branch names become hyphens.
|
||||
|
||||
Install on Paseo 0.14.0-beta.1 or newer with plugins enabled:
|
||||
|
||||
```bash
|
||||
paseo plugin install /absolute/path/to/plugin-examples/worktree-placement
|
||||
```
|
||||
|
||||
The plugin is one file, `index.server.ts`:
|
||||
|
||||
```ts
|
||||
import { basename, dirname, join } from "node:path";
|
||||
import type { PluginServerContext } from "@getpaseo/plugin/server";
|
||||
|
||||
export default function contribute(server: PluginServerContext) {
|
||||
return server.addHook("worktree.create.before", async ({ request }) => ({
|
||||
...request,
|
||||
path: join(
|
||||
dirname(request.sourceCheckout),
|
||||
`${basename(request.sourceCheckout)}.${request.branch.replaceAll("/", "-")}`,
|
||||
),
|
||||
}));
|
||||
}
|
||||
```
|
||||
|
||||
Paseo validates the returned path and refuses an existing one. It creates, lists, restores,
|
||||
and removes the worktree through the normal workspace workflow. Restoring uses the recorded
|
||||
path without running the hook. Git can choose a unique branch name after this hook runs;
|
||||
the path retains the branch name received by the hook.
|
||||
|
||||
See [worktree.create.before](../../public-docs/plugins/hooks.md#worktreecreatebefore)
|
||||
for the request fields and validation rules.
|
||||
@@ -0,0 +1,12 @@
|
||||
import { basename, dirname, join } from "node:path";
|
||||
import type { PluginServerContext } from "@getpaseo/plugin/server";
|
||||
|
||||
export default function contribute(server: PluginServerContext) {
|
||||
return server.addHook("worktree.create.before", async ({ request }) => ({
|
||||
...request,
|
||||
path: join(
|
||||
dirname(request.sourceCheckout),
|
||||
`${basename(request.sourceCheckout)}.${request.branch.replaceAll("/", "-")}`,
|
||||
),
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"id": "worktree-placement",
|
||||
"requirements": { "paseo": ">=0.14.0-beta.1" }
|
||||
}
|
||||
@@ -54,6 +54,7 @@ Paseo only sees the returned promise, so detached work gets no error reporting a
|
||||
| `workspace.create.before` | yes | `{ request }` | Entire create request |
|
||||
| `workspace.create.after` | no | `{ workspace }` | |
|
||||
| `workspace.archive.after` | no | `{ workspace }` | |
|
||||
| `worktree.create.before` | yes | `{ request }` | Creation options and destination |
|
||||
| `worktree.create.after` | yes | `{ worktree }` | Files in the worktree; throw to abort creation |
|
||||
| `agent.create.before` | yes | `{ request: { config, env? } }` | Saved config except `cwd`; `env` |
|
||||
| `agent.create.after` | no | `{ agent }` | |
|
||||
@@ -145,6 +146,46 @@ server.addHook("workspace.archive.after", async ({ workspace }) => {
|
||||
});
|
||||
```
|
||||
|
||||
## worktree.create.before {#worktreecreatebefore}
|
||||
|
||||
Choose the location and creation options for a new worktree. Paseo awaits this hook before `git worktree add`, on creation from the app, CLI, MCP, and agent creation. Receives `{ request }`; return a complete replacement request or `undefined` to keep it. Throwing, invalid returned data, or a 30-second timeout aborts creation with the plugin and hook named in the error.
|
||||
|
||||
```ts
|
||||
import { basename, dirname, join } from "node:path";
|
||||
|
||||
server.addHook("worktree.create.before", async ({ request }) => ({
|
||||
...request,
|
||||
path: join(
|
||||
dirname(request.sourceCheckout),
|
||||
`${basename(request.sourceCheckout)}.${request.branch.replaceAll("/", "-")}`,
|
||||
),
|
||||
}));
|
||||
```
|
||||
|
||||
For source `/home/me/src/paseo` and branch `feature/x`, this creates `/home/me/src/paseo.feature-x`. See the complete [worktree-placement example](https://github.com/getpaseo/paseo/tree/main/plugin-examples/worktree-placement).
|
||||
|
||||
| Request field | Meaning |
|
||||
| ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `kind` | `branch-off`, `checkout-branch`, `checkout-change-request`, or `checkout-github-pr` |
|
||||
| `branch` | Requested local branch name |
|
||||
| `baseRef` | Starting ref for branch-off; comparison base for change requests; absent for `checkout-branch`. To branch off a ref, return `kind: "branch-off"`. |
|
||||
| `slug` | Normalized worktree name |
|
||||
| `path` | Absolute destination; initially the default location with any existing-path suffix resolved |
|
||||
| `repoRoot` | Read-only main repository root |
|
||||
| `sourceCheckout` | Read-only source checkout root, including when creation starts from another worktree |
|
||||
| `forge`, `changeRequestNumber`, `headRef` | Change-request checkout identity; GitHub legacy checkouts use `githubPrNumber` instead of `forge` and `changeRequestNumber` |
|
||||
| `headRepositoryOwner`, `headRepository` | Optional source repository facts; `headRepository` is present only on change-request checkouts |
|
||||
| `checkoutRefs` | Optional fetch sources: `{ remoteName?: string, remoteRef: string }[]` |
|
||||
| `pushRemoteUrl`, `trackOriginHead` | Optional push destination and tracking policy |
|
||||
|
||||
Once `path` is returned, `slug` only feeds branch-off naming; the CLI looks up worktrees by the basename of their path.
|
||||
|
||||
Every field except `repoRoot` and `sourceCheckout` is transformable. Return all required facts for the chosen `kind`. Paseo resolves the Git source plan after this hook, so handlers can change the branch and base before fetching refs, while Git can still choose a unique final branch name afterward.
|
||||
|
||||
Paseo resolves symlinked parents and validates the returned path. It must be absolute, must not exist, and must not be the filesystem root, your home directory, the repository root, inside a `.git` directory, or an ancestor of the repository. An existing destination fails creation; Paseo never adds a suffix to a plugin-chosen path. The plugin must leave directory creation to Paseo.
|
||||
|
||||
Ordering: `worktree.create.before` → `git worktree add` → `worktree.create.after` → workspace registration. Restoring a missing worktree recreates it at its recorded path without running either worktree creation hook.
|
||||
|
||||
## worktree.create.after
|
||||
|
||||
Paseo waits for this hook after a new git worktree exists and before workspace registration, setup, terminals, or agents. Receives `{ worktree: { path, branch, sourceCheckout } }`: the absolute worktree path, branch name, and source checkout directory. There is no workspace id yet. Return nothing; write files directly into the worktree. Throwing or exceeding 30 seconds aborts creation and removes the new worktree.
|
||||
@@ -589,12 +630,12 @@ replaces that scope's prior value.
|
||||
|
||||
## Complete examples
|
||||
|
||||
| Plugin | Includes |
|
||||
| ------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------- |
|
||||
| [lifecycle-logger](https://github.com/getpaseo/paseo/tree/main/plugin-examples/lifecycle-logger) | All thirteen hooks; JSON logs with environment values redacted |
|
||||
| [lifecycle-actions](https://github.com/getpaseo/paseo/tree/main/plugin-examples/lifecycle-actions) | Follow-ups, permissions, environment, provider switching, worktree selection |
|
||||
| [agent-configuration](https://github.com/getpaseo/paseo/tree/main/plugin-examples/agent-configuration) | MCP injection and Codex sandbox/approval options |
|
||||
| [agent-tools](https://github.com/getpaseo/paseo/tree/main/plugin-examples/agent-tools) | Label-targeted tools and file operations in the plugin process |
|
||||
| Plugin | Includes |
|
||||
| ------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------- |
|
||||
| [lifecycle-logger](https://github.com/getpaseo/paseo/tree/main/plugin-examples/lifecycle-logger) | Agent, workspace, and worktree hooks; JSON logs with environment values redacted |
|
||||
| [lifecycle-actions](https://github.com/getpaseo/paseo/tree/main/plugin-examples/lifecycle-actions) | Follow-ups, permissions, environment, provider switching, worktree selection |
|
||||
| [agent-configuration](https://github.com/getpaseo/paseo/tree/main/plugin-examples/agent-configuration) | MCP injection and Codex sandbox/approval options |
|
||||
| [agent-tools](https://github.com/getpaseo/paseo/tree/main/plugin-examples/agent-tools) | Label-targeted tools and file operations in the plugin process |
|
||||
|
||||
Read logger output with `paseo plugin logs lifecycle-logger` or the host's `daemon.log`.
|
||||
|
||||
|
||||
@@ -52,6 +52,7 @@ Paseo 只观察返回的 Promise,因此独立任务没有错误报告,也没
|
||||
| `workspace.create.before` | 是 | `{ request }` | 整个创建请求 |
|
||||
| `workspace.create.after` | 否 | `{ workspace }` | |
|
||||
| `workspace.archive.after` | 否 | `{ workspace }` | |
|
||||
| `worktree.create.before` | 是 | `{ request }` | 创建选项和目标路径 |
|
||||
| `worktree.create.after` | 是 | `{ worktree }` | worktree 中的文件;抛出错误中止创建 |
|
||||
| `agent.create.before` | 是 | `{ request: { config, env? } }` | 除 `cwd` 外的已保存配置;`env` |
|
||||
| `agent.create.after` | 否 | `{ agent }` | |
|
||||
@@ -142,6 +143,46 @@ server.addHook("workspace.archive.after", async ({ workspace }) => {
|
||||
});
|
||||
```
|
||||
|
||||
## worktree.create.before {#worktreecreatebefore}
|
||||
|
||||
为新 worktree 选择路径和创建选项。通过应用、CLI、MCP 或创建 Agent 新建 worktree 时,Paseo 在 `git worktree add` 之前等待此钩子。钩子收到 `{ request }`,返回完整的替换请求,或返回 `undefined` 保持原值。抛出错误、返回无效数据或超过 30 秒会终止创建,错误包含插件和钩子名称。
|
||||
|
||||
```ts
|
||||
import { basename, dirname, join } from "node:path";
|
||||
|
||||
server.addHook("worktree.create.before", async ({ request }) => ({
|
||||
...request,
|
||||
path: join(
|
||||
dirname(request.sourceCheckout),
|
||||
`${basename(request.sourceCheckout)}.${request.branch.replaceAll("/", "-")}`,
|
||||
),
|
||||
}));
|
||||
```
|
||||
|
||||
源检出目录为 `/home/me/src/paseo`、分支为 `feature/x` 时,目标为 `/home/me/src/paseo.feature-x`。完整插件见 [worktree-placement 示例](https://github.com/getpaseo/paseo/tree/main/plugin-examples/worktree-placement)。
|
||||
|
||||
| 请求字段 | 含义 |
|
||||
| ----------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
|
||||
| `kind` | `branch-off`、`checkout-branch`、`checkout-change-request` 或 `checkout-github-pr` |
|
||||
| `branch` | 请求的本地分支名称 |
|
||||
| `baseRef` | branch-off 的起始引用;变更请求的比较基准;`checkout-branch` 不含此字段。要从引用创建分支,返回 `kind: "branch-off"`。 |
|
||||
| `slug` | 规范化的 worktree 名称 |
|
||||
| `path` | 绝对目标路径,初始值为已处理现有路径后缀的默认位置 |
|
||||
| `repoRoot` | 只读,主仓库根目录 |
|
||||
| `sourceCheckout` | 只读,源检出根目录,也支持从另一个 worktree 发起创建 |
|
||||
| `forge`、`changeRequestNumber`、`headRef` | 变更请求检出标识;旧版 GitHub 检出用 `githubPrNumber` 代替 `forge` 和 `changeRequestNumber` |
|
||||
| `headRepositoryOwner`、`headRepository` | 可选的源仓库信息;`headRepository` 仅用于变更请求检出 |
|
||||
| `checkoutRefs` | 可选的获取源:`{ remoteName?: string, remoteRef: string }[]` |
|
||||
| `pushRemoteUrl`、`trackOriginHead` | 可选的推送目标和跟踪策略 |
|
||||
|
||||
返回 `path` 后,`slug` 仅用于 branch-off 分支命名;CLI 使用路径的最后一级目录名查找 worktree。
|
||||
|
||||
除 `repoRoot` 和 `sourceCheckout` 外,所有字段都可以修改。返回所选 `kind` 的全部必填字段。Paseo 在钩子之后解析 Git 源计划,因此钩子可以在获取引用之前修改分支和基准,而 Git 之后仍可能选择唯一的最终分支名称。
|
||||
|
||||
Paseo 解析父目录中的符号链接并验证返回的路径。路径必须为绝对路径且不存在,不能是文件系统根目录、用户主目录、仓库根目录、`.git` 目录内的路径或仓库的祖先目录。目标已存在时创建失败,Paseo 不会为插件选择的路径添加后缀。插件应让 Paseo 创建目录。
|
||||
|
||||
顺序:`worktree.create.before` → `git worktree add` → `worktree.create.after` → 注册工作区。恢复缺失的 worktree 时使用记录的原路径,不运行这两个创建钩子。
|
||||
|
||||
## worktree.create.after {#worktreecreateafter}
|
||||
|
||||
新的 git worktree 在磁盘上创建后,Paseo 会等待此钩子,然后才注册工作区、运行 setup、打开终端或启动 Agent。收到 `{ worktree: { path, branch, sourceCheckout } }`,分别是 worktree 绝对路径、分支名、来源 checkout 目录。此时还没有工作区 id。无需返回数据,直接写入 worktree 即可。抛出错误或超过 30 秒会中止创建并删除新 worktree。
|
||||
@@ -571,12 +612,12 @@ Creation request
|
||||
|
||||
## 完整示例 {#complete-examples}
|
||||
|
||||
| 插件 | 包含 |
|
||||
| ------------------------------------------------------------------------------------------------------ | ------------------------------------------------------ |
|
||||
| [lifecycle-logger](https://github.com/getpaseo/paseo/tree/main/plugin-examples/lifecycle-logger) | 全部十三个钩子;隐去环境变量值的 JSON 日志 |
|
||||
| [lifecycle-actions](https://github.com/getpaseo/paseo/tree/main/plugin-examples/lifecycle-actions) | 后续消息、权限、环境变量、切换 Provider、选择 worktree |
|
||||
| [agent-configuration](https://github.com/getpaseo/paseo/tree/main/plugin-examples/agent-configuration) | MCP 注入以及 Codex 沙盒/批准选项 |
|
||||
| [agent-tools](https://github.com/getpaseo/paseo/tree/main/plugin-examples/agent-tools) | 按标签选择 Agent,并在插件进程中执行文件操作的工具 |
|
||||
| 插件 | 包含 |
|
||||
| ------------------------------------------------------------------------------------------------------ | --------------------------------------------------------- |
|
||||
| [lifecycle-logger](https://github.com/getpaseo/paseo/tree/main/plugin-examples/lifecycle-logger) | Agent、工作区和 worktree 钩子;隐去环境变量值的 JSON 日志 |
|
||||
| [lifecycle-actions](https://github.com/getpaseo/paseo/tree/main/plugin-examples/lifecycle-actions) | 后续消息、权限、环境变量、切换 Provider、选择 worktree |
|
||||
| [agent-configuration](https://github.com/getpaseo/paseo/tree/main/plugin-examples/agent-configuration) | MCP 注入以及 Codex 沙盒/批准选项 |
|
||||
| [agent-tools](https://github.com/getpaseo/paseo/tree/main/plugin-examples/agent-tools) | 按标签选择 Agent,并在插件进程中执行文件操作的工具 |
|
||||
|
||||
用 `paseo plugin logs lifecycle-logger` 或主机的 `daemon.log` 读取 logger 的输出。
|
||||
|
||||
|
||||
@@ -35,6 +35,8 @@ With a custom root, Paseo keeps the same hashed layout under that directory:
|
||||
}
|
||||
```
|
||||
|
||||
Plugins can choose a location anywhere on disk with [`worktree.create.before`](/docs/plugins/hooks#worktreecreatebefore). The [worktree-placement example](https://github.com/getpaseo/paseo/tree/main/plugin-examples/worktree-placement) places worktrees next to the source checkout. Paseo validates the path, refuses an existing destination, and manages listing, archive, and deletion there. Recovery uses the recorded path without running creation hooks.
|
||||
|
||||
1. Create a workspace with worktree isolation, Paseo creates the worktree and runs your setup hooks
|
||||
2. Launch one or more agents in that workspace
|
||||
3. Review the diff against the base branch
|
||||
|
||||
@@ -33,6 +33,8 @@ worktree 默认存放在 `$PASEO_HOME/worktrees/` 下,按源检出路径的哈
|
||||
}
|
||||
```
|
||||
|
||||
插件可以通过 [`worktree.create.before`](/docs/plugins/hooks#worktreecreatebefore) 将 worktree 放到磁盘上的自选位置。[worktree-placement 示例](https://github.com/getpaseo/paseo/tree/main/plugin-examples/worktree-placement) 将其放在源检出目录旁边。Paseo 验证路径并拒绝已存在的目标,同时支持在该位置列出、归档和删除 worktree。恢复时使用记录的原路径,不运行创建钩子。
|
||||
|
||||
1. 创建一个使用 worktree 隔离的工作区,Paseo 会创建 worktree 并运行你的 setup 钩子
|
||||
2. 在该工作区中启动一个或多个 Agent
|
||||
3. 对照基础分支审查 diff
|
||||
|
||||
Reference in New Issue
Block a user