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>
This commit is contained in:
jeda 2026-03-04 04:23:39 +00:00
parent 1a331eafaa
commit 0bb371bf5f
4 changed files with 934 additions and 2 deletions

View file

@ -55,7 +55,7 @@
{"id":"bd-2fz","title":"Add unit tests for store.ts","description":"Create comprehensive unit tests for the event store module (src/store.ts). Test cases should cover: event insertion, querying by worker/time range, memory limits, event expiration, and concurrent access patterns.","status":"closed","priority":2,"issue_type":"task","assignee":"coder","created_at":"2026-03-04T03:01:37.667181937Z","created_by":"coder","updated_at":"2026-03-04T04:09:24.891874684Z","closed_at":"2026-03-04T04:09:24.874172864Z","close_reason":"✅ Added comprehensive unit tests for store.ts\n\nCoverage Added:\n- Bead collision detection (6 tests)\n- Task collision detection (4 tests)\n- File heatmap functionality (9 tests)\n- Collision alert generation (3 tests)\n- Error grouping integration (4 tests)\n- Concurrent access patterns (3 tests)\n- Event expiration/memory limits (2 tests)\n- Worker analytics integration (2 tests)\n- Recovery suggestions integration (3 tests)\n- Edge cases (9 tests)\n\nTest Results: 99 passing tests, 1 skipped\nCode Added: 835 lines of test code\nCommitted: Yes (commit 7c72348)","source_repo":".","compaction_level":0,"original_size":0}
{"id":"bd-2id","title":"Integrate DependencyDag into main TUI app","description":"Wire DependencyDag component into the main TUI app. Add keyboard shortcut (e.g., 'd') to toggle DAG view, fetch bead dependencies from workspace, render task relationships visually.","status":"open","priority":3,"issue_type":"task","created_at":"2026-03-04T03:02:16.799752089Z","created_by":"coder","updated_at":"2026-03-04T03:07:19.419661626Z","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-2id","depends_on_id":"bd-102","type":"blocks","created_at":"2026-03-04T03:07:19.419555935Z","created_by":"coder"}]}
{"id":"bd-2it","title":"ALT-005: Environment variable override for br ready","description":"For HUMAN bd-1sw. Set BR_READY_COMMAND environment variable that workers check. If set, use that command instead of br ready. Allows drop-in replacement without modifying worker code. Example: export BR_READY_COMMAND='./scripts/br-ready-jsonl.sh --json'","status":"closed","priority":2,"issue_type":"task","created_at":"2026-03-03T08:30:24.411889607Z","created_by":"coder","updated_at":"2026-03-03T10:33:28.142886575Z","closed_at":"2026-03-03T10:33:27.043825562Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["alternative","discovery","worker"],"dependencies":[{"issue_id":"bd-2it","depends_on_id":"bd-1sw","type":"blocks","created_at":"2026-03-03T08:30:48.583924349Z","created_by":"coder","metadata":"{}","thread_id":""}],"comments":[{"id":30,"issue_id":"bd-2it","author":"Jed Arden","text":"No longer needed - br v0.1.20 fixes the schema bug natively.","created_at":"2026-03-03T10:33:28Z"}]}
{"id":"bd-2js","title":"Parse git status and diff from NEEDLE logs","description":"Extend parser to extract git-related events: commits, staged files, unstaged changes, branch info. Store in GitEvent type.","status":"open","priority":3,"issue_type":"task","created_at":"2026-03-04T03:05:52.782741482Z","created_by":"coder","updated_at":"2026-03-04T03:05:52.782741482Z","source_repo":".","compaction_level":0,"original_size":0}
{"id":"bd-2js","title":"Parse git status and diff from NEEDLE logs","description":"Extend parser to extract git-related events: commits, staged files, unstaged changes, branch info. Store in GitEvent type.","status":"in_progress","priority":3,"issue_type":"task","assignee":"coder","created_at":"2026-03-04T03:05:52.782741482Z","created_by":"coder","updated_at":"2026-03-04T04:18:59.092856527Z","source_repo":".","compaction_level":0,"original_size":0}
{"id":"bd-2kf","title":"FABRIC: Flow Analysis & Bead Reporting Interface Console","description":"# FABRIC Project Epic\n\n## Overview\nFABRIC is a live display for NEEDLE worker activity. It parses NEEDLE's structured JSON logging output and renders it in real-time as either a TUI (terminal) or web dashboard.\n\n## Core Goals\n1. **Live Display**: Real-time visualization of NEEDLE worker activity\n2. **Dual Interface**: TUI for terminal users, web app for browser users\n3. **Stateless Core**: Reads and displays - no storage or persistence (analytics features use optional SQLite)\n4. **Intelligence**: Beyond simple log display - provides insights, detection, and analysis\n\n## Data Flow\n```\nNEEDLE Workers → ~/.needle/logs/ → FABRIC → Live TUI or Web Dashboard\n```\n\n## Key Design Principles\n- **Read-only**: FABRIC observes and displays, never controls workers\n- **Non-blocking**: Display lag must never impact NEEDLE performance\n- **Graceful degradation**: Missing data fields should not crash the display\n- **Memory-bounded**: Configurable limits on in-memory event storage\n\n## Input Format\nFABRIC expects structured JSON log lines from NEEDLE:\n```json\n{\"ts\":1709337600,\"worker\":\"w-abc123\",\"level\":\"info\",\"msg\":\"Starting task\",\"task\":\"bd-xyz\"}\n{\"ts\":1709337601,\"worker\":\"w-abc123\",\"level\":\"debug\",\"msg\":\"Tool call\",\"tool\":\"Read\",\"path\":\"/src/main.ts\"}\n{\"ts\":1709337605,\"worker\":\"w-abc123\",\"level\":\"info\",\"msg\":\"Task complete\",\"duration_ms\":5000}\n```\n\n## Output Modes\n- `fabric tui` - Live terminal dashboard (keyboard-driven)\n- `fabric web` - Live browser dashboard (mouse-driven)\n- `fabric logs` - Simple log streaming (parsed + formatted)\n\n## Technology Stack (Tentative)\n- **Language**: TypeScript/Node.js (primary), Go (optional for performance)\n- **TUI**: blessed or ink (React for CLIs)\n- **Web**: Express + ws + React/Svelte\n- **Analytics Storage**: SQLite (~/.needle/fabric.db)\n\n## Success Metrics\n- Can display 1000+ events/second without UI lag\n- TUI responsive under 50ms for all interactions\n- Web dashboard WebSocket latency under 100ms\n- Memory usage bounded to configurable limit (default 100MB)\n\n## Related Documentation\n- [Implementation Plan](docs/plan.md)\n- [NEEDLE Documentation](~/.needle/README.md)\n\n## Child Beads\nThis epic contains 7 implementation phases with granular subtasks.","status":"in_progress","priority":0,"issue_type":"epic","assignee":"coder","created_at":"2026-03-02T14:37:57.192442627Z","created_by":"coder","updated_at":"2026-03-03T10:35:13.617383819Z","source_repo":".","compaction_level":0,"original_size":0}
{"id":"bd-2mt","title":"ALERT: Worker claude-code-glm-5-bravo has no work available","description":"# Worker Starvation Alert\n\nWorker **claude-code-glm-5-bravo** has exhausted all priorities and found zero work.\n\nThis is considered an error state - there should always be more work.\n\n## Worker State\n\n- **Executor:** claude-code-glm-5\n- **Model:** glm-5\n- **Workspace:** /home/coder/FABRIC\n- **Root Boundary:** /home/coder/FABRIC\n- **Last completion:** \n- **Beads completed:** 0\n- **Claim success rate:** %\n- **Uptime:** 29044s (h)\n- **Consecutive empty iterations:** 5\n\n## Priorities Exhausted\n\n1. ✗ Local workspace (bottoms-up): No beads in /home/coder/FABRIC or subfolders\n2. ✗ Parent exploration: No suitable workspaces found\n3. ✓ Maintenance: Completed (cleaned orphaned claims/locks)\n4. ✗ Gap analysis: false - No gaps found or created\n5. ✗ HUMAN alternatives: true - No HUMAN beads found to unblock\n\n## Discovered Workspaces\n\nTotal: 1\n\n- /home/coder/FABRIC\n\n## Required Actions\n\n1. Review discovery roots: Are all project folders being scanned?\n2. Check if projects need new features/tasks\n3. Review ROADMAP.md files across projects\n4. Enable gap analysis if disabled: `--enable-gap-analysis`\n5. Enable HUMAN alternatives if disabled\n6. Create manual beads to bootstrap work\n\n---\n*This alert was created automatically by Priority 6*","status":"closed","priority":0,"issue_type":"human","assignee":"coder","created_at":"2026-03-03T12:27:36.329456759Z","created_by":"coder","updated_at":"2026-03-03T12:28:53.397220662Z","closed_at":"2026-03-03T12:28:53.389791132Z","close_reason":"FALSE POSITIVE: 22 beads available in ready-queue.json. Worker discovery logic failed to check ready-queue.json before escalating to starvation alert. See MEMORY.md pattern for resolution.","source_repo":".","compaction_level":0,"original_size":0}
{"id":"bd-2n4","title":"ALERT: Worker claude-code-glm-5-alpha has no work available","description":"# Worker Starvation Alert\n\nWorker **claude-code-glm-5-alpha** has exhausted all priorities and found zero work.\n\nThis is considered an error state - there should always be more work.\n\n## Worker State\n\n- **Executor:** claude-code-glm-5\n- **Model:** glm-5\n- **Workspace:** /home/coder/FABRIC\n- **Root Boundary:** /home/coder/FABRIC\n- **Last completion:** \n- **Beads completed:** 0\n- **Claim success rate:** %\n- **Uptime:** 29605s (h)\n- **Consecutive empty iterations:** 5\n\n## Priorities Exhausted\n\n1. ✗ Local workspace (bottoms-up): No beads in /home/coder/FABRIC or subfolders\n2. ✗ Parent exploration: No suitable workspaces found\n3. ✓ Maintenance: Completed (cleaned orphaned claims/locks)\n4. ✗ Gap analysis: false - No gaps found or created\n5. ✗ HUMAN alternatives: true - No HUMAN beads found to unblock\n\n## Discovered Workspaces\n\nTotal: 1\n\n- /home/coder/FABRIC\n\n## Required Actions\n\n1. Review discovery roots: Are all project folders being scanned?\n2. Check if projects need new features/tasks\n3. Review ROADMAP.md files across projects\n4. Enable gap analysis if disabled: `--enable-gap-analysis`\n5. Enable HUMAN alternatives if disabled\n6. Create manual beads to bootstrap work\n\n---\n*This alert was created automatically by Priority 6*","status":"closed","priority":0,"issue_type":"human","assignee":"coder","created_at":"2026-03-03T12:36:55.641405565Z","created_by":"coder","updated_at":"2026-03-03T12:38:13.509716532Z","closed_at":"2026-03-03T12:38:13.501044506Z","close_reason":"FALSE POSITIVE: Worker starvation alert created without checking ready-queue.json. Ready queue has 22 beads available (bd-2zt, bd-2ed, bd-1mq, etc.). Workers should check ready-queue.json BEFORE creating HUMAN/alert beads.","source_repo":".","compaction_level":0,"original_size":0}

428
src/gitParser.test.ts Normal file
View file

@ -0,0 +1,428 @@
/**
* Tests for FABRIC Git Event Parser
*/
import { describe, it, expect } from 'vitest';
import {
isGitEvent,
parseGitEvent,
parseGitEvents,
formatGitEvent,
} from './gitParser.js';
import { LogEvent, GitEvent, GitStatusEvent, GitCommitEvent } from './types.js';
describe('isGitEvent', () => {
it('should detect git status events', () => {
const event: LogEvent = {
ts: Date.now(),
worker: 'w-test',
level: 'info',
msg: 'Git status',
git_status: { branch: 'main' },
};
expect(isGitEvent(event)).toBe(true);
});
it('should detect git commit events', () => {
const event: LogEvent = {
ts: Date.now(),
worker: 'w-test',
level: 'info',
msg: 'Git commit',
git_commit: 'abc123',
};
expect(isGitEvent(event)).toBe(true);
});
it('should detect git events from message patterns', () => {
const event: LogEvent = {
ts: Date.now(),
worker: 'w-test',
level: 'info',
msg: 'Running git status',
};
expect(isGitEvent(event)).toBe(true);
});
it('should return false for non-git events', () => {
const event: LogEvent = {
ts: Date.now(),
worker: 'w-test',
level: 'info',
msg: 'Regular log message',
};
expect(isGitEvent(event)).toBe(false);
});
});
describe('parseGitEvent', () => {
describe('git status events', () => {
it('should parse a basic git status event', () => {
const event: LogEvent = {
ts: 1709337600000,
worker: 'w-test',
level: 'info',
msg: 'Git status',
git_type: 'status',
git_branch: 'main',
git_staged: ['file1.ts', 'file2.ts'],
git_unstaged: ['file3.ts'],
git_untracked: ['file4.ts'],
};
const result = parseGitEvent(event) as GitStatusEvent;
expect(result).toBeDefined();
expect(result.type).toBe('status');
expect(result.branch).toBe('main');
expect(result.staged).toHaveLength(2);
expect(result.unstaged).toHaveLength(1);
expect(result.untracked).toHaveLength(1);
});
it('should parse git status with tracking info', () => {
const event: LogEvent = {
ts: 1709337600000,
worker: 'w-test',
level: 'info',
msg: 'Git status',
git_status: {
branch: 'feature/auth',
tracking: 'origin/feature/auth',
ahead: 3,
behind: 1,
commit: 'abc123',
},
git_staged: [],
git_unstaged: [],
git_untracked: [],
};
const result = parseGitEvent(event) as GitStatusEvent;
expect(result).toBeDefined();
expect(result.branch).toBe('feature/auth');
expect(result.tracking).toBe('origin/feature/auth');
expect(result.ahead).toBe(3);
expect(result.behind).toBe(1);
expect(result.commit).toBe('abc123');
});
it('should parse file changes with detailed status', () => {
const event: LogEvent = {
ts: 1709337600000,
worker: 'w-test',
level: 'info',
msg: 'Git status',
git_type: 'status',
git_branch: 'main',
git_staged: [
{ path: 'added.ts', status: 'added', staged: true },
{ path: 'modified.ts', status: 'modified', staged: true },
],
git_unstaged: [
{ path: 'deleted.ts', status: 'deleted', staged: false },
],
git_untracked: ['new-file.ts'],
};
const result = parseGitEvent(event) as GitStatusEvent;
expect(result.staged[0].path).toBe('added.ts');
expect(result.staged[0].status).toBe('added');
expect(result.staged[1].path).toBe('modified.ts');
expect(result.staged[1].status).toBe('modified');
expect(result.unstaged[0].path).toBe('deleted.ts');
expect(result.unstaged[0].status).toBe('deleted');
});
});
describe('git commit events', () => {
it('should parse a basic git commit event', () => {
const event: LogEvent = {
ts: 1709337600000,
worker: 'w-test',
level: 'info',
msg: 'Git commit',
git_type: 'commit',
git_commit: 'abc123def456',
git_message: 'feat: add new feature',
git_branch: 'main',
};
const result = parseGitEvent(event) as GitCommitEvent;
expect(result).toBeDefined();
expect(result.type).toBe('commit');
expect(result.hash).toBe('abc123def456');
expect(result.message).toBe('feat: add new feature');
expect(result.branch).toBe('main');
});
it('should parse commit with author and files', () => {
const event: LogEvent = {
ts: 1709337600000,
worker: 'w-test',
level: 'info',
msg: 'Git commit',
git_commit: {
hash: 'abc123',
message: 'fix: bug fix',
author: 'John Doe',
email: 'john@example.com',
files: [
{ path: 'file1.ts', status: 'modified' },
{ path: 'file2.ts', status: 'added' },
],
},
};
const result = parseGitEvent(event) as GitCommitEvent;
expect(result.hash).toBe('abc123');
expect(result.author).toBe('John Doe');
expect(result.email).toBe('john@example.com');
expect(result.files).toHaveLength(2);
expect(result.files![0].path).toBe('file1.ts');
});
it('should return null for commit without hash', () => {
const event: LogEvent = {
ts: 1709337600000,
worker: 'w-test',
level: 'info',
msg: 'Git commit',
git_type: 'commit',
git_message: 'No hash provided',
};
const result = parseGitEvent(event);
expect(result).toBeNull();
});
});
describe('git branch events', () => {
it('should parse a basic git branch event', () => {
const event: LogEvent = {
ts: 1709337600000,
worker: 'w-test',
level: 'info',
msg: 'Git branch',
git_type: 'branch',
git_branch: 'main',
};
const result = parseGitEvent(event);
expect(result).toBeDefined();
expect(result!.type).toBe('branch');
expect(result).toHaveProperty('current', 'main');
});
it('should parse branch with tracking info', () => {
const event: LogEvent = {
ts: 1709337600000,
worker: 'w-test',
level: 'info',
msg: 'Git branch',
git_branch: {
current: 'feature/test',
branches: ['main', 'feature/test', 'develop'],
tracking: 'origin/feature/test',
ahead: 2,
behind: 0,
},
};
const result = parseGitEvent(event);
expect(result).toBeDefined();
expect(result).toHaveProperty('current', 'feature/test');
expect(result).toHaveProperty('branches');
expect(result).toHaveProperty('tracking', 'origin/feature/test');
expect(result).toHaveProperty('ahead', 2);
});
});
describe('git diff events', () => {
it('should parse a basic git diff event', () => {
const event: LogEvent = {
ts: 1709337600000,
worker: 'w-test',
level: 'info',
msg: 'Git diff',
git_type: 'diff',
git_target: 'HEAD',
git_files: [
{ path: 'file1.ts', status: 'modified' },
{ path: 'file2.ts', status: 'added' },
],
git_lines_added: 45,
git_lines_deleted: 12,
};
const result = parseGitEvent(event);
expect(result).toBeDefined();
expect(result!.type).toBe('diff');
expect(result).toHaveProperty('target', 'HEAD');
expect(result).toHaveProperty('linesAdded', 45);
expect(result).toHaveProperty('linesDeleted', 12);
expect(result).toHaveProperty('files');
});
it('should truncate long diff content', () => {
const longContent = 'a'.repeat(15000);
const event: LogEvent = {
ts: 1709337600000,
worker: 'w-test',
level: 'info',
msg: 'Git diff',
git_diff: {
target: 'origin/main',
content: longContent,
files: [],
},
git_lines_added: 100,
git_lines_deleted: 50,
};
const result = parseGitEvent(event, { maxDiffLength: 1000 });
expect(result).toBeDefined();
expect(result).toHaveProperty('content');
expect(result).toHaveProperty('isTruncated', true);
});
});
});
describe('parseGitEvents', () => {
it('should parse multiple git events from log events', () => {
const events: LogEvent[] = [
{
ts: 1709337600000,
worker: 'w-test',
level: 'info',
msg: 'Git status',
git_type: 'status',
git_branch: 'main',
git_staged: [],
git_unstaged: [],
git_untracked: [],
},
{
ts: 1709337610000,
worker: 'w-test',
level: 'info',
msg: 'Regular log',
},
{
ts: 1709337620000,
worker: 'w-test',
level: 'info',
msg: 'Git commit',
git_type: 'commit',
git_commit: 'abc123',
git_message: 'test commit',
},
];
const result = parseGitEvents(events);
expect(result).toHaveLength(2);
expect(result[0].type).toBe('status');
expect(result[1].type).toBe('commit');
});
});
describe('formatGitEvent', () => {
it('should format a git status event', () => {
const event: GitStatusEvent = {
id: 'ge-1',
type: 'status',
ts: 1709337600000,
worker: 'w-test',
branch: 'main',
staged: [],
unstaged: [],
untracked: [],
};
const result = formatGitEvent(event);
expect(result).toContain('[git]');
expect(result).toContain('Status');
expect(result).toContain('Branch: main');
expect(result).toContain('Staged: 0 files');
});
it('should format a git commit event', () => {
const event: GitCommitEvent = {
id: 'ge-2',
type: 'commit',
ts: 1709337600000,
worker: 'w-test',
hash: 'abc123def456',
message: 'feat: add new feature',
branch: 'main',
author: 'John Doe',
};
const result = formatGitEvent(event);
expect(result).toContain('[git]');
expect(result).toContain('Commit');
expect(result).toContain('abc123d'); // Short hash
expect(result).toContain('[main]');
expect(result).toContain('by John Doe');
expect(result).toContain('feat: add new feature');
});
it('should format a git branch event', () => {
const event = {
id: 'ge-3',
type: 'branch' as const,
ts: 1709337600000,
worker: 'w-test',
current: 'feature/test',
tracking: 'origin/feature/test',
ahead: 2,
behind: 1,
};
const result = formatGitEvent(event);
expect(result).toContain('[git]');
expect(result).toContain('Branch');
expect(result).toContain('Current: feature/test');
expect(result).toContain('tracking origin/feature/test');
expect(result).toContain('+2');
expect(result).toContain('-1');
});
it('should format a git diff event', () => {
const event = {
id: 'ge-4',
type: 'diff' as const,
ts: 1709337600000,
worker: 'w-test',
target: 'origin/main',
files: [{ path: 'file1.ts', status: 'modified' as const, staged: false }],
linesAdded: 45,
linesDeleted: 12,
};
const result = formatGitEvent(event);
expect(result).toContain('[git]');
expect(result).toContain('Diff');
expect(result).toContain('Target: origin/main');
expect(result).toContain('+45/-12');
expect(result).toContain('1 files');
});
});

504
src/gitParser.ts Normal file
View file

@ -0,0 +1,504 @@
/**
* 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;
}
}

File diff suppressed because one or more lines are too long