fix(dev-infra): keep change scope probes inert

This commit is contained in:
Tianyi Cui
2026-07-27 22:55:10 +08:00
parent 5049dfeadb
commit 7acb4332eb
5 changed files with 103 additions and 29 deletions

View File

@@ -1,5 +1,5 @@
import { execFileSync } from 'node:child_process'
import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
@@ -43,9 +43,9 @@ function gitBytes(cwd: string, args: string[], input?: Buffer): Buffer {
})
}
function write(path: string, content: string): void {
function write(path: string, content: string, mode?: number): void {
mkdirSync(dirname(path), { recursive: true })
writeFileSync(path, content)
writeFileSync(path, content, mode === undefined ? undefined : { mode })
}
function fixture(worktreeName = 'worktree'): Fixture {
@@ -209,6 +209,67 @@ describe('change-scope', () => {
expect(repositoryState(root)).toEqual(before)
})
it.skipIf(process.platform === 'win32')('does not execute a configured filesystem monitor', () => {
const { container, root } = fixture()
const monitor = join(container, 'fsmonitor.sh')
const sideEffect = `${monitor}.ran`
write(monitor, '#!/bin/sh\ntouch "$0.ran"\n', 0o755)
git(root, ['config', 'core.fsmonitor', monitor])
const report = jsonReport(root, 'HEAD')
expect(report.paths).toEqual({ committed: [], staged: [], unstaged: [], untracked: [] })
expect(existsSync(sideEffect)).toBe(false)
})
it.skipIf(process.platform === 'win32')('rejects non-UTF-8 branch and upstream names without partial output', () => {
const invalidBranch = fixture()
const branchHead = git(invalidBranch.root, ['rev-parse', 'HEAD'])
const invalidBranchName = Buffer.from([0x80])
writeFileSync(join(invalidBranch.root, '.git/packed-refs'), Buffer.concat([
Buffer.from(`${branchHead} refs/heads/`),
invalidBranchName,
Buffer.from('\n'),
]))
writeFileSync(
join(invalidBranch.root, '.git/HEAD'),
Buffer.concat([Buffer.from('ref: refs/heads/'), invalidBranchName, Buffer.from('\n')]),
)
const branchOutput: string[] = []
expect(() => {
writeChangeScope(['--base', branchHead, '--json'], invalidBranch.root, chunk => branchOutput.push(chunk))
}).toThrow('cannot inspect the current branch: Git stdout is not valid UTF-8')
expect(branchOutput).toEqual([])
const invalidUpstream = fixture()
const upstreamHead = git(invalidUpstream.root, ['rev-parse', 'HEAD'])
const invalidUpstreamName = Buffer.from([0x81])
writeFileSync(join(invalidUpstream.root, '.git/packed-refs'), Buffer.concat([
Buffer.from(`${upstreamHead} refs/remotes/origin/`),
invalidUpstreamName,
Buffer.from('\n'),
]))
const configPath = join(invalidUpstream.root, '.git/config')
const config = readFileSync(configPath)
const merge = Buffer.from('\tmerge = refs/heads/master\n')
const mergeIndex = config.indexOf(merge)
expect(mergeIndex).toBeGreaterThanOrEqual(0)
writeFileSync(configPath, Buffer.concat([
config.subarray(0, mergeIndex),
Buffer.from('\tmerge = refs/heads/'),
invalidUpstreamName,
Buffer.from('\n'),
config.subarray(mergeIndex + merge.length),
]))
const upstreamOutput: string[] = []
expect(() => {
writeChangeScope(['--base', upstreamHead, '--json'], invalidUpstream.root, chunk => upstreamOutput.push(chunk))
}).toThrow('cannot inspect the configured upstream: Git stdout is not valid UTF-8')
expect(upstreamOutput).toEqual([])
})
it.skipIf(process.platform === 'win32')('rejects distinct non-UTF-8 Git paths without partial output', () => {
const { root } = fixture()
const blobSha = git(root, ['hash-object', '-w', '--stdin'], 'content')

View File

@@ -53,10 +53,19 @@ interface ChangeScopeOptions {
json: boolean
}
function executeGit(cwd: string, args: string[]): GitCommandResult {
const result = spawnSync('git', ['-C', cwd, ...args], {
encoding: 'utf8',
env: { ...process.env, LANG: 'C', LC_ALL: 'C' },
function executeGit(cwd: string, args: string[], context: string): GitCommandResult {
const result = executeGitBytes(cwd, args)
return {
status: result.status,
stdout: decodeGitText(result.stdout, context, 'stdout'),
stderr: decodeGitText(result.stderr, context, 'stderr'),
error: result.error,
}
}
function executeGitBytes(cwd: string, args: string[]): GitBytesCommandResult {
const result = spawnSync('git', ['-C', cwd, '-c', 'core.fsmonitor=false', ...args], {
env: { ...process.env, GIT_OPTIONAL_LOCKS: '0', LANG: 'C', LC_ALL: 'C' },
maxBuffer: MAX_GIT_OUTPUT,
})
return {
@@ -67,16 +76,11 @@ function executeGit(cwd: string, args: string[]): GitCommandResult {
}
}
function executeGitBytes(cwd: string, args: string[]): GitBytesCommandResult {
const result = spawnSync('git', ['-C', cwd, ...args], {
env: { ...process.env, LANG: 'C', LC_ALL: 'C' },
maxBuffer: MAX_GIT_OUTPUT,
})
return {
status: result.status,
stdout: result.stdout,
stderr: result.stderr,
error: result.error,
function decodeGitText(output: Buffer, context: string, stream: 'stdout' | 'stderr'): string {
try {
return UTF8_DECODER.decode(output)
} catch {
throw new Error(`${context}: Git ${stream} is not valid UTF-8`)
}
}
@@ -85,7 +89,7 @@ function failureDetail(result: GitCommandResult): string {
}
function requireGit(cwd: string, args: string[], context: string): string {
const result = executeGit(cwd, args)
const result = executeGit(cwd, args, context)
if (result.status !== 0) throw new Error(`${context}: ${failureDetail(result)}`)
return result.stdout
}
@@ -116,6 +120,7 @@ function parseOptions(args: string[]): ChangeScopeOptions {
}
function resolveCommit(root: string, label: 'base' | 'head', ref: string): string {
const context = `cannot resolve ${label} ref ${JSON.stringify(ref)}`
const result = executeGit(root, [
'-c',
'core.warnAmbiguousRefs=true',
@@ -123,7 +128,7 @@ function resolveCommit(root: string, label: 'base' | 'head', ref: string): strin
'--verify',
'--end-of-options',
`${ref}^{commit}`,
])
], context)
if (/\bambiguous\b/iu.test(result.stderr)) {
throw new Error(`${label} ref ${JSON.stringify(ref)} is ambiguous; use a fully qualified ref or commit ID`)
}
@@ -138,7 +143,11 @@ function resolveCommit(root: string, label: 'base' | 'head', ref: string): strin
}
function resolveMergeBase(root: string, baseSha: string, headSha: string): string {
const result = executeGit(root, ['merge-base', '--all', baseSha, headSha])
const result = executeGit(
root,
['merge-base', '--all', baseSha, headSha],
'cannot resolve the merge base',
)
if (result.status !== 0) {
throw new Error(`base and head do not have a merge base: ${failureDetail(result)}`)
}
@@ -150,7 +159,11 @@ function resolveMergeBase(root: string, baseSha: string, headSha: string): strin
}
function currentBranch(root: string): string | null {
const result = executeGit(root, ['symbolic-ref', '--quiet', '--short', 'HEAD'])
const result = executeGit(
root,
['symbolic-ref', '--quiet', '--short', 'HEAD'],
'cannot inspect the current branch',
)
if (result.status === 1) return null
if (result.status !== 0) throw new Error(`cannot inspect the current branch: ${failureDetail(result)}`)
return stripGitLineTerminator(result.stdout)