feat: implement default Git identity management and local identity check

This commit is contained in:
Bohdan Triapitsyn
2026-01-14 22:34:17 +02:00
parent e0cc212823
commit e0279e59b0
18 changed files with 310 additions and 25 deletions
+1 -1
View File
@@ -2977,7 +2977,7 @@ dependencies = [
[[package]] [[package]]
name = "openchamber-desktop" name = "openchamber-desktop"
version = "1.4.8" version = "1.4.9"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"axum", "axum",
@@ -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<bool, String> {
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] #[tauri::command]
pub async fn get_global_git_identity() -> Result<GitIdentitySummary, String> { pub async fn get_global_git_identity() -> Result<GitIdentitySummary, String> {
let user_name = tokio::process::Command::new("git") let user_name = tokio::process::Command::new("git")
@@ -257,6 +257,14 @@ fn sanitize_settings_update(payload: &Value) -> Value {
result_obj.insert("defaultAgent".to_string(), json!(trimmed)); 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") { if let Some(Value::String(s)) = obj.get("commitMessageModel") {
let trimmed = s.trim(); let trimmed = s.trim();
if trimmed.is_empty() { if trimmed.is_empty() {
+2 -1
View File
@@ -33,7 +33,7 @@ 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,
discover_git_credentials, 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, 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, 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, git_fetch, git_pull, git_push, is_linked_worktree, list_git_worktrees, remove_git_worktree,
revert_git_file, set_git_identity, update_git_identity, revert_git_file, set_git_identity, update_git_identity,
@@ -871,6 +871,7 @@ fn main() {
update_git_identity, update_git_identity,
delete_git_identity, delete_git_identity,
get_current_git_identity, get_current_git_identity,
has_local_identity,
get_global_git_identity, get_global_git_identity,
get_remote_url, get_remote_url,
set_git_identity, set_git_identity,
+8
View File
@@ -218,6 +218,14 @@ export const createDesktopGitAPI = (): GitAPI => ({
} }
}, },
async hasLocalIdentity(directory: string): Promise<boolean> {
try {
return await safeGitInvoke<boolean>('has_local_identity', { directory });
} catch {
return false;
}
},
async setGitIdentity(directory: string, profileId: string): Promise<{ success: boolean; profile: GitIdentityProfile }> { async setGitIdentity(directory: string, profileId: string): Promise<{ success: boolean; profile: GitIdentityProfile }> {
const profile = await safeGitInvoke<GitIdentityProfile>('set_git_identity', { directory, profileId }); const profile = await safeGitInvoke<GitIdentityProfile>('set_git_identity', { directory, profileId });
return { success: true, profile }; return { success: true, profile };
@@ -51,6 +51,7 @@ interface GitIdentitiesSidebarProps {
export const GitIdentitiesSidebar: React.FC<GitIdentitiesSidebarProps> = ({ onItemSelect }) => { export const GitIdentitiesSidebar: React.FC<GitIdentitiesSidebarProps> = ({ onItemSelect }) => {
const { const {
selectedProfileId, selectedProfileId,
defaultGitIdentityId,
profiles, profiles,
globalIdentity, globalIdentity,
setSelectedProfile, setSelectedProfile,
@@ -58,6 +59,8 @@ export const GitIdentitiesSidebar: React.FC<GitIdentitiesSidebarProps> = ({ onIt
loadProfiles, loadProfiles,
loadGlobalIdentity, loadGlobalIdentity,
loadDiscoveredCredentials, loadDiscoveredCredentials,
loadDefaultGitIdentityId,
setDefaultGitIdentityId,
getUnimportedCredentials, getUnimportedCredentials,
} = useGitIdentitiesStore(); } = useGitIdentitiesStore();
@@ -82,7 +85,8 @@ export const GitIdentitiesSidebar: React.FC<GitIdentitiesSidebarProps> = ({ onIt
loadProfiles(); loadProfiles();
loadGlobalIdentity(); loadGlobalIdentity();
loadDiscoveredCredentials(); loadDiscoveredCredentials();
}, [loadProfiles, loadGlobalIdentity, loadDiscoveredCredentials]); loadDefaultGitIdentityId();
}, [loadProfiles, loadGlobalIdentity, loadDiscoveredCredentials, loadDefaultGitIdentityId]);
const handleImportCredential = (credential: DiscoveredGitCredential) => { const handleImportCredential = (credential: DiscoveredGitCredential) => {
// Set a special "import" selection that carries the credential data // Set a special "import" selection that carries the credential data
@@ -119,6 +123,16 @@ export const GitIdentitiesSidebar: React.FC<GitIdentitiesSidebarProps> = ({ 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 ( return (
<div className={cn('flex h-full flex-col', bgClass)}> <div className={cn('flex h-full flex-col', bgClass)}>
<div className={cn('border-b px-3', isMobile ? 'mt-2 py-3' : 'py-3')}> <div className={cn('border-b px-3', isMobile ? 'mt-2 py-3' : 'py-3')}>
@@ -146,6 +160,7 @@ export const GitIdentitiesSidebar: React.FC<GitIdentitiesSidebarProps> = ({ onIt
<ProfileListItem <ProfileListItem
profile={globalIdentity} profile={globalIdentity}
isSelected={selectedProfileId === 'global'} isSelected={selectedProfileId === 'global'}
isDefault={defaultGitIdentityId === 'global'}
onSelect={() => { onSelect={() => {
setSelectedProfile('global'); setSelectedProfile('global');
onItemSelect?.(); onItemSelect?.();
@@ -153,6 +168,7 @@ export const GitIdentitiesSidebar: React.FC<GitIdentitiesSidebarProps> = ({ onIt
setSidebarOpen(false); setSidebarOpen(false);
} }
}} }}
onToggleDefault={() => handleToggleDefault('global')}
onDelete={undefined} onDelete={undefined}
isReadOnly isReadOnly
/> />
@@ -179,6 +195,7 @@ export const GitIdentitiesSidebar: React.FC<GitIdentitiesSidebarProps> = ({ onIt
key={profile.id} key={profile.id}
profile={profile} profile={profile}
isSelected={selectedProfileId === profile.id} isSelected={selectedProfileId === profile.id}
isDefault={defaultGitIdentityId === profile.id}
onSelect={() => { onSelect={() => {
setSelectedProfile(profile.id); setSelectedProfile(profile.id);
onItemSelect?.(); onItemSelect?.();
@@ -186,6 +203,7 @@ export const GitIdentitiesSidebar: React.FC<GitIdentitiesSidebarProps> = ({ onIt
setSidebarOpen(false); setSidebarOpen(false);
} }
}} }}
onToggleDefault={() => handleToggleDefault(profile.id)}
onDelete={() => handleDeleteProfile(profile)} onDelete={() => handleDeleteProfile(profile)}
/> />
))} ))}
@@ -218,7 +236,9 @@ export const GitIdentitiesSidebar: React.FC<GitIdentitiesSidebarProps> = ({ onIt
interface ProfileListItemProps { interface ProfileListItemProps {
profile: GitIdentityProfile; profile: GitIdentityProfile;
isSelected: boolean; isSelected: boolean;
isDefault?: boolean;
onSelect: () => void; onSelect: () => void;
onToggleDefault?: () => void | Promise<void>;
onDelete?: () => void; onDelete?: () => void;
isReadOnly?: boolean; isReadOnly?: boolean;
} }
@@ -226,7 +246,9 @@ interface ProfileListItemProps {
const ProfileListItem: React.FC<ProfileListItemProps> = ({ const ProfileListItem: React.FC<ProfileListItemProps> = ({
profile, profile,
isSelected, isSelected,
isDefault = false,
onSelect, onSelect,
onToggleDefault,
onDelete, onDelete,
isReadOnly = false, isReadOnly = false,
}) => { }) => {
@@ -258,6 +280,11 @@ const ProfileListItem: React.FC<ProfileListItemProps> = ({
<span className="typography-micro text-muted-foreground bg-muted px-1 rounded flex-shrink-0 leading-none pb-px border border-border/50"> <span className="typography-micro text-muted-foreground bg-muted px-1 rounded flex-shrink-0 leading-none pb-px border border-border/50">
{authType} {authType}
</span> </span>
{isDefault && (
<span className="typography-micro text-primary bg-primary/12 px-1 rounded flex-shrink-0 leading-none pb-px border border-primary/25">
default
</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">
@@ -265,7 +292,7 @@ const ProfileListItem: React.FC<ProfileListItemProps> = ({
</div> </div>
</button> </button>
{!isReadOnly && onDelete && ( {(onToggleDefault || (!isReadOnly && onDelete)) && (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button <Button
@@ -276,17 +303,29 @@ const ProfileListItem: React.FC<ProfileListItemProps> = ({
<RiMore2Line className="h-3.5 w-3.5" /> <RiMore2Line className="h-3.5 w-3.5" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-fit min-w-20"> <DropdownMenuContent align="end" className="w-fit min-w-28">
<DropdownMenuItem {onToggleDefault && (
onClick={(e) => { <DropdownMenuItem
e.stopPropagation(); onClick={(e) => {
onDelete(); e.stopPropagation();
}} void onToggleDefault();
className="text-destructive focus:text-destructive" }}
> >
<RiDeleteBinLine className="h-4 w-4 mr-px" /> {isDefault ? 'Unset default' : 'Set as default'}
Delete </DropdownMenuItem>
</DropdownMenuItem> )}
{!isReadOnly && onDelete && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
className="text-destructive focus:text-destructive"
>
<RiDeleteBinLine className="h-4 w-4 mr-px" />
Delete
</DropdownMenuItem>
)}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
)} )}
@@ -25,6 +25,7 @@ import { getDesktopSettings, isDesktopRuntime, isVSCodeRuntime } from '@/lib/des
import { updateDesktopSettings } from '@/lib/persistence'; import { updateDesktopSettings } from '@/lib/persistence';
import type { DesktopSettings, SkillCatalogConfig } from '@/lib/desktop'; import type { DesktopSettings, SkillCatalogConfig } from '@/lib/desktop';
import { useSkillsCatalogStore } from '@/stores/useSkillsCatalogStore'; import { useSkillsCatalogStore } from '@/stores/useSkillsCatalogStore';
import { useGitIdentitiesStore } from '@/stores/useGitIdentitiesStore';
const generateCatalogId = () => `custom:${Date.now()}-${Math.random().toString(16).slice(2)}`; const generateCatalogId = () => `custom:${Date.now()}-${Math.random().toString(16).slice(2)}`;
@@ -81,6 +82,8 @@ interface AddCatalogDialogProps {
export const AddCatalogDialog: React.FC<AddCatalogDialogProps> = ({ open, onOpenChange }) => { export const AddCatalogDialog: React.FC<AddCatalogDialogProps> = ({ open, onOpenChange }) => {
const { scanRepo, loadCatalog, isScanning } = useSkillsCatalogStore(); const { scanRepo, loadCatalog, isScanning } = useSkillsCatalogStore();
const defaultGitIdentityId = useGitIdentitiesStore((s) => s.defaultGitIdentityId);
const loadDefaultGitIdentityId = useGitIdentitiesStore((s) => s.loadDefaultGitIdentityId);
const [label, setLabel] = React.useState(''); const [label, setLabel] = React.useState('');
const [source, setSource] = React.useState(''); const [source, setSource] = React.useState('');
@@ -104,13 +107,14 @@ export const AddCatalogDialog: React.FC<AddCatalogDialogProps> = ({ open, onOpen
setScanOk(false); setScanOk(false);
setIdentityOptions([]); setIdentityOptions([]);
setGitIdentityId(null); setGitIdentityId(null);
void loadDefaultGitIdentityId();
void (async () => { void (async () => {
const settings = await loadSettings(); const settings = await loadSettings();
const catalogs = Array.isArray(settings?.skillCatalogs) ? settings?.skillCatalogs : []; const catalogs = Array.isArray(settings?.skillCatalogs) ? settings?.skillCatalogs : [];
setExistingCatalogs(catalogs || []); setExistingCatalogs(catalogs || []);
})(); })();
}, [open]); }, [open, loadDefaultGitIdentityId]);
const isDuplicate = React.useMemo(() => { const isDuplicate = React.useMemo(() => {
const normalizedSource = source.trim(); const normalizedSource = source.trim();
@@ -153,7 +157,13 @@ export const AddCatalogDialog: React.FC<AddCatalogDialogProps> = ({ open, onOpen
const ids = (result.error.identities || []) as IdentityOption[]; const ids = (result.error.identities || []) as IdentityOption[];
setIdentityOptions(ids); setIdentityOptions(ids);
if (!gitIdentityId && ids.length > 0) { 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.'); toast.error('Authentication required. Select a Git identity and scan again.');
return; return;
@@ -25,6 +25,7 @@ import { isVSCodeRuntime } from '@/lib/desktop';
import type { SkillsCatalogItem } from '@/lib/api/types'; import type { SkillsCatalogItem } from '@/lib/api/types';
import { useSkillsCatalogStore } from '@/stores/useSkillsCatalogStore'; import { useSkillsCatalogStore } from '@/stores/useSkillsCatalogStore';
import { useSkillsStore } from '@/stores/useSkillsStore'; import { useSkillsStore } from '@/stores/useSkillsStore';
import { useGitIdentitiesStore } from '@/stores/useGitIdentitiesStore';
import { InstallConflictsDialog, type ConflictDecision, type SkillConflict } from './InstallConflictsDialog'; import { InstallConflictsDialog, type ConflictDecision, type SkillConflict } from './InstallConflictsDialog';
interface InstallFromRepoDialogProps { interface InstallFromRepoDialogProps {
@@ -37,6 +38,8 @@ type IdentityOption = { id: string; name: string };
export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ open, onOpenChange }) => { export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ open, onOpenChange }) => {
const { scanRepo, installSkills, isScanning, isInstalling } = useSkillsCatalogStore(); const { scanRepo, installSkills, isScanning, isInstalling } = useSkillsCatalogStore();
const installedSkills = useSkillsStore((s) => s.skills); const installedSkills = useSkillsStore((s) => s.skills);
const defaultGitIdentityId = useGitIdentitiesStore((s) => s.defaultGitIdentityId);
const loadDefaultGitIdentityId = useGitIdentitiesStore((s) => s.loadDefaultGitIdentityId);
const [source, setSource] = React.useState(''); const [source, setSource] = React.useState('');
const [subpath, setSubpath] = React.useState(''); const [subpath, setSubpath] = React.useState('');
@@ -69,10 +72,13 @@ export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ op
setSearch(''); setSearch('');
setIdentities([]); setIdentities([]);
setGitIdentityId(null); setGitIdentityId(null);
void loadDefaultGitIdentityId();
setConflictsOpen(false); setConflictsOpen(false);
setConflicts([]); setConflicts([]);
setBaseInstallRequest(null); setBaseInstallRequest(null);
}, [open]); }, [open, loadDefaultGitIdentityId]);
const installedByName = React.useMemo(() => { const installedByName = React.useMemo(() => {
const map = new Map<string, { scope: 'user' | 'project' }>(); const map = new Map<string, { scope: 'user' | 'project' }>();
@@ -127,7 +133,13 @@ export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ op
const ids = (result.error.identities || []) as IdentityOption[]; const ids = (result.error.identities || []) as IdentityOption[];
setIdentities(ids); setIdentities(ids);
if (!gitIdentityId && ids.length > 0) { 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.'); toast.error('Authentication required. Select a Git identity and try scanning again.');
return; return;
@@ -195,7 +207,13 @@ export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ op
const ids = (result.error.identities || []) as IdentityOption[]; const ids = (result.error.identities || []) as IdentityOption[];
setIdentities(ids); setIdentities(ids);
if (!gitIdentityId && ids.length > 0) { 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.'); toast.error('Authentication required. Select a Git identity and try installing again.');
return; return;
+59 -4
View File
@@ -60,7 +60,7 @@ export const GitView: React.FC = () => {
? worktreeMap.get(currentSessionId) ?? undefined ? worktreeMap.get(currentSessionId) ?? undefined
: undefined; : undefined;
const { profiles, globalIdentity, loadProfiles, loadGlobalIdentity } = const { profiles, globalIdentity, defaultGitIdentityId, loadProfiles, loadGlobalIdentity, loadDefaultGitIdentityId } =
useGitIdentitiesStore(); useGitIdentitiesStore();
const isGitRepo = useIsGitRepo(currentDirectory ?? null); const isGitRepo = useIsGitRepo(currentDirectory ?? null);
@@ -95,6 +95,21 @@ export const GitView: React.FC = () => {
const [isSettingIdentity, setIsSettingIdentity] = React.useState(false); const [isSettingIdentity, setIsSettingIdentity] = React.useState(false);
const { triggerFireworks } = useFireworksCelebration(); const { triggerFireworks } = useFireworksCelebration();
const autoAppliedDefaultRef = React.useRef<Map<string, string>>(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<Set<string>>( const [selectedPaths, setSelectedPaths] = React.useState<Set<string>>(
() => new Set(initialSnapshot?.selectedPaths ?? []) () => new Set(initialSnapshot?.selectedPaths ?? [])
); );
@@ -187,7 +202,8 @@ export const GitView: React.FC = () => {
React.useEffect(() => { React.useEffect(() => {
loadProfiles(); loadProfiles();
loadGlobalIdentity(); loadGlobalIdentity();
}, [loadProfiles, loadGlobalIdentity]); loadDefaultGitIdentityId();
}, [loadProfiles, loadGlobalIdentity, loadDefaultGitIdentityId]);
React.useEffect(() => { React.useEffect(() => {
if (!currentDirectory || !git?.getRemoteUrl) { if (!currentDirectory || !git?.getRemoteUrl) {
@@ -238,6 +254,45 @@ export const GitView: React.FC = () => {
await fetchIdentity(currentDirectory, git); await fetchIdentity(currentDirectory, git);
}, [currentDirectory, git, fetchIdentity]); }, [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(() => { const changeEntries = React.useMemo(() => {
if (!status) return []; if (!status) return [];
const files = status.files ?? []; const files = status.files ?? [];
@@ -470,7 +525,7 @@ export const GitView: React.FC = () => {
const handleApplyIdentity = async (profile: GitIdentityProfile) => { const handleApplyIdentity = async (profile: GitIdentityProfile) => {
if (!currentDirectory) return; if (!currentDirectory) return;
setIsSettingIdentity(true); beginIdentityApply();
try { try {
await git.setGitIdentity(currentDirectory, profile.id); 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'; const message = err instanceof Error ? err.message : 'Failed to apply git identity';
toast.error(message); toast.error(message);
} finally { } finally {
setIsSettingIdentity(false); endIdentityApply();
} }
}; };
+1
View File
@@ -294,6 +294,7 @@ export interface GitAPI {
getGitLog(directory: string, options?: GitLogOptions): Promise<GitLogResponse>; getGitLog(directory: string, options?: GitLogOptions): Promise<GitLogResponse>;
getCommitFiles(directory: string, hash: string): Promise<GitCommitFilesResponse>; getCommitFiles(directory: string, hash: string): Promise<GitCommitFilesResponse>;
getCurrentGitIdentity(directory: string): Promise<GitIdentitySummary | null>; getCurrentGitIdentity(directory: string): Promise<GitIdentitySummary | null>;
hasLocalIdentity?(directory: string): Promise<boolean>;
setGitIdentity(directory: string, profileId: string): Promise<{ success: boolean; profile: GitIdentityProfile }>; setGitIdentity(directory: string, profileId: string): Promise<{ success: boolean; profile: GitIdentityProfile }>;
getGitIdentities(): Promise<GitIdentityProfile[]>; getGitIdentities(): Promise<GitIdentityProfile[]>;
createGitIdentity(profile: GitIdentityProfile): Promise<GitIdentityProfile>; createGitIdentity(profile: GitIdentityProfile): Promise<GitIdentityProfile>;
+1
View File
@@ -56,6 +56,7 @@ export type DesktopSettings = {
defaultModel?: string; // format: "provider/model" defaultModel?: string; // format: "provider/model"
defaultVariant?: string; defaultVariant?: string;
defaultAgent?: string; defaultAgent?: string;
defaultGitIdentityId?: string; // ''/undefined = unset, 'global' or profile id
autoCreateWorktree?: boolean; autoCreateWorktree?: boolean;
queueModeEnabled?: boolean; queueModeEnabled?: boolean;
commitMessageModel?: string; // format: "provider/model" commitMessageModel?: string; // format: "provider/model"
+6
View File
@@ -239,6 +239,12 @@ export async function getCurrentGitIdentity(directory: string): Promise<import('
return gitHttp.getCurrentGitIdentity(directory); return gitHttp.getCurrentGitIdentity(directory);
} }
export async function hasLocalIdentity(directory: string): Promise<boolean> {
const runtime = getRuntimeGit();
if (runtime?.hasLocalIdentity) return runtime.hasLocalIdentity(directory);
return gitHttp.hasLocalIdentity(directory);
}
export async function setGitIdentity( export async function setGitIdentity(
directory: string, directory: string,
profileId: string profileId: string
+12
View File
@@ -516,6 +516,18 @@ export async function getCurrentGitIdentity(directory: string): Promise<GitIdent
}; };
} }
export async function hasLocalIdentity(directory: string): Promise<boolean> {
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<GitIdentitySummary | null> { export async function getGlobalGitIdentity(): Promise<GitIdentitySummary | null> {
const response = await fetch(buildUrl(`${API_BASE}/global-identity`, undefined)); const response = await fetch(buildUrl(`${API_BASE}/global-identity`, undefined));
if (!response.ok) { if (!response.ok) {
@@ -10,6 +10,9 @@ import {
discoverGitCredentials, discoverGitCredentials,
getGlobalGitIdentity getGlobalGitIdentity
} from "@/lib/gitApi"; } from "@/lib/gitApi";
import { getDesktopSettings, isDesktopRuntime } from "@/lib/desktop";
import { updateDesktopSettings } from "@/lib/persistence";
import { getRegisteredRuntimeAPIs } from "@/contexts/runtimeAPIRegistry";
export type GitIdentityAuthType = 'ssh' | 'token'; export type GitIdentityAuthType = 'ssh' | 'token';
@@ -33,6 +36,7 @@ export interface DiscoveredGitCredential {
interface GitIdentitiesStore { interface GitIdentitiesStore {
selectedProfileId: string | null; selectedProfileId: string | null;
defaultGitIdentityId: string | null; // null = unset, 'global' = system, profile id = custom
profiles: GitIdentityProfile[]; profiles: GitIdentityProfile[];
globalIdentity: GitIdentityProfile | null; globalIdentity: GitIdentityProfile | null;
discoveredCredentials: DiscoveredGitCredential[]; discoveredCredentials: DiscoveredGitCredential[];
@@ -42,6 +46,9 @@ interface GitIdentitiesStore {
loadProfiles: () => Promise<boolean>; loadProfiles: () => Promise<boolean>;
loadGlobalIdentity: () => Promise<boolean>; loadGlobalIdentity: () => Promise<boolean>;
loadDiscoveredCredentials: () => Promise<boolean>; loadDiscoveredCredentials: () => Promise<boolean>;
loadDefaultGitIdentityId: () => Promise<boolean>;
setDefaultGitIdentityId: (id: string | null) => 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>;
@@ -61,6 +68,7 @@ export const useGitIdentitiesStore = create<GitIdentitiesStore>()(
(set, get) => ({ (set, get) => ({
selectedProfileId: null, selectedProfileId: null,
defaultGitIdentityId: null,
profiles: [], profiles: [],
globalIdentity: null, globalIdentity: null,
discoveredCredentials: [], discoveredCredentials: [],
@@ -125,6 +133,70 @@ export const useGitIdentitiesStore = create<GitIdentitiesStore>()(
} }
}, },
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<string, unknown>;
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<string, unknown> | 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) => { createProfile: async (profileData) => {
try { try {
+1 -1
View File
@@ -82,7 +82,7 @@ const persistSettings = async (changes: Record<string, unknown>, ctx?: BridgeCon
delete restChanges.lastDirectory; delete restChanges.lastDirectory;
// Normalize empty-string clears to key removal (match web/desktop behavior) // 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]; const value = restChanges[key];
if (typeof value === 'string' && value.trim().length === 0) { if (typeof value === 'string' && value.trim().length === 0) {
delete restChanges[key]; delete restChanges[key];
+20
View File
@@ -605,6 +605,10 @@ const sanitizeSettingsUpdate = (payload) => {
const trimmed = candidate.defaultAgent.trim(); const trimmed = candidate.defaultAgent.trim();
result.defaultAgent = trimmed.length > 0 ? trimmed : undefined; 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') { if (typeof candidate.queueModeEnabled === 'boolean') {
result.queueModeEnabled = candidate.queueModeEnabled; 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) => { app.post('/api/git/set-identity', async (req, res) => {
const { getProfile, setLocalIdentity, getGlobalIdentity } = await getGitLibraries(); const { getProfile, setLocalIdentity, getGlobalIdentity } = await getGitLibraries();
try { try {
+12
View File
@@ -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) { export async function setLocalIdentity(directory, profile) {
const git = simpleGit(normalizeDirectoryPath(directory)); const git = simpleGit(normalizeDirectoryPath(directory));
+1
View File
@@ -34,6 +34,7 @@ export const createWebGitAPI = (): GitAPI => ({
}, },
getCommitFiles: gitApiHttp.getCommitFiles, getCommitFiles: gitApiHttp.getCommitFiles,
getCurrentGitIdentity: gitApiHttp.getCurrentGitIdentity, getCurrentGitIdentity: gitApiHttp.getCurrentGitIdentity,
hasLocalIdentity: gitApiHttp.hasLocalIdentity,
setGitIdentity: gitApiHttp.setGitIdentity, setGitIdentity: gitApiHttp.setGitIdentity,
getGitIdentities: gitApiHttp.getGitIdentities, getGitIdentities: gitApiHttp.getGitIdentities,
createGitIdentity: gitApiHttp.createGitIdentity, createGitIdentity: gitApiHttp.createGitIdentity,