mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-06 08:24:46 +00:00
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.
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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<void> {
|
||||
return this.streams.invalidateForCwd(expandTilde(cwd));
|
||||
}
|
||||
getMetrics(): CheckoutDiffMetrics {
|
||||
const metrics = this.streams.getMetrics();
|
||||
|
||||
@@ -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<string> }> {
|
||||
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<string>();
|
||||
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<string> {
|
||||
): 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[] {
|
||||
|
||||
@@ -215,8 +215,8 @@ export class DiffSubscriptions {
|
||||
},
|
||||
};
|
||||
}
|
||||
invalidateForCwd(cwd: string): void {
|
||||
const matches = createRealpathAwarePathMatcher(cwd);
|
||||
async invalidateForCwd(cwd: string): Promise<void> {
|
||||
const matches = createRealpathAwarePathMatcher(await worktreeRoot(expandTilde(cwd)));
|
||||
for (const target of this.targets.values())
|
||||
if (matches(target.compare.cwd)) this.invalidate(target);
|
||||
}
|
||||
|
||||
@@ -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<string>,
|
||||
options: GitCommandOptions,
|
||||
): Promise<Map<string, string>> {
|
||||
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<string, string[]>();
|
||||
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 + '"';
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -78,7 +78,7 @@ function createFakeDiffSubscriber(initial: CheckoutDiffSnapshotPayload) {
|
||||
unsubscribe,
|
||||
};
|
||||
},
|
||||
scheduleRefreshForCwd: (cwd) => {
|
||||
scheduleRefreshForCwd: async (cwd) => {
|
||||
refreshedCwds.push(cwd);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -116,7 +116,7 @@ export interface CheckoutDiffSubscriber {
|
||||
params: CheckoutDiffSubscriptionRequest,
|
||||
listener: (snapshot: CheckoutDiffSnapshotPayload) => void,
|
||||
): Promise<CheckoutDiffSubscription>;
|
||||
scheduleRefreshForCwd(cwd: string): void;
|
||||
scheduleRefreshForCwd(cwd: string): Promise<void>;
|
||||
}
|
||||
|
||||
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<void> {
|
||||
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",
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user