diff --git a/src/subcommand/diff_subcommand.cpp b/src/subcommand/diff_subcommand.cpp index 1ed8fb1..818825f 100644 --- a/src/subcommand/diff_subcommand.cpp +++ b/src/subcommand/diff_subcommand.cpp @@ -12,7 +12,8 @@ diff_subcommand::diff_subcommand(const libgit2_object&, CLI::App& app) { auto* sub = app.add_subcommand("diff", "Show changes between commits, commit and working tree, etc"); - sub->add_option("", m_files, "tree-ish objects to compare"); + sub->add_option("", m_files, "tree-ish objects to compare") + ->expected(0, 2); sub->add_flag("--stat", m_stat_flag, "Generate a diffstat"); sub->add_flag("--shortstat", m_shortstat_flag, "Output only the last line of --stat"); @@ -33,15 +34,16 @@ diff_subcommand::diff_subcommand(const libgit2_object&, CLI::App& app) sub->add_flag("--patience", m_patience_flag, "Generate diff using patience algorithm"); sub->add_flag("--minimal", m_minimal_flag, "Spend extra time to find smallest diff"); - // TODO: add the following flags after the "move" subcommand has been implemented (needed for the tests) - // sub->add_option("-M,--find-renames", m_rename_threshold, "Detect renames") - // ->expected(0,1) - // ->each([this](const std::string&) { m_find_renames_flag = true; }); - // sub->add_option("-C,--find-copies", m_copy_threshold, "Detect copies") - // ->expected(0,1) - // ->each([this](const std::string&) { m_find_copies_flag = true; }); - // sub->add_flag("--find-copies-harder", m_find_copies_harder_flag, "Detect copies from unmodified files"); - // sub->add_flag("-B,--break-rewrites", m_break_rewrites_flag, "Detect file rewrites"); + sub->add_option("-M,--find-renames", m_rename_threshold, "Detect renames") + ->expected(0,1) + ->default_val(50) + ->each([this](const std::string&) { m_find_renames_flag = true; }); + sub->add_option("-C,--find-copies", m_copy_threshold, "Detect copies") + ->expected(0,1) + ->default_val(50) + ->each([this](const std::string&) { m_find_copies_flag = true; }); + sub->add_flag("--find-copies-harder", m_find_copies_harder_flag, "Detect copies from unmodified files"); + sub->add_flag("-B,--break-rewrites", m_break_rewrites_flag, "Detect file rewrites"); sub->add_option("-U,--unified", m_context_lines, "Lines of context"); sub->add_option("--inter-hunk-context", m_interhunk_lines, "Context between hunks"); @@ -142,7 +144,6 @@ static int colour_printer([[maybe_unused]] const git_diff_delta* delta, [[maybe_ bool use_colour = *reinterpret_cast(payload); // Only print origin for context/addition/deletion lines - // For other line types, content already includes everything bool print_origin = (line->origin == GIT_DIFF_LINE_CONTEXT || line->origin == GIT_DIFF_LINE_ADDITION || line->origin == GIT_DIFF_LINE_DELETION); @@ -172,6 +173,39 @@ static int colour_printer([[maybe_unused]] const git_diff_delta* delta, [[maybe_ std::cout << termcolor::reset; } + // Print copy/rename headers ONLY after the "diff --git" line + if (line->origin == GIT_DIFF_LINE_FILE_HDR) + { + if (delta->status == GIT_DELTA_COPIED) + { + if (use_colour) + { + std::cout << termcolor::bold; + } + std::cout << "similarity index " << delta->similarity << "%\n"; + std::cout << "copy from " << delta->old_file.path << "\n"; + std::cout << "copy to " << delta->new_file.path << "\n"; + if (use_colour) + { + std::cout << termcolor::reset; + } + } + else if (delta->status == GIT_DELTA_RENAMED) + { + if (use_colour) + { + std::cout << termcolor::bold; + } + std::cout << "similarity index " << delta->similarity << "%\n"; + std::cout << "rename from " << delta->old_file.path << "\n"; + std::cout << "rename to " << delta->new_file.path << "\n"; + if (use_colour) + { + std::cout << termcolor::reset; + } + } + } + return 0; } @@ -183,33 +217,30 @@ void diff_subcommand::print_diff(diff_wrapper& diff, bool use_colour) return; } - // TODO: add the following flags after the "move" subcommand has been implemented (needed for the tests) - // if (m_find_renames_flag || m_find_copies_flag || m_find_copies_harder_flag || m_break_rewrites_flag) - // { - // git_diff_find_options find_opts; - // git_diff_find_options_init(&find_opts, GIT_DIFF_FIND_OPTIONS_VERSION); - - // if (m_find_renames_flag) - // { - // find_opts.flags |= GIT_DIFF_FIND_RENAMES; - // find_opts.rename_threshold = m_rename_threshold; - // } - // if (m_find_copies_flag) - // { - // find_opts.flags |= GIT_DIFF_FIND_COPIES; - // find_opts.copy_threshold = m_copy_threshold; - // } - // if (m_find_copies_harder_flag) - // { - // find_opts.flags |= GIT_DIFF_FIND_COPIES_FROM_UNMODIFIED; - // } - // if (m_break_rewrites_flag) - // { - // find_opts.flags |= GIT_DIFF_FIND_REWRITES; - // } - - // diff.find_similar(&find_opts); - // } + if (m_find_renames_flag || m_find_copies_flag || m_find_copies_harder_flag || m_break_rewrites_flag) + { + git_diff_find_options find_opts = GIT_DIFF_FIND_OPTIONS_INIT; + + if (m_find_renames_flag || m_find_copies_flag) + { + find_opts.flags |= GIT_DIFF_FIND_RENAMES; + find_opts.rename_threshold = m_rename_threshold; + } + if (m_find_copies_flag) + { + find_opts.flags |= GIT_DIFF_FIND_COPIES; + find_opts.copy_threshold = m_copy_threshold; + } + if (m_find_copies_harder_flag) + { + find_opts.flags |= GIT_DIFF_FIND_COPIES_FROM_UNMODIFIED; + } + if (m_break_rewrites_flag) + { + find_opts.flags |= GIT_DIFF_FIND_REWRITES; + } + diff.find_similar(&find_opts); + } git_diff_format_t format = GIT_DIFF_FORMAT_PATCH; if (m_name_only_flag) @@ -228,7 +259,7 @@ void diff_subcommand::print_diff(diff_wrapper& diff, bool use_colour) diff.print(format, colour_printer, &use_colour); } -diff_wrapper compute_diff_no_index(std::vector files, git_diff_options& diffopts) //std::pair +diff_wrapper compute_diff_no_index(std::vector files, git_diff_options& diffopts) { if (files.size() != 2) { @@ -242,11 +273,11 @@ diff_wrapper compute_diff_no_index(std::vector files, git_diff_opti if (file1_str.empty()) { - throw git_exception("Cannot read file: " + files[0], git2cpp_error_code::GENERIC_ERROR); //TODO: check error code with git + throw git_exception("Cannot read file: " + files[0], git2cpp_error_code::GENERIC_ERROR); } if (file2_str.empty()) { - throw git_exception("Cannot read file: " + files[1], git2cpp_error_code::GENERIC_ERROR); //TODO: check error code with git + throw git_exception("Cannot read file: " + files[1], git2cpp_error_code::GENERIC_ERROR); } auto patch = patch_wrapper::patch_from_files(files[0], file1_str, files[1], file2_str, &diffopts); @@ -280,6 +311,11 @@ void diff_subcommand::run() use_colour = true; } + if (m_cached_flag && m_no_index_flag) + { + throw git_exception("--cached and --no-index are incompatible", git2cpp_error_code::BAD_ARGUMENT); + } + if (m_no_index_flag) { auto diff = compute_diff_no_index(m_files, diffopts); @@ -302,11 +338,14 @@ void diff_subcommand::run() if (m_untracked_flag) { diffopts.flags |= GIT_DIFF_INCLUDE_UNTRACKED; } if (m_patience_flag) { diffopts.flags |= GIT_DIFF_PATIENCE; } if (m_minimal_flag) { diffopts.flags |= GIT_DIFF_MINIMAL; } + if (m_find_copies_flag || m_find_copies_harder_flag || m_find_renames_flag) + { + diffopts.flags |= GIT_DIFF_INCLUDE_UNMODIFIED; + } std::optional tree1; std::optional tree2; - // TODO: throw error if m_files.size() > 2 if (m_files.size() >= 1) { tree1 = repo.treeish_to_tree(m_files[0]); @@ -324,7 +363,7 @@ void diff_subcommand::run() } else if (m_cached_flag) { - if (m_cached_flag || !tree1) + if (!tree1) { tree1 = repo.treeish_to_tree("HEAD"); } diff --git a/src/subcommand/diff_subcommand.hpp b/src/subcommand/diff_subcommand.hpp index 5c2c23f..62e0947 100644 --- a/src/subcommand/diff_subcommand.hpp +++ b/src/subcommand/diff_subcommand.hpp @@ -38,16 +38,16 @@ class diff_subcommand bool m_patience_flag = false; bool m_minimal_flag = false; - // int m_rename_threshold = 50; - // bool m_find_renames_flag = false; - // int m_copy_threshold = 50; - // bool m_find_copies_flag = false; - // bool m_find_copies_harder_flag = false; - // bool m_break_rewrites_flag = false; - - int m_context_lines = 3; - int m_interhunk_lines = 0; - int m_abbrev = 7; + uint16_t m_rename_threshold = 50; + bool m_find_renames_flag = false; + uint16_t m_copy_threshold = 50; + bool m_find_copies_flag = false; + bool m_find_copies_harder_flag = false; + bool m_break_rewrites_flag = false; + + uint m_context_lines = 3; + uint m_interhunk_lines = 0; + uint m_abbrev = 7; bool m_colour_flag = true; bool m_no_colour_flag = false; diff --git a/test/test_diff.py b/test/test_diff.py index ee1fdb5..c0f907f 100644 --- a/test/test_diff.py +++ b/test/test_diff.py @@ -1,5 +1,6 @@ import re import subprocess +from sys import stderr import pytest @@ -547,120 +548,158 @@ def test_diff_minimal(repo_init_with_commit, git2cpp_path, tmp_path): # assert bool(re.search(ansi_escape, p.stdout)) -# TODO: add the following flags after the "move" subcommand has been implemented (needed for the tests) -# @pytest.mark.parametrize("renames_flag", ["-M", "--find-renames"]) -# def test_diff_find_renames(xtl_clone, git2cpp_path, tmp_path, renames_flag): -# """Test diff with -M/--find-renames""" -# xtl_path = tmp_path / "xtl" +@pytest.mark.parametrize("renames_flag", ["-M", "--find-renames"]) +def test_diff_find_renames(repo_init_with_commit, git2cpp_path, tmp_path, renames_flag): + """Test diff with -M/--find-renames""" + assert (tmp_path / "initial.txt").exists() -# old_file = xtl_path / "old_name.txt" -# old_file.write_text("Hello\n") + old_file = tmp_path / "old.txt" + old_file.write_text("Hello\n") -# cmd_add = [git2cpp_path, "add", "old_name.txt"] -# subprocess.run(cmd_add, cwd=xtl_path, check=True) + cmd_add = [git2cpp_path, "add", "old.txt"] + subprocess.run(cmd_add, cwd=tmp_path, check=True) -# cmd_commit = [git2cpp_path, "commit", "-m", "Add file"] -# subprocess.run(cmd_commit, cwd=xtl_path, check=True) + cmd_commit = [git2cpp_path, "commit", "-m", "Add file"] + subprocess.run(cmd_commit, cwd=tmp_path, check=True) -# new_file = xtl_path / "new_name.txt" -# old_file.rename(new_file) -# old_file.write_text("Goodbye\n") + cmd_mv = [git2cpp_path, "mv", "old.txt", "new.txt"] + subprocess.run(cmd_mv, cwd=tmp_path, check=True) -# cmd_add_all = [git2cpp_path, "add", "-A"] -# subprocess.run(cmd_add_all, cwd=xtl_path, check=True) + cmd = [git2cpp_path, "diff", "--cached", renames_flag] + p = subprocess.run(cmd, capture_output=True, cwd=tmp_path, text=True) + assert p.returncode == 0 + assert "similarity index" in p.stdout + assert "rename from old.txt" in p.stdout + assert "rename to new.txt" in p.stdout -# cmd = [git2cpp_path, "diff", "--cached", renames_flag] -# p = subprocess.run(cmd, capture_output=True, cwd=xtl_path, text=True) -# assert p.returncode == 0 -# # assert "similarity index" in p.stdout -# # assert "rename from" in p.stdout -# assert "+++ b/new_name.txt" in p.stdout -# assert "--- a/old_name.txt" in p.stdout -# print("===\n", p.stdout, "===\n") +def test_diff_find_renames_with_threshold( + repo_init_with_commit, git2cpp_path, tmp_path +): + """Test diff with -M with threshold value""" + assert (tmp_path / "initial.txt").exists() -# def test_diff_find_renames_with_threshold(xtl_clone, git2cpp_path, tmp_path): -# """Test diff with -M with threshold value""" -# xtl_path = tmp_path / "xtl" + old_file = tmp_path / "old.txt" + old_file.write_text("Content\n") -# old_file = xtl_path / "old.txt" -# old_file.write_text("Content\n") + cmd_add = [git2cpp_path, "add", "old.txt"] + subprocess.run(cmd_add, cwd=tmp_path, check=True) -# cmd_add = [git2cpp_path, "add", "old.txt"] -# subprocess.run(cmd_add, cwd=xtl_path, check=True) + cmd_commit = [git2cpp_path, "commit", "-m", "Add file"] + subprocess.run(cmd_commit, cwd=tmp_path, check=True) -# cmd_commit = [git2cpp_path, "commit", "-m", "Add file"] -# subprocess.run(cmd_commit, cwd=xtl_path, check=True) + new_file = tmp_path / "new.txt" + old_file.rename(new_file) -# new_file = xtl_path / "new.txt" -# old_file.rename(new_file) + cmd_add_all = [git2cpp_path, "add", "-A"] + subprocess.run(cmd_add_all, cwd=tmp_path, check=True) -# cmd_add_all = [git2cpp_path, "add", "-A"] -# subprocess.run(cmd_add_all, cwd=xtl_path, check=True) + cmd = [git2cpp_path, "diff", "--cached", "-M60"] + p = subprocess.run(cmd, capture_output=True, cwd=tmp_path, text=True) + assert p.returncode == 0 + assert "similarity index" in p.stdout + assert "rename from old.txt" in p.stdout + assert "rename to new.txt" in p.stdout -# cmd = [git2cpp_path, "diff", "--cached", "-M50"] -# p = subprocess.run(cmd, capture_output=True, cwd=xtl_path, text=True) -# assert p.returncode == 0 -# print(p.stdout) # Doesn't do the same as the previous one. Why ??? +@pytest.mark.parametrize("copies_flag", ["-C", "--find-copies"]) +def test_diff_find_copies_from_modified( + repo_init_with_commit, commit_env_config, git2cpp_path, tmp_path, copies_flag +): + """Test diff with -C/--find-copies when source file is also modified""" + assert (tmp_path / "initial.txt").exists() -# @pytest.mark.parametrize("copies_flag", ["-C", "--find-copies"]) -# def test_diff_find_copies(xtl_clone, git2cpp_path, tmp_path, copies_flag): -# """Test diff with -C/--find-copies""" -# xtl_path = tmp_path / "xtl" + original_file = tmp_path / "original.txt" + original_file.write_text("Content to be copied\n") -# original_file = xtl_path / "original.txt" -# original_file.write_text("Content to be copied\n") + cmd_add = [git2cpp_path, "add", "original.txt"] + subprocess.run(cmd_add, cwd=tmp_path, check=True) -# cmd_add = [git2cpp_path, "add", "original.txt"] -# subprocess.run(cmd_add, cwd=xtl_path, check=True) + cmd_commit = [git2cpp_path, "commit", "-m", "add original file"] + subprocess.run(cmd_commit, cwd=tmp_path, check=True) -# copied_file = xtl_path / "copied.txt" -# copied_file.write_text("Content to be copied\n") + # Modify original.txt (this makes it a candidate for copy detection) + original_file.write_text("Content to be copied\nExtra line\n") + subprocess.run([git2cpp_path, "add", "original.txt"], cwd=tmp_path, check=True) -# cmd_add_copy = [git2cpp_path, "add", "copied.txt"] -# subprocess.run(cmd_add_copy, cwd=xtl_path, check=True) + # Create copy with original content + copied_file = tmp_path / "copied.txt" + copied_file.write_text("Content to be copied\n") + subprocess.run([git2cpp_path, "add", "copied.txt"], cwd=tmp_path, check=True) -# cmd = [git2cpp_path, "diff", "--cached", copies_flag] -# p = subprocess.run(cmd, capture_output=True, cwd=xtl_path, text=True) -# assert p.returncode == 0 -# print(p.stdout) + cmd = [git2cpp_path, "diff", "--cached", copies_flag] + p = subprocess.run(cmd, capture_output=True, cwd=tmp_path, text=True) + assert p.returncode == 0 + assert "similarity index" in p.stdout + assert "copy from original.txt" in p.stdout + assert "copy to copied.txt" in p.stdout -# def test_diff_find_copies_with_threshold(xtl_clone, git2cpp_path, tmp_path): -# """Test diff with -C with threshold value""" -# xtl_path = tmp_path / "xtl" +@pytest.mark.parametrize("copies_flag", ["-C", "--find-copies"]) +def test_diff_find_copies_harder( + repo_init_with_commit, commit_env_config, git2cpp_path, tmp_path, copies_flag +): + """Test diff with -C/--find-copies and --find-copies-harder for unmodified sources""" + assert (tmp_path / "initial.txt").exists() -# original_file = xtl_path / "original.txt" -# original_file.write_text("Content\n") + original_file = tmp_path / "original.txt" + original_file.write_text("Content to be copied\n") -# cmd_add = [git2cpp_path, "add", "original.txt"] -# subprocess.run(cmd_add, cwd=xtl_path, check=True) + subprocess.run([git2cpp_path, "add", "original.txt"], cwd=tmp_path, check=True) + subprocess.run( + [git2cpp_path, "commit", "-m", "add original file"], + cwd=tmp_path, + check=True, + env=commit_env_config, + ) -# copied_file = xtl_path / "copied.txt" -# copied_file.write_text("Content to be copied\n") + # Create identical copy + copied_file = tmp_path / "copied.txt" + copied_file.write_text("Content to be copied\n") + subprocess.run([git2cpp_path, "add", "copied.txt"], cwd=tmp_path, check=True) -# cmd_add_copy = [git2cpp_path, "add", "copied.txt"] -# subprocess.run(cmd_add_copy, cwd=xtl_path, check=True) + # Use --find-copies-harder to detect copies from unmodified files + cmd = [git2cpp_path, "diff", "--cached", copies_flag, "--find-copies-harder"] + p = subprocess.run(cmd, capture_output=True, cwd=tmp_path, text=True) -# cmd = [git2cpp_path, "diff", "--cached", "-C50"] -# p = subprocess.run(cmd, capture_output=True, cwd=xtl_path, text=True) -# assert p.returncode == 0 + assert p.returncode == 0 + assert "similarity index 100%" in p.stdout + assert "copy from original.txt" in p.stdout + assert "copy to copied.txt" in p.stdout -# def test_diff_find_copies_harder(xtl_clone, git2cpp_path, tmp_path): -# """Test diff with --find-copies-harder""" -# xtl_path = tmp_path / "xtl" +@pytest.mark.parametrize("copies_flag", ["-C50", "--find-copies=50"]) +def test_diff_find_copies_with_threshold( + repo_init_with_commit, commit_env_config, git2cpp_path, tmp_path, copies_flag +): + """Test diff with -C with custom threshold""" + assert (tmp_path / "initial.txt").exists() -# test_file = xtl_path / "test.txt" -# test_file.write_text("Content\n") + original_file = tmp_path / "original.txt" + original_content = "Line 1\nLine 2\nLine 3\nLine 4\nLine 5\n" + original_file.write_text(original_content) -# cmd_add = [git2cpp_path, "add", "test.txt"] -# subprocess.run(cmd_add, cwd=xtl_path, check=True) + subprocess.run([git2cpp_path, "add", "original.txt"], cwd=tmp_path, check=True) + subprocess.run( + [git2cpp_path, "commit", "-m", "add original file"], + cwd=tmp_path, + check=True, + env=commit_env_config, + ) -# cmd = [git2cpp_path, "diff", "--cached", "--find-copies-harder"] -# p = subprocess.run(cmd, capture_output=True, cwd=xtl_path, text=True) -# assert p.returncode == 0 + # Create a partial copy (60% similar) + copied_file = tmp_path / "copied.txt" + copied_file.write_text("Line 1\nLine 2\nLine 3\nNew line\nAnother line\n") + subprocess.run([git2cpp_path, "add", "copied.txt"], cwd=tmp_path, check=True) + + # With threshold of 50%, should detect copy + cmd = [git2cpp_path, "diff", "--cached", copies_flag, "--find-copies-harder"] + p = subprocess.run(cmd, capture_output=True, cwd=tmp_path, text=True) + + assert p.returncode == 0 + assert "similarity index" in p.stdout + assert "copy from original.txt" in p.stdout + assert "copy to copied.txt" in p.stdout # @pytest.mark.parametrize("break_rewrites_flag", ["-B", "--break-rewrites"]) @@ -683,4 +722,35 @@ def test_diff_minimal(repo_init_with_commit, git2cpp_path, tmp_path): # cmd = [git2cpp_path, "diff", break_rewrites_flag] # p = subprocess.run(cmd, capture_output=True, cwd=xtl_path, text=True) # assert p.returncode == 0 -# print(p.stdout) + + +def test_diff_refuses_more_than_two_treeish( + repo_init_with_commit, git2cpp_path, tmp_path +): + # HEAD exists thanks to repo_init_with_commit + p = subprocess.run( + [git2cpp_path, "diff", "HEAD", "HEAD", "HEAD"], + capture_output=True, + text=True, + cwd=tmp_path, + ) + assert p.returncode != 0 + assert "2 required but received 3" in p.stderr + + +def test_diff_cached_and_no_index_are_incompatible(git2cpp_path, tmp_path): + # Create two files to satisfy the --no-index path arguments + a = tmp_path / "a.txt" + b = tmp_path / "b.txt" + a.write_text("hello\n") + b.write_text("world\n") + + p = subprocess.run( + [git2cpp_path, "diff", "--cached", "--no-index", str(a), str(b)], + capture_output=True, + text=True, + cwd=tmp_path, + ) + + assert p.returncode != 0 + assert "--cached and --no-index are incompatible" in (p.stderr + p.stdout)