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>
This commit is contained in:
Adeniji Adekunle James
2025-12-10 03:01:25 +00:00
committed by GitHub
parent a7bcec220a
commit d3f3ac3ae1
19 changed files with 817 additions and 300 deletions

View File

@@ -74,6 +74,7 @@ const config: ForgeConfig = {
}, },
asar: true, asar: true,
ignore, ignore,
extraResource: ["node_modules/dugite/git"],
// ignore: [/node_modules\/(?!(better-sqlite3|bindings|file-uri-to-path)\/)/], // ignore: [/node_modules\/(?!(better-sqlite3|bindings|file-uri-to-path)\/)/],
}, },
rebuildConfig: { rebuildConfig: {

94
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "dyad", "name": "dyad",
"version": "0.28.0", "version": "0.29.0-beta.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "dyad", "name": "dyad",
"version": "0.28.0", "version": "0.29.0-beta.1",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@ai-sdk/amazon-bedrock": "^3.0.15", "@ai-sdk/amazon-bedrock": "^3.0.15",
@@ -59,6 +59,7 @@
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"drizzle-orm": "^0.41.0", "drizzle-orm": "^0.41.0",
"dugite": "^3.0.0",
"electron-log": "^5.3.3", "electron-log": "^5.3.3",
"electron-playwright-helpers": "^1.7.1", "electron-playwright-helpers": "^1.7.1",
"electron-squirrel-startup": "^1.0.1", "electron-squirrel-startup": "^1.0.1",
@@ -8090,6 +8091,20 @@
"proxy-from-env": "^1.1.0" "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": { "node_modules/bail": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz",
@@ -8106,6 +8121,20 @@
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"license": "MIT" "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": { "node_modules/base64-js": {
"version": "1.5.1", "version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "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": { "node_modules/dunder-proto": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -11584,6 +11638,15 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/eventsource": {
"version": "3.0.7", "version": "3.0.7",
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz",
@@ -11808,6 +11871,12 @@
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"license": "MIT" "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": { "node_modules/fast-glob": {
"version": "3.3.3", "version": "3.3.3",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
@@ -17970,7 +18039,6 @@
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
"integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=0.4.0" "node": ">=0.4.0"
@@ -19985,6 +20053,17 @@
"node": ">= 0.4" "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": { "node_modules/string_decoder": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
@@ -20524,6 +20603,15 @@
"rimraf": "bin.js" "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": { "node_modules/text-table": {
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",

View File

@@ -135,6 +135,7 @@
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"drizzle-orm": "^0.41.0", "drizzle-orm": "^0.41.0",
"dugite": "^3.0.0",
"electron-log": "^5.3.3", "electron-log": "^5.3.3",
"electron-playwright-helpers": "^1.7.1", "electron-playwright-helpers": "^1.7.1",
"electron-squirrel-startup": "^1.0.1", "electron-squirrel-startup": "^1.0.1",

View File

@@ -13,9 +13,9 @@ import {
hasUnclosedDyadWrite, hasUnclosedDyadWrite,
} from "../ipc/handlers/chat_stream_handlers"; } from "../ipc/handlers/chat_stream_handlers";
import fs from "node:fs"; import fs from "node:fs";
import git from "isomorphic-git";
import { db } from "../db"; 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 // Mock fs with default export
vi.mock("node:fs", async () => { vi.mock("node:fs", async () => {
@@ -43,14 +43,19 @@ vi.mock("node:fs", async () => {
}; };
}); });
// Mock isomorphic-git // Mock Git utils
vi.mock("isomorphic-git", () => ({ vi.mock("../ipc/utils/git_utils", () => ({
default: { gitAdd: vi.fn(),
add: vi.fn().mockResolvedValue(undefined), gitCommit: vi.fn(),
remove: vi.fn().mockResolvedValue(undefined), gitRemove: vi.fn(),
commit: vi.fn().mockResolvedValue(undefined), gitRenameBranch: vi.fn(),
statusMatrix: vi.fn().mockResolvedValue([]), 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 // Mock paths module to control getDyadAppPath
@@ -703,12 +708,12 @@ describe("processFullResponse", () => {
"/mock/user/data/path/mock-app-path/src/file1.js", "/mock/user/data/path/mock-app-path/src/file1.js",
"console.log('Hello');", "console.log('Hello');",
); );
expect(git.add).toHaveBeenCalledWith( expect(gitAdd).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
filepath: "src/file1.js", filepath: "src/file1.js",
}), }),
); );
expect(git.commit).toHaveBeenCalled(); expect(gitCommit).toHaveBeenCalled();
expect(result).toEqual({ updatedFiles: true }); expect(result).toEqual({ updatedFiles: true });
}); });
@@ -783,24 +788,24 @@ describe("processFullResponse", () => {
); );
// Verify git operations were called for each file // Verify git operations were called for each file
expect(git.add).toHaveBeenCalledWith( expect(gitAdd).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
filepath: "src/file1.js", filepath: "src/file1.js",
}), }),
); );
expect(git.add).toHaveBeenCalledWith( expect(gitAdd).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
filepath: "src/utils/file2.js", filepath: "src/utils/file2.js",
}), }),
); );
expect(git.add).toHaveBeenCalledWith( expect(gitAdd).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
filepath: "src/components/Button.tsx", filepath: "src/components/Button.tsx",
}), }),
); );
// Verify commit was called once after all files were added // Verify commit was called once after all files were added
expect(git.commit).toHaveBeenCalledTimes(1); expect(gitCommit).toHaveBeenCalledTimes(1);
expect(result).toEqual({ updatedFiles: true }); 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/OldComponent.jsx",
"/mock/user/data/path/mock-app-path/src/components/NewComponent.jsx", "/mock/user/data/path/mock-app-path/src/components/NewComponent.jsx",
); );
expect(git.add).toHaveBeenCalledWith( expect(gitAdd).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
filepath: "src/components/NewComponent.jsx", filepath: "src/components/NewComponent.jsx",
}), }),
); );
expect(git.remove).toHaveBeenCalledWith( expect(gitRemove).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
filepath: "src/components/OldComponent.jsx", filepath: "src/components/OldComponent.jsx",
}), }),
); );
expect(git.commit).toHaveBeenCalled(); expect(gitCommit).toHaveBeenCalled();
expect(result).toEqual({ updatedFiles: true }); expect(result).toEqual({ updatedFiles: true });
}); });
@@ -852,7 +857,7 @@ describe("processFullResponse", () => {
expect(fs.mkdirSync).toHaveBeenCalled(); expect(fs.mkdirSync).toHaveBeenCalled();
expect(fs.renameSync).not.toHaveBeenCalled(); expect(fs.renameSync).not.toHaveBeenCalled();
expect(git.commit).not.toHaveBeenCalled(); expect(gitCommit).not.toHaveBeenCalled();
expect(result).toEqual({ expect(result).toEqual({
updatedFiles: false, updatedFiles: false,
extraFiles: undefined, extraFiles: undefined,
@@ -875,12 +880,12 @@ describe("processFullResponse", () => {
expect(fs.unlinkSync).toHaveBeenCalledWith( expect(fs.unlinkSync).toHaveBeenCalledWith(
"/mock/user/data/path/mock-app-path/src/components/Unused.jsx", "/mock/user/data/path/mock-app-path/src/components/Unused.jsx",
); );
expect(git.remove).toHaveBeenCalledWith( expect(gitRemove).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
filepath: "src/components/Unused.jsx", filepath: "src/components/Unused.jsx",
}), }),
); );
expect(git.commit).toHaveBeenCalled(); expect(gitCommit).toHaveBeenCalled();
expect(result).toEqual({ updatedFiles: true }); expect(result).toEqual({ updatedFiles: true });
}); });
@@ -896,8 +901,8 @@ describe("processFullResponse", () => {
}); });
expect(fs.unlinkSync).not.toHaveBeenCalled(); expect(fs.unlinkSync).not.toHaveBeenCalled();
expect(git.remove).not.toHaveBeenCalled(); expect(gitRemove).not.toHaveBeenCalled();
expect(git.commit).not.toHaveBeenCalled(); expect(gitCommit).not.toHaveBeenCalled();
expect(result).toEqual({ expect(result).toEqual({
updatedFiles: false, updatedFiles: false,
extraFiles: undefined, extraFiles: undefined,
@@ -942,11 +947,11 @@ describe("processFullResponse", () => {
); );
// Check git operations // Check git operations
expect(git.add).toHaveBeenCalledTimes(2); // For the write and rename expect(gitAdd).toHaveBeenCalledTimes(2); // For the write and rename
expect(git.remove).toHaveBeenCalledTimes(2); // For the rename and delete expect(gitRemove).toHaveBeenCalledTimes(2); // For the rename and delete
// Check the commit message includes all operations // Check the commit message includes all operations
expect(git.commit).toHaveBeenCalledWith( expect(gitCommit).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
message: expect.stringContaining( message: expect.stringContaining(
"wrote 1 file(s), renamed 1 file(s), deleted 1 file(s)", "wrote 1 file(s), renamed 1 file(s), deleted 1 file(s)",

60
src/ipc/git_types.ts Normal file
View File

@@ -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;
}

View File

@@ -14,7 +14,6 @@ import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import { getDyadAppPath, getUserDataPath } from "../../paths/paths"; import { getDyadAppPath, getUserDataPath } from "../../paths/paths";
import { ChildProcess, spawn } from "node:child_process"; import { ChildProcess, spawn } from "node:child_process";
import git from "isomorphic-git";
import { promises as fsPromises } from "node:fs"; import { promises as fsPromises } from "node:fs";
// Import our utility modules // Import our utility modules
@@ -44,7 +43,13 @@ import { getLanguageModelProviders } from "../shared/language_model_helpers";
import { startProxy } from "../utils/start_proxy_server"; import { startProxy } from "../utils/start_proxy_server";
import { Worker } from "worker_threads"; import { Worker } from "worker_threads";
import { createFromTemplate } from "./createFromTemplate"; 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 { safeSend } from "../utils/safe_sender";
import { normalizePath } from "../../../shared/normalizePath"; import { normalizePath } from "../../../shared/normalizePath";
import { isServerFunction } from "@/supabase_admin/supabase_utils"; import { isServerFunction } from "@/supabase_admin/supabase_utils";
@@ -585,18 +590,11 @@ export function registerAppHandlers() {
}); });
// Initialize git repo and create first commit // Initialize git repo and create first commit
await git.init({
fs: fs, await gitInit({ path: fullAppPath, ref: "main" });
dir: fullAppPath,
defaultBranch: "main",
});
// Stage all files // Stage all files
await git.add({ await gitAdd({ path: fullAppPath, filepath: "." });
fs: fs,
dir: fullAppPath,
filepath: ".",
});
// Create initial commit // Create initial commit
const commitHash = await gitCommit({ const commitHash = await gitCommit({
@@ -657,18 +655,10 @@ export function registerAppHandlers() {
if (!withHistory) { if (!withHistory) {
// Initialize git repo and create first commit // Initialize git repo and create first commit
await git.init({ await gitInit({ path: newAppPath, ref: "main" });
fs: fs,
dir: newAppPath,
defaultBranch: "main",
});
// Stage all files // Stage all files
await git.add({ await gitAdd({ path: newAppPath, filepath: "." });
fs: fs,
dir: newAppPath,
filepath: ".",
});
// Create initial commit // Create initial commit
await gitCommit({ await gitCommit({
@@ -1049,11 +1039,7 @@ export function registerAppHandlers() {
// Check if git repository exists and commit the change // Check if git repository exists and commit the change
if (fs.existsSync(path.join(appPath, ".git"))) { if (fs.existsSync(path.join(appPath, ".git"))) {
await git.add({ await gitAdd({ path: appPath, filepath: filePath });
fs,
dir: appPath,
filepath: filePath,
});
await gitCommit({ await gitCommit({
path: appPath, path: appPath,
@@ -1398,7 +1384,7 @@ export function registerAppHandlers() {
return withLock(appId, async () => { return withLock(appId, async () => {
try { try {
// Check if the old branch exists // Check if the old branch exists
const branches = await git.listBranches({ fs, dir: appPath }); const branches = await gitListBranches({ path: appPath });
if (!branches.includes(oldBranchName)) { if (!branches.includes(oldBranchName)) {
throw new Error(`Branch '${oldBranchName}' not found.`); throw new Error(`Branch '${oldBranchName}' not found.`);
} }
@@ -1414,11 +1400,10 @@ export function registerAppHandlers() {
); );
} }
await git.renameBranch({ await gitRenameBranch({
fs: fs, path: appPath,
dir: appPath, oldBranch: oldBranchName,
oldref: oldBranchName, newBranch: newBranchName,
ref: newBranchName,
}); });
logger.info( logger.info(
`Branch renamed from '${oldBranchName}' to '${newBranchName}' for app ${appId}`, `Branch renamed from '${oldBranchName}' to '${newBranchName}' for app ${appId}`,

View File

@@ -3,13 +3,12 @@ import { db } from "../../db";
import { apps, chats, messages } from "../../db/schema"; import { apps, chats, messages } from "../../db/schema";
import { desc, eq, and, like } from "drizzle-orm"; import { desc, eq, and, like } from "drizzle-orm";
import type { ChatSearchResult, ChatSummary } from "../../lib/schemas"; import type { ChatSearchResult, ChatSummary } from "../../lib/schemas";
import * as git from "isomorphic-git";
import * as fs from "fs";
import { createLoggedHandler } from "./safe_handle"; import { createLoggedHandler } from "./safe_handle";
import log from "electron-log"; import log from "electron-log";
import { getDyadAppPath } from "../../paths/paths"; import { getDyadAppPath } from "../../paths/paths";
import { UpdateChatParams } from "../ipc_types"; import { UpdateChatParams } from "../ipc_types";
import { getCurrentCommitHash } from "../utils/git_utils";
const logger = log.scope("chat_handlers"); const logger = log.scope("chat_handlers");
const handle = createLoggedHandler(logger); const handle = createLoggedHandler(logger);
@@ -31,9 +30,8 @@ export function registerChatHandlers() {
let initialCommitHash = null; let initialCommitHash = null;
try { try {
// Get the current git revision of main branch // Get the current git revision of main branch
initialCommitHash = await git.resolveRef({ initialCommitHash = await getCurrentCommitHash({
fs, path: getDyadAppPath(app.path),
dir: getDyadAppPath(app.path),
ref: "main", ref: "main",
}); });
} catch (error) { } catch (error) {

View File

@@ -1,9 +1,8 @@
import path from "path"; import path from "path";
import fs from "fs-extra"; import fs from "fs-extra";
import git from "isomorphic-git";
import http from "isomorphic-git/http/node";
import { app } from "electron"; import { app } from "electron";
import { copyDirectoryRecursive } from "../utils/file_utils"; import { copyDirectoryRecursive } from "../utils/file_utils";
import { gitClone, getCurrentCommitHash } from "../utils/git_utils";
import { readSettings } from "@/main/settings"; import { readSettings } from "@/main/settings";
import { getTemplateOrThrow } from "../utils/template_utils"; import { getTemplateOrThrow } from "../utils/template_utils";
import log from "electron-log"; import log from "electron-log";
@@ -35,9 +34,6 @@ export async function createFromTemplate({
} }
async function cloneRepo(repoUrl: string): Promise<string> { async function cloneRepo(repoUrl: string): Promise<string> {
let orgName: string;
let repoName: string;
const url = new URL(repoUrl); const url = new URL(repoUrl);
if (url.protocol !== "https:") { if (url.protocol !== "https:") {
throw new Error("Repository URL must use HTTPS."); throw new Error("Repository URL must use HTTPS.");
@@ -55,8 +51,8 @@ async function cloneRepo(repoUrl: string): Promise<string> {
); );
} }
orgName = pathParts[0]; const orgName = pathParts[0];
repoName = path.basename(pathParts[1], ".git"); // Remove .git suffix if present const repoName = path.basename(pathParts[1], ".git"); // Remove .git suffix if present
if (!orgName || !repoName) { if (!orgName || !repoName) {
// This case should ideally be caught by pathParts.length !== 2 // This case should ideally be caught by pathParts.length !== 2
@@ -83,41 +79,31 @@ async function cloneRepo(repoUrl: string): Promise<string> {
const apiUrl = `https://api.github.com/repos/${orgName}/${repoName}/commits/HEAD`; const apiUrl = `https://api.github.com/repos/${orgName}/${repoName}/commits/HEAD`;
logger.info(`Fetching remote SHA from ${apiUrl}`); logger.info(`Fetching remote SHA from ${apiUrl}`);
let remoteSha: string | undefined; // Use native fetch instead of isomorphic-git http.request
const response = await fetch(apiUrl, {
const response = await http.request({
url: apiUrl,
method: "GET", method: "GET",
headers: { headers: {
"User-Agent": "Dyad", // GitHub API requires a User-Agent "User-Agent": "Dyad", // GitHub API requires this
Accept: "application/vnd.github.v3+json", Accept: "application/vnd.github.v3+json",
}, },
}); });
// Handle non-200 responses
if (response.statusCode === 200 && response.body) { if (!response.ok) {
// Convert AsyncIterableIterator<Uint8Array> to string throw new Error(
const chunks: Uint8Array[] = []; `GitHub API request failed with status ${response.status}: ${response.statusText}`,
for await (const chunk of response.body) { );
chunks.push(chunk);
} }
const responseBodyStr = Buffer.concat(chunks).toString("utf8"); // Parse JSON directly (fetch handles streaming internally)
const commitData = JSON.parse(responseBodyStr); const commitData = await response.json();
remoteSha = commitData.sha; const remoteSha = commitData.sha;
if (!remoteSha) { if (!remoteSha) {
throw new Error("SHA not found in GitHub API response."); throw new Error("SHA not found in GitHub API response.");
} }
logger.info(`Successfully fetched remote SHA: ${remoteSha}`);
} else {
throw new Error(
`GitHub API request failed with status ${response.statusCode}: ${response.statusMessage}`,
);
}
const localSha = await git.resolveRef({ logger.info(`Successfully fetched remote SHA: ${remoteSha}`);
fs,
dir: cachePath, // Compare with local SHA
ref: "HEAD", const localSha = await getCurrentCommitHash({ path: cachePath });
});
if (remoteSha === localSha) { if (remoteSha === localSha) {
logger.info( logger.info(
@@ -129,7 +115,7 @@ async function cloneRepo(repoUrl: string): Promise<string> {
`Local cache for ${repoName} (SHA: ${localSha}) is outdated (Remote SHA: ${remoteSha}). Removing and re-cloning.`, `Local cache for ${repoName} (SHA: ${localSha}) is outdated (Remote SHA: ${remoteSha}). Removing and re-cloning.`,
); );
fs.rmSync(cachePath, { recursive: true, force: true }); fs.rmSync(cachePath, { recursive: true, force: true });
// Proceed to clone // Continue to clone
} }
} catch (err) { } catch (err) {
logger.warn( logger.warn(
@@ -144,14 +130,7 @@ async function cloneRepo(repoUrl: string): Promise<string> {
logger.info(`Cloning ${repoUrl} to ${cachePath}`); logger.info(`Cloning ${repoUrl} to ${cachePath}`);
try { try {
await git.clone({ await gitClone({ path: cachePath, url: repoUrl, depth: 1 });
fs,
http,
dir: cachePath,
url: repoUrl,
singleBranch: true,
depth: 1,
});
logger.info(`Successfully cloned ${repoUrl} to ${cachePath}`); logger.info(`Successfully cloned ${repoUrl} to ${cachePath}`);
} catch (err) { } catch (err) {
logger.error(`Failed to clone ${repoUrl} to ${cachePath}: `, err); logger.error(`Failed to clone ${repoUrl} to ${cachePath}: `, err);

View File

@@ -1,8 +1,7 @@
import { ipcMain, BrowserWindow, IpcMainInvokeEvent } from "electron"; import { ipcMain, BrowserWindow, IpcMainInvokeEvent } from "electron";
import fetch from "node-fetch"; // Use node-fetch for making HTTP requests in main process import fetch from "node-fetch"; // Use node-fetch for making HTTP requests in main process
import { writeSettings, readSettings } from "../../main/settings"; import { writeSettings, readSettings } from "../../main/settings";
import git, { clone } from "isomorphic-git"; import { gitSetRemoteUrl, gitPush, gitClone } from "../utils/git_utils";
import http from "isomorphic-git/http/node";
import * as schema from "../../db/schema"; import * as schema from "../../db/schema";
import fs from "node:fs"; import fs from "node:fs";
import { getDyadAppPath } from "../../paths/paths"; import { getDyadAppPath } from "../../paths/paths";
@@ -575,25 +574,17 @@ async function handlePushToGithub(
? `${GITHUB_GIT_BASE}/${app.githubOrg}/${app.githubRepo}.git` ? `${GITHUB_GIT_BASE}/${app.githubOrg}/${app.githubRepo}.git`
: `https://${accessToken}:x-oauth-basic@github.com/${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 // Set or update remote URL using git config
await git.setConfig({ await gitSetRemoteUrl({
fs, path: appPath,
dir: appPath, remoteUrl,
path: "remote.origin.url",
value: remoteUrl,
}); });
// Push to GitHub // Push to GitHub
await git.push({ await gitPush({
fs, path: appPath,
http, branch,
dir: appPath, accessToken,
remote: "origin", force,
ref: "main",
remoteRef: branch,
onAuth: () => ({
username: accessToken,
password: "x-oauth-basic",
}),
force: !!force,
}); });
return { success: true }; return { success: true };
} catch (err: any) { } catch (err: any) {
@@ -673,9 +664,12 @@ async function handleCloneRepoFromUrl(
} }
const appPath = getDyadAppPath(finalAppName); const appPath = getDyadAppPath(finalAppName);
// Ensure the app directory exists if native git is disabled
if (!settings.enableNativeGit) {
if (!fs.existsSync(appPath)) { if (!fs.existsSync(appPath)) {
fs.mkdirSync(appPath, { recursive: true }); fs.mkdirSync(appPath, { recursive: true });
} }
}
// Use authenticated URL if token exists, otherwise use public HTTPS URL // Use authenticated URL if token exists, otherwise use public HTTPS URL
const cloneUrl = accessToken const cloneUrl = accessToken
? IS_TEST_BUILD ? IS_TEST_BUILD
@@ -683,17 +677,10 @@ async function handleCloneRepoFromUrl(
: `https://${accessToken}:x-oauth-basic@github.com/${owner}/${repoName}.git` : `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 : `https://github.com/${owner}/${repoName}.git`; // Changed: use public HTTPS URL instead of original url
try { try {
await clone({ await gitClone({
fs, path: appPath,
http,
dir: appPath,
url: cloneUrl, url: cloneUrl,
onAuth: accessToken accessToken,
? () => ({
username: accessToken,
password: "x-oauth-basic",
})
: undefined,
singleBranch: false, singleBranch: false,
}); });
} catch (cloneErr) { } catch (cloneErr) {

View File

@@ -8,11 +8,10 @@ import { apps } from "@/db/schema";
import { db } from "@/db"; import { db } from "@/db";
import { chats } from "@/db/schema"; import { chats } from "@/db/schema";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import git from "isomorphic-git";
import { ImportAppParams, ImportAppResult } from "../ipc_types"; import { ImportAppParams, ImportAppResult } from "../ipc_types";
import { copyDirectoryRecursive } from "../utils/file_utils"; 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 logger = log.scope("import-handlers");
const handle = createLoggedHandler(logger); const handle = createLoggedHandler(logger);
@@ -106,18 +105,11 @@ export function registerImportHandlers() {
.catch(() => false); .catch(() => false);
if (!isGitRepo) { if (!isGitRepo) {
// Initialize git repo and create first commit // Initialize git repo and create first commit
await git.init({ await gitInit({ path: destPath, ref: "main" });
fs: fs,
dir: destPath,
defaultBranch: "main",
});
// Stage all files // Stage all files
await git.add({
fs: fs, await gitAdd({ path: destPath, filepath: "." });
dir: destPath,
filepath: ".",
});
// Create initial commit // Create initial commit
await gitCommit({ await gitCommit({

View File

@@ -5,9 +5,7 @@ import { apps } from "../../db/schema";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { getDyadAppPath } from "../../paths/paths"; import { getDyadAppPath } from "../../paths/paths";
import { spawn } from "child_process"; import { spawn } from "child_process";
import fs from "node:fs"; import { gitCommit, gitAdd } from "../utils/git_utils";
import git from "isomorphic-git";
import { gitCommit } from "../utils/git_utils";
import { storeDbTimestampAtCurrentVersion } from "../utils/neon_timestamp_utils"; import { storeDbTimestampAtCurrentVersion } from "../utils/neon_timestamp_utils";
const logger = log.scope("portal_handlers"); const logger = log.scope("portal_handlers");
@@ -116,11 +114,7 @@ export function registerPortalHandlers() {
// Stage all changes and commit // Stage all changes and commit
try { try {
await git.add({ await gitAdd({ path: appPath, filepath: "." });
fs,
dir: appPath,
filepath: ".",
});
const commitHash = await gitCommit({ const commitHash = await gitCommit({
path: appPath, path: appPath,

View File

@@ -7,15 +7,23 @@ import type {
RevertVersionParams, RevertVersionParams,
RevertVersionResponse, RevertVersionResponse,
} from "../ipc_types"; } from "../ipc_types";
import type { GitCommit } from "../git_types";
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import { getDyadAppPath } from "../../paths/paths"; import { getDyadAppPath } from "../../paths/paths";
import git, { type ReadCommitResult } from "isomorphic-git";
import { withLock } from "../utils/lock_utils"; import { withLock } from "../utils/lock_utils";
import log from "electron-log"; import log from "electron-log";
import { createLoggedHandler } from "./safe_handle"; import { createLoggedHandler } from "./safe_handle";
import { gitCheckout, gitCommit, gitStageToRevert } from "../utils/git_utils";
import { deployAllSupabaseFunctions } from "../../supabase_admin/supabase_utils"; import { deployAllSupabaseFunctions } from "../../supabase_admin/supabase_utils";
import {
gitCheckout,
gitCommit,
gitStageToRevert,
getCurrentCommitHash,
gitCurrentBranch,
gitLog,
} from "../utils/git_utils";
import { import {
getNeonClient, getNeonClient,
@@ -80,11 +88,9 @@ export function registerVersionHandlers() {
return []; return [];
} }
const commits = await git.log({ const commits = await gitLog({
fs, path: appPath,
dir: appPath, depth: 100_000, // KEEP UP TO DATE WITH ChatHeader.tsx
// KEEP UP TO DATE WITH ChatHeader.tsx
depth: 100_000, // Limit to last 100_000 commits for performance
}); });
// Get all snapshots for this app to match with commits // 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); const snapshotInfo = snapshotMap.get(commit.oid);
return { return {
oid: commit.oid, oid: commit.oid,
@@ -134,11 +140,7 @@ export function registerVersionHandlers() {
} }
try { try {
const currentBranch = await git.currentBranch({ const currentBranch = await gitCurrentBranch({ path: appPath });
fs,
dir: appPath,
fullname: false,
});
return { return {
branch: currentBranch || "<no-branch>", branch: currentBranch || "<no-branch>",
@@ -169,9 +171,8 @@ export function registerVersionHandlers() {
const appPath = getDyadAppPath(app.path); const appPath = getDyadAppPath(app.path);
// Get the current commit hash before reverting // Get the current commit hash before reverting
const currentCommitHash = await git.resolveRef({ const currentCommitHash = await getCurrentCommitHash({
fs, path: appPath,
dir: appPath,
ref: "main", ref: "main",
}); });

View File

@@ -518,6 +518,7 @@ export interface GithubRepository {
full_name: string; full_name: string;
private: boolean; private: boolean;
} }
export type CloneRepoReturnType = export type CloneRepoReturnType =
| { | {
app: App; app: App;

View File

@@ -4,7 +4,6 @@ import { and, eq } from "drizzle-orm";
import fs from "node:fs"; import fs from "node:fs";
import { getDyadAppPath } from "../../paths/paths"; import { getDyadAppPath } from "../../paths/paths";
import path from "node:path"; import path from "node:path";
import git from "isomorphic-git";
import { safeJoin } from "../utils/path_utils"; import { safeJoin } from "../utils/path_utils";
import log from "electron-log"; import log from "electron-log";
@@ -16,7 +15,13 @@ import {
} from "../../supabase_admin/supabase_management_client"; } from "../../supabase_admin/supabase_management_client";
import { isServerFunction } from "../../supabase_admin/supabase_utils"; import { isServerFunction } from "../../supabase_admin/supabase_utils";
import { UserSettings } from "../../lib/schemas"; 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 { readSettings } from "@/main/settings";
import { writeMigrationFile } from "../utils/file_utils"; import { writeMigrationFile } from "../utils/file_utils";
import { import {
@@ -265,11 +270,7 @@ export async function processFullResponseActions(
// Remove the file from git // Remove the file from git
try { try {
await git.remove({ await gitRemove({ path: appPath, filepath: filePath });
fs,
dir: appPath,
filepath: filePath,
});
} catch (error) { } catch (error) {
logger.warn(`Failed to git remove deleted file ${filePath}:`, error); logger.warn(`Failed to git remove deleted file ${filePath}:`, error);
// Continue even if remove fails as the file was still deleted // Continue even if remove fails as the file was still deleted
@@ -308,17 +309,9 @@ export async function processFullResponseActions(
renamedFiles.push(tag.to); renamedFiles.push(tag.to);
// Add the new file and remove the old one from git // Add the new file and remove the old one from git
await git.add({ await gitAdd({ path: appPath, filepath: tag.to });
fs,
dir: appPath,
filepath: tag.to,
});
try { try {
await git.remove({ await gitRemove({ path: appPath, filepath: tag.from });
fs,
dir: appPath,
filepath: tag.from,
});
} catch (error) { } catch (error) {
logger.warn(`Failed to git remove old file ${tag.from}:`, error); logger.warn(`Failed to git remove old file ${tag.from}:`, error);
// Continue even if remove fails as the file was still renamed // Continue even if remove fails as the file was still renamed
@@ -469,11 +462,7 @@ export async function processFullResponseActions(
if (hasChanges) { if (hasChanges) {
// Stage all written files // Stage all written files
for (const file of writtenFiles) { for (const file of writtenFiles) {
await git.add({ await gitAdd({ path: appPath, filepath: file });
fs,
dir: appPath,
filepath: file,
});
} }
// Create commit with details of all changes // Create commit with details of all changes
@@ -502,18 +491,11 @@ export async function processFullResponseActions(
logger.log(`Successfully committed changes: ${changes.join(", ")}`); logger.log(`Successfully committed changes: ${changes.join(", ")}`);
// Check for any uncommitted changes after the commit // Check for any uncommitted changes after the commit
const statusMatrix = await git.statusMatrix({ fs, dir: appPath }); uncommittedFiles = await getGitUncommittedFiles({ path: appPath });
uncommittedFiles = statusMatrix
.filter((row) => row[1] !== 1 || row[2] !== 1 || row[3] !== 1)
.map((row) => row[0]); // Get just the file paths
if (uncommittedFiles.length > 0) { if (uncommittedFiles.length > 0) {
// Stage all changes // Stage all changes
await git.add({ await gitAddAll({ path: appPath });
fs,
dir: appPath,
filepath: ".",
});
try { try {
commitHash = await gitCommit({ commitHash = await gitCommit({
path: appPath, path: appPath,

View File

@@ -1,42 +1,67 @@
import { getGitAuthor } from "./git_author"; import { getGitAuthor } from "./git_author";
import git from "isomorphic-git"; import git from "isomorphic-git";
import http from "isomorphic-git/http/node";
import { exec } from "dugite";
import fs from "node:fs"; import fs from "node:fs";
import { promises as fsPromises } from "node:fs"; import { promises as fsPromises } from "node:fs";
import pathModule from "node:path"; import pathModule from "node:path";
import { exec } from "node:child_process";
import { promisify } from "node:util";
import { readSettings } from "../../main/settings"; import { readSettings } from "../../main/settings";
import log from "electron-log"; import log from "electron-log";
const logger = log.scope("git_utils"); 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, * Helper function that wraps exec and throws an error if the exit code is non-zero
): Promise<{ stdout: string; stderr: string }> { */
try { async function execOrThrow(
return await execAsync(command); args: string[],
} catch (error: any) { path: string,
let errorMessage = `Error: ${error.message}`; errorMessage?: string,
if (error.stdout) { ): Promise<void> {
errorMessage += `\nStdout: ${error.stdout}`; const result = await exec(args, path);
} if (result.exitCode !== 0) {
if (error.stderr) { const errorDetails = result.stderr.trim() || result.stdout.trim();
errorMessage += `\nStderr: ${error.stderr}`; const error = errorMessage
} ? `${errorMessage}. ${errorDetails}`
throw new Error(errorMessage); : `Git command failed: ${args.join(" ")}. ${errorDetails}`;
throw new Error(error);
} }
} }
export async function getCurrentCommitHash({ export async function getCurrentCommitHash({
path, path,
}: { ref = "HEAD",
path: string; }: GitInitParams): Promise<string> {
}): Promise<string> { 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({ return await git.resolveRef({
fs, fs,
dir: path, dir: path,
ref: "HEAD", ref,
}); });
}
} }
export async function isGitStatusClean({ export async function isGitStatusClean({
@@ -46,8 +71,15 @@ export async function isGitStatusClean({
}): Promise<boolean> { }): Promise<boolean> {
const settings = readSettings(); const settings = readSettings();
if (settings.enableNativeGit) { if (settings.enableNativeGit) {
const { stdout } = await execAsync(`git -C "${path}" status --porcelain`); const result = await exec(["status", "--porcelain"], path);
return stdout.trim() === "";
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 { } else {
const statusMatrix = await git.statusMatrix({ fs, dir: path }); const statusMatrix = await git.statusMatrix({ fs, dir: path });
return statusMatrix.every( return statusMatrix.every(
@@ -60,21 +92,31 @@ export async function gitCommit({
path, path,
message, message,
amend, amend,
}: { }: GitCommitParams): Promise<string> {
path: string;
message: string;
amend?: boolean;
}): Promise<string> {
const settings = readSettings(); const settings = readSettings();
if (settings.enableNativeGit) { 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) { if (amend) {
command += " --amend"; args.push("--amend");
} }
await execOrThrow(args, path, "Failed to create commit");
await verboseExecAsync(command); // Get the new commit hash
const { stdout } = await execAsync(`git -C "${path}" rev-parse HEAD`); const result = await exec(["rev-parse", "HEAD"], path);
return stdout.trim(); if (result.exitCode !== 0) {
throw new Error(
`Failed to get commit hash: ${result.stderr.trim() || result.stdout.trim()}`,
);
}
return result.stdout.trim();
} else { } else {
return git.commit({ return git.commit({
fs: fs, fs: fs,
@@ -89,13 +131,14 @@ export async function gitCommit({
export async function gitCheckout({ export async function gitCheckout({
path, path,
ref, ref,
}: { }: GitCheckoutParams): Promise<void> {
path: string;
ref: string;
}): Promise<void> {
const settings = readSettings(); const settings = readSettings();
if (settings.enableNativeGit) { if (settings.enableNativeGit) {
await execAsync(`git -C "${path}" checkout "${ref.replace(/"/g, '\\"')}"`); await execOrThrow(
["checkout", ref],
path,
`Failed to checkout ref '${ref}'`,
);
return; return;
} else { } else {
return git.checkout({ fs, dir: path, ref }); return git.checkout({ fs, dir: path, ref });
@@ -105,17 +148,18 @@ export async function gitCheckout({
export async function gitStageToRevert({ export async function gitStageToRevert({
path, path,
targetOid, targetOid,
}: { }: GitStageToRevertParams): Promise<void> {
path: string;
targetOid: string;
}): Promise<void> {
const settings = readSettings(); const settings = readSettings();
if (settings.enableNativeGit) { if (settings.enableNativeGit) {
// Get the current HEAD commit hash // Get the current HEAD commit hash
const { stdout: currentHead } = await execAsync( const currentHeadResult = await exec(["rev-parse", "HEAD"], path);
`git -C "${path}" rev-parse HEAD`, if (currentHeadResult.exitCode !== 0) {
throw new Error(
`Failed to get current commit: ${currentHeadResult.stderr.trim() || currentHeadResult.stdout.trim()}`,
); );
const currentCommit = currentHead.trim(); }
const currentCommit = currentHeadResult.stdout.trim();
// If we're already at the target commit, nothing to do // If we're already at the target commit, nothing to do
if (currentCommit === targetOid) { if (currentCommit === targetOid) {
@@ -123,20 +167,31 @@ export async function gitStageToRevert({
} }
// Safety: refuse to run if the work-tree isn't clean. // Safety: refuse to run if the work-tree isn't clean.
const { stdout: wtStatus } = await execAsync( const statusResult = await exec(["status", "--porcelain"], path);
`git -C "${path}" status --porcelain`, if (statusResult.exitCode !== 0) {
throw new Error(
`Failed to get status: ${statusResult.stderr.trim() || statusResult.stdout.trim()}`,
); );
if (wtStatus.trim() !== "") { }
if (statusResult.stdout.trim() !== "") {
throw new Error("Cannot revert: working tree has uncommitted changes."); throw new Error("Cannot revert: working tree has uncommitted changes.");
} }
// Reset the working directory and index to match the target commit state // Reset the working directory and index to match the target commit state
// This effectively undoes all changes since the target commit // 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 // 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 // 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 { } else {
// Get status matrix comparing the target commit (previousVersionId as HEAD) with current working directory // Get status matrix comparing the target commit (previousVersionId as HEAD) with current working directory
const matrix = await git.statusMatrix({ const matrix = await git.statusMatrix({
@@ -187,32 +242,111 @@ export async function gitStageToRevert({
} }
} }
export async function gitAddAll({ path }: { path: string }): Promise<void> { export async function gitAddAll({ path }: GitBaseParams): Promise<void> {
const settings = readSettings(); const settings = readSettings();
if (settings.enableNativeGit) { if (settings.enableNativeGit) {
await execAsync(`git -C "${path}" add .`); await execOrThrow(["add", "."], path, "Failed to stage all files");
return; return;
} else { } else {
return git.add({ fs, dir: path, filepath: "." }); return git.add({ fs, dir: path, filepath: "." });
} }
} }
export async function gitAdd({ path, filepath }: GitFileParams): Promise<void> {
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<void> {
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<void> {
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<string[]> {
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({ export async function getFileAtCommit({
path, path,
filePath, filePath,
commitHash, commitHash,
}: { }: GitFileAtCommitParams): Promise<string | null> {
path: string;
filePath: string;
commitHash: string;
}): Promise<string | null> {
const settings = readSettings(); const settings = readSettings();
if (settings.enableNativeGit) { if (settings.enableNativeGit) {
try { try {
const { stdout } = await execAsync( const result = await exec(["show", `${commitHash}:${filePath}`], path);
`git -C "${path}" show "${commitHash}:${filePath}"`, if (result.exitCode !== 0) {
); // File doesn't exist at this commit or other error
return stdout; return null;
}
return result.stdout;
} catch (error: any) { } catch (error: any) {
logger.error( logger.error(
`Error getting file at commit ${commitHash}: ${error.message}`, `Error getting file at commit ${commitHash}: ${error.message}`,
@@ -238,3 +372,312 @@ export async function getFileAtCommit({
} }
} }
} }
export async function gitListBranches({
path,
}: GitBaseParams): Promise<string[]> {
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<void> {
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<void> {
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<void> {
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<void> {
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<string | null> {
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<GitCommit[]> {
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<boolean> {
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<GitCommit[]> {
// 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;
}

View File

@@ -1,13 +1,12 @@
import { db } from "../../db"; import { db } from "../../db";
import { versions, apps } from "../../db/schema"; import { versions, apps } from "../../db/schema";
import { eq, and } from "drizzle-orm"; import { eq, and } from "drizzle-orm";
import fs from "node:fs";
import git from "isomorphic-git";
import { getDyadAppPath } from "../../paths/paths"; import { getDyadAppPath } from "../../paths/paths";
import { neon } from "@neondatabase/serverless"; import { neon } from "@neondatabase/serverless";
import log from "electron-log"; import log from "electron-log";
import { getNeonClient } from "@/neon_admin/neon_management_client"; import { getNeonClient } from "@/neon_admin/neon_management_client";
import { getCurrentCommitHash } from "./git_utils";
const logger = log.scope("neon_timestamp_utils"); const logger = log.scope("neon_timestamp_utils");
@@ -62,11 +61,7 @@ export async function storeDbTimestampAtCurrentVersion({
// 2. Get the current commit hash // 2. Get the current commit hash
const appPath = getDyadAppPath(app.path); const appPath = getDyadAppPath(app.path);
const currentCommitHash = await git.resolveRef({ const currentCommitHash = await getCurrentCommitHash({ path: appPath });
fs,
dir: appPath,
ref: "HEAD",
});
logger.info(`Current commit hash: ${currentCommitHash}`); logger.info(`Current commit hash: ${currentCommitHash}`);

View File

@@ -24,6 +24,7 @@ import {
AddPromptDataSchema, AddPromptDataSchema,
AddPromptPayload, AddPromptPayload,
} from "./ipc/deep_link_data"; } from "./ipc/deep_link_data";
import fs from "fs";
log.errorHandler.startCatching(); log.errorHandler.startCatching();
log.eventLogger.startLogging(); log.eventLogger.startLogging();
@@ -42,6 +43,22 @@ if (started) {
app.quit(); 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 // https://www.electronjs.org/docs/latest/tutorial/launch-app-from-url-in-another-app#main-process-mainjs
if (process.defaultApp) { if (process.defaultApp) {
if (process.argv.length >= 2) { if (process.argv.length >= 2) {

View File

@@ -163,18 +163,8 @@ export default function SettingsPage() {
<Label htmlFor="enable-native-git">Enable Native Git</Label> <Label htmlFor="enable-native-git">Enable Native Git</Label>
</div> </div>
<div className="text-sm text-gray-500 dark:text-gray-400"> <div className="text-sm text-gray-500 dark:text-gray-400">
Native Git offers faster performance but requires{" "} This doesn't require any external Git installation and offers
<a a faster, native-Git performance experience.
onClick={() => {
IpcClient.getInstance().openExternalUrl(
"https://git-scm.com/downloads",
);
}}
className="text-blue-600 hover:underline dark:text-blue-400"
>
installing Git
</a>
.
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,7 +1,6 @@
import fs from "node:fs";
import fsAsync from "node:fs/promises"; import fsAsync from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { isIgnored } from "isomorphic-git"; import { gitIsIgnored } from "../ipc/utils/git_utils";
import log from "electron-log"; import log from "electron-log";
import { IS_TEST_BUILD } from "../ipc/utils/test_utils"; import { IS_TEST_BUILD } from "../ipc/utils/test_utils";
import { glob } from "glob"; import { glob } from "glob";
@@ -176,9 +175,8 @@ async function isGitIgnored(
} }
const relativePath = path.relative(baseDir, filePath); const relativePath = path.relative(baseDir, filePath);
const result = await isIgnored({ const result = await gitIsIgnored({
fs, path: baseDir,
dir: baseDir,
filepath: relativePath, filepath: relativePath,
}); });