fix(ui): reconcile git state after worktree changes

This commit is contained in:
Iuliia Ivashko
2026-09-04 16:41:39 +03:00
parent bde6fed8a2
commit 94c90a16b6
20 changed files with 438 additions and 165 deletions
@@ -198,6 +198,7 @@ export function useDraftTarget(enabled: boolean) {
// about, such as the New Worktree dialog), so the probe never reads the
// transient bootstrap files as the branch being dirty.
const selectedDraftDirectoryBootstrapPending = useWorktreeBootstrapPending(selectedDraftDirectory);
const draftDirectoryNeedsFreshStatusRef = React.useRef<string | null>(null);
React.useEffect(() => {
if (
@@ -208,14 +209,24 @@ export function useDraftTarget(enabled: boolean) {
|| newSessionDraft?.bootstrapPendingDirectory
|| selectedDraftDirectoryBootstrapPending
) {
if (selectedDraftDirectoryBootstrapPending && selectedDraftDirectory) {
draftDirectoryNeedsFreshStatusRef.current = selectedDraftDirectory;
}
setDirtyDraftDirectory(null);
return;
}
let cancelled = false;
setDirtyDraftDirectory(null);
getGitStatus(selectedDraftDirectory, { mode: 'light' })
const needsFreshStatus = draftDirectoryNeedsFreshStatusRef.current === selectedDraftDirectory;
const statusRequest = needsFreshStatus
? getGitStatus(selectedDraftDirectory, { mode: 'light', fresh: true })
: getGitStatus(selectedDraftDirectory, { mode: 'light' });
statusRequest
.then((status) => {
if (!cancelled && needsFreshStatus && draftDirectoryNeedsFreshStatusRef.current === selectedDraftDirectory) {
draftDirectoryNeedsFreshStatusRef.current = null;
}
if (!cancelled && (status.files?.length ?? 0) > 0) {
setDirtyDraftDirectory(selectedDraftDirectory);
}
@@ -14,7 +14,6 @@ import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useSessionMessageRecords, useEnsureSessionMessages } from '@/sync/sync-context';
import { useUIStore } from '@/stores/useUIStore';
import { sessionEvents } from '@/lib/sessionEvents';
import { ScrollShadow } from '@/components/ui/ScrollShadow';
import { Button } from '@/components/ui/button';
import { toast } from '@/components/ui';
@@ -57,7 +56,6 @@ import {
extractFirstChangedLineFromDiff,
getDiffPatchEntries,
getFirstChangedLineFromMetadata,
getMutatedToolPaths,
getPatchText,
getPrimaryDiffFromMetadata,
getPrimaryToolPath,
@@ -108,14 +106,6 @@ const normalizeToolName = (toolName: string | undefined | null): string => {
return trimmed;
};
const GIT_REFRESH_MUTATING_TOOLS = new Set([
'bash',
'edit',
'write',
'apply_patch',
'patch',
]);
const formatDuration = (start: number, end?: number, now: number = Date.now()) => {
const duration = Math.max(0, (end ?? now) - start);
const seconds = duration / 1000;
@@ -1699,19 +1689,16 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
const status = state?.status as string | undefined;
const isFinalized = status === 'completed' || status === 'error' || status === 'aborted' || status === 'failed' || status === 'timeout' || status === 'cancelled';
const isSuccessfullyFinalized = status === 'completed';
const isError = status === 'error' || status === 'failed';
const [activeLatched, setActiveLatched] = React.useState<boolean>(!isFinalized);
const previousPartIdRef = React.useRef<string | undefined>(part.id);
const observedActiveGitToolRef = React.useRef(!isFinalized);
React.useEffect(() => {
if (previousPartIdRef.current === part.id) {
return;
}
previousPartIdRef.current = part.id;
observedActiveGitToolRef.current = !isFinalized;
// Reset latch only when tool identity changes.
setActiveLatched(!isFinalized);
}, [isFinalized, part.id]);
@@ -1722,36 +1709,6 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
}
}, [isFinalized]);
React.useEffect(() => {
if (!isFinalized) {
observedActiveGitToolRef.current = true;
return;
}
// Historical completed tools can remount when the timeline changes.
// Refresh only for a tool whose active state this instance observed.
const finalizedAfterObservedActive = observedActiveGitToolRef.current;
if (!finalizedAfterObservedActive) {
return;
}
if (!isSuccessfullyFinalized || !GIT_REFRESH_MUTATING_TOOLS.has(normalizedPartTool)) {
observedActiveGitToolRef.current = false;
return;
}
if (!currentDirectory) {
return;
}
observedActiveGitToolRef.current = false;
const paths = getMutatedToolPaths(normalizedPartTool, input, metadata)
.map((path) => getRelativePath(path, currentDirectory));
sessionEvents.requestGitRefresh({
directory: currentDirectory,
...(paths.length > 0 ? { paths } : {}),
});
}, [currentDirectory, input, isFinalized, isSuccessfullyFinalized, metadata, normalizedPartTool]);
const expandedContentRef = React.useRef<HTMLDivElement>(null);
React.useLayoutEffect(() => {
@@ -5,7 +5,6 @@ import {
getApplyPatchFilePath,
getDiffPatchEntries,
getFirstChangedLineFromMetadata,
getMutatedToolPaths,
getPrimaryDiffFromMetadata,
getPrimaryToolPath,
getRenderablePatchInfo,
@@ -57,28 +56,6 @@ describe('toolDiffUtils', () => {
})).toBe('/workspace/project/src/second.ts');
});
test('lists every apply_patch mutation path, including both sides of a move', () => {
expect(getMutatedToolPaths('apply_patch', undefined, {
files: [
{ filePath: '/workspace/project/src/deleted.ts', type: 'delete' },
{
filePath: '/workspace/project/src/old.ts',
movePath: '/workspace/project/src/new.ts',
type: 'move',
},
],
})).toEqual([
'/workspace/project/src/deleted.ts',
'/workspace/project/src/new.ts',
'/workspace/project/src/old.ts',
]);
});
test('does not invent paths for bash or task tools', () => {
expect(getMutatedToolPaths('bash', { command: 'date' }, undefined)).toEqual([]);
expect(getMutatedToolPaths('task', { description: 'inspect' }, undefined)).toEqual([]);
});
test('selects the move patch and line from the same non-deleted file', () => {
const deletedPatch = '@@ -3 +3 @@\n-old\n+deleted';
const movedPatch = '@@ -42 +42 @@\n-before\n+after';
@@ -200,29 +200,6 @@ export const getPrimaryToolPath = (
return null;
};
export const getMutatedToolPaths = (
toolName: string,
input: Record<string, unknown> | undefined,
metadata: Record<string, unknown> | undefined,
): string[] => {
if (toolName === 'apply_patch') {
const files = Array.isArray(metadata?.files) ? metadata.files : [];
const paths = new Set<string>();
for (const file of files) {
if (!isRecord(file)) continue;
const filePath = getApplyPatchFilePath(file);
if (filePath) paths.add(filePath);
if (file.type === 'move' && typeof file.filePath === 'string') {
paths.add(file.filePath);
}
}
return [...paths];
}
const primaryPath = getPrimaryToolPath(toolName, input, metadata);
return primaryPath ? [primaryPath] : [];
};
const supportsDiffMetadata = (toolName: string): boolean => (
toolName === 'edit' || toolName === 'multiedit' || toolName === 'apply_patch'
);
@@ -90,12 +90,17 @@ export const WorkStatusPrimaryGroup: React.FC<Props> = ({ sessionId, directory,
}
if (awaitingPostBootstrapStatus) {
let cancelled = false;
void runBackgroundNetworkTask(() => fetchStatus(gitDirectory, git, { silent: true }))
.finally(() => {
void runBackgroundNetworkTask(() => fetchStatus(gitDirectory, git, {
force: true,
silent: true,
throwOnError: true,
}))
.then(() => {
if (!cancelled) {
setPostBootstrapRefreshDirectory((current) => (current === gitDirectory ? null : current));
}
});
})
.catch(() => undefined);
return () => {
cancelled = true;
};
+123 -46
View File
@@ -20,6 +20,7 @@ import {
useGitLoadingLog,
} from '@/stores/useGitStore';
import { useNestedGitDirectory } from '@/hooks/useNestedGitDirectory';
import { useWorktreeBootstrapPending } from '@/hooks/useWorktreeBootstrapPending';
import { NestedRepoResolutionStates } from './git/NestedRepoResolutionStates';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { ScrollShadow } from '@/components/ui/ScrollShadow';
@@ -203,8 +204,14 @@ export const GitView: React.FC<GitViewProps> = ({ isActive }) => {
const { t } = useI18n();
const { git } = useRuntimeAPIs();
const currentDirectory = useEffectiveDirectory();
const [worktreeBootstrapStatus, setWorktreeBootstrapStatus] = React.useState<'pending' | 'ready' | 'failed' | null>(null);
const [isWaitingForGitRefreshAfterBootstrap, setIsWaitingForGitRefreshAfterBootstrap] = React.useState(false);
const [worktreeBootstrapSnapshot, setWorktreeBootstrapSnapshot] = React.useState<{
directory: string;
status: 'pending' | 'ready' | 'failed' | null;
} | null>(null);
const [postBootstrapRefresh, setPostBootstrapRefresh] = React.useState<{
directory: string;
status: 'refreshing' | 'failed';
} | null>(null);
const currentSessionId = useSessionUIStore((s) => s.currentSessionId);
const newSessionDraft = useSessionUIStore((s) => s.newSessionDraft);
const setDraftBootstrapPendingDirectory = useSessionUIStore((s) => s.setDraftBootstrapPendingDirectory);
@@ -329,7 +336,6 @@ export const GitView: React.FC<GitViewProps> = ({ isActive }) => {
});
const navigateToDiff = useUIStore((state) => state.navigateToDiff);
const previousBootstrapStatusRef = React.useRef<'pending' | 'ready' | 'failed' | null>(null);
const gitReconcileTimeoutRef = React.useRef<number | null>(null);
const gitMutationFlushTimeoutRef = React.useRef<number | null>(null);
const flushQueuedGitMutationsRef = React.useRef<(() => void) | null>(null);
@@ -438,11 +444,13 @@ export const GitView: React.FC<GitViewProps> = ({ isActive }) => {
React.useEffect(() => {
if (!isActive) return;
if (!currentDirectory) {
setWorktreeBootstrapStatus(null);
setIsWaitingForGitRefreshAfterBootstrap(false);
setWorktreeBootstrapSnapshot(null);
return;
}
const bootstrapDirectory = normalizePath(currentDirectory) ?? currentDirectory;
setWorktreeBootstrapSnapshot({ directory: bootstrapDirectory, status: null });
let cancelled = false;
let timeoutId: number | null = null;
@@ -452,7 +460,7 @@ export const GitView: React.FC<GitViewProps> = ({ isActive }) => {
if (cancelled) {
return;
}
setWorktreeBootstrapStatus(next.status);
setWorktreeBootstrapSnapshot({ directory: bootstrapDirectory, status: next.status });
if (next.status === 'pending') {
timeoutId = window.setTimeout(() => {
void poll();
@@ -460,7 +468,7 @@ export const GitView: React.FC<GitViewProps> = ({ isActive }) => {
}
} catch {
if (!cancelled) {
setWorktreeBootstrapStatus(null);
setWorktreeBootstrapSnapshot({ directory: bootstrapDirectory, status: null });
}
}
};
@@ -475,37 +483,84 @@ export const GitView: React.FC<GitViewProps> = ({ isActive }) => {
};
}, [isActive, currentDirectory]);
React.useEffect(() => {
const previous = previousBootstrapStatusRef.current;
previousBootstrapStatusRef.current = worktreeBootstrapStatus;
if (!currentDirectory || !git) {
return;
}
if (previous === 'pending' && worktreeBootstrapStatus === 'ready') {
setIsWaitingForGitRefreshAfterBootstrap(true);
void fetchAll(currentDirectory, git).finally(() => {
window.setTimeout(() => {
setIsWaitingForGitRefreshAfterBootstrap(false);
}, 1200);
});
}
if (worktreeBootstrapStatus === 'failed') {
setDraftBootstrapPendingDirectory(null);
setIsWaitingForGitRefreshAfterBootstrap(false);
}
}, [currentDirectory, fetchAll, git, setDraftBootstrapPendingDirectory, worktreeBootstrapStatus]);
const normalizedDraftBootstrapPendingDirectory = normalizePath(newSessionDraft?.bootstrapPendingDirectory ?? null);
const isDraftBootstrapPendingForCurrentDirectory = Boolean(
currentDirectory && normalizedDraftBootstrapPendingDirectory && normalizedDraftBootstrapPendingDirectory === normalizePath(currentDirectory)
);
const sharedWorktreeBootstrapPending = useWorktreeBootstrapPending(currentDirectory ?? null);
const normalizedCurrentBootstrapDirectory = normalizePath(currentDirectory);
const observedWorktreeBootstrapStatus = worktreeBootstrapSnapshot?.directory === normalizedCurrentBootstrapDirectory
? worktreeBootstrapSnapshot.status
: null;
const isPendingWorktreeSetup = Boolean(
currentDirectory && (worktreeBootstrapStatus === 'pending' || isDraftBootstrapPendingForCurrentDirectory)
currentDirectory
&& (
sharedWorktreeBootstrapPending
|| observedWorktreeBootstrapStatus === 'pending'
|| (isDraftBootstrapPendingForCurrentDirectory && newSessionDraft?.pendingWorktreeRequestId)
)
);
const shouldHideNotGitState = isPendingWorktreeSetup || isWaitingForGitRefreshAfterBootstrap;
const isPostBootstrapRefreshForCurrentDirectory = Boolean(
normalizedCurrentBootstrapDirectory
&& postBootstrapRefresh?.directory === normalizedCurrentBootstrapDirectory
);
React.useEffect(() => {
if (!normalizedCurrentBootstrapDirectory) return;
if (observedWorktreeBootstrapStatus === 'failed') {
setDraftBootstrapPendingDirectory(null);
setPostBootstrapRefresh((current) => (
current?.directory === normalizedCurrentBootstrapDirectory ? null : current
));
return;
}
if (isPendingWorktreeSetup) {
setPostBootstrapRefresh((current) => (
current?.directory === normalizedCurrentBootstrapDirectory && current.status === 'refreshing'
? current
: { directory: normalizedCurrentBootstrapDirectory, status: 'refreshing' }
));
return;
}
if (
postBootstrapRefresh?.directory !== normalizedCurrentBootstrapDirectory
|| postBootstrapRefresh.status !== 'refreshing'
|| !gitDirectory
|| !git
) {
return;
}
let cancelled = false;
void fetchStatus(gitDirectory, git, {
force: true,
silent: true,
throwOnError: true,
}).then(() => {
if (cancelled) return;
setPostBootstrapRefresh((current) => (
current?.directory === normalizedCurrentBootstrapDirectory ? null : current
));
}).catch(() => {
if (cancelled) return;
setPostBootstrapRefresh((current) => (
current?.directory === normalizedCurrentBootstrapDirectory
? { ...current, status: 'failed' }
: current
));
});
return () => {
cancelled = true;
};
}, [fetchStatus, git, gitDirectory, isPendingWorktreeSetup, normalizedCurrentBootstrapDirectory, observedWorktreeBootstrapStatus, postBootstrapRefresh, setDraftBootstrapPendingDirectory]);
const shouldHideGitState = isPendingWorktreeSetup || isPostBootstrapRefreshForCurrentDirectory;
const postBootstrapRefreshFailed = isPostBootstrapRefreshForCurrentDirectory
&& postBootstrapRefresh?.status === 'failed';
const initialSnapshot = React.useMemo(() => {
if (!gitDirectory) return null;
@@ -2336,6 +2391,42 @@ export const GitView: React.FC<GitViewProps> = ({ isActive }) => {
);
}
if (shouldHideGitState) {
return (
<div className="flex h-full flex-col items-center justify-center px-4 text-center">
{!postBootstrapRefreshFailed ? (
<Icon name="loader-4" className="mb-3 size-6 animate-spin text-muted-foreground" />
) : null}
<p className="typography-ui-label font-semibold text-foreground">
{postBootstrapRefreshFailed
? t('gitView.toast.refreshRepositoryFailed')
: t('gitView.empty.worktreeSetupInProgress')}
</p>
{!postBootstrapRefreshFailed ? (
<p className="typography-meta mt-1 text-muted-foreground">
{t('gitView.empty.worktreeSetupDescription')}
</p>
) : (
<Button
type="button"
variant="outline"
size="sm"
className="mt-3"
onClick={() => {
if (!normalizedCurrentBootstrapDirectory) return;
setPostBootstrapRefresh({
directory: normalizedCurrentBootstrapDirectory,
status: 'refreshing',
});
}}
>
{t('gitView.empty.retryDiscovery')}
</Button>
)}
</div>
);
}
if (isGitRepo === null || (isGitRepo === true && !status)) {
return (
<div className="flex h-full items-center justify-center">
@@ -2348,20 +2439,6 @@ export const GitView: React.FC<GitViewProps> = ({ isActive }) => {
}
if (isGitRepo === false) {
if (shouldHideNotGitState) {
return (
<div className="flex h-full flex-col items-center justify-center px-4 text-center">
<Icon name="loader-4" className="mb-3 size-6 animate-spin text-muted-foreground" />
<p className="typography-ui-label font-semibold text-foreground">
{t('gitView.empty.worktreeSetupInProgress')}
</p>
<p className="typography-meta mt-1 text-muted-foreground">
{t('gitView.empty.worktreeSetupDescription')}
</p>
</div>
);
}
// Nested repository discovery states (discovering, failed, unsupported,
// none found, or settling on the auto-selected repository).
return (
+1 -1
View File
@@ -494,7 +494,7 @@ interface GitWorktreeAPI {
export interface GitAPI {
checkIsGitRepository(directory: string): Promise<boolean>;
getGitStatus(directory: string, options?: { mode?: 'light' }): Promise<GitStatus>;
getGitStatus(directory: string, options?: { mode?: 'light'; fresh?: boolean }): Promise<GitStatus>;
getGitDiff(directory: string, options: GetGitDiffOptions): Promise<GitDiffResponse>;
getGitFileDiff(directory: string, options: GetGitFileDiffOptions): Promise<GitFileDiffResponse>;
getGitRangeDiff?(directory: string, options: GetGitRangeDiffOptions): Promise<GitDiffResponse>;
+1 -1
View File
@@ -84,7 +84,7 @@ export async function checkIsGitRepository(directory: string): Promise<boolean>
return gitHttp.checkIsGitRepository(directory);
}
export async function getGitStatus(directory: string, options?: { mode?: 'light' }): Promise<import('./api/types').GitStatus> {
export async function getGitStatus(directory: string, options?: { mode?: 'light'; fresh?: boolean }): Promise<import('./api/types').GitStatus> {
const runtime = getRuntimeGit();
if (runtime) return runtime.getGitStatus(directory, options);
return gitHttp.getGitStatus(directory, options);
+75
View File
@@ -29,6 +29,7 @@ import {
unstageGitFiles,
} from './gitApiHttp';
import type { GitStatus } from './api/types';
import { sessionEvents } from './sessionEvents';
type FetchCall = {
input: RequestInfo | URL;
@@ -141,6 +142,28 @@ describe('gitApiHttp index mutations', () => {
});
describe('gitApiHttp status cache', () => {
test('a Git refresh hint invalidates the cached status before listeners fetch', async () => {
installWindowMock();
let statusRequestCount = 0;
globalThis.fetch = async () => {
statusRequestCount += 1;
return jsonResponse(statusPayload({ behind: statusRequestCount }));
};
try {
const directory = '/repo-cache-tool-mutation';
const first = await getGitStatus(directory);
sessionEvents.requestGitRefresh({ directory });
const afterMutation = await getGitStatus(directory);
expect(first.behind).toBe(1);
expect(afterMutation.behind).toBe(2);
expect(statusRequestCount).toBe(2);
} finally {
restoreMocks();
}
});
test('invalidates cached status after fetch', async () => {
installWindowMock();
const calls: FetchCall[] = [];
@@ -188,6 +211,58 @@ describe('gitApiHttp status cache', () => {
restoreMocks();
}
});
test('fresh status bypasses an unexpired cached snapshot', async () => {
installWindowMock();
let statusRequestCount = 0;
globalThis.fetch = (async () => {
statusRequestCount += 1;
return jsonResponse(statusPayload({ behind: statusRequestCount }));
}) as typeof fetch;
try {
const directory = '/repo-cache-fresh';
const first = await getGitStatus(directory);
const cached = await getGitStatus(directory);
const fresh = await getGitStatus(directory, { fresh: true });
expect(first.behind).toBe(1);
expect(cached.behind).toBe(1);
expect(fresh.behind).toBe(2);
expect(statusRequestCount).toBe(2);
} finally {
restoreMocks();
}
});
test('fresh status cannot be replaced in cache by an older in-flight response', async () => {
installWindowMock();
const statusResolvers: Array<(response: Response) => void> = [];
// SAFETY: the mock accepts the same arguments as fetch and always returns
// a pending Response promise controlled by this test.
globalThis.fetch = (async () => new Promise<Response>((resolve) => {
statusResolvers.push(resolve);
})) as typeof fetch;
try {
const directory = '/repo-cache-fresh-race';
const older = getGitStatus(directory);
await new Promise((resolve) => setTimeout(resolve, 0));
const fresh = getGitStatus(directory, { fresh: true });
await new Promise((resolve) => setTimeout(resolve, 0));
expect(statusResolvers).toHaveLength(2);
statusResolvers[1](jsonResponse(statusPayload({ current: 'fresh' })));
statusResolvers[0](jsonResponse(statusPayload({ current: 'stale' })));
expect((await fresh).current).toBe('fresh');
expect((await older).current).toBe('stale');
expect((await getGitStatus(directory)).current).toBe('fresh');
expect(statusResolvers).toHaveLength(2);
} finally {
restoreMocks();
}
});
});
const statusPayload = (overrides: Partial<GitStatus> = {}): GitStatus => ({
+15 -4
View File
@@ -40,7 +40,7 @@ import { normalizePath } from './pathNormalization';
import { runtimeFetch } from './runtime-fetch';
import { getRuntimeUrlResolver } from './runtime-url';
import { getRuntimeKey } from './runtime-switch';
import { notifyGitStatusInvalidated } from './gitStatusInvalidation';
import { notifyGitStatusInvalidated, subscribeGitStatusInvalidations } from './gitStatusInvalidation';
const API_BASE = '/api/git';
const GIT_STATUS_CACHE_TTL_MS = 1200;
@@ -60,8 +60,7 @@ const getStatusCacheKey = (runtimeKey: string, directory: string, mode?: 'light'
const getStatusCacheVersion = (runtimeKey: string, directory: string): number =>
gitStatusCacheVersions.get(getDirectoryCacheKey(runtimeKey, directory)) ?? 0;
const invalidateGitStatusCache = (directory: string): void => {
const runtimeKey = getRuntimeKey();
const clearGitStatusCache = (runtimeKey: string, directory: string): void => {
const key = getDirectoryCacheKey(runtimeKey, directory);
gitStatusCacheVersions.set(key, getStatusCacheVersion(runtimeKey, directory) + 1);
for (const mode of [undefined, 'light'] as const) {
@@ -69,6 +68,13 @@ const invalidateGitStatusCache = (directory: string): void => {
gitStatusCache.delete(statusKey);
gitStatusInFlight.delete(statusKey);
}
};
subscribeGitStatusInvalidations((directory) => {
clearGitStatusCache(getRuntimeKey(), directory);
});
const invalidateGitStatusCache = (directory: string): void => {
notifyGitStatusInvalidated(directory);
};
@@ -162,9 +168,14 @@ export async function listGitDirectories(root: string): Promise<string[]> {
.filter((path): path is string => path !== null);
}
export async function getGitStatus(directory: string, options?: { mode?: 'light' }): Promise<GitStatus> {
export async function getGitStatus(directory: string, options?: { mode?: 'light'; fresh?: boolean }): Promise<GitStatus> {
const mode = options?.mode;
const runtimeKey = getRuntimeKey();
if (options?.fresh) {
// A forced read must cross the transport cache boundary too. Advancing the
// version also prevents an older in-flight response from repopulating it.
clearGitStatusCache(runtimeKey, directory);
}
const key = getStatusCacheKey(runtimeKey, directory, mode);
const now = Date.now();
const cached = gitStatusCache.get(key);
+7 -7
View File
@@ -1,18 +1,18 @@
/**
* Minimal notification channel for git status invalidation.
*
* Every successful status-affecting git mutation must call
* Every confirmed status-affecting mutation must call
* `notifyGitStatusInvalidated`. `useGitStore` subscribes and bumps its
* per-directory status mutation revision so an immediate refresh cannot join an
* in-flight status request admitted before the mutation, and a stale response
* cannot commit over newer authoritative state.
* cannot commit over newer authoritative state. The HTTP adapter also subscribes
* and clears its short-lived status cache.
*
* Runtime parity: this is about the store's in-flight status request, not about
* adapter caching, so it applies to every runtime. The HTTP adapter in
* `gitApiHttp.ts` emits it where it clears its own cache; runtime adapters (the
* VS Code bridge) have no cache of their own, so the dispatch layer in
* `gitApi.ts` emits it for them after a successful runtime mutation. Either
* path announces a mutation exactly once.
* adapter caching, so it applies to every runtime. HTTP mutations emit from
* `gitApiHttp.ts`; runtime adapters such as the VS Code bridge emit from the
* dispatch layer in `gitApi.ts`. Tool and editor mutations emit through the
* shared Git refresh hint. Each path announces a mutation exactly once.
*/
type GitStatusInvalidationListener = (directory: string) => void;
+21
View File
@@ -1,4 +1,6 @@
import type { Session } from '@opencode-ai/sdk/v2';
import type { Part } from '@opencode-ai/sdk/v2/client';
import { notifyGitStatusInvalidated } from '@/lib/gitStatusInvalidation';
import type { WorktreeMetadata } from '@/types/worktree';
export type SessionDeleteRequest = {
@@ -24,6 +26,12 @@ const deleteListeners = new Set<DeleteListener>();
const createListeners = new Set<CreateListener>();
const directoryListeners = new Set<DirectoryListener>();
const gitRefreshListeners = new Set<GitRefreshListener>();
const gitMutatingTools = new Set(['bash', 'edit', 'write', 'apply_patch', 'patch']);
const normalizeToolName = (tool: string): string => {
const parts = tool.trim().toLowerCase().split('.').filter(Boolean);
return parts[parts.length - 1] ?? '';
};
export const sessionEvents = {
onDeleteRequest(listener: DeleteListener) {
@@ -67,6 +75,19 @@ export const sessionEvents = {
if (!hint.directory.trim()) {
return;
}
notifyGitStatusInvalidated(hint.directory);
gitRefreshListeners.forEach((listener) => listener(hint));
},
requestGitRefreshForToolTransition(directory: string, previousPart: Part | undefined, nextPart: Part) {
if (nextPart.type !== 'tool' || nextPart.state.status !== 'completed') {
return;
}
if (previousPart?.type === 'tool' && previousPart.state.status === 'completed') {
return;
}
if (!gitMutatingTools.has(normalizeToolName(nextPart.tool))) {
return;
}
sessionEvents.requestGitRefresh({ directory });
},
};
@@ -106,6 +106,7 @@ const {
getLatestWorktreeMetadata,
listProjectWorktrees,
partitionWorktreesByRegisteredProject,
removeProjectWorktree,
validateWorktreeCreate,
worktreeMapsEqual,
} = await import('./worktreeManager');
@@ -365,6 +366,44 @@ describe('worktreeManager list invalidation', () => {
expect(metadata.worktreeStatus).toBe('pending');
expect(getLatestWorktreeMetadata(metadata).worktreeStatus).toBe('ready');
});
test('removes a worktree from sidebar topology owned by another registered checkout', async () => {
const removed: WorktreeMetadata = {
path: '/worktrees/removed',
projectDirectory: '/repo',
branch: 'removed',
label: 'removed',
};
const sibling: WorktreeMetadata = {
path: '/worktrees/sibling',
projectDirectory: '/repo',
branch: 'sibling',
label: 'sibling',
};
const unrelatedEntries: WorktreeMetadata[] = [{
path: '/other/worktree',
projectDirectory: '/other',
branch: 'other',
label: 'other',
}];
sessionState.availableWorktreesByProject = new Map([
['/worktrees/configured', [removed, sibling]],
['/other', unrelatedEntries],
]);
sessionState.availableWorktrees = [removed, sibling, ...unrelatedEntries];
sessionState.worktreeMetadata = new Map([
['removed-session', removed],
['sibling-session', sibling],
]);
await removeProjectWorktree({ id: 'path:/repo', path: '/repo' }, removed);
expect(sessionState.availableWorktreesByProject.get('/worktrees/configured')).toEqual([sibling]);
expect(sessionState.availableWorktreesByProject.get('/other')).toBe(unrelatedEntries);
expect(sessionState.availableWorktrees).toEqual([sibling, ...unrelatedEntries]);
expect(sessionState.worktreeMetadata.has('removed-session')).toBe(false);
expect(sessionState.worktreeMetadata.get('sibling-session')).toBe(sibling);
});
});
describe('worktreeMapsEqual', () => {
@@ -595,14 +595,16 @@ export async function removeProjectWorktree(project: ProjectRef, worktree: Workt
// Update sidebar store so removed worktree disappears immediately
const normalizedWorktreePath = normalizePath(worktree.path);
const sidebarProjectKey = projectDirectory;
const currentByProject = useSessionUIStore.getState().availableWorktreesByProject;
const updatedByProject = new Map(currentByProject);
const projectWorktrees = updatedByProject.get(sidebarProjectKey) ?? [];
updatedByProject.set(
sidebarProjectKey,
projectWorktrees.filter((w) => normalizePath(w.path) !== normalizedWorktreePath),
);
for (const [projectKey, projectWorktrees] of currentByProject) {
const remainingWorktrees = projectWorktrees.filter(
(candidate) => normalizePath(candidate.path) !== normalizedWorktreePath,
);
if (remainingWorktrees.length !== projectWorktrees.length) {
updatedByProject.set(projectKey, remainingWorktrees);
}
}
// Clean up worktreeMetadata for sessions in the removed worktree
const currentMetadata = useSessionUIStore.getState().worktreeMetadata;
+5 -2
View File
@@ -154,7 +154,8 @@ Important properties:
- status, branches, log, identity, repository probes, and prefetch diffs commit through runtime and per-channel generations
- status mutations advance a revision so older refreshes cannot undo optimistic or confirmed index changes
- a successful status-affecting git mutation also advances that revision: the HTTP adapter's cache invalidation notifies the store through `lib/gitStatusInvalidation.ts` (the VS Code bridge adapter has no client-side status cache, so it emits nothing today)
- `fetchAll({ force: true })` forces the status fetch as well as the log refresh
- `fetchStatus({ force: true })` and `fetchAll({ force: true })` cross both the store and runtime transport caches; a forced reconciliation must reach the active runtime rather than reuse an unexpired browser status snapshot
- status requests do not start while a managed worktree bootstrap is pending, and a response admitted before bootstrap began is discarded if it completes after the directory enters `pending`; the `--no-checkout` population window is not user working-tree state
- branch persistence is versioned, bounded, runtime-scoped, and claims the ambiguous legacy cache once
- diff data has per-directory and aggregate count/UTF-8-byte limits; oversized single entries are rejected
@@ -326,9 +327,11 @@ Do not raise limits casually.
Expected model:
- `GitView` / `DiffView` ensure current-directory Git state when visible
- the Git view gates its status-derived content and actions while a managed worktree bootstrap is pending, then keeps the gate closed until one forced fresh status read succeeds; refresh failure exposes retry without revealing the cached bootstrap snapshot
- explicit Git actions refresh status/branches/log as needed
- every status-affecting git mutation invalidates the HTTP adapter's status cache on its success path (failed mutations invalidate nothing), so the follow-up refresh is authoritative instead of the pre-mutation cache entry
- a mounted file-mutating tool issues a one-shot Git refresh hint when it transitions from active to successfully finalized; remounting historical completed tools does not replay the hint
- the sync event handler issues one Git refresh hint when a live file-mutating tool first reaches `completed`; this does not depend on `ToolPart` mounting, and duplicate terminal events do not replay the hint
- every Git refresh hint invalidates the store request generation and the HTTP status cache before visible consumers request status, so they share one post-mutation read instead of accepting a cached or pre-mutation response
- a successful dirty save from the in-app file editor issues a path-scoped Git refresh hint; clean autosave checks remain no-ops
- refresh hints with authoritative file paths invalidate only those cached and currently rendered diffs before status refresh; pathless tools request status reconciliation without broadly remounting DiffView
- targeted diff remounts preserve the user's current file-section anchor and intra-file offset before paint instead of resetting the stacked view to the top
+56 -1
View File
@@ -3,6 +3,7 @@ import type { GitStatus } from '@/lib/api/types';
import { useGitStore } from './useGitStore';
import { getRuntimeKey } from '@/lib/runtime-switch';
import { notifyGitStatusInvalidated } from '@/lib/gitStatusInvalidation';
import { clearWorktreeBootstrapState, markWorktreeBootstrapPending } from '@/lib/worktrees/worktreeBootstrap';
// The real transport has no server in tests and fails as a generic error.
// Tests that exercise other failure modes swap this implementation; the
@@ -86,6 +87,7 @@ const createGitApi = (getGitStatus: GitAPI['getGitStatus']): GitAPI => ({
describe('useGitStore', () => {
beforeEach(() => {
clearWorktreeBootstrapState('/repo');
useGitStore.getState().resetForRuntimeSwitch(getRuntimeKey());
});
@@ -198,8 +200,10 @@ describe('useGitStore', () => {
setDirectoryStatus(createStatus());
const requests: Deferred<GitStatus>[] = [];
let statusCalls = 0;
const git = createGitApi(() => {
const statusOptions: Array<{ mode?: 'light'; fresh?: boolean } | undefined> = [];
const git = createGitApi((_directory, options) => {
statusCalls += 1;
statusOptions.push(options);
const request = createDeferred<GitStatus>();
requests.push(request);
return request.promise;
@@ -212,6 +216,7 @@ describe('useGitStore', () => {
const all = useGitStore.getState().fetchAll('/repo', git, { force: true });
await Promise.resolve();
expect(statusCalls).toBe(2);
expect(statusOptions).toEqual([undefined, { fresh: true }]);
requests[1].resolve({ ...createStatus(), current: 'feature' });
requests[0].resolve(createStatus());
@@ -220,6 +225,56 @@ describe('useGitStore', () => {
expect(useGitStore.getState().getDirectoryState('/repo')?.status?.current).toBe('feature');
});
test('does not request status while worktree bootstrap is pending', async () => {
setDirectoryStatus(createStatus());
let statusCalls = 0;
const git = createGitApi(async () => {
statusCalls += 1;
return createStatus(undefined, [{ path: 'bootstrap.ts', index: 'D', working_dir: ' ' }]);
});
markWorktreeBootstrapPending('/repo');
const changed = await useGitStore.getState().fetchStatus('/repo', git, { force: true });
expect(changed).toBe(false);
expect(statusCalls).toBe(0);
expect(useGitStore.getState().getDirectoryState('/repo')?.status?.files).toEqual([]);
});
test('does not publish a status response after bootstrap becomes pending', async () => {
setDirectoryStatus(createStatus());
const request = createDeferred<GitStatus>();
const git = createGitApi(() => request.promise);
const loading = useGitStore.getState().fetchStatus('/repo', git, { silent: true });
await Promise.resolve();
markWorktreeBootstrapPending('/repo');
request.resolve(createStatus(undefined, [{ path: 'bootstrap.ts', index: 'D', working_dir: ' ' }]));
expect(await loading).toBe(false);
expect(useGitStore.getState().getDirectoryState('/repo')?.status?.files).toEqual([]);
});
test('can propagate a forced status failure to a reconciliation owner', async () => {
setDirectoryStatus(createStatus());
const git = createGitApi(async () => {
throw new Error('offline');
});
const originalConsoleError = console.error;
console.error = () => undefined;
try {
await expect(useGitStore.getState().fetchStatus('/repo', git, {
force: true,
silent: true,
throwOnError: true,
})).rejects.toThrow('offline');
expect(useGitStore.getState().getDirectoryState('/repo')?.status?.files).toEqual([]);
} finally {
console.error = originalConsoleError;
}
});
test('does not let an older status fetch undo an optimistic mutation', async () => {
const initial = createStatus(undefined, [{ path: 'src/index.ts', index: ' ', working_dir: 'M' }]);
setDirectoryStatus(initial);
+20 -3
View File
@@ -11,6 +11,7 @@ import { getDeferredSafeStorage } from '@/stores/utils/safeStorage';
import { getRuntimeKey } from '@/lib/runtime-switch';
import { GitDirectoriesUnsupportedError, listGitDirectories } from '@/lib/gitApiHttp';
import { subscribeGitStatusInvalidations } from '@/lib/gitStatusInvalidation';
import { getWorktreeBootstrapState } from '@/lib/worktrees/worktreeBootstrap';
const LOG_STALE_THRESHOLD = 10000;
const REPO_CHECK_STALE_THRESHOLD = 60_000;
@@ -28,6 +29,7 @@ const DIFF_CACHE_MAX_ENTRIES = 30;
const DIFF_CACHE_MAX_TOTAL_SIZE_BYTES = 20 * 1024 * 1024; // 20MB
const DIFF_CACHE_MAX_GLOBAL_ENTRIES = 200;
type GitStatusFetchMode = 'full' | 'light';
type GitStatusRequestOptions = { mode?: 'light'; fresh?: boolean };
// Discovery outcome for a root that is not itself a git repository. The three
// states are mutually exclusive: a repository list (possibly empty), a failed
@@ -64,7 +66,7 @@ interface GitStore {
setActiveDirectory: (directory: string | null) => void;
getDirectoryState: (directory: string) => DirectoryGitState | null;
fetchStatus: (directory: string, git: GitAPI, options?: { silent?: boolean; mode?: 'light'; force?: boolean }) => Promise<boolean>;
fetchStatus: (directory: string, git: GitAPI, options?: { silent?: boolean; mode?: 'light'; force?: boolean; throwOnError?: boolean }) => Promise<boolean>;
fetchBranches: (directory: string, git: GitAPI) => Promise<void>;
fetchLog: (directory: string, git: GitAPI, maxCount?: number) => Promise<void>;
fetchIdentity: (directory: string, git: GitAPI) => Promise<void>;
@@ -115,7 +117,7 @@ interface GitFileDiffResponse {
interface GitAPI {
checkIsGitRepository: (directory: string) => Promise<boolean>;
getGitStatus: (directory: string, options?: { mode?: 'light' }) => Promise<GitStatus>;
getGitStatus: (directory: string, options?: GitStatusRequestOptions) => Promise<GitStatus>;
getGitBranches: (directory: string) => Promise<GitBranch>;
getGitLog: (directory: string, options?: { maxCount?: number }) => Promise<GitLogResponse>;
getCurrentGitIdentity: (directory: string) => Promise<GitIdentitySummary | null>;
@@ -692,6 +694,9 @@ export const useGitStore = create<GitStore>()(
},
fetchStatus: async (directory, git, options = {}) => {
if (getWorktreeBootstrapState(directory)?.status === 'pending') {
return false;
}
const statusFetchMode: GitStatusFetchMode = options.mode ?? 'full';
const runtimeKey = getRuntimeKey();
const statusFetchKey = getStatusFetchKey(runtimeKey, directory, statusFetchMode);
@@ -757,8 +762,17 @@ export const useGitStore = create<GitStore>()(
return false;
}
const newStatus = await git.getGitStatus(directory, options.mode ? { mode: options.mode } : undefined);
let statusOptions: GitStatusRequestOptions | undefined;
if (options.mode || options.force) {
statusOptions = {};
if (options.mode) statusOptions.mode = options.mode;
if (options.force) statusOptions.fresh = true;
}
const newStatus = await git.getGitStatus(directory, statusOptions);
if (!isRequestCurrent(token, directory)) return false;
// A request admitted before worktree creation must not publish a
// transient --no-checkout/reset snapshot after bootstrap begins.
if (getWorktreeBootstrapState(directory)?.status === 'pending') return false;
const latestState = get().directories.get(directory) ?? createEmptyDirectoryState();
if (hasStatusChanged(latestState.status, newStatus)) {
@@ -830,6 +844,9 @@ export const useGitStore = create<GitStore>()(
}
} catch (error) {
console.error('Failed to fetch git status:', error);
if (options.throwOnError) {
throw error;
}
} finally {
if (!silent && isRequestCurrent(token, directory)) {
const newDirectories = new Map(get().directories);
@@ -67,6 +67,7 @@ mock.module("@/components/ui", () => ({
import { INITIAL_STATE, type State } from "../types"
import { ChildStoreManager, type DirectoryStore } from "../child-store"
import { getRuntimeKey } from "@/lib/runtime-switch"
import { sessionEvents } from "@/lib/sessionEvents"
const {
createEventRoutingIndex,
handleEvent,
@@ -340,4 +341,40 @@ describe("resyncBlockingRequestsForDirectory", () => {
unsubscribe()
childStores.disposeAll()
})
test("refreshes Git once when a live mutating tool completes between renders", () => {
const childStores = new ChildStoreManager()
childStores.ensureChild("/repo", { bootstrap: false })
const routingIndex = createEventRoutingIndex()
const refreshes: Array<{ directory: string; paths?: string[] }> = []
const unsubscribe = sessionEvents.onGitRefreshHint((hint) => refreshes.push(hint))
// SAFETY: this fixture supplies the SDK event discriminator and the tool
// part identity, tool name, and state fields consumed by the reducer.
const toolEvent = (tool: string, status: "pending" | "completed" | "error") => ({
type: "message.part.updated",
properties: {
part: {
id: "prt_tool",
messageID: "msg_assistant",
sessionID: "ses_a",
type: "tool",
tool,
state: { status, input: {}, metadata: {} },
},
},
}) as Event
try {
handleEvent("/repo", toolEvent("apply_patch", "pending"), childStores, routingIndex, getRuntimeKey())
handleEvent("/repo", toolEvent("apply_patch", "completed"), childStores, routingIndex, getRuntimeKey())
handleEvent("/repo", toolEvent("apply_patch", "completed"), childStores, routingIndex, getRuntimeKey())
handleEvent("/repo", toolEvent("read", "completed"), childStores, routingIndex, getRuntimeKey())
handleEvent("/repo", toolEvent("edit", "error"), childStores, routingIndex, getRuntimeKey())
expect(refreshes).toEqual([{ directory: "/repo" }])
} finally {
unsubscribe()
childStores.disposeAll()
}
})
})
+9
View File
@@ -78,6 +78,7 @@ import { getRuntimeKey } from "@/lib/runtime-switch"
import { getRegisteredRuntimeAPIs } from "@/contexts/runtimeAPIRegistry"
import { isFilesystemError } from "@/lib/api/files-errors"
import { formatMessage, useI18nStore } from "@/lib/i18n"
import { sessionEvents } from "@/lib/sessionEvents"
import { listGlobalSessionPages } from "@/stores/globalSessions"
import { areRequestArraysReferentiallyEqual, collectScopedBlockingRequests } from "./scoped-blocking-requests"
import { EMPTY_USER_MESSAGE_HISTORY_SNAPSHOT, buildUserMessageHistorySnapshot, type UserMessageHistorySnapshot } from "./user-message-history"
@@ -1818,6 +1819,10 @@ export function handleEvent(
// type will mutate. This preserves reference identity for untouched slices
// so Zustand selectors skip re-renders for unrelated subscribers.
const current = getDirectoryEventState(store, batch)
const updatedPart = payload.type === "message.part.updated" ? payload.properties.part : undefined
const previousPart = updatedPart && "messageID" in updatedPart
? current.part[updatedPart.messageID]?.find((part) => part.id === updatedPart.id)
: undefined
const draft: State = { ...current }
const clonedFields = batch?.clonedFields.get(store) ?? new Set<keyof State>()
const newlyClonedFields: Array<keyof State> = []
@@ -1894,6 +1899,10 @@ export function handleEvent(
const reducerChanged = typeof reducerResult === "boolean" ? reducerResult : reducerResult.changed
const materializationResult = typeof reducerResult === "boolean" ? undefined : reducerResult.materialization
if (reducerChanged && updatedPart) {
sessionEvents.requestGitRefreshForToolTransition(resolvedDirectory, previousPart, updatedPart)
}
if (reducerChanged) {
countSyncPerformance("reducerChangedEvents")
const eventSessionID = getSessionIdFromPayload(payload) ?? undefined
+1 -1
View File
@@ -69,7 +69,7 @@ export const createVSCodeGitAPI = (): GitAPI => ({
return sendBridgeMessage<boolean>('api:git/check', { directory });
},
getGitStatus: async (directory: string, options?: { mode?: 'light' }): Promise<GitStatus> => {
getGitStatus: async (directory: string, options?: { mode?: 'light'; fresh?: boolean }): Promise<GitStatus> => {
return sendBridgeMessage<GitStatus>('api:git/status', { directory, mode: options?.mode });
},