| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241 |
- import { execFileSync } from 'node:child_process'
- import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
- import { tmpdir } from 'node:os'
- import { dirname, join } from 'node:path'
- import { afterEach, describe, expect, it } from 'vitest'
- import { renderChangeScope } from './change-scope.ts'
- interface Report {
- formatVersion: number
- repositoryRoot: string
- input: { base: string; head: string }
- resolved: { baseSha: string; headSha: string; mergeBaseSha: string }
- paths: { committed: string[]; staged: string[]; unstaged: string[]; untracked: string[] }
- }
- interface Fixture {
- container: string
- root: string
- }
- const fixtureRoots: string[] = []
- afterEach(() => {
- for (const root of fixtureRoots.splice(0)) rmSync(root, { recursive: true, force: true })
- })
- function git(cwd: string, args: string[], input?: string | Buffer): string {
- return execFileSync('git', ['-C', cwd, ...args], {
- encoding: 'utf8',
- env: { ...process.env, LANG: 'C', LC_ALL: 'C' },
- input,
- stdio: ['pipe', 'pipe', 'pipe'],
- }).trim()
- }
- function gitBytes(cwd: string, args: string[], input?: Buffer): Buffer {
- return execFileSync('git', ['-C', cwd, ...args], {
- env: { ...process.env, LANG: 'C', LC_ALL: 'C' },
- input,
- stdio: ['pipe', 'pipe', 'pipe'],
- })
- }
- function write(path: string, content: string, mode?: number): void {
- mkdirSync(dirname(path), { recursive: true })
- writeFileSync(path, content, mode === undefined ? undefined : { mode })
- }
- function fixture(worktreeName = 'worktree'): Fixture {
- const container = mkdtempSync(join(tmpdir(), 'dsh-change-scope-'))
- fixtureRoots.push(container)
- const origin = join(container, 'origin.git')
- const root = join(container, worktreeName)
- const hooks = join(container, 'hooks')
- mkdirSync(hooks)
- git(container, ['init', '--bare', '--initial-branch=master', origin])
- git(container, ['init', '--initial-branch=master', root])
- git(root, ['config', 'user.email', 'change-scope@example.com'])
- git(root, ['config', 'user.name', 'Change Scope Tests'])
- git(root, ['config', 'commit.gpgsign', 'false'])
- git(root, ['config', 'core.hooksPath', hooks])
- write(join(root, 'README.md'), '# Fixture\n')
- git(root, ['add', 'README.md'])
- git(root, ['commit', '-m', 'initial'])
- git(root, ['remote', 'add', 'origin', origin])
- git(root, ['push', '--set-upstream', 'origin', 'master'])
- return { container, root }
- }
- function commit(root: string, path: string, content: string): string {
- write(join(root, path), content)
- git(root, ['add', '--', path])
- git(root, ['commit', '-m', `add ${path}`])
- return git(root, ['rev-parse', 'HEAD'])
- }
- function invoke(root: string, args: string[]): string {
- return renderChangeScope(args, root)
- }
- function jsonReport(root: string, base: string, head?: string): Report {
- const args = ['--base', base]
- if (head !== undefined) args.push('--head', head)
- return JSON.parse(invoke(root, args)) as Report
- }
- function repositoryState(root: string): Record<string, string> {
- const status = git(root, ['status', '--porcelain=v2', '--branch', '-z'])
- return {
- status,
- head: git(root, ['rev-parse', 'HEAD']),
- refs: git(root, ['for-each-ref', '--format=%(refname) %(objectname)']),
- index: readFileSync(join(root, '.git/index')).toString('base64'),
- config: readFileSync(join(root, '.git/config')).toString('base64'),
- }
- }
- describe('change-scope', () => {
- it('uses an explicit base on a fresh branch without a same-name remote and after its first push', { timeout: 20_000 }, () => {
- const { root } = fixture()
- git(root, ['switch', '-c', 'feature'])
- git(root, ['branch', '--set-upstream-to=origin/master'])
- const headSha = commit(root, 'feature.txt', 'feature\n')
- const fresh = jsonReport(root, 'origin/master')
- expect(realpathSync.native(fresh.repositoryRoot)).toBe(realpathSync.native(root))
- expect(fresh.resolved).toEqual({
- baseSha: git(root, ['rev-parse', 'origin/master']),
- headSha,
- mergeBaseSha: git(root, ['rev-parse', 'origin/master']),
- })
- expect(fresh.paths).toEqual({ committed: ['feature.txt'], staged: [], unstaged: [], untracked: [] })
- expect(git(root, ['for-each-ref', '--format=%(refname)', 'refs/remotes/origin/feature'])).toBe('')
- git(root, ['push', '--set-upstream', 'origin', 'feature'])
- const pushed = jsonReport(root, 'origin/master')
- expect(pushed.paths.committed).toEqual(['feature.txt'])
- })
- it.skipIf(process.platform === 'win32')('preserves trailing spaces in the worktree path', () => {
- const { root } = fixture('worktree ')
- const report = jsonReport(root, 'HEAD')
- expect(realpathSync.native(report.repositoryRoot)).toBe(realpathSync.native(root))
- expect(report.paths).toEqual({ committed: [], staged: [], unstaged: [], untracked: [] })
- })
- it('reports an exact head above a non-master stacked base while dirty paths remain worktree-local', () => {
- const { root } = fixture()
- git(root, ['switch', '-c', 'foundation'])
- const baseSha = commit(root, 'foundation.txt', 'foundation\n')
- git(root, ['switch', '-c', 'topic'])
- const headSha = commit(root, 'topic.txt', 'topic\n')
- commit(root, 'later.txt', 'later\n')
- write(join(root, 'current-worktree.txt'), 'current worktree\n')
- const report = jsonReport(root, 'foundation', headSha)
- expect(report.input).toEqual({ base: 'foundation', head: headSha })
- expect(report.resolved).toEqual({ baseSha, headSha, mergeBaseSha: baseSha })
- expect(report.paths.committed).toEqual(['topic.txt'])
- expect(report.paths.untracked).toEqual(['current-worktree.txt'])
- })
- it('keeps committed, staged, unstaged, and untracked paths independent and does not mutate state', () => {
- const { root } = fixture()
- commit(root, 'unstaged.txt', 'before\n')
- const baseSha = git(root, ['rev-parse', 'HEAD'])
- commit(root, 'committed.txt', 'committed\n')
- write(join(root, 'staged.txt'), 'staged\n')
- write(join(root, 'mixed.txt'), 'staged part\n')
- git(root, ['add', 'staged.txt', 'mixed.txt'])
- write(join(root, 'mixed.txt'), 'staged part\nunstaged part\n')
- write(join(root, 'unstaged.txt'), 'unstaged\n')
- write(join(root, 'untracked.txt'), 'untracked\n')
- const before = repositoryState(root)
- const report = jsonReport(root, baseSha)
- expect(report.paths).toEqual({
- committed: ['committed.txt'],
- staged: ['mixed.txt', 'staged.txt'],
- unstaged: ['mixed.txt', 'unstaged.txt'],
- untracked: ['untracked.txt'],
- })
- expect(repositoryState(root)).toEqual(before)
- })
- it.skipIf(process.platform === 'win32')('does not execute a configured filesystem monitor', () => {
- const { container, root } = fixture()
- const monitor = join(container, 'fsmonitor.sh')
- const sideEffect = `${monitor}.ran`
- write(monitor, '#!/bin/sh\ntouch "$0.ran"\n', 0o755)
- git(root, ['config', 'core.fsmonitor', monitor])
- const report = jsonReport(root, 'HEAD')
- expect(report.paths).toEqual({ committed: [], staged: [], unstaged: [], untracked: [] })
- expect(existsSync(sideEffect)).toBe(false)
- })
- it.skipIf(process.platform === 'win32')('rejects distinct non-UTF-8 Git paths', () => {
- const { root } = fixture()
- const blobSha = git(root, ['hash-object', '-w', '--stdin'], 'content')
- const entry = Buffer.from(`100644 ${blobSha}\t`, 'ascii')
- const firstPath = Buffer.from([0x80])
- const secondPath = Buffer.from([0x81])
- gitBytes(root, ['update-index', '-z', '--index-info'], Buffer.concat([
- entry,
- firstPath,
- Buffer.from([0]),
- entry,
- secondPath,
- Buffer.from([0]),
- ]))
- expect(gitBytes(root, ['diff', '--cached', '--name-only', '-z', '--'])).toEqual(Buffer.concat([
- firstPath,
- Buffer.from([0]),
- secondPath,
- Buffer.from([0]),
- ]))
- expect(() => {
- renderChangeScope(['--base', 'HEAD'], root)
- }).toThrow('cannot inspect staged paths: Git path 1 is not valid UTF-8')
- })
- it('rejects missing, ambiguous, and non-commit refs', () => {
- const { root } = fixture()
- git(root, ['branch', 'collision'])
- git(root, ['tag', 'collision'])
- write(join(root, 'blob.txt'), 'blob\n')
- const blobSha = git(root, ['hash-object', '-w', 'blob.txt'])
- git(root, ['tag', 'blob-ref', blobSha])
- for (const { args, message } of [
- { args: ['--base', 'missing'], message: /base ref .* does not resolve to a commit/u },
- { args: ['--base', 'collision'], message: /base ref .* is ambiguous/u },
- { args: ['--base', 'blob-ref'], message: /base ref .* does not resolve to a commit/u },
- { args: ['--base', 'HEAD', '--head', 'missing'], message: /head ref .* does not resolve to a commit/u },
- ]) {
- expect(() => {
- renderChangeScope(args, root)
- }).toThrow(message)
- }
- })
- it('renders deterministic versioned JSON', () => {
- const { root } = fixture()
- git(root, ['switch', '-c', 'format'])
- commit(root, 'zeta.txt', 'zeta\n')
- commit(root, 'alpha.txt', 'alpha\n')
- const json = invoke(root, ['--base', 'origin/master'])
- const repeatedJson = invoke(root, ['--base', 'origin/master'])
- const report = JSON.parse(json) as Report
- expect(json).toBe(repeatedJson)
- expect(report.formatVersion).toBe(1)
- expect(report.paths.committed).toEqual(['alpha.txt', 'zeta.txt'])
- })
- })
|