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.
Area
apps/server
Steps to reproduce
Use a checkout of the commit under Version or commit with its lockfile dependencies installed. Save the script below outside the checkout as repro.sh and run env -u NODE_ENV sh /path/to/repro.sh from the checkout root on macOS or Linux (it needs python3 to generate the test). It creates a private HOME, TMPDIR and T3CODE_HOME under a mktemp directory (set VERIFY_TMP_PARENT to choose its parent) and disables system and global Git config. It writes one temporary test, apps/server/src/git/tmp-verify-symlink-commit.test.ts (refusing to overwrite an existing file), built from the existing GitManager.test.ts service setup, runs only that file, and removes the test and fixtures on exit.
The test calls the exported production GitManager.runStackedAction with the payload shape the client sends, using the real GitVcsDriver and real Git processes against temporary repositories. Settings, stores and the unused provider and text-generation dependencies come from the existing test layer; nothing on the Git path is stubbed. No server, browser or provider runs. For each variant it builds <fixture>/repo with a committed README.md, a symlink <fixture>/nest/link → <fixture>/repo/sub/dir, and a symlink <fixture>/nest/rootlink → <fixture>/repo, then modifies README.md and adds an untracked sub/dir/nested.txt.
link-selected: through<fixture>/nest/link, run acommitaction with commit messageSelected changeandfilePaths: ["sub/dir/nested.txt"]. It fails as described under Actual behavior. The test then runs a directgit read-tree HEADwith the same alternate object setup as a diagnostic, and immediately retries the same project with all files.link-all: through<fixture>/nest/link, commit all files.real-selected: through the physical<fixture>/repo/sub/dir, commit onlysub/dir/nested.txt.rootlink-selected: through<fixture>/nest/rootlink, commit onlysub/dir/nested.txt.direct-selected: through<fixture>/nest/link, callGitVcsDriver.commitdirectly with the same selection, which skips commit preparation.
The script exits 0 only when step 1 fails as described and every control succeeds with the asserted committed file set. The equivalent UI path, traced in source but not run in a browser: add a project through such a symlink, open Git actions → Commit, edit the file list to exclude README.md, keep nested.txt, enter a message and click Commit. The dialog sends filePaths when not all files are selected (caller), and the client sends git.runStackedAction with the project cwd and the selection (RPC producer), which reaches GitWorkflowService and then GitManager unchanged.
Reproduction script: repro.sh
#!/bin/sh
# Run from the root of a t3code checkout at the reported commit with its lockfile dependencies installed.
set -eu
checkout=$(pwd)
parent=${VERIFY_TMP_PARENT:-${TMPDIR:-/tmp}}
run=$(mktemp -d "$parent/symlink-commit-verify.XXXXXX")
export HOME="$run/fake-home" TMPDIR="$run/tmp" T3CODE_HOME="$run/t3-home"
mkdir -p "$HOME" "$TMPDIR" "$T3CODE_HOME"
export PATH="$checkout/node_modules/.bin:$PATH"
export GIT_CONFIG_NOSYSTEM=1 GIT_CONFIG_GLOBAL=/dev/null GIT_TERMINAL_PROMPT=0
unset NODE_ENV
file="$checkout/apps/server/src/git/tmp-verify-symlink-commit.test.ts"
if [ -e "$file" ]; then echo "Refusing to replace existing $file" >&2; exit 1; fi
trap 'rm -f "$file"; rm -rf "$run"' EXIT INT TERM
python3 -I - "$checkout" "$file" <<'PY'
import pathlib,sys
checkout=pathlib.Path(sys.argv[1])
source=(checkout/'apps/server/src/git/GitManager.test.ts').read_text()
prefix=source.split('it.layer(layerGitManagerTest)("GitManager", (it) => {',1)[0]
assert prefix != source
pathlib.Path(sys.argv[2]).write_text(prefix+'''
it.layer(layerGitManagerTest)("independent symlink commit verification", (it) => {
it.effect("runs the production stacked commit with symlink and physical controls", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const driver = yield* GitVcsDriver.GitVcsDriver;
const { manager } = yield* makeManager();
for (const variant of ["link-selected", "link-all", "real-selected", "rootlink-selected", "direct-selected"]) {
const fixture = yield* makeTempDir("selected-commit-");
const repo = NodePath.join(fixture, "repo");
const sub = NodePath.join(repo, "sub", "dir");
const link = NodePath.join(fixture, "nest", "link");
const rootlink = NodePath.join(fixture, "nest", "rootlink");
yield* fs.makeDirectory(sub, { recursive: true });
yield* fs.makeDirectory(NodePath.dirname(link), { recursive: true });
yield* driver.initRepo({ cwd: repo });
yield* runGit(repo, ["config", "user.name", "Fixture"]);
yield* runGit(repo, ["config", "user.email", "fixture@example.invalid"]);
yield* fs.writeFileString(NodePath.join(repo, "README.md"), "initial\\n");
yield* runGit(repo, ["add", "-A"]);
yield* runGit(repo, ["commit", "-m", "Initial"]);
yield* fs.symlink(sub, link);
yield* fs.symlink(repo, rootlink);
yield* fs.writeFileString(NodePath.join(sub, "nested.txt"), "selected\\n");
yield* fs.writeFileString(NodePath.join(repo, "README.md"), "unselected\\n");
const cwd = variant === "real-selected" ? sub : variant === "rootlink-selected" ? rootlink : link;
const before = (yield* runGit(repo, ["rev-parse", "HEAD"])).stdout.trim();
const input = {
actionId: variant, cwd, action: "commit" as const, commitMessage: "Selected change",
...(variant === "link-all" ? {} : { filePaths: ["sub/dir/nested.txt"] }),
};
console.log("INPUT", variant, JSON.stringify(input));
console.log("GIT_PATHS", variant, JSON.stringify((yield* runGit(cwd, ["rev-parse", "--git-path", "index", "--git-path", "objects"])).stdout.trim()));
const result = yield* (variant === "direct-selected"
? driver.commit(cwd, "Selected change", "", { stage: { filePaths: ["sub/dir/nested.txt"] } })
: manager.runStackedAction(input)).pipe(Effect.exit);
console.log("RESULT", variant, result._tag, result._tag === "Success" ? JSON.stringify(result.value) : result.cause.toString());
const after = (yield* runGit(repo, ["rev-parse", "HEAD"])).stdout.trim();
const changed = (yield* runGit(repo, ["diff-tree", "--no-commit-id", "--name-only", "-r", "HEAD"])).stdout.trim();
const status = (yield* runGit(repo, ["status", "--porcelain"])).stdout.trim();
console.log("STATE", variant, JSON.stringify({headChanged: before !== after, changed, status}));
if (variant === "link-selected") {
expect(result._tag).toBe("Failure");
if (result._tag === "Failure") expect(result.cause.toString()).toContain("GitVcsDriver.prepareCommitContext.readTree");
expect(after).toBe(before);
expect(status).toContain("README.md");
expect(status).toContain("sub/");
const objects = NodePath.resolve(cwd, "../../.git/objects");
yield* fs.makeDirectory(NodePath.join(fixture, "diagnostic-objects"));
const diagnostic = NodeChildProcess.spawnSync("git", ["read-tree", "HEAD"], {
cwd, encoding: "utf8", env: { ...process.env,
GIT_INDEX_FILE: NodePath.join(fixture, "diagnostic-index"),
GIT_OBJECT_DIRECTORY: NodePath.join(fixture, "diagnostic-objects"),
GIT_ALTERNATE_OBJECT_DIRECTORIES: objects,
},
});
console.log("RAW_GIT", diagnostic.status, diagnostic.stderr.trim());
expect(diagnostic.status).toBe(128);
const { filePaths: _selected, ...allInput } = input;
const all = yield* manager.runStackedAction({ ...allInput, actionId: "same-link-all" }).pipe(Effect.exit);
const allChanged = (yield* runGit(repo, ["diff-tree", "--no-commit-id", "--name-only", "-r", "HEAD"])).stdout.trim();
const allStatus = (yield* runGit(repo, ["status", "--porcelain"])).stdout.trim();
console.log("RESULT same-link-all", all._tag, all._tag === "Success" ? JSON.stringify(all.value) : all.cause.toString());
console.log("STATE same-link-all", JSON.stringify({ changed: allChanged, status: allStatus }));
expect(all._tag).toBe("Success");
expect(allChanged.split("\\n").sort()).toEqual(["README.md", "sub/dir/nested.txt"]);
expect(allStatus).toBe("");
} else {
expect(result._tag).toBe("Success");
expect(after).not.toBe(before);
if (variant === "link-all") {
expect(changed.split("\\n").sort()).toEqual(["README.md", "sub/dir/nested.txt"]);
expect(status).toBe("");
} else {
expect(changed).toBe("sub/dir/nested.txt");
expect(status).toContain("README.md");
}
}
}
}),
);
});
''')
PY
cd "$checkout"
vp test run apps/server/src/git/tmp-verify-symlink-commit.test.ts --silent=falseExpected behavior
Selecting nested.txt commits only that file and leaves the README.md modification uncommitted, whether the project is opened through its physical path or through a symlink, as the physical-directory and repository-root-symlink controls already do. The server already states the rule that Git's relative paths are relative to its physical working directory, which can differ from a symlink (checkpoint helper), and selected staging explicitly supports repository-relative paths when the cwd is a repository subdirectory (staging contract).
Actual behavior
Summary. When a T3 Code project is opened through a symlink whose target is a subdirectory of a Git repository (for example a link pointing at repo/sub/dir), committing only some of the changed files fails with GitCommandError in GitVcsDriver.prepareCommitContext.readTree; no commit is created and every change stays in the working tree. git rev-parse --git-path index --git-path objects prints paths relative to Git's physical working directory (../../.git/index, ../../.git/objects), and commit preparation resolves them against the logical symlink path, so the temporary index's alternate object directory points at a nonexistent .git/objects one level outside the repository. Committing all files through the same symlink, selected files through the physical directory, and selected files through a symlink to the repository root all succeed. This is a sibling of #13187, whose fix #18152 resolved the same physical-versus-logical path rule for checkpoint capture and ref discovery but did not cover selected-file commit preparation; it is a remaining gap, not a regression. The known real-world layout is the #13187 reporter's: several tool-managed folders kept as symlinks into subdirectories of one repository, with one alias opened as a project. Broader prevalence is unknown.
Through GitManager.runStackedAction, the selected-only commit through the subdirectory symlink returns GitCommandError: Git command failed in GitVcsDriver.prepareCommitContext.readTree (<fixture>/nest/link): Git command exited with a non-zero status. HEAD is unchanged, README.md is still modified and sub/ is still untracked. Retrying the same project with all files succeeds and commits both files. Selecting nested.txt through the physical directory or through the repository-root symlink succeeds and commits only that file. The direct GitVcsDriver.commit call with the same selection also succeeds through the subdirectory symlink because it skips preparation; that is an API route, not a separate UI button.
The manager always runs commit preparation, including when the user writes the commit message (production consumer). Preparation asks Git for --git-path index and --git-path objects and resolves the relative results against the logical cwd (path lookup). Here Git prints ../../.git/index and ../../.git/objects; resolving them from <fixture>/nest/link reaches <fixture>/.git instead of <fixture>/repo/.git. With the repository's real object directory missing from the alternates, the selected path's read-tree HEAD cannot find HEAD's tree (failing operation). A direct git read-tree HEAD with the same setup exits 128 with error: unable to normalize alternate object path: <fixture>/.git/objects and fatal: failed to unpack tree object HEAD. On the tested main builds, preparation still resolves both Git paths against the logical cwd and fails at the same operation with the same result.
From UI source, the failure is shown as an "Action failed" toast carrying the error message (error handling); the rendered toast was not observed. Writing a manual commit message does not avoid the failure, and Commit on new branch and generated-message commits go through the same preparation.
Evidence
- Expected source: the physical-cwd rule in
apps/server/src/vcs/GitVcsDriver.tslines 887-900, the subdirectory staging contract inapps/server/src/vcs/GitVcsDriverCore.tslines 2515-2523, and the passing physical-directory and repository-root-symlink selected-file controls in the reproduction. - Failure source: the observed reproduction output under Logs or stack traces, and the lexical resolution of
--git-path indexand--git-path objectsagainst the logical cwd inGitVcsDriver.prepareCommitContext(apps/server/src/vcs/GitVcsDriverCore.tslines 2532-2568), reached fromGitManager.tslines 2130-2143. - Evidence provenance: observed
- Local verification: reproduced
- Reproduction completeness: complete
The run covers the production manager and Git driver with real Git; the UI wiring from the Commit dialog to GitManager and the toast are established from source only. Only macOS was tested; Linux and Windows were not run.
Restoration check
With the script's input, the link-selected commit succeeds, HEAD advances, the new commit contains only sub/dir/nested.txt, and README.md stays modified, matching the real-selected and rootlink-selected results. As controls, the physical-directory, repository-root-symlink and all-files variants keep their current results, and an all-files commit still commits both files. The script asserts the current failure, so its link-selected expectations need changing to the success result for this check.
References
- [Bug]: Checkpoint capture fails with exit 128 on every turn when the project path is a symlink to a repository subdirectory #13187 and its fix fix(server): resolve git common dirs through symlinked subdirectories #18152: checkpoint capture and ref discovery through a symlink to a repository subdirectory, the same path rule in a different operation; their tests cover checkpoint index placement and ref listing, not selected-file commits.
- fix(server): commit-message preparation no longer writes objects into the repository #34595: introduced the temporary object directory and the
--git-path objectsresolution in commit preparation. - Related but not duplicates: open fix(server): storage cleanup treats symlinked spellings of a worktree as one checkout #25735 (storage cleanup treating symlinked worktree spellings as one checkout), open fix(server): deduplicate symlinked project checkouts #32212 (canonicalizing symlinked project checkouts at registration, which does not change the Git driver for projects already saved under a symlink path), and open fix(server): selected-file commits work again with Git older than 2.25 #28850 (selected-file commits on Git older than 2.25, a different failure).
Impact
Minor bug or occasional failure
A selected-files commit fails on every attempt for projects opened through a symlink to a repository subdirectory, so users of that layout cannot make partial commits from T3 Code; it is limited to that layout, no data is lost, and HEAD and the working tree stay unchanged.
Version or commit
d9805b2, rerun from a clean checkout with its installed dependencies; the same script also reproduced the failure with every control passing on main at 4947db0 (using the tested commit's installed dependencies) and on main at 5911884 (fresh frozen-lockfile install with lifecycle scripts skipped)
Environment
Darwin arm64, Node v26.12.1, Git 2.56.0, repository-pinned Vite+ 1.4.0; production GitManager and GitVcsDriver against temporary Git repositories, with no server, browser, desktop app or provider
Logs or stack traces
Reproduction output on the tested commit, keeping the Git path, state and summary lines; the temporary fixture path is shown as <fixture>:
GIT_PATHS link-selected "../../.git/index\n../../.git/objects"
RESULT link-selected Failure Cause([Fail(GitCommandError: Git command failed in GitVcsDriver.prepareCommitContext.readTree (<fixture>/nest/link): Git command exited with a non-zero status.)])
STATE link-selected {"headChanged":false,"changed":"","status":"M README.md\n?? sub/"}
RAW_GIT 128 error: unable to normalize alternate object path: <fixture>/.git/objects
fatal: failed to unpack tree object HEAD
STATE same-link-all {"changed":"README.md\nsub/dir/nested.txt","status":""}
GIT_PATHS link-all "../../.git/index\n../../.git/objects"
STATE link-all {"headChanged":true,"changed":"README.md\nsub/dir/nested.txt","status":""}
GIT_PATHS real-selected "../../.git/index\n../../.git/objects"
STATE real-selected {"headChanged":true,"changed":"sub/dir/nested.txt","status":"M README.md"}
GIT_PATHS rootlink-selected ".git/index\n.git/objects"
STATE rootlink-selected {"headChanged":true,"changed":"sub/dir/nested.txt","status":"M README.md"}
GIT_PATHS direct-selected "../../.git/index\n../../.git/objects"
STATE direct-selected {"headChanged":true,"changed":"sub/dir/nested.txt","status":"M README.md"}
Test Files 1 passed (1)
Tests 1 passed (1)
Screenshots, recordings, or supporting files
No response
Workaround
Opening the project through its physical path (or through a symlink to the repository root) makes the same selected-file commit succeed with only the selected file committed, as the reproduction's controls show; committing all files through the original symlink also succeeds when including every change is acceptable.
Activity