Skip to main content

git_checkout_interactive/
lib.rs

1use core::error::Error;
2use core::fmt::{self, Display, Formatter};
3use git2::{BranchType, Repository};
4
5/// Errors produced while choosing and switching branches.
6#[derive(Clone, Copy, Debug, PartialEq, Eq)]
7enum AppError {
8    /// The interactive selection prompt was canceled.
9    CanceledSelection,
10    /// The selection prompt returned an index outside the item list.
11    InvalidSelectionIndex {
12        /// Invalid selected index.
13        index: usize,
14    },
15    /// There are no other local branches to switch to.
16    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
37/// Local branch choice shown in the interactive prompt.
38struct BranchOption {
39    /// Commit timestamp used for recent-branch ordering.
40    commit_time: i64,
41    /// Branch shorthand name.
42    name: String,
43}
44
45/// Run the branch-switching workflow with a caller-provided selector.
46///
47/// # Errors
48///
49/// Returns an error when the repository cannot be inspected, no alternate local
50/// branches exist, selection is canceled or invalid, or checkout fails.
51#[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, &current_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        // TODO this works but makes it so when I push it counts all elements
85        // I'm unsure why and how to fix it
86    }
87    Ok(())
88}
89
90/// Return local branches other than the currently checked-out branch.
91#[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}