Compare commits

..
Author SHA1 Message Date
Mohamed Boudra 8bd38fb236 chore(release): cut 0.15.0-beta.1 2026-11-01 06:19:33 +01:00
Mohamed Boudra 7e5ad53491 docs(changelog): prepare 0.15.0-beta.1 2026-11-01 06:11:09 +01:00
paseo-ai[bot] a529578cd1 fix: update lockfile signatures and Nix hash [skip ci]
(cherry picked from commit b2666a0aec)
2026-11-01 06:11:09 +01:00
14 changed files with 60 additions and 521 deletions
@@ -670,11 +670,11 @@ describe("paseo daemon bootstrap", () => {
test("rolls back standalone listener without starting plugins when main listen fails", async () => {
const mainPort = await findFreePort();
const standalonePort = await findFreePort();
const occupiedMain = http.createServer((_req, res) => {
res.end("occupied-main");
});
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 paseoHome = path.join(paseoHomeRoot, ".paseo");
@@ -61,24 +61,10 @@ describe("managed Git plugin sources", () => {
roots.push(home);
const sources = new ManagedPluginSources(home);
// Own the failure response instead of relying on a closed port and OS connect timing.
const server = createServer((_request, response) => {
response.writeHead(500, { "Content-Type": "text/plain" });
response.end("clone rejected for oauth2:super-secret\n");
const failure = sources.prepareInstall({
source: "git:https://oauth2:super-secret@127.0.0.1:1/missing.git",
});
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()));
}
await expect(failure).rejects.not.toThrow(/oauth2|super-secret/);
});
it("normalizes explicit GitHub and Git identifiers to the same Git source", async () => {
+31 -246
View File
@@ -1,9 +1,7 @@
import { type EventEmitter, once } from "node:events";
import { readdirSync, readFileSync, statSync } from "node:fs";
import path from "node:path";
import http from "node:http";
import net from "node:net";
import type { Duplex } from "node:stream";
import express from "express";
import { describe, expect, it } from "vitest";
import pino from "pino";
@@ -334,39 +332,48 @@ describe("service proxy subsystem shape", () => {
});
});
interface ProxiedServiceFixture {
interface ForwardedFixture {
upstreamPort: number;
daemonPort: number;
daemon: http.Server;
hostname: string;
close(): Promise<void>;
}
/**
* Runs a real workspace service behind a real daemon listener that serves the
* service proxy for both HTTP and upgrades.
* Runs a real workspace service behind a real daemon listener. The upstream
* echoes the headers it received so tests can assert what actually crossed the
* proxy, not what a helper returned.
*/
async function startProxiedService(
upstream: http.Server,
options: { upstreamHost?: string; logger?: pino.Logger } = {},
): Promise<ProxiedServiceFixture> {
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));
async function startForwardedHeadersFixture(upstreamHost = "127.0.0.1"): Promise<ForwardedFixture> {
const upstream = http.createServer((req, res) => {
let body = "";
req.on("data", (chunk: Buffer) => {
body += chunk.toString();
});
}
trackConnections(upstream);
req.on("end", () => {
res.writeHead(200, { "content-type": "application/json" });
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) => {
upstream.once("error", reject);
upstream.listen(0, upstreamHost, resolve);
});
const upstreamPort = (upstream.address() as net.AddressInfo).port;
const serviceProxy = createServiceProxySubsystem({ logger: proxyLogger });
const serviceProxy = createServiceProxySubsystem({ logger });
const route = serviceProxy.registerWorkspaceService({
workspaceId: "workspace-a",
projectSlug: "repo",
@@ -383,51 +390,24 @@ async function startProxiedService(
res.status(404).send("404 Not Found");
});
const daemon = http.createServer(app);
trackConnections(daemon);
daemon.on("upgrade", serviceProxy.upgradeHandler({ passthroughUnknown: false }));
await new Promise<void>((resolve) => daemon.listen(daemonPort, "127.0.0.1", resolve));
return {
upstreamPort,
daemonPort,
daemon,
hostname: route.hostname,
async close() {
for (const socket of sockets) socket.destroy();
// Upgraded sockets keep the server alive; close() alone would hang.
for (const socket of upgradeSockets) socket.destroy();
daemon.closeAllConnections();
await new Promise<void>((resolve) => daemon.close(() => resolve()));
upstream.closeAllConnections();
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(
port: number,
host: string,
@@ -671,198 +651,3 @@ 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,14 +342,7 @@ function proxyHttpRequest({
proxyRes.pipe(res, { end: true });
},
);
let clientClosed = false;
res.on("close", () => {
if (res.writableFinished) return;
clientClosed = true;
proxyReq.destroy();
});
proxyReq.on("error", (err) => {
if (clientClosed) return;
logger.warn(
{ err, hostname: route.hostname, port: route.port },
"Service proxy: upstream unreachable",
@@ -406,9 +399,6 @@ function proxyUpgradeRequest({
socket.on("error", () => {
targetSocket.destroy();
});
socket.on("close", () => {
targetSocket.destroy();
});
}
function sameRouteOwner(left: ServiceProxyRouteEntry, right: ServiceProxyRouteEntry): boolean {
@@ -95,13 +95,7 @@ it("accepts terminalId and token reports through the route into the tracker", as
command: process.execPath,
args: [
"-e",
`
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);
`,
`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);`,
],
});
await waitForCondition(() => existsSync(envPath), 10000);
+1 -7
View File
@@ -186,13 +186,7 @@ function insertDocByPath(nodes: DocsNavNode[], doc: Doc, stripTopDirectory = fal
// A section's top-level directory and its category name the same thing, so
// only directories below it become collapsible groups.
if (
stripTopDirectory &&
directories[0] &&
doc.frontmatter.category?.toLowerCase().endsWith(directories[0].toLowerCase())
) {
directories.shift();
}
if (stripTopDirectory) directories.shift();
let current = nodes;
let parent: Extract<DocsNavNode, { type: "group" }> | undefined;
@@ -2,25 +2,6 @@ import { describe, expect, it } from "vitest";
import { buildDocsNavTree, getDoc, getDocs, getLegacyDocsRedirect, parseHeadings } from "./docs";
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", () => {
const plugins = buildDocsNavTree(getDocs("en")).find(
(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");
it.each(["schedules.md", "schedules-cli.md", "mcp.md", "worktrees/index.md"])(
it.each(["schedules.md", "schedules-cli.md", "mcp.md", "worktrees.md"])(
"%s makes schedule run retention discoverable",
async (file) => {
const markdown = await readFile(path.join(docsRoot, file), "utf8");
@@ -21,9 +21,9 @@ export class PromptFiles {
)
throw new AntigravityError("Antigravity supports messages only");
const parts = prompt.input.content.map((part) => {
if (part.type === "text" || part.type === "uploaded_file") return part;
if (part.type === "text") return part;
if (part.type === "image") return { ...part, extension: imageExtension(part.mimeType) };
throw new AntigravityError("Antigravity supports text, image and uploaded file content only");
throw new AntigravityError("Antigravity supports text and image content only");
});
const text: string[] = [];
const native: string[] = [];
@@ -41,19 +41,6 @@ export class PromptFiles {
await writeFile(file, Buffer.from(part.data, "base64"), { flag: "wx", mode: 0o600 });
await chmod(file, 0o600);
native.push(`Attached image: ${file}`);
} else if (part.type === "uploaded_file") {
if (part.mimeType.startsWith("image/")) {
native.push(`Attached image: ${part.path}`);
} else {
const reference = [
`Uploaded file: ${part.fileName}`,
`Path: ${part.path}`,
`MIME: ${part.mimeType}`,
`Size: ${part.size} bytes`,
].join("\n");
text.push(reference);
native.push(reference);
}
}
}
return { text: text.join("\n"), nativeText: native.join("\n") };
@@ -774,54 +774,6 @@ describe("image prompts", () => {
});
});
describe("uploaded file prompts", () => {
it("references uploaded text, images and large files without changing their bytes", async () => {
const h = await harness();
await h.open();
const txtFile = path.join(h.cwd, "notes.txt");
await writeFile(txtFile, "export const meaning = 42;");
const imgFile = path.join(h.cwd, "photo.png");
await writeFile(imgFile, "png bytes");
await h.completed(
await h.message([
{ type: "text", text: "Please review these attachments:" },
{
type: "uploaded_file",
id: "upload-txt-1",
fileName: "notes.txt",
mimeType: "text/plain",
size: 26,
path: txtFile,
},
{
type: "uploaded_file",
id: "upload-img-1",
fileName: "photo.png",
mimeType: "image/png",
size: 9,
path: imgFile,
},
{
type: "uploaded_file",
id: "upload-big-1",
fileName: "dataset.bin",
mimeType: "application/octet-stream",
size: 3 * 1024 * 1024,
path: "/tmp/dataset.bin",
},
]),
);
const sent = (await h.records()).find((entry) => entry.input).input.message.content;
expect(sent).toBe(
`SYSTEM PREFIX\n\nPlease review these attachments:\nUploaded file: notes.txt\nPath: ${txtFile}\nMIME: text/plain\nSize: 26 bytes\nAttached image: ${imgFile}\nUploaded file: dataset.bin\nPath: /tmp/dataset.bin\nMIME: application/octet-stream\nSize: 3145728 bytes`,
);
expect(await readFile(txtFile, "utf8")).toBe("export const meaning = 42;");
expect(await readFile(imgFile, "utf8")).toBe("png bytes");
});
});
it.runIf(process.platform === "win32")(
"launches an Antigravity command shim for probes and conversation startup",
async () => {
@@ -837,29 +789,3 @@ it.runIf(process.platform === "win32")(
});
},
);
it("preserves a usable reference for an uploaded binary file", async () => {
const h = await harness();
await h.open();
const file = path.join(h.cwd, "document.pdf");
const bytes = Buffer.from([0x25, 0x50, 0x44, 0x46, 0x00, 0xff, 0xfe]);
await writeFile(file, bytes);
await h.completed(
await h.message([
{ type: "text", text: "Read the attachment" },
{
type: "uploaded_file",
id: "pdf-upload",
fileName: "document.pdf",
mimeType: "application/pdf",
size: 7,
path: file,
},
]),
);
const sent = (await h.records()).find((entry) => entry.input).input.message.content;
expect(sent).toContain(file);
expect(sent).not.toContain("\u0000");
expect(sent).not.toContain("\ufffd");
expect(await readFile(file)).toEqual(bytes);
});
@@ -84,7 +84,16 @@ Add `--forge <name>` when Paseo cannot infer the forge from the source checkout.
## Copy local files with .worktreeinclude
Use [`.worktreeinclude`](/docs/worktrees/worktreeinclude) to copy selected gitignored files, such as `.env`, from your source checkout before setup runs.
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
@@ -82,7 +82,16 @@ paseo workspace create \
## 使用 .worktreeinclude 复制本地文件 {#copy-local-files-with-worktreeinclude}
使用 [`.worktreeinclude`](/docs/worktrees/worktreeinclude),在 setup 运行前将 `.env` 等选定的 Git 忽略文件从源检出复制到新 worktree。
在源检出的根目录创建 `.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}
-61
View File
@@ -1,61 +0,0 @@
---
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.
@@ -1,61 +0,0 @@
---
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 运行时,复制的文件已可使用。