import type { GitFileDiff, SessionCapabilities, SessionEvent, WorkspaceCaptureManifest, WorkspaceCaptureRepo, } from "@opengeni/sdk"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { type ClientOverride, useOpenGeni } from "../provider"; import { sandboxAcceptsLiveIo } from "../lib/sandbox-liveness"; export type UseSandboxGitOptions = ClientOverride & { /** Live event log (usually `useSessionEvents().events`) — drives auto-refresh * on `git.changed`. */ events?: SessionEvent[] | undefined; /** Repo root within the workspace (multi-repo). Default: workspace root. */ repoPath?: string | undefined; /** All repository roots in the workspace. When supplied, status and diff are * aggregated into one workspace-wide result and paths are workspace-relative. */ repoPaths?: readonly string[] | undefined; /** Diff the staged index vs HEAD (`--cached`) instead of the working tree. */ staged?: boolean | undefined; /** Review scope. `branch` compares the current branch with the remote default * branch, `working` shows uncommitted edits, and `staged` shows the index. * `staged` above remains as a compatibility alias. */ comparison?: SandboxGitComparison | undefined; /** Hold off the initial fetch. Default true. */ enabled?: boolean | undefined; /** Pause live reads/invalidation while preserving the last rendered result. */ active?: boolean | undefined; /** Lease liveness. `null` means capability negotiation is still pending and * blocks live I/O. When not warm, the diff is served from the capture instead * of a live `gitDiff` RPC. Omission preserves legacy direct-hook behavior. */ liveness?: SessionCapabilities["liveness"] | null | undefined; /** The latest turn-end capture (from `useWorkspaceCapture`). Seeds the diff * immediately; a warm box reconciles live and leaves this durable review * surface in place if the live request is temporarily unavailable. */ capture?: WorkspaceCaptureManifest | null | undefined; }; export type SandboxGitComparison = "branch" | "working" | "staged"; /** A Git diff qualified into workspace scope. `repoRoot` is present for the * workspace-wide path and lets review UI group real repositories without * guessing from the first path segment. */ export type SandboxGitFileDiff = GitFileDiff & { repoRoot?: string | undefined; }; export type UseSandboxGitResult = { /** Working-tree (or staged) diff vs HEAD — the structured hunks the Pierre * diff view renders. */ diff: SandboxGitFileDiff[]; branch: string | null; /** Whether a repo is actually mounted (drives "no repository" vs "no changes"). */ isRepo: boolean; ahead: number; behind: number; /** Number and workspace-relative roots of repositories represented here. */ repoCount: number; repoRoots: string[]; refresh: () => Promise; /** Which source the diff is served from: the live box or the turn-end capture. */ source: "live" | "capture" | null; /** When the served capture was taken (ISO), when `source === "capture"`. */ capturedAt: string | null; loading: boolean; error: Error | null; }; /** Normalize a repo root for matching ("" and "." both mean the workspace root). */ function normalizeRoot(root: string): string { const normalized = root .replaceAll("\\", "/") .replace(/^\.\/+/, "") .replace(/\/+$/, ""); return normalized === "." ? "" : normalized; } function qualifyPath(repoRoot: string, path: string): string { const root = normalizeRoot(repoRoot); return root ? `${root}/${path}` : path; } function qualifyDiff(repoRoot: string, file: GitFileDiff): SandboxGitFileDiff { const root = normalizeRoot(repoRoot); return { ...file, path: qualifyPath(root, file.path), oldPath: file.oldPath === null ? null : qualifyPath(root, file.oldPath), repoRoot: root, }; } function uniqueRoots(roots: readonly string[]): string[] { return [...new Set(roots.map(normalizeRoot))].sort((a, b) => a.localeCompare(b)); } // GitReadBatchRequest is contract-bounded to 32 items. Keep workspace-wide // reads bounded without changing the wire limit; sequential chunks preserve // deterministic root/result mapping and cap Channel-A lease concurrency at one. const GIT_READ_BATCH_REQUEST_LIMIT = 32; /** Find the capture repo matching `repoPath` (default workspace root). */ function repoForPath( manifest: WorkspaceCaptureManifest, repoPath: string, ): WorkspaceCaptureRepo | null { const want = normalizeRoot(repoPath); const exact = manifest.repos.find((r) => normalizeRoot(r.root) === want); if (exact) return exact; // Root-scoped hook against a single-repo capture whose root isn't literally "": // fall back to the sole repo so the common single-repo case just works. if (want === "" && manifest.repos.length === 1) return manifest.repos[0] ?? null; return null; } /** Full diff equality for identity preservation. Hunk count/count summaries are * not sufficient: an edit can replace text without changing either shape. */ function sameFileDiff(a: SandboxGitFileDiff, b: SandboxGitFileDiff): boolean { if ( a.path !== b.path || a.oldPath !== b.oldPath || a.repoRoot !== b.repoRoot || a.status !== b.status || a.isBinary !== b.isBinary || a.isImage !== b.isImage || a.additions !== b.additions || a.deletions !== b.deletions || a.truncated !== b.truncated || a.hunks.length !== b.hunks.length ) return false; return a.hunks.every((hunk, hunkIndex) => { const other = b.hunks[hunkIndex]; if ( !other || hunk.oldStart !== other.oldStart || hunk.oldLines !== other.oldLines || hunk.newStart !== other.newStart || hunk.newLines !== other.newLines || hunk.header !== other.header || hunk.lines.length !== other.lines.length ) return false; return hunk.lines.every((line, lineIndex) => { const otherLine = other.lines[lineIndex]; return ( otherLine !== undefined && line.type === otherLine.type && line.oldNo === otherLine.oldNo && line.newNo === otherLine.newNo && line.text === otherLine.text ); }); }); } /** Merge a freshly-served diff over the current one, preserving the identity of * genuinely unchanged per-file entries so a cold→warm reconcile does NOT * remount unchanged file sections — no-flicker (§12-D1). */ function mergeDiffs( current: SandboxGitFileDiff[], next: SandboxGitFileDiff[], ): SandboxGitFileDiff[] { if (current.length === 0) return next; const byPath = new Map(current.map((d) => [d.path, d] as const)); let changed = current.length !== next.length; const merged = next.map((file, index) => { const existing = byPath.get(file.path); if (existing && sameFileDiff(existing, file)) { if (current[index] !== existing) changed = true; return existing; } changed = true; return file; }); return changed ? merged : current; } /** * Project the Git service into the Pierre diff data contract: structured * `GitFileDiff[]` (per-file hunks with per-line old/new numbers, rename * detection, binary flag, add/del counts) plus branch + ahead/behind. When the * box is warm the `git diff` runs in-box (API-direct); when it is cold/offline the * diff is served from the turn-end capture instead. Refreshes on * `git.changed`; reconciles live in place on the cold→warm transition. */ export function useSandboxGit( sessionId: string | null | undefined, options: UseSandboxGitOptions = {}, ): UseSandboxGitResult { const { client, workspaceId } = useOpenGeni(options); const enabled = (options.enabled ?? true) && Boolean(sessionId); const active = options.active ?? true; const repoPath = options.repoPath ?? ""; const workspaceWide = options.repoPaths !== undefined; // Serialize the caller's array so inline values do not retrigger effects. An // explicitly empty advertised list falls back to the legacy repoPath probe. const repoPathsKey = uniqueRoots( options.repoPaths && options.repoPaths.length > 0 ? options.repoPaths : [repoPath], ).join("\u0000"); const repoPaths = useMemo(() => repoPathsKey.split("\u0000"), [repoPathsKey]); const comparison: SandboxGitComparison = options.comparison ?? (options.staged ? "staged" : "working"); const hasAdvertisedRepoPaths = (options.repoPaths?.length ?? 0) > 0; // New captures retain both working-tree and branch comparisons. Staged state // remains live-only; legacy captures simply lack branchDiff and fall back via // the workspace controller without inventing an empty branch comparison. const capture = comparison === "staged" ? null : (options.capture ?? null); const isLive = sandboxAcceptsLiveIo(options.liveness); // `liveness` is optional for compatibility with direct hook consumers. Explicit // `null` (negotiating) and lifecycle values require authoritative warm. const acceptsEventReads = options.liveness === undefined || isLive; const acceptsEventReadsRef = useRef(acceptsEventReads); acceptsEventReadsRef.current = acceptsEventReads; const hasLiveIoFence = options.liveness !== undefined; const identityKey = `${workspaceId}\u0000${sessionId ?? ""}\u0000${repoPathsKey}\u0000${workspaceWide}\u0000${comparison}`; const [diff, setDiff] = useState([]); const [branch, setBranch] = useState(null); const [isRepo, setIsRepo] = useState(false); const [ahead, setAhead] = useState(0); const [behind, setBehind] = useState(0); const [repoRoots, setRepoRoots] = useState([]); const [source, setSource] = useState<"live" | "capture" | null>(null); const sourceRef = useRef<"live" | "capture" | null>(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [stateIdentity, setStateIdentity] = useState(identityKey); // Live requests and event cursors are identity-scoped. The refs deliberately // survive renders, so they must be fenced/reset when that identity changes. const refreshGenerationRef = useRef(0); const refreshAbortRef = useRef(null); const refreshInFlightRef = useRef | null>(null); const refreshPendingRef = useRef(false); const refreshWorkerEpochRef = useRef(0); const lastChangeRef = useRef(0); const eventRefreshTimerRef = useRef | null>(null); const performRefresh = useCallback(async () => { if (!sessionId) return; const refreshAbort = new AbortController(); refreshAbortRef.current = refreshAbort; const generation = (refreshGenerationRef.current += 1); setLoading(true); setError(null); try { const diffRequest = (root: string) => client.gitDiff( workspaceId, sessionId, { path: root, ...(comparison === "branch" ? { fromRef: "origin/HEAD", includeUntracked: true } : comparison === "working" ? { fromRef: "HEAD", includeUntracked: true } : { staged: true, includeUntracked: false }), }, { signal: refreshAbort.signal }, ); // Capability-advertised roots are known repositories. Fetch status metadata // and diff hunks together: both are independent Channel-A reads and each can // take several seconds on a remote sandbox. The legacy root probe remains // serial so a non-repository workspace never receives a failing git diff. const prefetched = hasAdvertisedRepoPaths ? await (async () => { const prefetchedRepos: Array<{ root: string; status: Awaited>; result: Awaited>; }> = []; for ( let offset = 0; offset < repoPaths.length; offset += GIT_READ_BATCH_REQUEST_LIMIT ) { const roots = repoPaths.slice(offset, offset + GIT_READ_BATCH_REQUEST_LIMIT); const batch = await client.gitReadBatch( workspaceId, sessionId, { requests: roots.map((root) => ({ status: { path: root }, diff: { path: root, ...(comparison === "branch" ? { fromRef: "origin/HEAD", includeUntracked: true } : comparison === "working" ? { fromRef: "HEAD", includeUntracked: true } : { staged: true, includeUntracked: false }), }, })), }, { signal: refreshAbort.signal }, ); if (batch.results.length !== roots.length) { throw new Error( `Workspace Git batch returned ${batch.results.length} results for ${roots.length} repositories.`, ); } batch.results.forEach((item, index) => { prefetchedRepos.push({ root: roots[index]!, status: item.status, result: item.diff ?? { files: [], revision: item.status.revision, }, }); }); } return prefetchedRepos; })() : null; const statuses = prefetched ?? (await Promise.all( repoPaths.map(async (root) => ({ root, status: await client.gitStatus( workspaceId, sessionId, { path: root }, { signal: refreshAbort.signal }, ), })), )); if (refreshGenerationRef.current !== generation) return; const repositories = statuses.filter(({ status }) => status.isRepo); const roots = repositories.map(({ root }) => root); setRepoRoots(roots); setIsRepo(repositories.length > 0); setBranch(repositories.length === 1 ? (repositories[0]?.status.head ?? null) : null); setAhead(repositories.reduce((sum, { status }) => sum + status.ahead, 0)); setBehind(repositories.reduce((sum, { status }) => sum + status.behind, 0)); if (repositories.length === 0) { setDiff([]); sourceRef.current = "live"; setSource("live"); return; } const prefetchedByRoot = new Map(prefetched?.map(({ root, result }) => [root, result])); const results = await Promise.all( repositories.map(async ({ root }) => ({ root, result: prefetchedByRoot.get(root) ?? (await diffRequest(root)), })), ); if (refreshGenerationRef.current !== generation) return; // Merge in place so the cold→warm swap keeps unchanged file sections mounted. setDiff((prev) => mergeDiffs( prev, results.flatMap(({ root, result }) => workspaceWide ? result.files.map((file) => qualifyDiff(root, file)) : result.files, ), ), ); sourceRef.current = "live"; setSource("live"); } catch (cause) { if (refreshGenerationRef.current !== generation) return; setError(cause instanceof Error ? cause : new Error(String(cause))); } finally { if (refreshGenerationRef.current === generation) setLoading(false); if (refreshAbortRef.current === refreshAbort) refreshAbortRef.current = null; } }, [ client, workspaceId, sessionId, repoPaths, hasAdvertisedRepoPaths, workspaceWide, comparison, ]); // Provider reads cannot be undispatched after they reach a remote sandbox. // Keep exactly one refresh in flight and collapse any burst of invalidations // into one trailing read. Aborting and replacing every tool-output refresh // used to leave several Modal reads contending for the same provider lock. const performRefreshRef = useRef(performRefresh); performRefreshRef.current = performRefresh; const refresh = useCallback(async () => { refreshPendingRef.current = true; const existing = refreshInFlightRef.current; if (existing) return await existing; const workerEpoch = refreshWorkerEpochRef.current; const run = (async () => { while (refreshPendingRef.current && refreshWorkerEpochRef.current === workerEpoch) { refreshPendingRef.current = false; await performRefreshRef.current(); } })(); refreshInFlightRef.current = run; try { await run; } finally { if (refreshInFlightRef.current === run) refreshInFlightRef.current = null; } }, []); // Serve the requested review scope from a capture when that scope exists. const seedFromCapture = useCallback( (manifest: WorkspaceCaptureManifest) => { const repositories = workspaceWide ? manifest.repos : ([repoForPath(manifest, repoPath)].filter(Boolean) as WorkspaceCaptureRepo[]); if (comparison === "branch" && repositories.some((repo) => repo.branchDiff === undefined)) { return; } if (repositories.length === 0) { setIsRepo(false); setBranch(null); setAhead(0); setBehind(0); setRepoRoots([]); setDiff((prev) => (prev.length === 0 ? prev : [])); sourceRef.current = "capture"; setSource("capture"); return; } setIsRepo(true); setBranch(repositories.length === 1 ? (repositories[0]?.head ?? null) : null); setAhead(repositories.reduce((sum, repo) => sum + repo.ahead, 0)); setBehind(repositories.reduce((sum, repo) => sum + repo.behind, 0)); setRepoRoots(repositories.map((repo) => normalizeRoot(repo.root))); setDiff((prev) => mergeDiffs( prev, repositories.flatMap((repo) => { const capturedDiff = comparison === "branch" ? (repo.branchDiff ?? []) : repo.diff; return workspaceWide ? capturedDiff.map((file) => qualifyDiff(repo.root, file)) : capturedDiff; }), ), ); sourceRef.current = "capture"; setSource("capture"); setError(null); }, [comparison, repoPath, workspaceWide], ); // Source selection on mount / liveness / capture-revision change. Key on the // capture REVISION (a primitive), not the manifest object, so a fresh object per // render can't spin the effect; the latest manifest is read from a ref. const captureRef = useRef(capture); captureRef.current = capture; const captureRevision = capture?.revision ?? null; const previousIdentityRef = useRef(identityKey); useEffect(() => { // A liveness/capture source change supersedes any older live request. Only a // true data-identity change clears rendered state and the event high-water mark. refreshAbortRef.current?.abort(); refreshAbortRef.current = null; refreshPendingRef.current = false; refreshGenerationRef.current += 1; const identityChanged = previousIdentityRef.current !== identityKey; previousIdentityRef.current = identityKey; if (identityChanged) { // Different sessions target different provider identities. Fence the old // worker and let the new session load immediately instead of waiting for a // stuck request that has already been aborted. refreshWorkerEpochRef.current += 1; refreshInFlightRef.current = null; } if (identityChanged || !enabled) { lastChangeRef.current = 0; setDiff([]); setStateIdentity(identityKey); setIsRepo(false); setBranch(null); setAhead(0); setBehind(0); setRepoRoots([]); sourceRef.current = null; setSource(null); setLoading(false); setError(null); } if (!enabled) { return; } const currentCapture = captureRevision !== null ? captureRef.current : null; if (currentCapture && (identityChanged || !isLive || sourceRef.current !== "live")) { seedFromCapture(currentCapture); } if (!active) { if (!active) setLoading(false); return; } if (isLive || (!hasLiveIoFence && !currentCapture)) { void refresh(); } return () => { refreshAbortRef.current?.abort(); refreshAbortRef.current = null; refreshPendingRef.current = false; refreshGenerationRef.current += 1; }; }, [ enabled, active, isLive, hasLiveIoFence, captureRevision, identityKey, refresh, seedFromCapture, ]); // Structured Git changes are debounced into one live reconcile. Generic // command/tool output performs no provider reads: the canonical turn-end // capture advances `captureRevision`, which refreshes after the burst settles. const events = options.events; useEffect(() => { if (!enabled || !events) { if (eventRefreshTimerRef.current) { clearTimeout(eventRefreshTimerRef.current); eventRefreshTimerRef.current = null; } return; } // Historical events describe the capture we already loaded. Inactive tabs // and holderless draining/cold leases advance the cursor without issuing // provider I/O; becoming warm performs one authoritative reconcile above. if (!active || !acceptsEventReads) { for (const event of events) { if (event.sequence > lastChangeRef.current) lastChangeRef.current = event.sequence; } if (eventRefreshTimerRef.current) { clearTimeout(eventRefreshTimerRef.current); eventRefreshTimerRef.current = null; } return; } let latest = lastChangeRef.current; let changed = false; for (const event of events) { if (event.sequence <= latest) continue; if (event.type === "git.changed") { latest = event.sequence; changed = true; } } if (latest > lastChangeRef.current) { lastChangeRef.current = latest; if (!changed) return; if (eventRefreshTimerRef.current) clearTimeout(eventRefreshTimerRef.current); eventRefreshTimerRef.current = setTimeout(() => { eventRefreshTimerRef.current = null; if (acceptsEventReadsRef.current) void refresh(); }, 150); } }, [enabled, active, acceptsEventReads, events, refresh]); useEffect( () => () => { if (eventRefreshTimerRef.current) clearTimeout(eventRefreshTimerRef.current); }, [], ); const identityMatches = enabled && stateIdentity === identityKey; const visibleRepoRoots = identityMatches ? repoRoots : []; return { diff: identityMatches ? diff : [], branch: identityMatches ? branch : null, isRepo: identityMatches && isRepo, ahead: identityMatches ? ahead : 0, behind: identityMatches ? behind : 0, repoCount: visibleRepoRoots.length, repoRoots: visibleRepoRoots, refresh, source: identityMatches ? source : null, capturedAt: identityMatches && source === "capture" ? (capture?.capturedAt ?? null) : null, loading: identityMatches && loading, error: identityMatches ? error : null, }; }