Files
claudecodeui/server/routes/git.test.js
Haileyesus 6daae87443 feat(git): commit graph in history view and cross-spawn everywhere
- render a VSCode-style commit graph in the source control history:
  lane-assignment algorithm, SVG strip with colored rails, merge and
  branch curves, commit dots, and branch/tag badges per commit
- /commits returns parents and ref decorations across all branches
  (--branches --remotes --tags --topo-order) using unit-separator
  fields so pipes in commit subjects can't break parsing
- collect stats via a single --shortstat pass instead of one
  `git show --stat` call per commit; raise the history limit to 50
- replace child_process spawn with cross-spawn in all runtimes,
  routes, and services: it resolves .cmd shims and PATHEXT on
  Windows (fixing taskmaster's npx invocations) and delegates to
  native spawn elsewhere, removing the per-file win32 ternaries
- unit tests for the log parser and lane assignment
2026-07-06 16:09:39 +03:00

107 lines
4.0 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import assert from 'node:assert/strict';
import test from 'node:test';
import { parseGitLogWithStats, parseGitStatusOutput } from './git.js';
// Builds `git status --porcelain=v1 -z` output: NUL-separated entries with a
// trailing NUL, exactly as git emits it.
const porcelain = (...entries) => entries.join('\0') + '\0';
test('parseGitStatusOutput buckets files and reports index-side staging', () => {
const output = porcelain(
'M staged-modified.ts',
' M unstaged-modified.ts',
'MM staged-and-unstaged.ts',
'A staged-new.ts',
'D staged-deleted.ts',
' D unstaged-deleted.ts',
'?? untracked.ts',
);
const result = parseGitStatusOutput(output);
assert.deepEqual(result.modified, ['staged-modified.ts', 'unstaged-modified.ts', 'staged-and-unstaged.ts']);
assert.deepEqual(result.added, ['staged-new.ts']);
assert.deepEqual(result.deleted, ['staged-deleted.ts', 'unstaged-deleted.ts']);
assert.deepEqual(result.untracked, ['untracked.ts']);
// Only index-side (X) changes count as staged.
assert.deepEqual(result.staged, [
'staged-modified.ts',
'staged-and-unstaged.ts',
'staged-new.ts',
'staged-deleted.ts',
]);
});
test('parseGitStatusOutput keeps paths with spaces intact (-z output has no quoting)', () => {
const result = parseGitStatusOutput(porcelain('M src/my folder/some file.ts'));
assert.deepEqual(result.modified, ['src/my folder/some file.ts']);
assert.deepEqual(result.staged, ['src/my folder/some file.ts']);
});
test('parseGitStatusOutput tracks the post-rename path and skips the original', () => {
const output = porcelain('R renamed-to.ts', 'renamed-from.ts', ' M other.ts');
const result = parseGitStatusOutput(output);
assert.deepEqual(result.modified, ['renamed-to.ts', 'other.ts']);
assert.deepEqual(result.staged, ['renamed-to.ts']);
// The pre-rename path is metadata, not a change entry.
assert.equal(JSON.stringify(result).includes('renamed-from.ts'), false);
});
test('parseGitStatusOutput never reports merge conflicts as staged', () => {
const output = porcelain('UU conflicted.ts', 'AA both-added.ts', 'DD both-deleted.ts');
const result = parseGitStatusOutput(output);
assert.deepEqual(result.modified, ['conflicted.ts', 'both-added.ts', 'both-deleted.ts']);
assert.deepEqual(result.staged, []);
});
test('parseGitStatusOutput handles empty output', () => {
assert.deepEqual(parseGitStatusOutput(''), {
modified: [],
added: [],
deleted: [],
untracked: [],
staged: [],
});
});
// Builds one `git log --pretty=format:%H%x1f%P%x1f%D%x1f%an%x1f%ae%x1f%ad%x1f%s` line.
const US = '';
const logLine = (hash, parents, refs, subject) =>
[hash, parents, refs, 'Alice', 'a@x.com', '2026-07-06T10:00:00+03:00', subject].join(US);
test('parseGitLogWithStats parses commits with parents, refs, and shortstat lines', () => {
const output = [
logLine('c3', 'c2', 'HEAD -> main, origin/main, tag: v1.0', 'feat: add | pipes | to subject'),
' 3 files changed, 10 insertions(+), 2 deletions(-)',
'',
logLine('c2', 'c1 c0', '', 'Merge branch feature'),
'',
logLine('c0', '', '', 'initial commit'),
' 1 file changed, 1 insertion(+)',
].join('\n');
const commits = parseGitLogWithStats(output);
assert.equal(commits.length, 3);
assert.deepEqual(commits[0].parents, ['c2']);
assert.deepEqual(commits[0].refs, ['HEAD -> main', 'origin/main', 'tag: v1.0']);
// Pipes in the subject survive because fields are joined with .
assert.equal(commits[0].message, 'feat: add | pipes | to subject');
assert.equal(commits[0].stats, '3 files changed, 10 insertions(+), 2 deletions(-)');
// Merge commit: two parents, no shortstat line.
assert.deepEqual(commits[1].parents, ['c1', 'c0']);
assert.equal(commits[1].stats, '');
// Root commit: no parents.
assert.deepEqual(commits[2].parents, []);
assert.equal(commits[2].stats, '1 file changed, 1 insertion(+)');
});
test('parseGitLogWithStats handles empty output', () => {
assert.deepEqual(parseGitLogWithStats(''), []);
});