Skip to content

[Bug]: Selecting remote branch detaches HEAD when local branch lacks upstream #35625

Closed

Description

@coygeek

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

  1. Create a repository on main, a local feature without an upstream, and an upstream remote with a fetched feature ref. The script below creates these fixtures under a private temporary directory.
  2. Ask the production driver to list refs and confirm that upstream/feature is a selectable remote row for the project checkout. As a control, switch to local feature and confirm HEAD is attached, then return to main.
  3. Switch to upstream/feature, inspect the returned branch and HEAD, and make an empty commit to check whether refs/heads/feature advances.

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 ;;
esac

Expected 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 reproduced

Screenshots, 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 feature without an upstream and a selectable non-origin remote row.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions