Skip to content
189 changes: 189 additions & 0 deletions apps/server/src/vcs/GitVcsDriverCore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
125 changes: 124 additions & 1 deletion apps/server/src/vcs/GitVcsDriverCore.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
// 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)) {
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
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 },
);
}
Comment thread
cursor[bot] marked this conversation as resolved.
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];
Expand All @@ -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(
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Effect.catchCause((cause) =>
Cause.hasInterrupts(cause)
? Effect.interrupt
: Effect.logWarning("failed to copy .worktreeinclude files into the new worktree", {
cwd: input.cwd,
worktreePath,
cause,
}),
),
);
Comment thread
cursor[bot] marked this conversation as resolved.

return {
worktree: {
path: worktreePath,
Expand Down
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion docs/internals/glossary.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
24 changes: 24 additions & 0 deletions docs/user/worktrees.md
Original file line number Diff line number Diff line change
@@ -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.
Loading