diff --git a/packages/desktop/src-tauri/Cargo.lock b/packages/desktop/src-tauri/Cargo.lock index 68d8f599..ff31e934 100644 --- a/packages/desktop/src-tauri/Cargo.lock +++ b/packages/desktop/src-tauri/Cargo.lock @@ -2977,7 +2977,7 @@ dependencies = [ [[package]] name = "openchamber-desktop" -version = "1.4.6" +version = "1.4.7" dependencies = [ "anyhow", "axum", @@ -3013,6 +3013,7 @@ dependencies = [ "tokio", "tokio-util", "tower-http 0.5.2", + "url", "urlencoding", "uuid", "window-vibrancy 0.7.1", diff --git a/packages/desktop/src-tauri/Cargo.toml b/packages/desktop/src-tauri/Cargo.toml index c6d5fa50..f7a387c5 100644 --- a/packages/desktop/src-tauri/Cargo.toml +++ b/packages/desktop/src-tauri/Cargo.toml @@ -45,6 +45,7 @@ tauri-plugin-log = "2.7.1" tauri-plugin-shell = "2.3.3" tokio = { version = "1.38", features = ["macros", "rt-multi-thread", "process", "signal", "sync", "time", "fs"] } tower-http = { version = "0.5.2", features = ["cors"] } +url = "2.5" uuid = { version = "1.18.1", features = ["v4"] } tokio-util = { version = "0.7", features = ["io"] } tauri-plugin-notification = "2.3.3" diff --git a/packages/desktop/src-tauri/src/commands/git.rs b/packages/desktop/src-tauri/src/commands/git.rs index 186a324f..ddfe528e 100644 --- a/packages/desktop/src-tauri/src/commands/git.rs +++ b/packages/desktop/src-tauri/src/commands/git.rs @@ -187,11 +187,20 @@ pub struct GitIdentityProfile { pub name: String, pub user_name: String, pub user_email: String, + pub auth_type: Option, pub ssh_key: Option, + pub host: Option, pub color: Option, pub icon: Option, } +#[derive(Serialize, Deserialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct DiscoveredGitCredential { + pub host: String, + pub username: String, +} + #[derive(Serialize, Deserialize, Debug, Clone)] #[serde(rename_all = "camelCase")] pub struct GitIdentityProfilesWrapper { @@ -1984,6 +1993,22 @@ pub async fn delete_git_identity(id: String) -> Result<(), String> { Ok(()) } +#[tauri::command] +pub async fn get_remote_url( + directory: String, + remote: Option, + state: State<'_, DesktopRuntime>, +) -> Result, 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] pub async fn get_current_git_identity( directory: String, @@ -2004,6 +2029,43 @@ pub async fn get_current_git_identity( }) } +#[tauri::command] +pub async fn get_global_git_identity() -> Result { + // 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] pub async fn set_git_identity( directory: String, @@ -2033,11 +2095,25 @@ pub async fn set_git_identity( .await .map_err(|e| e.to_string())?; - if let Some(key) = &profile.ssh_key { - let cmd = format!("ssh -i {}", key); - run_git(&["config", "--local", "core.sshCommand", &cmd], &root) + let auth_type = profile.auth_type.as_deref().unwrap_or("ssh"); + + 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 .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; } @@ -2045,6 +2121,53 @@ pub async fn set_git_identity( Ok(profile) } +#[tauri::command] +pub async fn discover_git_credentials() -> Result, 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] pub async fn generate_commit_message( directory: String, diff --git a/packages/desktop/src-tauri/src/main.rs b/packages/desktop/src-tauri/src/main.rs index 8ad6a03d..01b608f2 100644 --- a/packages/desktop/src-tauri/src/main.rs +++ b/packages/desktop/src-tauri/src/main.rs @@ -32,11 +32,11 @@ use commands::files::{create_directory, exec_commands, list_directory, read_file use commands::git::{ 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, - 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_git_identities, get_git_log, get_git_status, git_fetch, git_pull, git_push, - is_linked_worktree, list_git_worktrees, remove_git_worktree, revert_git_file, set_git_identity, - update_git_identity, + get_git_identities, get_git_log, get_git_status, get_global_git_identity, get_remote_url, + git_fetch, git_pull, git_push, is_linked_worktree, list_git_worktrees, remove_git_worktree, + revert_git_file, set_git_identity, update_git_identity, }; use commands::logs::fetch_desktop_logs; @@ -871,7 +871,10 @@ fn main() { update_git_identity, delete_git_identity, get_current_git_identity, + get_global_git_identity, + get_remote_url, set_git_identity, + discover_git_credentials, generate_commit_message, create_terminal_session, send_terminal_input, diff --git a/packages/desktop/src/api/git.ts b/packages/desktop/src/api/git.ts index 424c2c94..19fdd53b 100644 --- a/packages/desktop/src/api/git.ts +++ b/packages/desktop/src/api/git.ts @@ -21,7 +21,8 @@ import type { GitLogResponse, GitCommitFilesResponse, GitIdentitySummary, - GitIdentityProfile + GitIdentityProfile, + DiscoveredGitCredential } from '@openchamber/ui/lib/api/types'; async function safeGitInvoke(command: string, args?: Record): Promise { @@ -237,4 +238,24 @@ export const createDesktopGitAPI = (): GitAPI => ({ async deleteGitIdentity(id: string): Promise { return safeGitInvoke('delete_git_identity', { id }); }, + + async discoverGitCredentials(): Promise { + return safeGitInvoke('discover_git_credentials'); + }, + + async getGlobalGitIdentity(): Promise { + try { + return await safeGitInvoke('get_global_git_identity'); + } catch { + return null; + } + }, + + async getRemoteUrl(directory: string, remote?: string): Promise { + try { + return await safeGitInvoke('get_remote_url', { directory, remote }); + } catch { + return null; + } + }, }); diff --git a/packages/ui/src/components/sections/git-identities/GitIdentitiesPage.tsx b/packages/ui/src/components/sections/git-identities/GitIdentitiesPage.tsx index 9c8e43b9..12ed761a 100644 --- a/packages/ui/src/components/sections/git-identities/GitIdentitiesPage.tsx +++ b/packages/ui/src/components/sections/git-identities/GitIdentitiesPage.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { toast } from 'sonner'; -import { useGitIdentitiesStore, type GitIdentityProfile } from '@/stores/useGitIdentitiesStore'; +import { useGitIdentitiesStore, type GitIdentityProfile, type GitIdentityAuthType } from '@/stores/useGitIdentitiesStore'; import { RiUser3Line, RiSaveLine, @@ -12,7 +12,9 @@ import { RiHomeLine, RiGraduationCapLine, RiCodeLine, - RiInformationLine + RiInformationLine, + RiKeyLine, + RiLock2Line } from '@remixicon/react'; import { cn } from '@/lib/utils'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; @@ -43,40 +45,67 @@ export const GitIdentitiesPage: React.FC = () => { deleteProfile, } = 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(() => - selectedProfileId && selectedProfileId !== 'new' ? getProfileById(selectedProfileId) : null, - [selectedProfileId, getProfileById] + selectedProfileId && selectedProfileId !== 'new' && !importData ? getProfileById(selectedProfileId) : null, + [selectedProfileId, getProfileById, importData] ); - const isNewProfile = selectedProfileId === 'new'; + const isNewProfile = selectedProfileId === 'new' || importData !== null; const isGlobalProfile = selectedProfileId === 'global'; const [name, setName] = React.useState(''); const [userName, setUserName] = React.useState(''); const [userEmail, setUserEmail] = React.useState(''); + const [authType, setAuthType] = React.useState('ssh'); const [sshKey, setSshKey] = React.useState(''); + const [host, setHost] = React.useState(''); const [color, setColor] = React.useState('keyword'); const [icon, setIcon] = React.useState('branch'); const [isSaving, setIsSaving] = React.useState(false); 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(''); setUserName(''); setUserEmail(''); + setAuthType('ssh'); setSshKey(''); + setHost(''); setColor('keyword'); setIcon('branch'); } else if (selectedProfile) { - setName(selectedProfile.name); setUserName(selectedProfile.userName); setUserEmail(selectedProfile.userEmail); + setAuthType(selectedProfile.authType || 'ssh'); setSshKey(selectedProfile.sshKey || ''); + setHost(selectedProfile.host || ''); setColor(selectedProfile.color || 'keyword'); setIcon(selectedProfile.icon || 'branch'); } - }, [selectedProfile, isNewProfile, selectedProfileId]); + }, [selectedProfile, isNewProfile, selectedProfileId, importData]); const handleSave = async () => { if (!userName.trim() || !userEmail.trim()) { @@ -84,6 +113,11 @@ export const GitIdentitiesPage: React.FC = () => { return; } + if (authType === 'token' && !host.trim()) { + toast.error('Host is required for token-based authentication'); + return; + } + setIsSaving(true); try { @@ -91,7 +125,9 @@ export const GitIdentitiesPage: React.FC = () => { name: name.trim() || userName.trim(), userName: userName.trim(), userEmail: userEmail.trim(), - sshKey: sshKey.trim() || null, + authType, + sshKey: authType === 'ssh' ? (sshKey.trim() || null) : null, + host: authType === 'token' ? (host.trim() || null) : null, color, icon, }; @@ -161,10 +197,12 @@ export const GitIdentitiesPage: React.FC = () => { {/* Header */}

- {isNewProfile ? 'New Git Profile' : isGlobalProfile ? 'Global Identity' : name || 'Edit Profile'} + {importData ? 'Import Credential' : isNewProfile ? 'New Git Profile' : isGlobalProfile ? 'Global Identity' : name || 'Edit Profile'}

- {isNewProfile + {importData + ? `Import token credential for ${importData.host} - please fill in your email address` + : isNewProfile ? 'Create a new Git identity profile for your repositories' : isGlobalProfile ? 'System-wide Git identity from global configuration (read-only)' @@ -315,6 +353,54 @@ export const GitIdentitiesPage: React.FC = () => {

+ {/* Auth Type Selector */} + {!isGlobalProfile && ( +
+ +
+ + +
+
+ )} + + {/* SSH Key Path - only for SSH auth type */} + {authType === 'ssh' && (
- setSshKey(e.target.value)} - placeholder="/Users/username/.ssh/id_rsa" - readOnly={isGlobalProfile} - disabled={isGlobalProfile} - /> -

- Path to SSH private key for authentication (optional) -

-
+ setSshKey(e.target.value)} + placeholder="/Users/username/.ssh/id_rsa" + readOnly={isGlobalProfile} + disabled={isGlobalProfile} + /> +

+ Path to SSH private key for authentication (optional) +

+ + )} + + {/* Host - only for Token auth type */} + {authType === 'token' && !isGlobalProfile && ( +
+ + setHost(e.target.value)} + placeholder="github.com" + required + /> +

+ Git host for token authentication (from ~/.git-credentials) +

+
+ )} {} {!isGlobalProfile && ( diff --git a/packages/ui/src/components/sections/git-identities/GitIdentitiesSidebar.tsx b/packages/ui/src/components/sections/git-identities/GitIdentitiesSidebar.tsx index bc031457..be4059a2 100644 --- a/packages/ui/src/components/sections/git-identities/GitIdentitiesSidebar.tsx +++ b/packages/ui/src/components/sections/git-identities/GitIdentitiesSidebar.tsx @@ -17,6 +17,7 @@ import { RiGraduationCapLine, RiCodeLine, RiHeartLine, + RiDownloadLine, } from '@remixicon/react'; import { useGitIdentitiesStore } from '@/stores/useGitIdentitiesStore'; import { useUIStore } from '@/stores/useUIStore'; @@ -24,7 +25,7 @@ import { useDeviceInfo } from '@/lib/device'; import { isVSCodeRuntime } from '@/lib/desktop'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { cn } from '@/lib/utils'; -import type { GitIdentityProfile } from '@/stores/useGitIdentitiesStore'; +import type { GitIdentityProfile, DiscoveredGitCredential } from '@/stores/useGitIdentitiesStore'; const ICON_MAP: Record> = { branch: RiGitBranchLine, @@ -56,6 +57,8 @@ export const GitIdentitiesSidebar: React.FC = ({ onIt deleteProfile, loadProfiles, loadGlobalIdentity, + loadDiscoveredCredentials, + getUnimportedCredentials, } = useGitIdentitiesStore(); const { setSidebarOpen } = useUIStore(); @@ -73,10 +76,23 @@ export const GitIdentitiesSidebar: React.FC = ({ onIt setIsDesktopRuntime(typeof window.opencodeDesktop !== 'undefined'); }, []); + const unimportedCredentials = getUnimportedCredentials(); + React.useEffect(() => { loadProfiles(); 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 ? 'bg-transparent' @@ -150,7 +166,7 @@ export const GitIdentitiesSidebar: React.FC = ({ onIt )} - {profiles.length === 0 && !globalIdentity ? ( + {profiles.length === 0 && !globalIdentity && unimportedCredentials.length === 0 ? (

No profiles configured

@@ -175,6 +191,25 @@ export const GitIdentitiesSidebar: React.FC = ({ onIt ))} )} + + {/* Discovered Credentials Section */} + {unimportedCredentials.length > 0 && ( + <> +
+ Discovered Credentials +
+

+ Found in ~/.git-credentials +

+ {unimportedCredentials.map((cred) => ( + handleImportCredential(cred)} + /> + ))} + + )}
); @@ -197,6 +232,7 @@ const ProfileListItem: React.FC = ({ }) => { const IconComponent = ICON_MAP[profile.icon || 'branch'] || RiGitBranchLine; const iconColor = COLOR_MAP[profile.color || '']; + const authType = profile.authType || 'ssh'; return (
= ({ 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} > -
+
- + {profile.name} + + {authType} +
- {profile.userEmail} + {authType === 'token' && profile.host ? profile.host : profile.userEmail}
@@ -255,3 +294,57 @@ const ProfileListItem: React.FC = ({
); }; + +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 = ({ + credential, + onImport, +}) => { + const displayName = getCredentialDisplayName(credential.host); + const isRepoSpecific = credential.host.includes('/'); + + return ( +
+
+
+
+ + {displayName} + +
+
+ {isRepoSpecific ? credential.host : credential.username} +
+
+ +
+
+ ); +}; diff --git a/packages/ui/src/components/views/GitView.tsx b/packages/ui/src/components/views/GitView.tsx index 8a70b2b0..5cdf2165 100644 --- a/packages/ui/src/components/views/GitView.tsx +++ b/packages/ui/src/components/views/GitView.tsx @@ -108,6 +108,7 @@ export const GitView: React.FC = () => { const [expandedCommitHashes, setExpandedCommitHashes] = React.useState>(new Set()); const [commitFilesMap, setCommitFilesMap] = React.useState>(new Map()); const [loadingCommitHashes, setLoadingCommitHashes] = React.useState>(new Set()); + const [remoteUrl, setRemoteUrl] = React.useState(null); const handleCopyCommitHash = React.useCallback((hash: string) => { navigator.clipboard @@ -188,6 +189,15 @@ export const GitView: React.FC = () => { 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(() => { if (currentDirectory) { setActiveDirectory(currentDirectory); @@ -495,11 +505,55 @@ 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 + } + } + 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()); - }, [profiles, globalIdentity]); + }, [profiles, globalIdentity, remoteUrl]); const activeIdentityProfile = React.useMemo((): GitIdentityProfile | null => { if (currentIdentity?.userName && currentIdentity?.userEmail) { diff --git a/packages/ui/src/lib/api/types.ts b/packages/ui/src/lib/api/types.ts index c6edd04a..528a7073 100644 --- a/packages/ui/src/lib/api/types.ts +++ b/packages/ui/src/lib/api/types.ts @@ -168,16 +168,25 @@ export interface GitPullResult { deletions: number; } +export type GitIdentityAuthType = 'ssh' | 'token'; + export interface GitIdentityProfile { id: string; name: string; userName: string; userEmail: string; + authType?: GitIdentityAuthType; sshKey?: string | null; + host?: string | null; color?: string | null; icon?: string | null; } +export interface DiscoveredGitCredential { + host: string; + username: string; +} + export interface GitIdentitySummary { userName: string | null; userEmail: string | null; @@ -290,6 +299,9 @@ export interface GitAPI { createGitIdentity(profile: GitIdentityProfile): Promise; updateGitIdentity(id: string, updates: GitIdentityProfile): Promise; deleteGitIdentity(id: string): Promise; + discoverGitCredentials?(): Promise; + getGlobalGitIdentity?(): Promise; + getRemoteUrl?(directory: string, remote?: string): Promise; } export interface FileListEntry { diff --git a/packages/ui/src/lib/gitApi.ts b/packages/ui/src/lib/gitApi.ts index 3196924a..c886056e 100644 --- a/packages/ui/src/lib/gitApi.ts +++ b/packages/ui/src/lib/gitApi.ts @@ -13,6 +13,7 @@ export type { GitPushResult, GitPullResult, GitIdentityProfile, + GitIdentityAuthType, GitIdentitySummary, GitLogEntry, GitLogResponse, @@ -21,6 +22,7 @@ export type { GitRemoveWorktreePayload, GitDeleteBranchPayload, GitDeleteRemoteBranchPayload, + DiscoveredGitCredential, } from './api/types'; declare global { @@ -245,3 +247,21 @@ export async function setGitIdentity( if (runtime) return runtime.setGitIdentity(directory, profileId); return gitHttp.setGitIdentity(directory, profileId); } + +export async function discoverGitCredentials(): Promise { + const runtime = getRuntimeGit(); + if (runtime?.discoverGitCredentials) return runtime.discoverGitCredentials(); + return gitHttp.discoverGitCredentials(); +} + +export async function getGlobalGitIdentity(): Promise { + const runtime = getRuntimeGit(); + if (runtime?.getGlobalGitIdentity) return runtime.getGlobalGitIdentity(); + return gitHttp.getGlobalGitIdentity(); +} + +export async function getRemoteUrl(directory: string, remote?: string): Promise { + const runtime = getRuntimeGit(); + if (runtime?.getRemoteUrl) return runtime.getRemoteUrl(directory, remote); + return gitHttp.getRemoteUrl(directory, remote); +} diff --git a/packages/ui/src/lib/gitApiHttp.ts b/packages/ui/src/lib/gitApiHttp.ts index cfcf0678..f0c3f9de 100644 --- a/packages/ui/src/lib/gitApiHttp.ts +++ b/packages/ui/src/lib/gitApiHttp.ts @@ -22,6 +22,7 @@ import type { GitCommitFilesResponse, GitIdentityProfile, GitIdentitySummary, + DiscoveredGitCredential, } from './api/types'; declare global { @@ -515,6 +516,22 @@ export async function getCurrentGitIdentity(directory: string): Promise { + 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( directory: string, profileId: string @@ -530,3 +547,23 @@ export async function setGitIdentity( } return response.json(); } + +export async function discoverGitCredentials(): Promise { + 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 { + 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; +} diff --git a/packages/ui/src/stores/useGitIdentitiesStore.ts b/packages/ui/src/stores/useGitIdentitiesStore.ts index ffbd529c..32b30541 100644 --- a/packages/ui/src/stores/useGitIdentitiesStore.ts +++ b/packages/ui/src/stores/useGitIdentitiesStore.ts @@ -7,33 +7,46 @@ import { createGitIdentity, updateGitIdentity, deleteGitIdentity, - getCurrentGitIdentity + discoverGitCredentials, + getGlobalGitIdentity } from "@/lib/gitApi"; +export type GitIdentityAuthType = 'ssh' | 'token'; + export interface GitIdentityProfile { id: string; name: string; userName: string; userEmail: string; + authType?: GitIdentityAuthType; sshKey?: string | null; + host?: string | null; color?: string | null; icon?: string | null; } +export interface DiscoveredGitCredential { + host: string; + username: string; +} + interface GitIdentitiesStore { selectedProfileId: string | null; profiles: GitIdentityProfile[]; globalIdentity: GitIdentityProfile | null; + discoveredCredentials: DiscoveredGitCredential[]; isLoading: boolean; setSelectedProfile: (id: string | null) => void; loadProfiles: () => Promise; loadGlobalIdentity: () => Promise; + loadDiscoveredCredentials: () => Promise; createProfile: (profile: Omit & { id?: string }) => Promise; updateProfile: (id: string, updates: Partial) => Promise; deleteProfile: (id: string) => Promise; getProfileById: (id: string) => GitIdentityProfile | undefined; + getUnimportedCredentials: () => DiscoveredGitCredential[]; } declare global { @@ -50,6 +63,7 @@ export const useGitIdentitiesStore = create()( selectedProfileId: null, profiles: [], globalIdentity: null, + discoveredCredentials: [], isLoading: false, setSelectedProfile: (id: string | null) => { @@ -73,7 +87,7 @@ export const useGitIdentitiesStore = create()( loadGlobalIdentity: async () => { try { - const data = await getCurrentGitIdentity(''); + const data = await getGlobalGitIdentity(); if (data && data.userName && data.userEmail) { const globalProfile: GitIdentityProfile = { @@ -81,6 +95,7 @@ export const useGitIdentitiesStore = create()( name: 'Global Identity', userName: data.userName, userEmail: data.userEmail, + authType: data.sshCommand ? 'ssh' : undefined, sshKey: data.sshCommand ? data.sshCommand.replace('ssh -i ', '') : null, color: 'info', icon: 'house' @@ -98,6 +113,18 @@ export const useGitIdentitiesStore = create()( } }, + 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) => { try { @@ -160,6 +187,16 @@ export const useGitIdentitiesStore = create()( } 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", diff --git a/packages/web/server/index.js b/packages/web/server/index.js index d4147fdb..69a8cbbf 100644 --- a/packages/web/server/index.js +++ b/packages/web/server/index.js @@ -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) => { const { isGitRepository } = await getGitLibraries(); 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) => { const { getCurrentIdentity } = await getGitLibraries(); try { diff --git a/packages/web/server/lib/git-credentials.js b/packages/web/server/lib/git-credentials.js new file mode 100644 index 00000000..10a2cc11 --- /dev/null +++ b/packages/web/server/lib/git-credentials.js @@ -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; +} diff --git a/packages/web/server/lib/git-identity-storage.js b/packages/web/server/lib/git-identity-storage.js index 89448434..b2b98ae5 100644 --- a/packages/web/server/lib/git-identity-storage.js +++ b/packages/web/server/lib/git-identity-storage.js @@ -66,7 +66,9 @@ export function createProfile(profileData) { name: profileData.name || profileData.userName, userName: profileData.userName, userEmail: profileData.userEmail, + authType: profileData.authType || 'ssh', sshKey: profileData.sshKey || null, + host: profileData.host || null, color: profileData.color || 'keyword', icon: profileData.icon || 'branch' }; diff --git a/packages/web/server/lib/git-service.js b/packages/web/server/lib/git-service.js index 9877b7cf..8b7da662 100644 --- a/packages/web/server/lib/git-service.js +++ b/packages/web/server/lib/git-service.js @@ -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) { 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.email', profile.userEmail, false, 'local'); - if (profile.sshKey) { + const authType = profile.authType || 'ssh'; + + if (authType === 'ssh' && profile.sshKey) { await git.addConfig( 'core.sshCommand', `ssh -i ${profile.sshKey}`, 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(() => {}); } return true;