|
| 1 | +import * as fs from 'fs'; |
| 2 | +import * as os from 'os'; |
| 3 | +import * as path from 'path'; |
| 4 | +import { migrateCommand } from '../commands/migrate'; |
| 5 | +import { readTaskFile, writeTaskFile, readLedger } from '@brainfile/core'; |
| 6 | + |
| 7 | +function setupV2Workspace(tempDir: string): { dotDir: string; boardDir: string; logsDir: string } { |
| 8 | + const dotDir = path.join(tempDir, '.brainfile'); |
| 9 | + const boardDir = path.join(dotDir, 'board'); |
| 10 | + const logsDir = path.join(dotDir, 'logs'); |
| 11 | + fs.mkdirSync(boardDir, { recursive: true }); |
| 12 | + fs.mkdirSync(logsDir, { recursive: true }); |
| 13 | + |
| 14 | + const config = `--- |
| 15 | +title: Test Board |
| 16 | +schema: https://brainfile.md/v2/board.json |
| 17 | +columns: |
| 18 | + - id: todo |
| 19 | + title: To Do |
| 20 | + - id: in-progress |
| 21 | + title: In Progress |
| 22 | +--- |
| 23 | +`; |
| 24 | + fs.writeFileSync(path.join(dotDir, 'brainfile.md'), config, 'utf-8'); |
| 25 | + return { dotDir, boardDir, logsDir }; |
| 26 | +} |
| 27 | + |
| 28 | +function writeLogTask(logsDir: string, id: string, title: string, extra: Record<string, unknown> = {}): void { |
| 29 | + const task = { id, title, completedAt: '2026-01-15T12:00:00Z', ...extra }; |
| 30 | + writeTaskFile(path.join(logsDir, `${id}.md`), task as any, `Completed ${title}.`); |
| 31 | +} |
| 32 | + |
| 33 | +function writeBoardTask(boardDir: string, id: string, title: string, extra: Record<string, unknown> = {}): void { |
| 34 | + const task = { id, title, column: 'todo', position: 0, ...extra }; |
| 35 | + writeTaskFile(path.join(boardDir, `${id}.md`), task as any, ''); |
| 36 | +} |
| 37 | + |
| 38 | +describe('migrate --logs-to-ledger', () => { |
| 39 | + let tempDir: string; |
| 40 | + let originalCwd: string; |
| 41 | + |
| 42 | + beforeEach(() => { |
| 43 | + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'brainfile-logs-ledger-')); |
| 44 | + originalCwd = process.cwd(); |
| 45 | + process.chdir(tempDir); |
| 46 | + }); |
| 47 | + |
| 48 | + afterEach(() => { |
| 49 | + process.chdir(originalCwd); |
| 50 | + fs.rmSync(tempDir, { recursive: true, force: true }); |
| 51 | + }); |
| 52 | + |
| 53 | + it('migrates log .md files into ledger.jsonl and removes them', () => { |
| 54 | + const { logsDir } = setupV2Workspace(tempDir); |
| 55 | + writeLogTask(logsDir, 'task-1', 'First completed task'); |
| 56 | + writeLogTask(logsDir, 'task-2', 'Second completed task', { tags: ['bug'] }); |
| 57 | + |
| 58 | + migrateCommand({ logsToLedger: true }); |
| 59 | + |
| 60 | + const records = readLedger(logsDir); |
| 61 | + expect(records.length).toBe(2); |
| 62 | + expect(records.map((r) => r.id).sort()).toEqual(['task-1', 'task-2']); |
| 63 | + expect(records[0].completedAt).toBe('2026-01-15T12:00:00Z'); |
| 64 | + |
| 65 | + expect(fs.existsSync(path.join(logsDir, 'task-1.md'))).toBe(false); |
| 66 | + expect(fs.existsSync(path.join(logsDir, 'task-2.md'))).toBe(false); |
| 67 | + }); |
| 68 | + |
| 69 | + it('skips log tasks already in the ledger', () => { |
| 70 | + const { logsDir } = setupV2Workspace(tempDir); |
| 71 | + writeLogTask(logsDir, 'task-1', 'Already tracked'); |
| 72 | + |
| 73 | + // Pre-populate ledger with task-1 |
| 74 | + const ledgerPath = path.join(logsDir, 'ledger.jsonl'); |
| 75 | + fs.writeFileSync(ledgerPath, JSON.stringify({ |
| 76 | + id: 'task-1', type: 'task', title: 'Already tracked', |
| 77 | + filesChanged: ['task-1.md'], createdAt: '2026-01-01T00:00:00Z', |
| 78 | + completedAt: '2026-01-15T12:00:00Z', cycleTimeHours: 360, summary: 'done', |
| 79 | + }) + '\n'); |
| 80 | + |
| 81 | + migrateCommand({ logsToLedger: true }); |
| 82 | + |
| 83 | + const records = readLedger(logsDir); |
| 84 | + expect(records.length).toBe(1); |
| 85 | + // .md file kept (no --force) |
| 86 | + expect(fs.existsSync(path.join(logsDir, 'task-1.md'))).toBe(true); |
| 87 | + }); |
| 88 | + |
| 89 | + it('removes stale .md files with --force when already in ledger', () => { |
| 90 | + const { logsDir } = setupV2Workspace(tempDir); |
| 91 | + writeLogTask(logsDir, 'task-1', 'Already tracked'); |
| 92 | + |
| 93 | + const ledgerPath = path.join(logsDir, 'ledger.jsonl'); |
| 94 | + fs.writeFileSync(ledgerPath, JSON.stringify({ |
| 95 | + id: 'task-1', type: 'task', title: 'Already tracked', |
| 96 | + filesChanged: ['task-1.md'], createdAt: '2026-01-01T00:00:00Z', |
| 97 | + completedAt: '2026-01-15T12:00:00Z', cycleTimeHours: 360, summary: 'done', |
| 98 | + }) + '\n'); |
| 99 | + |
| 100 | + migrateCommand({ logsToLedger: true, force: true }); |
| 101 | + |
| 102 | + expect(fs.existsSync(path.join(logsDir, 'task-1.md'))).toBe(false); |
| 103 | + expect(readLedger(logsDir).length).toBe(1); |
| 104 | + }); |
| 105 | + |
| 106 | + it('resolves ID conflicts by renaming the board task', () => { |
| 107 | + const { boardDir, logsDir } = setupV2Workspace(tempDir); |
| 108 | + |
| 109 | + // Simulate the old bug: task-1 exists in both board/ and logs/ |
| 110 | + writeLogTask(logsDir, 'task-1', 'Original completed task'); |
| 111 | + writeBoardTask(boardDir, 'task-1', 'Duplicate board task'); |
| 112 | + writeBoardTask(boardDir, 'task-5', 'Another board task'); |
| 113 | + |
| 114 | + migrateCommand({ logsToLedger: true }); |
| 115 | + |
| 116 | + // Log task should be in ledger |
| 117 | + const records = readLedger(logsDir); |
| 118 | + expect(records.length).toBe(1); |
| 119 | + expect(records[0].id).toBe('task-1'); |
| 120 | + expect(records[0].title).toBe('Original completed task'); |
| 121 | + |
| 122 | + // Old board/task-1.md should be gone |
| 123 | + expect(fs.existsSync(path.join(boardDir, 'task-1.md'))).toBe(false); |
| 124 | + |
| 125 | + // Board task should have been renamed (next available after task-5 + ledger task-1) |
| 126 | + const boardFiles = fs.readdirSync(boardDir).filter((f) => f.endsWith('.md')); |
| 127 | + expect(boardFiles).toContain('task-5.md'); |
| 128 | + expect(boardFiles.length).toBe(2); |
| 129 | + |
| 130 | + // The renamed task should have the same title as the duplicate |
| 131 | + const renamedFile = boardFiles.find((f) => f !== 'task-5.md')!; |
| 132 | + const renamedTask = readTaskFile(path.join(boardDir, renamedFile)); |
| 133 | + expect(renamedTask).not.toBeNull(); |
| 134 | + expect(renamedTask!.task.title).toBe('Duplicate board task'); |
| 135 | + |
| 136 | + // Log .md file should be cleaned up |
| 137 | + expect(fs.existsSync(path.join(logsDir, 'task-1.md'))).toBe(false); |
| 138 | + }); |
| 139 | + |
| 140 | + it('handles epic- and adr- prefixed tasks', () => { |
| 141 | + const { logsDir } = setupV2Workspace(tempDir); |
| 142 | + writeLogTask(logsDir, 'epic-1', 'Done epic'); |
| 143 | + writeLogTask(logsDir, 'adr-1', 'Done ADR'); |
| 144 | + |
| 145 | + migrateCommand({ logsToLedger: true }); |
| 146 | + |
| 147 | + const records = readLedger(logsDir); |
| 148 | + expect(records.length).toBe(2); |
| 149 | + expect(records.find((r) => r.id === 'epic-1')?.type).toBe('epic'); |
| 150 | + expect(records.find((r) => r.id === 'adr-1')?.type).toBe('adr'); |
| 151 | + }); |
| 152 | + |
| 153 | + it('does nothing when logs/ has no .md files', () => { |
| 154 | + const { logsDir } = setupV2Workspace(tempDir); |
| 155 | + |
| 156 | + migrateCommand({ logsToLedger: true }); |
| 157 | + |
| 158 | + expect(fs.existsSync(path.join(logsDir, 'ledger.jsonl'))).toBe(false); |
| 159 | + }); |
| 160 | +}); |
0 commit comments