Repository navigation
Description
Before submitting
- I searched existing issues and did not find a duplicate.
- I included enough detail to reproduce or investigate the problem.
Checked and ruled out: #9245 (symlinked worktree paths compared as strings, different code), #12204 (git dir followed when the back link is missing, different trigger), #5489 (intermittent exit 128 during checkpoint restore, caused by an existing index.lock), and #12602 (open PR that calls realpath in the new metadata path but does not touch the checkpoint code below).
Area
apps/server
Steps to reproduce
In the app
- Have a git repository with a subdirectory, and a symlink somewhere else that points at that subdirectory. (My case: I keep several tool-managed folders as symlinks into subdirectories of one repository, and opened one of those symlinks as a project.)
- Add the symlink path as a project in T3 Code.
- Start a thread and let one turn finish.
- The turn ends with
VCS process failed in GitVcsDriver.checkpoints.captureCheckpoint: git (<project path>) exited with 128. It repeats after every turn, and the thread never gets a checkpoint.
The mechanism, without the app
This uses Node's path.resolve, the same call resolveGitCommonDir makes, so it isolates the bug in a few lines:
R=$(mktemp -d)
git init -q "$R/repo"
git -C "$R/repo" -c user.name=x -c user.email=x@example.invalid commit -q --allow-empty -m init
mkdir -p "$R/repo/sub/dir"
ln -s "$R/repo/sub/dir" "$R/link"
cd "$R/link"
rel=$(git rev-parse --git-common-dir) # ../../.git, relative to git's PHYSICAL cwd
res=$(node -e 'console.log(require("path").resolve(process.argv[1], process.argv[2]))' "$R/link" "$rel")
echo "$res" # the symlink's grandparent + /.git: does not exist
GIT_INDEX_FILE="$res/t3-checkpoint-index-test" git read-tree HEAD; echo "exit=$?" # exit=128
git rev-parse --path-format=absolute --git-common-dir # the correct directoryObserved, one row per way of opening the same repository (paths shortened):
| Project path | --git-common-dir |
path.resolve(projectPath, it) |
read-tree into that dir |
|---|---|---|---|
| symlink to a repo subdirectory | ../../.git |
/tmp/.git (does not exist) |
exit 128 |
| symlink to the repo root | .git |
<link>/.git (resolves through the link) |
exit 0 |
| the real subdirectory path | ../../.git |
<repo>/.git |
exit 0 |
It takes both: a symlink in the project path, and a project folder that is a subdirectory of the repository, so the relative answer contains ...
Expected behavior
A project opened through a symlink behaves like the directory it points to: checkpoints are captured each turn and "Go back" is available.
Actual behavior
Every turn fails checkpoint capture with exit 128, and the thread has no checkpoints. The agent itself keeps working, because nothing else in the turn depends on the checkpoint.
Cause, in apps/server/src/vcs/GitVcsDriver.ts at main @ 6975efd3dd52, lines 756 to 765:
const resolveGitCommonDir = (cwd: string) =>
Effect.gen(function* () {
const result = yield* execute({ /* ... */ cwd, args: ["rev-parse", "--git-common-dir"] });
const gitCommonDir = result.stdout.trim();
return path.isAbsolute(gitCommonDir) ? gitCommonDir : path.resolve(cwd, gitCommonDir);
});Git computes the relative path from its physical working directory, but path.resolve applies the .. segments lexically to the project path string. When that string goes through a symlink to a subdirectory, the .. climbs out of the symlink's parent instead of the repository. captureCheckpoint then builds tempIndexPath inside that nonexistent directory, and the first git command given it as GIT_INDEX_FILE fails:
fatal: Unable to create '<wrong dir>/.git/t3-checkpoint-index-<uuid>.lock': No such file or directory
From reading the source (not observed separately): the index-reuse branch fails first, at fileSystem.copyFile(..., tempIndexPath), and is swallowed by Effect.orElseSucceed(() => false). The fallback read-tree is what surfaces as exit 128. Git's actual message above is not shown anywhere, which is the stderr-discarding problem described in #12224 and #4380.
Suggested fix (one line): ask git for the absolute path, the same way this function already does for --git-path index at line 831:
args: ["rev-parse", "--path-format=absolute", "--git-common-dir"],In the reproduction above, that form returns the correct directory from the symlinked path. realpath(cwd) before path.resolve would also work. --path-format needs git 2.31 or later, which line 831 already assumes.
Impact
Major degradation or frequent failure
For an affected project it fails on every turn, and checkpoints, diffs and "Go back" are all unavailable for the thread. It only affects projects opened through a symlink that points into a repository subdirectory.
Version or commit
T3 Code (Alpha) 0.0.42 desktop; the cause is present on main @ 6975efd3dd52 (2026-09-23)
Environment
macOS 27.0, git 2.55.0, Node v26.8.2, Claude provider
Logs or stack traces
# server.trace.ndjson, one failure per turn (home path redacted)
VcsProcessExitError: VCS process failed in GitVcsDriver.checkpoints.captureCheckpoint: git (<home>/<symlink to a repo subdirectory>) exited with 128 - Process exited with a non-zero status.
at VcsProcessExitError.fromProcessExit (app.asar/apps/server/dist/bin.mjs:31121:10)
at VcsProcess.runUnbounded (app.asar/apps/server/dist/bin.mjs:93005:47)
at VcsProcess.run (app.asar/apps/server/dist/bin.mjs:93073:72)
at GitVcsDriver.checkpoints.captureCheckpoint (app.asar/apps/server/dist/bin.mjs:93682:93)
at captureCheckpoint (app.asar/apps/server/dist/bin.mjs:93681:28)Screenshots, recordings, or supporting files
No response
Workaround
Add the project by its real path (realpath <symlink>), or open the repository root instead of the symlinked subdirectory.
Activity
juliusmarminge commentedon Sep 23, 2026
Triage
Confirmed on current
main(6975efd3d). This is a real server checkpoint bug. Desktop is only the surface that stored the symlink as the project path. Not already fixed. The samepath.resolve(cwd, relativeGitCommonDir)is in thev0.0.42tag (719a76ca1) and onmainafter that release. No newer nightly changes it.What you reported. The project path is a symlink to a subdirectory of a git repo. Every turn ends with
VCS process failed in GitVcsDriver.checkpoints.captureCheckpoint: git (<symlink>) exited with 128. The thread never gets a checkpoint, so diffs and Go back stay unavailable. The agent itself keeps working.That matches the code and a local reproduction of the mechanism (Linux, git 2.43.0, Node
path.resolve). Git's relative--git-common-diris computed from the physical directory.path.resolveapplies..to the symlink string and climbs out of the symlink's parent.What the code does
1. Checkpoint capture builds the temp index path from a lexical join.
resolveGitCommonDirasks for--git-common-dirand, when the answer is relative, joins it with the project cwd:t3code/apps/server/src/vcs/GitVcsDriver.ts
Lines 756 to 765 in 6975efd
captureCheckpointputst3-checkpoint-index-<uuid>in that directory and setsGIT_INDEX_FILE:t3code/apps/server/src/vcs/GitVcsDriver.ts
Lines 787 to 798 in 6975efd
The cwd is the stored project path, the thread worktree path, or the provider session cwd.
resolveCheckpointCwddoes not realpath it:t3code/apps/server/src/orchestration/Layers/CheckpointReactor.ts
Lines 210 to 239 in 6975efd
This helper is only used by
captureCheckpoint. Restore and diff never call it. Go back is missing because the ref was never written, not because restore has the same bug.2. The index-reuse path fails first and is swallowed.
read-treeis what surfaces as exit 128.The fast path copies the real index to
tempIndexPath, then runsread-tree. The copy goes to a directory that does not exist (ENOENT). That whole branch ends inEffect.orElseSucceed(() => false):t3code/apps/server/src/vcs/GitVcsDriver.ts
Lines 826 to 901 in 6975efd
The fallback then runs
read-treewith the sameGIT_INDEX_FILE:t3code/apps/server/src/vcs/GitVcsDriver.ts
Lines 902 to 930 in 6975efd
Git's own message is
fatal: Unable to create '<wrong dir>/.git/t3-checkpoint-index-<uuid>.lock': No such file or directory.VcsProcessExitErrorreplaces stderr withProcess exited with a non-zero status.That opacity is #4380 and #12224, not this bug.3. It only breaks when both conditions hold.
Git prints a relative common dir that contains
..(project folder is below the repo root), and the project cwd is a symlink, so those..segments leave the symlink's parent instead of the repo.--git-common-dirpath.resolve(cwd, it)read-tree../../.git/.git(missing).git<link>/.git(kernel follows the link)../../.git<repo>/.gitFrom the symlink,
git rev-parse --git-dirwas already absolute, while--git-common-dirwas../../.git. Do not switch this call to--git-dir. For a linked worktree those are different directories, and the temp index is supposed to live in the common dir. Linked worktrees already print an absolute--git-common-dir, so T3-created worktrees do not hit this.4. If the lexical path exists, capture does not fail. It writes the temp index into that other directory.
With the symlink placed so
path.resolvelanded on a different repo's.git,read-treeexited 0 and createdt3-checkpoint-index-*inside the decoy.git. Cleanup deletes that path afterward, so a finished capture does not leave the file, and it does not rewrite the decoy repo's commits.mkdiron the resolved path would turn the reported failure into this wrong-directory write. The path has to be the real common dir.5. The metadata cache has the same join, and
realpathdoes not repair it.resolveRepositoryPathsUncacheddoes the samepath.resolve(cwd, commonDirOutput), thenrealPathon the result, and on failure keeps the unresolved path:t3code/apps/server/src/vcs/GitVcsDriverCore.ts
Lines 1175 to 1181 in 6975efd
realpathof a directory that does not exist fails. For this layout the cachedgitCommonDirstays the wrong path, and later--git-dirfetches and ref reads use it. #12602 realpaths that metadata path but does not change this order, and it does not touch checkpoint capture. The checkpoint one-line fix does not cover this call.Reproduction
Same shape as the report, on Linux with git 2.43.0:
fs.copyFileof the real index onto the bad temp path failed withENOENTbeforeread-treeran. Symlink-to-root and the real subdirectory both exited 0. A linked worktree printed an absolute common dir. The decoy case wrote the temp index into the other repo's.gitand left that repo's history unchanged.Not a duplicate
/tmpvs/private/tmpstring equality for worktree identity. Different comparison, not the checkpoint index path.git-dirwith nocore.worktreeback-link. Different trigger.index.lock.realpathon the metadata path after the lexical join. Does not fix capture, and does not fix this layout inresolveRepositoryPathseither.VcsProcessExitError. That is why the trace hides Git'sUnable to create … lockline.No open issue covers
--git-common-dirjoined lexically with a symlink cwd.Fix direction
GitVcsDriver.tsresolveGitCommonDir, ask for the absolute path, the same way line 831 already does for the index:--path-formatneeds git 2.31 or later. Checkpoint capture already assumes that for--git-path index. In the repro this returned<repo>/.gitandread-treeexited 0.realpath(cwd)beforepath.resolvealso matched. Prefer--path-format=absolute: the relative answer is from Git's physical cwd, not from the string the project was added with.Use that same absolute common dir in
resolveRepositoryPathsUncachedbeforepath.resolve. Keep the existingrealPathafterward so Windows 8.3 short names still collapse. Do notmkdirthe lexically resolved directory.Test with a real git repo: symlink to a subdirectory,
captureCheckpointmust write the temp index under the real.gitand publish the ref. Second case: place the symlink so today'spath.resolvelands on a different repo's.git, and assert not3-checkpoint-index-*is created there.Workaround
Add the project by its real path (
realpathof the symlink), or open the repository root. A symlink to the repo root is fine, because the relative answer is.gitand has no... Safe to keep using until a fix ships.Accepting as a server checkpoint bug.