fix(review): detect remote default branch asynchronously at startup (#609)

getDefaultBranch relied on `symbolic-ref refs/remotes/origin/HEAD`
which is commonly unset in worktrees, manual remote setups, and
long-lived repos. When absent, the code fell through to local
`main`/`master`, silently losing the upstream-preference behavior.

Adds `detectRemoteDefaultBranch()` — queries the remote via
`git ls-remote --symref origin HEAD` to find the actual default
branch name (works for any name: develop, trunk, etc.).

The call is fire-and-forget at server startup: non-blocking, runs
concurrently while the browser loads. By the time the user opens
Branch diff or PR Diff (usually 1-2 seconds later), the result has
arrived and `currentBase` has been silently upgraded to the upstream
ref. If offline or slow, the 5-second timeout fires and the local
fallback sticks. If the user has already switched bases manually,
their choice is never overwritten.

`ReviewGitRuntime.runGit` gains an optional `timeoutMs` param. Bun
kills the process via setTimeout + proc.kill(). Pi uses spawnSync's
native `timeout` option. Both treat timeout as a non-zero exit.

For provenance purposes, this commit was AI assisted.
This commit is contained in:
Michael Ramos
2026-04-23 18:13:03 -07:00
committed by GitHub
parent d102c5f709
commit b0c5db924f
4 changed files with 67 additions and 6 deletions
+14 -3
View File
@@ -11,10 +11,9 @@
import { isRemoteSession, getServerHostname, getServerPort } from "./remote";
import type { Origin } from "@plannotator/shared/agents";
import { type DiffType, type GitContext, runVcsDiff, getVcsFileContentsForDiff, canStageFiles, stageFile, unstageFile, resolveVcsCwd, validateFilePath, getVcsContext } from "./vcs";
import { parseWorktreeDiffType } from "@plannotator/shared/review-core";
import { type DiffType, type GitContext, runVcsDiff, getVcsFileContentsForDiff, canStageFiles, stageFile, unstageFile, resolveVcsCwd, validateFilePath, getVcsContext, gitRuntime } from "./vcs";
import { parseWorktreeDiffType, detectRemoteDefaultBranch, resolveBaseBranch } from "@plannotator/shared/review-core";
import type { AgentJobInfo } from "@plannotator/shared/agent-jobs";
import { resolveBaseBranch } from "@plannotator/shared/review-core";
import { getRepoInfo } from "./repo";
import { handleImage, handleUpload, handleAgents, handleServerReady, handleDraftSave, handleDraftLoad, handleDraftDelete, handleFavicon, type OpencodeClient } from "./shared-handlers";
import { contentHash, deleteDraft } from "./draft";
@@ -144,6 +143,17 @@ export async function startReviewServer(
// the reviewer is currently looking at. Honors an explicit initialBase from
// the caller — e.g. programmatic Pi callers can request a non-detected base.
let currentBase = options.initialBase || gitContext?.defaultBranch || "main";
let baseEverSwitched = false;
// Fire-and-forget: query the remote for its actual default branch. If it
// arrives before the user interacts, quietly upgrade currentBase from the
// local fallback (e.g. "main") to the upstream ref (e.g. "origin/main").
// Non-blocking — the server is already listening by the time this resolves.
if (gitContext && !options.initialBase && !isPRMode) {
detectRemoteDefaultBranch(gitRuntime, gitContext.cwd).then((remote) => {
if (remote && !baseEverSwitched) currentBase = remote;
});
}
// Agent jobs — background process manager (late-binds serverUrl via getter)
let serverUrl = "";
@@ -496,6 +506,7 @@ export async function startReviewServer(
currentGitRef = result.label;
currentDiffType = newDiffType;
currentBase = base;
baseEverSwitched = true;
currentError = result.error;
// Recompute gitContext for the effective cwd so the client's