|
| 1 | +import { execSync } from 'node:child_process' |
| 2 | +import fs from 'node:fs/promises' |
| 3 | +import os from 'node:os' |
| 4 | +import path from 'node:path' |
| 5 | + |
| 6 | +import { |
| 7 | + buildGitBlameArgs, |
| 8 | + parseGitBlamePorcelainByLine, |
| 9 | +} from '@mobb/bugsy/utils/blame/gitBlameUtils' |
| 10 | +import { afterAll, beforeAll, describe, expect, it } from 'vitest' |
| 11 | + |
| 12 | +/** |
| 13 | + * These tests spawn a real `git` process against temp repos we create on |
| 14 | + * the fly and feed the raw porcelain output through the exact function |
| 15 | + * the production blame pipeline uses. Hand-crafted fixture strings would |
| 16 | + * silently drift out of sync with git's actual output; this harness |
| 17 | + * catches that drift. |
| 18 | + */ |
| 19 | + |
| 20 | +type GitEnv = NodeJS.ProcessEnv |
| 21 | + |
| 22 | +const gitEnv: GitEnv = { |
| 23 | + ...process.env, |
| 24 | + GIT_AUTHOR_NAME: 'Test', |
| 25 | + GIT_AUTHOR_EMAIL: 'test@test.com', |
| 26 | + GIT_COMMITTER_NAME: 'Test', |
| 27 | + GIT_COMMITTER_EMAIL: 'test@test.com', |
| 28 | + // Prevent global git config (hooks, signing, templates) from interfering. |
| 29 | + GIT_CONFIG_GLOBAL: '/dev/null', |
| 30 | + GIT_CONFIG_SYSTEM: '/dev/null', |
| 31 | +} |
| 32 | + |
| 33 | +function git(cwd: string, args: string[]): string { |
| 34 | + return execSync( |
| 35 | + ['git', ...args].map((a) => `'${a.replace(/'/g, "'\\''")}'`).join(' '), |
| 36 | + { |
| 37 | + cwd, |
| 38 | + env: gitEnv, |
| 39 | + encoding: 'utf8', |
| 40 | + } |
| 41 | + ) |
| 42 | +} |
| 43 | + |
| 44 | +async function writeFile(repo: string, relPath: string, content: string) { |
| 45 | + const abs = path.join(repo, relPath) |
| 46 | + await fs.mkdir(path.dirname(abs), { recursive: true }) |
| 47 | + await fs.writeFile(abs, content) |
| 48 | +} |
| 49 | + |
| 50 | +async function initRepo(): Promise<string> { |
| 51 | + const repo = await fs.mkdtemp(path.join(os.tmpdir(), 'blame-rename-test-')) |
| 52 | + git(repo, ['init', '--initial-branch=main']) |
| 53 | + return repo |
| 54 | +} |
| 55 | + |
| 56 | +function headSha(repo: string): string { |
| 57 | + return git(repo, ['rev-parse', 'HEAD']).trim() |
| 58 | +} |
| 59 | + |
| 60 | +function runBlame(repo: string, filePath: string, baseRef: string): string { |
| 61 | + const args = buildGitBlameArgs({ |
| 62 | + mode: 'diffRange', |
| 63 | + filePath, |
| 64 | + baseRef, |
| 65 | + headRef: 'HEAD', |
| 66 | + }) |
| 67 | + return execSync( |
| 68 | + ['git', ...args].map((a) => `'${a.replace(/'/g, "'\\''")}'`).join(' '), |
| 69 | + { |
| 70 | + cwd: repo, |
| 71 | + env: gitEnv, |
| 72 | + encoding: 'utf8', |
| 73 | + } |
| 74 | + ) |
| 75 | +} |
| 76 | + |
| 77 | +describe('parseGitBlamePorcelainByLine — rename scenarios', () => { |
| 78 | + describe('scenario 1: non-merge pure rename (git mv)', () => { |
| 79 | + let repo: string |
| 80 | + let base: string |
| 81 | + let commitAddFile: string |
| 82 | + const addedLines = [ |
| 83 | + 'line one', |
| 84 | + 'line two', |
| 85 | + 'line three', |
| 86 | + 'line four', |
| 87 | + 'line five', |
| 88 | + ] |
| 89 | + |
| 90 | + beforeAll(async () => { |
| 91 | + repo = await initRepo() |
| 92 | + // Commit 1: establish a base commit so blame has a diff range to walk |
| 93 | + await writeFile(repo, 'README.md', 'initial\n') |
| 94 | + git(repo, ['add', '.']) |
| 95 | + git(repo, ['commit', '-m', 'base']) |
| 96 | + base = headSha(repo) |
| 97 | + |
| 98 | + // Commit 2: add foo.ts with 5 lines — this is the authoring commit |
| 99 | + await writeFile(repo, 'foo.ts', addedLines.join('\n') + '\n') |
| 100 | + git(repo, ['add', 'foo.ts']) |
| 101 | + git(repo, ['commit', '-m', 'add foo.ts']) |
| 102 | + commitAddFile = headSha(repo) |
| 103 | + |
| 104 | + // Commit 3: pure rename, no content change |
| 105 | + git(repo, ['mv', 'foo.ts', 'bar.ts']) |
| 106 | + git(repo, ['commit', '-m', 'rename foo -> bar']) |
| 107 | + }) |
| 108 | + |
| 109 | + afterAll(async () => { |
| 110 | + await fs.rm(repo, { recursive: true, force: true }) |
| 111 | + }) |
| 112 | + |
| 113 | + it('traces every blamed line back to the authoring commit and reports the pre-rename path', () => { |
| 114 | + const raw = runBlame(repo, 'bar.ts', base) |
| 115 | + const lines = parseGitBlamePorcelainByLine(raw) |
| 116 | + |
| 117 | + const results = Object.values(lines) |
| 118 | + expect(results.length).toBe(addedLines.length) |
| 119 | + for (const info of results) { |
| 120 | + expect(info.commit).toBe(commitAddFile) |
| 121 | + // The whole point of the test: `-M` + porcelain `filename` must |
| 122 | + // give us the pre-rename path, not the current path. |
| 123 | + expect(info.originalFile).toBe('foo.ts') |
| 124 | + } |
| 125 | + }) |
| 126 | + }) |
| 127 | + |
| 128 | + describe('scenario 2: merge-conflict rename (PR #3596 pattern)', () => { |
| 129 | + let repo: string |
| 130 | + let featureCommit: string |
| 131 | + let base: string |
| 132 | + const fileContent = |
| 133 | + Array.from({ length: 10 }, (_, i) => `line ${i + 1}`).join('\n') + '\n' |
| 134 | + |
| 135 | + beforeAll(async () => { |
| 136 | + repo = await initRepo() |
| 137 | + // Base commit on main |
| 138 | + await writeFile(repo, 'README.md', 'initial\n') |
| 139 | + git(repo, ['add', '.']) |
| 140 | + git(repo, ['commit', '-m', 'base']) |
| 141 | + base = headSha(repo) |
| 142 | + |
| 143 | + // Feature branch: add test-1-16.ts |
| 144 | + git(repo, ['checkout', '-b', 'feature']) |
| 145 | + await writeFile(repo, 'test-1-16.ts', fileContent) |
| 146 | + git(repo, ['add', '.']) |
| 147 | + git(repo, ['commit', '-m', 'add test-1-16']) |
| 148 | + featureCommit = headSha(repo) |
| 149 | + |
| 150 | + // Main branch moves forward with an unrelated change |
| 151 | + git(repo, ['checkout', 'main']) |
| 152 | + await writeFile(repo, 'other.ts', 'unrelated main work\n') |
| 153 | + git(repo, ['add', '.']) |
| 154 | + git(repo, ['commit', '-m', 'unrelated main change']) |
| 155 | + |
| 156 | + // Back on feature: merge main in with a conflict-resolution rename. |
| 157 | + // Simulate by first merging (no actual conflict since files differ), |
| 158 | + // then renaming to test-1-17 in a commit that represents the |
| 159 | + // resolution. The merge does encode the content-path history git |
| 160 | + // needs to trace blame correctly. |
| 161 | + git(repo, ['checkout', 'feature']) |
| 162 | + git(repo, ['merge', '--no-edit', 'main']) |
| 163 | + // Rename test-1-16 -> test-1-17 to mirror the real PR |
| 164 | + git(repo, ['mv', 'test-1-16.ts', 'test-1-17.ts']) |
| 165 | + git(repo, ['commit', '-m', 'rename test-1-16 -> test-1-17']) |
| 166 | + }) |
| 167 | + |
| 168 | + afterAll(async () => { |
| 169 | + await fs.rm(repo, { recursive: true, force: true }) |
| 170 | + }) |
| 171 | + |
| 172 | + it('traces every unchanged line back to the feature commit with the pre-rename path', () => { |
| 173 | + const raw = runBlame(repo, 'test-1-17.ts', base) |
| 174 | + const lines = parseGitBlamePorcelainByLine(raw) |
| 175 | + |
| 176 | + const results = Object.values(lines) |
| 177 | + expect(results.length).toBe(10) |
| 178 | + for (const info of results) { |
| 179 | + expect(info.commit).toBe(featureCommit) |
| 180 | + // The critical assertion: the historical path is the pre-rename |
| 181 | + // name, which is what the attribution rows are keyed by. |
| 182 | + expect(info.originalFile).toBe('test-1-16.ts') |
| 183 | + } |
| 184 | + }) |
| 185 | + }) |
| 186 | + |
| 187 | + describe('scenario 3: baseline (no rename)', () => { |
| 188 | + let repo: string |
| 189 | + let base: string |
| 190 | + let commitAddFile: string |
| 191 | + |
| 192 | + beforeAll(async () => { |
| 193 | + repo = await initRepo() |
| 194 | + await writeFile(repo, 'README.md', 'initial\n') |
| 195 | + git(repo, ['add', '.']) |
| 196 | + git(repo, ['commit', '-m', 'base']) |
| 197 | + base = headSha(repo) |
| 198 | + |
| 199 | + await writeFile(repo, 'baseline.ts', 'line one\nline two\nline three\n') |
| 200 | + git(repo, ['add', '.']) |
| 201 | + git(repo, ['commit', '-m', 'add baseline']) |
| 202 | + commitAddFile = headSha(repo) |
| 203 | + }) |
| 204 | + |
| 205 | + afterAll(async () => { |
| 206 | + await fs.rm(repo, { recursive: true, force: true }) |
| 207 | + }) |
| 208 | + |
| 209 | + it('reports the current path as the historical path (no rename)', () => { |
| 210 | + const raw = runBlame(repo, 'baseline.ts', base) |
| 211 | + const lines = parseGitBlamePorcelainByLine(raw) |
| 212 | + |
| 213 | + const results = Object.values(lines) |
| 214 | + expect(results.length).toBe(3) |
| 215 | + for (const info of results) { |
| 216 | + expect(info.commit).toBe(commitAddFile) |
| 217 | + expect(info.originalFile).toBe('baseline.ts') |
| 218 | + } |
| 219 | + }) |
| 220 | + }) |
| 221 | +}) |
0 commit comments