diff --git a/packages/ui/src/components/views/GitView.tsx b/packages/ui/src/components/views/GitView.tsx index a8b43567..8a4e1108 100644 --- a/packages/ui/src/components/views/GitView.tsx +++ b/packages/ui/src/components/views/GitView.tsx @@ -68,6 +68,7 @@ type SyncAction = 'fetch' | 'pull' | 'push' | 'sync' | null; type CommitAction = 'commit' | 'commitAndPush' | null; type BranchOperation = 'merge' | 'rebase' | null; type ActionTab = 'commit' | 'branch' | 'pr'; +type GitLogDialogMode = 'history' | 'graph'; type HistoryBranchDivider = { insertBeforeIndex: number; branchName: string; @@ -313,9 +314,9 @@ export const GitView: React.FC = () => { const fetchStatus = useGitStore((state) => state.fetchStatus); const fetchBranches = useGitStore((state) => state.fetchBranches); const fetchLog = useGitStore((state) => state.fetchLog); + const setLogMaxCount = useGitStore((state) => state.setLogMaxCount); const fetchIdentity = useGitStore((state) => state.fetchIdentity); const prefetchDiffs = useGitStore((state) => state.prefetchDiffs); - const setLogMaxCount = useGitStore((state) => state.setLogMaxCount); const moveStatusPathsOptimistically = useGitStore((state) => state.moveStatusPathsOptimistically); const restoreStatus = useGitStore((state) => state.restoreStatus); const bumpIndexRevision = useGitStore((state) => state.bumpIndexRevision); @@ -626,7 +627,7 @@ export const GitView: React.FC = () => { const [remoteUrl, setRemoteUrl] = React.useState(null); const [gitmojiEmojis, setGitmojiEmojis] = React.useState([]); const [gitmojiSearch, setGitmojiSearch] = React.useState(''); - const [isHistoryDialogOpen, setIsHistoryDialogOpen] = React.useState(false); + const [gitLogDialogMode, setGitLogDialogMode] = React.useState(null); const actionTabItems = React.useMemo(() => [ { id: 'commit', label: t('gitView.tabs.commit'), icon: }, @@ -650,6 +651,9 @@ export const GitView: React.FC = () => { const [conflictDialogOpen, setConflictDialogOpen] = React.useState(false); const [conflictFiles, setConflictFiles] = React.useState([]); const [conflictOperation, setConflictOperation] = React.useState<'merge' | 'rebase'>('merge'); + const [graphLog, setGraphLog] = React.useState(null); + const [graphLogLoading, setGraphLogLoading] = React.useState(false); + const [graphLogMaxCount, setGraphLogMaxCount] = React.useState(100); // Conflict state persistence key const conflictStorageKey = React.useMemo(() => { @@ -1631,6 +1635,32 @@ export const GitView: React.FC = () => { cancelled = true; }; }, [baseBranch, currentBranch, currentDirectory, git, log, logMaxCountLocal]); + + // Clear graph log when directory changes + React.useEffect(() => { + setGraphLog(null); + }, [currentDirectory]); + + React.useEffect(() => { + if (gitLogDialogMode !== 'graph' || !currentDirectory) { + if (gitLogDialogMode !== 'graph') setGraphLog(null); + return; + } + let cancelled = false; + setGraphLogLoading(true); + git.getGitLog(currentDirectory, { maxCount: graphLogMaxCount, all: true }) + .then((result) => { + if (!cancelled) setGraphLog(result); + }) + .catch((err) => { + console.error('Failed to fetch graph log:', err); + }) + .finally(() => { + if (!cancelled) setGraphLogLoading(false); + }); + return () => { cancelled = true; }; + }, [gitLogDialogMode, currentDirectory, graphLogMaxCount, git]); + // Keep these sections stable in layout; individual cards render placeholders when unavailable. const moveChangePaths = React.useCallback((paths: string[], direction: GitIndexMutationDirection) => { @@ -1834,16 +1864,7 @@ export const GitView: React.FC = () => { setIsGitmojiPickerOpen(false); }, []); - const handleLogMaxCountChange = React.useCallback( - (count: number) => { - setLogMaxCountLocal(count); - if (currentDirectory) { - setLogMaxCount(currentDirectory, count); - fetchLog(currentDirectory, git, count); - } - }, - [currentDirectory, setLogMaxCount, fetchLog, git] - ); + const isUncommittedChangesError = React.useCallback((error: unknown): boolean => { const message = error instanceof Error ? error.message.toLowerCase() : ''; @@ -2207,6 +2228,61 @@ export const GitView: React.FC = () => { [bumpIndexRevision, currentDirectory, git, status, stashDialogOperation, stashDialogBranch, refreshStatusAndBranches, refreshLog, t] ); + const handleLogMaxCountChange = React.useCallback( + (count: number) => { + setLogMaxCountLocal(count); + if (currentDirectory) { + setLogMaxCount(currentDirectory, count); + fetchLog(currentDirectory, git, count); + } + }, + [currentDirectory, fetchLog, git, setLogMaxCount] + ); + + const handleGraphLogMaxCountChange = React.useCallback((count: number) => { + setGraphLogMaxCount(count); + }, []); + + const handleGraphActionSuccess = React.useCallback(() => { + setGitLogDialogMode(null); + if (currentDirectory) { + fetchStatus(currentDirectory, git); + fetchBranches(currentDirectory, git); + fetchLog(currentDirectory, git, logMaxCountLocal); + } + }, [currentDirectory, fetchStatus, fetchBranches, fetchLog, logMaxCountLocal, git]); + + const handleGraphConflict = React.useCallback((result: { + conflict: boolean; + conflictFiles?: string[]; + operation: 'cherry-pick' | 'revert' | 'merge' | 'rebase'; + }) => { + if (!result.conflict) return; + + if (result.operation === 'cherry-pick' || result.operation === 'revert') { + // Cherry-pick and revert conflicts are not supported by the shared ConflictDialog + // Show a toast with manual resolution instructions + toast.error(t('gitView.history.actions.conflictToastTitle'), { + description: t('gitView.history.actions.conflictToastDescription', { + files: result.conflictFiles?.join(', ') ?? 'unknown files', + }), + }); + if (currentDirectory) { + fetchStatus(currentDirectory, git); + fetchBranches(currentDirectory, git); + fetchLog(currentDirectory, git, logMaxCountLocal); + } + return; + } + + setConflictFiles(result.conflictFiles ?? []); + setConflictOperation(result.operation); + setConflictDialogOpen(true); + if (currentDirectory) { + persistConflictState(currentDirectory, result.conflictFiles ?? [], result.operation); + } + }, [t, setConflictFiles, setConflictOperation, setConflictDialogOpen, persistConflictState, currentDirectory, fetchStatus, fetchBranches, fetchLog, logMaxCountLocal, git]); + if (!currentDirectory) { return (
@@ -2282,7 +2358,8 @@ export const GitView: React.FC = () => { onSelectIdentity={handleApplyIdentity} isApplyingIdentity={isSettingIdentity} isWorktreeMode={!!worktreeMetadata} - onOpenHistory={() => setIsHistoryDialogOpen(true)} + onOpenHistory={() => setGitLogDialogMode('history')} + onOpenGraph={() => setGitLogDialogMode('graph')} onOpenStashes={openStashes} actionTabItems={actionTabItems} activeActionTab={actionTab} @@ -2421,20 +2498,23 @@ export const GitView: React.FC = () => {
- + { if (!open) setGitLogDialogMode(null); }}> - {t('gitView.history.title')} + + {gitLogDialogMode === 'graph' ? t('gitView.graph.title') : t('gitView.history.title')} + {t('gitView.history.dialogDescription')}
{ directory={currentDirectory ?? undefined} showHeader={false} contentMaxHeightClassName="h-full max-h-none" - branchDivider={historyBranchDivider} + branchDivider={gitLogDialogMode === 'graph' ? null : historyBranchDivider} + onConflict={gitLogDialogMode === 'graph' ? handleGraphConflict : undefined} + onActionSuccess={gitLogDialogMode === 'graph' ? handleGraphActionSuccess : undefined} />
diff --git a/packages/ui/src/components/views/git/GitGraphSegment.tsx b/packages/ui/src/components/views/git/GitGraphSegment.tsx new file mode 100644 index 00000000..d5035d4f --- /dev/null +++ b/packages/ui/src/components/views/git/GitGraphSegment.tsx @@ -0,0 +1,149 @@ +import React from 'react'; +import type { LanedCommit } from './gitGraph'; + +export const LANE_WIDTH = 8; + +interface GitGraphSegmentProps { + laned: LanedCommit; + totalLanes: number; + isExpanded: boolean; +} + +/** + * Renders the git graph lane column using an HTML Canvas element. + * + * Layout isolation pattern: + * A plain
(no replaced-element intrinsic sizing) owns all layout via + * `height: 100%` + self-stretch on the parent. The is absolutely + * positioned inside it (`inset: 0`) so it fills the div without affecting + * the flex layout measurement. Canvas intrinsic height (default 150px) never + * leaks into the row height calculation. + * + * useLayoutEffect reads the div's offsetHeight (stable, no replaced-element + * quirks) and sets the canvas drawing-buffer size + draws. + */ +export const GitGraphSegment: React.FC = ({ + laned, + totalLanes, + isExpanded, +}) => { + const { lane, color, connectors } = laned; + const effectiveLanes = Math.max(totalLanes, lane + 1); + const w = effectiveLanes * LANE_WIDTH + LANE_WIDTH / 2; + + const containerRef = React.useRef(null); + const canvasRef = React.useRef(null); + + React.useLayoutEffect(() => { + const container = containerRef.current; + const canvas = canvasRef.current; + if (!container || !canvas) return; + + const h = container.offsetHeight; + if (h === 0) return; + + const dpr = window.devicePixelRatio || 1; + canvas.width = w * dpr; + canvas.height = h * dpr; + + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + ctx.scale(dpr, dpr); + ctx.clearRect(0, 0, w, h); + + const dotCy = h / 2; + const dotCx = lane * LANE_WIDTH + LANE_WIDTH / 2; + + const styles = getComputedStyle(canvas); + const fallbackColor = styles.getPropertyValue('--surface-muted-foreground').trim() || styles.color; + + const resolveColor = (value: string): string => { + if (!value.startsWith('var(')) return value; + const varName = value.slice(4, -1).trim(); + return styles.getPropertyValue(varName).trim() || fallbackColor; + }; + + // Straight lines first so bezier curves render on top + const sorted = [...connectors].sort((a, b) => { + const isBezier = (t: string) => t === 'branch-out' || t === 'merge-in'; + return (isBezier(a.type) ? 1 : 0) - (isBezier(b.type) ? 1 : 0); + }); + + for (const seg of sorted) { + const x1 = seg.fromLane * LANE_WIDTH + LANE_WIDTH / 2; + const x2 = seg.toLane * LANE_WIDTH + LANE_WIDTH / 2; + const lineAlpha = seg.type === 'passing' + ? 0.72 + : seg.type === 'branch-out' || seg.type === 'merge-in' + ? 0.95 + : 1; + + ctx.beginPath(); + ctx.strokeStyle = resolveColor(seg.color); + ctx.globalAlpha = lineAlpha; + ctx.lineWidth = 1.25; + ctx.lineCap = 'round'; + + switch (seg.type) { + case 'passing': + case 'commit-lane': + ctx.moveTo(x1, 0); + ctx.lineTo(x1, h); + break; + case 'top-stub': + ctx.moveTo(x1, 0); + ctx.lineTo(x1, dotCy); + break; + case 'bottom-stub': + ctx.moveTo(x1, dotCy); + ctx.lineTo(x1, h); + break; + case 'branch-out': { + const mid = (dotCy + h) / 2; + ctx.moveTo(dotCx, dotCy); + ctx.bezierCurveTo(dotCx, mid, x2, mid, x2, h); + break; + } + case 'merge-in': { + const mid = dotCy / 2; + ctx.moveTo(x1, 0); + ctx.bezierCurveTo(x1, mid, dotCx, mid, dotCx, dotCy); + break; + } + default: + continue; + } + ctx.stroke(); + ctx.globalAlpha = 1; + } + + // Dot — drawn last, always on top + const bg = styles.getPropertyValue('--background').trim() || styles.getPropertyValue('--surface-background').trim(); + ctx.beginPath(); + ctx.arc(dotCx, dotCy, 4, 0, Math.PI * 2); + ctx.fillStyle = resolveColor(color); + ctx.fill(); + ctx.beginPath(); + ctx.arc(dotCx, dotCy, 5, 0, Math.PI * 2); + ctx.strokeStyle = bg || fallbackColor; + ctx.lineWidth = 2; + ctx.stroke(); + }, [laned, lane, color, connectors, totalLanes, isExpanded, w]); + + return ( + // This div owns the layout: height: 100% fills the self-stretch parent, + // width is fixed to the lane count. No replaced-element intrinsic sizing. +
+ {/* Canvas is absolutely inset so it matches the div exactly and never + contributes its own intrinsic height (150px default) to flex layout. */} + +
+ ); +}; diff --git a/packages/ui/src/components/views/git/GitHeader.tsx b/packages/ui/src/components/views/git/GitHeader.tsx index 830399c1..bb255a5b 100644 --- a/packages/ui/src/components/views/git/GitHeader.tsx +++ b/packages/ui/src/components/views/git/GitHeader.tsx @@ -43,6 +43,7 @@ interface GitHeaderProps { isApplyingIdentity: boolean; isWorktreeMode: boolean; onOpenHistory?: () => void; + onOpenGraph?: () => void; onOpenStashes?: () => void; actionTabItems?: SortableTabsStripItem[]; activeActionTab?: string; @@ -246,6 +247,7 @@ export const GitHeader: React.FC = ({ isApplyingIdentity, isWorktreeMode, onOpenHistory, + onOpenGraph, onOpenStashes, actionTabItems, activeActionTab, @@ -258,7 +260,7 @@ export const GitHeader: React.FC = ({ const managementButtons = (
- {onOpenHistory || onOpenStashes ? ( + {onOpenHistory || onOpenGraph || onOpenStashes ? ( @@ -267,13 +269,13 @@ export const GitHeader: React.FC = ({ variant="ghost" size="sm" className="h-8 w-8 px-0" - aria-label={t('gitView.history.title')} + aria-label={t('gitView.header.repositoryViews')} > - {t('gitView.history.title')} + {t('gitView.header.repositoryViews')} {onOpenHistory ? ( @@ -282,6 +284,12 @@ export const GitHeader: React.FC = ({ {t('gitView.history.title')} ) : null} + {onOpenGraph ? ( + + + {t('gitView.graph.title')} + + ) : null} {onOpenStashes ? ( diff --git a/packages/ui/src/components/views/git/HistoryCommitRow.tsx b/packages/ui/src/components/views/git/HistoryCommitRow.tsx index 3993299f..e4a28efc 100644 --- a/packages/ui/src/components/views/git/HistoryCommitRow.tsx +++ b/packages/ui/src/components/views/git/HistoryCommitRow.tsx @@ -1,5 +1,11 @@ import React from 'react'; import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { Icon } from "@/components/icon/Icon"; import { cn } from '@/lib/utils'; @@ -8,6 +14,10 @@ import { useI18n } from '@/lib/i18n'; import { getCommitFileDiff, type CommitFileDiffResponse } from '@/lib/gitApi'; import { PierreDiffViewer } from '@/components/views/PierreDiffViewer'; import { getLanguageFromExtension } from '@/lib/toolHelpers'; +import type { LanedCommit } from './gitGraph'; +import { GitGraphSegment } from './GitGraphSegment'; +import * as git from '@/lib/gitApi'; +import { toast } from '@/components/ui/toast'; const HISTORY_DIFF_REQUEST_TIMEOUT_MS = 15000; const HISTORY_DIFF_LARGE_CHANGED_LINES = 500; @@ -54,12 +64,17 @@ const trimHistoryDiffCache = (cache: Map): Map void; files: CommitFileEntry[]; isLoadingFiles: boolean; onCopyHash: (hash: string) => void; directory: string | undefined; + onConflict?: (result: { conflict: boolean; conflictFiles?: string[]; operation: 'cherry-pick' | 'revert' | 'merge' | 'rebase' }) => void; + onActionSuccess?: () => void; } function formatCommitDate(date: string) { @@ -93,21 +108,186 @@ function getChangeTypeColor(changeType: string) { } } +interface RefBadge { + label: string; + isHead: boolean; + isTag: boolean; +} + +function parseRefBadges(refs: string): RefBadge[] { + if (!refs) return []; + return refs + .split(',') + .map((r) => r.trim()) + .filter(Boolean) + .map((r) => { + const isHead = r.startsWith('HEAD ->'); + const label = isHead ? r.replace('HEAD -> ', '') : r.replace('tag: ', ''); + return { + label, + isHead, + isTag: r.startsWith('tag: '), + }; + }); +} + export const HistoryCommitRow = React.memo(({ entry, + mode = 'history', + laned, + totalLanes, isExpanded, onToggle, files, isLoadingFiles, onCopyHash, directory, + onConflict, + onActionSuccess, }: HistoryCommitRowProps) => { const { t } = useI18n(); + const isGraphMode = mode === 'graph'; + type PendingAction = + | 'checkout' | 'cherryPick' | 'revert' + | 'merge' | 'rebase' + | 'resetSoft' | 'resetMixed' | 'resetHard'; + + const [actionLoading, setActionLoading] = React.useState(null); + const [showCreateBranch, setShowCreateBranch] = React.useState(false); + const [newBranchName, setNewBranchName] = React.useState(''); + const [pendingAction, setPendingAction] = React.useState(null); const [openDiffPaths, setOpenDiffPaths] = React.useState>(new Set()); const [diffCache, setDiffCache] = React.useState>(new Map()); const [forceRenderLargePaths, setForceRenderLargePaths] = React.useState>(new Set()); + const handleCheckout = async () => { + if (!directory) return; + setActionLoading('checkout'); + try { + await git.checkoutCommit(directory, entry.hash); + toast.success(t('gitView.history.actions.detachedHead')); + onActionSuccess?.(); + } catch (e: unknown) { + toast.error(String((e as Error).message)); + } finally { + setActionLoading(null); + } + }; + + const handleCreateBranch = async () => { + if (!directory || !newBranchName.trim()) return; + setActionLoading('createBranch'); + try { + await git.createBranch(directory, newBranchName.trim(), entry.hash); + setShowCreateBranch(false); + setNewBranchName(''); + onActionSuccess?.(); + } catch (e: unknown) { + toast.error(String((e as Error).message)); + } finally { + setActionLoading(null); + } + }; + + const handleCherryPick = async () => { + if (!directory) return; + setActionLoading('cherryPick'); + try { + const result = await git.cherryPick(directory, entry.hash); + if (result.conflict) { + onConflict?.({ conflict: true, conflictFiles: result.conflictFiles, operation: 'cherry-pick' }); + } else { + onActionSuccess?.(); + } + } catch (e: unknown) { + toast.error(String((e as Error).message)); + } finally { + setActionLoading(null); + } + }; + + const handleRevert = async () => { + if (!directory) return; + setActionLoading('revert'); + try { + const result = await git.revertCommit(directory, entry.hash); + if (result.conflict) { + onConflict?.({ conflict: true, conflictFiles: result.conflictFiles, operation: 'revert' }); + } else { + onActionSuccess?.(); + } + } catch (e: unknown) { + toast.error(String((e as Error).message)); + } finally { + setActionLoading(null); + } + }; + + const handleReset = async (mode: 'soft' | 'mixed' | 'hard', force = false) => { + if (!directory || actionLoading !== null) return; + setActionLoading('reset'); + try { + await git.resetToCommit(directory, entry.hash, mode, force); + onActionSuccess?.(); + } catch (e: unknown) { + toast.error(String((e as Error).message)); + } finally { + setActionLoading(null); + } + }; + + // Single confirm handler dispatches to the right action based on pendingAction + const confirmPendingAction = async () => { + if (!pendingAction) return; + const action = pendingAction; + setPendingAction(null); + switch (action) { + case 'checkout': return handleCheckout(); + case 'cherryPick': return handleCherryPick(); + case 'revert': return handleRevert(); + case 'merge': return handleMerge(); + case 'rebase': return handleRebase(); + case 'resetSoft': return handleReset('soft'); + case 'resetMixed': return handleReset('mixed'); + case 'resetHard': return handleReset('hard', true); // force=true: user already confirmed + } + }; + + const handleMerge = async () => { + if (!directory) return; + setActionLoading('merge'); + try { + const result = await git.merge(directory, { branch: entry.hash }); + if (result.conflict) { + onConflict?.({ conflict: true, conflictFiles: result.conflictFiles, operation: 'merge' }); + } else { + onActionSuccess?.(); + } + } catch (e: unknown) { + toast.error(String((e as Error).message)); + } finally { + setActionLoading(null); + } + }; + + const handleRebase = async () => { + if (!directory) return; + setActionLoading('rebase'); + try { + const result = await git.rebase(directory, { onto: entry.hash }); + if (result.conflict) { + onConflict?.({ conflict: true, conflictFiles: result.conflictFiles, operation: 'rebase' }); + } else { + onActionSuccess?.(); + } + } catch (e: unknown) { + toast.error(String((e as Error).message)); + } finally { + setActionLoading(null); + } + }; + const loadFileDiff = React.useCallback(async (file: CommitFileEntry) => { const key = file.path; if (!directory) { @@ -169,15 +349,45 @@ export const HistoryCommitRow = React.memo(({ onClick={onToggle} className={cn( 'w-full flex items-start gap-3 px-3 py-2 text-left transition-colors', - isExpanded ? 'bg-sidebar/90' : 'hover:bg-sidebar/40' + isGraphMode + ? 'hover:bg-[var(--interactive-hover)]/40' + : isExpanded ? 'bg-sidebar/90' : 'hover:bg-sidebar/40' )} > -
+ {isGraphMode && laned && totalLanes !== undefined ? ( +
+ +
+ ) : ( +
+ )}
+ {/* Ref badges */} + {isGraphMode ? (() => { + const badges = parseRefBadges(entry.refs); + return badges.length > 0 ? ( +
+ {badges.map((badge) => ( + + {badge.label} + + ))} +
+ ) : null; + })() : null} +

{entry.message}

@@ -217,6 +427,132 @@ export const HistoryCommitRow = React.memo(({ {isExpanded && (
+ {/* Action buttons */} + {isGraphMode && pendingAction ? ( + /* Confirmation banner — replaces the button row while an action is pending */ +
+ + {t(`gitView.history.actions.${pendingAction}Confirm` as never)} + + + +
+ ) : isGraphMode ? ( +
+ + + {showCreateBranch ? ( +
+ setNewBranchName(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') void handleCreateBranch(); + if (e.key === 'Escape') { setShowCreateBranch(false); setNewBranchName(''); } + }} + placeholder={t('gitView.history.actions.createBranchPlaceholder')} + className="h-6 text-xs px-2 rounded border border-border/60 bg-background min-w-0 w-32" + /> + +
+ ) : ( + + )} + + + + + + {/* Reset: dropdown first to pick mode, then confirmation banner */} + + + + + + {(['soft', 'mixed', 'hard'] as const).map((mode) => ( + { + e.stopPropagation(); + setPendingAction(`reset${mode.charAt(0).toUpperCase() + mode.slice(1)}` as PendingAction); + }} + > + {t(`gitView.history.actions.reset${mode.charAt(0).toUpperCase() + mode.slice(1)}` as never)} + + ))} + + + + + + +
+ ) : null} + {isLoadingFiles ? (
diff --git a/packages/ui/src/components/views/git/HistorySection.tsx b/packages/ui/src/components/views/git/HistorySection.tsx index 1a6ac335..fd8e8e40 100644 --- a/packages/ui/src/components/views/git/HistorySection.tsx +++ b/packages/ui/src/components/views/git/HistorySection.tsx @@ -11,11 +11,14 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; +import { Button } from '@/components/ui/button'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { Icon } from "@/components/icon/Icon"; import { HistoryCommitRow } from './HistoryCommitRow'; import type { GitLogEntry, CommitFileEntry } from '@/lib/api/types'; import { useI18n } from '@/lib/i18n'; +import { assignLanes } from './gitGraph'; +import type { LanedCommit } from './gitGraph'; const LOG_SIZE_OPTIONS = [ { labelKey: 'gitView.history.logSize25', value: 25 }, @@ -24,6 +27,7 @@ const LOG_SIZE_OPTIONS = [ ]; interface HistorySectionProps { + mode?: 'history' | 'graph'; log: { all: GitLogEntry[] } | null; isLogLoading: boolean; logMaxCount: number; @@ -41,9 +45,12 @@ interface HistorySectionProps { branchName: string; direction: 'up' | 'down'; } | null; + onConflict?: (result: { conflict: boolean; conflictFiles?: string[]; operation: 'cherry-pick' | 'revert' | 'merge' | 'rebase' }) => void; + onActionSuccess?: () => void; } export const HistorySection: React.FC = ({ + mode = 'history', log, isLogLoading, logMaxCount, @@ -57,10 +64,29 @@ export const HistorySection: React.FC = ({ showHeader = true, contentMaxHeightClassName = 'max-h-[50vh]', branchDivider = null, + onConflict, + onActionSuccess, }) => { const { t } = useI18n(); const [isOpen, setIsOpen] = React.useState(true); + const isGraphMode = mode === 'graph'; + const laned: LanedCommit[] = React.useMemo( + () => (isGraphMode && log ? assignLanes(log.all) : []), + [isGraphMode, log] + ); + + const maxLanes = React.useMemo( + () => Math.max(1, ...laned.map((l) => l.lane + 1)), + [laned] + ); + + const lanedByHash = React.useMemo( + () => new Map(laned.map((l) => [l.commit.hash, l])), + [laned] + ); + + // Early return AFTER all hooks if (!log) { return null; } @@ -89,17 +115,44 @@ export const HistorySection: React.FC = ({ onToggleCommit(entry.hash)} files={commitFilesMap.get(entry.hash) ?? []} isLoadingFiles={loadingCommitHashes.has(entry.hash)} onCopyHash={onCopyHash} directory={directory} + onConflict={onConflict} + onActionSuccess={onActionSuccess} /> ))} ); + const loadMoreButton = log.all.length >= logMaxCount ? ( +
+ +
+ ) : null; + const content = ( {log.all.length === 0 ? ( @@ -109,30 +162,36 @@ export const HistorySection: React.FC = ({

) : hasSplitHistory && branchDivider ? ( -
- {topEntries.length > 0 ? ( -
- {renderCommitList(topEntries)} -
- ) : null} + <> +
+ {topEntries.length > 0 ? ( +
+ {renderCommitList(topEntries)} +
+ ) : null} -
- - - {branchDivider.branchName} - {dividerIcon} - - +
+ + + {branchDivider.branchName} + {dividerIcon} + + +
+ + {bottomEntries.length > 0 ? ( +
+ {renderCommitList(bottomEntries)} +
+ ) : null}
- - {bottomEntries.length > 0 ? ( -
- {renderCommitList(bottomEntries)} -
- ) : null} -
+ {loadMoreButton} + ) : ( - renderCommitList(log.all) + <> + {renderCommitList(log.all)} + {loadMoreButton} + )} ); diff --git a/packages/ui/src/components/views/git/gitGraph.test.ts b/packages/ui/src/components/views/git/gitGraph.test.ts new file mode 100644 index 00000000..ccffe4a5 --- /dev/null +++ b/packages/ui/src/components/views/git/gitGraph.test.ts @@ -0,0 +1,169 @@ +import { describe, expect, test } from 'bun:test'; +import { assignLanes } from './gitGraph'; +import type { GitLogEntry } from '@/lib/api/types'; + +function makeCommit(hash: string, parents: string[], refs = ''): GitLogEntry { + return { + hash, + parents, + date: '2024-01-01T00:00:00Z', + message: `commit ${hash}`, + refs, + body: '', + author_name: 'Test', + author_email: 'test@test.com', + filesChanged: 0, + insertions: 0, + deletions: 0, + }; +} + +describe('assignLanes', () => { + test('returns empty array for empty input', () => { + expect(assignLanes([])).toEqual([]); + }); + + test('assigns lane 0 to all commits in a linear history', () => { + const commits = [ + makeCommit('c', ['b']), + makeCommit('b', ['a']), + makeCommit('a', []), + ]; + const result = assignLanes(commits); + expect(result.every((r) => r.lane === 0)).toBe(true); + expect(result).toHaveLength(3); + }); + + test('assigns a color to every commit', () => { + const commits = [makeCommit('a', [])]; + const result = assignLanes(commits); + expect(result[0].color).toBeTruthy(); + expect(result[0].color).toContain('var(--'); + }); + + test('assigns separate lanes to two diverging branches', () => { + // main: c -> a; feat: b -> a; order newest first: c, b, a + const commits = [ + makeCommit('c', ['a']), + makeCommit('b', ['a']), + makeCommit('a', []), + ]; + const result = assignLanes(commits); + const cLane = result.find((r) => r.commit.hash === 'c')!.lane; + const bLane = result.find((r) => r.commit.hash === 'b')!.lane; + expect(cLane).not.toEqual(bLane); + // convergence commit 'a' should be on the lower lane + const aLane = result.find((r) => r.commit.hash === 'a')!.lane; + expect(aLane <= Math.min(cLane, bLane)).toBe(true); + }); + + test('handles a merge commit (2 parents)', () => { + const commits = [ + makeCommit('m', ['b', 'a']), + makeCommit('b', ['base']), + makeCommit('a', ['base']), + makeCommit('base', []), + ]; + const result = assignLanes(commits); + expect(result).toHaveLength(4); + result.forEach((r) => expect(r.lane >= 0).toBe(true)); + const baseResult = result.find((r) => r.commit.hash === 'base')!; + expect(baseResult.lane).toBe(0); + }); + + test('handles an octopus merge (3 parents)', () => { + const commits = [ + makeCommit('oct', ['p1', 'p2', 'p3']), + makeCommit('p1', ['base']), + makeCommit('p2', ['base']), + makeCommit('p3', ['base']), + makeCommit('base', []), + ]; + const result = assignLanes(commits); + expect(result).toHaveLength(5); + result.forEach((r) => expect(r.lane >= 0).toBe(true)); + }); + + test('root commit gets a top-stub connector', () => { + const commits = [ + makeCommit('b', ['a']), + makeCommit('a', []), + ]; + const result = assignLanes(commits); + const aResult = result.find((r) => r.commit.hash === 'a')!; + const topStub = aResult.connectors.find((c) => c.type === 'top-stub'); + expect(topStub).not.toBeNull(); + }); + + test('commit with both parent and child gets a commit-lane connector', () => { + const commits = [ + makeCommit('c', ['b']), + makeCommit('b', ['a']), + makeCommit('a', []), + ]; + const result = assignLanes(commits); + const bResult = result.find((r) => r.commit.hash === 'b')!; + const commitLane = bResult.connectors.find((c) => c.type === 'commit-lane'); + expect(commitLane).not.toBeNull(); + }); + + test('merge commit produces branch-out connectors for extra parents', () => { + const commits = [ + makeCommit('m', ['main', 'feat']), + makeCommit('main', ['base']), + makeCommit('feat', ['base']), + makeCommit('base', []), + ]; + const result = assignLanes(commits); + const mResult = result.find((r) => r.commit.hash === 'm')!; + const branchOut = mResult.connectors.filter((c) => c.type === 'branch-out'); + expect(branchOut.length).toBeGreaterThan(0); + }); + + test('converges two branches cleanly with merge-in connectors', () => { + const commits = [ + makeCommit('c', ['a']), + makeCommit('b', ['a']), + makeCommit('a', ['base']), + makeCommit('base', []), + ]; + const result = assignLanes(commits); + + // 'a' should be where the two lanes converge + const aResult = result.find((r) => r.commit.hash === 'a')!; + const mergeIns = aResult.connectors.filter((c) => c.type === 'merge-in'); + expect(mergeIns.length).toBeGreaterThan(0); + + // 'base' should only have one lane (the merged one) + const baseResult = result.find((r) => r.commit.hash === 'base')!; + const passingThroughBase = baseResult.connectors.filter((c) => c.type === 'passing'); + expect(passingThroughBase.length).toBe(0); + }); + + test('produces passing connectors for unrelated active lanes', () => { + const commits = [ + makeCommit('c', ['a']), + makeCommit('b', ['a']), + makeCommit('a', []), + ]; + const result = assignLanes(commits); + // While processing 'b', lane 0 (from c) is still active — should be 'passing' + const bResult = result.find((r) => r.commit.hash === 'b')!; + const passing = bResult.connectors.filter((c) => c.type === 'passing'); + expect(passing.length).toBeGreaterThan(0); + }); + + test('produces a bottom-stub connector when a new branch starts', () => { + const commits = [ + makeCommit('c', ['a']), + makeCommit('b', ['a']), + makeCommit('a', []), + ]; + const result = assignLanes(commits); + // 'c' is the first commit processed — no child above claims it. + // Its lane has a parent ('a') but no incoming. + const cResult = result.find((r) => r.commit.hash === 'c')!; + const bottomStub = cResult.connectors.find((c) => c.type === 'bottom-stub'); + expect(bottomStub).toBeTruthy(); + }); +}); diff --git a/packages/ui/src/components/views/git/gitGraph.ts b/packages/ui/src/components/views/git/gitGraph.ts new file mode 100644 index 00000000..36d77765 --- /dev/null +++ b/packages/ui/src/components/views/git/gitGraph.ts @@ -0,0 +1,171 @@ +import type { GitLogEntry } from '@/lib/api/types'; + +export type LaneColor = string; + +/** + * Describes one visible line/curve in a commit row's SVG. + * Each segment covers the FULL row height (y=0 to y=100%). + * + * Types: + * - 'passing' : straight vertical line, lane active but this row is not its commit + * - 'commit-lane': straight vertical line for this commit's lane (has both incoming and outgoing) + * - 'top-stub' : line from y=0 to dot-y only (branch HEAD — no child above) + * - 'bottom-stub': line from dot-y to y=100% only (root commit — nothing above) + * - 'branch-out' : bezier from (dot-x, dot-y) to (toLane-x, 100%) — new parent lane opens + * - 'merge-in' : bezier from (fromLane-x, 0) to (dot-x, dot-y) — lane converges here + */ +export interface ConnectorSegment { + fromLane: number; + toLane: number; + color: LaneColor; + type: 'passing' | 'commit-lane' | 'top-stub' | 'bottom-stub' | 'branch-out' | 'merge-in'; +} + +export interface LanedCommit { + commit: GitLogEntry; + lane: number; + color: LaneColor; + /** All visible line segments in this row's height. */ + connectors: ConnectorSegment[]; +} + +const LANE_COLORS: LaneColor[] = [ + 'var(--chart-1)', + 'var(--chart-2)', + 'var(--chart-3)', + 'var(--chart-4)', + 'var(--chart-5)', + 'var(--syntax-keyword)', + 'var(--syntax-string)', + 'var(--status-info)', +]; + +export function laneColor(lane: number): LaneColor { + return LANE_COLORS[lane % LANE_COLORS.length]; +} + +/** + * Assigns visual lanes to a list of commits (newest-first order). + * + * Greedy lane assignment algorithm (O(n × lanes) where lanes = max concurrent active branches): + * - activeLanes[i] holds the hash expected next on lane i (or null if free) + * - Each commit takes the lane that was waiting for it, or the next free lane + * - Merge commits open new lanes for additional parents + * - Connectors describe ALL visible lines in each row (both above and below the dot) + */ +export function assignLanes(commits: GitLogEntry[]): LanedCommit[] { + if (commits.length === 0) return []; + + // activeLanes[i] = hash of the next commit expected on lane i, or null if free + const activeLanes: Array = []; + + const result: LanedCommit[] = []; + + for (let i = 0; i < commits.length; i++) { + const commit = commits[i]; + + // Find all lanes waiting for this commit + const waitingLanes: number[] = []; + for (let li = 0; li < activeLanes.length; li++) { + if (activeLanes[li] === commit.hash) { + waitingLanes.push(li); + } + } + + // Use the first waiting lane as the commit's lane + let assignedLane = waitingLanes.length > 0 ? waitingLanes[0] : -1; + if (assignedLane === -1) { + // No existing lane claimed this commit; take the first free lane + const freeLane = activeLanes.indexOf(null); + if (freeLane !== -1) { + assignedLane = freeLane; + } else { + assignedLane = activeLanes.length; + activeLanes.push(null); + } + } + + // Mark other waiting lanes as converging here (will emit merge-in connectors) + const convergingLanes = waitingLanes.slice(1); + + const color = laneColor(assignedLane); + const hasIncoming = activeLanes[assignedLane] === commit.hash; + const hasParent = commit.parents.length > 0; + + // Update this commit's lane to point at its first parent + if (hasParent) { + activeLanes[assignedLane] = commit.parents[0]; + } else { + activeLanes[assignedLane] = null; + } + + // Open new lanes for additional parents (merge commits) + const extraParentLanes: number[] = []; + for (let p = 1; p < commit.parents.length; p++) { + const parentHash = commit.parents[p]; + // Check if another lane is already waiting for this parent + const existingLane = activeLanes.indexOf(parentHash); + if (existingLane !== -1) { + extraParentLanes.push(existingLane); + } else { + const freeLane = activeLanes.indexOf(null); + const newLane = freeLane !== -1 ? freeLane : activeLanes.length; + activeLanes[newLane] = parentHash; + if (newLane === activeLanes.length) activeLanes.push(parentHash); + extraParentLanes.push(newLane); + } + } + + // Build connectors: ALL visible line segments in this row + const connectors: ConnectorSegment[] = []; + + // This commit's own lane segment + if (hasIncoming && hasParent) { + connectors.push({ fromLane: assignedLane, toLane: assignedLane, color, type: 'commit-lane' }); + } else if (hasIncoming && !hasParent) { + connectors.push({ fromLane: assignedLane, toLane: assignedLane, color, type: 'top-stub' }); + } else if (!hasIncoming && hasParent) { + connectors.push({ fromLane: assignedLane, toLane: assignedLane, color, type: 'bottom-stub' }); + } + // else: orphan with no parent and no child — just the dot, no lines + + // Merge-in connectors for converging lanes + for (const convergingLane of convergingLanes) { + connectors.push({ + fromLane: convergingLane, + toLane: assignedLane, + color: laneColor(convergingLane), + type: 'merge-in', + }); + // Clear the converging lane + activeLanes[convergingLane] = null; + } + + // Branch-out segments for merge commit's extra parents + for (const extraLane of extraParentLanes) { + connectors.push({ + fromLane: assignedLane, + toLane: extraLane, + color: laneColor(extraLane), + type: 'branch-out', + }); + } + + // Passing-through lanes (active but not this commit's lane or extra parent lanes) + for (let lane = 0; lane < activeLanes.length; lane++) { + if (activeLanes[lane] === null) continue; + if (lane === assignedLane) continue; + if (extraParentLanes.includes(lane)) continue; + connectors.push({ + fromLane: lane, + toLane: lane, + color: laneColor(lane), + type: 'passing', + }); + } + + result.push({ commit, lane: assignedLane, color, connectors }); + } + + return result; +} diff --git a/packages/ui/src/lib/api/types.ts b/packages/ui/src/lib/api/types.ts index d4f332fa..b69e42e0 100644 --- a/packages/ui/src/lib/api/types.ts +++ b/packages/ui/src/lib/api/types.ts @@ -236,6 +236,37 @@ export interface GitMergeResult { conflictFiles?: string[]; } +export interface CheckoutCommitResponse { + success: boolean; +} + +export interface CherryPickRequest { + hash: string; +} +export interface CherryPickResponse { + success: boolean; + conflict?: boolean; + conflictFiles?: string[]; +} + +export interface RevertCommitRequest { + hash: string; +} +export interface RevertCommitResponse { + success: boolean; + conflict?: boolean; + conflictFiles?: string[]; +} + +export interface ResetToCommitRequest { + hash: string; + mode: 'soft' | 'mixed' | 'hard'; + force?: boolean; +} +export interface ResetToCommitResponse { + success: boolean; +} + export interface GitRebaseResult { success: boolean; conflict?: boolean; @@ -291,6 +322,7 @@ export interface GitLogEntry { filesChanged: number; insertions: number; deletions: number; + parents: string[]; } export interface GitLogResponse { @@ -404,6 +436,7 @@ export interface GitLogOptions { from?: string; to?: string; file?: string; + all?: boolean; } export interface GeneratedCommitMessage { @@ -484,6 +517,10 @@ export interface GitAPI { merge(directory: string, options: { branch: string }): Promise; abortMerge(directory: string): Promise<{ success: boolean }>; continueMerge(directory: string): Promise<{ success: boolean; conflict: boolean; conflictFiles?: string[] }>; + checkoutCommit(directory: string, hash: string): Promise; + cherryPick(directory: string, hash: string): Promise; + revertCommit(directory: string, hash: string): Promise; + resetToCommit(directory: string, hash: string, mode: 'soft' | 'mixed' | 'hard', force?: boolean): Promise; stash(directory: string, options?: { message?: string; includeUntracked?: boolean }): Promise<{ success: boolean }>; stashPop(directory: string): Promise<{ success: boolean }>; getConflictDetails(directory: string): Promise; diff --git a/packages/ui/src/lib/gitApi.ts b/packages/ui/src/lib/gitApi.ts index 85e47c6d..969daa51 100644 --- a/packages/ui/src/lib/gitApi.ts +++ b/packages/ui/src/lib/gitApi.ts @@ -784,6 +784,44 @@ export async function merge( return gitHttp.merge(directory, options); } +export async function checkoutCommit( + directory: string, + hash: string +): Promise { + const runtime = getRuntimeGit(); + if (runtime) return runtime.checkoutCommit(directory, hash); + return gitHttp.checkoutCommit(directory, hash); +} + +export async function cherryPick( + directory: string, + hash: string +): Promise { + const runtime = getRuntimeGit(); + if (runtime) return runtime.cherryPick(directory, hash); + return gitHttp.cherryPick(directory, hash); +} + +export async function revertCommit( + directory: string, + hash: string +): Promise { + const runtime = getRuntimeGit(); + if (runtime) return runtime.revertCommit(directory, hash); + return gitHttp.revertCommit(directory, hash); +} + +export async function resetToCommit( + directory: string, + hash: string, + mode: 'soft' | 'mixed' | 'hard', + force?: boolean +): Promise { + const runtime = getRuntimeGit(); + if (runtime) return runtime.resetToCommit(directory, hash, mode, force); + return gitHttp.resetToCommit(directory, hash, mode, force); +} + export async function abortMerge(directory: string): Promise<{ success: boolean }> { const runtime = getRuntimeGit(); if (runtime) return runtime.abortMerge(directory); diff --git a/packages/ui/src/lib/gitApiHttp.ts b/packages/ui/src/lib/gitApiHttp.ts index a3d4177b..c136059e 100644 --- a/packages/ui/src/lib/gitApiHttp.ts +++ b/packages/ui/src/lib/gitApiHttp.ts @@ -30,6 +30,10 @@ import type { GitIdentitySummary, DiscoveredGitCredential, MergeConflictDetails, + CheckoutCommitResponse, + CherryPickResponse, + RevertCommitResponse, + ResetToCommitResponse, } from './api/types'; declare global { @@ -711,6 +715,7 @@ export async function getGitLog( from: options.from, to: options.to, file: options.file, + all: options.all ? 'true' : undefined, }) ); if (!response.ok) { @@ -930,6 +935,72 @@ export async function merge( return response.json(); } +export async function checkoutCommit( + directory: string, + hash: string +): Promise { + const response = await fetch(buildUrl(`${API_BASE}/checkout-commit`, directory), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ hash }), + }); + if (!response.ok) { + const error = await response.json().catch(() => ({ error: response.statusText })); + throw new Error(error.error || 'Failed to checkout commit'); + } + return response.json(); +} + +export async function cherryPick( + directory: string, + hash: string +): Promise { + const response = await fetch(buildUrl(`${API_BASE}/cherry-pick`, directory), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ hash }), + }); + if (!response.ok) { + const error = await response.json().catch(() => ({ error: response.statusText })); + throw new Error(error.error || 'Failed to cherry-pick'); + } + return response.json(); +} + +export async function revertCommit( + directory: string, + hash: string +): Promise { + const response = await fetch(buildUrl(`${API_BASE}/revert-commit`, directory), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ hash }), + }); + if (!response.ok) { + const error = await response.json().catch(() => ({ error: response.statusText })); + throw new Error(error.error || 'Failed to revert commit'); + } + return response.json(); +} + +export async function resetToCommit( + directory: string, + hash: string, + mode: 'soft' | 'mixed' | 'hard', + force?: boolean +): Promise { + const response = await fetch(buildUrl(`${API_BASE}/reset-to-commit`, directory), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ hash, mode, force }), + }); + if (!response.ok) { + const error = await response.json().catch(() => ({ error: response.statusText })); + throw new Error(error.error || 'Failed to reset'); + } + return response.json(); +} + export async function abortMerge(directory: string): Promise<{ success: boolean }> { const response = await fetch(buildUrl(`${API_BASE}/merge/abort`, directory), { method: 'POST', diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index a749919c..8bbe1f4c 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -503,11 +503,39 @@ export const dict = { 'gitView.header.identityTooltip': 'Git identity', 'gitView.header.noIdentity': 'No identity', 'gitView.header.noProfiles': 'No profiles available to apply.', + 'gitView.header.repositoryViews': 'Repository views', 'gitView.header.removeRemoteAria': 'Remove Remote aria label', 'gitView.header.removeRemoteTitle': 'Remove Remote Title', 'gitView.header.upstreamSynced': 'synced', 'gitView.header.upstreamTooltip': 'Compared with {target}.', 'gitView.header.upstreamTooltipTracking': 'Compared with {target}. Primary sync badges still reflect {tracking}.', + 'gitView.history.actions.cancelButton': 'Cancel', + 'gitView.history.actions.checkout': 'Checkout', + 'gitView.history.actions.checkoutConfirm': 'Check out this commit as detached HEAD?', + 'gitView.history.actions.cherryPick': 'Cherry-pick', + 'gitView.history.actions.cherryPickConfirm': 'Cherry-pick this commit onto the current branch?', + 'gitView.history.actions.conflictToastDescription': 'Conflicts in: {files}. Resolve manually and commit, or abort with git cherry-pick/revert --abort.', + 'gitView.history.actions.conflictToastTitle': 'Conflict', + 'gitView.history.actions.confirmButton': 'Confirm', + 'gitView.history.actions.createBranch': 'Create branch here', + 'gitView.history.actions.createBranchConfirm': 'Create', + 'gitView.history.actions.createBranchPlaceholder': 'Branch name', + 'gitView.history.actions.detachedHead': 'Checked out (detached HEAD)', + 'gitView.history.actions.merge': 'Merge into current', + 'gitView.history.actions.mergeConfirm': 'Merge this commit into the current branch?', + 'gitView.history.actions.rebase': 'Rebase onto this', + 'gitView.history.actions.rebaseConfirm': 'Rebase current branch onto this commit?', + 'gitView.history.actions.reset': 'Reset...', + 'gitView.history.actions.resetHard': 'Hard — discard all changes', + 'gitView.history.actions.resetHardConfirm': 'Hard reset — HEAD moves, all uncommitted changes permanently discarded.', + 'gitView.history.actions.resetHardConfirmButton': 'Discard changes', + 'gitView.history.actions.resetMixed': 'Mixed — unstage changes', + 'gitView.history.actions.resetMixedConfirm': 'Mixed reset — HEAD moves, changes unstaged.', + 'gitView.history.actions.resetSoft': 'Soft — keep staged', + 'gitView.history.actions.resetSoftConfirm': 'Soft reset — HEAD moves, staged changes preserved.', + 'gitView.history.actions.revert': 'Revert', + 'gitView.history.actions.revertConfirm': 'Stage a revert of this commit?', + 'gitView.history.binary': 'Binary', 'gitView.history.binaryNoDiff': 'Binary file — no diff available', 'gitView.history.commitsPlaceholder': 'Commits Placeholder', @@ -517,6 +545,8 @@ export const dict = { 'gitView.history.largeDiffTitle': 'Large diff ({count} changed lines)', 'gitView.history.loadingDiff': 'Loading diff...', 'gitView.history.loadingFiles': 'Loading files...', + 'gitView.history.loadMore': 'Load more', + 'gitView.history.loadingMore': 'Loading...', 'gitView.history.logSize100': 'Log Size100', 'gitView.history.logSize25': 'Log Size25', 'gitView.history.logSize50': 'Log Size50', @@ -525,6 +555,7 @@ export const dict = { 'gitView.history.renamedNoDiff': 'Renamed file — diff not supported', 'gitView.history.renderDiffAnyway': 'Render anyway', 'gitView.history.title': 'History', + 'gitView.graph.title': 'Graph', 'gitView.integrate.checking': 'Checking…', 'gitView.integrate.cherryPickAbortedToast': 'Cherry Pick Aborted Toast', 'gitView.integrate.cherryPickConflictDescription': 'Cherry Pick Conflict Description', diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index 6cd4b556..e10ceb9d 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -504,11 +504,39 @@ export const dict: Record = { "gitView.header.identityTooltip": "Identidad de Git", "gitView.header.noIdentity": "Sin identidad", "gitView.header.noProfiles": "No hay perfiles disponibles para aplicar.", + "gitView.header.repositoryViews": "Vistas del repositorio", "gitView.header.removeRemoteAria": "Eliminar remoto", "gitView.header.removeRemoteTitle": "Eliminar remoto", "gitView.header.upstreamSynced": "sincronizado", "gitView.header.upstreamTooltip": "Comparado con {target}.", "gitView.header.upstreamTooltipTracking": "Comparado con {target}. Los indicadores principales de sincronización aún reflejan {tracking}.", + "gitView.history.actions.cancelButton": "Cancelar", + "gitView.history.actions.checkoutConfirm": "¿Cambiar a este commit como HEAD separado?", + "gitView.history.actions.cherryPickConfirm": "¿Aplicar cherry-pick de este commit sobre la rama actual?", + "gitView.history.actions.conflictToastDescription": "Conflictos en: {files}. Resuélvelos manualmente y haz commit, o aborta con git cherry-pick/revert --abort.", + "gitView.history.actions.conflictToastTitle": "Conflicto", + "gitView.history.actions.confirmButton": "Confirmar", + "gitView.history.actions.mergeConfirm": "¿Fusionar este commit en la rama actual?", + "gitView.history.actions.rebaseConfirm": "¿Rebasear la rama actual sobre este commit?", + "gitView.history.actions.resetHardConfirm": "Reset hard — HEAD se mueve y todos los cambios sin commit se descartan permanentemente.", + "gitView.history.actions.resetMixedConfirm": "Reset mixed — HEAD se mueve y los cambios quedan sin preparar.", + "gitView.history.actions.resetSoftConfirm": "Reset soft — HEAD se mueve y los cambios preparados se conservan.", + "gitView.history.actions.revertConfirm": "¿Preparar una reversión de este commit?", + "gitView.history.actions.checkout": "Cambiar a commit", + "gitView.history.actions.cherryPick": "Cherry-pick", + "gitView.history.actions.createBranch": "Crear rama aquí", + "gitView.history.actions.createBranchConfirm": "Crear", + "gitView.history.actions.createBranchPlaceholder": "Nombre de rama", + "gitView.history.actions.detachedHead": "Checkout realizado (HEAD separado)", + "gitView.history.actions.merge": "Fusionar en la actual", + "gitView.history.actions.rebase": "Rebasear sobre este", + "gitView.history.actions.reset": "Reset...", + "gitView.history.actions.resetHard": "Hard — descartar todos los cambios", + "gitView.history.actions.resetHardConfirmButton": "Descartar cambios", + "gitView.history.actions.resetMixed": "Mixed — quitar del stage", + "gitView.history.actions.resetSoft": "Soft — conservar staged", + "gitView.history.actions.revert": "Revertir", + "gitView.history.binary": "Binario", "gitView.history.binaryNoDiff": "Archivo binario — no hay diff disponible", "gitView.history.commitsPlaceholder": "Buscar commits...", @@ -518,6 +546,8 @@ export const dict: Record = { "gitView.history.largeDiffTitle": "Diff grande ({count} líneas cambiadas)", "gitView.history.loadingDiff": "Cargando diff...", "gitView.history.loadingFiles": "Cargando archivos...", + "gitView.history.loadMore": "Cargar más", + "gitView.history.loadingMore": "Cargando...", "gitView.history.logSize100": "100 commits", "gitView.history.logSize25": "25 commits", "gitView.history.logSize50": "50 commits", @@ -526,6 +556,7 @@ export const dict: Record = { "gitView.history.renamedNoDiff": "Archivo renombrado — diff no soportado", "gitView.history.renderDiffAnyway": "Renderizar igualmente", "gitView.history.title": "Historial", + "gitView.graph.title": "Grafo", "gitView.integrate.checking": "Verificando…", "gitView.integrate.cherryPickAbortedToast": "Cherry-pick abortado", "gitView.integrate.cherryPickConflictDescription": "Resuelve los conflictos de cherry-pick para continuar.", diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index 735043e1..cf5b903b 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -504,11 +504,39 @@ export const dict: Record = { 'gitView.header.identityTooltip': 'Git 인증 정보', 'gitView.header.noIdentity': '인증 정보 없음', 'gitView.header.noProfiles': '적용할 프로필 없음', + 'gitView.header.repositoryViews': '저장소 보기', 'gitView.header.removeRemoteAria': '리모트 제거', 'gitView.header.removeRemoteTitle': '리모트 제거', 'gitView.header.upstreamSynced': '동기화됨', 'gitView.header.upstreamTooltip': '{target}와 비교됨.', 'gitView.header.upstreamTooltipTracking': '{target}와 비교됨. 기본 동기화 배지는 계속 {tracking}을 반영합니다.', + 'gitView.history.actions.cancelButton': '취소', + 'gitView.history.actions.checkoutConfirm': '이 커밋을 detached HEAD로 체크아웃할까요?', + 'gitView.history.actions.cherryPickConfirm': '이 커밋을 현재 브랜치에 cherry-pick할까요?', + 'gitView.history.actions.conflictToastDescription': '충돌 파일: {files}. 수동으로 해결한 뒤 커밋하거나 git cherry-pick/revert --abort로 중단하세요.', + 'gitView.history.actions.conflictToastTitle': '충돌', + 'gitView.history.actions.confirmButton': '확인', + 'gitView.history.actions.mergeConfirm': '이 커밋을 현재 브랜치에 병합할까요?', + 'gitView.history.actions.rebaseConfirm': '현재 브랜치를 이 커밋 위로 rebase할까요?', + 'gitView.history.actions.resetHardConfirm': 'Hard reset — HEAD가 이동하고 커밋하지 않은 모든 변경 사항이 영구적으로 삭제됩니다.', + 'gitView.history.actions.resetMixedConfirm': 'Mixed reset — HEAD가 이동하고 변경 사항은 stage에서 내려갑니다.', + 'gitView.history.actions.resetSoftConfirm': 'Soft reset — HEAD가 이동하고 staged 변경 사항은 유지됩니다.', + 'gitView.history.actions.revertConfirm': '이 커밋의 revert 변경 사항을 stage할까요?', + 'gitView.history.actions.checkout': '체크아웃', + 'gitView.history.actions.cherryPick': 'Cherry-pick', + 'gitView.history.actions.createBranch': '여기에 브랜치 만들기', + 'gitView.history.actions.createBranchConfirm': '만들기', + 'gitView.history.actions.createBranchPlaceholder': '브랜치 이름', + 'gitView.history.actions.detachedHead': '체크아웃됨(detached HEAD)', + 'gitView.history.actions.merge': '현재 브랜치에 병합', + 'gitView.history.actions.rebase': '여기로 rebase', + 'gitView.history.actions.reset': 'Reset...', + 'gitView.history.actions.resetHard': 'Hard — 모든 변경 사항 삭제', + 'gitView.history.actions.resetHardConfirmButton': '변경 사항 삭제', + 'gitView.history.actions.resetMixed': 'Mixed — stage에서 내리기', + 'gitView.history.actions.resetSoft': 'Soft — staged 유지', + 'gitView.history.actions.revert': 'Revert', + 'gitView.history.binary': '바이너리', 'gitView.history.binaryNoDiff': '바이너리 파일 — diff 없음', 'gitView.history.commitsPlaceholder': '커밋 검색', @@ -518,6 +546,8 @@ export const dict: Record = { 'gitView.history.largeDiffTitle': '큰 diff({count}개 변경된 줄)', 'gitView.history.loadingDiff': 'diff 로드 중…', 'gitView.history.loadingFiles': '파일 로드 중…', + 'gitView.history.loadMore': '더 불러오기', + 'gitView.history.loadingMore': '로드 중...', 'gitView.history.logSize100': '최근 100개', 'gitView.history.logSize25': '최근 25개', 'gitView.history.logSize50': '최근 50개', @@ -526,6 +556,7 @@ export const dict: Record = { 'gitView.history.renamedNoDiff': '이름 변경된 파일 — diff 미지원', 'gitView.history.renderDiffAnyway': '그래도 렌더링', 'gitView.history.title': '히스토리', + 'gitView.graph.title': '그래프', 'gitView.integrate.checking': '확인 중…', 'gitView.integrate.cherryPickAbortedToast': 'Cherry-pick이 중단되었습니다', 'gitView.integrate.cherryPickConflictDescription': '충돌을 해결한 뒤 계속 진행하세요.', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index 4cee60ae..07736a35 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -1491,11 +1491,39 @@ export const dict: Record = { 'gitView.header.identityTooltip': 'Git identity', 'gitView.header.noIdentity': 'No identity', 'gitView.header.noProfiles': 'No profiles available to apply.', + 'gitView.header.repositoryViews': 'Widoki repozytorium', 'gitView.header.removeRemoteAria': 'Remove Remote aria label', 'gitView.header.removeRemoteTitle': 'Remove Remote Title', 'gitView.header.upstreamSynced': 'zsynchronizowano', 'gitView.header.upstreamTooltip': 'Porównano z {target}.', 'gitView.header.upstreamTooltipTracking': 'Porównano z {target}. Główne wskaźniki synchronizacji nadal odzwierciedlają {tracking}.', + 'gitView.history.actions.cancelButton': 'Anuluj', + 'gitView.history.actions.checkoutConfirm': 'Przełączyć na ten commit jako detached HEAD?', + 'gitView.history.actions.cherryPickConfirm': 'Wykonać cherry-pick tego commitu na bieżącą gałąź?', + 'gitView.history.actions.conflictToastDescription': 'Konflikty w: {files}. Rozwiąż je ręcznie i wykonaj commit albo przerwij przez git cherry-pick/revert --abort.', + 'gitView.history.actions.conflictToastTitle': 'Konflikt', + 'gitView.history.actions.confirmButton': 'Potwierdź', + 'gitView.history.actions.mergeConfirm': 'Scalić ten commit z bieżącą gałęzią?', + 'gitView.history.actions.rebaseConfirm': 'Przebazować bieżącą gałąź na ten commit?', + 'gitView.history.actions.resetHardConfirm': 'Hard reset — HEAD zostanie przesunięty, a wszystkie niezatwierdzone zmiany trwale usunięte.', + 'gitView.history.actions.resetMixedConfirm': 'Mixed reset — HEAD zostanie przesunięty, a zmiany zostaną usunięte ze stage.', + 'gitView.history.actions.resetSoftConfirm': 'Soft reset — HEAD zostanie przesunięty, a staged zmiany zostaną zachowane.', + 'gitView.history.actions.revertConfirm': 'Przygotować revert tego commitu?', + 'gitView.history.actions.checkout': 'Checkout', + 'gitView.history.actions.cherryPick': 'Cherry-pick', + 'gitView.history.actions.createBranch': 'Utwórz gałąź tutaj', + 'gitView.history.actions.createBranchConfirm': 'Utwórz', + 'gitView.history.actions.createBranchPlaceholder': 'Nazwa gałęzi', + 'gitView.history.actions.detachedHead': 'Checkout wykonany (detached HEAD)', + 'gitView.history.actions.merge': 'Scal z bieżącą', + 'gitView.history.actions.rebase': 'Rebase na ten commit', + 'gitView.history.actions.reset': 'Reset...', + 'gitView.history.actions.resetHard': 'Hard — usuń wszystkie zmiany', + 'gitView.history.actions.resetHardConfirmButton': 'Usuń zmiany', + 'gitView.history.actions.resetMixed': 'Mixed — usuń ze stage', + 'gitView.history.actions.resetSoft': 'Soft — zachowaj staged', + 'gitView.history.actions.revert': 'Revert', + 'gitView.history.binary': 'Binary', 'gitView.history.binaryNoDiff': 'Binary file — no diff available', 'gitView.history.commitsPlaceholder': 'Commits Placeholder', @@ -1506,6 +1534,8 @@ export const dict: Record = { 'gitView.history.largeDiffTitle': 'Large diff ({count} changed lines)', 'gitView.history.loadingDiff': 'Loading diff...', 'gitView.history.loadingFiles': 'Loading files...', + 'gitView.history.loadMore': 'Załaduj więcej', + 'gitView.history.loadingMore': 'Ładowanie...', 'gitView.history.logSize100': 'Log Size100', 'gitView.history.logSize25': 'Log Size25', 'gitView.history.logSize50': 'Log Size50', @@ -1514,6 +1544,7 @@ export const dict: Record = { 'gitView.history.renamedNoDiff': 'Renamed file — diff not supported', 'gitView.history.renderDiffAnyway': 'Render anyway', 'gitView.history.title': 'History', + 'gitView.graph.title': 'Graf', 'gitView.integrate.checking': 'Sprawdzanie…', 'gitView.integrate.cherryPickAbortedToast': 'Cherry-pick został przerwany', 'gitView.integrate.cherryPickConflictDescription': 'Wykryto konflikt podczas cherry-pick.', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index 3cbdc993..e0541818 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -504,11 +504,39 @@ export const dict: Record = { "gitView.header.identityTooltip": "Identidad de Git", "gitView.header.noIdentity": "Sem identidade", "gitView.header.noProfiles": "Não há perfiles disponíveis para aplicar.", + "gitView.header.repositoryViews": "Visualizações do repositório", "gitView.header.removeRemoteAria": "Excluir remoto", "gitView.header.removeRemoteTitle": "Excluir remoto", "gitView.header.upstreamSynced": "sincronizado", "gitView.header.upstreamTooltip": "Comparado com {target}.", "gitView.header.upstreamTooltipTracking": "Comparado com {target}. Os indicadores principais de sincronização ainda refletem {tracking}.", + "gitView.history.actions.cancelButton": "Cancelar", + "gitView.history.actions.checkoutConfirm": "Fazer checkout deste commit como HEAD destacado?", + "gitView.history.actions.cherryPickConfirm": "Aplicar cherry-pick deste commit na branch atual?", + "gitView.history.actions.conflictToastDescription": "Conflitos em: {files}. Resolva manualmente e faça commit, ou aborte com git cherry-pick/revert --abort.", + "gitView.history.actions.conflictToastTitle": "Conflito", + "gitView.history.actions.confirmButton": "Confirmar", + "gitView.history.actions.mergeConfirm": "Fazer merge deste commit na branch atual?", + "gitView.history.actions.rebaseConfirm": "Fazer rebase da branch atual neste commit?", + "gitView.history.actions.resetHardConfirm": "Reset hard — HEAD muda e todas as alterações sem commit são descartadas permanentemente.", + "gitView.history.actions.resetMixedConfirm": "Reset mixed — HEAD muda e as alterações ficam fora do stage.", + "gitView.history.actions.resetSoftConfirm": "Reset soft — HEAD muda e as alterações em stage são preservadas.", + "gitView.history.actions.revertConfirm": "Preparar uma reversão deste commit?", + "gitView.history.actions.checkout": "Checkout", + "gitView.history.actions.cherryPick": "Cherry-pick", + "gitView.history.actions.createBranch": "Criar branch aqui", + "gitView.history.actions.createBranchConfirm": "Criar", + "gitView.history.actions.createBranchPlaceholder": "Nome da branch", + "gitView.history.actions.detachedHead": "Checkout concluído (HEAD destacado)", + "gitView.history.actions.merge": "Merge na atual", + "gitView.history.actions.rebase": "Rebase neste commit", + "gitView.history.actions.reset": "Reset...", + "gitView.history.actions.resetHard": "Hard — descartar todas as alterações", + "gitView.history.actions.resetHardConfirmButton": "Descartar alterações", + "gitView.history.actions.resetMixed": "Mixed — remover do stage", + "gitView.history.actions.resetSoft": "Soft — manter em stage", + "gitView.history.actions.revert": "Reverter", + "gitView.history.binary": "Binario", "gitView.history.binaryNoDiff": "Arquivo binário — diff não disponível", "gitView.history.commitsPlaceholder": "Buscar commits...", @@ -518,6 +546,8 @@ export const dict: Record = { "gitView.history.largeDiffTitle": "Diff grande ({count} linhas alteradas)", "gitView.history.loadingDiff": "Carregando diff...", "gitView.history.loadingFiles": "Carregando arquivos...", + "gitView.history.loadMore": "Carregar mais", + "gitView.history.loadingMore": "Carregando...", "gitView.history.logSize100": "100 commits", "gitView.history.logSize25": "25 commits", "gitView.history.logSize50": "50 commits", @@ -526,6 +556,7 @@ export const dict: Record = { "gitView.history.renamedNoDiff": "Arquivo renomeado — diff não suportado", "gitView.history.renderDiffAnyway": "Renderizar mesmo assim", "gitView.history.title": "Histórico", + "gitView.graph.title": "Grafo", "gitView.integrate.checking": "Verificando…", "gitView.integrate.cherryPickAbortedToast": "Cherry-pick abortado", "gitView.integrate.cherryPickConflictDescription": "Resuelve os conflitos de cherry-pick para continuar.", diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index 1d7fb2ea..6fc5333c 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -504,11 +504,39 @@ export const dict: Record = { "gitView.header.identityTooltip": "Ідентичність Git", "gitView.header.noIdentity": "Ідентичність не вибрано", "gitView.header.noProfiles": "Немає доступних профілів для застосування.", + "gitView.header.repositoryViews": "Перегляди репозиторію", "gitView.header.removeRemoteAria": "Видалити remote", "gitView.header.removeRemoteTitle": "Видалити remote", "gitView.header.upstreamSynced": "синхронізовано", "gitView.header.upstreamTooltip": "Порівняно з {target}.", "gitView.header.upstreamTooltipTracking": "Порівняно з {target}. Основні індикатори синхронізації все ще відображають {tracking}.", + "gitView.history.actions.cancelButton": "Скасувати", + "gitView.history.actions.checkoutConfirm": "Перейти на цей коміт як detached HEAD?", + "gitView.history.actions.cherryPickConfirm": "Застосувати cherry-pick цього коміту до поточної гілки?", + "gitView.history.actions.conflictToastDescription": "Конфлікти у: {files}. Розв'яжіть їх вручну й закомітьте або скасуйте через git cherry-pick/revert --abort.", + "gitView.history.actions.conflictToastTitle": "Конфлікт", + "gitView.history.actions.confirmButton": "Підтвердити", + "gitView.history.actions.mergeConfirm": "Змерджити цей коміт у поточну гілку?", + "gitView.history.actions.rebaseConfirm": "Перебазувати поточну гілку на цей коміт?", + "gitView.history.actions.resetHardConfirm": "Hard reset — HEAD переміститься, усі незакомічені зміни буде остаточно втрачено.", + "gitView.history.actions.resetMixedConfirm": "Mixed reset — HEAD переміститься, зміни буде прибрано зі stage.", + "gitView.history.actions.resetSoftConfirm": "Soft reset — HEAD переміститься, staged-зміни збережуться.", + "gitView.history.actions.revertConfirm": "Підготувати revert цього коміту?", + "gitView.history.actions.checkout": "Checkout", + "gitView.history.actions.cherryPick": "Cherry-pick", + "gitView.history.actions.createBranch": "Створити гілку тут", + "gitView.history.actions.createBranchConfirm": "Створити", + "gitView.history.actions.createBranchPlaceholder": "Назва гілки", + "gitView.history.actions.detachedHead": "Checkout виконано (detached HEAD)", + "gitView.history.actions.merge": "Merge у поточну", + "gitView.history.actions.rebase": "Rebase на цей", + "gitView.history.actions.reset": "Reset...", + "gitView.history.actions.resetHard": "Hard — втратити всі зміни", + "gitView.history.actions.resetHardConfirmButton": "Втратити зміни", + "gitView.history.actions.resetMixed": "Mixed — прибрати зі stage", + "gitView.history.actions.resetSoft": "Soft — зберегти staged", + "gitView.history.actions.revert": "Revert", + "gitView.history.binary": "Бінарний", "gitView.history.binaryNoDiff": "Бінарний файл — diff недоступний", "gitView.history.commitsPlaceholder": "Пошук комітів", @@ -518,6 +546,8 @@ export const dict: Record = { "gitView.history.largeDiffTitle": "Великий diff ({count} змінених рядків)", "gitView.history.loadingDiff": "Завантаження diff...", "gitView.history.loadingFiles": "Завантаження файлів...", + "gitView.history.loadMore": "Завантажити ще", + "gitView.history.loadingMore": "Завантаження...", "gitView.history.logSize100": "Розмір журналу 100", "gitView.history.logSize25": "Розмір журналу 25", "gitView.history.logSize50": "Розмір журналу 50", @@ -526,6 +556,7 @@ export const dict: Record = { "gitView.history.renamedNoDiff": "Перейменований файл — diff не підтримується", "gitView.history.renderDiffAnyway": "Показати все одно", "gitView.history.title": "Історія", + "gitView.graph.title": "Граф", "gitView.integrate.checking": "Перевірка…", "gitView.integrate.cherryPickAbortedToast": "Cherry-pick перервано", "gitView.integrate.cherryPickConflictDescription": "Вирішіть конфлікти cherry-pick, щоб продовжити.", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index 6a952754..1a93edb2 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -504,11 +504,39 @@ export const dict: Record = { 'gitView.header.identityTooltip': 'Git 身份', 'gitView.header.noIdentity': '无身份', 'gitView.header.noProfiles': '没有可应用的配置。', + 'gitView.header.repositoryViews': '仓库视图', 'gitView.header.removeRemoteAria': '移除远程 {name}', 'gitView.header.removeRemoteTitle': '移除 {name}', 'gitView.header.upstreamSynced': '已同步', 'gitView.header.upstreamTooltip': '与 {target} 对比。', 'gitView.header.upstreamTooltipTracking': '与 {target} 对比。主要同步徽标仍然反映 {tracking}。', + 'gitView.history.actions.cancelButton': '取消', + 'gitView.history.actions.checkoutConfirm': '要将此提交检出为 detached HEAD 吗?', + 'gitView.history.actions.cherryPickConfirm': '要将此提交 cherry-pick 到当前分支吗?', + 'gitView.history.actions.conflictToastDescription': '冲突文件:{files}。请手动解决并提交,或使用 git cherry-pick/revert --abort 中止。', + 'gitView.history.actions.conflictToastTitle': '冲突', + 'gitView.history.actions.confirmButton': '确认', + 'gitView.history.actions.mergeConfirm': '要将此提交合并到当前分支吗?', + 'gitView.history.actions.rebaseConfirm': '要将当前分支变基到此提交吗?', + 'gitView.history.actions.resetHardConfirm': 'Hard reset — HEAD 会移动,所有未提交更改将被永久丢弃。', + 'gitView.history.actions.resetMixedConfirm': 'Mixed reset — HEAD 会移动,更改将取消暂存。', + 'gitView.history.actions.resetSoftConfirm': 'Soft reset — HEAD 会移动,已暂存更改会保留。', + 'gitView.history.actions.revertConfirm': '要暂存对此提交的 revert 吗?', + 'gitView.history.actions.checkout': '检出', + 'gitView.history.actions.cherryPick': 'Cherry-pick', + 'gitView.history.actions.createBranch': '在此创建分支', + 'gitView.history.actions.createBranchConfirm': '创建', + 'gitView.history.actions.createBranchPlaceholder': '分支名称', + 'gitView.history.actions.detachedHead': '已检出(detached HEAD)', + 'gitView.history.actions.merge': '合并到当前分支', + 'gitView.history.actions.rebase': '变基到此处', + 'gitView.history.actions.reset': 'Reset...', + 'gitView.history.actions.resetHard': 'Hard — 丢弃所有更改', + 'gitView.history.actions.resetHardConfirmButton': '丢弃更改', + 'gitView.history.actions.resetMixed': 'Mixed — 取消暂存更改', + 'gitView.history.actions.resetSoft': 'Soft — 保留暂存', + 'gitView.history.actions.revert': 'Revert', + 'gitView.history.binary': '二进制', 'gitView.history.binaryNoDiff': '二进制文件,无法显示差异', 'gitView.history.commitsPlaceholder': '提交数', @@ -518,6 +546,8 @@ export const dict: Record = { 'gitView.history.largeDiffTitle': '大型差异({count} 行变更)', 'gitView.history.loadingDiff': '正在加载差异...', 'gitView.history.loadingFiles': '正在加载文件...', + 'gitView.history.loadMore': '加载更多', + 'gitView.history.loadingMore': '加载中...', 'gitView.history.logSize100': '100 个提交', 'gitView.history.logSize25': '25 个提交', 'gitView.history.logSize50': '50 个提交', @@ -526,6 +556,7 @@ export const dict: Record = { 'gitView.history.renamedNoDiff': '已重命名文件,不支持显示差异', 'gitView.history.renderDiffAnyway': '仍然渲染', 'gitView.history.title': '历史', + 'gitView.graph.title': '图谱', 'gitView.integrate.checking': '检查中…', 'gitView.integrate.cherryPickAbortedToast': '已中止 cherry-pick', 'gitView.integrate.cherryPickConflictDescription': '请先解决冲突,然后继续。', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index 704cccc4..28541d41 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -504,12 +504,41 @@ export const dict: Record = { 'gitView.header.identityTooltip': 'Git 身分', 'gitView.header.noIdentity': '無身分', 'gitView.header.noProfiles': '沒有可套用的設定。', + 'gitView.header.repositoryViews': '儲存庫檢視', 'gitView.header.removeRemoteAria': '移除遠端 {name}', 'gitView.header.removeRemoteTitle': '移除 {name}', 'gitView.header.upstreamSynced': '已同步', 'gitView.header.upstreamTooltip': '與 {target} 比較。', 'gitView.header.upstreamTooltipTracking': '與 {target} 比較。主要同步徽章仍反映 {tracking}。', 'gitView.history.binary': '二進位', + 'gitView.history.actions.cancelButton': '取消', + 'gitView.history.actions.checkout': '簽出', + 'gitView.history.actions.checkoutConfirm': '要將此提交簽出為 detached HEAD 嗎?', + 'gitView.history.actions.cherryPick': 'Cherry-pick', + 'gitView.history.actions.cherryPickConfirm': '要將此提交 cherry-pick 到目前分支嗎?', + 'gitView.history.actions.conflictToastDescription': '衝突檔案:{files}。請手動解決並提交,或使用 git cherry-pick/revert --abort 中止。', + 'gitView.history.actions.conflictToastTitle': '衝突', + 'gitView.history.actions.confirmButton': '確認', + 'gitView.history.actions.createBranch': '在此建立分支', + 'gitView.history.actions.createBranchConfirm': '建立', + 'gitView.history.actions.createBranchPlaceholder': '分支名稱', + 'gitView.history.actions.detachedHead': '已簽出(detached HEAD)', + 'gitView.history.actions.merge': '合併到目前分支', + 'gitView.history.actions.mergeConfirm': '要將此提交合併到目前分支嗎?', + 'gitView.history.actions.rebase': 'Rebase 到此處', + 'gitView.history.actions.rebaseConfirm': '要將目前分支 rebase 到此提交嗎?', + 'gitView.history.actions.reset': 'Reset...', + 'gitView.history.actions.resetHard': 'Hard — 捨棄所有變更', + 'gitView.history.actions.resetHardConfirm': 'Hard reset — HEAD 會移動,所有未提交變更將永久捨棄。', + 'gitView.history.actions.resetHardConfirmButton': '捨棄變更', + 'gitView.history.actions.resetMixed': 'Mixed — 取消暫存變更', + 'gitView.history.actions.resetMixedConfirm': 'Mixed reset — HEAD 會移動,變更將取消暫存。', + 'gitView.history.actions.resetSoft': 'Soft — 保留暫存', + 'gitView.history.actions.resetSoftConfirm': 'Soft reset — HEAD 會移動,已暫存變更會保留。', + 'gitView.history.actions.revert': 'Revert', + 'gitView.history.actions.revertConfirm': '要暫存對此提交的 revert 嗎?', + 'gitView.history.loadMore': '載入更多', + 'gitView.history.loadingMore': '載入中...', 'gitView.history.binaryNoDiff': '二進位檔案 — 無可用 diff', 'gitView.history.commitsPlaceholder': '提交數', 'gitView.history.copySha': '複製 SHA', @@ -526,6 +555,7 @@ export const dict: Record = { 'gitView.history.renamedNoDiff': '已重新命名檔案 — 不支援 diff', 'gitView.history.renderDiffAnyway': '仍然渲染', 'gitView.history.title': '歷史紀錄', + 'gitView.graph.title': '圖譜', 'gitView.integrate.checking': '檢查中…', 'gitView.integrate.cherryPickAbortedToast': '已中止 cherry-pick', 'gitView.integrate.cherryPickConflictDescription': '請先解決衝突,然後繼續。', diff --git a/packages/vscode/src/bridge-git-runtime.test.js b/packages/vscode/src/bridge-git-runtime.test.js index ef523d55..3d5d79ac 100644 --- a/packages/vscode/src/bridge-git-runtime.test.js +++ b/packages/vscode/src/bridge-git-runtime.test.js @@ -3,6 +3,10 @@ import { beforeEach, describe, expect, it, mock } from 'bun:test'; const gitService = { stageGitFiles: mock(), unstageGitFiles: mock(), + checkoutCommit: mock(), + cherryPick: mock(), + revertCommit: mock(), + resetToCommit: mock(), }; mock.module('./gitService', () => gitService); @@ -13,6 +17,10 @@ describe('bridge git runtime index mutations', () => { beforeEach(() => { gitService.stageGitFiles.mockReset(); gitService.unstageGitFiles.mockReset(); + gitService.checkoutCommit.mockReset(); + gitService.cherryPick.mockReset(); + gitService.revertCommit.mockReset(); + gitService.resetToCommit.mockReset(); }); it('accepts legacy stage path payloads', async () => { @@ -69,4 +77,36 @@ describe('bridge git runtime index mutations', () => { expect(response?.success).toBe(false); expect(gitService.stageGitFiles).not.toHaveBeenCalled(); }); + + it('rejects invalid commit hashes before commit actions reach git service', async () => { + const checkoutResponse = await handleStandardGitBridgeMessage({ + id: '1', + type: 'api:git/checkout-commit', + payload: { directory: '/repo', hash: 'HEAD' }, + }); + const cherryPickResponse = await handleStandardGitBridgeMessage({ + id: '2', + type: 'api:git/cherry-pick', + payload: { directory: '/repo', hash: '--abort' }, + }); + const revertResponse = await handleStandardGitBridgeMessage({ + id: '3', + type: 'api:git/revert-commit', + payload: { directory: '/repo', hash: '--continue' }, + }); + const resetResponse = await handleStandardGitBridgeMessage({ + id: '4', + type: 'api:git/reset-to-commit', + payload: { directory: '/repo', hash: '--hard', mode: 'mixed' }, + }); + + expect(checkoutResponse).toEqual({ id: '1', type: 'api:git/checkout-commit', success: false, error: 'Invalid commit hash' }); + expect(cherryPickResponse).toEqual({ id: '2', type: 'api:git/cherry-pick', success: false, error: 'Invalid commit hash' }); + expect(revertResponse).toEqual({ id: '3', type: 'api:git/revert-commit', success: false, error: 'Invalid commit hash' }); + expect(resetResponse).toEqual({ id: '4', type: 'api:git/reset-to-commit', success: false, error: 'Invalid commit hash' }); + expect(gitService.checkoutCommit).not.toHaveBeenCalled(); + expect(gitService.cherryPick).not.toHaveBeenCalled(); + expect(gitService.revertCommit).not.toHaveBeenCalled(); + expect(gitService.resetToCommit).not.toHaveBeenCalled(); + }); }); diff --git a/packages/vscode/src/bridge-git-runtime.ts b/packages/vscode/src/bridge-git-runtime.ts index 6e93470d..e02cd5d4 100644 --- a/packages/vscode/src/bridge-git-runtime.ts +++ b/packages/vscode/src/bridge-git-runtime.ts @@ -14,6 +14,10 @@ const requireDirectory = (id: string, type: string, directory?: string): BridgeR return null; }; +const isValidCommitHash = (hash: string | undefined): hash is string => ( + typeof hash === 'string' && /^[0-9a-fA-F]{7,40}$/.test(hash) +); + export async function handleStandardGitBridgeMessage(message: BridgeMessageInput): Promise { const { id, type, payload } = message; @@ -416,17 +420,70 @@ export async function handleStandardGitBridgeMessage(message: BridgeMessageInput return { id, type, success: true, data: result }; } + case 'api:git/checkout-commit': { + const { directory, hash } = (payload || {}) as { directory?: string; hash?: string }; + const dirError = requireDirectory(id, type, directory); + if (dirError) return dirError; + if (!isValidCommitHash(hash)) { + return { id, type, success: false, error: 'Invalid commit hash' }; + } + const result = await gitService.checkoutCommit(directory!, hash); + return { id, type, success: true, data: result }; + } + + case 'api:git/cherry-pick': { + const { directory, hash } = (payload || {}) as { directory?: string; hash?: string }; + const dirError = requireDirectory(id, type, directory); + if (dirError) return dirError; + if (!isValidCommitHash(hash)) { + return { id, type, success: false, error: 'Invalid commit hash' }; + } + const result = await gitService.cherryPick(directory!, hash); + return { id, type, success: true, data: result }; + } + + case 'api:git/revert-commit': { + const { directory, hash } = (payload || {}) as { directory?: string; hash?: string }; + const dirError = requireDirectory(id, type, directory); + if (dirError) return dirError; + if (!isValidCommitHash(hash)) { + return { id, type, success: false, error: 'Invalid commit hash' }; + } + const result = await gitService.revertCommit(directory!, hash); + return { id, type, success: true, data: result }; + } + + case 'api:git/reset-to-commit': { + const { directory, hash, mode, force } = (payload || {}) as { + directory?: string; + hash?: string; + mode?: 'soft' | 'mixed' | 'hard'; + force?: boolean; + }; + const dirError = requireDirectory(id, type, directory); + if (dirError) return dirError; + if (!isValidCommitHash(hash)) { + return { id, type, success: false, error: 'Invalid commit hash' }; + } + if (!mode || !['soft', 'mixed', 'hard'].includes(mode)) { + return { id, type, success: false, error: 'mode must be soft, mixed, or hard' }; + } + const result = await gitService.resetToCommit(directory!, hash, mode, force); + return { id, type, success: true, data: result }; + } + case 'api:git/log': { - const { directory, maxCount, from, to, file } = (payload || {}) as { + const { directory, maxCount, from, to, file, all } = (payload || {}) as { directory?: string; maxCount?: number; from?: string; to?: string; file?: string; + all?: boolean; }; const dirError = requireDirectory(id, type, directory); if (dirError) return dirError; - const result = await gitService.getGitLog(directory!, { maxCount, from, to, file }); + const result = await gitService.getGitLog(directory!, { maxCount, from, to, file, all }); return { id, type, success: true, data: result }; } diff --git a/packages/vscode/src/gitService.ts b/packages/vscode/src/gitService.ts index af245c5a..e9acb694 100644 --- a/packages/vscode/src/gitService.ts +++ b/packages/vscode/src/gitService.ts @@ -279,6 +279,10 @@ async function execGit(args: string[], cwd: string): Promise<{ stdout: string; s }); } +function isValidCommitHash(hash: string): boolean { + return /^[0-9a-fA-F]{7,40}$/.test(hash); +} + function extractGitStatusPath(status: string, pathPart: string): string { if ((status === 'R' || status === 'C') && pathPart.includes('\t')) { return pathPart.split('\t').pop() || pathPart; @@ -2678,6 +2682,7 @@ export interface GitLogEntry { filesChanged: number; insertions: number; deletions: number; + parents: string[]; } /** @@ -2713,10 +2718,73 @@ async function resolveBaseRefForLog( */ export async function getGitLog( directory: string, - options?: { maxCount?: number; from?: string; to?: string; file?: string } + options?: { maxCount?: number; from?: string; to?: string; file?: string; all?: boolean } ): Promise<{ all: GitLogEntry[]; latest: GitLogEntry | null; total: number }> { const maxCount = options?.maxCount || 50; + if (options?.all) { + const logArgs = [ + 'log', + `--max-count=${maxCount}`, + '--all', + '--topo-order', + '--date=iso', + '--pretty=format:%x1e%H%x1f%P%x1f%an%x1f%ae%x1f%ad%x1f%s%x1f%D', + '--shortstat', + ]; + + const result = await execGit(logArgs, directory); + + if (result.exitCode !== 0) { + throw new Error(result.stderr.trim() || result.stdout.trim() || 'Failed to get git log'); + } + + const records = result.stdout + .split('\x1e') + .map((e) => e.trim()) + .filter(Boolean); + + const entries: GitLogEntry[] = []; + for (const record of records) { + const lines = record.split('\n').filter((l) => l.trim().length > 0); + const header = lines.shift() || ''; + const [hash, parentsRaw, author_name, author_email, date, message, refsRaw] = + header.split('\x1f'); + if (!hash) continue; + + const parents = parentsRaw ? parentsRaw.trim().split(' ').filter(Boolean) : []; + const refs = refsRaw ? refsRaw.trim() : ''; + + let filesChanged = 0; + let insertions = 0; + let deletions = 0; + for (const line of lines) { + const filesMatch = line.match(/(\d+)\s+files?\s+changed/); + const insertMatch = line.match(/(\d+)\s+insertions?\(\+\)/); + const deleteMatch = line.match(/(\d+)\s+deletions?\(-\)/); + if (filesMatch) filesChanged = parseInt(filesMatch[1], 10); + if (insertMatch) insertions = parseInt(insertMatch[1], 10); + if (deleteMatch) deletions = parseInt(deleteMatch[1], 10); + } + + entries.push({ + hash, + date: date || '', + message: message || '', + refs, + body: '', + author_name: author_name || '', + author_email: author_email || '', + filesChanged, + insertions, + deletions, + parents, + }); + } + + return { all: entries, latest: entries[0] || null, total: entries.length }; + } + // Prefer the local ref; fall back to origin/ only when the local ref // cannot be resolved (e.g. user has never checked out the base branch). const resolvedFrom = await resolveBaseRefForLog(options?.from, directory); @@ -2724,10 +2792,11 @@ export async function getGitLog( const args = [ 'log', `--max-count=${maxCount}`, - '--format=%H|%aI|%s|%D|%b|%an|%ae', + '--date=iso', + '--pretty=format:%x1e%H%x1f%P%x1f%an%x1f%ae%x1f%ad%x1f%s%x1f%D', '--shortstat', ]; - + if (resolvedFrom && options?.to) { args.push(`${resolvedFrom}..${options.to}`); } else if (resolvedFrom) { @@ -2735,51 +2804,70 @@ export async function getGitLog( } else if (options?.to) { args.push(options.to); } - + if (options?.file) { args.push('--', options.file); } const result = await execGit(args, directory); - + if (result.exitCode !== 0) { throw new Error(result.stderr.trim() || result.stdout.trim() || 'Failed to get git log'); } - const entries: GitLogEntry[] = []; - const lines = result.stdout.split('\n'); - let current: Partial | null = null; + const records = result.stdout + .split('\x1e') + .map((entry) => entry.trim()) + .filter(Boolean); - for (const line of lines) { - if (line.includes('|') && !line.startsWith(' ')) { - if (current?.hash) { - entries.push(current as GitLogEntry); - } - const parts = line.split('|'); - current = { - hash: parts[0] || '', - date: parts[1] || '', - message: parts[2] || '', - refs: parts[3] || '', - body: parts[4] || '', - author_name: parts[5] || '', - author_email: parts[6] || '', - filesChanged: 0, - insertions: 0, - deletions: 0, - }; - } else if (current && line.includes('file')) { - const statsMatch = line.match(/(\d+)\s+files?\s+changed(?:,\s+(\d+)\s+insertions?)?(?:,\s+(\d+)\s+deletions?)?/); - if (statsMatch) { - current.filesChanged = parseInt(statsMatch[1] || '0', 10); - current.insertions = parseInt(statsMatch[2] || '0', 10); - current.deletions = parseInt(statsMatch[3] || '0', 10); - } + const statsMap = new Map(); + + for (const record of records) { + const lines = record.split('\n').filter((line) => line.trim().length > 0); + const header = lines.shift() || ''; + const [hash, parentsRaw] = header.split('\x1f'); + const parents = parentsRaw ? parentsRaw.trim().split(' ').filter(Boolean) : []; + if (!hash) continue; + + let filesChanged = 0; + let insertions = 0; + let deletions = 0; + + for (const line of lines) { + const filesMatch = line.match(/(\d+)\s+files?\s+changed/); + const insertMatch = line.match(/(\d+)\s+insertions?\(\+\)/); + const deleteMatch = line.match(/(\d+)\s+deletions?\(-\)/); + if (filesMatch) filesChanged = parseInt(filesMatch[1], 10); + if (insertMatch) insertions = parseInt(insertMatch[1], 10); + if (deleteMatch) deletions = parseInt(deleteMatch[1], 10); } + + statsMap.set(hash, { filesChanged, insertions, deletions, parents }); } - if (current?.hash) { - entries.push(current as GitLogEntry); + const entries: GitLogEntry[] = []; + for (const record of records) { + const header = record.split('\n').filter((l) => l.trim().length > 0)[0] || ''; + const [hash] = header.split('\x1f'); + if (!hash) continue; + const stats = statsMap.get(hash) || { filesChanged: 0, insertions: 0, deletions: 0, parents: [] }; + // Need to re-parse header fields for the final entries array + const lines = record.split('\n').filter((l) => l.trim().length > 0); + const lineHeader = lines.shift() || ''; + const [, , author_name, author_email, date, message, refs] = lineHeader.split('\x1f'); + entries.push({ + hash, + date: date || '', + message: message || '', + refs: refs?.trim() || '', + body: '', + author_name: author_name || '', + author_email: author_email || '', + filesChanged: stats.filesChanged, + insertions: stats.insertions, + deletions: stats.deletions, + parents: stats.parents, + }); } return { @@ -3205,6 +3293,99 @@ export async function continueMerge(directory: string): Promise<{ success: boole throw new Error(result.stderr || 'Continue merge failed'); } +// ============== Commit Actions ============== + +export async function checkoutCommit(directory: string, hash: string): Promise<{ success: boolean }> { + if (!isValidCommitHash(hash)) { + throw new Error('Invalid commit hash'); + } + const result = await execGit(['checkout', hash], directory); + if (result.exitCode !== 0) { + throw new Error(result.stderr || 'Failed to checkout commit'); + } + return { success: true }; +} + +export async function cherryPick(directory: string, hash: string): Promise<{ success: boolean; conflict: boolean; conflictFiles?: string[] }> { + if (!isValidCommitHash(hash)) { + throw new Error('Invalid commit hash'); + } + const result = await execGit(['cherry-pick', hash], directory); + + if (result.exitCode === 0) { + return { success: true, conflict: false }; + } + + const output = (result.stdout + result.stderr).toLowerCase(); + const isConflict = + output.includes('conflict') || + output.includes('patch does not apply'); + + if (isConflict) { + const statusResult = await execGit(['status', '--porcelain'], directory); + const conflictFiles = statusResult.stdout + .split('\n') + .filter((line) => line.startsWith('UU') || line.startsWith('AA') || line.startsWith('DD')) + .map((line) => line.slice(3).trim()); + + return { success: false, conflict: true, conflictFiles }; + } + + throw new Error(result.stderr || 'Cherry-pick failed'); +} + +export async function revertCommit(directory: string, hash: string): Promise<{ success: boolean; conflict: boolean; conflictFiles?: string[] }> { + if (!isValidCommitHash(hash)) { + throw new Error('Invalid commit hash'); + } + const result = await execGit(['revert', '--no-commit', hash], directory); + + if (result.exitCode === 0) { + return { success: true, conflict: false }; + } + + const output = (result.stdout + result.stderr).toLowerCase(); + const isConflict = + output.includes('conflict') || + output.includes('revert failed'); + + if (isConflict) { + const statusResult = await execGit(['status', '--porcelain'], directory); + const conflictFiles = statusResult.stdout + .split('\n') + .filter((line) => line.startsWith('UU') || line.startsWith('AA') || line.startsWith('DD')) + .map((line) => line.slice(3).trim()); + + return { success: false, conflict: true, conflictFiles }; + } + + throw new Error(result.stderr || 'Revert failed'); +} + +export async function resetToCommit( + directory: string, + hash: string, + mode: 'soft' | 'mixed' | 'hard', + force = false +): Promise<{ success: boolean }> { + if (!isValidCommitHash(hash)) { + throw new Error('Invalid commit hash'); + } + if (mode === 'hard' && !force) { + const statusResult = await execGit(['status', '--porcelain'], directory); + const isDirty = statusResult.stdout.trim().length > 0; + if (isDirty) { + throw new Error('Cannot hard reset: uncommitted changes in working tree. Stash or commit first, or use force.'); + } + } + + const result = await execGit(['reset', `--${mode}`, hash], directory); + if (result.exitCode !== 0) { + throw new Error(result.stderr || 'Reset failed'); + } + return { success: true }; +} + // ============== Stash Operations ============== /** diff --git a/packages/vscode/webview/api/git.ts b/packages/vscode/webview/api/git.ts index cc049f3c..5d64eb64 100644 --- a/packages/vscode/webview/api/git.ts +++ b/packages/vscode/webview/api/git.ts @@ -35,6 +35,10 @@ import type { GitRemote, GitRebaseResult, GitMergeResult, + CheckoutCommitResponse, + CherryPickResponse, + RevertCommitResponse, + ResetToCommitResponse, } from '@openchamber/ui/lib/api/types'; export const createVSCodeGitAPI = (): GitAPI => ({ @@ -267,6 +271,7 @@ export const createVSCodeGitAPI = (): GitAPI => ({ from: options?.from, to: options?.to, file: options?.file, + all: options?.all, }); }, @@ -355,6 +360,22 @@ export const createVSCodeGitAPI = (): GitAPI => ({ return sendBridgeMessage<{ success: boolean; conflict: boolean; conflictFiles?: string[] }>('api:git/merge/continue', { directory }); }, + checkoutCommit: async (directory: string, hash: string): Promise => { + return sendBridgeMessage('api:git/checkout-commit', { directory, hash }); + }, + + cherryPick: async (directory: string, hash: string): Promise => { + return sendBridgeMessage('api:git/cherry-pick', { directory, hash }); + }, + + revertCommit: async (directory: string, hash: string): Promise => { + return sendBridgeMessage('api:git/revert-commit', { directory, hash }); + }, + + resetToCommit: async (directory: string, hash: string, mode: 'soft' | 'mixed' | 'hard', force?: boolean): Promise => { + return sendBridgeMessage('api:git/reset-to-commit', { directory, hash, mode, force }); + }, + stash: async ( directory: string, options?: { message?: string; includeUntracked?: boolean } diff --git a/packages/web/server/lib/git/routes.js b/packages/web/server/lib/git/routes.js index 17cb2594..be1cc188 100644 --- a/packages/web/server/lib/git/routes.js +++ b/packages/web/server/lib/git/routes.js @@ -766,6 +766,85 @@ export function registerGitRoutes(app) { } }); + app.post('/api/git/checkout-commit', async (req, res) => { + const { checkoutCommit } = await getGitLibraries(); + try { + const directory = req.query.directory; + if (!directory) { + return res.status(400).json({ error: 'directory parameter is required' }); + } + const { hash } = req.body; + if (!req.body.hash || typeof req.body.hash !== 'string' || !/^[0-9a-fA-F]{7,40}$/.test(req.body.hash)) { + return res.status(400).json({ error: 'Invalid commit hash' }); + } + const result = await checkoutCommit(directory, hash); + res.json(result); + } catch (error) { + console.error('Failed to checkout commit:', error); + res.status(500).json({ error: error.message || 'Failed to checkout commit' }); + } + }); + + app.post('/api/git/cherry-pick', async (req, res) => { + const { cherryPick } = await getGitLibraries(); + try { + const directory = req.query.directory; + if (!directory) { + return res.status(400).json({ error: 'directory parameter is required' }); + } + const { hash } = req.body; + if (!req.body.hash || typeof req.body.hash !== 'string' || !/^[0-9a-fA-F]{7,40}$/.test(req.body.hash)) { + return res.status(400).json({ error: 'Invalid commit hash' }); + } + const result = await cherryPick(directory, hash); + res.json(result); + } catch (error) { + console.error('Failed to cherry-pick:', error); + res.status(500).json({ error: error.message || 'Failed to cherry-pick' }); + } + }); + + app.post('/api/git/revert-commit', async (req, res) => { + const { revertCommit } = await getGitLibraries(); + try { + const directory = req.query.directory; + if (!directory) { + return res.status(400).json({ error: 'directory parameter is required' }); + } + const { hash } = req.body; + if (!req.body.hash || typeof req.body.hash !== 'string' || !/^[0-9a-fA-F]{7,40}$/.test(req.body.hash)) { + return res.status(400).json({ error: 'Invalid commit hash' }); + } + const result = await revertCommit(directory, hash); + res.json(result); + } catch (error) { + console.error('Failed to revert commit:', error); + res.status(500).json({ error: error.message || 'Failed to revert commit' }); + } + }); + + app.post('/api/git/reset-to-commit', async (req, res) => { + const { resetToCommit } = await getGitLibraries(); + try { + const directory = req.query.directory; + if (!directory) { + return res.status(400).json({ error: 'directory parameter is required' }); + } + const { hash, mode, force } = req.body; + if (!req.body.hash || typeof req.body.hash !== 'string' || !/^[0-9a-fA-F]{7,40}$/.test(req.body.hash)) { + return res.status(400).json({ error: 'Invalid commit hash' }); + } + if (!['soft', 'mixed', 'hard'].includes(mode)) { + return res.status(400).json({ error: 'mode must be soft, mixed, or hard' }); + } + const result = await resetToCommit(directory, hash, mode, force === true); + res.json(result); + } catch (error) { + console.error('Failed to reset to commit:', error); + res.status(500).json({ error: error.message || 'Failed to reset' }); + } + }); + app.get('/api/git/worktrees', async (req, res) => { const { getWorktrees } = await getGitLibraries(); try { @@ -956,11 +1035,13 @@ export function registerGitRoutes(app) { } const { maxCount, from, to, file } = req.query; + const all = req.query.all === 'true'; const log = await getLog(directory, { maxCount: maxCount ? parseInt(maxCount) : undefined, from, to, - file + file, + all }); res.json(log); } catch (error) { diff --git a/packages/web/server/lib/git/service.js b/packages/web/server/lib/git/service.js index 57976b75..85455625 100644 --- a/packages/web/server/lib/git/service.js +++ b/packages/web/server/lib/git/service.js @@ -636,6 +636,10 @@ const normalizeStartRef = (value) => { return trimmed; }; +function isValidCommitHash(hash) { + return typeof hash === 'string' && /^[0-9a-fA-F]{7,40}$/.test(hash); +} + const parseRemoteBranchRef = (value) => { const trimmed = String(value || '').trim(); if (!trimmed) { @@ -2692,6 +2696,99 @@ export async function checkoutBranch(directory, branchName) { } } +export async function checkoutCommit(directory, hash) { + if (!isValidCommitHash(hash)) { + throw new Error('Invalid commit hash'); + } + const { git } = await createRepositoryGitContext(directory); + try { + await git.checkout(hash); + return { success: true }; + } catch (error) { + console.error('Failed to checkout commit:', error); + throw error; + } +} + +export async function cherryPick(directory, hash) { + if (!isValidCommitHash(hash)) { + throw new Error('Invalid commit hash'); + } + const { git } = await createRepositoryGitContext(directory); + try { + await git.raw(['cherry-pick', hash]); + return { success: true, conflict: false }; + } catch (error) { + const errorMessage = String(error?.message || error || '').toLowerCase(); + const isConflict = + errorMessage.includes('conflict') || + errorMessage.includes('patch does not apply'); + + if (isConflict) { + const status = await git.status().catch(() => ({ conflicted: [] })); + return { + success: false, + conflict: true, + conflictFiles: status.conflicted || [], + }; + } + + console.error('Failed to cherry-pick:', error); + throw error; + } +} + +export async function revertCommit(directory, hash) { + if (!isValidCommitHash(hash)) { + throw new Error('Invalid commit hash'); + } + const { git } = await createRepositoryGitContext(directory); + try { + await git.raw(['revert', '--no-commit', hash]); + return { success: true, conflict: false }; + } catch (error) { + const errorMessage = String(error?.message || error || '').toLowerCase(); + const isConflict = + errorMessage.includes('conflict') || + errorMessage.includes('revert failed'); + + if (isConflict) { + const status = await git.status().catch(() => ({ conflicted: [] })); + return { + success: false, + conflict: true, + conflictFiles: status.conflicted || [], + }; + } + + console.error('Failed to revert commit:', error); + throw error; + } +} + +export async function resetToCommit(directory, hash, mode, force = false) { + if (!isValidCommitHash(hash)) { + throw new Error('Invalid commit hash'); + } + const { git } = await createRepositoryGitContext(directory); + + if (mode === 'hard' && !force) { + const status = await git.status(); + const isDirty = !status.isClean(); + if (isDirty) { + throw new Error('Cannot hard reset: uncommitted changes in working tree. Stash or commit first, or use force.'); + } + } + + try { + await git.raw(['reset', `--${mode}`, hash]); + return { success: true }; + } catch (error) { + console.error('Failed to reset to commit:', error); + throw error; + } +} + export async function getWorktrees(directory) { const directoryPath = normalizeDirectoryPath(directory); if (!directoryPath || !fs.existsSync(directoryPath)) { @@ -3179,6 +3276,65 @@ export async function getLog(directory, options = {}) { try { const maxCount = options.maxCount || 50; + + if (options.all) { + const logArgs = [ + 'log', + `--max-count=${maxCount}`, + '--all', + '--topo-order', + '--date=iso', + '--pretty=format:%x1e%H%x1f%P%x1f%an%x1f%ae%x1f%ad%x1f%s%x1f%D', + '--shortstat', + ]; + + const rawLog = await git.raw(logArgs); + const records = rawLog + .split('\x1e') + .map((e) => e.trim()) + .filter(Boolean); + + const entries = []; + for (const record of records) { + const lines = record.split('\n').filter((l) => l.trim().length > 0); + const header = lines.shift() || ''; + const [hash, parentsRaw, author_name, author_email, date, message, refsRaw] = + header.split('\x1f'); + if (!hash) continue; + + const parents = parentsRaw ? parentsRaw.trim().split(' ').filter(Boolean) : []; + const refs = refsRaw ? refsRaw.trim() : ''; + + let filesChanged = 0; + let insertions = 0; + let deletions = 0; + for (const line of lines) { + const filesMatch = line.match(/(\d+)\s+files?\s+changed/); + const insertMatch = line.match(/(\d+)\s+insertions?\(\+\)/); + const deleteMatch = line.match(/(\d+)\s+deletions?\(-\)/); + if (filesMatch) filesChanged = parseInt(filesMatch[1], 10); + if (insertMatch) insertions = parseInt(insertMatch[1], 10); + if (deleteMatch) deletions = parseInt(deleteMatch[1], 10); + } + + entries.push({ + hash, + date: date || '', + message: message || '', + refs, + body: '', + author_name: author_name || '', + author_email: author_email || '', + filesChanged, + insertions, + deletions, + parents, + }); + } + + return { all: entries, latest: entries[0] || null, total: entries.length }; + } + const filePath = options.file ? (await resolveGitFileContext(directoryPath, directoryGit, options.file, repoRoot)).repoPath : undefined; @@ -3206,7 +3362,7 @@ export async function getLog(directory, options = {}) { 'log', `--max-count=${maxCount}`, '--date=iso', - '--pretty=format:%H%x1f%an%x1f%ae%x1f%ad%x1f%s%x1e', + '--pretty=format:%x1e%H%x1f%P%x1f%an%x1f%ae%x1f%ad%x1f%s', '--shortstat' ]; @@ -3233,7 +3389,8 @@ export async function getLog(directory, options = {}) { records.forEach((record) => { const lines = record.split('\n').filter((line) => line.trim().length > 0); const header = lines.shift() || ''; - const [hash] = header.split('\x1f'); + const [hash, parentsRaw] = header.split('\x1f'); + const parents = parentsRaw ? parentsRaw.trim().split(' ').filter(Boolean) : []; if (!hash) { return; } @@ -3258,11 +3415,11 @@ export async function getLog(directory, options = {}) { } }); - statsMap.set(hash, { filesChanged, insertions, deletions }); + statsMap.set(hash, { filesChanged, insertions, deletions, parents }); }); const merged = baseLog.all.map((entry) => { - const stats = statsMap.get(entry.hash) || { filesChanged: 0, insertions: 0, deletions: 0 }; + const stats = statsMap.get(entry.hash) || { filesChanged: 0, insertions: 0, deletions: 0, parents: [] }; return { hash: entry.hash, date: entry.date, @@ -3273,7 +3430,8 @@ export async function getLog(directory, options = {}) { author_email: entry.author_email, filesChanged: stats.filesChanged, insertions: stats.insertions, - deletions: stats.deletions + deletions: stats.deletions, + parents: stats.parents || [], }; }); diff --git a/packages/web/server/lib/git/service.test.js b/packages/web/server/lib/git/service.test.js index 9f115dc6..96814df7 100644 --- a/packages/web/server/lib/git/service.test.js +++ b/packages/web/server/lib/git/service.test.js @@ -3,22 +3,38 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; +import simpleGit from 'simple-git'; -import { getStatus, resolveBaseRefForLog, stageFiles, unstageFiles } from './service.js'; +import { + checkoutCommit, + cherryPick, + getStatus, + resetToCommit, + resolveBaseRefForLog, + revertCommit, + stageFiles, + unstageFiles, +} from './service.js'; + +// --------------------------------------------------------------------------- +// Shared test infrastructure +// --------------------------------------------------------------------------- const tempDirs = []; +/** Create a temp dir and register it for afterEach cleanup. */ const createTempDir = () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-git-service-')); tempDirs.push(dir); return dir; }; -const runGit = (cwd, args) => execFileSync('git', args, { - cwd, - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'pipe'], -}); +const runGit = (cwd, args) => + execFileSync('git', args, { + cwd, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); const canRunGit = () => { try { @@ -35,22 +51,36 @@ afterEach(() => { } }); +/** + * Create a temp repo using simple-git (for tests that need its assertion API). + * The dir is registered in tempDirs so afterEach handles cleanup automatically. + */ +async function createTempRepo() { + const tmpDir = createTempDir(); + const git = simpleGit(tmpDir); + await git.init(); + await git.addConfig('user.name', 'Test User', false, 'local'); + await git.addConfig('user.email', 'test@example.com', false, 'local'); + await git.raw(['symbolic-ref', 'HEAD', 'refs/heads/main']); + return { tmpDir, git }; +} + +// --------------------------------------------------------------------------- +// resolveBaseRefForLog +// --------------------------------------------------------------------------- + describe('resolveBaseRefForLog', () => { it('returns the local ref unchanged when it exists, even if origin also exists', async () => { - // Both local 'main' and 'refs/remotes/origin/main' are present. - // The local ref takes precedence — callers that ask for 'main' get 'main'. const checkRef = async (ref) => ref === 'main' || ref === 'refs/remotes/origin/main'; expect(await resolveBaseRefForLog('main', checkRef)).toBe('main'); }); it('falls back to origin/ when local ref cannot be resolved but origin can', async () => { - // Local 'main' is absent (e.g. user never checked it out), but origin/main exists. const checkRef = async (ref) => ref === 'refs/remotes/origin/main'; expect(await resolveBaseRefForLog('main', checkRef)).toBe('origin/main'); }); it('returns the original ref when neither local nor origin ref can be resolved', async () => { - // Neither ref exists; return as-is so git surfaces a meaningful error. const checkRef = async () => false; expect(await resolveBaseRefForLog('nonexistent-branch', checkRef)).toBe('nonexistent-branch'); }); @@ -71,21 +101,31 @@ describe('resolveBaseRefForLog', () => { }); }); +// --------------------------------------------------------------------------- +// git index path validation +// --------------------------------------------------------------------------- + describe('git index path validation', () => { it('rejects stage paths outside the repository before invoking git', async () => { - await expect(stageFiles('/repo', ['../secret.txt'])).rejects.toThrow('Path is outside repository: ../secret.txt'); + await expect(stageFiles('/repo', ['../secret.txt'])).rejects.toThrow( + 'Path is outside repository: ../secret.txt' + ); }); it('rejects unstage paths outside the repository before invoking git', async () => { - await expect(unstageFiles('/repo', ['../secret.txt'])).rejects.toThrow('Path is outside repository: ../secret.txt'); + await expect(unstageFiles('/repo', ['../secret.txt'])).rejects.toThrow( + 'Path is outside repository: ../secret.txt' + ); }); }); +// --------------------------------------------------------------------------- +// getStatus +// --------------------------------------------------------------------------- + describe('getStatus', () => { it('handles repositories without upstream tracking', async () => { - if (!canRunGit()) { - return; - } + if (!canRunGit()) return; const repo = createTempDir(); runGit(repo, ['init', '-b', 'main']); @@ -95,8 +135,321 @@ describe('getStatus', () => { runGit(repo, ['add', 'README.md']); runGit(repo, ['commit', '-m', 'Initial commit']); - await expect(getStatus(repo)).resolves.toMatchObject({ - current: 'main', - }); + await expect(getStatus(repo)).resolves.toMatchObject({ current: 'main' }); + }); +}); + +// --------------------------------------------------------------------------- +// checkoutCommit +// --------------------------------------------------------------------------- + +describe('checkoutCommit', () => { + it('checks out a valid commit and puts the repo in detached HEAD state', async () => { + const { tmpDir, git } = await createTempRepo(); + const filePath = path.join(tmpDir, 'file.txt'); + await fs.promises.writeFile(filePath, 'first', 'utf8'); + await git.add('file.txt'); + const firstCommit = await git.commit('First commit'); + + await fs.promises.writeFile(filePath, 'second', 'utf8'); + await git.add('file.txt'); + await git.commit('Second commit'); + + const result = await checkoutCommit(tmpDir, firstCommit.commit); + expect(result).toEqual({ success: true }); + + const status = await git.status(); + expect(status.detached).toBe(true); + }); + + it('throws an error for an invalid/nonexistent hash', async () => { + const { tmpDir } = await createTempRepo(); + await expect(checkoutCommit(tmpDir, 'invalidhash123')).rejects.toThrow(); + }); +}); + +// --------------------------------------------------------------------------- +// cherryPick +// --------------------------------------------------------------------------- + +describe('cherryPick', () => { + it('cherry-picks a commit that applies cleanly', async () => { + const { tmpDir, git } = await createTempRepo(); + const filePath = path.join(tmpDir, 'file.txt'); + await fs.promises.writeFile(filePath, 'line1\nline2\n', 'utf8'); + await git.add('file.txt'); + await git.commit('Initial commit'); + + await git.checkoutBranch('feature', 'HEAD'); + await fs.promises.writeFile(filePath, 'line1\nline2\nline3\n', 'utf8'); + await git.add('file.txt'); + const featureCommit = await git.commit('Add line3'); + + await git.checkout('main'); + const result = await cherryPick(tmpDir, featureCommit.commit); + expect(result).toEqual({ success: true, conflict: false }); + + const content = await fs.promises.readFile(filePath, 'utf8'); + expect(content).toBe('line1\nline2\nline3\n'); + }); + + it('returns conflict info when cherry-picking a conflicting commit', async () => { + const { tmpDir, git } = await createTempRepo(); + const filePath = path.join(tmpDir, 'file.txt'); + await fs.promises.writeFile(filePath, 'line1\nline2\n', 'utf8'); + await git.add('file.txt'); + await git.commit('Initial commit'); + + await git.checkoutBranch('feature', 'HEAD'); + await fs.promises.writeFile(filePath, 'line1\nfeature-line2\n', 'utf8'); + await git.add('file.txt'); + const featureCommit = await git.commit('Change line2 in feature'); + + await git.checkout('main'); + await fs.promises.writeFile(filePath, 'line1\nmain-line2\n', 'utf8'); + await git.add('file.txt'); + await git.commit('Change line2 in main'); + + const result = await cherryPick(tmpDir, featureCommit.commit); + expect(result.success).toBe(false); + expect(result.conflict).toBe(true); + expect(Array.isArray(result.conflictFiles)).toBe(true); + expect(result.conflictFiles.length).toBeGreaterThan(0); + }); + + it('throws for an invalid/nonexistent hash', async () => { + const { tmpDir } = await createTempRepo(); + await expect(cherryPick(tmpDir, 'deadbeef00000000')).rejects.toThrow(); + }); +}); + +// --------------------------------------------------------------------------- +// revertCommit +// --------------------------------------------------------------------------- + +describe('revertCommit', () => { + it('reverts a commit and stages the revert changes', async () => { + const { tmpDir, git } = await createTempRepo(); + const filePath = path.join(tmpDir, 'file.txt'); + await fs.promises.writeFile(filePath, 'line1\nline2\n', 'utf8'); + await git.add('file.txt'); + await git.commit('Initial commit'); + + await fs.promises.writeFile(filePath, 'line1\nline2\nline3\n', 'utf8'); + await git.add('file.txt'); + const changeCommit = await git.commit('Add line3'); + + const result = await revertCommit(tmpDir, changeCommit.commit); + expect(result).toEqual({ success: true, conflict: false }); + + const status = await git.status(); + expect(status.staged.length).toBeGreaterThan(0); + const content = await fs.promises.readFile(filePath, 'utf8'); + expect(content).toBe('line1\nline2\n'); + }); + + it('returns conflict info when reverting causes a conflict', async () => { + const { tmpDir, git } = await createTempRepo(); + const filePath = path.join(tmpDir, 'file.txt'); + await fs.promises.writeFile(filePath, 'line1\nline2\nline3\n', 'utf8'); + await git.add('file.txt'); + await git.commit('Initial commit'); + + await fs.promises.writeFile(filePath, 'line1\nchanged-a\nline3\n', 'utf8'); + await git.add('file.txt'); + const commitA = await git.commit('Change line2 to changed-a'); + + await fs.promises.writeFile(filePath, 'line1\nchanged-b\nline3\n', 'utf8'); + await git.add('file.txt'); + await git.commit('Change line2 to changed-b'); + + const result = await revertCommit(tmpDir, commitA.commit); + expect(result.success).toBe(false); + expect(result.conflict).toBe(true); + expect(Array.isArray(result.conflictFiles)).toBe(true); + expect(result.conflictFiles.length).toBeGreaterThan(0); + }); + + it('throws for an invalid/nonexistent hash', async () => { + const { tmpDir } = await createTempRepo(); + await expect(revertCommit(tmpDir, 'deadbeef00000000')).rejects.toThrow(); + }); +}); + +// --------------------------------------------------------------------------- +// resetToCommit +// --------------------------------------------------------------------------- + +describe('resetToCommit', () => { + it('soft reset moves HEAD without touching the working tree', async () => { + const { tmpDir, git } = await createTempRepo(); + const filePath = path.join(tmpDir, 'file.txt'); + await fs.promises.writeFile(filePath, 'first\n', 'utf8'); + await git.add('file.txt'); + const firstCommit = await git.commit('First commit'); + + await fs.promises.writeFile(filePath, 'second\n', 'utf8'); + await git.add('file.txt'); + await git.commit('Second commit'); + + const result = await resetToCommit(tmpDir, firstCommit.commit, 'soft'); + expect(result).toEqual({ success: true }); + + const log = await git.log(); + expect(log.latest.hash).toBe(firstCommit.commit); + const content = await fs.promises.readFile(filePath, 'utf8'); + expect(content).toBe('second\n'); + + const status = await git.status(); + expect(status.staged.length).toBeGreaterThan(0); + }); + + it('mixed reset moves HEAD and unstages changes', async () => { + const { tmpDir, git } = await createTempRepo(); + const filePath = path.join(tmpDir, 'file.txt'); + await fs.promises.writeFile(filePath, 'first\n', 'utf8'); + await git.add('file.txt'); + const firstCommit = await git.commit('First commit'); + + await fs.promises.writeFile(filePath, 'second\n', 'utf8'); + await git.add('file.txt'); + await git.commit('Second commit'); + + const result = await resetToCommit(tmpDir, firstCommit.commit, 'mixed'); + expect(result).toEqual({ success: true }); + + const log = await git.log(); + expect(log.latest.hash).toBe(firstCommit.commit); + const content = await fs.promises.readFile(filePath, 'utf8'); + expect(content).toBe('second\n'); + + const status = await git.status(); + expect(status.staged.length).toBe(0); + expect(status.modified.length).toBeGreaterThan(0); + }); + + it('hard reset with clean working tree succeeds', async () => { + const { tmpDir, git } = await createTempRepo(); + const filePath = path.join(tmpDir, 'file.txt'); + await fs.promises.writeFile(filePath, 'first\n', 'utf8'); + await git.add('file.txt'); + const firstCommit = await git.commit('First commit'); + + await fs.promises.writeFile(filePath, 'second\n', 'utf8'); + await git.add('file.txt'); + await git.commit('Second commit'); + + const result = await resetToCommit(tmpDir, firstCommit.commit, 'hard'); + expect(result).toEqual({ success: true }); + + const log = await git.log(); + expect(log.latest.hash).toBe(firstCommit.commit); + const content = await fs.promises.readFile(filePath, 'utf8'); + expect(content).toBe('first\n'); + + const status = await git.status(); + expect(status.isClean()).toBe(true); + }); + + it('hard reset with dirty working tree without force throws', async () => { + const { tmpDir, git } = await createTempRepo(); + const filePath = path.join(tmpDir, 'file.txt'); + await fs.promises.writeFile(filePath, 'first\n', 'utf8'); + await git.add('file.txt'); + const firstCommit = await git.commit('First commit'); + + await fs.promises.writeFile(filePath, 'second\n', 'utf8'); + await git.add('file.txt'); + await git.commit('Second commit'); + + await fs.promises.writeFile(filePath, 'dirty\n', 'utf8'); + + await expect(resetToCommit(tmpDir, firstCommit.commit, 'hard')).rejects.toThrow( + 'Cannot hard reset: uncommitted changes in working tree' + ); + }); + + it('hard reset with dirty working tree with force succeeds', async () => { + const { tmpDir, git } = await createTempRepo(); + const filePath = path.join(tmpDir, 'file.txt'); + await fs.promises.writeFile(filePath, 'first\n', 'utf8'); + await git.add('file.txt'); + const firstCommit = await git.commit('First commit'); + + await fs.promises.writeFile(filePath, 'second\n', 'utf8'); + await git.add('file.txt'); + await git.commit('Second commit'); + + await fs.promises.writeFile(filePath, 'dirty\n', 'utf8'); + + const result = await resetToCommit(tmpDir, firstCommit.commit, 'hard', true); + expect(result).toEqual({ success: true }); + + const log = await git.log(); + expect(log.latest.hash).toBe(firstCommit.commit); + const content = await fs.promises.readFile(filePath, 'utf8'); + expect(content).toBe('first\n'); + }); +}); + +// --------------------------------------------------------------------------- +// hash validation +// --------------------------------------------------------------------------- + +describe('hash validation', () => { + it('checkoutCommit rejects non-hex hash', async () => { + await expect(checkoutCommit('/tmp', '--hard')).rejects.toThrow('Invalid commit hash'); + }); + + it('checkoutCommit rejects ref name', async () => { + await expect(checkoutCommit('/tmp', 'HEAD')).rejects.toThrow('Invalid commit hash'); + }); + + it('checkoutCommit accepts valid 40-char hex format', async () => { + await expect( + checkoutCommit('/tmp', '1234567890abcdef1234567890abcdef12345678') + ).rejects.not.toThrow('Invalid commit hash'); + }); + + it('cherryPick rejects non-hex hash', async () => { + await expect(cherryPick('/tmp', '--hard')).rejects.toThrow('Invalid commit hash'); + }); + + it('cherryPick rejects ref name', async () => { + await expect(cherryPick('/tmp', 'HEAD')).rejects.toThrow('Invalid commit hash'); + }); + + it('cherryPick accepts valid 40-char hex format', async () => { + await expect( + cherryPick('/tmp', '1234567890abcdef1234567890abcdef12345678') + ).rejects.not.toThrow('Invalid commit hash'); + }); + + it('revertCommit rejects non-hex hash', async () => { + await expect(revertCommit('/tmp', '--hard')).rejects.toThrow('Invalid commit hash'); + }); + + it('revertCommit rejects ref name', async () => { + await expect(revertCommit('/tmp', 'HEAD')).rejects.toThrow('Invalid commit hash'); + }); + + it('revertCommit accepts valid 40-char hex format', async () => { + await expect( + revertCommit('/tmp', '1234567890abcdef1234567890abcdef12345678') + ).rejects.not.toThrow('Invalid commit hash'); + }); + + it('resetToCommit rejects non-hex hash', async () => { + await expect(resetToCommit('/tmp', '--hard', 'soft')).rejects.toThrow('Invalid commit hash'); + }); + + it('resetToCommit rejects ref name', async () => { + await expect(resetToCommit('/tmp', 'HEAD', 'soft')).rejects.toThrow('Invalid commit hash'); + }); + + it('resetToCommit accepts valid 40-char hex format', async () => { + await expect( + resetToCommit('/tmp', '1234567890abcdef1234567890abcdef12345678', 'soft') + ).rejects.not.toThrow('Invalid commit hash'); }); }); diff --git a/packages/web/src/api/git.ts b/packages/web/src/api/git.ts index 5d5c75f6..77d36cf0 100644 --- a/packages/web/src/api/git.ts +++ b/packages/web/src/api/git.ts @@ -61,6 +61,10 @@ export const createWebGitAPI = (): GitAPI => ({ merge: gitApiHttp.merge, abortMerge: gitApiHttp.abortMerge, continueMerge: gitApiHttp.continueMerge, + checkoutCommit: gitApiHttp.checkoutCommit, + cherryPick: gitApiHttp.cherryPick, + revertCommit: gitApiHttp.revertCommit, + resetToCommit: gitApiHttp.resetToCommit, stash: gitApiHttp.stash, stashPop: gitApiHttp.stashPop, getConflictDetails: gitApiHttp.getConflictDetails,