Skip to main content

ytil_tui/
git_branch.rs

1use std::fmt::Display;
2use std::fmt::Formatter;
3use std::ops::Deref;
4use std::path::Path;
5
6use owo_colors::OwoColorize;
7use rootcause::prelude::ResultExt;
8use ytil_git::branch::Branch;
9
10/// Prompts the user to select a branch from all available branches.
11///
12/// The previously checked-out branch (`@{-1}`) is placed first when available.
13/// Then up to seven branches whose latest commit was made with the configured
14/// `user.email` are shown before the remaining recency-ordered list.
15///
16/// # Errors
17/// - If repository discovery fails.
18/// - If [`ytil_git::branch::get_all_no_redundant`] fails.
19/// - If [`crate::minimal_select`] fails.
20pub fn select() -> rootcause::Result<Option<Branch>> {
21    let repo = ytil_git::repo::discover(Path::new(".")).context("error discovering repo for branch selection")?;
22    let branches = prioritize_branches(
23        ytil_git::branch::get_all_no_redundant(&repo)?,
24        ytil_git::branch::get_previous(&repo).as_deref(),
25        ytil_git::branch::get_user_email(&repo)?.as_deref(),
26    );
27
28    let Some(branch) = crate::minimal_select(branches.into_iter().map(RenderableBranch).collect())? else {
29        return Ok(None);
30    };
31
32    Ok(Some(branch.0))
33}
34
35/// A wrapper around [`Branch`] for display purposes.
36struct RenderableBranch(pub Branch);
37
38impl Deref for RenderableBranch {
39    type Target = Branch;
40
41    fn deref(&self) -> &Self::Target {
42        &self.0
43    }
44}
45
46impl Display for RenderableBranch {
47    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
48        let styled_date_time = format!("({})", self.committer_date_time());
49        let styled_email = format!("<{}>", self.committer_email());
50        write!(
51            f,
52            "{} {} {}",
53            self.name(),
54            styled_date_time.green(),
55            styled_email.blue().bold(),
56        )
57    }
58}
59
60fn prioritize_branches(branches: Vec<Branch>, previous_branch: Option<&str>, user_email: Option<&str>) -> Vec<Branch> {
61    const MINE_DESIRED_COUNT: usize = 5;
62
63    let branches_len = branches.len();
64    let mut previous = None;
65    let mut mine = Vec::new();
66    let mut rest = Vec::new();
67
68    for branch in branches {
69        if previous.is_none() && previous_branch.is_some_and(|prev| branch.name_no_origin() == prev) {
70            previous = Some(branch);
71        } else if mine.len() < MINE_DESIRED_COUNT && user_email.is_some_and(|email| branch.committer_email() == email) {
72            mine.push(branch);
73        } else {
74            rest.push(branch);
75        }
76    }
77
78    let mut prioritized = Vec::with_capacity(branches_len);
79    prioritized.extend(previous);
80    prioritized.extend(mine);
81    prioritized.extend(rest);
82    prioritized
83}
84
85#[cfg(test)]
86mod tests {
87    use chrono::DateTime;
88    use chrono::Utc;
89    use rstest::rstest;
90    use ytil_git::branch::Branch;
91
92    use super::*;
93
94    #[rstest]
95    #[case(
96        vec![
97            branch("main", "other@example.com", 30),
98            branch("feature-a", "me@example.com", 20),
99            branch("feature-b", "me@example.com", 10),
100        ],
101        Some("feature-b"),
102        Some("me@example.com"),
103        vec![
104            branch("feature-b", "me@example.com", 10),
105            branch("feature-a", "me@example.com", 20),
106            branch("main", "other@example.com", 30),
107        ]
108    )]
109    #[case(
110        vec![
111            remote_branch("origin/feature-a", "me@example.com", 30),
112            branch("main", "other@example.com", 20),
113        ],
114        Some("feature-a"),
115        Some("me@example.com"),
116        vec![
117            remote_branch("origin/feature-a", "me@example.com", 30),
118            branch("main", "other@example.com", 20),
119        ]
120    )]
121    #[case(
122        vec![
123            branch("main", "other@example.com", 30),
124            branch("feature-a", "me@example.com", 20),
125            branch("feature-b", "me@example.com", 10),
126        ],
127        Some("feature-a"),
128        Some("me@example.com"),
129        vec![
130            branch("feature-a", "me@example.com", 20),
131            branch("feature-b", "me@example.com", 10),
132            branch("main", "other@example.com", 30),
133        ]
134    )]
135    fn prioritize_branches_prioritizes_previous_branch_cases(
136        #[case] branches: Vec<Branch>,
137        #[case] previous_branch: Option<&str>,
138        #[case] user_email: Option<&str>,
139        #[case] expected: Vec<Branch>,
140    ) {
141        let ordered = prioritize_branches(branches, previous_branch, user_email);
142
143        pretty_assertions::assert_eq!(ordered, expected);
144    }
145
146    #[test]
147    fn test_prioritize_branches_puts_only_the_wanted_number_of_branches_matching_email_before_rest() {
148        let branches = vec![
149            branch("other-1", "other@example.com", 100),
150            branch("mine-1", "me@example.com", 99),
151            branch("mine-2", "me@example.com", 98),
152            branch("mine-3", "me@example.com", 97),
153            branch("mine-4", "me@example.com", 96),
154            branch("mine-5", "me@example.com", 95),
155            branch("mine-6", "me@example.com", 94),
156            branch("mine-7", "me@example.com", 93),
157            branch("mine-8", "me@example.com", 92),
158            branch("other-2", "other@example.com", 91),
159        ];
160
161        let ordered = prioritize_branches(branches, None, Some("me@example.com"));
162
163        pretty_assertions::assert_eq!(
164            &ordered,
165            &[
166                branch("mine-1", "me@example.com", 99),
167                branch("mine-2", "me@example.com", 98),
168                branch("mine-3", "me@example.com", 97),
169                branch("mine-4", "me@example.com", 96),
170                branch("mine-5", "me@example.com", 95),
171                branch("other-1", "other@example.com", 100),
172                branch("mine-6", "me@example.com", 94),
173                branch("mine-7", "me@example.com", 93),
174                branch("mine-8", "me@example.com", 92),
175                branch("other-2", "other@example.com", 91),
176            ],
177        );
178    }
179
180    #[rstest]
181    #[case(
182        vec![
183            branch("other-1", "other@example.com", 100),
184            branch("mine-1", "me@example.com", 99),
185            branch("other-2", "other@example.com", 98),
186            branch("mine-2", "me@example.com", 97),
187        ],
188        None,
189        Some("me@example.com"),
190        vec![
191            branch("mine-1", "me@example.com", 99),
192            branch("mine-2", "me@example.com", 97),
193            branch("other-1", "other@example.com", 100),
194            branch("other-2", "other@example.com", 98),
195        ]
196    )]
197    #[case(
198        vec![
199            branch("main", "other@example.com", 30),
200            branch("feature-a", "me@example.com", 20),
201            branch("feature-b", "me@example.com", 10),
202        ],
203        Some("feature-b"),
204        None,
205        vec![
206            branch("feature-b", "me@example.com", 10),
207            branch("main", "other@example.com", 30),
208            branch("feature-a", "me@example.com", 20),
209        ]
210    )]
211    #[case(
212        vec![
213            branch("main", "other@example.com", 30),
214            branch("feature-a", "other@example.com", 20),
215        ],
216        Some("missing"),
217        None,
218        vec![
219            branch("main", "other@example.com", 30),
220            branch("feature-a", "other@example.com", 20),
221        ]
222    )]
223    #[case(
224        vec![
225            branch("main", "other@example.com", 30),
226            branch("feature-a", "other@example.com", 20),
227        ],
228        None,
229        None,
230        vec![
231            branch("main", "other@example.com", 30),
232            branch("feature-a", "other@example.com", 20),
233        ]
234    )]
235    fn prioritize_branches_misc_cases(
236        #[case] branches: Vec<Branch>,
237        #[case] previous_branch: Option<&str>,
238        #[case] user_email: Option<&str>,
239        #[case] expected: Vec<Branch>,
240    ) {
241        let ordered = prioritize_branches(branches, previous_branch, user_email);
242
243        pretty_assertions::assert_eq!(ordered, expected);
244    }
245
246    fn branch(name: &str, email: &str, timestamp: i64) -> Branch {
247        Branch::Local {
248            name: name.to_string(),
249            committer_email: email.to_string(),
250            committer_date_time: DateTime::<Utc>::from_timestamp(timestamp, 0).unwrap(),
251        }
252    }
253
254    fn remote_branch(name: &str, email: &str, timestamp: i64) -> Branch {
255        Branch::Remote {
256            name: name.to_string(),
257            committer_email: email.to_string(),
258            committer_date_time: DateTime::<Utc>::from_timestamp(timestamp, 0).unwrap(),
259        }
260    }
261}