feat: added auth checks and first-push checks.

This commit is contained in:
2026-02-16 12:57:42 +01:00
parent 56931f81ba
commit 3c9d4b6bce
6 changed files with 504 additions and 20 deletions

View File

@@ -100,9 +100,13 @@ export interface GitLfsPruneResult {
export interface GitActionResult { export interface GitActionResult {
success: boolean; success: boolean;
code?: 'auth-required' | 'conflict' | 'network' | 'action-failed';
error?: string; error?: string;
guidance?: string[];
} }
type GitProvider = 'unknown' | 'github' | 'gitlab' | 'gitea-forgejo';
let gitEngineInstance: GitEngine | null = null; let gitEngineInstance: GitEngine | null = null;
export function getGitEngine(): GitEngine { export function getGitEngine(): GitEngine {
@@ -124,6 +128,241 @@ export class GitEngine {
'.fseventsd', '.fseventsd',
]; ];
private createNonInteractiveGit(projectPath: string): ReturnType<typeof simpleGit> {
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:<owner>/<repo>.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:<group>/<repo>.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@<host>:<owner>/<repo>.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@<host>:<owner>/<repo>.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:<owner>/<repo>.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<typeof simpleGit>): Promise<GitProvider> {
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<typeof simpleGit>, message: string): Promise<GitProvider> {
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<typeof simpleGit>): Promise<string | null> {
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<typeof simpleGit>, error: unknown, fallback: string): Promise<GitActionResult> {
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<Set<string>> { private async readLfsTrackedPatterns(projectPath: string): Promise<Set<string>> {
try { try {
const attributesPath = path.join(projectPath, '.gitattributes'); const attributesPath = path.join(projectPath, '.gitattributes');
@@ -280,41 +519,44 @@ export class GitEngine {
} }
async fetch(projectPath: string): Promise<GitActionResult> { async fetch(projectPath: string): Promise<GitActionResult> {
const git = simpleGit(projectPath); const git = this.createNonInteractiveGit(projectPath);
try { try {
await git.fetch(['--prune']); await git.fetch(['--prune']);
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
return { return this.toActionFailure(git, error, 'Failed to fetch remote updates.');
success: false,
error: error instanceof Error ? error.message : 'Failed to fetch remote updates.',
};
} }
} }
async pull(projectPath: string): Promise<GitActionResult> { async pull(projectPath: string): Promise<GitActionResult> {
const git = simpleGit(projectPath); const git = this.createNonInteractiveGit(projectPath);
try { try {
await git.pull(); await git.pull();
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
return { return this.toActionFailure(git, error, 'Failed to pull remote changes.');
success: false,
error: error instanceof Error ? error.message : 'Failed to pull remote changes.',
};
} }
} }
async push(projectPath: string): Promise<GitActionResult> { async push(projectPath: string): Promise<GitActionResult> {
const git = simpleGit(projectPath); const git = this.createNonInteractiveGit(projectPath);
try { try {
await git.push(); await git.push();
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
return { const message = error instanceof Error ? error.message : 'Failed to push local commits.';
success: false, if (this.isNoUpstreamError(message)) {
error: error instanceof Error ? error.message : 'Failed to push local commits.', 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(); 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) { if (normalizedRemoteUrl) {
emitProgress('configuring-remote', 96, 'Configuring remote repository...'); emitProgress('configuring-remote', 96, 'Configuring remote repository...');
try { try {

View File

@@ -295,7 +295,9 @@ export interface GitLfsPruneResult {
export interface GitActionResult { export interface GitActionResult {
success: boolean; success: boolean;
code?: 'auth-required' | 'conflict' | 'network' | 'action-failed';
error?: string; error?: string;
guidance?: string[];
} }
// Post-Media Link types // Post-Media Link types

View File

@@ -148,6 +148,18 @@
color: var(--vscode-descriptionForeground); 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 { .git-sidebar-transcript {
margin-top: auto; margin-top: auto;
padding-top: 8px; padding-top: 8px;

View File

@@ -12,6 +12,7 @@ export const GitSidebar: React.FC = () => {
const [statusLoading, setStatusLoading] = useState(false); const [statusLoading, setStatusLoading] = useState(false);
const [actionLoading, setActionLoading] = useState<'fetch' | 'pull' | 'push' | 'commit' | null>(null); const [actionLoading, setActionLoading] = useState<'fetch' | 'pull' | 'push' | 'commit' | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [errorGuidance, setErrorGuidance] = useState<string[]>([]);
const [isRepo, setIsRepo] = useState(false); const [isRepo, setIsRepo] = useState(false);
const [currentBranch, setCurrentBranch] = useState<string | null>(null); const [currentBranch, setCurrentBranch] = useState<string | null>(null);
const [statusFiles, setStatusFiles] = useState<Array<{ path: string; status: string }>>([]); const [statusFiles, setStatusFiles] = useState<Array<{ path: string; status: string }>>([]);
@@ -26,6 +27,19 @@ export const GitSidebar: React.FC = () => {
const getDiffTabId = (filePath: string): string => `git-diff:${filePath}`; 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( const openDiffTab = useCallback(
(filePath: string, isTransient: boolean) => { (filePath: string, isTransient: boolean) => {
openTab({ openTab({
@@ -52,6 +66,7 @@ export const GitSidebar: React.FC = () => {
const loadRepoState = useCallback(async () => { const loadRepoState = useCallback(async () => {
setLoading(true); setLoading(true);
setError(null); setError(null);
setErrorGuidance([]);
try { try {
const availability = await window.electronAPI.git.checkAvailability(); const availability = await window.electronAPI.git.checkAvailability();
@@ -168,6 +183,7 @@ export const GitSidebar: React.FC = () => {
setActionLoading(action); setActionLoading(action);
setError(null); setError(null);
setErrorGuidance([]);
try { try {
const result = const result =
action === 'fetch' action === 'fetch'
@@ -177,6 +193,7 @@ export const GitSidebar: React.FC = () => {
: await window.electronAPI.git.push(effectiveProjectPath); : await window.electronAPI.git.push(effectiveProjectPath);
if (!result.success) { if (!result.success) {
setError(result.error || `Failed to ${action}.`); setError(result.error || `Failed to ${action}.`);
setErrorGuidance(result.guidance || []);
return; return;
} }
await loadRepoState(); await loadRepoState();
@@ -203,11 +220,13 @@ export const GitSidebar: React.FC = () => {
setActionLoading('commit'); setActionLoading('commit');
setError(null); setError(null);
setErrorGuidance([]);
try { try {
const messageToCommit = commitMessageInputRef.current?.value ?? commitMessage; const messageToCommit = commitMessageInputRef.current?.value ?? commitMessage;
const result = await window.electronAPI.git.commitAll(effectiveProjectPath, messageToCommit); const result = await window.electronAPI.git.commitAll(effectiveProjectPath, messageToCommit);
if (!result.success) { if (!result.success) {
setError(result.error || 'Failed to commit changes.'); setError(result.error || 'Failed to commit changes.');
setErrorGuidance(result.guidance || []);
return; return;
} }
@@ -268,7 +287,7 @@ export const GitSidebar: React.FC = () => {
onClick={() => handleRepoAction('fetch')} onClick={() => handleRepoAction('fetch')}
disabled={actionLoading !== null} disabled={actionLoading !== null}
> >
Fetch {actionLoading === 'fetch' ? 'Fetching...' : 'Fetch'}
</button> </button>
<button <button
type="button" type="button"
@@ -276,7 +295,7 @@ export const GitSidebar: React.FC = () => {
onClick={() => handleRepoAction('pull')} onClick={() => handleRepoAction('pull')}
disabled={actionLoading !== null} disabled={actionLoading !== null}
> >
Pull {actionLoading === 'pull' ? 'Pulling...' : 'Pull'}
</button> </button>
<button <button
type="button" type="button"
@@ -284,9 +303,14 @@ export const GitSidebar: React.FC = () => {
onClick={() => handleRepoAction('push')} onClick={() => handleRepoAction('push')}
disabled={actionLoading !== null} disabled={actionLoading !== null}
> >
Push {actionLoading === 'push' ? 'Pushing...' : 'Push'}
</button> </button>
</div> </div>
{actionLoading && (
<div className="git-sidebar-empty-state git-sidebar-progress" role="status">
{getActionProgressMessage(actionLoading)}
</div>
)}
<div className="git-sidebar-section"> <div className="git-sidebar-section">
<div className="sidebar-section-title">Open Changes ({statusFiles.length})</div> <div className="sidebar-section-title">Open Changes ({statusFiles.length})</div>
@@ -307,7 +331,7 @@ export const GitSidebar: React.FC = () => {
onClick={handleCommit} onClick={handleCommit}
disabled={actionLoading !== null} disabled={actionLoading !== null}
> >
Commit {actionLoading === 'commit' ? 'Committing...' : 'Commit'}
</button> </button>
</div> </div>
@@ -356,7 +380,18 @@ export const GitSidebar: React.FC = () => {
)} )}
{currentBranch && <div className="git-sidebar-empty-state">Branch: {currentBranch}</div>} {currentBranch && <div className="git-sidebar-empty-state">Branch: {currentBranch}</div>}
</div> </div>
{error && <div className="git-sidebar-empty-state git-sidebar-error">{error}</div>} {error && (
<div className="git-sidebar-empty-state git-sidebar-error">
<div>{error}</div>
{errorGuidance.length > 0 && (
<ul className="git-sidebar-guidance-list">
{errorGuidance.map((step) => (
<li key={step}>{step}</li>
))}
</ul>
)}
</div>
)}
{transcriptSection} {transcriptSection}
</div> </div>
</div> </div>

View File

@@ -1,6 +1,7 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'; import { describe, it, expect, beforeEach, vi } from 'vitest';
const mockVersion = vi.fn(); const mockVersion = vi.fn();
const mockEnv = vi.fn();
const mockCheckIsRepo = vi.fn(); const mockCheckIsRepo = vi.fn();
const mockRevparse = vi.fn(); const mockRevparse = vi.fn();
const mockStatus = vi.fn(); const mockStatus = vi.fn();
@@ -32,6 +33,7 @@ vi.mock('fs/promises', () => ({
vi.mock('simple-git', () => ({ vi.mock('simple-git', () => ({
simpleGit: vi.fn(() => ({ simpleGit: vi.fn(() => ({
env: mockEnv,
version: mockVersion, version: mockVersion,
checkIsRepo: mockCheckIsRepo, checkIsRepo: mockCheckIsRepo,
revparse: mockRevparse, revparse: mockRevparse,
@@ -63,6 +65,26 @@ describe('GitEngine', () => {
mockStat.mockResolvedValue({}); mockStat.mockResolvedValue({});
mockWriteFile.mockResolvedValue(undefined); mockWriteFile.mockResolvedValue(undefined);
mockCheckIsRepo.mockResolvedValue(false); 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(); gitEngine = new GitEngine();
}); });
@@ -389,6 +411,7 @@ describe('GitEngine', () => {
expect(result).toEqual({ success: true }); expect(result).toEqual({ success: true });
expect(mockGetRemotes).toHaveBeenCalledWith(true); expect(mockGetRemotes).toHaveBeenCalledWith(true);
expect(mockAddRemote).toHaveBeenCalledWith('origin', 'https://github.com/example/repo.git'); 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 () => { 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'); 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(mockFetch).toHaveBeenCalledWith(['--prune']);
expect(result).toEqual({ success: true }); expect(result).toEqual({ success: true });
}); });
@@ -486,6 +511,19 @@ describe('GitEngine', () => {
expect(result).toEqual({ success: true }); 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 () => { it('should stage all files and commit with message', async () => {
mockAdd.mockResolvedValue(undefined); mockAdd.mockResolvedValue(undefined);
mockCommit.mockResolvedValue(undefined); mockCommit.mockResolvedValue(undefined);
@@ -504,5 +542,95 @@ describe('GitEngine', () => {
expect(mockCommit).not.toHaveBeenCalled(); expect(mockCommit).not.toHaveBeenCalled();
expect(result).toEqual({ success: false, error: 'Commit message is required.' }); 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'),
]));
});
}); });
}); });

View File

@@ -393,4 +393,63 @@ describe('GitSidebar', () => {
{ type: 'post', id: 'post-1', isTransient: false }, { 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(<GitSidebar />);
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(<GitSidebar />);
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 });
});
});
}); });