11pub mod git;
22pub mod pruning;
33
4- use std:: collections:: { BTreeMap , HashMap } ;
4+ use std:: {
5+ collections:: HashMap ,
6+ io:: { BufRead , Write } ,
7+ process:: { Command , Stdio } ,
8+ sync:: {
9+ atomic:: { AtomicBool , Ordering } ,
10+ Arc ,
11+ } ,
12+ time:: Duration ,
13+ } ;
514
615use chrono:: { DateTime , Duration as ChronoDuration , Months , NaiveDate , Utc } ;
716use clap:: {
@@ -193,69 +202,94 @@ struct TimeRange {
193202}
194203
195204fn repo_stats ( options : & RepoStatsOptions ) -> Result < ( ) , String > {
205+ let progress = start_delayed_progress_meter ( "Computing repo stats..." , Duration :: from_secs ( 1 ) ) ;
206+
196207 let range = resolve_time_range ( options) ?;
197208 if let Some ( top) = options. top {
198209 if top == 0 {
199210 return Err ( String :: from ( "--top must be greater than zero." ) ) ;
200211 }
201212 }
202- let log_lines = git:: git_command_lines (
203- "collect author stats" ,
204- vec ! [
205- "log" ,
206- "--first-parent" ,
207- "--pretty=format:%ct%x09%an%x09%ae" ,
208- "HEAD" ,
209- ] ,
210- ) ?;
211213
212214 let mut totals: HashMap < String , usize > = HashMap :: new ( ) ;
213215 let mut email_to_name: HashMap < String , String > = HashMap :: new ( ) ;
214216 let mut email_aliases: HashMap < String , String > = HashMap :: new ( ) ;
215217 let mut name_to_email: HashMap < String , String > = HashMap :: new ( ) ;
216- let mut timeline: BTreeMap < NaiveDate , HashMap < String , usize > > = BTreeMap :: new ( ) ;
217-
218- for raw_line in log_lines {
218+ let mut latest_commit_date_in_range: Option < NaiveDate > = None ;
219+ let mut latest_commit_ts_by_author: HashMap < String , i64 > = HashMap :: new ( ) ;
220+ let mut oldest_commit_ts_by_author: HashMap < String , i64 > = HashMap :: new ( ) ;
221+
222+ let name_filters_lower: Vec < String > = options. names . iter ( ) . map ( |s| s. to_lowercase ( ) ) . collect ( ) ;
223+ let email_filters_lower: Vec < String > =
224+ options. emails . iter ( ) . map ( |s| s. to_lowercase ( ) ) . collect ( ) ;
225+
226+ let mut git_args: Vec < String > = vec ! [
227+ "log" . to_string( ) ,
228+ "--first-parent" . to_string( ) ,
229+ "--pretty=format:%ct%x09%an%x09%ae" . to_string( ) ,
230+ ] ;
231+ if let Some ( start_ts) = range. start_ts {
232+ git_args. push ( format ! ( "--since=@{start_ts}" ) ) ;
233+ }
234+ if !range. end_is_latest {
235+ git_args. push ( format ! ( "--until=@{}" , range. end_ts) ) ;
236+ }
237+ git_args. push ( "HEAD" . to_string ( ) ) ;
238+
239+ let mut child = Command :: new ( "git" )
240+ . args ( git_args)
241+ . stdout ( Stdio :: piped ( ) )
242+ // Avoid buffering/stalling on stderr while still surfacing errors.
243+ . stderr ( Stdio :: inherit ( ) )
244+ . spawn ( )
245+ . map_err ( |err| format ! ( "collect author stats failed to start: {err}" ) ) ?;
246+ let stdout = child
247+ . stdout
248+ . take ( )
249+ . ok_or_else ( || String :: from ( "collect author stats failed to capture stdout" ) ) ?;
250+ let reader = std:: io:: BufReader :: new ( stdout) ;
251+
252+ for raw_line in reader. lines ( ) {
253+ let raw_line = raw_line
254+ . map_err ( |err| format ! ( "Failed to read git log output: {err}" ) ) ?;
219255 let trimmed = raw_line. trim ( ) ;
220256 if trimmed. is_empty ( ) {
221257 continue ;
222258 }
223259
224- let parts: Vec < & str > = trimmed. split ( '\t' ) . collect ( ) ;
225- if parts. len ( ) != 3 {
260+ let mut parts = trimmed. splitn ( 3 , '\t' ) ;
261+ let ( timestamp_part, name_part, email_part) =
262+ match ( parts. next ( ) , parts. next ( ) , parts. next ( ) ) {
263+ ( Some ( ts) , Some ( name) , Some ( email) ) => ( ts, name, email) ,
264+ _ => {
265+ return Err ( format ! (
266+ "Unexpected git log output (expected `<timestamp>\\ t<name>\\ t<email>`): `{trimmed}`"
267+ ) ) ;
268+ }
269+ } ;
270+ if timestamp_part. is_empty ( ) {
226271 return Err ( format ! (
227272 "Unexpected git log output (expected `<timestamp>\\ t<name>\\ t<email>`): `{trimmed}`"
228273 ) ) ;
229274 }
230- let timestamp_part = parts[ 0 ] ;
231- let name_part = parts[ 1 ] ;
232- let email_part = parts[ 2 ] ;
233275
234276 let timestamp = timestamp_part. parse :: < i64 > ( ) . map_err ( |err| {
235277 format ! ( "Failed to parse git log timestamp `{timestamp_part}`: {err}" )
236278 } ) ?;
237279
238- if timestamp > range. end_ts {
239- continue ;
240- }
241- if let Some ( start_ts) = range. start_ts {
242- if timestamp < start_ts {
243- continue ;
244- }
245- }
246-
247280 let email = email_part. trim ( ) ;
248- let email = if email. is_empty ( ) {
249- String :: from ( "Unknown" )
250- } else {
251- email. to_string ( )
252- } ;
281+ let email = if email. is_empty ( ) { "Unknown" } else { email } ;
253282
254283 let name = name_part. trim ( ) ;
255284 let canonical_email =
256- canonicalize_author ( email. as_str ( ) , name, & mut email_aliases, & mut name_to_email) ;
257-
258- if !matches_author_filters ( name, canonical_email. as_str ( ) , options) {
285+ canonicalize_author ( email, name, & mut email_aliases, & mut name_to_email) ;
286+
287+ if !matches_author_filters_lowered (
288+ name,
289+ canonical_email. as_str ( ) ,
290+ & name_filters_lower,
291+ & email_filters_lower,
292+ ) {
259293 continue ;
260294 }
261295
@@ -268,16 +302,34 @@ fn repo_stats(options: &RepoStatsOptions) -> Result<(), String> {
268302 let date = DateTime :: from_timestamp ( timestamp, 0 )
269303 . ok_or_else ( || format ! ( "Commit timestamp out of range: {timestamp}" ) ) ?
270304 . date_naive ( ) ;
305+ if latest_commit_date_in_range. is_none ( ) {
306+ // `git log` is reverse-chronological, so the first matching commit is the latest.
307+ latest_commit_date_in_range = Some ( date) ;
308+ }
309+
310+ // Per-author active windows. Because `git log` is reverse chronological:
311+ // - the first time we see an author is their latest commit in the range
312+ // - the last time we see an author becomes their oldest commit in the range
313+ latest_commit_ts_by_author
314+ . entry ( canonical_email. clone ( ) )
315+ . or_insert ( timestamp) ;
316+ oldest_commit_ts_by_author. insert ( canonical_email. clone ( ) , timestamp) ;
271317
272318 * totals. entry ( canonical_email. clone ( ) ) . or_insert ( 0 ) += 1 ;
273- timeline
274- . entry ( date)
275- . or_default ( )
276- . entry ( canonical_email)
277- . and_modify ( |count| * count += 1 )
278- . or_insert ( 1 ) ;
279319 }
280320
321+ let status = child
322+ . wait ( )
323+ . map_err ( |err| format ! ( "collect author stats failed to wait: {err}" ) ) ?;
324+ if !status. success ( ) {
325+ return Err ( format ! (
326+ "collect author stats failed with exit code: {}" ,
327+ status. code( ) . unwrap_or( -1 )
328+ ) ) ;
329+ }
330+
331+ progress. finish ( ) ;
332+
281333 if totals. is_empty ( ) {
282334 println ! (
283335 "No first-parent commits found between {} and {}." ,
@@ -300,9 +352,7 @@ fn repo_stats(options: &RepoStatsOptions) -> Result<(), String> {
300352 } ;
301353
302354 let resolved_end_label = if range. end_is_latest {
303- timeline
304- . keys ( )
305- . next_back ( )
355+ latest_commit_date_in_range
306356 . map ( |date| format ! ( "{date} (latest commit)" ) )
307357 . unwrap_or_else ( || String :: from ( "latest commit" ) )
308358 } else {
@@ -326,7 +376,11 @@ fn repo_stats(options: &RepoStatsOptions) -> Result<(), String> {
326376 ( display, count)
327377 } )
328378 . collect ( ) ;
329- print_author_graph ( & display_author_counts_with_names) ;
379+ print_author_graph (
380+ & display_author_counts_with_names,
381+ & latest_commit_ts_by_author,
382+ & oldest_commit_ts_by_author,
383+ ) ;
330384
331385 Ok ( ( ) )
332386}
@@ -357,7 +411,54 @@ fn canonicalize_author(
357411 canonical
358412}
359413
360- fn print_author_graph ( author_counts : & [ ( String , usize ) ] ) {
414+ fn matches_author_filters_lowered (
415+ name : & str ,
416+ email : & str ,
417+ name_filters_lower : & [ String ] ,
418+ email_filters_lower : & [ String ] ,
419+ ) -> bool {
420+ if !name_filters_lower. is_empty ( ) {
421+ if name. is_empty ( ) {
422+ return false ;
423+ }
424+ let name_lower = name. to_lowercase ( ) ;
425+ if !name_filters_lower
426+ . iter ( )
427+ . any ( |filter| name_lower. contains ( filter) )
428+ {
429+ return false ;
430+ }
431+ }
432+
433+ if !email_filters_lower. is_empty ( ) {
434+ if email. is_empty ( ) {
435+ return false ;
436+ }
437+ let email_lower = email. to_lowercase ( ) ;
438+ if !email_filters_lower
439+ . iter ( )
440+ . any ( |filter| email_lower. contains ( filter) )
441+ {
442+ return false ;
443+ }
444+ }
445+
446+ true
447+ }
448+
449+ fn active_weeks_inclusive ( latest_ts : i64 , oldest_ts : i64 ) -> f64 {
450+ let span_seconds = latest_ts. saturating_sub ( oldest_ts) . max ( 0 ) ;
451+ // Inclusive day window keeps single-commit authors reasonable (1 day => 1/7 week),
452+ // and still properly boosts authors who only started partway through the range.
453+ let active_days = ( span_seconds / 86_400 ) + 1 ;
454+ ( active_days as f64 ) / 7.0
455+ }
456+
457+ fn print_author_graph (
458+ author_counts : & [ ( String , usize ) ] ,
459+ latest_commit_ts_by_author : & HashMap < String , i64 > ,
460+ oldest_commit_ts_by_author : & HashMap < String , i64 > ,
461+ ) {
361462 if author_counts. is_empty ( ) {
362463 return ;
363464 }
@@ -381,10 +482,27 @@ fn print_author_graph(author_counts: &[(String, usize)]) {
381482 author_display. yellow ( ) . to_string ( )
382483 } ;
383484
384- println ! ( "({count_str}) {colored_author}" ) ;
485+ let email_key = extract_email_key ( author_display) ;
486+ let commits_per_week_suffix = email_key
487+ . and_then ( |email| {
488+ let latest_ts = latest_commit_ts_by_author. get ( email) ?;
489+ let oldest_ts = oldest_commit_ts_by_author. get ( email) ?;
490+ let weeks = active_weeks_inclusive ( * latest_ts, * oldest_ts) ;
491+ let commits_per_week = ( * count as f64 ) / weeks;
492+ Some ( format ! ( "({commits_per_week:.1}/wk)" ) . purple ( ) )
493+ } )
494+ . unwrap_or_else ( || "(?/wk)" . purple ( ) ) ;
495+
496+ println ! ( "({count_str}) {colored_author} {commits_per_week_suffix}" ) ;
385497 }
386498}
387499
500+ fn extract_email_key ( author_display : & str ) -> Option < & str > {
501+ let start = author_display. find ( '<' ) ?;
502+ let end = author_display. rfind ( '>' ) ?;
503+ ( start < end) . then ( || author_display[ start + 1 ..end] . trim ( ) )
504+ }
505+
388506fn resolve_time_range ( options : & RepoStatsOptions ) -> Result < TimeRange , String > {
389507 let now = Utc :: now ( ) ;
390508 let ( reference_end_dt, end_label, end_is_latest, end_ts) = if let Some ( to_date) = options. to {
@@ -497,6 +615,62 @@ fn parse_naive_date(value: &str) -> Result<NaiveDate, String> {
497615 . map_err ( |err| format ! ( "Invalid date `{value}` (expected YYYY-MM-DD): {err}" ) )
498616}
499617
618+ struct ProgressMeter {
619+ done : Arc < AtomicBool > ,
620+ handle : Option < std:: thread:: JoinHandle < ( ) > > ,
621+ }
622+
623+ impl ProgressMeter {
624+ fn finish ( mut self ) {
625+ self . done . store ( true , Ordering :: Relaxed ) ;
626+ if let Some ( handle) = self . handle . take ( ) {
627+ let _ = handle. join ( ) ;
628+ }
629+ // Clear the line in case we printed progress.
630+ let mut stderr = std:: io:: stderr ( ) ;
631+ let _ = write ! ( stderr, "\r {}\r " , " " . repeat( 80 ) ) ;
632+ let _ = stderr. flush ( ) ;
633+ }
634+ }
635+
636+ impl Drop for ProgressMeter {
637+ fn drop ( & mut self ) {
638+ self . done . store ( true , Ordering :: Relaxed ) ;
639+ if let Some ( handle) = self . handle . take ( ) {
640+ let _ = handle. join ( ) ;
641+ }
642+ let mut stderr = std:: io:: stderr ( ) ;
643+ let _ = write ! ( stderr, "\r {}\r " , " " . repeat( 80 ) ) ;
644+ let _ = stderr. flush ( ) ;
645+ }
646+ }
647+
648+ fn start_delayed_progress_meter ( message : & ' static str , delay : Duration ) -> ProgressMeter {
649+ let done = Arc :: new ( AtomicBool :: new ( false ) ) ;
650+ let done_clone = Arc :: clone ( & done) ;
651+ let handle = std:: thread:: spawn ( move || {
652+ std:: thread:: sleep ( delay) ;
653+ if done_clone. load ( Ordering :: Relaxed ) {
654+ return ;
655+ }
656+
657+ let spinner = [ '|' , '/' , '-' , '\\' ] ;
658+ let mut i = 0usize ;
659+ while !done_clone. load ( Ordering :: Relaxed ) {
660+ let mut stderr = std:: io:: stderr ( ) ;
661+ let _ = write ! ( stderr, "\r {message} {}" , spinner[ i % spinner. len( ) ] ) ;
662+ let _ = stderr. flush ( ) ;
663+ i = i. wrapping_add ( 1 ) ;
664+ std:: thread:: sleep ( Duration :: from_millis ( 120 ) ) ;
665+ }
666+ } ) ;
667+
668+ ProgressMeter {
669+ done,
670+ handle : Some ( handle) ,
671+ }
672+ }
673+
500674fn rebase ( target : & str , interactive : bool ) -> Result < ( ) , String > {
501675 let fetch_target = format ! ( "{target}:{target}" ) ;
502676 git_command_status (
@@ -879,4 +1053,20 @@ mod tests {
8791053 & options
8801054 ) ) ;
8811055 }
1056+
1057+ #[ test]
1058+ fn active_weeks_inclusive_counts_single_day_as_one_seventh_week ( ) {
1059+ // Same-day activity => 1 active day => 1/7 week.
1060+ let weeks = active_weeks_inclusive ( 1_700_000_000 , 1_700_000_000 ) ;
1061+ assert ! ( ( weeks - ( 1.0 / 7.0 ) ) . abs( ) < 1e-9 , "weeks={weeks}" ) ;
1062+ }
1063+
1064+ #[ test]
1065+ fn active_weeks_inclusive_increases_with_span_days ( ) {
1066+ // 8 days inclusive => 8/7 weeks.
1067+ let oldest = 1_700_000_000 ;
1068+ let latest = oldest + ( 7 * 86_400 ) ;
1069+ let weeks = active_weeks_inclusive ( latest, oldest) ;
1070+ assert ! ( ( weeks - ( 8.0 / 7.0 ) ) . abs( ) < 1e-9 , "weeks={weeks}" ) ;
1071+ }
8821072}
0 commit comments