Skip to content
Open
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
261 changes: 221 additions & 40 deletions src/subcommand/checkout_subcommand.cpp
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
#include "../subcommand/checkout_subcommand.hpp"

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

#include <git2/oid.h>

#include "../subcommand/status_subcommand.hpp"
#include "../utils/git_exception.hpp"
Expand All @@ -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("<branch>", m_branch_name, "Branch to checkout");
// "-- file" lands in m_positional_args because CLI11 consumes "--" silently.
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 @@ -51,6 +56,56 @@ 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::checkout_paths(
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 All @@ -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<std::string> 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<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 '" << m_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 @@ -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<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;
}
}
17 changes: 15 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,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<std::string>& files,
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;
bool m_force_checkout_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
Loading