Skip to content

Commit

Permalink
Introduce git utilities in the API (#422)
Browse files Browse the repository at this point in the history
* Use isomorphic-git, initial commit and commit post packages install during app creation

* Commit progress on git API on the web package

* Add ability to fetch current version on the frontend
  • Loading branch information
nichochar authored Oct 24, 2024
1 parent 26d548e commit 534b222
Show file tree
Hide file tree
Showing 10 changed files with 323 additions and 3 deletions.
6 changes: 6 additions & 0 deletions packages/api/apps/app.mts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { npmInstall } from '../exec.mjs';
import { generateApp } from '../ai/generate.mjs';
import { toValidPackageName } from '../apps/utils.mjs';
import { getPackagesToInstall, parsePlan } from '../ai/plan-parser.mjs';
import { commitAllFiles, initRepo } from './git.mjs';

function toSecondsSinceEpoch(date: Date): number {
return Math.floor(date.getTime() / 1000);
Expand All @@ -34,6 +35,9 @@ export async function createAppWithAi(data: CreateAppWithAiSchemaType): Promise<
});

await createViteApp(app);

await initRepo(app);

// Note: we don't surface issues or retries and this is "running in the background".
// In this case it works in our favor because we'll kickoff generation while it happens
npmInstall({
Expand Down Expand Up @@ -69,6 +73,8 @@ export async function createAppWithAi(data: CreateAppWithAiSchemaType): Promise<
},
onExit(code) {
console.log(`npm install exit code: ${code}`);
console.log('Applying git commit');
commitAllFiles(app, `Add dependencies: ${packagesToInstall.join(', ')}`);
},
});
}
Expand Down
80 changes: 80 additions & 0 deletions packages/api/apps/git.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import fs from 'node:fs/promises';
import Path from 'node:path';
import git, { type ReadCommitResult } from 'isomorphic-git';
import { pathToApp } from './disk.mjs';
import type { App as DBAppType } from '../db/schema.mjs';

// Initialize a git repository in the app directory
export async function initRepo(app: DBAppType): Promise<void> {
const dir = pathToApp(app.externalId);
await git.init({ fs, dir });
await commitAllFiles(app, 'Initial commit');
}

// Commit all current files in the app directory
export async function commitAllFiles(app: DBAppType, message: string): Promise<string> {
const dir = pathToApp(app.externalId);

// Stage all files
await git.add({ fs, dir, filepath: '.' });

// Create commit
const sha = await git.commit({
fs,
dir,
message,
author: {
name: 'Srcbook',
email: '[email protected]',
},
});

return sha;
}

// Checkout to a specific commit
// Use this to revert to a previous commit or "version"
export async function checkoutCommit(app: DBAppType, commitSha: string): Promise<void> {
const dir = pathToApp(app.externalId);

await git.checkout({
fs,
dir,
ref: commitSha,
force: true,
});
}

// Get commit history
export async function getCommitHistory(
app: DBAppType,
limit: number = 100,
): Promise<Array<ReadCommitResult>> {
const dir = pathToApp(app.externalId);

const commits = await git.log({
fs,
dir,
depth: limit, // Limit to specified number of commits, default 100
});

return commits;
}

// Helper function to ensure the repo exists, initializing it if necessary
export async function ensureRepoExists(app: DBAppType): Promise<void> {
const dir = pathToApp(app.externalId);
try {
await fs.access(Path.join(dir, '.git'));
} catch (error) {
// If .git directory doesn't exist, initialize the repo
await initRepo(app);
}
}

// Get the current commit SHA
export async function getCurrentCommitSha(app: DBAppType): Promise<string> {
const dir = pathToApp(app.externalId);
const currentCommit = await git.resolveRef({ fs, dir, ref: 'HEAD' });
return currentCommit;
}
4 changes: 4 additions & 0 deletions packages/api/apps/templates/react-typescript/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# typical gitignore for web apps
node_modules
dist
.DS_Store
1 change: 1 addition & 0 deletions packages/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
"drizzle-orm": "^0.33.0",
"express": "^4.20.0",
"fast-xml-parser": "^4.5.0",
"isomorphic-git": "^1.27.1",
"marked": "catalog:",
"posthog-node": "^4.2.0",
"ws": "catalog:",
Expand Down
39 changes: 39 additions & 0 deletions packages/api/server/http.mts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ import {
import { CreateAppSchema } from '../apps/schemas.mjs';
import { AppGenerationFeedbackType } from '@srcbook/shared';
import { createZipFromApp } from '../apps/disk.mjs';
import { checkoutCommit, commitAllFiles, getCurrentCommitSha } from '../apps/git.mjs';

const app: Application = express();

Expand Down Expand Up @@ -562,6 +563,44 @@ router.post('/apps/:id/edit', cors(), async (req, res) => {
}
});

router.options('/apps/:id/commit', cors());
router.get('/apps/:id/commit', cors(), async (req, res) => {
const { id } = req.params;
const app = await loadApp(id);
if (!app) {
return res.status(404).json({ error: 'App not found' });
}

const sha = await getCurrentCommitSha(app);
return res.json({ sha });
});
router.post('/apps/:id/commit', cors(), async (req, res) => {
const { id } = req.params;
const { message } = req.body;
// import the commit function from the apps/git.mjs file
const app = await loadApp(id);

if (!app) {
return res.status(404).json({ error: 'App not found' });
}

const sha = await commitAllFiles(app, message);
return res.json({ data: { success: true, sha } });
});

router.options('/apps/:id/checkout/:sha ', cors());
router.post('/apps/:id/checkout/:sha', cors(), async (req, res) => {
const { id, sha } = req.params;
const app = await loadApp(id);

if (!app) {
return res.status(404).json({ error: 'App not found' });
}

await checkoutCommit(app, sha);
return res.json({ data: { success: true, sha } });
});

router.options('/apps/:id/directories', cors());
router.post('/apps/:id/directories', cors(), async (req, res) => {
const { id } = req.params;
Expand Down
33 changes: 33 additions & 0 deletions packages/web/src/clients/http/apps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -295,3 +295,36 @@ export async function exportApp(id: string, name: string): Promise<Blob> {

return response.blob();
}

type VersionResponse = {
sha: string;
};

export async function getCurrentVersion(id: string): Promise<VersionResponse> {
const response = await fetch(API_BASE_URL + `/apps/${id}/commit`, {
method: 'GET',
headers: { 'content-type': 'application/json' },
});
return response.json();
}

export async function commitVersion(id: string, message: string): Promise<VersionResponse> {
const response = await fetch(API_BASE_URL + `/apps/${id}/commit`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ message }),
});

return response.json();
}

export async function checkoutVersion(
id: string,
sha: string,
): Promise<{ data: { success: true; sha: string } }> {
const response = await fetch(API_BASE_URL + `/apps/${id}/checkout/${sha}`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
});
return response.json();
}
89 changes: 89 additions & 0 deletions packages/web/src/components/apps/use-version.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import React, { createContext, useContext, useState, useCallback, useEffect } from 'react';
import { useApp } from './use-app';
import { commitVersion, getCurrentVersion } from '@/clients/http/apps';

interface Version {
sha: string;
message?: string;
}

interface VersionContextType {
currentVersion: Version | null;
commitFiles: (message: string) => Promise<void>;
checkout: (sha: string) => Promise<void>;
fetchVersions: () => Promise<void>;
}

const VersionContext = createContext<VersionContextType | undefined>(undefined);

export const VersionProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { app } = useApp();
// TODO implement this
// const { refreshFiles } = useFiles();
const [currentVersion, setCurrentVersion] = useState<Version | null>(null);

const fetchVersion = useCallback(async () => {
if (!app) return;

try {
const currentVersionResponse = await getCurrentVersion(app.id);
setCurrentVersion({ sha: currentVersionResponse.sha });
} catch (error) {
console.error('Error fetching current version:', error);
}
}, [app]);

useEffect(() => {
fetchVersion();
}, [fetchVersion]);

const commitFiles = useCallback(
async (message: string) => {
if (!app) return;

try {
const response = await commitVersion(app.id, message);
setCurrentVersion({ sha: response.sha, message });
} catch (error) {
console.error('Error committing files:', error);
}
},
[app],
);

const checkout = useCallback(
async (sha: string) => {
if (!app) return;

try {
const response = await fetch(`/api/apps/${app.id}/checkout`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sha }),
});
if (!response.ok) throw new Error('Failed to checkout version');
await fetchVersion();
// await refreshFiles();
} catch (error) {
console.error('Error checking out version:', error);
}
},
[app, fetchVersion],
);

return (
<VersionContext.Provider
value={{ currentVersion, commitFiles, checkout, fetchVersions: fetchVersion }}
>
{children}
</VersionContext.Provider>
);
};

export const useVersion = () => {
const context = useContext(VersionContext);
if (context === undefined) {
throw new Error('useVersion must be used within a VersionProvider');
}
return context;
};
7 changes: 7 additions & 0 deletions packages/web/src/components/chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import { DiffStats } from './apps/diff-stats.js';
import { useApp } from './apps/use-app.js';
import { usePackageJson } from './apps/use-package-json.js';
import { AiFeedbackModal } from './apps/AiFeedbackModal';
import { useVersion } from './apps/use-version.js';

function Chat({
history,
Expand Down Expand Up @@ -264,6 +265,7 @@ function DiffBox({
version: number;
planId: string;
}) {
const { currentVersion } = useVersion();
const [showFeedbackToast, setShowFeedbackToast] = React.useState(false);
const [feedbackGiven, _setFeedbackGiven] = React.useState<null | 'positive' | 'negative'>(null);
const [isFeedbackModalOpen, setIsFeedbackModalOpen] = React.useState(false);
Expand All @@ -286,6 +288,11 @@ function DiffBox({
<div className="flex gap-2 items-center text-sm h-10">
<span className="font-medium">{app.name}</span>
<span className="font-mono px-1 bg-ai-border rounded-sm">v{version}</span>
{currentVersion && (
<span className="font-mono px-1 bg-ai-border rounded-sm">
{currentVersion.sha.slice(0, 7)}
</span>
)}
</div>
<div>
{files.map((file) => (
Expand Down
9 changes: 6 additions & 3 deletions packages/web/src/routes/apps/context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { PreviewProvider } from '@/components/apps/use-preview';
import { LogsProvider } from '@/components/apps/use-logs';
import { PackageJsonProvider } from '@/components/apps/use-package-json';
import { AppProvider, useApp } from '@/components/apps/use-app';
import { VersionProvider } from '@/components/apps/use-version';

export function AppContext() {
const { app } = useLoaderData() as { app: AppType };
Expand Down Expand Up @@ -34,9 +35,11 @@ export function AppProviders(props: { children: React.ReactNode }) {
initialOpenedFile={initialOpenedFile}
>
<LogsProvider channel={channel}>
<PackageJsonProvider channel={channel}>
<PreviewProvider channel={channel}>{props.children}</PreviewProvider>
</PackageJsonProvider>
<VersionProvider>
<PackageJsonProvider channel={channel}>
<PreviewProvider channel={channel}>{props.children}</PreviewProvider>
</PackageJsonProvider>
</VersionProvider>
</LogsProvider>
</FilesProvider>
);
Expand Down
Loading

0 comments on commit 534b222

Please sign in to comment.