git_checkout_interactive/
lib.rs1use core::error::Error;
2use core::fmt::{self, Display, Formatter};
3use git2::{BranchType, Repository};
4
5#[derive(Clone, Copy, Debug, PartialEq, Eq)]
7enum AppError {
8 CanceledSelection,
10 InvalidSelectionIndex {
12 index: usize,
14 },
15 SingleBranch,
17}
18
19impl Display for AppError {
20 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
21 match *self {
22 Self::CanceledSelection => f.write_str("canceled selection"),
23 Self::InvalidSelectionIndex { index } => {
24 write!(f, "invalid selection index: {index}")
25 }
26 Self::SingleBranch => f.write_str("cant switch from a single branch"),
27 }
28 }
29}
30
31#[expect(
32 clippy::missing_trait_methods,
33 reason = "std::error::Error default methods are sufficient for this leaf error type"
34)]
35impl Error for AppError {}
36
37struct BranchOption {
39 commit_time: i64,
41 name: String,
43}
44
45#[inline]
52pub fn run_with_selector<S>(repo: &Repository, selector: S) -> Result<(), Box<dyn Error>>
53where
54 S: FnOnce(&str, &[&str]) -> Result<Option<usize>, Box<dyn Error>>,
55{
56 let current_branch_name = repo.head()?.shorthand().unwrap_or_default().to_owned();
57 let mut branches = branch_options(repo, ¤t_branch_name)?;
58 branches.sort_by_key(|branch| branch.commit_time);
59
60 let items = branches
61 .iter()
62 .rev()
63 .map(|branch| branch.name.as_str())
64 .collect::<Vec<&str>>();
65 if items.is_empty() {
66 return Err(Box::new(AppError::SingleBranch));
67 }
68
69 let prompt = format!("Switch Branch? {current_branch_name} ->");
70 let selected_index = selector(&prompt, &items)?.ok_or(AppError::CanceledSelection)?;
71 let branch_name =
72 items
73 .get(selected_index)
74 .copied()
75 .ok_or(AppError::InvalidSelectionIndex {
76 index: selected_index,
77 })?;
78
79 let branch = repo.find_branch(branch_name, BranchType::Local)?;
80 if let Some(name) = branch.get().name() {
81 let target = branch.get().peel_to_commit()?;
82 repo.checkout_tree(target.as_object(), None)?;
83 repo.set_head(name)?;
84 }
87 Ok(())
88}
89
90#[expect(
92 clippy::single_call_fn,
93 reason = "keeps branch discovery testable and readable"
94)]
95fn branch_options(
96 repo: &Repository,
97 current_branch_name: &str,
98) -> Result<Vec<BranchOption>, git2::Error> {
99 Ok(repo
100 .branches(Some(BranchType::Local))?
101 .filter_map(Result::ok)
102 .filter_map(
103 |(branch, _)| match (branch.name(), branch.get().peel_to_commit()) {
104 (Ok(Some(name)), Ok(commit)) => {
105 (name != current_branch_name).then(|| BranchOption {
106 name: name.to_owned(),
107 commit_time: commit.committer().when().seconds(),
108 })
109 }
110 _ => None,
111 },
112 )
113 .collect())
114}
115
116#[cfg(test)]
117#[expect(
118 clippy::expect_used,
119 clippy::panic,
120 clippy::unwrap_used,
121 reason = "tests favor direct assertions and fixture setup"
122)]
123mod tests {
124 use super::*;
125 use core::sync::atomic::{AtomicU64, Ordering};
126 use git2::{IndexAddOption, Oid, Signature, Time};
127 use std::env::temp_dir;
128 use std::fs;
129 use std::path::PathBuf;
130 use std::process::id;
131 use std::time::{SystemTime, UNIX_EPOCH};
132
133 static TEST_REPO_COUNTER: AtomicU64 = AtomicU64::new(0);
134
135 struct TestRepo {
136 path: PathBuf,
137 repo: Repository,
138 }
139
140 impl TestRepo {
141 fn checkout(&self, branch_name: &str) {
142 let object = self
143 .repo
144 .revparse_single(&format!("refs/heads/{branch_name}"))
145 .expect("find branch object");
146 self.repo
147 .checkout_tree(&object, None)
148 .expect("checkout tree");
149 self.repo
150 .set_head(&format!("refs/heads/{branch_name}"))
151 .expect("set head");
152 }
153
154 fn commit_file(&self, branch_name: &str, timestamp: i64) -> Oid {
155 fs::write(
156 self.path.join("branch.txt"),
157 format!("{branch_name}:{timestamp}\n"),
158 )
159 .expect("write branch file");
160
161 let mut index = self.repo.index().expect("open index");
162 index
163 .add_all(["*"], IndexAddOption::DEFAULT, None)
164 .expect("add files");
165 index.write().expect("write index");
166 let tree_oid = index.write_tree().expect("write tree");
167 let tree = self.repo.find_tree(tree_oid).expect("find tree");
168 let signature = Signature::new(
169 "gci tests",
170 "gci-tests@example.com",
171 &Time::new(timestamp, 0),
172 )
173 .expect("create signature");
174 let parent = self
175 .repo
176 .head()
177 .ok()
178 .and_then(|head| head.target())
179 .and_then(|oid| self.repo.find_commit(oid).ok());
180 let message = format!("commit {branch_name}");
181
182 parent.map_or_else(
183 || {
184 self.repo
185 .commit(Some("HEAD"), &signature, &signature, &message, &tree, &[])
186 .expect("initial commit")
187 },
188 |parent_commit| {
189 self.repo
190 .commit(
191 Some("HEAD"),
192 &signature,
193 &signature,
194 &message,
195 &tree,
196 &[&parent_commit],
197 )
198 .expect("commit with parent")
199 },
200 )
201 }
202
203 fn create_branch_from_head(&self, branch_name: &str) {
204 let head_commit = self
205 .repo
206 .head()
207 .expect("read head")
208 .peel_to_commit()
209 .expect("peel head");
210 self.repo
211 .branch(branch_name, &head_commit, false)
212 .expect("create branch");
213 }
214
215 fn new() -> Self {
216 let path = unique_temp_path();
217 fs::create_dir_all(&path).expect("create temp repo dir");
218 let repo = Repository::init(&path).expect("init repository");
219
220 Self { path, repo }
221 }
222 }
223
224 impl Drop for TestRepo {
225 fn drop(&mut self) {
226 let _ignored = fs::remove_dir_all(&self.path);
227 }
228 }
229
230 #[test]
231 fn returns_single_branch_error_when_no_other_branch_exists() {
232 let test_repo = TestRepo::new();
233 test_repo.commit_file("master", 1_700_000_000);
234
235 let error = run_with_selector(&test_repo.repo, |_, _| Ok(Some(0))).unwrap_err();
236
237 assert_app_error(error.as_ref(), &AppError::SingleBranch);
238 }
239
240 #[test]
241 fn returns_canceled_selection_error() {
242 let test_repo = repo_with_three_branches();
243
244 let error = run_with_selector(&test_repo.repo, |_, _| Ok(None)).unwrap_err();
245
246 assert_app_error(error.as_ref(), &AppError::CanceledSelection);
247 }
248
249 #[test]
250 fn returns_invalid_selection_index_error() {
251 let test_repo = repo_with_three_branches();
252
253 let error = run_with_selector(&test_repo.repo, |_, _| Ok(Some(99))).unwrap_err();
254
255 assert_app_error(
256 error.as_ref(),
257 &AppError::InvalidSelectionIndex { index: 99 },
258 );
259 }
260
261 #[test]
262 fn offers_other_local_branches_newest_first() {
263 let test_repo = repo_with_three_branches();
264 let mut offered_items = Vec::new();
265
266 run_with_selector(&test_repo.repo, |_, items| {
267 offered_items = items.iter().map(ToString::to_string).collect();
268 Ok(Some(0))
269 })
270 .expect("checkout selected branch");
271
272 assert_eq!(offered_items, ["newer", "older"]);
273 }
274
275 #[test]
276 fn selecting_a_branch_updates_head() {
277 let test_repo = repo_with_three_branches();
278
279 run_with_selector(&test_repo.repo, |_, items| {
280 assert_eq!(items, ["newer", "older"]);
281 Ok(Some(1))
282 })
283 .expect("checkout selected branch");
284
285 let head = test_repo.repo.head().expect("read head");
286 assert_eq!(head.shorthand(), Some("older"));
287 }
288
289 fn repo_with_three_branches() -> TestRepo {
290 let test_repo = TestRepo::new();
291 test_repo.commit_file("master", 1_700_000_000);
292 test_repo.create_branch_from_head("older");
293 test_repo.create_branch_from_head("newer");
294
295 test_repo.checkout("older");
296 test_repo.commit_file("older", 1_700_000_100);
297
298 test_repo.checkout("newer");
299 test_repo.commit_file("newer", 1_700_000_200);
300
301 test_repo.checkout("master");
302 test_repo
303 }
304
305 fn assert_app_error(error: &(dyn Error + 'static), expected: &AppError) {
306 match error.downcast_ref::<AppError>() {
307 Some(actual) if actual == expected => {}
308 other => panic!("expected {expected:?}, got {other:?}"),
309 }
310 }
311
312 #[expect(
313 clippy::single_call_fn,
314 reason = "keeps test fixture creation readable"
315 )]
316 fn unique_temp_path() -> PathBuf {
317 let timestamp = SystemTime::now()
318 .duration_since(UNIX_EPOCH)
319 .expect("system clock after unix epoch")
320 .as_nanos();
321 let counter = TEST_REPO_COUNTER.fetch_add(1, Ordering::Relaxed);
322 temp_dir().join(format!("gci-test-{}-{timestamp}-{counter}", id()))
323 }
324}