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
@@ -279,6 +279,10 @@ async function execGit(args: string[], cwd: string): Promise<{ stdout: string; s
|
||||
});
|
||||
}
|
||||
|
||||
function isValidCommitHash(hash: string): boolean {
|
||||
return /^[0-9a-fA-F]{7,40}$/.test(hash);
|
||||
}
|
||||
|
||||
function extractGitStatusPath(status: string, pathPart: string): string {
|
||||
if ((status === 'R' || status === 'C') && pathPart.includes('\t')) {
|
||||
return pathPart.split('\t').pop() || pathPart;
|
||||
@@ -2678,6 +2682,7 @@ export interface GitLogEntry {
|
||||
filesChanged: number;
|
||||
insertions: number;
|
||||
deletions: number;
|
||||
parents: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2713,10 +2718,73 @@ async function resolveBaseRefForLog(
|
||||
*/
|
||||
export async function getGitLog(
|
||||
directory: string,
|
||||
options?: { maxCount?: number; from?: string; to?: string; file?: string }
|
||||
options?: { maxCount?: number; from?: string; to?: string; file?: string; all?: boolean }
|
||||
): Promise<{ all: GitLogEntry[]; latest: GitLogEntry | null; total: number }> {
|
||||
const maxCount = options?.maxCount || 50;
|
||||
|
||||
if (options?.all) {
|
||||
const logArgs = [
|
||||
'log',
|
||||
`--max-count=${maxCount}`,
|
||||
'--all',
|
||||
'--topo-order',
|
||||
'--date=iso',
|
||||
'--pretty=format:%x1e%H%x1f%P%x1f%an%x1f%ae%x1f%ad%x1f%s%x1f%D',
|
||||
'--shortstat',
|
||||
];
|
||||
|
||||
const result = await execGit(logArgs, directory);
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error(result.stderr.trim() || result.stdout.trim() || 'Failed to get git log');
|
||||
}
|
||||
|
||||
const records = result.stdout
|
||||
.split('\x1e')
|
||||
.map((e) => e.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const entries: GitLogEntry[] = [];
|
||||
for (const record of records) {
|
||||
const lines = record.split('\n').filter((l) => l.trim().length > 0);
|
||||
const header = lines.shift() || '';
|
||||
const [hash, parentsRaw, author_name, author_email, date, message, refsRaw] =
|
||||
header.split('\x1f');
|
||||
if (!hash) continue;
|
||||
|
||||
const parents = parentsRaw ? parentsRaw.trim().split(' ').filter(Boolean) : [];
|
||||
const refs = refsRaw ? refsRaw.trim() : '';
|
||||
|
||||
let filesChanged = 0;
|
||||
let insertions = 0;
|
||||
let deletions = 0;
|
||||
for (const line of lines) {
|
||||
const filesMatch = line.match(/(\d+)\s+files?\s+changed/);
|
||||
const insertMatch = line.match(/(\d+)\s+insertions?\(\+\)/);
|
||||
const deleteMatch = line.match(/(\d+)\s+deletions?\(-\)/);
|
||||
if (filesMatch) filesChanged = parseInt(filesMatch[1], 10);
|
||||
if (insertMatch) insertions = parseInt(insertMatch[1], 10);
|
||||
if (deleteMatch) deletions = parseInt(deleteMatch[1], 10);
|
||||
}
|
||||
|
||||
entries.push({
|
||||
hash,
|
||||
date: date || '',
|
||||
message: message || '',
|
||||
refs,
|
||||
body: '',
|
||||
author_name: author_name || '',
|
||||
author_email: author_email || '',
|
||||
filesChanged,
|
||||
insertions,
|
||||
deletions,
|
||||
parents,
|
||||
});
|
||||
}
|
||||
|
||||
return { all: entries, latest: entries[0] || null, total: entries.length };
|
||||
}
|
||||
|
||||
// Prefer the local ref; fall back to origin/<from> only when the local ref
|
||||
// cannot be resolved (e.g. user has never checked out the base branch).
|
||||
const resolvedFrom = await resolveBaseRefForLog(options?.from, directory);
|
||||
@@ -2724,10 +2792,11 @@ export async function getGitLog(
|
||||
const args = [
|
||||
'log',
|
||||
`--max-count=${maxCount}`,
|
||||
'--format=%H|%aI|%s|%D|%b|%an|%ae',
|
||||
'--date=iso',
|
||||
'--pretty=format:%x1e%H%x1f%P%x1f%an%x1f%ae%x1f%ad%x1f%s%x1f%D',
|
||||
'--shortstat',
|
||||
];
|
||||
|
||||
|
||||
if (resolvedFrom && options?.to) {
|
||||
args.push(`${resolvedFrom}..${options.to}`);
|
||||
} else if (resolvedFrom) {
|
||||
@@ -2735,51 +2804,70 @@ export async function getGitLog(
|
||||
} else if (options?.to) {
|
||||
args.push(options.to);
|
||||
}
|
||||
|
||||
|
||||
if (options?.file) {
|
||||
args.push('--', options.file);
|
||||
}
|
||||
|
||||
const result = await execGit(args, directory);
|
||||
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error(result.stderr.trim() || result.stdout.trim() || 'Failed to get git log');
|
||||
}
|
||||
|
||||
const entries: GitLogEntry[] = [];
|
||||
const lines = result.stdout.split('\n');
|
||||
let current: Partial<GitLogEntry> | null = null;
|
||||
const records = result.stdout
|
||||
.split('\x1e')
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.includes('|') && !line.startsWith(' ')) {
|
||||
if (current?.hash) {
|
||||
entries.push(current as GitLogEntry);
|
||||
}
|
||||
const parts = line.split('|');
|
||||
current = {
|
||||
hash: parts[0] || '',
|
||||
date: parts[1] || '',
|
||||
message: parts[2] || '',
|
||||
refs: parts[3] || '',
|
||||
body: parts[4] || '',
|
||||
author_name: parts[5] || '',
|
||||
author_email: parts[6] || '',
|
||||
filesChanged: 0,
|
||||
insertions: 0,
|
||||
deletions: 0,
|
||||
};
|
||||
} else if (current && line.includes('file')) {
|
||||
const statsMatch = line.match(/(\d+)\s+files?\s+changed(?:,\s+(\d+)\s+insertions?)?(?:,\s+(\d+)\s+deletions?)?/);
|
||||
if (statsMatch) {
|
||||
current.filesChanged = parseInt(statsMatch[1] || '0', 10);
|
||||
current.insertions = parseInt(statsMatch[2] || '0', 10);
|
||||
current.deletions = parseInt(statsMatch[3] || '0', 10);
|
||||
}
|
||||
const statsMap = new Map<string, { filesChanged: number; insertions: number; deletions: number; parents: string[] }>();
|
||||
|
||||
for (const record of records) {
|
||||
const lines = record.split('\n').filter((line) => line.trim().length > 0);
|
||||
const header = lines.shift() || '';
|
||||
const [hash, parentsRaw] = header.split('\x1f');
|
||||
const parents = parentsRaw ? parentsRaw.trim().split(' ').filter(Boolean) : [];
|
||||
if (!hash) continue;
|
||||
|
||||
let filesChanged = 0;
|
||||
let insertions = 0;
|
||||
let deletions = 0;
|
||||
|
||||
for (const line of lines) {
|
||||
const filesMatch = line.match(/(\d+)\s+files?\s+changed/);
|
||||
const insertMatch = line.match(/(\d+)\s+insertions?\(\+\)/);
|
||||
const deleteMatch = line.match(/(\d+)\s+deletions?\(-\)/);
|
||||
if (filesMatch) filesChanged = parseInt(filesMatch[1], 10);
|
||||
if (insertMatch) insertions = parseInt(insertMatch[1], 10);
|
||||
if (deleteMatch) deletions = parseInt(deleteMatch[1], 10);
|
||||
}
|
||||
|
||||
statsMap.set(hash, { filesChanged, insertions, deletions, parents });
|
||||
}
|
||||
|
||||
if (current?.hash) {
|
||||
entries.push(current as GitLogEntry);
|
||||
const entries: GitLogEntry[] = [];
|
||||
for (const record of records) {
|
||||
const header = record.split('\n').filter((l) => l.trim().length > 0)[0] || '';
|
||||
const [hash] = header.split('\x1f');
|
||||
if (!hash) continue;
|
||||
const stats = statsMap.get(hash) || { filesChanged: 0, insertions: 0, deletions: 0, parents: [] };
|
||||
// Need to re-parse header fields for the final entries array
|
||||
const lines = record.split('\n').filter((l) => l.trim().length > 0);
|
||||
const lineHeader = lines.shift() || '';
|
||||
const [, , author_name, author_email, date, message, refs] = lineHeader.split('\x1f');
|
||||
entries.push({
|
||||
hash,
|
||||
date: date || '',
|
||||
message: message || '',
|
||||
refs: refs?.trim() || '',
|
||||
body: '',
|
||||
author_name: author_name || '',
|
||||
author_email: author_email || '',
|
||||
filesChanged: stats.filesChanged,
|
||||
insertions: stats.insertions,
|
||||
deletions: stats.deletions,
|
||||
parents: stats.parents,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -3205,6 +3293,99 @@ export async function continueMerge(directory: string): Promise<{ success: boole
|
||||
throw new Error(result.stderr || 'Continue merge failed');
|
||||
}
|
||||
|
||||
// ============== Commit Actions ==============
|
||||
|
||||
export async function checkoutCommit(directory: string, hash: string): Promise<{ success: boolean }> {
|
||||
if (!isValidCommitHash(hash)) {
|
||||
throw new Error('Invalid commit hash');
|
||||
}
|
||||
const result = await execGit(['checkout', hash], directory);
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error(result.stderr || 'Failed to checkout commit');
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function cherryPick(directory: string, hash: string): Promise<{ success: boolean; conflict: boolean; conflictFiles?: string[] }> {
|
||||
if (!isValidCommitHash(hash)) {
|
||||
throw new Error('Invalid commit hash');
|
||||
}
|
||||
const result = await execGit(['cherry-pick', hash], directory);
|
||||
|
||||
if (result.exitCode === 0) {
|
||||
return { success: true, conflict: false };
|
||||
}
|
||||
|
||||
const output = (result.stdout + result.stderr).toLowerCase();
|
||||
const isConflict =
|
||||
output.includes('conflict') ||
|
||||
output.includes('patch does not apply');
|
||||
|
||||
if (isConflict) {
|
||||
const statusResult = await execGit(['status', '--porcelain'], directory);
|
||||
const conflictFiles = statusResult.stdout
|
||||
.split('\n')
|
||||
.filter((line) => line.startsWith('UU') || line.startsWith('AA') || line.startsWith('DD'))
|
||||
.map((line) => line.slice(3).trim());
|
||||
|
||||
return { success: false, conflict: true, conflictFiles };
|
||||
}
|
||||
|
||||
throw new Error(result.stderr || 'Cherry-pick failed');
|
||||
}
|
||||
|
||||
export async function revertCommit(directory: string, hash: string): Promise<{ success: boolean; conflict: boolean; conflictFiles?: string[] }> {
|
||||
if (!isValidCommitHash(hash)) {
|
||||
throw new Error('Invalid commit hash');
|
||||
}
|
||||
const result = await execGit(['revert', '--no-commit', hash], directory);
|
||||
|
||||
if (result.exitCode === 0) {
|
||||
return { success: true, conflict: false };
|
||||
}
|
||||
|
||||
const output = (result.stdout + result.stderr).toLowerCase();
|
||||
const isConflict =
|
||||
output.includes('conflict') ||
|
||||
output.includes('revert failed');
|
||||
|
||||
if (isConflict) {
|
||||
const statusResult = await execGit(['status', '--porcelain'], directory);
|
||||
const conflictFiles = statusResult.stdout
|
||||
.split('\n')
|
||||
.filter((line) => line.startsWith('UU') || line.startsWith('AA') || line.startsWith('DD'))
|
||||
.map((line) => line.slice(3).trim());
|
||||
|
||||
return { success: false, conflict: true, conflictFiles };
|
||||
}
|
||||
|
||||
throw new Error(result.stderr || 'Revert failed');
|
||||
}
|
||||
|
||||
export async function resetToCommit(
|
||||
directory: string,
|
||||
hash: string,
|
||||
mode: 'soft' | 'mixed' | 'hard',
|
||||
force = false
|
||||
): Promise<{ success: boolean }> {
|
||||
if (!isValidCommitHash(hash)) {
|
||||
throw new Error('Invalid commit hash');
|
||||
}
|
||||
if (mode === 'hard' && !force) {
|
||||
const statusResult = await execGit(['status', '--porcelain'], directory);
|
||||
const isDirty = statusResult.stdout.trim().length > 0;
|
||||
if (isDirty) {
|
||||
throw new Error('Cannot hard reset: uncommitted changes in working tree. Stash or commit first, or use force.');
|
||||
}
|
||||
}
|
||||
|
||||
const result = await execGit(['reset', `--${mode}`, hash], directory);
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error(result.stderr || 'Reset failed');
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// ============== Stash Operations ==============
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user