feat(worktrees): copy included local files before setup and agents (#8100)

* feat(worktrees): copy included local files before setup and agents

Keep local-file materialization in one built-in lifecycle hook so workspace setup and the first agent see the completed copy. Reuse the community plugin algorithm with the source checkout supplied by the worktree hook.

Co-authored-by: Carlos Precioso <github@precioso.design>

* test(plugins): make worktree lifecycle checks portable on Windows

Compare source checkout filesystem identity across Git and Windows path spellings. Exercise copy rollback with a destination file blocking a directory, since Git for Windows accepts directories as exclude files.

---------

Co-authored-by: Carlos Precioso <github@precioso.design>
This commit is contained in:
Mohamed Boudra
2026-10-28 17:21:01 +07:00
committed by GitHub
co-authored by Carlos Precioso
parent 986687b8fd
commit dee0d138e9
11 changed files with 337 additions and 9 deletions
+1
View File
@@ -10,6 +10,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 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))
+4
View File
@@ -99,6 +99,10 @@ Built-in plugins ship from `plugins/<id>/`, with `paseo-plugin.json`, `index.ser
`packages/server/src/server/plugins/builtin/index.ts`; the workspace, build copy, and CI
checks cover that registry. Unlisted directories do not load.
The `worktree-include` built-in copies gitignored files selected by
[`.worktreeinclude`](../public-docs/worktrees.md#copy-local-files-with-worktreeinclude)
before setup and agents, through `worktree.create.after`.
Desktop packaging ships the entire built-in plugin directory as an external resource,
including declarations. The external esbuild compiler cannot read Electron's `app.asar`
filesystem, and packaging dependencies excludes `.d.ts` files needed for import validation.
+1 -1
View File
@@ -89,7 +89,7 @@
"speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts",
"test": "npm run test:unit && npm run test:integration",
"test:unit": "vitest run --fileParallelism --exclude \"**/*.e2e.test.ts\"",
"test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/checkout-diff-content.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/workspace-create-worktree-source.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts src/server/connected-hosts/hosts.e2e.test.ts && vitest run --maxWorkers=1 src/server/agent/providers/omp/history.e2e.test.ts src/server/daemon-e2e/git-operations.e2e.test.ts src/server/session.create-agent-worktree-autoarchive.e2e.test.ts src/server/agent/providers/opencode-bridge.local.e2e.test.ts src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"merges into|creates a worktree and auto-archives both|versioned runtime|oversized attachments|connected hosts|password-protected daemon refuses|replays selected OMP history\"",
"test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/checkout-diff-content.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/workspace-create-worktree-source.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts src/server/plugins/lifecycle.e2e.test.ts src/server/connected-hosts/hosts.e2e.test.ts && vitest run --maxWorkers=1 src/server/agent/providers/omp/history.e2e.test.ts src/server/daemon-e2e/git-operations.e2e.test.ts src/server/session.create-agent-worktree-autoarchive.e2e.test.ts src/server/agent/providers/opencode-bridge.local.e2e.test.ts src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"merges into|creates a worktree and auto-archives both|versioned runtime|oversized attachments|connected hosts|password-protected daemon refuses|replays selected OMP history\"",
"test:hub-cli-contract": "vitest run --maxWorkers=1 src/server/hub/hub-cli-contract.test.ts",
"test:integration:all": "npm run test:e2e",
"test:integration:real": "vitest run real.e2e.test.ts",
@@ -14,6 +14,7 @@ export const builtinPlugins = [
"minimax-usage-source",
"muse-provider",
"opencode-go-usage-source",
"worktree-include",
"zai-usage-source",
] as const;
@@ -1,5 +1,5 @@
import { execFileSync } from "node:child_process";
import { cp, mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
import { cp, mkdir, mkdtemp, readFile, realpath, rm, stat, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
@@ -7,6 +7,7 @@ import { expect, onTestFinished, test } from "vitest";
import { DaemonClient } from "../test-utils/daemon-client.js";
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
import { resolveDaemonVersion } from "../daemon-version.js";
import { BuiltinPluginLoader } from "./builtin/index.js";
test("a plugin transforms workspace creation once across receipt replays and observes its committed lifecycle", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-lifecycle-"));
@@ -838,12 +839,19 @@ test.each([false, true])(
"workspace.create.after",
"agent.create.before",
]);
expect(records[0].worktree).toEqual({
expect({
...records[0].worktree,
sourceCheckout: await realpath(records[0].worktree.sourceCheckout),
}).toEqual({
path: cwd,
branch: "accept-hook",
sourceCheckout: repository,
sourceCheckout: await realpath(repository),
});
const beforeFailure = await client.fetchWorkspaces();
const worktreesBeforeFailure = execFileSync("git", ["worktree", "list", "--porcelain"], {
cwd: repository,
encoding: "utf8",
});
const rejected = await client.createWorkspace({
source: {
kind: "worktree",
@@ -870,7 +878,7 @@ test.each([false, true])(
cwd: repository,
encoding: "utf8",
}),
).not.toContain(failedPath);
).toBe(worktreesBeforeFailure);
} finally {
await client.close();
await daemon.close();
@@ -879,3 +887,190 @@ test.each([false, true])(
},
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");
execFileSync("git", ["init", "-b", "main", repository], { stdio: "pipe" });
const git = (...args: string[]) => execFileSync("git", args, { cwd: repository, stdio: "pipe" });
await mkdir(path.join(repository, "config"));
await writeFile(path.join(repository, ".gitignore"), ".env\nconfig/*.local.json\n");
await writeFile(
path.join(repository, ".worktreeinclude"),
".env\nconfig/*.local.json\nunignored.txt\n",
);
await writeFile(path.join(repository, "config/tracked.local.json"), "git version");
git("add", ".gitignore", ".worktreeinclude");
git("add", "-f", "config/tracked.local.json");
git(
"-c",
"user.name=Test",
"-c",
"user.email=test@example.com",
"-c",
"commit.gpgsign=false",
"commit",
"-m",
"initial",
);
await writeFile(path.join(repository, ".env"), "SECRET=source");
await writeFile(path.join(repository, "config/app.local.json"), "local config");
await writeFile(path.join(repository, "config/tracked.local.json"), "uncommitted source version");
await writeFile(path.join(repository, "unignored.txt"), "unignored source");
const observed = path.join(root, "observed.json");
const pluginDirectory = await createProviderPlugin(root, {
acknowledgeClose: true,
hooks: `
server.addHook("agent.open.before", async ({ request }) => {
const { readFile, writeFile } = await import("node:fs/promises");
const contents = await Promise.all([".env", "config/app.local.json", "config/tracked.local.json"].map(file => readFile(request.cwd + "/" + file, "utf8")));
await writeFile(${JSON.stringify(observed)}, JSON.stringify(contents));
});`,
});
const daemon = await createTestPaseoDaemon({ builtinPlugins: new BuiltinPluginLoader() });
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
try {
await client.connect();
await client.patchDaemonConfig({ pluginsEnabled: true });
expect((await client.installDirectoryPlugin(pluginDirectory)).status).toBe("running");
const created = await client.createWorkspace({
source: { kind: "worktree", cwd: repository, branchName: "include", worktreeSlug: "include" },
});
expect(created.error).toBeNull();
await client.createAgent({
provider: PROVIDER_ID,
cwd: created.workspace!.workspaceDirectory,
workspaceId: created.workspace!.id,
});
expect(JSON.parse(await readFile(observed, "utf8"))).toEqual([
"SECRET=source",
"local config",
"git version",
]);
await expect(
stat(path.join(created.workspace!.workspaceDirectory, "unignored.txt")),
).rejects.toMatchObject({ code: "ENOENT" });
const sourceWorktree = created.workspace!.workspaceDirectory;
await writeFile(path.join(sourceWorktree, ".env"), "SECRET=worktree");
await writeFile(path.join(sourceWorktree, "config/app.local.json"), "worktree config");
const nested = await client.createWorkspace({
source: {
kind: "worktree",
cwd: sourceWorktree,
branchName: "nested",
worktreeSlug: "nested",
},
});
expect(nested.error).toBeNull();
await client.createAgent({
provider: PROVIDER_ID,
cwd: nested.workspace!.workspaceDirectory,
workspaceId: nested.workspace!.id,
});
expect(JSON.parse(await readFile(observed, "utf8"))).toEqual([
"SECRET=worktree",
"worktree config",
"git version",
]);
execFileSync("git", ["add", "-f", ".env"], { cwd: sourceWorktree, stdio: "pipe" });
execFileSync(
"git",
[
"-c",
"user.name=Test",
"-c",
"user.email=test@example.com",
"-c",
"commit.gpgsign=false",
"commit",
"-m",
"commit branch env",
],
{ cwd: sourceWorktree, stdio: "pipe" },
);
const existing = await client.createWorkspace({
source: {
kind: "worktree",
cwd: repository,
baseBranch: "include",
branchName: "existing",
worktreeSlug: "existing",
},
});
expect(existing.error).toBeNull();
await client.createAgent({
provider: PROVIDER_ID,
cwd: existing.workspace!.workspaceDirectory,
workspaceId: existing.workspace!.id,
});
expect(JSON.parse(await readFile(observed, "utf8"))).toEqual([
"SECRET=worktree",
"local config",
"git version",
]);
await rm(path.join(repository, ".worktreeinclude"));
const noIncludes = await client.createWorkspace({
source: {
kind: "worktree",
cwd: repository,
baseBranch: "main",
branchName: "no-includes",
worktreeSlug: "no-includes",
},
});
expect(noIncludes.error).toBeNull();
await expect(
stat(path.join(noIncludes.workspace!.workspaceDirectory, ".env")),
).rejects.toMatchObject({ code: "ENOENT" });
await expect(
stat(path.join(noIncludes.workspace!.workspaceDirectory, "config/app.local.json")),
).rejects.toMatchObject({ code: "ENOENT" });
await writeFile(path.join(repository, ".worktreeinclude"), ".env\nconfig/*.local.json\n");
await rm(path.join(sourceWorktree, "config"), { recursive: true });
await writeFile(
path.join(sourceWorktree, "config"),
"committed file blocks the config directory",
);
execFileSync("git", ["add", "-A", "config"], { cwd: sourceWorktree, stdio: "pipe" });
execFileSync(
"git",
[
"-c",
"user.name=Test",
"-c",
"user.email=test@example.com",
"-c",
"commit.gpgsign=false",
"commit",
"-m",
"block config directory",
],
{ cwd: sourceWorktree, stdio: "pipe" },
);
const beforeFailure = await client.fetchWorkspaces();
const worktreesBeforeFailure = git("worktree", "list", "--porcelain").toString();
const failed = await client.createWorkspace({
source: {
kind: "worktree",
cwd: repository,
baseBranch: "include",
branchName: "copy-failure",
worktreeSlug: "copy-failure",
},
});
expect(failed.workspace).toBeNull();
expect(failed.error).toContain("Plugin worktree-include worktree.create.after failed:");
expect((await client.fetchWorkspaces()).entries.map(({ id }) => id).sort()).toEqual(
beforeFailure.entries.map(({ id }) => id).sort(),
);
expect(git("worktree", "list", "--porcelain").toString()).toBe(worktreesBeforeFailure);
} finally {
await client.close();
await daemon.close();
await rm(root, { recursive: true, force: true });
}
}, 60_000);
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Carlos Precioso
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+8
View File
@@ -0,0 +1,8 @@
import type { PluginServerContext } from "@getpaseo/plugin/server";
import { copyWorktreeIncludes } from "./server/copy.js";
export default function contribute(server: PluginServerContext) {
return server.addHook("worktree.create.after", async ({ worktree }) => {
await copyWorktreeIncludes(worktree.path, worktree.sourceCheckout);
});
}
@@ -0,0 +1 @@
{ "id": "worktree-include", "requirements": { "paseo": ">=0.14.0-beta.1" } }
+71
View File
@@ -0,0 +1,71 @@
// Derived from cprecioso/paseo-worktreeinclude (MIT); see ./LICENSE in the plugin root.
// Copyright (c) 2026 Carlos Precioso.
import { execFile } from "node:child_process";
import { constants, existsSync } from "node:fs";
import { copyFile, mkdir } from "node:fs/promises";
import { dirname, join } from "node:path";
import { promisify } from "node:util";
const exec = promisify(execFile);
const INCLUDE_FILE = ".worktreeinclude";
export type CopyResult =
| { kind: "no-include-file" }
| { kind: "copied"; copied: string[]; skipped: string[] };
async function git(cwd: string, args: string[], input?: string): Promise<string> {
const child = exec("git", args, { cwd, maxBuffer: 64 * 1024 * 1024 });
if (input !== undefined) {
child.child.stdin?.end(input);
}
const { stdout } = await child;
return stdout;
}
async function gitAllowExit1(cwd: string, args: string[], input: string): Promise<string> {
try {
return await git(cwd, args, input);
} catch (error) {
// `git check-ignore` exits 1 when nothing matched.
if ((error as { code?: number }).code === 1) return "";
throw error;
}
}
const splitNul = (s: string) => s.split("\0").filter(Boolean);
// Copy files that match .worktreeinclude (gitignore syntax)
// AND are gitignored, so tracked files are never touched.
export async function copyWorktreeIncludes(worktree: string, source: string): Promise<CopyResult> {
if (!existsSync(join(source, INCLUDE_FILE))) return { kind: "no-include-file" };
const matches = splitNul(
await git(source, [
"ls-files",
"--others",
"--ignored",
`--exclude-from=${INCLUDE_FILE}`,
"-z",
]),
);
if (matches.length === 0) return { kind: "copied", copied: [], skipped: [] };
const ignored = splitNul(
await gitAllowExit1(source, ["check-ignore", "--stdin", "-z"], matches.join("\0") + "\0"),
);
const copied: string[] = [];
const skipped: string[] = [];
for (const file of ignored) {
const dest = join(worktree, file);
if (existsSync(dest)) {
skipped.push(file);
continue;
}
await mkdir(dirname(dest), { recursive: true });
// Clone (APFS/btrfs copy-on-write) when supported, else fall back to a regular copy.
await copyFile(join(source, file), dest, constants.COPYFILE_FICLONE);
copied.push(file);
}
return { kind: "copied", copied, skipped };
}
+15 -2
View File
@@ -80,6 +80,19 @@ paseo workspace create \
Add `--forge <name>` when Paseo cannot infer the forge from the source checkout.
## Copy local files with .worktreeinclude
Create `.worktreeinclude` at the root of your source checkout to copy selected gitignored files into new worktrees:
```gitignore
.env
config/*.local.json
```
Patterns use gitignore syntax. A file must match `.worktreeinclude` and be gitignored to be copied. Tracked files are never copied, and existing destination files are never overwritten. Without `.worktreeinclude`, no local files are copied. Symlinked directories and nested repositories inside an included path cause the copy to fail and abort worktree creation.
The built-in `worktree-include` plugin copies from the checkout you create the worktree from, including when that checkout is another worktree. Copying finishes before setup, terminals, and the first agent. A copy failure aborts creation and removes the new worktree. Copies use copy-on-write clones on APFS and btrfs when supported, with a regular copy elsewhere.
## paseo.json
Drop a `paseo.json` in your repo root. Paseo reads it from the committed version of the base branch you picked, so uncommitted changes in other branches don't apply.
@@ -101,12 +114,12 @@ Drop a `paseo.json` in your repo root. Paseo reads it from the committed version
Plugins can use [`worktree.create.after`](/docs/plugins/hooks#worktreecreateafter) to write files before setup, terminals, and the first agent.
`setup` runs once after the worktree is created. A fresh worktree has no installed dependencies and no ignored files (like `.env`), so use setup to install and copy what you need. `teardown` runs during archive, before the directory is removed.
`setup` runs once after the worktree is created. Use setup to install dependencies and run project initialization. Use [`.worktreeinclude`](#copy-local-files-with-worktreeinclude) to copy ignored files such as `.env`. `teardown` runs during archive, before the directory is removed.
```json
{
"worktree": {
"setup": "npm ci\ncp \"$PASEO_SOURCE_CHECKOUT_PATH/.env\" .env\nnpm run db:migrate",
"setup": "npm ci\nnpm run db:migrate",
"teardown": "npm run db:drop || true"
}
}
+15 -2
View File
@@ -78,6 +78,19 @@ paseo workspace create \
当 Paseo 无法从源检出推断代码托管平台时,添加 `--forge <name>`。
## 使用 .worktreeinclude 复制本地文件 {#copy-local-files-with-worktreeinclude}
在源检出的根目录创建 `.worktreeinclude`,将选定的 Git 忽略文件复制到新的 worktree:
```gitignore
.env
config/*.local.json
```
模式使用 gitignore 语法。只有同时匹配 `.worktreeinclude` 且被 Git 忽略的文件才会复制。已跟踪的文件不会被复制,目标中已存在的文件不会被覆盖。没有 `.worktreeinclude` 时,不会复制任何本地文件。包含路径中的符号链接目录和嵌套仓库会导致复制失败,并中止 worktree 创建。
内置的 `worktree-include` 插件从你创建 worktree 时所选的检出复制文件,即使源检出本身也是 worktree。复制会在 setup、终端和第一个 Agent 运行之前完成。复制失败会中止创建并删除新的 worktree。支持时会在 APFS 和 btrfs 上使用写时复制克隆,否则执行普通复制。
## paseo.json {#paseojson}
在仓库根目录放一个 `paseo.json`。Paseo 从你所选基础分支的已提交版本中读取它,因此其他分支中未提交的改动不会生效。
@@ -99,12 +112,12 @@ paseo workspace create \
插件可以通过 [`worktree.create.after`](/docs/plugins/hooks#worktreecreateafter) 在 setup、终端和第一个 Agent 运行之前写入文件。
`setup` 在 worktree 创建后运行一次。新的 worktree 没有安装依赖,也没有被忽略的文件(例如 `.env`),因此请用 setup 安装和复制你需要的内容。`teardown` 在归档期间、目录被删除之前运行。
`setup` 在 worktree 创建后运行一次。使用 setup 安装依赖并初始化项目。使用 [`.worktreeinclude`](#copy-local-files-with-worktreeinclude) 复制 `.env` 等被忽略的文件。`teardown` 在归档期间、目录被删除之前运行。
```json
{
"worktree": {
"setup": "npm ci\ncp \"$PASEO_SOURCE_CHECKOUT_PATH/.env\" .env\nnpm run db:migrate",
"setup": "npm ci\nnpm run db:migrate",
"teardown": "npm run db:drop || true"
}
}