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: {