fix(ui): reconcile git state after worktree changes
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user