change-scope.ts 7.5 KB

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