change-scope.ts 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261
  1. /** Report the explicit committed and worktree scope of a repository change. */
  2. import { spawnSync } from 'node:child_process'
  3. import { fileURLToPath } from 'node:url'
  4. import { resolve } from 'node:path'
  5. import { parseArgs } from 'node:util'
  6. const FORMAT_VERSION = 1
  7. const MAX_GIT_OUTPUT = 64 * 1024 * 1024
  8. interface ChangeScopeReport {
  9. formatVersion: typeof FORMAT_VERSION
  10. repository: {
  11. root: string
  12. branch: string | null
  13. upstream: string | null
  14. }
  15. input: {
  16. base: string
  17. head: string
  18. }
  19. resolved: {
  20. baseSha: string
  21. headSha: string
  22. mergeBaseSha: string
  23. }
  24. paths: {
  25. committed: string[]
  26. staged: string[]
  27. unstaged: string[]
  28. untracked: string[]
  29. }
  30. }
  31. interface GitCommandResult {
  32. status: number | null
  33. stdout: string
  34. stderr: string
  35. error: Error | undefined
  36. }
  37. interface ChangeScopeOptions {
  38. base: string
  39. head: string
  40. json: boolean
  41. }
  42. function executeGit(cwd: string, args: string[]): GitCommandResult {
  43. const result = spawnSync('git', ['-C', cwd, ...args], {
  44. encoding: 'utf8',
  45. env: { ...process.env, LANG: 'C', LC_ALL: 'C' },
  46. maxBuffer: MAX_GIT_OUTPUT,
  47. })
  48. return {
  49. status: result.status,
  50. stdout: result.stdout,
  51. stderr: result.stderr,
  52. error: result.error,
  53. }
  54. }
  55. function failureDetail(result: GitCommandResult): string {
  56. return result.error?.message ?? (result.stderr.trim() || `Git exited with status ${String(result.status)}`)
  57. }
  58. function requireGit(cwd: string, args: string[], context: string): string {
  59. const result = executeGit(cwd, args)
  60. if (result.status !== 0) throw new Error(`${context}: ${failureDetail(result)}`)
  61. return result.stdout
  62. }
  63. function parseOptions(args: string[]): ChangeScopeOptions {
  64. const { values } = parseArgs({
  65. args,
  66. allowPositionals: false,
  67. options: {
  68. base: { type: 'string' },
  69. head: { type: 'string', default: 'HEAD' },
  70. json: { type: 'boolean', default: false },
  71. },
  72. strict: true,
  73. })
  74. if (values.base === undefined) throw new Error('missing required --base <ref>')
  75. return { base: values.base, head: values.head, json: values.json }
  76. }
  77. function resolveCommit(root: string, label: 'base' | 'head', ref: string): string {
  78. const result = executeGit(root, [
  79. '-c',
  80. 'core.warnAmbiguousRefs=true',
  81. 'rev-parse',
  82. '--verify',
  83. '--end-of-options',
  84. `${ref}^{commit}`,
  85. ])
  86. if (/\bambiguous\b/iu.test(result.stderr)) {
  87. throw new Error(`${label} ref ${JSON.stringify(ref)} is ambiguous; use a fully qualified ref or commit ID`)
  88. }
  89. if (result.status !== 0) {
  90. throw new Error(`${label} ref ${JSON.stringify(ref)} does not resolve to a commit: ${failureDetail(result)}`)
  91. }
  92. const commits = result.stdout.trim().split(/\r?\n/u).filter(Boolean)
  93. if (commits.length !== 1) {
  94. throw new Error(`${label} ref ${JSON.stringify(ref)} did not resolve to exactly one commit`)
  95. }
  96. return commits[0] as string
  97. }
  98. function resolveMergeBase(root: string, baseSha: string, headSha: string): string {
  99. const result = executeGit(root, ['merge-base', '--all', baseSha, headSha])
  100. if (result.status !== 0) {
  101. throw new Error(`base and head do not have a merge base: ${failureDetail(result)}`)
  102. }
  103. const mergeBases = result.stdout.trim().split(/\r?\n/u).filter(Boolean)
  104. if (mergeBases.length !== 1) {
  105. throw new Error(`base and head do not have a unique merge base; found ${mergeBases.length}`)
  106. }
  107. return mergeBases[0] as string
  108. }
  109. function currentBranch(root: string): string | null {
  110. const result = executeGit(root, ['symbolic-ref', '--quiet', '--short', 'HEAD'])
  111. if (result.status === 1) return null
  112. if (result.status !== 0) throw new Error(`cannot inspect the current branch: ${failureDetail(result)}`)
  113. return result.stdout.trim()
  114. }
  115. function configuredUpstream(root: string, branch: string | null): string | null {
  116. if (branch === null) return null
  117. const output = requireGit(
  118. root,
  119. ['for-each-ref', '--count=1', '--format=%(upstream:short)', `refs/heads/${branch}`],
  120. 'cannot inspect the configured upstream',
  121. ).trim()
  122. return output === '' ? null : output
  123. }
  124. function comparePaths(left: string, right: string): number {
  125. if (left < right) return -1
  126. if (left > right) return 1
  127. return 0
  128. }
  129. function parsePathSet(output: string): string[] {
  130. return [...new Set(output.split('\0').filter(Boolean))].sort(comparePaths)
  131. }
  132. function diffPaths(root: string, args: string[], context: string): string[] {
  133. return parsePathSet(requireGit(root, [
  134. 'diff',
  135. '--no-ext-diff',
  136. '--no-textconv',
  137. '--no-renames',
  138. '--ignore-submodules=none',
  139. '--name-only',
  140. '-z',
  141. ...args,
  142. '--',
  143. ], context))
  144. }
  145. function stripGitLineTerminator(output: string): string {
  146. const withoutLineFeed = output.endsWith('\n') ? output.slice(0, -1) : output
  147. return process.platform === 'win32' && withoutLineFeed.endsWith('\r')
  148. ? withoutLineFeed.slice(0, -1)
  149. : withoutLineFeed
  150. }
  151. function collectReport(options: ChangeScopeOptions, cwd: string): ChangeScopeReport {
  152. const root = stripGitLineTerminator(
  153. requireGit(cwd, ['rev-parse', '--show-toplevel'], 'cannot locate a Git worktree'),
  154. )
  155. const baseSha = resolveCommit(root, 'base', options.base)
  156. const headSha = resolveCommit(root, 'head', options.head)
  157. const mergeBaseSha = resolveMergeBase(root, baseSha, headSha)
  158. const branch = currentBranch(root)
  159. return {
  160. formatVersion: FORMAT_VERSION,
  161. repository: {
  162. root,
  163. branch,
  164. upstream: configuredUpstream(root, branch),
  165. },
  166. input: {
  167. base: options.base,
  168. head: options.head,
  169. },
  170. resolved: {
  171. baseSha,
  172. headSha,
  173. mergeBaseSha,
  174. },
  175. paths: {
  176. committed: diffPaths(root, [mergeBaseSha, headSha], 'cannot inspect committed paths'),
  177. staged: diffPaths(root, ['--cached'], 'cannot inspect staged paths'),
  178. unstaged: diffPaths(root, [], 'cannot inspect unstaged paths'),
  179. untracked: parsePathSet(requireGit(
  180. root,
  181. ['ls-files', '--others', '--exclude-standard', '-z', '--'],
  182. 'cannot inspect untracked paths',
  183. )),
  184. },
  185. }
  186. }
  187. function formatValue(value: string | null): string {
  188. return JSON.stringify(value)
  189. }
  190. function formatPaths(label: string, paths: string[]): string[] {
  191. return [
  192. `${label} (${paths.length}):`,
  193. ...(paths.length === 0 ? [' (none)'] : paths.map(path => ` - ${formatValue(path)}`)),
  194. ]
  195. }
  196. function formatHuman(report: ChangeScopeReport): string {
  197. return [
  198. `Format version: ${report.formatVersion}`,
  199. `Repository root: ${formatValue(report.repository.root)}`,
  200. `Branch: ${formatValue(report.repository.branch)}`,
  201. `Upstream: ${formatValue(report.repository.upstream)}`,
  202. `Base ref: ${formatValue(report.input.base)}`,
  203. `Head ref: ${formatValue(report.input.head)}`,
  204. `Base commit: ${report.resolved.baseSha}`,
  205. `Head commit: ${report.resolved.headSha}`,
  206. `Merge base: ${report.resolved.mergeBaseSha}`,
  207. ...formatPaths('Committed paths', report.paths.committed),
  208. ...formatPaths('Staged paths', report.paths.staged),
  209. ...formatPaths('Unstaged paths', report.paths.unstaged),
  210. ...formatPaths('Untracked paths', report.paths.untracked),
  211. ].join('\n')
  212. }
  213. /**
  214. * Validate arguments, collect one complete report, then invoke the writer once.
  215. * @param args - Command-line arguments after the script path.
  216. * @param cwd - Directory whose containing Git worktree is inspected.
  217. * @param write - Destination called once only after every Git query succeeds.
  218. * @returns Nothing.
  219. */
  220. export function writeChangeScope(
  221. args: string[],
  222. cwd: string,
  223. write: (output: string) => void,
  224. ): void {
  225. const options = parseOptions(args)
  226. const report = collectReport(options, cwd)
  227. write(`${options.json ? JSON.stringify(report, null, 2) : formatHuman(report)}\n`)
  228. }
  229. const entryPath = process.argv[1]
  230. if (entryPath !== undefined && resolve(entryPath) === fileURLToPath(import.meta.url)) {
  231. try {
  232. writeChangeScope(process.argv.slice(2), process.cwd(), output => process.stdout.write(output))
  233. } catch (error) {
  234. const message = error instanceof Error ? error.message : String(error)
  235. process.stderr.write(`change-scope: ${message}\n`)
  236. process.exitCode = 1
  237. }
  238. }