diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 66dc7b96a73e..391ae19d2a06 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -1401,6 +1401,195 @@ 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("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("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(); + 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"); + yield* writeTextFile(cwd, "shared/deep/nested/.env", "NESTED_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); + assert.equal(yield* fileSystem.exists(pathService.join(outsideDir, "deep")), 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\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-"), + "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); + assert.equal( + yield* fileSystem.readFileString(pathService.join(worktreePath, "app.local")), + "AFTER_FAILURE=1\n", + ); + }), + ); }); describe("remote operations", () => { diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 800ec6d4e722..38d422080aaa 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"; @@ -43,6 +44,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,13 +2768,113 @@ 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, + // 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) { + yield* Effect.logWarning( + ".worktreeinclude matched more files than fit in the git output buffer; copying a partial set", + { sourceRoot, worktreePath }, + ); + } + + 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 + // checked-out branch; the checked-out version wins over the local copy. + if (yield* fileSystem.exists(targetFilePath)) { + return; + } + // 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); + // 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 ( + 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. + // Interruptions still propagate so cancellation is not masked. + Effect.catchCause((cause) => + Cause.hasInterrupts(cause) + ? Effect.interrupt + : Effect.logWarning("failed to copy a .worktreeinclude file into the new worktree", { + relativePath, + worktreePath, + cause, + }), + ), + ); + } + }); + const createWorktree: GitVcsDriver.GitVcsDriver["Service"]["createWorktree"] = Effect.fn( "createWorktree", )(function* (input) { 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]; @@ -2790,6 +2898,21 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ]); } + // Best effort: a failed copy leaves the worktree usable, so log instead of + // rolling back the creation. Interruptions still propagate so cancellation + // is not masked. + yield* copyWorktreeIncludedFiles(input.cwd, worktreePath).pipe( + Effect.catchCause((cause) => + Cause.hasInterrupts(cause) + ? Effect.interrupt + : 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.