fix(git): make post-mutation status refresh authoritative (#2740)
fix(git): make post-mutation status refresh authoritative
This commit is contained in:
@@ -1,10 +1,30 @@
|
|||||||
import { describe, expect, test } from 'bun:test';
|
import { describe, expect, test } from 'bun:test';
|
||||||
import {
|
import {
|
||||||
|
abortMerge,
|
||||||
|
abortRebase,
|
||||||
|
applyGitStash,
|
||||||
|
checkoutBranch,
|
||||||
|
checkoutCommit,
|
||||||
|
cherryPick,
|
||||||
|
continueMerge,
|
||||||
|
continueRebase,
|
||||||
|
createBranch,
|
||||||
|
deleteGitBranch,
|
||||||
|
deleteRemoteBranch,
|
||||||
|
dropGitStash,
|
||||||
getGitBranches,
|
getGitBranches,
|
||||||
getGitStatus,
|
getGitStatus,
|
||||||
gitFetch,
|
gitFetch,
|
||||||
|
merge,
|
||||||
|
popGitStash,
|
||||||
|
rebase,
|
||||||
|
removeRemote,
|
||||||
|
renameBranch,
|
||||||
|
resetToCommit,
|
||||||
|
revertCommit,
|
||||||
stageGitFile,
|
stageGitFile,
|
||||||
stageGitFiles,
|
stageGitFiles,
|
||||||
|
stashGitChanges,
|
||||||
unstageGitFile,
|
unstageGitFile,
|
||||||
unstageGitFiles,
|
unstageGitFiles,
|
||||||
} from './gitApiHttp';
|
} from './gitApiHttp';
|
||||||
@@ -169,6 +189,200 @@ describe('gitApiHttp status cache', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const statusPayload = (overrides: Record<string, unknown> = {}) => ({
|
||||||
|
current: 'main',
|
||||||
|
tracking: null,
|
||||||
|
ahead: 0,
|
||||||
|
behind: 0,
|
||||||
|
files: [],
|
||||||
|
isClean: true,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
const jsonResponse = (payload: unknown) => new Response(JSON.stringify(payload), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const installStatusMutationFetchMock = () => {
|
||||||
|
const mock = {
|
||||||
|
statusUrls: [] as string[],
|
||||||
|
behind: 0,
|
||||||
|
};
|
||||||
|
globalThis.fetch = (async (input) => {
|
||||||
|
const url = String(input);
|
||||||
|
if (url.startsWith('/api/git/status')) {
|
||||||
|
mock.statusUrls.push(url);
|
||||||
|
return jsonResponse(statusPayload({ behind: mock.behind }));
|
||||||
|
}
|
||||||
|
return jsonResponse({ success: true });
|
||||||
|
}) as typeof fetch;
|
||||||
|
return mock;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seeds the status cache, performs the mutation, and asserts the next status
|
||||||
|
* read issues a fresh request that observes the post-mutation state instead of
|
||||||
|
* serving the pre-mutation cache entry.
|
||||||
|
*/
|
||||||
|
const expectStatusInvalidatedBy = async (
|
||||||
|
directory: string,
|
||||||
|
mutate: () => Promise<unknown>
|
||||||
|
): Promise<void> => {
|
||||||
|
const mock = installStatusMutationFetchMock();
|
||||||
|
|
||||||
|
const seeded = await getGitStatus(directory);
|
||||||
|
expect(seeded.behind).toBe(0);
|
||||||
|
|
||||||
|
mock.behind = 2;
|
||||||
|
const cached = await getGitStatus(directory);
|
||||||
|
expect(cached.behind).toBe(0);
|
||||||
|
expect(mock.statusUrls).toHaveLength(1);
|
||||||
|
|
||||||
|
await mutate();
|
||||||
|
|
||||||
|
const refreshed = await getGitStatus(directory);
|
||||||
|
expect(refreshed.behind).toBe(2);
|
||||||
|
expect(mock.statusUrls).toHaveLength(2);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('gitApiHttp post-mutation status invalidation (#2281)', () => {
|
||||||
|
test('checkout and branch mutations invalidate cached status', async () => {
|
||||||
|
installWindowMock();
|
||||||
|
try {
|
||||||
|
await expectStatusInvalidatedBy('/repo-2281-checkout', () => checkoutBranch('/repo-2281-checkout', 'feature'));
|
||||||
|
await expectStatusInvalidatedBy('/repo-2281-create-branch', () => createBranch('/repo-2281-create-branch', 'feature/new'));
|
||||||
|
await expectStatusInvalidatedBy('/repo-2281-rename-branch', () => renameBranch('/repo-2281-rename-branch', 'old', 'new'));
|
||||||
|
await expectStatusInvalidatedBy('/repo-2281-delete-branch', () => deleteGitBranch('/repo-2281-delete-branch', { branch: 'feature/old' }));
|
||||||
|
} finally {
|
||||||
|
restoreMocks();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stash lifecycle mutations invalidate cached status', async () => {
|
||||||
|
installWindowMock();
|
||||||
|
try {
|
||||||
|
await expectStatusInvalidatedBy('/repo-2281-stash', () => stashGitChanges('/repo-2281-stash', { message: 'WIP' }));
|
||||||
|
await expectStatusInvalidatedBy('/repo-2281-stash-apply', () => applyGitStash('/repo-2281-stash-apply', { ref: 'stash@{0}' }));
|
||||||
|
await expectStatusInvalidatedBy('/repo-2281-stash-pop', () => popGitStash('/repo-2281-stash-pop', { ref: 'stash@{0}' }));
|
||||||
|
await expectStatusInvalidatedBy('/repo-2281-stash-drop', () => dropGitStash('/repo-2281-stash-drop', { ref: 'stash@{0}' }));
|
||||||
|
} finally {
|
||||||
|
restoreMocks();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('merge and rebase lifecycle mutations invalidate cached status', async () => {
|
||||||
|
installWindowMock();
|
||||||
|
try {
|
||||||
|
await expectStatusInvalidatedBy('/repo-2281-merge', () => merge('/repo-2281-merge', { branch: 'feature' }));
|
||||||
|
await expectStatusInvalidatedBy('/repo-2281-merge-abort', () => abortMerge('/repo-2281-merge-abort'));
|
||||||
|
await expectStatusInvalidatedBy('/repo-2281-merge-continue', () => continueMerge('/repo-2281-merge-continue'));
|
||||||
|
await expectStatusInvalidatedBy('/repo-2281-rebase', () => rebase('/repo-2281-rebase', { onto: 'main' }));
|
||||||
|
await expectStatusInvalidatedBy('/repo-2281-rebase-abort', () => abortRebase('/repo-2281-rebase-abort'));
|
||||||
|
await expectStatusInvalidatedBy('/repo-2281-rebase-continue', () => continueRebase('/repo-2281-rebase-continue'));
|
||||||
|
} finally {
|
||||||
|
restoreMocks();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('history mutations invalidate cached status', async () => {
|
||||||
|
installWindowMock();
|
||||||
|
try {
|
||||||
|
await expectStatusInvalidatedBy('/repo-2281-checkout-commit', () => checkoutCommit('/repo-2281-checkout-commit', 'abc123'));
|
||||||
|
await expectStatusInvalidatedBy('/repo-2281-cherry-pick', () => cherryPick('/repo-2281-cherry-pick', 'abc123'));
|
||||||
|
await expectStatusInvalidatedBy('/repo-2281-revert-commit', () => revertCommit('/repo-2281-revert-commit', 'abc123'));
|
||||||
|
await expectStatusInvalidatedBy('/repo-2281-reset', () => resetToCommit('/repo-2281-reset', 'abc123', 'mixed'));
|
||||||
|
} finally {
|
||||||
|
restoreMocks();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('remote-side mutations invalidate cached status', async () => {
|
||||||
|
installWindowMock();
|
||||||
|
try {
|
||||||
|
await expectStatusInvalidatedBy('/repo-2281-delete-remote-branch', () => deleteRemoteBranch('/repo-2281-delete-remote-branch', { branch: 'feature', remote: 'origin' }));
|
||||||
|
await expectStatusInvalidatedBy('/repo-2281-remove-remote', () => removeRemote('/repo-2281-remove-remote', { remote: 'origin' }));
|
||||||
|
} finally {
|
||||||
|
restoreMocks();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a failed mutation does not invalidate cached status', async () => {
|
||||||
|
installWindowMock();
|
||||||
|
const statusUrls: string[] = [];
|
||||||
|
globalThis.fetch = (async (input) => {
|
||||||
|
const url = String(input);
|
||||||
|
if (url.startsWith('/api/git/status')) {
|
||||||
|
statusUrls.push(url);
|
||||||
|
return jsonResponse(statusPayload());
|
||||||
|
}
|
||||||
|
return new Response(JSON.stringify({ error: 'checkout failed' }), {
|
||||||
|
status: 500,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
}) as typeof fetch;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const directory = '/repo-2281-failed-checkout';
|
||||||
|
await getGitStatus(directory);
|
||||||
|
|
||||||
|
const error = await captureError(async () => {
|
||||||
|
await checkoutBranch(directory, 'feature');
|
||||||
|
});
|
||||||
|
expect(error).toBeInstanceOf(Error);
|
||||||
|
expect((error as Error).message).toBe('checkout failed');
|
||||||
|
|
||||||
|
await getGitStatus(directory);
|
||||||
|
expect(statusUrls).toHaveLength(1);
|
||||||
|
} finally {
|
||||||
|
restoreMocks();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a status request admitted before a mutation cannot satisfy the post-mutation refresh', async () => {
|
||||||
|
installWindowMock();
|
||||||
|
const statusResolvers: Array<(response: Response) => void> = [];
|
||||||
|
const statusUrls: string[] = [];
|
||||||
|
globalThis.fetch = (async (input) => {
|
||||||
|
const url = String(input);
|
||||||
|
if (url.startsWith('/api/git/status')) {
|
||||||
|
statusUrls.push(url);
|
||||||
|
return new Promise<Response>((resolve) => {
|
||||||
|
statusResolvers.push(resolve);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return jsonResponse({ success: true });
|
||||||
|
}) as typeof fetch;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const directory = '/repo-2281-deferred';
|
||||||
|
const preMutationRead = getGitStatus(directory);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
expect(statusUrls).toHaveLength(1);
|
||||||
|
|
||||||
|
await checkoutBranch(directory, 'feature');
|
||||||
|
|
||||||
|
const postMutationRead = getGitStatus(directory);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
expect(statusUrls).toHaveLength(2);
|
||||||
|
|
||||||
|
statusResolvers[1](jsonResponse(statusPayload({ current: 'feature' })));
|
||||||
|
statusResolvers[0](jsonResponse(statusPayload({ current: 'main' })));
|
||||||
|
|
||||||
|
const [preMutationStatus, postMutationStatus] = await Promise.all([preMutationRead, postMutationRead]);
|
||||||
|
expect(preMutationStatus.current).toBe('main');
|
||||||
|
expect(postMutationStatus.current).toBe('feature');
|
||||||
|
|
||||||
|
// The late pre-mutation response must not repopulate the cache.
|
||||||
|
const cachedRead = await getGitStatus(directory);
|
||||||
|
expect(cachedRead.current).toBe('feature');
|
||||||
|
expect(statusUrls).toHaveLength(2);
|
||||||
|
} finally {
|
||||||
|
restoreMocks();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('gitApiHttp request priority', () => {
|
describe('gitApiHttp request priority', () => {
|
||||||
test('leaves low-level reads outside the background policy', async () => {
|
test('leaves low-level reads outside the background policy', async () => {
|
||||||
installWindowMock();
|
installWindowMock();
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import type {
|
|||||||
import { runtimeFetch } from './runtime-fetch';
|
import { runtimeFetch } from './runtime-fetch';
|
||||||
import { getRuntimeUrlResolver } from './runtime-url';
|
import { getRuntimeUrlResolver } from './runtime-url';
|
||||||
import { getRuntimeKey } from './runtime-switch';
|
import { getRuntimeKey } from './runtime-switch';
|
||||||
|
import { notifyGitStatusInvalidated } from './gitStatusInvalidation';
|
||||||
|
|
||||||
const API_BASE = '/api/git';
|
const API_BASE = '/api/git';
|
||||||
const GIT_STATUS_CACHE_TTL_MS = 1200;
|
const GIT_STATUS_CACHE_TTL_MS = 1200;
|
||||||
@@ -66,6 +67,16 @@ const invalidateGitStatusCache = (directory: string): void => {
|
|||||||
gitStatusCache.delete(statusKey);
|
gitStatusCache.delete(statusKey);
|
||||||
gitStatusInFlight.delete(statusKey);
|
gitStatusInFlight.delete(statusKey);
|
||||||
}
|
}
|
||||||
|
notifyGitStatusInvalidated(directory);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Shared success path for status-affecting mutations. The payload is parsed
|
||||||
|
// before invalidating so a failed mutation (non-ok response handled by the
|
||||||
|
// caller, or a malformed body) cannot publish a false state change.
|
||||||
|
const completeStatusMutation = async <T>(directory: string, response: Response): Promise<T> => {
|
||||||
|
const result = await response.json() as T;
|
||||||
|
invalidateGitStatusCache(directory);
|
||||||
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
function buildUrl(
|
function buildUrl(
|
||||||
@@ -464,7 +475,7 @@ export async function deleteGitBranch(directory: string, payload: GitDeleteBranc
|
|||||||
throw new Error(error.error || 'Failed to delete branch');
|
throw new Error(error.error || 'Failed to delete branch');
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.json();
|
return completeStatusMutation(directory, response);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteRemoteBranch(directory: string, payload: GitDeleteRemoteBranchPayload): Promise<{ success: boolean }> {
|
export async function deleteRemoteBranch(directory: string, payload: GitDeleteRemoteBranchPayload): Promise<{ success: boolean }> {
|
||||||
@@ -483,7 +494,7 @@ export async function deleteRemoteBranch(directory: string, payload: GitDeleteRe
|
|||||||
throw new Error(error.error || 'Failed to delete remote branch');
|
throw new Error(error.error || 'Failed to delete remote branch');
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.json();
|
return completeStatusMutation(directory, response);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function removeRemote(directory: string, payload: GitRemoveRemotePayload): Promise<{ success: boolean }> {
|
export async function removeRemote(directory: string, payload: GitRemoveRemotePayload): Promise<{ success: boolean }> {
|
||||||
@@ -503,7 +514,7 @@ export async function removeRemote(directory: string, payload: GitRemoveRemotePa
|
|||||||
throw new Error(error.error || 'Failed to remove remote');
|
throw new Error(error.error || 'Failed to remove remote');
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.json();
|
return completeStatusMutation(directory, response);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateCommitMessage(
|
export async function generateCommitMessage(
|
||||||
@@ -710,9 +721,7 @@ export async function createGitCommit(
|
|||||||
const error = await response.json().catch(() => ({ error: response.statusText }));
|
const error = await response.json().catch(() => ({ error: response.statusText }));
|
||||||
throw new Error(error.error || 'Failed to create commit');
|
throw new Error(error.error || 'Failed to create commit');
|
||||||
}
|
}
|
||||||
const result = await response.json();
|
return completeStatusMutation(directory, response);
|
||||||
invalidateGitStatusCache(directory);
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function gitPush(
|
export async function gitPush(
|
||||||
@@ -728,9 +737,7 @@ export async function gitPush(
|
|||||||
const error = await response.json().catch(() => ({ error: response.statusText }));
|
const error = await response.json().catch(() => ({ error: response.statusText }));
|
||||||
throw new Error(error.error || 'Failed to push');
|
throw new Error(error.error || 'Failed to push');
|
||||||
}
|
}
|
||||||
const result = await response.json();
|
return completeStatusMutation(directory, response);
|
||||||
invalidateGitStatusCache(directory);
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function gitPull(
|
export async function gitPull(
|
||||||
@@ -746,9 +753,7 @@ export async function gitPull(
|
|||||||
const error = await response.json().catch(() => ({ error: response.statusText }));
|
const error = await response.json().catch(() => ({ error: response.statusText }));
|
||||||
throw new Error(error.error || 'Failed to pull');
|
throw new Error(error.error || 'Failed to pull');
|
||||||
}
|
}
|
||||||
const result = await response.json();
|
return completeStatusMutation(directory, response);
|
||||||
invalidateGitStatusCache(directory);
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function gitFetch(
|
export async function gitFetch(
|
||||||
@@ -764,9 +769,7 @@ export async function gitFetch(
|
|||||||
const error = await response.json().catch(() => ({ error: response.statusText }));
|
const error = await response.json().catch(() => ({ error: response.statusText }));
|
||||||
throw new Error(error.error || 'Failed to fetch');
|
throw new Error(error.error || 'Failed to fetch');
|
||||||
}
|
}
|
||||||
const result = await response.json();
|
return completeStatusMutation(directory, response);
|
||||||
invalidateGitStatusCache(directory);
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listGitStashes(directory: string): Promise<{ stashes: GitStashEntry[] }> {
|
export async function listGitStashes(directory: string): Promise<{ stashes: GitStashEntry[] }> {
|
||||||
@@ -801,7 +804,7 @@ export async function stashGitChanges(directory: string, options: { message?: st
|
|||||||
const error = await response.json().catch(() => ({ error: response.statusText }));
|
const error = await response.json().catch(() => ({ error: response.statusText }));
|
||||||
throw new Error(error.error || 'Failed to stash changes');
|
throw new Error(error.error || 'Failed to stash changes');
|
||||||
}
|
}
|
||||||
return response.json();
|
return completeStatusMutation(directory, response);
|
||||||
}
|
}
|
||||||
|
|
||||||
const postStashRef = async (directory: string, path: string, options: { ref: string }): Promise<{ success: boolean; ref: string }> => {
|
const postStashRef = async (directory: string, path: string, options: { ref: string }): Promise<{ success: boolean; ref: string }> => {
|
||||||
@@ -814,7 +817,7 @@ const postStashRef = async (directory: string, path: string, options: { ref: str
|
|||||||
const error = await response.json().catch(() => ({ error: response.statusText }));
|
const error = await response.json().catch(() => ({ error: response.statusText }));
|
||||||
throw new Error(error.error || `Failed to ${path}`);
|
throw new Error(error.error || `Failed to ${path}`);
|
||||||
}
|
}
|
||||||
return response.json();
|
return completeStatusMutation(directory, response);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const applyGitStash = (directory: string, options: { ref: string }) => postStashRef(directory, 'stash/apply', options);
|
export const applyGitStash = (directory: string, options: { ref: string }) => postStashRef(directory, 'stash/apply', options);
|
||||||
@@ -831,7 +834,7 @@ export async function checkoutBranch(directory: string, branch: string): Promise
|
|||||||
const error = await response.json().catch(() => ({ error: response.statusText }));
|
const error = await response.json().catch(() => ({ error: response.statusText }));
|
||||||
throw new Error(error.error || 'Failed to checkout branch');
|
throw new Error(error.error || 'Failed to checkout branch');
|
||||||
}
|
}
|
||||||
return response.json();
|
return completeStatusMutation(directory, response);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createBranch(
|
export async function createBranch(
|
||||||
@@ -848,7 +851,7 @@ export async function createBranch(
|
|||||||
const error = await response.json().catch(() => ({ error: response.statusText }));
|
const error = await response.json().catch(() => ({ error: response.statusText }));
|
||||||
throw new Error(error.error || 'Failed to create branch');
|
throw new Error(error.error || 'Failed to create branch');
|
||||||
}
|
}
|
||||||
return response.json();
|
return completeStatusMutation(directory, response);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function renameBranch(
|
export async function renameBranch(
|
||||||
@@ -865,7 +868,7 @@ export async function renameBranch(
|
|||||||
const error = await response.json().catch(() => ({ error: response.statusText }));
|
const error = await response.json().catch(() => ({ error: response.statusText }));
|
||||||
throw new Error(error.error || 'Failed to rename branch');
|
throw new Error(error.error || 'Failed to rename branch');
|
||||||
}
|
}
|
||||||
return response.json();
|
return completeStatusMutation(directory, response);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getGitLog(
|
export async function getGitLog(
|
||||||
@@ -1068,7 +1071,7 @@ export async function rebase(
|
|||||||
const error = await response.json().catch(() => ({ error: response.statusText }));
|
const error = await response.json().catch(() => ({ error: response.statusText }));
|
||||||
throw new Error(error.error || 'Failed to rebase');
|
throw new Error(error.error || 'Failed to rebase');
|
||||||
}
|
}
|
||||||
return response.json();
|
return completeStatusMutation(directory, response);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function abortRebase(directory: string): Promise<{ success: boolean }> {
|
export async function abortRebase(directory: string): Promise<{ success: boolean }> {
|
||||||
@@ -1079,7 +1082,7 @@ export async function abortRebase(directory: string): Promise<{ success: boolean
|
|||||||
const error = await response.json().catch(() => ({ error: response.statusText }));
|
const error = await response.json().catch(() => ({ error: response.statusText }));
|
||||||
throw new Error(error.error || 'Failed to abort rebase');
|
throw new Error(error.error || 'Failed to abort rebase');
|
||||||
}
|
}
|
||||||
return response.json();
|
return completeStatusMutation(directory, response);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function merge(
|
export async function merge(
|
||||||
@@ -1095,7 +1098,7 @@ export async function merge(
|
|||||||
const error = await response.json().catch(() => ({ error: response.statusText }));
|
const error = await response.json().catch(() => ({ error: response.statusText }));
|
||||||
throw new Error(error.error || 'Failed to merge');
|
throw new Error(error.error || 'Failed to merge');
|
||||||
}
|
}
|
||||||
return response.json();
|
return completeStatusMutation(directory, response);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function checkoutCommit(
|
export async function checkoutCommit(
|
||||||
@@ -1111,7 +1114,7 @@ export async function checkoutCommit(
|
|||||||
const error = await response.json().catch(() => ({ error: response.statusText }));
|
const error = await response.json().catch(() => ({ error: response.statusText }));
|
||||||
throw new Error(error.error || 'Failed to checkout commit');
|
throw new Error(error.error || 'Failed to checkout commit');
|
||||||
}
|
}
|
||||||
return response.json();
|
return completeStatusMutation(directory, response);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function cherryPick(
|
export async function cherryPick(
|
||||||
@@ -1127,7 +1130,7 @@ export async function cherryPick(
|
|||||||
const error = await response.json().catch(() => ({ error: response.statusText }));
|
const error = await response.json().catch(() => ({ error: response.statusText }));
|
||||||
throw new Error(error.error || 'Failed to cherry-pick');
|
throw new Error(error.error || 'Failed to cherry-pick');
|
||||||
}
|
}
|
||||||
return response.json();
|
return completeStatusMutation(directory, response);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function revertCommit(
|
export async function revertCommit(
|
||||||
@@ -1143,7 +1146,7 @@ export async function revertCommit(
|
|||||||
const error = await response.json().catch(() => ({ error: response.statusText }));
|
const error = await response.json().catch(() => ({ error: response.statusText }));
|
||||||
throw new Error(error.error || 'Failed to revert commit');
|
throw new Error(error.error || 'Failed to revert commit');
|
||||||
}
|
}
|
||||||
return response.json();
|
return completeStatusMutation(directory, response);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function resetToCommit(
|
export async function resetToCommit(
|
||||||
@@ -1161,7 +1164,7 @@ export async function resetToCommit(
|
|||||||
const error = await response.json().catch(() => ({ error: response.statusText }));
|
const error = await response.json().catch(() => ({ error: response.statusText }));
|
||||||
throw new Error(error.error || 'Failed to reset');
|
throw new Error(error.error || 'Failed to reset');
|
||||||
}
|
}
|
||||||
return response.json();
|
return completeStatusMutation(directory, response);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function abortMerge(directory: string): Promise<{ success: boolean }> {
|
export async function abortMerge(directory: string): Promise<{ success: boolean }> {
|
||||||
@@ -1172,7 +1175,7 @@ export async function abortMerge(directory: string): Promise<{ success: boolean
|
|||||||
const error = await response.json().catch(() => ({ error: response.statusText }));
|
const error = await response.json().catch(() => ({ error: response.statusText }));
|
||||||
throw new Error(error.error || 'Failed to abort merge');
|
throw new Error(error.error || 'Failed to abort merge');
|
||||||
}
|
}
|
||||||
return response.json();
|
return completeStatusMutation(directory, response);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function continueRebase(directory: string): Promise<{ success: boolean; conflict: boolean; conflictFiles?: string[] }> {
|
export async function continueRebase(directory: string): Promise<{ success: boolean; conflict: boolean; conflictFiles?: string[] }> {
|
||||||
@@ -1183,7 +1186,7 @@ export async function continueRebase(directory: string): Promise<{ success: bool
|
|||||||
const error = await response.json().catch(() => ({ error: response.statusText }));
|
const error = await response.json().catch(() => ({ error: response.statusText }));
|
||||||
throw new Error(error.error || 'Failed to continue rebase');
|
throw new Error(error.error || 'Failed to continue rebase');
|
||||||
}
|
}
|
||||||
return response.json();
|
return completeStatusMutation(directory, response);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function continueMerge(directory: string): Promise<{ success: boolean; conflict: boolean; conflictFiles?: string[] }> {
|
export async function continueMerge(directory: string): Promise<{ success: boolean; conflict: boolean; conflictFiles?: string[] }> {
|
||||||
@@ -1194,7 +1197,7 @@ export async function continueMerge(directory: string): Promise<{ success: boole
|
|||||||
const error = await response.json().catch(() => ({ error: response.statusText }));
|
const error = await response.json().catch(() => ({ error: response.statusText }));
|
||||||
throw new Error(error.error || 'Failed to continue merge');
|
throw new Error(error.error || 'Failed to continue merge');
|
||||||
}
|
}
|
||||||
return response.json();
|
return completeStatusMutation(directory, response);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function stash(
|
export async function stash(
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* Minimal notification channel for git status invalidation.
|
||||||
|
*
|
||||||
|
* A runtime adapter that caches git status (currently only the HTTP adapter in
|
||||||
|
* `gitApiHttp.ts`) must call `notifyGitStatusInvalidated` whenever a successful
|
||||||
|
* status-affecting mutation invalidates its cache. `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.
|
||||||
|
*
|
||||||
|
* Runtime parity: the VS Code bridge adapter performs no client-side status
|
||||||
|
* caching (every `getGitStatus` is a fresh bridge request), so it has no cache
|
||||||
|
* to invalidate and does not emit this signal today. Any adapter that adds
|
||||||
|
* caching must emit on invalidation.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type GitStatusInvalidationListener = (directory: string) => void;
|
||||||
|
|
||||||
|
const listeners = new Set<GitStatusInvalidationListener>();
|
||||||
|
|
||||||
|
export const subscribeGitStatusInvalidations = (
|
||||||
|
listener: GitStatusInvalidationListener
|
||||||
|
): (() => void) => {
|
||||||
|
listeners.add(listener);
|
||||||
|
return () => {
|
||||||
|
listeners.delete(listener);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const notifyGitStatusInvalidated = (directory: string): void => {
|
||||||
|
for (const listener of listeners) {
|
||||||
|
listener(directory);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -147,10 +147,12 @@ Important properties:
|
|||||||
- `directories: Map<string, DirectoryGitState>` is the source of truth
|
- `directories: Map<string, DirectoryGitState>` is the source of truth
|
||||||
- loading state is per-directory, not global
|
- loading state is per-directory, not global
|
||||||
- `ensureStatus()` and `ensureAll()` are the preferred entry points for consumers
|
- `ensureStatus()` and `ensureAll()` are the preferred entry points for consumers
|
||||||
- in-flight dedupe exists for status and `ensureAll()`
|
- in-flight dedupe exists for status and `ensureAll()`; status dedupe is scoped to the per-directory status mutation revision, so a refresh requested after a mutation never joins a pre-mutation in-flight request
|
||||||
- runtime reset replaces all live entries with that runtime's persisted branch seeds and invalidates old completions
|
- runtime reset replaces all live entries with that runtime's persisted branch seeds and invalidates old completions
|
||||||
- status, branches, log, identity, repository probes, and prefetch diffs commit through runtime and per-channel generations
|
- 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
|
- 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
|
||||||
- branch persistence is versioned, bounded, runtime-scoped, and claims the ambiguous legacy cache once
|
- 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
|
- diff data has per-directory and aggregate count/UTF-8-byte limits; oversized single entries are rejected
|
||||||
|
|
||||||
@@ -312,6 +314,7 @@ Expected model:
|
|||||||
|
|
||||||
- `GitView` / `DiffView` ensure current-directory Git state when visible
|
- `GitView` / `DiffView` ensure current-directory Git state when visible
|
||||||
- explicit Git actions refresh status/branches/log as needed
|
- 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
|
- 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
|
||||||
- a successful dirty save from the in-app file editor issues a path-scoped Git refresh hint; clean autosave checks remain no-ops
|
- 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
|
- 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
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, test } from 'bun:test';
|
|||||||
import type { GitStatus } from '@/lib/api/types';
|
import type { GitStatus } from '@/lib/api/types';
|
||||||
import { useGitStore } from './useGitStore';
|
import { useGitStore } from './useGitStore';
|
||||||
import { getRuntimeKey } from '@/lib/runtime-switch';
|
import { getRuntimeKey } from '@/lib/runtime-switch';
|
||||||
|
import { notifyGitStatusInvalidated } from '@/lib/gitStatusInvalidation';
|
||||||
|
|
||||||
type Deferred<T> = {
|
type Deferred<T> = {
|
||||||
promise: Promise<T>;
|
promise: Promise<T>;
|
||||||
@@ -126,6 +127,85 @@ describe('useGitStore', () => {
|
|||||||
expect(lightResult).toBe(fullResult);
|
expect(lightResult).toBe(fullResult);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('deduplicates concurrent status requests when no mutation occurs', async () => {
|
||||||
|
setDirectoryStatus(createStatus());
|
||||||
|
let statusCalls = 0;
|
||||||
|
const request = createDeferred<GitStatus>();
|
||||||
|
const git = createGitApi(() => {
|
||||||
|
statusCalls += 1;
|
||||||
|
return request.promise;
|
||||||
|
});
|
||||||
|
|
||||||
|
const first = useGitStore.getState().fetchStatus('/repo', git, { silent: true });
|
||||||
|
const second = useGitStore.getState().fetchStatus('/repo', git, { silent: true });
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(statusCalls).toBe(1);
|
||||||
|
|
||||||
|
request.resolve(createStatus());
|
||||||
|
await Promise.all([first, second]);
|
||||||
|
expect(statusCalls).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a refresh after a mutation does not join the pre-mutation in-flight status request', async () => {
|
||||||
|
setDirectoryStatus(createStatus());
|
||||||
|
const requests: Deferred<GitStatus>[] = [];
|
||||||
|
let statusCalls = 0;
|
||||||
|
const git = createGitApi(() => {
|
||||||
|
statusCalls += 1;
|
||||||
|
const request = createDeferred<GitStatus>();
|
||||||
|
requests.push(request);
|
||||||
|
return request.promise;
|
||||||
|
});
|
||||||
|
|
||||||
|
const preMutation = useGitStore.getState().fetchStatus('/repo', git, { silent: true });
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(statusCalls).toBe(1);
|
||||||
|
|
||||||
|
// A successful git mutation invalidates the adapter status cache, which
|
||||||
|
// notifies the store that the in-flight request predates the mutation.
|
||||||
|
notifyGitStatusInvalidated('/repo');
|
||||||
|
|
||||||
|
const postMutation = useGitStore.getState().fetchStatus('/repo', git, { silent: true });
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(statusCalls).toBe(2);
|
||||||
|
|
||||||
|
requests[1].resolve({ ...createStatus(), current: 'feature' });
|
||||||
|
await postMutation;
|
||||||
|
expect(useGitStore.getState().getDirectoryState('/repo')?.status?.current).toBe('feature');
|
||||||
|
|
||||||
|
// The late pre-mutation response cannot overwrite the newer authoritative one.
|
||||||
|
requests[0].resolve(createStatus());
|
||||||
|
await preMutation;
|
||||||
|
expect(useGitStore.getState().getDirectoryState('/repo')?.status?.current).toBe('feature');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fetchAll({ force: true }) forces a fresh status fetch past the in-flight dedup', async () => {
|
||||||
|
setDirectoryStatus(createStatus());
|
||||||
|
const requests: Deferred<GitStatus>[] = [];
|
||||||
|
let statusCalls = 0;
|
||||||
|
const git = createGitApi(() => {
|
||||||
|
statusCalls += 1;
|
||||||
|
const request = createDeferred<GitStatus>();
|
||||||
|
requests.push(request);
|
||||||
|
return request.promise;
|
||||||
|
});
|
||||||
|
|
||||||
|
const inFlight = useGitStore.getState().fetchStatus('/repo', git, { silent: true });
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(statusCalls).toBe(1);
|
||||||
|
|
||||||
|
const all = useGitStore.getState().fetchAll('/repo', git, { force: true });
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(statusCalls).toBe(2);
|
||||||
|
|
||||||
|
requests[1].resolve({ ...createStatus(), current: 'feature' });
|
||||||
|
requests[0].resolve(createStatus());
|
||||||
|
await Promise.allSettled([inFlight, all]);
|
||||||
|
|
||||||
|
expect(useGitStore.getState().getDirectoryState('/repo')?.status?.current).toBe('feature');
|
||||||
|
});
|
||||||
|
|
||||||
test('does not let an older status fetch undo an optimistic mutation', async () => {
|
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' }]);
|
const initial = createStatus(undefined, [{ path: 'src/index.ts', index: ' ', working_dir: 'M' }]);
|
||||||
setDirectoryStatus(initial);
|
setDirectoryStatus(initial);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import type {
|
|||||||
} from '@/lib/api/types';
|
} from '@/lib/api/types';
|
||||||
import { getDeferredSafeStorage } from '@/stores/utils/safeStorage';
|
import { getDeferredSafeStorage } from '@/stores/utils/safeStorage';
|
||||||
import { getRuntimeKey } from '@/lib/runtime-switch';
|
import { getRuntimeKey } from '@/lib/runtime-switch';
|
||||||
|
import { subscribeGitStatusInvalidations } from '@/lib/gitStatusInvalidation';
|
||||||
|
|
||||||
const LOG_STALE_THRESHOLD = 10000;
|
const LOG_STALE_THRESHOLD = 10000;
|
||||||
const REPO_CHECK_STALE_THRESHOLD = 60_000;
|
const REPO_CHECK_STALE_THRESHOLD = 60_000;
|
||||||
@@ -57,7 +58,7 @@ interface GitStore {
|
|||||||
setActiveDirectory: (directory: string | null) => void;
|
setActiveDirectory: (directory: string | null) => void;
|
||||||
getDirectoryState: (directory: string) => DirectoryGitState | null;
|
getDirectoryState: (directory: string) => DirectoryGitState | null;
|
||||||
|
|
||||||
fetchStatus: (directory: string, git: GitAPI, options?: { silent?: boolean; mode?: 'light' }) => Promise<boolean>;
|
fetchStatus: (directory: string, git: GitAPI, options?: { silent?: boolean; mode?: 'light'; force?: boolean }) => Promise<boolean>;
|
||||||
fetchBranches: (directory: string, git: GitAPI) => Promise<void>;
|
fetchBranches: (directory: string, git: GitAPI) => Promise<void>;
|
||||||
fetchLog: (directory: string, git: GitAPI, maxCount?: number) => Promise<void>;
|
fetchLog: (directory: string, git: GitAPI, maxCount?: number) => Promise<void>;
|
||||||
fetchIdentity: (directory: string, git: GitAPI) => Promise<void>;
|
fetchIdentity: (directory: string, git: GitAPI) => Promise<void>;
|
||||||
@@ -99,7 +100,7 @@ interface GitAPI {
|
|||||||
|
|
||||||
const inFlightDiffFetchesByDirectory = new Map<string, Set<string>>();
|
const inFlightDiffFetchesByDirectory = new Map<string, Set<string>>();
|
||||||
const diffFetchGenerationByDirectory = new Map<string, number>();
|
const diffFetchGenerationByDirectory = new Map<string, number>();
|
||||||
const inFlightStatusFetches = new Map<string, Promise<boolean>>();
|
const inFlightStatusFetches = new Map<string, { promise: Promise<boolean>; statusMutationRevision: number }>();
|
||||||
const inFlightEnsureAllByDirectory = new Map<string, Promise<void>>();
|
const inFlightEnsureAllByDirectory = new Map<string, Promise<void>>();
|
||||||
const requestGenerationByChannel = new Map<string, number>();
|
const requestGenerationByChannel = new Map<string, number>();
|
||||||
const statusMutationRevisionByDirectory = new Map<string, number>();
|
const statusMutationRevisionByDirectory = new Map<string, number>();
|
||||||
@@ -150,6 +151,18 @@ const bumpStatusMutationRevision = (runtimeKey: string, directory: string): void
|
|||||||
statusMutationRevisionByDirectory.set(key, (statusMutationRevisionByDirectory.get(key) ?? 0) + 1);
|
statusMutationRevisionByDirectory.set(key, (statusMutationRevisionByDirectory.get(key) ?? 0) + 1);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getStatusMutationRevision = (runtimeKey: string, directory: string): number =>
|
||||||
|
statusMutationRevisionByDirectory.get(runtimeDirectoryKey(runtimeKey, directory)) ?? 0;
|
||||||
|
|
||||||
|
// A successful status-affecting git mutation invalidates the runtime adapter's
|
||||||
|
// status cache (see lib/gitStatusInvalidation.ts). Bump the per-directory
|
||||||
|
// mutation revision so a status request admitted before the mutation can
|
||||||
|
// neither be joined by a post-mutation refresh nor commit its stale payload
|
||||||
|
// over the refreshed state.
|
||||||
|
subscribeGitStatusInvalidations((directory) => {
|
||||||
|
bumpStatusMutationRevision(getRuntimeKey(), directory);
|
||||||
|
});
|
||||||
|
|
||||||
const getDiffFetchGeneration = (directory: string): number =>
|
const getDiffFetchGeneration = (directory: string): number =>
|
||||||
diffFetchGenerationByDirectory.get(runtimeDirectoryKey(getRuntimeKey(), directory)) ?? 0;
|
diffFetchGenerationByDirectory.get(runtimeDirectoryKey(getRuntimeKey(), directory)) ?? 0;
|
||||||
|
|
||||||
@@ -590,10 +603,16 @@ export const useGitStore = create<GitStore>()(
|
|||||||
const statusFetchMode: GitStatusFetchMode = options.mode ?? 'full';
|
const statusFetchMode: GitStatusFetchMode = options.mode ?? 'full';
|
||||||
const runtimeKey = getRuntimeKey();
|
const runtimeKey = getRuntimeKey();
|
||||||
const statusFetchKey = getStatusFetchKey(runtimeKey, directory, statusFetchMode);
|
const statusFetchKey = getStatusFetchKey(runtimeKey, directory, statusFetchMode);
|
||||||
const existing = inFlightStatusFetches.get(statusFetchKey)
|
const statusMutationRevision = getStatusMutationRevision(runtimeKey, directory);
|
||||||
?? (statusFetchMode === 'light' ? inFlightStatusFetches.get(getStatusFetchKey(runtimeKey, directory, 'full')) : undefined);
|
if (!options.force) {
|
||||||
if (existing) {
|
const existing = inFlightStatusFetches.get(statusFetchKey)
|
||||||
return existing;
|
?? (statusFetchMode === 'light' ? inFlightStatusFetches.get(getStatusFetchKey(runtimeKey, directory, 'full')) : undefined);
|
||||||
|
// Join an in-flight request only when it was admitted at the current
|
||||||
|
// mutation revision; a request that predates a mutation must not
|
||||||
|
// satisfy the post-mutation refresh.
|
||||||
|
if (existing && existing.statusMutationRevision === statusMutationRevision) {
|
||||||
|
return existing.promise;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = startRequest(directory, 'status', true);
|
const token = startRequest(directory, 'status', true);
|
||||||
@@ -727,12 +746,12 @@ export const useGitStore = create<GitStore>()(
|
|||||||
return statusChanged;
|
return statusChanged;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
inFlightStatusFetches.set(statusFetchKey, fetchPromise);
|
inFlightStatusFetches.set(statusFetchKey, { promise: fetchPromise, statusMutationRevision });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await fetchPromise;
|
return await fetchPromise;
|
||||||
} finally {
|
} finally {
|
||||||
if (inFlightStatusFetches.get(statusFetchKey) === fetchPromise) {
|
if (inFlightStatusFetches.get(statusFetchKey)?.promise === fetchPromise) {
|
||||||
inFlightStatusFetches.delete(statusFetchKey);
|
inFlightStatusFetches.delete(statusFetchKey);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -936,8 +955,11 @@ export const useGitStore = create<GitStore>()(
|
|||||||
const { force = false, silentIfCached = false } = options;
|
const { force = false, silentIfCached = false } = options;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
|
// `force` applies to status as well as log: a forced refresh must not
|
||||||
|
// resolve from an in-flight status request admitted earlier.
|
||||||
await get().fetchStatus(directory, git, {
|
await get().fetchStatus(directory, git, {
|
||||||
silent: silentIfCached && Boolean(dirState?.status),
|
silent: silentIfCached && Boolean(dirState?.status),
|
||||||
|
force,
|
||||||
});
|
});
|
||||||
|
|
||||||
const updatedDirState = get().directories.get(directory);
|
const updatedDirState = get().directories.get(directory);
|
||||||
|
|||||||
Reference in New Issue
Block a user