Skip to content

Commit feda37a

Browse files
committed
feat: use tinyexec for Git commands
# Conflicts: # package-lock.json # package.json
1 parent e15178a commit feda37a

9 files changed

Lines changed: 104 additions & 66 deletions

File tree

.changeset/crisp-wombats-doubt.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
---
2+
---

lib/execGit.js

Lines changed: 20 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import spawn, { SubprocessError } from 'nano-spawn'
1+
import { exec } from 'tinyexec'
22

33
import { createDebug } from './debug.js'
44

@@ -13,22 +13,25 @@ const NO_SUBMODULE_RECURSE = ['-c', 'submodule.recurse=false']
1313
// exported for tests
1414
export const GIT_GLOBAL_OPTIONS = [...NO_SUBMODULE_RECURSE]
1515

16-
/** @type {(cmd: string[], options?: import('nano-spawn').Options) => Promise<string>} */
16+
/** @type {(cmd: string[], options?: { cwd?: string }) => Promise<string>} */
1717
export const execGit = async (cmd, options) => {
18-
debugLog('Running git command', cmd)
19-
try {
20-
const result = await spawn('git', [...NO_SUBMODULE_RECURSE, ...cmd], {
21-
...options,
22-
cwd: options?.cwd ?? process.cwd(),
23-
stdin: 'ignore',
24-
})
25-
26-
return result.stdout
27-
} catch (error) {
28-
if (error instanceof SubprocessError) {
29-
throw new Error(error.output, { cause: error })
30-
}
31-
32-
throw error
18+
debugLog('Running git command:', cmd)
19+
const result = exec('git', [...NO_SUBMODULE_RECURSE, ...cmd], {
20+
nodeOptions: {
21+
cwd: options?.cwd,
22+
stdio: ['ignore'],
23+
},
24+
})
25+
26+
let output = ''
27+
for await (const line of result) {
28+
output += line + '\n'
29+
}
30+
output = output.trimEnd()
31+
32+
if (result.exitCode > 0) {
33+
throw new Error(output, { cause: result })
3334
}
35+
36+
return output
3437
}

package-lock.json

Lines changed: 2 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@
5353
"micromatch": "^4.0.8",
5454
"nano-spawn": "^2.0.0",
5555
"string-argv": "^0.3.2",
56+
"tinyexec": "^1.0.2",
5657
"yaml": "^2.8.2"
5758
},
5859
"devDependencies": {

scripts/test-node-range.js

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import { readFile } from 'node:fs/promises'
22
import { fileURLToPath } from 'node:url'
33

4-
import spawn from 'nano-spawn'
54
import { subset } from 'semver'
5+
import { exec } from 'tinyexec'
66

77
import { bold, green, red } from '../lib/colors.js'
88

@@ -27,13 +27,14 @@ for (const [dependency, version] of Object.entries(packageJson.dependencies)) {
2727
* @example <caption>when matching multiple versions</caption>
2828
* [">=6.0", ">=6.0"]
2929
*/
30-
const { output } = await spawn('npm', [
30+
const { stdout } = await exec('npm', [
3131
'info',
3232
`${dependency}@${version}`,
3333
'engines.node',
3434
'--json',
3535
])
36-
const json = JSON.parse(output)
36+
37+
const json = stdout ? JSON.parse(stdout.trim()) : ''
3738

3839
const requiredVersion = Array.isArray(json) ? json[json.length - 1] : json
3940

@@ -47,7 +48,7 @@ for (const [dependency, version] of Object.entries(packageJson.dependencies)) {
4748
const color = isSubset ? green : red
4849
const symbol = isSubset ? `✓` : '×'
4950

50-
console.log(`${color(`${symbol} ${dependency}`)}:`, requiredVersion)
51+
console.log(`${color(`${symbol} ${dependency}`)}:`, requiredVersion || '?')
5152
}
5253

5354
if (!allDependenciesSupported) {
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { vi } from 'vitest'
2+
3+
import { mockTinyexecReturnValue } from './mockTinyexecReturnValue.js'
4+
5+
/**
6+
* @returns {Promise<{ exec: import('vitest').Mocked<import('tinyexec').TinyExec> }>}
7+
*/
8+
export const getMockTinyexec = async () => {
9+
vi.mock('tinyexec', async (importOriginal) => {
10+
const mod = await importOriginal()
11+
return {
12+
...mod,
13+
exec: vi.fn(() => mockTinyexecReturnValue()),
14+
}
15+
})
16+
17+
return import('tinyexec')
18+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
const MOCK_DEFAULT_VALUE = {
2+
stdout: 'a-ok',
3+
stderr: '',
4+
pid: 0,
5+
cmd: 'mock cmd',
6+
}
7+
8+
export const mockTinyexecReturnValue = (value = MOCK_DEFAULT_VALUE, executionTime) => {
9+
let triggerResult
10+
let resolveTimeout
11+
12+
const isFailed = value instanceof Error
13+
14+
const returnedPromise = executionTime
15+
? new Promise((resolve, reject) => {
16+
triggerResult = isFailed ? reject.bind(null, value) : resolve.bind(null, value)
17+
resolveTimeout = setTimeout(triggerResult, executionTime)
18+
})
19+
: isFailed
20+
? Promise.reject(value)
21+
: Promise.resolve(value)
22+
23+
returnedPromise.kill = () => {
24+
clearTimeout(resolveTimeout)
25+
triggerResult?.()
26+
}
27+
28+
return returnedPromise
29+
}

test/unit/execGit.spec.js

Lines changed: 16 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,35 @@
11
import path from 'node:path'
22

3-
import { beforeEach, describe, it, test } from 'vitest'
3+
import { exec } from 'tinyexec'
4+
import { beforeEach, describe, it, test, vi } from 'vitest'
45

5-
import { getMockNanoSpawn } from './__utils__/getMockNanoSpawn.js'
6+
import { execGit, GIT_GLOBAL_OPTIONS } from '../../lib/execGit.js'
67

7-
const { default: spawn } = await getMockNanoSpawn()
8-
9-
const { execGit, GIT_GLOBAL_OPTIONS } = await import('../../lib/execGit.js')
8+
vi.mock('tinyexec', () => ({
9+
exec: vi.fn().mockReturnValue({
10+
async *[Symbol.asyncIterator]() {
11+
yield 'test'
12+
},
13+
}),
14+
}))
1015

1116
test('GIT_GLOBAL_OPTIONS', ({ expect }) => {
1217
expect(GIT_GLOBAL_OPTIONS).toEqual(['-c', 'submodule.recurse=false'])
1318
})
1419

1520
describe('execGit', () => {
1621
beforeEach(() => {
17-
spawn.mockReset()
18-
})
19-
20-
it('should execute git in process.cwd if working copy is not specified', async ({ expect }) => {
21-
const cwd = process.cwd()
22-
await execGit(['init', 'param'])
23-
expect(spawn).toHaveBeenCalledExactlyOnceWith('git', [...GIT_GLOBAL_OPTIONS, 'init', 'param'], {
24-
cwd,
25-
stdin: 'ignore',
26-
})
22+
vi.clearAllMocks()
2723
})
2824

2925
it('should execute git in a given working copy', async ({ expect }) => {
3026
const cwd = path.join(process.cwd(), 'test', '__fixtures__')
3127
await execGit(['init', 'param'], { cwd })
32-
expect(spawn).toHaveBeenCalledExactlyOnceWith('git', [...GIT_GLOBAL_OPTIONS, 'init', 'param'], {
33-
cwd,
34-
stdin: 'ignore',
28+
expect(exec).toHaveBeenCalledExactlyOnceWith('git', [...GIT_GLOBAL_OPTIONS, 'init', 'param'], {
29+
nodeOptions: {
30+
cwd,
31+
stdio: ['ignore'],
32+
},
3533
})
3634
})
3735
})

test/unit/runAll.spec.js

Lines changed: 11 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import path from 'node:path'
22

33
import makeConsoleMock from 'consolemock'
44
import { SubprocessError } from 'nano-spawn'
5+
import { exec } from 'tinyexec'
56
import { afterAll, afterEach, beforeAll, describe, it, vi } from 'vitest'
67

78
import { normalizePath } from '../../lib/normalizePath.js'
@@ -11,6 +12,14 @@ import { mockNanoSpawnReturnValue } from './__utils__/mockNanoSpawnReturnValue.j
1112

1213
const { default: spawn } = await getMockNanoSpawn()
1314

15+
vi.mock('tinyexec', () => ({
16+
exec: vi.fn().mockReturnValue({
17+
async *[Symbol.asyncIterator]() {
18+
yield 'test'
19+
},
20+
}),
21+
}))
22+
1423
vi.mock('../../lib/getStagedFiles.js', () => ({
1524
getStagedFiles: vi.fn(async () => []),
1625
}))
@@ -177,13 +186,6 @@ describe('runAll', () => {
177186
'': { '*.js': 'echo "sample"' },
178187
}))
179188

180-
spawn.mockImplementationOnce(() =>
181-
mockNanoSpawnReturnValue({
182-
output: 'Has staged files',
183-
nodeChildProcess: { pid: 0 },
184-
})
185-
)
186-
187189
mockGitWorkflow.runTasks.mockImplementationOnce(async (ctx, task, { listrTasks }) => {
188190
ctx.errors.add(TaskError)
189191
return task.newListr(listrTasks)
@@ -202,13 +204,6 @@ describe('runAll', () => {
202204
'': { '*.js': 'echo "sample"' },
203205
}))
204206

205-
spawn.mockImplementationOnce(() =>
206-
mockNanoSpawnReturnValue({
207-
output: 'Has staged files',
208-
nodeChildProcess: { pid: 0 },
209-
})
210-
)
211-
212207
spawn.mockImplementationOnce(() =>
213208
mockNanoSpawnReturnValue(
214209
Object.assign(new SubprocessError(), {
@@ -433,12 +428,7 @@ describe('runAll', () => {
433428
}))
434429

435430
// Mock first spawn call (git operations) to succeed
436-
spawn.mockImplementationOnce(() =>
437-
mockNanoSpawnReturnValue({
438-
output: 'Success',
439-
nodeChildProcess: { pid: 0 },
440-
})
441-
)
431+
vi.mocked(exec).mockResolvedValueOnce('Has staged files')
442432

443433
// Mock second spawn call (the actual task) to fail
444434
spawn.mockImplementationOnce(() =>
@@ -475,12 +465,7 @@ describe('runAll', () => {
475465
}))
476466

477467
// Mock first spawn call (git operations) to succeed
478-
spawn.mockImplementationOnce(() =>
479-
mockNanoSpawnReturnValue({
480-
output: 'Success',
481-
nodeChildProcess: { pid: 0 },
482-
})
483-
)
468+
vi.mocked(exec).mockResolvedValueOnce('Has staged files')
484469

485470
// Mock second spawn call (`echo "success js command 1"`) to succeed
486471
spawn.mockImplementationOnce(() =>

0 commit comments

Comments
 (0)