diff --git a/apps/pi-extension/server/serverReview.ts b/apps/pi-extension/server/serverReview.ts index 6ca1e35b..e7ff8b70 100644 --- a/apps/pi-extension/server/serverReview.ts +++ b/apps/pi-extension/server/serverReview.ts @@ -26,6 +26,7 @@ import { type DiffType, type GitCommandResult, type GitContext, + detectRemoteDefaultBranch, getFileContentsForDiff as getFileContentsForDiffCore, getGitContext as getGitContextCore, gitAddFile as gitAddFileCore, @@ -106,11 +107,12 @@ export interface ReviewServerResult { export const reviewRuntime: ReviewGitRuntime = { async runGit( args: string[], - options?: { cwd?: string }, + options?: { cwd?: string; timeoutMs?: number }, ): Promise { const result = spawnSync("git", args, { cwd: options?.cwd, encoding: "utf-8", + ...(options?.timeoutMs ? { timeout: options.timeoutMs } : {}), }); return { stdout: result.stdout ?? "", @@ -207,6 +209,14 @@ export async function startReviewServer(options: { // 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 || options.gitContext?.defaultBranch || "main"; + let baseEverSwitched = false; + + // Fire-and-forget: query the remote for its actual default branch. + if (options.gitContext && !options.initialBase && !isPRMode) { + detectRemoteDefaultBranch(reviewRuntime, options.gitContext.cwd).then((remote) => { + if (remote && !baseEverSwitched) currentBase = remote; + }); + } // Agent jobs — background process manager (late-binds serverUrl via getter) let serverUrl = ""; @@ -547,6 +557,7 @@ export async function startReviewServer(options: { currentGitRef = result.label; currentDiffType = newType; currentBase = base; + baseEverSwitched = true; currentError = result.error; // Recompute gitContext for the effective cwd so the client's diff --git a/packages/server/git.ts b/packages/server/git.ts index 864c46ba..3ceacc44 100644 --- a/packages/server/git.ts +++ b/packages/server/git.ts @@ -36,7 +36,7 @@ export type { async function runGit( args: string[], - options?: { cwd?: string }, + options?: { cwd?: string; timeoutMs?: number }, ): Promise { const proc = Bun.spawn(["git", ...args], { cwd: options?.cwd, @@ -44,12 +44,19 @@ async function runGit( stderr: "pipe", }); + let timer: ReturnType | undefined; + if (options?.timeoutMs) { + timer = setTimeout(() => proc.kill(), options.timeoutMs); + } + const [stdout, stderr, exitCode] = await Promise.all([ new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited, ]); + if (timer) clearTimeout(timer); + return { stdout, stderr, exitCode }; } diff --git a/packages/server/review.ts b/packages/server/review.ts index f14306c0..4be67b09 100644 --- a/packages/server/review.ts +++ b/packages/server/review.ts @@ -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 diff --git a/packages/shared/review-core.ts b/packages/shared/review-core.ts index c9b4c3a5..b0fc6be7 100644 --- a/packages/shared/review-core.ts +++ b/packages/shared/review-core.ts @@ -59,7 +59,7 @@ export interface GitCommandResult { export interface ReviewGitRuntime { runGit: ( args: string[], - options?: { cwd?: string }, + options?: { cwd?: string; timeoutMs?: number }, ) => Promise; readTextFile: (path: string) => Promise; } @@ -111,6 +111,38 @@ export async function getDefaultBranch( return "master"; } +/** + * Query the remote for its default branch via `ls-remote --symref`. Returns + * `origin/` if the remote answers and the tracking ref exists locally, + * otherwise `null`. Designed to run in the background at server startup — the + * caller fires it with `.then()` and uses the result if/when it arrives. + * + * Timeout-guarded: if the network is slow or absent, the promise resolves + * (with `null`) once the timeout fires. Never throws. + */ +export async function detectRemoteDefaultBranch( + runtime: ReviewGitRuntime, + cwd?: string, +): Promise { + try { + const lsRemote = await runtime.runGit( + ["ls-remote", "--symref", "origin", "HEAD"], + { cwd, timeoutMs: 5000 }, + ); + if (lsRemote.exitCode !== 0) return null; + const match = lsRemote.stdout.match(/^ref:\s+refs\/heads\/(\S+)\s+HEAD/m); + if (!match) return null; + const remoteBranch = `origin/${match[1]}`; + const refExists = await runtime.runGit( + ["show-ref", "--verify", "--quiet", `refs/remotes/${remoteBranch}`], + { cwd }, + ); + return refExists.exitCode === 0 ? remoteBranch : null; + } catch { + return null; + } +} + export async function listBranches( runtime: ReviewGitRuntime, cwd?: string,