diff --git a/src/subcommand/push_subcommand.cpp b/src/subcommand/push_subcommand.cpp index e5ad44f..4aabbf2 100644 --- a/src/subcommand/push_subcommand.cpp +++ b/src/subcommand/push_subcommand.cpp @@ -1,9 +1,12 @@ #include "../subcommand/push_subcommand.hpp" #include +#include #include +#include "../utils/ansi_code.hpp" +#include "../utils/common.hpp" #include "../utils/credentials.hpp" #include "../utils/progress.hpp" #include "../wasm/scope.hpp" @@ -14,8 +17,13 @@ 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]() @@ -40,25 +48,132 @@ 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()) + if (m_branches_flag) { + auto iter = repo.iterate_branches(GIT_BRANCH_LOCAL); + auto br = iter.next(); + while (br) + { + std::string refspec = "refs/heads/" + std::string(br->name()); + m_refspecs.push_back(refspec); + br = iter.next(); + } + } + else if (m_refspecs.empty()) + { + std::string branch; try { auto head_ref = repo.head(); - std::string short_name = head_ref.short_name(); - std::string refspec = "refs/heads/" + short_name; - m_refspecs.push_back(refspec); + branch = head_ref.short_name(); } catch (...) { std::cerr << "Could not determine current branch to push." << std::endl; return; } + std::string refspec = "refs/heads/" + branch; + m_refspecs.push_back(refspec); + } + else + { + for (auto& r : m_refspecs) + { + if (r.rfind("refs/", 0) == 0) + { + continue; + } + + r = "refs/heads/" + r; + } } git_strarray_wrapper refspecs_wrapper(m_refspecs); - git_strarray* refspecs_ptr = nullptr; - refspecs_ptr = refspecs_wrapper; + git_strarray* refspecs_ptr = refspecs_wrapper; + + // Take a snapshot of repo's references to check which ones are new after push + auto repo_refs = repo.reference_list(); + std::unordered_map remote_refs_before_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" + std::string canonical_remote_ref = std::string("refs/heads/") + short_name; + + git_oid oid = repo.ref_name_to_id(r); + remote_refs_before_oids.emplace(std::move(canonical_remote_ref), oid); + } + } remote.push(refspecs_ptr, &push_opts); - std::cout << "Pushed to " << remote_name << std::endl; + + std::cout << "To " << remote.url() << std::endl; + for (const auto& refspec : m_refspecs) + { + std::string_view ref_view(refspec); + std::string_view prefix_local = "refs/heads/"; + std::string local_short_name; + if (ref_view.size() >= prefix_local.size() && ref_view.substr(0, prefix_local.size()) == prefix_local) + { + local_short_name = std::string(ref_view.substr(prefix_local.size())); + } + else + { + local_short_name = std::string(refspec); + } + + std::optional upstream_opt = repo.branch_upstream_name(local_short_name); + + std::string remote_branch = local_short_name; + std::string canonical_remote_ref = "refs/heads/" + local_short_name; + 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; + canonical_remote_ref = "refs/heads/" + remote_branch; + } + } + } + + auto it = remote_refs_before_oids.find(canonical_remote_ref); + if (it == remote_refs_before_oids.end()) + { + std::cout << " * [new branch] " << local_short_name << " -> " << remote_branch << std::endl; + continue; + } + + git_oid remote_oid = it->second; + + std::optional local_oid_opt; + if (auto ref_opt = repo.find_reference_dwim(("refs/heads/" + local_short_name))) + { + const git_oid* target = ref_opt->target(); + local_oid_opt = *target; + } + + if (!local_oid_opt) + { + std::cout << " " << local_short_name << " -> " << remote_branch << std::endl; + continue; + } + git_oid local_oid = local_oid_opt.value(); + + if (!git_oid_equal(&remote_oid, &local_oid)) + { + 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 << " -> " << local_short_name << std::endl; + } + } } diff --git a/src/subcommand/push_subcommand.hpp b/src/subcommand/push_subcommand.hpp index 07c301e..615931e 100644 --- a/src/subcommand/push_subcommand.hpp +++ b/src/subcommand/push_subcommand.hpp @@ -18,4 +18,5 @@ class push_subcommand 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..91fa7e9 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::reference_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..1e29e69 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 reference_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..9c31a18 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 " * [new branch] test-" in p_push.stdout + assert "main" not in p_push.stdout