feat(git): add token authentication and credential discovery
Add support for token-based git authentication in identity profiles. Enable discovery and import of credentials from ~/.git-credentials file. Introduce remote URL-based filtering for token identities in git view.
This commit is contained in:
Generated
+2
-1
@@ -2977,7 +2977,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openchamber-desktop"
|
name = "openchamber-desktop"
|
||||||
version = "1.4.6"
|
version = "1.4.7"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"axum",
|
"axum",
|
||||||
@@ -3013,6 +3013,7 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
"tokio-util",
|
"tokio-util",
|
||||||
"tower-http 0.5.2",
|
"tower-http 0.5.2",
|
||||||
|
"url",
|
||||||
"urlencoding",
|
"urlencoding",
|
||||||
"uuid",
|
"uuid",
|
||||||
"window-vibrancy 0.7.1",
|
"window-vibrancy 0.7.1",
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ tauri-plugin-log = "2.7.1"
|
|||||||
tauri-plugin-shell = "2.3.3"
|
tauri-plugin-shell = "2.3.3"
|
||||||
tokio = { version = "1.38", features = ["macros", "rt-multi-thread", "process", "signal", "sync", "time", "fs"] }
|
tokio = { version = "1.38", features = ["macros", "rt-multi-thread", "process", "signal", "sync", "time", "fs"] }
|
||||||
tower-http = { version = "0.5.2", features = ["cors"] }
|
tower-http = { version = "0.5.2", features = ["cors"] }
|
||||||
|
url = "2.5"
|
||||||
uuid = { version = "1.18.1", features = ["v4"] }
|
uuid = { version = "1.18.1", features = ["v4"] }
|
||||||
tokio-util = { version = "0.7", features = ["io"] }
|
tokio-util = { version = "0.7", features = ["io"] }
|
||||||
tauri-plugin-notification = "2.3.3"
|
tauri-plugin-notification = "2.3.3"
|
||||||
|
|||||||
@@ -187,11 +187,20 @@ pub struct GitIdentityProfile {
|
|||||||
pub name: String,
|
pub name: String,
|
||||||
pub user_name: String,
|
pub user_name: String,
|
||||||
pub user_email: String,
|
pub user_email: String,
|
||||||
|
pub auth_type: Option<String>,
|
||||||
pub ssh_key: Option<String>,
|
pub ssh_key: Option<String>,
|
||||||
|
pub host: Option<String>,
|
||||||
pub color: Option<String>,
|
pub color: Option<String>,
|
||||||
pub icon: Option<String>,
|
pub icon: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct DiscoveredGitCredential {
|
||||||
|
pub host: String,
|
||||||
|
pub username: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct GitIdentityProfilesWrapper {
|
pub struct GitIdentityProfilesWrapper {
|
||||||
@@ -1984,6 +1993,22 @@ pub async fn delete_git_identity(id: String) -> Result<(), String> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_remote_url(
|
||||||
|
directory: String,
|
||||||
|
remote: Option<String>,
|
||||||
|
state: State<'_, DesktopRuntime>,
|
||||||
|
) -> Result<Option<String>, String> {
|
||||||
|
let root = validate_git_path(&directory, state.settings())
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let remote_name = remote.unwrap_or_else(|| "origin".to_string());
|
||||||
|
let url = run_git(&["remote", "get-url", &remote_name], &root).await.ok();
|
||||||
|
|
||||||
|
Ok(url.filter(|s| !s.is_empty()))
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn get_current_git_identity(
|
pub async fn get_current_git_identity(
|
||||||
directory: String,
|
directory: String,
|
||||||
@@ -2004,6 +2029,43 @@ 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()
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.and_then(|o| String::from_utf8(o.stdout).ok())
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.filter(|s| !s.is_empty());
|
||||||
|
|
||||||
|
let user_email = tokio::process::Command::new("git")
|
||||||
|
.args(["config", "--global", "user.email"])
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.and_then(|o| String::from_utf8(o.stdout).ok())
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.filter(|s| !s.is_empty());
|
||||||
|
|
||||||
|
let ssh_command = tokio::process::Command::new("git")
|
||||||
|
.args(["config", "--global", "core.sshCommand"])
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.and_then(|o| String::from_utf8(o.stdout).ok())
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.filter(|s| !s.is_empty());
|
||||||
|
|
||||||
|
Ok(GitIdentitySummary {
|
||||||
|
user_name,
|
||||||
|
user_email,
|
||||||
|
ssh_command,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn set_git_identity(
|
pub async fn set_git_identity(
|
||||||
directory: String,
|
directory: String,
|
||||||
@@ -2033,11 +2095,25 @@ pub async fn set_git_identity(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
if let Some(key) = &profile.ssh_key {
|
let auth_type = profile.auth_type.as_deref().unwrap_or("ssh");
|
||||||
let cmd = format!("ssh -i {}", key);
|
|
||||||
run_git(&["config", "--local", "core.sshCommand", &cmd], &root)
|
if auth_type == "ssh" {
|
||||||
|
if let Some(key) = &profile.ssh_key {
|
||||||
|
let cmd = format!("ssh -i {}", key);
|
||||||
|
run_git(&["config", "--local", "core.sshCommand", &cmd], &root)
|
||||||
|
.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
|
.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;
|
||||||
} else {
|
} else {
|
||||||
let _ = run_git(&["config", "--local", "--unset", "core.sshCommand"], &root).await;
|
let _ = run_git(&["config", "--local", "--unset", "core.sshCommand"], &root).await;
|
||||||
}
|
}
|
||||||
@@ -2045,6 +2121,53 @@ pub async fn set_git_identity(
|
|||||||
Ok(profile)
|
Ok(profile)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn discover_git_credentials() -> Result<Vec<DiscoveredGitCredential>, String> {
|
||||||
|
let home = dirs::home_dir().ok_or_else(|| "Could not find home directory".to_string())?;
|
||||||
|
let credentials_path = home.join(".git-credentials");
|
||||||
|
|
||||||
|
if !credentials_path.exists() {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
let content = fs::read_to_string(&credentials_path)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to read .git-credentials: {}", e))?;
|
||||||
|
|
||||||
|
let mut credentials = Vec::new();
|
||||||
|
for line in content.lines() {
|
||||||
|
let trimmed = line.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
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 {
|
||||||
|
format!("{}{}", hostname, path)
|
||||||
|
};
|
||||||
|
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);
|
||||||
|
if !exists {
|
||||||
|
credentials.push(DiscoveredGitCredential { host, username });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(credentials)
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn generate_commit_message(
|
pub async fn generate_commit_message(
|
||||||
directory: String,
|
directory: String,
|
||||||
|
|||||||
@@ -32,11 +32,11 @@ use commands::files::{create_directory, exec_commands, list_directory, read_file
|
|||||||
use commands::git::{
|
use commands::git::{
|
||||||
add_git_worktree, check_is_git_repository, checkout_branch, create_branch, create_git_commit, rename_branch,
|
add_git_worktree, check_is_git_repository, checkout_branch, create_branch, create_git_commit, rename_branch,
|
||||||
create_git_identity, delete_git_branch, delete_git_identity, delete_remote_branch,
|
create_git_identity, delete_git_branch, delete_git_identity, delete_remote_branch,
|
||||||
ensure_openchamber_ignored, generate_commit_message, get_commit_files,
|
discover_git_credentials, ensure_openchamber_ignored, generate_commit_message, get_commit_files,
|
||||||
get_current_git_identity, get_git_branches, get_git_diff, get_git_file_diff,
|
get_current_git_identity, get_git_branches, get_git_diff, get_git_file_diff,
|
||||||
get_git_identities, get_git_log, get_git_status, git_fetch, git_pull, git_push,
|
get_git_identities, get_git_log, get_git_status, get_global_git_identity, get_remote_url,
|
||||||
is_linked_worktree, list_git_worktrees, remove_git_worktree, revert_git_file, set_git_identity,
|
git_fetch, git_pull, git_push, is_linked_worktree, list_git_worktrees, remove_git_worktree,
|
||||||
update_git_identity,
|
revert_git_file, set_git_identity, update_git_identity,
|
||||||
};
|
};
|
||||||
use commands::logs::fetch_desktop_logs;
|
use commands::logs::fetch_desktop_logs;
|
||||||
|
|
||||||
@@ -871,7 +871,10 @@ fn main() {
|
|||||||
update_git_identity,
|
update_git_identity,
|
||||||
delete_git_identity,
|
delete_git_identity,
|
||||||
get_current_git_identity,
|
get_current_git_identity,
|
||||||
|
get_global_git_identity,
|
||||||
|
get_remote_url,
|
||||||
set_git_identity,
|
set_git_identity,
|
||||||
|
discover_git_credentials,
|
||||||
generate_commit_message,
|
generate_commit_message,
|
||||||
create_terminal_session,
|
create_terminal_session,
|
||||||
send_terminal_input,
|
send_terminal_input,
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ import type {
|
|||||||
GitLogResponse,
|
GitLogResponse,
|
||||||
GitCommitFilesResponse,
|
GitCommitFilesResponse,
|
||||||
GitIdentitySummary,
|
GitIdentitySummary,
|
||||||
GitIdentityProfile
|
GitIdentityProfile,
|
||||||
|
DiscoveredGitCredential
|
||||||
} from '@openchamber/ui/lib/api/types';
|
} from '@openchamber/ui/lib/api/types';
|
||||||
|
|
||||||
async function safeGitInvoke<T>(command: string, args?: Record<string, unknown>): Promise<T> {
|
async function safeGitInvoke<T>(command: string, args?: Record<string, unknown>): Promise<T> {
|
||||||
@@ -237,4 +238,24 @@ export const createDesktopGitAPI = (): GitAPI => ({
|
|||||||
async deleteGitIdentity(id: string): Promise<void> {
|
async deleteGitIdentity(id: string): Promise<void> {
|
||||||
return safeGitInvoke<void>('delete_git_identity', { id });
|
return safeGitInvoke<void>('delete_git_identity', { id });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async discoverGitCredentials(): Promise<DiscoveredGitCredential[]> {
|
||||||
|
return safeGitInvoke<DiscoveredGitCredential[]>('discover_git_credentials');
|
||||||
|
},
|
||||||
|
|
||||||
|
async getGlobalGitIdentity(): Promise<GitIdentitySummary | null> {
|
||||||
|
try {
|
||||||
|
return await safeGitInvoke<GitIdentitySummary>('get_global_git_identity');
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async getRemoteUrl(directory: string, remote?: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
return await safeGitInvoke<string | null>('get_remote_url', { directory, remote });
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React from 'react';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { useGitIdentitiesStore, type GitIdentityProfile } from '@/stores/useGitIdentitiesStore';
|
import { useGitIdentitiesStore, type GitIdentityProfile, type GitIdentityAuthType } from '@/stores/useGitIdentitiesStore';
|
||||||
import {
|
import {
|
||||||
RiUser3Line,
|
RiUser3Line,
|
||||||
RiSaveLine,
|
RiSaveLine,
|
||||||
@@ -12,7 +12,9 @@ import {
|
|||||||
RiHomeLine,
|
RiHomeLine,
|
||||||
RiGraduationCapLine,
|
RiGraduationCapLine,
|
||||||
RiCodeLine,
|
RiCodeLine,
|
||||||
RiInformationLine
|
RiInformationLine,
|
||||||
|
RiKeyLine,
|
||||||
|
RiLock2Line
|
||||||
} from '@remixicon/react';
|
} from '@remixicon/react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
@@ -43,40 +45,67 @@ export const GitIdentitiesPage: React.FC = () => {
|
|||||||
deleteProfile,
|
deleteProfile,
|
||||||
} = useGitIdentitiesStore();
|
} = useGitIdentitiesStore();
|
||||||
|
|
||||||
|
// Parse import: prefix for credential import flow
|
||||||
|
const importData = React.useMemo(() => {
|
||||||
|
if (selectedProfileId?.startsWith('import:')) {
|
||||||
|
const [, host, username] = selectedProfileId.split(':');
|
||||||
|
return { host, username };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}, [selectedProfileId]);
|
||||||
|
|
||||||
const selectedProfile = React.useMemo(() =>
|
const selectedProfile = React.useMemo(() =>
|
||||||
selectedProfileId && selectedProfileId !== 'new' ? getProfileById(selectedProfileId) : null,
|
selectedProfileId && selectedProfileId !== 'new' && !importData ? getProfileById(selectedProfileId) : null,
|
||||||
[selectedProfileId, getProfileById]
|
[selectedProfileId, getProfileById, importData]
|
||||||
);
|
);
|
||||||
const isNewProfile = selectedProfileId === 'new';
|
const isNewProfile = selectedProfileId === 'new' || importData !== null;
|
||||||
const isGlobalProfile = selectedProfileId === 'global';
|
const isGlobalProfile = selectedProfileId === 'global';
|
||||||
|
|
||||||
const [name, setName] = React.useState('');
|
const [name, setName] = React.useState('');
|
||||||
const [userName, setUserName] = React.useState('');
|
const [userName, setUserName] = React.useState('');
|
||||||
const [userEmail, setUserEmail] = React.useState('');
|
const [userEmail, setUserEmail] = React.useState('');
|
||||||
|
const [authType, setAuthType] = React.useState<GitIdentityAuthType>('ssh');
|
||||||
const [sshKey, setSshKey] = React.useState('');
|
const [sshKey, setSshKey] = React.useState('');
|
||||||
|
const [host, setHost] = React.useState('');
|
||||||
const [color, setColor] = React.useState('keyword');
|
const [color, setColor] = React.useState('keyword');
|
||||||
const [icon, setIcon] = React.useState('branch');
|
const [icon, setIcon] = React.useState('branch');
|
||||||
const [isSaving, setIsSaving] = React.useState(false);
|
const [isSaving, setIsSaving] = React.useState(false);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (isNewProfile) {
|
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
|
||||||
|
setIcon('code');
|
||||||
|
} else if (isNewProfile) {
|
||||||
setName('');
|
setName('');
|
||||||
setUserName('');
|
setUserName('');
|
||||||
setUserEmail('');
|
setUserEmail('');
|
||||||
|
setAuthType('ssh');
|
||||||
setSshKey('');
|
setSshKey('');
|
||||||
|
setHost('');
|
||||||
setColor('keyword');
|
setColor('keyword');
|
||||||
setIcon('branch');
|
setIcon('branch');
|
||||||
} else if (selectedProfile) {
|
} else if (selectedProfile) {
|
||||||
|
|
||||||
setName(selectedProfile.name);
|
setName(selectedProfile.name);
|
||||||
setUserName(selectedProfile.userName);
|
setUserName(selectedProfile.userName);
|
||||||
setUserEmail(selectedProfile.userEmail);
|
setUserEmail(selectedProfile.userEmail);
|
||||||
|
setAuthType(selectedProfile.authType || 'ssh');
|
||||||
setSshKey(selectedProfile.sshKey || '');
|
setSshKey(selectedProfile.sshKey || '');
|
||||||
|
setHost(selectedProfile.host || '');
|
||||||
setColor(selectedProfile.color || 'keyword');
|
setColor(selectedProfile.color || 'keyword');
|
||||||
setIcon(selectedProfile.icon || 'branch');
|
setIcon(selectedProfile.icon || 'branch');
|
||||||
}
|
}
|
||||||
}, [selectedProfile, isNewProfile, selectedProfileId]);
|
}, [selectedProfile, isNewProfile, selectedProfileId, importData]);
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (!userName.trim() || !userEmail.trim()) {
|
if (!userName.trim() || !userEmail.trim()) {
|
||||||
@@ -84,6 +113,11 @@ export const GitIdentitiesPage: React.FC = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (authType === 'token' && !host.trim()) {
|
||||||
|
toast.error('Host is required for token-based authentication');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -91,7 +125,9 @@ export const GitIdentitiesPage: React.FC = () => {
|
|||||||
name: name.trim() || userName.trim(),
|
name: name.trim() || userName.trim(),
|
||||||
userName: userName.trim(),
|
userName: userName.trim(),
|
||||||
userEmail: userEmail.trim(),
|
userEmail: userEmail.trim(),
|
||||||
sshKey: sshKey.trim() || null,
|
authType,
|
||||||
|
sshKey: authType === 'ssh' ? (sshKey.trim() || null) : null,
|
||||||
|
host: authType === 'token' ? (host.trim() || null) : null,
|
||||||
color,
|
color,
|
||||||
icon,
|
icon,
|
||||||
};
|
};
|
||||||
@@ -161,10 +197,12 @@ export const GitIdentitiesPage: React.FC = () => {
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<h1 className="typography-ui-header font-semibold text-lg">
|
<h1 className="typography-ui-header font-semibold text-lg">
|
||||||
{isNewProfile ? 'New Git Profile' : isGlobalProfile ? 'Global Identity' : name || 'Edit Profile'}
|
{importData ? 'Import Credential' : isNewProfile ? 'New Git Profile' : isGlobalProfile ? 'Global Identity' : name || 'Edit Profile'}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="typography-body text-muted-foreground mt-1">
|
<p className="typography-body text-muted-foreground mt-1">
|
||||||
{isNewProfile
|
{importData
|
||||||
|
? `Import token credential for ${importData.host} - please fill in your email address`
|
||||||
|
: isNewProfile
|
||||||
? 'Create a new Git identity profile for your repositories'
|
? 'Create a new Git identity profile for your repositories'
|
||||||
: isGlobalProfile
|
: isGlobalProfile
|
||||||
? 'System-wide Git identity from global configuration (read-only)'
|
? 'System-wide Git identity from global configuration (read-only)'
|
||||||
@@ -315,6 +353,54 @@ export const GitIdentitiesPage: React.FC = () => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Auth Type Selector */}
|
||||||
|
{!isGlobalProfile && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="typography-ui-label font-medium text-foreground flex items-center gap-2">
|
||||||
|
Authentication Type
|
||||||
|
<Tooltip delayDuration={1000}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent sideOffset={8} className="max-w-xs">
|
||||||
|
SSH: Uses SSH key for authentication<br/>
|
||||||
|
Token: Uses personal access token from ~/.git-credentials
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setAuthType('ssh')}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-2 px-3 py-1.5 rounded-md border transition-all',
|
||||||
|
authType === 'ssh'
|
||||||
|
? 'border-primary bg-accent'
|
||||||
|
: 'border-border hover:border-primary/50'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<RiLock2Line className="w-4 h-4" />
|
||||||
|
<span className="typography-ui-label">SSH Key</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setAuthType('token')}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-2 px-3 py-1.5 rounded-md border transition-all',
|
||||||
|
authType === 'token'
|
||||||
|
? 'border-primary bg-accent'
|
||||||
|
: 'border-border hover:border-primary/50'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<RiKeyLine className="w-4 h-4" />
|
||||||
|
<span className="typography-ui-label">Token (HTTPS)</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* SSH Key Path - only for SSH auth type */}
|
||||||
|
{authType === 'ssh' && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="typography-ui-label font-medium text-foreground flex items-center gap-2">
|
<label className="typography-ui-label font-medium text-foreground flex items-center gap-2">
|
||||||
SSH Key Path
|
SSH Key Path
|
||||||
@@ -324,22 +410,51 @@ export const GitIdentitiesPage: React.FC = () => {
|
|||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent sideOffset={8} className="max-w-xs">
|
<TooltipContent sideOffset={8} className="max-w-xs">
|
||||||
Path to SSH private key used for Git authentication.<br/>
|
Path to SSH private key used for Git authentication.<br/>
|
||||||
This key will be used for HTTPS and SSH Git operations.<br/>
|
This key will be used for SSH Git operations.<br/>
|
||||||
Common paths: ~/.ssh/id_rsa, ~/.ssh/id_ed25519
|
Common paths: ~/.ssh/id_rsa, ~/.ssh/id_ed25519
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
value={sshKey}
|
value={sshKey}
|
||||||
onChange={(e) => setSshKey(e.target.value)}
|
onChange={(e) => setSshKey(e.target.value)}
|
||||||
placeholder="/Users/username/.ssh/id_rsa"
|
placeholder="/Users/username/.ssh/id_rsa"
|
||||||
readOnly={isGlobalProfile}
|
readOnly={isGlobalProfile}
|
||||||
disabled={isGlobalProfile}
|
disabled={isGlobalProfile}
|
||||||
/>
|
/>
|
||||||
<p className="typography-meta text-muted-foreground">
|
<p className="typography-meta text-muted-foreground">
|
||||||
Path to SSH private key for authentication (optional)
|
Path to SSH private key for authentication (optional)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Host - only for Token auth type */}
|
||||||
|
{authType === 'token' && !isGlobalProfile && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="typography-ui-label font-medium text-foreground flex items-center gap-2">
|
||||||
|
Host {<span className="text-destructive">*</span>}
|
||||||
|
<Tooltip delayDuration={1000}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent sideOffset={8} className="max-w-xs">
|
||||||
|
The Git host this credential applies to.<br/>
|
||||||
|
Token will be read from ~/.git-credentials for this host.<br/>
|
||||||
|
Examples: github.com, gitlab.com
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
value={host}
|
||||||
|
onChange={(e) => setHost(e.target.value)}
|
||||||
|
placeholder="github.com"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<p className="typography-meta text-muted-foreground">
|
||||||
|
Git host for token authentication (from ~/.git-credentials)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{}
|
{}
|
||||||
{!isGlobalProfile && (
|
{!isGlobalProfile && (
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
RiGraduationCapLine,
|
RiGraduationCapLine,
|
||||||
RiCodeLine,
|
RiCodeLine,
|
||||||
RiHeartLine,
|
RiHeartLine,
|
||||||
|
RiDownloadLine,
|
||||||
} from '@remixicon/react';
|
} from '@remixicon/react';
|
||||||
import { useGitIdentitiesStore } from '@/stores/useGitIdentitiesStore';
|
import { useGitIdentitiesStore } from '@/stores/useGitIdentitiesStore';
|
||||||
import { useUIStore } from '@/stores/useUIStore';
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
@@ -24,7 +25,7 @@ import { useDeviceInfo } from '@/lib/device';
|
|||||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import type { GitIdentityProfile } from '@/stores/useGitIdentitiesStore';
|
import type { GitIdentityProfile, DiscoveredGitCredential } from '@/stores/useGitIdentitiesStore';
|
||||||
|
|
||||||
const ICON_MAP: Record<string, React.ComponentType<{ className?: string; style?: React.CSSProperties }>> = {
|
const ICON_MAP: Record<string, React.ComponentType<{ className?: string; style?: React.CSSProperties }>> = {
|
||||||
branch: RiGitBranchLine,
|
branch: RiGitBranchLine,
|
||||||
@@ -56,6 +57,8 @@ export const GitIdentitiesSidebar: React.FC<GitIdentitiesSidebarProps> = ({ onIt
|
|||||||
deleteProfile,
|
deleteProfile,
|
||||||
loadProfiles,
|
loadProfiles,
|
||||||
loadGlobalIdentity,
|
loadGlobalIdentity,
|
||||||
|
loadDiscoveredCredentials,
|
||||||
|
getUnimportedCredentials,
|
||||||
} = useGitIdentitiesStore();
|
} = useGitIdentitiesStore();
|
||||||
|
|
||||||
const { setSidebarOpen } = useUIStore();
|
const { setSidebarOpen } = useUIStore();
|
||||||
@@ -73,10 +76,23 @@ export const GitIdentitiesSidebar: React.FC<GitIdentitiesSidebarProps> = ({ onIt
|
|||||||
setIsDesktopRuntime(typeof window.opencodeDesktop !== 'undefined');
|
setIsDesktopRuntime(typeof window.opencodeDesktop !== 'undefined');
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const unimportedCredentials = getUnimportedCredentials();
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
loadProfiles();
|
loadProfiles();
|
||||||
loadGlobalIdentity();
|
loadGlobalIdentity();
|
||||||
}, [loadProfiles, loadGlobalIdentity]);
|
loadDiscoveredCredentials();
|
||||||
|
}, [loadProfiles, loadGlobalIdentity, loadDiscoveredCredentials]);
|
||||||
|
|
||||||
|
const handleImportCredential = (credential: DiscoveredGitCredential) => {
|
||||||
|
// Set a special "import" selection that carries the credential data
|
||||||
|
// The form will read this and pre-fill fields
|
||||||
|
setSelectedProfile(`import:${credential.host}:${credential.username}`);
|
||||||
|
onItemSelect?.();
|
||||||
|
if (isMobile) {
|
||||||
|
setSidebarOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const bgClass = isDesktopRuntime
|
const bgClass = isDesktopRuntime
|
||||||
? 'bg-transparent'
|
? 'bg-transparent'
|
||||||
@@ -150,7 +166,7 @@ export const GitIdentitiesSidebar: React.FC<GitIdentitiesSidebarProps> = ({ onIt
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{profiles.length === 0 && !globalIdentity ? (
|
{profiles.length === 0 && !globalIdentity && unimportedCredentials.length === 0 ? (
|
||||||
<div className="py-12 px-4 text-center text-muted-foreground">
|
<div className="py-12 px-4 text-center text-muted-foreground">
|
||||||
<RiGitBranchLine className="mx-auto mb-3 h-10 w-10 opacity-50" />
|
<RiGitBranchLine className="mx-auto mb-3 h-10 w-10 opacity-50" />
|
||||||
<p className="typography-ui-label font-medium">No profiles configured</p>
|
<p className="typography-ui-label font-medium">No profiles configured</p>
|
||||||
@@ -175,6 +191,25 @@ export const GitIdentitiesSidebar: React.FC<GitIdentitiesSidebarProps> = ({ onIt
|
|||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Discovered Credentials Section */}
|
||||||
|
{unimportedCredentials.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="px-2 pb-1.5 pt-3 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
|
Discovered Credentials
|
||||||
|
</div>
|
||||||
|
<p className="px-2 pb-2 typography-micro text-muted-foreground/60">
|
||||||
|
Found in ~/.git-credentials
|
||||||
|
</p>
|
||||||
|
{unimportedCredentials.map((cred) => (
|
||||||
|
<DiscoveredCredentialItem
|
||||||
|
key={`${cred.host}-${cred.username}`}
|
||||||
|
credential={cred}
|
||||||
|
onImport={() => handleImportCredential(cred)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</ScrollableOverlay>
|
</ScrollableOverlay>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -197,6 +232,7 @@ const ProfileListItem: React.FC<ProfileListItemProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const IconComponent = ICON_MAP[profile.icon || 'branch'] || RiGitBranchLine;
|
const IconComponent = ICON_MAP[profile.icon || 'branch'] || RiGitBranchLine;
|
||||||
const iconColor = COLOR_MAP[profile.color || ''];
|
const iconColor = COLOR_MAP[profile.color || ''];
|
||||||
|
const authType = profile.authType || 'ssh';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -211,18 +247,21 @@ const ProfileListItem: React.FC<ProfileListItemProps> = ({
|
|||||||
className="flex min-w-0 flex-1 flex-col gap-0 rounded-sm text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
|
className="flex min-w-0 flex-1 flex-col gap-0 rounded-sm text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-1.5">
|
||||||
<IconComponent
|
<IconComponent
|
||||||
className="w-4 h-4 flex-shrink-0"
|
className="w-4 h-4 flex-shrink-0"
|
||||||
style={{ color: iconColor }}
|
style={{ color: iconColor }}
|
||||||
/>
|
/>
|
||||||
<span className="typography-ui-label font-normal truncate flex-1 text-foreground">
|
<span className="typography-ui-label font-normal truncate text-foreground">
|
||||||
{profile.name}
|
{profile.name}
|
||||||
</span>
|
</span>
|
||||||
|
<span className="typography-micro text-muted-foreground bg-muted px-1 rounded flex-shrink-0 leading-none pb-px border border-border/50">
|
||||||
|
{authType}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="typography-micro text-muted-foreground/60 truncate leading-tight">
|
<div className="typography-micro text-muted-foreground/60 truncate leading-tight">
|
||||||
{profile.userEmail}
|
{authType === 'token' && profile.host ? profile.host : profile.userEmail}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -255,3 +294,57 @@ const ProfileListItem: React.FC<ProfileListItemProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
interface DiscoveredCredentialItemProps {
|
||||||
|
credential: DiscoveredGitCredential;
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DiscoveredCredentialItem: React.FC<DiscoveredCredentialItemProps> = ({
|
||||||
|
credential,
|
||||||
|
onImport,
|
||||||
|
}) => {
|
||||||
|
const displayName = getCredentialDisplayName(credential.host);
|
||||||
|
const isRepoSpecific = credential.host.includes('/');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="group relative flex items-center rounded-md px-1.5 py-1 transition-all duration-200 hover:dark:bg-accent/40 hover:bg-primary/6">
|
||||||
|
<div className="flex min-w-0 flex-1 items-center">
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col gap-0">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="typography-ui-label font-normal truncate text-foreground">
|
||||||
|
{displayName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="typography-micro text-muted-foreground/60 truncate leading-tight">
|
||||||
|
{isRepoSpecific ? credential.host : credential.username}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={onImport}
|
||||||
|
className="h-6 px-2 text-xs gap-1 opacity-100 transition-opacity md:opacity-0 md:group-hover:opacity-100"
|
||||||
|
>
|
||||||
|
<RiDownloadLine className="h-3 w-3" />
|
||||||
|
Import
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ export const GitView: React.FC = () => {
|
|||||||
const [expandedCommitHashes, setExpandedCommitHashes] = React.useState<Set<string>>(new Set());
|
const [expandedCommitHashes, setExpandedCommitHashes] = React.useState<Set<string>>(new Set());
|
||||||
const [commitFilesMap, setCommitFilesMap] = React.useState<Map<string, CommitFileEntry[]>>(new Map());
|
const [commitFilesMap, setCommitFilesMap] = React.useState<Map<string, CommitFileEntry[]>>(new Map());
|
||||||
const [loadingCommitHashes, setLoadingCommitHashes] = React.useState<Set<string>>(new Set());
|
const [loadingCommitHashes, setLoadingCommitHashes] = React.useState<Set<string>>(new Set());
|
||||||
|
const [remoteUrl, setRemoteUrl] = React.useState<string | null>(null);
|
||||||
|
|
||||||
const handleCopyCommitHash = React.useCallback((hash: string) => {
|
const handleCopyCommitHash = React.useCallback((hash: string) => {
|
||||||
navigator.clipboard
|
navigator.clipboard
|
||||||
@@ -188,6 +189,15 @@ export const GitView: React.FC = () => {
|
|||||||
loadGlobalIdentity();
|
loadGlobalIdentity();
|
||||||
}, [loadProfiles, loadGlobalIdentity]);
|
}, [loadProfiles, loadGlobalIdentity]);
|
||||||
|
|
||||||
|
// Fetch remote URL for filtering token-based identities
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!currentDirectory || !git?.getRemoteUrl) {
|
||||||
|
setRemoteUrl(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
git.getRemoteUrl(currentDirectory).then(setRemoteUrl).catch(() => setRemoteUrl(null));
|
||||||
|
}, [currentDirectory, git]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (currentDirectory) {
|
if (currentDirectory) {
|
||||||
setActiveDirectory(currentDirectory);
|
setActiveDirectory(currentDirectory);
|
||||||
@@ -495,11 +505,55 @@ 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;
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (const profile of profiles) {
|
for (const profile of profiles) {
|
||||||
unique.set(profile.id, profile);
|
// 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return Array.from(unique.values());
|
return Array.from(unique.values());
|
||||||
}, [profiles, globalIdentity]);
|
}, [profiles, globalIdentity, remoteUrl]);
|
||||||
|
|
||||||
const activeIdentityProfile = React.useMemo((): GitIdentityProfile | null => {
|
const activeIdentityProfile = React.useMemo((): GitIdentityProfile | null => {
|
||||||
if (currentIdentity?.userName && currentIdentity?.userEmail) {
|
if (currentIdentity?.userName && currentIdentity?.userEmail) {
|
||||||
|
|||||||
@@ -168,16 +168,25 @@ export interface GitPullResult {
|
|||||||
deletions: number;
|
deletions: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type GitIdentityAuthType = 'ssh' | 'token';
|
||||||
|
|
||||||
export interface GitIdentityProfile {
|
export interface GitIdentityProfile {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
userName: string;
|
userName: string;
|
||||||
userEmail: string;
|
userEmail: string;
|
||||||
|
authType?: GitIdentityAuthType;
|
||||||
sshKey?: string | null;
|
sshKey?: string | null;
|
||||||
|
host?: string | null;
|
||||||
color?: string | null;
|
color?: string | null;
|
||||||
icon?: string | null;
|
icon?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DiscoveredGitCredential {
|
||||||
|
host: string;
|
||||||
|
username: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface GitIdentitySummary {
|
export interface GitIdentitySummary {
|
||||||
userName: string | null;
|
userName: string | null;
|
||||||
userEmail: string | null;
|
userEmail: string | null;
|
||||||
@@ -290,6 +299,9 @@ export interface GitAPI {
|
|||||||
createGitIdentity(profile: GitIdentityProfile): Promise<GitIdentityProfile>;
|
createGitIdentity(profile: GitIdentityProfile): Promise<GitIdentityProfile>;
|
||||||
updateGitIdentity(id: string, updates: GitIdentityProfile): Promise<GitIdentityProfile>;
|
updateGitIdentity(id: string, updates: GitIdentityProfile): Promise<GitIdentityProfile>;
|
||||||
deleteGitIdentity(id: string): Promise<void>;
|
deleteGitIdentity(id: string): Promise<void>;
|
||||||
|
discoverGitCredentials?(): Promise<DiscoveredGitCredential[]>;
|
||||||
|
getGlobalGitIdentity?(): Promise<GitIdentitySummary | null>;
|
||||||
|
getRemoteUrl?(directory: string, remote?: string): Promise<string | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FileListEntry {
|
export interface FileListEntry {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export type {
|
|||||||
GitPushResult,
|
GitPushResult,
|
||||||
GitPullResult,
|
GitPullResult,
|
||||||
GitIdentityProfile,
|
GitIdentityProfile,
|
||||||
|
GitIdentityAuthType,
|
||||||
GitIdentitySummary,
|
GitIdentitySummary,
|
||||||
GitLogEntry,
|
GitLogEntry,
|
||||||
GitLogResponse,
|
GitLogResponse,
|
||||||
@@ -21,6 +22,7 @@ export type {
|
|||||||
GitRemoveWorktreePayload,
|
GitRemoveWorktreePayload,
|
||||||
GitDeleteBranchPayload,
|
GitDeleteBranchPayload,
|
||||||
GitDeleteRemoteBranchPayload,
|
GitDeleteRemoteBranchPayload,
|
||||||
|
DiscoveredGitCredential,
|
||||||
} from './api/types';
|
} from './api/types';
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
@@ -245,3 +247,21 @@ export async function setGitIdentity(
|
|||||||
if (runtime) return runtime.setGitIdentity(directory, profileId);
|
if (runtime) return runtime.setGitIdentity(directory, profileId);
|
||||||
return gitHttp.setGitIdentity(directory, profileId);
|
return gitHttp.setGitIdentity(directory, profileId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function discoverGitCredentials(): Promise<import('./api/types').DiscoveredGitCredential[]> {
|
||||||
|
const runtime = getRuntimeGit();
|
||||||
|
if (runtime?.discoverGitCredentials) return runtime.discoverGitCredentials();
|
||||||
|
return gitHttp.discoverGitCredentials();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getGlobalGitIdentity(): Promise<import('./api/types').GitIdentitySummary | null> {
|
||||||
|
const runtime = getRuntimeGit();
|
||||||
|
if (runtime?.getGlobalGitIdentity) return runtime.getGlobalGitIdentity();
|
||||||
|
return gitHttp.getGlobalGitIdentity();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getRemoteUrl(directory: string, remote?: string): Promise<string | null> {
|
||||||
|
const runtime = getRuntimeGit();
|
||||||
|
if (runtime?.getRemoteUrl) return runtime.getRemoteUrl(directory, remote);
|
||||||
|
return gitHttp.getRemoteUrl(directory, remote);
|
||||||
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import type {
|
|||||||
GitCommitFilesResponse,
|
GitCommitFilesResponse,
|
||||||
GitIdentityProfile,
|
GitIdentityProfile,
|
||||||
GitIdentitySummary,
|
GitIdentitySummary,
|
||||||
|
DiscoveredGitCredential,
|
||||||
} from './api/types';
|
} from './api/types';
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
@@ -515,6 +516,22 @@ export async function getCurrentGitIdentity(directory: string): Promise<GitIdent
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getGlobalGitIdentity(): Promise<GitIdentitySummary | null> {
|
||||||
|
const response = await fetch(buildUrl(`${API_BASE}/global-identity`, undefined));
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to get global git identity: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
if (!data || (!data.userName && !data.userEmail)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
userName: data.userName ?? null,
|
||||||
|
userEmail: data.userEmail ?? null,
|
||||||
|
sshCommand: data.sshCommand ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function setGitIdentity(
|
export async function setGitIdentity(
|
||||||
directory: string,
|
directory: string,
|
||||||
profileId: string
|
profileId: string
|
||||||
@@ -530,3 +547,23 @@ export async function setGitIdentity(
|
|||||||
}
|
}
|
||||||
return response.json();
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function discoverGitCredentials(): Promise<DiscoveredGitCredential[]> {
|
||||||
|
const response = await fetch(buildUrl(`${API_BASE}/discover-credentials`, undefined));
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to discover git credentials: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getRemoteUrl(directory: string, remote?: string): Promise<string | null> {
|
||||||
|
if (!directory) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const response = await fetch(buildUrl(`${API_BASE}/remote-url`, directory, { remote }));
|
||||||
|
if (!response.ok) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
return data.url ?? null;
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,33 +7,46 @@ import {
|
|||||||
createGitIdentity,
|
createGitIdentity,
|
||||||
updateGitIdentity,
|
updateGitIdentity,
|
||||||
deleteGitIdentity,
|
deleteGitIdentity,
|
||||||
getCurrentGitIdentity
|
discoverGitCredentials,
|
||||||
|
getGlobalGitIdentity
|
||||||
} from "@/lib/gitApi";
|
} from "@/lib/gitApi";
|
||||||
|
|
||||||
|
export type GitIdentityAuthType = 'ssh' | 'token';
|
||||||
|
|
||||||
export interface GitIdentityProfile {
|
export interface GitIdentityProfile {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
userName: string;
|
userName: string;
|
||||||
userEmail: string;
|
userEmail: string;
|
||||||
|
authType?: GitIdentityAuthType;
|
||||||
sshKey?: string | null;
|
sshKey?: string | null;
|
||||||
|
host?: string | null;
|
||||||
color?: string | null;
|
color?: string | null;
|
||||||
icon?: string | null;
|
icon?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DiscoveredGitCredential {
|
||||||
|
host: string;
|
||||||
|
username: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface GitIdentitiesStore {
|
interface GitIdentitiesStore {
|
||||||
|
|
||||||
selectedProfileId: string | null;
|
selectedProfileId: string | null;
|
||||||
profiles: GitIdentityProfile[];
|
profiles: GitIdentityProfile[];
|
||||||
globalIdentity: GitIdentityProfile | null;
|
globalIdentity: GitIdentityProfile | null;
|
||||||
|
discoveredCredentials: DiscoveredGitCredential[];
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
|
|
||||||
setSelectedProfile: (id: string | null) => void;
|
setSelectedProfile: (id: string | null) => void;
|
||||||
loadProfiles: () => Promise<boolean>;
|
loadProfiles: () => Promise<boolean>;
|
||||||
loadGlobalIdentity: () => Promise<boolean>;
|
loadGlobalIdentity: () => Promise<boolean>;
|
||||||
|
loadDiscoveredCredentials: () => Promise<boolean>;
|
||||||
createProfile: (profile: Omit<GitIdentityProfile, 'id'> & { id?: string }) => Promise<boolean>;
|
createProfile: (profile: Omit<GitIdentityProfile, 'id'> & { id?: string }) => Promise<boolean>;
|
||||||
updateProfile: (id: string, updates: Partial<GitIdentityProfile>) => Promise<boolean>;
|
updateProfile: (id: string, updates: Partial<GitIdentityProfile>) => Promise<boolean>;
|
||||||
deleteProfile: (id: string) => Promise<boolean>;
|
deleteProfile: (id: string) => Promise<boolean>;
|
||||||
getProfileById: (id: string) => GitIdentityProfile | undefined;
|
getProfileById: (id: string) => GitIdentityProfile | undefined;
|
||||||
|
getUnimportedCredentials: () => DiscoveredGitCredential[];
|
||||||
}
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
@@ -50,6 +63,7 @@ export const useGitIdentitiesStore = create<GitIdentitiesStore>()(
|
|||||||
selectedProfileId: null,
|
selectedProfileId: null,
|
||||||
profiles: [],
|
profiles: [],
|
||||||
globalIdentity: null,
|
globalIdentity: null,
|
||||||
|
discoveredCredentials: [],
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
|
|
||||||
setSelectedProfile: (id: string | null) => {
|
setSelectedProfile: (id: string | null) => {
|
||||||
@@ -73,7 +87,7 @@ export const useGitIdentitiesStore = create<GitIdentitiesStore>()(
|
|||||||
|
|
||||||
loadGlobalIdentity: async () => {
|
loadGlobalIdentity: async () => {
|
||||||
try {
|
try {
|
||||||
const data = await getCurrentGitIdentity('');
|
const data = await getGlobalGitIdentity();
|
||||||
|
|
||||||
if (data && data.userName && data.userEmail) {
|
if (data && data.userName && data.userEmail) {
|
||||||
const globalProfile: GitIdentityProfile = {
|
const globalProfile: GitIdentityProfile = {
|
||||||
@@ -81,6 +95,7 @@ export const useGitIdentitiesStore = create<GitIdentitiesStore>()(
|
|||||||
name: 'Global Identity',
|
name: 'Global Identity',
|
||||||
userName: data.userName,
|
userName: data.userName,
|
||||||
userEmail: data.userEmail,
|
userEmail: data.userEmail,
|
||||||
|
authType: data.sshCommand ? 'ssh' : undefined,
|
||||||
sshKey: data.sshCommand ? data.sshCommand.replace('ssh -i ', '') : null,
|
sshKey: data.sshCommand ? data.sshCommand.replace('ssh -i ', '') : null,
|
||||||
color: 'info',
|
color: 'info',
|
||||||
icon: 'house'
|
icon: 'house'
|
||||||
@@ -98,6 +113,18 @@ export const useGitIdentitiesStore = create<GitIdentitiesStore>()(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
loadDiscoveredCredentials: async () => {
|
||||||
|
try {
|
||||||
|
const credentials = await discoverGitCredentials();
|
||||||
|
set({ discoveredCredentials: credentials });
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to discover git credentials:", error);
|
||||||
|
set({ discoveredCredentials: [] });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
createProfile: async (profileData) => {
|
createProfile: async (profileData) => {
|
||||||
try {
|
try {
|
||||||
|
|
||||||
@@ -160,6 +187,16 @@ export const useGitIdentitiesStore = create<GitIdentitiesStore>()(
|
|||||||
}
|
}
|
||||||
return profiles.find((p) => p.id === id);
|
return profiles.find((p) => p.id === id);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getUnimportedCredentials: () => {
|
||||||
|
const { profiles, discoveredCredentials } = get();
|
||||||
|
// Filter out credentials that have already been imported as token-based profiles
|
||||||
|
return discoveredCredentials.filter(cred => {
|
||||||
|
return !profiles.some(p =>
|
||||||
|
p.authType === 'token' && p.host === cred.host
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: "git-identities-store",
|
name: "git-identities-store",
|
||||||
|
|||||||
@@ -3305,6 +3305,17 @@ async function main(options = {}) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get('/api/git/discover-credentials', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { discoverGitCredentials } = await import('./lib/git-credentials.js');
|
||||||
|
const credentials = discoverGitCredentials();
|
||||||
|
res.json(credentials);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to discover git credentials:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to discover git credentials' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.get('/api/git/check', async (req, res) => {
|
app.get('/api/git/check', async (req, res) => {
|
||||||
const { isGitRepository } = await getGitLibraries();
|
const { isGitRepository } = await getGitLibraries();
|
||||||
try {
|
try {
|
||||||
@@ -3321,6 +3332,23 @@ async function main(options = {}) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get('/api/git/remote-url', async (req, res) => {
|
||||||
|
const { getRemoteUrl } = await getGitLibraries();
|
||||||
|
try {
|
||||||
|
const directory = req.query.directory;
|
||||||
|
if (!directory) {
|
||||||
|
return res.status(400).json({ error: 'directory parameter is required' });
|
||||||
|
}
|
||||||
|
const remote = req.query.remote || 'origin';
|
||||||
|
|
||||||
|
const url = await getRemoteUrl(directory, remote);
|
||||||
|
res.json({ url });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to get remote url:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to get remote url' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.get('/api/git/current-identity', async (req, res) => {
|
app.get('/api/git/current-identity', async (req, res) => {
|
||||||
const { getCurrentIdentity } = await getGitLibraries();
|
const { getCurrentIdentity } = await getGitLibraries();
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
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 = [];
|
||||||
|
|
||||||
|
if (!fs.existsSync(GIT_CREDENTIALS_PATH)) {
|
||||||
|
return credentials;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const content = fs.readFileSync(GIT_CREDENTIALS_PATH, 'utf8');
|
||||||
|
const lines = content.split('\n').filter(line => line.trim());
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
try {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to read .git-credentials:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const content = fs.readFileSync(GIT_CREDENTIALS_PATH, 'utf8');
|
||||||
|
const lines = content.split('\n').filter(line => line.trim());
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
try {
|
||||||
|
const url = new URL(line.trim());
|
||||||
|
const hostname = url.hostname;
|
||||||
|
const pathname = url.pathname && url.pathname !== '/' ? url.pathname : '';
|
||||||
|
const credHost = hostname + pathname;
|
||||||
|
|
||||||
|
if (credHost === host) {
|
||||||
|
return {
|
||||||
|
username: url.username || '',
|
||||||
|
token: url.password || ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to read .git-credentials for host lookup:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -66,7 +66,9 @@ export function createProfile(profileData) {
|
|||||||
name: profileData.name || profileData.userName,
|
name: profileData.name || profileData.userName,
|
||||||
userName: profileData.userName,
|
userName: profileData.userName,
|
||||||
userEmail: profileData.userEmail,
|
userEmail: profileData.userEmail,
|
||||||
|
authType: profileData.authType || 'ssh',
|
||||||
sshKey: profileData.sshKey || null,
|
sshKey: profileData.sshKey || null,
|
||||||
|
host: profileData.host || null,
|
||||||
color: profileData.color || 'keyword',
|
color: profileData.color || 'keyword',
|
||||||
icon: profileData.icon || 'branch'
|
icon: profileData.icon || 'branch'
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -117,6 +117,17 @@ export async function getGlobalIdentity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getRemoteUrl(directory, remoteName = 'origin') {
|
||||||
|
const git = simpleGit(normalizeDirectoryPath(directory));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = await git.remote(['get-url', remoteName]);
|
||||||
|
return url?.trim() || null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function getCurrentIdentity(directory) {
|
export async function getCurrentIdentity(directory) {
|
||||||
const git = simpleGit(normalizeDirectoryPath(directory));
|
const git = simpleGit(normalizeDirectoryPath(directory));
|
||||||
|
|
||||||
@@ -157,13 +168,28 @@ export async function setLocalIdentity(directory, profile) {
|
|||||||
await git.addConfig('user.name', profile.userName, false, 'local');
|
await git.addConfig('user.name', profile.userName, false, 'local');
|
||||||
await git.addConfig('user.email', profile.userEmail, false, 'local');
|
await git.addConfig('user.email', profile.userEmail, false, 'local');
|
||||||
|
|
||||||
if (profile.sshKey) {
|
const authType = profile.authType || 'ssh';
|
||||||
|
|
||||||
|
if (authType === 'ssh' && profile.sshKey) {
|
||||||
await git.addConfig(
|
await git.addConfig(
|
||||||
'core.sshCommand',
|
'core.sshCommand',
|
||||||
`ssh -i ${profile.sshKey}`,
|
`ssh -i ${profile.sshKey}`,
|
||||||
false,
|
false,
|
||||||
'local'
|
'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(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
Reference in New Issue
Block a user