From dd53ecc2be40d0c6345ba6503f4555172be4e20e Mon Sep 17 00:00:00 2001 From: Johan Mabille Date: Fri, 6 Feb 2026 09:43:44 +0100 Subject: [PATCH 1/2] Implemented mv subcommand --- CMakeLists.txt | 2 + src/main.cpp | 2 + src/subcommand/mv_subcommand.cpp | 45 ++++++ src/subcommand/mv_subcommand.hpp | 21 +++ src/wrapper/index_wrapper.cpp | 10 ++ src/wrapper/index_wrapper.hpp | 3 + test/test_mv.py | 247 +++++++++++++++++++++++++++++++ 7 files changed, 330 insertions(+) create mode 100644 src/subcommand/mv_subcommand.cpp create mode 100644 src/subcommand/mv_subcommand.hpp create mode 100644 test/test_mv.py diff --git a/CMakeLists.txt b/CMakeLists.txt index 3212e5a..ad29fd2 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -60,6 +60,8 @@ set(GIT2CPP_SRC ${GIT2CPP_SOURCE_DIR}/subcommand/log_subcommand.hpp ${GIT2CPP_SOURCE_DIR}/subcommand/merge_subcommand.cpp ${GIT2CPP_SOURCE_DIR}/subcommand/merge_subcommand.hpp + ${GIT2CPP_SOURCE_DIR}/subcommand/mv_subcommand.cpp + ${GIT2CPP_SOURCE_DIR}/subcommand/mv_subcommand.hpp ${GIT2CPP_SOURCE_DIR}/subcommand/push_subcommand.cpp ${GIT2CPP_SOURCE_DIR}/subcommand/push_subcommand.hpp ${GIT2CPP_SOURCE_DIR}/subcommand/rebase_subcommand.cpp diff --git a/src/main.cpp b/src/main.cpp index 6aaec34..529002d 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -15,6 +15,7 @@ #include "subcommand/init_subcommand.hpp" #include "subcommand/log_subcommand.hpp" #include "subcommand/merge_subcommand.hpp" +#include "subcommand/mv_subcommand.hpp" #include "subcommand/push_subcommand.hpp" #include "subcommand/rebase_subcommand.hpp" #include "subcommand/remote_subcommand.hpp" @@ -48,6 +49,7 @@ int main(int argc, char** argv) reset_subcommand reset(lg2_obj, app); log_subcommand log(lg2_obj, app); merge_subcommand merge(lg2_obj, app); + mv_subcommand mv(lg2_obj, app); push_subcommand push(lg2_obj, app); rebase_subcommand rebase(lg2_obj, app); remote_subcommand remote(lg2_obj, app); diff --git a/src/subcommand/mv_subcommand.cpp b/src/subcommand/mv_subcommand.cpp new file mode 100644 index 0000000..adff792 --- /dev/null +++ b/src/subcommand/mv_subcommand.cpp @@ -0,0 +1,45 @@ +#include +#include +#include "mv_subcommand.hpp" + +#include "../utils/git_exception.hpp" +#include "../wrapper/index_wrapper.hpp" +#include "../wrapper/repository_wrapper.hpp" + +namespace fs = std::filesystem; + +mv_subcommand::mv_subcommand(const libgit2_object&, CLI::App& app) +{ + auto* sub = app.add_subcommand("mv" , "Move or rename a file, a directory, or a symlink"); + sub->add_option("", m_source_path, "The path of the source to move")->required()->check(CLI::ExistingFile); + sub->add_option("", m_destination_path, "The path of the destination")->required(); + sub->add_flag("-f,--force", m_force, "Force renaming or moving of a file even if the exists."); + + sub->callback([this]() { this->run(); }); +} + +void mv_subcommand::run() +{ + auto directory = get_current_git_path(); + auto repo = repository_wrapper::open(directory); + + bool exists = fs::exists(m_destination_path) && !fs::is_directory(m_destination_path); + if (exists && !m_force) + { + // TODO: replace magic number with enum when diff command is merged + throw git_exception("destination already exists", 128); + } + + std::error_code ec; + fs::rename(m_source_path, m_destination_path, ec); + + if(ec) + { + throw git_exception("Could not move file", ec.value()); + } + + auto index = repo.make_index(); + index.remove_entry(m_source_path); + index.add_entry(m_destination_path); + index.write(); +} diff --git a/src/subcommand/mv_subcommand.hpp b/src/subcommand/mv_subcommand.hpp new file mode 100644 index 0000000..dc74032 --- /dev/null +++ b/src/subcommand/mv_subcommand.hpp @@ -0,0 +1,21 @@ +#pragma once + +#include +#include + +#include "../utils/common.hpp" + +class mv_subcommand +{ +public: + + explicit mv_subcommand(const libgit2_object&, CLI::App& app); + void run(); + +private: + + std::string m_source_path; + std::string m_destination_path; + bool m_force = false; +}; + diff --git a/src/wrapper/index_wrapper.cpp b/src/wrapper/index_wrapper.cpp index 7ff0ce2..4ba0b15 100644 --- a/src/wrapper/index_wrapper.cpp +++ b/src/wrapper/index_wrapper.cpp @@ -21,6 +21,11 @@ index_wrapper index_wrapper::init(repository_wrapper& rw) return index; } +void index_wrapper::add_entry(const std::string& path) +{ + throw_if_error(git_index_add_bypath(*this, path.c_str())); +} + void index_wrapper::add_entries(std::vector patterns) { add_impl(std::move(patterns)); @@ -37,6 +42,11 @@ void index_wrapper::add_impl(std::vector patterns) throw_if_error(git_index_add_all(*this, array, 0, NULL, NULL)); } +void index_wrapper::remove_entry(const std::string& path) +{ + throw_if_error(git_index_remove_bypath(*this, path.c_str())); +} + 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 0fa8b55..9091242 100644 --- a/src/wrapper/index_wrapper.hpp +++ b/src/wrapper/index_wrapper.hpp @@ -23,9 +23,12 @@ class index_wrapper : public wrapper_base void write(); git_oid write_tree(); + void add_entry(const std::string& path); void add_entries(std::vector patterns); void add_all(); + void remove_entry(const std::string& path); + bool has_conflict() const; void output_conflicts(); void conflict_cleanup(); diff --git a/test/test_mv.py b/test/test_mv.py new file mode 100644 index 0000000..4d2f863 --- /dev/null +++ b/test/test_mv.py @@ -0,0 +1,247 @@ +import subprocess + +import pytest + + +def test_mv_basic(xtl_clone, git2cpp_path, tmp_path): + """Test basic mv operation to rename 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 the file to git + 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 + + # Move/rename the file + mv_cmd = [git2cpp_path, "mv", "test_file.txt", "renamed_file.txt"] + p_mv = subprocess.run(mv_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_mv.returncode == 0 + + # Verify the file was moved + assert not test_file.exists() + assert (xtl_path / "renamed_file.txt").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 "renamed:" in p_status.stdout or "renamed_file.txt" in p_status.stdout + + +def test_mv_to_subdirectory(xtl_clone, git2cpp_path, tmp_path): + """Test moving a file to a subdirectory""" + assert (tmp_path / "xtl").exists() + xtl_path = tmp_path / "xtl" + + # Create a test file + test_file = xtl_path / "move_me.txt" + test_file.write_text("content to move") + + # Add the file to git + add_cmd = [git2cpp_path, "add", "move_me.txt"] + p_add = subprocess.run(add_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_add.returncode == 0 + + # Move the file to existing subdirectory + mv_cmd = [git2cpp_path, "mv", "move_me.txt", "include/move_me.txt"] + p_mv = subprocess.run(mv_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_mv.returncode == 0 + + # Verify the file was moved + assert not test_file.exists() + assert (xtl_path / "include" / "move_me.txt").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 + + +def test_mv_destination_exists_without_force(xtl_clone, git2cpp_path, tmp_path): + """Test that mv fails when destination exists without --force flag""" + assert (tmp_path / "xtl").exists() + xtl_path = tmp_path / "xtl" + + # Create source file + source_file = xtl_path / "source.txt" + source_file.write_text("source content") + + # Create destination file + dest_file = xtl_path / "destination.txt" + dest_file.write_text("destination content") + + # Add both files to git + add_cmd = [git2cpp_path, "add", "source.txt", "destination.txt"] + p_add = subprocess.run(add_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_add.returncode == 0 + + # Try to move without force - should fail + mv_cmd = [git2cpp_path, "mv", "source.txt", "destination.txt"] + p_mv = subprocess.run(mv_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_mv.returncode != 0 + assert "destination already exists" in p_mv.stderr + + # Verify source file still exists + assert source_file.exists() + assert dest_file.exists() + + +@pytest.mark.parametrize("force_flag", ["-f", "--force"]) +def test_mv_destination_exists_with_force(xtl_clone, git2cpp_path, tmp_path, force_flag): + """Test that mv succeeds when destination exists with --force flag""" + assert (tmp_path / "xtl").exists() + xtl_path = tmp_path / "xtl" + + # Create source file + source_file = xtl_path / "source.txt" + source_file.write_text("source content") + + # Create destination file + dest_file = xtl_path / "destination.txt" + dest_file.write_text("destination content") + + # Add both files to git + add_cmd = [git2cpp_path, "add", "source.txt", "destination.txt"] + p_add = subprocess.run(add_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_add.returncode == 0 + + # Move with force - should succeed + mv_cmd = [git2cpp_path, "mv", force_flag, "source.txt", "destination.txt"] + p_mv = subprocess.run(mv_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_mv.returncode == 0 + + # Verify source file was moved + assert not source_file.exists() + assert dest_file.exists() + assert dest_file.read_text() == "source content" + + +def test_mv_nonexistent_source(xtl_clone, git2cpp_path, tmp_path): + """Test that mv fails when source file doesn't exist""" + assert (tmp_path / "xtl").exists() + xtl_path = tmp_path / "xtl" + + # Try to move a file that doesn't exist + mv_cmd = [git2cpp_path, "mv", "nonexistent.txt", "destination.txt"] + p_mv = subprocess.run(mv_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_mv.returncode != 0 + + +def test_mv_multiple_files(xtl_clone, commit_env_config, git2cpp_path, tmp_path): + """Test moving multiple files sequentially""" + 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 files to git + 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 the files + 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 + + # Move first file + mv_cmd1 = [git2cpp_path, "mv", "file1.txt", "renamed1.txt"] + p_mv1 = subprocess.run(mv_cmd1, capture_output=True, cwd=xtl_path, text=True) + assert p_mv1.returncode == 0 + + # Move second file + mv_cmd2 = [git2cpp_path, "mv", "file2.txt", "renamed2.txt"] + p_mv2 = subprocess.run(mv_cmd2, capture_output=True, cwd=xtl_path, text=True) + assert p_mv2.returncode == 0 + + # Verify both files were moved + assert not file1.exists() + assert not file2.exists() + assert (xtl_path / "renamed1.txt").exists() + assert (xtl_path / "renamed2.txt").exists() + + +def test_mv_and_commit(xtl_clone, commit_env_config, git2cpp_path, tmp_path): + """Test moving a file and committing the change""" + assert (tmp_path / "xtl").exists() + xtl_path = tmp_path / "xtl" + + # Create a test file + test_file = xtl_path / "original.txt" + test_file.write_text("original content") + + # Add and commit the file + add_cmd = [git2cpp_path, "add", "original.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 original file"] + p_commit = subprocess.run(commit_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_commit.returncode == 0 + + # Move the file + mv_cmd = [git2cpp_path, "mv", "original.txt", "moved.txt"] + p_mv = subprocess.run(mv_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_mv.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 + + # Commit the move + commit_cmd2 = [git2cpp_path, "commit", "-m", "Move file"] + p_commit2 = subprocess.run(commit_cmd2, capture_output=True, cwd=xtl_path, text=True) + assert p_commit2.returncode == 0 + + # Verify the file is in the new location + assert not (xtl_path / "original.txt").exists() + assert (xtl_path / "moved.txt").exists() + + +def test_mv_nogit(git2cpp_path, tmp_path): + """Test that mv 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 mv without being in a git repo + mv_cmd = [git2cpp_path, "mv", "test.txt", "moved.txt"] + p_mv = subprocess.run(mv_cmd, capture_output=True, cwd=tmp_path, text=True) + assert p_mv.returncode != 0 + + +def test_mv_preserve_content(xtl_clone, git2cpp_path, tmp_path): + """Test that file content is preserved after mv""" + assert (tmp_path / "xtl").exists() + xtl_path = tmp_path / "xtl" + + # Create a test file with specific content + test_content = "This is important content that should be preserved" + test_file = xtl_path / "important.txt" + test_file.write_text(test_content) + + # Add the file to git + add_cmd = [git2cpp_path, "add", "important.txt"] + p_add = subprocess.run(add_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_add.returncode == 0 + + # Move the file + mv_cmd = [git2cpp_path, "mv", "important.txt", "preserved.txt"] + p_mv = subprocess.run(mv_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_mv.returncode == 0 + + # Verify content is preserved + moved_file = xtl_path / "preserved.txt" + assert moved_file.exists() + assert moved_file.read_text() == test_content \ No newline at end of file From b962107ab88a288984b4d11f023fa3f64a115862 Mon Sep 17 00:00:00 2001 From: Johan Mabille Date: Fri, 6 Feb 2026 15:52:08 +0100 Subject: [PATCH 2/2] Applied review comments --- test/conftest_wasm.py | 1 + test/test_mv.py | 7 +++++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/test/conftest_wasm.py b/test/conftest_wasm.py index 23df174..64e33ae 100644 --- a/test/conftest_wasm.py +++ b/test/conftest_wasm.py @@ -24,6 +24,7 @@ def pytest_ignore_collect(collection_path: pathlib.Path) -> bool: "test_init.py", "test_log.py", "test_merge.py", + "test_mv.py", "test_rebase.py", "test_remote.py", "test_reset.py", diff --git a/test/test_mv.py b/test/test_mv.py index 4d2f863..72b71bb 100644 --- a/test/test_mv.py +++ b/test/test_mv.py @@ -30,7 +30,8 @@ def test_mv_basic(xtl_clone, git2cpp_path, tmp_path): 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 "renamed:" in p_status.stdout or "renamed_file.txt" in p_status.stdout + # TODO: uncomment this when the status command is fixed. + #assert "renamed:" in p_status.stdout and "renamed_file.txt" in p_status.stdout def test_mv_to_subdirectory(xtl_clone, git2cpp_path, tmp_path): @@ -60,6 +61,8 @@ def test_mv_to_subdirectory(xtl_clone, git2cpp_path, tmp_path): 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 + # TODO: uncomment this when the status command is fixed. + #assert "renamed:" in p_status.stdout and "move_me.txt" in p_status.stdout def test_mv_destination_exists_without_force(xtl_clone, git2cpp_path, tmp_path): @@ -244,4 +247,4 @@ def test_mv_preserve_content(xtl_clone, git2cpp_path, tmp_path): # Verify content is preserved moved_file = xtl_path / "preserved.txt" assert moved_file.exists() - assert moved_file.read_text() == test_content \ No newline at end of file + assert moved_file.read_text() == test_content