feat(git-graph): VS Code-style git graph with commit actions in History modal (#1431)
* feat(types): add parents to GitLogEntry and new commit action types
* feat(git): add parent hashes and --all flag to getLog
* fix(git): move record separator to start of log format string
* feat(git): add checkoutCommit server function and route
* feat(git): add cherryPick server function and route
* feat(git): add revertCommit server function and route
* feat(git): add resetToCommit server function and route
* fix(tests): make git service tests branch-name portable, add error path tests
* feat(client): add checkoutCommit, cherryPick, revertCommit, resetToCommit API wrappers
* feat(git-graph): add lane assignment algorithm with tests
* feat(git-graph): add GitGraphSegment per-row SVG renderer
* feat(i18n): add locale strings for git graph action buttons
* fix(git-graph): handle lane convergence, fix SVG path coords, add connector tests
* feat(git-graph): add ref badges and action buttons to HistoryCommitRow
* fix(git-graph): add loading guards to reset actions, use theme tokens for ref badges
* fix(git-graph): conditional hooks, stale graph log, conflict handling, i18n
* fix(types): replace toBeDefined with toBeTruthy, fix toast API usage
* fix(lint): remove unused variables
* fix(git-graph): fix SVG height causing 150px row spacing
* fix(git-graph): smooth bezier curves, fill row height, round line caps
* fix(git-graph): non-scaling-stroke fixes bezier white spaces, sort curves on top
* fix(git-graph): remove viewBox scaling, match SVG height to actual row height
* fix(git-graph): ResizeObserver tracks actual row height, eliminates SVG height mismatch
* feat(git-graph): replace SVG with Canvas for graph rendering
* fix(git-graph): isolate canvas from flex layout to prevent replaced-element height leak
* feat(git-graph): align action buttons, add confirmation popups for all actions
* fix(git-graph): address code review findings CR-001 through CR-005
- CR-001: VS Code getGitLog now forwards 'all' option and parses %P parents
- CR-002: VS Code bridge/gitService implement checkoutCommit, cherryPick,
revertCommit, resetToCommit with conflict detection and hard-reset guard
- CR-003: server-side commit hash validated with /^[0-9a-fA-F]{7,40}$/
in both routes.js and service.js; 12 new rejection tests added
- CR-004: cherry-pick/revert conflict path now refreshes fetchStatus/
fetchBranches/fetchLog; conflict toast uses i18n keys in all 7 locales
- CR-005: corrected O(n) comment to O(n x lanes)
* fix(i18n): add zh-TW locale and common.language.traditionalChinese key to all locales
upstream/main added zh-TW.ts after branch diverged; CI type-check fails
when PR is merged because zh-TW.ts was missing all gitView.history.actions.*
keys and loadMore/loadingMore. Also adds common.language.traditionalChinese
to en.ts and all 6 non-English files to match upstream en.ts.
* fix: harden git history actions
* feat: split git history graph view
* chore: remove git graph planning docs
---------
Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
committed by
GitHub
co-authored by
Bohdan Triapitsyn
parent
cc3d1bd63c
commit
52ffe9daef
@@ -68,6 +68,7 @@ type SyncAction = 'fetch' | 'pull' | 'push' | 'sync' | null;
|
|||||||
type CommitAction = 'commit' | 'commitAndPush' | null;
|
type CommitAction = 'commit' | 'commitAndPush' | null;
|
||||||
type BranchOperation = 'merge' | 'rebase' | null;
|
type BranchOperation = 'merge' | 'rebase' | null;
|
||||||
type ActionTab = 'commit' | 'branch' | 'pr';
|
type ActionTab = 'commit' | 'branch' | 'pr';
|
||||||
|
type GitLogDialogMode = 'history' | 'graph';
|
||||||
type HistoryBranchDivider = {
|
type HistoryBranchDivider = {
|
||||||
insertBeforeIndex: number;
|
insertBeforeIndex: number;
|
||||||
branchName: string;
|
branchName: string;
|
||||||
@@ -313,9 +314,9 @@ export const GitView: React.FC = () => {
|
|||||||
const fetchStatus = useGitStore((state) => state.fetchStatus);
|
const fetchStatus = useGitStore((state) => state.fetchStatus);
|
||||||
const fetchBranches = useGitStore((state) => state.fetchBranches);
|
const fetchBranches = useGitStore((state) => state.fetchBranches);
|
||||||
const fetchLog = useGitStore((state) => state.fetchLog);
|
const fetchLog = useGitStore((state) => state.fetchLog);
|
||||||
|
const setLogMaxCount = useGitStore((state) => state.setLogMaxCount);
|
||||||
const fetchIdentity = useGitStore((state) => state.fetchIdentity);
|
const fetchIdentity = useGitStore((state) => state.fetchIdentity);
|
||||||
const prefetchDiffs = useGitStore((state) => state.prefetchDiffs);
|
const prefetchDiffs = useGitStore((state) => state.prefetchDiffs);
|
||||||
const setLogMaxCount = useGitStore((state) => state.setLogMaxCount);
|
|
||||||
const moveStatusPathsOptimistically = useGitStore((state) => state.moveStatusPathsOptimistically);
|
const moveStatusPathsOptimistically = useGitStore((state) => state.moveStatusPathsOptimistically);
|
||||||
const restoreStatus = useGitStore((state) => state.restoreStatus);
|
const restoreStatus = useGitStore((state) => state.restoreStatus);
|
||||||
const bumpIndexRevision = useGitStore((state) => state.bumpIndexRevision);
|
const bumpIndexRevision = useGitStore((state) => state.bumpIndexRevision);
|
||||||
@@ -626,7 +627,7 @@ export const GitView: React.FC = () => {
|
|||||||
const [remoteUrl, setRemoteUrl] = React.useState<string | null>(null);
|
const [remoteUrl, setRemoteUrl] = React.useState<string | null>(null);
|
||||||
const [gitmojiEmojis, setGitmojiEmojis] = React.useState<GitmojiEntry[]>([]);
|
const [gitmojiEmojis, setGitmojiEmojis] = React.useState<GitmojiEntry[]>([]);
|
||||||
const [gitmojiSearch, setGitmojiSearch] = React.useState('');
|
const [gitmojiSearch, setGitmojiSearch] = React.useState('');
|
||||||
const [isHistoryDialogOpen, setIsHistoryDialogOpen] = React.useState(false);
|
const [gitLogDialogMode, setGitLogDialogMode] = React.useState<GitLogDialogMode | null>(null);
|
||||||
|
|
||||||
const actionTabItems = React.useMemo(() => [
|
const actionTabItems = React.useMemo(() => [
|
||||||
{ id: 'commit', label: t('gitView.tabs.commit'), icon: <Icon name="git-commit" className="h-3.5 w-3.5" /> },
|
{ id: 'commit', label: t('gitView.tabs.commit'), icon: <Icon name="git-commit" className="h-3.5 w-3.5" /> },
|
||||||
@@ -650,6 +651,9 @@ export const GitView: React.FC = () => {
|
|||||||
const [conflictDialogOpen, setConflictDialogOpen] = React.useState(false);
|
const [conflictDialogOpen, setConflictDialogOpen] = React.useState(false);
|
||||||
const [conflictFiles, setConflictFiles] = React.useState<string[]>([]);
|
const [conflictFiles, setConflictFiles] = React.useState<string[]>([]);
|
||||||
const [conflictOperation, setConflictOperation] = React.useState<'merge' | 'rebase'>('merge');
|
const [conflictOperation, setConflictOperation] = React.useState<'merge' | 'rebase'>('merge');
|
||||||
|
const [graphLog, setGraphLog] = React.useState<import('@/lib/api/types').GitLogResponse | null>(null);
|
||||||
|
const [graphLogLoading, setGraphLogLoading] = React.useState(false);
|
||||||
|
const [graphLogMaxCount, setGraphLogMaxCount] = React.useState(100);
|
||||||
|
|
||||||
// Conflict state persistence key
|
// Conflict state persistence key
|
||||||
const conflictStorageKey = React.useMemo(() => {
|
const conflictStorageKey = React.useMemo(() => {
|
||||||
@@ -1631,6 +1635,32 @@ export const GitView: React.FC = () => {
|
|||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [baseBranch, currentBranch, currentDirectory, git, log, logMaxCountLocal]);
|
}, [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.
|
// Keep these sections stable in layout; individual cards render placeholders when unavailable.
|
||||||
|
|
||||||
const moveChangePaths = React.useCallback((paths: string[], direction: GitIndexMutationDirection) => {
|
const moveChangePaths = React.useCallback((paths: string[], direction: GitIndexMutationDirection) => {
|
||||||
@@ -1834,16 +1864,7 @@ export const GitView: React.FC = () => {
|
|||||||
setIsGitmojiPickerOpen(false);
|
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 isUncommittedChangesError = React.useCallback((error: unknown): boolean => {
|
||||||
const message = error instanceof Error ? error.message.toLowerCase() : '';
|
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]
|
[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) {
|
if (!currentDirectory) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full items-center justify-center px-4 text-center">
|
<div className="flex h-full items-center justify-center px-4 text-center">
|
||||||
@@ -2282,7 +2358,8 @@ export const GitView: React.FC = () => {
|
|||||||
onSelectIdentity={handleApplyIdentity}
|
onSelectIdentity={handleApplyIdentity}
|
||||||
isApplyingIdentity={isSettingIdentity}
|
isApplyingIdentity={isSettingIdentity}
|
||||||
isWorktreeMode={!!worktreeMetadata}
|
isWorktreeMode={!!worktreeMetadata}
|
||||||
onOpenHistory={() => setIsHistoryDialogOpen(true)}
|
onOpenHistory={() => setGitLogDialogMode('history')}
|
||||||
|
onOpenGraph={() => setGitLogDialogMode('graph')}
|
||||||
onOpenStashes={openStashes}
|
onOpenStashes={openStashes}
|
||||||
actionTabItems={actionTabItems}
|
actionTabItems={actionTabItems}
|
||||||
activeActionTab={actionTab}
|
activeActionTab={actionTab}
|
||||||
@@ -2421,20 +2498,23 @@ export const GitView: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Dialog open={isHistoryDialogOpen} onOpenChange={setIsHistoryDialogOpen}>
|
<Dialog open={gitLogDialogMode !== null} onOpenChange={(open) => { if (!open) setGitLogDialogMode(null); }}>
|
||||||
<DialogContent className="max-w-5xl h-[90vh] max-h-[90vh] flex flex-col overflow-hidden">
|
<DialogContent className="max-w-5xl h-[90vh] max-h-[90vh] flex flex-col overflow-hidden">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{t('gitView.history.title')}</DialogTitle>
|
<DialogTitle>
|
||||||
|
{gitLogDialogMode === 'graph' ? t('gitView.graph.title') : t('gitView.history.title')}
|
||||||
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
{t('gitView.history.dialogDescription')}
|
{t('gitView.history.dialogDescription')}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="flex-1 min-h-0">
|
<div className="flex-1 min-h-0">
|
||||||
<HistorySection
|
<HistorySection
|
||||||
log={log}
|
mode={gitLogDialogMode === 'graph' ? 'graph' : 'history'}
|
||||||
isLogLoading={isLogLoading}
|
log={gitLogDialogMode === 'graph' ? graphLog ?? log : log}
|
||||||
logMaxCount={logMaxCountLocal}
|
isLogLoading={gitLogDialogMode === 'graph' ? graphLogLoading || isLogLoading : isLogLoading}
|
||||||
onLogMaxCountChange={handleLogMaxCountChange}
|
logMaxCount={gitLogDialogMode === 'graph' ? graphLogMaxCount : logMaxCountLocal}
|
||||||
|
onLogMaxCountChange={gitLogDialogMode === 'graph' ? handleGraphLogMaxCountChange : handleLogMaxCountChange}
|
||||||
expandedCommitHashes={expandedCommitHashes}
|
expandedCommitHashes={expandedCommitHashes}
|
||||||
onToggleCommit={handleToggleCommit}
|
onToggleCommit={handleToggleCommit}
|
||||||
commitFilesMap={commitFilesMap}
|
commitFilesMap={commitFilesMap}
|
||||||
@@ -2443,7 +2523,9 @@ export const GitView: React.FC = () => {
|
|||||||
directory={currentDirectory ?? undefined}
|
directory={currentDirectory ?? undefined}
|
||||||
showHeader={false}
|
showHeader={false}
|
||||||
contentMaxHeightClassName="h-full max-h-none"
|
contentMaxHeightClassName="h-full max-h-none"
|
||||||
branchDivider={historyBranchDivider}
|
branchDivider={gitLogDialogMode === 'graph' ? null : historyBranchDivider}
|
||||||
|
onConflict={gitLogDialogMode === 'graph' ? handleGraphConflict : undefined}
|
||||||
|
onActionSuccess={gitLogDialogMode === 'graph' ? handleGraphActionSuccess : undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -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 <div> (no replaced-element intrinsic sizing) owns all layout via
|
||||||
|
* `height: 100%` + self-stretch on the parent. The <canvas> 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<GitGraphSegmentProps> = ({
|
||||||
|
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<HTMLDivElement>(null);
|
||||||
|
const canvasRef = React.useRef<HTMLCanvasElement>(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.
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
style={{ width: w, height: '100%', position: 'relative', flexShrink: 0, overflow: 'hidden' }}
|
||||||
|
>
|
||||||
|
{/* Canvas is absolutely inset so it matches the div exactly and never
|
||||||
|
contributes its own intrinsic height (150px default) to flex layout. */}
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', display: 'block' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -43,6 +43,7 @@ interface GitHeaderProps {
|
|||||||
isApplyingIdentity: boolean;
|
isApplyingIdentity: boolean;
|
||||||
isWorktreeMode: boolean;
|
isWorktreeMode: boolean;
|
||||||
onOpenHistory?: () => void;
|
onOpenHistory?: () => void;
|
||||||
|
onOpenGraph?: () => void;
|
||||||
onOpenStashes?: () => void;
|
onOpenStashes?: () => void;
|
||||||
actionTabItems?: SortableTabsStripItem[];
|
actionTabItems?: SortableTabsStripItem[];
|
||||||
activeActionTab?: string;
|
activeActionTab?: string;
|
||||||
@@ -246,6 +247,7 @@ export const GitHeader: React.FC<GitHeaderProps> = ({
|
|||||||
isApplyingIdentity,
|
isApplyingIdentity,
|
||||||
isWorktreeMode,
|
isWorktreeMode,
|
||||||
onOpenHistory,
|
onOpenHistory,
|
||||||
|
onOpenGraph,
|
||||||
onOpenStashes,
|
onOpenStashes,
|
||||||
actionTabItems,
|
actionTabItems,
|
||||||
activeActionTab,
|
activeActionTab,
|
||||||
@@ -258,7 +260,7 @@ export const GitHeader: React.FC<GitHeaderProps> = ({
|
|||||||
|
|
||||||
const managementButtons = (
|
const managementButtons = (
|
||||||
<div className="flex items-center gap-1 shrink-0">
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
{onOpenHistory || onOpenStashes ? (
|
{onOpenHistory || onOpenGraph || onOpenStashes ? (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
@@ -267,13 +269,13 @@ export const GitHeader: React.FC<GitHeaderProps> = ({
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-8 w-8 px-0"
|
className="h-8 w-8 px-0"
|
||||||
aria-label={t('gitView.history.title')}
|
aria-label={t('gitView.header.repositoryViews')}
|
||||||
>
|
>
|
||||||
<Icon name="git-repository" className="size-4" />
|
<Icon name="git-repository" className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent sideOffset={8}>{t('gitView.history.title')}</TooltipContent>
|
<TooltipContent sideOffset={8}>{t('gitView.header.repositoryViews')}</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
{onOpenHistory ? (
|
{onOpenHistory ? (
|
||||||
@@ -282,6 +284,12 @@ export const GitHeader: React.FC<GitHeaderProps> = ({
|
|||||||
{t('gitView.history.title')}
|
{t('gitView.history.title')}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
) : null}
|
) : null}
|
||||||
|
{onOpenGraph ? (
|
||||||
|
<DropdownMenuItem onSelect={onOpenGraph}>
|
||||||
|
<Icon name="git-merge" className="size-4" />
|
||||||
|
{t('gitView.graph.title')}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
) : null}
|
||||||
{onOpenStashes ? (
|
{onOpenStashes ? (
|
||||||
<DropdownMenuItem onSelect={onOpenStashes}>
|
<DropdownMenuItem onSelect={onOpenStashes}>
|
||||||
<Icon name="archive-stack" className="size-4" />
|
<Icon name="archive-stack" className="size-4" />
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Button } from '@/components/ui/button';
|
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 { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
import { Icon } from "@/components/icon/Icon";
|
import { Icon } from "@/components/icon/Icon";
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
@@ -8,6 +14,10 @@ import { useI18n } from '@/lib/i18n';
|
|||||||
import { getCommitFileDiff, type CommitFileDiffResponse } from '@/lib/gitApi';
|
import { getCommitFileDiff, type CommitFileDiffResponse } from '@/lib/gitApi';
|
||||||
import { PierreDiffViewer } from '@/components/views/PierreDiffViewer';
|
import { PierreDiffViewer } from '@/components/views/PierreDiffViewer';
|
||||||
import { getLanguageFromExtension } from '@/lib/toolHelpers';
|
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_REQUEST_TIMEOUT_MS = 15000;
|
||||||
const HISTORY_DIFF_LARGE_CHANGED_LINES = 500;
|
const HISTORY_DIFF_LARGE_CHANGED_LINES = 500;
|
||||||
@@ -54,12 +64,17 @@ const trimHistoryDiffCache = (cache: Map<string, HistoryDiffCacheValue>): Map<st
|
|||||||
|
|
||||||
interface HistoryCommitRowProps {
|
interface HistoryCommitRowProps {
|
||||||
entry: GitLogEntry;
|
entry: GitLogEntry;
|
||||||
|
mode?: 'history' | 'graph';
|
||||||
|
laned?: LanedCommit;
|
||||||
|
totalLanes?: number;
|
||||||
isExpanded: boolean;
|
isExpanded: boolean;
|
||||||
onToggle: () => void;
|
onToggle: () => void;
|
||||||
files: CommitFileEntry[];
|
files: CommitFileEntry[];
|
||||||
isLoadingFiles: boolean;
|
isLoadingFiles: boolean;
|
||||||
onCopyHash: (hash: string) => void;
|
onCopyHash: (hash: string) => void;
|
||||||
directory: string | undefined;
|
directory: string | undefined;
|
||||||
|
onConflict?: (result: { conflict: boolean; conflictFiles?: string[]; operation: 'cherry-pick' | 'revert' | 'merge' | 'rebase' }) => void;
|
||||||
|
onActionSuccess?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatCommitDate(date: string) {
|
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(({
|
export const HistoryCommitRow = React.memo(({
|
||||||
entry,
|
entry,
|
||||||
|
mode = 'history',
|
||||||
|
laned,
|
||||||
|
totalLanes,
|
||||||
isExpanded,
|
isExpanded,
|
||||||
onToggle,
|
onToggle,
|
||||||
files,
|
files,
|
||||||
isLoadingFiles,
|
isLoadingFiles,
|
||||||
onCopyHash,
|
onCopyHash,
|
||||||
directory,
|
directory,
|
||||||
|
onConflict,
|
||||||
|
onActionSuccess,
|
||||||
}: HistoryCommitRowProps) => {
|
}: HistoryCommitRowProps) => {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
const isGraphMode = mode === 'graph';
|
||||||
|
type PendingAction =
|
||||||
|
| 'checkout' | 'cherryPick' | 'revert'
|
||||||
|
| 'merge' | 'rebase'
|
||||||
|
| 'resetSoft' | 'resetMixed' | 'resetHard';
|
||||||
|
|
||||||
|
const [actionLoading, setActionLoading] = React.useState<string | null>(null);
|
||||||
|
const [showCreateBranch, setShowCreateBranch] = React.useState(false);
|
||||||
|
const [newBranchName, setNewBranchName] = React.useState('');
|
||||||
|
const [pendingAction, setPendingAction] = React.useState<PendingAction | null>(null);
|
||||||
|
|
||||||
const [openDiffPaths, setOpenDiffPaths] = React.useState<Set<string>>(new Set());
|
const [openDiffPaths, setOpenDiffPaths] = React.useState<Set<string>>(new Set());
|
||||||
const [diffCache, setDiffCache] = React.useState<Map<string, HistoryDiffCacheValue>>(new Map());
|
const [diffCache, setDiffCache] = React.useState<Map<string, HistoryDiffCacheValue>>(new Map());
|
||||||
const [forceRenderLargePaths, setForceRenderLargePaths] = React.useState<Set<string>>(new Set());
|
const [forceRenderLargePaths, setForceRenderLargePaths] = React.useState<Set<string>>(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 loadFileDiff = React.useCallback(async (file: CommitFileEntry) => {
|
||||||
const key = file.path;
|
const key = file.path;
|
||||||
if (!directory) {
|
if (!directory) {
|
||||||
@@ -169,15 +349,45 @@ export const HistoryCommitRow = React.memo(({
|
|||||||
onClick={onToggle}
|
onClick={onToggle}
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-full flex items-start gap-3 px-3 py-2 text-left transition-colors',
|
'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'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div
|
{isGraphMode && laned && totalLanes !== undefined ? (
|
||||||
className="h-2 w-2 translate-y-2 rounded-full shrink-0"
|
<div className="-my-2 shrink-0 self-stretch">
|
||||||
style={{ backgroundColor: 'var(--status-success)' }}
|
<GitGraphSegment laned={laned} totalLanes={totalLanes} isExpanded={isExpanded} />
|
||||||
aria-hidden
|
</div>
|
||||||
/>
|
) : (
|
||||||
|
<div
|
||||||
|
className="h-2 w-2 translate-y-2 rounded-full shrink-0"
|
||||||
|
style={{ backgroundColor: 'var(--status-success)' }}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
|
{/* Ref badges */}
|
||||||
|
{isGraphMode ? (() => {
|
||||||
|
const badges = parseRefBadges(entry.refs);
|
||||||
|
return badges.length > 0 ? (
|
||||||
|
<div className="flex flex-wrap gap-1 mb-0.5">
|
||||||
|
{badges.map((badge) => (
|
||||||
|
<span key={badge.label}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center px-1.5 py-0 typography-micro rounded font-medium',
|
||||||
|
badge.isHead
|
||||||
|
? 'bg-[var(--chart-1)] text-[var(--primary-foreground)]'
|
||||||
|
: badge.isTag
|
||||||
|
? 'bg-[var(--chart-5)] text-[var(--primary-foreground)]'
|
||||||
|
: 'bg-[var(--interactive-hover)] text-[var(--foreground)]'
|
||||||
|
)}>
|
||||||
|
{badge.label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null;
|
||||||
|
})() : null}
|
||||||
|
|
||||||
<p className="typography-ui-label font-medium text-foreground line-clamp-1">
|
<p className="typography-ui-label font-medium text-foreground line-clamp-1">
|
||||||
{entry.message}
|
{entry.message}
|
||||||
</p>
|
</p>
|
||||||
@@ -217,6 +427,132 @@ export const HistoryCommitRow = React.memo(({
|
|||||||
|
|
||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
<div className="px-3 pb-2 pl-8 border-t border-border/40">
|
<div className="px-3 pb-2 pl-8 border-t border-border/40">
|
||||||
|
{/* Action buttons */}
|
||||||
|
{isGraphMode && pendingAction ? (
|
||||||
|
/* Confirmation banner — replaces the button row while an action is pending */
|
||||||
|
<div className="flex items-center gap-2 py-2 border-b border-border/30 mb-2">
|
||||||
|
<span className="typography-micro text-muted-foreground flex-1 min-w-0">
|
||||||
|
{t(`gitView.history.actions.${pendingAction}Confirm` as never)}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="destructive" size="xs" className="h-6 shrink-0"
|
||||||
|
disabled={actionLoading !== null}
|
||||||
|
onClick={(e) => { e.stopPropagation(); void confirmPendingAction(); }}
|
||||||
|
>
|
||||||
|
{actionLoading !== null
|
||||||
|
? <Icon name="loader-4" className="size-3 animate-spin mr-1" />
|
||||||
|
: null}
|
||||||
|
{t('gitView.history.actions.confirmButton')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost" size="xs" className="h-6 shrink-0"
|
||||||
|
disabled={actionLoading !== null}
|
||||||
|
onClick={(e) => { e.stopPropagation(); setPendingAction(null); }}
|
||||||
|
>
|
||||||
|
{t('gitView.history.actions.cancelButton')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : isGraphMode ? (
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5 py-2 border-b border-border/30 mb-2">
|
||||||
|
<Button variant="outline" size="xs" className="h-6"
|
||||||
|
disabled={actionLoading !== null}
|
||||||
|
onClick={(e) => { e.stopPropagation(); setPendingAction('checkout'); }}
|
||||||
|
>
|
||||||
|
{t('gitView.history.actions.checkout')}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{showCreateBranch ? (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<input
|
||||||
|
autoFocus value={newBranchName}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
<Button variant="outline" size="xs" className="h-6"
|
||||||
|
disabled={!newBranchName.trim() || actionLoading !== null}
|
||||||
|
onClick={(e) => { e.stopPropagation(); void handleCreateBranch(); }}
|
||||||
|
>
|
||||||
|
{actionLoading === 'createBranch'
|
||||||
|
? <Icon name="loader-4" className="size-3 animate-spin mr-1" />
|
||||||
|
: null}
|
||||||
|
{t('gitView.history.actions.createBranchConfirm')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Button variant="outline" size="xs" className="h-6"
|
||||||
|
onClick={(e) => { e.stopPropagation(); setShowCreateBranch(true); }}
|
||||||
|
>
|
||||||
|
{t('gitView.history.actions.createBranch')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button variant="outline" size="xs" className="h-6"
|
||||||
|
disabled={actionLoading !== null}
|
||||||
|
onClick={(e) => { e.stopPropagation(); setPendingAction('cherryPick'); }}
|
||||||
|
>
|
||||||
|
{t('gitView.history.actions.cherryPick')}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button variant="outline" size="xs" className="h-6"
|
||||||
|
disabled={actionLoading !== null}
|
||||||
|
onClick={(e) => { e.stopPropagation(); setPendingAction('revert'); }}
|
||||||
|
>
|
||||||
|
{t('gitView.history.actions.revert')}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Reset: dropdown first to pick mode, then confirmation banner */}
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="xs"
|
||||||
|
className="h-6"
|
||||||
|
disabled={actionLoading !== null}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{actionLoading === 'reset'
|
||||||
|
? <Icon name="loader-4" className="size-3 animate-spin mr-1" />
|
||||||
|
: null}
|
||||||
|
{t('gitView.history.actions.reset')}
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="start" className="min-w-max">
|
||||||
|
{(['soft', 'mixed', 'hard'] as const).map((mode) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={mode}
|
||||||
|
disabled={actionLoading !== null}
|
||||||
|
onSelect={(e) => {
|
||||||
|
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)}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
|
<Button variant="outline" size="xs" className="h-6"
|
||||||
|
disabled={actionLoading !== null}
|
||||||
|
onClick={(e) => { e.stopPropagation(); setPendingAction('merge'); }}
|
||||||
|
>
|
||||||
|
{t('gitView.history.actions.merge')}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button variant="outline" size="xs" className="h-6"
|
||||||
|
disabled={actionLoading !== null}
|
||||||
|
onClick={(e) => { e.stopPropagation(); setPendingAction('rebase'); }}
|
||||||
|
>
|
||||||
|
{t('gitView.history.actions.rebase')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{isLoadingFiles ? (
|
{isLoadingFiles ? (
|
||||||
<div className="flex items-center gap-2 py-2">
|
<div className="flex items-center gap-2 py-2">
|
||||||
<Icon name="loader-4" className="size-4 animate-spin text-muted-foreground" />
|
<Icon name="loader-4" className="size-4 animate-spin text-muted-foreground" />
|
||||||
|
|||||||
@@ -11,11 +11,14 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||||
import { Icon } from "@/components/icon/Icon";
|
import { Icon } from "@/components/icon/Icon";
|
||||||
import { HistoryCommitRow } from './HistoryCommitRow';
|
import { HistoryCommitRow } from './HistoryCommitRow';
|
||||||
import type { GitLogEntry, CommitFileEntry } from '@/lib/api/types';
|
import type { GitLogEntry, CommitFileEntry } from '@/lib/api/types';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
import { assignLanes } from './gitGraph';
|
||||||
|
import type { LanedCommit } from './gitGraph';
|
||||||
|
|
||||||
const LOG_SIZE_OPTIONS = [
|
const LOG_SIZE_OPTIONS = [
|
||||||
{ labelKey: 'gitView.history.logSize25', value: 25 },
|
{ labelKey: 'gitView.history.logSize25', value: 25 },
|
||||||
@@ -24,6 +27,7 @@ const LOG_SIZE_OPTIONS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
interface HistorySectionProps {
|
interface HistorySectionProps {
|
||||||
|
mode?: 'history' | 'graph';
|
||||||
log: { all: GitLogEntry[] } | null;
|
log: { all: GitLogEntry[] } | null;
|
||||||
isLogLoading: boolean;
|
isLogLoading: boolean;
|
||||||
logMaxCount: number;
|
logMaxCount: number;
|
||||||
@@ -41,9 +45,12 @@ interface HistorySectionProps {
|
|||||||
branchName: string;
|
branchName: string;
|
||||||
direction: 'up' | 'down';
|
direction: 'up' | 'down';
|
||||||
} | null;
|
} | null;
|
||||||
|
onConflict?: (result: { conflict: boolean; conflictFiles?: string[]; operation: 'cherry-pick' | 'revert' | 'merge' | 'rebase' }) => void;
|
||||||
|
onActionSuccess?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const HistorySection: React.FC<HistorySectionProps> = ({
|
export const HistorySection: React.FC<HistorySectionProps> = ({
|
||||||
|
mode = 'history',
|
||||||
log,
|
log,
|
||||||
isLogLoading,
|
isLogLoading,
|
||||||
logMaxCount,
|
logMaxCount,
|
||||||
@@ -57,10 +64,29 @@ export const HistorySection: React.FC<HistorySectionProps> = ({
|
|||||||
showHeader = true,
|
showHeader = true,
|
||||||
contentMaxHeightClassName = 'max-h-[50vh]',
|
contentMaxHeightClassName = 'max-h-[50vh]',
|
||||||
branchDivider = null,
|
branchDivider = null,
|
||||||
|
onConflict,
|
||||||
|
onActionSuccess,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [isOpen, setIsOpen] = React.useState(true);
|
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) {
|
if (!log) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -89,17 +115,44 @@ export const HistorySection: React.FC<HistorySectionProps> = ({
|
|||||||
<HistoryCommitRow
|
<HistoryCommitRow
|
||||||
key={entry.hash}
|
key={entry.hash}
|
||||||
entry={entry}
|
entry={entry}
|
||||||
|
mode={mode}
|
||||||
|
laned={isGraphMode ? lanedByHash.get(entry.hash) : undefined}
|
||||||
|
totalLanes={isGraphMode ? maxLanes : undefined}
|
||||||
isExpanded={expandedCommitHashes.has(entry.hash)}
|
isExpanded={expandedCommitHashes.has(entry.hash)}
|
||||||
onToggle={() => onToggleCommit(entry.hash)}
|
onToggle={() => onToggleCommit(entry.hash)}
|
||||||
files={commitFilesMap.get(entry.hash) ?? []}
|
files={commitFilesMap.get(entry.hash) ?? []}
|
||||||
isLoadingFiles={loadingCommitHashes.has(entry.hash)}
|
isLoadingFiles={loadingCommitHashes.has(entry.hash)}
|
||||||
onCopyHash={onCopyHash}
|
onCopyHash={onCopyHash}
|
||||||
directory={directory}
|
directory={directory}
|
||||||
|
onConflict={onConflict}
|
||||||
|
onActionSuccess={onActionSuccess}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const loadMoreButton = log.all.length >= logMaxCount ? (
|
||||||
|
<div className="flex justify-center py-2 border-t border-border/40">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="xs"
|
||||||
|
onClick={() => onLogMaxCountChange(logMaxCount + 25)}
|
||||||
|
disabled={isLogLoading}
|
||||||
|
className="px-3 text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
{isLogLoading ? (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Icon name="loader-4" className="size-3 animate-spin" />
|
||||||
|
{t('gitView.history.loadingMore')}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
t('gitView.history.loadMore')
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null;
|
||||||
|
|
||||||
const content = (
|
const content = (
|
||||||
<ScrollableOverlay outerClassName={`min-h-0 ${contentMaxHeightClassName}`} className="h-full w-full">
|
<ScrollableOverlay outerClassName={`min-h-0 ${contentMaxHeightClassName}`} className="h-full w-full">
|
||||||
{log.all.length === 0 ? (
|
{log.all.length === 0 ? (
|
||||||
@@ -109,30 +162,36 @@ export const HistorySection: React.FC<HistorySectionProps> = ({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : hasSplitHistory && branchDivider ? (
|
) : hasSplitHistory && branchDivider ? (
|
||||||
<div className="flex flex-col gap-0">
|
<>
|
||||||
{topEntries.length > 0 ? (
|
<div className="flex flex-col gap-0">
|
||||||
<div className="rounded-xl border border-border/60 bg-background/70 overflow-hidden">
|
{topEntries.length > 0 ? (
|
||||||
{renderCommitList(topEntries)}
|
<div className="rounded-xl border border-border/60 bg-background/70 overflow-hidden">
|
||||||
</div>
|
{renderCommitList(topEntries)}
|
||||||
) : null}
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className="flex items-center gap-2 px-3 py-1.5" aria-hidden>
|
<div className="flex items-center gap-2 px-3 py-1.5" aria-hidden>
|
||||||
<span className="h-px flex-1 bg-border/60" />
|
<span className="h-px flex-1 bg-border/60" />
|
||||||
<span className="inline-flex max-w-[80%] items-center gap-1 typography-micro text-muted-foreground">
|
<span className="inline-flex max-w-[80%] items-center gap-1 typography-micro text-muted-foreground">
|
||||||
<span className="truncate" title={branchDivider.branchName}>{branchDivider.branchName}</span>
|
<span className="truncate" title={branchDivider.branchName}>{branchDivider.branchName}</span>
|
||||||
{dividerIcon}
|
{dividerIcon}
|
||||||
</span>
|
</span>
|
||||||
<span className="h-px flex-1 bg-border/60" />
|
<span className="h-px flex-1 bg-border/60" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{bottomEntries.length > 0 ? (
|
||||||
|
<div className="rounded-xl border border-border/60 bg-background/70 overflow-hidden">
|
||||||
|
{renderCommitList(bottomEntries)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
{loadMoreButton}
|
||||||
{bottomEntries.length > 0 ? (
|
</>
|
||||||
<div className="rounded-xl border border-border/60 bg-background/70 overflow-hidden">
|
|
||||||
{renderCommitList(bottomEntries)}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
renderCommitList(log.all)
|
<>
|
||||||
|
{renderCommitList(log.all)}
|
||||||
|
{loadMoreButton}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</ScrollableOverlay>
|
</ScrollableOverlay>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string | null> = [];
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -236,6 +236,37 @@ export interface GitMergeResult {
|
|||||||
conflictFiles?: string[];
|
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 {
|
export interface GitRebaseResult {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
conflict?: boolean;
|
conflict?: boolean;
|
||||||
@@ -291,6 +322,7 @@ export interface GitLogEntry {
|
|||||||
filesChanged: number;
|
filesChanged: number;
|
||||||
insertions: number;
|
insertions: number;
|
||||||
deletions: number;
|
deletions: number;
|
||||||
|
parents: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GitLogResponse {
|
export interface GitLogResponse {
|
||||||
@@ -404,6 +436,7 @@ export interface GitLogOptions {
|
|||||||
from?: string;
|
from?: string;
|
||||||
to?: string;
|
to?: string;
|
||||||
file?: string;
|
file?: string;
|
||||||
|
all?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GeneratedCommitMessage {
|
export interface GeneratedCommitMessage {
|
||||||
@@ -484,6 +517,10 @@ export interface GitAPI {
|
|||||||
merge(directory: string, options: { branch: string }): Promise<GitMergeResult>;
|
merge(directory: string, options: { branch: string }): Promise<GitMergeResult>;
|
||||||
abortMerge(directory: string): Promise<{ success: boolean }>;
|
abortMerge(directory: string): Promise<{ success: boolean }>;
|
||||||
continueMerge(directory: string): Promise<{ success: boolean; conflict: boolean; conflictFiles?: string[] }>;
|
continueMerge(directory: string): Promise<{ success: boolean; conflict: boolean; conflictFiles?: string[] }>;
|
||||||
|
checkoutCommit(directory: string, hash: string): Promise<CheckoutCommitResponse>;
|
||||||
|
cherryPick(directory: string, hash: string): Promise<CherryPickResponse>;
|
||||||
|
revertCommit(directory: string, hash: string): Promise<RevertCommitResponse>;
|
||||||
|
resetToCommit(directory: string, hash: string, mode: 'soft' | 'mixed' | 'hard', force?: boolean): Promise<ResetToCommitResponse>;
|
||||||
stash(directory: string, options?: { message?: string; includeUntracked?: boolean }): Promise<{ success: boolean }>;
|
stash(directory: string, options?: { message?: string; includeUntracked?: boolean }): Promise<{ success: boolean }>;
|
||||||
stashPop(directory: string): Promise<{ success: boolean }>;
|
stashPop(directory: string): Promise<{ success: boolean }>;
|
||||||
getConflictDetails(directory: string): Promise<MergeConflictDetails>;
|
getConflictDetails(directory: string): Promise<MergeConflictDetails>;
|
||||||
|
|||||||
@@ -784,6 +784,44 @@ export async function merge(
|
|||||||
return gitHttp.merge(directory, options);
|
return gitHttp.merge(directory, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function checkoutCommit(
|
||||||
|
directory: string,
|
||||||
|
hash: string
|
||||||
|
): Promise<import('./api/types').CheckoutCommitResponse> {
|
||||||
|
const runtime = getRuntimeGit();
|
||||||
|
if (runtime) return runtime.checkoutCommit(directory, hash);
|
||||||
|
return gitHttp.checkoutCommit(directory, hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cherryPick(
|
||||||
|
directory: string,
|
||||||
|
hash: string
|
||||||
|
): Promise<import('./api/types').CherryPickResponse> {
|
||||||
|
const runtime = getRuntimeGit();
|
||||||
|
if (runtime) return runtime.cherryPick(directory, hash);
|
||||||
|
return gitHttp.cherryPick(directory, hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function revertCommit(
|
||||||
|
directory: string,
|
||||||
|
hash: string
|
||||||
|
): Promise<import('./api/types').RevertCommitResponse> {
|
||||||
|
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<import('./api/types').ResetToCommitResponse> {
|
||||||
|
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 }> {
|
export async function abortMerge(directory: string): Promise<{ success: boolean }> {
|
||||||
const runtime = getRuntimeGit();
|
const runtime = getRuntimeGit();
|
||||||
if (runtime) return runtime.abortMerge(directory);
|
if (runtime) return runtime.abortMerge(directory);
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ import type {
|
|||||||
GitIdentitySummary,
|
GitIdentitySummary,
|
||||||
DiscoveredGitCredential,
|
DiscoveredGitCredential,
|
||||||
MergeConflictDetails,
|
MergeConflictDetails,
|
||||||
|
CheckoutCommitResponse,
|
||||||
|
CherryPickResponse,
|
||||||
|
RevertCommitResponse,
|
||||||
|
ResetToCommitResponse,
|
||||||
} from './api/types';
|
} from './api/types';
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
@@ -711,6 +715,7 @@ export async function getGitLog(
|
|||||||
from: options.from,
|
from: options.from,
|
||||||
to: options.to,
|
to: options.to,
|
||||||
file: options.file,
|
file: options.file,
|
||||||
|
all: options.all ? 'true' : undefined,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -930,6 +935,72 @@ export async function merge(
|
|||||||
return response.json();
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function checkoutCommit(
|
||||||
|
directory: string,
|
||||||
|
hash: string
|
||||||
|
): Promise<CheckoutCommitResponse> {
|
||||||
|
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<CherryPickResponse> {
|
||||||
|
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<RevertCommitResponse> {
|
||||||
|
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<ResetToCommitResponse> {
|
||||||
|
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 }> {
|
export async function abortMerge(directory: string): Promise<{ success: boolean }> {
|
||||||
const response = await fetch(buildUrl(`${API_BASE}/merge/abort`, directory), {
|
const response = await fetch(buildUrl(`${API_BASE}/merge/abort`, directory), {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -503,11 +503,39 @@ export const dict = {
|
|||||||
'gitView.header.identityTooltip': 'Git identity',
|
'gitView.header.identityTooltip': 'Git identity',
|
||||||
'gitView.header.noIdentity': 'No identity',
|
'gitView.header.noIdentity': 'No identity',
|
||||||
'gitView.header.noProfiles': 'No profiles available to apply.',
|
'gitView.header.noProfiles': 'No profiles available to apply.',
|
||||||
|
'gitView.header.repositoryViews': 'Repository views',
|
||||||
'gitView.header.removeRemoteAria': 'Remove Remote aria label',
|
'gitView.header.removeRemoteAria': 'Remove Remote aria label',
|
||||||
'gitView.header.removeRemoteTitle': 'Remove Remote Title',
|
'gitView.header.removeRemoteTitle': 'Remove Remote Title',
|
||||||
'gitView.header.upstreamSynced': 'synced',
|
'gitView.header.upstreamSynced': 'synced',
|
||||||
'gitView.header.upstreamTooltip': 'Compared with {target}.',
|
'gitView.header.upstreamTooltip': 'Compared with {target}.',
|
||||||
'gitView.header.upstreamTooltipTracking': 'Compared with {target}. Primary sync badges still reflect {tracking}.',
|
'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.binary': 'Binary',
|
||||||
'gitView.history.binaryNoDiff': 'Binary file — no diff available',
|
'gitView.history.binaryNoDiff': 'Binary file — no diff available',
|
||||||
'gitView.history.commitsPlaceholder': 'Commits Placeholder',
|
'gitView.history.commitsPlaceholder': 'Commits Placeholder',
|
||||||
@@ -517,6 +545,8 @@ export const dict = {
|
|||||||
'gitView.history.largeDiffTitle': 'Large diff ({count} changed lines)',
|
'gitView.history.largeDiffTitle': 'Large diff ({count} changed lines)',
|
||||||
'gitView.history.loadingDiff': 'Loading diff...',
|
'gitView.history.loadingDiff': 'Loading diff...',
|
||||||
'gitView.history.loadingFiles': 'Loading files...',
|
'gitView.history.loadingFiles': 'Loading files...',
|
||||||
|
'gitView.history.loadMore': 'Load more',
|
||||||
|
'gitView.history.loadingMore': 'Loading...',
|
||||||
'gitView.history.logSize100': 'Log Size100',
|
'gitView.history.logSize100': 'Log Size100',
|
||||||
'gitView.history.logSize25': 'Log Size25',
|
'gitView.history.logSize25': 'Log Size25',
|
||||||
'gitView.history.logSize50': 'Log Size50',
|
'gitView.history.logSize50': 'Log Size50',
|
||||||
@@ -525,6 +555,7 @@ export const dict = {
|
|||||||
'gitView.history.renamedNoDiff': 'Renamed file — diff not supported',
|
'gitView.history.renamedNoDiff': 'Renamed file — diff not supported',
|
||||||
'gitView.history.renderDiffAnyway': 'Render anyway',
|
'gitView.history.renderDiffAnyway': 'Render anyway',
|
||||||
'gitView.history.title': 'History',
|
'gitView.history.title': 'History',
|
||||||
|
'gitView.graph.title': 'Graph',
|
||||||
'gitView.integrate.checking': 'Checking…',
|
'gitView.integrate.checking': 'Checking…',
|
||||||
'gitView.integrate.cherryPickAbortedToast': 'Cherry Pick Aborted Toast',
|
'gitView.integrate.cherryPickAbortedToast': 'Cherry Pick Aborted Toast',
|
||||||
'gitView.integrate.cherryPickConflictDescription': 'Cherry Pick Conflict Description',
|
'gitView.integrate.cherryPickConflictDescription': 'Cherry Pick Conflict Description',
|
||||||
|
|||||||
@@ -504,11 +504,39 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"gitView.header.identityTooltip": "Identidad de Git",
|
"gitView.header.identityTooltip": "Identidad de Git",
|
||||||
"gitView.header.noIdentity": "Sin identidad",
|
"gitView.header.noIdentity": "Sin identidad",
|
||||||
"gitView.header.noProfiles": "No hay perfiles disponibles para aplicar.",
|
"gitView.header.noProfiles": "No hay perfiles disponibles para aplicar.",
|
||||||
|
"gitView.header.repositoryViews": "Vistas del repositorio",
|
||||||
"gitView.header.removeRemoteAria": "Eliminar remoto",
|
"gitView.header.removeRemoteAria": "Eliminar remoto",
|
||||||
"gitView.header.removeRemoteTitle": "Eliminar remoto",
|
"gitView.header.removeRemoteTitle": "Eliminar remoto",
|
||||||
"gitView.header.upstreamSynced": "sincronizado",
|
"gitView.header.upstreamSynced": "sincronizado",
|
||||||
"gitView.header.upstreamTooltip": "Comparado con {target}.",
|
"gitView.header.upstreamTooltip": "Comparado con {target}.",
|
||||||
"gitView.header.upstreamTooltipTracking": "Comparado con {target}. Los indicadores principales de sincronización aún reflejan {tracking}.",
|
"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.binary": "Binario",
|
||||||
"gitView.history.binaryNoDiff": "Archivo binario — no hay diff disponible",
|
"gitView.history.binaryNoDiff": "Archivo binario — no hay diff disponible",
|
||||||
"gitView.history.commitsPlaceholder": "Buscar commits...",
|
"gitView.history.commitsPlaceholder": "Buscar commits...",
|
||||||
@@ -518,6 +546,8 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"gitView.history.largeDiffTitle": "Diff grande ({count} líneas cambiadas)",
|
"gitView.history.largeDiffTitle": "Diff grande ({count} líneas cambiadas)",
|
||||||
"gitView.history.loadingDiff": "Cargando diff...",
|
"gitView.history.loadingDiff": "Cargando diff...",
|
||||||
"gitView.history.loadingFiles": "Cargando archivos...",
|
"gitView.history.loadingFiles": "Cargando archivos...",
|
||||||
|
"gitView.history.loadMore": "Cargar más",
|
||||||
|
"gitView.history.loadingMore": "Cargando...",
|
||||||
"gitView.history.logSize100": "100 commits",
|
"gitView.history.logSize100": "100 commits",
|
||||||
"gitView.history.logSize25": "25 commits",
|
"gitView.history.logSize25": "25 commits",
|
||||||
"gitView.history.logSize50": "50 commits",
|
"gitView.history.logSize50": "50 commits",
|
||||||
@@ -526,6 +556,7 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"gitView.history.renamedNoDiff": "Archivo renombrado — diff no soportado",
|
"gitView.history.renamedNoDiff": "Archivo renombrado — diff no soportado",
|
||||||
"gitView.history.renderDiffAnyway": "Renderizar igualmente",
|
"gitView.history.renderDiffAnyway": "Renderizar igualmente",
|
||||||
"gitView.history.title": "Historial",
|
"gitView.history.title": "Historial",
|
||||||
|
"gitView.graph.title": "Grafo",
|
||||||
"gitView.integrate.checking": "Verificando…",
|
"gitView.integrate.checking": "Verificando…",
|
||||||
"gitView.integrate.cherryPickAbortedToast": "Cherry-pick abortado",
|
"gitView.integrate.cherryPickAbortedToast": "Cherry-pick abortado",
|
||||||
"gitView.integrate.cherryPickConflictDescription": "Resuelve los conflictos de cherry-pick para continuar.",
|
"gitView.integrate.cherryPickConflictDescription": "Resuelve los conflictos de cherry-pick para continuar.",
|
||||||
|
|||||||
@@ -504,11 +504,39 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'gitView.header.identityTooltip': 'Git 인증 정보',
|
'gitView.header.identityTooltip': 'Git 인증 정보',
|
||||||
'gitView.header.noIdentity': '인증 정보 없음',
|
'gitView.header.noIdentity': '인증 정보 없음',
|
||||||
'gitView.header.noProfiles': '적용할 프로필 없음',
|
'gitView.header.noProfiles': '적용할 프로필 없음',
|
||||||
|
'gitView.header.repositoryViews': '저장소 보기',
|
||||||
'gitView.header.removeRemoteAria': '리모트 제거',
|
'gitView.header.removeRemoteAria': '리모트 제거',
|
||||||
'gitView.header.removeRemoteTitle': '리모트 제거',
|
'gitView.header.removeRemoteTitle': '리모트 제거',
|
||||||
'gitView.header.upstreamSynced': '동기화됨',
|
'gitView.header.upstreamSynced': '동기화됨',
|
||||||
'gitView.header.upstreamTooltip': '{target}와 비교됨.',
|
'gitView.header.upstreamTooltip': '{target}와 비교됨.',
|
||||||
'gitView.header.upstreamTooltipTracking': '{target}와 비교됨. 기본 동기화 배지는 계속 {tracking}을 반영합니다.',
|
'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.binary': '바이너리',
|
||||||
'gitView.history.binaryNoDiff': '바이너리 파일 — diff 없음',
|
'gitView.history.binaryNoDiff': '바이너리 파일 — diff 없음',
|
||||||
'gitView.history.commitsPlaceholder': '커밋 검색',
|
'gitView.history.commitsPlaceholder': '커밋 검색',
|
||||||
@@ -518,6 +546,8 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'gitView.history.largeDiffTitle': '큰 diff({count}개 변경된 줄)',
|
'gitView.history.largeDiffTitle': '큰 diff({count}개 변경된 줄)',
|
||||||
'gitView.history.loadingDiff': 'diff 로드 중…',
|
'gitView.history.loadingDiff': 'diff 로드 중…',
|
||||||
'gitView.history.loadingFiles': '파일 로드 중…',
|
'gitView.history.loadingFiles': '파일 로드 중…',
|
||||||
|
'gitView.history.loadMore': '더 불러오기',
|
||||||
|
'gitView.history.loadingMore': '로드 중...',
|
||||||
'gitView.history.logSize100': '최근 100개',
|
'gitView.history.logSize100': '최근 100개',
|
||||||
'gitView.history.logSize25': '최근 25개',
|
'gitView.history.logSize25': '최근 25개',
|
||||||
'gitView.history.logSize50': '최근 50개',
|
'gitView.history.logSize50': '최근 50개',
|
||||||
@@ -526,6 +556,7 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'gitView.history.renamedNoDiff': '이름 변경된 파일 — diff 미지원',
|
'gitView.history.renamedNoDiff': '이름 변경된 파일 — diff 미지원',
|
||||||
'gitView.history.renderDiffAnyway': '그래도 렌더링',
|
'gitView.history.renderDiffAnyway': '그래도 렌더링',
|
||||||
'gitView.history.title': '히스토리',
|
'gitView.history.title': '히스토리',
|
||||||
|
'gitView.graph.title': '그래프',
|
||||||
'gitView.integrate.checking': '확인 중…',
|
'gitView.integrate.checking': '확인 중…',
|
||||||
'gitView.integrate.cherryPickAbortedToast': 'Cherry-pick이 중단되었습니다',
|
'gitView.integrate.cherryPickAbortedToast': 'Cherry-pick이 중단되었습니다',
|
||||||
'gitView.integrate.cherryPickConflictDescription': '충돌을 해결한 뒤 계속 진행하세요.',
|
'gitView.integrate.cherryPickConflictDescription': '충돌을 해결한 뒤 계속 진행하세요.',
|
||||||
|
|||||||
@@ -1491,11 +1491,39 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'gitView.header.identityTooltip': 'Git identity',
|
'gitView.header.identityTooltip': 'Git identity',
|
||||||
'gitView.header.noIdentity': 'No identity',
|
'gitView.header.noIdentity': 'No identity',
|
||||||
'gitView.header.noProfiles': 'No profiles available to apply.',
|
'gitView.header.noProfiles': 'No profiles available to apply.',
|
||||||
|
'gitView.header.repositoryViews': 'Widoki repozytorium',
|
||||||
'gitView.header.removeRemoteAria': 'Remove Remote aria label',
|
'gitView.header.removeRemoteAria': 'Remove Remote aria label',
|
||||||
'gitView.header.removeRemoteTitle': 'Remove Remote Title',
|
'gitView.header.removeRemoteTitle': 'Remove Remote Title',
|
||||||
'gitView.header.upstreamSynced': 'zsynchronizowano',
|
'gitView.header.upstreamSynced': 'zsynchronizowano',
|
||||||
'gitView.header.upstreamTooltip': 'Porównano z {target}.',
|
'gitView.header.upstreamTooltip': 'Porównano z {target}.',
|
||||||
'gitView.header.upstreamTooltipTracking': 'Porównano z {target}. Główne wskaźniki synchronizacji nadal odzwierciedlają {tracking}.',
|
'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.binary': 'Binary',
|
||||||
'gitView.history.binaryNoDiff': 'Binary file — no diff available',
|
'gitView.history.binaryNoDiff': 'Binary file — no diff available',
|
||||||
'gitView.history.commitsPlaceholder': 'Commits Placeholder',
|
'gitView.history.commitsPlaceholder': 'Commits Placeholder',
|
||||||
@@ -1506,6 +1534,8 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'gitView.history.largeDiffTitle': 'Large diff ({count} changed lines)',
|
'gitView.history.largeDiffTitle': 'Large diff ({count} changed lines)',
|
||||||
'gitView.history.loadingDiff': 'Loading diff...',
|
'gitView.history.loadingDiff': 'Loading diff...',
|
||||||
'gitView.history.loadingFiles': 'Loading files...',
|
'gitView.history.loadingFiles': 'Loading files...',
|
||||||
|
'gitView.history.loadMore': 'Załaduj więcej',
|
||||||
|
'gitView.history.loadingMore': 'Ładowanie...',
|
||||||
'gitView.history.logSize100': 'Log Size100',
|
'gitView.history.logSize100': 'Log Size100',
|
||||||
'gitView.history.logSize25': 'Log Size25',
|
'gitView.history.logSize25': 'Log Size25',
|
||||||
'gitView.history.logSize50': 'Log Size50',
|
'gitView.history.logSize50': 'Log Size50',
|
||||||
@@ -1514,6 +1544,7 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'gitView.history.renamedNoDiff': 'Renamed file — diff not supported',
|
'gitView.history.renamedNoDiff': 'Renamed file — diff not supported',
|
||||||
'gitView.history.renderDiffAnyway': 'Render anyway',
|
'gitView.history.renderDiffAnyway': 'Render anyway',
|
||||||
'gitView.history.title': 'History',
|
'gitView.history.title': 'History',
|
||||||
|
'gitView.graph.title': 'Graf',
|
||||||
'gitView.integrate.checking': 'Sprawdzanie…',
|
'gitView.integrate.checking': 'Sprawdzanie…',
|
||||||
'gitView.integrate.cherryPickAbortedToast': 'Cherry-pick został przerwany',
|
'gitView.integrate.cherryPickAbortedToast': 'Cherry-pick został przerwany',
|
||||||
'gitView.integrate.cherryPickConflictDescription': 'Wykryto konflikt podczas cherry-pick.',
|
'gitView.integrate.cherryPickConflictDescription': 'Wykryto konflikt podczas cherry-pick.',
|
||||||
|
|||||||
@@ -504,11 +504,39 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"gitView.header.identityTooltip": "Identidad de Git",
|
"gitView.header.identityTooltip": "Identidad de Git",
|
||||||
"gitView.header.noIdentity": "Sem identidade",
|
"gitView.header.noIdentity": "Sem identidade",
|
||||||
"gitView.header.noProfiles": "Não há perfiles disponíveis para aplicar.",
|
"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.removeRemoteAria": "Excluir remoto",
|
||||||
"gitView.header.removeRemoteTitle": "Excluir remoto",
|
"gitView.header.removeRemoteTitle": "Excluir remoto",
|
||||||
"gitView.header.upstreamSynced": "sincronizado",
|
"gitView.header.upstreamSynced": "sincronizado",
|
||||||
"gitView.header.upstreamTooltip": "Comparado com {target}.",
|
"gitView.header.upstreamTooltip": "Comparado com {target}.",
|
||||||
"gitView.header.upstreamTooltipTracking": "Comparado com {target}. Os indicadores principais de sincronização ainda refletem {tracking}.",
|
"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.binary": "Binario",
|
||||||
"gitView.history.binaryNoDiff": "Arquivo binário — diff não disponível",
|
"gitView.history.binaryNoDiff": "Arquivo binário — diff não disponível",
|
||||||
"gitView.history.commitsPlaceholder": "Buscar commits...",
|
"gitView.history.commitsPlaceholder": "Buscar commits...",
|
||||||
@@ -518,6 +546,8 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"gitView.history.largeDiffTitle": "Diff grande ({count} linhas alteradas)",
|
"gitView.history.largeDiffTitle": "Diff grande ({count} linhas alteradas)",
|
||||||
"gitView.history.loadingDiff": "Carregando diff...",
|
"gitView.history.loadingDiff": "Carregando diff...",
|
||||||
"gitView.history.loadingFiles": "Carregando arquivos...",
|
"gitView.history.loadingFiles": "Carregando arquivos...",
|
||||||
|
"gitView.history.loadMore": "Carregar mais",
|
||||||
|
"gitView.history.loadingMore": "Carregando...",
|
||||||
"gitView.history.logSize100": "100 commits",
|
"gitView.history.logSize100": "100 commits",
|
||||||
"gitView.history.logSize25": "25 commits",
|
"gitView.history.logSize25": "25 commits",
|
||||||
"gitView.history.logSize50": "50 commits",
|
"gitView.history.logSize50": "50 commits",
|
||||||
@@ -526,6 +556,7 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"gitView.history.renamedNoDiff": "Arquivo renomeado — diff não suportado",
|
"gitView.history.renamedNoDiff": "Arquivo renomeado — diff não suportado",
|
||||||
"gitView.history.renderDiffAnyway": "Renderizar mesmo assim",
|
"gitView.history.renderDiffAnyway": "Renderizar mesmo assim",
|
||||||
"gitView.history.title": "Histórico",
|
"gitView.history.title": "Histórico",
|
||||||
|
"gitView.graph.title": "Grafo",
|
||||||
"gitView.integrate.checking": "Verificando…",
|
"gitView.integrate.checking": "Verificando…",
|
||||||
"gitView.integrate.cherryPickAbortedToast": "Cherry-pick abortado",
|
"gitView.integrate.cherryPickAbortedToast": "Cherry-pick abortado",
|
||||||
"gitView.integrate.cherryPickConflictDescription": "Resuelve os conflitos de cherry-pick para continuar.",
|
"gitView.integrate.cherryPickConflictDescription": "Resuelve os conflitos de cherry-pick para continuar.",
|
||||||
|
|||||||
@@ -504,11 +504,39 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"gitView.header.identityTooltip": "Ідентичність Git",
|
"gitView.header.identityTooltip": "Ідентичність Git",
|
||||||
"gitView.header.noIdentity": "Ідентичність не вибрано",
|
"gitView.header.noIdentity": "Ідентичність не вибрано",
|
||||||
"gitView.header.noProfiles": "Немає доступних профілів для застосування.",
|
"gitView.header.noProfiles": "Немає доступних профілів для застосування.",
|
||||||
|
"gitView.header.repositoryViews": "Перегляди репозиторію",
|
||||||
"gitView.header.removeRemoteAria": "Видалити remote",
|
"gitView.header.removeRemoteAria": "Видалити remote",
|
||||||
"gitView.header.removeRemoteTitle": "Видалити remote",
|
"gitView.header.removeRemoteTitle": "Видалити remote",
|
||||||
"gitView.header.upstreamSynced": "синхронізовано",
|
"gitView.header.upstreamSynced": "синхронізовано",
|
||||||
"gitView.header.upstreamTooltip": "Порівняно з {target}.",
|
"gitView.header.upstreamTooltip": "Порівняно з {target}.",
|
||||||
"gitView.header.upstreamTooltipTracking": "Порівняно з {target}. Основні індикатори синхронізації все ще відображають {tracking}.",
|
"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.binary": "Бінарний",
|
||||||
"gitView.history.binaryNoDiff": "Бінарний файл — diff недоступний",
|
"gitView.history.binaryNoDiff": "Бінарний файл — diff недоступний",
|
||||||
"gitView.history.commitsPlaceholder": "Пошук комітів",
|
"gitView.history.commitsPlaceholder": "Пошук комітів",
|
||||||
@@ -518,6 +546,8 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"gitView.history.largeDiffTitle": "Великий diff ({count} змінених рядків)",
|
"gitView.history.largeDiffTitle": "Великий diff ({count} змінених рядків)",
|
||||||
"gitView.history.loadingDiff": "Завантаження diff...",
|
"gitView.history.loadingDiff": "Завантаження diff...",
|
||||||
"gitView.history.loadingFiles": "Завантаження файлів...",
|
"gitView.history.loadingFiles": "Завантаження файлів...",
|
||||||
|
"gitView.history.loadMore": "Завантажити ще",
|
||||||
|
"gitView.history.loadingMore": "Завантаження...",
|
||||||
"gitView.history.logSize100": "Розмір журналу 100",
|
"gitView.history.logSize100": "Розмір журналу 100",
|
||||||
"gitView.history.logSize25": "Розмір журналу 25",
|
"gitView.history.logSize25": "Розмір журналу 25",
|
||||||
"gitView.history.logSize50": "Розмір журналу 50",
|
"gitView.history.logSize50": "Розмір журналу 50",
|
||||||
@@ -526,6 +556,7 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"gitView.history.renamedNoDiff": "Перейменований файл — diff не підтримується",
|
"gitView.history.renamedNoDiff": "Перейменований файл — diff не підтримується",
|
||||||
"gitView.history.renderDiffAnyway": "Показати все одно",
|
"gitView.history.renderDiffAnyway": "Показати все одно",
|
||||||
"gitView.history.title": "Історія",
|
"gitView.history.title": "Історія",
|
||||||
|
"gitView.graph.title": "Граф",
|
||||||
"gitView.integrate.checking": "Перевірка…",
|
"gitView.integrate.checking": "Перевірка…",
|
||||||
"gitView.integrate.cherryPickAbortedToast": "Cherry-pick перервано",
|
"gitView.integrate.cherryPickAbortedToast": "Cherry-pick перервано",
|
||||||
"gitView.integrate.cherryPickConflictDescription": "Вирішіть конфлікти cherry-pick, щоб продовжити.",
|
"gitView.integrate.cherryPickConflictDescription": "Вирішіть конфлікти cherry-pick, щоб продовжити.",
|
||||||
|
|||||||
@@ -504,11 +504,39 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'gitView.header.identityTooltip': 'Git 身份',
|
'gitView.header.identityTooltip': 'Git 身份',
|
||||||
'gitView.header.noIdentity': '无身份',
|
'gitView.header.noIdentity': '无身份',
|
||||||
'gitView.header.noProfiles': '没有可应用的配置。',
|
'gitView.header.noProfiles': '没有可应用的配置。',
|
||||||
|
'gitView.header.repositoryViews': '仓库视图',
|
||||||
'gitView.header.removeRemoteAria': '移除远程 {name}',
|
'gitView.header.removeRemoteAria': '移除远程 {name}',
|
||||||
'gitView.header.removeRemoteTitle': '移除 {name}',
|
'gitView.header.removeRemoteTitle': '移除 {name}',
|
||||||
'gitView.header.upstreamSynced': '已同步',
|
'gitView.header.upstreamSynced': '已同步',
|
||||||
'gitView.header.upstreamTooltip': '与 {target} 对比。',
|
'gitView.header.upstreamTooltip': '与 {target} 对比。',
|
||||||
'gitView.header.upstreamTooltipTracking': '与 {target} 对比。主要同步徽标仍然反映 {tracking}。',
|
'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.binary': '二进制',
|
||||||
'gitView.history.binaryNoDiff': '二进制文件,无法显示差异',
|
'gitView.history.binaryNoDiff': '二进制文件,无法显示差异',
|
||||||
'gitView.history.commitsPlaceholder': '提交数',
|
'gitView.history.commitsPlaceholder': '提交数',
|
||||||
@@ -518,6 +546,8 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'gitView.history.largeDiffTitle': '大型差异({count} 行变更)',
|
'gitView.history.largeDiffTitle': '大型差异({count} 行变更)',
|
||||||
'gitView.history.loadingDiff': '正在加载差异...',
|
'gitView.history.loadingDiff': '正在加载差异...',
|
||||||
'gitView.history.loadingFiles': '正在加载文件...',
|
'gitView.history.loadingFiles': '正在加载文件...',
|
||||||
|
'gitView.history.loadMore': '加载更多',
|
||||||
|
'gitView.history.loadingMore': '加载中...',
|
||||||
'gitView.history.logSize100': '100 个提交',
|
'gitView.history.logSize100': '100 个提交',
|
||||||
'gitView.history.logSize25': '25 个提交',
|
'gitView.history.logSize25': '25 个提交',
|
||||||
'gitView.history.logSize50': '50 个提交',
|
'gitView.history.logSize50': '50 个提交',
|
||||||
@@ -526,6 +556,7 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'gitView.history.renamedNoDiff': '已重命名文件,不支持显示差异',
|
'gitView.history.renamedNoDiff': '已重命名文件,不支持显示差异',
|
||||||
'gitView.history.renderDiffAnyway': '仍然渲染',
|
'gitView.history.renderDiffAnyway': '仍然渲染',
|
||||||
'gitView.history.title': '历史',
|
'gitView.history.title': '历史',
|
||||||
|
'gitView.graph.title': '图谱',
|
||||||
'gitView.integrate.checking': '检查中…',
|
'gitView.integrate.checking': '检查中…',
|
||||||
'gitView.integrate.cherryPickAbortedToast': '已中止 cherry-pick',
|
'gitView.integrate.cherryPickAbortedToast': '已中止 cherry-pick',
|
||||||
'gitView.integrate.cherryPickConflictDescription': '请先解决冲突,然后继续。',
|
'gitView.integrate.cherryPickConflictDescription': '请先解决冲突,然后继续。',
|
||||||
|
|||||||
@@ -504,12 +504,41 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'gitView.header.identityTooltip': 'Git 身分',
|
'gitView.header.identityTooltip': 'Git 身分',
|
||||||
'gitView.header.noIdentity': '無身分',
|
'gitView.header.noIdentity': '無身分',
|
||||||
'gitView.header.noProfiles': '沒有可套用的設定。',
|
'gitView.header.noProfiles': '沒有可套用的設定。',
|
||||||
|
'gitView.header.repositoryViews': '儲存庫檢視',
|
||||||
'gitView.header.removeRemoteAria': '移除遠端 {name}',
|
'gitView.header.removeRemoteAria': '移除遠端 {name}',
|
||||||
'gitView.header.removeRemoteTitle': '移除 {name}',
|
'gitView.header.removeRemoteTitle': '移除 {name}',
|
||||||
'gitView.header.upstreamSynced': '已同步',
|
'gitView.header.upstreamSynced': '已同步',
|
||||||
'gitView.header.upstreamTooltip': '與 {target} 比較。',
|
'gitView.header.upstreamTooltip': '與 {target} 比較。',
|
||||||
'gitView.header.upstreamTooltipTracking': '與 {target} 比較。主要同步徽章仍反映 {tracking}。',
|
'gitView.header.upstreamTooltipTracking': '與 {target} 比較。主要同步徽章仍反映 {tracking}。',
|
||||||
'gitView.history.binary': '二進位',
|
'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.binaryNoDiff': '二進位檔案 — 無可用 diff',
|
||||||
'gitView.history.commitsPlaceholder': '提交數',
|
'gitView.history.commitsPlaceholder': '提交數',
|
||||||
'gitView.history.copySha': '複製 SHA',
|
'gitView.history.copySha': '複製 SHA',
|
||||||
@@ -526,6 +555,7 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'gitView.history.renamedNoDiff': '已重新命名檔案 — 不支援 diff',
|
'gitView.history.renamedNoDiff': '已重新命名檔案 — 不支援 diff',
|
||||||
'gitView.history.renderDiffAnyway': '仍然渲染',
|
'gitView.history.renderDiffAnyway': '仍然渲染',
|
||||||
'gitView.history.title': '歷史紀錄',
|
'gitView.history.title': '歷史紀錄',
|
||||||
|
'gitView.graph.title': '圖譜',
|
||||||
'gitView.integrate.checking': '檢查中…',
|
'gitView.integrate.checking': '檢查中…',
|
||||||
'gitView.integrate.cherryPickAbortedToast': '已中止 cherry-pick',
|
'gitView.integrate.cherryPickAbortedToast': '已中止 cherry-pick',
|
||||||
'gitView.integrate.cherryPickConflictDescription': '請先解決衝突,然後繼續。',
|
'gitView.integrate.cherryPickConflictDescription': '請先解決衝突,然後繼續。',
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ import { beforeEach, describe, expect, it, mock } from 'bun:test';
|
|||||||
const gitService = {
|
const gitService = {
|
||||||
stageGitFiles: mock(),
|
stageGitFiles: mock(),
|
||||||
unstageGitFiles: mock(),
|
unstageGitFiles: mock(),
|
||||||
|
checkoutCommit: mock(),
|
||||||
|
cherryPick: mock(),
|
||||||
|
revertCommit: mock(),
|
||||||
|
resetToCommit: mock(),
|
||||||
};
|
};
|
||||||
|
|
||||||
mock.module('./gitService', () => gitService);
|
mock.module('./gitService', () => gitService);
|
||||||
@@ -13,6 +17,10 @@ describe('bridge git runtime index mutations', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
gitService.stageGitFiles.mockReset();
|
gitService.stageGitFiles.mockReset();
|
||||||
gitService.unstageGitFiles.mockReset();
|
gitService.unstageGitFiles.mockReset();
|
||||||
|
gitService.checkoutCommit.mockReset();
|
||||||
|
gitService.cherryPick.mockReset();
|
||||||
|
gitService.revertCommit.mockReset();
|
||||||
|
gitService.resetToCommit.mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('accepts legacy stage path payloads', async () => {
|
it('accepts legacy stage path payloads', async () => {
|
||||||
@@ -69,4 +77,36 @@ describe('bridge git runtime index mutations', () => {
|
|||||||
expect(response?.success).toBe(false);
|
expect(response?.success).toBe(false);
|
||||||
expect(gitService.stageGitFiles).not.toHaveBeenCalled();
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ const requireDirectory = (id: string, type: string, directory?: string): BridgeR
|
|||||||
return null;
|
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<BridgeResponse | null> {
|
export async function handleStandardGitBridgeMessage(message: BridgeMessageInput): Promise<BridgeResponse | null> {
|
||||||
const { id, type, payload } = message;
|
const { id, type, payload } = message;
|
||||||
|
|
||||||
@@ -416,17 +420,70 @@ export async function handleStandardGitBridgeMessage(message: BridgeMessageInput
|
|||||||
return { id, type, success: true, data: result };
|
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': {
|
case 'api:git/log': {
|
||||||
const { directory, maxCount, from, to, file } = (payload || {}) as {
|
const { directory, maxCount, from, to, file, all } = (payload || {}) as {
|
||||||
directory?: string;
|
directory?: string;
|
||||||
maxCount?: number;
|
maxCount?: number;
|
||||||
from?: string;
|
from?: string;
|
||||||
to?: string;
|
to?: string;
|
||||||
file?: string;
|
file?: string;
|
||||||
|
all?: boolean;
|
||||||
};
|
};
|
||||||
const dirError = requireDirectory(id, type, directory);
|
const dirError = requireDirectory(id, type, directory);
|
||||||
if (dirError) return dirError;
|
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 };
|
return { id, type, success: true, data: result };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
function extractGitStatusPath(status: string, pathPart: string): string {
|
||||||
if ((status === 'R' || status === 'C') && pathPart.includes('\t')) {
|
if ((status === 'R' || status === 'C') && pathPart.includes('\t')) {
|
||||||
return pathPart.split('\t').pop() || pathPart;
|
return pathPart.split('\t').pop() || pathPart;
|
||||||
@@ -2678,6 +2682,7 @@ export interface GitLogEntry {
|
|||||||
filesChanged: number;
|
filesChanged: number;
|
||||||
insertions: number;
|
insertions: number;
|
||||||
deletions: number;
|
deletions: number;
|
||||||
|
parents: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -2713,10 +2718,73 @@ async function resolveBaseRefForLog(
|
|||||||
*/
|
*/
|
||||||
export async function getGitLog(
|
export async function getGitLog(
|
||||||
directory: string,
|
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 }> {
|
): Promise<{ all: GitLogEntry[]; latest: GitLogEntry | null; total: number }> {
|
||||||
const maxCount = options?.maxCount || 50;
|
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/<from> only when the local ref
|
// Prefer the local ref; fall back to origin/<from> only when the local ref
|
||||||
// cannot be resolved (e.g. user has never checked out the base branch).
|
// cannot be resolved (e.g. user has never checked out the base branch).
|
||||||
const resolvedFrom = await resolveBaseRefForLog(options?.from, directory);
|
const resolvedFrom = await resolveBaseRefForLog(options?.from, directory);
|
||||||
@@ -2724,10 +2792,11 @@ export async function getGitLog(
|
|||||||
const args = [
|
const args = [
|
||||||
'log',
|
'log',
|
||||||
`--max-count=${maxCount}`,
|
`--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',
|
'--shortstat',
|
||||||
];
|
];
|
||||||
|
|
||||||
if (resolvedFrom && options?.to) {
|
if (resolvedFrom && options?.to) {
|
||||||
args.push(`${resolvedFrom}..${options.to}`);
|
args.push(`${resolvedFrom}..${options.to}`);
|
||||||
} else if (resolvedFrom) {
|
} else if (resolvedFrom) {
|
||||||
@@ -2735,51 +2804,70 @@ export async function getGitLog(
|
|||||||
} else if (options?.to) {
|
} else if (options?.to) {
|
||||||
args.push(options.to);
|
args.push(options.to);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options?.file) {
|
if (options?.file) {
|
||||||
args.push('--', options.file);
|
args.push('--', options.file);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await execGit(args, directory);
|
const result = await execGit(args, directory);
|
||||||
|
|
||||||
if (result.exitCode !== 0) {
|
if (result.exitCode !== 0) {
|
||||||
throw new Error(result.stderr.trim() || result.stdout.trim() || 'Failed to get git log');
|
throw new Error(result.stderr.trim() || result.stdout.trim() || 'Failed to get git log');
|
||||||
}
|
}
|
||||||
|
|
||||||
const entries: GitLogEntry[] = [];
|
const records = result.stdout
|
||||||
const lines = result.stdout.split('\n');
|
.split('\x1e')
|
||||||
let current: Partial<GitLogEntry> | null = null;
|
.map((entry) => entry.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
for (const line of lines) {
|
const statsMap = new Map<string, { filesChanged: number; insertions: number; deletions: number; parents: string[] }>();
|
||||||
if (line.includes('|') && !line.startsWith(' ')) {
|
|
||||||
if (current?.hash) {
|
for (const record of records) {
|
||||||
entries.push(current as GitLogEntry);
|
const lines = record.split('\n').filter((line) => line.trim().length > 0);
|
||||||
}
|
const header = lines.shift() || '';
|
||||||
const parts = line.split('|');
|
const [hash, parentsRaw] = header.split('\x1f');
|
||||||
current = {
|
const parents = parentsRaw ? parentsRaw.trim().split(' ').filter(Boolean) : [];
|
||||||
hash: parts[0] || '',
|
if (!hash) continue;
|
||||||
date: parts[1] || '',
|
|
||||||
message: parts[2] || '',
|
let filesChanged = 0;
|
||||||
refs: parts[3] || '',
|
let insertions = 0;
|
||||||
body: parts[4] || '',
|
let deletions = 0;
|
||||||
author_name: parts[5] || '',
|
|
||||||
author_email: parts[6] || '',
|
for (const line of lines) {
|
||||||
filesChanged: 0,
|
const filesMatch = line.match(/(\d+)\s+files?\s+changed/);
|
||||||
insertions: 0,
|
const insertMatch = line.match(/(\d+)\s+insertions?\(\+\)/);
|
||||||
deletions: 0,
|
const deleteMatch = line.match(/(\d+)\s+deletions?\(-\)/);
|
||||||
};
|
if (filesMatch) filesChanged = parseInt(filesMatch[1], 10);
|
||||||
} else if (current && line.includes('file')) {
|
if (insertMatch) insertions = parseInt(insertMatch[1], 10);
|
||||||
const statsMatch = line.match(/(\d+)\s+files?\s+changed(?:,\s+(\d+)\s+insertions?)?(?:,\s+(\d+)\s+deletions?)?/);
|
if (deleteMatch) deletions = parseInt(deleteMatch[1], 10);
|
||||||
if (statsMatch) {
|
|
||||||
current.filesChanged = parseInt(statsMatch[1] || '0', 10);
|
|
||||||
current.insertions = parseInt(statsMatch[2] || '0', 10);
|
|
||||||
current.deletions = parseInt(statsMatch[3] || '0', 10);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
statsMap.set(hash, { filesChanged, insertions, deletions, parents });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (current?.hash) {
|
const entries: GitLogEntry[] = [];
|
||||||
entries.push(current as 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 {
|
return {
|
||||||
@@ -3205,6 +3293,99 @@ export async function continueMerge(directory: string): Promise<{ success: boole
|
|||||||
throw new Error(result.stderr || 'Continue merge failed');
|
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 ==============
|
// ============== Stash Operations ==============
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -35,6 +35,10 @@ import type {
|
|||||||
GitRemote,
|
GitRemote,
|
||||||
GitRebaseResult,
|
GitRebaseResult,
|
||||||
GitMergeResult,
|
GitMergeResult,
|
||||||
|
CheckoutCommitResponse,
|
||||||
|
CherryPickResponse,
|
||||||
|
RevertCommitResponse,
|
||||||
|
ResetToCommitResponse,
|
||||||
} from '@openchamber/ui/lib/api/types';
|
} from '@openchamber/ui/lib/api/types';
|
||||||
|
|
||||||
export const createVSCodeGitAPI = (): GitAPI => ({
|
export const createVSCodeGitAPI = (): GitAPI => ({
|
||||||
@@ -267,6 +271,7 @@ export const createVSCodeGitAPI = (): GitAPI => ({
|
|||||||
from: options?.from,
|
from: options?.from,
|
||||||
to: options?.to,
|
to: options?.to,
|
||||||
file: options?.file,
|
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 });
|
return sendBridgeMessage<{ success: boolean; conflict: boolean; conflictFiles?: string[] }>('api:git/merge/continue', { directory });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
checkoutCommit: async (directory: string, hash: string): Promise<CheckoutCommitResponse> => {
|
||||||
|
return sendBridgeMessage<CheckoutCommitResponse>('api:git/checkout-commit', { directory, hash });
|
||||||
|
},
|
||||||
|
|
||||||
|
cherryPick: async (directory: string, hash: string): Promise<CherryPickResponse> => {
|
||||||
|
return sendBridgeMessage<CherryPickResponse>('api:git/cherry-pick', { directory, hash });
|
||||||
|
},
|
||||||
|
|
||||||
|
revertCommit: async (directory: string, hash: string): Promise<RevertCommitResponse> => {
|
||||||
|
return sendBridgeMessage<RevertCommitResponse>('api:git/revert-commit', { directory, hash });
|
||||||
|
},
|
||||||
|
|
||||||
|
resetToCommit: async (directory: string, hash: string, mode: 'soft' | 'mixed' | 'hard', force?: boolean): Promise<ResetToCommitResponse> => {
|
||||||
|
return sendBridgeMessage<ResetToCommitResponse>('api:git/reset-to-commit', { directory, hash, mode, force });
|
||||||
|
},
|
||||||
|
|
||||||
stash: async (
|
stash: async (
|
||||||
directory: string,
|
directory: string,
|
||||||
options?: { message?: string; includeUntracked?: boolean }
|
options?: { message?: string; includeUntracked?: boolean }
|
||||||
|
|||||||
@@ -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) => {
|
app.get('/api/git/worktrees', async (req, res) => {
|
||||||
const { getWorktrees } = await getGitLibraries();
|
const { getWorktrees } = await getGitLibraries();
|
||||||
try {
|
try {
|
||||||
@@ -956,11 +1035,13 @@ export function registerGitRoutes(app) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { maxCount, from, to, file } = req.query;
|
const { maxCount, from, to, file } = req.query;
|
||||||
|
const all = req.query.all === 'true';
|
||||||
const log = await getLog(directory, {
|
const log = await getLog(directory, {
|
||||||
maxCount: maxCount ? parseInt(maxCount) : undefined,
|
maxCount: maxCount ? parseInt(maxCount) : undefined,
|
||||||
from,
|
from,
|
||||||
to,
|
to,
|
||||||
file
|
file,
|
||||||
|
all
|
||||||
});
|
});
|
||||||
res.json(log);
|
res.json(log);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -636,6 +636,10 @@ const normalizeStartRef = (value) => {
|
|||||||
return trimmed;
|
return trimmed;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function isValidCommitHash(hash) {
|
||||||
|
return typeof hash === 'string' && /^[0-9a-fA-F]{7,40}$/.test(hash);
|
||||||
|
}
|
||||||
|
|
||||||
const parseRemoteBranchRef = (value) => {
|
const parseRemoteBranchRef = (value) => {
|
||||||
const trimmed = String(value || '').trim();
|
const trimmed = String(value || '').trim();
|
||||||
if (!trimmed) {
|
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) {
|
export async function getWorktrees(directory) {
|
||||||
const directoryPath = normalizeDirectoryPath(directory);
|
const directoryPath = normalizeDirectoryPath(directory);
|
||||||
if (!directoryPath || !fs.existsSync(directoryPath)) {
|
if (!directoryPath || !fs.existsSync(directoryPath)) {
|
||||||
@@ -3179,6 +3276,65 @@ export async function getLog(directory, options = {}) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const maxCount = options.maxCount || 50;
|
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
|
const filePath = options.file
|
||||||
? (await resolveGitFileContext(directoryPath, directoryGit, options.file, repoRoot)).repoPath
|
? (await resolveGitFileContext(directoryPath, directoryGit, options.file, repoRoot)).repoPath
|
||||||
: undefined;
|
: undefined;
|
||||||
@@ -3206,7 +3362,7 @@ export async function getLog(directory, options = {}) {
|
|||||||
'log',
|
'log',
|
||||||
`--max-count=${maxCount}`,
|
`--max-count=${maxCount}`,
|
||||||
'--date=iso',
|
'--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'
|
'--shortstat'
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -3233,7 +3389,8 @@ export async function getLog(directory, options = {}) {
|
|||||||
records.forEach((record) => {
|
records.forEach((record) => {
|
||||||
const lines = record.split('\n').filter((line) => line.trim().length > 0);
|
const lines = record.split('\n').filter((line) => line.trim().length > 0);
|
||||||
const header = lines.shift() || '';
|
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) {
|
if (!hash) {
|
||||||
return;
|
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 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 {
|
return {
|
||||||
hash: entry.hash,
|
hash: entry.hash,
|
||||||
date: entry.date,
|
date: entry.date,
|
||||||
@@ -3273,7 +3430,8 @@ export async function getLog(directory, options = {}) {
|
|||||||
author_email: entry.author_email,
|
author_email: entry.author_email,
|
||||||
filesChanged: stats.filesChanged,
|
filesChanged: stats.filesChanged,
|
||||||
insertions: stats.insertions,
|
insertions: stats.insertions,
|
||||||
deletions: stats.deletions
|
deletions: stats.deletions,
|
||||||
|
parents: stats.parents || [],
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,22 +3,38 @@ import fs from 'node:fs';
|
|||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { afterEach, describe, expect, it } from 'vitest';
|
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 = [];
|
const tempDirs = [];
|
||||||
|
|
||||||
|
/** Create a temp dir and register it for afterEach cleanup. */
|
||||||
const createTempDir = () => {
|
const createTempDir = () => {
|
||||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-git-service-'));
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-git-service-'));
|
||||||
tempDirs.push(dir);
|
tempDirs.push(dir);
|
||||||
return dir;
|
return dir;
|
||||||
};
|
};
|
||||||
|
|
||||||
const runGit = (cwd, args) => execFileSync('git', args, {
|
const runGit = (cwd, args) =>
|
||||||
cwd,
|
execFileSync('git', args, {
|
||||||
encoding: 'utf8',
|
cwd,
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
encoding: 'utf8',
|
||||||
});
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
|
||||||
const canRunGit = () => {
|
const canRunGit = () => {
|
||||||
try {
|
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', () => {
|
describe('resolveBaseRefForLog', () => {
|
||||||
it('returns the local ref unchanged when it exists, even if origin also exists', async () => {
|
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';
|
const checkRef = async (ref) => ref === 'main' || ref === 'refs/remotes/origin/main';
|
||||||
expect(await resolveBaseRefForLog('main', checkRef)).toBe('main');
|
expect(await resolveBaseRefForLog('main', checkRef)).toBe('main');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('falls back to origin/<from> when local ref cannot be resolved but origin can', async () => {
|
it('falls back to origin/<from> 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';
|
const checkRef = async (ref) => ref === 'refs/remotes/origin/main';
|
||||||
expect(await resolveBaseRefForLog('main', checkRef)).toBe('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 () => {
|
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;
|
const checkRef = async () => false;
|
||||||
expect(await resolveBaseRefForLog('nonexistent-branch', checkRef)).toBe('nonexistent-branch');
|
expect(await resolveBaseRefForLog('nonexistent-branch', checkRef)).toBe('nonexistent-branch');
|
||||||
});
|
});
|
||||||
@@ -71,21 +101,31 @@ describe('resolveBaseRefForLog', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// git index path validation
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
describe('git index path validation', () => {
|
describe('git index path validation', () => {
|
||||||
it('rejects stage paths outside the repository before invoking git', async () => {
|
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 () => {
|
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', () => {
|
describe('getStatus', () => {
|
||||||
it('handles repositories without upstream tracking', async () => {
|
it('handles repositories without upstream tracking', async () => {
|
||||||
if (!canRunGit()) {
|
if (!canRunGit()) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const repo = createTempDir();
|
const repo = createTempDir();
|
||||||
runGit(repo, ['init', '-b', 'main']);
|
runGit(repo, ['init', '-b', 'main']);
|
||||||
@@ -95,8 +135,321 @@ describe('getStatus', () => {
|
|||||||
runGit(repo, ['add', 'README.md']);
|
runGit(repo, ['add', 'README.md']);
|
||||||
runGit(repo, ['commit', '-m', 'Initial commit']);
|
runGit(repo, ['commit', '-m', 'Initial commit']);
|
||||||
|
|
||||||
await expect(getStatus(repo)).resolves.toMatchObject({
|
await expect(getStatus(repo)).resolves.toMatchObject({ current: 'main' });
|
||||||
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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -61,6 +61,10 @@ export const createWebGitAPI = (): GitAPI => ({
|
|||||||
merge: gitApiHttp.merge,
|
merge: gitApiHttp.merge,
|
||||||
abortMerge: gitApiHttp.abortMerge,
|
abortMerge: gitApiHttp.abortMerge,
|
||||||
continueMerge: gitApiHttp.continueMerge,
|
continueMerge: gitApiHttp.continueMerge,
|
||||||
|
checkoutCommit: gitApiHttp.checkoutCommit,
|
||||||
|
cherryPick: gitApiHttp.cherryPick,
|
||||||
|
revertCommit: gitApiHttp.revertCommit,
|
||||||
|
resetToCommit: gitApiHttp.resetToCommit,
|
||||||
stash: gitApiHttp.stash,
|
stash: gitApiHttp.stash,
|
||||||
stashPop: gitApiHttp.stashPop,
|
stashPop: gitApiHttp.stashPop,
|
||||||
getConflictDetails: gitApiHttp.getConflictDetails,
|
getConflictDetails: gitApiHttp.getConflictDetails,
|
||||||
|
|||||||
Reference in New Issue
Block a user