diff --git a/Cargo.lock b/Cargo.lock index 2bb38cb3807..af68f74eff4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2571,6 +2571,7 @@ dependencies = [ "humantime", "log", "parking_lot 0.11.2", + "signal-hook", "time", "tui", "tui-react", diff --git a/Cargo.toml b/Cargo.toml index 3f0fbc5c8ff..e932f555e9d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,7 +37,7 @@ pretty-cli = [ "gitoxide-core/serde1", "prodash/progress-tree", "prodash/progres ## The `--verbose` flag will be powered by an interactive progress mechanism that doubles as log as well as interactive progress ## that appears after a short duration. -prodash-render-line-crossterm = ["prodash-render-line", "prodash/render-line-crossterm", "atty", "crosstermion"] +prodash-render-line-crossterm = ["prodash-render-line", "prodash/render-line-crossterm", "prodash/signal-hook", "atty", "crosstermion"] #! ### Convenience Features #! These combine common choices of the above features to represent typical builds diff --git a/README.md b/README.md index d2fc0167c0c..da00f4e2143 100644 --- a/README.md +++ b/README.md @@ -167,12 +167,6 @@ is usable to some extend. * [x] Generate and verify large commit graphs * [ ] Generate huge pack from a lot of loose objects -### Cargo features - -Many crates use feature flags to allow tuning the compiled result based on your needs. Have a [look at the guide][cargo-features] for more information. - -[cargo-features]: https://github.com/Byron/gitoxide/blob/main/cargo-features.md#git-config - ### Stability and MSRV Our [stability guide] helps to judge how much churn can be expected when depending on crates in this workspace. diff --git a/git-object/src/tree/mod.rs b/git-object/src/tree/mod.rs index 9441040b8a6..d1210342be2 100644 --- a/git-object/src/tree/mod.rs +++ b/git-object/src/tree/mod.rs @@ -39,6 +39,11 @@ impl EntryMode { *self != EntryMode::Tree } + /// Return true if the entry is any kind of blob. + pub fn is_blob(&self) -> bool { + matches!(self, EntryMode::Blob | EntryMode::BlobExecutable) + } + /// Represent the mode as descriptive string. pub fn as_str(&self) -> &'static str { use EntryMode::*; diff --git a/gitoxide-core/src/hours/core.rs b/gitoxide-core/src/hours/core.rs new file mode 100644 index 00000000000..4c42792959c --- /dev/null +++ b/gitoxide-core/src/hours/core.rs @@ -0,0 +1,92 @@ +use crate::hours::FileStats; +use crate::hours::LineStats; +use crate::hours::WorkByEmail; +use crate::hours::WorkByPerson; +use git::bstr::BStr; +use git_repository as git; +use itertools::Itertools; +use std::collections::hash_map::Entry; +use std::collections::HashMap; + +const MINUTES_PER_HOUR: f32 = 60.0; +pub const HOURS_PER_WORKDAY: f32 = 8.0; + +pub fn estimate_hours( + commits: &[(u32, git::actor::SignatureRef<'static>)], + stats: &[(u32, FileStats, LineStats)], +) -> 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_for_commits = commits.iter().map(|t| &t.1).rev().tuple_windows().fold( + 0_f32, + |hours, (cur, next): (&git::actor::SignatureRef<'_>, &git::actor::SignatureRef<'_>)| { + let change_in_minutes = (next + .time + .seconds_since_unix_epoch + .saturating_sub(cur.time.seconds_since_unix_epoch)) 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].1; + let (files, lines) = (!stats.is_empty()) + .then(|| { + commits + .iter() + .map(|t| &t.0) + .fold((FileStats::default(), LineStats::default()), |mut acc, id| match stats + .binary_search_by(|t| t.0.cmp(id)) + { + Ok(idx) => { + let t = &stats[idx]; + acc.0.add(&t.1); + acc.1.add(&t.2); + acc + } + Err(_) => acc, + }) + }) + .unwrap_or_default(); + WorkByEmail { + name: author.name, + email: author.email, + hours: FIRST_COMMIT_ADDITION_IN_MINUTES / 60.0 + hours_for_commits, + num_commits: commits.len() as u32, + files, + lines, + } +} + +pub fn deduplicate_identities(persons: &[WorkByEmail]) -> Vec { + let mut email_to_index = HashMap::<&'static BStr, usize>::with_capacity(persons.len()); + let mut name_to_index = HashMap::<&'static BStr, usize>::with_capacity(persons.len()); + let mut out = Vec::::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 +} diff --git a/gitoxide-core/src/hours.rs b/gitoxide-core/src/hours/mod.rs similarity index 62% rename from gitoxide-core/src/hours.rs rename to gitoxide-core/src/hours/mod.rs index 81f65042f4b..d9713e4ccc9 100644 --- a/gitoxide-core/src/hours.rs +++ b/gitoxide-core/src/hours/mod.rs @@ -1,18 +1,12 @@ use std::collections::BTreeSet; use std::convert::Infallible; use std::sync::atomic::Ordering; -use std::{ - collections::{hash_map::Entry, HashMap}, - io, - path::Path, - time::Instant, -}; +use std::{io, path::Path, time::Instant}; use anyhow::{anyhow, bail}; use git_repository as git; use git_repository::bstr::BStr; use git_repository::{actor, bstr::ByteSlice, interrupt, prelude::*, progress, Progress}; -use itertools::Itertools; /// Additional configuration for the hours estimation functionality. pub struct Context { @@ -57,9 +51,9 @@ where let repo = git::discover(working_dir)?.apply_environment(); let commit_id = repo.rev_parse_single(rev_spec)?.detach(); let mut string_heap = BTreeSet::<&'static [u8]>::new(); + let needs_stats = file_stats || line_stats; - let (commit_authors, stats, is_shallow) = { - let needs_stats = file_stats || line_stats; + let (commit_authors, stats, is_shallow, skipped_merge_commits) = { let stat_progress = needs_stats.then(|| progress.add_child("extract stats")).map(|mut p| { p.init(None, progress::count("commits")); p @@ -179,66 +173,78 @@ where match change.event { Addition { entry_mode, id } => { if entry_mode.is_no_tree() { - files.added += 1 - } - if let Ok(blob) = id.object() { - let nl = blob.data.lines_with_terminator().count(); - lines.added += nl; - if let Some(c) = lines_counter.as_ref() { - c.fetch_add(nl, Ordering::SeqCst); - } + files.added += 1; + add_lines(line_stats, lines_counter.as_deref(), &mut lines, id); } } Deletion { entry_mode, id } => { if entry_mode.is_no_tree() { - files.removed += 1 - } - if let Ok(blob) = id.object() { - let nl = blob.data.lines_with_terminator().count(); - lines.removed += nl; - if let Some(c) = lines_counter.as_ref() { - c.fetch_add(nl, Ordering::SeqCst); - } + files.removed += 1; + remove_lines( + line_stats, + lines_counter.as_deref(), + &mut lines, + id, + ); } } - Modification { entry_mode, .. } => { - if entry_mode.is_no_tree() { - files.modified += 1; + Modification { + entry_mode, + previous_entry_mode, + id, + previous_id, + } => match (previous_entry_mode.is_blob(), entry_mode.is_blob()) { + (false, false) => {} + (false, true) => { + files.added += 1; + add_lines(line_stats, lines_counter.as_deref(), &mut lines, id); + } + (true, false) => { + files.removed += 1; + add_lines( + line_stats, + lines_counter.as_deref(), + &mut lines, + previous_id, + ); } - if line_stats { - let is_text_file = mime_guess::from_path( - git::path::from_bstr(change.location).as_ref(), - ) - .first_or_text_plain() - .type_() - == mime_guess::mime::TEXT; - if let Some(Ok(diff)) = - is_text_file.then(|| change.event.diff()).flatten() - { - use git::diff::lines::similar::ChangeTag::*; - let mut nl = 0; - for change in diff - .text(git::diff::lines::Algorithm::Myers) - .iter_all_changes() + (true, true) => { + files.modified += 1; + if line_stats { + let is_text_file = mime_guess::from_path( + git::path::from_bstr(change.location).as_ref(), + ) + .first_or_text_plain() + .type_() + == mime_guess::mime::TEXT; + if let Some(Ok(diff)) = + is_text_file.then(|| change.event.diff()).flatten() { - match change.tag() { - Delete => { - lines.removed += 1; - nl += 1; + use git::diff::lines::similar::ChangeTag::*; + let mut nl = 0; + for change in diff + .text(git::diff::lines::Algorithm::Myers) + .iter_all_changes() + { + match change.tag() { + Delete => { + lines.removed += 1; + nl += 1; + } + Insert => { + lines.added += 1; + nl += 1 + } + Equal => {} } - Insert => { - lines.added += 1; - nl += 1 - } - Equal => {} } - } - if let Some(c) = lines_counter.as_ref() { - c.fetch_add(nl, Ordering::SeqCst); + if let Some(c) = lines_counter.as_ref() { + c.fetch_add(nl, Ordering::SeqCst); + } } } } - } + }, } Ok::<_, Infallible>(Default::default()) })?; @@ -254,16 +260,24 @@ where .unwrap_or_else(Default::default); let mut commit_idx = 0_u32; + let mut skipped_merge_commits = 0; let commit_iter = interrupt::Iter::new( commit_id.ancestors(|oid, buf| { progress.inc(); repo.objects.find(oid, buf).map(|obj| { tx.send((commit_idx, obj.data.to_owned())).ok(); if let Some((tx_tree, first_parent, commit)) = tx_tree_id.as_ref().and_then(|tx| { - git::objs::CommitRefIter::from_bytes(obj.data) - .parent_ids() + let mut parents = git::objs::CommitRefIter::from_bytes(obj.data).parent_ids(); + let res = parents .next() - .map(|first_parent| (tx, Some(first_parent), oid.to_owned())) + .map(|first_parent| (tx, Some(first_parent), oid.to_owned())); + match parents.next() { + Some(_) => { + skipped_merge_commits += 1; + None + } + None => res, + } }) { tx_tree.send((commit_idx, first_parent, commit)).ok(); } @@ -316,6 +330,7 @@ where commit_thread.join().expect("no panic")?, stats_by_commit_idx, is_shallow, + skipped_merge_commits, )) })? }; @@ -394,15 +409,20 @@ where if file_stats { writeln!( out, - "total files added/removed/modified: {}/{}/{}", - total_files.added, total_files.removed, total_files.modified + "total files added/removed/modified/remaining: {}/{}/{}/{}", + total_files.added, + total_files.removed, + total_files.modified, + total_files.added - total_files.removed )?; } if line_stats { writeln!( out, - "total lines added/removed: {}/{}", - total_lines.added, total_lines.removed + "total lines added/removed/remaining: {}/{}/{}", + total_lines.added, + total_lines.removed, + total_lines.added - total_lines.removed )?; } if !omit_unify_identities { @@ -414,7 +434,10 @@ where )?; } if ignored_bot_commits != 0 { - writeln!(out, "commits by bots: {}", ignored_bot_commits,)?; + writeln!(out, "commits by bots: {}", ignored_bot_commits)?; + } + if needs_stats && skipped_merge_commits != 0 { + writeln!(out, "stats omitted for {} merge commits", skipped_merge_commits)?; } assert_eq!( total_commits, @@ -424,234 +447,8 @@ where Ok(()) } -const MINUTES_PER_HOUR: f32 = 60.0; -const HOURS_PER_WORKDAY: f32 = 8.0; - -fn estimate_hours( - commits: &[(u32, actor::SignatureRef<'static>)], - stats: &[(u32, FileStats, LineStats)], -) -> 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_for_commits = commits.iter().map(|t| &t.1).rev().tuple_windows().fold( - 0_f32, - |hours, (cur, next): (&actor::SignatureRef<'_>, &actor::SignatureRef<'_>)| { - let change_in_minutes = (next - .time - .seconds_since_unix_epoch - .saturating_sub(cur.time.seconds_since_unix_epoch)) 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].1; - let (files, lines) = (!stats.is_empty()) - .then(|| { - commits - .iter() - .map(|t| &t.0) - .fold((FileStats::default(), LineStats::default()), |mut acc, id| match stats - .binary_search_by(|t| t.0.cmp(id)) - { - Ok(idx) => { - let t = &stats[idx]; - acc.0.add(&t.1); - acc.1.add(&t.2); - acc - } - Err(_) => acc, - }) - }) - .unwrap_or_default(); - WorkByEmail { - name: author.name, - email: author.email, - hours: FIRST_COMMIT_ADDITION_IN_MINUTES / 60.0 + hours_for_commits, - num_commits: commits.len() as u32, - files, - lines, - } -} - -fn deduplicate_identities(persons: &[WorkByEmail]) -> Vec { - let mut email_to_index = HashMap::<&'static BStr, usize>::with_capacity(persons.len()); - let mut name_to_index = HashMap::<&'static BStr, usize>::with_capacity(persons.len()); - let mut out = Vec::::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 { - name: Vec<&'static BStr>, - email: Vec<&'static BStr>, - hours: f32, - num_commits: u32, - files: FileStats, - lines: LineStats, -} - -impl<'a> WorkByPerson { - 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; - self.files.add(&other.files); - } -} - -impl<'a> From<&'a WorkByEmail> for WorkByPerson { - fn from(w: &'a WorkByEmail) -> Self { - WorkByPerson { - name: vec![w.name], - email: vec![w.email], - hours: w.hours, - num_commits: w.num_commits, - files: w.files, - lines: w.lines, - } - } -} +mod core; +use self::core::{deduplicate_identities, estimate_hours, HOURS_PER_WORKDAY}; -impl WorkByPerson { - fn write_to( - &self, - total_hours: f32, - total_files: Option, - total_lines: Option, - mut out: impl std::io::Write, - ) -> std::io::Result<()> { - writeln!( - out, - "{} <{}>", - self.name.iter().join(", "), - self.email.iter().join(", ") - )?; - writeln!(out, "{} commits found", self.num_commits)?; - writeln!( - out, - "total time spent: {:.02}h ({:.02} 8h days, {:.02}%)", - self.hours, - self.hours / HOURS_PER_WORKDAY, - (self.hours / total_hours) * 100.0 - )?; - if let Some(total) = total_files { - writeln!( - out, - "total files added/removed/modified: {}/{}/{} ({:.02}%)", - self.files.added, - self.files.removed, - self.files.modified, - (self.files.sum() / total.sum()) * 100.0 - )?; - } - if let Some(total) = total_lines { - writeln!( - out, - "total lines added/removed: {}/{} ({:.02}%)", - self.lines.added, - self.lines.removed, - (self.lines.sum() / total.sum()) * 100.0 - )?; - } - Ok(()) - } -} - -#[derive(Debug)] -struct WorkByEmail { - name: &'static BStr, - email: &'static BStr, - hours: f32, - num_commits: u32, - files: FileStats, - lines: LineStats, -} - -/// File statistics for a particular commit. -#[derive(Debug, Default, Copy, Clone)] -struct FileStats { - /// amount of added files - added: usize, - /// amount of removed files - removed: usize, - /// amount of modified files - modified: usize, -} - -/// Line statistics for a particular commit. -#[derive(Debug, Default, Copy, Clone)] -struct LineStats { - /// amount of added lines - added: usize, - /// amount of removed lines - removed: usize, -} - -impl FileStats { - fn add(&mut self, other: &FileStats) -> &mut Self { - self.added += other.added; - self.removed += other.removed; - self.modified += other.modified; - self - } - - fn added(&self, other: &FileStats) -> Self { - let mut a = *self; - a.add(other); - a - } - - fn sum(&self) -> f32 { - (self.added + self.removed + self.modified) as f32 - } -} - -impl LineStats { - fn add(&mut self, other: &LineStats) -> &mut Self { - self.added += other.added; - self.removed += other.removed; - self - } - - fn added(&self, other: &LineStats) -> Self { - let mut a = *self; - a.add(other); - a - } - - fn sum(&self) -> f32 { - (self.added + self.removed) as f32 - } -} +mod util; +use util::{add_lines, remove_lines, FileStats, LineStats, WorkByEmail, WorkByPerson}; diff --git a/gitoxide-core/src/hours/util.rs b/gitoxide-core/src/hours/util.rs new file mode 100644 index 00000000000..ac851bc564d --- /dev/null +++ b/gitoxide-core/src/hours/util.rs @@ -0,0 +1,176 @@ +use crate::hours::core::HOURS_PER_WORKDAY; +use git::bstr::BStr; +use git::bstr::ByteSlice; +use git_repository as git; +use itertools::Itertools; +use std::sync::atomic::{AtomicUsize, Ordering}; + +#[derive(Debug)] +pub struct WorkByPerson { + pub name: Vec<&'static BStr>, + pub email: Vec<&'static BStr>, + pub hours: f32, + pub num_commits: u32, + pub files: FileStats, + pub lines: LineStats, +} + +impl<'a> WorkByPerson { + pub 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; + self.files.add(&other.files); + self.lines.add(&other.lines); + } +} + +impl<'a> From<&'a WorkByEmail> for WorkByPerson { + fn from(w: &'a WorkByEmail) -> Self { + WorkByPerson { + name: vec![w.name], + email: vec![w.email], + hours: w.hours, + num_commits: w.num_commits, + files: w.files, + lines: w.lines, + } + } +} + +impl WorkByPerson { + pub fn write_to( + &self, + total_hours: f32, + total_files: Option, + total_lines: Option, + mut out: impl std::io::Write, + ) -> std::io::Result<()> { + writeln!( + out, + "{} <{}>", + self.name.iter().join(", "), + self.email.iter().join(", ") + )?; + writeln!(out, "{} commits found", self.num_commits)?; + writeln!( + out, + "total time spent: {:.02}h ({:.02} 8h days, {:.02}%)", + self.hours, + self.hours / HOURS_PER_WORKDAY, + (self.hours / total_hours) * 100.0 + )?; + if let Some(total) = total_files { + writeln!( + out, + "total files added/removed/modified: {}/{}/{} ({:.02}%)", + self.files.added, + self.files.removed, + self.files.modified, + (self.files.sum() / total.sum()) * 100.0 + )?; + } + if let Some(total) = total_lines { + writeln!( + out, + "total lines added/removed: {}/{} ({:.02}%)", + self.lines.added, + self.lines.removed, + (self.lines.sum() / total.sum()) * 100.0 + )?; + } + Ok(()) + } +} + +#[derive(Debug)] +pub struct WorkByEmail { + pub name: &'static BStr, + pub email: &'static BStr, + pub hours: f32, + pub num_commits: u32, + pub files: FileStats, + pub lines: LineStats, +} + +/// File statistics for a particular commit. +#[derive(Debug, Default, Copy, Clone)] +pub struct FileStats { + /// amount of added files + pub added: usize, + /// amount of removed files + pub removed: usize, + /// amount of modified files + pub modified: usize, +} + +/// Line statistics for a particular commit. +#[derive(Debug, Default, Copy, Clone)] +pub struct LineStats { + /// amount of added lines + pub added: usize, + /// amount of removed lines + pub removed: usize, +} + +impl FileStats { + pub fn add(&mut self, other: &FileStats) -> &mut Self { + self.added += other.added; + self.removed += other.removed; + self.modified += other.modified; + self + } + + pub fn added(&self, other: &FileStats) -> Self { + let mut a = *self; + a.add(other); + a + } + + pub fn sum(&self) -> f32 { + (self.added + self.removed + self.modified) as f32 + } +} + +impl LineStats { + pub fn add(&mut self, other: &LineStats) -> &mut Self { + self.added += other.added; + self.removed += other.removed; + self + } + + pub fn added(&self, other: &LineStats) -> Self { + let mut a = *self; + a.add(other); + a + } + + pub fn sum(&self) -> f32 { + (self.added + self.removed) as f32 + } +} + +pub fn add_lines(line_stats: bool, lines_counter: Option<&AtomicUsize>, mut lines: &mut LineStats, id: git::Id<'_>) { + if let Some(Ok(blob)) = line_stats.then(|| id.object()) { + let nl = blob.data.lines_with_terminator().count(); + lines.added += nl; + if let Some(c) = lines_counter { + c.fetch_add(nl, Ordering::SeqCst); + } + } +} + +pub fn remove_lines(line_stats: bool, lines_counter: Option<&AtomicUsize>, mut lines: &mut LineStats, id: git::Id<'_>) { + if let Some(Ok(blob)) = line_stats.then(|| id.object()) { + let nl = blob.data.lines_with_terminator().count(); + lines.removed += nl; + if let Some(c) = lines_counter { + c.fetch_add(nl, Ordering::SeqCst); + } + } +} diff --git a/gitoxide-core/src/organize.rs b/gitoxide-core/src/organize.rs index 372e1d73d33..65f3b79e4d8 100644 --- a/gitoxide-core/src/organize.rs +++ b/gitoxide-core/src/organize.rs @@ -173,10 +173,10 @@ fn handle( } let destination = canonicalized_destination - .join( - url.host() - .ok_or_else(|| anyhow::Error::msg(format!("Remote URLs must have host names: {}", url.to_bstring())))?, - ) + .join(match url.host() { + Some(h) => h, + None => return Ok(()), + }) .join(to_relative({ let mut path = git_url::expand_path(None, url.path.as_bstr())?; match kind { diff --git a/tests/snapshots/porcelain/estimate-hours/all-stats-success b/tests/snapshots/porcelain/estimate-hours/all-stats-success index 1c30a868651..6479fc2c77c 100644 --- a/tests/snapshots/porcelain/estimate-hours/all-stats-success +++ b/tests/snapshots/porcelain/estimate-hours/all-stats-success @@ -8,6 +8,6 @@ total hours: 2.00 total 8h days: 0.25 total commits = 3 total authors: 1 -total files added/removed/modified: 1/0/1 -total lines added/removed: 1/0 +total files added/removed/modified/remaining: 1/0/1/1 +total lines added/removed/remaining: 1/0/1 total unique authors: 1 (0.00% duplication) \ No newline at end of file diff --git a/tests/snapshots/porcelain/estimate-hours/file-stats-success b/tests/snapshots/porcelain/estimate-hours/file-stats-success index cfdb9d1ffef..2f0e78dcbe4 100644 --- a/tests/snapshots/porcelain/estimate-hours/file-stats-success +++ b/tests/snapshots/porcelain/estimate-hours/file-stats-success @@ -2,5 +2,5 @@ total hours: 2.00 total 8h days: 0.25 total commits = 3 total authors: 1 -total files added/removed/modified: 1/0/1 +total files added/removed/modified/remaining: 1/0/1/1 total unique authors: 1 (0.00% duplication) \ No newline at end of file diff --git a/tests/snapshots/porcelain/estimate-hours/line-stats-success b/tests/snapshots/porcelain/estimate-hours/line-stats-success index 0d1db43a6fa..d8ccbfba1a7 100644 --- a/tests/snapshots/porcelain/estimate-hours/line-stats-success +++ b/tests/snapshots/porcelain/estimate-hours/line-stats-success @@ -2,5 +2,5 @@ total hours: 2.00 total 8h days: 0.25 total commits = 3 total authors: 1 -total lines added/removed: 1/0 +total lines added/removed/remaining: 1/0/1 total unique authors: 1 (0.00% duplication) \ No newline at end of file