feat: added auth checks and first-push checks.
This commit is contained in:
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
]));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user