diff --git a/packages/server/src/server/workspace-git-fetch.test.ts b/packages/server/src/server/workspace-git-fetch.test.ts new file mode 100644 index 000000000..c6d1c943d --- /dev/null +++ b/packages/server/src/server/workspace-git-fetch.test.ts @@ -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" }, + ]); + }); +}); diff --git a/packages/server/src/server/workspace-git-fetch.ts b/packages/server/src/server/workspace-git-fetch.ts new file mode 100644 index 000000000..795066c38 --- /dev/null +++ b/packages/server/src/server/workspace-git-fetch.ts @@ -0,0 +1,139 @@ +import { runGitCommand } from "../utils/run-git-command.js"; + +export interface WorkspaceGitFetchResult { + changes: WorkspaceGitRemoteRefChange[] | null; + nonRemoteRefsChanged?: boolean; + remoteRefs?: ReadonlySet; + 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 { + let before: Map | null = null; + let after: Map | 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): Set { + 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> { + const { stdout } = await runGitCommand(["for-each-ref", "--format=%(refname)%00%(objectname)"], { + cwd, + }); + return parseWorkspaceGitRefs(stdout); +} + +export function parseWorkspaceGitRefs(stdout: string): Map { + const refs = new Map(); + 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, + after: ReadonlyMap, +): WorkspaceGitRefDiff { + const remotePrefix = "refs/remotes/"; + const beforeRemote = new Map(); + const afterRemote = new Map(); + 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, + after: ReadonlyMap, +): 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; +} diff --git a/packages/server/src/server/workspace-git-noop-fetch.local.e2e.test.ts b/packages/server/src/server/workspace-git-noop-fetch.local.e2e.test.ts new file mode 100644 index 000000000..4634daa8c --- /dev/null +++ b/packages/server/src/server/workspace-git-noop-fetch.local.e2e.test.ts @@ -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; + +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 { + 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() { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((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 { + 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, + quietMs = 1_500, +): Promise<{ + fetchCount: number; + gitCommands: number; + nonRemoteRefsChanged: boolean | undefined; + operations: Record; + snapshotUpdates: number; +}> { + const fetchStarted = createDeferred(); + const releaseFetch = createDeferred(); + 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; + snapshotUpdates: number; +}> { + const initialFetchCompleted = createDeferred(); + 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); diff --git a/packages/server/src/server/workspace-git-service.observation.test.ts b/packages/server/src/server/workspace-git-service.observation.test.ts index 6ca28b81d..7f7f0db35 100644 --- a/packages/server/src/server/workspace-git-service.observation.test.ts +++ b/packages/server/src/server/workspace-git-service.observation.test.ts @@ -157,6 +157,10 @@ function createService( deps: { subscribe: watcher.subscribe, getCheckoutSnapshotFacts: vi.fn(async (cwd: string) => createCheckoutFacts(cwd)), + getCheckoutRefDerivedState: vi.fn(async (_cwd, facts, current) => ({ + ...current, + upstreamStatus: facts.upstreamStatus, + })), getCheckoutStatus, getCheckoutShortstat, getCheckoutWorktreeState: vi.fn(async (cwd: string) => { @@ -441,6 +445,441 @@ describe("WorkspaceGitService checkout observation", () => { service.dispose(); }); + test("a loose remote-ref watcher echo during fetch coalesces into the narrow refresh", async () => { + const watcher = createWatcherHarness(); + const releaseFetch = createDeferred(); + 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(); + const releaseFetch = createDeferred(); + 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(); + const releaseFetch = createDeferred(); + 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(); + const releaseFetch = createDeferred(); + 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(); + 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 + >() + .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(); + const releaseFetch = createDeferred(); + 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 () => { const watcher = createWatcherHarness(); const getCheckoutSnapshotFacts = vi.fn(async (cwd: string) => createLinkedCheckoutFacts(cwd)); @@ -535,7 +974,7 @@ describe("WorkspaceGitService checkout observation", () => { 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 getCheckoutSnapshotFacts = vi.fn(async (cwd: string) => { const facts = createLinkedCheckoutFacts(cwd); @@ -577,7 +1016,7 @@ describe("WorkspaceGitService checkout observation", () => { ]); await vi.advanceTimersByTimeAsync(1_000); await vi.waitFor(() => { - expect(getCalledCwds(getCheckoutStatus)).toEqual([WORKTREE_A]); + expect(getCalledCwds(getCheckoutStatus)).toEqual([WORKTREE_A, WORKTREE_B]); }); 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 () => { const watcher = createWatcherHarness(); const fetch = createDeferred(); - 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 worktrees = Array.from({ length: 10 }, (_, index) => path.resolve(`/tmp/paseo-shared-worktree-${index}`), @@ -886,7 +1328,7 @@ describe("WorkspaceGitService checkout observation", () => { await vi.waitFor(() => { expect(runGitFetch).toHaveBeenCalledTimes(2); }); - expect(runGitFetch).toHaveBeenLastCalledWith(worktrees[1]); + expect(runGitFetch).toHaveBeenLastCalledWith(worktrees[1], expect.anything()); for (const subscription of subscriptions.slice(1)) { subscription.unsubscribe(); diff --git a/packages/server/src/server/workspace-git-service.primitive.test.ts b/packages/server/src/server/workspace-git-service.primitive.test.ts index f0669c2f1..180fb5416 100644 --- a/packages/server/src/server/workspace-git-service.primitive.test.ts +++ b/packages/server/src/server/workspace-git-service.primitive.test.ts @@ -334,6 +334,10 @@ function buildDefaultServiceDeps() { return { subscribe: vi.fn(async () => ({ unsubscribe: vi.fn(async () => {}) })), 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)), getCheckoutShortstat: vi.fn(async () => ({ additions: 1, @@ -349,7 +353,10 @@ function buildDefaultServiceDeps() { forgeOverrides: { github: createGitHubServiceStub() }, resolveAbsoluteGitDir: vi.fn(async () => join(REPO_CWD, ".git")), hasOriginRemote: vi.fn(async () => false), - runGitFetch: vi.fn(() => createDeferred().promise), + runGitFetch: vi.fn(async () => { + await createDeferred().promise; + return { changes: [], error: null }; + }), runGitCommand: vi.fn(async () => ({ stdout: `${REPO_CWD}\n`, stderr: "", diff --git a/packages/server/src/server/workspace-git-service.test.ts b/packages/server/src/server/workspace-git-service.test.ts index f7f51ad56..a83487e2f 100644 --- a/packages/server/src/server/workspace-git-service.test.ts +++ b/packages/server/src/server/workspace-git-service.test.ts @@ -264,6 +264,10 @@ function buildDefaultTestServiceDeps() { return { subscribe: vi.fn(async () => ({ unsubscribe: vi.fn(async () => {}) })), 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)), getCheckoutShortstat: vi.fn(async () => ({ additions: 1, @@ -274,7 +278,7 @@ function buildDefaultTestServiceDeps() { forgeOverrides: { github: createGitHubServiceStub() }, resolveAbsoluteGitDir: vi.fn(async () => join(REPO_CWD, ".git")), hasOriginRemote: vi.fn(async () => false), - runGitFetch: vi.fn(async () => {}), + runGitFetch: vi.fn(async () => ({ changes: [], error: null })), runGitCommand: vi.fn(async () => ({ stdout: `${REPO_CWD}\n`, stderr: "", @@ -842,17 +846,28 @@ describe("WorkspaceGitServiceImpl", () => { test("repo-level fetch intervals are shared for workspaces in the same repo", async () => { const firstFetch = createDeferred(); - 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 getCheckoutSnapshotFacts = vi.fn(async (cwd: string) => ({ ...createCheckoutSnapshotFacts(cwd), gitCommonDir: 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 service = createService({ getCheckoutSnapshotFacts, + getCheckoutRefDerivedState, resolveAbsoluteGitDir, hasOriginRemote, runGitFetch, @@ -877,7 +892,8 @@ describe("WorkspaceGitServiceImpl", () => { await flushPromises(); await vi.advanceTimersByTimeAsync(1_000); await vi.waitFor(() => { - expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(4); + expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(2); + expect(getCheckoutRefDerivedState).toHaveBeenCalledTimes(2); }); await vi.advanceTimersByTimeAsync(180_000); @@ -890,6 +906,165 @@ describe("WorkspaceGitServiceImpl", () => { 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(); + 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(); + 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 () => { const getPullRequestStatus = vi .fn<() => Promise>() @@ -1180,7 +1355,10 @@ describe("WorkspaceGitServiceImpl", () => { const backgroundFetch = createDeferred(); const service = createService({ getCheckoutWorktreeState, - runGitFetch: vi.fn(() => backgroundFetch.promise), + runGitFetch: vi.fn(async () => { + await backgroundFetch.promise; + return { changes: [], error: null }; + }), subscribe, }); const workspaceListener = vi.fn(); diff --git a/packages/server/src/server/workspace-git-service.ts b/packages/server/src/server/workspace-git-service.ts index 01909810d..917811c70 100644 --- a/packages/server/src/server/workspace-git-service.ts +++ b/packages/server/src/server/workspace-git-service.ts @@ -15,6 +15,7 @@ import { type CheckoutDiffCompare, type CheckoutDiffResult, getCheckoutDiff, + getCheckoutRefDerivedState, getCheckoutSnapshotFacts, getCheckoutShortstat, getCheckoutStatus, @@ -46,14 +47,22 @@ import { isRealpathInsideRoot, } from "../utils/path.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 { READ_ONLY_GIT_ENV } from "./checkout-git-utils.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 { checkoutLiteFromGitSnapshot } from "./workspace-registry-model.js"; const WORKSPACE_GIT_WATCH_DEBOUNCE_MS = 1_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; const FORGE_PR_STATUS_POLL_FAST_INTERVAL_MS = 20_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. const WORKSPACE_GIT_AUXILIARY_CACHE_MAX = 256; +function mergeSets( + left: ReadonlySet, + right: ReadonlySet, +): { merged: Set; 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 { return ( createHash("sha256").update(cwd).digest().readUInt32BE(0) % WORKSPACE_GIT_SELF_HEAL_INTERVAL_MS @@ -269,15 +293,17 @@ interface WorkspaceGitRefreshRequest { reason: string; notify: boolean; queueIfBusy: boolean; + movedRemoteRefs: Set; } interface ScheduledWorkspaceGitRefreshOptions { force?: boolean; - scope?: "structure" | "worktree"; + scope?: "refs" | "structure" | "worktree"; includeForge?: boolean; emitUnchanged?: boolean; reason?: string; queueIfBusy?: boolean; + movedRemoteRefs?: ReadonlySet; } type WorkspaceGitRefreshState = @@ -294,6 +320,7 @@ type WorkspaceGitRefreshState = interface WorkspaceGitServiceDependencies { subscribe: typeof parcelWatcher.subscribe; getCheckoutSnapshotFacts: typeof getCheckoutSnapshotFacts; + getCheckoutRefDerivedState: typeof getCheckoutRefDerivedState; getCheckoutStatus: typeof getCheckoutStatus; getCheckoutShortstat: typeof getCheckoutShortstat; getCheckoutWorktreeState: typeof getCheckoutWorktreeState; @@ -311,7 +338,10 @@ interface WorkspaceGitServiceDependencies { forgeOverrides?: Record; resolveAbsoluteGitDir: (cwd: string) => Promise; hasOriginRemote: (cwd: string) => Promise; - runGitFetch: (cwd: string) => Promise; + runGitFetch: ( + cwd: string, + observer: WorkspaceGitFetchObserver, + ) => Promise; runGitCommand: typeof runGitCommand; getWorkspaceGitSelfHealPhaseMs: typeof getWorkspaceGitSelfHealPhaseMs; now: () => Date; @@ -405,11 +435,20 @@ interface RepoGitTarget { recovery: WatchRecoveryState; intervalId: NodeJS.Timeout | null; fetchInFlight: boolean; + bufferedFetchMetadataEvents: parcelWatcher.Event[]; + recentFetchRemoteRefChanges: Map< + string, + { change: WorkspaceGitRemoteRefChange; expiresAtMs: number } + >; + knownRemoteRefs: Set | null; closed: boolean; } interface RepoMetadataWorkspaceRefresh { refreshBase: boolean; + structural: boolean; + movedRemoteRefs: Set; + queueIfBusy: boolean; } interface WorkingTreeWatchTarget { @@ -433,6 +472,10 @@ interface WatchRecoveryState { timer: NodeJS.Timeout | null; } +function setsEqual(left: ReadonlySet, right: ReadonlySet): boolean { + return left.size === right.size && [...left].every((value) => right.has(value)); +} + type WorkingTreeWatchFallbackReason = | "not_a_git_checkout" | "watcher_error" @@ -456,6 +499,7 @@ function buildDefaultWorkspaceGitServiceDeps(): WorkspaceGitServiceDependencies return { subscribe: subscribeToWorkspaceFileChanges, getCheckoutSnapshotFacts, + getCheckoutRefDerivedState, getCheckoutStatus, getCheckoutShortstat, getCheckoutWorktreeState, @@ -467,7 +511,7 @@ function buildDefaultWorkspaceGitServiceDeps(): WorkspaceGitServiceDependencies listPaseoWorktrees, resolveAbsoluteGitDir, hasOriginRemote, - runGitFetch, + runGitFetch: fetchWorkspaceGitRemote, runGitCommand, getWorkspaceGitSelfHealPhaseMs, now: () => new Date(), @@ -863,6 +907,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { reason: "refresh", notify: true, queueIfBusy: false, + movedRemoteRefs: new Set(), }); this.scheduleWorkspaceObservationSetup(target); } @@ -1130,6 +1175,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { reason: "initial", notify: true, queueIfBusy: false, + movedRemoteRefs: new Set(), }); }); } @@ -1475,6 +1521,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { reason: "working-tree-watch-fallback", notify: true, queueIfBusy: true, + movedRemoteRefs: new Set(), }); if (target.repoRoot === null && workspaceTarget.latestGit?.isGit === true) { workspaceTarget.observationSetupComplete = false; @@ -1741,6 +1788,9 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { recovery: { attemptCount: 0, timer: null }, intervalId: null, fetchInFlight: false, + bufferedFetchMetadataEvents: [], + recentFetchRemoteRefChanges: new Map(), + knownRemoteRefs: null, closed: false, }; this.repoTargets.set(repoGitRoot, repoTarget); @@ -1821,12 +1871,24 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { ignore.every((ignoredPath) => !isRealpathInsideRoot(ignoredPath, event.path)), ); if (relevantEvents.length > 0) { - this.scheduleRepoMetadataRefresh( - target, - "git-metadata-watch", - true, - this.routeRepoMetadataEvents(target, relevantEvents), - ); + const immediateEvents = target.fetchInFlight + ? relevantEvents.filter((event) => { + if (this.isFetchRemoteMetadataEvent(target, event)) { + target.bufferedFetchMetadataEvents.push(event); + 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 }, @@ -1922,6 +1984,16 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { 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( target: RepoGitTarget, event: parcelWatcher.Event, @@ -1942,10 +2014,39 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { if (effect.namespace === "local") { this.routeLocalBranchRef(target, effect.ref, refreshes); } 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; case "all": + if ( + commonRelativePath === "packed-refs" || + commonRelativePath?.startsWith("reftable/") === true + ) { + target.knownRemoteRefs = null; + } return false; } } @@ -2016,6 +2117,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { target: RepoGitTarget, remoteRef: string, refreshes: Map, + options?: { narrow?: boolean; queueIfBusy?: boolean }, ): void { const qualifiedRemoteRef = `refs/remotes/${remoteRef}`; for (const workspaceKey of target.workspaceKeys) { @@ -2028,16 +2130,26 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { facts.branchRemoteName && facts.branchRemoteName !== "." && trackedBranch ? `${facts.branchRemoteName}/${trackedBranch}` : null; + const shortstatRemoteRef = + facts.currentBranch && + (!facts.resolvedBaseRef || branchNameFromRef(facts.resolvedBaseRef) === facts.currentBranch) + ? `origin/${facts.currentBranch}` + : null; const refs = [ facts.storedBaseRef, facts.resolvedBaseRef, facts.comparisonBaseRef, facts.upstreamStatus?.ref, configuredRemoteRef, + shortstatRemoteRef, ]; const usesRemoteRef = refs.some((ref) => ref === remoteRef || ref === qualifiedRemoteRef); 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, workspaceKey: string, refreshBase: boolean, + options?: { + structural?: boolean; + movedRemoteRef?: string; + queueIfBusy?: boolean; + }, ): void { const previous = refreshes.get(workspaceKey); + const movedRemoteRefs = new Set(previous?.movedRemoteRefs); + if (options?.movedRemoteRef) { + movedRemoteRefs.add(options.movedRemoteRef); + } refreshes.set(workspaceKey, { 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 = routedRefreshes ?? new Map( - Array.from(target.workspaceKeys, (workspaceKey) => [workspaceKey, { refreshBase: true }]), + Array.from(target.workspaceKeys, (workspaceKey) => [ + workspaceKey, + { + refreshBase: true, + structural: true, + movedRemoteRefs: new Set(), + queueIfBusy: true, + }, + ]), ); for (const [workspaceKey, refresh] of refreshes) { const workspaceTarget = this.workspaceTargets.get(workspaceKey); if (workspaceTarget) { + let scope: ScheduledWorkspaceGitRefreshOptions["scope"]; + if (!refreshWorktree) { + scope = refresh.structural ? "structure" : "refs"; + } if (refresh.refreshBase) { this.invalidateCheckoutDiffCache(workspaceTarget.cwd, "base"); if (workspaceTarget.latestFacts?.isGit) { @@ -2084,9 +2220,11 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { } } this.scheduleWorkspaceRefresh(workspaceTarget, { - scope: refreshWorktree ? undefined : "structure", + scope, emitUnchanged: refresh.refreshBase, reason, + queueIfBusy: refresh.queueIfBusy, + movedRemoteRefs: refresh.movedRemoteRefs, }); } } @@ -2129,6 +2267,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { reason: "git-metadata-watch-fallback", notify: true, queueIfBusy: true, + movedRemoteRefs: new Set(), }); }), ); @@ -2221,6 +2360,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { reason: "self-heal-git", notify: true, queueIfBusy: true, + movedRemoteRefs: new Set(), }).catch((error) => { this.logger.warn( { err: error, cwd: target.cwd, reason: "self-heal-git" }, @@ -2488,7 +2628,8 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { (request.refreshStructure && !active.refreshStructure) || (request.refreshWorktree && !active.refreshWorktree) || (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) { target.refreshState.queued = this.mergeRefreshRequests(target.refreshState.queued, request); } @@ -2533,6 +2674,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { reason: options?.reason ?? defaultReason, notify, queueIfBusy: false, + movedRemoteRefs: new Set(), }; } @@ -2554,13 +2696,14 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { const scope = options?.scope; return { force: options?.force === true, - refreshStructure: scope !== "worktree", - refreshWorktree: scope !== "structure", + refreshStructure: scope !== "worktree" && scope !== "refs", + refreshWorktree: scope !== "structure" && scope !== "refs", includeForge: options?.includeForge ?? false, emitUnchanged: options?.emitUnchanged, reason: options?.reason ?? "watch", notify: 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 upgradesForge = request.includeForge && !pending.includeForge; const upgradesEmit = request.emitUnchanged === true && pending.emitUnchanged !== true; + const { merged: movedRemoteRefs, added: upgradesMovedRefs } = mergeSets( + pending.movedRemoteRefs, + request.movedRemoteRefs, + ); return { force, refreshStructure: pending.refreshStructure || request.refreshStructure, @@ -2585,11 +2732,17 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { includeForge: pending.includeForge || request.includeForge, emitUnchanged: pending.emitUnchanged === true || request.emitUnchanged === true, reason: - upgradesForce || upgradesStructure || upgradesWorktree || upgradesForge || upgradesEmit + upgradesForce || + upgradesStructure || + upgradesWorktree || + upgradesForge || + upgradesEmit || + upgradesMovedRefs ? request.reason : pending.reason, notify: pending.notify || request.notify, queueIfBusy: pending.queueIfBusy || request.queueIfBusy, + movedRemoteRefs, }; } @@ -2602,7 +2755,11 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { let failure: { error: unknown } | null = null; while (true) { - if (request.emitUnchanged === true && target.latestSnapshot) { + if ( + request.emitUnchanged === true && + request.movedRemoteRefs.size === 0 && + target.latestSnapshot + ) { this.rememberSnapshot(target, target.latestSnapshot, { notify: request.notify, forceEmit: true, @@ -2621,7 +2778,8 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { snapshot = admittedSnapshot; this.rememberSnapshot(target, snapshot, { notify: request.notify, - forceEmit: request.force, + forceEmit: + request.force || (request.emitUnchanged === true && request.movedRemoteRefs.size > 0), }); failure = null; } catch (error) { @@ -2653,6 +2811,25 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { request: WorkspaceGitRefreshRequest, ): Promise { 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) { facts = await this.refreshGitSnapshot(target, request); } else if (request.refreshWorktree) { @@ -2670,6 +2847,36 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { return snapshot; } + private async refreshRefDerivedSnapshot( + target: WorkspaceGitTarget, + facts: Extract, + movedRemoteRefs: ReadonlySet, + ): Promise { + 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( target: WorkspaceGitTarget, facts: CheckoutSnapshotFacts, @@ -2899,10 +3106,17 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { "Running background git fetch", ); - let succeeded = false; + let result: WorkspaceGitFetchResult | null = null; + const eventsBeforeFetchSnapshot: parcelWatcher.Event[] = []; try { - await this.deps.runGitFetch(target.cwd); - succeeded = true; + result = await this.deps.runGitFetch(target.cwd, { + onRefSnapshot: (phase) => { + const events = target.bufferedFetchMetadataEvents.splice(0); + if (phase === "before") { + eventsBeforeFetchSnapshot.push(...events); + } + }, + }); } catch (error) { this.logger.warn( { err: error, repoGitRoot: target.repoGitRoot, cwd: target.cwd }, @@ -2911,10 +3125,73 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { } finally { 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; } - 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(); + 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 { @@ -3250,11 +3527,3 @@ function computeGenericForgeNextInterval( FORGE_PR_STATUS_POLL_ERROR_BACKOFF_CAP_MS, ); } - -async function runGitFetch(cwd: string): Promise { - await runGitCommand(["fetch", "origin", "--prune"], { - cwd, - envOverlay: { GIT_TERMINAL_PROMPT: "0" }, - timeout: 120_000, - }); -} diff --git a/packages/server/src/utils/checkout-git-ref-derived.test.ts b/packages/server/src/utils/checkout-git-ref-derived.test.ts new file mode 100644 index 000000000..ddafaab6b --- /dev/null +++ b/packages/server/src/utils/checkout-git-ref-derived.test.ts @@ -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; +} { + 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; +} { + 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 }); +}); diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts index 7dcb8bf28..468600de7 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -1615,8 +1615,22 @@ async function getAheadBehind( if (!comparisonBaseRef) { 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 { 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 }, ); const [behindRaw, aheadRaw] = stdout.trim().split(/\s+/); @@ -1628,11 +1642,6 @@ async function getAheadBehind( return { ahead, behind }; } -interface UpstreamStatus { - ref: string; - aheadBehind: AheadBehind; -} - async function getUpstreamStatus( cwd: string, currentBranch: string, @@ -2554,7 +2563,7 @@ function parseCheckoutShortstat(text: string): CheckoutShortstat | null { const UNTRACKED_SHORTSTAT_MAX_FILES = 500; -async function countUntrackedAdditions(cwd: string): Promise { +async function countUntrackedAdditions(cwd: string, throwOnGitError = false): Promise { try { const { stdout } = await runGitCommand(["ls-files", "--others", "--exclude-standard"], { cwd, @@ -2582,14 +2591,25 @@ async function countUntrackedAdditions(cwd: string): Promise { } } return additions; - } catch { + } catch (error) { + if (throwOnGitError) { + throw error; + } return 0; } } +function handleShortstatGitError(error: unknown, throwOnGitError = false): null { + if (throwOnGitError) { + throw error; + } + return null; +} + async function getCheckoutShortstatUncached( cwd: string, context?: CheckoutContext, + options?: { throwOnGitError?: boolean }, ): Promise { if (context?.facts?.isGit === false) { return null; @@ -2632,7 +2652,7 @@ async function getCheckoutShortstatUncached( cwd, envOverlay: READ_ONLY_GIT_ENV, }), - countUntrackedAdditions(cwd), + countUntrackedAdditions(cwd, options?.throwOnGitError), ]); const tracked = parseCheckoutShortstat(stdout); @@ -2644,8 +2664,8 @@ async function getCheckoutShortstatUncached( return { additions: untrackedAdditions, deletions: 0 }; } return null; - } catch { - return null; + } catch (error) { + return handleShortstatGitError(error, options?.throwOnGitError); } } @@ -2713,6 +2733,117 @@ export async function getCheckoutShortstat( 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, +): 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, + movedRemoteRefs: ReadonlySet, +): { + 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, + current: Pick, + movedRemoteRefs: ReadonlySet, + context?: CheckoutContext, +): Promise { + 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 { isDirty: boolean; diffStat: CheckoutShortstat | null;