diff --git a/src/subcommand/log_subcommand.cpp b/src/subcommand/log_subcommand.cpp index 1c7fff0..e0eac66 100644 --- a/src/subcommand/log_subcommand.cpp +++ b/src/subcommand/log_subcommand.cpp @@ -11,16 +11,17 @@ #include "log_subcommand.hpp" #include "../utils/terminal_pager.hpp" -#include "../wrapper/repository_wrapper.hpp" -#include "../wrapper/commit_wrapper.hpp" log_subcommand::log_subcommand(const libgit2_object&, CLI::App& app) { auto *sub = app.add_subcommand("log", "Shows commit logs"); - sub->add_flag("--format", m_format_flag, "Pretty-print the contents of the commit logs in a given format, where can be one of full and fuller"); + sub->add_option("--format", m_format_flag, "Pretty-print the contents of the commit logs in a given format, where can be one of full, fuller or oneline"); sub->add_option("-n,--max-count", m_max_count_flag, "Limit the output to commits."); - // sub->add_flag("--oneline", m_oneline_flag, "This is a shorthand for --pretty=oneline --abbrev-commit used together."); + sub->add_flag("--abbrev-commit", m_abbrev_commit_flag, "Instead of showing the full 40-byte hexadecimal commit object name, show a prefix that names the object uniquely. --abbrev= (which also modifies diff output, if it is displayed) option can be used to specify the minimum length of the prefix."); + sub->add_option("--abbrev", m_abbrev, "Instead of showing the full 40-byte hexadecimal object name in diff-raw format output and diff-tree header lines, show the shortest prefix that is at least hexdigits long that uniquely refers the object."); + sub->add_flag("--no-abbrev-commit", m_no_abbrev_commit_flag, "Show the full 40-byte hexadecimal commit object name. This negates --abbrev-commit, either explicit or implied by other options such as --oneline."); + sub->add_flag("--oneline", m_oneline_flag, "This is a shorthand for --format=oneline --abbrev-commit used together."); sub->callback([this]() { this->run(); }); }; @@ -166,6 +167,7 @@ void print_refs(const commit_refs& refs) return; } + std::cout << termcolor::yellow; std::cout << " ("; bool first = true; @@ -179,7 +181,7 @@ void print_refs(const commit_refs& refs) first = false; } - for (const auto& tag :refs.tags) + for (const auto& tag : refs.tags) { if (!first) { @@ -212,17 +214,49 @@ void print_refs(const commit_refs& refs) std::cout << ")" << termcolor::reset; } -void print_commit(repository_wrapper& repo, const commit_wrapper& commit, std::string m_format_flag) +void log_subcommand::print_commit(repository_wrapper& repo, const commit_wrapper& commit) { - std::string buf = commit.commit_oid_tostr(); + const bool abbrev_commit = (m_abbrev_commit_flag || m_oneline_flag) && !m_no_abbrev_commit_flag; + const bool oneline = (m_format_flag == "oneline") || m_oneline_flag; + + std::string sha = commit.commit_oid_tostr(); + + if (abbrev_commit && m_abbrev <= sha.size()) + { + sha = sha.substr(0, m_abbrev); + } + + commit_refs refs = get_refs_for_commit(repo, commit.oid()); + + std::string message = commit.message(); + while (!message.empty() && message.back() == '\n') + { + message.pop_back(); + } + + if (oneline) + { + std::string subject; + { + std::istringstream s(message); + std::getline(s, subject); + } + + std::cout << termcolor::yellow << sha << termcolor::reset; + print_refs(refs); + if (!subject.empty()) + { + std::cout << " " << subject; + } + return; + } signature_wrapper author = signature_wrapper::get_commit_author(commit); signature_wrapper committer = signature_wrapper::get_commit_committer(commit); stream_colour_fn colour = termcolor::yellow; - std::cout << colour << "commit " << buf; + std::cout << colour << "commit " << sha << termcolor::reset; - commit_refs refs = get_refs_for_commit(repo, commit.oid()); print_refs(refs); std::cout << termcolor::reset << std::endl; @@ -247,11 +281,6 @@ void print_commit(repository_wrapper& repo, const commit_wrapper& commit, std::s } } - std::string message = commit.message(); - while (!message.empty() && message.back() == '\n') - { - message.pop_back(); - } std::istringstream message_stream(message); std::string line; while (std::getline(message_stream, line)) @@ -287,7 +316,7 @@ void log_subcommand::run() std::cout << std::endl; } commit_wrapper commit = repo.find_commit(commit_oid); - print_commit(repo, commit, m_format_flag); + print_commit(repo, commit); ++i; } diff --git a/src/subcommand/log_subcommand.hpp b/src/subcommand/log_subcommand.hpp index a42c052..ec0922d 100644 --- a/src/subcommand/log_subcommand.hpp +++ b/src/subcommand/log_subcommand.hpp @@ -4,6 +4,8 @@ #include #include "../utils/common.hpp" +#include "../wrapper/commit_wrapper.hpp" +#include "../wrapper/repository_wrapper.hpp" class log_subcommand @@ -14,7 +16,13 @@ class log_subcommand void run(); private: + + void print_commit(repository_wrapper& repo, const commit_wrapper& commit); + std::string m_format_flag; int m_max_count_flag=std::numeric_limits::max(); - // bool m_oneline_flag = false; + size_t m_abbrev = 7; + bool m_abbrev_commit_flag = false; + bool m_no_abbrev_commit_flag = false; + bool m_oneline_flag = false; }; diff --git a/src/subcommand/revparse_subcommand.cpp b/src/subcommand/revparse_subcommand.cpp index a521ad6..a866d90 100644 --- a/src/subcommand/revparse_subcommand.cpp +++ b/src/subcommand/revparse_subcommand.cpp @@ -1,14 +1,32 @@ #include "revparse_subcommand.hpp" +#include "../utils/git_exception.hpp" #include "../wrapper/repository_wrapper.hpp" -#include -#include revparse_subcommand::revparse_subcommand(const libgit2_object&, CLI::App& app) { auto* sub = app.add_subcommand("rev-parse", "Pick out and message parameters"); - sub->add_flag("--is-bare-repository", m_is_bare_repository_flag); - sub->add_flag("--is-shallow-repository", m_is_shallow_repository_flag); + auto* bare_opt = sub->add_flag("--is-bare-repository", m_is_bare_repository_flag, "When the repository is bare print \"true\", otherwise \"false\"."); + auto* shallow_opt = sub->add_flag("--is-shallow-repository", m_is_shallow_repository_flag, "When the repository is shallow print \"true\", otherwise \"false\"."); + auto* rev_opt = sub->add_option("", m_revisions, "Revision(s) to parse (e.g. HEAD, main, HEAD~1, dae86e, ...)"); + + sub->parse_complete_callback([this, sub, bare_opt, shallow_opt, rev_opt]() { + for (CLI::Option* opt : sub->parse_order()) + { + if (opt == bare_opt) + { + m_queries_in_order.push_back("is_bare"); + } + else if (opt == shallow_opt) + { + m_queries_in_order.push_back("is_shallow"); + } + else if (opt == rev_opt) + { + m_queries_in_order.push_back("is_rev"); + } + } + }); sub->callback([this]() { this->run(); }); } @@ -18,16 +36,30 @@ void revparse_subcommand::run() auto directory = get_current_git_path(); auto repo = repository_wrapper::open(directory); - if (m_is_bare_repository_flag) - { - std::cout << std::boolalpha << repo.is_bare() << std::endl; - } - else if (m_is_shallow_repository_flag) + size_t i = 0; + for (const auto& q : m_queries_in_order) { - std::cout << std::boolalpha << repo.is_shallow() << std::endl; - } - else - { - std::cout << "revparse only supports --is-bare-repository and --is-shallow-repository for now" << std::endl; + if (q == "is_bare") + { + std::cout << std::boolalpha << repo.is_bare() << std::endl; + } + else if (q == "is_shallow") + { + std::cout << std::boolalpha << repo.is_shallow() << std::endl; + } + else if (q == "is_rev") + { + const auto& rev = m_revisions[i]; + auto obj = repo.revparse_single(rev.c_str()); + + if (!obj.has_value()) + { + throw git_exception("bad revision '" + rev + "'", git2cpp_error_code::BAD_ARGUMENT); + } + + auto oid = obj.value().oid(); + std::cout << git_oid_tostr_s(&oid) << std::endl; + i += 1; + } } } diff --git a/src/subcommand/revparse_subcommand.hpp b/src/subcommand/revparse_subcommand.hpp index dcc363a..a08dcd9 100644 --- a/src/subcommand/revparse_subcommand.hpp +++ b/src/subcommand/revparse_subcommand.hpp @@ -1,5 +1,8 @@ #pragma once +#include +#include + #include #include "../utils/common.hpp" @@ -13,6 +16,8 @@ class revparse_subcommand private: + std::vector m_revisions; + std::vector m_queries_in_order; bool m_is_bare_repository_flag = false; bool m_is_shallow_repository_flag = false; }; diff --git a/src/subcommand/tag_subcommand.cpp b/src/subcommand/tag_subcommand.cpp index fd25be1..a64fdca 100644 --- a/src/subcommand/tag_subcommand.cpp +++ b/src/subcommand/tag_subcommand.cpp @@ -1,8 +1,6 @@ #include #include "../subcommand/tag_subcommand.hpp" -#include "../wrapper/commit_wrapper.hpp" -#include "../wrapper/tag_wrapper.hpp" tag_subcommand::tag_subcommand(const libgit2_object&, CLI::App& app) { @@ -12,6 +10,7 @@ tag_subcommand::tag_subcommand(const libgit2_object&, CLI::App& app) sub->add_flag("-f,--force", m_force_flag, "Replace an existing tag with the given name (instead of failing)"); sub->add_option("-d,--delete", m_delete, "Delete existing tags with the given names."); sub->add_option("-n", m_num_lines, " specifies how many lines from the annotation, if any, are printed when using -l. Implies --list."); + sub->add_flag("-a,--annotate", m_annotate_flag, "Make an annotated tag."); sub->add_option("-m,--message", m_message, "Tag message for annotated tags"); sub->add_option("", m_tag_name, "Tag name"); sub->add_option("", m_target, "Target commit (defaults to HEAD)"); @@ -217,10 +216,18 @@ void tag_subcommand::run() { delete_tag(repo); } - else if (m_list_flag || (m_tag_name.empty() && m_message.empty())) + else if (m_list_flag || (m_tag_name.empty() && m_message.empty() && !m_annotate_flag)) { list_tags(repo); } + else if (m_annotate_flag) + { + if (m_message.empty()) + { + throw git_exception("error: -a/--annotate requires -m/--message", git2cpp_error_code::BAD_ARGUMENT); + } + create_tag(repo); + } else if (!m_message.empty()) { create_tag(repo); diff --git a/src/subcommand/tag_subcommand.hpp b/src/subcommand/tag_subcommand.hpp index 512ea18..4aaba8d 100644 --- a/src/subcommand/tag_subcommand.hpp +++ b/src/subcommand/tag_subcommand.hpp @@ -28,5 +28,6 @@ class tag_subcommand std::string m_target; bool m_list_flag = false; bool m_force_flag = false; + bool m_annotate_flag = false; int m_num_lines = 0; }; diff --git a/test/test_log.py b/test/test_log.py index f4d3e40..5cc3eb1 100644 --- a/test/test_log.py +++ b/test/test_log.py @@ -1,3 +1,4 @@ +import re import subprocess import pytest @@ -193,7 +194,7 @@ def test_log_with_annotated_tag(commit_env_config, git2cpp_path, tmp_path): # Create an annotated tag subprocess.run( - ["git", "tag", "-a", "v2.0.0", "-m", "Version 2.0.0"], + [git2cpp_path, "tag", "-a", "v2.0.0", "-m", "Version 2.0.0"], cwd=tmp_path, check=True, ) @@ -284,3 +285,119 @@ def test_log_commit_without_references(commit_env_config, git2cpp_path, tmp_path if "(" in second_commit_line: # If it has parentheses, they shouldn't be empty assert "()" not in second_commit_line + + +@pytest.mark.parametrize( + "abbrev_commit_flag", ["", "--abbrev-commit", "--no-abbrev-commit"] +) +@pytest.mark.parametrize("abbrev_flag", ["", "--abbrev=10"]) +def test_log_abbrev_commit_flags( + repo_init_with_commit, + commit_env_config, + git2cpp_path, + tmp_path, + abbrev_commit_flag, + abbrev_flag, +): + """Test for --abbrev-commit, --abbrev= (only applies when --abbrev-commit is set) and --no-abbrev-commit""" + assert (tmp_path / "initial.txt").exists() + + cmd = [git2cpp_path, "log", "-n", "1"] + if abbrev_commit_flag != "": + cmd.append(abbrev_commit_flag) + if abbrev_flag != "": + cmd.append(abbrev_flag) + p = subprocess.run(cmd, capture_output=True, cwd=tmp_path, text=True) + assert p.returncode == 0 + + m = re.search(r"^commit\s+([0-9a-fA-F]+)", p.stdout, flags=re.MULTILINE) + if abbrev_commit_flag in ["", "--no-abbrev-commit"]: + assert len(m.group(1)) == 40 + else: + if abbrev_flag == "--abbrev=10": + assert len(m.group(1)) == 10 + else: + assert len(m.group(1)) == 7 + + +def test_log_format_oneline(repo_init_with_commit, git2cpp_path, tmp_path): + """Test --format=oneline prints the full sha""" + assert (tmp_path / "initial.txt").exists() + + p = subprocess.run( + [git2cpp_path, "rev-parse", "HEAD"], + cwd=tmp_path, + capture_output=True, + text=True, + ) + assert p.returncode == 0 + full_sha = p.stdout.strip() + + p = subprocess.run( + [git2cpp_path, "log", "--format=oneline", "-n", "1"], + cwd=tmp_path, + capture_output=True, + text=True, + ) + assert p.returncode == 0 + + # assert: should contain full 40-hex sha and subject + assert full_sha in p.stdout + assert "Initial commit" in p.stdout + + +def test_log_oneline(repo_init_with_commit, git2cpp_path, tmp_path): + """Test --oneline prints the default length sha (i.e 7)""" + assert (tmp_path / "initial.txt").exists() + + p = subprocess.run( + [git2cpp_path, "rev-parse", "HEAD"], + cwd=tmp_path, + capture_output=True, + text=True, + ) + assert p.returncode == 0 + full_sha = str(p.stdout.strip()) + abbrev7 = full_sha[:7] + + subprocess.run( + [git2cpp_path, "tag", "v1"], capture_output=True, cwd=tmp_path, check=True + ) + + p = subprocess.run( + [git2cpp_path, "log", "--oneline", "-n", "1"], + cwd=tmp_path, + capture_output=True, + text=True, + ) + assert p.returncode == 0 + + assert abbrev7 in p.stdout + assert full_sha not in p.stdout + assert "tag: v1" in p.stdout + assert "Initial commit" in p.stdout + + +def test_log_oneline_no_abbrev_commit(repo_init_with_commit, git2cpp_path, tmp_path): + """Test --oneline prints prints the full sha when using --no-abbrev-commit""" + assert (tmp_path / "initial.txt").exists() + + p = subprocess.run( + [git2cpp_path, "rev-parse", "HEAD"], + cwd=tmp_path, + capture_output=True, + text=True, + ) + assert p.returncode == 0 + full_sha = str(p.stdout.strip()) + + p = subprocess.run( + [git2cpp_path, "log", "--oneline", "--no-abbrev-commit", "-n", "1"], + cwd=tmp_path, + capture_output=True, + text=True, + ) + assert p.returncode == 0 + + assert full_sha in p.stdout + assert "Initial commit" in p.stdout diff --git a/test/test_revparse.py b/test/test_revparse.py index af99577..5b72ab7 100644 --- a/test/test_revparse.py +++ b/test/test_revparse.py @@ -29,3 +29,96 @@ def test_revparse_shallow(git2cpp_path, tmp_path, run_in_tmp_path): p2 = subprocess.run(cmd2, capture_output=True, text=True, cwd=xtl_path) assert p2.returncode == 0 assert p2.stdout == "true\n" + + cmd3 = [ + git2cpp_path, + "rev-parse", + "--is-shallow-repository", + "--is-bare-repository", + ] + p3 = subprocess.run(cmd3, capture_output=True, text=True, cwd=xtl_path) + assert p3.returncode == 0 + assert p3.stdout == "true\nfalse\n" + + +def test_revparse_multiple_revs(repo_init_with_commit, git2cpp_path, tmp_path): + """Test one sha per line is printed when multiple revisions are provided""" + assert (tmp_path / "initial.txt").exists() + + (tmp_path / "second.txt").write_text("second") + subprocess.run( + [git2cpp_path, "add", "second.txt"], + capture_output=True, + text=True, + cwd=tmp_path, + check=True, + ) + subprocess.run( + [git2cpp_path, "commit", "-m", "Second commit"], + capture_output=True, + text=True, + cwd=tmp_path, + check=True, + ) + + p = subprocess.run( + [git2cpp_path, "rev-parse", "HEAD", "HEAD~1"], + capture_output=True, + text=True, + cwd=tmp_path, + ) + assert p.returncode == 0 + + lines = p.stdout.splitlines() + print() + assert len(lines) == 2 + assert all(len(x) == 40 for x in lines) + assert lines[0] != lines[1] + + +def test_revparse_multiple_opts(git2cpp_path, tmp_path, run_in_tmp_path): + """Test the options are printed in order""" + url = "https://github.com/xtensor-stack/xtl.git" + cmd = [git2cpp_path, "clone", "--depth", "2", url] + p = subprocess.run(cmd, capture_output=True, text=True, cwd=tmp_path) + assert p.returncode == 0 + assert (tmp_path / "xtl").exists() + + xtl_path = tmp_path / "xtl" + + p = subprocess.run( + [ + git2cpp_path, + "rev-parse", + "HEAD", + "--is-shallow-repository", + "--is-bare-repository", + "HEAD~1", + ], + capture_output=True, + text=True, + cwd=xtl_path, + ) + assert p.returncode == 0 + + lines = p.stdout.splitlines() + assert len(lines) == 4 + assert len(lines[0]) == 40 + assert len(lines[3]) == 40 + assert lines[0] != lines[1] + assert "true" in lines[1] + assert "false" in lines[2] + + +def test_revparse_errors(repo_init_with_commit, git2cpp_path, tmp_path): + assert (tmp_path / "initial.txt").exists() + + rev_cmd = [git2cpp_path, "rev-parse", "HEAD~1"] + p_rev = subprocess.run(rev_cmd, capture_output=True, text=True, cwd=tmp_path) + assert p_rev.returncode == 129 + assert "bad revision" in p_rev.stderr + + opt_cmd = [git2cpp_path, "rev-parse", "--parseopt"] + p_opt = subprocess.run(opt_cmd, capture_output=True, text=True, cwd=tmp_path) + assert p_opt.returncode != 0 + assert "The following argument was not expected:" in p_opt.stderr diff --git a/test/test_tag.py b/test/test_tag.py index 5575877..bcb1c30 100644 --- a/test/test_tag.py +++ b/test/test_tag.py @@ -55,7 +55,7 @@ def test_tag_create_on_specific_commit( assert (tmp_path / "initial.txt").exists() # Get the commit SHA before creating new commit - old_head_cmd = ["git", "rev-parse", "HEAD"] + old_head_cmd = [git2cpp_path, "rev-parse", "HEAD"] p_old_head = subprocess.run( old_head_cmd, capture_output=True, cwd=tmp_path, text=True ) @@ -72,7 +72,7 @@ def test_tag_create_on_specific_commit( subprocess.run(commit_cmd, cwd=tmp_path, check=True) # Get new HEAD commit SHA - new_head_cmd = ["git", "rev-parse", "HEAD"] + new_head_cmd = [git2cpp_path, "rev-parse", "HEAD"] p_new_head = subprocess.run( new_head_cmd, capture_output=True, cwd=tmp_path, text=True ) @@ -92,7 +92,7 @@ def test_tag_create_on_specific_commit( assert "v1.0.0" in p_list.stdout # Get commit SHA that the tag points to - tag_sha_cmd = ["git", "rev-parse", "v1.0.0^{commit}"] + tag_sha_cmd = [git2cpp_path, "rev-parse", "v1.0.0^{commit}"] p_tag_sha = subprocess.run( tag_sha_cmd, capture_output=True, cwd=tmp_path, text=True ) @@ -135,8 +135,8 @@ def test_tag_delete_nonexistent(repo_init_with_commit, git2cpp_path, tmp_path): assert "not found" in p_delete.stderr -@pytest.mark.parametrize("list_flag", ["-l", "--list"]) -def test_tag_list_with_flag( +@pytest.mark.parametrize("list_flag", ["", "-l", "--list"]) +def test_tag_list( repo_init_with_commit, commit_env_config, git2cpp_path, tmp_path, list_flag ): """Test listing tags with -l or --list flag.""" @@ -320,3 +320,48 @@ def test_tag_on_new_commit( assert p_list.returncode == 0 assert "before-change" in p_list.stdout assert "after-change" in p_list.stdout + + +def test_tag_create_annotated_with_a_flag( + repo_init_with_commit, commit_env_config, git2cpp_path, tmp_path +): + """Test creating an annotated tag using -a flag.""" + assert (tmp_path / "initial.txt").exists() + + # Create an annotated tag using -a and -m + create_cmd = [git2cpp_path, "tag", "-a", "-m", "Release version 1.0", "v1.0.0"] + subprocess.run(create_cmd, capture_output=True, cwd=tmp_path, text=True, check=True) + + # List tags with message lines to verify it was created as an annotated tag + list_cmd = [git2cpp_path, "tag", "-n", "1"] + p_list = subprocess.run(list_cmd, capture_output=True, cwd=tmp_path, text=True) + assert p_list.returncode == 0 + assert "v1.0.0" in p_list.stdout + assert "Release version 1.0" in p_list.stdout + + +def test_tag_annotate_flag_requires_message( + repo_init_with_commit, commit_env_config, git2cpp_path, tmp_path +): + """Test that -a/--annotate without -m fails.""" + assert (tmp_path / "initial.txt").exists() + + create_cmd = [git2cpp_path, "tag", "-a", "v1.0.0"] + p = subprocess.run(create_cmd, capture_output=True, cwd=tmp_path, text=True) + assert p.returncode != 0 + assert "requires -m" in p.stderr + + +def test_tag_errors(repo_init_with_commit, commit_env_config, git2cpp_path, tmp_path): + assert (tmp_path / "initial.txt").exists() + + # Test that -a/--annotate without -m fails. + create_cmd = [git2cpp_path, "tag", "-a", "v1.0.0"] + p_create = subprocess.run(create_cmd, capture_output=True, cwd=tmp_path, text=True) + assert p_create.returncode != 0 + assert "requires -m" in p_create.stderr + + # Test that command fails when no message + del_cmd = [git2cpp_path, "tag", "-d"] + p_del = subprocess.run(del_cmd, capture_output=True, cwd=tmp_path, text=True) + assert p_del.returncode != 0