Skip to content
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

Introduce git utilities in the API #422

Merged
merged 3 commits into from
Oct 24, 2024
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
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