From 6ef5d649f4dd3241dbfc30f65870562544c58697 Mon Sep 17 00:00:00 2001 From: Julien Jerphanion Date: Mon, 24 Nov 2025 21:40:20 +0100 Subject: [PATCH 1/2] 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 --- CMakeLists.txt | 8 + src/main.cpp | 6 + src/subcommand/fetch_subcommand.cpp | 121 +++++++++++ src/subcommand/fetch_subcommand.hpp | 23 +++ src/subcommand/push_subcommand.cpp | 72 +++++++ src/subcommand/push_subcommand.hpp | 25 +++ src/subcommand/remote_subcommand.cpp | 216 ++++++++++++++++++++ src/subcommand/remote_subcommand.hpp | 35 ++++ src/wrapper/remote_wrapper.cpp | 54 +++++ src/wrapper/remote_wrapper.hpp | 35 ++++ src/wrapper/repository_wrapper.cpp | 68 +++++++ src/wrapper/repository_wrapper.hpp | 9 + test/test_remote.py | 293 +++++++++++++++++++++++++++ 13 files changed, 965 insertions(+) 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/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..d0c7c08 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 @@ -82,6 +88,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/fetch_subcommand.cpp b/src/subcommand/fetch_subcommand.cpp new file mode 100644 index 0000000..6944165 --- /dev/null +++ b/src/subcommand/fetch_subcommand.cpp @@ -0,0 +1,121 @@ +#include +#include + +#include + +#include "../subcommand/fetch_subcommand.hpp" +#include "../wrapper/repository_wrapper.hpp" +#include "../wrapper/remote_wrapper.hpp" +#include "../utils/output.hpp" +#include "../utils/git_exception.hpp" + +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; + + 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; + } + + 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)) + { + printf("[new] %.20s %s\n", b_str, refname); + } + else + { + git_oid_fmt(a_str, a); + a_str[GIT_OID_SHA1_HEXSIZE] = '\0'; + printf("[updated] %.10s..%.10s %s\n", a_str, b_str, refname); + } + + return 0; + } +} + +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 + throw_if_error(git_remote_fetch(remote, 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..e83eb83 --- /dev/null +++ b/src/subcommand/fetch_subcommand.hpp @@ -0,0 +1,23 @@ +#pragma once + +#include + +#include + +#include "../utils/common.hpp" +#include "../wrapper/repository_wrapper.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..1f3795c --- /dev/null +++ b/src/subcommand/push_subcommand.cpp @@ -0,0 +1,72 @@ +#include + +#include + +#include "../subcommand/push_subcommand.hpp" +#include "../wrapper/repository_wrapper.hpp" +#include "../wrapper/remote_wrapper.hpp" +#include "../utils/git_exception.hpp" +#include "../utils/common.hpp" + +namespace +{ + 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; + } +} + +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; + + git_strarray_wrapper refspecs_wrapper(m_refspecs); + git_strarray* refspecs_ptr = nullptr; + if (!m_refspecs.empty()) + { + refspecs_ptr = refspecs_wrapper; + } + + throw_if_error(git_remote_push(remote, 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..626bb4a --- /dev/null +++ b/src/subcommand/push_subcommand.hpp @@ -0,0 +1,25 @@ +#pragma once + +#include +#include + +#include + +#include "../utils/common.hpp" +#include "../wrapper/repository_wrapper.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..4ed24ab --- /dev/null +++ b/src/subcommand/remote_subcommand.cpp @@ -0,0 +1,216 @@ +#include +#include + +#include "../subcommand/remote_subcommand.hpp" +#include "../wrapper/repository_wrapper.hpp" +#include "../utils/git_exception.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" && extras.size() >= 2) + { + m_remote_name = extras[0]; + m_url = extras[1]; + run_add(repo); + } + else if ((m_operation == "remove" || m_operation == "rm") && extras.size() >= 1) + { + m_remote_name = extras[0]; + run_remove(repo); + } + else if (m_operation == "rename" && 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); + } + else + { + // Fallback to using option values if extras not available + if (m_operation == "add") + { + run_add(repo); + } + else if (m_operation == "remove" || m_operation == "rm") + { + run_remove(repo); + } + else if (m_operation == "rename") + { + run_rename(repo); + } + else if (m_operation == "set-url") + { + run_seturl(repo); + } + } +} + +void remote_subcommand::run_list(const repository_wrapper& repo) +{ + auto remotes = repo.list_remotes(); + for (const auto& name : remotes) + { + std::cout << name << std::endl; + } +} + +void remote_subcommand::run_add(repository_wrapper& repo) +{ + if (m_remote_name.empty() || m_url.empty()) + { + throw std::runtime_error("remote add requires both name and URL"); + } + 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("remote remove requires a name"); + } + repo.delete_remote(m_remote_name); +} + +void remote_subcommand::run_rename(repository_wrapper& repo) +{ + if (m_old_name.empty() || m_new_name.empty()) + { + throw std::runtime_error("remote rename requires both old and new names"); + } + 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..9b8ea35 --- /dev/null +++ b/src/subcommand/remote_subcommand.hpp @@ -0,0 +1,35 @@ +#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/wrapper/remote_wrapper.cpp b/src/wrapper/remote_wrapper.cpp new file mode 100644 index 0000000..c06aee7 --- /dev/null +++ b/src/wrapper/remote_wrapper.cpp @@ -0,0 +1,54 @@ +#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; +} + diff --git a/src/wrapper/remote_wrapper.hpp b/src/wrapper/remote_wrapper.hpp new file mode 100644 index 0000000..962dd85 --- /dev/null +++ b/src/wrapper/remote_wrapper.hpp @@ -0,0 +1,35 @@ +#pragma once + +#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; + +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_remote.py b/test/test_remote.py new file mode 100644 index 0000000..187c3ee --- /dev/null +++ b/test/test_remote.py @@ -0,0 +1,293 @@ +import os +import subprocess +import pytest +from pathlib import Path + + +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 + subprocess.run([git2cpp_path, 'init'], capture_output=True, check=True) + + 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.""" + subprocess.run([git2cpp_path, 'init'], capture_output=True, check=True) + + cmd = [git2cpp_path, 'remote', 'add', 'origin', 'https://github.com/user/repo.git'] + p = subprocess.run(cmd, capture_output=True, text=True) + assert p.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.""" + subprocess.run([git2cpp_path, 'init'], capture_output=True, check=True) + + subprocess.run([git2cpp_path, 'remote', 'add', 'origin', 'https://github.com/user/repo.git'], + capture_output=True, check=True) + subprocess.run([git2cpp_path, 'remote', 'add', 'upstream', 'https://github.com/upstream/repo.git'], + capture_output=True, check=True) + + 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 + + +def test_remote_remove(git2cpp_path, tmp_path, run_in_tmp_path): + """Test removing a remote.""" + subprocess.run([git2cpp_path, 'init'], capture_output=True, check=True) + subprocess.run([git2cpp_path, 'remote', 'add', 'origin', 'https://github.com/user/repo.git'], + capture_output=True, check=True) + + # Remove the remote + cmd = [git2cpp_path, 'remote', 'remove', 'origin'] + p = subprocess.run(cmd, capture_output=True, text=True) + assert p.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_remove_rm_alias(git2cpp_path, tmp_path, run_in_tmp_path): + """Test removing a remote using 'rm' alias.""" + subprocess.run([git2cpp_path, 'init'], capture_output=True, check=True) + subprocess.run([git2cpp_path, 'remote', 'add', 'origin', 'https://github.com/user/repo.git'], + capture_output=True, check=True) + + # Remove using 'rm' alias + cmd = [git2cpp_path, 'remote', 'rm', 'origin'] + p = subprocess.run(cmd, capture_output=True, text=True) + assert p.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.""" + subprocess.run([git2cpp_path, 'init'], capture_output=True, check=True) + subprocess.run([git2cpp_path, 'remote', 'add', 'origin', 'https://github.com/user/repo.git'], + capture_output=True, check=True) + + # Rename the remote + cmd = [git2cpp_path, 'remote', 'rename', 'origin', 'upstream'] + p = subprocess.run(cmd, capture_output=True, text=True) + assert p.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.""" + subprocess.run([git2cpp_path, 'init'], capture_output=True, check=True) + subprocess.run([git2cpp_path, 'remote', 'add', 'origin', 'https://github.com/user/repo.git'], + capture_output=True, check=True) + + # Change the URL + new_url = 'https://github.com/user/newrepo.git' + cmd = [git2cpp_path, 'remote', 'set-url', 'origin', new_url] + p = subprocess.run(cmd, capture_output=True, text=True) + assert p.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.""" + subprocess.run([git2cpp_path, 'init'], capture_output=True, check=True) + subprocess.run([git2cpp_path, 'remote', 'add', 'origin', 'https://github.com/user/repo.git'], + 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.""" + subprocess.run([git2cpp_path, 'init'], capture_output=True, check=True) + url = 'https://github.com/user/repo.git' + subprocess.run([git2cpp_path, 'remote', 'add', 'origin', 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 url in p.stdout + + +def test_remote_show_verbose(git2cpp_path, tmp_path, run_in_tmp_path): + """Test showing remotes with verbose flag.""" + subprocess.run([git2cpp_path, 'init'], capture_output=True, check=True) + url = 'https://github.com/user/repo.git' + subprocess.run([git2cpp_path, 'remote', 'add', 'origin', 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 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.""" + subprocess.run([git2cpp_path, 'init'], capture_output=True, check=True) + subprocess.run([git2cpp_path, 'remote', 'add', 'origin', 'https://github.com/user/repo.git'], + capture_output=True, check=True) + subprocess.run([git2cpp_path, 'remote', 'add', 'upstream', 'https://github.com/upstream/repo.git'], + capture_output=True, check=True) + + cmd = [git2cpp_path, 'remote', 'show', '-v'] + p = subprocess.run(cmd, capture_output=True, text=True) + assert p.returncode == 0 + assert 'origin' in p.stdout + assert 'upstream' in p.stdout + + +def test_remote_error_on_duplicate_add(git2cpp_path, tmp_path, run_in_tmp_path): + """Test error when adding duplicate remote.""" + subprocess.run([git2cpp_path, 'init'], capture_output=True, check=True) + subprocess.run([git2cpp_path, 'remote', 'add', 'origin', 'https://github.com/user/repo.git'], + capture_output=True, check=True) + + # Try to add duplicate + cmd = [git2cpp_path, 'remote', 'add', 'origin', 'https://github.com/user/other.git'] + p = subprocess.run(cmd, capture_output=True, text=True) + assert p.returncode != 0 + + +def test_remote_error_on_remove_nonexistent(git2cpp_path, tmp_path, run_in_tmp_path): + """Test error when removing non-existent remote.""" + subprocess.run([git2cpp_path, 'init'], capture_output=True, check=True) + + 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.""" + subprocess.run([git2cpp_path, 'init'], capture_output=True, check=True) + + 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.""" + subprocess.run([git2cpp_path, 'init'], capture_output=True, check=True) + + 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() + subprocess.run([git2cpp_path, 'init', '--bare', str(remote_path)], + capture_output=True, check=True) + + # Create a regular repo + repo_path = tmp_path / "local_repo" + repo_path.mkdir() + + # Initialize repo in the directory + subprocess.run([git2cpp_path, 'init'], capture_output=True, check=True, cwd=repo_path) + + # Add remote + subprocess.run([git2cpp_path, 'remote', 'add', 'origin', str(remote_path)], + capture_output=True, check=True, cwd=repo_path) + + return repo_path, remote_path + + +def test_fetch_from_remote(git2cpp_path, repo_with_remote): + """Test fetching from a remote.""" + repo_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=repo_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.""" + repo_path, remote_path = repo_with_remote + + cmd = [git2cpp_path, 'fetch'] + p = subprocess.run(cmd, capture_output=True, text=True, cwd=repo_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 + From b724fee32c08c3d900c05f5b1c8860367397a8e1 Mon Sep 17 00:00:00 2001 From: Julien Jerphanion Date: Mon, 1 Dec 2025 14:20:17 +0100 Subject: [PATCH 2/2] Address review comments Signed-off-by: Julien Jerphanion Co-authored-by: Johan Mabille --- src/subcommand/fetch_subcommand.cpp | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/src/subcommand/fetch_subcommand.cpp b/src/subcommand/fetch_subcommand.cpp index 6944165..afe8638 100644 --- a/src/subcommand/fetch_subcommand.cpp +++ b/src/subcommand/fetch_subcommand.cpp @@ -13,14 +13,14 @@ namespace { int sideband_progress(const char* str, int len, void*) { - printf("remote: %.*s", len, str); - fflush(stdout); + std::cout << "remote: " << std::string(str, static_cast(len)); + std::cout.flush(); return 0; } int fetch_progress(const git_indexer_progress* stats, void* payload) { - static bool done = false; + bool done = false; auto* pr = reinterpret_cast(payload); *pr = *stats; @@ -60,13 +60,20 @@ namespace if (git_oid_is_zero(a)) { - printf("[new] %.20s %s\n", b_str, refname); + std::cout << "[new] " + << std::string(b_str, 20) + << " " << refname << std::endl; } else { git_oid_fmt(a_str, a); a_str[GIT_OID_SHA1_HEXSIZE] = '\0'; - printf("[updated] %.10s..%.10s %s\n", a_str, b_str, refname); + + std::cout << "[updated] " + << std::string(a_str, 10) + << ".." + << std::string(b_str, 10) + << " " << refname << std::endl; } return 0;