Keep shared-worktree fetches proportional to ref changes (#3078)
* fix(server): bound shared-worktree fetch refreshes * fix(server): preserve buffered git metadata events * fix(server): cover remote ref selection changes * fix(server): preserve narrow fetch echo routing * fix(server): preserve ref updates during refresh Keep normal refresh requeue semantics for watcher events that arrive while a ref-derived calculation is active, so a second same-ref movement cannot be mistaken for the original fetch echo. * fix(server): narrow external fetch ref updates Use authoritative fetch boundaries to separate watcher echoes from outside events, and retain remote ref names so packed-to-loose materialization from other Git clients stays narrow without weakening add/delete fallbacks.
This commit is contained in:
@@ -0,0 +1,78 @@
|
|||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
diffWorkspaceGitRefs,
|
||||||
|
diffWorkspaceGitRemoteRefs,
|
||||||
|
parseWorkspaceGitRefs,
|
||||||
|
} from "./workspace-git-fetch.js";
|
||||||
|
|
||||||
|
describe("workspace Git remote refs", () => {
|
||||||
|
test("parses refs for semantic before/after comparison", () => {
|
||||||
|
expect(
|
||||||
|
parseWorkspaceGitRefs(
|
||||||
|
[
|
||||||
|
"refs/remotes/origin/main\0aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||||
|
"refs/remotes/upstream/topic\0bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
||||||
|
"refs/heads/main\0cccccccccccccccccccccccccccccccccccccccc",
|
||||||
|
].join("\n"),
|
||||||
|
),
|
||||||
|
).toEqual(
|
||||||
|
new Map([
|
||||||
|
["refs/remotes/origin/main", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"],
|
||||||
|
["refs/remotes/upstream/topic", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"],
|
||||||
|
["refs/heads/main", "cccccccccccccccccccccccccccccccccccccccc"],
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("distinguishes remote changes from concurrent local ref changes", () => {
|
||||||
|
expect(
|
||||||
|
diffWorkspaceGitRefs(
|
||||||
|
new Map([
|
||||||
|
["refs/remotes/origin/main", "aaaaaaaa"],
|
||||||
|
["refs/heads/topic", "bbbbbbbb"],
|
||||||
|
]),
|
||||||
|
new Map([
|
||||||
|
["refs/remotes/origin/main", "cccccccc"],
|
||||||
|
["refs/heads/topic", "dddddddd"],
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
).toEqual({
|
||||||
|
remoteChanges: [
|
||||||
|
{
|
||||||
|
kind: "moved",
|
||||||
|
ref: "origin/main",
|
||||||
|
beforeOid: "aaaaaaaa",
|
||||||
|
afterOid: "cccccccc",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
nonRemoteRefsChanged: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reports added, changed, and deleted remote refs", () => {
|
||||||
|
expect(
|
||||||
|
diffWorkspaceGitRemoteRefs(
|
||||||
|
new Map([
|
||||||
|
["origin/main", "aaaaaaaa"],
|
||||||
|
["origin/deleted", "bbbbbbbb"],
|
||||||
|
["origin/unchanged", "cccccccc"],
|
||||||
|
]),
|
||||||
|
new Map([
|
||||||
|
["origin/main", "dddddddd"],
|
||||||
|
["origin/added", "eeeeeeee"],
|
||||||
|
["origin/unchanged", "cccccccc"],
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
).toEqual([
|
||||||
|
{
|
||||||
|
kind: "moved",
|
||||||
|
ref: "origin/main",
|
||||||
|
beforeOid: "aaaaaaaa",
|
||||||
|
afterOid: "dddddddd",
|
||||||
|
},
|
||||||
|
{ kind: "added", ref: "origin/added", afterOid: "eeeeeeee" },
|
||||||
|
{ kind: "deleted", ref: "origin/deleted", beforeOid: "bbbbbbbb" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import { runGitCommand } from "../utils/run-git-command.js";
|
||||||
|
|
||||||
|
export interface WorkspaceGitFetchResult {
|
||||||
|
changes: WorkspaceGitRemoteRefChange[] | null;
|
||||||
|
nonRemoteRefsChanged?: boolean;
|
||||||
|
remoteRefs?: ReadonlySet<string>;
|
||||||
|
error: unknown | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkspaceGitFetchObserver {
|
||||||
|
onRefSnapshot(phase: "before" | "after"): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WorkspaceGitRemoteRefChange =
|
||||||
|
| { kind: "moved"; ref: string; beforeOid: string; afterOid: string }
|
||||||
|
| { kind: "added"; ref: string; afterOid: string }
|
||||||
|
| { kind: "deleted"; ref: string; beforeOid: string };
|
||||||
|
|
||||||
|
export async function fetchWorkspaceGitRemote(
|
||||||
|
cwd: string,
|
||||||
|
observer: WorkspaceGitFetchObserver,
|
||||||
|
): Promise<WorkspaceGitFetchResult> {
|
||||||
|
let before: Map<string, string> | null = null;
|
||||||
|
let after: Map<string, string> | null = null;
|
||||||
|
let error: unknown | null = null;
|
||||||
|
try {
|
||||||
|
before = await readWorkspaceGitRefs(cwd);
|
||||||
|
observer.onRefSnapshot("before");
|
||||||
|
} catch (caught) {
|
||||||
|
error = caught;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await runGitCommand(["fetch", "origin", "--prune"], {
|
||||||
|
cwd,
|
||||||
|
envOverlay: { GIT_TERMINAL_PROMPT: "0" },
|
||||||
|
timeout: 120_000,
|
||||||
|
});
|
||||||
|
} catch (caught) {
|
||||||
|
error ??= caught;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
after = await readWorkspaceGitRefs(cwd);
|
||||||
|
} catch (caught) {
|
||||||
|
error ??= caught;
|
||||||
|
}
|
||||||
|
if (!before || !after) {
|
||||||
|
return { changes: null, error };
|
||||||
|
}
|
||||||
|
const diff = diffWorkspaceGitRefs(before, after);
|
||||||
|
const result = {
|
||||||
|
changes: diff.remoteChanges,
|
||||||
|
nonRemoteRefsChanged: diff.nonRemoteRefsChanged,
|
||||||
|
remoteRefs: collectWorkspaceGitRemoteRefs(after),
|
||||||
|
error,
|
||||||
|
} satisfies WorkspaceGitFetchResult;
|
||||||
|
observer.onRefSnapshot("after");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectWorkspaceGitRemoteRefs(refs: ReadonlyMap<string, string>): Set<string> {
|
||||||
|
const remotePrefix = "refs/remotes/";
|
||||||
|
return new Set(
|
||||||
|
[...refs.keys()]
|
||||||
|
.filter((ref) => ref.startsWith(remotePrefix))
|
||||||
|
.map((ref) => ref.slice(remotePrefix.length)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readWorkspaceGitRefs(cwd: string): Promise<Map<string, string>> {
|
||||||
|
const { stdout } = await runGitCommand(["for-each-ref", "--format=%(refname)%00%(objectname)"], {
|
||||||
|
cwd,
|
||||||
|
});
|
||||||
|
return parseWorkspaceGitRefs(stdout);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseWorkspaceGitRefs(stdout: string): Map<string, string> {
|
||||||
|
const refs = new Map<string, string>();
|
||||||
|
for (const line of stdout.split("\n")) {
|
||||||
|
const [ref, objectId] = line.split("\0");
|
||||||
|
if (ref?.startsWith("refs/") && objectId) {
|
||||||
|
refs.set(ref, objectId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return refs;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkspaceGitRefDiff {
|
||||||
|
remoteChanges: WorkspaceGitRemoteRefChange[];
|
||||||
|
nonRemoteRefsChanged: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function diffWorkspaceGitRefs(
|
||||||
|
before: ReadonlyMap<string, string>,
|
||||||
|
after: ReadonlyMap<string, string>,
|
||||||
|
): WorkspaceGitRefDiff {
|
||||||
|
const remotePrefix = "refs/remotes/";
|
||||||
|
const beforeRemote = new Map<string, string>();
|
||||||
|
const afterRemote = new Map<string, string>();
|
||||||
|
let nonRemoteRefsChanged = false;
|
||||||
|
for (const [ref, objectId] of before) {
|
||||||
|
if (ref.startsWith(remotePrefix)) {
|
||||||
|
beforeRemote.set(ref.slice(remotePrefix.length), objectId);
|
||||||
|
} else if (after.get(ref) !== objectId) {
|
||||||
|
nonRemoteRefsChanged = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [ref, objectId] of after) {
|
||||||
|
if (ref.startsWith(remotePrefix)) {
|
||||||
|
afterRemote.set(ref.slice(remotePrefix.length), objectId);
|
||||||
|
} else if (!before.has(ref)) {
|
||||||
|
nonRemoteRefsChanged = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
remoteChanges: diffWorkspaceGitRemoteRefs(beforeRemote, afterRemote),
|
||||||
|
nonRemoteRefsChanged,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function diffWorkspaceGitRemoteRefs(
|
||||||
|
before: ReadonlyMap<string, string>,
|
||||||
|
after: ReadonlyMap<string, string>,
|
||||||
|
): WorkspaceGitRemoteRefChange[] {
|
||||||
|
const changes: WorkspaceGitRemoteRefChange[] = [];
|
||||||
|
for (const [ref, objectId] of after) {
|
||||||
|
const beforeOid = before.get(ref);
|
||||||
|
if (beforeOid === undefined) {
|
||||||
|
changes.push({ kind: "added", ref, afterOid: objectId });
|
||||||
|
} else if (beforeOid !== objectId) {
|
||||||
|
changes.push({ kind: "moved", ref, beforeOid, afterOid: objectId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [ref, beforeOid] of before) {
|
||||||
|
if (!after.has(ref)) {
|
||||||
|
changes.push({ kind: "deleted", ref, beforeOid });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return changes;
|
||||||
|
}
|
||||||
@@ -0,0 +1,466 @@
|
|||||||
|
import { execFile, execFileSync } from "node:child_process";
|
||||||
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
import pino from "pino";
|
||||||
|
import { afterAll, beforeAll, expect, test } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
getGitCommandMetrics,
|
||||||
|
startGitCommandMetrics,
|
||||||
|
stopGitCommandMetrics,
|
||||||
|
waitForGitCommandMetricsIdle,
|
||||||
|
} from "../utils/run-git-command.js";
|
||||||
|
import { fetchWorkspaceGitRemote } from "./workspace-git-fetch.js";
|
||||||
|
import {
|
||||||
|
WorkspaceGitServiceImpl,
|
||||||
|
getWorkspaceGitSelfHealPhaseMs,
|
||||||
|
} from "./workspace-git-service.js";
|
||||||
|
|
||||||
|
const SIBLING_COUNT = 59;
|
||||||
|
const REMOTE_REF_COUNT = 19_500;
|
||||||
|
const cleanupPaths: string[] = [];
|
||||||
|
let fixture: ReturnType<typeof seedFetchFixture>;
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
fixture = seedFetchFixture();
|
||||||
|
}, 120_000);
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
for (const path of cleanupPaths.splice(0)) {
|
||||||
|
rmSync(path, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}, 120_000);
|
||||||
|
|
||||||
|
function git(cwd: string, args: string[], input?: string): string {
|
||||||
|
return execFileSync("git", args, {
|
||||||
|
cwd,
|
||||||
|
encoding: "utf8",
|
||||||
|
input,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
GIT_CONFIG_NOSYSTEM: "1",
|
||||||
|
GIT_TERMINAL_PROMPT: "0",
|
||||||
|
},
|
||||||
|
}).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function gitAsync(cwd: string, args: string[]): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
execFile(
|
||||||
|
"git",
|
||||||
|
args,
|
||||||
|
{
|
||||||
|
cwd,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
GIT_CONFIG_NOSYSTEM: "1",
|
||||||
|
GIT_TERMINAL_PROMPT: "0",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
if (error) reject(error);
|
||||||
|
else resolve();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDeferred<T>() {
|
||||||
|
let resolve!: (value: T | PromiseLike<T>) => void;
|
||||||
|
let reject!: (reason?: unknown) => void;
|
||||||
|
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
||||||
|
resolve = resolvePromise;
|
||||||
|
reject = rejectPromise;
|
||||||
|
});
|
||||||
|
return { promise, reject, resolve };
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasSubmittedGitOperation(operation: string): boolean {
|
||||||
|
return getGitCommandMetrics().submissions.some((command) => command.args[0] === operation);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForCondition(
|
||||||
|
description: string,
|
||||||
|
condition: () => boolean,
|
||||||
|
timeoutMs = 120_000,
|
||||||
|
): Promise<void> {
|
||||||
|
const startedAt = Date.now();
|
||||||
|
while (!condition()) {
|
||||||
|
if (Date.now() - startedAt >= timeoutMs) {
|
||||||
|
throw new Error(`Timed out waiting for ${description}`);
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedFetchFixture(): {
|
||||||
|
originRoot: string;
|
||||||
|
paseoHome: string;
|
||||||
|
repoRoot: string;
|
||||||
|
worktrees: string[];
|
||||||
|
} {
|
||||||
|
const fixtureRoot = mkdtempSync(join(tmpdir(), "paseo-noop-fetch-"));
|
||||||
|
cleanupPaths.push(fixtureRoot);
|
||||||
|
const repoRoot = join(fixtureRoot, "repo");
|
||||||
|
const originRoot = join(fixtureRoot, "origin.git");
|
||||||
|
const paseoHome = join(fixtureRoot, "paseo-home");
|
||||||
|
const worktreesRoot = join(fixtureRoot, "worktrees");
|
||||||
|
mkdirSync(repoRoot, { recursive: true });
|
||||||
|
mkdirSync(paseoHome, { recursive: true });
|
||||||
|
mkdirSync(worktreesRoot, { recursive: true });
|
||||||
|
|
||||||
|
git(repoRoot, ["init", "-b", "main"]);
|
||||||
|
git(repoRoot, ["config", "user.email", "repro@example.com"]);
|
||||||
|
git(repoRoot, ["config", "user.name", "Repro"]);
|
||||||
|
writeFileSync(join(repoRoot, "README.md"), "no-op fetch repro\n");
|
||||||
|
git(repoRoot, ["add", "README.md"]);
|
||||||
|
git(repoRoot, ["commit", "-m", "initial"]);
|
||||||
|
|
||||||
|
git(fixtureRoot, ["init", "--bare", originRoot]);
|
||||||
|
git(repoRoot, ["remote", "add", "origin", originRoot]);
|
||||||
|
git(repoRoot, ["push", "--quiet", "-u", "origin", "main"]);
|
||||||
|
|
||||||
|
const head = git(repoRoot, ["rev-parse", "HEAD"]);
|
||||||
|
const remoteRefUpdates = Array.from(
|
||||||
|
{ length: REMOTE_REF_COUNT },
|
||||||
|
(_, index) => `update refs/heads/load/${index.toString().padStart(5, "0")} ${head}`,
|
||||||
|
).join("\n");
|
||||||
|
git(originRoot, ["update-ref", "--stdin"], `${remoteRefUpdates}\n`);
|
||||||
|
git(originRoot, ["pack-refs", "--all", "--prune"]);
|
||||||
|
git(repoRoot, ["fetch", "--quiet", "origin", "--prune"]);
|
||||||
|
git(repoRoot, ["pack-refs", "--all", "--prune"]);
|
||||||
|
|
||||||
|
const worktrees = Array.from({ length: SIBLING_COUNT }, (_, index) => {
|
||||||
|
let suffix = 0;
|
||||||
|
let branch = `sibling-${index}-${suffix}`;
|
||||||
|
let cwd = join(worktreesRoot, branch);
|
||||||
|
while (getWorkspaceGitSelfHealPhaseMs(cwd) < 50_000) {
|
||||||
|
suffix += 1;
|
||||||
|
branch = `sibling-${index}-${suffix}`;
|
||||||
|
cwd = join(worktreesRoot, branch);
|
||||||
|
}
|
||||||
|
git(repoRoot, ["worktree", "add", "--quiet", "-b", branch, cwd, "HEAD"]);
|
||||||
|
return cwd;
|
||||||
|
});
|
||||||
|
|
||||||
|
return { originRoot, paseoHome, repoRoot, worktrees };
|
||||||
|
}
|
||||||
|
|
||||||
|
function advanceOriginRef(originRoot: string, ref: string): void {
|
||||||
|
git(originRoot, ["config", "user.email", "repro@example.com"]);
|
||||||
|
git(originRoot, ["config", "user.name", "Repro"]);
|
||||||
|
const previous = git(originRoot, ["rev-parse", ref]);
|
||||||
|
const tree = git(originRoot, ["show", "-s", "--format=%T", previous]);
|
||||||
|
const next = git(originRoot, ["commit-tree", tree, "-p", previous], `advance ${ref}\n`);
|
||||||
|
git(originRoot, ["update-ref", ref, next, previous]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function advanceOriginMain(originRoot: string): void {
|
||||||
|
advanceOriginRef(originRoot, "refs/heads/main");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function measureFetchScenario(
|
||||||
|
name: string,
|
||||||
|
beforeFetch: () => void,
|
||||||
|
duringFetch?: () => Promise<void>,
|
||||||
|
quietMs = 1_500,
|
||||||
|
): Promise<{
|
||||||
|
fetchCount: number;
|
||||||
|
gitCommands: number;
|
||||||
|
nonRemoteRefsChanged: boolean | undefined;
|
||||||
|
operations: Record<string, number>;
|
||||||
|
snapshotUpdates: number;
|
||||||
|
}> {
|
||||||
|
const fetchStarted = createDeferred<void>();
|
||||||
|
const releaseFetch = createDeferred<void>();
|
||||||
|
let fetchCount = 0;
|
||||||
|
let fetchChangedNonRemoteRefs: boolean | undefined;
|
||||||
|
|
||||||
|
const service = new WorkspaceGitServiceImpl({
|
||||||
|
logger: pino({ level: "silent" }),
|
||||||
|
paseoHome: fixture.paseoHome,
|
||||||
|
deps: {
|
||||||
|
runGitFetch: async (cwd, observer) => {
|
||||||
|
fetchStarted.resolve();
|
||||||
|
await releaseFetch.promise;
|
||||||
|
const result = await fetchWorkspaceGitRemote(cwd, observer);
|
||||||
|
fetchChangedNonRemoteRefs = result.nonRemoteRefsChanged;
|
||||||
|
fetchCount += 1;
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const snapshotCounts = new Map(fixture.worktrees.map((cwd) => [cwd, 0]));
|
||||||
|
const subscriptions = fixture.worktrees.map((cwd) =>
|
||||||
|
service.registerWorkspace({ cwd }, () => {
|
||||||
|
snapshotCounts.set(cwd, (snapshotCounts.get(cwd) ?? 0) + 1);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetchStarted.promise;
|
||||||
|
await waitForCondition(
|
||||||
|
"all sibling workspace snapshots to warm before the measured fetch runs",
|
||||||
|
() =>
|
||||||
|
[...snapshotCounts.values()].every((count) => count >= 1) &&
|
||||||
|
service.getMetrics().repositoryWorkspaceLinkCount === SIBLING_COUNT &&
|
||||||
|
service.getMetrics().workspaceRefreshInFlightCount === 0 &&
|
||||||
|
service.getMetrics().workspaceRefreshQueuedCount === 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
beforeFetch();
|
||||||
|
|
||||||
|
const snapshotBaseline = new Map(snapshotCounts);
|
||||||
|
const fetchCycleStartedAt = Date.now();
|
||||||
|
startGitCommandMetrics();
|
||||||
|
releaseFetch.resolve();
|
||||||
|
await duringFetch?.();
|
||||||
|
await waitForGitCommandMetricsIdle({ quietMs, timeoutMs: 120_000 });
|
||||||
|
const postFetch = stopGitCommandMetrics();
|
||||||
|
const snapshotUpdatesAfterFetch = [...snapshotCounts].reduce(
|
||||||
|
(total, [cwd, count]) => total + Math.max(0, count - (snapshotBaseline.get(cwd) ?? 0)),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
const operations = Object.fromEntries(
|
||||||
|
[...Map.groupBy(postFetch.submissions, (command) => command.args[0] ?? "").entries()]
|
||||||
|
.map(([operation, commands]) => [operation, commands.length] as const)
|
||||||
|
.sort(([left], [right]) => left.localeCompare(right)),
|
||||||
|
);
|
||||||
|
console.info(
|
||||||
|
"[workspace-git-fetch]",
|
||||||
|
JSON.stringify({
|
||||||
|
scenario: name,
|
||||||
|
siblingCount: SIBLING_COUNT,
|
||||||
|
remoteRefCount: REMOTE_REF_COUNT,
|
||||||
|
fetchCount,
|
||||||
|
durationMs: Date.now() - fetchCycleStartedAt,
|
||||||
|
gitCommands: postFetch.submitted,
|
||||||
|
maxConcurrentGitCommands: postFetch.maxConcurrent,
|
||||||
|
operations,
|
||||||
|
snapshotUpdates: snapshotUpdatesAfterFetch,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
fetchCount,
|
||||||
|
gitCommands: postFetch.submitted,
|
||||||
|
nonRemoteRefsChanged: fetchChangedNonRemoteRefs,
|
||||||
|
operations,
|
||||||
|
snapshotUpdates: snapshotUpdatesAfterFetch,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
for (const subscription of subscriptions) {
|
||||||
|
subscription.unsubscribe();
|
||||||
|
}
|
||||||
|
service.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function measureExternalFetchScenario(
|
||||||
|
name: string,
|
||||||
|
beforeFetch: () => void,
|
||||||
|
): Promise<{
|
||||||
|
gitCommands: number;
|
||||||
|
operations: Record<string, number>;
|
||||||
|
snapshotUpdates: number;
|
||||||
|
}> {
|
||||||
|
const initialFetchCompleted = createDeferred<void>();
|
||||||
|
const service = new WorkspaceGitServiceImpl({
|
||||||
|
logger: pino({ level: "silent" }),
|
||||||
|
paseoHome: fixture.paseoHome,
|
||||||
|
deps: {
|
||||||
|
runGitFetch: async (cwd, observer) => {
|
||||||
|
const result = await fetchWorkspaceGitRemote(cwd, observer);
|
||||||
|
initialFetchCompleted.resolve();
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const snapshotCounts = new Map(fixture.worktrees.map((cwd) => [cwd, 0]));
|
||||||
|
const subscriptions = fixture.worktrees.map((cwd) =>
|
||||||
|
service.registerWorkspace({ cwd }, () => {
|
||||||
|
snapshotCounts.set(cwd, (snapshotCounts.get(cwd) ?? 0) + 1);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await initialFetchCompleted.promise;
|
||||||
|
await waitForCondition(
|
||||||
|
"all sibling workspace snapshots to warm before the external fetch runs",
|
||||||
|
() =>
|
||||||
|
[...snapshotCounts.values()].every((count) => count >= 1) &&
|
||||||
|
service.getMetrics().repositoryWorkspaceLinkCount === SIBLING_COUNT &&
|
||||||
|
service.getMetrics().workspaceRefreshInFlightCount === 0 &&
|
||||||
|
service.getMetrics().workspaceRefreshQueuedCount === 0,
|
||||||
|
);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 6_000));
|
||||||
|
|
||||||
|
const snapshotBaseline = new Map(snapshotCounts);
|
||||||
|
beforeFetch();
|
||||||
|
const fetchCycleStartedAt = Date.now();
|
||||||
|
startGitCommandMetrics();
|
||||||
|
await gitAsync(fixture.repoRoot, ["fetch", "origin", "--prune"]);
|
||||||
|
await waitForGitCommandMetricsIdle({ quietMs: 6_000, timeoutMs: 120_000 });
|
||||||
|
const postFetch = stopGitCommandMetrics();
|
||||||
|
const snapshotUpdates = [...snapshotCounts].reduce(
|
||||||
|
(total, [cwd, count]) => total + Math.max(0, count - (snapshotBaseline.get(cwd) ?? 0)),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const operations = Object.fromEntries(
|
||||||
|
[...Map.groupBy(postFetch.submissions, (command) => command.args[0] ?? "").entries()]
|
||||||
|
.map(([operation, commands]) => [operation, commands.length] as const)
|
||||||
|
.sort(([left], [right]) => left.localeCompare(right)),
|
||||||
|
);
|
||||||
|
console.info(
|
||||||
|
"[workspace-git-fetch]",
|
||||||
|
JSON.stringify({
|
||||||
|
scenario: name,
|
||||||
|
siblingCount: SIBLING_COUNT,
|
||||||
|
remoteRefCount: REMOTE_REF_COUNT,
|
||||||
|
durationMs: Date.now() - fetchCycleStartedAt,
|
||||||
|
gitCommands: postFetch.submitted,
|
||||||
|
maxConcurrentGitCommands: postFetch.maxConcurrent,
|
||||||
|
operations,
|
||||||
|
snapshotUpdates,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return { gitCommands: postFetch.submitted, operations, snapshotUpdates };
|
||||||
|
} finally {
|
||||||
|
for (const subscription of subscriptions) subscription.unsubscribe();
|
||||||
|
service.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test("a no-op fetch does not refresh sibling worktrees", async () => {
|
||||||
|
expect(await measureFetchScenario("a no-op fetch", () => {})).toEqual({
|
||||||
|
fetchCount: 1,
|
||||||
|
gitCommands: 3,
|
||||||
|
nonRemoteRefsChanged: false,
|
||||||
|
operations: { fetch: 1, "for-each-ref": 2 },
|
||||||
|
snapshotUpdates: 0,
|
||||||
|
});
|
||||||
|
}, 240_000);
|
||||||
|
|
||||||
|
test("an origin/main update narrowly refreshes 59 sibling worktrees", async () => {
|
||||||
|
expect(
|
||||||
|
await measureFetchScenario("an origin/main update", () => {
|
||||||
|
advanceOriginMain(fixture.originRoot);
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
fetchCount: 1,
|
||||||
|
gitCommands: 239,
|
||||||
|
nonRemoteRefsChanged: false,
|
||||||
|
operations: {
|
||||||
|
diff: 59,
|
||||||
|
fetch: 1,
|
||||||
|
"for-each-ref": 2,
|
||||||
|
"ls-files": 59,
|
||||||
|
"merge-base": 59,
|
||||||
|
"rev-list": 59,
|
||||||
|
},
|
||||||
|
snapshotUpdates: 59,
|
||||||
|
});
|
||||||
|
}, 240_000);
|
||||||
|
|
||||||
|
test("an overlapping external no-op fetch stays idle", async () => {
|
||||||
|
const result = await measureFetchScenario(
|
||||||
|
"an overlapping external no-op fetch",
|
||||||
|
() => {},
|
||||||
|
() => gitAsync(fixture.repoRoot, ["fetch", "origin", "--prune"]),
|
||||||
|
6_000,
|
||||||
|
);
|
||||||
|
expect(result).toEqual({
|
||||||
|
fetchCount: 1,
|
||||||
|
gitCommands: 3,
|
||||||
|
nonRemoteRefsChanged: false,
|
||||||
|
operations: { fetch: 1, "for-each-ref": 2 },
|
||||||
|
snapshotUpdates: 0,
|
||||||
|
});
|
||||||
|
}, 240_000);
|
||||||
|
|
||||||
|
test("an overlapping external fetch of one main update stays narrow", async () => {
|
||||||
|
const result = await measureFetchScenario(
|
||||||
|
"an overlapping external fetch of one main update",
|
||||||
|
() => {
|
||||||
|
advanceOriginMain(fixture.originRoot);
|
||||||
|
},
|
||||||
|
() => gitAsync(fixture.repoRoot, ["fetch", "origin", "--prune"]),
|
||||||
|
6_000,
|
||||||
|
);
|
||||||
|
expect(result).toEqual({
|
||||||
|
fetchCount: 1,
|
||||||
|
gitCommands: 239,
|
||||||
|
nonRemoteRefsChanged: false,
|
||||||
|
operations: {
|
||||||
|
diff: 59,
|
||||||
|
fetch: 1,
|
||||||
|
"for-each-ref": 2,
|
||||||
|
"ls-files": 59,
|
||||||
|
"merge-base": 59,
|
||||||
|
"rev-list": 59,
|
||||||
|
},
|
||||||
|
snapshotUpdates: 59,
|
||||||
|
});
|
||||||
|
}, 240_000);
|
||||||
|
|
||||||
|
test("an overlapping external fetch of an unrelated branch stays idle", async () => {
|
||||||
|
const result = await measureFetchScenario(
|
||||||
|
"an overlapping external fetch of an unrelated branch",
|
||||||
|
() => {
|
||||||
|
advanceOriginRef(fixture.originRoot, "refs/heads/load/00000");
|
||||||
|
},
|
||||||
|
() => gitAsync(fixture.repoRoot, ["fetch", "origin", "--prune"]),
|
||||||
|
6_000,
|
||||||
|
);
|
||||||
|
expect(result).toEqual({
|
||||||
|
fetchCount: 1,
|
||||||
|
gitCommands: 3,
|
||||||
|
nonRemoteRefsChanged: false,
|
||||||
|
operations: { fetch: 1, "for-each-ref": 2 },
|
||||||
|
snapshotUpdates: 0,
|
||||||
|
});
|
||||||
|
}, 240_000);
|
||||||
|
|
||||||
|
test("a second same-ref fetch during calculation stays narrow", async () => {
|
||||||
|
const result = await measureFetchScenario(
|
||||||
|
"a second same-ref fetch during calculation",
|
||||||
|
() => {
|
||||||
|
advanceOriginMain(fixture.originRoot);
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
await waitForCondition("the first narrow calculation to start", () =>
|
||||||
|
hasSubmittedGitOperation("rev-list"),
|
||||||
|
);
|
||||||
|
advanceOriginMain(fixture.originRoot);
|
||||||
|
await gitAsync(fixture.repoRoot, ["fetch", "origin", "--prune"]);
|
||||||
|
},
|
||||||
|
6_000,
|
||||||
|
);
|
||||||
|
expect(result).toEqual({
|
||||||
|
fetchCount: 1,
|
||||||
|
gitCommands: 475,
|
||||||
|
nonRemoteRefsChanged: false,
|
||||||
|
operations: {
|
||||||
|
diff: 118,
|
||||||
|
fetch: 1,
|
||||||
|
"for-each-ref": 2,
|
||||||
|
"ls-files": 118,
|
||||||
|
"merge-base": 118,
|
||||||
|
"rev-list": 118,
|
||||||
|
},
|
||||||
|
snapshotUpdates: 118,
|
||||||
|
});
|
||||||
|
}, 240_000);
|
||||||
|
|
||||||
|
test("a standalone external fetch of an unrelated packed branch stays idle", async () => {
|
||||||
|
const result = await measureExternalFetchScenario(
|
||||||
|
"a standalone external fetch of an unrelated packed branch",
|
||||||
|
() => {
|
||||||
|
advanceOriginRef(fixture.originRoot, "refs/heads/load/00001");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(result).toEqual({ gitCommands: 0, operations: {}, snapshotUpdates: 0 });
|
||||||
|
}, 240_000);
|
||||||
@@ -157,6 +157,10 @@ function createService(
|
|||||||
deps: {
|
deps: {
|
||||||
subscribe: watcher.subscribe,
|
subscribe: watcher.subscribe,
|
||||||
getCheckoutSnapshotFacts: vi.fn(async (cwd: string) => createCheckoutFacts(cwd)),
|
getCheckoutSnapshotFacts: vi.fn(async (cwd: string) => createCheckoutFacts(cwd)),
|
||||||
|
getCheckoutRefDerivedState: vi.fn(async (_cwd, facts, current) => ({
|
||||||
|
...current,
|
||||||
|
upstreamStatus: facts.upstreamStatus,
|
||||||
|
})),
|
||||||
getCheckoutStatus,
|
getCheckoutStatus,
|
||||||
getCheckoutShortstat,
|
getCheckoutShortstat,
|
||||||
getCheckoutWorktreeState: vi.fn(async (cwd: string) => {
|
getCheckoutWorktreeState: vi.fn(async (cwd: string) => {
|
||||||
@@ -441,6 +445,441 @@ describe("WorkspaceGitService checkout observation", () => {
|
|||||||
service.dispose();
|
service.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("a loose remote-ref watcher echo during fetch coalesces into the narrow refresh", async () => {
|
||||||
|
const watcher = createWatcherHarness();
|
||||||
|
const releaseFetch = createDeferred<void>();
|
||||||
|
const getCheckoutSnapshotFacts = vi.fn(async (cwd: string) => ({
|
||||||
|
...createCheckoutFacts(cwd),
|
||||||
|
currentBranch: "feature",
|
||||||
|
remoteUrl: "https://example.com/repo.git",
|
||||||
|
resolvedBaseRef: "main",
|
||||||
|
comparisonBaseRef: "origin/main",
|
||||||
|
}));
|
||||||
|
const getCheckoutRefDerivedState = vi.fn(async (_cwd, facts, current) => ({
|
||||||
|
...current,
|
||||||
|
upstreamStatus: facts.upstreamStatus,
|
||||||
|
}));
|
||||||
|
const service = createService(watcher, {
|
||||||
|
getCheckoutSnapshotFacts,
|
||||||
|
getCheckoutRefDerivedState,
|
||||||
|
getCheckoutStatus: vi.fn(async (cwd: string) =>
|
||||||
|
createCheckoutStatus(cwd, {
|
||||||
|
currentBranch: "feature",
|
||||||
|
baseRef: "main",
|
||||||
|
hasRemote: true,
|
||||||
|
remoteUrl: "https://example.com/repo.git",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
hasOriginRemote: vi.fn(async () => true),
|
||||||
|
runGitFetch: vi.fn(async (_cwd, observer) => {
|
||||||
|
observer?.onRefSnapshot("before");
|
||||||
|
await releaseFetch.promise;
|
||||||
|
observer?.onRefSnapshot("after");
|
||||||
|
return {
|
||||||
|
changes: [{ kind: "moved" as const, ref: "origin/main", beforeOid: "a", afterOid: "b" }],
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const listener = vi.fn();
|
||||||
|
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, listener);
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(service.getMetrics().fetchInFlightCount).toBe(1);
|
||||||
|
expect(service.getMetrics().workspaceRefreshInFlightCount).toBe(0);
|
||||||
|
});
|
||||||
|
listener.mockClear();
|
||||||
|
|
||||||
|
watcher.records
|
||||||
|
.find((record) => record.directory === GIT_DIR)
|
||||||
|
?.callback(null, [
|
||||||
|
{ path: path.join(GIT_DIR, "refs", "remotes", "origin", "main"), type: "create" },
|
||||||
|
]);
|
||||||
|
releaseFetch.resolve();
|
||||||
|
await flushPromises();
|
||||||
|
await vi.advanceTimersByTimeAsync(1_000);
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(getCheckoutRefDerivedState).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(1);
|
||||||
|
expect(listener).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
subscription.unsubscribe();
|
||||||
|
service.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a packed-refs event covered by the fetch snapshot uses the exact ref delta", async () => {
|
||||||
|
const watcher = createWatcherHarness();
|
||||||
|
const fetchWindowOpen = createDeferred<void>();
|
||||||
|
const releaseFetch = createDeferred<void>();
|
||||||
|
const getCheckoutSnapshotFacts = vi.fn(async (cwd: string) => ({
|
||||||
|
...createCheckoutFacts(cwd),
|
||||||
|
currentBranch: "feature",
|
||||||
|
remoteUrl: "https://example.com/repo.git",
|
||||||
|
resolvedBaseRef: "main",
|
||||||
|
comparisonBaseRef: "origin/main",
|
||||||
|
}));
|
||||||
|
const getCheckoutRefDerivedState = vi.fn();
|
||||||
|
const service = createService(watcher, {
|
||||||
|
getCheckoutSnapshotFacts,
|
||||||
|
getCheckoutRefDerivedState,
|
||||||
|
hasOriginRemote: vi.fn(async () => true),
|
||||||
|
runGitFetch: vi.fn(async (_cwd, observer) => {
|
||||||
|
observer.onRefSnapshot("before");
|
||||||
|
fetchWindowOpen.resolve();
|
||||||
|
await releaseFetch.promise;
|
||||||
|
observer.onRefSnapshot("after");
|
||||||
|
return {
|
||||||
|
changes: [
|
||||||
|
{
|
||||||
|
kind: "moved" as const,
|
||||||
|
ref: "origin/unrelated",
|
||||||
|
beforeOid: "a",
|
||||||
|
afterOid: "b",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
nonRemoteRefsChanged: false,
|
||||||
|
remoteRefs: new Set(["origin/main", "origin/unrelated"]),
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
|
||||||
|
await fetchWindowOpen.promise;
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
watcher.records
|
||||||
|
.find((record) => record.directory === GIT_DIR)
|
||||||
|
?.callback(null, [{ path: path.join(GIT_DIR, "packed-refs"), type: "update" }]);
|
||||||
|
releaseFetch.resolve();
|
||||||
|
await flushPromises();
|
||||||
|
await vi.advanceTimersByTimeAsync(1_000);
|
||||||
|
|
||||||
|
expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(1);
|
||||||
|
expect(getCheckoutRefDerivedState).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
subscription.unsubscribe();
|
||||||
|
service.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each(["before", "after"] as const)(
|
||||||
|
"a packed-refs event %s the fetch snapshot remains conservative",
|
||||||
|
async (phase) => {
|
||||||
|
const watcher = createWatcherHarness();
|
||||||
|
const afterSnapshot = createDeferred<void>();
|
||||||
|
const releaseFetch = createDeferred<void>();
|
||||||
|
const getCheckoutSnapshotFacts = vi.fn(async (cwd: string) => ({
|
||||||
|
...createCheckoutFacts(cwd),
|
||||||
|
remoteUrl: "https://example.com/repo.git",
|
||||||
|
}));
|
||||||
|
const runGitFetch = vi.fn(async (_cwd, observer) => {
|
||||||
|
if (phase === "before") {
|
||||||
|
await releaseFetch.promise;
|
||||||
|
observer.onRefSnapshot("before");
|
||||||
|
observer.onRefSnapshot("after");
|
||||||
|
} else {
|
||||||
|
observer.onRefSnapshot("before");
|
||||||
|
observer.onRefSnapshot("after");
|
||||||
|
afterSnapshot.resolve();
|
||||||
|
await releaseFetch.promise;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
changes: [],
|
||||||
|
nonRemoteRefsChanged: false,
|
||||||
|
remoteRefs: new Set(["origin/main"]),
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const service = createService(watcher, {
|
||||||
|
getCheckoutSnapshotFacts,
|
||||||
|
hasOriginRemote: vi.fn(async () => true),
|
||||||
|
runGitFetch,
|
||||||
|
});
|
||||||
|
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(runGitFetch).toHaveBeenCalledTimes(1);
|
||||||
|
expect(getWatcherRecordsForDirectory(watcher, GIT_DIR)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
if (phase === "after") await afterSnapshot.promise;
|
||||||
|
|
||||||
|
watcher.records
|
||||||
|
.find((record) => record.directory === GIT_DIR)
|
||||||
|
?.callback(null, [{ path: path.join(GIT_DIR, "packed-refs"), type: "update" }]);
|
||||||
|
releaseFetch.resolve();
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(service.getMetrics().fetchInFlightCount).toBe(0);
|
||||||
|
});
|
||||||
|
await vi.advanceTimersByTimeAsync(1_000);
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
subscription.unsubscribe();
|
||||||
|
service.dispose();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test("a post-snapshot delete overrides the fetch's moved-ref classification", async () => {
|
||||||
|
const watcher = createWatcherHarness();
|
||||||
|
const afterSnapshot = createDeferred<void>();
|
||||||
|
const releaseFetch = createDeferred<void>();
|
||||||
|
const getCheckoutSnapshotFacts = vi.fn(async (cwd: string) => ({
|
||||||
|
...createCheckoutFacts(cwd),
|
||||||
|
remoteUrl: "https://example.com/repo.git",
|
||||||
|
}));
|
||||||
|
const getCheckoutRefDerivedState = vi.fn();
|
||||||
|
const service = createService(watcher, {
|
||||||
|
getCheckoutSnapshotFacts,
|
||||||
|
getCheckoutRefDerivedState,
|
||||||
|
hasOriginRemote: vi.fn(async () => true),
|
||||||
|
runGitFetch: vi.fn(async (_cwd, observer) => {
|
||||||
|
observer.onRefSnapshot("before");
|
||||||
|
observer.onRefSnapshot("after");
|
||||||
|
afterSnapshot.resolve();
|
||||||
|
await releaseFetch.promise;
|
||||||
|
return {
|
||||||
|
changes: [{ kind: "moved" as const, ref: "origin/main", beforeOid: "a", afterOid: "b" }],
|
||||||
|
nonRemoteRefsChanged: false,
|
||||||
|
remoteRefs: new Set(["origin/main"]),
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
|
||||||
|
await afterSnapshot.promise;
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
watcher.records
|
||||||
|
.find((record) => record.directory === GIT_DIR)
|
||||||
|
?.callback(null, [
|
||||||
|
{ path: path.join(GIT_DIR, "refs", "remotes", "origin", "main"), type: "delete" },
|
||||||
|
]);
|
||||||
|
releaseFetch.resolve();
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(service.getMetrics().fetchInFlightCount).toBe(0);
|
||||||
|
});
|
||||||
|
await vi.advanceTimersByTimeAsync(1_000);
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
expect(getCheckoutRefDerivedState).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
subscription.unsubscribe();
|
||||||
|
service.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("known packed remote refs materialize narrowly while namespace directories are ignored", async () => {
|
||||||
|
const watcher = createWatcherHarness();
|
||||||
|
const getCheckoutSnapshotFacts = vi.fn(async (cwd: string) => ({
|
||||||
|
...createCheckoutFacts(cwd),
|
||||||
|
currentBranch: "feature",
|
||||||
|
remoteUrl: "https://example.com/repo.git",
|
||||||
|
resolvedBaseRef: "main",
|
||||||
|
comparisonBaseRef: "origin/main",
|
||||||
|
}));
|
||||||
|
const getCheckoutRefDerivedState = vi.fn(async (_cwd, facts, current) => ({
|
||||||
|
...current,
|
||||||
|
upstreamStatus: facts.upstreamStatus,
|
||||||
|
}));
|
||||||
|
const service = createService(watcher, {
|
||||||
|
getCheckoutSnapshotFacts,
|
||||||
|
getCheckoutRefDerivedState,
|
||||||
|
hasOriginRemote: vi.fn(async () => true),
|
||||||
|
runGitFetch: vi.fn(async (_cwd, observer) => {
|
||||||
|
observer.onRefSnapshot("before");
|
||||||
|
observer.onRefSnapshot("after");
|
||||||
|
return {
|
||||||
|
changes: [],
|
||||||
|
nonRemoteRefsChanged: false,
|
||||||
|
remoteRefs: new Set(["origin/main", "origin/load/00001"]),
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(service.getMetrics().fetchInFlightCount).toBe(0);
|
||||||
|
expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
const repoWatcher = watcher.records.find((record) => record.directory === GIT_DIR);
|
||||||
|
repoWatcher?.callback(null, [
|
||||||
|
{ path: path.join(GIT_DIR, "refs", "remotes", "origin", "load"), type: "create" },
|
||||||
|
{
|
||||||
|
path: path.join(GIT_DIR, "refs", "remotes", "origin", "load", "00001"),
|
||||||
|
type: "create",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
await vi.advanceTimersByTimeAsync(1_000);
|
||||||
|
expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(1);
|
||||||
|
expect(getCheckoutRefDerivedState).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
repoWatcher?.callback(null, [
|
||||||
|
{ path: path.join(GIT_DIR, "refs", "remotes", "origin", "main"), type: "create" },
|
||||||
|
]);
|
||||||
|
await vi.advanceTimersByTimeAsync(1_000);
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(getCheckoutRefDerivedState).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
subscription.unsubscribe();
|
||||||
|
service.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a second remote-ref move during a narrow refresh queues a final calculation", async () => {
|
||||||
|
const watcher = createWatcherHarness();
|
||||||
|
const firstRefRefresh = createDeferred<CheckoutStatusGit>();
|
||||||
|
const getCheckoutSnapshotFacts = vi.fn(async (cwd: string) => ({
|
||||||
|
...createCheckoutFacts(cwd),
|
||||||
|
currentBranch: "feature",
|
||||||
|
remoteUrl: "https://example.com/repo.git",
|
||||||
|
resolvedBaseRef: "main",
|
||||||
|
comparisonBaseRef: "origin/main",
|
||||||
|
}));
|
||||||
|
const getCheckoutRefDerivedState = vi
|
||||||
|
.fn<
|
||||||
|
(
|
||||||
|
cwd: string,
|
||||||
|
facts: CheckoutSnapshotFacts,
|
||||||
|
current: CheckoutStatusGit,
|
||||||
|
) => Promise<CheckoutStatusGit>
|
||||||
|
>()
|
||||||
|
.mockImplementationOnce(() => firstRefRefresh.promise)
|
||||||
|
.mockImplementation(async (_cwd, facts, current) => ({
|
||||||
|
...current,
|
||||||
|
upstreamStatus: facts.upstreamStatus,
|
||||||
|
}));
|
||||||
|
const service = createService(watcher, {
|
||||||
|
getCheckoutSnapshotFacts,
|
||||||
|
getCheckoutRefDerivedState,
|
||||||
|
getCheckoutStatus: vi.fn(async (cwd: string) =>
|
||||||
|
createCheckoutStatus(cwd, {
|
||||||
|
currentBranch: "feature",
|
||||||
|
baseRef: "main",
|
||||||
|
hasRemote: true,
|
||||||
|
remoteUrl: "https://example.com/repo.git",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
hasOriginRemote: vi.fn(async () => true),
|
||||||
|
runGitFetch: vi.fn(async () => ({
|
||||||
|
changes: [{ kind: "moved" as const, ref: "origin/main", beforeOid: "a", afterOid: "b" }],
|
||||||
|
error: null,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(1_000);
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(getCheckoutRefDerivedState).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
watcher.records
|
||||||
|
.find((record) => record.directory === GIT_DIR)
|
||||||
|
?.callback(null, [
|
||||||
|
{ path: path.join(GIT_DIR, "refs", "remotes", "origin", "main"), type: "update" },
|
||||||
|
]);
|
||||||
|
await vi.advanceTimersByTimeAsync(1_000);
|
||||||
|
expect(service.getMetrics().workspaceRefreshQueuedCount).toBe(1);
|
||||||
|
|
||||||
|
firstRefRefresh.resolve(createCheckoutStatus(REPO_CWD, { currentBranch: "feature" }));
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(getCheckoutRefDerivedState).toHaveBeenCalledTimes(2);
|
||||||
|
expect(service.getMetrics().workspaceRefreshInFlightCount).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
subscription.unsubscribe();
|
||||||
|
service.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an unmatched remote-ref event buffered after the fetch snapshot is refreshed", async () => {
|
||||||
|
const watcher = createWatcherHarness();
|
||||||
|
const fetchSnapshotRead = createDeferred<void>();
|
||||||
|
const releaseFetch = createDeferred<void>();
|
||||||
|
const getCheckoutSnapshotFacts = vi.fn(async (cwd: string) => ({
|
||||||
|
...createCheckoutFacts(cwd),
|
||||||
|
currentBranch: "feature",
|
||||||
|
remoteUrl: "https://example.com/repo.git",
|
||||||
|
resolvedBaseRef: "main",
|
||||||
|
comparisonBaseRef: "origin/main",
|
||||||
|
}));
|
||||||
|
const service = createService(watcher, {
|
||||||
|
getCheckoutSnapshotFacts,
|
||||||
|
getCheckoutStatus: vi.fn(async (cwd: string) =>
|
||||||
|
createCheckoutStatus(cwd, {
|
||||||
|
currentBranch: "feature",
|
||||||
|
baseRef: "main",
|
||||||
|
hasRemote: true,
|
||||||
|
remoteUrl: "https://example.com/repo.git",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
hasOriginRemote: vi.fn(async () => true),
|
||||||
|
runGitFetch: vi.fn(async (_cwd, observer) => {
|
||||||
|
observer?.onRefSnapshot("before");
|
||||||
|
observer?.onRefSnapshot("after");
|
||||||
|
fetchSnapshotRead.resolve();
|
||||||
|
await releaseFetch.promise;
|
||||||
|
return { changes: [], nonRemoteRefsChanged: false, error: null };
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
|
||||||
|
await fetchSnapshotRead.promise;
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
watcher.records
|
||||||
|
.find((record) => record.directory === GIT_DIR)
|
||||||
|
?.callback(null, [
|
||||||
|
{ path: path.join(GIT_DIR, "refs", "remotes", "origin", "main"), type: "update" },
|
||||||
|
]);
|
||||||
|
releaseFetch.resolve();
|
||||||
|
await flushPromises();
|
||||||
|
await vi.advanceTimersByTimeAsync(1_000);
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
subscription.unsubscribe();
|
||||||
|
service.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("origin/main refreshes a main checkout without configured upstream", async () => {
|
||||||
|
const watcher = createWatcherHarness();
|
||||||
|
const getCheckoutSnapshotFacts = vi.fn(async (cwd: string) => ({
|
||||||
|
...createCheckoutFacts(cwd),
|
||||||
|
currentBranch: "main",
|
||||||
|
remoteUrl: "https://example.com/repo.git",
|
||||||
|
resolvedBaseRef: "main",
|
||||||
|
comparisonBaseRef: null,
|
||||||
|
branchRemoteName: null,
|
||||||
|
branchMergeRef: null,
|
||||||
|
upstreamStatus: null,
|
||||||
|
}));
|
||||||
|
const service = createService(watcher, { getCheckoutSnapshotFacts });
|
||||||
|
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
watcher.records
|
||||||
|
.find((record) => record.directory === GIT_DIR)
|
||||||
|
?.callback(null, [
|
||||||
|
{ path: path.join(GIT_DIR, "refs", "remotes", "origin", "main"), type: "update" },
|
||||||
|
]);
|
||||||
|
await vi.advanceTimersByTimeAsync(1_000);
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
subscription.unsubscribe();
|
||||||
|
service.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
test("routes private worktree metadata to its owner and shared base refs to dependents", async () => {
|
test("routes private worktree metadata to its owner and shared base refs to dependents", async () => {
|
||||||
const watcher = createWatcherHarness();
|
const watcher = createWatcherHarness();
|
||||||
const getCheckoutSnapshotFacts = vi.fn(async (cwd: string) => createLinkedCheckoutFacts(cwd));
|
const getCheckoutSnapshotFacts = vi.fn(async (cwd: string) => createLinkedCheckoutFacts(cwd));
|
||||||
@@ -535,7 +974,7 @@ describe("WorkspaceGitService checkout observation", () => {
|
|||||||
service.dispose();
|
service.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("routes a newly created remote tracking ref to its configured workspace", async () => {
|
test("a newly created remote tracking ref refreshes every repository workspace", async () => {
|
||||||
const watcher = createWatcherHarness();
|
const watcher = createWatcherHarness();
|
||||||
const getCheckoutSnapshotFacts = vi.fn(async (cwd: string) => {
|
const getCheckoutSnapshotFacts = vi.fn(async (cwd: string) => {
|
||||||
const facts = createLinkedCheckoutFacts(cwd);
|
const facts = createLinkedCheckoutFacts(cwd);
|
||||||
@@ -577,7 +1016,7 @@ describe("WorkspaceGitService checkout observation", () => {
|
|||||||
]);
|
]);
|
||||||
await vi.advanceTimersByTimeAsync(1_000);
|
await vi.advanceTimersByTimeAsync(1_000);
|
||||||
await vi.waitFor(() => {
|
await vi.waitFor(() => {
|
||||||
expect(getCalledCwds(getCheckoutStatus)).toEqual([WORKTREE_A]);
|
expect(getCalledCwds(getCheckoutStatus)).toEqual([WORKTREE_A, WORKTREE_B]);
|
||||||
});
|
});
|
||||||
|
|
||||||
first.unsubscribe();
|
first.unsubscribe();
|
||||||
@@ -831,7 +1270,10 @@ describe("WorkspaceGitService checkout observation", () => {
|
|||||||
test("ten worktrees share one repository metadata and fetch observer while retaining checkout state", async () => {
|
test("ten worktrees share one repository metadata and fetch observer while retaining checkout state", async () => {
|
||||||
const watcher = createWatcherHarness();
|
const watcher = createWatcherHarness();
|
||||||
const fetch = createDeferred<void>();
|
const fetch = createDeferred<void>();
|
||||||
const runGitFetch = vi.fn(() => fetch.promise);
|
const runGitFetch = vi.fn(async () => {
|
||||||
|
await fetch.promise;
|
||||||
|
return { changes: [], error: null };
|
||||||
|
});
|
||||||
const commonGitDir = path.resolve("/tmp/paseo-shared-repository.git");
|
const commonGitDir = path.resolve("/tmp/paseo-shared-repository.git");
|
||||||
const worktrees = Array.from({ length: 10 }, (_, index) =>
|
const worktrees = Array.from({ length: 10 }, (_, index) =>
|
||||||
path.resolve(`/tmp/paseo-shared-worktree-${index}`),
|
path.resolve(`/tmp/paseo-shared-worktree-${index}`),
|
||||||
@@ -886,7 +1328,7 @@ describe("WorkspaceGitService checkout observation", () => {
|
|||||||
await vi.waitFor(() => {
|
await vi.waitFor(() => {
|
||||||
expect(runGitFetch).toHaveBeenCalledTimes(2);
|
expect(runGitFetch).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
expect(runGitFetch).toHaveBeenLastCalledWith(worktrees[1]);
|
expect(runGitFetch).toHaveBeenLastCalledWith(worktrees[1], expect.anything());
|
||||||
|
|
||||||
for (const subscription of subscriptions.slice(1)) {
|
for (const subscription of subscriptions.slice(1)) {
|
||||||
subscription.unsubscribe();
|
subscription.unsubscribe();
|
||||||
|
|||||||
@@ -334,6 +334,10 @@ function buildDefaultServiceDeps() {
|
|||||||
return {
|
return {
|
||||||
subscribe: vi.fn(async () => ({ unsubscribe: vi.fn(async () => {}) })),
|
subscribe: vi.fn(async () => ({ unsubscribe: vi.fn(async () => {}) })),
|
||||||
getCheckoutSnapshotFacts: vi.fn(async (cwd: string) => createCheckoutFacts(cwd)),
|
getCheckoutSnapshotFacts: vi.fn(async (cwd: string) => createCheckoutFacts(cwd)),
|
||||||
|
getCheckoutRefDerivedState: vi.fn(async (_cwd, facts, current) => ({
|
||||||
|
...current,
|
||||||
|
upstreamStatus: facts.upstreamStatus,
|
||||||
|
})),
|
||||||
getCheckoutStatus: vi.fn(async (cwd: string) => createCheckoutStatus(cwd)),
|
getCheckoutStatus: vi.fn(async (cwd: string) => createCheckoutStatus(cwd)),
|
||||||
getCheckoutShortstat: vi.fn(async () => ({
|
getCheckoutShortstat: vi.fn(async () => ({
|
||||||
additions: 1,
|
additions: 1,
|
||||||
@@ -349,7 +353,10 @@ function buildDefaultServiceDeps() {
|
|||||||
forgeOverrides: { github: createGitHubServiceStub() },
|
forgeOverrides: { github: createGitHubServiceStub() },
|
||||||
resolveAbsoluteGitDir: vi.fn(async () => join(REPO_CWD, ".git")),
|
resolveAbsoluteGitDir: vi.fn(async () => join(REPO_CWD, ".git")),
|
||||||
hasOriginRemote: vi.fn(async () => false),
|
hasOriginRemote: vi.fn(async () => false),
|
||||||
runGitFetch: vi.fn(() => createDeferred<void>().promise),
|
runGitFetch: vi.fn(async () => {
|
||||||
|
await createDeferred<void>().promise;
|
||||||
|
return { changes: [], error: null };
|
||||||
|
}),
|
||||||
runGitCommand: vi.fn(async () => ({
|
runGitCommand: vi.fn(async () => ({
|
||||||
stdout: `${REPO_CWD}\n`,
|
stdout: `${REPO_CWD}\n`,
|
||||||
stderr: "",
|
stderr: "",
|
||||||
|
|||||||
@@ -264,6 +264,10 @@ function buildDefaultTestServiceDeps() {
|
|||||||
return {
|
return {
|
||||||
subscribe: vi.fn(async () => ({ unsubscribe: vi.fn(async () => {}) })),
|
subscribe: vi.fn(async () => ({ unsubscribe: vi.fn(async () => {}) })),
|
||||||
getCheckoutSnapshotFacts: vi.fn(async (cwd: string) => createCheckoutSnapshotFacts(cwd)),
|
getCheckoutSnapshotFacts: vi.fn(async (cwd: string) => createCheckoutSnapshotFacts(cwd)),
|
||||||
|
getCheckoutRefDerivedState: vi.fn(async (_cwd, facts, current) => ({
|
||||||
|
...current,
|
||||||
|
upstreamStatus: facts.upstreamStatus,
|
||||||
|
})),
|
||||||
getCheckoutStatus: vi.fn(async (cwd: string) => createCheckoutStatus(cwd)),
|
getCheckoutStatus: vi.fn(async (cwd: string) => createCheckoutStatus(cwd)),
|
||||||
getCheckoutShortstat: vi.fn(async () => ({
|
getCheckoutShortstat: vi.fn(async () => ({
|
||||||
additions: 1,
|
additions: 1,
|
||||||
@@ -274,7 +278,7 @@ function buildDefaultTestServiceDeps() {
|
|||||||
forgeOverrides: { github: createGitHubServiceStub() },
|
forgeOverrides: { github: createGitHubServiceStub() },
|
||||||
resolveAbsoluteGitDir: vi.fn(async () => join(REPO_CWD, ".git")),
|
resolveAbsoluteGitDir: vi.fn(async () => join(REPO_CWD, ".git")),
|
||||||
hasOriginRemote: vi.fn(async () => false),
|
hasOriginRemote: vi.fn(async () => false),
|
||||||
runGitFetch: vi.fn(async () => {}),
|
runGitFetch: vi.fn(async () => ({ changes: [], error: null })),
|
||||||
runGitCommand: vi.fn(async () => ({
|
runGitCommand: vi.fn(async () => ({
|
||||||
stdout: `${REPO_CWD}\n`,
|
stdout: `${REPO_CWD}\n`,
|
||||||
stderr: "",
|
stderr: "",
|
||||||
@@ -842,17 +846,28 @@ describe("WorkspaceGitServiceImpl", () => {
|
|||||||
|
|
||||||
test("repo-level fetch intervals are shared for workspaces in the same repo", async () => {
|
test("repo-level fetch intervals are shared for workspaces in the same repo", async () => {
|
||||||
const firstFetch = createDeferred<void>();
|
const firstFetch = createDeferred<void>();
|
||||||
const runGitFetch = vi.fn(() => firstFetch.promise);
|
const runGitFetch = vi.fn(async () => {
|
||||||
|
await firstFetch.promise;
|
||||||
|
return {
|
||||||
|
changes: [{ kind: "moved" as const, ref: "origin/main", beforeOid: "a", afterOid: "b" }],
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
});
|
||||||
const hasOriginRemote = vi.fn(async () => true);
|
const hasOriginRemote = vi.fn(async () => true);
|
||||||
const getCheckoutSnapshotFacts = vi.fn(async (cwd: string) => ({
|
const getCheckoutSnapshotFacts = vi.fn(async (cwd: string) => ({
|
||||||
...createCheckoutSnapshotFacts(cwd),
|
...createCheckoutSnapshotFacts(cwd),
|
||||||
gitCommonDir: join(REPO_CWD, ".git"),
|
gitCommonDir: join(REPO_CWD, ".git"),
|
||||||
absoluteGitDir: join(REPO_CWD, ".git"),
|
absoluteGitDir: join(REPO_CWD, ".git"),
|
||||||
}));
|
}));
|
||||||
|
const getCheckoutRefDerivedState = vi.fn(async (_cwd, facts, current) => ({
|
||||||
|
...current,
|
||||||
|
upstreamStatus: facts.upstreamStatus,
|
||||||
|
}));
|
||||||
const resolveAbsoluteGitDir = vi.fn(async () => join(REPO_CWD, ".git"));
|
const resolveAbsoluteGitDir = vi.fn(async () => join(REPO_CWD, ".git"));
|
||||||
|
|
||||||
const service = createService({
|
const service = createService({
|
||||||
getCheckoutSnapshotFacts,
|
getCheckoutSnapshotFacts,
|
||||||
|
getCheckoutRefDerivedState,
|
||||||
resolveAbsoluteGitDir,
|
resolveAbsoluteGitDir,
|
||||||
hasOriginRemote,
|
hasOriginRemote,
|
||||||
runGitFetch,
|
runGitFetch,
|
||||||
@@ -877,7 +892,8 @@ describe("WorkspaceGitServiceImpl", () => {
|
|||||||
await flushPromises();
|
await flushPromises();
|
||||||
await vi.advanceTimersByTimeAsync(1_000);
|
await vi.advanceTimersByTimeAsync(1_000);
|
||||||
await vi.waitFor(() => {
|
await vi.waitFor(() => {
|
||||||
expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(4);
|
expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(2);
|
||||||
|
expect(getCheckoutRefDerivedState).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
await vi.advanceTimersByTimeAsync(180_000);
|
await vi.advanceTimersByTimeAsync(180_000);
|
||||||
@@ -890,6 +906,165 @@ describe("WorkspaceGitServiceImpl", () => {
|
|||||||
service.dispose();
|
service.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("remote ref name drift outside the fetch interval takes the structural fallback", async () => {
|
||||||
|
const runGitFetch = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
changes: [],
|
||||||
|
nonRemoteRefsChanged: false,
|
||||||
|
remoteRefs: new Set(["origin/main"]),
|
||||||
|
error: null,
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
changes: [],
|
||||||
|
nonRemoteRefsChanged: false,
|
||||||
|
remoteRefs: new Set(["origin/main", "origin/new"]),
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
const getCheckoutSnapshotFacts = vi.fn(async (cwd: string) => ({
|
||||||
|
...createCheckoutSnapshotFacts(cwd),
|
||||||
|
gitCommonDir: join(REPO_CWD, ".git"),
|
||||||
|
absoluteGitDir: join(REPO_CWD, ".git"),
|
||||||
|
}));
|
||||||
|
const service = createService({
|
||||||
|
getCheckoutSnapshotFacts,
|
||||||
|
resolveAbsoluteGitDir: vi.fn(async () => join(REPO_CWD, ".git")),
|
||||||
|
hasOriginRemote: vi.fn(async () => true),
|
||||||
|
runGitFetch,
|
||||||
|
});
|
||||||
|
const first = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
|
||||||
|
const second = service.registerWorkspace(
|
||||||
|
{ cwd: join(REPO_CWD, "packages", "server") },
|
||||||
|
vi.fn(),
|
||||||
|
);
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(runGitFetch).toHaveBeenCalledTimes(1);
|
||||||
|
expect(service.getMetrics().fetchInFlightCount).toBe(0);
|
||||||
|
expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
getCheckoutSnapshotFacts.mockClear();
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(180_000);
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(runGitFetch).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
await vi.advanceTimersByTimeAsync(1_000);
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
first.unsubscribe();
|
||||||
|
second.unsubscribe();
|
||||||
|
service.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
{
|
||||||
|
name: "an added remote ref",
|
||||||
|
result: {
|
||||||
|
changes: [{ kind: "added" as const, ref: "origin/new", afterOid: "b" }],
|
||||||
|
error: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "a deleted remote ref",
|
||||||
|
result: {
|
||||||
|
changes: [{ kind: "deleted" as const, ref: "origin/main", beforeOid: "a" }],
|
||||||
|
error: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "a concurrent local ref change",
|
||||||
|
result: { changes: [], nonRemoteRefsChanged: true, error: null },
|
||||||
|
},
|
||||||
|
])("$name takes the repository-wide structural fallback", async ({ result }) => {
|
||||||
|
const releaseFetch = createDeferred<void>();
|
||||||
|
const getCheckoutSnapshotFacts = vi.fn(async (cwd: string) => ({
|
||||||
|
...createCheckoutSnapshotFacts(cwd),
|
||||||
|
gitCommonDir: join(REPO_CWD, ".git"),
|
||||||
|
absoluteGitDir: join(REPO_CWD, ".git"),
|
||||||
|
}));
|
||||||
|
const getCheckoutRefDerivedState = vi.fn();
|
||||||
|
const service = createService({
|
||||||
|
getCheckoutSnapshotFacts,
|
||||||
|
getCheckoutRefDerivedState,
|
||||||
|
resolveAbsoluteGitDir: vi.fn(async () => join(REPO_CWD, ".git")),
|
||||||
|
hasOriginRemote: vi.fn(async () => true),
|
||||||
|
runGitFetch: vi.fn(async () => {
|
||||||
|
await releaseFetch.promise;
|
||||||
|
return result;
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const first = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
|
||||||
|
const second = service.registerWorkspace(
|
||||||
|
{ cwd: join(REPO_CWD, "packages", "server") },
|
||||||
|
vi.fn(),
|
||||||
|
);
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
releaseFetch.resolve();
|
||||||
|
await flushPromises();
|
||||||
|
await vi.advanceTimersByTimeAsync(1_000);
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(4);
|
||||||
|
});
|
||||||
|
expect(getCheckoutRefDerivedState).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
first.unsubscribe();
|
||||||
|
second.unsubscribe();
|
||||||
|
service.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a failed narrow remote-ref refresh falls back for that workspace", async () => {
|
||||||
|
const releaseFetch = createDeferred<void>();
|
||||||
|
const getCheckoutShortstat = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce({ additions: 1, deletions: 0 })
|
||||||
|
.mockResolvedValue({ additions: 2, deletions: 0 });
|
||||||
|
const getCheckoutSnapshotFacts = vi.fn(async (cwd: string) => ({
|
||||||
|
...createCheckoutSnapshotFacts(cwd),
|
||||||
|
gitCommonDir: join(REPO_CWD, ".git"),
|
||||||
|
absoluteGitDir: join(REPO_CWD, ".git"),
|
||||||
|
}));
|
||||||
|
const getCheckoutRefDerivedState = vi.fn(async () => {
|
||||||
|
throw new Error("ref moved again");
|
||||||
|
});
|
||||||
|
const service = createService({
|
||||||
|
getCheckoutSnapshotFacts,
|
||||||
|
getCheckoutRefDerivedState,
|
||||||
|
getCheckoutShortstat,
|
||||||
|
resolveAbsoluteGitDir: vi.fn(async () => join(REPO_CWD, ".git")),
|
||||||
|
hasOriginRemote: vi.fn(async () => true),
|
||||||
|
runGitFetch: vi.fn(async () => {
|
||||||
|
await releaseFetch.promise;
|
||||||
|
return {
|
||||||
|
changes: [{ kind: "moved" as const, ref: "origin/main", beforeOid: "a", afterOid: "b" }],
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const listener = vi.fn();
|
||||||
|
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, listener);
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
releaseFetch.resolve();
|
||||||
|
await flushPromises();
|
||||||
|
await vi.advanceTimersByTimeAsync(1_000);
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(getCheckoutRefDerivedState).toHaveBeenCalledTimes(1);
|
||||||
|
expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(2);
|
||||||
|
expect(getCheckoutShortstat).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
expect(listener.mock.lastCall?.[0].git.diffStat).toEqual({ additions: 2, deletions: 0 });
|
||||||
|
|
||||||
|
subscription.unsubscribe();
|
||||||
|
service.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
test("explicit forced snapshot refresh recomputes github state and notifies listeners", async () => {
|
test("explicit forced snapshot refresh recomputes github state and notifies listeners", async () => {
|
||||||
const getPullRequestStatus = vi
|
const getPullRequestStatus = vi
|
||||||
.fn<() => Promise<PullRequestStatusResult>>()
|
.fn<() => Promise<PullRequestStatusResult>>()
|
||||||
@@ -1180,7 +1355,10 @@ describe("WorkspaceGitServiceImpl", () => {
|
|||||||
const backgroundFetch = createDeferred<void>();
|
const backgroundFetch = createDeferred<void>();
|
||||||
const service = createService({
|
const service = createService({
|
||||||
getCheckoutWorktreeState,
|
getCheckoutWorktreeState,
|
||||||
runGitFetch: vi.fn(() => backgroundFetch.promise),
|
runGitFetch: vi.fn(async () => {
|
||||||
|
await backgroundFetch.promise;
|
||||||
|
return { changes: [], error: null };
|
||||||
|
}),
|
||||||
subscribe,
|
subscribe,
|
||||||
});
|
});
|
||||||
const workspaceListener = vi.fn();
|
const workspaceListener = vi.fn();
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
type CheckoutDiffCompare,
|
type CheckoutDiffCompare,
|
||||||
type CheckoutDiffResult,
|
type CheckoutDiffResult,
|
||||||
getCheckoutDiff,
|
getCheckoutDiff,
|
||||||
|
getCheckoutRefDerivedState,
|
||||||
getCheckoutSnapshotFacts,
|
getCheckoutSnapshotFacts,
|
||||||
getCheckoutShortstat,
|
getCheckoutShortstat,
|
||||||
getCheckoutStatus,
|
getCheckoutStatus,
|
||||||
@@ -46,14 +47,22 @@ import {
|
|||||||
isRealpathInsideRoot,
|
isRealpathInsideRoot,
|
||||||
} from "../utils/path.js";
|
} from "../utils/path.js";
|
||||||
import { runGitCommand } from "../utils/run-git-command.js";
|
import { runGitCommand } from "../utils/run-git-command.js";
|
||||||
|
import { branchNameFromRef } from "../utils/worktree-metadata.js";
|
||||||
import { listPaseoWorktrees, type PaseoWorktreeInfo } from "../utils/worktree.js";
|
import { listPaseoWorktrees, type PaseoWorktreeInfo } from "../utils/worktree.js";
|
||||||
import { READ_ONLY_GIT_ENV } from "./checkout-git-utils.js";
|
import { READ_ONLY_GIT_ENV } from "./checkout-git-utils.js";
|
||||||
import { classifyGitMetadataPath, getPrunedGitMetadataPaths } from "./git-metadata-event-rules.js";
|
import { classifyGitMetadataPath, getPrunedGitMetadataPaths } from "./git-metadata-event-rules.js";
|
||||||
|
import {
|
||||||
|
fetchWorkspaceGitRemote,
|
||||||
|
type WorkspaceGitFetchResult,
|
||||||
|
type WorkspaceGitFetchObserver,
|
||||||
|
type WorkspaceGitRemoteRefChange,
|
||||||
|
} from "./workspace-git-fetch.js";
|
||||||
import { deriveProjectSlug } from "./workspace-git-metadata.js";
|
import { deriveProjectSlug } from "./workspace-git-metadata.js";
|
||||||
import { checkoutLiteFromGitSnapshot } from "./workspace-registry-model.js";
|
import { checkoutLiteFromGitSnapshot } from "./workspace-registry-model.js";
|
||||||
|
|
||||||
const WORKSPACE_GIT_WATCH_DEBOUNCE_MS = 1_000;
|
const WORKSPACE_GIT_WATCH_DEBOUNCE_MS = 1_000;
|
||||||
const BACKGROUND_GIT_FETCH_INTERVAL_MS = 180_000;
|
const BACKGROUND_GIT_FETCH_INTERVAL_MS = 180_000;
|
||||||
|
const FETCH_METADATA_ECHO_TTL_MS = 5_000;
|
||||||
export const WORKSPACE_GIT_SELF_HEAL_INTERVAL_MS = 60_000;
|
export const WORKSPACE_GIT_SELF_HEAL_INTERVAL_MS = 60_000;
|
||||||
const FORGE_PR_STATUS_POLL_FAST_INTERVAL_MS = 20_000;
|
const FORGE_PR_STATUS_POLL_FAST_INTERVAL_MS = 20_000;
|
||||||
const FORGE_PR_STATUS_POLL_SLOW_INTERVAL_MS = 120_000;
|
const FORGE_PR_STATUS_POLL_SLOW_INTERVAL_MS = 120_000;
|
||||||
@@ -75,6 +84,21 @@ const WORKSPACE_GIT_CHECKOUT_DIFF_CACHE_MAX = 64;
|
|||||||
// Small values (booleans, short strings, small arrays); generous cap.
|
// Small values (booleans, short strings, small arrays); generous cap.
|
||||||
const WORKSPACE_GIT_AUXILIARY_CACHE_MAX = 256;
|
const WORKSPACE_GIT_AUXILIARY_CACHE_MAX = 256;
|
||||||
|
|
||||||
|
function mergeSets<T>(
|
||||||
|
left: ReadonlySet<T>,
|
||||||
|
right: ReadonlySet<T>,
|
||||||
|
): { merged: Set<T>; added: boolean } {
|
||||||
|
const merged = new Set(left);
|
||||||
|
let added = false;
|
||||||
|
for (const value of right) {
|
||||||
|
if (!merged.has(value)) {
|
||||||
|
merged.add(value);
|
||||||
|
added = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { merged, added };
|
||||||
|
}
|
||||||
|
|
||||||
export function getWorkspaceGitSelfHealPhaseMs(cwd: string): number {
|
export function getWorkspaceGitSelfHealPhaseMs(cwd: string): number {
|
||||||
return (
|
return (
|
||||||
createHash("sha256").update(cwd).digest().readUInt32BE(0) % WORKSPACE_GIT_SELF_HEAL_INTERVAL_MS
|
createHash("sha256").update(cwd).digest().readUInt32BE(0) % WORKSPACE_GIT_SELF_HEAL_INTERVAL_MS
|
||||||
@@ -269,15 +293,17 @@ interface WorkspaceGitRefreshRequest {
|
|||||||
reason: string;
|
reason: string;
|
||||||
notify: boolean;
|
notify: boolean;
|
||||||
queueIfBusy: boolean;
|
queueIfBusy: boolean;
|
||||||
|
movedRemoteRefs: Set<string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ScheduledWorkspaceGitRefreshOptions {
|
interface ScheduledWorkspaceGitRefreshOptions {
|
||||||
force?: boolean;
|
force?: boolean;
|
||||||
scope?: "structure" | "worktree";
|
scope?: "refs" | "structure" | "worktree";
|
||||||
includeForge?: boolean;
|
includeForge?: boolean;
|
||||||
emitUnchanged?: boolean;
|
emitUnchanged?: boolean;
|
||||||
reason?: string;
|
reason?: string;
|
||||||
queueIfBusy?: boolean;
|
queueIfBusy?: boolean;
|
||||||
|
movedRemoteRefs?: ReadonlySet<string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
type WorkspaceGitRefreshState =
|
type WorkspaceGitRefreshState =
|
||||||
@@ -294,6 +320,7 @@ type WorkspaceGitRefreshState =
|
|||||||
interface WorkspaceGitServiceDependencies {
|
interface WorkspaceGitServiceDependencies {
|
||||||
subscribe: typeof parcelWatcher.subscribe;
|
subscribe: typeof parcelWatcher.subscribe;
|
||||||
getCheckoutSnapshotFacts: typeof getCheckoutSnapshotFacts;
|
getCheckoutSnapshotFacts: typeof getCheckoutSnapshotFacts;
|
||||||
|
getCheckoutRefDerivedState: typeof getCheckoutRefDerivedState;
|
||||||
getCheckoutStatus: typeof getCheckoutStatus;
|
getCheckoutStatus: typeof getCheckoutStatus;
|
||||||
getCheckoutShortstat: typeof getCheckoutShortstat;
|
getCheckoutShortstat: typeof getCheckoutShortstat;
|
||||||
getCheckoutWorktreeState: typeof getCheckoutWorktreeState;
|
getCheckoutWorktreeState: typeof getCheckoutWorktreeState;
|
||||||
@@ -311,7 +338,10 @@ interface WorkspaceGitServiceDependencies {
|
|||||||
forgeOverrides?: Record<string, ForgeService>;
|
forgeOverrides?: Record<string, ForgeService>;
|
||||||
resolveAbsoluteGitDir: (cwd: string) => Promise<string | null>;
|
resolveAbsoluteGitDir: (cwd: string) => Promise<string | null>;
|
||||||
hasOriginRemote: (cwd: string) => Promise<boolean>;
|
hasOriginRemote: (cwd: string) => Promise<boolean>;
|
||||||
runGitFetch: (cwd: string) => Promise<void>;
|
runGitFetch: (
|
||||||
|
cwd: string,
|
||||||
|
observer: WorkspaceGitFetchObserver,
|
||||||
|
) => Promise<WorkspaceGitFetchResult>;
|
||||||
runGitCommand: typeof runGitCommand;
|
runGitCommand: typeof runGitCommand;
|
||||||
getWorkspaceGitSelfHealPhaseMs: typeof getWorkspaceGitSelfHealPhaseMs;
|
getWorkspaceGitSelfHealPhaseMs: typeof getWorkspaceGitSelfHealPhaseMs;
|
||||||
now: () => Date;
|
now: () => Date;
|
||||||
@@ -405,11 +435,20 @@ interface RepoGitTarget {
|
|||||||
recovery: WatchRecoveryState;
|
recovery: WatchRecoveryState;
|
||||||
intervalId: NodeJS.Timeout | null;
|
intervalId: NodeJS.Timeout | null;
|
||||||
fetchInFlight: boolean;
|
fetchInFlight: boolean;
|
||||||
|
bufferedFetchMetadataEvents: parcelWatcher.Event[];
|
||||||
|
recentFetchRemoteRefChanges: Map<
|
||||||
|
string,
|
||||||
|
{ change: WorkspaceGitRemoteRefChange; expiresAtMs: number }
|
||||||
|
>;
|
||||||
|
knownRemoteRefs: Set<string> | null;
|
||||||
closed: boolean;
|
closed: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RepoMetadataWorkspaceRefresh {
|
interface RepoMetadataWorkspaceRefresh {
|
||||||
refreshBase: boolean;
|
refreshBase: boolean;
|
||||||
|
structural: boolean;
|
||||||
|
movedRemoteRefs: Set<string>;
|
||||||
|
queueIfBusy: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface WorkingTreeWatchTarget {
|
interface WorkingTreeWatchTarget {
|
||||||
@@ -433,6 +472,10 @@ interface WatchRecoveryState {
|
|||||||
timer: NodeJS.Timeout | null;
|
timer: NodeJS.Timeout | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setsEqual(left: ReadonlySet<string>, right: ReadonlySet<string>): boolean {
|
||||||
|
return left.size === right.size && [...left].every((value) => right.has(value));
|
||||||
|
}
|
||||||
|
|
||||||
type WorkingTreeWatchFallbackReason =
|
type WorkingTreeWatchFallbackReason =
|
||||||
| "not_a_git_checkout"
|
| "not_a_git_checkout"
|
||||||
| "watcher_error"
|
| "watcher_error"
|
||||||
@@ -456,6 +499,7 @@ function buildDefaultWorkspaceGitServiceDeps(): WorkspaceGitServiceDependencies
|
|||||||
return {
|
return {
|
||||||
subscribe: subscribeToWorkspaceFileChanges,
|
subscribe: subscribeToWorkspaceFileChanges,
|
||||||
getCheckoutSnapshotFacts,
|
getCheckoutSnapshotFacts,
|
||||||
|
getCheckoutRefDerivedState,
|
||||||
getCheckoutStatus,
|
getCheckoutStatus,
|
||||||
getCheckoutShortstat,
|
getCheckoutShortstat,
|
||||||
getCheckoutWorktreeState,
|
getCheckoutWorktreeState,
|
||||||
@@ -467,7 +511,7 @@ function buildDefaultWorkspaceGitServiceDeps(): WorkspaceGitServiceDependencies
|
|||||||
listPaseoWorktrees,
|
listPaseoWorktrees,
|
||||||
resolveAbsoluteGitDir,
|
resolveAbsoluteGitDir,
|
||||||
hasOriginRemote,
|
hasOriginRemote,
|
||||||
runGitFetch,
|
runGitFetch: fetchWorkspaceGitRemote,
|
||||||
runGitCommand,
|
runGitCommand,
|
||||||
getWorkspaceGitSelfHealPhaseMs,
|
getWorkspaceGitSelfHealPhaseMs,
|
||||||
now: () => new Date(),
|
now: () => new Date(),
|
||||||
@@ -863,6 +907,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
reason: "refresh",
|
reason: "refresh",
|
||||||
notify: true,
|
notify: true,
|
||||||
queueIfBusy: false,
|
queueIfBusy: false,
|
||||||
|
movedRemoteRefs: new Set(),
|
||||||
});
|
});
|
||||||
this.scheduleWorkspaceObservationSetup(target);
|
this.scheduleWorkspaceObservationSetup(target);
|
||||||
}
|
}
|
||||||
@@ -1130,6 +1175,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
reason: "initial",
|
reason: "initial",
|
||||||
notify: true,
|
notify: true,
|
||||||
queueIfBusy: false,
|
queueIfBusy: false,
|
||||||
|
movedRemoteRefs: new Set(),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1475,6 +1521,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
reason: "working-tree-watch-fallback",
|
reason: "working-tree-watch-fallback",
|
||||||
notify: true,
|
notify: true,
|
||||||
queueIfBusy: true,
|
queueIfBusy: true,
|
||||||
|
movedRemoteRefs: new Set(),
|
||||||
});
|
});
|
||||||
if (target.repoRoot === null && workspaceTarget.latestGit?.isGit === true) {
|
if (target.repoRoot === null && workspaceTarget.latestGit?.isGit === true) {
|
||||||
workspaceTarget.observationSetupComplete = false;
|
workspaceTarget.observationSetupComplete = false;
|
||||||
@@ -1741,6 +1788,9 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
recovery: { attemptCount: 0, timer: null },
|
recovery: { attemptCount: 0, timer: null },
|
||||||
intervalId: null,
|
intervalId: null,
|
||||||
fetchInFlight: false,
|
fetchInFlight: false,
|
||||||
|
bufferedFetchMetadataEvents: [],
|
||||||
|
recentFetchRemoteRefChanges: new Map(),
|
||||||
|
knownRemoteRefs: null,
|
||||||
closed: false,
|
closed: false,
|
||||||
};
|
};
|
||||||
this.repoTargets.set(repoGitRoot, repoTarget);
|
this.repoTargets.set(repoGitRoot, repoTarget);
|
||||||
@@ -1821,12 +1871,24 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
ignore.every((ignoredPath) => !isRealpathInsideRoot(ignoredPath, event.path)),
|
ignore.every((ignoredPath) => !isRealpathInsideRoot(ignoredPath, event.path)),
|
||||||
);
|
);
|
||||||
if (relevantEvents.length > 0) {
|
if (relevantEvents.length > 0) {
|
||||||
this.scheduleRepoMetadataRefresh(
|
const immediateEvents = target.fetchInFlight
|
||||||
target,
|
? relevantEvents.filter((event) => {
|
||||||
"git-metadata-watch",
|
if (this.isFetchRemoteMetadataEvent(target, event)) {
|
||||||
true,
|
target.bufferedFetchMetadataEvents.push(event);
|
||||||
this.routeRepoMetadataEvents(target, relevantEvents),
|
return false;
|
||||||
);
|
}
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
: relevantEvents;
|
||||||
|
if (immediateEvents.length > 0) {
|
||||||
|
const routedRefreshes = this.routeRepoMetadataEvents(target, immediateEvents);
|
||||||
|
this.scheduleRepoMetadataRefresh(
|
||||||
|
target,
|
||||||
|
"git-metadata-watch",
|
||||||
|
routedRefreshes === null || [...routedRefreshes.values()].some((r) => r.structural),
|
||||||
|
routedRefreshes,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ ignore },
|
{ ignore },
|
||||||
@@ -1922,6 +1984,16 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
return refreshes;
|
return refreshes;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private isFetchRemoteMetadataEvent(target: RepoGitTarget, event: parcelWatcher.Event): boolean {
|
||||||
|
const relativePath = getRealpathAwareRelativePath(target.repoGitRoot, event.path);
|
||||||
|
const effect = classifyGitMetadataPath("common", relativePath ?? "");
|
||||||
|
return (
|
||||||
|
(effect.kind === "ref" && effect.namespace === "remote") ||
|
||||||
|
(effect.kind === "all" &&
|
||||||
|
(relativePath === "packed-refs" || relativePath?.startsWith("reftable/") === true))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private routeRepoMetadataEvent(
|
private routeRepoMetadataEvent(
|
||||||
target: RepoGitTarget,
|
target: RepoGitTarget,
|
||||||
event: parcelWatcher.Event,
|
event: parcelWatcher.Event,
|
||||||
@@ -1942,10 +2014,39 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
if (effect.namespace === "local") {
|
if (effect.namespace === "local") {
|
||||||
this.routeLocalBranchRef(target, effect.ref, refreshes);
|
this.routeLocalBranchRef(target, effect.ref, refreshes);
|
||||||
} else {
|
} else {
|
||||||
this.routeRemoteBranchRef(target, effect.ref, refreshes);
|
if (event.type === "delete") {
|
||||||
|
target.recentFetchRemoteRefChanges.delete(effect.ref);
|
||||||
|
target.knownRemoteRefs = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const recent = target.recentFetchRemoteRefChanges.get(effect.ref);
|
||||||
|
if (recent && recent.expiresAtMs >= this.deps.now().getTime()) {
|
||||||
|
this.routeRemoteBranchRef(target, effect.ref, refreshes, {
|
||||||
|
narrow: recent.change.kind === "moved",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
target.recentFetchRemoteRefChanges.delete(effect.ref);
|
||||||
|
if (target.knownRemoteRefs?.has(effect.ref)) {
|
||||||
|
this.routeRemoteBranchRef(target, effect.ref, refreshes, { narrow: true });
|
||||||
|
} else if (
|
||||||
|
event.type === "create" &&
|
||||||
|
target.knownRemoteRefs &&
|
||||||
|
[...target.knownRemoteRefs].some((ref) => ref.startsWith(`${effect.ref}/`))
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
case "all":
|
case "all":
|
||||||
|
if (
|
||||||
|
commonRelativePath === "packed-refs" ||
|
||||||
|
commonRelativePath?.startsWith("reftable/") === true
|
||||||
|
) {
|
||||||
|
target.knownRemoteRefs = null;
|
||||||
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2016,6 +2117,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
target: RepoGitTarget,
|
target: RepoGitTarget,
|
||||||
remoteRef: string,
|
remoteRef: string,
|
||||||
refreshes: Map<string, RepoMetadataWorkspaceRefresh>,
|
refreshes: Map<string, RepoMetadataWorkspaceRefresh>,
|
||||||
|
options?: { narrow?: boolean; queueIfBusy?: boolean },
|
||||||
): void {
|
): void {
|
||||||
const qualifiedRemoteRef = `refs/remotes/${remoteRef}`;
|
const qualifiedRemoteRef = `refs/remotes/${remoteRef}`;
|
||||||
for (const workspaceKey of target.workspaceKeys) {
|
for (const workspaceKey of target.workspaceKeys) {
|
||||||
@@ -2028,16 +2130,26 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
facts.branchRemoteName && facts.branchRemoteName !== "." && trackedBranch
|
facts.branchRemoteName && facts.branchRemoteName !== "." && trackedBranch
|
||||||
? `${facts.branchRemoteName}/${trackedBranch}`
|
? `${facts.branchRemoteName}/${trackedBranch}`
|
||||||
: null;
|
: null;
|
||||||
|
const shortstatRemoteRef =
|
||||||
|
facts.currentBranch &&
|
||||||
|
(!facts.resolvedBaseRef || branchNameFromRef(facts.resolvedBaseRef) === facts.currentBranch)
|
||||||
|
? `origin/${facts.currentBranch}`
|
||||||
|
: null;
|
||||||
const refs = [
|
const refs = [
|
||||||
facts.storedBaseRef,
|
facts.storedBaseRef,
|
||||||
facts.resolvedBaseRef,
|
facts.resolvedBaseRef,
|
||||||
facts.comparisonBaseRef,
|
facts.comparisonBaseRef,
|
||||||
facts.upstreamStatus?.ref,
|
facts.upstreamStatus?.ref,
|
||||||
configuredRemoteRef,
|
configuredRemoteRef,
|
||||||
|
shortstatRemoteRef,
|
||||||
];
|
];
|
||||||
const usesRemoteRef = refs.some((ref) => ref === remoteRef || ref === qualifiedRemoteRef);
|
const usesRemoteRef = refs.some((ref) => ref === remoteRef || ref === qualifiedRemoteRef);
|
||||||
if (usesRemoteRef) {
|
if (usesRemoteRef) {
|
||||||
this.addWorkspaceMetadataRefresh(refreshes, workspaceKey, true);
|
this.addWorkspaceMetadataRefresh(refreshes, workspaceKey, true, {
|
||||||
|
structural: options?.narrow !== true,
|
||||||
|
movedRemoteRef: options?.narrow === true ? remoteRef : undefined,
|
||||||
|
queueIfBusy: options?.queueIfBusy,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2046,10 +2158,22 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
refreshes: Map<string, RepoMetadataWorkspaceRefresh>,
|
refreshes: Map<string, RepoMetadataWorkspaceRefresh>,
|
||||||
workspaceKey: string,
|
workspaceKey: string,
|
||||||
refreshBase: boolean,
|
refreshBase: boolean,
|
||||||
|
options?: {
|
||||||
|
structural?: boolean;
|
||||||
|
movedRemoteRef?: string;
|
||||||
|
queueIfBusy?: boolean;
|
||||||
|
},
|
||||||
): void {
|
): void {
|
||||||
const previous = refreshes.get(workspaceKey);
|
const previous = refreshes.get(workspaceKey);
|
||||||
|
const movedRemoteRefs = new Set(previous?.movedRemoteRefs);
|
||||||
|
if (options?.movedRemoteRef) {
|
||||||
|
movedRemoteRefs.add(options.movedRemoteRef);
|
||||||
|
}
|
||||||
refreshes.set(workspaceKey, {
|
refreshes.set(workspaceKey, {
|
||||||
refreshBase: previous?.refreshBase === true || refreshBase,
|
refreshBase: previous?.refreshBase === true || refreshBase,
|
||||||
|
structural: previous?.structural === true || options?.structural !== false,
|
||||||
|
movedRemoteRefs,
|
||||||
|
queueIfBusy: (previous?.queueIfBusy ?? false) || (options?.queueIfBusy ?? true),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2066,11 +2190,23 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
const refreshes =
|
const refreshes =
|
||||||
routedRefreshes ??
|
routedRefreshes ??
|
||||||
new Map(
|
new Map(
|
||||||
Array.from(target.workspaceKeys, (workspaceKey) => [workspaceKey, { refreshBase: true }]),
|
Array.from(target.workspaceKeys, (workspaceKey) => [
|
||||||
|
workspaceKey,
|
||||||
|
{
|
||||||
|
refreshBase: true,
|
||||||
|
structural: true,
|
||||||
|
movedRemoteRefs: new Set<string>(),
|
||||||
|
queueIfBusy: true,
|
||||||
|
},
|
||||||
|
]),
|
||||||
);
|
);
|
||||||
for (const [workspaceKey, refresh] of refreshes) {
|
for (const [workspaceKey, refresh] of refreshes) {
|
||||||
const workspaceTarget = this.workspaceTargets.get(workspaceKey);
|
const workspaceTarget = this.workspaceTargets.get(workspaceKey);
|
||||||
if (workspaceTarget) {
|
if (workspaceTarget) {
|
||||||
|
let scope: ScheduledWorkspaceGitRefreshOptions["scope"];
|
||||||
|
if (!refreshWorktree) {
|
||||||
|
scope = refresh.structural ? "structure" : "refs";
|
||||||
|
}
|
||||||
if (refresh.refreshBase) {
|
if (refresh.refreshBase) {
|
||||||
this.invalidateCheckoutDiffCache(workspaceTarget.cwd, "base");
|
this.invalidateCheckoutDiffCache(workspaceTarget.cwd, "base");
|
||||||
if (workspaceTarget.latestFacts?.isGit) {
|
if (workspaceTarget.latestFacts?.isGit) {
|
||||||
@@ -2084,9 +2220,11 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.scheduleWorkspaceRefresh(workspaceTarget, {
|
this.scheduleWorkspaceRefresh(workspaceTarget, {
|
||||||
scope: refreshWorktree ? undefined : "structure",
|
scope,
|
||||||
emitUnchanged: refresh.refreshBase,
|
emitUnchanged: refresh.refreshBase,
|
||||||
reason,
|
reason,
|
||||||
|
queueIfBusy: refresh.queueIfBusy,
|
||||||
|
movedRemoteRefs: refresh.movedRemoteRefs,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2129,6 +2267,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
reason: "git-metadata-watch-fallback",
|
reason: "git-metadata-watch-fallback",
|
||||||
notify: true,
|
notify: true,
|
||||||
queueIfBusy: true,
|
queueIfBusy: true,
|
||||||
|
movedRemoteRefs: new Set(),
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -2221,6 +2360,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
reason: "self-heal-git",
|
reason: "self-heal-git",
|
||||||
notify: true,
|
notify: true,
|
||||||
queueIfBusy: true,
|
queueIfBusy: true,
|
||||||
|
movedRemoteRefs: new Set(),
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
{ err: error, cwd: target.cwd, reason: "self-heal-git" },
|
{ err: error, cwd: target.cwd, reason: "self-heal-git" },
|
||||||
@@ -2488,7 +2628,8 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
(request.refreshStructure && !active.refreshStructure) ||
|
(request.refreshStructure && !active.refreshStructure) ||
|
||||||
(request.refreshWorktree && !active.refreshWorktree) ||
|
(request.refreshWorktree && !active.refreshWorktree) ||
|
||||||
(request.includeForge && !active.includeForge) ||
|
(request.includeForge && !active.includeForge) ||
|
||||||
(request.emitUnchanged === true && active.emitUnchanged !== true);
|
(request.emitUnchanged === true && active.emitUnchanged !== true) ||
|
||||||
|
[...request.movedRemoteRefs].some((ref) => !active.movedRemoteRefs.has(ref));
|
||||||
if (request.queueIfBusy || addsWork) {
|
if (request.queueIfBusy || addsWork) {
|
||||||
target.refreshState.queued = this.mergeRefreshRequests(target.refreshState.queued, request);
|
target.refreshState.queued = this.mergeRefreshRequests(target.refreshState.queued, request);
|
||||||
}
|
}
|
||||||
@@ -2533,6 +2674,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
reason: options?.reason ?? defaultReason,
|
reason: options?.reason ?? defaultReason,
|
||||||
notify,
|
notify,
|
||||||
queueIfBusy: false,
|
queueIfBusy: false,
|
||||||
|
movedRemoteRefs: new Set(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2554,13 +2696,14 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
const scope = options?.scope;
|
const scope = options?.scope;
|
||||||
return {
|
return {
|
||||||
force: options?.force === true,
|
force: options?.force === true,
|
||||||
refreshStructure: scope !== "worktree",
|
refreshStructure: scope !== "worktree" && scope !== "refs",
|
||||||
refreshWorktree: scope !== "structure",
|
refreshWorktree: scope !== "structure" && scope !== "refs",
|
||||||
includeForge: options?.includeForge ?? false,
|
includeForge: options?.includeForge ?? false,
|
||||||
emitUnchanged: options?.emitUnchanged,
|
emitUnchanged: options?.emitUnchanged,
|
||||||
reason: options?.reason ?? "watch",
|
reason: options?.reason ?? "watch",
|
||||||
notify: true,
|
notify: true,
|
||||||
queueIfBusy: options?.queueIfBusy ?? true,
|
queueIfBusy: options?.queueIfBusy ?? true,
|
||||||
|
movedRemoteRefs: new Set(options?.movedRemoteRefs),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2578,6 +2721,10 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
const upgradesWorktree = request.refreshWorktree && !pending.refreshWorktree;
|
const upgradesWorktree = request.refreshWorktree && !pending.refreshWorktree;
|
||||||
const upgradesForge = request.includeForge && !pending.includeForge;
|
const upgradesForge = request.includeForge && !pending.includeForge;
|
||||||
const upgradesEmit = request.emitUnchanged === true && pending.emitUnchanged !== true;
|
const upgradesEmit = request.emitUnchanged === true && pending.emitUnchanged !== true;
|
||||||
|
const { merged: movedRemoteRefs, added: upgradesMovedRefs } = mergeSets(
|
||||||
|
pending.movedRemoteRefs,
|
||||||
|
request.movedRemoteRefs,
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
force,
|
force,
|
||||||
refreshStructure: pending.refreshStructure || request.refreshStructure,
|
refreshStructure: pending.refreshStructure || request.refreshStructure,
|
||||||
@@ -2585,11 +2732,17 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
includeForge: pending.includeForge || request.includeForge,
|
includeForge: pending.includeForge || request.includeForge,
|
||||||
emitUnchanged: pending.emitUnchanged === true || request.emitUnchanged === true,
|
emitUnchanged: pending.emitUnchanged === true || request.emitUnchanged === true,
|
||||||
reason:
|
reason:
|
||||||
upgradesForce || upgradesStructure || upgradesWorktree || upgradesForge || upgradesEmit
|
upgradesForce ||
|
||||||
|
upgradesStructure ||
|
||||||
|
upgradesWorktree ||
|
||||||
|
upgradesForge ||
|
||||||
|
upgradesEmit ||
|
||||||
|
upgradesMovedRefs
|
||||||
? request.reason
|
? request.reason
|
||||||
: pending.reason,
|
: pending.reason,
|
||||||
notify: pending.notify || request.notify,
|
notify: pending.notify || request.notify,
|
||||||
queueIfBusy: pending.queueIfBusy || request.queueIfBusy,
|
queueIfBusy: pending.queueIfBusy || request.queueIfBusy,
|
||||||
|
movedRemoteRefs,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2602,7 +2755,11 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
let failure: { error: unknown } | null = null;
|
let failure: { error: unknown } | null = null;
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
if (request.emitUnchanged === true && target.latestSnapshot) {
|
if (
|
||||||
|
request.emitUnchanged === true &&
|
||||||
|
request.movedRemoteRefs.size === 0 &&
|
||||||
|
target.latestSnapshot
|
||||||
|
) {
|
||||||
this.rememberSnapshot(target, target.latestSnapshot, {
|
this.rememberSnapshot(target, target.latestSnapshot, {
|
||||||
notify: request.notify,
|
notify: request.notify,
|
||||||
forceEmit: true,
|
forceEmit: true,
|
||||||
@@ -2621,7 +2778,8 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
snapshot = admittedSnapshot;
|
snapshot = admittedSnapshot;
|
||||||
this.rememberSnapshot(target, snapshot, {
|
this.rememberSnapshot(target, snapshot, {
|
||||||
notify: request.notify,
|
notify: request.notify,
|
||||||
forceEmit: request.force,
|
forceEmit:
|
||||||
|
request.force || (request.emitUnchanged === true && request.movedRemoteRefs.size > 0),
|
||||||
});
|
});
|
||||||
failure = null;
|
failure = null;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -2653,6 +2811,25 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
request: WorkspaceGitRefreshRequest,
|
request: WorkspaceGitRefreshRequest,
|
||||||
): Promise<WorkspaceGitRuntimeSnapshot> {
|
): Promise<WorkspaceGitRuntimeSnapshot> {
|
||||||
let facts = target.latestFacts;
|
let facts = target.latestFacts;
|
||||||
|
if (request.movedRemoteRefs.size > 0 && !request.refreshStructure && !request.refreshWorktree) {
|
||||||
|
if (facts?.isGit && target.latestGit?.isGit) {
|
||||||
|
try {
|
||||||
|
await this.refreshRefDerivedSnapshot(target, facts, request.movedRemoteRefs);
|
||||||
|
return this.combineSnapshot(target);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.debug(
|
||||||
|
{ err: error, cwd: target.cwd, movedRemoteRefs: [...request.movedRemoteRefs] },
|
||||||
|
"Narrow remote ref refresh failed; using structural refresh",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
facts = await this.refreshGitSnapshot(target, {
|
||||||
|
...request,
|
||||||
|
refreshStructure: true,
|
||||||
|
refreshWorktree: true,
|
||||||
|
});
|
||||||
|
return this.combineSnapshot(target);
|
||||||
|
}
|
||||||
if (request.refreshStructure || !facts || !target.latestGit) {
|
if (request.refreshStructure || !facts || !target.latestGit) {
|
||||||
facts = await this.refreshGitSnapshot(target, request);
|
facts = await this.refreshGitSnapshot(target, request);
|
||||||
} else if (request.refreshWorktree) {
|
} else if (request.refreshWorktree) {
|
||||||
@@ -2670,6 +2847,36 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
return snapshot;
|
return snapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async refreshRefDerivedSnapshot(
|
||||||
|
target: WorkspaceGitTarget,
|
||||||
|
facts: Extract<CheckoutSnapshotFacts, { isGit: true }>,
|
||||||
|
movedRemoteRefs: ReadonlySet<string>,
|
||||||
|
): Promise<void> {
|
||||||
|
const latestGit = target.latestGit;
|
||||||
|
if (!latestGit?.isGit) {
|
||||||
|
throw new Error("Remote ref refresh requires a warmed Git snapshot");
|
||||||
|
}
|
||||||
|
target.lastShellOutAtMs = this.deps.now().getTime();
|
||||||
|
const derived = await this.deps.getCheckoutRefDerivedState(
|
||||||
|
target.cwd,
|
||||||
|
facts,
|
||||||
|
{ aheadBehind: latestGit.aheadBehind, diffStat: latestGit.diffStat },
|
||||||
|
movedRemoteRefs,
|
||||||
|
{ paseoHome: this.paseoHome, worktreesRoot: this.worktreesRoot, logger: this.logger, facts },
|
||||||
|
);
|
||||||
|
target.latestFacts = { ...facts, upstreamStatus: derived.upstreamStatus };
|
||||||
|
target.latestGit = {
|
||||||
|
...latestGit,
|
||||||
|
aheadBehind: derived.aheadBehind,
|
||||||
|
diffStat: derived.diffStat,
|
||||||
|
upstreamRef: derived.upstreamStatus?.ref ?? null,
|
||||||
|
aheadOfOrigin: derived.upstreamStatus?.aheadBehind.ahead ?? null,
|
||||||
|
behindOfOrigin: derived.upstreamStatus?.aheadBehind.behind ?? null,
|
||||||
|
};
|
||||||
|
const loadedAtMs = this.deps.now().getTime();
|
||||||
|
target.latestGitLoadedAtMs = loadedAtMs;
|
||||||
|
}
|
||||||
|
|
||||||
private async refreshWorktreeSnapshot(
|
private async refreshWorktreeSnapshot(
|
||||||
target: WorkspaceGitTarget,
|
target: WorkspaceGitTarget,
|
||||||
facts: CheckoutSnapshotFacts,
|
facts: CheckoutSnapshotFacts,
|
||||||
@@ -2899,10 +3106,17 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
"Running background git fetch",
|
"Running background git fetch",
|
||||||
);
|
);
|
||||||
|
|
||||||
let succeeded = false;
|
let result: WorkspaceGitFetchResult | null = null;
|
||||||
|
const eventsBeforeFetchSnapshot: parcelWatcher.Event[] = [];
|
||||||
try {
|
try {
|
||||||
await this.deps.runGitFetch(target.cwd);
|
result = await this.deps.runGitFetch(target.cwd, {
|
||||||
succeeded = true;
|
onRefSnapshot: (phase) => {
|
||||||
|
const events = target.bufferedFetchMetadataEvents.splice(0);
|
||||||
|
if (phase === "before") {
|
||||||
|
eventsBeforeFetchSnapshot.push(...events);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
{ err: error, repoGitRoot: target.repoGitRoot, cwd: target.cwd },
|
{ err: error, repoGitRoot: target.repoGitRoot, cwd: target.cwd },
|
||||||
@@ -2911,10 +3125,73 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
} finally {
|
} finally {
|
||||||
target.fetchInFlight = false;
|
target.fetchInFlight = false;
|
||||||
}
|
}
|
||||||
if (!succeeded) {
|
this.flushFetchMetadataEvents(target, eventsBeforeFetchSnapshot);
|
||||||
|
if (!result || result.changes === null) {
|
||||||
|
target.recentFetchRemoteRefChanges.clear();
|
||||||
|
target.knownRemoteRefs = null;
|
||||||
|
this.flushBufferedFetchMetadataEvents(target);
|
||||||
|
if (result) {
|
||||||
|
this.logger.warn(
|
||||||
|
{ err: result.error, repoGitRoot: target.repoGitRoot, cwd: target.cwd },
|
||||||
|
"Background git fetch ref classification failed; using structural refresh",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.scheduleRepoMetadataRefresh(target, "repo-fetch-unclassified", false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.scheduleRepoMetadataRefresh(target, "repo-fetch", false);
|
if (result.error) {
|
||||||
|
this.logger.warn(
|
||||||
|
{ err: result.error, repoGitRoot: target.repoGitRoot, cwd: target.cwd },
|
||||||
|
"Background git fetch completed with errors after changing refs",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const expiresAtMs = this.deps.now().getTime() + FETCH_METADATA_ECHO_TTL_MS;
|
||||||
|
const remoteRefShapeChanged =
|
||||||
|
target.knownRemoteRefs !== null &&
|
||||||
|
result.remoteRefs !== undefined &&
|
||||||
|
!setsEqual(target.knownRemoteRefs, result.remoteRefs);
|
||||||
|
if (result.remoteRefs) {
|
||||||
|
target.knownRemoteRefs = new Set(result.remoteRefs);
|
||||||
|
}
|
||||||
|
target.recentFetchRemoteRefChanges.clear();
|
||||||
|
for (const change of result.changes) {
|
||||||
|
target.recentFetchRemoteRefChanges.set(change.ref, { change, expiresAtMs });
|
||||||
|
}
|
||||||
|
this.flushBufferedFetchMetadataEvents(target);
|
||||||
|
if (
|
||||||
|
result.nonRemoteRefsChanged === true ||
|
||||||
|
remoteRefShapeChanged ||
|
||||||
|
result.changes.some((change) => change.kind !== "moved")
|
||||||
|
) {
|
||||||
|
this.scheduleRepoMetadataRefresh(target, "repo-fetch-ref-shape", false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (result.changes.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const refreshes = new Map<string, RepoMetadataWorkspaceRefresh>();
|
||||||
|
for (const change of result.changes) {
|
||||||
|
this.routeRemoteBranchRef(target, change.ref, refreshes, { narrow: true });
|
||||||
|
}
|
||||||
|
this.scheduleRepoMetadataRefresh(target, "repo-fetch", false, refreshes);
|
||||||
|
}
|
||||||
|
|
||||||
|
private flushBufferedFetchMetadataEvents(target: RepoGitTarget): void {
|
||||||
|
this.flushFetchMetadataEvents(target, target.bufferedFetchMetadataEvents.splice(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
private flushFetchMetadataEvents(target: RepoGitTarget, events: parcelWatcher.Event[]): void {
|
||||||
|
if (events.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const routedRefreshes = this.routeRepoMetadataEvents(target, events);
|
||||||
|
this.scheduleRepoMetadataRefresh(
|
||||||
|
target,
|
||||||
|
"git-metadata-watch-after-fetch",
|
||||||
|
routedRefreshes === null ||
|
||||||
|
[...routedRefreshes.values()].some((refresh) => refresh.structural),
|
||||||
|
routedRefreshes,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private removeWorkspaceListener(cwd: string, listener: WorkspaceGitListener): void {
|
private removeWorkspaceListener(cwd: string, listener: WorkspaceGitListener): void {
|
||||||
@@ -3250,11 +3527,3 @@ function computeGenericForgeNextInterval(
|
|||||||
FORGE_PR_STATUS_POLL_ERROR_BACKOFF_CAP_MS,
|
FORGE_PR_STATUS_POLL_ERROR_BACKOFF_CAP_MS,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runGitFetch(cwd: string): Promise<void> {
|
|
||||||
await runGitCommand(["fetch", "origin", "--prune"], {
|
|
||||||
cwd,
|
|
||||||
envOverlay: { GIT_TERMINAL_PROMPT: "0" },
|
|
||||||
timeout: 120_000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import { execFileSync } from "node:child_process";
|
||||||
|
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
import { afterEach, expect, test } from "vitest";
|
||||||
|
import { type CheckoutSnapshotFacts, getCheckoutRefDerivedState } from "./checkout-git.js";
|
||||||
|
import {
|
||||||
|
startGitCommandMetrics,
|
||||||
|
stopGitCommandMetrics,
|
||||||
|
waitForGitCommandMetricsIdle,
|
||||||
|
} from "./run-git-command.js";
|
||||||
|
|
||||||
|
const cleanupPaths: string[] = [];
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const path of cleanupPaths.splice(0)) {
|
||||||
|
rmSync(path, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function git(cwd: string, args: string[]): string {
|
||||||
|
return execFileSync("git", args, {
|
||||||
|
cwd,
|
||||||
|
encoding: "utf8",
|
||||||
|
env: { ...process.env, GIT_CONFIG_NOSYSTEM: "1" },
|
||||||
|
}).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedFeatureCheckout(): {
|
||||||
|
cwd: string;
|
||||||
|
facts: Extract<CheckoutSnapshotFacts, { isGit: true }>;
|
||||||
|
} {
|
||||||
|
const cwd = mkdtempSync(join(tmpdir(), "paseo-ref-derived-"));
|
||||||
|
cleanupPaths.push(cwd);
|
||||||
|
git(cwd, ["init", "-b", "main"]);
|
||||||
|
git(cwd, ["config", "user.email", "repro@example.com"]);
|
||||||
|
git(cwd, ["config", "user.name", "Repro"]);
|
||||||
|
writeFileSync(join(cwd, "base.txt"), "base\n");
|
||||||
|
git(cwd, ["add", "base.txt"]);
|
||||||
|
git(cwd, ["commit", "-m", "base"]);
|
||||||
|
const base = git(cwd, ["rev-parse", "HEAD"]);
|
||||||
|
git(cwd, ["update-ref", "refs/remotes/origin/main", base]);
|
||||||
|
git(cwd, ["switch", "--quiet", "-c", "feature"]);
|
||||||
|
writeFileSync(join(cwd, "feature.txt"), "feature\n");
|
||||||
|
git(cwd, ["add", "feature.txt"]);
|
||||||
|
git(cwd, ["commit", "-m", "feature"]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
cwd,
|
||||||
|
facts: {
|
||||||
|
isGit: true,
|
||||||
|
worktreeRoot: cwd,
|
||||||
|
currentBranch: "feature",
|
||||||
|
remoteUrl: "https://example.com/repo.git",
|
||||||
|
absoluteGitDir: join(cwd, ".git"),
|
||||||
|
gitCommonDir: join(cwd, ".git"),
|
||||||
|
paseoWorktree: { isPaseoOwnedWorktree: false },
|
||||||
|
storedBaseRef: null,
|
||||||
|
resolvedBaseRef: "main",
|
||||||
|
mainRepoRoot: null,
|
||||||
|
comparisonBaseRef: "origin/main",
|
||||||
|
branchRemoteName: "origin",
|
||||||
|
branchMergeRef: "refs/heads/main",
|
||||||
|
upstreamStatus: {
|
||||||
|
ref: "refs/remotes/origin/main",
|
||||||
|
aheadBehind: { ahead: 0, behind: 0 },
|
||||||
|
},
|
||||||
|
pullRequestLookupTarget: { headRef: "feature" },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedMainCheckout(): {
|
||||||
|
cwd: string;
|
||||||
|
facts: Extract<CheckoutSnapshotFacts, { isGit: true }>;
|
||||||
|
} {
|
||||||
|
const cwd = mkdtempSync(join(tmpdir(), "paseo-ref-derived-main-"));
|
||||||
|
cleanupPaths.push(cwd);
|
||||||
|
git(cwd, ["init", "-b", "main"]);
|
||||||
|
git(cwd, ["config", "user.email", "repro@example.com"]);
|
||||||
|
git(cwd, ["config", "user.name", "Repro"]);
|
||||||
|
writeFileSync(join(cwd, "base.txt"), "base\n");
|
||||||
|
git(cwd, ["add", "base.txt"]);
|
||||||
|
git(cwd, ["commit", "-m", "base"]);
|
||||||
|
git(cwd, ["update-ref", "refs/remotes/origin/main", git(cwd, ["rev-parse", "HEAD"])]);
|
||||||
|
writeFileSync(join(cwd, "local.txt"), "local\n");
|
||||||
|
git(cwd, ["add", "local.txt"]);
|
||||||
|
git(cwd, ["commit", "-m", "local"]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
cwd,
|
||||||
|
facts: {
|
||||||
|
isGit: true,
|
||||||
|
worktreeRoot: cwd,
|
||||||
|
currentBranch: "main",
|
||||||
|
remoteUrl: "https://example.com/repo.git",
|
||||||
|
absoluteGitDir: join(cwd, ".git"),
|
||||||
|
gitCommonDir: join(cwd, ".git"),
|
||||||
|
paseoWorktree: { isPaseoOwnedWorktree: false },
|
||||||
|
storedBaseRef: null,
|
||||||
|
resolvedBaseRef: "main",
|
||||||
|
mainRepoRoot: null,
|
||||||
|
comparisonBaseRef: null,
|
||||||
|
branchRemoteName: "origin",
|
||||||
|
branchMergeRef: "refs/heads/main",
|
||||||
|
upstreamStatus: {
|
||||||
|
ref: "refs/remotes/origin/main",
|
||||||
|
aheadBehind: { ahead: 1, behind: 0 },
|
||||||
|
},
|
||||||
|
pullRequestLookupTarget: { headRef: "main" },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test("one comparison refresh updates a shared base and upstream ref", async () => {
|
||||||
|
const { cwd, facts } = seedFeatureCheckout();
|
||||||
|
startGitCommandMetrics();
|
||||||
|
const result = await getCheckoutRefDerivedState(
|
||||||
|
cwd,
|
||||||
|
facts,
|
||||||
|
{ aheadBehind: { ahead: 0, behind: 0 }, diffStat: null },
|
||||||
|
new Set(["origin/main"]),
|
||||||
|
);
|
||||||
|
await waitForGitCommandMetricsIdle({ quietMs: 50, timeoutMs: 5_000 });
|
||||||
|
const metrics = stopGitCommandMetrics();
|
||||||
|
|
||||||
|
expect(result.aheadBehind).toEqual({ ahead: 1, behind: 0 });
|
||||||
|
expect(result.upstreamStatus).toEqual({
|
||||||
|
ref: "refs/remotes/origin/main",
|
||||||
|
aheadBehind: { ahead: 1, behind: 0 },
|
||||||
|
});
|
||||||
|
expect(result.diffStat).toEqual({ additions: 1, deletions: 0 });
|
||||||
|
expect(metrics.submissions.map((command) => command.args[0]).sort()).toEqual([
|
||||||
|
"diff",
|
||||||
|
"ls-files",
|
||||||
|
"merge-base",
|
||||||
|
"rev-list",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an upstream move refreshes the main checkout shortstat", async () => {
|
||||||
|
const { cwd, facts } = seedMainCheckout();
|
||||||
|
git(cwd, ["update-ref", "refs/remotes/origin/main", git(cwd, ["rev-parse", "HEAD"])]);
|
||||||
|
|
||||||
|
const result = await getCheckoutRefDerivedState(
|
||||||
|
cwd,
|
||||||
|
facts,
|
||||||
|
{ aheadBehind: null, diffStat: { additions: 1, deletions: 0 } },
|
||||||
|
new Set(["origin/main"]),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
aheadBehind: null,
|
||||||
|
diffStat: null,
|
||||||
|
upstreamStatus: {
|
||||||
|
ref: "refs/remotes/origin/main",
|
||||||
|
aheadBehind: { ahead: 0, behind: 0 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an origin move refreshes an untracked main checkout shortstat", async () => {
|
||||||
|
const seeded = seedMainCheckout();
|
||||||
|
const facts = {
|
||||||
|
...seeded.facts,
|
||||||
|
branchRemoteName: null,
|
||||||
|
branchMergeRef: null,
|
||||||
|
upstreamStatus: null,
|
||||||
|
};
|
||||||
|
git(seeded.cwd, [
|
||||||
|
"update-ref",
|
||||||
|
"refs/remotes/origin/main",
|
||||||
|
git(seeded.cwd, ["rev-parse", "HEAD"]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await getCheckoutRefDerivedState(
|
||||||
|
seeded.cwd,
|
||||||
|
facts,
|
||||||
|
{ aheadBehind: null, diffStat: { additions: 1, deletions: 0 } },
|
||||||
|
new Set(["origin/main"]),
|
||||||
|
),
|
||||||
|
).toEqual({ aheadBehind: null, diffStat: null, upstreamStatus: null });
|
||||||
|
});
|
||||||
@@ -1615,8 +1615,22 @@ async function getAheadBehind(
|
|||||||
if (!comparisonBaseRef) {
|
if (!comparisonBaseRef) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
return getAheadBehindForComparisonRef(cwd, comparisonBaseRef, currentBranch, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpstreamStatus {
|
||||||
|
ref: string;
|
||||||
|
aheadBehind: AheadBehind;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getAheadBehindForComparisonRef(
|
||||||
|
cwd: string,
|
||||||
|
comparisonRef: string,
|
||||||
|
currentBranch: string,
|
||||||
|
context?: CheckoutContext,
|
||||||
|
): Promise<AheadBehind | null> {
|
||||||
const { stdout } = await runGitCommand(
|
const { stdout } = await runGitCommand(
|
||||||
["rev-list", "--left-right", "--count", `${comparisonBaseRef}...${currentBranch}`],
|
["rev-list", "--left-right", "--count", `${comparisonRef}...${currentBranch}`],
|
||||||
{ cwd, envOverlay: READ_ONLY_GIT_ENV, logger: context?.logger },
|
{ cwd, envOverlay: READ_ONLY_GIT_ENV, logger: context?.logger },
|
||||||
);
|
);
|
||||||
const [behindRaw, aheadRaw] = stdout.trim().split(/\s+/);
|
const [behindRaw, aheadRaw] = stdout.trim().split(/\s+/);
|
||||||
@@ -1628,11 +1642,6 @@ async function getAheadBehind(
|
|||||||
return { ahead, behind };
|
return { ahead, behind };
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UpstreamStatus {
|
|
||||||
ref: string;
|
|
||||||
aheadBehind: AheadBehind;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getUpstreamStatus(
|
async function getUpstreamStatus(
|
||||||
cwd: string,
|
cwd: string,
|
||||||
currentBranch: string,
|
currentBranch: string,
|
||||||
@@ -2554,7 +2563,7 @@ function parseCheckoutShortstat(text: string): CheckoutShortstat | null {
|
|||||||
|
|
||||||
const UNTRACKED_SHORTSTAT_MAX_FILES = 500;
|
const UNTRACKED_SHORTSTAT_MAX_FILES = 500;
|
||||||
|
|
||||||
async function countUntrackedAdditions(cwd: string): Promise<number> {
|
async function countUntrackedAdditions(cwd: string, throwOnGitError = false): Promise<number> {
|
||||||
try {
|
try {
|
||||||
const { stdout } = await runGitCommand(["ls-files", "--others", "--exclude-standard"], {
|
const { stdout } = await runGitCommand(["ls-files", "--others", "--exclude-standard"], {
|
||||||
cwd,
|
cwd,
|
||||||
@@ -2582,14 +2591,25 @@ async function countUntrackedAdditions(cwd: string): Promise<number> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return additions;
|
return additions;
|
||||||
} catch {
|
} catch (error) {
|
||||||
|
if (throwOnGitError) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleShortstatGitError(error: unknown, throwOnGitError = false): null {
|
||||||
|
if (throwOnGitError) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
async function getCheckoutShortstatUncached(
|
async function getCheckoutShortstatUncached(
|
||||||
cwd: string,
|
cwd: string,
|
||||||
context?: CheckoutContext,
|
context?: CheckoutContext,
|
||||||
|
options?: { throwOnGitError?: boolean },
|
||||||
): Promise<CheckoutShortstat | null> {
|
): Promise<CheckoutShortstat | null> {
|
||||||
if (context?.facts?.isGit === false) {
|
if (context?.facts?.isGit === false) {
|
||||||
return null;
|
return null;
|
||||||
@@ -2632,7 +2652,7 @@ async function getCheckoutShortstatUncached(
|
|||||||
cwd,
|
cwd,
|
||||||
envOverlay: READ_ONLY_GIT_ENV,
|
envOverlay: READ_ONLY_GIT_ENV,
|
||||||
}),
|
}),
|
||||||
countUntrackedAdditions(cwd),
|
countUntrackedAdditions(cwd, options?.throwOnGitError),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const tracked = parseCheckoutShortstat(stdout);
|
const tracked = parseCheckoutShortstat(stdout);
|
||||||
@@ -2644,8 +2664,8 @@ async function getCheckoutShortstatUncached(
|
|||||||
return { additions: untrackedAdditions, deletions: 0 };
|
return { additions: untrackedAdditions, deletions: 0 };
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
} catch {
|
} catch (error) {
|
||||||
return null;
|
return handleShortstatGitError(error, options?.throwOnGitError);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2713,6 +2733,117 @@ export async function getCheckoutShortstat(
|
|||||||
return getOrLoadCheckoutShortstat(cwd, context, options);
|
return getOrLoadCheckoutShortstat(cwd, context, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CheckoutRefDerivedState {
|
||||||
|
aheadBehind: AheadBehind | null;
|
||||||
|
diffStat: CheckoutShortstat | null;
|
||||||
|
upstreamStatus: UpstreamStatus | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRemoteTrackingRef(ref: string): string {
|
||||||
|
return ref.startsWith("refs/remotes/") ? ref.slice("refs/remotes/".length) : ref;
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkoutFactsConfiguredRemoteRef(
|
||||||
|
facts: Extract<CheckoutSnapshotFacts, { isGit: true }>,
|
||||||
|
): string | null {
|
||||||
|
const trackedBranch = facts.branchMergeRef?.startsWith("refs/heads/")
|
||||||
|
? facts.branchMergeRef.slice("refs/heads/".length)
|
||||||
|
: null;
|
||||||
|
return facts.branchRemoteName && facts.branchRemoteName !== "." && trackedBranch
|
||||||
|
? `${facts.branchRemoteName}/${trackedBranch}`
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCheckoutRefMovement(
|
||||||
|
facts: Extract<CheckoutSnapshotFacts, { isGit: true }>,
|
||||||
|
movedRemoteRefs: ReadonlySet<string>,
|
||||||
|
): {
|
||||||
|
baseMoved: boolean;
|
||||||
|
comparisonRef: string | null;
|
||||||
|
currentBranch: string | null;
|
||||||
|
normalizedResolvedBase: string | null;
|
||||||
|
upstreamMoved: boolean;
|
||||||
|
upstreamRef: string | null;
|
||||||
|
} {
|
||||||
|
const currentBranch = facts.currentBranch;
|
||||||
|
const comparisonRef = facts.comparisonBaseRef;
|
||||||
|
const normalizedMoves = new Set([...movedRemoteRefs].map(normalizeRemoteTrackingRef));
|
||||||
|
const normalizedResolvedBase = facts.resolvedBaseRef
|
||||||
|
? branchNameFromRef(facts.resolvedBaseRef)
|
||||||
|
: null;
|
||||||
|
const shortstatRemoteRef =
|
||||||
|
currentBranch && (!normalizedResolvedBase || normalizedResolvedBase === currentBranch)
|
||||||
|
? `origin/${currentBranch}`
|
||||||
|
: null;
|
||||||
|
const baseMoved = [facts.storedBaseRef, facts.resolvedBaseRef, comparisonRef, shortstatRemoteRef]
|
||||||
|
.filter((ref): ref is string => Boolean(ref))
|
||||||
|
.map(normalizeRemoteTrackingRef)
|
||||||
|
.some((ref) => normalizedMoves.has(ref));
|
||||||
|
const upstreamRef = facts.upstreamStatus?.ref ?? checkoutFactsConfiguredRemoteRef(facts);
|
||||||
|
const upstreamMoved = upstreamRef
|
||||||
|
? normalizedMoves.has(normalizeRemoteTrackingRef(upstreamRef))
|
||||||
|
: false;
|
||||||
|
return {
|
||||||
|
baseMoved,
|
||||||
|
comparisonRef,
|
||||||
|
currentBranch,
|
||||||
|
normalizedResolvedBase,
|
||||||
|
upstreamMoved,
|
||||||
|
upstreamRef,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCheckoutRefDerivedState(
|
||||||
|
cwd: string,
|
||||||
|
facts: Extract<CheckoutSnapshotFacts, { isGit: true }>,
|
||||||
|
current: Pick<CheckoutRefDerivedState, "aheadBehind" | "diffStat">,
|
||||||
|
movedRemoteRefs: ReadonlySet<string>,
|
||||||
|
context?: CheckoutContext,
|
||||||
|
): Promise<CheckoutRefDerivedState> {
|
||||||
|
const {
|
||||||
|
baseMoved,
|
||||||
|
comparisonRef,
|
||||||
|
currentBranch,
|
||||||
|
normalizedResolvedBase,
|
||||||
|
upstreamMoved,
|
||||||
|
upstreamRef,
|
||||||
|
} = getCheckoutRefMovement(facts, movedRemoteRefs);
|
||||||
|
|
||||||
|
let aheadBehind = current.aheadBehind;
|
||||||
|
let diffStat = current.diffStat;
|
||||||
|
if (baseMoved && currentBranch && facts.resolvedBaseRef) {
|
||||||
|
aheadBehind = await getAheadBehind(cwd, facts.resolvedBaseRef, currentBranch, {
|
||||||
|
...context,
|
||||||
|
facts,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (baseMoved || (upstreamMoved && currentBranch === normalizedResolvedBase)) {
|
||||||
|
diffStat = await getCheckoutShortstatUncached(
|
||||||
|
cwd,
|
||||||
|
{ ...context, facts },
|
||||||
|
{ throwOnGitError: true },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let upstreamStatus = facts.upstreamStatus;
|
||||||
|
if (upstreamMoved && currentBranch && upstreamRef) {
|
||||||
|
const normalizedUpstream = normalizeRemoteTrackingRef(upstreamRef);
|
||||||
|
const normalizedComparison = comparisonRef ? normalizeRemoteTrackingRef(comparisonRef) : null;
|
||||||
|
const upstreamAheadBehind =
|
||||||
|
baseMoved && normalizedComparison === normalizedUpstream
|
||||||
|
? aheadBehind
|
||||||
|
: await getAheadBehindForComparisonRef(cwd, upstreamRef, currentBranch, context);
|
||||||
|
upstreamStatus = upstreamAheadBehind
|
||||||
|
? {
|
||||||
|
ref: facts.upstreamStatus?.ref ?? `refs/remotes/${normalizedUpstream}`,
|
||||||
|
aheadBehind: upstreamAheadBehind,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { aheadBehind, diffStat, upstreamStatus };
|
||||||
|
}
|
||||||
|
|
||||||
export interface CheckoutWorktreeState {
|
export interface CheckoutWorktreeState {
|
||||||
isDirty: boolean;
|
isDirty: boolean;
|
||||||
diffStat: CheckoutShortstat | null;
|
diffStat: CheckoutShortstat | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user