diff --git a/CMakeLists.txt b/CMakeLists.txt index cdcecda..30c36bc 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -82,6 +82,8 @@ set(GIT2CPP_SRC ${GIT2CPP_SOURCE_DIR}/subcommand/stash_subcommand.hpp ${GIT2CPP_SOURCE_DIR}/subcommand/status_subcommand.cpp ${GIT2CPP_SOURCE_DIR}/subcommand/status_subcommand.hpp + ${GIT2CPP_SOURCE_DIR}/subcommand/tag_subcommand.cpp + ${GIT2CPP_SOURCE_DIR}/subcommand/tag_subcommand.hpp ${GIT2CPP_SOURCE_DIR}/utils/ansi_code.cpp ${GIT2CPP_SOURCE_DIR}/utils/ansi_code.hpp ${GIT2CPP_SOURCE_DIR}/utils/common.cpp @@ -126,6 +128,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/tag_wrapper.cpp + ${GIT2CPP_SOURCE_DIR}/wrapper/tag_wrapper.hpp ${GIT2CPP_SOURCE_DIR}/wrapper/tree_wrapper.cpp ${GIT2CPP_SOURCE_DIR}/wrapper/tree_wrapper.hpp ${GIT2CPP_SOURCE_DIR}/wrapper/wrapper_base.hpp diff --git a/src/main.cpp b/src/main.cpp index efb385c..5d0223a 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -23,6 +23,7 @@ #include "subcommand/reset_subcommand.hpp" #include "subcommand/stash_subcommand.hpp" #include "subcommand/status_subcommand.hpp" +#include "subcommand/tag_subcommand.hpp" #include "subcommand/revparse_subcommand.hpp" #include "subcommand/revlist_subcommand.hpp" #include "subcommand/rm_subcommand.hpp" @@ -60,6 +61,7 @@ int main(int argc, char** argv) revparse_subcommand revparse(lg2_obj, app); rm_subcommand rm(lg2_obj, app); stash_subcommand stash(lg2_obj, app); + tag_subcommand tag(lg2_obj, app); app.require_subcommand(/* min */ 0, /* max */ 1); diff --git a/src/subcommand/log_subcommand.cpp b/src/subcommand/log_subcommand.cpp index 856ba7d..f5bc56b 100644 --- a/src/subcommand/log_subcommand.cpp +++ b/src/subcommand/log_subcommand.cpp @@ -78,7 +78,7 @@ void print_commit(const commit_wrapper& commit, std::string m_format_flag) print_time(author.when(), "Date:\t"); } } - std::cout << "\n " << git_commit_message(commit) << "\n" << std::endl; + std::cout << "\n " << commit.message() << "\n" << std::endl; } void log_subcommand::run() diff --git a/src/subcommand/tag_subcommand.cpp b/src/subcommand/tag_subcommand.cpp new file mode 100644 index 0000000..fd25be1 --- /dev/null +++ b/src/subcommand/tag_subcommand.cpp @@ -0,0 +1,237 @@ +#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) +{ + auto* sub = app.add_subcommand("tag", "Create, list, delete or verify tags"); + + sub->add_flag("-l,--list", m_list_flag, "List tags. With optional ."); + 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_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)"); + + sub->callback([this]() { this->run(); }); +} + +// Tag listing: Print individual message lines +void print_list_lines(const std::string& message, int num_lines) +{ + if (message.empty()) + { + return; + } + + auto lines = split_input_at_newlines(message); + + // 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++) + { + 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) + { + 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; + } +} + +// 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) + { + std::string msg = git_commit_message(commit); + if (!msg.empty()) + { + print_list_lines(msg, num_lines); + } + else + { + std::cout < tag_subcommand::get_target_obj(repository_wrapper& repo) +{ + if (m_tag_name.empty()) + { + throw git_exception("Tag name required", git2cpp_error_code::GENERIC_ERROR); + } + + std::string target = m_target.empty() ? "HEAD" : m_target; + + auto target_obj = repo.revparse_single(target); + if (!target_obj.has_value()) + { + throw git_exception("Unable to resolve target: " + target, git2cpp_error_code::GENERIC_ERROR); + } + + return target_obj; +} + +void tag_subcommand::handle_error(int error) +{ + if (error < 0) + { + if (error == GIT_EEXISTS) + { + throw git_exception("tag '" + m_tag_name + "' already exists", git2cpp_error_code::FILESYSTEM_ERROR); + } + throw git_exception("Unable to create annotated tag", error); + } +} + +void tag_subcommand::create_lightweight_tag(repository_wrapper& repo) +{ + auto target_obj = tag_subcommand::get_target_obj(repo); + + git_oid oid; + size_t force = m_force_flag ? 1 : 0; + int error = git_tag_create_lightweight(&oid, repo, m_tag_name.c_str(), target_obj.value(), force); + + handle_error(error); +} + +void tag_subcommand::create_tag(repository_wrapper& repo) +{ + auto target_obj = tag_subcommand::get_target_obj(repo); + + auto tagger = signature_wrapper::get_default_signature_from_env(repo); + + git_oid oid; + size_t force = m_force_flag ? 1 : 0; + int error = git_tag_create(&oid, repo, m_tag_name.c_str(), target_obj.value(), tagger.first, m_message.c_str(), force); + + handle_error(error); +} + +void tag_subcommand::run() +{ + auto directory = get_current_git_path(); + auto repo = repository_wrapper::open(directory); + + if (!m_delete.empty()) + { + delete_tag(repo); + } + else if (m_list_flag || (m_tag_name.empty() && m_message.empty())) + { + list_tags(repo); + } + else if (!m_message.empty()) + { + create_tag(repo); + } + else if (!m_tag_name.empty()) + { + create_lightweight_tag(repo); + } + else + { + list_tags(repo); + } + +} diff --git a/src/subcommand/tag_subcommand.hpp b/src/subcommand/tag_subcommand.hpp new file mode 100644 index 0000000..512ea18 --- /dev/null +++ b/src/subcommand/tag_subcommand.hpp @@ -0,0 +1,32 @@ +#pragma once + +#include + +#include "../utils/common.hpp" +#include "../wrapper/repository_wrapper.hpp" + +class tag_subcommand +{ +public: + + explicit tag_subcommand(const libgit2_object&, CLI::App& app); + + void run(); + +private: + + void list_tags(repository_wrapper& repo); + void delete_tag(repository_wrapper& repo); + void create_lightweight_tag(repository_wrapper& repo); + void create_tag(repository_wrapper& repo); + std::optional get_target_obj(repository_wrapper& repo); + void handle_error(int error); + + std::string m_delete; + std::string m_message; + std::string m_tag_name; + std::string m_target; + bool m_list_flag = false; + bool m_force_flag = false; + int m_num_lines = 0; +}; diff --git a/src/utils/common.cpp b/src/utils/common.cpp index 1df06e6..0d918f2 100644 --- a/src/utils/common.cpp +++ b/src/utils/common.cpp @@ -4,6 +4,9 @@ #include #include #include +#include + +#include #include "common.hpp" #include "git_exception.hpp" @@ -103,6 +106,11 @@ void git_strarray_wrapper::init_str_array() } } +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); @@ -114,3 +122,12 @@ std::string read_file(const std::string& path) buffer << file.rdbuf(); return buffer.str(); } + +std::vector split_input_at_newlines(std::string_view str) +{ + auto split = str | std::ranges::views::split('\n') + | std::ranges::views::transform([](auto&& range) { + return std::string(range.begin(), range.end()); + }); + return std::vector{split.begin(), split.end()}; +} diff --git a/src/utils/common.hpp b/src/utils/common.hpp index be9f360..bd77845 100644 --- a/src/utils/common.hpp +++ b/src/utils/common.hpp @@ -57,6 +57,8 @@ class git_strarray_wrapper operator git_strarray*(); + size_t size(); + private: std::vector m_patterns; git_strarray m_array; @@ -66,3 +68,5 @@ class git_strarray_wrapper }; std::string read_file(const std::string& path); + +std::vector split_input_at_newlines(std::string_view str); diff --git a/src/utils/terminal_pager.cpp b/src/utils/terminal_pager.cpp index 1b79996..3bec415 100644 --- a/src/utils/terminal_pager.cpp +++ b/src/utils/terminal_pager.cpp @@ -12,6 +12,7 @@ #include "ansi_code.hpp" #include "output.hpp" #include "terminal_pager.hpp" +#include "common.hpp" terminal_pager::terminal_pager() : m_rows(0), m_columns(0), m_start_row_index(0) @@ -167,7 +168,7 @@ void terminal_pager::show() { release_cout(); - split_input_at_newlines(m_stringbuf.view()); + m_lines = split_input_at_newlines(m_stringbuf.view()); update_terminal_size(); if (m_rows == 0 || m_lines.size() <= m_rows - 1) @@ -196,15 +197,6 @@ void terminal_pager::show() m_start_row_index = 0; } -void terminal_pager::split_input_at_newlines(std::string_view str) -{ - auto split = str | std::ranges::views::split('\n') - | std::ranges::views::transform([](auto&& range) { - return std::string(range.begin(), range.end()); - }); - m_lines = std::vector{split.begin(), split.end()}; -} - void terminal_pager::update_terminal_size() { struct winsize size; diff --git a/src/utils/terminal_pager.hpp b/src/utils/terminal_pager.hpp index 8c710a1..ea02865 100644 --- a/src/utils/terminal_pager.hpp +++ b/src/utils/terminal_pager.hpp @@ -49,8 +49,6 @@ class terminal_pager void scroll(bool up, bool page); - void split_input_at_newlines(std::string_view str); - void update_terminal_size(); diff --git a/src/wrapper/commit_wrapper.cpp b/src/wrapper/commit_wrapper.cpp index 33efa9f..fc214cc 100644 --- a/src/wrapper/commit_wrapper.cpp +++ b/src/wrapper/commit_wrapper.cpp @@ -28,6 +28,11 @@ std::string commit_wrapper::commit_oid_tostr() const return git_oid_tostr(buf, sizeof(buf), &this->oid()); } +std::string commit_wrapper::message() const +{ + return git_commit_message(*this); +} + std::string commit_wrapper::summary() const { return git_commit_summary(*this); diff --git a/src/wrapper/commit_wrapper.hpp b/src/wrapper/commit_wrapper.hpp index 4fe280f..0db1066 100644 --- a/src/wrapper/commit_wrapper.hpp +++ b/src/wrapper/commit_wrapper.hpp @@ -24,6 +24,7 @@ class commit_wrapper : public wrapper_base const git_oid& oid() const; std::string commit_oid_tostr() const; + std::string message() const; std::string summary() const; commit_list_wrapper get_parents_list() const; diff --git a/src/wrapper/object_wrapper.cpp b/src/wrapper/object_wrapper.cpp index bf21361..7649540 100644 --- a/src/wrapper/object_wrapper.cpp +++ b/src/wrapper/object_wrapper.cpp @@ -20,3 +20,8 @@ object_wrapper::operator git_commit*() const noexcept { return reinterpret_cast(p_resource); } + +object_wrapper::operator git_tag*() const noexcept +{ + return reinterpret_cast(p_resource); +} diff --git a/src/wrapper/object_wrapper.hpp b/src/wrapper/object_wrapper.hpp index d839ade..8faf1e1 100644 --- a/src/wrapper/object_wrapper.hpp +++ b/src/wrapper/object_wrapper.hpp @@ -18,6 +18,7 @@ class object_wrapper : public wrapper_base const git_oid& oid() const; operator git_commit*() const noexcept; + operator git_tag*() const noexcept; private: diff --git a/src/wrapper/repository_wrapper.cpp b/src/wrapper/repository_wrapper.cpp index a7fcf2b..2050ffe 100644 --- a/src/wrapper/repository_wrapper.cpp +++ b/src/wrapper/repository_wrapper.cpp @@ -505,3 +505,16 @@ diff_wrapper repository_wrapper::diff_index_to_workdir(std::optional 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; +} diff --git a/src/wrapper/repository_wrapper.hpp b/src/wrapper/repository_wrapper.hpp index 991bdc5..428f64f 100644 --- a/src/wrapper/repository_wrapper.hpp +++ b/src/wrapper/repository_wrapper.hpp @@ -6,6 +6,7 @@ #include +#include "../utils/common.hpp" #include "../utils/git_exception.hpp" #include "../wrapper/annotated_commit_wrapper.hpp" #include "../wrapper/branch_wrapper.hpp" @@ -113,6 +114,10 @@ class repository_wrapper : public wrapper_base diff_wrapper diff_tree_to_workdir_with_index(tree_wrapper old_tree, git_diff_options* diffopts); 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); + private: repository_wrapper() = default; diff --git a/src/wrapper/tag_wrapper.cpp b/src/wrapper/tag_wrapper.cpp new file mode 100644 index 0000000..e385dd4 --- /dev/null +++ b/src/wrapper/tag_wrapper.cpp @@ -0,0 +1,23 @@ +#include "../wrapper/tag_wrapper.hpp" +#include + +tag_wrapper::tag_wrapper(git_tag* tag) + : base_type(tag) +{ +} + +tag_wrapper::~tag_wrapper() +{ + git_tag_free(p_resource); + p_resource = nullptr; +} + +std::string tag_wrapper::name() +{ + return git_tag_name(*this); +} + +std::string tag_wrapper::message() +{ + return git_tag_message(*this); +} diff --git a/src/wrapper/tag_wrapper.hpp b/src/wrapper/tag_wrapper.hpp new file mode 100644 index 0000000..fb78eee --- /dev/null +++ b/src/wrapper/tag_wrapper.hpp @@ -0,0 +1,25 @@ +#pragma once + +#include +#include + +#include "../wrapper/wrapper_base.hpp" + +class tag_wrapper : public wrapper_base +{ +public: + + using base_type = wrapper_base; + + ~tag_wrapper(); + + tag_wrapper(tag_wrapper&&) noexcept = default; + tag_wrapper& operator=(tag_wrapper&&) noexcept = default; + + std::string name(); + std::string message(); + +private: + + tag_wrapper(git_tag* tag); +}; diff --git a/test/test_tag.py b/test/test_tag.py new file mode 100644 index 0000000..d698463 --- /dev/null +++ b/test/test_tag.py @@ -0,0 +1,298 @@ +import subprocess + +import pytest + +def test_tag_list_empty(xtl_clone, git2cpp_path, tmp_path): + """Test listing tags when there are no tags.""" + assert (tmp_path / "xtl").exists() + xtl_path = tmp_path / "xtl" + + cmd = [git2cpp_path, 'tag'] + p = subprocess.run(cmd, capture_output=True, cwd=xtl_path, text=True) + assert p.returncode == 0 + assert "0.2.0" in p.stdout + + +def test_tag_create_lightweight(xtl_clone, commit_env_config, git2cpp_path, tmp_path): + """Test creating a lightweight tag.""" + assert (tmp_path / "xtl").exists() + xtl_path = tmp_path / "xtl" + + # Create a lightweight tag + create_cmd = [git2cpp_path, 'tag', 'v1.0.0'] + subprocess.run(create_cmd, capture_output=True, cwd=xtl_path, text=True, check=True) + + # List tags to verify it was created + list_cmd = [git2cpp_path, 'tag'] + p_list = subprocess.run(list_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_list.returncode == 0 + assert 'v1.0.0' in p_list.stdout + + +def test_tag_create_annotated(xtl_clone, commit_env_config, git2cpp_path, tmp_path): + """Test creating an annotated tag.""" + assert (tmp_path / "xtl").exists() + xtl_path = tmp_path / "xtl" + + # Create an annotated tag + create_cmd = [git2cpp_path, 'tag', '-m', 'Release version 1.0', 'v1.0.0'] + subprocess.run(create_cmd, capture_output=True, cwd=xtl_path, text=True, check=True) + + # List tags to verify it was created + list_cmd = [git2cpp_path, 'tag', "-n", "1"] + p_list = subprocess.run(list_cmd, capture_output=True, cwd=xtl_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_create_on_specific_commit(xtl_clone, commit_env_config, git2cpp_path, tmp_path): + """Test creating a tag on a specific commit.""" + assert (tmp_path / "xtl").exists() + xtl_path = tmp_path / "xtl" + + # Get the commit SHA before creating new commit + old_head_cmd = ['git', 'rev-parse', 'HEAD'] + p_old_head = subprocess.run(old_head_cmd, capture_output=True, cwd=xtl_path, text=True) + old_head_sha = p_old_head.stdout.strip() + + # Create a commit first + file_path = xtl_path / "test_file.txt" + file_path.write_text("test content") + + add_cmd = [git2cpp_path, 'add', 'test_file.txt'] + subprocess.run(add_cmd, cwd=xtl_path, check=True) + + commit_cmd = [git2cpp_path, 'commit', '-m', 'test commit'] + subprocess.run(commit_cmd, cwd=xtl_path, check=True) + + # Get new HEAD commit SHA + new_head_cmd = ['git', 'rev-parse', 'HEAD'] + p_new_head = subprocess.run(new_head_cmd, capture_output=True, cwd=xtl_path, text=True) + new_head_sha = p_new_head.stdout.strip() + + # Verify we actually created a new commit + assert old_head_sha != new_head_sha + + # Create tag on HEAD + tag_cmd = [git2cpp_path, 'tag', 'v1.0.0', 'HEAD'] + subprocess.run(tag_cmd, capture_output=True, cwd=xtl_path, check=True) + + # Verify tag exists + list_cmd = [git2cpp_path, 'tag'] + p_list = subprocess.run(list_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_list.returncode == 0 + 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}'] + p_tag_sha = subprocess.run(tag_sha_cmd, capture_output=True, cwd=xtl_path, text=True) + tag_sha = p_tag_sha.stdout.strip() + + # Verify tag points to new HEAD, not old HEAD + assert tag_sha == new_head_sha + assert tag_sha != old_head_sha + + +def test_tag_delete(xtl_clone, commit_env_config, git2cpp_path, tmp_path): + """Test deleting a tag.""" + assert (tmp_path / "xtl").exists() + xtl_path = tmp_path / "xtl" + + # Create a tag + create_cmd = [git2cpp_path, 'tag', 'v1.0.0'] + subprocess.run(create_cmd, capture_output=True, cwd=xtl_path, text=True, check=True) + + # Delete the tag + delete_cmd = [git2cpp_path, 'tag', '-d', 'v1.0.0'] + p_delete = subprocess.run(delete_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_delete.returncode == 0 + assert "Deleted tag 'v1.0.0'" in p_delete.stdout + + # Verify tag is gone + list_cmd = [git2cpp_path, 'tag'] + p_list = subprocess.run(list_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_list.returncode == 0 + assert 'v1.0.0' not in p_list.stdout + + +def test_tag_delete_nonexistent(xtl_clone, git2cpp_path, tmp_path): + """Test deleting a tag that doesn't exist.""" + assert (tmp_path / "xtl").exists() + xtl_path = tmp_path / "xtl" + + # Try to delete non-existent tag + delete_cmd = [git2cpp_path, 'tag', '-d', 'nonexistent'] + p_delete = subprocess.run(delete_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_delete.returncode != 0 + assert "not found" in p_delete.stderr + + +@pytest.mark.parametrize("list_flag", ["-l", "--list"]) +def test_tag_list_with_flag(xtl_clone, commit_env_config, git2cpp_path, tmp_path, list_flag): + """Test listing tags with -l or --list flag.""" + assert (tmp_path / "xtl").exists() + xtl_path = tmp_path / "xtl" + + # Create a tag + tag_cmd = [git2cpp_path, 'tag', 'v1.0.0'] + subprocess.run(tag_cmd, capture_output=True, cwd=xtl_path, text=True) + + # List tags + list_cmd = [git2cpp_path, 'tag', list_flag] + p_list = subprocess.run(list_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_list.returncode == 0 + assert 'v1.0.0' in p_list.stdout + + +def test_tag_list_with_pattern(xtl_clone, commit_env_config, git2cpp_path, tmp_path): + """Test listing tags with a pattern.""" + assert (tmp_path / "xtl").exists() + xtl_path = tmp_path / "xtl" + + # Create tags with different prefixes + tag_cmd_1 = [git2cpp_path, 'tag', 'v1.0.0'] + subprocess.run(tag_cmd_1, capture_output=True, cwd=xtl_path, text=True) + + tag_cmd_2 = [git2cpp_path, 'tag', 'v1.0.1'] + subprocess.run(tag_cmd_2, capture_output=True, cwd=xtl_path, text=True) + + tag_cmd_3 = [git2cpp_path, 'tag', 'release-1.0'] + subprocess.run(tag_cmd_3, capture_output=True, cwd=xtl_path, text=True) + + # List only tags matching pattern + list_cmd = [git2cpp_path, 'tag', '-l', 'v1.0*'] + p_list = subprocess.run(list_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_list.returncode == 0 + assert 'v1.0.0' in p_list.stdout + assert 'v1.0.1' in p_list.stdout + assert 'release-1.0' not in p_list.stdout + + +def test_tag_list_with_message_lines(xtl_clone, commit_env_config, git2cpp_path, tmp_path): + """Test listing tags with message lines (-n flag).""" + assert (tmp_path / "xtl").exists() + xtl_path = tmp_path / "xtl" + + # Create an annotated tag with a message + create_cmd = [git2cpp_path, 'tag', '-m', 'First line\nSecond line\nThird line\nForth line', 'v1.0.0'] + p_create = subprocess.run(create_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_create.returncode == 0 + + # List tags with message lines + list_cmd = [git2cpp_path, 'tag', '-n', '3', '-l'] + p_list = subprocess.run(list_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_list.returncode == 0 + assert 'v1.0.0' in p_list.stdout + assert 'First line' in p_list.stdout + assert 'Second line' in p_list.stdout + assert 'Third line' in p_list.stdout + assert 'Forth line' not in p_list.stdout + + +@pytest.mark.parametrize("force_flag", ["-f", "--force"]) +def test_tag_force_replace(xtl_clone, commit_env_config, git2cpp_path, tmp_path, force_flag): + """Test replacing an existing tag with -f or --force flag.""" + assert (tmp_path / "xtl").exists() + xtl_path = tmp_path / "xtl" + + # Create initial tag + create_cmd_1 = [git2cpp_path, 'tag', 'v1.0.0'] + subprocess.run(create_cmd_1, capture_output=True, cwd=xtl_path, text=True, check=True) + + # Try to create same tag without force (should fail) + create_cmd_2 = [git2cpp_path, 'tag', 'v1.0.0'] + p_create_2 = subprocess.run(create_cmd_2, capture_output=True, cwd=xtl_path) + assert p_create_2.returncode != 0 + + # Create same tag with force (should succeed) + create_cmd_3 = [git2cpp_path, 'tag', force_flag, 'v1.0.0'] + p_create_3 = subprocess.run(create_cmd_3, capture_output=True, cwd=xtl_path, text=True) + assert p_create_3.returncode == 0 + + +def test_tag_nogit(git2cpp_path, tmp_path): + """Test tag command outside a git repository.""" + cmd = [git2cpp_path, 'tag'] + p = subprocess.run(cmd, capture_output=True, cwd=tmp_path, text=True) + assert p.returncode != 0 + + +def test_tag_annotated_no_message(xtl_clone, commit_env_config, git2cpp_path, tmp_path): + """Test creating an annotated tag without a message should fail.""" + assert (tmp_path / "xtl").exists() + xtl_path = tmp_path / "xtl" + + # Create a commit with a known message + file_path = xtl_path / "test_file.txt" + file_path.write_text("test content") + + add_cmd = [git2cpp_path, 'add', 'test_file.txt'] + subprocess.run(add_cmd, cwd=xtl_path, check=True) + + commit_cmd = [git2cpp_path, 'commit', '-m', 'my specific commit message'] + subprocess.run(commit_cmd, cwd=xtl_path, check=True) + + # Create tag with empty message (should create lightweight tag) + create_cmd = [git2cpp_path, 'tag', '-m', '', 'v1.0.0'] + subprocess.run(create_cmd, capture_output=True, cwd=xtl_path, check=True) + + # List tag with messages - lightweight tag shows commit message + list_cmd = [git2cpp_path, 'tag', '-n', '1'] + p_list = subprocess.run(list_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_list.returncode == 0 + assert 'v1.0.0' in p_list.stdout + # Lightweight tag shows the commit message, not a tag message + assert 'my specific commit message' in p_list.stdout + + +def test_tag_multiple_create_and_list(xtl_clone, commit_env_config, git2cpp_path, tmp_path): + """Test creating multiple tags and listing them.""" + assert (tmp_path / "xtl").exists() + xtl_path = tmp_path / "xtl" + + # Create multiple tags + tags = ['v1.0.0', 'v1.0.1', 'v1.1.0', 'v2.0.0'] + for tag in tags: + create_cmd = [git2cpp_path, 'tag', tag] + subprocess.run(create_cmd, capture_output=True, cwd=xtl_path, check=True) + + # List all tags + list_cmd = [git2cpp_path, 'tag'] + p_list = subprocess.run(list_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_list.returncode == 0 + + # Verify all tags are in the list + for tag in tags: + assert tag in p_list.stdout + + +def test_tag_on_new_commit(xtl_clone, commit_env_config, git2cpp_path, tmp_path): + """Test creating tags on new commits.""" + assert (tmp_path / "xtl").exists() + xtl_path = tmp_path / "xtl" + + # Tag the current commit + tag_cmd_1 = [git2cpp_path, 'tag', 'before-change'] + subprocess.run(tag_cmd_1, cwd=xtl_path, check=True) + + # Make a new commit + file_path = xtl_path / "new_file.txt" + file_path.write_text("new content") + + add_cmd = [git2cpp_path, 'add', 'new_file.txt'] + subprocess.run(add_cmd, cwd=xtl_path, check=True) + + commit_cmd = [git2cpp_path, 'commit', '-m', 'new commit'] + subprocess.run(commit_cmd, cwd=xtl_path, check=True) + + # Tag the new commit + tag_cmd_2 = [git2cpp_path, 'tag', 'after-change'] + subprocess.run(tag_cmd_2, cwd=xtl_path, check=True) + + # List tags + list_cmd = [git2cpp_path, 'tag'] + p_list = subprocess.run(list_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_list.returncode == 0 + assert 'before-change' in p_list.stdout + assert 'after-change' in p_list.stdout