Skip to main content

fallow_engine/
flag_age.rs

1//! Flag age from git history for `fallow flags --retirement`.
2//!
3//! `blame` mode runs one `git blame --porcelain` per file with flag sites and
4//! asks for the lines of those sites only. The oldest commit is a lower bound
5//! on the age of the flag, because a rewrite of the line resets it. `pickaxe`
6//! mode also runs one `git log -S<name>` per flag name, which gives the first
7//! commit that added the name. Both modes count days against the
8//! [`AnalysisClock`], so two runs over one commit give the same ages.
9//!
10//! Results are cached under the cache directory for the current HEAD. A blame
11//! entry is also keyed by the file content, because blame reads the working
12//! tree.
13
14use 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
27/// Cache file name under the cache directory.
28const CACHE_FILE: &str = "flag-age.json";
29
30/// Cache format version. Bump it when the cached shape or meaning changes.
31const CACHE_VERSION: u32 = 1;
32
33/// Length of the abbreviated commit hash in the report.
34const SHORT_SHA_LEN: usize = 12;
35
36/// Seconds in one day.
37const SECS_PER_DAY: u64 = 86_400;
38
39/// Number of pickaxe runs between two progress reports.
40const PICKAXE_PROGRESS_STEP: usize = 10;
41
42/// Pickaxe progress: flag names read so far, and the flag names to read.
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub struct PickaxeProgress {
45    /// Flag names read so far.
46    pub done: usize,
47    /// Flag names this run reads.
48    pub total: usize,
49}
50
51/// Inputs of one flag-age measurement.
52#[derive(Clone, Copy)]
53pub struct FlagAgeRequest<'a> {
54    /// Project root. Git runs here, and site paths are relative to it.
55    pub root: &'a Path,
56    /// How to measure the age.
57    pub mode: FlagAgeMode,
58    /// Directory for the age cache, or `None` to run without the cache.
59    pub cache_dir: Option<&'a Path>,
60    /// Receives pickaxe progress: once at the start, then every few names.
61    pub progress: Option<&'a (dyn Fn(PickaxeProgress) + Sync)>,
62}
63
64/// What a flag-age measurement did.
65#[derive(Debug, Default)]
66pub struct FlagAgeOutcome {
67    /// The analysis clock as an RFC 3339 timestamp, when ages were measured.
68    pub generated_at_clock: Option<String>,
69    /// Why ages are missing, when history was not available.
70    pub diagnostics: Vec<WorkspaceDiagnosticKind>,
71    /// `git blame` subprocesses this run started.
72    pub blame_calls: usize,
73    /// `git log -S` subprocesses this run started.
74    pub pickaxe_calls: usize,
75}
76
77/// A commit and its committer time.
78#[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
98/// Fill the age fields of `rows` from git history.
99///
100/// A missing repository, a branch without commits and a shallow clone leave
101/// every age `None` and return the matching diagnostic.
102pub 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
145/// HEAD's commit hash, or the diagnostic that explains why no history is
146/// available.
147fn 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
177/// Blame every file whose site lines the cache does not hold for the current
178/// file content. Returns the number of blame subprocesses.
179fn 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
226/// Commit of each of `lines` in `path`. A line that no commit holds (an
227/// uncommitted change, or an untracked file) maps to `None`.
228fn 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
245/// Parse `git blame --porcelain` into the commit of each final line.
246fn 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
292/// Run `git log -S` for every flag name the cache does not hold. Returns the
293/// number of pickaxe subprocesses.
294fn 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
332/// The oldest commit under the root that changed the number of times `name`
333/// occurs.
334fn 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
401/// Write the cache through a temporary file, so a reader never sees a
402/// partial file. A failed write only costs the next run its warm cache.
403fn 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    /// 2023-11-14T22:13:20Z.
425    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    /// A repository where `FEATURE_A` first appears at day 0, is rewritten
485    /// on day 10 in `a.ts`, and gets a second site in `b.ts` on day 40. HEAD
486    /// is at day 100.
487    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}