Skip to main content

gbm/cmds/
pick.rs

1//! Interactive Git branch picking and prioritization.
2
3use std::fmt::Display;
4use std::fmt::Formatter;
5use std::ops::Deref;
6use std::path::Path;
7
8use owo_colors::OwoColorize;
9use rootcause::prelude::ResultExt;
10use ytil_git::branch::Branch;
11
12pub fn run() -> rootcause::Result<()> {
13    let Some(branch) = select_branch_with_current_first()? else {
14        return Ok(());
15    };
16
17    println!("{}", branch.name_no_origin());
18    Ok(())
19}
20
21fn select_branch_with_current_first() -> rootcause::Result<Option<Branch>> {
22    let repo = ytil_git::repo::discover(Path::new(".")).context("error discovering repo for branch selection")?;
23    let branches = prioritize_current_branch_first(
24        ytil_git::branch::get_all_no_redundant(&repo)?,
25        ytil_git::branch::get_current()?.as_str(),
26        ytil_git::branch::get_previous(&repo).as_deref(),
27        ytil_git::branch::get_user_email(&repo)?.as_deref(),
28    );
29
30    let Some(branch) = ytil_tui::minimal_select(branches.into_iter().map(RenderableBranch).collect())? else {
31        return Ok(None);
32    };
33
34    Ok(Some(branch.0))
35}
36
37fn prioritize_current_branch_first(
38    branches: Vec<Branch>,
39    current_branch: &str,
40    previous_branch: Option<&str>,
41    user_email: Option<&str>,
42) -> Vec<Branch> {
43    let branches = prioritize_recent_branches(branches, previous_branch, user_email);
44    let mut current = None;
45    let mut rest = Vec::with_capacity(branches.len());
46
47    for branch in branches {
48        if current.is_none() && branch.name_no_origin() == current_branch {
49            current = Some(branch);
50        } else {
51            rest.push(branch);
52        }
53    }
54
55    current.into_iter().chain(rest).collect()
56}
57
58fn prioritize_recent_branches(
59    branches: Vec<Branch>,
60    previous_branch: Option<&str>,
61    user_email: Option<&str>,
62) -> Vec<Branch> {
63    const MINE_DESIRED_COUNT: usize = 5;
64
65    let branches_len = branches.len();
66    let mut previous = None;
67    let mut mine = Vec::new();
68    let mut rest = Vec::new();
69
70    for branch in branches {
71        if previous.is_none() && previous_branch.is_some_and(|prev| branch.name_no_origin() == prev) {
72            previous = Some(branch);
73        } else if mine.len() < MINE_DESIRED_COUNT && user_email.is_some_and(|email| branch.committer_email() == email) {
74            mine.push(branch);
75        } else {
76            rest.push(branch);
77        }
78    }
79
80    let mut prioritized = Vec::with_capacity(branches_len);
81    prioritized.extend(previous);
82    prioritized.extend(mine);
83    prioritized.extend(rest);
84    prioritized
85}
86
87struct RenderableBranch(pub Branch);
88
89impl Deref for RenderableBranch {
90    type Target = Branch;
91
92    fn deref(&self) -> &Self::Target {
93        &self.0
94    }
95}
96
97impl Display for RenderableBranch {
98    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
99        let styled_date_time = format!("({})", self.committer_date_time());
100        let styled_email = format!("<{}>", self.committer_email());
101        write!(
102            f,
103            "{} {} {}",
104            self.name(),
105            styled_date_time.green(),
106            styled_email.blue().bold(),
107        )
108    }
109}
110
111#[cfg(test)]
112mod tests {
113    use jiff::Timestamp;
114    use rstest::rstest;
115    use test_that::prelude::*;
116
117    use super::*;
118
119    #[rstest]
120    #[case(
121        vec![branch("main", 30), branch("feature-a", 20), branch("feature-b", 10)],
122        "feature-b",
123        vec![branch("feature-b", 10), branch("main", 30), branch("feature-a", 20)]
124    )]
125    #[case(
126        vec![remote_branch("origin/feature-a", 30), branch("main", 20)],
127        "feature-a",
128        vec![remote_branch("origin/feature-a", 30), branch("main", 20)]
129    )]
130    #[case(
131        vec![branch("main", 30), branch("feature-a", 20)],
132        "missing",
133        vec![branch("main", 30), branch("feature-a", 20)]
134    )]
135    fn test_prioritize_current_branch_first_when_current_branch_varies_orders_expected_branches(
136        #[case] branches: Vec<Branch>,
137        #[case] current_branch: &str,
138        #[case] expected: Vec<Branch>,
139    ) {
140        assert_that!(
141            prioritize_current_branch_first(branches, current_branch, None, None),
142            eq(expected)
143        );
144    }
145
146    #[test]
147    fn test_prioritize_current_branch_first_preserves_gcu_recent_order_after_current() {
148        let branches = vec![
149            branch_with_email("other-1", "other@example.com", 100),
150            branch_with_email("mine-1", "me@example.com", 99),
151            branch_with_email("previous", "other@example.com", 98),
152            branch_with_email("current", "me@example.com", 97),
153            branch_with_email("mine-2", "me@example.com", 96),
154        ];
155
156        assert_that!(
157            prioritize_current_branch_first(branches, "current", Some("previous"), Some("me@example.com")),
158            eq(vec![
159                branch_with_email("current", "me@example.com", 97),
160                branch_with_email("previous", "other@example.com", 98),
161                branch_with_email("mine-1", "me@example.com", 99),
162                branch_with_email("mine-2", "me@example.com", 96),
163                branch_with_email("other-1", "other@example.com", 100),
164            ])
165        );
166    }
167
168    fn branch(name: &str, timestamp: i64) -> Branch {
169        branch_with_email(name, "me@example.com", timestamp)
170    }
171
172    fn branch_with_email(name: &str, email: &str, timestamp: i64) -> Branch {
173        Branch::Local {
174            name: name.to_string(),
175            committer_email: email.to_string(),
176            committer_date_time: Timestamp::from_second(timestamp).unwrap(),
177        }
178    }
179
180    fn remote_branch(name: &str, timestamp: i64) -> Branch {
181        Branch::Remote {
182            name: name.to_string(),
183            committer_email: "me@example.com".to_string(),
184            committer_date_time: Timestamp::from_second(timestamp).unwrap(),
185        }
186    }
187}