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
+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);