From 7a0f664eb256999c467a7f3add737059b771c42e Mon Sep 17 00:00:00 2001 From: Marcel Gotsch Date: Sun, 8 Feb 2026 14:09:09 +0100 Subject: [PATCH 1/4] fix: picker doesn't close menu on error or cancellation --- src/app.rs | 4 ++++ .../gitu__tests__merge__merge_picker_cancel.snap | 14 +++++++------- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/src/app.rs b/src/app.rs index eef004fb7a..e999465952 100644 --- a/src/app.rs +++ b/src/app.rs @@ -664,6 +664,10 @@ impl App { self.state.picker = Some(picker_state); let result = self.handle_picker(term); + if let Err(_) | Ok(None) = result { + self.close_menu(); + } + self.state.picker = None; result diff --git a/src/tests/snapshots/gitu__tests__merge__merge_picker_cancel.snap b/src/tests/snapshots/gitu__tests__merge__merge_picker_cancel.snap index 9e3885316e..65b3b5a298 100644 --- a/src/tests/snapshots/gitu__tests__merge__merge_picker_cancel.snap +++ b/src/tests/snapshots/gitu__tests__merge__merge_picker_cancel.snap @@ -16,10 +16,10 @@ expression: ctx.redact_buffer() | | | -────────────────────────────────────────────────────────────────────────────────| - Merge Arguments | - m merge -f Fast-forward only (--ff-only) | - a abort -n No fast-forward (--no-ff) | - c continue | - q/ Quit/Close | -styles_hash: 7b86e45a0c70a078 + | + | + | + | + | + | +styles_hash: f47a6512af0aca26 From 63b60574803d99ba3219884558ce7fdc1fbeeb85 Mon Sep 17 00:00:00 2001 From: Marcel Gotsch Date: Sun, 8 Feb 2026 14:35:35 +0100 Subject: [PATCH 2/4] add convenience function for starting a picker for branches & tags --- src/item_data.rs | 2 +- src/ops/merge.rs | 45 ++++++++---------------------------- src/picker.rs | 59 ++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 70 insertions(+), 36 deletions(-) diff --git a/src/item_data.rs b/src/item_data.rs index 283322c5fb..5bd6f54488 100644 --- a/src/item_data.rs +++ b/src/item_data.rs @@ -67,7 +67,7 @@ impl Default for ItemData { } } -#[derive(Clone, Debug)] +#[derive(PartialEq, Clone, Debug)] pub(crate) enum RefKind { Tag(String), Branch(String), diff --git a/src/ops/merge.rs b/src/ops/merge.rs index 43f7608ad1..963eec37b9 100644 --- a/src/ops/merge.rs +++ b/src/ops/merge.rs @@ -1,12 +1,10 @@ use super::{Action, OpTrait}; -use crate::item_data::RefKind; use crate::{ Res, app::{App, State}, - error::Error, item_data::ItemData, menu::arg::Arg, - picker::PickerState, + picker::{BranchesAndTagsOptions, PickerState}, term::Term, }; @@ -77,40 +75,17 @@ impl OpTrait for Merge { }; Some(Rc::new(move |app: &mut App, term: &mut Term| { - // Get current HEAD reference to exclude it from picker - let exclude_ref = { - let head = app.state.repo.head().map_err(Error::GetHead)?; - RefKind::from_reference(&head) - }; - - // Collect all branches (local and remote) - let branches = app - .state - .repo - .branches(None) - .map_err(Error::ListGitReferences)? - .filter_map(|branch| { - let (branch, _) = branch.ok()?; - RefKind::from_reference(branch.get()) - }); - - // Collect all tags - let tags: Vec = app - .state - .repo - .tag_names(None) - .map_err(Error::ListGitReferences)? - .into_iter() - .flatten() - .map(|tag_name| RefKind::Tag(tag_name.to_string())) - .collect(); - - let all_refs: Vec = branches.chain(tags).collect(); - // Allow custom input to support commit hashes, relative refs (e.g., HEAD~3), // and other git revisions not in the predefined list - let picker = - PickerState::with_refs("Merge", all_refs, exclude_ref, default_ref.clone(), true); + let picker = PickerState::for_branches_and_tags( + "Merge", + &app.state.repo, + BranchesAndTagsOptions { + exclude_head: true, + allow_custom_input: true, + default: default_ref.clone(), + }, + )?; let result = app.picker(term, picker)?; if let Some(data) = result { diff --git a/src/picker.rs b/src/picker.rs index 55ef715ef5..bc4781ced4 100644 --- a/src/picker.rs +++ b/src/picker.rs @@ -1,5 +1,7 @@ +use crate::error::Error; use fuzzy_matcher::FuzzyMatcher; use fuzzy_matcher::skim::SkimMatcherV2; +use git2::Repository; use std::borrow::Cow; use std::collections::HashMap; use tui_prompts::State as _; @@ -65,6 +67,12 @@ pub enum PickerStatus { Cancelled, } +pub(crate) struct BranchesAndTagsOptions { + pub exclude_head: bool, + pub allow_custom_input: bool, + pub default: Option, +} + /// State of the picker component pub struct PickerState { /// All available items (excluding custom input) @@ -109,6 +117,57 @@ impl PickerState { state } + /// Create a picker to select from existing branches or tags. + /// + /// The default option, if provided, will be displayed at the top and + /// is selected by default. + /// + /// Custom user input can be enabled to select arbitrary revisions, such as + /// HEAD~2. + pub(crate) fn for_branches_and_tags( + prompt: impl Into>, + repo: &Repository, + options: BranchesAndTagsOptions, + ) -> Result { + // Get current HEAD reference to exclude it from picker + let exclude_ref = if options.exclude_head { + let head = repo.head().map_err(Error::GetHead)?; + RefKind::from_reference(&head) + } else { + None + }; + // Ignore default if it's excluded. + let default = if options.default == exclude_ref { + None + } else { + options.default + }; + + let branches = repo + .branches(None) + .map_err(Error::ListGitReferences)? + .filter_map(|branch| { + let (branch, _) = branch.ok()?; + RefKind::from_reference(branch.get()) + }); + + let tags = repo.tag_names(None).map_err(Error::ListGitReferences)?; + let tags = tags + .into_iter() + .flatten() + .map(|tag_name| RefKind::Tag(tag_name.to_string())); + + let all_refs: Vec = branches.chain(tags).collect(); + + Ok(Self::with_refs( + prompt, + all_refs, + exclude_ref, + default, + options.allow_custom_input, + )) + } + /// Create a picker from RefKinds, automatically handling duplicates and sorting /// /// Items are sorted as: default (if provided) -> branches -> tags -> remotes From 06bfb6ad738df6dac3300df8afc418f18f474a1f Mon Sep 17 00:00:00 2001 From: Marcel Gotsch Date: Sun, 1 Feb 2026 21:26:51 +0100 Subject: [PATCH 3/4] feat: checkout branch/revision with picker The checkout action shows an interactive picker to select a branch to checkout using fuzzy matching. Checking out a custom revision not found in the branch list, e.g. a commit hash, is also possible. If the checkout is started from a previously selected revision, e.g. by selecting one in Show Refs or from the recent commits section, the revision is put at the top, allowing users to continue by just hitting like before. --- src/item_data.rs | 7 +++++ src/ops/branch.rs | 28 +++++++++++-------- src/ops/merge.rs | 6 +--- src/tests/branch.rs | 10 +++++++ ...__tests__branch__switch_branch_picker.snap | 25 +++++++++++++++++ ...witch_branch_selected_revision_picker.snap | 25 +++++++++++++++++ ...ts__editor__re_enter_prompt_from_menu.snap | 14 +++++----- 7 files changed, 92 insertions(+), 23 deletions(-) create mode 100644 src/tests/snapshots/gitu__tests__branch__switch_branch_picker.snap create mode 100644 src/tests/snapshots/gitu__tests__branch__switch_branch_selected_revision_picker.snap diff --git a/src/item_data.rs b/src/item_data.rs index 5bd6f54488..f8735b1ad0 100644 --- a/src/item_data.rs +++ b/src/item_data.rs @@ -59,6 +59,13 @@ impl ItemData { | ItemData::BranchStatus(_, _, _) ) } + + pub(crate) fn to_ref_kind(&self) -> Option { + match self { + Self::Reference { kind, .. } => Some(kind.clone()), + _ => None, + } + } } impl Default for ItemData { diff --git a/src/ops/branch.rs b/src/ops/branch.rs index 825234982d..4f60458944 100644 --- a/src/ops/branch.rs +++ b/src/ops/branch.rs @@ -1,4 +1,4 @@ -use super::{Action, OpTrait, selected_rev}; +use super::{Action, OpTrait}; use crate::{ Res, app::{App, PromptParams, State}, @@ -9,6 +9,7 @@ use crate::{ }, item_data::{ItemData, RefKind}, menu::arg::Arg, + picker::{BranchesAndTagsOptions, PickerState}, term::Term, }; use std::{process::Command, rc::Rc}; @@ -19,19 +20,24 @@ pub(crate) fn init_args() -> Vec { pub(crate) struct Checkout; impl OpTrait for Checkout { - fn get_action(&self, _target: &ItemData) -> Option { + fn get_action(&self, target: &ItemData) -> Option { + let default_ref = target.to_ref_kind(); + Some(Rc::new(move |app: &mut App, term: &mut Term| { - let rev = app.prompt( - term, - &PromptParams { - prompt: "Checkout", - create_default_value: Box::new(selected_rev), - ..Default::default() + // Allow custom input to support checking out other revisions not in the list + let picker = PickerState::for_branches_and_tags( + "Checkout", + &app.state.repo, + BranchesAndTagsOptions { + exclude_head: true, + allow_custom_input: true, + default: default_ref.clone(), }, )?; - - checkout(app, term, &rev)?; - Ok(()) + match app.picker(term, picker)? { + Some(data) => checkout(app, term, data.display()), + None => Ok(()), // picker got cancelled + } })) } diff --git a/src/ops/merge.rs b/src/ops/merge.rs index 963eec37b9..56d0cceeb7 100644 --- a/src/ops/merge.rs +++ b/src/ops/merge.rs @@ -68,11 +68,7 @@ pub(crate) struct Merge; impl OpTrait for Merge { fn get_action(&self, target: &ItemData) -> Option { // Extract default ref from target if it's a Reference - let default_ref = if let ItemData::Reference { kind, .. } = target { - Some(kind.clone()) - } else { - None - }; + let default_ref = target.to_ref_kind(); Some(Rc::new(move |app: &mut App, term: &mut Term| { // Allow custom input to support commit hashes, relative refs (e.g., HEAD~3), diff --git a/src/tests/branch.rs b/src/tests/branch.rs index ce189cf4d1..7812646bf7 100644 --- a/src/tests/branch.rs +++ b/src/tests/branch.rs @@ -23,6 +23,16 @@ fn switch_branch_input() { snapshot!(setup(setup_clone!()), "Ybbmerged"); } +#[test] +fn switch_branch_picker() { + snapshot!(setup(setup_clone!()), "bb"); +} + +#[test] +fn switch_branch_selected_revision_picker() { + snapshot!(setup(setup_clone!()), "Yjjbb"); +} + #[test] fn checkout_new_branch() { snapshot!(setup(setup_clone!()), "bcnew"); diff --git a/src/tests/snapshots/gitu__tests__branch__switch_branch_picker.snap b/src/tests/snapshots/gitu__tests__branch__switch_branch_picker.snap new file mode 100644 index 0000000000..0e7ba2891d --- /dev/null +++ b/src/tests/snapshots/gitu__tests__branch__switch_branch_picker.snap @@ -0,0 +1,25 @@ +--- +source: src/tests/branch.rs +expression: ctx.redact_buffer() +--- + On branch main | + Your branch is up to date with 'origin/main'. | + | + Recent commits | + b66a0bf main merged origin/main add initial-file | + | + | + | +────────────────────────────────────────────────────────────────────────────────| + 4/4 Checkout › █ | +▌merged | + unmerged | + origin/HEAD | + origin/main | + | + | + | + | + | + | +styles_hash: a64f5b0f5c87b3e5 diff --git a/src/tests/snapshots/gitu__tests__branch__switch_branch_selected_revision_picker.snap b/src/tests/snapshots/gitu__tests__branch__switch_branch_selected_revision_picker.snap new file mode 100644 index 0000000000..307126511c --- /dev/null +++ b/src/tests/snapshots/gitu__tests__branch__switch_branch_selected_revision_picker.snap @@ -0,0 +1,25 @@ +--- +source: src/tests/branch.rs +expression: ctx.redact_buffer() +--- + Branches | + * main | + merged | + unmerged | + | + Remote origin | + origin/HEAD | + origin/main | +────────────────────────────────────────────────────────────────────────────────| + 4/4 Checkout › █ | +▌merged | + unmerged | + origin/HEAD | + origin/main | + | + | + | + | + | + | +styles_hash: 2cac6f3d25b0271c diff --git a/src/tests/snapshots/gitu__tests__editor__re_enter_prompt_from_menu.snap b/src/tests/snapshots/gitu__tests__editor__re_enter_prompt_from_menu.snap index 225503bda1..eea969d927 100644 --- a/src/tests/snapshots/gitu__tests__editor__re_enter_prompt_from_menu.snap +++ b/src/tests/snapshots/gitu__tests__editor__re_enter_prompt_from_menu.snap @@ -2,14 +2,18 @@ source: src/tests/editor.rs expression: ctx.redact_buffer() --- -▌On branch main | -▌Your branch is up to date with 'origin/main'. | + On branch main | + Your branch is up to date with 'origin/main'. | | Recent commits | b66a0bf main origin/main add initial-file | | | | +────────────────────────────────────────────────────────────────────────────────| + 2/2 Checkout › █ | +▌origin/HEAD | + origin/main | | | | @@ -18,8 +22,4 @@ expression: ctx.redact_buffer() | | | - | - | -────────────────────────────────────────────────────────────────────────────────| -? Checkout: › █ | -styles_hash: 43da189f53b3be3d +styles_hash: 49e995be810c0237 From 0c21c2091664eb9214a4ebf24574937423ca10fb Mon Sep 17 00:00:00 2001 From: Marcel Gotsch Date: Tue, 3 Feb 2026 08:39:16 +0100 Subject: [PATCH 4/4] feat: ability to pick the starting point of a new branch A picker will allow the user to select a starting point of a new branch from the list of known branches. The current branch is at the top and selected by default, so that the default workflow requires just a single to continue. The branches are sorted so that local branches appear before remote branches. The picker also allows entering any custom revision as starting point. Unlike the checkout picker, this picker doesn't use the selected revision as default, but always the current branch. --- src/ops/branch.rs | 30 ++++++++++++++++--- src/tests/branch.rs | 17 ++++++++++- ...u__tests__branch__checkout_new_branch.snap | 4 +-- ...anch__checkout_new_branch_name_prompt.snap | 25 ++++++++++++++++ ...kout_new_branch_starting_point_picker.snap | 25 ++++++++++++++++ ...arting_point_picker_from_selected_rev.snap | 25 ++++++++++++++++ 6 files changed, 119 insertions(+), 7 deletions(-) create mode 100644 src/tests/snapshots/gitu__tests__branch__checkout_new_branch_name_prompt.snap create mode 100644 src/tests/snapshots/gitu__tests__branch__checkout_new_branch_starting_point_picker.snap create mode 100644 src/tests/snapshots/gitu__tests__branch__checkout_new_branch_starting_point_picker_from_selected_rev.snap diff --git a/src/ops/branch.rs b/src/ops/branch.rs index 4f60458944..10c4c55fe8 100644 --- a/src/ops/branch.rs +++ b/src/ops/branch.rs @@ -59,15 +59,32 @@ pub(crate) struct CheckoutNewBranch; impl OpTrait for CheckoutNewBranch { fn get_action(&self, _target: &ItemData) -> Option { Some(Rc::new(|app: &mut App, term: &mut Term| { + let start_point_picker = PickerState::for_branches_and_tags( + "Create branch starting at", + &app.state.repo, + BranchesAndTagsOptions { + exclude_head: false, + allow_custom_input: true, + default: { + let head = app.state.repo.head().map_err(Error::GetHead)?; + RefKind::from_reference(&head) + }, + }, + )?; + + let Some(starting_point) = app.picker(term, start_point_picker)? else { + return Ok(()); + }; + let branch_name = app.prompt( term, &PromptParams { - prompt: "Create and checkout branch:", + prompt: "Create and checkout branch", ..Default::default() }, )?; - checkout_new_branch_prompt_update(app, term, &branch_name)?; + checkout_new_branch_prompt_update(app, term, &branch_name, starting_point.display())?; Ok(()) })) } @@ -77,9 +94,14 @@ impl OpTrait for CheckoutNewBranch { } } -fn checkout_new_branch_prompt_update(app: &mut App, term: &mut Term, branch_name: &str) -> Res<()> { +fn checkout_new_branch_prompt_update( + app: &mut App, + term: &mut Term, + branch_name: &str, + starting_point: &str, +) -> Res<()> { let mut cmd = Command::new("git"); - cmd.args(["checkout", "-b", branch_name]); + cmd.args(["checkout", "-b", branch_name, starting_point]); app.close_menu(); app.run_cmd(term, &[], cmd)?; diff --git a/src/tests/branch.rs b/src/tests/branch.rs index 7812646bf7..cc52b7b1f4 100644 --- a/src/tests/branch.rs +++ b/src/tests/branch.rs @@ -33,9 +33,24 @@ fn switch_branch_selected_revision_picker() { snapshot!(setup(setup_clone!()), "Yjjbb"); } +#[test] +fn checkout_new_branch_starting_point_picker() { + snapshot!(setup(setup_clone!()), "bc"); +} + +#[test] +fn checkout_new_branch_starting_point_picker_from_selected_rev() { + snapshot!(setup(setup_clone!()), "Yjjbc"); +} + +#[test] +fn checkout_new_branch_name_prompt() { + snapshot!(setup(setup_clone!()), "bc"); +} + #[test] fn checkout_new_branch() { - snapshot!(setup(setup_clone!()), "bcnew"); + snapshot!(setup(setup_clone!()), "bcnew"); } #[test] diff --git a/src/tests/snapshots/gitu__tests__branch__checkout_new_branch.snap b/src/tests/snapshots/gitu__tests__branch__checkout_new_branch.snap index 3b0cc6f1ef..1322791533 100644 --- a/src/tests/snapshots/gitu__tests__branch__checkout_new_branch.snap +++ b/src/tests/snapshots/gitu__tests__branch__checkout_new_branch.snap @@ -20,6 +20,6 @@ expression: ctx.redact_buffer() | | ────────────────────────────────────────────────────────────────────────────────| -$ git checkout -b new | +$ git checkout -b new main | Switched to a new branch 'new' | -styles_hash: 2afc72138214b087 +styles_hash: 51d00da50a627344 diff --git a/src/tests/snapshots/gitu__tests__branch__checkout_new_branch_name_prompt.snap b/src/tests/snapshots/gitu__tests__branch__checkout_new_branch_name_prompt.snap new file mode 100644 index 0000000000..335fb34ee4 --- /dev/null +++ b/src/tests/snapshots/gitu__tests__branch__checkout_new_branch_name_prompt.snap @@ -0,0 +1,25 @@ +--- +source: src/tests/branch.rs +expression: ctx.redact_buffer() +--- +▌On branch main | +▌Your branch is up to date with 'origin/main'. | + | + Recent commits | + b66a0bf main merged origin/main add initial-file | + | + | + | + | + | + | + | + | + | + | + | + | + | +────────────────────────────────────────────────────────────────────────────────| +? Create and checkout branch: › █ | +styles_hash: 5475e2883a3b1774 diff --git a/src/tests/snapshots/gitu__tests__branch__checkout_new_branch_starting_point_picker.snap b/src/tests/snapshots/gitu__tests__branch__checkout_new_branch_starting_point_picker.snap new file mode 100644 index 0000000000..8a9cd88b8c --- /dev/null +++ b/src/tests/snapshots/gitu__tests__branch__checkout_new_branch_starting_point_picker.snap @@ -0,0 +1,25 @@ +--- +source: src/tests/branch.rs +expression: ctx.redact_buffer() +--- + On branch main | + Your branch is up to date with 'origin/main'. | + | + Recent commits | + b66a0bf main merged origin/main add initial-file | + | + | + | +────────────────────────────────────────────────────────────────────────────────| + 5/5 Create branch starting at › █ | +▌main | + merged | + unmerged | + origin/HEAD | + origin/main | + | + | + | + | + | +styles_hash: 106946388e06558f diff --git a/src/tests/snapshots/gitu__tests__branch__checkout_new_branch_starting_point_picker_from_selected_rev.snap b/src/tests/snapshots/gitu__tests__branch__checkout_new_branch_starting_point_picker_from_selected_rev.snap new file mode 100644 index 0000000000..1608419f41 --- /dev/null +++ b/src/tests/snapshots/gitu__tests__branch__checkout_new_branch_starting_point_picker_from_selected_rev.snap @@ -0,0 +1,25 @@ +--- +source: src/tests/branch.rs +expression: ctx.redact_buffer() +--- + Branches | + * main | + merged | + unmerged | + | + Remote origin | + origin/HEAD | + origin/main | +────────────────────────────────────────────────────────────────────────────────| + 5/5 Create branch starting at › █ | +▌main | + merged | + unmerged | + origin/HEAD | + origin/main | + | + | + | + | + | +styles_hash: e48ad585ed1f40a5