diff --git a/src/config.ts b/src/config.ts index 0c2d15af..f297c00f 100644 --- a/src/config.ts +++ b/src/config.ts @@ -499,10 +499,19 @@ class Config { } /** - * Get the value of the `git.path` Visual Studio Code Setting. - */ - get gitPath() { - return vscode.workspace.getConfiguration('git').get('path', null); + * Get the Git executable paths configured by the `git.path` Visual Studio Code Setting. + */ + get gitPaths() { + const configValue = vscode.workspace.getConfiguration('git').get('path', null); + if (configValue === null) { + return []; + } else if (typeof configValue === 'string') { + return [configValue]; + } else if (Array.isArray(configValue)) { + return configValue.filter((value) => typeof value === 'string'); + } else { + return []; + } } /** diff --git a/src/extension.ts b/src/extension.ts index 74f9257c..6e618451 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -9,7 +9,7 @@ import { onStartUp } from './life-cycle/startup'; import { Logger } from './logger'; import { RepoManager } from './repoManager'; import { StatusBarItem } from './statusBarItem'; -import { GitExecutable, UNABLE_TO_FIND_GIT_MSG, findGit, getGitExecutable, showErrorMessage, showInformationMessage } from './utils'; +import { GitExecutable, UNABLE_TO_FIND_GIT_MSG, findGit, getGitExecutableFromPaths, showErrorMessage, showInformationMessage } from './utils'; import { EventEmitter } from './utils/event'; /** @@ -52,17 +52,17 @@ export async function activate(context: vscode.ExtensionContext) { if (event.affectsConfiguration('git-graph')) { configurationEmitter.emit(event); } else if (event.affectsConfiguration('git.path')) { - const path = getConfig().gitPath; - if (path === null) return; + const paths = getConfig().gitPaths; + if (paths.length === 0) return; - getGitExecutable(path).then((gitExecutable) => { + getGitExecutableFromPaths(paths).then((gitExecutable) => { gitExecutableEmitter.emit(gitExecutable); const msg = 'Git Graph is now using ' + gitExecutable.path + ' (version: ' + gitExecutable.version + ')'; showInformationMessage(msg); logger.log(msg); repoManager.searchWorkspaceForRepos(); }, () => { - const msg = 'The new value of "git.path" (' + path + ') does not match the path and filename of a valid Git executable.'; + const msg = 'The new value of "git.path" ("' + paths.join('", "') + '") does not ' + (paths.length > 1 ? 'contain a string that matches' : 'match') + ' the path and filename of a valid Git executable.'; showErrorMessage(msg); logger.logError(msg); }); diff --git a/src/utils.ts b/src/utils.ts index ce3585cb..43f35788 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -523,10 +523,10 @@ export async function findGit(extensionState: ExtensionState) { } catch (_) { } } - const configGitPath = getConfig().gitPath; - if (configGitPath !== null) { + const configGitPaths = getConfig().gitPaths; + if (configGitPaths.length > 0) { try { - return await getGitExecutable(configGitPath); + return await getGitExecutableFromPaths(configGitPaths); } catch (_) { } } @@ -612,7 +612,7 @@ function isExecutable(path: string) { } /** - * Gets information about a Git executable. + * Tests whether the specified path corresponds to the path of a Git executable. * @param path The path of the Git executable. * @returns The GitExecutable data. */ @@ -628,6 +628,20 @@ export function getGitExecutable(path: string) { }); } +/** + * Tests whether one of the specified paths corresponds to the path of a Git executable. + * @param paths The paths of possible Git executables. + * @returns The GitExecutable data. + */ +export async function getGitExecutableFromPaths(paths: string[]): Promise { + for (let i = 0; i < paths.length; i++) { + try { + return await getGitExecutable(paths[i]); + } catch (_) { } + } + throw new Error('None of the provided paths are a Git executable'); +} + /* Git Version Handling */ diff --git a/tests/config.test.ts b/tests/config.test.ts index 9bb4e569..11d72c4f 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -3720,44 +3720,70 @@ describe('Config', () => { }); }); - describe('gitPath', () => { + describe('gitPaths', () => { it('Should return the configured path', () => { // Setup workspaceConfiguration.get.mockReturnValueOnce('/path/to/git'); // Run - const value = config.gitPath; + const value = config.gitPaths; // Assert expect(vscode.workspace.getConfiguration).toBeCalledWith('git'); expect(workspaceConfiguration.get).toBeCalledWith('path', null); - expect(value).toBe('/path/to/git'); + expect(value).toStrictEqual(['/path/to/git']); }); - it('Should return NULL when the configuration value is NULL', () => { + it('Should return the valid configured paths', () => { + // Setup + workspaceConfiguration.get.mockReturnValueOnce(['/path/to/first/git', '/path/to/second/git', 4, {}, null, '/path/to/third/git']); + + // Run + const value = config.gitPaths; + + // Assert + expect(vscode.workspace.getConfiguration).toBeCalledWith('git'); + expect(workspaceConfiguration.get).toBeCalledWith('path', null); + expect(value).toStrictEqual(['/path/to/first/git', '/path/to/second/git', '/path/to/third/git']); + }); + + it('Should return an empty array when the configuration value is NULL', () => { // Setup workspaceConfiguration.get.mockReturnValueOnce(null); // Run - const value = config.gitPath; + const value = config.gitPaths; + + // Assert + expect(vscode.workspace.getConfiguration).toBeCalledWith('git'); + expect(workspaceConfiguration.get).toBeCalledWith('path', null); + expect(value).toStrictEqual([]); + }); + + it('Should return an empty array when the configuration value is invalid', () => { + // Setup + workspaceConfiguration.get.mockReturnValueOnce(4); + + // Run + const value = config.gitPaths; // Assert expect(vscode.workspace.getConfiguration).toBeCalledWith('git'); expect(workspaceConfiguration.get).toBeCalledWith('path', null); - expect(value).toBe(null); + expect(value).toStrictEqual([]); }); - it('Should return the default configuration value (NULL)', () => { + it('Should return an empty array when the default configuration value (NULL) is received', () => { // Setup workspaceConfiguration.get.mockImplementationOnce((_, defaultValue) => defaultValue); // Run - const value = config.gitPath; + const value = config.gitPaths; // Assert expect(vscode.workspace.getConfiguration).toBeCalledWith('git'); expect(workspaceConfiguration.get).toBeCalledWith('path', null); - expect(value).toBe(null); + expect(value).toStrictEqual([]); }); }); diff --git a/tests/utils.test.ts b/tests/utils.test.ts index 09eefc39..f99a6f52 100644 --- a/tests/utils.test.ts +++ b/tests/utils.test.ts @@ -23,7 +23,7 @@ import { DataSource } from '../src/dataSource'; import { ExtensionState } from '../src/extensionState'; import { Logger } from '../src/logger'; import { GitFileStatus, PullRequestProvider } from '../src/types'; -import { GitExecutable, UNCOMMITTED, abbrevCommit, abbrevText, archive, constructIncompatibleGitVersionMessage, copyFilePathToClipboard, copyToClipboard, createPullRequest, evalPromises, findGit, getGitExecutable, getNonce, getPathFromStr, getPathFromUri, getRelativeTimeDiff, getRepoName, isGitAtLeastVersion, isPathInWorkspace, openExtensionSettings, openFile, openGitTerminal, pathWithTrailingSlash, realpath, resolveSpawnOutput, resolveToSymbolicPath, showErrorMessage, showInformationMessage, viewDiff, viewFileAtRevision, viewScm } from '../src/utils'; +import { GitExecutable, UNCOMMITTED, abbrevCommit, abbrevText, archive, constructIncompatibleGitVersionMessage, copyFilePathToClipboard, copyToClipboard, createPullRequest, evalPromises, findGit, getGitExecutable, getGitExecutableFromPaths, getNonce, getPathFromStr, getPathFromUri, getRelativeTimeDiff, getRepoName, isGitAtLeastVersion, isPathInWorkspace, openExtensionSettings, openFile, openGitTerminal, pathWithTrailingSlash, realpath, resolveSpawnOutput, resolveToSymbolicPath, showErrorMessage, showInformationMessage, viewDiff, viewFileAtRevision, viewScm } from '../src/utils'; import { EventEmitter } from '../src/utils/event'; let extensionContext = vscode.mocks.extensionContext; @@ -1543,7 +1543,7 @@ describe('findGit', () => { it('Should use the users git.path if the last known Git executable path no longer exists', async () => { // Setup jest.spyOn(extensionState, 'getLastKnownGitPath').mockReturnValueOnce('/path/to/not-git'); - workspaceConfiguration.get.mockReturnValueOnce('/path/to/git'); // gitPath + workspaceConfiguration.get.mockReturnValueOnce('/path/to/git'); // git.path mockSpawnGitVersionThrowingErrorOnce(); mockSpawnGitVersionSuccessOnce(); @@ -1561,7 +1561,7 @@ describe('findGit', () => { it('Should use the users git.path if there is no last known Git executable path', async () => { // Setup jest.spyOn(extensionState, 'getLastKnownGitPath').mockReturnValueOnce(null); - workspaceConfiguration.get.mockReturnValueOnce('/path/to/git'); // gitPath + workspaceConfiguration.get.mockReturnValueOnce('/path/to/git'); // git.path mockSpawnGitVersionSuccessOnce(); // Run @@ -1579,7 +1579,7 @@ describe('findGit', () => { let spyOnExec: jest.SpyInstance; beforeEach(() => { jest.spyOn(extensionState, 'getLastKnownGitPath').mockReturnValueOnce(null); - workspaceConfiguration.get.mockReturnValueOnce(null); // gitPath + workspaceConfiguration.get.mockReturnValueOnce(null); // git.path Object.defineProperty(process, 'platform', { value: 'darwin' }); spyOnExec = jest.spyOn(cp, 'exec'); }); @@ -1699,7 +1699,7 @@ describe('findGit', () => { let programW6432: string | undefined, programFilesX86: string | undefined, programFiles: string | undefined, localAppData: string | undefined, envPath: string | undefined; beforeEach(() => { jest.spyOn(extensionState, 'getLastKnownGitPath').mockReturnValueOnce(null); - workspaceConfiguration.get.mockReturnValueOnce(null); // gitPath + workspaceConfiguration.get.mockReturnValueOnce([]); // git.path programW6432 = process.env['ProgramW6432']; programFilesX86 = process.env['ProgramFiles(x86)']; programFiles = process.env['ProgramFiles']; @@ -1856,7 +1856,7 @@ describe('findGit', () => { describe('process.platform === \'unknown\'', () => { beforeEach(() => { jest.spyOn(extensionState, 'getLastKnownGitPath').mockReturnValueOnce(null); - workspaceConfiguration.get.mockReturnValueOnce(null); // gitPath + workspaceConfiguration.get.mockReturnValueOnce(null); // git.path Object.defineProperty(process, 'platform', { value: 'unknown' }); }); @@ -1948,6 +1948,65 @@ describe('getGitExecutable', () => { }); }); +describe('getGitExecutableFromPaths', () => { + it('Should return the git version information from the first valid path (all valid)', async () => { + // Setup + mockSpawnGitVersionSuccessOnce(); + + // Run + const result = await getGitExecutableFromPaths(['/path/to/first/git', '/path/to/second/git']); + + // Assert + expect(result).toStrictEqual({ + path: '/path/to/first/git', + version: '1.2.3' + }); + expect(spyOnSpawn).toBeCalledTimes(1); + }); + + it('Should return the git version information from the first valid path (first invalid)', async () => { + // Setup + mockSpawnGitVersionThrowingErrorOnce(); + mockSpawnGitVersionSuccessOnce(); + + // Run + const result = await getGitExecutableFromPaths(['/path/to/first/git', '/path/to/second/git']); + + // Assert + expect(result).toStrictEqual({ + path: '/path/to/second/git', + version: '1.2.3' + }); + expect(spyOnSpawn).toBeCalledTimes(2); + }); + + it('Should reject when none of the provided paths are valid Git executables', async () => { + // Setup + let rejected = false; + mockSpawnGitVersionThrowingErrorOnce(); + mockSpawnGitVersionThrowingErrorOnce(); + + // Run + await getGitExecutableFromPaths(['/path/to/first/git', '/path/to/second/git']).catch(() => rejected = true); + + // Assert + expect(rejected).toBe(true); + expect(spyOnSpawn).toBeCalledTimes(2); + }); + + it('Should reject when no paths are provided', async () => { + // Setup + let rejected = false; + + // Run + await getGitExecutableFromPaths([]).catch(() => rejected = true); + + // Assert + expect(rejected).toBe(true); + expect(spyOnSpawn).toBeCalledTimes(0); + }); +}); + describe('isGitAtLeastVersion', () => { it('Should correctly determine major newer', () => { // Run