1use std::collections::BTreeMap;
15use std::path::Path;
16use std::process::Stdio;
17use std::sync::atomic::{AtomicUsize, Ordering};
18
19use fallow_types::flag_retirement::{FlagAgeMode, FlagCommit, RetirementFlag};
20use fallow_types::workspace::WorkspaceDiagnosticKind;
21use rayon::prelude::*;
22use rustc_hash::{FxHashMap, FxHashSet};
23use serde::{Deserialize, Serialize};
24
25use crate::clock::{AnalysisClock, utc_date, utc_timestamp};
26
27const CACHE_FILE: &str = "flag-age.json";
29
30const CACHE_VERSION: u32 = 1;
32
33const SHORT_SHA_LEN: usize = 12;
35
36const SECS_PER_DAY: u64 = 86_400;
38
39const PICKAXE_PROGRESS_STEP: usize = 10;
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub struct PickaxeProgress {
45 pub done: usize,
47 pub total: usize,
49}
50
51#[derive(Clone, Copy)]
53pub struct FlagAgeRequest<'a> {
54 pub root: &'a Path,
56 pub mode: FlagAgeMode,
58 pub cache_dir: Option<&'a Path>,
60 pub progress: Option<&'a (dyn Fn(PickaxeProgress) + Sync)>,
62}
63
64#[derive(Debug, Default)]
66pub struct FlagAgeOutcome {
67 pub generated_at_clock: Option<String>,
69 pub diagnostics: Vec<WorkspaceDiagnosticKind>,
71 pub blame_calls: usize,
73 pub pickaxe_calls: usize,
75}
76
77#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
79struct CommitStamp {
80 sha: String,
81 time: u64,
82}
83
84#[derive(Debug, Default, Serialize, Deserialize)]
85struct AgeCache {
86 version: u32,
87 head: String,
88 files: BTreeMap<String, FileBlame>,
89 pickaxe: BTreeMap<String, Option<CommitStamp>>,
90}
91
92#[derive(Debug, Default, Serialize, Deserialize)]
93struct FileBlame {
94 content_hash: u64,
95 lines: BTreeMap<u32, Option<CommitStamp>>,
96}
97
98pub fn apply_flag_ages(
103 rows: &mut [RetirementFlag],
104 request: &FlagAgeRequest<'_>,
105) -> FlagAgeOutcome {
106 let mut outcome = FlagAgeOutcome::default();
107 if request.mode == FlagAgeMode::Off {
108 return outcome;
109 }
110 let head = match probe_history(request.root) {
111 Ok(head) => head,
112 Err(diagnostic) => {
113 outcome.diagnostics.push(diagnostic);
114 return outcome;
115 }
116 };
117 let clock = AnalysisClock::for_repo(request.root);
118 outcome.generated_at_clock = Some(utc_timestamp(clock.epoch_secs()));
119
120 let mut cache = request
121 .cache_dir
122 .and_then(|dir| load_cache(dir, &head))
123 .unwrap_or_else(|| AgeCache {
124 version: CACHE_VERSION,
125 head: head.clone(),
126 ..AgeCache::default()
127 });
128
129 outcome.blame_calls = refresh_blame(&mut cache, rows, request.root);
130 if request.mode == FlagAgeMode::Pickaxe {
131 outcome.pickaxe_calls = refresh_pickaxe(&mut cache, rows, request);
132 }
133 if let Some(dir) = request.cache_dir
134 && (outcome.blame_calls > 0 || outcome.pickaxe_calls > 0)
135 {
136 save_cache(dir, &cache);
137 }
138
139 for row in rows.iter_mut() {
140 fill_row(row, &cache, request.mode, clock);
141 }
142 outcome
143}
144
145fn probe_history(root: &Path) -> Result<String, WorkspaceDiagnosticKind> {
148 let shallow = run_git(root, &["rev-parse", "--is-shallow-repository"]).ok_or_else(|| {
149 WorkspaceDiagnosticKind::FlagAgeUnavailable {
150 cause: "not-a-repository".to_string(),
151 }
152 })?;
153 if shallow.trim().eq_ignore_ascii_case("true") {
154 return Err(WorkspaceDiagnosticKind::FlagAgeShallowClone);
155 }
156 let head = run_git(root, &["rev-parse", "--verify", "--quiet", "HEAD"]).ok_or_else(|| {
157 WorkspaceDiagnosticKind::FlagAgeUnavailable {
158 cause: "no-commits".to_string(),
159 }
160 })?;
161 Ok(head.trim().to_string())
162}
163
164fn run_git(root: &Path, args: &[&str]) -> Option<String> {
165 let output = crate::git_env::git_command()
166 .args(args)
167 .current_dir(root)
168 .stderr(Stdio::null())
169 .output()
170 .ok()?;
171 output
172 .status
173 .success()
174 .then(|| String::from_utf8_lossy(&output.stdout).into_owned())
175}
176
177fn refresh_blame(cache: &mut AgeCache, rows: &[RetirementFlag], root: &Path) -> usize {
180 let mut lines_by_file: BTreeMap<&str, FxHashSet<u32>> = BTreeMap::new();
181 for site in rows.iter().flat_map(|row| &row.sites) {
182 lines_by_file
183 .entry(site.path.as_str())
184 .or_default()
185 .insert(site.line);
186 }
187 let stale: Vec<(String, u64, Vec<u32>)> = lines_by_file
188 .into_iter()
189 .filter_map(|(path, lines)| {
190 let bytes = std::fs::read(root.join(path)).ok()?;
191 let content_hash = xxhash_rust::xxh3::xxh3_64(&bytes);
192 let cached = cache.files.get(path).is_some_and(|entry| {
193 entry.content_hash == content_hash
194 && lines.iter().all(|line| entry.lines.contains_key(line))
195 });
196 if cached {
197 return None;
198 }
199 let mut lines: Vec<u32> = lines.into_iter().collect();
200 lines.sort_unstable();
201 Some((path.to_string(), content_hash, lines))
202 })
203 .collect();
204
205 let calls = AtomicUsize::new(0);
206 let blamed: Vec<(String, u64, FxHashMap<u32, Option<CommitStamp>>)> = stale
207 .into_par_iter()
208 .map(|(path, content_hash, lines)| {
209 calls.fetch_add(1, Ordering::Relaxed);
210 let stamps = blame_lines(root, &path, &lines);
211 (path, content_hash, stamps)
212 })
213 .collect();
214
215 for (path, content_hash, stamps) in blamed {
216 let entry = cache.files.entry(path).or_default();
217 if entry.content_hash != content_hash {
218 entry.lines.clear();
219 entry.content_hash = content_hash;
220 }
221 entry.lines.extend(stamps);
222 }
223 calls.into_inner()
224}
225
226fn blame_lines(root: &Path, path: &str, lines: &[u32]) -> FxHashMap<u32, Option<CommitStamp>> {
229 let mut args: Vec<String> = vec!["blame".to_string(), "--porcelain".to_string()];
230 for line in lines {
231 args.push("-L".to_string());
232 args.push(format!("{line},{line}"));
233 }
234 args.push("--".to_string());
235 args.push(path.to_string());
236 let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
237 let mut stamps: FxHashMap<u32, Option<CommitStamp>> =
238 lines.iter().map(|&line| (line, None)).collect();
239 if let Some(output) = run_git(root, &arg_refs) {
240 stamps.extend(parse_blame_porcelain(&output));
241 }
242 stamps
243}
244
245fn parse_blame_porcelain(output: &str) -> FxHashMap<u32, Option<CommitStamp>> {
247 let mut times: FxHashMap<&str, u64> = FxHashMap::default();
248 let mut line_shas: Vec<(u32, &str)> = Vec::new();
249 let mut current: Option<(&str, u32)> = None;
250 for line in output.lines() {
251 if line.starts_with('\t') {
252 if let Some((sha, final_line)) = current.take() {
253 line_shas.push((final_line, sha));
254 }
255 continue;
256 }
257 if let Some(time) = line.strip_prefix("committer-time ") {
258 if let (Some((sha, _)), Ok(time)) = (current, time.trim().parse::<u64>()) {
259 times.insert(sha, time);
260 }
261 continue;
262 }
263 let mut fields = line.split(' ');
264 let Some(first) = fields.next() else {
265 continue;
266 };
267 if is_object_id(first)
268 && let Some(Ok(final_line)) = fields.nth(1).map(str::parse::<u32>)
269 {
270 current = Some((first, final_line));
271 }
272 }
273 line_shas
274 .into_iter()
275 .map(|(line, sha)| {
276 let stamp = (!sha.bytes().all(|byte| byte == b'0'))
277 .then(|| times.get(sha))
278 .flatten()
279 .map(|&time| CommitStamp {
280 sha: sha.to_string(),
281 time,
282 });
283 (line, stamp)
284 })
285 .collect()
286}
287
288fn is_object_id(token: &str) -> bool {
289 matches!(token.len(), 40 | 64) && token.bytes().all(|byte| byte.is_ascii_hexdigit())
290}
291
292fn refresh_pickaxe(
295 cache: &mut AgeCache,
296 rows: &[RetirementFlag],
297 request: &FlagAgeRequest<'_>,
298) -> usize {
299 let mut names: Vec<&str> = rows
300 .iter()
301 .map(|row| row.flag_name.as_str())
302 .filter(|name| !name.is_empty() && !cache.pickaxe.contains_key(*name))
303 .collect();
304 names.sort_unstable();
305 names.dedup();
306 if names.is_empty() {
307 return 0;
308 }
309 let total = names.len();
310 let report = |done: usize| {
311 if let Some(progress) = request.progress {
312 progress(PickaxeProgress { done, total });
313 }
314 };
315 report(0);
316 let done = AtomicUsize::new(0);
317 let found: Vec<(String, Option<CommitStamp>)> = names
318 .into_par_iter()
319 .map(|name| {
320 let stamp = first_commit_with(request.root, name);
321 let finished = done.fetch_add(1, Ordering::Relaxed) + 1;
322 if finished < total && finished.is_multiple_of(PICKAXE_PROGRESS_STEP) {
323 report(finished);
324 }
325 (name.to_string(), stamp)
326 })
327 .collect();
328 cache.pickaxe.extend(found);
329 total
330}
331
332fn first_commit_with(root: &Path, name: &str) -> Option<CommitStamp> {
335 let pickaxe = format!("-S{name}");
336 let output = run_git(
337 root,
338 &[
339 "log",
340 pickaxe.as_str(),
341 "--reverse",
342 "--format=%H %ct",
343 "--",
344 ".",
345 ],
346 )?;
347 let first = output.lines().next()?;
348 let (sha, time) = first.split_once(' ')?;
349 Some(CommitStamp {
350 sha: sha.to_string(),
351 time: time.trim().parse().ok()?,
352 })
353}
354
355fn fill_row(row: &mut RetirementFlag, cache: &AgeCache, mode: FlagAgeMode, clock: AnalysisClock) {
356 let stamps: Vec<&CommitStamp> = row
357 .sites
358 .iter()
359 .filter_map(|site| {
360 cache
361 .files
362 .get(&site.path)
363 .and_then(|entry| entry.lines.get(&site.line))
364 .and_then(Option::as_ref)
365 })
366 .collect();
367 let oldest = stamps
368 .iter()
369 .min_by(|a, b| a.time.cmp(&b.time).then(a.sha.cmp(&b.sha)))
370 .copied();
371 let newest = stamps
372 .iter()
373 .max_by(|a, b| a.time.cmp(&b.time).then(b.sha.cmp(&a.sha)))
374 .copied();
375 let first_seen = (mode == FlagAgeMode::Pickaxe)
376 .then(|| cache.pickaxe.get(&row.flag_name))
377 .flatten()
378 .and_then(Option::as_ref);
379
380 row.oldest_surviving_site = oldest.map(flag_commit);
381 row.last_touched = newest.map(flag_commit);
382 row.first_seen = first_seen.map(flag_commit);
383 row.age_days = first_seen
384 .or(oldest)
385 .map(|stamp| clock.epoch_secs().saturating_sub(stamp.time) / SECS_PER_DAY);
386}
387
388fn flag_commit(stamp: &CommitStamp) -> FlagCommit {
389 FlagCommit {
390 commit: stamp.sha.chars().take(SHORT_SHA_LEN).collect(),
391 date: utc_date(stamp.time),
392 }
393}
394
395fn load_cache(dir: &Path, head: &str) -> Option<AgeCache> {
396 let bytes = std::fs::read(dir.join(CACHE_FILE)).ok()?;
397 let cache: AgeCache = serde_json::from_slice(&bytes).ok()?;
398 (cache.version == CACHE_VERSION && cache.head == head).then_some(cache)
399}
400
401fn save_cache(dir: &Path, cache: &AgeCache) {
404 let Ok(bytes) = serde_json::to_vec(cache) else {
405 return;
406 };
407 if std::fs::create_dir_all(dir).is_err() {
408 return;
409 }
410 let tmp = dir.join(format!("{CACHE_FILE}.tmp"));
411 if std::fs::write(&tmp, bytes).is_ok() {
412 let _ = std::fs::rename(&tmp, dir.join(CACHE_FILE));
413 }
414}
415
416#[cfg(test)]
417mod tests {
418 use std::path::PathBuf;
419
420 use fallow_types::flag_retirement::{FlagSiteRole, RetirementFlagKind, RetirementSite};
421
422 use super::*;
423
424 const BASE_EPOCH: u64 = 1_700_000_000;
426
427 fn git(root: &Path, args: &[&str], epoch: Option<u64>) {
428 let mut command = crate::git_env::git_command();
429 command.current_dir(root).args(args).stdout(Stdio::null());
430 if let Some(epoch) = epoch {
431 let stamp = format!("{epoch} +0000");
432 command
433 .env("GIT_AUTHOR_DATE", &stamp)
434 .env("GIT_COMMITTER_DATE", &stamp);
435 }
436 let status = command.status().expect("run git");
437 assert!(status.success(), "git {args:?} failed");
438 }
439
440 fn init_repo(root: &Path) {
441 git(root, &["init", "--quiet", "--initial-branch=main"], None);
442 git(root, &["config", "user.name", "Flag Fixture"], None);
443 git(root, &["config", "user.email", "fixture@example.com"], None);
444 git(root, &["config", "commit.gpgsign", "false"], None);
445 }
446
447 fn commit_file(root: &Path, path: &str, contents: &str, epoch: u64) {
448 let file = root.join(path);
449 std::fs::create_dir_all(file.parent().expect("parent")).expect("dirs");
450 std::fs::write(&file, contents).expect("write");
451 git(root, &["add", path], None);
452 git(root, &["commit", "--quiet", "-m", path], Some(epoch));
453 }
454
455 fn row(name: &str, sites: &[(&str, u32)]) -> RetirementFlag {
456 RetirementFlag {
457 flag_name: name.to_string(),
458 kind: RetirementFlagKind::EnvironmentVariable,
459 sdk_name: None,
460 workspace: None,
461 sites: sites
462 .iter()
463 .map(|&(path, line)| RetirementSite {
464 path: path.to_string(),
465 line,
466 col: 0,
467 role: FlagSiteRole::Read,
468 in_test: false,
469 })
470 .collect(),
471 read_sites: sites.len(),
472 test_only: false,
473 first_seen: None,
474 oldest_surviving_site: None,
475 last_touched: None,
476 age_days: None,
477 reasons: Vec::new(),
478 evidence: Vec::new(),
479 actions: Vec::new(),
480 vendor: None,
481 }
482 }
483
484 fn fixture() -> (tempfile::TempDir, PathBuf) {
488 let dir = tempfile::tempdir().expect("temp dir");
489 let root = dir.path().to_path_buf();
490 init_repo(&root);
491 let day = |n: u64| BASE_EPOCH + n * SECS_PER_DAY;
492 commit_file(&root, "a.ts", "x\nif (process.env.FEATURE_A) {}\n", day(0));
493 commit_file(
494 &root,
495 "a.ts",
496 "x\nif (process.env.FEATURE_A === '1') {}\n",
497 day(10),
498 );
499 commit_file(&root, "b.ts", "if (process.env.FEATURE_A) {}\n", day(40));
500 commit_file(&root, "c.ts", "// unrelated\n", day(100));
501 (dir, root)
502 }
503
504 fn measure(
505 root: &Path,
506 mode: FlagAgeMode,
507 cache_dir: Option<&Path>,
508 ) -> (Vec<RetirementFlag>, FlagAgeOutcome) {
509 let mut rows = vec![row("FEATURE_A", &[("a.ts", 2), ("b.ts", 1)])];
510 let outcome = apply_flag_ages(
511 &mut rows,
512 &FlagAgeRequest {
513 root,
514 mode,
515 cache_dir,
516 progress: None,
517 },
518 );
519 (rows, outcome)
520 }
521
522 #[test]
523 fn blame_ages_count_from_the_oldest_surviving_line() {
524 let (_dir, root) = fixture();
525 let (rows, outcome) = measure(&root, FlagAgeMode::Blame, None);
526 let row = &rows[0];
527 assert!(outcome.diagnostics.is_empty());
528 assert_eq!(outcome.blame_calls, 2, "one blame per file with sites");
529 assert_eq!(outcome.pickaxe_calls, 0);
530 assert_eq!(
531 outcome.generated_at_clock.as_deref(),
532 Some("2024-02-22T22:13:20Z")
533 );
534 assert_eq!(row.age_days, Some(90), "day 100 minus the day-10 rewrite");
535 assert_eq!(
536 row.oldest_surviving_site.as_ref().map(|c| c.date.as_str()),
537 Some("2023-11-24")
538 );
539 assert_eq!(
540 row.last_touched.as_ref().map(|c| c.date.as_str()),
541 Some("2023-12-24")
542 );
543 assert!(row.first_seen.is_none(), "blame does not read first_seen");
544 assert_eq!(
545 row.oldest_surviving_site.as_ref().map(|c| c.commit.len()),
546 Some(SHORT_SHA_LEN)
547 );
548 }
549
550 #[test]
551 fn pickaxe_ages_count_from_the_first_commit_with_the_name() {
552 let (_dir, root) = fixture();
553 let (rows, outcome) = measure(&root, FlagAgeMode::Pickaxe, None);
554 let row = &rows[0];
555 assert_eq!(outcome.pickaxe_calls, 1, "one pickaxe per flag name");
556 assert_eq!(row.age_days, Some(100));
557 assert_eq!(
558 row.first_seen.as_ref().map(|c| c.date.as_str()),
559 Some("2023-11-14")
560 );
561 }
562
563 #[test]
564 fn a_warm_cache_starts_no_git_history_subprocess() {
565 let (_dir, root) = fixture();
566 let cache = tempfile::tempdir().expect("cache dir");
567 let (cold_rows, cold) = measure(&root, FlagAgeMode::Pickaxe, Some(cache.path()));
568 assert_eq!((cold.blame_calls, cold.pickaxe_calls), (2, 1));
569 let (warm_rows, warm) = measure(&root, FlagAgeMode::Pickaxe, Some(cache.path()));
570 assert_eq!((warm.blame_calls, warm.pickaxe_calls), (0, 0));
571 assert_eq!(cold_rows, warm_rows);
572 }
573
574 #[test]
575 fn an_edited_file_is_blamed_again() {
576 let (_dir, root) = fixture();
577 let cache = tempfile::tempdir().expect("cache dir");
578 let _ = measure(&root, FlagAgeMode::Blame, Some(cache.path()));
579 std::fs::write(
580 root.join("b.ts"),
581 "if (process.env.FEATURE_A) { edit(); }\n",
582 )
583 .expect("edit");
584 let (rows, outcome) = measure(&root, FlagAgeMode::Blame, Some(cache.path()));
585 assert_eq!(outcome.blame_calls, 1);
586 assert_eq!(
587 rows[0].last_touched.as_ref().map(|c| c.date.as_str()),
588 Some("2023-11-24"),
589 "an uncommitted line has no commit"
590 );
591 }
592
593 #[test]
594 fn off_mode_starts_no_git_subprocess() {
595 let (_dir, root) = fixture();
596 let (rows, outcome) = measure(&root, FlagAgeMode::Off, None);
597 assert_eq!((outcome.blame_calls, outcome.pickaxe_calls), (0, 0));
598 assert!(outcome.generated_at_clock.is_none());
599 assert_eq!(rows[0].age_days, None);
600 }
601
602 #[test]
603 fn no_repository_gives_no_age_and_a_diagnostic() {
604 let dir = tempfile::tempdir().expect("temp dir");
605 std::fs::write(dir.path().join("a.ts"), "x\n").expect("write");
606 let (rows, outcome) = measure(dir.path(), FlagAgeMode::Blame, None);
607 assert_eq!(rows[0].age_days, None);
608 assert_eq!(
609 outcome.diagnostics,
610 vec![WorkspaceDiagnosticKind::FlagAgeUnavailable {
611 cause: "not-a-repository".to_string()
612 }]
613 );
614 }
615
616 #[test]
617 fn a_branch_without_commits_gives_no_age_and_a_diagnostic() {
618 let dir = tempfile::tempdir().expect("temp dir");
619 init_repo(dir.path());
620 let (_, outcome) = measure(dir.path(), FlagAgeMode::Blame, None);
621 assert_eq!(
622 outcome.diagnostics,
623 vec![WorkspaceDiagnosticKind::FlagAgeUnavailable {
624 cause: "no-commits".to_string()
625 }]
626 );
627 }
628
629 #[test]
630 fn a_shallow_clone_gives_no_age_and_a_diagnostic() {
631 let (_dir, root) = fixture();
632 let clone = tempfile::tempdir().expect("clone dir");
633 let source = format!("file://{}", root.display());
634 git(
635 clone.path(),
636 &["clone", "--quiet", "--depth", "1", &source, "."],
637 None,
638 );
639 let (rows, outcome) = measure(clone.path(), FlagAgeMode::Blame, None);
640 assert_eq!(rows[0].age_days, None);
641 assert_eq!(
642 outcome.diagnostics,
643 vec![WorkspaceDiagnosticKind::FlagAgeShallowClone]
644 );
645 }
646
647 #[test]
648 fn porcelain_parser_reads_repeated_commits_and_uncommitted_lines() {
649 let sha = "a".repeat(40);
650 let zero = "0".repeat(40);
651 let output = format!(
652 "{sha} 1 3 1\nauthor A\ncommitter-time 100\nsummary s\nfilename a.ts\n\tline three\n\
653 {sha} 2 5 1\n\tline five\n\
654 {zero} 7 7 1\ncommitter-time 900\n\tline seven\n"
655 );
656 let stamps = parse_blame_porcelain(&output);
657 assert_eq!(stamps[&3].as_ref().map(|s| s.time), Some(100));
658 assert_eq!(stamps[&5].as_ref().map(|s| s.time), Some(100));
659 assert_eq!(stamps[&7], None);
660 }
661}