From a5c5ad8e1d57e8be68909d4991170e7773edb664 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 6 Nov 2026 09:10:54 +0100 Subject: [PATCH] fix(diff): validate Git inputs and resolve refresh roots Reuse now accounts for effective attributes, configuration, and canonical worktree blobs as well as filesystem state. Explicit refresh resolves repository identity like subscription creation, and callers await that resolution before acknowledging the command. --- .../src/server/checkout-diff-manager.test.ts | 4 +- .../src/server/checkout-diff-manager.ts | 4 +- packages/server/src/server/diff/git.ts | 109 ++++++++------ packages/server/src/server/diff/index.ts | 4 +- packages/server/src/server/diff/inputs.ts | 70 +++++++++ .../src/server/diff/subscriptions.test.ts | 134 +++++++++++++++++- .../src/server/git-filename-diffs.e2e.test.ts | 52 +++++++ .../session/checkout/checkout-session.test.ts | 2 +- .../session/checkout/checkout-session.ts | 26 ++-- .../server/src/server/wire-compat.test.ts | 2 +- 10 files changed, 334 insertions(+), 73 deletions(-) create mode 100644 packages/server/src/server/diff/inputs.ts diff --git a/packages/server/src/server/checkout-diff-manager.test.ts b/packages/server/src/server/checkout-diff-manager.test.ts index 1335d4abec..da2947c24d 100644 --- a/packages/server/src/server/checkout-diff-manager.test.ts +++ b/packages/server/src/server/checkout-diff-manager.test.ts @@ -180,7 +180,7 @@ test("legacy diffs retain successful bodies when an oversized sibling is removed { path: "b.txt", status: "too_large" }, ]); await rm(join(cwd, "b.txt")); - manager.scheduleRefreshForCwd(cwd); + await manager.scheduleRefreshForCwd(cwd); await expect.poll(() => updates.at(-1)?.error).toBeNull(); expect(updates.at(-1)?.files[0]).toMatchObject({ path: "a.ts", @@ -206,7 +206,7 @@ test("legacy subscriptions reselect the preferred base when a remote ref appears try { expect(subscription.initial.files.map((file) => file.path)).toEqual(["b.ts"]); git("update-ref", "refs/remotes/origin/main", "HEAD"); - manager.scheduleRefreshForCwd(cwd); + await manager.scheduleRefreshForCwd(cwd); await expect.poll(() => updates.at(-1)?.files.length, { timeout: 5000 }).toBe(0); expect(updates.at(-1)).toEqual(await manager.read(params)); } finally { diff --git a/packages/server/src/server/checkout-diff-manager.ts b/packages/server/src/server/checkout-diff-manager.ts index 83ea816ba7..921934e70e 100644 --- a/packages/server/src/server/checkout-diff-manager.ts +++ b/packages/server/src/server/checkout-diff-manager.ts @@ -163,8 +163,8 @@ export class CheckoutDiffManager { return { initial: { cwd, files: [], error: toCheckoutError(error) }, unsubscribe() {} }; } } - scheduleRefreshForCwd(cwd: string): void { - this.streams.invalidateForCwd(expandTilde(cwd)); + scheduleRefreshForCwd(cwd: string): Promise { + return this.streams.invalidateForCwd(expandTilde(cwd)); } getMetrics(): CheckoutDiffMetrics { const metrics = this.streams.getMetrics(); diff --git a/packages/server/src/server/diff/git.ts b/packages/server/src/server/diff/git.ts index da38902b25..5ec552ea7a 100644 --- a/packages/server/src/server/diff/git.ts +++ b/packages/server/src/server/diff/git.ts @@ -1,4 +1,5 @@ import { parseGitRevParsePath } from "../../utils/git-rev-parse-path.js"; +import { readGitInputIdentities } from "./inputs.js"; import { Patches } from "./patches.js"; import { HighlightSources } from "./sources.js"; import { @@ -193,53 +194,11 @@ async function readComparisonList( ...(privateIndex ? { GIT_INDEX_FILE: privateIndex.indexFile } : {}), }, }; - const [raw, stat] = await Promise.all([ - runGitCommand([...args, "--raw", "--no-abbrev", "-z"], options), - runGitCommand([...args, "--numstat", "-z"], options), - ]); - if (raw.truncated || stat.truncated) - throw new Error("Diff entry list exceeded the git output budget"); - const stats = parseStats(stat.stdout); - const fields = raw.stdout.split("\0"); - const files: ListedFile[] = []; - for (let index = 0; index < fields.length - 1; index++) { - const header = fields[index].split(" "); - const code = header[4]?.[0]; - let path = fields[++index]; - let oldPath: string | null = null; - if (code === "R" || code === "C") { - oldPath = path; - path = fields[++index]; - } - const counts = stats.get(path); - // With -w, a raw metadata entry need not have a numstat record. - if (!counts && compare.ignoreWhitespace) continue; - if (!counts) throw new Error(`Missing diff statistics for ${path}`); - let change: DiffEntry["change"] = "modified"; - if (code === "A") change = "added"; - else if (code === "D") change = "deleted"; - else if (oldPath !== null) change = "renamed"; - let identity = header.join(" "); - if (compare.head.kind === "worktree" && code !== "D") { - identity += `:${await readWorktreeIdentity(path, header, options)}`; - } - - files.push({ - entry: { - path, - oldPath, - change, - content: counts.binary ? "binary" : "text", - additions: counts.additions, - deletions: counts.deletions, - body: !counts.binary, - }, - identity, - source: { kind: "tracked" }, - }); - } + const { files, worktreeFiles } = await readTrackedFiles(compare, args, options); if (compare.head.kind === "worktree") files.push(...(await listUntrackedFiles(options))); files.sort((a, b) => a.entry.path.localeCompare(b.entry.path)); + const gitInputs = await readGitInputIdentities(files, worktreeFiles, options); + for (const file of files) file.identity += `:${gitInputs.get(file.entry.path) ?? ""}`; const metrics = { highlightCalls: 0 }; const patches = new Patches(args, options, files); const sources = new HighlightSources({ @@ -319,11 +278,67 @@ async function readComparisonList( }; } +async function readTrackedFiles( + compare: Compare, + args: string[], + options: GitCommandOptions, +): Promise<{ files: ListedFile[]; worktreeFiles: Set }> { + const [raw, stat] = await Promise.all([ + runGitCommand([...args, "--raw", "--no-abbrev", "-z"], options), + runGitCommand([...args, "--numstat", "-z"], options), + ]); + if (raw.truncated || stat.truncated) + throw new Error("Diff entry list exceeded the git output budget"); + const stats = parseStats(stat.stdout); + const fields = raw.stdout.split("\0"); + const files: ListedFile[] = []; + const worktreeFiles = new Set(); + for (let index = 0; index < fields.length - 1; index++) { + const header = fields[index].split(" "); + const code = header[4]?.[0]; + let path = fields[++index]; + let oldPath: string | null = null; + if (code === "R" || code === "C") { + oldPath = path; + path = fields[++index]; + } + const counts = stats.get(path); + // With -w, a raw metadata entry need not have a numstat record. + if (!counts && compare.ignoreWhitespace) continue; + if (!counts) throw new Error(`Missing diff statistics for ${path}`); + let change: DiffEntry["change"] = "modified"; + if (code === "A") change = "added"; + else if (code === "D") change = "deleted"; + else if (oldPath !== null) change = "renamed"; + let identity = header.join(" "); + if (compare.head.kind === "worktree" && code !== "D") { + const worktree = await readWorktreeIdentity(path, header, options); + identity += `:${worktree.identity}`; + if (worktree.regular) worktreeFiles.add(path); + } + + files.push({ + entry: { + path, + oldPath, + change, + content: counts.binary ? "binary" : "text", + additions: counts.additions, + deletions: counts.deletions, + body: !counts.binary, + }, + identity, + source: { kind: "tracked" }, + }); + } + return { files, worktreeFiles }; +} + async function readWorktreeIdentity( path: string, header: string[], options: GitCommandOptions, -): Promise { +): Promise<{ identity: string; regular: boolean }> { const value = await lstat(resolve(options.cwd, path), { bigint: true }); let identity = fileStatIdentity(value); if (value.isDirectory() && (header[0] === ":160000" || header[1] === "160000")) { @@ -333,7 +348,7 @@ async function readWorktreeIdentity( ); identity += `:${submodule.stdout}`; } - return identity; + return { identity, regular: value.isFile() }; } function diffArguments(compare: Compare, baseSha: string, headSha: string): string[] { diff --git a/packages/server/src/server/diff/index.ts b/packages/server/src/server/diff/index.ts index 9ddd586681..3316678f5a 100644 --- a/packages/server/src/server/diff/index.ts +++ b/packages/server/src/server/diff/index.ts @@ -215,8 +215,8 @@ export class DiffSubscriptions { }, }; } - invalidateForCwd(cwd: string): void { - const matches = createRealpathAwarePathMatcher(cwd); + async invalidateForCwd(cwd: string): Promise { + const matches = createRealpathAwarePathMatcher(await worktreeRoot(expandTilde(cwd))); for (const target of this.targets.values()) if (matches(target.compare.cwd)) this.invalidate(target); } diff --git a/packages/server/src/server/diff/inputs.ts b/packages/server/src/server/diff/inputs.ts new file mode 100644 index 0000000000..1c99aec4f3 --- /dev/null +++ b/packages/server/src/server/diff/inputs.ts @@ -0,0 +1,70 @@ +import { createHash } from "node:crypto"; +import { runGitCommand, type GitCommandOptions } from "../../utils/run-git-command.js"; +import type { ListedFile } from "./git.js"; + +// Reuse depends on Git's interpretation, not just filesystem metadata. Ask Git +// for effective attributes/config and filtered blob identities without writing objects. +export async function readGitInputIdentities( + files: ListedFile[], + worktreeFiles: Set, + options: GitCommandOptions, +): Promise> { + const paths = files + .filter((file) => file.entry.content === "text") + .map((file) => file.entry.path); + if (!paths.length) return new Map(); + const regular = paths.filter((path) => worktreeFiles.has(path)); + const [config, attributes, blobs] = await Promise.all([ + runGitCommand(["config", "--null", "--list"], options), + runGitCommand(["check-attr", "--all", "-z", "--stdin"], { + ...options, + input: paths.join("\0") + "\0", + }), + regular.length + ? runGitCommand(["hash-object", "--stdin-paths"], { + ...options, + input: regular.map(quotePath).join("\n") + "\n", + }) + : null, + ]); + if (config.truncated || attributes.truncated || blobs?.truncated) + throw new Error("Git input identity exceeded the output budget"); + const byPath = new Map(); + const fields = attributes.stdout.split("\0"); + if (fields.at(-1) === "") fields.pop(); + if (fields.length % 3) throw new Error("Git returned invalid attributes"); + for (let index = 0; index < fields.length; index += 3) { + const values = byPath.get(fields[index]) ?? []; + values.push(JSON.stringify([fields[index + 1], fields[index + 2]])); + byPath.set(fields[index], values); + } + const hashes = blobs?.stdout.trim().split(/\r?\n/) ?? []; + if (hashes.length !== regular.length) throw new Error("Git returned incomplete blob identities"); + const blobByPath = new Map(regular.map((path, index) => [path, hashes[index]])); + const configIdentity = createHash("sha256").update(config.stdout).digest("hex"); + return new Map( + paths.map((path) => [ + path, + createHash("sha256") + .update( + JSON.stringify([ + configIdentity, + byPath.get(path)?.sort() ?? [], + blobByPath.get(path) ?? null, + ]), + ) + .digest("hex"), + ]), + ); +} + +function quotePath(path: string): string { + let quoted = '"'; + for (const byte of Buffer.from(path)) { + quoted += + byte >= 32 && byte <= 126 && byte !== 34 && byte !== 92 + ? String.fromCharCode(byte) + : `\\${byte.toString(8).padStart(3, "0")}`; + } + return quoted + '"'; +} diff --git a/packages/server/src/server/diff/subscriptions.test.ts b/packages/server/src/server/diff/subscriptions.test.ts index e738386eb5..24ec63843b 100644 --- a/packages/server/src/server/diff/subscriptions.test.ts +++ b/packages/server/src/server/diff/subscriptions.test.ts @@ -176,7 +176,7 @@ test("supersedes an in-flight revision at a batch boundary and ends the replacem if (frame.opcode === DiffOpcode.Bodies && !interrupted) { interrupted = true; await writeFile(join(cwd, "0.ts"), content + "export const added = true;\n"); - subscriptions.invalidateForCwd(cwd); + await subscriptions.invalidateForCwd(cwd); } }, ); @@ -341,7 +341,7 @@ test("a cached replay completes before a live refresh can depend on it", async ( const replay = second.start(); await replayBegan; await writeFile(join(cwd, "a.txt"), "second\n"); - subscriptions.invalidateForCwd(cwd); + await subscriptions.invalidateForCwd(cwd); await new Promise((resolve) => setTimeout(resolve, 250)); resume(); await replay; @@ -404,7 +404,7 @@ test("submodule commits invalidate cached worktree gitlink bodies", async () => git("-C", "sub", "-c", "commit.gpgsign=false", "commit", "-am", "C"); const sha = git("-C", "sub", "rev-parse", "HEAD"); expect((await lstat(sub, { bigint: true })).mtimeNs).toBe(before.mtimeNs); - streams.invalidateForCwd(cwd); + await streams.invalidateForCwd(cwd); await expect .poll(() => frames.some((frame) => frame.opcode === DiffOpcode.End), { timeout: 5000 }) .toBe(true); @@ -463,7 +463,7 @@ test.each([true, false])( expect(after.mtimeNs).toBe(before.mtimeNs); expect(after.size).toBe(before.size); expect(after.ctimeNs).not.toBe(before.ctimeNs); - streams.invalidateForCwd(cwd); + await streams.invalidateForCwd(cwd); await expect .poll(() => frames.some((frame) => frame.opcode === DiffOpcode.End), { timeout: 5000 }) .toBe(true); @@ -573,7 +573,7 @@ test("explicit refresh accepts an equivalent worktree path alias", async () => { ); await subscription.start(); frames.length = 0; - streams.invalidateForCwd(alias); + await streams.invalidateForCwd(alias); await expect .poll(() => frames.some((frame) => frame.opcode === DiffOpcode.End), { timeout: 10000 }) .toBe(true); @@ -584,3 +584,127 @@ test("explicit refresh accepts an equivalent worktree path alias", async () => { await rm(root, { recursive: true, force: true }); } }); + +test("attribute changes refresh interpreted content without rewriting the tracked file", async () => { + const cwd = await mkdtemp(join(tmpdir(), "diff-attribute-input-")); + const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim(); + const streams = new DiffSubscriptions({ + async requestWorkingTreeWatch(root) { + return { repoRoot: root, unsubscribe: async () => {} }; + }, + }); + const frames: DiffFrame[] = []; + try { + git("init", "-b", "main"); + git("config", "user.name", "Test"); + git("config", "user.email", "test@example.com"); + git("config", "core.autocrlf", "false"); + await writeFile(join(cwd, "file.txt"), "base\n"); + git("add", "."); + git("-c", "commit.gpgsign=false", "commit", "-m", "base"); + await writeFile(join(cwd, "file.txt"), "first\r\n"); + const compare = { + cwd, + base: { kind: "rev" as const, rev: "HEAD" }, + head: { kind: "worktree" as const }, + }; + const subscription = await streams.subscribe(compare, async (frame) => { + frames.push(frame); + }); + await subscription.start(); + frames.length = 0; + await writeFile(join(cwd, ".git", "info", "attributes"), "file.txt text eol=lf\n"); + await streams.invalidateForCwd(cwd); + await expect + .poll(() => frames.some((frame) => frame.opcode === DiffOpcode.End), { timeout: 10000 }) + .toBe(true); + const files = frames.flatMap((frame) => + frame.opcode === DiffOpcode.Bodies ? frame.files : [], + ); + expect(files.flatMap((file) => file.hunks.flatMap((hunk) => hunk.lines))).toContainEqual( + expect.objectContaining({ type: "add", content: "first" }), + ); + const { listComparison } = await import("./git.js"); + const fresh = await listComparison(compare); + try { + const record = await fresh.read(fresh.files[0]); + if (record.kind !== "ready") throw new Error("Expected interpreted body"); + expect(files).toEqual([record.body]); + } finally { + await fresh.close(); + } + await subscription.release(); + } finally { + await streams.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("explicit refresh resolves subdirectories without refreshing a nested repository's parent", async () => { + const root = await mkdtemp(join(tmpdir(), "diff-refresh-directory-")); + const cwd = join(root, "repo"); + const directory = join(cwd, "workspace"); + const nested = join(directory, "nested"); + const git = (at: string, ...args: string[]) => + execFileSync("git", args, { cwd: at, encoding: "utf8" }).trim(); + const streams = new DiffSubscriptions({ + async requestWorkingTreeWatch(at) { + return { repoRoot: at, unsubscribe: async () => {} }; + }, + }); + const frames: DiffFrame[] = []; + try { + await mkdir(nested, { recursive: true }); + git(cwd, "init", "-b", "main"); + git(cwd, "config", "user.name", "Test"); + git(cwd, "config", "user.email", "test@example.com"); + await writeFile(join(cwd, "file.txt"), "before\n"); + git(cwd, "add", "."); + git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "base"); + const base = git(cwd, "rev-parse", "HEAD"); + await writeFile(join(cwd, "file.txt"), "after\n"); + git(cwd, "add", "."); + git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "edit"); + const head = git(cwd, "rev-parse", "HEAD"); + git(nested, "init", "-b", "main"); + git(nested, "config", "user.name", "Test"); + git(nested, "config", "user.email", "test@example.com"); + await writeFile(join(nested, "nested.txt"), "nested content\n"); + git(nested, "add", "."); + git(nested, "-c", "commit.gpgsign=false", "commit", "-m", "nested base"); + const nestedHead = git(nested, "rev-parse", "HEAD"); + const subscription = await streams.subscribe( + { cwd: directory, base: { kind: "rev", rev: base }, head: { kind: "rev", rev: head } }, + async (frame) => { + frames.push(frame); + }, + ); + await subscription.start(); + frames.length = 0; + await streams.invalidateForCwd(directory); + await expect + .poll(() => frames.some((frame) => frame.opcode === DiffOpcode.End), { timeout: 10000 }) + .toBe(true); + expect(frames.at(-1)).toMatchObject({ opcode: DiffOpcode.End, error: null, revision: 2 }); + const nestedFrames: DiffFrame[] = []; + const sibling = await streams.subscribe( + { cwd: nested, base: { kind: "empty" }, head: { kind: "rev", rev: nestedHead } }, + async (frame) => { + nestedFrames.push(frame); + }, + ); + await sibling.start(); + nestedFrames.length = 0; + const before = streams.getMetrics().computations; + await streams.invalidateForCwd(nested); + await expect + .poll(() => nestedFrames.some((frame) => frame.opcode === DiffOpcode.End), { timeout: 10000 }) + .toBe(true); + expect(streams.getMetrics().computations).toBe(before + 1); + await sibling.release(); + await subscription.release(); + } finally { + await streams.close(); + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/packages/server/src/server/git-filename-diffs.e2e.test.ts b/packages/server/src/server/git-filename-diffs.e2e.test.ts index 57e7718e1b..3991cb8096 100644 --- a/packages/server/src/server/git-filename-diffs.e2e.test.ts +++ b/packages/server/src/server/git-filename-diffs.e2e.test.ts @@ -142,3 +142,55 @@ test("streamed Changes preserves BOM-prefixed paths and highlighted content", as rmSync(cwd, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); } }); + +test("checkout refresh from a subdirectory updates a pinned streamed comparison", async () => { + const cwd = mkdtempSync(join(tmpdir(), "stream-subdirectory-refresh-")); + const { mkdirSync } = await import("node:fs"); + const workspace = join(cwd, "workspace"); + const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim(); + const daemon = await createTestPaseoDaemon({ agentClients: {} }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + try { + git("init", "-b", "main"); + git("config", "user.name", "Test"); + git("config", "user.email", "test@example.com"); + mkdirSync(workspace); + writeFileSync(join(cwd, "file.txt"), "committed content\n"); + git("add", "."); + git("-c", "commit.gpgsign=false", "commit", "-m", "base"); + const head = git("rev-parse", "HEAD"); + await client.connect(); + const states: import("@getpaseo/client").DiffState[] = []; + const observation = client.observeDiff({ + cwd: workspace, + base: { kind: "empty" }, + head: { kind: "rev", rev: head }, + }); + const unsubscribe = observation.subscribe({ + snapshot: (state) => states.push(state), + update: () => {}, + }); + try { + await expect.poll(() => states.at(-1)?.phase, { timeout: 15000 }).toBe("ready"); + const initial = states.at(-1); + if (initial?.phase !== "ready") throw new Error("Expected initial diff"); + expect(initial.files[0]).toMatchObject({ path: "file.txt", status: "ok" }); + writeFileSync(join(cwd, ".git", "info", "attributes"), "file.txt -diff\n"); + expect(await client.checkoutRefresh(workspace)).toMatchObject({ success: true, error: null }); + await expect + .poll(() => states.at(-1)?.revision, { timeout: 15000 }) + .toBeGreaterThan(initial.revision); + expect(states.at(-1)).toMatchObject({ + phase: "ready", + files: [{ path: "file.txt", status: "binary", hunks: [] }], + }); + } finally { + unsubscribe(); + await observation.release(); + } + } finally { + await client.close(); + await daemon.close(); + rmSync(cwd, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + } +}); diff --git a/packages/server/src/server/session/checkout/checkout-session.test.ts b/packages/server/src/server/session/checkout/checkout-session.test.ts index 2a68b87de1..61c9328e48 100644 --- a/packages/server/src/server/session/checkout/checkout-session.test.ts +++ b/packages/server/src/server/session/checkout/checkout-session.test.ts @@ -78,7 +78,7 @@ function createFakeDiffSubscriber(initial: CheckoutDiffSnapshotPayload) { unsubscribe, }; }, - scheduleRefreshForCwd: (cwd) => { + scheduleRefreshForCwd: async (cwd) => { refreshedCwds.push(cwd); }, }; diff --git a/packages/server/src/server/session/checkout/checkout-session.ts b/packages/server/src/server/session/checkout/checkout-session.ts index 60fda36b01..1611356bc8 100644 --- a/packages/server/src/server/session/checkout/checkout-session.ts +++ b/packages/server/src/server/session/checkout/checkout-session.ts @@ -116,7 +116,7 @@ export interface CheckoutDiffSubscriber { params: CheckoutDiffSubscriptionRequest, listener: (snapshot: CheckoutDiffSnapshotPayload) => void, ): Promise; - scheduleRefreshForCwd(cwd: string): void; + scheduleRefreshForCwd(cwd: string): Promise; } export interface CheckoutSessionOptions { @@ -499,7 +499,7 @@ export class CheckoutSession { includeForge: true, reason: "manual-refresh", }); - this.checkoutDiffManager.scheduleRefreshForCwd(resolvedCwd); + await this.checkoutDiffManager.scheduleRefreshForCwd(resolvedCwd); this.host.emit({ type: "checkout.refresh.response", payload: { @@ -553,8 +553,8 @@ export class CheckoutSession { * Notify the live diff subscriptions that the working tree at `cwd` changed. * Called by the command handlers below after they mutate the repository. */ - private scheduleDiffRefresh(cwd: string): void { - this.checkoutDiffManager.scheduleRefreshForCwd(cwd); + private scheduleDiffRefresh(cwd: string): Promise { + return this.checkoutDiffManager.scheduleRefreshForCwd(cwd); } // --------------------------------------------------------------------------- @@ -568,7 +568,7 @@ export class CheckoutSession { try { const checkoutResult = await this.gitMutation.checkoutExistingBranch(cwd, branch); - this.scheduleDiffRefresh(cwd); + await this.scheduleDiffRefresh(cwd); // Push a workspace_update immediately so the sidebar/header reflect // the new branch name without waiting for the background git watcher. @@ -620,7 +620,7 @@ export class CheckoutSession { try { const result = await this.host.renameCurrentBranch(cwd, branch); await this.gitMutation.notifyGitMutation(cwd, "rename-branch", { invalidateForge: true }); - this.scheduleDiffRefresh(cwd); + await this.scheduleDiffRefresh(cwd); this.host.handleWorkspaceGitBranchSnapshot(cwd, result.currentBranch); // Branch is a git fact derived per-descriptor from each workspace's own @@ -662,7 +662,7 @@ export class CheckoutSession { try { await discardChanges(cwd, paths); await this.gitMutation.notifyGitMutation(cwd, "discard-changes"); - this.scheduleDiffRefresh(cwd); + await this.scheduleDiffRefresh(cwd); this.host.emit({ type: "checkout.discard_changes.response", payload: { cwd, success: true, error: null, requestId }, @@ -689,7 +689,7 @@ export class CheckoutSession { timeout: 120_000, }); await this.gitMutation.notifyGitMutation(cwd, "stash-push"); - this.scheduleDiffRefresh(cwd); + await this.scheduleDiffRefresh(cwd); this.host.emit({ type: "stash_save_response", payload: { cwd, success: true, error: null, requestId }, @@ -712,7 +712,7 @@ export class CheckoutSession { timeout: 120_000, }); await this.gitMutation.notifyGitMutation(cwd, "stash-pop"); - this.scheduleDiffRefresh(cwd); + await this.scheduleDiffRefresh(cwd); this.host.emit({ type: "stash_pop_response", payload: { cwd, success: true, error: null, requestId }, @@ -764,7 +764,7 @@ export class CheckoutSession { addAll: msg.addAll ?? true, }); await this.gitMutation.notifyGitMutation(cwd, "commit-changes"); - this.scheduleDiffRefresh(cwd); + await this.scheduleDiffRefresh(cwd); this.host.emit({ type: "checkout_commit_response", @@ -825,7 +825,7 @@ export class CheckoutSession { this.gitMutation.notifyGitMutation(mutatedCwd, "merge-to-base", { invalidateForge: true }), ...(mutatedCwd !== cwd ? [this.gitMutation.notifyGitMutation(cwd, "merge-to-base")] : []), ]); - this.scheduleDiffRefresh(cwd); + await this.scheduleDiffRefresh(cwd); this.host.emit({ type: "checkout_merge_response", @@ -871,7 +871,7 @@ export class CheckoutSession { { paseoHome: this.paseoHome, worktreesRoot: this.worktreesRoot }, ); await this.gitMutation.notifyGitMutation(cwd, "merge-from-base", { invalidateForge: true }); - this.scheduleDiffRefresh(cwd); + await this.scheduleDiffRefresh(cwd); this.host.emit({ type: "checkout_merge_from_base_response", @@ -903,7 +903,7 @@ export class CheckoutSession { try { await pullCurrentBranch(cwd); await this.gitMutation.notifyGitMutation(cwd, "pull", { invalidateForge: true }); - this.scheduleDiffRefresh(cwd); + await this.scheduleDiffRefresh(cwd); this.host.emit({ type: "checkout_pull_response", diff --git a/packages/server/src/server/wire-compat.test.ts b/packages/server/src/server/wire-compat.test.ts index 9e563ec40b..b13b185047 100644 --- a/packages/server/src/server/wire-compat.test.ts +++ b/packages/server/src/server/wire-compat.test.ts @@ -254,7 +254,7 @@ function createSessionForWireCompatTest(options?: { directorySync: options?.directorySync, scheduleService: {} as SessionOptions["scheduleService"], checkoutDiffManager: { - scheduleRefreshForCwd() {}, + async scheduleRefreshForCwd() {}, onWorkspaceStateMayHaveChanged() {}, } as unknown as SessionOptions["checkoutDiffManager"], github: {