change-scope.spec.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343
  1. import { execFileSync } from 'node:child_process'
  2. import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
  3. import { tmpdir } from 'node:os'
  4. import { dirname, join } from 'node:path'
  5. import { afterEach, describe, expect, it } from 'vitest'
  6. import { writeChangeScope } from './change-scope.ts'
  7. interface Report {
  8. formatVersion: number
  9. repository: { root: string; branch: string | null; upstream: string | null }
  10. input: { base: string; head: string }
  11. resolved: { baseSha: string; headSha: string; mergeBaseSha: string }
  12. paths: { committed: string[]; staged: string[]; unstaged: string[]; untracked: string[] }
  13. }
  14. interface Fixture {
  15. container: string
  16. root: string
  17. origin: string
  18. }
  19. const fixtureRoots: string[] = []
  20. afterEach(() => {
  21. for (const root of fixtureRoots.splice(0)) rmSync(root, { recursive: true, force: true })
  22. })
  23. function git(cwd: string, args: string[], input?: string | Buffer): string {
  24. return execFileSync('git', ['-C', cwd, ...args], {
  25. encoding: 'utf8',
  26. env: { ...process.env, LANG: 'C', LC_ALL: 'C' },
  27. input,
  28. stdio: ['pipe', 'pipe', 'pipe'],
  29. }).trim()
  30. }
  31. function gitBytes(cwd: string, args: string[], input?: Buffer): Buffer {
  32. return execFileSync('git', ['-C', cwd, ...args], {
  33. env: { ...process.env, LANG: 'C', LC_ALL: 'C' },
  34. input,
  35. stdio: ['pipe', 'pipe', 'pipe'],
  36. })
  37. }
  38. function write(path: string, content: string, mode?: number): void {
  39. mkdirSync(dirname(path), { recursive: true })
  40. writeFileSync(path, content, mode === undefined ? undefined : { mode })
  41. }
  42. function fixture(worktreeName = 'worktree'): Fixture {
  43. const container = mkdtempSync(join(tmpdir(), 'dsh-change-scope-'))
  44. fixtureRoots.push(container)
  45. const origin = join(container, 'origin.git')
  46. const root = join(container, worktreeName)
  47. const hooks = join(container, 'hooks')
  48. mkdirSync(hooks)
  49. git(container, ['init', '--bare', '--initial-branch=master', origin])
  50. git(container, ['init', '--initial-branch=master', root])
  51. git(root, ['config', 'user.email', 'change-scope@example.com'])
  52. git(root, ['config', 'user.name', 'Change Scope Tests'])
  53. git(root, ['config', 'commit.gpgsign', 'false'])
  54. git(root, ['config', 'core.hooksPath', hooks])
  55. write(join(root, 'README.md'), '# Fixture\n')
  56. git(root, ['add', 'README.md'])
  57. git(root, ['commit', '-m', 'initial'])
  58. git(root, ['remote', 'add', 'origin', origin])
  59. git(root, ['push', '--set-upstream', 'origin', 'master'])
  60. return { container, root, origin }
  61. }
  62. function commit(root: string, path: string, content: string): string {
  63. write(join(root, path), content)
  64. git(root, ['add', '--', path])
  65. git(root, ['commit', '-m', `add ${path}`])
  66. return git(root, ['rev-parse', 'HEAD'])
  67. }
  68. function invoke(root: string, args: string[]): string {
  69. const output: string[] = []
  70. writeChangeScope(args, root, chunk => output.push(chunk))
  71. expect(output).toHaveLength(1)
  72. return output[0] as string
  73. }
  74. function jsonReport(root: string, base: string, head?: string): Report {
  75. const args = ['--base', base, '--json']
  76. if (head !== undefined) args.push('--head', head)
  77. return JSON.parse(invoke(root, args)) as Report
  78. }
  79. function formatHumanFromJson(report: Report): string {
  80. const value = (input: string | null): string => JSON.stringify(input) ?? 'null'
  81. const paths = (label: string, entries: string[]): string[] => [
  82. `${label} (${entries.length}):`,
  83. ...(entries.length === 0 ? [' (none)'] : entries.map(entry => ` - ${value(entry)}`)),
  84. ]
  85. return [
  86. `Format version: ${report.formatVersion}`,
  87. `Repository root: ${value(report.repository.root)}`,
  88. `Branch: ${value(report.repository.branch)}`,
  89. `Upstream: ${value(report.repository.upstream)}`,
  90. `Base ref: ${value(report.input.base)}`,
  91. `Head ref: ${value(report.input.head)}`,
  92. `Base commit: ${report.resolved.baseSha}`,
  93. `Head commit: ${report.resolved.headSha}`,
  94. `Merge base: ${report.resolved.mergeBaseSha}`,
  95. ...paths('Committed paths', report.paths.committed),
  96. ...paths('Staged paths', report.paths.staged),
  97. ...paths('Unstaged paths', report.paths.unstaged),
  98. ...paths('Untracked paths', report.paths.untracked),
  99. '',
  100. ].join('\n')
  101. }
  102. function repositoryState(root: string): Record<string, string> {
  103. const status = git(root, ['status', '--porcelain=v2', '--branch', '-z'])
  104. return {
  105. status,
  106. head: git(root, ['rev-parse', 'HEAD']),
  107. refs: git(root, ['for-each-ref', '--format=%(refname) %(objectname)']),
  108. index: readFileSync(join(root, '.git/index')).toString('base64'),
  109. config: readFileSync(join(root, '.git/config')).toString('base64'),
  110. }
  111. }
  112. describe('change-scope', () => {
  113. it('uses an explicit base on a fresh branch without a same-name remote and after its first push', () => {
  114. const { root } = fixture()
  115. git(root, ['switch', '-c', 'feature'])
  116. git(root, ['branch', '--set-upstream-to=origin/master'])
  117. const headSha = commit(root, 'feature.txt', 'feature\n')
  118. const fresh = jsonReport(root, 'origin/master')
  119. expect(fresh.repository).toEqual({ root: realpathSync(root), branch: 'feature', upstream: 'origin/master' })
  120. expect(fresh.resolved).toEqual({
  121. baseSha: git(root, ['rev-parse', 'origin/master']),
  122. headSha,
  123. mergeBaseSha: git(root, ['rev-parse', 'origin/master']),
  124. })
  125. expect(fresh.paths).toEqual({ committed: ['feature.txt'], staged: [], unstaged: [], untracked: [] })
  126. expect(git(root, ['for-each-ref', '--format=%(refname)', 'refs/remotes/origin/feature'])).toBe('')
  127. git(root, ['push', '--set-upstream', 'origin', 'feature'])
  128. const pushed = jsonReport(root, 'origin/master')
  129. expect(pushed.repository.upstream).toBe('origin/feature')
  130. expect(pushed.paths.committed).toEqual(['feature.txt'])
  131. })
  132. it.skipIf(process.platform === 'win32')('preserves trailing spaces in the worktree path', () => {
  133. const { root } = fixture('worktree ')
  134. const report = jsonReport(root, 'HEAD')
  135. expect(report.repository.root).toBe(realpathSync(root))
  136. expect(report.paths).toEqual({ committed: [], staged: [], unstaged: [], untracked: [] })
  137. })
  138. it('preserves legal Unicode edge whitespace in branch and upstream names', () => {
  139. const { root } = fixture()
  140. const branch = '\u00a0topic\u3000'
  141. const upstreamBranch = '\u3000upstream\u00a0'
  142. git(root, ['switch', '-c', branch])
  143. git(root, ['push', 'origin', `HEAD:refs/heads/${upstreamBranch}`])
  144. git(root, ['branch', '--set-upstream-to', `origin/${upstreamBranch}`])
  145. const report = jsonReport(root, 'origin/master')
  146. expect(report.repository.branch).toBe(branch)
  147. expect(report.repository.upstream).toBe(`origin/${upstreamBranch}`)
  148. })
  149. it('reports an exact head above a non-master stacked base while dirty paths remain worktree-local', () => {
  150. const { root } = fixture()
  151. git(root, ['switch', '-c', 'foundation'])
  152. const baseSha = commit(root, 'foundation.txt', 'foundation\n')
  153. git(root, ['switch', '-c', 'topic'])
  154. const headSha = commit(root, 'topic.txt', 'topic\n')
  155. commit(root, 'later.txt', 'later\n')
  156. write(join(root, 'current-worktree.txt'), 'current worktree\n')
  157. const report = jsonReport(root, 'foundation', headSha)
  158. expect(report.input).toEqual({ base: 'foundation', head: headSha })
  159. expect(report.resolved).toEqual({ baseSha, headSha, mergeBaseSha: baseSha })
  160. expect(report.paths.committed).toEqual(['topic.txt'])
  161. expect(report.paths.untracked).toEqual(['current-worktree.txt'])
  162. })
  163. it('keeps committed, staged, unstaged, and untracked paths independent and does not mutate state', () => {
  164. const { root } = fixture()
  165. commit(root, 'unstaged.txt', 'before\n')
  166. const baseSha = git(root, ['rev-parse', 'HEAD'])
  167. commit(root, 'committed.txt', 'committed\n')
  168. write(join(root, 'staged.txt'), 'staged\n')
  169. write(join(root, 'mixed.txt'), 'staged part\n')
  170. git(root, ['add', 'staged.txt', 'mixed.txt'])
  171. write(join(root, 'mixed.txt'), 'staged part\nunstaged part\n')
  172. write(join(root, 'unstaged.txt'), 'unstaged\n')
  173. write(join(root, 'untracked.txt'), 'untracked\n')
  174. const before = repositoryState(root)
  175. const report = jsonReport(root, baseSha)
  176. expect(report.paths).toEqual({
  177. committed: ['committed.txt'],
  178. staged: ['mixed.txt', 'staged.txt'],
  179. unstaged: ['mixed.txt', 'unstaged.txt'],
  180. untracked: ['untracked.txt'],
  181. })
  182. expect(repositoryState(root)).toEqual(before)
  183. })
  184. it.skipIf(process.platform === 'win32')('does not execute a configured filesystem monitor', () => {
  185. const { container, root } = fixture()
  186. const monitor = join(container, 'fsmonitor.sh')
  187. const sideEffect = `${monitor}.ran`
  188. write(monitor, '#!/bin/sh\ntouch "$0.ran"\n', 0o755)
  189. git(root, ['config', 'core.fsmonitor', monitor])
  190. const report = jsonReport(root, 'HEAD')
  191. expect(report.paths).toEqual({ committed: [], staged: [], unstaged: [], untracked: [] })
  192. expect(existsSync(sideEffect)).toBe(false)
  193. })
  194. it.skipIf(process.platform === 'win32')('rejects non-UTF-8 branch and upstream names without partial output', () => {
  195. const invalidBranch = fixture()
  196. const branchHead = git(invalidBranch.root, ['rev-parse', 'HEAD'])
  197. const invalidBranchName = Buffer.from([0x80])
  198. writeFileSync(join(invalidBranch.root, '.git/packed-refs'), Buffer.concat([
  199. Buffer.from(`${branchHead} refs/heads/`),
  200. invalidBranchName,
  201. Buffer.from('\n'),
  202. ]))
  203. writeFileSync(
  204. join(invalidBranch.root, '.git/HEAD'),
  205. Buffer.concat([Buffer.from('ref: refs/heads/'), invalidBranchName, Buffer.from('\n')]),
  206. )
  207. const branchOutput: string[] = []
  208. expect(() => {
  209. writeChangeScope(['--base', branchHead, '--json'], invalidBranch.root, chunk => branchOutput.push(chunk))
  210. }).toThrow('cannot inspect the current branch: Git stdout is not valid UTF-8')
  211. expect(branchOutput).toEqual([])
  212. const invalidUpstream = fixture()
  213. const upstreamHead = git(invalidUpstream.root, ['rev-parse', 'HEAD'])
  214. const invalidUpstreamName = Buffer.from([0x81])
  215. writeFileSync(join(invalidUpstream.root, '.git/packed-refs'), Buffer.concat([
  216. Buffer.from(`${upstreamHead} refs/remotes/origin/`),
  217. invalidUpstreamName,
  218. Buffer.from('\n'),
  219. ]))
  220. const configPath = join(invalidUpstream.root, '.git/config')
  221. const config = readFileSync(configPath)
  222. const merge = Buffer.from('\tmerge = refs/heads/master\n')
  223. const mergeIndex = config.indexOf(merge)
  224. expect(mergeIndex).toBeGreaterThanOrEqual(0)
  225. writeFileSync(configPath, Buffer.concat([
  226. config.subarray(0, mergeIndex),
  227. Buffer.from('\tmerge = refs/heads/'),
  228. invalidUpstreamName,
  229. Buffer.from('\n'),
  230. config.subarray(mergeIndex + merge.length),
  231. ]))
  232. const upstreamOutput: string[] = []
  233. expect(() => {
  234. writeChangeScope(['--base', upstreamHead, '--json'], invalidUpstream.root, chunk => upstreamOutput.push(chunk))
  235. }).toThrow('cannot inspect the configured upstream: Git stdout is not valid UTF-8')
  236. expect(upstreamOutput).toEqual([])
  237. })
  238. it.skipIf(process.platform === 'win32')('rejects distinct non-UTF-8 Git paths without partial output', () => {
  239. const { root } = fixture()
  240. const blobSha = git(root, ['hash-object', '-w', '--stdin'], 'content')
  241. const entry = Buffer.from(`100644 ${blobSha}\t`, 'ascii')
  242. const firstPath = Buffer.from([0x80])
  243. const secondPath = Buffer.from([0x81])
  244. gitBytes(root, ['update-index', '-z', '--index-info'], Buffer.concat([
  245. entry,
  246. firstPath,
  247. Buffer.from([0]),
  248. entry,
  249. secondPath,
  250. Buffer.from([0]),
  251. ]))
  252. expect(gitBytes(root, ['diff', '--cached', '--name-only', '-z', '--'])).toEqual(Buffer.concat([
  253. firstPath,
  254. Buffer.from([0]),
  255. secondPath,
  256. Buffer.from([0]),
  257. ]))
  258. const output: string[] = []
  259. expect(() => {
  260. writeChangeScope(['--base', 'HEAD', '--json'], root, chunk => output.push(chunk))
  261. }).toThrow('cannot inspect staged paths: Git path 1 is not valid UTF-8')
  262. expect(output).toEqual([])
  263. })
  264. it('rejects missing, ambiguous, and non-commit refs before writing output', () => {
  265. const { root } = fixture()
  266. git(root, ['branch', 'collision'])
  267. git(root, ['tag', 'collision'])
  268. write(join(root, 'blob.txt'), 'blob\n')
  269. const blobSha = git(root, ['hash-object', '-w', 'blob.txt'])
  270. git(root, ['tag', 'blob-ref', blobSha])
  271. for (const { args, message } of [
  272. { args: ['--base', 'missing'], message: /base ref .* does not resolve to a commit/u },
  273. { args: ['--base', 'collision'], message: /base ref .* is ambiguous/u },
  274. { args: ['--base', 'blob-ref'], message: /base ref .* does not resolve to a commit/u },
  275. { args: ['--base', 'HEAD', '--head', 'missing'], message: /head ref .* does not resolve to a commit/u },
  276. ]) {
  277. const output: string[] = []
  278. expect(() => {
  279. writeChangeScope(args, root, (chunk) => {
  280. output.push(chunk)
  281. })
  282. }).toThrow(message)
  283. expect(output).toEqual([])
  284. }
  285. })
  286. it('renders deterministic human and JSON forms with the same facts', () => {
  287. const { root } = fixture()
  288. git(root, ['switch', '-c', 'format'])
  289. commit(root, 'zeta.txt', 'zeta\n')
  290. commit(root, 'alpha.txt', 'alpha\n')
  291. const json = invoke(root, ['--base', 'origin/master', '--json'])
  292. const repeatedJson = invoke(root, ['--base', 'origin/master', '--json'])
  293. const human = invoke(root, ['--base', 'origin/master'])
  294. const repeatedHuman = invoke(root, ['--base', 'origin/master'])
  295. const report = JSON.parse(json) as Report
  296. expect(json).toBe(repeatedJson)
  297. expect(report.formatVersion).toBe(1)
  298. expect(report.paths.committed).toEqual(['alpha.txt', 'zeta.txt'])
  299. expect(human).toBe(repeatedHuman)
  300. expect(human).toBe(formatHumanFromJson(report))
  301. })
  302. })