Skip to content

Commit fcbd9be

Browse files
committed
Add checkout file
1 parent 4bdec5e commit fcbd9be

3 files changed

Lines changed: 176 additions & 15 deletions

File tree

src/subcommand/checkout_subcommand.cpp

Lines changed: 61 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
#include <iostream>
44
#include <set>
5-
#include <sstream>
5+
#include <filesystem>
66

77
#include "../subcommand/status_subcommand.hpp"
88
#include "../utils/git_exception.hpp"
@@ -13,7 +13,8 @@ checkout_subcommand::checkout_subcommand(const libgit2_object&, CLI::App& app)
1313
{
1414
auto* sub = app.add_subcommand("checkout", "Switch branches or restore working tree files");
1515

16-
sub->add_option("<branch>", m_branch_name, "Branch to checkout");
16+
// "-- 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)");
1718
sub->add_flag("-b", m_create_flag, "Create a new branch before checking it out");
1819
sub->add_flag("-B", m_force_create_flag, "Create a new branch or reset it if it exists before checking it out");
1920
sub->add_flag(
@@ -51,6 +52,26 @@ namespace
5152
}
5253
}
5354

55+
void checkout_subcommand::checkout_files(
56+
const repository_wrapper& repo,
57+
const std::vector<std::string>& files,
58+
const git_checkout_options& base_options
59+
)
60+
{
61+
std::vector<const char*> pathspec_strings;
62+
pathspec_strings.reserve(files.size());
63+
for (const auto& f : files)
64+
{
65+
pathspec_strings.push_back(f.c_str());
66+
}
67+
68+
git_checkout_options options = base_options;
69+
options.paths.strings = const_cast<char**>(pathspec_strings.data());
70+
options.paths.count = pathspec_strings.size();
71+
72+
throw_if_error(git_checkout_head(repo, &options));
73+
}
74+
5475
void checkout_subcommand::run()
5576
{
5677
auto directory = get_current_git_path();
@@ -73,30 +94,57 @@ void checkout_subcommand::run()
7394
options.checkout_strategy = GIT_CHECKOUT_SAFE;
7495
}
7596

97+
if (m_positional_args.empty())
98+
{
99+
throw std::runtime_error("error: no branch or file specified");
100+
}
101+
102+
std::string branch_name = m_positional_args[0];
76103
if (m_create_flag || m_force_create_flag)
77104
{
78-
auto annotated_commit = create_local_branch(repo, m_branch_name, m_force_create_flag);
79-
checkout_tree(repo, annotated_commit, m_branch_name, options);
80-
update_head(repo, annotated_commit, m_branch_name);
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);
81108

82-
std::cout << "Switched to a new branch '" << m_branch_name << "'" << std::endl;
109+
std::cout << "Switched to a new branch '" << branch_name << "'" << std::endl;
83110
}
84111
else
85112
{
86-
auto optional_commit = repo.resolve_local_ref(m_branch_name);
113+
auto optional_commit = repo.resolve_local_ref(branch_name);
87114
if (!optional_commit)
88115
{
89116
// TODO: handle remote refs
90-
std::ostringstream buffer;
91-
buffer << "error: could not resolve pathspec '" << m_branch_name << "'" << std::endl;
92-
throw std::runtime_error(buffer.str());
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)
124+
{
125+
return std::filesystem::exists(
126+
std::filesystem::path(directory) / p
127+
);
128+
}
129+
);
130+
131+
if (!any_exists)
132+
{
133+
std::ostringstream buffer;
134+
buffer << "error: could not resolve pathspec '" << branch_name << "'" << std::endl;
135+
throw std::runtime_error(buffer.str());
136+
}
137+
138+
options.checkout_strategy = GIT_CHECKOUT_FORCE;
139+
checkout_files(repo, m_positional_args, options);
140+
return;
93141
}
94142

95143
auto sl = status_list_wrapper::status_list(repo);
96144
try
97145
{
98-
checkout_tree(repo, *optional_commit, m_branch_name, options);
99-
update_head(repo, *optional_commit, m_branch_name);
146+
checkout_tree(repo, *optional_commit, branch_name, options);
147+
update_head(repo, *optional_commit, branch_name);
100148
}
101149
catch (const git_exception& e)
102150
{
@@ -121,7 +169,7 @@ void checkout_subcommand::run()
121169
std::set<std::string> tracked_dir_set{};
122170
print_tobecommited(sl, tracked_dir_set, is_long, is_coloured);
123171
}
124-
std::cout << "Switched to branch '" << m_branch_name << "'" << std::endl;
172+
std::cout << "Switched to branch '" << branch_name << "'" << std::endl;
125173
print_tracking_info(repo, sl, true, false);
126174
}
127175
}

src/subcommand/checkout_subcommand.hpp

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

3-
#include <optional>
43
#include <string>
4+
#include <vector>
55

66
#include <CLI/CLI.hpp>
77

@@ -33,7 +33,13 @@ class checkout_subcommand
3333
const std::string_view target_name
3434
);
3535

36-
std::string m_branch_name = {};
36+
void checkout_files(
37+
const repository_wrapper& repo,
38+
const std::vector<std::string>& files,
39+
const git_checkout_options& options
40+
);
41+
42+
std::vector<std::string> m_positional_args = {};
3743
bool m_create_flag = false;
3844
bool m_force_create_flag = false;
3945
bool m_force_checkout_flag = false;

test/test_checkout.py

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,3 +170,110 @@ def test_checkout_refuses_overwrite(
170170
branch_cmd = [git2cpp_path, "branch"]
171171
p_branch = subprocess.run(branch_cmd, capture_output=True, cwd=tmp_path, text=True)
172172
assert "* newbranch" in p_branch.stdout
173+
174+
175+
def test_checkout_file_restores_modified_file(repo_init_with_commit, git2cpp_path, tmp_path):
176+
"""Test that checkout -- <file> discards working tree changes"""
177+
initial_file = tmp_path / "initial.txt"
178+
original_content = initial_file.read_text()
179+
180+
# Modify the file (unstaged)
181+
initial_file.write_text("Modified content")
182+
assert initial_file.read_text() == "Modified content"
183+
184+
# Restore it via checkout -- <file>
185+
checkout_cmd = [git2cpp_path, "checkout", "--", "initial.txt"]
186+
p = subprocess.run(checkout_cmd, capture_output=True, cwd=tmp_path, text=True)
187+
188+
assert p.returncode == 0
189+
assert initial_file.read_text() == original_content
190+
191+
192+
def test_checkout_file_restores_multiple_files(repo_init_with_commit, git2cpp_path, tmp_path):
193+
"""Test that checkout -- <file1> <file2> restores multiple files at once"""
194+
initial_file = tmp_path / "initial.txt"
195+
196+
# Create and commit a second file first
197+
second_file = tmp_path / "second.txt"
198+
second_file.write_text("second content")
199+
200+
add_cmd = [git2cpp_path, "add", "second.txt"]
201+
subprocess.run(add_cmd, cwd=tmp_path, text=True)
202+
commit_cmd = [git2cpp_path, "commit", "-m", "Add second file"]
203+
subprocess.run(commit_cmd, cwd=tmp_path, text=True)
204+
205+
original_initial = initial_file.read_text()
206+
original_second = second_file.read_text()
207+
208+
# Modify both files
209+
initial_file.write_text("dirty initial")
210+
second_file.write_text("dirty second")
211+
212+
checkout_cmd = [git2cpp_path, "checkout", "--", "initial.txt", "second.txt"]
213+
p = subprocess.run(checkout_cmd, capture_output=True, cwd=tmp_path, text=True)
214+
215+
assert p.returncode == 0
216+
assert initial_file.read_text() == original_initial
217+
assert second_file.read_text() == original_second
218+
219+
220+
def test_checkout_file_does_not_affect_other_files(repo_init_with_commit, git2cpp_path, tmp_path):
221+
"""Test that checkout -- <file> only touches the specified file"""
222+
initial_file = tmp_path / "initial.txt"
223+
original_initial = initial_file.read_text()
224+
225+
# Create and commit a second file
226+
second_file = tmp_path / "second.txt"
227+
second_file.write_text("second content")
228+
229+
add_cmd = [git2cpp_path, "add", "second.txt"]
230+
subprocess.run(add_cmd, cwd=tmp_path, text=True)
231+
commit_cmd = [git2cpp_path, "commit", "-m", "Add second file"]
232+
subprocess.run(commit_cmd, cwd=tmp_path, text=True)
233+
234+
# Modify both files
235+
initial_file.write_text("dirty initial")
236+
second_file.write_text("dirty second")
237+
238+
# Only restore initial.txt
239+
checkout_cmd = [git2cpp_path, "checkout", "--", "initial.txt"]
240+
p = subprocess.run(checkout_cmd, capture_output=True, cwd=tmp_path, text=True)
241+
242+
assert p.returncode == 0
243+
assert initial_file.read_text() == original_initial
244+
assert second_file.read_text() == "dirty second"
245+
246+
247+
def test_checkout_file_does_not_change_branch(repo_init_with_commit, git2cpp_path, tmp_path):
248+
"""Test that checkout -- <file> does not move HEAD or change the current branch"""
249+
initial_file = tmp_path / "initial.txt"
250+
original_initial = initial_file.read_text()
251+
252+
initial_file.write_text("dirty")
253+
254+
checkout_cmd = [git2cpp_path, "checkout", "--", "initial.txt"]
255+
p = subprocess.run(checkout_cmd, capture_output=True, cwd=tmp_path, text=True)
256+
assert p.returncode == 0
257+
assert initial_file.read_text() == original_initial
258+
259+
branch_cmd = [git2cpp_path, "branch"]
260+
p_branch = subprocess.run(branch_cmd, capture_output=True, cwd=tmp_path, text=True)
261+
assert p_branch.returncode == 0
262+
assert "* main" in p_branch.stdout
263+
264+
265+
def test_checkout_file_nonexistent_path_fails(repo_init_with_commit, git2cpp_path, tmp_path):
266+
"""Test that checkout -- <nonexistent> fails with a non-zero exit code"""
267+
checkout_cmd = [git2cpp_path, "checkout", "--", "doesnotexist.txt"]
268+
p = subprocess.run(checkout_cmd, capture_output=True, cwd=tmp_path, text=True)
269+
270+
assert p.returncode != 0
271+
272+
273+
def test_checkout_file_no_paths_fails(repo_init_with_commit, git2cpp_path, tmp_path):
274+
"""Test that checkout -- with no file arguments fails"""
275+
checkout_cmd = [git2cpp_path, "checkout", "--"]
276+
p = subprocess.run(checkout_cmd, capture_output=True, cwd=tmp_path, text=True)
277+
278+
assert p.returncode != 0
279+
assert "no branch or file specified" in p.stderr

0 commit comments

Comments
 (0)