From e0279e59b0502c94b78be32be8dab98326510f39 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Wed, 14 Jan 2026 22:34:13 +0200 Subject: [PATCH] feat: implement default Git identity management and local identity check --- packages/desktop/src-tauri/Cargo.lock | 2 +- .../desktop/src-tauri/src/commands/git.rs | 21 ++++++ .../src-tauri/src/commands/settings.rs | 8 +++ packages/desktop/src-tauri/src/main.rs | 3 +- packages/desktop/src/api/git.ts | 8 +++ .../git-identities/GitIdentitiesSidebar.tsx | 65 +++++++++++++---- .../skills/catalog/AddCatalogDialog.tsx | 14 +++- .../skills/catalog/InstallFromRepoDialog.tsx | 24 ++++++- packages/ui/src/components/views/GitView.tsx | 63 ++++++++++++++-- packages/ui/src/lib/api/types.ts | 1 + packages/ui/src/lib/desktop.ts | 1 + packages/ui/src/lib/gitApi.ts | 6 ++ packages/ui/src/lib/gitApiHttp.ts | 12 ++++ .../ui/src/stores/useGitIdentitiesStore.ts | 72 +++++++++++++++++++ packages/vscode/src/bridge.ts | 2 +- packages/web/server/index.js | 20 ++++++ packages/web/server/lib/git-service.js | 12 ++++ packages/web/src/api/git.ts | 1 + 18 files changed, 310 insertions(+), 25 deletions(-) diff --git a/packages/desktop/src-tauri/Cargo.lock b/packages/desktop/src-tauri/Cargo.lock index cc718b6e..fcc6d151 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.8" +version = "1.4.9" dependencies = [ "anyhow", "axum", diff --git a/packages/desktop/src-tauri/src/commands/git.rs b/packages/desktop/src-tauri/src/commands/git.rs index 4fab1037..6a9ee58b 100644 --- a/packages/desktop/src-tauri/src/commands/git.rs +++ b/packages/desktop/src-tauri/src/commands/git.rs @@ -2029,6 +2029,27 @@ pub async fn get_current_git_identity( }) } +#[tauri::command] +pub async fn has_local_identity( + directory: String, + state: State<'_, DesktopRuntime>, +) -> Result { + let root = validate_git_path(&directory, state.settings()) + .await + .map_err(|e| e.to_string())?; + + let user_name = run_git(&["config", "--local", "--get", "user.name"], &root) + .await + .ok() + .filter(|s| !s.is_empty()); + let user_email = run_git(&["config", "--local", "--get", "user.email"], &root) + .await + .ok() + .filter(|s| !s.is_empty()); + + Ok(user_name.is_some() || user_email.is_some()) +} + #[tauri::command] pub async fn get_global_git_identity() -> Result { let user_name = tokio::process::Command::new("git") diff --git a/packages/desktop/src-tauri/src/commands/settings.rs b/packages/desktop/src-tauri/src/commands/settings.rs index 85213347..bed01079 100644 --- a/packages/desktop/src-tauri/src/commands/settings.rs +++ b/packages/desktop/src-tauri/src/commands/settings.rs @@ -257,6 +257,14 @@ fn sanitize_settings_update(payload: &Value) -> Value { result_obj.insert("defaultAgent".to_string(), json!(trimmed)); } } + if let Some(Value::String(s)) = obj.get("defaultGitIdentityId") { + let trimmed = s.trim(); + if trimmed.is_empty() { + result_obj.insert("defaultGitIdentityId".to_string(), Value::Null); + } else { + result_obj.insert("defaultGitIdentityId".to_string(), json!(trimmed)); + } + } if let Some(Value::String(s)) = obj.get("commitMessageModel") { let trimmed = s.trim(); if trimmed.is_empty() { diff --git a/packages/desktop/src-tauri/src/main.rs b/packages/desktop/src-tauri/src/main.rs index 54b212d0..8b94ec64 100644 --- a/packages/desktop/src-tauri/src/main.rs +++ b/packages/desktop/src-tauri/src/main.rs @@ -33,7 +33,7 @@ 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, 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, has_local_identity, get_git_branches, get_git_diff, get_git_file_diff, 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, @@ -871,6 +871,7 @@ fn main() { update_git_identity, delete_git_identity, get_current_git_identity, + has_local_identity, get_global_git_identity, get_remote_url, set_git_identity, diff --git a/packages/desktop/src/api/git.ts b/packages/desktop/src/api/git.ts index 19fdd53b..90293e32 100644 --- a/packages/desktop/src/api/git.ts +++ b/packages/desktop/src/api/git.ts @@ -218,6 +218,14 @@ export const createDesktopGitAPI = (): GitAPI => ({ } }, + async hasLocalIdentity(directory: string): Promise { + try { + return await safeGitInvoke('has_local_identity', { directory }); + } catch { + return false; + } + }, + async setGitIdentity(directory: string, profileId: string): Promise<{ success: boolean; profile: GitIdentityProfile }> { const profile = await safeGitInvoke('set_git_identity', { directory, profileId }); return { success: true, profile }; diff --git a/packages/ui/src/components/sections/git-identities/GitIdentitiesSidebar.tsx b/packages/ui/src/components/sections/git-identities/GitIdentitiesSidebar.tsx index 99612ef3..23ea97b1 100644 --- a/packages/ui/src/components/sections/git-identities/GitIdentitiesSidebar.tsx +++ b/packages/ui/src/components/sections/git-identities/GitIdentitiesSidebar.tsx @@ -51,6 +51,7 @@ interface GitIdentitiesSidebarProps { export const GitIdentitiesSidebar: React.FC = ({ onItemSelect }) => { const { selectedProfileId, + defaultGitIdentityId, profiles, globalIdentity, setSelectedProfile, @@ -58,6 +59,8 @@ export const GitIdentitiesSidebar: React.FC = ({ onIt loadProfiles, loadGlobalIdentity, loadDiscoveredCredentials, + loadDefaultGitIdentityId, + setDefaultGitIdentityId, getUnimportedCredentials, } = useGitIdentitiesStore(); @@ -82,7 +85,8 @@ export const GitIdentitiesSidebar: React.FC = ({ onIt loadProfiles(); loadGlobalIdentity(); loadDiscoveredCredentials(); - }, [loadProfiles, loadGlobalIdentity, loadDiscoveredCredentials]); + loadDefaultGitIdentityId(); + }, [loadProfiles, loadGlobalIdentity, loadDiscoveredCredentials, loadDefaultGitIdentityId]); const handleImportCredential = (credential: DiscoveredGitCredential) => { // Set a special "import" selection that carries the credential data @@ -119,6 +123,16 @@ export const GitIdentitiesSidebar: React.FC = ({ onIt } }; + const handleToggleDefault = async (profileId: string) => { + const next = defaultGitIdentityId === profileId ? null : profileId; + const ok = await setDefaultGitIdentityId(next); + if (!ok) { + toast.error('Failed to update default identity'); + return; + } + toast.success(next ? 'Default identity updated' : 'Default identity unset'); + }; + return (
@@ -146,6 +160,7 @@ export const GitIdentitiesSidebar: React.FC = ({ onIt { setSelectedProfile('global'); onItemSelect?.(); @@ -153,6 +168,7 @@ export const GitIdentitiesSidebar: React.FC = ({ onIt setSidebarOpen(false); } }} + onToggleDefault={() => handleToggleDefault('global')} onDelete={undefined} isReadOnly /> @@ -179,6 +195,7 @@ export const GitIdentitiesSidebar: React.FC = ({ onIt key={profile.id} profile={profile} isSelected={selectedProfileId === profile.id} + isDefault={defaultGitIdentityId === profile.id} onSelect={() => { setSelectedProfile(profile.id); onItemSelect?.(); @@ -186,6 +203,7 @@ export const GitIdentitiesSidebar: React.FC = ({ onIt setSidebarOpen(false); } }} + onToggleDefault={() => handleToggleDefault(profile.id)} onDelete={() => handleDeleteProfile(profile)} /> ))} @@ -218,7 +236,9 @@ export const GitIdentitiesSidebar: React.FC = ({ onIt interface ProfileListItemProps { profile: GitIdentityProfile; isSelected: boolean; + isDefault?: boolean; onSelect: () => void; + onToggleDefault?: () => void | Promise; onDelete?: () => void; isReadOnly?: boolean; } @@ -226,7 +246,9 @@ interface ProfileListItemProps { const ProfileListItem: React.FC = ({ profile, isSelected, + isDefault = false, onSelect, + onToggleDefault, onDelete, isReadOnly = false, }) => { @@ -258,6 +280,11 @@ const ProfileListItem: React.FC = ({ {authType} + {isDefault && ( + + default + + )}
@@ -265,7 +292,7 @@ const ProfileListItem: React.FC = ({
- {!isReadOnly && onDelete && ( + {(onToggleDefault || (!isReadOnly && onDelete)) && ( - - { - e.stopPropagation(); - onDelete(); - }} - className="text-destructive focus:text-destructive" - > - - Delete - + + {onToggleDefault && ( + { + e.stopPropagation(); + void onToggleDefault(); + }} + > + {isDefault ? 'Unset default' : 'Set as default'} + + )} + {!isReadOnly && onDelete && ( + { + e.stopPropagation(); + onDelete(); + }} + className="text-destructive focus:text-destructive" + > + + Delete + + )} )} diff --git a/packages/ui/src/components/sections/skills/catalog/AddCatalogDialog.tsx b/packages/ui/src/components/sections/skills/catalog/AddCatalogDialog.tsx index d0354994..b32d005d 100644 --- a/packages/ui/src/components/sections/skills/catalog/AddCatalogDialog.tsx +++ b/packages/ui/src/components/sections/skills/catalog/AddCatalogDialog.tsx @@ -25,6 +25,7 @@ import { getDesktopSettings, isDesktopRuntime, isVSCodeRuntime } from '@/lib/des import { updateDesktopSettings } from '@/lib/persistence'; import type { DesktopSettings, SkillCatalogConfig } from '@/lib/desktop'; import { useSkillsCatalogStore } from '@/stores/useSkillsCatalogStore'; +import { useGitIdentitiesStore } from '@/stores/useGitIdentitiesStore'; const generateCatalogId = () => `custom:${Date.now()}-${Math.random().toString(16).slice(2)}`; @@ -81,6 +82,8 @@ interface AddCatalogDialogProps { export const AddCatalogDialog: React.FC = ({ open, onOpenChange }) => { const { scanRepo, loadCatalog, isScanning } = useSkillsCatalogStore(); + const defaultGitIdentityId = useGitIdentitiesStore((s) => s.defaultGitIdentityId); + const loadDefaultGitIdentityId = useGitIdentitiesStore((s) => s.loadDefaultGitIdentityId); const [label, setLabel] = React.useState(''); const [source, setSource] = React.useState(''); @@ -104,13 +107,14 @@ export const AddCatalogDialog: React.FC = ({ open, onOpen setScanOk(false); setIdentityOptions([]); setGitIdentityId(null); + void loadDefaultGitIdentityId(); void (async () => { const settings = await loadSettings(); const catalogs = Array.isArray(settings?.skillCatalogs) ? settings?.skillCatalogs : []; setExistingCatalogs(catalogs || []); })(); - }, [open]); + }, [open, loadDefaultGitIdentityId]); const isDuplicate = React.useMemo(() => { const normalizedSource = source.trim(); @@ -153,7 +157,13 @@ export const AddCatalogDialog: React.FC = ({ open, onOpen const ids = (result.error.identities || []) as IdentityOption[]; setIdentityOptions(ids); if (!gitIdentityId && ids.length > 0) { - setGitIdentityId(ids[0].id); + const preferred = + defaultGitIdentityId && + defaultGitIdentityId !== 'global' && + ids.some((i) => i.id === defaultGitIdentityId) + ? defaultGitIdentityId + : ids[0].id; + setGitIdentityId(preferred); } toast.error('Authentication required. Select a Git identity and scan again.'); return; diff --git a/packages/ui/src/components/sections/skills/catalog/InstallFromRepoDialog.tsx b/packages/ui/src/components/sections/skills/catalog/InstallFromRepoDialog.tsx index fcf6c815..29a34304 100644 --- a/packages/ui/src/components/sections/skills/catalog/InstallFromRepoDialog.tsx +++ b/packages/ui/src/components/sections/skills/catalog/InstallFromRepoDialog.tsx @@ -25,6 +25,7 @@ import { isVSCodeRuntime } from '@/lib/desktop'; import type { SkillsCatalogItem } from '@/lib/api/types'; import { useSkillsCatalogStore } from '@/stores/useSkillsCatalogStore'; import { useSkillsStore } from '@/stores/useSkillsStore'; +import { useGitIdentitiesStore } from '@/stores/useGitIdentitiesStore'; import { InstallConflictsDialog, type ConflictDecision, type SkillConflict } from './InstallConflictsDialog'; interface InstallFromRepoDialogProps { @@ -37,6 +38,8 @@ type IdentityOption = { id: string; name: string }; export const InstallFromRepoDialog: React.FC = ({ open, onOpenChange }) => { const { scanRepo, installSkills, isScanning, isInstalling } = useSkillsCatalogStore(); const installedSkills = useSkillsStore((s) => s.skills); + const defaultGitIdentityId = useGitIdentitiesStore((s) => s.defaultGitIdentityId); + const loadDefaultGitIdentityId = useGitIdentitiesStore((s) => s.loadDefaultGitIdentityId); const [source, setSource] = React.useState(''); const [subpath, setSubpath] = React.useState(''); @@ -69,10 +72,13 @@ export const InstallFromRepoDialog: React.FC = ({ op setSearch(''); setIdentities([]); setGitIdentityId(null); + void loadDefaultGitIdentityId(); + setConflictsOpen(false); + setConflicts([]); setBaseInstallRequest(null); - }, [open]); + }, [open, loadDefaultGitIdentityId]); const installedByName = React.useMemo(() => { const map = new Map(); @@ -127,7 +133,13 @@ export const InstallFromRepoDialog: React.FC = ({ op const ids = (result.error.identities || []) as IdentityOption[]; setIdentities(ids); if (!gitIdentityId && ids.length > 0) { - setGitIdentityId(ids[0].id); + const preferred = + defaultGitIdentityId && + defaultGitIdentityId !== 'global' && + ids.some((i) => i.id === defaultGitIdentityId) + ? defaultGitIdentityId + : ids[0].id; + setGitIdentityId(preferred); } toast.error('Authentication required. Select a Git identity and try scanning again.'); return; @@ -195,7 +207,13 @@ export const InstallFromRepoDialog: React.FC = ({ op const ids = (result.error.identities || []) as IdentityOption[]; setIdentities(ids); if (!gitIdentityId && ids.length > 0) { - setGitIdentityId(ids[0].id); + const preferred = + defaultGitIdentityId && + defaultGitIdentityId !== 'global' && + ids.some((i) => i.id === defaultGitIdentityId) + ? defaultGitIdentityId + : ids[0].id; + setGitIdentityId(preferred); } toast.error('Authentication required. Select a Git identity and try installing again.'); return; diff --git a/packages/ui/src/components/views/GitView.tsx b/packages/ui/src/components/views/GitView.tsx index 050eb90f..8e4c9f91 100644 --- a/packages/ui/src/components/views/GitView.tsx +++ b/packages/ui/src/components/views/GitView.tsx @@ -60,7 +60,7 @@ export const GitView: React.FC = () => { ? worktreeMap.get(currentSessionId) ?? undefined : undefined; - const { profiles, globalIdentity, loadProfiles, loadGlobalIdentity } = + const { profiles, globalIdentity, defaultGitIdentityId, loadProfiles, loadGlobalIdentity, loadDefaultGitIdentityId } = useGitIdentitiesStore(); const isGitRepo = useIsGitRepo(currentDirectory ?? null); @@ -95,6 +95,21 @@ export const GitView: React.FC = () => { const [isSettingIdentity, setIsSettingIdentity] = React.useState(false); const { triggerFireworks } = useFireworksCelebration(); + const autoAppliedDefaultRef = React.useRef>(new Map()); + const identityApplyCountRef = React.useRef(0); + + const beginIdentityApply = React.useCallback(() => { + identityApplyCountRef.current += 1; + setIsSettingIdentity(true); + }, []); + + const endIdentityApply = React.useCallback(() => { + identityApplyCountRef.current = Math.max(0, identityApplyCountRef.current - 1); + if (identityApplyCountRef.current === 0) { + setIsSettingIdentity(false); + } + }, []); + const [selectedPaths, setSelectedPaths] = React.useState>( () => new Set(initialSnapshot?.selectedPaths ?? []) ); @@ -187,7 +202,8 @@ export const GitView: React.FC = () => { React.useEffect(() => { loadProfiles(); loadGlobalIdentity(); - }, [loadProfiles, loadGlobalIdentity]); + loadDefaultGitIdentityId(); + }, [loadProfiles, loadGlobalIdentity, loadDefaultGitIdentityId]); React.useEffect(() => { if (!currentDirectory || !git?.getRemoteUrl) { @@ -238,6 +254,45 @@ export const GitView: React.FC = () => { await fetchIdentity(currentDirectory, git); }, [currentDirectory, git, fetchIdentity]); + React.useEffect(() => { + if (!currentDirectory) return; + if (!git?.hasLocalIdentity) return; + if (isGitRepo !== true) return; + + const defaultId = typeof defaultGitIdentityId === 'string' ? defaultGitIdentityId.trim() : ''; + if (!defaultId || defaultId === 'global') return; + + const previousAttempt = autoAppliedDefaultRef.current.get(currentDirectory); + if (previousAttempt === defaultId) return; + + let cancelled = false; + + const run = async () => { + try { + const hasLocal = await git.hasLocalIdentity!(currentDirectory); + if (cancelled) return; + if (hasLocal) return; + + beginIdentityApply(); + await git.setGitIdentity(currentDirectory, defaultId); + autoAppliedDefaultRef.current.set(currentDirectory, defaultId); + await refreshIdentity(); + } catch (error) { + console.warn('Failed to auto-apply default git identity:', error); + } finally { + if (!cancelled) { + endIdentityApply(); + } + } + }; + + void run(); + + return () => { + cancelled = true; + }; + }, [beginIdentityApply, currentDirectory, defaultGitIdentityId, endIdentityApply, git, isGitRepo, refreshIdentity]); + const changeEntries = React.useMemo(() => { if (!status) return []; const files = status.files ?? []; @@ -470,7 +525,7 @@ export const GitView: React.FC = () => { const handleApplyIdentity = async (profile: GitIdentityProfile) => { if (!currentDirectory) return; - setIsSettingIdentity(true); + beginIdentityApply(); try { await git.setGitIdentity(currentDirectory, profile.id); @@ -480,7 +535,7 @@ export const GitView: React.FC = () => { const message = err instanceof Error ? err.message : 'Failed to apply git identity'; toast.error(message); } finally { - setIsSettingIdentity(false); + endIdentityApply(); } }; diff --git a/packages/ui/src/lib/api/types.ts b/packages/ui/src/lib/api/types.ts index 528a7073..e7aae102 100644 --- a/packages/ui/src/lib/api/types.ts +++ b/packages/ui/src/lib/api/types.ts @@ -294,6 +294,7 @@ export interface GitAPI { getGitLog(directory: string, options?: GitLogOptions): Promise; getCommitFiles(directory: string, hash: string): Promise; getCurrentGitIdentity(directory: string): Promise; + hasLocalIdentity?(directory: string): Promise; setGitIdentity(directory: string, profileId: string): Promise<{ success: boolean; profile: GitIdentityProfile }>; getGitIdentities(): Promise; createGitIdentity(profile: GitIdentityProfile): Promise; diff --git a/packages/ui/src/lib/desktop.ts b/packages/ui/src/lib/desktop.ts index 2a4c15b4..8156f79c 100644 --- a/packages/ui/src/lib/desktop.ts +++ b/packages/ui/src/lib/desktop.ts @@ -56,6 +56,7 @@ export type DesktopSettings = { defaultModel?: string; // format: "provider/model" defaultVariant?: string; defaultAgent?: string; + defaultGitIdentityId?: string; // ''/undefined = unset, 'global' or profile id autoCreateWorktree?: boolean; queueModeEnabled?: boolean; commitMessageModel?: string; // format: "provider/model" diff --git a/packages/ui/src/lib/gitApi.ts b/packages/ui/src/lib/gitApi.ts index c886056e..e334a39c 100644 --- a/packages/ui/src/lib/gitApi.ts +++ b/packages/ui/src/lib/gitApi.ts @@ -239,6 +239,12 @@ export async function getCurrentGitIdentity(directory: string): Promise { + const runtime = getRuntimeGit(); + if (runtime?.hasLocalIdentity) return runtime.hasLocalIdentity(directory); + return gitHttp.hasLocalIdentity(directory); +} + export async function setGitIdentity( directory: string, profileId: string diff --git a/packages/ui/src/lib/gitApiHttp.ts b/packages/ui/src/lib/gitApiHttp.ts index f0c3f9de..1ffbc4ea 100644 --- a/packages/ui/src/lib/gitApiHttp.ts +++ b/packages/ui/src/lib/gitApiHttp.ts @@ -516,6 +516,18 @@ export async function getCurrentGitIdentity(directory: string): Promise { + if (!directory) { + return false; + } + const response = await fetch(buildUrl(`${API_BASE}/has-local-identity`, directory)); + if (!response.ok) { + throw new Error(`Failed to check local identity: ${response.statusText}`); + } + const data = await response.json().catch(() => null); + return data?.hasLocalIdentity === true; +} + export async function getGlobalGitIdentity(): Promise { const response = await fetch(buildUrl(`${API_BASE}/global-identity`, undefined)); if (!response.ok) { diff --git a/packages/ui/src/stores/useGitIdentitiesStore.ts b/packages/ui/src/stores/useGitIdentitiesStore.ts index 32b30541..dc3f354f 100644 --- a/packages/ui/src/stores/useGitIdentitiesStore.ts +++ b/packages/ui/src/stores/useGitIdentitiesStore.ts @@ -10,6 +10,9 @@ import { discoverGitCredentials, getGlobalGitIdentity } from "@/lib/gitApi"; +import { getDesktopSettings, isDesktopRuntime } from "@/lib/desktop"; +import { updateDesktopSettings } from "@/lib/persistence"; +import { getRegisteredRuntimeAPIs } from "@/contexts/runtimeAPIRegistry"; export type GitIdentityAuthType = 'ssh' | 'token'; @@ -33,6 +36,7 @@ export interface DiscoveredGitCredential { interface GitIdentitiesStore { selectedProfileId: string | null; + defaultGitIdentityId: string | null; // null = unset, 'global' = system, profile id = custom profiles: GitIdentityProfile[]; globalIdentity: GitIdentityProfile | null; discoveredCredentials: DiscoveredGitCredential[]; @@ -42,6 +46,9 @@ interface GitIdentitiesStore { loadProfiles: () => Promise; loadGlobalIdentity: () => Promise; loadDiscoveredCredentials: () => Promise; + loadDefaultGitIdentityId: () => Promise; + setDefaultGitIdentityId: (id: string | null) => Promise; + createProfile: (profile: Omit & { id?: string }) => Promise; updateProfile: (id: string, updates: Partial) => Promise; deleteProfile: (id: string) => Promise; @@ -61,6 +68,7 @@ export const useGitIdentitiesStore = create()( (set, get) => ({ selectedProfileId: null, + defaultGitIdentityId: null, profiles: [], globalIdentity: null, discoveredCredentials: [], @@ -125,6 +133,70 @@ export const useGitIdentitiesStore = create()( } }, + loadDefaultGitIdentityId: async () => { + const normalize = (value: unknown): string | null => { + if (typeof value !== 'string') { + return null; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; + }; + + try { + let defaultId: string | null = null; + + if (isDesktopRuntime()) { + const settings = await getDesktopSettings(); + defaultId = normalize((settings as { defaultGitIdentityId?: unknown } | null | undefined)?.defaultGitIdentityId); + } else { + const runtimeSettings = getRegisteredRuntimeAPIs()?.settings; + if (runtimeSettings) { + try { + const result = await runtimeSettings.load(); + const settings = (result?.settings || {}) as Record; + defaultId = normalize(settings.defaultGitIdentityId); + } catch { + // fall through + } + } + + if (defaultId === null) { + try { + const response = await fetch('/api/config/settings', { + method: 'GET', + headers: { Accept: 'application/json' }, + }); + if (response.ok) { + const data = (await response.json().catch(() => null)) as Record | null; + defaultId = normalize(data?.defaultGitIdentityId); + } + } catch { + // ignore + } + } + } + + set({ defaultGitIdentityId: defaultId }); + return true; + } catch (error) { + console.error('Failed to load default git identity setting:', error); + return false; + } + }, + + setDefaultGitIdentityId: async (id) => { + try { + const trimmed = typeof id === 'string' ? id.trim() : ''; + const value = trimmed.length > 0 ? trimmed : ''; + await updateDesktopSettings({ defaultGitIdentityId: value }); + set({ defaultGitIdentityId: value.length > 0 ? value : null }); + return true; + } catch (error) { + console.error('Failed to save default git identity setting:', error); + return false; + } + }, + createProfile: async (profileData) => { try { diff --git a/packages/vscode/src/bridge.ts b/packages/vscode/src/bridge.ts index be35d4bd..f3b34394 100644 --- a/packages/vscode/src/bridge.ts +++ b/packages/vscode/src/bridge.ts @@ -82,7 +82,7 @@ const persistSettings = async (changes: Record, ctx?: BridgeCon delete restChanges.lastDirectory; // Normalize empty-string clears to key removal (match web/desktop behavior) - for (const key of ['defaultModel', 'defaultVariant', 'defaultAgent']) { + for (const key of ['defaultModel', 'defaultVariant', 'defaultAgent', 'defaultGitIdentityId']) { const value = restChanges[key]; if (typeof value === 'string' && value.trim().length === 0) { delete restChanges[key]; diff --git a/packages/web/server/index.js b/packages/web/server/index.js index 84828763..db6c837c 100644 --- a/packages/web/server/index.js +++ b/packages/web/server/index.js @@ -605,6 +605,10 @@ const sanitizeSettingsUpdate = (payload) => { const trimmed = candidate.defaultAgent.trim(); result.defaultAgent = trimmed.length > 0 ? trimmed : undefined; } + if (typeof candidate.defaultGitIdentityId === 'string') { + const trimmed = candidate.defaultGitIdentityId.trim(); + result.defaultGitIdentityId = trimmed.length > 0 ? trimmed : undefined; + } if (typeof candidate.queueModeEnabled === 'boolean') { result.queueModeEnabled = candidate.queueModeEnabled; } @@ -3366,6 +3370,22 @@ async function main(options = {}) { } }); + app.get('/api/git/has-local-identity', async (req, res) => { + const { hasLocalIdentity } = await getGitLibraries(); + try { + const directory = req.query.directory; + if (!directory) { + return res.status(400).json({ error: 'directory parameter is required' }); + } + + const hasLocal = await hasLocalIdentity(directory); + res.json({ hasLocalIdentity: hasLocal }); + } catch (error) { + console.error('Failed to check local git identity:', error); + res.status(500).json({ error: 'Failed to check local git identity' }); + } + }); + app.post('/api/git/set-identity', async (req, res) => { const { getProfile, setLocalIdentity, getGlobalIdentity } = await getGitLibraries(); try { diff --git a/packages/web/server/lib/git-service.js b/packages/web/server/lib/git-service.js index 5a902df7..269b4f5a 100644 --- a/packages/web/server/lib/git-service.js +++ b/packages/web/server/lib/git-service.js @@ -160,6 +160,18 @@ export async function getCurrentIdentity(directory) { } } +export async function hasLocalIdentity(directory) { + const git = simpleGit(normalizeDirectoryPath(directory)); + + try { + const localName = await git.getConfig('user.name', 'local').catch(() => null); + const localEmail = await git.getConfig('user.email', 'local').catch(() => null); + return Boolean(localName?.value || localEmail?.value); + } catch { + return false; + } +} + export async function setLocalIdentity(directory, profile) { const git = simpleGit(normalizeDirectoryPath(directory)); diff --git a/packages/web/src/api/git.ts b/packages/web/src/api/git.ts index ec576324..49b9f9c0 100644 --- a/packages/web/src/api/git.ts +++ b/packages/web/src/api/git.ts @@ -34,6 +34,7 @@ export const createWebGitAPI = (): GitAPI => ({ }, getCommitFiles: gitApiHttp.getCommitFiles, getCurrentGitIdentity: gitApiHttp.getCurrentGitIdentity, + hasLocalIdentity: gitApiHttp.hasLocalIdentity, setGitIdentity: gitApiHttp.setGitIdentity, getGitIdentities: gitApiHttp.getGitIdentities, createGitIdentity: gitApiHttp.createGitIdentity,