fix: stop stale checkout diff subscriptions (#2317)
* Fix pending checkout diff subscriptions * Reduce checkout status Git spawns * fix(checkout): own pending diff cancellation Checkout sessions could invalidate a subscription before its shared watch had finished opening. Register targets synchronously and let the diff manager honor cancellation so pending and concurrent subscriptions share one teardown path. --------- Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
This commit is contained in:
co-authored by
Mohamed Boudra
parent
bd937850b3
commit
9952615c33
@@ -510,6 +510,45 @@ describe("checkout git utilities", () => {
|
||||
expect(message).toBe("update file");
|
||||
});
|
||||
|
||||
it("reads the origin URL once when collecting facts for an origin-tracking branch", async () => {
|
||||
setupRemoteTrackingMain(repoDir, tempDir);
|
||||
|
||||
startGitCommandMetrics();
|
||||
const facts = await getCheckoutSnapshotFacts(repoDir, { paseoHome });
|
||||
const metrics = stopGitCommandMetrics();
|
||||
const originUrlCommands = metrics.commands.filter(
|
||||
(command) => command.args.join(" ") === "config --get remote.origin.url",
|
||||
);
|
||||
|
||||
expect(facts.isGit).toBe(true);
|
||||
expect(originUrlCommands).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("reads a non-origin branch remote without replacing it with the origin URL", async () => {
|
||||
setupRemoteTrackingMain(repoDir, tempDir);
|
||||
execFileSync("git", ["remote", "set-url", "origin", "git@github.com:upstream/repo.git"], {
|
||||
cwd: repoDir,
|
||||
});
|
||||
execFileSync("git", ["remote", "add", "fork", "git@github.com:contributor/repo.git"], {
|
||||
cwd: repoDir,
|
||||
});
|
||||
execFileSync("git", ["config", "branch.main.remote", "fork"], { cwd: repoDir });
|
||||
execFileSync("git", ["config", "branch.main.merge", "refs/heads/main"], { cwd: repoDir });
|
||||
|
||||
startGitCommandMetrics();
|
||||
const facts = await getCheckoutSnapshotFacts(repoDir, { paseoHome });
|
||||
const metrics = stopGitCommandMetrics();
|
||||
const commands = metrics.commands.map((command) => command.args.join(" "));
|
||||
|
||||
expect(facts.isGit).toBe(true);
|
||||
expect(commands.filter((command) => command === "config --get remote.origin.url")).toHaveLength(
|
||||
1,
|
||||
);
|
||||
expect(commands.filter((command) => command === "config --get remote.fork.url")).toHaveLength(
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
it("reuses checkout snapshot facts across status, shortstat, and PR status reads", async () => {
|
||||
setupRemoteTrackingMain(repoDir, tempDir);
|
||||
execFileSync("git", ["checkout", "-b", "feature/facts"], { cwd: repoDir });
|
||||
@@ -685,19 +724,34 @@ const x = 1;
|
||||
expect(behindStatus.aheadOfOrigin).toBe(0);
|
||||
expect(behindStatus.behindOfOrigin).toBe(1);
|
||||
|
||||
writeFileSync(join(repoDir, "local.txt"), "local\n");
|
||||
execFileSync("git", ["add", "local.txt"], { cwd: repoDir });
|
||||
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "local update"], {
|
||||
cwd: repoDir,
|
||||
});
|
||||
commitFile(repoDir, "local-1.txt", "local 1\n", "local update 1");
|
||||
commitFile(repoDir, "local-2.txt", "local 2\n", "local update 2");
|
||||
commitFile(repoDir, "local-3.txt", "local 3\n", "local update 3");
|
||||
commitFile(cloneDir, "remote-2.txt", "remote 2\n", "remote update 2");
|
||||
execFileSync("git", ["push"], { cwd: cloneDir });
|
||||
execFileSync("git", ["fetch", "origin"], { cwd: repoDir });
|
||||
|
||||
const facts = await getCheckoutSnapshotFacts(repoDir);
|
||||
startGitCommandMetrics();
|
||||
const divergedStatus = await getCheckoutStatus(repoDir, { facts });
|
||||
const metrics = stopGitCommandMetrics();
|
||||
const upstreamCountCommands = metrics.commands.filter(
|
||||
(command) => command.args[0] === "rev-list" && command.args.join(" ").includes("main"),
|
||||
);
|
||||
|
||||
const divergedStatus = await getCheckoutStatus(repoDir);
|
||||
expect(divergedStatus.isGit).toBe(true);
|
||||
if (!divergedStatus.isGit) {
|
||||
return;
|
||||
}
|
||||
expect(divergedStatus.aheadOfOrigin).toBe(1);
|
||||
expect(divergedStatus.behindOfOrigin).toBe(1);
|
||||
expect(divergedStatus.aheadOfOrigin).toBe(3);
|
||||
expect(divergedStatus.behindOfOrigin).toBe(2);
|
||||
expect(upstreamCountCommands).toHaveLength(1);
|
||||
expect(upstreamCountCommands[0]?.args).toEqual([
|
||||
"rev-list",
|
||||
"--left-right",
|
||||
"--count",
|
||||
"main...origin/main",
|
||||
]);
|
||||
});
|
||||
|
||||
it("reports a PR worktree as not ahead when its branch is pushed to the configured PR remote", async () => {
|
||||
@@ -800,6 +854,7 @@ const x = 1;
|
||||
return;
|
||||
}
|
||||
expect(status.aheadOfOrigin).toBeNull();
|
||||
expect(status.behindOfOrigin).toBeNull();
|
||||
});
|
||||
|
||||
it("does not report full history as unpushed for fresh no-track Paseo worktrees", async () => {
|
||||
@@ -1419,6 +1474,26 @@ const x = 1;
|
||||
expect(diff.diff).toContain("# untracked-large.txt: diff too large omitted");
|
||||
});
|
||||
|
||||
it("resolves the Git common directory once when reading Paseo worktree facts", async () => {
|
||||
const result = await createLegacyWorktreeForTest({
|
||||
branchName: "main",
|
||||
cwd: repoDir,
|
||||
baseBranch: "main",
|
||||
worktreeSlug: "common-dir",
|
||||
paseoHome,
|
||||
});
|
||||
|
||||
startGitCommandMetrics();
|
||||
const facts = await getCheckoutSnapshotFacts(result.worktreePath, { paseoHome });
|
||||
const metrics = stopGitCommandMetrics();
|
||||
const commonDirCommands = metrics.commands.filter(
|
||||
(command) => command.args.join(" ") === "rev-parse --git-common-dir",
|
||||
);
|
||||
|
||||
expect(facts.isGit).toBe(true);
|
||||
expect(commonDirCommands).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("handles status/diff/commit in a .paseo worktree", async () => {
|
||||
const result = await createLegacyWorktreeForTest({
|
||||
branchName: "main",
|
||||
|
||||
@@ -1052,10 +1052,15 @@ type PaseoWorktreeForCwd =
|
||||
| { isPaseoOwnedWorktree: false }
|
||||
| { isPaseoOwnedWorktree: true; worktreeRoot: string };
|
||||
|
||||
interface PaseoWorktreeLookupOptions {
|
||||
context?: CheckoutContext;
|
||||
knownWorktreeRoot?: string | null;
|
||||
knownGitCommonDir?: string | null;
|
||||
}
|
||||
|
||||
async function getPaseoWorktreeForCwd(
|
||||
cwd: string,
|
||||
context?: CheckoutContext,
|
||||
knownWorktreeRoot?: string | null,
|
||||
options: PaseoWorktreeLookupOptions = {},
|
||||
): Promise<PaseoWorktreeForCwd> {
|
||||
// Fast-path reject: non-worktree paths do not need expensive ownership checks.
|
||||
if (!/[\\/]worktrees[\\/]/.test(cwd)) {
|
||||
@@ -1063,8 +1068,9 @@ async function getPaseoWorktreeForCwd(
|
||||
}
|
||||
|
||||
const ownership = await isPaseoOwnedWorktreeCwd(cwd, {
|
||||
paseoHome: context?.paseoHome,
|
||||
worktreesRoot: context?.worktreesRoot,
|
||||
paseoHome: options.context?.paseoHome,
|
||||
worktreesRoot: options.context?.worktreesRoot,
|
||||
knownGitCommonDir: options.knownGitCommonDir,
|
||||
});
|
||||
if (!ownership.allowed) {
|
||||
return { isPaseoOwnedWorktree: false };
|
||||
@@ -1072,7 +1078,7 @@ async function getPaseoWorktreeForCwd(
|
||||
|
||||
return {
|
||||
isPaseoOwnedWorktree: true,
|
||||
worktreeRoot: knownWorktreeRoot ?? (await getWorktreeRoot(cwd, context)) ?? cwd,
|
||||
worktreeRoot: options.knownWorktreeRoot ?? (await getWorktreeRoot(cwd, options.context)) ?? cwd,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1087,7 +1093,7 @@ async function getStoredBaseRefForCwd(
|
||||
if (context?.facts?.isGit) {
|
||||
return context.facts.storedBaseRef;
|
||||
}
|
||||
const paseoWorktree = await getPaseoWorktreeForCwd(cwd, context);
|
||||
const paseoWorktree = await getPaseoWorktreeForCwd(cwd, { context });
|
||||
if (!paseoWorktree.isPaseoOwnedWorktree) {
|
||||
return null;
|
||||
}
|
||||
@@ -1492,30 +1498,6 @@ async function getAheadBehind(
|
||||
return { ahead, behind };
|
||||
}
|
||||
|
||||
async function getAheadOfOrigin(
|
||||
cwd: string,
|
||||
currentBranch: string,
|
||||
context?: CheckoutContext,
|
||||
): Promise<number | null> {
|
||||
if (!currentBranch) {
|
||||
return null;
|
||||
}
|
||||
const upstreamRef = await getConfiguredUpstreamRef(cwd, currentBranch, context);
|
||||
if (!upstreamRef) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const { stdout } = await runGitCommand(
|
||||
["rev-list", "--count", `${upstreamRef}..${currentBranch}`],
|
||||
{ cwd, envOverlay: READ_ONLY_GIT_ENV, logger: context?.logger },
|
||||
);
|
||||
const count = Number.parseInt(stdout.trim(), 10);
|
||||
return Number.isNaN(count) ? null : count;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function getConfiguredUpstreamRef(
|
||||
cwd: string,
|
||||
currentBranch: string,
|
||||
@@ -1537,11 +1519,11 @@ async function getConfiguredUpstreamRef(
|
||||
return upstreamBranch ? `${remoteName}/${upstreamBranch}` : null;
|
||||
}
|
||||
|
||||
async function getBehindOfOrigin(
|
||||
async function getOriginAheadBehind(
|
||||
cwd: string,
|
||||
currentBranch: string,
|
||||
context?: CheckoutContext,
|
||||
): Promise<number | null> {
|
||||
): Promise<AheadBehind | null> {
|
||||
if (!currentBranch) {
|
||||
return null;
|
||||
}
|
||||
@@ -1551,11 +1533,13 @@ async function getBehindOfOrigin(
|
||||
}
|
||||
try {
|
||||
const { stdout } = await runGitCommand(
|
||||
["rev-list", "--count", `${currentBranch}..${upstreamRef}`],
|
||||
["rev-list", "--left-right", "--count", `${currentBranch}...${upstreamRef}`],
|
||||
{ cwd, envOverlay: READ_ONLY_GIT_ENV, logger: context?.logger },
|
||||
);
|
||||
const count = Number.parseInt(stdout.trim(), 10);
|
||||
return Number.isNaN(count) ? null : count;
|
||||
const [aheadRaw, behindRaw] = stdout.trim().split(/\s+/);
|
||||
const ahead = Number.parseInt(aheadRaw ?? "", 10);
|
||||
const behind = Number.parseInt(behindRaw ?? "", 10);
|
||||
return Number.isNaN(ahead) || Number.isNaN(behind) ? null : { ahead, behind };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -1579,15 +1563,17 @@ async function inspectCheckoutContext(
|
||||
return null;
|
||||
}
|
||||
|
||||
const [currentBranch, remoteUrl, absoluteGitDir, gitCommonDir, paseoWorktree] = await Promise.all(
|
||||
[
|
||||
getCurrentBranch(cwd),
|
||||
getOriginRemoteUrl(cwd),
|
||||
resolveAbsoluteGitDir(cwd),
|
||||
resolveGitCommonDir(cwd),
|
||||
getPaseoWorktreeForCwd(cwd, context, root),
|
||||
],
|
||||
);
|
||||
const [currentBranch, remoteUrl, absoluteGitDir, gitCommonDir] = await Promise.all([
|
||||
getCurrentBranch(cwd),
|
||||
getOriginRemoteUrl(cwd),
|
||||
resolveAbsoluteGitDir(cwd),
|
||||
resolveGitCommonDir(cwd),
|
||||
]);
|
||||
const paseoWorktree = await getPaseoWorktreeForCwd(cwd, {
|
||||
context,
|
||||
knownWorktreeRoot: root,
|
||||
knownGitCommonDir: gitCommonDir,
|
||||
});
|
||||
|
||||
return {
|
||||
worktreeRoot: root,
|
||||
@@ -1776,7 +1762,9 @@ export async function getCheckoutSnapshotFacts(
|
||||
if (branchRemoteName) {
|
||||
[branchMergeRef, branchRemoteUrl] = await Promise.all([
|
||||
getGitConfigValue(cwd, `branch.${inspected.currentBranch}.merge`, context),
|
||||
getGitConfigValue(cwd, `remote.${branchRemoteName}.url`, context),
|
||||
branchRemoteName === "origin"
|
||||
? inspected.remoteUrl
|
||||
: getGitConfigValue(cwd, `remote.${branchRemoteName}.url`, context),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1955,17 +1943,16 @@ export async function getCheckoutStatus(
|
||||
const baseRef = facts.resolvedBaseRef;
|
||||
const mainRepoRoot = facts.mainRepoRoot;
|
||||
const factsContext = { ...context, facts };
|
||||
const [aheadBehind, aheadOfOrigin, behindOfOrigin] = await Promise.all([
|
||||
const [aheadBehind, originAheadBehind] = await Promise.all([
|
||||
baseRef && currentBranch
|
||||
? getAheadBehind(cwd, baseRef, currentBranch, factsContext)
|
||||
: Promise.resolve(null),
|
||||
hasRemote && currentBranch
|
||||
? getAheadOfOrigin(cwd, currentBranch, factsContext)
|
||||
: Promise.resolve(null),
|
||||
hasRemote && currentBranch
|
||||
? getBehindOfOrigin(cwd, currentBranch, factsContext)
|
||||
? getOriginAheadBehind(cwd, currentBranch, factsContext)
|
||||
: Promise.resolve(null),
|
||||
]);
|
||||
const aheadOfOrigin = originAheadBehind?.ahead ?? null;
|
||||
const behindOfOrigin = originAheadBehind?.behind ?? null;
|
||||
|
||||
if (paseoWorktree.isPaseoOwnedWorktree && baseRef) {
|
||||
return {
|
||||
|
||||
@@ -155,6 +155,10 @@ export interface PaseoWorktreeOwnership {
|
||||
worktreePath?: string;
|
||||
}
|
||||
|
||||
export interface PaseoWorktreeOwnershipOptions extends WorktreeRootOptions {
|
||||
knownGitCommonDir?: string | null;
|
||||
}
|
||||
|
||||
export interface WorktreeRootOptions {
|
||||
paseoHome?: string;
|
||||
worktreesRoot?: string;
|
||||
@@ -913,7 +917,7 @@ function resolveRepoRootFromGitCommonDir(commonDir: string): string {
|
||||
|
||||
export async function isPaseoOwnedWorktreeCwd(
|
||||
cwd: string,
|
||||
options?: WorktreeRootOptions,
|
||||
options?: PaseoWorktreeOwnershipOptions,
|
||||
): Promise<PaseoWorktreeOwnership> {
|
||||
const resolvedCwd = normalizePathForOwnership(cwd);
|
||||
|
||||
@@ -921,11 +925,15 @@ export async function isPaseoOwnedWorktreeCwd(
|
||||
// previous archive attempt removed the admin dir before the working tree
|
||||
// could be fully cleaned up). We still want to allow archiving in that case.
|
||||
let repoRoot: string | undefined;
|
||||
try {
|
||||
const gitCommonDir = await getGitCommonDir(cwd);
|
||||
repoRoot = resolveRepoRootFromGitCommonDir(gitCommonDir);
|
||||
} catch {
|
||||
// ignore
|
||||
if (options?.knownGitCommonDir) {
|
||||
repoRoot = resolveRepoRootFromGitCommonDir(options.knownGitCommonDir);
|
||||
} else if (options?.knownGitCommonDir === undefined) {
|
||||
try {
|
||||
const gitCommonDir = await getGitCommonDir(cwd);
|
||||
repoRoot = resolveRepoRootFromGitCommonDir(gitCommonDir);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
const worktreesBaseRoot = resolvePaseoWorktreesBaseRoot(options);
|
||||
|
||||
Reference in New Issue
Block a user