Skip to content
8 changes: 8 additions & 0 deletions apps/desktop/electron-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ const config: Configuration = {
asar: true,
asarUnpack: [
"**/node_modules/node-pty/**/*",
// Dugite's bundled git binaries must be unpacked to be executable
"**/node_modules/dugite/**/*",
// Sound files must be unpacked so external audio players (afplay, paplay, etc.) can access them
"**/resources/sounds/**/*",
],
Expand All @@ -54,6 +56,12 @@ const config: Configuration = {
to: "node_modules/node-pty",
filter: ["**/*"],
},
// Dugite's bundled git binaries (avoids system git dependency)
{
from: "node_modules/dugite",
to: "node_modules/dugite",
filter: ["**/*"],
},
"!**/.DS_Store",
],

Expand Down
1 change: 1 addition & 0 deletions apps/desktop/electron.vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ export default defineConfig({
external: [
"electron",
"node-pty", // Native module - must stay external
"dugite", // Must stay external so __dirname resolves correctly for git binary
],
},
},
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@
"default-shell": "^2.2.0",
"dnd-core": "^16.0.1",
"dotenv": "^17.2.3",
"dugite": "^3.0.0",
"electron-router-dom": "^2.1.0",
"electron-updater": "6",
"execa": "^9.6.0",
Expand Down
21 changes: 17 additions & 4 deletions apps/desktop/scripts/copy-native-modules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
import { cpSync, existsSync, lstatSync, realpathSync, rmSync } from "node:fs";
import { dirname, join } from "node:path";

const NATIVE_MODULES = ["node-pty"] as const;
const NATIVE_MODULES = ["node-pty", "dugite"] as const;

function prepareNativeModules() {
console.log("Preparing native modules for electron-builder...");
Expand All @@ -42,12 +42,25 @@ function prepareNativeModules() {
// Remove the symlink
rmSync(modulePath);

// Copy the actual files
cpSync(realPath, modulePath, { recursive: true });
// Copy the actual files, dereferencing all internal symlinks.
// This is critical for dugite which has symlinks like git-apply -> git
// inside git/libexec/git-core/. Without dereference, those symlinks
// would still point to the Bun cache location.
cpSync(realPath, modulePath, { recursive: true, dereference: true });

console.log(` Copied to: ${modulePath}`);
} else {
console.log(` ${moduleName}: already real directory (not a symlink)`);
// Even if the module directory itself isn't a symlink, it may contain
// internal symlinks that need to be dereferenced. Re-copy with dereference.
console.log(
` ${moduleName}: real directory, checking for internal symlinks`,
);
const tempPath = `${modulePath}.tmp`;
cpSync(modulePath, tempPath, { recursive: true, dereference: true });
rmSync(modulePath, { recursive: true });
cpSync(tempPath, modulePath, { recursive: true });
rmSync(tempPath, { recursive: true });
console.log(` Re-copied with dereferenced symlinks`);
}
}

Expand Down
20 changes: 10 additions & 10 deletions apps/desktop/src/lib/trpc/routers/changes/changes.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { readFile, rm } from "node:fs/promises";
import { join } from "node:path";
import { createBundledGit } from "main/lib/git-binary";
import type {
ChangedFile,
FileContents,
GitChangesStatus,
} from "shared/changes-types";
import simpleGit from "simple-git";
import { z } from "zod";
import { publicProcedure, router } from "../..";
import {
Expand All @@ -28,7 +28,7 @@ export const createChangesRouter = () => {
remote: string[];
defaultBranch: string;
}> => {
const git = simpleGit(input.worktreePath);
const git = createBundledGit(input.worktreePath);

const branchSummary = await git.branch(["-a"]);

Expand Down Expand Up @@ -77,7 +77,7 @@ export const createChangesRouter = () => {
}),
)
.query(async ({ input }): Promise<GitChangesStatus> => {
const git = simpleGit(input.worktreePath);
const git = createBundledGit(input.worktreePath);
const defaultBranch = input.defaultBranch || "main";

const status = await git.status();
Expand Down Expand Up @@ -193,7 +193,7 @@ export const createChangesRouter = () => {
}),
)
.query(async ({ input }): Promise<ChangedFile[]> => {
const git = simpleGit(input.worktreePath);
const git = createBundledGit(input.worktreePath);

const nameStatus = await git.raw([
"diff-tree",
Expand Down Expand Up @@ -235,7 +235,7 @@ export const createChangesRouter = () => {
}),
)
.query(async ({ input }): Promise<FileContents> => {
const git = simpleGit(input.worktreePath);
const git = createBundledGit(input.worktreePath);
const defaultBranch = input.defaultBranch || "main";
const originalPath = input.oldPath || input.filePath;
let original = "";
Expand Down Expand Up @@ -330,7 +330,7 @@ export const createChangesRouter = () => {
}),
)
.mutation(async ({ input }): Promise<{ success: boolean }> => {
const git = simpleGit(input.worktreePath);
const git = createBundledGit(input.worktreePath);
await git.add(input.filePath);
return { success: true };
}),
Expand All @@ -343,7 +343,7 @@ export const createChangesRouter = () => {
}),
)
.mutation(async ({ input }): Promise<{ success: boolean }> => {
const git = simpleGit(input.worktreePath);
const git = createBundledGit(input.worktreePath);
await git.reset(["HEAD", "--", input.filePath]);
return { success: true };
}),
Expand All @@ -356,7 +356,7 @@ export const createChangesRouter = () => {
}),
)
.mutation(async ({ input }): Promise<{ success: boolean }> => {
const git = simpleGit(input.worktreePath);
const git = createBundledGit(input.worktreePath);
try {
await git.checkout(["--", input.filePath]);
return { success: true };
Expand All @@ -370,15 +370,15 @@ export const createChangesRouter = () => {
stageAll: publicProcedure
.input(z.object({ worktreePath: z.string() }))
.mutation(async ({ input }): Promise<{ success: boolean }> => {
const git = simpleGit(input.worktreePath);
const git = createBundledGit(input.worktreePath);
await git.add("-A");
return { success: true };
}),

unstageAll: publicProcedure
.input(z.object({ worktreePath: z.string() }))
.mutation(async ({ input }): Promise<{ success: boolean }> => {
const git = simpleGit(input.worktreePath);
const git = createBundledGit(input.worktreePath);
await git.reset(["HEAD"]);
return { success: true };
}),
Expand Down
6 changes: 3 additions & 3 deletions apps/desktop/src/lib/trpc/routers/projects/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ import type { BrowserWindow } from "electron";
import { dialog } from "electron";
import { db } from "main/lib/db";
import type { Project } from "main/lib/db/schemas";
import { createBundledGit } from "main/lib/git-binary";
import { nanoid } from "nanoid";
import { PROJECT_COLOR_VALUES } from "shared/constants/project-colors";
import simpleGit from "simple-git";
import { z } from "zod";
import { publicProcedure, router } from "../..";
import { getDefaultBranch, getGitRoot } from "../workspaces/utils/git";
Expand Down Expand Up @@ -191,7 +191,7 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => {
initGitAndOpen: publicProcedure
.input(z.object({ path: z.string() }))
.mutation(async ({ input }) => {
const git = simpleGit(input.path);
const git = createBundledGit(input.path);

// Initialize git repository with 'main' as default branch
// Try with --initial-branch=main (Git 2.28+), fall back to plain init
Expand Down Expand Up @@ -331,7 +331,7 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => {
}

// Clone the repository
const git = simpleGit();
const git = createBundledGit();
await git.clone(input.url, clonePath);

// Create new project
Expand Down
27 changes: 0 additions & 27 deletions apps/desktop/src/lib/trpc/routers/workspaces/utils/git.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,30 +92,3 @@ describe("LFS Detection", () => {
expect(existsSync(join(repoPath, ".gitattributes"))).toBe(false);
});
});

describe("Shell Environment", () => {
test("getShellEnvironment returns PATH", async () => {
const { getShellEnvironment } = await import("./shell-env");

const env = await getShellEnvironment();

// Should have PATH
expect(env.PATH || env.Path).toBeDefined();
});

test("clearShellEnvCache clears cache", async () => {
const { clearShellEnvCache, getShellEnvironment } = await import(
"./shell-env"
);

// Get env (populates cache)
await getShellEnvironment();

// Clear cache
clearShellEnvCache();

// Should work again (cache was cleared)
const env = await getShellEnvironment();
expect(env.PATH || env.Path).toBeDefined();
});
});
Loading