diff --git a/assistant/src/__tests__/app-git-service.test.ts b/assistant/src/__tests__/app-git-service.test.ts new file mode 100644 index 00000000000..20c3423057a --- /dev/null +++ b/assistant/src/__tests__/app-git-service.test.ts @@ -0,0 +1,169 @@ +import { describe, test, expect, beforeEach, afterEach, mock } from 'bun:test'; +import { mkdirSync, rmSync, existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { execFileSync } from 'node:child_process'; +import { _resetGitServiceRegistry } from '../workspace/git-service.js'; +import { commitAppChange, _resetAppGitState } from '../memory/app-git-service.js'; + +// Mock getDataDir to use a temp directory +let testDataDir: string; + +mock.module('../util/platform.js', () => ({ + getDataDir: () => testDataDir, + getProjectDir: () => testDataDir, +})); + +// Re-import app-store after mocking so it uses our temp dir +const { createApp, updateApp, deleteApp, writeAppFile, editAppFile, getAppsDir } = await import('../memory/app-store.js'); + +describe('App Git Service', () => { + beforeEach(() => { + testDataDir = join(tmpdir(), `vellum-app-git-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(join(testDataDir, 'apps'), { recursive: true }); + _resetGitServiceRegistry(); + _resetAppGitState(); + }); + + afterEach(() => { + if (existsSync(testDataDir)) { + rmSync(testDataDir, { recursive: true, force: true }); + } + }); + + function getGitLog(dir: string): string[] { + try { + const output = execFileSync('git', ['log', '--oneline', '--format=%s'], { + cwd: dir, + encoding: 'utf-8', + }); + return output.trim().split('\n').filter(Boolean); + } catch { + return []; + } + } + + test('initializes git repo in apps directory on first commit', async () => { + const appsDir = getAppsDir(); + expect(existsSync(join(appsDir, '.git'))).toBe(false); + + await commitAppChange('test commit'); + + expect(existsSync(join(appsDir, '.git'))).toBe(true); + }); + + test('.gitignore excludes preview files and records', async () => { + const appsDir = getAppsDir(); + await commitAppChange('test commit'); + + const gitignore = readFileSync(join(appsDir, '.gitignore'), 'utf-8'); + expect(gitignore).toContain('*.preview'); + expect(gitignore).toContain('*/records/'); + }); + + test('createApp produces a commit', async () => { + createApp({ + name: 'Test App', + schemaJson: '{}', + htmlDefinition: '
v1
', + }); + await new Promise(resolve => setTimeout(resolve, 500)); + + updateApp(app.id, { name: 'My App v2', htmlDefinition: 'v2
' }); + await new Promise(resolve => setTimeout(resolve, 500)); + + const appsDir = getAppsDir(); + const commits = getGitLog(appsDir); + expect(commits.some(c => c.includes('Update app: My App v2'))).toBe(true); + }); + + test('deleteApp produces a commit with app name', async () => { + const app = createApp({ + name: 'Doomed App', + schemaJson: '{}', + htmlDefinition: 'bye
', + }); + await new Promise(resolve => setTimeout(resolve, 500)); + + deleteApp(app.id); + await new Promise(resolve => setTimeout(resolve, 500)); + + const appsDir = getAppsDir(); + const commits = getGitLog(appsDir); + expect(commits.some(c => c.includes('Delete app: Doomed App'))).toBe(true); + }); + + test('writeAppFile produces a commit', async () => { + const app = createApp({ + name: 'File App', + schemaJson: '{}', + htmlDefinition: 'hi
', + }); + await new Promise(resolve => setTimeout(resolve, 500)); + + writeAppFile(app.id, 'styles.css', 'body { color: red; }'); + await new Promise(resolve => setTimeout(resolve, 500)); + + const appsDir = getAppsDir(); + const commits = getGitLog(appsDir); + expect(commits.some(c => c.includes('Write styles.css in app'))).toBe(true); + }); + + test('editAppFile produces a commit on success', async () => { + const app = createApp({ + name: 'Edit App', + schemaJson: '{}', + htmlDefinition: 'old text
', + }); + await new Promise(resolve => setTimeout(resolve, 500)); + + const result = editAppFile(app.id, 'index.html', 'old text', 'new text'); + expect(result.ok).toBe(true); + await new Promise(resolve => setTimeout(resolve, 500)); + + const appsDir = getAppsDir(); + const commits = getGitLog(appsDir); + expect(commits.some(c => c.includes('Edit index.html in app'))).toBe(true); + }); + + test('editAppFile does not commit on failure', async () => { + const app = createApp({ + name: 'No Edit App', + schemaJson: '{}', + htmlDefinition: 'content
', + }); + await new Promise(resolve => setTimeout(resolve, 500)); + + const commitsBefore = getGitLog(getAppsDir()); + + const result = editAppFile(app.id, 'index.html', 'nonexistent string', 'replacement'); + expect(result.ok).toBe(false); + await new Promise(resolve => setTimeout(resolve, 500)); + + const commitsAfter = getGitLog(getAppsDir()); + // No new commits should have been created for the failed edit + expect(commitsAfter.length).toBe(commitsBefore.length); + }); + + test('commitAppChange swallows errors gracefully', async () => { + _resetAppGitState(); + + // This should not throw + await commitAppChange('test'); + }); +}); diff --git a/assistant/src/memory/app-git-service.ts b/assistant/src/memory/app-git-service.ts new file mode 100644 index 00000000000..2115e95692a --- /dev/null +++ b/assistant/src/memory/app-git-service.ts @@ -0,0 +1,98 @@ +/** + * Git-backed version control for user-defined apps. + * + * Initializes a git repository in the apps directory (~/.vellum/apps/) and + * commits after every app mutation (create, update, delete, file write/edit). + * Commits are fire-and-forget — they never block the caller. + * + * Reuses WorkspaceGitService for all git operations (mutex, circuit breaker, + * lazy init, etc.). + */ + +import { getWorkspaceGitService } from '../workspace/git-service.js'; +import { getAppsDir } from './app-store.js'; +import { getLogger } from '../util/logger.js'; +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; + +const log = getLogger('app-git'); + +/** + * Patterns excluded from app version tracking. + * - *.preview — large base64 preview images + * - records directories — user data (form submissions), not app code + */ +const APP_GITIGNORE_RULES = [ + '*.preview', + '*/records/', +]; + +/** + * Ensure the apps directory .gitignore contains app-specific exclusion rules. + * Idempotent: only appends rules that are missing. + */ +function ensureAppGitignoreRules(appsDir: string): void { + const gitignorePath = join(appsDir, '.gitignore'); + let content = ''; + if (existsSync(gitignorePath)) { + content = readFileSync(gitignorePath, 'utf-8'); + } + + const missingRules = APP_GITIGNORE_RULES.filter(rule => !content.includes(rule)); + if (missingRules.length > 0) { + if (content && !content.endsWith('\n')) { + content += '\n'; + } + content += missingRules.join('\n') + '\n'; + writeFileSync(gitignorePath, content, 'utf-8'); + } +} + +/** + * Eagerly initialize the app git repo so that the "Initial commit" is + * created before any app files are written. Without this, the first + * mutation's files get absorbed into WorkspaceGitService's bootstrap + * commit and the "Create app: ..." commit ends up empty. + * + * Safe to call multiple times — ensureInitialized() is idempotent. + * Fire-and-forget: errors are logged but never thrown. + */ +export async function initAppGit(): Promise