| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261 |
- /** 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 } from 'node:util'
- const FORMAT_VERSION = 1
- const MAX_GIT_OUTPUT = 64 * 1024 * 1024
- 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 ChangeScopeOptions {
- base: string
- head: string
- 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' },
- maxBuffer: MAX_GIT_OUTPUT,
- })
- return {
- status: result.status,
- stdout: result.stdout,
- stderr: result.stderr,
- error: result.error,
- }
- }
- 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)
- if (result.status !== 0) throw new Error(`${context}: ${failureDetail(result)}`)
- 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 result = executeGit(root, [
- '-c',
- 'core.warnAmbiguousRefs=true',
- 'rev-parse',
- '--verify',
- '--end-of-options',
- `${ref}^{commit}`,
- ])
- 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])
- 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'])
- if (result.status === 1) return null
- if (result.status !== 0) throw new Error(`cannot inspect the current branch: ${failureDetail(result)}`)
- return result.stdout.trim()
- }
- function configuredUpstream(root: string, branch: string | null): string | null {
- if (branch === null) return null
- const output = requireGit(
- root,
- ['for-each-ref', '--count=1', '--format=%(upstream:short)', `refs/heads/${branch}`],
- 'cannot inspect the configured upstream',
- ).trim()
- 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: string): string[] {
- return [...new Set(output.split('\0').filter(Boolean))].sort(comparePaths)
- }
- function diffPaths(root: string, args: string[], context: string): string[] {
- return parsePathSet(requireGit(root, [
- 'diff',
- '--no-ext-diff',
- '--no-textconv',
- '--no-renames',
- '--ignore-submodules=none',
- '--name-only',
- '-z',
- ...args,
- '--',
- ], 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(requireGit(
- root,
- ['ls-files', '--others', '--exclude-standard', '-z', '--'],
- '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
- }
- }
|