change-scope.ts 9.8 KB

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