change-scope.spec.ts 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241
  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 { renderChangeScope } from './change-scope.ts'
  7. interface Report {
  8. formatVersion: number
  9. repositoryRoot: string
  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. }
  18. const fixtureRoots: string[] = []
  19. afterEach(() => {
  20. for (const root of fixtureRoots.splice(0)) rmSync(root, { recursive: true, force: true })
  21. })
  22. function git(cwd: string, args: string[], input?: string | Buffer): string {
  23. return execFileSync('git', ['-C', cwd, ...args], {
  24. encoding: 'utf8',
  25. env: { ...process.env, LANG: 'C', LC_ALL: 'C' },
  26. input,
  27. stdio: ['pipe', 'pipe', 'pipe'],
  28. }).trim()
  29. }
  30. function gitBytes(cwd: string, args: string[], input?: Buffer): Buffer {
  31. return execFileSync('git', ['-C', cwd, ...args], {
  32. env: { ...process.env, LANG: 'C', LC_ALL: 'C' },
  33. input,
  34. stdio: ['pipe', 'pipe', 'pipe'],
  35. })
  36. }
  37. function write(path: string, content: string, mode?: number): void {
  38. mkdirSync(dirname(path), { recursive: true })
  39. writeFileSync(path, content, mode === undefined ? undefined : { mode })
  40. }
  41. function fixture(worktreeName = 'worktree'): Fixture {
  42. const container = mkdtempSync(join(tmpdir(), 'dsh-change-scope-'))
  43. fixtureRoots.push(container)
  44. const origin = join(container, 'origin.git')
  45. const root = join(container, worktreeName)
  46. const hooks = join(container, 'hooks')
  47. mkdirSync(hooks)
  48. git(container, ['init', '--bare', '--initial-branch=master', origin])
  49. git(container, ['init', '--initial-branch=master', root])
  50. git(root, ['config', 'user.email', 'change-scope@example.com'])
  51. git(root, ['config', 'user.name', 'Change Scope Tests'])
  52. git(root, ['config', 'commit.gpgsign', 'false'])
  53. git(root, ['config', 'core.hooksPath', hooks])
  54. write(join(root, 'README.md'), '# Fixture\n')
  55. git(root, ['add', 'README.md'])
  56. git(root, ['commit', '-m', 'initial'])
  57. git(root, ['remote', 'add', 'origin', origin])
  58. git(root, ['push', '--set-upstream', 'origin', 'master'])
  59. return { container, root }
  60. }
  61. function commit(root: string, path: string, content: string): string {
  62. write(join(root, path), content)
  63. git(root, ['add', '--', path])
  64. git(root, ['commit', '-m', `add ${path}`])
  65. return git(root, ['rev-parse', 'HEAD'])
  66. }
  67. function invoke(root: string, args: string[]): string {
  68. return renderChangeScope(args, root)
  69. }
  70. function jsonReport(root: string, base: string, head?: string): Report {
  71. const args = ['--base', base]
  72. if (head !== undefined) args.push('--head', head)
  73. return JSON.parse(invoke(root, args)) as Report
  74. }
  75. function repositoryState(root: string): Record<string, string> {
  76. const status = git(root, ['status', '--porcelain=v2', '--branch', '-z'])
  77. return {
  78. status,
  79. head: git(root, ['rev-parse', 'HEAD']),
  80. refs: git(root, ['for-each-ref', '--format=%(refname) %(objectname)']),
  81. index: readFileSync(join(root, '.git/index')).toString('base64'),
  82. config: readFileSync(join(root, '.git/config')).toString('base64'),
  83. }
  84. }
  85. describe('change-scope', () => {
  86. it('uses an explicit base on a fresh branch without a same-name remote and after its first push', { timeout: 20_000 }, () => {
  87. const { root } = fixture()
  88. git(root, ['switch', '-c', 'feature'])
  89. git(root, ['branch', '--set-upstream-to=origin/master'])
  90. const headSha = commit(root, 'feature.txt', 'feature\n')
  91. const fresh = jsonReport(root, 'origin/master')
  92. expect(realpathSync.native(fresh.repositoryRoot)).toBe(realpathSync.native(root))
  93. expect(fresh.resolved).toEqual({
  94. baseSha: git(root, ['rev-parse', 'origin/master']),
  95. headSha,
  96. mergeBaseSha: git(root, ['rev-parse', 'origin/master']),
  97. })
  98. expect(fresh.paths).toEqual({ committed: ['feature.txt'], staged: [], unstaged: [], untracked: [] })
  99. expect(git(root, ['for-each-ref', '--format=%(refname)', 'refs/remotes/origin/feature'])).toBe('')
  100. git(root, ['push', '--set-upstream', 'origin', 'feature'])
  101. const pushed = jsonReport(root, 'origin/master')
  102. expect(pushed.paths.committed).toEqual(['feature.txt'])
  103. })
  104. it.skipIf(process.platform === 'win32')('preserves trailing spaces in the worktree path', () => {
  105. const { root } = fixture('worktree ')
  106. const report = jsonReport(root, 'HEAD')
  107. expect(realpathSync.native(report.repositoryRoot)).toBe(realpathSync.native(root))
  108. expect(report.paths).toEqual({ committed: [], staged: [], unstaged: [], untracked: [] })
  109. })
  110. it('reports an exact head above a non-master stacked base while dirty paths remain worktree-local', () => {
  111. const { root } = fixture()
  112. git(root, ['switch', '-c', 'foundation'])
  113. const baseSha = commit(root, 'foundation.txt', 'foundation\n')
  114. git(root, ['switch', '-c', 'topic'])
  115. const headSha = commit(root, 'topic.txt', 'topic\n')
  116. commit(root, 'later.txt', 'later\n')
  117. write(join(root, 'current-worktree.txt'), 'current worktree\n')
  118. const report = jsonReport(root, 'foundation', headSha)
  119. expect(report.input).toEqual({ base: 'foundation', head: headSha })
  120. expect(report.resolved).toEqual({ baseSha, headSha, mergeBaseSha: baseSha })
  121. expect(report.paths.committed).toEqual(['topic.txt'])
  122. expect(report.paths.untracked).toEqual(['current-worktree.txt'])
  123. })
  124. it('keeps committed, staged, unstaged, and untracked paths independent and does not mutate state', () => {
  125. const { root } = fixture()
  126. commit(root, 'unstaged.txt', 'before\n')
  127. const baseSha = git(root, ['rev-parse', 'HEAD'])
  128. commit(root, 'committed.txt', 'committed\n')
  129. write(join(root, 'staged.txt'), 'staged\n')
  130. write(join(root, 'mixed.txt'), 'staged part\n')
  131. git(root, ['add', 'staged.txt', 'mixed.txt'])
  132. write(join(root, 'mixed.txt'), 'staged part\nunstaged part\n')
  133. write(join(root, 'unstaged.txt'), 'unstaged\n')
  134. write(join(root, 'untracked.txt'), 'untracked\n')
  135. const before = repositoryState(root)
  136. const report = jsonReport(root, baseSha)
  137. expect(report.paths).toEqual({
  138. committed: ['committed.txt'],
  139. staged: ['mixed.txt', 'staged.txt'],
  140. unstaged: ['mixed.txt', 'unstaged.txt'],
  141. untracked: ['untracked.txt'],
  142. })
  143. expect(repositoryState(root)).toEqual(before)
  144. })
  145. it.skipIf(process.platform === 'win32')('does not execute a configured filesystem monitor', () => {
  146. const { container, root } = fixture()
  147. const monitor = join(container, 'fsmonitor.sh')
  148. const sideEffect = `${monitor}.ran`
  149. write(monitor, '#!/bin/sh\ntouch "$0.ran"\n', 0o755)
  150. git(root, ['config', 'core.fsmonitor', monitor])
  151. const report = jsonReport(root, 'HEAD')
  152. expect(report.paths).toEqual({ committed: [], staged: [], unstaged: [], untracked: [] })
  153. expect(existsSync(sideEffect)).toBe(false)
  154. })
  155. it.skipIf(process.platform === 'win32')('rejects distinct non-UTF-8 Git paths', () => {
  156. const { root } = fixture()
  157. const blobSha = git(root, ['hash-object', '-w', '--stdin'], 'content')
  158. const entry = Buffer.from(`100644 ${blobSha}\t`, 'ascii')
  159. const firstPath = Buffer.from([0x80])
  160. const secondPath = Buffer.from([0x81])
  161. gitBytes(root, ['update-index', '-z', '--index-info'], Buffer.concat([
  162. entry,
  163. firstPath,
  164. Buffer.from([0]),
  165. entry,
  166. secondPath,
  167. Buffer.from([0]),
  168. ]))
  169. expect(gitBytes(root, ['diff', '--cached', '--name-only', '-z', '--'])).toEqual(Buffer.concat([
  170. firstPath,
  171. Buffer.from([0]),
  172. secondPath,
  173. Buffer.from([0]),
  174. ]))
  175. expect(() => {
  176. renderChangeScope(['--base', 'HEAD'], root)
  177. }).toThrow('cannot inspect staged paths: Git path 1 is not valid UTF-8')
  178. })
  179. it('rejects missing, ambiguous, and non-commit refs', () => {
  180. const { root } = fixture()
  181. git(root, ['branch', 'collision'])
  182. git(root, ['tag', 'collision'])
  183. write(join(root, 'blob.txt'), 'blob\n')
  184. const blobSha = git(root, ['hash-object', '-w', 'blob.txt'])
  185. git(root, ['tag', 'blob-ref', blobSha])
  186. for (const { args, message } of [
  187. { args: ['--base', 'missing'], message: /base ref .* does not resolve to a commit/u },
  188. { args: ['--base', 'collision'], message: /base ref .* is ambiguous/u },
  189. { args: ['--base', 'blob-ref'], message: /base ref .* does not resolve to a commit/u },
  190. { args: ['--base', 'HEAD', '--head', 'missing'], message: /head ref .* does not resolve to a commit/u },
  191. ]) {
  192. expect(() => {
  193. renderChangeScope(args, root)
  194. }).toThrow(message)
  195. }
  196. })
  197. it('renders deterministic versioned JSON', () => {
  198. const { root } = fixture()
  199. git(root, ['switch', '-c', 'format'])
  200. commit(root, 'zeta.txt', 'zeta\n')
  201. commit(root, 'alpha.txt', 'alpha\n')
  202. const json = invoke(root, ['--base', 'origin/master'])
  203. const repeatedJson = invoke(root, ['--base', 'origin/master'])
  204. const report = JSON.parse(json) as Report
  205. expect(json).toBe(repeatedJson)
  206. expect(report.formatVersion).toBe(1)
  207. expect(report.paths.committed).toEqual(['alpha.txt', 'zeta.txt'])
  208. })
  209. })