@@ -13,6 +13,19 @@ const execAsync = promisify(exec);
1313const execFileAsync = promisify ( execFile ) ;
1414const fsPromises = fs . promises ;
1515
16+ const countFileLines = async ( filePath : string ) : Promise < number > => {
17+ try {
18+ const content = await fsPromises . readFile ( filePath , "utf-8" ) ;
19+ if ( ! content ) return 0 ;
20+
21+ // Match git line counting: do not count trailing newline as extra line
22+ const lines = content . split ( "\n" ) ;
23+ return lines [ lines . length - 1 ] === "" ? lines . length - 1 : lines . length ;
24+ } catch {
25+ return 0 ;
26+ }
27+ } ;
28+
1629const getAllFilesInDirectory = async (
1730 directoryPath : string ,
1831 basePath : string ,
@@ -194,26 +207,63 @@ const getChangedFilesAgainstHead = async (
194207 const files : ChangedFile [ ] = [ ] ;
195208 const seenPaths = new Set < string > ( ) ;
196209
197- // Use git diff with -M to detect renames in working tree
198- const { stdout : diffOutput } = await execAsync (
199- "git diff -M --name-status HEAD" ,
200- { cwd : directoryPath } ,
201- ) ;
210+ // Run git commands in parallel
211+ const [ nameStatusResult , numstatResult , statusResult ] = await Promise . all ( [
212+ execAsync ( "git diff -M --name-status HEAD" , { cwd : directoryPath } ) ,
213+ execAsync ( "git diff -M --numstat HEAD" , { cwd : directoryPath } ) ,
214+ execAsync ( "git status --porcelain" , { cwd : directoryPath } ) ,
215+ ] ) ;
216+
217+ // Build line stats map from numstat output
218+ // Format: ADDED\tREMOVED\tPATH or for renames: ADDED\tREMOVED\tOLD_PATH => NEW_PATH
219+ const lineStats = new Map < string , { added : number ; removed : number } > ( ) ;
220+ for ( const line of numstatResult . stdout
221+ . trim ( )
222+ . split ( "\n" )
223+ . filter ( Boolean ) ) {
224+ const parts = line . split ( "\t" ) ;
225+ if ( parts . length >= 3 ) {
226+ const added = parts [ 0 ] === "-" ? 0 : parseInt ( parts [ 0 ] , 10 ) || 0 ;
227+ const removed = parts [ 1 ] === "-" ? 0 : parseInt ( parts [ 1 ] , 10 ) || 0 ;
228+ const filePath = parts . slice ( 2 ) . join ( "\t" ) ;
229+ // For renames, numstat shows "old => new" - extract both paths
230+ if ( filePath . includes ( " => " ) ) {
231+ const renameParts = filePath . split ( " => " ) ;
232+ // Store under both old and new path for lookup
233+ lineStats . set ( renameParts [ 0 ] , { added, removed } ) ;
234+ lineStats . set ( renameParts [ 1 ] , { added, removed } ) ;
235+ } else {
236+ lineStats . set ( filePath , { added, removed } ) ;
237+ }
238+ }
239+ }
202240
203- for ( const line of diffOutput . trim ( ) . split ( "\n" ) . filter ( Boolean ) ) {
204- // Format: STATUS\tPATH or STATUS\tOLD_PATH\tNEW_PATH for renames
241+ // Parse name-status output for file status
242+ // Format: STATUS\tPATH or STATUS\tOLD_PATH\tNEW_PATH for renames
243+ for ( const line of nameStatusResult . stdout
244+ . trim ( )
245+ . split ( "\n" )
246+ . filter ( Boolean ) ) {
205247 const parts = line . split ( "\t" ) ;
206248 const statusChar = parts [ 0 ] [ 0 ] ; // First char (ignore rename percentage like R100)
207249
208250 if ( statusChar === "R" && parts . length >= 3 ) {
209251 // Rename: R100\told-path\tnew-path
210252 const originalPath = parts [ 1 ] ;
211253 const newPath = parts [ 2 ] ;
212- files . push ( { path : newPath , status : "renamed" , originalPath } ) ;
254+ const stats = lineStats . get ( newPath ) || lineStats . get ( originalPath ) ;
255+ files . push ( {
256+ path : newPath ,
257+ status : "renamed" ,
258+ originalPath,
259+ linesAdded : stats ?. added ,
260+ linesRemoved : stats ?. removed ,
261+ } ) ;
213262 seenPaths . add ( newPath ) ;
214263 seenPaths . add ( originalPath ) ;
215264 } else if ( parts . length >= 2 ) {
216265 const filePath = parts [ 1 ] ;
266+ const stats = lineStats . get ( filePath ) ;
217267 let status : GitFileStatus ;
218268 switch ( statusChar ) {
219269 case "D" :
@@ -225,23 +275,22 @@ const getChangedFilesAgainstHead = async (
225275 default :
226276 status = "modified" ;
227277 }
228- files . push ( { path : filePath , status } ) ;
278+ files . push ( {
279+ path : filePath ,
280+ status,
281+ linesAdded : stats ?. added ,
282+ linesRemoved : stats ?. removed ,
283+ } ) ;
229284 seenPaths . add ( filePath ) ;
230285 }
231286 }
232287
233288 // Add untracked files from git status
234- const { stdout : statusOutput } = await execAsync ( "git status --porcelain" , {
235- cwd : directoryPath ,
236- } ) ;
237-
238- for ( const line of statusOutput . trim ( ) . split ( "\n" ) . filter ( Boolean ) ) {
289+ for ( const line of statusResult . stdout . trim ( ) . split ( "\n" ) . filter ( Boolean ) ) {
239290 const statusCode = line . substring ( 0 , 2 ) ;
240291 const filePath = line . substring ( 3 ) ;
241292
242- // Only add untracked files not already seen
243293 if ( statusCode === "??" && ! seenPaths . has ( filePath ) ) {
244- // Check if it's a directory (git shows directories with trailing /)
245294 if ( filePath . endsWith ( "/" ) ) {
246295 const dirPath = filePath . slice ( 0 , - 1 ) ;
247296 try {
@@ -251,14 +300,28 @@ const getChangedFilesAgainstHead = async (
251300 ) ;
252301 for ( const file of dirFiles ) {
253302 if ( ! seenPaths . has ( file ) ) {
254- files . push ( { path : file , status : "untracked" } ) ;
303+ const lineCount = await countFileLines (
304+ path . join ( directoryPath , file ) ,
305+ ) ;
306+ files . push ( {
307+ path : file ,
308+ status : "untracked" ,
309+ linesAdded : lineCount || undefined ,
310+ } ) ;
255311 }
256312 }
257313 } catch {
258314 // Directory might not exist or be inaccessible
259315 }
260316 } else {
261- files . push ( { path : filePath , status : "untracked" } ) ;
317+ const lineCount = await countFileLines (
318+ path . join ( directoryPath , filePath ) ,
319+ ) ;
320+ files . push ( {
321+ path : filePath ,
322+ status : "untracked" ,
323+ linesAdded : lineCount || undefined ,
324+ } ) ;
262325 }
263326 }
264327 }
0 commit comments