@@ -10,10 +10,13 @@ import type {
1010 ContributorStat ,
1111 ContributorStatsResult ,
1212 LanguageStat ,
13- LanguageStatsResult
13+ LanguageFileStat ,
14+ LanguageStatsResult ,
15+ RepoAnalyticsResult
1416} from '../types' ;
1517
1618const GIT_AUTHOR_PREFIX = '--AUTHOR--' ;
19+ const GIT_COMMIT_PREFIX = '--COMMIT--' ;
1720
1821export class StatsService {
1922 constructor (
@@ -63,6 +66,49 @@ export class StatsService {
6366 } ;
6467 }
6568
69+ async getLanguageFileStats ( languageId : string ) : Promise < { available : boolean ; stats : LanguageFileStat [ ] } > {
70+ if ( ! vscode . workspace . workspaceFolders ?. length ) {
71+ return { available : false , stats : [ ] } ;
72+ }
73+
74+ const files = await vscode . workspace . findFiles (
75+ '**/*' ,
76+ '**/{node_modules,out,dist,.git}/**'
77+ ) ;
78+ if ( files . length === 0 ) {
79+ return { available : true , stats : [ ] } ;
80+ }
81+
82+ const filtered = await this . filterIgnoredFiles ( files ) ;
83+ if ( filtered . length === 0 ) {
84+ return { available : true , stats : [ ] } ;
85+ }
86+
87+ const stats : LanguageFileStat [ ] = [ ] ;
88+ for ( const uri of filtered ) {
89+ try {
90+ const document = await vscode . workspace . openTextDocument ( uri ) ;
91+ if ( document . languageId !== languageId ) {
92+ continue ;
93+ }
94+ const lines = this . lineCounter . countDocumentLines ( document ) ;
95+ const workspaceFolder = vscode . workspace . getWorkspaceFolder ( uri ) ;
96+ const rootPath = workspaceFolder ?. uri . fsPath ;
97+ const relativePath = rootPath
98+ ? path . relative ( rootPath , uri . fsPath ) . replace ( / \\ / g, '/' )
99+ : uri . fsPath ;
100+ stats . push ( { filePath : relativePath , absolutePath : uri . fsPath , lines } ) ;
101+ } catch {
102+ continue ;
103+ }
104+ }
105+
106+ return {
107+ available : true ,
108+ stats : stats . sort ( ( a , b ) => b . lines - a . lines )
109+ } ;
110+ }
111+
66112 async getContributorStats ( ) : Promise < ContributorStatsResult > {
67113 const rootPath = await this . getGitRoot ( ) ;
68114 if ( ! rootPath ) {
@@ -165,6 +211,236 @@ export class StatsService {
165211 }
166212 }
167213
214+ async getRepoAnalytics ( ) : Promise < RepoAnalyticsResult > {
215+ const rootPath = await this . getGitRoot ( ) ;
216+ if ( ! rootPath ) {
217+ return {
218+ available : false ,
219+ totalCommits : 0 ,
220+ activeDays : 0 ,
221+ totalAdded : 0 ,
222+ totalDeleted : 0 ,
223+ avgLinesChangedPerCommit : 0 ,
224+ commitsByDate : [ ] ,
225+ commitsByMonth : [ ] ,
226+ commitsByAuthor : [ ] ,
227+ commitsByWeekday : Array . from ( { length : 7 } , ( ) => 0 ) ,
228+ commitsByHour : Array . from ( { length : 24 } , ( ) => 0 ) ,
229+ topChangedFiles : [ ]
230+ } ;
231+ }
232+
233+ try {
234+ const output = await this . execGit (
235+ [ 'log' , '--date=iso-strict' , `--pretty=${ GIT_COMMIT_PREFIX } %aI|%an` , '--numstat' ] ,
236+ rootPath
237+ ) ;
238+
239+ const byDate = new Map < string , { commits : number ; added : number ; deleted : number } > ( ) ;
240+ const byMonth = new Map < string , { commits : number ; added : number ; deleted : number } > ( ) ;
241+ const byAuthor = new Map < string , { commits : number ; added : number ; deleted : number } > ( ) ;
242+ const byFile = new Map < string , { added : number ; deleted : number } > ( ) ;
243+ const commitsByWeekday = Array . from ( { length : 7 } , ( ) => 0 ) ;
244+ const commitsByHour = Array . from ( { length : 24 } , ( ) => 0 ) ;
245+ let totalCommits = 0 ;
246+ let totalAdded = 0 ;
247+ let totalDeleted = 0 ;
248+ let currentDateKey : string | undefined ;
249+ let currentMonthKey : string | undefined ;
250+ let currentAuthor : string | undefined ;
251+ let firstCommitDate : string | undefined ;
252+ let lastCommitDate : string | undefined ;
253+
254+ const lines = output . split ( / \r ? \n / ) ;
255+ for ( const line of lines ) {
256+ if ( line . startsWith ( GIT_COMMIT_PREFIX ) ) {
257+ const payload = line . slice ( GIT_COMMIT_PREFIX . length ) . trim ( ) ;
258+ const [ dateTime , authorName ] = payload . split ( '|' ) ;
259+ if ( ! dateTime ) {
260+ currentDateKey = undefined ;
261+ currentMonthKey = undefined ;
262+ currentAuthor = undefined ;
263+ continue ;
264+ }
265+
266+ const dateKey = dateTime . slice ( 0 , 10 ) ;
267+ const monthKey = dateTime . slice ( 0 , 7 ) ;
268+ const author = ( authorName ?? '' ) . trim ( ) || 'Unknown' ;
269+ const date = new Date ( dateTime ) ;
270+ if ( Number . isNaN ( date . getTime ( ) ) ) {
271+ currentDateKey = undefined ;
272+ currentMonthKey = undefined ;
273+ currentAuthor = undefined ;
274+ continue ;
275+ }
276+
277+ const weekday = date . getDay ( ) ;
278+ const hourMatch = dateTime . match ( / T ( \d { 2 } ) : / ) ;
279+ const hour = hourMatch ? Number ( hourMatch [ 1 ] ) : date . getHours ( ) ;
280+
281+ totalCommits += 1 ;
282+ currentDateKey = dateKey ;
283+ currentMonthKey = monthKey ;
284+ currentAuthor = author ;
285+ commitsByWeekday [ weekday ] += 1 ;
286+ if ( hour >= 0 && hour < 24 ) {
287+ commitsByHour [ hour ] += 1 ;
288+ }
289+
290+ if ( ! lastCommitDate ) {
291+ lastCommitDate = dateKey ;
292+ }
293+ firstCommitDate = dateKey ;
294+
295+ const dateBucket = byDate . get ( dateKey ) ?? { commits : 0 , added : 0 , deleted : 0 } ;
296+ dateBucket . commits += 1 ;
297+ byDate . set ( dateKey , dateBucket ) ;
298+
299+ const monthBucket = byMonth . get ( monthKey ) ?? { commits : 0 , added : 0 , deleted : 0 } ;
300+ monthBucket . commits += 1 ;
301+ byMonth . set ( monthKey , monthBucket ) ;
302+
303+ const authorBucket = byAuthor . get ( author ) ?? { commits : 0 , added : 0 , deleted : 0 } ;
304+ authorBucket . commits += 1 ;
305+ byAuthor . set ( author , authorBucket ) ;
306+ continue ;
307+ }
308+
309+ if ( ! currentDateKey || ! currentMonthKey || ! currentAuthor || ! line . trim ( ) ) {
310+ continue ;
311+ }
312+
313+ const [ addedText , deletedText , filePath ] = line . split ( '\t' ) ;
314+ if ( ! addedText || ! deletedText || ! filePath || addedText === '-' || deletedText === '-' ) {
315+ continue ;
316+ }
317+
318+ const added = Number ( addedText ) ;
319+ const deleted = Number ( deletedText ) ;
320+ if ( Number . isNaN ( added ) || Number . isNaN ( deleted ) ) {
321+ continue ;
322+ }
323+
324+ totalAdded += added ;
325+ totalDeleted += deleted ;
326+
327+ const dateBucket = byDate . get ( currentDateKey ) ;
328+ if ( dateBucket ) {
329+ dateBucket . added += added ;
330+ dateBucket . deleted += deleted ;
331+ byDate . set ( currentDateKey , dateBucket ) ;
332+ }
333+
334+ const monthBucket = byMonth . get ( currentMonthKey ) ;
335+ if ( monthBucket ) {
336+ monthBucket . added += added ;
337+ monthBucket . deleted += deleted ;
338+ byMonth . set ( currentMonthKey , monthBucket ) ;
339+ }
340+
341+ const authorBucket = byAuthor . get ( currentAuthor ) ;
342+ if ( authorBucket ) {
343+ authorBucket . added += added ;
344+ authorBucket . deleted += deleted ;
345+ byAuthor . set ( currentAuthor , authorBucket ) ;
346+ }
347+
348+ const normalizedPath = this . normalizeGitPath ( filePath ) ;
349+ const fileBucket = byFile . get ( normalizedPath ) ?? { added : 0 , deleted : 0 } ;
350+ fileBucket . added += added ;
351+ fileBucket . deleted += deleted ;
352+ byFile . set ( normalizedPath , fileBucket ) ;
353+ }
354+
355+ const commitsByDate = Array . from ( byDate . entries ( ) )
356+ . map ( ( [ date , value ] ) => ( {
357+ date,
358+ commits : value . commits ,
359+ added : value . added ,
360+ deleted : value . deleted
361+ } ) )
362+ . sort ( ( a , b ) => a . date . localeCompare ( b . date ) ) ;
363+
364+ const commitsByMonth = Array . from ( byMonth . entries ( ) )
365+ . map ( ( [ month , value ] ) => ( {
366+ month,
367+ commits : value . commits ,
368+ added : value . added ,
369+ deleted : value . deleted
370+ } ) )
371+ . sort ( ( a , b ) => a . month . localeCompare ( b . month ) ) ;
372+
373+ const commitsByAuthor = Array . from ( byAuthor . entries ( ) )
374+ . map ( ( [ author , value ] ) => ( {
375+ author,
376+ commits : value . commits ,
377+ added : value . added ,
378+ deleted : value . deleted
379+ } ) )
380+ . sort ( ( a , b ) => b . commits - a . commits ) ;
381+
382+ const topChangedFiles = Array . from ( byFile . entries ( ) )
383+ . map ( ( [ filePath , totals ] ) => ( {
384+ filePath,
385+ changes : totals . added + totals . deleted ,
386+ added : totals . added ,
387+ deleted : totals . deleted
388+ } ) )
389+ . sort ( ( a , b ) => b . changes - a . changes )
390+ . slice ( 0 , 12 ) ;
391+
392+ let busiestDay : { date : string ; commits : number } | undefined ;
393+ let mostChangedDay : { date : string ; changes : number } | undefined ;
394+ for ( const item of commitsByDate ) {
395+ if ( ! busiestDay || item . commits > busiestDay . commits ) {
396+ busiestDay = { date : item . date , commits : item . commits } ;
397+ }
398+ const changes = item . added + item . deleted ;
399+ if ( ! mostChangedDay || changes > mostChangedDay . changes ) {
400+ mostChangedDay = { date : item . date , changes } ;
401+ }
402+ }
403+
404+ const avgLinesChangedPerCommit = totalCommits > 0
405+ ? Math . round ( ( totalAdded + totalDeleted ) / totalCommits )
406+ : 0 ;
407+
408+ return {
409+ available : true ,
410+ totalCommits,
411+ activeDays : commitsByDate . length ,
412+ firstCommitDate,
413+ lastCommitDate,
414+ totalAdded,
415+ totalDeleted,
416+ avgLinesChangedPerCommit,
417+ busiestDay,
418+ mostChangedDay,
419+ commitsByDate,
420+ commitsByMonth,
421+ commitsByAuthor,
422+ commitsByWeekday,
423+ commitsByHour,
424+ topChangedFiles
425+ } ;
426+ } catch {
427+ return {
428+ available : true ,
429+ totalCommits : 0 ,
430+ activeDays : 0 ,
431+ totalAdded : 0 ,
432+ totalDeleted : 0 ,
433+ avgLinesChangedPerCommit : 0 ,
434+ commitsByDate : [ ] ,
435+ commitsByMonth : [ ] ,
436+ commitsByAuthor : [ ] ,
437+ commitsByWeekday : Array . from ( { length : 7 } , ( ) => 0 ) ,
438+ commitsByHour : Array . from ( { length : 24 } , ( ) => 0 ) ,
439+ topChangedFiles : [ ]
440+ } ;
441+ }
442+ }
443+
168444 private async filterIgnoredFiles ( files : readonly vscode . Uri [ ] ) : Promise < vscode . Uri [ ] > {
169445 const filteredFiles : vscode . Uri [ ] = [ ] ;
170446 for ( const uri of files ) {
0 commit comments