diff --git a/e2e-tests/delete_app.spec.ts b/e2e-tests/delete_app.spec.ts index 017540f..d4fe12d 100644 --- a/e2e-tests/delete_app.spec.ts +++ b/e2e-tests/delete_app.spec.ts @@ -20,9 +20,7 @@ test("delete app", async ({ po }) => { await po.page.getByRole("button", { name: "Delete App" }).click(); // Make sure the app is deleted - await expect(async () => { - expect(await po.getCurrentAppName()).toBe("(no app selected)"); - }).toPass(); + await po.isCurrentAppNameNone(); expect(fs.existsSync(appPath)).toBe(false); expect(po.getAppListItem({ appName })).not.toBeVisible(); }); diff --git a/e2e-tests/fixtures/import-app/version-integrity/AI_RULES.md b/e2e-tests/fixtures/import-app/version-integrity/AI_RULES.md new file mode 100644 index 0000000..85abfb8 --- /dev/null +++ b/e2e-tests/fixtures/import-app/version-integrity/AI_RULES.md @@ -0,0 +1 @@ +avoid AI_RULES auto-prompt diff --git a/e2e-tests/fixtures/import-app/version-integrity/a.txt b/e2e-tests/fixtures/import-app/version-integrity/a.txt new file mode 100644 index 0000000..2e65efe --- /dev/null +++ b/e2e-tests/fixtures/import-app/version-integrity/a.txt @@ -0,0 +1 @@ +a \ No newline at end of file diff --git a/e2e-tests/fixtures/import-app/version-integrity/b.txt b/e2e-tests/fixtures/import-app/version-integrity/b.txt new file mode 100644 index 0000000..63d8dbd --- /dev/null +++ b/e2e-tests/fixtures/import-app/version-integrity/b.txt @@ -0,0 +1 @@ +b \ No newline at end of file diff --git a/e2e-tests/fixtures/import-app/version-integrity/dir/c.txt b/e2e-tests/fixtures/import-app/version-integrity/dir/c.txt new file mode 100644 index 0000000..21b6070 --- /dev/null +++ b/e2e-tests/fixtures/import-app/version-integrity/dir/c.txt @@ -0,0 +1 @@ +dir/c.txt \ No newline at end of file diff --git a/e2e-tests/fixtures/import-app/version-integrity/to-be-deleted.txt b/e2e-tests/fixtures/import-app/version-integrity/to-be-deleted.txt new file mode 100644 index 0000000..22de87f --- /dev/null +++ b/e2e-tests/fixtures/import-app/version-integrity/to-be-deleted.txt @@ -0,0 +1 @@ +this file should be deleted \ No newline at end of file diff --git a/e2e-tests/fixtures/import-app/version-integrity/to-be-edited.txt b/e2e-tests/fixtures/import-app/version-integrity/to-be-edited.txt new file mode 100644 index 0000000..71e5997 --- /dev/null +++ b/e2e-tests/fixtures/import-app/version-integrity/to-be-edited.txt @@ -0,0 +1 @@ +before-edit \ No newline at end of file diff --git a/e2e-tests/fixtures/version-integrity-add-edit-delete.md b/e2e-tests/fixtures/version-integrity-add-edit-delete.md new file mode 100644 index 0000000..0bc00c9 --- /dev/null +++ b/e2e-tests/fixtures/version-integrity-add-edit-delete.md @@ -0,0 +1,10 @@ +Deleting a file + + + +new-file +end of new-file + + +after-edit + diff --git a/e2e-tests/fixtures/version-integrity-move-file.md b/e2e-tests/fixtures/version-integrity-move-file.md new file mode 100644 index 0000000..a2080e3 --- /dev/null +++ b/e2e-tests/fixtures/version-integrity-move-file.md @@ -0,0 +1,2 @@ +Moving a file + diff --git a/e2e-tests/helpers/test_helper.ts b/e2e-tests/helpers/test_helper.ts index e1817b2..6a4d0f9 100644 --- a/e2e-tests/helpers/test_helper.ts +++ b/e2e-tests/helpers/test_helper.ts @@ -15,7 +15,7 @@ export const Timeout = { MEDIUM: os.platform() === "win32" ? 30_000 : 15_000, }; -class PageObject { +export class PageObject { private userDataDir: string; constructor( @@ -26,11 +26,17 @@ class PageObject { this.userDataDir = userDataDir; } - async setUp({ autoApprove = false }: { autoApprove?: boolean } = {}) { + async setUp({ + autoApprove = false, + nativeGit = false, + }: { autoApprove?: boolean; nativeGit?: boolean } = {}) { await this.goToSettingsTab(); if (autoApprove) { await this.toggleAutoApprove(); } + if (nativeGit) { + await this.toggleNativeGit(); + } await this.setUpTestProvider(); await this.setUpTestModel(); @@ -67,6 +73,37 @@ class PageObject { // await page.getByRole('button', { name: 'Select Folder' }).press('Escape'); } + async snapshotAppFiles({ name }: { name?: string } = {}) { + const appPath = await this.getCurrentAppPath(); + if (!appPath || !fs.existsSync(appPath)) { + throw new Error(`App path does not exist: ${appPath}`); + } + + await expect(() => { + const filesData = generateAppFilesSnapshotData(appPath, appPath, [ + ".git", + "node_modules", + // Avoid snapshotting lock files because they are getting generated + // automatically and cause noise, and not super important anyways. + "package-lock.json", + "pnpm-lock.yaml", + ]); + + // Sort by relative path to ensure deterministic output + filesData.sort((a, b) => a.relativePath.localeCompare(b.relativePath)); + + const snapshotContent = filesData + .map((file) => `=== ${file.relativePath} ===\n${file.content}`) + .join("\n\n"); + + if (name) { + expect(snapshotContent).toMatchSnapshot(name); + } else { + expect(snapshotContent).toMatchSnapshot(); + } + }).toPass(); + } + async snapshotMessages({ replaceDumpPath = false, }: { replaceDumpPath?: boolean } = {}) { @@ -134,9 +171,10 @@ class PageObject { return this.page.getByTestId("preview-iframe-element"); } - async snapshotPreview() { + async snapshotPreview({ name }: { name?: string } = {}) { const iframe = this.getPreviewIframeElement(); await expect(iframe.contentFrame().locator("body")).toMatchAriaSnapshot({ + name, timeout: Timeout.LONG, }); } @@ -299,7 +337,21 @@ class PageObject { return this.page.getByTestId(`app-list-item-${appName}`); } + async isCurrentAppNameNone() { + await expect(async () => { + await expect(this.getTitleBarAppNameButton()).toContainText( + "no app selected", + ); + }).toPass(); + } + async getCurrentAppName() { + // Make sure to wait for the app to be set to avoid a race condition. + await expect(async () => { + await expect(this.getTitleBarAppNameButton()).not.toContainText( + "no app selected", + ); + }).toPass(); return (await this.getTitleBarAppNameButton().textContent())?.replace( "App: ", "", @@ -338,6 +390,10 @@ class PageObject { await this.page.getByRole("switch", { name: "Auto-approve" }).click(); } + async toggleNativeGit() { + await this.page.getByRole("switch", { name: "Enable Native Git" }).click(); + } + async snapshotSettings() { const settings = path.join(this.userDataDir, "user-settings.json"); const settingsContent = fs.readFileSync(settings, "utf-8"); @@ -588,3 +644,48 @@ function prettifyDump( }) .join("\n\n"); } + +interface FileSnapshotData { + relativePath: string; + content: string; +} + +function generateAppFilesSnapshotData( + currentPath: string, + basePath: string, + ignorePatterns: string[], +): FileSnapshotData[] { + const entries = fs.readdirSync(currentPath, { withFileTypes: true }); + let files: FileSnapshotData[] = []; + + // Sort entries for deterministic order + entries.sort((a, b) => a.name.localeCompare(b.name)); + + for (const entry of entries) { + const entryPath = path.join(currentPath, entry.name); + if (ignorePatterns.includes(entry.name)) { + continue; + } + + if (entry.isDirectory()) { + files = files.concat( + generateAppFilesSnapshotData(entryPath, basePath, ignorePatterns), + ); + } else if (entry.isFile()) { + const relativePath = path.relative(basePath, entryPath); + try { + const content = fs.readFileSync(entryPath, "utf-8"); + files.push({ relativePath, content }); + } catch (error) { + // Could be a binary file or permission issue, log and add a placeholder + const e = error as Error; + console.warn(`Could not read file ${entryPath}: ${e.message}`); + files.push({ + relativePath, + content: `[Error reading file: ${e.message}]`, + }); + } + } + } + return files; +} diff --git a/e2e-tests/snapshots/switch_versions.spec.ts_v1 b/e2e-tests/snapshots/switch_versions.spec.ts_v1 new file mode 100644 index 0000000..a778454 --- /dev/null +++ b/e2e-tests/snapshots/switch_versions.spec.ts_v1 @@ -0,0 +1,7 @@ +- region "Notifications (F8)": + - list +- region "Notifications alt+T" +- heading "Welcome to Your Blank App" [level=1] +- paragraph: Start building your amazing project here! +- link "Made with Dyad": + - /url: https://www.dyad.sh/ \ No newline at end of file diff --git a/e2e-tests/snapshots/switch_versions.spec.ts_v2 b/e2e-tests/snapshots/switch_versions.spec.ts_v2 new file mode 100644 index 0000000..d176260 --- /dev/null +++ b/e2e-tests/snapshots/switch_versions.spec.ts_v2 @@ -0,0 +1,4 @@ +- region "Notifications (F8)": + - list +- region "Notifications alt+T" +- text: Testing:write-index! \ No newline at end of file diff --git a/e2e-tests/snapshots/version_integrity.spec.ts_v1 b/e2e-tests/snapshots/version_integrity.spec.ts_v1 new file mode 100644 index 0000000..a792447 --- /dev/null +++ b/e2e-tests/snapshots/version_integrity.spec.ts_v1 @@ -0,0 +1,18 @@ +=== a.txt === +a + +=== AI_RULES.md === +avoid AI_RULES auto-prompt + + +=== b.txt === +b + +=== dir/c.txt === +dir/c.txt + +=== to-be-deleted.txt === +this file should be deleted + +=== to-be-edited.txt === +before-edit \ No newline at end of file diff --git a/e2e-tests/snapshots/version_integrity.spec.ts_v2 b/e2e-tests/snapshots/version_integrity.spec.ts_v2 new file mode 100644 index 0000000..17b268b --- /dev/null +++ b/e2e-tests/snapshots/version_integrity.spec.ts_v2 @@ -0,0 +1,19 @@ +=== a.txt === +a + +=== AI_RULES.md === +avoid AI_RULES auto-prompt + + +=== b.txt === +b + +=== dir/c.txt === +dir/c.txt + +=== new-file.js === +new-file +end of new-file + +=== to-be-edited.txt === +after-edit \ No newline at end of file diff --git a/e2e-tests/snapshots/version_integrity.spec.ts_v3 b/e2e-tests/snapshots/version_integrity.spec.ts_v3 new file mode 100644 index 0000000..b31411a --- /dev/null +++ b/e2e-tests/snapshots/version_integrity.spec.ts_v3 @@ -0,0 +1,19 @@ +=== a.txt === +a + +=== AI_RULES.md === +avoid AI_RULES auto-prompt + + +=== b.txt === +b + +=== new-dir/d.txt === +dir/c.txt + +=== new-file.js === +new-file +end of new-file + +=== to-be-edited.txt === +after-edit \ No newline at end of file diff --git a/e2e-tests/switch_versions.spec.ts b/e2e-tests/switch_versions.spec.ts index 9a823f5..f954d25 100644 --- a/e2e-tests/switch_versions.spec.ts +++ b/e2e-tests/switch_versions.spec.ts @@ -1,22 +1,30 @@ -import { testSkipIfWindows } from "./helpers/test_helper"; +import { PageObject, testSkipIfWindows } from "./helpers/test_helper"; import { expect } from "@playwright/test"; -testSkipIfWindows("switch versions", async ({ po }) => { - await po.setUp({ autoApprove: true }); +const runSwitchVersionTest = async (po: PageObject, nativeGit: boolean) => { + await po.setUp({ autoApprove: true, nativeGit }); await po.sendPrompt("tc=write-index"); - await po.snapshotPreview(); + await po.snapshotPreview({ name: `v2` }); expect( await po.page.getByRole("button", { name: "Version" }).textContent(), ).toBe("Version 2"); await po.page.getByRole("button", { name: "Version" }).click(); await po.page.getByText("Init Dyad app Undo").click(); - await po.snapshotPreview(); + await po.snapshotPreview({ name: `v1` }); await po.page.getByRole("button", { name: "Undo to latest version" }).click(); // Should be same as the previous snapshot, but just to be sure. - await po.snapshotPreview(); + await po.snapshotPreview({ name: `v1` }); await expect(po.page.getByText("Version 3")).toBeVisible(); +}; + +testSkipIfWindows("switch versions", async ({ po }) => { + await runSwitchVersionTest(po, false); +}); + +testSkipIfWindows("switch versions with native git", async ({ po }) => { + await runSwitchVersionTest(po, true); }); diff --git a/e2e-tests/template-create-nextjs.spec.ts b/e2e-tests/template-create-nextjs.spec.ts index a23d2f6..c291fd6 100644 --- a/e2e-tests/template-create-nextjs.spec.ts +++ b/e2e-tests/template-create-nextjs.spec.ts @@ -15,6 +15,6 @@ test("create next.js app", async ({ po }) => { await po.clickRestart(); // This can be pretty slow because it's waiting for the app to build. - await expect(po.getPreviewIframeElement()).toBeVisible({ timeout: 50_000 }); + await expect(po.getPreviewIframeElement()).toBeVisible({ timeout: 100_000 }); await po.snapshotPreview(); }); diff --git a/e2e-tests/version_integrity.spec.ts b/e2e-tests/version_integrity.spec.ts new file mode 100644 index 0000000..97419ec --- /dev/null +++ b/e2e-tests/version_integrity.spec.ts @@ -0,0 +1,51 @@ +import { PageObject, testSkipIfWindows } from "./helpers/test_helper"; + +import * as eph from "electron-playwright-helpers"; +import path from "node:path"; + +const runVersionIntegrityTest = async (po: PageObject, nativeGit: boolean) => { + await po.setUp({ autoApprove: true, nativeGit }); + + // Importing a simple app with a few files. + await po.page.getByRole("button", { name: "Import App" }).click(); + await eph.stubDialog(po.electronApp, "showOpenDialog", { + filePaths: [ + path.join(__dirname, "fixtures", "import-app", "version-integrity"), + ], + }); + + await po.page.getByRole("button", { name: "Select Folder" }).click(); + await po.page.getByRole("textbox", { name: "Enter new app name" }).click(); + await po.page + .getByRole("textbox", { name: "Enter new app name" }) + .fill("version-integrity-app"); + await po.page.getByRole("button", { name: "Import" }).click(); + + // Initial snapshot + await po.snapshotAppFiles({ name: "v1" }); + + // Add a file and delete a file + await po.sendPrompt("tc=version-integrity-add-edit-delete"); + await po.snapshotAppFiles({ name: "v2" }); + + // Move a file + await po.sendPrompt("tc=version-integrity-move-file"); + await po.snapshotAppFiles({ name: "v3" }); + + // Open version pane + await po.page.getByRole("button", { name: "Version 3" }).click(); + await po.page.getByText("Init Dyad app Undo").click(); + await po.snapshotAppFiles({ name: "v1" }); + + await po.page.getByRole("button", { name: "Undo to latest version" }).click(); + // Should be same as the previous snapshot, but just to be sure. + await po.snapshotAppFiles({ name: "v1" }); +}; + +testSkipIfWindows("version integrity (git isomorphic)", async ({ po }) => { + await runVersionIntegrityTest(po, false); +}); + +testSkipIfWindows("version integrity (git native)", async ({ po }) => { + await runVersionIntegrityTest(po, true); +}); diff --git a/src/ipc/handlers/app_handlers.ts b/src/ipc/handlers/app_handlers.ts index 210e810..160888a 100644 --- a/src/ipc/handlers/app_handlers.ts +++ b/src/ipc/handlers/app_handlers.ts @@ -23,7 +23,7 @@ import { getEnvVar } from "../utils/read_env"; import { readSettings } from "../../main/settings"; import fixPath from "fix-path"; -import { getGitAuthor } from "../utils/git_author"; + import killPort from "kill-port"; import util from "util"; import log from "electron-log"; @@ -33,6 +33,7 @@ import { getLanguageModelProviders } from "../shared/language_model_helpers"; import { startProxy } from "../utils/start_proxy_server"; import { Worker } from "worker_threads"; import { createFromTemplate } from "./createFromTemplate"; +import { gitCommit } from "../utils/git_utils"; const logger = log.scope("app_handlers"); const handle = createLoggedHandler(logger); @@ -207,11 +208,9 @@ export function registerAppHandlers() { }); // Create initial commit - const commitHash = await git.commit({ - fs: fs, - dir: fullAppPath, + const commitHash = await gitCommit({ + path: fullAppPath, message: "Init Dyad app", - author: await getGitAuthor(), }); // Update chat with initial commit hash @@ -521,11 +520,9 @@ export function registerAppHandlers() { filepath: filePath, }); - await git.commit({ - fs, - dir: appPath, + await gitCommit({ + path: appPath, message: `Updated ${filePath}`, - author: await getGitAuthor(), }); } diff --git a/src/ipc/handlers/import_handlers.ts b/src/ipc/handlers/import_handlers.ts index 9882c8a..08a449e 100644 --- a/src/ipc/handlers/import_handlers.ts +++ b/src/ipc/handlers/import_handlers.ts @@ -9,9 +9,10 @@ import { db } from "@/db"; import { chats } from "@/db/schema"; import { eq } from "drizzle-orm"; import git from "isomorphic-git"; -import { getGitAuthor } from "../utils/git_author"; + import { ImportAppParams, ImportAppResult } from "../ipc_types"; import { copyDirectoryRecursive } from "../utils/file_utils"; +import { gitCommit } from "../utils/git_utils"; const logger = log.scope("import-handlers"); const handle = createLoggedHandler(logger); @@ -114,11 +115,9 @@ export function registerImportHandlers() { }); // Create initial commit - await git.commit({ - fs: fs, - dir: destPath, + await gitCommit({ + path: destPath, message: "Init Dyad app", - author: await getGitAuthor(), }); } diff --git a/src/ipc/handlers/version_handlers.ts b/src/ipc/handlers/version_handlers.ts index 94c1ef9..f00b742 100644 --- a/src/ipc/handlers/version_handlers.ts +++ b/src/ipc/handlers/version_handlers.ts @@ -5,12 +5,11 @@ import type { Version, BranchResult } from "../ipc_types"; import fs from "node:fs"; import path from "node:path"; import { getDyadAppPath } from "../../paths/paths"; -import git from "isomorphic-git"; -import { promises as fsPromises } from "node:fs"; +import git, { type ReadCommitResult } from "isomorphic-git"; import { withLock } from "../utils/lock_utils"; -import { getGitAuthor } from "../utils/git_author"; import log from "electron-log"; import { createLoggedHandler } from "./safe_handle"; +import { gitCheckout, gitCommit, gitStageToRevert } from "../utils/git_utils"; const logger = log.scope("version_handlers"); @@ -40,7 +39,7 @@ export function registerVersionHandlers() { depth: 10_000, // Limit to last 10_000 commits for performance }); - return commits.map((commit) => ({ + return commits.map((commit: ReadCommitResult) => ({ oid: commit.oid, message: commit.commit.message, timestamp: commit.commit.author.timestamp, @@ -102,65 +101,19 @@ export function registerVersionHandlers() { const appPath = getDyadAppPath(app.path); - await git.checkout({ - fs, - dir: appPath, + await gitCheckout({ + path: appPath, ref: "main", - force: true, - }); - // Get status matrix comparing the target commit (previousVersionId as HEAD) with current working directory - const matrix = await git.statusMatrix({ - fs, - dir: appPath, - ref: previousVersionId, }); - // Process each file to revert to the state in previousVersionId - for (const [filepath, headStatus, workdirStatus] of matrix) { - const fullPath = path.join(appPath, filepath); - - // If file exists in HEAD (previous version) - if (headStatus === 1) { - // If file doesn't exist or has changed in working directory, restore it from the target commit - if (workdirStatus !== 1) { - const { blob } = await git.readBlob({ - fs, - dir: appPath, - oid: previousVersionId, - filepath, - }); - await fsPromises.mkdir(path.dirname(fullPath), { - recursive: true, - }); - await fsPromises.writeFile(fullPath, Buffer.from(blob)); - } - } - // If file doesn't exist in HEAD but exists in working directory, delete it - else if (headStatus === 0 && workdirStatus !== 0) { - if (fs.existsSync(fullPath)) { - await fsPromises.unlink(fullPath); - await git.remove({ - fs, - dir: appPath, - filepath: filepath, - }); - } - } - } - - // Stage all changes - await git.add({ - fs, - dir: appPath, - filepath: ".", + await gitStageToRevert({ + path: appPath, + targetOid: previousVersionId, }); - // Create a revert commit - await git.commit({ - fs, - dir: appPath, + await gitCommit({ + path: appPath, message: `Reverted all changes back to version ${previousVersionId}`, - author: await getGitAuthor(), }); // Find the chat and message associated with the commit hash @@ -221,9 +174,8 @@ export function registerVersionHandlers() { const appPath = getDyadAppPath(app.path); - await git.checkout({ - fs, - dir: appPath, + await gitCheckout({ + path: appPath, ref: versionId, }); }); diff --git a/src/ipc/processors/response_processor.ts b/src/ipc/processors/response_processor.ts index b2afa4e..1f6fdfa 100644 --- a/src/ipc/processors/response_processor.ts +++ b/src/ipc/processors/response_processor.ts @@ -6,7 +6,6 @@ import { getDyadAppPath } from "../../paths/paths"; import path from "node:path"; import git from "isomorphic-git"; -import { getGitAuthor } from "../utils/git_author"; import log from "electron-log"; import { executeAddDependency } from "./executeAddDependency"; import { @@ -16,6 +15,7 @@ import { } from "../../supabase_admin/supabase_management_client"; import { isServerFunction } from "../../supabase_admin/supabase_utils"; import { SqlQuery } from "../../lib/schemas"; +import { gitCommit } from "../utils/git_utils"; const readFile = fs.promises.readFile; const logger = log.scope("response_processor"); @@ -460,11 +460,9 @@ export async function processFullResponseActions( ? `[dyad] ${chatSummary} - ${changes.join(", ")}` : `[dyad] ${changes.join(", ")}`; // Use chat summary, if provided, or default for commit message - let commitHash = await git.commit({ - fs, - dir: appPath, + let commitHash = await gitCommit({ + path: appPath, message, - author: await getGitAuthor(), }); logger.log(`Successfully committed changes: ${changes.join(", ")}`); @@ -482,11 +480,9 @@ export async function processFullResponseActions( filepath: ".", }); try { - commitHash = await git.commit({ - fs, - dir: appPath, + commitHash = await gitCommit({ + path: appPath, message: message + " + extra files edited outside of Dyad", - author: await getGitAuthor(), amend: true, }); logger.log( diff --git a/src/ipc/utils/git_utils.ts b/src/ipc/utils/git_utils.ts new file mode 100644 index 0000000..0e79ce7 --- /dev/null +++ b/src/ipc/utils/git_utils.ts @@ -0,0 +1,140 @@ +import { getGitAuthor } from "./git_author"; +import git from "isomorphic-git"; +import fs from "node:fs"; +import { promises as fsPromises } from "node:fs"; +import pathModule from "node:path"; +import { exec } from "node:child_process"; +import { promisify } from "node:util"; +import { readSettings } from "../../main/settings"; + +const execAsync = promisify(exec); + +export async function gitCommit({ + path, + message, + amend, +}: { + path: string; + message: string; + amend?: boolean; +}): Promise { + const settings = readSettings(); + if (settings.enableNativeGit) { + let command = `git -C "${path}" commit -m "${message.replace(/"/g, '\\"')}"`; + if (amend) { + command += " --amend"; + } + await execAsync(command); + const { stdout } = await execAsync(`git -C "${path}" rev-parse HEAD`); + return stdout.trim(); + } else { + return git.commit({ + fs: fs, + dir: path, + message, + author: await getGitAuthor(), + amend: amend, + }); + } +} + +export async function gitCheckout({ + path, + ref, +}: { + path: string; + ref: string; +}): Promise { + const settings = readSettings(); + if (settings.enableNativeGit) { + await execAsync(`git -C "${path}" checkout "${ref.replace(/"/g, '\\"')}"`); + return; + } else { + return git.checkout({ fs, dir: path, ref }); + } +} + +export async function gitStageToRevert({ + path, + targetOid, +}: { + path: string; + targetOid: string; +}): Promise { + const settings = readSettings(); + if (settings.enableNativeGit) { + // Get the current HEAD commit hash + const { stdout: currentHead } = await execAsync( + `git -C "${path}" rev-parse HEAD`, + ); + const currentCommit = currentHead.trim(); + + // If we're already at the target commit, nothing to do + if (currentCommit === targetOid) { + return; + } + + // Safety: refuse to run if the work-tree isn’t clean. + const { stdout: wtStatus } = await execAsync( + `git -C "${path}" status --porcelain`, + ); + if (wtStatus.trim() !== "") { + throw new Error("Cannot revert: working tree has uncommitted changes."); + } + + // Reset the working directory and index to match the target commit state + // This effectively undoes all changes since the target commit + await execAsync(`git -C "${path}" reset --hard "${targetOid}"`); + + // Reset back to the original HEAD but keep the working directory as it is + // This stages all the changes needed to revert to the target state + await execAsync(`git -C "${path}" reset --soft "${currentCommit}"`); + } else { + // Get status matrix comparing the target commit (previousVersionId as HEAD) with current working directory + const matrix = await git.statusMatrix({ + fs, + dir: path, + ref: targetOid, + }); + + // Process each file to revert to the state in previousVersionId + for (const [filepath, headStatus, workdirStatus] of matrix) { + const fullPath = pathModule.join(path, filepath); + + // If file exists in HEAD (previous version) + if (headStatus === 1) { + // If file doesn't exist or has changed in working directory, restore it from the target commit + if (workdirStatus !== 1) { + const { blob } = await git.readBlob({ + fs, + dir: path, + oid: targetOid, + filepath, + }); + await fsPromises.mkdir(pathModule.dirname(fullPath), { + recursive: true, + }); + await fsPromises.writeFile(fullPath, Buffer.from(blob)); + } + } + // If file doesn't exist in HEAD but exists in working directory, delete it + else if (headStatus === 0 && workdirStatus !== 0) { + if (fs.existsSync(fullPath)) { + await fsPromises.unlink(fullPath); + await git.remove({ + fs, + dir: path, + filepath: filepath, + }); + } + } + } + + // Stage all changes + await git.add({ + fs, + dir: path, + filepath: ".", + }); + } +} diff --git a/src/lib/schemas.ts b/src/lib/schemas.ts index d3ff133..aa41f2f 100644 --- a/src/lib/schemas.ts +++ b/src/lib/schemas.ts @@ -122,6 +122,8 @@ export const UserSettingsSchema = z.object({ enableProSmartFilesContextMode: z.boolean().optional(), selectedTemplateId: z.string().optional(), + enableNativeGit: z.boolean().optional(), + //////////////////////////////// // E2E TESTING ONLY. //////////////////////////////// diff --git a/src/pages/settings.tsx b/src/pages/settings.tsx index 3183c04..441c5af 100644 --- a/src/pages/settings.tsx +++ b/src/pages/settings.tsx @@ -15,6 +15,7 @@ import { useRouter } from "@tanstack/react-router"; import { GitHubIntegration } from "@/components/GitHubIntegration"; import { SupabaseIntegration } from "@/components/SupabaseIntegration"; import { Switch } from "@/components/ui/switch"; +import { Label } from "@/components/ui/label"; export default function SettingsPage() { const { theme, setTheme } = useTheme(); @@ -108,6 +109,35 @@ export default function SettingsPage() { + + + { + updateSettings({ + enableNativeGit: checked, + }); + }} + /> + Enable Native Git + + + (Experimental) Native Git offers faster performance but requires{" "} + { + IpcClient.getInstance().openExternalUrl( + "https://git-scm.com/downloads", + ); + }} + className="text-blue-600 hover:underline dark:text-blue-400" + > + installing Git + + . + + +