import { createHash } from "node:crypto"; import { readFile } from "node:fs/promises"; import { join, resolve } from "node:path"; import parcelWatcher from "@parcel/watcher"; import { LRUCache } from "lru-cache"; import type pino from "pino"; import type { ProjectCheckoutLitePayload } from "@getpaseo/protocol/messages"; import { parseGitRemoteLocation } from "@getpaseo/protocol/git-remote"; import type { CheckoutContext } from "../utils/checkout-git.js"; import { type BranchCheckoutResolution, type BranchSuggestion, type CheckoutSnapshotFacts, type CheckoutDiffCompare, type CheckoutDiffResult, getCheckoutDiff, getCheckoutSnapshotFacts, getCheckoutShortstat, getCheckoutStatus, getCheckoutWorktreeState, getPullRequestStatus, forgeAuthStateFromError, hasOriginRemote, listBranchSuggestions, resolveRepositoryDefaultBranch, resolveBranchCheckout, resolveAbsoluteGitDir, } from "../utils/checkout-git.js"; import type { ForgeAuthState, ForgeService, ForgeSpecificStatusFacts, PullRequestMergeable, } from "../services/forge-service.js"; import { createForgeService } from "../services/forge-registry.js"; import { createForgeResolver, type ForgeResolution, type ForgeResolver, } from "../services/forge-resolver.js"; import { parseGitRevParsePath } from "../utils/git-rev-parse-path.js"; import { createRealpathAwarePathMatcher, 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 { deriveProjectSlug } from "./workspace-git-metadata.js"; import { checkoutLiteFromGitSnapshot } from "./workspace-registry-model.js"; const WORKSPACE_GIT_WATCH_DEBOUNCE_MS = 1_000; const BACKGROUND_GIT_FETCH_INTERVAL_MS = 180_000; export const WORKSPACE_GIT_SELF_HEAL_INTERVAL_MS = 60_000; const FORGE_PR_STATUS_POLL_FAST_INTERVAL_MS = 20_000; const FORGE_PR_STATUS_POLL_SLOW_INTERVAL_MS = 120_000; const FORGE_PR_STATUS_POLL_ERROR_BACKOFF_CAP_MS = 300_000; const DEGRADED_GIT_POLL_INTERVAL_MS = 5_000; // Auxiliary reads may reuse cached values within this window; snapshots do not expire on read. const WORKSPACE_GIT_AUXILIARY_READ_TTL_MS = 15_000; // Non-forced refresh triggers share this minimum gap to absorb watcher/self-heal bursts; force bypasses it. const WORKSPACE_GIT_INTERNAL_MIN_GAP_MS = 2_000; // Heavy values (multi-MB highlighted diffs); cap aggressively. Ephemeral worktree cwds would otherwise pile up forever. const WORKSPACE_GIT_CHECKOUT_DIFF_CACHE_MAX = 64; // Small values (booleans, short strings, small arrays); generous cap. const WORKSPACE_GIT_AUXILIARY_CACHE_MAX = 256; export function getWorkspaceGitSelfHealPhaseMs(cwd: string): number { return ( createHash("sha256").update(cwd).digest().readUInt32BE(0) % WORKSPACE_GIT_SELF_HEAL_INTERVAL_MS ); } export interface WorkspaceGitRuntimeSnapshot { cwd: string; git: { isGit: boolean; repoRoot: string | null; mainRepoRoot: string | null; currentBranch: string | null; remoteUrl: string | null; isPaseoOwnedWorktree: boolean; isDirty: boolean | null; baseRef: string | null; aheadBehind: { ahead: number; behind: number } | null; upstreamRef: string | null; aheadOfOrigin: number | null; behindOfOrigin: number | null; hasRemote: boolean; diffStat: { additions: number; deletions: number } | null; }; forge: { featuresEnabled: boolean; authState: ForgeAuthState; /** * Forge resolved for this workspace from its remote — including the per-host * probe, so self-managed GitLab hosts (no "gitlab" in the name) are labeled * correctly. The wire projection prefers this over the bare name heuristic. */ forge?: string; pullRequest: { number?: number; repoOwner?: string; repoName?: string; projectPath?: string; url: string; title: string; state: string; baseRefName: string; headRefName: string; isMerged: boolean; isDraft?: boolean; mergeable?: PullRequestMergeable; checks?: Array<{ name: string; status: "success" | "failure" | "pending" | "skipped" | "cancelled"; url: string | null; workflow?: string; duration?: string; }>; checksStatus?: "none" | "pending" | "success" | "failure"; reviewDecision?: "approved" | "changes_requested" | "pending" | null; forgeSpecific?: ForgeSpecificStatusFacts; } | null; error: { message: string } | null; }; } export interface WorkspaceGitService { registerWorkspace( params: { cwd: string }, listener: WorkspaceGitListener, ): WorkspaceGitSubscription; onSnapshotUpdated(listener: WorkspaceGitSnapshotUpdatedListener): WorkspaceGitSubscription; peekSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot | null; getCheckout(cwd: string): Promise; getSnapshot( cwd: string, options?: WorkspaceGitSnapshotOptions, ): Promise; resolveForge(cwd: string): Promise; getCheckoutDiff( cwd: string, options: CheckoutDiffCompare, readOptions?: WorkspaceGitReadOptions, ): Promise; validateBranchRef( cwd: string, ref: string, options?: WorkspaceGitReadOptions, ): Promise; hasLocalBranch(cwd: string, branch: string, options?: WorkspaceGitReadOptions): Promise; suggestBranchesForCwd( cwd: string, options?: WorkspaceGitBranchSuggestionsOptions, readOptions?: WorkspaceGitReadOptions, ): Promise; listStashes( cwd: string, options?: WorkspaceGitStashListOptions, readOptions?: WorkspaceGitReadOptions, ): Promise; listWorktrees( cwdOrRepoRoot: string, options?: WorkspaceGitReadOptions, ): Promise; getProjectSlug(cwd: string, options?: WorkspaceGitReadOptions): Promise; resolveRepoRoot(cwd: string, options?: WorkspaceGitReadOptions): Promise; resolveDefaultBranch(cwdOrRepoRoot: string, options?: WorkspaceGitReadOptions): Promise; resolveRepoRemoteUrl(cwd: string, options?: WorkspaceGitReadOptions): Promise; refresh(cwd: string, options?: { priority?: "normal" | "high" }): Promise; requestWorkingTreeWatch( cwd: string, onChange: () => void, ): Promise<{ repoRoot: string | null; unsubscribe: () => void }>; scheduleRefreshForCwd(cwd: string): void; onWorkspaceStateMayHaveChanged(cwd: string): void; invalidateForge(cwd: string): void; getMetrics(): WorkspaceGitServiceMetrics; dispose(): void; } export interface WorkspaceGitServiceMetrics { workspaceTargetCount: number; workspaceListenerCount: number; repositoryTargetCount: number; repositoryWorkspaceLinkCount: number; workingTreeWatchTargetCount: number; workingTreeWatchListenerCount: number; workspaceObservationSetupInFlightCount: number; workingTreeWatchSetupInFlightCount: number; workspaceRefreshInFlightCount: number; workspaceRefreshQueuedCount: number; fetchInFlightCount: number; snapshotUpdatedListenerCount: number; } export type WorkspaceGitListener = (snapshot: WorkspaceGitRuntimeSnapshot) => void; export type WorkspaceGitSnapshotUpdatedListener = (snapshot: WorkspaceGitRuntimeSnapshot) => void; export interface WorkspaceGitSubscription { unsubscribe: () => void; } export type WorkspaceGitReadOptions = | { force?: false; reason?: string; } | { force: true; reason: string; }; export interface WorkspaceGitBranchSuggestionsOptions { query?: string; limit?: number; } export interface WorkspaceGitStashListOptions { paseoOnly?: boolean; } export interface WorkspaceGitStashEntry { index: number; message: string; branch: string | null; isPaseo: boolean; } export type WorkspaceGitBranchValidationResult = BranchCheckoutResolution; export type WorkspaceGitBranchSuggestion = BranchSuggestion; export type WorkspaceGitWorktreeInfo = PaseoWorktreeInfo; export type WorkspaceGitSnapshotOptions = | { force?: false; includeForge?: boolean; reason?: string; } | { force: true; includeForge?: boolean; reason: string; }; interface WorkspaceGitRefreshRequest { force: boolean; refreshStructure: boolean; refreshWorktree: boolean; includeForge: boolean; emitUnchanged?: boolean; reason: string; notify: boolean; queueIfBusy: boolean; } interface ScheduledWorkspaceGitRefreshOptions { force?: boolean; scope?: "structure" | "worktree"; includeForge?: boolean; emitUnchanged?: boolean; reason?: string; queueIfBusy?: boolean; } type WorkspaceGitRefreshState = | { status: "idle"; } | { status: "in-flight"; promise: Promise; request: WorkspaceGitRefreshRequest; queued: WorkspaceGitRefreshRequest | null; }; interface WorkspaceGitServiceDependencies { subscribe: typeof parcelWatcher.subscribe; getCheckoutSnapshotFacts: typeof getCheckoutSnapshotFacts; getCheckoutStatus: typeof getCheckoutStatus; getCheckoutShortstat: typeof getCheckoutShortstat; getCheckoutWorktreeState: typeof getCheckoutWorktreeState; getCheckoutDiff: typeof getCheckoutDiff; getPullRequestStatus: typeof getPullRequestStatus; resolveBranchCheckout: typeof resolveBranchCheckout; resolveRepositoryDefaultBranch: typeof resolveRepositoryDefaultBranch; listBranchSuggestions: typeof listBranchSuggestions; listPaseoWorktrees: typeof listPaseoWorktrees; /** * Adapter instances to bind by forge id instead of building from the registry * — the injection seam for the daemon's shared GitHub adapter and for test * fakes. Any forge not listed here is built (and cached once) by the registry. */ forgeOverrides?: Record; resolveAbsoluteGitDir: (cwd: string) => Promise; hasOriginRemote: (cwd: string) => Promise; runGitFetch: (cwd: string) => Promise; runGitCommand: typeof runGitCommand; getWorkspaceGitSelfHealPhaseMs: typeof getWorkspaceGitSelfHealPhaseMs; now: () => Date; } interface WorkspaceGitServiceOptions { logger: pino.Logger; paseoHome: string; worktreesRoot?: string; deps?: Partial; } export function getWorkspaceFileWatcherBackend( platform: NodeJS.Platform, ): parcelWatcher.BackendType { switch (platform) { case "darwin": return "fs-events"; case "linux": return "inotify"; case "win32": return "windows"; default: throw new Error(`No native workspace file watcher configured for ${platform}`); } } export function subscribeToWorkspaceFileChanges( directory: string, callback: parcelWatcher.SubscribeCallback, options?: parcelWatcher.Options, ): Promise { return parcelWatcher.subscribe(directory, callback, { ...options, backend: getWorkspaceFileWatcherBackend(process.platform), }); } interface WorkspaceGitTarget { cwd: string; listeners: Set; workingTreeWatchTarget: WorkingTreeWatchTarget | null; debounceTimer: NodeJS.Timeout | null; pendingDebounceRequest: WorkspaceGitRefreshRequest | null; selfHealTimer: NodeJS.Timeout | null; forgePrStatusPollSubscription: { unsubscribe: () => void } | null; forgePrStatusPollKey: string | null; refreshState: WorkspaceGitRefreshState; latestGit: WorkspaceGitRuntimeSnapshot["git"] | null; latestGitLoadedAtMs: number | null; latestStructuralRefreshAtMs: number | null; latestWorktreeRefreshAtMs: number | null; auditWindowStartedAtMs: number | null; latestForge: WorkspaceGitRuntimeSnapshot["forge"] | null; latestForgeLoadedAtMs: number | null; latestSnapshot: WorkspaceGitRuntimeSnapshot | null; latestSnapshotLoadedAtMs: number | null; latestFacts: CheckoutSnapshotFacts | null; factsPromise: Promise | null; latestFingerprint: string | null; lastShellOutAtMs: number | null; repoGitRoot: string | null; observationSetupPromise: Promise | null; observationSetupComplete: boolean; closed: boolean; } interface RepoGitTarget { repoGitRoot: string; cwd: string; workspaceKeys: Set; subscription: parcelWatcher.AsyncSubscription | null; fallbackPollTimer: NodeJS.Timeout | null; intervalId: NodeJS.Timeout | null; fetchInFlight: boolean; closed: boolean; } interface WorkingTreeWatchTarget { cwd: string; watchPath: string; repoRoot: string | null; subscription: parcelWatcher.AsyncSubscription | null; ignoredDirectories: Set; ignoredDirectoriesRefreshPromise: Promise | null; aliases: Set; workspaceKeys: Set; fallbackPollTimer: NodeJS.Timeout | null; listeners: Set<() => void>; closed: boolean; } interface WorkspaceGitAuxiliaryReadCacheEntry { value: T | null; loadedAtMs: number | null; lastShellOutAtMs: number | null; inFlight: Promise | null; } interface WorkspaceForgePrStatusPollTarget { headRef: string; headSha?: string; headRepositoryOwner?: string; } function buildDefaultWorkspaceGitServiceDeps(): WorkspaceGitServiceDependencies { return { subscribe: subscribeToWorkspaceFileChanges, getCheckoutSnapshotFacts, getCheckoutStatus, getCheckoutShortstat, getCheckoutWorktreeState, getCheckoutDiff, getPullRequestStatus, resolveBranchCheckout, resolveRepositoryDefaultBranch, listBranchSuggestions, listPaseoWorktrees, resolveAbsoluteGitDir, hasOriginRemote, runGitFetch, runGitCommand, getWorkspaceGitSelfHealPhaseMs, now: () => new Date(), }; } function resolveWorkspaceGitServiceDeps( deps: Partial | undefined, ): WorkspaceGitServiceDependencies { return { ...buildDefaultWorkspaceGitServiceDeps(), ...deps }; } export class WorkspaceGitServiceImpl implements WorkspaceGitService { private readonly logger: pino.Logger; private readonly paseoHome: string; private readonly worktreesRoot: string | undefined; private readonly deps: WorkspaceGitServiceDependencies; private readonly forgeResolver: ForgeResolver; private readonly snapshotUpdatedListeners = new Set(); private readonly workspaceTargets = new Map(); private readonly repoTargets = new Map(); private readonly workingTreeWatchTargets = new Map(); private readonly workingTreeWatchSetups = new Map>(); private readonly workingTreeWatchResolutions = new Map>(); private readonly workingTreeWatchAliases = new Map(); private readonly branchValidationCache = new LRUCache< string, WorkspaceGitAuxiliaryReadCacheEntry >({ max: WORKSPACE_GIT_AUXILIARY_CACHE_MAX }); private readonly localBranchCache = new LRUCache< string, WorkspaceGitAuxiliaryReadCacheEntry >({ max: WORKSPACE_GIT_AUXILIARY_CACHE_MAX }); private readonly branchSuggestionsCache = new LRUCache< string, WorkspaceGitAuxiliaryReadCacheEntry >({ max: WORKSPACE_GIT_AUXILIARY_CACHE_MAX }); private readonly stashListCache = new LRUCache< string, WorkspaceGitAuxiliaryReadCacheEntry >({ max: WORKSPACE_GIT_AUXILIARY_CACHE_MAX }); private readonly worktreeListCache = new LRUCache< string, WorkspaceGitAuxiliaryReadCacheEntry >({ max: WORKSPACE_GIT_AUXILIARY_CACHE_MAX }); private readonly defaultBranchCache = new LRUCache< string, WorkspaceGitAuxiliaryReadCacheEntry >({ max: WORKSPACE_GIT_AUXILIARY_CACHE_MAX }); private readonly checkoutDiffCache = new LRUCache< string, WorkspaceGitAuxiliaryReadCacheEntry >({ max: WORKSPACE_GIT_CHECKOUT_DIFF_CACHE_MAX }); constructor(options: WorkspaceGitServiceOptions) { this.logger = options.logger.child({ module: "workspace-git-service" }); this.paseoHome = options.paseoHome; this.worktreesRoot = options.worktreesRoot; this.deps = resolveWorkspaceGitServiceDeps(options.deps); this.forgeResolver = createForgeResolver({ createService: (forge) => this.deps.forgeOverrides?.[forge] ?? createForgeService(forge), }); } resolveForge(cwd: string): Promise { return this.forgeResolver.resolve(resolve(cwd)); } registerWorkspace( params: { cwd: string }, listener: WorkspaceGitListener, ): WorkspaceGitSubscription { const cwd = resolve(params.cwd); const target = this.ensureWorkspaceTarget(cwd); target.listeners.add(listener); if (target.listeners.size === 1) { this.startWorkspaceSubscriptionTimers(target); } if (!target.latestSnapshot) { this.scheduleInitialWorkspaceRefresh(target); } this.scheduleWorkspaceObservationSetup(target); return { unsubscribe: () => { this.removeWorkspaceListener(cwd, listener); }, }; } onSnapshotUpdated(listener: WorkspaceGitSnapshotUpdatedListener): WorkspaceGitSubscription { this.snapshotUpdatedListeners.add(listener); return { unsubscribe: () => { this.snapshotUpdatedListeners.delete(listener); }, }; } getMetrics(): WorkspaceGitServiceMetrics { let workspaceListenerCount = 0; let repositoryWorkspaceLinkCount = 0; let workingTreeWatchListenerCount = 0; let workspaceRefreshInFlightCount = 0; let workspaceRefreshQueuedCount = 0; let workspaceObservationSetupInFlightCount = 0; let fetchInFlightCount = 0; for (const target of this.workspaceTargets.values()) { workspaceListenerCount += target.listeners.size; if (target.observationSetupPromise) { workspaceObservationSetupInFlightCount += 1; } if (target.refreshState.status === "in-flight") { workspaceRefreshInFlightCount += 1; if (target.refreshState.queued) { workspaceRefreshQueuedCount += 1; } } } for (const target of this.repoTargets.values()) { repositoryWorkspaceLinkCount += target.workspaceKeys.size; if (target.fetchInFlight) { fetchInFlightCount += 1; } } for (const target of this.workingTreeWatchTargets.values()) { workingTreeWatchListenerCount += target.listeners.size; } return { workspaceTargetCount: this.workspaceTargets.size, workspaceListenerCount, repositoryTargetCount: this.repoTargets.size, repositoryWorkspaceLinkCount, workingTreeWatchTargetCount: this.workingTreeWatchTargets.size, workingTreeWatchListenerCount, workspaceObservationSetupInFlightCount, workingTreeWatchSetupInFlightCount: this.workingTreeWatchSetups.size, workspaceRefreshInFlightCount, workspaceRefreshQueuedCount, fetchInFlightCount, snapshotUpdatedListenerCount: this.snapshotUpdatedListeners.size, }; } async getSnapshot( cwd: string, options?: WorkspaceGitSnapshotOptions, ): Promise { cwd = resolve(cwd); const request = this.normalizeRefreshRequest(options, "getSnapshot", true); const target = this.ensureWorkspaceTarget(cwd); if (!request.force && target.latestSnapshot) { return target.latestSnapshot; } return this.requestWorkspaceSnapshot(target, request); } async getCheckout(cwd: string): Promise { const normalizedCwd = resolve(cwd); const status = await this.deps.getCheckoutStatus(normalizedCwd, { paseoHome: this.paseoHome, worktreesRoot: this.worktreesRoot, logger: this.logger, }); if (!status.isGit) { return checkoutLiteFromGitSnapshot(normalizedCwd, { isGit: false, currentBranch: null, remoteUrl: null, repoRoot: null, isPaseoOwnedWorktree: false, mainRepoRoot: null, }); } return checkoutLiteFromGitSnapshot(normalizedCwd, { isGit: true, currentBranch: status.currentBranch, remoteUrl: status.remoteUrl, repoRoot: status.repoRoot, isPaseoOwnedWorktree: status.isPaseoOwnedWorktree, mainRepoRoot: status.mainRepoRoot, }); } peekSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot | null { cwd = resolve(cwd); return this.workspaceTargets.get(cwd)?.latestSnapshot ?? null; } getCheckoutDiff( cwd: string, options: CheckoutDiffCompare, readOptions?: WorkspaceGitReadOptions, ): Promise { const normalizedCwd = resolve(cwd); const normalizedOptions = this.normalizeCheckoutDiffOptions(options); const key = this.buildCheckoutDiffCacheKey(normalizedCwd, normalizedOptions); return this.readAuxiliaryCache(this.checkoutDiffCache, key, readOptions, () => this.deps.getCheckoutDiff(normalizedCwd, normalizedOptions, { paseoHome: this.paseoHome, worktreesRoot: this.worktreesRoot, }), ); } private normalizeCheckoutDiffOptions(options: CheckoutDiffCompare): CheckoutDiffCompare { return { mode: options.mode, ...(options.mode === "base" && options.baseRef !== undefined ? { baseRef: options.baseRef } : {}), ...(options.ignoreWhitespace === true ? { ignoreWhitespace: true } : {}), ...(options.includeStructured === true ? { includeStructured: true } : {}), }; } private buildCheckoutDiffCacheKey(cwd: string, options: CheckoutDiffCompare): string { // Diff content varies by compare signature. Keep the cache per exact diff read shape so // hot diff panes coalesce while base refs and rendering options never share stale patches. return JSON.stringify([ "checkout-diff", cwd, options.mode, options.mode === "base" ? (options.baseRef ?? null) : null, options.ignoreWhitespace === true, options.includeStructured === true, ]); } private invalidateCheckoutDiffCache(cwd: string, mode: CheckoutDiffCompare["mode"]): void { for (const key of this.checkoutDiffCache.keys()) { const [kind, cachedCwd, cachedMode] = JSON.parse(key) as unknown[]; if (kind === "checkout-diff" && cachedCwd === cwd && cachedMode === mode) { this.checkoutDiffCache.delete(key); } } } validateBranchRef( cwd: string, ref: string, options?: WorkspaceGitReadOptions, ): Promise { const normalizedCwd = resolve(cwd); const normalizedRef = ref.trim(); const key = JSON.stringify(["branch-validation", normalizedCwd, normalizedRef]); return this.readAuxiliaryCache(this.branchValidationCache, key, options, () => this.deps.resolveBranchCheckout(normalizedCwd, normalizedRef), ); } hasLocalBranch(cwd: string, branch: string, options?: WorkspaceGitReadOptions): Promise { const normalizedCwd = resolve(cwd); const normalizedBranch = branch.trim(); const ref = `refs/heads/${normalizedBranch}`; const key = JSON.stringify(["local-branch", normalizedCwd, ref]); return this.readAuxiliaryCache(this.localBranchCache, key, options, async () => { const result = await this.deps.runGitCommand(["rev-parse", "--verify", "--quiet", ref], { cwd: normalizedCwd, envOverlay: READ_ONLY_GIT_ENV, acceptExitCodes: [0, 1], }); return result.exitCode === 0; }); } suggestBranchesForCwd( cwd: string, options?: WorkspaceGitBranchSuggestionsOptions, readOptions?: WorkspaceGitReadOptions, ): Promise { const normalizedCwd = resolve(cwd); const query = options?.query ?? ""; const limit = options?.limit; const key = JSON.stringify(["branch-suggestions", normalizedCwd, query, limit ?? null]); return this.readAuxiliaryCache(this.branchSuggestionsCache, key, readOptions, () => this.deps.listBranchSuggestions(normalizedCwd, options), ); } listStashes( cwd: string, options?: WorkspaceGitStashListOptions, readOptions?: WorkspaceGitReadOptions, ): Promise { const normalizedCwd = resolve(cwd); const paseoOnly = options?.paseoOnly !== false; const key = JSON.stringify(["stashes", normalizedCwd, paseoOnly]); return this.readAuxiliaryCache(this.stashListCache, key, readOptions, async () => { const { stdout } = await this.deps.runGitCommand(["stash", "list", "--format=%gd%x00%s"], { cwd: normalizedCwd, envOverlay: READ_ONLY_GIT_ENV, }); return parseWorkspaceGitStashList(stdout, { paseoOnly }); }); } async listWorktrees( cwdOrRepoRoot: string, options?: WorkspaceGitReadOptions, ): Promise { const repoRoot = await this.resolveRepoRoot(cwdOrRepoRoot, options); const key = JSON.stringify(["worktrees", repoRoot]); return this.readAuxiliaryCache(this.worktreeListCache, key, options, () => this.deps.listPaseoWorktrees({ cwd: repoRoot, paseoHome: this.paseoHome, worktreesRoot: this.worktreesRoot, }), ); } async resolveRepoRoot(cwd: string, options?: WorkspaceGitReadOptions): Promise { const snapshot = await this.getSnapshot(cwd, options); if (!snapshot.git.isGit) { throw new Error("Create worktree requires a git repository"); } return snapshot.git.isPaseoOwnedWorktree ? (snapshot.git.mainRepoRoot ?? snapshot.git.repoRoot ?? resolve(cwd)) : (snapshot.git.repoRoot ?? resolve(cwd)); } async resolveDefaultBranch( cwdOrRepoRoot: string, options?: WorkspaceGitReadOptions, ): Promise { const cwd = resolve(cwdOrRepoRoot); const key = JSON.stringify(["default-branch", cwd]); return this.readAuxiliaryCache(this.defaultBranchCache, key, options, async () => { const defaultBranch = await this.deps.resolveRepositoryDefaultBranch(cwd); if (!defaultBranch) { throw new Error("Unable to resolve repository default branch"); } return defaultBranch; }); } async getProjectSlug(cwd: string, options?: WorkspaceGitReadOptions): Promise { const snapshot = await this.getSnapshot(cwd, options); return deriveProjectSlug(resolve(cwd), snapshot.git.isGit ? snapshot.git.remoteUrl : null); } async resolveRepoRemoteUrl( cwd: string, options?: WorkspaceGitReadOptions, ): Promise { const snapshot = await this.getSnapshot(cwd, options); return snapshot.git.remoteUrl; } async refresh(cwd: string, _options?: { priority?: "normal" | "high" }): Promise { cwd = resolve(cwd); const target = this.ensureWorkspaceTarget(cwd); await this.refreshWorkspaceTarget(target, { force: false, refreshStructure: true, refreshWorktree: true, includeForge: false, reason: "refresh", notify: true, queueIfBusy: false, }); this.scheduleWorkspaceObservationSetup(target); } async requestWorkingTreeWatch( cwd: string, onChange: () => void, ): Promise<{ repoRoot: string | null; unsubscribe: () => void }> { cwd = resolve(cwd); const target = await this.ensureWorkingTreeWatchTarget(cwd); target.listeners.add(onChange); return { repoRoot: target.repoRoot, unsubscribe: () => { this.removeWorkingTreeWatchListener(target.cwd, onChange); }, }; } scheduleRefreshForCwd(cwd: string): void { cwd = resolve(cwd); const target = this.workspaceTargets.get(cwd); if (target) { this.scheduleWorkspaceRefresh(target, { queueIfBusy: false }); } } onWorkspaceStateMayHaveChanged(cwd: string): void { const normalizedCwd = resolve(cwd); const target = this.workspaceTargets.get(normalizedCwd); if (!target || target.closed) { return; } this.invalidateForge(normalizedCwd); this.scheduleWorkspaceRefresh(target, { force: true, includeForge: true, reason: "external-state-change", }); } /** * Drop the resolved forge adapter's cached state for a cwd. Goes through the * resolver so it targets the same adapter instance the poller reads — used by * git mutations to force a fresh forge status on the next refresh. */ invalidateForge(cwd: string): void { this.forgeResolver.invalidate(resolve(cwd)); } dispose(): void { for (const target of this.workspaceTargets.values()) { this.closeWorkspaceTarget(target); } this.workspaceTargets.clear(); for (const target of this.repoTargets.values()) { this.closeRepoTarget(target); } this.repoTargets.clear(); for (const target of this.workingTreeWatchTargets.values()) { this.closeWorkingTreeWatchTarget(target); } this.workingTreeWatchTargets.clear(); this.workingTreeWatchSetups.clear(); this.workingTreeWatchResolutions.clear(); this.workingTreeWatchAliases.clear(); this.snapshotUpdatedListeners.clear(); } private ensureWorkspaceTarget(cwd: string): WorkspaceGitTarget { const existingTarget = this.workspaceTargets.get(cwd); if (existingTarget) { return existingTarget; } return this.createWorkspaceTarget(cwd); } private readAuxiliaryCache( cache: LRUCache>, key: string, options: WorkspaceGitReadOptions | undefined, load: () => Promise, ): Promise { if (options?.force && !options.reason) { throw new Error("WorkspaceGitService forced read requires a reason"); } const entry = this.ensureAuxiliaryCacheEntry(cache, key); const nowMs = this.deps.now().getTime(); if (!options?.force && entry.value !== null && entry.loadedAtMs !== null) { const ageMs = nowMs - entry.loadedAtMs; if (ageMs <= WORKSPACE_GIT_AUXILIARY_READ_TTL_MS) { return Promise.resolve(entry.value); } if ( entry.lastShellOutAtMs !== null && nowMs - entry.lastShellOutAtMs < WORKSPACE_GIT_INTERNAL_MIN_GAP_MS ) { return Promise.resolve(entry.value); } } if (entry.inFlight) { return entry.inFlight; } entry.lastShellOutAtMs = nowMs; entry.inFlight = load() .then((value) => { entry.value = value; entry.loadedAtMs = this.deps.now().getTime(); return value; }) .finally(() => { entry.inFlight = null; }); return entry.inFlight; } private ensureAuxiliaryCacheEntry( cache: LRUCache>, key: string, ): WorkspaceGitAuxiliaryReadCacheEntry { const existing = cache.get(key); if (existing) { return existing; } const entry: WorkspaceGitAuxiliaryReadCacheEntry = { value: null, loadedAtMs: null, lastShellOutAtMs: null, inFlight: null, }; cache.set(key, entry); return entry; } private ensureWorkingTreeWatchTarget(cwd: string): Promise { const targetCwd = this.workingTreeWatchAliases.get(cwd); if (targetCwd) { const existingTarget = this.workingTreeWatchTargets.get(targetCwd); if (existingTarget) { return Promise.resolve(existingTarget); } this.workingTreeWatchAliases.delete(cwd); } const existingResolution = this.workingTreeWatchResolutions.get(cwd); if (existingResolution) { return existingResolution; } const resolution = this.resolveWorkingTreeWatchTarget(cwd).finally(() => { if (this.workingTreeWatchResolutions.get(cwd) === resolution) { this.workingTreeWatchResolutions.delete(cwd); } }); this.workingTreeWatchResolutions.set(cwd, resolution); return resolution; } private async resolveWorkingTreeWatchTarget(cwd: string): Promise { const repoRoot = await this.resolveCheckoutWatchRoot(cwd); const targetCwd = repoRoot ?? cwd; const existingTarget = this.workingTreeWatchTargets.get(targetCwd); if (existingTarget) { this.rememberWorkingTreeWatchAlias(existingTarget, cwd); return existingTarget; } const existingSetup = this.workingTreeWatchSetups.get(targetCwd); if (existingSetup) { const target = await existingSetup; this.rememberWorkingTreeWatchAlias(target, cwd); return target; } const watchPath = repoRoot && createRealpathAwarePathMatcher(repoRoot)(cwd) ? cwd : targetCwd; const setup = this.createWorkingTreeWatchTarget(targetCwd, watchPath, repoRoot).finally(() => { if (this.workingTreeWatchSetups.get(targetCwd) === setup) { this.workingTreeWatchSetups.delete(targetCwd); } }); this.workingTreeWatchSetups.set(targetCwd, setup); const target = await setup; this.rememberWorkingTreeWatchAlias(target, cwd); return target; } private rememberWorkingTreeWatchAlias(target: WorkingTreeWatchTarget, cwd: string): void { target.aliases.add(cwd); this.workingTreeWatchAliases.set(cwd, target.cwd); } private createWorkspaceTarget(cwd: string): WorkspaceGitTarget { const target: WorkspaceGitTarget = { cwd, listeners: new Set(), workingTreeWatchTarget: null, debounceTimer: null, pendingDebounceRequest: null, selfHealTimer: null, forgePrStatusPollSubscription: null, forgePrStatusPollKey: null, refreshState: { status: "idle" }, latestGit: null, latestGitLoadedAtMs: null, latestStructuralRefreshAtMs: null, latestWorktreeRefreshAtMs: null, auditWindowStartedAtMs: null, latestForge: null, latestForgeLoadedAtMs: null, latestSnapshot: null, latestSnapshotLoadedAtMs: null, latestFacts: null, factsPromise: null, latestFingerprint: null, lastShellOutAtMs: null, repoGitRoot: null, observationSetupPromise: null, observationSetupComplete: false, closed: false, }; this.workspaceTargets.set(cwd, target); return target; } private scheduleInitialWorkspaceRefresh(target: WorkspaceGitTarget): void { queueMicrotask(() => { if (!this.isActiveObservedWorkspaceTarget(target) || target.latestSnapshot) { return; } void this.refreshWorkspaceTarget(target, { force: false, refreshStructure: true, refreshWorktree: true, includeForge: true, reason: "initial", notify: true, queueIfBusy: false, }); }); } private scheduleWorkspaceObservationSetup(target: WorkspaceGitTarget): void { if ( target.observationSetupComplete || target.observationSetupPromise || !this.isActiveObservedWorkspaceTarget(target) ) { return; } target.observationSetupPromise = Promise.resolve() .then(() => this.setupWorkspaceObservation(target)) .catch((error) => { this.logger.warn( { err: error, cwd: target.cwd }, "Failed to set up workspace git observation", ); }) .finally(() => { target.observationSetupPromise = null; }); } private async setupWorkspaceObservation(target: WorkspaceGitTarget): Promise { const facts = await this.getFactsForObservation(target); if (!this.isActiveObservedWorkspaceTarget(target)) { return; } const watchCwd = facts.isGit ? facts.worktreeRoot : target.cwd; const workingTreeTargetPromise = this.ensureWorkingTreeWatchTarget(watchCwd); const workingTreeTarget = await workingTreeTargetPromise; if (!this.isActiveObservedWorkspaceTarget(target)) { queueMicrotask(() => this.closeWorkingTreeWatchTargetIfUnused(workingTreeTarget)); return; } if (target.workingTreeWatchTarget !== workingTreeTarget) { if (target.workingTreeWatchTarget) { this.removeWorkspaceWorkingTreeLink(target.workingTreeWatchTarget, target.cwd); } target.workingTreeWatchTarget = workingTreeTarget; } workingTreeTarget.workspaceKeys.add(target.cwd); if (!facts.isGit || !facts.absoluteGitDir) { target.observationSetupComplete = true; return; } await this.promoteWorkingTreeWatchTarget(workingTreeTarget, facts.worktreeRoot); const gitDir = facts.absoluteGitDir; const repoGitRoot = facts.gitCommonDir ?? (await this.resolveWorkspaceGitRefsRoot(gitDir)); if (!this.isActiveObservedWorkspaceTarget(target)) { return; } target.repoGitRoot = repoGitRoot; await this.ensureRepoTarget(target); if (this.isActiveObservedWorkspaceTarget(target)) { target.observationSetupComplete = true; } } private async getFactsForObservation(target: WorkspaceGitTarget): Promise { if (target.latestFacts) { return target.latestFacts; } return this.loadCheckoutFacts(target, { paseoHome: this.paseoHome, logger: this.logger, }); } private loadCheckoutFacts( target: WorkspaceGitTarget, context: CheckoutContext, ): Promise { if (target.factsPromise) { return target.factsPromise; } const promise = this.deps .getCheckoutSnapshotFacts(target.cwd, context) .then((facts) => { target.latestFacts = facts; return facts; }) .finally(() => { if (target.factsPromise === promise) { target.factsPromise = null; } }); target.factsPromise = promise; return promise; } private isActiveObservedWorkspaceTarget(target: WorkspaceGitTarget): boolean { return ( !target.closed && target.listeners.size > 0 && this.workspaceTargets.get(target.cwd) === target ); } private async createWorkingTreeWatchTarget( cwd: string, watchPath: string, repoRoot: string | null, ): Promise { const ignoredDirectories = repoRoot ? await this.loadIgnoredDirs(watchPath) : new Set(); const target: WorkingTreeWatchTarget = { cwd, watchPath, repoRoot, subscription: null, ignoredDirectories, ignoredDirectoriesRefreshPromise: null, aliases: new Set([cwd]), workspaceKeys: new Set(), fallbackPollTimer: null, listeners: new Set(), closed: false, }; await this.startWorkingTreeSubscription(target); if (repoRoot === null) { this.startWorkingTreeWatchFallback(target, "not_a_git_checkout"); } this.workingTreeWatchTargets.set(cwd, target); this.workingTreeWatchAliases.set(cwd, cwd); return target; } private async startWorkingTreeSubscription(target: WorkingTreeWatchTarget): Promise { const ignore = [join(target.watchPath, ".git"), ...target.ignoredDirectories]; try { const subscription = await this.deps.subscribe( target.watchPath, (error, events) => { if (error) { this.logger.warn( { err: error, cwd: target.cwd }, "Working tree watcher error; using degraded polling", ); this.degradeWorkingTreeWatch(target, "watcher_error"); return; } if (!this.hasRelevantWorkingTreeEvent(target, events)) { return; } this.notifyWorkingTreeChanged(target, "working-tree-watch"); }, { ignore }, ); if (target.closed || target.fallbackPollTimer || target.subscription) { await subscription.unsubscribe(); } else { target.subscription = subscription; } } catch (error) { this.logger.warn( { err: error, cwd: target.cwd }, "Failed to start working tree watcher; using degraded polling", ); this.startWorkingTreeWatchFallback(target, "watcher_setup_failed"); } } private startWorkingTreeWatchFallback( target: WorkingTreeWatchTarget, reason: "not_a_git_checkout" | "watcher_error" | "watcher_setup_failed", ): void { if (target.fallbackPollTimer) { return; } const { cwd } = target; const poll = async () => { target.fallbackPollTimer = null; if (target.closed || this.workingTreeWatchTargets.get(target.cwd) !== target) { return; } await Promise.all( Array.from(target.workspaceKeys, async (workspaceKey) => { const workspaceTarget = this.workspaceTargets.get(workspaceKey); if (!workspaceTarget) { return; } await this.refreshWorkspaceTarget(workspaceTarget, { force: false, refreshStructure: target.repoRoot === null, refreshWorktree: true, includeForge: false, reason: "working-tree-watch-fallback", notify: true, queueIfBusy: true, }); if (target.repoRoot === null && workspaceTarget.latestGit?.isGit === true) { workspaceTarget.observationSetupComplete = false; this.scheduleWorkspaceObservationSetup(workspaceTarget); } }), ); this.notifyWorkingTreeConsumers(target); if (!target.closed && (target.subscription === null || target.repoRoot === null)) { target.fallbackPollTimer = setTimeout(poll, DEGRADED_GIT_POLL_INTERVAL_MS); } }; target.fallbackPollTimer = setTimeout(poll, DEGRADED_GIT_POLL_INTERVAL_MS); this.logger.warn( { cwd, intervalMs: DEGRADED_GIT_POLL_INTERVAL_MS, reason, }, "Working tree watcher unavailable; using bounded polling fallback", ); } private degradeWorkingTreeWatch(target: WorkingTreeWatchTarget, reason: "watcher_error"): void { if (target.subscription) { const subscription = target.subscription; target.subscription = null; void subscription.unsubscribe().catch((error) => { this.logger.warn({ err: error, cwd: target.cwd }, "Failed to stop working tree watcher"); }); } this.startWorkingTreeWatchFallback(target, reason); } private async promoteWorkingTreeWatchTarget( target: WorkingTreeWatchTarget, repoRoot: string, ): Promise { if (target.repoRoot !== null) { return; } target.repoRoot = repoRoot; if (target.subscription && target.fallbackPollTimer) { clearTimeout(target.fallbackPollTimer); target.fallbackPollTimer = null; } await this.refreshWorkingTreeIgnoredDirectories(target); } private hasRelevantWorkingTreeEvent( target: WorkingTreeWatchTarget, events: parcelWatcher.Event[], ): boolean { const gitDir = join(target.watchPath, ".git"); const matchesWatchPath = createRealpathAwarePathMatcher(target.watchPath); return events.some((event) => { // Directory metadata changes at the watch root do not change Git state. // FSEvents may emit these when every changed descendant was ignored. if (matchesWatchPath(event.path)) { return false; } if (isRealpathInsideRoot(gitDir, event.path)) { return false; } for (const ignoredDirectory of target.ignoredDirectories) { if (isRealpathInsideRoot(ignoredDirectory, event.path)) { return false; } } return true; }); } private refreshWorkingTreeIgnoredDirectories(target: WorkingTreeWatchTarget): Promise { if (target.closed || target.repoRoot === null || target.fallbackPollTimer) { return Promise.resolve(); } if (target.ignoredDirectoriesRefreshPromise) { return target.ignoredDirectoriesRefreshPromise; } const refreshPromise = this.replaceWorkingTreeIgnoredDirectories(target) .catch((error) => { this.logger.warn( { err: error, cwd: target.cwd }, "Failed to refresh working tree watcher ignore paths", ); }) .finally(() => { if (target.ignoredDirectoriesRefreshPromise === refreshPromise) { target.ignoredDirectoriesRefreshPromise = null; } }); target.ignoredDirectoriesRefreshPromise = refreshPromise; return refreshPromise; } private async replaceWorkingTreeIgnoredDirectories( target: WorkingTreeWatchTarget, ): Promise { const ignoredDirectories = await this.loadIgnoredDirs(target.watchPath); if ( target.closed || target.fallbackPollTimer || this.haveSamePaths(target.ignoredDirectories, ignoredDirectories) ) { return; } const subscription = target.subscription; if (subscription) { target.subscription = null; try { await subscription.unsubscribe(); } catch (error) { if (!target.closed && !target.fallbackPollTimer) { target.subscription = subscription; } this.logger.warn( { err: error, cwd: target.cwd }, "Failed to stop working tree watcher while refreshing ignore paths", ); return; } } if (target.closed || target.fallbackPollTimer) { return; } target.ignoredDirectories = ignoredDirectories; await this.startWorkingTreeSubscription(target); this.notifyWorkingTreeChanged(target, "working-tree-watch-reconfigured"); } private haveSamePaths(first: Set, second: Set): boolean { if (first.size !== second.size) { return false; } for (const path of first) { if (!second.has(path)) { return false; } } return true; } private notifyWorkingTreeChanged(target: WorkingTreeWatchTarget, reason: string): void { if (target.closed) { return; } this.notifyWorkingTreeConsumers(target); this.scheduleWorkingTreeRefreshes(target, reason); } private notifyWorkingTreeConsumers(target: WorkingTreeWatchTarget): void { for (const alias of target.aliases) { this.invalidateCheckoutDiffCache(alias, "uncommitted"); } for (const listener of target.listeners) { listener(); } } private scheduleWorkingTreeRefreshes(target: WorkingTreeWatchTarget, reason: string): void { for (const workspaceKey of target.workspaceKeys) { const workspaceTarget = this.workspaceTargets.get(workspaceKey); if (workspaceTarget) { this.scheduleWorkspaceRefresh(workspaceTarget, { scope: "worktree", reason }); } } } private getWorkingTreeWatchTargetForWorkspace( workspaceTarget: WorkspaceGitTarget, ): WorkingTreeWatchTarget | null { const target = workspaceTarget.workingTreeWatchTarget; return target && !target.closed ? target : null; } private async resolveCheckoutWatchRoot(cwd: string): Promise { try { const { stdout } = await this.deps.runGitCommand(["rev-parse", "--show-toplevel"], { cwd, envOverlay: READ_ONLY_GIT_ENV, }); return parseGitRevParsePath(stdout); } catch { return null; } } private async resolveWorkspaceGitRefsRoot(gitDir: string): Promise { try { const commonDir = (await readFile(join(gitDir, "commondir"), "utf8")).trim(); if (commonDir.length > 0) { return resolve(gitDir, commonDir); } } catch { return gitDir; } return gitDir; } private async ensureRepoTarget(workspaceTarget: WorkspaceGitTarget): Promise { const repoGitRoot = workspaceTarget.repoGitRoot; if (!repoGitRoot || !this.isActiveObservedWorkspaceTarget(workspaceTarget)) { return; } const existingTarget = this.repoTargets.get(repoGitRoot); if (existingTarget) { existingTarget.workspaceKeys.add(workspaceTarget.cwd); return; } const repoTarget: RepoGitTarget = { repoGitRoot, cwd: workspaceTarget.cwd, workspaceKeys: new Set([workspaceTarget.cwd]), subscription: null, fallbackPollTimer: null, intervalId: null, fetchInFlight: false, closed: false, }; this.repoTargets.set(repoGitRoot, repoTarget); await this.startRepoMetadataObservation(repoTarget); if (repoTarget.closed || this.repoTargets.get(repoGitRoot) !== repoTarget) { return; } const fetchWorkspaceTarget = Array.from(repoTarget.workspaceKeys) .map((workspaceKey) => this.workspaceTargets.get(workspaceKey)) .find( (candidate): candidate is WorkspaceGitTarget => candidate !== undefined && this.isActiveObservedWorkspaceTarget(candidate), ); if (!fetchWorkspaceTarget) { return; } repoTarget.cwd = fetchWorkspaceTarget.cwd; const facts = fetchWorkspaceTarget.latestFacts; const hasOrigin = facts?.isGit === true ? facts.remoteUrl !== null : await this.deps.hasOriginRemote(fetchWorkspaceTarget.cwd); if (repoTarget.closed || this.repoTargets.get(repoGitRoot) !== repoTarget) { return; } if (!hasOrigin) { return; } repoTarget.intervalId = setInterval(() => { void this.runRepoFetch(repoTarget); }, BACKGROUND_GIT_FETCH_INTERVAL_MS); void this.runRepoFetch(repoTarget); } private async startRepoMetadataObservation(target: RepoGitTarget): Promise { const ignore = [ join(target.repoGitRoot, "hooks"), join(target.repoGitRoot, "logs"), join(target.repoGitRoot, "objects"), ]; const matchesRepoGitRoot = createRealpathAwarePathMatcher(target.repoGitRoot); try { const subscription = await this.deps.subscribe( target.repoGitRoot, (error, events) => { if (error) { this.logger.warn( { err: error, repoGitRoot: target.repoGitRoot }, "Repository metadata watcher error; using degraded polling", ); this.degradeRepoMetadataWatch(target); return; } const hasRelevantEvent = events.some( (event) => !matchesRepoGitRoot(event.path) && ignore.every((ignoredPath) => !isRealpathInsideRoot(ignoredPath, event.path)), ); if (hasRelevantEvent) { this.scheduleRepoMetadataRefresh(target, "git-metadata-watch", true); } }, { ignore }, ); if ( target.closed || target.fallbackPollTimer || this.repoTargets.get(target.repoGitRoot) !== target ) { await subscription.unsubscribe(); } else { target.subscription = subscription; } } catch (error) { this.logger.warn( { err: error, repoGitRoot: target.repoGitRoot }, "Failed to start repository metadata watcher; using degraded polling", ); this.startRepoMetadataFallback(target); } } private degradeRepoMetadataWatch(target: RepoGitTarget): void { if (target.subscription) { const subscription = target.subscription; target.subscription = null; void subscription.unsubscribe().catch((error) => { this.logger.warn( { err: error, repoGitRoot: target.repoGitRoot }, "Failed to stop repository metadata watcher", ); }); } this.startRepoMetadataFallback(target); } private scheduleRepoMetadataRefresh( target: RepoGitTarget, reason: string, refreshWorktree: boolean, ): void { if (target.closed || this.repoTargets.get(target.repoGitRoot) !== target) { return; } const workingTreeTargets = new Set(); for (const workspaceKey of target.workspaceKeys) { const workspaceTarget = this.workspaceTargets.get(workspaceKey); if (workspaceTarget) { this.invalidateCheckoutDiffCache(workspaceTarget.cwd, "base"); if (workspaceTarget.latestFacts?.isGit) { this.invalidateCheckoutDiffCache(workspaceTarget.latestFacts.worktreeRoot, "base"); } if (refreshWorktree) { const workingTreeTarget = this.getWorkingTreeWatchTargetForWorkspace(workspaceTarget); if (workingTreeTarget) { workingTreeTargets.add(workingTreeTarget); } } this.scheduleWorkspaceRefresh(workspaceTarget, { scope: refreshWorktree ? undefined : "structure", emitUnchanged: true, reason, }); } } for (const workingTreeTarget of workingTreeTargets) { this.notifyWorkingTreeConsumers(workingTreeTarget); } } private startRepoMetadataFallback(target: RepoGitTarget): void { if (target.fallbackPollTimer || target.closed) { return; } const poll = async () => { target.fallbackPollTimer = null; if (target.closed || this.repoTargets.get(target.repoGitRoot) !== target) { return; } const workingTreeTargets = new Set(); await Promise.all( Array.from(target.workspaceKeys, async (workspaceKey) => { const workspaceTarget = this.workspaceTargets.get(workspaceKey); if (!workspaceTarget) { return; } this.invalidateCheckoutDiffCache(workspaceTarget.cwd, "base"); if (workspaceTarget.latestFacts?.isGit) { this.invalidateCheckoutDiffCache(workspaceTarget.latestFacts.worktreeRoot, "base"); } const workingTreeTarget = this.getWorkingTreeWatchTargetForWorkspace(workspaceTarget); if (workingTreeTarget) { workingTreeTargets.add(workingTreeTarget); } await this.refreshWorkspaceTarget(workspaceTarget, { force: false, refreshStructure: true, refreshWorktree: true, includeForge: false, emitUnchanged: true, reason: "git-metadata-watch-fallback", notify: true, queueIfBusy: true, }); }), ); for (const workingTreeTarget of workingTreeTargets) { this.notifyWorkingTreeConsumers(workingTreeTarget); } if (!target.closed) { target.fallbackPollTimer = setTimeout(poll, DEGRADED_GIT_POLL_INTERVAL_MS); } }; target.fallbackPollTimer = setTimeout(poll, DEGRADED_GIT_POLL_INTERVAL_MS); } private scheduleWorkspaceRefresh( targetOrCwd: WorkspaceGitTarget | string, options?: ScheduledWorkspaceGitRefreshOptions, ): void { const target = typeof targetOrCwd === "string" ? this.workspaceTargets.get(resolve(targetOrCwd)) : targetOrCwd; if (!target || target.closed || this.workspaceTargets.get(target.cwd) !== target) { return; } const request = this.buildScheduledRefreshRequest(options); target.pendingDebounceRequest = this.mergeRefreshRequests( target.pendingDebounceRequest, request, ); if (target.debounceTimer) { clearTimeout(target.debounceTimer); } target.debounceTimer = setTimeout(() => { if (target.closed || this.workspaceTargets.get(target.cwd) !== target) { return; } target.debounceTimer = null; const merged = target.pendingDebounceRequest; target.pendingDebounceRequest = null; if (merged) { void this.refreshWorkspaceTarget(target, merged); } }, WORKSPACE_GIT_WATCH_DEBOUNCE_MS); } private startWorkspaceSubscriptionTimers(target: WorkspaceGitTarget): void { if (!target.selfHealTimer) { target.auditWindowStartedAtMs = this.deps.now().getTime(); const runSelfHealTick = () => { if (!this.isActiveObservedWorkspaceTarget(target)) { target.selfHealTimer = null; return; } target.selfHealTimer = setTimeout(runSelfHealTick, WORKSPACE_GIT_SELF_HEAL_INTERVAL_MS); this.scheduleWorkspaceObservationSetup(target); const auditWindowStartedAtMs = target.auditWindowStartedAtMs; target.auditWindowStartedAtMs = this.deps.now().getTime(); if (auditWindowStartedAtMs === null) { return; } const workingTreeTarget = this.getWorkingTreeWatchTargetForWorkspace(target); if (workingTreeTarget) { void this.refreshWorkingTreeIgnoredDirectories(workingTreeTarget); } const refreshStructure = target.latestStructuralRefreshAtMs === null || target.latestStructuralRefreshAtMs < auditWindowStartedAtMs; const refreshWorktree = refreshStructure || target.latestWorktreeRefreshAtMs === null || target.latestWorktreeRefreshAtMs < auditWindowStartedAtMs; if (!refreshStructure && !refreshWorktree) { return; } if (refreshWorktree) { if (workingTreeTarget) { this.notifyWorkingTreeConsumers(workingTreeTarget); } } this.refreshWorkspaceTarget(target, { force: false, refreshStructure, refreshWorktree, includeForge: false, reason: "self-heal-git", notify: true, queueIfBusy: true, }).catch((error) => { this.logger.warn( { err: error, cwd: target.cwd, reason: "self-heal-git" }, "Failed to run workspace git self-heal refresh", ); }); }; target.selfHealTimer = setTimeout( runSelfHealTick, this.deps.getWorkspaceGitSelfHealPhaseMs(target.cwd), ); } this.updateForgePrStatusPollForTarget(target); } private updateForgePrStatusPollForTarget(target: WorkspaceGitTarget): void { if (target.listeners.size === 0) { this.stopForgePrStatusPollForTarget(target); return; } const git = target.latestGit; if (!git?.remoteUrl) { this.stopForgePrStatusPollForTarget(target); return; } const resolution = this.forgeResolver.resolveFromRemoteUrl(git.remoteUrl); if (!resolution) { this.stopForgePrStatusPollForTarget(target); return; } const pollTarget = this.resolveForgePrStatusPollTarget(target); const remoteUrl = git.remoteUrl; if (!pollTarget) { this.stopForgePrStatusPollForTarget(target); return; } const pollKey = buildWorkspaceForgePrStatusPollKey({ forge: resolution.forge, remoteUrl, target: pollTarget, }); const previousPollKey = target.forgePrStatusPollKey; if (target.forgePrStatusPollKey === pollKey && target.forgePrStatusPollSubscription) { return; } const pollImmediately = previousPollKey !== null && previousPollKey !== pollKey; this.stopForgePrStatusPollForTarget(target); target.forgePrStatusPollKey = pollKey; if (resolution.service.retainCurrentPullRequestStatusPoll) { target.forgePrStatusPollSubscription = resolution.service.retainCurrentPullRequestStatusPoll({ cwd: target.cwd, headRef: pollTarget.headRef, ...(pollTarget.headSha ? { headSha: pollTarget.headSha } : {}), ...(pollTarget.headRepositoryOwner ? { headRepositoryOwner: pollTarget.headRepositoryOwner } : {}), onStatus: (status) => { if (!this.isActiveObservedWorkspaceTarget(target)) { return; } this.rememberForgePrStatusSnapshot( target, buildForgeSnapshotFromStatus(status, resolution.forge), { notify: true, }, ); }, onError: (error) => { this.logger.warn( { err: error, cwd: target.cwd, forge: resolution.forge, headRef: pollTarget.headRef, headRepositoryOwner: pollTarget.headRepositoryOwner, reason: "self-heal-forge-pr-status", }, "Failed to run forge PR status self-heal refresh", ); }, }); return; } target.forgePrStatusPollSubscription = this.retainGenericForgePrStatusPoll({ target, forge: resolution.forge, service: resolution.service, pollTarget, pollImmediately, }); } private retainGenericForgePrStatusPoll({ target, forge, service, pollTarget, pollImmediately, }: { target: WorkspaceGitTarget; forge: string; service: ForgeService; pollTarget: WorkspaceForgePrStatusPollTarget; pollImmediately: boolean; }): { unsubscribe: () => void } { let closed = false; let timer: NodeJS.Timeout | null = null; let latestStatus: WorkspaceGitRuntimeSnapshot["forge"]["pullRequest"] = target.latestForge?.pullRequest ?? null; let consecutiveErrors = 0; const schedule = (delayMs: number) => { if (closed) { return; } timer = setTimeout(() => { timer = null; void poll(); }, delayMs); }; const poll = async () => { if (closed || !this.isActiveObservedWorkspaceTarget(target)) { return; } try { const status = await service.getCurrentPullRequestStatus({ cwd: target.cwd, headRef: pollTarget.headRef, ...(pollTarget.headSha ? { headSha: pollTarget.headSha } : {}), ...(pollTarget.headRepositoryOwner ? { headRepositoryOwner: pollTarget.headRepositoryOwner } : {}), reason: "self-heal-forge-pr-status", }); if (!closed && this.isActiveObservedWorkspaceTarget(target)) { latestStatus = status; consecutiveErrors = 0; this.rememberForgePrStatusSnapshot(target, buildForgeSnapshotFromStatus(status, forge), { notify: true, }); } } catch (error) { consecutiveErrors += 1; this.logger.warn( { err: error, cwd: target.cwd, forge, headRef: pollTarget.headRef, headRepositoryOwner: pollTarget.headRepositoryOwner, reason: "self-heal-forge-pr-status", }, "Failed to run forge PR status self-heal refresh", ); } finally { schedule(computeGenericForgeNextInterval(latestStatus, consecutiveErrors)); } }; // A git-only refresh clears forge state when the commit-aware poll identity // changes. Revalidate that new identity immediately instead of leaving the // PR panel empty for the full stable polling interval. schedule( pollImmediately ? 0 : computeGenericForgeNextInterval(latestStatus, consecutiveErrors), ); return { unsubscribe: () => { closed = true; if (timer) { clearTimeout(timer); timer = null; } }, }; } private resolveForgePrStatusPollTarget( target: WorkspaceGitTarget, ): WorkspaceForgePrStatusPollTarget | null { const git = target.latestGit; if (!git?.currentBranch) { return null; } const lookupTarget = target.latestFacts?.isGit && target.latestFacts.currentBranch === git.currentBranch ? target.latestFacts.pullRequestLookupTarget : null; if (lookupTarget) { return lookupTarget; } return { headRef: git.currentBranch }; } private stopForgePrStatusPollForTarget(target: WorkspaceGitTarget): void { target.forgePrStatusPollSubscription?.unsubscribe(); target.forgePrStatusPollSubscription = null; target.forgePrStatusPollKey = null; } private async loadIgnoredDirs(rootPath: string): Promise> { const ignored = new Set(); try { const result = await this.deps.runGitCommand( ["ls-files", "-o", "-i", "--directory", "--exclude-standard"], { cwd: rootPath, env: READ_ONLY_GIT_ENV }, ); for (const raw of result.stdout.split("\n")) { if (!raw.endsWith("/")) { continue; } const rel = raw.replace(/\/+$/, ""); if (!rel) { continue; } ignored.add(resolve(rootPath, rel)); } } catch (error) { this.logger.debug( { err: error, rootPath }, "Failed to load gitignore directories; falling back to name-based skip only", ); } return ignored; } private async refreshWorkspaceTarget( target: WorkspaceGitTarget, request: WorkspaceGitRefreshRequest, ): Promise { if (target.closed || this.workspaceTargets.get(target.cwd) !== target) { return; } try { await this.requestWorkspaceSnapshot(target, request); } catch (error) { this.logger.warn( { err: error, cwd: target.cwd, reason: request.reason }, "Failed to refresh workspace git snapshot", ); } } private requestWorkspaceSnapshot( target: WorkspaceGitTarget, request: WorkspaceGitRefreshRequest, ): Promise { if (target.refreshState.status === "in-flight") { const active = target.refreshState.request; const addsWork = (request.force && !active.force) || (request.refreshStructure && !active.refreshStructure) || (request.refreshWorktree && !active.refreshWorktree) || (request.includeForge && !active.includeForge) || (request.emitUnchanged === true && active.emitUnchanged !== true); if (request.queueIfBusy || addsWork) { target.refreshState.queued = this.mergeRefreshRequests(target.refreshState.queued, request); } return target.refreshState.promise; } if (!request.force && !request.queueIfBusy && this.shouldThrottleNonForcedRefresh(target)) { return Promise.resolve(target.latestSnapshot); } const promise = this.runWorkspaceRefreshLoop(target, request).finally(() => { const state = target.refreshState; if (state.status === "in-flight" && state.promise === promise) { target.refreshState = { status: "idle" }; } }); target.refreshState = { status: "in-flight", promise, request, queued: null, }; return promise; } private normalizeRefreshRequest( options: WorkspaceGitSnapshotOptions | undefined, defaultReason: string, notify: boolean, ): WorkspaceGitRefreshRequest { if (options?.force && !options.reason) { throw new Error("WorkspaceGitService.getSnapshot force refresh requires a reason"); } const force = options?.force === true; return { force, refreshStructure: true, refreshWorktree: true, includeForge: options?.includeForge ?? true, reason: options?.reason ?? defaultReason, notify, queueIfBusy: false, }; } private shouldThrottleNonForcedRefresh( target: WorkspaceGitTarget, ): target is WorkspaceGitTarget & { latestSnapshot: WorkspaceGitRuntimeSnapshot; } { if (!target.latestSnapshot || target.lastShellOutAtMs === null) { return false; } return this.deps.now().getTime() - target.lastShellOutAtMs < WORKSPACE_GIT_INTERNAL_MIN_GAP_MS; } private buildScheduledRefreshRequest( options: ScheduledWorkspaceGitRefreshOptions | undefined, ): WorkspaceGitRefreshRequest { const scope = options?.scope; return { force: options?.force === true, refreshStructure: scope !== "worktree", refreshWorktree: scope !== "structure", includeForge: options?.includeForge ?? false, emitUnchanged: options?.emitUnchanged, reason: options?.reason ?? "watch", notify: true, queueIfBusy: options?.queueIfBusy ?? true, }; } private mergeRefreshRequests( pending: WorkspaceGitRefreshRequest | null, request: WorkspaceGitRefreshRequest, ): WorkspaceGitRefreshRequest { if (!pending) { return request; } const force = pending.force || request.force; const upgradesForce = request.force && !pending.force; const upgradesStructure = request.refreshStructure && !pending.refreshStructure; const upgradesWorktree = request.refreshWorktree && !pending.refreshWorktree; const upgradesForge = request.includeForge && !pending.includeForge; const upgradesEmit = request.emitUnchanged === true && pending.emitUnchanged !== true; return { force, refreshStructure: pending.refreshStructure || request.refreshStructure, refreshWorktree: pending.refreshWorktree || request.refreshWorktree, includeForge: pending.includeForge || request.includeForge, emitUnchanged: pending.emitUnchanged === true || request.emitUnchanged === true, reason: upgradesForce || upgradesStructure || upgradesWorktree || upgradesForge || upgradesEmit ? request.reason : pending.reason, notify: pending.notify || request.notify, queueIfBusy: pending.queueIfBusy || request.queueIfBusy, }; } private async runWorkspaceRefreshLoop( target: WorkspaceGitTarget, initialRequest: WorkspaceGitRefreshRequest, ): Promise { let request = initialRequest; let snapshot = target.latestSnapshot ?? buildNotGitSnapshot(target.cwd); let failure: { error: unknown } | null = null; while (true) { if (request.emitUnchanged === true && target.latestSnapshot) { this.rememberSnapshot(target, target.latestSnapshot, { notify: request.notify, forceEmit: true, }); } try { snapshot = await this.refreshSnapshot(target, request); this.rememberSnapshot(target, snapshot, { notify: request.notify, forceEmit: request.force, }); failure = null; } catch (error) { failure = { error }; } const state = target.refreshState; if (state.status !== "in-flight" || !state.queued) { break; } request = state.queued; state.queued = null; state.request = request; } if (failure) { throw failure.error; } return snapshot; } private async refreshSnapshot( target: WorkspaceGitTarget, request: WorkspaceGitRefreshRequest, ): Promise { let facts = target.latestFacts; if (request.refreshStructure || !facts || !target.latestGit) { facts = await this.refreshGitSnapshot(target, request); } else if (request.refreshWorktree) { await this.refreshWorktreeSnapshot(target, facts); } if (!facts) { facts = await this.refreshGitSnapshot(target, request); } if (request.includeForge) { await this.refreshForgeSnapshot(target, request, facts); } const snapshot = this.combineSnapshot(target); target.latestSnapshotLoadedAtMs = this.deps.now().getTime(); return snapshot; } private async refreshWorktreeSnapshot( target: WorkspaceGitTarget, facts: CheckoutSnapshotFacts, ): Promise { const latestGit = target.latestGit; if (!latestGit || !facts.isGit) { return; } target.lastShellOutAtMs = this.deps.now().getTime(); const context: CheckoutContext = { paseoHome: this.paseoHome, worktreesRoot: this.worktreesRoot, logger: this.logger, facts, }; const worktree = await this.deps.getCheckoutWorktreeState(target.cwd, context); target.latestGit = { ...latestGit, isDirty: worktree.isDirty, diffStat: worktree.diffStat, }; const loadedAtMs = this.deps.now().getTime(); target.latestGitLoadedAtMs = loadedAtMs; target.latestWorktreeRefreshAtMs = loadedAtMs; } private async refreshGitSnapshot( target: WorkspaceGitTarget, request: WorkspaceGitRefreshRequest, ): Promise { const now = this.deps.now(); target.lastShellOutAtMs = now.getTime(); const cwd = target.cwd; const previousForgePrStatusPollKey = this.getForgePrStatusPollKey(target); const baseContext: CheckoutContext = { paseoHome: this.paseoHome, worktreesRoot: this.worktreesRoot, logger: this.logger, }; const facts = await this.loadCheckoutFacts(target, baseContext); const context: CheckoutContext = { ...baseContext, facts }; const checkoutStatus = await this.deps.getCheckoutStatus(cwd, context); if (!checkoutStatus.isGit) { target.latestGit = buildNotGitSnapshot(cwd).git; const loadedAtMs = this.deps.now().getTime(); target.latestGitLoadedAtMs = loadedAtMs; target.latestStructuralRefreshAtMs = loadedAtMs; target.latestWorktreeRefreshAtMs = loadedAtMs; target.latestForge = buildForgeUnavailableSnapshot(); target.latestForgeLoadedAtMs = target.latestGitLoadedAtMs; return facts; } const refreshWorktree = request.refreshWorktree || target.latestGit === null; const diffStat = refreshWorktree ? await this.deps .getCheckoutShortstat(cwd, context, { force: request.force || target.latestGit !== null, }) .catch(() => null) : (target.latestGit?.diffStat ?? null); target.latestGit = { isGit: true, repoRoot: checkoutStatus.repoRoot, mainRepoRoot: checkoutStatus.mainRepoRoot, currentBranch: checkoutStatus.currentBranch, remoteUrl: checkoutStatus.remoteUrl, isPaseoOwnedWorktree: checkoutStatus.isPaseoOwnedWorktree, isDirty: refreshWorktree ? checkoutStatus.isDirty : (target.latestGit?.isDirty ?? checkoutStatus.isDirty), baseRef: checkoutStatus.baseRef, aheadBehind: checkoutStatus.aheadBehind, upstreamRef: checkoutStatus.upstreamRef, aheadOfOrigin: checkoutStatus.aheadOfOrigin, behindOfOrigin: checkoutStatus.behindOfOrigin, hasRemote: checkoutStatus.hasRemote, diffStat, }; const loadedAtMs = this.deps.now().getTime(); target.latestGitLoadedAtMs = loadedAtMs; target.latestStructuralRefreshAtMs = loadedAtMs; if (refreshWorktree) { target.latestWorktreeRefreshAtMs = loadedAtMs; } if (previousForgePrStatusPollKey !== this.getForgePrStatusPollKey(target)) { target.latestForge = buildForgeUnavailableSnapshot(); target.latestForgeLoadedAtMs = target.latestGitLoadedAtMs; } return facts; } private async refreshForgeSnapshot( target: WorkspaceGitTarget, request: WorkspaceGitRefreshRequest, facts: CheckoutSnapshotFacts, ): Promise { const remoteUrl = target.latestGit?.remoteUrl ?? null; const resolution = await this.forgeResolver.resolveFromRemoteUrlAsync(remoteUrl); // Every forge gates on the resolver alone: a cloud host matches synchronously // and a self-hosted/Enterprise host is recognized by the adapter probe (which // this async resolution populates), so GitHub Enterprise is no longer gated // out by a cloud-only identity check. if (!resolution) { target.latestForge = buildUnresolvedRemoteForgeSnapshot(remoteUrl); target.latestForgeLoadedAtMs = this.deps.now().getTime(); return; } const forgeService: ForgeService = resolution.service; const forceForge = request.force && request.includeForge; if (forceForge) { forgeService.invalidate({ cwd: target.cwd }); } const forgeSnapshot = await loadForgeSnapshot({ cwd: target.cwd, forgeService, now: this.deps.now(), deps: this.deps, force: forceForge, reason: request.reason, facts, }); // Carry the resolved forge (probe-aware) so the wire projection labels // self-managed GitLab hosts correctly instead of falling back to "github". target.latestForge = { ...forgeSnapshot, forge: resolution.forge }; target.latestForgeLoadedAtMs = this.deps.now().getTime(); } private combineSnapshot(target: WorkspaceGitTarget): WorkspaceGitRuntimeSnapshot { if (!target.latestGit) { return target.latestSnapshot ?? buildNotGitSnapshot(target.cwd); } return { cwd: target.cwd, git: target.latestGit, forge: target.latestForge ?? buildForgeUnavailableSnapshot(), }; } private getForgePrStatusPollKey(target: WorkspaceGitTarget): string | null { const git = target.latestGit; if (!git?.currentBranch || !git.remoteUrl) { return null; } const resolution = this.forgeResolver.resolveFromRemoteUrl(git.remoteUrl); if (!resolution) { return null; } const pollTarget = this.resolveForgePrStatusPollTarget(target); if (!pollTarget) { return null; } return buildWorkspaceForgePrStatusPollKey({ forge: resolution.forge, remoteUrl: git.remoteUrl, target: pollTarget, }); } private rememberForgePrStatusSnapshot( target: WorkspaceGitTarget, github: WorkspaceGitRuntimeSnapshot["forge"], options?: { notify?: boolean }, ): void { if (target.closed || this.workspaceTargets.get(target.cwd) !== target) { return; } target.latestForge = github; target.latestForgeLoadedAtMs = this.deps.now().getTime(); this.rememberSnapshot(target, this.combineSnapshot(target), { notify: options?.notify, forceEmit: false, }); } private rememberSnapshot( target: WorkspaceGitTarget, snapshot: WorkspaceGitRuntimeSnapshot, options?: { forceEmit?: boolean; notify?: boolean }, ): void { target.latestSnapshot = snapshot; if (target.listeners.size > 0) { this.updateForgePrStatusPollForTarget(target); } const fingerprint = JSON.stringify(snapshot); const fingerprintMatches = target.latestFingerprint === fingerprint; if (fingerprintMatches && !options?.forceEmit) { return; } target.latestFingerprint = fingerprint; if (!options?.notify || target.listeners.size === 0) { return; } for (const listener of target.listeners) { listener(snapshot); } for (const listener of this.snapshotUpdatedListeners) { try { listener(snapshot); } catch (error) { this.logger.warn( { err: error, cwd: snapshot.cwd }, "Workspace git snapshot listener threw", ); } } } private async runRepoFetch(target: RepoGitTarget): Promise { if (target.fetchInFlight) { return; } target.fetchInFlight = true; this.logger.debug( { repoGitRoot: target.repoGitRoot, cwd: target.cwd }, "Running background git fetch", ); let succeeded = false; try { await this.deps.runGitFetch(target.cwd); succeeded = true; } catch (error) { this.logger.warn( { err: error, repoGitRoot: target.repoGitRoot, cwd: target.cwd }, "Background git fetch failed", ); } finally { target.fetchInFlight = false; } if (!succeeded) { return; } this.scheduleRepoMetadataRefresh(target, "repo-fetch", false); } private removeWorkspaceListener(cwd: string, listener: WorkspaceGitListener): void { const target = this.workspaceTargets.get(cwd); if (!target) { return; } target.listeners.delete(listener); if (target.listeners.size > 0) { return; } this.removeWorkspaceTarget(target); } private removeWorkspaceTarget(target: WorkspaceGitTarget): void { if (target.repoGitRoot) { const repoTarget = this.repoTargets.get(target.repoGitRoot); repoTarget?.workspaceKeys.delete(target.cwd); if (repoTarget && repoTarget.workspaceKeys.size === 0) { this.closeRepoTarget(repoTarget); this.repoTargets.delete(target.repoGitRoot); } else if (repoTarget?.cwd === target.cwd) { repoTarget.cwd = repoTarget.workspaceKeys.values().next().value ?? repoTarget.cwd; } } this.closeWorkspaceTarget(target); this.workspaceTargets.delete(target.cwd); } private removeWorkingTreeWatchListener(cwd: string, listener: () => void): void { const target = this.workingTreeWatchTargets.get(cwd); if (!target) { return; } target.listeners.delete(listener); if (target.listeners.size > 0 || target.workspaceKeys.size > 0) { return; } this.closeWorkingTreeWatchTarget(target); this.workingTreeWatchTargets.delete(cwd); } private removeWorkspaceWorkingTreeLink( target: WorkingTreeWatchTarget, workspaceKey: string, ): void { target.workspaceKeys.delete(workspaceKey); if (target.workspaceKeys.size > 0 || target.listeners.size > 0) { return; } this.closeWorkingTreeWatchTarget(target); if (this.workingTreeWatchTargets.get(target.cwd) === target) { this.workingTreeWatchTargets.delete(target.cwd); } } private closeWorkingTreeWatchTargetIfUnused(target: WorkingTreeWatchTarget): void { if ( target.closed || target.workspaceKeys.size > 0 || target.listeners.size > 0 || this.workingTreeWatchTargets.get(target.cwd) !== target ) { return; } this.closeWorkingTreeWatchTarget(target); this.workingTreeWatchTargets.delete(target.cwd); } private closeWorkspaceTarget(target: WorkspaceGitTarget): void { target.closed = true; if (target.workingTreeWatchTarget) { this.removeWorkspaceWorkingTreeLink(target.workingTreeWatchTarget, target.cwd); target.workingTreeWatchTarget = null; } if (target.debounceTimer) { clearTimeout(target.debounceTimer); target.debounceTimer = null; } if (target.selfHealTimer) { clearTimeout(target.selfHealTimer); target.selfHealTimer = null; } this.stopForgePrStatusPollForTarget(target); target.listeners.clear(); } private closeWorkingTreeWatchTarget(target: WorkingTreeWatchTarget): void { target.closed = true; for (const alias of target.aliases) { if (this.workingTreeWatchAliases.get(alias) === target.cwd) { this.workingTreeWatchAliases.delete(alias); } } target.aliases.clear(); if (target.fallbackPollTimer) { clearTimeout(target.fallbackPollTimer); target.fallbackPollTimer = null; } if (target.subscription) { const subscription = target.subscription; target.subscription = null; void subscription.unsubscribe().catch((error) => { this.logger.warn({ err: error, cwd: target.cwd }, "Failed to stop working tree watcher"); }); } target.workspaceKeys.clear(); target.listeners.clear(); } private closeRepoTarget(target: RepoGitTarget): void { target.closed = true; if (target.intervalId) { clearInterval(target.intervalId); target.intervalId = null; } if (target.fallbackPollTimer) { clearTimeout(target.fallbackPollTimer); target.fallbackPollTimer = null; } if (target.subscription) { const subscription = target.subscription; target.subscription = null; void subscription.unsubscribe().catch((error) => { this.logger.warn( { err: error, repoGitRoot: target.repoGitRoot }, "Failed to stop repository metadata watcher", ); }); } target.workspaceKeys.clear(); } } async function loadForgeSnapshot(options: { cwd: string; forgeService: ForgeService | null; now: Date; deps: Pick; force?: boolean; reason?: string; facts?: CheckoutSnapshotFacts; }): Promise { const forgeService = options.forgeService; if (!forgeService) { return buildForgeSnapshot("no_remote", null, null); } // GitHub's isAuthenticated throws the precise CLI-missing / auth error; GitLab's // and Gitea's return false without throwing (the precise kind surfaces from // the PR-status lookup below instead), so probing them here can't change the // outcome and would just be a wasted CLI spawn on every refresh. if (forgeService.authProbeCanThrow) { try { await forgeService.isAuthenticated({ cwd: options.cwd }); } catch (error) { return buildForgeSnapshot(forgeAuthStateFromError(error), null, null); } } try { const result = await options.deps.getPullRequestStatus( options.cwd, forgeService, { force: options.force, reason: options.reason, }, { facts: options.facts }, ); return buildForgeSnapshot(result.authState, result.status, null); } catch (error) { // The auth probe succeeded, so a failure here is a command error, not an // auth problem — surface it as an error while keeping features enabled. return buildForgeSnapshot("authenticated", null, { message: error instanceof Error ? error.message : String(error), }); } } function buildForgeSnapshot( authState: ForgeAuthState, pullRequest: WorkspaceGitRuntimeSnapshot["forge"]["pullRequest"], error: WorkspaceGitRuntimeSnapshot["forge"]["error"], ): WorkspaceGitRuntimeSnapshot["forge"] { return { featuresEnabled: authState === "authenticated", authState, pullRequest, error, }; } function parseWorkspaceGitStashList( stdout: string, options: { paseoOnly: boolean }, ): WorkspaceGitStashEntry[] { const entries: WorkspaceGitStashEntry[] = []; const lines = stdout.trim().split("\n").filter(Boolean); for (const line of lines) { const sepIdx = line.indexOf("\0"); if (sepIdx < 0) { continue; } const refPart = line.slice(0, sepIdx); const subject = line.slice(sepIdx + 1); const indexMatch = refPart.match(/\{(\d+)\}/); if (!indexMatch) { continue; } const index = Number(indexMatch[1]); const prefix = "paseo-auto-stash:"; const prefixIdx = subject.indexOf(prefix); const isPaseo = prefixIdx >= 0; const branch = isPaseo ? subject.slice(prefixIdx + prefix.length).trim() || null : null; if (options.paseoOnly && !isPaseo) { continue; } entries.push({ index, message: subject, branch, isPaseo }); } return entries; } function buildNotGitSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot { return { cwd, git: { isGit: false, repoRoot: null, mainRepoRoot: null, currentBranch: null, remoteUrl: null, isPaseoOwnedWorktree: false, isDirty: null, baseRef: null, aheadBehind: null, upstreamRef: null, aheadOfOrigin: null, behindOfOrigin: null, hasRemote: false, diffStat: null, }, forge: buildForgeUnavailableSnapshot(), }; } function buildForgeUnavailableSnapshot(): WorkspaceGitRuntimeSnapshot["forge"] { return buildForgeSnapshot("no_remote", null, null); } /** * Snapshot for a remote whose host matched no registered forge and no * CLI-authenticated host. Deliberate choice: expose the hostname as the open * `forge` id with `authState: "unauthenticated"`, because a self-hosted * GitLab/Gitea becomes resolvable the moment its CLI is authenticated for * that host — so "authenticate" is the actionable next step. The trade-off: * a genuinely unsupported host (e.g. Bitbucket) also reads as a login * problem; clients that want to distinguish can check the id against the * forge registry. */ function buildUnresolvedRemoteForgeSnapshot( remoteUrl: string | null, ): WorkspaceGitRuntimeSnapshot["forge"] { const host = remoteUrl ? parseGitRemoteLocation(remoteUrl)?.host : null; if (!host) { return buildForgeUnavailableSnapshot(); } return { ...buildForgeSnapshot("unauthenticated", null, null), forge: host }; } function buildForgeSnapshotFromStatus( status: WorkspaceGitRuntimeSnapshot["forge"]["pullRequest"], forge: string, ): WorkspaceGitRuntimeSnapshot["forge"] { return { ...buildForgeSnapshot("authenticated", status, null), forge }; } function buildWorkspaceForgePrStatusPollKey({ forge, remoteUrl, target, }: { forge: string; remoteUrl: string; target: WorkspaceForgePrStatusPollTarget; }): string { return JSON.stringify([ forge, remoteUrl, target.headRef, target.headSha ?? null, target.headRepositoryOwner ?? null, ]); } function computeGenericForgeNextInterval( status: WorkspaceGitRuntimeSnapshot["forge"]["pullRequest"], consecutiveErrors: number, ): number { const isPending = status?.checksStatus === "pending" || status?.checks?.some((check) => check.status === "pending") === true; const baseInterval = isPending ? FORGE_PR_STATUS_POLL_FAST_INTERVAL_MS : FORGE_PR_STATUS_POLL_SLOW_INTERVAL_MS; if (consecutiveErrors <= 1) { return baseInterval; } return Math.min( baseInterval * 2 ** (consecutiveErrors - 1), FORGE_PR_STATUS_POLL_ERROR_BACKOFF_CAP_MS, ); } async function runGitFetch(cwd: string): Promise { await runGitCommand(["fetch", "origin", "--prune"], { cwd, envOverlay: { GIT_TERMINAL_PROMPT: "0" }, timeout: 120_000, }); }