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] ## [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). - 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). - 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: sidebar sessions are now automatically sorted by last updated date (thanks to @vio1ator).
- Chat: fixed edit tool output. - Chat: fixed edit tool output and added turn duration.
- Chat: added turn duration.
- UI: todo lists and status indicators now hide automatically when all tasks are completed (thanks to @vio1ator). - 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. - 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). - 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] #[tauri::command]
pub async fn get_global_git_identity() -> Result<GitIdentitySummary, String> { 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") let user_name = tokio::process::Command::new("git")
.args(["config", "--global", "user.name"]) .args(["config", "--global", "user.name"])
.output() .output()
@@ -2104,15 +2103,11 @@ pub async fn set_git_identity(
.await .await
.map_err(|e| e.to_string())?; .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; let _ = run_git(&["config", "--local", "--unset", "credential.helper"], &root).await;
} else if auth_type == "token" && profile.host.is_some() { } 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) run_git(&["config", "--local", "credential.helper", "store"], &root)
.await .await
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
// Clear SSH command if previously set
let _ = run_git(&["config", "--local", "--unset", "core.sshCommand"], &root).await; let _ = run_git(&["config", "--local", "--unset", "core.sshCommand"], &root).await;
} else { } else {
let _ = run_git(&["config", "--local", "--unset", "core.sshCommand"], &root).await; let _ = run_git(&["config", "--local", "--unset", "core.sshCommand"], &root).await;
@@ -2141,11 +2136,9 @@ pub async fn discover_git_credentials() -> Result<Vec<DiscoveredGitCredential>,
continue; continue;
} }
// Parse URL format: https://username:token@host/path
if let Ok(url) = url::Url::parse(trimmed) { if let Ok(url) = url::Url::parse(trimmed) {
let hostname = url.host_str().unwrap_or("").to_string(); let hostname = url.host_str().unwrap_or("").to_string();
let path = url.path(); let path = url.path();
// Include path for repo-specific tokens (e.g., github.com/user/repo)
let host = if path.is_empty() || path == "/" { let host = if path.is_empty() || path == "/" {
hostname hostname
} else { } else {
@@ -2154,7 +2147,6 @@ pub async fn discover_git_credentials() -> Result<Vec<DiscoveredGitCredential>,
let username = url.username().to_string(); let username = url.username().to_string();
if !host.is_empty() && !username.is_empty() { if !host.is_empty() && !username.is_empty() {
// Avoid duplicates
let exists = credentials let exists = credentials
.iter() .iter()
.any(|c: &DiscoveredGitCredential| c.host == host && c.username == username); .any(|c: &DiscoveredGitCredential| c.host == host && c.username == username);
@@ -73,18 +73,16 @@ export const GitIdentitiesPage: React.FC = () => {
React.useEffect(() => { React.useEffect(() => {
if (importData) { 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 parts = importData.host.split('/');
const displayName = parts.length >= 3 ? parts[parts.length - 1] : importData.host; const displayName = parts.length >= 3 ? parts[parts.length - 1] : importData.host;
setName(displayName); setName(displayName);
setUserName(importData.username); setUserName(importData.username);
setUserEmail(''); setUserEmail('');
setAuthType('token'); setAuthType('token');
setSshKey(''); setSshKey('');
setHost(importData.host); setHost(importData.host);
setColor('string'); // cyan for token-based setColor('string');
setIcon('code'); setIcon('code');
} else if (isNewProfile) { } else if (isNewProfile) {
setName(''); setName('');
@@ -300,18 +300,11 @@ interface DiscoveredCredentialItemProps {
onImport: () => void; 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 getCredentialDisplayName = (host: string): string => {
const parts = host.split('/'); const parts = host.split('/');
if (parts.length >= 3) { if (parts.length >= 3) {
// repo-specific: github.com/user/repo -> repo
return parts[parts.length - 1]; return parts[parts.length - 1];
} }
// host-only: github.com
return host; return host;
}; };
+6 -17
View File
@@ -189,7 +189,6 @@ export const GitView: React.FC = () => {
loadGlobalIdentity(); loadGlobalIdentity();
}, [loadProfiles, loadGlobalIdentity]); }, [loadProfiles, loadGlobalIdentity]);
// Fetch remote URL for filtering token-based identities
React.useEffect(() => { React.useEffect(() => {
if (!currentDirectory || !git?.getRemoteUrl) { if (!currentDirectory || !git?.getRemoteUrl) {
setRemoteUrl(null); setRemoteUrl(null);
@@ -505,49 +504,39 @@ export const GitView: React.FC = () => {
if (globalIdentity) { if (globalIdentity) {
unique.set(globalIdentity.id, 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; let repoHostPath: string | null = null;
if (remoteUrl) { if (remoteUrl) {
try { try {
let normalized = remoteUrl.trim(); let normalized = remoteUrl.trim();
// Handle SSH format: git@github.com:user/repo.git -> https://github.com/user/repo.git
if (normalized.startsWith('git@')) { if (normalized.startsWith('git@')) {
normalized = 'https://' + normalized.slice(4).replace(':', '/'); normalized = 'https://' + normalized.slice(4).replace(':', '/');
} }
// Remove .git suffix
if (normalized.endsWith('.git')) { if (normalized.endsWith('.git')) {
normalized = normalized.slice(0, -4); normalized = normalized.slice(0, -4);
} }
const url = new URL(normalized); const url = new URL(normalized);
repoHostPath = url.hostname + url.pathname; repoHostPath = url.hostname + url.pathname;
} catch { } catch { /* ignore */ }
// ignore parse errors
}
} }
for (const profile of profiles) { for (const profile of profiles) {
// SSH identities always shown
if (profile.authType !== 'token') { if (profile.authType !== 'token') {
unique.set(profile.id, profile); unique.set(profile.id, profile);
continue; continue;
} }
// Token identities: filter by host match
const profileHost = profile.host; const profileHost = profile.host;
if (!profileHost) { if (!profileHost) {
unique.set(profile.id, profile); unique.set(profile.id, profile);
continue; continue;
} }
// Host-only token (e.g., "github.com") - always show
if (!profileHost.includes('/')) { if (!profileHost.includes('/')) {
unique.set(profile.id, profile); unique.set(profile.id, profile);
continue; continue;
} }
// Repo-specific token - only show if matches current repo
if (repoHostPath && repoHostPath === profileHost) { if (repoHostPath && repoHostPath === profileHost) {
unique.set(profile.id, profile); 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'); 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() { export function discoverGitCredentials() {
const credentials = []; const credentials = [];
@@ -25,19 +20,16 @@ export function discoverGitCredentials() {
const url = new URL(line.trim()); const url = new URL(line.trim());
const hostname = url.hostname; const hostname = url.hostname;
const pathname = url.pathname && url.pathname !== '/' ? url.pathname : ''; 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 host = hostname + pathname;
const username = url.username || ''; const username = url.username || '';
if (host && username) { if (host && username) {
// Avoid duplicates
const exists = credentials.some(c => c.host === host && c.username === username); const exists = credentials.some(c => c.host === host && c.username === username);
if (!exists) { if (!exists) {
credentials.push({ host, username }); credentials.push({ host, username });
} }
} }
} catch { } catch {
// Skip malformed lines
continue; continue;
} }
} }
@@ -48,11 +40,6 @@ export function discoverGitCredentials() {
return credentials; 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) { export function getCredentialForHost(host) {
if (!fs.existsSync(GIT_CREDENTIALS_PATH)) { if (!fs.existsSync(GIT_CREDENTIALS_PATH)) {
return null; return null;
@@ -68,7 +55,7 @@ export function getCredentialForHost(host) {
const hostname = url.hostname; const hostname = url.hostname;
const pathname = url.pathname && url.pathname !== '/' ? url.pathname : ''; const pathname = url.pathname && url.pathname !== '/' ? url.pathname : '';
const credHost = hostname + pathname; const credHost = hostname + pathname;
if (credHost === host) { if (credHost === host) {
return { return {
username: url.username || '', username: url.username || '',
-4
View File
@@ -177,18 +177,14 @@ export async function setLocalIdentity(directory, profile) {
false, false,
'local' 'local'
); );
// Clear credential helper if previously set for token auth
await git.raw(['config', '--local', '--unset', 'credential.helper']).catch(() => {}); await git.raw(['config', '--local', '--unset', 'credential.helper']).catch(() => {});
} else if (authType === 'token' && profile.host) { } 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( await git.addConfig(
'credential.helper', 'credential.helper',
'store', 'store',
false, false,
'local' 'local'
); );
// Clear SSH command if previously set
await git.raw(['config', '--local', '--unset', 'core.sshCommand']).catch(() => {}); await git.raw(['config', '--local', '--unset', 'core.sshCommand']).catch(() => {});
} }