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
@@ -766,6 +766,85 @@ export function registerGitRoutes(app) {
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/git/checkout-commit', async (req, res) => {
|
||||
const { checkoutCommit } = await getGitLibraries();
|
||||
try {
|
||||
const directory = req.query.directory;
|
||||
if (!directory) {
|
||||
return res.status(400).json({ error: 'directory parameter is required' });
|
||||
}
|
||||
const { hash } = req.body;
|
||||
if (!req.body.hash || typeof req.body.hash !== 'string' || !/^[0-9a-fA-F]{7,40}$/.test(req.body.hash)) {
|
||||
return res.status(400).json({ error: 'Invalid commit hash' });
|
||||
}
|
||||
const result = await checkoutCommit(directory, hash);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error('Failed to checkout commit:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to checkout commit' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/git/cherry-pick', async (req, res) => {
|
||||
const { cherryPick } = await getGitLibraries();
|
||||
try {
|
||||
const directory = req.query.directory;
|
||||
if (!directory) {
|
||||
return res.status(400).json({ error: 'directory parameter is required' });
|
||||
}
|
||||
const { hash } = req.body;
|
||||
if (!req.body.hash || typeof req.body.hash !== 'string' || !/^[0-9a-fA-F]{7,40}$/.test(req.body.hash)) {
|
||||
return res.status(400).json({ error: 'Invalid commit hash' });
|
||||
}
|
||||
const result = await cherryPick(directory, hash);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error('Failed to cherry-pick:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to cherry-pick' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/git/revert-commit', async (req, res) => {
|
||||
const { revertCommit } = await getGitLibraries();
|
||||
try {
|
||||
const directory = req.query.directory;
|
||||
if (!directory) {
|
||||
return res.status(400).json({ error: 'directory parameter is required' });
|
||||
}
|
||||
const { hash } = req.body;
|
||||
if (!req.body.hash || typeof req.body.hash !== 'string' || !/^[0-9a-fA-F]{7,40}$/.test(req.body.hash)) {
|
||||
return res.status(400).json({ error: 'Invalid commit hash' });
|
||||
}
|
||||
const result = await revertCommit(directory, hash);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error('Failed to revert commit:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to revert commit' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/git/reset-to-commit', async (req, res) => {
|
||||
const { resetToCommit } = await getGitLibraries();
|
||||
try {
|
||||
const directory = req.query.directory;
|
||||
if (!directory) {
|
||||
return res.status(400).json({ error: 'directory parameter is required' });
|
||||
}
|
||||
const { hash, mode, force } = req.body;
|
||||
if (!req.body.hash || typeof req.body.hash !== 'string' || !/^[0-9a-fA-F]{7,40}$/.test(req.body.hash)) {
|
||||
return res.status(400).json({ error: 'Invalid commit hash' });
|
||||
}
|
||||
if (!['soft', 'mixed', 'hard'].includes(mode)) {
|
||||
return res.status(400).json({ error: 'mode must be soft, mixed, or hard' });
|
||||
}
|
||||
const result = await resetToCommit(directory, hash, mode, force === true);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error('Failed to reset to commit:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to reset' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/git/worktrees', async (req, res) => {
|
||||
const { getWorktrees } = await getGitLibraries();
|
||||
try {
|
||||
@@ -956,11 +1035,13 @@ export function registerGitRoutes(app) {
|
||||
}
|
||||
|
||||
const { maxCount, from, to, file } = req.query;
|
||||
const all = req.query.all === 'true';
|
||||
const log = await getLog(directory, {
|
||||
maxCount: maxCount ? parseInt(maxCount) : undefined,
|
||||
from,
|
||||
to,
|
||||
file
|
||||
file,
|
||||
all
|
||||
});
|
||||
res.json(log);
|
||||
} catch (error) {
|
||||
|
||||
@@ -636,6 +636,10 @@ const normalizeStartRef = (value) => {
|
||||
return trimmed;
|
||||
};
|
||||
|
||||
function isValidCommitHash(hash) {
|
||||
return typeof hash === 'string' && /^[0-9a-fA-F]{7,40}$/.test(hash);
|
||||
}
|
||||
|
||||
const parseRemoteBranchRef = (value) => {
|
||||
const trimmed = String(value || '').trim();
|
||||
if (!trimmed) {
|
||||
@@ -2692,6 +2696,99 @@ export async function checkoutBranch(directory, branchName) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkoutCommit(directory, hash) {
|
||||
if (!isValidCommitHash(hash)) {
|
||||
throw new Error('Invalid commit hash');
|
||||
}
|
||||
const { git } = await createRepositoryGitContext(directory);
|
||||
try {
|
||||
await git.checkout(hash);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Failed to checkout commit:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function cherryPick(directory, hash) {
|
||||
if (!isValidCommitHash(hash)) {
|
||||
throw new Error('Invalid commit hash');
|
||||
}
|
||||
const { git } = await createRepositoryGitContext(directory);
|
||||
try {
|
||||
await git.raw(['cherry-pick', hash]);
|
||||
return { success: true, conflict: false };
|
||||
} catch (error) {
|
||||
const errorMessage = String(error?.message || error || '').toLowerCase();
|
||||
const isConflict =
|
||||
errorMessage.includes('conflict') ||
|
||||
errorMessage.includes('patch does not apply');
|
||||
|
||||
if (isConflict) {
|
||||
const status = await git.status().catch(() => ({ conflicted: [] }));
|
||||
return {
|
||||
success: false,
|
||||
conflict: true,
|
||||
conflictFiles: status.conflicted || [],
|
||||
};
|
||||
}
|
||||
|
||||
console.error('Failed to cherry-pick:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function revertCommit(directory, hash) {
|
||||
if (!isValidCommitHash(hash)) {
|
||||
throw new Error('Invalid commit hash');
|
||||
}
|
||||
const { git } = await createRepositoryGitContext(directory);
|
||||
try {
|
||||
await git.raw(['revert', '--no-commit', hash]);
|
||||
return { success: true, conflict: false };
|
||||
} catch (error) {
|
||||
const errorMessage = String(error?.message || error || '').toLowerCase();
|
||||
const isConflict =
|
||||
errorMessage.includes('conflict') ||
|
||||
errorMessage.includes('revert failed');
|
||||
|
||||
if (isConflict) {
|
||||
const status = await git.status().catch(() => ({ conflicted: [] }));
|
||||
return {
|
||||
success: false,
|
||||
conflict: true,
|
||||
conflictFiles: status.conflicted || [],
|
||||
};
|
||||
}
|
||||
|
||||
console.error('Failed to revert commit:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function resetToCommit(directory, hash, mode, force = false) {
|
||||
if (!isValidCommitHash(hash)) {
|
||||
throw new Error('Invalid commit hash');
|
||||
}
|
||||
const { git } = await createRepositoryGitContext(directory);
|
||||
|
||||
if (mode === 'hard' && !force) {
|
||||
const status = await git.status();
|
||||
const isDirty = !status.isClean();
|
||||
if (isDirty) {
|
||||
throw new Error('Cannot hard reset: uncommitted changes in working tree. Stash or commit first, or use force.');
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await git.raw(['reset', `--${mode}`, hash]);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Failed to reset to commit:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getWorktrees(directory) {
|
||||
const directoryPath = normalizeDirectoryPath(directory);
|
||||
if (!directoryPath || !fs.existsSync(directoryPath)) {
|
||||
@@ -3179,6 +3276,65 @@ export async function getLog(directory, options = {}) {
|
||||
|
||||
try {
|
||||
const maxCount = options.maxCount || 50;
|
||||
|
||||
if (options.all) {
|
||||
const logArgs = [
|
||||
'log',
|
||||
`--max-count=${maxCount}`,
|
||||
'--all',
|
||||
'--topo-order',
|
||||
'--date=iso',
|
||||
'--pretty=format:%x1e%H%x1f%P%x1f%an%x1f%ae%x1f%ad%x1f%s%x1f%D',
|
||||
'--shortstat',
|
||||
];
|
||||
|
||||
const rawLog = await git.raw(logArgs);
|
||||
const records = rawLog
|
||||
.split('\x1e')
|
||||
.map((e) => e.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const entries = [];
|
||||
for (const record of records) {
|
||||
const lines = record.split('\n').filter((l) => l.trim().length > 0);
|
||||
const header = lines.shift() || '';
|
||||
const [hash, parentsRaw, author_name, author_email, date, message, refsRaw] =
|
||||
header.split('\x1f');
|
||||
if (!hash) continue;
|
||||
|
||||
const parents = parentsRaw ? parentsRaw.trim().split(' ').filter(Boolean) : [];
|
||||
const refs = refsRaw ? refsRaw.trim() : '';
|
||||
|
||||
let filesChanged = 0;
|
||||
let insertions = 0;
|
||||
let deletions = 0;
|
||||
for (const line of lines) {
|
||||
const filesMatch = line.match(/(\d+)\s+files?\s+changed/);
|
||||
const insertMatch = line.match(/(\d+)\s+insertions?\(\+\)/);
|
||||
const deleteMatch = line.match(/(\d+)\s+deletions?\(-\)/);
|
||||
if (filesMatch) filesChanged = parseInt(filesMatch[1], 10);
|
||||
if (insertMatch) insertions = parseInt(insertMatch[1], 10);
|
||||
if (deleteMatch) deletions = parseInt(deleteMatch[1], 10);
|
||||
}
|
||||
|
||||
entries.push({
|
||||
hash,
|
||||
date: date || '',
|
||||
message: message || '',
|
||||
refs,
|
||||
body: '',
|
||||
author_name: author_name || '',
|
||||
author_email: author_email || '',
|
||||
filesChanged,
|
||||
insertions,
|
||||
deletions,
|
||||
parents,
|
||||
});
|
||||
}
|
||||
|
||||
return { all: entries, latest: entries[0] || null, total: entries.length };
|
||||
}
|
||||
|
||||
const filePath = options.file
|
||||
? (await resolveGitFileContext(directoryPath, directoryGit, options.file, repoRoot)).repoPath
|
||||
: undefined;
|
||||
@@ -3206,7 +3362,7 @@ export async function getLog(directory, options = {}) {
|
||||
'log',
|
||||
`--max-count=${maxCount}`,
|
||||
'--date=iso',
|
||||
'--pretty=format:%H%x1f%an%x1f%ae%x1f%ad%x1f%s%x1e',
|
||||
'--pretty=format:%x1e%H%x1f%P%x1f%an%x1f%ae%x1f%ad%x1f%s',
|
||||
'--shortstat'
|
||||
];
|
||||
|
||||
@@ -3233,7 +3389,8 @@ export async function getLog(directory, options = {}) {
|
||||
records.forEach((record) => {
|
||||
const lines = record.split('\n').filter((line) => line.trim().length > 0);
|
||||
const header = lines.shift() || '';
|
||||
const [hash] = header.split('\x1f');
|
||||
const [hash, parentsRaw] = header.split('\x1f');
|
||||
const parents = parentsRaw ? parentsRaw.trim().split(' ').filter(Boolean) : [];
|
||||
if (!hash) {
|
||||
return;
|
||||
}
|
||||
@@ -3258,11 +3415,11 @@ export async function getLog(directory, options = {}) {
|
||||
}
|
||||
});
|
||||
|
||||
statsMap.set(hash, { filesChanged, insertions, deletions });
|
||||
statsMap.set(hash, { filesChanged, insertions, deletions, parents });
|
||||
});
|
||||
|
||||
const merged = baseLog.all.map((entry) => {
|
||||
const stats = statsMap.get(entry.hash) || { filesChanged: 0, insertions: 0, deletions: 0 };
|
||||
const stats = statsMap.get(entry.hash) || { filesChanged: 0, insertions: 0, deletions: 0, parents: [] };
|
||||
return {
|
||||
hash: entry.hash,
|
||||
date: entry.date,
|
||||
@@ -3273,7 +3430,8 @@ export async function getLog(directory, options = {}) {
|
||||
author_email: entry.author_email,
|
||||
filesChanged: stats.filesChanged,
|
||||
insertions: stats.insertions,
|
||||
deletions: stats.deletions
|
||||
deletions: stats.deletions,
|
||||
parents: stats.parents || [],
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -3,22 +3,38 @@ import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import simpleGit from 'simple-git';
|
||||
|
||||
import { getStatus, resolveBaseRefForLog, stageFiles, unstageFiles } from './service.js';
|
||||
import {
|
||||
checkoutCommit,
|
||||
cherryPick,
|
||||
getStatus,
|
||||
resetToCommit,
|
||||
resolveBaseRefForLog,
|
||||
revertCommit,
|
||||
stageFiles,
|
||||
unstageFiles,
|
||||
} from './service.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared test infrastructure
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const tempDirs = [];
|
||||
|
||||
/** Create a temp dir and register it for afterEach cleanup. */
|
||||
const createTempDir = () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-git-service-'));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
};
|
||||
|
||||
const runGit = (cwd, args) => execFileSync('git', args, {
|
||||
cwd,
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
const runGit = (cwd, args) =>
|
||||
execFileSync('git', args, {
|
||||
cwd,
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
const canRunGit = () => {
|
||||
try {
|
||||
@@ -35,22 +51,36 @@ afterEach(() => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Create a temp repo using simple-git (for tests that need its assertion API).
|
||||
* The dir is registered in tempDirs so afterEach handles cleanup automatically.
|
||||
*/
|
||||
async function createTempRepo() {
|
||||
const tmpDir = createTempDir();
|
||||
const git = simpleGit(tmpDir);
|
||||
await git.init();
|
||||
await git.addConfig('user.name', 'Test User', false, 'local');
|
||||
await git.addConfig('user.email', 'test@example.com', false, 'local');
|
||||
await git.raw(['symbolic-ref', 'HEAD', 'refs/heads/main']);
|
||||
return { tmpDir, git };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// resolveBaseRefForLog
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('resolveBaseRefForLog', () => {
|
||||
it('returns the local ref unchanged when it exists, even if origin also exists', async () => {
|
||||
// Both local 'main' and 'refs/remotes/origin/main' are present.
|
||||
// The local ref takes precedence — callers that ask for 'main' get 'main'.
|
||||
const checkRef = async (ref) => ref === 'main' || ref === 'refs/remotes/origin/main';
|
||||
expect(await resolveBaseRefForLog('main', checkRef)).toBe('main');
|
||||
});
|
||||
|
||||
it('falls back to origin/<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';
|
||||
expect(await resolveBaseRefForLog('main', checkRef)).toBe('origin/main');
|
||||
});
|
||||
|
||||
it('returns the original ref when neither local nor origin ref can be resolved', async () => {
|
||||
// Neither ref exists; return as-is so git surfaces a meaningful error.
|
||||
const checkRef = async () => false;
|
||||
expect(await resolveBaseRefForLog('nonexistent-branch', checkRef)).toBe('nonexistent-branch');
|
||||
});
|
||||
@@ -71,21 +101,31 @@ describe('resolveBaseRefForLog', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// git index path validation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('git index path validation', () => {
|
||||
it('rejects stage paths outside the repository before invoking git', async () => {
|
||||
await expect(stageFiles('/repo', ['../secret.txt'])).rejects.toThrow('Path is outside repository: ../secret.txt');
|
||||
await expect(stageFiles('/repo', ['../secret.txt'])).rejects.toThrow(
|
||||
'Path is outside repository: ../secret.txt'
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects unstage paths outside the repository before invoking git', async () => {
|
||||
await expect(unstageFiles('/repo', ['../secret.txt'])).rejects.toThrow('Path is outside repository: ../secret.txt');
|
||||
await expect(unstageFiles('/repo', ['../secret.txt'])).rejects.toThrow(
|
||||
'Path is outside repository: ../secret.txt'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// getStatus
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('getStatus', () => {
|
||||
it('handles repositories without upstream tracking', async () => {
|
||||
if (!canRunGit()) {
|
||||
return;
|
||||
}
|
||||
if (!canRunGit()) return;
|
||||
|
||||
const repo = createTempDir();
|
||||
runGit(repo, ['init', '-b', 'main']);
|
||||
@@ -95,8 +135,321 @@ describe('getStatus', () => {
|
||||
runGit(repo, ['add', 'README.md']);
|
||||
runGit(repo, ['commit', '-m', 'Initial commit']);
|
||||
|
||||
await expect(getStatus(repo)).resolves.toMatchObject({
|
||||
current: 'main',
|
||||
});
|
||||
await expect(getStatus(repo)).resolves.toMatchObject({ current: 'main' });
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// checkoutCommit
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('checkoutCommit', () => {
|
||||
it('checks out a valid commit and puts the repo in detached HEAD state', async () => {
|
||||
const { tmpDir, git } = await createTempRepo();
|
||||
const filePath = path.join(tmpDir, 'file.txt');
|
||||
await fs.promises.writeFile(filePath, 'first', 'utf8');
|
||||
await git.add('file.txt');
|
||||
const firstCommit = await git.commit('First commit');
|
||||
|
||||
await fs.promises.writeFile(filePath, 'second', 'utf8');
|
||||
await git.add('file.txt');
|
||||
await git.commit('Second commit');
|
||||
|
||||
const result = await checkoutCommit(tmpDir, firstCommit.commit);
|
||||
expect(result).toEqual({ success: true });
|
||||
|
||||
const status = await git.status();
|
||||
expect(status.detached).toBe(true);
|
||||
});
|
||||
|
||||
it('throws an error for an invalid/nonexistent hash', async () => {
|
||||
const { tmpDir } = await createTempRepo();
|
||||
await expect(checkoutCommit(tmpDir, 'invalidhash123')).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// cherryPick
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('cherryPick', () => {
|
||||
it('cherry-picks a commit that applies cleanly', async () => {
|
||||
const { tmpDir, git } = await createTempRepo();
|
||||
const filePath = path.join(tmpDir, 'file.txt');
|
||||
await fs.promises.writeFile(filePath, 'line1\nline2\n', 'utf8');
|
||||
await git.add('file.txt');
|
||||
await git.commit('Initial commit');
|
||||
|
||||
await git.checkoutBranch('feature', 'HEAD');
|
||||
await fs.promises.writeFile(filePath, 'line1\nline2\nline3\n', 'utf8');
|
||||
await git.add('file.txt');
|
||||
const featureCommit = await git.commit('Add line3');
|
||||
|
||||
await git.checkout('main');
|
||||
const result = await cherryPick(tmpDir, featureCommit.commit);
|
||||
expect(result).toEqual({ success: true, conflict: false });
|
||||
|
||||
const content = await fs.promises.readFile(filePath, 'utf8');
|
||||
expect(content).toBe('line1\nline2\nline3\n');
|
||||
});
|
||||
|
||||
it('returns conflict info when cherry-picking a conflicting commit', async () => {
|
||||
const { tmpDir, git } = await createTempRepo();
|
||||
const filePath = path.join(tmpDir, 'file.txt');
|
||||
await fs.promises.writeFile(filePath, 'line1\nline2\n', 'utf8');
|
||||
await git.add('file.txt');
|
||||
await git.commit('Initial commit');
|
||||
|
||||
await git.checkoutBranch('feature', 'HEAD');
|
||||
await fs.promises.writeFile(filePath, 'line1\nfeature-line2\n', 'utf8');
|
||||
await git.add('file.txt');
|
||||
const featureCommit = await git.commit('Change line2 in feature');
|
||||
|
||||
await git.checkout('main');
|
||||
await fs.promises.writeFile(filePath, 'line1\nmain-line2\n', 'utf8');
|
||||
await git.add('file.txt');
|
||||
await git.commit('Change line2 in main');
|
||||
|
||||
const result = await cherryPick(tmpDir, featureCommit.commit);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.conflict).toBe(true);
|
||||
expect(Array.isArray(result.conflictFiles)).toBe(true);
|
||||
expect(result.conflictFiles.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('throws for an invalid/nonexistent hash', async () => {
|
||||
const { tmpDir } = await createTempRepo();
|
||||
await expect(cherryPick(tmpDir, 'deadbeef00000000')).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// revertCommit
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('revertCommit', () => {
|
||||
it('reverts a commit and stages the revert changes', async () => {
|
||||
const { tmpDir, git } = await createTempRepo();
|
||||
const filePath = path.join(tmpDir, 'file.txt');
|
||||
await fs.promises.writeFile(filePath, 'line1\nline2\n', 'utf8');
|
||||
await git.add('file.txt');
|
||||
await git.commit('Initial commit');
|
||||
|
||||
await fs.promises.writeFile(filePath, 'line1\nline2\nline3\n', 'utf8');
|
||||
await git.add('file.txt');
|
||||
const changeCommit = await git.commit('Add line3');
|
||||
|
||||
const result = await revertCommit(tmpDir, changeCommit.commit);
|
||||
expect(result).toEqual({ success: true, conflict: false });
|
||||
|
||||
const status = await git.status();
|
||||
expect(status.staged.length).toBeGreaterThan(0);
|
||||
const content = await fs.promises.readFile(filePath, 'utf8');
|
||||
expect(content).toBe('line1\nline2\n');
|
||||
});
|
||||
|
||||
it('returns conflict info when reverting causes a conflict', async () => {
|
||||
const { tmpDir, git } = await createTempRepo();
|
||||
const filePath = path.join(tmpDir, 'file.txt');
|
||||
await fs.promises.writeFile(filePath, 'line1\nline2\nline3\n', 'utf8');
|
||||
await git.add('file.txt');
|
||||
await git.commit('Initial commit');
|
||||
|
||||
await fs.promises.writeFile(filePath, 'line1\nchanged-a\nline3\n', 'utf8');
|
||||
await git.add('file.txt');
|
||||
const commitA = await git.commit('Change line2 to changed-a');
|
||||
|
||||
await fs.promises.writeFile(filePath, 'line1\nchanged-b\nline3\n', 'utf8');
|
||||
await git.add('file.txt');
|
||||
await git.commit('Change line2 to changed-b');
|
||||
|
||||
const result = await revertCommit(tmpDir, commitA.commit);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.conflict).toBe(true);
|
||||
expect(Array.isArray(result.conflictFiles)).toBe(true);
|
||||
expect(result.conflictFiles.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('throws for an invalid/nonexistent hash', async () => {
|
||||
const { tmpDir } = await createTempRepo();
|
||||
await expect(revertCommit(tmpDir, 'deadbeef00000000')).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// resetToCommit
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('resetToCommit', () => {
|
||||
it('soft reset moves HEAD without touching the working tree', async () => {
|
||||
const { tmpDir, git } = await createTempRepo();
|
||||
const filePath = path.join(tmpDir, 'file.txt');
|
||||
await fs.promises.writeFile(filePath, 'first\n', 'utf8');
|
||||
await git.add('file.txt');
|
||||
const firstCommit = await git.commit('First commit');
|
||||
|
||||
await fs.promises.writeFile(filePath, 'second\n', 'utf8');
|
||||
await git.add('file.txt');
|
||||
await git.commit('Second commit');
|
||||
|
||||
const result = await resetToCommit(tmpDir, firstCommit.commit, 'soft');
|
||||
expect(result).toEqual({ success: true });
|
||||
|
||||
const log = await git.log();
|
||||
expect(log.latest.hash).toBe(firstCommit.commit);
|
||||
const content = await fs.promises.readFile(filePath, 'utf8');
|
||||
expect(content).toBe('second\n');
|
||||
|
||||
const status = await git.status();
|
||||
expect(status.staged.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('mixed reset moves HEAD and unstages changes', async () => {
|
||||
const { tmpDir, git } = await createTempRepo();
|
||||
const filePath = path.join(tmpDir, 'file.txt');
|
||||
await fs.promises.writeFile(filePath, 'first\n', 'utf8');
|
||||
await git.add('file.txt');
|
||||
const firstCommit = await git.commit('First commit');
|
||||
|
||||
await fs.promises.writeFile(filePath, 'second\n', 'utf8');
|
||||
await git.add('file.txt');
|
||||
await git.commit('Second commit');
|
||||
|
||||
const result = await resetToCommit(tmpDir, firstCommit.commit, 'mixed');
|
||||
expect(result).toEqual({ success: true });
|
||||
|
||||
const log = await git.log();
|
||||
expect(log.latest.hash).toBe(firstCommit.commit);
|
||||
const content = await fs.promises.readFile(filePath, 'utf8');
|
||||
expect(content).toBe('second\n');
|
||||
|
||||
const status = await git.status();
|
||||
expect(status.staged.length).toBe(0);
|
||||
expect(status.modified.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('hard reset with clean working tree succeeds', async () => {
|
||||
const { tmpDir, git } = await createTempRepo();
|
||||
const filePath = path.join(tmpDir, 'file.txt');
|
||||
await fs.promises.writeFile(filePath, 'first\n', 'utf8');
|
||||
await git.add('file.txt');
|
||||
const firstCommit = await git.commit('First commit');
|
||||
|
||||
await fs.promises.writeFile(filePath, 'second\n', 'utf8');
|
||||
await git.add('file.txt');
|
||||
await git.commit('Second commit');
|
||||
|
||||
const result = await resetToCommit(tmpDir, firstCommit.commit, 'hard');
|
||||
expect(result).toEqual({ success: true });
|
||||
|
||||
const log = await git.log();
|
||||
expect(log.latest.hash).toBe(firstCommit.commit);
|
||||
const content = await fs.promises.readFile(filePath, 'utf8');
|
||||
expect(content).toBe('first\n');
|
||||
|
||||
const status = await git.status();
|
||||
expect(status.isClean()).toBe(true);
|
||||
});
|
||||
|
||||
it('hard reset with dirty working tree without force throws', async () => {
|
||||
const { tmpDir, git } = await createTempRepo();
|
||||
const filePath = path.join(tmpDir, 'file.txt');
|
||||
await fs.promises.writeFile(filePath, 'first\n', 'utf8');
|
||||
await git.add('file.txt');
|
||||
const firstCommit = await git.commit('First commit');
|
||||
|
||||
await fs.promises.writeFile(filePath, 'second\n', 'utf8');
|
||||
await git.add('file.txt');
|
||||
await git.commit('Second commit');
|
||||
|
||||
await fs.promises.writeFile(filePath, 'dirty\n', 'utf8');
|
||||
|
||||
await expect(resetToCommit(tmpDir, firstCommit.commit, 'hard')).rejects.toThrow(
|
||||
'Cannot hard reset: uncommitted changes in working tree'
|
||||
);
|
||||
});
|
||||
|
||||
it('hard reset with dirty working tree with force succeeds', async () => {
|
||||
const { tmpDir, git } = await createTempRepo();
|
||||
const filePath = path.join(tmpDir, 'file.txt');
|
||||
await fs.promises.writeFile(filePath, 'first\n', 'utf8');
|
||||
await git.add('file.txt');
|
||||
const firstCommit = await git.commit('First commit');
|
||||
|
||||
await fs.promises.writeFile(filePath, 'second\n', 'utf8');
|
||||
await git.add('file.txt');
|
||||
await git.commit('Second commit');
|
||||
|
||||
await fs.promises.writeFile(filePath, 'dirty\n', 'utf8');
|
||||
|
||||
const result = await resetToCommit(tmpDir, firstCommit.commit, 'hard', true);
|
||||
expect(result).toEqual({ success: true });
|
||||
|
||||
const log = await git.log();
|
||||
expect(log.latest.hash).toBe(firstCommit.commit);
|
||||
const content = await fs.promises.readFile(filePath, 'utf8');
|
||||
expect(content).toBe('first\n');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// hash validation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('hash validation', () => {
|
||||
it('checkoutCommit rejects non-hex hash', async () => {
|
||||
await expect(checkoutCommit('/tmp', '--hard')).rejects.toThrow('Invalid commit hash');
|
||||
});
|
||||
|
||||
it('checkoutCommit rejects ref name', async () => {
|
||||
await expect(checkoutCommit('/tmp', 'HEAD')).rejects.toThrow('Invalid commit hash');
|
||||
});
|
||||
|
||||
it('checkoutCommit accepts valid 40-char hex format', async () => {
|
||||
await expect(
|
||||
checkoutCommit('/tmp', '1234567890abcdef1234567890abcdef12345678')
|
||||
).rejects.not.toThrow('Invalid commit hash');
|
||||
});
|
||||
|
||||
it('cherryPick rejects non-hex hash', async () => {
|
||||
await expect(cherryPick('/tmp', '--hard')).rejects.toThrow('Invalid commit hash');
|
||||
});
|
||||
|
||||
it('cherryPick rejects ref name', async () => {
|
||||
await expect(cherryPick('/tmp', 'HEAD')).rejects.toThrow('Invalid commit hash');
|
||||
});
|
||||
|
||||
it('cherryPick accepts valid 40-char hex format', async () => {
|
||||
await expect(
|
||||
cherryPick('/tmp', '1234567890abcdef1234567890abcdef12345678')
|
||||
).rejects.not.toThrow('Invalid commit hash');
|
||||
});
|
||||
|
||||
it('revertCommit rejects non-hex hash', async () => {
|
||||
await expect(revertCommit('/tmp', '--hard')).rejects.toThrow('Invalid commit hash');
|
||||
});
|
||||
|
||||
it('revertCommit rejects ref name', async () => {
|
||||
await expect(revertCommit('/tmp', 'HEAD')).rejects.toThrow('Invalid commit hash');
|
||||
});
|
||||
|
||||
it('revertCommit accepts valid 40-char hex format', async () => {
|
||||
await expect(
|
||||
revertCommit('/tmp', '1234567890abcdef1234567890abcdef12345678')
|
||||
).rejects.not.toThrow('Invalid commit hash');
|
||||
});
|
||||
|
||||
it('resetToCommit rejects non-hex hash', async () => {
|
||||
await expect(resetToCommit('/tmp', '--hard', 'soft')).rejects.toThrow('Invalid commit hash');
|
||||
});
|
||||
|
||||
it('resetToCommit rejects ref name', async () => {
|
||||
await expect(resetToCommit('/tmp', 'HEAD', 'soft')).rejects.toThrow('Invalid commit hash');
|
||||
});
|
||||
|
||||
it('resetToCommit accepts valid 40-char hex format', async () => {
|
||||
await expect(
|
||||
resetToCommit('/tmp', '1234567890abcdef1234567890abcdef12345678', 'soft')
|
||||
).rejects.not.toThrow('Invalid commit hash');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -61,6 +61,10 @@ export const createWebGitAPI = (): GitAPI => ({
|
||||
merge: gitApiHttp.merge,
|
||||
abortMerge: gitApiHttp.abortMerge,
|
||||
continueMerge: gitApiHttp.continueMerge,
|
||||
checkoutCommit: gitApiHttp.checkoutCommit,
|
||||
cherryPick: gitApiHttp.cherryPick,
|
||||
revertCommit: gitApiHttp.revertCommit,
|
||||
resetToCommit: gitApiHttp.resetToCommit,
|
||||
stash: gitApiHttp.stash,
|
||||
stashPop: gitApiHttp.stashPop,
|
||||
getConflictDetails: gitApiHttp.getConflictDetails,
|
||||
|
||||
Reference in New Issue
Block a user