From fcbd9bea4156215d95880b6ffd008961a29b1be2 Mon Sep 17 00:00:00 2001 From: Sandrine Pataut Date: Tue, 2 Jun 2026 17:33:20 +0200 Subject: [PATCH 1/4] Add checkout file --- src/subcommand/checkout_subcommand.cpp | 74 ++++++++++++++--- src/subcommand/checkout_subcommand.hpp | 10 ++- test/test_checkout.py | 107 +++++++++++++++++++++++++ 3 files changed, 176 insertions(+), 15 deletions(-) diff --git a/src/subcommand/checkout_subcommand.cpp b/src/subcommand/checkout_subcommand.cpp index cab71f0..34f9177 100644 --- a/src/subcommand/checkout_subcommand.cpp +++ b/src/subcommand/checkout_subcommand.cpp @@ -2,7 +2,7 @@ #include #include -#include +#include #include "../subcommand/status_subcommand.hpp" #include "../utils/git_exception.hpp" @@ -13,7 +13,8 @@ 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, "Branch to checkout, or one/many file path(s)"); 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 +52,26 @@ 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::run() { auto directory = get_current_git_path(); @@ -73,30 +94,57 @@ void checkout_subcommand::run() options.checkout_strategy = GIT_CHECKOUT_SAFE; } + if (m_positional_args.empty()) + { + throw std::runtime_error("error: no branch or file specified"); + } + + std::string branch_name = m_positional_args[0]; if (m_create_flag || m_force_create_flag) { - 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); + auto annotated_commit = create_local_branch(repo, branch_name, m_force_create_flag); + checkout_tree(repo, annotated_commit, branch_name, options); + update_head(repo, annotated_commit, branch_name); - std::cout << "Switched to a new branch '" << m_branch_name << "'" << std::endl; + std::cout << "Switched to a new branch '" << branch_name << "'" << std::endl; } else { - auto optional_commit = repo.resolve_local_ref(m_branch_name); + auto optional_commit = repo.resolve_local_ref(branch_name); if (!optional_commit) { // TODO: handle remote refs - std::ostringstream buffer; - buffer << "error: could not resolve pathspec '" << m_branch_name << "'" << std::endl; - throw std::runtime_error(buffer.str()); + + // Fall back to file restore only if at least one path exists on disk. + // If none do, it's an unresolvable branch name — report it as such. + bool any_exists = std::any_of( + m_positional_args.begin(), + m_positional_args.end(), + [&](const std::string& p) + { + return std::filesystem::exists( + std::filesystem::path(directory) / p + ); + } + ); + + if (!any_exists) + { + std::ostringstream buffer; + buffer << "error: could not resolve pathspec '" << branch_name << "'" << std::endl; + throw std::runtime_error(buffer.str()); + } + + options.checkout_strategy = GIT_CHECKOUT_FORCE; + checkout_files(repo, m_positional_args, options); + return; } auto sl = status_list_wrapper::status_list(repo); try { - checkout_tree(repo, *optional_commit, m_branch_name, options); - update_head(repo, *optional_commit, m_branch_name); + checkout_tree(repo, *optional_commit, branch_name, options); + update_head(repo, *optional_commit, branch_name); } catch (const git_exception& e) { @@ -121,7 +169,7 @@ void checkout_subcommand::run() std::set tracked_dir_set{}; print_tobecommited(sl, tracked_dir_set, is_long, is_coloured); } - std::cout << "Switched to branch '" << m_branch_name << "'" << std::endl; + std::cout << "Switched to branch '" << branch_name << "'" << std::endl; print_tracking_info(repo, sl, true, false); } } diff --git a/src/subcommand/checkout_subcommand.hpp b/src/subcommand/checkout_subcommand.hpp index 99661d4..223d69b 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,13 @@ 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 + ); + + 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/test/test_checkout.py b/test/test_checkout.py index d789e91..1c9124b 100644 --- a/test/test_checkout.py +++ b/test/test_checkout.py @@ -170,3 +170,110 @@ 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 From 1136abea90d34aaacde33fe9619377085153eb00 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 2 Jun 2026 15:35:21 +0000 Subject: [PATCH 2/4] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/subcommand/checkout_subcommand.cpp | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/subcommand/checkout_subcommand.cpp b/src/subcommand/checkout_subcommand.cpp index 34f9177..1e1b71f 100644 --- a/src/subcommand/checkout_subcommand.cpp +++ b/src/subcommand/checkout_subcommand.cpp @@ -1,8 +1,8 @@ #include "../subcommand/checkout_subcommand.hpp" +#include #include #include -#include #include "../subcommand/status_subcommand.hpp" #include "../utils/git_exception.hpp" @@ -122,9 +122,7 @@ void checkout_subcommand::run() m_positional_args.end(), [&](const std::string& p) { - return std::filesystem::exists( - std::filesystem::path(directory) / p - ); + return std::filesystem::exists(std::filesystem::path(directory) / p); } ); From a16dc5e2f52a8e39d83944b8654860957589cc22 Mon Sep 17 00:00:00 2001 From: Sandrine Pataut Date: Wed, 10 Jun 2026 18:28:53 +0200 Subject: [PATCH 3/4] Add 'checkout ', fix 'chekout tag', fix output messages --- src/subcommand/checkout_subcommand.cpp | 241 ++++++++++++++++++----- src/subcommand/checkout_subcommand.hpp | 7 + src/wrapper/annotated_commit_wrapper.cpp | 6 + src/wrapper/annotated_commit_wrapper.hpp | 2 + src/wrapper/refs_wrapper.cpp | 5 + src/wrapper/refs_wrapper.hpp | 1 + test/test_checkout.py | 115 ++++++++++- 7 files changed, 323 insertions(+), 54 deletions(-) diff --git a/src/subcommand/checkout_subcommand.cpp b/src/subcommand/checkout_subcommand.cpp index 1e1b71f..599931b 100644 --- a/src/subcommand/checkout_subcommand.cpp +++ b/src/subcommand/checkout_subcommand.cpp @@ -4,6 +4,8 @@ #include #include +#include + #include "../subcommand/status_subcommand.hpp" #include "../utils/git_exception.hpp" #include "../wrapper/repository_wrapper.hpp" @@ -14,7 +16,9 @@ checkout_subcommand::checkout_subcommand(const libgit2_object&, CLI::App& app) auto* sub = app.add_subcommand("checkout", "Switch branches or restore working tree files"); // "-- file" lands in m_positional_args because CLI11 consumes "--" silently. - sub->add_option("", m_positional_args, "Branch to checkout, or one/many file path(s)"); + 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( @@ -72,6 +76,36 @@ void checkout_subcommand::checkout_files( 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(); @@ -99,77 +133,129 @@ void checkout_subcommand::run() throw std::runtime_error("error: no branch or file specified"); } - std::string branch_name = m_positional_args[0]; + 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 annotated_commit = create_local_branch(repo, branch_name, m_force_create_flag); - checkout_tree(repo, annotated_commit, branch_name, options); - update_head(repo, annotated_commit, branch_name); + if (!pathspecs.empty()) + { + throw git_exception("error: '-b' or '-B' does not accept pathspecs.", git2cpp_error_code::BAD_ARGUMENT); + } + + 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 '" << branch_name << "'" << std::endl; + std::cout << "Switched to a new branch '" << target_name << "'" << std::endl; + return; } - else + + if (!pathspecs.empty()) { - auto optional_commit = repo.resolve_local_ref(branch_name); - if (!optional_commit) + // Try tree-ish + pathspec(s) + if (auto obj = repo.revparse_single(target_name)) { - // TODO: handle remote refs - - // Fall back to file restore only if at least one path exists on disk. - // If none do, it's an unresolvable branch name — report it as such. - bool any_exists = std::any_of( - m_positional_args.begin(), - m_positional_args.end(), - [&](const std::string& p) + // 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)) { - return std::filesystem::exists(std::filesystem::path(directory) / p); + throw git_exception( + "error: pathspec '" + p + "' did not match any file(s) known to git", + git2cpp_error_code::BAD_ARGUMENT + ); } - ); - - if (!any_exists) - { - std::ostringstream buffer; - buffer << "error: could not resolve pathspec '" << branch_name << "'" << std::endl; - throw std::runtime_error(buffer.str()); } options.checkout_strategy = GIT_CHECKOUT_FORCE; - checkout_files(repo, m_positional_args, options); + checkout_paths(repo, target_name, pathspecs, options); return; } - auto sl = status_list_wrapper::status_list(repo); - try + // Else treat as files + for (const auto& p : pathspecs) { - checkout_tree(repo, *optional_commit, branch_name, options); - update_head(repo, *optional_commit, branch_name); - } - catch (const git_exception& e) - { - 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 '" << 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 @@ -196,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 223d69b..c646180 100644 --- a/src/subcommand/checkout_subcommand.hpp +++ b/src/subcommand/checkout_subcommand.hpp @@ -39,6 +39,13 @@ class checkout_subcommand 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; 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 1c9124b..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): @@ -277,3 +279,114 @@ def test_checkout_file_no_paths_fails(repo_init_with_commit, git2cpp_path, tmp_p 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 From f5874fc90c6f24ebc8e2d8e0e50015e8664c2196 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 10 Jun 2026 16:43:06 +0000 Subject: [PATCH 4/4] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/subcommand/checkout_subcommand.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/subcommand/checkout_subcommand.cpp b/src/subcommand/checkout_subcommand.cpp index 599931b..8e2846a 100644 --- a/src/subcommand/checkout_subcommand.cpp +++ b/src/subcommand/checkout_subcommand.cpp @@ -296,8 +296,8 @@ void checkout_subcommand::update_head( { 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(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();