44#include < iostream>
55#include < set>
66
7+ #include < git2/oid.h>
8+
79#include " ../subcommand/status_subcommand.hpp"
810#include " ../utils/git_exception.hpp"
911#include " ../wrapper/repository_wrapper.hpp"
@@ -14,7 +16,9 @@ checkout_subcommand::checkout_subcommand(const libgit2_object&, CLI::App& app)
1416 auto * sub = app.add_subcommand (" checkout" , " Switch branches or restore working tree files" );
1517
1618 // "-- 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)" );
19+ sub->add_option (" <tree-ish|pathspec>" , m_positional_args, " Tree-ish to checkout, and/or one/many pathspec(s)" );
20+ // checkout <branch>, checkout <tag>, checkout <file> ..., checkout <branch> <file> ...
21+ // Use without "--"
1822 sub->add_flag (" -b" , m_create_flag, " Create a new branch before checking it out" );
1923 sub->add_flag (" -B" , m_force_create_flag, " Create a new branch or reset it if it exists before checking it out" );
2024 sub->add_flag (
@@ -72,6 +76,36 @@ void checkout_subcommand::checkout_files(
7276 throw_if_error (git_checkout_head (repo, &options));
7377}
7478
79+ void checkout_subcommand::checkout_paths (
80+ const repository_wrapper& repo,
81+ const std::string_view tree_ish,
82+ const std::vector<std::string>& pathspecs,
83+ const git_checkout_options& base_options
84+ )
85+ {
86+ auto obj = repo.revparse_single (tree_ish);
87+ if (!obj)
88+ {
89+ throw git_exception (
90+ " error: could not resolve tree-ish '" + std::string (tree_ish) + " '" ,
91+ git2cpp_error_code::BAD_ARGUMENT
92+ );
93+ }
94+
95+ std::vector<const char *> pathspec_strings;
96+ pathspec_strings.reserve (pathspecs.size ());
97+ for (const auto & p : pathspecs)
98+ {
99+ pathspec_strings.push_back (p.c_str ());
100+ }
101+
102+ git_checkout_options options = base_options;
103+ options.paths .strings = const_cast <char **>(pathspec_strings.data ());
104+ options.paths .count = pathspec_strings.size ();
105+
106+ throw_if_error (git_checkout_tree (repo, *obj, &options));
107+ }
108+
75109void checkout_subcommand::run ()
76110{
77111 auto directory = get_current_git_path ();
@@ -99,77 +133,129 @@ void checkout_subcommand::run()
99133 throw std::runtime_error (" error: no branch or file specified" );
100134 }
101135
102- std::string branch_name = m_positional_args[0 ];
136+ const std::string& target_name = m_positional_args[0 ]; // can be a branch or a tag
137+ const std::vector<std::string> pathspecs (m_positional_args.begin () + 1 , m_positional_args.end ());
138+
103139 if (m_create_flag || m_force_create_flag)
104140 {
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);
141+ if (!pathspecs.empty ())
142+ {
143+ throw git_exception (" error: '-b' or '-B' does not accept pathspecs." , git2cpp_error_code::BAD_ARGUMENT );
144+ }
145+
146+ auto annotated_commit = create_local_branch (repo, target_name, m_force_create_flag);
147+ checkout_tree (repo, annotated_commit, target_name, options);
148+ update_head (repo, annotated_commit, target_name);
108149
109- std::cout << " Switched to a new branch '" << branch_name << " '" << std::endl;
150+ std::cout << " Switched to a new branch '" << target_name << " '" << std::endl;
151+ return ;
110152 }
111- else
153+
154+ if (!pathspecs.empty ())
112155 {
113- auto optional_commit = repo. resolve_local_ref (branch_name);
114- if (!optional_commit )
156+ // Try tree-ish + pathspec(s)
157+ if (auto obj = repo. revparse_single (target_name) )
115158 {
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)
159+ // Validate all pathspecs before checkout so we can mimic git-like errors
160+ for (const auto & p : pathspecs)
161+ {
162+ if (!std::filesystem::exists (std::filesystem::path (directory) / p) && !repo.does_track (p))
124163 {
125- return std::filesystem::exists (std::filesystem::path (directory) / p);
164+ throw git_exception (
165+ " error: pathspec '" + p + " ' did not match any file(s) known to git" ,
166+ git2cpp_error_code::BAD_ARGUMENT
167+ );
126168 }
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 ());
134169 }
135170
136171 options.checkout_strategy = GIT_CHECKOUT_FORCE ;
137- checkout_files (repo, m_positional_args , options);
172+ checkout_paths (repo, target_name, pathspecs , options);
138173 return ;
139174 }
140175
141- auto sl = status_list_wrapper::status_list (repo);
142- try
176+ // Else treat as files
177+ for ( const auto & p : pathspecs)
143178 {
144- checkout_tree (repo, *optional_commit, branch_name, options);
145- update_head (repo, *optional_commit, branch_name);
146- }
147- catch (const git_exception& e)
148- {
149- if (sl.has_notstagged_header ())
179+ if (!std::filesystem::exists (std::filesystem::path (directory) / p) && !repo.does_track (p))
150180 {
151- print_no_switch (sl);
181+ throw git_exception (
182+ " error: pathspec '" + p + " ' did not match any file(s) known to git" ,
183+ git2cpp_error_code::BAD_ARGUMENT
184+ );
152185 }
153- throw e;
154186 }
155187
156- if (sl.has_notstagged_header ())
188+ std::vector<std::string> files = m_positional_args;
189+ options.checkout_strategy = GIT_CHECKOUT_FORCE ;
190+ checkout_files (repo, files, options);
191+ return ;
192+ }
193+
194+ auto optional_commit = repo.resolve_local_ref (target_name);
195+ if (!optional_commit)
196+ {
197+ // TODO: handle remote refs
198+
199+ // Fall back to checking out a unique file
200+ const std::vector<std::string> file = {target_name};
201+
202+ if (!std::filesystem::exists (std::filesystem::path (directory) / target_name))
157203 {
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);
204+ // Neither a branch/tag nor a file
205+ throw git_exception (
206+ " error: pathspec '" + target_name + " ' did not match any file(s) known to git" ,
207+ git2cpp_error_code::BAD_ARGUMENT
208+ );
162209 }
163- if (sl.has_tobecommited_header ())
210+
211+ options.checkout_strategy = GIT_CHECKOUT_FORCE ;
212+ checkout_files (repo, file, options);
213+ return ;
214+ }
215+
216+ auto sl = status_list_wrapper::status_list (repo);
217+ try
218+ {
219+ checkout_tree (repo, *optional_commit, target_name, options);
220+ update_head (repo, *optional_commit, target_name);
221+ }
222+ catch (const git_exception& e)
223+ {
224+ if (sl.has_notstagged_header ())
164225 {
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);
226+ print_no_switch (sl);
169227 }
170- std::cout << " Switched to branch '" << branch_name << " '" << std::endl;
228+ throw e;
229+ }
230+
231+ if (sl.has_notstagged_header ())
232+ {
233+ bool is_long = false ;
234+ bool is_coloured = false ;
235+ std::set<std::string> tracked_dir_set{};
236+ print_notstagged (sl, tracked_dir_set, is_long, is_coloured);
237+ }
238+ if (sl.has_tobecommited_header ())
239+ {
240+ bool is_long = false ;
241+ bool is_coloured = false ;
242+ std::set<std::string> tracked_dir_set{};
243+ print_tobecommited (sl, tracked_dir_set, is_long, is_coloured);
244+ }
245+
246+ std::string_view annotated_ref = optional_commit->reference_name ();
247+ if (!annotated_ref.empty () && repo.find_reference (annotated_ref).is_branch ())
248+ {
249+ std::cout << " Switched to branch '" << target_name << " '" << std::endl;
171250 print_tracking_info (repo, sl, true , false );
172251 }
252+ else
253+ {
254+ std::string sha = optional_commit->commit_oid_tostr ().substr (0 , 7 );
255+ auto commit = repo.find_commit (optional_commit->oid ());
256+ std::string summary = commit.summary ();
257+ std::cout << " HEAD is now at " << sha << " " << summary << std::endl;
258+ }
173259}
174260
175261annotated_commit_wrapper
@@ -196,22 +282,71 @@ void checkout_subcommand::update_head(
196282 const std::string_view target_name
197283)
198284{
285+ // Check if HEAD is already detached or not
286+ const bool head_was_detached = [&]()
287+ {
288+ auto head_ref = repo.head ();
289+ return !head_ref.is_branch ();
290+ }();
291+
292+ // Save previous HEAD info (if it was detached) before changing it (for output message)
293+ std::optional<commit_wrapper> previous_head_commit;
294+ std::string previous_head_message;
295+ if (head_was_detached)
296+ {
297+ previous_head_commit = repo.find_commit (" HEAD" );
298+ previous_head_message = " Previous HEAD position was "
299+ + std::string (previous_head_commit.value ().commit_oid_tostr ().substr (0 , 7 )) + " "
300+ + previous_head_commit.value ().summary ();
301+ }
302+
199303 std::string_view annotated_ref = target_annotated_commit.reference_name ();
200304 if (!annotated_ref.empty ())
201305 {
202306 auto ref = repo.find_reference (annotated_ref);
203- if (ref.is_remote ())
307+ if (ref.is_branch ())
204308 {
205- auto branch = repo.create_branch (target_name, target_annotated_commit);
206- repo.set_head (branch.reference_name ());
309+ if (head_was_detached)
310+ {
311+ std::cout << previous_head_message << std::endl;
312+ }
313+ repo.set_head (annotated_ref);
314+ return ;
207315 }
208- else
316+ }
317+
318+ repo.set_head_detached (target_annotated_commit);
319+
320+ if (head_was_detached)
321+ {
322+ // Only print "Previous HEAD position..." if HEAD was already detached before and if there is an
323+ // actual checkout
324+ auto new_head_commit = repo.find_commit (" HEAD" );
325+ if (!git_oid_equal (&previous_head_commit.value ().oid (), &new_head_commit.oid ()))
209326 {
210- repo. set_head (annotated_ref) ;
327+ std::cout << previous_head_message << std::endl ;
211328 }
212329 }
213330 else
214331 {
215- repo.set_head_detached (target_annotated_commit);
332+ // Only print the detached-HEAD advice if HEAD was not already detached.
333+ std::cout << " Note: switching to '" << target_name << " '." << std::endl;
334+ std::cout << std::endl;
335+ std::cout << " You are in 'detached HEAD' state. You can look around, make experimental" << std::endl;
336+ std::cout << " changes and commit them, and you can discard any commits you make in this" << std::endl;
337+ std::cout << " state without impacting any branches by switching back to a branch." << std::endl;
338+ std::cout << std::endl;
339+
340+ // TODO: add to the following when the switch subcommand is implemented:
341+ // std::cout << "If you want to create a new branch to retain commits you create, you may" <<
342+ // std::endl; std::cout << "do so (now or later) by using -c with the switch command. Example:" <<
343+ // std::endl; std::cout << " git switch -c <new-branch-name>" << std::endl; std::cout << std::endl;
344+ // std::cout << "Or undo this operation with:" << std::endl;
345+ // std::cout << std::endl;
346+ // std::cout << " git switch -" << std::endl;
347+ // std::cout << std::endl;
348+ // TODO: add the following later
349+ // std::cout << "Turn off this advice by setting config variable advice.detachedHead to false"
350+ // << std::endl;
216351 }
217352}
0 commit comments