From e6be1c0b6cf0184115399b8a4872a94119999d70 Mon Sep 17 00:00:00 2001 From: Sriram-52 Date: Sat, 22 Aug 2026 22:13:27 -0400 Subject: [PATCH 1/7] feat(server): copy .worktreeinclude files into new worktrees Co-Authored-By: Claude Fable 5 --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 87 ++++++++++++++++++++ apps/server/src/vcs/GitVcsDriverCore.ts | 55 +++++++++++++ docs/README.md | 1 + docs/internals/glossary.md | 2 +- docs/user/worktrees.md | 24 ++++++ 5 files changed, 168 insertions(+), 1 deletion(-) create mode 100644 docs/user/worktrees.md diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 66dc7b96a73e..166e8d4412fe 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -1401,6 +1401,93 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { assert.equal(yield* fileSystem.exists(worktreePath), false); }), ); + + it.effect("copies .worktreeinclude-matched untracked files into a new worktree", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + + yield* writeTextFile(cwd, ".gitignore", ".env\nnode_modules/\nsecrets/\n"); + yield* git(cwd, ["add", ".gitignore"]); + yield* git(cwd, ["commit", "-m", "ignore local files"]); + yield* writeTextFile( + cwd, + ".worktreeinclude", + "# copied into new worktrees\n.env\nsecrets/\n", + ); + yield* writeTextFile(cwd, ".env", "TOP=1\n"); + yield* writeTextFile(cwd, "infra/relay/.env", "NESTED=1\n"); + yield* writeTextFile(cwd, "secrets/token.txt", "token\n"); + yield* writeTextFile(cwd, "node_modules/pkg/index.js", "module.exports = {};\n"); + yield* writeTextFile(cwd, "notes.txt", "untracked but not included\n"); + + const worktreePath = pathService.join( + yield* makeTmpDir("git-worktrees-"), + "include-worktree", + ); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* driver.createWorktree({ + cwd, + path: worktreePath, + refName: initialBranch, + newRefName: "feature/worktree-include", + }); + + assert.equal( + yield* fileSystem.readFileString(pathService.join(worktreePath, ".env")), + "TOP=1\n", + ); + assert.equal( + yield* fileSystem.readFileString(pathService.join(worktreePath, "infra/relay/.env")), + "NESTED=1\n", + ); + assert.equal( + yield* fileSystem.readFileString(pathService.join(worktreePath, "secrets/token.txt")), + "token\n", + ); + assert.equal( + yield* fileSystem.exists(pathService.join(worktreePath, "node_modules")), + false, + ); + assert.equal(yield* fileSystem.exists(pathService.join(worktreePath, "notes.txt")), false); + }), + ); + + it.effect("still creates the worktree when a .worktreeinclude copy fails", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + + yield* writeTextFile(cwd, ".worktreeinclude", ".env\n"); + yield* fileSystem.symlink( + pathService.join(cwd, "missing-target"), + pathService.join(cwd, ".env"), + ); + + const worktreePath = pathService.join( + yield* makeTmpDir("git-worktrees-"), + "include-failure-worktree", + ); + const driver = yield* GitVcsDriver.GitVcsDriver; + const created = yield* driver.createWorktree({ + cwd, + path: worktreePath, + refName: initialBranch, + newRefName: "feature/worktree-include-failure", + }); + + assert.equal(created.worktree.path, worktreePath); + assert.equal( + yield* git(worktreePath, ["branch", "--show-current"]), + "feature/worktree-include-failure", + ); + assert.equal(yield* fileSystem.exists(pathService.join(worktreePath, ".env")), false); + }), + ); }); describe("remote operations", () => { diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 800ec6d4e722..a244205f3afc 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -43,6 +43,13 @@ const DEFAULT_TIMEOUT_MS = 30_000; // take well beyond the default 30s (e.g. a 375k-file repo takes ~40s on an idle // machine). Give it generous headroom while still bounding a genuinely hung git. const WORKTREE_ADD_TIMEOUT_MS = 300_000; +// `.worktreeinclude` at the repository root lists gitignore-style patterns of +// untracked files (typically gitignored ones like `.env`) to copy from the +// source checkout into each newly created worktree. +const WORKTREE_INCLUDE_FILE_NAME = ".worktreeinclude"; +// Listing the candidates walks the full untracked tree, ignored directories +// included, which can take a while on large repositories. +const WORKTREE_INCLUDE_LIST_TIMEOUT_MS = 60_000; const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000; const OUTPUT_TRUNCATED_MARKER = "\n\n[truncated]"; const PREPARED_COMMIT_PATCH_MAX_OUTPUT_BYTES = 49_000; @@ -2760,6 +2767,42 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }, ); + const copyWorktreeIncludedFiles = Effect.fn("copyWorktreeIncludedFiles")(function* ( + cwd: string, + worktreePath: string, + ) { + const repositoryPaths = yield* resolveRepositoryPaths(cwd); + const sourceRoot = repositoryPaths?.worktreeRoot ?? cwd; + const includeFilePath = path.join(sourceRoot, WORKTREE_INCLUDE_FILE_NAME); + if (!(yield* fileSystem.exists(includeFilePath))) { + return; + } + + // Git applies the gitignore-style patterns itself: list the untracked + // files in the source checkout that match the `.worktreeinclude` entries. + const listed = yield* executeGit( + "GitVcsDriver.createWorktree.listIncludedFiles", + sourceRoot, + ["ls-files", "--others", "--ignored", `--exclude-from=${includeFilePath}`, "-z"], + { + fallbackErrorDetail: "git ls-files for .worktreeinclude failed", + timeoutMs: WORKTREE_INCLUDE_LIST_TIMEOUT_MS, + }, + ); + if (listed.stdoutTruncated) { + yield* Effect.logWarning( + ".worktreeinclude matched more files than fit in the git output buffer; copying a partial set", + { sourceRoot, worktreePath }, + ); + } + + for (const relativePath of splitNullSeparatedGitStdoutPaths(listed)) { + const targetFilePath = path.join(worktreePath, relativePath); + yield* fileSystem.makeDirectory(path.dirname(targetFilePath), { recursive: true }); + yield* fileSystem.copyFile(path.join(sourceRoot, relativePath), targetFilePath); + } + }); + const createWorktree: GitVcsDriver.GitVcsDriver["Service"]["createWorktree"] = Effect.fn( "createWorktree", )(function* (input) { @@ -2790,6 +2833,18 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ]); } + // Best effort: a failed copy leaves the worktree usable, so log instead of + // rolling back the creation. + yield* copyWorktreeIncludedFiles(input.cwd, worktreePath).pipe( + Effect.catchCause((cause) => + Effect.logWarning("failed to copy .worktreeinclude files into the new worktree", { + cwd: input.cwd, + worktreePath, + cause, + }), + ), + ); + return { worktree: { path: worktreePath, diff --git a/docs/README.md b/docs/README.md index 622d81064387..504cb207f81a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -8,6 +8,7 @@ - [Organizing threads](./user/thread-sidebar.md) - [Review usage](./user/usage.md) - [Customize a project icon](./user/project-settings.md) +- [Copy local files into new worktrees](./user/worktrees.md) - [Mobile appearance](./user/mobile-appearance.md) - [Remote access](./user/remote-access.md) - [Keeping app and server in sync](./user/updating.md) diff --git a/docs/internals/glossary.md b/docs/internals/glossary.md index da16f74d339f..bca4250f1b33 100644 --- a/docs/internals/glossary.md +++ b/docs/internals/glossary.md @@ -26,7 +26,7 @@ The root filesystem path for a project. In [the orchestration model][1], it is t #### Worktree -A Git worktree used as an isolated workspace for a thread. If a thread has a `worktreePath` in [the contracts][1], it runs there instead of in the main working tree. Git operations live behind the VCS driver contract in `apps/server/src/vcs/VcsDriver.ts`, implemented by [GitVcsDriverCore.ts][3]. +A Git worktree used as an isolated workspace for a thread. If a thread has a `worktreePath` in [the contracts][1], it runs there instead of in the main working tree. Git operations live behind the VCS driver contract in `apps/server/src/vcs/VcsDriver.ts`, implemented by [GitVcsDriverCore.ts][3]. On creation, the driver copies untracked files matching a repo-root `.worktreeinclude` (gitignore-style patterns) from the source checkout into the new worktree. ### Thread timeline diff --git a/docs/user/worktrees.md b/docs/user/worktrees.md new file mode 100644 index 000000000000..e3c734c244ac --- /dev/null +++ b/docs/user/worktrees.md @@ -0,0 +1,24 @@ +# Copy local files into new worktrees + +When T3 Code starts a thread in a new worktree, Git checks out tracked files only. Untracked +local files — `.env` files, credentials, machine-specific config — stay behind in the original +checkout. + +To copy those files into each new worktree automatically, add a `.worktreeinclude` file to the +repository root. List one pattern per line, using the same syntax as `.gitignore`: + +``` +# copied into every new worktree +.env +.env.local +secrets/ +``` + +When a worktree is created, every untracked file in the original checkout that matches a pattern +is copied into the same location in the worktree. Patterns follow `.gitignore` rules: a bare name +like `.env` matches at any depth, a leading `/` anchors it to the repository root, and a trailing +`/` matches a whole directory. + +The copy finishes before the project setup script runs, so a setup script can rely on the files +being there. A file that fails to copy never blocks the worktree: T3 Code logs a warning and the +thread starts normally. From 40bfb08b47395c4ce3496a9417b52bda66c006b2 Mon Sep 17 00:00:00 2001 From: Sriram-52 Date: Sat, 22 Aug 2026 22:18:19 -0400 Subject: [PATCH 2/7] fix(server): make .worktreeinclude copies collision-safe and truncation-tolerant Co-Authored-By: Claude Fable 5 --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 35 ++++++++++++++++++++ apps/server/src/vcs/GitVcsDriverCore.ts | 9 +++++ 2 files changed, 44 insertions(+) diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 166e8d4412fe..0c6cbc9bbc85 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -1455,6 +1455,41 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }), ); + it.effect("keeps checked-out files over .worktreeinclude copies on path collisions", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + + yield* git(cwd, ["checkout", "-b", "tracked-config"]); + yield* writeTextFile(cwd, "config.json", "COMMITTED\n"); + yield* git(cwd, ["add", "config.json"]); + yield* git(cwd, ["commit", "-m", "track config"]); + yield* git(cwd, ["checkout", initialBranch]); + + yield* writeTextFile(cwd, ".worktreeinclude", "config.json\n"); + yield* writeTextFile(cwd, "config.json", "LOCAL\n"); + + const worktreePath = pathService.join( + yield* makeTmpDir("git-worktrees-"), + "include-collision-worktree", + ); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* driver.createWorktree({ + cwd, + path: worktreePath, + refName: "tracked-config", + }); + + assert.equal( + yield* fileSystem.readFileString(pathService.join(worktreePath, "config.json")), + "COMMITTED\n", + ); + assert.equal(yield* git(worktreePath, ["status", "--porcelain"]), ""); + }), + ); + it.effect("still creates the worktree when a .worktreeinclude copy fails", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index a244205f3afc..cceec716dace 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -2787,6 +2787,10 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* { fallbackErrorDetail: "git ls-files for .worktreeinclude failed", timeoutMs: WORKTREE_INCLUDE_LIST_TIMEOUT_MS, + // Truncate oversized listings instead of failing, so a huge match set + // still copies a partial set. splitNullSeparatedGitStdoutPaths drops + // the possibly-partial last entry. + appendTruncationMarker: true, }, ); if (listed.stdoutTruncated) { @@ -2798,6 +2802,11 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* for (const relativePath of splitNullSeparatedGitStdoutPaths(listed)) { const targetFilePath = path.join(worktreePath, relativePath); + // A path untracked in the source checkout can still be tracked in the + // checked-out branch; the checked-out version wins over the local copy. + if (yield* fileSystem.exists(targetFilePath)) { + continue; + } yield* fileSystem.makeDirectory(path.dirname(targetFilePath), { recursive: true }); yield* fileSystem.copyFile(path.join(sourceRoot, relativePath), targetFilePath); } From d34559570413ee1bc7a8ee10e5646dae37d7bbc6 Mon Sep 17 00:00:00 2001 From: Sriram-52 Date: Sat, 22 Aug 2026 22:25:36 -0400 Subject: [PATCH 3/7] fix(server): keep copying .worktreeinclude entries after one fails Co-Authored-By: Claude Fable 5 --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 7 +++++- apps/server/src/vcs/GitVcsDriverCore.ts | 25 ++++++++++++++------ 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 0c6cbc9bbc85..78b373b39662 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -1497,11 +1497,12 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { const fileSystem = yield* FileSystem.FileSystem; const pathService = yield* Path.Path; - yield* writeTextFile(cwd, ".worktreeinclude", ".env\n"); + yield* writeTextFile(cwd, ".worktreeinclude", ".env\napp.local\n"); yield* fileSystem.symlink( pathService.join(cwd, "missing-target"), pathService.join(cwd, ".env"), ); + yield* writeTextFile(cwd, "app.local", "AFTER_FAILURE=1\n"); const worktreePath = pathService.join( yield* makeTmpDir("git-worktrees-"), @@ -1521,6 +1522,10 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { "feature/worktree-include-failure", ); assert.equal(yield* fileSystem.exists(pathService.join(worktreePath, ".env")), false); + assert.equal( + yield* fileSystem.readFileString(pathService.join(worktreePath, "app.local")), + "AFTER_FAILURE=1\n", + ); }), ); }); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index cceec716dace..68f2be9de24a 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -2802,13 +2802,24 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* for (const relativePath of splitNullSeparatedGitStdoutPaths(listed)) { const targetFilePath = path.join(worktreePath, relativePath); - // A path untracked in the source checkout can still be tracked in the - // checked-out branch; the checked-out version wins over the local copy. - if (yield* fileSystem.exists(targetFilePath)) { - continue; - } - yield* fileSystem.makeDirectory(path.dirname(targetFilePath), { recursive: true }); - yield* fileSystem.copyFile(path.join(sourceRoot, relativePath), targetFilePath); + yield* Effect.gen(function* () { + // A path untracked in the source checkout can still be tracked in the + // checked-out branch; the checked-out version wins over the local copy. + if (yield* fileSystem.exists(targetFilePath)) { + return; + } + yield* fileSystem.makeDirectory(path.dirname(targetFilePath), { recursive: true }); + yield* fileSystem.copyFile(path.join(sourceRoot, relativePath), targetFilePath); + }).pipe( + // Best effort per entry: one broken file must not block the rest. + Effect.catchCause((cause) => + Effect.logWarning("failed to copy a .worktreeinclude file into the new worktree", { + relativePath, + worktreePath, + cause, + }), + ), + ); } }); From 5a11ddf6a36133c97ed209d57ce4995955d0a68a Mon Sep 17 00:00:00 2001 From: Sriram-52 Date: Sat, 22 Aug 2026 22:37:40 -0400 Subject: [PATCH 4/7] fix(server): propagate interrupts through .worktreeinclude copy handlers Co-Authored-By: Claude Fable 5 --- apps/server/src/vcs/GitVcsDriverCore.ts | 29 +++++++++++++++---------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 68f2be9de24a..a352c69118ab 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -1,5 +1,6 @@ import * as Arr from "effect/Array"; import * as Cache from "effect/Cache"; +import * as Cause from "effect/Cause"; import * as Data from "effect/Data"; import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; @@ -2812,12 +2813,15 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* yield* fileSystem.copyFile(path.join(sourceRoot, relativePath), targetFilePath); }).pipe( // Best effort per entry: one broken file must not block the rest. + // Interruptions still propagate so cancellation is not masked. Effect.catchCause((cause) => - Effect.logWarning("failed to copy a .worktreeinclude file into the new worktree", { - relativePath, - worktreePath, - cause, - }), + Cause.hasInterrupts(cause) + ? Effect.interrupt + : Effect.logWarning("failed to copy a .worktreeinclude file into the new worktree", { + relativePath, + worktreePath, + cause, + }), ), ); } @@ -2854,14 +2858,17 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* } // Best effort: a failed copy leaves the worktree usable, so log instead of - // rolling back the creation. + // rolling back the creation. Interruptions still propagate so cancellation + // is not masked. yield* copyWorktreeIncludedFiles(input.cwd, worktreePath).pipe( Effect.catchCause((cause) => - Effect.logWarning("failed to copy .worktreeinclude files into the new worktree", { - cwd: input.cwd, - worktreePath, - cause, - }), + Cause.hasInterrupts(cause) + ? Effect.interrupt + : Effect.logWarning("failed to copy .worktreeinclude files into the new worktree", { + cwd: input.cwd, + worktreePath, + cause, + }), ), ); From 71252d58854399928205af2169f38e9a55abbead Mon Sep 17 00:00:00 2001 From: Sriram-52 Date: Sat, 22 Aug 2026 22:42:44 -0400 Subject: [PATCH 5/7] fix(server): keep .worktreeinclude copies inside the worktree Co-Authored-By: Claude Fable 5 --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 33 ++++++++++++++++++++ apps/server/src/vcs/GitVcsDriverCore.ts | 33 ++++++++++++++++++-- 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 78b373b39662..a46245ffc7f7 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -1490,6 +1490,39 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }), ); + it.effect("never writes .worktreeinclude copies through tracked symlinks", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + const outsideDir = yield* makeTmpDir("git-worktrees-outside-"); + + yield* git(cwd, ["checkout", "-b", "symlinked-shared"]); + yield* fileSystem.symlink(outsideDir, pathService.join(cwd, "shared")); + yield* git(cwd, ["add", "shared"]); + yield* git(cwd, ["commit", "-m", "track shared as symlink"]); + yield* git(cwd, ["checkout", initialBranch]); + yield* fileSystem.remove(pathService.join(cwd, "shared"), { force: true }); + + yield* writeTextFile(cwd, ".worktreeinclude", ".env\n"); + yield* writeTextFile(cwd, "shared/.env", "LEAK=1\n"); + + const worktreePath = pathService.join( + yield* makeTmpDir("git-worktrees-"), + "include-symlink-worktree", + ); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* driver.createWorktree({ + cwd, + path: worktreePath, + refName: "symlinked-shared", + }); + + assert.equal(yield* fileSystem.exists(pathService.join(outsideDir, ".env")), false); + }), + ); + it.effect("still creates the worktree when a .worktreeinclude copy fails", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index a352c69118ab..2d813930faae 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -2801,7 +2801,12 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ); } - for (const relativePath of splitNullSeparatedGitStdoutPaths(listed)) { + const relativePaths = splitNullSeparatedGitStdoutPaths(listed); + if (relativePaths.length === 0) { + return; + } + const realWorktreeRoot = yield* fileSystem.realPath(worktreePath); + for (const relativePath of relativePaths) { const targetFilePath = path.join(worktreePath, relativePath); yield* Effect.gen(function* () { // A path untracked in the source checkout can still be tracked in the @@ -2809,7 +2814,31 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* if (yield* fileSystem.exists(targetFilePath)) { return; } - yield* fileSystem.makeDirectory(path.dirname(targetFilePath), { recursive: true }); + // Tracked symlinks in the checked-out tree could otherwise redirect + // the write outside the worktree (dangling link at the target path, + // or a symlinked parent directory). + const targetIsSymlink = yield* fileSystem.readLink(targetFilePath).pipe( + Effect.as(true), + Effect.orElseSucceed(() => false), + ); + if (targetIsSymlink) { + return yield* Effect.logWarning( + ".worktreeinclude entry resolves through a symlink; skipped", + { relativePath, worktreePath }, + ); + } + const targetDir = path.dirname(targetFilePath); + yield* fileSystem.makeDirectory(targetDir, { recursive: true }); + const realTargetDir = yield* fileSystem.realPath(targetDir); + if ( + realTargetDir !== realWorktreeRoot && + !realTargetDir.startsWith(realWorktreeRoot + path.sep) + ) { + return yield* Effect.logWarning( + ".worktreeinclude entry resolves through a symlink; skipped", + { relativePath, worktreePath }, + ); + } yield* fileSystem.copyFile(path.join(sourceRoot, relativePath), targetFilePath); }).pipe( // Best effort per entry: one broken file must not block the rest. From a78d28e6d5325fad90475c7ebc01e4031e8c9cb8 Mon Sep 17 00:00:00 2001 From: Sriram-52 Date: Sat, 22 Aug 2026 22:50:12 -0400 Subject: [PATCH 6/7] fix(server): check .worktreeinclude containment before creating directories Co-Authored-By: Claude Fable 5 --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 2 ++ apps/server/src/vcs/GitVcsDriverCore.ts | 15 +++++++++++---- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index a46245ffc7f7..e843912bc617 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -1507,6 +1507,7 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { yield* writeTextFile(cwd, ".worktreeinclude", ".env\n"); yield* writeTextFile(cwd, "shared/.env", "LEAK=1\n"); + yield* writeTextFile(cwd, "shared/deep/nested/.env", "NESTED_LEAK=1\n"); const worktreePath = pathService.join( yield* makeTmpDir("git-worktrees-"), @@ -1520,6 +1521,7 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }); assert.equal(yield* fileSystem.exists(pathService.join(outsideDir, ".env")), false); + assert.equal(yield* fileSystem.exists(pathService.join(outsideDir, "deep")), false); }), ); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 2d813930faae..5098d3946119 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -2828,17 +2828,24 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ); } const targetDir = path.dirname(targetFilePath); - yield* fileSystem.makeDirectory(targetDir, { recursive: true }); - const realTargetDir = yield* fileSystem.realPath(targetDir); + // Verify containment on the deepest existing ancestor before creating + // anything, so a recursive mkdir cannot follow a tracked symlink out + // of the worktree either. + let existingAncestor = targetDir; + while (!(yield* fileSystem.exists(existingAncestor))) { + existingAncestor = path.dirname(existingAncestor); + } + const realAncestor = yield* fileSystem.realPath(existingAncestor); if ( - realTargetDir !== realWorktreeRoot && - !realTargetDir.startsWith(realWorktreeRoot + path.sep) + realAncestor !== realWorktreeRoot && + !realAncestor.startsWith(realWorktreeRoot + path.sep) ) { return yield* Effect.logWarning( ".worktreeinclude entry resolves through a symlink; skipped", { relativePath, worktreePath }, ); } + yield* fileSystem.makeDirectory(targetDir, { recursive: true }); yield* fileSystem.copyFile(path.join(sourceRoot, relativePath), targetFilePath); }).pipe( // Best effort per entry: one broken file must not block the rest. From ed8a86bd02343e72392e17065d4c8572a6b8315e Mon Sep 17 00:00:00 2001 From: Sriram-52 Date: Sat, 22 Aug 2026 22:54:35 -0400 Subject: [PATCH 7/7] fix(server): resolve relative worktree paths against the repo cwd Co-Authored-By: Claude Fable 5 --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 27 ++++++++++++++++++++ apps/server/src/vcs/GitVcsDriverCore.ts | 7 ++++- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index e843912bc617..391ae19d2a06 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -1490,6 +1490,33 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }), ); + it.effect("resolves relative worktree paths against cwd when copying includes", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + + yield* writeTextFile(cwd, ".worktreeinclude", ".env\n"); + yield* writeTextFile(cwd, ".env", "RELATIVE=1\n"); + + const driver = yield* GitVcsDriver.GitVcsDriver; + const created = yield* driver.createWorktree({ + cwd, + path: "relative-worktree", + refName: initialBranch, + newRefName: "feature/relative-worktree", + }); + + const expectedPath = pathService.resolve(cwd, "relative-worktree"); + assert.equal(created.worktree.path, expectedPath); + assert.equal( + yield* fileSystem.readFileString(pathService.join(expectedPath, ".env")), + "RELATIVE=1\n", + ); + }), + ); + it.effect("never writes .worktreeinclude copies through tracked symlinks", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 5098d3946119..38d422080aaa 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -2869,7 +2869,12 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const targetBranch = input.newRefName ?? input.refName; const sanitizedBranch = targetBranch.replace(/\//g, "-"); const repoName = path.basename(input.cwd); - const worktreePath = input.path ?? path.join(worktreesDir, repoName, sanitizedBranch); + // git resolves a relative worktree path against its cwd; mirror that so + // the copy step and the returned path always point at the real location. + const worktreePath = path.resolve( + input.cwd, + input.path ?? path.join(worktreesDir, repoName, sanitizedBranch), + ); const args = input.newRefName ? ["worktree", "add", "-b", input.newRefName, worktreePath, input.refName] : ["worktree", "add", worktreePath, input.refName];