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: '

Hello

', + }); + + // Give the fire-and-forget commit time to complete + await new Promise(resolve => setTimeout(resolve, 500)); + + const appsDir = getAppsDir(); + const commits = getGitLog(appsDir); + expect(commits.some(c => c.includes('Create app: Test App'))).toBe(true); + }); + + test('updateApp produces a commit with changed fields', async () => { + const app = createApp({ + name: 'My 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 { + try { + const appsDir = getAppsDir(); + ensureAppGitignoreRules(appsDir); + const gitService = getWorkspaceGitService(appsDir); + await gitService.ensureInitialized(); + } catch (err) { + log.error({ err }, 'Failed to initialize app git repo'); + } +} + +/** + * Commit app changes to the apps git repository. + * + * This is fire-and-forget: errors are logged but never thrown. + * The caller should not await the returned promise unless it needs + * to guarantee the commit completed (e.g. in tests). + */ +export async function commitAppChange(message: string): Promise { + try { + const appsDir = getAppsDir(); + + // Re-check .gitignore rules every call in case the apps dir was + // recreated while the process was running. + ensureAppGitignoreRules(appsDir); + + const gitService = getWorkspaceGitService(appsDir); + await gitService.commitChanges(message); + } catch (err) { + log.error({ err, message }, 'Failed to commit app change'); + } +} + +/** + * @internal Test-only: reset module state. + */ +export function _resetAppGitState(): void { + // no-op — kept for test API compatibility +} diff --git a/assistant/src/memory/app-store.ts b/assistant/src/memory/app-store.ts index 2aa10f2b991..7a0582426cb 100644 --- a/assistant/src/memory/app-store.ts +++ b/assistant/src/memory/app-store.ts @@ -29,6 +29,7 @@ import { isPrebuiltHomeBaseApp, validatePrebuiltHomeBaseHtml, } from '../home-base/prebuilt-home-base-updater.js'; +import { commitAppChange } from './app-git-service.js'; export interface AppDefinition { id: string; @@ -210,6 +211,8 @@ export function createApp(params: { app.pages = params.pages; } + void commitAppChange(`Create app: ${params.name}`); + return app; } @@ -372,14 +375,27 @@ export function updateApp( updated.pages = loadedPages; } + const changedFields = Object.keys(updates).filter(k => updates[k as keyof typeof updates] !== undefined); + void commitAppChange(`Update app: ${updated.name}\n\nChanged: ${changedFields.join(', ')}`); + return updated; } export function deleteApp(id: string): void { validateId(id); const dir = getAppsDir(); + + // Read app name before deleting for the commit message + let appName = id; const filePath = join(dir, `${id}.json`); if (existsSync(filePath)) { + try { + const raw = readFileSync(filePath, 'utf-8'); + const app = JSON.parse(raw); + if (app.name) appName = app.name; + } catch { + // fall back to id + } unlinkSync(filePath); } const previewPath = join(dir, `${id}.preview`); @@ -388,6 +404,8 @@ export function deleteApp(id: string): void { } const appDir = join(dir, id); rmSync(appDir, { recursive: true, force: true }); + + void commitAppChange(`Delete app: ${appName}`); } export function createAppRecord(appId: string, data: Record): AppRecord { @@ -527,6 +545,8 @@ export function writeAppFile(appId: string, path: string, content: string): void const dir = join(resolved, '..'); mkdirSync(dir, { recursive: true }); writeFileSync(resolved, content, 'utf-8'); + + void commitAppChange(`Write ${path} in app ${appId}`); } /** @@ -549,6 +569,7 @@ export function editAppFile( const result = applyEdit(content, oldString, newString, replaceAll ?? false); if (result.ok) { writeFileSync(resolved, result.updatedContent, 'utf-8'); + void commitAppChange(`Edit ${path} in app ${appId}`); } return result; }