import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { DatabaseSync } from 'node:sqlite' import { execFileSync } from 'child_process' import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs' import { join } from 'path' import { tmpdir } from 'os' const state = vi.hoisted(() => ({ db: null as DatabaseSync | null, appHome: '', })) vi.mock('../../packages/server/src/db/index', () => ({ getDb: () => state.db, isSqliteAvailable: () => Boolean(state.db), jsonDelete: vi.fn(), jsonGet: vi.fn(), jsonGetAll: vi.fn(() => ({})), jsonSet: vi.fn(), })) vi.mock('../../packages/server/src/config', () => ({ config: { appHome: state.appHome, }, })) function git(cwd: string, args: string[]): void { execFileSync('git', args, { cwd, stdio: 'ignore' }) } describe('workspace diff tracker', () => { let root: string let repo: string beforeEach(async () => { vi.resetModules() root = mkdtempSync(join(tmpdir(), 'hermes-workspace-diff-')) state.appHome = join(root, 'home') state.db = new DatabaseSync(join(root, 'diffs.db')) const { initAllHermesTables } = await import('../../packages/server/src/db/hermes/schemas') initAllHermesTables() repo = join(root, 'repo') mkdirSync(repo) git(repo, ['init']) git(repo, ['config', 'user.email', 'test@example.com']) git(repo, ['config', 'user.name', 'Test User']) writeFileSync(join(repo, 'dirty.txt'), 'committed\n') writeFileSync(join(repo, 'changed.txt'), 'old\n') git(repo, ['add', '.']) git(repo, ['commit', '-m', 'initial']) }) afterEach(() => { state.db?.close() state.db = null rmSync(root, { recursive: true, force: true }) }) it('records only files changed during the run when the repo was already dirty', async () => { const { completeWorkspaceRunCheckpoint, startWorkspaceRunCheckpoint, } = await import('../../packages/server/src/services/hermes/run-chat/workspace-diff-tracker') writeFileSync(join(repo, 'dirty.txt'), 'preexisting dirty change\n') startWorkspaceRunCheckpoint({ sessionId: 'session-1', runId: 'run-1', workspace: repo, }) writeFileSync(join(repo, 'changed.txt'), 'new\n') const change = completeWorkspaceRunCheckpoint({ sessionId: 'session-1', runId: 'run-1', workspace: repo, }) expect(change).not.toBeNull() expect(change?.change_id).toMatch(/^run:run-1:/) expect(change?.files.map(file => file.path)).toEqual(['changed.txt']) expect(change?.files[0]).toMatchObject({ change_type: 'modified', additions: 1, deletions: 1, binary: false, }) expect(change?.files[0].patch).toBeUndefined() const { getWorkspaceRunChangeFile, listWorkspaceRunChangesForSession } = await import('../../packages/server/src/db/hermes/workspace-run-changes-store') const detail = getWorkspaceRunChangeFile('session-1', change!.change_id, change!.files[0].id) expect(detail?.patch).toContain('-old') expect(detail?.patch).toContain('+new') startWorkspaceRunCheckpoint({ sessionId: 'session-1', runId: 'run-1', workspace: repo, }) writeFileSync(join(repo, 'changed.txt'), 'newer\n') const secondChange = completeWorkspaceRunCheckpoint({ sessionId: 'session-1', runId: 'run-1', workspace: repo, }) expect(secondChange).not.toBeNull() expect(secondChange?.change_id).toMatch(/^run:run-1:/) expect(secondChange?.change_id).not.toBe(change?.change_id) const savedChanges = listWorkspaceRunChangesForSession('session-1') expect(savedChanges).toHaveLength(2) expect(savedChanges.map(saved => saved.change_id)).toEqual(expect.arrayContaining([ change!.change_id, secondChange!.change_id, ])) }) it('records added, modified, and deleted files in non-git workspaces', async () => { const { completeWorkspaceRunCheckpoint, startWorkspaceRunCheckpoint, } = await import('../../packages/server/src/services/hermes/run-chat/workspace-diff-tracker') const workspace = join(root, 'plain-workspace') mkdirSync(workspace) writeFileSync(join(workspace, 'deleted.txt'), 'remove me\n') writeFileSync(join(workspace, 'old.txt'), 'old\n') writeFileSync(join(workspace, 'unchanged.txt'), 'same\n') startWorkspaceRunCheckpoint({ sessionId: 'session-plain', runId: 'run-plain', workspace, }) rmSync(join(workspace, 'deleted.txt')) writeFileSync(join(workspace, 'added.txt'), 'added\n') writeFileSync(join(workspace, 'old.txt'), 'new\n') const change = completeWorkspaceRunCheckpoint({ sessionId: 'session-plain', runId: 'run-plain', workspace, }) expect(change).not.toBeNull() expect(change?.workspace_kind).toBe('filesystem') expect(change?.files.map(file => file.path)).toEqual(['added.txt', 'deleted.txt', 'old.txt']) expect(change?.files.map(file => [file.path, file.change_type])).toEqual([ ['added.txt', 'added'], ['deleted.txt', 'deleted'], ['old.txt', 'modified'], ]) const { getWorkspaceRunChangeFile } = await import('../../packages/server/src/db/hermes/workspace-run-changes-store') const modified = change!.files.find(file => file.path === 'old.txt')! const detail = getWorkspaceRunChangeFile('session-plain', change!.change_id, modified.id) expect(detail?.patch).toContain('-old') expect(detail?.patch).toContain('+new') }) it('skips empty zero-byte file changes in non-git workspaces', async () => { const { completeWorkspaceRunCheckpoint, startWorkspaceRunCheckpoint, } = await import('../../packages/server/src/services/hermes/run-chat/workspace-diff-tracker') const workspace = join(root, 'plain-empty-file') mkdirSync(workspace) startWorkspaceRunCheckpoint({ sessionId: 'session-empty', runId: 'run-empty', workspace, }) writeFileSync(join(workspace, 'empty.txt'), '') const emptyOnlyChange = completeWorkspaceRunCheckpoint({ sessionId: 'session-empty', runId: 'run-empty', workspace, }) expect(emptyOnlyChange).toBeNull() startWorkspaceRunCheckpoint({ sessionId: 'session-empty', runId: 'run-non-empty', workspace, }) writeFileSync(join(workspace, 'non-empty.txt'), 'content\n') const nonEmptyChange = completeWorkspaceRunCheckpoint({ sessionId: 'session-empty', runId: 'run-non-empty', workspace, }) expect(nonEmptyChange).not.toBeNull() expect(nonEmptyChange?.files.map(file => file.path)).toEqual(['non-empty.txt']) }) it('skips common language dependency and build directories in non-git workspaces', async () => { const { completeWorkspaceRunCheckpoint, startWorkspaceRunCheckpoint, } = await import('../../packages/server/src/services/hermes/run-chat/workspace-diff-tracker') const workspace = join(root, 'plain-with-language-artifacts') mkdirSync(join(workspace, 'src'), { recursive: true }) mkdirSync(join(workspace, 'node_modules'), { recursive: true }) mkdirSync(join(workspace, '__pycache__'), { recursive: true }) mkdirSync(join(workspace, 'target'), { recursive: true }) mkdirSync(join(workspace, 'vendor'), { recursive: true }) mkdirSync(join(workspace, '.terraform'), { recursive: true }) writeFileSync(join(workspace, 'src', 'app.py'), 'old\n') writeFileSync(join(workspace, 'node_modules', 'ignored.js'), 'before\n') writeFileSync(join(workspace, '__pycache__', 'ignored.pyc'), 'before\n') writeFileSync(join(workspace, 'target', 'ignored.class'), 'before\n') writeFileSync(join(workspace, 'vendor', 'ignored.php'), 'before\n') writeFileSync(join(workspace, '.terraform', 'ignored.tfstate'), 'before\n') startWorkspaceRunCheckpoint({ sessionId: 'session-ignore', runId: 'run-ignore', workspace, }) writeFileSync(join(workspace, 'src', 'app.py'), 'new\n') writeFileSync(join(workspace, 'node_modules', 'ignored.js'), 'after\n') writeFileSync(join(workspace, '__pycache__', 'ignored.pyc'), 'after\n') writeFileSync(join(workspace, 'target', 'ignored.class'), 'after\n') writeFileSync(join(workspace, 'vendor', 'ignored.php'), 'after\n') writeFileSync(join(workspace, '.terraform', 'ignored.tfstate'), 'after\n') const change = completeWorkspaceRunCheckpoint({ sessionId: 'session-ignore', runId: 'run-ignore', workspace, }) expect(change).not.toBeNull() expect(change?.files.map(file => file.path)).toEqual(['src/app.py']) }) })