From 3c9d4b6bce0249e57b8cf09feda2fb29bb41d301 Mon Sep 17 00:00:00 2001 From: hugo Date: Mon, 16 Feb 2026 12:57:42 +0100 Subject: [PATCH] feat: added auth checks and first-push checks. --- src/main/engine/GitEngine.ts | 278 +++++++++++++++++- src/main/shared/electronApi.ts | 2 + .../components/GitSidebar/GitSidebar.css | 12 + .../components/GitSidebar/GitSidebar.tsx | 45 ++- tests/engine/GitEngine.test.ts | 128 ++++++++ tests/renderer/components/GitSidebar.test.tsx | 59 ++++ 6 files changed, 504 insertions(+), 20 deletions(-) diff --git a/src/main/engine/GitEngine.ts b/src/main/engine/GitEngine.ts index 65e6ffc..a5f4041 100644 --- a/src/main/engine/GitEngine.ts +++ b/src/main/engine/GitEngine.ts @@ -100,9 +100,13 @@ export interface GitLfsPruneResult { export interface GitActionResult { success: boolean; + code?: 'auth-required' | 'conflict' | 'network' | 'action-failed'; error?: string; + guidance?: string[]; } +type GitProvider = 'unknown' | 'github' | 'gitlab' | 'gitea-forgejo'; + let gitEngineInstance: GitEngine | null = null; export function getGitEngine(): GitEngine { @@ -124,6 +128,241 @@ export class GitEngine { '.fseventsd', ]; + private createNonInteractiveGit(projectPath: string): ReturnType { + return simpleGit(projectPath) + .env('GIT_TERMINAL_PROMPT', '0') + .env('GCM_INTERACTIVE', 'never') + .env('GIT_SSH_COMMAND', 'ssh -oBatchMode=yes'); + } + + private getPlatformAuthGuidance(): string { + if (process.platform === 'darwin') { + return 'On macOS, authenticate using Git Credential Manager (`brew install --cask git-credential-manager`), then run `git credential-manager configure`, or use SSH keys added to the keychain and loaded into ssh-agent.'; + } + if (process.platform === 'win32') { + return 'On Windows, install Git Credential Manager (included with Git for Windows), run `git credential-manager configure`, then sign in when prompted, or configure SSH keys in the OpenSSH agent.'; + } + return 'On Linux, install Git Credential Manager (`gcm`) and configure it, or use SSH keys with ssh-agent. Ensure your key is loaded before running fetch/pull/push.'; + } + + private getPlatformAuthGuidanceSteps(): string[] { + if (process.platform === 'darwin') { + return [ + 'Install Git Credential Manager: brew install --cask git-credential-manager', + 'Configure it: git credential-manager configure', + 'Alternatively use SSH keys in macOS keychain + ssh-agent.', + ]; + } + if (process.platform === 'win32') { + return [ + 'Install Git for Windows (includes Git Credential Manager).', + 'Configure it: git credential-manager configure', + 'Alternatively configure SSH keys with the OpenSSH agent.', + ]; + } + return [ + 'Install Git Credential Manager (`gcm`) and configure it.', + 'Or use SSH keys with ssh-agent and ensure the key is loaded before fetch/pull/push.', + ]; + } + + private getGitHubGuidance(): string { + return [ + 'GitHub detected: use HTTPS with a Personal Access Token (classic/fine-grained) as password, or use SSH keys.', + 'Token flow: create a token in GitHub Settings > Developer settings > Personal access tokens with at least `repo` scope (or equivalent fine-grained repository permissions).', + 'Then run `git config --global credential.helper manager` (or `osxkeychain` on macOS if preferred), retry fetch/pull/push, and use your GitHub username + token when prompted.', + 'SSH alternative: switch origin with `git remote set-url origin git@github.com:/.git` and ensure your SSH key is loaded.', + ].join(' '); + } + + private getGitLabGuidanceSteps(): string[] { + return [ + 'Create a GitLab Personal Access Token in User Settings > Access Tokens.', + 'Grant at least `read_repository` and `write_repository` scopes (or equivalent project token permissions).', + 'Set credential helper: git config --global credential.helper manager (or osxkeychain on macOS).', + 'Retry and log in with your GitLab username and the token as password.', + 'SSH alternative: git remote set-url origin git@gitlab.com:/.git', + ]; + } + + private getGiteaForgejoGuidanceSteps(): string[] { + return [ + 'Create an access token in your instance under Settings > Applications > Access Tokens (wording may vary by instance).', + 'Grant repository read/write permissions for the target repository.', + 'Set credential helper: git config --global credential.helper manager (or osxkeychain on macOS).', + 'Retry and log in with your username and the token as password for HTTPS remotes.', + 'SSH alternative: use git@:/.git and ensure your SSH key is loaded.', + ]; + } + + private getGenericTokenGuidanceSteps(): string[] { + return [ + 'If your host uses HTTPS auth, create an access token in your Git hosting account settings.', + 'Retry and log in with your normal username and the token as password.', + 'If unsure, switch to SSH and use git@:/.git with a loaded SSH key.', + ]; + } + + private getGitHubGuidanceSteps(): string[] { + return [ + 'Create a GitHub Personal Access Token in Settings > Developer settings > Personal access tokens.', + 'Use at least `repo` scope (or equivalent fine-grained repository permissions).', + 'Set credential helper: git config --global credential.helper manager (or osxkeychain on macOS).', + 'Retry and log in with your GitHub username and the token as password.', + 'SSH alternative: git remote set-url origin git@github.com:/.git', + ]; + } + + private isGitHubUrl(value: string): boolean { + const normalized = value.toLowerCase(); + return normalized.includes('github.com') || normalized.includes('git@github.com:'); + } + + private isGitLabUrl(value: string): boolean { + const normalized = value.toLowerCase(); + return normalized.includes('gitlab.com') || normalized.includes('git@gitlab.com:') || normalized.includes('gitlab'); + } + + private isGiteaForgejoUrl(value: string): boolean { + const normalized = value.toLowerCase(); + return normalized.includes('gitea') || normalized.includes('forgejo'); + } + + private detectProviderFromValue(value: string): GitProvider { + if (this.isGitHubUrl(value)) { + return 'github'; + } + if (this.isGitLabUrl(value)) { + return 'gitlab'; + } + if (this.isGiteaForgejoUrl(value)) { + return 'gitea-forgejo'; + } + return 'unknown'; + } + + private getProviderLabel(provider: GitProvider): string { + if (provider === 'github') return 'GitHub'; + if (provider === 'gitlab') return 'GitLab'; + if (provider === 'gitea-forgejo') return 'Gitea/Forgejo'; + return 'Unknown'; + } + + private async detectProviderFromRemotes(git: ReturnType): Promise { + try { + const remotes = await git.getRemotes(true); + const urls = remotes.flatMap((remote) => [remote.refs.fetch || '', remote.refs.push || '']); + for (const url of urls) { + const provider = this.detectProviderFromValue(url); + if (provider !== 'unknown') { + return provider; + } + } + return 'unknown'; + } catch { + return 'unknown'; + } + } + + private async detectProvider(git: ReturnType, message: string): Promise { + const byMessage = this.detectProviderFromValue(message); + if (byMessage !== 'unknown') { + return byMessage; + } + return this.detectProviderFromRemotes(git); + } + + private isAuthError(message: string): boolean { + const normalized = message.toLowerCase(); + return ( + normalized.includes('authentication failed') + || normalized.includes('could not read username') + || normalized.includes('could not resolve host') === false && normalized.includes('permission denied (publickey)') + || normalized.includes('terminal prompts disabled') + || normalized.includes('credential') && normalized.includes('denied') + ); + } + + private isConflictError(message: string): boolean { + const normalized = message.toLowerCase(); + return normalized.includes('conflict') || normalized.includes('non-fast-forward') || normalized.includes('rejected'); + } + + private isNetworkError(message: string): boolean { + const normalized = message.toLowerCase(); + return ( + normalized.includes('timed out') + || normalized.includes('could not resolve host') + || normalized.includes('connection reset') + || normalized.includes('network') + || normalized.includes('unable to access') + ); + } + + private isNoUpstreamError(message: string): boolean { + const normalized = message.toLowerCase(); + return normalized.includes('no upstream branch') || normalized.includes('has no upstream branch'); + } + + private async getCurrentBranchName(git: ReturnType): Promise { + try { + const status = await git.status(); + const branch = typeof status.current === 'string' ? status.current.trim() : ''; + return branch || null; + } catch { + return null; + } + } + + private async toActionFailure(git: ReturnType, error: unknown, fallback: string): Promise { + const message = error instanceof Error ? error.message : fallback; + + if (this.isAuthError(message)) { + const provider = await this.detectProvider(git, message); + const providerText = provider === 'unknown' ? '' : ` Detected provider: ${this.getProviderLabel(provider)}.`; + const providerGuidance = + provider === 'github' + ? this.getGitHubGuidanceSteps() + : provider === 'gitlab' + ? this.getGitLabGuidanceSteps() + : provider === 'gitea-forgejo' + ? this.getGiteaForgejoGuidanceSteps() + : this.getGenericTokenGuidanceSteps(); + + return { + success: false, + code: 'auth-required', + error: `Authentication required for remote Git action.${providerText} Follow the steps below to sign in.`, + guidance: [ + ...this.getPlatformAuthGuidanceSteps(), + ...providerGuidance, + ], + }; + } + + if (this.isConflictError(message)) { + return { + success: false, + code: 'conflict', + error: message, + }; + } + + if (this.isNetworkError(message)) { + return { + success: false, + code: 'network', + error: message, + }; + } + + return { + success: false, + code: 'action-failed', + error: message, + }; + } + private async readLfsTrackedPatterns(projectPath: string): Promise> { try { const attributesPath = path.join(projectPath, '.gitattributes'); @@ -280,41 +519,44 @@ export class GitEngine { } async fetch(projectPath: string): Promise { - const git = simpleGit(projectPath); + const git = this.createNonInteractiveGit(projectPath); try { await git.fetch(['--prune']); return { success: true }; } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : 'Failed to fetch remote updates.', - }; + return this.toActionFailure(git, error, 'Failed to fetch remote updates.'); } } async pull(projectPath: string): Promise { - const git = simpleGit(projectPath); + const git = this.createNonInteractiveGit(projectPath); try { await git.pull(); return { success: true }; } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : 'Failed to pull remote changes.', - }; + return this.toActionFailure(git, error, 'Failed to pull remote changes.'); } } async push(projectPath: string): Promise { - const git = simpleGit(projectPath); + const git = this.createNonInteractiveGit(projectPath); try { await git.push(); return { success: true }; } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : 'Failed to push local commits.', - }; + const message = error instanceof Error ? error.message : 'Failed to push local commits.'; + if (this.isNoUpstreamError(message)) { + const currentBranch = await this.getCurrentBranchName(git); + if (currentBranch) { + try { + await git.push(['-u', 'origin', currentBranch]); + return { success: true }; + } catch (retryError) { + return this.toActionFailure(git, retryError, 'Failed to push local commits.'); + } + } + } + return this.toActionFailure(git, error, 'Failed to push local commits.'); } } @@ -540,6 +782,12 @@ export class GitEngine { } const normalizedRemoteUrl = remoteUrl?.trim(); + try { + await git.raw(['config', '--local', 'push.autoSetupRemote', 'true']); + } catch { + emitProgress('configuring-remote', 96, 'Configuring remote repository...', 'unable to set auto upstream'); + } + if (normalizedRemoteUrl) { emitProgress('configuring-remote', 96, 'Configuring remote repository...'); try { diff --git a/src/main/shared/electronApi.ts b/src/main/shared/electronApi.ts index 6280540..e061bb6 100644 --- a/src/main/shared/electronApi.ts +++ b/src/main/shared/electronApi.ts @@ -295,7 +295,9 @@ export interface GitLfsPruneResult { export interface GitActionResult { success: boolean; + code?: 'auth-required' | 'conflict' | 'network' | 'action-failed'; error?: string; + guidance?: string[]; } // Post-Media Link types diff --git a/src/renderer/components/GitSidebar/GitSidebar.css b/src/renderer/components/GitSidebar/GitSidebar.css index 28cbae3..1a018c9 100644 --- a/src/renderer/components/GitSidebar/GitSidebar.css +++ b/src/renderer/components/GitSidebar/GitSidebar.css @@ -148,6 +148,18 @@ color: var(--vscode-descriptionForeground); } +.git-sidebar-guidance-list { + margin: 8px 0 0; + padding-left: 16px; + display: flex; + flex-direction: column; + gap: 4px; +} + +.git-sidebar-guidance-list li { + color: var(--vscode-descriptionForeground); +} + .git-sidebar-transcript { margin-top: auto; padding-top: 8px; diff --git a/src/renderer/components/GitSidebar/GitSidebar.tsx b/src/renderer/components/GitSidebar/GitSidebar.tsx index 13edf70..a2ce7b7 100644 --- a/src/renderer/components/GitSidebar/GitSidebar.tsx +++ b/src/renderer/components/GitSidebar/GitSidebar.tsx @@ -12,6 +12,7 @@ export const GitSidebar: React.FC = () => { const [statusLoading, setStatusLoading] = useState(false); const [actionLoading, setActionLoading] = useState<'fetch' | 'pull' | 'push' | 'commit' | null>(null); const [error, setError] = useState(null); + const [errorGuidance, setErrorGuidance] = useState([]); const [isRepo, setIsRepo] = useState(false); const [currentBranch, setCurrentBranch] = useState(null); const [statusFiles, setStatusFiles] = useState>([]); @@ -26,6 +27,19 @@ export const GitSidebar: React.FC = () => { const getDiffTabId = (filePath: string): string => `git-diff:${filePath}`; + const getActionProgressMessage = (action: 'fetch' | 'pull' | 'push' | 'commit'): string => { + if (action === 'push') { + return 'Pushing commits to remote... this can take a while for large uploads.'; + } + if (action === 'fetch') { + return 'Fetching remote updates...'; + } + if (action === 'pull') { + return 'Pulling latest changes...'; + } + return 'Creating commit...'; + }; + const openDiffTab = useCallback( (filePath: string, isTransient: boolean) => { openTab({ @@ -52,6 +66,7 @@ export const GitSidebar: React.FC = () => { const loadRepoState = useCallback(async () => { setLoading(true); setError(null); + setErrorGuidance([]); try { const availability = await window.electronAPI.git.checkAvailability(); @@ -168,6 +183,7 @@ export const GitSidebar: React.FC = () => { setActionLoading(action); setError(null); + setErrorGuidance([]); try { const result = action === 'fetch' @@ -177,6 +193,7 @@ export const GitSidebar: React.FC = () => { : await window.electronAPI.git.push(effectiveProjectPath); if (!result.success) { setError(result.error || `Failed to ${action}.`); + setErrorGuidance(result.guidance || []); return; } await loadRepoState(); @@ -203,11 +220,13 @@ export const GitSidebar: React.FC = () => { setActionLoading('commit'); setError(null); + setErrorGuidance([]); try { const messageToCommit = commitMessageInputRef.current?.value ?? commitMessage; const result = await window.electronAPI.git.commitAll(effectiveProjectPath, messageToCommit); if (!result.success) { setError(result.error || 'Failed to commit changes.'); + setErrorGuidance(result.guidance || []); return; } @@ -268,7 +287,7 @@ export const GitSidebar: React.FC = () => { onClick={() => handleRepoAction('fetch')} disabled={actionLoading !== null} > - Fetch + {actionLoading === 'fetch' ? 'Fetching...' : 'Fetch'} + {actionLoading && ( +
+ {getActionProgressMessage(actionLoading)} +
+ )}
Open Changes ({statusFiles.length})
@@ -307,7 +331,7 @@ export const GitSidebar: React.FC = () => { onClick={handleCommit} disabled={actionLoading !== null} > - Commit + {actionLoading === 'commit' ? 'Committing...' : 'Commit'}
@@ -356,7 +380,18 @@ export const GitSidebar: React.FC = () => { )} {currentBranch &&
Branch: {currentBranch}
} - {error &&
{error}
} + {error && ( +
+
{error}
+ {errorGuidance.length > 0 && ( +
    + {errorGuidance.map((step) => ( +
  • {step}
  • + ))} +
+ )} +
+ )} {transcriptSection} diff --git a/tests/engine/GitEngine.test.ts b/tests/engine/GitEngine.test.ts index 360a846..eefa306 100644 --- a/tests/engine/GitEngine.test.ts +++ b/tests/engine/GitEngine.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; const mockVersion = vi.fn(); +const mockEnv = vi.fn(); const mockCheckIsRepo = vi.fn(); const mockRevparse = vi.fn(); const mockStatus = vi.fn(); @@ -32,6 +33,7 @@ vi.mock('fs/promises', () => ({ vi.mock('simple-git', () => ({ simpleGit: vi.fn(() => ({ + env: mockEnv, version: mockVersion, checkIsRepo: mockCheckIsRepo, revparse: mockRevparse, @@ -63,6 +65,26 @@ describe('GitEngine', () => { mockStat.mockResolvedValue({}); mockWriteFile.mockResolvedValue(undefined); mockCheckIsRepo.mockResolvedValue(false); + mockEnv.mockImplementation(() => ({ + env: mockEnv, + version: mockVersion, + checkIsRepo: mockCheckIsRepo, + revparse: mockRevparse, + status: mockStatus, + diff: mockDiff, + show: mockShow, + log: mockLog, + init: mockInit, + raw: mockRaw, + add: mockAdd, + commit: mockCommit, + fetch: mockFetch, + pull: mockPull, + push: mockPush, + getRemotes: mockGetRemotes, + addRemote: mockAddRemote, + remote: mockRemote, + })); gitEngine = new GitEngine(); }); @@ -389,6 +411,7 @@ describe('GitEngine', () => { expect(result).toEqual({ success: true }); expect(mockGetRemotes).toHaveBeenCalledWith(true); expect(mockAddRemote).toHaveBeenCalledWith('origin', 'https://github.com/example/repo.git'); + expect(mockRaw).toHaveBeenCalledWith(['config', '--local', 'push.autoSetupRemote', 'true']); }); it('should succeed when there is nothing to commit after staging', async () => { @@ -464,6 +487,8 @@ describe('GitEngine', () => { const result = await gitEngine.fetch('/tmp/project'); + expect(mockEnv).toHaveBeenCalledWith('GIT_TERMINAL_PROMPT', '0'); + expect(mockEnv).toHaveBeenCalledWith('GCM_INTERACTIVE', 'never'); expect(mockFetch).toHaveBeenCalledWith(['--prune']); expect(result).toEqual({ success: true }); }); @@ -486,6 +511,19 @@ describe('GitEngine', () => { expect(result).toEqual({ success: true }); }); + it('should auto-set upstream and retry push when no upstream branch exists', async () => { + mockPush + .mockRejectedValueOnce(new Error('fatal: The current branch main has no upstream branch.')) + .mockResolvedValueOnce(undefined); + mockStatus.mockResolvedValue({ current: 'main' }); + + const result = await gitEngine.push('/tmp/project'); + + expect(mockPush).toHaveBeenNthCalledWith(1); + expect(mockPush).toHaveBeenNthCalledWith(2, ['-u', 'origin', 'main']); + expect(result).toEqual({ success: true }); + }); + it('should stage all files and commit with message', async () => { mockAdd.mockResolvedValue(undefined); mockCommit.mockResolvedValue(undefined); @@ -504,5 +542,95 @@ describe('GitEngine', () => { expect(mockCommit).not.toHaveBeenCalled(); expect(result).toEqual({ success: false, error: 'Commit message is required.' }); }); + + it('should return auth-required code with OS guidance when fetch authentication fails', async () => { + mockFetch.mockRejectedValue(new Error('fatal: Authentication failed for https://example.com/repo.git')); + mockGetRemotes.mockResolvedValue([]); + + const result = await gitEngine.fetch('/tmp/project'); + + expect(result.success).toBe(false); + expect(result.code).toBe('auth-required'); + expect(result.error).toContain('Authentication required for remote Git action'); + expect(result.error).not.toContain('Original error'); + expect(Array.isArray(result.guidance)).toBe(true); + expect(result.guidance!.length).toBeGreaterThan(0); + expect(result.guidance!.join(' ')).not.toContain('GitHub detected'); + expect(result.guidance!.join(' ')).toContain('token'); + expect(result.guidance!.join(' ')).toContain('password'); + }); + + it('should include GitHub-specific guidance when remote points to github.com', async () => { + mockFetch.mockRejectedValue(new Error('fatal: Authentication failed')); + mockGetRemotes.mockResolvedValue([ + { + name: 'origin', + refs: { + fetch: 'https://github.com/example/repo.git', + push: 'https://github.com/example/repo.git', + }, + }, + ]); + + const result = await gitEngine.fetch('/tmp/project'); + + expect(result.success).toBe(false); + expect(result.code).toBe('auth-required'); + expect(result.error).toContain('Detected provider: GitHub'); + expect(result.guidance).toEqual(expect.arrayContaining([ + expect.stringContaining('Personal Access Token'), + expect.stringContaining('repo'), + expect.stringContaining('git config --global credential.helper'), + expect.stringContaining('git remote set-url origin'), + ])); + }); + + it('should include GitLab-specific token guidance when remote points to gitlab', async () => { + mockFetch.mockRejectedValue(new Error('fatal: Authentication failed')); + mockGetRemotes.mockResolvedValue([ + { + name: 'origin', + refs: { + fetch: 'https://gitlab.com/example/repo.git', + push: 'https://gitlab.com/example/repo.git', + }, + }, + ]); + + const result = await gitEngine.fetch('/tmp/project'); + + expect(result.success).toBe(false); + expect(result.code).toBe('auth-required'); + expect(result.error).toContain('Detected provider: GitLab'); + expect(result.guidance).toEqual(expect.arrayContaining([ + expect.stringContaining('User Settings > Access Tokens'), + expect.stringContaining('read_repository'), + expect.stringContaining('write_repository'), + ])); + }); + + it('should include Gitea/Forgejo token guidance when remote appears self-hosted gitea/forgejo', async () => { + mockFetch.mockRejectedValue(new Error('fatal: Authentication failed')); + mockGetRemotes.mockResolvedValue([ + { + name: 'origin', + refs: { + fetch: 'https://forgejo.example.com/my/repo.git', + push: 'https://forgejo.example.com/my/repo.git', + }, + }, + ]); + + const result = await gitEngine.fetch('/tmp/project'); + + expect(result.success).toBe(false); + expect(result.code).toBe('auth-required'); + expect(result.error).toContain('Detected provider: Gitea/Forgejo'); + expect(result.guidance).toEqual(expect.arrayContaining([ + expect.stringContaining('Settings > Applications'), + expect.stringContaining('Access Tokens'), + expect.stringContaining('repository'), + ])); + }); }); }); diff --git a/tests/renderer/components/GitSidebar.test.tsx b/tests/renderer/components/GitSidebar.test.tsx index a6e835d..4bdada0 100644 --- a/tests/renderer/components/GitSidebar.test.tsx +++ b/tests/renderer/components/GitSidebar.test.tsx @@ -393,4 +393,63 @@ describe('GitSidebar', () => { { type: 'post', id: 'post-1', isTransient: false }, ]); }); + + it('shows auth guidance when fetch fails due to authentication', async () => { + (window as any).electronAPI.git.getRepoState = vi.fn().mockResolvedValue({ + isRepo: true, + rootPath: '/repo/path', + currentBranch: 'main', + hasRemote: true, + }); + (window as any).electronAPI.git.fetch = vi.fn().mockResolvedValue({ + success: false, + code: 'auth-required', + error: 'Authentication required for remote Git action. Detected provider: GitHub.', + guidance: [ + 'Create a GitHub Personal Access Token.', + 'Retry with username + token.', + ], + }); + + render(); + + const fetchButton = await screen.findByRole('button', { name: /fetch/i }); + await act(async () => { + fireEvent.click(fetchButton); + }); + + expect(await screen.findByText(/authentication required/i)).toBeInTheDocument(); + expect(screen.getAllByText(/create a github personal access token/i)).toHaveLength(1); + expect(screen.getByText(/retry with username \+ token/i)).toBeInTheDocument(); + }); + + it('shows in-progress feedback while push is running', async () => { + let resolvePush: ((value: { success: boolean }) => void) | null = null; + (window as any).electronAPI.git.getRepoState = vi.fn().mockResolvedValue({ + isRepo: true, + rootPath: '/repo/path', + currentBranch: 'main', + hasRemote: true, + }); + (window as any).electronAPI.git.push = vi.fn().mockImplementation( + () => + new Promise((resolve) => { + resolvePush = resolve; + }), + ); + + render(); + + const pushButton = await screen.findByRole('button', { name: /push/i }); + await act(async () => { + fireEvent.click(pushButton); + }); + + expect(screen.getByRole('status')).toHaveTextContent(/pushing commits to remote/i); + expect(screen.getByRole('button', { name: /pushing/i })).toBeDisabled(); + + await act(async () => { + resolvePush?.({ success: true }); + }); + }); });