feat(git): enhance token authentication and credential handling

This commit is contained in:
Bohdan Triapitsyn
2026-01-14 02:54:48 +02:00
parent fa83c1e645
commit 1319033df9
7 changed files with 11 additions and 56 deletions
+2 -2
View File
@@ -4,11 +4,11 @@ All notable changes to this project will be documented in this file.
## [Unreleased]
- Git Identities: added token-based authentication support with ~/.git-credentials discovery and import.
- Settings: consolidated Git settings and added opencode zen model selection for commit generation (thanks to @nelsonpires).
- Web Notifications: added configurable native web notifications for assistant completion (thanks to @vio1ator).
- Chat: sidebar sessions are now automatically sorted by last updated date (thanks to @vio1ator).
- Chat: fixed edit tool output.
- Chat: added turn duration.
- Chat: fixed edit tool output and added turn duration.
- UI: todo lists and status indicators now hide automatically when all tasks are completed (thanks to @vio1ator).
- Reliability: improved project state preservation on validation failures (thanks to @vio1ator) and refined server health monitoring.
- Stability: added graceful shutdown handling for the server process (thanks to @vio1ator).
@@ -2031,7 +2031,6 @@ pub async fn get_current_git_identity(
#[tauri::command]
pub async fn get_global_git_identity() -> Result<GitIdentitySummary, String> {
// Run git config --global commands without a specific directory
let user_name = tokio::process::Command::new("git")
.args(["config", "--global", "user.name"])
.output()
@@ -2104,15 +2103,11 @@ pub async fn set_git_identity(
.await
.map_err(|e| e.to_string())?;
}
// Clear credential helper if previously set for token auth
let _ = run_git(&["config", "--local", "--unset", "credential.helper"], &root).await;
} else if auth_type == "token" && profile.host.is_some() {
// For token auth, configure git to use the store credential helper
// which reads from ~/.git-credentials
run_git(&["config", "--local", "credential.helper", "store"], &root)
.await
.map_err(|e| e.to_string())?;
// Clear SSH command if previously set
let _ = run_git(&["config", "--local", "--unset", "core.sshCommand"], &root).await;
} else {
let _ = run_git(&["config", "--local", "--unset", "core.sshCommand"], &root).await;
@@ -2141,11 +2136,9 @@ pub async fn discover_git_credentials() -> Result<Vec<DiscoveredGitCredential>,
continue;
}
// Parse URL format: https://username:token@host/path
if let Ok(url) = url::Url::parse(trimmed) {
let hostname = url.host_str().unwrap_or("").to_string();
let path = url.path();
// Include path for repo-specific tokens (e.g., github.com/user/repo)
let host = if path.is_empty() || path == "/" {
hostname
} else {
@@ -2154,7 +2147,6 @@ pub async fn discover_git_credentials() -> Result<Vec<DiscoveredGitCredential>,
let username = url.username().to_string();
if !host.is_empty() && !username.is_empty() {
// Avoid duplicates
let exists = credentials
.iter()
.any(|c: &DiscoveredGitCredential| c.host == host && c.username == username);
@@ -73,18 +73,16 @@ export const GitIdentitiesPage: React.FC = () => {
React.useEffect(() => {
if (importData) {
// Pre-fill from imported credential
// For repo-specific hosts like "github.com/user/repo", use just "repo" as name
const parts = importData.host.split('/');
const displayName = parts.length >= 3 ? parts[parts.length - 1] : importData.host;
setName(displayName);
setUserName(importData.username);
setUserEmail('');
setAuthType('token');
setSshKey('');
setHost(importData.host);
setColor('string'); // cyan for token-based
setColor('string');
setIcon('code');
} else if (isNewProfile) {
setName('');
@@ -300,18 +300,11 @@ interface DiscoveredCredentialItemProps {
onImport: () => void;
}
/**
* Get display name for a credential host.
* For repo-specific hosts like "github.com/user/repo", returns just "repo".
* For host-only like "github.com", returns "github.com".
*/
const getCredentialDisplayName = (host: string): string => {
const parts = host.split('/');
if (parts.length >= 3) {
// repo-specific: github.com/user/repo -> repo
return parts[parts.length - 1];
}
// host-only: github.com
return host;
};
+6 -17
View File
@@ -189,7 +189,6 @@ export const GitView: React.FC = () => {
loadGlobalIdentity();
}, [loadProfiles, loadGlobalIdentity]);
// Fetch remote URL for filtering token-based identities
React.useEffect(() => {
if (!currentDirectory || !git?.getRemoteUrl) {
setRemoteUrl(null);
@@ -505,49 +504,39 @@ export const GitView: React.FC = () => {
if (globalIdentity) {
unique.set(globalIdentity.id, globalIdentity);
}
// Parse repo host/path from remote URL for filtering token identities
// e.g., "git@github.com:user/repo.git" or "https://github.com/user/repo.git"
let repoHostPath: string | null = null;
if (remoteUrl) {
try {
let normalized = remoteUrl.trim();
// Handle SSH format: git@github.com:user/repo.git -> https://github.com/user/repo.git
if (normalized.startsWith('git@')) {
normalized = 'https://' + normalized.slice(4).replace(':', '/');
}
// Remove .git suffix
if (normalized.endsWith('.git')) {
normalized = normalized.slice(0, -4);
}
const url = new URL(normalized);
repoHostPath = url.hostname + url.pathname;
} catch {
// ignore parse errors
}
} catch { /* ignore */ }
}
for (const profile of profiles) {
// SSH identities always shown
if (profile.authType !== 'token') {
unique.set(profile.id, profile);
continue;
}
// Token identities: filter by host match
const profileHost = profile.host;
if (!profileHost) {
unique.set(profile.id, profile);
continue;
}
// Host-only token (e.g., "github.com") - always show
if (!profileHost.includes('/')) {
unique.set(profile.id, profile);
continue;
}
// Repo-specific token - only show if matches current repo
if (repoHostPath && repoHostPath === profileHost) {
unique.set(profile.id, profile);
}
+1 -14
View File
@@ -4,11 +4,6 @@ import os from 'os';
const GIT_CREDENTIALS_PATH = path.join(os.homedir(), '.git-credentials');
/**
* Parse ~/.git-credentials file and return discovered credentials.
* Format: https://username:token@host or https://username:token@host/path
* @returns {Array<{host: string, username: string}>}
*/
export function discoverGitCredentials() {
const credentials = [];
@@ -25,19 +20,16 @@ export function discoverGitCredentials() {
const url = new URL(line.trim());
const hostname = url.hostname;
const pathname = url.pathname && url.pathname !== '/' ? url.pathname : '';
// Include path for repo-specific tokens (e.g., github.com/user/repo)
const host = hostname + pathname;
const username = url.username || '';
if (host && username) {
// Avoid duplicates
const exists = credentials.some(c => c.host === host && c.username === username);
if (!exists) {
credentials.push({ host, username });
}
}
} catch {
// Skip malformed lines
continue;
}
}
@@ -48,11 +40,6 @@ export function discoverGitCredentials() {
return credentials;
}
/**
* Get credential for a specific host from ~/.git-credentials
* @param {string} host - The host to look up (e.g., "github.com" or "github.com/user/repo")
* @returns {{username: string, token: string} | null}
*/
export function getCredentialForHost(host) {
if (!fs.existsSync(GIT_CREDENTIALS_PATH)) {
return null;
@@ -68,7 +55,7 @@ export function getCredentialForHost(host) {
const hostname = url.hostname;
const pathname = url.pathname && url.pathname !== '/' ? url.pathname : '';
const credHost = hostname + pathname;
if (credHost === host) {
return {
username: url.username || '',
-4
View File
@@ -177,18 +177,14 @@ export async function setLocalIdentity(directory, profile) {
false,
'local'
);
// Clear credential helper if previously set for token auth
await git.raw(['config', '--local', '--unset', 'credential.helper']).catch(() => {});
} else if (authType === 'token' && profile.host) {
// For token auth, configure git to use the store credential helper
// which reads from ~/.git-credentials
await git.addConfig(
'credential.helper',
'store',
false,
'local'
);
// Clear SSH command if previously set
await git.raw(['config', '--local', '--unset', 'core.sshCommand']).catch(() => {});
}