11import * as path from "path"
22import { promises as fs } from "fs"
33import { 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-
2515const DEFAULT_RECENT_COMMIT_COUNT = 5
2616const DEFAULT_RECENT_COMMIT_DIFF_COUNT = 1
2717
18+ /** Collects Git status, diff, and repository metadata for commit-message generation. */
2819export 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