feat(git): enhance token authentication and credential handling
This commit is contained in:
+2
-2
@@ -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,8 +73,6 @@ 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;
|
||||||
|
|
||||||
@@ -84,7 +82,7 @@ export const GitIdentitiesPage: React.FC = () => {
|
|||||||
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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -506,48 +505,38 @@ export const GitView: React.FC = () => {
|
|||||||
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user