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,
ignore,
extraResource: ["node_modules/dugite/git"],
// ignore: [/node_modules\/(?!(better-sqlite3|bindings|file-uri-to-path)\/)/],
},
rebuildConfig: {

94
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -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)",

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 { 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}`,

View File

@@ -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) {

View File

@@ -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<string> {
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<string> {
);
}
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<string> {
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<Uint8Array> 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<string> {
`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<string> {
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);

View File

@@ -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) {

View File

@@ -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({

View File

@@ -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,

View File

@@ -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 || "<no-branch>",
@@ -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",
});

View File

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

View File

@@ -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,

View File

@@ -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<void> {
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<string> {
return await git.resolveRef({
fs,
dir: path,
ref: "HEAD",
});
ref = "HEAD",
}: GitInitParams): 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({
fs,
dir: path,
ref,
});
}
}
export async function isGitStatusClean({
@@ -46,8 +71,15 @@ export async function isGitStatusClean({
}): Promise<boolean> {
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<string> {
}: GitCommitParams): Promise<string> {
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<void> {
}: GitCheckoutParams): Promise<void> {
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<void> {
}: GitStageToRevertParams): Promise<void> {
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<void> {
export async function gitAddAll({ path }: GitBaseParams): Promise<void> {
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<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({
path,
filePath,
commitHash,
}: {
path: string;
filePath: string;
commitHash: string;
}): Promise<string | null> {
}: GitFileAtCommitParams): Promise<string | null> {
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<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 { 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}`);

View File

@@ -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) {

View File

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

View File

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