Skip to content

Commit

Permalink
Add jj diffs --stat option
Browse files Browse the repository at this point in the history
  • Loading branch information
ob committed Aug 20, 2023
1 parent 4bd05e8 commit 44e28dd
Show file tree
Hide file tree
Showing 3 changed files with 188 additions and 1 deletion.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]
* `jj diff --stat` has been implemented. It shows a histogram of the changes,
same as `git diff --stat`. Fixes [#2066](https://github.com/martinvonz/jj/issues/2066)

### Breaking changes

Expand Down
114 changes: 113 additions & 1 deletion cli/src/diff_util.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.

use std::cmp::max;
use std::collections::VecDeque;
use std::io;
use std::ops::Range;
Expand All @@ -36,12 +37,15 @@ use crate::merge_tools::{self, ExternalMergeTool};
use crate::ui::Ui;

#[derive(clap::Args, Clone, Debug)]
#[command(group(clap::ArgGroup::new("short-format").args(&["summary", "types"])))]
#[command(group(clap::ArgGroup::new("short-format").args(&["summary", "stat", "types"])))]
#[command(group(clap::ArgGroup::new("long-format").args(&["git", "color_words", "tool"])))]
pub struct DiffFormatArgs {
/// For each path, show only whether it was modified, added, or removed
#[arg(long, short)]
pub summary: bool,
// Show a histogram of the changes
#[arg(long)]
pub stat: bool,
/// For each path, show only its type before and after
///
/// The diff is shown as two letters. The first letter indicates the type
Expand All @@ -65,6 +69,7 @@ pub struct DiffFormatArgs {
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum DiffFormat {
Summary,
Stat,
Types,
Git,
ColorWords,
Expand Down Expand Up @@ -109,6 +114,7 @@ fn diff_formats_from_args(
(args.types, DiffFormat::Types),
(args.git, DiffFormat::Git),
(args.color_words, DiffFormat::ColorWords),
(args.stat, DiffFormat::Stat),
]
.into_iter()
.filter_map(|(arg, format)| arg.then_some(format))
Expand Down Expand Up @@ -141,6 +147,7 @@ fn default_diff_format(settings: &UserSettings) -> Result<DiffFormat, config::Co
"types" => Ok(DiffFormat::Types),
"git" => Ok(DiffFormat::Git),
"color-words" => Ok(DiffFormat::ColorWords),
"stat" => Ok(DiffFormat::Stat),
_ => Err(config::ConfigError::Message(format!(
"invalid diff format: {name}"
))),
Expand All @@ -162,6 +169,10 @@ pub fn show_diff(
let tree_diff = from_tree.diff(to_tree, matcher);
show_diff_summary(formatter, workspace_command, tree_diff)?;
}
DiffFormat::Stat => {
let tree_diff = from_tree.diff(to_tree, matcher);
show_diff_stat(ui, formatter, workspace_command, tree_diff)?;
}
DiffFormat::Types => {
let tree_diff = from_tree.diff(to_tree, matcher);
show_types(formatter, workspace_command, tree_diff)?;
Expand Down Expand Up @@ -749,6 +760,107 @@ pub fn show_diff_summary(
})
}

struct DiffStat {
path: String,
added: usize,
removed: usize,
}

fn get_diff_stat(repo_path: String, left_content: &[u8], right_content: &[u8]) -> DiffStat {
let hunks = unified_diff_hunks(left_content, right_content, 0);
let mut adds = 0;
let mut dels = 0;
for hunk in hunks {
for (line_type, _content) in hunk.lines {
match line_type {
DiffLineType::Context => {}
DiffLineType::Removed => dels += 1,
DiffLineType::Added => adds += 1,
}
}
}
DiffStat {
path: repo_path,
added: adds,
removed: dels,
}
}

pub fn show_diff_stat(
ui: &Ui,
formatter: &mut dyn Formatter,
workspace_command: &WorkspaceCommandHelper,
tree_diff: TreeDiffIterator,
) -> Result<(), CommandError> {
let mut stats: Vec<DiffStat> = vec![];
let mut max_path_length = 0;
let mut max_diffs = 0;
for (repo_path, diff) in tree_diff {
let path = workspace_command.format_file_path(&repo_path);
let mut left_content: Vec<u8> = vec![];
let mut right_content: Vec<u8> = vec![];
match diff {
tree::Diff::Modified(left, right) => {
left_content = diff_content(workspace_command.repo(), &repo_path, &left)?;
right_content = diff_content(workspace_command.repo(), &repo_path, &right)?;
}
tree::Diff::Added(right) => {
right_content = diff_content(workspace_command.repo(), &repo_path, &right)?;
}
tree::Diff::Removed(left) => {
left_content = diff_content(workspace_command.repo(), &repo_path, &left)?;
}
}
max_path_length = max(max_path_length, path.len());
let stat = get_diff_stat(path, &left_content, &right_content);
max_diffs = max(max_diffs, stat.added + stat.removed);
stats.push(stat);
}

let display_width = usize::from(ui.term_width().unwrap_or(80)) - 4; // padding
let max_bar_length =
display_width - max_path_length - " | ".len() - max_diffs.to_string().len() - 1;
let factor = if max_diffs < max_bar_length {
1.0
} else {
max_bar_length as f64 / max_diffs as f64
};
let number_padding = max_diffs.to_string().len();

formatter.with_label("diff", |formatter| {
let mut total_added = 0;
let mut total_removed = 0;
for stat in &stats {
total_added += stat.added;
total_removed += stat.removed;
let bar_added = (stat.added as f64 * factor).ceil() as usize;
let bar_removed = (stat.removed as f64 * factor).ceil() as usize;
// pad to max_path_length
write!(
formatter.labeled("stat-line"),
" {:<max_path_length$} | {:>number_padding$}{}",
stat.path,
stat.added + stat.removed,
if bar_added + bar_removed > 0 { " " } else { "" },
)?;
write!(formatter.labeled("added"), "{}", "+".repeat(bar_added))?;
writeln!(formatter.labeled("removed"), "{}", "-".repeat(bar_removed))?;
}
writeln!(
formatter.labeled("stat-summary"),
" {} file{} changed, {} insertion{}(+), {} deletion{}(-)",
stats.len(),
if stats.len() == 1 { "" } else { "s" },
total_added,
if total_added == 1 { "" } else { "s" },
total_removed,
if total_removed == 1 { "" } else { "s" },
)?;
Ok(())
})?;
Ok(())
}

pub fn show_types(
formatter: &mut dyn Formatter,
workspace_command: &WorkspaceCommandHelper,
Expand Down
73 changes: 73 additions & 0 deletions cli/tests/test_diff_command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@ use common::TestEnvironment;

pub mod common;

fn strip_lines_prefix(s: String, prefix: &str) -> String {
s.lines()
.map(|line| line.strip_prefix(prefix).unwrap())
.collect::<Vec<_>>()
.join("\n")
}

#[test]
fn test_diff_basic() {
let test_env = TestEnvironment::default();
Expand Down Expand Up @@ -106,6 +113,15 @@ fn test_diff_basic() {
@@ -1,0 +1,1 @@
+foo
"###);

let stdout = test_env.jj_cmd_success(&repo_path, &["diff", "--stat"]);
let stdout = strip_lines_prefix(stdout, " ");
insta::assert_snapshot!(stdout, @r###"
file1 | 1 -
file2 | 1 +
file3 | 1 +
3 files changed, 2 insertions(+), 1 deletion(-)
"###);
}

#[test]
Expand All @@ -128,6 +144,13 @@ fn test_diff_empty() {
Removed regular file file1:
(empty)
"###);

let stdout = test_env.jj_cmd_success(&repo_path, &["diff", "--stat"]);
let stdout = strip_lines_prefix(stdout, " ");
insta::assert_snapshot!(stdout, @r###"
file1 | 0
1 file changed, 0 insertions(+), 0 deletions(-)
"###);
}

#[test]
Expand Down Expand Up @@ -337,6 +360,16 @@ fn test_diff_relative_paths() {
-foo1
+bar1
"###);

let stdout = test_env.jj_cmd_success(&repo_path.join("dir1"), &["diff", "--stat"]);
let stdout = strip_lines_prefix(stdout, " ");
insta::assert_snapshot!(stdout, @r###"
file2 | 2 +-
subdir1/file3 | 2 +-
../dir2/file4 | 2 +-
../file1 | 2 +-
4 files changed, 4 insertions(+), 4 deletions(-)
"###);
}

#[test]
Expand Down Expand Up @@ -384,6 +417,14 @@ fn test_diff_missing_newline() {
+foo
\ No newline at end of file
"###);

let stdout = test_env.jj_cmd_success(&repo_path, &["diff", "--stat"]);
let stdout = strip_lines_prefix(stdout, " ");
insta::assert_snapshot!(stdout, @r###"
file1 | 3 ++-
file2 | 3 +--
2 files changed, 3 insertions(+), 3 deletions(-)
"###);
}

#[test]
Expand Down Expand Up @@ -715,3 +756,35 @@ fn test_diff_external_tool() {
Tool exited with a non-zero code (run with --verbose to see the exact invocation). Exit code: 1.
"###);
}

#[test]
fn test_diff_stat() {
let test_env = TestEnvironment::default();
test_env.jj_cmd_success(test_env.env_root(), &["init", "repo", "--git"]);
let repo_path = test_env.env_root().join("repo");
std::fs::write(repo_path.join("file1"), "foo\n").unwrap();

let stdout = test_env.jj_cmd_success(&repo_path, &["diff", "--stat"]);
let stdout = strip_lines_prefix(stdout, " ");
insta::assert_snapshot!(stdout, @r###"
file1 | 1 +
1 file changed, 1 insertion(+), 0 deletions(-)
"###);

test_env.jj_cmd_success(&repo_path, &["new"]);

let stdout = test_env.jj_cmd_success(&repo_path, &["diff", "--stat"]);
let stdout = strip_lines_prefix(stdout, " ");
insta::assert_snapshot!(stdout, @"0 files changed, 0 insertions(+), 0 deletions(-)");

std::fs::write(repo_path.join("file1"), "foo\nbar\n").unwrap();
test_env.jj_cmd_success(&repo_path, &["new"]);
std::fs::write(repo_path.join("file1"), "bar\n").unwrap();

let stdout = test_env.jj_cmd_success(&repo_path, &["diff", "--stat"]);
let stdout = strip_lines_prefix(stdout, " ");
insta::assert_snapshot!(stdout, @r###"
file1 | 1 -
1 file changed, 0 insertions(+), 1 deletion(-)
"###);
}

0 comments on commit 44e28dd

Please sign in to comment.