Skip to content

[Bug]: Fork PR worktree checkout fails when a local branch owns its generated prefix #35743

Closed

Description

@coygeek

Summary

Opening a fork pull request in a worktree fails when an unrelated local branch occupies the prefix T3 Code uses for the generated PR branch. The same PR opens successfully when that branch is absent or renamed. This was reproduced through the real PR preparation service with local Git repositories and a fake hosting provider; T3 Code should choose a usable local PR branch while preserving the user's existing branch.

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. Use a checkout of the stated commit with its pinned dependencies installed and Node available.
  2. Run the script below. It calls the real PR preparation service with a fork PR response and local bare remotes, first with no blocker, then with t3code, then with t3code/pr-94.
  3. Observe that the control succeeds, both blocked cases fail, and both retries after renaming the pre-existing branch succeed.

The service failures reproduced in the initial probe and the final script. The script's control and workaround cases succeeded. An intermediate harness attempt was inconclusive because it read the Effect Result success payload using the wrong field; that runner error was corrected before the final run below.

Save this as repro.sh outside the checkout and run sh repro.sh /path/to/t3code. It requires the checkout's installed dependencies, Node with TypeScript stripping, and Git. It reads the repository's existing test helpers, imports the real services, creates all repositories and T3 state under mktemp -d, uses no network, removes its fixtures on exit, and leaves the checkout unchanged. Exit 0 means all PR preparations succeeded, 1 means the prefix defect reproduced, and 2 means setup or a control failed.

Reproduction script: repro.sh
#!/bin/sh
set -u
checkout=$(cd "$1" && pwd) || exit 2
scratch=$(mktemp -d) || exit 2
trap 'rm -rf "$scratch"' EXIT HUP INT TERM
mkdir -p "$scratch/home" "$scratch/tmp" "$scratch/state"
export HOME="$scratch/home" TMPDIR="$scratch/tmp" T3CODE_HOME="$scratch/state"
export GIT_CONFIG_NOSYSTEM=1 GIT_CONFIG_GLOBAL=/dev/null
node --version
git --version
git -C "$checkout" rev-parse HEAD
node --input-type=module - "$checkout" "$scratch" <<'JS'
import fs from 'node:fs';
import path from 'node:path';
const [checkout, scratch] = process.argv.slice(2);
const sourceDir = path.join(checkout, 'apps/server/src/git');
const source = fs.readFileSync(path.join(sourceDir, 'GitManager.test.ts'), 'utf8');
let helpers = source.split('it.layer(layerGitManagerTest)')[0]
  .replace('import { it } from "@effect/vitest";', '')
  .replace('import { expect } from "vite-plus/test";', '')
  .replace(/from "(\.{1,2}\/[^\"]+)"/g, (_, specifier) => `from ${JSON.stringify(path.resolve(sourceDir, specifier))}`);
fs.symlinkSync(path.join(checkout, 'apps/server/node_modules'), path.join(scratch, 'node_modules'), 'dir');
const probe = `
let failed = 0;
const probe = Effect.gen(function* () {
  for (const blocker of [null, "t3code", "t3code/pr-94"]) {
    const repoDir = yield* makeTempDir("pr-namespace-");
    yield* initRepo(repoDir);
    const originDir = yield* createBareRemote();
    const forkDir = yield* createBareRemote();
    yield* runGit(repoDir, ["remote", "add", "origin", originDir]);
    yield* runGit(repoDir, ["push", "-u", "origin", "main"]);
    yield* runGit(repoDir, ["remote", "add", "seed", forkDir]);
    yield* runGit(repoDir, ["push", "seed", "main:topic"]);
    if (blocker !== null) yield* runGit(repoDir, ["branch", blocker]);
    const {manager} = yield* makeManager({providerScenario:{
      pullRequest:{number:94,title:"PR namespace probe",url:"https://github.com/example/base/pull/94",baseRefName:"main",headRefName:"topic",state:"open",isCrossRepository:true,headRepositoryNameWithOwner:"contributor/base",headRepositoryOwnerLogin:"contributor"},
      repositoryCloneUrls:{"contributor/base":{url:forkDir,sshUrl:forkDir}}
    }});
    const open = () => manager.preparePullRequestThread({cwd:repoDir,reference:"94",mode:"worktree"}).pipe(Effect.result);
    const before = (yield* runGit(repoDir, ["rev-parse", "main"])).stdout.trim();
    const result = yield* open();
    if (blocker !== null) {
      const retained = (yield* runGit(repoDir, ["rev-parse", "refs/heads/" + blocker])).stdout.trim();
      if (retained !== before) throw new Error("Existing blocker was changed");
    }
    if (result._tag === "Success") {
      const actual = (yield* runGit(result.success.worktreePath!, ["rev-parse", "HEAD"])).stdout.trim();
      const expected = (yield* runGit(repoDir, ["rev-parse", "seed/topic"])).stdout.trim();
      if (actual !== expected) throw new Error("Wrong PR checkout");
    }
    console.log("PROBE blocker=" + (blocker ?? "none") + " result=" + result._tag + (result._tag === "Failure" ? " error=" + result.failure._tag : ""));
    if (blocker === null && result._tag !== "Success") throw new Error("Control failed");
    if (blocker !== null && result._tag === "Failure") {
      failed++;
      yield* runGit(repoDir, ["branch", "-m", blocker, "saved-local"]);
      const retry = yield* open();
      console.log("PROBE after renaming blocker=" + blocker + " result=" + retry._tag);
      if (retry._tag !== "Success") throw new Error("Workaround control failed");
    }
  }
});
try {
  await Effect.runPromise(Effect.scoped(probe.pipe(Effect.provide(layerGitManagerTest))));
  process.exitCode = failed > 0 ? 1 : 0;
} catch (error) {
  console.error(error);
  process.exitCode = 2;
}
`;
fs.writeFileSync(path.join(scratch, 'probe.ts'), helpers + probe);
JS
if [ "$?" -ne 0 ]; then
  echo 'RESULT: inconclusive'
  exit 2
fi
(cd "$checkout/apps/server" && node "$scratch/probe.ts") > "$scratch/output" 2>&1
status=$?
sed -n '/^PROBE /p' "$scratch/output"
case "$status" in
  0) echo 'RESULT: expected behavior'; exit 0 ;;
  1) echo 'RESULT: defect reproduced'; exit 1 ;;
  *) cat "$scratch/output"; echo 'RESULT: inconclusive'; exit 2 ;;
esac

Expected behavior

A valid fork PR should open in a worktree while preserving an unrelated existing branch. The local branch name is generated by T3 Code, so the user has not requested a conflicting name. Git stores branch refs hierarchically under refs/heads, as described in git-check-ref-format, and T3 Code already handles an occupied generated namespace for ordinary thread launches in apps/server/src/orchestration-v2/ThreadLaunchService.ts#L557-L572.

Actual behavior

GitManager.preparePullRequestThread({ cwd, reference: "94", mode: "worktree" }) succeeds for a fork PR whose head branch is topic. Creating either valid local branch t3code or t3code/pr-94 makes the same operation return GitPullRequestMaterializationError for its generated local branch t3code/pr-94/topic. Renaming the blocker to saved-local makes the retry succeed. Both cases were run against the real Git driver; only the hosting provider's metadata and clone URLs were faked to use disposable local repositories.

Impact

Minor bug or occasional failure

Impact: P2 - Fork PR worktree checkout is blocked in repositories containing a local branch that occupies the generated PR namespace; a safe workaround is available by renaming that existing branch.

A t3code branch blocks the generated name for every new fork PR in the repository; t3code/pr-94 blocks the corresponding PR. Renaming either blocker was verified to restore the same service operation. No data loss was observed. The script verifies the real service and Git driver on macOS; the client UI and other platforms were not run.

Version or commit

main @ 0bb6eaa

Environment

Node v26.12.1, Git 2.56.0, Darwin arm64. Dependencies installed from the checkout's lockfile.

Verification scope: real GitManager and Git driver, local fake hosting provider; no running UI or real hosting account was exercised.

Logs or stack traces

Evidence

Final script output:

v26.12.1
git version 2.56.0
0bb6eaa546c91915efbd3f092088c694f3949709
PROBE blocker=none result=Success
PROBE blocker=t3code result=Failure error=GitPullRequestMaterializationError
PROBE after renaming blocker=t3code result=Success
PROBE blocker=t3code/pr-94 result=Failure error=GitPullRequestMaterializationError
PROBE after renaming blocker=t3code/pr-94 result=Success
RESULT: defect reproduced

The generated fork branch always starts with t3code/pr-<number>/ in apps/server/src/git/GitManager.ts#L275-L285. PR preparation passes it directly to materialization in apps/server/src/git/GitManager.ts#L3256-L3260. The materialization path forwards that name to fetchRemoteBranch in apps/server/src/git/GitManager.ts#L1093-L1098, whose existence check only checks that complete branch name before trying to create it in apps/server/src/vcs/GitVcsDriverCore.ts#L5053-L5072.

Evidence provenance: observed. Local verification: reproduced. Reproduction completeness: complete for the service path. Expected source: user intent of PR worktree preparation, Git ref namespace rules, and the existing ordinary-thread namespace handling linked above. Failure source: the included service reproduction and linked source. Missing fact: no missing fact needed for the service reproduction; client presentation was not exercised.

Restoration check

Both blocked scenarios should return a worktree on the PR head without renaming, deleting, or changing the existing blocker. The nearby control must continue to return a worktree whose HEAD equals the fork head. The included script checks these service outcomes and becomes green when the blocked cases succeed.

Source analysis

Source evidence: the PR branch generator chooses a fixed namespace without considering existing parent refs. The real Git driver then attempts that branch creation. Unlike ordinary temporary worktree names and automatic renames, this path has no available-name resolution before materialization.

Workaround

Rename the existing blocker before opening the fork PR in a worktree, for example git branch -m t3code saved-local, or git branch -m t3code/pr-94 saved-local for the corresponding blocker. Both were verified by the script. This preserves the local branch's commit while freeing the generated namespace.

References

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