diff --git a/src/subcommand/checkout_subcommand.cpp b/src/subcommand/checkout_subcommand.cpp index 1c3dfdb..ccc24f9 100644 --- a/src/subcommand/checkout_subcommand.cpp +++ b/src/subcommand/checkout_subcommand.cpp @@ -118,7 +118,7 @@ void checkout_subcommand::run() print_tobecommited(sl, tracked_dir_set, is_long, is_coloured); } std::cout << "Switched to branch '" << m_branch_name << "'" << std::endl; - print_tracking_info(repo, sl, true); + print_tracking_info(repo, sl, true, false); } } diff --git a/src/subcommand/status_subcommand.cpp b/src/subcommand/status_subcommand.cpp index 43cc9e8..e2c0f0c 100644 --- a/src/subcommand/status_subcommand.cpp +++ b/src/subcommand/status_subcommand.cpp @@ -1,9 +1,11 @@ #include "status_subcommand.hpp" +#include #include #include #include #include +#include #include #include @@ -32,158 +34,324 @@ status_subcommand::status_subcommand(const libgit2_object&, CLI::App& app) ); }; -const std::string - untracked_header = "Untracked files:\n (use \"git add ...\" to include in what will be committed)\n"; -const std::string tobecommited_header = "Changes to be committed:\n (use \"git reset HEAD ...\" to unstage)\n"; -const std::string - ignored_header = "Ignored files:\n (use \"git add -f ...\" to include in what will be committed)\n"; -const std::string - notstagged_header = "Changes not staged for commit:\n (use \"git add ...\" to update what will be committed)\n"; -// TODO: add the following ot notstagged_header after "checkout " is implemented: (use \"git checkout -- -// ...\" to discard changes in working directory)\n"; -const std::string unmerged_header = "Unmerged paths:\n (use \"git add ...\" to mark resolution)\n"; -const std::string nothingtocommit_message = "no changes added to commit (use \"git add\" and/or \"git commit -a\")"; -const std::string treeclean_message = "Nothing to commit, working tree clean"; - -enum class output_format +namespace { - DEFAULT = 0, - LONG = 1, - SHORT = 2 -}; - -struct print_entry -{ - std::string status; - std::string item; -}; - -std::string get_print_status(git_status_t status, bool is_long) -{ - std::string entry_status; - if (is_long) - { - entry_status = get_status_msg(status).long_mod; - } - else - { - entry_status = get_status_msg(status).short_mod; + const std::string + untracked_header = "Untracked files:\n (use \"git add ...\" to include in what will be committed)\n"; + const std::string tobecommited_header = "Changes to be committed:\n (use \"git reset HEAD ...\" to unstage)\n"; + const std::string + ignored_header = "Ignored files:\n (use \"git add -f ...\" to include in what will be committed)\n"; + const std::string + notstagged_header = "Changes not staged for commit:\n (use \"git add ...\" to update what will be committed)\n"; + // TODO: add the following ot notstagged_header after "checkout " is implemented: (use \"git + // checkout -- ...\" to discard changes in working directory)\n"; + const std::string unmerged_header = "Unmerged paths:\n (use \"git add ...\" to mark resolution)\n"; + const std::string nothingtocommit_message = "no changes added to commit (use \"git add\" and/or \"git commit -a\")"; + const std::string treeclean_message = "Nothing to commit, working tree clean"; + + enum class output_format + { + DEFAULT = 0, + LONG = 1, + SHORT = 2 + }; + + struct print_entry + { + std::string status; + std::string item; + }; + + struct combined_entry + { + git_status_t index_status = GIT_STATUS_CURRENT; + git_status_t workdir_status = GIT_STATUS_CURRENT; + std::string item; + }; + + std::string get_print_status(git_status_t status, bool is_long) + { + std::string entry_status; + if (is_long) + { + entry_status = get_status_msg(status).long_mod; + } + else + { + entry_status = get_status_msg(status).short_mod; + } + return entry_status; } - return entry_status; -} -void update_tracked_dir_set(const char* path, std::set* tracked_dir_set = nullptr) -{ - if (tracked_dir_set) + char short_char_from_status(git_status_t status) { - const size_t first_slash_idx = std::string_view(path).find('/'); - if (std::string::npos != first_slash_idx) + switch (status) { - auto directory = std::string_view(path).substr(0, first_slash_idx); - tracked_dir_set->insert(std::string(directory)); + case GIT_STATUS_INDEX_NEW: + case GIT_STATUS_WT_NEW: + return 'A'; + case GIT_STATUS_INDEX_MODIFIED: + case GIT_STATUS_WT_MODIFIED: + return 'M'; + case GIT_STATUS_INDEX_DELETED: + case GIT_STATUS_WT_DELETED: + return 'D'; + case GIT_STATUS_INDEX_RENAMED: + case GIT_STATUS_WT_RENAMED: + return 'R'; + case GIT_STATUS_INDEX_TYPECHANGE: + case GIT_STATUS_WT_TYPECHANGE: + return 'T'; + default: + return ' '; } } -} -std::string get_print_item(const char* old_path, const char* new_path) -{ - std::string entry_item; - if (old_path && new_path && std::strcmp(old_path, new_path)) + void update_tracked_dir_set(const char* path, std::set* tracked_dir_set = nullptr) { - entry_item = std::string(old_path) + " -> " + std::string(new_path); - } - else - { - entry_item = old_path ? old_path : new_path; + if (tracked_dir_set) + { + const size_t first_slash_idx = std::string_view(path).find('/'); + if (std::string::npos != first_slash_idx) + { + auto directory = std::string_view(path).substr(0, first_slash_idx); + tracked_dir_set->insert(std::string(directory)); + } + } } - return entry_item; -} -std::vector get_entries_to_print( - git_status_t status, - status_list_wrapper& sl, - bool head_selector, - bool is_long, - std::set* tracked_dir_set = nullptr -) -{ - std::vector entries_to_print{}; - const auto& entry_list = sl.get_entry_list(status); - if (entry_list.empty()) + std::string get_print_item(const char* old_path, const char* new_path) { - return entries_to_print; + std::string entry_item; + if (old_path && new_path && std::strcmp(old_path, new_path)) + { + entry_item = std::string(old_path) + " -> " + std::string(new_path); + } + else + { + entry_item = old_path ? old_path : new_path; + } + return entry_item; } - for (auto* entry : entry_list) + std::unordered_map + build_combined_status_map(status_list_wrapper& sl, std::set& tracked_dir_set) { - git_diff_delta* diff_delta = head_selector ? entry->head_to_index : entry->index_to_workdir; - const char* old_path = diff_delta->old_file.path; - const char* new_path = diff_delta->new_file.path; + std::unordered_map combined; + + auto update_status_map = + [&sl, &tracked_dir_set, &combined](const git_status_t(&status_array)[5], bool index) + { + for (git_status_t status : status_array) + { + const auto& list = sl.get_entry_list(status); + for (auto* entry : list) + { + git_diff_delta* dd = index ? entry->head_to_index : entry->index_to_workdir; + const char* old_path = dd->old_file.path; + const char* new_path = dd->new_file.path; + update_tracked_dir_set(old_path, &tracked_dir_set); + std::string item = get_print_item(old_path, new_path); + auto& ce = combined[item]; + ce.item = item; + if (index) + { + ce.index_status = status; + } + else + { + ce.workdir_status = status; + } + } + } + }; + + const git_status_t index_statuses[] = { + GIT_STATUS_INDEX_NEW, + GIT_STATUS_INDEX_MODIFIED, + GIT_STATUS_INDEX_DELETED, + GIT_STATUS_INDEX_RENAMED, + GIT_STATUS_INDEX_TYPECHANGE + }; + update_status_map(index_statuses, true); + + const git_status_t worktree_statuses[] = { + GIT_STATUS_WT_NEW, + GIT_STATUS_WT_MODIFIED, + GIT_STATUS_WT_DELETED, + GIT_STATUS_WT_TYPECHANGE, + GIT_STATUS_WT_RENAMED + }; + update_status_map(worktree_statuses, false); + + return combined; + } + + void + print_combined_short(const std::unordered_map& entries_map, bool is_coloured) + { + std::vector keys; + keys.reserve(entries_map.size()); + for (const auto& kv : entries_map) + { + keys.push_back(kv.first); + } + std::sort(keys.begin(), keys.end()); + + struct normal_row + { + char idx; + char wt; + std::string item; + }; + + std::vector normal_rows; + std::vector untracked_items; + std::vector ignored_items; + + for (const auto& k : keys) + { + const auto& ce = entries_map.at(k); + + // Collect special cases to print last (untracked, ignored) only when not staged. + if ((ce.workdir_status & GIT_STATUS_WT_NEW) && ce.index_status == 0) + { + untracked_items.push_back(ce.item); + continue; + } + if ((ce.workdir_status & GIT_STATUS_IGNORED || ce.index_status & GIT_STATUS_IGNORED) + && ce.index_status == 0) + { + ignored_items.push_back(ce.item); + continue; + } + + // Regular two-column entry (may include index or worktree or both) + char idx = short_char_from_status(ce.index_status); + char wt = short_char_from_status(ce.workdir_status); + normal_rows.push_back({idx, wt, ce.item}); + } + + for (const auto& r : normal_rows) + { + if (is_coloured) + { + std::cout << termcolor::green << r.idx << termcolor::reset; + std::cout << termcolor::red << r.wt << termcolor::reset; + std::cout << " " << r.item << std::endl; + } + else + { + std::cout << r.idx << r.wt << " " << r.item << std::endl; + } + } - update_tracked_dir_set(old_path, tracked_dir_set); + stream_colour_fn colour; + if (is_coloured) + { + colour = termcolor::red; + } + else + { + colour = termcolor::bright_white; + } - print_entry e = {get_print_status(status, is_long), get_print_item(old_path, new_path)}; + std::sort(untracked_items.begin(), untracked_items.end()); + for (const auto& it : untracked_items) + { + std::cout << colour << "?? " << termcolor::reset << it << std::endl; + } - entries_to_print.push_back(std::move(e)); + std::sort(ignored_items.begin(), ignored_items.end()); + for (const auto& it : ignored_items) + { + std::cout << colour << "!! " << termcolor::reset << it << std::endl; + } } - return entries_to_print; -} -void print_entries(std::vector entries_to_print, bool is_long, stream_colour_fn colour) -{ - for (auto e : entries_to_print) + std::vector get_entries_to_print( + git_status_t status, + status_list_wrapper& sl, + bool head_selector, + bool is_long, + std::set* tracked_dir_set = nullptr + ) { - if (is_long) + std::vector entries_to_print{}; + const auto& entry_list = sl.get_entry_list(status); + if (entry_list.empty()) { - std::cout << colour << e.status << e.item << termcolor::reset << std::endl; + return entries_to_print; } - else + + for (auto* entry : entry_list) { - std::cout << colour << e.status << termcolor::reset << e.item << std::endl; + git_diff_delta* diff_delta = head_selector ? entry->head_to_index : entry->index_to_workdir; + const char* old_path = diff_delta->old_file.path; + const char* new_path = diff_delta->new_file.path; + + update_tracked_dir_set(old_path, tracked_dir_set); + + print_entry e = {get_print_status(status, is_long), get_print_item(old_path, new_path)}; + + entries_to_print.push_back(std::move(e)); } + return entries_to_print; } -} -void print_not_tracked( - const std::vector& entries_to_print, - const std::set& tracked_dir_set, - std::set& untracked_dir_set, - bool is_long, - stream_colour_fn colour -) -{ - std::vector not_tracked_entries_to_print{}; - for (auto e : entries_to_print) + void print_entries(std::vector entries_to_print, bool is_long, stream_colour_fn colour) { - const size_t first_slash_idx = e.item.find('/'); - if (std::string::npos != first_slash_idx) + for (const auto& e : entries_to_print) { - auto directory = e.item.substr(0, first_slash_idx) + "/"; - if (tracked_dir_set.contains(directory)) + if (is_long) { - not_tracked_entries_to_print.push_back(e); + std::cout << colour << e.status << e.item << termcolor::reset << std::endl; } else { - if (untracked_dir_set.contains(directory)) + std::cout << colour << e.status << termcolor::reset << e.item << std::endl; + } + } + } + + void print_not_tracked( + const std::vector& entries_to_print, + const std::set& tracked_dir_set, + std::set& untracked_dir_set, + bool is_long, + stream_colour_fn colour + ) + { + std::vector not_tracked_entries_to_print{}; + for (const auto& e : entries_to_print) + { + const size_t first_slash_idx = e.item.find('/'); + if (std::string::npos != first_slash_idx) + { + auto directory = e.item.substr(0, first_slash_idx) + "/"; + if (tracked_dir_set.contains(directory)) { + not_tracked_entries_to_print.push_back(e); } else { - not_tracked_entries_to_print.push_back({e.status, directory}); - untracked_dir_set.insert(std::string(directory)); + if (!untracked_dir_set.contains(directory)) + { + not_tracked_entries_to_print.push_back({e.status, directory}); + untracked_dir_set.insert(std::string(directory)); + } } } + else + { + not_tracked_entries_to_print.push_back(e); + } } - else - { - not_tracked_entries_to_print.push_back(e); - } + print_entries(not_tracked_entries_to_print, is_long, colour); } - print_entries(not_tracked_entries_to_print, is_long, colour); } -void print_tracking_info(repository_wrapper& repo, status_list_wrapper& sl, bool is_long) +void print_tracking_info(repository_wrapper& repo, status_list_wrapper& sl, bool is_long, bool branch_flag) { auto tracking_info = repo.get_tracking_info(); @@ -234,18 +402,18 @@ void print_tracking_info(repository_wrapper& repo, status_list_wrapper& sl, bool << std::endl; } } - else + else if (branch_flag) { if (tracking_info.has_upstream) { - std::cout << "..." << tracking_info.upstream_name; + std::cout << "..." << termcolor::red << tracking_info.upstream_name << termcolor::reset; if (tracking_info.ahead > 0 || tracking_info.behind > 0) { std::cout << " ["; if (tracking_info.ahead > 0) { - std::cout << "ahead " << tracking_info.ahead; + std::cout << "ahead " << termcolor::green << tracking_info.ahead << termcolor::reset; } if (tracking_info.behind > 0) { @@ -253,7 +421,7 @@ void print_tracking_info(repository_wrapper& repo, status_list_wrapper& sl, bool { std::cout << ", "; } - std::cout << "behind " << tracking_info.behind; + std::cout << "behind " << termcolor::red << tracking_info.behind << termcolor::reset; } std::cout << "]"; } @@ -451,31 +619,46 @@ void status_run(status_subcommand_options options) is_long = ((of == output_format::DEFAULT) || (of == output_format::LONG)); auto branch_name = repo.head_short_name(); + bool is_coloured = true; + bool branch_flag = options.m_branch_flag; if (is_long) { std::cout << "On branch " << branch_name << std::endl; } - else if (options.m_branch_flag) + else if (branch_flag) + { + if (is_coloured) + { + std::cout << "## " << termcolor::green << branch_name << termcolor::reset; + } + else + { + std::cout << "## " << branch_name; + } + } + + print_tracking_info(repo, sl, is_long, branch_flag); + + if (of == output_format::SHORT) { - std::cout << "## " << branch_name << std::endl; + auto combined = build_combined_status_map(sl, tracked_dir_set); + print_combined_short(combined, is_coloured); + return; } - bool is_coloured = true; - print_tracking_info(repo, sl, is_long); if (sl.has_tobecommited_header()) { print_tobecommited(sl, tracked_dir_set, is_long, is_coloured); } - if (sl.has_notstagged_header()) + if (sl.has_unmerged_header()) { - print_notstagged(sl, tracked_dir_set, is_long, is_coloured); + print_unmerged(sl, tracked_dir_set, untracked_dir_set, is_long, is_coloured); } - // TODO: check if should be printed before "not stagged" files - if (sl.has_unmerged_header()) + if (sl.has_notstagged_header()) { - print_unmerged(sl, tracked_dir_set, untracked_dir_set, is_long, is_coloured); + print_notstagged(sl, tracked_dir_set, is_long, is_coloured); } if (sl.has_untracked_header()) diff --git a/src/subcommand/status_subcommand.hpp b/src/subcommand/status_subcommand.hpp index 3d64480..d017571 100644 --- a/src/subcommand/status_subcommand.hpp +++ b/src/subcommand/status_subcommand.hpp @@ -26,5 +26,5 @@ class status_subcommand void print_tobecommited(status_list_wrapper& sl, std::set tracked_dir_set, bool is_long, bool is_coloured); void print_notstagged(status_list_wrapper& sl, std::set tracked_dir_set, bool is_long, bool is_coloured); -void print_tracking_info(repository_wrapper& repo, status_list_wrapper& sl, bool is_long); +void print_tracking_info(repository_wrapper& repo, status_list_wrapper& sl, bool is_long, bool branch_flag); void status_run(status_subcommand_options fl = {}); diff --git a/src/wrapper/status_wrapper.cpp b/src/wrapper/status_wrapper.cpp index 410651a..dbeaaab 100644 --- a/src/wrapper/status_wrapper.cpp +++ b/src/wrapper/status_wrapper.cpp @@ -23,7 +23,58 @@ status_list_wrapper status_list_wrapper::status_list(const repository_wrapper& r for (std::size_t i = 0; i < status_list_size; ++i) { auto entry = git_status_byindex(res.p_resource, i); - res.m_entries[entry->status].push_back(entry); + auto s = entry->status; + + if (s & GIT_STATUS_INDEX_NEW) + { + res.m_entries[GIT_STATUS_INDEX_NEW].push_back(entry); + } + if (s & GIT_STATUS_INDEX_MODIFIED) + { + res.m_entries[GIT_STATUS_INDEX_MODIFIED].push_back(entry); + } + if (s & GIT_STATUS_INDEX_DELETED) + { + res.m_entries[GIT_STATUS_INDEX_DELETED].push_back(entry); + } + if (s & GIT_STATUS_INDEX_RENAMED) + { + res.m_entries[GIT_STATUS_INDEX_RENAMED].push_back(entry); + } + if (s & GIT_STATUS_INDEX_TYPECHANGE) + { + res.m_entries[GIT_STATUS_INDEX_TYPECHANGE].push_back(entry); + } + + if (s & GIT_STATUS_WT_NEW) + { + res.m_entries[GIT_STATUS_WT_NEW].push_back(entry); + } + if (s & GIT_STATUS_WT_MODIFIED) + { + res.m_entries[GIT_STATUS_WT_MODIFIED].push_back(entry); + } + if (s & GIT_STATUS_WT_DELETED) + { + res.m_entries[GIT_STATUS_WT_DELETED].push_back(entry); + } + if (s & GIT_STATUS_WT_RENAMED) + { + res.m_entries[GIT_STATUS_WT_RENAMED].push_back(entry); + } + if (s & GIT_STATUS_WT_TYPECHANGE) + { + res.m_entries[GIT_STATUS_WT_TYPECHANGE].push_back(entry); + } + + if (s & GIT_STATUS_IGNORED) + { + res.m_entries[GIT_STATUS_IGNORED].push_back(entry); + } + if (s & GIT_STATUS_CONFLICTED) + { + res.m_entries[GIT_STATUS_CONFLICTED].push_back(entry); + } } if (!res.get_entry_list(GIT_STATUS_INDEX_NEW).empty() diff --git a/test/test_status.py b/test/test_status.py index cb4b530..e8e203a 100644 --- a/test/test_status.py +++ b/test/test_status.py @@ -258,7 +258,10 @@ def test_status_untracked_directory(repo_init_with_commit, git2cpp_path, tmp_pat @pytest.mark.parametrize("short_flag", ["", "-s"]) -def test_status_ahead_of_upstream(commit_env_config, git2cpp_path, tmp_path, short_flag): +@pytest.mark.parametrize("branch_flag", ["", "-b"]) +def test_status_ahead_of_upstream( + commit_env_config, git2cpp_path, tmp_path, short_flag, branch_flag +): """Test status when local branch is ahead of upstream""" # Create a repository with remote tracking repo_path = tmp_path / "repo" @@ -287,12 +290,17 @@ def test_status_ahead_of_upstream(commit_env_config, git2cpp_path, tmp_path, sho cmd_status = [git2cpp_path, "status"] if short_flag == "-s": cmd_status.append(short_flag) + if branch_flag == "-b": + cmd_status.append(branch_flag) p = subprocess.run(cmd_status, capture_output=True, cwd=clone_path, text=True) assert p.returncode == 0 if short_flag == "-s": - assert "...origin/main" in p.stdout - assert "[ahead 1]" in p.stdout + if branch_flag == "-b": + assert "...origin/main" in p.stdout + assert "[ahead 1]" in p.stdout + else: + assert "main" not in p.stdout else: assert "Your branch is ahead of 'origin/main'" in p.stdout assert "by 1 commit" in p.stdout @@ -372,3 +380,40 @@ def test_status_all_headers_shown(repo_init_with_commit, git2cpp_path, tmp_path) assert 'use "git add ..." to update what will be committed' in p.stdout assert "Untracked files:" in p.stdout assert 'use "git add ..." to include in what will be committed' in p.stdout + + +def test_status_modified_staged_then_modified(repo_init_with_commit, git2cpp_path, tmp_path): + """ + File is modified, staged, then modified again. + Short status should show 'MM filename' + Long status should show the file in both 'Changes to be committed' and 'Changes not staged for commit' + """ + # initial.txt present from fixture + f = tmp_path / "initial.txt" + assert f.exists() + + # Modify and stage + f.write_text("first change") + subprocess.run([git2cpp_path, "add", "initial.txt"], cwd=tmp_path, check=True) + + # Modify again (unstaged) + f.write_text("second change") + + # Short mode + p_short = subprocess.run( + [git2cpp_path, "status", "-s"], capture_output=True, cwd=tmp_path, text=True, check=True + ) + assert p_short.returncode == 0 + # Ensure exact two-letter code for index+worktree is present + assert "MM initial.txt" in strip_ansi_colours(p_short.stdout) + + # Long mode + p_long = subprocess.run( + [git2cpp_path, "status", "--long"], capture_output=True, cwd=tmp_path, text=True, check=True + ) + assert p_long.returncode == 0 + out = strip_ansi_colours(p_long.stdout) + assert "Changes to be committed" in out + assert "Changes not staged for commit" in out + # Ensure the filename appears (it should appear in one or both sections) + assert "initial.txt" in out