diff --git a/.github/workflows/deploy-wasm.yml b/.github/workflows/deploy-wasm.yml index afb0c0e..f75e82a 100644 --- a/.github/workflows/deploy-wasm.yml +++ b/.github/workflows/deploy-wasm.yml @@ -16,7 +16,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Setup Python uses: actions/setup-python@v6 diff --git a/.github/workflows/test-wasm.yml b/.github/workflows/test-wasm.yml index a1f33d5..5bbddc1 100644 --- a/.github/workflows/test-wasm.yml +++ b/.github/workflows/test-wasm.yml @@ -14,7 +14,7 @@ jobs: timeout-minutes: 30 steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Setup Python uses: actions/setup-python@v6 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a3764f6..73f41cf 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -23,7 +23,7 @@ jobs: os: [ubuntu-latest, macos-latest] steps: - name: Checkout source - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: fetch-depth: 0 @@ -58,7 +58,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout source - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: fetch-depth: 0 @@ -97,7 +97,7 @@ jobs: path: outdir - name: Upload coverage to codecov - uses: codecov/codecov-action@v6 + uses: codecov/codecov-action@v7 with: files: coverage.lcov token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 73fb1f6..8472f9a 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.12 + rev: v0.15.20 hooks: - id: ruff-check args: [--fix] diff --git a/CMakeLists.txt b/CMakeLists.txt index 6f542a4..48e3e5b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -149,6 +149,8 @@ set(GIT2CPP_SRC ${GIT2CPP_SOURCE_DIR}/wrapper/signature_wrapper.hpp ${GIT2CPP_SOURCE_DIR}/wrapper/status_wrapper.cpp ${GIT2CPP_SOURCE_DIR}/wrapper/status_wrapper.hpp + ${GIT2CPP_SOURCE_DIR}/wrapper/strarray_wrapper.cpp + ${GIT2CPP_SOURCE_DIR}/wrapper/strarray_wrapper.hpp ${GIT2CPP_SOURCE_DIR}/wrapper/tag_wrapper.cpp ${GIT2CPP_SOURCE_DIR}/wrapper/tag_wrapper.hpp ${GIT2CPP_SOURCE_DIR}/wrapper/tree_wrapper.cpp diff --git a/docs/env_vars.md b/docs/env_vars.md new file mode 100644 index 0000000..40bcc76 --- /dev/null +++ b/docs/env_vars.md @@ -0,0 +1,77 @@ +# Environment variables + + +## In all builds + +The following four environment variables should be set for `git2cpp commit` and `git2cpp merge` +subcommands. The use of `git2cpp config` instead is partially supported and will be improved in +time. + +`GIT_AUTHOR_EMAIL` +: The email for the "author" field. + +`GIT_AUTHOR_NAME` +: The human-readable name in the "author" field. + +`GIT_COMMITTER_EMAIL` +: The email for the "committer" field. + +`GIT_COMMITTER_NAME` +: The human-readable name for the "committer" field. + +`GIT_CREDENTIAL_CALLBACK` +: By default, `git2cpp` will prompt the user to enter a username and password if they are required + for remote authentication, using a + [libgit2 credential callback](https://libgit2.org/docs/reference/main/credential/git_credential_acquire_cb.html). + To disable the callback use `export GIT_CREDENTIAL_CALLBACK=0`. + + +## In WebAssembly build only + +(git_cors_proxy)= +`GIT_CORS_PROXY` +: In-browser remote `git2cpp` operations such as `clone`, `fetch` and `push` usually require use of + a [CORS proxy server](cors_proxy). Use this environment variable to specify how the target URL is + encoded into the CORS proxy URL, details of which depend on how the CORS proxy server is + implemented. + + The `GIT_CORS_PROXY` should contain the URL of the CORS proxy itself, followed by a number of + substitutions which are denoted by curly braces. To illustrate the substitutions, assume that the + `git2cpp` command is for the repository at `https://github.com/organisation/repository`. + + Substitutions: + + - `{host}` is replaced by `github.com` + - `{path}` is replaced by `/organisation/repository/` followed by extra information that depends + on details of the `git2cpp` operation being performed + - `{protocol}` is replaced by `https:` + - `{url}` is equivalent to `{protocol}//{host}{path}` + - `{api_key}` is replaced by the value of environment variable `GIT_CORS_PROXY_API_KEY` if it is + set. + + If no substitutions are specified then `{url}` is appended. + + All of the substitutions except `{api_key}` have an `:encode` variant such as `{url:encode}` + that passes the argument through the + [encodeURIComponent](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURIComponent) + JavaScript function, which some CORS proxies require. + + You can verify the actual URL used for requests in the Network tab of your browser's Developer + Tools for debugging purposes. + + See [CORS proxy server](cors_proxy) for usage examples. + +`GIT_CORS_PROXY_API_KEY` +: This value is used to replace the `{api_key}` in `GIT_CORS_PROXY` and is intended for use with a + CORS proxy that requires an API key. Alternatively the API key could be put directly in the + `GIT_CORS_PROXY` instead. + +(git_http_timeout)= +`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/docs/index.md b/docs/index.md index 81f5be3..c8799bb 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,28 +1,28 @@ # Overview `git2cpp` is a C++ wrapper of [libgit2](https://libgit2.org/) to provide a command-line interface -(CLI) to `git` functionality. The intended use is in WebAssembly in-browser terminals (the -[cockle](https://github.com/jupyterlite/cockle) and -[JupyterLite terminal](https://github.com/jupyterlite/terminal) projects) but it can be compiled and +(CLI) to `git` functionality. The intended use is in WebAssembly in-browser terminals (see the +[cockle](https://github.com/jupyterlite/cockle), +[JupyterLite terminal](https://github.com/jupyterlite/terminal) and +[Notebook.link](https://notebook.link) projects) but it can be compiled and used on any POSIX-compliant system. The Help pages here are generated from the `git2cpp` command and subcommands to show the functionality that is currently supported. If there are features missing that you would like to use, please create an issue in the [git2cpp github repository](https://github.com/QuantStack/git2cpp). +The Appendix contains additional information on [Environment variables](env_vars.md) used in +`git2cpp` and about the behaviour of the [WebAssembly build](wasm_build.md). + ```{toctree} :caption: Help pages :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 - ``` +```{toctree} +:caption: Appendix +:hidden: +env_vars +wasm_build +``` diff --git a/docs/wasm_build.md b/docs/wasm_build.md new file mode 100644 index 0000000..f5f452d --- /dev/null +++ b/docs/wasm_build.md @@ -0,0 +1,89 @@ +# WebAssembly build + +The in-browser WebAssembly build of `git2cpp` is intended to behave as similar as possible to other +builds but there are some differences when remotely accessing remote servers, in particular with +blocking requests and the requirement for a CORS proxy server. + + +## Blocking requests + +Remote `git2cpp` requests in non-WebAssembly builds are progressive and feedback is provided as the +data is streamed back from the remote. But in WebAssembly builds such remote requests are blocking +and no feedback can be provided until the entire response is received back from the remote. +This can be a long time to wait without feedback, and if there is an error such that a response is +not received it could block forever, leaving the in-browser terminal unusable. + +Hence the WebAssembly build limits http(s) requests with a timeout that defaults to 10 seconds. +This timeout can be increased using the [`GIT_HTTP_TIMEOUT` environment variable](git_http_timeout). + +In addition, when a `git2cpp` response is received that is larger than 10 MB, a prompt is presented +to the user to confirm whether to proceed to unpack the response or not. Note that the size of the +response may be smaller or larger than the size of the directory structure it unpacks to. + + +(cors_proxy)= +## CORS proxy server + +The fetching of resources in a browser from one domain to another is often limited by a browser +security feature called +[Cross-Origin Resource Sharing](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS) +(CORS). For this to be allowed the target domain must indicate it is happy to accept cross-origin +requests by adding certain headers to `https` responses. Most git servers such as `github.com` do +not add these headers, so a `git2cpp clone` from `github.com` will fail with a CORS error if run +from within a browser whereas there is no such limitation if run from a terminal of a real computer. + +The solution to this problem is to use a separate CORS proxy server. The `git2cpp` remote request is +sent to this intermediate server which send the request to the target server and as this request is +coming from outside a browser it is not subject to CORS restrictions. The proxy receives the +response from the target server and adds the required CORS headers before returning it to `git2cpp`. + +Various public CORS proxy servers are available for use or you serve your own. It can be useful to +serve your own on `localhost` when experimenting to confirm that everything works as expected +before moving on to a more complex solution. Be aware that the CORS proxy server is able to read the +content of your request so be careful if you are using authentication tokens. + +The [`GIT_CORS_PROXY` environment variable](git_cors_proxy) is used to specify how the target URL is +encoded in the CORS proxy URL. + +### Example running a local CORS proxy server + +If you are running a local [cockle](https://github.com/jupyterlite/cockle) or +[JupyterLite terminal](https://github.com/jupyterlite/terminal) deployment you can also run a local +CORS proxy such as [CORS Anywhere](https://github.com/Rob--W/cors-anywhere) to test it out. +In a separate terminal on your host machine on which you have `nodejs` available, `cd` to a new +clean directory, and download and run the CORS proxy server using: + +```js +npm install cors-anywhere +HOST=localhost PORT=8881 node node_modules/cors-anywhere/server.js +``` + +This will start the CORS proxy server listening on `http://localhost:8881/`. To use this in your +local `cockle` or `JupyterLite terminal` deployment in your browser set the `CORS_PROXY_URL` to be +```bash +export GIT_CORS_PROXY=http://localhost:8881/ +``` +and then try a `git2cpp clone` using something like: +```bash +git2cpp clone https://github.com/some-organisation/some-repository +``` + +### Example using a public CORS proxy server + +There is a public instance of [CORS Anywhere](https://github.com/Rob--W/cors-anywhere) available at +`https://cors-anywhere.herokuapp.com/`. This can be used for demonstration purposes but it requires +an explicit opt-in and your access will be time-limited. To request temporary access go to +`https://cors-anywhere.herokuapp.com/` and follow the instructions there. + +Once you have access, you can try this out in one of the public deployments such as those at +[https://jupyterlite.github.io/cockle](https://jupyterlite.github.io/cockle) or +[https://jupyterlite.github.io/terminal](https://jupyterlite.github.io/terminal). + +Set the `CORS_PROXY_URL` to be +```bash +export GIT_CORS_PROXY=https://cors-anywhere.herokuapp.com/ +``` +and then try a `git2cpp clone` using something like: +```bash +git2cpp clone https://github.com/some-organisation/some-repository +``` diff --git a/src/subcommand/checkout_subcommand.cpp b/src/subcommand/checkout_subcommand.cpp index ccc24f9..d2b29cd 100644 --- a/src/subcommand/checkout_subcommand.cpp +++ b/src/subcommand/checkout_subcommand.cpp @@ -1,8 +1,10 @@ #include "../subcommand/checkout_subcommand.hpp" +#include #include #include -#include + +#include #include "../subcommand/status_subcommand.hpp" #include "../utils/git_exception.hpp" @@ -13,7 +15,11 @@ checkout_subcommand::checkout_subcommand(const libgit2_object&, CLI::App& app) { auto* sub = app.add_subcommand("checkout", "Switch branches or restore working tree files"); - sub->add_option("", m_branch_name, "Branch to checkout"); + sub->add_option( + "", + m_positional_args, + "Tree-ish to checkout, and/or one/many pathspec(s)\ne.g. checkout , checkout , checkout ..., checkout ...\nNote: use without '--'" + ); sub->add_flag("-b", m_create_flag, "Create a new branch before checking it out"); sub->add_flag("-B", m_force_create_flag, "Create a new branch or reset it if it exists before checking it out"); sub->add_flag( @@ -30,21 +36,76 @@ checkout_subcommand::checkout_subcommand(const libgit2_object&, CLI::App& app) ); } -void print_no_switch(status_list_wrapper& sl) +namespace { - std::cout << "Your local changes to the following files would be overwritten by checkout:" << std::endl; + void print_no_switch(status_list_wrapper& sl) + { + std::cout << "Your local changes to the following files would be overwritten by checkout:" << std::endl; + + for (const auto* entry : sl.get_entry_list(GIT_STATUS_WT_MODIFIED)) + { + std::cout << "\t" << entry->index_to_workdir->new_file.path << std::endl; + } + for (const auto* entry : sl.get_entry_list(GIT_STATUS_WT_DELETED)) + { + std::cout << "\t" << entry->index_to_workdir->old_file.path << std::endl; + } + + std::cout << "Please commit your changes or stash them before you switch branches.\nAborting" + << std::endl; + return; + } +} - for (const auto* entry : sl.get_entry_list(GIT_STATUS_WT_MODIFIED)) +std::vector convert_paths_to_cstr(const std::vector& pathspecs) +{ + std::vector pathspec_strings; + pathspec_strings.reserve(pathspecs.size()); + for (const auto& f : pathspecs) { - std::cout << "\t" << entry->index_to_workdir->new_file.path << std::endl; + pathspec_strings.push_back(f.c_str()); } - for (const auto* entry : sl.get_entry_list(GIT_STATUS_WT_DELETED)) + return pathspec_strings; +} + +void checkout_subcommand::checkout_head_files( + const repository_wrapper& repo, + const std::vector& pathspecs, + const git_checkout_options& base_options +) +{ + std::vector pathspec_strings = convert_paths_to_cstr(pathspecs); + + git_checkout_options options = base_options; + options.paths.strings = const_cast(pathspec_strings.data()); + options.paths.count = pathspec_strings.size(); + + throw_if_error(git_checkout_head(repo, &options)); +} + +void checkout_subcommand::checkout_ref_files( + const repository_wrapper& repo, + const std::string_view tree_ish, + const std::vector& pathspecs, + const git_checkout_options& base_options +) +{ + auto obj = repo.revparse_single(tree_ish); + if (!obj) { - std::cout << "\t" << entry->index_to_workdir->old_file.path << std::endl; + throw git_exception( + "error: could not resolve tree-ish '" + std::string(tree_ish) + "'", + git2cpp_error_code::BAD_ARGUMENT + ); } - std::cout << "Please commit your changes or stash them before you switch branches.\nAborting" << std::endl; - return; + std::vector pathspec_strings = convert_paths_to_cstr(pathspecs); + + git_checkout_options options = base_options; + options.paths.strings = const_cast(pathspec_strings.data()); + options.paths.count = pathspec_strings.size(); + + throw_if_error(git_checkout_tree(repo, *obj, &options)); } void checkout_subcommand::run() @@ -69,57 +130,130 @@ void checkout_subcommand::run() options.checkout_strategy = GIT_CHECKOUT_SAFE; } + if (m_positional_args.empty()) + { + throw std::runtime_error("error: no branch or file specified"); + } + + const std::string& target_name = m_positional_args[0]; // can be a branch or a tag + const std::vector pathspecs(m_positional_args.begin() + 1, m_positional_args.end()); + if (m_create_flag || m_force_create_flag) { - auto annotated_commit = create_local_branch(repo, m_branch_name, m_force_create_flag); - checkout_tree(repo, annotated_commit, m_branch_name, options); - update_head(repo, annotated_commit, m_branch_name); + if (!pathspecs.empty()) + { + throw git_exception("error: '-b' or '-B' does not accept pathspecs.", git2cpp_error_code::BAD_ARGUMENT); + } + + auto annotated_commit = create_local_branch(repo, target_name, m_force_create_flag); + checkout_tree(repo, annotated_commit, target_name, options); + update_head(repo, annotated_commit, target_name); - std::cout << "Switched to a new branch '" << m_branch_name << "'" << std::endl; + std::cout << "Switched to a new branch '" << target_name << "'" << std::endl; } - else + else if (!pathspecs.empty()) { - auto optional_commit = repo.resolve_local_ref(m_branch_name); - if (!optional_commit) + // Validate all pathspecs before checkout so we can mimic git-like errors + auto lambda_validate_paths = + [](repository_wrapper& repo, const std::vector pathspecs, std::string directory) { - // TODO: handle remote refs - std::ostringstream buffer; - buffer << "error: could not resolve pathspec '" << m_branch_name << "'" << std::endl; - throw std::runtime_error(buffer.str()); - } + for (const auto& p : pathspecs) + { + if (!std::filesystem::exists(std::filesystem::path(directory) / p) && !repo.does_track(p)) + { + throw git_exception( + "error: pathspec '" + p + "' did not match any file(s) known to git", + git2cpp_error_code::BAD_ARGUMENT + ); + } + } + }; - auto sl = status_list_wrapper::status_list(repo); - try + // Try tree-ish + pathspec(s) + if (auto obj = repo.revparse_single(target_name)) { - checkout_tree(repo, *optional_commit, m_branch_name, options); - update_head(repo, *optional_commit, m_branch_name); + lambda_validate_paths(repo, pathspecs, directory); + + options.checkout_strategy = GIT_CHECKOUT_FORCE; + checkout_ref_files(repo, target_name, pathspecs, options); } - catch (const git_exception& e) + // Else treat as files + else { - if (sl.has_notstagged_header()) - { - print_no_switch(sl); - } - throw e; + lambda_validate_paths(repo, pathspecs, directory); + + std::vector files = m_positional_args; + options.checkout_strategy = GIT_CHECKOUT_FORCE; + checkout_head_files(repo, files, options); } + return; + } - if (sl.has_notstagged_header()) + auto optional_commit = repo.resolve_local_ref(target_name); + if (!optional_commit) + { + // TODO: handle remote refs + + // Fall back to checking out a unique file + const std::vector file = {target_name}; + + if (!std::filesystem::exists(std::filesystem::path(directory) / target_name)) { - bool is_long = false; - bool is_coloured = false; - std::set tracked_dir_set{}; - print_notstagged(sl, tracked_dir_set, is_long, is_coloured); + // Neither a branch/tag nor a file + throw git_exception( + "error: pathspec '" + target_name + "' did not match any file(s) known to git", + git2cpp_error_code::BAD_ARGUMENT + ); } - if (sl.has_tobecommited_header()) + + options.checkout_strategy = GIT_CHECKOUT_FORCE; + checkout_head_files(repo, file, options); + return; + } + + auto sl = status_list_wrapper::status_list(repo); + try + { + checkout_tree(repo, *optional_commit, target_name, options); + update_head(repo, *optional_commit, target_name); + } + catch (const git_exception& e) + { + if (sl.has_notstagged_header()) { - bool is_long = false; - bool is_coloured = false; - std::set tracked_dir_set{}; - print_tobecommited(sl, tracked_dir_set, is_long, is_coloured); + print_no_switch(sl); } - std::cout << "Switched to branch '" << m_branch_name << "'" << std::endl; + throw e; + } + + if (sl.has_notstagged_header()) + { + bool is_long = false; + bool is_coloured = false; + std::set tracked_dir_set{}; + print_notstagged(sl, tracked_dir_set, is_long, is_coloured); + } + if (sl.has_tobecommited_header()) + { + bool is_long = false; + bool is_coloured = false; + std::set tracked_dir_set{}; + print_tobecommited(sl, tracked_dir_set, is_long, is_coloured); + } + + std::string_view annotated_ref = optional_commit->reference_name(); + if (!annotated_ref.empty() && repo.find_reference(annotated_ref).is_branch()) + { + std::cout << "Switched to branch '" << target_name << "'" << std::endl; print_tracking_info(repo, sl, true, false); } + else + { + std::string sha = optional_commit->commit_oid_tostr().substr(0, 7); + auto commit = repo.find_commit(optional_commit->oid()); + std::string summary = commit.summary(); + std::cout << "HEAD is now at " << sha << " " << summary << std::endl; + } } annotated_commit_wrapper @@ -146,22 +280,71 @@ void checkout_subcommand::update_head( const std::string_view target_name ) { + // Check if HEAD is already detached or not + const bool head_was_detached = [&]() + { + auto head_ref = repo.head(); + return !head_ref.is_branch(); + }(); + + // Save previous HEAD info (if it was detached) before changing it (for output message) + std::optional previous_head_commit; + std::string previous_head_message; + if (head_was_detached) + { + previous_head_commit = repo.find_commit("HEAD"); + previous_head_message = "Previous HEAD position was " + + std::string(previous_head_commit.value().commit_oid_tostr().substr(0, 7)) + + " " + previous_head_commit.value().summary(); + } + std::string_view annotated_ref = target_annotated_commit.reference_name(); if (!annotated_ref.empty()) { auto ref = repo.find_reference(annotated_ref); - if (ref.is_remote()) + if (ref.is_branch()) { - auto branch = repo.create_branch(target_name, target_annotated_commit); - repo.set_head(branch.reference_name()); + if (head_was_detached) + { + std::cout << previous_head_message << std::endl; + } + repo.set_head(annotated_ref); + return; } - else + } + + repo.set_head_detached(target_annotated_commit); + + if (head_was_detached) + { + // Only print "Previous HEAD position..." if HEAD was already detached before and if there is an + // actual checkout + auto new_head_commit = repo.find_commit("HEAD"); + if (!git_oid_equal(&previous_head_commit.value().oid(), &new_head_commit.oid())) { - repo.set_head(annotated_ref); + std::cout << previous_head_message << std::endl; } } else { - repo.set_head_detached(target_annotated_commit); + // Only print the detached-HEAD advice if HEAD was not already detached. + std::cout << "Note: switching to '" << target_name << "'." << std::endl; + std::cout << std::endl; + std::cout << "You are in 'detached HEAD' state. You can look around, make experimental" << std::endl; + std::cout << "changes and commit them, and you can discard any commits you make in this" << std::endl; + std::cout << "state without impacting any branches by switching back to a branch." << std::endl; + std::cout << std::endl; + + // TODO: add to the following when the switch subcommand is implemented: + // std::cout << "If you want to create a new branch to retain commits you create, you may" << + // std::endl; std::cout << "do so (now or later) by using -c with the switch command. Example:" << + // std::endl; std::cout << " git switch -c " << std::endl; std::cout << std::endl; + // std::cout << "Or undo this operation with:" << std::endl; + // std::cout << std::endl; + // std::cout << " git switch -" << std::endl; + // std::cout << std::endl; + // TODO: add the following later + // std::cout << "Turn off this advice by setting config variable advice.detachedHead to false" + // << std::endl; } } diff --git a/src/subcommand/checkout_subcommand.hpp b/src/subcommand/checkout_subcommand.hpp index 99661d4..d34f94b 100644 --- a/src/subcommand/checkout_subcommand.hpp +++ b/src/subcommand/checkout_subcommand.hpp @@ -1,7 +1,7 @@ #pragma once -#include #include +#include #include @@ -33,7 +33,20 @@ class checkout_subcommand const std::string_view target_name ); - std::string m_branch_name = {}; + void checkout_head_files( + const repository_wrapper& repo, + const std::vector& files, + const git_checkout_options& options + ); + + void checkout_ref_files( + const repository_wrapper& repo, + const std::string_view tree_ish, + const std::vector& pathspecs, + const git_checkout_options& options + ); + + std::vector m_positional_args = {}; bool m_create_flag = false; bool m_force_create_flag = false; bool m_force_checkout_flag = false; diff --git a/src/subcommand/clone_subcommand.cpp b/src/subcommand/clone_subcommand.cpp index af945ce..b93af35 100644 --- a/src/subcommand/clone_subcommand.cpp +++ b/src/subcommand/clone_subcommand.cpp @@ -53,7 +53,10 @@ void clone_subcommand::run() checkout_opts.progress_cb = checkout_progress; checkout_opts.progress_payload = &pd; clone_opts.checkout_opts = checkout_opts; - clone_opts.fetch_opts.callbacks.credentials = user_credentials; + if (want_user_credentials()) + { + clone_opts.fetch_opts.callbacks.credentials = user_credentials; + } clone_opts.fetch_opts.callbacks.sideband_progress = sideband_progress; clone_opts.fetch_opts.callbacks.transfer_progress = fetch_progress; clone_opts.fetch_opts.callbacks.payload = &pd; diff --git a/src/subcommand/diff_subcommand.cpp b/src/subcommand/diff_subcommand.cpp index 4527fec..434d318 100644 --- a/src/subcommand/diff_subcommand.cpp +++ b/src/subcommand/diff_subcommand.cpp @@ -71,6 +71,129 @@ diff_subcommand::diff_subcommand(const libgit2_object&, CLI::App& app) ); } +namespace +{ + int colour_printer( + [[maybe_unused]] const git_diff_delta* delta, + [[maybe_unused]] const git_diff_hunk* hunk, + const git_diff_line* line, + void* payload + ) + { + bool use_colour = *reinterpret_cast(payload); + + // Only print origin for context/addition/deletion lines + bool print_origin = (line->origin == GIT_DIFF_LINE_CONTEXT || line->origin == GIT_DIFF_LINE_ADDITION || line->origin == GIT_DIFF_LINE_DELETION); + + if (use_colour) + { + switch (line->origin) + { + case GIT_DIFF_LINE_ADDITION: + std::cout << termcolor::green; + break; + case GIT_DIFF_LINE_DELETION: + std::cout << termcolor::red; + break; + case GIT_DIFF_LINE_ADD_EOFNL: + std::cout << termcolor::green; + break; + case GIT_DIFF_LINE_DEL_EOFNL: + std::cout << termcolor::red; + break; + case GIT_DIFF_LINE_FILE_HDR: + std::cout << termcolor::bold; + break; + case GIT_DIFF_LINE_HUNK_HDR: + std::cout << termcolor::cyan; + break; + default: + break; + } + } + + if (print_origin) + { + std::cout << line->origin; + } + + std::cout << std::string_view(line->content, line->content_len); + + if (use_colour) + { + std::cout << termcolor::reset; + } + + // Print copy/rename headers ONLY after the "diff --git" line + if (line->origin == GIT_DIFF_LINE_FILE_HDR) + { + if (delta->status == GIT_DELTA_COPIED) + { + if (use_colour) + { + std::cout << termcolor::bold; + } + std::cout << "similarity index " << delta->similarity << "%\n"; + std::cout << "copy from " << delta->old_file.path << "\n"; + std::cout << "copy to " << delta->new_file.path << "\n"; + if (use_colour) + { + std::cout << termcolor::reset; + } + } + else if (delta->status == GIT_DELTA_RENAMED) + { + if (use_colour) + { + std::cout << termcolor::bold; + } + std::cout << "similarity index " << delta->similarity << "%\n"; + std::cout << "rename from " << delta->old_file.path << "\n"; + std::cout << "rename to " << delta->new_file.path << "\n"; + if (use_colour) + { + std::cout << termcolor::reset; + } + } + } + + return 0; + } + + diff_wrapper compute_diff_no_index(std::vector files, git_diff_options& diffopts) + { + if (files.size() != 2) + { + throw git_exception( + "usage: git diff --no-index [] [...]", + git2cpp_error_code::BAD_ARGUMENT + ); + } + + git_diff_options_init(&diffopts, GIT_DIFF_OPTIONS_VERSION); + + std::string file1_str = read_file(files[0]); + std::string file2_str = read_file(files[1]); + + if (file1_str.empty()) + { + throw git_exception("Cannot read file: " + files[0], git2cpp_error_code::GENERIC_ERROR); + } + if (file2_str.empty()) + { + throw git_exception("Cannot read file: " + files[1], git2cpp_error_code::GENERIC_ERROR); + } + + auto patch = patch_wrapper::patch_from_files(files[0], file1_str, files[1], file2_str, &diffopts); + auto buf = patch.to_buf(); + auto diff = diff_wrapper::diff_from_buffer(buf); + + git_buf_dispose(&buf); + + return diff; + } +} + void print_stats( const diff_wrapper& diff, bool use_colour, @@ -170,93 +293,6 @@ void print_stats( git_buf_dispose(&buf); } -static int colour_printer( - [[maybe_unused]] const git_diff_delta* delta, - [[maybe_unused]] const git_diff_hunk* hunk, - const git_diff_line* line, - void* payload -) -{ - bool use_colour = *reinterpret_cast(payload); - - // Only print origin for context/addition/deletion lines - bool print_origin = (line->origin == GIT_DIFF_LINE_CONTEXT || line->origin == GIT_DIFF_LINE_ADDITION || line->origin == GIT_DIFF_LINE_DELETION); - - if (use_colour) - { - switch (line->origin) - { - case GIT_DIFF_LINE_ADDITION: - std::cout << termcolor::green; - break; - case GIT_DIFF_LINE_DELETION: - std::cout << termcolor::red; - break; - case GIT_DIFF_LINE_ADD_EOFNL: - std::cout << termcolor::green; - break; - case GIT_DIFF_LINE_DEL_EOFNL: - std::cout << termcolor::red; - break; - case GIT_DIFF_LINE_FILE_HDR: - std::cout << termcolor::bold; - break; - case GIT_DIFF_LINE_HUNK_HDR: - std::cout << termcolor::cyan; - break; - default: - break; - } - } - - if (print_origin) - { - std::cout << line->origin; - } - - std::cout << std::string_view(line->content, line->content_len); - - if (use_colour) - { - std::cout << termcolor::reset; - } - - // Print copy/rename headers ONLY after the "diff --git" line - if (line->origin == GIT_DIFF_LINE_FILE_HDR) - { - if (delta->status == GIT_DELTA_COPIED) - { - if (use_colour) - { - std::cout << termcolor::bold; - } - std::cout << "similarity index " << delta->similarity << "%\n"; - std::cout << "copy from " << delta->old_file.path << "\n"; - std::cout << "copy to " << delta->new_file.path << "\n"; - if (use_colour) - { - std::cout << termcolor::reset; - } - } - else if (delta->status == GIT_DELTA_RENAMED) - { - if (use_colour) - { - std::cout << termcolor::bold; - } - std::cout << "similarity index " << delta->similarity << "%\n"; - std::cout << "rename from " << delta->old_file.path << "\n"; - std::cout << "rename to " << delta->new_file.path << "\n"; - if (use_colour) - { - std::cout << termcolor::reset; - } - } - } - - return 0; -} - void diff_subcommand::print_diff(diff_wrapper& diff, bool use_colour) { if (m_stat_flag || m_shortstat_flag || m_numstat_flag || m_summary_flag) @@ -307,39 +343,6 @@ void diff_subcommand::print_diff(diff_wrapper& diff, bool use_colour) diff.print(format, colour_printer, &use_colour); } -diff_wrapper compute_diff_no_index(std::vector files, git_diff_options& diffopts) -{ - if (files.size() != 2) - { - throw git_exception( - "usage: git diff --no-index [] [...]", - git2cpp_error_code::BAD_ARGUMENT - ); - } - - git_diff_options_init(&diffopts, GIT_DIFF_OPTIONS_VERSION); - - std::string file1_str = read_file(files[0]); - std::string file2_str = read_file(files[1]); - - if (file1_str.empty()) - { - throw git_exception("Cannot read file: " + files[0], git2cpp_error_code::GENERIC_ERROR); - } - if (file2_str.empty()) - { - throw git_exception("Cannot read file: " + files[1], git2cpp_error_code::GENERIC_ERROR); - } - - auto patch = patch_wrapper::patch_from_files(files[0], file1_str, files[1], file2_str, &diffopts); - auto buf = patch.to_buf(); - auto diff = diff_wrapper::diff_from_buffer(buf); - - git_buf_dispose(&buf); - - return diff; -} - void diff_subcommand::run() { git_diff_options diffopts; diff --git a/src/subcommand/fetch_subcommand.cpp b/src/subcommand/fetch_subcommand.cpp index e853f82..6d4544d 100644 --- a/src/subcommand/fetch_subcommand.cpp +++ b/src/subcommand/fetch_subcommand.cpp @@ -44,7 +44,10 @@ void fetch_subcommand::run() git_indexer_progress pd = {0}; git_fetch_options fetch_opts = GIT_FETCH_OPTIONS_INIT; - fetch_opts.callbacks.credentials = user_credentials; + if (want_user_credentials()) + { + fetch_opts.callbacks.credentials = user_credentials; + } fetch_opts.callbacks.sideband_progress = sideband_progress; fetch_opts.callbacks.transfer_progress = fetch_progress; fetch_opts.callbacks.payload = &pd; diff --git a/src/subcommand/log_subcommand.cpp b/src/subcommand/log_subcommand.cpp index 98e50d8..61ead6d 100644 --- a/src/subcommand/log_subcommand.cpp +++ b/src/subcommand/log_subcommand.cpp @@ -12,6 +12,7 @@ #include #include "../utils/terminal_pager.hpp" +#include "../wrapper/strarray_wrapper.hpp" log_subcommand::log_subcommand(const libgit2_object&, CLI::App& app) { @@ -52,198 +53,200 @@ log_subcommand::log_subcommand(const libgit2_object&, CLI::App& app) ); }; -void print_time(git_time intime, std::string prefix) +namespace { - char sign, out[32]; - struct tm* intm; - int offset, hours, minutes; - time_t t; - - offset = intime.offset; - if (offset < 0) + void print_time(git_time intime, std::string prefix) { - sign = '-'; - offset = -offset; - } - else - { - sign = '+'; - } + char sign, out[32]; + struct tm* intm; + int offset, hours, minutes; + time_t t; - hours = offset / 60; - minutes = offset % 60; - - t = (time_t) intime.time + (intime.offset * 60); + offset = intime.offset; + if (offset < 0) + { + sign = '-'; + offset = -offset; + } + else + { + sign = '+'; + } - intm = gmtime(&t); - strftime(out, sizeof(out), "%a %b %e %T %Y", intm); + hours = offset / 60; + minutes = offset % 60; - std::cout << prefix << out << " " << sign << std::format("{:02d}", hours) - << std::format("{:02d}", minutes) << std::endl; -} + t = (time_t) intime.time + (intime.offset * 60); -std::vector get_tags_for_commit(repository_wrapper& repo, const git_oid& commit_oid) -{ - std::vector tags; - git_strarray tag_names = {0}; + intm = gmtime(&t); + strftime(out, sizeof(out), "%a %b %e %T %Y", intm); - if (git_tag_list(&tag_names, repo) != 0) - { - return tags; + std::cout << prefix << out << " " << sign << std::format("{:02d}", hours) + << std::format("{:02d}", minutes) << std::endl; } - for (size_t i = 0; i < tag_names.count; i++) + std::vector get_tags_for_commit(repository_wrapper& repo, const git_oid& commit_oid) { - std::string tag_name = tag_names.strings[i]; - std::string ref_name = "refs/tags/" + std::string(tag_name); + std::vector tags; + strarray_owned_wrapper tag_names; - reference_wrapper tag_ref = repo.find_reference(ref_name); - object_wrapper peeled = tag_ref.peel(); - - if (git_oid_equal(&peeled.oid(), &commit_oid)) + if (git_tag_list(tag_names, repo) != 0) { - tags.push_back(std::string(tag_name)); + return tags; } - } - git_strarray_dispose(&tag_names); // TODO: refactor git_strarray_wrapper to use it here - return tags; -} - -std::vector get_branches_for_commit( - repository_wrapper& repo, - git_branch_t type, - const git_oid& commit_oid, - const std::string exclude_branch -) -{ - std::vector branches; + for (size_t i = 0; i < tag_names.size(); i++) + { + auto tag_name = std::string(tag_names[i]); + std::string ref_name = "refs/tags/" + tag_name; - auto branch_iter = repo.iterate_branches(type); - while (auto branch = branch_iter.next()) - { - const git_oid* branch_target = nullptr; - git_reference* ref = branch.value(); + reference_wrapper tag_ref = repo.find_reference(ref_name); + object_wrapper peeled = tag_ref.peel(); - if (git_reference_type(ref) == GIT_REFERENCE_DIRECT) - { - branch_target = git_reference_target(ref); - } - else if (git_reference_type(ref) == GIT_REFERENCE_SYMBOLIC) - { - git_reference* resolved = nullptr; - if (git_reference_resolve(&resolved, ref) == 0) + if (git_oid_equal(&peeled.oid(), &commit_oid)) { - branch_target = git_reference_target(resolved); - git_reference_free(resolved); + tags.push_back(std::string(tag_name)); } } - if (branch_target && git_oid_equal(branch_target, &commit_oid)) + return tags; + } + + std::vector get_branches_for_commit( + repository_wrapper& repo, + git_branch_t type, + const git_oid& commit_oid, + const std::string exclude_branch + ) + { + std::vector branches; + + auto branch_iter = repo.iterate_branches(type); + while (auto branch = branch_iter.next()) { - std::string branch_name(branch->name()); - if (type == GIT_BRANCH_LOCAL) + const git_oid* branch_target = nullptr; + git_reference* ref = branch.value(); + + if (git_reference_type(ref) == GIT_REFERENCE_DIRECT) + { + branch_target = git_reference_target(ref); + } + else if (git_reference_type(ref) == GIT_REFERENCE_SYMBOLIC) { - if (branch_name != exclude_branch) + git_reference* resolved = nullptr; + if (git_reference_resolve(&resolved, ref) == 0) { - branches.push_back(branch_name); + branch_target = git_reference_target(resolved); + git_reference_free(resolved); } } - else + + if (branch_target && git_oid_equal(branch_target, &commit_oid)) { - branches.push_back(branch_name); + std::string branch_name(branch->name()); + if (type == GIT_BRANCH_LOCAL) + { + if (branch_name != exclude_branch) + { + branches.push_back(branch_name); + } + } + else + { + branches.push_back(branch_name); + } } } - } - return branches; -} - -struct commit_refs -{ - std::string head_branch; - std::vector tags; - std::vector local_branches; - std::vector remote_branches; + return branches; + } - bool has_refs() const + struct commit_refs { - return !head_branch.empty() || !tags.empty() || !local_branches.empty() || !remote_branches.empty(); - } -}; + std::string head_branch; + std::vector tags; + std::vector local_branches; + std::vector remote_branches; -commit_refs get_refs_for_commit(repository_wrapper& repo, const git_oid& commit_oid) -{ - commit_refs refs; + bool has_refs() const + { + return !head_branch.empty() || !tags.empty() || !local_branches.empty() || !remote_branches.empty(); + } + }; - if (!repo.is_head_unborn()) + commit_refs get_refs_for_commit(repository_wrapper& repo, const git_oid& commit_oid) { - auto head = repo.head(); - auto head_taget = head.target(); - if (git_oid_equal(head_taget, &commit_oid)) + commit_refs refs; + + if (!repo.is_head_unborn()) { - refs.head_branch = head.short_name(); + auto head = repo.head(); + auto head_taget = head.target(); + if (git_oid_equal(head_taget, &commit_oid)) + { + refs.head_branch = head.short_name(); + } } - } - refs.tags = get_tags_for_commit(repo, commit_oid); - refs.local_branches = get_branches_for_commit(repo, GIT_BRANCH_LOCAL, commit_oid, refs.head_branch); - refs.remote_branches = get_branches_for_commit(repo, GIT_BRANCH_REMOTE, commit_oid, ""); + refs.tags = get_tags_for_commit(repo, commit_oid); + refs.local_branches = get_branches_for_commit(repo, GIT_BRANCH_LOCAL, commit_oid, refs.head_branch); + refs.remote_branches = get_branches_for_commit(repo, GIT_BRANCH_REMOTE, commit_oid, ""); - return refs; -} + return refs; + } -void print_refs(const commit_refs& refs) -{ - if (!refs.has_refs()) + void print_refs(const commit_refs& refs) { - return; - } + if (!refs.has_refs()) + { + return; + } - std::cout << termcolor::yellow; - std::cout << " ("; + std::cout << termcolor::yellow; + std::cout << " ("; - bool first = true; + bool first = true; - if (!refs.head_branch.empty()) - { - std::cout << termcolor::bold << termcolor::cyan << "HEAD" << termcolor::reset << termcolor::yellow - << " -> " << termcolor::reset << termcolor::bold << termcolor::green << refs.head_branch - << termcolor::reset << termcolor::yellow; - first = false; - } + if (!refs.head_branch.empty()) + { + std::cout << termcolor::bold << termcolor::cyan << "HEAD" << termcolor::reset << termcolor::yellow + << " -> " << termcolor::reset << termcolor::bold << termcolor::green << refs.head_branch + << termcolor::reset << termcolor::yellow; + first = false; + } - for (const auto& tag : refs.tags) - { - if (!first) + for (const auto& tag : refs.tags) { - std::cout << ", "; + if (!first) + { + std::cout << ", "; + } + std::cout << termcolor::bold << "tag: " << tag << termcolor::reset << termcolor::yellow; + first = false; } - std::cout << termcolor::bold << "tag: " << tag << termcolor::reset << termcolor::yellow; - first = false; - } - for (const auto& remote : refs.remote_branches) - { - if (!first) + for (const auto& remote : refs.remote_branches) { - std::cout << ", "; + if (!first) + { + std::cout << ", "; + } + std::cout << termcolor::bold << termcolor::red << remote << termcolor::reset << termcolor::yellow; + first = false; } - std::cout << termcolor::bold << termcolor::red << remote << termcolor::reset << termcolor::yellow; - first = false; - } - for (const auto& local : refs.local_branches) - { - if (!first) + for (const auto& local : refs.local_branches) { - std::cout << ", "; + if (!first) + { + std::cout << ", "; + } + std::cout << termcolor::bold << termcolor::green << local << termcolor::reset << termcolor::yellow; + first = false; } - std::cout << termcolor::bold << termcolor::green << local << termcolor::reset << termcolor::yellow; - first = false; - } - std::cout << ")" << termcolor::reset; + std::cout << ")" << termcolor::reset; + } } void log_subcommand::print_commit(repository_wrapper& repo, const commit_wrapper& commit) diff --git a/src/subcommand/merge_subcommand.cpp b/src/subcommand/merge_subcommand.cpp index 74f7abb..41f1a46 100644 --- a/src/subcommand/merge_subcommand.cpp +++ b/src/subcommand/merge_subcommand.cpp @@ -47,31 +47,67 @@ merge_subcommand::merge_subcommand(const libgit2_object&, CLI::App& app) ); } -annotated_commit_list_wrapper merge_subcommand::resolve_heads(const repository_wrapper& repo) +namespace { - std::vector commits_to_merge; - commits_to_merge.reserve(m_branches_to_merge.size()); - - for (const auto branch_name : m_branches_to_merge) + annotated_commit_list_wrapper + resolve_mergeheads(const repository_wrapper& repo, const std::vector& oid_list) { - std::optional commit = repo.resolve_local_ref(branch_name); - if (commit.has_value()) + std::vector commits_to_merge; + commits_to_merge.reserve(oid_list.size()); + + for (const auto& id : oid_list) { - commits_to_merge.push_back(std::move(commit).value()); + std::optional commit = repo.find_annotated_commit(id); + if (commit.has_value()) + { + commits_to_merge.push_back(std::move(commit).value()); + } } + return annotated_commit_list_wrapper(std::move(commits_to_merge)); + } + + void perform_fastforward(repository_wrapper& repo, const git_oid& target_oid, int is_unborn) + { + const git_checkout_options ff_checkout_options = GIT_CHECKOUT_OPTIONS_INIT; + + auto lambda_get_target_ref = [](auto repo, auto is_unborn) + { + if (!is_unborn) + { + return repo->head(); + } + else + { + return repo->find_reference("HEAD"); + } + }; + reference_wrapper target_ref = lambda_get_target_ref(&repo, is_unborn); + + object_wrapper target = repo.find_object(target_oid, GIT_OBJECT_COMMIT); + + repo.checkout_tree(target, ff_checkout_options); + + target_ref.write_new_ref(target_oid); + } + + // This function is used as a callback in git_repository_mergehead_foreach and therefore its type must be + // git_repository_mergehead_foreach_cb. + int populate_list(const git_oid* oid, void* payload) + { + auto* l = reinterpret_cast*>(payload); + l->push_back(*oid); + return 0; } - return annotated_commit_list_wrapper(std::move(commits_to_merge)); } -annotated_commit_list_wrapper -resolve_mergeheads(const repository_wrapper& repo, const std::vector& oid_list) +annotated_commit_list_wrapper merge_subcommand::resolve_heads(const repository_wrapper& repo) { std::vector commits_to_merge; - commits_to_merge.reserve(oid_list.size()); + commits_to_merge.reserve(m_branches_to_merge.size()); - for (const auto& id : oid_list) + for (const auto branch_name : m_branches_to_merge) { - std::optional commit = repo.find_annotated_commit(id); + std::optional commit = repo.resolve_local_ref(branch_name); if (commit.has_value()) { commits_to_merge.push_back(std::move(commit).value()); @@ -80,30 +116,6 @@ resolve_mergeheads(const repository_wrapper& repo, const std::vector& o return annotated_commit_list_wrapper(std::move(commits_to_merge)); } -void perform_fastforward(repository_wrapper& repo, const git_oid& target_oid, int is_unborn) -{ - const git_checkout_options ff_checkout_options = GIT_CHECKOUT_OPTIONS_INIT; - - auto lambda_get_target_ref = [](auto repo, auto is_unborn) - { - if (!is_unborn) - { - return repo->head(); - } - else - { - return repo->find_reference("HEAD"); - } - }; - reference_wrapper target_ref = lambda_get_target_ref(&repo, is_unborn); - - object_wrapper target = repo.find_object(target_oid, GIT_OBJECT_COMMIT); - - repo.checkout_tree(target, ff_checkout_options); - - target_ref.write_new_ref(target_oid); -} - void merge_subcommand::create_merge_commit( repository_wrapper& repo, const index_wrapper& index, @@ -148,15 +160,6 @@ void merge_subcommand::create_merge_commit( repo.state_cleanup(); } -// This function is used as a callback in git_repository_mergehead_foreach and therefore its type must be -// git_repository_mergehead_foreach_cb. -int populate_list(const git_oid* oid, void* payload) -{ - auto* l = reinterpret_cast*>(payload); - l->push_back(*oid); - return 0; -} - void merge_subcommand::run() { auto directory = get_current_git_path(); diff --git a/src/subcommand/push_subcommand.cpp b/src/subcommand/push_subcommand.cpp index 5a1243c..a4984b8 100644 --- a/src/subcommand/push_subcommand.cpp +++ b/src/subcommand/push_subcommand.cpp @@ -7,10 +7,10 @@ #include #include "../utils/ansi_code.hpp" -#include "../utils/common.hpp" #include "../utils/credentials.hpp" #include "../utils/progress.hpp" #include "../wasm/scope.hpp" +#include "../wrapper/strarray_wrapper.hpp" push_subcommand::push_subcommand(const libgit2_object&, CLI::App& app) { @@ -33,6 +33,89 @@ push_subcommand::push_subcommand(const libgit2_object&, CLI::App& app) ); } +namespace +{ + 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::fill_refspec(repository_wrapper& repo) { const std::string prefix = std::string("refs/heads/"); @@ -78,86 +161,6 @@ void push_subcommand::fill_refspec(repository_wrapper& repo) } } -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. @@ -169,7 +172,10 @@ void push_subcommand::run() auto remote = repo.find_remote(remote_name); git_push_options push_opts = GIT_PUSH_OPTIONS_INIT; - push_opts.callbacks.credentials = user_credentials; + if (want_user_credentials()) + { + push_opts.callbacks.credentials = user_credentials; + } push_opts.callbacks.push_transfer_progress = push_transfer_progress; push_opts.callbacks.push_update_reference = push_update_reference; @@ -179,7 +185,7 @@ void push_subcommand::run() { refspecs_push.push_back("refs/heads/" + refspec); } - git_strarray_wrapper refspecs_wrapper(refspecs_push); + strarray_view_wrapper refspecs_wrapper(refspecs_push); git_strarray* refspecs_ptr = refspecs_wrapper; auto remotes_before_push = get_remotes(repo, remote_name); diff --git a/src/subcommand/rebase_subcommand.cpp b/src/subcommand/rebase_subcommand.cpp index 4bedcc5..8e17b7c 100644 --- a/src/subcommand/rebase_subcommand.cpp +++ b/src/subcommand/rebase_subcommand.cpp @@ -31,11 +31,14 @@ rebase_subcommand::rebase_subcommand(const libgit2_object&, CLI::App& app) ); } -void ensure_rebase_in_progress(git_repository_state_t state) +namespace { - if (state != GIT_REPOSITORY_STATE_REBASE_INTERACTIVE && state != GIT_REPOSITORY_STATE_REBASE_MERGE) + void ensure_rebase_in_progress(git_repository_state_t state) { - throw std::runtime_error("No rebase in progress"); + if (state != GIT_REPOSITORY_STATE_REBASE_INTERACTIVE && state != GIT_REPOSITORY_STATE_REBASE_MERGE) + { + throw std::runtime_error("No rebase in progress"); + } } } diff --git a/src/subcommand/tag_subcommand.cpp b/src/subcommand/tag_subcommand.cpp index 192b0d1..fc5b90f 100644 --- a/src/subcommand/tag_subcommand.cpp +++ b/src/subcommand/tag_subcommand.cpp @@ -27,104 +27,107 @@ tag_subcommand::tag_subcommand(const libgit2_object&, CLI::App& app) ); } -// Tag listing: Print individual message lines -void print_list_lines(const std::string& message, int num_lines) +namespace { - if (message.empty()) + // Tag listing: Print individual message lines + void print_list_lines(const std::string& message, int num_lines) { - return; - } + if (message.empty()) + { + return; + } - auto lines = split_input_at_newlines(message); + auto lines = split_input_at_newlines(message); - // header - std::cout << lines[0]; + // header + std::cout << lines[0]; - // other lines - if (num_lines <= 1 || lines.size() <= 2) - { - std::cout << std::endl; - } - else - { - for (size_t i = 1; i < lines.size(); i++) + // other lines + if (num_lines <= 1 || lines.size() <= 2) + { + std::cout << std::endl; + } + else { - if (i < num_lines) + for (size_t i = 1; i < lines.size(); i++) { - std::cout << "\n\t\t" << lines[i]; + if (i < num_lines) + { + std::cout << "\n\t\t" << lines[i]; + } } } } -} -// Tag listing: Print an actual tag object -void print_tag(git_tag* tag, int num_lines) -{ - std::cout << std::left << std::setw(16) << git_tag_name(tag); - - if (num_lines) + // Tag listing: Print an actual tag object + void print_tag(git_tag* tag, int num_lines) { - std::string msg = git_tag_message(tag); - if (!msg.empty()) + std::cout << std::left << std::setw(16) << git_tag_name(tag); + + if (num_lines) { - print_list_lines(msg, num_lines); + std::string msg = git_tag_message(tag); + if (!msg.empty()) + { + print_list_lines(msg, num_lines); + } + else + { + std::cout << std::endl; + } } else { std::cout << std::endl; } } - else - { - std::cout << std::endl; - } -} -// Tag listing: Print a commit (target of a lightweight tag) -void print_commit(git_commit* commit, std::string name, int num_lines) -{ - std::cout << std::left << std::setw(16) << name; - - if (num_lines) + // Tag listing: Print a commit (target of a lightweight tag) + void print_commit(git_commit* commit, std::string name, int num_lines) { - std::string msg = git_commit_message(commit); - if (!msg.empty()) + std::cout << std::left << std::setw(16) << name; + + if (num_lines) { - print_list_lines(msg, num_lines); + std::string msg = git_commit_message(commit); + if (!msg.empty()) + { + print_list_lines(msg, num_lines); + } + else + { + std::cout << std::endl; + } } else { std::cout << std::endl; } } - else - { - std::cout << std::endl; - } -} -// Tag listing: Lookup tags based on ref name and dispatch to print -void each_tag(repository_wrapper& repo, const std::string& name, int num_lines) -{ - auto obj = repo.revparse_single(name); - - if (obj.has_value()) + // Tag listing: Lookup tags based on ref name and dispatch to print + void each_tag(repository_wrapper& repo, const std::string& name, int num_lines) { - switch (git_object_type(obj.value())) + auto obj = repo.revparse_single(name); + + if (obj.has_value()) { - case GIT_OBJECT_TAG: - print_tag(obj.value(), num_lines); - break; - case GIT_OBJECT_COMMIT: - print_commit(obj.value(), name, num_lines); - break; - default: - std::cout << name << std::endl; + switch (git_object_type(obj.value())) + { + case GIT_OBJECT_TAG: + print_tag(obj.value(), num_lines); + break; + case GIT_OBJECT_COMMIT: + print_commit(obj.value(), name, num_lines); + break; + default: + std::cout << name << std::endl; + } + } + else + { + std::cout << name << std::endl; } - } - else - { - std::cout << name << std::endl; } } @@ -133,9 +136,9 @@ void tag_subcommand::list_tags(repository_wrapper& repo) std::string pattern = m_tag_name.empty() ? "*" : m_tag_name; auto tag_names = repo.tag_list_match(pattern); - for (const auto& tag_name : tag_names) + for (size_t i = 0u; i < tag_names.size(); ++i) { - each_tag(repo, tag_name, m_num_lines); + each_tag(repo, tag_names[i], m_num_lines); } } diff --git a/src/utils/common.cpp b/src/utils/common.cpp index 4bb7d32..a9aeb77 100644 --- a/src/utils/common.cpp +++ b/src/utils/common.cpp @@ -60,59 +60,6 @@ status_messages get_status_msg(git_status_t st) return get_status_msg_map().find(st)->second; } -git_strarray_wrapper::git_strarray_wrapper(std::vector patterns) - : m_patterns(std::move(patterns)) -{ - init_str_array(); -} - -git_strarray_wrapper::git_strarray_wrapper(git_strarray_wrapper&& rhs) - : m_patterns(std::move(rhs.m_patterns)) -{ - init_str_array(); - rhs.reset_str_array(); -} - -git_strarray_wrapper& git_strarray_wrapper::operator=(git_strarray_wrapper&& rhs) -{ - using std::swap; - swap(m_patterns, rhs.m_patterns); - swap(m_array.strings, rhs.m_array.strings); - swap(m_array.count, rhs.m_array.count); - return *this; -} - -git_strarray_wrapper::~git_strarray_wrapper() -{ - reset_str_array(); -} - -git_strarray_wrapper::operator git_strarray*() -{ - return &m_array; -} - -void git_strarray_wrapper::reset_str_array() -{ - delete[] m_array.strings; - m_array = {nullptr, 0}; -} - -void git_strarray_wrapper::init_str_array() -{ - m_array.strings = new char*[m_patterns.size()]; - m_array.count = m_patterns.size(); - for (size_t i = 0; i < m_patterns.size(); ++i) - { - m_array.strings[i] = const_cast(m_patterns[i].c_str()); - } -} - -size_t git_strarray_wrapper::size() -{ - return m_patterns.size(); -} - std::string read_file(const std::string& path) { std::ifstream file(path, std::ios::binary); diff --git a/src/utils/common.hpp b/src/utils/common.hpp index afb0d88..86fc1b4 100644 --- a/src/utils/common.hpp +++ b/src/utils/common.hpp @@ -40,39 +40,6 @@ status_messages get_status_msg(git_status_t); using stream_colour_fn = std::ostream& (*) (std::ostream&); -class git_strarray_wrapper -{ -public: - - git_strarray_wrapper() - : m_patterns{} - , m_array{nullptr, 0} - { - } - - git_strarray_wrapper(std::vector patterns); - - git_strarray_wrapper(const git_strarray_wrapper&) = delete; - git_strarray_wrapper& operator=(const git_strarray_wrapper&) = delete; - - git_strarray_wrapper(git_strarray_wrapper&& rhs); - git_strarray_wrapper& operator=(git_strarray_wrapper&&); - - ~git_strarray_wrapper(); - - operator git_strarray*(); - - size_t size(); - -private: - - std::vector m_patterns; - git_strarray m_array; - - void reset_str_array(); - void init_str_array(); -}; - std::string read_file(const std::string& path); std::vector split_input_at_newlines(std::string_view str); diff --git a/src/utils/credentials.cpp b/src/utils/credentials.cpp index 4ed8307..152417d 100644 --- a/src/utils/credentials.cpp +++ b/src/utils/credentials.cpp @@ -47,3 +47,9 @@ int user_credentials( giterr_set_str(GIT_ERROR_HTTP, "Unexpected credentials request"); return GIT_ERROR; } + +bool want_user_credentials() +{ + const char* env_var = std::getenv("GIT_CREDENTIAL_CALLBACK"); + return env_var == nullptr || std::string_view(env_var) != "0"; +} diff --git a/src/utils/credentials.hpp b/src/utils/credentials.hpp index ba970e6..8e60ce3 100644 --- a/src/utils/credentials.hpp +++ b/src/utils/credentials.hpp @@ -11,3 +11,5 @@ int user_credentials( unsigned int allowed_types, void* payload ); + +bool want_user_credentials(); diff --git a/src/version.hpp b/src/version.hpp index 06c9ebb..5d29e0c 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 9 +#define GIT2CPP_VERSION_PATCH 10 // e.g. ".rc0" #define GIT2CPP_VERSION_SUFFIX diff --git a/src/wasm/stream.cpp b/src/wasm/stream.cpp index b16dcc3..8716209 100644 --- a/src/wasm/stream.cpp +++ b/src/wasm/stream.cpp @@ -8,6 +8,7 @@ # include # include "../utils/common.hpp" +# include "../utils/credentials.hpp" # include "constants.hpp" # include "read_buffer.hpp" # include "response.hpp" @@ -116,7 +117,8 @@ EM_JS( ); EM_JS(const char*, js_maybe_convert_url, (const char* url_str), { - // Convert URL to use CORS proxy based on env vars GIT_CORS_PROXY and GIT_CORS_PROXY_TYPE. + // Convert URL to use CORS proxy based on env vars GIT_CORS_PROXY and possible + // GIT_CORS_PROXY_API_KEY. // If no conversion occurs, return the original unconverted URL as a new string. const url_js = UTF8ToString(url_str); const url = new URL(url_js); @@ -463,28 +465,38 @@ static int create_credential(wasm_http_stream* stream) } subtransport->m_authorization_header = ""; - // Check that response headers show support for 'www-authenticate: Basic'. - if (!stream->m_response.has_header_starts_with("www-authenticate", "Basic")) + if (!want_user_credentials()) { - git_error_set( - GIT_ERROR_HTTP, - "remote host for request %s does not support Basic authentication", - stream->m_unconverted_url.c_str() - ); - return -1; + // Check that response headers show support for 'www-authenticate: Basic'. + if (!stream->m_response.has_header_starts_with("www-authenticate", "Basic")) + { + git_error_set( + GIT_ERROR_HTTP, + "remote host for request %s does not support Basic authentication", + stream->m_unconverted_url.c_str() + ); + return -1; + } } // Get credentials from user via libgit2 registered callback. - if (git_transport_smart_credentials( - &subtransport->m_credential, - subtransport->m_owner, - nullptr, - GIT_CREDENTIAL_USERPASS_PLAINTEXT - ) + int err; + if ((err = git_transport_smart_credentials( + &subtransport->m_credential, + subtransport->m_owner, + nullptr, + GIT_CREDENTIAL_USERPASS_PLAINTEXT + )) < 0) { + if (err == GIT_PASSTHROUGH) + { + // Use same error message as libgit2 + git_error_set(GIT_ERROR_HTTP, "remote authentication required but no callback set"); + } + // credentials_callback will have set git error. - return -1; + return err; } if (subtransport->m_credential->credtype != GIT_CREDENTIAL_USERPASS_PLAINTEXT) diff --git a/src/wrapper/annotated_commit_wrapper.cpp b/src/wrapper/annotated_commit_wrapper.cpp index da38620..683636f 100644 --- a/src/wrapper/annotated_commit_wrapper.cpp +++ b/src/wrapper/annotated_commit_wrapper.cpp @@ -16,6 +16,12 @@ const git_oid& annotated_commit_wrapper::oid() const return *git_annotated_commit_id(p_resource); } +std::string annotated_commit_wrapper::commit_oid_tostr() const +{ + char buf[GIT_OID_SHA1_HEXSIZE + 1]; + return git_oid_tostr(buf, sizeof(buf), &this->oid()); +} + std::string_view annotated_commit_wrapper::reference_name() const { const char* res = git_annotated_commit_ref(*this); diff --git a/src/wrapper/annotated_commit_wrapper.hpp b/src/wrapper/annotated_commit_wrapper.hpp index c390e2f..9fcd6b1 100644 --- a/src/wrapper/annotated_commit_wrapper.hpp +++ b/src/wrapper/annotated_commit_wrapper.hpp @@ -1,5 +1,6 @@ #pragma once +#include #include #include @@ -18,6 +19,7 @@ class annotated_commit_wrapper : public wrapper_base annotated_commit_wrapper& operator=(annotated_commit_wrapper&&) noexcept = default; const git_oid& oid() const; + std::string commit_oid_tostr() const; std::string_view reference_name() const; private: diff --git a/src/wrapper/index_wrapper.cpp b/src/wrapper/index_wrapper.cpp index d34ce20..ee5d958 100644 --- a/src/wrapper/index_wrapper.cpp +++ b/src/wrapper/index_wrapper.cpp @@ -9,6 +9,7 @@ #include "../utils/common.hpp" #include "../utils/git_exception.hpp" #include "../wrapper/repository_wrapper.hpp" +#include "../wrapper/strarray_wrapper.hpp" index_wrapper::~index_wrapper() { @@ -40,7 +41,7 @@ void index_wrapper::add_all() void index_wrapper::add_impl(std::vector patterns) { - git_strarray_wrapper array{patterns}; + strarray_view_wrapper array{patterns}; throw_if_error(git_index_add_all(*this, array, 0, NULL, NULL)); } @@ -51,7 +52,7 @@ void index_wrapper::remove_entry(const std::string& path) void index_wrapper::remove_entries(std::vector paths) { - git_strarray_wrapper array{paths}; + strarray_view_wrapper array{paths}; throw_if_error(git_index_remove_all(*this, array, NULL, NULL)); } @@ -79,6 +80,11 @@ git_oid index_wrapper::write_tree() return tree_id; } +size_t index_wrapper::entry_count() const +{ + return git_index_entrycount(*this); +} + bool index_wrapper::has_conflict() const { return git_index_has_conflicts(*this); diff --git a/src/wrapper/index_wrapper.hpp b/src/wrapper/index_wrapper.hpp index 9a973cc..05d637b 100644 --- a/src/wrapper/index_wrapper.hpp +++ b/src/wrapper/index_wrapper.hpp @@ -23,6 +23,8 @@ class index_wrapper : public wrapper_base void write(); git_oid write_tree(); + size_t entry_count() const; + void add_entry(const std::string& path); void add_entries(std::vector patterns); void add_all(); diff --git a/src/wrapper/refs_wrapper.cpp b/src/wrapper/refs_wrapper.cpp index 691f07a..a3a1902 100644 --- a/src/wrapper/refs_wrapper.cpp +++ b/src/wrapper/refs_wrapper.cpp @@ -27,6 +27,11 @@ bool reference_wrapper::is_remote() const return git_reference_is_remote(*this); } +bool reference_wrapper::is_branch() const +{ + return git_reference_is_branch(*this); +} + const git_oid* reference_wrapper::target() const { return git_reference_target(p_resource); diff --git a/src/wrapper/refs_wrapper.hpp b/src/wrapper/refs_wrapper.hpp index dddc6b0..d6bc11e 100644 --- a/src/wrapper/refs_wrapper.hpp +++ b/src/wrapper/refs_wrapper.hpp @@ -22,6 +22,7 @@ class reference_wrapper : public wrapper_base std::string short_name() const; bool is_remote() const; + bool is_branch() const; const git_oid* target() const; reference_wrapper write_new_ref(const git_oid target_oid); diff --git a/src/wrapper/repository_wrapper.cpp b/src/wrapper/repository_wrapper.cpp index bf57486..58070dd 100644 --- a/src/wrapper/repository_wrapper.cpp +++ b/src/wrapper/repository_wrapper.cpp @@ -6,11 +6,11 @@ #include "../utils/git_exception.hpp" #include "../wrapper/commit_wrapper.hpp" +#include "../wrapper/config_wrapper.hpp" +#include "../wrapper/diff_wrapper.hpp" #include "../wrapper/index_wrapper.hpp" #include "../wrapper/object_wrapper.hpp" #include "../wrapper/remote_wrapper.hpp" -#include "config_wrapper.hpp" -#include "diff_wrapper.hpp" repository_wrapper::~repository_wrapper() { @@ -293,7 +293,6 @@ void repository_wrapper::create_commit( { if (parents_list) { - // TODO: write a "as_const" function to replace the following auto pl_size = parents_list.value().size(); git_commit** pl_value = parents_list.value(); auto pl_value_const = const_cast(pl_value); @@ -313,9 +312,29 @@ void repository_wrapper::create_commit( }(); index_wrapper index = this->make_index(); + + // Check: initial commit (no parents) with nothing staged + if (parents_count == 0 && index.entry_count() == 0) + { + throw std::runtime_error( + "On branch main\n\nInitial commit\n\nnothing to commit " + "(create/copy files and use \"git add\" to track)" + ); + } + git_oid tree_id = index.write_tree(); index.write(); + // Check: tree is identical to parent tree (nothing changed since last commit) + if (parents_count > 0 && placeholder[0] != nullptr) + { + const git_oid* parent_tree_id = git_commit_tree_id(placeholder[0]); + if (git_oid_equal(&tree_id, parent_tree_id)) + { + throw std::runtime_error("nothing to commit, working tree clean"); + } + } + auto tree = this->tree_lookup(&tree_id); throw_if_error(git_commit_create( @@ -632,13 +651,9 @@ repository_wrapper::diff_index_to_workdir(std::optional index, gi // Tags -std::vector repository_wrapper::tag_list_match(std::string pattern) +strarray_owned_wrapper repository_wrapper::tag_list_match(std::string pattern) { - git_strarray tag_names; - throw_if_error(git_tag_list_match(&tag_names, pattern.c_str(), *this)); - - std::vector result(tag_names.strings, tag_names.strings + tag_names.count); - - git_strarray_dispose(&tag_names); - return result; + strarray_owned_wrapper tag_names; + throw_if_error(git_tag_list_match(tag_names, pattern.c_str(), *this)); + return tag_names; } diff --git a/src/wrapper/repository_wrapper.hpp b/src/wrapper/repository_wrapper.hpp index 1c5899e..86f6d1e 100644 --- a/src/wrapper/repository_wrapper.hpp +++ b/src/wrapper/repository_wrapper.hpp @@ -19,6 +19,7 @@ #include "../wrapper/remote_wrapper.hpp" #include "../wrapper/revwalk_wrapper.hpp" #include "../wrapper/signature_wrapper.hpp" +#include "../wrapper/strarray_wrapper.hpp" #include "../wrapper/tree_wrapper.hpp" #include "../wrapper/wrapper_base.hpp" @@ -134,8 +135,7 @@ class repository_wrapper : public wrapper_base diff_wrapper diff_index_to_workdir(std::optional index, git_diff_options* diffopts); // Tags - // git_strarray_wrapper tag_list_match(std::string pattern); - std::vector tag_list_match(std::string pattern); + strarray_owned_wrapper tag_list_match(std::string pattern); private: diff --git a/src/wrapper/strarray_wrapper.cpp b/src/wrapper/strarray_wrapper.cpp new file mode 100644 index 0000000..6a105bf --- /dev/null +++ b/src/wrapper/strarray_wrapper.cpp @@ -0,0 +1,97 @@ +#include "strarray_wrapper.hpp" + +strarray_owned_wrapper::strarray_owned_wrapper() + : m_array{nullptr, 0} +{ +} + +strarray_owned_wrapper::strarray_owned_wrapper(git_strarray&& arr) + : m_array(std::move(arr)) +{ +} + +strarray_owned_wrapper::strarray_owned_wrapper(strarray_owned_wrapper&& rhs) + : m_array(std::move(rhs.m_array)) +{ + rhs.m_array = git_strarray{nullptr, 0}; +} + +strarray_owned_wrapper& strarray_owned_wrapper::operator=(strarray_owned_wrapper&& rhs) +{ + std::swap(m_array.strings, rhs.m_array.strings); + std::swap(m_array.count, rhs.m_array.count); + return *this; +} + +strarray_owned_wrapper::~strarray_owned_wrapper() +{ + git_strarray_dispose(&m_array); +} + +strarray_owned_wrapper::operator git_strarray*() +{ + return &m_array; +} + +size_t strarray_owned_wrapper::size() const +{ + return m_array.count; +} + +std::string strarray_owned_wrapper::operator[](size_t i) const +{ + return {m_array.strings[i]}; +} + +strarray_view_wrapper::strarray_view_wrapper(std::vector patterns) + : m_patterns(std::move(patterns)) +{ + init_str_array(); +} + +strarray_view_wrapper::strarray_view_wrapper(strarray_view_wrapper&& rhs) + : m_patterns(std::move(rhs.m_patterns)) +{ + init_str_array(); + rhs.reset_str_array(); +} + +strarray_view_wrapper& strarray_view_wrapper::operator=(strarray_view_wrapper&& rhs) +{ + using std::swap; + swap(m_patterns, rhs.m_patterns); + swap(m_array.strings, rhs.m_array.strings); + swap(m_array.count, rhs.m_array.count); + return *this; +} + +strarray_view_wrapper::~strarray_view_wrapper() +{ + reset_str_array(); +} + +strarray_view_wrapper::operator git_strarray*() +{ + return &m_array; +} + +void strarray_view_wrapper::reset_str_array() +{ + delete[] m_array.strings; + m_array = {nullptr, 0}; +} + +void strarray_view_wrapper::init_str_array() +{ + m_array.strings = new char*[m_patterns.size()]; + m_array.count = m_patterns.size(); + for (size_t i = 0; i < m_patterns.size(); ++i) + { + m_array.strings[i] = const_cast(m_patterns[i].c_str()); + } +} + +size_t strarray_view_wrapper::size() const +{ + return m_patterns.size(); +} diff --git a/src/wrapper/strarray_wrapper.hpp b/src/wrapper/strarray_wrapper.hpp new file mode 100644 index 0000000..cc344bb --- /dev/null +++ b/src/wrapper/strarray_wrapper.hpp @@ -0,0 +1,70 @@ +#pragma once + +#include +#include + +#include + +// Wrapper of git_strarray that frees the contained +// strings (i.e. calls git_strarray_dispose) upon destruction. +class strarray_owned_wrapper +{ +public: + + strarray_owned_wrapper(); + explicit strarray_owned_wrapper(git_strarray&& arr); + + strarray_owned_wrapper(const strarray_owned_wrapper&) = delete; + strarray_owned_wrapper operator=(const strarray_owned_wrapper&) = delete; + + strarray_owned_wrapper(strarray_owned_wrapper&& rhs); + strarray_owned_wrapper& operator=(strarray_owned_wrapper&& rhs); + + ~strarray_owned_wrapper(); + + operator git_strarray*(); + + size_t size() const; + + std::string operator[](size_t i) const; + +private: + + git_strarray m_array; +}; + +// Wrapper of git_strarray containing pointers to strings +// stored in a stnadard container. Does not free them upon +// destruction. +class strarray_view_wrapper +{ +public: + + strarray_view_wrapper() + : m_patterns{} + , m_array{nullptr, 0} + { + } + + strarray_view_wrapper(std::vector patterns); + + strarray_view_wrapper(const strarray_view_wrapper&) = delete; + strarray_view_wrapper& operator=(const strarray_view_wrapper&) = delete; + + strarray_view_wrapper(strarray_view_wrapper&& rhs); + strarray_view_wrapper& operator=(strarray_view_wrapper&& rhs); + + ~strarray_view_wrapper(); + + operator git_strarray*(); + + size_t size() const; + +private: + + std::vector m_patterns; + git_strarray m_array; + + void reset_str_array(); + void init_str_array(); +}; diff --git a/test/conftest.py b/test/conftest.py index eccc89d..d521b5d 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -51,6 +51,14 @@ def commit_env_config(monkeypatch): monkeypatch.setenv(key, value) +@pytest.fixture +def disable_credential_callback(monkeypatch): + if GIT2CPP_TEST_WASM: + subprocess.run(["export", "GIT_CREDENTIAL_CALLBACK=0"], check=True) + else: + monkeypatch.setenv("GIT_CREDENTIAL_CALLBACK", "0") + + @pytest.fixture def repo_init_with_commit(commit_env_config, git2cpp_path, tmp_path): cmd_init = [git2cpp_path, "init", ".", "-b", "main"] diff --git a/test/test_checkout.py b/test/test_checkout.py index d789e91..afe8541 100644 --- a/test/test_checkout.py +++ b/test/test_checkout.py @@ -83,7 +83,9 @@ def test_checkout_invalid_branch(repo_init_with_commit, git2cpp_path, tmp_path): # Should fail with error message assert p_checkout.returncode != 0 - assert "error: could not resolve pathspec 'nonexistent'" in p_checkout.stderr + assert ( + "error: pathspec 'nonexistent' did not match any file(s) known to git" in p_checkout.stderr + ) def test_checkout_with_unstaged_changes(repo_init_with_commit, git2cpp_path, tmp_path): @@ -127,10 +129,10 @@ def test_checkout_refuses_overwrite( initial_file.write_text("Content on newbranch") add_cmd = [git2cpp_path, "add", "initial.txt"] - subprocess.run(add_cmd, cwd=tmp_path, text=True) + subprocess.run(add_cmd, cwd=tmp_path, text=True, check=True) commit_cmd = [git2cpp_path, "commit", "-m", "Change on newbranch"] - subprocess.run(commit_cmd, cwd=tmp_path, text=True) + subprocess.run(commit_cmd, cwd=tmp_path, text=True, check=True) # Switch back to default branch checkout_default_cmd = [git2cpp_path, "checkout", "main"] @@ -170,3 +172,221 @@ def test_checkout_refuses_overwrite( branch_cmd = [git2cpp_path, "branch"] p_branch = subprocess.run(branch_cmd, capture_output=True, cwd=tmp_path, text=True) assert "* newbranch" in p_branch.stdout + + +def test_checkout_file_restores_modified_file(repo_init_with_commit, git2cpp_path, tmp_path): + """Test that checkout -- discards working tree changes""" + initial_file = tmp_path / "initial.txt" + original_content = initial_file.read_text() + + # Modify the file (unstaged) + initial_file.write_text("Modified content") + assert initial_file.read_text() == "Modified content" + + # Restore it via checkout -- + checkout_cmd = [git2cpp_path, "checkout", "--", "initial.txt"] + p = subprocess.run(checkout_cmd, capture_output=True, cwd=tmp_path, text=True) + + assert p.returncode == 0 + assert initial_file.read_text() == original_content + + +def test_checkout_file_restores_multiple_files(repo_init_with_commit, git2cpp_path, tmp_path): + """Test that checkout -- restores multiple files at once""" + initial_file = tmp_path / "initial.txt" + + # Create and commit a second file first + second_file = tmp_path / "second.txt" + second_file.write_text("second content") + + add_cmd = [git2cpp_path, "add", "second.txt"] + subprocess.run(add_cmd, cwd=tmp_path, text=True, check=True) + commit_cmd = [git2cpp_path, "commit", "-m", "Add second file"] + subprocess.run(commit_cmd, cwd=tmp_path, text=True, check=True) + + original_initial = initial_file.read_text() + original_second = second_file.read_text() + + # Modify both files + initial_file.write_text("dirty initial") + second_file.write_text("dirty second") + + checkout_cmd = [git2cpp_path, "checkout", "--", "initial.txt", "second.txt"] + p = subprocess.run(checkout_cmd, capture_output=True, cwd=tmp_path, text=True) + + assert p.returncode == 0 + assert initial_file.read_text() == original_initial + assert second_file.read_text() == original_second + + +def test_checkout_file_does_not_affect_other_files(repo_init_with_commit, git2cpp_path, tmp_path): + """Test that checkout -- only touches the specified file""" + initial_file = tmp_path / "initial.txt" + original_initial = initial_file.read_text() + + # Create and commit a second file + second_file = tmp_path / "second.txt" + second_file.write_text("second content") + + add_cmd = [git2cpp_path, "add", "second.txt"] + subprocess.run(add_cmd, cwd=tmp_path, text=True, check=True) + commit_cmd = [git2cpp_path, "commit", "-m", "Add second file"] + subprocess.run(commit_cmd, cwd=tmp_path, text=True, check=True) + + # Modify both files + initial_file.write_text("dirty initial") + second_file.write_text("dirty second") + + # Only restore initial.txt + checkout_cmd = [git2cpp_path, "checkout", "--", "initial.txt"] + p = subprocess.run(checkout_cmd, capture_output=True, cwd=tmp_path, text=True) + + assert p.returncode == 0 + assert initial_file.read_text() == original_initial + assert second_file.read_text() == "dirty second" + + +def test_checkout_file_does_not_change_branch(repo_init_with_commit, git2cpp_path, tmp_path): + """Test that checkout -- does not move HEAD or change the current branch""" + initial_file = tmp_path / "initial.txt" + original_initial = initial_file.read_text() + + initial_file.write_text("dirty") + + checkout_cmd = [git2cpp_path, "checkout", "--", "initial.txt"] + p = subprocess.run(checkout_cmd, capture_output=True, cwd=tmp_path, text=True) + assert p.returncode == 0 + assert initial_file.read_text() == original_initial + + branch_cmd = [git2cpp_path, "branch"] + p_branch = subprocess.run(branch_cmd, capture_output=True, cwd=tmp_path, text=True) + assert p_branch.returncode == 0 + assert "* main" in p_branch.stdout + + +def test_checkout_file_nonexistent_path_fails(repo_init_with_commit, git2cpp_path, tmp_path): + """Test that checkout -- fails with a non-zero exit code""" + checkout_cmd = [git2cpp_path, "checkout", "--", "doesnotexist.txt"] + p = subprocess.run(checkout_cmd, capture_output=True, cwd=tmp_path, text=True) + + assert p.returncode != 0 + + +def test_checkout_file_no_paths_fails(repo_init_with_commit, git2cpp_path, tmp_path): + """Test that checkout -- with no file arguments fails""" + checkout_cmd = [git2cpp_path, "checkout", "--"] + p = subprocess.run(checkout_cmd, capture_output=True, cwd=tmp_path, text=True) + + assert p.returncode != 0 + assert "no branch or file specified" in p.stderr + + +def test_checkout_branch_file_restores_modified_file(repo_init_with_commit, git2cpp_path, tmp_path): + """Test that checkout -- restores the file from the branch.""" + initial_file = tmp_path / "initial.txt" + + # Create a new commit on main so the branch switch is meaningful + second_file = tmp_path / "second.txt" + second_file.write_text("second content") + subprocess.run([git2cpp_path, "add", "second.txt"], cwd=tmp_path, text=True, check=True) + subprocess.run( + [git2cpp_path, "commit", "-m", "Add second file"], cwd=tmp_path, text=True, check=True + ) + + # Create and switch to feature branch + subprocess.run([git2cpp_path, "checkout", "-b", "feature"], cwd=tmp_path, text=True, check=True) + + # Modify the file on feature branch and commit it + initial_file.write_text("feature content") + subprocess.run([git2cpp_path, "add", "initial.txt"], cwd=tmp_path, text=True, check=True) + subprocess.run( + [git2cpp_path, "commit", "-m", "Change initial on feature"], + cwd=tmp_path, + text=True, + check=True, + ) + + # Go back to main and dirty the file + subprocess.run([git2cpp_path, "checkout", "main"], cwd=tmp_path, text=True, check=True) + initial_file.write_text("local dirty content") + + # Restore only initial.txt from feature + checkout_cmd = [git2cpp_path, "checkout", "feature", "initial.txt"] + p = subprocess.run(checkout_cmd, capture_output=True, cwd=tmp_path, text=True) + + assert p.returncode == 0 + assert initial_file.read_text() == "feature content" + + branch_cmd = [git2cpp_path, "branch"] + p_branch = subprocess.run(branch_cmd, capture_output=True, cwd=tmp_path, text=True) + assert p_branch.returncode == 0 + assert "* main" in p_branch.stdout + + +def test_checkout_branch_multiple_files_restores_all(repo_init_with_commit, git2cpp_path, tmp_path): + """Test that checkout -- restores multiple files from the branch.""" + initial_file = tmp_path / "initial.txt" + + second_file = tmp_path / "second.txt" + second_file.write_text("second content") + subprocess.run([git2cpp_path, "add", "second.txt"], cwd=tmp_path, text=True, check=True) + subprocess.run( + [git2cpp_path, "commit", "-m", "Add second file"], cwd=tmp_path, text=True, check=True + ) + + # Create feature branch and modify both files there + subprocess.run([git2cpp_path, "checkout", "-b", "feature"], cwd=tmp_path, text=True, check=True) + initial_file.write_text("feature initial") + second_file.write_text("feature second") + subprocess.run( + [git2cpp_path, "add", "initial.txt", "second.txt"], cwd=tmp_path, text=True, check=True + ) + subprocess.run( + [git2cpp_path, "commit", "-m", "Change both files on feature"], + cwd=tmp_path, + text=True, + check=True, + ) + + # Return to main and dirty both files + subprocess.run([git2cpp_path, "checkout", "main"], cwd=tmp_path, text=True, check=True) + initial_file.write_text("dirty main initial") + second_file.write_text("dirty main second") + + # Restore both files from feature + checkout_cmd = [git2cpp_path, "checkout", "feature", "initial.txt", "second.txt"] + p = subprocess.run(checkout_cmd, capture_output=True, cwd=tmp_path, text=True) + + assert p.returncode == 0 + assert initial_file.read_text() == "feature initial" + assert second_file.read_text() == "feature second" + + branch_cmd = [git2cpp_path, "branch"] + p_branch = subprocess.run(branch_cmd, capture_output=True, cwd=tmp_path, text=True) + assert p_branch.returncode == 0 + assert "* main" in p_branch.stdout + + +def test_checkout_tag(repo_init_with_commit, git2cpp_path, tmp_path): + """checkout should detach HEAD at the tag commit.""" + # Create a tag pointing to HEAD + tag_cmd = [git2cpp_path, "tag", "v1.0"] + p_tag = subprocess.run(tag_cmd, capture_output=True, cwd=tmp_path, text=True) + assert p_tag.returncode == 0 + + # Switch to the tag + checkout_cmd = [git2cpp_path, "checkout", "v1.0"] + p_checkout = subprocess.run(checkout_cmd, capture_output=True, cwd=tmp_path, text=True) + assert p_checkout.returncode == 0 + assert "detached HEAD" in p_checkout.stdout + + # Verify we're detached + current_branch_cmd = [git2cpp_path, "branch", "--show-current"] + p_current = subprocess.run(current_branch_cmd, capture_output=True, cwd=tmp_path, text=True) + assert p_current.returncode == 0 + assert p_current.stdout.strip() == "" + + branch_cmd = [git2cpp_path, "branch"] + p_branch = subprocess.run(branch_cmd, capture_output=True, cwd=tmp_path, text=True) + assert p_branch.returncode == 0 + assert "*" not in p_branch.stdout diff --git a/test/test_clone.py b/test/test_clone.py index 35693ae..1f81f33 100644 --- a/test/test_clone.py +++ b/test/test_clone.py @@ -128,6 +128,17 @@ def test_clone_private_repo_fails_on_no_password( assert p_clone.stdout.count("Password:") == 1 +def test_clone_private_repo_fails_with_no_credential_callback( + git2cpp_path, tmp_path, run_in_tmp_path, disable_credential_callback +): + clone_cmd = [git2cpp_path, "clone", "https://github.com/QuantStack/git2cpp-test-private"] + p_clone = subprocess.run(clone_cmd, capture_output=True, text=True) + + assert p_clone.returncode != 0 + assert "Cloning into 'git2cpp-test-private'..." in p_clone.stdout + assert "error: remote authentication required but no callback set" in p_clone.stderr + + @pytest.mark.parametrize("protocol", ["http", "https"]) def test_clone_gitlab(git2cpp_path, tmp_path, run_in_tmp_path, protocol): repo_url = f"{protocol}://gitlab.quantstack.net/ianthomas23_group/cockle-playground" diff --git a/test/test_commit.py b/test/test_commit.py index 6b3998e..9314a19 100644 --- a/test/test_commit.py +++ b/test/test_commit.py @@ -81,3 +81,45 @@ def test_commit_message_via_stdin( assert "Author:" in lines[1] assert "Date" in lines[2] assert commit_msg_out in lines[4] + + +def test_commit_no_changes_initial(commit_env_config, git2cpp_path, tmp_path): + """Commit on fresh repo with no staged files should fail""" + cmd_init = [git2cpp_path, "init", "."] + p_init = subprocess.run(cmd_init, capture_output=True, cwd=tmp_path) + assert p_init.returncode == 0 + + # Do NOT add any files — attempt to commit immediately + cmd_commit = [git2cpp_path, "commit", "-m", "empty commit"] + p_commit = subprocess.run(cmd_commit, capture_output=True, cwd=tmp_path, text=True) + + # Should fail: nothing to commit + assert p_commit.returncode != 0 + assert "nothing to commit" in p_commit.stderr or "nothing to commit" in p_commit.stdout + + +def test_commit_no_changes_after_first_commit(commit_env_config, git2cpp_path, tmp_path): + """Commit twice without changes between commits should fail""" + cmd_init = [git2cpp_path, "init", "."] + p_init = subprocess.run(cmd_init, capture_output=True, cwd=tmp_path) + assert p_init.returncode == 0 + + # Create and commit a file + (tmp_path / "file.txt").write_text("hello") + subprocess.run([git2cpp_path, "add", "file.txt"], cwd=tmp_path, check=True) + p_first = subprocess.run( + [git2cpp_path, "commit", "-m", "first commit"], cwd=tmp_path, capture_output=True, text=True + ) + assert p_first.returncode == 0 + + # Try to commit again without any new changes + p_second = subprocess.run( + [git2cpp_path, "commit", "-m", "second commit (no changes)"], + cwd=tmp_path, + capture_output=True, + text=True, + ) + + # Should fail: nothing to commit + assert p_second.returncode != 0 + assert "nothing to commit" in p_second.stderr or "nothing to commit" in p_second.stdout diff --git a/wasm/cockle-deploy/package.json b/wasm/cockle-deploy/package.json index 0c93ce2..bd54cfa 100644 --- a/wasm/cockle-deploy/package.json +++ b/wasm/cockle-deploy/package.json @@ -12,11 +12,11 @@ "author": "", "license": "ISC", "devDependencies": { - "@rspack/cli": "^1.0.4", - "@rspack/core": "^1.0.4", - "css-loader": "^7.1.2", + "@rspack/cli": "^2.0.6", + "@rspack/core": "^2.0.6", + "css-loader": "^7.1.4", "style-loader": "^4.0.0", - "ts-loader": "^9.5.1", + "ts-loader": "^9.6.0", "typescript": "^5.4.5" }, "dependencies": { diff --git a/wasm/test/package.json b/wasm/test/package.json index 908dcce..fea56d7 100644 --- a/wasm/test/package.json +++ b/wasm/test/package.json @@ -16,11 +16,12 @@ }, "devDependencies": { "@jupyterlite/cockle": "file:../cockle", - "@rspack/cli": "^0.7.5", - "@rspack/core": "^0.7.5", + "@rspack/cli": "^2.0.6", + "@rspack/core": "^2.0.6", + "@rspack/dev-server": "^2.0.3", "concurrently": "^9.2.1", "cors-anywhere": "^0.4.4", - "ts-loader": "^9.5.1", + "ts-loader": "^9.6.0", "ts-node": "^10.9.2", "typescript": "^5.5.4" } diff --git a/wasm/wasm-environment.yml b/wasm/wasm-environment.yml index f2cf5b0..4e9c241 100644 --- a/wasm/wasm-environment.yml +++ b/wasm/wasm-environment.yml @@ -15,7 +15,7 @@ dependencies: - jupyter_server - jupyterlite-terminal # For cockle and JupyterLite deployments - - nodejs + - nodejs <25 # For testing - pytest - pytest-playwright