From 601a97419b8020a26ff6ee2b1d6293575929a1c4 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 31 Mar 2026 08:53:42 +0100 Subject: [PATCH 01/23] [pre-commit.ci] pre-commit autoupdate (#135) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/pre-commit/mirrors-clang-format: v22.1.1 → v22.1.2](https://github.com/pre-commit/mirrors-clang-format/compare/v22.1.1...v22.1.2) - [github.com/astral-sh/ruff-pre-commit: v0.15.7 → v0.15.8](https://github.com/astral-sh/ruff-pre-commit/compare/v0.15.7...v0.15.8) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6389e1a..df97f0b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -17,14 +17,14 @@ repos: args: ['--py312-plus'] - repo: https://github.com/pre-commit/mirrors-clang-format - rev: v22.1.1 + rev: v22.1.2 hooks: - id: clang-format args: [--style=file] exclude_types: [javascript,json] - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.15.7 + rev: v0.15.8 hooks: - id: ruff-check args: [--fix] From f759ddfb380359dc55bf78a5447f2267d333bca3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 31 Mar 2026 08:54:07 +0100 Subject: [PATCH 02/23] Bump the actions group with 3 updates (#136) Bumps the actions group with 3 updates: [mamba-org/setup-micromamba](https://github.com/mamba-org/setup-micromamba), [actions/deploy-pages](https://github.com/actions/deploy-pages) and [codecov/codecov-action](https://github.com/codecov/codecov-action). Updates `mamba-org/setup-micromamba` from 2 to 3 - [Release notes](https://github.com/mamba-org/setup-micromamba/releases) - [Commits](https://github.com/mamba-org/setup-micromamba/compare/v2...v3) Updates `actions/deploy-pages` from 4 to 5 - [Release notes](https://github.com/actions/deploy-pages/releases) - [Commits](https://github.com/actions/deploy-pages/compare/v4...v5) Updates `codecov/codecov-action` from 5 to 6 - [Release notes](https://github.com/codecov/codecov-action/releases) - [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/codecov/codecov-action/compare/v5...v6) --- updated-dependencies: - dependency-name: mamba-org/setup-micromamba dependency-version: '3' dependency-type: direct:production update-type: version-update:semver-major dependency-group: actions - dependency-name: actions/deploy-pages dependency-version: '5' dependency-type: direct:production update-type: version-update:semver-major dependency-group: actions - dependency-name: codecov/codecov-action dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major dependency-group: actions ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/deploy-wasm.yml | 4 ++-- .github/workflows/test-wasm.yml | 2 +- .github/workflows/test.yml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/deploy-wasm.yml b/.github/workflows/deploy-wasm.yml index 94a4de7..6f3b71f 100644 --- a/.github/workflows/deploy-wasm.yml +++ b/.github/workflows/deploy-wasm.yml @@ -24,7 +24,7 @@ jobs: python-version: '3.14' - name: Install mamba - uses: mamba-org/setup-micromamba@v2 + uses: mamba-org/setup-micromamba@v3 with: environment-file: wasm/wasm-environment.yml cache-environment: true @@ -56,4 +56,4 @@ jobs: steps: - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v4 + uses: actions/deploy-pages@v5 diff --git a/.github/workflows/test-wasm.yml b/.github/workflows/test-wasm.yml index 5d3052f..bf928be 100644 --- a/.github/workflows/test-wasm.yml +++ b/.github/workflows/test-wasm.yml @@ -21,7 +21,7 @@ jobs: python-version: '3.14' - name: Install mamba - uses: mamba-org/setup-micromamba@v2 + uses: mamba-org/setup-micromamba@v3 with: environment-file: wasm/wasm-environment.yml cache-environment: true diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 37adfb0..a3764f6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -97,7 +97,7 @@ jobs: path: outdir - name: Upload coverage to codecov - uses: codecov/codecov-action@v5 + uses: codecov/codecov-action@v6 with: files: coverage.lcov token: ${{ secrets.CODECOV_TOKEN }} From f2e1dfbd82c3a2c028d2216851745e6eb711f7d1 Mon Sep 17 00:00:00 2001 From: Sandrine Pataut Date: Thu, 2 Apr 2026 11:36:42 +0200 Subject: [PATCH 03/23] small fix (#137) --- src/wrapper/repository_wrapper.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wrapper/repository_wrapper.cpp b/src/wrapper/repository_wrapper.cpp index ccc0408..08bd7ec 100644 --- a/src/wrapper/repository_wrapper.cpp +++ b/src/wrapper/repository_wrapper.cpp @@ -426,7 +426,7 @@ size_t repository_wrapper::shallow_depth_from_head() const if (parent_list.size() > 0u) { has_parent = true; - for (size_t j = 0u; parent_list.size(); j++) + for (size_t j = 0u; j < parent_list.size(); ++j) { const commit_wrapper& c = parent_list[j]; temp_commits_list.push_back(std::move(const_cast(c))); From 8611d96315976768ad23cc0e6898774fcba9321c Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 7 Apr 2026 11:03:41 +0200 Subject: [PATCH 04/23] [pre-commit.ci] pre-commit autoupdate (#138) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/astral-sh/ruff-pre-commit: v0.15.8 → v0.15.9](https://github.com/astral-sh/ruff-pre-commit/compare/v0.15.8...v0.15.9) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index df97f0b..a3994ff 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -24,7 +24,7 @@ repos: exclude_types: [javascript,json] - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.15.8 + rev: v0.15.9 hooks: - id: ruff-check args: [--fix] From 19f47f73813c106666c98ba73b11d00aa2071096 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 14 Apr 2026 07:09:24 +0100 Subject: [PATCH 05/23] [pre-commit.ci] pre-commit autoupdate (#139) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/pre-commit/mirrors-clang-format: v22.1.2 → v22.1.3](https://github.com/pre-commit/mirrors-clang-format/compare/v22.1.2...v22.1.3) - [github.com/astral-sh/ruff-pre-commit: v0.15.9 → v0.15.10](https://github.com/astral-sh/ruff-pre-commit/compare/v0.15.9...v0.15.10) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a3994ff..d984109 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -17,14 +17,14 @@ repos: args: ['--py312-plus'] - repo: https://github.com/pre-commit/mirrors-clang-format - rev: v22.1.2 + rev: v22.1.3 hooks: - id: clang-format args: [--style=file] exclude_types: [javascript,json] - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.15.9 + rev: v0.15.10 hooks: - id: ruff-check args: [--fix] From ad032f01d814d5a8619f99f975d73a04cef4fe28 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 14 Apr 2026 07:10:38 +0100 Subject: [PATCH 06/23] Bump actions/upload-pages-artifact from 4 to 5 in the actions group (#140) Bumps the actions group with 1 update: [actions/upload-pages-artifact](https://github.com/actions/upload-pages-artifact). Updates `actions/upload-pages-artifact` from 4 to 5 - [Release notes](https://github.com/actions/upload-pages-artifact/releases) - [Commits](https://github.com/actions/upload-pages-artifact/compare/v4...v5) --- updated-dependencies: - dependency-name: actions/upload-pages-artifact dependency-version: '5' dependency-type: direct:production update-type: version-update:semver-major dependency-group: actions ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/deploy-wasm.yml | 2 +- .github/workflows/test-wasm.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/deploy-wasm.yml b/.github/workflows/deploy-wasm.yml index 6f3b71f..afb0c0e 100644 --- a/.github/workflows/deploy-wasm.yml +++ b/.github/workflows/deploy-wasm.yml @@ -37,7 +37,7 @@ jobs: make - name: Upload artifact - uses: actions/upload-pages-artifact@v4 + uses: actions/upload-pages-artifact@v5 with: path: ./wasm/serve/dist diff --git a/.github/workflows/test-wasm.yml b/.github/workflows/test-wasm.yml index bf928be..edb158a 100644 --- a/.github/workflows/test-wasm.yml +++ b/.github/workflows/test-wasm.yml @@ -41,7 +41,7 @@ jobs: npx playwright install chromium - name: Upload artifact containing emscripten-forge package - uses: actions/upload-pages-artifact@v4 + uses: actions/upload-pages-artifact@v5 with: path: ./wasm/recipe/em-forge-recipes/output/ From d4e011b05b8ece73e698fe63321eb5f4af361ce9 Mon Sep 17 00:00:00 2001 From: Ian Thomas Date: Fri, 17 Apr 2026 15:38:44 +0100 Subject: [PATCH 07/23] Fix cursor showing and hiding (#141) * Fix cursor showing and hiding * Remove unwanted include --- src/utils/input_output.cpp | 24 ++++++++++++++++++++++-- src/utils/input_output.hpp | 6 ++++-- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/src/utils/input_output.cpp b/src/utils/input_output.cpp index 9e0ac45..21a0578 100644 --- a/src/utils/input_output.cpp +++ b/src/utils/input_output.cpp @@ -1,19 +1,39 @@ #include "input_output.hpp" +#include + #include "ansi_code.hpp" // OS-specific libraries. #include +unsigned int cursor_hider::s_scope_count = 0; + cursor_hider::cursor_hider(bool hide /* = true */) : m_hide(hide) { - std::cout << (m_hide ? ansi_code::hide_cursor : ansi_code::show_cursor); + s_scope_count++; + write_ansi_code(m_hide); } cursor_hider::~cursor_hider() { - std::cout << (m_hide ? ansi_code::show_cursor : ansi_code::hide_cursor); + s_scope_count--; + + if (s_scope_count == 0) + { + // Ensure cursor is visible when git2cpp exits. + write_ansi_code(false); + } + else + { + write_ansi_code(!m_hide); + } +} + +void cursor_hider::write_ansi_code(bool hide) +{ + std::cout << (hide ? ansi_code::hide_cursor : ansi_code::show_cursor); } alternative_buffer::alternative_buffer() diff --git a/src/utils/input_output.hpp b/src/utils/input_output.hpp index bb11ae1..3e56e55 100644 --- a/src/utils/input_output.hpp +++ b/src/utils/input_output.hpp @@ -1,7 +1,5 @@ #pragma once -#include - #include "common.hpp" // OS-specific libraries. @@ -22,7 +20,11 @@ class cursor_hider : noncopyable_nonmovable private: + void write_ansi_code(bool hide); + bool m_hide; + + static unsigned int s_scope_count; }; // Scope object to use alternative output buffer for From 22db9ed09a5e628fe7e89e44b29f9f8e9e1eb4f5 Mon Sep 17 00:00:00 2001 From: Sandrine Pataut Date: Fri, 17 Apr 2026 17:13:00 +0200 Subject: [PATCH 08/23] Add branches to push subcommand (#126) * Add branches to push subcommand * refactoring * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * address review comments --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- src/subcommand/push_subcommand.cpp | 196 +++++++++++++++++++++++--- src/subcommand/push_subcommand.hpp | 4 + src/subcommand/revlist_subcommand.cpp | 5 +- src/utils/ansi_code.hpp | 3 + src/utils/common.cpp | 8 ++ src/utils/common.hpp | 2 + src/utils/progress.cpp | 18 +-- src/wrapper/remote_wrapper.cpp | 2 - src/wrapper/remote_wrapper.hpp | 1 - src/wrapper/repository_wrapper.cpp | 35 +++++ src/wrapper/repository_wrapper.hpp | 4 +- test/test_push.py | 180 ++++++++++++++++++++++- 12 files changed, 418 insertions(+), 40 deletions(-) diff --git a/src/subcommand/push_subcommand.cpp b/src/subcommand/push_subcommand.cpp index e5ad44f..5a1243c 100644 --- a/src/subcommand/push_subcommand.cpp +++ b/src/subcommand/push_subcommand.cpp @@ -1,21 +1,29 @@ #include "../subcommand/push_subcommand.hpp" #include +#include +#include #include +#include "../utils/ansi_code.hpp" +#include "../utils/common.hpp" #include "../utils/credentials.hpp" #include "../utils/progress.hpp" #include "../wasm/scope.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->add_option("", m_refspecs, "The refspec(s) to push")->expected(0, -1); + sub->add_flag( + "--all,--branches", + m_branches_flag, + "Push all branches (i.e. refs under " + ansi_code::bold + "refs/heads/" + ansi_code::reset + + "); cannot be used with other ." + ); sub->callback( [this]() @@ -25,6 +33,131 @@ push_subcommand::push_subcommand(const libgit2_object&, CLI::App& app) ); } +void push_subcommand::fill_refspec(repository_wrapper& repo) +{ + const std::string prefix = std::string("refs/heads/"); + if (m_branches_flag) + { + auto iter = repo.iterate_branches(GIT_BRANCH_LOCAL); + auto br = iter.next(); + while (br) + { + std::string refspec = std::string(br->name()); + if (refspec.starts_with(prefix)) + { + refspec = refspec.substr(prefix.size()); + } + m_refspecs.push_back(refspec); + br = iter.next(); + } + } + else if (m_refspecs.empty()) + { + std::string branch; + try + { + auto head_ref = repo.head(); + branch = head_ref.short_name(); + } + catch (...) + { + std::cerr << "Could not determine current branch to push." << std::endl; + return; + } + m_refspecs.push_back(branch); + } + else + { + for (auto& refspec : m_refspecs) + { + if (refspec.starts_with(prefix)) + { + refspec = refspec.substr(prefix.size()); + } + } + } +} + +std::unordered_map get_remotes(repository_wrapper& repo, std::string remote_name) +{ + std::vector repo_refs = repo.refs_list(); + std::unordered_map remotes_oids; + const std::string prefix = std::string("refs/remotes/") + remote_name + "/"; + for (const auto& r : repo_refs) + { + if (r.size() > prefix.size() && r.compare(0, prefix.size(), prefix) == 0) + { + // r is like "refs/remotes/origin/main" + std::string short_name = r.substr(prefix.size()); // "main" or "feature/x" + + git_oid oid = repo.ref_name_to_id(r); + remotes_oids.emplace(short_name, oid); + } + } + return remotes_oids; +} + +std::unordered_map diff_branches( + std::unordered_map remotes_before_push, + std::unordered_map remotes_after_push +) +{ + std::unordered_map new_branches; + for (const auto& br : remotes_after_push) + { + const std::string name = br.first; + const git_oid& oid = br.second; + if (!remotes_before_push.contains(name)) + { + new_branches.emplace(name, oid); + } + } + return new_branches; +} + +std::pair, std::vector> +split_refspecs(std::vector refspecs, std::unordered_map new_branches) +{ + std::vector new_pushed_refspecs; + std::vector existing_refspecs; + + for (const auto refspec : refspecs) + { + if (!new_branches.contains(refspec)) + { + existing_refspecs.push_back(refspec); + } + else + { + new_pushed_refspecs.push_back(refspec); + } + } + + return std::make_pair(new_pushed_refspecs, existing_refspecs); +} + +std::pair +get_branch_names(repository_wrapper& repo, std::string remote_name, std::string refspec) +{ + std::optional upstream_opt = repo.branch_upstream_name(refspec); + std::string remote_branch = refspec; + if (upstream_opt.has_value()) + { + const std::string up_name = upstream_opt.value(); + auto pos = up_name.find('/'); + if (pos != std::string::npos && pos + 1 < up_name.size()) + { + std::string up_remote = up_name.substr(0, pos); + std::string up_branch = up_name.substr(pos + 1); + if (up_remote == remote_name) + { + remote_branch = up_branch; + } + } + } + return std::make_pair(refspec, remote_branch); +} + void push_subcommand::run() { wasm_http_transport_scope transport; // Enables wasm http(s) transport. @@ -40,25 +173,52 @@ void push_subcommand::run() push_opts.callbacks.push_transfer_progress = push_transfer_progress; push_opts.callbacks.push_update_reference = push_update_reference; - if (m_refspecs.empty()) + fill_refspec(repo); + std::vector refspecs_push; + for (auto refspec : m_refspecs) { - try + refspecs_push.push_back("refs/heads/" + refspec); + } + git_strarray_wrapper refspecs_wrapper(refspecs_push); + git_strarray* refspecs_ptr = refspecs_wrapper; + + auto remotes_before_push = get_remotes(repo, remote_name); + + remote.push(refspecs_ptr, &push_opts); + + auto remotes_after_push = get_remotes(repo, remote_name); + + auto new_branches = diff_branches(remotes_before_push, remotes_after_push); + auto [new_pushed_refspecs, existing_refspecs] = split_refspecs(m_refspecs, new_branches); + + std::cout << "To " << remote.url() << std::endl; + for (const auto& refspec : new_pushed_refspecs) + { + auto [local_short_name, remote_branch] = get_branch_names(repo, remote_name, refspec); + + std::cout << " * [new branch] " << local_short_name << " -> " << remote_branch << std::endl; + } + + for (const auto& refspec : existing_refspecs) + { + auto [local_short_name, remote_branch] = get_branch_names(repo, remote_name, refspec); + + git_oid remote_oid = remotes_before_push[refspec]; + + git_oid local_oid; + if (auto ref_opt = repo.find_reference_dwim(("refs/heads/" + local_short_name))) { - 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); + const git_oid* target = ref_opt->target(); + local_oid = *target; } - catch (...) + + if (!git_oid_equal(&remote_oid, &local_oid)) { - std::cerr << "Could not determine current branch to push." << std::endl; - return; + std::string old_hex = oid_to_hex(remote_oid); + std::string new_hex = oid_to_hex(local_oid); + // TODO: check order of hex codes + std::cout << " " << old_hex.substr(0, 7) << ".." << new_hex.substr(0, 7) << " " + << local_short_name << " -> " << remote_branch << std::endl; } } - 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 index 07c301e..f759058 100644 --- a/src/subcommand/push_subcommand.hpp +++ b/src/subcommand/push_subcommand.hpp @@ -6,6 +6,7 @@ #include #include "../utils/common.hpp" +#include "../wrapper/repository_wrapper.hpp" class push_subcommand { @@ -14,8 +15,11 @@ class push_subcommand explicit push_subcommand(const libgit2_object&, CLI::App& app); void run(); + void fill_refspec(repository_wrapper& repo); + private: std::string m_remote_name; std::vector m_refspecs; + bool m_branches_flag = false; }; diff --git a/src/subcommand/revlist_subcommand.cpp b/src/subcommand/revlist_subcommand.cpp index bd2fe55..a713eeb 100644 --- a/src/subcommand/revlist_subcommand.cpp +++ b/src/subcommand/revlist_subcommand.cpp @@ -42,12 +42,9 @@ void revlist_subcommand::run() std::size_t i = 0; git_oid commit_oid; - char buf[GIT_OID_SHA1_HEXSIZE + 1]; while (!walker.next(commit_oid) && i < m_max_count_flag) { - git_oid_fmt(buf, &commit_oid); - buf[GIT_OID_SHA1_HEXSIZE] = '\0'; - std::cout << buf << std::endl; + std::cout << oid_to_hex(commit_oid) << std::endl; ++i; } } diff --git a/src/utils/ansi_code.hpp b/src/utils/ansi_code.hpp index 90b1e25..becc5a9 100644 --- a/src/utils/ansi_code.hpp +++ b/src/utils/ansi_code.hpp @@ -19,6 +19,9 @@ namespace ansi_code const std::string hide_cursor = "\e[?25l"; const std::string show_cursor = "\e[?25h"; + const std::string bold = "\033[1m"; + const std::string reset = "\033[0m"; + // Functions. std::string cursor_to_row(size_t row); diff --git a/src/utils/common.cpp b/src/utils/common.cpp index 9bf7f84..4bb7d32 100644 --- a/src/utils/common.cpp +++ b/src/utils/common.cpp @@ -142,3 +142,11 @@ std::string trim(const std::string& str) auto s = std::regex_replace(str, std::regex("^\\s+"), ""); return std::regex_replace(s, std::regex("\\s+$"), ""); } + +std::string oid_to_hex(const git_oid& oid) +{ + char oid_str[GIT_OID_SHA1_HEXSIZE + 1]; + git_oid_fmt(oid_str, &oid); + oid_str[GIT_OID_SHA1_HEXSIZE] = '\0'; + return std::string(oid_str); +} diff --git a/src/utils/common.hpp b/src/utils/common.hpp index d9059f2..afb0d88 100644 --- a/src/utils/common.hpp +++ b/src/utils/common.hpp @@ -79,3 +79,5 @@ std::vector split_input_at_newlines(std::string_view str); // Remove whitespace from start and end of a string. std::string trim(const std::string& str); + +std::string oid_to_hex(const git_oid& oid); diff --git a/src/utils/progress.cpp b/src/utils/progress.cpp index 12b7c63..c6c93ad 100644 --- a/src/utils/progress.cpp +++ b/src/utils/progress.cpp @@ -4,6 +4,8 @@ #include #include +#include "../utils/common.hpp" + int sideband_progress(const char* str, int len, void*) { printf("remote: %.*s", len, str); @@ -83,10 +85,7 @@ 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*) { - 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'; + std::string b_str = oid_to_hex(*b); if (git_oid_is_zero(a)) { @@ -114,8 +113,7 @@ int update_refs(const char* refname, const git_oid* a, const git_oid* b, git_ref } else { - git_oid_fmt(a_str, a); - a_str[GIT_OID_SHA1_HEXSIZE] = '\0'; + std::string a_str = oid_to_hex(*a); std::cout << "[updated] " << std::string(a_str, 10) << ".." << std::string(b_str, 10) << " " << refname << std::endl; @@ -139,11 +137,9 @@ 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; + std::cout << " ! [remote rejected] " << refname << " (" << status << ")" << std::endl; + return -1; } + return 0; } diff --git a/src/wrapper/remote_wrapper.cpp b/src/wrapper/remote_wrapper.cpp index 3f603dd..7cde488 100644 --- a/src/wrapper/remote_wrapper.cpp +++ b/src/wrapper/remote_wrapper.cpp @@ -3,8 +3,6 @@ #include #include -#include - #include "../utils/git_exception.hpp" remote_wrapper::remote_wrapper(git_remote* remote) diff --git a/src/wrapper/remote_wrapper.hpp b/src/wrapper/remote_wrapper.hpp index a933fb8..56b3202 100644 --- a/src/wrapper/remote_wrapper.hpp +++ b/src/wrapper/remote_wrapper.hpp @@ -4,7 +4,6 @@ #include #include -#include #include "../wrapper/wrapper_base.hpp" diff --git a/src/wrapper/repository_wrapper.cpp b/src/wrapper/repository_wrapper.cpp index 08bd7ec..bf57486 100644 --- a/src/wrapper/repository_wrapper.cpp +++ b/src/wrapper/repository_wrapper.cpp @@ -134,6 +134,26 @@ std::optional repository_wrapper::find_reference_dwim(std::st return rc == 0 ? std::make_optional(reference_wrapper(ref)) : std::nullopt; } +std::vector repository_wrapper::refs_list() const +{ + git_strarray refs = {0}; + throw_if_error(git_reference_list(&refs, *this)); + std::vector result; + for (size_t i = 0; i < refs.count; ++i) + { + result.push_back(refs.strings[i]); + } + git_strarray_free(&refs); + return result; +} + +const git_oid repository_wrapper::ref_name_to_id(std::string ref_name) const +{ + git_oid ref_id; + throw_if_error(git_reference_name_to_id(&ref_id, *this, ref_name.c_str())); + return ref_id; +} + // Index index_wrapper repository_wrapper::make_index() @@ -194,6 +214,21 @@ std::optional repository_wrapper::upstream() const } } +std::optional repository_wrapper::branch_upstream_name(std::string local_branch) const +{ + git_buf buf = GIT_BUF_INIT; + int error = git_branch_upstream_name(&buf, *this, local_branch.c_str()); + if (error != 0) + { + git_buf_dispose(&buf); + return std::nullopt; + } + + std::string result(buf.ptr ? buf.ptr : ""); + git_buf_dispose(&buf); + return result; +} + branch_tracking_info repository_wrapper::get_tracking_info() const { branch_tracking_info info; diff --git a/src/wrapper/repository_wrapper.hpp b/src/wrapper/repository_wrapper.hpp index d630343..f365401 100644 --- a/src/wrapper/repository_wrapper.hpp +++ b/src/wrapper/repository_wrapper.hpp @@ -6,7 +6,6 @@ #include -#include "../utils/common.hpp" #include "../utils/git_exception.hpp" #include "../wrapper/annotated_commit_wrapper.hpp" #include "../wrapper/branch_wrapper.hpp" @@ -63,6 +62,8 @@ class repository_wrapper : public wrapper_base // References reference_wrapper find_reference(std::string_view ref_name) const; std::optional find_reference_dwim(std::string_view ref_name) const; + std::vector refs_list() const; + const git_oid ref_name_to_id(std::string ref_name) const; // Index index_wrapper make_index(); @@ -74,6 +75,7 @@ class repository_wrapper : public wrapper_base branch_wrapper find_branch(std::string_view name) const; branch_iterator iterate_branches(git_branch_t type) const; std::optional upstream() const; + std::optional branch_upstream_name(std::string local_branch) const; branch_tracking_info get_tracking_info() const; // Commits diff --git a/test/test_push.py b/test/test_push.py index 03f4fb7..3540acc 100644 --- a/test/test_push.py +++ b/test/test_push.py @@ -1,3 +1,4 @@ +import re import subprocess from uuid import uuid4 @@ -12,7 +13,7 @@ def test_push_private_repo( # that to the remote. username = "abc" # Can be any non-empty string. password = private_test_repo["token"] - input = f"{username}\n{password}\n" + input = f"{username}\n{password}" repo_path = tmp_path / private_test_repo["repo_name"] url = private_test_repo["https_url"] @@ -55,10 +56,183 @@ def test_push_private_repo( assert p_log.stdout.count("This is my commit message") == 1 # push with incorrect credentials to check it fails, then with correct to check it works. - input = f"${username}\ndef\n{username}\n{password}\n" + input = f"${username}\ndef\n{username}\n{password}" push_cmd = [git2cpp_path, "push", "origin"] p_push = subprocess.run(push_cmd, cwd=repo_path, capture_output=True, text=True, input=input) assert p_push.returncode == 0 assert p_push.stdout.count("Username:") == 2 assert p_push.stdout.count("Password:") == 2 - assert "Pushed to origin" in p_push.stdout + assert " * [new branch] test-" in p_push.stdout + + +def test_push_branch_private_repo( + git2cpp_path, tmp_path, run_in_tmp_path, private_test_repo, commit_env_config +): + """Test push with an explicit branch name: git2cpp push .""" + branch_name = f"test-{uuid4()}" + + username = "abc" + password = private_test_repo["token"] + input = f"{username}\n{password}" + repo_path = tmp_path / private_test_repo["repo_name"] + url = private_test_repo["https_url"] + + # Clone the private repo. + clone_cmd = [git2cpp_path, "clone", url] + p_clone = subprocess.run(clone_cmd, capture_output=True, text=True, input=input) + assert p_clone.returncode == 0 + assert repo_path.exists() + + # Create a new branch and commit on it. + checkout_cmd = [git2cpp_path, "checkout", "-b", branch_name] + p_checkout = subprocess.run(checkout_cmd, cwd=repo_path, capture_output=True, text=True) + assert p_checkout.returncode == 0 + + (repo_path / "push_branch_file.txt").write_text("push branch test") + subprocess.run([git2cpp_path, "add", "push_branch_file.txt"], cwd=repo_path, check=True) + subprocess.run([git2cpp_path, "commit", "-m", "branch commit"], cwd=repo_path, check=True) + + # Switch back to main so HEAD is NOT on the branch we want to push. + subprocess.run( + [git2cpp_path, "checkout", "main"], capture_output=True, check=True, cwd=repo_path + ) + + status_cmd = [git2cpp_path, "status"] + p_status = subprocess.run(status_cmd, cwd=repo_path, capture_output=True, text=True) + assert p_status.returncode == 0 + assert "On branch main" in p_status.stdout + + # Push specifying the branch explicitly (HEAD is on main, not the test branch). + input = f"{username}\n{password}" + push_cmd = [git2cpp_path, "push", "origin", branch_name] + p_push = subprocess.run(push_cmd, cwd=repo_path, capture_output=True, text=True, input=input) + assert p_push.returncode == 0 + assert " * [new branch] test-" in p_push.stdout + + +def test_push_updates_existing_branch_private_repo( + git2cpp_path, tmp_path, run_in_tmp_path, private_test_repo, commit_env_config +): + """Create a branch on remote, then update it locally and push — expect old..new output.""" + branch_name = f"test-update-{uuid4()}" + + username = "abc" + password = private_test_repo["token"] + creds = f"{username}\n{password}" + repo_path = tmp_path / private_test_repo["repo_name"] + url = private_test_repo["https_url"] + + # Clone the private repo. + p_clone = subprocess.run( + [git2cpp_path, "clone", url], capture_output=True, text=True, input=creds + ) + assert p_clone.returncode == 0 + assert repo_path.exists() + + # Create the branch locally and push it to the remote (first push: new branch) + subprocess.run([git2cpp_path, "checkout", "-b", branch_name], cwd=repo_path, check=True) + (repo_path / "file_for_branch.txt").write_text("initial content\n") + subprocess.run([git2cpp_path, "add", "file_for_branch.txt"], cwd=repo_path, check=True) + subprocess.run( + [git2cpp_path, "commit", "-m", "create branch commit"], cwd=repo_path, check=True + ) + + # first push creates the branch on remote + p_push1 = subprocess.run( + [git2cpp_path, "push", "origin", branch_name], + cwd=repo_path, + capture_output=True, + text=True, + input=creds, + ) + assert p_push1.returncode == 0 + assert " * [new branch]" in p_push1.stdout # first push creates branch + + # IMPORTANT: fetch to update local remote-tracking refs (refs/remotes/origin/...) + p_fetch = subprocess.run( + [git2cpp_path, "fetch", "origin"], + cwd=repo_path, + capture_output=True, + text=True, + input=creds, + ) + assert p_fetch.returncode == 0, ( + f"fetch failed: stdout={p_fetch.stdout!r} stderr={p_fetch.stderr!r}" + ) + + # Now make another commit on the same branch and push again — this should be an update. + (repo_path / "file_for_branch.txt").write_text("modified content\n") + subprocess.run([git2cpp_path, "add", "file_for_branch.txt"], cwd=repo_path, check=True) + subprocess.run( + [git2cpp_path, "commit", "-m", "update branch commit"], cwd=repo_path, check=True + ) + + p_push2 = subprocess.run( + [git2cpp_path, "push", "origin", branch_name], + cwd=repo_path, + capture_output=True, + text=True, + input=creds, + ) + assert p_push2.returncode == 0 + + # Match old..new short-hex and branch short name + pattern = re.compile( + r"[0-9a-f]{7}\.\.[0-9a-f]{7}\s+" + + re.escape(branch_name) + + r"\s+->\s+" + + re.escape(branch_name), + re.IGNORECASE, + ) + assert pattern.search(p_push2.stdout), ( + f"expected old..new line for branch in push output, got:\n{p_push2.stdout}" + ) + + +def test_push_branches_flag_private_repo( + git2cpp_path, tmp_path, run_in_tmp_path, private_test_repo, commit_env_config +): + """Test push --branches pushes all local branches.""" + branch_a = f"test-a-{uuid4()}" + branch_b = f"test-b-{uuid4()}" + + username = "abc" + password = private_test_repo["token"] + input = f"{username}\n{password}" + repo_path = tmp_path / private_test_repo["repo_name"] + url = private_test_repo["https_url"] + + # Clone the private repo. + clone_cmd = [git2cpp_path, "clone", url] + p_clone = subprocess.run(clone_cmd, capture_output=True, text=True, input=input) + assert p_clone.returncode == 0 + assert repo_path.exists() + + # Create two extra branches with commits. + for branch_name in [branch_a, branch_b]: + subprocess.run( + [git2cpp_path, "checkout", "-b", branch_name], + capture_output=True, + check=True, + cwd=repo_path, + ) + (repo_path / f"{branch_name}.txt").write_text(f"content for {branch_name}") + subprocess.run([git2cpp_path, "add", f"{branch_name}.txt"], cwd=repo_path, check=True) + subprocess.run( + [git2cpp_path, "commit", "-m", f"commit on {branch_name}"], + cwd=repo_path, + check=True, + ) + + # Go back to main. + subprocess.run( + [git2cpp_path, "checkout", "main"], capture_output=True, check=True, cwd=repo_path + ) + + # Push all branches at once. + input = f"{username}\n{password}" + push_cmd = [git2cpp_path, "push", "origin", "--branches"] + p_push = subprocess.run(push_cmd, cwd=repo_path, capture_output=True, text=True, input=input) + assert p_push.returncode == 0 + assert p_push.stdout.count(" * [new branch] test-") == 2 + assert "main" not in p_push.stdout From 85b406fa99c4ac2a6e983adb86b4d089625247a7 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 21 Apr 2026 07:25:53 +0100 Subject: [PATCH 09/23] [pre-commit.ci] pre-commit autoupdate (#143) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/astral-sh/ruff-pre-commit: v0.15.10 → v0.15.11](https://github.com/astral-sh/ruff-pre-commit/compare/v0.15.10...v0.15.11) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d984109..53e9bc2 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -24,7 +24,7 @@ repos: exclude_types: [javascript,json] - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.15.10 + rev: v0.15.11 hooks: - id: ruff-check args: [--fix] From 5ec6e88e7552303215b02ee38a8e58dff1aa711a Mon Sep 17 00:00:00 2001 From: Ian Thomas Date: Wed, 22 Apr 2026 17:18:40 +0100 Subject: [PATCH 10/23] Better error messages for wasm network errors (#145) --- src/wasm/response.cpp | 12 ++++++ src/wasm/response.hpp | 2 + src/wasm/stream.cpp | 87 ++++++++++++++++++++++++++++--------------- 3 files changed, 71 insertions(+), 30 deletions(-) diff --git a/src/wasm/response.cpp b/src/wasm/response.cpp index 35a9433..8ba0b1b 100644 --- a/src/wasm/response.cpp +++ b/src/wasm/response.cpp @@ -71,4 +71,16 @@ bool wasm_http_response::has_header_starts_with(const std::string& key, std::str return false; } +void wasm_http_response::set_git_error(std::string_view url) const +{ + std::string extra = m_status_text.empty() ? "" : " (" + m_status_text + ")"; + git_error_set( + GIT_ERROR_HTTP, + "unexpected HTTP response %d%s to request %s, see the browser console and/or network tab for more details", + m_status, + extra.c_str(), + url.data() + ); +} + #endif // EMSCRIPTEN diff --git a/src/wasm/response.hpp b/src/wasm/response.hpp index cb3c735..42b31c3 100644 --- a/src/wasm/response.hpp +++ b/src/wasm/response.hpp @@ -27,6 +27,8 @@ class wasm_http_response bool has_header_starts_with(const std::string& key, std::string_view start) const; + void set_git_error(std::string_view url) const; + char* m_buffer; // Not owned. size_t m_buffer_size; size_t* m_bytes_read; // Not owned. diff --git a/src/wasm/stream.cpp b/src/wasm/stream.cpp index db69766..4474834 100644 --- a/src/wasm/stream.cpp +++ b/src/wasm/stream.cpp @@ -31,10 +31,17 @@ EM_JS(void, js_delete_request, (int request_index), { // Return the latest error string set in JS. Caller must delete the returned const char*. EM_JS(const char*, js_get_error, (void), { - // clang-format off - const err = Module["git2cpp_js_error"] ?? ""; - // clang-format on - return stringToNewUTF8(err); + const err = Module["git2cpp_js_error"]; + if (!err) + { + return stringToNewUTF8("unknown error"); + } + let msg = err.message; + if (err.name) + { + msg = err.name + ": " + msg; + } + return stringToNewUTF8(msg); }); EM_JS( @@ -90,7 +97,9 @@ EM_JS( catch (err) { // Store error for later retrieval - Module["git2cpp_js_error"] = String(err); + // clang-format off + Module["git2cpp_js_error"] = { name: err.name ?? "", message : err.message ?? "" }; + // clang-format on console.error(err); return -1; } @@ -190,7 +199,9 @@ EM_JS( catch (err) { // Store error for later retrieval - Module["git2cpp_js_error"] = String(err); + // clang-format off + Module["git2cpp_js_error"] = { name: err.name ?? "", message : err.message ?? "" }; + // clang-format on console.error(err); return -1; } @@ -225,7 +236,9 @@ EM_JS(size_t, js_write, (int request_index, const char* buffer, size_t buffer_si catch (err) { // Store error for later retrieval - Module["git2cpp_js_error"] = String(err); + // clang-format off + Module["git2cpp_js_error"] = { name: err.name ?? "", message : err.message ?? "" }; + // clang-format on console.error(err); return -1; } @@ -242,11 +255,22 @@ static std::string base64_encode(std::string_view str) return ret; } -static void convert_js_to_git_error(void) +static void convert_js_to_git_error(wasm_http_stream* stream) { // Convert error on JS side to git error. const char* error_str = js_get_error(); - git_error_set(GIT_ERROR_HTTP, "%s", error_str); + if (std::string_view(error_str).starts_with("NetworkError:")) + { + git_error_set( + GIT_ERROR_HTTP, + "network error sending request to %s, see the browser console and/or network tab for more details", + stream->m_unconverted_url.c_str() + ); + } + else + { + git_error_set(GIT_ERROR_HTTP, "%s", error_str); + } delete error_str; // Delete const char* allocated in JavaScript. } @@ -278,7 +302,11 @@ static int read(wasm_http_stream* stream, wasm_http_response& response, bool is_ // Response from a write. if (stream->m_request_index == -1) { - git_error_set(GIT_ERROR_HTTP, "read_response called without pending request"); + git_error_set( + GIT_ERROR_HTTP, + "read_response called without pending request to %s", + stream->m_unconverted_url.c_str() + ); return -1; } } @@ -286,13 +314,17 @@ static int read(wasm_http_stream* stream, wasm_http_response& response, bool is_ { if (stream->m_request_index != -1) { - git_error_set(GIT_ERROR_HTTP, "read called with pending request"); + git_error_set( + GIT_ERROR_HTTP, + "read called with pending request to %s", + stream->m_unconverted_url.c_str() + ); return -1; } if (create_request(stream, stream->m_service.m_response_type.c_str()) < 0) { - convert_js_to_git_error(); + convert_js_to_git_error(stream); return -1; } } @@ -311,7 +343,7 @@ static int read(wasm_http_stream* stream, wasm_http_response& response, bool is_ ); if (bytes_read < 0) { - convert_js_to_git_error(); + convert_js_to_git_error(stream); // Delete const char* allocated in JavaScript. delete status_text; delete response_headers; @@ -346,8 +378,9 @@ static int read(wasm_http_stream* stream, wasm_http_response& response, bool is_ // with it. git_error_set( GIT_ERROR_HTTP, - "expected response content-type header '%s'", - expected_response_type.c_str() + "expected response content-type header '%s' to request %s", + expected_response_type.c_str(), + stream->m_unconverted_url.c_str() ); return -1; } @@ -364,7 +397,7 @@ static int write(wasm_http_stream* stream, const char* buffer, size_t buffer_siz // If there is not already a request opened, do so now. if (create_request(stream, stream->m_service.m_request_type.c_str()) < 0) { - convert_js_to_git_error(); + convert_js_to_git_error(stream); return -1; } } @@ -372,7 +405,7 @@ static int write(wasm_http_stream* stream, const char* buffer, size_t buffer_siz int error = js_write(stream->m_request_index, buffer, buffer_size); if (error < 0) { - convert_js_to_git_error(); + convert_js_to_git_error(stream); return -1; } @@ -396,7 +429,11 @@ static int create_credential(wasm_http_stream* stream, const wasm_http_response& // Check that response headers show support for 'www-authenticate: Basic'. if (!response.has_header_starts_with("www-authenticate", "Basic")) { - git_error_set(GIT_ERROR_HTTP, "remote host does not support Basic authentication"); + git_error_set( + GIT_ERROR_HTTP, + "remote host for request %s does not support Basic authentication", + stream->m_unconverted_url.c_str() + ); return -1; } @@ -536,12 +573,7 @@ int wasm_http_stream_read(git_smart_subtransport_stream* s, char* buffer, size_t if (response.m_status != GIT_HTTP_STATUS_OK) { - git_error_set( - GIT_ERROR_HTTP, - "unexpected HTTP response: %d %s", - response.m_status, - response.m_status_text.c_str() - ); + response.set_git_error(stream->m_unconverted_url); return -1; } @@ -560,12 +592,7 @@ int wasm_http_stream_read_response(git_smart_subtransport_stream* s, char* buffe if (error == 0 && response.m_status != GIT_HTTP_STATUS_OK) { - git_error_set( - GIT_ERROR_HTTP, - "unexpected HTTP response: %d %s", - response.m_status, - response.m_status_text.c_str() - ); + response.set_git_error(stream->m_unconverted_url); error = -1; } From 297072e2e2c753021c5eba591a9259c79dce4793 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 28 Apr 2026 08:59:32 +0100 Subject: [PATCH 11/23] [pre-commit.ci] pre-commit autoupdate (#148) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/pre-commit/mirrors-clang-format: v22.1.3 → v22.1.4](https://github.com/pre-commit/mirrors-clang-format/compare/v22.1.3...v22.1.4) - [github.com/astral-sh/ruff-pre-commit: v0.15.11 → v0.15.12](https://github.com/astral-sh/ruff-pre-commit/compare/v0.15.11...v0.15.12) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 53e9bc2..a173676 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -17,14 +17,14 @@ repos: args: ['--py312-plus'] - repo: https://github.com/pre-commit/mirrors-clang-format - rev: v22.1.3 + rev: v22.1.4 hooks: - id: clang-format args: [--style=file] exclude_types: [javascript,json] - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.15.11 + rev: v0.15.12 hooks: - id: ruff-check args: [--fix] From 028322fa84e86dd70570348b73f584c5b8db1407 Mon Sep 17 00:00:00 2001 From: Ian Thomas Date: Tue, 28 Apr 2026 09:03:42 +0100 Subject: [PATCH 12/23] Improve `test_merge_conflict` via new `prompt_yes_or_no` (#147) * Improve `test_merge_conflict` via new `prompt_yes_or_no` * Fix wasm tests * Longer initial wait at start of wasm tests --- src/subcommand/merge_subcommand.cpp | 7 +++-- src/utils/input_output.cpp | 22 ++++++++++++++++ src/utils/input_output.hpp | 6 +++++ test/conftest.py | 2 +- test/conftest_wasm.py | 2 +- test/test_fixtures.py | 36 +++++++++++++++++++++++++ test/test_merge.py | 41 +++++++++++++++++++---------- wasm/test/src/index.ts | 5 +++- 8 files changed, 100 insertions(+), 21 deletions(-) diff --git a/src/subcommand/merge_subcommand.cpp b/src/subcommand/merge_subcommand.cpp index dc4015b..74f7abb 100644 --- a/src/subcommand/merge_subcommand.cpp +++ b/src/subcommand/merge_subcommand.cpp @@ -7,6 +7,7 @@ #include #include +#include "../utils/input_output.hpp" #include "../wrapper/status_wrapper.hpp" merge_subcommand::merge_subcommand(const libgit2_object&, CLI::App& app) @@ -181,10 +182,8 @@ void merge_subcommand::run() 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") + auto answer = prompt_yes_or_no("Do you want to continue [y/N] ? ", false); + if (answer) { repo.state_cleanup(); index.conflict_cleanup(); diff --git a/src/utils/input_output.cpp b/src/utils/input_output.cpp index 21a0578..3053419 100644 --- a/src/utils/input_output.cpp +++ b/src/utils/input_output.cpp @@ -94,3 +94,25 @@ std::string prompt_input(const std::string_view prompt, bool echo /* = true */) // Maybe sanitise input, removing escape codes? return input; } + +bool prompt_yes_or_no(const std::string_view prompt, bool default_return) +{ + while (true) + { + auto input = prompt_input(prompt); + if (input.empty()) + { + return default_return; + } + auto first_char = std::tolower(input.front()); + if (first_char == 'y') + { + return true; + } + else if (first_char == 'n') + { + return false; + } + // Repeat prompt. + } +} diff --git a/src/utils/input_output.hpp b/src/utils/input_output.hpp index 3e56e55..3e99385 100644 --- a/src/utils/input_output.hpp +++ b/src/utils/input_output.hpp @@ -62,3 +62,9 @@ class echo_control : noncopyable_nonmovable // stdin from the user. The `echo` argument controls whether stdin is echoed // to stdout, use `false` for passwords. std::string prompt_input(const std::string_view prompt, bool echo = true); + +// Display a prompt on stdout and accept a yes or no answer on stdin. +// Return true if the first character on stdin is 'y' or 'Y', false if it is +// 'n' or 'N', or default_return if the input is empty which occurs if the user +// presses enter only. Otherwise the input is ambiguous so repeat the prompt. +bool prompt_yes_or_no(const std::string_view prompt, bool default_return); diff --git a/test/conftest.py b/test/conftest.py index 5749674..eccc89d 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -52,7 +52,7 @@ def commit_env_config(monkeypatch): @pytest.fixture -def repo_init_with_commit(commit_env_config, git2cpp_path, tmp_path, run_in_tmp_path): +def repo_init_with_commit(commit_env_config, git2cpp_path, tmp_path): cmd_init = [git2cpp_path, "init", ".", "-b", "main"] p_init = subprocess.run(cmd_init, capture_output=True, cwd=tmp_path, text=True) assert p_init.returncode == 0 diff --git a/test/conftest_wasm.py b/test/conftest_wasm.py index a5d8ee9..b486a6e 100644 --- a/test/conftest_wasm.py +++ b/test/conftest_wasm.py @@ -46,7 +46,7 @@ def run_web_server(): cwd = pathlib.Path(__file__).parent.parent / "wasm/test" proc = subprocess.Popen(["npm", "run", "serve"], stdout=f, stderr=f, cwd=cwd) # Wait a bit until server ready to receive connections. - time.sleep(0.3) + time.sleep(0.5) yield proc.terminate() diff --git a/test/test_fixtures.py b/test/test_fixtures.py index a982d45..14bd925 100644 --- a/test/test_fixtures.py +++ b/test/test_fixtures.py @@ -37,3 +37,39 @@ def test_env_vars(): assert git_lines == ["GIT_CORS_PROXY=http://localhost:8881/"] else: assert git_lines == [] + + +def test_repo_init_with_commit(repo_init_with_commit, git2cpp_path, tmp_path): + assert (tmp_path / "initial.txt").exists() + assert (tmp_path / "initial.txt").is_file() + assert (tmp_path / "initial.txt").read_text() == "initial" + + git_dir = tmp_path / ".git" + assert git_dir.exists() + assert git_dir.is_dir() + + assert sorted(tmp_path.iterdir()) == [ + git_dir, + tmp_path / "initial.txt", + ] + + assert sorted(git_dir.iterdir()) == [ + git_dir / "HEAD", + git_dir / "config", + git_dir / "description", + git_dir / "hooks", + git_dir / "index", + git_dir / "info", + git_dir / "logs", + git_dir / "objects", + git_dir / "refs", + ] + + cmd = [git2cpp_path, "log"] + p = subprocess.run(cmd, capture_output=True, cwd=tmp_path, text=True) + assert p.returncode == 0 + lines = p.stdout.splitlines() + assert "commit" in lines[0] + assert lines[1].startswith("Author:") + assert lines[2].startswith("Date:") + assert "Initial commit" in lines[4] diff --git a/test/test_merge.py b/test/test_merge.py index 4540e2c..403157e 100644 --- a/test/test_merge.py +++ b/test/test_merge.py @@ -97,8 +97,21 @@ def test_merge_commit(repo_init_with_commit, commit_env_config, git2cpp_path, tm assert p_merge_2.stdout == "Already up-to-date\n" -@pytest.mark.parametrize("flag", ["--abort", "--quit", "--continue"]) -def test_merge_conflict(repo_init_with_commit, commit_env_config, git2cpp_path, tmp_path, flag): +@pytest.mark.parametrize( + "flag,abort_input", + [ + ("--abort", "y"), + ("--abort", "Y"), + ("--abort", "n"), + ("--abort", "N"), + ("--abort", ""), + ("--quit", None), + ("--continue", None), + ], +) +def test_merge_conflict( + repo_init_with_commit, commit_env_config, git2cpp_path, tmp_path, flag, abort_input +): assert (tmp_path / "initial.txt").exists() checkout_cmd = [git2cpp_path, "checkout", "-b", "foregone"] @@ -140,18 +153,18 @@ def test_merge_conflict(repo_init_with_commit, commit_env_config, git2cpp_path, 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=tmp_path, text=True - ) - assert p_abort.returncode == 0 - assert (tmp_path / "mook_file.txt").exists() - text = (tmp_path / "mook_file.txt").read_text() - if answer == "y": - assert "BLA" in text - assert "bla" not in text - else: - assert "Abort." in p_abort.stdout + p_abort = subprocess.run( + flag_cmd, input=abort_input, capture_output=True, cwd=tmp_path, text=True + ) + + assert p_abort.returncode == 0 + assert (tmp_path / "mook_file.txt").exists() + text = (tmp_path / "mook_file.txt").read_text() + if abort_input.lower() == "y": + assert "BLA" in text + assert "bla" not in text + else: + assert "Abort." in p_abort.stdout elif flag == "--quit": pass diff --git a/wasm/test/src/index.ts b/wasm/test/src/index.ts index 8f38b22..38d939b 100644 --- a/wasm/test/src/index.ts +++ b/wasm/test/src/index.ts @@ -47,7 +47,10 @@ async function shellRun( if (input !== undefined && input !== null) { async function delayThenStdin(): Promise { - const chars = input! + '\x04'; // EOT + let chars = input!; + if (!chars.endsWith('\n')) { + chars += '\n'; + } await delay(100); for (const char of chars) { await shell.input(char); From 016182435e5382be67c95d649eb08a9f0e4bef04 Mon Sep 17 00:00:00 2001 From: Sandrine Pataut Date: Wed, 29 Apr 2026 12:35:37 +0200 Subject: [PATCH 13/23] Add ```show-ref``` subcommand (#142) * Add show-ref subcommand * address review comment * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- CMakeLists.txt | 2 ++ src/main.cpp | 2 ++ src/subcommand/showref_subcommand.cpp | 31 +++++++++++++++++ src/subcommand/showref_subcommand.hpp | 13 ++++++++ src/wrapper/repository_wrapper.hpp | 1 + test/test_fetch.py | 3 +- test/test_showref.py | 48 +++++++++++++++++++++++++++ 7 files changed, 99 insertions(+), 1 deletion(-) create mode 100644 src/subcommand/showref_subcommand.cpp create mode 100644 src/subcommand/showref_subcommand.hpp create mode 100644 test/test_showref.py diff --git a/CMakeLists.txt b/CMakeLists.txt index 940e418..ef6fb9b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -78,6 +78,8 @@ set(GIT2CPP_SRC ${GIT2CPP_SOURCE_DIR}/subcommand/revparse_subcommand.hpp ${GIT2CPP_SOURCE_DIR}/subcommand/rm_subcommand.cpp ${GIT2CPP_SOURCE_DIR}/subcommand/rm_subcommand.hpp + ${GIT2CPP_SOURCE_DIR}/subcommand/showref_subcommand.cpp + ${GIT2CPP_SOURCE_DIR}/subcommand/showref_subcommand.hpp ${GIT2CPP_SOURCE_DIR}/subcommand/stash_subcommand.cpp ${GIT2CPP_SOURCE_DIR}/subcommand/stash_subcommand.hpp ${GIT2CPP_SOURCE_DIR}/subcommand/status_subcommand.cpp diff --git a/src/main.cpp b/src/main.cpp index 712885e..c1ce04d 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -23,6 +23,7 @@ #include "subcommand/revlist_subcommand.hpp" #include "subcommand/revparse_subcommand.hpp" #include "subcommand/rm_subcommand.hpp" +#include "subcommand/showref_subcommand.hpp" #include "subcommand/stash_subcommand.hpp" #include "subcommand/status_subcommand.hpp" #include "subcommand/tag_subcommand.hpp" @@ -63,6 +64,7 @@ int main(int argc, char** argv) rm_subcommand rm(lg2_obj, app); stash_subcommand stash(lg2_obj, app); tag_subcommand tag(lg2_obj, app); + showref_subcommand showref(lg2_obj, app); app.require_subcommand(/* min */ 0, /* max */ 1); diff --git a/src/subcommand/showref_subcommand.cpp b/src/subcommand/showref_subcommand.cpp new file mode 100644 index 0000000..346acac --- /dev/null +++ b/src/subcommand/showref_subcommand.cpp @@ -0,0 +1,31 @@ +#include "showref_subcommand.hpp" + +#include + +#include "../wrapper/repository_wrapper.hpp" + +showref_subcommand::showref_subcommand(const libgit2_object&, CLI::App& app) +{ + auto* sub = app.add_subcommand("show-ref", "List references in a local repository"); + + sub->callback( + [this]() + { + this->run(); + } + ); +}; + +void showref_subcommand::run() +{ + auto directory = get_current_git_path(); + auto repo = repository_wrapper::open(directory); + + auto repo_refs = repo.refs_list(); + + for (auto r : repo_refs) + { + git_oid oid = repo.ref_name_to_id(r); + std::cout << oid_to_hex(oid) << " " << r << std::endl; + } +} diff --git a/src/subcommand/showref_subcommand.hpp b/src/subcommand/showref_subcommand.hpp new file mode 100644 index 0000000..13534b8 --- /dev/null +++ b/src/subcommand/showref_subcommand.hpp @@ -0,0 +1,13 @@ +#pragma once + +#include + +#include "../utils/common.hpp" + +class showref_subcommand +{ +public: + + explicit showref_subcommand(const libgit2_object&, CLI::App& app); + void run(); +}; diff --git a/src/wrapper/repository_wrapper.hpp b/src/wrapper/repository_wrapper.hpp index f365401..1c5899e 100644 --- a/src/wrapper/repository_wrapper.hpp +++ b/src/wrapper/repository_wrapper.hpp @@ -6,6 +6,7 @@ #include +#include "../utils/common.hpp" #include "../utils/git_exception.hpp" #include "../wrapper/annotated_commit_wrapper.hpp" #include "../wrapper/branch_wrapper.hpp" diff --git a/test/test_fetch.py b/test/test_fetch.py index 4510875..f59ad7e 100644 --- a/test/test_fetch.py +++ b/test/test_fetch.py @@ -1,6 +1,7 @@ -import pytest import subprocess +import pytest + @pytest.mark.parametrize("protocol", ["http", "https"]) def test_fetch_private_repo(git2cpp_path, tmp_path, run_in_tmp_path, private_test_repo, protocol): diff --git a/test/test_showref.py b/test/test_showref.py new file mode 100644 index 0000000..9e08b84 --- /dev/null +++ b/test/test_showref.py @@ -0,0 +1,48 @@ +import re +import subprocess + + +def test_showref_list(repo_init_with_commit, git2cpp_path, tmp_path): + """`show-ref` lists the repository references (heads present after init+commit).""" + cmd = [git2cpp_path, "show-ref"] + p = subprocess.run(cmd, capture_output=True, cwd=tmp_path, text=True) + assert p.returncode == 0 + # repo_init_with_commit in conftest creates the branch "main" + assert "refs/heads/main" in p.stdout + + +def test_showref_includes_tag(repo_init_with_commit, git2cpp_path, tmp_path): + """A created tag appears in show-ref output as refs/tags/.""" + # create a lightweight tag using the CLI under test + subprocess.run([git2cpp_path, "tag", "v1.0"], cwd=tmp_path, check=True) + + p = subprocess.run([git2cpp_path, "show-ref"], capture_output=True, cwd=tmp_path, text=True) + assert p.returncode == 0 + assert "refs/tags/v1.0" in p.stdout + + +def test_showref_line_format(repo_init_with_commit, git2cpp_path, tmp_path): + """Each line of show-ref is: <40-hex-oid> .""" + p = subprocess.run([git2cpp_path, "show-ref"], capture_output=True, cwd=tmp_path, text=True) + assert p.returncode == 0 + print(p.stdout) + + hex_re = re.compile(r"^[0-9a-f]{40}$") + for line in p.stdout.splitlines(): + line = line.strip() + if not line: + continue + parts = line.split() + # Expect at least two tokens: oid and refname + assert len(parts) >= 2 + oid, refname = parts[0], parts[1] + assert hex_re.match(oid), f"OID not a 40-char hex: {oid!r}" + assert refname.startswith("refs/"), f"Refname does not start with refs/: {refname!r}" + + +def test_showref_nogit(git2cpp_path, tmp_path): + """Running show-ref outside a repository returns an error and non-zero exit.""" + cmd = [git2cpp_path, "show-ref"] + p = subprocess.run(cmd, capture_output=True, cwd=tmp_path, text=True) + assert p.returncode != 0 + assert "error: could not find repository at" in p.stderr From 62ef058e560aa4b8a666dc3d0d56b38aa228664c Mon Sep 17 00:00:00 2001 From: Sandrine Pataut Date: Wed, 29 Apr 2026 16:37:56 +0200 Subject: [PATCH 14/23] Fix ```status --short``` (#149) * Fix status --short * address review comment * use namespace * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * address review comments * fix * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- src/subcommand/checkout_subcommand.cpp | 2 +- src/subcommand/status_subcommand.cpp | 427 ++++++++++++++++++------- src/subcommand/status_subcommand.hpp | 2 +- src/wrapper/status_wrapper.cpp | 53 ++- test/test_status.py | 51 ++- 5 files changed, 407 insertions(+), 128 deletions(-) diff --git a/src/subcommand/checkout_subcommand.cpp b/src/subcommand/checkout_subcommand.cpp index 1c3dfdb..ccc24f9 100644 --- a/src/subcommand/checkout_subcommand.cpp +++ b/src/subcommand/checkout_subcommand.cpp @@ -118,7 +118,7 @@ void checkout_subcommand::run() print_tobecommited(sl, tracked_dir_set, is_long, is_coloured); } std::cout << "Switched to branch '" << m_branch_name << "'" << std::endl; - print_tracking_info(repo, sl, true); + print_tracking_info(repo, sl, true, false); } } diff --git a/src/subcommand/status_subcommand.cpp b/src/subcommand/status_subcommand.cpp index 43cc9e8..e2c0f0c 100644 --- a/src/subcommand/status_subcommand.cpp +++ b/src/subcommand/status_subcommand.cpp @@ -1,9 +1,11 @@ #include "status_subcommand.hpp" +#include #include #include #include #include +#include #include #include @@ -32,158 +34,324 @@ 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 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"; -// TODO: add the following ot notstagged_header after "checkout " is implemented: (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 +namespace { - DEFAULT = 0, - LONG = 1, - SHORT = 2 -}; - -struct print_entry -{ - std::string status; - std::string item; -}; - -std::string get_print_status(git_status_t status, bool is_long) -{ - std::string entry_status; - if (is_long) - { - entry_status = get_status_msg(status).long_mod; - } - else - { - entry_status = get_status_msg(status).short_mod; + 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 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"; + // TODO: add the following ot notstagged_header after "checkout " is implemented: (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 + { + DEFAULT = 0, + LONG = 1, + SHORT = 2 + }; + + struct print_entry + { + std::string status; + std::string item; + }; + + struct combined_entry + { + git_status_t index_status = GIT_STATUS_CURRENT; + git_status_t workdir_status = GIT_STATUS_CURRENT; + std::string item; + }; + + std::string get_print_status(git_status_t status, bool is_long) + { + std::string entry_status; + if (is_long) + { + entry_status = get_status_msg(status).long_mod; + } + else + { + entry_status = get_status_msg(status).short_mod; + } + return entry_status; } - return entry_status; -} -void update_tracked_dir_set(const char* path, std::set* tracked_dir_set = nullptr) -{ - if (tracked_dir_set) + char short_char_from_status(git_status_t status) { - const size_t first_slash_idx = std::string_view(path).find('/'); - if (std::string::npos != first_slash_idx) + switch (status) { - auto directory = std::string_view(path).substr(0, first_slash_idx); - tracked_dir_set->insert(std::string(directory)); + case GIT_STATUS_INDEX_NEW: + case GIT_STATUS_WT_NEW: + return 'A'; + case GIT_STATUS_INDEX_MODIFIED: + case GIT_STATUS_WT_MODIFIED: + return 'M'; + case GIT_STATUS_INDEX_DELETED: + case GIT_STATUS_WT_DELETED: + return 'D'; + case GIT_STATUS_INDEX_RENAMED: + case GIT_STATUS_WT_RENAMED: + return 'R'; + case GIT_STATUS_INDEX_TYPECHANGE: + case GIT_STATUS_WT_TYPECHANGE: + return 'T'; + default: + return ' '; } } -} -std::string get_print_item(const char* old_path, const char* new_path) -{ - std::string entry_item; - if (old_path && new_path && std::strcmp(old_path, new_path)) + void update_tracked_dir_set(const char* path, std::set* tracked_dir_set = nullptr) { - entry_item = std::string(old_path) + " -> " + std::string(new_path); - } - else - { - entry_item = old_path ? old_path : new_path; + if (tracked_dir_set) + { + const size_t first_slash_idx = std::string_view(path).find('/'); + if (std::string::npos != first_slash_idx) + { + auto directory = std::string_view(path).substr(0, first_slash_idx); + tracked_dir_set->insert(std::string(directory)); + } + } } - return entry_item; -} -std::vector get_entries_to_print( - git_status_t status, - status_list_wrapper& sl, - bool head_selector, - bool is_long, - std::set* tracked_dir_set = nullptr -) -{ - std::vector entries_to_print{}; - const auto& entry_list = sl.get_entry_list(status); - if (entry_list.empty()) + std::string get_print_item(const char* old_path, const char* new_path) { - return entries_to_print; + std::string entry_item; + if (old_path && new_path && std::strcmp(old_path, new_path)) + { + entry_item = std::string(old_path) + " -> " + std::string(new_path); + } + else + { + entry_item = old_path ? old_path : new_path; + } + return entry_item; } - for (auto* entry : entry_list) + std::unordered_map + build_combined_status_map(status_list_wrapper& sl, std::set& tracked_dir_set) { - git_diff_delta* diff_delta = head_selector ? entry->head_to_index : entry->index_to_workdir; - const char* old_path = diff_delta->old_file.path; - const char* new_path = diff_delta->new_file.path; + std::unordered_map combined; + + auto update_status_map = + [&sl, &tracked_dir_set, &combined](const git_status_t(&status_array)[5], bool index) + { + for (git_status_t status : status_array) + { + const auto& list = sl.get_entry_list(status); + for (auto* entry : list) + { + git_diff_delta* dd = index ? entry->head_to_index : entry->index_to_workdir; + const char* old_path = dd->old_file.path; + const char* new_path = dd->new_file.path; + update_tracked_dir_set(old_path, &tracked_dir_set); + std::string item = get_print_item(old_path, new_path); + auto& ce = combined[item]; + ce.item = item; + if (index) + { + ce.index_status = status; + } + else + { + ce.workdir_status = status; + } + } + } + }; + + const git_status_t index_statuses[] = { + GIT_STATUS_INDEX_NEW, + GIT_STATUS_INDEX_MODIFIED, + GIT_STATUS_INDEX_DELETED, + GIT_STATUS_INDEX_RENAMED, + GIT_STATUS_INDEX_TYPECHANGE + }; + update_status_map(index_statuses, true); + + const git_status_t worktree_statuses[] = { + GIT_STATUS_WT_NEW, + GIT_STATUS_WT_MODIFIED, + GIT_STATUS_WT_DELETED, + GIT_STATUS_WT_TYPECHANGE, + GIT_STATUS_WT_RENAMED + }; + update_status_map(worktree_statuses, false); + + return combined; + } + + void + print_combined_short(const std::unordered_map& entries_map, bool is_coloured) + { + std::vector keys; + keys.reserve(entries_map.size()); + for (const auto& kv : entries_map) + { + keys.push_back(kv.first); + } + std::sort(keys.begin(), keys.end()); + + struct normal_row + { + char idx; + char wt; + std::string item; + }; + + std::vector normal_rows; + std::vector untracked_items; + std::vector ignored_items; + + for (const auto& k : keys) + { + const auto& ce = entries_map.at(k); + + // Collect special cases to print last (untracked, ignored) only when not staged. + if ((ce.workdir_status & GIT_STATUS_WT_NEW) && ce.index_status == 0) + { + untracked_items.push_back(ce.item); + continue; + } + if ((ce.workdir_status & GIT_STATUS_IGNORED || ce.index_status & GIT_STATUS_IGNORED) + && ce.index_status == 0) + { + ignored_items.push_back(ce.item); + continue; + } + + // Regular two-column entry (may include index or worktree or both) + char idx = short_char_from_status(ce.index_status); + char wt = short_char_from_status(ce.workdir_status); + normal_rows.push_back({idx, wt, ce.item}); + } + + for (const auto& r : normal_rows) + { + if (is_coloured) + { + std::cout << termcolor::green << r.idx << termcolor::reset; + std::cout << termcolor::red << r.wt << termcolor::reset; + std::cout << " " << r.item << std::endl; + } + else + { + std::cout << r.idx << r.wt << " " << r.item << std::endl; + } + } - update_tracked_dir_set(old_path, tracked_dir_set); + stream_colour_fn colour; + if (is_coloured) + { + colour = termcolor::red; + } + else + { + colour = termcolor::bright_white; + } - print_entry e = {get_print_status(status, is_long), get_print_item(old_path, new_path)}; + std::sort(untracked_items.begin(), untracked_items.end()); + for (const auto& it : untracked_items) + { + std::cout << colour << "?? " << termcolor::reset << it << std::endl; + } - entries_to_print.push_back(std::move(e)); + std::sort(ignored_items.begin(), ignored_items.end()); + for (const auto& it : ignored_items) + { + std::cout << colour << "!! " << termcolor::reset << it << std::endl; + } } - return entries_to_print; -} -void print_entries(std::vector entries_to_print, bool is_long, stream_colour_fn colour) -{ - for (auto e : entries_to_print) + std::vector get_entries_to_print( + git_status_t status, + status_list_wrapper& sl, + bool head_selector, + bool is_long, + std::set* tracked_dir_set = nullptr + ) { - if (is_long) + std::vector entries_to_print{}; + const auto& entry_list = sl.get_entry_list(status); + if (entry_list.empty()) { - std::cout << colour << e.status << e.item << termcolor::reset << std::endl; + return entries_to_print; } - else + + for (auto* entry : entry_list) { - std::cout << colour << e.status << termcolor::reset << e.item << std::endl; + git_diff_delta* diff_delta = head_selector ? entry->head_to_index : entry->index_to_workdir; + const char* old_path = diff_delta->old_file.path; + const char* new_path = diff_delta->new_file.path; + + update_tracked_dir_set(old_path, tracked_dir_set); + + print_entry e = {get_print_status(status, is_long), get_print_item(old_path, new_path)}; + + entries_to_print.push_back(std::move(e)); } + return entries_to_print; } -} -void print_not_tracked( - const std::vector& entries_to_print, - const std::set& tracked_dir_set, - std::set& untracked_dir_set, - bool is_long, - stream_colour_fn colour -) -{ - std::vector not_tracked_entries_to_print{}; - for (auto e : entries_to_print) + void print_entries(std::vector entries_to_print, bool is_long, stream_colour_fn colour) { - const size_t first_slash_idx = e.item.find('/'); - if (std::string::npos != first_slash_idx) + for (const auto& e : entries_to_print) { - auto directory = e.item.substr(0, first_slash_idx) + "/"; - if (tracked_dir_set.contains(directory)) + if (is_long) { - not_tracked_entries_to_print.push_back(e); + std::cout << colour << e.status << e.item << termcolor::reset << std::endl; } else { - if (untracked_dir_set.contains(directory)) + std::cout << colour << e.status << termcolor::reset << e.item << std::endl; + } + } + } + + void print_not_tracked( + const std::vector& entries_to_print, + const std::set& tracked_dir_set, + std::set& untracked_dir_set, + bool is_long, + stream_colour_fn colour + ) + { + std::vector not_tracked_entries_to_print{}; + for (const auto& e : entries_to_print) + { + 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) + "/"; + if (tracked_dir_set.contains(directory)) { + not_tracked_entries_to_print.push_back(e); } else { - not_tracked_entries_to_print.push_back({e.status, directory}); - untracked_dir_set.insert(std::string(directory)); + if (!untracked_dir_set.contains(directory)) + { + not_tracked_entries_to_print.push_back({e.status, directory}); + untracked_dir_set.insert(std::string(directory)); + } } } + else + { + not_tracked_entries_to_print.push_back(e); + } } - else - { - not_tracked_entries_to_print.push_back(e); - } + print_entries(not_tracked_entries_to_print, is_long, colour); } - print_entries(not_tracked_entries_to_print, is_long, colour); } -void print_tracking_info(repository_wrapper& repo, status_list_wrapper& sl, bool is_long) +void print_tracking_info(repository_wrapper& repo, status_list_wrapper& sl, bool is_long, bool branch_flag) { auto tracking_info = repo.get_tracking_info(); @@ -234,18 +402,18 @@ void print_tracking_info(repository_wrapper& repo, status_list_wrapper& sl, bool << std::endl; } } - else + else if (branch_flag) { if (tracking_info.has_upstream) { - std::cout << "..." << tracking_info.upstream_name; + std::cout << "..." << termcolor::red << tracking_info.upstream_name << termcolor::reset; if (tracking_info.ahead > 0 || tracking_info.behind > 0) { std::cout << " ["; if (tracking_info.ahead > 0) { - std::cout << "ahead " << tracking_info.ahead; + std::cout << "ahead " << termcolor::green << tracking_info.ahead << termcolor::reset; } if (tracking_info.behind > 0) { @@ -253,7 +421,7 @@ void print_tracking_info(repository_wrapper& repo, status_list_wrapper& sl, bool { std::cout << ", "; } - std::cout << "behind " << tracking_info.behind; + std::cout << "behind " << termcolor::red << tracking_info.behind << termcolor::reset; } std::cout << "]"; } @@ -451,31 +619,46 @@ void status_run(status_subcommand_options options) is_long = ((of == output_format::DEFAULT) || (of == output_format::LONG)); auto branch_name = repo.head_short_name(); + bool is_coloured = true; + bool branch_flag = options.m_branch_flag; if (is_long) { std::cout << "On branch " << branch_name << std::endl; } - else if (options.m_branch_flag) + else if (branch_flag) + { + if (is_coloured) + { + std::cout << "## " << termcolor::green << branch_name << termcolor::reset; + } + else + { + std::cout << "## " << branch_name; + } + } + + print_tracking_info(repo, sl, is_long, branch_flag); + + if (of == output_format::SHORT) { - std::cout << "## " << branch_name << std::endl; + auto combined = build_combined_status_map(sl, tracked_dir_set); + print_combined_short(combined, is_coloured); + return; } - bool is_coloured = true; - print_tracking_info(repo, sl, is_long); if (sl.has_tobecommited_header()) { print_tobecommited(sl, tracked_dir_set, is_long, is_coloured); } - if (sl.has_notstagged_header()) + if (sl.has_unmerged_header()) { - print_notstagged(sl, tracked_dir_set, is_long, is_coloured); + print_unmerged(sl, tracked_dir_set, untracked_dir_set, is_long, is_coloured); } - // TODO: check if should be printed before "not stagged" files - if (sl.has_unmerged_header()) + if (sl.has_notstagged_header()) { - print_unmerged(sl, tracked_dir_set, untracked_dir_set, is_long, is_coloured); + print_notstagged(sl, tracked_dir_set, is_long, is_coloured); } if (sl.has_untracked_header()) diff --git a/src/subcommand/status_subcommand.hpp b/src/subcommand/status_subcommand.hpp index 3d64480..d017571 100644 --- a/src/subcommand/status_subcommand.hpp +++ b/src/subcommand/status_subcommand.hpp @@ -26,5 +26,5 @@ class status_subcommand void print_tobecommited(status_list_wrapper& sl, std::set tracked_dir_set, bool is_long, bool is_coloured); void print_notstagged(status_list_wrapper& sl, std::set tracked_dir_set, bool is_long, bool is_coloured); -void print_tracking_info(repository_wrapper& repo, status_list_wrapper& sl, bool is_long); +void print_tracking_info(repository_wrapper& repo, status_list_wrapper& sl, bool is_long, bool branch_flag); void status_run(status_subcommand_options fl = {}); diff --git a/src/wrapper/status_wrapper.cpp b/src/wrapper/status_wrapper.cpp index 410651a..dbeaaab 100644 --- a/src/wrapper/status_wrapper.cpp +++ b/src/wrapper/status_wrapper.cpp @@ -23,7 +23,58 @@ status_list_wrapper status_list_wrapper::status_list(const repository_wrapper& r for (std::size_t i = 0; i < status_list_size; ++i) { auto entry = git_status_byindex(res.p_resource, i); - res.m_entries[entry->status].push_back(entry); + auto s = entry->status; + + if (s & GIT_STATUS_INDEX_NEW) + { + res.m_entries[GIT_STATUS_INDEX_NEW].push_back(entry); + } + if (s & GIT_STATUS_INDEX_MODIFIED) + { + res.m_entries[GIT_STATUS_INDEX_MODIFIED].push_back(entry); + } + if (s & GIT_STATUS_INDEX_DELETED) + { + res.m_entries[GIT_STATUS_INDEX_DELETED].push_back(entry); + } + if (s & GIT_STATUS_INDEX_RENAMED) + { + res.m_entries[GIT_STATUS_INDEX_RENAMED].push_back(entry); + } + if (s & GIT_STATUS_INDEX_TYPECHANGE) + { + res.m_entries[GIT_STATUS_INDEX_TYPECHANGE].push_back(entry); + } + + if (s & GIT_STATUS_WT_NEW) + { + res.m_entries[GIT_STATUS_WT_NEW].push_back(entry); + } + if (s & GIT_STATUS_WT_MODIFIED) + { + res.m_entries[GIT_STATUS_WT_MODIFIED].push_back(entry); + } + if (s & GIT_STATUS_WT_DELETED) + { + res.m_entries[GIT_STATUS_WT_DELETED].push_back(entry); + } + if (s & GIT_STATUS_WT_RENAMED) + { + res.m_entries[GIT_STATUS_WT_RENAMED].push_back(entry); + } + if (s & GIT_STATUS_WT_TYPECHANGE) + { + res.m_entries[GIT_STATUS_WT_TYPECHANGE].push_back(entry); + } + + if (s & GIT_STATUS_IGNORED) + { + res.m_entries[GIT_STATUS_IGNORED].push_back(entry); + } + if (s & GIT_STATUS_CONFLICTED) + { + res.m_entries[GIT_STATUS_CONFLICTED].push_back(entry); + } } if (!res.get_entry_list(GIT_STATUS_INDEX_NEW).empty() diff --git a/test/test_status.py b/test/test_status.py index cb4b530..e8e203a 100644 --- a/test/test_status.py +++ b/test/test_status.py @@ -258,7 +258,10 @@ def test_status_untracked_directory(repo_init_with_commit, git2cpp_path, tmp_pat @pytest.mark.parametrize("short_flag", ["", "-s"]) -def test_status_ahead_of_upstream(commit_env_config, git2cpp_path, tmp_path, short_flag): +@pytest.mark.parametrize("branch_flag", ["", "-b"]) +def test_status_ahead_of_upstream( + commit_env_config, git2cpp_path, tmp_path, short_flag, branch_flag +): """Test status when local branch is ahead of upstream""" # Create a repository with remote tracking repo_path = tmp_path / "repo" @@ -287,12 +290,17 @@ def test_status_ahead_of_upstream(commit_env_config, git2cpp_path, tmp_path, sho cmd_status = [git2cpp_path, "status"] if short_flag == "-s": cmd_status.append(short_flag) + if branch_flag == "-b": + cmd_status.append(branch_flag) p = subprocess.run(cmd_status, capture_output=True, cwd=clone_path, text=True) assert p.returncode == 0 if short_flag == "-s": - assert "...origin/main" in p.stdout - assert "[ahead 1]" in p.stdout + if branch_flag == "-b": + assert "...origin/main" in p.stdout + assert "[ahead 1]" in p.stdout + else: + assert "main" not in p.stdout else: assert "Your branch is ahead of 'origin/main'" in p.stdout assert "by 1 commit" in p.stdout @@ -372,3 +380,40 @@ def test_status_all_headers_shown(repo_init_with_commit, git2cpp_path, tmp_path) assert 'use "git add ..." to update what will be committed' in p.stdout assert "Untracked files:" in p.stdout assert 'use "git add ..." to include in what will be committed' in p.stdout + + +def test_status_modified_staged_then_modified(repo_init_with_commit, git2cpp_path, tmp_path): + """ + File is modified, staged, then modified again. + Short status should show 'MM filename' + Long status should show the file in both 'Changes to be committed' and 'Changes not staged for commit' + """ + # initial.txt present from fixture + f = tmp_path / "initial.txt" + assert f.exists() + + # Modify and stage + f.write_text("first change") + subprocess.run([git2cpp_path, "add", "initial.txt"], cwd=tmp_path, check=True) + + # Modify again (unstaged) + f.write_text("second change") + + # Short mode + p_short = subprocess.run( + [git2cpp_path, "status", "-s"], capture_output=True, cwd=tmp_path, text=True, check=True + ) + assert p_short.returncode == 0 + # Ensure exact two-letter code for index+worktree is present + assert "MM initial.txt" in strip_ansi_colours(p_short.stdout) + + # Long mode + p_long = subprocess.run( + [git2cpp_path, "status", "--long"], capture_output=True, cwd=tmp_path, text=True, check=True + ) + assert p_long.returncode == 0 + out = strip_ansi_colours(p_long.stdout) + assert "Changes to be committed" in out + assert "Changes not staged for commit" in out + # Ensure the filename appears (it should appear in one or both sections) + assert "initial.txt" in out From 5b2e65ebe5ff2db81a0ddc535cc379cfc77dd6d2 Mon Sep 17 00:00:00 2001 From: Sandrine Pataut Date: Wed, 29 Apr 2026 16:45:12 +0200 Subject: [PATCH 15/23] Add missing help information (#150) * Add missing help information * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- src/subcommand/add_subcommand.cpp | 8 +++- src/subcommand/reset_subcommand.cpp | 26 +++++++++++-- src/subcommand/stash_subcommand.cpp | 59 +++++++++++++++++++++++------ 3 files changed, 76 insertions(+), 17 deletions(-) diff --git a/src/subcommand/add_subcommand.cpp b/src/subcommand/add_subcommand.cpp index ca52940..96f947e 100644 --- a/src/subcommand/add_subcommand.cpp +++ b/src/subcommand/add_subcommand.cpp @@ -9,9 +9,13 @@ add_subcommand::add_subcommand(const libgit2_object&, CLI::App& app) { auto* sub = app.add_subcommand("add", "Add file contents to the index"); - sub->add_option("", m_add_files, "Files to add"); + sub->add_option("", m_add_files, "Files to add content from."); - sub->add_flag("-A,--all,--no-ignore-removal", m_all_flag, ""); + sub->add_flag( + "-A,--all,--no-ignore-removal", + m_all_flag, + "Update the index not only where the working tree has a file matching but also where the index already has an entry. This adds, modifies, and removes index entries to match the working tree.\n\nIf no is given when -A option is used, all files in the entire working tree are updated (old versions of Git used to limit the update to the current directory and its subdirectories)." + ); // sub->add_flag("-n,--dryrun", dryrun_flag, ""); // sub->add_flag("-u,--update", update_flag, ""); // sub->add_flag("-v,--verbose", verbose_flag, ""); diff --git a/src/subcommand/reset_subcommand.cpp b/src/subcommand/reset_subcommand.cpp index 783a05e..c3ed07b 100644 --- a/src/subcommand/reset_subcommand.cpp +++ b/src/subcommand/reset_subcommand.cpp @@ -2,6 +2,7 @@ // #include "../wrapper/index_wrapper.hpp" #include +#include "../utils/ansi_code.hpp" #include "../wrapper/repository_wrapper.hpp" enum class reset_type @@ -17,9 +18,28 @@ reset_subcommand::reset_subcommand(const libgit2_object&, CLI::App& app) sub->add_option("", m_commit, "The ID of the commit that will become HEAD"); - sub->add_flag("--soft", m_soft_flag, ""); - sub->add_flag("--mixed", m_mixed_flag, ""); - sub->add_flag("--hard", m_hard_flag, ""); + sub->add_flag( + "--soft", + m_soft_flag, + "Leave your working tree files and the index unchanged. For example, if you have no staged changes, you can use" + + ansi_code::bold + "git reset --soft HEAD~5" + ansi_code::reset + "; " + ansi_code::bold + + "git commit" + ansi_code::reset + + " to combine the last 5 commits into 1 commit. This works even with changes in the working tree, which are left untouched, but such usage can lead to confusion." + ); + sub->add_flag( + "--mixed", + m_mixed_flag, + "Leave your working directory unchanged. Update the index to match the new HEAD, so nothing will be staged." + ); + sub->add_flag( + "--hard", + m_hard_flag, + "Overwrite all files and directories with the version from " + ansi_code::bold + "" + + ansi_code::reset + ", and may overwrite untracked files. Tracked files not in " + + ansi_code::bold + "" + ansi_code::reset + + " are removed so that the working tree matches " + ansi_code::bold + "" + + ansi_code::reset + ". Update the index to match the new HEAD, so nothing will be staged." + ); sub->callback( [this]() diff --git a/src/subcommand/stash_subcommand.cpp b/src/subcommand/stash_subcommand.cpp index 9fc0cc8..4c4448f 100644 --- a/src/subcommand/stash_subcommand.cpp +++ b/src/subcommand/stash_subcommand.cpp @@ -9,6 +9,7 @@ #include "../subcommand/diff_subcommand.hpp" #include "../subcommand/status_subcommand.hpp" +#include "../utils/ansi_code.hpp" bool has_subcommand(CLI::App* cmd) { @@ -25,20 +26,54 @@ bool has_subcommand(CLI::App* cmd) stash_subcommand::stash_subcommand(const libgit2_object&, CLI::App& app) { - auto* stash = app.add_subcommand("stash", "Stash the changes in a dirty working directory away"); - auto* push = stash->add_subcommand("push", ""); - auto* list = stash->add_subcommand("list", ""); - auto* pop = stash->add_subcommand("pop", ""); - auto* apply = stash->add_subcommand("apply", ""); - auto* show = stash->add_subcommand("show", "Show the changes recorded in the stash as a diff"); + auto* stash = app.add_subcommand("stash", "Stash the changes in a dirty working directory away\n"); + auto* push = stash->add_subcommand( + "push", + "Save your local modifications to a new stash entry and roll them back to " + ansi_code::bold + + "HEAD " + ansi_code::reset + + "(in the working tree and in the index). The part is optional and gives the description along with the stashed state.\n" + ); + auto* list = stash->add_subcommand( + "list", + "List the stash entries that you currently have. Each stash entry is listed with its name (e.g. " + + ansi_code::bold + "stash@" + ansi_code::reset + "{0} is the latest entry, " + ansi_code::bold + + "stash@" + ansi_code::reset + + "{1} is the one before, etc.), the name of the branch that was current when the entry was made, and a short description of the commit the entry was based on.\n" + ); // TODO: check if shows all of that + auto* pop = stash->add_subcommand( + "pop", + "Remove a single stashed state from the stash list and apply it on top of the current working tree state, i.e., do the inverse operation of " + + ansi_code::bold + "git stash push.\n" + ansi_code::reset + ); + auto* apply = stash->add_subcommand( + "apply", + "Like " + ansi_code::bold + "pop" + ansi_code::reset + + ", but do not remove the state from the stash list. Unlike " + ansi_code::bold + "pop" + + ansi_code::reset + ", may be any commit that looks like a commit created by " + + ansi_code::bold + "stash push" + ansi_code::reset + " or " + ".\n" + ); // TODO: add when "create" is implemented: ansi_code::bold + "stash create" + ansi_code::reset + + auto* show = stash->add_subcommand( + "show", + "Show the changes recorded in the stash entry as a diff between the stashed contents and the commit back when the stash entry was first created.\n" + ); push->add_option("-m,--message", m_message, ""); - pop->add_option("--index", m_index, ""); - apply->add_option("--index", m_index, ""); - show->add_flag("--stat", m_stat_flag, "Generate a diffstat"); - show->add_flag("--shortstat", m_shortstat_flag, "Output only the last line of --stat"); - show->add_flag("--numstat", m_numstat_flag, "Machine-friendly --stat"); - show->add_flag("--summary", m_summary_flag, "Output a condensed summary"); + pop->add_option( + "--index", + m_index, + "This option is only valid for " + ansi_code::bold + "pop" + ansi_code::reset + " and " + + ansi_code::bold + "apply" + ansi_code::reset + " commands.\n" + ); + apply->add_option( + "--index", + m_index, + "This option is only valid for " + ansi_code::bold + "pop" + ansi_code::reset + " and " + + ansi_code::bold + "apply" + ansi_code::reset + " commands.\n" + ); + show->add_flag("--stat", m_stat_flag, "Generate a diffstat\n"); + show->add_flag("--shortstat", m_shortstat_flag, "Output only the last line of --stat\n"); + show->add_flag("--numstat", m_numstat_flag, "Machine-friendly --stat\n"); + show->add_flag("--summary", m_summary_flag, "Output a condensed summary\n"); stash->callback( [this, stash]() From 1ea8bec8dd967e37279885702f08b3ffad6eb433 Mon Sep 17 00:00:00 2001 From: Ian Thomas Date: Thu, 30 Apr 2026 13:56:25 +0100 Subject: [PATCH 16/23] Add wasm test that input prompt using line not character buffering for stdin (#146) * Add wasm test that input prompt using line not character buffering for stdin * Set timeout on wasm test CI run * Update wasm tests in line with latest changes --- .github/workflows/test-wasm.yml | 1 + test/conftest_wasm.py | 3 ++- test/test_commit.py | 24 +++++++++++++++++++----- test/test_status.py | 9 +++++++++ 4 files changed, 31 insertions(+), 6 deletions(-) diff --git a/.github/workflows/test-wasm.yml b/.github/workflows/test-wasm.yml index edb158a..a1f33d5 100644 --- a/.github/workflows/test-wasm.yml +++ b/.github/workflows/test-wasm.yml @@ -11,6 +11,7 @@ concurrency: jobs: build: runs-on: ubuntu-latest + timeout-minutes: 30 steps: - name: Checkout uses: actions/checkout@v6 diff --git a/test/conftest_wasm.py b/test/conftest_wasm.py index b486a6e..d8763e0 100644 --- a/test/conftest_wasm.py +++ b/test/conftest_wasm.py @@ -34,6 +34,7 @@ def pytest_ignore_collect(collection_path: pathlib.Path) -> bool: "test_revlist.py", "test_revparse.py", "test_rm.py", + "test_showref.py", "test_stash.py", "test_status.py", "test_tag.py", @@ -46,7 +47,7 @@ def run_web_server(): cwd = pathlib.Path(__file__).parent.parent / "wasm/test" proc = subprocess.Popen(["npm", "run", "serve"], stdout=f, stderr=f, cwd=cwd) # Wait a bit until server ready to receive connections. - time.sleep(0.5) + time.sleep(1) yield proc.terminate() diff --git a/test/test_commit.py b/test/test_commit.py index 91c67b5..6b3998e 100644 --- a/test/test_commit.py +++ b/test/test_commit.py @@ -2,6 +2,8 @@ import pytest +from .conftest import GIT2CPP_TEST_WASM + @pytest.mark.parametrize("all_flag", ["", "-A", "--all", "--no-ignore-removal"]) def test_commit(commit_env_config, git2cpp_path, tmp_path, all_flag): @@ -33,10 +35,20 @@ def test_commit(commit_env_config, git2cpp_path, tmp_path, all_flag): assert "mook_file" not in p_status_2.stdout -@pytest.mark.parametrize("commit_msg", ["Added file", ""]) +@pytest.mark.parametrize( + "commit_msg_in,commit_msg_out", + [ + ("Added file", "Added file"), + ("", ""), + ("ab\x7fc", "ac"), # Check deletes previous character to prove using stdin line buffering + ], +) def test_commit_message_via_stdin( - commit_env_config, git2cpp_path, tmp_path, run_in_tmp_path, commit_msg + commit_env_config, git2cpp_path, tmp_path, run_in_tmp_path, commit_msg_in, commit_msg_out ): + if not GIT2CPP_TEST_WASM and commit_msg_in != commit_msg_out: + pytest.skip("Skip stdin delete test if not using webassembly") + cmd = [git2cpp_path, "init", "."] p_init = subprocess.run(cmd) assert p_init.returncode == 0 @@ -48,9 +60,11 @@ def test_commit_message_via_stdin( assert p_add.returncode == 0 cmd_commit = [git2cpp_path, "commit"] - p_commit = subprocess.run(cmd_commit, text=True, capture_output=True, input=commit_msg) + p_commit = subprocess.run( + cmd_commit, text=True, capture_output=True, input=commit_msg_in + "\n" + ) - if commit_msg == "": + if commit_msg_out == "": # No commit message assert p_commit.returncode != 0 assert "Aborting, no commit message specified" in p_commit.stderr @@ -66,4 +80,4 @@ def test_commit_message_via_stdin( assert "commit" in lines[0] assert "Author:" in lines[1] assert "Date" in lines[2] - assert commit_msg in lines[4] + assert commit_msg_out in lines[4] diff --git a/test/test_status.py b/test/test_status.py index e8e203a..275da0e 100644 --- a/test/test_status.py +++ b/test/test_status.py @@ -30,6 +30,7 @@ def test_status_new_file(repo_init_with_commit, git2cpp_path, tmp_path, short_fl if long_flag != "": cmd.append(long_flag) p = subprocess.run(cmd, capture_output=True, cwd=tmp_path, text=True, check=True) + p.stdout = strip_ansi_colours(p.stdout) if (long_flag == "--long") or ((long_flag == "") & (short_flag == "")): assert "On branch main" in p.stdout @@ -72,6 +73,7 @@ def test_status_add_file(repo_init_with_commit, git2cpp_path, tmp_path, short_fl cmd_status.append(long_flag) p_status = subprocess.run(cmd_status, capture_output=True, cwd=tmp_path, text=True) assert p_status.returncode == 0 + p_status.stdout = strip_ansi_colours(p_status.stdout) if (long_flag == "--long") or ((long_flag == "") & (short_flag == "")): assert "Changes to be committed" in p_status.stdout @@ -144,6 +146,7 @@ def test_status_rename_detection(repo_init_with_commit, git2cpp_path, tmp_path, cmd_status.append(short_flag) p = subprocess.run(cmd_status, capture_output=True, cwd=tmp_path, text=True) assert p.returncode == 0 + p.stdout = strip_ansi_colours(p.stdout) # Should show as renamed, not as deleted + new file assert "initial.txt -> initial_renamed.txt" in p.stdout @@ -223,6 +226,8 @@ def test_status_typechange(repo_init_with_commit, git2cpp_path, tmp_path, short_ p = subprocess.run(cmd_status, capture_output=True, cwd=tmp_path, text=True) assert p.returncode == 0 + p.stdout = strip_ansi_colours(p.stdout) + # Should show typechange in unstaged changes if short_flag == "-s": assert " T " in p.stdout @@ -295,6 +300,8 @@ def test_status_ahead_of_upstream( p = subprocess.run(cmd_status, capture_output=True, cwd=clone_path, text=True) assert p.returncode == 0 + p.stdout = strip_ansi_colours(p.stdout) + if short_flag == "-s": if branch_flag == "-b": assert "...origin/main" in p.stdout @@ -341,6 +348,8 @@ def test_status_with_branch_and_tracking( p = subprocess.run(cmd_status, capture_output=True, cwd=clone_path, text=True) assert p.returncode == 0 + p.stdout = strip_ansi_colours(p.stdout) + if short_flag == "-s": assert "## main" in p.stdout # "main" locally, but "master" in the CI assert "[ahead 1]" in p.stdout From 17279da70da8a56ad58987e0cc98ebcd0711410e Mon Sep 17 00:00:00 2001 From: Ian Thomas Date: Thu, 30 Apr 2026 13:57:12 +0100 Subject: [PATCH 17/23] Fix display of bold in docs (#152) --- docs/_static/custom.css | 3 +++ docs/conf.py | 3 ++- docs/create_markdown.py | 6 ++++++ src/subcommand/reset_subcommand.cpp | 2 +- src/subcommand/stash_subcommand.cpp | 2 +- 5 files changed, 13 insertions(+), 3 deletions(-) create mode 100644 docs/_static/custom.css diff --git a/docs/_static/custom.css b/docs/_static/custom.css new file mode 100644 index 0000000..ace31ce --- /dev/null +++ b/docs/_static/custom.css @@ -0,0 +1,3 @@ +span.git2cpp-bold { + font-weight: bold; +} diff --git a/docs/conf.py b/docs/conf.py index 3bf0cde..8ad33a3 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -10,7 +10,8 @@ exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] -html_static_path = [] +html_css_files = ["custom.css"] +html_static_path = ["_static"] html_theme = "sphinx_book_theme" html_theme_options = { "github_url": "https://github.com/QuantStack/git2cpp", diff --git a/docs/create_markdown.py b/docs/create_markdown.py index 0c7bec2..8bc3c87 100644 --- a/docs/create_markdown.py +++ b/docs/create_markdown.py @@ -19,6 +19,12 @@ def sanitise_line(line): line = line.replace(r"<", r"<") line = line.replace(r">", r">") + # Replace ansi style codes with elements, only bold is displayed. + # Colour codes are converted to empty elements to match the number of . + line = line.replace("\x1b[1m", r"") + line = line.replace("\x1b[0m", r"") + line = re.sub(r"\x1b\[[^m]+m", r"", line) + # If there are whitespace characters at the start of the line, replace the first with an   # so that it is not discarded by the markdown parser used by the parsed-literal directive. line = re.sub(r"^\s", r" ", line) diff --git a/src/subcommand/reset_subcommand.cpp b/src/subcommand/reset_subcommand.cpp index c3ed07b..efb23c1 100644 --- a/src/subcommand/reset_subcommand.cpp +++ b/src/subcommand/reset_subcommand.cpp @@ -21,7 +21,7 @@ reset_subcommand::reset_subcommand(const libgit2_object&, CLI::App& app) sub->add_flag( "--soft", m_soft_flag, - "Leave your working tree files and the index unchanged. For example, if you have no staged changes, you can use" + "Leave your working tree files and the index unchanged. For example, if you have no staged changes, you can use " + ansi_code::bold + "git reset --soft HEAD~5" + ansi_code::reset + "; " + ansi_code::bold + "git commit" + ansi_code::reset + " to combine the last 5 commits into 1 commit. This works even with changes in the working tree, which are left untouched, but such usage can lead to confusion." diff --git a/src/subcommand/stash_subcommand.cpp b/src/subcommand/stash_subcommand.cpp index 4c4448f..72547a0 100644 --- a/src/subcommand/stash_subcommand.cpp +++ b/src/subcommand/stash_subcommand.cpp @@ -43,7 +43,7 @@ stash_subcommand::stash_subcommand(const libgit2_object&, CLI::App& app) auto* pop = stash->add_subcommand( "pop", "Remove a single stashed state from the stash list and apply it on top of the current working tree state, i.e., do the inverse operation of " - + ansi_code::bold + "git stash push.\n" + ansi_code::reset + + ansi_code::bold + "git stash push" + ansi_code::reset + ".\n" ); auto* apply = stash->add_subcommand( "apply", From b57d3b3c5fbf66c938d084ca386270315829f5c5 Mon Sep 17 00:00:00 2001 From: Ian Thomas Date: Thu, 7 May 2026 13:28:54 +0100 Subject: [PATCH 18/23] Support adding header to wasm http request containing runtime token (#153) --- src/wasm/stream.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/wasm/stream.cpp b/src/wasm/stream.cpp index 4474834..afc039e 100644 --- a/src/wasm/stream.cpp +++ b/src/wasm/stream.cpp @@ -72,6 +72,10 @@ EM_JS( // Should this only be set if using https? What about CORS via http? xhr.setRequestHeader("Authorization", authorization_header_js); } + if (!"{#{RUNTIME_TOKEN}#}".startsWith("{#{")) + { + xhr.setRequestHeader("x-runtime-token", "{#{RUNTIME_TOKEN}#}"); + } // Cache request info on JavaScript side so that it is available in subsequent calls // without having to pass it back and forth to/from C++. From 659a1f3712ae9eeae3429245f779864dcdb456ef Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 12 May 2026 07:33:35 +0100 Subject: [PATCH 19/23] [pre-commit.ci] pre-commit autoupdate (#154) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/pre-commit/mirrors-clang-format: v22.1.4 → v22.1.5](https://github.com/pre-commit/mirrors-clang-format/compare/v22.1.4...v22.1.5) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a173676..73fb1f6 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -17,7 +17,7 @@ repos: args: ['--py312-plus'] - repo: https://github.com/pre-commit/mirrors-clang-format - rev: v22.1.4 + rev: v22.1.5 hooks: - id: clang-format args: [--style=file] From 68ae681930ae90b5e18aab36c839b4acdd4e5ca3 Mon Sep 17 00:00:00 2001 From: Ian Thomas Date: Wed, 13 May 2026 11:43:06 +0100 Subject: [PATCH 20/23] Add user-configurable timeout to wasm http requests (#133) * Add user-configurable timeout to wasm http requests * Timeout error message * Add timeout tests * Add to online docs * Be more careful with signed/unsigned comparisons --- CMakeLists.txt | 3 ++ docs/conf.py | 1 + docs/index.md | 11 +++++++ src/wasm/constants.hpp | 11 +++++++ src/wasm/stream.cpp | 26 +++++++++++++---- src/wasm/subtransport.cpp | 4 ++- src/wasm/subtransport.hpp | 3 +- src/wasm/utils.cpp | 38 ++++++++++++++++++++++++ src/wasm/utils.hpp | 8 +++++ test/test_clone.py | 61 +++++++++++++++++++++++++++++++++++++++ 10 files changed, 158 insertions(+), 8 deletions(-) create mode 100644 src/wasm/constants.hpp create mode 100644 src/wasm/utils.cpp create mode 100644 src/wasm/utils.hpp diff --git a/CMakeLists.txt b/CMakeLists.txt index ef6fb9b..a045f1e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -100,6 +100,7 @@ set(GIT2CPP_SRC ${GIT2CPP_SOURCE_DIR}/utils/progress.hpp ${GIT2CPP_SOURCE_DIR}/utils/terminal_pager.cpp ${GIT2CPP_SOURCE_DIR}/utils/terminal_pager.hpp + ${GIT2CPP_SOURCE_DIR}/wasm/constants.hpp ${GIT2CPP_SOURCE_DIR}/wasm/libgit2_internals.cpp ${GIT2CPP_SOURCE_DIR}/wasm/libgit2_internals.hpp ${GIT2CPP_SOURCE_DIR}/wasm/response.cpp @@ -112,6 +113,8 @@ set(GIT2CPP_SRC ${GIT2CPP_SOURCE_DIR}/wasm/subtransport.hpp ${GIT2CPP_SOURCE_DIR}/wasm/transport.cpp ${GIT2CPP_SOURCE_DIR}/wasm/transport.hpp + ${GIT2CPP_SOURCE_DIR}/wasm/utils.cpp + ${GIT2CPP_SOURCE_DIR}/wasm/utils.hpp ${GIT2CPP_SOURCE_DIR}/wrapper/annotated_commit_wrapper.cpp ${GIT2CPP_SOURCE_DIR}/wrapper/annotated_commit_wrapper.hpp ${GIT2CPP_SOURCE_DIR}/wrapper/branch_wrapper.cpp diff --git a/docs/conf.py b/docs/conf.py index 8ad33a3..9b2109b 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -19,3 +19,4 @@ "show_navbar_depth": 2, } html_title = "git2cpp documentation" +myst_enable_extensions = ["deflist"] diff --git a/docs/index.md b/docs/index.md index 462e7d4..81f5be3 100644 --- a/docs/index.md +++ b/docs/index.md @@ -15,3 +15,14 @@ please create an issue in the [git2cpp github repository](https://github.com/Qua :hidden: created/git2cpp ``` + +## Environment variables + +`GIT_HTTP_TIMEOUT` +: In the WebAssembly build, all http(s) requests are limited by a timeout which has a default of 10 + seconds. To use a different timeout set the `GIT_HTTP_TIMEOUT` environment variable. For example, + to set a timeout of 20 seconds use: + + ```bash + export GIT_HTTP_TIMEOUT=20 + ``` diff --git a/src/wasm/constants.hpp b/src/wasm/constants.hpp new file mode 100644 index 0000000..d6f9dee --- /dev/null +++ b/src/wasm/constants.hpp @@ -0,0 +1,11 @@ +#pragma once + +#include + +// Constants used in wasm transport layer. +// Exposed to non-emscripten builds so that they can be used in help. + +// Environment variable for http transport timeout. +// Must be a positive number as a timeout of 0 will block forever. +inline constexpr std::string_view WASM_HTTP_TRANSPORT_TIMEOUT_NAME = "GIT_HTTP_TIMEOUT"; +inline constexpr unsigned int WASM_HTTP_TRANSPORT_TIMEOUT_DEFAULT_S = 10; diff --git a/src/wasm/stream.cpp b/src/wasm/stream.cpp index afc039e..559c764 100644 --- a/src/wasm/stream.cpp +++ b/src/wasm/stream.cpp @@ -8,6 +8,7 @@ # include # include "../utils/common.hpp" +# include "constants.hpp" # include "response.hpp" // Buffer size used in transport_smart, hardcoded in libgit2. @@ -51,6 +52,7 @@ EM_JS( const char* method, const char* content_type_header, const char* authorization_header, + unsigned long request_timeout_ms, size_t buffer_size), { const url_js = UTF8ToString(url); @@ -76,6 +78,7 @@ EM_JS( { xhr.setRequestHeader("x-runtime-token", "{#{RUNTIME_TOKEN}#}"); } + xhr.timeout = request_timeout_ms; // Cache request info on JavaScript side so that it is available in subsequent calls // without having to pass it back and forth to/from C++. @@ -104,7 +107,7 @@ EM_JS( // clang-format off Module["git2cpp_js_error"] = { name: err.name ?? "", message : err.message ?? "" }; // clang-format on - console.error(err); + (err.name == "TimeoutError" ? console.warn : console.error)(err); return -1; } } @@ -206,7 +209,7 @@ EM_JS( // clang-format off Module["git2cpp_js_error"] = { name: err.name ?? "", message : err.message ?? "" }; // clang-format on - console.error(err); + (err.name == "TimeoutError" ? console.warn : console.error)(err); return -1; } } @@ -217,7 +220,7 @@ EM_JS(void, js_warning, (const char* msg), { console.warning(msg_js); }); -EM_JS(size_t, js_write, (int request_index, const char* buffer, size_t buffer_size), { +EM_JS(int, js_write, (int request_index, const char* buffer, size_t buffer_size), { try { const cache = Module["git2cpp_js_cache"]; @@ -243,7 +246,7 @@ EM_JS(size_t, js_write, (int request_index, const char* buffer, size_t buffer_si // clang-format off Module["git2cpp_js_error"] = { name: err.name ?? "", message : err.message ?? "" }; // clang-format on - console.error(err); + (err.name == "TimeoutError" ? console.warn : console.error)(err); return -1; } }); @@ -271,6 +274,16 @@ static void convert_js_to_git_error(wasm_http_stream* stream) stream->m_unconverted_url.c_str() ); } + else if (std::string_view(error_str).starts_with("TimeoutError:")) + { + git_error_set( + GIT_ERROR_HTTP, + "network request timed out connecting to %s. You can set a longer timeout in seconds using the environment variable %s, the default value is %u seconds.", + stream->m_unconverted_url.c_str(), + WASM_HTTP_TRANSPORT_TIMEOUT_NAME.data(), + WASM_HTTP_TRANSPORT_TIMEOUT_DEFAULT_S + ); + } else { git_error_set(GIT_ERROR_HTTP, "%s", error_str); @@ -285,6 +298,7 @@ static int create_request(wasm_http_stream* stream, std::string_view content_hea name_for_method(stream->m_service.m_method).c_str(), content_header.data(), stream->m_subtransport->m_authorization_header.c_str(), + stream->m_subtransport->m_request_timeout_ms, EMFORGE_BUFSIZE ); return stream->m_request_index; @@ -345,7 +359,7 @@ static int read(wasm_http_stream* stream, wasm_http_response& response, bool is_ &status_text, &response_headers ); - if (bytes_read < 0) + if (bytes_read == static_cast(-1)) { convert_js_to_git_error(stream); // Delete const char* allocated in JavaScript. @@ -537,7 +551,7 @@ int wasm_http_stream_read(git_smart_subtransport_stream* s, char* buffer, size_t bool send = true; while (send) { - if (read(stream, response, false) < 0) + if (read(stream, response, false) == static_cast(-1)) { return -1; // git error already set. } diff --git a/src/wasm/subtransport.cpp b/src/wasm/subtransport.cpp index bdf0202..609f73c 100644 --- a/src/wasm/subtransport.cpp +++ b/src/wasm/subtransport.cpp @@ -2,15 +2,16 @@ # include "subtransport.hpp" +# include # include # include -# include # include # include # include "libgit2_internals.hpp" # include "stream.hpp" +# include "utils.hpp" // C functions. @@ -93,6 +94,7 @@ int create_wasm_http_subtransport(git_smart_subtransport** out, git_transport* o subtransport->m_owner = owner; subtransport->m_base_url = ""; subtransport->m_credential = nullptr; + subtransport->m_request_timeout_ms = get_request_timeout_ms(); *out = &subtransport->m_parent; return 0; diff --git a/src/wasm/subtransport.hpp b/src/wasm/subtransport.hpp index 1bb3bb6..6dd58df 100644 --- a/src/wasm/subtransport.hpp +++ b/src/wasm/subtransport.hpp @@ -17,7 +17,8 @@ struct wasm_http_subtransport // Data stored for reuse on other streams of this transport: std::string m_base_url; std::string m_authorization_header; - git_credential* m_credential; // libgit2 creates this, we are responsible for deleting it. + git_credential* m_credential; // libgit2 creates this, we are responsible for deleting it. + unsigned long m_request_timeout_ms; // Timeout for http(s) requests in milliseconds. }; // git_smart_subtransport_cb diff --git a/src/wasm/utils.cpp b/src/wasm/utils.cpp new file mode 100644 index 0000000..7cc34e5 --- /dev/null +++ b/src/wasm/utils.cpp @@ -0,0 +1,38 @@ +#ifdef EMSCRIPTEN + +# include "utils.hpp" + +# include + +# include "constants.hpp" + +unsigned long get_request_timeout_ms() +{ + double timeout_seconds = WASM_HTTP_TRANSPORT_TIMEOUT_DEFAULT_S; + auto env_var = std::getenv(WASM_HTTP_TRANSPORT_TIMEOUT_NAME.data()); + if (env_var != nullptr) + { + try + { + auto value = std::stod(env_var); + if (value < 1e-3) // Must be at least 1 ms. + { + throw std::runtime_error(""); // Caught below. + } + timeout_seconds = value; + } + catch (std::exception& e) + { + // Catch failures from (1) stod and (2) timeout <= 0. + // Print warning and use default value. + std::cout << termcolor::yellow << "Warning: environment variable " + << WASM_HTTP_TRANSPORT_TIMEOUT_NAME + << " must be a positive number of seconds, using default value of " + << WASM_HTTP_TRANSPORT_TIMEOUT_DEFAULT_S << " seconds instead." << termcolor::reset + << std::endl; + } + } + return 1000 * timeout_seconds; +} + +#endif // EMSCRIPTEN diff --git a/src/wasm/utils.hpp b/src/wasm/utils.hpp new file mode 100644 index 0000000..2325479 --- /dev/null +++ b/src/wasm/utils.hpp @@ -0,0 +1,8 @@ +#pragma once + +#ifdef EMSCRIPTEN + +// Get wasm http request timeout in milliseconds from environment variable or default value. +unsigned long get_request_timeout_ms(); + +#endif // EMSCRIPTEN diff --git a/test/test_clone.py b/test/test_clone.py index 8a3211b..35693ae 100644 --- a/test/test_clone.py +++ b/test/test_clone.py @@ -1,5 +1,6 @@ import pytest import subprocess +from .conftest import GIT2CPP_TEST_WASM xtl_url = "https://github.com/xtensor-stack/xtl.git" @@ -144,3 +145,63 @@ def test_clone_gitlab(git2cpp_path, tmp_path, run_in_tmp_path, protocol): assert p_status.returncode == 0 assert "On branch main" in p_status.stdout assert "Your branch is up to date with 'origin/main'" in p_status.stdout + + +@pytest.mark.skipif(not GIT2CPP_TEST_WASM, reason="Only test in WebAssembly") +def test_clone_timeout(git2cpp_path, tmp_path, run_in_tmp_path): + # Set very short timeout. + subprocess.run(["export", "GIT_HTTP_TIMEOUT=0.001"], check=True) + + # Check timeout is set. + check_env_cmd = ["env"] + p_check_env = subprocess.run(check_env_cmd, capture_output=True, cwd=tmp_path, text=True) + assert "GIT_HTTP_TIMEOUT=0.001" in p_check_env.stdout + + # Clone fails with timeout. + clone_cmd = [git2cpp_path, "clone", xtl_url] + p_clone = subprocess.run(clone_cmd, capture_output=True, cwd=tmp_path, text=True) + assert p_clone.returncode != 0 + assert "network request timed out connecting to" in p_clone.stderr + assert ( + "set a longer timeout in seconds using the environment variable GIT_HTTP_TIMEOUT" + in p_clone.stderr + ) + + # Set more reasonable timeout. + subprocess.run(["export", "GIT_HTTP_TIMEOUT=10"], check=True) + + # Check timeout is set. + p_check_env = subprocess.run(check_env_cmd, capture_output=True, cwd=tmp_path, text=True) + assert "GIT_HTTP_TIMEOUT=10" in p_check_env.stdout + + # Clone succeeds. + clone_cmd = [git2cpp_path, "clone", xtl_url] + p_clone = subprocess.run(clone_cmd, capture_output=True, cwd=tmp_path, text=True) + assert p_clone.returncode == 0 + + assert (tmp_path / "xtl").exists() + assert (tmp_path / "xtl/include").exists() + + +@pytest.mark.skipif(not GIT2CPP_TEST_WASM, reason="Only test in WebAssembly") +def test_clone_negative_timeout_ignored(git2cpp_path, tmp_path, run_in_tmp_path): + # Set negative timeout. + subprocess.run(["export", "GIT_HTTP_TIMEOUT=-1"], check=True) + + # Check timeout is set. + check_env_cmd = ["env"] + p_check_env = subprocess.run(check_env_cmd, capture_output=True, cwd=tmp_path, text=True) + assert "GIT_HTTP_TIMEOUT=-1" in p_check_env.stdout + + # Clone succeeds, ignoring invalid timeout. + clone_cmd = [git2cpp_path, "clone", xtl_url] + p_clone = subprocess.run(clone_cmd, capture_output=True, cwd=tmp_path, text=True) + assert p_clone.returncode == 0 + + assert (tmp_path / "xtl").exists() + assert (tmp_path / "xtl/include").exists() + + assert ( + "environment variable GIT_HTTP_TIMEOUT must be a positive number of seconds" + in p_clone.stdout + ) From d7d2008a5b396d72179052c8163c6490a5b19b99 Mon Sep 17 00:00:00 2001 From: Ian Thomas Date: Thu, 14 May 2026 10:25:01 +0100 Subject: [PATCH 21/23] Make wasm GIT_CORS_PROXY env var much more configurable (#155) --- src/wasm/stream.cpp | 37 ++++++++++-------------- wasm/cockle-deploy/cockle-config-in.json | 1 - wasm/lite-deploy/cockle-config-in.json | 1 - 3 files changed, 16 insertions(+), 23 deletions(-) diff --git a/src/wasm/stream.cpp b/src/wasm/stream.cpp index 559c764..8b356a5 100644 --- a/src/wasm/stream.cpp +++ b/src/wasm/stream.cpp @@ -125,32 +125,27 @@ EM_JS(const char*, js_maybe_convert_url, (const char* url_str), { let ret = url_js; // Default to returning original unconverted URL as new string. if (GIT_CORS_PROXY) { - // clang-format off - const GIT_CORS_PROXY_TYPE = env["GIT_CORS_PROXY_TYPE"] ?? "prefix"; - // clang-format on - if (GIT_CORS_PROXY_TYPE == "prefix") + if (!GIT_CORS_PROXY.includes("{")) { - ret = GIT_CORS_PROXY; - if (ret.at(-1) != '/') - { - ret += '/'; - } - ret += url_js; + ret = GIT_CORS_PROXY + url_js; } - else if (GIT_CORS_PROXY_TYPE == "insert") + else { - ret = url.protocol + "/" + GIT_CORS_PROXY; - if (ret.at(-1) != '/') + ret = GIT_CORS_PROXY; + ret = ret.replace("{url}", url_js); + ret = ret.replace("{url:encode}", encodeURIComponent(url_js)); + ret = ret.replace("{protocol}", url.protocol); + ret = ret.replace("{protocol:encode}", encodeURIComponent(url.protocol)); + ret = ret.replace("{host}", url.host); + ret = ret.replace("{host:encode}", encodeURIComponent(url.host)); + ret = ret.replace("{path}", url.pathname + url.search); + ret = ret.replace("{path:encode}", encodeURIComponent(url.pathname + url.search)); + const api_key = env["GIT_CORS_PROXY_API_KEY"]; + if (api_key) { - ret += '/'; + ret = ret.replace("{api_key}", api_key); } - ret += url.host + url.pathname + url.search; - } - else - { - // clang-format off - console.warn(`Invalid GIT_CORS_PROXY_TYPE of '${GIT_CORS_PROXY_TYPE}'`); - // clang-format on + // Ignore any other use of curly braces. } } return stringToNewUTF8(ret); diff --git a/wasm/cockle-deploy/cockle-config-in.json b/wasm/cockle-deploy/cockle-config-in.json index bbb59a1..95a1db1 100644 --- a/wasm/cockle-deploy/cockle-config-in.json +++ b/wasm/cockle-deploy/cockle-config-in.json @@ -10,7 +10,6 @@ "vi": "vim" }, "environment": { - "GIT_CORS_PROXY": "https://corsproxy.io/?url=", "GIT_AUTHOR_NAME": "Jane Doe", "GIT_AUTHOR_EMAIL": "jane.doe@blabla.com", "GIT_COMMITTER_NAME": "Jane Doe", diff --git a/wasm/lite-deploy/cockle-config-in.json b/wasm/lite-deploy/cockle-config-in.json index bbb59a1..95a1db1 100644 --- a/wasm/lite-deploy/cockle-config-in.json +++ b/wasm/lite-deploy/cockle-config-in.json @@ -10,7 +10,6 @@ "vi": "vim" }, "environment": { - "GIT_CORS_PROXY": "https://corsproxy.io/?url=", "GIT_AUTHOR_NAME": "Jane Doe", "GIT_AUTHOR_EMAIL": "jane.doe@blabla.com", "GIT_COMMITTER_NAME": "Jane Doe", From e76eab1c0a54b64e48ae699108b5f72447873f77 Mon Sep 17 00:00:00 2001 From: Ian Thomas Date: Fri, 15 May 2026 07:07:23 +0100 Subject: [PATCH 22/23] Require yes/no from user if wasm downloading > 10 MB of data (#156) * Separate read buffer class instead of in response class * Store response in stream * Show prompt for large downloads --- CMakeLists.txt | 2 + src/wasm/read_buffer.cpp | 12 ++++ src/wasm/read_buffer.hpp | 18 ++++++ src/wasm/response.cpp | 13 ++-- src/wasm/response.hpp | 9 ++- src/wasm/stream.cpp | 136 +++++++++++++++++++++++---------------- src/wasm/stream.hpp | 2 + src/wasm/utils.cpp | 42 ++++++++++++ src/wasm/utils.hpp | 4 ++ 9 files changed, 170 insertions(+), 68 deletions(-) create mode 100644 src/wasm/read_buffer.cpp create mode 100644 src/wasm/read_buffer.hpp diff --git a/CMakeLists.txt b/CMakeLists.txt index a045f1e..6f542a4 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -103,6 +103,8 @@ set(GIT2CPP_SRC ${GIT2CPP_SOURCE_DIR}/wasm/constants.hpp ${GIT2CPP_SOURCE_DIR}/wasm/libgit2_internals.cpp ${GIT2CPP_SOURCE_DIR}/wasm/libgit2_internals.hpp + ${GIT2CPP_SOURCE_DIR}/wasm/read_buffer.cpp + ${GIT2CPP_SOURCE_DIR}/wasm/read_buffer.hpp ${GIT2CPP_SOURCE_DIR}/wasm/response.cpp ${GIT2CPP_SOURCE_DIR}/wasm/response.hpp ${GIT2CPP_SOURCE_DIR}/wasm/scope.cpp diff --git a/src/wasm/read_buffer.cpp b/src/wasm/read_buffer.cpp new file mode 100644 index 0000000..c4a59ea --- /dev/null +++ b/src/wasm/read_buffer.cpp @@ -0,0 +1,12 @@ +#ifdef EMSCRIPTEN + +# include "read_buffer.hpp" + +read_buffer_t::read_buffer_t(char* buffer, size_t buffer_size, size_t* bytes_read) + : m_buffer(buffer) + , m_buffer_size(buffer_size) + , m_bytes_read(bytes_read) +{ +} + +#endif // EMSCRIPTEN diff --git a/src/wasm/read_buffer.hpp b/src/wasm/read_buffer.hpp new file mode 100644 index 0000000..e874f26 --- /dev/null +++ b/src/wasm/read_buffer.hpp @@ -0,0 +1,18 @@ +#pragma once + +#ifdef EMSCRIPTEN + +# include + +// Buffer used when reading remote data is created by libgit2 and passed to transport layer to use. +// We only fill the buffer and otherwise return it unmodified. +struct read_buffer_t +{ + read_buffer_t(char* buffer, size_t buffer_size, size_t* bytes_read); + + char* m_buffer; + size_t m_buffer_size; + size_t* m_bytes_read; +}; + +#endif // EMSCRIPTEN diff --git a/src/wasm/response.cpp b/src/wasm/response.cpp index 8ba0b1b..af391f8 100644 --- a/src/wasm/response.cpp +++ b/src/wasm/response.cpp @@ -5,13 +5,11 @@ # include "../utils/common.hpp" # include "libgit2_internals.hpp" -wasm_http_response::wasm_http_response(char* buffer, size_t buffer_size, size_t* bytes_read) - : m_buffer(buffer) - , m_buffer_size(buffer_size) - , m_bytes_read(bytes_read) - , m_status(0) +wasm_http_response::wasm_http_response() + : m_status(0) + , m_read_count(0) + , m_total_bytes(0) { - *m_bytes_read = 0; } void wasm_http_response::add_header(const std::string& key, const std::string& value) @@ -21,9 +19,10 @@ void wasm_http_response::add_header(const std::string& key, const std::string& v void wasm_http_response::clear() { - *m_bytes_read = 0; m_status = 0; m_status_text.clear(); + m_read_count = 0; + m_total_bytes = 0; m_response_headers.clear(); } diff --git a/src/wasm/response.hpp b/src/wasm/response.hpp index 42b31c3..127b705 100644 --- a/src/wasm/response.hpp +++ b/src/wasm/response.hpp @@ -13,7 +13,7 @@ class wasm_http_response { public: - wasm_http_response(char* buffer, size_t buffer_size, size_t* bytes_read); + wasm_http_response(); void add_header(const std::string& key, const std::string& value); @@ -29,11 +29,10 @@ class wasm_http_response void set_git_error(std::string_view url) const; - char* m_buffer; // Not owned. - size_t m_buffer_size; - size_t* m_bytes_read; // Not owned. - int32_t m_status; // Specific type corresponding to i32 in emscripten setValue call. + int32_t m_status; // Specific type corresponding to i32 in emscripten setValue call. std::string m_status_text; + unsigned int m_read_count; + int64_t m_total_bytes; // Specific type corresponding to i64 in emscripten setValue call. private: diff --git a/src/wasm/stream.cpp b/src/wasm/stream.cpp index 8b356a5..b16dcc3 100644 --- a/src/wasm/stream.cpp +++ b/src/wasm/stream.cpp @@ -9,7 +9,9 @@ # include "../utils/common.hpp" # include "constants.hpp" +# include "read_buffer.hpp" # include "response.hpp" +# include "utils.hpp" // Buffer size used in transport_smart, hardcoded in libgit2. # define EMFORGE_BUFSIZE 65536 @@ -157,8 +159,10 @@ EM_JS( (int request_index, char* buffer, size_t buffer_size, + bool first_read, // Set subsequent arguments only if this is true. int32_t* status, const char** status_text, + int64_t* total_bytes, const char** response_headers), { try @@ -173,22 +177,28 @@ EM_JS( request.content = null; } - let bytes_read = 0; + let byte_length = 0; // Total number of bytes that will be returned. + let bytes_read = 0; // Number of bytes returned by this call. if (xhr.response && xhr.response.byteLength) { - bytes_read = xhr.response.byteLength - request.result_buffer_pointer; + byte_length = xhr.response.byteLength; + bytes_read = byte_length - request.result_buffer_pointer; if (bytes_read > buffer_size) { bytes_read = buffer_size; } } - // Caller must delete the returned status_text and response_headers. - // clang-format off - setValue(status, xhr.status, 'i32*'); - setValue(status_text, stringToNewUTF8(xhr.statusText ?? ""), 'i8**'); - setValue(response_headers, stringToNewUTF8(xhr.getAllResponseHeaders() ?? ""), 'i8**'); - // clang-format on + if (first_read) + { + // Caller must delete the returned status_text and response_headers. + // clang-format off + setValue(status, xhr.status, 'i32*'); + setValue(status_text, stringToNewUTF8(xhr.statusText ?? ""), 'i8**'); + setValue(total_bytes, byte_length, 'i64*'); + setValue(response_headers, stringToNewUTF8(xhr.getAllResponseHeaders() ?? ""), 'i8**'); + // clang-format on + } if (bytes_read > 0) { @@ -308,7 +318,7 @@ static void delete_request(wasm_http_stream* stream) } } -static int read(wasm_http_stream* stream, wasm_http_response& response, bool is_read_response) +static int read(wasm_http_stream* stream, read_buffer_t& read_buffer, bool is_read_response) { if (is_read_response) { @@ -342,18 +352,23 @@ static int read(wasm_http_stream* stream, wasm_http_response& response, bool is_ } } + auto& response = stream->m_response; + bool first_read = response.m_read_count == 0; const char* status_text = nullptr; const char* response_headers = nullptr; // Actual read. size_t bytes_read = js_read( stream->m_request_index, - response.m_buffer, - response.m_buffer_size, + read_buffer.m_buffer, + read_buffer.m_buffer_size, + first_read, &response.m_status, &status_text, + &response.m_total_bytes, &response_headers ); + stream->m_response.m_read_count++; if (bytes_read == static_cast(-1)) { convert_js_to_git_error(stream); @@ -363,43 +378,52 @@ static int read(wasm_http_stream* stream, wasm_http_response& response, bool is_ return -1; } - response.m_status_text = status_text; - delete status_text; // Delete const char* allocated in JavaScript. - - // Split single string with response headers separated by \r\n into individual headers. - auto lines = split_input_at_newlines(response_headers); - for (const auto& line : lines) + if (first_read) { - auto pos = line.find(":"); - if (pos == std::string::npos) + response.m_status_text = status_text; + delete status_text; // Delete const char* allocated in JavaScript. + + // Split single string with response headers separated by \r\n into individual headers. + auto lines = split_input_at_newlines(response_headers); + for (const auto& line : lines) { - // Skip invalid lines. Should this be an error condition? - continue; + auto pos = line.find(":"); + if (pos == std::string::npos) + { + // Skip invalid lines. Should this be an error condition? + continue; + } + response.add_header(line.substr(0, pos), line.substr(pos + 1)); } - response.add_header(line.substr(0, pos), line.substr(pos + 1)); - } - delete response_headers; // Delete const char* allocated in JavaScript. + delete response_headers; // Delete const char* allocated in JavaScript. - // If successful, check expected response content-type is correct. - if (response.m_status == GIT_HTTP_STATUS_OK) - { - auto expected_response_type = stream->m_service.m_response_type; - if (!expected_response_type.empty() - && !response.has_header_matches("content-type", expected_response_type)) + // If successful, check expected response content-type is correct. + if (response.m_status == GIT_HTTP_STATUS_OK) { - // Not sure this should be checked at all, as CORS proxy may be doing something - // with it. - git_error_set( - GIT_ERROR_HTTP, - "expected response content-type header '%s' to request %s", - expected_response_type.c_str(), - stream->m_unconverted_url.c_str() - ); + auto expected_response_type = stream->m_service.m_response_type; + if (!expected_response_type.empty() + && !response.has_header_matches("content-type", expected_response_type)) + { + // Not sure this should be checked at all, as CORS proxy may be doing something + // with it. + git_error_set( + GIT_ERROR_HTTP, + "expected response content-type header '%s' to request %s", + expected_response_type.c_str(), + stream->m_unconverted_url.c_str() + ); + return -1; + } + } + + if (!maybe_prompt_to_download(response.m_total_bytes)) + { + git_error_set(GIT_ERROR_NONE, "Download aborted"); return -1; } } - *response.m_bytes_read = bytes_read; + *read_buffer.m_bytes_read = bytes_read; return 0; } @@ -427,7 +451,7 @@ static int write(wasm_http_stream* stream, const char* buffer, size_t buffer_siz // C credential functions. -static int create_credential(wasm_http_stream* stream, const wasm_http_response& response) +static int create_credential(wasm_http_stream* stream) { wasm_http_subtransport* subtransport = stream->m_subtransport; @@ -440,7 +464,7 @@ static int create_credential(wasm_http_stream* stream, const wasm_http_response& subtransport->m_authorization_header = ""; // Check that response headers show support for 'www-authenticate: Basic'. - if (!response.has_header_starts_with("www-authenticate", "Basic")) + if (!stream->m_response.has_header_starts_with("www-authenticate", "Basic")) { git_error_set( GIT_ERROR_HTTP, @@ -541,36 +565,36 @@ void wasm_http_stream_free(git_smart_subtransport_stream* s) int wasm_http_stream_read(git_smart_subtransport_stream* s, char* buffer, size_t buffer_size, size_t* bytes_read) { wasm_http_stream* stream = reinterpret_cast(s); - wasm_http_response response(buffer, buffer_size, bytes_read); + read_buffer_t read_buffer(buffer, buffer_size, bytes_read); bool send = true; while (send) { - if (read(stream, response, false) == static_cast(-1)) + if (read(stream, read_buffer, false) == static_cast(-1)) { return -1; // git error already set. } send = false; - auto final_url_header = response.get_header("x-final-url"); + auto final_url_header = stream->m_response.get_header("x-final-url"); if (final_url_header.has_value() && stream->ensure_final_url(final_url_header.value()) - && response.m_status != GIT_HTTP_STATUS_OK) + && stream->m_response.m_status != GIT_HTTP_STATUS_OK) { // Resend only if status not OK, if OK next request will use updated URL. send = true; } - if (response.has_header("strict-transport-security") && stream->ensure_https() - && response.m_status != GIT_HTTP_STATUS_OK) + if (stream->m_response.has_header("strict-transport-security") && stream->ensure_https() + && stream->m_response.m_status != GIT_HTTP_STATUS_OK) { // Resend only if status not OK, if OK next request will use https not http. send = true; } - if (response.m_status == GIT_HTTP_STATUS_UNAUTHORIZED) + if (stream->m_response.m_status == GIT_HTTP_STATUS_UNAUTHORIZED) { // Request and create new credentials. - if (create_credential(stream, response) < 0) + if (create_credential(stream) < 0) { return -1; // git error already set. } @@ -580,13 +604,13 @@ int wasm_http_stream_read(git_smart_subtransport_stream* s, char* buffer, size_t if (send) { delete_request(stream); - response.clear(); + stream->m_response.clear(); } } - if (response.m_status != GIT_HTTP_STATUS_OK) + if (stream->m_response.m_status != GIT_HTTP_STATUS_OK) { - response.set_git_error(stream->m_unconverted_url); + stream->m_response.set_git_error(stream->m_unconverted_url); return -1; } @@ -597,15 +621,15 @@ int wasm_http_stream_read_response(git_smart_subtransport_stream* s, char* buffe { wasm_http_stream* stream = reinterpret_cast(s); - wasm_http_response response(buffer, buffer_size, bytes_read); - int error = read(stream, response, true); + read_buffer_t read_buffer(buffer, buffer_size, bytes_read); + int error = read(stream, read_buffer, true); // May need similar handling of response status and headers as occurs in read() above, but so // far this has not been necessary. - if (error == 0 && response.m_status != GIT_HTTP_STATUS_OK) + if (error == 0 && stream->m_response.m_status != GIT_HTTP_STATUS_OK) { - response.set_git_error(stream->m_unconverted_url); + stream->m_response.set_git_error(stream->m_unconverted_url); error = -1; } diff --git a/src/wasm/stream.hpp b/src/wasm/stream.hpp index a17c6f0..712a35a 100644 --- a/src/wasm/stream.hpp +++ b/src/wasm/stream.hpp @@ -5,6 +5,7 @@ # include # include "libgit2_internals.hpp" +# include "response.hpp" # include "subtransport.hpp" // A stream represents a single http/https request. @@ -26,6 +27,7 @@ struct wasm_http_stream http_service m_service; std::string m_unconverted_url; int m_request_index; + wasm_http_response m_response; }; void wasm_http_stream_free(git_smart_subtransport_stream* s); diff --git a/src/wasm/utils.cpp b/src/wasm/utils.cpp index 7cc34e5..3027326 100644 --- a/src/wasm/utils.cpp +++ b/src/wasm/utils.cpp @@ -2,8 +2,14 @@ # include "utils.hpp" +# include +# include +# include +# include + # include +# include "../utils/input_output.hpp" # include "constants.hpp" unsigned long get_request_timeout_ms() @@ -35,4 +41,40 @@ unsigned long get_request_timeout_ms() return 1000 * timeout_seconds; } +std::string human_readable_size(double bytes) +{ + static constexpr const char* units[] = {"B", "kB", "MB", "GB", "TB"}; + int nunits = sizeof(units) / sizeof(units[0]); + int index = std::trunc(std::log10(bytes) / 3); + index = std::clamp(index, 0, nunits - 1, std::less()); + + std::ostringstream oss; + oss << std::fixed << std::setprecision(1) << (bytes / std::pow(1000.0, index)) << ' ' << units[index]; + return oss.str(); +} + +bool maybe_prompt_to_download(double bytes) +{ + if (bytes <= 0.0) + { + return true; + } + + // Limit + double bytes_limit = 1e7; // 10 MB + if (bytes > bytes_limit) + { + if (!termcolor::_internal::is_atty(std::cout)) + { + // If not a tty cannot prompt user so prevent the download. + return false; + } + + std::string prompt = "Download size is " + human_readable_size(bytes) + ", continue [y/N]? "; + return prompt_yes_or_no(prompt, false); + } + + return true; +} + #endif // EMSCRIPTEN diff --git a/src/wasm/utils.hpp b/src/wasm/utils.hpp index 2325479..ace9191 100644 --- a/src/wasm/utils.hpp +++ b/src/wasm/utils.hpp @@ -5,4 +5,8 @@ // Get wasm http request timeout in milliseconds from environment variable or default value. unsigned long get_request_timeout_ms(); +// Return true if OK to continue with download, false otherwise. +// May or may not prompt the user for input depending on the size of the download. +bool maybe_prompt_to_download(double bytes); + #endif // EMSCRIPTEN From b393f98abfc0ec798e66aa70393e88f81098856a Mon Sep 17 00:00:00 2001 From: Ian Thomas Date: Fri, 15 May 2026 07:14:26 +0100 Subject: [PATCH 23/23] Prepare for 0.0.9 release (#158) --- src/version.hpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/version.hpp b/src/version.hpp index ff85787..06c9ebb 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 8 +#define GIT2CPP_VERSION_PATCH 9 // e.g. ".rc0" #define GIT2CPP_VERSION_SUFFIX