fix: sync before pushing git commits
Makes Commit & Sync fetch and pull before push when needed Prevents stale git status from showing already up to date Adds regression coverage for git status cache invalidation
This commit is contained in:
@@ -1157,7 +1157,34 @@ export const GitView: React.FC<GitViewProps> = ({ isActive }) => {
|
||||
await refreshStatusAndBranches();
|
||||
|
||||
if (options.pushAfter) {
|
||||
const result = await git.gitPush(currentDirectory);
|
||||
const trackingRemoteName = status?.tracking?.split('/')[0];
|
||||
const remote = effectiveRemotes.find((entry) => entry.name === trackingRemoteName) ?? effectiveRemotes[0];
|
||||
if (!remote) {
|
||||
throw new Error(t('mobile.changes.noRemote'));
|
||||
}
|
||||
|
||||
setSyncAction('sync');
|
||||
const trackingPrefix = `${remote.name}/`;
|
||||
const trackedBranch = status?.tracking?.startsWith(trackingPrefix)
|
||||
? status.tracking.slice(trackingPrefix.length)
|
||||
: undefined;
|
||||
|
||||
await git.gitFetch(currentDirectory, { remote: remote.name });
|
||||
const afterFetch = await git.getGitStatus(currentDirectory);
|
||||
if ((afterFetch.behind ?? 0) > 0) {
|
||||
if ((afterFetch.files?.length ?? 0) > 0) {
|
||||
toast.error(t('gitView.toast.commitOrStashBeforeSync'));
|
||||
await refreshStatusAndBranches(false);
|
||||
return;
|
||||
}
|
||||
await git.gitPull(currentDirectory, { remote: remote.name, branch: trackedBranch, rebase: true });
|
||||
}
|
||||
|
||||
const afterPull = await git.getGitStatus(currentDirectory);
|
||||
let result: Awaited<ReturnType<typeof git.gitPush>> | undefined;
|
||||
if ((afterPull.ahead ?? 0) > 0) {
|
||||
result = await git.gitPush(currentDirectory);
|
||||
}
|
||||
toast.success(t('gitView.toast.pushedToUpstream', { name: getPushedRemoteName(result) }));
|
||||
triggerFireworks();
|
||||
await refreshStatusAndBranches(false);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { stageGitFile, stageGitFiles, unstageGitFile, unstageGitFiles } from './gitApiHttp';
|
||||
import { getGitStatus, gitFetch, stageGitFile, stageGitFiles, unstageGitFile, unstageGitFiles } from './gitApiHttp';
|
||||
|
||||
type FetchCall = {
|
||||
input: RequestInfo | URL;
|
||||
@@ -110,3 +110,53 @@ describe('gitApiHttp index mutations', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('gitApiHttp status cache', () => {
|
||||
test('invalidates cached status after fetch', async () => {
|
||||
installWindowMock();
|
||||
const calls: FetchCall[] = [];
|
||||
let statusRequestCount = 0;
|
||||
globalThis.fetch = (async (input, init) => {
|
||||
calls.push({ input, init });
|
||||
const url = String(input);
|
||||
if (url.startsWith('/api/git/status')) {
|
||||
statusRequestCount += 1;
|
||||
return new Response(JSON.stringify({
|
||||
current: 'main',
|
||||
tracking: 'origin/main',
|
||||
ahead: 0,
|
||||
behind: statusRequestCount === 1 ? 0 : 2,
|
||||
files: [],
|
||||
isClean: true,
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
return new Response(JSON.stringify({ success: true }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
const directory = '/repo-cache-fetch';
|
||||
const first = await getGitStatus(directory);
|
||||
const cached = await getGitStatus(directory);
|
||||
await gitFetch(directory, { remote: 'origin' });
|
||||
const afterFetch = await getGitStatus(directory);
|
||||
|
||||
expect(first.behind).toBe(0);
|
||||
expect(cached.behind).toBe(0);
|
||||
expect(afterFetch.behind).toBe(2);
|
||||
expect(statusRequestCount).toBe(2);
|
||||
expect(calls.map((call) => String(call.input))).toEqual([
|
||||
'/api/git/status?directory=%2Frepo-cache-fetch',
|
||||
'/api/git/fetch?directory=%2Frepo-cache-fetch',
|
||||
'/api/git/status?directory=%2Frepo-cache-fetch',
|
||||
]);
|
||||
} finally {
|
||||
restoreMocks();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -43,10 +43,31 @@ const GIT_STATUS_CACHE_TTL_MS = 1200;
|
||||
const GIT_REPO_CHECK_CACHE_TTL_MS = 5000;
|
||||
const gitStatusCache = new Map<string, { value: GitStatus; expiresAt: number }>();
|
||||
const gitStatusInFlight = new Map<string, Promise<GitStatus>>();
|
||||
const gitStatusCacheVersions = new Map<string, number>();
|
||||
const gitRepoCache = new Map<string, { value: boolean; expiresAt: number }>();
|
||||
const gitRepoInFlight = new Map<string, Promise<boolean>>();
|
||||
|
||||
const normalizeDirectoryKey = (directory: string): string => directory.trim();
|
||||
const getStatusCacheKey = (directory: string, mode?: 'light'): string =>
|
||||
mode === 'light' ? `${normalizeDirectoryKey(directory)}::light` : normalizeDirectoryKey(directory);
|
||||
|
||||
const getStatusCacheVersion = (directory: string): number =>
|
||||
gitStatusCacheVersions.get(normalizeDirectoryKey(directory)) ?? 0;
|
||||
|
||||
const invalidateGitStatusCache = (directory: string): void => {
|
||||
const key = normalizeDirectoryKey(directory);
|
||||
gitStatusCacheVersions.set(key, getStatusCacheVersion(directory) + 1);
|
||||
for (const cacheKey of Array.from(gitStatusCache.keys())) {
|
||||
if (cacheKey === key || cacheKey.startsWith(`${key}::`)) {
|
||||
gitStatusCache.delete(cacheKey);
|
||||
}
|
||||
}
|
||||
for (const cacheKey of Array.from(gitStatusInFlight.keys())) {
|
||||
if (cacheKey === key || cacheKey.startsWith(`${key}::`)) {
|
||||
gitStatusInFlight.delete(cacheKey);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function buildUrl(
|
||||
path: string,
|
||||
@@ -98,7 +119,7 @@ export async function checkIsGitRepository(directory: string): Promise<boolean>
|
||||
|
||||
export async function getGitStatus(directory: string, options?: { mode?: 'light' }): Promise<GitStatus> {
|
||||
const mode = options?.mode;
|
||||
const key = mode === 'light' ? `${normalizeDirectoryKey(directory)}::light` : normalizeDirectoryKey(directory);
|
||||
const key = getStatusCacheKey(directory, mode);
|
||||
const now = Date.now();
|
||||
const cached = gitStatusCache.get(key);
|
||||
if (cached && cached.expiresAt > now) {
|
||||
@@ -111,15 +132,18 @@ export async function getGitStatus(directory: string, options?: { mode?: 'light'
|
||||
}
|
||||
|
||||
const task = (async () => {
|
||||
const cacheVersion = getStatusCacheVersion(directory);
|
||||
const response = await runtimeFetch(buildUrl(`${API_BASE}/status`, directory, mode ? { mode } : undefined));
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to get git status: ${response.statusText}`);
|
||||
}
|
||||
const payload = await response.json() as GitStatus;
|
||||
gitStatusCache.set(key, {
|
||||
value: payload,
|
||||
expiresAt: Date.now() + GIT_STATUS_CACHE_TTL_MS,
|
||||
});
|
||||
if (getStatusCacheVersion(directory) === cacheVersion) {
|
||||
gitStatusCache.set(key, {
|
||||
value: payload,
|
||||
expiresAt: Date.now() + GIT_STATUS_CACHE_TTL_MS,
|
||||
});
|
||||
}
|
||||
return payload;
|
||||
})();
|
||||
|
||||
@@ -241,6 +265,8 @@ export async function revertGitFile(
|
||||
.catch(() => ({ error: response.statusText }));
|
||||
throw new Error(message.error || 'Failed to revert git changes');
|
||||
}
|
||||
|
||||
invalidateGitStatusCache(directory);
|
||||
}
|
||||
|
||||
export async function stageGitFile(directory: string, filePath: string): Promise<void> {
|
||||
@@ -264,6 +290,8 @@ export async function stageGitFiles(directory: string, filePaths: string[]): Pro
|
||||
const message = await response.json().catch(() => ({ error: response.statusText }));
|
||||
throw new Error(message.error || 'Failed to stage git changes');
|
||||
}
|
||||
|
||||
invalidateGitStatusCache(directory);
|
||||
}
|
||||
|
||||
export async function unstageGitFile(directory: string, filePath: string): Promise<void> {
|
||||
@@ -287,6 +315,8 @@ export async function unstageGitFiles(directory: string, filePaths: string[]): P
|
||||
const message = await response.json().catch(() => ({ error: response.statusText }));
|
||||
throw new Error(message.error || 'Failed to unstage git changes');
|
||||
}
|
||||
|
||||
invalidateGitStatusCache(directory);
|
||||
}
|
||||
|
||||
export async function stageGitHunk(directory: string, filePath: string, patch: string): Promise<void> {
|
||||
@@ -324,6 +354,8 @@ async function applyGitHunk(
|
||||
const message = await response.json().catch(() => ({ error: response.statusText }));
|
||||
throw new Error(message.error || 'Failed to apply git hunk');
|
||||
}
|
||||
|
||||
invalidateGitStatusCache(directory);
|
||||
}
|
||||
|
||||
export async function isLinkedWorktree(directory: string): Promise<boolean> {
|
||||
@@ -608,7 +640,9 @@ export async function createGitCommit(
|
||||
const error = await response.json().catch(() => ({ error: response.statusText }));
|
||||
throw new Error(error.error || 'Failed to create commit');
|
||||
}
|
||||
return response.json();
|
||||
const result = await response.json();
|
||||
invalidateGitStatusCache(directory);
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function gitPush(
|
||||
@@ -624,7 +658,9 @@ export async function gitPush(
|
||||
const error = await response.json().catch(() => ({ error: response.statusText }));
|
||||
throw new Error(error.error || 'Failed to push');
|
||||
}
|
||||
return response.json();
|
||||
const result = await response.json();
|
||||
invalidateGitStatusCache(directory);
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function gitPull(
|
||||
@@ -640,7 +676,9 @@ export async function gitPull(
|
||||
const error = await response.json().catch(() => ({ error: response.statusText }));
|
||||
throw new Error(error.error || 'Failed to pull');
|
||||
}
|
||||
return response.json();
|
||||
const result = await response.json();
|
||||
invalidateGitStatusCache(directory);
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function gitFetch(
|
||||
@@ -656,7 +694,9 @@ export async function gitFetch(
|
||||
const error = await response.json().catch(() => ({ error: response.statusText }));
|
||||
throw new Error(error.error || 'Failed to fetch');
|
||||
}
|
||||
return response.json();
|
||||
const result = await response.json();
|
||||
invalidateGitStatusCache(directory);
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function listGitStashes(directory: string): Promise<{ stashes: GitStashEntry[] }> {
|
||||
|
||||
Reference in New Issue
Block a user