Keep workspace Git refreshes responsive at scale (#2979)
* fix(server): prevent Git watcher fanout stalls Repository metadata events were refreshing every observed worktree, multiplying Git process submissions and starving workspace create and archive requests behind observation work. Route metadata to the workspaces that depend on it, preserve conservative shared-ref refreshes, and admit interactive lifecycle operations ahead of queued background reads. * fix(server): preserve Git metadata dependencies
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<CreatePaseoWorktreeResult> {
|
||||
return runWithGitCommandPriority("high", () => createPaseoWorktreeWithPriority(input, deps));
|
||||
}
|
||||
|
||||
async function createPaseoWorktreeWithPriority(
|
||||
input: CreatePaseoWorktreeInput,
|
||||
deps: CreatePaseoWorktreeDeps,
|
||||
): Promise<CreatePaseoWorktreeResult> {
|
||||
const workspaceCwdPlan = await planWorkspaceCwdForWorktree(input.cwd, deps.workspaceGitService);
|
||||
const createdWorktree = await createWorktreeCore(input, deps);
|
||||
|
||||
@@ -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<ArchiveResult> {
|
||||
return runWithGitCommandPriority("high", () => archiveByScopeWithPriority(dependencies, request));
|
||||
}
|
||||
|
||||
async function archiveByScopeWithPriority(
|
||||
dependencies: ArchiveDependencies,
|
||||
request: ArchiveByScopeRequest,
|
||||
): Promise<ArchiveResult> {
|
||||
const target = await resolveArchiveTarget(dependencies, request.scope);
|
||||
const targetWorkspaceIds = target.workspaceIds;
|
||||
|
||||
@@ -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<ReturnType<typeof seedFixture>> {
|
||||
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<keyof GitRuntimeExpectation, unknown> = {
|
||||
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<void> {
|
||||
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<string, number> {
|
||||
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<void> {
|
||||
startGitCommandMetrics();
|
||||
await waitForGitCommandMetricsIdle({ quietMs: 1_500, timeoutMs: 30_000 });
|
||||
stopGitCommandMetrics();
|
||||
}
|
||||
|
||||
async function measureGitCommands(operation: () => void | Promise<void>) {
|
||||
startGitCommandMetrics();
|
||||
await operation();
|
||||
await waitForGitCommandMetricsIdle({ quietMs: 1_500, timeoutMs: 30_000 });
|
||||
return stopGitCommandMetrics();
|
||||
}
|
||||
|
||||
function waitForCheckoutDiffFiles(subscriptionId: string, expectedPaths: string[]): Promise<void> {
|
||||
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);
|
||||
@@ -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<CheckoutStatusGit>,
|
||||
@@ -104,6 +118,10 @@ function createDeferred<T>() {
|
||||
return { promise, reject, resolve };
|
||||
}
|
||||
|
||||
function getCalledCwds(mock: ReturnType<typeof vi.fn>): string[] {
|
||||
return mock.mock.calls.map(([cwd]) => cwd as string);
|
||||
}
|
||||
|
||||
function createService(
|
||||
watcher: ReturnType<typeof createWatcherHarness>,
|
||||
overrides?: Record<string, unknown>,
|
||||
@@ -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));
|
||||
|
||||
@@ -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<void> {
|
||||
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<string, RepoMetadataWorkspaceRefresh> | null {
|
||||
const refreshes = new Map<string, RepoMetadataWorkspaceRefresh>();
|
||||
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<typeof createRealpathAwarePathMatcher>,
|
||||
refreshes: Map<string, RepoMetadataWorkspaceRefresh>,
|
||||
): 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<typeof createRealpathAwarePathMatcher>,
|
||||
refreshes: Map<string, RepoMetadataWorkspaceRefresh>,
|
||||
): 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<typeof createRealpathAwarePathMatcher>,
|
||||
refreshBase: boolean,
|
||||
refreshes: Map<string, RepoMetadataWorkspaceRefresh>,
|
||||
): 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<string, RepoMetadataWorkspaceRefresh>,
|
||||
): 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<string, RepoMetadataWorkspaceRefresh>,
|
||||
): 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<string, RepoMetadataWorkspaceRefresh>,
|
||||
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<string, RepoMetadataWorkspaceRefresh> | null = null,
|
||||
): void {
|
||||
if (target.closed || this.repoTargets.get(target.repoGitRoot) !== target) {
|
||||
return;
|
||||
}
|
||||
const workingTreeTargets = new Set<WorkingTreeWatchTarget>();
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<CreateWorktreeCoreResult> {
|
||||
return runWithGitCommandPriority("high", () => createWorktreeCoreWithPriority(input, deps));
|
||||
}
|
||||
|
||||
async function createWorktreeCoreWithPriority(
|
||||
input: CreateWorktreeCoreInput,
|
||||
deps: CreateWorktreeCoreDeps,
|
||||
): Promise<CreateWorktreeCoreResult> {
|
||||
const repoRoot = await resolveWorktreeRepoRoot(input, deps.workspaceGitService);
|
||||
const requestedWorktreeSlug = input.worktreeSlug
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import pLimit from "p-limit";
|
||||
import pThrottle from "p-throttle";
|
||||
|
||||
export interface GitProcessPolicy {
|
||||
@@ -16,6 +15,8 @@ export interface ScheduledGitProcess<T> {
|
||||
exited: Promise<void>;
|
||||
}
|
||||
|
||||
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<void>) => start());
|
||||
}
|
||||
|
||||
get activeCount(): number {
|
||||
@@ -65,24 +68,55 @@ export class GitProcessScheduler {
|
||||
return this.waiting;
|
||||
}
|
||||
|
||||
run<T>(start: () => ScheduledGitProcess<T>): Promise<T> {
|
||||
run<T>(
|
||||
start: () => ScheduledGitProcess<T>,
|
||||
options?: { priority?: GitProcessPriority },
|
||||
): Promise<T> {
|
||||
this.waiting += 1;
|
||||
return new Promise<T>((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<T>(
|
||||
start: () => ScheduledGitProcess<T>,
|
||||
resolve: (value: T | PromiseLike<T>) => void,
|
||||
reject: (reason?: unknown) => void,
|
||||
): Promise<void> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
() => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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<GitProcessPriority>();
|
||||
|
||||
export function runWithGitCommandPriority<T>(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<void> {
|
||||
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<GitCommandResult> {
|
||||
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<void>((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,
|
||||
|
||||
Reference in New Issue
Block a user