diff --git a/.vscode/launch.json b/.vscode/launch.json index 8dcf04fa..a264a1b6 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -9,7 +9,7 @@ "args": ["--silent", "--config", "${workspaceFolder}/jest.fast.config.js"], "console": "integratedTerminal", "env": { - "LOCAL_GIT_DIRECTORY": "./git/" + "LOCAL_GIT_DIRECTORY": "./git/default" }, "internalConsoleOptions": "neverOpen" }, @@ -21,7 +21,7 @@ "args": ["--silent", "--config", "${workspaceFolder}/jest.slow.config.js"], "console": "integratedTerminal", "env": { - "LOCAL_GIT_DIRECTORY": "./git/" + "LOCAL_GIT_DIRECTORY": "./git/default" }, "internalConsoleOptions": "neverOpen" }, diff --git a/lib/git-environment.ts b/lib/git-environment.ts index 36dfe1c2..196af056 100644 --- a/lib/git-environment.ts +++ b/lib/git-environment.ts @@ -9,19 +9,33 @@ function resolveEmbeddedGitDir(): string { ) { const s = path.sep return path - .resolve(__dirname, '..', '..', 'git') + .resolve(__dirname, '..', '..', 'git', 'default') .replace(/[\\\/]app.asar[\\\/]/, `${s}app.asar.unpacked${s}`) } throw new Error('Git not supported on platform: ' + process.platform) } +function resolveEmbeddedGitDirForWSL(): string { + return path + .resolve(__dirname, '..', '..', 'git_platforms', 'linux-x64') + .replace(/[\\\/]app.asar[\\\/]/, `${path.sep}app.asar.unpacked${path.sep}`) +} + /** * Find the path to the embedded Git environment. * * If a custom Git directory path is defined as the `LOCAL_GIT_DIRECTORY` environment variable, then * returns with it after resolving it as a path. */ -function resolveGitDir(): string { +function resolveGitDir(wslDistro?: string): string { + if (wslDistro) { + if (process.env.LOCAL_GIT_DIRECTORY_WSL != null) { + return path.resolve(process.env.LOCAL_GIT_DIRECTORY_WSL) + } else { + return resolveEmbeddedGitDirForWSL() + } + } + if (process.env.LOCAL_GIT_DIRECTORY != null) { return path.resolve(process.env.LOCAL_GIT_DIRECTORY) } else { @@ -32,12 +46,28 @@ function resolveGitDir(): string { /** * Find the path to the embedded Git binary. */ -function resolveGitBinary(): string { - const gitDir = resolveGitDir() +function resolveGitBinary(wslDistro?: string): { + gitLocation: string + gitArgs: string[] +} { + const gitDir = resolveGitDir(wslDistro) + + if (wslDistro) { + return { + gitLocation: 'wsl.exe', + gitArgs: [ + '-d', + wslDistro, + '-e', + toWSLPath(path.join(gitDir, 'bin', 'git')), + ], + } + } + if (process.platform === 'win32') { - return path.join(gitDir, 'cmd', 'git.exe') + return { gitLocation: path.join(gitDir, 'cmd', 'git.exe'), gitArgs: [] } } else { - return path.join(gitDir, 'bin', 'git') + return { gitLocation: path.join(gitDir, 'bin', 'git'), gitArgs: [] } } } @@ -47,7 +77,14 @@ function resolveGitBinary(): string { * If a custom git exec path is given as the `GIT_EXEC_PATH` environment variable, * then it returns with it after resolving it as a path. */ -function resolveGitExecPath(): string { +function resolveGitExecPath(wslDistro?: string): string { + if (wslDistro) { + if (process.env.GIT_EXEC_PATH_WSL != null) { + return path.resolve(process.env.GIT_EXEC_PATH_WSL) + } + return path.join(resolveGitDir(wslDistro), 'libexec', 'git-core') + } + if (process.env.GIT_EXEC_PATH != null) { return path.resolve(process.env.GIT_EXEC_PATH) } @@ -63,6 +100,19 @@ function resolveGitExecPath(): string { } } +/** + * Convert a windows path to a WSL path. + */ +export function toWSLPath(windowsPath: string) { + const [, drive, path] = windowsPath.match(/^([a-zA-Z]):(.*)/) ?? [] + + if (!drive || !path) { + return windowsPath + } + + return `/mnt/${drive.toLowerCase()}${path.replace(/\\/g, '/')}` +} + /** * Setup the process environment before invoking Git. * @@ -71,16 +121,23 @@ function resolveGitExecPath(): string { * * @param additional options to include with the process */ -export function setupEnvironment(environmentVariables: NodeJS.ProcessEnv): { +export function setupEnvironment( + environmentVariables: NodeJS.ProcessEnv, + targetPath: string +): { env: NodeJS.ProcessEnv gitLocation: string + gitArgs: string[] } { - const gitLocation = resolveGitBinary() + const [, wslDistro] = + targetPath.match(/\\\\wsl(?:\$|\.localhost)\\([^\\]+)\\/) ?? [] + + const { gitLocation, gitArgs } = resolveGitBinary(wslDistro) let envPath: string = process.env.PATH || '' - const gitDir = resolveGitDir() + const gitDir = resolveGitDir(wslDistro) - if (process.platform === 'win32') { + if (process.platform === 'win32' && !wslDistro) { if (process.arch === 'x64') { envPath = `${gitDir}\\mingw64\\bin;${gitDir}\\mingw64\\usr\\bin;${envPath}` } else { @@ -92,12 +149,31 @@ export function setupEnvironment(environmentVariables: NodeJS.ProcessEnv): { {}, process.env, { - GIT_EXEC_PATH: resolveGitExecPath(), + GIT_EXEC_PATH: resolveGitExecPath(wslDistro), PATH: envPath, }, environmentVariables ) + if (wslDistro) { + // Forward certain environment variables to WSL to allow authentication. + // The /p flag translates windows<->wsl paths. + env.WSLENV = [ + env?.WSLENV, + 'GIT_ASKPASS/p', + 'DESKTOP_USERNAME', + 'DESKTOP_ENDPOINT', + 'DESKTOP_PORT', + 'DESKTOP_TRAMPOLINE_TOKEN', + 'DESKTOP_TRAMPOLINE_IDENTIFIER', + 'GIT_EXEC_PATH/p', + 'GIT_TEMPLATE_DIR/p', + 'PREFIX/p', + ] + .filter(Boolean) + .join(':') + } + if (process.platform === 'win32') { // while reading the environment variable is case-insensitive // you can create a hash with multiple values, which means the @@ -109,27 +185,34 @@ export function setupEnvironment(environmentVariables: NodeJS.ProcessEnv): { } } - if (process.platform === 'darwin' || process.platform === 'linux') { + if ( + process.platform === 'darwin' || + process.platform === 'linux' || + wslDistro + ) { // templates are used to populate your .git folder // when a repository is initialized locally - const templateDir = `${gitDir}/share/git-core/templates` + const templateDir = path.join(gitDir, 'share/git-core/templates') env.GIT_TEMPLATE_DIR = templateDir } - if (process.platform === 'linux') { + if (process.platform === 'linux' || wslDistro) { // when building Git for Linux and then running it from // an arbitrary location, you should set PREFIX for the // process to ensure that it knows how to resolve things env.PREFIX = gitDir - if (!env.GIT_SSL_CAINFO && !env.LOCAL_GIT_DIRECTORY) { + if ( + !env.GIT_SSL_CAINFO && + !(wslDistro ? env.LOCAL_GIT_DIRECTORY_WSL : env.LOCAL_GIT_DIRECTORY) + ) { // use the SSL certificate bundle included in the distribution only // when using embedded Git and not providing your own bundle - const distDir = resolveEmbeddedGitDir() - const sslCABundle = `${distDir}/ssl/cacert.pem` + const distDir = resolveGitDir(wslDistro) + const sslCABundle = path.join(distDir, 'ssl/cacert.pem') env.GIT_SSL_CAINFO = sslCABundle } } - return { env, gitLocation } + return { env, gitLocation, gitArgs } } diff --git a/lib/git-process.ts b/lib/git-process.ts index def68479..06b468d3 100644 --- a/lib/git-process.ts +++ b/lib/git-process.ts @@ -116,14 +116,14 @@ export class GitProcess { customEnv = options.env } - const { env, gitLocation } = setupEnvironment(customEnv) + const { env, gitLocation, gitArgs } = setupEnvironment(customEnv, path) const spawnArgs = { env, cwd: path, } - const spawnedProcess = spawn(gitLocation, args, spawnArgs) + const spawnedProcess = spawn(gitLocation, [...gitArgs, ...args], spawnArgs) ignoreClosedInputStream(spawnedProcess) @@ -183,7 +183,7 @@ export class GitProcess { customEnv = options.env } - const { env, gitLocation } = setupEnvironment(customEnv) + const { env, gitLocation, gitArgs } = setupEnvironment(customEnv, path) // Explicitly annotate opts since typescript is unable to infer the correct // signature for execFile when options is passed as an opaque hash. The type @@ -199,7 +199,7 @@ export class GitProcess { const spawnedProcess = execFile( gitLocation, - args, + [...gitArgs, ...args], execOptions, function (err: Error | null, stdout, stderr) { result.updateProcessEnded() diff --git a/package.json b/package.json index d1cbd433..9aefda4f 100644 --- a/package.json +++ b/package.json @@ -10,8 +10,8 @@ "prepack": "yarn build && yarn test", "postpublish": "git push --follow-tags", "test": "yarn test:fast && yarn test:slow && yarn test:external", - "test:fast": "cross-env LOCAL_GIT_DIRECTORY=./git/ jest --runInBand --silent --config ./jest.fast.config.js", - "test:slow": "cross-env LOCAL_GIT_DIRECTORY=./git/ jest --runInBand --silent --config ./jest.slow.config.js", + "test:fast": "cross-env LOCAL_GIT_DIRECTORY=./git/default jest --runInBand --silent --config ./jest.fast.config.js", + "test:slow": "cross-env LOCAL_GIT_DIRECTORY=./git/default jest --runInBand --silent --config ./jest.slow.config.js", "test:external": "jest --runInBand --silent --config ./jest.external.config.js", "download-git": "node ./script/download-git.js", "postinstall": "node ./script/download-git.js", diff --git a/script/config.js b/script/config.js index e9d4aa37..796c066f 100644 --- a/script/config.js +++ b/script/config.js @@ -7,26 +7,26 @@ const embeddedGit = require('./embedded-git.json') function getConfig() { const config = { - outputPath: path.join(__dirname, '..', 'git'), + outputPath: path.join(__dirname, '..', 'git', 'default'), source: '', checksum: '', fileName: '', - tempFile: '' + tempFile: '', } // Possible values are ‘x64’, ‘arm’, ‘arm64’, ‘s390’, ‘s390x’, ‘mipsel’, ‘ia32’, ‘mips’, ‘ppc’ and ‘ppc64’ - let arch = os.arch(); + let arch = os.arch() if (process.env.npm_config_arch) { // If a specific npm_config_arch is set, we use that one instead of the OS arch (to support cross compilation) - console.log('npm_config_arch detected: ' + process.env.npm_config_arch); - arch = process.env.npm_config_arch; + console.log('npm_config_arch detected: ' + process.env.npm_config_arch) + arch = process.env.npm_config_arch } if (process.platform === 'win32' && arch === 'arm64') { // Use the Dugite Native ia32 package for Windows arm64 (arm64 can run 32-bit code through emulation) - console.log('Downloading 32-bit Dugite Native for Windows arm64'); - arch = 'ia32'; + console.log('Downloading 32-bit Dugite Native for Windows arm64') + arch = 'ia32' } const key = `${process.platform}-${arch}` @@ -37,30 +37,52 @@ function getConfig() { config.checksum = entry.checksum config.source = entry.url } else { - console.log(`No embedded Git found for ${process.platform} and architecture ${arch}`) + console.log( + `No embedded Git found for ${process.platform} and architecture ${arch}` + ) } if (config.source !== '') { - // compute the filename from the download source - const url = URL.parse(config.source) - const pathName = url.pathname - const index = pathName.lastIndexOf('/') - config.fileName = pathName.substr(index + 1) - - const cacheDirEnv = process.env.DUGITE_CACHE_DIR + processConfig(config) + } - const cacheDir = cacheDirEnv ? path.resolve(cacheDirEnv) : os.tmpdir() + if (process.platform === 'win32') { + const entry = embeddedGit['linux-x64'] - try { - fs.statSync(cacheDir) - } catch (e) { - fs.mkdirSync(cacheDir) + const wslConfig = { + outputPath: path.join(__dirname, '..', 'git', 'linux-x64'), + source: entry.url, + checksum: entry.checksum, + fileName: '', + tempFile: '', } - config.tempFile = path.join(cacheDir, config.fileName) + processConfig(wslConfig) + + return [config, wslConfig] + } + + return [config] +} + +function processConfig(config) { + // compute the filename from the download source + const url = URL.parse(config.source) + const pathName = url.pathname + const index = pathName.lastIndexOf('/') + config.fileName = pathName.substr(index + 1) + + const cacheDirEnv = process.env.DUGITE_CACHE_DIR + + const cacheDir = cacheDirEnv ? path.resolve(cacheDirEnv) : os.tmpdir() + + try { + fs.statSync(cacheDir) + } catch (e) { + fs.mkdirSync(cacheDir) } - return config + config.tempFile = path.join(cacheDir, config.fileName) } module.exports = getConfig diff --git a/script/download-git.js b/script/download-git.js index f25f2351..79e3b065 100644 --- a/script/download-git.js +++ b/script/download-git.js @@ -4,31 +4,37 @@ const ProgressBar = require('progress') const tar = require('tar') const https = require('https') const { createHash } = require('crypto') -const { rm, rmSync, mkdir, createReadStream, createWriteStream, existsSync } = require('fs') - -const config = require('./config')() - -const verifyFile = function(file, callback) { - const h = createHash('sha256').on('finish', () => { - const hash = h.digest('hex') - const match = hash === config.checksum - if (!match) { - console.log(`Validation failed. Expected '${config.checksum}' but got '${hash}'`) - } - callback(match) +const { createReadStream, createWriteStream, existsSync } = require('fs') +const { rm, mkdir } = require('fs/promises') + +const configs = require('./config')() + +const verifyFile = function (config) { + const h = createHash('sha256') + createReadStream(config.tempFile).pipe(h) + + return new Promise(resolve => { + h.on('finish', () => { + const hash = h.digest('hex') + const match = hash === config.checksum + if (!match) { + console.log( + `Validation failed. Expected '${config.checksum}' but got '${hash}'` + ) + } + resolve(match) + }) }) - - createReadStream(file).pipe(h) } -const unpackFile = function(file) { - tar.x({ cwd: config.outputPath, file }).catch(e => { +const unpackFile = async function (config) { + await tar.x({ cwd: config.outputPath, file: config.tempFile }).catch(e => { console.log('Unable to extract archive, aborting...', error) process.exit(1) }) } -const downloadAndUnpack = (url, isFollowingRedirect) => { +const downloadAndUnpack = (config, url, isFollowingRedirect) => { if (!isFollowingRedirect) { console.log(`Downloading Git from: ${url}`) } @@ -36,14 +42,14 @@ const downloadAndUnpack = (url, isFollowingRedirect) => { const options = { headers: { Accept: 'application/octet-stream', - 'User-Agent': 'dugite' + 'User-Agent': 'dugite', }, - secureProtocol: 'TLSv1_2_method' + secureProtocol: 'TLSv1_2_method', } const req = https.get(url, options) - req.on('error', function(error) { + req.on('error', function (error) { if (error.code === 'ETIMEDOUT') { console.log( `A timeout has occurred while downloading '${url}' - check ` + @@ -57,33 +63,35 @@ const downloadAndUnpack = (url, isFollowingRedirect) => { process.exit(1) }) - req.on('response', function(res) { - if ([301, 302].includes(res.statusCode) && res.headers['location']) { - downloadAndUnpack(res.headers.location, true) - return - } - - if (res.statusCode !== 200) { - console.log(`Non-200 response returned from ${url} - (${res.statusCode})`) - process.exit(1) - } - - const len = parseInt(res.headers['content-length'], 10) - - const bar = new ProgressBar('Downloading Git [:bar] :percent :etas', { - complete: '=', - incomplete: ' ', - width: 50, - total: len - }) + return new Promise(resolve => { + req.on('response', function (res) { + if ([301, 302].includes(res.statusCode) && res.headers['location']) { + return downloadAndUnpack(config, res.headers.location, true) + } + + if (res.statusCode !== 200) { + console.log( + `Non-200 response returned from ${url} - (${res.statusCode})` + ) + process.exit(1) + } + + const len = parseInt(res.headers['content-length'], 10) + + const bar = new ProgressBar('Downloading Git [:bar] :percent :etas', { + complete: '=', + incomplete: ' ', + width: 50, + total: len, + }) - res.pipe(createWriteStream(config.tempFile)) + res.pipe(createWriteStream(config.tempFile)) - res.on('data', c => bar.tick(c.length)) - res.on('end', function() { - verifyFile(config.tempFile, valid => { - if (valid) { - unpackFile(config.tempFile) + res.on('data', c => bar.tick(c.length)) + res.on('end', async function () { + if (await verifyFile(config)) { + await unpackFile(config) + resolve() } else { console.log(`checksum verification failed, refusing to unpack...`) process.exit(1) @@ -93,40 +101,43 @@ const downloadAndUnpack = (url, isFollowingRedirect) => { }) } -if (config.source === '') { - console.log( - `Skipping downloading embedded Git as platform '${process.platform}' is not supported.` - ) - console.log(`To learn more about using dugite with a system Git: https://git.io/vF5oj`) - process.exit(0) -} +;(async () => { + for (const config of configs) { + if (config.source === '') { + console.log( + `Skipping downloading embedded Git as platform '${process.platform}' is not supported.` + ) + console.log( + `To learn more about using dugite with a system Git: https://git.io/vF5oj` + ) + process.exit(0) + } -rm(config.outputPath, { recursive: true, force: true }, error => { - if (error) { - console.log(`Unable to clean directory at ${config.outputPath}`, error) - process.exit(1) - } + try { + await rm(config.outputPath, { recursive: true, force: true }) + } catch (error) { + console.log(`Unable to clean directory at ${config.outputPath}`, error) + process.exit(1) + } - mkdir(config.outputPath, { recursive: true }, function(error) { - if (error) { + try { + await mkdir(config.outputPath, { recursive: true }) + } catch (error) { console.log(`Unable to create directory at ${config.outputPath}`, error) process.exit(1) } - const tempFile = config.tempFile + if (existsSync(config.tempFile)) { + if (await verifyFile(config)) { + await unpackFile(config) + } else { + await rm(config.tempFile) + await downloadAndUnpack(config, config.source) + } - if (existsSync(tempFile)) { - verifyFile(tempFile, valid => { - if (valid) { - unpackFile(tempFile) - } else { - rmSync(tempFile) - downloadAndUnpack(config.source) - } - }) - return + continue } - downloadAndUnpack(config.source) - }) -}) + await downloadAndUnpack(config, config.source) + } +})() diff --git a/test/fast/environment-test.ts b/test/fast/environment-test.ts index 06131485..340f4c01 100644 --- a/test/fast/environment-test.ts +++ b/test/fast/environment-test.ts @@ -22,7 +22,7 @@ describe('environment variables', () => { it('when GIT_EXEC_PATH environment variable is *not* set, it will be calculated', async () => { expect(process.env.GIT_EXEC_PATH).toBeUndefined() - const { env } = await setupEnvironment({}) + const { env } = await setupEnvironment({}, '') expect((env)['GIT_EXEC_PATH']).not.toBeUndefined() }) @@ -30,7 +30,7 @@ describe('environment variables', () => { expect(process.env.GIT_EXEC_PATH).toBeUndefined() try { process.env.GIT_EXEC_PATH = __filename - const { env } = await setupEnvironment({}) + const { env } = await setupEnvironment({}, '') expect((env)['GIT_EXEC_PATH']).toBe(__filename) } finally { delete process.env.GIT_EXEC_PATH