Files
openchamber/packages/web/server/lib/git/service.test.js
T
Erman HAVUÇandBohdan Triapitsyn 52ffe9daef 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>
2026-05-27 00:13:25 +03:00

456 lines
17 KiB
JavaScript

import { execFileSync } from 'node:child_process';
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 {
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 canRunGit = () => {
try {
execFileSync('git', ['--version'], { stdio: 'ignore' });
return true;
} catch {
return false;
}
};
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
});
/**
* 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 () => {
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 () => {
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 () => {
const checkRef = async () => false;
expect(await resolveBaseRefForLog('nonexistent-branch', checkRef)).toBe('nonexistent-branch');
});
it('returns undefined when from is undefined', async () => {
const checkRef = async () => true;
expect(await resolveBaseRefForLog(undefined, checkRef)).toBeUndefined();
});
it('returns undefined when from is an empty string', async () => {
const checkRef = async () => true;
expect(await resolveBaseRefForLog('', checkRef)).toBeUndefined();
});
it('returns undefined when from is a whitespace-only string', async () => {
const checkRef = async () => true;
expect(await resolveBaseRefForLog(' ', checkRef)).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// 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'
);
});
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'
);
});
});
// ---------------------------------------------------------------------------
// getStatus
// ---------------------------------------------------------------------------
describe('getStatus', () => {
it('handles repositories without upstream tracking', async () => {
if (!canRunGit()) return;
const repo = createTempDir();
runGit(repo, ['init', '-b', 'main']);
runGit(repo, ['config', 'user.email', 'test@example.com']);
runGit(repo, ['config', 'user.name', 'Test User']);
fs.writeFileSync(path.join(repo, 'README.md'), '# Test\n');
runGit(repo, ['add', 'README.md']);
runGit(repo, ['commit', '-m', 'Initial commit']);
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');
});
});