-
Notifications
You must be signed in to change notification settings - Fork 88
feat: git-backed version control for app mutations #6227
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
b6d2958
3494237
578d355
454c06f
4bee47f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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: '<h1>Hello</h1>', | ||
| }); | ||
|
|
||
| // 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: '<p>v1</p>', | ||
| }); | ||
| await new Promise(resolve => setTimeout(resolve, 500)); | ||
|
|
||
| updateApp(app.id, { name: 'My App v2', htmlDefinition: '<p>v2</p>' }); | ||
| 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: '<p>bye</p>', | ||
| }); | ||
| 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: '<p>hi</p>', | ||
| }); | ||
| 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: '<p>old text</p>', | ||
| }); | ||
| 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: '<p>content</p>', | ||
| }); | ||
| 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'); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<void> { | ||
| 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<void> { | ||
| 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 | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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}`); | ||
|
vincent0426 marked this conversation as resolved.
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The first mutation writes files and only then calls Useful? React with 👍 / 👎. |
||
|
|
||
| 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<string, unknown>): 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; | ||
| } | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
commitAppChangecreates the repo viagetWorkspaceGitService(appsDir), and that service always normalizes.gitignorewithWORKSPACE_GITIGNORE_RULES(including patterns like*.db,*.log, andlogs/), not just app-specific exclusions. As a result, valid app files matching those patterns are silently excluded from staging, so mutations likewriteAppFilecan report success while the corresponding content never enters app history. This breaks the guarantee that app mutations are versioned and should be isolated to app-only ignore rules.Useful? React with 👍 / 👎.