Skip to content

Commit 6a3ec58

Browse files
authored
Merge pull request #31 from kyle-rader/cursor/rep-stats-performance-and-progress-41f8
2 parents 0b40f3f + 460c7ba commit 6a3ec58

3 files changed

Lines changed: 239 additions & 49 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "loki-cli"
3-
version = "1.3.0"
3+
version = "1.5.2"
44
authors = ["Kyle W. Rader"]
55
description = "Loki: 🚀 A Git productivity tool"
66
homepage = "https://github.com/kyle-rader/loki-cli"

src/main.rs

Lines changed: 237 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,16 @@
11
pub mod git;
22
pub 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

615
use chrono::{DateTime, Duration as ChronoDuration, Months, NaiveDate, Utc};
716
use clap::{
@@ -193,69 +202,94 @@ struct TimeRange {
193202
}
194203

195204
fn 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+
388506
fn 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+
500674
fn 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

Comments
 (0)