From 6e5db96c19a24444cbf2c247bf5f2ae32e7a3a39 Mon Sep 17 00:00:00 2001 From: Stephan Dilly Date: Mon, 8 Mar 2021 18:00:30 +0100 Subject: [PATCH] support discard selected lines (#571) --- CHANGELOG.md | 1 + assets/vim_style_key_config.ron | 2 +- asyncgit/src/error.rs | 5 +- asyncgit/src/lib.rs | 1 + asyncgit/src/sync/branch/merge_commit.rs | 3 +- asyncgit/src/sync/branch/merge_ff.rs | 31 +- asyncgit/src/sync/diff.rs | 42 +- asyncgit/src/sync/hunks.rs | 11 +- asyncgit/src/sync/mod.rs | 26 +- asyncgit/src/sync/patches.rs | 74 ++++ asyncgit/src/sync/remotes/tags.rs | 34 +- asyncgit/src/sync/staging/mod.rs | 519 +++++++++++++++++++++++ src/app.rs | 4 + src/components/diff.rs | 57 ++- src/components/reset.rs | 4 + src/keys.rs | 2 + src/main.rs | 1 + src/queue.rs | 3 +- src/strings.rs | 23 +- 19 files changed, 763 insertions(+), 80 deletions(-) create mode 100644 asyncgit/src/sync/patches.rs create mode 100644 asyncgit/src/sync/staging/mod.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index b44b697d..75b7669c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased ### Added +- support discarding diff by lines ([#59](https://github.com/extrawurst/gitui/issues/59)) - support for pushing tags ([#568](https://github.com/extrawurst/gitui/issues/568)) ## [0.12.0] - 2020-03-03 diff --git a/assets/vim_style_key_config.ron b/assets/vim_style_key_config.ron index f7f6cfa6..c6a0cced 100644 --- a/assets/vim_style_key_config.ron +++ b/assets/vim_style_key_config.ron @@ -50,8 +50,8 @@ edit_file: ( code: Char('I'), modifiers: ( bits: 1,),), status_stage_all: ( code: Char('a'), modifiers: ( bits: 0,),), - status_reset_item: ( code: Char('U'), modifiers: ( bits: 1,),), + status_reset_lines: ( code: Char('u'), modifiers: ( bits: 0,),), status_ignore_file: ( code: Char('i'), modifiers: ( bits: 0,),), stashing_save: ( code: Char('w'), modifiers: ( bits: 0,),), diff --git a/asyncgit/src/error.rs b/asyncgit/src/error.rs index d502a5fd..0aebe1d8 100644 --- a/asyncgit/src/error.rs +++ b/asyncgit/src/error.rs @@ -1,4 +1,4 @@ -use std::string::FromUtf8Error; +use std::{num::TryFromIntError, string::FromUtf8Error}; use thiserror::Error; #[derive(Error, Debug)] @@ -26,6 +26,9 @@ pub enum Error { #[error("utf8 error:{0}")] Utf8Error(#[from] FromUtf8Error), + + #[error("TryFromInt error:{0}")] + IntError(#[from] TryFromIntError), } pub type Result = std::result::Result; diff --git a/asyncgit/src/lib.rs b/asyncgit/src/lib.rs index f17824bb..40ba1424 100644 --- a/asyncgit/src/lib.rs +++ b/asyncgit/src/lib.rs @@ -3,6 +3,7 @@ #![forbid(missing_docs)] #![deny(unsafe_code)] #![deny(unused_imports)] +#![deny(unused_must_use)] #![deny(clippy::all)] #![deny(clippy::unwrap_used)] #![deny(clippy::panic)] diff --git a/asyncgit/src/sync/branch/merge_commit.rs b/asyncgit/src/sync/branch/merge_commit.rs index 76f7c710..d8b1b9df 100644 --- a/asyncgit/src/sync/branch/merge_commit.rs +++ b/asyncgit/src/sync/branch/merge_commit.rs @@ -92,14 +92,13 @@ pub fn merge_upstream_commit( #[cfg(test)] mod test { - use super::super::merge_ff::test::write_commit_file; use super::*; use crate::sync::{ branch_compare_upstream, remotes::{fetch_origin, push::push}, tests::{ debug_cmd_print, get_commit_ids, repo_clone, - repo_init_bare, + repo_init_bare, write_commit_file, }, RepoState, }; diff --git a/asyncgit/src/sync/branch/merge_ff.rs b/asyncgit/src/sync/branch/merge_ff.rs index 586c769c..eb2973ea 100644 --- a/asyncgit/src/sync/branch/merge_ff.rs +++ b/asyncgit/src/sync/branch/merge_ff.rs @@ -49,41 +49,12 @@ pub fn branch_merge_upstream_fastforward( pub mod test { use super::*; use crate::sync::{ - commit, remotes::{fetch_origin, push::push}, - stage_add_file, tests::{ debug_cmd_print, get_commit_ids, repo_clone, - repo_init_bare, + repo_init_bare, write_commit_file, }, - CommitId, }; - use git2::Repository; - use std::{fs::File, io::Write, path::Path}; - - // write, stage and commit a file - pub fn write_commit_file( - repo: &Repository, - file: &str, - content: &str, - commit_name: &str, - ) -> CommitId { - File::create( - repo.workdir().unwrap().join(file).to_str().unwrap(), - ) - .unwrap() - .write_all(content.as_bytes()) - .unwrap(); - - stage_add_file( - repo.workdir().unwrap().to_str().unwrap(), - Path::new(file), - ) - .unwrap(); - - commit(repo.workdir().unwrap().to_str().unwrap(), commit_name) - .unwrap() - } #[test] fn test_merge_fastforward() { diff --git a/asyncgit/src/sync/diff.rs b/asyncgit/src/sync/diff.rs index 88c43ca7..5831f758 100644 --- a/asyncgit/src/sync/diff.rs +++ b/asyncgit/src/sync/diff.rs @@ -39,14 +39,41 @@ pub struct DiffLine { pub content: String, /// pub line_type: DiffLineType, + /// + pub position: DiffLinePosition, +} + +/// +#[derive(Clone, Copy, Default, Hash, Debug, PartialEq, Eq)] +pub struct DiffLinePosition { + /// + pub old_lineno: Option, + /// + pub new_lineno: Option, +} + +impl PartialEq<&git2::DiffLine<'_>> for DiffLinePosition { + fn eq(&self, other: &&git2::DiffLine) -> bool { + other.new_lineno() == self.new_lineno + && other.old_lineno() == self.old_lineno + } +} + +impl From<&git2::DiffLine<'_>> for DiffLinePosition { + fn from(line: &git2::DiffLine<'_>) -> Self { + Self { + old_lineno: line.old_lineno(), + new_lineno: line.new_lineno(), + } + } } #[derive(Debug, Default, Clone, Copy, PartialEq, Hash)] pub(crate) struct HunkHeader { - old_start: u32, - old_lines: u32, - new_start: u32, - new_lines: u32, + pub old_start: u32, + pub old_lines: u32, + pub new_start: u32, + pub new_lines: u32, } impl From> for HunkHeader { @@ -89,10 +116,14 @@ pub(crate) fn get_diff_raw<'a>( p: &str, stage: bool, reverse: bool, + context: Option, ) -> Result> { // scope_time!("get_diff_raw"); let mut opt = DiffOptions::new(); + if let Some(context) = context { + opt.context_lines(context); + } opt.pathspec(p); opt.reverse(reverse); @@ -133,7 +164,7 @@ pub fn get_diff( let repo = utils::repo(repo_path)?; let work_dir = work_dir(&repo)?; - let diff = get_diff_raw(&repo, &p, stage, false)?; + let diff = get_diff_raw(&repo, &p, stage, false, None)?; raw_diff_to_file_diff(&diff, work_dir) } @@ -209,6 +240,7 @@ fn raw_diff_to_file_diff<'a>( }; let diff_line = DiffLine { + position: DiffLinePosition::from(&line), content: String::from_utf8_lossy(line.content()) .to_string(), line_type, diff --git a/asyncgit/src/sync/hunks.rs b/asyncgit/src/sync/hunks.rs index 19cda65b..cf347bb8 100644 --- a/asyncgit/src/sync/hunks.rs +++ b/asyncgit/src/sync/hunks.rs @@ -19,7 +19,7 @@ pub fn stage_hunk( let repo = repo(repo_path)?; - let diff = get_diff_raw(&repo, &file_path, false, false)?; + let diff = get_diff_raw(&repo, &file_path, false, false, None)?; let mut opt = ApplyOptions::new(); opt.hunk_callback(|hunk| { @@ -46,7 +46,7 @@ pub fn reset_hunk( let repo = repo(repo_path)?; - let diff = get_diff_raw(&repo, &file_path, false, false)?; + let diff = get_diff_raw(&repo, &file_path, false, false, None)?; let hunk_index = find_hunk_index(&diff, hunk_hash); if let Some(hunk_index) = hunk_index { @@ -58,7 +58,8 @@ pub fn reset_hunk( res }); - let diff = get_diff_raw(&repo, &file_path, false, true)?; + let diff = + get_diff_raw(&repo, &file_path, false, true, None)?; repo.apply(&diff, ApplyLocation::WorkDir, Some(&mut opt))?; @@ -104,7 +105,7 @@ pub fn unstage_hunk( let repo = repo(repo_path)?; - let diff = get_diff_raw(&repo, &file_path, true, false)?; + let diff = get_diff_raw(&repo, &file_path, true, false, None)?; let diff_count_positive = diff.deltas().len(); let hunk_index = find_hunk_index(&diff, hunk_hash); @@ -113,7 +114,7 @@ pub fn unstage_hunk( Ok, )?; - let diff = get_diff_raw(&repo, &file_path, true, true)?; + let diff = get_diff_raw(&repo, &file_path, true, true, None)?; if diff.deltas().len() != diff_count_positive { return Err(Error::Generic(format!( diff --git a/asyncgit/src/sync/mod.rs b/asyncgit/src/sync/mod.rs index 2d96d265..cc425d7b 100644 --- a/asyncgit/src/sync/mod.rs +++ b/asyncgit/src/sync/mod.rs @@ -14,8 +14,10 @@ mod hooks; mod hunks; mod ignore; mod logwalker; +mod patches; pub mod remotes; mod reset; +mod staging; mod stash; mod state; pub mod status; @@ -47,6 +49,7 @@ pub use remotes::{ tags::PushTagsProgress, }; pub use reset::{reset_stage, reset_workdir}; +pub use staging::discard_lines; pub use stash::{get_stashes, stash_apply, stash_drop, stash_save}; pub use state::{repo_state, RepoState}; pub use tags::{get_tags, CommitTags, Tags}; @@ -58,12 +61,14 @@ pub use utils::{ #[cfg(test)] mod tests { use super::{ + commit, stage_add_file, + staging::repo_write_file, status::{get_status, StatusType}, CommitId, LogWalker, }; use crate::error::Result; use git2::Repository; - use std::process::Command; + use std::{path::Path, process::Command}; use tempfile::TempDir; /// Calling `set_search_path` with an empty directory makes sure that there @@ -88,6 +93,25 @@ mod tests { }); } + /// write, stage and commit a file + pub fn write_commit_file( + repo: &Repository, + file: &str, + content: &str, + commit_name: &str, + ) -> CommitId { + repo_write_file(repo, file, content).unwrap(); + + stage_add_file( + repo.workdir().unwrap().to_str().unwrap(), + Path::new(file), + ) + .unwrap(); + + commit(repo.workdir().unwrap().to_str().unwrap(), commit_name) + .unwrap() + } + /// pub fn repo_init_empty() -> Result<(TempDir, Repository)> { sandbox_config_files(); diff --git a/asyncgit/src/sync/patches.rs b/asyncgit/src/sync/patches.rs new file mode 100644 index 00000000..b9333aa5 --- /dev/null +++ b/asyncgit/src/sync/patches.rs @@ -0,0 +1,74 @@ +use super::diff::{get_diff_raw, HunkHeader}; +use crate::error::{Error, Result}; +use git2::{Diff, DiffLine, Patch, Repository}; + +// +pub(crate) struct HunkLines<'a> { + pub hunk: HunkHeader, + pub lines: Vec>, +} + +/// +pub(crate) fn get_file_diff_patch_and_hunklines<'a>( + repo: &'a Repository, + file: &str, + is_staged: bool, + reverse: bool, +) -> Result<(Patch<'a>, Vec>)> { + let diff = + get_diff_raw(&repo, file, is_staged, reverse, Some(1))?; + let patches = get_patches(&diff)?; + if patches.len() > 1 { + return Err(Error::Generic(String::from("patch error"))); + } + + let patch = patches.into_iter().next().ok_or_else(|| { + Error::Generic(String::from("no patch found")) + })?; + + let lines = patch_get_hunklines(&patch)?; + + Ok((patch, lines)) +} + +// +fn patch_get_hunklines<'a>( + patch: &Patch<'a>, +) -> Result>> { + let count_hunks = patch.num_hunks(); + let mut res = Vec::with_capacity(count_hunks); + for hunk_idx in 0..count_hunks { + let (hunk, _) = patch.hunk(hunk_idx)?; + + let count_lines = patch.num_lines_in_hunk(hunk_idx)?; + + let mut hunk = HunkLines { + hunk: HunkHeader::from(hunk), + lines: Vec::with_capacity(count_lines), + }; + + for line_idx in 0..count_lines { + let line = patch.line_in_hunk(hunk_idx, line_idx)?; + hunk.lines.push(line); + } + + res.push(hunk); + } + + Ok(res) +} + +// +fn get_patches<'a>(diff: &Diff<'a>) -> Result>> { + let count = diff.deltas().len(); + + let mut res = Vec::with_capacity(count); + for idx in 0..count { + let p = Patch::from_diff(&diff, idx)?; + if let Some(p) = p { + res.push(p); + } + } + + Ok(res) +} diff --git a/asyncgit/src/sync/remotes/tags.rs b/asyncgit/src/sync/remotes/tags.rs index e30021a6..bfb983a1 100644 --- a/asyncgit/src/sync/remotes/tags.rs +++ b/asyncgit/src/sync/remotes/tags.rs @@ -1,7 +1,5 @@ //! -use std::collections::HashSet; - use super::{ push::{remote_callbacks, AsyncProgress}, utils, @@ -13,6 +11,7 @@ use crate::{ use crossbeam_channel::Sender; use git2::{Direction, PushOptions}; use scopetime::scope_time; +use std::collections::HashSet; /// #[derive(Debug, Copy, Clone, PartialEq)] @@ -157,37 +156,8 @@ mod tests { self, remotes::{fetch_origin, push::push}, tests::{repo_clone, repo_init_bare}, - CommitId, }; - use git2::Repository; - use std::{fs::File, io::Write, path::Path}; - - // write, stage and commit a file - fn write_commit_file( - repo: &Repository, - file: &str, - content: &str, - commit_name: &str, - ) -> CommitId { - File::create( - repo.workdir().unwrap().join(file).to_str().unwrap(), - ) - .unwrap() - .write_all(content.as_bytes()) - .unwrap(); - - sync::stage_add_file( - repo.workdir().unwrap().to_str().unwrap(), - Path::new(file), - ) - .unwrap(); - - sync::commit( - repo.workdir().unwrap().to_str().unwrap(), - commit_name, - ) - .unwrap() - } + use sync::tests::write_commit_file; #[test] fn test_push_pull_tags() { diff --git a/asyncgit/src/sync/staging/mod.rs b/asyncgit/src/sync/staging/mod.rs new file mode 100644 index 00000000..c0039c8e --- /dev/null +++ b/asyncgit/src/sync/staging/mod.rs @@ -0,0 +1,519 @@ +use super::{ + diff::DiffLinePosition, + patches::{get_file_diff_patch_and_hunklines, HunkLines}, + utils::{repo, work_dir}, +}; +use crate::error::{Error, Result}; +use git2::{DiffLine, Repository}; +use scopetime::scope_time; +use std::{ + collections::HashSet, + convert::TryFrom, + fs::File, + io::{Read, Write}, +}; + +/// discards specific lines in an unstaged hunk of a diff +pub fn discard_lines( + repo_path: &str, + file_path: &str, + lines: &[DiffLinePosition], +) -> Result<()> { + scope_time!("discard_lines"); + + if lines.is_empty() { + return Ok(()); + } + + let repo = repo(repo_path)?; + + //TODO: check that file is not new (status modified) + let new_content = { + let (_patch, hunks) = get_file_diff_patch_and_hunklines( + &repo, file_path, false, false, + )?; + + let working_content = load_file(&repo, file_path)?; + let old_lines = working_content.lines().collect::>(); + + apply_selection(lines, &hunks, old_lines, false, true)? + }; + + repo_write_file(&repo, file_path, new_content.as_str())?; + + Ok(()) +} + +#[derive(Default)] +struct NewFromOldContent { + lines: Vec, + old_index: usize, +} + +impl NewFromOldContent { + fn add_from_hunk(&mut self, line: &DiffLine) -> Result<()> { + let line = String::from_utf8(line.content().into())?; + + let line = if line.ends_with('\n') { + line[0..line.len() - 1].to_string() + } else { + line + }; + + self.lines.push(line); + + Ok(()) + } + + fn skip_old_line(&mut self) { + self.old_index += 1; + } + + fn add_old_line(&mut self, old_lines: &[&str]) { + self.lines.push(old_lines[self.old_index].to_string()); + self.old_index += 1; + } + + fn catchup_to_hunkstart( + &mut self, + hunk_start: usize, + old_lines: &[&str], + ) { + while hunk_start > self.old_index + 1 { + self.add_old_line(old_lines); + } + } + + fn finish(mut self, old_lines: &[&str]) -> String { + for line in old_lines.iter().skip(self.old_index) { + self.lines.push(line.to_string()); + } + let lines = self.lines.join("\n"); + if lines.ends_with('\n') { + lines + } else { + let mut lines = lines; + lines.push('\n'); + lines + } + } +} + +// this is the heart of the per line discard,stage,unstage. heavily inspired by the great work in nodegit: https://github.com/nodegit/nodegit +fn apply_selection( + lines: &[DiffLinePosition], + hunks: &[HunkLines], + old_lines: Vec<&str>, + is_staged: bool, + reverse: bool, +) -> Result { + let mut new_content = NewFromOldContent::default(); + let lines = lines.iter().collect::>(); + + let char_added = if reverse { '-' } else { '+' }; + let char_deleted = if reverse { '+' } else { '-' }; + + let mut first_hunk_encountered = false; + for hunk in hunks { + let hunk_start = if is_staged || reverse { + usize::try_from(hunk.hunk.new_start)? + } else { + usize::try_from(hunk.hunk.old_start)? + }; + + if !first_hunk_encountered { + let any_slection_in_hunk = + hunk.lines.iter().any(|line| { + let line: DiffLinePosition = line.into(); + lines.contains(&line) + }); + + first_hunk_encountered = any_slection_in_hunk; + } + + if first_hunk_encountered { + // catchup until this hunk + new_content.catchup_to_hunkstart(hunk_start, &old_lines); + + for hunk_line in &hunk.lines { + let hunk_line_pos: DiffLinePosition = + hunk_line.into(); + let selected_line = lines.contains(&hunk_line_pos); + + log::debug!( + // println!( + "{} line: {} [{:?} old, {:?} new] -> {}", + if selected_line { "*" } else { " " }, + hunk_line.origin(), + hunk_line.old_lineno(), + hunk_line.new_lineno(), + String::from_utf8_lossy(hunk_line.content()) + .trim() + ); + + if hunk_line.origin() == '<' { + break; + } + + if (is_staged && !selected_line) + || (!is_staged && selected_line) + { + if hunk_line.origin() == char_added { + new_content.add_from_hunk(hunk_line)?; + if is_staged { + new_content.skip_old_line(); + } + } else if hunk_line.origin() == char_deleted { + if !is_staged { + new_content.skip_old_line(); + } + } else { + new_content.add_old_line(&old_lines); + } + } else { + if hunk_line.origin() != char_added { + new_content.add_from_hunk(hunk_line)?; + } + + if (is_staged + && hunk_line.origin() != char_deleted) + || (!is_staged + && hunk_line.origin() != char_added) + { + new_content.skip_old_line(); + } + } + } + } + } + + Ok(new_content.finish(&old_lines)) +} + +fn load_file(repo: &Repository, file_path: &str) -> Result { + let repo_path = work_dir(repo)?; + let mut file = File::open(repo_path.join(file_path).as_path())?; + let mut res = String::new(); + file.read_to_string(&mut res)?; + + Ok(res) +} + +//TODO: use this in unittests instead of the test specific one +/// write a file in repo +pub(crate) fn repo_write_file( + repo: &Repository, + file: &str, + content: &str, +) -> Result<()> { + let dir = work_dir(repo)?.join(file); + let file_path = dir.to_str().ok_or_else(|| { + Error::Generic(String::from("invalid file path")) + })?; + let mut file = File::create(file_path)?; + file.write_all(content.as_bytes())?; + Ok(()) +} + +#[cfg(test)] +mod test { + use super::*; + use crate::sync::tests::{repo_init, write_commit_file}; + + #[test] + fn test_discard() { + static FILE_1: &str = r"0 +1 +2 +3 +4 +"; + + static FILE_2: &str = r"0 + + +3 +4 +"; + + static FILE_3: &str = r"0 +2 + +3 +4 +"; + + let (path, repo) = repo_init().unwrap(); + let path = path.path().to_str().unwrap(); + + write_commit_file(&repo, "test.txt", FILE_1, "c1"); + + repo_write_file(&repo, "test.txt", FILE_2).unwrap(); + + discard_lines( + path, + "test.txt", + &[ + DiffLinePosition { + old_lineno: Some(3), + new_lineno: None, + }, + DiffLinePosition { + old_lineno: None, + new_lineno: Some(2), + }, + ], + ) + .unwrap(); + + let result_file = load_file(&repo, "test.txt").unwrap(); + + assert_eq!(result_file.as_str(), FILE_3); + } + + #[test] + fn test_discard2() { + static FILE_1: &str = r"start +end +"; + + static FILE_2: &str = r"start +1 +2 +end +"; + + static FILE_3: &str = r"start +1 +end +"; + + let (path, repo) = repo_init().unwrap(); + let path = path.path().to_str().unwrap(); + + write_commit_file(&repo, "test.txt", FILE_1, "c1"); + + repo_write_file(&repo, "test.txt", FILE_2).unwrap(); + + discard_lines( + path, + "test.txt", + &[DiffLinePosition { + old_lineno: None, + new_lineno: Some(3), + }], + ) + .unwrap(); + + let result_file = load_file(&repo, "test.txt").unwrap(); + + assert_eq!(result_file.as_str(), FILE_3); + } + + #[test] + fn test_discard3() { + static FILE_1: &str = r"start +1 +end +"; + + static FILE_2: &str = r"start +2 +end +"; + + static FILE_3: &str = r"start +1 +end +"; + + let (path, repo) = repo_init().unwrap(); + let path = path.path().to_str().unwrap(); + + write_commit_file(&repo, "test.txt", FILE_1, "c1"); + + repo_write_file(&repo, "test.txt", FILE_2).unwrap(); + + discard_lines( + path, + "test.txt", + &[ + DiffLinePosition { + old_lineno: Some(2), + new_lineno: None, + }, + DiffLinePosition { + old_lineno: None, + new_lineno: Some(2), + }, + ], + ) + .unwrap(); + + let result_file = load_file(&repo, "test.txt").unwrap(); + + assert_eq!(result_file.as_str(), FILE_3); + } + + #[test] + fn test_discard4() { + static FILE_1: &str = r"start +mid +end +"; + + static FILE_2: &str = r"start +1 +mid +2 +end +"; + + static FILE_3: &str = r"start +mid +end +"; + + let (path, repo) = repo_init().unwrap(); + let path = path.path().to_str().unwrap(); + + write_commit_file(&repo, "test.txt", FILE_1, "c1"); + + repo_write_file(&repo, "test.txt", FILE_2).unwrap(); + + discard_lines( + path, + "test.txt", + &[ + DiffLinePosition { + old_lineno: None, + new_lineno: Some(2), + }, + DiffLinePosition { + old_lineno: None, + new_lineno: Some(4), + }, + ], + ) + .unwrap(); + + let result_file = load_file(&repo, "test.txt").unwrap(); + + assert_eq!(result_file.as_str(), FILE_3); + } + + #[test] + fn test_discard_if_first_selected_line_is_not_in_any_hunk() { + static FILE_1: &str = r"start +end +"; + + static FILE_2: &str = r"start +1 +end +"; + + static FILE_3: &str = r"start +end +"; + + let (path, repo) = repo_init().unwrap(); + let path = path.path().to_str().unwrap(); + + write_commit_file(&repo, "test.txt", FILE_1, "c1"); + + repo_write_file(&repo, "test.txt", FILE_2).unwrap(); + + discard_lines( + path, + "test.txt", + &[ + DiffLinePosition { + old_lineno: None, + new_lineno: Some(1), + }, + DiffLinePosition { + old_lineno: None, + new_lineno: Some(2), + }, + ], + ) + .unwrap(); + + let result_file = load_file(&repo, "test.txt").unwrap(); + + assert_eq!(result_file.as_str(), FILE_3); + } + + //this test shows that we require at least a diff context around add/removes of 1 + #[test] + fn test_discard_deletions_filestart_breaking_with_zero_context() { + static FILE_1: &str = r"start +mid +end +"; + + static FILE_2: &str = r"start +end +"; + + static FILE_3: &str = r"start +mid +end +"; + + let (path, repo) = repo_init().unwrap(); + let path = path.path().to_str().unwrap(); + + write_commit_file(&repo, "test.txt", FILE_1, "c1"); + + repo_write_file(&repo, "test.txt", FILE_2).unwrap(); + + discard_lines( + path, + "test.txt", + &[DiffLinePosition { + old_lineno: Some(2), + new_lineno: None, + }], + ) + .unwrap(); + + let result_file = load_file(&repo, "test.txt").unwrap(); + + assert_eq!(result_file.as_str(), FILE_3); + } + + #[test] + fn test_discard5() { + static FILE_1: &str = r"start +"; + + static FILE_2: &str = r"start +1"; + + static FILE_3: &str = r"start +"; + + let (path, repo) = repo_init().unwrap(); + let path = path.path().to_str().unwrap(); + + write_commit_file(&repo, "test.txt", FILE_1, "c1"); + + repo_write_file(&repo, "test.txt", FILE_2).unwrap(); + + discard_lines( + path, + "test.txt", + &[DiffLinePosition { + old_lineno: None, + new_lineno: Some(2), + }], + ) + .unwrap(); + + let result_file = load_file(&repo, "test.txt").unwrap(); + + assert_eq!(result_file.as_str(), FILE_3); + } +} diff --git a/src/app.rs b/src/app.rs index 30e9dab3..9a18608b 100644 --- a/src/app.rs +++ b/src/app.rs @@ -502,6 +502,10 @@ impl App { sync::reset_hunk(CWD, path, hash)?; flags.insert(NeedsUpdate::ALL); } + Action::ResetLines(path, lines) => { + sync::discard_lines(CWD, &path, &lines)?; + flags.insert(NeedsUpdate::ALL); + } Action::DeleteBranch(branch_ref) => { if let Err(e) = sync::delete_branch(CWD, &branch_ref) diff --git a/src/components/diff.rs b/src/components/diff.rs index 304d7e0c..2e980930 100644 --- a/src/components/diff.rs +++ b/src/components/diff.rs @@ -9,7 +9,11 @@ use crate::{ ui::{self, calc_scroll_top, style::SharedTheme}, }; use anyhow::Result; -use asyncgit::{hash, sync, DiffLine, DiffLineType, FileDiff, CWD}; +use asyncgit::{ + hash, + sync::{self, diff::DiffLinePosition}, + DiffLine, DiffLineType, FileDiff, CWD, +}; use bytesize::ByteSize; use crossterm::event::Event; use std::{borrow::Cow, cell::Cell, cmp, path::Path}; @@ -509,6 +513,38 @@ impl DiffComponent { } } + fn reset_lines(&self) { + if let Some(diff) = &self.diff { + if self.selected_hunk.is_some() { + let selected_lines: Vec = diff + .hunks + .iter() + .flat_map(|hunk| hunk.lines.iter()) + .enumerate() + .filter_map(|(i, line)| { + let is_add_or_delete = line.line_type + == DiffLineType::Add + || line.line_type == DiffLineType::Delete; + if self.selection.contains(i) + && is_add_or_delete + { + Some(line.position) + } else { + None + } + }) + .collect(); + + self.queue.as_ref().borrow_mut().push_back( + InternalEvent::ConfirmAction(Action::ResetLines( + self.current.path.clone(), + selected_lines, + )), + ); + } + } + } + fn reset_untracked(&self) { self.queue.as_ref().borrow_mut().push_back( InternalEvent::ConfirmAction(Action::Reset(ResetItem { @@ -634,11 +670,20 @@ impl Component for DiffComponent { self.selected_hunk.is_some(), self.focused && !self.is_stage(), )); + out.push(CommandInfo::new( + strings::commands::diff_lines_revert( + &self.key_config, + ), + //TODO: only if any modifications are selected + true, + self.focused && !self.is_stage(), + )); } CommandBlocking::PassingOn } + #[allow(clippy::cognitive_complexity)] fn event(&mut self, ev: Event) -> Result { if self.focused { if let Event::Key(e) = ev { @@ -688,6 +733,16 @@ impl Component for DiffComponent { } } Ok(true) + } else if e == self.key_config.status_reset_lines + && !self.is_immutable + && !self.is_stage() + { + if let Some(diff) = &self.diff { + if !diff.untracked { + self.reset_lines(); + } + } + Ok(true) } else if e == self.key_config.copy { self.copy_selection(); Ok(true) diff --git a/src/components/reset.rs b/src/components/reset.rs index dd543185..6c969e18 100644 --- a/src/components/reset.rs +++ b/src/components/reset.rs @@ -151,6 +151,10 @@ impl ResetComponent { strings::confirm_title_reset(&self.key_config), strings::confirm_msg_resethunk(&self.key_config), ), + Action::ResetLines(_, lines) => ( + strings::confirm_title_reset(&self.key_config), + strings::confirm_msg_reset_lines(&self.key_config,lines.len()), + ), Action::DeleteBranch(branch_ref) => ( strings::confirm_title_delete_branch( &self.key_config, diff --git a/src/keys.rs b/src/keys.rs index 503cf5e5..42fb553d 100644 --- a/src/keys.rs +++ b/src/keys.rs @@ -51,6 +51,7 @@ pub struct KeyConfig { pub edit_file: KeyEvent, pub status_stage_all: KeyEvent, pub status_reset_item: KeyEvent, + pub status_reset_lines: KeyEvent, pub status_ignore_file: KeyEvent, pub stashing_save: KeyEvent, pub stashing_toggle_untracked: KeyEvent, @@ -105,6 +106,7 @@ impl Default for KeyConfig { edit_file: KeyEvent { code: KeyCode::Char('e'), modifiers: KeyModifiers::empty()}, status_stage_all: KeyEvent { code: KeyCode::Char('a'), modifiers: KeyModifiers::empty()}, status_reset_item: KeyEvent { code: KeyCode::Char('D'), modifiers: KeyModifiers::SHIFT}, + status_reset_lines: KeyEvent { code: KeyCode::Char('d'), modifiers: KeyModifiers::empty()}, status_ignore_file: KeyEvent { code: KeyCode::Char('i'), modifiers: KeyModifiers::empty()}, stashing_save: KeyEvent { code: KeyCode::Char('s'), modifiers: KeyModifiers::empty()}, stashing_toggle_untracked: KeyEvent { code: KeyCode::Char('u'), modifiers: KeyModifiers::empty()}, diff --git a/src/main.rs b/src/main.rs index 47972eae..deaa4704 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,6 @@ #![forbid(unsafe_code)] #![deny(unused_imports)] +#![deny(unused_must_use)] #![deny(clippy::cargo)] #![deny(clippy::pedantic)] #![deny(clippy::perf)] diff --git a/src/queue.rs b/src/queue.rs index fbafee7e..21da03fc 100644 --- a/src/queue.rs +++ b/src/queue.rs @@ -1,5 +1,5 @@ use crate::tabs::StashingOptions; -use asyncgit::sync::{CommitId, CommitTags}; +use asyncgit::sync::{diff::DiffLinePosition, CommitId, CommitTags}; use bitflags::bitflags; use std::{cell::RefCell, collections::VecDeque, rc::Rc}; @@ -27,6 +27,7 @@ pub struct ResetItem { pub enum Action { Reset(ResetItem), ResetHunk(String, u64), + ResetLines(String, Vec), StashDrop(CommitId), DeleteBranch(String), ForcePush(String, bool), diff --git a/src/strings.rs b/src/strings.rs index 0eff7ae2..42945e04 100644 --- a/src/strings.rs +++ b/src/strings.rs @@ -107,6 +107,15 @@ pub fn confirm_msg_merge( pub fn confirm_msg_reset(_key_config: &SharedKeyConfig) -> String { "confirm file reset?".to_string() } +pub fn confirm_msg_reset_lines( + _key_config: &SharedKeyConfig, + lines: usize, +) -> String { + format!( + "are you sure you want to discard {} selected lines?", + lines + ) +} pub fn confirm_msg_stashdrop( _key_config: &SharedKeyConfig, ) -> String { @@ -388,13 +397,25 @@ pub mod commands { ) -> CommandText { CommandText::new( format!( - "Revert hunk [{}]", + "Reset hunk [{}]", key_config.get_hint(key_config.status_reset_item), ), "reverts selected hunk", CMD_GROUP_DIFF, ) } + pub fn diff_lines_revert( + key_config: &SharedKeyConfig, + ) -> CommandText { + CommandText::new( + format!( + "Reset lines [{}]", + key_config.get_hint(key_config.status_reset_lines), + ), + "resets selected lines", + CMD_GROUP_DIFF, + ) + } pub fn diff_hunk_remove( key_config: &SharedKeyConfig, ) -> CommandText {