Files
moreminimore-vibe/src/ipc/handlers/version_handlers.ts
Adeniji Adekunle James d3f3ac3ae1 Replace native Git with Dugite to support users without Git installed (#1760)
I moved all isomorphic-git usage into a single git_utils.ts file and
added Dugite as an alternative Git provider. The app now checks the
user’s settings and uses dugite when user enabled native git for all
isomorphic-git commands. This makes it easy to fully remove
isomorphic-git in the future by updating only git_utils.ts.

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> Adds Dugite-based native Git (bundled binary) and refactors all Git
calls to a unified git_utils API, replacing direct isomorphic-git usage
across the app.
> 
> - **Git Platform Abstraction**:
> - Introduces `dugite` and bundles Git via Electron Forge
(`extraResource`) with `LOCAL_GIT_DIRECTORY` setup in `src/main.ts`.
> - Adds `src/ipc/git_types.ts` and a comprehensive
`src/ipc/utils/git_utils.ts` wrapper supporting both Dugite (native) and
`isomorphic-git` (fallback): `commit`, `add`/`addAll`, `remove`, `init`,
`clone`, `push`, `setRemoteUrl`, `currentBranch`, `listBranches`,
`renameBranch`, `log`, `isIgnored`, `getCurrentCommitHash`,
`getGitUncommittedFiles`, `getFileAtCommit`, `checkout`,
`stageToRevert`.
> - **Refactors (switch to git_utils)**:
> - Replaces direct `isomorphic-git` imports in handlers and processors:
`app_handlers`, `chat_handlers`, `createFromTemplate`,
`github_handlers`, `import_handlers`, `portal_handlers`,
`version_handlers`, `response_processor`, `neon_timestamp_utils`,
`utils/codebase`.
> - Updates tests to mock `git_utils`
(`src/__tests__/chat_stream_handlers.test.ts`).
> - **Behavioral/Feature Updates**:
> - `createFromTemplate` uses `fetch` for GitHub API and `gitClone` for
cloning with cache validation.
> - GitHub integration uses `gitSetRemoteUrl`/`gitPush`/`gitClone`,
handling public vs token URLs and directory creation when native Git is
disabled.
> - Versioning, imports, app file edits, migrations now stage/commit via
`git_utils`.
> - **UI/Copy**:
>   - Updates Settings description for “Enable Native Git”.
> - **Config/Version**:
>   - Bumps version to `0.29.0-beta.1`; adds `dugite` dependency.
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
ba098f7f25d85fc6330a41dc718fbfd43fff2d6c. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: Will Chen <willchen90@gmail.com>
2025-12-09 19:01:25 -08:00

469 lines
15 KiB
TypeScript

import { db } from "../../db";
import { apps, messages, versions } from "../../db/schema";
import { desc, eq, and, gt } from "drizzle-orm";
import type {
Version,
BranchResult,
RevertVersionParams,
RevertVersionResponse,
} from "../ipc_types";
import type { GitCommit } from "../git_types";
import fs from "node:fs";
import path from "node:path";
import { getDyadAppPath } from "../../paths/paths";
import { withLock } from "../utils/lock_utils";
import log from "electron-log";
import { createLoggedHandler } from "./safe_handle";
import { deployAllSupabaseFunctions } from "../../supabase_admin/supabase_utils";
import {
gitCheckout,
gitCommit,
gitStageToRevert,
getCurrentCommitHash,
gitCurrentBranch,
gitLog,
} from "../utils/git_utils";
import {
getNeonClient,
getNeonErrorMessage,
} from "../../neon_admin/neon_management_client";
import {
updatePostgresUrlEnvVar,
updateDbPushEnvVar,
} from "../utils/app_env_var_utils";
import { storeDbTimestampAtCurrentVersion } from "../utils/neon_timestamp_utils";
import { retryOnLocked } from "../utils/retryOnLocked";
const logger = log.scope("version_handlers");
const handle = createLoggedHandler(logger);
async function restoreBranchForPreview({
appId,
dbTimestamp,
neonProjectId,
previewBranchId,
developmentBranchId,
}: {
appId: number;
dbTimestamp: string;
neonProjectId: string;
previewBranchId: string;
developmentBranchId: string;
}): Promise<void> {
try {
const neonClient = await getNeonClient();
await retryOnLocked(
() =>
neonClient.restoreProjectBranch(neonProjectId, previewBranchId, {
source_branch_id: developmentBranchId,
source_timestamp: dbTimestamp,
}),
`Restore preview branch ${previewBranchId} for app ${appId}`,
);
} catch (error) {
const errorMessage = getNeonErrorMessage(error);
logger.error("Error in restoreBranchForPreview:", errorMessage);
throw new Error(errorMessage);
}
}
export function registerVersionHandlers() {
handle("list-versions", async (_, { appId }: { appId: number }) => {
const app = await db.query.apps.findFirst({
where: eq(apps.id, appId),
});
if (!app) {
// The app might have just been deleted, so we return an empty array.
return [];
}
const appPath = getDyadAppPath(app.path);
// Just return an empty array if the app is not a git repo.
if (!fs.existsSync(path.join(appPath, ".git"))) {
return [];
}
const commits = await gitLog({
path: appPath,
depth: 100_000, // KEEP UP TO DATE WITH ChatHeader.tsx
});
// Get all snapshots for this app to match with commits
const appSnapshots = await db.query.versions.findMany({
where: eq(versions.appId, appId),
});
// Create a map of commitHash -> snapshot info for quick lookup
const snapshotMap = new Map<
string,
{ neonDbTimestamp: string | null; createdAt: Date }
>();
for (const snapshot of appSnapshots) {
snapshotMap.set(snapshot.commitHash, {
neonDbTimestamp: snapshot.neonDbTimestamp,
createdAt: snapshot.createdAt,
});
}
return commits.map((commit: GitCommit) => {
const snapshotInfo = snapshotMap.get(commit.oid);
return {
oid: commit.oid,
message: commit.commit.message,
timestamp: commit.commit.author.timestamp,
dbTimestamp: snapshotInfo?.neonDbTimestamp,
};
}) satisfies Version[];
});
handle(
"get-current-branch",
async (_, { appId }: { appId: number }): Promise<BranchResult> => {
const app = await db.query.apps.findFirst({
where: eq(apps.id, appId),
});
if (!app) {
throw new Error("App not found");
}
const appPath = getDyadAppPath(app.path);
// Return appropriate result if the app is not a git repo
if (!fs.existsSync(path.join(appPath, ".git"))) {
throw new Error("Not a git repository");
}
try {
const currentBranch = await gitCurrentBranch({ path: appPath });
return {
branch: currentBranch || "<no-branch>",
};
} catch (error: any) {
logger.error(`Error getting current branch for app ${appId}:`, error);
throw new Error(`Failed to get current branch: ${error.message}`);
}
},
);
handle(
"revert-version",
async (
_,
{ appId, previousVersionId }: RevertVersionParams,
): Promise<RevertVersionResponse> => {
return withLock(appId, async () => {
let successMessage = "Restored version";
let warningMessage = "";
const app = await db.query.apps.findFirst({
where: eq(apps.id, appId),
});
if (!app) {
throw new Error("App not found");
}
const appPath = getDyadAppPath(app.path);
// Get the current commit hash before reverting
const currentCommitHash = await getCurrentCommitHash({
path: appPath,
ref: "main",
});
await gitCheckout({
path: appPath,
ref: "main",
});
if (app.neonProjectId && app.neonDevelopmentBranchId) {
// We are going to add a new commit on top, so let's store
// the current timestamp at the current version.
await storeDbTimestampAtCurrentVersion({
appId,
});
}
await gitStageToRevert({
path: appPath,
targetOid: previousVersionId,
});
await gitCommit({
path: appPath,
message: `Reverted all changes back to version ${previousVersionId}`,
});
// Find the chat and message associated with the commit hash
const messageWithCommit = await db.query.messages.findFirst({
where: eq(messages.commitHash, previousVersionId),
with: {
chat: true,
},
});
// If we found a message with this commit hash, delete all subsequent messages (but keep this message)
if (messageWithCommit) {
const chatId = messageWithCommit.chatId;
// Find all messages in this chat with IDs > the one with our commit hash
const messagesToDelete = await db.query.messages.findMany({
where: and(
eq(messages.chatId, chatId),
gt(messages.id, messageWithCommit.id),
),
orderBy: desc(messages.id),
});
logger.log(
`Deleting ${messagesToDelete.length} messages after commit ${previousVersionId} from chat ${chatId}`,
);
// Delete the messages
if (messagesToDelete.length > 0) {
await db
.delete(messages)
.where(
and(
eq(messages.chatId, chatId),
gt(messages.id, messageWithCommit.id),
),
);
}
}
if (app.neonProjectId && app.neonDevelopmentBranchId) {
const version = await db.query.versions.findFirst({
where: and(
eq(versions.appId, appId),
eq(versions.commitHash, previousVersionId),
),
});
if (version && version.neonDbTimestamp) {
try {
const preserveBranchName = `preserve_${currentCommitHash}-${Date.now()}`;
const neonClient = await getNeonClient();
const response = await retryOnLocked(
() =>
neonClient.restoreProjectBranch(
app.neonProjectId!,
app.neonDevelopmentBranchId!,
{
source_branch_id: app.neonDevelopmentBranchId!,
source_timestamp: version.neonDbTimestamp!,
preserve_under_name: preserveBranchName,
},
),
`Restore development branch ${app.neonDevelopmentBranchId} for app ${appId}`,
);
// Update all versions which have a newer DB timestamp than the version we're restoring to
// and remove their DB timestamp.
await db
.update(versions)
.set({ neonDbTimestamp: null })
.where(
and(
eq(versions.appId, appId),
gt(versions.neonDbTimestamp, version.neonDbTimestamp),
),
);
const preserveBranchId = response.data.branch.parent_id;
if (!preserveBranchId) {
throw new Error("Preserve branch ID not found");
}
logger.info(
`Deleting preserve branch ${preserveBranchId} for app ${appId}`,
);
try {
// Intentionally do not await this because it's not
// critical for the restore operation, it's to clean up branches
// so the user doesn't hit the branch limit later.
retryOnLocked(
() =>
neonClient.deleteProjectBranch(
app.neonProjectId!,
preserveBranchId,
),
`Delete preserve branch ${preserveBranchId} for app ${appId}`,
{ retryBranchWithChildError: true },
);
} catch (error) {
const errorMessage = getNeonErrorMessage(error);
logger.error("Error in deleteProjectBranch:", errorMessage);
}
} catch (error) {
const errorMessage = getNeonErrorMessage(error);
logger.error("Error in restoreBranchForCheckout:", errorMessage);
warningMessage = `Could not restore database because of error: ${errorMessage}`;
// Do not throw, so we can finish switching the postgres branch
// It might throw because they picked a timestamp that's too old.
}
successMessage =
"Successfully restored to version (including database)";
}
await switchPostgresToDevelopmentBranch({
neonProjectId: app.neonProjectId,
neonDevelopmentBranchId: app.neonDevelopmentBranchId,
appPath: app.path,
});
}
// Re-deploy all Supabase edge functions after reverting
if (app.supabaseProjectId) {
try {
logger.info(
`Re-deploying all Supabase edge functions for app ${appId} after revert`,
);
const deployErrors = await deployAllSupabaseFunctions({
appPath,
supabaseProjectId: app.supabaseProjectId,
});
if (deployErrors.length > 0) {
warningMessage += `Some Supabase functions failed to deploy after revert: ${deployErrors.join(", ")}`;
logger.warn(warningMessage);
// Note: We don't fail the revert operation if function deployment fails
// The code has been successfully reverted, but functions may be out of sync
} else {
logger.info(
`Successfully re-deployed all Supabase edge functions for app ${appId}`,
);
}
} catch (error) {
warningMessage += `Error re-deploying Supabase edge functions after revert: ${error}`;
logger.warn(warningMessage);
// Continue with the revert operation even if function deployment fails
}
}
if (warningMessage) {
return { warningMessage };
}
return { successMessage };
});
},
);
handle(
"checkout-version",
async (
_,
{ appId, versionId: gitRef }: { appId: number; versionId: string },
): Promise<void> => {
return withLock(appId, async () => {
const app = await db.query.apps.findFirst({
where: eq(apps.id, appId),
});
if (!app) {
throw new Error("App not found");
}
if (
app.neonProjectId &&
app.neonDevelopmentBranchId &&
app.neonPreviewBranchId
) {
if (gitRef === "main") {
logger.info(
`Switching Postgres to development branch for app ${appId}`,
);
await switchPostgresToDevelopmentBranch({
neonProjectId: app.neonProjectId,
neonDevelopmentBranchId: app.neonDevelopmentBranchId,
appPath: app.path,
});
} else {
logger.info(
`Switching Postgres to preview branch for app ${appId}`,
);
// Regardless of whether we have a timestamp or not, we want to disable DB push
// while we're checking out an earlier version
await updateDbPushEnvVar({
appPath: app.path,
disabled: true,
});
const version = await db.query.versions.findFirst({
where: and(
eq(versions.appId, appId),
eq(versions.commitHash, gitRef),
),
});
if (version && version.neonDbTimestamp) {
// SWITCH the env var for POSTGRES_URL to the preview branch
const neonClient = await getNeonClient();
const connectionUri = await neonClient.getConnectionUri({
projectId: app.neonProjectId,
branch_id: app.neonPreviewBranchId,
// This is the default database name for Neon
database_name: "neondb",
// This is the default role name for Neon
role_name: "neondb_owner",
});
await restoreBranchForPreview({
appId,
dbTimestamp: version.neonDbTimestamp,
neonProjectId: app.neonProjectId,
previewBranchId: app.neonPreviewBranchId,
developmentBranchId: app.neonDevelopmentBranchId,
});
await updatePostgresUrlEnvVar({
appPath: app.path,
connectionUri: connectionUri.data.uri,
});
logger.info(
`Switched Postgres to preview branch for app ${appId} commit ${version.commitHash} dbTimestamp=${version.neonDbTimestamp}`,
);
}
}
}
const fullAppPath = getDyadAppPath(app.path);
await gitCheckout({
path: fullAppPath,
ref: gitRef,
});
});
},
);
}
async function switchPostgresToDevelopmentBranch({
neonProjectId,
neonDevelopmentBranchId,
appPath,
}: {
neonProjectId: string;
neonDevelopmentBranchId: string;
appPath: string;
}) {
// SWITCH the env var for POSTGRES_URL to the development branch
const neonClient = await getNeonClient();
const connectionUri = await neonClient.getConnectionUri({
projectId: neonProjectId,
branch_id: neonDevelopmentBranchId,
// This is the default database name for Neon
database_name: "neondb",
// This is the default role name for Neon
role_name: "neondb_owner",
});
await updatePostgresUrlEnvVar({
appPath,
connectionUri: connectionUri.data.uri,
});
await updateDbPushEnvVar({
appPath,
disabled: false,
});
}