Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
169 changes: 169 additions & 0 deletions assistant/src/__tests__/app-git-service.test.ts
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');
});
});
98 changes: 98 additions & 0 deletions assistant/src/memory/app-git-service.ts
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);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Stop inheriting workspace ignore rules for app repos

commitAppChange creates the repo via getWorkspaceGitService(appsDir), and that service always normalizes .gitignore with WORKSPACE_GITIGNORE_RULES (including patterns like *.db, *.log, and logs/), not just app-specific exclusions. As a result, valid app files matching those patterns are silently excluded from staging, so mutations like writeAppFile can 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 👍 / 👎.

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
}
21 changes: 21 additions & 0 deletions assistant/src/memory/app-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -210,6 +211,8 @@ export function createApp(params: {
app.pages = params.pages;
}

void commitAppChange(`Create app: ${params.name}`);
Comment thread
vincent0426 marked this conversation as resolved.
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Initialize app git before first mutation is written

The first mutation writes files and only then calls commitAppChange; on a fresh repo, commitChanges() performs initialization first and absorbs those already-written files into the bootstrap Initial commit, leaving the user-facing mutation commit empty. This means the first create/update/delete after rollout is not represented as its own diff, even though initAppGit was added specifically to prevent that case. Initialize app git before mutation writes (or at startup) so the first mutation commit contains the actual change.

Useful? React with 👍 / 👎.


return app;
}

Expand Down Expand Up @@ -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`);
Expand All @@ -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 {
Expand Down Expand Up @@ -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}`);
}

/**
Expand All @@ -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;
}
Expand Down
Loading