Skip to content

Commit 0956b04

Browse files
committed
Add 'checkout <tree-ish> <pathspecs>', fix 'chekout tag', fix output messages
1 parent 1136abe commit 0956b04

7 files changed

Lines changed: 307 additions & 54 deletions

File tree

src/subcommand/checkout_subcommand.cpp

Lines changed: 172 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
#include <filesystem>
44
#include <iostream>
55
#include <set>
6+
#include <git2/oid.h>
67

78
#include "../subcommand/status_subcommand.hpp"
89
#include "../utils/git_exception.hpp"
@@ -14,7 +15,9 @@ checkout_subcommand::checkout_subcommand(const libgit2_object&, CLI::App& app)
1415
auto* sub = app.add_subcommand("checkout", "Switch branches or restore working tree files");
1516

1617
// "-- file" lands in m_positional_args because CLI11 consumes "--" silently.
17-
sub->add_option("<branch|files>", m_positional_args, "Branch to checkout, or one/many file path(s)");
18+
sub->add_option("<tree-ish|pathspec>", m_positional_args, "Tree-ish to checkout, and/or one/many pathspec(s)");
19+
// checkout <branch>, checkout <tag>, checkout <file> ..., checkout <branch> <file> ...
20+
// Use without "--"
1821
sub->add_flag("-b", m_create_flag, "Create a new branch before checking it out");
1922
sub->add_flag("-B", m_force_create_flag, "Create a new branch or reset it if it exists before checking it out");
2023
sub->add_flag(
@@ -72,6 +75,33 @@ void checkout_subcommand::checkout_files(
7275
throw_if_error(git_checkout_head(repo, &options));
7376
}
7477

78+
void checkout_subcommand::checkout_paths(
79+
const repository_wrapper& repo,
80+
const std::string_view tree_ish,
81+
const std::vector<std::string>& pathspecs,
82+
const git_checkout_options& base_options
83+
)
84+
{
85+
auto obj = repo.revparse_single(tree_ish);
86+
if (!obj)
87+
{
88+
throw git_exception("error: could not resolve tree-ish '" + std::string(tree_ish) + "'", git2cpp_error_code::BAD_ARGUMENT);
89+
}
90+
91+
std::vector<const char*> pathspec_strings;
92+
pathspec_strings.reserve(pathspecs.size());
93+
for (const auto& p : pathspecs)
94+
{
95+
pathspec_strings.push_back(p.c_str());
96+
}
97+
98+
git_checkout_options options = base_options;
99+
options.paths.strings = const_cast<char**>(pathspec_strings.data());
100+
options.paths.count = pathspec_strings.size();
101+
102+
throw_if_error(git_checkout_tree(repo, *obj, &options));
103+
}
104+
75105
void checkout_subcommand::run()
76106
{
77107
auto directory = get_current_git_path();
@@ -99,77 +129,126 @@ void checkout_subcommand::run()
99129
throw std::runtime_error("error: no branch or file specified");
100130
}
101131

102-
std::string branch_name = m_positional_args[0];
132+
const std::string& target_name = m_positional_args[0]; // can be a branch or a tag
133+
const std::vector<std::string> pathspecs(m_positional_args.begin()+1, m_positional_args.end());
134+
103135
if (m_create_flag || m_force_create_flag)
104136
{
105-
auto annotated_commit = create_local_branch(repo, branch_name, m_force_create_flag);
106-
checkout_tree(repo, annotated_commit, branch_name, options);
107-
update_head(repo, annotated_commit, branch_name);
137+
if (!pathspecs.empty())
138+
{
139+
throw git_exception("error: '-b' or '-B' does not accept pathspecs.", git2cpp_error_code::BAD_ARGUMENT);
140+
}
108141

109-
std::cout << "Switched to a new branch '" << branch_name << "'" << std::endl;
142+
auto annotated_commit = create_local_branch(repo, target_name, m_force_create_flag);
143+
checkout_tree(repo, annotated_commit, target_name, options);
144+
update_head(repo, annotated_commit, target_name);
145+
146+
std::cout << "Switched to a new branch '" << target_name << "'" << std::endl;
147+
return;
110148
}
111-
else
149+
150+
if (!pathspecs.empty())
112151
{
113-
auto optional_commit = repo.resolve_local_ref(branch_name);
114-
if (!optional_commit)
152+
// Try tree-ish + pathspec(s)
153+
if (auto obj = repo.revparse_single(target_name))
115154
{
116-
// TODO: handle remote refs
117-
118-
// Fall back to file restore only if at least one path exists on disk.
119-
// If none do, it's an unresolvable branch name — report it as such.
120-
bool any_exists = std::any_of(
121-
m_positional_args.begin(),
122-
m_positional_args.end(),
123-
[&](const std::string& p)
155+
// Validate all pathspecs before checkout so we can mimic git-like errors
156+
for (const auto& p : pathspecs)
157+
{
158+
if (!std::filesystem::exists(std::filesystem::path(directory) / p) && !repo.does_track(p))
124159
{
125-
return std::filesystem::exists(std::filesystem::path(directory) / p);
160+
throw git_exception(
161+
"error: pathspec '" + p + "' did not match any file(s) known to git",
162+
git2cpp_error_code::BAD_ARGUMENT
163+
);
126164
}
127-
);
128-
129-
if (!any_exists)
130-
{
131-
std::ostringstream buffer;
132-
buffer << "error: could not resolve pathspec '" << branch_name << "'" << std::endl;
133-
throw std::runtime_error(buffer.str());
134165
}
135166

136167
options.checkout_strategy = GIT_CHECKOUT_FORCE;
137-
checkout_files(repo, m_positional_args, options);
168+
checkout_paths(repo, target_name, pathspecs, options);
138169
return;
139170
}
140171

141-
auto sl = status_list_wrapper::status_list(repo);
142-
try
143-
{
144-
checkout_tree(repo, *optional_commit, branch_name, options);
145-
update_head(repo, *optional_commit, branch_name);
146-
}
147-
catch (const git_exception& e)
172+
// Else treat as files
173+
for (const auto& p : pathspecs)
148174
{
149-
if (sl.has_notstagged_header())
175+
if (!std::filesystem::exists(std::filesystem::path(directory) / p) && !repo.does_track(p))
150176
{
151-
print_no_switch(sl);
177+
throw git_exception(
178+
"error: pathspec '" + p + "' did not match any file(s) known to git",
179+
git2cpp_error_code::BAD_ARGUMENT
180+
);
152181
}
153-
throw e;
154182
}
155183

156-
if (sl.has_notstagged_header())
184+
std::vector<std::string> files = m_positional_args;
185+
options.checkout_strategy = GIT_CHECKOUT_FORCE;
186+
checkout_files(repo, files, options);
187+
return;
188+
}
189+
190+
auto optional_commit = repo.resolve_local_ref(target_name);
191+
if (!optional_commit)
192+
{
193+
// TODO: handle remote refs
194+
195+
// Fall back to checking out a unique file
196+
const std::vector<std::string> file = {target_name};
197+
198+
if (!std::filesystem::exists(std::filesystem::path(directory) / target_name))
157199
{
158-
bool is_long = false;
159-
bool is_coloured = false;
160-
std::set<std::string> tracked_dir_set{};
161-
print_notstagged(sl, tracked_dir_set, is_long, is_coloured);
200+
// Neither a branch/tag nor a file
201+
throw git_exception("error: pathspec '" + target_name + "' did not match any file(s) known to git", git2cpp_error_code::BAD_ARGUMENT);
162202
}
163-
if (sl.has_tobecommited_header())
203+
204+
options.checkout_strategy = GIT_CHECKOUT_FORCE;
205+
checkout_files(repo, file, options);
206+
return;
207+
}
208+
209+
auto sl = status_list_wrapper::status_list(repo);
210+
try
211+
{
212+
checkout_tree(repo, *optional_commit, target_name, options);
213+
update_head(repo, *optional_commit, target_name);
214+
}
215+
catch (const git_exception& e)
216+
{
217+
if (sl.has_notstagged_header())
164218
{
165-
bool is_long = false;
166-
bool is_coloured = false;
167-
std::set<std::string> tracked_dir_set{};
168-
print_tobecommited(sl, tracked_dir_set, is_long, is_coloured);
219+
print_no_switch(sl);
169220
}
170-
std::cout << "Switched to branch '" << branch_name << "'" << std::endl;
221+
throw e;
222+
}
223+
224+
if (sl.has_notstagged_header())
225+
{
226+
bool is_long = false;
227+
bool is_coloured = false;
228+
std::set<std::string> tracked_dir_set{};
229+
print_notstagged(sl, tracked_dir_set, is_long, is_coloured);
230+
}
231+
if (sl.has_tobecommited_header())
232+
{
233+
bool is_long = false;
234+
bool is_coloured = false;
235+
std::set<std::string> tracked_dir_set{};
236+
print_tobecommited(sl, tracked_dir_set, is_long, is_coloured);
237+
}
238+
239+
std::string_view annotated_ref = optional_commit->reference_name();
240+
if (!annotated_ref.empty() && repo.find_reference(annotated_ref).is_branch())
241+
{
242+
std::cout << "Switched to branch '" << target_name << "'" << std::endl;
171243
print_tracking_info(repo, sl, true, false);
172244
}
245+
else
246+
{
247+
std::string sha = optional_commit->commit_oid_tostr().substr(0, 7);
248+
auto commit = repo.find_commit(optional_commit->oid());
249+
std::string summary = commit.summary();
250+
std::cout << "HEAD is now at " << sha << " " << summary << std::endl;
251+
}
173252
}
174253

175254
annotated_commit_wrapper
@@ -196,22 +275,62 @@ void checkout_subcommand::update_head(
196275
const std::string_view target_name
197276
)
198277
{
278+
// Check if HEAD is already detached or not
279+
const bool head_was_detached = [&]()
280+
{
281+
auto head_ref = repo.head();
282+
return !head_ref.is_branch();
283+
}();
284+
199285
std::string_view annotated_ref = target_annotated_commit.reference_name();
200286
if (!annotated_ref.empty())
201287
{
202288
auto ref = repo.find_reference(annotated_ref);
203-
if (ref.is_remote())
289+
if (ref.is_branch())
204290
{
205-
auto branch = repo.create_branch(target_name, target_annotated_commit);
206-
repo.set_head(branch.reference_name());
291+
repo.set_head(annotated_ref);
292+
return;
207293
}
208-
else
294+
}
295+
296+
// Save previous HEAD info before changing it (for output message)
297+
auto previous_head_commit = repo.find_commit("HEAD");
298+
299+
repo.set_head_detached(target_annotated_commit);
300+
301+
if (head_was_detached)
302+
{
303+
// Only print "Previous HEAD position..." if HEAD was already detached before and if there is an actual checkout
304+
auto new_head_commit = repo.find_commit("HEAD");
305+
if (!git_oid_equal(&previous_head_commit.oid(), &new_head_commit.oid()))
209306
{
210-
repo.set_head(annotated_ref);
307+
std::string previous_head_message;
308+
previous_head_message = "Previous HEAD position was " + std::string(previous_head_commit.commit_oid_tostr().substr(0,7)) +
309+
" " + previous_head_commit.summary();
310+
std::cout << previous_head_message << std::endl;
211311
}
212312
}
213313
else
214314
{
215-
repo.set_head_detached(target_annotated_commit);
315+
// Only print the detached-HEAD advice if HEAD was not already detached.
316+
std::cout << "Note: switching to '" << target_name << "'." << std::endl;
317+
std::cout << std::endl;
318+
std::cout << "You are in 'detached HEAD' state. You can look around, make experimental" << std::endl;
319+
std::cout << "changes and commit them, and you can discard any commits you make in this" << std::endl;
320+
std::cout << "state without impacting any branches by switching back to a branch." << std::endl;
321+
std::cout << std::endl;
322+
323+
// TODO: add to the following when the switch subcommand is implemented:
324+
// std::cout << "If you want to create a new branch to retain commits you create, you may" << std::endl;
325+
// std::cout << "do so (now or later) by using -c with the switch command. Example:" << std::endl;
326+
// std::cout << " git switch -c <new-branch-name>" << std::endl;
327+
// std::cout << std::endl;
328+
// std::cout << "Or undo this operation with:" << std::endl;
329+
// std::cout << std::endl;
330+
// std::cout << " git switch -" << std::endl;
331+
// std::cout << std::endl;
332+
// TODO: add the following later
333+
// std::cout << "Turn off this advice by setting config variable advice.detachedHead to false"
334+
// << std::endl;
216335
}
217336
}

src/subcommand/checkout_subcommand.hpp

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,13 @@ class checkout_subcommand
3939
const git_checkout_options& options
4040
);
4141

42+
void checkout_paths(
43+
const repository_wrapper& repo,
44+
const std::string_view tree_ish,
45+
const std::vector<std::string>& pathspecs,
46+
const git_checkout_options& options
47+
);
48+
4249
std::vector<std::string> m_positional_args = {};
4350
bool m_create_flag = false;
4451
bool m_force_create_flag = false;

src/wrapper/annotated_commit_wrapper.cpp

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@ const git_oid& annotated_commit_wrapper::oid() const
1616
return *git_annotated_commit_id(p_resource);
1717
}
1818

19+
std::string annotated_commit_wrapper::commit_oid_tostr() const
20+
{
21+
char buf[GIT_OID_SHA1_HEXSIZE + 1];
22+
return git_oid_tostr(buf, sizeof(buf), &this->oid());
23+
}
24+
1925
std::string_view annotated_commit_wrapper::reference_name() const
2026
{
2127
const char* res = git_annotated_commit_ref(*this);

src/wrapper/annotated_commit_wrapper.hpp

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
#pragma once
22

3+
#include <string>
34
#include <string_view>
45

56
#include <git2.h>
@@ -18,6 +19,7 @@ class annotated_commit_wrapper : public wrapper_base<git_annotated_commit>
1819
annotated_commit_wrapper& operator=(annotated_commit_wrapper&&) noexcept = default;
1920

2021
const git_oid& oid() const;
22+
std::string commit_oid_tostr() const;
2123
std::string_view reference_name() const;
2224

2325
private:

src/wrapper/refs_wrapper.cpp

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,11 @@ bool reference_wrapper::is_remote() const
2727
return git_reference_is_remote(*this);
2828
}
2929

30+
bool reference_wrapper::is_branch() const
31+
{
32+
return git_reference_is_branch(*this);
33+
}
34+
3035
const git_oid* reference_wrapper::target() const
3136
{
3237
return git_reference_target(p_resource);

src/wrapper/refs_wrapper.hpp

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ class reference_wrapper : public wrapper_base<git_reference>
2222

2323
std::string short_name() const;
2424
bool is_remote() const;
25+
bool is_branch() const;
2526
const git_oid* target() const;
2627
reference_wrapper write_new_ref(const git_oid target_oid);
2728

0 commit comments

Comments
 (0)