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+
75105void 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
175254annotated_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}
0 commit comments