From d3f3ac3ae1e9ae9f31b005b22eec333725c4a276 Mon Sep 17 00:00:00 2001 From: Adeniji Adekunle James Date: Wed, 10 Dec 2025 03:01:25 +0000 Subject: [PATCH] Replace native Git with Dugite to support users without Git installed (#1760) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- > [!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. > > 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). --------- Co-authored-by: Will Chen --- forge.config.ts | 1 + package-lock.json | 94 +++- package.json | 1 + src/__tests__/chat_stream_handlers.test.ts | 59 ++- src/ipc/git_types.ts | 60 +++ src/ipc/handlers/app_handlers.ts | 51 +- src/ipc/handlers/chat_handlers.ts | 8 +- src/ipc/handlers/createFromTemplate.ts | 63 +-- src/ipc/handlers/github_handlers.ts | 49 +- src/ipc/handlers/import_handlers.ts | 16 +- src/ipc/handlers/portal_handlers.ts | 10 +- src/ipc/handlers/version_handlers.ts | 33 +- src/ipc/ipc_types.ts | 1 + src/ipc/processors/response_processor.ts | 44 +- src/ipc/utils/git_utils.ts | 579 ++++++++++++++++++--- src/ipc/utils/neon_timestamp_utils.ts | 9 +- src/main.ts | 17 + src/pages/settings.tsx | 14 +- src/utils/codebase.ts | 8 +- 19 files changed, 817 insertions(+), 300 deletions(-) create mode 100644 src/ipc/git_types.ts diff --git a/forge.config.ts b/forge.config.ts index 847a851..8748e0c 100644 --- a/forge.config.ts +++ b/forge.config.ts @@ -74,6 +74,7 @@ const config: ForgeConfig = { }, asar: true, ignore, + extraResource: ["node_modules/dugite/git"], // ignore: [/node_modules\/(?!(better-sqlite3|bindings|file-uri-to-path)\/)/], }, rebuildConfig: { diff --git a/package-lock.json b/package-lock.json index 1621c85..9b10bb8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "dyad", - "version": "0.28.0", + "version": "0.29.0-beta.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "dyad", - "version": "0.28.0", + "version": "0.29.0-beta.1", "license": "MIT", "dependencies": { "@ai-sdk/amazon-bedrock": "^3.0.15", @@ -59,6 +59,7 @@ "date-fns": "^4.1.0", "dotenv": "^16.4.7", "drizzle-orm": "^0.41.0", + "dugite": "^3.0.0", "electron-log": "^5.3.3", "electron-playwright-helpers": "^1.7.1", "electron-squirrel-startup": "^1.0.1", @@ -8090,6 +8091,20 @@ "proxy-from-env": "^1.1.0" } }, + "node_modules/b4a": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.7.3.tgz", + "integrity": "sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q==", + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, "node_modules/bail": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", @@ -8106,6 +8121,20 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "license": "MIT" }, + "node_modules/bare-events": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz", + "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==", + "license": "Apache-2.0", + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -10203,6 +10232,31 @@ } } }, + "node_modules/dugite": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/dugite/-/dugite-3.0.0.tgz", + "integrity": "sha512-+q2i3y5TvlC2YaZofkdELHtmvHbT6yuBODimItxU6xEGtHqRt6rpApJzf6lAqtpo+y1gokhfsHyULH0yNZuTWQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "progress": "^2.0.3", + "tar-stream": "^3.1.7" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/dugite/node_modules/tar-stream": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", + "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==", + "license": "MIT", + "dependencies": { + "b4a": "^1.6.4", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -11584,6 +11638,15 @@ "dev": true, "license": "MIT" }, + "node_modules/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.7.0" + } + }, "node_modules/eventsource": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", @@ -11808,6 +11871,12 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "license": "MIT" }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "license": "MIT" + }, "node_modules/fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", @@ -17970,7 +18039,6 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.4.0" @@ -19985,6 +20053,17 @@ "node": ">= 0.4" } }, + "node_modules/streamx": { + "version": "2.23.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.23.0.tgz", + "integrity": "sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==", + "license": "MIT", + "dependencies": { + "events-universal": "^1.0.0", + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + } + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -20524,6 +20603,15 @@ "rimraf": "bin.js" } }, + "node_modules/text-decoder": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.3.tgz", + "integrity": "sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==", + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", diff --git a/package.json b/package.json index 298dc48..5635703 100644 --- a/package.json +++ b/package.json @@ -135,6 +135,7 @@ "date-fns": "^4.1.0", "dotenv": "^16.4.7", "drizzle-orm": "^0.41.0", + "dugite": "^3.0.0", "electron-log": "^5.3.3", "electron-playwright-helpers": "^1.7.1", "electron-squirrel-startup": "^1.0.1", diff --git a/src/__tests__/chat_stream_handlers.test.ts b/src/__tests__/chat_stream_handlers.test.ts index eae5200..5ddf638 100644 --- a/src/__tests__/chat_stream_handlers.test.ts +++ b/src/__tests__/chat_stream_handlers.test.ts @@ -13,9 +13,9 @@ import { hasUnclosedDyadWrite, } from "../ipc/handlers/chat_stream_handlers"; import fs from "node:fs"; -import git from "isomorphic-git"; import { db } from "../db"; -import { cleanFullResponse } from "@/ipc/utils/cleanFullResponse"; +import { cleanFullResponse } from "../ipc/utils/cleanFullResponse"; +import { gitAdd, gitRemove, gitCommit } from "../ipc/utils/git_utils"; // Mock fs with default export vi.mock("node:fs", async () => { @@ -43,14 +43,19 @@ vi.mock("node:fs", async () => { }; }); -// Mock isomorphic-git -vi.mock("isomorphic-git", () => ({ - default: { - add: vi.fn().mockResolvedValue(undefined), - remove: vi.fn().mockResolvedValue(undefined), - commit: vi.fn().mockResolvedValue(undefined), - statusMatrix: vi.fn().mockResolvedValue([]), - }, +// Mock Git utils +vi.mock("../ipc/utils/git_utils", () => ({ + gitAdd: vi.fn(), + gitCommit: vi.fn(), + gitRemove: vi.fn(), + gitRenameBranch: vi.fn(), + gitCurrentBranch: vi.fn(), + gitLog: vi.fn(), + gitInit: vi.fn(), + gitPush: vi.fn(), + gitSetRemoteUrl: vi.fn(), + gitStatus: vi.fn().mockResolvedValue([]), + getGitUncommittedFiles: vi.fn().mockResolvedValue([]), })); // Mock paths module to control getDyadAppPath @@ -703,12 +708,12 @@ describe("processFullResponse", () => { "/mock/user/data/path/mock-app-path/src/file1.js", "console.log('Hello');", ); - expect(git.add).toHaveBeenCalledWith( + expect(gitAdd).toHaveBeenCalledWith( expect.objectContaining({ filepath: "src/file1.js", }), ); - expect(git.commit).toHaveBeenCalled(); + expect(gitCommit).toHaveBeenCalled(); expect(result).toEqual({ updatedFiles: true }); }); @@ -783,24 +788,24 @@ describe("processFullResponse", () => { ); // Verify git operations were called for each file - expect(git.add).toHaveBeenCalledWith( + expect(gitAdd).toHaveBeenCalledWith( expect.objectContaining({ filepath: "src/file1.js", }), ); - expect(git.add).toHaveBeenCalledWith( + expect(gitAdd).toHaveBeenCalledWith( expect.objectContaining({ filepath: "src/utils/file2.js", }), ); - expect(git.add).toHaveBeenCalledWith( + expect(gitAdd).toHaveBeenCalledWith( expect.objectContaining({ filepath: "src/components/Button.tsx", }), ); // Verify commit was called once after all files were added - expect(git.commit).toHaveBeenCalledTimes(1); + expect(gitCommit).toHaveBeenCalledTimes(1); expect(result).toEqual({ updatedFiles: true }); }); @@ -825,17 +830,17 @@ describe("processFullResponse", () => { "/mock/user/data/path/mock-app-path/src/components/OldComponent.jsx", "/mock/user/data/path/mock-app-path/src/components/NewComponent.jsx", ); - expect(git.add).toHaveBeenCalledWith( + expect(gitAdd).toHaveBeenCalledWith( expect.objectContaining({ filepath: "src/components/NewComponent.jsx", }), ); - expect(git.remove).toHaveBeenCalledWith( + expect(gitRemove).toHaveBeenCalledWith( expect.objectContaining({ filepath: "src/components/OldComponent.jsx", }), ); - expect(git.commit).toHaveBeenCalled(); + expect(gitCommit).toHaveBeenCalled(); expect(result).toEqual({ updatedFiles: true }); }); @@ -852,7 +857,7 @@ describe("processFullResponse", () => { expect(fs.mkdirSync).toHaveBeenCalled(); expect(fs.renameSync).not.toHaveBeenCalled(); - expect(git.commit).not.toHaveBeenCalled(); + expect(gitCommit).not.toHaveBeenCalled(); expect(result).toEqual({ updatedFiles: false, extraFiles: undefined, @@ -875,12 +880,12 @@ describe("processFullResponse", () => { expect(fs.unlinkSync).toHaveBeenCalledWith( "/mock/user/data/path/mock-app-path/src/components/Unused.jsx", ); - expect(git.remove).toHaveBeenCalledWith( + expect(gitRemove).toHaveBeenCalledWith( expect.objectContaining({ filepath: "src/components/Unused.jsx", }), ); - expect(git.commit).toHaveBeenCalled(); + expect(gitCommit).toHaveBeenCalled(); expect(result).toEqual({ updatedFiles: true }); }); @@ -896,8 +901,8 @@ describe("processFullResponse", () => { }); expect(fs.unlinkSync).not.toHaveBeenCalled(); - expect(git.remove).not.toHaveBeenCalled(); - expect(git.commit).not.toHaveBeenCalled(); + expect(gitRemove).not.toHaveBeenCalled(); + expect(gitCommit).not.toHaveBeenCalled(); expect(result).toEqual({ updatedFiles: false, extraFiles: undefined, @@ -942,11 +947,11 @@ describe("processFullResponse", () => { ); // Check git operations - expect(git.add).toHaveBeenCalledTimes(2); // For the write and rename - expect(git.remove).toHaveBeenCalledTimes(2); // For the rename and delete + expect(gitAdd).toHaveBeenCalledTimes(2); // For the write and rename + expect(gitRemove).toHaveBeenCalledTimes(2); // For the rename and delete // Check the commit message includes all operations - expect(git.commit).toHaveBeenCalledWith( + expect(gitCommit).toHaveBeenCalledWith( expect.objectContaining({ message: expect.stringContaining( "wrote 1 file(s), renamed 1 file(s), deleted 1 file(s)", diff --git a/src/ipc/git_types.ts b/src/ipc/git_types.ts new file mode 100644 index 0000000..5d08e56 --- /dev/null +++ b/src/ipc/git_types.ts @@ -0,0 +1,60 @@ +// Type definitions for Git operations +export type GitCommit = { + oid: string; + commit: { + message: string; + author: { + timestamp: number; + }; + }; +}; +export interface GitBaseParams { + path: string; +} +export interface GitCommitParams extends GitBaseParams { + message: string; + amend?: boolean; +} +export interface GitFileParams extends GitBaseParams { + filepath: string; +} +export interface GitCheckoutParams extends GitBaseParams { + ref: string; +} +export interface GitBranchRenameParams extends GitBaseParams { + oldBranch: string; + newBranch: string; +} +export interface GitCloneParams { + path: string; // destination + url: string; + depth?: number | null; + singleBranch?: boolean; + accessToken?: string; +} +export interface GitLogParams extends GitBaseParams { + depth?: number; +} + +export interface GitResult { + success: boolean; + error?: string; +} +export interface GitPushParams extends GitBaseParams { + branch: string; + accessToken: string; + force?: boolean; +} +export interface GitFileAtCommitParams extends GitBaseParams { + filePath: string; + commitHash: string; +} +export interface GitSetRemoteUrlParams extends GitBaseParams { + remoteUrl: string; +} +export interface GitInitParams extends GitBaseParams { + ref?: string; // branch name, default = "main" +} +export interface GitStageToRevertParams extends GitBaseParams { + targetOid: string; +} diff --git a/src/ipc/handlers/app_handlers.ts b/src/ipc/handlers/app_handlers.ts index 5f6c6d7..599d8dd 100644 --- a/src/ipc/handlers/app_handlers.ts +++ b/src/ipc/handlers/app_handlers.ts @@ -14,7 +14,6 @@ import fs from "node:fs"; import path from "node:path"; import { getDyadAppPath, getUserDataPath } from "../../paths/paths"; import { ChildProcess, spawn } from "node:child_process"; -import git from "isomorphic-git"; import { promises as fsPromises } from "node:fs"; // Import our utility modules @@ -44,7 +43,13 @@ 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"; +import { + gitCommit, + gitAdd, + gitInit, + gitListBranches, + gitRenameBranch, +} from "../utils/git_utils"; import { safeSend } from "../utils/safe_sender"; import { normalizePath } from "../../../shared/normalizePath"; import { isServerFunction } from "@/supabase_admin/supabase_utils"; @@ -585,18 +590,11 @@ export function registerAppHandlers() { }); // Initialize git repo and create first commit - await git.init({ - fs: fs, - dir: fullAppPath, - defaultBranch: "main", - }); + + await gitInit({ path: fullAppPath, ref: "main" }); // Stage all files - await git.add({ - fs: fs, - dir: fullAppPath, - filepath: ".", - }); + await gitAdd({ path: fullAppPath, filepath: "." }); // Create initial commit const commitHash = await gitCommit({ @@ -657,18 +655,10 @@ export function registerAppHandlers() { if (!withHistory) { // Initialize git repo and create first commit - await git.init({ - fs: fs, - dir: newAppPath, - defaultBranch: "main", - }); + await gitInit({ path: newAppPath, ref: "main" }); // Stage all files - await git.add({ - fs: fs, - dir: newAppPath, - filepath: ".", - }); + await gitAdd({ path: newAppPath, filepath: "." }); // Create initial commit await gitCommit({ @@ -1049,11 +1039,7 @@ export function registerAppHandlers() { // Check if git repository exists and commit the change if (fs.existsSync(path.join(appPath, ".git"))) { - await git.add({ - fs, - dir: appPath, - filepath: filePath, - }); + await gitAdd({ path: appPath, filepath: filePath }); await gitCommit({ path: appPath, @@ -1398,7 +1384,7 @@ export function registerAppHandlers() { return withLock(appId, async () => { try { // Check if the old branch exists - const branches = await git.listBranches({ fs, dir: appPath }); + const branches = await gitListBranches({ path: appPath }); if (!branches.includes(oldBranchName)) { throw new Error(`Branch '${oldBranchName}' not found.`); } @@ -1414,11 +1400,10 @@ export function registerAppHandlers() { ); } - await git.renameBranch({ - fs: fs, - dir: appPath, - oldref: oldBranchName, - ref: newBranchName, + await gitRenameBranch({ + path: appPath, + oldBranch: oldBranchName, + newBranch: newBranchName, }); logger.info( `Branch renamed from '${oldBranchName}' to '${newBranchName}' for app ${appId}`, diff --git a/src/ipc/handlers/chat_handlers.ts b/src/ipc/handlers/chat_handlers.ts index d0c558f..733425e 100644 --- a/src/ipc/handlers/chat_handlers.ts +++ b/src/ipc/handlers/chat_handlers.ts @@ -3,13 +3,12 @@ import { db } from "../../db"; import { apps, chats, messages } from "../../db/schema"; import { desc, eq, and, like } from "drizzle-orm"; import type { ChatSearchResult, ChatSummary } from "../../lib/schemas"; -import * as git from "isomorphic-git"; -import * as fs from "fs"; import { createLoggedHandler } from "./safe_handle"; import log from "electron-log"; import { getDyadAppPath } from "../../paths/paths"; import { UpdateChatParams } from "../ipc_types"; +import { getCurrentCommitHash } from "../utils/git_utils"; const logger = log.scope("chat_handlers"); const handle = createLoggedHandler(logger); @@ -31,9 +30,8 @@ export function registerChatHandlers() { let initialCommitHash = null; try { // Get the current git revision of main branch - initialCommitHash = await git.resolveRef({ - fs, - dir: getDyadAppPath(app.path), + initialCommitHash = await getCurrentCommitHash({ + path: getDyadAppPath(app.path), ref: "main", }); } catch (error) { diff --git a/src/ipc/handlers/createFromTemplate.ts b/src/ipc/handlers/createFromTemplate.ts index 26756f8..a726c7f 100644 --- a/src/ipc/handlers/createFromTemplate.ts +++ b/src/ipc/handlers/createFromTemplate.ts @@ -1,9 +1,8 @@ import path from "path"; import fs from "fs-extra"; -import git from "isomorphic-git"; -import http from "isomorphic-git/http/node"; import { app } from "electron"; import { copyDirectoryRecursive } from "../utils/file_utils"; +import { gitClone, getCurrentCommitHash } from "../utils/git_utils"; import { readSettings } from "@/main/settings"; import { getTemplateOrThrow } from "../utils/template_utils"; import log from "electron-log"; @@ -35,9 +34,6 @@ export async function createFromTemplate({ } async function cloneRepo(repoUrl: string): Promise { - let orgName: string; - let repoName: string; - const url = new URL(repoUrl); if (url.protocol !== "https:") { throw new Error("Repository URL must use HTTPS."); @@ -55,8 +51,8 @@ async function cloneRepo(repoUrl: string): Promise { ); } - orgName = pathParts[0]; - repoName = path.basename(pathParts[1], ".git"); // Remove .git suffix if present + const orgName = pathParts[0]; + const repoName = path.basename(pathParts[1], ".git"); // Remove .git suffix if present if (!orgName || !repoName) { // This case should ideally be caught by pathParts.length !== 2 @@ -83,41 +79,31 @@ async function cloneRepo(repoUrl: string): Promise { const apiUrl = `https://api.github.com/repos/${orgName}/${repoName}/commits/HEAD`; logger.info(`Fetching remote SHA from ${apiUrl}`); - let remoteSha: string | undefined; - - const response = await http.request({ - url: apiUrl, + // Use native fetch instead of isomorphic-git http.request + const response = await fetch(apiUrl, { method: "GET", headers: { - "User-Agent": "Dyad", // GitHub API requires a User-Agent + "User-Agent": "Dyad", // GitHub API requires this Accept: "application/vnd.github.v3+json", }, }); - - if (response.statusCode === 200 && response.body) { - // Convert AsyncIterableIterator to string - const chunks: Uint8Array[] = []; - for await (const chunk of response.body) { - chunks.push(chunk); - } - const responseBodyStr = Buffer.concat(chunks).toString("utf8"); - const commitData = JSON.parse(responseBodyStr); - remoteSha = commitData.sha; - if (!remoteSha) { - throw new Error("SHA not found in GitHub API response."); - } - logger.info(`Successfully fetched remote SHA: ${remoteSha}`); - } else { + // Handle non-200 responses + if (!response.ok) { throw new Error( - `GitHub API request failed with status ${response.statusCode}: ${response.statusMessage}`, + `GitHub API request failed with status ${response.status}: ${response.statusText}`, ); } + // Parse JSON directly (fetch handles streaming internally) + const commitData = await response.json(); + const remoteSha = commitData.sha; + if (!remoteSha) { + throw new Error("SHA not found in GitHub API response."); + } - const localSha = await git.resolveRef({ - fs, - dir: cachePath, - ref: "HEAD", - }); + logger.info(`Successfully fetched remote SHA: ${remoteSha}`); + + // Compare with local SHA + const localSha = await getCurrentCommitHash({ path: cachePath }); if (remoteSha === localSha) { logger.info( @@ -129,7 +115,7 @@ async function cloneRepo(repoUrl: string): Promise { `Local cache for ${repoName} (SHA: ${localSha}) is outdated (Remote SHA: ${remoteSha}). Removing and re-cloning.`, ); fs.rmSync(cachePath, { recursive: true, force: true }); - // Proceed to clone + // Continue to clone… } } catch (err) { logger.warn( @@ -144,14 +130,7 @@ async function cloneRepo(repoUrl: string): Promise { logger.info(`Cloning ${repoUrl} to ${cachePath}`); try { - await git.clone({ - fs, - http, - dir: cachePath, - url: repoUrl, - singleBranch: true, - depth: 1, - }); + await gitClone({ path: cachePath, url: repoUrl, depth: 1 }); logger.info(`Successfully cloned ${repoUrl} to ${cachePath}`); } catch (err) { logger.error(`Failed to clone ${repoUrl} to ${cachePath}: `, err); diff --git a/src/ipc/handlers/github_handlers.ts b/src/ipc/handlers/github_handlers.ts index deb51f2..f8b73a8 100644 --- a/src/ipc/handlers/github_handlers.ts +++ b/src/ipc/handlers/github_handlers.ts @@ -1,8 +1,7 @@ import { ipcMain, BrowserWindow, IpcMainInvokeEvent } from "electron"; import fetch from "node-fetch"; // Use node-fetch for making HTTP requests in main process import { writeSettings, readSettings } from "../../main/settings"; -import git, { clone } from "isomorphic-git"; -import http from "isomorphic-git/http/node"; +import { gitSetRemoteUrl, gitPush, gitClone } from "../utils/git_utils"; import * as schema from "../../db/schema"; import fs from "node:fs"; import { getDyadAppPath } from "../../paths/paths"; @@ -575,25 +574,17 @@ async function handlePushToGithub( ? `${GITHUB_GIT_BASE}/${app.githubOrg}/${app.githubRepo}.git` : `https://${accessToken}:x-oauth-basic@github.com/${app.githubOrg}/${app.githubRepo}.git`; // Set or update remote URL using git config - await git.setConfig({ - fs, - dir: appPath, - path: "remote.origin.url", - value: remoteUrl, + await gitSetRemoteUrl({ + path: appPath, + remoteUrl, }); + // Push to GitHub - await git.push({ - fs, - http, - dir: appPath, - remote: "origin", - ref: "main", - remoteRef: branch, - onAuth: () => ({ - username: accessToken, - password: "x-oauth-basic", - }), - force: !!force, + await gitPush({ + path: appPath, + branch, + accessToken, + force, }); return { success: true }; } catch (err: any) { @@ -673,8 +664,11 @@ async function handleCloneRepoFromUrl( } const appPath = getDyadAppPath(finalAppName); - if (!fs.existsSync(appPath)) { - fs.mkdirSync(appPath, { recursive: true }); + // Ensure the app directory exists if native git is disabled + if (!settings.enableNativeGit) { + if (!fs.existsSync(appPath)) { + fs.mkdirSync(appPath, { recursive: true }); + } } // Use authenticated URL if token exists, otherwise use public HTTPS URL const cloneUrl = accessToken @@ -683,17 +677,10 @@ async function handleCloneRepoFromUrl( : `https://${accessToken}:x-oauth-basic@github.com/${owner}/${repoName}.git` : `https://github.com/${owner}/${repoName}.git`; // Changed: use public HTTPS URL instead of original url try { - await clone({ - fs, - http, - dir: appPath, + await gitClone({ + path: appPath, url: cloneUrl, - onAuth: accessToken - ? () => ({ - username: accessToken, - password: "x-oauth-basic", - }) - : undefined, + accessToken, singleBranch: false, }); } catch (cloneErr) { diff --git a/src/ipc/handlers/import_handlers.ts b/src/ipc/handlers/import_handlers.ts index 4059f77..8b1b478 100644 --- a/src/ipc/handlers/import_handlers.ts +++ b/src/ipc/handlers/import_handlers.ts @@ -8,11 +8,10 @@ import { apps } from "@/db/schema"; import { db } from "@/db"; import { chats } from "@/db/schema"; import { eq } from "drizzle-orm"; -import git from "isomorphic-git"; import { ImportAppParams, ImportAppResult } from "../ipc_types"; import { copyDirectoryRecursive } from "../utils/file_utils"; -import { gitCommit } from "../utils/git_utils"; +import { gitCommit, gitAdd, gitInit } from "../utils/git_utils"; const logger = log.scope("import-handlers"); const handle = createLoggedHandler(logger); @@ -106,18 +105,11 @@ export function registerImportHandlers() { .catch(() => false); if (!isGitRepo) { // Initialize git repo and create first commit - await git.init({ - fs: fs, - dir: destPath, - defaultBranch: "main", - }); + await gitInit({ path: destPath, ref: "main" }); // Stage all files - await git.add({ - fs: fs, - dir: destPath, - filepath: ".", - }); + + await gitAdd({ path: destPath, filepath: "." }); // Create initial commit await gitCommit({ diff --git a/src/ipc/handlers/portal_handlers.ts b/src/ipc/handlers/portal_handlers.ts index cb44c81..62416c0 100644 --- a/src/ipc/handlers/portal_handlers.ts +++ b/src/ipc/handlers/portal_handlers.ts @@ -5,9 +5,7 @@ import { apps } from "../../db/schema"; import { eq } from "drizzle-orm"; import { getDyadAppPath } from "../../paths/paths"; import { spawn } from "child_process"; -import fs from "node:fs"; -import git from "isomorphic-git"; -import { gitCommit } from "../utils/git_utils"; +import { gitCommit, gitAdd } from "../utils/git_utils"; import { storeDbTimestampAtCurrentVersion } from "../utils/neon_timestamp_utils"; const logger = log.scope("portal_handlers"); @@ -116,11 +114,7 @@ export function registerPortalHandlers() { // Stage all changes and commit try { - await git.add({ - fs, - dir: appPath, - filepath: ".", - }); + await gitAdd({ path: appPath, filepath: "." }); const commitHash = await gitCommit({ path: appPath, diff --git a/src/ipc/handlers/version_handlers.ts b/src/ipc/handlers/version_handlers.ts index dcf1029..a3453de 100644 --- a/src/ipc/handlers/version_handlers.ts +++ b/src/ipc/handlers/version_handlers.ts @@ -7,15 +7,23 @@ import type { 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 git, { type ReadCommitResult } from "isomorphic-git"; import { withLock } from "../utils/lock_utils"; import log from "electron-log"; import { createLoggedHandler } from "./safe_handle"; -import { gitCheckout, gitCommit, gitStageToRevert } from "../utils/git_utils"; + import { deployAllSupabaseFunctions } from "../../supabase_admin/supabase_utils"; +import { + gitCheckout, + gitCommit, + gitStageToRevert, + getCurrentCommitHash, + gitCurrentBranch, + gitLog, +} from "../utils/git_utils"; import { getNeonClient, @@ -80,11 +88,9 @@ export function registerVersionHandlers() { return []; } - const commits = await git.log({ - fs, - dir: appPath, - // KEEP UP TO DATE WITH ChatHeader.tsx - depth: 100_000, // Limit to last 100_000 commits for performance + 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 @@ -104,7 +110,7 @@ export function registerVersionHandlers() { }); } - return commits.map((commit: ReadCommitResult) => { + return commits.map((commit: GitCommit) => { const snapshotInfo = snapshotMap.get(commit.oid); return { oid: commit.oid, @@ -134,11 +140,7 @@ export function registerVersionHandlers() { } try { - const currentBranch = await git.currentBranch({ - fs, - dir: appPath, - fullname: false, - }); + const currentBranch = await gitCurrentBranch({ path: appPath }); return { branch: currentBranch || "", @@ -169,9 +171,8 @@ export function registerVersionHandlers() { const appPath = getDyadAppPath(app.path); // Get the current commit hash before reverting - const currentCommitHash = await git.resolveRef({ - fs, - dir: appPath, + const currentCommitHash = await getCurrentCommitHash({ + path: appPath, ref: "main", }); diff --git a/src/ipc/ipc_types.ts b/src/ipc/ipc_types.ts index 894852d..add5210 100644 --- a/src/ipc/ipc_types.ts +++ b/src/ipc/ipc_types.ts @@ -518,6 +518,7 @@ export interface GithubRepository { full_name: string; private: boolean; } + export type CloneRepoReturnType = | { app: App; diff --git a/src/ipc/processors/response_processor.ts b/src/ipc/processors/response_processor.ts index 11267e3..789c67b 100644 --- a/src/ipc/processors/response_processor.ts +++ b/src/ipc/processors/response_processor.ts @@ -4,7 +4,6 @@ import { and, eq } from "drizzle-orm"; import fs from "node:fs"; import { getDyadAppPath } from "../../paths/paths"; import path from "node:path"; -import git from "isomorphic-git"; import { safeJoin } from "../utils/path_utils"; import log from "electron-log"; @@ -16,7 +15,13 @@ import { } from "../../supabase_admin/supabase_management_client"; import { isServerFunction } from "../../supabase_admin/supabase_utils"; import { UserSettings } from "../../lib/schemas"; -import { gitCommit } from "../utils/git_utils"; +import { + gitCommit, + gitAdd, + gitRemove, + gitAddAll, + getGitUncommittedFiles, +} from "../utils/git_utils"; import { readSettings } from "@/main/settings"; import { writeMigrationFile } from "../utils/file_utils"; import { @@ -265,11 +270,7 @@ export async function processFullResponseActions( // Remove the file from git try { - await git.remove({ - fs, - dir: appPath, - filepath: filePath, - }); + await gitRemove({ path: appPath, filepath: filePath }); } catch (error) { logger.warn(`Failed to git remove deleted file ${filePath}:`, error); // Continue even if remove fails as the file was still deleted @@ -308,17 +309,9 @@ export async function processFullResponseActions( renamedFiles.push(tag.to); // Add the new file and remove the old one from git - await git.add({ - fs, - dir: appPath, - filepath: tag.to, - }); + await gitAdd({ path: appPath, filepath: tag.to }); try { - await git.remove({ - fs, - dir: appPath, - filepath: tag.from, - }); + await gitRemove({ path: appPath, filepath: tag.from }); } catch (error) { logger.warn(`Failed to git remove old file ${tag.from}:`, error); // Continue even if remove fails as the file was still renamed @@ -469,11 +462,7 @@ export async function processFullResponseActions( if (hasChanges) { // Stage all written files for (const file of writtenFiles) { - await git.add({ - fs, - dir: appPath, - filepath: file, - }); + await gitAdd({ path: appPath, filepath: file }); } // Create commit with details of all changes @@ -502,18 +491,11 @@ export async function processFullResponseActions( logger.log(`Successfully committed changes: ${changes.join(", ")}`); // Check for any uncommitted changes after the commit - const statusMatrix = await git.statusMatrix({ fs, dir: appPath }); - uncommittedFiles = statusMatrix - .filter((row) => row[1] !== 1 || row[2] !== 1 || row[3] !== 1) - .map((row) => row[0]); // Get just the file paths + uncommittedFiles = await getGitUncommittedFiles({ path: appPath }); if (uncommittedFiles.length > 0) { // Stage all changes - await git.add({ - fs, - dir: appPath, - filepath: ".", - }); + await gitAddAll({ path: appPath }); try { commitHash = await gitCommit({ path: appPath, diff --git a/src/ipc/utils/git_utils.ts b/src/ipc/utils/git_utils.ts index 729b038..31ba56b 100644 --- a/src/ipc/utils/git_utils.ts +++ b/src/ipc/utils/git_utils.ts @@ -1,42 +1,67 @@ import { getGitAuthor } from "./git_author"; import git from "isomorphic-git"; +import http from "isomorphic-git/http/node"; +import { exec } from "dugite"; 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"; import log from "electron-log"; const logger = log.scope("git_utils"); -const execAsync = promisify(exec); +import type { + GitBaseParams, + GitFileParams, + GitCheckoutParams, + GitBranchRenameParams, + GitCloneParams, + GitCommitParams, + GitLogParams, + GitFileAtCommitParams, + GitSetRemoteUrlParams, + GitStageToRevertParams, + GitInitParams, + GitPushParams, + GitCommit, +} from "../git_types"; -async function verboseExecAsync( - command: string, -): Promise<{ stdout: string; stderr: string }> { - try { - return await execAsync(command); - } catch (error: any) { - let errorMessage = `Error: ${error.message}`; - if (error.stdout) { - errorMessage += `\nStdout: ${error.stdout}`; - } - if (error.stderr) { - errorMessage += `\nStderr: ${error.stderr}`; - } - throw new Error(errorMessage); +/** + * Helper function that wraps exec and throws an error if the exit code is non-zero + */ +async function execOrThrow( + args: string[], + path: string, + errorMessage?: string, +): Promise { + const result = await exec(args, path); + if (result.exitCode !== 0) { + const errorDetails = result.stderr.trim() || result.stdout.trim(); + const error = errorMessage + ? `${errorMessage}. ${errorDetails}` + : `Git command failed: ${args.join(" ")}. ${errorDetails}`; + throw new Error(error); } } export async function getCurrentCommitHash({ path, -}: { - path: string; -}): Promise { - return await git.resolveRef({ - fs, - dir: path, - ref: "HEAD", - }); + ref = "HEAD", +}: GitInitParams): Promise { + const settings = readSettings(); + if (settings.enableNativeGit) { + const result = await exec(["rev-parse", ref], path); + if (result.exitCode !== 0) { + throw new Error( + `Failed to resolve ref '${ref}': ${result.stderr.trim() || result.stdout.trim()}`, + ); + } + return result.stdout.trim(); + } else { + return await git.resolveRef({ + fs, + dir: path, + ref, + }); + } } export async function isGitStatusClean({ @@ -46,8 +71,15 @@ export async function isGitStatusClean({ }): Promise { const settings = readSettings(); if (settings.enableNativeGit) { - const { stdout } = await execAsync(`git -C "${path}" status --porcelain`); - return stdout.trim() === ""; + const result = await exec(["status", "--porcelain"], path); + + if (result.exitCode !== 0) { + throw new Error(`Failed to get status: ${result.stderr}`); + } + + // If output is empty, working directory is clean (no changes) + const isClean = result.stdout.trim().length === 0; + return isClean; } else { const statusMatrix = await git.statusMatrix({ fs, dir: path }); return statusMatrix.every( @@ -60,21 +92,31 @@ export async function gitCommit({ path, message, amend, -}: { - path: string; - message: string; - amend?: boolean; -}): Promise { +}: GitCommitParams): Promise { const settings = readSettings(); if (settings.enableNativeGit) { - let command = `git -C "${path}" commit -m "${message.replace(/"/g, '\\"')}"`; + // Get author info to match isomorphic-git behavior + const author = await getGitAuthor(); + // Perform the commit using dugite with --author flag + const args = [ + "commit", + "-m", + message, + "--author", + `${author.name} <${author.email}>`, + ]; if (amend) { - command += " --amend"; + args.push("--amend"); } - - await verboseExecAsync(command); - const { stdout } = await execAsync(`git -C "${path}" rev-parse HEAD`); - return stdout.trim(); + await execOrThrow(args, path, "Failed to create commit"); + // Get the new commit hash + const result = await exec(["rev-parse", "HEAD"], path); + if (result.exitCode !== 0) { + throw new Error( + `Failed to get commit hash: ${result.stderr.trim() || result.stdout.trim()}`, + ); + } + return result.stdout.trim(); } else { return git.commit({ fs: fs, @@ -89,13 +131,14 @@ export async function gitCommit({ export async function gitCheckout({ path, ref, -}: { - path: string; - ref: string; -}): Promise { +}: GitCheckoutParams): Promise { const settings = readSettings(); if (settings.enableNativeGit) { - await execAsync(`git -C "${path}" checkout "${ref.replace(/"/g, '\\"')}"`); + await execOrThrow( + ["checkout", ref], + path, + `Failed to checkout ref '${ref}'`, + ); return; } else { return git.checkout({ fs, dir: path, ref }); @@ -105,17 +148,18 @@ export async function gitCheckout({ export async function gitStageToRevert({ path, targetOid, -}: { - path: string; - targetOid: string; -}): Promise { +}: GitStageToRevertParams): 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(); + const currentHeadResult = await exec(["rev-parse", "HEAD"], path); + if (currentHeadResult.exitCode !== 0) { + throw new Error( + `Failed to get current commit: ${currentHeadResult.stderr.trim() || currentHeadResult.stdout.trim()}`, + ); + } + + const currentCommit = currentHeadResult.stdout.trim(); // If we're already at the target commit, nothing to do if (currentCommit === targetOid) { @@ -123,20 +167,31 @@ export async function gitStageToRevert({ } // Safety: refuse to run if the work-tree isn't clean. - const { stdout: wtStatus } = await execAsync( - `git -C "${path}" status --porcelain`, - ); - if (wtStatus.trim() !== "") { + const statusResult = await exec(["status", "--porcelain"], path); + if (statusResult.exitCode !== 0) { + throw new Error( + `Failed to get status: ${statusResult.stderr.trim() || statusResult.stdout.trim()}`, + ); + } + if (statusResult.stdout.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}"`); + await execOrThrow( + ["reset", "--hard", targetOid], + path, + `Failed to reset to target commit '${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}"`); + await execOrThrow( + ["reset", "--soft", currentCommit], + path, + "Failed to reset back to original HEAD", + ); } else { // Get status matrix comparing the target commit (previousVersionId as HEAD) with current working directory const matrix = await git.statusMatrix({ @@ -187,32 +242,111 @@ export async function gitStageToRevert({ } } -export async function gitAddAll({ path }: { path: string }): Promise { +export async function gitAddAll({ path }: GitBaseParams): Promise { const settings = readSettings(); if (settings.enableNativeGit) { - await execAsync(`git -C "${path}" add .`); + await execOrThrow(["add", "."], path, "Failed to stage all files"); return; } else { return git.add({ fs, dir: path, filepath: "." }); } } +export async function gitAdd({ path, filepath }: GitFileParams): Promise { + const settings = readSettings(); + if (settings.enableNativeGit) { + await execOrThrow( + ["add", "--", filepath], + path, + `Failed to stage file '${filepath}'`, + ); + } else { + await git.add({ + fs, + dir: path, + filepath, + }); + } +} + +export async function gitInit({ + path, + ref = "main", +}: GitInitParams): Promise { + const settings = readSettings(); + if (settings.enableNativeGit) { + await execOrThrow( + ["init", "-b", ref], + path, + `Failed to initialize git repository with branch '${ref}'`, + ); + } else { + await git.init({ + fs, + dir: path, + defaultBranch: ref, + }); + } +} + +export async function gitRemove({ + path, + filepath, +}: GitFileParams): Promise { + const settings = readSettings(); + if (settings.enableNativeGit) { + await execOrThrow( + ["rm", "-f", "--", filepath], + path, + `Failed to remove file '${filepath}'`, + ); + } else { + await git.remove({ + fs, + dir: path, + filepath, + }); + } +} + +export async function getGitUncommittedFiles({ + path, +}: GitBaseParams): Promise { + const settings = readSettings(); + if (settings.enableNativeGit) { + const result = await exec(["status", "--porcelain"], path); + if (result.exitCode !== 0) { + throw new Error( + `Failed to get uncommitted files: ${result.stderr.trim() || result.stdout.trim()}`, + ); + } + return result.stdout + .toString() + .split("\n") + .filter((line) => line.trim() !== "") + .map((line) => line.slice(3).trim()); + } else { + const statusMatrix = await git.statusMatrix({ fs, dir: path }); + return statusMatrix + .filter((row) => row[1] !== 1 || row[2] !== 1 || row[3] !== 1) + .map((row) => row[0]); + } +} + export async function getFileAtCommit({ path, filePath, commitHash, -}: { - path: string; - filePath: string; - commitHash: string; -}): Promise { +}: GitFileAtCommitParams): Promise { const settings = readSettings(); if (settings.enableNativeGit) { try { - const { stdout } = await execAsync( - `git -C "${path}" show "${commitHash}:${filePath}"`, - ); - return stdout; + const result = await exec(["show", `${commitHash}:${filePath}`], path); + if (result.exitCode !== 0) { + // File doesn't exist at this commit or other error + return null; + } + return result.stdout; } catch (error: any) { logger.error( `Error getting file at commit ${commitHash}: ${error.message}`, @@ -238,3 +372,312 @@ export async function getFileAtCommit({ } } } + +export async function gitListBranches({ + path, +}: GitBaseParams): Promise { + const settings = readSettings(); + + if (settings.enableNativeGit) { + const result = await exec(["branch", "--list"], path); + + if (result.exitCode !== 0) { + throw new Error(result.stderr.toString()); + } + // Parse output: + // e.g. "* main\n feature/login" + return result.stdout + .toString() + .split("\n") + .map((line) => line.replace("*", "").trim()) + .filter((line) => line.length > 0); + } else { + return await git.listBranches({ + fs, + dir: path, + }); + } +} + +export async function gitRenameBranch({ + path, + oldBranch, + newBranch, +}: GitBranchRenameParams): Promise { + const settings = readSettings(); + + if (settings.enableNativeGit) { + // git branch -m oldBranch newBranch + const result = await exec(["branch", "-m", oldBranch, newBranch], path); + if (result.exitCode !== 0) { + throw new Error(result.stderr.toString()); + } + } else { + await git.renameBranch({ + fs, + dir: path, + oldref: oldBranch, + ref: newBranch, + }); + } +} + +export async function gitClone({ + path, + url, + accessToken, + singleBranch = true, + depth, +}: GitCloneParams): Promise { + const settings = readSettings(); + if (settings.enableNativeGit) { + // Dugite version (real Git) + // Build authenticated URL if accessToken is provided and URL doesn't already have auth + const finalUrl = + accessToken && !url.includes("@") + ? url.replace("https://", `https://${accessToken}:x-oauth-basic@`) + : url; + const args = ["clone"]; + if (depth && depth > 0) { + args.push("--depth", String(depth)); + } + if (singleBranch) { + args.push("--single-branch"); + } + args.push(finalUrl, path); + const result = await exec(args, "."); + + if (result.exitCode !== 0) { + throw new Error(result.stderr.toString()); + } + } else { + // isomorphic-git version + // Strip any embedded auth from URL since isomorphic-git uses onAuth + const cleanUrl = url.replace(/https:\/\/[^@]+@/, "https://"); + await git.clone({ + fs, + http, + dir: path, + url: cleanUrl, + onAuth: accessToken + ? () => ({ + username: accessToken, + password: "x-oauth-basic", + }) + : undefined, + singleBranch, + depth: depth ?? undefined, + }); + } +} + +export async function gitSetRemoteUrl({ + path, + remoteUrl, +}: GitSetRemoteUrlParams): Promise { + const settings = readSettings(); + + if (settings.enableNativeGit) { + // Dugite version + try { + // Try to add the remote + const result = await exec(["remote", "add", "origin", remoteUrl], path); + + // If remote already exists, update it instead + if (result.exitCode !== 0 && result.stderr.includes("already exists")) { + const updateResult = await exec( + ["remote", "set-url", "origin", remoteUrl], + path, + ); + + if (updateResult.exitCode !== 0) { + throw new Error(`Failed to update remote: ${updateResult.stderr}`); + } + } else if (result.exitCode !== 0) { + // Handle other errors + throw new Error(`Failed to add remote: ${result.stderr}`); + } + } catch (error: any) { + logger.error("Error setting up remote:", error); + throw error; // or handle as needed + } + } else { + //isomorphic-git version + await git.setConfig({ + fs, + dir: path, + path: "remote.origin.url", + value: remoteUrl, + }); + } +} + +export async function gitPush({ + path, + branch, + accessToken, + force, +}: GitPushParams): Promise { + const settings = readSettings(); + + if (settings.enableNativeGit) { + // Dugite version + try { + // Push using the configured origin remote (which already has auth in URL) + const args = ["push", "origin", `main:${branch}`]; + if (force) { + args.push("--force"); + } + const result = await exec(args, path); + + if (result.exitCode !== 0) { + const errorMsg = result.stderr.toString() || result.stdout.toString(); + throw new Error(`Git push failed: ${errorMsg}`); + } + } catch (error: any) { + logger.error("Error during git push:", error); + throw new Error(`Git push failed: ${error.message}`); + } + } else { + // isomorphic-git version + await git.push({ + fs, + http, + dir: path, + remote: "origin", + ref: "main", + remoteRef: branch, + onAuth: () => ({ + username: accessToken, + password: "x-oauth-basic", + }), + force: !!force, + }); + } +} + +export async function gitCurrentBranch({ + path, +}: GitBaseParams): Promise { + const settings = readSettings(); + if (settings.enableNativeGit) { + // Dugite version + const result = await exec(["branch", "--show-current"], path); + if (result.exitCode !== 0) { + throw new Error( + `Failed to get current branch: ${result.stderr.trim() || result.stdout.trim()}`, + ); + } + const branch = result.stdout.trim() || null; + return branch; + } else { + // isomorphic-git version returns string | undefined + const branch = await git.currentBranch({ + fs, + dir: path, + fullname: false, + }); + return branch ?? null; + } +} + +export async function gitLog({ + path, + depth = 100_000, +}: GitLogParams): Promise { + const settings = readSettings(); + + if (settings.enableNativeGit) { + return await gitLogNative(path, depth); + } else { + // isomorphic-git fallback: this already returns the same structure + return await git.log({ + fs, + dir: path, + depth, + }); + } +} + +export async function gitIsIgnored({ + path, + filepath, +}: GitFileParams): Promise { + const settings = readSettings(); + + if (settings.enableNativeGit) { + // Dugite version + // git check-ignore file + const result = await exec(["check-ignore", filepath], path); + + // If exitCode == 0 → file is ignored + if (result.exitCode === 0) return true; + + // If exitCode == 1 → not ignored + if (result.exitCode === 1) return false; + + // Other exit codes are actual errors + throw new Error(result.stderr.toString()); + } else { + // isomorphic-git version + return await git.isIgnored({ + fs, + dir: path, + filepath, + }); + } +} + +export async function gitLogNative( + path: string, + depth = 100_000, +): Promise { + // Use git log with custom format to get all data in a single process + // Format: %H = commit hash, %at = author timestamp (unix), %B = raw body (message) + // Using null byte as field separator and custom delimiter between commits + const logArgs = [ + "log", + "--max-count", + String(depth), + "--format=%H%x00%at%x00%B%x00---END-COMMIT---", + "HEAD", + ]; + + const logResult = await exec(logArgs, path); + + if (logResult.exitCode !== 0) { + throw new Error(logResult.stderr.toString()); + } + + const output = logResult.stdout.toString().trim(); + if (!output) { + return []; + } + + // Split by commit delimiter (without newline since trim() removes trailing newline) + const commitChunks = output.split("\x00---END-COMMIT---").filter(Boolean); + const entries: GitCommit[] = []; + + for (const chunk of commitChunks) { + // Split by null byte: [oid, timestamp, message] + const parts = chunk.split("\x00"); + if (parts.length >= 3) { + const oid = parts[0].trim(); + const timestamp = Number(parts[1]); + // Message is everything after the second null byte, may contain null bytes itself + const message = parts.slice(2).join("\x00"); + + entries.push({ + oid, + commit: { + message: message, + author: { + timestamp: timestamp, + }, + }, + }); + } + } + + return entries; +} diff --git a/src/ipc/utils/neon_timestamp_utils.ts b/src/ipc/utils/neon_timestamp_utils.ts index b080062..aa0fe4a 100644 --- a/src/ipc/utils/neon_timestamp_utils.ts +++ b/src/ipc/utils/neon_timestamp_utils.ts @@ -1,13 +1,12 @@ import { db } from "../../db"; import { versions, apps } from "../../db/schema"; import { eq, and } from "drizzle-orm"; -import fs from "node:fs"; -import git from "isomorphic-git"; import { getDyadAppPath } from "../../paths/paths"; import { neon } from "@neondatabase/serverless"; import log from "electron-log"; import { getNeonClient } from "@/neon_admin/neon_management_client"; +import { getCurrentCommitHash } from "./git_utils"; const logger = log.scope("neon_timestamp_utils"); @@ -62,11 +61,7 @@ export async function storeDbTimestampAtCurrentVersion({ // 2. Get the current commit hash const appPath = getDyadAppPath(app.path); - const currentCommitHash = await git.resolveRef({ - fs, - dir: appPath, - ref: "HEAD", - }); + const currentCommitHash = await getCurrentCommitHash({ path: appPath }); logger.info(`Current commit hash: ${currentCommitHash}`); diff --git a/src/main.ts b/src/main.ts index 6d2a4f7..50fd15f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -24,6 +24,7 @@ import { AddPromptDataSchema, AddPromptPayload, } from "./ipc/deep_link_data"; +import fs from "fs"; log.errorHandler.startCatching(); log.eventLogger.startLogging(); @@ -42,6 +43,22 @@ if (started) { app.quit(); } +// Decide the git directory depending on environment +function resolveLocalGitDirectory() { + if (!app.isPackaged) { + // Dev: app.getAppPath() is the project root + return path.join(app.getAppPath(), "node_modules/dugite/git"); + } + + // Packaged app: git is bundled via extraResource + return path.join(process.resourcesPath, "git"); +} + +const gitDir = resolveLocalGitDirectory(); +if (fs.existsSync(gitDir)) { + process.env.LOCAL_GIT_DIRECTORY = gitDir; +} + // https://www.electronjs.org/docs/latest/tutorial/launch-app-from-url-in-another-app#main-process-mainjs if (process.defaultApp) { if (process.argv.length >= 2) { diff --git a/src/pages/settings.tsx b/src/pages/settings.tsx index 6cd6c09..743899e 100644 --- a/src/pages/settings.tsx +++ b/src/pages/settings.tsx @@ -163,18 +163,8 @@ export default function SettingsPage() {
- 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 - - . + This doesn't require any external Git installation and offers + a faster, native-Git performance experience.
diff --git a/src/utils/codebase.ts b/src/utils/codebase.ts index 41ae8e3..2f03d26 100644 --- a/src/utils/codebase.ts +++ b/src/utils/codebase.ts @@ -1,7 +1,6 @@ -import fs from "node:fs"; import fsAsync from "node:fs/promises"; import path from "node:path"; -import { isIgnored } from "isomorphic-git"; +import { gitIsIgnored } from "../ipc/utils/git_utils"; import log from "electron-log"; import { IS_TEST_BUILD } from "../ipc/utils/test_utils"; import { glob } from "glob"; @@ -176,9 +175,8 @@ async function isGitIgnored( } const relativePath = path.relative(baseDir, filePath); - const result = await isIgnored({ - fs, - dir: baseDir, + const result = await gitIsIgnored({ + path: baseDir, filepath: relativePath, });