From 6a36db99248c7ab750c637531035154932a863b7 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 19 Apr 2026 13:33:18 +0700 Subject: [PATCH] feat(server): invalidate GitHub cache after local git mutations Pass the GitHub service into pullCurrentBranch, pushCurrentBranch, createPullRequest, branch setup, and worktree archival, and invalidate the cached PR status / repo view whenever a mutation succeeds. Have mergeToBase return the worktree cwd it actually touched so the session can invalidate the base checkout when the merge lands there. --- .../src/server/paseo-worktree-service.ts | 1 + packages/server/src/server/session.ts | 9 ++- .../src/server/worktree-session.test.ts | 60 +++++++++++++++++++ .../server/src/server/worktree-session.ts | 9 +++ .../server/src/utils/checkout-git.test.ts | 47 +++++++++++++++ packages/server/src/utils/checkout-git.ts | 17 ++++-- 6 files changed, 136 insertions(+), 7 deletions(-) diff --git a/packages/server/src/server/paseo-worktree-service.ts b/packages/server/src/server/paseo-worktree-service.ts index 9bae5e38e..44df5cdf7 100644 --- a/packages/server/src/server/paseo-worktree-service.ts +++ b/packages/server/src/server/paseo-worktree-service.ts @@ -58,6 +58,7 @@ export async function createPaseoWorktree( deps, }); + deps.github.invalidate({ cwd: createdWorktree.worktree.worktreePath }); await deps.primeWorkspaceGitWatchFingerprints(workspace); await deps.broadcastWorkspaceUpdate(workspace.workspaceId); diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 05d0f3da6..c4d51b2b4 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -3024,6 +3024,7 @@ export class Session { this.createPaseoWorktree(input, serviceOptions), checkoutExistingBranch: (cwd, branch) => this.checkoutExistingBranch(cwd, branch), createBranchFromBase: (params) => this.createBranchFromBase(params), + github: this.github, }, config, gitOptions, @@ -4341,6 +4342,7 @@ export class Session { try { await this.checkoutExistingBranch(cwd, branch); + this.github.invalidate({ cwd }); this.checkoutDiffManager.scheduleRefreshForCwd(cwd); // Push a workspace_update immediately so the sidebar/header reflect @@ -4551,7 +4553,7 @@ export class Session { baseRef = baseRef.slice("origin/".length); } - await mergeToBase( + const mutatedCwd = await mergeToBase( cwd, { baseRef, @@ -4559,6 +4561,7 @@ export class Session { }, { paseoHome: this.paseoHome }, ); + this.github.invalidate({ cwd: mutatedCwd }); this.checkoutDiffManager.scheduleRefreshForCwd(cwd); this.emit({ @@ -4603,6 +4606,7 @@ export class Session { baseRef: msg.baseRef, requireCleanTarget: msg.requireCleanTarget ?? true, }); + this.github.invalidate({ cwd }); this.checkoutDiffManager.scheduleRefreshForCwd(cwd); this.emit({ @@ -4634,6 +4638,7 @@ export class Session { try { await pullCurrentBranch(cwd); + this.github.invalidate({ cwd }); this.checkoutDiffManager.scheduleRefreshForCwd(cwd); this.emit({ @@ -4665,6 +4670,7 @@ export class Session { try { await pushCurrentBranch(cwd); + this.github.invalidate({ cwd }); this.emit({ type: "checkout_push_response", payload: { @@ -4790,6 +4796,7 @@ export class Session { return handleWorktreeArchiveRequest( { paseoHome: this.paseoHome, + github: this.github, agentManager: this.agentManager, agentStorage: this.agentStorage, archiveWorkspaceRecord: async (workspaceDirectory) => { diff --git a/packages/server/src/server/worktree-session.test.ts b/packages/server/src/server/worktree-session.test.ts index 695cee905..be99375aa 100644 --- a/packages/server/src/server/worktree-session.test.ts +++ b/packages/server/src/server/worktree-session.test.ts @@ -1105,6 +1105,63 @@ describe("handleCreatePaseoWorktreeRequest", () => { expect(path.basename(result.worktreeBootstrap?.worktree.worktreePath ?? "")).toBe("feature-x"); }); + test("buildAgentSessionConfig invalidates GitHub cache after branch setup mutations", async () => { + const invalidate = vi.fn(); + const createBranchFromBase = vi.fn(async () => {}); + const checkoutExistingBranch = vi.fn(async () => {}); + const createPaseoWorktree = vi.fn(async () => { + throw new Error("should not create worktree"); + }); + + await buildAgentSessionConfig( + { + sessionLogger: createLogger(), + createPaseoWorktree, + checkoutExistingBranch, + createBranchFromBase, + github: { invalidate }, + }, + { + provider: "codex", + cwd: "/tmp/repo", + }, + { + createNewBranch: true, + baseBranch: "main", + newBranchName: "feature-x", + }, + ); + + expect(createBranchFromBase).toHaveBeenCalledWith({ + cwd: "/tmp/repo", + baseBranch: "main", + newBranchName: "feature-x", + }); + expect(invalidate).toHaveBeenCalledWith({ cwd: "/tmp/repo" }); + + invalidate.mockClear(); + + await buildAgentSessionConfig( + { + sessionLogger: createLogger(), + createPaseoWorktree, + checkoutExistingBranch, + createBranchFromBase, + github: { invalidate }, + }, + { + provider: "codex", + cwd: "/tmp/repo", + }, + { + baseBranch: "release", + }, + ); + + expect(checkoutExistingBranch).toHaveBeenCalledWith("/tmp/repo", "release"); + expect(invalidate).toHaveBeenCalledWith({ cwd: "/tmp/repo" }); + }); + test("createPaseoWorktreeForTest forwards the default branch resolver for branch-off intents", async () => { const { tempDir, repoDir } = createGitRepo(); cleanupPaths.push(tempDir); @@ -1371,6 +1428,7 @@ describe("archivePaseoWorktree", () => { const removedAgents = await archivePaseoWorktree( { paseoHome, + github: createGitHubServiceStub(), agentManager: { listAgents: () => [ createManagedAgentForArchive({ id: "agent-1", cwd: created.worktreePath }), @@ -1427,6 +1485,7 @@ describe("archivePaseoWorktree", () => { await archivePaseoWorktree( { paseoHome, + github: createGitHubServiceStub(), agentManager: { listAgents: () => [], closeAgent: vi.fn(async () => {}), @@ -1475,6 +1534,7 @@ describe("archivePaseoWorktree", () => { await handlePaseoWorktreeArchiveRequest( { paseoHome, + github: createGitHubServiceStub(), agentManager: { listAgents: () => [], closeAgent: vi.fn(async () => {}), diff --git a/packages/server/src/server/worktree-session.ts b/packages/server/src/server/worktree-session.ts index 435481535..971cadd60 100644 --- a/packages/server/src/server/worktree-session.ts +++ b/packages/server/src/server/worktree-session.ts @@ -25,6 +25,7 @@ import { import type { TerminalManager } from "../terminal/terminal-manager.js"; import type { ScriptRouteStore } from "./script-proxy.js"; import type { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js"; +import type { GitHubService } from "../services/github-service.js"; import { getCheckoutStatus, resolveRepositoryDefaultBranch } from "../utils/checkout-git.js"; import { expandTilde } from "../utils/path.js"; import { @@ -80,10 +81,12 @@ type BuildAgentSessionConfigDependencies = { baseBranch: string; newBranchName: string; }) => Promise; + github?: Pick; }; type ArchivePaseoWorktreeDependencies = { paseoHome?: string; + github: GitHubService; agentManager: Pick; agentStorage: Pick; archiveWorkspaceRecord: (workspaceId: string) => Promise; @@ -210,8 +213,10 @@ export async function buildAgentSessionConfig( baseBranch, newBranchName: normalized.newBranchName!, }); + dependencies.github?.invalidate({ cwd }); } else if (normalized.baseBranch) { await dependencies.checkoutExistingBranch(cwd, normalized.baseBranch); + dependencies.github?.invalidate({ cwd }); } return { @@ -477,6 +482,10 @@ export async function archivePaseoWorktree( paseoHome: dependencies.paseoHome, }); + for (const cwd of affectedWorkspaceCwds) { + dependencies.github.invalidate({ cwd }); + } + for (const workspaceId of affectedWorkspaceIds) { try { await dependencies.archiveWorkspaceRecord(workspaceId); diff --git a/packages/server/src/utils/checkout-git.test.ts b/packages/server/src/utils/checkout-git.test.ts index db3815168..da83fec69 100644 --- a/packages/server/src/utils/checkout-git.test.ts +++ b/packages/server/src/utils/checkout-git.test.ts @@ -571,6 +571,36 @@ const x = 1; expect(currentBranch).toBe("feature"); }); + it("reports the base worktree cwd when merge-to-base mutates a separate checkout", async () => { + execSync("git checkout -b develop", { cwd: repoDir }); + writeFileSync(join(repoDir, "develop.txt"), "develop\n"); + execSync("git add develop.txt", { cwd: repoDir }); + execSync("git -c commit.gpgsign=false commit -m 'develop commit'", { cwd: repoDir }); + execSync("git checkout main", { cwd: repoDir }); + + const baseWorktreePath = join(tempDir, "base-worktree"); + execSync(`git worktree add ${baseWorktreePath} develop`, { cwd: repoDir }); + + const featureWorktree = await createLegacyWorktreeForTest({ + branchName: "feature", + cwd: repoDir, + baseBranch: "develop", + worktreeSlug: "feature-worktree", + paseoHome, + }); + + writeFileSync(join(featureWorktree.worktreePath, "feature.txt"), "feature\n"); + execSync("git add feature.txt", { cwd: featureWorktree.worktreePath }); + execSync("git -c commit.gpgsign=false commit -m 'feature commit'", { + cwd: featureWorktree.worktreePath, + }); + + const mutatedCwd = await mergeToBase(featureWorktree.worktreePath, {}, { paseoHome }); + + expect(mutatedCwd).toBe(baseWorktreePath); + expect(mutatedCwd).not.toBe(featureWorktree.worktreePath); + }); + it("merges from the most-ahead base ref (origin/main when it is ahead)", async () => { const remoteDir = join(tempDir, "remote.git"); execSync(`git init --bare -b main ${remoteDir}`); @@ -669,6 +699,23 @@ const x = 1; expect(readFileSync(join(repoDir, "pulled.txt"), "utf8")).toBe("remote\n"); }); + it("invalidates GitHub cache after successful local git mutation paths", async () => { + const remoteDir = join(tempDir, "remote.git"); + execSync(`git init --bare -b main ${remoteDir}`); + execSync(`git remote add origin ${remoteDir}`, { cwd: repoDir }); + execSync("git push -u origin main", { cwd: repoDir }); + + const invalidatedCwds: string[] = []; + const github = createGitHubServiceForStatus(null); + github.invalidate = ({ cwd }) => { + invalidatedCwds.push(cwd); + }; + + await pullCurrentBranch(repoDir, github); + + expect(invalidatedCwds).toEqual([repoDir]); + }); + it("aborts pull on merge conflicts and leaves no merge in progress", async () => { const remoteDir = join(tempDir, "remote.git"); execSync(`git init --bare -b main ${remoteDir}`); diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts index e6d877f37..56e8e6476 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -1573,7 +1573,7 @@ export async function mergeToBase( cwd: string, options: MergeToBaseOptions = {}, context?: CheckoutContext, -): Promise { +): Promise { await requireGitRepo(cwd); const currentBranch = await getCurrentBranch(cwd); const configured = await getConfiguredBaseRefForCwd(cwd, context); @@ -1589,11 +1589,11 @@ export async function mergeToBase( } let normalizedBaseRef = baseRef; normalizedBaseRef = normalizeLocalBranchRefName(normalizedBaseRef); + const currentWorktreeRoot = (await getWorktreeRoot(cwd)) ?? cwd; if (normalizedBaseRef === currentBranch) { - return; + return currentWorktreeRoot; } - const currentWorktreeRoot = (await getWorktreeRoot(cwd)) ?? cwd; const baseWorktree = await getWorktreePathForBranch(cwd, normalizedBaseRef); const operationCwd = baseWorktree ?? currentWorktreeRoot; const isSameCheckout = resolve(operationCwd) === resolve(currentWorktreeRoot); @@ -1681,6 +1681,7 @@ export async function mergeToBase( } } } + return operationCwd; } export async function mergeFromBase( @@ -1776,7 +1777,7 @@ export async function mergeFromBase( } } -export async function pullCurrentBranch(cwd: string): Promise { +export async function pullCurrentBranch(cwd: string, github?: GitHubService): Promise { await requireGitRepo(cwd); const currentBranch = await getCurrentBranch(cwd); if (!currentBranch || currentBranch === "HEAD") { @@ -1788,13 +1789,14 @@ export async function pullCurrentBranch(cwd: string): Promise { } try { await runGitCommand(["pull"], { cwd, timeout: 120_000 }); + github?.invalidate({ cwd }); } catch (error) { await abortGitPullConflictState(cwd); throw error; } } -export async function pushCurrentBranch(cwd: string): Promise { +export async function pushCurrentBranch(cwd: string, github?: GitHubService): Promise { await requireGitRepo(cwd); const currentBranch = await getCurrentBranch(cwd); if (!currentBranch || currentBranch === "HEAD") { @@ -1805,6 +1807,7 @@ export async function pushCurrentBranch(cwd: string): Promise { throw new Error("Remote 'origin' is not configured."); } await runGitCommand(["push", "-u", "origin", currentBranch], { cwd, timeout: 120_000 }); + github?.invalidate({ cwd }); } export interface CreatePullRequestOptions { @@ -1869,7 +1872,7 @@ export async function createPullRequest( await runGitCommand(["push", "-u", "origin", head], { cwd, timeout: 120_000 }); - return github.createPullRequest({ + const result = await github.createPullRequest({ cwd, repo, title: options.title, @@ -1877,6 +1880,8 @@ export async function createPullRequest( head, base: normalizedBase, }); + github.invalidate({ cwd }); + return result; } export async function getPullRequestStatus(