Support choosing checkout branch method when status is not empty (#2494)

This commit is contained in:
Fatpandac 2025-11-29 05:36:40 +08:00 committed by GitHub
parent 0cce6907a2
commit 3082396bf1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 383 additions and 33 deletions

View file

@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* increase MSRV from 1.81 to 1.82 [[@cruessler](https://github.com/cruessler)]
### Added
* support choosing checkout branch method when status is not empty [[@fatpandac](https://github.com/fatpandac)] ([#2404](https://github.com/extrawurst/gitui/issues/2404))
* Support pre-push hook [[@xlai89](https://github.com/xlai89)] ([#1933](https://github.com/extrawurst/gitui/issues/1933))
* Message tab supports pageUp and pageDown [[@xlai89](https://github.com/xlai89)] ([#2623](https://github.com/extrawurst/gitui/issues/2623))
* Files and status tab support pageUp and pageDown [[@fatpandac](https://github.com/fatpandac)] ([#1951](https://github.com/extrawurst/gitui/issues/1951))

View file

@ -104,6 +104,11 @@ impl BranchInfo {
None
}
/// returns whether branch is local
pub const fn is_local(&self) -> bool {
matches!(self.details, BranchDetails::Local(_))
}
}
///

View file

@ -281,20 +281,59 @@ pub fn get_status(
Ok(res)
}
/// discard all changes in the working directory
pub fn discard_status(repo_path: &RepoPath) -> Result<bool> {
let repo = repo(repo_path)?;
let commit = repo.head()?.peel_to_commit()?;
repo.reset(commit.as_object(), git2::ResetType::Hard, None)?;
Ok(true)
}
#[cfg(test)]
mod tests {
use std::{fs::File, io::Write, path::Path};
use tempfile::TempDir;
use super::*;
use crate::{
sync::{
commit, stage_add_file,
status::{get_status, StatusType},
tests::repo_init_bare,
tests::{repo_init, repo_init_bare},
RepoPath,
},
StatusItem, StatusItemType,
};
use std::{fs::File, io::Write, path::Path};
use tempfile::TempDir;
#[test]
fn test_discard_status() {
let file_path = Path::new("README.md");
let (_td, repo) = repo_init().unwrap();
let root = repo.path().parent().unwrap();
let repo_path: &RepoPath =
&root.as_os_str().to_str().unwrap().into();
let mut file = File::create(root.join(file_path)).unwrap();
// initial commit
stage_add_file(repo_path, file_path).unwrap();
commit(repo_path, "commit msg").unwrap();
writeln!(file, "Test for discard_status").unwrap();
let statuses =
get_status(repo_path, StatusType::WorkingDir, None)
.unwrap();
assert_eq!(statuses.len(), 1);
discard_status(repo_path).unwrap();
let statuses =
get_status(repo_path, StatusType::WorkingDir, None)
.unwrap();
assert_eq!(statuses.len(), 0);
}
#[test]
fn test_get_status_with_workdir() {

View file

@ -11,16 +11,16 @@ use crate::{
options::{Options, SharedOptions},
popup_stack::PopupStack,
popups::{
AppOption, BlameFilePopup, BranchListPopup, CommitPopup,
CompareCommitsPopup, ConfirmPopup, CreateBranchPopup,
CreateRemotePopup, ExternalEditorPopup, FetchPopup,
FileRevlogPopup, FuzzyFindPopup, GotoLinePopup, HelpPopup,
InspectCommitPopup, LogSearchPopupPopup, MsgPopup,
OptionsPopup, PullPopup, PushPopup, PushTagsPopup,
RemoteListPopup, RenameBranchPopup, RenameRemotePopup,
ResetPopup, RevisionFilesPopup, StashMsgPopup,
SubmodulesListPopup, TagCommitPopup, TagListPopup,
UpdateRemoteUrlPopup,
AppOption, BlameFilePopup, BranchListPopup,
CheckoutOptionPopup, CommitPopup, CompareCommitsPopup,
ConfirmPopup, CreateBranchPopup, CreateRemotePopup,
ExternalEditorPopup, FetchPopup, FileRevlogPopup,
FuzzyFindPopup, GotoLinePopup, HelpPopup, InspectCommitPopup,
LogSearchPopupPopup, MsgPopup, OptionsPopup, PullPopup,
PushPopup, PushTagsPopup, RemoteListPopup, RenameBranchPopup,
RenameRemotePopup, ResetPopup, RevisionFilesPopup,
StashMsgPopup, SubmodulesListPopup, TagCommitPopup,
TagListPopup, UpdateRemoteUrlPopup,
},
queue::{
Action, AppTabs, InternalEvent, NeedsUpdate, Queue,
@ -99,6 +99,7 @@ pub struct App {
submodule_popup: SubmodulesListPopup,
tags_popup: TagListPopup,
reset_popup: ResetPopup,
checkout_option_popup: CheckoutOptionPopup,
cmdbar: RefCell<CommandBar>,
tab: usize,
revlog: Revlog,
@ -234,6 +235,7 @@ impl App {
stashing_tab: Stashing::new(&env),
stashlist_tab: StashList::new(&env),
files_tab: FilesTab::new(&env, select_file),
checkout_option_popup: CheckoutOptionPopup::new(&env),
goto_line_popup: GotoLinePopup::new(&env),
tab: 0,
queue: env.queue,
@ -511,6 +513,7 @@ impl App {
fetch_popup,
tag_commit_popup,
reset_popup,
checkout_option_popup,
create_branch_popup,
create_remote_popup,
rename_remote_popup,
@ -551,6 +554,7 @@ impl App {
submodule_popup,
tags_popup,
reset_popup,
checkout_option_popup,
create_branch_popup,
rename_branch_popup,
revision_files_popup,
@ -932,6 +936,9 @@ impl App {
self.blame_file_popup.goto_line(line);
}
}
InternalEvent::CheckoutOption(branch) => {
self.checkout_option_popup.open(branch)?;
}
}
Ok(flags)

View file

@ -20,8 +20,9 @@ use asyncgit::{
checkout_remote_branch, BranchDetails, LocalBranch,
RemoteBranch,
},
checkout_branch, get_branches_info, BranchInfo, BranchType,
CommitId, RepoPathRef, RepoState,
checkout_branch, get_branches_info,
status::StatusType,
BranchInfo, BranchType, CommitId, RepoPathRef, RepoState,
},
AsyncGitNotification,
};
@ -582,22 +583,35 @@ impl BranchListPopup {
anyhow::bail!("no valid branch selected");
}
if self.local {
checkout_branch(
&self.repo.borrow(),
&self.branches[self.selection as usize].name,
)?;
self.hide();
} else {
checkout_remote_branch(
&self.repo.borrow(),
&self.branches[self.selection as usize],
)?;
self.local = true;
self.update_branches()?;
}
let status = sync::status::get_status(
&self.repo.borrow(),
StatusType::WorkingDir,
None,
)
.expect("Could not get status");
self.queue.push(InternalEvent::Update(NeedsUpdate::ALL));
let selected_branch = &self.branches[self.selection as usize];
if status.is_empty() {
if self.local {
checkout_branch(
&self.repo.borrow(),
&selected_branch.name,
)?;
self.hide();
} else {
checkout_remote_branch(
&self.repo.borrow(),
selected_branch,
)?;
self.local = true;
self.update_branches()?;
}
self.queue.push(InternalEvent::Update(NeedsUpdate::ALL));
} else {
self.queue.push(InternalEvent::CheckoutOption(
selected_branch.clone(),
));
}
Ok(())
}

View file

@ -0,0 +1,239 @@
use crate::components::{
visibility_blocking, CommandBlocking, CommandInfo, Component,
DrawableComponent, EventState,
};
use crate::queue::{InternalEvent, NeedsUpdate};
use crate::strings::CheckoutOptions;
use crate::try_or_popup;
use crate::{
app::Environment,
keys::{key_match, SharedKeyConfig},
queue::Queue,
strings,
ui::{self, style::SharedTheme},
};
use anyhow::{Ok, Result};
use asyncgit::sync::branch::checkout_remote_branch;
use asyncgit::sync::status::discard_status;
use asyncgit::sync::{checkout_branch, BranchInfo, RepoPath};
use crossterm::event::Event;
use ratatui::{
layout::{Alignment, Rect},
text::{Line, Span},
widgets::{Block, Borders, Clear, Paragraph},
Frame,
};
pub struct CheckoutOptionPopup {
queue: Queue,
repo: RepoPath,
branch: Option<BranchInfo>,
option: CheckoutOptions,
visible: bool,
key_config: SharedKeyConfig,
theme: SharedTheme,
}
impl CheckoutOptionPopup {
///
pub fn new(env: &Environment) -> Self {
Self {
queue: env.queue.clone(),
repo: env.repo.borrow().clone(),
branch: None,
option: CheckoutOptions::KeepLocalChanges,
visible: false,
key_config: env.key_config.clone(),
theme: env.theme.clone(),
}
}
fn get_text(&self, _width: u16) -> Vec<Line<'_>> {
let mut txt: Vec<Line> = Vec::with_capacity(10);
txt.push(Line::from(vec![
Span::styled(
String::from("Switch to: "),
self.theme.text(true, false),
),
Span::styled(
self.branch.as_ref().expect("No branch").name.clone(),
self.theme.commit_hash(false),
),
]));
let (kind_name, kind_desc) = self.option.to_string_pair();
txt.push(Line::from(vec![
Span::styled(
String::from("How: "),
self.theme.text(true, false),
),
Span::styled(kind_name, self.theme.text(true, true)),
Span::styled(kind_desc, self.theme.text(true, false)),
]));
txt
}
///
pub fn open(&mut self, branch: BranchInfo) -> Result<()> {
self.show()?;
self.branch = Some(branch);
Ok(())
}
fn checkout(&self) -> Result<()> {
if let Some(branch) = &self.branch {
if branch.is_local() {
checkout_branch(&self.repo, &branch.name)?;
} else {
checkout_remote_branch(&self.repo, branch)?;
}
}
Ok(())
}
fn handle_event(&mut self) -> Result<()> {
match self.option {
CheckoutOptions::KeepLocalChanges => {
self.checkout()?;
}
CheckoutOptions::DiscardAllLocalChagnes => {
discard_status(&self.repo)?;
self.checkout()?;
}
}
self.queue.push(InternalEvent::Update(NeedsUpdate::ALL));
self.queue.push(InternalEvent::SelectBranch);
self.hide();
Ok(())
}
fn change_kind(&mut self, incr: bool) {
self.option = if incr {
self.option.next()
} else {
self.option.previous()
};
}
}
impl DrawableComponent for CheckoutOptionPopup {
fn draw(&self, f: &mut Frame, area: Rect) -> Result<()> {
if self.is_visible() {
const SIZE: (u16, u16) = (55, 4);
let area =
ui::centered_rect_absolute(SIZE.0, SIZE.1, area);
let width = area.width;
f.render_widget(Clear, area);
f.render_widget(
Paragraph::new(self.get_text(width))
.block(
Block::default()
.borders(Borders::ALL)
.title(Span::styled(
"Checkout options",
self.theme.title(true),
))
.border_style(self.theme.block(true)),
)
.alignment(Alignment::Left),
area,
);
}
Ok(())
}
}
impl Component for CheckoutOptionPopup {
fn commands(
&self,
out: &mut Vec<CommandInfo>,
force_all: bool,
) -> CommandBlocking {
if self.is_visible() || force_all {
out.push(
CommandInfo::new(
strings::commands::close_popup(&self.key_config),
true,
true,
)
.order(1),
);
out.push(
CommandInfo::new(
strings::commands::reset_commit(&self.key_config),
true,
true,
)
.order(1),
);
out.push(
CommandInfo::new(
strings::commands::reset_type(&self.key_config),
true,
true,
)
.order(1),
);
}
visibility_blocking(self)
}
fn event(
&mut self,
event: &crossterm::event::Event,
) -> Result<EventState> {
if self.is_visible() {
if let Event::Key(key) = &event {
if key_match(key, self.key_config.keys.exit_popup) {
self.hide();
} else if key_match(
key,
self.key_config.keys.move_down,
) {
self.change_kind(true);
} else if key_match(key, self.key_config.keys.move_up)
{
self.change_kind(false);
} else if key_match(key, self.key_config.keys.enter) {
try_or_popup!(
self,
"checkout error:",
self.handle_event()
);
}
}
return Ok(EventState::Consumed);
}
Ok(EventState::NotConsumed)
}
fn is_visible(&self) -> bool {
self.visible
}
fn hide(&mut self) {
self.visible = false;
}
fn show(&mut self) -> Result<()> {
self.visible = true;
Ok(())
}
}

View file

@ -1,5 +1,6 @@
mod blame_file;
mod branchlist;
mod checkout_option;
mod commit;
mod compare_commits;
mod confirm;
@ -31,6 +32,7 @@ mod update_remote_url;
pub use blame_file::{BlameFileOpen, BlameFilePopup};
pub use branchlist::BranchListPopup;
pub use checkout_option::CheckoutOptionPopup;
pub use commit::CommitPopup;
pub use compare_commits::CompareCommitsPopup;
pub use confirm::ConfirmPopup;

View file

@ -8,7 +8,8 @@ use crate::{
};
use asyncgit::{
sync::{
diff::DiffLinePosition, CommitId, LogFilterSearchOptions,
diff::DiffLinePosition, BranchInfo, CommitId,
LogFilterSearchOptions,
},
PushType,
};
@ -161,6 +162,8 @@ pub enum InternalEvent {
OpenGotoLinePopup(usize),
///
GotoLine(usize),
///
CheckoutOption(BranchInfo),
}
/// single threaded simple queue for components to communicate with each other

View file

@ -438,6 +438,46 @@ pub fn ellipsis_trim_start(s: &str, width: usize) -> Cow<'_, str> {
}
}
#[derive(PartialEq, Eq, Clone, Copy)]
pub enum CheckoutOptions {
KeepLocalChanges,
DiscardAllLocalChagnes,
}
impl CheckoutOptions {
pub const fn previous(self) -> Self {
match self {
Self::KeepLocalChanges => Self::DiscardAllLocalChagnes,
Self::DiscardAllLocalChagnes => Self::KeepLocalChanges,
}
}
pub const fn next(self) -> Self {
match self {
Self::KeepLocalChanges => Self::DiscardAllLocalChagnes,
Self::DiscardAllLocalChagnes => Self::KeepLocalChanges,
}
}
pub const fn to_string_pair(
self,
) -> (&'static str, &'static str) {
const CHECKOUT_OPTION_UNCHANGE: &str =
" 🟡 Keep local changes";
const CHECKOUT_OPTION_DISCARD: &str =
" 🔴 Discard all local changes";
match self {
Self::KeepLocalChanges => {
("Don't change", CHECKOUT_OPTION_UNCHANGE)
}
Self::DiscardAllLocalChagnes => {
("Discard", CHECKOUT_OPTION_DISCARD)
}
}
}
}
pub mod commit {
use crate::keys::SharedKeyConfig;