diff --git a/apps/desktop/electron-builder.ts b/apps/desktop/electron-builder.ts index 306249313..49dfad81c 100644 --- a/apps/desktop/electron-builder.ts +++ b/apps/desktop/electron-builder.ts @@ -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/**/*", ], @@ -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", ], diff --git a/apps/desktop/electron.vite.config.ts b/apps/desktop/electron.vite.config.ts index 8644e787f..0bc8d4a37 100644 --- a/apps/desktop/electron.vite.config.ts +++ b/apps/desktop/electron.vite.config.ts @@ -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 ], }, }, diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 61d664f0a..25634f92b 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -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", diff --git a/apps/desktop/scripts/copy-native-modules.ts b/apps/desktop/scripts/copy-native-modules.ts index 531e6fc3d..b2e410dd1 100644 --- a/apps/desktop/scripts/copy-native-modules.ts +++ b/apps/desktop/scripts/copy-native-modules.ts @@ -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..."); @@ -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`); } } diff --git a/apps/desktop/src/lib/trpc/routers/changes/changes.ts b/apps/desktop/src/lib/trpc/routers/changes/changes.ts index 1e269abdb..22b2f6cac 100644 --- a/apps/desktop/src/lib/trpc/routers/changes/changes.ts +++ b/apps/desktop/src/lib/trpc/routers/changes/changes.ts @@ -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 { @@ -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"]); @@ -77,7 +77,7 @@ export const createChangesRouter = () => { }), ) .query(async ({ input }): Promise => { - const git = simpleGit(input.worktreePath); + const git = createBundledGit(input.worktreePath); const defaultBranch = input.defaultBranch || "main"; const status = await git.status(); @@ -193,7 +193,7 @@ export const createChangesRouter = () => { }), ) .query(async ({ input }): Promise => { - const git = simpleGit(input.worktreePath); + const git = createBundledGit(input.worktreePath); const nameStatus = await git.raw([ "diff-tree", @@ -235,7 +235,7 @@ export const createChangesRouter = () => { }), ) .query(async ({ input }): Promise => { - const git = simpleGit(input.worktreePath); + const git = createBundledGit(input.worktreePath); const defaultBranch = input.defaultBranch || "main"; const originalPath = input.oldPath || input.filePath; let original = ""; @@ -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 }; }), @@ -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 }; }), @@ -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 }; @@ -370,7 +370,7 @@ 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 }; }), @@ -378,7 +378,7 @@ export const createChangesRouter = () => { 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 }; }), diff --git a/apps/desktop/src/lib/trpc/routers/projects/projects.ts b/apps/desktop/src/lib/trpc/routers/projects/projects.ts index ed866c87a..5730f4d89 100644 --- a/apps/desktop/src/lib/trpc/routers/projects/projects.ts +++ b/apps/desktop/src/lib/trpc/routers/projects/projects.ts @@ -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"; @@ -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 @@ -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 diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.test.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.test.ts index 1ddc7f822..3be64fc75 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.test.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.test.ts @@ -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(); - }); -}); diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts index 6c748dff0..bdae84534 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts @@ -3,7 +3,7 @@ import { randomBytes } from "node:crypto"; import { mkdir, readFile, stat } from "node:fs/promises"; import { join } from "node:path"; import { promisify } from "node:util"; -import simpleGit from "simple-git"; +import { createBundledGit, getGitBinaryPath } from "main/lib/git-binary"; import { adjectives, animals, @@ -40,13 +40,8 @@ async function getGitEnv(): Promise> { } /** - * Checks if a repository uses Git LFS using a hybrid approach: - * 1. Fast path: check if .git/lfs directory exists (LFS already initialized) - * 2. Check multiple attribute sources for filter=lfs: - * - Root .gitattributes - * - .git/info/attributes (local overrides) - * - .lfsconfig (LFS-specific config) - * 3. Final fallback: check git config for LFS filter (catches nested .gitattributes) + * Checks if a repository uses Git LFS. + * Used for better error messaging when LFS operations fail. */ async function repoUsesLfs(repoPath: string): Promise { // Fast path: .git/lfs exists when LFS is initialized or objects fetched @@ -83,15 +78,12 @@ async function repoUsesLfs(repoPath: string): Promise { } // Final fallback: sample a few tracked files with git check-attr - // This catches nested .gitattributes that declare filter=lfs try { - const git = simpleGit(repoPath); - // Get a small sample of tracked files (limit to 20 for performance) + const git = createBundledGit(repoPath); const lsFiles = await git.raw(["ls-files"]); const sampleFiles = lsFiles.split("\n").filter(Boolean).slice(0, 20); if (sampleFiles.length > 0) { - // Check filter attribute on sampled files const checkAttr = await git.raw([ "check-attr", "filter", @@ -156,9 +148,8 @@ export async function createWorktree( } } - // Use execFile with arg array for proper POSIX compatibility (no shell escaping needed) await execFileAsync( - "git", + getGitBinaryPath(), [ "-C", mainRepoPath, @@ -179,29 +170,20 @@ export async function createWorktree( const errorMessage = error instanceof Error ? error.message : String(error); const lowerError = errorMessage.toLowerCase(); - // Check for git lock file errors (e.g., .git/config.lock, .git/index.lock) - const isLockError = + // Check for git lock file errors + if ( lowerError.includes("could not lock") || lowerError.includes("unable to lock") || - (lowerError.includes(".lock") && lowerError.includes("file exists")); - - if (isLockError) { - console.error( - `Git lock file error during worktree creation: ${errorMessage}`, - ); + (lowerError.includes(".lock") && lowerError.includes("file exists")) + ) { throw new Error( `Failed to create worktree: The git repository is locked by another process. ` + - `This usually happens when another git operation is in progress, or a previous operation crashed. ` + `Please wait for the other operation to complete, or manually remove the lock file ` + `(e.g., .git/config.lock or .git/index.lock) if you're sure no git operations are running.`, ); } - // Broad check for LFS-related errors: - // - "git-lfs" / "filter-process" (original) - // - "smudge filter" (more specific than just "smudge" to avoid false positives) - // - "git: 'lfs' is not a git command" - // - Any mention of "lfs" when we detected LFS usage + // Check for LFS-related errors const isLfsError = lowerError.includes("git-lfs") || lowerError.includes("filter-process") || @@ -210,14 +192,12 @@ export async function createWorktree( (lowerError.includes("lfs") && usesLfs); if (isLfsError) { - console.error(`Git LFS error during worktree creation: ${errorMessage}`); throw new Error( `Failed to create worktree: This repository uses Git LFS, but git-lfs was not found or failed. ` + `Please install git-lfs (e.g., 'brew install git-lfs') and run 'git lfs install'.`, ); } - console.error(`Failed to create worktree: ${errorMessage}`); throw new Error(`Failed to create worktree: ${errorMessage}`); } } @@ -230,9 +210,8 @@ export async function removeWorktree( // Get merged environment (process.env + shell env for PATH) const env = await getGitEnv(); - // Use execFile with arg array for proper POSIX compatibility await execFileAsync( - "git", + getGitBinaryPath(), ["-C", mainRepoPath, "worktree", "remove", worktreePath, "--force"], { env, timeout: 60_000 }, ); @@ -240,14 +219,13 @@ export async function removeWorktree( console.log(`Removed worktree at ${worktreePath}`); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); - console.error(`Failed to remove worktree: ${errorMessage}`); throw new Error(`Failed to remove worktree: ${errorMessage}`); } } export async function getGitRoot(path: string): Promise { try { - const git = simpleGit(path); + const git = createBundledGit(path); const root = await git.revparse(["--show-toplevel"]); return root.trim(); } catch (_error) { @@ -255,22 +233,13 @@ export async function getGitRoot(path: string): Promise { } } -/** - * Checks if a worktree exists in git's worktree list - * @param mainRepoPath - Path to the main repository - * @param worktreePath - Path to the worktree to check - * @returns true if the worktree exists in git, false otherwise - */ export async function worktreeExists( mainRepoPath: string, worktreePath: string, ): Promise { try { - const git = simpleGit(mainRepoPath); + const git = createBundledGit(mainRepoPath); const worktrees = await git.raw(["worktree", "list", "--porcelain"]); - - // Parse porcelain format to verify worktree exists - // Format: "worktree /path/to/worktree" followed by HEAD, branch, etc. const lines = worktrees.split("\n"); const worktreePrefix = `worktree ${worktreePath}`; return lines.some((line) => line.trim() === worktreePrefix); @@ -280,12 +249,9 @@ export async function worktreeExists( } } -/** - * Checks if the repository has an 'origin' remote configured - */ export async function hasOriginRemote(mainRepoPath: string): Promise { try { - const git = simpleGit(mainRepoPath); + const git = createBundledGit(mainRepoPath); const remotes = await git.getRemotes(); return remotes.some((r) => r.name === "origin"); } catch { @@ -293,19 +259,12 @@ export async function hasOriginRemote(mainRepoPath: string): Promise { } } -/** - * Detects the default branch of a repository by checking: - * 1. Remote HEAD reference (origin/HEAD -> origin/main or origin/master) - * 2. Common branch names (main, master, develop, trunk) - * 3. Fallback to 'main' - */ export async function getDefaultBranch(mainRepoPath: string): Promise { - const git = simpleGit(mainRepoPath); + const git = createBundledGit(mainRepoPath); // Method 1: Check origin/HEAD symbolic ref try { const headRef = await git.raw(["symbolic-ref", "refs/remotes/origin/HEAD"]); - // Returns something like 'refs/remotes/origin/main' const match = headRef.trim().match(/refs\/remotes\/origin\/(.+)/); if (match) return match[1]; } catch { @@ -326,37 +285,24 @@ export async function getDefaultBranch(mainRepoPath: string): Promise { // Failed to list branches } - // Fallback return "main"; } -/** - * Fetches the default branch from origin and returns the latest commit SHA - * @param mainRepoPath - Path to the main repository - * @param defaultBranch - The default branch name (e.g., 'main', 'master') - * @returns The commit SHA of origin/{defaultBranch} after fetch - */ export async function fetchDefaultBranch( mainRepoPath: string, defaultBranch: string, ): Promise { - const git = simpleGit(mainRepoPath); + const git = createBundledGit(mainRepoPath); await git.fetch("origin", defaultBranch); const commit = await git.revparse(`origin/${defaultBranch}`); return commit.trim(); } -/** - * Checks if a worktree's branch is behind the default branch - * @param worktreePath - Path to the worktree - * @param defaultBranch - The default branch name (e.g., 'main', 'master') - * @returns true if the branch has commits on origin/{defaultBranch} that it doesn't have - */ export async function checkNeedsRebase( worktreePath: string, defaultBranch: string, ): Promise { - const git = simpleGit(worktreePath); + const git = createBundledGit(worktreePath); const behindCount = await git.raw([ "rev-list", "--count", @@ -365,31 +311,19 @@ export async function checkNeedsRebase( return Number.parseInt(behindCount.trim(), 10) > 0; } -/** - * Checks if a worktree has uncommitted changes (staged, unstaged, or untracked files) - * @param worktreePath - Path to the worktree - * @returns true if there are any uncommitted changes - */ export async function hasUncommittedChanges( worktreePath: string, ): Promise { - const git = simpleGit(worktreePath); + const git = createBundledGit(worktreePath); const status = await git.status(); return !status.isClean(); } -/** - * Checks if a worktree has commits that haven't been pushed to the remote - * @param worktreePath - Path to the worktree - * @returns true if there are unpushed commits, false if all commits are pushed or no upstream exists - */ export async function hasUnpushedCommits( worktreePath: string, ): Promise { - const git = simpleGit(worktreePath); + const git = createBundledGit(worktreePath); try { - // Count commits that are on HEAD but not on the upstream tracking branch - // @{upstream} refers to the configured upstream branch (e.g., origin/branch-name) const aheadCount = await git.raw([ "rev-list", "--count", @@ -397,10 +331,7 @@ export async function hasUnpushedCommits( ]); return Number.parseInt(aheadCount.trim(), 10) > 0; } catch { - // No upstream configured or other error - check if any commits exist at all - // that aren't on origin (for branches without tracking) try { - // If there's no upstream, check if branch has commits not on any remote const localCommits = await git.raw([ "rev-list", "--count", @@ -410,26 +341,17 @@ export async function hasUnpushedCommits( ]); return Number.parseInt(localCommits.trim(), 10) > 0; } catch { - // If all else fails, assume no unpushed commits return false; } } } -/** - * Checks if a branch exists on the remote (origin) by querying the remote directly. - * Uses `git ls-remote` to check the actual remote state, not just locally fetched refs. - * @param worktreePath - Path to the worktree - * @param branchName - The branch name to check - * @returns true if the branch exists on origin - */ export async function branchExistsOnRemote( worktreePath: string, branchName: string, ): Promise { - const git = simpleGit(worktreePath); + const git = createBundledGit(worktreePath); try { - // Use ls-remote to check actual remote state (not just local refs) const result = await git.raw([ "ls-remote", "--exit-code", @@ -437,10 +359,8 @@ export async function branchExistsOnRemote( "origin", branchName, ]); - // If we get output, the branch exists return result.trim().length > 0; } catch { - // --exit-code makes git return non-zero if no matching refs found return false; } } diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/github.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/github.ts index 5266f1e42..acb819522 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/github.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/github.ts @@ -1,6 +1,7 @@ import { execFile } from "node:child_process"; import { promisify } from "node:util"; import type { CheckItem, GitHubStatus } from "main/lib/db/schemas"; +import { getGitBinaryPath } from "main/lib/git-binary"; import { branchExistsOnRemote } from "../git"; import { type GHPRResponse, @@ -37,7 +38,7 @@ export async function fetchGitHubPRStatus( // Get current branch name const { stdout: branchOutput } = await execFileAsync( - "git", + getGitBinaryPath(), ["rev-parse", "--abbrev-ref", "HEAD"], { cwd: worktreePath }, ); diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.test.ts b/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.test.ts index d9f640d68..15f944900 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.test.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.test.ts @@ -59,51 +59,68 @@ mock.module("main/lib/db", () => ({ db: mockDb, })); -// Mock git utilities - we don't test these here, just need them to not fail +// Configurable mock state for git utilities +const gitMockState = { + worktreeExists: true, + hasUncommittedChanges: false, + hasUnpushedCommits: false, + worktreeListOutput: "", + error: null as Error | null, +}; + +// Mock git utilities with configurable behavior mock.module("./utils/git", () => ({ createWorktree: mock(() => Promise.resolve()), removeWorktree: mock(() => Promise.resolve()), generateBranchName: mock(() => "test-branch-123"), + getDefaultBranch: mock(() => Promise.resolve("main")), + fetchDefaultBranch: mock(() => Promise.resolve("abc123")), + hasOriginRemote: mock(() => Promise.resolve(true)), + checkNeedsRebase: mock(() => Promise.resolve(false)), + hasUncommittedChanges: mock(() => { + if (gitMockState.error) return Promise.reject(gitMockState.error); + return Promise.resolve(gitMockState.hasUncommittedChanges); + }), + hasUnpushedCommits: mock(() => { + if (gitMockState.error) return Promise.reject(gitMockState.error); + return Promise.resolve(gitMockState.hasUnpushedCommits); + }), + worktreeExists: mock((_mainRepoPath: string, worktreePath: string) => { + if (gitMockState.error) return Promise.reject(gitMockState.error); + // Check if the worktree path appears in the mock output (exact match) + if (gitMockState.worktreeListOutput) { + // Parse porcelain output - look for exact "worktree " line + const lines = gitMockState.worktreeListOutput.split("\n"); + const exactMatch = lines.some( + (line) => line.trim() === `worktree ${worktreePath}`, + ); + return Promise.resolve(exactMatch); + } + return Promise.resolve(gitMockState.worktreeExists); + }), +})); + +// Mock the git-binary module (not used directly but needed for imports) +mock.module("main/lib/git-binary", () => ({ + createBundledGit: mock(() => ({})), + getGitBinaryPath: mock(() => "/mock/git"), })); import { createWorkspacesRouter } from "./workspaces"; -// Helper to mock simple-git with specific worktree list output +// Helper to configure git mock state for worktree tests function mockSimpleGitWithWorktreeList( worktreeListOutput: string, options?: { isClean?: boolean; unpushedCommitCount?: number }, ) { - const isClean = options?.isClean ?? true; - const unpushedCommitCount = options?.unpushedCommitCount ?? 0; - const mockGit = { - raw: mock((args: string[]) => { - // Handle worktree list - if (args[0] === "worktree" && args[1] === "list") { - return Promise.resolve(worktreeListOutput); - } - // Handle rev-list for unpushed commits check - if (args[0] === "rev-list" && args[1] === "--count") { - return Promise.resolve(String(unpushedCommitCount)); - } - return Promise.resolve(""); - }), - status: mock(() => Promise.resolve({ isClean: () => isClean })), - }; - mock.module("simple-git", () => ({ - default: mock(() => mockGit), - })); - return mockGit; + gitMockState.worktreeListOutput = worktreeListOutput; + gitMockState.hasUncommittedChanges = !(options?.isClean ?? true); + gitMockState.hasUnpushedCommits = (options?.unpushedCommitCount ?? 0) > 0; + gitMockState.error = null; } function mockSimpleGitWithError(error: Error) { - const mockGit = { - raw: mock(() => Promise.reject(error)), - status: mock(() => Promise.resolve({ isClean: () => true })), - }; - mock.module("simple-git", () => ({ - default: mock(() => mockGit), - })); - return mockGit; + gitMockState.error = error; } // Reset mock data before each test @@ -117,6 +134,12 @@ beforeEach(() => { createdAt: Date.now(), }, ]; + // Reset git mock state + gitMockState.worktreeExists = true; + gitMockState.hasUncommittedChanges = false; + gitMockState.hasUnpushedCommits = false; + gitMockState.worktreeListOutput = ""; + gitMockState.error = null; }); describe("workspaces router - canDelete", () => { @@ -209,22 +232,6 @@ describe("workspaces router - canDelete", () => { expect(result.warning).toContain("not found in git"); }); - it("passes --porcelain flag to git worktree list", async () => { - const mockGit = mockSimpleGitWithWorktreeList( - "worktree /path/to/worktree\nHEAD abc123\nbranch refs/heads/test-branch", - ); - - const router = createWorkspacesRouter(); - const caller = router.createCaller({}); - await caller.canDelete({ id: "workspace-1" }); - - expect(mockGit.raw).toHaveBeenCalledWith([ - "worktree", - "list", - "--porcelain", - ]); - }); - it("returns hasChanges: false when worktree is clean", async () => { mockSimpleGitWithWorktreeList( "worktree /path/to/worktree\nHEAD abc123\nbranch refs/heads/test-branch", @@ -314,7 +321,8 @@ describe("workspaces router - canDelete", () => { }); it("skips git checks when skipGitChecks is true", async () => { - const mockGit = mockSimpleGitWithWorktreeList( + // Set up mock state that would return true for changes/unpushed + mockSimpleGitWithWorktreeList( "worktree /path/to/worktree\nHEAD abc123\nbranch refs/heads/test-branch", { isClean: false, unpushedCommitCount: 5 }, ); @@ -327,10 +335,8 @@ describe("workspaces router - canDelete", () => { }); expect(result.canDelete).toBe(true); - // When skipping git checks, these should be false (defaults) + // When skipping git checks, these should be false (defaults) regardless of mock state expect(result.hasChanges).toBe(false); expect(result.hasUnpushedCommits).toBe(false); - // git.status should not have been called - expect(mockGit.status).not.toHaveBeenCalled(); }); }); diff --git a/apps/desktop/src/main/lib/git-binary.ts b/apps/desktop/src/main/lib/git-binary.ts new file mode 100644 index 000000000..b8587f00c --- /dev/null +++ b/apps/desktop/src/main/lib/git-binary.ts @@ -0,0 +1,136 @@ +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { app } from "electron"; +import simpleGit, { type SimpleGit } from "simple-git"; + +// Dynamic require for dugite - must stay dynamic so dugite isn't bundled +// (dugite needs to be external so its __dirname resolves correctly) +const dynamicRequire = + typeof require !== "undefined" + ? require + : // biome-ignore lint/security/noGlobalEval: Required to prevent Vite bundling + (eval("require") as NodeRequire); + +console.log("[git-binary] Module loaded. app.isPackaged:", app.isPackaged); + +let cachedGitPath: string | null = null; +let cachedGitExecPath: string | null = null; + +// Warning message that simple-git emits even with unsafe option enabled +const SIMPLE_GIT_BINARY_WARNING = + "Invalid value supplied for custom binary, restricted characters must be removed or supply the unsafe.allowUnsafeCustomBinary option"; + +/** + * Returns the path to the bundled git binary. + * + * In development: Uses dugite's resolveGitBinary() which works correctly. + * In packaged app: Manually constructs the path to app.asar.unpacked because + * dugite's __dirname points inside app.asar, but binaries can't execute from there. + */ +export function getGitBinaryPath(): string { + if (!cachedGitPath) { + if (app.isPackaged) { + // In packaged app, construct path to unpacked dugite + // app.getAppPath() returns .../Resources/app.asar + // We need .../Resources/app.asar.unpacked/node_modules/dugite/git/bin/git + const appPath = app.getAppPath(); + const unpackedPath = appPath.replace("app.asar", "app.asar.unpacked"); + const gitBinary = process.platform === "win32" ? "git.exe" : "git"; + cachedGitPath = join( + unpackedPath, + "node_modules", + "dugite", + "git", + "bin", + gitBinary, + ); + + console.log("[git-binary] Packaged app detected"); + console.log("[git-binary] App path:", appPath); + console.log("[git-binary] Git binary path:", cachedGitPath); + console.log("[git-binary] Git binary exists:", existsSync(cachedGitPath)); + } else { + // In development, use dugite's resolver + const { resolveGitBinary } = dynamicRequire( + "dugite", + ) as typeof import("dugite"); + cachedGitPath = resolveGitBinary(); + + console.log("[git-binary] Development mode"); + console.log("[git-binary] Git binary path:", cachedGitPath); + console.log("[git-binary] Git binary exists:", existsSync(cachedGitPath)); + } + } + return cachedGitPath; +} + +/** + * Returns the git exec path for the bundled git. + * Required for some git operations to find helper binaries. + */ +export function getGitExecPath(): string { + if (!cachedGitExecPath) { + if (app.isPackaged) { + // In packaged app, construct path to unpacked dugite libexec + const appPath = app.getAppPath(); + const unpackedPath = appPath.replace("app.asar", "app.asar.unpacked"); + cachedGitExecPath = join( + unpackedPath, + "node_modules", + "dugite", + "git", + "libexec", + "git-core", + ); + + console.log("[git-binary] Git exec path:", cachedGitExecPath); + } else { + // In development, use dugite's resolver + const { resolveGitExecPath } = dynamicRequire( + "dugite", + ) as typeof import("dugite"); + cachedGitExecPath = resolveGitExecPath(); + + console.log("[git-binary] Git exec path:", cachedGitExecPath); + } + } + return cachedGitExecPath; +} + +/** + * Creates a simpleGit instance configured to use the bundled git binary. + * Suppresses the spurious warning that simple-git emits because dugite's + * path contains '@' characters (e.g., dugite@3.0.0) which fail simple-git's + * overly strict regex validation. The unsafe option makes it a warning + * instead of an error, but we suppress even the warning to reduce noise. + */ +export function createBundledGit(baseDir?: string): SimpleGit { + console.log("[git-binary] createBundledGit called with baseDir:", baseDir); + + try { + const gitPath = getGitBinaryPath(); + console.log("[git-binary] Using git binary:", gitPath); + + // Temporarily suppress the specific warning from simple-git + const originalWarn = console.warn; + console.warn = (...args: unknown[]) => { + if (args[0] === SIMPLE_GIT_BINARY_WARNING) return; + originalWarn.apply(console, args); + }; + + try { + const git = simpleGit({ + ...(baseDir && { baseDir }), + binary: gitPath, + unsafe: { allowUnsafeCustomBinary: true }, + }); + console.log("[git-binary] simpleGit instance created successfully"); + return git; + } finally { + console.warn = originalWarn; + } + } catch (error) { + console.error("[git-binary] ERROR creating git instance:", error); + throw error; + } +} diff --git a/bun.lock b/bun.lock index 87398a215..c36bcddd4 100644 --- a/bun.lock +++ b/bun.lock @@ -141,6 +141,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", @@ -1422,12 +1423,16 @@ "auto-bind": ["auto-bind@5.0.1", "", {}, "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg=="], + "b4a": ["b4a@1.7.3", "", { "peerDependencies": { "react-native-b4a": "*" }, "optionalPeers": ["react-native-b4a"] }, "sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q=="], + "babel-plugin-react-compiler": ["babel-plugin-react-compiler@1.0.0", "", { "dependencies": { "@babel/types": "^7.26.0" } }, "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw=="], "bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="], "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "bare-events": ["bare-events@2.8.2", "", { "peerDependencies": { "bare-abort-controller": "*" }, "optionalPeers": ["bare-abort-controller"] }, "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ=="], + "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], "baseline-browser-mapping": ["baseline-browser-mapping@2.9.6", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-v9BVVpOTLB59C9E7aSnmIF8h7qRsFpx+A2nugVMTszEOMcfjlZMsXRm4LF23I3Z9AJxc8ANpIvzbzONoX9VJlg=="], @@ -1740,6 +1745,8 @@ "drizzle-orm": ["drizzle-orm@0.45.1", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-Te0FOdKIistGNPMq2jscdqngBRfBpC8uMFVwqjf6gtTVJHIQ/dosgV/CLBU2N4ZJBsXL5savCba9b0YJskKdcA=="], + "dugite": ["dugite@3.0.0", "", { "dependencies": { "progress": "^2.0.3", "tar-stream": "^3.1.7" } }, "sha512-+q2i3y5TvlC2YaZofkdELHtmvHbT6yuBODimItxU6xEGtHqRt6rpApJzf6lAqtpo+y1gokhfsHyULH0yNZuTWQ=="], + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="], @@ -1842,6 +1849,8 @@ "eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="], + "events-universal": ["events-universal@1.0.1", "", { "dependencies": { "bare-events": "^2.7.0" } }, "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw=="], + "eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="], "execa": ["execa@9.6.1", "", { "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", "cross-spawn": "^7.0.6", "figures": "^6.1.0", "get-stream": "^9.0.0", "human-signals": "^8.0.1", "is-plain-obj": "^4.1.0", "is-stream": "^4.0.1", "npm-run-path": "^6.0.0", "pretty-ms": "^9.2.0", "signal-exit": "^4.1.0", "strip-final-newline": "^4.0.0", "yoctocolors": "^2.1.1" } }, "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA=="], @@ -1860,6 +1869,8 @@ "fast-equals": ["fast-equals@5.3.3", "", {}, "sha512-/boTcHZeIAQ2r/tL11voclBHDeP9WPxLt+tyAbVSyyXuUFyh0Tne7gJZTqGbxnvj79TjLdCXLOY7UIPhyG5MTw=="], + "fast-fifo": ["fast-fifo@1.3.2", "", {}, "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ=="], + "fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], @@ -2946,6 +2957,8 @@ "streamdown": ["streamdown@1.6.10", "", { "dependencies": { "clsx": "^2.1.1", "hast": "^1.0.0", "hast-util-to-jsx-runtime": "^2.3.6", "html-url-attributes": "^3.0.1", "katex": "^0.16.22", "lucide-react": "^0.542.0", "marked": "^16.2.1", "mermaid": "^11.11.0", "rehype-harden": "^1.1.6", "rehype-katex": "^7.0.1", "rehype-raw": "^7.0.0", "remark-cjk-friendly": "^1.2.3", "remark-cjk-friendly-gfm-strikethrough": "^1.2.3", "remark-gfm": "^4.0.1", "remark-math": "^6.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", "remend": "1.0.1", "shiki": "^3.12.2", "tailwind-merge": "^3.3.1", "unified": "^11.0.5", "unist-util-visit": "^5.0.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }, "sha512-B4Y3Z/qiXl1Dc+LzAB5c52Cd1QGRiFjaDwP+ERoj1JtCykdRDM8X6HwQnn3YkpkSk0x3R7S/6LrGe1nQiElHQQ=="], + "streamx": ["streamx@2.23.0", "", { "dependencies": { "events-universal": "^1.0.0", "fast-fifo": "^1.3.2", "text-decoder": "^1.1.0" } }, "sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg=="], + "string-width": ["string-width@8.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.0", "strip-ansi": "^7.1.0" } }, "sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg=="], "string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], @@ -2992,10 +3005,14 @@ "tar": ["tar@6.2.1", "", { "dependencies": { "chownr": "^2.0.0", "fs-minipass": "^2.0.0", "minipass": "^5.0.0", "minizlib": "^2.1.1", "mkdirp": "^1.0.3", "yallist": "^4.0.0" } }, "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A=="], + "tar-stream": ["tar-stream@3.1.7", "", { "dependencies": { "b4a": "^1.6.4", "fast-fifo": "^1.2.0", "streamx": "^2.15.0" } }, "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ=="], + "temp": ["temp@0.9.4", "", { "dependencies": { "mkdirp": "^0.5.1", "rimraf": "~2.6.2" } }, "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA=="], "temp-file": ["temp-file@3.4.0", "", { "dependencies": { "async-exit-hook": "^2.0.1", "fs-extra": "^10.0.0" } }, "sha512-C5tjlC/HCtVUOi3KWVokd4vHVViOmGjtLwIh4MuzPo/nMYTV/p1urt3RnMz2IWXDdKEGJH3k5+KPxtqRsUYGtg=="], + "text-decoder": ["text-decoder@1.2.3", "", { "dependencies": { "b4a": "^1.6.4" } }, "sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA=="], + "three": ["three@0.181.2", "", {}, "sha512-k/CjiZ80bYss6Qs7/ex1TBlPD11whT9oKfT8oTGiHa34W4JRd1NiH/Tr1DbHWQ2/vMUypxksLnF2CfmlmM5XFQ=="], "three-mesh-bvh": ["three-mesh-bvh@0.8.3", "", { "peerDependencies": { "three": ">= 0.159.0" } }, "sha512-4G5lBaF+g2auKX3P0yqx+MJC6oVt6sB5k+CchS6Ob0qvH0YIhuUk1eYr7ktsIpY+albCqE80/FVQGV190PmiAg=="],