From 5185f1c4d488f6d15eaae8939183fda12e21f425 Mon Sep 17 00:00:00 2001 From: Stephan Dilly Date: Sat, 13 Jun 2020 01:20:53 +0200 Subject: [PATCH] support commit amend (#89) --- CHANGELOG.md | 3 + asyncgit/src/sync/commit.rs | 121 ++++++++++++++++++++++++++++ asyncgit/src/sync/commit_details.rs | 9 +++ asyncgit/src/sync/mod.rs | 6 +- asyncgit/src/sync/utils.rs | 6 ++ src/components/commit.rs | 62 ++++++++++++-- src/components/textinput.rs | 16 +++- src/keys.rs | 2 + src/strings.rs | 7 ++ 9 files changed, 220 insertions(+), 12 deletions(-) create mode 100644 asyncgit/src/sync/commit.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 08c15668..71934754 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- Commit Amend (`ctrl+a`) when in commit popup ([#89](https://github.com/extrawurst/gitui/issues/89)) + ### Changed - file trees: `arrow-right` on expanded folder moves down into folder - better scrolling in diff ([#52](https://github.com/extrawurst/gitui/issues/52)) diff --git a/asyncgit/src/sync/commit.rs b/asyncgit/src/sync/commit.rs new file mode 100644 index 00000000..d39e6edf --- /dev/null +++ b/asyncgit/src/sync/commit.rs @@ -0,0 +1,121 @@ +use super::{utils::repo, CommitId}; +use crate::error::Result; +use scopetime::scope_time; + +/// +pub fn get_head(repo_path: &str) -> Result { + scope_time!("get_head"); + + let repo = repo(repo_path)?; + + let head_id = repo.head()?.target().expect("head target error"); + + Ok(CommitId::new(head_id)) +} + +/// +pub fn amend( + repo_path: &str, + id: CommitId, + msg: &str, +) -> Result { + scope_time!("commit"); + + let repo = repo(repo_path)?; + let commit = repo.find_commit(id.into())?; + + let mut index = repo.index()?; + let tree_id = index.write_tree()?; + let tree = repo.find_tree(tree_id)?; + + let new_id = commit.amend( + Some("HEAD"), + None, + None, + None, + Some(msg), + Some(&tree), + )?; + + Ok(CommitId::new(new_id)) +} + +#[cfg(test)] +mod tests { + + use crate::error::Result; + use crate::sync::{ + commit, get_commit_details, get_commit_files, stage_add_file, + tests::{repo_init, repo_init_empty}, + CommitId, LogWalker, + }; + use commit::{amend, get_head}; + use git2::Repository; + use std::{fs::File, io::Write, path::Path}; + + fn count_commits(repo: &Repository, max: usize) -> usize { + let mut items = Vec::new(); + let mut walk = LogWalker::new(&repo); + walk.read(&mut items, max).unwrap(); + items.len() + } + + #[test] + fn test_amend() -> Result<()> { + let file_path1 = Path::new("foo"); + let file_path2 = Path::new("foo2"); + let (_td, repo) = repo_init_empty()?; + let root = repo.path().parent().unwrap(); + let repo_path = root.as_os_str().to_str().unwrap(); + + File::create(&root.join(file_path1))?.write_all(b"test1")?; + + stage_add_file(repo_path, file_path1)?; + let id = commit(repo_path, "commit msg")?; + + assert_eq!(count_commits(&repo, 10), 1); + + File::create(&root.join(file_path2))?.write_all(b"test2")?; + + stage_add_file(repo_path, file_path2)?; + + let new_id = amend(repo_path, CommitId::new(id), "amended")?; + + assert_eq!(count_commits(&repo, 10), 1); + + let details = get_commit_details(repo_path, new_id)?; + assert_eq!(details.message.unwrap().subject, "amended"); + + let files = get_commit_files(repo_path, new_id)?; + + assert_eq!(files.len(), 2); + + let head = get_head(repo_path)?; + + assert_eq!(head, new_id); + + Ok(()) + } + + #[test] + fn test_head_empty() -> Result<()> { + let (_td, repo) = repo_init_empty()?; + let root = repo.path().parent().unwrap(); + let repo_path = root.as_os_str().to_str().unwrap(); + + assert_eq!(get_head(repo_path).is_ok(), false); + + Ok(()) + } + + #[test] + fn test_head() -> Result<()> { + let (_td, repo) = repo_init()?; + let root = repo.path().parent().unwrap(); + let repo_path = root.as_os_str().to_str().unwrap(); + + assert_eq!(get_head(repo_path).is_ok(), true); + + Ok(()) + } +} diff --git a/asyncgit/src/sync/commit_details.rs b/asyncgit/src/sync/commit_details.rs index 36d439d6..fd806988 100644 --- a/asyncgit/src/sync/commit_details.rs +++ b/asyncgit/src/sync/commit_details.rs @@ -53,6 +53,15 @@ impl CommitMessage { } } } + + /// + pub fn combine(self) -> String { + if let Some(body) = self.body { + format!("{}{}", self.subject, body) + } else { + self.subject + } + } } /// diff --git a/asyncgit/src/sync/mod.rs b/asyncgit/src/sync/mod.rs index bf97b41e..46ce63f1 100644 --- a/asyncgit/src/sync/mod.rs +++ b/asyncgit/src/sync/mod.rs @@ -1,6 +1,7 @@ //! sync git api mod branch; +mod commit; mod commit_details; mod commit_files; mod commits_info; @@ -16,6 +17,7 @@ mod tags; pub mod utils; pub use branch::get_branch_name; +pub use commit::{amend, get_head}; pub use commit_details::{get_commit_details, CommitDetails}; pub use commit_files::get_commit_files; pub use commits_info::{get_commits_info, CommitId, CommitInfo}; @@ -28,8 +30,8 @@ pub use reset::{reset_stage, reset_workdir}; pub use stash::{get_stashes, stash_apply, stash_drop, stash_save}; pub use tags::{get_tags, Tags}; pub use utils::{ - commit, is_bare_repo, is_repo, stage_add_all, stage_add_file, - stage_addremoved, + commit, commit_new, is_bare_repo, is_repo, stage_add_all, + stage_add_file, stage_addremoved, }; #[cfg(test)] diff --git a/asyncgit/src/sync/utils.rs b/asyncgit/src/sync/utils.rs index fa7d3824..fdeb83d3 100644 --- a/asyncgit/src/sync/utils.rs +++ b/asyncgit/src/sync/utils.rs @@ -1,5 +1,6 @@ //! sync git api (various methods) +use super::CommitId; use crate::error::{Error, Result}; use git2::{IndexAddOption, Oid, Repository, RepositoryOpenFlags}; use scopetime::scope_time; @@ -46,6 +47,11 @@ pub fn work_dir(repo: &Repository) -> &Path { repo.workdir().expect("unable to query workdir") } +/// ditto +pub fn commit_new(repo_path: &str, msg: &str) -> Result { + commit(repo_path, msg).map(CommitId::new) +} + /// this does not run any git hooks pub fn commit(repo_path: &str, msg: &str) -> Result { scope_time!("commit"); diff --git a/src/components/commit.rs b/src/components/commit.rs index 6107a2df..a7d1737e 100644 --- a/src/components/commit.rs +++ b/src/components/commit.rs @@ -3,19 +3,24 @@ use super::{ CommandBlocking, CommandInfo, Component, DrawableComponent, }; use crate::{ + keys, queue::{InternalEvent, NeedsUpdate, Queue}, strings, ui::style::Theme, }; use anyhow::Result; -use asyncgit::{sync, CWD}; -use crossterm::event::{Event, KeyCode}; +use asyncgit::{ + sync::{self, CommitId}, + CWD, +}; +use crossterm::event::Event; use strings::commands; use sync::HookResult; use tui::{backend::Backend, layout::Rect, Frame}; pub struct CommitComponent { input: TextInputComponent, + amend: Option, queue: Queue, } @@ -42,8 +47,15 @@ impl Component for CommitComponent { out.push(CommandInfo::new( commands::COMMIT_ENTER, self.can_commit(), - self.is_visible(), + self.is_visible() || force_all, )); + + out.push(CommandInfo::new( + commands::COMMIT_AMEND, + self.can_amend(), + self.is_visible() || force_all, + )); + visibility_blocking(self) } @@ -54,11 +66,15 @@ impl Component for CommitComponent { } if let Event::Key(e) = ev { - match e.code { - KeyCode::Enter if self.can_commit() => { + match e { + keys::ENTER if self.can_commit() => { self.commit()?; } + keys::COMMIT_AMEND if self.can_amend() => { + self.amend()?; + } + _ => (), }; @@ -79,6 +95,10 @@ impl Component for CommitComponent { } fn show(&mut self) -> Result<()> { + self.amend = None; + + self.input.clear(); + self.input.set_title(strings::COMMIT_TITLE.into()); self.input.show()?; Ok(()) @@ -90,9 +110,10 @@ impl CommitComponent { pub fn new(queue: Queue, theme: &Theme) -> Self { Self { queue, + amend: None, input: TextInputComponent::new( theme, - strings::COMMIT_TITLE, + "", strings::COMMIT_MSG, ), } @@ -113,7 +134,12 @@ impl CommitComponent { return Ok(()); } - if let Err(e) = sync::commit(CWD, &msg) { + let res = if let Some(amend) = self.amend { + sync::amend(CWD, amend, &msg) + } else { + sync::commit_new(CWD, &msg) + }; + if let Err(e) = res { log::error!("commit error: {}", &e); self.queue.borrow_mut().push_back( InternalEvent::ShowErrorMsg(format!( @@ -134,7 +160,6 @@ impl CommitComponent { ); } - self.input.clear(); self.hide(); self.queue @@ -147,4 +172,25 @@ impl CommitComponent { fn can_commit(&self) -> bool { !self.input.get_text().is_empty() } + + fn can_amend(&self) -> bool { + self.amend.is_none() + && sync::get_head(CWD).is_ok() + && self.input.get_text().is_empty() + } + + fn amend(&mut self) -> Result<()> { + let id = sync::get_head(CWD)?; + self.amend = Some(id); + + let details = sync::get_commit_details(CWD, id)?; + + self.input.set_title(strings::COMMIT_TITLE_AMEND.into()); + + if let Some(msg) = details.message { + self.input.set_text(msg.combine()); + } + + Ok(()) + } } diff --git a/src/components/textinput.rs b/src/components/textinput.rs index a546d8a9..04a28d4b 100644 --- a/src/components/textinput.rs +++ b/src/components/textinput.rs @@ -7,7 +7,7 @@ use crate::{ ui::style::Theme, }; use anyhow::Result; -use crossterm::event::{Event, KeyCode}; +use crossterm::event::{Event, KeyCode, KeyModifiers}; use std::borrow::Cow; use strings::commands; use tui::{ @@ -52,6 +52,16 @@ impl TextInputComponent { pub const fn get_text(&self) -> &String { &self.msg } + + /// + pub fn set_text(&mut self, msg: String) { + self.msg = msg; + } + + /// + pub fn set_title(&mut self, t: String) { + self.title = t; + } } impl DrawableComponent for TextInputComponent { @@ -110,12 +120,14 @@ impl Component for TextInputComponent { fn event(&mut self, ev: Event) -> Result { if self.visible { if let Event::Key(e) = ev { + let is_ctrl = + e.modifiers.contains(KeyModifiers::CONTROL); match e.code { KeyCode::Esc => { self.hide(); return Ok(true); } - KeyCode::Char(c) => { + KeyCode::Char(c) if !is_ctrl => { self.msg.push(c); return Ok(true); } diff --git a/src/keys.rs b/src/keys.rs index fd4058e6..6a1720aa 100644 --- a/src/keys.rs +++ b/src/keys.rs @@ -61,3 +61,5 @@ pub const STASH_DROP: KeyEvent = with_mod(KeyCode::Char('D'), KeyModifiers::SHIFT); pub const CMD_BAR_TOGGLE: KeyEvent = no_mod(KeyCode::Char('.')); pub const LOG_COMMIT_DETAILS: KeyEvent = no_mod(KeyCode::Enter); +pub const COMMIT_AMEND: KeyEvent = + with_mod(KeyCode::Char('a'), KeyModifiers::CONTROL); diff --git a/src/strings.rs b/src/strings.rs index eb0cb97b..c75f515c 100644 --- a/src/strings.rs +++ b/src/strings.rs @@ -12,6 +12,7 @@ pub static CMD_SPLITTER: &str = " "; pub static MSG_TITLE_ERROR: &str = "Error"; pub static COMMIT_TITLE: &str = "Commit"; +pub static COMMIT_TITLE_AMEND: &str = "Commit (Amend)"; pub static COMMIT_MSG: &str = "type commit message.."; pub static STASH_POPUP_TITLE: &str = "Stash"; pub static STASH_POPUP_MSG: &str = "type name (optional)"; @@ -148,6 +149,12 @@ pub mod commands { CMD_GROUP_COMMIT, ); /// + pub static COMMIT_AMEND: CommandText = CommandText::new( + "Amend [^a]", + "amend last commit", + CMD_GROUP_COMMIT, + ); + /// pub static STAGE_ITEM: CommandText = CommandText::new( "Stage Item [enter]", "stage currently selected file or entire path",