Files
paseo/packages/server/src/server/workspace-git-service.ts
T

4177 lines
144 KiB
TypeScript

import { createHash } from "node:crypto";
import { readFile, stat } from "node:fs/promises";
import { basename, dirname, join, resolve } from "node:path";
import { LRUCache } from "lru-cache";
import { CheckoutDiffCache } from "./checkout-diff-cache.js";
import pLimit from "p-limit";
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,
getCheckoutRefDerivedState,
getCheckoutSnapshotFacts,
getCheckoutShortstat,
getCheckoutStatus,
getCheckoutWorktreeState,
getPullRequestStatus,
forgeAuthStateFromError,
hasOriginRemote,
listBranchSuggestions,
resolveRepositoryDefaultBranch,
resolveBranchCheckout,
resolveAbsoluteGitDir,
} from "../utils/checkout-git.js";
import type {
ForgeAuthState,
ForgeService,
ForgeSpecificStatusFacts,
PullRequestCheck,
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 { isPathInsideRoot, looksLikeDefiniteWindowsPath } from "../utils/path.js";
import {
createRunGitCommand,
runGitCommand,
type RunGitCommand,
} from "../utils/run-git-command.js";
import { GitObservationPaths } from "./git-observation-paths.js";
import { branchNameFromRef } from "../utils/worktree-metadata.js";
import { listPaseoWorktrees, type PaseoWorktreeInfo } from "../utils/worktree.js";
import { READ_ONLY_GIT_ENV } from "./checkout-git-utils.js";
import { classifyGitMetadataPath, getPrunedGitMetadataPaths } from "./git-metadata-event-rules.js";
import {
fetchWorkspaceGitRemote,
type WorkspaceGitFetchResult,
type WorkspaceGitFetchObserver,
type WorkspaceGitRemoteRefChange,
} from "./workspace-git-fetch.js";
import { deriveProjectSlug } from "./workspace-git-metadata.js";
import {
type FileChange,
type FileObserverDiagnostics,
type FileObserverCallback,
type FileObserver,
type FileObserverOptions,
type FileObserverSubscription,
type SubscribeToFileChanges,
createFileObserver,
} from "./file-observer/index.js";
import { checkoutLiteFromGitSnapshot } from "./workspace-registry-model.js";
import { createWatcherLivenessCanary } from "./watcher-liveness-canary.js";
const WORKSPACE_GIT_WATCH_DEBOUNCE_MS = 1_000;
export const BACKGROUND_GIT_FETCH_INTERVAL_MS = 180_000;
const FETCH_METADATA_ECHO_TTL_MS = 5_000;
export const WORKSPACE_GIT_OBSERVATION_REENSURE_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;
// Degraded polling shells out a full Git refresh per tick, and the repositories that defeat the
// recursive watcher are the ones where that refresh is expensive. At a fixed cadence the daemon
// therefore burns a core forever on a workspace nobody has touched. Double the gap after every
// tick that produced an identical snapshot, and drop back to the base interval as soon as one
// does not. Explicit reads still force a refresh, so this only bounds background discovery of
// changes made outside Paseo.
const DEGRADED_GIT_POLL_MAX_INTERVAL_MS = 60_000;
// A dependency install creates directories for minutes. Collapse the burst into
// a couple of `git ls-files` runs rather than one per event.
const WORKING_TREE_IGNORE_REFRESH_QUIET_MS = 300;
const WORKING_TREE_IGNORE_REFRESH_MAX_DELAY_MS = 2_000;
// `knownDirectories` is a memoisation of the fast path in
// `noteWorkingTreeDirectories`, not authoritative state — every entry is
// rediscoverable from a later watcher event under the same path. Deletion
// handling (`removeDeletedKnownDirectories`) keeps it close to the live
// directory count, but this cap is the backstop for what that cannot cover
// (a missed or coalesced delete event, a backend quirk): clearing the whole
// set outright on overflow is always safe, since the only cost is one extra
// `git ls-files` refresh the next time a directory under this root is
// touched again. Set well below `MAX_TRACKED_ENTRIES` (250_000, the
// native-recursive.ts cap on combined file+directory entries per watched
// root) because directories are a minority of any tracked tree, and the
// observer itself already fails into polling before a single root's
// combined entry count could approach that cap.
const WORKING_TREE_KNOWN_DIRECTORIES_MAX = 50_000;
// Keep whole workspace pipelines below the lower-level Git process pool so daemon control work
// retains subprocess and event-loop headroom during large workspace reconciliation bursts.
export const WORKSPACE_GIT_REFRESH_CONCURRENCY = 4;
export const WORKSPACE_GIT_OBSERVATION_SETUP_CONCURRENCY = 2;
export const WORKSPACE_GIT_WATCHER_SUBSCRIBE_TIMEOUT_MS = 10_000;
const WATCH_RECOVERY_BASE_DELAY_MS = 30_000;
// Giving up permanently leaves polling as the only behaviour until a daemon
// restart, which kills running agents. Back off, but keep trying.
const WATCH_RECOVERY_MAX_DELAY_MS = 300_000;
const WATCH_RECOVERY_MAX_BACKOFF_STEPS = 4;
// 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;
// Small values (booleans, short strings, small arrays); generous cap.
const WORKSPACE_GIT_AUXILIARY_CACHE_MAX = 256;
function mergeSets<T>(
left: ReadonlySet<T>,
right: ReadonlySet<T>,
): { merged: Set<T>; added: boolean } {
const merged = new Set(left);
let added = false;
for (const value of right) {
if (!merged.has(value)) {
merged.add(value);
added = true;
}
}
return { merged, added };
}
export function getWorkspaceGitObservationReensurePhaseMs(cwd: string): number {
return (
createHash("sha256").update(cwd).digest().readUInt32BE(0) %
WORKSPACE_GIT_OBSERVATION_REENSURE_INTERVAL_MS
);
}
// Kept for local diagnostic fixtures that only use the stable phase calculation.
export const getWorkspaceGitSelfHealPhaseMs = getWorkspaceGitObservationReensurePhaseMs;
export interface WorkspaceGitRuntimeSnapshot {
cwd: string;
git: {
isGit: boolean;
repoRoot: string | null;
mainRepoRoot: string | null;
currentBranch: string | null;
headSha?: string;
remoteUrl: string | null;
isPaseoOwnedWorktree: boolean;
isDirty: boolean | null;
baseRef: string | null;
baseRefExact?: 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;
headSha?: string;
isMerged: boolean;
isDraft?: boolean;
mergeable?: PullRequestMergeable;
checks?: PullRequestCheck[];
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;
/** Publishes computed snapshot changes independently of workspace UI subscriptions. */
onSnapshotUpdated(listener: WorkspaceGitSnapshotUpdatedListener): WorkspaceGitSubscription;
getCheckout(cwd: string): Promise<ProjectCheckoutLitePayload>;
getSnapshot(
cwd: string,
options?: WorkspaceGitSnapshotOptions,
): Promise<WorkspaceGitRuntimeSnapshot>;
resolveForge(cwd: string): Promise<ForgeResolution | null>;
getCheckoutDiff(
cwd: string,
options: CheckoutDiffCompare,
readOptions?: WorkspaceGitReadOptions,
): Promise<CheckoutDiffResult>;
validateBranchRef(
cwd: string,
ref: string,
options?: WorkspaceGitReadOptions,
): Promise<WorkspaceGitBranchValidationResult>;
hasLocalBranch(cwd: string, branch: string, options?: WorkspaceGitReadOptions): Promise<boolean>;
suggestBranchesForCwd(
cwd: string,
options?: WorkspaceGitBranchSuggestionsOptions,
readOptions?: WorkspaceGitReadOptions,
): Promise<WorkspaceGitBranchSuggestion[]>;
listStashes(
cwd: string,
options?: WorkspaceGitStashListOptions,
readOptions?: WorkspaceGitReadOptions,
): Promise<WorkspaceGitStashEntry[]>;
listWorktrees(
cwdOrRepoRoot: string,
options?: WorkspaceGitReadOptions,
): Promise<WorkspaceGitWorktreeInfo[]>;
invalidateWorktrees(): void;
getProjectSlug(cwd: string, options?: WorkspaceGitReadOptions): Promise<string>;
resolveRepoRoot(cwd: string, options?: WorkspaceGitReadOptions): Promise<string>;
resolveDefaultBranch(cwdOrRepoRoot: string, options?: WorkspaceGitReadOptions): Promise<string>;
resolveRepoRemoteUrl(cwd: string, options?: WorkspaceGitReadOptions): Promise<string | null>;
refresh(cwd: string, options?: { priority?: "normal" | "high" }): Promise<void>;
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(): Promise<void>;
}
export interface WorkspaceGitServiceMetrics {
workspaceTargetCount: number;
workspaceListenerCount: number;
repositoryTargetCount: number;
repositoryWorkspaceLinkCount: number;
workingTreeWatchTargetCount: number;
workingTreeWatchListenerCount: number;
workspaceObservationSetupInFlightCount: number;
workingTreeWatchSetupInFlightCount: number;
workspaceRefreshInFlightCount: number;
workspaceRefreshQueuedCount: number;
workspaceRefreshAdmissionActiveCount: number;
workspaceRefreshAdmissionPendingCount: number;
workspaceObservationSetupAdmissionActiveCount: number;
workspaceObservationSetupAdmissionPendingCount: number;
fetchInFlightCount: number;
snapshotUpdatedListenerCount: number;
watcherErrorCallbackCount: number;
fileObserver: FileObserverDiagnostics;
}
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;
refMode?: "exact";
}
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;
afterInFlight?: boolean;
reason: string;
};
interface WorkspaceGitRefreshRequest {
force: boolean;
refreshStructure: boolean;
refreshWorktree: boolean;
includeForge: boolean;
emitUnchanged?: boolean;
reason: string;
queueIfBusy: boolean;
movedRemoteRefs: Set<string>;
}
interface ScheduledWorkspaceGitRefreshOptions {
force?: boolean;
scope?: "refs" | "structure" | "worktree";
includeForge?: boolean;
emitUnchanged?: boolean;
reason?: string;
queueIfBusy?: boolean;
movedRemoteRefs?: ReadonlySet<string>;
}
type WorkspaceGitRefreshState =
| {
status: "idle";
}
| {
status: "in-flight";
promise: Promise<WorkspaceGitRuntimeSnapshot>;
request: WorkspaceGitRefreshRequest;
queued: WorkspaceGitRefreshRequest | null;
};
interface WorkspaceGitServiceDependencies {
subscribe: SubscribeToFileChanges;
getCheckoutSnapshotFacts: typeof getCheckoutSnapshotFacts;
getCheckoutRefDerivedState: typeof getCheckoutRefDerivedState;
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<string, ForgeService>;
resolveAbsoluteGitDir: (cwd: string) => Promise<string | null>;
hasOriginRemote: (cwd: string) => Promise<boolean>;
runGitFetch: (
cwd: string,
observer: WorkspaceGitFetchObserver,
runGitCommand: RunGitCommand,
) => Promise<WorkspaceGitFetchResult>;
runGitCommand: typeof runGitCommand;
getWorkspaceGitSelfHealPhaseMs: typeof getWorkspaceGitObservationReensurePhaseMs;
createWatcherLivenessCanary: typeof createWatcherLivenessCanary;
workspaceFolderExists: (cwd: string) => Promise<boolean>;
now: () => Date;
}
interface WorkspaceGitServiceOptions {
logger: pino.Logger;
paseoHome: string;
worktreesRoot?: string;
fileObserver?: FileObserver;
deps?: Partial<WorkspaceGitServiceDependencies>;
}
class WorkspaceGitServiceDisposedError extends Error {
constructor() {
super("WorkspaceGitService is disposed");
this.name = "WorkspaceGitServiceDisposedError";
}
}
class WorkspaceGitWatcherSubscriptionTimeoutError extends Error {
constructor(watchPath: string) {
super(
`Watcher subscription for ${watchPath} timed out after ${WORKSPACE_GIT_WATCHER_SUBSCRIBE_TIMEOUT_MS}ms`,
);
this.name = "WorkspaceGitWatcherSubscriptionTimeoutError";
}
}
interface WorkspaceGitTarget {
gitDirPaths: GitObservationPaths | null;
cwd: string;
listeners: Set<WorkspaceGitListener>;
workingTreeWatchTarget: WorkingTreeWatchTarget | null;
debounceTimer: NodeJS.Timeout | null;
pendingDebounceRequest: WorkspaceGitRefreshRequest | null;
observationReensureTimer: NodeJS.Timeout | null;
forgePrStatusPollSubscription: { unsubscribe: () => void } | null;
forgePrStatusPollKey: string | null;
refreshState: WorkspaceGitRefreshState;
latestGit: WorkspaceGitRuntimeSnapshot["git"] | null;
latestGitLoadedAtMs: number | null;
latestForge: WorkspaceGitRuntimeSnapshot["forge"] | null;
latestForgeLoadedAtMs: number | null;
latestSnapshot: WorkspaceGitRuntimeSnapshot | null;
latestSnapshotLoadedAtMs: number | null;
latestFacts: CheckoutSnapshotFacts | null;
factsPromise: Promise<CheckoutSnapshotFacts> | null;
latestFingerprint: string | null;
lastShellOutAtMs: number | null;
repoGitRoot: string | null;
observationSetupPromise: Promise<void> | null;
observationSetupComplete: boolean;
folderMissing: boolean;
folderRestorePromise: Promise<void> | null;
closed: boolean;
}
interface RepoGitTarget {
paths: GitObservationPaths;
repoGitRoot: string;
cwd: string;
workspaceKeys: Set<string>;
subscription: FileObserverSubscription | null;
fallbackPolling: boolean;
fallbackPollTimer: NodeJS.Timeout | null;
fallbackPollQuietTickCount: number;
recovery: WatchRecoveryState;
intervalId: NodeJS.Timeout | null;
fetchInFlight: boolean;
bufferedFetchMetadataEvents: FileChange[];
recentFetchRemoteRefChanges: Map<
string,
{ change: WorkspaceGitRemoteRefChange; expiresAtMs: number }
>;
knownRemoteRefs: Set<string> | null;
closed: boolean;
}
interface RepoMetadataWorkspaceRefresh {
refreshBase: boolean;
structural: boolean;
movedRemoteRefs: Set<string>;
queueIfBusy: boolean;
}
interface WorkingTreeWatchTarget {
paths: GitObservationPaths;
acceptsEvent: (candidate: string) => boolean;
cwd: string;
watchPath: string;
repoRoot: string | null;
subscription: FileObserverSubscription | null;
ignoredDirectories: Set<string>;
// What the live subscription actually accepted, as of the last successful
// `updateIgnore`/subscribe — the full resolved ignore list (the watch root's
// `.git` plus gitignore-derived `ignoredDirectories`), not just
// `ignoredDirectories`. The resolved list can move ahead of this (a fresh
// `git ls-files` result) while the subscription still runs on the old set;
// comparing against this field instead of the last-computed value keeps a
// skipped update from latching forever.
appliedIgnoreList: string[];
ignoredDirectoriesRefreshPromise: Promise<void> | null;
ignoredDirectoriesRefreshRequested: boolean;
// Bounded two ways so a long-lived daemon over a churning tree cannot grow
// this without limit: `removeDeletedKnownDirectories` drops an entry (and
// anything nested under it) once its own path is reported deleted, and
// `enforceKnownDirectoriesCap` clears the whole set outright past
// `WORKING_TREE_KNOWN_DIRECTORIES_MAX`. See both for why clearing is safe.
knownDirectories: Set<string>;
// Directories added to `knownDirectories` since the last ignore-list
// refresh that actually completed (successfully or not). A failed refresh
// (`loadIgnoredDirs` falling back to the previous set) must not leave a
// newly seen directory permanently marked known — that would silently
// reintroduce the original "watched forever" bug through the failure path.
// `replaceWorkingTreeIgnoredDirectories` rolls this set back into
// `knownDirectories` on failure so a later event under the same directory
// is treated as unseen again and re-arms the refresh; on success it is
// simply cleared, since a successful refresh reflects the full current
// ignore state and `pruneKnownDirectories` already removes anything that
// turned out to be ignored.
pendingKnownDirectories: Set<string>;
ignoreRefreshTimer: NodeJS.Timeout | null;
ignoreRefreshDeadlineMs: number | null;
aliases: Set<string>;
workspaceKeys: Set<string>;
fallbackPolling: boolean;
fallbackPollTimer: NodeJS.Timeout | null;
fallbackPollQuietTickCount: number;
recovery: WatchRecoveryState;
listeners: Set<() => void>;
closed: boolean;
}
interface WatchRecoveryState {
attemptCount: number;
timer: NodeJS.Timeout | null;
// Set when a subscription is accepted, cleared when it is scheduled for
// recovery. `attemptCount` only resets to 0 once the subscription this
// replaces is proven to have survived at least `WATCH_RECOVERY_MAX_DELAY_MS`
// — otherwise a filesystem that accepts `subscribe()` but errors on first
// use (SMB/NFS, Docker virtiofs, some FUSE) resets the ladder on every
// attempt and the backoff never engages.
establishedAt: number | null;
}
function setsEqual(left: ReadonlySet<string>, right: ReadonlySet<string>): boolean {
return left.size === right.size && [...left].every((value) => right.has(value));
}
type WorkingTreeWatchFallbackReason =
| "not_a_git_checkout"
| "watcher_error"
| "watcher_setup_failed"
| "watcher_update_failed";
interface WorkspaceGitAuxiliaryReadCacheEntry<T> {
value: T | null;
loadedAtMs: number | null;
lastShellOutAtMs: number | null;
inFlight: Promise<T> | null;
}
interface WorkspaceForgePrStatusPollTarget {
headRef: string;
headSha?: string;
headRepositoryOwner?: string;
}
function buildDefaultWorkspaceGitServiceDeps(
subscribe: SubscribeToFileChanges,
): WorkspaceGitServiceDependencies {
return {
subscribe,
getCheckoutSnapshotFacts,
getCheckoutRefDerivedState,
getCheckoutStatus,
getCheckoutShortstat,
getCheckoutWorktreeState,
getCheckoutDiff,
getPullRequestStatus,
resolveBranchCheckout,
resolveRepositoryDefaultBranch,
listBranchSuggestions,
listPaseoWorktrees,
resolveAbsoluteGitDir,
hasOriginRemote,
runGitFetch: fetchWorkspaceGitRemote,
runGitCommand,
getWorkspaceGitSelfHealPhaseMs: getWorkspaceGitObservationReensurePhaseMs,
createWatcherLivenessCanary,
workspaceFolderExists,
now: () => new Date(),
};
}
export async function workspaceFolderExists(cwd: string): Promise<boolean> {
try {
await stat(cwd);
return true;
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
return code !== "ENOENT" && code !== "ENOTDIR";
}
}
function resolveWorkspaceGitServiceDeps(
subscribe: SubscribeToFileChanges,
deps: Partial<WorkspaceGitServiceDependencies> | undefined,
): WorkspaceGitServiceDependencies {
return { ...buildDefaultWorkspaceGitServiceDeps(subscribe), ...deps };
}
export class WorkspaceGitServiceImpl implements WorkspaceGitService {
private readonly logger: pino.Logger;
private readonly paseoHome: string;
private readonly worktreesRoot: string | undefined;
private readonly fileObserver: FileObserver;
private readonly deps: WorkspaceGitServiceDependencies;
private readonly forgeResolver: ForgeResolver;
private readonly workspaceRefreshLimit = pLimit({
concurrency: WORKSPACE_GIT_REFRESH_CONCURRENCY,
rejectOnClear: true,
});
private readonly workspaceObservationSetupLimit = pLimit({
concurrency: WORKSPACE_GIT_OBSERVATION_SETUP_CONCURRENCY,
rejectOnClear: true,
});
private readonly disposeController = new AbortController();
private disposed = false;
private disposePromise: Promise<void> | null = null;
private readonly pendingRefreshes = new Set<Promise<WorkspaceGitRuntimeSnapshot>>();
private readonly snapshotUpdatedListeners = new Set<WorkspaceGitSnapshotUpdatedListener>();
private readonly workspaceTargets = new Map<string, WorkspaceGitTarget>();
private readonly repoTargets = new Map<string, RepoGitTarget>();
private readonly workingTreeWatchTargets = new Map<string, WorkingTreeWatchTarget>();
private readonly workingTreeWatchSetups = new Map<string, Promise<WorkingTreeWatchTarget>>();
private readonly workingTreeWatchResolutions = new Map<string, Promise<WorkingTreeWatchTarget>>();
private readonly workingTreeWatchAliases = new Map<string, string>();
private readonly branchValidationCache = new LRUCache<
string,
WorkspaceGitAuxiliaryReadCacheEntry<WorkspaceGitBranchValidationResult>
>({ max: WORKSPACE_GIT_AUXILIARY_CACHE_MAX });
private readonly localBranchCache = new LRUCache<
string,
WorkspaceGitAuxiliaryReadCacheEntry<boolean>
>({ max: WORKSPACE_GIT_AUXILIARY_CACHE_MAX });
private readonly branchSuggestionsCache = new LRUCache<
string,
WorkspaceGitAuxiliaryReadCacheEntry<WorkspaceGitBranchSuggestion[]>
>({ max: WORKSPACE_GIT_AUXILIARY_CACHE_MAX });
private readonly stashListCache = new LRUCache<
string,
WorkspaceGitAuxiliaryReadCacheEntry<WorkspaceGitStashEntry[]>
>({ max: WORKSPACE_GIT_AUXILIARY_CACHE_MAX });
private readonly worktreeListCache = new LRUCache<
string,
WorkspaceGitAuxiliaryReadCacheEntry<WorkspaceGitWorktreeInfo[]>
>({ max: WORKSPACE_GIT_AUXILIARY_CACHE_MAX });
private readonly defaultBranchCache = new LRUCache<
string,
WorkspaceGitAuxiliaryReadCacheEntry<string>
>({ max: WORKSPACE_GIT_AUXILIARY_CACHE_MAX });
private readonly checkoutDiffCache = new CheckoutDiffCache(() => this.deps.now().getTime());
private watcherErrorCallbackCount = 0;
constructor(options: WorkspaceGitServiceOptions) {
this.logger = options.logger.child({ module: "workspace-git-service" });
this.paseoHome = options.paseoHome;
this.worktreesRoot = options.worktreesRoot;
this.fileObserver = options.fileObserver ?? createFileObserver();
this.deps = resolveWorkspaceGitServiceDeps(
this.fileObserver.subscribe.bind(this.fileObserver),
options.deps,
);
this.forgeResolver = createForgeResolver({
createService: (forge) => this.deps.forgeOverrides?.[forge] ?? createForgeService(forge),
});
}
resolveForge(cwd: string): Promise<ForgeResolution | null> {
this.assertNotDisposed();
return this.forgeResolver.resolve(resolve(cwd));
}
registerWorkspace(
params: { cwd: string },
listener: WorkspaceGitListener,
): WorkspaceGitSubscription {
this.assertNotDisposed();
const cwd = resolve(params.cwd);
const target = this.ensureWorkspaceTarget(cwd);
target.listeners.add(listener);
if (target.folderMissing) {
void this.resumeWorkspaceIfFolderRestored(target);
}
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.assertNotDisposed();
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,
workspaceRefreshAdmissionActiveCount: this.workspaceRefreshLimit.activeCount,
workspaceRefreshAdmissionPendingCount: this.workspaceRefreshLimit.pendingCount,
workspaceObservationSetupAdmissionActiveCount:
this.workspaceObservationSetupLimit.activeCount,
workspaceObservationSetupAdmissionPendingCount:
this.workspaceObservationSetupLimit.pendingCount,
fetchInFlightCount,
snapshotUpdatedListenerCount: this.snapshotUpdatedListeners.size,
watcherErrorCallbackCount: this.watcherErrorCallbackCount,
fileObserver: this.fileObserver.getDiagnostics(),
};
}
async getSnapshot(
cwd: string,
options?: WorkspaceGitSnapshotOptions,
): Promise<WorkspaceGitRuntimeSnapshot> {
this.assertNotDisposed();
cwd = resolve(cwd);
const request = this.normalizeRefreshRequest(options, "getSnapshot");
const target = this.ensureWorkspaceTarget(cwd);
if (!request.force && target.latestSnapshot) {
return target.latestSnapshot;
}
if (target.folderMissing) {
await this.resumeWorkspaceIfFolderRestored(target);
}
return this.requestWorkspaceSnapshot(target, request);
}
async getCheckout(cwd: string): Promise<ProjectCheckoutLitePayload> {
this.assertNotDisposed();
const normalizedCwd = resolve(cwd);
const status = await this.deps.getCheckoutStatus(normalizedCwd, {
paseoHome: this.paseoHome,
worktreesRoot: this.worktreesRoot,
logger: this.logger,
});
const cached = this.getCachedSnapshot(normalizedCwd);
if (cached && cached.git.isGit !== status.isGit) {
// A fresh checkout read discovered a repository transition. Keep the
// shared snapshot current before exposing the new project capability.
await this.getSnapshot(normalizedCwd, {
force: true,
includeForge: false,
reason: "checkout-classification-changed",
});
}
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,
});
}
private getCachedSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot | null {
cwd = resolve(cwd);
return this.workspaceTargets.get(cwd)?.latestSnapshot ?? null;
}
async getCheckoutDiff(
cwd: string,
options: CheckoutDiffCompare,
readOptions?: WorkspaceGitReadOptions,
): Promise<CheckoutDiffResult> {
this.assertNotDisposed();
const normalizedCwd = resolve(cwd);
const normalizedOptions = this.normalizeCheckoutDiffOptions(options);
// Initial inventory events cover the watcher setup gap. Process them before
// starting a cold diff, rather than invalidating that build halfway through.
await this.workspaceTargets.get(normalizedCwd)?.observationSetupPromise;
this.assertNotDisposed();
return this.checkoutDiffCache.read({
cwd: normalizedCwd,
compare: normalizedOptions,
...readOptions,
load: (signal) =>
this.deps.getCheckoutDiff(
normalizedCwd,
normalizedOptions,
{
paseoHome: this.paseoHome,
worktreesRoot: this.worktreesRoot,
},
signal,
),
});
}
private normalizeCheckoutDiffOptions(options: CheckoutDiffCompare): CheckoutDiffCompare {
return {
mode: options.mode,
...(options.mode !== "uncommitted" && options.baseRef !== undefined
? { baseRef: options.baseRef }
: {}),
...(options.ignoreWhitespace === true ? { ignoreWhitespace: true } : {}),
...(options.includeStructured === true ? { includeStructured: true } : {}),
};
}
private invalidateCheckoutDiffCache(cwd: string, mode: CheckoutDiffCompare["mode"]): void {
this.checkoutDiffCache.invalidate(cwd, mode);
}
validateBranchRef(
cwd: string,
ref: string,
options?: WorkspaceGitReadOptions,
): Promise<WorkspaceGitBranchValidationResult> {
this.assertNotDisposed();
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<boolean> {
this.assertNotDisposed();
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<WorkspaceGitBranchSuggestion[]> {
this.assertNotDisposed();
const normalizedCwd = resolve(cwd);
const query = options?.query ?? "";
const limit = options?.limit;
const key = JSON.stringify([
"branch-suggestions",
normalizedCwd,
query,
limit ?? null,
options?.refMode,
]);
return this.readAuxiliaryCache(this.branchSuggestionsCache, key, readOptions, () =>
this.deps.listBranchSuggestions(normalizedCwd, options),
);
}
listStashes(
cwd: string,
options?: WorkspaceGitStashListOptions,
readOptions?: WorkspaceGitReadOptions,
): Promise<WorkspaceGitStashEntry[]> {
this.assertNotDisposed();
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<WorkspaceGitWorktreeInfo[]> {
this.assertNotDisposed();
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<string> {
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));
}
invalidateWorktrees(): void {
this.assertNotDisposed();
// Checkout paths can cache the same list under different roots, including bare repos.
// Detach in-flight reads too, so a pre-mutation result cannot refill the cache.
this.worktreeListCache.clear();
}
async resolveDefaultBranch(
cwdOrRepoRoot: string,
options?: WorkspaceGitReadOptions,
): Promise<string> {
this.assertNotDisposed();
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<string> {
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<string | null> {
const snapshot = await this.getSnapshot(cwd, options);
return snapshot.git.remoteUrl;
}
async refresh(cwd: string, _options?: { priority?: "normal" | "high" }): Promise<void> {
this.assertNotDisposed();
cwd = resolve(cwd);
const target = this.ensureWorkspaceTarget(cwd);
if (target.folderMissing) {
await this.resumeWorkspaceIfFolderRestored(target);
}
await this.refreshWorkspaceTarget(target, {
force: false,
refreshStructure: true,
refreshWorktree: true,
includeForge: false,
reason: "refresh",
queueIfBusy: false,
movedRemoteRefs: new Set(),
});
this.scheduleWorkspaceObservationSetup(target);
}
async requestWorkingTreeWatch(
cwd: string,
onChange: () => void,
): Promise<{ repoRoot: string | null; unsubscribe: () => void }> {
this.assertNotDisposed();
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 {
this.assertNotDisposed();
cwd = resolve(cwd);
const target = this.workspaceTargets.get(cwd);
if (!target) {
return;
}
if (target.folderMissing) {
void this.resumeWorkspaceIfFolderRestored(target);
return;
}
this.scheduleWorkspaceRefresh(target, { queueIfBusy: false });
}
onWorkspaceStateMayHaveChanged(cwd: string): void {
this.assertNotDisposed();
const normalizedCwd = resolve(cwd);
const target = this.workspaceTargets.get(normalizedCwd);
if (!target || target.closed) {
return;
}
this.invalidateForge(normalizedCwd);
if (target.folderMissing) {
void this.resumeWorkspaceIfFolderRestored(target);
return;
}
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.assertNotDisposed();
this.forgeResolver.invalidate(resolve(cwd));
}
dispose(): Promise<void> {
if (this.disposePromise) return this.disposePromise;
this.disposed = true;
this.disposeController.abort(new WorkspaceGitServiceDisposedError());
this.workspaceRefreshLimit.clearQueue();
this.workspaceObservationSetupLimit.clearQueue();
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();
// Unsubscribing can remove a target while its Git command still owns the cwd.
// Stop admission first, then drain those refreshes before releasing the service.
this.disposePromise = Promise.all([
this.fileObserver.close(),
Promise.allSettled(this.pendingRefreshes),
]).then(() => undefined);
return this.disposePromise;
}
private assertNotDisposed(): void {
if (this.disposed) {
throw new WorkspaceGitServiceDisposedError();
}
}
private ensureWorkspaceTarget(cwd: string): WorkspaceGitTarget {
this.assertNotDisposed();
const existingTarget = this.workspaceTargets.get(cwd);
if (existingTarget) {
return existingTarget;
}
return this.createWorkspaceTarget(cwd);
}
private readAuxiliaryCache<T>(
cache: LRUCache<string, WorkspaceGitAuxiliaryReadCacheEntry<T>>,
key: string,
options: WorkspaceGitReadOptions | undefined,
load: () => Promise<T>,
): Promise<T> {
this.assertNotDisposed();
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<T>(
cache: LRUCache<string, WorkspaceGitAuxiliaryReadCacheEntry<T>>,
key: string,
): WorkspaceGitAuxiliaryReadCacheEntry<T> {
const existing = cache.get(key);
if (existing) {
return existing;
}
const entry: WorkspaceGitAuxiliaryReadCacheEntry<T> = {
value: null,
loadedAtMs: null,
lastShellOutAtMs: null,
inFlight: null,
};
cache.set(key, entry);
return entry;
}
private ensureWorkingTreeWatchTarget(cwd: string): Promise<WorkingTreeWatchTarget> {
this.assertNotDisposed();
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<WorkingTreeWatchTarget> {
const repoRoot = await this.resolveCheckoutWatchRoot(cwd);
const cwdPaths = repoRoot ? await GitObservationPaths.create(cwd) : null;
this.assertNotDisposed();
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 && cwdPaths?.matches(repoRoot) ? 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 = {
gitDirPaths: null,
cwd,
listeners: new Set(),
workingTreeWatchTarget: null,
debounceTimer: null,
pendingDebounceRequest: null,
observationReensureTimer: null,
forgePrStatusPollSubscription: null,
forgePrStatusPollKey: null,
refreshState: { status: "idle" },
latestGit: null,
latestGitLoadedAtMs: null,
latestForge: null,
latestForgeLoadedAtMs: null,
latestSnapshot: null,
latestSnapshotLoadedAtMs: null,
latestFacts: null,
factsPromise: null,
latestFingerprint: null,
lastShellOutAtMs: null,
repoGitRoot: null,
observationSetupPromise: null,
observationSetupComplete: false,
folderMissing: false,
folderRestorePromise: null,
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",
queueIfBusy: false,
movedRemoteRefs: new Set(),
});
});
}
private scheduleWorkspaceObservationSetup(target: WorkspaceGitTarget): void {
if (
target.observationSetupComplete ||
target.observationSetupPromise ||
!this.isActiveObservedWorkspaceTarget(target)
) {
return;
}
target.observationSetupPromise = this.workspaceObservationSetupLimit(async () => {
if (!this.isActiveObservedWorkspaceTarget(target)) {
return;
}
await this.setupWorkspaceObservation(target);
})
.catch((error) => {
if (this.disposed || !this.isActiveObservedWorkspaceTarget(target)) {
return;
}
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<void> {
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 gitDirPaths = target.gitDirPaths?.matches(gitDir)
? target.gitDirPaths
: await GitObservationPaths.create(gitDir);
if (!this.isActiveObservedWorkspaceTarget(target)) return;
target.gitDirPaths = gitDirPaths;
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<CheckoutSnapshotFacts> {
if (target.latestFacts) {
return target.latestFacts;
}
return this.loadCheckoutFacts(target, {
paseoHome: this.paseoHome,
logger: this.logger,
});
}
private loadCheckoutFacts(
target: WorkspaceGitTarget,
context: CheckoutContext,
): Promise<CheckoutSnapshotFacts> {
if (target.factsPromise) {
return target.factsPromise;
}
const promise = this.deps
.getCheckoutSnapshotFacts(target.cwd, context)
.then((facts) => {
if (!target.closed) 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.folderMissing &&
target.listeners.size > 0 &&
this.workspaceTargets.get(target.cwd) === target
);
}
private async createWorkingTreeWatchTarget(
cwd: string,
watchPath: string,
repoRoot: string | null,
): Promise<WorkingTreeWatchTarget> {
const ignoredDirectories = repoRoot
? (await this.loadIgnoredDirs(watchPath)).ignored
: new Set<string>();
const paths = await GitObservationPaths.create(watchPath);
this.assertNotDisposed();
const target: WorkingTreeWatchTarget = {
paths,
acceptsEvent: paths.createEventFilter([join(watchPath, ".git"), ...ignoredDirectories]),
cwd,
watchPath,
repoRoot,
subscription: null,
ignoredDirectories,
appliedIgnoreList: [],
ignoredDirectoriesRefreshPromise: null,
ignoredDirectoriesRefreshRequested: false,
knownDirectories: new Set(),
pendingKnownDirectories: new Set(),
ignoreRefreshTimer: null,
ignoreRefreshDeadlineMs: null,
aliases: new Set([cwd]),
workspaceKeys: new Set(),
fallbackPolling: false,
fallbackPollTimer: null,
fallbackPollQuietTickCount: 0,
recovery: { attemptCount: 0, timer: null, establishedAt: null },
listeners: new Set(),
closed: false,
};
this.workingTreeWatchTargets.set(cwd, target);
this.workingTreeWatchAliases.set(cwd, cwd);
await this.startWorkingTreeSubscription(target);
this.assertNotDisposed();
if (repoRoot === null) {
this.startWorkingTreeWatchFallback(target, "not_a_git_checkout");
}
return target;
}
private async subscribeWithDeadline(
watchPath: string,
callback: FileObserverCallback,
options: FileObserverOptions,
onSubscribeSettled: () => void,
): Promise<FileObserverSubscription> {
this.assertNotDisposed();
const signal = this.disposeController.signal;
let outcome: "pending" | "accepted" | "expired" = "pending";
let timeout: NodeJS.Timeout | null = null;
let removeAbortListener = () => {};
let unsubscribePromise: Promise<void> | null = null;
let subscriptionPromise: Promise<FileObserverSubscription>;
try {
subscriptionPromise = this.deps
.subscribe(watchPath, callback, options)
.finally(onSubscribeSettled);
} catch (error) {
onSubscribeSettled();
throw error;
}
void subscriptionPromise.then(
(subscription) => {
if (outcome === "expired" || signal.aborted) {
unsubscribePromise ??= this.unsubscribeWatcherSubscription(subscription, watchPath);
return unsubscribePromise;
}
return undefined;
},
() => undefined,
);
const timeoutPromise = new Promise<never>((_resolve, reject) => {
timeout = setTimeout(() => {
outcome = "expired";
reject(new WorkspaceGitWatcherSubscriptionTimeoutError(watchPath));
}, WORKSPACE_GIT_WATCHER_SUBSCRIBE_TIMEOUT_MS);
});
const disposalPromise = new Promise<never>((_resolve, reject) => {
const rejectForDisposal = () => {
outcome = "expired";
reject(signal.reason);
};
if (signal.aborted) {
rejectForDisposal();
return;
}
signal.addEventListener("abort", rejectForDisposal, { once: true });
removeAbortListener = () => signal.removeEventListener("abort", rejectForDisposal);
});
try {
const subscription = await Promise.race([
subscriptionPromise,
timeoutPromise,
disposalPromise,
]);
if (signal.aborted) {
outcome = "expired";
unsubscribePromise ??= this.unsubscribeWatcherSubscription(subscription, watchPath);
await unsubscribePromise;
throw signal.reason;
}
outcome = "accepted";
return subscription;
} finally {
if (outcome === "pending") {
outcome = "expired";
}
if (timeout) {
clearTimeout(timeout);
}
removeAbortListener();
}
}
private async unsubscribeWatcherSubscription(
subscription: FileObserverSubscription,
watchPath: string,
): Promise<void> {
try {
await subscription.unsubscribe();
} catch (error) {
this.logger.warn({ err: error, watchPath }, "Failed to stop watcher subscription");
}
}
// Shared by startWorkingTreeSubscription and
// replaceWorkingTreeIgnoredDirectories's applied-vs-desired gate so both
// compute the same resolved ignore list.
private workingTreeIgnoreList(target: WorkingTreeWatchTarget): string[] {
return [join(target.watchPath, ".git"), ...target.ignoredDirectories];
}
private async startWorkingTreeSubscription(
target: WorkingTreeWatchTarget,
options?: { replaceFallback?: boolean },
): Promise<boolean> {
const ignore = this.workingTreeIgnoreList(target);
let watcherErrored = false;
let subscribeSettled = false;
const markSubscribeSettled = () => {
subscribeSettled = true;
if (watcherErrored) {
this.scheduleWorkingTreeWatchRecovery(target);
}
};
try {
const subscription = await this.subscribeWithDeadline(
target.watchPath,
(error, events) => {
if (error) {
if (watcherErrored) {
return;
}
watcherErrored = true;
this.watcherErrorCallbackCount += 1;
this.logger.warn(
{ err: error, cwd: target.cwd },
"Working tree watcher error; using degraded polling",
);
this.degradeWorkingTreeWatch(target, "watcher_error");
if (subscribeSettled) {
this.scheduleWorkingTreeWatchRecovery(target);
}
return;
}
if (watcherErrored) {
return;
}
events = this.relevantWorkingTreeEvents(target, events);
if (events.length === 0) return;
const discovered = this.noteWorkingTreeDirectories(target, events);
if (events.some((event) => basename(event.path) === ".gitignore")) {
void this.refreshWorkingTreeIgnoredDirectories(target);
} else if (discovered) {
this.scheduleWorkingTreeIgnoreRefresh(target);
}
this.notifyWorkingTreeChanged(target, "working-tree-watch");
},
{ ignore },
markSubscribeSettled,
);
if (watcherErrored) {
await this.unsubscribeWatcherSubscription(subscription, target.watchPath);
return false;
}
if (
target.closed ||
(target.fallbackPolling && !options?.replaceFallback) ||
target.subscription
) {
await this.unsubscribeWatcherSubscription(subscription, target.watchPath);
return false;
}
target.subscription = subscription;
target.appliedIgnoreList = ignore;
// Do not reset `attemptCount` here — `subscribe()` resolving proves
// nothing about the watcher's health on a filesystem where it errors on
// first use. Record when this subscription was established instead;
// `scheduleWorkingTreeWatchRecovery` only resets the ladder once this
// one has survived long enough to prove itself durable.
target.recovery.establishedAt = this.deps.now().getTime();
if (options?.replaceFallback && target.repoRoot !== null) {
target.fallbackPolling = false;
if (target.fallbackPollTimer) {
clearTimeout(target.fallbackPollTimer);
target.fallbackPollTimer = null;
}
}
return true;
} catch (error) {
if (watcherErrored) {
return false;
}
if (this.disposed || target.closed) {
throw error;
}
this.logger.warn(
{ err: error, cwd: target.cwd },
"Failed to start working tree watcher; using degraded polling",
);
if (!options?.replaceFallback) {
this.startWorkingTreeWatchFallback(target, "watcher_setup_failed");
}
return false;
}
}
private degradedPollDelayMs(quietTickCount: number): number {
return Math.min(
DEGRADED_GIT_POLL_MAX_INTERVAL_MS,
DEGRADED_GIT_POLL_INTERVAL_MS * 2 ** quietTickCount,
);
}
// A refresh that throws is swallowed by refreshWorkspaceTarget and leaves the fingerprint
// alone, so it reads as a quiet tick and widens the gap. That is the intended outcome: a
// repository whose Git commands keep failing is the last one to retry every 5 seconds.
private async refreshDegradedPollTarget(
workspaceTarget: WorkspaceGitTarget,
request: WorkspaceGitRefreshRequest,
): Promise<boolean> {
if (await this.suspendWorkspaceIfFolderMissing(workspaceTarget)) {
return false;
}
const fingerprintBefore = workspaceTarget.latestFingerprint;
await this.refreshWorkspaceTarget(workspaceTarget, request);
return workspaceTarget.latestFingerprint !== fingerprintBefore;
}
private async suspendWorkspaceIfFolderMissing(target: WorkspaceGitTarget): Promise<boolean> {
if (target.folderMissing) {
return true;
}
if (await this.deps.workspaceFolderExists(target.cwd)) {
return false;
}
this.suspendWorkspaceWithMissingFolder(target);
return true;
}
private suspendWorkspaceWithMissingFolder(target: WorkspaceGitTarget): void {
if (target.folderMissing || target.closed || this.workspaceTargets.get(target.cwd) !== target) {
return;
}
target.folderMissing = true;
if (target.refreshState.status === "in-flight") {
target.refreshState.queued = null;
}
this.detachWorkspaceFromRepoTarget(target);
target.repoGitRoot = null;
this.stopWorkspaceObservation(target);
target.observationSetupComplete = false;
this.scheduleMissingFolderProbe(target);
this.logger.warn(
{ cwd: target.cwd },
"Workspace folder no longer exists; suspended git observation until it is back",
);
}
private isSuspendedWorkspaceTarget(target: WorkspaceGitTarget): boolean {
return (
target.folderMissing && !target.closed && this.workspaceTargets.get(target.cwd) === target
);
}
private scheduleMissingFolderProbe(target: WorkspaceGitTarget): void {
if (
!this.isSuspendedWorkspaceTarget(target) ||
target.observationReensureTimer ||
target.folderRestorePromise
) {
return;
}
target.observationReensureTimer = setTimeout(() => {
target.observationReensureTimer = null;
void this.resumeWorkspaceIfFolderRestored(target);
}, WORKSPACE_GIT_OBSERVATION_REENSURE_INTERVAL_MS);
}
private resumeWorkspaceIfFolderRestored(target: WorkspaceGitTarget): Promise<void> {
if (!target.folderMissing) {
return Promise.resolve();
}
target.folderRestorePromise ??= this.resumeWorkspaceWithRestoredFolder(target).finally(() => {
target.folderRestorePromise = null;
this.scheduleMissingFolderProbe(target);
});
return target.folderRestorePromise;
}
private async resumeWorkspaceWithRestoredFolder(target: WorkspaceGitTarget): Promise<void> {
if (!(await this.deps.workspaceFolderExists(target.cwd))) {
return;
}
await target.observationSetupPromise;
if (target.refreshState.status === "in-flight") {
await target.refreshState.promise.catch(() => undefined);
}
if (!this.isSuspendedWorkspaceTarget(target)) {
return;
}
target.latestFacts = null;
target.folderMissing = false;
await this.refreshWorkspaceTarget(target, {
force: false,
refreshStructure: true,
refreshWorktree: true,
includeForge: false,
reason: "workspace-folder-restored",
queueIfBusy: true,
movedRemoteRefs: new Set(),
});
if (target.closed || this.workspaceTargets.get(target.cwd) !== target) {
return;
}
target.observationSetupComplete = false;
if (target.observationReensureTimer) {
clearTimeout(target.observationReensureTimer);
target.observationReensureTimer = null;
}
this.logger.info({ cwd: target.cwd }, "Workspace folder is back; resumed git observation");
this.startWorkspaceSubscriptionTimers(target);
this.scheduleWorkspaceObservationSetup(target);
}
private startWorkingTreeWatchFallback(
target: WorkingTreeWatchTarget,
reason: WorkingTreeWatchFallbackReason,
): void {
if (this.disposed || target.closed || target.fallbackPolling) {
return;
}
target.fallbackPolling = true;
target.fallbackPollQuietTickCount = 0;
const { cwd } = target;
const poll = async () => {
target.fallbackPollTimer = null;
if (target.closed || this.workingTreeWatchTargets.get(target.cwd) !== target) {
return;
}
const changes = await Promise.all(
Array.from(target.workspaceKeys, async (workspaceKey) => {
const workspaceTarget = this.workspaceTargets.get(workspaceKey);
if (!workspaceTarget) {
return false;
}
const changed = await this.refreshDegradedPollTarget(workspaceTarget, {
force: false,
refreshStructure: target.repoRoot === null,
refreshWorktree: true,
includeForge: false,
reason: "working-tree-watch-fallback",
queueIfBusy: true,
movedRemoteRefs: new Set(),
});
if (target.repoRoot === null && workspaceTarget.latestGit?.isGit === true) {
workspaceTarget.observationSetupComplete = false;
this.scheduleWorkspaceObservationSetup(workspaceTarget);
}
return changed;
}),
);
if (await this.deps.workspaceFolderExists(target.watchPath)) {
this.notifyWorkingTreeConsumers(target);
}
if (!target.closed && (target.subscription === null || target.repoRoot === null)) {
target.fallbackPollQuietTickCount = changes.some(Boolean)
? 0
: target.fallbackPollQuietTickCount + 1;
target.fallbackPollTimer = setTimeout(
poll,
this.degradedPollDelayMs(target.fallbackPollQuietTickCount),
);
} else {
target.fallbackPolling = false;
}
};
target.fallbackPollTimer = setTimeout(poll, DEGRADED_GIT_POLL_INTERVAL_MS);
this.logger.warn(
{
cwd,
intervalMs: DEGRADED_GIT_POLL_INTERVAL_MS,
maxIntervalMs: DEGRADED_GIT_POLL_MAX_INTERVAL_MS,
reason,
},
"Working tree watcher unavailable; using bounded polling fallback",
);
}
private degradeWorkingTreeWatch(target: WorkingTreeWatchTarget, reason: "watcher_error"): void {
const subscription = target.subscription;
target.subscription = null;
if (subscription) {
void this.unsubscribeWatcherSubscription(subscription, target.watchPath);
}
this.notifyWorkingTreeChanged(target, "working-tree-watch-error");
this.startWorkingTreeWatchFallback(target, reason);
}
/**
* Advances the recovery backoff ladder and returns the delay to wait
* before the next attempt. `attemptCount` only resets to 0 here, and only
* when the subscription being replaced was established long enough ago to
* have proven itself durable (>= `WATCH_RECOVERY_MAX_DELAY_MS`) — otherwise
* a filesystem that accepts `subscribe()` but errors immediately on every
* attempt (SMB/NFS, Docker virtiofs, some FUSE) would reset the ladder to 0
* on every cycle and the backoff would never engage. Shared by the
* working-tree and repo-metadata recovery paths so both apply the same
* rule.
*/
private advanceWatchRecoveryLadder(recovery: WatchRecoveryState): number {
if (
recovery.establishedAt !== null &&
this.deps.now().getTime() - recovery.establishedAt >= WATCH_RECOVERY_MAX_DELAY_MS
) {
recovery.attemptCount = 0;
}
recovery.establishedAt = null;
recovery.attemptCount += 1;
return Math.min(
WATCH_RECOVERY_BASE_DELAY_MS *
2 ** Math.min(recovery.attemptCount - 1, WATCH_RECOVERY_MAX_BACKOFF_STEPS),
WATCH_RECOVERY_MAX_DELAY_MS,
);
}
private scheduleWorkingTreeWatchRecovery(target: WorkingTreeWatchTarget): void {
if (target.closed || target.subscription || target.recovery.timer) {
return;
}
const delayMs = this.advanceWatchRecoveryLadder(target.recovery);
target.recovery.timer = setTimeout(() => {
target.recovery.timer = null;
void this.recoverWorkingTreeWatch(target);
}, delayMs);
}
private async recoverWorkingTreeWatch(target: WorkingTreeWatchTarget): Promise<void> {
if (target.closed || target.subscription) {
return;
}
if (!(await this.deps.workspaceFolderExists(target.watchPath))) {
this.scheduleWorkingTreeWatchRecovery(target);
return;
}
await this.refreshWorkingTreeIgnoredDirectories(target);
if (target.closed || target.subscription) {
return;
}
const recovered = await this.startWorkingTreeSubscription(target, { replaceFallback: true });
if (!recovered) {
this.scheduleWorkingTreeWatchRecovery(target);
return;
}
this.logger.info({ cwd: target.cwd }, "Working tree watcher recovered");
this.notifyWorkingTreeChanged(target, "working-tree-watch-recovered");
}
private async promoteWorkingTreeWatchTarget(
target: WorkingTreeWatchTarget,
repoRoot: string,
): Promise<void> {
if (target.repoRoot !== null) {
return;
}
target.repoRoot = repoRoot;
if (target.subscription && target.fallbackPolling) {
target.fallbackPolling = false;
if (target.fallbackPollTimer) {
clearTimeout(target.fallbackPollTimer);
target.fallbackPollTimer = null;
}
}
await this.refreshWorkingTreeIgnoredDirectories(target);
}
/**
* `git ls-files` reports ignored directories only once they exist, so the
* ignore set must be re-read when new directory topology appears — not only
* when the rules change. Keyed on `dirname` because FSEvents coalescing makes
* the directory's own creation event unreliable; a file inside it proves it
* exists either way.
*/
private noteWorkingTreeDirectories(
target: WorkingTreeWatchTarget,
events: FileChange[],
): boolean {
if (target.repoRoot === null) {
return false;
}
this.removeDeletedKnownDirectories(target, events);
const gitDir = join(target.watchPath, ".git");
// Event paths have already been mapped into this subscription's established namespace.
const matchesWatchPath = target.paths.matches;
// One new directory is enough: the scheduled Git query inventories every
// ignored directory that exists by then. Stop before a large event batch
// multiplies containment checks across all its new directories.
for (const event of events) {
const directory = dirname(event.path);
// Cheap Set lookup first — the steady-state case where the directory is
// already known do not need another root comparison.
if (target.knownDirectories.has(directory) || matchesWatchPath(directory)) {
continue;
}
// `directory` and `gitDir`/`ignoredDirectory` all derive from the same
// `target.watchPath` used to build this subscription, and `directory`
// itself is the same `dirname(event.path)` value already trusted as a
// plain string key by `target.knownDirectories.has()` above — no realpath
// resolution needed for containment among values from this one source.
if (!isPathInsideRoot(target.watchPath, directory)) {
continue;
}
if (isPathInsideRoot(gitDir, directory)) {
continue;
}
let ignored = false;
for (const ignoredDirectory of target.ignoredDirectories) {
if (isPathInsideRoot(ignoredDirectory, directory)) {
ignored = true;
break;
}
}
if (ignored) {
continue;
}
target.knownDirectories.add(directory);
target.pendingKnownDirectories.add(directory);
this.enforceKnownDirectoriesCap(target);
return true;
}
return false;
}
/**
* `knownDirectories` only ever grows in the loop above — nothing
* previously dropped an entry once its directory was deleted from disk,
* so a long-lived daemon over a tree that creates and deletes directories
* (build output, temp dirs, a churning dependency tree) would accumulate
* tombstones for the life of the target and slow every future
* `pruneKnownDirectories` pass along with it. `FileChange` events arrive
* for files too, and a delete of `dir/file.txt` does not mean `dir` is
* gone, so this only acts once the deleted path is itself confirmed a
* known directory — a cheap `Set.has()` first, so the common case (a file
* delete) costs one lookup and returns, same as the fast path this method
* feeds into. A directory delete does not guarantee a delete event for
* everything beneath it (coalescing, an `rm -rf` of a populated tree), so
* once a deleted path is confirmed known, this drops the whole known
* subtree the same way `pruneKnownDirectories` drops a newly ignored one —
* the deleted path is the containment root here instead of an ignored
* root.
*/
private removeDeletedKnownDirectories(
target: WorkingTreeWatchTarget,
events: FileChange[],
): void {
if (target.knownDirectories.size === 0) {
return;
}
for (const event of events) {
if (event.type !== "delete" || !target.knownDirectories.has(event.path)) {
continue;
}
for (const directory of target.knownDirectories) {
if (!isPathInsideRoot(event.path, directory)) {
continue;
}
target.knownDirectories.delete(directory);
// Not required for correctness — both refresh outcomes tolerate a
// stale entry that no longer resolves to anything live: a
// successful refresh clears `pendingKnownDirectories` outright, and
// a failed one's `rearmPendingKnownDirectories` no-ops a delete of
// an already-absent key. Dropping it here keeps
// `pendingKnownDirectories` a subset of `knownDirectories` at all
// times instead of "true except right after a deletion."
target.pendingKnownDirectories.delete(directory);
}
}
}
/**
* See `WORKING_TREE_KNOWN_DIRECTORIES_MAX` for why clearing on overflow is
* always safe. Runs only when this batch discovered at least one new
* directory, since that is the only way `knownDirectories` grows.
*/
private enforceKnownDirectoriesCap(target: WorkingTreeWatchTarget): void {
if (target.knownDirectories.size <= WORKING_TREE_KNOWN_DIRECTORIES_MAX) {
return;
}
target.knownDirectories.clear();
target.pendingKnownDirectories.clear();
}
private scheduleWorkingTreeIgnoreRefresh(target: WorkingTreeWatchTarget): void {
if (this.disposed || target.closed || target.repoRoot === null) {
return;
}
const now = this.deps.now().getTime();
target.ignoreRefreshDeadlineMs ??= now + WORKING_TREE_IGNORE_REFRESH_MAX_DELAY_MS;
if (target.ignoreRefreshTimer) {
clearTimeout(target.ignoreRefreshTimer);
}
const delayMs = Math.max(
0,
Math.min(WORKING_TREE_IGNORE_REFRESH_QUIET_MS, target.ignoreRefreshDeadlineMs - now),
);
target.ignoreRefreshTimer = setTimeout(() => {
target.ignoreRefreshTimer = null;
target.ignoreRefreshDeadlineMs = null;
void this.refreshWorkingTreeIgnoredDirectories(target);
}, delayMs);
}
/**
* String-prefix containment, not `isRealpathInsideRoot`: every entry on
* both sides is a plain path built from `target.watchPath` by this same
* class (`dirname(event.path)` for `knownDirectories`, `resolve(watchPath,
* ...)` for `ignoredDirectories` in `loadIgnoredDirs`) — no realpath
* divergence to reconcile, per the same reasoning as
* `noteWorkingTreeDirectories`. Normalizing each ignored root once up front
* instead of recomputing per pair turns a 30k-known x 40-ignored scan from
* ~26s of synchronous realpath syscalls into a bounded number of cheap
* string comparisons.
*
* Case-folding still needs to happen for a Windows-looking root, same as
* `isPathInsideRoot`/`isRealpathInsideRoot` in `../utils/path.js` — a
* case-insensitive filesystem can report a known directory whose case
* diverges from the git-reported ignored root. Since both sides derive
* from the single `target.watchPath` for this target, whether to fold is
* one decision for the whole call (`looksLikeDefiniteWindowsPath`
* reused from `../utils/path.js`, not reimplemented here), not a per-pair
* one — so it costs nothing beyond the already-precomputed prefixes.
*/
private pruneKnownDirectories(target: WorkingTreeWatchTarget): void {
if (target.ignoredDirectories.size === 0) {
return;
}
const foldCase = looksLikeDefiniteWindowsPath(target.watchPath);
const comparable = (path: string) =>
foldCase ? path.replaceAll("\\", "/").toLowerCase() : path;
const ignoredPrefixes: string[] = [];
const foldedIgnoredExact = foldCase ? new Set<string>() : null;
for (const ignoredDirectory of target.ignoredDirectories) {
const ignored = comparable(ignoredDirectory);
ignoredPrefixes.push(`${ignored}/`);
foldedIgnoredExact?.add(ignored);
}
for (const directory of target.knownDirectories) {
const comparableDirectory = comparable(directory);
const comparableDirectoryWithSep = `${comparableDirectory}/`;
const isIgnored =
(foldedIgnoredExact ?? target.ignoredDirectories).has(comparableDirectory) ||
ignoredPrefixes.some((prefix) => comparableDirectoryWithSep.startsWith(prefix));
if (isIgnored) {
target.knownDirectories.delete(directory);
}
}
}
/**
* Undoes the "mark known" side effect of `noteWorkingTreeDirectories` for
* every directory discovered since the last refresh that actually
* completed, because this refresh did not — it fell back to the previous
* ignore set (see `loadIgnoredDirs`). Leaving them in `knownDirectories`
* would make the steady-state Set-lookup fast path in
* `noteWorkingTreeDirectories` skip them forever, even though this refresh
* never determined whether they are ignored. Removing them here re-arms
* discovery: the next event under any of them is treated as unseen again,
* so `noteWorkingTreeDirectories` schedules another refresh attempt.
*/
private rearmPendingKnownDirectories(target: WorkingTreeWatchTarget): void {
if (target.pendingKnownDirectories.size === 0) {
return;
}
for (const directory of target.pendingKnownDirectories) {
target.knownDirectories.delete(directory);
}
target.pendingKnownDirectories.clear();
}
private relevantWorkingTreeEvents(
target: WorkingTreeWatchTarget,
events: FileChange[],
): FileChange[] {
const relevant: FileChange[] = [];
// Watch backends may report the same path more than once in a batch.
const acceptedPaths = new Map<string, boolean>();
for (const event of events) {
let accepted = acceptedPaths.get(event.path);
if (accepted === undefined) {
accepted = target.acceptsEvent(event.path);
acceptedPaths.set(event.path, accepted);
}
if (accepted) relevant.push({ ...event, path: target.paths.toWatchPath(event.path) });
}
return relevant;
}
private refreshWorkingTreeIgnoredDirectories(target: WorkingTreeWatchTarget): Promise<void> {
if (target.closed || target.repoRoot === null) {
return Promise.resolve();
}
target.ignoredDirectoriesRefreshRequested = true;
if (target.ignoredDirectoriesRefreshPromise) {
return target.ignoredDirectoriesRefreshPromise;
}
const refreshPromise = (async () => {
while (!target.closed && target.ignoredDirectoriesRefreshRequested) {
target.ignoredDirectoriesRefreshRequested = false;
try {
await 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<void> {
const { ignored: ignoredDirectories, failed } = await this.loadIgnoredDirs(
target.watchPath,
target.ignoredDirectories,
);
if (target.closed) {
return;
}
if (failed) {
// A failed `git ls-files` must not leave directories discovered since
// the last completed refresh permanently marked known — see the
// `pendingKnownDirectories` field comment. Re-arm them so the next
// event under any of them is treated as unseen again and schedules
// another refresh attempt.
this.rearmPendingKnownDirectories(target);
} else {
target.pendingKnownDirectories.clear();
}
if (!this.haveSamePaths(target.ignoredDirectories, ignoredDirectories)) {
target.ignoredDirectories = ignoredDirectories;
target.acceptsEvent = target.paths.createEventFilter(this.workingTreeIgnoreList(target));
this.pruneKnownDirectories(target);
}
if (target.fallbackPolling) {
return;
}
const subscription = target.subscription;
if (!subscription) {
return;
}
// Compare against the resolved list the live subscription actually
// accepted (`appliedIgnoreList`), not what git last reported
// (`ignoredDirectories` alone) — the comparison and the value passed to
// `updateIgnore` have to be the same array, or a skipped update would
// otherwise latch forever.
const next = this.workingTreeIgnoreList(target);
if (this.haveSamePaths(new Set(target.appliedIgnoreList), new Set(next))) {
return;
}
try {
await subscription.updateIgnore(next);
target.appliedIgnoreList = next;
} catch (error) {
target.subscription = null;
if (!target.closed && !target.fallbackPolling) {
this.startWorkingTreeWatchFallback(target, "watcher_update_failed");
this.scheduleWorkingTreeWatchRecovery(target);
}
this.logger.warn(
{ err: error, cwd: target.cwd },
"Failed to update working tree watcher ignore paths",
);
return;
}
if (target.closed || target.fallbackPolling) {
return;
}
this.notifyWorkingTreeChanged(target, "working-tree-watch-reconfigured");
}
private haveSamePaths(first: Set<string>, second: Set<string>): 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<string | null> {
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<string> {
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<void> {
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 paths = await GitObservationPaths.create(repoGitRoot);
if (!this.isActiveObservedWorkspaceTarget(workspaceTarget)) return;
const establishedTarget = this.repoTargets.get(repoGitRoot);
if (establishedTarget) {
establishedTarget.workspaceKeys.add(workspaceTarget.cwd);
return;
}
const repoTarget: RepoGitTarget = {
paths,
repoGitRoot,
cwd: workspaceTarget.cwd,
workspaceKeys: new Set([workspaceTarget.cwd]),
subscription: null,
fallbackPolling: false,
fallbackPollTimer: null,
fallbackPollQuietTickCount: 0,
recovery: { attemptCount: 0, timer: null, establishedAt: null },
intervalId: null,
fetchInFlight: false,
bufferedFetchMetadataEvents: [],
recentFetchRemoteRefChanges: new Map(),
knownRemoteRefs: null,
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,
options?: { replaceFallback?: boolean },
): Promise<boolean> {
const paths = target.paths;
const ignore = getPrunedGitMetadataPaths("common").map((path) =>
join(target.repoGitRoot, path),
);
const matchesRepoGitRoot = paths.matches;
const canary = this.deps.createWatcherLivenessCanary(target.repoGitRoot);
let openedSubscription: FileObserverSubscription | null = null;
let watcherErrored = false;
let subscribeSettled = false;
const markSubscribeSettled = () => {
subscribeSettled = true;
if (watcherErrored) {
this.scheduleRepoMetadataWatchRecovery(target);
}
};
try {
const subscription = await this.subscribeWithDeadline(
target.repoGitRoot,
(error, events) => {
const liveEvents = canary.filterEvents(
events.map((event) => ({ ...event, path: paths.toWatchPath(event.path) })),
);
if (error) {
if (watcherErrored) {
return;
}
watcherErrored = true;
this.watcherErrorCallbackCount += 1;
this.logger.warn(
{ err: error, repoGitRoot: target.repoGitRoot },
"Repository metadata watcher error; using degraded polling",
);
this.degradeRepoMetadataWatch(target);
if (subscribeSettled) {
this.scheduleRepoMetadataWatchRecovery(target);
}
return;
}
if (watcherErrored) {
return;
}
if (liveEvents.length === 0) {
return;
}
const relevantEvents = liveEvents.filter(
(event) =>
!matchesRepoGitRoot(event.path) &&
ignore.every((ignoredPath) => !isPathInsideRoot(ignoredPath, event.path)),
);
if (relevantEvents.length > 0) {
const immediateEvents = target.fetchInFlight
? relevantEvents.filter((event) => {
if (this.isFetchRemoteMetadataEvent(target, event)) {
target.bufferedFetchMetadataEvents.push(event);
return false;
}
return true;
})
: relevantEvents;
if (immediateEvents.length > 0) {
this.refreshWorkingTreeIgnoresFromRepoMetadataEvents(target, immediateEvents);
const routedRefreshes = this.routeRepoMetadataEvents(target, immediateEvents);
this.scheduleRepoMetadataRefresh(
target,
"git-metadata-watch",
routedRefreshes === null || [...routedRefreshes.values()].some((r) => r.structural),
routedRefreshes,
);
}
}
},
{ ignore },
markSubscribeSettled,
);
openedSubscription = subscription;
await canary.verify(this.disposeController.signal);
if (watcherErrored) {
await this.unsubscribeWatcherSubscription(subscription, target.repoGitRoot);
return false;
}
if (
target.closed ||
(target.fallbackPolling && !options?.replaceFallback) ||
this.repoTargets.get(target.repoGitRoot) !== target
) {
await this.unsubscribeWatcherSubscription(subscription, target.repoGitRoot);
return false;
}
target.subscription = subscription;
// See `advanceWatchRecoveryLadder`: do not reset `attemptCount` on
// subscribe success alone, only record when this subscription was
// established.
target.recovery.establishedAt = this.deps.now().getTime();
if (options?.replaceFallback) {
target.fallbackPolling = false;
if (target.fallbackPollTimer) {
clearTimeout(target.fallbackPollTimer);
target.fallbackPollTimer = null;
}
}
return true;
} catch (error) {
if (openedSubscription) {
await this.unsubscribeWatcherSubscription(openedSubscription, target.repoGitRoot);
}
if (watcherErrored) {
return false;
}
if (this.disposed || target.closed) {
throw error;
}
this.logger.warn(
{ err: error, repoGitRoot: target.repoGitRoot },
"Failed to start repository metadata watcher; using degraded polling",
);
if (!options?.replaceFallback) {
this.startRepoMetadataFallback(target);
}
return false;
}
}
private degradeRepoMetadataWatch(target: RepoGitTarget): void {
const subscription = target.subscription;
target.subscription = null;
if (subscription) {
void this.unsubscribeWatcherSubscription(subscription, target.repoGitRoot);
}
this.scheduleRepoMetadataRefresh(target, "git-metadata-watch-error", true);
this.startRepoMetadataFallback(target);
}
private scheduleRepoMetadataWatchRecovery(target: RepoGitTarget): void {
if (target.closed || target.subscription || target.recovery.timer) {
return;
}
const delayMs = this.advanceWatchRecoveryLadder(target.recovery);
target.recovery.timer = setTimeout(() => {
target.recovery.timer = null;
void this.recoverRepoMetadataWatch(target);
}, delayMs);
}
private async recoverRepoMetadataWatch(target: RepoGitTarget): Promise<void> {
if (target.closed || target.subscription) {
return;
}
const recovered = await this.startRepoMetadataObservation(target, { replaceFallback: true });
if (!recovered) {
this.scheduleRepoMetadataWatchRecovery(target);
return;
}
this.logger.info({ repoGitRoot: target.repoGitRoot }, "Repository metadata watcher recovered");
}
private routeRepoMetadataEvents(
target: RepoGitTarget,
events: FileChange[],
): Map<string, RepoMetadataWorkspaceRefresh> | null {
const refreshes = new Map<string, RepoMetadataWorkspaceRefresh>();
const matchesRepoGitRoot = target.paths.matches;
for (const event of events) {
if (!this.routeRepoMetadataEvent(target, event, matchesRepoGitRoot, refreshes)) return null;
}
return refreshes;
}
private refreshWorkingTreeIgnoresFromRepoMetadataEvents(
target: RepoGitTarget,
events: FileChange[],
): void {
const workspaceKeys = new Set<string>();
for (const event of events) {
const commonRelativePath = target.paths.relative(event.path)?.replaceAll("\\", "/");
if (commonRelativePath === "config" || commonRelativePath === "info/exclude") {
for (const workspaceKey of target.workspaceKeys) workspaceKeys.add(workspaceKey);
continue;
}
for (const workspaceKey of target.workspaceKeys) {
const facts = this.workspaceTargets.get(workspaceKey)?.latestFacts;
if (
facts?.isGit &&
facts.absoluteGitDir &&
this.workspaceTargets
.get(workspaceKey)
?.gitDirPaths?.relative(event.path)
?.replaceAll("\\", "/") === "config.worktree"
) {
workspaceKeys.add(workspaceKey);
}
}
}
for (const workspaceKey of workspaceKeys) {
const workspaceTarget = this.workspaceTargets.get(workspaceKey);
const workingTreeTarget = workspaceTarget
? this.getWorkingTreeWatchTargetForWorkspace(workspaceTarget)
: null;
if (workingTreeTarget) void this.refreshWorkingTreeIgnoredDirectories(workingTreeTarget);
}
}
private isFetchRemoteMetadataEvent(target: RepoGitTarget, event: FileChange): boolean {
const relativePath = target.paths.relative(event.path);
const effect = classifyGitMetadataPath("common", relativePath ?? "");
return (
(effect.kind === "ref" && effect.namespace === "remote") ||
(effect.kind === "all" &&
(relativePath === "packed-refs" || relativePath?.startsWith("reftable/") === true))
);
}
private routeRepoMetadataEvent(
target: RepoGitTarget,
event: FileChange,
matchesRepoGitRoot: (candidate: string) => boolean,
refreshes: Map<string, RepoMetadataWorkspaceRefresh>,
): boolean {
if (this.routePrivateGitDirEvent(target, event, matchesRepoGitRoot, refreshes)) return true;
const commonRelativePath = target.paths.relative(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 {
if (event.type === "delete") {
target.recentFetchRemoteRefChanges.delete(effect.ref);
target.knownRemoteRefs = null;
return false;
}
const recent = target.recentFetchRemoteRefChanges.get(effect.ref);
if (recent && recent.expiresAtMs >= this.deps.now().getTime()) {
this.routeRemoteBranchRef(target, effect.ref, refreshes, {
narrow: recent.change.kind === "moved",
});
} else {
target.recentFetchRemoteRefChanges.delete(effect.ref);
if (target.knownRemoteRefs?.has(effect.ref)) {
this.routeRemoteBranchRef(target, effect.ref, refreshes, { narrow: true });
} else if (
event.type === "create" &&
target.knownRemoteRefs &&
[...target.knownRemoteRefs].some((ref) => ref.startsWith(`${effect.ref}/`))
) {
return true;
} else {
return false;
}
}
}
return true;
case "all":
if (
commonRelativePath === "packed-refs" ||
commonRelativePath?.startsWith("reftable/") === true
) {
target.knownRemoteRefs = null;
}
return false;
}
}
private routePrivateGitDirEvent(
target: RepoGitTarget,
event: FileChange,
matchesRepoGitRoot: (candidate: string) => boolean,
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 = this.workspaceTargets
.get(workspaceKey)
?.gitDirPaths?.relative(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: (candidate: string) => boolean,
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>,
options?: { narrow?: boolean; queueIfBusy?: boolean },
): 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 shortstatRemoteRef =
facts.currentBranch &&
(!facts.resolvedBaseRef || branchNameFromRef(facts.resolvedBaseRef) === facts.currentBranch)
? `origin/${facts.currentBranch}`
: null;
const refs = [
facts.storedBaseRef,
facts.resolvedBaseRef,
facts.comparisonBaseRef,
facts.upstreamStatus?.ref,
configuredRemoteRef,
shortstatRemoteRef,
];
const usesRemoteRef = refs.some((ref) => ref === remoteRef || ref === qualifiedRemoteRef);
if (usesRemoteRef) {
this.addWorkspaceMetadataRefresh(refreshes, workspaceKey, true, {
structural: options?.narrow !== true,
movedRemoteRef: options?.narrow === true ? remoteRef : undefined,
queueIfBusy: options?.queueIfBusy,
});
}
}
}
private addWorkspaceMetadataRefresh(
refreshes: Map<string, RepoMetadataWorkspaceRefresh>,
workspaceKey: string,
refreshBase: boolean,
options?: {
structural?: boolean;
movedRemoteRef?: string;
queueIfBusy?: boolean;
},
): void {
const previous = refreshes.get(workspaceKey);
const movedRemoteRefs = new Set(previous?.movedRemoteRefs);
if (options?.movedRemoteRef) {
movedRemoteRefs.add(options.movedRemoteRef);
}
refreshes.set(workspaceKey, {
refreshBase: previous?.refreshBase === true || refreshBase,
structural: previous?.structural === true || options?.structural !== false,
movedRemoteRefs,
queueIfBusy: (previous?.queueIfBusy ?? false) || (options?.queueIfBusy ?? true),
});
}
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>();
const refreshes =
routedRefreshes ??
new Map(
Array.from(target.workspaceKeys, (workspaceKey) => [
workspaceKey,
{
refreshBase: true,
structural: true,
movedRemoteRefs: new Set<string>(),
queueIfBusy: true,
},
]),
);
for (const [workspaceKey, refresh] of refreshes) {
const workspaceTarget = this.workspaceTargets.get(workspaceKey);
if (workspaceTarget) {
let scope: ScheduledWorkspaceGitRefreshOptions["scope"];
if (!refreshWorktree) {
scope = refresh.structural ? "structure" : "refs";
}
if (refresh.refreshBase) {
this.invalidateCheckoutDiffCache(workspaceTarget.cwd, "base");
if (workspaceTarget.latestFacts?.isGit) {
this.invalidateCheckoutDiffCache(workspaceTarget.latestFacts.worktreeRoot, "base");
}
}
if (refreshWorktree) {
const workingTreeTarget = this.getWorkingTreeWatchTargetForWorkspace(workspaceTarget);
if (workingTreeTarget) {
workingTreeTargets.add(workingTreeTarget);
}
}
this.scheduleWorkspaceRefresh(workspaceTarget, {
scope,
emitUnchanged: refresh.refreshBase,
reason,
queueIfBusy: refresh.queueIfBusy,
movedRemoteRefs: refresh.movedRemoteRefs,
});
}
}
for (const workingTreeTarget of workingTreeTargets) {
this.notifyWorkingTreeConsumers(workingTreeTarget);
}
}
private startRepoMetadataFallback(target: RepoGitTarget): void {
if (target.fallbackPolling || target.closed) {
return;
}
target.fallbackPolling = true;
target.fallbackPollQuietTickCount = 0;
const poll = async () => {
target.fallbackPollTimer = null;
if (target.closed || this.repoTargets.get(target.repoGitRoot) !== target) {
return;
}
const workingTreeTargets = new Set<WorkingTreeWatchTarget>();
const changes = await Promise.all(
Array.from(target.workspaceKeys, async (workspaceKey) => {
const workspaceTarget = this.workspaceTargets.get(workspaceKey);
if (!workspaceTarget) {
return false;
}
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);
}
return await this.refreshDegradedPollTarget(workspaceTarget, {
force: false,
refreshStructure: true,
refreshWorktree: true,
includeForge: false,
emitUnchanged: true,
reason: "git-metadata-watch-fallback",
queueIfBusy: true,
movedRemoteRefs: new Set(),
});
}),
);
for (const workingTreeTarget of workingTreeTargets) {
this.notifyWorkingTreeConsumers(workingTreeTarget);
}
if (!target.closed && target.subscription === null) {
target.fallbackPollQuietTickCount = changes.some(Boolean)
? 0
: target.fallbackPollQuietTickCount + 1;
target.fallbackPollTimer = setTimeout(
poll,
this.degradedPollDelayMs(target.fallbackPollQuietTickCount),
);
} else {
target.fallbackPolling = false;
}
};
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.observationReensureTimer) {
const reensureObservation = () => {
if (!this.isActiveObservedWorkspaceTarget(target)) {
target.observationReensureTimer = null;
return;
}
target.observationReensureTimer = setTimeout(
reensureObservation,
WORKSPACE_GIT_OBSERVATION_REENSURE_INTERVAL_MS,
);
this.scheduleWorkspaceObservationSetup(target);
};
target.observationReensureTimer = setTimeout(
reensureObservation,
this.deps.getWorkspaceGitSelfHealPhaseMs(target.cwd),
);
}
this.updateForgePrStatusPollForTarget(target);
}
private updateForgePrStatusPollForTarget(target: WorkspaceGitTarget): void {
if (!this.isActiveObservedWorkspaceTarget(target)) {
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),
);
},
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));
}
} 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 (target.latestFacts?.isGit && target.latestFacts.paseoWorktree.isPaseoOwnedWorktree) {
return lookupTarget;
}
if (lookupTarget) {
return lookupTarget;
}
return { headRef: git.currentBranch };
}
private stopForgePrStatusPollForTarget(target: WorkspaceGitTarget): void {
target.forgePrStatusPollSubscription?.unsubscribe();
target.forgePrStatusPollSubscription = null;
target.forgePrStatusPollKey = null;
}
/**
* `previous` is what the caller should get back on failure. `runGitCommand`
* rejects on any non-zero exit or timeout, and a timeout is plausible under
* exactly the churn storm this ignore-refresh path targets. Returning an
* empty set on failure would read to the caller as "nothing is ignored" —
* indistinguishable from a real, freshly-computed empty result — and
* `replaceWorkingTreeIgnoredDirectories` treats its return value as
* authoritative, replacing `target.ignoredDirectories` and re-ingesting
* every directory that was previously ignored. Returning the previous set
* instead means a failed refresh leaves the watcher's ignore state
* unchanged rather than un-ignoring the entire dependency tree.
*
* `failed` lets the caller distinguish "ran fresh and happened to match
* `previous`" from "the git command itself failed" — the two are
* indistinguishable by comparing sets alone, but `replaceWorkingTreeIgnoredDirectories`
* needs to know which one happened to decide whether directories newly
* marked known since the last refresh can be trusted or must be re-armed.
*/
private async loadIgnoredDirs(
rootPath: string,
previous: Set<string> = new Set(),
): Promise<{ ignored: Set<string>; failed: boolean }> {
const ignored = new Set<string>();
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.warn(
{ err: error, rootPath },
"Failed to load gitignore directories; keeping previous ignore set",
);
return { ignored: previous, failed: true };
}
return { ignored, failed: false };
}
private async refreshWorkspaceTarget(
target: WorkspaceGitTarget,
request: WorkspaceGitRefreshRequest,
): Promise<void> {
if (target.closed || this.workspaceTargets.get(target.cwd) !== target) {
return;
}
try {
await this.requestWorkspaceSnapshot(target, request);
} catch (error) {
if (this.disposed || target.closed) {
return;
}
this.logger.warn(
{ err: error, cwd: target.cwd, reason: request.reason },
"Failed to refresh workspace git snapshot",
);
}
}
private requestWorkspaceSnapshot(
target: WorkspaceGitTarget,
request: WorkspaceGitRefreshRequest,
): Promise<WorkspaceGitRuntimeSnapshot> {
if (target.folderMissing) {
return Promise.resolve(target.latestSnapshot ?? buildNotGitSnapshot(target.cwd));
}
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) ||
[...request.movedRemoteRefs].some((ref) => !active.movedRemoteRefs.has(ref));
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(() => {
this.pendingRefreshes.delete(promise);
const state = target.refreshState;
if (state.status === "in-flight" && state.promise === promise) {
target.refreshState = { status: "idle" };
}
});
this.pendingRefreshes.add(promise);
target.refreshState = {
status: "in-flight",
promise,
request,
queued: null,
};
return promise;
}
private normalizeRefreshRequest(
options: WorkspaceGitSnapshotOptions | undefined,
defaultReason: string,
): 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,
queueIfBusy: force && options?.afterInFlight === true,
movedRemoteRefs: new Set(),
};
}
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" && scope !== "refs",
refreshWorktree: scope !== "structure" && scope !== "refs",
includeForge: options?.includeForge ?? false,
emitUnchanged: options?.emitUnchanged,
reason: options?.reason ?? "watch",
queueIfBusy: options?.queueIfBusy ?? true,
movedRemoteRefs: new Set(options?.movedRemoteRefs),
};
}
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;
const { merged: movedRemoteRefs, added: upgradesMovedRefs } = mergeSets(
pending.movedRemoteRefs,
request.movedRemoteRefs,
);
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 ||
upgradesMovedRefs
? request.reason
: pending.reason,
queueIfBusy: pending.queueIfBusy || request.queueIfBusy,
movedRemoteRefs,
};
}
private async runWorkspaceRefreshLoop(
target: WorkspaceGitTarget,
initialRequest: WorkspaceGitRefreshRequest,
): Promise<WorkspaceGitRuntimeSnapshot> {
let request = initialRequest;
let snapshot = target.latestSnapshot ?? buildNotGitSnapshot(target.cwd);
let failure: { error: unknown } | null = null;
while (true) {
if (
request.emitUnchanged === true &&
request.movedRemoteRefs.size === 0 &&
target.latestSnapshot
) {
this.rememberSnapshot(target, target.latestSnapshot, {
forceEmit: true,
});
}
try {
const runRefreshGitCommand = createRunGitCommand(`workspace-refresh:${request.reason}`);
const admittedSnapshot = await this.workspaceRefreshLimit(() => {
if (
target.closed ||
target.folderMissing ||
this.workspaceTargets.get(target.cwd) !== target
) {
return null;
}
return this.refreshSnapshot(target, request, runRefreshGitCommand);
});
if (target.folderMissing) {
failure = null;
break;
}
if (!admittedSnapshot) {
break;
}
snapshot = admittedSnapshot;
this.rememberSnapshot(target, snapshot, {
forceEmit:
request.force || (request.emitUnchanged === true && request.movedRemoteRefs.size > 0),
});
failure = null;
} catch (error) {
failure = { error };
}
if (target.folderMissing) {
failure = null;
break;
}
if (this.disposed || target.closed || this.workspaceTargets.get(target.cwd) !== target) {
break;
}
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,
runRefreshGitCommand: RunGitCommand,
): Promise<WorkspaceGitRuntimeSnapshot> {
let facts = target.latestFacts;
if (request.movedRemoteRefs.size > 0 && !request.refreshStructure && !request.refreshWorktree) {
if (facts?.isGit && target.latestGit?.isGit) {
try {
await this.refreshRefDerivedSnapshot(
target,
facts,
request.movedRemoteRefs,
runRefreshGitCommand,
);
return this.combineSnapshot(target);
} catch (error) {
this.logger.debug(
{ err: error, cwd: target.cwd, movedRemoteRefs: [...request.movedRemoteRefs] },
"Narrow remote ref refresh failed; using structural refresh",
);
}
}
facts = await this.refreshGitSnapshot(
target,
{
...request,
refreshStructure: true,
refreshWorktree: true,
},
runRefreshGitCommand,
);
return this.combineSnapshot(target);
}
if (request.refreshStructure || !facts || !target.latestGit) {
facts = await this.refreshGitSnapshot(target, request, runRefreshGitCommand);
} else if (request.refreshWorktree) {
await this.refreshWorktreeSnapshot(target, facts, runRefreshGitCommand);
}
if (!facts) {
facts = await this.refreshGitSnapshot(target, request, runRefreshGitCommand);
}
if (request.includeForge) {
await this.refreshForgeSnapshot(target, request, facts, runRefreshGitCommand);
}
const snapshot = this.combineSnapshot(target);
target.latestSnapshotLoadedAtMs = this.deps.now().getTime();
return snapshot;
}
private async refreshRefDerivedSnapshot(
target: WorkspaceGitTarget,
facts: Extract<CheckoutSnapshotFacts, { isGit: true }>,
movedRemoteRefs: ReadonlySet<string>,
runRefreshGitCommand: RunGitCommand,
): Promise<void> {
const latestGit = target.latestGit;
if (!latestGit?.isGit) {
throw new Error("Remote ref refresh requires a warmed Git snapshot");
}
target.lastShellOutAtMs = this.deps.now().getTime();
const derived = await this.deps.getCheckoutRefDerivedState(
target.cwd,
facts,
{ aheadBehind: latestGit.aheadBehind, diffStat: latestGit.diffStat },
movedRemoteRefs,
{
paseoHome: this.paseoHome,
worktreesRoot: this.worktreesRoot,
logger: this.logger,
facts,
runGitCommand: runRefreshGitCommand,
},
);
target.latestFacts = { ...facts, upstreamStatus: derived.upstreamStatus };
target.latestGit = {
...latestGit,
aheadBehind: derived.aheadBehind,
diffStat: derived.diffStat,
upstreamRef: derived.upstreamStatus?.ref ?? null,
aheadOfOrigin: derived.upstreamStatus?.aheadBehind.ahead ?? null,
behindOfOrigin: derived.upstreamStatus?.aheadBehind.behind ?? null,
};
const loadedAtMs = this.deps.now().getTime();
target.latestGitLoadedAtMs = loadedAtMs;
}
private async refreshWorktreeSnapshot(
target: WorkspaceGitTarget,
facts: CheckoutSnapshotFacts,
runRefreshGitCommand: RunGitCommand,
): Promise<void> {
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,
runGitCommand: runRefreshGitCommand,
};
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;
}
private async refreshGitSnapshot(
target: WorkspaceGitTarget,
request: WorkspaceGitRefreshRequest,
runRefreshGitCommand: RunGitCommand,
): Promise<CheckoutSnapshotFacts> {
const now = this.deps.now();
target.lastShellOutAtMs = now.getTime();
const cwd = target.cwd;
const previousForgePrStatusPollKey = this.getForgePrStatusPollKey(target);
const previousBaseRef = target.latestFacts?.isGit ? target.latestFacts.resolvedBaseRef : null;
const baseContext: CheckoutContext = {
paseoHome: this.paseoHome,
worktreesRoot: this.worktreesRoot,
logger: this.logger,
runGitCommand: runRefreshGitCommand,
};
const facts = await this.loadCheckoutFacts(target, baseContext);
if (facts.isGit && facts.resolvedBaseRef !== previousBaseRef) {
this.invalidateCheckoutDiffCache(cwd, "base");
this.invalidateCheckoutDiffCache(facts.worktreeRoot, "base");
}
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.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,
headSha: facts.isGit ? facts.pullRequestLookupTarget?.headSha : undefined,
remoteUrl: checkoutStatus.remoteUrl,
isPaseoOwnedWorktree: checkoutStatus.isPaseoOwnedWorktree,
isDirty: refreshWorktree
? checkoutStatus.isDirty
: (target.latestGit?.isDirty ?? checkoutStatus.isDirty),
baseRef: checkoutStatus.baseRef,
baseRefExact: checkoutStatus.baseRefExact,
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;
if (previousForgePrStatusPollKey !== this.getForgePrStatusPollKey(target)) {
target.latestForge = buildForgeUnavailableSnapshot();
target.latestForgeLoadedAtMs = target.latestGitLoadedAtMs;
}
return facts;
}
private async refreshForgeSnapshot(
target: WorkspaceGitTarget,
request: WorkspaceGitRefreshRequest,
facts: CheckoutSnapshotFacts,
runRefreshGitCommand: RunGitCommand,
): Promise<void> {
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,
runGitCommand: runRefreshGitCommand,
});
// 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"],
): 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), {
forceEmit: false,
});
}
private rememberSnapshot(
target: WorkspaceGitTarget,
snapshot: WorkspaceGitRuntimeSnapshot,
options?: { forceEmit?: boolean },
): void {
if (target.closed || target.folderMissing || this.workspaceTargets.get(target.cwd) !== target) {
return;
}
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;
for (const listener of this.snapshotUpdatedListeners) {
try {
listener(snapshot);
} catch (error) {
this.logger.warn(
{ err: error, cwd: snapshot.cwd },
"Workspace git snapshot listener threw",
);
}
}
for (const listener of target.listeners) {
listener(snapshot);
}
}
private async runRepoFetch(target: RepoGitTarget): Promise<void> {
if (target.fetchInFlight) {
return;
}
target.fetchInFlight = true;
this.logger.debug(
{ repoGitRoot: target.repoGitRoot, cwd: target.cwd },
"Running background git fetch",
);
let result: WorkspaceGitFetchResult | null = null;
const eventsBeforeFetchSnapshot: FileChange[] = [];
try {
try {
result = await this.deps.runGitFetch(
target.cwd,
{
onRefSnapshot: (phase) => {
const events = target.bufferedFetchMetadataEvents.splice(0);
if (phase === "before") {
eventsBeforeFetchSnapshot.push(...events);
}
},
},
createRunGitCommand("background-fetch"),
);
} catch (error) {
this.logger.warn(
{ err: error, repoGitRoot: target.repoGitRoot, cwd: target.cwd },
"Background git fetch failed",
);
}
this.applyRepoFetchResult(target, result, eventsBeforeFetchSnapshot);
} finally {
target.fetchInFlight = false;
}
}
private applyRepoFetchResult(
target: RepoGitTarget,
result: WorkspaceGitFetchResult | null,
eventsBeforeFetchSnapshot: FileChange[],
): void {
this.flushFetchMetadataEvents(target, eventsBeforeFetchSnapshot);
if (!result || result.changes === null) {
target.recentFetchRemoteRefChanges.clear();
target.knownRemoteRefs = null;
this.flushBufferedFetchMetadataEvents(target);
if (result) {
this.logger.warn(
{ err: result.error, repoGitRoot: target.repoGitRoot, cwd: target.cwd },
"Background git fetch ref classification failed; using structural refresh",
);
}
this.scheduleRepoMetadataRefresh(target, "repo-fetch-unclassified", false);
return;
}
if (result.error) {
this.logger.warn(
{ err: result.error, repoGitRoot: target.repoGitRoot, cwd: target.cwd },
"Background git fetch completed with errors after changing refs",
);
}
const expiresAtMs = this.deps.now().getTime() + FETCH_METADATA_ECHO_TTL_MS;
const remoteRefShapeChanged =
target.knownRemoteRefs !== null &&
result.remoteRefs !== undefined &&
!setsEqual(target.knownRemoteRefs, result.remoteRefs);
if (result.remoteRefs) {
target.knownRemoteRefs = new Set(result.remoteRefs);
}
target.recentFetchRemoteRefChanges.clear();
for (const change of result.changes) {
target.recentFetchRemoteRefChanges.set(change.ref, { change, expiresAtMs });
}
this.flushBufferedFetchMetadataEvents(target);
if (
result.nonRemoteRefsChanged === true ||
remoteRefShapeChanged ||
result.changes.some((change) => change.kind !== "moved")
) {
this.scheduleRepoMetadataRefresh(target, "repo-fetch-ref-shape", false);
return;
}
if (result.changes.length === 0) {
return;
}
const refreshes = new Map<string, RepoMetadataWorkspaceRefresh>();
for (const change of result.changes) {
this.routeRemoteBranchRef(target, change.ref, refreshes, { narrow: true });
}
this.scheduleRepoMetadataRefresh(target, "repo-fetch", false, refreshes);
}
private flushBufferedFetchMetadataEvents(target: RepoGitTarget): void {
this.flushFetchMetadataEvents(target, target.bufferedFetchMetadataEvents.splice(0));
}
private flushFetchMetadataEvents(target: RepoGitTarget, events: FileChange[]): void {
if (events.length === 0) {
return;
}
const routedRefreshes = this.routeRepoMetadataEvents(target, events);
this.scheduleRepoMetadataRefresh(
target,
"git-metadata-watch-after-fetch",
routedRefreshes === null ||
[...routedRefreshes.values()].some((refresh) => refresh.structural),
routedRefreshes,
);
}
private removeWorkspaceListener(cwd: string, listener: WorkspaceGitListener): void {
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 {
this.detachWorkspaceFromRepoTarget(target);
this.closeWorkspaceTarget(target);
this.workspaceTargets.delete(target.cwd);
}
private detachWorkspaceFromRepoTarget(target: WorkspaceGitTarget): void {
if (!target.repoGitRoot) {
return;
}
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;
}
}
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;
this.stopWorkspaceObservation(target);
target.listeners.clear();
}
private stopWorkspaceObservation(target: WorkspaceGitTarget): void {
if (target.workingTreeWatchTarget) {
this.removeWorkspaceWorkingTreeLink(target.workingTreeWatchTarget, target.cwd);
target.workingTreeWatchTarget = null;
}
if (target.debounceTimer) {
clearTimeout(target.debounceTimer);
target.debounceTimer = null;
}
target.pendingDebounceRequest = null;
if (target.observationReensureTimer) {
clearTimeout(target.observationReensureTimer);
target.observationReensureTimer = null;
}
this.stopForgePrStatusPollForTarget(target);
}
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;
}
target.fallbackPolling = false;
if (target.recovery.timer) {
clearTimeout(target.recovery.timer);
target.recovery.timer = null;
}
if (target.ignoreRefreshTimer) {
clearTimeout(target.ignoreRefreshTimer);
target.ignoreRefreshTimer = null;
}
target.ignoreRefreshDeadlineMs = 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;
}
target.fallbackPolling = false;
if (target.recovery.timer) {
clearTimeout(target.recovery.timer);
target.recovery.timer = 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<WorkspaceGitServiceDependencies, "getPullRequestStatus">;
force?: boolean;
reason?: string;
facts?: CheckoutSnapshotFacts;
runGitCommand: RunGitCommand;
}): Promise<WorkspaceGitRuntimeSnapshot["forge"]> {
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, runGitCommand: options.runGitCommand },
);
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,
);
}