feat: implement default Git identity management and local identity check
This commit is contained in:
Generated
+1
-1
@@ -2977,7 +2977,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openchamber-desktop"
|
||||
version = "1.4.8"
|
||||
version = "1.4.9"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"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]
|
||||
pub async fn get_global_git_identity() -> Result<GitIdentitySummary, String> {
|
||||
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));
|
||||
}
|
||||
}
|
||||
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() {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }> {
|
||||
const profile = await safeGitInvoke<GitIdentityProfile>('set_git_identity', { directory, profileId });
|
||||
return { success: true, profile };
|
||||
|
||||
@@ -51,6 +51,7 @@ interface GitIdentitiesSidebarProps {
|
||||
export const GitIdentitiesSidebar: React.FC<GitIdentitiesSidebarProps> = ({ onItemSelect }) => {
|
||||
const {
|
||||
selectedProfileId,
|
||||
defaultGitIdentityId,
|
||||
profiles,
|
||||
globalIdentity,
|
||||
setSelectedProfile,
|
||||
@@ -58,6 +59,8 @@ export const GitIdentitiesSidebar: React.FC<GitIdentitiesSidebarProps> = ({ onIt
|
||||
loadProfiles,
|
||||
loadGlobalIdentity,
|
||||
loadDiscoveredCredentials,
|
||||
loadDefaultGitIdentityId,
|
||||
setDefaultGitIdentityId,
|
||||
getUnimportedCredentials,
|
||||
} = useGitIdentitiesStore();
|
||||
|
||||
@@ -82,7 +85,8 @@ export const GitIdentitiesSidebar: React.FC<GitIdentitiesSidebarProps> = ({ 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<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 (
|
||||
<div className={cn('flex h-full flex-col', bgClass)}>
|
||||
<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
|
||||
profile={globalIdentity}
|
||||
isSelected={selectedProfileId === 'global'}
|
||||
isDefault={defaultGitIdentityId === 'global'}
|
||||
onSelect={() => {
|
||||
setSelectedProfile('global');
|
||||
onItemSelect?.();
|
||||
@@ -153,6 +168,7 @@ export const GitIdentitiesSidebar: React.FC<GitIdentitiesSidebarProps> = ({ onIt
|
||||
setSidebarOpen(false);
|
||||
}
|
||||
}}
|
||||
onToggleDefault={() => handleToggleDefault('global')}
|
||||
onDelete={undefined}
|
||||
isReadOnly
|
||||
/>
|
||||
@@ -179,6 +195,7 @@ export const GitIdentitiesSidebar: React.FC<GitIdentitiesSidebarProps> = ({ 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<GitIdentitiesSidebarProps> = ({ onIt
|
||||
setSidebarOpen(false);
|
||||
}
|
||||
}}
|
||||
onToggleDefault={() => handleToggleDefault(profile.id)}
|
||||
onDelete={() => handleDeleteProfile(profile)}
|
||||
/>
|
||||
))}
|
||||
@@ -218,7 +236,9 @@ export const GitIdentitiesSidebar: React.FC<GitIdentitiesSidebarProps> = ({ onIt
|
||||
interface ProfileListItemProps {
|
||||
profile: GitIdentityProfile;
|
||||
isSelected: boolean;
|
||||
isDefault?: boolean;
|
||||
onSelect: () => void;
|
||||
onToggleDefault?: () => void | Promise<void>;
|
||||
onDelete?: () => void;
|
||||
isReadOnly?: boolean;
|
||||
}
|
||||
@@ -226,7 +246,9 @@ interface ProfileListItemProps {
|
||||
const ProfileListItem: React.FC<ProfileListItemProps> = ({
|
||||
profile,
|
||||
isSelected,
|
||||
isDefault = false,
|
||||
onSelect,
|
||||
onToggleDefault,
|
||||
onDelete,
|
||||
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">
|
||||
{authType}
|
||||
</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 className="typography-micro text-muted-foreground/60 truncate leading-tight">
|
||||
@@ -265,7 +292,7 @@ const ProfileListItem: React.FC<ProfileListItemProps> = ({
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{!isReadOnly && onDelete && (
|
||||
{(onToggleDefault || (!isReadOnly && onDelete)) && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
@@ -276,17 +303,29 @@ const ProfileListItem: React.FC<ProfileListItemProps> = ({
|
||||
<RiMore2Line className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-fit min-w-20">
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<RiDeleteBinLine className="h-4 w-4 mr-px" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuContent align="end" className="w-fit min-w-28">
|
||||
{onToggleDefault && (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void onToggleDefault();
|
||||
}}
|
||||
>
|
||||
{isDefault ? 'Unset default' : 'Set as default'}
|
||||
</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>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
@@ -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<AddCatalogDialogProps> = ({ 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<AddCatalogDialogProps> = ({ 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<AddCatalogDialogProps> = ({ 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;
|
||||
|
||||
@@ -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<InstallFromRepoDialogProps> = ({ 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<InstallFromRepoDialogProps> = ({ op
|
||||
setSearch('');
|
||||
setIdentities([]);
|
||||
setGitIdentityId(null);
|
||||
void loadDefaultGitIdentityId();
|
||||
|
||||
setConflictsOpen(false);
|
||||
|
||||
setConflicts([]);
|
||||
setBaseInstallRequest(null);
|
||||
}, [open]);
|
||||
}, [open, loadDefaultGitIdentityId]);
|
||||
|
||||
const installedByName = React.useMemo(() => {
|
||||
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[];
|
||||
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<InstallFromRepoDialogProps> = ({ 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;
|
||||
|
||||
@@ -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<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>>(
|
||||
() => 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();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -294,6 +294,7 @@ export interface GitAPI {
|
||||
getGitLog(directory: string, options?: GitLogOptions): Promise<GitLogResponse>;
|
||||
getCommitFiles(directory: string, hash: string): Promise<GitCommitFilesResponse>;
|
||||
getCurrentGitIdentity(directory: string): Promise<GitIdentitySummary | null>;
|
||||
hasLocalIdentity?(directory: string): Promise<boolean>;
|
||||
setGitIdentity(directory: string, profileId: string): Promise<{ success: boolean; profile: GitIdentityProfile }>;
|
||||
getGitIdentities(): Promise<GitIdentityProfile[]>;
|
||||
createGitIdentity(profile: GitIdentityProfile): Promise<GitIdentityProfile>;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -239,6 +239,12 @@ export async function getCurrentGitIdentity(directory: string): Promise<import('
|
||||
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(
|
||||
directory: string,
|
||||
profileId: string
|
||||
|
||||
@@ -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> {
|
||||
const response = await fetch(buildUrl(`${API_BASE}/global-identity`, undefined));
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -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<boolean>;
|
||||
loadGlobalIdentity: () => Promise<boolean>;
|
||||
loadDiscoveredCredentials: () => Promise<boolean>;
|
||||
loadDefaultGitIdentityId: () => Promise<boolean>;
|
||||
setDefaultGitIdentityId: (id: string | null) => Promise<boolean>;
|
||||
|
||||
createProfile: (profile: Omit<GitIdentityProfile, 'id'> & { id?: string }) => Promise<boolean>;
|
||||
updateProfile: (id: string, updates: Partial<GitIdentityProfile>) => Promise<boolean>;
|
||||
deleteProfile: (id: string) => Promise<boolean>;
|
||||
@@ -61,6 +68,7 @@ export const useGitIdentitiesStore = create<GitIdentitiesStore>()(
|
||||
(set, get) => ({
|
||||
|
||||
selectedProfileId: null,
|
||||
defaultGitIdentityId: null,
|
||||
profiles: [],
|
||||
globalIdentity: null,
|
||||
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) => {
|
||||
try {
|
||||
|
||||
|
||||
@@ -82,7 +82,7 @@ const persistSettings = async (changes: Record<string, unknown>, 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];
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user