diff --git a/CMakeLists.txt b/CMakeLists.txt index 8bf8207..cdcecda 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -76,6 +76,8 @@ set(GIT2CPP_SRC ${GIT2CPP_SOURCE_DIR}/subcommand/revlist_subcommand.hpp ${GIT2CPP_SOURCE_DIR}/subcommand/revparse_subcommand.cpp ${GIT2CPP_SOURCE_DIR}/subcommand/revparse_subcommand.hpp + ${GIT2CPP_SOURCE_DIR}/subcommand/rm_subcommand.cpp + ${GIT2CPP_SOURCE_DIR}/subcommand/rm_subcommand.hpp ${GIT2CPP_SOURCE_DIR}/subcommand/stash_subcommand.cpp ${GIT2CPP_SOURCE_DIR}/subcommand/stash_subcommand.hpp ${GIT2CPP_SOURCE_DIR}/subcommand/status_subcommand.cpp diff --git a/src/main.cpp b/src/main.cpp index 029e694..efb385c 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -25,6 +25,7 @@ #include "subcommand/status_subcommand.hpp" #include "subcommand/revparse_subcommand.hpp" #include "subcommand/revlist_subcommand.hpp" +#include "subcommand/rm_subcommand.hpp" int main(int argc, char** argv) { @@ -55,8 +56,9 @@ int main(int argc, char** argv) push_subcommand push(lg2_obj, app); rebase_subcommand rebase(lg2_obj, app); remote_subcommand remote(lg2_obj, app); - revparse_subcommand revparse(lg2_obj, app); revlist_subcommand revlist(lg2_obj, app); + revparse_subcommand revparse(lg2_obj, app); + rm_subcommand rm(lg2_obj, app); stash_subcommand stash(lg2_obj, app); app.require_subcommand(/* min */ 0, /* max */ 1); diff --git a/src/subcommand/rm_subcommand.cpp b/src/subcommand/rm_subcommand.cpp new file mode 100644 index 0000000..5310c21 --- /dev/null +++ b/src/subcommand/rm_subcommand.cpp @@ -0,0 +1,64 @@ +#include +#include +#include "rm_subcommand.hpp" +#include "../utils/common.hpp" +#include "../utils/git_exception.hpp" +#include "../wrapper/index_wrapper.hpp" +#include "../wrapper/repository_wrapper.hpp" + +namespace fs = std::filesystem; + +rm_subcommand::rm_subcommand(const libgit2_object&, CLI::App& app) +{ + auto* rm = app.add_subcommand("rm", "Remove files from the working tree and from the index"); + rm->add_option("", m_pathspec, "Files to remove"); + rm->add_flag("-r", m_recursive, "Allow recursive removal when a leading directory name is given"); + + rm->callback([this]() { this->run(); }); +} + +void rm_subcommand::run() +{ + auto directory = get_current_git_path(); + auto repo = repository_wrapper::open(directory); + + index_wrapper index = repo.make_index(); + + std::vector files; + std::vector directories; + + std::ranges::for_each(m_pathspec, [&](const std::string& path) + { + if (!fs::exists(path)) + { + std::string msg = "fatal: pathspec '" + path + "' did not math any file"; + throw git_exception(msg, 128); + } + if (fs::is_directory(path)) + { + directories.push_back(path); + } + else + { + if (!repo.does_track(path)) + { + std::string msg = "fatal: pathsspec '" + path + "'is not tracked"; + throw git_exception(msg, 128); + } + files.push_back(path); + } + }); + + if (!directories.empty() && !m_recursive) + { + std::string msg = "fatal: not removing '" + directories.front() + "' recursively without -r"; + throw git_exception(msg, 128); + } + + index.remove_entries(files); + index.remove_directories(directories); + index.write(); + + std::ranges::for_each(files, [](const std::string& path) { fs::remove(path); }); + std::ranges::for_each(directories, [](const std::string& path) { fs::remove_all(path); }); +} diff --git a/src/subcommand/rm_subcommand.hpp b/src/subcommand/rm_subcommand.hpp new file mode 100644 index 0000000..1f575fc --- /dev/null +++ b/src/subcommand/rm_subcommand.hpp @@ -0,0 +1,21 @@ +#pragma once + +#include +#include +#include + +#include "../utils/common.hpp" + +class rm_subcommand +{ +public: + + explicit rm_subcommand(const libgit2_object&, CLI::App& app); + void run(); + +private: + + std::vector m_pathspec; + bool m_recursive = false; +}; + diff --git a/src/wrapper/index_wrapper.cpp b/src/wrapper/index_wrapper.cpp index 4ba0b15..701aab1 100644 --- a/src/wrapper/index_wrapper.cpp +++ b/src/wrapper/index_wrapper.cpp @@ -1,4 +1,5 @@ #include +#include #include #include @@ -47,6 +48,20 @@ void index_wrapper::remove_entry(const std::string& path) throw_if_error(git_index_remove_bypath(*this, path.c_str())); } +void index_wrapper::remove_entries(std::vector paths) +{ + git_strarray_wrapper array{paths}; + throw_if_error(git_index_remove_all(*this, array, NULL, NULL)); +} + +void index_wrapper::remove_directories(std::vector entries) +{ + std::for_each(entries.cbegin(), entries.cend(), [this](const std::string& path) + { + throw_if_error(git_index_remove_directory(*this, path.c_str(), 0)); + }); +} + void index_wrapper::write() { throw_if_error(git_index_write(*this)); diff --git a/src/wrapper/index_wrapper.hpp b/src/wrapper/index_wrapper.hpp index 9091242..9a973cc 100644 --- a/src/wrapper/index_wrapper.hpp +++ b/src/wrapper/index_wrapper.hpp @@ -28,6 +28,8 @@ class index_wrapper : public wrapper_base void add_all(); void remove_entry(const std::string& path); + void remove_entries(std::vector paths); + void remove_directories(std::vector paths); bool has_conflict() const; void output_conflicts(); diff --git a/src/wrapper/repository_wrapper.cpp b/src/wrapper/repository_wrapper.cpp index 31b7177..a7fcf2b 100644 --- a/src/wrapper/repository_wrapper.cpp +++ b/src/wrapper/repository_wrapper.cpp @@ -70,6 +70,13 @@ revwalk_wrapper repository_wrapper::new_walker() return revwalk_wrapper(walker); } +bool repository_wrapper::does_track(std::string_view path) const +{ + unsigned int flags; + throw_if_error(git_status_file(&flags, *this, path.data())); + return !(flags & GIT_STATUS_WT_NEW) && !(flags & GIT_STATUS_IGNORED); +} + // Head bool repository_wrapper::is_head_unborn() const diff --git a/src/wrapper/repository_wrapper.hpp b/src/wrapper/repository_wrapper.hpp index b3e8dbc..991bdc5 100644 --- a/src/wrapper/repository_wrapper.hpp +++ b/src/wrapper/repository_wrapper.hpp @@ -43,6 +43,8 @@ class repository_wrapper : public wrapper_base revwalk_wrapper new_walker(); + bool does_track(std::string_view path) const; + // Head bool is_head_unborn() const; reference_wrapper head() const; diff --git a/src/wrapper/status_wrapper.cpp b/src/wrapper/status_wrapper.cpp index a6fe876..b45395e 100644 --- a/src/wrapper/status_wrapper.cpp +++ b/src/wrapper/status_wrapper.cpp @@ -84,16 +84,3 @@ auto status_list_wrapper::get_entry_list(git_status_t status) const -> const sta } } - - -// std::ostream& operator<<(std::ostream& out, const status_list_wrapper& slw) -// { -// std::size_t status_list_size = git_status_list_entrycount(slw); -// for (std::size_t i = 0; i < status_list_size; ++i) -// { -// std::cout << i << " "; -// auto entry = git_status_byindex(slw, i); - -// } -// return out; -// }; diff --git a/test/conftest_wasm.py b/test/conftest_wasm.py index 64e33ae..d6f2b1e 100644 --- a/test/conftest_wasm.py +++ b/test/conftest_wasm.py @@ -30,6 +30,7 @@ def pytest_ignore_collect(collection_path: pathlib.Path) -> bool: "test_reset.py", "test_revlist.py", "test_revparse.py", + "test_rm.py", "test_stash.py", "test_status.py", ] diff --git a/test/test_rm.py b/test/test_rm.py new file mode 100644 index 0000000..08ae2a1 --- /dev/null +++ b/test/test_rm.py @@ -0,0 +1,359 @@ +import subprocess + +import pytest + + +def test_rm_basic_file(xtl_clone, commit_env_config, git2cpp_path, tmp_path): + """Test basic rm operation to remove a file""" + assert (tmp_path / "xtl").exists() + xtl_path = tmp_path / "xtl" + + # Create a test file + test_file = xtl_path / "test_file.txt" + test_file.write_text("test content") + + # Add and commit the file + add_cmd = [git2cpp_path, "add", "test_file.txt"] + p_add = subprocess.run(add_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_add.returncode == 0 + + commit_cmd = [git2cpp_path, "commit", "-m", "Add test file"] + p_commit = subprocess.run(commit_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_commit.returncode == 0 + + # Remove the file + rm_cmd = [git2cpp_path, "rm", "test_file.txt"] + p_rm = subprocess.run(rm_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_rm.returncode == 0 + + # Verify the file was removed from working tree + assert not test_file.exists() + + # Check git status + status_cmd = [git2cpp_path, "status", "--long"] + p_status = subprocess.run(status_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_status.returncode == 0 + assert "Changes to be committed" in p_status.stdout + assert "deleted" in p_status.stdout + + +def test_rm_multiple_files(xtl_clone, commit_env_config, git2cpp_path, tmp_path): + """Test removing multiple files at once""" + assert (tmp_path / "xtl").exists() + xtl_path = tmp_path / "xtl" + + # Create test files + file1 = xtl_path / "file1.txt" + file1.write_text("content 1") + file2 = xtl_path / "file2.txt" + file2.write_text("content 2") + + # Add and commit files + add_cmd = [git2cpp_path, "add", "file1.txt", "file2.txt"] + p_add = subprocess.run(add_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_add.returncode == 0 + + commit_cmd = [git2cpp_path, "commit", "-m", "Add test files"] + p_commit = subprocess.run(commit_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_commit.returncode == 0 + + # Remove both files + rm_cmd = [git2cpp_path, "rm", "file1.txt", "file2.txt"] + p_rm = subprocess.run(rm_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_rm.returncode == 0 + + # Verify both files were removed + assert not file1.exists() + assert not file2.exists() + + # Check git status + status_cmd = [git2cpp_path, "status", "--long"] + p_status = subprocess.run(status_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_status.returncode == 0 + assert "Changes to be committed" in p_status.stdout + assert "deleted" in p_status.stdout + + +def test_rm_directory_without_recursive_flag(xtl_clone, commit_env_config, git2cpp_path, tmp_path): + """Test that rm fails when trying to remove a directory without -r flag""" + assert (tmp_path / "xtl").exists() + xtl_path = tmp_path / "xtl" + + # Create a directory with a file + test_dir = xtl_path / "test_dir" + test_dir.mkdir() + test_file = test_dir / "file.txt" + test_file.write_text("content") + + # Add and commit the file + add_cmd = [git2cpp_path, "add", "test_dir/file.txt"] + p_add = subprocess.run(add_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_add.returncode == 0 + + commit_cmd = [git2cpp_path, "commit", "-m", "Add test directory"] + p_commit = subprocess.run(commit_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_commit.returncode == 0 + + # Try to remove directory without -r flag - should fail + rm_cmd = [git2cpp_path, "rm", "test_dir"] + p_rm = subprocess.run(rm_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_rm.returncode != 0 + assert "not removing" in p_rm.stderr and "recursively without -r" in p_rm.stderr + + # Verify directory still exists + assert test_dir.exists() + assert test_file.exists() + + +def test_rm_directory_with_recursive_flag(xtl_clone, commit_env_config, git2cpp_path, tmp_path): + """Test removing a directory with -r flag""" + assert (tmp_path / "xtl").exists() + xtl_path = tmp_path / "xtl" + + # Create a directory with files + test_dir = xtl_path / "test_dir" + test_dir.mkdir() + file1 = test_dir / "file1.txt" + file1.write_text("content 1") + file2 = test_dir / "file2.txt" + file2.write_text("content 2") + + # Add and commit the files + add_cmd = [git2cpp_path, "add", "test_dir/file1.txt", "test_dir/file2.txt"] + p_add = subprocess.run(add_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_add.returncode == 0 + + commit_cmd = [git2cpp_path, "commit", "-m", "Add test directory"] + p_commit = subprocess.run(commit_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_commit.returncode == 0 + + # Remove directory with -r flag - should succeed + rm_cmd = [git2cpp_path, "rm", "-r", "test_dir"] + p_rm = subprocess.run(rm_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_rm.returncode == 0 + + # Check git status + status_cmd = [git2cpp_path, "status", "--long"] + p_status = subprocess.run(status_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_status.returncode == 0 + assert "Changes to be committed" in p_status.stdout + assert "deleted" in p_status.stdout + + +def test_rm_and_commit(xtl_clone, commit_env_config, git2cpp_path, tmp_path): + """Test removing a file and committing the change""" + assert (tmp_path / "xtl").exists() + xtl_path = tmp_path / "xtl" + + # Create a test file + test_file = xtl_path / "to_remove.txt" + test_file.write_text("content to remove") + + # Add and commit the file + add_cmd = [git2cpp_path, "add", "to_remove.txt"] + p_add = subprocess.run(add_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_add.returncode == 0 + + commit_cmd = [git2cpp_path, "commit", "-m", "Add file to remove"] + p_commit = subprocess.run(commit_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_commit.returncode == 0 + + # Remove the file + rm_cmd = [git2cpp_path, "rm", "to_remove.txt"] + p_rm = subprocess.run(rm_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_rm.returncode == 0 + + # Check status before commit + status_cmd = [git2cpp_path, "status", "--long"] + p_status = subprocess.run(status_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_status.returncode == 0 + assert "Changes to be committed" in p_status.stdout + assert "deleted" in p_status.stdout + + # Commit the removal + commit_cmd2 = [git2cpp_path, "commit", "-m", "Remove file"] + p_commit2 = subprocess.run(commit_cmd2, capture_output=True, cwd=xtl_path, text=True) + assert p_commit2.returncode == 0 + + # Verify the file is gone + assert not test_file.exists() + + # Check status after commit + status_cmd2 = [git2cpp_path, "status", "--long"] + p_status2 = subprocess.run(status_cmd2, capture_output=True, cwd=xtl_path, text=True) + assert p_status2.returncode == 0 + assert "to_remove.txt" not in p_status2.stdout + + +def test_rm_nonexistent_file(xtl_clone, git2cpp_path, tmp_path): + """Test that rm fails when file doesn't exist""" + assert (tmp_path / "xtl").exists() + xtl_path = tmp_path / "xtl" + + # Try to remove a file that doesn't exist + rm_cmd = [git2cpp_path, "rm", "nonexistent.txt"] + p_rm = subprocess.run(rm_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_rm.returncode != 0 + + +def test_rm_untracked_file(xtl_clone, git2cpp_path, tmp_path): + """Test that rm fails when trying to remove an untracked file""" + assert (tmp_path / "xtl").exists() + xtl_path = tmp_path / "xtl" + + # Create an untracked file + untracked_file = xtl_path / "untracked.txt" + untracked_file.write_text("untracked content") + + # Try to remove untracked file - should fail + rm_cmd = [git2cpp_path, "rm", "untracked.txt"] + p_rm = subprocess.run(rm_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_rm.returncode != 0 + + +def test_rm_staged_file(xtl_clone, git2cpp_path, tmp_path): + """Test removing a file that was added but not yet committed""" + assert (tmp_path / "xtl").exists() + xtl_path = tmp_path / "xtl" + + # Create a test file + test_file = xtl_path / "staged.txt" + test_file.write_text("staged content") + + # Add the file (but don't commit) + add_cmd = [git2cpp_path, "add", "staged.txt"] + p_add = subprocess.run(add_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_add.returncode == 0 + + # Remove the file + rm_cmd = [git2cpp_path, "rm", "staged.txt"] + p_rm = subprocess.run(rm_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_rm.returncode == 0 + + # Verify the file was removed + assert not test_file.exists() + + # Check git status - should show nothing staged + status_cmd = [git2cpp_path, "status", "--long"] + p_status = subprocess.run(status_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_status.returncode == 0 + + +def test_rm_file_in_subdirectory(xtl_clone, commit_env_config, git2cpp_path, tmp_path): + """Test removing a file in a subdirectory""" + assert (tmp_path / "xtl").exists() + xtl_path = tmp_path / "xtl" + + # Use existing subdirectory + test_file = xtl_path / "include" / "test.txt" + test_file.write_text("test content") + + # Add and commit the file + add_cmd = [git2cpp_path, "add", "include/test.txt"] + p_add = subprocess.run(add_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_add.returncode == 0 + + commit_cmd = [git2cpp_path, "commit", "-m", "Add file in subdirectory"] + p_commit = subprocess.run(commit_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_commit.returncode == 0 + + # Remove the file + rm_cmd = [git2cpp_path, "rm", "include/test.txt"] + p_rm = subprocess.run(rm_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_rm.returncode == 0 + + # Verify the file was removed + assert not test_file.exists() + + # Check git status + status_cmd = [git2cpp_path, "status", "--long"] + p_status = subprocess.run(status_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_status.returncode == 0 + assert "Changes to be committed" in p_status.stdout + assert "deleted" in p_status.stdout + + +def test_rm_nogit(git2cpp_path, tmp_path): + """Test that rm fails when not in a git repository""" + # Create a test file outside a git repo + test_file = tmp_path / "test.txt" + test_file.write_text("test content") + + # Try to rm without being in a git repo + rm_cmd = [git2cpp_path, "rm", "test.txt"] + p_rm = subprocess.run(rm_cmd, capture_output=True, cwd=tmp_path, text=True) + assert p_rm.returncode != 0 + + +def test_rm_nested_directory_recursive(xtl_clone, commit_env_config, git2cpp_path, tmp_path): + """Test removing a nested directory structure with -r flag""" + assert (tmp_path / "xtl").exists() + xtl_path = tmp_path / "xtl" + + # Create nested directory structure + nested_dir = xtl_path / "level1" / "level2" + nested_dir.mkdir(parents=True) + file1 = xtl_path / "level1" / "file1.txt" + file1.write_text("content 1") + file2 = nested_dir / "file2.txt" + file2.write_text("content 2") + + # Add and commit the files + add_cmd = [git2cpp_path, "add", "level1/file1.txt", "level1/level2/file2.txt"] + p_add = subprocess.run(add_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_add.returncode == 0 + + commit_cmd = [git2cpp_path, "commit", "-m", "Add nested structure"] + p_commit = subprocess.run(commit_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_commit.returncode == 0 + + # Remove the directory tree with -r flag + rm_cmd = [git2cpp_path, "rm", "-r", "level1"] + p_rm = subprocess.run(rm_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_rm.returncode == 0 + + # Check git status + status_cmd = [git2cpp_path, "status", "--long"] + p_status = subprocess.run(status_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_status.returncode == 0 + assert "Changes to be committed" in p_status.stdout + assert "deleted" in p_status.stdout + + +def test_rm_mixed_files_and_directory(xtl_clone, commit_env_config, git2cpp_path, tmp_path): + """Test removing both individual files and directories in one command""" + assert (tmp_path / "xtl").exists() + xtl_path = tmp_path / "xtl" + + # Create a file and a directory with contents + single_file = xtl_path / "single.txt" + single_file.write_text("single file") + + test_dir = xtl_path / "remove_dir" + test_dir.mkdir() + dir_file = test_dir / "file.txt" + dir_file.write_text("file in dir") + + # Add and commit everything + add_cmd = [git2cpp_path, "add", "single.txt", "remove_dir/file.txt"] + p_add = subprocess.run(add_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_add.returncode == 0 + + commit_cmd = [git2cpp_path, "commit", "-m", "Add mixed content"] + p_commit = subprocess.run(commit_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_commit.returncode == 0 + + # Remove both file and directory + rm_cmd = [git2cpp_path, "rm", "-r", "single.txt", "remove_dir"] + p_rm = subprocess.run(rm_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_rm.returncode == 0 + + # Verify everything was removed + assert not single_file.exists() + + # Check git status + status_cmd = [git2cpp_path, "status", "--long"] + p_status = subprocess.run(status_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_status.returncode == 0 + assert "Changes to be committed" in p_status.stdout + assert "deleted" in p_status.stdout \ No newline at end of file