Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Replace `git fetch origin` (full fetch) with `git fetch origin <branch>` 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.
2 changes: 1 addition & 1 deletion src/bun/__tests__/rpc-handlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
65 changes: 48 additions & 17 deletions src/bun/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -946,53 +946,78 @@ export async function isWorktreeDirty(worktreePath: string): Promise<boolean> {
// 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<string, Promise<boolean>>();
const fetchLastSuccess = new Map<string, number>();
const fetchProjectQueue = new Map<string, Promise<void>>();
const FETCH_COOLDOWN_MS = 5_000;

export async function fetchOrigin(projectPath: string): Promise<boolean> {
export async function fetchOrigin(projectPath: string, branch?: string): Promise<boolean> {
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<boolean> = 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);
}
}

Expand All @@ -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;
}
Expand Down Expand Up @@ -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(
Expand Down
34 changes: 29 additions & 5 deletions src/bun/rpc-handlers/git-operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -267,8 +267,14 @@ async function checkMergedBranches(): Promise<void> {

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;
}
Expand Down Expand Up @@ -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/<task-branch> 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(
Expand Down Expand Up @@ -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, {
Expand Down Expand Up @@ -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}`,
Expand Down Expand Up @@ -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";
Expand Down
Loading