284 lines · 9.4 KB · Rust
RawDownload
use std::{    collections::{hash_map::Entry, HashMap},    ffi::OsStr,    fmt,    fmt::{Display, Formatter},    io,    path::Path,    time::Instant,}; use anyhow::{anyhow, bail};use git_repository::{    actor, bstr::BString, interrupt, objs, odb, odb::pack, prelude::*, progress, refs::file::ReferenceExt, Progress,};use itertools::Itertools;use rayon::prelude::*; /// Additional configuration for the hours estimation functionality.pub struct Context<W> {    /// Show personally identifiable information before the summary. Includes names and email addresses.    pub show_pii: bool,    /// Omit unifying identities by name and email which can lead to the same author appear multiple times    /// due to using different names or email addresses.    pub omit_unify_identities: bool,    /// Where to write our output to    pub out: W,} /// Estimate the hours it takes to produce the content of the repository in `_working_dir_`, with `_refname_` for/// the start of the commit graph traversal.////// * _working_dir_ - The directory containing a '.git/' folder./// * _refname_ - The name of the ref like 'main' or 'master' at which to start iterating the commit graph./// * _progress_ - A way to provide progress and performance informationpub fn estimate<W, P>(    working_dir: &Path,    refname: &OsStr,    mut progress: P,    Context {        show_pii,        omit_unify_identities,        mut out,    }: Context<W>,) -> anyhow::Result<()>where    W: io::Write,    P: Progress,{    let repo = git_repository::discover(working_dir)?;    let packed = repo.refs.packed_buffer()?;    let commit_id = repo        .refs        .find(refname.to_string_lossy().as_ref(), packed.as_ref())?        .peel_to_id_in_place(&repo.refs, packed.as_ref(), |oid, buf| {            repo.odb                .try_find(oid, buf, &mut pack::cache::Never)                .map(|obj| obj.map(|obj| (obj.kind, obj.data)))        })?        .to_owned();     let all_commits = {        let start = Instant::now();        let mut progress = progress.add_child("Traverse commit graph");        progress.init(None, progress::count("commits"));        let mut pack_cache = odb::pack::cache::Never;        let mut commits: Vec<Vec<u8>> = Vec::new();        for c in interrupt::Iter::new(            commit_id.ancestors(|oid, buf| {                progress.inc();                repo.odb.find(oid, buf, &mut pack_cache).ok().map(|o| {                    commits.push(o.data.to_owned());                    objs::CommitRefIter::from_bytes(o.data)                })            }),            || anyhow!("Cancelled by user"),        ) {            c??;        }        progress.show_throughput(start);        commits    };     let start = Instant::now();    #[allow(clippy::redundant_closure)]    let mut all_commits: Vec<actor::Signature> = all_commits        .into_par_iter()        .map(|commit_data: Vec<u8>| {            objs::CommitRefIter::from_bytes(&commit_data)                .signatures()                .next()                .map(|author| actor::Signature::from(author))        })        .try_fold(            || Vec::new(),            |mut out: Vec<_>, item| {                out.push(item?);                Some(out)            },        )        .try_reduce(            || Vec::new(),            |mut out, vec| {                out.extend(vec.into_iter());                Some(out)            },        )        .ok_or_else(|| anyhow!("An error occurred when decoding commits - one commit could not be parsed"))?;    all_commits.sort_by(|a, b| a.email.cmp(&b.email).then(a.time.time.cmp(&b.time.time).reverse()));    if all_commits.is_empty() {        bail!("No commits to process");    }    let mut current_email = &all_commits[0].email;    let mut slice_start = 0;    let mut results_by_hours = Vec::new();    for (idx, elm) in all_commits.iter().enumerate() {        if elm.email != *current_email {            results_by_hours.push(estimate_hours(&all_commits[slice_start..idx]));            slice_start = idx;            current_email = &elm.email;        }    }    if let Some(commits) = all_commits.get(slice_start..) {        results_by_hours.push(estimate_hours(commits));    }     let num_authors = results_by_hours.len();    let mut results_by_hours = if !omit_unify_identities {        deduplicate_identities(&results_by_hours)    } else {        results_by_hours            .iter()            .fold(Vec::with_capacity(results_by_hours.len()), |mut acc, e| {                acc.push(e.into());                acc            })    };    let elapsed = start.elapsed();    progress.done(format!(        "Extracted and organized data from {} commits in {:?} ({:0.0} commits/s)",        all_commits.len(),        elapsed,        all_commits.len() as f32 / elapsed.as_secs_f32()    ));     let num_unique_authors = results_by_hours.len();    if show_pii {        results_by_hours.sort_by(|a, b| a.hours.partial_cmp(&b.hours).unwrap_or(std::cmp::Ordering::Equal));        for entry in results_by_hours.iter() {            writeln!(out, "{}\n", entry)?;        }    }    let (total_hours, total_commits) = results_by_hours        .iter()        .map(|e| (e.hours, e.num_commits))        .reduce(|a, b| (a.0 + b.0, a.1 + b.1))        .expect("at least one commit at this point");    writeln!(        out,        "total hours: {:.02}\ntotal 8h days: {:.02}\ntotal commits = {}\ntotal authors: {}",        total_hours,        total_hours / HOURS_PER_WORKDAY,        total_commits,        num_authors    )?;    if !omit_unify_identities {        writeln!(            out,            "total unique authors: {} ({:.02}% duplication)",            num_unique_authors,            (1.0 - (num_unique_authors as f32 / num_authors as f32)) * 100.0        )?;    }    assert_eq!(total_commits, all_commits.len() as u32, "need to get all commits");    Ok(())} const MINUTES_PER_HOUR: f32 = 60.0;const HOURS_PER_WORKDAY: f32 = 8.0; fn estimate_hours(commits: &[actor::Signature]) -> WorkByEmail {    assert!(!commits.is_empty());    const MAX_COMMIT_DIFFERENCE_IN_MINUTES: f32 = 2.0 * MINUTES_PER_HOUR;    const FIRST_COMMIT_ADDITION_IN_MINUTES: f32 = 2.0 * MINUTES_PER_HOUR;     let hours = FIRST_COMMIT_ADDITION_IN_MINUTES / 60.0        + commits.iter().rev().tuple_windows().fold(            0_f32,            |hours, (cur, next): (&actor::Signature, &actor::Signature)| {                let change_in_minutes = (next.time.time - cur.time.time) as f32 / MINUTES_PER_HOUR;                if change_in_minutes < MAX_COMMIT_DIFFERENCE_IN_MINUTES {                    hours + change_in_minutes as f32 / MINUTES_PER_HOUR                } else {                    hours + (FIRST_COMMIT_ADDITION_IN_MINUTES / MINUTES_PER_HOUR)                }            },        );    let author = &commits[0];    WorkByEmail {        name: author.name.to_owned(),        email: author.email.to_owned(),        hours,        num_commits: commits.len() as u32,    }} fn deduplicate_identities(persons: &[WorkByEmail]) -> Vec<WorkByPerson<'_>> {    let mut email_to_index = HashMap::<&BString, usize>::with_capacity(persons.len());    let mut name_to_index = HashMap::<&BString, usize>::with_capacity(persons.len());    let mut out = Vec::<WorkByPerson<'_>>::with_capacity(persons.len());    for person_by_email in persons {        match email_to_index.entry(&person_by_email.email) {            Entry::Occupied(email_entry) => {                out[*email_entry.get()].merge(person_by_email);                name_to_index.insert(&person_by_email.name, *email_entry.get());            }            Entry::Vacant(email_entry) => match name_to_index.entry(&person_by_email.name) {                Entry::Occupied(name_entry) => {                    out[*name_entry.get()].merge(person_by_email);                    email_entry.insert(*name_entry.get());                }                Entry::Vacant(name_entry) => {                    let idx = out.len();                    name_entry.insert(idx);                    email_entry.insert(idx);                    out.push(person_by_email.into());                }            },        }    }    out} #[derive(Debug)]struct WorkByPerson<'a> {    name: Vec<&'a BString>,    email: Vec<&'a BString>,    hours: f32,    num_commits: u32,} impl<'a> WorkByPerson<'a> {    fn merge(&mut self, other: &'a WorkByEmail) {        if !self.name.contains(&&other.name) {            self.name.push(&other.name);        }        if !self.email.contains(&&other.email) {            self.email.push(&other.email);        }        self.num_commits += other.num_commits;        self.hours += other.hours;    }} impl<'a> From<&'a WorkByEmail> for WorkByPerson<'a> {    fn from(w: &'a WorkByEmail) -> Self {        WorkByPerson {            name: vec![&w.name],            email: vec![&w.email],            hours: w.hours,            num_commits: w.num_commits,        }    }} impl<'a> Display for WorkByPerson<'a> {    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {        writeln!(f, "{} <{}>", self.name.iter().join(", "), self.email.iter().join(", "))?;        writeln!(f, "{} commits found", self.num_commits)?;        writeln!(            f,            "total time spent: {:.02}h ({:.02} 8h days)",            self.hours,            self.hours / HOURS_PER_WORKDAY        )    }} #[derive(Debug)]struct WorkByEmail {    name: BString,    email: BString,    hours: f32,    num_commits: u32,}