diff --git a/src/subcommand/log_subcommand.cpp b/src/subcommand/log_subcommand.cpp index f5bc56b..1c7fff0 100644 --- a/src/subcommand/log_subcommand.cpp +++ b/src/subcommand/log_subcommand.cpp @@ -1,8 +1,11 @@ #include #include -#include +#include +#include #include +#include #include +#include #include @@ -50,7 +53,166 @@ void print_time(git_time intime, std::string prefix) std::cout << prefix << out << " " << sign << std::format("{:02d}", hours) << std::format("{:02d}", minutes) < get_tags_for_commit(repository_wrapper& repo, const git_oid& commit_oid) +{ + std::vector tags; + git_strarray tag_names = {0}; + + if (git_tag_list(&tag_names, repo) != 0) + { + return tags; + } + + for (size_t i = 0; i < tag_names.count; i++) + { + std::string tag_name = tag_names.strings[i]; + std::string ref_name = "refs/tags/" + std::string(tag_name); + + reference_wrapper tag_ref = repo.find_reference(ref_name); + object_wrapper peeled = tag_ref.peel(); + + if (git_oid_equal(&peeled.oid(), &commit_oid)) + { + tags.push_back(std::string(tag_name)); + } + } + + 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; + + auto branch_iter = repo.iterate_branches(type); + while (auto branch = branch_iter.next()) + { + 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) + { + git_reference* resolved = nullptr; + if (git_reference_resolve(&resolved, ref) == 0) + { + branch_target = git_reference_target(resolved); + git_reference_free(resolved); + } + } + + if (branch_target && git_oid_equal(branch_target, &commit_oid)) + { + 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; + + bool has_refs() const { + return !head_branch.empty() || !tags.empty() || + !local_branches.empty() || !remote_branches.empty(); + } +}; + +commit_refs get_refs_for_commit(repository_wrapper& repo, const git_oid& commit_oid) +{ + commit_refs refs; + + if (!repo.is_head_unborn()) + { + 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, ""); + + return refs; +} + +void print_refs(const commit_refs& refs) +{ + if (!refs.has_refs()) + { + return; + } + + std::cout << " ("; + + 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; + } + + for (const auto& tag :refs.tags) + { + if (!first) + { + std::cout << ", "; + } + std::cout << termcolor::bold << "tag: " << tag << termcolor::reset << termcolor::yellow; + first = false; + } + + for (const auto& remote : refs.remote_branches) + { + if (!first) + { + std::cout << ", "; + } + std::cout << termcolor::bold << termcolor::red << remote << termcolor::reset << termcolor::yellow; + first = false; + } + + for (const auto& local : refs.local_branches) + { + if (!first) + { + std::cout << ", "; + } + std::cout << termcolor::bold << termcolor::green << local << termcolor::reset << termcolor::yellow; + first = false; + } + + std::cout << ")" << termcolor::reset; +} + +void print_commit(repository_wrapper& repo, const commit_wrapper& commit, std::string m_format_flag) { std::string buf = commit.commit_oid_tostr(); @@ -58,7 +220,13 @@ void print_commit(const commit_wrapper& commit, std::string m_format_flag) signature_wrapper committer = signature_wrapper::get_commit_committer(commit); stream_colour_fn colour = termcolor::yellow; - std::cout << colour << "commit " << buf << termcolor::reset << std::endl; + std::cout << colour << "commit " << buf; + + commit_refs refs = get_refs_for_commit(repo, commit.oid()); + print_refs(refs); + + std::cout << termcolor::reset << std::endl; + if (m_format_flag=="fuller") { std::cout << "Author:\t " << author.name() << " " << author.email() << std::endl; @@ -78,7 +246,19 @@ void print_commit(const commit_wrapper& commit, std::string m_format_flag) print_time(author.when(), "Date:\t"); } } - std::cout << "\n " << commit.message() << "\n" << std::endl; + + 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)) + { + std::cout << "\n " << line; + } + std::cout << std::endl; } void log_subcommand::run() @@ -102,8 +282,12 @@ void log_subcommand::run() git_oid commit_oid; while (!walker.next(commit_oid) && i 2 else: assert p_log.stdout.count("Author") == 2 + + +def test_log_with_head_reference(xtl_clone, commit_env_config, git2cpp_path, tmp_path): + """Test that HEAD reference is shown on the latest commit.""" + assert (tmp_path / "xtl").exists() + xtl_path = tmp_path / "xtl" + + # Create a new commit + p = xtl_path / "test_file.txt" + p.write_text("test content") + + subprocess.run([git2cpp_path, "add", "test_file.txt"], cwd=xtl_path, check=True) + subprocess.run( + [git2cpp_path, "commit", "-m", "test commit"], cwd=xtl_path, check=True + ) + + # Run log with max count 1 to get only the latest commit + p_log = subprocess.run( + [git2cpp_path, "log", "-n", "1"], capture_output=True, cwd=xtl_path, text=True + ) + assert p_log.returncode == 0 + + # Check that HEAD reference is shown + assert "HEAD ->" in p_log.stdout + assert "master" in p_log.stdout or "main" in p_log.stdout + + +def test_log_with_tag(xtl_clone, commit_env_config, git2cpp_path, tmp_path): + """Test that tags are shown in log output.""" + assert (tmp_path / "xtl").exists() + xtl_path = tmp_path / "xtl" + + # Create a commit and tag it + p = xtl_path / "tagged_file.txt" + p.write_text("tagged content") + + subprocess.run([git2cpp_path, "add", "tagged_file.txt"], cwd=xtl_path, check=True) + subprocess.run( + [git2cpp_path, "commit", "-m", "tagged commit"], cwd=xtl_path, check=True + ) + + # Create a tag (using git command since git2cpp might not have tag creation yet) + subprocess.run(["git", "tag", "v1.0.0"], cwd=xtl_path, check=True) + + # Run log + p_log = subprocess.run( + [git2cpp_path, "log", "-n", "1"], capture_output=True, cwd=xtl_path, text=True + ) + assert p_log.returncode == 0 + + # Check that tag is shown + assert "tag: v1.0.0" in p_log.stdout + + +def test_log_with_multiple_tags(xtl_clone, commit_env_config, git2cpp_path, tmp_path): + """Test that multiple tags on the same commit are all shown.""" + assert (tmp_path / "xtl").exists() + xtl_path = tmp_path / "xtl" + + # Create a commit + p = xtl_path / "multi_tag_file.txt" + p.write_text("content") + + subprocess.run( + [git2cpp_path, "add", "multi_tag_file.txt"], cwd=xtl_path, check=True + ) + subprocess.run( + [git2cpp_path, "commit", "-m", "multi tag commit"], cwd=xtl_path, check=True + ) + + # Create multiple tags + subprocess.run(["git", "tag", "v1.0.0"], cwd=xtl_path, check=True) + subprocess.run(["git", "tag", "stable"], cwd=xtl_path, check=True) + subprocess.run(["git", "tag", "latest"], cwd=xtl_path, check=True) + + # Run log + p_log = subprocess.run( + [git2cpp_path, "log", "-n", "1"], capture_output=True, cwd=xtl_path, text=True + ) + assert p_log.returncode == 0 + + # Check that all tags are shown + assert "tag: v1.0.0" in p_log.stdout + assert "tag: stable" in p_log.stdout + assert "tag: latest" in p_log.stdout + + +def test_log_with_annotated_tag(xtl_clone, commit_env_config, git2cpp_path, tmp_path): + """Test that annotated tags are shown in log output.""" + assert (tmp_path / "xtl").exists() + xtl_path = tmp_path / "xtl" + + # Create a commit + p = xtl_path / "annotated_tag_file.txt" + p.write_text("content") + + subprocess.run( + [git2cpp_path, "add", "annotated_tag_file.txt"], cwd=xtl_path, check=True + ) + subprocess.run( + [git2cpp_path, "commit", "-m", "annotated tag commit"], cwd=xtl_path, check=True + ) + + # Create an annotated tag + subprocess.run( + ["git", "tag", "-a", "v2.0.0", "-m", "Version 2.0.0"], cwd=xtl_path, check=True + ) + + # Run log + p_log = subprocess.run( + [git2cpp_path, "log", "-n", "1"], capture_output=True, cwd=xtl_path, text=True + ) + assert p_log.returncode == 0 + + # Check that annotated tag is shown + assert "tag: v2.0.0" in p_log.stdout + + +def test_log_with_branch(xtl_clone, commit_env_config, git2cpp_path, tmp_path): + """Test that branches are shown in log output.""" + assert (tmp_path / "xtl").exists() + xtl_path = tmp_path / "xtl" + + # Create a commit + p = xtl_path / "branch_file.txt" + p.write_text("content") + + subprocess.run([git2cpp_path, "add", "branch_file.txt"], cwd=xtl_path, check=True) + subprocess.run( + [git2cpp_path, "commit", "-m", "branch commit"], cwd=xtl_path, check=True + ) + + # Create a new branch pointing to HEAD + subprocess.run(["git", "branch", "feature-branch"], cwd=xtl_path, check=True) + + # Run log + p_log = subprocess.run( + [git2cpp_path, "log", "-n", "1"], capture_output=True, cwd=xtl_path, text=True + ) + assert p_log.returncode == 0 + + # Check that both branches are shown (HEAD -> master/main and feature-branch) + assert "feature-branch" in p_log.stdout + + +def test_log_with_remote_branches(xtl_clone, commit_env_config, git2cpp_path, tmp_path): + """Test that remote branches are shown in log output.""" + assert (tmp_path / "xtl").exists() + xtl_path = tmp_path / "xtl" + + # The xtl_clone fixture already has remote branches (origin/master, etc.) + # Run log to check they appear + p_log = subprocess.run( + [git2cpp_path, "log", "-n", "1"], capture_output=True, cwd=xtl_path, text=True + ) + assert p_log.returncode == 0 + + # Check that origin remote branches are shown + assert "origin/master" in p_log.stdout + + +def test_log_commit_without_references( + xtl_clone, commit_env_config, git2cpp_path, tmp_path +): + """Test that commits without any references don't show empty parentheses.""" + assert (tmp_path / "xtl").exists() + xtl_path = tmp_path / "xtl" + + # Create two commits - the second one will have refs, the first won't + for i in range(2): + p = xtl_path / f"file_{i}.txt" + p.write_text(f"content {i}") + subprocess.run([git2cpp_path, "add", f"file_{i}.txt"], cwd=xtl_path, check=True) + subprocess.run( + [git2cpp_path, "commit", "-m", f"commit {i}"], cwd=xtl_path, check=True + ) + + # Run log with 2 commits + p_log = subprocess.run( + [git2cpp_path, "log", "-n", "2"], capture_output=True, cwd=xtl_path, text=True + ) + assert p_log.returncode == 0 + + # First commit line should have references + lines = p_log.stdout.split("\n") + first_commit_line = [l for l in lines if l.startswith("commit")][0] + assert "(" in first_commit_line # Has references + + # Second commit (older one) should not have empty parentheses + second_commit_line = [l for l in lines if l.startswith("commit")][1] + # Should either have no parentheses or have actual references + if "(" in second_commit_line: + # If it has parentheses, they shouldn't be empty + assert "()" not in second_commit_line