diff --git a/packages/server/src/server/git-metadata-event-rules.test.ts b/packages/server/src/server/git-metadata-event-rules.test.ts new file mode 100644 index 000000000..8c7e49197 --- /dev/null +++ b/packages/server/src/server/git-metadata-event-rules.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, test } from "vitest"; +import { + classifyGitMetadataPath, + GIT_METADATA_EVENT_RULES, + getPrunedGitMetadataPaths, +} from "./git-metadata-event-rules.js"; + +describe("Git metadata event rules", () => { + test("has unique rule names", () => { + const names = GIT_METADATA_EVENT_RULES.map((rule) => rule.id); + expect(new Set(names).size).toBe(names.length); + }); + + test("derives watcher pruning from the same inventory", () => { + expect(getPrunedGitMetadataPaths("common")).toEqual(["hooks", "logs", "objects"]); + expect(getPrunedGitMetadataPaths("worktree")).toEqual([]); + }); + + test.each([ + ["worktree", "index", { kind: "owner", refreshBase: false }], + ["worktree", "HEAD", { kind: "owner", refreshBase: true }], + ["worktree", "config.worktree", { kind: "owner", refreshBase: true }], + ["worktree", "paseo/worktree.json", { kind: "owner", refreshBase: true }], + ["worktree", "COMMIT_EDITMSG", { kind: "ignore" }], + ["worktree", "ORIG_HEAD", { kind: "ignore" }], + ["common", "index", { kind: "owner", refreshBase: false }], + ["common", "HEAD", { kind: "owner", refreshBase: true }], + ["common", "refs/heads/feature", { kind: "ref", namespace: "local", ref: "feature" }], + [ + "common", + "refs/remotes/origin/main", + { kind: "ref", namespace: "remote", ref: "origin/main" }, + ], + ["common", "packed-refs", { kind: "all", refreshBase: true }], + ["common", "reftable/tables.list", { kind: "all", refreshBase: true }], + ["common", "config", { kind: "all", refreshBase: true }], + ["common", "worktrees/new/index", { kind: "ignore" }], + ["common", "hooks/pre-commit", { kind: "ignore" }], + ["common", "logs/refs/heads/main", { kind: "ignore" }], + ["common", "objects/ab/cdef", { kind: "ignore" }], + ["common", "refs/tags/v1", { kind: "ignore" }], + ["common", "FETCH_HEAD", { kind: "ignore" }], + ["common", "packed-refs.lock", { kind: "ignore" }], + ] as const)("classifies %s metadata path %s", (scope, path, expected) => { + expect(classifyGitMetadataPath(scope, path)).toEqual(expected); + }); +}); diff --git a/packages/server/src/server/git-metadata-event-rules.ts b/packages/server/src/server/git-metadata-event-rules.ts new file mode 100644 index 000000000..dab6bc488 --- /dev/null +++ b/packages/server/src/server/git-metadata-event-rules.ts @@ -0,0 +1,177 @@ +export type GitMetadataScope = "worktree" | "common"; + +type GitMetadataEventRule = { + id: string; + scope: GitMetadataScope | "both"; + path: string; + prune?: true; +} & ( + | { match: "exact" | "prefix" | "suffix"; route: "ignore" } + | { match: "exact" | "prefix" | "suffix"; route: "owner" | "all"; refreshBase: boolean } + | { match: "prefix"; route: "local-ref" | "remote-ref" } +); + +export type GitMetadataEffect = + | { kind: "ignore" } + | { kind: "owner" | "all"; refreshBase: boolean } + | { kind: "ref"; namespace: "local" | "remote"; ref: string }; + +export const GIT_METADATA_EVENT_RULES = [ + { id: "lock", scope: "both", match: "suffix", path: ".lock", route: "ignore" }, + { + id: "worktree-index", + scope: "worktree", + match: "exact", + path: "index", + route: "owner", + refreshBase: false, + }, + { + id: "worktree-head", + scope: "worktree", + match: "exact", + path: "HEAD", + route: "owner", + refreshBase: true, + }, + { + id: "worktree-config", + scope: "worktree", + match: "exact", + path: "config.worktree", + route: "owner", + refreshBase: true, + }, + { + id: "paseo-worktree-metadata", + scope: "worktree", + match: "exact", + path: "paseo/worktree.json", + route: "owner", + refreshBase: true, + }, + { + id: "main-index", + scope: "common", + match: "exact", + path: "index", + route: "owner", + refreshBase: false, + }, + { + id: "main-head", + scope: "common", + match: "exact", + path: "HEAD", + route: "owner", + refreshBase: true, + }, + { + id: "local-branch", + scope: "common", + match: "prefix", + path: "refs/heads/", + route: "local-ref", + }, + { + id: "remote-branch", + scope: "common", + match: "prefix", + path: "refs/remotes/", + route: "remote-ref", + }, + { + id: "packed-refs", + scope: "common", + match: "exact", + path: "packed-refs", + route: "all", + refreshBase: true, + }, + { + id: "reftable", + scope: "common", + match: "prefix", + path: "reftable/", + route: "all", + refreshBase: true, + }, + { + id: "shared-config", + scope: "common", + match: "exact", + path: "config", + route: "all", + refreshBase: true, + }, + { + id: "worktree-administration", + scope: "common", + match: "prefix", + path: "worktrees/", + route: "ignore", + }, + { + id: "hooks", + scope: "common", + match: "prefix", + path: "hooks/", + route: "ignore", + prune: true, + }, + { + id: "ref-logs", + scope: "common", + match: "prefix", + path: "logs/", + route: "ignore", + prune: true, + }, + { + id: "object-database", + scope: "common", + match: "prefix", + path: "objects/", + route: "ignore", + prune: true, + }, +] as const satisfies readonly GitMetadataEventRule[]; + +export function getPrunedGitMetadataPaths(scope: GitMetadataScope): string[] { + return GIT_METADATA_EVENT_RULES.flatMap((rule) => { + if (rule.scope !== "both" && rule.scope !== scope) return []; + if (!("prune" in rule) || !rule.prune || rule.match !== "prefix") return []; + return [rule.path.slice(0, -1)]; + }); +} + +export function classifyGitMetadataPath( + scope: GitMetadataScope, + inputPath: string, +): GitMetadataEffect { + const path = inputPath.replaceAll("\\", "/"); + const rule = GIT_METADATA_EVENT_RULES.find( + (candidate) => + (candidate.scope === "both" || candidate.scope === scope) && + matchesGitMetadataPath(candidate.match, candidate.path, path), + ); + if (!rule || rule.route === "ignore") return { kind: "ignore" }; + if (rule.route === "owner" || rule.route === "all") { + return { kind: rule.route, refreshBase: rule.refreshBase }; + } + return { + kind: "ref", + namespace: rule.route === "local-ref" ? "local" : "remote", + ref: path.slice(rule.path.length), + }; +} + +function matchesGitMetadataPath( + match: GitMetadataEventRule["match"], + pattern: string, + path: string, +): boolean { + if (match === "exact") return path === pattern; + if (match === "suffix") return path.endsWith(pattern); + return path === pattern.slice(0, -1) || path.startsWith(pattern); +} diff --git a/packages/server/src/server/paseo-worktree-service.ts b/packages/server/src/server/paseo-worktree-service.ts index f09a81338..b32123cb3 100644 --- a/packages/server/src/server/paseo-worktree-service.ts +++ b/packages/server/src/server/paseo-worktree-service.ts @@ -28,6 +28,7 @@ import type { WorktreeCreationIntent } from "./resolve-worktree-creation-intent. import { resolveFirstAgentPromptTitle } from "./agent/create-agent-title.js"; import { buildAgentBranchNameSeed } from "./agent/prompt-attachments.js"; import type { FirstAgentContext } from "@getpaseo/protocol/messages"; +import { runWithGitCommandPriority } from "../utils/run-git-command.js"; export interface CreatePaseoWorktreeInput extends CreateWorktreeCoreInput { projectId?: string; @@ -63,6 +64,13 @@ export interface CreatePaseoWorktreeDeps extends CreateWorktreeCoreDeps { export async function createPaseoWorktree( input: CreatePaseoWorktreeInput, deps: CreatePaseoWorktreeDeps, +): Promise { + return runWithGitCommandPriority("high", () => createPaseoWorktreeWithPriority(input, deps)); +} + +async function createPaseoWorktreeWithPriority( + input: CreatePaseoWorktreeInput, + deps: CreatePaseoWorktreeDeps, ): Promise { const workspaceCwdPlan = await planWorkspaceCwdForWorktree(input.cwd, deps.workspaceGitService); const createdWorktree = await createWorktreeCore(input, deps); diff --git a/packages/server/src/server/workspace-archive-service.ts b/packages/server/src/server/workspace-archive-service.ts index cfdb59ffc..c1a2b76db 100644 --- a/packages/server/src/server/workspace-archive-service.ts +++ b/packages/server/src/server/workspace-archive-service.ts @@ -19,6 +19,7 @@ import type { WorkspaceRegistry, } from "./workspace-registry.js"; import { createRealpathAwarePathMatcher } from "../utils/path.js"; +import { runWithGitCommandPriority } from "../utils/run-git-command.js"; export type ActiveWorkspaceRef = Pick< PersistedWorkspaceRecord, @@ -117,6 +118,13 @@ export async function resolveWorkspaceIdAtPath( export async function archiveByScope( dependencies: ArchiveDependencies, request: ArchiveByScopeRequest, +): Promise { + return runWithGitCommandPriority("high", () => archiveByScopeWithPriority(dependencies, request)); +} + +async function archiveByScopeWithPriority( + dependencies: ArchiveDependencies, + request: ArchiveByScopeRequest, ): Promise { const target = await resolveArchiveTarget(dependencies, request.scope); const targetWorkspaceIds = target.workspaceIds; diff --git a/packages/server/src/server/workspace-create-git-fanout.local.e2e.test.ts b/packages/server/src/server/workspace-create-git-fanout.local.e2e.test.ts new file mode 100644 index 000000000..7037d7f17 --- /dev/null +++ b/packages/server/src/server/workspace-create-git-fanout.local.e2e.test.ts @@ -0,0 +1,685 @@ +import { execFileSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, expect, test } from "vitest"; +import type { WorkspaceDescriptorPayload } from "@getpaseo/protocol/messages"; + +import { DaemonClient } from "./test-utils/daemon-client.js"; +import { createTestPaseoDaemon, type TestPaseoDaemon } from "./test-utils/paseo-daemon.js"; +import { getWorkspaceGitSelfHealPhaseMs } from "./workspace-git-service.js"; +import { + configureGitProcessPolicy, + snapshotGitCommandRuntimeMetrics, + type GitCommandSubmissionMetric, + startGitCommandMetrics, + stopGitCommandMetrics, + waitForGitCommandMetricsIdle, +} from "../utils/run-git-command.js"; +import { resolveGitProcessPolicy } from "../utils/git-process-scheduler.js"; + +const SIBLING_COUNT = 100; +const CREATED_AT = "2026-08-07T00:00:00.000Z"; +const originalMaxProcessesPerSecond = process.env.PASEO_GIT_MAX_PROCESSES_PER_SECOND; + +let daemon: TestPaseoDaemon | null = null; +let client: DaemonClient | null = null; +const cleanupPaths: string[] = []; + +afterEach(async () => { + await client?.close().catch(() => undefined); + await daemon?.close().catch(() => undefined); + client = null; + daemon = null; + for (const path of cleanupPaths.splice(0)) { + rmSync(path, { recursive: true, force: true }); + } + if (originalMaxProcessesPerSecond === undefined) { + delete process.env.PASEO_GIT_MAX_PROCESSES_PER_SECOND; + } else { + process.env.PASEO_GIT_MAX_PROCESSES_PER_SECOND = originalMaxProcessesPerSecond; + } + configureGitProcessPolicy(resolveGitProcessPolicy({ env: process.env })); +}); + +function git(cwd: string, ...args: string[]): string { + return execFileSync("git", args, { + cwd, + encoding: "utf8", + env: { + ...process.env, + GIT_CONFIG_NOSYSTEM: "1", + GIT_TERMINAL_PROMPT: "0", + }, + }).trim(); +} + +function seedFixture(siblingCount = SIBLING_COUNT): { + repoRoot: string; + paseoHomeRoot: string; + projectId: string; + siblingWorktrees: string[]; +} { + const fixtureRoot = mkdtempSync(join(tmpdir(), "paseo-workspace-create-fanout-")); + cleanupPaths.push(fixtureRoot); + const repoRoot = join(fixtureRoot, "repo"); + const worktreesRoot = join(fixtureRoot, "siblings"); + const paseoHomeRoot = join(fixtureRoot, "home"); + const projectsDir = join(paseoHomeRoot, ".paseo", "projects"); + mkdirSync(repoRoot, { recursive: true }); + mkdirSync(worktreesRoot, { recursive: true }); + mkdirSync(projectsDir, { 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"), "repro\n"); + git(repoRoot, "add", "README.md"); + git(repoRoot, "commit", "-m", "initial"); + + const projectId = "proj-fanout-repro"; + const workspaces = []; + const siblingWorktrees = []; + for (let index = 0; index < siblingCount; index += 1) { + let suffix = 0; + let branch = `sibling-${index}-${suffix}`; + let cwd = join(worktreesRoot, branch); + while (getWorkspaceGitSelfHealPhaseMs(cwd) < 30_000) { + suffix += 1; + branch = `sibling-${index}-${suffix}`; + cwd = join(worktreesRoot, branch); + } + git(repoRoot, "worktree", "add", "-b", branch, cwd, "HEAD"); + siblingWorktrees.push(cwd); + workspaces.push({ + workspaceId: `ws-sibling-${index}`, + projectId, + cwd, + kind: "worktree", + displayName: branch, + title: null, + branch, + worktreeRoot: cwd, + baseBranch: "main", + isPaseoOwnedWorktree: false, + mainRepoRoot: repoRoot, + createdAt: CREATED_AT, + updatedAt: CREATED_AT, + archivedAt: null, + autoArchivedChangeRequestUrl: null, + pinnedAt: null, + }); + } + + writeFileSync( + join(projectsDir, "projects.json"), + JSON.stringify([ + { + projectId, + rootPath: repoRoot, + kind: "git", + displayName: "repo", + projectKey: null, + customName: null, + customIconRevision: null, + createdAt: CREATED_AT, + updatedAt: CREATED_AT, + archivedAt: null, + }, + ]), + ); + writeFileSync(join(projectsDir, "workspaces.json"), JSON.stringify(workspaces)); + return { repoRoot, paseoHomeRoot, projectId, siblingWorktrees }; +} + +async function startObservedFixture(siblingCount: number): Promise> { + const fixture = seedFixture(siblingCount); + daemon = await createTestPaseoDaemon({ + paseoHomeRoot: fixture.paseoHomeRoot, + cleanup: false, + mcpEnabled: false, + }); + client = new DaemonClient({ + url: `ws://127.0.0.1:${daemon.port}/ws`, + appVersion: "0.3.0-beta.2", + }); + await client.connect(); + await client.fetchAgents({ subscribe: { subscriptionId: "agents" } }); + await client.fetchWorkspaces({ + page: { limit: 200 }, + subscribe: { subscriptionId: "workspaces" }, + }); + return fixture; +} + +interface GitRuntimeExpectation { + currentBranch?: string; + remoteUrl?: string; + isDirty?: boolean; + additions?: number; + deletions?: number; + ahead?: number; + aheadOfOrigin?: number; + behindOfOrigin?: number; +} + +function matchesGitRuntime( + workspace: WorkspaceDescriptorPayload, + expected: GitRuntimeExpectation, +): boolean { + const actual: Record = { + currentBranch: workspace.gitRuntime?.currentBranch, + remoteUrl: workspace.gitRuntime?.remoteUrl, + isDirty: workspace.gitRuntime?.isDirty, + additions: workspace.diffStat?.additions, + deletions: workspace.diffStat?.deletions, + ahead: workspace.gitRuntime?.aheadBehind?.ahead, + aheadOfOrigin: workspace.gitRuntime?.aheadOfOrigin, + behindOfOrigin: workspace.gitRuntime?.behindOfOrigin, + }; + return (Object.entries(expected) as Array<[keyof GitRuntimeExpectation, unknown]>).every( + ([key, value]) => actual[key] === value, + ); +} + +function waitForWorkspaceGitRuntime( + workspaceId: string, + expected: GitRuntimeExpectation, +): Promise { + if (!client) throw new Error("Daemon client is not connected"); + const activeClient = client; + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + unsubscribe(); + reject(new Error(`Timed out waiting for Git runtime update for ${workspaceId}`)); + }, 15_000); + const unsubscribe = activeClient.subscribe((event) => { + if (event.type !== "workspace_update" || event.payload.kind !== "upsert") return; + const workspace = event.payload.workspace; + if (workspace.id !== workspaceId || !matchesGitRuntime(workspace, expected)) return; + clearTimeout(timeout); + unsubscribe(); + resolve(); + }); + }); +} + +function countGitOperations(commands: GitCommandSubmissionMetric[]): Record { + return Object.fromEntries( + [...Map.groupBy(commands, (command) => command.args[0] ?? "").entries()] + .map(([operation, matchingCommands]) => [operation, matchingCommands.length] as const) + .sort(([left], [right]) => left.localeCompare(right)), + ); +} + +async function settleGitCommands(): Promise { + startGitCommandMetrics(); + await waitForGitCommandMetricsIdle({ quietMs: 1_500, timeoutMs: 30_000 }); + stopGitCommandMetrics(); +} + +async function measureGitCommands(operation: () => void | Promise) { + startGitCommandMetrics(); + await operation(); + await waitForGitCommandMetricsIdle({ quietMs: 1_500, timeoutMs: 30_000 }); + return stopGitCommandMetrics(); +} + +function waitForCheckoutDiffFiles(subscriptionId: string, expectedPaths: string[]): Promise { + if (!client) { + throw new Error("Daemon client is not connected"); + } + const activeClient = client; + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + unsubscribe(); + reject(new Error(`Timed out waiting for checkout diff ${subscriptionId}`)); + }, 15_000); + const unsubscribe = activeClient.on("checkout_diff_update", (message) => { + if (message.type !== "checkout_diff_update") { + return; + } + const payload = message.payload; + if (payload.subscriptionId !== subscriptionId) { + return; + } + const paths = payload.files.map((file) => file.path); + if (JSON.stringify(paths) !== JSON.stringify(expectedPaths)) { + return; + } + clearTimeout(timeout); + unsubscribe(); + resolve(); + }); + }); +} + +test.each([1, 2, 10])( + "coalesces working-file and index events for one of %i observed sibling worktrees", + async (siblingCount) => { + configureGitProcessPolicy({ maxProcessConcurrency: 8, maxProcessesPerSecond: 64 }); + const fixture = await startObservedFixture(siblingCount); + startGitCommandMetrics(); + await waitForGitCommandMetricsIdle({ quietMs: 1_500, timeoutMs: 30_000 }); + stopGitCommandMetrics(); + + startGitCommandMetrics(); + const dirtyUpdate = waitForWorkspaceGitRuntime("ws-sibling-0", { + isDirty: true, + additions: 1, + deletions: 1, + }); + writeFileSync(join(fixture.siblingWorktrees[0] ?? "", "README.md"), "staged change\n"); + git(fixture.siblingWorktrees[0] ?? "", "add", "README.md"); + await dirtyUpdate; + await waitForGitCommandMetricsIdle({ quietMs: 1_500, timeoutMs: 30_000 }); + const staging = stopGitCommandMetrics(); + + expect(staging.submissions.map((command) => command.args[0]).sort()).toEqual([ + "branch", + "config", + "config", + "config", + "diff", + "for-each-ref", + "ls-files", + "merge-base", + "rev-list", + "rev-parse", + "rev-parse", + "rev-parse", + "rev-parse", + "rev-parse", + "show-ref", + "show-ref", + "status", + "symbolic-ref", + ]); + const changedWorktree = realpathSync(fixture.siblingWorktrees[0] ?? ""); + expect(staging.submissions.map((command) => realpathSync(command.cwd))).toEqual( + Array.from({ length: 18 }, () => changedWorktree), + ); + }, + 120_000, +); + +test.each([false, true])( + "records unchanged index metadata work with base diff subscribed: %s", + async (hasBaseDiffSubscription) => { + configureGitProcessPolicy({ maxProcessConcurrency: 8, maxProcessesPerSecond: 64 }); + const fixture = await startObservedFixture(2); + + startGitCommandMetrics(); + await waitForGitCommandMetricsIdle({ quietMs: 1_500, timeoutMs: 30_000 }); + stopGitCommandMetrics(); + + if (hasBaseDiffSubscription) { + await client?.subscribeCheckoutDiff( + fixture.siblingWorktrees[0] ?? "", + { mode: "base", baseRef: "main" }, + { subscriptionId: "base-diff" }, + ); + startGitCommandMetrics(); + await waitForGitCommandMetricsIdle({ quietMs: 1_500, timeoutMs: 30_000 }); + stopGitCommandMetrics(); + } + + startGitCommandMetrics(); + git(fixture.siblingWorktrees[0] ?? "", "update-index", "--index-version=4"); + await waitForGitCommandMetricsIdle({ quietMs: 1_500, timeoutMs: 30_000 }); + const metadataUpdate = stopGitCommandMetrics(); + + expect(metadataUpdate.submitted).toBe(18); + expect(countGitOperations(metadataUpdate.submissions)).toEqual({ + branch: 1, + config: 3, + diff: 1, + "for-each-ref": 1, + "ls-files": 1, + "merge-base": 1, + "rev-list": 1, + "rev-parse": 5, + "show-ref": 2, + status: 1, + "symbolic-ref": 1, + }); + const changedWorktree = realpathSync(fixture.siblingWorktrees[0] ?? ""); + expect(metadataUpdate.submissions.map((command) => realpathSync(command.cwd))).toEqual( + Array.from({ length: 18 }, () => changedWorktree), + ); + }, + 120_000, +); + +test.each([1, 2, 10])( + "records the Git command budget of creating a workspace with %i warm siblings", + async (siblingCount) => { + configureGitProcessPolicy({ maxProcessConcurrency: 8, maxProcessesPerSecond: 64 }); + const fixture = await startObservedFixture(siblingCount); + startGitCommandMetrics(); + await waitForGitCommandMetricsIdle({ quietMs: 1_500, timeoutMs: 30_000 }); + stopGitCommandMetrics(); + + startGitCommandMetrics(); + const response = await client?.createWorkspace({ + source: { + kind: "worktree", + cwd: fixture.repoRoot, + projectId: fixture.projectId, + action: "branch-off", + refName: "main", + branchName: "created-during-measurement", + worktreeSlug: "created-during-measurement", + }, + }); + await waitForGitCommandMetricsIdle({ quietMs: 1_500, timeoutMs: 30_000 }); + const creation = stopGitCommandMetrics(); + + expect(response?.workspace?.name).toBe("created-during-measurement"); + expect(creation.submitted).toBe(90); + expect(countGitOperations(creation.submissions)).toEqual({ + branch: 4, + config: 19, + diff: 2, + "for-each-ref": 6, + "ls-files": 3, + "merge-base": 2, + "rev-list": 2, + "rev-parse": 34, + "show-ref": 6, + status: 6, + "symbolic-ref": 4, + worktree: 2, + }); + const existingWorktrees = new Set(fixture.siblingWorktrees.map((cwd) => realpathSync(cwd))); + expect( + creation.submissions.filter((command) => existingWorktrees.has(realpathSync(command.cwd))), + ).toEqual([]); + }, + 120_000, +); + +test("refreshes every workspace that depends on a changed shared base ref", async () => { + configureGitProcessPolicy({ maxProcessConcurrency: 8, maxProcessesPerSecond: 64 }); + const fixture = await startObservedFixture(2); + startGitCommandMetrics(); + await waitForGitCommandMetricsIdle({ quietMs: 1_500, timeoutMs: 30_000 }); + stopGitCommandMetrics(); + + const changedWorktree = fixture.siblingWorktrees[0] ?? ""; + writeFileSync(join(changedWorktree, "README.md"), "branch change\n"); + git(changedWorktree, "add", "README.md"); + git(changedWorktree, "commit", "-m", "branch change"); + startGitCommandMetrics(); + await waitForGitCommandMetricsIdle({ quietMs: 1_500, timeoutMs: 30_000 }); + stopGitCommandMetrics(); + + const subscriptionId = "shared-base-ref-diff"; + const initial = await client?.subscribeCheckoutDiff( + changedWorktree, + { mode: "base", baseRef: "main" }, + { subscriptionId }, + ); + expect(initial?.files.map((file) => file.path)).toEqual(["README.md"]); + startGitCommandMetrics(); + await waitForGitCommandMetricsIdle({ quietMs: 1_500, timeoutMs: 30_000 }); + stopGitCommandMetrics(); + + const changedHead = git(changedWorktree, "rev-parse", "HEAD"); + const emptyDiffUpdate = waitForCheckoutDiffFiles(subscriptionId, []); + startGitCommandMetrics(); + git(fixture.repoRoot, "update-ref", "refs/heads/main", changedHead); + await emptyDiffUpdate; + await waitForGitCommandMetricsIdle({ quietMs: 1_500, timeoutMs: 30_000 }); + const sharedRefUpdate = stopGitCommandMetrics(); + + expect(sharedRefUpdate.submitted).toBe(43); + expect(countGitOperations(sharedRefUpdate.submissions)).toEqual({ + branch: 3, + config: 6, + diff: 3, + "for-each-ref": 2, + "ls-files": 2, + "merge-base": 3, + "rev-list": 2, + "rev-parse": 11, + "show-ref": 6, + status: 2, + "symbolic-ref": 3, + }); + client?.unsubscribeCheckoutDiff(subscriptionId); +}, 120_000); + +test("records the Git command ledger for repository metadata business rules", async () => { + configureGitProcessPolicy({ maxProcessConcurrency: 8, maxProcessesPerSecond: 64 }); + const fixture = await startObservedFixture(2); + await settleGitCommands(); + + const firstWorktree = fixture.siblingWorktrees[0] ?? ""; + const firstGitDir = git(firstWorktree, "rev-parse", "--absolute-git-dir"); + const externalWorktree = join(fixture.repoRoot, "..", "external-worktree"); + + const privateNoise = await measureGitCommands(() => { + writeFileSync(join(firstGitDir, "COMMIT_EDITMSG"), "ignored message\n"); + }); + const unrelatedWorktreeAdd = await measureGitCommands(() => { + git(fixture.repoRoot, "worktree", "add", "-b", "external-worktree", externalWorktree, "main"); + }); + + writeFileSync(join(firstWorktree, "README.md"), "committed change\n"); + git(firstWorktree, "add", "README.md"); + await settleGitCommands(); + const committedUpdate = waitForWorkspaceGitRuntime("ws-sibling-0", { ahead: 1 }); + const ownCommit = await measureGitCommands(() => { + git(firstWorktree, "commit", "-m", "measured commit"); + }); + await committedUpdate; + + git(firstWorktree, "branch", "measured-switch", "main"); + await settleGitCommands(); + const switchedUpdate = waitForWorkspaceGitRuntime("ws-sibling-0", { + currentBranch: "measured-switch", + ahead: 0, + }); + const ownBranchSwitch = await measureGitCommands(() => { + git(firstWorktree, "switch", "measured-switch"); + }); + await switchedUpdate; + + const configUpdates = [ + waitForWorkspaceGitRuntime("ws-sibling-0", { remoteUrl: fixture.repoRoot }), + waitForWorkspaceGitRuntime("ws-sibling-1", { remoteUrl: fixture.repoRoot }), + ]; + const sharedConfig = await measureGitCommands(() => { + git(fixture.repoRoot, "remote", "add", "origin", fixture.repoRoot); + }); + await Promise.all(configUpdates); + git(fixture.repoRoot, "config", "branch.measured-switch.remote", "origin"); + git(fixture.repoRoot, "config", "branch.measured-switch.merge", "refs/heads/measured-switch"); + await settleGitCommands(); + const trackingUpdate = waitForWorkspaceGitRuntime("ws-sibling-0", { + aheadOfOrigin: 0, + behindOfOrigin: 0, + }); + const remoteTrackingRef = await measureGitCommands(() => { + const head = git(firstWorktree, "rev-parse", "measured-switch"); + git(fixture.repoRoot, "update-ref", "refs/remotes/origin/measured-switch", head); + }); + await trackingUpdate; + + writeFileSync(join(externalWorktree, "README.md"), "local upstream change\n"); + git(externalWorktree, "add", "README.md"); + git(externalWorktree, "commit", "-m", "local upstream change"); + const advancedLocalUpstream = git(externalWorktree, "rev-parse", "HEAD"); + git(fixture.repoRoot, "branch", "local-upstream", "measured-switch"); + git(fixture.repoRoot, "config", "branch.measured-switch.remote", "."); + git(fixture.repoRoot, "config", "branch.measured-switch.merge", "refs/heads/local-upstream"); + await settleGitCommands(); + const localUpstreamUpdate = waitForWorkspaceGitRuntime("ws-sibling-0", { + aheadOfOrigin: 0, + behindOfOrigin: 1, + }); + const localUpstreamRef = await measureGitCommands(() => { + git(fixture.repoRoot, "update-ref", "refs/heads/local-upstream", advancedLocalUpstream); + }); + await localUpstreamUpdate; + const packedRefs = await measureGitCommands(() => { + git(fixture.repoRoot, "pack-refs", "--all", "--prune"); + }); + + const commandLedger = { + privateNoise: privateNoise.submitted, + unrelatedWorktreeAdd: unrelatedWorktreeAdd.submitted, + ownCommit: ownCommit.submitted, + ownBranchSwitch: ownBranchSwitch.submitted, + remoteTrackingRef: remoteTrackingRef.submitted, + localUpstreamRef: localUpstreamRef.submitted, + sharedConfig: sharedConfig.submitted, + packedRefs: packedRefs.submitted, + }; + expect(commandLedger).toEqual({ + privateNoise: 0, + unrelatedWorktreeAdd: 0, + ownCommit: 18, + ownBranchSwitch: 18, + remoteTrackingRef: 19, + localUpstreamRef: 19, + sharedConfig: 36, + packedRefs: 37, + }); + + const ownerOperations = { + branch: 1, + config: 3, + diff: 1, + "for-each-ref": 1, + "ls-files": 1, + "merge-base": 1, + "rev-list": 1, + "rev-parse": 5, + "show-ref": 2, + status: 1, + "symbolic-ref": 1, + }; + expect(countGitOperations(ownCommit.submissions)).toEqual(ownerOperations); + expect(countGitOperations(ownBranchSwitch.submissions)).toEqual(ownerOperations); + expect(countGitOperations(remoteTrackingRef.submissions)).toEqual({ + ...ownerOperations, + config: 4, + }); + expect(countGitOperations(localUpstreamRef.submissions)).toEqual({ + ...ownerOperations, + config: 4, + }); + const ownerWorktree = realpathSync(firstWorktree); + expect(localUpstreamRef.submissions.map((command) => realpathSync(command.cwd))).toEqual( + Array.from({ length: 19 }, () => ownerWorktree), + ); + expect(countGitOperations(sharedConfig.submissions)).toEqual( + Object.fromEntries( + Object.entries(ownerOperations).map(([operation, count]) => [operation, count * 2]), + ), + ); + expect(countGitOperations(packedRefs.submissions)).toEqual({ + ...Object.fromEntries( + Object.entries(ownerOperations).map(([operation, count]) => [operation, count * 2]), + ), + config: 7, + }); + + const firstWorktreeRoot = realpathSync(firstWorktree); + const secondWorktreeRoot = realpathSync(fixture.siblingWorktrees[1] ?? ""); + for (const measurement of [ownCommit, ownBranchSwitch, remoteTrackingRef]) { + expect(new Set(measurement.submissions.map((command) => realpathSync(command.cwd)))).toEqual( + new Set([firstWorktreeRoot]), + ); + } + for (const measurement of [sharedConfig, packedRefs]) { + expect(new Set(measurement.submissions.map((command) => realpathSync(command.cwd)))).toEqual( + new Set([firstWorktreeRoot, secondWorktreeRoot]), + ); + } +}, 120_000); + +test("workspace archive is admitted while 52 sibling observations hydrate", async () => { + configureGitProcessPolicy({ maxProcessConcurrency: 8, maxProcessesPerSecond: 64 }); + const fixture = seedFixture(52); + daemon = await createTestPaseoDaemon({ + paseoHomeRoot: fixture.paseoHomeRoot, + cleanup: false, + mcpEnabled: false, + }); + client = new DaemonClient({ + url: `ws://127.0.0.1:${daemon.port}/ws`, + appVersion: "0.3.0-beta.2", + }); + await client.connect(); + await client.fetchAgents({ subscribe: { subscriptionId: "agents" } }); + const created = await client.createWorkspace({ + source: { + kind: "worktree", + cwd: fixture.repoRoot, + projectId: fixture.projectId, + action: "branch-off", + refName: "main", + branchName: "archive-during-hydration", + worktreeSlug: "archive-during-hydration", + }, + }); + const workspaceId = created.workspace?.id; + expect(workspaceId).toBeTypeOf("string"); + + await client.fetchWorkspaces({ + page: { limit: 200 }, + subscribe: { subscriptionId: "workspaces" }, + }); + expect(snapshotGitCommandRuntimeMetrics().pending).toBeGreaterThan(0); + const archiveStartedAt = Date.now(); + const archived = await client.archiveWorkspace(workspaceId ?? ""); + + expect(archived.error).toBeNull(); + expect(archived.archivedAt).not.toBeNull(); + expect(Date.now() - archiveStartedAt).toBeLessThan(30_000); +}, 180_000); + +test("workspace create is admitted while 100 sibling observations hydrate", async () => { + process.env.PASEO_GIT_MAX_PROCESSES_PER_SECOND = "64"; + configureGitProcessPolicy({ maxProcessConcurrency: 8, maxProcessesPerSecond: 64 }); + const fixture = seedFixture(); + daemon = await createTestPaseoDaemon({ + paseoHomeRoot: fixture.paseoHomeRoot, + cleanup: false, + mcpEnabled: false, + }); + configureGitProcessPolicy({ maxProcessConcurrency: 8, maxProcessesPerSecond: 64 }); + client = new DaemonClient({ + url: `ws://127.0.0.1:${daemon.port}/ws`, + appVersion: "0.3.0-beta.2", + }); + await client.connect(); + await client.fetchAgents({ subscribe: { subscriptionId: "agents" } }); + const snapshot = await client.fetchWorkspaces({ + page: { limit: 200 }, + subscribe: { subscriptionId: "workspaces" }, + }); + expect(snapshot.entries).toHaveLength(SIBLING_COUNT); + expect(snapshotGitCommandRuntimeMetrics().pending).toBeGreaterThan(0); + + const createStartedAt = Date.now(); + const createPromise = client.createWorkspace({ + source: { + kind: "worktree", + cwd: fixture.repoRoot, + projectId: fixture.projectId, + action: "branch-off", + refName: "main", + branchName: "created-during-repro", + worktreeSlug: "created-during-repro", + }, + }); + const response = await createPromise; + expect(response.error).toBeNull(); + expect(response.workspace?.name).toBe("created-during-repro"); + expect(Date.now() - createStartedAt).toBeLessThan(30_000); +}, 180_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 aa92a3203..97e6c1c76 100644 --- a/packages/server/src/server/workspace-git-service.observation.test.ts +++ b/packages/server/src/server/workspace-git-service.observation.test.ts @@ -7,6 +7,8 @@ import { WorkspaceGitServiceImpl } from "./workspace-git-service.js"; const REPO_CWD = path.resolve("/tmp/paseo-observation-repo"); const GIT_DIR = path.join(REPO_CWD, ".git"); +const WORKTREE_A = path.resolve("/tmp/paseo-observation-worktree-a"); +const WORKTREE_B = path.resolve("/tmp/paseo-observation-worktree-b"); interface WatchEvent { path: string; @@ -64,6 +66,18 @@ function createCheckoutFacts(cwd: string): CheckoutSnapshotFacts { }; } +function createLinkedCheckoutFacts(cwd: string): CheckoutSnapshotFacts { + const worktreeName = path.basename(cwd); + return { + ...createCheckoutFacts(cwd), + currentBranch: worktreeName, + absoluteGitDir: path.join(GIT_DIR, "worktrees", worktreeName), + gitCommonDir: GIT_DIR, + resolvedBaseRef: "main", + pullRequestLookupTarget: { headRef: worktreeName }, + }; +} + function createCheckoutStatus( cwd: string, overrides?: Partial, @@ -104,6 +118,10 @@ function createDeferred() { return { promise, reject, resolve }; } +function getCalledCwds(mock: ReturnType): string[] { + return mock.mock.calls.map(([cwd]) => cwd as string); +} + function createService( watcher: ReturnType, overrides?: Record, @@ -335,9 +353,15 @@ describe("WorkspaceGitService checkout observation", () => { service.dispose(); }); - test("worktree and metadata events invalidate only their matching diff projections", async () => { + test("worktree events invalidate uncommitted diffs and metadata events invalidate both", async () => { const watcher = createWatcherHarness(); - const getCheckoutDiff = vi.fn(async () => ({ diff: "", structured: [] })); + const projectionVersions = { uncommitted: 0, base: 0 }; + const getCheckoutDiff = vi.fn( + async (_cwd: string, options: { mode: "uncommitted" | "base" }) => { + projectionVersions[options.mode] += 1; + return { diff: `${options.mode}-${projectionVersions[options.mode]}`, structured: [] }; + }, + ); const service = createService(watcher, { getCheckoutDiff }); const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn()); @@ -345,24 +369,29 @@ describe("WorkspaceGitService checkout observation", () => { expect(service.peekSnapshot(REPO_CWD)).not.toBeNull(); expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0); }); - await service.getCheckoutDiff(REPO_CWD, { mode: "uncommitted" }); - await service.getCheckoutDiff(REPO_CWD, { mode: "base", baseRef: "main" }); - expect(getCheckoutDiff).toHaveBeenCalledTimes(2); + const initialUncommitted = await service.getCheckoutDiff(REPO_CWD, { mode: "uncommitted" }); + const initialBase = await service.getCheckoutDiff(REPO_CWD, { mode: "base", baseRef: "main" }); + expect(initialUncommitted.diff).toBe("uncommitted-1"); + expect(initialBase.diff).toBe("base-1"); watcher.records .find((record) => record.directory === REPO_CWD) ?.callback(null, [{ path: path.join(REPO_CWD, "tracked.txt"), type: "update" }]); - await service.getCheckoutDiff(REPO_CWD, { mode: "uncommitted" }); - await service.getCheckoutDiff(REPO_CWD, { mode: "base", baseRef: "main" }); - expect(getCheckoutDiff).toHaveBeenCalledTimes(3); - expect(getCheckoutDiff.mock.calls[2]?.[1]).toMatchObject({ mode: "uncommitted" }); + const changedUncommitted = await service.getCheckoutDiff(REPO_CWD, { mode: "uncommitted" }); + const cachedBase = await service.getCheckoutDiff(REPO_CWD, { mode: "base", baseRef: "main" }); + expect(changedUncommitted.diff).toBe("uncommitted-2"); + expect(cachedBase.diff).toBe("base-1"); watcher.records .find((record) => record.directory === GIT_DIR) ?.callback(null, [{ path: path.join(GIT_DIR, "HEAD"), type: "update" }]); - await service.getCheckoutDiff(REPO_CWD, { mode: "base", baseRef: "main" }); - expect(getCheckoutDiff).toHaveBeenCalledTimes(4); - expect(getCheckoutDiff.mock.calls[3]?.[1]).toMatchObject({ mode: "base" }); + const changedByMetadata = await service.getCheckoutDiff(REPO_CWD, { mode: "uncommitted" }); + const changedBase = await service.getCheckoutDiff(REPO_CWD, { + mode: "base", + baseRef: "main", + }); + expect(changedByMetadata.diff).toBe("uncommitted-3"); + expect(changedBase.diff).toBe("base-2"); subscription.unsubscribe(); service.dispose(); @@ -395,6 +424,150 @@ describe("WorkspaceGitService checkout observation", () => { 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)); + const getCheckoutStatus = vi.fn(async (cwd: string) => + createCheckoutStatus(cwd, { currentBranch: path.basename(cwd) }), + ); + const service = createService(watcher, { getCheckoutSnapshotFacts, getCheckoutStatus }); + const first = service.registerWorkspace({ cwd: WORKTREE_A }, vi.fn()); + const second = service.registerWorkspace({ cwd: WORKTREE_B }, vi.fn()); + + await vi.waitFor(() => { + expect(service.getMetrics()).toMatchObject({ + repositoryTargetCount: 1, + repositoryWorkspaceLinkCount: 2, + workspaceObservationSetupInFlightCount: 0, + workspaceRefreshInFlightCount: 0, + }); + }); + const repoWatcher = watcher.records.find((record) => record.directory === GIT_DIR); + expect(repoWatcher).toBeDefined(); + getCheckoutStatus.mockClear(); + + repoWatcher?.callback(null, [{ path: path.join(GIT_DIR, "packed-refs.lock"), type: "create" }]); + await vi.advanceTimersByTimeAsync(1_000); + await flushPromises(); + expect(getCheckoutStatus).not.toHaveBeenCalled(); + + repoWatcher?.callback(null, [ + { + path: path.join(createLinkedCheckoutFacts(WORKTREE_A).absoluteGitDir, "COMMIT_EDITMSG"), + type: "update", + }, + ]); + await vi.advanceTimersByTimeAsync(1_000); + await flushPromises(); + expect(getCheckoutStatus).not.toHaveBeenCalled(); + + repoWatcher?.callback(null, [ + { + path: path.join(createLinkedCheckoutFacts(WORKTREE_A).absoluteGitDir, "index"), + type: "update", + }, + ]); + await vi.advanceTimersByTimeAsync(1_000); + await vi.waitFor(() => { + expect(getCalledCwds(getCheckoutStatus)).toEqual([WORKTREE_A]); + }); + getCheckoutStatus.mockClear(); + + repoWatcher?.callback(null, [ + { path: path.join(GIT_DIR, "refs", "heads", "main"), type: "update" }, + ]); + await vi.advanceTimersByTimeAsync(1_000); + await vi.waitFor(() => { + expect(getCalledCwds(getCheckoutStatus).sort()).toEqual([WORKTREE_A, WORKTREE_B].sort()); + }); + + first.unsubscribe(); + second.unsubscribe(); + service.dispose(); + }); + + test("routes the main checkout index to the main checkout only", async () => { + const watcher = createWatcherHarness(); + const getCheckoutSnapshotFacts = vi.fn(async (cwd: string) => + cwd === REPO_CWD ? createCheckoutFacts(cwd) : createLinkedCheckoutFacts(cwd), + ); + const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd)); + const service = createService(watcher, { getCheckoutSnapshotFacts, getCheckoutStatus }); + const main = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn()); + const linked = service.registerWorkspace({ cwd: WORKTREE_A }, vi.fn()); + + await vi.waitFor(() => { + expect(service.getMetrics()).toMatchObject({ + repositoryTargetCount: 1, + repositoryWorkspaceLinkCount: 2, + workspaceObservationSetupInFlightCount: 0, + workspaceRefreshInFlightCount: 0, + }); + }); + getCheckoutStatus.mockClear(); + watcher.records + .find((record) => record.directory === GIT_DIR) + ?.callback(null, [{ path: path.join(GIT_DIR, "index"), type: "update" }]); + await vi.advanceTimersByTimeAsync(1_000); + await vi.waitFor(() => { + expect(getCalledCwds(getCheckoutStatus)).toEqual([REPO_CWD]); + }); + + main.unsubscribe(); + linked.unsubscribe(); + service.dispose(); + }); + + test("routes a newly created remote tracking ref to its configured workspace", async () => { + const watcher = createWatcherHarness(); + const getCheckoutSnapshotFacts = vi.fn(async (cwd: string) => { + const facts = createLinkedCheckoutFacts(cwd); + const branch = path.basename(cwd); + return { + ...facts, + currentBranch: branch, + storedBaseRef: "refs/heads/main", + resolvedBaseRef: "refs/heads/main", + comparisonBaseRef: "refs/heads/main", + branchRemoteName: "origin", + branchMergeRef: `refs/heads/${branch}`, + upstreamStatus: null, + } satisfies CheckoutSnapshotFacts; + }); + const getCheckoutStatus = vi.fn(async (cwd: string) => + createCheckoutStatus(cwd, { currentBranch: path.basename(cwd) }), + ); + const service = createService(watcher, { getCheckoutSnapshotFacts, getCheckoutStatus }); + const first = service.registerWorkspace({ cwd: WORKTREE_A }, vi.fn()); + const second = service.registerWorkspace({ cwd: WORKTREE_B }, vi.fn()); + + await vi.waitFor(() => { + expect(service.getMetrics()).toMatchObject({ + repositoryTargetCount: 1, + repositoryWorkspaceLinkCount: 2, + workspaceObservationSetupInFlightCount: 0, + workspaceRefreshInFlightCount: 0, + }); + }); + getCheckoutStatus.mockClear(); + watcher.records + .find((record) => record.directory === GIT_DIR) + ?.callback(null, [ + { + path: path.join(GIT_DIR, "refs", "remotes", "origin", path.basename(WORKTREE_A)), + type: "create", + }, + ]); + await vi.advanceTimersByTimeAsync(1_000); + await vi.waitFor(() => { + expect(getCalledCwds(getCheckoutStatus)).toEqual([WORKTREE_A]); + }); + + first.unsubscribe(); + second.unsubscribe(); + service.dispose(); + }); + test("repository metadata observation ignores root and pruned-directory noise", async () => { const watcher = createWatcherHarness(); const getCheckoutSnapshotFacts = vi.fn(async (cwd: string) => createCheckoutFacts(cwd)); diff --git a/packages/server/src/server/workspace-git-service.ts b/packages/server/src/server/workspace-git-service.ts index 86ea5902b..3fe51becb 100644 --- a/packages/server/src/server/workspace-git-service.ts +++ b/packages/server/src/server/workspace-git-service.ts @@ -39,10 +39,15 @@ import { type ForgeResolver, } from "../services/forge-resolver.js"; import { parseGitRevParsePath } from "../utils/git-rev-parse-path.js"; -import { createRealpathAwarePathMatcher, isRealpathInsideRoot } from "../utils/path.js"; +import { + createRealpathAwarePathMatcher, + getRealpathAwareRelativePath, + isRealpathInsideRoot, +} from "../utils/path.js"; import { runGitCommand } from "../utils/run-git-command.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 { deriveProjectSlug } from "./workspace-git-metadata.js"; import { checkoutLiteFromGitSnapshot } from "./workspace-registry-model.js"; @@ -372,6 +377,10 @@ interface RepoGitTarget { closed: boolean; } +interface RepoMetadataWorkspaceRefresh { + refreshBase: boolean; +} + interface WorkingTreeWatchTarget { cwd: string; watchPath: string; @@ -1482,11 +1491,9 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { } private async startRepoMetadataObservation(target: RepoGitTarget): Promise { - const ignore = [ - join(target.repoGitRoot, "hooks"), - join(target.repoGitRoot, "logs"), - join(target.repoGitRoot, "objects"), - ]; + const ignore = getPrunedGitMetadataPaths("common").map((path) => + join(target.repoGitRoot, path), + ); const matchesRepoGitRoot = createRealpathAwarePathMatcher(target.repoGitRoot); try { const subscription = await this.deps.subscribe( @@ -1500,13 +1507,18 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { this.degradeRepoMetadataWatch(target); return; } - const hasRelevantEvent = events.some( + const relevantEvents = events.filter( (event) => !matchesRepoGitRoot(event.path) && ignore.every((ignoredPath) => !isRealpathInsideRoot(ignoredPath, event.path)), ); - if (hasRelevantEvent) { - this.scheduleRepoMetadataRefresh(target, "git-metadata-watch", true); + if (relevantEvents.length > 0) { + this.scheduleRepoMetadataRefresh( + target, + "git-metadata-watch", + true, + this.routeRepoMetadataEvents(target, relevantEvents), + ); } }, { ignore }, @@ -1543,21 +1555,174 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { this.startRepoMetadataFallback(target); } + private routeRepoMetadataEvents( + target: RepoGitTarget, + events: parcelWatcher.Event[], + ): Map | null { + const refreshes = new Map(); + const matchesRepoGitRoot = createRealpathAwarePathMatcher(target.repoGitRoot); + + for (const event of events) { + if (!this.routeRepoMetadataEvent(target, event, matchesRepoGitRoot, refreshes)) return null; + } + + return refreshes; + } + + private routeRepoMetadataEvent( + target: RepoGitTarget, + event: parcelWatcher.Event, + matchesRepoGitRoot: ReturnType, + refreshes: Map, + ): boolean { + if (this.routePrivateGitDirEvent(target, event, matchesRepoGitRoot, refreshes)) return true; + + const commonRelativePath = getRealpathAwareRelativePath(target.repoGitRoot, event.path); + const effect = classifyGitMetadataPath("common", commonRelativePath ?? ""); + switch (effect.kind) { + case "ignore": + return true; + case "owner": + this.routeMainCheckoutMetadata(target, matchesRepoGitRoot, effect.refreshBase, refreshes); + return true; + case "ref": + if (effect.namespace === "local") { + this.routeLocalBranchRef(target, effect.ref, refreshes); + } else { + this.routeRemoteBranchRef(target, effect.ref, refreshes); + } + return true; + case "all": + return false; + } + } + + private routePrivateGitDirEvent( + target: RepoGitTarget, + event: parcelWatcher.Event, + matchesRepoGitRoot: ReturnType, + refreshes: Map, + ): boolean { + let matched = false; + for (const workspaceKey of target.workspaceKeys) { + const facts = this.workspaceTargets.get(workspaceKey)?.latestFacts; + if (!facts?.isGit || !facts.absoluteGitDir || matchesRepoGitRoot(facts.absoluteGitDir)) { + continue; + } + const relativePath = getRealpathAwareRelativePath(facts.absoluteGitDir, event.path); + if (relativePath === null) continue; + matched = true; + const effect = classifyGitMetadataPath("worktree", relativePath); + if (effect.kind === "owner") { + this.addWorkspaceMetadataRefresh(refreshes, workspaceKey, effect.refreshBase); + } else if (effect.kind !== "ignore") { + throw new Error(`Invalid ${effect.kind} effect for worktree metadata`); + } + } + return matched; + } + + private routeMainCheckoutMetadata( + target: RepoGitTarget, + matchesRepoGitRoot: ReturnType, + refreshBase: boolean, + refreshes: Map, + ): void { + for (const workspaceKey of target.workspaceKeys) { + const facts = this.workspaceTargets.get(workspaceKey)?.latestFacts; + if (facts?.isGit && facts.absoluteGitDir && matchesRepoGitRoot(facts.absoluteGitDir)) { + this.addWorkspaceMetadataRefresh(refreshes, workspaceKey, refreshBase); + } + } + } + + private routeLocalBranchRef( + target: RepoGitTarget, + branch: string, + refreshes: Map, + ): void { + for (const workspaceKey of target.workspaceKeys) { + const facts = this.workspaceTargets.get(workspaceKey)?.latestFacts; + if (!facts?.isGit) continue; + const dependentRefs = [ + facts.storedBaseRef, + facts.resolvedBaseRef, + facts.comparisonBaseRef, + facts.upstreamStatus?.ref, + ]; + const usesBranch = dependentRefs.some( + (ref) => ref === branch || ref === `refs/heads/${branch}`, + ); + if (facts.currentBranch === branch || usesBranch) { + this.addWorkspaceMetadataRefresh(refreshes, workspaceKey, true); + } + } + } + + private routeRemoteBranchRef( + target: RepoGitTarget, + remoteRef: string, + refreshes: Map, + ): void { + const qualifiedRemoteRef = `refs/remotes/${remoteRef}`; + for (const workspaceKey of target.workspaceKeys) { + const facts = this.workspaceTargets.get(workspaceKey)?.latestFacts; + if (!facts?.isGit) continue; + const trackedBranch = facts.branchMergeRef?.startsWith("refs/heads/") + ? facts.branchMergeRef.slice("refs/heads/".length) + : null; + const configuredRemoteRef = + facts.branchRemoteName && facts.branchRemoteName !== "." && trackedBranch + ? `${facts.branchRemoteName}/${trackedBranch}` + : null; + const refs = [ + facts.storedBaseRef, + facts.resolvedBaseRef, + facts.comparisonBaseRef, + facts.upstreamStatus?.ref, + configuredRemoteRef, + ]; + const usesRemoteRef = refs.some((ref) => ref === remoteRef || ref === qualifiedRemoteRef); + if (usesRemoteRef) { + this.addWorkspaceMetadataRefresh(refreshes, workspaceKey, true); + } + } + } + + private addWorkspaceMetadataRefresh( + refreshes: Map, + workspaceKey: string, + refreshBase: boolean, + ): void { + const previous = refreshes.get(workspaceKey); + refreshes.set(workspaceKey, { + refreshBase: previous?.refreshBase === true || refreshBase, + }); + } + private scheduleRepoMetadataRefresh( target: RepoGitTarget, reason: string, refreshWorktree: boolean, + routedRefreshes: Map | null = null, ): void { if (target.closed || this.repoTargets.get(target.repoGitRoot) !== target) { return; } const workingTreeTargets = new Set(); - for (const workspaceKey of target.workspaceKeys) { + const refreshes = + routedRefreshes ?? + new Map( + Array.from(target.workspaceKeys, (workspaceKey) => [workspaceKey, { refreshBase: true }]), + ); + for (const [workspaceKey, refresh] of refreshes) { const workspaceTarget = this.workspaceTargets.get(workspaceKey); if (workspaceTarget) { - this.invalidateCheckoutDiffCache(workspaceTarget.cwd, "base"); - if (workspaceTarget.latestFacts?.isGit) { - this.invalidateCheckoutDiffCache(workspaceTarget.latestFacts.worktreeRoot, "base"); + if (refresh.refreshBase) { + this.invalidateCheckoutDiffCache(workspaceTarget.cwd, "base"); + if (workspaceTarget.latestFacts?.isGit) { + this.invalidateCheckoutDiffCache(workspaceTarget.latestFacts.worktreeRoot, "base"); + } } if (refreshWorktree) { const workingTreeTarget = this.getWorkingTreeWatchTargetForWorkspace(workspaceTarget); @@ -1567,7 +1732,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { } this.scheduleWorkspaceRefresh(workspaceTarget, { scope: refreshWorktree ? undefined : "structure", - emitUnchanged: true, + emitUnchanged: refresh.refreshBase, reason, }); } diff --git a/packages/server/src/server/worktree-core.ts b/packages/server/src/server/worktree-core.ts index 90d078890..98ffa777d 100644 --- a/packages/server/src/server/worktree-core.ts +++ b/packages/server/src/server/worktree-core.ts @@ -16,6 +16,7 @@ import { } from "./resolve-worktree-creation-intent.js"; import type { ChangeRequestCheckoutSource, FirstAgentContext } from "@getpaseo/protocol/messages"; import type { WorkspaceGitService } from "./workspace-git-service.js"; +import { runWithGitCommandPriority } from "../utils/run-git-command.js"; export interface CreateWorktreeCoreInput { cwd: string; @@ -50,6 +51,13 @@ export interface CreateWorktreeCoreResult { export async function createWorktreeCore( input: CreateWorktreeCoreInput, deps: CreateWorktreeCoreDeps, +): Promise { + return runWithGitCommandPriority("high", () => createWorktreeCoreWithPriority(input, deps)); +} + +async function createWorktreeCoreWithPriority( + input: CreateWorktreeCoreInput, + deps: CreateWorktreeCoreDeps, ): Promise { const repoRoot = await resolveWorktreeRepoRoot(input, deps.workspaceGitService); const requestedWorktreeSlug = input.worktreeSlug diff --git a/packages/server/src/utils/git-process-scheduler.test.ts b/packages/server/src/utils/git-process-scheduler.test.ts index 0440ce518..4c879267b 100644 --- a/packages/server/src/utils/git-process-scheduler.test.ts +++ b/packages/server/src/utils/git-process-scheduler.test.ts @@ -46,6 +46,27 @@ describe("GitProcessScheduler", () => { expect(scheduler.pendingCount).toBe(0); }); + test("admits a high-priority user operation before queued observation work", async () => { + const scheduler = new GitProcessScheduler({ + maxProcessesPerSecond: 100, + maxProcessConcurrency: 1, + }); + const releases: Array<() => void> = []; + const started: number[] = []; + + const activeObservation = scheduler.run(createConcurrencyTask(0, started, releases)); + const queuedObservation = scheduler.run(createConcurrencyTask(2, started, releases)); + const userOperation = scheduler.run(createConcurrencyTask(3, started, releases), { + priority: "high", + }); + + await vi.waitFor(() => expect(started).toEqual([0])); + releases.shift()?.(); + await Promise.all([activeObservation, queuedObservation, userOperation]); + + expect(started).toEqual([0, 3, 2]); + }); + test("limits process starts in each strict one-second interval", async () => { vi.useFakeTimers(); const scheduler = new GitProcessScheduler({ diff --git a/packages/server/src/utils/git-process-scheduler.ts b/packages/server/src/utils/git-process-scheduler.ts index 81ce87c4b..366029c36 100644 --- a/packages/server/src/utils/git-process-scheduler.ts +++ b/packages/server/src/utils/git-process-scheduler.ts @@ -1,4 +1,3 @@ -import pLimit from "p-limit"; import pThrottle from "p-throttle"; export interface GitProcessPolicy { @@ -16,6 +15,8 @@ export interface ScheduledGitProcess { exited: Promise; } +export type GitProcessPriority = "normal" | "high"; + function parsePositiveInteger(value: string | undefined): number | undefined { if (value === undefined) { return undefined; @@ -43,18 +44,20 @@ export function resolveGitProcessPolicy(input: { } export class GitProcessScheduler { - private readonly limit; - private readonly throttle; + private readonly startThrottled; + private readonly highPriorityQueue: Array<() => void> = []; + private readonly normalPriorityQueue: Array<() => void> = []; + private admitted = 0; private running = 0; private waiting = 0; constructor(readonly policy: GitProcessPolicy) { - this.limit = pLimit(policy.maxProcessConcurrency); - this.throttle = pThrottle({ + const throttle = pThrottle({ limit: policy.maxProcessesPerSecond, interval: 1_000, strict: true, }); + this.startThrottled = throttle((start: () => Promise) => start()); } get activeCount(): number { @@ -65,24 +68,55 @@ export class GitProcessScheduler { return this.waiting; } - run(start: () => ScheduledGitProcess): Promise { + run( + start: () => ScheduledGitProcess, + options?: { priority?: GitProcessPriority }, + ): Promise { this.waiting += 1; return new Promise((resolve, reject) => { - void this.limit( - this.throttle(async () => { + const admit = () => { + void this.startThrottled(() => this.execute(start, resolve, reject)).catch((error) => { this.waiting = Math.max(0, this.waiting - 1); - this.running += 1; - try { - const process = start(); - void process.result.then(resolve, reject); - await process.exited; - } catch (error) { - reject(error); - } finally { - this.running = Math.max(0, this.running - 1); - } - }), - ).catch(reject); + this.admitted = Math.max(0, this.admitted - 1); + reject(error); + this.drain(); + }); + }; + const queue = + options?.priority === "high" ? this.highPriorityQueue : this.normalPriorityQueue; + queue.push(admit); + this.drain(); }); } + + private drain(): void { + while (this.admitted < this.policy.maxProcessConcurrency) { + const admit = this.highPriorityQueue.shift() ?? this.normalPriorityQueue.shift(); + if (!admit) { + return; + } + this.admitted += 1; + admit(); + } + } + + private async execute( + start: () => ScheduledGitProcess, + resolve: (value: T | PromiseLike) => void, + reject: (reason?: unknown) => void, + ): Promise { + this.waiting = Math.max(0, this.waiting - 1); + this.running += 1; + try { + const process = start(); + void process.result.then(resolve, reject); + await process.exited; + } catch (error) { + reject(error); + } finally { + this.running = Math.max(0, this.running - 1); + this.admitted = Math.max(0, this.admitted - 1); + this.drain(); + } + } } diff --git a/packages/server/src/utils/path.test.ts b/packages/server/src/utils/path.test.ts index fbdf509f5..f72f5d82b 100644 --- a/packages/server/src/utils/path.test.ts +++ b/packages/server/src/utils/path.test.ts @@ -42,6 +42,13 @@ describe("path equivalence", () => { ); }); + test("preserves the casing of Windows relative suffixes", () => { + expect(getRealpathAwareRelativePath("C:\\Repo\\.git", "c:\\repo\\.git\\HEAD")).toBe("HEAD"); + expect( + getRealpathAwareRelativePath("C:\\Repo\\.git", "c:\\repo\\.git\\refs\\heads\\FeatureCase"), + ).toBe("refs\\heads\\FeatureCase"); + }); + test.skipIf(process.platform === "win32")( "derives the contained suffix from a realpath-equivalent root", () => { diff --git a/packages/server/src/utils/path.ts b/packages/server/src/utils/path.ts index 3190e7a1d..d5f0d30c8 100644 --- a/packages/server/src/utils/path.ts +++ b/packages/server/src/utils/path.ts @@ -97,11 +97,18 @@ function getRelativePathInsideRoot(root: string, candidate: string): string | nu const platformPath = compareAsWindows ? nodePath.win32 : nodePath.posix; const normalizedRoot = normalizePathForComparison(root, compareAsWindows); const normalizedCandidate = normalizePathForComparison(candidate, compareAsWindows); - const relative = platformPath.relative(normalizedRoot, normalizedCandidate); + const comparableRelative = platformPath.relative(normalizedRoot, normalizedCandidate); - return relative === "" || (!relative.startsWith("..") && !platformPath.isAbsolute(relative)) - ? relative - : null; + if ( + comparableRelative !== "" && + (comparableRelative.startsWith("..") || platformPath.isAbsolute(comparableRelative)) + ) { + return null; + } + + const casePreservingRoot = normalizePathPreservingCase(root, compareAsWindows); + const casePreservingCandidate = normalizePathPreservingCase(candidate, compareAsWindows); + return platformPath.relative(casePreservingRoot, casePreservingCandidate); } function collectPathVariants(value: string): string[] { @@ -140,15 +147,19 @@ function looksLikeDefiniteWindowsPath(value: string): boolean { } function normalizePathForComparison(value: string, compareAsWindows: boolean): string { + const normalized = normalizePathPreservingCase(value, compareAsWindows); + return compareAsWindows ? normalized.toLowerCase() : normalized; +} + +function normalizePathPreservingCase(value: string, compareAsWindows: boolean): string { const platformPath = compareAsWindows ? nodePath.win32 : nodePath.posix; const comparableValue = compareAsWindows ? stripWindowsNamespacePrefix(value) : value; const platformNormalized = platformPath.normalize(comparableValue); - const normalized = stripTrailingSeparators( + return stripTrailingSeparators( platformNormalized, platformPath.parse(platformNormalized).root, compareAsWindows, ); - return compareAsWindows ? normalized.toLowerCase() : normalized; } function stripWindowsNamespacePrefix(value: string): string { diff --git a/packages/server/src/utils/run-git-command.test.ts b/packages/server/src/utils/run-git-command.test.ts index 7bbbe3e2b..1c33aff9d 100644 --- a/packages/server/src/utils/run-git-command.test.ts +++ b/packages/server/src/utils/run-git-command.test.ts @@ -342,6 +342,73 @@ describe("runGitCommand", () => { }); }); + it("records submitted Git commands before they start and refuses an incomplete measurement", async () => { + const { + getGitCommandMetrics, + runGitCommand, + startGitCommandMetrics, + stopGitCommandMetrics, + waitForGitCommandMetricsIdle, + } = await loadRunGitCommand(1); + + enqueueSpawnBehaviors({ delayMs: 100 }, { delayMs: 0 }); + startGitCommandMetrics(); + + const first = runGitCommand(["status", "--short"], { cwd: process.cwd() }); + const second = runGitCommand(["rev-parse", "--show-toplevel"], { cwd: process.cwd() }); + + await vi.waitFor(() => { + expect(fakeSpawnController.processes).toHaveLength(1); + }); + expect(getGitCommandMetrics()).toMatchObject({ + submitted: 2, + started: 1, + completed: 0, + active: 1, + pending: 1, + submissions: [ + { args: ["status", "--short"], cwd: process.cwd() }, + { args: ["rev-parse", "--show-toplevel"], cwd: process.cwd() }, + ], + }); + expect(() => stopGitCommandMetrics()).toThrow( + "Cannot stop Git command metrics while 2 submitted commands are unfinished", + ); + + await Promise.all([first, second]); + await waitForGitCommandMetricsIdle({ quietMs: 0, timeoutMs: 1_000 }); + expect(stopGitCommandMetrics()).toMatchObject({ + submitted: 2, + started: 2, + completed: 2, + active: 0, + pending: 0, + total: 2, + }); + }); + + it("carries high-priority admission across an async user operation", async () => { + const { runGitCommand, runWithGitCommandPriority } = await loadRunGitCommand(1); + enqueueSpawnBehaviors( + { delayMs: 100, stdoutData: "active" }, + { stdoutData: "high" }, + { stdoutData: "normal" }, + ); + + const active = runGitCommand(["status"], { cwd: process.cwd() }); + await vi.waitFor(() => expect(fakeSpawnController.processes).toHaveLength(1)); + const normal = runGitCommand(["rev-parse", "normal"], { cwd: process.cwd() }); + const high = runWithGitCommandPriority("high", () => + runGitCommand(["rev-parse", "high"], { cwd: process.cwd() }), + ); + + await expect(Promise.all([active, high, normal])).resolves.toEqual([ + expect.objectContaining({ stdout: "active" }), + expect.objectContaining({ stdout: "high" }), + expect.objectContaining({ stdout: "normal" }), + ]); + }); + it("resolves truncated stdout, caps output, and kills the child process", async () => { const { runGitCommand } = await loadRunGitCommand(1); diff --git a/packages/server/src/utils/run-git-command.ts b/packages/server/src/utils/run-git-command.ts index 89c640648..71c9e95f6 100644 --- a/packages/server/src/utils/run-git-command.ts +++ b/packages/server/src/utils/run-git-command.ts @@ -1,3 +1,4 @@ +import { AsyncLocalStorage } from "node:async_hooks"; import { existsSync } from "node:fs"; import type { Logger } from "pino"; import type { ProcessEnvRecord } from "../server/paseo-env.js"; @@ -14,6 +15,7 @@ import { import { spawnProcess } from "./spawn.js"; import { GitProcessScheduler, + type GitProcessPriority, resolveGitProcessPolicy, type GitProcessPolicy, } from "./git-process-scheduler.js"; @@ -24,6 +26,11 @@ const DEFAULT_STDERR_LIMIT = 2048; let gitProcessScheduler = new GitProcessScheduler(resolveGitProcessPolicy({ env: process.env })); let gitRuntimeMetrics = createGitCommandRuntimeMetricsWindow(gitProcessScheduler.policy); +const gitCommandPriority = new AsyncLocalStorage(); + +export function runWithGitCommandPriority(priority: GitProcessPriority, operation: () => T): T { + return gitCommandPriority.run(priority, operation); +} function createGitCommandRuntimeMetricsWindow(policy: GitProcessPolicy) { return new GitCommandRuntimeMetricsWindow({ @@ -67,15 +74,31 @@ export interface GitCommandMetric { export interface GitCommandMetricsSnapshot { commands: GitCommandMetric[]; + submissions: GitCommandSubmissionMetric[]; + submitted: number; + started: number; + completed: number; + active: number; + pending: number; total: number; failed: number; maxConcurrent: number; } +export interface GitCommandSubmissionMetric { + args: string[]; + cwd: string; +} + interface GitCommandMetricsState { commands: GitCommandMetric[]; + submissions: GitCommandSubmissionMetric[]; + submitted: number; + started: number; + completed: number; active: number; maxConcurrent: number; + lastSubmittedAtMs: number; } let gitCommandMetricsState: GitCommandMetricsState | null = null; @@ -83,24 +106,87 @@ let gitCommandMetricsState: GitCommandMetricsState | null = null; export function startGitCommandMetrics(): void { gitCommandMetricsState = { commands: [], + submissions: [], + submitted: 0, + started: 0, + completed: 0, active: 0, maxConcurrent: 0, + lastSubmittedAtMs: Date.now(), }; } export function stopGitCommandMetrics(): GitCommandMetricsSnapshot { const state = gitCommandMetricsState; - gitCommandMetricsState = null; if (!state) { return { commands: [], + submissions: [], + submitted: 0, + started: 0, + completed: 0, + active: 0, + pending: 0, total: 0, failed: 0, maxConcurrent: 0, }; } + const unfinished = state.submitted - state.completed; + if (unfinished > 0) { + throw new Error( + `Cannot stop Git command metrics while ${unfinished} submitted commands are unfinished`, + ); + } + gitCommandMetricsState = null; + return snapshotGitCommandMetricsState(state); +} + +export function getGitCommandMetrics(): GitCommandMetricsSnapshot { + const state = gitCommandMetricsState; + if (!state) { + return stopGitCommandMetrics(); + } + return snapshotGitCommandMetricsState(state); +} + +export async function waitForGitCommandMetricsIdle(options: { + quietMs: number; + timeoutMs: number; +}): Promise { + const startedAtMs = Date.now(); + while (true) { + const state = gitCommandMetricsState; + if (!state) { + throw new Error("Git command metrics are not running"); + } + const isComplete = state.completed === state.submitted; + const hasBeenQuiet = Date.now() - state.lastSubmittedAtMs >= options.quietMs; + if (isComplete && hasBeenQuiet) { + return; + } + if (Date.now() - startedAtMs >= options.timeoutMs) { + const unfinished = state.submitted - state.completed; + throw new Error( + `Timed out waiting for Git command metrics to become idle (${unfinished} unfinished)`, + ); + } + await new Promise((resolve) => setTimeout(resolve, 10)); + } +} + +function snapshotGitCommandMetricsState(state: GitCommandMetricsState): GitCommandMetricsSnapshot { return { commands: [...state.commands], + submissions: state.submissions.map((submission) => ({ + args: [...submission.args], + cwd: submission.cwd, + })), + submitted: state.submitted, + started: state.started, + completed: state.completed, + active: state.active, + pending: state.submitted - state.started, total: state.commands.length, failed: state.commands.filter((command) => !command.success).length, maxConcurrent: state.maxConcurrent, @@ -114,14 +200,24 @@ export function snapshotGitCommandRuntimeMetrics(): GitCommandRuntimeMetricsSnap }); } -function beginGitCommandMetric(): GitCommandMetricsState | null { +function submitGitCommandMetric(args: string[], cwd: string): GitCommandMetricsState | null { const state = gitCommandMetricsState; if (!state) { return null; } + state.submissions.push({ args: [...args], cwd }); + state.submitted += 1; + state.lastSubmittedAtMs = Date.now(); + return state; +} + +function beginGitCommandMetric(state: GitCommandMetricsState | null): void { + if (!state) { + return; + } + state.started += 1; state.active += 1; state.maxConcurrent = Math.max(state.maxConcurrent, state.active); - return state; } function finishGitCommandMetric( @@ -132,6 +228,7 @@ function finishGitCommandMetric( return; } state.active = Math.max(0, state.active - 1); + state.completed += 1; state.commands.push(metric); } @@ -156,12 +253,13 @@ export function runGitCommand( args: string[], options: GitCommandOptions, ): Promise { + const metricsState = submitGitCommandMetric(args, options.cwd); const commandTrace = submitGitCommandTrace(args, options.cwd, { active: gitProcessScheduler.activeCount, pending: gitProcessScheduler.pendingCount, }); const runtimeMetric = gitRuntimeMetrics.submit(getGitOperation(args)); - const promise = gitProcessScheduler.run(() => { + const startCommand = () => { let releaseProcessSlot!: () => void; const exited = new Promise((resolve) => { releaseProcessSlot = resolve; @@ -178,7 +276,7 @@ export function runGitCommand( const command = formatGitCommand(args); const envOverlay = mergeEnvOverlays(options.env, options.envOverlay); const startedAt = Date.now(); - const metricsState = beginGitCommandMetric(); + beginGitCommandMetric(metricsState); const logger = typeof options.logger?.trace === "function" ? options.logger : undefined; const traceContext = logger ? { @@ -417,6 +515,9 @@ export function runGitCommand( }); }); return { result: resultPromise, exited }; + }; + const promise = gitProcessScheduler.run(startCommand, { + priority: gitCommandPriority.getStore() ?? "normal", }); gitRuntimeMetrics.observeLimiter( gitProcessScheduler.activeCount,