From 938b0ba501f3e567ed8f931b8d8c1c8e02b255c0 Mon Sep 17 00:00:00 2001 From: Jan Burzinski <156842394+janburzinski@users.noreply.github.com> Date: Wed, 24 Jun 2026 18:54:35 +0200 Subject: [PATCH 1/8] fix(git): prefer PATH git before fallback --- .../src/main/core/utils/exec.test.ts | 37 +++++++++++++++++++ .../src/main/core/utils/exec.ts | 24 +++++++++++- 2 files changed, 59 insertions(+), 2 deletions(-) create mode 100644 apps/emdash-desktop/src/main/core/utils/exec.test.ts diff --git a/apps/emdash-desktop/src/main/core/utils/exec.test.ts b/apps/emdash-desktop/src/main/core/utils/exec.test.ts new file mode 100644 index 0000000000..3a9f263ee1 --- /dev/null +++ b/apps/emdash-desktop/src/main/core/utils/exec.test.ts @@ -0,0 +1,37 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { resolveGitBin } from './exec'; + +let tempDir: string; + +beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'emdash-git-bin-')); +}); + +afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); +}); + +function executableGit(directory: string): string { + fs.mkdirSync(directory, { recursive: true }); + const gitPath = path.join(directory, 'git'); + fs.writeFileSync(gitPath, '#!/bin/sh\nexit 0\n', { mode: 0o755 }); + return gitPath; +} + +describe('resolveGitBin', () => { + it('prefers explicit GIT_PATH over PATH git', () => { + const pathGit = executableGit(path.join(tempDir, 'path-bin')); + const explicitGit = executableGit(path.join(tempDir, 'explicit-bin')); + + expect(resolveGitBin({ GIT_PATH: explicitGit, PATH: path.dirname(pathGit) })).toBe(explicitGit); + }); + + it('prefers PATH git before hardcoded fallbacks', () => { + const pathGit = executableGit(path.join(tempDir, 'path-bin')); + + expect(resolveGitBin({ PATH: path.dirname(pathGit) })).toBe(pathGit); + }); +}); diff --git a/apps/emdash-desktop/src/main/core/utils/exec.ts b/apps/emdash-desktop/src/main/core/utils/exec.ts index da766ac7cd..6f7024f69c 100644 --- a/apps/emdash-desktop/src/main/core/utils/exec.ts +++ b/apps/emdash-desktop/src/main/core/utils/exec.ts @@ -1,8 +1,28 @@ import fs from 'node:fs'; +import path from 'node:path'; -function resolveGitBin(): string { +function findExecutableOnPath(name: string, env: NodeJS.ProcessEnv = process.env): string | null { + const pathValue = env.PATH; + if (!pathValue) return null; + + for (const directory of pathValue.split(path.delimiter)) { + if (!directory) continue; + + const candidate = path.join(directory, name); + try { + fs.accessSync(candidate, fs.constants.X_OK); + return candidate; + } catch {} + } + + return null; +} + +export function resolveGitBin(env: NodeJS.ProcessEnv = process.env): string { + const pathGit = findExecutableOnPath('git', env); const candidates = [ - (process.env.GIT_PATH || '').trim(), + (env.GIT_PATH || '').trim(), + pathGit, '/opt/homebrew/bin/git', '/usr/local/bin/git', '/usr/bin/git', From 9bca7b82ddafef683091cfe139a477d2b813adb7 Mon Sep 17 00:00:00 2001 From: Jan Burzinski <156842394+janburzinski@users.noreply.github.com> Date: Wed, 24 Jun 2026 19:09:40 +0200 Subject: [PATCH 2/8] fix(git): resolve Windows PATH executable --- .../src/main/core/utils/exec.test.ts | 25 ++++++++++++++++-- .../src/main/core/utils/exec.ts | 26 +++++++++++++++---- 2 files changed, 44 insertions(+), 7 deletions(-) diff --git a/apps/emdash-desktop/src/main/core/utils/exec.test.ts b/apps/emdash-desktop/src/main/core/utils/exec.test.ts index 3a9f263ee1..98c1e01717 100644 --- a/apps/emdash-desktop/src/main/core/utils/exec.test.ts +++ b/apps/emdash-desktop/src/main/core/utils/exec.test.ts @@ -4,6 +4,7 @@ import path from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { resolveGitBin } from './exec'; +const originalPlatform = process.platform; let tempDir: string; beforeEach(() => { @@ -11,12 +12,17 @@ beforeEach(() => { }); afterEach(() => { + Object.defineProperty(process, 'platform', { value: originalPlatform }); fs.rmSync(tempDir, { recursive: true, force: true }); }); -function executableGit(directory: string): string { +function setPlatform(platform: NodeJS.Platform): void { + Object.defineProperty(process, 'platform', { value: platform }); +} + +function executableGit(directory: string, filename = 'git'): string { fs.mkdirSync(directory, { recursive: true }); - const gitPath = path.join(directory, 'git'); + const gitPath = path.join(directory, filename); fs.writeFileSync(gitPath, '#!/bin/sh\nexit 0\n', { mode: 0o755 }); return gitPath; } @@ -34,4 +40,19 @@ describe('resolveGitBin', () => { expect(resolveGitBin({ PATH: path.dirname(pathGit) })).toBe(pathGit); }); + + it('skips invalid explicit GIT_PATH and falls back to PATH git', () => { + const pathGit = executableGit(path.join(tempDir, 'path-bin')); + + expect(resolveGitBin({ GIT_PATH: '/does/not/exist', PATH: path.dirname(pathGit) })).toBe( + pathGit + ); + }); + + it('finds PATHEXT executables on Windows PATH', () => { + setPlatform('win32'); + const pathGit = executableGit(path.join(tempDir, 'path-bin'), 'git.exe'); + + expect(resolveGitBin({ PATH: path.dirname(pathGit), PATHEXT: '.exe' })).toBe(pathGit); + }); }); diff --git a/apps/emdash-desktop/src/main/core/utils/exec.ts b/apps/emdash-desktop/src/main/core/utils/exec.ts index 6f7024f69c..663017c36c 100644 --- a/apps/emdash-desktop/src/main/core/utils/exec.ts +++ b/apps/emdash-desktop/src/main/core/utils/exec.ts @@ -1,18 +1,34 @@ import fs from 'node:fs'; import path from 'node:path'; +function windowsExecutableExtensions(env: NodeJS.ProcessEnv): string[] { + const pathExt = env.PATHEXT || '.COM;.EXE;.BAT;.CMD'; + return pathExt + .split(';') + .map((extension) => extension.trim().toLowerCase()) + .filter(Boolean) + .map((extension) => (extension.startsWith('.') ? extension : `.${extension}`)); +} + function findExecutableOnPath(name: string, env: NodeJS.ProcessEnv = process.env): string | null { const pathValue = env.PATH; if (!pathValue) return null; + const extensions = + process.platform === 'win32' && !path.extname(name) + ? ['', ...windowsExecutableExtensions(env)] + : ['']; + for (const directory of pathValue.split(path.delimiter)) { if (!directory) continue; - const candidate = path.join(directory, name); - try { - fs.accessSync(candidate, fs.constants.X_OK); - return candidate; - } catch {} + for (const extension of extensions) { + const candidate = path.join(directory, `${name}${extension}`); + try { + fs.accessSync(candidate, fs.constants.X_OK); + return candidate; + } catch {} + } } return null; From 04ac3e09a18e4b59c82e6c499a130c26e5160c24 Mon Sep 17 00:00:00 2001 From: Jan Burzinski <156842394+janburzinski@users.noreply.github.com> Date: Wed, 24 Jun 2026 21:16:30 +0200 Subject: [PATCH 3/8] feat(deps): add git host dependency --- .../dependencies/agent-update-service.test.ts | 36 ++++++++ .../core/dependencies/agent-update-service.ts | 8 +- .../main/core/dependencies/registry.test.ts | 6 ++ .../src/main/core/dependencies/registry.ts | 14 +-- .../host-dependencies/core-dependencies.ts | 43 +++++++++ .../runtime/host-dependency-manager.test.ts | 88 +++++++++++++++++++ .../runtime/host-dependency-manager.ts | 81 +++++++++++++---- .../src/host-dependencies/runtime/index.ts | 1 + .../src/host-dependencies/runtime/types.ts | 2 +- 9 files changed, 253 insertions(+), 26 deletions(-) create mode 100644 packages/core/src/host-dependencies/core-dependencies.ts diff --git a/apps/emdash-desktop/src/main/core/dependencies/agent-update-service.test.ts b/apps/emdash-desktop/src/main/core/dependencies/agent-update-service.test.ts index f59d0589e6..4412391a01 100644 --- a/apps/emdash-desktop/src/main/core/dependencies/agent-update-service.test.ts +++ b/apps/emdash-desktop/src/main/core/dependencies/agent-update-service.test.ts @@ -37,6 +37,7 @@ function makeManager(): { const npmDescriptor = { id: 'codex', + category: 'agent' as const, updates: { kind: 'supported' as const, releaseSource: { kind: 'npm' as const, package: '@openai/codex' }, @@ -128,6 +129,39 @@ describe('AgentUpdateService', () => { vi.unstubAllGlobals(); }); + it('ignores core dependency host state events', () => { + vi.mocked(getDependencyDescriptor).mockReturnValue({ + id: 'git', + category: 'core', + updates: { kind: 'none' }, + } as never); + + const service = new AgentUpdateService(); + const { manager, emitStatus } = makeManager(); + service.attach(manager as unknown as HostDependencyManager, undefined); + + emitStatus({ + id: 'git' as DependencyId, + state: { + id: 'git' as DependencyId, + category: 'core', + status: 'available', + version: '2.45.0', + path: '/opt/homebrew/bin/git', + checkedAt: 1000, + }, + hostDependency: { + hostId: 'local', + dependencyId: 'git' as DependencyId, + used: { kind: 'auto' as const }, + installations: [], + }, + }); + + expect(toAgentInstallationStatus).not.toHaveBeenCalled(); + expect(events.emit).not.toHaveBeenCalled(); + }); + it('getUpdateInfo returns null/false before any fetch', () => { const service = new AgentUpdateService(); @@ -210,6 +244,7 @@ describe('AgentUpdateService', () => { it('enrichHostDependency: unknown+package-manager => updateAvailable=false', async () => { const pmDescriptor = { id: 'amp', + category: 'agent' as const, updates: { kind: 'supported' as const, releaseSource: { kind: 'npm' as const, package: '@ampcode/cli' }, @@ -254,6 +289,7 @@ describe('AgentUpdateService', () => { it('enrichHostDependency: unknown+cli => updateAvailable=true', async () => { const cliDescriptor = { id: 'claude', + category: 'agent' as const, updates: { kind: 'supported' as const, releaseSource: { kind: 'github' as const, repo: 'anthropics/claude-code' }, diff --git a/apps/emdash-desktop/src/main/core/dependencies/agent-update-service.ts b/apps/emdash-desktop/src/main/core/dependencies/agent-update-service.ts index 6e2cf13a97..7237b51dce 100644 --- a/apps/emdash-desktop/src/main/core/dependencies/agent-update-service.ts +++ b/apps/emdash-desktop/src/main/core/dependencies/agent-update-service.ts @@ -107,6 +107,9 @@ export class AgentUpdateService { } private handleManagerEvent(event: DependencyStatusUpdatedEvent, connectionId?: string): void { + const descriptor = getDependencyDescriptor(event.id); + if (descriptor?.category !== 'agent') return; + const storageKey = `${connectionId ?? 'local'}:${event.id}`; this.storedEvents.set(storageKey, { raw: event, installedVersion: event.state.version }); @@ -119,10 +122,7 @@ export class AgentUpdateService { // Emit the raw event first (no update info yet), then kick off async fetch this.emitEnrichedEvent(event, null, connectionId); - const descriptor = getDependencyDescriptor(event.id); - if (descriptor) { - void this.fetchLatestAndReemit(event.id as DependencyId, descriptor, connectionId); - } + void this.fetchLatestAndReemit(event.id as DependencyId, descriptor, connectionId); } } diff --git a/apps/emdash-desktop/src/main/core/dependencies/registry.test.ts b/apps/emdash-desktop/src/main/core/dependencies/registry.test.ts index 9a76589110..d39eb7efcf 100644 --- a/apps/emdash-desktop/src/main/core/dependencies/registry.test.ts +++ b/apps/emdash-desktop/src/main/core/dependencies/registry.test.ts @@ -39,6 +39,12 @@ describe('buildDescriptorFromProvider', () => { }); describe('DEPENDENCIES', () => { + it('contains core dependencies', () => { + expect(DEPENDENCIES).toContainEqual( + expect.objectContaining({ id: 'git', category: 'core', commands: ['git'] }) + ); + }); + it('contains an entry for every registered plugin', () => { const pluginIds = pluginRegistry.getAll().map((p: CLIAgentPluginProvider) => p.metadata.id); for (const id of pluginIds) { diff --git a/apps/emdash-desktop/src/main/core/dependencies/registry.ts b/apps/emdash-desktop/src/main/core/dependencies/registry.ts index 490ade04f2..ae2f53b629 100644 --- a/apps/emdash-desktop/src/main/core/dependencies/registry.ts +++ b/apps/emdash-desktop/src/main/core/dependencies/registry.ts @@ -1,8 +1,9 @@ import type { CLIAgentPluginProvider } from '@emdash/core/agents/plugins'; -import type { - DependencyDescriptor, - DependencyStatus, - ProbeResult, +import { + CORE_DEPENDENCIES, + type DependencyDescriptor, + type DependencyStatus, + type ProbeResult, } from '@emdash/core/deps/runtime'; import { pluginRegistry } from '@emdash/plugins/agents'; @@ -67,7 +68,10 @@ function buildAgentDependencies(): DependencyDescriptor[] { return pluginRegistry.getAll().map(buildDescriptorFromProvider); } -export const DEPENDENCIES: DependencyDescriptor[] = buildAgentDependencies(); +export const DEPENDENCIES: DependencyDescriptor[] = [ + ...CORE_DEPENDENCIES, + ...buildAgentDependencies(), +]; export const AGENT_DEPENDENCIES = DEPENDENCIES.filter( (dependency) => dependency.category === 'agent' ); diff --git a/packages/core/src/host-dependencies/core-dependencies.ts b/packages/core/src/host-dependencies/core-dependencies.ts new file mode 100644 index 0000000000..5d2001411c --- /dev/null +++ b/packages/core/src/host-dependencies/core-dependencies.ts @@ -0,0 +1,43 @@ +import type { DependencyDescriptor } from './runtime/types'; + +export const GIT_DEPENDENCY_DESCRIPTOR: DependencyDescriptor = { + id: 'git', + name: 'Git', + category: 'core', + commands: ['git'], + versionArgs: ['--version'], + docUrl: 'https://git-scm.com/downloads', + installCommands: { + macos: [ + { + method: 'homebrew', + command: 'brew install git', + updateCommand: 'brew upgrade git', + uninstallCommand: 'brew uninstall git', + recommended: true, + }, + ], + linux: [ + { + method: 'apt', + command: 'sudo apt-get update && sudo apt-get install -y git', + updateCommand: 'sudo apt-get update && sudo apt-get install --only-upgrade -y git', + uninstallCommand: 'sudo apt-get remove -y git', + recommended: true, + }, + ], + windows: [ + { + method: 'winget', + command: 'winget install --id Git.Git -e', + updateCommand: 'winget upgrade --id Git.Git -e', + uninstallCommand: 'winget uninstall --id Git.Git -e', + recommended: true, + }, + ], + }, + updates: { kind: 'none' }, + uninstall: { kind: 'none' }, +}; + +export const CORE_DEPENDENCIES: DependencyDescriptor[] = [GIT_DEPENDENCY_DESCRIPTOR]; diff --git a/packages/core/src/host-dependencies/runtime/host-dependency-manager.test.ts b/packages/core/src/host-dependencies/runtime/host-dependency-manager.test.ts index 0797063433..54cf8ddf17 100644 --- a/packages/core/src/host-dependencies/runtime/host-dependency-manager.test.ts +++ b/packages/core/src/host-dependencies/runtime/host-dependency-manager.test.ts @@ -376,6 +376,53 @@ describe('HostDependencyManager install', () => { }) ); }); + + it('builds host dependency installation state for core dependencies', async () => { + const gitCtx = makeCtx(async (command, args = []) => { + if (command === 'which' && args[0] === '-a' && args[1] === 'git') { + return { stdout: '/opt/homebrew/bin/git\n', stderr: '' }; + } + if (command === 'which' && args[0] === 'git') { + return { stdout: '/opt/homebrew/bin/git\n', stderr: '' }; + } + if (command === 'realpath') { + return { stdout: `${args[0]}\n`, stderr: '' }; + } + if (command === '/opt/homebrew/bin/git' && args[0] === '--version') { + return { stdout: 'git version 2.45.0\n', stderr: '' }; + } + throw new Error('missing'); + }); + const manager = new HostDependencyManager(gitCtx, { + dependencies: TEST_DEPENDENCIES, + installMethodDetector: unknownDetector, + }); + const events: unknown[] = []; + manager.onStatusUpdated.subscribe((event) => events.push(event)); + + await manager.probe('git'); + await new Promise((resolve) => setTimeout(resolve, 50)); + + const hostDependency = manager.getHostDependency('git'); + expect(hostDependency).toEqual( + expect.objectContaining({ + dependencyId: 'git', + installations: [ + expect.objectContaining({ + pathEntry: '/opt/homebrew/bin/git', + status: 'available', + version: '2.45.0', + }), + ], + }) + ); + expect(events).toContainEqual( + expect.objectContaining({ + id: 'git', + hostDependency: expect.objectContaining({ dependencyId: 'git' }), + }) + ); + }); }); describe('HostDependencyManager update', () => { @@ -849,6 +896,47 @@ describe('HostDependencyManager probeOverride', () => { }); describe('HostDependencyManager enumeration', () => { + it('includes a pinned installation even when it is not on PATH', async () => { + const pinnedPath = '/custom/git'; + const ctx = makeCtx(async (command, args = []) => { + if (command === 'which' && args[0] === 'git') { + return { stdout: '/usr/bin/git\n', stderr: '' }; + } + if (command === 'which' && args[0] === '-a' && args[1] === 'git') { + return { stdout: '/usr/bin/git\n', stderr: '' }; + } + if (command === 'realpath') { + return { stdout: `${args[0]}\n`, stderr: '' }; + } + if (command === '/usr/bin/git' && args[0] === '--version') { + return { stdout: 'git version 2.40.0\n', stderr: '' }; + } + if (command === pinnedPath && args[0] === '--version') { + return { stdout: 'git version 2.45.0\n', stderr: '' }; + } + throw new Error(`Unexpected: ${command} ${args.join(' ')}`); + }); + const manager = new HostDependencyManager(ctx, { + dependencies: TEST_DEPENDENCIES, + getSelection: async (id) => + id === 'git' ? { kind: 'pinned' as const, realpath: pinnedPath } : null, + installMethodDetector: unknownDetector, + }); + + await manager.probe('git'); + + const hostDependency = manager.getHostDependency('git'); + expect(hostDependency?.used).toEqual({ kind: 'pinned', realpath: pinnedPath }); + expect(hostDependency?.installations).toContainEqual( + expect.objectContaining({ + realpath: pinnedPath, + pathEntry: null, + status: 'available', + version: '2.45.0', + }) + ); + }); + it('enumerates multiple installations from which -a and dedupes by realpath', async () => { const BREW_PATH = '/opt/homebrew/bin/codex'; const BREW_REAL = '/opt/homebrew/Cellar/codex/1.0.0/bin/codex'; diff --git a/packages/core/src/host-dependencies/runtime/host-dependency-manager.ts b/packages/core/src/host-dependencies/runtime/host-dependency-manager.ts index 304e484492..b199733957 100644 --- a/packages/core/src/host-dependencies/runtime/host-dependency-manager.ts +++ b/packages/core/src/host-dependencies/runtime/host-dependency-manager.ts @@ -154,7 +154,7 @@ export type HostDependencyManagerOptions = { */ export class HostDependencyManager { private state = new Map(); - /** Host-scoped installation data, populated for agent-category deps during probe(). */ + /** Host-scoped installation data, populated for every dependency during probe(). */ private hostState = new Map(); private readonly ctx: IExecutionContext; @@ -221,7 +221,7 @@ export class HostDependencyManager { }); } - /** Returns the host-scoped installation data for an agent dep, if available. */ + /** Returns the host-scoped installation data for a dependency, if available. */ getHostDependency(id: DependencyId): HostDependency | undefined { return this.hostState.get(id); } @@ -231,8 +231,8 @@ export class HostDependencyManager { * 1. Resolve path (fast, ~5ms) — fires onStatusUpdated immediately. * 2. Run version probe (slow, up to 10s) — fires a second update on completion. * - * For agent-category deps, also builds a HostDependency with per-installation - * status (enumerated via which -a + path/cli overrides). + * Also builds a HostDependency with per-installation status (enumerated via + * which -a + path/cli overrides). * * Note: emitted state does not carry latestVersion/updateAvailable — those are * filled in by the application layer (AgentUpdateService) after receiving this event. @@ -249,10 +249,7 @@ export class HostDependencyManager { this.updateState(pathState); if (pathState.status === 'missing' || descriptor.skipVersionProbe) { - if (descriptor.category === 'agent') { - // Fire-and-forget: hostState is populated asynchronously after probe() returns. - void this.buildAndStoreHostDependency(id, descriptor, null, null); - } + await this.buildHostDependencyAfterProbe(id, descriptor, null, null); return pathState; } @@ -267,14 +264,28 @@ export class HostDependencyManager { const fullState = dependencyStateFromProbeResult(descriptor, resolvedPath, probeResult); this.updateState(fullState); - // Phase 3: build HostDependency for agent deps (async, non-blocking). - if (descriptor.category === 'agent') { - void this.buildAndStoreHostDependency(id, descriptor, fullState, probeResult); - } + // Phase 3: build HostDependency state. + await this.buildHostDependencyAfterProbe(id, descriptor, fullState, probeResult); return fullState; } + private async buildHostDependencyAfterProbe( + id: DependencyId, + descriptor: DependencyDescriptor, + fullState: DependencyState | null, + probeResult: ProbeResult | null + ): Promise { + if (descriptor.category === 'core') { + await this.buildAndStoreHostDependency(id, descriptor, fullState, probeResult); + return; + } + + // Preserve the existing fast return path for agent probes; installation enumeration + // can run slower package-manager provenance checks in the background. + void this.buildAndStoreHostDependency(id, descriptor, fullState, probeResult); + } + /** * Enumerate all installed copies of a dependency binary by running `which -a` * (or `where` on Windows) for all configured command names. @@ -354,7 +365,7 @@ export class HostDependencyManager { } /** - * Builds and stores a HostDependency for an agent dep. + * Builds and stores a HostDependency. * * Enumerates all discovered installations via `which -a`, classifies each by * provenance, and appends any path/cli override installations from the persisted @@ -376,6 +387,14 @@ export class HostDependencyManager { // Enumerate all discovered installations const installations = await this.enumerateInstallations(descriptor, fullState); + // Pinned override: include the authoritative binary even when it is not on PATH. + if ( + selection?.kind === 'pinned' && + !installations.some((installation) => installation.realpath === selection.realpath) + ) { + installations.push(await this.probePinnedSource(descriptor, selection.realpath)); + } + // Path override: probe when explicitly selected or previously saved if (selection?.kind === 'path') { installations.push(await this.probeOverrideSource(descriptor, 'path', selection.path)); @@ -412,6 +431,36 @@ export class HostDependencyManager { }); } + private async probePinnedSource( + descriptor: DependencyDescriptor, + pinnedRealpath: string + ): Promise { + const versionArgs = descriptor.versionArgs ?? ['--version']; + const probe = await runVersionProbe(pinnedRealpath, null, versionArgs, this.ctx); + const exists = probe.exitCode !== null || !!probe.stdout || !!probe.stderr; + const canonicalRealpath = exists + ? await resolveRealpath(pinnedRealpath, this.ctx, this.platform) + : pinnedRealpath; + const status = dependencyStateFromProbeResult( + descriptor, + exists ? canonicalRealpath : null, + probe + ).status; + + return { + id: canonicalRealpath, + realpath: canonicalRealpath, + pathEntry: null, + isActive: false, + manageable: false, + provenance: { kind: 'unknown', confidence: 'inferred' }, + status, + version: status === 'available' ? extractVersion(probe) : null, + latestVersion: null, + updateAvailable: false, + }; + } + /** * Probe a single path or cli override value without persisting or emitting any events. * Used both internally by buildAndStoreHostDependency and publicly by probeOverride. @@ -635,7 +684,7 @@ export class HostDependencyManager { } /** - * Apply an available update for an agent dependency, then re-probe. + * Apply an available update for a dependency, then re-probe. * Routing is driven by the active installation's provenance: method selection * uses PM commands, unknown/manual sources fall back to CLI self-update. * When `method` is explicitly passed it overrides the provenance-based routing. @@ -722,7 +771,7 @@ export class HostDependencyManager { } /** - * Uninstall an agent dependency on this host, then re-probe to confirm it is gone. + * Uninstall a dependency on this host, then re-probe to confirm it is gone. * * Routing is driven by the active installation's provenance: method selections use * PM uninstall commands when available (e.g. `brew uninstall `), otherwise @@ -804,7 +853,7 @@ export class HostDependencyManager { return ok(state); } - /** Returns the resolved install options for an agent dep on the current platform. */ + /** Returns the resolved install options for a dependency on the current platform. */ getInstallOptions(id: DependencyId) { const descriptor = this._getDependencyDescriptor(id); if (!descriptor) return []; diff --git a/packages/core/src/host-dependencies/runtime/index.ts b/packages/core/src/host-dependencies/runtime/index.ts index 268ef946bf..7cb6488775 100644 --- a/packages/core/src/host-dependencies/runtime/index.ts +++ b/packages/core/src/host-dependencies/runtime/index.ts @@ -1,3 +1,4 @@ +export { CORE_DEPENDENCIES, GIT_DEPENDENCY_DESCRIPTOR } from '../core-dependencies'; export { INSTALL_METHOD_LOCATION_HINTS, inferMethod } from './location-hints'; export { createInstallMethodDetector, type InstallMethodDetector } from './method-detection'; export { resolveInstallOptions, pickInstallOption, toPlatform } from './install-options'; diff --git a/packages/core/src/host-dependencies/runtime/types.ts b/packages/core/src/host-dependencies/runtime/types.ts index 1ef5cf096d..097744b99e 100644 --- a/packages/core/src/host-dependencies/runtime/types.ts +++ b/packages/core/src/host-dependencies/runtime/types.ts @@ -281,7 +281,7 @@ export type DependencyStatusUpdatedEvent = { id: string; state: DependencyState; connectionId?: string; - /** Present for agent-category deps after the host dependency has been computed. */ + /** Present after the host dependency has been computed. */ hostDependency?: HostDependency; }; From 2c3c81efa67e3317fdf594fb71cee81de79c1d3b Mon Sep 17 00:00:00 2001 From: Jan Burzinski <156842394+janburzinski@users.noreply.github.com> Date: Wed, 24 Jun 2026 21:16:45 +0200 Subject: [PATCH 4/8] fix(git): honor selected host binary --- .../dependencies/dependency-managers.test.ts | 107 ++++++++++++++++++ .../core/dependencies/dependency-managers.ts | 29 +++++ .../local-execution-context.ts | 4 +- .../ssh-execution-context.test.ts | 16 ++- .../ssh-execution-context.ts | 23 ++-- .../src/main/core/git/legacy/git-service.ts | 4 +- .../src/main/core/runtime/legacy/ssh-git.ts | 14 ++- .../src/main/core/runtime/runtime-manager.ts | 72 +++++++++++- .../src/main/core/utils/exec.test.ts | 26 ++++- .../src/main/core/utils/exec.ts | 26 ++++- 10 files changed, 300 insertions(+), 21 deletions(-) diff --git a/apps/emdash-desktop/src/main/core/dependencies/dependency-managers.test.ts b/apps/emdash-desktop/src/main/core/dependencies/dependency-managers.test.ts index 35116c5ff8..f350d081a0 100644 --- a/apps/emdash-desktop/src/main/core/dependencies/dependency-managers.test.ts +++ b/apps/emdash-desktop/src/main/core/dependencies/dependency-managers.test.ts @@ -4,12 +4,15 @@ const mocks = vi.hoisted(() => { const instances: Array<{ get: ReturnType; probeCategory: ReturnType; + onStatusUpdated: { subscribe: ReturnType }; onExecutableInvalidated: { subscribe: ReturnType }; + emitStatus(event: unknown): void; setAgentStates(): void; }> = []; class FakeHostDependencyManager { private readonly states = new Map(); + private statusListener: ((event: unknown) => void) | undefined; readonly get = vi.fn((id: string) => this.states.get(id)); readonly probeCategory = vi.fn(async (category: string) => { if (category === 'agent') { @@ -17,8 +20,17 @@ const mocks = vi.hoisted(() => { this.states.set('codex', { id: 'codex', category: 'agent' }); } }); + readonly onStatusUpdated = { + subscribe: vi.fn((listener: (event: unknown) => void) => { + this.statusListener = listener; + }), + }; readonly onExecutableInvalidated = { subscribe: vi.fn() }; + emitStatus(event: unknown): void { + this.statusListener?.(event); + } + setAgentStates(): void { this.states.set('claude', { id: 'claude', category: 'agent' }); this.states.set('codex', { id: 'codex', category: 'agent' }); @@ -38,11 +50,15 @@ const mocks = vi.hoisted(() => { getSelection: vi.fn(), createLocalInstallCommandRunner: vi.fn(() => vi.fn()), createSshInstallCommandRunner: vi.fn(() => vi.fn()), + setGitExecutableOverride: vi.fn(), }; }); vi.mock('@emdash/core/deps/runtime', () => ({ HostDependencyManager: mocks.FakeHostDependencyManager, + resolveActiveInstallation: vi.fn((installations: Array<{ isActive?: boolean }>) => + installations.find((installation) => installation.isActive) + ), })); vi.mock('@main/core/conversations/impl/resolve-agent-executable', () => ({ @@ -77,6 +93,10 @@ vi.mock('@main/core/terminal-shell/resolver', () => ({ resolveLocalAutomationShellWithSystemFallback: vi.fn(async () => ({ shell: '/bin/sh' })), })); +vi.mock('@main/core/utils/exec', () => ({ + setGitExecutableOverride: mocks.setGitExecutableOverride, +})); + vi.mock('@main/lib/logger', () => ({ log: { warn: vi.fn(), @@ -175,6 +195,93 @@ describe('ensureAgentDependenciesProbed', () => { await expect(getDependencyManager('ssh-1')).resolves.toBe(remoteManager); }); + it('syncs the local git executable from host dependency events', async () => { + await import('./dependency-managers'); + const localManager = mocks.instances[0]!; + + localManager.emitStatus({ + id: 'git', + state: { + id: 'git', + category: 'core', + status: 'available', + path: '/usr/bin/git', + }, + hostDependency: { + hostId: 'local', + dependencyId: 'git', + used: { kind: 'auto' as const }, + installations: [ + { + id: '/opt/homebrew/bin/git', + realpath: '/opt/homebrew/bin/git', + pathEntry: '/opt/homebrew/bin/git', + isActive: true, + status: 'available', + }, + ], + }, + }); + + expect(mocks.setGitExecutableOverride).toHaveBeenCalledWith('/opt/homebrew/bin/git', undefined); + }); + + it('honors a missing pinned git selection instead of falling back to PATH', async () => { + await import('./dependency-managers'); + const localManager = mocks.instances[0]!; + + localManager.emitStatus({ + id: 'git', + state: { + id: 'git', + category: 'core', + status: 'available', + path: '/usr/bin/git', + }, + hostDependency: { + hostId: 'local', + dependencyId: 'git', + used: { kind: 'pinned' as const, realpath: '/missing/git' }, + installations: [], + }, + }); + + expect(mocks.setGitExecutableOverride).toHaveBeenCalledWith('/missing/git', undefined); + }); + + it('syncs remote git executables per connection', async () => { + const { getDependencyManager } = await import('./dependency-managers'); + mocks.connect.mockResolvedValue({}); + await getDependencyManager('ssh-1'); + const remoteManager = mocks.instances[1]!; + + remoteManager.emitStatus({ + id: 'git', + state: { + id: 'git', + category: 'core', + status: 'available', + path: '/usr/bin/git', + }, + hostDependency: { + hostId: 'ssh-1', + dependencyId: 'git', + used: { kind: 'auto' as const }, + installations: [ + { + id: '/usr/local/bin/git', + realpath: '/usr/local/bin/git', + pathEntry: '/usr/local/bin/git', + isActive: true, + status: 'available', + }, + ], + }, + }); + + expect(mocks.setGitExecutableOverride).toHaveBeenCalledWith('/usr/local/bin/git', 'ssh-1'); + }); + it('deduplicates concurrent remote manager creation', async () => { const { getDependencyManager } = await import('./dependency-managers'); let resolveConnect: ((proxy: unknown) => void) | undefined; diff --git a/apps/emdash-desktop/src/main/core/dependencies/dependency-managers.ts b/apps/emdash-desktop/src/main/core/dependencies/dependency-managers.ts index b0813700ad..31765554b0 100644 --- a/apps/emdash-desktop/src/main/core/dependencies/dependency-managers.ts +++ b/apps/emdash-desktop/src/main/core/dependencies/dependency-managers.ts @@ -1,8 +1,11 @@ import type { Platform } from '@emdash/core/deps'; import { HostDependencyManager, + resolveActiveInstallation, type DependencyId, type DependencyProbeOptions, + type DependencyStatusUpdatedEvent, + type SelectedSource, } from '@emdash/core/deps/runtime'; import { clearResolvedPathCache } from '@main/core/conversations/impl/resolve-agent-executable'; import { LocalExecutionContext } from '@main/core/execution-context/local-execution-context'; @@ -11,6 +14,7 @@ import type { IExecutionContext } from '@main/core/execution-context/types'; import { appSettingsService } from '@main/core/settings/settings-service'; import { sshConnectionManager } from '@main/core/ssh/lifecycle/production-ssh-connection-manager'; import { resolveLocalAutomationShellWithSystemFallback } from '@main/core/terminal-shell/resolver'; +import { setGitExecutableOverride } from '@main/core/utils/exec'; import { log } from '@main/lib/logger'; import { agentUpdateService } from './agent-update-service'; import { hostDependencyStore } from './host-dependency-store'; @@ -30,9 +34,34 @@ async function resolveLocalInstallShellProfile() { }); } +function syncGitExecutable(event: DependencyStatusUpdatedEvent, connectionId?: string): void { + if (event.id !== 'git' || !event.hostDependency) return; + + const activeInstallation = resolveActiveInstallation( + event.hostDependency.installations, + event.hostDependency.used + ); + + const executable = activeInstallation + ? (activeInstallation.pathEntry ?? activeInstallation.realpath) + : gitExecutableFromMissingSelection(event.hostDependency.used); + + setGitExecutableOverride(executable, connectionId); +} + +function gitExecutableFromMissingSelection(selection: SelectedSource): string | null { + if (selection.kind === 'pinned') return selection.realpath; + if (selection.kind === 'path') return selection.path; + if (selection.kind === 'cli') return selection.command; + return null; +} + function wireDesktopBridges(manager: HostDependencyManager, connectionId?: string): void { // AgentUpdateService owns the enriched event emission (adds latestVersion/updateAvailable) agentUpdateService.attach(manager, connectionId); + manager.onStatusUpdated.subscribe((event: DependencyStatusUpdatedEvent) => + syncGitExecutable(event, connectionId) + ); manager.onExecutableInvalidated.subscribe(({ id }: { id: DependencyId }) => { clearResolvedPathCache(id, connectionId); }); diff --git a/apps/emdash-desktop/src/main/core/execution-context/local-execution-context.ts b/apps/emdash-desktop/src/main/core/execution-context/local-execution-context.ts index 11e2af2959..c01136c800 100644 --- a/apps/emdash-desktop/src/main/core/execution-context/local-execution-context.ts +++ b/apps/emdash-desktop/src/main/core/execution-context/local-execution-context.ts @@ -1,7 +1,7 @@ import { execFile, spawn } from 'node:child_process'; import { promisify } from 'node:util'; import { - GIT_EXECUTABLE, + getGitExecutable, isMissingGitExecutableError, missingGitExecutableError, } from '@main/core/utils/exec'; @@ -34,7 +34,7 @@ export class LocalExecutionContext implements IExecutionContext { } private resolveCommand(command: string): string { - return command === 'git' ? GIT_EXECUTABLE : command; + return command === 'git' ? getGitExecutable() : command; } exec(command: string, args: string[] = [], opts: ExecOptions = {}): Promise { diff --git a/apps/emdash-desktop/src/main/core/execution-context/ssh-execution-context.test.ts b/apps/emdash-desktop/src/main/core/execution-context/ssh-execution-context.test.ts index 8dbf1f2f88..ff73ca1e5f 100644 --- a/apps/emdash-desktop/src/main/core/execution-context/ssh-execution-context.test.ts +++ b/apps/emdash-desktop/src/main/core/execution-context/ssh-execution-context.test.ts @@ -30,7 +30,21 @@ describe('buildSshCommand', () => { const command = buildSshCommand('/workspace/project', 'git', ['fetch', 'origin']); expect(command).toBe( - "'/bin/sh' -c 'cd '\\''/workspace/project'\\'' && GIT_ASKPASS='\\'''\\'' GIT_TERMINAL_PROMPT='\\''0'\\'' GCM_INTERACTIVE='\\''never'\\'' SSH_ASKPASS='\\'''\\'' git '\\''fetch'\\'' '\\''origin'\\'''" + "'/bin/sh' -c 'cd '\\''/workspace/project'\\'' && GIT_ASKPASS='\\'''\\'' GIT_TERMINAL_PROMPT='\\''0'\\'' GCM_INTERACTIVE='\\''never'\\'' SSH_ASKPASS='\\'''\\'' '\\''git'\\'' '\\''fetch'\\'' '\\''origin'\\'''" + ); + }); + + it('uses the selected remote Git executable when provided', () => { + const command = buildSshCommand( + '/workspace/project', + 'git', + ['status'], + undefined, + '/opt/homebrew/bin/git' + ); + + expect(command).toBe( + "'/bin/sh' -c 'cd '\\''/workspace/project'\\'' && GIT_ASKPASS='\\'''\\'' GIT_TERMINAL_PROMPT='\\''0'\\'' GCM_INTERACTIVE='\\''never'\\'' SSH_ASKPASS='\\'''\\'' '\\''/opt/homebrew/bin/git'\\'' '\\''status'\\'''" ); }); }); diff --git a/apps/emdash-desktop/src/main/core/execution-context/ssh-execution-context.ts b/apps/emdash-desktop/src/main/core/execution-context/ssh-execution-context.ts index 4405a6369b..ade979ed18 100644 --- a/apps/emdash-desktop/src/main/core/execution-context/ssh-execution-context.ts +++ b/apps/emdash-desktop/src/main/core/execution-context/ssh-execution-context.ts @@ -4,16 +4,17 @@ import { type RemoteShellProfile, } from '@main/core/ssh/lifecycle/remote-shell-profile'; import type { SshClientProxy } from '@main/core/ssh/lifecycle/ssh-client-proxy'; +import { getGitExecutable } from '@main/core/utils/exec'; import { quoteShellArg } from '@main/utils/shellEscape'; import { NON_INTERACTIVE_GIT_ENV } from './non-interactive-git-env'; import type { ExecOptions, ExecResult, IExecutionContext } from './types'; -function withNonInteractiveGitEnv(command: string): string { +function withNonInteractiveGitEnv(command: string, gitExecutable?: string): string { if (command !== 'git') return command; const envPrefix = Object.entries(NON_INTERACTIVE_GIT_ENV) .map(([key, value]) => `${key}=${quoteShellArg(value)}`) .join(' '); - return `${envPrefix} ${command}`; + return `${envPrefix} ${quoteShellArg(gitExecutable ?? command)}`; } /** @@ -25,10 +26,11 @@ export function buildSshCommand( root: string | undefined, command: string, args: string[], - profile?: RemoteShellProfile + profile?: RemoteShellProfile, + gitExecutable?: string ): string { const escaped = args.map(quoteShellArg).join(' '); - const executable = withNonInteractiveGitEnv(command); + const executable = withNonInteractiveGitEnv(command, gitExecutable); const inner = args.length ? `${executable} ${escaped}` : executable; const body = root ? `cd ${quoteShellArg(root)} && ${inner}` : inner; return buildRemoteShellCommand(profile ?? FALLBACK_REMOTE_SHELL_PROFILE, body); @@ -42,15 +44,15 @@ export class SshExecutionContext implements IExecutionContext { constructor( private readonly proxy: SshClientProxy, - opts: { root?: string } = {} + private readonly contextOptions: { root?: string; connectionId?: string } = {} ) { - this.root = opts.root; + this.root = contextOptions.root; } async exec(command: string, args: string[] = [], opts: ExecOptions = {}): Promise { const { signal } = opts; const profile = await this.proxy.getRemoteShellProfile(); - const full = buildSshCommand(this.root, command, args, profile); + const full = buildSshCommand(this.root, command, args, profile, this.gitExecutableFor(command)); const combined = this._signal(signal); return new Promise((resolve, reject) => { @@ -120,7 +122,7 @@ export class SshExecutionContext implements IExecutionContext { ): Promise { const { signal } = opts; const profile = await this.proxy.getRemoteShellProfile(); - const full = buildSshCommand(this.root, command, args, profile); + const full = buildSshCommand(this.root, command, args, profile, this.gitExecutableFor(command)); const combined = this._signal(signal); return new Promise((resolve, reject) => { @@ -173,6 +175,11 @@ export class SshExecutionContext implements IExecutionContext { this._lifetime.abort(); } + private gitExecutableFor(command: string): string | undefined { + if (command !== 'git' || !this.contextOptions.connectionId) return undefined; + return getGitExecutable(this.contextOptions.connectionId); + } + private _signal(callerSignal?: AbortSignal): AbortSignal { const signals: AbortSignal[] = [this._lifetime.signal]; if (callerSignal) signals.push(callerSignal); diff --git a/apps/emdash-desktop/src/main/core/git/legacy/git-service.ts b/apps/emdash-desktop/src/main/core/git/legacy/git-service.ts index dad1f19afc..3062bee8c3 100644 --- a/apps/emdash-desktop/src/main/core/git/legacy/git-service.ts +++ b/apps/emdash-desktop/src/main/core/git/legacy/git-service.ts @@ -30,7 +30,7 @@ import { err, ok, type Result } from '@emdash/shared'; import type { IDisposable } from '@emdash/shared'; import type { IExecutionContext } from '@main/core/execution-context/types'; import type { FileSystemProvider } from '@main/core/fs/types'; -import { GIT_EXECUTABLE } from '@main/core/utils/exec'; +import { getGitExecutable } from '@main/core/utils/exec'; import { log } from '@main/lib/logger'; import { DEFAULT_REMOTE_NAME, @@ -425,7 +425,7 @@ export class GitService implements IDisposable { if (!mimeType) return { kind: 'unavailable', reason: 'unsupported' }; return new Promise((resolve) => { - const child = spawn(GIT_EXECUTABLE, ['cat-file', '--filters', spec], { + const child = spawn(getGitExecutable(), ['cat-file', '--filters', spec], { cwd: this.ctx.root || undefined, }); const chunks: Buffer[] = []; diff --git a/apps/emdash-desktop/src/main/core/runtime/legacy/ssh-git.ts b/apps/emdash-desktop/src/main/core/runtime/legacy/ssh-git.ts index 1ed796a54a..24c42b55e5 100644 --- a/apps/emdash-desktop/src/main/core/runtime/legacy/ssh-git.ts +++ b/apps/emdash-desktop/src/main/core/runtime/legacy/ssh-git.ts @@ -91,7 +91,10 @@ export class LegacySshGitRuntime implements IGitRuntime { log.warn('LegacySshGitRuntime: worktree teardown failed', { context, error: String(error) }), }); - constructor(private readonly proxy: SshClientProxy) {} + constructor( + private readonly proxy: SshClientProxy, + private readonly connectionId: string + ) {} async openRepository(pathInsideRepo: string): Promise> { const lease = await this.acquireRepository(pathInsideRepo); @@ -153,7 +156,10 @@ export class LegacySshGitRuntime implements IGitRuntime { repositoryUrl: string, targetPath: string ): Promise> { - const ctx = new SshExecutionContext(this.proxy, { root: path.posix.dirname(targetPath) }); + const ctx = new SshExecutionContext(this.proxy, { + root: path.posix.dirname(targetPath), + connectionId: this.connectionId, + }); try { await ctx.exec('git', ['clone', repositoryUrl, targetPath]); } catch (error) { @@ -210,7 +216,7 @@ export class LegacySshGitRuntime implements IGitRuntime { } private async resolveGitCommonDir(root: string): Promise { - const ctx = new SshExecutionContext(this.proxy, { root }); + const ctx = new SshExecutionContext(this.proxy, { root, connectionId: this.connectionId }); const { stdout } = await ctx.exec('git', [ 'rev-parse', '--path-format=absolute', @@ -223,7 +229,7 @@ export class LegacySshGitRuntime implements IGitRuntime { private createGit(root: string): GitService { const fs = new SshFileSystem(this.proxy, root); - const ctx = new SshExecutionContext(this.proxy, { root }); + const ctx = new SshExecutionContext(this.proxy, { root, connectionId: this.connectionId }); return new GitService(ctx, fs); } } diff --git a/apps/emdash-desktop/src/main/core/runtime/runtime-manager.ts b/apps/emdash-desktop/src/main/core/runtime/runtime-manager.ts index 19a214f2af..64d59c0c1a 100644 --- a/apps/emdash-desktop/src/main/core/runtime/runtime-manager.ts +++ b/apps/emdash-desktop/src/main/core/runtime/runtime-manager.ts @@ -1,15 +1,70 @@ +import { + createBoundExec, + type BoundExec, + type ExecBufferResult, + type ExecOptions, + type ExecResult, +} from '@emdash/core/exec'; import { GitRuntime } from '@emdash/core/git'; import { ResourceMap } from '@emdash/core/lib'; import type { Lease } from '@emdash/shared'; +import { getDependencyManager } from '@main/core/dependencies/dependency-managers'; +import { NON_INTERACTIVE_GIT_ENV } from '@main/core/execution-context/non-interactive-git-env'; import { sshConnectionManager } from '@main/core/ssh/lifecycle/production-ssh-connection-manager'; +import { getGitExecutable } from '@main/core/utils/exec'; import { log } from '@main/lib/logger'; import { ConstantHealthSource } from './health'; import { LegacySshGitRuntime } from './legacy/ssh-git'; import { machineKey, type MachineRef, type MachineRuntime, type RuntimeManager } from './types'; +class DynamicGitExec implements BoundExec { + readonly file = 'git'; + readonly env = { + ...process.env, + ...NON_INTERACTIVE_GIT_ENV, + LC_ALL: 'C', + LANG: 'C', + LANGUAGE: 'C', + }; + + constructor( + readonly cwd: string, + private readonly connectionId?: string + ) {} + + exec(args: string[], options?: ExecOptions): Promise { + return this.current().exec(args, options); + } + + execStreaming( + args: string[], + onStdout: (chunk: string) => boolean | void, + options?: ExecOptions + ): Promise { + return this.current().execStreaming(args, onStdout, options); + } + + execBuffer(args: string[], options?: ExecOptions): Promise { + return this.current().execBuffer(args, options); + } + + withCwd(cwd: string): BoundExec { + return new DynamicGitExec(cwd, this.connectionId); + } + + private current(): BoundExec { + return createBoundExec({ + file: getGitExecutable(this.connectionId), + cwd: this.cwd, + env: this.env, + }); + } +} + class LocalMachineRuntime implements MachineRuntime { readonly machine: MachineRef = { kind: 'local' }; readonly git = new GitRuntime({ + exec: new DynamicGitExec(process.cwd()), onError: (context, error) => log.warn('Local GitRuntime background error', { context, error: String(error) }), }); @@ -30,7 +85,7 @@ class SshMachineRuntime implements MachineRuntime { proxy: Awaited> ) { this.machine = { kind: 'ssh', connectionId }; - this.git = new LegacySshGitRuntime(proxy); + this.git = new LegacySshGitRuntime(proxy, connectionId); } dispose(): void { @@ -38,6 +93,20 @@ class SshMachineRuntime implements MachineRuntime { } } +async function probeGitDependency(machine: MachineRef): Promise { + try { + const manager = await getDependencyManager( + machine.kind === 'ssh' ? machine.connectionId : undefined + ); + await manager.probe('git'); + } catch (error) { + log.warn('RuntimeManager: Git dependency probe failed', { + machine: machineKey(machine), + error: String(error), + }); + } +} + class DefaultRuntimeManager implements RuntimeManager { private readonly runtimes = new ResourceMap({ teardown: (_key, runtime) => runtime.dispose(), @@ -47,6 +116,7 @@ class DefaultRuntimeManager implements RuntimeManager { acquire(machine: MachineRef): Promise> { return this.runtimes.acquire(machineKey(machine), async () => { + await probeGitDependency(machine); if (machine.kind === 'local') return new LocalMachineRuntime(); const proxy = await sshConnectionManager.connect(machine.connectionId); return new SshMachineRuntime(machine.connectionId, proxy); diff --git a/apps/emdash-desktop/src/main/core/utils/exec.test.ts b/apps/emdash-desktop/src/main/core/utils/exec.test.ts index 98c1e01717..485cecd316 100644 --- a/apps/emdash-desktop/src/main/core/utils/exec.test.ts +++ b/apps/emdash-desktop/src/main/core/utils/exec.test.ts @@ -2,16 +2,18 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { resolveGitBin } from './exec'; +import { getGitExecutable, GIT_EXECUTABLE, resolveGitBin, setGitExecutableOverride } from './exec'; const originalPlatform = process.platform; let tempDir: string; beforeEach(() => { + setGitExecutableOverride(null); tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'emdash-git-bin-')); }); afterEach(() => { + setGitExecutableOverride(null); Object.defineProperty(process, 'platform', { value: originalPlatform }); fs.rmSync(tempDir, { recursive: true, force: true }); }); @@ -55,4 +57,26 @@ describe('resolveGitBin', () => { expect(resolveGitBin({ PATH: path.dirname(pathGit), PATHEXT: '.exe' })).toBe(pathGit); }); + + it('uses the local host dependency override when present', () => { + setGitExecutableOverride('/opt/homebrew/bin/git'); + + expect(getGitExecutable()).toBe('/opt/homebrew/bin/git'); + + setGitExecutableOverride(null); + expect(getGitExecutable()).toBe(GIT_EXECUTABLE); + }); + + it('keeps remote host dependency overrides scoped by connection', () => { + setGitExecutableOverride('/remote-a/bin/git', 'ssh-a'); + setGitExecutableOverride('/remote-b/bin/git', 'ssh-b'); + + expect(getGitExecutable('ssh-a')).toBe('/remote-a/bin/git'); + expect(getGitExecutable('ssh-b')).toBe('/remote-b/bin/git'); + expect(getGitExecutable('ssh-c')).toBe('git'); + + setGitExecutableOverride(null, 'ssh-a'); + setGitExecutableOverride(null, 'ssh-b'); + expect(getGitExecutable('ssh-a')).toBe('git'); + }); }); diff --git a/apps/emdash-desktop/src/main/core/utils/exec.ts b/apps/emdash-desktop/src/main/core/utils/exec.ts index 663017c36c..0cb91122d1 100644 --- a/apps/emdash-desktop/src/main/core/utils/exec.ts +++ b/apps/emdash-desktop/src/main/core/utils/exec.ts @@ -51,12 +51,34 @@ export function resolveGitBin(env: NodeJS.ProcessEnv = process.env): string { return 'git'; } -/** Resolved path to the `git` binary — use for all git exec calls. */ +/** Initial fallback path for Git before the host dependency probe completes. */ export const GIT_EXECUTABLE = resolveGitBin(); +let localGitExecutableOverride: string | null = null; +const remoteGitExecutableOverrides = new Map(); + +export function setGitExecutableOverride(executable: string | null, connectionId?: string): void { + if (connectionId) { + if (executable) remoteGitExecutableOverrides.set(connectionId, executable); + else remoteGitExecutableOverrides.delete(connectionId); + return; + } + + localGitExecutableOverride = executable; +} + +/** Current Git executable selected by the host dependency system, with startup fallback. */ +export function getGitExecutable(connectionId?: string): string { + if (connectionId) return remoteGitExecutableOverrides.get(connectionId) ?? 'git'; + return localGitExecutableOverride ?? GIT_EXECUTABLE; +} + export function isMissingGitExecutableError(error: unknown): boolean { const err = error as NodeJS.ErrnoException | undefined; - return err?.code === 'ENOENT' && (err.path === 'git' || err.path === GIT_EXECUTABLE); + return ( + err?.code === 'ENOENT' && + (err.path === 'git' || err.path === GIT_EXECUTABLE || err.path === localGitExecutableOverride) + ); } export function missingGitExecutableError(): Error { From 60c42d7103119af44576e470488f542018eda15f Mon Sep 17 00:00:00 2001 From: Jan Burzinski <156842394+janburzinski@users.noreply.github.com> Date: Thu, 25 Jun 2026 08:06:56 +0200 Subject: [PATCH 5/8] test(tabs): mock ssh rpc in pane store --- .../src/renderer/features/tabs/pane-store.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/emdash-desktop/src/renderer/features/tabs/pane-store.test.ts b/apps/emdash-desktop/src/renderer/features/tabs/pane-store.test.ts index 1f06cb21a4..383b601144 100644 --- a/apps/emdash-desktop/src/renderer/features/tabs/pane-store.test.ts +++ b/apps/emdash-desktop/src/renderer/features/tabs/pane-store.test.ts @@ -15,6 +15,11 @@ vi.mock('@renderer/lib/ipc', () => ({ browser: { unregisterSession: vi.fn(), }, + ssh: { + getConnections: vi.fn(async () => []), + getConnectionState: vi.fn(async () => ({})), + getHealthStates: vi.fn(async () => ({})), + }, }, })); From bac58692c689821fd2fedb0a60c1af52b79b3593 Mon Sep 17 00:00:00 2001 From: Jan Burzinski <156842394+janburzinski@users.noreply.github.com> Date: Thu, 25 Jun 2026 12:18:39 +0200 Subject: [PATCH 6/8] fix(deps): mark missing pinned installs --- .../runtime/host-dependency-manager.test.ts | 39 +++++++++++++++++++ .../runtime/host-dependency-manager.ts | 35 +++++++++++------ 2 files changed, 62 insertions(+), 12 deletions(-) diff --git a/packages/core/src/host-dependencies/runtime/host-dependency-manager.test.ts b/packages/core/src/host-dependencies/runtime/host-dependency-manager.test.ts index 54cf8ddf17..9677cd4d58 100644 --- a/packages/core/src/host-dependencies/runtime/host-dependency-manager.test.ts +++ b/packages/core/src/host-dependencies/runtime/host-dependency-manager.test.ts @@ -905,6 +905,9 @@ describe('HostDependencyManager enumeration', () => { if (command === 'which' && args[0] === '-a' && args[1] === 'git') { return { stdout: '/usr/bin/git\n', stderr: '' }; } + if (command === 'which' && args[0] === pinnedPath) { + return { stdout: `${pinnedPath}\n`, stderr: '' }; + } if (command === 'realpath') { return { stdout: `${args[0]}\n`, stderr: '' }; } @@ -937,6 +940,42 @@ describe('HostDependencyManager enumeration', () => { ); }); + it('reports a missing pinned installation as missing', async () => { + const pinnedPath = '/missing/git'; + const ctx = makeCtx(async (command, args = []) => { + if (command === 'which' && args[0] === 'git') { + return { stdout: '/usr/bin/git\n', stderr: '' }; + } + if (command === 'which' && args[0] === '-a' && args[1] === 'git') { + return { stdout: '/usr/bin/git\n', stderr: '' }; + } + if (command === 'realpath') { + return { stdout: `${args[0]}\n`, stderr: '' }; + } + if (command === '/usr/bin/git' && args[0] === '--version') { + return { stdout: 'git version 2.40.0\n', stderr: '' }; + } + throw new Error(`Unexpected: ${command} ${args.join(' ')}`); + }); + const manager = new HostDependencyManager(ctx, { + dependencies: TEST_DEPENDENCIES, + getSelection: async (id) => + id === 'git' ? { kind: 'pinned' as const, realpath: pinnedPath } : null, + installMethodDetector: unknownDetector, + }); + + await manager.probe('git'); + + expect(manager.getHostDependency('git')?.installations).toContainEqual( + expect.objectContaining({ + realpath: pinnedPath, + pathEntry: null, + status: 'missing', + version: null, + }) + ); + }); + it('enumerates multiple installations from which -a and dedupes by realpath', async () => { const BREW_PATH = '/opt/homebrew/bin/codex'; const BREW_REAL = '/opt/homebrew/Cellar/codex/1.0.0/bin/codex'; diff --git a/packages/core/src/host-dependencies/runtime/host-dependency-manager.ts b/packages/core/src/host-dependencies/runtime/host-dependency-manager.ts index b199733957..5c31641bcd 100644 --- a/packages/core/src/host-dependencies/runtime/host-dependency-manager.ts +++ b/packages/core/src/host-dependencies/runtime/host-dependency-manager.ts @@ -435,17 +435,28 @@ export class HostDependencyManager { descriptor: DependencyDescriptor, pinnedRealpath: string ): Promise { + const resolvedPinnedPath = await resolveCommandPath(pinnedRealpath, this.ctx, this.platform); + if (!resolvedPinnedPath) { + return { + id: pinnedRealpath, + realpath: pinnedRealpath, + pathEntry: null, + isActive: false, + manageable: false, + provenance: { kind: 'unknown', confidence: 'inferred' }, + status: 'missing', + version: null, + latestVersion: null, + updateAvailable: false, + }; + } + + const canonicalRealpath = await resolveRealpath(resolvedPinnedPath, this.ctx, this.platform); const versionArgs = descriptor.versionArgs ?? ['--version']; - const probe = await runVersionProbe(pinnedRealpath, null, versionArgs, this.ctx); - const exists = probe.exitCode !== null || !!probe.stdout || !!probe.stderr; - const canonicalRealpath = exists - ? await resolveRealpath(pinnedRealpath, this.ctx, this.platform) - : pinnedRealpath; - const status = dependencyStateFromProbeResult( - descriptor, - exists ? canonicalRealpath : null, - probe - ).status; + const probe = descriptor.skipVersionProbe + ? null + : await runVersionProbe(resolvedPinnedPath, resolvedPinnedPath, versionArgs, this.ctx); + const state = dependencyStateFromProbeResult(descriptor, resolvedPinnedPath, probe); return { id: canonicalRealpath, @@ -454,8 +465,8 @@ export class HostDependencyManager { isActive: false, manageable: false, provenance: { kind: 'unknown', confidence: 'inferred' }, - status, - version: status === 'available' ? extractVersion(probe) : null, + status: state.status, + version: state.version, latestVersion: null, updateAvailable: false, }; From 91c8b463b220c4d80bd230c294e75c55cde954bc Mon Sep 17 00:00:00 2001 From: Jan Burzinski <156842394+janburzinski@users.noreply.github.com> Date: Thu, 25 Jun 2026 12:23:14 +0200 Subject: [PATCH 7/8] fix(git): pass ssh connection to exec contexts --- .../src/main/core/projects/create-project-provider.ts | 5 ++++- .../src/main/core/workspaces/workspace-factory.ts | 6 ++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/apps/emdash-desktop/src/main/core/projects/create-project-provider.ts b/apps/emdash-desktop/src/main/core/projects/create-project-provider.ts index 674a2a66b1..8c2e87185c 100644 --- a/apps/emdash-desktop/src/main/core/projects/create-project-provider.ts +++ b/apps/emdash-desktop/src/main/core/projects/create-project-provider.ts @@ -95,7 +95,10 @@ async function createSshProvider(project: SshProject): Promise const rootFs = new SshFileSystem(proxy, '/'); const projectFs = new SshFileSystem(proxy, project.path); - const baseCtx = new SshExecutionContext(proxy, { root: project.path }); + const baseCtx = new SshExecutionContext(proxy, { + root: project.path, + connectionId: project.connectionId, + }); const ctx = baseCtx; const projectMachine: MachineRef = { kind: 'ssh', connectionId: project.connectionId }; const runtimeLease = await runtimeManager.acquire(projectMachine); diff --git a/apps/emdash-desktop/src/main/core/workspaces/workspace-factory.ts b/apps/emdash-desktop/src/main/core/workspaces/workspace-factory.ts index 62229c07ba..55b7417be0 100644 --- a/apps/emdash-desktop/src/main/core/workspaces/workspace-factory.ts +++ b/apps/emdash-desktop/src/main/core/workspaces/workspace-factory.ts @@ -76,7 +76,9 @@ export function createWorkspaceFactory( type.kind === 'ssh' ? new SshFileSystem(type.proxy, workDir) : new LocalFileSystem(workDir); const ctx = - type.kind === 'ssh' ? new SshExecutionContext(type.proxy) : new LocalExecutionContext(); + type.kind === 'ssh' + ? new SshExecutionContext(type.proxy, { connectionId: type.connectionId }) + : new LocalExecutionContext(); // Settings (shared) const projectSettings = await context.settings.get(); @@ -306,7 +308,7 @@ export async function buildTaskProviders( opts: TaskProviderOpts ): Promise<{ conversations: ConversationProvider; terminals: TerminalProvider }> { if (type.kind === 'ssh') { - const ctx = new SshExecutionContext(type.proxy); + const ctx = new SshExecutionContext(type.proxy, { connectionId: type.connectionId }); return { conversations: new SshConversationProvider({ projectId: opts.projectId, From a6064ad67740f4716104fffe029ec46f2c18a114 Mon Sep 17 00:00:00 2001 From: Jan Burzinski <156842394+janburzinski@users.noreply.github.com> Date: Thu, 25 Jun 2026 14:54:05 +0200 Subject: [PATCH 8/8] fix(deps): clear ssh git override --- .../main/core/dependencies/dependency-managers.test.ts | 8 ++++++++ .../src/main/core/dependencies/dependency-managers.ts | 1 + 2 files changed, 9 insertions(+) diff --git a/apps/emdash-desktop/src/main/core/dependencies/dependency-managers.test.ts b/apps/emdash-desktop/src/main/core/dependencies/dependency-managers.test.ts index f350d081a0..44e4cd1aef 100644 --- a/apps/emdash-desktop/src/main/core/dependencies/dependency-managers.test.ts +++ b/apps/emdash-desktop/src/main/core/dependencies/dependency-managers.test.ts @@ -358,6 +358,14 @@ describe('ensureAgentDependenciesProbed', () => { expect(mocks.connect).toHaveBeenCalledTimes(2); }); + it('clears remote git executable overrides with remote managers', async () => { + const { clearDependencyManager } = await import('./dependency-managers'); + + clearDependencyManager('ssh-1'); + + expect(mocks.setGitExecutableOverride).toHaveBeenCalledWith(null, 'ssh-1'); + }); + it('keeps in-flight probes deduped for a manager after cache clear', async () => { const { clearDependencyManager, ensureAgentDependenciesProbed, getDependencyManager } = await import('./dependency-managers'); diff --git a/apps/emdash-desktop/src/main/core/dependencies/dependency-managers.ts b/apps/emdash-desktop/src/main/core/dependencies/dependency-managers.ts index 31765554b0..d4125ca4dc 100644 --- a/apps/emdash-desktop/src/main/core/dependencies/dependency-managers.ts +++ b/apps/emdash-desktop/src/main/core/dependencies/dependency-managers.ts @@ -136,6 +136,7 @@ async function createSshDependencyManager(connectionId: string): Promise