Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Prev Previous commit
Next Next commit
Add 'checkout <tree-ish> <pathspecs>', fix 'chekout tag', fix output …
…messages
  • Loading branch information
SandrineP committed Jun 10, 2026
commit a16dc5e2f52a8e39d83944b8654860957589cc22
241 changes: 188 additions & 53 deletions src/subcommand/checkout_subcommand.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
#include <iostream>
#include <set>

#include <git2/oid.h>

#include "../subcommand/status_subcommand.hpp"
#include "../utils/git_exception.hpp"
#include "../wrapper/repository_wrapper.hpp"
Expand All @@ -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("<branch|files>", m_positional_args, "Branch to checkout, or one/many file path(s)");
sub->add_option("<tree-ish|pathspec>", m_positional_args, "Tree-ish to checkout, and/or one/many pathspec(s)");
// checkout <branch>, checkout <tag>, checkout <file> ..., checkout <branch> <file> ...
// 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(
Expand Down Expand Up @@ -72,6 +76,36 @@ void checkout_subcommand::checkout_files(
throw_if_error(git_checkout_head(repo, &options));
}

void checkout_subcommand::checkout_paths(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could be renamed checkout_tree_files or checkout_ref_files

const repository_wrapper& repo,
const std::string_view tree_ish,
const std::vector<std::string>& 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<const char*> 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<char**>(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();
Expand Down Expand Up @@ -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<std::string> 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());
}
Comment thread
JohanMabille marked this conversation as resolved.

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<std::string> 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<std::string> file = {target_name};

if (!std::filesystem::exists(std::filesystem::path(directory) / target_name))
{
bool is_long = false;
bool is_coloured = false;
std::set<std::string> 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<std::string> 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<std::string> 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<std::string> 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
Expand All @@ -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<commit_wrapper> 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 <new-branch-name>" << 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;
}
}
7 changes: 7 additions & 0 deletions src/subcommand/checkout_subcommand.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<std::string>& pathspecs,
const git_checkout_options& options
);

std::vector<std::string> m_positional_args = {};
bool m_create_flag = false;
bool m_force_create_flag = false;
Expand Down
6 changes: 6 additions & 0 deletions src/wrapper/annotated_commit_wrapper.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
2 changes: 2 additions & 0 deletions src/wrapper/annotated_commit_wrapper.hpp
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
#pragma once

#include <string>
#include <string_view>

#include <git2.h>
Expand All @@ -18,6 +19,7 @@ class annotated_commit_wrapper : public wrapper_base<git_annotated_commit>
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:
Expand Down
5 changes: 5 additions & 0 deletions src/wrapper/refs_wrapper.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions src/wrapper/refs_wrapper.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ class reference_wrapper : public wrapper_base<git_reference>

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);

Expand Down
Loading