From 72a2fc2db95c4509d8d1a726148f5f91c32d925f Mon Sep 17 00:00:00 2001 From: Sandrine Pataut Date: Wed, 3 Dec 2025 14:52:40 +0100 Subject: [PATCH 01/15] Merge conflict (#60) Merge conflicts --- src/subcommand/merge_subcommand.cpp | 194 ++++++++++++++++++++++++--- src/subcommand/merge_subcommand.hpp | 4 + src/subcommand/status_subcommand.cpp | 103 ++++++-------- src/utils/common.cpp | 28 ++++ src/utils/common.hpp | 8 ++ src/wrapper/index_wrapper.cpp | 54 +++++++- src/wrapper/index_wrapper.hpp | 6 + src/wrapper/status_wrapper.cpp | 8 ++ src/wrapper/status_wrapper.hpp | 2 + test/test_merge.py | 136 +++++++++++++++++-- 10 files changed, 452 insertions(+), 91 deletions(-) diff --git a/src/subcommand/merge_subcommand.cpp b/src/subcommand/merge_subcommand.cpp index d537e1e..53d2115 100644 --- a/src/subcommand/merge_subcommand.cpp +++ b/src/subcommand/merge_subcommand.cpp @@ -1,8 +1,11 @@ #include +#include #include +#include +#include #include "merge_subcommand.hpp" -#include +#include "../wrapper/status_wrapper.hpp" merge_subcommand::merge_subcommand(const libgit2_object&, CLI::App& app) @@ -10,9 +13,12 @@ merge_subcommand::merge_subcommand(const libgit2_object&, CLI::App& app) auto *sub = app.add_subcommand("merge", "Join two or more development histories together"); sub->add_option("", m_branches_to_merge, "Branch(es) to merge"); - // sub->add_flag("--no-ff", m_no_ff, ""); + // sub->add_flag("--no-ff", m_no_ff, "Create a merge commit in all cases, even when the merge could instead be resolved as a fast-forward."); // sub->add_flag("--commit", m_commit, "Perform the merge and commit the result. This option can be used to override --no-commit."); sub->add_flag("--no-commit", m_no_commit, "With --no-commit perform the merge and stop just before creating a merge commit, to give the user a chance to inspect and further tweak the merge result before committing. \nNote that fast-forward updates do not create a merge commit and therefore there is no way to stop those merges with --no-commit. Thus, if you want to ensure your branch is not changed or updated by the merge command, use --no-ff with --no-commit."); + sub->add_flag("--abort", m_abort, "Abort the current conflict resolution process, and try to reconstruct the pre-merge state. If an autostash entry is present, apply it to the worktree.\nIf there were uncommitted worktree changes present when the merge started, git merge --abort will in some cases be unable to reconstruct these changes. It is therefore recommended to always commit or stash your changes before running git merge.\ngit merge --abort is equivalent to git reset --merge when MERGE_HEAD is present unless MERGE_AUTOSTASH is also present in which case git merge --abort applies the stash entry to the worktree whereas git reset --merge will save the stashed changes in the stash list."); + sub->add_flag("--quit", m_quit, "Forget about the current merge in progress. Leave the index and the working tree as-is. If MERGE_AUTOSTASH is present, the stash entry will be saved to the stash list."); + sub->add_flag("--continue", m_continue, "After a git merge stops due to conflicts you can conclude the merge by running git merge --continue"); // (see "HOW TO RESOLVE CONFLICTS" section below). sub->callback([this]() { this->run(); }); } @@ -33,7 +39,23 @@ annotated_commit_list_wrapper merge_subcommand::resolve_heads(const repository_w return annotated_commit_list_wrapper(std::move(commits_to_merge)); } -void perform_fastforward(repository_wrapper& repo, const git_oid target_oid, int is_unborn) +annotated_commit_list_wrapper resolve_mergeheads(const repository_wrapper& repo, const std::vector& oid_list) +{ + std::vector commits_to_merge; + commits_to_merge.reserve(oid_list.size()); + + for (const auto& id:oid_list) + { + std::optional commit = repo.find_annotated_commit(id); + if (commit.has_value()) + { + commits_to_merge.push_back(std::move(commit).value()); + } + } + return annotated_commit_list_wrapper(std::move(commits_to_merge)); +} + +void perform_fastforward(repository_wrapper& repo, const git_oid& target_oid, int is_unborn) { const git_checkout_options ff_checkout_options = GIT_CHECKOUT_OPTIONS_INIT; @@ -60,12 +82,13 @@ void perform_fastforward(repository_wrapper& repo, const git_oid target_oid, int void merge_subcommand::create_merge_commit( repository_wrapper& repo, const index_wrapper& index, + const std::vector& branches_to_merge, const annotated_commit_list_wrapper& commits_to_merge, size_t num_commits_to_merge) { auto head_ref = repo.head(); - auto merge_ref = repo.find_reference_dwim(m_branches_to_merge.front()); - auto merge_commit = repo.resolve_local_ref(m_branches_to_merge.front()).value(); + auto merge_ref = repo.find_reference_dwim(branches_to_merge.front()); + auto merge_commit = repo.resolve_local_ref(branches_to_merge.front()).value(); std::vector parents_list; parents_list.reserve(num_commits_to_merge + 1); @@ -85,6 +108,7 @@ void merge_subcommand::create_merge_commit( // TODO: add a prompt to edit the merge message std::string msg_target = merge_ref ? merge_ref->short_name() : git_oid_tostr_s(&(merge_commit.oid())); + msg_target = "\'" + msg_target + "\'"; std::string msg = merge_ref ? "Merge branch " : "Merge commit "; msg.append(msg_target); @@ -93,15 +117,159 @@ void merge_subcommand::create_merge_commit( repo.state_cleanup(); } +// This function is used as a callback in git_repository_mergehead_foreach and therefore its type must be git_repository_mergehead_foreach_cb. +int populate_list(const git_oid* oid, void* payload) +{ + auto* l = reinterpret_cast*>(payload); + l->push_back(*oid); + return 0; +} + void merge_subcommand::run() { auto directory = get_current_git_path(); auto bare = false; auto repo = repository_wrapper::open(directory); - auto state = repo.state(); - if (state != GIT_REPOSITORY_STATE_NONE) + index_wrapper index = repo.make_index(); + stream_colour_fn yellow = termcolor::yellow; + + if (state == GIT_REPOSITORY_STATE_MERGE) { + if (m_abort) + { + // git merge --abort is equivalent to git reset --merge when MERGE_HEAD is present + // unless MERGE_AUTOSTASH is also present in which case git merge --abort applies + // the stash entry to the worktree whereas git reset --merge will save the stashed + // changes in the stash list. + + if (m_quit | m_continue) + { + std::cout << "fatal: --abort expects no arguments" << std::endl; // TODO: add the help info + return; + } + + std::cout << "Warning: 'merge --abort' is not implemented yet. A 'reset --hard HEAD' will be executed." << std::endl; + std::cout << "Do you want to continue [y/N] ?" << std::endl; + std::string answer; + std::cin >> answer; + if (answer == "y") + { + repo.state_cleanup(); + index.conflict_cleanup(); + + git_checkout_options options; + git_checkout_options_init(&options, GIT_CHECKOUT_OPTIONS_VERSION); + auto head_ref = repo.head(); + repo.reset(head_ref.peel(), GIT_RESET_HARD, options); + } + else + { + std::cout << "Abort." << std::endl; // maybe another message would be more clear? + } + return; + } + else if (m_quit) + { + // Forget about the current merge in progress. Leave the index and the working tree as-is. + // If MERGE_AUTOSTASH is present, the stash entry will be saved to the stash list. + // + + // if (m_continue) + // { + // std::cout << "fatal: --abort expects no arguments" << std::endl; // TODO: add the help info + // return; + // } + + // problem: can't do a reset if the state is not cleaned up, but it shouldn't be. + // Idem for the index and the conflicts. + + // repo.state_cleanup(); + // index.conflict_cleanup(); + + // git_checkout_options options; + // git_checkout_options_init(&options, GIT_CHECKOUT_OPTIONS_VERSION); + // auto head_ref = repo.head(); + // repo.reset(head_ref.peel(), GIT_RESET_SOFT, options); + + std::cout << "merge --quit is not implemented yet." << std::endl; + return; + } + else if (m_continue) + { + auto sl = status_list_wrapper::status_list(repo); + if (!sl.has_unmerged_header()) + { + // std::string commit_message = "Merge branch "; // how to get the name of the branch the merge was started on ? + // auto author_committer_signatures = signature_wrapper::get_default_signature_from_env(repo); + // repo.create_commit(author_committer_signatures, commit_message, std::nullopt); + + std::vector oid_list; + git_repository_mergehead_foreach(repo, populate_list, &oid_list); + + annotated_commit_list_wrapper commits_to_merge = resolve_mergeheads(repo, oid_list); + size_t num_commits_to_merge = commits_to_merge.size(); + + std::vector branches_to_merge_names; + for (const auto& id:oid_list) + { + git_reference_iterator* iter; + git_reference_iterator_new(&iter, repo); + git_reference* ref; + git_reference_next(&ref, iter); + if (git_oid_equal(git_reference_target(ref), &id)) + { + auto name = git_reference_name(ref); + branches_to_merge_names.push_back(name); + } + git_reference_free(ref); + } + + create_merge_commit(repo, index, branches_to_merge_names, commits_to_merge, num_commits_to_merge); + std::cout << "Merge made" << std::endl; // TODO: change the outpout to something like this: 3c22161 (HEAD -> master) Merge branch 'foregone' + + repo.state_cleanup(); + index.conflict_cleanup(); + return; + } + else + { + auto entry_status = get_status_msg(GIT_STATUS_CONFLICTED).short_mod; + const auto& entry_list = sl.get_entry_list(GIT_STATUS_CONFLICTED); + for (auto* entry : entry_list) + { + git_diff_delta* diff_delta = entry->head_to_index; //ou entry->index_to_workdir ??? + const char* old_path = diff_delta->old_file.path; + std::cout << entry_status << "\t" << old_path << std::endl; + } + std::cout << "error: Committing is not possible because you have unmerged files." << std::endl; + } + } + else + { + std::cout << "error: Merging is not possible because you have unmerged files." << std::endl; + } + std::cout << yellow << "hint: Fix them up in the work tree, and then use 'git add/rm '" << std::endl; + std::cout << "hint: as appropriate to mark resolution and make a commit." << termcolor::reset << std::endl; + std::cout << "fatal: Exiting because of an unresolved conflict." << std::endl; + return; + } + else + { + if (m_abort) + { + std::cout << "fatal: There is no merge to abort (MERGE_HEAD missing)." << std::endl; + return; + } + if (m_continue) + { + std::cout << "fatal: There is no merge in progress (MERGE_HEAD missing)." << std::endl; + return; + } + } + + if (state != GIT_REPOSITORY_STATE_NONE) // Could this be a "else if before the "else" above ? + { std::cout << "repository is in unexpected state " << state <& branches_to_merge, const annotated_commit_list_wrapper& commits_to_merge, size_t num_commits_to_merge); @@ -25,4 +26,7 @@ class merge_subcommand // bool m_no_ff = false; // bool m_commit = false; bool m_no_commit = false; + bool m_abort = false; + bool m_quit = false; + bool m_continue = false; }; diff --git a/src/subcommand/status_subcommand.cpp b/src/subcommand/status_subcommand.cpp index 1b9c1f1..36aa213 100644 --- a/src/subcommand/status_subcommand.cpp +++ b/src/subcommand/status_subcommand.cpp @@ -27,41 +27,13 @@ status_subcommand::status_subcommand(const libgit2_object&, CLI::App& app) }; const std::string untracked_header = "Untracked files:\n (use \"git add ...\" to include in what will be committed)\n"; -const std::string tobecommited_header = "Changes to be committed:\n"; -// (use \"git restore --staged ...\" to unstage)\n -// (use \"git reset HEAD ...\" to unstage)\n"; -// const std::string ignored_header = "Ignored files:\n (use \"git add -f ...\" to include in what will be committed)\n"; -const std::string notstagged_header = "Changes not staged for commit:\n (use \"git add ...\" to update what will be committed)\n"; -// (use \"git restore ...\" to discard changes in working directory)\n -// (use \"git checkout -- ...\" to discard changes in working directory)\n" -const std::string nothingtocommit_msg = "No changes added to commit (use \"git add\" and/or \"git commit -a\")"; -const std::string uptodate_msg = "Nothing to commit, working tree clean."; -const std::string nothingtocommit_untrackedfiles_msg = "Nothing added to commit but untracked files present (use \"git add\" to track)"; -// no changes added to commit (use "git add" and/or "git commit -a") - -struct status_messages -{ - std::string short_mod; - std::string long_mod; -}; - -const std::map status_msg_map = //TODO : check spaces in short_mod -{ - { GIT_STATUS_CURRENT, {"", ""} }, - { GIT_STATUS_INDEX_NEW, {"A ", "\tnew file:"} }, - { GIT_STATUS_INDEX_MODIFIED, {"M ", "\tmodified:"} }, - { GIT_STATUS_INDEX_DELETED, {"D ", "\tdeleted:"} }, - { GIT_STATUS_INDEX_RENAMED, {"R ", "\trenamed:"} }, - { GIT_STATUS_INDEX_TYPECHANGE, {"T ", "\ttypechange:"} }, - { GIT_STATUS_WT_NEW, {"?? ", " "} }, - { GIT_STATUS_WT_MODIFIED, {" M " , "\tmodified:"} }, - { GIT_STATUS_WT_DELETED, {" D ", "\tdeleted:"} }, - { GIT_STATUS_WT_TYPECHANGE, {" T ", "\ttypechange:"} }, - { GIT_STATUS_WT_RENAMED, {" R ", "\trenamed:"} }, - { GIT_STATUS_WT_UNREADABLE, {"", ""} }, - { GIT_STATUS_IGNORED, {"!! ", ""} }, - { GIT_STATUS_CONFLICTED, {"", ""} }, -}; +const std::string tobecommited_header = "Changes to be committed:\n (use \"git reset HEAD ...\" to unstage)\n"; +const std::string ignored_header = "Ignored files:\n (use \"git add -f ...\" to include in what will be committed)\n"; +const std::string notstagged_header = "Changes not staged for commit:\n"; +// "Changes not staged for commit:\n (use \"git add%s ...\" to update what will be committed)\n (use \"git checkout -- ...\" to discard changes in working directory)\n" +const std::string unmerged_header = "Unmerged paths:\n (use \"git add ...\" to mark resolution)\n"; +// const std::string nothingtocommit_message = "No changes added to commit (use \"git add\" and/or \"git commit -a\")"; +const std::string treeclean_message = "Nothing to commit, working tree clean"; enum class output_format { @@ -81,18 +53,11 @@ std::string get_print_status(git_status_t status, output_format of) std::string entry_status; if ((of == output_format::DEFAULT) || (of == output_format::LONG)) { - if (status == GIT_STATUS_WT_NEW) - { - entry_status = status_msg_map.at(status).long_mod + "\t"; - } - else - { - entry_status = status_msg_map.at(status).long_mod + " "; - } + entry_status = get_status_msg(status).long_mod; } else if (of == output_format::SHORT) { - entry_status = status_msg_map.at(status).short_mod; + entry_status = get_status_msg(status).short_mod; } return entry_status; } @@ -173,8 +138,7 @@ void print_not_tracked(const std::vector& entries_to_print, const s const size_t first_slash_idx = e.item.find('/'); if (std::string::npos != first_slash_idx) { - auto directory = e.item.substr(0, first_slash_idx); - auto directory_print = e.item.substr(0, first_slash_idx) + "/"; + auto directory = e.item.substr(0, first_slash_idx) + "/"; if (tracked_dir_set.contains(directory)) { not_tracked_entries_to_print.push_back(e); @@ -185,7 +149,7 @@ void print_not_tracked(const std::vector& entries_to_print, const s {} else { - not_tracked_entries_to_print.push_back({e.status, directory_print}); + not_tracked_entries_to_print.push_back({e.status, directory}); untracked_dir_set.insert(std::string(directory)); } } @@ -228,7 +192,12 @@ void status_subcommand::run() is_long = ((of == output_format::DEFAULT) || (of == output_format::LONG)); if (is_long) { - std::cout << "On branch " << branch_name << std::endl; + std::cout << "On branch " << branch_name << "\n" << std::endl; + + if (sl.has_unmerged_header()) + { + std::cout << "You have unmerged paths.\n (fix conflicts and run \"git commit\")\n (use \"git merge --abort\" to abort the merge)\n" << std::endl; + } } else { @@ -273,6 +242,21 @@ void status_subcommand::run() } } + // TODO: check if should be printed before "not stagged" files + if (sl.has_unmerged_header()) + { + stream_colour_fn colour = termcolor::red; + if (is_long) + { + std::cout << unmerged_header; + } + print_not_tracked(get_entries_to_print(GIT_STATUS_CONFLICTED, sl, false, of), tracked_dir_set, untracked_dir_set, is_long, colour); + if (is_long) + { + std::cout << std::endl; + } + } + if (sl.has_untracked_header()) { stream_colour_fn colour = termcolor::red; @@ -287,6 +271,12 @@ void status_subcommand::run() } } + // TODO: check if this message should be displayed even if there are untracked files + if (!(sl.has_tobecommited_header() | sl.has_notstagged_header() | sl.has_unmerged_header() | sl.has_untracked_header())) + { + std::cout << treeclean_message << std::endl; + } + // if (sl.has_ignored_header()) // { // stream_colour_fn colour = termcolor::red; @@ -300,21 +290,4 @@ void status_subcommand::run() // std::cout << std::endl; // } // } - - if (!sl.has_tobecommited_header() && (sl.has_notstagged_header() || sl.has_untracked_header())) - { - if (sl.has_untracked_header()) - { - std::cout << nothingtocommit_untrackedfiles_msg << std::endl; - } - else - { - std::cout << nothingtocommit_msg << std::endl; - } - } - - if (!sl.has_notstagged_header() && !sl.has_untracked_header()) - { - std::cout << uptodate_msg << std::endl; - } } diff --git a/src/utils/common.cpp b/src/utils/common.cpp index 9a5787c..a9b84d4 100644 --- a/src/utils/common.cpp +++ b/src/utils/common.cpp @@ -1,6 +1,7 @@ #include #include #include +#include #include @@ -26,6 +27,33 @@ std::string get_current_git_path() // ->check(CLI::ExistingDirectory | CLI::NonexistentPath) // ->default_val(std::filesystem::current_path()); +const std::map& get_status_msg_map() +{ + static std::map status_msg_map = //TODO : check spaces in short_mod + { + { GIT_STATUS_CURRENT, {"", ""} }, + { GIT_STATUS_INDEX_NEW, {"A ", "\tnew file: "} }, + { GIT_STATUS_INDEX_MODIFIED, {"M ", "\tmodified: "} }, + { GIT_STATUS_INDEX_DELETED, {"D ", "\tdeleted: "} }, + { GIT_STATUS_INDEX_RENAMED, {"R ", "\trenamed: "} }, + { GIT_STATUS_INDEX_TYPECHANGE, {"T ", "\ttypechange: "} }, + { GIT_STATUS_WT_NEW, {"?? ", "\t"} }, + { GIT_STATUS_WT_MODIFIED, {" M " , "\tmodified: "} }, + { GIT_STATUS_WT_DELETED, {" D ", "\tdeleted: "} }, + { GIT_STATUS_WT_TYPECHANGE, {" T ", "\ttypechange: "} }, + { GIT_STATUS_WT_RENAMED, {" R ", "\trenamed: "} }, + { GIT_STATUS_WT_UNREADABLE, {"", ""} }, + { GIT_STATUS_IGNORED, {"!! ", ""} }, + { GIT_STATUS_CONFLICTED, {"AA ", "\tboth added: "} }, + }; + return status_msg_map; +} + +status_messages get_status_msg(git_status_t st) +{ + return get_status_msg_map().find(st)->second; +} + git_strarray_wrapper::git_strarray_wrapper(std::vector patterns) : m_patterns(std::move(patterns)) { diff --git a/src/utils/common.hpp b/src/utils/common.hpp index e3b959c..6751b46 100644 --- a/src/utils/common.hpp +++ b/src/utils/common.hpp @@ -28,6 +28,14 @@ class libgit2_object : private noncopyable_nonmovable std::string get_current_git_path(); +struct status_messages +{ + std::string short_mod; + std::string long_mod; +}; + +status_messages get_status_msg(git_status_t); + using stream_colour_fn = std::ostream& (*)(std::ostream&); class git_strarray_wrapper diff --git a/src/wrapper/index_wrapper.cpp b/src/wrapper/index_wrapper.cpp index 03e8afb..7ff0ce2 100644 --- a/src/wrapper/index_wrapper.cpp +++ b/src/wrapper/index_wrapper.cpp @@ -1,9 +1,12 @@ +#include +#include +#include + #include "index_wrapper.hpp" #include "../utils/common.hpp" #include "../utils/git_exception.hpp" #include "../wrapper/repository_wrapper.hpp" -#include index_wrapper::~index_wrapper() { @@ -32,7 +35,6 @@ void index_wrapper::add_impl(std::vector patterns) { git_strarray_wrapper array{patterns}; throw_if_error(git_index_add_all(*this, array, 0, NULL, NULL)); - // throw_if_error(git_index_write(*this)); } void index_wrapper::write() @@ -46,3 +48,51 @@ git_oid index_wrapper::write_tree() throw_if_error(git_index_write_tree(&tree_id, *this)); return tree_id; } + +bool index_wrapper::has_conflict() const +{ + return git_index_has_conflicts(*this); +} + +git_index_conflict_iterator* index_wrapper::create_conflict_iterator() +{ + git_index_conflict_iterator* conflict_iterator; + throw_if_error(git_index_conflict_iterator_new(&conflict_iterator, *this)); + return conflict_iterator; +} + +void index_wrapper::output_conflicts() +{ + git_index_conflict_iterator* conflicts = create_conflict_iterator(); + + const git_index_entry* ancestor; + const git_index_entry* our; + const git_index_entry* their; + int err = 0; + std::string msg_conflict; + + while ((err = git_index_conflict_next(&ancestor, &our, &their, conflicts)) == 0) + { + std::string ancestor_path = ancestor ? ancestor->path : ""; + std::string our_path = our->path ? our->path : "NULL"; + std::string their_path = their->path ? their->path : "NULL"; + msg_conflict = "conflict: " + ancestor_path + " " + our_path + " " + their_path; + std::cout << msg_conflict << std::endl; +// Message with git is a bit different: +// Auto-merging mook_file.txt +// CONFLICT (add/add): Merge conflict in mook_file.txt +// Automatic merge failed; fix conflicts and then commit the result. + } + + if (err != GIT_ITEROVER) + { + std::cout << "error iterating conflicts" << std::endl; + } + + git_index_conflict_iterator_free(conflicts); +} + +void index_wrapper::conflict_cleanup() +{ + throw_if_error(git_index_conflict_cleanup(*this)); +} diff --git a/src/wrapper/index_wrapper.hpp b/src/wrapper/index_wrapper.hpp index 2095fe0..0fa8b55 100644 --- a/src/wrapper/index_wrapper.hpp +++ b/src/wrapper/index_wrapper.hpp @@ -26,9 +26,15 @@ class index_wrapper : public wrapper_base void add_entries(std::vector patterns); void add_all(); + bool has_conflict() const; + void output_conflicts(); + void conflict_cleanup(); + private: index_wrapper() = default; void add_impl(std::vector patterns); + + git_index_conflict_iterator* create_conflict_iterator(); }; diff --git a/src/wrapper/status_wrapper.cpp b/src/wrapper/status_wrapper.cpp index a1962d8..a6fe876 100644 --- a/src/wrapper/status_wrapper.cpp +++ b/src/wrapper/status_wrapper.cpp @@ -35,6 +35,10 @@ status_list_wrapper status_list_wrapper::status_list(const repository_wrapper& r { res.m_ignored_header_flag = true; } + if (!res.get_entry_list(GIT_STATUS_CONFLICTED).empty()) + { + res.m_unmerged_header_flag = true; + } // if (!res.tobecommited_header_flag) // { // res.m_nothingtocommit_message_flag = true; @@ -59,6 +63,10 @@ bool status_list_wrapper::has_notstagged_header() const { return m_notstagged_header_flag; } +bool status_list_wrapper::has_unmerged_header() const +{ + return m_unmerged_header_flag; +} bool status_list_wrapper::has_nothingtocommit_message() const { return m_nothingtocommit_message_flag; diff --git a/src/wrapper/status_wrapper.hpp b/src/wrapper/status_wrapper.hpp index 2a8335d..b20e18a 100644 --- a/src/wrapper/status_wrapper.hpp +++ b/src/wrapper/status_wrapper.hpp @@ -26,6 +26,7 @@ class status_list_wrapper : public wrapper_base bool has_tobecommited_header() const; bool has_ignored_header() const; bool has_notstagged_header() const; + bool has_unmerged_header() const; bool has_nothingtocommit_message() const; private: @@ -39,5 +40,6 @@ class status_list_wrapper : public wrapper_base bool m_tobecommited_header_flag = false; bool m_ignored_header_flag = false; bool m_notstagged_header_flag = false; + bool m_unmerged_header_flag = false; bool m_nothingtocommit_message_flag = false; }; diff --git a/test/test_merge.py b/test/test_merge.py index c123553..0f531f0 100644 --- a/test/test_merge.py +++ b/test/test_merge.py @@ -1,5 +1,4 @@ import subprocess -import time import pytest @@ -16,8 +15,8 @@ def test_merge_fast_forward(xtl_clone, git_config, git2cpp_path, tmp_path, monke ) assert p_checkout.returncode == 0 - p = xtl_path / "mook_file.txt" - p.write_text("blablabla") + file_path = xtl_path / "mook_file.txt" + file_path.write_text("blablabla") add_cmd = [git2cpp_path, "add", "mook_file.txt"] p_add = subprocess.run(add_cmd, capture_output=True, cwd=xtl_path, text=True) @@ -53,7 +52,7 @@ def test_merge_fast_forward(xtl_clone, git_config, git2cpp_path, tmp_path, monke assert p_merge_2.stdout == "Already up-to-date\n" -def test_merge(xtl_clone, git_config, git2cpp_path, tmp_path, monkeypatch): +def test_merge_commit(xtl_clone, git_config, git2cpp_path, tmp_path, monkeypatch): assert (tmp_path / "xtl").exists() xtl_path = tmp_path / "xtl" @@ -63,8 +62,8 @@ def test_merge(xtl_clone, git_config, git2cpp_path, tmp_path, monkeypatch): ) assert p_checkout.returncode == 0 - p = xtl_path / "mook_file.txt" - p.write_text("blablabla") + file_path = xtl_path / "mook_file.txt" + file_path.write_text("blablabla") add_cmd = [git2cpp_path, "add", "mook_file.txt"] p_add = subprocess.run(add_cmd, capture_output=True, cwd=xtl_path, text=True) @@ -80,8 +79,8 @@ def test_merge(xtl_clone, git_config, git2cpp_path, tmp_path, monkeypatch): ) assert p_checkout_2.returncode == 0 - p = xtl_path / "mook_file_2.txt" - p.write_text("BLABLABLA") + file_path_2 = xtl_path / "mook_file_2.txt" + file_path_2.write_text("BLABLABLA") add_cmd_2 = [git2cpp_path, "add", "mook_file_2.txt"] p_add_2 = subprocess.run(add_cmd_2, capture_output=True, cwd=xtl_path, text=True) @@ -104,7 +103,7 @@ def test_merge(xtl_clone, git_config, git2cpp_path, tmp_path, monkeypatch): # assert "Commit: John Doe" in p_log.stdout assert "Johan" not in p_log.stdout assert (xtl_path / "mook_file.txt").exists() - assert (xtl_path / "mook_file.txt").exists() + assert (xtl_path / "mook_file_2.txt").exists() merge_cmd_2 = [git2cpp_path, "merge", "foregone"] p_merge_2 = subprocess.run( @@ -112,3 +111,122 @@ def test_merge(xtl_clone, git_config, git2cpp_path, tmp_path, monkeypatch): ) assert p_merge_2.returncode == 0 assert p_merge_2.stdout == "Already up-to-date\n" + + +@pytest.mark.parametrize("flag", ["--abort", "--quit", "--continue"]) +def test_merge_conflict( + xtl_clone, git_config, git2cpp_path, tmp_path, monkeypatch, flag +): + assert (tmp_path / "xtl").exists() + xtl_path = tmp_path / "xtl" + + checkout_cmd = [git2cpp_path, "checkout", "-b", "foregone"] + p_checkout = subprocess.run( + checkout_cmd, capture_output=True, cwd=xtl_path, text=True + ) + assert p_checkout.returncode == 0 + + file_path = xtl_path / "mook_file.txt" + file_path.write_text("blablabla") + + file_path_2 = xtl_path / "mook_file_2.txt" + file_path_2.write_text("Second file") + + add_cmd = [git2cpp_path, "add", "--all"] + p_add = subprocess.run(add_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_add.returncode == 0 + + commit_cmd = [git2cpp_path, "commit", "-m", "test commit foregone"] + p_commit = subprocess.run(commit_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_commit.returncode == 0 + + checkout_cmd_2 = [git2cpp_path, "checkout", "master"] + p_checkout_2 = subprocess.run( + checkout_cmd_2, capture_output=True, cwd=xtl_path, text=True + ) + assert p_checkout_2.returncode == 0 + + file_path.write_text("BLABLABLA") + + add_cmd_2 = [git2cpp_path, "add", "mook_file.txt"] + p_add_2 = subprocess.run(add_cmd_2, capture_output=True, cwd=xtl_path, text=True) + assert p_add_2.returncode == 0 + + commit_cmd_2 = [git2cpp_path, "commit", "-m", "test commit master"] + p_commit_2 = subprocess.run( + commit_cmd_2, capture_output=True, cwd=xtl_path, text=True + ) + assert p_commit_2.returncode == 0 + + merge_cmd = [git2cpp_path, "merge", "foregone"] + p_merge = subprocess.run(merge_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_merge.returncode == 0 + assert "conflict: " in p_merge.stdout + + flag_cmd = [git2cpp_path, "merge", flag] + if flag == "--abort": + for answer in {"y", ""}: + p_abort = subprocess.run( + flag_cmd, input=answer, capture_output=True, cwd=xtl_path, text=True + ) + assert p_abort.returncode == 0 + assert (xtl_path / "mook_file.txt").exists() + with open(xtl_path / "mook_file.txt") as f: + if answer == "y": + assert "BLA" in f.read() + assert "bla" not in f.read() + else: + assert "Abort." in p_abort.stdout + + elif flag == "--quit": + pass + # p_quit = subprocess.run(flag_cmd, capture_output=True, cwd=xtl_path, text=True) + # assert p_quit.returncode == 0 + # assert (xtl_path / "mook_file.txt").exists() + # with open(xtl_path / "mook_file.txt") as f: + # lines = f.readlines() + # assert "<<<<<<< HEAD" in lines[0] + # assert ">>>>>>> foregone" in lines[-1] + + # p_merge_2 = subprocess.run( + # merge_cmd, capture_output=True, cwd=xtl_path, text=True + # ) + # assert p_merge_2.returncode != 0 + # print(p_merge_2.stdout) + # assert "error: Merging is not possible because you have unmerged files." in p_merge_2.stdout + + elif flag == "--continue": + # Create another branch pointing to the same commit (alias branch). + # This checks the merge behaviour when a different branch name points to the same commit. + branch_alias_cmd = [git2cpp_path, "branch", "foregone_alias"] + p_branch_alias = subprocess.run( + branch_alias_cmd, capture_output=True, cwd=xtl_path, text=True + ) + assert p_branch_alias.returncode == 0 + + file_path.write_text("blablabla") + + cmd_add = [git2cpp_path, "add", "mook_file.txt"] + p_add = subprocess.run(cmd_add, cwd=xtl_path, text=True) + assert p_add.returncode == 0 + + p_continue = subprocess.run( + flag_cmd, capture_output=True, cwd=xtl_path, text=True + ) + assert p_continue.returncode == 0 + + log_cmd = [git2cpp_path, "log", "--format=full", "--max-count", "2"] + p_log = subprocess.run(log_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_log.returncode == 0 + assert "Author: Jane Doe" in p_log.stdout + # assert "Commit: John Doe" in p_log.stdout + assert "Johan" not in p_log.stdout + assert (xtl_path / "mook_file.txt").exists() + assert (xtl_path / "mook_file_2.txt").exists() + + merge_cmd_2 = [git2cpp_path, "merge", "foregone"] + p_merge_2 = subprocess.run( + merge_cmd_2, capture_output=True, cwd=xtl_path, text=True + ) + assert p_merge_2.returncode == 0 + assert p_merge_2.stdout == "Already up-to-date\n" From 6342fb263ccaec188be0c5b10f7622533ca97fea Mon Sep 17 00:00:00 2001 From: Sandrine Pataut Date: Wed, 10 Dec 2025 11:22:48 +0100 Subject: [PATCH 02/15] change release version to 0.0.5 (#61) --- src/version.hpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/version.hpp b/src/version.hpp index cb8064c..31704ac 100644 --- a/src/version.hpp +++ b/src/version.hpp @@ -2,7 +2,7 @@ #define GIT2CPP_VERSION_MAJOR 0 #define GIT2CPP_VERSION_MINOR 0 -#define GIT2CPP_VERSION_PATCH 4 +#define GIT2CPP_VERSION_PATCH 5 // e.g. ".rc0" #define GIT2CPP_VERSION_SUFFIX From 324e7c27cc46552c65cf2b197119e93f6b30e48a Mon Sep 17 00:00:00 2001 From: Julien Jerphanion Date: Thu, 11 Dec 2025 22:43:37 +0100 Subject: [PATCH 03/15] feat: Add remote management, fetch, and push subcommands (#59) * feat: Add remote management, fetch, and push subcommands - Implement remote add/remove/rename/set-url/show operations - Add fetch and push subcommands for remote synchronization - Create remote_wrapper class for RAII management - Add comprehensive test suite (19 tests, all passing) - Fix CMakeLists.txt to find CLI11 in conda/pixi environment Signed-off-by: Julien Jerphanion * Address review comments Signed-off-by: Julien Jerphanion Co-authored-by: Johan Mabille * edit remote * small fix in test * another small fix --------- Signed-off-by: Julien Jerphanion Co-authored-by: Johan Mabille Co-authored-by: Sandrine Pataut --- CMakeLists.txt | 10 + src/main.cpp | 6 + src/subcommand/clone_subcommand.cpp | 80 +----- src/subcommand/clone_subcommand.hpp | 1 + src/subcommand/fetch_subcommand.cpp | 54 ++++ src/subcommand/fetch_subcommand.hpp | 19 ++ src/subcommand/push_subcommand.cpp | 54 ++++ src/subcommand/push_subcommand.hpp | 21 ++ src/subcommand/remote_subcommand.cpp | 194 ++++++++++++++ src/subcommand/remote_subcommand.hpp | 34 +++ src/utils/progress.cpp | 155 +++++++++++ src/utils/progress.hpp | 10 + src/wrapper/remote_wrapper.cpp | 63 +++++ src/wrapper/remote_wrapper.hpp | 36 +++ src/wrapper/repository_wrapper.cpp | 68 +++++ src/wrapper/repository_wrapper.hpp | 9 + test/test_clone.py | 22 +- test/test_remote.py | 383 +++++++++++++++++++++++++++ 18 files changed, 1140 insertions(+), 79 deletions(-) create mode 100644 src/subcommand/fetch_subcommand.cpp create mode 100644 src/subcommand/fetch_subcommand.hpp create mode 100644 src/subcommand/push_subcommand.cpp create mode 100644 src/subcommand/push_subcommand.hpp create mode 100644 src/subcommand/remote_subcommand.cpp create mode 100644 src/subcommand/remote_subcommand.hpp create mode 100644 src/utils/progress.cpp create mode 100644 src/utils/progress.hpp create mode 100644 src/wrapper/remote_wrapper.cpp create mode 100644 src/wrapper/remote_wrapper.hpp create mode 100644 test/test_remote.py diff --git a/CMakeLists.txt b/CMakeLists.txt index d18cbd9..a9a266b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -50,12 +50,18 @@ set(GIT2CPP_SRC ${GIT2CPP_SOURCE_DIR}/subcommand/clone_subcommand.hpp ${GIT2CPP_SOURCE_DIR}/subcommand/commit_subcommand.cpp ${GIT2CPP_SOURCE_DIR}/subcommand/commit_subcommand.hpp + ${GIT2CPP_SOURCE_DIR}/subcommand/fetch_subcommand.cpp + ${GIT2CPP_SOURCE_DIR}/subcommand/fetch_subcommand.hpp ${GIT2CPP_SOURCE_DIR}/subcommand/init_subcommand.cpp ${GIT2CPP_SOURCE_DIR}/subcommand/init_subcommand.hpp ${GIT2CPP_SOURCE_DIR}/subcommand/log_subcommand.cpp ${GIT2CPP_SOURCE_DIR}/subcommand/log_subcommand.hpp ${GIT2CPP_SOURCE_DIR}/subcommand/merge_subcommand.cpp ${GIT2CPP_SOURCE_DIR}/subcommand/merge_subcommand.hpp + ${GIT2CPP_SOURCE_DIR}/subcommand/push_subcommand.cpp + ${GIT2CPP_SOURCE_DIR}/subcommand/push_subcommand.hpp + ${GIT2CPP_SOURCE_DIR}/subcommand/remote_subcommand.cpp + ${GIT2CPP_SOURCE_DIR}/subcommand/remote_subcommand.hpp ${GIT2CPP_SOURCE_DIR}/subcommand/reset_subcommand.cpp ${GIT2CPP_SOURCE_DIR}/subcommand/reset_subcommand.hpp ${GIT2CPP_SOURCE_DIR}/subcommand/status_subcommand.cpp @@ -68,6 +74,8 @@ set(GIT2CPP_SRC ${GIT2CPP_SOURCE_DIR}/utils/git_exception.hpp ${GIT2CPP_SOURCE_DIR}/utils/output.cpp ${GIT2CPP_SOURCE_DIR}/utils/output.hpp + ${GIT2CPP_SOURCE_DIR}/utils/progress.cpp + ${GIT2CPP_SOURCE_DIR}/utils/progress.hpp ${GIT2CPP_SOURCE_DIR}/utils/terminal_pager.cpp ${GIT2CPP_SOURCE_DIR}/utils/terminal_pager.hpp ${GIT2CPP_SOURCE_DIR}/wrapper/annotated_commit_wrapper.cpp @@ -82,6 +90,8 @@ set(GIT2CPP_SRC ${GIT2CPP_SOURCE_DIR}/wrapper/object_wrapper.hpp ${GIT2CPP_SOURCE_DIR}/wrapper/refs_wrapper.cpp ${GIT2CPP_SOURCE_DIR}/wrapper/refs_wrapper.hpp + ${GIT2CPP_SOURCE_DIR}/wrapper/remote_wrapper.cpp + ${GIT2CPP_SOURCE_DIR}/wrapper/remote_wrapper.hpp ${GIT2CPP_SOURCE_DIR}/wrapper/repository_wrapper.cpp ${GIT2CPP_SOURCE_DIR}/wrapper/repository_wrapper.hpp ${GIT2CPP_SOURCE_DIR}/wrapper/signature_wrapper.cpp diff --git a/src/main.cpp b/src/main.cpp index e8479c8..7b52301 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -10,9 +10,12 @@ #include "subcommand/checkout_subcommand.hpp" #include "subcommand/clone_subcommand.hpp" #include "subcommand/commit_subcommand.hpp" +#include "subcommand/fetch_subcommand.hpp" #include "subcommand/init_subcommand.hpp" #include "subcommand/log_subcommand.hpp" #include "subcommand/merge_subcommand.hpp" +#include "subcommand/push_subcommand.hpp" +#include "subcommand/remote_subcommand.hpp" #include "subcommand/reset_subcommand.hpp" #include "subcommand/status_subcommand.hpp" @@ -35,9 +38,12 @@ int main(int argc, char** argv) checkout_subcommand checkout(lg2_obj, app); clone_subcommand clone(lg2_obj, app); commit_subcommand commit(lg2_obj, app); + fetch_subcommand fetch(lg2_obj, app); reset_subcommand reset(lg2_obj, app); log_subcommand log(lg2_obj, app); merge_subcommand merge(lg2_obj, app); + push_subcommand push(lg2_obj, app); + remote_subcommand remote(lg2_obj, app); app.require_subcommand(/* min */ 0, /* max */ 1); diff --git a/src/subcommand/clone_subcommand.cpp b/src/subcommand/clone_subcommand.cpp index 6c9b803..69b44fa 100644 --- a/src/subcommand/clone_subcommand.cpp +++ b/src/subcommand/clone_subcommand.cpp @@ -2,6 +2,7 @@ #include "../subcommand/clone_subcommand.hpp" #include "../utils/output.hpp" +#include "../utils/progress.hpp" #include "../wrapper/repository_wrapper.hpp" clone_subcommand::clone_subcommand(const libgit2_object&, CLI::App& app) @@ -10,81 +11,11 @@ clone_subcommand::clone_subcommand(const libgit2_object&, CLI::App& app) sub->add_option("", m_repository, "The (possibly remote) repository to clone from.")->required(); sub->add_option("", m_directory, "The name of a new directory to clone into."); + sub->add_flag("--bare", m_bare, "Create a bare Git repository."); sub->callback([this]() { this->run(); }); } -namespace -{ - int sideband_progress(const char* str, int len, void*) - { - printf("remote: %.*s", len, str); - fflush(stdout); - return 0; - } - - int fetch_progress(const git_indexer_progress* stats, void* payload) - { - static bool done = false; - - // We need to copy stats into payload even if the fetch is done, - // because the checkout_progress callback will be called with the - // same payload and needs the data to be up do date. - auto* pr = reinterpret_cast(payload); - *pr = *stats; - - if (done) - { - return 0; - } - - int network_percent = pr->total_objects > 0 ? - (100 * pr->received_objects / pr->total_objects) - : 0; - size_t mbytes = pr->received_bytes / (1024*1024); - - std::cout << "Receiving objects: " << std::setw(4) << network_percent - << "% (" << pr->received_objects << "/" << pr->total_objects << "), " - << mbytes << " MiB"; - - if (pr->received_objects == pr->total_objects) - { - std::cout << ", done." << std::endl; - done = true; - } - else - { - std::cout << '\r'; - } - return 0; - } - - void checkout_progress(const char* path, size_t cur, size_t tot, void* payload) - { - static bool done = false; - if (done) - { - return; - } - auto* pr = reinterpret_cast(payload); - int deltas_percent = pr->total_deltas > 0 ? - (100 * pr->indexed_deltas / pr->total_deltas) - : 0; - - std::cout << "Resolving deltas: " << std::setw(4) << deltas_percent - << "% (" << pr->indexed_deltas << "/" << pr->total_deltas << ")"; - if (pr->indexed_deltas == pr->total_deltas) - { - std::cout << ", done." << std::endl; - done = true; - } - else - { - std::cout << '\r'; - } - } -} - void clone_subcommand::run() { git_indexer_progress pd; @@ -94,9 +25,10 @@ void clone_subcommand::run() checkout_opts.progress_cb = checkout_progress; checkout_opts.progress_payload = &pd; clone_opts.checkout_opts = checkout_opts; - clone_opts.fetch_opts.callbacks.sideband_progress = sideband_progress; - clone_opts.fetch_opts.callbacks.transfer_progress = fetch_progress; - clone_opts.fetch_opts.callbacks.payload = &pd; + clone_opts.fetch_opts.callbacks.sideband_progress = sideband_progress; + clone_opts.fetch_opts.callbacks.transfer_progress = fetch_progress; + clone_opts.fetch_opts.callbacks.payload = &pd; + clone_opts.bare = m_bare ? 1 : 0; std::string short_name = m_directory; if (m_directory.empty()) diff --git a/src/subcommand/clone_subcommand.hpp b/src/subcommand/clone_subcommand.hpp index bf2a0d7..631cd07 100644 --- a/src/subcommand/clone_subcommand.hpp +++ b/src/subcommand/clone_subcommand.hpp @@ -15,4 +15,5 @@ class clone_subcommand std::string m_repository = {}; std::string m_directory = {}; + bool m_bare = false; }; diff --git a/src/subcommand/fetch_subcommand.cpp b/src/subcommand/fetch_subcommand.cpp new file mode 100644 index 0000000..4d07e1a --- /dev/null +++ b/src/subcommand/fetch_subcommand.cpp @@ -0,0 +1,54 @@ +#include + +#include + +#include "../subcommand/fetch_subcommand.hpp" +#include "../utils/output.hpp" +#include "../utils/progress.hpp" +#include "../wrapper/repository_wrapper.hpp" + +fetch_subcommand::fetch_subcommand(const libgit2_object&, CLI::App& app) +{ + auto* sub = app.add_subcommand("fetch", "Download objects and refs from another repository"); + + sub->add_option("", m_remote_name, "The remote to fetch from") + ->default_val("origin"); + + sub->callback([this]() { this->run(); }); +} + +void fetch_subcommand::run() +{ + auto directory = get_current_git_path(); + auto repo = repository_wrapper::open(directory); + + // Find the remote (default to origin if not specified) + std::string remote_name = m_remote_name.empty() ? "origin" : m_remote_name; + auto remote = repo.find_remote(remote_name); + + git_indexer_progress pd = {0}; + git_fetch_options fetch_opts = GIT_FETCH_OPTIONS_INIT; + fetch_opts.callbacks.sideband_progress = sideband_progress; + fetch_opts.callbacks.transfer_progress = fetch_progress; + fetch_opts.callbacks.payload = &pd; + fetch_opts.callbacks.update_refs = update_refs; + + cursor_hider ch; + + // Perform the fetch + remote.fetch(nullptr, &fetch_opts, "fetch"); + + // Show statistics + const git_indexer_progress* stats = git_remote_stats(remote); + if (stats->local_objects > 0) + { + std::cout << "\rReceived " << stats->indexed_objects << "/" << stats->total_objects + << " objects in " << stats->received_bytes << " bytes (used " + << stats->local_objects << " local objects)" << std::endl; + } + else + { + std::cout << "\rReceived " << stats->indexed_objects << "/" << stats->total_objects + << " objects in " << stats->received_bytes << " bytes" << std::endl; + } +} diff --git a/src/subcommand/fetch_subcommand.hpp b/src/subcommand/fetch_subcommand.hpp new file mode 100644 index 0000000..bc607c1 --- /dev/null +++ b/src/subcommand/fetch_subcommand.hpp @@ -0,0 +1,19 @@ +#pragma once + +#include + +#include + +#include "../utils/common.hpp" + +class fetch_subcommand +{ +public: + + explicit fetch_subcommand(const libgit2_object&, CLI::App& app); + void run(); + +private: + + std::string m_remote_name; +}; diff --git a/src/subcommand/push_subcommand.cpp b/src/subcommand/push_subcommand.cpp new file mode 100644 index 0000000..be04267 --- /dev/null +++ b/src/subcommand/push_subcommand.cpp @@ -0,0 +1,54 @@ +#include + +#include + +#include "../subcommand/push_subcommand.hpp" +#include "../utils/progress.hpp" +#include "../wrapper/repository_wrapper.hpp" + +push_subcommand::push_subcommand(const libgit2_object&, CLI::App& app) +{ + auto* sub = app.add_subcommand("push", "Update remote refs along with associated objects"); + + sub->add_option("", m_remote_name, "The remote to push to") + ->default_val("origin"); + + sub->add_option("", m_refspecs, "The refspec(s) to push"); + + sub->callback([this]() { this->run(); }); +} + +void push_subcommand::run() +{ + auto directory = get_current_git_path(); + auto repo = repository_wrapper::open(directory); + + std::string remote_name = m_remote_name.empty() ? "origin" : m_remote_name; + auto remote = repo.find_remote(remote_name); + + git_push_options push_opts = GIT_PUSH_OPTIONS_INIT; + push_opts.callbacks.push_transfer_progress = push_transfer_progress; + push_opts.callbacks.push_update_reference = push_update_reference; + + if (m_refspecs.empty()) + { + try + { + auto head_ref = repo.head(); + std::string short_name = head_ref.short_name(); + std::string refspec = "refs/heads/" + short_name; + m_refspecs.push_back(refspec); + } + catch (...) + { + std::cerr << "Could not determine current branch to push." << std::endl; + return; + } + } + git_strarray_wrapper refspecs_wrapper(m_refspecs); + git_strarray* refspecs_ptr = nullptr; + refspecs_ptr = refspecs_wrapper; + + remote.push(refspecs_ptr, &push_opts); + std::cout << "Pushed to " << remote_name << std::endl; +} diff --git a/src/subcommand/push_subcommand.hpp b/src/subcommand/push_subcommand.hpp new file mode 100644 index 0000000..07c301e --- /dev/null +++ b/src/subcommand/push_subcommand.hpp @@ -0,0 +1,21 @@ +#pragma once + +#include +#include + +#include + +#include "../utils/common.hpp" + +class push_subcommand +{ +public: + + explicit push_subcommand(const libgit2_object&, CLI::App& app); + void run(); + +private: + + std::string m_remote_name; + std::vector m_refspecs; +}; diff --git a/src/subcommand/remote_subcommand.cpp b/src/subcommand/remote_subcommand.cpp new file mode 100644 index 0000000..f76ad70 --- /dev/null +++ b/src/subcommand/remote_subcommand.cpp @@ -0,0 +1,194 @@ +#include +#include + +#include "../subcommand/remote_subcommand.hpp" +#include "../wrapper/repository_wrapper.hpp" + +remote_subcommand::remote_subcommand(const libgit2_object&, CLI::App& app) +{ + m_subcommand = app.add_subcommand("remote", "Manage set of tracked repositories"); + + m_subcommand->add_option("operation", m_operation, "Operation: add, remove, rename, set-url, show") + ->check(CLI::IsMember({"add", "remove", "rm", "rename", "set-url", "show"})); + + m_subcommand->add_flag("-v,--verbose", m_verbose_flag, "Be verbose"); + m_subcommand->add_flag("--push", m_push_flag, "Set push URL instead of fetch URL"); + + // Allow positional arguments after operation + m_subcommand->allow_extras(); + + m_subcommand->callback([this]() { this->run(); }); +} + +void remote_subcommand::run() +{ + auto directory = get_current_git_path(); + auto repo = repository_wrapper::open(directory); + + // Get extra positional arguments + auto extras = m_subcommand->remaining(); + + // Parse positional arguments based on operation + if (m_operation == "add") + { + if (extras.size() == 2) + { + m_remote_name = extras[0]; + m_url = extras[1]; + } + run_add(repo); + } + else if (m_operation == "remove" || m_operation == "rm") + { + if (extras.size() == 1) + { + m_remote_name = extras[0]; + } + run_remove(repo); + } + else if (m_operation == "rename") + { + if (extras.size() == 2) + { + m_old_name = extras[0]; + m_new_name = extras[1]; + } + run_rename(repo); + } + else if (m_operation == "set-url") + { + // Handle --push flag before arguments + size_t arg_idx = 0; + if (extras.size() > 0 && extras[0] == "--push") + { + m_push_flag = true; + arg_idx = 1; + } + if (extras.size() >= arg_idx + 2) + { + m_remote_name = extras[arg_idx]; + m_new_name = extras[arg_idx + 1]; + run_seturl(repo); + } + else if (m_remote_name.empty() || m_new_name.empty()) + { + throw std::runtime_error("remote set-url requires both name and new URL"); + } + else + { + run_seturl(repo); + } + } + else if (m_operation.empty() || m_operation == "show") + { + if (extras.size() >= 1) + { + m_remote_name = extras[0]; + } + run_show(repo); + } +} + +void remote_subcommand::run_add(repository_wrapper& repo) +{ + if (m_remote_name.empty()) + { + throw std::runtime_error("usage: git remote add "); // TODO: add [] when implemented + } + repo.create_remote(m_remote_name, m_url); +} + +void remote_subcommand::run_remove(repository_wrapper& repo) +{ + if (m_remote_name.empty()) + { + throw std::runtime_error("usage: git remote remove "); + } + repo.delete_remote(m_remote_name); +} + +void remote_subcommand::run_rename(repository_wrapper& repo) +{ + if (m_old_name.empty()) + { + throw std::runtime_error("usage: git remote rename "); // TODO: add [--[no-]progress] when implemented + } + repo.rename_remote(m_old_name, m_new_name); +} + +void remote_subcommand::run_seturl(repository_wrapper& repo) +{ + if (m_remote_name.empty() || m_new_name.empty()) + { + throw std::runtime_error("remote set-url requires both name and new URL"); + } + repo.set_remote_url(m_remote_name, m_new_name, m_push_flag); +} + +void remote_subcommand::run_show(const repository_wrapper& repo) +{ + auto remotes = repo.list_remotes(); + + if (m_remote_name.empty()) + { + // Show all remotes + for (const auto& name : remotes) + { + if (m_verbose_flag) + { + auto remote = repo.find_remote(name); + auto fetch_url = remote.url(); + auto push_url = remote.pushurl(); + + if (!fetch_url.empty()) + { + std::cout << name << "\t" << fetch_url << " (fetch)" << std::endl; + } + if (!push_url.empty()) + { + std::cout << name << "\t" << push_url << " (push)" << std::endl; + } + else if (!fetch_url.empty()) + { + std::cout << name << "\t" << fetch_url << " (push)" << std::endl; + } + } + else + { + std::cout << name << std::endl; + } + } + } + else + { + // Show specific remote + auto remote = repo.find_remote(m_remote_name); + std::cout << "* remote " << m_remote_name << std::endl; + + auto fetch_url = remote.url(); + if (!fetch_url.empty()) + { + std::cout << " Fetch URL: " << fetch_url << std::endl; + } + + auto push_url = remote.pushurl(); + if (!push_url.empty()) + { + std::cout << " Push URL: " << push_url << std::endl; + } + else if (!fetch_url.empty()) + { + std::cout << " Push URL: " << fetch_url << std::endl; + } + + auto refspecs = remote.refspecs(); + if (!refspecs.empty()) + { + std::cout << " HEAD branch: (not yet implemented)" << std::endl; + for (const auto& refspec : refspecs) + { + std::cout << " " << refspec << std::endl; + } + } + } +} diff --git a/src/subcommand/remote_subcommand.hpp b/src/subcommand/remote_subcommand.hpp new file mode 100644 index 0000000..b6f3bf2 --- /dev/null +++ b/src/subcommand/remote_subcommand.hpp @@ -0,0 +1,34 @@ +#pragma once + +#include + +#include + +#include "../utils/common.hpp" +#include "../wrapper/repository_wrapper.hpp" + +class remote_subcommand +{ +public: + + explicit remote_subcommand(const libgit2_object&, CLI::App& app); + void run(); + +private: + + void run_list(const repository_wrapper& repo); + void run_add(repository_wrapper& repo); + void run_remove(repository_wrapper& repo); + void run_rename(repository_wrapper& repo); + void run_seturl(repository_wrapper& repo); + void run_show(const repository_wrapper& repo); + + CLI::App* m_subcommand = nullptr; + std::string m_operation; + std::string m_remote_name; + std::string m_url; + std::string m_old_name; + std::string m_new_name; + bool m_verbose_flag = false; + bool m_push_flag = false; +}; diff --git a/src/utils/progress.cpp b/src/utils/progress.cpp new file mode 100644 index 0000000..bdb3a23 --- /dev/null +++ b/src/utils/progress.cpp @@ -0,0 +1,155 @@ +#include +#include +#include + +#include "../utils/progress.hpp" + +int sideband_progress(const char* str, int len, void*) +{ + printf("remote: %.*s", len, str); + fflush(stdout); + return 0; +} + +int fetch_progress(const git_indexer_progress* stats, void* payload) +{ + static bool done = false; + + // We need to copy stats into payload even if the fetch is done, + // because the checkout_progress callback will be called with the + // same payload and needs the data to be up do date. + auto* pr = reinterpret_cast(payload); + *pr = *stats; + + if (done) + { + return 0; + } + + int network_percent = pr->total_objects > 0 ? + (100 * pr->received_objects / pr->total_objects) + : 0; + size_t kbytes = pr->received_bytes / 1024; + size_t mbytes = kbytes / 1024; + + std::cout << "Receiving objects: " << std::setw(4) << network_percent + << "% (" << pr->received_objects << "/" << pr->total_objects << "), "; + if (mbytes != 0) + { + std::cout << mbytes << " MiB"; + } + else if (kbytes != 0) + { + std::cout << kbytes << " KiB"; + } + else + { + std::cout << pr->received_bytes << " bytes"; + } + // TODO: compute speed + + if (pr->received_objects == pr->total_objects) + { + std::cout << ", done." << std::endl; + done = true; + } + else + { + std::cout << '\r'; + } + return 0; +} + +void checkout_progress(const char* path, size_t cur, size_t tot, void* payload) +{ + static bool done = false; + if (done) + { + return; + } + auto* pr = reinterpret_cast(payload); + int deltas_percent = pr->total_deltas > 0 ? + (100 * pr->indexed_deltas / pr->total_deltas) + : 0; + + std::cout << "Resolving deltas: " << std::setw(4) << deltas_percent + << "% (" << pr->indexed_deltas << "/" << pr->total_deltas << ")"; + if (pr->indexed_deltas == pr->total_deltas) + { + std::cout << ", done." << std::endl; + done = true; + } + else + { + std::cout << '\r'; + } +} + +int update_refs(const char* refname, const git_oid* a, const git_oid* b, git_refspec*, void*) +{ + char a_str[GIT_OID_SHA1_HEXSIZE+1], b_str[GIT_OID_SHA1_HEXSIZE+1]; + + git_oid_fmt(b_str, b); + b_str[GIT_OID_SHA1_HEXSIZE] = '\0'; + + if (git_oid_is_zero(a)) + { + std::string n, name, ref; + const size_t last_slash_idx = std::string_view(refname).find_last_of('/'); + name = std::string_view(refname).substr(last_slash_idx + 1, -1); + if (std::string_view(refname).find("remote") != std::string::npos) // maybe will string_view need the size of the string + { + n = " * [new branch] "; + auto new_refname = std::string_view(refname).substr(0, last_slash_idx - 1); + const size_t second_to_last_slash_idx = std::string_view(new_refname).find_last_of('/'); + ref = std::string_view(refname).substr(second_to_last_slash_idx + 1, -1); + } + else if (std::string_view(refname).find("tags") != std::string::npos) + { + n = " * [new tag] "; + ref = name; + } + else + { + // could it be something else ? + } + std::cout << n << name << "\t-> " << ref << std::endl; + } + else + { + git_oid_fmt(a_str, a); + a_str[GIT_OID_SHA1_HEXSIZE] = '\0'; + + std::cout << "[updated] " + << std::string(a_str, 10) + << ".." + << std::string(b_str, 10) + << " " << refname << std::endl; + } + + return 0; +} + +int push_transfer_progress(unsigned int current, unsigned int total, size_t bytes, void*) +{ + if (total > 0) + { + int percent = (100 * current) / total; + std::cout << "Writing objects: " << percent << "% (" << current + << "/" << total << "), " << bytes << " bytes\r"; + } + return 0; +} + +int push_update_reference(const char* refname, const char* status, void*) +{ + if (status) + { + std::cout << " " << refname << " " << status << std::endl; + } + else + { + std::cout << " " << refname << std::endl; + } + return 0; +} diff --git a/src/utils/progress.hpp b/src/utils/progress.hpp new file mode 100644 index 0000000..861c8d9 --- /dev/null +++ b/src/utils/progress.hpp @@ -0,0 +1,10 @@ +#pragma once + +#include + +int sideband_progress(const char* str, int len, void*); +int fetch_progress(const git_indexer_progress* stats, void* payload); +void checkout_progress(const char* path, size_t cur, size_t tot, void* payload); +int update_refs(const char* refname, const git_oid* a, const git_oid* b, git_refspec*, void*); +int push_transfer_progress(unsigned int current, unsigned int total, size_t bytes, void*); +int push_update_reference(const char* refname, const char* status, void*); diff --git a/src/wrapper/remote_wrapper.cpp b/src/wrapper/remote_wrapper.cpp new file mode 100644 index 0000000..08420ca --- /dev/null +++ b/src/wrapper/remote_wrapper.cpp @@ -0,0 +1,63 @@ +#include +#include + +#include + +#include "../utils/git_exception.hpp" +#include "../wrapper/remote_wrapper.hpp" + +remote_wrapper::remote_wrapper(git_remote* remote) + : base_type(remote) +{ +} + +remote_wrapper::~remote_wrapper() +{ + git_remote_free(p_resource); + p_resource = nullptr; +} + +std::string_view remote_wrapper::name() const +{ + const char* out = git_remote_name(*this); + return out ? std::string_view(out) : std::string_view(); +} + +std::string_view remote_wrapper::url() const +{ + const char* out = git_remote_url(*this); + return out ? std::string_view(out) : std::string_view(); +} + +std::string_view remote_wrapper::pushurl() const +{ + const char* out = git_remote_pushurl(*this); + return out ? std::string_view(out) : std::string_view(); +} + +std::vector remote_wrapper::refspecs() const +{ + git_strarray refspecs = {0}; + std::vector result; + + if (git_remote_get_fetch_refspecs(&refspecs, *this) == 0) + { + for (size_t i = 0; i < refspecs.count; ++i) + { + result.emplace_back(refspecs.strings[i]); + } + git_strarray_dispose(&refspecs); + } + + return result; +} + +void remote_wrapper::fetch(const git_strarray* refspecs, const git_fetch_options* opts, const char* reflog_message) +{ + throw_if_error(git_remote_fetch(*this, refspecs, opts, reflog_message)); +} + +void remote_wrapper::push(const git_strarray* refspecs, const git_push_options* opts) +{ + throw_if_error(git_remote_push(*this, refspecs, opts)); +} diff --git a/src/wrapper/remote_wrapper.hpp b/src/wrapper/remote_wrapper.hpp new file mode 100644 index 0000000..1fa1632 --- /dev/null +++ b/src/wrapper/remote_wrapper.hpp @@ -0,0 +1,36 @@ +#pragma once + +#include +#include +#include + +#include + +#include "../wrapper/wrapper_base.hpp" + +class remote_wrapper : public wrapper_base +{ +public: + + using base_type = wrapper_base; + + ~remote_wrapper(); + + remote_wrapper(remote_wrapper&&) = default; + remote_wrapper& operator=(remote_wrapper&&) = default; + + std::string_view name() const; + std::string_view url() const; + std::string_view pushurl() const; + + std::vector refspecs() const; + + void fetch(const git_strarray* refspecs, const git_fetch_options* opts, const char* reflog_message); + void push(const git_strarray* refspecs, const git_push_options* opts); + +private: + + explicit remote_wrapper(git_remote* remote); + + friend class repository_wrapper; +}; diff --git a/src/wrapper/repository_wrapper.cpp b/src/wrapper/repository_wrapper.cpp index fcbd365..15cdfa8 100644 --- a/src/wrapper/repository_wrapper.cpp +++ b/src/wrapper/repository_wrapper.cpp @@ -1,8 +1,12 @@ +#include + #include "../utils/git_exception.hpp" #include "../wrapper/index_wrapper.hpp" #include "../wrapper/object_wrapper.hpp" #include "../wrapper/commit_wrapper.hpp" +#include "../wrapper/remote_wrapper.hpp" #include +#include #include "../wrapper/repository_wrapper.hpp" repository_wrapper::~repository_wrapper() @@ -238,3 +242,67 @@ void repository_wrapper::checkout_tree(const object_wrapper& target, const git_c { throw_if_error(git_checkout_tree(*this, target, &opts)); } + +// Remotes + +remote_wrapper repository_wrapper::find_remote(std::string_view name) const +{ + git_remote* remote = nullptr; + throw_if_error(git_remote_lookup(&remote, *this, name.data())); + return remote_wrapper(remote); +} + +remote_wrapper repository_wrapper::create_remote(std::string_view name, std::string_view url) +{ + git_remote* remote = nullptr; + throw_if_error(git_remote_create(&remote, *this, name.data(), url.data())); + return remote_wrapper(remote); +} + +void repository_wrapper::delete_remote(std::string_view name) +{ + throw_if_error(git_remote_delete(*this, name.data())); +} + +void repository_wrapper::rename_remote(std::string_view old_name, std::string_view new_name) +{ + git_strarray problems = {0}; + int error = git_remote_rename(&problems, *this, old_name.data(), new_name.data()); + if (error != 0) + { + for (size_t i = 0; i < problems.count; ++i) + { + std::cerr << problems.strings[i] << std::endl; + } + git_strarray_dispose(&problems); + throw_if_error(error); + } + git_strarray_dispose(&problems); +} + +void repository_wrapper::set_remote_url(std::string_view name, std::string_view url, bool push) +{ + if (push) + { + throw_if_error(git_remote_set_pushurl(*this, name.data(), url.data())); + } + else + { + throw_if_error(git_remote_set_url(*this, name.data(), url.data())); + } +} + +std::vector repository_wrapper::list_remotes() const +{ + git_strarray remotes = {0}; + throw_if_error(git_remote_list(&remotes, *this)); + + std::vector result; + for (size_t i = 0; i < remotes.count; ++i) + { + result.emplace_back(remotes.strings[i]); + } + + git_strarray_dispose(&remotes); + return result; +} diff --git a/src/wrapper/repository_wrapper.hpp b/src/wrapper/repository_wrapper.hpp index 99e36ae..6b3e55a 100644 --- a/src/wrapper/repository_wrapper.hpp +++ b/src/wrapper/repository_wrapper.hpp @@ -13,6 +13,7 @@ #include "../wrapper/index_wrapper.hpp" #include "../wrapper/object_wrapper.hpp" #include "../wrapper/refs_wrapper.hpp" +#include "../wrapper/remote_wrapper.hpp" #include "../wrapper/signature_wrapper.hpp" #include "../wrapper/wrapper_base.hpp" @@ -73,6 +74,14 @@ class repository_wrapper : public wrapper_base // Trees void checkout_tree(const object_wrapper& target, const git_checkout_options opts); + // Remotes + remote_wrapper find_remote(std::string_view name) const; + remote_wrapper create_remote(std::string_view name, std::string_view url); + void delete_remote(std::string_view name); + void rename_remote(std::string_view old_name, std::string_view new_name); + void set_remote_url(std::string_view name, std::string_view url, bool push = false); + std::vector list_remotes() const; + private: repository_wrapper() = default; diff --git a/test/test_clone.py b/test/test_clone.py index 7ada28e..a28a058 100644 --- a/test/test_clone.py +++ b/test/test_clone.py @@ -5,11 +5,23 @@ def test_clone(git2cpp_path, tmp_path, run_in_tmp_path): - url = 'https://github.com/xtensor-stack/xtl.git' + url = "https://github.com/xtensor-stack/xtl.git" - clone_cmd = [git2cpp_path, 'clone', url] - p_clone = subprocess.run(clone_cmd, capture_output=True, cwd = tmp_path, text=True) + clone_cmd = [git2cpp_path, "clone", url] + p_clone = subprocess.run(clone_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_clone.returncode == 0 - assert os.path.exists(os.path.join(tmp_path, 'xtl')) - assert os.path.exists(os.path.join(tmp_path, 'xtl/include')) + assert os.path.exists(os.path.join(tmp_path, "xtl")) + assert os.path.exists(os.path.join(tmp_path, "xtl/include")) + + +def test_clone_is_bare(git2cpp_path, tmp_path, run_in_tmp_path): + url = "https://github.com/xtensor-stack/xtl.git" + + clone_cmd = [git2cpp_path, "clone", "--bare", url] + p_clone = subprocess.run(clone_cmd, capture_output=True, cwd=tmp_path, text=True) + assert p_clone.returncode == 0 + + status_cmd = [git2cpp_path, "status"] + p_status = subprocess.run(status_cmd, capture_output=True, cwd=tmp_path, text=True) + assert p_status.returncode != 0 diff --git a/test/test_remote.py b/test/test_remote.py new file mode 100644 index 0000000..56f6dc4 --- /dev/null +++ b/test/test_remote.py @@ -0,0 +1,383 @@ +import subprocess + +import pytest + +repo_url = "https://github.com/user/repo.git" + + +def test_remote_list_empty(git2cpp_path, tmp_path, run_in_tmp_path): + """Test listing remotes in a repo with no remotes.""" + # Initialize a repo + p_init = subprocess.run([git2cpp_path, "init"], capture_output=True, check=True) + assert p_init.returncode == 0 + + cmd = [git2cpp_path, "remote"] + p = subprocess.run(cmd, capture_output=True, text=True) + assert p.returncode == 0 + assert p.stdout == "" # No remotes yet + + +def test_remote_add(git2cpp_path, tmp_path, run_in_tmp_path): + """Test adding a remote.""" + p_init = subprocess.run([git2cpp_path, "init"], capture_output=True, check=True) + assert p_init.returncode == 0 + + missing_cmd = [git2cpp_path, "remote", "add", "origin"] + p_missing = subprocess.run(missing_cmd, capture_output=True, text=True) + assert p_missing.returncode != 0 + assert "usage: git remote add " in p_missing.stderr + + add_cmd = [git2cpp_path, "remote", "add", "origin", repo_url] + p_add = subprocess.run(add_cmd, capture_output=True, text=True) + assert p_add.returncode == 0 + + # Verify remote was added + list_cmd = [git2cpp_path, "remote"] + p_list = subprocess.run(list_cmd, capture_output=True, text=True) + assert p_list.returncode == 0 + assert "origin" in p_list.stdout + + +def test_remote_add_multiple(git2cpp_path, tmp_path, run_in_tmp_path): + """Test adding multiple remotes.""" + p_init = subprocess.run([git2cpp_path, "init"], capture_output=True, check=True) + assert p_init.returncode == 0 + + add_origin_cmd = [git2cpp_path, "remote", "add", "origin", repo_url] + p_add_origin = subprocess.run(add_origin_cmd, capture_output=True, check=True) + assert p_add_origin.returncode == 0 + add_upstream_cmd = [ + git2cpp_path, + "remote", + "add", + "upstream", + "https://github.com/upstream/repo.git", + ] + p_add_upstream = subprocess.run(add_upstream_cmd, capture_output=True, check=True) + assert p_add_upstream.returncode == 0 + + list_cmd = [git2cpp_path, "remote"] + p_list = subprocess.run(list_cmd, capture_output=True, text=True) + assert p_list.returncode == 0 + output = p_list.stdout.strip() + assert "origin" in output + assert "upstream" in output + + +@pytest.mark.parametrize("remove", ["rm", "remove"]) +def test_remote_remove(git2cpp_path, tmp_path, run_in_tmp_path, remove): + """Test removing a remote.""" + p_init = subprocess.run([git2cpp_path, "init"], capture_output=True, check=True) + assert p_init.returncode == 0 + + add_cmd = [git2cpp_path, "remote", "add", "origin", repo_url] + p_add = subprocess.run(add_cmd, capture_output=True, check=True) + assert p_add.returncode == 0 + + # Remove the remote + remove_cmd = [git2cpp_path, "remote", remove, "origin"] + p_remove = subprocess.run(remove_cmd, capture_output=True, text=True) + assert p_remove.returncode == 0 + + # Verify remote was removed + list_cmd = [git2cpp_path, "remote"] + p_list = subprocess.run(list_cmd, capture_output=True, text=True) + assert p_list.returncode == 0 + assert "origin" not in p_list.stdout + + +def test_remote_rename(git2cpp_path, tmp_path, run_in_tmp_path): + """Test renaming a remote.""" + p_init = subprocess.run([git2cpp_path, "init"], capture_output=True, check=True) + assert p_init.returncode == 0 + + add_cmd = [git2cpp_path, "remote", "add", "origin", repo_url] + p_add = subprocess.run(add_cmd, capture_output=True, check=True) + assert p_add.returncode == 0 + + # Rename the remote + rename_cmd = [git2cpp_path, "remote", "rename", "origin", "upstream"] + p_rename = subprocess.run(rename_cmd, capture_output=True, text=True) + assert p_rename.returncode == 0 + + # Verify remote was renamed + list_cmd = [git2cpp_path, "remote"] + p_list = subprocess.run(list_cmd, capture_output=True, text=True) + assert p_list.returncode == 0 + assert "origin" not in p_list.stdout + assert "upstream" in p_list.stdout + + +def test_remote_set_url(git2cpp_path, tmp_path, run_in_tmp_path): + """Test setting remote URL.""" + p_init = subprocess.run([git2cpp_path, "init"], capture_output=True, check=True) + assert p_init.returncode == 0 + + add_cmd = [git2cpp_path, "remote", "add", "origin", repo_url] + p_add = subprocess.run(add_cmd, capture_output=True, check=True) + assert p_add.returncode == 0 + + # Change the URL + new_url = "https://github.com/user/newrepo.git" + set_url_cmd = [git2cpp_path, "remote", "set-url", "origin", new_url] + p_set_url = subprocess.run(set_url_cmd, capture_output=True, text=True) + assert p_set_url.returncode == 0 + + # Verify URL was changed + show_cmd = [git2cpp_path, "remote", "show", "origin"] + p_show = subprocess.run(show_cmd, capture_output=True, text=True) + assert p_show.returncode == 0 + assert new_url in p_show.stdout + + +def test_remote_set_push_url(git2cpp_path, tmp_path, run_in_tmp_path): + """Test setting remote push URL.""" + p_init = subprocess.run([git2cpp_path, "init"], capture_output=True, check=True) + assert p_init.returncode == 0 + + subprocess.run( + [git2cpp_path, "remote", "add", "origin", repo_url], + capture_output=True, + check=True, + ) + + # Set push URL + push_url = "https://github.com/user/pushrepo.git" + cmd = [git2cpp_path, "remote", "set-url", "--push", "origin", push_url] + p = subprocess.run(cmd, capture_output=True, text=True) + assert p.returncode == 0 + + # Verify push URL was set + show_cmd = [git2cpp_path, "remote", "show", "origin"] + p_show = subprocess.run(show_cmd, capture_output=True, text=True) + assert p_show.returncode == 0 + assert push_url in p_show.stdout + + +def test_remote_show(git2cpp_path, tmp_path, run_in_tmp_path): + """Test showing remote details.""" + p_init = subprocess.run([git2cpp_path, "init"], capture_output=True, check=True) + assert p_init.returncode == 0 + + subprocess.run( + [git2cpp_path, "remote", "add", "origin", repo_url], + capture_output=True, + check=True, + ) + + cmd = [git2cpp_path, "remote", "show", "origin"] + p = subprocess.run(cmd, capture_output=True, text=True) + assert p.returncode == 0 + assert "origin" in p.stdout + assert repo_url in p.stdout + + +def test_remote_show_verbose(git2cpp_path, tmp_path, run_in_tmp_path): + """Test showing remotes with verbose flag.""" + p_init = subprocess.run([git2cpp_path, "init"], capture_output=True, check=True) + assert p_init.returncode == 0 + + subprocess.run( + [git2cpp_path, "remote", "add", "origin", repo_url], + capture_output=True, + check=True, + ) + + cmd = [git2cpp_path, "remote", "-v"] + p = subprocess.run(cmd, capture_output=True, text=True) + assert p.returncode == 0 + assert "origin" in p.stdout + assert repo_url in p.stdout + assert "(fetch)" in p.stdout or "(push)" in p.stdout + + +def test_remote_show_all_verbose(git2cpp_path, tmp_path, run_in_tmp_path): + """Test showing all remotes with verbose flag.""" + p_init = subprocess.run([git2cpp_path, "init"], capture_output=True, check=True) + assert p_init.returncode == 0 + + add_origin_cmd = [git2cpp_path, "remote", "add", "origin", repo_url] + p_add_origin = subprocess.run(add_origin_cmd, capture_output=True, check=True) + assert p_add_origin.returncode == 0 + add_upstream_cmd = [ + git2cpp_path, + "remote", + "add", + "upstream", + "https://github.com/upstream/repo.git", + ] + p_add_upstream = subprocess.run(add_upstream_cmd, capture_output=True, check=True) + assert p_add_upstream.returncode == 0 + + show_cmd = [git2cpp_path, "remote", "show", "-v"] + p_show = subprocess.run(show_cmd, capture_output=True, text=True) + assert p_show.returncode == 0 + assert "origin" in p_show.stdout + assert "upstream" in p_show.stdout + + +def test_remote_error_on_duplicate_add(git2cpp_path, tmp_path, run_in_tmp_path): + """Test error when adding duplicate remote.""" + p_init = subprocess.run([git2cpp_path, "init"], capture_output=True, check=True) + assert p_init.returncode == 0 + + add_cmd = [git2cpp_path, "remote", "add", "origin", repo_url] + p_add = subprocess.run(add_cmd, capture_output=True, check=True) + assert p_add.returncode == 0 + + # Try to add duplicate + add_dup_cmd = [ + git2cpp_path, + "remote", + "add", + "origin", + "https://github.com/user/other.git", + ] + p_add_dup = subprocess.run(add_dup_cmd, capture_output=True, text=True) + assert p_add_dup.returncode != 0 + + +def test_remote_error_on_remove_nonexistent(git2cpp_path, tmp_path, run_in_tmp_path): + """Test error when removing non-existent remote.""" + p_init = subprocess.run([git2cpp_path, "init"], capture_output=True, check=True) + assert p_init.returncode == 0 + + cmd = [git2cpp_path, "remote", "remove", "nonexistent"] + p = subprocess.run(cmd, capture_output=True, text=True) + assert p.returncode != 0 + + +def test_remote_error_on_rename_nonexistent(git2cpp_path, tmp_path, run_in_tmp_path): + """Test error when renaming non-existent remote.""" + p_init = subprocess.run([git2cpp_path, "init"], capture_output=True, check=True) + assert p_init.returncode == 0 + + cmd = [git2cpp_path, "remote", "rename", "nonexistent", "new"] + p = subprocess.run(cmd, capture_output=True, text=True) + assert p.returncode != 0 + + +def test_remote_error_on_show_nonexistent(git2cpp_path, tmp_path, run_in_tmp_path): + """Test error when showing non-existent remote.""" + p_init = subprocess.run([git2cpp_path, "init"], capture_output=True, check=True) + assert p_init.returncode == 0 + + cmd = [git2cpp_path, "remote", "show", "nonexistent"] + p = subprocess.run(cmd, capture_output=True, text=True) + assert p.returncode != 0 + + +@pytest.fixture +def repo_with_remote(git2cpp_path, tmp_path, run_in_tmp_path): + """Fixture that creates a repo with a remote pointing to a local bare repo.""" + # Create a bare repo to use as remote + remote_path = tmp_path / "remote_repo" + remote_path.mkdir() + init_cmd = [git2cpp_path, "init", "--bare", str(remote_path)] + p_init = subprocess.run(init_cmd, capture_output=True, check=True) + assert p_init.returncode == 0 + + # Create a regular repo + local_path = tmp_path / "local_repo" + local_path.mkdir() + + # Initialize repo in the directory + p_init_2 = subprocess.run( + [git2cpp_path, "init"], capture_output=True, check=True, cwd=local_path + ) + assert p_init_2.returncode == 0 + + # Add remote + add_cmd = [git2cpp_path, "remote", "add", "origin", str(remote_path)] + p_add = subprocess.run(add_cmd, capture_output=True, check=True, cwd=local_path) + assert p_add.returncode == 0 + + return local_path, remote_path + + +def test_fetch_from_remote(git2cpp_path, repo_with_remote): + """Test fetching from a remote.""" + local_path, remote_path = repo_with_remote + + # Note: This is a bare repo with no refs, so fetch will fail gracefully + # For now, just test that fetch command runs (it will fail gracefully if no refs) + cmd = [git2cpp_path, "fetch", "origin"] + p = subprocess.run(cmd, capture_output=True, text=True, cwd=local_path) + # Fetch might succeed (empty) or fail (no refs), but shouldn't crash + assert p.returncode in [0, 1] # 0 for success, 1 for no refs/error + + +def test_fetch_default_origin(git2cpp_path, repo_with_remote): + """Test fetching with default origin.""" + local_path, remote_path = repo_with_remote + + cmd = [git2cpp_path, "fetch"] + p = subprocess.run(cmd, capture_output=True, text=True, cwd=local_path) + # Fetch might succeed (empty) or fail (no refs), but shouldn't crash + assert p.returncode in [0, 1] + + +def test_remote_in_cloned_repo(xtl_clone, git2cpp_path, tmp_path): + """Test that cloned repos have remotes configured.""" + assert (tmp_path / "xtl").exists() + xtl_path = tmp_path / "xtl" + + cmd = [git2cpp_path, "remote"] + p = subprocess.run(cmd, capture_output=True, cwd=xtl_path, text=True) + assert p.returncode == 0 + assert "origin" in p.stdout + + +def test_remote_show_in_cloned_repo(xtl_clone, git2cpp_path, tmp_path): + """Test showing remote in cloned repo.""" + assert (tmp_path / "xtl").exists() + xtl_path = tmp_path / "xtl" + + cmd = [git2cpp_path, "remote", "show", "origin"] + p = subprocess.run(cmd, capture_output=True, cwd=xtl_path, text=True) + assert p.returncode == 0 + assert "origin" in p.stdout + # Should contain URL information + assert "http" in p.stdout or "git" in p.stdout or "https" in p.stdout + + +def test_push_local(xtl_clone, git_config, git2cpp_path, tmp_path, monkeypatch): + """Test setting push on a local remote.""" + assert (tmp_path / "xtl").exists() + xtl_path = tmp_path / "xtl" + + checkout_cmd = [git2cpp_path, "checkout", "-b", "foregone"] + p_checkout = subprocess.run( + checkout_cmd, capture_output=True, check=True, cwd=xtl_path + ) + assert p_checkout.returncode == 0 + + p = xtl_path / "mook_file.txt" + p.write_text("") + + cmd_add = [git2cpp_path, "add", "mook_file.txt"] + p_add = subprocess.run(cmd_add, cwd=xtl_path, text=True) + assert p_add.returncode == 0 + + cmd_commit = [git2cpp_path, "commit", "-m", "test commit"] + p_commit = subprocess.run(cmd_commit, cwd=xtl_path, text=True) + assert p_commit.returncode == 0 + + url = "https://github.com/xtensor-stack/xtl.git" + local_path = tmp_path / "local_repo" + clone_cmd = [git2cpp_path, "clone", "--bare", url, local_path] + p_clone = subprocess.run(clone_cmd, capture_output=True, cwd=tmp_path, text=True) + assert p_clone.returncode == 0 + + add_cmd = [git2cpp_path, "remote", "add", "local_repo", str(local_path)] + p_add = subprocess.run(add_cmd, capture_output=True, check=True, cwd=xtl_path) + assert p_add.returncode == 0 + + cmd_push = [git2cpp_path, "push", "local_repo"] # "foregone" + p_push = subprocess.run(cmd_push, capture_output=True, check=True, cwd=xtl_path) + assert p_push.returncode == 0 + + list_cmd = [git2cpp_path, "branch"] + p_list = subprocess.run(list_cmd, capture_output=True, cwd=local_path, text=True) + assert p_list.returncode == 0 + assert "foregone" in p_list.stdout From 6510681cf86800dc865a5af1eae3d246ce576a83 Mon Sep 17 00:00:00 2001 From: Johan Mabille Date: Fri, 12 Dec 2025 10:48:28 +0100 Subject: [PATCH 04/15] Implemented rev-parse -is-bare-repository (#63) --- CMakeLists.txt | 2 ++ src/main.cpp | 2 ++ src/subcommand/revparse_subcommand.cpp | 28 ++++++++++++++++++++++++++ src/subcommand/revparse_subcommand.hpp | 18 +++++++++++++++++ src/wrapper/repository_wrapper.cpp | 5 +++++ src/wrapper/repository_wrapper.hpp | 2 ++ test/test_revparse.py | 16 +++++++++++++++ 7 files changed, 73 insertions(+) create mode 100644 src/subcommand/revparse_subcommand.cpp create mode 100644 src/subcommand/revparse_subcommand.hpp create mode 100644 test/test_revparse.py diff --git a/CMakeLists.txt b/CMakeLists.txt index a9a266b..1165dbd 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -64,6 +64,8 @@ set(GIT2CPP_SRC ${GIT2CPP_SOURCE_DIR}/subcommand/remote_subcommand.hpp ${GIT2CPP_SOURCE_DIR}/subcommand/reset_subcommand.cpp ${GIT2CPP_SOURCE_DIR}/subcommand/reset_subcommand.hpp + ${GIT2CPP_SOURCE_DIR}/subcommand/revparse_subcommand.cpp + ${GIT2CPP_SOURCE_DIR}/subcommand/revparse_subcommand.hpp ${GIT2CPP_SOURCE_DIR}/subcommand/status_subcommand.cpp ${GIT2CPP_SOURCE_DIR}/subcommand/status_subcommand.hpp ${GIT2CPP_SOURCE_DIR}/utils/ansi_code.cpp diff --git a/src/main.cpp b/src/main.cpp index 7b52301..bbdbcef 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -18,6 +18,7 @@ #include "subcommand/remote_subcommand.hpp" #include "subcommand/reset_subcommand.hpp" #include "subcommand/status_subcommand.hpp" +#include "subcommand/revparse_subcommand.hpp" int main(int argc, char** argv) { @@ -44,6 +45,7 @@ int main(int argc, char** argv) merge_subcommand merge(lg2_obj, app); push_subcommand push(lg2_obj, app); remote_subcommand remote(lg2_obj, app); + revparse_subcommand rev(lg2_obj, app); app.require_subcommand(/* min */ 0, /* max */ 1); diff --git a/src/subcommand/revparse_subcommand.cpp b/src/subcommand/revparse_subcommand.cpp new file mode 100644 index 0000000..0d58f15 --- /dev/null +++ b/src/subcommand/revparse_subcommand.cpp @@ -0,0 +1,28 @@ +#include "revparse_subcommand.hpp" +#include "../wrapper/repository_wrapper.hpp" +#include +#include + +revparse_subcommand::revparse_subcommand(const libgit2_object&, CLI::App& app) +{ + auto* sub = app.add_subcommand("rev-parse", "Pick out and message parameters"); + + sub->add_flag("--is-bare-repository", m_is_bare_repository_flag); + + sub->callback([this]() { this->run(); }); +} + +void revparse_subcommand::run() +{ + auto directory = get_current_git_path(); + auto repo = repository_wrapper::open(directory); + + if (m_is_bare_repository_flag) + { + std::cout << std::boolalpha << repo.is_bare() << std::endl; + } + else + { + std::cout << "revparse only supports --is-bare-repository for now" << std::endl; + } +} diff --git a/src/subcommand/revparse_subcommand.hpp b/src/subcommand/revparse_subcommand.hpp new file mode 100644 index 0000000..baf430a --- /dev/null +++ b/src/subcommand/revparse_subcommand.hpp @@ -0,0 +1,18 @@ +#pragma once + +#include + +#include "../utils/common.hpp" + +class revparse_subcommand +{ +public: + + explicit revparse_subcommand(const libgit2_object&, CLI::App& app); + void run(); + +private: + + bool m_is_bare_repository_flag = false; +}; + diff --git a/src/wrapper/repository_wrapper.cpp b/src/wrapper/repository_wrapper.cpp index 15cdfa8..93aae24 100644 --- a/src/wrapper/repository_wrapper.cpp +++ b/src/wrapper/repository_wrapper.cpp @@ -46,6 +46,11 @@ void repository_wrapper::state_cleanup() throw_if_error(git_repository_state_cleanup(*this)); } +bool repository_wrapper::is_bare() const +{ + return git_repository_is_bare(*this); +} + // References reference_wrapper repository_wrapper::head() const diff --git a/src/wrapper/repository_wrapper.hpp b/src/wrapper/repository_wrapper.hpp index 6b3e55a..5cce074 100644 --- a/src/wrapper/repository_wrapper.hpp +++ b/src/wrapper/repository_wrapper.hpp @@ -33,6 +33,8 @@ class repository_wrapper : public wrapper_base git_repository_state_t state() const; void state_cleanup(); + bool is_bare() const; + // References reference_wrapper head() const; reference_wrapper find_reference(std::string_view ref_name) const; diff --git a/test/test_revparse.py b/test/test_revparse.py new file mode 100644 index 0000000..ea11240 --- /dev/null +++ b/test/test_revparse.py @@ -0,0 +1,16 @@ +import subprocess + +import pytest + +def test_revparse(git2cpp_path, tmp_path, run_in_tmp_path): + # tmp_path exists and is empty. + assert list(tmp_path.iterdir()) == [] + + cmd = [git2cpp_path, 'init', '--bare'] + p = subprocess.run(cmd, cwd = tmp_path) + + cmd2 = [git2cpp_path, 'rev-parse', '--is-bare-repository'] + p2 = subprocess.run(cmd2, capture_output=True, text=True, cwd = tmp_path) + + assert p2.returncode == 0 + assert p2.stdout == 'true\n' From 28cb7a9ab86147e7221740c336143773c1c3f186 Mon Sep 17 00:00:00 2001 From: Johan Mabille Date: Fri, 12 Dec 2025 16:43:34 +0100 Subject: [PATCH 05/15] Fixed status and branch command on newly created repos (#67) * Fixed status and branch command on newly created repos * Fixed typo --- src/subcommand/branch_subcommand.cpp | 2 +- src/subcommand/status_subcommand.cpp | 16 +--------------- src/wrapper/repository_wrapper.cpp | 27 ++++++++++++++++++++++++++- src/wrapper/repository_wrapper.hpp | 6 +++++- test/test_branch.py | 12 ++++++++++++ test/test_status.py | 12 ++++++++++++ 6 files changed, 57 insertions(+), 18 deletions(-) diff --git a/src/subcommand/branch_subcommand.cpp b/src/subcommand/branch_subcommand.cpp index c321fa8..82decf1 100644 --- a/src/subcommand/branch_subcommand.cpp +++ b/src/subcommand/branch_subcommand.cpp @@ -39,7 +39,7 @@ void branch_subcommand::run() void branch_subcommand::run_list(const repository_wrapper& repo) { - auto head_name = repo.head().short_name(); + auto head_name = repo.head_short_name(); git_branch_t type = m_all_flag ? GIT_BRANCH_ALL : (m_remote_flag ? GIT_BRANCH_REMOTE : GIT_BRANCH_LOCAL); auto iter = repo.iterate_branches(type); auto br = iter.next(); diff --git a/src/subcommand/status_subcommand.cpp b/src/subcommand/status_subcommand.cpp index 36aa213..9e98840 100644 --- a/src/subcommand/status_subcommand.cpp +++ b/src/subcommand/status_subcommand.cpp @@ -167,7 +167,7 @@ void status_subcommand::run() auto directory = get_current_git_path(); auto repo = repository_wrapper::open(directory); auto sl = status_list_wrapper::status_list(repo); - auto branch_name = repo.head().short_name(); + auto branch_name = repo.head_short_name(); std::set tracked_dir_set{}; std::set untracked_dir_set{}; @@ -276,18 +276,4 @@ void status_subcommand::run() { std::cout << treeclean_message << std::endl; } - - // if (sl.has_ignored_header()) - // { - // stream_colour_fn colour = termcolor::red; - // if (is_long) - // { - // std::cout << ignored_header; - // } - // print_not_tracked(get_entries_to_print(GIT_STATUS_IGNORED, sl, false, of), tracked_dir_set, untracked_dir_set, is_long, colour); - // if (is_long) - // { - // std::cout << std::endl; - // } - // } } diff --git a/src/wrapper/repository_wrapper.cpp b/src/wrapper/repository_wrapper.cpp index 93aae24..d58bb4d 100644 --- a/src/wrapper/repository_wrapper.cpp +++ b/src/wrapper/repository_wrapper.cpp @@ -51,7 +51,12 @@ bool repository_wrapper::is_bare() const return git_repository_is_bare(*this); } -// References +// Head + +bool repository_wrapper::is_head_unborn() const +{ + return git_repository_head_unborn(*this) == 1; +} reference_wrapper repository_wrapper::head() const { @@ -60,6 +65,26 @@ reference_wrapper repository_wrapper::head() const return reference_wrapper(ref); } +std::string repository_wrapper::head_short_name() const +{ + git_reference* ref; + std::string name; + throw_if_error(git_reference_lookup(&ref, *this, "HEAD")); + if (git_reference_type(ref) == GIT_REFERENCE_DIRECT) + { + name = git_reference_shorthand(ref); + } + else + { + name = git_reference_symbolic_target(ref); + name = name.substr(name.find_last_of('/') + 1); + } + git_reference_free(ref); + return name; +} + +// References + reference_wrapper repository_wrapper::find_reference(std::string_view ref_name) const { git_reference* ref; diff --git a/src/wrapper/repository_wrapper.hpp b/src/wrapper/repository_wrapper.hpp index 5cce074..c117e8a 100644 --- a/src/wrapper/repository_wrapper.hpp +++ b/src/wrapper/repository_wrapper.hpp @@ -35,8 +35,12 @@ class repository_wrapper : public wrapper_base bool is_bare() const; - // References + // Head + bool is_head_unborn() const; reference_wrapper head() const; + std::string head_short_name() const; + + // References reference_wrapper find_reference(std::string_view ref_name) const; std::optional find_reference_dwim(std::string_view ref_name) const; diff --git a/test/test_branch.py b/test/test_branch.py index f81bc8c..20c1149 100644 --- a/test/test_branch.py +++ b/test/test_branch.py @@ -38,3 +38,15 @@ def test_branch_nogit(git2cpp_path, tmp_path): cmd = [git2cpp_path, 'branch'] p = subprocess.run(cmd, capture_output=True, cwd=tmp_path, text=True) assert p.returncode != 0 + +def test_branch_new_repo(git2cpp_path, tmp_path, run_in_tmp_path): + # tmp_path exists and is empty. + assert list(tmp_path.iterdir()) == [] + + cmd = [git2cpp_path, 'init'] + p = subprocess.run(cmd, cwd = tmp_path) + + branch_cmd = [git2cpp_path, 'branch'] + p_branch = subprocess.run(branch_cmd, cwd = tmp_path) + + assert p_branch.returncode == 0 diff --git a/test/test_status.py b/test/test_status.py index ab7288e..6e97e19 100644 --- a/test/test_status.py +++ b/test/test_status.py @@ -76,3 +76,15 @@ def test_status_add_file(xtl_clone, git2cpp_path, tmp_path, short_flag, long_fla elif short_flag in ["-s", "--short"]: assert "A " in p_status.stdout assert "D " in p_status.stdout + +def test_status_new_repo(git2cpp_path, tmp_path, run_in_tmp_path): + # tmp_path exists and is empty. + assert list(tmp_path.iterdir()) == [] + + cmd = [git2cpp_path, 'init'] + p = subprocess.run(cmd, cwd = tmp_path) + + status_cmd = [git2cpp_path, 'status'] + p_status = subprocess.run(status_cmd, cwd = tmp_path) + + assert p_status.returncode == 0 From 1cd32b6854990cba817455a83602994d7847adc0 Mon Sep 17 00:00:00 2001 From: Sandrine Pataut Date: Fri, 26 Dec 2025 11:28:38 +0100 Subject: [PATCH 06/15] Add shallow clone (#68) --- src/subcommand/clone_subcommand.cpp | 6 ++++ src/subcommand/clone_subcommand.hpp | 3 ++ src/subcommand/fetch_subcommand.cpp | 23 ++++++++++++ src/subcommand/fetch_subcommand.hpp | 9 +++-- src/subcommand/revparse_subcommand.cpp | 7 +++- src/subcommand/revparse_subcommand.hpp | 2 +- src/wrapper/repository_wrapper.cpp | 9 +++-- src/wrapper/repository_wrapper.hpp | 1 + test/test_clone.py | 20 ++++++++--- test/test_remote.py | 48 ++++++++++++++++++++++++++ test/test_revparse.py | 27 +++++++++++---- 11 files changed, 139 insertions(+), 16 deletions(-) diff --git a/src/subcommand/clone_subcommand.cpp b/src/subcommand/clone_subcommand.cpp index 69b44fa..d660ae1 100644 --- a/src/subcommand/clone_subcommand.cpp +++ b/src/subcommand/clone_subcommand.cpp @@ -11,6 +11,9 @@ clone_subcommand::clone_subcommand(const libgit2_object&, CLI::App& app) sub->add_option("", m_repository, "The (possibly remote) repository to clone from.")->required(); sub->add_option("", m_directory, "The name of a new directory to clone into."); + sub->add_option("--depth", m_depth, "Create a shallow clone of that depth."); + // sub->add_option("--shallow-since", m_shallow_since, "