diff --git a/CHANGELOG.md b/CHANGELOG.md index 162040ca45..7d2cb59ac2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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)) diff --git a/docs/plugins.md b/docs/plugins.md index fe5190e3bc..28eca0267e 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -99,6 +99,10 @@ Built-in plugins ship from `plugins//`, 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. diff --git a/packages/server/package.json b/packages/server/package.json index c651f134bf..a6eb8eaa32 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -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", diff --git a/packages/server/src/server/plugins/builtin/index.ts b/packages/server/src/server/plugins/builtin/index.ts index cf6ffe9eee..463ee8407d 100644 --- a/packages/server/src/server/plugins/builtin/index.ts +++ b/packages/server/src/server/plugins/builtin/index.ts @@ -14,6 +14,7 @@ export const builtinPlugins = [ "minimax-usage-source", "muse-provider", "opencode-go-usage-source", + "worktree-include", "zai-usage-source", ] as const; diff --git a/packages/server/src/server/plugins/lifecycle.e2e.test.ts b/packages/server/src/server/plugins/lifecycle.e2e.test.ts index d91b5f39bf..1299387647 100644 --- a/packages/server/src/server/plugins/lifecycle.e2e.test.ts +++ b/packages/server/src/server/plugins/lifecycle.e2e.test.ts @@ -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); diff --git a/plugins/worktree-include/LICENSE b/plugins/worktree-include/LICENSE new file mode 100644 index 0000000000..60cf520f1d --- /dev/null +++ b/plugins/worktree-include/LICENSE @@ -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. diff --git a/plugins/worktree-include/index.server.ts b/plugins/worktree-include/index.server.ts new file mode 100644 index 0000000000..9055c1677d --- /dev/null +++ b/plugins/worktree-include/index.server.ts @@ -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); + }); +} diff --git a/plugins/worktree-include/paseo-plugin.json b/plugins/worktree-include/paseo-plugin.json new file mode 100644 index 0000000000..711476b70f --- /dev/null +++ b/plugins/worktree-include/paseo-plugin.json @@ -0,0 +1 @@ +{ "id": "worktree-include", "requirements": { "paseo": ">=0.14.0-beta.1" } } diff --git a/plugins/worktree-include/server/copy.ts b/plugins/worktree-include/server/copy.ts new file mode 100644 index 0000000000..73a3ba63f3 --- /dev/null +++ b/plugins/worktree-include/server/copy.ts @@ -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 { + 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 { + 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 { + 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 }; +} diff --git a/public-docs/worktrees.md b/public-docs/worktrees.md index 952f48687e..7fcef41519 100644 --- a/public-docs/worktrees.md +++ b/public-docs/worktrees.md @@ -80,6 +80,19 @@ paseo workspace create \ Add `--forge ` 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" } } diff --git a/public-docs/worktrees.zh-CN.md b/public-docs/worktrees.zh-CN.md index aff3251af4..7ec91da189 100644 --- a/public-docs/worktrees.zh-CN.md +++ b/public-docs/worktrees.zh-CN.md @@ -78,6 +78,19 @@ paseo workspace create \ 当 Paseo 无法从源检出推断代码托管平台时,添加 `--forge `。 +## 使用 .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" } }