FABRIC/src/gitParser.ts
jeda 0bb371bf5f feat(bd-2js): Parse git status and diff from NEEDLE logs
- Add GitEvent types (GitStatusEvent, GitCommitEvent, GitBranchEvent, GitDiffEvent)
- Implement git event parsing in gitParser.ts
- Support parsing git status (staged, unstaged, untracked files)
- Support parsing git commits with file changes
- Support parsing git branch information
- Support parsing git diff output with line counts
- Add comprehensive unit tests (19 tests)
- All tests pass (1011 total tests)

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-03-04 04:23:39 +00:00

504 lines
14 KiB
TypeScript

/**
* FABRIC Git Event Parser
*
* Parses git-related NEEDLE log lines into structured GitEvent objects.
*/
import {
LogEvent,
GitEvent,
GitStatusEvent,
GitCommitEvent,
GitBranchEvent,
GitDiffEvent,
GitFileChange,
GitFileStatus,
GitParseOptions,
} from './types.js';
/**
* Event sequence counter for generating unique git event IDs
*/
let gitEventSequence = 0;
/**
* Generate a unique git event ID
*/
function generateGitEventId(): string {
return `ge-${Date.now()}-${++gitEventSequence}`;
}
/**
* Truncate content to max length
*/
function truncate(content: string, maxLength: number): string {
if (content.length <= maxLength) {
return content;
}
return content.slice(0, maxLength - 3) + '...';
}
/**
* Check if a log event contains git-related content
*/
export function isGitEvent(event: LogEvent): boolean {
// Check for explicit git fields
if (
event.git_status ||
event.git_commit ||
event.git_branch ||
event.git_diff ||
event.git_staged ||
event.git_unstaged ||
event.git_type
) {
return true;
}
// Check message patterns
const msg = event.msg.toLowerCase();
if (
msg.includes('git status') ||
msg.includes('git commit') ||
msg.includes('git branch') ||
msg.includes('git diff') ||
msg.includes('git log')
) {
return true;
}
return false;
}
/**
* Parse a log event into a git event
*
* @param event - The log event to parse
* @param options - Parse options
* @returns Parsed git event or null if not a git event
*/
export function parseGitEvent(
event: LogEvent,
options: GitParseOptions = {}
): GitEvent | null {
const { maxDiffLength = 10000, includeFileChanges = true, maxFiles = 100 } = options;
// Determine git event type
const gitType = event.git_type as string | undefined;
if (gitType === 'status' || event.git_status) {
return parseGitStatusEvent(event, includeFileChanges, maxFiles);
}
if (gitType === 'commit' || event.git_commit) {
return parseGitCommitEvent(event, includeFileChanges, maxFiles);
}
if (gitType === 'branch' || event.git_branch) {
return parseGitBranchEvent(event);
}
if (gitType === 'diff' || event.git_diff) {
return parseGitDiffEvent(event, maxDiffLength, includeFileChanges, maxFiles);
}
// Infer from message if no explicit type
const msg = event.msg.toLowerCase();
if (msg.includes('git status')) {
return parseGitStatusEvent(event, includeFileChanges, maxFiles);
} else if (msg.includes('git commit')) {
return parseGitCommitEvent(event, includeFileChanges, maxFiles);
} else if (msg.includes('git branch')) {
return parseGitBranchEvent(event);
} else if (msg.includes('git diff')) {
return parseGitDiffEvent(event, maxDiffLength, includeFileChanges, maxFiles);
}
return null;
}
/**
* Parse a git status event
*/
function parseGitStatusEvent(
event: LogEvent,
includeFileChanges: boolean,
maxFiles: number
): GitStatusEvent | null {
const statusData = event.git_status as Record<string, unknown> | undefined;
// Get branch info
const branch = (statusData?.branch || event.git_branch || event.branch || 'unknown') as string;
// Parse staged files
const staged: GitFileChange[] = [];
const stagedData = (statusData?.staged || event.git_staged || []) as unknown[];
if (includeFileChanges && Array.isArray(stagedData)) {
for (const file of stagedData.slice(0, maxFiles)) {
const change = parseGitFileChange(file, true);
if (change) staged.push(change);
}
}
// Parse unstaged files
const unstaged: GitFileChange[] = [];
const unstagedData = (statusData?.unstaged || event.git_unstaged || []) as unknown[];
if (includeFileChanges && Array.isArray(unstagedData)) {
for (const file of unstagedData.slice(0, maxFiles)) {
const change = parseGitFileChange(file, false);
if (change) unstaged.push(change);
}
}
// Parse untracked files
const untracked: string[] = [];
const untrackedData = (statusData?.untracked || event.git_untracked || []) as unknown[];
if (Array.isArray(untrackedData)) {
for (const file of untrackedData.slice(0, maxFiles)) {
if (typeof file === 'string') {
untracked.push(file);
} else if (typeof file === 'object' && file !== null && 'path' in file) {
untracked.push((file as { path: string }).path);
}
}
}
return {
id: generateGitEventId(),
type: 'status',
ts: event.ts,
worker: event.worker,
bead: event.bead,
branch,
commit: (statusData?.commit || event.git_commit || event.commit) as string | undefined,
staged,
unstaged,
untracked,
ahead: (statusData?.ahead || event.git_ahead || event.ahead) as number | undefined,
behind: (statusData?.behind || event.git_behind || event.behind) as number | undefined,
tracking: (statusData?.tracking || event.git_tracking || event.tracking) as string | undefined,
};
}
/**
* Parse a git commit event
*/
function parseGitCommitEvent(
event: LogEvent,
includeFileChanges: boolean,
maxFiles: number
): GitCommitEvent | null {
const commitData = event.git_commit as Record<string, unknown> | undefined;
// Get commit hash
const hash = (
(typeof commitData === 'string' ? commitData : commitData?.hash) ||
event.commit_hash ||
event.hash ||
event.commit
) as string;
if (!hash) return null;
// Get commit message
const message = (
commitData?.message ||
event.git_message ||
event.commit_message ||
event.message ||
''
) as string;
// Parse files if available
const files: GitFileChange[] = [];
const filesData = (commitData?.files || event.git_files || event.files || []) as unknown[];
if (includeFileChanges && Array.isArray(filesData)) {
for (const file of filesData.slice(0, maxFiles)) {
const change = parseGitFileChange(file, true);
if (change) files.push(change);
}
}
// Get parents
const parents: string[] = [];
const parentsData = (commitData?.parents || event.git_parents || event.parents || []) as unknown[];
if (Array.isArray(parentsData)) {
for (const parent of parentsData) {
if (typeof parent === 'string') {
parents.push(parent);
}
}
}
return {
id: generateGitEventId(),
type: 'commit',
ts: event.ts,
worker: event.worker,
bead: event.bead,
hash,
message,
branch: (commitData?.branch || event.git_branch || event.branch) as string | undefined,
author: (commitData?.author || event.git_author || event.author) as string | undefined,
email: (commitData?.email || event.git_email || event.email) as string | undefined,
parents: parents.length > 0 ? parents : undefined,
files: files.length > 0 ? files : undefined,
};
}
/**
* Parse a git branch event
*/
function parseGitBranchEvent(event: LogEvent): GitBranchEvent | null {
const branchData = event.git_branch as Record<string, unknown> | undefined;
// Get current branch
const current = (
(typeof branchData === 'string' ? branchData : branchData?.current) ||
event.current_branch ||
event.branch ||
'unknown'
) as string;
// Get all branches
const branches: string[] = [];
const branchesData = (branchData?.branches || event.git_branches || event.branches || []) as unknown[];
if (Array.isArray(branchesData)) {
for (const branch of branchesData) {
if (typeof branch === 'string') {
branches.push(branch);
}
}
}
return {
id: generateGitEventId(),
type: 'branch',
ts: event.ts,
worker: event.worker,
bead: event.bead,
current,
branches: branches.length > 0 ? branches : undefined,
tracking: (branchData?.tracking || event.git_tracking || event.tracking) as string | undefined,
ahead: (branchData?.ahead || event.git_ahead || event.ahead) as number | undefined,
behind: (branchData?.behind || event.git_behind || event.behind) as number | undefined,
};
}
/**
* Parse a git diff event
*/
function parseGitDiffEvent(
event: LogEvent,
maxLength: number,
includeFileChanges: boolean,
maxFiles: number
): GitDiffEvent | null {
const diffData = event.git_diff as Record<string, unknown> | undefined;
// Get diff target
const target = (
(typeof diffData === 'string' ? diffData : diffData?.target) ||
event.git_target ||
event.diff_target ||
event.target ||
'HEAD'
) as string;
// Parse files
const files: GitFileChange[] = [];
const filesData = (diffData?.files || event.git_files || event.files || []) as unknown[];
if (includeFileChanges && Array.isArray(filesData)) {
for (const file of filesData.slice(0, maxFiles)) {
const change = parseGitFileChange(file, false);
if (change) files.push(change);
}
}
// Get diff content
const content = (diffData?.content || event.git_content || event.diff_content || event.content) as
| string
| undefined;
const truncatedContent = content ? truncate(content, maxLength) : undefined;
return {
id: generateGitEventId(),
type: 'diff',
ts: event.ts,
worker: event.worker,
bead: event.bead,
target,
files,
linesAdded: (diffData?.lines_added || event.git_lines_added || event.lines_added || 0) as number,
linesDeleted: (diffData?.lines_deleted || event.git_lines_deleted || event.lines_deleted || 0) as number,
content: truncatedContent,
isTruncated: content ? content.length > maxLength : false,
};
}
/**
* Parse a single file change from various formats
*/
function parseGitFileChange(data: unknown, defaultStaged: boolean): GitFileChange | null {
if (typeof data === 'string') {
// Simple string path - assume modified
return {
path: data,
status: 'modified',
staged: defaultStaged,
};
}
if (typeof data !== 'object' || data === null) {
return null;
}
const obj = data as Record<string, unknown>;
// Extract path
const path = (obj.path || obj.file || obj.filename) as string;
if (!path) return null;
// Extract status
let status: GitFileStatus = 'modified';
const statusStr = (obj.status || obj.state || obj.type) as string | undefined;
if (statusStr) {
const normalized = statusStr.toLowerCase();
if (normalized === 'a' || normalized === 'added' || normalized === 'new') {
status = 'added';
} else if (normalized === 'm' || normalized === 'modified') {
status = 'modified';
} else if (normalized === 'd' || normalized === 'deleted' || normalized === 'removed') {
status = 'deleted';
} else if (normalized === 'r' || normalized === 'renamed') {
status = 'renamed';
} else if (normalized === 'c' || normalized === 'copied') {
status = 'copied';
} else if (normalized === '??' || normalized === 'untracked') {
status = 'untracked';
} else if (normalized === 'u' || normalized === 'unmerged' || normalized === 'conflict') {
status = 'unmerged';
}
}
// Extract staged status
const staged = typeof obj.staged === 'boolean' ? obj.staged : defaultStaged;
// Extract original path for renames
const originalPath = (obj.original_path || obj.from || obj.old_path) as string | undefined;
return {
path,
status,
staged,
originalPath,
};
}
/**
* Parse all git events from a list of log events
*
* @param events - List of log events to parse
* @param options - Parse options
* @returns List of git events in chronological order
*/
export function parseGitEvents(
events: LogEvent[],
options: GitParseOptions = {}
): GitEvent[] {
const gitEvents: GitEvent[] = [];
for (const event of events) {
const gitEvent = parseGitEvent(event, options);
if (gitEvent) {
gitEvents.push(gitEvent);
}
}
return gitEvents;
}
/**
* Format timestamp for display
*/
function formatTimestamp(ts: number): string {
const date = new Date(ts);
const hours = date.getHours().toString().padStart(2, '0');
const minutes = date.getMinutes().toString().padStart(2, '0');
const seconds = date.getSeconds().toString().padStart(2, '0');
return `${hours}:${minutes}:${seconds}`;
}
/**
* Format a git event for display
*/
export function formatGitEvent(event: GitEvent): string {
const timestamp = formatTimestamp(event.ts);
const prefix = `${timestamp} [git]`;
switch (event.type) {
case 'status':
const statusParts: string[] = [
`Branch: ${event.branch}`,
];
if (event.tracking) {
statusParts.push(`tracking ${event.tracking}`);
}
if (event.ahead) {
statusParts.push(`+${event.ahead}`);
}
if (event.behind) {
statusParts.push(`-${event.behind}`);
}
statusParts.push(`\n Staged: ${event.staged.length} files`);
statusParts.push(`Unstaged: ${event.unstaged.length} files`);
statusParts.push(`Untracked: ${event.untracked.length} files`);
return `${prefix} Status\n ${statusParts.join(', ')}`;
case 'commit':
const commitParts: string[] = [
`${event.hash.slice(0, 7)}`,
];
if (event.branch) {
commitParts.push(`[${event.branch}]`);
}
if (event.author) {
commitParts.push(`by ${event.author}`);
}
commitParts.push(`\n ${event.message.split('\n')[0]}`);
if (event.files) {
commitParts.push(`\n ${event.files.length} files changed`);
}
return `${prefix} Commit ${commitParts.join(' ')}`;
case 'branch':
const branchParts: string[] = [`Current: ${event.current}`];
if (event.tracking) {
branchParts.push(`tracking ${event.tracking}`);
}
if (event.ahead) {
branchParts.push(`+${event.ahead}`);
}
if (event.behind) {
branchParts.push(`-${event.behind}`);
}
if (event.branches) {
branchParts.push(`\n Total branches: ${event.branches.length}`);
}
return `${prefix} Branch\n ${branchParts.join(', ')}`;
case 'diff':
const diffParts: string[] = [
`Target: ${event.target}`,
`+${event.linesAdded}/-${event.linesDeleted}`,
`${event.files.length} files`,
];
if (event.isTruncated) {
diffParts.push('[truncated]');
}
return `${prefix} Diff ${diffParts.join(', ')}`;
default:
return prefix;
}
}