From c4362a3306556b7b7dab7e8bb3db468d3526c1d2 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Fri, 12 Dec 2025 23:09:34 -0500 Subject: [PATCH 1/8] fix(desktop): bundle git binary to avoid system git dependency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use dugite to bundle a portable git binary with the app, eliminating the need for system git. This fixes the "Xcode license agreements" error that macOS users encounter when they haven't accepted the Xcode license. Changes: - Add dugite package for bundled git binaries - Create git-binary.ts helper to resolve bundled git path - Update all simple-git usages to use bundled binary - Update electron-builder to include dugite in app bundle - Remove shell-env.ts (no longer needed - was for finding system git) - Bundled git includes git-lfs, so LFS repos work out of the box 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- apps/desktop/electron-builder.ts | 8 + apps/desktop/package.json | 1 + .../src/lib/trpc/routers/changes/changes.ts | 26 ++- .../src/lib/trpc/routers/projects/projects.ts | 12 +- .../trpc/routers/workspaces/utils/git.test.ts | 27 --- .../lib/trpc/routers/workspaces/utils/git.ts | 185 +++--------------- .../routers/workspaces/utils/shell-env.ts | 105 ---------- apps/desktop/src/main/lib/git-binary.ts | 27 +++ bun.lock | 17 ++ 9 files changed, 110 insertions(+), 298 deletions(-) delete mode 100644 apps/desktop/src/lib/trpc/routers/workspaces/utils/shell-env.ts create mode 100644 apps/desktop/src/main/lib/git-binary.ts diff --git a/apps/desktop/electron-builder.ts b/apps/desktop/electron-builder.ts index 30624931388..69c4fa9e524 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/git/**/*", // 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/package.json b/apps/desktop/package.json index 61d664f0aca..25634f92bef 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/src/lib/trpc/routers/changes/changes.ts b/apps/desktop/src/lib/trpc/routers/changes/changes.ts index 1e269abdb79..46244126d5a 100644 --- a/apps/desktop/src/lib/trpc/routers/changes/changes.ts +++ b/apps/desktop/src/lib/trpc/routers/changes/changes.ts @@ -1,5 +1,6 @@ import { readFile, rm } from "node:fs/promises"; import { join } from "node:path"; +import { getGitBinaryPath } from "main/lib/git-binary"; import type { ChangedFile, FileContents, @@ -16,6 +17,13 @@ import { parseNameStatus, } from "./utils/parse-status"; +/** + * Creates a simpleGit instance configured to use the bundled git binary. + */ +function createGit(baseDir: string) { + return simpleGit({ baseDir, binary: getGitBinaryPath() }); +} + export const createChangesRouter = () => { return router({ getBranches: publicProcedure @@ -28,7 +36,7 @@ export const createChangesRouter = () => { remote: string[]; defaultBranch: string; }> => { - const git = simpleGit(input.worktreePath); + const git = createGit(input.worktreePath); const branchSummary = await git.branch(["-a"]); @@ -77,7 +85,7 @@ export const createChangesRouter = () => { }), ) .query(async ({ input }): Promise => { - const git = simpleGit(input.worktreePath); + const git = createGit(input.worktreePath); const defaultBranch = input.defaultBranch || "main"; const status = await git.status(); @@ -193,7 +201,7 @@ export const createChangesRouter = () => { }), ) .query(async ({ input }): Promise => { - const git = simpleGit(input.worktreePath); + const git = createGit(input.worktreePath); const nameStatus = await git.raw([ "diff-tree", @@ -235,7 +243,7 @@ export const createChangesRouter = () => { }), ) .query(async ({ input }): Promise => { - const git = simpleGit(input.worktreePath); + const git = createGit(input.worktreePath); const defaultBranch = input.defaultBranch || "main"; const originalPath = input.oldPath || input.filePath; let original = ""; @@ -330,7 +338,7 @@ export const createChangesRouter = () => { }), ) .mutation(async ({ input }): Promise<{ success: boolean }> => { - const git = simpleGit(input.worktreePath); + const git = createGit(input.worktreePath); await git.add(input.filePath); return { success: true }; }), @@ -343,7 +351,7 @@ export const createChangesRouter = () => { }), ) .mutation(async ({ input }): Promise<{ success: boolean }> => { - const git = simpleGit(input.worktreePath); + const git = createGit(input.worktreePath); await git.reset(["HEAD", "--", input.filePath]); return { success: true }; }), @@ -356,7 +364,7 @@ export const createChangesRouter = () => { }), ) .mutation(async ({ input }): Promise<{ success: boolean }> => { - const git = simpleGit(input.worktreePath); + const git = createGit(input.worktreePath); try { await git.checkout(["--", input.filePath]); return { success: true }; @@ -370,7 +378,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 = createGit(input.worktreePath); await git.add("-A"); return { success: true }; }), @@ -378,7 +386,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 = createGit(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 ed866c87aca..d8551c4b5c9 100644 --- a/apps/desktop/src/lib/trpc/routers/projects/projects.ts +++ b/apps/desktop/src/lib/trpc/routers/projects/projects.ts @@ -5,6 +5,7 @@ import type { BrowserWindow } from "electron"; import { dialog } from "electron"; import { db } from "main/lib/db"; import type { Project } from "main/lib/db/schemas"; +import { getGitBinaryPath } from "main/lib/git-binary"; import { nanoid } from "nanoid"; import { PROJECT_COLOR_VALUES } from "shared/constants/project-colors"; import simpleGit from "simple-git"; @@ -13,6 +14,13 @@ import { publicProcedure, router } from "../.."; import { getDefaultBranch, getGitRoot } from "../workspaces/utils/git"; import { assignRandomColor } from "./utils/colors"; +/** + * Creates a simpleGit instance configured to use the bundled git binary. + */ +function createGit(baseDir?: string) { + return simpleGit(baseDir ? { baseDir, binary: getGitBinaryPath() } : { binary: getGitBinaryPath() }); +} + // Return types for openNew procedure type OpenNewCanceled = { canceled: true }; type OpenNewSuccess = { canceled: false; project: Project }; @@ -191,7 +199,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 = createGit(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 +339,7 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => { } // Clone the repository - const git = simpleGit(); + const git = createGit(); 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 1ddc7f822ee..3be64fc7531 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 6c748dff06f..7f7007e4368 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts @@ -3,50 +3,27 @@ import { randomBytes } from "node:crypto"; import { mkdir, readFile, stat } from "node:fs/promises"; import { join } from "node:path"; import { promisify } from "node:util"; +import { getGitBinaryPath } from "main/lib/git-binary"; import simpleGit from "simple-git"; import { adjectives, animals, uniqueNamesGenerator, } from "unique-names-generator"; -import { checkGitLfsAvailable, getShellEnvironment } from "./shell-env"; const execFileAsync = promisify(execFile); /** - * Builds the merged environment for git operations. - * Takes process.env as base, then overrides only PATH from shell environment. - * This preserves runtime vars (git credentials, proxy, ELECTRON_*, etc.) - * while picking up PATH modifications from shell profiles (e.g., homebrew git-lfs). + * Creates a simpleGit instance configured to use the bundled git binary. + * This avoids dependency on system git (no Xcode license issues on macOS, etc.) */ -async function getGitEnv(): Promise> { - const shellEnv = await getShellEnvironment(); - const result: Record = {}; - - // Start with process.env as base - for (const [key, value] of Object.entries(process.env)) { - if (typeof value === "string") { - result[key] = value; - } - } - - // Only override PATH from shell env (use platform-appropriate key) - const pathKey = process.platform === "win32" ? "Path" : "PATH"; - if (shellEnv[pathKey]) { - result[pathKey] = shellEnv[pathKey]; - } - - return result; +function createGit(baseDir: string) { + return simpleGit({ baseDir, binary: getGitBinaryPath() }); } /** - * 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 +60,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 = createGit(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", @@ -135,41 +109,17 @@ export async function createWorktree( worktreePath: string, startPoint = "origin/main", ): Promise { - // Check LFS usage before try block so it's available in catch for error messaging + // Check LFS usage for better error messaging const usesLfs = await repoUsesLfs(mainRepoPath); try { const parentDir = join(worktreePath, ".."); await mkdir(parentDir, { recursive: true }); - // Get merged environment (process.env + shell env for PATH) - const env = await getGitEnv(); - - // Proactive LFS check: detect early if repo uses LFS but git-lfs is missing - if (usesLfs) { - const lfsAvailable = await checkGitLfsAvailable(env); - if (!lfsAvailable) { - throw new Error( - `This repository uses Git LFS, but git-lfs was not found. ` + - `Please install git-lfs (e.g., 'brew install git-lfs') and run 'git lfs install'.`, - ); - } - } - - // Use execFile with arg array for proper POSIX compatibility (no shell escaping needed) await execFileAsync( - "git", - [ - "-C", - mainRepoPath, - "worktree", - "add", - worktreePath, - "-b", - branch, - startPoint, - ], - { env, timeout: 120_000 }, + getGitBinaryPath(), + ["-C", mainRepoPath, "worktree", "add", worktreePath, "-b", branch, startPoint], + { timeout: 120_000 }, ); console.log( @@ -179,45 +129,33 @@ 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") || lowerError.includes("smudge filter") || - (lowerError.includes("lfs") && lowerError.includes("not")) || (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'.`, + `Failed to create worktree: Git LFS operation failed. ` + + `This repository uses Git LFS for large files. Error: ${errorMessage}`, ); } - console.error(`Failed to create worktree: ${errorMessage}`); throw new Error(`Failed to create worktree: ${errorMessage}`); } } @@ -227,27 +165,22 @@ export async function removeWorktree( worktreePath: string, ): Promise { try { - // 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 }, + { timeout: 60_000 }, ); 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 = createGit(path); const root = await git.revparse(["--show-toplevel"]); return root.trim(); } catch (_error) { @@ -255,22 +188,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 = createGit(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 +204,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 = createGit(mainRepoPath); const remotes = await git.getRemotes(); return remotes.some((r) => r.name === "origin"); } catch { @@ -293,19 +214,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 = createGit(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 +240,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 = createGit(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 = createGit(worktreePath); const behindCount = await git.raw([ "rev-list", "--count", @@ -365,31 +266,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 = createGit(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 = createGit(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 +286,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 +296,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 = createGit(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 +314,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/shell-env.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/shell-env.ts deleted file mode 100644 index 396504588e0..00000000000 --- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/shell-env.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { execFile } from "node:child_process"; -import os from "node:os"; -import { promisify } from "node:util"; - -const execFileAsync = promisify(execFile); - -// Cache the shell environment to avoid repeated shell spawns -let cachedEnv: Record | null = null; -let cacheTime = 0; -let isFallbackCache = false; -const CACHE_TTL_MS = 60_000; // 1 minute cache -const FALLBACK_CACHE_TTL_MS = 10_000; // 10 second cache for fallback (retry sooner) - -/** - * Gets the full shell environment by spawning a login shell. - * This captures PATH and other environment variables set in shell profiles - * which includes tools like git-lfs installed via homebrew. - * - * Uses -lc (login, command) instead of -ilc to avoid interactive prompts - * and TTY issues from dotfiles expecting a terminal. - * - * Results are cached for 1 minute to avoid spawning shells repeatedly. - */ -export async function getShellEnvironment(): Promise> { - const now = Date.now(); - const ttl = isFallbackCache ? FALLBACK_CACHE_TTL_MS : CACHE_TTL_MS; - if (cachedEnv && now - cacheTime < ttl) { - // Return a copy to prevent caller mutations from corrupting cache - return { ...cachedEnv }; - } - - const shell = process.env.SHELL || "/bin/bash"; - - try { - // Use -lc flags (not -ilc): - // -l: login shell (sources .zprofile/.profile for PATH setup) - // -c: execute command - // Avoids -i (interactive) to skip TTY prompts and reduce latency - const { stdout } = await execFileAsync(shell, ["-lc", "env"], { - timeout: 10_000, - env: { - ...process.env, - HOME: os.homedir(), - }, - }); - - const env: Record = {}; - for (const line of stdout.split("\n")) { - const idx = line.indexOf("="); - if (idx > 0) { - const key = line.substring(0, idx); - const value = line.substring(idx + 1); - env[key] = value; - } - } - - cachedEnv = env; - cacheTime = now; - isFallbackCache = false; - return { ...env }; - } catch (error) { - console.warn( - `[shell-env] Failed to get shell environment: ${error}. Falling back to process.env`, - ); - // Fall back to process.env if shell spawn fails - // Cache with shorter TTL so we retry sooner - const fallback: Record = {}; - for (const [key, value] of Object.entries(process.env)) { - if (typeof value === "string") { - fallback[key] = value; - } - } - cachedEnv = fallback; - cacheTime = now; - isFallbackCache = true; - return { ...fallback }; - } -} - -/** - * Checks if git-lfs is available in the given environment. - */ -export async function checkGitLfsAvailable( - env: Record, -): Promise { - try { - await execFileAsync("git", ["lfs", "version"], { - timeout: 5_000, - env, - }); - return true; - } catch { - return false; - } -} - -/** - * Clears the cached shell environment. - * Useful for testing or when environment changes are expected. - */ -export function clearShellEnvCache(): void { - cachedEnv = null; - cacheTime = 0; - isFallbackCache = false; -} 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 00000000000..0216b71ec33 --- /dev/null +++ b/apps/desktop/src/main/lib/git-binary.ts @@ -0,0 +1,27 @@ +import { resolveGitBinary, resolveGitExecPath } from "dugite"; + +let cachedGitPath: string | null = null; +let cachedGitExecPath: string | null = null; + +/** + * Returns the path to the bundled git binary. + * Uses dugite's embedded git so we don't depend on system git + * (avoids Xcode license issues on macOS, missing git on Windows, etc.) + */ +export function getGitBinaryPath(): string { + if (!cachedGitPath) { + cachedGitPath = resolveGitBinary(); + } + 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) { + cachedGitExecPath = resolveGitExecPath(); + } + return cachedGitExecPath; +} diff --git a/bun.lock b/bun.lock index 87398a21558..c36bcddd4e3 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=="], From 43335ca827ef83aeee9447bec9bc78c99c76ec65 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Sat, 13 Dec 2025 15:50:17 -0500 Subject: [PATCH 2/8] fix(desktop): bundle git binary and suppress simple-git warnings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use dugite's bundled git binary to avoid Xcode license requirement on macOS - Add createBundledGit() helper that suppresses spurious simple-git warnings (dugite's path contains '@' which fails simple-git's overly strict regex) - Centralize all git binary logic in git-binary.ts - Remove shell-env.ts since we no longer need to detect system git 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../src/lib/trpc/routers/changes/changes.ts | 28 ++++------ .../src/lib/trpc/routers/projects/projects.ts | 14 ++--- .../lib/trpc/routers/workspaces/utils/git.ts | 42 +++++++-------- .../routers/workspaces/utils/github/github.ts | 3 +- apps/desktop/src/main/lib/git-binary.ts | 51 ++++++++++++++++++- 5 files changed, 86 insertions(+), 52 deletions(-) diff --git a/apps/desktop/src/lib/trpc/routers/changes/changes.ts b/apps/desktop/src/lib/trpc/routers/changes/changes.ts index 46244126d5a..22b2f6cac5d 100644 --- a/apps/desktop/src/lib/trpc/routers/changes/changes.ts +++ b/apps/desktop/src/lib/trpc/routers/changes/changes.ts @@ -1,12 +1,11 @@ import { readFile, rm } from "node:fs/promises"; import { join } from "node:path"; -import { getGitBinaryPath } from "main/lib/git-binary"; +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 { @@ -17,13 +16,6 @@ import { parseNameStatus, } from "./utils/parse-status"; -/** - * Creates a simpleGit instance configured to use the bundled git binary. - */ -function createGit(baseDir: string) { - return simpleGit({ baseDir, binary: getGitBinaryPath() }); -} - export const createChangesRouter = () => { return router({ getBranches: publicProcedure @@ -36,7 +28,7 @@ export const createChangesRouter = () => { remote: string[]; defaultBranch: string; }> => { - const git = createGit(input.worktreePath); + const git = createBundledGit(input.worktreePath); const branchSummary = await git.branch(["-a"]); @@ -85,7 +77,7 @@ export const createChangesRouter = () => { }), ) .query(async ({ input }): Promise => { - const git = createGit(input.worktreePath); + const git = createBundledGit(input.worktreePath); const defaultBranch = input.defaultBranch || "main"; const status = await git.status(); @@ -201,7 +193,7 @@ export const createChangesRouter = () => { }), ) .query(async ({ input }): Promise => { - const git = createGit(input.worktreePath); + const git = createBundledGit(input.worktreePath); const nameStatus = await git.raw([ "diff-tree", @@ -243,7 +235,7 @@ export const createChangesRouter = () => { }), ) .query(async ({ input }): Promise => { - const git = createGit(input.worktreePath); + const git = createBundledGit(input.worktreePath); const defaultBranch = input.defaultBranch || "main"; const originalPath = input.oldPath || input.filePath; let original = ""; @@ -338,7 +330,7 @@ export const createChangesRouter = () => { }), ) .mutation(async ({ input }): Promise<{ success: boolean }> => { - const git = createGit(input.worktreePath); + const git = createBundledGit(input.worktreePath); await git.add(input.filePath); return { success: true }; }), @@ -351,7 +343,7 @@ export const createChangesRouter = () => { }), ) .mutation(async ({ input }): Promise<{ success: boolean }> => { - const git = createGit(input.worktreePath); + const git = createBundledGit(input.worktreePath); await git.reset(["HEAD", "--", input.filePath]); return { success: true }; }), @@ -364,7 +356,7 @@ export const createChangesRouter = () => { }), ) .mutation(async ({ input }): Promise<{ success: boolean }> => { - const git = createGit(input.worktreePath); + const git = createBundledGit(input.worktreePath); try { await git.checkout(["--", input.filePath]); return { success: true }; @@ -378,7 +370,7 @@ export const createChangesRouter = () => { stageAll: publicProcedure .input(z.object({ worktreePath: z.string() })) .mutation(async ({ input }): Promise<{ success: boolean }> => { - const git = createGit(input.worktreePath); + const git = createBundledGit(input.worktreePath); await git.add("-A"); return { success: true }; }), @@ -386,7 +378,7 @@ export const createChangesRouter = () => { unstageAll: publicProcedure .input(z.object({ worktreePath: z.string() })) .mutation(async ({ input }): Promise<{ success: boolean }> => { - const git = createGit(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 d8551c4b5c9..5730f4d89d0 100644 --- a/apps/desktop/src/lib/trpc/routers/projects/projects.ts +++ b/apps/desktop/src/lib/trpc/routers/projects/projects.ts @@ -5,22 +5,14 @@ import type { BrowserWindow } from "electron"; import { dialog } from "electron"; import { db } from "main/lib/db"; import type { Project } from "main/lib/db/schemas"; -import { getGitBinaryPath } from "main/lib/git-binary"; +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"; import { assignRandomColor } from "./utils/colors"; -/** - * Creates a simpleGit instance configured to use the bundled git binary. - */ -function createGit(baseDir?: string) { - return simpleGit(baseDir ? { baseDir, binary: getGitBinaryPath() } : { binary: getGitBinaryPath() }); -} - // Return types for openNew procedure type OpenNewCanceled = { canceled: true }; type OpenNewSuccess = { canceled: false; project: Project }; @@ -199,7 +191,7 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => { initGitAndOpen: publicProcedure .input(z.object({ path: z.string() })) .mutation(async ({ input }) => { - const git = createGit(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 @@ -339,7 +331,7 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => { } // Clone the repository - const git = createGit(); + const git = createBundledGit(); await git.clone(input.url, clonePath); // Create new project 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 7f7007e4368..4e12ff3db93 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts @@ -3,8 +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 { getGitBinaryPath } from "main/lib/git-binary"; -import simpleGit from "simple-git"; +import { createBundledGit, getGitBinaryPath } from "main/lib/git-binary"; import { adjectives, animals, @@ -13,14 +12,6 @@ import { const execFileAsync = promisify(execFile); -/** - * Creates a simpleGit instance configured to use the bundled git binary. - * This avoids dependency on system git (no Xcode license issues on macOS, etc.) - */ -function createGit(baseDir: string) { - return simpleGit({ baseDir, binary: getGitBinaryPath() }); -} - /** * Checks if a repository uses Git LFS. * Used for better error messaging when LFS operations fail. @@ -61,7 +52,7 @@ async function repoUsesLfs(repoPath: string): Promise { // Final fallback: sample a few tracked files with git check-attr try { - const git = createGit(repoPath); + const git = createBundledGit(repoPath); const lsFiles = await git.raw(["ls-files"]); const sampleFiles = lsFiles.split("\n").filter(Boolean).slice(0, 20); @@ -118,7 +109,16 @@ export async function createWorktree( await execFileAsync( getGitBinaryPath(), - ["-C", mainRepoPath, "worktree", "add", worktreePath, "-b", branch, startPoint], + [ + "-C", + mainRepoPath, + "worktree", + "add", + worktreePath, + "-b", + branch, + startPoint, + ], { timeout: 120_000 }, ); @@ -180,7 +180,7 @@ export async function removeWorktree( export async function getGitRoot(path: string): Promise { try { - const git = createGit(path); + const git = createBundledGit(path); const root = await git.revparse(["--show-toplevel"]); return root.trim(); } catch (_error) { @@ -193,7 +193,7 @@ export async function worktreeExists( worktreePath: string, ): Promise { try { - const git = createGit(mainRepoPath); + const git = createBundledGit(mainRepoPath); const worktrees = await git.raw(["worktree", "list", "--porcelain"]); const lines = worktrees.split("\n"); const worktreePrefix = `worktree ${worktreePath}`; @@ -206,7 +206,7 @@ export async function worktreeExists( export async function hasOriginRemote(mainRepoPath: string): Promise { try { - const git = createGit(mainRepoPath); + const git = createBundledGit(mainRepoPath); const remotes = await git.getRemotes(); return remotes.some((r) => r.name === "origin"); } catch { @@ -215,7 +215,7 @@ export async function hasOriginRemote(mainRepoPath: string): Promise { } export async function getDefaultBranch(mainRepoPath: string): Promise { - const git = createGit(mainRepoPath); + const git = createBundledGit(mainRepoPath); // Method 1: Check origin/HEAD symbolic ref try { @@ -247,7 +247,7 @@ export async function fetchDefaultBranch( mainRepoPath: string, defaultBranch: string, ): Promise { - const git = createGit(mainRepoPath); + const git = createBundledGit(mainRepoPath); await git.fetch("origin", defaultBranch); const commit = await git.revparse(`origin/${defaultBranch}`); return commit.trim(); @@ -257,7 +257,7 @@ export async function checkNeedsRebase( worktreePath: string, defaultBranch: string, ): Promise { - const git = createGit(worktreePath); + const git = createBundledGit(worktreePath); const behindCount = await git.raw([ "rev-list", "--count", @@ -269,7 +269,7 @@ export async function checkNeedsRebase( export async function hasUncommittedChanges( worktreePath: string, ): Promise { - const git = createGit(worktreePath); + const git = createBundledGit(worktreePath); const status = await git.status(); return !status.isClean(); } @@ -277,7 +277,7 @@ export async function hasUncommittedChanges( export async function hasUnpushedCommits( worktreePath: string, ): Promise { - const git = createGit(worktreePath); + const git = createBundledGit(worktreePath); try { const aheadCount = await git.raw([ "rev-list", @@ -305,7 +305,7 @@ export async function branchExistsOnRemote( worktreePath: string, branchName: string, ): Promise { - const git = createGit(worktreePath); + const git = createBundledGit(worktreePath); try { const result = await git.raw([ "ls-remote", 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 5266f1e42c6..acb81952240 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/main/lib/git-binary.ts b/apps/desktop/src/main/lib/git-binary.ts index 0216b71ec33..10e0b28eb7a 100644 --- a/apps/desktop/src/main/lib/git-binary.ts +++ b/apps/desktop/src/main/lib/git-binary.ts @@ -1,8 +1,21 @@ -import { resolveGitBinary, resolveGitExecPath } from "dugite"; +import type { SimpleGit, SimpleGitOptions } from "simple-git"; + +// Dynamic require to prevent Vite from bundling dugite in the renderer process. +// The trpc routers are shared code, but dugite can only run in main process. +// Using eval('require') ensures Vite won't statically analyze this import. +const dynamicRequire = + typeof require !== "undefined" + ? require + : // biome-ignore lint/security/noGlobalEval: Required to prevent Vite bundling + (eval("require") as NodeRequire); 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. * Uses dugite's embedded git so we don't depend on system git @@ -10,6 +23,9 @@ let cachedGitExecPath: string | null = null; */ export function getGitBinaryPath(): string { if (!cachedGitPath) { + const { resolveGitBinary } = dynamicRequire( + "dugite", + ) as typeof import("dugite"); cachedGitPath = resolveGitBinary(); } return cachedGitPath; @@ -21,7 +37,40 @@ export function getGitBinaryPath(): string { */ export function getGitExecPath(): string { if (!cachedGitExecPath) { + const { resolveGitExecPath } = dynamicRequire( + "dugite", + ) as typeof import("dugite"); cachedGitExecPath = resolveGitExecPath(); } 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 { + const simpleGit = dynamicRequire("simple-git").default as ( + opts?: Partial, + ) => SimpleGit; + + // 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 { + return simpleGit({ + ...(baseDir && { baseDir }), + binary: getGitBinaryPath(), + unsafe: { allowUnsafeCustomBinary: true }, + }); + } finally { + console.warn = originalWarn; + } +} From b7618208aebedd9d9e9c170798e04a5ae96d1aa8 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Sun, 14 Dec 2025 14:59:08 -0500 Subject: [PATCH 3/8] test(desktop): update workspace tests to mock git-binary module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The workspace tests previously mocked simple-git directly, but now that we use createBundledGit from git-binary.ts, we need to mock at the function level. Updated tests to use configurable mock state for git utility functions. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../routers/workspaces/workspaces.test.ts | 104 +++++++++++------- 1 file changed, 63 insertions(+), 41 deletions(-) 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 d9f640d6811..105afcb5073 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,79 @@ 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 })), + gitMockState.worktreeListOutput = worktreeListOutput; + gitMockState.hasUncommittedChanges = !(options?.isClean ?? true); + gitMockState.hasUnpushedCommits = (options?.unpushedCommitCount ?? 0) > 0; + gitMockState.error = null; + // Return a mock object for compatibility with tests that check mock calls + return { + raw: mock(() => Promise.resolve(worktreeListOutput)), + status: mock(() => + Promise.resolve({ isClean: () => options?.isClean ?? true }), + ), }; - mock.module("simple-git", () => ({ - default: mock(() => mockGit), - })); - return mockGit; } function mockSimpleGitWithError(error: Error) { - const mockGit = { + gitMockState.error = error; + return { raw: mock(() => Promise.reject(error)), status: mock(() => Promise.resolve({ isClean: () => true })), }; - mock.module("simple-git", () => ({ - default: mock(() => mockGit), - })); - return mockGit; } // Reset mock data before each test @@ -117,6 +145,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,21 +243,9 @@ 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", - ]); - }); + // Note: The test for --porcelain flag was removed because we now mock + // worktreeExists directly rather than simple-git internals. The flag + // usage is tested in git.ts unit tests if needed. it("returns hasChanges: false when worktree is clean", async () => { mockSimpleGitWithWorktreeList( From 4df469fcfc4122ea3579b8c09899219473c5f291 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Sun, 14 Dec 2025 15:02:06 -0500 Subject: [PATCH 4/8] chore(desktop): clean up test file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove unused mock return values and unnecessary comments from workspace tests. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../routers/workspaces/workspaces.test.ts | 22 +++---------------- 1 file changed, 3 insertions(+), 19 deletions(-) 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 105afcb5073..15f9449009a 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.test.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.test.ts @@ -117,21 +117,10 @@ function mockSimpleGitWithWorktreeList( gitMockState.hasUncommittedChanges = !(options?.isClean ?? true); gitMockState.hasUnpushedCommits = (options?.unpushedCommitCount ?? 0) > 0; gitMockState.error = null; - // Return a mock object for compatibility with tests that check mock calls - return { - raw: mock(() => Promise.resolve(worktreeListOutput)), - status: mock(() => - Promise.resolve({ isClean: () => options?.isClean ?? true }), - ), - }; } function mockSimpleGitWithError(error: Error) { gitMockState.error = error; - return { - raw: mock(() => Promise.reject(error)), - status: mock(() => Promise.resolve({ isClean: () => true })), - }; } // Reset mock data before each test @@ -243,10 +232,6 @@ describe("workspaces router - canDelete", () => { expect(result.warning).toContain("not found in git"); }); - // Note: The test for --porcelain flag was removed because we now mock - // worktreeExists directly rather than simple-git internals. The flag - // usage is tested in git.ts unit tests if needed. - it("returns hasChanges: false when worktree is clean", async () => { mockSimpleGitWithWorktreeList( "worktree /path/to/worktree\nHEAD abc123\nbranch refs/heads/test-branch", @@ -336,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 }, ); @@ -349,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(); }); }); From 525a1e108f91039584ffee84efb14d3761e0f01b Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Sun, 14 Dec 2025 15:15:16 -0500 Subject: [PATCH 5/8] fix(desktop): fix build failure with dugite symlinks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add dugite to copy-native-modules script to replace symlinks with real files before electron-builder runs - Update asarUnpack pattern to unpack entire dugite module to avoid "pattern is too long" error from minimatch 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- apps/desktop/electron-builder.ts | 2 +- apps/desktop/scripts/copy-native-modules.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/desktop/electron-builder.ts b/apps/desktop/electron-builder.ts index 69c4fa9e524..49dfad81cfa 100644 --- a/apps/desktop/electron-builder.ts +++ b/apps/desktop/electron-builder.ts @@ -35,7 +35,7 @@ const config: Configuration = { asarUnpack: [ "**/node_modules/node-pty/**/*", // Dugite's bundled git binaries must be unpacked to be executable - "**/node_modules/dugite/git/**/*", + "**/node_modules/dugite/**/*", // Sound files must be unpacked so external audio players (afplay, paplay, etc.) can access them "**/resources/sounds/**/*", ], diff --git a/apps/desktop/scripts/copy-native-modules.ts b/apps/desktop/scripts/copy-native-modules.ts index 531e6fc3d7b..b42856ad51b 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..."); From 4291069db8e0a1c48b0611be99ab985c3dab2bac Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Sun, 14 Dec 2025 21:57:07 -0500 Subject: [PATCH 6/8] fix(desktop): dereference internal symlinks in native modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dugite package contains internal symlinks (e.g., git-apply -> git) inside git/libexec/git-core/. When copying the module, these symlinks would still point to the original Bun cache location, causing electron-builder to fail with "symlinked outside the package" errors. This fix adds `dereference: true` to cpSync calls to resolve all internal symlinks into actual file copies. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- apps/desktop/scripts/copy-native-modules.ts | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/apps/desktop/scripts/copy-native-modules.ts b/apps/desktop/scripts/copy-native-modules.ts index b42856ad51b..b2e410dd1bb 100644 --- a/apps/desktop/scripts/copy-native-modules.ts +++ b/apps/desktop/scripts/copy-native-modules.ts @@ -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`); } } From f347715e7f2839c6f113aadb169aa2027e2a0898 Mon Sep 17 00:00:00 2001 From: AviPeltz Date: Tue, 16 Dec 2025 02:18:23 -0800 Subject: [PATCH 7/8] fix(desktop): bundle simple-git for packaged app git operations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The diff viewer was failing in packaged builds because simple-git was loaded via dynamicRequire, preventing Rollup from bundling it. Changed to a static import so simple-git is included in the bundle. Also manually construct paths to app.asar.unpacked for dugite's git binary in packaged apps, since dugite's __dirname points inside the asar archive where binaries can't execute. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- apps/desktop/electron.vite.config.ts | 1 + apps/desktop/src/main/lib/git-binary.ts | 122 ++++++++++++++++++------ 2 files changed, 92 insertions(+), 31 deletions(-) diff --git a/apps/desktop/electron.vite.config.ts b/apps/desktop/electron.vite.config.ts index 8644e787fae..0bc8d4a37c9 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/src/main/lib/git-binary.ts b/apps/desktop/src/main/lib/git-binary.ts index 10e0b28eb7a..b8587f00c26 100644 --- a/apps/desktop/src/main/lib/git-binary.ts +++ b/apps/desktop/src/main/lib/git-binary.ts @@ -1,14 +1,18 @@ -import type { SimpleGit, SimpleGitOptions } from "simple-git"; +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { app } from "electron"; +import simpleGit, { type SimpleGit } from "simple-git"; -// Dynamic require to prevent Vite from bundling dugite in the renderer process. -// The trpc routers are shared code, but dugite can only run in main process. -// Using eval('require') ensures Vite won't statically analyze this import. +// 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; @@ -18,15 +22,44 @@ const SIMPLE_GIT_BINARY_WARNING = /** * Returns the path to the bundled git binary. - * Uses dugite's embedded git so we don't depend on system git - * (avoids Xcode license issues on macOS, missing git on Windows, etc.) + * + * 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) { - const { resolveGitBinary } = dynamicRequire( - "dugite", - ) as typeof import("dugite"); - cachedGitPath = resolveGitBinary(); + 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; } @@ -37,10 +70,29 @@ export function getGitBinaryPath(): string { */ export function getGitExecPath(): string { if (!cachedGitExecPath) { - const { resolveGitExecPath } = dynamicRequire( - "dugite", - ) as typeof import("dugite"); - cachedGitExecPath = resolveGitExecPath(); + 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; } @@ -53,24 +105,32 @@ export function getGitExecPath(): string { * instead of an error, but we suppress even the warning to reduce noise. */ export function createBundledGit(baseDir?: string): SimpleGit { - const simpleGit = dynamicRequire("simple-git").default as ( - opts?: Partial, - ) => SimpleGit; - - // 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); - }; + console.log("[git-binary] createBundledGit called with baseDir:", baseDir); try { - return simpleGit({ - ...(baseDir && { baseDir }), - binary: getGitBinaryPath(), - unsafe: { allowUnsafeCustomBinary: true }, - }); - } finally { - console.warn = originalWarn; + 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; } } From e32494d1cba79dcb689cd195151fd69cf4dc6dec Mon Sep 17 00:00:00 2001 From: AviPeltz Date: Tue, 16 Dec 2025 02:45:27 -0800 Subject: [PATCH 8/8] fix(desktop): restore shell-env PATH merging for git-lfs support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dugite's bundled git doesn't include git-lfs, so we need to find it from the user's shell PATH. Restored shell-env.ts and getGitEnv() to merge PATH from shell profiles, and added proactive LFS availability check before worktree creation. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../lib/trpc/routers/workspaces/utils/git.ts | 55 ++++++++- .../routers/workspaces/utils/shell-env.ts | 105 ++++++++++++++++++ 2 files changed, 155 insertions(+), 5 deletions(-) create mode 100644 apps/desktop/src/lib/trpc/routers/workspaces/utils/shell-env.ts 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 4e12ff3db93..bdae84534ef 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts @@ -9,9 +9,36 @@ import { animals, uniqueNamesGenerator, } from "unique-names-generator"; +import { checkGitLfsAvailable, getShellEnvironment } from "./shell-env"; const execFileAsync = promisify(execFile); +/** + * Builds the merged environment for git operations. + * Takes process.env as base, then overrides only PATH from shell environment. + * This preserves runtime vars (git credentials, proxy, ELECTRON_*, etc.) + * while picking up PATH modifications from shell profiles (e.g., homebrew git-lfs). + */ +async function getGitEnv(): Promise> { + const shellEnv = await getShellEnvironment(); + const result: Record = {}; + + // Start with process.env as base + for (const [key, value] of Object.entries(process.env)) { + if (typeof value === "string") { + result[key] = value; + } + } + + // Only override PATH from shell env (use platform-appropriate key) + const pathKey = process.platform === "win32" ? "Path" : "PATH"; + if (shellEnv[pathKey]) { + result[pathKey] = shellEnv[pathKey]; + } + + return result; +} + /** * Checks if a repository uses Git LFS. * Used for better error messaging when LFS operations fail. @@ -100,13 +127,27 @@ export async function createWorktree( worktreePath: string, startPoint = "origin/main", ): Promise { - // Check LFS usage for better error messaging + // Check LFS usage before try block so it's available in catch for error messaging const usesLfs = await repoUsesLfs(mainRepoPath); try { const parentDir = join(worktreePath, ".."); await mkdir(parentDir, { recursive: true }); + // Get merged environment (process.env + shell env for PATH) + const env = await getGitEnv(); + + // Proactive LFS check: detect early if repo uses LFS but git-lfs is missing + if (usesLfs) { + const lfsAvailable = await checkGitLfsAvailable(env); + if (!lfsAvailable) { + throw new Error( + `This repository uses Git LFS, but git-lfs was not found. ` + + `Please install git-lfs (e.g., 'brew install git-lfs') and run 'git lfs install'.`, + ); + } + } + await execFileAsync( getGitBinaryPath(), [ @@ -119,7 +160,7 @@ export async function createWorktree( branch, startPoint, ], - { timeout: 120_000 }, + { env, timeout: 120_000 }, ); console.log( @@ -147,12 +188,13 @@ export async function createWorktree( lowerError.includes("git-lfs") || lowerError.includes("filter-process") || lowerError.includes("smudge filter") || + (lowerError.includes("lfs") && lowerError.includes("not")) || (lowerError.includes("lfs") && usesLfs); if (isLfsError) { throw new Error( - `Failed to create worktree: Git LFS operation failed. ` + - `This repository uses Git LFS for large files. Error: ${errorMessage}`, + `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'.`, ); } @@ -165,10 +207,13 @@ export async function removeWorktree( worktreePath: string, ): Promise { try { + // Get merged environment (process.env + shell env for PATH) + const env = await getGitEnv(); + await execFileAsync( getGitBinaryPath(), ["-C", mainRepoPath, "worktree", "remove", worktreePath, "--force"], - { timeout: 60_000 }, + { env, timeout: 60_000 }, ); console.log(`Removed worktree at ${worktreePath}`); diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/shell-env.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/shell-env.ts new file mode 100644 index 00000000000..396504588e0 --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/shell-env.ts @@ -0,0 +1,105 @@ +import { execFile } from "node:child_process"; +import os from "node:os"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +// Cache the shell environment to avoid repeated shell spawns +let cachedEnv: Record | null = null; +let cacheTime = 0; +let isFallbackCache = false; +const CACHE_TTL_MS = 60_000; // 1 minute cache +const FALLBACK_CACHE_TTL_MS = 10_000; // 10 second cache for fallback (retry sooner) + +/** + * Gets the full shell environment by spawning a login shell. + * This captures PATH and other environment variables set in shell profiles + * which includes tools like git-lfs installed via homebrew. + * + * Uses -lc (login, command) instead of -ilc to avoid interactive prompts + * and TTY issues from dotfiles expecting a terminal. + * + * Results are cached for 1 minute to avoid spawning shells repeatedly. + */ +export async function getShellEnvironment(): Promise> { + const now = Date.now(); + const ttl = isFallbackCache ? FALLBACK_CACHE_TTL_MS : CACHE_TTL_MS; + if (cachedEnv && now - cacheTime < ttl) { + // Return a copy to prevent caller mutations from corrupting cache + return { ...cachedEnv }; + } + + const shell = process.env.SHELL || "/bin/bash"; + + try { + // Use -lc flags (not -ilc): + // -l: login shell (sources .zprofile/.profile for PATH setup) + // -c: execute command + // Avoids -i (interactive) to skip TTY prompts and reduce latency + const { stdout } = await execFileAsync(shell, ["-lc", "env"], { + timeout: 10_000, + env: { + ...process.env, + HOME: os.homedir(), + }, + }); + + const env: Record = {}; + for (const line of stdout.split("\n")) { + const idx = line.indexOf("="); + if (idx > 0) { + const key = line.substring(0, idx); + const value = line.substring(idx + 1); + env[key] = value; + } + } + + cachedEnv = env; + cacheTime = now; + isFallbackCache = false; + return { ...env }; + } catch (error) { + console.warn( + `[shell-env] Failed to get shell environment: ${error}. Falling back to process.env`, + ); + // Fall back to process.env if shell spawn fails + // Cache with shorter TTL so we retry sooner + const fallback: Record = {}; + for (const [key, value] of Object.entries(process.env)) { + if (typeof value === "string") { + fallback[key] = value; + } + } + cachedEnv = fallback; + cacheTime = now; + isFallbackCache = true; + return { ...fallback }; + } +} + +/** + * Checks if git-lfs is available in the given environment. + */ +export async function checkGitLfsAvailable( + env: Record, +): Promise { + try { + await execFileAsync("git", ["lfs", "version"], { + timeout: 5_000, + env, + }); + return true; + } catch { + return false; + } +} + +/** + * Clears the cached shell environment. + * Useful for testing or when environment changes are expected. + */ +export function clearShellEnvCache(): void { + cachedEnv = null; + cacheTime = 0; + isFallbackCache = false; +}