Skip to content

Commit b8226ad

Browse files
committed
fix(scm): 🐛 address git context review feedback
1 parent e173b0e commit b8226ad

3 files changed

Lines changed: 230 additions & 32 deletions

File tree

src/services/git-context/GitContextCollector.ts

Lines changed: 93 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import * as path from "path"
22
import { promises as fs } from "fs"
33
import { spawn } from "child_process"
4-
import {
4+
import type {
55
GitContextCollection,
66
GitContextCollectorOptions,
77
GitChange,
@@ -12,22 +12,15 @@ import {
1212
GitStatus,
1313
} from "./types"
1414

15-
export type {
16-
GitChange,
17-
GitContextCollection,
18-
GitContextCollectorOptions,
19-
GitDiffContextOptions,
20-
GitContextOptions,
21-
GitRecentCommitContextOptions,
22-
GitContextResult,
23-
} from "./types"
24-
2515
const DEFAULT_RECENT_COMMIT_COUNT = 5
2616
const DEFAULT_RECENT_COMMIT_DIFF_COUNT = 1
2717

18+
/** Collects Git status, diff, and repository metadata for commit-message generation. */
2819
export class GitContextCollector {
20+
/** Creates a collector scoped to one workspace repository root. */
2921
constructor(private workspaceRoot: string) {}
3022

23+
/** Returns changed files from staged or unstaged Git state. */
3124
public async gatherChanges(options: GitContextCollectorOptions): Promise<GitChange[]> {
3225
const statusOutput = await this.getStatus(options)
3326
if (!statusOutput) {
@@ -37,13 +30,15 @@ export class GitContextCollector {
3730
return options.staged ? this.parseNameStatus(statusOutput, true) : this.parsePorcelainStatus(statusOutput)
3831
}
3932

33+
/** Gathers changes and formats their Git context in one call. */
4034
public async collect(options: GitContextCollectorOptions, specificFiles?: string[]): Promise<GitContextCollection> {
4135
const changes = await this.gatherChanges(options)
4236
const result = await this.collectContext(changes, options, specificFiles)
4337

4438
return { ...result, changes }
4539
}
4640

41+
/** Runs a Git subprocess in the workspace root and returns stdout. */
4742
private async runGit(args: string[]): Promise<string> {
4843
return new Promise((resolve, reject) => {
4944
const child = spawn("git", args, {
@@ -69,12 +64,16 @@ export class GitContextCollector {
6964
})
7065
}
7166

67+
/** Builds full diff text for tracked, untracked, and binary changes. */
7268
private async getDiffForChanges(changes: GitChange[], options: GitContextCollectorOptions): Promise<string> {
69+
options.onProgress?.(0)
7370
if (changes.length === 0) {
71+
options.onProgress?.(100)
7472
return ""
7573
}
7674

7775
const binaryChanges = await this.findBinaryChanges(changes, options.staged)
76+
options.onProgress?.(25)
7877
const diffableChanges = changes.filter((change) => change.status !== "?" && !binaryChanges.has(change.filePath))
7978
const untrackedFiles = changes.filter((change) => change.status === "?")
8079
const parts: string[] = []
@@ -86,10 +85,12 @@ export class GitContextCollector {
8685
parts.push(diff)
8786
}
8887
}
88+
options.onProgress?.(65)
8989

9090
if (untrackedFiles.length > 0) {
9191
parts.push(await this.getUntrackedFileDiffs(untrackedFiles))
9292
}
93+
options.onProgress?.(85)
9394

9495
if (binaryChanges.size > 0) {
9596
parts.push(
@@ -107,6 +108,7 @@ export class GitContextCollector {
107108
return parts.join("\n")
108109
}
109110

111+
/** Builds diff-stat text for tracked changes and synthesized untracked files. */
110112
private async getDiffStats(changes: GitChange[], options: GitContextCollectorOptions): Promise<string> {
111113
const trackedChanges = changes.filter((change) => change.status !== "?")
112114
const untrackedChanges = changes.filter((change) => change.status === "?")
@@ -127,6 +129,7 @@ export class GitContextCollector {
127129
return parts.join("\n")
128130
}
129131

132+
/** Returns a Git-style stat summary for an untracked working-tree file. */
130133
private async getUntrackedFileStat(change: GitChange): Promise<string> {
131134
const relativePath = this.getRelativePath(change.filePath)
132135
if (await this.isProbablyBinaryFile(change.filePath)) {
@@ -135,37 +138,48 @@ export class GitContextCollector {
135138

136139
const content = await fs.readFile(change.filePath, "utf8")
137140
const normalizedContent = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n")
138-
const lineCount = normalizedContent.length === 0 ? 0 : normalizedContent.split("\n").filter(Boolean).length
141+
const lineCount = this.countTextLines(normalizedContent)
139142
return `${relativePath} | ${lineCount} ${"+".repeat(Math.min(lineCount, 60))}`
140143
}
141144

145+
/** Returns the byte size for a file on disk. */
142146
private async getFileSize(filePath: string): Promise<number> {
143147
return (await fs.stat(filePath)).size
144148
}
145149

150+
/** Detects binary tracked changes with a single numstat invocation. */
146151
private async findBinaryChanges(changes: GitChange[], staged: boolean): Promise<Set<string>> {
147152
const binaryFiles = new Set<string>()
153+
const trackedChanges = changes.filter((change) => change.status !== "?")
154+
if (trackedChanges.length === 0) {
155+
return binaryFiles
156+
}
148157

149-
for (const change of changes) {
150-
if (change.status === "?") {
151-
continue
152-
}
153-
154-
const args = this.buildNumstatArgs(staged, change)
155-
const output = await this.runGit(args)
156-
if (output.includes("-\t-\t")) {
158+
const args = this.buildNumstatArgs(staged, trackedChanges)
159+
const output = await this.runGit(args)
160+
const binaryRelativePaths = output
161+
.split("\n")
162+
.map((line) => line.split("\t"))
163+
.filter(([added, deleted, filePath]) => added === "-" && deleted === "-" && Boolean(filePath))
164+
.map(([, , ...filePathParts]) => filePathParts.join("\t"))
165+
166+
for (const change of trackedChanges) {
167+
const relativePath = this.getRelativePath(change.filePath)
168+
if (binaryRelativePaths.includes(relativePath)) {
157169
binaryFiles.add(change.filePath)
158170
}
159171
}
160172

161173
return binaryFiles
162174
}
163175

164-
private buildNumstatArgs(staged: boolean, change: GitChange): string[] {
176+
/** Builds path-limited numstat arguments for binary detection. */
177+
private buildNumstatArgs(staged: boolean, changes: GitChange[]): string[] {
165178
const args = staged ? ["diff", "--cached", "--numstat"] : ["diff", "--numstat"]
166-
return [...args, "--", this.getRelativePath(change.filePath)]
179+
return [...args, "--", ...changes.map((change) => this.getRelativePath(change.filePath))]
167180
}
168181

182+
/** Checks the first bytes of a file for NUL bytes. */
169183
private async isProbablyBinaryFile(filePath: string): Promise<boolean> {
170184
const fileHandle = await fs.open(filePath, "r")
171185
try {
@@ -177,6 +191,7 @@ export class GitContextCollector {
177191
}
178192
}
179193

194+
/** Builds synthesized diff text for untracked files. */
180195
private async getUntrackedFileDiffs(changes: GitChange[]): Promise<string> {
181196
const diffs: string[] = []
182197

@@ -192,6 +207,7 @@ export class GitContextCollector {
192207
return diffs.join("\n")
193208
}
194209

210+
/** Creates a unified new-file diff from working-tree file content. */
195211
private async createNewFileDiff(filePath: string): Promise<string> {
196212
const relativePath = this.getRelativePath(filePath)
197213
const content = await fs.readFile(filePath, "utf8")
@@ -224,16 +240,19 @@ export class GitContextCollector {
224240
return diffLines.join("\n")
225241
}
226242

243+
/** Returns raw Git status output for staged or unstaged collection. */
227244
private async getStatus(options: GitContextOptions): Promise<string> {
228245
return options.staged
229246
? this.runGit(["diff", "--name-status", "--cached", "-z"])
230247
: this.runGit(["status", "--porcelain=v1", "-z", "--untracked-files=all"])
231248
}
232249

250+
/** Returns the currently checked-out branch name. */
233251
private async getCurrentBranch(): Promise<string> {
234252
return this.runGit(["branch", "--show-current"])
235253
}
236254

255+
/** Returns recent commit summaries and optional stats or patch context. */
237256
private async getRecentCommits(options: GitRecentCommitContextOptions): Promise<string> {
238257
const count = this.clampNumber(options.count, 1, 20, DEFAULT_RECENT_COMMIT_COUNT)
239258
const args = options.includeBodies
@@ -261,6 +280,7 @@ export class GitContextCollector {
261280
return parts.join("\n")
262281
}
263282

283+
/** Formats collected changes as Markdown context for prompt input. */
264284
public async collectContext(
265285
changes: GitChange[],
266286
options: GitContextCollectorOptions,
@@ -338,6 +358,7 @@ export class GitContextCollector {
338358
return { context, warnings }
339359
}
340360

361+
/** Formats collected changes and returns only the Markdown context body. */
341362
public async getContext(
342363
changes: GitChange[],
343364
options: GitContextCollectorOptions,
@@ -346,10 +367,12 @@ export class GitContextCollector {
346367
return (await this.collectContext(changes, options, specificFiles)).context
347368
}
348369

370+
/** Normalizes unknown thrown values into displayable error messages. */
349371
private getErrorMessage(error: unknown): string {
350372
return error instanceof Error ? error.message : String(error)
351373
}
352374

375+
/** Parses NUL-delimited git diff --name-status output. */
353376
private parseNameStatus(output: string, staged: boolean): GitChange[] {
354377
const fields = this.splitNullDelimited(output)
355378
const changes: GitChange[] = []
@@ -359,6 +382,10 @@ export class GitContextCollector {
359382
const status = this.getChangeStatusFromCode(statusCode)
360383

361384
if (status === "R" || status === "C") {
385+
if (index + 2 >= fields.length) {
386+
break
387+
}
388+
362389
const oldFilePath = fields[++index]
363390
const filePath = fields[++index]
364391
if (oldFilePath && filePath) {
@@ -372,6 +399,10 @@ export class GitContextCollector {
372399
continue
373400
}
374401

402+
if (index + 1 >= fields.length) {
403+
break
404+
}
405+
375406
const filePath = fields[++index]
376407
if (filePath) {
377408
changes.push({
@@ -385,6 +416,7 @@ export class GitContextCollector {
385416
return changes
386417
}
387418

419+
/** Parses NUL-delimited git status --porcelain=v1 output for unstaged changes. */
388420
private parsePorcelainStatus(output: string): GitChange[] {
389421
const fields = this.splitNullDelimited(output)
390422
const changes: GitChange[] = []
@@ -397,12 +429,18 @@ export class GitContextCollector {
397429

398430
const indexStatus = entry.charAt(0)
399431
const workingStatus = entry.charAt(1)
400-
const statusCode = indexStatus === "?" && workingStatus === "?" ? "?" : workingStatus.trim() || indexStatus
432+
const isUntracked = indexStatus === "?" && workingStatus === "?"
433+
const worktreeStatus = workingStatus.trim()
434+
if (!isUntracked && !worktreeStatus) {
435+
continue
436+
}
437+
438+
const statusCode = isUntracked ? "?" : worktreeStatus
401439
const status = this.getChangeStatusFromCode(statusCode)
402440
const filePath = entry.substring(3)
403441

404442
if (status === "R" || status === "C") {
405-
const oldFilePath = fields[++index]
443+
const oldFilePath = index + 1 < fields.length ? fields[++index] : undefined
406444
changes.push({
407445
filePath: path.join(this.workspaceRoot, filePath),
408446
oldFilePath: oldFilePath ? path.join(this.workspaceRoot, oldFilePath) : undefined,
@@ -422,30 +460,38 @@ export class GitContextCollector {
422460
return changes
423461
}
424462

463+
/** Splits NUL-delimited Git output and drops the trailing empty field. */
425464
private splitNullDelimited(output: string): string[] {
426465
return output.split("\0").filter(Boolean)
427466
}
428467

468+
/** Applies exact path or basename-only file selection to collected changes. */
429469
private filterChanges(changes: GitChange[], specificFiles?: string[]): GitChange[] {
430470
if (!specificFiles || specificFiles.length === 0) {
431471
return changes
432472
}
433473

434474
return changes.filter((change) => {
435-
const absolutePath = change.filePath
436-
const relativePath = this.getRelativePath(absolutePath)
475+
const absolutePath = this.normalizePath(change.filePath)
476+
const relativePath = this.getRelativePath(change.filePath)
437477
return specificFiles.some((file) => {
438478
const normalizedFile = path.normalize(file).replace(/\\/g, "/")
479+
const absoluteFile = this.normalizePath(
480+
path.isAbsolute(file) ? file : path.join(this.workspaceRoot, file),
481+
)
482+
const isBasenameOnly = !normalizedFile.includes("/")
483+
439484
return (
440-
file === absolutePath ||
441-
file === relativePath ||
442-
absolutePath.endsWith(file) ||
443-
relativePath === normalizedFile
485+
absoluteFile === absolutePath ||
486+
relativePath === normalizedFile ||
487+
// Basename-only matching is intentional for SCM selections that pass only file names.
488+
(isBasenameOnly && path.basename(relativePath) === normalizedFile)
444489
)
445490
})
446491
})
447492
}
448493

494+
/** Builds path-limited diff arguments for the requested change set. */
449495
private buildDiffArgs(
450496
staged: boolean,
451497
changes: GitChange[],
@@ -470,6 +516,7 @@ export class GitContextCollector {
470516
return paths.length > 0 ? [...args, ...extraArgs, ...contextLines, "--", ...paths] : [...args, ...extraArgs]
471517
}
472518

519+
/** Clamps a numeric option to an integer range with fallback handling. */
473520
private clampNumber(value: number | undefined, min: number, max: number, fallback: number): number {
474521
if (typeof value !== "number" || !Number.isFinite(value)) {
475522
return fallback
@@ -478,10 +525,12 @@ export class GitContextCollector {
478525
return Math.min(Math.max(Math.trunc(value), min), max)
479526
}
480527

528+
/** Converts an absolute file path to a slash-normalized repository-relative path. */
481529
private getRelativePath(filePath: string): string {
482530
return path.relative(this.workspaceRoot, filePath).replace(/\\/g, "/")
483531
}
484532

533+
/** Converts a Git status code into the collector's status enum. */
485534
private getChangeStatusFromCode(code: string): GitStatus {
486535
const status = code.charAt(0)
487536
switch (status) {
@@ -498,6 +547,7 @@ export class GitContextCollector {
498547
}
499548
}
500549

550+
/** Converts a status enum to a human-readable label. */
501551
private getReadableStatus(status: GitStatus): string {
502552
switch (status) {
503553
case "M":
@@ -520,5 +570,17 @@ export class GitContextCollector {
520570
}
521571
}
522572

523-
public dispose(): void {}
573+
/** Counts text lines while preserving blank lines and ignoring a final newline terminator. */
574+
private countTextLines(content: string): number {
575+
if (content.length === 0) {
576+
return 0
577+
}
578+
579+
return (content.endsWith("\n") ? content.slice(0, -1) : content).split("\n").length
580+
}
581+
582+
/** Normalizes absolute paths for platform-independent comparisons. */
583+
private normalizePath(filePath: string): string {
584+
return path.normalize(filePath).replace(/\\/g, "/")
585+
}
524586
}

0 commit comments

Comments
 (0)