diff --git a/packages/ui/src/components/views/GitView.tsx b/packages/ui/src/components/views/GitView.tsx index 1a42984d..ab0afb92 100644 --- a/packages/ui/src/components/views/GitView.tsx +++ b/packages/ui/src/components/views/GitView.tsx @@ -1157,7 +1157,34 @@ export const GitView: React.FC = ({ 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> | undefined; + if ((afterPull.ahead ?? 0) > 0) { + result = await git.gitPush(currentDirectory); + } toast.success(t('gitView.toast.pushedToUpstream', { name: getPushedRemoteName(result) })); triggerFireworks(); await refreshStatusAndBranches(false); diff --git a/packages/ui/src/lib/gitApiHttp.test.ts b/packages/ui/src/lib/gitApiHttp.test.ts index de59719c..2d0d113e 100644 --- a/packages/ui/src/lib/gitApiHttp.test.ts +++ b/packages/ui/src/lib/gitApiHttp.test.ts @@ -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(); + } + }); +}); diff --git a/packages/ui/src/lib/gitApiHttp.ts b/packages/ui/src/lib/gitApiHttp.ts index adfa6830..04a0c449 100644 --- a/packages/ui/src/lib/gitApiHttp.ts +++ b/packages/ui/src/lib/gitApiHttp.ts @@ -43,10 +43,31 @@ const GIT_STATUS_CACHE_TTL_MS = 1200; const GIT_REPO_CHECK_CACHE_TTL_MS = 5000; const gitStatusCache = new Map(); const gitStatusInFlight = new Map>(); +const gitStatusCacheVersions = new Map(); const gitRepoCache = new Map(); const gitRepoInFlight = new Map>(); 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 export async function getGitStatus(directory: string, options?: { mode?: 'light' }): Promise { 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 { @@ -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 { @@ -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 { @@ -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 { @@ -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[] }> {