Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 59 additions & 13 deletions src/subcommand/checkout_subcommand.cpp
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
#include "../subcommand/checkout_subcommand.hpp"

#include <filesystem>
#include <iostream>
#include <set>
#include <sstream>

#include "../subcommand/status_subcommand.hpp"
#include "../utils/git_exception.hpp"
Expand All @@ -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("<branch>", m_branch_name, "Branch to checkout");
// "-- file" lands in m_positional_args because CLI11 consumes "--" silently.
sub->add_option("<branch|files>", 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(
Expand Down Expand Up @@ -51,6 +52,26 @@ namespace
}
}

void checkout_subcommand::checkout_files(
const repository_wrapper& repo,
const std::vector<std::string>& files,
const git_checkout_options& base_options
)
{
std::vector<const char*> 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<char**>(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();
Expand All @@ -73,30 +94,55 @@ 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)
{
Expand All @@ -121,7 +167,7 @@ void checkout_subcommand::run()
std::set<std::string> 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);
}
}
Expand Down
10 changes: 8 additions & 2 deletions src/subcommand/checkout_subcommand.hpp
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
#pragma once

#include <optional>
#include <string>
#include <vector>

#include <CLI/CLI.hpp>

Expand Down Expand Up @@ -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<std::string>& files,
const git_checkout_options& options
);

std::vector<std::string> m_positional_args = {};
bool m_create_flag = false;
bool m_force_create_flag = false;
bool m_force_checkout_flag = false;
Expand Down
107 changes: 107 additions & 0 deletions test/test_checkout.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 -- <file> 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 -- <file>
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 -- <file1> <file2> 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 -- <file> 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 -- <file> 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 -- <nonexistent> 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