fix(git): make post-mutation status refresh authoritative (#2281)

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>
This commit is contained in:
Serhii Dziupin
2026-08-06 20:04:20 +00:00
co-authored by Serhii Dziupin
parent fe331c093b
commit 696349d606
6 changed files with 395 additions and 39 deletions
+214
View File
@@ -1,10 +1,30 @@
import { describe, expect, test } from 'bun:test';
import {
abortMerge,
abortRebase,
applyGitStash,
checkoutBranch,
checkoutCommit,
cherryPick,
continueMerge,
continueRebase,
createBranch,
deleteGitBranch,
deleteRemoteBranch,
dropGitStash,
getGitBranches,
getGitStatus,
gitFetch,
merge,
popGitStash,
rebase,
removeRemote,
renameBranch,
resetToCommit,
revertCommit,
stageGitFile,
stageGitFiles,
stashGitChanges,
unstageGitFile,
unstageGitFiles,
} 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', () => {
test('leaves low-level reads outside the background policy', async () => {
installWindowMock();