From 666b5bd9a107c66351563e7fb2bbc489ee099217 Mon Sep 17 00:00:00 2001 From: Sandrine Pataut Date: Wed, 21 Jan 2026 14:55:05 +0100 Subject: [PATCH 1/4] Add diff subcommand --- CMakeLists.txt | 10 + src/main.cpp | 2 + src/subcommand/checkout_subcommand.cpp | 2 +- src/subcommand/diff_subcommand.cpp | 318 ++++++++++++ src/subcommand/diff_subcommand.hpp | 55 ++ src/utils/common.cpp | 14 + src/utils/common.hpp | 2 + src/wrapper/diff_wrapper.cpp | 37 ++ src/wrapper/diff_wrapper.hpp | 31 ++ src/wrapper/diffstats_wrapper.cpp | 20 + src/wrapper/diffstats_wrapper.hpp | 25 + src/wrapper/patch_wrapper.cpp | 27 + src/wrapper/patch_wrapper.hpp | 25 + src/wrapper/repository_wrapper.cpp | 71 ++- src/wrapper/repository_wrapper.hpp | 12 + src/wrapper/tree_wrapper.cpp | 12 + src/wrapper/tree_wrapper.hpp | 23 + test/test_diff.py | 676 +++++++++++++++++++++++++ 18 files changed, 1357 insertions(+), 5 deletions(-) create mode 100644 src/subcommand/diff_subcommand.cpp create mode 100644 src/subcommand/diff_subcommand.hpp create mode 100644 src/wrapper/diff_wrapper.cpp create mode 100644 src/wrapper/diff_wrapper.hpp create mode 100644 src/wrapper/diffstats_wrapper.cpp create mode 100644 src/wrapper/diffstats_wrapper.hpp create mode 100644 src/wrapper/patch_wrapper.cpp create mode 100644 src/wrapper/patch_wrapper.hpp create mode 100644 src/wrapper/tree_wrapper.cpp create mode 100644 src/wrapper/tree_wrapper.hpp create mode 100644 test/test_diff.py diff --git a/CMakeLists.txt b/CMakeLists.txt index 3212e5a..298ff8f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -48,6 +48,8 @@ set(GIT2CPP_SRC ${GIT2CPP_SOURCE_DIR}/subcommand/checkout_subcommand.hpp ${GIT2CPP_SOURCE_DIR}/subcommand/clone_subcommand.cpp ${GIT2CPP_SOURCE_DIR}/subcommand/clone_subcommand.hpp + ${GIT2CPP_SOURCE_DIR}/subcommand/diff_subcommand.cpp + ${GIT2CPP_SOURCE_DIR}/subcommand/diff_subcommand.hpp ${GIT2CPP_SOURCE_DIR}/subcommand/commit_subcommand.cpp ${GIT2CPP_SOURCE_DIR}/subcommand/commit_subcommand.hpp ${GIT2CPP_SOURCE_DIR}/subcommand/config_subcommand.cpp @@ -96,10 +98,16 @@ set(GIT2CPP_SRC ${GIT2CPP_SOURCE_DIR}/wrapper/commit_wrapper.hpp ${GIT2CPP_SOURCE_DIR}/wrapper/config_wrapper.cpp ${GIT2CPP_SOURCE_DIR}/wrapper/config_wrapper.hpp + ${GIT2CPP_SOURCE_DIR}/wrapper/diff_wrapper.cpp + ${GIT2CPP_SOURCE_DIR}/wrapper/diff_wrapper.hpp + ${GIT2CPP_SOURCE_DIR}/wrapper/diffstats_wrapper.cpp + ${GIT2CPP_SOURCE_DIR}/wrapper/diffstats_wrapper.hpp ${GIT2CPP_SOURCE_DIR}/wrapper/index_wrapper.cpp ${GIT2CPP_SOURCE_DIR}/wrapper/index_wrapper.hpp ${GIT2CPP_SOURCE_DIR}/wrapper/object_wrapper.cpp ${GIT2CPP_SOURCE_DIR}/wrapper/object_wrapper.hpp + ${GIT2CPP_SOURCE_DIR}/wrapper/patch_wrapper.cpp + ${GIT2CPP_SOURCE_DIR}/wrapper/patch_wrapper.hpp ${GIT2CPP_SOURCE_DIR}/wrapper/rebase_wrapper.cpp ${GIT2CPP_SOURCE_DIR}/wrapper/rebase_wrapper.hpp ${GIT2CPP_SOURCE_DIR}/wrapper/refs_wrapper.cpp @@ -114,6 +122,8 @@ set(GIT2CPP_SRC ${GIT2CPP_SOURCE_DIR}/wrapper/signature_wrapper.hpp ${GIT2CPP_SOURCE_DIR}/wrapper/status_wrapper.cpp ${GIT2CPP_SOURCE_DIR}/wrapper/status_wrapper.hpp + ${GIT2CPP_SOURCE_DIR}/wrapper/tree_wrapper.cpp + ${GIT2CPP_SOURCE_DIR}/wrapper/tree_wrapper.hpp ${GIT2CPP_SOURCE_DIR}/wrapper/wrapper_base.hpp ${GIT2CPP_SOURCE_DIR}/main.cpp ${GIT2CPP_SOURCE_DIR}/version.hpp diff --git a/src/main.cpp b/src/main.cpp index 6aaec34..f16f065 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -11,6 +11,7 @@ #include "subcommand/clone_subcommand.hpp" #include "subcommand/commit_subcommand.hpp" #include "subcommand/config_subcommand.hpp" +#include "subcommand/diff_subcommand.hpp" #include "subcommand/fetch_subcommand.hpp" #include "subcommand/init_subcommand.hpp" #include "subcommand/log_subcommand.hpp" @@ -44,6 +45,7 @@ int main(int argc, char** argv) clone_subcommand clone(lg2_obj, app); commit_subcommand commit(lg2_obj, app); config_subcommand config(lg2_obj, app); + diff_subcommand diff(lg2_obj, app); fetch_subcommand fetch(lg2_obj, app); reset_subcommand reset(lg2_obj, app); log_subcommand log(lg2_obj, app); diff --git a/src/subcommand/checkout_subcommand.cpp b/src/subcommand/checkout_subcommand.cpp index 8ae8ebe..a3e0844 100644 --- a/src/subcommand/checkout_subcommand.cpp +++ b/src/subcommand/checkout_subcommand.cpp @@ -24,7 +24,7 @@ void checkout_subcommand::run() if (repo.state() != GIT_REPOSITORY_STATE_NONE) { - throw std::runtime_error("Cannot checkout, repository is in unexpected state"); + std::runtime_error("Cannot checkout, repository is in unexpected state"); } git_checkout_options options; diff --git a/src/subcommand/diff_subcommand.cpp b/src/subcommand/diff_subcommand.cpp new file mode 100644 index 0000000..8d71998 --- /dev/null +++ b/src/subcommand/diff_subcommand.cpp @@ -0,0 +1,318 @@ +#include +#include +#include +#include +#include + +#include "../utils/common.hpp" +#include "../subcommand/diff_subcommand.hpp" +#include "../wrapper/patch_wrapper.hpp" +#include "../wrapper/repository_wrapper.hpp" + +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_flag("--stat", m_stat_flag, "Generate a diffstat"); + sub->add_flag("--shortstat", m_shortstat_flag, "Output only the last line of --stat"); + sub->add_flag("--numstat", m_numstat_flag, "Machine-friendly --stat"); + sub->add_flag("--summary", m_summary_flag, "Output a condensed summary"); + sub->add_flag("--name-only", m_name_only_flag, "Show only names of changed files"); + sub->add_flag("--name-status", m_name_status_flag, "Show names and status of changed files"); + sub->add_flag("--raw", m_raw_flag, "Generate the diff in raw format"); + + sub->add_flag("--cached,--staged", m_cached_flag, "Compare staged changes to HEAD"); + sub->add_flag("--no-index", m_no_index_flag, "Compare two files on filesystem"); + + sub->add_flag("-R", m_reverse_flag, "Swap two inputs"); + sub->add_flag("-a,--text", m_text_flag, "Treat all files as text"); + sub->add_flag("--ignore-space-at-eol", m_ignore_space_at_eol_flag, "Ignore changes in whitespace at EOL"); + sub->add_flag("-b,--ignore-space-change", m_ignore_space_change_flag, "Ignore changes in amount of whitespace"); + sub->add_flag("-w,--ignore-all-space", m_ignore_all_space_flag, "Ignore whitespace when comparing lines"); + 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("-U,--unified", m_context_lines, "Lines of context"); + sub->add_option("--inter-hunk-context", m_interhunk_lines, "Context between hunks"); + sub->add_option("--abbrev", m_abbrev, "Abbreviation length for object names") + ->expected(0,1); + + sub->add_flag("--color", m_colour_flag, "Show colored diff"); + sub->add_flag("--no-color", m_no_colour_flag, "Turn off colored diff"); + + sub->callback([this]() { this->run(); }); +} + +void diff_subcommand::print_stats(const diff_wrapper& diff, bool use_colour) +{ + git_diff_stats_format_t format; + if (m_stat_flag) + { + format = GIT_DIFF_STATS_FULL; + } + if (m_shortstat_flag) + { + format = GIT_DIFF_STATS_SHORT; + } + if (m_numstat_flag) + { + format = GIT_DIFF_STATS_NUMBER; + } + if (m_summary_flag) + { + format = GIT_DIFF_STATS_INCLUDE_SUMMARY; + } + + auto stats = diff.get_stats(); + auto buf = stats.to_buf(format, 80); + + if (use_colour && m_stat_flag) + { + // Add colors to + and - characters + std::string output(buf.ptr); + bool in_parentheses = false; + for (char c : output) + { + if (c == '(') + { + in_parentheses = true; + std::cout << c; + } + else if (c == ')') + { + in_parentheses = false; + std::cout << c; + } + else if (c == '+' && !in_parentheses) + { + std::cout << termcolor::green << '+' << termcolor::reset; + } + else if (c == '-' && !in_parentheses) + { + std::cout << termcolor::red << '-' << termcolor::reset; + } + else + { + std::cout << c; + } + } + } + else + { + std::cout << buf.ptr; + } + + git_buf_dispose(&buf); +} + +static int colour_printer([[maybe_unused]] const git_diff_delta* delta, [[maybe_unused]] const git_diff_hunk* hunk, const git_diff_line* line, void* payload) +{ + 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); + + if (*use_colour) + { + switch (line->origin) { + case GIT_DIFF_LINE_ADDITION: std::cout << termcolor::green; break; + case GIT_DIFF_LINE_DELETION: std::cout << termcolor::red; break; + case GIT_DIFF_LINE_ADD_EOFNL: std::cout << termcolor::green; break; + case GIT_DIFF_LINE_DEL_EOFNL: std::cout << termcolor::red; break; + case GIT_DIFF_LINE_FILE_HDR: std::cout << termcolor::bold; break; + case GIT_DIFF_LINE_HUNK_HDR: std::cout << termcolor::cyan; break; + default: break; + } + } + + if (print_origin) + { + std::cout << line->origin; + } + + std::cout << std::string_view(line->content, line->content_len); + + if (use_colour) + { + std::cout << termcolor::reset; + } + + return 0; +} + +void diff_subcommand::print_diff(diff_wrapper& diff, bool use_colour) +{ + if (m_stat_flag || m_shortstat_flag || m_numstat_flag || m_summary_flag) + { + print_stats(diff, 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); + // } + + git_diff_format_t format = GIT_DIFF_FORMAT_PATCH; + if (m_name_only_flag) + { + format = GIT_DIFF_FORMAT_NAME_ONLY; + } + else if (m_name_status_flag) + { + format = GIT_DIFF_FORMAT_NAME_STATUS; + } + else if (m_raw_flag) + { + format = GIT_DIFF_FORMAT_RAW; + } + + diff.print(format, colour_printer, &use_colour); +} + +diff_wrapper compute_diff_no_index(std::vector files, git_diff_options& diffopts) //std::pair +{ + if (files.size() != 2) + { + throw git_exception("two files should be provided as arguments", -1); //TODO: check error + code + } + + git_diff_options_init(&diffopts, GIT_DIFF_OPTIONS_VERSION); + + std::string file1_str = read_file(files[0]); + std::string file2_str = read_file(files[1]); + + if (file1_str.empty()) + { + throw git_exception("file " + files[0] + " cannot be read", -1); //TODO: check error + code + } + if (file2_str.empty()) + { + throw git_exception("file " + files[1] + " cannot be read", -1); //TODO: check error + code + } + + auto patch = patch_wrapper::patch_from_files(files[0], file1_str, files[1], file2_str, &diffopts); + auto buf = patch.to_buf(); + auto diff = diff_wrapper::diff_from_buffer(buf); + + git_buf_dispose(&buf); + + return diff; +} + +void diff_subcommand::run() +{ + git_diff_options diffopts; + git_diff_options_init(&diffopts, GIT_DIFF_OPTIONS_VERSION); + + bool use_colour = false; + if (m_colour_flag) + { + use_colour = true; + } + if (m_no_colour_flag) + { + use_colour = false; + } + + if (m_no_index_flag) + { + auto diff = compute_diff_no_index(m_files, diffopts); + diff_subcommand::print_diff(diff, use_colour); + } + else + { + auto directory = get_current_git_path(); + auto repo = repository_wrapper::open(directory); + + diffopts.context_lines = m_context_lines; + diffopts.interhunk_lines = m_interhunk_lines; + diffopts.id_abbrev = m_abbrev; + + if (m_reverse_flag) { diffopts.flags |= GIT_DIFF_REVERSE; } + if (m_text_flag) { diffopts.flags |= GIT_DIFF_FORCE_TEXT; } + if (m_ignore_space_at_eol_flag) { diffopts.flags |= GIT_DIFF_IGNORE_WHITESPACE_EOL; } + if (m_ignore_space_change_flag) { diffopts.flags |= GIT_DIFF_IGNORE_WHITESPACE_CHANGE; } + if (m_ignore_all_space_flag) { diffopts.flags |= GIT_DIFF_IGNORE_WHITESPACE; } + 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; } + + 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]); + } + if (m_files.size() ==2) + { + tree2 = repo.treeish_to_tree(m_files[1]); + } + + auto diff = [&repo, &tree1, &tree2, &diffopts, this]() + { + if (tree1.has_value() && tree2.has_value()) + { + return repo.diff_tree_to_tree(std::move(tree1.value()), std::move(tree2.value()), &diffopts); + } + else if (m_cached_flag) + { + if (m_cached_flag || !tree1) + { + tree1 = repo.treeish_to_tree("HEAD"); + } + return repo.diff_tree_to_index(std::move(tree1.value()), std::nullopt, &diffopts); + } + else if (tree1) + { + return repo.diff_tree_to_workdir_with_index(std::move(tree1.value()), &diffopts); + } + else + { + return repo.diff_index_to_workdir(std::nullopt, &diffopts); + } + }(); + + diff_subcommand::print_diff(diff, use_colour); + } +} diff --git a/src/subcommand/diff_subcommand.hpp b/src/subcommand/diff_subcommand.hpp new file mode 100644 index 0000000..966e77c --- /dev/null +++ b/src/subcommand/diff_subcommand.hpp @@ -0,0 +1,55 @@ +#pragma once + +#include +#include + +#include "../utils/common.hpp" +#include "../wrapper/diff_wrapper.hpp" + +class diff_subcommand +{ +public: + + explicit diff_subcommand(const libgit2_object&, CLI::App& app); + void print_stats(const diff_wrapper& diff, bool use_colour); + void print_diff(diff_wrapper& diff, bool use_colour); + void run(); + +private: + + std::vector m_files; + + bool m_stat_flag = false; + bool m_shortstat_flag = false; + bool m_numstat_flag = false; + bool m_summary_flag = false; + bool m_name_only_flag = false; + bool m_name_status_flag = false; + bool m_raw_flag = false; + + bool m_cached_flag = false; + bool m_no_index_flag = false; + + bool m_reverse_flag = false; + bool m_text_flag = false; + bool m_ignore_space_at_eol_flag = false; + bool m_ignore_space_change_flag = false; + bool m_ignore_all_space_flag = false; + bool m_untracked_flag = false; + 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; + + bool m_colour_flag = true; + bool m_no_colour_flag = false; +}; diff --git a/src/utils/common.cpp b/src/utils/common.cpp index a9b84d4..6aa8fdc 100644 --- a/src/utils/common.cpp +++ b/src/utils/common.cpp @@ -1,11 +1,13 @@ #include #include +#include #include #include #include #include "common.hpp" +#include "git_exception.hpp" libgit2_object::libgit2_object() { @@ -101,3 +103,15 @@ void git_strarray_wrapper::init_str_array() m_array.strings[i] = const_cast(m_patterns[i].c_str()); } } + +std::string read_file(const std::string& path) +{ + std::ifstream file(path, std::ios::binary); + if (!file) + { + throw git_exception("Cannot read file: " + path, -1); + } + std::stringstream buffer; + buffer << file.rdbuf(); + return buffer.str(); +} diff --git a/src/utils/common.hpp b/src/utils/common.hpp index 6751b46..be9f360 100644 --- a/src/utils/common.hpp +++ b/src/utils/common.hpp @@ -64,3 +64,5 @@ class git_strarray_wrapper void reset_str_array(); void init_str_array(); }; + +std::string read_file(const std::string& path); diff --git a/src/wrapper/diff_wrapper.cpp b/src/wrapper/diff_wrapper.cpp new file mode 100644 index 0000000..37e4cd7 --- /dev/null +++ b/src/wrapper/diff_wrapper.cpp @@ -0,0 +1,37 @@ +#include "../utils/git_exception.hpp" +#include "../wrapper/diff_wrapper.hpp" + +diff_wrapper::diff_wrapper(git_diff* diff) + : base_type(diff) +{ +} + +diff_wrapper::~diff_wrapper() +{ + git_diff_free(p_resource); + p_resource = nullptr; +} + +void diff_wrapper::find_similar(git_diff_find_options* find_opts) +{ + throw_if_error(git_diff_find_similar(p_resource, find_opts)); +} + +void diff_wrapper::print(git_diff_format_t format, git_diff_line_cb print_cb, void* payload) +{ + throw_if_error(git_diff_print(p_resource, format, print_cb, payload)); +} + +diffstats_wrapper diff_wrapper::get_stats() const +{ + git_diff_stats* stats; + throw_if_error(git_diff_get_stats(&stats, *this)); + return diffstats_wrapper(stats); +} + +diff_wrapper diff_wrapper::diff_from_buffer(git_buf buf) +{ + git_diff* diff; + throw_if_error(git_diff_from_buffer(&diff, buf.ptr, buf.size)); + return diff_wrapper(diff); +} diff --git a/src/wrapper/diff_wrapper.hpp b/src/wrapper/diff_wrapper.hpp new file mode 100644 index 0000000..b82b497 --- /dev/null +++ b/src/wrapper/diff_wrapper.hpp @@ -0,0 +1,31 @@ +#pragma once + +#include + +#include "../wrapper/wrapper_base.hpp" +#include "../wrapper/diffstats_wrapper.hpp" + +class diff_wrapper : public wrapper_base +{ +public: + + using base_type = wrapper_base; + + ~diff_wrapper(); + + diff_wrapper(diff_wrapper&&) noexcept = default; + diff_wrapper& operator=(diff_wrapper&&) noexcept = default; + + void find_similar(git_diff_find_options* find_opts); + void print(git_diff_format_t format, git_diff_line_cb print_cb, void* payload); + diffstats_wrapper get_stats() const; + static diff_wrapper diff_from_buffer(git_buf buf); + + +private: + + diff_wrapper(git_diff* diff); + + friend class buf_wrapper; + friend class repository_wrapper; +}; diff --git a/src/wrapper/diffstats_wrapper.cpp b/src/wrapper/diffstats_wrapper.cpp new file mode 100644 index 0000000..6dea273 --- /dev/null +++ b/src/wrapper/diffstats_wrapper.cpp @@ -0,0 +1,20 @@ +#include "../utils/git_exception.hpp" +#include "../wrapper/diffstats_wrapper.hpp" + +diffstats_wrapper::diffstats_wrapper(git_diff_stats* stats) + : base_type(stats) +{ +} + +diffstats_wrapper::~diffstats_wrapper() +{ + git_diff_stats_free(p_resource); + p_resource = nullptr; +} + +git_buf diffstats_wrapper::to_buf(git_diff_stats_format_t format, size_t width) +{ + git_buf buf = GIT_BUF_INIT; + throw_if_error(git_diff_stats_to_buf(&buf, *this, format, 80)); + return buf; +} diff --git a/src/wrapper/diffstats_wrapper.hpp b/src/wrapper/diffstats_wrapper.hpp new file mode 100644 index 0000000..e4152e8 --- /dev/null +++ b/src/wrapper/diffstats_wrapper.hpp @@ -0,0 +1,25 @@ +#pragma once + +#include + +#include "../wrapper/wrapper_base.hpp" + +class diffstats_wrapper : public wrapper_base +{ +public: + + using base_type = wrapper_base; + + ~diffstats_wrapper(); + + diffstats_wrapper(diffstats_wrapper&&) noexcept = default; + diffstats_wrapper& operator=(diffstats_wrapper&&) noexcept = default; + + git_buf to_buf(git_diff_stats_format_t format, size_t width); + +private: + + diffstats_wrapper(git_diff_stats* stats); + + friend class diff_wrapper; +}; diff --git a/src/wrapper/patch_wrapper.cpp b/src/wrapper/patch_wrapper.cpp new file mode 100644 index 0000000..d75a450 --- /dev/null +++ b/src/wrapper/patch_wrapper.cpp @@ -0,0 +1,27 @@ +#include "../utils/git_exception.hpp" +#include "../wrapper/patch_wrapper.hpp" + +patch_wrapper::patch_wrapper(git_patch* patch) + : base_type(patch) +{ +} + +patch_wrapper::~patch_wrapper() +{ + git_patch_free(p_resource); + p_resource = nullptr; +} + +git_buf patch_wrapper::to_buf() +{ + git_buf buf = GIT_BUF_INIT; + throw_if_error(git_patch_to_buf(&buf, *this)); + return buf; +} + +patch_wrapper patch_wrapper::patch_from_files(const std::string& path1, const std::string& file1_str, const std::string& path2, const std::string& file2_str, git_diff_options* diffopts) +{ + git_patch* patch; + throw_if_error(git_patch_from_buffers(&patch, file1_str.c_str(), file1_str.length(), path1.c_str(), file2_str.c_str(), file2_str.length(), path2.c_str(), diffopts)); + return patch_wrapper(patch); +} diff --git a/src/wrapper/patch_wrapper.hpp b/src/wrapper/patch_wrapper.hpp new file mode 100644 index 0000000..4f15dc4 --- /dev/null +++ b/src/wrapper/patch_wrapper.hpp @@ -0,0 +1,25 @@ +#pragma once + +#include +#include + +#include "../wrapper/wrapper_base.hpp" + +class patch_wrapper : public wrapper_base +{ +public: + + using base_type = wrapper_base; + + ~patch_wrapper(); + + patch_wrapper(patch_wrapper&&) noexcept = default; + patch_wrapper& operator=(patch_wrapper&&) noexcept = default; + + git_buf to_buf(); + static patch_wrapper patch_from_files(const std::string& path1, const std::string& file1_str, const std::string& path2, const std::string& file2_str, git_diff_options* diffopts); + +private: + + patch_wrapper(git_patch* patch); +}; diff --git a/src/wrapper/repository_wrapper.cpp b/src/wrapper/repository_wrapper.cpp index 418920e..6a5cc29 100644 --- a/src/wrapper/repository_wrapper.cpp +++ b/src/wrapper/repository_wrapper.cpp @@ -1,5 +1,6 @@ #include #include +#include #include #include "../utils/git_exception.hpp" @@ -9,6 +10,7 @@ #include "../wrapper/remote_wrapper.hpp" #include "../wrapper/repository_wrapper.hpp" #include "config_wrapper.hpp" +#include "diff_wrapper.hpp" repository_wrapper::~repository_wrapper() { @@ -208,17 +210,14 @@ void repository_wrapper::create_commit(const signature_wrapper::author_committer } }(); - git_tree* tree; index_wrapper index = this->make_index(); git_oid tree_id = index.write_tree(); index.write(); - throw_if_error(git_tree_lookup(&tree, *this, &tree_id)); + auto tree = this->tree_lookup(&tree_id); throw_if_error(git_commit_create(&commit_id, *this, update_ref.c_str(), author_committer_signatures.first, author_committer_signatures.second, message_encoding, message.data(), tree, parents_count, parents)); - - git_tree_free(tree); } std::optional repository_wrapper::resolve_local_ref @@ -364,6 +363,21 @@ void repository_wrapper::checkout_tree(const object_wrapper& target, const git_c throw_if_error(git_checkout_tree(*this, target, &opts)); } +tree_wrapper repository_wrapper::tree_lookup(const git_oid* tree_id) +{ + git_tree* tree; + throw_if_error(git_tree_lookup(&tree, *this, tree_id)); + return tree_wrapper(tree); +} + +tree_wrapper repository_wrapper::treeish_to_tree(const std::string& treeish) +{ + auto obj = this->revparse_single(treeish.c_str()); + git_tree* tree = nullptr; + throw_if_error(git_object_peel(reinterpret_cast(&tree), obj.value(), GIT_OBJECT_TREE)); + return tree_wrapper(tree); +} + // Remotes remote_wrapper repository_wrapper::find_remote(std::string_view name) const @@ -430,9 +444,58 @@ std::vector repository_wrapper::list_remotes() const // Config + config_wrapper repository_wrapper::get_config() { git_config* cfg; throw_if_error(git_repository_config(&cfg, *this)); return config_wrapper(cfg); } + + +// Diff + +diff_wrapper repository_wrapper::diff_tree_to_index(tree_wrapper old_tree, std::optional index, git_diff_options* diffopts) +{ + git_diff* diff; + git_index* idx = nullptr; + if (index) + { + idx = *index; + } + throw_if_error(git_diff_tree_to_index(&diff, *this, old_tree, idx, diffopts)); + return diff_wrapper(diff); +} + +diff_wrapper repository_wrapper::diff_tree_to_tree(tree_wrapper old_tree, tree_wrapper new_tree, git_diff_options* diffopts) +{ + git_diff* diff; + throw_if_error(git_diff_tree_to_tree(&diff, *this, old_tree, new_tree, diffopts)); + return diff_wrapper(diff); +} + +diff_wrapper repository_wrapper::diff_tree_to_workdir(tree_wrapper old_tree, git_diff_options* diffopts) +{ + git_diff* diff; + throw_if_error(git_diff_tree_to_workdir(&diff, *this, old_tree, diffopts)); + return diff_wrapper(diff); +} + +diff_wrapper repository_wrapper::diff_tree_to_workdir_with_index(tree_wrapper old_tree, git_diff_options* diffopts) +{ + git_diff* diff; + throw_if_error(git_diff_tree_to_workdir_with_index(&diff, *this, old_tree, diffopts)); + return diff_wrapper(diff); +} + +diff_wrapper repository_wrapper::diff_index_to_workdir(std::optional index, git_diff_options* diffopts) +{ + git_diff* diff; + git_index* idx = nullptr; + if (index) + { + idx = *index; + } + throw_if_error(git_diff_index_to_workdir(&diff, *this, idx, diffopts)); + return diff_wrapper(diff); +} diff --git a/src/wrapper/repository_wrapper.hpp b/src/wrapper/repository_wrapper.hpp index a3be98d..76a2d32 100644 --- a/src/wrapper/repository_wrapper.hpp +++ b/src/wrapper/repository_wrapper.hpp @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include @@ -11,12 +12,14 @@ #include "../wrapper/branch_wrapper.hpp" #include "../wrapper/commit_wrapper.hpp" #include "../wrapper/config_wrapper.hpp" +#include "../wrapper/diff_wrapper.hpp" #include "../wrapper/index_wrapper.hpp" #include "../wrapper/object_wrapper.hpp" #include "../wrapper/refs_wrapper.hpp" #include "../wrapper/remote_wrapper.hpp" #include "../wrapper/revwalk_wrapper.hpp" #include "../wrapper/signature_wrapper.hpp" +#include "../wrapper/tree_wrapper.hpp" #include "../wrapper/wrapper_base.hpp" class repository_wrapper : public wrapper_base @@ -88,6 +91,8 @@ class repository_wrapper : public wrapper_base // Trees void checkout_tree(const object_wrapper& target, const git_checkout_options opts); + tree_wrapper tree_lookup(const git_oid* tree_id); + tree_wrapper treeish_to_tree(const std::string& treeish); // Remotes remote_wrapper find_remote(std::string_view name) const; @@ -100,6 +105,13 @@ class repository_wrapper : public wrapper_base // Config config_wrapper get_config(); + // Diff + diff_wrapper diff_tree_to_index(tree_wrapper old_tree, std::optional index, git_diff_options* diffopts); + diff_wrapper diff_tree_to_tree(tree_wrapper old_tree, tree_wrapper new_tree, git_diff_options* diffopts); + diff_wrapper diff_tree_to_workdir(tree_wrapper old_tree, git_diff_options* diffopts); + diff_wrapper diff_tree_to_workdir_with_index(tree_wrapper old_tree, git_diff_options* diffopts); + diff_wrapper diff_index_to_workdir(std::optional index, git_diff_options* diffopts); + private: repository_wrapper() = default; diff --git a/src/wrapper/tree_wrapper.cpp b/src/wrapper/tree_wrapper.cpp new file mode 100644 index 0000000..3f696ff --- /dev/null +++ b/src/wrapper/tree_wrapper.cpp @@ -0,0 +1,12 @@ +#include "../wrapper/tree_wrapper.hpp" + +tree_wrapper::tree_wrapper(git_tree* tree) + : base_type(tree) +{ +} + +tree_wrapper::~tree_wrapper() +{ + git_tree_free(p_resource); + p_resource = nullptr; +} diff --git a/src/wrapper/tree_wrapper.hpp b/src/wrapper/tree_wrapper.hpp new file mode 100644 index 0000000..06d11c4 --- /dev/null +++ b/src/wrapper/tree_wrapper.hpp @@ -0,0 +1,23 @@ +#pragma once + +#include + +#include "../wrapper/wrapper_base.hpp" + +class tree_wrapper : public wrapper_base +{ +public: + + using base_type = wrapper_base; + + ~tree_wrapper(); + + tree_wrapper(tree_wrapper&&) noexcept = default; + tree_wrapper& operator=(tree_wrapper&&) noexcept = default; + +private: + + tree_wrapper(git_tree* tree); + + friend class repository_wrapper; +}; diff --git a/test/test_diff.py b/test/test_diff.py new file mode 100644 index 0000000..6dd5f1a --- /dev/null +++ b/test/test_diff.py @@ -0,0 +1,676 @@ +import re +import subprocess + +import pytest + + +def test_diff_nogit(git2cpp_path, tmp_path): + cmd = [git2cpp_path, "diff"] + p = subprocess.run(cmd, capture_output=True, cwd=tmp_path, text=True) + assert p.returncode != 0 + assert "repository" in p.stderr.lower() or "not a git" in p.stderr.lower() + + +def test_diff_working_directory(xtl_clone, git2cpp_path, tmp_path): + xtl_path = tmp_path / "xtl" + + readme = xtl_path / "README.md" + original_content = readme.read_text() + readme.write_text(original_content + "\nNew line added") + + cmd = [git2cpp_path, "diff"] + p = subprocess.run(cmd, capture_output=True, cwd=xtl_path, text=True) + assert p.returncode == 0 + assert "README.md" in p.stdout + assert "New line added" in p.stdout # should be "+New line added" + + +@pytest.mark.parametrize("cached_flag", ["--cached", "--staged"]) +def test_diff_cached(xtl_clone, git2cpp_path, tmp_path, cached_flag): + xtl_path = tmp_path / "xtl" + + new_file = xtl_path / "new_file.txt" + new_file.write_text("Hello, world!") + + cmd_add = [git2cpp_path, "add", "new_file.txt"] + subprocess.run(cmd_add, cwd=xtl_path, check=True) + + cmd_diff = [git2cpp_path, "diff", cached_flag] + p_diff = subprocess.run(cmd_diff, capture_output=True, cwd=xtl_path, text=True) + assert p_diff.returncode == 0 + assert "new_file.txt" in p_diff.stdout + assert "+Hello, world!" in p_diff.stdout + + +def test_diff_two_commits(xtl_clone, commit_env_config, git2cpp_path, tmp_path): + xtl_path = tmp_path / "xtl" + + new_file = xtl_path / "new_file.txt" + new_file.write_text("Hello, world!") + + cmd_add = [git2cpp_path, "add", "new_file.txt"] + subprocess.run(cmd_add, cwd=xtl_path, check=True) + + cmd_commit = [git2cpp_path, "commit", "-m", "new commit"] + subprocess.run(cmd_commit, cwd=xtl_path, check=True) + + cmd_diff = [git2cpp_path, "diff", "HEAD~1", "HEAD"] + p_diff = subprocess.run(cmd_diff, capture_output=True, cwd=xtl_path, text=True) + assert p_diff.returncode == 0 + assert "new_file.txt" in p_diff.stdout + assert "+Hello, world!" in p_diff.stdout + + +def test_diff_no_index(git2cpp_path, tmp_path): + file1 = tmp_path / "file1.txt" + file2 = tmp_path / "file2.txt" + + file1.write_text("Hello\nWorld\n") + file2.write_text("Hello\nPython\n") + + cmd = [git2cpp_path, "diff", "--no-index", str(file1), str(file2)] + p = subprocess.run(cmd, capture_output=True, cwd=tmp_path, text=True) + assert p.returncode == 0 + assert "-World" in p.stdout + assert "+Python" in p.stdout + + +def test_diff_stat(xtl_clone, git2cpp_path, tmp_path): + xtl_path = tmp_path / "xtl" + + readme = xtl_path / "README.md" + readme.write_text("Modified content\n") + + cmd = [git2cpp_path, "diff", "--stat"] + p = subprocess.run(cmd, capture_output=True, cwd=xtl_path, text=True) + assert p.returncode == 0 + assert "README.md" in p.stdout + assert "1 file changed, 1 insertion(+)" in p.stdout + assert "Modified content" not in p.stdout + + +def test_diff_shortstat(xtl_clone, git2cpp_path, tmp_path): + """Test diff with --shortstat (last line of --stat only)""" + xtl_path = tmp_path / "xtl" + + readme = xtl_path / "README.md" + readme.write_text("Modified content\n") + + cmd = [git2cpp_path, "diff", "--shortstat"] + p = subprocess.run(cmd, capture_output=True, cwd=xtl_path, text=True) + assert p.returncode == 0 + assert "README.md" not in p.stdout + assert "1 file changed, 1 insertion(+)" in p.stdout + assert "Modified content" not in p.stdout + + +def test_diff_numstat(xtl_clone, git2cpp_path, tmp_path): + """Test diff with --numstat (machine-friendly stat)""" + xtl_path = tmp_path / "xtl" + + readme = xtl_path / "README.md" + readme.write_text("Modified content\n") + + cmd = [git2cpp_path, "diff", "--numstat"] + p = subprocess.run(cmd, capture_output=True, cwd=xtl_path, text=True) + assert p.returncode == 0 + assert "README.md" in p.stdout + assert bool(re.search("1 [0-9]*", p.stdout)) + assert "Modified content" not in p.stdout + + +def test_diff_summary(xtl_clone, git2cpp_path, tmp_path): + """Test diff with --summary""" + xtl_path = tmp_path / "xtl" + + new_file = xtl_path / "newfile.txt" + new_file.write_text("New content") + + cmd_add = [git2cpp_path, "add", "newfile.txt"] + subprocess.run(cmd_add, cwd=xtl_path, check=True) + + cmd = [git2cpp_path, "diff", "--cached", "--summary"] + p = subprocess.run(cmd, capture_output=True, cwd=xtl_path, text=True) + assert p.returncode == 0 + assert "newfile.txt" in p.stdout + assert "+" not in p.stdout + + +def test_diff_name_only(xtl_clone, git2cpp_path, tmp_path): + xtl_path = tmp_path / "xtl" + + (xtl_path / "README.md").write_text("Modified") + + cmd = [git2cpp_path, "diff", "--name-only"] + p = subprocess.run(cmd, capture_output=True, cwd=xtl_path, text=True) + + assert p.returncode == 0 + assert p.stdout == "README.md\n" + assert "+" not in p.stdout + + +def test_diff_name_status(xtl_clone, git2cpp_path, tmp_path): + xtl_path = tmp_path / "xtl" + + (xtl_path / "README.md").write_text("Modified") + + cmd = [git2cpp_path, "diff", "--name-status"] + p = subprocess.run(cmd, capture_output=True, cwd=xtl_path, text=True) + assert p.returncode == 0 + assert p.stdout == "M\tREADME.md\n" + + +def test_diff_raw(xtl_clone, git2cpp_path, tmp_path): + """Test diff with --raw format""" + xtl_path = tmp_path / "xtl" + + readme = xtl_path / "README.md" + readme.write_text("Modified") + + cmd = [git2cpp_path, "diff", "--raw"] + p = subprocess.run(cmd, capture_output=True, cwd=xtl_path, text=True) + assert p.returncode == 0 + assert "M\tREADME.md" in p.stdout + assert bool(re.search(":[0-9]*", p.stdout)) + + +def test_diff_reverse(xtl_clone, git2cpp_path, tmp_path): + xtl_path = tmp_path / "xtl" + + readme = xtl_path / "README.md" + original = readme.read_text() + readme.write_text(original + "\nAdded line") + + cmd_normal = [git2cpp_path, "diff"] + p_normal = subprocess.run(cmd_normal, capture_output=True, cwd=xtl_path, text=True) + assert p_normal.returncode == 0 + assert "+Added line" in p_normal.stdout + + cmd_reverse = [git2cpp_path, "diff", "-R"] + p_reverse = subprocess.run( + cmd_reverse, capture_output=True, cwd=xtl_path, text=True + ) + assert p_reverse.returncode == 0 + assert "-Added line" in p_reverse.stdout + + +@pytest.mark.parametrize("text_flag", ["-a", "--text"]) +def test_diff_text(xtl_clone, commit_env_config, git2cpp_path, tmp_path, text_flag): + """Test diff with -a/--text (treat all files as text)""" + xtl_path = tmp_path / "xtl" + + binary_file = xtl_path / "binary.bin" + binary_file.write_bytes(b"\x00\x01\x02\x03") + + cmd_add = [git2cpp_path, "add", "binary.bin"] + subprocess.run(cmd_add, cwd=xtl_path, check=True) + + cmd_commit = [git2cpp_path, "commit", "-m", "add binary"] + subprocess.run(cmd_commit, cwd=xtl_path, check=True) + + binary_file.write_bytes(b"\x00\x01\x02\x04") + + cmd_text = [git2cpp_path, "diff", text_flag] + p = subprocess.run(cmd_text, capture_output=True, cwd=xtl_path, text=True) + assert p.returncode == 0 + assert "binary.bin" in p.stdout + assert "@@" in p.stdout + + +def test_diff_ignore_space_at_eol(xtl_clone, git2cpp_path, tmp_path): + """Test diff with --ignore-space-at-eol""" + xtl_path = tmp_path / "xtl" + + readme = xtl_path / "README.md" + original = readme.read_text() + # Add trailing spaces at end of line + readme.write_text(original.rstrip() + " \n") + + cmd = [git2cpp_path, "diff", "--ignore-space-at-eol"] + p = subprocess.run(cmd, capture_output=True, cwd=xtl_path, text=True) + assert p.returncode == 0 + assert p.stdout == "" + + +@pytest.mark.parametrize("space_change_flag", ["-b", "--ignore-space-change"]) +def test_diff_ignore_space_change( + xtl_clone, commit_env_config, git2cpp_path, tmp_path, space_change_flag +): + """Test diff with -b/--ignore-space-change""" + xtl_path = tmp_path / "xtl" + + test_file = xtl_path / "test.txt" + test_file.write_text("Hello world\n") + + cmd_add = [git2cpp_path, "add", "test.txt"] + subprocess.run(cmd_add, cwd=xtl_path, check=True) + + cmd_commit = [git2cpp_path, "commit", "-m", "test"] + subprocess.run(cmd_commit, cwd=xtl_path, check=True) + + # Change spacing + test_file.write_text("Hello world\n") + + cmd_diff = [git2cpp_path, "diff", space_change_flag] + p = subprocess.run(cmd_diff, capture_output=True, cwd=xtl_path, text=True) + assert p.returncode == 0 + assert p.stdout == "" + + +@pytest.mark.parametrize("ignore_space_flag", ["-w", "--ignore-all-space"]) +def test_diff_ignore_all_space( + xtl_clone, commit_env_config, git2cpp_path, tmp_path, ignore_space_flag +): + """Test diff with -w/--ignore-all-space""" + xtl_path = tmp_path / "xtl" + + test_file = xtl_path / "test.txt" + test_file.write_text("Hello world\n") + + cmd_add = [git2cpp_path, "add", "test.txt"] + subprocess.run(cmd_add, cwd=xtl_path, check=True) + + cmd_commit = [git2cpp_path, "commit", "-m", "test"] + subprocess.run(cmd_commit, cwd=xtl_path, check=True) + + test_file.write_text("Helloworld") + + cmd_diff = [git2cpp_path, "diff", ignore_space_flag] + p = subprocess.run(cmd_diff, capture_output=True, cwd=xtl_path, text=True) + assert p.returncode == 0 + assert p.stdout == "" + + +@pytest.mark.parametrize( + "unified_context_flag,context_lines", + [("-U0", 0), ("-U1", 1), ("-U5", 5), ("--unified=3", 3)], +) +def test_diff_unified_context( + xtl_clone, + commit_env_config, + git2cpp_path, + tmp_path, + unified_context_flag, + context_lines, +): + """Test diff with -U/--unified for context lines""" + xtl_path = tmp_path / "xtl" + + test_file = xtl_path / "test.txt" + # Create a file with enough lines to see context differences + test_file.write_text( + "Line 1\nLine 2\nLine 3\nLine 4\nLine 5\nLine 6\nLine 7\nLine 8\nLine 9\nLine 10\n" + ) + + cmd_add = [git2cpp_path, "add", "test.txt"] + subprocess.run(cmd_add, cwd=xtl_path, check=True) + + cmd_commit = [git2cpp_path, "commit", "-m", "test"] + subprocess.run(cmd_commit, cwd=xtl_path, check=True) + + # Modify line 5 (middle of the file) + test_file.write_text( + "Line 1\nLine 2\nLine 3\nLine 4\nMODIFIED LINE 5\nLine 6\nLine 7\nLine 8\nLine 9\nLine 10\n" + ) + + # Run diff with the parameterized flag + cmd = [git2cpp_path, "diff", unified_context_flag] + p = subprocess.run(cmd, capture_output=True, cwd=xtl_path, text=True) + assert p.returncode == 0 + assert "test.txt" in p.stdout + assert "MODIFIED LINE 5" in p.stdout + assert "@@" in p.stdout # Hunk header should always be present + + # Verify context lines based on context_lines parameter + + if context_lines >= 1: + # Should show immediate neighbors + assert "Line 4" in p.stdout + assert "Line 6" in p.stdout + + if context_lines >= 3: + # Should show 3 lines before and after + assert "Line 2" in p.stdout + assert "Line 3" in p.stdout + assert "Line 7" in p.stdout + assert "Line 8" in p.stdout + + if context_lines >= 5: + # Should show 5 lines before and after (reaching file boundaries) + assert "Line 1" in p.stdout + assert "Line 9" in p.stdout + assert "Line 10" in p.stdout + + if context_lines == 0: + # With U0, context lines should not appear (except in headers) + # Filter out header lines + output_lines = [ + line + for line in p.stdout.split("\n") + if not line.startswith("@@") + and not line.startswith("---") + and not line.startswith("+++") + and not line.startswith("diff ") + and not line.startswith("index ") + ] + output_content = "\n".join(output_lines) + + # Should have the deletion and addition, but minimal other content + assert "-Line 5" in output_content + assert "+MODIFIED LINE 5" in output_content + + # Verify that lines too far from the change don't appear with small context + if context_lines == 1: + assert "Line 2" not in p.stdout or p.stdout.count("Line 2") == 0 + assert "Line 8" not in p.stdout or p.stdout.count("Line 8") == 0 + + +def test_diff_inter_hunk_context(xtl_clone, commit_env_config, git2cpp_path, tmp_path): + """Test diff with --inter-hunk-context""" + xtl_path = tmp_path / "xtl" + + test_file = xtl_path / "test.txt" + lines = [f"Line {i}\n" for i in range(1, 31)] + test_file.write_text("".join(lines)) + + cmd_add = [git2cpp_path, "add", "test.txt"] + subprocess.run(cmd_add, cwd=xtl_path, check=True) + + cmd_commit = [git2cpp_path, "commit", "-m", "test"] + subprocess.run(cmd_commit, cwd=xtl_path, check=True) + + # Modify two separate sections + lines[4] = "Modified Line 5\n" + lines[19] = "Modified Line 20\n" + test_file.write_text("".join(lines)) + + # Test with small inter-hunk-context (should keep hunks separate) + cmd_small = [git2cpp_path, "diff", "--inter-hunk-context=1"] + p_small = subprocess.run(cmd_small, capture_output=True, cwd=xtl_path, text=True) + assert p_small.returncode == 0 + assert "Modified Line 5" in p_small.stdout + assert "Modified Line 20" in p_small.stdout + assert "@@" in p_small.stdout + + # Count hunks in small context output + hunk_count_small = len( + [ + l + for l in p_small.stdout.split("\n") + if l.startswith("@@") and l.endswith("@@") + ] + ) + + # Test with large inter-hunk-context (should merge hunks into one) + cmd_large = [git2cpp_path, "diff", "--inter-hunk-context=15"] + p_large = subprocess.run(cmd_large, capture_output=True, cwd=xtl_path, text=True) + assert p_large.returncode == 0 + assert "Modified Line 5" in p_large.stdout + assert "Modified Line 20" in p_large.stdout + assert "@@" in p_large.stdout + + # Count hunks in large context output + hunk_count_large = len( + [ + l + for l in p_large.stdout.split("\n") + if l.startswith("@@") and l.endswith("@@") + ] + ) + + # Verify both modifications appear in both outputs + assert "Modified Line 5" in p_small.stdout and "Modified Line 5" in p_large.stdout + assert "Modified Line 20" in p_small.stdout and "Modified Line 20" in p_large.stdout + + # Large inter-hunk-context should produce fewer or equal hunks (merging effect) + assert hunk_count_large <= hunk_count_small, ( + f"Expected large context ({hunk_count_large} hunks) to have <= hunks than small context ({hunk_count_small} hunks)" + ) + + +def test_diff_abbrev(xtl_clone, commit_env_config, git2cpp_path, tmp_path): + """Test diff with --abbrev for object name abbreviation""" + xtl_path = tmp_path / "xtl" + + test_file = xtl_path / "test.txt" + test_file.write_text("Original content\n") + + cmd_add = [git2cpp_path, "add", "test.txt"] + subprocess.run(cmd_add, cwd=xtl_path, check=True) + + cmd_commit = [git2cpp_path, "commit", "-m", "initial commit"] + subprocess.run(cmd_commit, cwd=xtl_path, check=True) + + # Modify the file + test_file.write_text("Modified content\n") + + # Test default --abbrev + cmd_default = [git2cpp_path, "diff", "--abbrev"] + p_default = subprocess.run( + cmd_default, capture_output=True, cwd=xtl_path, text=True + ) + assert p_default.returncode == 0 + assert "test.txt" in p_default.stdout + + # Test --abbrev=7 (short hash) + cmd_7 = [git2cpp_path, "diff", "--abbrev=7"] + p_7 = subprocess.run(cmd_7, capture_output=True, cwd=xtl_path, text=True) + assert p_7.returncode == 0 + assert "test.txt" in p_7.stdout + + # Test --abbrev=12 (longer hash) + cmd_12 = [git2cpp_path, "diff", "--abbrev=12"] + p_12 = subprocess.run(cmd_12, capture_output=True, cwd=xtl_path, text=True) + assert p_12.returncode == 0 + assert "test.txt" in p_12.stdout + + # Extract hash lengths from index lines to verify abbrev is working + hash_pattern = r"index ([0-9a-f]+)\.\.([0-9a-f]+)" + match_7 = re.search(hash_pattern, p_7.stdout) + match_12 = re.search(hash_pattern, p_12.stdout) + + if match_7 and match_12: + hash_len_7 = len(match_7.group(1)) + hash_len_12 = len(match_12.group(1)) + + # Verify that abbrev=12 produces longer or equal hash than abbrev=7 + assert hash_len_12 >= hash_len_7, ( + f"Expected abbrev=12 ({hash_len_12}) to be >= abbrev=7 ({hash_len_7})" + ) + + +# Note: only checking if the output is a diff +def test_diff_patience(xtl_clone, commit_env_config, git2cpp_path, tmp_path): + """Test diff with --patience algorithm""" + xtl_path = tmp_path / "xtl" + + test_file = xtl_path / "test.txt" + test_file.write_text("Line 1\nLine 2\nLine 3\n") + + cmd_add = [git2cpp_path, "add", "test.txt"] + subprocess.run(cmd_add, cwd=xtl_path, check=True) + + cmd_commit = [git2cpp_path, "commit", "-m", "test"] + subprocess.run(cmd_commit, cwd=xtl_path, check=True) + + test_file.write_text("Line 1\nNew Line\nLine 2\nLine 3\n") + + cmd = [git2cpp_path, "diff"] + p = subprocess.run(cmd, capture_output=True, cwd=xtl_path, text=True) + assert p.returncode == 0 + assert "test.txt" in p.stdout + assert "+New Line" in p.stdout + + +# Note: only checking if the output is a diff +def test_diff_minimal(xtl_clone, git2cpp_path, tmp_path): + """Test diff with --minimal (spend extra time to find smallest diff)""" + xtl_path = tmp_path / "xtl" + + readme = xtl_path / "README.md" + original = readme.read_text() + readme.write_text(original + "\nExtra line\n") + + cmd = [git2cpp_path, "diff", "--minimal"] + p = subprocess.run(cmd, capture_output=True, cwd=xtl_path, text=True) + assert p.returncode == 0 + assert "README.md" in p.stdout + assert "+Extra line" in p.stdout + + +# TODO: Find a way to check the colour +# @pytest.mark.parametrize("colour_flag", ["--color", "--no-color"]) +# def test_diff_colour(xtl_clone, git2cpp_path, tmp_path, colour_flag): +# xtl_path = tmp_path / "xtl" + +# (xtl_path / "README.md").write_text("Modified") + +# cmd = [git2cpp_path, "diff", colour_flag] +# p = subprocess.run(cmd, capture_output=True, cwd=xtl_path, text=True, shell=True) +# assert p.returncode == 0 +# # how to check if colour ? + +# ansi_escape = re.compile(r"\x1b\[[0-9;]*m") +# if colour_flag == "--no-color": +# assert not bool(re.search(ansi_escape, p.stdout)) +# else: +# 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" + +# old_file = xtl_path / "old_name.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_commit = [git2cpp_path, "commit", "-m", "Add file"] +# subprocess.run(cmd_commit, cwd=xtl_path, check=True) + +# new_file = xtl_path / "new_name.txt" +# old_file.rename(new_file) +# old_file.write_text("Goodbye\n") + +# 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=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(xtl_clone, git2cpp_path, tmp_path): +# """Test diff with -M with threshold value""" +# xtl_path = tmp_path / "xtl" + +# old_file = xtl_path / "old.txt" +# old_file.write_text("Content\n") + +# 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=xtl_path, check=True) + +# new_file = xtl_path / "new.txt" +# old_file.rename(new_file) + +# cmd_add_all = [git2cpp_path, "add", "-A"] +# subprocess.run(cmd_add_all, cwd=xtl_path, check=True) + +# 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(xtl_clone, git2cpp_path, tmp_path, copies_flag): +# """Test diff with -C/--find-copies""" +# xtl_path = tmp_path / "xtl" + +# 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=xtl_path, check=True) + +# copied_file = xtl_path / "copied.txt" +# copied_file.write_text("Content to be copied\n") + +# cmd_add_copy = [git2cpp_path, "add", "copied.txt"] +# subprocess.run(cmd_add_copy, cwd=xtl_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) + + +# 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" + +# original_file = xtl_path / "original.txt" +# original_file.write_text("Content\n") + +# cmd_add = [git2cpp_path, "add", "original.txt"] +# subprocess.run(cmd_add, cwd=xtl_path, check=True) + +# copied_file = xtl_path / "copied.txt" +# copied_file.write_text("Content to be copied\n") + +# cmd_add_copy = [git2cpp_path, "add", "copied.txt"] +# subprocess.run(cmd_add_copy, cwd=xtl_path, check=True) + +# cmd = [git2cpp_path, "diff", "--cached", "-C50"] +# p = subprocess.run(cmd, capture_output=True, cwd=xtl_path, text=True) +# assert p.returncode == 0 + + +# def test_diff_find_copies_harder(xtl_clone, git2cpp_path, tmp_path): +# """Test diff with --find-copies-harder""" +# xtl_path = tmp_path / "xtl" + +# test_file = xtl_path / "test.txt" +# test_file.write_text("Content\n") + +# cmd_add = [git2cpp_path, "add", "test.txt"] +# subprocess.run(cmd_add, cwd=xtl_path, check=True) + +# cmd = [git2cpp_path, "diff", "--cached", "--find-copies-harder"] +# p = subprocess.run(cmd, capture_output=True, cwd=xtl_path, text=True) +# assert p.returncode == 0 + + +# @pytest.mark.parametrize("break_rewrites_flag", ["-B", "--break-rewrites"]) +# def test_diff_break_rewrites(xtl_clone, git2cpp_path, tmp_path, break_rewrites_flag): +# """Test diff with -B/--break-rewrites""" +# xtl_path = tmp_path / "xtl" + +# test_file = xtl_path / "test.txt" +# test_file.write_text("Original content\n") + +# cmd_add = [git2cpp_path, "add", "test.txt"] +# subprocess.run(cmd_add, cwd=xtl_path, check=True) + +# cmd_commit = [git2cpp_path, "commit", "-m", "test"] +# subprocess.run(cmd_commit, cwd=xtl_path, check=True) + +# # Completely rewrite the file +# test_file.write_text("Completely different content\n") + +# 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) From be2f30980bc2efa8799fedc49f22c105dcdd09ab Mon Sep 17 00:00:00 2001 From: Sandrine Pataut Date: Thu, 5 Feb 2026 14:21:26 +0100 Subject: [PATCH 2/4] edit error codes --- src/subcommand/diff_subcommand.cpp | 7 ++++--- src/utils/common.cpp | 2 +- src/utils/git_exception.cpp | 5 ++++- src/utils/git_exception.hpp | 7 +++++++ 4 files changed, 16 insertions(+), 5 deletions(-) diff --git a/src/subcommand/diff_subcommand.cpp b/src/subcommand/diff_subcommand.cpp index 8d71998..b6e3865 100644 --- a/src/subcommand/diff_subcommand.cpp +++ b/src/subcommand/diff_subcommand.cpp @@ -5,6 +5,7 @@ #include #include "../utils/common.hpp" +#include "../utils/git_exception.hpp" #include "../subcommand/diff_subcommand.hpp" #include "../wrapper/patch_wrapper.hpp" #include "../wrapper/repository_wrapper.hpp" @@ -212,7 +213,7 @@ diff_wrapper compute_diff_no_index(std::vector files, git_diff_opti { if (files.size() != 2) { - throw git_exception("two files should be provided as arguments", -1); //TODO: check error + code + throw git_exception("usage: git diff --no-index [] [...]", git2cpp_error_code::BAD_ARGUMENT); } git_diff_options_init(&diffopts, GIT_DIFF_OPTIONS_VERSION); @@ -222,11 +223,11 @@ diff_wrapper compute_diff_no_index(std::vector files, git_diff_opti if (file1_str.empty()) { - throw git_exception("file " + files[0] + " cannot be read", -1); //TODO: check error + code + throw git_exception("Cannot read file: " + files[0], git2cpp_error_code::GENERIC_ERROR); //TODO: check error code with git } if (file2_str.empty()) { - throw git_exception("file " + files[1] + " cannot be read", -1); //TODO: check error + code + throw git_exception("Cannot read file: " + files[1], git2cpp_error_code::GENERIC_ERROR); //TODO: check error code with git } auto patch = patch_wrapper::patch_from_files(files[0], file1_str, files[1], file2_str, &diffopts); diff --git a/src/utils/common.cpp b/src/utils/common.cpp index 6aa8fdc..91255be 100644 --- a/src/utils/common.cpp +++ b/src/utils/common.cpp @@ -109,7 +109,7 @@ std::string read_file(const std::string& path) std::ifstream file(path, std::ios::binary); if (!file) { - throw git_exception("Cannot read file: " + path, -1); + throw git_exception("error: Could not access " + path, git2cpp_error_code::GENERIC_ERROR); } std::stringstream buffer; buffer << file.rdbuf(); diff --git a/src/utils/git_exception.cpp b/src/utils/git_exception.cpp index 18bca2c..8426d45 100644 --- a/src/utils/git_exception.cpp +++ b/src/utils/git_exception.cpp @@ -10,11 +10,14 @@ void throw_if_error(int exit_code) } } - git_exception::git_exception(const std::string_view message, int error_code) : m_message(message), m_error_code(error_code) {} +git_exception::git_exception(const std::string_view message, git2cpp_error_code error_code) + : git_exception(message, static_cast(error_code)) +{} + int git_exception::error_code() const { return m_error_code; diff --git a/src/utils/git_exception.hpp b/src/utils/git_exception.hpp index e9d67ec..1efb76a 100644 --- a/src/utils/git_exception.hpp +++ b/src/utils/git_exception.hpp @@ -3,6 +3,12 @@ #include #include +enum class git2cpp_error_code +{ + GENERIC_ERROR = -1, + BAD_ARGUMENT = 129, +}; + void throw_if_error(int exit_code); class git_exception : public std::exception @@ -10,6 +16,7 @@ class git_exception : public std::exception public: git_exception(const std::string_view message, int error_code); + git_exception(const std::string_view message, git2cpp_error_code error_code); int error_code() const; From 5879159e7e4d51fb95de79285141c4bc6c6652a1 Mon Sep 17 00:00:00 2001 From: Sandrine Pataut Date: Fri, 6 Feb 2026 11:24:34 +0100 Subject: [PATCH 3/4] address review comments --- src/subcommand/checkout_subcommand.cpp | 2 +- src/subcommand/diff_subcommand.cpp | 54 +++++++++++++++++++------- src/utils/common.cpp | 3 +- src/wrapper/repository_wrapper.cpp | 1 - src/wrapper/repository_wrapper.hpp | 1 - 5 files changed, 42 insertions(+), 19 deletions(-) diff --git a/src/subcommand/checkout_subcommand.cpp b/src/subcommand/checkout_subcommand.cpp index a3e0844..aba90fb 100644 --- a/src/subcommand/checkout_subcommand.cpp +++ b/src/subcommand/checkout_subcommand.cpp @@ -24,7 +24,7 @@ void checkout_subcommand::run() if (repo.state() != GIT_REPOSITORY_STATE_NONE) { - std::runtime_error("Cannot checkout, repository is in unexpected state"); + throw std::runtime_error("Cannot checkout, repository is in unexpected state"); } git_checkout_options options; diff --git a/src/subcommand/diff_subcommand.cpp b/src/subcommand/diff_subcommand.cpp index b6e3865..4c70793 100644 --- a/src/subcommand/diff_subcommand.cpp +++ b/src/subcommand/diff_subcommand.cpp @@ -1,6 +1,4 @@ #include -#include -#include #include #include @@ -61,17 +59,38 @@ void diff_subcommand::print_stats(const diff_wrapper& diff, bool use_colour) git_diff_stats_format_t format; if (m_stat_flag) { - format = GIT_DIFF_STATS_FULL; + if (m_shortstat_flag || m_numstat_flag || m_summary_flag) + { + throw git_exception("Only one of --stat, --shortstat, --numstat and --summary should be provided.", git2cpp_error_code::BAD_ARGUMENT); + } + else + { + format = GIT_DIFF_STATS_FULL; + } } - if (m_shortstat_flag) + else if (m_shortstat_flag) { - format = GIT_DIFF_STATS_SHORT; + if (m_numstat_flag || m_summary_flag) + { + throw git_exception("Only one of --stat, --shortstat, --numstat and --summary should be provided.", git2cpp_error_code::BAD_ARGUMENT); + } + else + { + format = GIT_DIFF_STATS_SHORT; + } } - if (m_numstat_flag) + else if (m_numstat_flag) { - format = GIT_DIFF_STATS_NUMBER; + if (m_summary_flag) + { + throw git_exception("Only one of --stat, --shortstat, --numstat and --summary should be provided.", git2cpp_error_code::BAD_ARGUMENT); + } + else + { + format = GIT_DIFF_STATS_NUMBER; + } } - if (m_summary_flag) + else if (m_summary_flag) { format = GIT_DIFF_STATS_INCLUDE_SUMMARY; } @@ -120,7 +139,7 @@ void diff_subcommand::print_stats(const diff_wrapper& diff, bool use_colour) static int colour_printer([[maybe_unused]] const git_diff_delta* delta, [[maybe_unused]] const git_diff_hunk* hunk, const git_diff_line* line, void* payload) { - bool* use_colour = reinterpret_cast(payload); + bool use_colour = reinterpret_cast(payload); // Only print origin for context/addition/deletion lines // For other line types, content already includes everything @@ -128,7 +147,7 @@ static int colour_printer([[maybe_unused]] const git_diff_delta* delta, [[maybe_ line->origin == GIT_DIFF_LINE_ADDITION || line->origin == GIT_DIFF_LINE_DELETION); - if (*use_colour) + if (use_colour) { switch (line->origin) { case GIT_DIFF_LINE_ADDITION: std::cout << termcolor::green; break; @@ -245,13 +264,20 @@ void diff_subcommand::run() git_diff_options_init(&diffopts, GIT_DIFF_OPTIONS_VERSION); bool use_colour = false; - if (m_colour_flag) + if (m_no_colour_flag) { - use_colour = true; + if (m_colour_flag) + { + throw git_exception("Only one of --color and --no-color should be provided.", git2cpp_error_code::BAD_ARGUMENT); + } + else + { + use_colour = false; + } } - if (m_no_colour_flag) + else if (m_colour_flag) { - use_colour = false; + use_colour = true; } if (m_no_index_flag) diff --git a/src/utils/common.cpp b/src/utils/common.cpp index 91255be..1df06e6 100644 --- a/src/utils/common.cpp +++ b/src/utils/common.cpp @@ -1,11 +1,10 @@ #include #include #include +#include #include #include -#include - #include "common.hpp" #include "git_exception.hpp" diff --git a/src/wrapper/repository_wrapper.cpp b/src/wrapper/repository_wrapper.cpp index 6a5cc29..31b7177 100644 --- a/src/wrapper/repository_wrapper.cpp +++ b/src/wrapper/repository_wrapper.cpp @@ -1,6 +1,5 @@ #include #include -#include #include #include "../utils/git_exception.hpp" diff --git a/src/wrapper/repository_wrapper.hpp b/src/wrapper/repository_wrapper.hpp index 76a2d32..b3e8dbc 100644 --- a/src/wrapper/repository_wrapper.hpp +++ b/src/wrapper/repository_wrapper.hpp @@ -1,7 +1,6 @@ #pragma once #include -#include #include #include From 93be714c923ef179126df6e910486ef489bc4bc2 Mon Sep 17 00:00:00 2001 From: Sandrine Pataut Date: Fri, 6 Feb 2026 16:03:41 +0100 Subject: [PATCH 4/4] Update src/subcommand/diff_subcommand.cpp Co-authored-by: Johan Mabille --- src/subcommand/diff_subcommand.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/subcommand/diff_subcommand.cpp b/src/subcommand/diff_subcommand.cpp index 4c70793..0e352e0 100644 --- a/src/subcommand/diff_subcommand.cpp +++ b/src/subcommand/diff_subcommand.cpp @@ -139,7 +139,7 @@ void diff_subcommand::print_stats(const diff_wrapper& diff, bool use_colour) static int colour_printer([[maybe_unused]] const git_diff_delta* delta, [[maybe_unused]] const git_diff_hunk* hunk, const git_diff_line* line, void* payload) { - bool use_colour = reinterpret_cast(payload); + bool use_colour = *reinterpret_cast(payload); // Only print origin for context/addition/deletion lines // For other line types, content already includes everything