mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-29 04:32:57 +00:00
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:
co-authored by
Carlos Precioso
parent
986687b8fd
commit
dee0d138e9
@@ -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))
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
@@ -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" } }
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user