Repository navigation
Description
Summary
Selecting a non-origin remote branch in Current checkout mode detaches HEAD when a local branch with the same short name exists but does not track that remote. The picker records the local branch name even though new commits do not advance it. I reproduced the remote row and server switch with real Git in a disposable repository; Impact is P2 because selecting the local row restores the branch, but the detached state can go unnoticed.
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
- Create a repository on
main, a localfeaturewithout an upstream, and anupstreamremote with a fetchedfeatureref. The script below creates these fixtures under a private temporary directory. - Ask the production driver to list refs and confirm that
upstream/featureis a selectable remote row for the project checkout. As a control, switch to localfeatureand confirm HEAD is attached, then return tomain. - Switch to
upstream/feature, inspect the returned branch and HEAD, and make an empty commit to check whetherrefs/heads/featureadvances.
The run reproduced the defect once; the code path and Git fixture make it deterministic for this input. Save the following script as repro.sh in the checkout parent and run sh ../repro.sh "$PWD" from the checkout root with installed checkout dependencies, Git, Node.js, and vp. It uses the production driver and picker selection helper, keeps HOME, TMPDIR, and T3 state private, removes its temporary test file, and leaves tracked files untouched. Exit 1 means the defect reproduced, 0 means HEAD stayed attached to the expected branch, and 2 means setup or the control was inconclusive.
Reproduction script: repro.sh
#!/bin/sh
set -eu
checkout=${1:?pass the checkout path}
checkout=$(cd "$checkout" && pwd)
printf 'git: %s\n' "$(git --version)"
printf 'node: %s\n' "$(node --version)"
printf 'vp: %s\n' "$(vp --version | head -n 1)"
printf 'revision: %s\n' "$(git -C "$checkout" rev-parse HEAD)"
run=$(mktemp -d "${TMPDIR:-/tmp}/s07-01.XXXXXX")
testfile="$checkout/apps/server/src/vcs/remote-switch.s07-01.test.ts"
if [ -e "$testfile" ]; then
rm -rf "$run"
printf 'RESULT: inconclusive\n'
exit 2
fi
cleanup() { rm -f "$testfile"; rm -rf "$run"; }
trap cleanup 0
trap 'exit 2' 1 2 3 15
mkdir -p "$run/home" "$run/tmp" "$run/state"
export HOME="$run/home" TMPDIR="$run/tmp" T3CODE_HOME="$run/state" REPRO_RUN="$run"
export GIT_CONFIG_NOSYSTEM=1 GIT_CONFIG_GLOBAL=/dev/null GIT_TERMINAL_PROMPT=0
unset GIT_DIR GIT_WORK_TREE GIT_COMMON_DIR GIT_INDEX_FILE GIT_OBJECT_DIRECTORY GIT_ALTERNATE_OBJECT_DIRECTORIES NODE_ENV || true
cat > "$testfile" <<'TS'
import { strict as assert } from "node:assert";
import { execFileSync, spawnSync } from "node:child_process";
import { mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
import * as NodeServices from "@effect/platform-node/NodeServices";
import { it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as ServerConfig from "../config.ts";
import * as GitVcsDriver from "./GitVcsDriver.ts";
import { resolveBranchSelectionTarget } from "../../../web/src/components/BranchToolbar.logic.ts";
const root = process.env.REPRO_RUN!;
const git = (cwd: string, ...args: string[]) =>
execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
const layer = GitVcsDriver.layer.pipe(
Layer.provideMerge(ServerConfig.layerTest(root, { prefix: "s07-01-" })),
Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)),
);
it.layer(layer)("remote branch with same-named untracked local branch", (it) => {
it.effect("keeps HEAD on the branch shown by the picker", () =>
Effect.gen(function* () {
const cwd = join(root, "project");
const bare = join(root, "upstream.git");
mkdirSync(cwd);
mkdirSync(bare);
git(cwd, "init", "-q", "-b", "main");
git(bare, "init", "-q", "--bare");
git(cwd, "config", "user.name", "Fixture");
git(cwd, "config", "user.email", "fixture.invalid");
writeFileSync(join(cwd, "file.txt"), "initial\n");
git(cwd, "add", "file.txt");
git(cwd, "commit", "-q", "-m", "initial");
git(cwd, "branch", "--no-track", "feature");
git(cwd, "remote", "add", "upstream", bare);
git(cwd, "push", "-q", "upstream", "feature:refs/heads/feature");
git(cwd, "fetch", "-q", "upstream");
assert.equal(git(cwd, "for-each-ref", "--format=%(upstream)", "refs/heads/feature"), "");
const driver = yield* GitVcsDriver.GitVcsDriver;
const refs = yield* driver.listRefs({ cwd, refresh: true, limit: 100 });
const row = refs.refs.find((ref) => ref.name === "upstream/feature");
assert.ok(row?.isRemote);
const target = resolveBranchSelectionTarget({
activeProjectCwd: cwd,
activeWorktreePath: null,
refName: row,
});
assert.equal(target.checkoutCwd, cwd);
assert.equal(target.reuseExistingWorktree, false);
const control = yield* driver.switchRef({ cwd, refName: "feature" });
assert.equal(control.refName, "feature");
assert.equal(git(cwd, "symbolic-ref", "HEAD"), "refs/heads/feature");
yield* driver.switchRef({ cwd, refName: "main" });
const selected = yield* driver.switchRef({ cwd, refName: row.name });
const branch = git(cwd, "branch", "--show-current");
const symbolic = spawnSync("git", ["symbolic-ref", "-q", "HEAD"], {
cwd,
encoding: "utf8",
});
writeFileSync(
join(root, "observed"),
`row=${row.name} control=${control.refName} selected=${selected.refName} current=${branch || "<empty>"} symbolicExit=${symbolic.status}\n`,
);
if (selected.refName === null && branch === "" && symbolic.status === 1) {
const oldTip = git(cwd, "rev-parse", "refs/heads/feature");
git(cwd, "commit", "--allow-empty", "-q", "-m", "detached work");
assert.equal(git(cwd, "rev-parse", "refs/heads/feature"), oldTip);
assert.notEqual(git(cwd, "rev-parse", "HEAD"), oldTip);
writeFileSync(join(root, "result"), "defect");
return;
}
assert.equal(selected.refName, "feature");
assert.equal(branch, "feature");
assert.equal(symbolic.stdout.trim(), "refs/heads/feature");
writeFileSync(join(root, "result"), "expected");
}),
);
});
TS
cd "$checkout"
set +e
vp test run apps/server/src/vcs/remote-switch.s07-01.test.ts > "$run/test-output" 2>&1
test_status=$?
set -e
if [ -f "$run/observed" ]; then cat "$run/observed"; fi
if [ "$test_status" -ne 0 ] || [ ! -f "$run/result" ]; then
sed -n '/FAIL/,$p' "$run/test-output" | head -n 30
printf 'RESULT: inconclusive\n'
exit 2
fi
case "$(cat "$run/result")" in
expected) printf 'RESULT: expected behavior\n'; exit 0 ;;
defect) printf 'commit: HEAD advanced; refs/heads/feature unchanged\nRESULT: defect reproduced\n'; exit 1 ;;
*) printf 'RESULT: inconclusive\n'; exit 2 ;;
esacExpected behavior
Selecting a remote branch row should leave the checkout attached to the branch represented in the thread, or report a failure instead of silently reporting another branch. The existing remote switch test expects switchRef to return the attached local branch for a tracked remote, and the picker records the derived local name after a successful switch. A same-named local branch without an upstream should not cause a successful switch that detaches HEAD while the thread still names that branch.
Actual behavior
In a disposable repository, listRefs included the selectable upstream/feature row beside a local feature with no upstream. Calling the production GitVcsDriver.switchRef({ cwd, refName: "upstream/feature" }), as the web picker does, returned { refName: null } with success and left git symbolic-ref -q HEAD failing because HEAD was detached. An empty commit then advanced HEAD without advancing refs/heads/feature. The web picker uses the derived local name when the switch returns null, so its thread branch becomes feature despite the detached checkout; that UI result is a source trace, not a browser observation.
Impact and scope
Impact: P2 - commits made after this switch advance detached HEAD instead of the branch named by the thread; the run confirmed this for an empty commit. The local branch row is a working workaround, but users may not see that the remote selection detached HEAD before committing.
The reproduced input has a non-origin remote and an untracked local branch with the remote branch's short name. The default ref list keeps non-origin remote rows. Other remotes, platforms, and the full browser interaction were not run.
Interface
UI: web branch picker calling the vcs.switchRef WebSocket method; reproduced at the production Git driver boundary.
Earliest failure stage
Core processing.
Analysis or suspected cause
Source trace: the remoteExists && !localTrackingBranch && localTrackedBranchTargetExists case passes the remote ref itself to Git, which checks out its commit and detaches HEAD. The driver considers a null current branch a successful result, and the picker converts that null into the derived local branch name.
Additional context
#35217 is closed as completed, and #35400 is merged. That fix changed tracking lookup and creation for other detached-HEAD triggers but left this existing-untracked-local checkout branch unchanged. The same fallback was still present in upstream main at f255158370ebd7509f92903589ad0abd6ebe40ec when checked.
Evidence
The driver detects the existing local candidate, then chooses git checkout upstream/feature -- if that branch does not track the remote. The empty git branch --show-current becomes refName: null. The web selection handler substitutes feature on success. The fixture's remote row was confirmed with the production listRefs, and its checkout target with resolveBranchSelectionTarget.
Expected source: the existing remote switch test linked above and the working local branch control. Failure source: the production-driver run and the source trace linked above. Evidence provenance: observed. Local verification: reproduced. Reproduction completeness: complete. The script uses fixture.invalid as a synthetic Git author identifier in place of the synthetic address used in the local run; this does not enter the branch-selection logic.
Restoration check
With the script fixture, selecting upstream/feature should leave HEAD attached to the branch represented by the thread, or return a failure. A subsequent commit should advance that branch. The local feature selection remains the working control.
Impact
Minor bug or occasional failure
Version or commit
main @ c00a373
Environment
macOS (Darwin arm64), Node.js v26.12.1, Git 2.56.0, vp v1.5.1 (local vite-plus v1.4.0); production Git driver and picker selection helper with local fixture repositories.
Logs or stack traces
git: git version 2.56.0
node: v26.12.1
vp: vp v1.5.1
revision: c00a373491d20ebcef901b81170abfb9f5f757fe
row=upstream/feature control=feature selected=null current=<empty> symbolicExit=1
commit: HEAD advanced; refs/heads/feature unchanged
RESULT: defect reproducedScreenshots, recordings, or supporting files
No response
Workaround
Select the local feature branch row instead of the upstream/feature remote row; the reproduced control left HEAD attached to refs/heads/feature.
References
- #35217 was closed as completed after #35400 merged. That change addressed other remote-branch detachment triggers; its existing-untracked-local checkout path remained unchanged, as the PR description notes. The reproduced case uses a pre-existing local
featurewithout an upstream and a selectable non-origin remote row.
Activity