From 78a0f0f62f6f793093abfaa68caf8bc2babcdb40 Mon Sep 17 00:00:00 2001 From: hugo Date: Sun, 22 Feb 2026 11:10:54 +0100 Subject: [PATCH] fix: made git more stable --- src/main/engine/GitEngine.ts | 481 +++++++++++++++++- .../components/GitSidebar/GitSidebar.tsx | 2 +- tests/engine/GitEngine.test.ts | 83 ++- tests/renderer/components/GitSidebar.test.tsx | 6 +- 4 files changed, 540 insertions(+), 32 deletions(-) diff --git a/src/main/engine/GitEngine.ts b/src/main/engine/GitEngine.ts index 7911f33..83445ef 100644 --- a/src/main/engine/GitEngine.ts +++ b/src/main/engine/GitEngine.ts @@ -1,6 +1,7 @@ import { simpleGit } from 'simple-git'; import * as fsPromises from 'fs/promises'; import * as path from 'path'; +import { execFile } from 'node:child_process'; export interface GitAvailability { gitFound: boolean; @@ -155,7 +156,7 @@ export class GitEngine { 'html/', ]; - private createNonInteractiveGit(projectPath: string): ReturnType { + private createNonInteractiveGit(projectPath?: string): ReturnType { return simpleGit(projectPath) .env('GIT_TERMINAL_PROMPT', '0') .env('GCM_INTERACTIVE', 'never') @@ -397,6 +398,279 @@ export class GitEngine { return normalized.includes('spawn ebadf'); } + private runGitCli(projectPath: string, args: string[], allowRetry = true): Promise { + return new Promise((resolve, reject) => { + execFile( + 'git', + args, + { + cwd: projectPath, + env: { + ...process.env, + GIT_TERMINAL_PROMPT: '0', + GCM_INTERACTIVE: 'never', + GIT_SSH_COMMAND: 'ssh -oBatchMode=yes', + }, + windowsHide: true, + maxBuffer: 10 * 1024 * 1024, + }, + (error, stdout, stderr) => { + if (!error) { + resolve(stdout); + return; + } + + const composedMessage = [stderr?.toString().trim(), error.message] + .filter((part) => Boolean(part)) + .join(' | '); + + if (allowRetry && this.isSpawnBadFileDescriptorError(composedMessage)) { + this.runGitCli(projectPath, args, false).then(resolve).catch(reject); + return; + } + + reject(new Error(composedMessage || 'Git command failed.')); + }, + ); + }); + } + + private parseGitLogCliOutput(raw: string): Array<{ hash: string; date: string; message: string; author_name: string }> { + return raw + .split('\x1e') + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0) + .map((entry) => { + const [hash, date, message, author] = entry.split('\x1f'); + return { + hash: hash || '', + date: date || '', + message: message || '', + author_name: author || '', + }; + }) + .filter((entry) => entry.hash.length > 0); + } + + private parsePorcelainStatus(raw: string): GitStatusDto { + const records = raw.split('\0').filter((record) => record.length > 0); + const files: GitStatusFile[] = []; + + for (let index = 0; index < records.length; index += 1) { + const record = records[index]; + const x = record[0] ?? ' '; + const y = record[1] ?? ' '; + const pathValue = record.slice(3); + + if (x === '?' && y === '?') { + files.push({ path: pathValue, status: 'untracked' }); + continue; + } + + if (x === 'R' || y === 'R') { + const previousPath = pathValue; + const renamedTo = records[index + 1] ?? pathValue; + files.push({ path: renamedTo, status: 'renamed', previousPath }); + index += 1; + continue; + } + + if (x === 'D' || y === 'D') { + files.push({ path: pathValue, status: 'deleted' }); + continue; + } + + if (y === 'M' || y === 'A' || y === 'T') { + files.push({ path: pathValue, status: 'modified' }); + continue; + } + + if (x !== ' ') { + files.push({ path: pathValue, status: 'staged' }); + } + } + + const counts: GitStatusCounts = { + untracked: files.filter((file) => file.status === 'untracked').length, + modified: files.filter((file) => file.status === 'modified').length, + deleted: files.filter((file) => file.status === 'deleted').length, + renamed: files.filter((file) => file.status === 'renamed').length, + staged: files.filter((file) => file.status === 'staged').length, + total: files.length, + }; + + return { files, counts }; + } + + private async getStatusViaCli(projectPath: string): Promise { + const raw = await this.runGitCli(projectPath, ['status', '--porcelain=v1', '-z']); + return this.parsePorcelainStatus(raw); + } + + private async getRepoStateViaCli(projectPath: string): Promise { + try { + const isInsideRaw = await this.runGitCli(projectPath, ['rev-parse', '--is-inside-work-tree']); + if (isInsideRaw.trim() !== 'true') { + return { isRepo: false, hasRemote: false }; + } + } catch { + return { isRepo: false, hasRemote: false }; + } + + const rootPath = (await this.runGitCli(projectPath, ['rev-parse', '--show-toplevel'])).trim(); + + let currentBranch: string | undefined; + try { + const branch = (await this.runGitCli(projectPath, ['symbolic-ref', '--short', 'HEAD'])).trim(); + currentBranch = branch || undefined; + } catch { + currentBranch = undefined; + } + + let hasRemote = false; + try { + const upstream = (await this.runGitCli(projectPath, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}'])).trim(); + hasRemote = upstream.length > 0; + } catch { + hasRemote = false; + } + + return { + isRepo: true, + rootPath, + currentBranch, + hasRemote, + }; + } + + private async getRemoteStateViaCli(projectPath: string): Promise { + let localBranch: string | null = null; + try { + localBranch = (await this.runGitCli(projectPath, ['symbolic-ref', '--short', 'HEAD'])).trim() || null; + } catch { + localBranch = null; + } + + let upstreamBranch: string | null = null; + try { + upstreamBranch = (await this.runGitCli(projectPath, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}'])).trim() || null; + } catch { + upstreamBranch = null; + } + + if (!upstreamBranch) { + return { + localBranch, + upstreamBranch: null, + hasUpstream: false, + ahead: 0, + behind: 0, + }; + } + + let ahead = 0; + let behind = 0; + + try { + ahead = Number((await this.runGitCli(projectPath, ['rev-list', '--count', `${upstreamBranch}..HEAD`])).trim() || 0); + } catch { + ahead = 0; + } + + try { + behind = Number((await this.runGitCli(projectPath, ['rev-list', '--count', `HEAD..${upstreamBranch}`])).trim() || 0); + } catch { + behind = 0; + } + + return { + localBranch, + upstreamBranch, + hasUpstream: true, + ahead, + behind, + }; + } + + private async getHistoryViaCli(projectPath: string, limit: number): Promise { + const format = '%H%x1f%aI%x1f%s%x1f%aN%x1e'; + const localRaw = await this.runGitCli(projectPath, ['log', `--pretty=format:${format}`, '--max-count', String(limit)]); + const localCommits = this.parseGitLogCliOutput(localRaw); + + const mapLocalHistory = (): GitHistoryEntry[] => localCommits.map((entry) => ({ + hash: entry.hash, + shortHash: entry.hash.slice(0, 7), + date: entry.date, + subject: entry.message, + author: entry.author_name, + syncStatus: 'local-only', + })); + + let upstreamBranch: string | null = null; + try { + upstreamBranch = (await this.runGitCli(projectPath, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}'])).trim(); + } catch { + return mapLocalHistory(); + } + + if (!upstreamBranch) { + return mapLocalHistory(); + } + + let behindCount = 0; + try { + behindCount = Number((await this.runGitCli(projectPath, ['rev-list', '--count', `HEAD..${upstreamBranch}`])).trim() || 0); + } catch { + behindCount = 0; + } + + const remoteHistoryLimit = Math.max(limit, limit + Math.max(behindCount, 0)); + let remoteCommits: Array<{ hash: string; date: string; message: string; author_name: string }> = []; + + try { + const remoteRaw = await this.runGitCli(projectPath, ['log', `--pretty=format:${format}`, '--max-count', String(remoteHistoryLimit), upstreamBranch]); + remoteCommits = this.parseGitLogCliOutput(remoteRaw); + } catch { + return mapLocalHistory(); + } + + const localMap = new Map(localCommits.map((entry) => [entry.hash, entry])); + const remoteMap = new Map(remoteCommits.map((entry) => [entry.hash, entry])); + + const combined = new Map(); + for (const entry of localMap.values()) { + combined.set(entry.hash, entry); + } + for (const entry of remoteMap.values()) { + if (!combined.has(entry.hash)) { + combined.set(entry.hash, entry); + } + } + + const classifiedEntries = Array.from(combined.values()) + .sort((first, second) => new Date(second.date).getTime() - new Date(first.date).getTime()) + .map((entry) => { + const inLocal = localMap.has(entry.hash); + const inRemote = remoteMap.has(entry.hash); + const syncStatus: GitHistorySyncStatus = inLocal && inRemote ? 'both' : inLocal ? 'local-only' : 'remote-only'; + + return { + hash: entry.hash, + shortHash: entry.hash.slice(0, 7), + date: entry.date, + subject: entry.message, + author: entry.author_name, + syncStatus, + }; + }); + + const remoteOnlyEntries = classifiedEntries.filter((entry) => entry.syncStatus === 'remote-only'); + const localAndSyncedEntries = classifiedEntries.filter((entry) => entry.syncStatus !== 'remote-only').slice(0, limit); + + return [...localAndSyncedEntries, ...remoteOnlyEntries] + .sort((first, second) => new Date(second.date).getTime() - new Date(first.date).getTime()); + } + private async getCurrentBranchName(git: ReturnType): Promise { try { const status = await git.status(); @@ -508,7 +782,7 @@ export class GitEngine { async checkAvailability(): Promise { try { - const versionResult = await simpleGit().version(); + const versionResult = await this.createNonInteractiveGit().version(); return { gitFound: true, version: `${versionResult.major}.${versionResult.minor}.${versionResult.patch}`, @@ -519,8 +793,17 @@ export class GitEngine { } async getRepoState(projectPath: string): Promise { - const git = simpleGit(projectPath); - const isRepo = await git.checkIsRepo(); + const git = this.createNonInteractiveGit(projectPath); + let isRepo; + try { + isRepo = await git.checkIsRepo(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error ?? ''); + if (this.isSpawnBadFileDescriptorError(message)) { + return this.getRepoStateViaCli(projectPath); + } + throw error; + } if (!isRepo) { return { @@ -529,10 +812,20 @@ export class GitEngine { }; } - const [rootPath, status] = await Promise.all([ - git.revparse(['--show-toplevel']), - git.status(), - ]); + let rootPath; + let status; + try { + [rootPath, status] = await Promise.all([ + git.revparse(['--show-toplevel']), + git.status(), + ]); + } catch (error) { + const message = error instanceof Error ? error.message : String(error ?? ''); + if (this.isSpawnBadFileDescriptorError(message)) { + return this.getRepoStateViaCli(projectPath); + } + throw error; + } return { isRepo: true, @@ -543,8 +836,17 @@ export class GitEngine { } async getStatus(projectPath: string): Promise { - const git = simpleGit(projectPath); - const status = await git.status(); + const git = this.createNonInteractiveGit(projectPath); + let status; + try { + status = await git.status(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error ?? ''); + if (this.isSpawnBadFileDescriptorError(message)) { + return this.getStatusViaCli(projectPath); + } + throw error; + } const files: GitStatusFile[] = [ ...status.not_added.map((filePath) => ({ path: filePath, status: 'untracked' as const })), @@ -574,8 +876,18 @@ export class GitEngine { } async getDiff(projectPath: string, filePath: string): Promise { - const git = simpleGit(projectPath); - const patch = await git.diff(['--', filePath]); + const git = this.createNonInteractiveGit(projectPath); + let patch; + try { + patch = await git.diff(['--', filePath]); + } catch (error) { + const message = error instanceof Error ? error.message : String(error ?? ''); + if (this.isSpawnBadFileDescriptorError(message)) { + patch = await this.runGitCli(projectPath, ['diff', '--', filePath]); + } else { + throw error; + } + } return { filePath, @@ -584,10 +896,20 @@ export class GitEngine { } async getDiffContent(projectPath: string, filePath: string): Promise { - const git = simpleGit(projectPath); + const git = this.createNonInteractiveGit(projectPath); const [original, modified] = await Promise.all([ - git.show([`HEAD:${filePath}`]).catch(() => ''), + git.show([`HEAD:${filePath}`]).catch(async (error) => { + const message = error instanceof Error ? error.message : String(error ?? ''); + if (this.isSpawnBadFileDescriptorError(message)) { + try { + return await this.runGitCli(projectPath, ['show', `HEAD:${filePath}`]); + } catch { + return ''; + } + } + return ''; + }), fsPromises.readFile(path.join(projectPath, filePath), 'utf8').catch(() => ''), ]); @@ -599,8 +921,18 @@ export class GitEngine { } async getCommitDiffContent(projectPath: string, commitHash: string): Promise { - const git = simpleGit(projectPath); - const patch = await git.show(['--format=', '--patch', commitHash]); + const git = this.createNonInteractiveGit(projectPath); + let patch; + try { + patch = await git.show(['--format=', '--patch', commitHash]); + } catch (error) { + const message = error instanceof Error ? error.message : String(error ?? ''); + if (this.isSpawnBadFileDescriptorError(message)) { + patch = await this.runGitCli(projectPath, ['show', '--format=', '--patch', commitHash]); + } else { + throw error; + } + } const files = this.parseUnifiedPatchFiles(patch); if (files.length === 0) { @@ -723,8 +1055,27 @@ export class GitEngine { async getHistory(projectPath: string, limit = 20): Promise { const git = this.createNonInteractiveGit(projectPath); - const status = await git.status(); - const localHistory = await git.log({ maxCount: limit }); + let status; + try { + status = await git.status(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error ?? ''); + if (this.isSpawnBadFileDescriptorError(message)) { + return this.getHistoryViaCli(projectPath, limit); + } + throw error; + } + + let localHistory; + try { + localHistory = await git.log({ maxCount: limit }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error ?? ''); + if (this.isSpawnBadFileDescriptorError(message)) { + return this.getHistoryViaCli(projectPath, limit); + } + throw error; + } const mapLocalHistory = (): GitHistoryEntry[] => localHistory.all.map((entry) => ({ hash: entry.hash, @@ -806,8 +1157,26 @@ export class GitEngine { } async getFileHistory(projectPath: string, filePath: string, limit = 50): Promise { - const git = simpleGit(projectPath); - const history = await git.log(['--max-count', String(limit), '--', filePath]); + const git = this.createNonInteractiveGit(projectPath); + let history; + try { + history = await git.log(['--max-count', String(limit), '--', filePath]); + } catch (error) { + const message = error instanceof Error ? error.message : String(error ?? ''); + if (this.isSpawnBadFileDescriptorError(message)) { + const format = '%H%x1f%aI%x1f%s%x1f%aN%x1e'; + const raw = await this.runGitCli(projectPath, ['log', `--pretty=format:${format}`, '--max-count', String(limit), '--', filePath]); + const cliEntries = this.parseGitLogCliOutput(raw); + return cliEntries.map((entry) => ({ + hash: entry.hash, + shortHash: entry.hash.slice(0, 7), + date: entry.date, + subject: entry.message, + author: entry.author_name, + })); + } + throw error; + } return history.all.map((entry) => ({ hash: entry.hash, @@ -819,8 +1188,17 @@ export class GitEngine { } async getRemoteState(projectPath: string): Promise { - const git = simpleGit(projectPath); - const status = await git.status(); + const git = this.createNonInteractiveGit(projectPath); + let status; + try { + status = await git.status(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error ?? ''); + if (this.isSpawnBadFileDescriptorError(message)) { + return this.getRemoteStateViaCli(projectPath); + } + throw error; + } const localBranch = typeof status.current === 'string' && status.current.trim().length > 0 ? status.current @@ -844,6 +1222,15 @@ export class GitEngine { await git.fetch(['--prune']); return { success: true }; } catch (error) { + const message = error instanceof Error ? error.message : String(error ?? ''); + if (this.isSpawnBadFileDescriptorError(message)) { + try { + await this.runGitCli(projectPath, ['fetch', '--prune']); + return { success: true }; + } catch { + // continue to action failure mapping below + } + } return this.toActionFailure(git, error, 'Failed to fetch remote updates.'); } } @@ -854,6 +1241,15 @@ export class GitEngine { await git.pull(); return { success: true }; } catch (error) { + const message = error instanceof Error ? error.message : String(error ?? ''); + if (this.isSpawnBadFileDescriptorError(message)) { + try { + await this.runGitCli(projectPath, ['pull']); + return { success: true }; + } catch { + // continue to action failure mapping below + } + } return this.toActionFailure(git, error, 'Failed to pull remote changes.'); } } @@ -865,6 +1261,14 @@ export class GitEngine { return { success: true }; } catch (error) { const message = error instanceof Error ? error.message : 'Failed to push local commits.'; + if (this.isSpawnBadFileDescriptorError(message)) { + try { + await this.runGitCli(projectPath, ['push']); + return { success: true }; + } catch { + // continue with existing upstream handling below + } + } if (this.isNoUpstreamError(message)) { const currentBranch = await this.getCurrentBranchName(git); if (currentBranch) { @@ -889,15 +1293,28 @@ export class GitEngine { }; } - const git = simpleGit(projectPath); + const git = this.createNonInteractiveGit(projectPath); try { await git.add(['-A']); await git.commit(normalizedMessage); return { success: true }; } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Failed to create commit.'; + if (this.isSpawnBadFileDescriptorError(errorMessage)) { + try { + await this.runGitCli(projectPath, ['add', '-A']); + await this.runGitCli(projectPath, ['commit', '-m', normalizedMessage]); + return { success: true }; + } catch (cliError) { + return { + success: false, + error: cliError instanceof Error ? cliError.message : 'Failed to create commit.', + }; + } + } return { success: false, - error: error instanceof Error ? error.message : 'Failed to create commit.', + error: errorMessage, }; } } @@ -949,7 +1366,7 @@ export class GitEngine { } async pruneLfsCache(projectPath: string, options: GitLfsPruneOptions = {}): Promise { - const git = simpleGit(projectPath); + const git = this.createNonInteractiveGit(projectPath); const verifyRemote = options.verifyRemote ?? true; const dryRun = options.dryRun ?? false; const recentCommitsToKeep = Math.max(0, options.recentCommitsToKeep ?? 2); @@ -986,7 +1403,17 @@ export class GitEngine { } try { - const output = await git.raw(args); + let output; + try { + output = await git.raw(args); + } catch (error) { + const message = error instanceof Error ? error.message : String(error ?? ''); + if (this.isSpawnBadFileDescriptorError(message)) { + output = await this.runGitCli(projectPath, args); + } else { + throw error; + } + } return { success: true, dryRun, @@ -1026,7 +1453,7 @@ export class GitEngine { }; } - const git = simpleGit(projectPath); + const git = this.createNonInteractiveGit(projectPath); const isRepo = await git.checkIsRepo(); if (isRepo) { diff --git a/src/renderer/components/GitSidebar/GitSidebar.tsx b/src/renderer/components/GitSidebar/GitSidebar.tsx index 6ff91e4..0e6be39 100644 --- a/src/renderer/components/GitSidebar/GitSidebar.tsx +++ b/src/renderer/components/GitSidebar/GitSidebar.tsx @@ -240,7 +240,7 @@ export const GitSidebar: React.FC = () => { } if (repoState.hasRemote) { - await refreshRemoteState(resolvedProjectPath); + await refreshRemoteState(resolvedProjectPath, { fetchFirst: true }); if (!isCurrentRequest()) { return; } diff --git a/tests/engine/GitEngine.test.ts b/tests/engine/GitEngine.test.ts index af5afd8..9fdecf0 100644 --- a/tests/engine/GitEngine.test.ts +++ b/tests/engine/GitEngine.test.ts @@ -18,10 +18,11 @@ const mockPush = vi.fn(); const mockGetRemotes = vi.fn(); const mockAddRemote = vi.fn(); const mockRemote = vi.fn(); -const { mockReadFile, mockStat, mockWriteFile } = vi.hoisted(() => ({ +const { mockReadFile, mockStat, mockWriteFile, mockExecFile } = vi.hoisted(() => ({ mockReadFile: vi.fn(), mockStat: vi.fn(), mockWriteFile: vi.fn(), + mockExecFile: vi.fn(), })); vi.mock('fs/promises', () => ({ @@ -54,6 +55,13 @@ vi.mock('simple-git', () => ({ })), })); +vi.mock('node:child_process', () => ({ + execFile: mockExecFile, + default: { + execFile: mockExecFile, + }, +})); + import { GitEngine } from '../../src/main/engine/GitEngine'; describe('GitEngine', () => { @@ -85,6 +93,9 @@ describe('GitEngine', () => { addRemote: mockAddRemote, remote: mockRemote, })); + mockExecFile.mockImplementation((_command, _args, _options, callback) => { + callback(null, '', ''); + }); gitEngine = new GitEngine(); }); @@ -171,6 +182,29 @@ describe('GitEngine', () => { { path: 'staged.md', status: 'staged' }, ]); }); + + it('should recover status through CLI when status command fails with spawn EBADF', async () => { + mockStatus.mockRejectedValue(new Error('Error: spawn EBADF')); + mockExecFile.mockImplementation((command, args, _options, callback) => { + expect(command).toBe('git'); + expect(args).toEqual(['status', '--porcelain=v1', '-z']); + callback(null, '?? new-file.md\0', ''); + }); + + const result = await gitEngine.getStatus('/tmp/project'); + + expect(result).toEqual({ + files: [{ path: 'new-file.md', status: 'untracked' }], + counts: { + untracked: 1, + modified: 0, + deleted: 0, + renamed: 0, + staged: 0, + total: 1, + }, + }); + }); }); describe('getDiff', () => { @@ -465,6 +499,53 @@ describe('GitEngine', () => { }, ]); }); + + it('should recover with CLI history when local log fails with spawn EBADF', async () => { + mockStatus.mockResolvedValue({ current: 'main', tracking: 'origin/main' }); + mockLog.mockRejectedValueOnce(new Error('Error: spawn EBADF')); + + mockExecFile.mockImplementation((command, args, _options, callback) => { + expect(command).toBe('git'); + const normalizedArgs = Array.isArray(args) ? args : []; + + if (normalizedArgs[0] === 'log' && normalizedArgs.includes('--max-count') && normalizedArgs[normalizedArgs.length - 1] === '20') { + const local = 'abc123def456\x1f2026-02-16T10:00:00.000Z\x1ffeat: local via cli\x1fLocal Dev\x1e'; + callback(null, local, ''); + return; + } + + if (normalizedArgs[0] === 'rev-parse') { + callback(null, 'origin/main\n', ''); + return; + } + + if (normalizedArgs[0] === 'rev-list') { + callback(null, '0\n', ''); + return; + } + + if (normalizedArgs[0] === 'log' && normalizedArgs[normalizedArgs.length - 1] === 'origin/main') { + callback(null, '', ''); + return; + } + + callback(new Error(`Unexpected git args: ${normalizedArgs.join(' ')}`), '', ''); + }); + + const result = await gitEngine.getHistory('/tmp/project', 20); + + expect(result).toEqual([ + { + hash: 'abc123def456', + shortHash: 'abc123d', + date: '2026-02-16T10:00:00.000Z', + subject: 'feat: local via cli', + author: 'Local Dev', + syncStatus: 'local-only', + }, + ]); + expect(mockExecFile).toHaveBeenCalled(); + }); }); describe('getFileHistory', () => { diff --git a/tests/renderer/components/GitSidebar.test.tsx b/tests/renderer/components/GitSidebar.test.tsx index dcabb6a..f53cab4 100644 --- a/tests/renderer/components/GitSidebar.test.tsx +++ b/tests/renderer/components/GitSidebar.test.tsx @@ -750,7 +750,7 @@ describe('GitSidebar', () => { fireEvent.click(fetchButton); }); - expect(await screen.findByText(/authentication required/i)).toBeInTheDocument(); + expect((await screen.findAllByText(/authentication required/i)).length).toBeGreaterThan(0); expect(screen.getAllByText(/create a github personal access token/i)).toHaveLength(1); expect(screen.getByText(/retry with username \+ token/i)).toBeInTheDocument(); }); @@ -872,8 +872,8 @@ describe('GitSidebar', () => { await Promise.resolve(); }); + expect((window as any).electronAPI.git.fetch).toHaveBeenCalledTimes(1); expect((window as any).electronAPI.git.getRemoteState).toHaveBeenCalledTimes(1); - expect((window as any).electronAPI.git.fetch).toHaveBeenCalledTimes(0); await act(async () => { vi.advanceTimersByTime(30000); @@ -881,7 +881,7 @@ describe('GitSidebar', () => { await Promise.resolve(); }); - expect((window as any).electronAPI.git.fetch).toHaveBeenCalledTimes(1); + expect((window as any).electronAPI.git.fetch).toHaveBeenCalledTimes(2); expect((window as any).electronAPI.git.getRemoteState).toHaveBeenCalledTimes(2); } finally { vi.useRealTimers();