Merge remote-tracking branch 'origin/master' into worktree/pr703-ci-fix-20260727
This commit is contained in:
343
scripts/change-scope.spec.ts
Normal file
343
scripts/change-scope.spec.ts
Normal file
@@ -0,0 +1,343 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
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'
|
||||
|
||||
import { writeChangeScope } from './change-scope.ts'
|
||||
|
||||
interface Report {
|
||||
formatVersion: number
|
||||
repository: { root: string; branch: string | null; upstream: string | null }
|
||||
input: { base: string; head: string }
|
||||
resolved: { baseSha: string; headSha: string; mergeBaseSha: string }
|
||||
paths: { committed: string[]; staged: string[]; unstaged: string[]; untracked: string[] }
|
||||
}
|
||||
|
||||
interface Fixture {
|
||||
container: string
|
||||
root: string
|
||||
origin: string
|
||||
}
|
||||
|
||||
const fixtureRoots: string[] = []
|
||||
|
||||
afterEach(() => {
|
||||
for (const root of fixtureRoots.splice(0)) rmSync(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function git(cwd: string, args: string[], input?: string | Buffer): string {
|
||||
return execFileSync('git', ['-C', cwd, ...args], {
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, LANG: 'C', LC_ALL: 'C' },
|
||||
input,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
}).trim()
|
||||
}
|
||||
|
||||
function gitBytes(cwd: string, args: string[], input?: Buffer): Buffer {
|
||||
return execFileSync('git', ['-C', cwd, ...args], {
|
||||
env: { ...process.env, LANG: 'C', LC_ALL: 'C' },
|
||||
input,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
})
|
||||
}
|
||||
|
||||
function write(path: string, content: string, mode?: number): void {
|
||||
mkdirSync(dirname(path), { recursive: true })
|
||||
writeFileSync(path, content, mode === undefined ? undefined : { mode })
|
||||
}
|
||||
|
||||
function fixture(worktreeName = 'worktree'): Fixture {
|
||||
const container = mkdtempSync(join(tmpdir(), 'dsh-change-scope-'))
|
||||
fixtureRoots.push(container)
|
||||
const origin = join(container, 'origin.git')
|
||||
const root = join(container, worktreeName)
|
||||
const hooks = join(container, 'hooks')
|
||||
mkdirSync(hooks)
|
||||
git(container, ['init', '--bare', '--initial-branch=master', origin])
|
||||
git(container, ['init', '--initial-branch=master', root])
|
||||
git(root, ['config', 'user.email', 'change-scope@example.com'])
|
||||
git(root, ['config', 'user.name', 'Change Scope Tests'])
|
||||
git(root, ['config', 'commit.gpgsign', 'false'])
|
||||
git(root, ['config', 'core.hooksPath', hooks])
|
||||
write(join(root, 'README.md'), '# Fixture\n')
|
||||
git(root, ['add', 'README.md'])
|
||||
git(root, ['commit', '-m', 'initial'])
|
||||
git(root, ['remote', 'add', 'origin', origin])
|
||||
git(root, ['push', '--set-upstream', 'origin', 'master'])
|
||||
return { container, root, origin }
|
||||
}
|
||||
|
||||
function commit(root: string, path: string, content: string): string {
|
||||
write(join(root, path), content)
|
||||
git(root, ['add', '--', path])
|
||||
git(root, ['commit', '-m', `add ${path}`])
|
||||
return git(root, ['rev-parse', 'HEAD'])
|
||||
}
|
||||
|
||||
function invoke(root: string, args: string[]): string {
|
||||
const output: string[] = []
|
||||
writeChangeScope(args, root, chunk => output.push(chunk))
|
||||
expect(output).toHaveLength(1)
|
||||
return output[0] as string
|
||||
}
|
||||
|
||||
function jsonReport(root: string, base: string, head?: string): Report {
|
||||
const args = ['--base', base, '--json']
|
||||
if (head !== undefined) args.push('--head', head)
|
||||
return JSON.parse(invoke(root, args)) as Report
|
||||
}
|
||||
|
||||
function formatHumanFromJson(report: Report): string {
|
||||
const value = (input: string | null): string => JSON.stringify(input) ?? 'null'
|
||||
const paths = (label: string, entries: string[]): string[] => [
|
||||
`${label} (${entries.length}):`,
|
||||
...(entries.length === 0 ? [' (none)'] : entries.map(entry => ` - ${value(entry)}`)),
|
||||
]
|
||||
return [
|
||||
`Format version: ${report.formatVersion}`,
|
||||
`Repository root: ${value(report.repository.root)}`,
|
||||
`Branch: ${value(report.repository.branch)}`,
|
||||
`Upstream: ${value(report.repository.upstream)}`,
|
||||
`Base ref: ${value(report.input.base)}`,
|
||||
`Head ref: ${value(report.input.head)}`,
|
||||
`Base commit: ${report.resolved.baseSha}`,
|
||||
`Head commit: ${report.resolved.headSha}`,
|
||||
`Merge base: ${report.resolved.mergeBaseSha}`,
|
||||
...paths('Committed paths', report.paths.committed),
|
||||
...paths('Staged paths', report.paths.staged),
|
||||
...paths('Unstaged paths', report.paths.unstaged),
|
||||
...paths('Untracked paths', report.paths.untracked),
|
||||
'',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function repositoryState(root: string): Record<string, string> {
|
||||
const status = git(root, ['status', '--porcelain=v2', '--branch', '-z'])
|
||||
return {
|
||||
status,
|
||||
head: git(root, ['rev-parse', 'HEAD']),
|
||||
refs: git(root, ['for-each-ref', '--format=%(refname) %(objectname)']),
|
||||
index: readFileSync(join(root, '.git/index')).toString('base64'),
|
||||
config: readFileSync(join(root, '.git/config')).toString('base64'),
|
||||
}
|
||||
}
|
||||
|
||||
describe('change-scope', () => {
|
||||
it('uses an explicit base on a fresh branch without a same-name remote and after its first push', () => {
|
||||
const { root } = fixture()
|
||||
git(root, ['switch', '-c', 'feature'])
|
||||
git(root, ['branch', '--set-upstream-to=origin/master'])
|
||||
const headSha = commit(root, 'feature.txt', 'feature\n')
|
||||
|
||||
const fresh = jsonReport(root, 'origin/master')
|
||||
expect(fresh.repository).toEqual({ root: realpathSync(root), branch: 'feature', upstream: 'origin/master' })
|
||||
expect(fresh.resolved).toEqual({
|
||||
baseSha: git(root, ['rev-parse', 'origin/master']),
|
||||
headSha,
|
||||
mergeBaseSha: git(root, ['rev-parse', 'origin/master']),
|
||||
})
|
||||
expect(fresh.paths).toEqual({ committed: ['feature.txt'], staged: [], unstaged: [], untracked: [] })
|
||||
expect(git(root, ['for-each-ref', '--format=%(refname)', 'refs/remotes/origin/feature'])).toBe('')
|
||||
|
||||
git(root, ['push', '--set-upstream', 'origin', 'feature'])
|
||||
const pushed = jsonReport(root, 'origin/master')
|
||||
expect(pushed.repository.upstream).toBe('origin/feature')
|
||||
expect(pushed.paths.committed).toEqual(['feature.txt'])
|
||||
})
|
||||
|
||||
it.skipIf(process.platform === 'win32')('preserves trailing spaces in the worktree path', () => {
|
||||
const { root } = fixture('worktree ')
|
||||
const report = jsonReport(root, 'HEAD')
|
||||
|
||||
expect(report.repository.root).toBe(realpathSync(root))
|
||||
expect(report.paths).toEqual({ committed: [], staged: [], unstaged: [], untracked: [] })
|
||||
})
|
||||
|
||||
it('preserves legal Unicode edge whitespace in branch and upstream names', () => {
|
||||
const { root } = fixture()
|
||||
const branch = '\u00a0topic\u3000'
|
||||
const upstreamBranch = '\u3000upstream\u00a0'
|
||||
git(root, ['switch', '-c', branch])
|
||||
git(root, ['push', 'origin', `HEAD:refs/heads/${upstreamBranch}`])
|
||||
git(root, ['branch', '--set-upstream-to', `origin/${upstreamBranch}`])
|
||||
|
||||
const report = jsonReport(root, 'origin/master')
|
||||
|
||||
expect(report.repository.branch).toBe(branch)
|
||||
expect(report.repository.upstream).toBe(`origin/${upstreamBranch}`)
|
||||
})
|
||||
|
||||
it('reports an exact head above a non-master stacked base while dirty paths remain worktree-local', () => {
|
||||
const { root } = fixture()
|
||||
git(root, ['switch', '-c', 'foundation'])
|
||||
const baseSha = commit(root, 'foundation.txt', 'foundation\n')
|
||||
git(root, ['switch', '-c', 'topic'])
|
||||
const headSha = commit(root, 'topic.txt', 'topic\n')
|
||||
commit(root, 'later.txt', 'later\n')
|
||||
write(join(root, 'current-worktree.txt'), 'current worktree\n')
|
||||
|
||||
const report = jsonReport(root, 'foundation', headSha)
|
||||
expect(report.input).toEqual({ base: 'foundation', head: headSha })
|
||||
expect(report.resolved).toEqual({ baseSha, headSha, mergeBaseSha: baseSha })
|
||||
expect(report.paths.committed).toEqual(['topic.txt'])
|
||||
expect(report.paths.untracked).toEqual(['current-worktree.txt'])
|
||||
})
|
||||
|
||||
it('keeps committed, staged, unstaged, and untracked paths independent and does not mutate state', () => {
|
||||
const { root } = fixture()
|
||||
commit(root, 'unstaged.txt', 'before\n')
|
||||
const baseSha = git(root, ['rev-parse', 'HEAD'])
|
||||
commit(root, 'committed.txt', 'committed\n')
|
||||
write(join(root, 'staged.txt'), 'staged\n')
|
||||
write(join(root, 'mixed.txt'), 'staged part\n')
|
||||
git(root, ['add', 'staged.txt', 'mixed.txt'])
|
||||
write(join(root, 'mixed.txt'), 'staged part\nunstaged part\n')
|
||||
write(join(root, 'unstaged.txt'), 'unstaged\n')
|
||||
write(join(root, 'untracked.txt'), 'untracked\n')
|
||||
const before = repositoryState(root)
|
||||
|
||||
const report = jsonReport(root, baseSha)
|
||||
|
||||
expect(report.paths).toEqual({
|
||||
committed: ['committed.txt'],
|
||||
staged: ['mixed.txt', 'staged.txt'],
|
||||
unstaged: ['mixed.txt', 'unstaged.txt'],
|
||||
untracked: ['untracked.txt'],
|
||||
})
|
||||
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')
|
||||
const entry = Buffer.from(`100644 ${blobSha}\t`, 'ascii')
|
||||
const firstPath = Buffer.from([0x80])
|
||||
const secondPath = Buffer.from([0x81])
|
||||
gitBytes(root, ['update-index', '-z', '--index-info'], Buffer.concat([
|
||||
entry,
|
||||
firstPath,
|
||||
Buffer.from([0]),
|
||||
entry,
|
||||
secondPath,
|
||||
Buffer.from([0]),
|
||||
]))
|
||||
expect(gitBytes(root, ['diff', '--cached', '--name-only', '-z', '--'])).toEqual(Buffer.concat([
|
||||
firstPath,
|
||||
Buffer.from([0]),
|
||||
secondPath,
|
||||
Buffer.from([0]),
|
||||
]))
|
||||
const output: string[] = []
|
||||
|
||||
expect(() => {
|
||||
writeChangeScope(['--base', 'HEAD', '--json'], root, chunk => output.push(chunk))
|
||||
}).toThrow('cannot inspect staged paths: Git path 1 is not valid UTF-8')
|
||||
expect(output).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects missing, ambiguous, and non-commit refs before writing output', () => {
|
||||
const { root } = fixture()
|
||||
git(root, ['branch', 'collision'])
|
||||
git(root, ['tag', 'collision'])
|
||||
write(join(root, 'blob.txt'), 'blob\n')
|
||||
const blobSha = git(root, ['hash-object', '-w', 'blob.txt'])
|
||||
git(root, ['tag', 'blob-ref', blobSha])
|
||||
|
||||
for (const { args, message } of [
|
||||
{ args: ['--base', 'missing'], message: /base ref .* does not resolve to a commit/u },
|
||||
{ args: ['--base', 'collision'], message: /base ref .* is ambiguous/u },
|
||||
{ args: ['--base', 'blob-ref'], message: /base ref .* does not resolve to a commit/u },
|
||||
{ args: ['--base', 'HEAD', '--head', 'missing'], message: /head ref .* does not resolve to a commit/u },
|
||||
]) {
|
||||
const output: string[] = []
|
||||
expect(() => {
|
||||
writeChangeScope(args, root, (chunk) => {
|
||||
output.push(chunk)
|
||||
})
|
||||
}).toThrow(message)
|
||||
expect(output).toEqual([])
|
||||
}
|
||||
})
|
||||
|
||||
it('renders deterministic human and JSON forms with the same facts', () => {
|
||||
const { root } = fixture()
|
||||
git(root, ['switch', '-c', 'format'])
|
||||
commit(root, 'zeta.txt', 'zeta\n')
|
||||
commit(root, 'alpha.txt', 'alpha\n')
|
||||
|
||||
const json = invoke(root, ['--base', 'origin/master', '--json'])
|
||||
const repeatedJson = invoke(root, ['--base', 'origin/master', '--json'])
|
||||
const human = invoke(root, ['--base', 'origin/master'])
|
||||
const repeatedHuman = invoke(root, ['--base', 'origin/master'])
|
||||
const report = JSON.parse(json) as Report
|
||||
|
||||
expect(json).toBe(repeatedJson)
|
||||
expect(report.formatVersion).toBe(1)
|
||||
expect(report.paths.committed).toEqual(['alpha.txt', 'zeta.txt'])
|
||||
expect(human).toBe(repeatedHuman)
|
||||
expect(human).toBe(formatHumanFromJson(report))
|
||||
})
|
||||
})
|
||||
320
scripts/change-scope.ts
Normal file
320
scripts/change-scope.ts
Normal file
@@ -0,0 +1,320 @@
|
||||
/** Report the explicit committed and worktree scope of a repository change. */
|
||||
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { resolve } from 'node:path'
|
||||
import { parseArgs, TextDecoder } from 'node:util'
|
||||
|
||||
const FORMAT_VERSION = 1
|
||||
const MAX_GIT_OUTPUT = 64 * 1024 * 1024
|
||||
const UTF8_DECODER = new TextDecoder('utf-8', { fatal: true })
|
||||
|
||||
interface ChangeScopeReport {
|
||||
formatVersion: typeof FORMAT_VERSION
|
||||
repository: {
|
||||
root: string
|
||||
branch: string | null
|
||||
upstream: string | null
|
||||
}
|
||||
input: {
|
||||
base: string
|
||||
head: string
|
||||
}
|
||||
resolved: {
|
||||
baseSha: string
|
||||
headSha: string
|
||||
mergeBaseSha: string
|
||||
}
|
||||
paths: {
|
||||
committed: string[]
|
||||
staged: string[]
|
||||
unstaged: string[]
|
||||
untracked: string[]
|
||||
}
|
||||
}
|
||||
|
||||
interface GitCommandResult {
|
||||
status: number | null
|
||||
stdout: string
|
||||
stderr: string
|
||||
error: Error | undefined
|
||||
}
|
||||
|
||||
interface GitBytesCommandResult {
|
||||
status: number | null
|
||||
stdout: Buffer
|
||||
stderr: Buffer
|
||||
error: Error | undefined
|
||||
}
|
||||
|
||||
interface ChangeScopeOptions {
|
||||
base: string
|
||||
head: string
|
||||
json: boolean
|
||||
}
|
||||
|
||||
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 {
|
||||
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`)
|
||||
}
|
||||
}
|
||||
|
||||
function failureDetail(result: GitCommandResult): string {
|
||||
return result.error?.message ?? (result.stderr.trim() || `Git exited with status ${String(result.status)}`)
|
||||
}
|
||||
|
||||
function requireGit(cwd: string, args: string[], context: string): string {
|
||||
const result = executeGit(cwd, args, context)
|
||||
if (result.status !== 0) throw new Error(`${context}: ${failureDetail(result)}`)
|
||||
return result.stdout
|
||||
}
|
||||
|
||||
function requireGitBytes(cwd: string, args: string[], context: string): Buffer {
|
||||
const result = executeGitBytes(cwd, args)
|
||||
if (result.status !== 0) {
|
||||
const detail = result.error?.message
|
||||
?? (result.stderr.toString('utf8').trim() || `Git exited with status ${String(result.status)}`)
|
||||
throw new Error(`${context}: ${detail}`)
|
||||
}
|
||||
return result.stdout
|
||||
}
|
||||
|
||||
function parseOptions(args: string[]): ChangeScopeOptions {
|
||||
const { values } = parseArgs({
|
||||
args,
|
||||
allowPositionals: false,
|
||||
options: {
|
||||
base: { type: 'string' },
|
||||
head: { type: 'string', default: 'HEAD' },
|
||||
json: { type: 'boolean', default: false },
|
||||
},
|
||||
strict: true,
|
||||
})
|
||||
if (values.base === undefined) throw new Error('missing required --base <ref>')
|
||||
return { base: values.base, head: values.head, json: values.json }
|
||||
}
|
||||
|
||||
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',
|
||||
'rev-parse',
|
||||
'--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`)
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`${label} ref ${JSON.stringify(ref)} does not resolve to a commit: ${failureDetail(result)}`)
|
||||
}
|
||||
const commits = result.stdout.trim().split(/\r?\n/u).filter(Boolean)
|
||||
if (commits.length !== 1) {
|
||||
throw new Error(`${label} ref ${JSON.stringify(ref)} did not resolve to exactly one commit`)
|
||||
}
|
||||
return commits[0] as string
|
||||
}
|
||||
|
||||
function resolveMergeBase(root: string, baseSha: string, headSha: string): string {
|
||||
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)}`)
|
||||
}
|
||||
const mergeBases = result.stdout.trim().split(/\r?\n/u).filter(Boolean)
|
||||
if (mergeBases.length !== 1) {
|
||||
throw new Error(`base and head do not have a unique merge base; found ${mergeBases.length}`)
|
||||
}
|
||||
return mergeBases[0] as string
|
||||
}
|
||||
|
||||
function currentBranch(root: string): string | null {
|
||||
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)
|
||||
}
|
||||
|
||||
function configuredUpstream(root: string, branch: string | null): string | null {
|
||||
if (branch === null) return null
|
||||
const output = stripGitLineTerminator(requireGit(
|
||||
root,
|
||||
['for-each-ref', '--count=1', '--format=%(upstream:short)', `refs/heads/${branch}`],
|
||||
'cannot inspect the configured upstream',
|
||||
))
|
||||
return output === '' ? null : output
|
||||
}
|
||||
|
||||
function comparePaths(left: string, right: string): number {
|
||||
if (left < right) return -1
|
||||
if (left > right) return 1
|
||||
return 0
|
||||
}
|
||||
|
||||
function parsePathSet(output: Buffer, context: string): string[] {
|
||||
const paths: string[] = []
|
||||
let start = 0
|
||||
let record = 0
|
||||
for (let end = 0; end < output.length; end += 1) {
|
||||
if (output[end] !== 0) continue
|
||||
if (end > start) {
|
||||
record += 1
|
||||
try {
|
||||
paths.push(UTF8_DECODER.decode(output.subarray(start, end)))
|
||||
} catch {
|
||||
throw new Error(`${context}: Git path ${record} is not valid UTF-8`)
|
||||
}
|
||||
}
|
||||
start = end + 1
|
||||
}
|
||||
return [...new Set(paths)].sort(comparePaths)
|
||||
}
|
||||
|
||||
function diffPaths(root: string, args: string[], context: string): string[] {
|
||||
return parsePathSet(requireGitBytes(root, [
|
||||
'diff',
|
||||
'--no-ext-diff',
|
||||
'--no-textconv',
|
||||
'--no-renames',
|
||||
'--ignore-submodules=none',
|
||||
'--name-only',
|
||||
'-z',
|
||||
...args,
|
||||
'--',
|
||||
], context), context)
|
||||
}
|
||||
|
||||
function stripGitLineTerminator(output: string): string {
|
||||
const withoutLineFeed = output.endsWith('\n') ? output.slice(0, -1) : output
|
||||
return process.platform === 'win32' && withoutLineFeed.endsWith('\r')
|
||||
? withoutLineFeed.slice(0, -1)
|
||||
: withoutLineFeed
|
||||
}
|
||||
|
||||
function collectReport(options: ChangeScopeOptions, cwd: string): ChangeScopeReport {
|
||||
const root = stripGitLineTerminator(
|
||||
requireGit(cwd, ['rev-parse', '--show-toplevel'], 'cannot locate a Git worktree'),
|
||||
)
|
||||
const baseSha = resolveCommit(root, 'base', options.base)
|
||||
const headSha = resolveCommit(root, 'head', options.head)
|
||||
const mergeBaseSha = resolveMergeBase(root, baseSha, headSha)
|
||||
const branch = currentBranch(root)
|
||||
return {
|
||||
formatVersion: FORMAT_VERSION,
|
||||
repository: {
|
||||
root,
|
||||
branch,
|
||||
upstream: configuredUpstream(root, branch),
|
||||
},
|
||||
input: {
|
||||
base: options.base,
|
||||
head: options.head,
|
||||
},
|
||||
resolved: {
|
||||
baseSha,
|
||||
headSha,
|
||||
mergeBaseSha,
|
||||
},
|
||||
paths: {
|
||||
committed: diffPaths(root, [mergeBaseSha, headSha], 'cannot inspect committed paths'),
|
||||
staged: diffPaths(root, ['--cached'], 'cannot inspect staged paths'),
|
||||
unstaged: diffPaths(root, [], 'cannot inspect unstaged paths'),
|
||||
untracked: parsePathSet(requireGitBytes(
|
||||
root,
|
||||
['ls-files', '--others', '--exclude-standard', '-z', '--'],
|
||||
'cannot inspect untracked paths',
|
||||
), 'cannot inspect untracked paths'),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function formatValue(value: string | null): string {
|
||||
return JSON.stringify(value)
|
||||
}
|
||||
|
||||
function formatPaths(label: string, paths: string[]): string[] {
|
||||
return [
|
||||
`${label} (${paths.length}):`,
|
||||
...(paths.length === 0 ? [' (none)'] : paths.map(path => ` - ${formatValue(path)}`)),
|
||||
]
|
||||
}
|
||||
|
||||
function formatHuman(report: ChangeScopeReport): string {
|
||||
return [
|
||||
`Format version: ${report.formatVersion}`,
|
||||
`Repository root: ${formatValue(report.repository.root)}`,
|
||||
`Branch: ${formatValue(report.repository.branch)}`,
|
||||
`Upstream: ${formatValue(report.repository.upstream)}`,
|
||||
`Base ref: ${formatValue(report.input.base)}`,
|
||||
`Head ref: ${formatValue(report.input.head)}`,
|
||||
`Base commit: ${report.resolved.baseSha}`,
|
||||
`Head commit: ${report.resolved.headSha}`,
|
||||
`Merge base: ${report.resolved.mergeBaseSha}`,
|
||||
...formatPaths('Committed paths', report.paths.committed),
|
||||
...formatPaths('Staged paths', report.paths.staged),
|
||||
...formatPaths('Unstaged paths', report.paths.unstaged),
|
||||
...formatPaths('Untracked paths', report.paths.untracked),
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate arguments, collect one complete report, then invoke the writer once.
|
||||
* @param args - Command-line arguments after the script path.
|
||||
* @param cwd - Directory whose containing Git worktree is inspected.
|
||||
* @param write - Destination called once only after every Git query succeeds.
|
||||
* @returns Nothing.
|
||||
*/
|
||||
export function writeChangeScope(
|
||||
args: string[],
|
||||
cwd: string,
|
||||
write: (output: string) => void,
|
||||
): void {
|
||||
const options = parseOptions(args)
|
||||
const report = collectReport(options, cwd)
|
||||
write(`${options.json ? JSON.stringify(report, null, 2) : formatHuman(report)}\n`)
|
||||
}
|
||||
|
||||
const entryPath = process.argv[1]
|
||||
if (entryPath !== undefined && resolve(entryPath) === fileURLToPath(import.meta.url)) {
|
||||
try {
|
||||
writeChangeScope(process.argv.slice(2), process.cwd(), output => process.stdout.write(output))
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
process.stderr.write(`change-scope: ${message}\n`)
|
||||
process.exitCode = 1
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user