From 534b222775499caa7689fb2bff611eec1f148e27 Mon Sep 17 00:00:00 2001 From: Nicholas Charriere Date: Thu, 24 Oct 2024 11:04:33 -0700 Subject: [PATCH] Introduce git utilities in the API (#422) * 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 --- packages/api/apps/app.mts | 6 ++ packages/api/apps/git.mts | 80 +++++++++++++++++ .../templates/react-typescript/.gitignore | 4 + packages/api/package.json | 1 + packages/api/server/http.mts | 39 ++++++++ packages/web/src/clients/http/apps.ts | 33 +++++++ .../web/src/components/apps/use-version.tsx | 89 +++++++++++++++++++ packages/web/src/components/chat.tsx | 7 ++ packages/web/src/routes/apps/context.tsx | 9 +- pnpm-lock.yaml | 58 ++++++++++++ 10 files changed, 323 insertions(+), 3 deletions(-) create mode 100644 packages/api/apps/git.mts create mode 100644 packages/api/apps/templates/react-typescript/.gitignore create mode 100644 packages/web/src/components/apps/use-version.tsx diff --git a/packages/api/apps/app.mts b/packages/api/apps/app.mts index edb707e3..949ad43d 100644 --- a/packages/api/apps/app.mts +++ b/packages/api/apps/app.mts @@ -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); @@ -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({ @@ -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(', ')}`); }, }); } diff --git a/packages/api/apps/git.mts b/packages/api/apps/git.mts new file mode 100644 index 00000000..4ad80041 --- /dev/null +++ b/packages/api/apps/git.mts @@ -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 { + 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 { + 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: 'ai@srcbook.com', + }, + }); + + 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 { + 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> { + 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 { + 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 { + const dir = pathToApp(app.externalId); + const currentCommit = await git.resolveRef({ fs, dir, ref: 'HEAD' }); + return currentCommit; +} diff --git a/packages/api/apps/templates/react-typescript/.gitignore b/packages/api/apps/templates/react-typescript/.gitignore new file mode 100644 index 00000000..b2646b39 --- /dev/null +++ b/packages/api/apps/templates/react-typescript/.gitignore @@ -0,0 +1,4 @@ +# typical gitignore for web apps +node_modules +dist +.DS_Store \ No newline at end of file diff --git a/packages/api/package.json b/packages/api/package.json index 75260d71..0bc8b666 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -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:", diff --git a/packages/api/server/http.mts b/packages/api/server/http.mts index 31f1cb8d..d793bdf7 100644 --- a/packages/api/server/http.mts +++ b/packages/api/server/http.mts @@ -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(); @@ -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; diff --git a/packages/web/src/clients/http/apps.ts b/packages/web/src/clients/http/apps.ts index b8f6cda0..7a0d2111 100644 --- a/packages/web/src/clients/http/apps.ts +++ b/packages/web/src/clients/http/apps.ts @@ -295,3 +295,36 @@ export async function exportApp(id: string, name: string): Promise { return response.blob(); } + +type VersionResponse = { + sha: string; +}; + +export async function getCurrentVersion(id: string): Promise { + 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 { + 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(); +} diff --git a/packages/web/src/components/apps/use-version.tsx b/packages/web/src/components/apps/use-version.tsx new file mode 100644 index 00000000..a3ae4f60 --- /dev/null +++ b/packages/web/src/components/apps/use-version.tsx @@ -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; + checkout: (sha: string) => Promise; + fetchVersions: () => Promise; +} + +const VersionContext = createContext(undefined); + +export const VersionProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const { app } = useApp(); + // TODO implement this + // const { refreshFiles } = useFiles(); + const [currentVersion, setCurrentVersion] = useState(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 ( + + {children} + + ); +}; + +export const useVersion = () => { + const context = useContext(VersionContext); + if (context === undefined) { + throw new Error('useVersion must be used within a VersionProvider'); + } + return context; +}; diff --git a/packages/web/src/components/chat.tsx b/packages/web/src/components/chat.tsx index 0f3a812a..ead9c351 100644 --- a/packages/web/src/components/chat.tsx +++ b/packages/web/src/components/chat.tsx @@ -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, @@ -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); const [isFeedbackModalOpen, setIsFeedbackModalOpen] = React.useState(false); @@ -286,6 +288,11 @@ function DiffBox({
{app.name} v{version} + {currentVersion && ( + + {currentVersion.sha.slice(0, 7)} + + )}
{files.map((file) => ( diff --git a/packages/web/src/routes/apps/context.tsx b/packages/web/src/routes/apps/context.tsx index 780b6cc8..01a73f89 100644 --- a/packages/web/src/routes/apps/context.tsx +++ b/packages/web/src/routes/apps/context.tsx @@ -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 }; @@ -34,9 +35,11 @@ export function AppProviders(props: { children: React.ReactNode }) { initialOpenedFile={initialOpenedFile} > - - {props.children} - + + + {props.children} + + ); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2bca2738..571ad3c9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -84,6 +84,9 @@ importers: fast-xml-parser: specifier: ^4.5.0 version: 4.5.0 + isomorphic-git: + specifier: ^1.27.1 + version: 1.27.1 marked: specifier: 'catalog:' version: 14.1.2 @@ -2475,6 +2478,9 @@ packages: ast-types-flow@0.0.8: resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==} + async-lock@1.4.1: + resolution: {integrity: sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ==} + async@3.2.6: resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} @@ -2651,6 +2657,9 @@ packages: class-variance-authority@0.7.0: resolution: {integrity: sha512-jFI8IQw4hczaL4ALINxqLEXQbWcNjoSkloa4IaufXCJr6QawJyw7tuRysRsrE8w2p/4gGaxKIt/hX3qz/IbD1A==} + clean-git-ref@2.0.1: + resolution: {integrity: sha512-bLSptAy2P0s6hU4PzuIMKmMJJSE6gLXGH1cntDu7bWJUksvuM+7ReOK61mozULErYvP6a15rnYl0zFDef+pyPw==} + client-only@0.0.1: resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} @@ -3064,6 +3073,9 @@ packages: diff-match-patch@1.0.5: resolution: {integrity: sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==} + diff3@0.0.3: + resolution: {integrity: sha512-iSq8ngPOt0K53A6eVr4d5Kn6GNrM2nQZtC740pzIriHtn4pOQ2lyzEXQMBeVcWERN0ye7fhBsk9PbLLQOnUx/g==} + diff@7.0.0: resolution: {integrity: sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==} engines: {node: '>=0.3.1'} @@ -3901,6 +3913,11 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + isomorphic-git@1.27.1: + resolution: {integrity: sha512-X32ph5zIWfT75QAqW2l3JCIqnx9/GWd17bRRehmn3qmWc34OYbSXY6Cxv0o9bIIY+CWugoN4nQFHNA+2uYf2nA==} + engines: {node: '>=12'} + hasBin: true + iterator.prototype@1.1.2: resolution: {integrity: sha512-DR33HMMr8EzwuRL8Y9D3u2BMj8+RqSE850jfGu59kS7tbmPLzGkZmVSfyCFSDxuZiEY6Rzt3T2NA/qU+NwVj1w==} @@ -4143,6 +4160,9 @@ packages: minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + minimisted@2.0.1: + resolution: {integrity: sha512-1oPjfuLQa2caorJUM8HV8lGgWCc0qqAO1MNv/k05G4qslmsndV/5WdNZrqCiyqiz3wohia2Ij2B7w2Dr7/IyrA==} + minipass@7.1.2: resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} engines: {node: '>=16 || 14 >=14.17'} @@ -4318,6 +4338,9 @@ packages: package-manager-detector@0.2.0: resolution: {integrity: sha512-E385OSk9qDcXhcM9LNSe4sdhx8a9mAPrZ4sMLW+tmxl5ZuGtPUcdFu+MPP2jbgiWAZ6Pfe5soGFMd+0Db5Vrog==} + pako@1.0.11: + resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -4791,6 +4814,10 @@ packages: setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + sha.js@2.4.11: + resolution: {integrity: sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ==} + hasBin: true + shebang-command@1.2.0: resolution: {integrity: sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==} engines: {node: '>=0.10.0'} @@ -7525,6 +7552,8 @@ snapshots: ast-types-flow@0.0.8: {} + async-lock@1.4.1: {} + async@3.2.6: {} asynckit@0.4.0: {} @@ -7727,6 +7756,8 @@ snapshots: dependencies: clsx: 2.0.0 + clean-git-ref@2.0.1: {} + client-only@0.0.1: {} cliui@7.0.4: @@ -8190,6 +8221,8 @@ snapshots: diff-match-patch@1.0.5: {} + diff3@0.0.3: {} + diff@7.0.0: {} dir-glob@3.0.1: @@ -9116,6 +9149,20 @@ snapshots: isexe@2.0.0: {} + isomorphic-git@1.27.1: + dependencies: + async-lock: 1.4.1 + clean-git-ref: 2.0.1 + crc-32: 1.2.2 + diff3: 0.0.3 + ignore: 5.3.2 + minimisted: 2.0.1 + pako: 1.0.11 + pify: 4.0.1 + readable-stream: 3.6.2 + sha.js: 2.4.11 + simple-get: 4.0.1 + iterator.prototype@1.1.2: dependencies: define-properties: 1.2.1 @@ -9347,6 +9394,10 @@ snapshots: minimist@1.2.8: {} + minimisted@2.0.1: + dependencies: + minimist: 1.2.8 + minipass@7.1.2: {} mkdirp-classic@0.5.3: {} @@ -9517,6 +9568,8 @@ snapshots: package-manager-detector@0.2.0: {} + pako@1.0.11: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -10048,6 +10101,11 @@ snapshots: setprototypeof@1.2.0: {} + sha.js@2.4.11: + dependencies: + inherits: 2.0.4 + safe-buffer: 5.2.1 + shebang-command@1.2.0: dependencies: shebang-regex: 1.0.0