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:
parent
1a331eafaa
commit
0bb371bf5f
4 changed files with 934 additions and 2 deletions
|
|
@ -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
428
src/gitParser.test.ts
Normal 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
504
src/gitParser.ts
Normal 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
Loading…
Add table
Reference in a new issue