Skip to content

Commit d610999

Browse files
Kirill89Mobb autofixer
authored andcommitted
version 1.3.4
1 parent 6911269 commit d610999

9 files changed

Lines changed: 349 additions & 7 deletions

File tree

Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
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+
})

__tests__/parseGitBlamePorcelain.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,5 +39,9 @@ describe('parseGitBlamePorcelain', () => {
3939
expect(lineInfo?.authorName).toBeTypeOf('string')
4040
expect(lineInfo?.authorEmail).toBeTypeOf('string')
4141
expect(lineInfo?.authorTime).toBeGreaterThan(1600000000)
42+
// The asset file has not been renamed, so porcelain should report
43+
// its current path as the historical path.
44+
expect(lineInfo?.originalFile).toBeTypeOf('string')
45+
expect(lineInfo?.originalFile).toContain('SigningAssignment.java')
4246
})
4347
})

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "mobbdev",
3-
"version": "1.3.3",
3+
"version": "1.3.4",
44
"description": "Automated secure code remediation tool",
55
"repository": "git+https://github.com/mobb-dev/bugsy.git",
66
"main": "dist/index.mjs",

src/args/commands/upload_ai_blame.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,26 @@ export async function getRepositoryUrl(
124124
}
125125
}
126126

127+
/**
128+
* Gets the absolute git root path of the working directory's git checkout.
129+
* Used by Tracy uploads to let the server filter out events whose filePath
130+
* falls outside the repo (e.g., /tmp scratchpads, ~/.zshrc).
131+
*/
132+
export async function getRepoGitRoot(
133+
workingDir?: string
134+
): Promise<string | null> {
135+
try {
136+
const gitService = new GitService(workingDir ?? process.cwd())
137+
const isRepo = await gitService.isGitRepository()
138+
if (!isRepo) {
139+
return null
140+
}
141+
return await gitService.getGitRoot()
142+
} catch {
143+
return null
144+
}
145+
}
146+
127147
/**
128148
* Get system information for tracking inference source.
129149
* Works cross-platform (Windows, macOS, Linux).

src/features/analysis/graphql/tracy-batch-upload.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import Debug from 'debug'
22

33
import {
4+
getRepoGitRoot,
45
getRepositoryUrl,
56
getSystemInfo,
67
} from '../../../args/commands/upload_ai_blame'
@@ -37,13 +38,16 @@ export type TracyRecordClientInput = Omit<
3738
TracyRecordInput,
3839
| 'rawDataS3Key'
3940
| 'repositoryUrl'
41+
| 'gitRoot'
4042
| 'computerName'
4143
| 'userName'
4244
| 'clientVersion'
4345
> & {
4446
rawData?: unknown // object from extension, will be sanitized & serialized
4547
/** Override auto-detected repo URL (e.g. from extension metadata) */
4648
repositoryUrl?: string
49+
/** Override auto-detected git root (e.g. from extension metadata) */
50+
gitRoot?: string
4751
/** Override auto-detected client version (e.g. extension version instead of CLI version) */
4852
clientVersion?: string
4953
}
@@ -90,6 +94,11 @@ export async function prepareAndSendTracyRecords(
9094
? undefined
9195
: ((await getRepositoryUrl(workingDir)) ?? undefined)
9296

97+
// Only resolve default git root if the caller didn't provide one per-record.
98+
const defaultGitRoot = rawRecords[0]?.gitRoot
99+
? undefined
100+
: ((await getRepoGitRoot(workingDir)) ?? undefined)
101+
93102
// 1. Enrich records and optionally sanitize rawData
94103
debug(
95104
'[step:sanitize] %s %d records',
@@ -113,6 +122,7 @@ export async function prepareAndSendTracyRecords(
113122
return {
114123
...rest,
115124
repositoryUrl: record.repositoryUrl ?? defaultRepoUrl,
125+
gitRoot: record.gitRoot ?? defaultGitRoot,
116126
computerName,
117127
userName,
118128
clientVersion: record.clientVersion ?? defaultClientVersion,

0 commit comments

Comments
 (0)