Skip to content

Commit cfd1ad5

Browse files
committed
fix: respect the VS Code "git.path" setting
The extension always spawned "git" from PATH, so it failed with "spawn git ENOENT" when git was not globally installed (e.g. portable or MSYS2 installs configured via the built-in "git.path" setting). - Add a central git-binary module holding the resolved git executable, used by every spawn site (git-service, repo-discovery, content-provider). - Resolve the path on activation: the configured "git.path" (string or first existing array entry) takes precedence, falling back to the path the built-in git extension resolved, then to PATH. - Re-resolve when "git.path" changes at runtime. Fixes #18
1 parent cfc0d70 commit cfd1ad5

6 files changed

Lines changed: 103 additions & 11 deletions

File tree

src/extension.ts

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import * as vscode from 'vscode';
22
import * as path from 'path';
3+
import { existsSync } from 'fs';
4+
import { setGitBinaryPath } from './git/git-binary';
35
import { MainPanel } from './panels/MainPanel';
46
import { GitContentProvider } from './services/git-content-provider';
57
import { GitService } from './git/git-service';
@@ -12,6 +14,20 @@ import { WorktreesViewProvider } from './views/worktrees-view';
1214
import { StatusBarManager } from './views/status-bar';
1315
import { RepoDiscoveryService } from './services/repo-discovery';
1416

17+
/**
18+
* Resolve the `git.path` setting to an existing executable. The setting may be
19+
* a single path or an array of candidates (VS Code uses the first that exists).
20+
* Returns undefined when nothing is configured or none of the candidates exist.
21+
*/
22+
function resolveConfiguredGitPath(): string | undefined {
23+
const cfg = vscode.workspace.getConfiguration('git').get<string | string[] | null>('path');
24+
const candidates = Array.isArray(cfg) ? cfg : cfg ? [cfg] : [];
25+
for (const c of candidates) {
26+
if (c && existsSync(c)) return c;
27+
}
28+
return undefined;
29+
}
30+
1531
export function activate(context: vscode.ExtensionContext) {
1632
// Status bar is always visible regardless of workspace state
1733
const statusBar = new StatusBarManager();
@@ -48,23 +64,45 @@ export function activate(context: vscode.ExtensionContext) {
4864
}
4965

5066
let activeRepoPath = workspaceFolder.uri.fsPath;
67+
68+
// Resolve the git executable so the extension works when git is not on PATH
69+
// (e.g. portable/MSYS2 installs configured via `git.path`). The configured
70+
// path takes precedence; otherwise we fall back to the path the built-in git
71+
// extension resolved (set below once its API is available), then to PATH. #18
72+
let apiGitPath: string | undefined;
73+
const applyGitPath = () => setGitBinaryPath(resolveConfiguredGitPath() ?? apiGitPath);
74+
applyGitPath();
75+
5176
let activeGitService = new GitService(activeRepoPath);
5277

5378
// Inject VS Code's built-in git extension askpass env so authentication prompts work
5479
const builtinGit = vscode.extensions.getExtension('vscode.git');
5580
if (builtinGit) {
5681
const waitForGit = builtinGit.isActive ? Promise.resolve(builtinGit.exports) : Promise.resolve(builtinGit.activate());
57-
waitForGit.then((ext: { getAPI(version: number): { git: { env?: Record<string, string> } } }) => {
82+
waitForGit.then((ext: { getAPI(version: number): { git: { env?: Record<string, string>; path?: string } } }) => {
5883
try {
59-
const env = ext.getAPI(1)?.git?.env;
60-
if (env) {
61-
activeGitService.setExtraEnv(env);
62-
MainPanel.setExtraEnv(env);
84+
const git = ext.getAPI(1)?.git;
85+
if (git?.env) {
86+
activeGitService.setExtraEnv(git.env);
87+
MainPanel.setExtraEnv(git.env);
88+
}
89+
// The built-in extension's resolved path already honors `git.path`; adopt
90+
// it as the fallback for when the user hasn't set a valid `git.path`.
91+
if (typeof git?.path === 'string' && git.path) {
92+
apiGitPath = git.path;
93+
applyGitPath();
6394
}
6495
} catch { /* built-in git extension API unavailable */ }
6596
}).catch(() => {});
6697
}
6798

99+
// Re-resolve when the user changes `git.path` at runtime.
100+
context.subscriptions.push(
101+
vscode.workspace.onDidChangeConfiguration(e => {
102+
if (e.affectsConfiguration('git.path')) applyGitPath();
103+
}),
104+
);
105+
68106
// --- Content Provider for diff URIs ---
69107
const contentProvider = new GitContentProvider();
70108
context.subscriptions.push(
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { describe, it, expect, afterEach } from 'vitest';
2+
import { getGitBinaryPath, setGitBinaryPath } from '../git-binary';
3+
4+
describe('git-binary', () => {
5+
afterEach(() => setGitBinaryPath('git')); // reset shared module state
6+
7+
it("defaults to 'git'", () => {
8+
expect(getGitBinaryPath()).toBe('git');
9+
});
10+
11+
it('stores an explicit path', () => {
12+
setGitBinaryPath('/usr/local/bin/git');
13+
expect(getGitBinaryPath()).toBe('/usr/local/bin/git');
14+
});
15+
16+
it('keeps a Windows-style path verbatim', () => {
17+
setGitBinaryPath('D:/app/msys64/mingw64/bin/git.exe');
18+
expect(getGitBinaryPath()).toBe('D:/app/msys64/mingw64/bin/git.exe');
19+
});
20+
21+
it.each([undefined, null, '', ' '])('resets to "git" for %p', (value) => {
22+
setGitBinaryPath('/custom/git');
23+
setGitBinaryPath(value as string | undefined | null);
24+
expect(getGitBinaryPath()).toBe('git');
25+
});
26+
});

src/git/git-binary.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
// Resolved path to the git executable used for every spawned git process.
2+
//
3+
// Defaults to `'git'` (looked up on PATH). When git is not on PATH — common on
4+
// Windows with portable/MSYS2 installs — the extension host resolves the
5+
// VS Code `git.path` setting (or the built-in git extension's resolved path)
6+
// during activation and stores it here so all spawn sites pick it up. See #18.
7+
//
8+
// This module is intentionally free of any `vscode` import so GitService and
9+
// the other git callers stay unit-testable. The vscode-aware resolution lives
10+
// in the extension entry point, which calls `setGitBinaryPath`.
11+
12+
let gitBinaryPath = 'git';
13+
14+
/** The git executable to spawn. */
15+
export function getGitBinaryPath(): string {
16+
return gitBinaryPath;
17+
}
18+
19+
/**
20+
* Set the git executable path. A nullish or blank value resets to the default
21+
* PATH lookup (`'git'`).
22+
*/
23+
export function setGitBinaryPath(p: string | undefined | null): void {
24+
gitBinaryPath = p && p.trim() ? p : 'git';
25+
}

src/git/git-service.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { writeFile, unlink } from 'fs/promises';
44
import { join } from 'path';
55
import { randomUUID } from 'crypto';
66
import { bufferStream, BufferOverflowError } from '../utils/buffer-stream';
7+
import { getGitBinaryPath } from './git-binary';
78

89
/** Default max bytes per stdout/stderr stream for a single git invocation.
910
* Picked to comfortably hold large log/diff output (e.g. `git log --all`
@@ -244,7 +245,7 @@ export class GitService {
244245
const maxBytes = options?.maxBufferBytes ?? DEFAULT_MAX_BUFFER_BYTES;
245246

246247
return new Promise((resolve, reject) => {
247-
const proc = spawn('git', args, {
248+
const proc = spawn(getGitBinaryPath(), args, {
248249
cwd: this.repoPath,
249250
env: { ...process.env, ...this.extraEnv, GIT_TERMINAL_PROMPT: '0', LC_ALL: 'C', GIT_MERGE_AUTOEDIT: 'no', GIT_EDITOR: 'true', EDITOR: 'true' },
250251
});
@@ -694,7 +695,7 @@ export class GitService {
694695
? ['merge-tree', '--write-tree', `--merge-base=${mergeBase}`, ours, theirs]
695696
: ['merge-tree', '--write-tree', ours, theirs];
696697
return new Promise((resolve) => {
697-
const proc = spawn('git', args, {
698+
const proc = spawn(getGitBinaryPath(), args, {
698699
cwd: this.repoPath,
699700
env: { ...process.env, GIT_TERMINAL_PROMPT: '0', LC_ALL: 'C' },
700701
});
@@ -1176,7 +1177,7 @@ export class GitService {
11761177
// that cmd.exe / sh expansion handles repo paths containing &, |, (, ),
11771178
// ^, etc. safely without manual escaping.
11781179
await new Promise<void>((resolve, reject) => {
1179-
const proc = spawn('git', ['rebase', '-i', base], {
1180+
const proc = spawn(getGitBinaryPath(), ['rebase', '-i', base], {
11801181
cwd: this.repoPath,
11811182
env: {
11821183
...process.env,
@@ -1854,7 +1855,7 @@ export class GitService {
18541855
}
18551856
const MAX_IMAGE_SIZE = 50 * 1024 * 1024; // 50MB limit
18561857
return new Promise((resolve, reject) => {
1857-
const proc = spawn('git', ['show', `${ref}:${filePath}`], {
1858+
const proc = spawn(getGitBinaryPath(), ['show', `${ref}:${filePath}`], {
18581859
cwd: this.repoPath,
18591860
env: { ...process.env, ...this.extraEnv, GIT_TERMINAL_PROMPT: '0', LC_ALL: 'C', GIT_MERGE_AUTOEDIT: 'no', GIT_EDITOR: 'true', EDITOR: 'true' },
18601861
});

src/services/git-content-provider.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import * as vscode from 'vscode';
22
import { spawn } from 'child_process';
3+
import { getGitBinaryPath } from '../git/git-binary';
34

45
export class GitContentProvider implements vscode.TextDocumentContentProvider {
56
private onDidChangeEmitter = new vscode.EventEmitter<vscode.Uri>();
@@ -21,7 +22,7 @@ export class GitContentProvider implements vscode.TextDocumentContentProvider {
2122
}
2223

2324
return new Promise((resolve) => {
24-
const proc = spawn('git', ['show', `${ref}:${filePath}`], {
25+
const proc = spawn(getGitBinaryPath(), ['show', `${ref}:${filePath}`], {
2526
cwd: repoPath,
2627
env: { ...process.env, GIT_TERMINAL_PROMPT: '0', LC_ALL: 'C' },
2728
});

src/services/repo-discovery.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { spawn } from 'child_process';
22
import * as fs from 'fs';
33
import * as path from 'path';
4+
import { getGitBinaryPath } from '../git/git-binary';
45

56
export type RepoType = 'root' | 'submodule' | 'nested';
67

@@ -214,7 +215,7 @@ export class RepoDiscoveryService {
214215

215216
private static execGit(args: string[], cwd: string, timeoutMs = 15000): Promise<string> {
216217
return new Promise((resolve, reject) => {
217-
const proc = spawn('git', args, {
218+
const proc = spawn(getGitBinaryPath(), args, {
218219
cwd,
219220
env: { ...process.env, GIT_TERMINAL_PROMPT: '0', LC_ALL: 'C' },
220221
});

0 commit comments

Comments
 (0)