diff --git a/src/subcommand/checkout_subcommand.cpp b/src/subcommand/checkout_subcommand.cpp index cab71f0..8e2846a 100644 --- a/src/subcommand/checkout_subcommand.cpp +++ b/src/subcommand/checkout_subcommand.cpp @@ -1,8 +1,10 @@ #include "../subcommand/checkout_subcommand.hpp" +#include #include #include -#include + +#include #include "../subcommand/status_subcommand.hpp" #include "../utils/git_exception.hpp" @@ -13,7 +15,10 @@ checkout_subcommand::checkout_subcommand(const libgit2_object&, CLI::App& app) { auto* sub = app.add_subcommand("checkout", "Switch branches or restore working tree files"); - sub->add_option("", m_branch_name, "Branch to checkout"); + // "-- file" lands in m_positional_args because CLI11 consumes "--" silently. + sub->add_option("", m_positional_args, "Tree-ish to checkout, and/or one/many pathspec(s)"); + // checkout , checkout , checkout ..., checkout ... + // Use without "--" sub->add_flag("-b", m_create_flag, "Create a new branch before checking it out"); sub->add_flag("-B", m_force_create_flag, "Create a new branch or reset it if it exists before checking it out"); sub->add_flag( @@ -51,6 +56,56 @@ namespace } } +void checkout_subcommand::checkout_files( + const repository_wrapper& repo, + const std::vector& files, + const git_checkout_options& base_options +) +{ + std::vector pathspec_strings; + pathspec_strings.reserve(files.size()); + for (const auto& f : files) + { + pathspec_strings.push_back(f.c_str()); + } + + git_checkout_options options = base_options; + options.paths.strings = const_cast(pathspec_strings.data()); + options.paths.count = pathspec_strings.size(); + + throw_if_error(git_checkout_head(repo, &options)); +} + +void checkout_subcommand::checkout_paths( + const repository_wrapper& repo, + const std::string_view tree_ish, + const std::vector& pathspecs, + const git_checkout_options& base_options +) +{ + auto obj = repo.revparse_single(tree_ish); + if (!obj) + { + throw git_exception( + "error: could not resolve tree-ish '" + std::string(tree_ish) + "'", + git2cpp_error_code::BAD_ARGUMENT + ); + } + + std::vector pathspec_strings; + pathspec_strings.reserve(pathspecs.size()); + for (const auto& p : pathspecs) + { + pathspec_strings.push_back(p.c_str()); + } + + git_checkout_options options = base_options; + options.paths.strings = const_cast(pathspec_strings.data()); + options.paths.count = pathspec_strings.size(); + + throw_if_error(git_checkout_tree(repo, *obj, &options)); +} + void checkout_subcommand::run() { auto directory = get_current_git_path(); @@ -73,57 +128,134 @@ void checkout_subcommand::run() options.checkout_strategy = GIT_CHECKOUT_SAFE; } - if (m_create_flag || m_force_create_flag) + if (m_positional_args.empty()) { - auto annotated_commit = create_local_branch(repo, m_branch_name, m_force_create_flag); - checkout_tree(repo, annotated_commit, m_branch_name, options); - update_head(repo, annotated_commit, m_branch_name); - - std::cout << "Switched to a new branch '" << m_branch_name << "'" << std::endl; + throw std::runtime_error("error: no branch or file specified"); } - else + + const std::string& target_name = m_positional_args[0]; // can be a branch or a tag + const std::vector pathspecs(m_positional_args.begin() + 1, m_positional_args.end()); + + if (m_create_flag || m_force_create_flag) { - auto optional_commit = repo.resolve_local_ref(m_branch_name); - if (!optional_commit) + if (!pathspecs.empty()) { - // TODO: handle remote refs - std::ostringstream buffer; - buffer << "error: could not resolve pathspec '" << m_branch_name << "'" << std::endl; - throw std::runtime_error(buffer.str()); + throw git_exception("error: '-b' or '-B' does not accept pathspecs.", git2cpp_error_code::BAD_ARGUMENT); } - auto sl = status_list_wrapper::status_list(repo); - try + auto annotated_commit = create_local_branch(repo, target_name, m_force_create_flag); + checkout_tree(repo, annotated_commit, target_name, options); + update_head(repo, annotated_commit, target_name); + + std::cout << "Switched to a new branch '" << target_name << "'" << std::endl; + return; + } + + if (!pathspecs.empty()) + { + // Try tree-ish + pathspec(s) + if (auto obj = repo.revparse_single(target_name)) { - checkout_tree(repo, *optional_commit, m_branch_name, options); - update_head(repo, *optional_commit, m_branch_name); + // Validate all pathspecs before checkout so we can mimic git-like errors + for (const auto& p : pathspecs) + { + if (!std::filesystem::exists(std::filesystem::path(directory) / p) && !repo.does_track(p)) + { + throw git_exception( + "error: pathspec '" + p + "' did not match any file(s) known to git", + git2cpp_error_code::BAD_ARGUMENT + ); + } + } + + options.checkout_strategy = GIT_CHECKOUT_FORCE; + checkout_paths(repo, target_name, pathspecs, options); + return; } - catch (const git_exception& e) + + // Else treat as files + for (const auto& p : pathspecs) { - if (sl.has_notstagged_header()) + if (!std::filesystem::exists(std::filesystem::path(directory) / p) && !repo.does_track(p)) { - print_no_switch(sl); + throw git_exception( + "error: pathspec '" + p + "' did not match any file(s) known to git", + git2cpp_error_code::BAD_ARGUMENT + ); } - throw e; } - if (sl.has_notstagged_header()) + std::vector files = m_positional_args; + options.checkout_strategy = GIT_CHECKOUT_FORCE; + checkout_files(repo, files, options); + return; + } + + auto optional_commit = repo.resolve_local_ref(target_name); + if (!optional_commit) + { + // TODO: handle remote refs + + // Fall back to checking out a unique file + const std::vector file = {target_name}; + + if (!std::filesystem::exists(std::filesystem::path(directory) / target_name)) { - bool is_long = false; - bool is_coloured = false; - std::set tracked_dir_set{}; - print_notstagged(sl, tracked_dir_set, is_long, is_coloured); + // Neither a branch/tag nor a file + throw git_exception( + "error: pathspec '" + target_name + "' did not match any file(s) known to git", + git2cpp_error_code::BAD_ARGUMENT + ); } - if (sl.has_tobecommited_header()) + + options.checkout_strategy = GIT_CHECKOUT_FORCE; + checkout_files(repo, file, options); + return; + } + + auto sl = status_list_wrapper::status_list(repo); + try + { + checkout_tree(repo, *optional_commit, target_name, options); + update_head(repo, *optional_commit, target_name); + } + catch (const git_exception& e) + { + if (sl.has_notstagged_header()) { - bool is_long = false; - bool is_coloured = false; - std::set tracked_dir_set{}; - print_tobecommited(sl, tracked_dir_set, is_long, is_coloured); + print_no_switch(sl); } - std::cout << "Switched to branch '" << m_branch_name << "'" << std::endl; + throw e; + } + + if (sl.has_notstagged_header()) + { + bool is_long = false; + bool is_coloured = false; + std::set tracked_dir_set{}; + print_notstagged(sl, tracked_dir_set, is_long, is_coloured); + } + if (sl.has_tobecommited_header()) + { + bool is_long = false; + bool is_coloured = false; + std::set tracked_dir_set{}; + print_tobecommited(sl, tracked_dir_set, is_long, is_coloured); + } + + std::string_view annotated_ref = optional_commit->reference_name(); + if (!annotated_ref.empty() && repo.find_reference(annotated_ref).is_branch()) + { + std::cout << "Switched to branch '" << target_name << "'" << std::endl; print_tracking_info(repo, sl, true, false); } + else + { + std::string sha = optional_commit->commit_oid_tostr().substr(0, 7); + auto commit = repo.find_commit(optional_commit->oid()); + std::string summary = commit.summary(); + std::cout << "HEAD is now at " << sha << " " << summary << std::endl; + } } annotated_commit_wrapper @@ -150,22 +282,71 @@ void checkout_subcommand::update_head( const std::string_view target_name ) { + // Check if HEAD is already detached or not + const bool head_was_detached = [&]() + { + auto head_ref = repo.head(); + return !head_ref.is_branch(); + }(); + + // Save previous HEAD info (if it was detached) before changing it (for output message) + std::optional previous_head_commit; + std::string previous_head_message; + if (head_was_detached) + { + previous_head_commit = repo.find_commit("HEAD"); + previous_head_message = "Previous HEAD position was " + + std::string(previous_head_commit.value().commit_oid_tostr().substr(0, 7)) + + " " + previous_head_commit.value().summary(); + } + std::string_view annotated_ref = target_annotated_commit.reference_name(); if (!annotated_ref.empty()) { auto ref = repo.find_reference(annotated_ref); - if (ref.is_remote()) + if (ref.is_branch()) { - auto branch = repo.create_branch(target_name, target_annotated_commit); - repo.set_head(branch.reference_name()); + if (head_was_detached) + { + std::cout << previous_head_message << std::endl; + } + repo.set_head(annotated_ref); + return; } - else + } + + repo.set_head_detached(target_annotated_commit); + + if (head_was_detached) + { + // Only print "Previous HEAD position..." if HEAD was already detached before and if there is an + // actual checkout + auto new_head_commit = repo.find_commit("HEAD"); + if (!git_oid_equal(&previous_head_commit.value().oid(), &new_head_commit.oid())) { - repo.set_head(annotated_ref); + std::cout << previous_head_message << std::endl; } } else { - repo.set_head_detached(target_annotated_commit); + // Only print the detached-HEAD advice if HEAD was not already detached. + std::cout << "Note: switching to '" << target_name << "'." << std::endl; + std::cout << std::endl; + std::cout << "You are in 'detached HEAD' state. You can look around, make experimental" << std::endl; + std::cout << "changes and commit them, and you can discard any commits you make in this" << std::endl; + std::cout << "state without impacting any branches by switching back to a branch." << std::endl; + std::cout << std::endl; + + // TODO: add to the following when the switch subcommand is implemented: + // std::cout << "If you want to create a new branch to retain commits you create, you may" << + // std::endl; std::cout << "do so (now or later) by using -c with the switch command. Example:" << + // std::endl; std::cout << " git switch -c " << std::endl; std::cout << std::endl; + // std::cout << "Or undo this operation with:" << std::endl; + // std::cout << std::endl; + // std::cout << " git switch -" << std::endl; + // std::cout << std::endl; + // TODO: add the following later + // std::cout << "Turn off this advice by setting config variable advice.detachedHead to false" + // << std::endl; } } diff --git a/src/subcommand/checkout_subcommand.hpp b/src/subcommand/checkout_subcommand.hpp index 99661d4..c646180 100644 --- a/src/subcommand/checkout_subcommand.hpp +++ b/src/subcommand/checkout_subcommand.hpp @@ -1,7 +1,7 @@ #pragma once -#include #include +#include #include @@ -33,7 +33,20 @@ class checkout_subcommand const std::string_view target_name ); - std::string m_branch_name = {}; + void checkout_files( + const repository_wrapper& repo, + const std::vector& files, + const git_checkout_options& options + ); + + void checkout_paths( + const repository_wrapper& repo, + const std::string_view tree_ish, + const std::vector& pathspecs, + const git_checkout_options& options + ); + + std::vector m_positional_args = {}; bool m_create_flag = false; bool m_force_create_flag = false; bool m_force_checkout_flag = false; diff --git a/src/wrapper/annotated_commit_wrapper.cpp b/src/wrapper/annotated_commit_wrapper.cpp index da38620..683636f 100644 --- a/src/wrapper/annotated_commit_wrapper.cpp +++ b/src/wrapper/annotated_commit_wrapper.cpp @@ -16,6 +16,12 @@ const git_oid& annotated_commit_wrapper::oid() const return *git_annotated_commit_id(p_resource); } +std::string annotated_commit_wrapper::commit_oid_tostr() const +{ + char buf[GIT_OID_SHA1_HEXSIZE + 1]; + return git_oid_tostr(buf, sizeof(buf), &this->oid()); +} + std::string_view annotated_commit_wrapper::reference_name() const { const char* res = git_annotated_commit_ref(*this); diff --git a/src/wrapper/annotated_commit_wrapper.hpp b/src/wrapper/annotated_commit_wrapper.hpp index c390e2f..9fcd6b1 100644 --- a/src/wrapper/annotated_commit_wrapper.hpp +++ b/src/wrapper/annotated_commit_wrapper.hpp @@ -1,5 +1,6 @@ #pragma once +#include #include #include @@ -18,6 +19,7 @@ class annotated_commit_wrapper : public wrapper_base annotated_commit_wrapper& operator=(annotated_commit_wrapper&&) noexcept = default; const git_oid& oid() const; + std::string commit_oid_tostr() const; std::string_view reference_name() const; private: diff --git a/src/wrapper/refs_wrapper.cpp b/src/wrapper/refs_wrapper.cpp index 691f07a..a3a1902 100644 --- a/src/wrapper/refs_wrapper.cpp +++ b/src/wrapper/refs_wrapper.cpp @@ -27,6 +27,11 @@ bool reference_wrapper::is_remote() const return git_reference_is_remote(*this); } +bool reference_wrapper::is_branch() const +{ + return git_reference_is_branch(*this); +} + const git_oid* reference_wrapper::target() const { return git_reference_target(p_resource); diff --git a/src/wrapper/refs_wrapper.hpp b/src/wrapper/refs_wrapper.hpp index dddc6b0..d6bc11e 100644 --- a/src/wrapper/refs_wrapper.hpp +++ b/src/wrapper/refs_wrapper.hpp @@ -22,6 +22,7 @@ class reference_wrapper : public wrapper_base std::string short_name() const; bool is_remote() const; + bool is_branch() const; const git_oid* target() const; reference_wrapper write_new_ref(const git_oid target_oid); diff --git a/test/test_checkout.py b/test/test_checkout.py index d789e91..a1d5579 100644 --- a/test/test_checkout.py +++ b/test/test_checkout.py @@ -83,7 +83,9 @@ def test_checkout_invalid_branch(repo_init_with_commit, git2cpp_path, tmp_path): # Should fail with error message assert p_checkout.returncode != 0 - assert "error: could not resolve pathspec 'nonexistent'" in p_checkout.stderr + assert ( + "error: pathspec 'nonexistent' did not match any file(s) known to git" in p_checkout.stderr + ) def test_checkout_with_unstaged_changes(repo_init_with_commit, git2cpp_path, tmp_path): @@ -170,3 +172,221 @@ def test_checkout_refuses_overwrite( branch_cmd = [git2cpp_path, "branch"] p_branch = subprocess.run(branch_cmd, capture_output=True, cwd=tmp_path, text=True) assert "* newbranch" in p_branch.stdout + + +def test_checkout_file_restores_modified_file(repo_init_with_commit, git2cpp_path, tmp_path): + """Test that checkout -- discards working tree changes""" + initial_file = tmp_path / "initial.txt" + original_content = initial_file.read_text() + + # Modify the file (unstaged) + initial_file.write_text("Modified content") + assert initial_file.read_text() == "Modified content" + + # Restore it via checkout -- + checkout_cmd = [git2cpp_path, "checkout", "--", "initial.txt"] + p = subprocess.run(checkout_cmd, capture_output=True, cwd=tmp_path, text=True) + + assert p.returncode == 0 + assert initial_file.read_text() == original_content + + +def test_checkout_file_restores_multiple_files(repo_init_with_commit, git2cpp_path, tmp_path): + """Test that checkout -- restores multiple files at once""" + initial_file = tmp_path / "initial.txt" + + # Create and commit a second file first + second_file = tmp_path / "second.txt" + second_file.write_text("second content") + + add_cmd = [git2cpp_path, "add", "second.txt"] + subprocess.run(add_cmd, cwd=tmp_path, text=True) + commit_cmd = [git2cpp_path, "commit", "-m", "Add second file"] + subprocess.run(commit_cmd, cwd=tmp_path, text=True) + + original_initial = initial_file.read_text() + original_second = second_file.read_text() + + # Modify both files + initial_file.write_text("dirty initial") + second_file.write_text("dirty second") + + checkout_cmd = [git2cpp_path, "checkout", "--", "initial.txt", "second.txt"] + p = subprocess.run(checkout_cmd, capture_output=True, cwd=tmp_path, text=True) + + assert p.returncode == 0 + assert initial_file.read_text() == original_initial + assert second_file.read_text() == original_second + + +def test_checkout_file_does_not_affect_other_files(repo_init_with_commit, git2cpp_path, tmp_path): + """Test that checkout -- only touches the specified file""" + initial_file = tmp_path / "initial.txt" + original_initial = initial_file.read_text() + + # Create and commit a second file + second_file = tmp_path / "second.txt" + second_file.write_text("second content") + + add_cmd = [git2cpp_path, "add", "second.txt"] + subprocess.run(add_cmd, cwd=tmp_path, text=True) + commit_cmd = [git2cpp_path, "commit", "-m", "Add second file"] + subprocess.run(commit_cmd, cwd=tmp_path, text=True) + + # Modify both files + initial_file.write_text("dirty initial") + second_file.write_text("dirty second") + + # Only restore initial.txt + checkout_cmd = [git2cpp_path, "checkout", "--", "initial.txt"] + p = subprocess.run(checkout_cmd, capture_output=True, cwd=tmp_path, text=True) + + assert p.returncode == 0 + assert initial_file.read_text() == original_initial + assert second_file.read_text() == "dirty second" + + +def test_checkout_file_does_not_change_branch(repo_init_with_commit, git2cpp_path, tmp_path): + """Test that checkout -- does not move HEAD or change the current branch""" + initial_file = tmp_path / "initial.txt" + original_initial = initial_file.read_text() + + initial_file.write_text("dirty") + + checkout_cmd = [git2cpp_path, "checkout", "--", "initial.txt"] + p = subprocess.run(checkout_cmd, capture_output=True, cwd=tmp_path, text=True) + assert p.returncode == 0 + assert initial_file.read_text() == original_initial + + branch_cmd = [git2cpp_path, "branch"] + p_branch = subprocess.run(branch_cmd, capture_output=True, cwd=tmp_path, text=True) + assert p_branch.returncode == 0 + assert "* main" in p_branch.stdout + + +def test_checkout_file_nonexistent_path_fails(repo_init_with_commit, git2cpp_path, tmp_path): + """Test that checkout -- fails with a non-zero exit code""" + checkout_cmd = [git2cpp_path, "checkout", "--", "doesnotexist.txt"] + p = subprocess.run(checkout_cmd, capture_output=True, cwd=tmp_path, text=True) + + assert p.returncode != 0 + + +def test_checkout_file_no_paths_fails(repo_init_with_commit, git2cpp_path, tmp_path): + """Test that checkout -- with no file arguments fails""" + checkout_cmd = [git2cpp_path, "checkout", "--"] + p = subprocess.run(checkout_cmd, capture_output=True, cwd=tmp_path, text=True) + + assert p.returncode != 0 + assert "no branch or file specified" in p.stderr + + +def test_checkout_branch_file_restores_modified_file(repo_init_with_commit, git2cpp_path, tmp_path): + """Test that checkout -- restores the file from the branch.""" + initial_file = tmp_path / "initial.txt" + + # Create a new commit on main so the branch switch is meaningful + second_file = tmp_path / "second.txt" + second_file.write_text("second content") + subprocess.run([git2cpp_path, "add", "second.txt"], cwd=tmp_path, text=True, check=True) + subprocess.run( + [git2cpp_path, "commit", "-m", "Add second file"], cwd=tmp_path, text=True, check=True + ) + + # Create and switch to feature branch + subprocess.run([git2cpp_path, "checkout", "-b", "feature"], cwd=tmp_path, text=True, check=True) + + # Modify the file on feature branch and commit it + initial_file.write_text("feature content") + subprocess.run([git2cpp_path, "add", "initial.txt"], cwd=tmp_path, text=True, check=True) + subprocess.run( + [git2cpp_path, "commit", "-m", "Change initial on feature"], + cwd=tmp_path, + text=True, + check=True, + ) + + # Go back to main and dirty the file + subprocess.run([git2cpp_path, "checkout", "main"], cwd=tmp_path, text=True, check=True) + initial_file.write_text("local dirty content") + + # Restore only initial.txt from feature + checkout_cmd = [git2cpp_path, "checkout", "feature", "initial.txt"] + p = subprocess.run(checkout_cmd, capture_output=True, cwd=tmp_path, text=True) + + assert p.returncode == 0 + assert initial_file.read_text() == "feature content" + + branch_cmd = [git2cpp_path, "branch"] + p_branch = subprocess.run(branch_cmd, capture_output=True, cwd=tmp_path, text=True) + assert p_branch.returncode == 0 + assert "* main" in p_branch.stdout + + +def test_checkout_branch_multiple_files_restores_all(repo_init_with_commit, git2cpp_path, tmp_path): + """Test that checkout -- restores multiple files from the branch.""" + initial_file = tmp_path / "initial.txt" + + second_file = tmp_path / "second.txt" + second_file.write_text("second content") + subprocess.run([git2cpp_path, "add", "second.txt"], cwd=tmp_path, text=True, check=True) + subprocess.run( + [git2cpp_path, "commit", "-m", "Add second file"], cwd=tmp_path, text=True, check=True + ) + + # Create feature branch and modify both files there + subprocess.run([git2cpp_path, "checkout", "-b", "feature"], cwd=tmp_path, text=True, check=True) + initial_file.write_text("feature initial") + second_file.write_text("feature second") + subprocess.run( + [git2cpp_path, "add", "initial.txt", "second.txt"], cwd=tmp_path, text=True, check=True + ) + subprocess.run( + [git2cpp_path, "commit", "-m", "Change both files on feature"], + cwd=tmp_path, + text=True, + check=True, + ) + + # Return to main and dirty both files + subprocess.run([git2cpp_path, "checkout", "main"], cwd=tmp_path, text=True, check=True) + initial_file.write_text("dirty main initial") + second_file.write_text("dirty main second") + + # Restore both files from feature + checkout_cmd = [git2cpp_path, "checkout", "feature", "initial.txt", "second.txt"] + p = subprocess.run(checkout_cmd, capture_output=True, cwd=tmp_path, text=True) + + assert p.returncode == 0 + assert initial_file.read_text() == "feature initial" + assert second_file.read_text() == "feature second" + + branch_cmd = [git2cpp_path, "branch"] + p_branch = subprocess.run(branch_cmd, capture_output=True, cwd=tmp_path, text=True) + assert p_branch.returncode == 0 + assert "* main" in p_branch.stdout + + +def test_checkout_tag(repo_init_with_commit, git2cpp_path, tmp_path): + """checkout should detach HEAD at the tag commit.""" + # Create a tag pointing to HEAD + tag_cmd = [git2cpp_path, "tag", "v1.0"] + p_tag = subprocess.run(tag_cmd, capture_output=True, cwd=tmp_path, text=True) + assert p_tag.returncode == 0 + + # Switch to the tag + checkout_cmd = [git2cpp_path, "checkout", "v1.0"] + p_checkout = subprocess.run(checkout_cmd, capture_output=True, cwd=tmp_path, text=True) + assert p_checkout.returncode == 0 + assert "detached HEAD" in p_checkout.stdout + + # Verify we're detached + current_branch_cmd = [git2cpp_path, "branch", "--show-current"] + p_current = subprocess.run(current_branch_cmd, capture_output=True, cwd=tmp_path, text=True) + assert p_current.returncode == 0 + assert p_current.stdout.strip() == "" + + branch_cmd = [git2cpp_path, "branch"] + p_branch = subprocess.run(branch_cmd, capture_output=True, cwd=tmp_path, text=True) + assert p_branch.returncode == 0 + assert "*" not in p_branch.stdout