From bc55d7a94d6acbe0ff9c0e768b96e566b3cb1542 Mon Sep 17 00:00:00 2001 From: Ittai Zeidman Date: Sat, 6 Jun 2026 07:36:56 +0300 Subject: [PATCH 1/4] Narrow git fetch origin to specific branch in all non-picker call sites All six fetchOrigin call sites (createWorktree, getBranchStatus, getTaskDiff, mergeTask, checkMergedBranches, rebase script) only ever compare against origin/. Pass that branch to fetchOrigin so the fetch is git fetch origin instead of a full-repo fetch. The fetch cache key changes from projectPath to projectPath:branch so different branches cache independently. pullOrigin's cache invalidation and removeFetchCache are updated accordingly. The fetchBranches (branch picker) path retains the full fetch since it populates a branch list and needs all remote refs. --- .../fix-dev3-fetch-origin-branch-specific.md | 1 + src/bun/__tests__/rpc-handlers.test.ts | 2 +- src/bun/git.ts | 41 ++++++++++++------- src/bun/rpc-handlers/git-operations.ts | 10 ++--- 4 files changed, 33 insertions(+), 21 deletions(-) create mode 100644 change-logs/2026/06/06/fix-dev3-fetch-origin-branch-specific.md 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..4929c831 --- /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. 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..7c073a70 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 @@ -950,37 +950,44 @@ const fetchInFlight = new Map>(); const fetchLastSuccess = 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 () => { 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), }); @@ -988,11 +995,11 @@ export async function fetchOrigin(projectPath: string): Promise { return result.ok; })(); - fetchInFlight.set(projectPath, promise); + fetchInFlight.set(cacheKey, promise); try { return await promise; } finally { - fetchInFlight.delete(projectPath); + fetchInFlight.delete(cacheKey); } } @@ -1012,7 +1019,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,8 +1100,12 @@ 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); + } } /** Reset fetch dedup state — for tests only. */ diff --git a/src/bun/rpc-handlers/git-operations.ts b/src/bun/rpc-handlers/git-operations.ts index 045fad25..6e68ea13 100644 --- a/src/bun/rpc-handlers/git-operations.ts +++ b/src/bun/rpc-handlers/git-operations.ts @@ -268,7 +268,7 @@ async function checkMergedBranches(): Promise { if (reviewTasks.length === 0) continue; try { - await git.fetchOrigin(project.path); + await git.fetchOrigin(project.path, project.defaultBaseBranch || "main"); } catch { continue; } @@ -440,7 +440,7 @@ 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 prDetection: Promise<{ number: number; url: string } | null> = (async () => { try { @@ -527,7 +527,7 @@ 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 result = await git.getTaskDiff(task.worktreePath, params.mode, { @@ -564,7 +564,7 @@ async function rebaseTask(params: { taskId: string; projectId: string; compareRe const script = [ `#!/bin/bash`, `echo "Fetching origin..."`, - `git fetch origin --quiet`, + `git fetch origin ${baseBranch} --quiet`, `echo "Rebasing on ${rebaseTarget}..."`, `set -x`, `git rebase ${rebaseTarget}`, @@ -605,7 +605,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"; From 921b3a14aaec127ca78a69ac76e0af3dd645f7ba Mon Sep 17 00:00:00 2001 From: Ittai Zeidman Date: Sat, 6 Jun 2026 08:06:13 +0300 Subject: [PATCH 2/4] Fix stale-ref bugs introduced by branch-specific fetch narrowing rebaseTask: derive the fetch target from rebaseTarget (which may be a custom compareRef like origin/develop), not just baseBranch. getBranchStatusImpl / getTaskDiff: when params.compareRef points at a different origin branch, fetch that branch too so ahead/behind counts and diffs are computed against a fresh ref. checkMergedBranches: fetch every distinct baseBranch used by the task set, not only project.defaultBaseBranch, so per-task merge detection does not silently compare against a stale remote-tracking ref. --- .../fix-dev3-fetch-origin-branch-specific.md | 2 +- src/bun/rpc-handlers/git-operations.ts | 24 +++++++++++++++++-- 2 files changed, 23 insertions(+), 3 deletions(-) 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 index 4929c831..53ce0ef7 100644 --- 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 @@ -1 +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. +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/rpc-handlers/git-operations.ts b/src/bun/rpc-handlers/git-operations.ts index 6e68ea13..8ad50e1d 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, project.defaultBaseBranch || "main"); + await Promise.all(uniqueBaseBranches.map((b) => git.fetchOrigin(project.path, b))); } catch { continue; } @@ -442,6 +448,10 @@ async function getBranchStatusImpl(params: { taskId: string; projectId: string; log.info("getBranchStatus: fetching origin", { worktreePath: task.worktreePath, baseBranch, branchName: branchForPush }); 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); + } const prDetection: Promise<{ number: number; url: string } | null> = (async () => { try { const ghResult = await github.runGitHub( @@ -528,6 +538,10 @@ async function getTaskDiff(params: { const baseBranch = task.baseBranch || project.defaultBaseBranch || "main"; if (params.mode !== "uncommitted") { 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 +575,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 ${baseBranch} --quiet`, + `git fetch origin ${fetchBranch} --quiet`, `echo "Rebasing on ${rebaseTarget}..."`, `set -x`, `git rebase ${rebaseTarget}`, From 74359ad1e87a7b7f3ae8142b63279654c0a4338a Mon Sep 17 00:00:00 2001 From: Ittai Zeidman Date: Sat, 6 Jun 2026 08:21:39 +0300 Subject: [PATCH 3/4] Serialize concurrent git fetch subprocesses per repo via per-project queue Switching the dedup key from projectPath to projectPath:branch removed the implicit serializer that prevented concurrent git fetch processes for the same repo. Multiple parallel fetches (different branches) race on .git/packed-refs.lock on repos with packed refs (common on large repos where this optimization matters most). fetchProjectQueue chains each new fetch promise off the previous tail for that projectPath so at most one git fetch subprocess runs per repo at a time. Same-branch callers still coalesce via fetchInFlight before reaching the queue, so this only adds sequencing for different-branch concurrent fetches. --- src/bun/git.ts | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/src/bun/git.ts b/src/bun/git.ts index 7c073a70..71689f39 100644 --- a/src/bun/git.ts +++ b/src/bun/git.ts @@ -946,8 +946,13 @@ 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, branch?: string): Promise { @@ -970,7 +975,18 @@ export async function fetchOrigin(projectPath: string, branch?: string): Promise 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(); const cmd = branch ? ["git", "fetch", "origin", branch, "--quiet"] @@ -993,8 +1009,10 @@ export async function fetchOrigin(projectPath: string, branch?: string): Promise }); } return result.ok; - })(); + }); + // 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; @@ -1106,6 +1124,7 @@ export function removeFetchCache(projectPath: string): void { for (const key of fetchLastSuccess.keys()) { if (key.startsWith(projectPath + ":")) fetchLastSuccess.delete(key); } + fetchProjectQueue.delete(projectPath); } /** Reset fetch dedup state — for tests only. */ From 4ff9c6a19a6700094e78f84051b676589624efc8 Mon Sep 17 00:00:00 2001 From: Ittai Zeidman Date: Sat, 6 Jun 2026 08:40:13 +0300 Subject: [PATCH 4/4] Fix stale origin/ ref and _resetFetchState missing queue clear getBranchStatusImpl: also fetch origin/ (branchForPush) so getUnpushedCount sees out-of-band remote pushes to the task branch. The old full fetch refreshed this ref as a side effect; the narrowed fetch did not. _resetFetchState: add fetchProjectQueue.clear() to prevent cross-test isolation issues from stale queue tails (test-only helper). --- src/bun/git.ts | 1 + src/bun/rpc-handlers/git-operations.ts | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/src/bun/git.ts b/src/bun/git.ts index 71689f39..cb4a5ea8 100644 --- a/src/bun/git.ts +++ b/src/bun/git.ts @@ -1131,6 +1131,7 @@ export function removeFetchCache(projectPath: string): void { 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 8ad50e1d..90d65d9b 100644 --- a/src/bun/rpc-handlers/git-operations.ts +++ b/src/bun/rpc-handlers/git-operations.ts @@ -452,6 +452,10 @@ async function getBranchStatusImpl(params: { taskId: string; projectId: string; 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(