Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: use built-in exec instead of execa with execGit #1379

Draft
wants to merge 7 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/strong-eggs-bake.md
@@ -0,0 +1,7 @@
---
'lint-staged': minor
---

Replace use of `execa` with a simple wrapper for `spawn()` from `node:child_process`, when using git commands internally. The user-configured tasks still use `execa`.

Because we require at least Node.js v18.12.0, it is possible to simplify and drop external dependencies.
42 changes: 42 additions & 0 deletions lib/exec.js
@@ -0,0 +1,42 @@
import { spawn } from 'node:child_process'
import { once } from 'node:events'
import { pipeline } from 'node:stream/promises'

/**
* @typedef {Object} Options
* @property {String} cwd the directory to spawn the file in
*/

/**
* Spawn a file using Node.js internal child_process, wrapped in a promise
*
* @param {string} file the file to spawn
* @param {string[]} [args] the args to pass to the file
* @param {Options} [options]
* @returns {Promise<string>} the output of the spawned file
*/
export const exec = async (file, args, options) => {
const buffer = []
async function* pipeToBuffer(source) {
source.setEncoding('utf8')
for await (const chunk of source) {
yield buffer.push(chunk)
}
}

const program = spawn(file, args, { cwd: options?.cwd })

await Promise.allSettled([
pipeline(program.stdout, pipeToBuffer),
pipeline(program.stderr, pipeToBuffer),
once(program, 'exit'),
])

const output = buffer.reduce((result, chunk) => result + chunk, '').replace(/\n$/, '')

if (program.exitCode !== 0) {
throw new Error(output)
}

return output
}
17 changes: 4 additions & 13 deletions lib/execGit.js
@@ -1,5 +1,6 @@
import debug from 'debug'
import { execa } from 'execa'

import { exec } from './exec.js'

const debugLog = debug('lint-staged:execGit')

Expand All @@ -12,17 +13,7 @@ const NO_SUBMODULE_RECURSE = ['-c', 'submodule.recurse=false']
// exported for tests
export const GIT_GLOBAL_OPTIONS = [...NO_SUBMODULE_RECURSE]

export const execGit = async (cmd, options = {}) => {
export const execGit = async (cmd, options) => {
debugLog('Running git command', cmd)
try {
const { stdout } = await execa('git', GIT_GLOBAL_OPTIONS.concat(cmd), {
...options,
all: true,
cwd: options.cwd || process.cwd(),
stdin: 'ignore',
})
return stdout
} catch ({ all }) {
throw new Error(all)
}
return exec('git', [...GIT_GLOBAL_OPTIONS, ...cmd], { cwd: options?.cwd })
}
8 changes: 4 additions & 4 deletions scripts/list-dependency-node-versions.js
Expand Up @@ -3,7 +3,8 @@ import { dirname, join } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'

import chalk from 'chalk'
import { execa } from 'execa'

import { exec } from '../lib/exec.js'

const __dirname = dirname(fileURLToPath(import.meta.url))

Expand All @@ -15,10 +16,9 @@ console.log(chalk.dim('Listing required Node.js versions for dependencies:'))
console.log(chalk.dim('---------------------------------------------------'))

for (const dependency of Object.keys(dependencies)) {
const { all } = await execa('npm', ['info', dependency, 'engines.node'], {
all: true,
const output = await exec('npm', ['info', dependency, 'engines.node'], {
cwd: __dirname,
})

console.log(`${chalk.greenBright(dependency)}:`, all || chalk.dim('-'))
console.log(`${chalk.greenBright(dependency)}:`, output || chalk.dim('-'))
}
2 changes: 1 addition & 1 deletion test/integration/git-submodules.test.js
Expand Up @@ -19,7 +19,7 @@ describe('lint-staged', () => {
// create a new repo for the git submodule to a temp path
let submoduleDir = path.resolve(cwd, 'submodule-temp')
await fs.mkdir(submoduleDir, { recursive: true })
await execGit('init', { cwd: submoduleDir })
await execGit(['init'], { cwd: submoduleDir })
await execGit(['config', 'user.name', '"test"'], { cwd: submoduleDir })
await execGit(['config', 'user.email', '"test@test.com"'], { cwd: submoduleDir })
await appendFile('README.md', '# Test\n', submoduleDir)
Expand Down
19 changes: 9 additions & 10 deletions test/unit/execGit.spec.js
@@ -1,8 +1,12 @@
import path from 'node:path'

import { getMockExeca } from './__utils__/getMockExeca.js'
import { jest } from '@jest/globals'

const { execa } = await getMockExeca()
jest.unstable_mockModule('../../lib/exec.js', () => ({
exec: jest.fn().mockResolvedValue(''),
}))

const { exec } = await import('../../lib/exec.js')
const { execGit, GIT_GLOBAL_OPTIONS } = await import('../../lib/execGit.js')

test('GIT_GLOBAL_OPTIONS', () => {
Expand All @@ -16,22 +20,17 @@ test('GIT_GLOBAL_OPTIONS', () => {

describe('execGit', () => {
it('should execute git in process.cwd if working copy is not specified', async () => {
const cwd = process.cwd()
await execGit(['init', 'param'])
expect(execa).toHaveBeenCalledWith('git', [...GIT_GLOBAL_OPTIONS, 'init', 'param'], {
all: true,
cwd,
stdin: 'ignore',
expect(exec).toHaveBeenCalledWith('git', [...GIT_GLOBAL_OPTIONS, 'init', 'param'], {
cwd: undefined,
})
})

it('should execute git in a given working copy', async () => {
const cwd = path.join(process.cwd(), 'test', '__fixtures__')
await execGit(['init', 'param'], { cwd })
expect(execa).toHaveBeenCalledWith('git', [...GIT_GLOBAL_OPTIONS, 'init', 'param'], {
all: true,
expect(exec).toHaveBeenCalledWith('git', [...GIT_GLOBAL_OPTIONS, 'init', 'param'], {
cwd,
stdin: 'ignore',
})
})
})