change-scope.spec.ts 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232
  1. import { execFileSync } from 'node:child_process'
  2. import { 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): 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 write(path: string, content: string): void {
  32. mkdirSync(dirname(path), { recursive: true })
  33. writeFileSync(path, content)
  34. }
  35. function fixture(worktreeName = 'worktree'): Fixture {
  36. const container = mkdtempSync(join(tmpdir(), 'dsh-change-scope-'))
  37. fixtureRoots.push(container)
  38. const origin = join(container, 'origin.git')
  39. const root = join(container, worktreeName)
  40. const hooks = join(container, 'hooks')
  41. mkdirSync(hooks)
  42. git(container, ['init', '--bare', '--initial-branch=master', origin])
  43. git(container, ['init', '--initial-branch=master', root])
  44. git(root, ['config', 'user.email', 'change-scope@example.com'])
  45. git(root, ['config', 'user.name', 'Change Scope Tests'])
  46. git(root, ['config', 'commit.gpgsign', 'false'])
  47. git(root, ['config', 'core.hooksPath', hooks])
  48. write(join(root, 'README.md'), '# Fixture\n')
  49. git(root, ['add', 'README.md'])
  50. git(root, ['commit', '-m', 'initial'])
  51. git(root, ['remote', 'add', 'origin', origin])
  52. git(root, ['push', '--set-upstream', 'origin', 'master'])
  53. return { container, root, origin }
  54. }
  55. function commit(root: string, path: string, content: string): string {
  56. write(join(root, path), content)
  57. git(root, ['add', '--', path])
  58. git(root, ['commit', '-m', `add ${path}`])
  59. return git(root, ['rev-parse', 'HEAD'])
  60. }
  61. function invoke(root: string, args: string[]): string {
  62. const output: string[] = []
  63. writeChangeScope(args, root, chunk => output.push(chunk))
  64. expect(output).toHaveLength(1)
  65. return output[0] as string
  66. }
  67. function jsonReport(root: string, base: string, head?: string): Report {
  68. const args = ['--base', base, '--json']
  69. if (head !== undefined) args.push('--head', head)
  70. return JSON.parse(invoke(root, args)) as Report
  71. }
  72. function formatHumanFromJson(report: Report): string {
  73. const value = (input: string | null): string => JSON.stringify(input) ?? 'null'
  74. const paths = (label: string, entries: string[]): string[] => [
  75. `${label} (${entries.length}):`,
  76. ...(entries.length === 0 ? [' (none)'] : entries.map(entry => ` - ${value(entry)}`)),
  77. ]
  78. return [
  79. `Format version: ${report.formatVersion}`,
  80. `Repository root: ${value(report.repository.root)}`,
  81. `Branch: ${value(report.repository.branch)}`,
  82. `Upstream: ${value(report.repository.upstream)}`,
  83. `Base ref: ${value(report.input.base)}`,
  84. `Head ref: ${value(report.input.head)}`,
  85. `Base commit: ${report.resolved.baseSha}`,
  86. `Head commit: ${report.resolved.headSha}`,
  87. `Merge base: ${report.resolved.mergeBaseSha}`,
  88. ...paths('Committed paths', report.paths.committed),
  89. ...paths('Staged paths', report.paths.staged),
  90. ...paths('Unstaged paths', report.paths.unstaged),
  91. ...paths('Untracked paths', report.paths.untracked),
  92. '',
  93. ].join('\n')
  94. }
  95. function repositoryState(root: string): Record<string, string> {
  96. const status = git(root, ['status', '--porcelain=v2', '--branch', '-z'])
  97. return {
  98. status,
  99. head: git(root, ['rev-parse', 'HEAD']),
  100. refs: git(root, ['for-each-ref', '--format=%(refname) %(objectname)']),
  101. index: readFileSync(join(root, '.git/index')).toString('base64'),
  102. config: readFileSync(join(root, '.git/config')).toString('base64'),
  103. }
  104. }
  105. describe('change-scope', () => {
  106. it('uses an explicit base on a fresh branch without a same-name remote and after its first push', () => {
  107. const { root } = fixture()
  108. git(root, ['switch', '-c', 'feature'])
  109. git(root, ['branch', '--set-upstream-to=origin/master'])
  110. const headSha = commit(root, 'feature.txt', 'feature\n')
  111. const fresh = jsonReport(root, 'origin/master')
  112. expect(fresh.repository).toEqual({ root: realpathSync(root), branch: 'feature', upstream: 'origin/master' })
  113. expect(fresh.resolved).toEqual({
  114. baseSha: git(root, ['rev-parse', 'origin/master']),
  115. headSha,
  116. mergeBaseSha: git(root, ['rev-parse', 'origin/master']),
  117. })
  118. expect(fresh.paths).toEqual({ committed: ['feature.txt'], staged: [], unstaged: [], untracked: [] })
  119. expect(git(root, ['for-each-ref', '--format=%(refname)', 'refs/remotes/origin/feature'])).toBe('')
  120. git(root, ['push', '--set-upstream', 'origin', 'feature'])
  121. const pushed = jsonReport(root, 'origin/master')
  122. expect(pushed.repository.upstream).toBe('origin/feature')
  123. expect(pushed.paths.committed).toEqual(['feature.txt'])
  124. })
  125. it.skipIf(process.platform === 'win32')('preserves trailing spaces in the worktree path', () => {
  126. const { root } = fixture('worktree ')
  127. const report = jsonReport(root, 'HEAD')
  128. expect(report.repository.root).toBe(realpathSync(root))
  129. expect(report.paths).toEqual({ committed: [], staged: [], unstaged: [], untracked: [] })
  130. })
  131. it('reports an exact head above a non-master stacked base while dirty paths remain worktree-local', () => {
  132. const { root } = fixture()
  133. git(root, ['switch', '-c', 'foundation'])
  134. const baseSha = commit(root, 'foundation.txt', 'foundation\n')
  135. git(root, ['switch', '-c', 'topic'])
  136. const headSha = commit(root, 'topic.txt', 'topic\n')
  137. commit(root, 'later.txt', 'later\n')
  138. write(join(root, 'current-worktree.txt'), 'current worktree\n')
  139. const report = jsonReport(root, 'foundation', headSha)
  140. expect(report.input).toEqual({ base: 'foundation', head: headSha })
  141. expect(report.resolved).toEqual({ baseSha, headSha, mergeBaseSha: baseSha })
  142. expect(report.paths.committed).toEqual(['topic.txt'])
  143. expect(report.paths.untracked).toEqual(['current-worktree.txt'])
  144. })
  145. it('keeps committed, staged, unstaged, and untracked paths independent and does not mutate state', () => {
  146. const { root } = fixture()
  147. commit(root, 'unstaged.txt', 'before\n')
  148. const baseSha = git(root, ['rev-parse', 'HEAD'])
  149. commit(root, 'committed.txt', 'committed\n')
  150. write(join(root, 'staged.txt'), 'staged\n')
  151. write(join(root, 'mixed.txt'), 'staged part\n')
  152. git(root, ['add', 'staged.txt', 'mixed.txt'])
  153. write(join(root, 'mixed.txt'), 'staged part\nunstaged part\n')
  154. write(join(root, 'unstaged.txt'), 'unstaged\n')
  155. write(join(root, 'untracked.txt'), 'untracked\n')
  156. const before = repositoryState(root)
  157. const report = jsonReport(root, baseSha)
  158. expect(report.paths).toEqual({
  159. committed: ['committed.txt'],
  160. staged: ['mixed.txt', 'staged.txt'],
  161. unstaged: ['mixed.txt', 'unstaged.txt'],
  162. untracked: ['untracked.txt'],
  163. })
  164. expect(repositoryState(root)).toEqual(before)
  165. })
  166. it('rejects missing, ambiguous, and non-commit refs before writing output', () => {
  167. const { root } = fixture()
  168. git(root, ['branch', 'collision'])
  169. git(root, ['tag', 'collision'])
  170. write(join(root, 'blob.txt'), 'blob\n')
  171. const blobSha = git(root, ['hash-object', '-w', 'blob.txt'])
  172. git(root, ['tag', 'blob-ref', blobSha])
  173. for (const { args, message } of [
  174. { args: ['--base', 'missing'], message: /base ref .* does not resolve to a commit/u },
  175. { args: ['--base', 'collision'], message: /base ref .* is ambiguous/u },
  176. { args: ['--base', 'blob-ref'], message: /base ref .* does not resolve to a commit/u },
  177. { args: ['--base', 'HEAD', '--head', 'missing'], message: /head ref .* does not resolve to a commit/u },
  178. ]) {
  179. const output: string[] = []
  180. expect(() => {
  181. writeChangeScope(args, root, (chunk) => {
  182. output.push(chunk)
  183. })
  184. }).toThrow(message)
  185. expect(output).toEqual([])
  186. }
  187. })
  188. it('renders deterministic human and JSON forms with the same facts', () => {
  189. const { root } = fixture()
  190. git(root, ['switch', '-c', 'format'])
  191. commit(root, 'zeta.txt', 'zeta\n')
  192. commit(root, 'alpha.txt', 'alpha\n')
  193. const json = invoke(root, ['--base', 'origin/master', '--json'])
  194. const repeatedJson = invoke(root, ['--base', 'origin/master', '--json'])
  195. const human = invoke(root, ['--base', 'origin/master'])
  196. const repeatedHuman = invoke(root, ['--base', 'origin/master'])
  197. const report = JSON.parse(json) as Report
  198. expect(json).toBe(repeatedJson)
  199. expect(report.formatVersion).toBe(1)
  200. expect(report.paths.committed).toEqual(['alpha.txt', 'zeta.txt'])
  201. expect(human).toBe(repeatedHuman)
  202. expect(human).toBe(formatHumanFromJson(report))
  203. })
  204. })