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.
This commit is contained in:
Mohamed Boudra
2026-04-19 14:26:51 +07:00
parent d3393b434a
commit 6a36db9924
6 changed files with 136 additions and 7 deletions
@@ -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);
+8 -1
View File
@@ -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) => {
@@ -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 () => {}),
@@ -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<void>;
github?: Pick<GitHubService, "invalidate">;
};
type ArchivePaseoWorktreeDependencies = {
paseoHome?: string;
github: GitHubService;
agentManager: Pick<AgentManager, "listAgents" | "closeAgent">;
agentStorage: Pick<AgentStorage, "list" | "remove">;
archiveWorkspaceRecord: (workspaceId: string) => Promise<void>;
@@ -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);
@@ -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}`);
+11 -6
View File
@@ -1573,7 +1573,7 @@ export async function mergeToBase(
cwd: string,
options: MergeToBaseOptions = {},
context?: CheckoutContext,
): Promise<void> {
): Promise<string> {
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<void> {
export async function pullCurrentBranch(cwd: string, github?: GitHubService): Promise<void> {
await requireGitRepo(cwd);
const currentBranch = await getCurrentBranch(cwd);
if (!currentBranch || currentBranch === "HEAD") {
@@ -1788,13 +1789,14 @@ export async function pullCurrentBranch(cwd: string): Promise<void> {
}
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<void> {
export async function pushCurrentBranch(cwd: string, github?: GitHubService): Promise<void> {
await requireGitRepo(cwd);
const currentBranch = await getCurrentBranch(cwd);
if (!currentBranch || currentBranch === "HEAD") {
@@ -1805,6 +1807,7 @@ export async function pushCurrentBranch(cwd: string): Promise<void> {
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(