Compare commits

..
5 Commits
Author SHA1 Message Date
paseo-bot[bot] 6ae6308cfb test(server): control failed Git clone responses in credential test (#8428)
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-01 06:01:30 +00:00
Mohamed Boudra ef77b823ae chore(release): reconcile 0.15.0-beta.1 metadata (#8443)
* docs(changelog): prepare 0.15.0-beta.1

(cherry picked from commit 7e5ad53491)

* chore(release): cut 0.15.0-beta.1

(cherry picked from commit 8bd38fb236)

* test(docs): follow nested worktrees page
2026-11-01 12:56:41 +07:00
Mohamed Boudra 60f29a4350 docs(worktrees): add nested worktreeinclude reference 2026-11-01 06:31:01 +01:00
paseo-bot[bot]andDominic Anaeto 8efb21f55c fix(server): close abandoned workspace service connections (#8421)
* fix(server): close the upstream connection when a proxied client leaves

The service proxy piped the service's response to the client but never
destroyed the upstream request when the client went away. The upstream
socket stayed open and unread, so a Server-Sent Events service kept
writing into it and grew in memory. The proxy now destroys the upstream
request when the client response closes before it finished, without
logging that deliberate abort as an unreachable upstream, and a
WebSocket upgrade closes the service socket when the client socket
closes.

Fixes #8149

* test(server): publish terminal activity fixture atomically

File existence did not mean the child had finished writing its JSON. Publish the completed fixture by rename so a concurrent reader cannot parse an empty file.

Co-authored-by: Dominic Anaeto <dominic.anaeto@gmail.com>

* test(server): keep rollback listener ports distinct

Selecting both free ports before binding either lets the OS return the same port twice. Hold the intentionally occupied main port before selecting the standalone port so the rollback probe cannot reach the wrong server.

---------

Co-authored-by: Dominic Anaeto <dominic.anaeto@gmail.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-01 03:55:48 +00:00
paseo-ai[bot] b2666a0aec fix: update lockfile signatures and Nix hash [skip ci] 2026-11-01 03:43:24 +00:00
12 changed files with 432 additions and 58 deletions
@@ -670,11 +670,11 @@ describe("paseo daemon bootstrap", () => {
test("rolls back standalone listener without starting plugins when main listen fails", async () => { test("rolls back standalone listener without starting plugins when main listen fails", async () => {
const mainPort = await findFreePort(); const mainPort = await findFreePort();
const standalonePort = await findFreePort();
const occupiedMain = http.createServer((_req, res) => { const occupiedMain = http.createServer((_req, res) => {
res.end("occupied-main"); res.end("occupied-main");
}); });
await new Promise<void>((resolve) => occupiedMain.listen(mainPort, "127.0.0.1", resolve)); await new Promise<void>((resolve) => occupiedMain.listen(mainPort, "127.0.0.1", resolve));
const standalonePort = await findFreePort();
const paseoHomeRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-main-rollback-")); const paseoHomeRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-main-rollback-"));
const paseoHome = path.join(paseoHomeRoot, ".paseo"); const paseoHome = path.join(paseoHomeRoot, ".paseo");
@@ -61,10 +61,24 @@ describe("managed Git plugin sources", () => {
roots.push(home); roots.push(home);
const sources = new ManagedPluginSources(home); const sources = new ManagedPluginSources(home);
const failure = sources.prepareInstall({ // Own the failure response instead of relying on a closed port and OS connect timing.
source: "git:https://oauth2:super-secret@127.0.0.1:1/missing.git", const server = createServer((_request, response) => {
response.writeHead(500, { "Content-Type": "text/plain" });
response.end("clone rejected for oauth2:super-secret\n");
}); });
await expect(failure).rejects.not.toThrow(/oauth2|super-secret/); await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address() as { port: number };
try {
const failure = sources.prepareInstall({
source: `git:http://oauth2:super-secret@127.0.0.1:${address.port}/missing.git`,
});
await expect(failure).rejects.toThrow("500");
await expect(failure).rejects.toThrow("clone rejected for [redacted]:[redacted]");
await expect(failure).rejects.not.toThrow(/oauth2|super-secret/);
} finally {
server.closeAllConnections();
await new Promise<void>((resolve) => server.close(() => resolve()));
}
}); });
it("normalizes explicit GitHub and Git identifiers to the same Git source", async () => { it("normalizes explicit GitHub and Git identifiers to the same Git source", async () => {
+246 -31
View File
@@ -1,7 +1,9 @@
import { type EventEmitter, once } from "node:events";
import { readdirSync, readFileSync, statSync } from "node:fs"; import { readdirSync, readFileSync, statSync } from "node:fs";
import path from "node:path"; import path from "node:path";
import http from "node:http"; import http from "node:http";
import net from "node:net"; import net from "node:net";
import type { Duplex } from "node:stream";
import express from "express"; import express from "express";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import pino from "pino"; import pino from "pino";
@@ -332,48 +334,39 @@ describe("service proxy subsystem shape", () => {
}); });
}); });
interface ForwardedFixture { interface ProxiedServiceFixture {
upstreamPort: number; upstreamPort: number;
daemonPort: number; daemonPort: number;
daemon: http.Server;
hostname: string; hostname: string;
close(): Promise<void>; close(): Promise<void>;
} }
/** /**
* Runs a real workspace service behind a real daemon listener. The upstream * Runs a real workspace service behind a real daemon listener that serves the
* echoes the headers it received so tests can assert what actually crossed the * service proxy for both HTTP and upgrades.
* proxy, not what a helper returned.
*/ */
async function startForwardedHeadersFixture(upstreamHost = "127.0.0.1"): Promise<ForwardedFixture> { async function startProxiedService(
const upstream = http.createServer((req, res) => { upstream: http.Server,
let body = ""; options: { upstreamHost?: string; logger?: pino.Logger } = {},
req.on("data", (chunk: Buffer) => { ): Promise<ProxiedServiceFixture> {
body += chunk.toString(); const { upstreamHost = "127.0.0.1", logger: proxyLogger = logger } = options;
// Upgraded sockets keep the server alive; close() alone would hang.
const sockets = new Set<net.Socket>();
function trackConnections(server: http.Server) {
server.on("connection", (socket: net.Socket) => {
sockets.add(socket);
socket.once("close", () => sockets.delete(socket));
}); });
req.on("end", () => { }
res.writeHead(200, { "content-type": "application/json" }); trackConnections(upstream);
res.end(JSON.stringify({ ...req.headers, method: req.method, url: req.url, body }));
});
});
const upgradeSockets: net.Socket[] = [];
upstream.on("upgrade", (req, socket) => {
upgradeSockets.push(socket);
// The client resets this connection once it has the echo, so the reset
// reaches us as an 'error'. A raw upgrade socket has no default handler —
// without this the event goes unhandled and takes down the test process.
socket.on("error", () => socket.destroy());
const payload = JSON.stringify(req.headers);
socket.write(
`HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nX-Echo-Length: ${payload.length}\r\n\r\n${payload}`,
);
});
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
upstream.once("error", reject); upstream.once("error", reject);
upstream.listen(0, upstreamHost, resolve); upstream.listen(0, upstreamHost, resolve);
}); });
const upstreamPort = (upstream.address() as net.AddressInfo).port; const upstreamPort = (upstream.address() as net.AddressInfo).port;
const serviceProxy = createServiceProxySubsystem({ logger }); const serviceProxy = createServiceProxySubsystem({ logger: proxyLogger });
const route = serviceProxy.registerWorkspaceService({ const route = serviceProxy.registerWorkspaceService({
workspaceId: "workspace-a", workspaceId: "workspace-a",
projectSlug: "repo", projectSlug: "repo",
@@ -390,24 +383,51 @@ async function startForwardedHeadersFixture(upstreamHost = "127.0.0.1"): Promise
res.status(404).send("404 Not Found"); res.status(404).send("404 Not Found");
}); });
const daemon = http.createServer(app); const daemon = http.createServer(app);
trackConnections(daemon);
daemon.on("upgrade", serviceProxy.upgradeHandler({ passthroughUnknown: false })); daemon.on("upgrade", serviceProxy.upgradeHandler({ passthroughUnknown: false }));
await new Promise<void>((resolve) => daemon.listen(daemonPort, "127.0.0.1", resolve)); await new Promise<void>((resolve) => daemon.listen(daemonPort, "127.0.0.1", resolve));
return { return {
upstreamPort, upstreamPort,
daemonPort, daemonPort,
daemon,
hostname: route.hostname, hostname: route.hostname,
async close() { async close() {
// Upgraded sockets keep the server alive; close() alone would hang. for (const socket of sockets) socket.destroy();
for (const socket of upgradeSockets) socket.destroy();
daemon.closeAllConnections();
await new Promise<void>((resolve) => daemon.close(() => resolve())); await new Promise<void>((resolve) => daemon.close(() => resolve()));
upstream.closeAllConnections();
await new Promise<void>((resolve) => upstream.close(() => resolve())); await new Promise<void>((resolve) => upstream.close(() => resolve()));
}, },
}; };
} }
/**
* The upstream echoes the headers it received so tests can assert what
* actually crossed the proxy, not what a helper returned.
*/
function startForwardedHeadersFixture(upstreamHost = "127.0.0.1"): Promise<ProxiedServiceFixture> {
const upstream = http.createServer((req, res) => {
let body = "";
req.on("data", (chunk: Buffer) => {
body += chunk.toString();
});
req.on("end", () => {
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify({ ...req.headers, method: req.method, url: req.url, body }));
});
});
upstream.on("upgrade", (req, socket) => {
// The client resets this connection once it has the echo, so the reset
// reaches us as an 'error'. A raw upgrade socket has no default handler —
// without this the event goes unhandled and takes down the test process.
socket.on("error", () => socket.destroy());
const payload = JSON.stringify(req.headers);
socket.write(
`HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nX-Echo-Length: ${payload.length}\r\n\r\n${payload}`,
);
});
return startProxiedService(upstream, { upstreamHost });
}
function upgradeThroughProxy( function upgradeThroughProxy(
port: number, port: number,
host: string, host: string,
@@ -651,3 +671,198 @@ describe("service proxy forwarded headers", () => {
} }
}); });
}); });
function closed(emitter: EventEmitter): Promise<void> {
return new Promise((resolve) => {
emitter.once("close", () => resolve());
});
}
async function within<T>(promise: Promise<T>, ms: number, failure: string): Promise<T> {
let timer: NodeJS.Timeout | undefined;
const timeout = new Promise<never>((_resolve, reject) => {
timer = setTimeout(() => reject(new Error(`${failure} after ${ms}ms`)), ms);
});
try {
return await Promise.race([promise, timeout]);
} finally {
clearTimeout(timer);
}
}
function requestThroughProxy(
fixture: ProxiedServiceFixture,
requestPath: string,
): http.ClientRequest {
return http.get({
hostname: "127.0.0.1",
port: fixture.daemonPort,
path: requestPath,
headers: { host: fixture.hostname },
});
}
const STRAY_WARNING_GRACE_MS = 100;
function createUpgradeUpstream(onUpgrade: (socket: Duplex) => void): http.Server {
const upstream = http.createServer();
upstream.on("upgrade", (_req, socket) => {
socket.on("error", () => socket.destroy());
socket.on("end", () => socket.end());
socket.write(
"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\r\n",
);
onUpgrade(socket);
});
return upstream;
}
function openUpgradeThroughProxy(fixture: ProxiedServiceFixture): Promise<net.Socket> {
return new Promise((resolve, reject) => {
const socket = net.connect({ host: "127.0.0.1", port: fixture.daemonPort }, () => {
const lines = [
"GET /ws HTTP/1.1",
`Host: ${fixture.hostname}`,
"Upgrade: websocket",
"Connection: Upgrade",
"",
"",
];
socket.write(lines.join("\r\n"));
});
let raw = "";
socket.on("data", (chunk: Buffer) => {
raw += chunk.toString();
if (raw.includes("\r\n\r\n")) resolve(socket);
});
socket.on("close", () => reject(new Error("upgrade closed before the 101 arrived")));
socket.on("error", reject);
});
}
describe("service proxy connection teardown", () => {
it("closes the upstream connection when a client leaves an event stream", async () => {
const upstreamResponse = Promise.withResolvers<http.ServerResponse>();
const upstream = http.createServer((_req, res) => {
res.writeHead(200, { "content-type": "text/event-stream" });
res.write("data: open\n\n");
upstreamResponse.resolve(res);
});
const fixture = await startProxiedService(upstream);
try {
const request = requestThroughProxy(fixture, "/events");
const [response] = (await once(request, "response")) as [http.IncomingMessage];
await once(response, "data");
const upstreamClosed = closed(await upstreamResponse.promise);
request.destroy();
await within(upstreamClosed, 1000, "the service still holds the event stream");
} finally {
await fixture.close();
}
}, 5000);
it("aborts the upstream request when a client leaves before the response starts", async () => {
const warnings: unknown[] = [];
const proxyLogger = pino(
{ level: "warn" },
{ write: (line: string) => warnings.push(JSON.parse(line)) },
);
const upstreamResponse = Promise.withResolvers<http.ServerResponse>();
const upstream = http.createServer((_req, res) => {
upstreamResponse.resolve(res);
});
const fixture = await startProxiedService(upstream, { logger: proxyLogger });
try {
const request = requestThroughProxy(fixture, "/slow");
request.on("error", () => undefined);
const upstreamClosed = closed(await upstreamResponse.promise);
request.destroy();
await within(upstreamClosed, 1000, "the service still holds the abandoned request");
await new Promise((resolve) => setTimeout(resolve, STRAY_WARNING_GRACE_MS));
expect(warnings).toEqual([]);
} finally {
await fixture.close();
}
}, 5000);
it("lets a complete response finish without resetting the service", async () => {
const upstreamResponse = Promise.withResolvers<http.ServerResponse>();
const upstream = http.createServer((_req, res) => {
res.writeHead(200, { "content-type": "text/plain" });
res.write("first;");
upstreamResponse.resolve(res);
});
const fixture = await startProxiedService(upstream);
try {
const response = httpGet(fixture.daemonPort, fixture.hostname, { path: "/" });
const res = await upstreamResponse.promise;
const upstreamClosed = closed(res);
res.end("second");
await expect(response).resolves.toEqual({ status: 200, body: "first;second" });
await within(upstreamClosed, 1000, "the service response never closed");
expect(res.writableFinished).toBe(true);
} finally {
await fixture.close();
}
}, 5000);
it("closes the upstream WebSocket when the daemon drops the client socket", async () => {
const upstreamSocket = Promise.withResolvers<Duplex>();
const fixture = await startProxiedService(
createUpgradeUpstream((socket) => upstreamSocket.resolve(socket)),
);
const daemonSide = Promise.withResolvers<Duplex>();
fixture.daemon.on("upgrade", (_req, socket) => daemonSide.resolve(socket));
try {
const client = await openUpgradeThroughProxy(fixture);
const upstreamClosed = closed(await upstreamSocket.promise);
(await daemonSide.promise).destroy();
await within(upstreamClosed, 1000, "the service still holds the WebSocket");
client.destroy();
} finally {
await fixture.close();
}
}, 5000);
it("closes the upstream WebSocket when the client disconnects", async () => {
const upstreamSocket = Promise.withResolvers<Duplex>();
const fixture = await startProxiedService(
createUpgradeUpstream((socket) => upstreamSocket.resolve(socket)),
);
try {
const client = await openUpgradeThroughProxy(fixture);
const upstreamClosed = closed(await upstreamSocket.promise);
client.destroy();
await within(upstreamClosed, 1000, "the service still holds the WebSocket");
} finally {
await fixture.close();
}
}, 5000);
it("closes the client WebSocket when the service closes it", async () => {
const upstreamSocket = Promise.withResolvers<Duplex>();
const fixture = await startProxiedService(
createUpgradeUpstream((socket) => upstreamSocket.resolve(socket)),
);
try {
const client = await openUpgradeThroughProxy(fixture);
const clientClosed = closed(client);
(await upstreamSocket.promise).destroy();
await within(clientClosed, 1000, "the client WebSocket is still open");
} finally {
await fixture.close();
}
}, 5000);
});
@@ -342,7 +342,14 @@ function proxyHttpRequest({
proxyRes.pipe(res, { end: true }); proxyRes.pipe(res, { end: true });
}, },
); );
let clientClosed = false;
res.on("close", () => {
if (res.writableFinished) return;
clientClosed = true;
proxyReq.destroy();
});
proxyReq.on("error", (err) => { proxyReq.on("error", (err) => {
if (clientClosed) return;
logger.warn( logger.warn(
{ err, hostname: route.hostname, port: route.port }, { err, hostname: route.hostname, port: route.port },
"Service proxy: upstream unreachable", "Service proxy: upstream unreachable",
@@ -399,6 +406,9 @@ function proxyUpgradeRequest({
socket.on("error", () => { socket.on("error", () => {
targetSocket.destroy(); targetSocket.destroy();
}); });
socket.on("close", () => {
targetSocket.destroy();
});
} }
function sameRouteOwner(left: ServiceProxyRouteEntry, right: ServiceProxyRouteEntry): boolean { function sameRouteOwner(left: ServiceProxyRouteEntry, right: ServiceProxyRouteEntry): boolean {
@@ -95,7 +95,13 @@ it("accepts terminalId and token reports through the route into the tracker", as
command: process.execPath, command: process.execPath,
args: [ args: [
"-e", "-e",
`require("node:fs").writeFileSync(${JSON.stringify(envPath)}, JSON.stringify({ terminalId: process.env.PASEO_TERMINAL_ID, token: process.env.PASEO_ACTIVITY_TOKEN, url: process.env.PASEO_TERMINAL_ACTIVITY_URL })); setInterval(() => {}, 1000);`, `
const fs = require("node:fs");
const envPath = ${JSON.stringify(envPath)};
fs.writeFileSync(envPath + ".tmp", JSON.stringify({ terminalId: process.env.PASEO_TERMINAL_ID, token: process.env.PASEO_ACTIVITY_TOKEN, url: process.env.PASEO_TERMINAL_ACTIVITY_URL }));
fs.renameSync(envPath + ".tmp", envPath);
setInterval(() => {}, 1000);
`,
], ],
}); });
await waitForCondition(() => existsSync(envPath), 10000); await waitForCondition(() => existsSync(envPath), 10000);
+7 -1
View File
@@ -186,7 +186,13 @@ function insertDocByPath(nodes: DocsNavNode[], doc: Doc, stripTopDirectory = fal
// A section's top-level directory and its category name the same thing, so // A section's top-level directory and its category name the same thing, so
// only directories below it become collapsible groups. // only directories below it become collapsible groups.
if (stripTopDirectory) directories.shift(); if (
stripTopDirectory &&
directories[0] &&
doc.frontmatter.category?.toLowerCase().endsWith(directories[0].toLowerCase())
) {
directories.shift();
}
let current = nodes; let current = nodes;
let parent: Extract<DocsNavNode, { type: "group" }> | undefined; let parent: Extract<DocsNavNode, { type: "group" }> | undefined;
@@ -2,6 +2,25 @@ import { describe, expect, it } from "vitest";
import { buildDocsNavTree, getDoc, getDocs, getLegacyDocsRedirect, parseHeadings } from "./docs"; import { buildDocsNavTree, getDoc, getDocs, getLegacyDocsRedirect, parseHeadings } from "./docs";
describe("plugin documentation", () => { describe("plugin documentation", () => {
it.each(["en", "zh-CN"] as const)(
"nests local file copying under Git worktrees in %s",
(locale) => {
const workspaces = buildDocsNavTree(getDocs(locale)).find(
(node) => node.type === "category" && node.label === "Workspaces",
);
expect(workspaces).toMatchObject({
children: expect.arrayContaining([
expect.objectContaining({
type: "group",
href: "/docs/worktrees",
children: [expect.objectContaining({ href: "/docs/worktrees/worktreeinclude" })],
}),
]),
});
expect(getDoc("worktrees/worktreeinclude", locale)).toBeDefined();
},
);
it("groups current guides by task without version navigation", () => { it("groups current guides by task without version navigation", () => {
const plugins = buildDocsNavTree(getDocs("en")).find( const plugins = buildDocsNavTree(getDocs("en")).find(
(node) => node.type === "category" && node.label === "Plugins", (node) => node.type === "category" && node.label === "Plugins",
+1 -1
View File
@@ -4,7 +4,7 @@ import { expect, it } from "vitest";
const docsRoot = path.resolve(import.meta.dirname, "../../../public-docs"); const docsRoot = path.resolve(import.meta.dirname, "../../../public-docs");
it.each(["schedules.md", "schedules-cli.md", "mcp.md", "worktrees.md"])( it.each(["schedules.md", "schedules-cli.md", "mcp.md", "worktrees/index.md"])(
"%s makes schedule run retention discoverable", "%s makes schedule run retention discoverable",
async (file) => { async (file) => {
const markdown = await readFile(path.join(docsRoot, file), "utf8"); const markdown = await readFile(path.join(docsRoot, file), "utf8");
@@ -84,16 +84,7 @@ Add `--forge <name>` when Paseo cannot infer the forge from the source checkout.
## Copy local files with .worktreeinclude ## Copy local files with .worktreeinclude
Create `.worktreeinclude` at the root of your source checkout to copy selected gitignored files into new worktrees: Use [`.worktreeinclude`](/docs/worktrees/worktreeinclude) to copy selected gitignored files, such as `.env`, from your source checkout before setup runs.
```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 ## paseo.json
@@ -82,16 +82,7 @@ paseo workspace create \
## 使用 .worktreeinclude 复制本地文件 {#copy-local-files-with-worktreeinclude} ## 使用 .worktreeinclude 复制本地文件 {#copy-local-files-with-worktreeinclude}
在源检出的根目录创建 `.worktreeinclude`,将选定的 Git 忽略文件复制到新的 worktree: 使用 [`.worktreeinclude`](/docs/worktrees/worktreeinclude),在 setup 运行前将 `.env` 等选定的 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 {#paseojson}
+61
View File
@@ -0,0 +1,61 @@
---
title: .worktreeinclude
description: Copy gitignored local files into new Paseo worktrees.
nav: .worktreeinclude
order: 12
category: Workspaces
---
# .worktreeinclude
Add `.worktreeinclude` at the root of your source checkout to copy local configuration into new [Git worktrees](/docs/worktrees). Paseo copies matching gitignored files before setup, terminals, and the first Agent start.
## Copy your local configuration
For a project whose `.gitignore` includes `.env` and `config/*.local.json`, create this `.worktreeinclude`:
```gitignore
.env
config/*.local.json
```
When you create a worktree through Paseo, an existing `.env` and matching files such as `config/dev.local.json` are copied to the same relative paths in the new worktree. Their contents stay out of Git.
Commit `.worktreeinclude` to share the patterns with your team. Each teammate supplies their own ignored files in the checkout they create worktrees from.
## Pattern syntax
Use gitignore syntax, with one pattern per line. Comments start with `#`. Prefix a pattern with `!` to exclude a match:
```gitignore
# Local environment and configuration
.env
config/*.local.json
!config/production.local.json
```
A pattern only selects files that Git already ignores. Adding a path here does not ignore it, so also configure `.gitignore` when needed. Tracked files and untracked files that Git does not ignore are excluded.
## When files are copied
The built-in `worktree-include` plugin reads the file from the checkout you create the worktree from. That source can itself be another worktree. Keep the plugin enabled for copying to run.
- Without `.worktreeinclude`, the plugin copies nothing.
- Matching files must exist in the source checkout.
- Existing destination files are skipped, preserving their contents.
- Copies happen during creation, with no ongoing synchronization between checkouts.
- A copy error aborts creation and removes the new worktree. Avoid including symlinked directories or nested repositories, which cannot be copied as files.
## Run commands during setup
Use `.worktreeinclude` for files that already exist locally. Use a [setup hook](/docs/worktrees#setup-and-teardown) to install dependencies, generate files, or fetch secrets:
```json
{
"worktree": {
"setup": "npm ci"
}
}
```
Put this configuration in `paseo.json` on the base branch used for the new worktree. Copied files are available when setup runs.
@@ -0,0 +1,61 @@
---
title: .worktreeinclude
description: 将 Git 忽略的本地文件复制到新的 Paseo worktree。
nav: .worktreeinclude
order: 12
category: Workspaces
---
# .worktreeinclude
在源检出的根目录添加 `.worktreeinclude`,将本地配置复制到新的 [Git worktree](/docs/worktrees)。Paseo 会在 setup、终端和首个 Agent 启动之前复制匹配的 Git 忽略文件。
## 复制本地配置 {#copy-your-local-configuration}
如果项目的 `.gitignore` 包含 `.env` 和 `config/*.local.json`,创建以下 `.worktreeinclude`:
```gitignore
.env
config/*.local.json
```
通过 Paseo 创建 worktree 时,已有的 `.env` 和 `config/dev.local.json` 等匹配文件会被复制到新 worktree 中的相同相对路径。文件内容仍不会纳入 Git。
提交 `.worktreeinclude`,与团队共享这些模式。每位团队成员在用于创建 worktree 的检出中提供自己的忽略文件。
## 模式语法 {#pattern-syntax}
使用 gitignore 语法,每行一个模式。注释以 `#` 开头。模式前加 `!` 可排除匹配项:
```gitignore
# Local environment and configuration
.env
config/*.local.json
!config/production.local.json
```
模式只会选择 Git 已忽略的文件。在此添加路径不会使 Git 忽略该文件,需要时还应配置 `.gitignore`。已跟踪的文件和未被 Git 忽略的未跟踪文件都不会被复制。
## 文件复制时机 {#when-files-are-copied}
内置的 `worktree-include` 插件从用于创建 worktree 的检出读取该文件。源检出也可以是另一个 worktree。保持该插件启用才能执行复制。
- 没有 `.worktreeinclude` 时,插件不会复制任何文件。
- 匹配的文件必须存在于源检出中。
- 目标中已存在的文件会被跳过,保留原有内容。
- 复制发生在创建期间,之后不会在检出之间持续同步。
- 复制错误会中止创建并删除新 worktree。避免包含符号链接目录或嵌套仓库,因为它们无法作为文件复制。
## 在 setup 期间运行命令 {#run-commands-during-setup}
使用 `.worktreeinclude` 复制本地已有文件。使用 [setup 钩子](/docs/worktrees#setup-and-teardown) 安装依赖、生成文件或获取密钥:
```json
{
"worktree": {
"setup": "npm ci"
}
}
```
将此配置放入新 worktree 所用基准分支的 `paseo.json` 中。setup 运行时,复制的文件已可使用。