diff --git a/change-logs/2026/06/06/fix-dev3-fetch-origin-branch-specific.md b/change-logs/2026/06/06/fix-dev3-fetch-origin-branch-specific.md new file mode 100644 index 00000000..53ce0ef7 --- /dev/null +++ b/change-logs/2026/06/06/fix-dev3-fetch-origin-branch-specific.md @@ -0,0 +1 @@ +Replace `git fetch origin` (full fetch) with `git fetch origin ` for all background and triggered fetches that only need the base branch ref. The fetch cache is now keyed by `projectPath:branch` so concurrent callers for different branches no longer share stale cache. The branch picker's explicit fetch-all remains unchanged. Fixed follow-up bugs: rebaseTask now fetches the actual rebase target ref (not just baseBranch), getBranchStatus/getTaskDiff also fetch a custom compareRef branch when it differs from baseBranch, and checkMergedBranches fetches all distinct per-task base branches instead of only the project default. diff --git a/src/bun/__tests__/rpc-handlers.test.ts b/src/bun/__tests__/rpc-handlers.test.ts index 65d38b28..f2769653 100644 --- a/src/bun/__tests__/rpc-handlers.test.ts +++ b/src/bun/__tests__/rpc-handlers.test.ts @@ -3342,7 +3342,7 @@ describe("handlers.getTaskDiff", () => { compareLabel: "origin/main", }); - expect(git.fetchOrigin).toHaveBeenCalledWith(project.path); + expect(git.fetchOrigin).toHaveBeenCalledWith(project.path, "main"); expect(git.getTaskDiff).toHaveBeenCalledWith("/tmp/wt", "branch", { baseBranch: "main", compareRef: "origin/main", diff --git a/src/bun/git.ts b/src/bun/git.ts index b55dc9e7..cb4a5ea8 100644 --- a/src/bun/git.ts +++ b/src/bun/git.ts @@ -743,7 +743,7 @@ export async function createWorktree( const fetched = await measureGitStep( "createWorktree.fetchOrigin", { taskId: task.id.slice(0, 8), projectPath: project.path }, - () => fetchOrigin(project.path), + () => fetchOrigin(project.path, baseBranch), ); const remoteBase = `origin/${baseBranch}`; const refCheckResult = fetched @@ -946,53 +946,78 @@ export async function isWorktreeDirty(worktreePath: string): Promise { // Per-project fetch deduplication: reuse in-flight fetch promises and enforce // a cooldown to prevent lock contention when multiple callers (polling, git // operation completion, merge detection) trigger concurrent fetches. +// +// fetchProjectQueue serializes the actual git subprocess per repo so that +// concurrent fetches for *different* branches don't race on .git/packed-refs.lock. +// Same-branch callers are coalesced by fetchInFlight before reaching the queue. const fetchInFlight = new Map>(); const fetchLastSuccess = new Map(); +const fetchProjectQueue = new Map>(); const FETCH_COOLDOWN_MS = 5_000; -export async function fetchOrigin(projectPath: string): Promise { +export async function fetchOrigin(projectPath: string, branch?: string): Promise { await reportCurrentPreparationStage("fetching-origin"); const now = Date.now(); - const lastSuccess = fetchLastSuccess.get(projectPath) ?? 0; + // Cache key is scoped to the specific branch when provided, or "*" for a full fetch. + const cacheKey = branch ? `${projectPath}:${branch}` : `${projectPath}:*`; + const lastSuccess = fetchLastSuccess.get(cacheKey) ?? 0; // Skip if a successful fetch completed recently if (now - lastSuccess < FETCH_COOLDOWN_MS) { - log.debug("fetchOrigin: skipping (cooldown)", { projectPath, msSinceLast: now - lastSuccess }); + log.debug("fetchOrigin: skipping (cooldown)", { projectPath, branch, msSinceLast: now - lastSuccess }); return true; } - // Reuse in-flight fetch for the same project - const existing = fetchInFlight.get(projectPath); + // Reuse in-flight fetch for the same project+branch + const existing = fetchInFlight.get(cacheKey); if (existing) { - log.debug("fetchOrigin: reusing in-flight fetch", { projectPath }); + log.debug("fetchOrigin: reusing in-flight fetch", { projectPath, branch }); return existing; } - const promise = (async () => { + // Chain behind any concurrent fetch on this repo. All setup below is synchronous + // so the queue tail is correctly sequenced even when two callers enter back-to-back. + const prevInQueue = fetchProjectQueue.get(projectPath) ?? Promise.resolve(); + + const promise: Promise = prevInQueue.catch(() => {}).then(async () => { + // Re-check cooldown: a preceding branch fetch may have taken long enough that we + // now fall within the window, or another caller for this branch got here first. + if (Date.now() - (fetchLastSuccess.get(cacheKey) ?? 0) < FETCH_COOLDOWN_MS) { + log.debug("fetchOrigin: skipping (cooldown after queue wait)", { projectPath, branch }); + return true; + } + const startedAt = performance.now(); - log.debug("Fetching origin", { projectPath }); - const result = await run(["git", "fetch", "origin", "--quiet"], projectPath); + const cmd = branch + ? ["git", "fetch", "origin", branch, "--quiet"] + : ["git", "fetch", "origin", "--quiet"]; + log.debug("Fetching origin", { projectPath, branch }); + const result = await run(cmd, projectPath); if (result.ok) { - fetchLastSuccess.set(projectPath, Date.now()); + fetchLastSuccess.set(cacheKey, Date.now()); log.info("fetchOrigin finished", { projectPath, + branch, durationMs: Math.round(performance.now() - startedAt), }); } else { log.warn("fetchOrigin failed", { projectPath, + branch, stderr: result.stderr, durationMs: Math.round(performance.now() - startedAt), }); } return result.ok; - })(); + }); - fetchInFlight.set(projectPath, promise); + // Become the new queue tail. Errors are swallowed so subsequent fetches always run. + fetchProjectQueue.set(projectPath, promise.then(() => {}).catch(() => {})); + fetchInFlight.set(cacheKey, promise); try { return await promise; } finally { - fetchInFlight.delete(projectPath); + fetchInFlight.delete(cacheKey); } } @@ -1012,7 +1037,7 @@ export async function pullOrigin( if (result.ok) { // A successful pull effectively refreshes the remote tracking branch too — // keep the fetch cache honest so immediate callers don't re-fetch. - fetchLastSuccess.set(projectPath, Date.now()); + fetchLastSuccess.set(`${projectPath}:${branch}`, Date.now()); } return result; } @@ -1093,14 +1118,20 @@ export async function fetchFork( /** Remove fetch cache for a specific project path (call on project deletion). */ export function removeFetchCache(projectPath: string): void { - fetchInFlight.delete(projectPath); - fetchLastSuccess.delete(projectPath); + for (const key of fetchInFlight.keys()) { + if (key.startsWith(projectPath + ":")) fetchInFlight.delete(key); + } + for (const key of fetchLastSuccess.keys()) { + if (key.startsWith(projectPath + ":")) fetchLastSuccess.delete(key); + } + fetchProjectQueue.delete(projectPath); } /** Reset fetch dedup state — for tests only. */ export function _resetFetchState(): void { fetchInFlight.clear(); fetchLastSuccess.clear(); + fetchProjectQueue.clear(); } export async function getBranchStatus( diff --git a/src/bun/rpc-handlers/git-operations.ts b/src/bun/rpc-handlers/git-operations.ts index 045fad25..90d65d9b 100644 --- a/src/bun/rpc-handlers/git-operations.ts +++ b/src/bun/rpc-handlers/git-operations.ts @@ -267,8 +267,14 @@ async function checkMergedBranches(): Promise { if (reviewTasks.length === 0) continue; + // Fetch every distinct base branch used by tasks in this project so that + // per-task merge-detection checks don't compare against stale remote refs. + const uniqueBaseBranches = [...new Set([ + project.defaultBaseBranch || "main", + ...reviewTasks.map((t) => t.baseBranch || project.defaultBaseBranch || "main"), + ])]; try { - await git.fetchOrigin(project.path); + await Promise.all(uniqueBaseBranches.map((b) => git.fetchOrigin(project.path, b))); } catch { continue; } @@ -440,8 +446,16 @@ async function getBranchStatusImpl(params: { taskId: string; projectId: string; } log.info("getBranchStatus: fetching origin", { worktreePath: task.worktreePath, baseBranch, branchName: branchForPush }); - await git.fetchOrigin(project.path); + await git.fetchOrigin(project.path, baseBranch); const ref = params.compareRef || `origin/${baseBranch}`; + const compareRefBranch = params.compareRef?.startsWith("origin/") ? params.compareRef.slice("origin/".length) : null; + if (compareRefBranch && compareRefBranch !== baseBranch) { + await git.fetchOrigin(project.path, compareRefBranch); + } + // Also refresh origin/ so getUnpushedCount reflects out-of-band remote pushes. + if (branchForPush && branchForPush !== baseBranch && branchForPush !== compareRefBranch) { + await git.fetchOrigin(project.path, branchForPush); + } const prDetection: Promise<{ number: number; url: string } | null> = (async () => { try { const ghResult = await github.runGitHub( @@ -527,7 +541,11 @@ async function getTaskDiff(params: { const baseBranch = task.baseBranch || project.defaultBaseBranch || "main"; if (params.mode !== "uncommitted") { - await git.fetchOrigin(project.path); + await git.fetchOrigin(project.path, baseBranch); + const compareRefBranch = params.compareRef?.startsWith("origin/") ? params.compareRef.slice("origin/".length) : null; + if (compareRefBranch && compareRefBranch !== baseBranch) { + await git.fetchOrigin(project.path, compareRefBranch); + } } const result = await git.getTaskDiff(task.worktreePath, params.mode, { @@ -561,10 +579,16 @@ async function rebaseTask(params: { taskId: string; projectId: string; compareRe const socket = task.tmuxSocket ?? pty.DEFAULT_TMUX_SOCKET; await killExistingGitPane(task.id, tmuxSession, socket); + // Fetch the ref we will actually rebase onto, not just baseBranch. + // rebaseTarget may be a custom compareRef (e.g. origin/develop) that differs from baseBranch. + const fetchBranch = rebaseTarget.startsWith("origin/") + ? rebaseTarget.slice("origin/".length) + : baseBranch; + const script = [ `#!/bin/bash`, `echo "Fetching origin..."`, - `git fetch origin --quiet`, + `git fetch origin ${fetchBranch} --quiet`, `echo "Rebasing on ${rebaseTarget}..."`, `set -x`, `git rebase ${rebaseTarget}`, @@ -605,7 +629,7 @@ async function mergeTask(params: { taskId: string; projectId: string }): Promise if (!branchForMerge) throw new Error("Task has no branch"); const baseBranch = task.baseBranch || project.defaultBaseBranch || "main"; - await git.fetchOrigin(project.path); + await git.fetchOrigin(project.path, baseBranch); // For task-specific base branches (not the project default), compare against the local branch — // consistent with what the UI displays. For the project default, check against the remote. const projectBaseBranch = project.defaultBaseBranch || "main";