diff --git a/packages/ui/src/components/views/GitView.tsx b/packages/ui/src/components/views/GitView.tsx index 773b556e..6b90930a 100644 --- a/packages/ui/src/components/views/GitView.tsx +++ b/packages/ui/src/components/views/GitView.tsx @@ -54,6 +54,7 @@ import { getSessionWorktreeRepairActions, getMutationBlockingReasons } from '@/s import { IntegrateCommitsSection } from './git/IntegrateCommitsSection'; import { GitHeader } from './git/GitHeader'; +import { StashesDialog } from './git/StashesDialog'; import { ChangesSection } from './git/ChangesSection'; import { CommitSection } from './git/CommitSection'; import { GitEmptyState } from './git/GitEmptyState'; @@ -429,6 +430,7 @@ export const GitView: React.FC = () => { const [isGitmojiPickerOpen, setIsGitmojiPickerOpen] = React.useState(false); const actionPanelScrollRef = React.useRef(null); const [syncAction, setSyncAction] = React.useState(null); + const [isStashesDialogOpen, setIsStashesDialogOpen] = React.useState(false); const [commitAction, setCommitAction] = React.useState(null); const [logMaxCountLocal, setLogMaxCountLocal] = React.useState(25); const [isSettingIdentity, setIsSettingIdentity] = React.useState(false); @@ -2069,6 +2071,7 @@ export const GitView: React.FC = () => { isApplyingIdentity={isSettingIdentity} isWorktreeMode={!!worktreeMetadata} onOpenHistory={() => setIsHistoryDialogOpen(true)} + onOpenStashes={() => setIsStashesDialogOpen(true)} /> {/* In-progress operation banner */} @@ -2274,6 +2277,18 @@ export const GitView: React.FC = () => { + 0} + uncommittedFileCount={status?.files?.length ?? 0} + onChanged={async () => { + await refreshStatusAndBranches(false); + await refreshLog(); + }} + /> + diff --git a/packages/ui/src/components/views/git/GitHeader.tsx b/packages/ui/src/components/views/git/GitHeader.tsx index 6008e905..88a890a0 100644 --- a/packages/ui/src/components/views/git/GitHeader.tsx +++ b/packages/ui/src/components/views/git/GitHeader.tsx @@ -10,6 +10,7 @@ import { RiCodeLine, RiHeartLine, RiHistoryLine, + RiArchiveStackLine, RiUser3Line, } from '@remixicon/react'; import { Button } from '@/components/ui/button'; @@ -48,6 +49,7 @@ interface GitHeaderProps { isApplyingIdentity: boolean; isWorktreeMode: boolean; onOpenHistory?: () => void; + onOpenStashes?: () => void; } const IDENTITY_ICON_MAP: Record< @@ -206,6 +208,7 @@ export const GitHeader: React.FC = ({ isApplyingIdentity, isWorktreeMode, onOpenHistory, + onOpenStashes, }) => { const { t } = useI18n(); if (!status) { @@ -229,6 +232,16 @@ export const GitHeader: React.FC = ({ {t('gitView.history.title')} ) : null} + {onOpenStashes ? ( + + + + + {t('gitView.stashes.title')} + + ) : null} ); diff --git a/packages/ui/src/components/views/git/StashesDialog.tsx b/packages/ui/src/components/views/git/StashesDialog.tsx new file mode 100644 index 00000000..1e858168 --- /dev/null +++ b/packages/ui/src/components/views/git/StashesDialog.tsx @@ -0,0 +1,196 @@ +import React from 'react'; +import { RiArchiveStackLine, RiDeleteBinLine, RiInboxArchiveLine, RiInboxUnarchiveFill, RiInboxUnarchiveLine, RiLoader4Line, RiSearchLine } from '@remixicon/react'; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; +import { toast } from '@/components/ui'; +import { useI18n } from '@/lib/i18n'; +import { cn } from '@/lib/utils'; +import type { GitStashEntry } from '@/lib/api/types'; +import { applyGitStash, countGitStashFiles, dropGitStash, listGitStashes, popGitStash, stashGitChanges } from '@/lib/gitApi'; + +interface StashesDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + directory: string | null; + hasUncommittedChanges: boolean; + uncommittedFileCount: number; + onChanged?: () => void | Promise; +} + +type StashOperation = 'create' | `apply:${string}` | `pop:${string}` | `drop:${string}` | null; + +export const StashesDialog: React.FC = ({ + open, + onOpenChange, + directory, + hasUncommittedChanges, + uncommittedFileCount, + onChanged, +}) => { + const { t } = useI18n(); + const [stashes, setStashes] = React.useState([]); + const [isLoading, setIsLoading] = React.useState(false); + const [query, setQuery] = React.useState(''); + const [message, setMessage] = React.useState(''); + const [operation, setOperation] = React.useState(null); + const [fileCounts, setFileCounts] = React.useState>({}); + + const load = React.useCallback(async () => { + if (!directory) return; + setIsLoading(true); + try { + const result = await listGitStashes(directory); + setStashes(result.stashes); + setFileCounts({}); + } catch (error) { + toast.error(error instanceof Error ? error.message : t('gitView.stashes.toast.loadFailed')); + } finally { + setIsLoading(false); + } + }, [directory, t]); + + React.useEffect(() => { + if (open) void load(); + }, [load, open]); + + React.useEffect(() => { + if (!open || !directory || stashes.length === 0) return; + let cancelled = false; + const refs = stashes.map((stash) => stash.ref); + void countGitStashFiles(directory, refs).then((result) => { + if (!cancelled) setFileCounts(result.counts); + }).catch(() => undefined); + return () => { + cancelled = true; + }; + }, [directory, open, stashes]); + + const filtered = React.useMemo(() => { + const normalized = query.trim().toLowerCase(); + if (!normalized) return stashes; + return stashes.filter((stash) => `${stash.ref} ${stash.message} ${stash.relativeTime}`.toLowerCase().includes(normalized)); + }, [query, stashes]); + + const refreshAfterChange = React.useCallback(async () => { + await load(); + await onChanged?.(); + }, [load, onChanged]); + + const handleCreate = async () => { + if (!directory || operation) return; + setOperation('create'); + try { + const result = await stashGitChanges(directory, { message: message.trim() || undefined }); + if (result.created) { + toast.success(t('gitView.stashes.toast.created')); + setMessage(''); + } else { + toast.info(t('gitView.stashes.toast.noChanges')); + } + await refreshAfterChange(); + } catch (error) { + toast.error(error instanceof Error ? error.message : t('gitView.stashes.toast.createFailed')); + } finally { + setOperation(null); + } + }; + + const runStashAction = async (stash: GitStashEntry, kind: 'apply' | 'pop' | 'drop') => { + if (!directory || operation) return; + if (kind === 'drop' && !window.confirm(t('gitView.stashes.confirm.drop', { ref: stash.ref }))) return; + setOperation(`${kind}:${stash.ref}`); + try { + if (kind === 'apply') await applyGitStash(directory, { ref: stash.ref }); + if (kind === 'pop') await popGitStash(directory, { ref: stash.ref }); + if (kind === 'drop') await dropGitStash(directory, { ref: stash.ref }); + const successKey = kind === 'apply' ? 'gitView.stashes.toast.applySuccess' : kind === 'pop' ? 'gitView.stashes.toast.popSuccess' : 'gitView.stashes.toast.dropSuccess'; + toast.success(t(successKey)); + await refreshAfterChange(); + } catch (error) { + const failedKey = kind === 'apply' ? 'gitView.stashes.toast.applyFailed' : kind === 'pop' ? 'gitView.stashes.toast.popFailed' : 'gitView.stashes.toast.dropFailed'; + toast.error(error instanceof Error ? error.message : t(failedKey)); + await refreshAfterChange(); + } finally { + setOperation(null); + } + }; + + const isOperating = operation !== null; + + return ( + + + + + + {t('gitView.stashes.title')} + + {t('gitView.stashes.description')} + + +
+
+ setMessage(event.target.value)} + placeholder={t('gitView.stashes.messagePlaceholder')} + disabled={isOperating || !hasUncommittedChanges} + className="flex-1" + /> + +
+

{t('gitView.stashes.includeUntrackedHint')}

+
+ +
+ + setQuery(event.target.value)} placeholder={t('gitView.stashes.searchPlaceholder')} className="pl-9" /> +
+ +
+ {isLoading ? ( +
+ ) : filtered.length === 0 ? ( +
{query ? t('gitView.stashes.empty.search') : t('gitView.stashes.empty.list')}
+ ) : filtered.map((stash, index) => ( +
+
+

{stash.message || t('gitView.stashes.untitled')}

+

+ + + {index === 0 && !query.trim() ? t('gitView.stashes.latestLabel') : t('gitView.stashes.itemNumber', { number: index + 1 })} + + {stash.ref} + + {' · '}{stash.relativeTime} · {typeof fileCounts[stash.ref] === 'number' ? t('gitView.stashes.fileCount', { count: fileCounts[stash.ref] }) : t('gitView.stashes.fileCountLoading')} +

+
+
+ runStashAction(stash, 'apply')} disabled={isOperating}> + runStashAction(stash, 'pop')} disabled={isOperating}> + runStashAction(stash, 'drop')} disabled={isOperating} destructive> +
+
+ ))} +
+
+
+ ); +}; + +const StashIconButton: React.FC<{ label: string; loading: boolean; disabled: boolean; destructive?: boolean; onClick: () => void; children: React.ReactNode }> = ({ label, loading, disabled, destructive, onClick, children }) => ( + + + + + {label} + +); diff --git a/packages/ui/src/lib/api/types.ts b/packages/ui/src/lib/api/types.ts index f1730c86..e58d7c4e 100644 --- a/packages/ui/src/lib/api/types.ts +++ b/packages/ui/src/lib/api/types.ts @@ -209,6 +209,13 @@ export interface GitPullOptions { rebase?: boolean; } +export interface GitStashEntry { + ref: string; + message: string; + relativeTime: string; + hash: string; +} + export interface GitRemote { name: string; fetchUrl: string; @@ -429,6 +436,12 @@ export interface GitAPI { gitPush(directory: string, options?: { remote?: string; branch?: string; options?: string[] | Record }): Promise; gitPull(directory: string, options?: GitPullOptions): Promise; gitFetch(directory: string, options?: { remote?: string; branch?: string }): Promise<{ success: boolean }>; + listGitStashes(directory: string): Promise<{ stashes: GitStashEntry[] }>; + countGitStashFiles(directory: string, refs: string[]): Promise<{ counts: Record }>; + stashGitChanges(directory: string, options?: { message?: string }): Promise<{ success: boolean; created: boolean; message: string; output: string }>; + applyGitStash(directory: string, options: { ref: string }): Promise<{ success: boolean; ref: string }>; + popGitStash(directory: string, options: { ref: string }): Promise<{ success: boolean; ref: string }>; + dropGitStash(directory: string, options: { ref: string }): Promise<{ success: boolean; ref: string }>; checkoutBranch(directory: string, branch: string): Promise<{ success: boolean; branch: string }>; createBranch(directory: string, name: string, startPoint?: string): Promise<{ success: boolean; branch: string }>; renameBranch(directory: string, oldName: string, newName: string): Promise<{ success: boolean; branch: string }>; diff --git a/packages/ui/src/lib/gitApi.ts b/packages/ui/src/lib/gitApi.ts index 74516767..d4f9d266 100644 --- a/packages/ui/src/lib/gitApi.ts +++ b/packages/ui/src/lib/gitApi.ts @@ -546,6 +546,42 @@ export async function gitFetch( return gitHttp.gitFetch(directory, options); } +export async function listGitStashes(directory: string): Promise<{ stashes: import('./api/types').GitStashEntry[] }> { + const runtime = getRuntimeGit(); + if (runtime) return runtime.listGitStashes(directory); + return gitHttp.listGitStashes(directory); +} + +export async function countGitStashFiles(directory: string, refs: string[]): Promise<{ counts: Record }> { + const runtime = getRuntimeGit(); + if (runtime) return runtime.countGitStashFiles(directory, refs); + return gitHttp.countGitStashFiles(directory, refs); +} + +export async function stashGitChanges(directory: string, options: { message?: string } = {}): Promise<{ success: boolean; created: boolean; message: string; output: string }> { + const runtime = getRuntimeGit(); + if (runtime) return runtime.stashGitChanges(directory, options); + return gitHttp.stashGitChanges(directory, options); +} + +export async function applyGitStash(directory: string, options: { ref: string }): Promise<{ success: boolean; ref: string }> { + const runtime = getRuntimeGit(); + if (runtime) return runtime.applyGitStash(directory, options); + return gitHttp.applyGitStash(directory, options); +} + +export async function popGitStash(directory: string, options: { ref: string }): Promise<{ success: boolean; ref: string }> { + const runtime = getRuntimeGit(); + if (runtime) return runtime.popGitStash(directory, options); + return gitHttp.popGitStash(directory, options); +} + +export async function dropGitStash(directory: string, options: { ref: string }): Promise<{ success: boolean; ref: string }> { + const runtime = getRuntimeGit(); + if (runtime) return runtime.dropGitStash(directory, options); + return gitHttp.dropGitStash(directory, options); +} + export async function checkoutBranch(directory: string, branch: string): Promise<{ success: boolean; branch: string }> { const runtime = getRuntimeGit(); if (runtime) return runtime.checkoutBranch(directory, branch); diff --git a/packages/ui/src/lib/gitApiHttp.ts b/packages/ui/src/lib/gitApiHttp.ts index 756193fd..5e2571e5 100644 --- a/packages/ui/src/lib/gitApiHttp.ts +++ b/packages/ui/src/lib/gitApiHttp.ts @@ -21,6 +21,7 @@ import type { GitPushResult, GitPullResult, GitPullOptions, + GitStashEntry, GitLogOptions, GitLogResponse, GitCommitFilesResponse, @@ -549,6 +550,58 @@ export async function gitFetch( return response.json(); } +export async function listGitStashes(directory: string): Promise<{ stashes: GitStashEntry[] }> { + const response = await fetch(buildUrl(`${API_BASE}/stashes`, directory)); + if (!response.ok) { + const error = await response.json().catch(() => ({ error: response.statusText })); + throw new Error(error.error || 'Failed to list stashes'); + } + return response.json(); +} + +export async function countGitStashFiles(directory: string, refs: string[]): Promise<{ counts: Record }> { + const response = await fetch(buildUrl(`${API_BASE}/stashes/file-counts`, directory), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refs }), + }); + if (!response.ok) { + const error = await response.json().catch(() => ({ error: response.statusText })); + throw new Error(error.error || 'Failed to count stash files'); + } + return response.json(); +} + +export async function stashGitChanges(directory: string, options: { message?: string } = {}): Promise<{ success: boolean; created: boolean; message: string; output: string }> { + const response = await fetch(buildUrl(`${API_BASE}/stash`, directory), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(options), + }); + if (!response.ok) { + const error = await response.json().catch(() => ({ error: response.statusText })); + throw new Error(error.error || 'Failed to stash changes'); + } + return response.json(); +} + +const postStashRef = async (directory: string, path: string, options: { ref: string }): Promise<{ success: boolean; ref: string }> => { + const response = await fetch(buildUrl(`${API_BASE}/${path}`, directory), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(options), + }); + if (!response.ok) { + const error = await response.json().catch(() => ({ error: response.statusText })); + throw new Error(error.error || `Failed to ${path}`); + } + return response.json(); +}; + +export const applyGitStash = (directory: string, options: { ref: string }) => postStashRef(directory, 'stash/apply', options); +export const popGitStash = (directory: string, options: { ref: string }) => postStashRef(directory, 'stash/pop', options); +export const dropGitStash = (directory: string, options: { ref: string }) => postStashRef(directory, 'stash/drop', options); + export async function checkoutBranch(directory: string, branch: string): Promise<{ success: boolean; branch: string }> { const response = await fetch(buildUrl(`${API_BASE}/checkout`, directory), { method: 'POST', @@ -842,27 +895,13 @@ export async function stash( directory: string, options?: { message?: string; includeUntracked?: boolean } ): Promise<{ success: boolean }> { - const response = await fetch(buildUrl(`${API_BASE}/stash`, directory), { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(options || {}), - }); - if (!response.ok) { - const error = await response.json().catch(() => ({ error: response.statusText })); - throw new Error(error.error || 'Failed to stash'); - } - return response.json(); + await stashGitChanges(directory, { message: options?.message }); + return { success: true }; } export async function stashPop(directory: string): Promise<{ success: boolean }> { - const response = await fetch(buildUrl(`${API_BASE}/stash/pop`, directory), { - method: 'POST', - }); - if (!response.ok) { - const error = await response.json().catch(() => ({ error: response.statusText })); - throw new Error(error.error || 'Failed to pop stash'); - } - return response.json(); + await popGitStash(directory, { ref: 'stash@{0}' }); + return { success: true }; } export async function getConflictDetails(directory: string): Promise { diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index 4e8eaa0e..cfc621df 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -517,6 +517,34 @@ export const dict = { 'gitView.stash.stepStash': 'Stash your uncommitted changes', 'gitView.stash.thisWill': 'This will:', 'gitView.stash.title': 'Uncommitted Changes', + 'gitView.stashes.actions.apply': 'Apply', + 'gitView.stashes.actions.drop': 'Drop', + 'gitView.stashes.actions.pop': 'Pop', + 'gitView.stashes.actions.stashCurrent': 'Stash current changes', + 'gitView.stashes.actions.stashCurrentWithCount': 'Stash {count} files', + 'gitView.stashes.confirm.drop': 'Drop {ref}?', + 'gitView.stashes.description': 'Save, restore, and clean up Git stashes for this repository.', + 'gitView.stashes.empty.list': 'No stashes yet.', + 'gitView.stashes.empty.search': 'No matching stashes.', + 'gitView.stashes.includeUntrackedHint': 'Untracked files are included automatically.', + 'gitView.stashes.fileCount': '{count} files', + 'gitView.stashes.fileCountLoading': 'counting files...', + 'gitView.stashes.itemNumber': '#{number}', + 'gitView.stashes.latestLabel': 'Latest', + 'gitView.stashes.messagePlaceholder': 'Stash name', + 'gitView.stashes.searchPlaceholder': 'Search stashes', + 'gitView.stashes.title': 'Stashes', + 'gitView.stashes.toast.applyFailed': 'Failed to apply stash', + 'gitView.stashes.toast.applySuccess': 'Stash applied', + 'gitView.stashes.toast.createFailed': 'Failed to stash changes', + 'gitView.stashes.toast.created': 'Changes stashed', + 'gitView.stashes.toast.dropFailed': 'Failed to drop stash', + 'gitView.stashes.toast.dropSuccess': 'Stash dropped', + 'gitView.stashes.toast.loadFailed': 'Failed to load stashes', + 'gitView.stashes.toast.noChanges': 'No local changes to stash', + 'gitView.stashes.toast.popFailed': 'Failed to pop stash', + 'gitView.stashes.toast.popSuccess': 'Stash popped', + 'gitView.stashes.untitled': 'Untitled stash', 'gitView.sync.fetch': 'Fetch', 'gitView.sync.fetchFromRemote': 'Fetch from {name}', 'gitView.sync.fetchTooltip': 'Fetch from remote', diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index c4f04098..294d3a4b 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -518,6 +518,34 @@ export const dict: Record = { "gitView.stash.stepStash": "Guarda tus cambios sin commit", "gitView.stash.thisWill": "Esto hará:", "gitView.stash.title": "Cambios sin commit", + "gitView.stashes.actions.apply": "Apply", + "gitView.stashes.actions.drop": "Drop", + "gitView.stashes.actions.pop": "Pop", + "gitView.stashes.actions.stashCurrent": "Stash current changes", + "gitView.stashes.actions.stashCurrentWithCount": "Stash {count} files", + "gitView.stashes.confirm.drop": "Drop {ref}?", + "gitView.stashes.description": "Save, restore, and clean up Git stashes for this repository.", + "gitView.stashes.empty.list": "No stashes yet.", + "gitView.stashes.empty.search": "No matching stashes.", + "gitView.stashes.includeUntrackedHint": "Untracked files are included automatically.", + "gitView.stashes.fileCount": "{count} files", + "gitView.stashes.fileCountLoading": "counting files...", + "gitView.stashes.itemNumber": "#{number}", + "gitView.stashes.latestLabel": "Latest", + "gitView.stashes.messagePlaceholder": "Stash name", + "gitView.stashes.searchPlaceholder": "Search stashes", + "gitView.stashes.title": "Stashes", + "gitView.stashes.toast.applyFailed": "Failed to apply stash", + "gitView.stashes.toast.applySuccess": "Stash applied", + "gitView.stashes.toast.createFailed": "Failed to stash changes", + "gitView.stashes.toast.created": "Changes stashed", + "gitView.stashes.toast.dropFailed": "Failed to drop stash", + "gitView.stashes.toast.dropSuccess": "Stash dropped", + "gitView.stashes.toast.loadFailed": "Failed to load stashes", + "gitView.stashes.toast.noChanges": "No local changes to stash", + "gitView.stashes.toast.popFailed": "Failed to pop stash", + "gitView.stashes.toast.popSuccess": "Stash popped", + "gitView.stashes.untitled": "Untitled stash", "gitView.sync.fetch": "Fetch", "gitView.sync.fetchFromRemote": "Fetch de {name}", "gitView.sync.fetchTooltip": "Fetch del remoto", diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index b77c4e37..1d1bd0d3 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -518,6 +518,34 @@ export const dict: Record = { 'gitView.stash.stepStash': '커밋하지 않은 변경 사항 stash', 'gitView.stash.thisWill': '다음 작업을 수행합니다:', 'gitView.stash.title': '커밋하지 않은 변경 사항', + 'gitView.stashes.actions.apply': 'Apply', + 'gitView.stashes.actions.drop': 'Drop', + 'gitView.stashes.actions.pop': 'Pop', + 'gitView.stashes.actions.stashCurrent': 'Stash current changes', + 'gitView.stashes.actions.stashCurrentWithCount': 'Stash {count} files', + 'gitView.stashes.confirm.drop': 'Drop {ref}?', + 'gitView.stashes.description': 'Save, restore, and clean up Git stashes for this repository.', + 'gitView.stashes.empty.list': 'No stashes yet.', + 'gitView.stashes.empty.search': 'No matching stashes.', + 'gitView.stashes.includeUntrackedHint': 'Untracked files are included automatically.', + 'gitView.stashes.fileCount': '{count} files', + 'gitView.stashes.fileCountLoading': 'counting files...', + 'gitView.stashes.itemNumber': '#{number}', + 'gitView.stashes.latestLabel': 'Latest', + 'gitView.stashes.messagePlaceholder': 'Stash name', + 'gitView.stashes.searchPlaceholder': 'Search stashes', + 'gitView.stashes.title': 'Stashes', + 'gitView.stashes.toast.applyFailed': 'Failed to apply stash', + 'gitView.stashes.toast.applySuccess': 'Stash applied', + 'gitView.stashes.toast.createFailed': 'Failed to stash changes', + 'gitView.stashes.toast.created': 'Changes stashed', + 'gitView.stashes.toast.dropFailed': 'Failed to drop stash', + 'gitView.stashes.toast.dropSuccess': 'Stash dropped', + 'gitView.stashes.toast.loadFailed': 'Failed to load stashes', + 'gitView.stashes.toast.noChanges': 'No local changes to stash', + 'gitView.stashes.toast.popFailed': 'Failed to pop stash', + 'gitView.stashes.toast.popSuccess': 'Stash popped', + 'gitView.stashes.untitled': 'Untitled stash', 'gitView.sync.fetch': '가져오기', 'gitView.sync.fetchFromRemote': '{name}에서 가져오기', 'gitView.sync.fetchTooltip': '리모트에서 가져오기', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index def17628..c7b717cc 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -518,6 +518,34 @@ export const dict: Record = { "gitView.stash.stepStash": "Salva suas alterações sem commit", "gitView.stash.thisWill": "Isso fará:", "gitView.stash.title": "Alterações sem commit", + "gitView.stashes.actions.apply": "Apply", + "gitView.stashes.actions.drop": "Drop", + "gitView.stashes.actions.pop": "Pop", + "gitView.stashes.actions.stashCurrent": "Stash current changes", + "gitView.stashes.actions.stashCurrentWithCount": "Stash {count} files", + "gitView.stashes.confirm.drop": "Drop {ref}?", + "gitView.stashes.description": "Save, restore, and clean up Git stashes for this repository.", + "gitView.stashes.empty.list": "No stashes yet.", + "gitView.stashes.empty.search": "No matching stashes.", + "gitView.stashes.includeUntrackedHint": "Untracked files are included automatically.", + "gitView.stashes.fileCount": "{count} files", + "gitView.stashes.fileCountLoading": "counting files...", + "gitView.stashes.itemNumber": "#{number}", + "gitView.stashes.latestLabel": "Latest", + "gitView.stashes.messagePlaceholder": "Stash name", + "gitView.stashes.searchPlaceholder": "Search stashes", + "gitView.stashes.title": "Stashes", + "gitView.stashes.toast.applyFailed": "Failed to apply stash", + "gitView.stashes.toast.applySuccess": "Stash applied", + "gitView.stashes.toast.createFailed": "Failed to stash changes", + "gitView.stashes.toast.created": "Changes stashed", + "gitView.stashes.toast.dropFailed": "Failed to drop stash", + "gitView.stashes.toast.dropSuccess": "Stash dropped", + "gitView.stashes.toast.loadFailed": "Failed to load stashes", + "gitView.stashes.toast.noChanges": "No local changes to stash", + "gitView.stashes.toast.popFailed": "Failed to pop stash", + "gitView.stashes.toast.popSuccess": "Stash popped", + "gitView.stashes.untitled": "Untitled stash", "gitView.sync.fetch": "Fetch", "gitView.sync.fetchFromRemote": "Fetch de {name}", "gitView.sync.fetchTooltip": "Fetch do remoto", diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index ea30ca41..90456d70 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -518,6 +518,34 @@ export const dict: Record = { "gitView.stash.stepStash": "Сховати незакомічені зміни", "gitView.stash.thisWill": "Це:", "gitView.stash.title": "Незакомічені зміни", + "gitView.stashes.actions.apply": "Apply", + "gitView.stashes.actions.drop": "Drop", + "gitView.stashes.actions.pop": "Pop", + "gitView.stashes.actions.stashCurrent": "Сховати поточні зміни", + "gitView.stashes.actions.stashCurrentWithCount": "Сховати файлів: {count}", + "gitView.stashes.confirm.drop": "Видалити {ref}?", + "gitView.stashes.description": "Зберігайте, відновлюйте й очищайте Git stashes для цього репозиторію.", + "gitView.stashes.empty.list": "Stashes ще немає.", + "gitView.stashes.empty.search": "Збігів не знайдено.", + "gitView.stashes.includeUntrackedHint": "Untracked файли додаються автоматично.", + "gitView.stashes.fileCount": "Файлів: {count}", + "gitView.stashes.fileCountLoading": "рахуємо файли...", + "gitView.stashes.itemNumber": "#{number}", + "gitView.stashes.latestLabel": "Останній", + "gitView.stashes.messagePlaceholder": "Назва stash", + "gitView.stashes.searchPlaceholder": "Пошук stashes", + "gitView.stashes.title": "Stashes", + "gitView.stashes.toast.applyFailed": "Не вдалося застосувати stash", + "gitView.stashes.toast.applySuccess": "Stash застосовано", + "gitView.stashes.toast.createFailed": "Не вдалося сховати зміни", + "gitView.stashes.toast.created": "Зміни сховано", + "gitView.stashes.toast.dropFailed": "Не вдалося видалити stash", + "gitView.stashes.toast.dropSuccess": "Stash видалено", + "gitView.stashes.toast.loadFailed": "Не вдалося завантажити stashes", + "gitView.stashes.toast.noChanges": "Немає локальних змін для stash", + "gitView.stashes.toast.popFailed": "Не вдалося pop stash", + "gitView.stashes.toast.popSuccess": "Stash popped", + "gitView.stashes.untitled": "Stash без назви", "gitView.sync.fetch": "Fetch", "gitView.sync.fetchFromRemote": "Fetch з {name}", "gitView.sync.fetchTooltip": "Отримати з віддаленого", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index c23c4b2b..4d7021cf 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -518,6 +518,34 @@ export const dict: Record = { 'gitView.stash.stepStash': '储藏未提交的更改', 'gitView.stash.thisWill': '这将会:', 'gitView.stash.title': '未提交的更改', + 'gitView.stashes.actions.apply': 'Apply', + 'gitView.stashes.actions.drop': 'Drop', + 'gitView.stashes.actions.pop': 'Pop', + 'gitView.stashes.actions.stashCurrent': 'Stash current changes', + 'gitView.stashes.actions.stashCurrentWithCount': 'Stash {count} files', + 'gitView.stashes.confirm.drop': 'Drop {ref}?', + 'gitView.stashes.description': 'Save, restore, and clean up Git stashes for this repository.', + 'gitView.stashes.empty.list': 'No stashes yet.', + 'gitView.stashes.empty.search': 'No matching stashes.', + 'gitView.stashes.includeUntrackedHint': 'Untracked files are included automatically.', + 'gitView.stashes.fileCount': '{count} files', + 'gitView.stashes.fileCountLoading': 'counting files...', + 'gitView.stashes.itemNumber': '#{number}', + 'gitView.stashes.latestLabel': 'Latest', + 'gitView.stashes.messagePlaceholder': 'Stash name', + 'gitView.stashes.searchPlaceholder': 'Search stashes', + 'gitView.stashes.title': 'Stashes', + 'gitView.stashes.toast.applyFailed': 'Failed to apply stash', + 'gitView.stashes.toast.applySuccess': 'Stash applied', + 'gitView.stashes.toast.createFailed': 'Failed to stash changes', + 'gitView.stashes.toast.created': 'Changes stashed', + 'gitView.stashes.toast.dropFailed': 'Failed to drop stash', + 'gitView.stashes.toast.dropSuccess': 'Stash dropped', + 'gitView.stashes.toast.loadFailed': 'Failed to load stashes', + 'gitView.stashes.toast.noChanges': 'No local changes to stash', + 'gitView.stashes.toast.popFailed': 'Failed to pop stash', + 'gitView.stashes.toast.popSuccess': 'Stash popped', + 'gitView.stashes.untitled': 'Untitled stash', 'gitView.sync.fetch': '获取', 'gitView.sync.fetchFromRemote': '从 {name} 获取', 'gitView.sync.fetchTooltip': '从远程获取', diff --git a/packages/vscode/src/bridge-git-runtime.ts b/packages/vscode/src/bridge-git-runtime.ts index 8fb2c228..63d5889e 100644 --- a/packages/vscode/src/bridge-git-runtime.ts +++ b/packages/vscode/src/bridge-git-runtime.ts @@ -277,6 +277,42 @@ export async function handleStandardGitBridgeMessage(message: BridgeMessageInput return { id, type, success: true, data: result }; } + case 'api:git/stashes': { + const { directory } = (payload || {}) as { directory?: string }; + const dirError = requireDirectory(id, type, directory); + if (dirError) return dirError; + return { id, type, success: true, data: { stashes: await gitService.listGitStashes(directory!) } }; + } + + case 'api:git/stashes/file-counts': { + const { directory, refs } = (payload || {}) as { directory?: string; refs?: string[] }; + const dirError = requireDirectory(id, type, directory); + if (dirError) return dirError; + return { id, type, success: true, data: { counts: await gitService.countGitStashFiles(directory!, refs ?? []) } }; + } + + case 'api:git/stash': { + const { directory, message } = (payload || {}) as { directory?: string; message?: string }; + const dirError = requireDirectory(id, type, directory); + if (dirError) return dirError; + return { id, type, success: true, data: await gitService.stashGitChanges(directory!, { message }) }; + } + + case 'api:git/stash/apply': + case 'api:git/stash/pop': + case 'api:git/stash/drop': { + const { directory, ref } = (payload || {}) as { directory?: string; ref?: string }; + const dirError = requireDirectory(id, type, directory); + if (dirError) return dirError; + const stashRef = ref || 'stash@{0}'; + const data = type === 'api:git/stash/apply' + ? await gitService.applyGitStash(directory!, { ref: stashRef }) + : type === 'api:git/stash/pop' + ? await gitService.popGitStash(directory!, { ref: stashRef }) + : await gitService.dropGitStash(directory!, { ref: stashRef }); + return { id, type, success: true, data }; + } + case 'api:git/remotes': { const { directory, method, remote } = (payload || {}) as { directory?: string; @@ -357,26 +393,6 @@ export async function handleStandardGitBridgeMessage(message: BridgeMessageInput return { id, type, success: true, data: result }; } - case 'api:git/stash': { - const { directory, message, includeUntracked } = (payload || {}) as { - directory?: string; - message?: string; - includeUntracked?: boolean; - }; - const dirError = requireDirectory(id, type, directory); - if (dirError) return dirError; - const result = await gitService.stash(directory!, { message, includeUntracked }); - return { id, type, success: true, data: result }; - } - - case 'api:git/stash/pop': { - const { directory } = (payload || {}) as { directory?: string }; - const dirError = requireDirectory(id, type, directory); - if (dirError) return dirError; - const result = await gitService.stashPop(directory!); - return { id, type, success: true, data: result }; - } - case 'api:git/log': { const { directory, maxCount, from, to, file } = (payload || {}) as { directory?: string; diff --git a/packages/vscode/src/gitService.ts b/packages/vscode/src/gitService.ts index 82e71aa7..57db3610 100644 --- a/packages/vscode/src/gitService.ts +++ b/packages/vscode/src/gitService.ts @@ -2399,6 +2399,63 @@ export async function gitPull( }; } +export async function listGitStashes(directory: string): Promise> { + const result = await execGit(['stash', 'list', '--format=%gd%x1f%gs%x1f%cr%x1f%H'], directory); + if (result.exitCode !== 0) throw new Error(result.stderr.trim() || 'Failed to list stashes'); + return result.stdout.split('\n').map((line) => line.trim()).filter(Boolean).map((line) => { + const [ref = '', message = '', relativeTime = '', hash = ''] = line.split('\x1f'); + return { ref, message, relativeTime, hash }; + }).filter((entry) => entry.ref); +} + +export async function countGitStashFiles(directory: string, refs: string[]): Promise> { + const uniqueRefs = Array.from(new Set(refs.map((ref) => String(ref || '').trim()).filter(Boolean))); + const counts: Record = {}; + const concurrency = 4; + let cursor = 0; + + const worker = async () => { + while (cursor < uniqueRefs.length) { + const ref = uniqueRefs[cursor++]; + if (!ref) continue; + const names = await execGit(['stash', 'show', '--name-only', ref], directory); + counts[ref] = names.exitCode === 0 ? names.stdout.split('\n').map((line) => line.trim()).filter(Boolean).length : 0; + } + }; + + await Promise.all(Array.from({ length: Math.min(concurrency, uniqueRefs.length) }, () => worker())); + return counts; +} + +export async function stashGitChanges(directory: string, options: { message?: string } = {}): Promise<{ success: boolean; created: boolean; message: string; output: string }> { + const message = options.message?.trim() || `OpenChamber stash ${new Date().toISOString()}`; + const result = await execGit(['stash', 'push', '--include-untracked', '-m', message], directory); + if (result.exitCode !== 0) throw new Error(result.stderr.trim() || 'Failed to stash changes'); + const output = result.stdout.trim() || result.stderr.trim(); + return { success: true, created: !/no local changes/i.test(output), message, output }; +} + +export async function applyGitStash(directory: string, options: { ref: string }): Promise<{ success: boolean; ref: string }> { + const ref = options.ref || 'stash@{0}'; + const result = await execGit(['stash', 'apply', ref], directory); + if (result.exitCode !== 0) throw new Error(result.stderr.trim() || result.stdout.trim() || 'Failed to apply stash'); + return { success: true, ref }; +} + +export async function dropGitStash(directory: string, options: { ref: string }): Promise<{ success: boolean; ref: string }> { + const ref = options.ref || 'stash@{0}'; + const result = await execGit(['stash', 'drop', ref], directory); + if (result.exitCode !== 0) throw new Error(result.stderr.trim() || result.stdout.trim() || 'Failed to drop stash'); + return { success: true, ref }; +} + +export async function popGitStash(directory: string, options: { ref: string }): Promise<{ success: boolean; ref: string }> { + const ref = options.ref || 'stash@{0}'; + await applyGitStash(directory, { ref }); + await dropGitStash(directory, { ref }); + return { success: true, ref }; +} + /** * Fetch from remote */ diff --git a/packages/vscode/webview/api/git.ts b/packages/vscode/webview/api/git.ts index 4c6d2ee4..81084bad 100644 --- a/packages/vscode/webview/api/git.ts +++ b/packages/vscode/webview/api/git.ts @@ -210,6 +210,13 @@ export const createVSCodeGitAPI = (): GitAPI => ({ }); }, + listGitStashes: async (directory: string) => sendBridgeMessage('api:git/stashes', { directory }), + countGitStashFiles: async (directory: string, refs: string[]) => sendBridgeMessage('api:git/stashes/file-counts', { directory, refs }), + stashGitChanges: async (directory: string, options?: { message?: string }) => sendBridgeMessage('api:git/stash', { directory, message: options?.message }), + applyGitStash: async (directory: string, options: { ref: string }) => sendBridgeMessage('api:git/stash/apply', { directory, ref: options.ref }), + popGitStash: async (directory: string, options: { ref: string }) => sendBridgeMessage('api:git/stash/pop', { directory, ref: options.ref }), + dropGitStash: async (directory: string, options: { ref: string }) => sendBridgeMessage('api:git/stash/drop', { directory, ref: options.ref }), + checkoutBranch: async (directory: string, branch: string): Promise<{ success: boolean; branch: string }> => { return sendBridgeMessage<{ success: boolean; branch: string }>('api:git/checkout', { directory, diff --git a/packages/web/server/lib/git/DOCUMENTATION.md b/packages/web/server/lib/git/DOCUMENTATION.md index 5222288a..97154d43 100644 --- a/packages/web/server/lib/git/DOCUMENTATION.md +++ b/packages/web/server/lib/git/DOCUMENTATION.md @@ -69,8 +69,12 @@ The following functions are exported and used by the web server: - `getConflictDetails(directory)`: Get detailed conflict information including operation type, unmerged files, and diff. ### Stash Operations -- `stash(directory, options)`: Stash changes (supports message and includeUntracked options). -- `stashPop(directory)`: Pop and apply the most recent stash. +- `listStashes(directory)`: List stash entries with ref, message, relative time, and hash. +- `countStashFiles(directory, refs)`: Batch-count changed files for stash refs with bounded concurrency. +- `stashPush(directory, options)`: Stash changes, always including untracked files, with optional message. +- `stashApply(directory, options)`: Apply a stash by ref without removing it. +- `stashPop(directory, options)`: Apply a stash by ref and drop it only after a successful apply. +- `stashDrop(directory, options)`: Drop a stash by ref. ## Internal Helpers diff --git a/packages/web/server/lib/git/routes.js b/packages/web/server/lib/git/routes.js index 03fe983b..4aadeb58 100644 --- a/packages/web/server/lib/git/routes.js +++ b/packages/web/server/lib/git/routes.js @@ -336,6 +336,78 @@ export function registerGitRoutes(app) { } }); + app.get('/api/git/stashes', async (req, res) => { + const { listStashes } = await getGitLibraries(); + try { + const directory = req.query.directory; + if (!directory) return res.status(400).json({ error: 'directory parameter is required' }); + res.json({ stashes: await listStashes(directory) }); + } catch (error) { + console.error('Failed to list stashes:', error); + res.status(500).json({ error: error.message || 'Failed to list stashes' }); + } + }); + + app.post('/api/git/stashes/file-counts', async (req, res) => { + const { countStashFiles } = await getGitLibraries(); + try { + const directory = req.query.directory; + if (!directory) return res.status(400).json({ error: 'directory parameter is required' }); + res.json({ counts: await countStashFiles(directory, req.body?.refs) }); + } catch (error) { + console.error('Failed to count stash files:', error); + res.status(500).json({ error: error.message || 'Failed to count stash files' }); + } + }); + + app.post('/api/git/stash', async (req, res) => { + const { stashPush } = await getGitLibraries(); + try { + const directory = req.query.directory; + if (!directory) return res.status(400).json({ error: 'directory parameter is required' }); + res.json(await stashPush(directory, req.body)); + } catch (error) { + console.error('Failed to stash changes:', error); + res.status(500).json({ error: error.message || 'Failed to stash changes' }); + } + }); + + app.post('/api/git/stash/apply', async (req, res) => { + const { stashApply } = await getGitLibraries(); + try { + const directory = req.query.directory; + if (!directory) return res.status(400).json({ error: 'directory parameter is required' }); + res.json(await stashApply(directory, req.body)); + } catch (error) { + console.error('Failed to apply stash:', error); + res.status(500).json({ error: error.message || 'Failed to apply stash' }); + } + }); + + app.post('/api/git/stash/pop', async (req, res) => { + const { stashPop } = await getGitLibraries(); + try { + const directory = req.query.directory; + if (!directory) return res.status(400).json({ error: 'directory parameter is required' }); + res.json(await stashPop(directory, req.body)); + } catch (error) { + console.error('Failed to pop stash:', error); + res.status(500).json({ error: error.message || 'Failed to pop stash' }); + } + }); + + app.post('/api/git/stash/drop', async (req, res) => { + const { stashDrop } = await getGitLibraries(); + try { + const directory = req.query.directory; + if (!directory) return res.status(400).json({ error: 'directory parameter is required' }); + res.json(await stashDrop(directory, req.body)); + } catch (error) { + console.error('Failed to drop stash:', error); + res.status(500).json({ error: error.message || 'Failed to drop stash' }); + } + }); + app.post('/api/git/fetch', async (req, res) => { const { fetch: gitFetch } = await getGitLibraries(); try { @@ -501,38 +573,6 @@ export function registerGitRoutes(app) { } }); - app.post('/api/git/stash', async (req, res) => { - const { stash } = await getGitLibraries(); - try { - const directory = req.query.directory; - if (!directory) { - return res.status(400).json({ error: 'directory parameter is required' }); - } - - const result = await stash(directory, req.body); - res.json(result); - } catch (error) { - console.error('Failed to stash:', error); - res.status(500).json({ error: error.message || 'Failed to stash' }); - } - }); - - app.post('/api/git/stash/pop', async (req, res) => { - const { stashPop } = await getGitLibraries(); - try { - const directory = req.query.directory; - if (!directory) { - return res.status(400).json({ error: 'directory parameter is required' }); - } - - const result = await stashPop(directory); - res.json(result); - } catch (error) { - console.error('Failed to pop stash:', error); - res.status(500).json({ error: error.message || 'Failed to pop stash' }); - } - }); - app.post('/api/git/commit', async (req, res) => { const { commit } = await getGitLibraries(); try { diff --git a/packages/web/server/lib/git/service.js b/packages/web/server/lib/git/service.js index 2fdecd08..75ec1790 100644 --- a/packages/web/server/lib/git/service.js +++ b/packages/web/server/lib/git/service.js @@ -1828,6 +1828,78 @@ export async function pull(directory, options = {}) { } } +export async function listStashes(directory) { + const git = await createGit(directory); + const output = await git.raw(['stash', 'list', '--format=%gd%x1f%gs%x1f%cr%x1f%H']); + return String(output || '') + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => { + const [ref = '', message = '', relativeTime = '', hash = ''] = line.split('\x1f'); + return { ref, message, relativeTime, hash }; + }) + .filter((entry) => entry.ref); +} + +export async function countStashFiles(directory, refs = []) { + const git = await createGit(directory); + const uniqueRefs = Array.from(new Set((Array.isArray(refs) ? refs : []).map((ref) => String(ref || '').trim()).filter(Boolean))); + const counts = {}; + const concurrency = 4; + let cursor = 0; + + const worker = async () => { + while (cursor < uniqueRefs.length) { + const ref = uniqueRefs[cursor++]; + if (!ref) continue; + try { + const names = await git.raw(['stash', 'show', '--name-only', ref]); + counts[ref] = String(names || '').split('\n').map((line) => line.trim()).filter(Boolean).length; + } catch { + counts[ref] = 0; + } + } + }; + + await Promise.all(Array.from({ length: Math.min(concurrency, uniqueRefs.length) }, () => worker())); + return counts; +} +export async function stashPush(directory, options = {}) { + const git = await createGit(directory); + const message = typeof options.message === 'string' && options.message.trim() + ? options.message.trim() + : `OpenChamber stash ${new Date().toISOString()}`; + const output = await git.raw(['stash', 'push', '--include-untracked', '-m', message]); + return { + success: true, + created: !/no local changes/i.test(String(output || '')), + message, + output: String(output || '').trim(), + }; +} + +export async function stashApply(directory, options = {}) { + const git = await createGit(directory); + const ref = typeof options.ref === 'string' && options.ref.trim() ? options.ref.trim() : 'stash@{0}'; + await git.raw(['stash', 'apply', ref]); + return { success: true, ref }; +} + +export async function stashDrop(directory, options = {}) { + const git = await createGit(directory); + const ref = typeof options.ref === 'string' && options.ref.trim() ? options.ref.trim() : 'stash@{0}'; + await git.raw(['stash', 'drop', ref]); + return { success: true, ref }; +} + +export async function stashPop(directory, options = {}) { + const ref = typeof options.ref === 'string' && options.ref.trim() ? options.ref.trim() : 'stash@{0}'; + await stashApply(directory, { ref }); + await stashDrop(directory, { ref }); + return { success: true, ref }; +} + export async function push(directory, options = {}) { const git = await createGit(directory); @@ -3267,40 +3339,3 @@ export async function getConflictDetails(directory) { throw error; } } - -// ============== Stash Operations ============== - -export async function stash(directory, options = {}) { - const git = await createGit(directory); - - try { - const args = ['stash', 'push']; - - // Include untracked files by default - if (options.includeUntracked !== false) { - args.push('--include-untracked'); - } - - if (options.message) { - args.push('-m', options.message); - } - - await git.raw(args); - return { success: true }; - } catch (error) { - console.error('Failed to stash:', error); - throw error; - } -} - -export async function stashPop(directory) { - const git = await createGit(directory); - - try { - await git.raw(['stash', 'pop']); - return { success: true }; - } catch (error) { - console.error('Failed to pop stash:', error); - throw error; - } -} diff --git a/packages/web/src/api/git.ts b/packages/web/src/api/git.ts index ec0108bc..d57038e2 100644 --- a/packages/web/src/api/git.ts +++ b/packages/web/src/api/git.ts @@ -30,6 +30,12 @@ export const createWebGitAPI = (): GitAPI => ({ gitPush: gitApiHttp.gitPush, gitPull: gitApiHttp.gitPull, gitFetch: gitApiHttp.gitFetch, + listGitStashes: gitApiHttp.listGitStashes, + countGitStashFiles: gitApiHttp.countGitStashFiles, + stashGitChanges: gitApiHttp.stashGitChanges, + applyGitStash: gitApiHttp.applyGitStash, + popGitStash: gitApiHttp.popGitStash, + dropGitStash: gitApiHttp.dropGitStash, checkoutBranch: gitApiHttp.checkoutBranch, createBranch: gitApiHttp.createBranch, renameBranch: gitApiHttp.renameBranch,