From fcbd9bea4156215d95880b6ffd008961a29b1be2 Mon Sep 17 00:00:00 2001 From: Sandrine Pataut Date: Tue, 2 Jun 2026 17:33:20 +0200 Subject: [PATCH 1/2] 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/2] [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); } );