diff --git a/Cargo.lock b/Cargo.lock index d238ba77..39f4d868 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -293,8 +293,7 @@ dependencies = [ [[package]] name = "git2" version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef222034f2069cfc5af01ce423574d3d9a3925bd4052912a14e5bcfd7ca9e47a" +source = "git+https://github.com/rust-lang/git2-rs.git?rev=617499d7fcf315cf92faa1ffde425666d3edd500#617499d7fcf315cf92faa1ffde425666d3edd500" dependencies = [ "bitflags", "libc", @@ -404,8 +403,7 @@ checksum = "dea0c0405123bba743ee3f91f49b1c7cfb684eef0da0a50110f758ccf24cdff0" [[package]] name = "libgit2-sys" version = "0.12.2+1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a12c878ccc1a49ff71e264233a66d2114cdcc7fdc44c0ebe2b54075240831238" +source = "git+https://github.com/rust-lang/git2-rs.git?rev=617499d7fcf315cf92faa1ffde425666d3edd500#617499d7fcf315cf92faa1ffde425666d3edd500" dependencies = [ "cc", "libc", diff --git a/Makefile b/Makefile index ff1d5109..961ee9db 100644 --- a/Makefile +++ b/Makefile @@ -4,6 +4,9 @@ debug: GITUI_LOGGING=true cargo run --features=timing +build-release: + cargo build --release + test: cargo test --workspace diff --git a/README.md b/README.md index 9755cb5b..881f7fb9 100644 --- a/README.md +++ b/README.md @@ -56,10 +56,8 @@ to enable logging to `~/.gitui/gitui.log`: GITUI_LOGGING=true gitui ``` -# todo for 0.1 (first release) +# todo for 0.2 (first release) -* [ ] make staging/unstaging async -* [ ] (un)staging selected hunks * [ ] publish as homebrew-tap # inspiration diff --git a/asyncgit/Cargo.toml b/asyncgit/Cargo.toml index bd805283..8202507a 100644 --- a/asyncgit/Cargo.toml +++ b/asyncgit/Cargo.toml @@ -10,7 +10,8 @@ license = "MIT" categories = ["concurrency","asynchronous"] [dependencies] -git2 = "0.13" +# git2 = "0.13" +git2 = { git = "https://github.com/rust-lang/git2-rs.git", rev = "617499d7fcf315cf92faa1ffde425666d3edd500" } rayon-core = "1.7" crossbeam-channel = "0.4" log = "0.4" diff --git a/asyncgit/src/diff.rs b/asyncgit/src/diff.rs index 07d60445..1c45e05c 100644 --- a/asyncgit/src/diff.rs +++ b/asyncgit/src/diff.rs @@ -1,4 +1,4 @@ -use crate::{hash, sync, AsyncNotification, Diff, CWD}; +use crate::{hash, sync, AsyncNotification, FileDiff, CWD}; use crossbeam_channel::Sender; use log::trace; use std::{ @@ -21,8 +21,8 @@ struct LastResult { /// pub struct AsyncDiff { - current: Arc>>, - last: Arc>>>, + current: Arc>>, + last: Arc>>>, sender: Sender, } @@ -37,7 +37,7 @@ impl AsyncDiff { } /// - pub fn last(&mut self) -> Option { + pub fn last(&mut self) -> Option { let last = self.last.lock().unwrap(); if let Some(res) = last.clone() { Some(res.result) @@ -55,7 +55,10 @@ impl AsyncDiff { } /// - pub fn request(&mut self, params: DiffParams) -> Option { + pub fn request( + &mut self, + params: DiffParams, + ) -> Option { trace!("request"); let hash = hash(¶ms); diff --git a/asyncgit/src/lib.rs b/asyncgit/src/lib.rs index 29e28263..c1705813 100644 --- a/asyncgit/src/lib.rs +++ b/asyncgit/src/lib.rs @@ -12,7 +12,7 @@ pub use crate::{ diff::{AsyncDiff, DiffParams}, status::AsyncStatus, sync::{ - diff::{Diff, DiffLine, DiffLineType}, + diff::{DiffLine, DiffLineType, FileDiff}, status::{StatusItem, StatusItemType}, utils::is_repo, }, diff --git a/asyncgit/src/sync/diff.rs b/asyncgit/src/sync/diff.rs index c8664c92..4b37719a 100644 --- a/asyncgit/src/sync/diff.rs +++ b/asyncgit/src/sync/diff.rs @@ -1,8 +1,10 @@ //! sync git api for fetching a diff use super::utils; +use crate::hash; use git2::{ - Delta, DiffDelta, DiffFormat, DiffHunk, DiffOptions, Patch, + Delta, Diff, DiffDelta, DiffFormat, DiffHunk, DiffOptions, Patch, + Repository, }; use scopetime::scope_time; use std::{fs, path::Path}; @@ -35,9 +37,8 @@ pub struct DiffLine { pub line_type: DiffLineType, } -/// -#[derive(Default, Clone, Copy, PartialEq)] -struct HunkHeader { +#[derive(Debug, Default, Clone, Copy, PartialEq, Hash)] +pub(crate) struct HunkHeader { old_start: u32, old_lines: u32, new_start: u32, @@ -57,20 +58,31 @@ impl From> for HunkHeader { /// #[derive(Default, Clone, Hash)] -pub struct Hunk(pub Vec); +pub struct Hunk { + /// + pub header_hash: u64, + /// + pub lines: Vec, +} /// #[derive(Default, Clone, Hash)] -pub struct Diff(pub Vec, pub u16); - -/// -pub fn get_diff(repo_path: &str, p: String, stage: bool) -> Diff { - scope_time!("get_diff"); - - let repo = utils::repo(repo_path); +pub struct FileDiff { + /// list of hunks + pub hunks: Vec, + /// lines total summed up over hunks + pub lines: u16, +} +pub(crate) fn get_diff_raw<'a>( + repo: &'a Repository, + p: &str, + stage: bool, + reverse: bool, +) -> (Diff<'a>, DiffOptions) { let mut opt = DiffOptions::new(); opt.pathspec(p); + opt.reverse(reverse); let diff = if stage { // diff against head @@ -98,13 +110,27 @@ pub fn get_diff(repo_path: &str, p: String, stage: bool) -> Diff { repo.diff_index_to_workdir(None, Some(&mut opt)).unwrap() }; - let mut res: Diff = Diff::default(); + (diff, opt) +} + +/// +pub fn get_diff(repo_path: &str, p: String, stage: bool) -> FileDiff { + scope_time!("get_diff"); + + let repo = utils::repo(repo_path); + + let (diff, mut opt) = get_diff_raw(&repo, &p, stage, false); + + let mut res: FileDiff = FileDiff::default(); let mut current_lines = Vec::new(); let mut current_hunk: Option = None; - let mut adder = |lines: &Vec| { - res.0.push(Hunk(lines.clone())); - res.1 += lines.len() as u16; + let mut adder = |header: &HunkHeader, lines: &Vec| { + res.hunks.push(Hunk { + header_hash: hash(header), + lines: lines.clone(), + }); + res.lines += lines.len() as u16; }; let mut put = |hunk: Option, line: git2::DiffLine| { @@ -114,7 +140,7 @@ pub fn get_diff(repo_path: &str, p: String, stage: bool) -> Diff { match current_hunk { None => current_hunk = Some(hunk_header), Some(h) if h != hunk_header => { - adder(¤t_lines); + adder(&h, ¤t_lines); current_lines.clear(); current_hunk = Some(hunk_header) } @@ -184,7 +210,7 @@ pub fn get_diff(repo_path: &str, p: String, stage: bool) -> Diff { } if !current_lines.is_empty() { - adder(¤t_lines); + adder(¤t_hunk.unwrap(), ¤t_lines); } res @@ -243,8 +269,8 @@ mod tests { let diff = get_diff(repo_path, "foo/bar.txt".to_string(), false); - assert_eq!(diff.0.len(), 1); - assert_eq!(diff.0[0].0[1].content, "test\n"); + assert_eq!(diff.hunks.len(), 1); + assert_eq!(diff.hunks[0].lines[1].content, "test\n"); } #[test] @@ -270,7 +296,7 @@ mod tests { true, ); - assert_eq!(diff.0.len(), 1); + assert_eq!(diff.hunks.len(), 1); } static HUNK_A: &str = r" @@ -345,6 +371,6 @@ mod tests { let res = get_diff(repo_path, "bar.txt".to_string(), false); - assert_eq!(res.0.len(), 2) + assert_eq!(res.hunks.len(), 2) } } diff --git a/asyncgit/src/sync/hunks.rs b/asyncgit/src/sync/hunks.rs new file mode 100644 index 00000000..3fdda32e --- /dev/null +++ b/asyncgit/src/sync/hunks.rs @@ -0,0 +1,108 @@ +use super::{ + diff::{get_diff_raw, HunkHeader}, + utils::repo, +}; +use crate::hash; +use git2::{ApplyLocation, ApplyOptions, Diff}; +use log::error; +use scopetime::scope_time; + +/// +pub fn stage_hunk( + repo_path: &str, + file_path: String, + hunk_hash: u64, +) -> bool { + scope_time!("stage_hunk"); + + let repo = repo(repo_path); + + let (diff, _) = get_diff_raw(&repo, &file_path, false, false); + + let mut opt = ApplyOptions::new(); + opt.hunk_callback(|hunk| { + let header = HunkHeader::from(hunk.unwrap()); + hash(&header) == hunk_hash + }); + + repo.apply(&diff, ApplyLocation::Index, Some(&mut opt)) + .is_ok() +} + +fn find_hunk_index(diff: &Diff, hunk_hash: u64) -> Option { + let mut result = None; + + let mut hunk_count = 0; + + let foreach_result = diff.foreach( + &mut |_, _| true, + None, + Some(&mut |_, hunk| { + let header = HunkHeader::from(hunk); + if hash(&header) == hunk_hash { + result = Some(hunk_count); + } + hunk_count += 1; + true + }), + None, + ); + + if foreach_result.is_ok() { + result + } else { + None + } +} + +/// +pub fn revert_hunk( + repo_path: &str, + file_path: String, + hunk_hash: u64, +) -> bool { + scope_time!("revert_hunk"); + + let repo = repo(repo_path); + + let (diff, _) = get_diff_raw(&repo, &file_path, true, false); + let diff_count_positive = diff.deltas().len(); + + let hunk_index = find_hunk_index(&diff, hunk_hash); + + if hunk_index.is_none() { + error!("hunk not found"); + return false; + } + + let (diff, _) = get_diff_raw(&repo, &file_path, true, true); + + assert_eq!(diff.deltas().len(), diff_count_positive); + + let mut count = 0; + { + let mut hunk_idx = 0; + let mut opt = ApplyOptions::new(); + opt.hunk_callback(|_hunk| { + let res = if hunk_idx == hunk_index.unwrap() { + count += 1; + true + } else { + false + }; + + hunk_idx += 1; + + res + }); + if repo + .apply(&diff, ApplyLocation::Index, Some(&mut opt)) + .is_err() + { + error!("apply failed"); + return false; + } + } + + count == 1 +} diff --git a/asyncgit/src/sync/mod.rs b/asyncgit/src/sync/mod.rs index c1779406..b35f4b8f 100644 --- a/asyncgit/src/sync/mod.rs +++ b/asyncgit/src/sync/mod.rs @@ -1,10 +1,12 @@ //! sync git api pub mod diff; +mod hunks; mod reset; pub mod status; pub mod utils; +pub use hunks::{revert_hunk, stage_hunk}; pub use reset::{reset_stage, reset_workdir}; pub use utils::{commit, stage_add}; diff --git a/src/app.rs b/src/app.rs index fe4f809c..3bf613a9 100644 --- a/src/app.rs +++ b/src/app.rs @@ -82,7 +82,7 @@ impl App { false, queue.clone(), ), - diff: DiffComponent::default(), + diff: DiffComponent::new(queue.clone()), git_diff: AsyncDiff::new(sender.clone()), git_status: AsyncStatus::new(sender), current_commands: Vec::new(), @@ -216,13 +216,7 @@ impl App { // private impls impl App { fn update_diff(&mut self) { - let (idx, is_stage) = match self.diff_target { - DiffTarget::Stage => (&self.index, true), - DiffTarget::WorkingDir => (&self.index_wd, false), - }; - - if let Some(i) = idx.selection() { - let path = i.path; + if let Some((path, is_stage)) = self.selected_path() { let diff_params = DiffParams(path.clone(), is_stage); if self.diff.current() == (path.clone(), is_stage) { @@ -245,6 +239,19 @@ impl App { } } + fn selected_path(&self) -> Option<(String, bool)> { + let (idx, is_stage) = match self.diff_target { + DiffTarget::Stage => (&self.index, true), + DiffTarget::WorkingDir => (&self.index_wd, false), + }; + + if let Some(i) = idx.selection() { + Some((i.path, is_stage)) + } else { + None + } + } + fn update_commands(&mut self) { self.help.set_cmds(self.commands(true)); self.current_commands = self.commands(false); @@ -284,6 +291,17 @@ impl App { self.reset.open_for_path(p); self.update_commands(); } + InternalEvent::AddHunk(hash) => { + if let Some((path, is_stage)) = self.selected_path() { + if is_stage { + if sync::revert_hunk(CWD, path, *hash) { + self.update(); + } + } else if sync::stage_hunk(CWD, path, *hash) { + self.update(); + } + } + } }; } diff --git a/src/components/diff.rs b/src/components/diff.rs index 705f5eb9..829ac44d 100644 --- a/src/components/diff.rs +++ b/src/components/diff.rs @@ -1,9 +1,10 @@ use super::{CommandBlocking, DrawableComponent, EventUpdate}; use crate::{ components::{CommandInfo, Component}, + queue::{InternalEvent, Queue}, strings, }; -use asyncgit::{hash, Diff, DiffLine, DiffLineType}; +use asyncgit::{hash, DiffLine, DiffLineType, FileDiff}; use crossterm::event::{Event, KeyCode}; use std::{borrow::Cow, cmp, convert::TryFrom}; use strings::commands; @@ -16,57 +17,113 @@ use tui::{ Frame, }; -/// #[derive(Default)] +struct Current { + path: String, + is_stage: bool, + hash: u64, +} + +/// pub struct DiffComponent { - diff: Diff, + diff: FileDiff, scroll: u16, focused: bool, - current: (String, bool), - current_hash: u64, + current: Current, + selected_hunk: Option, + queue: Queue, } impl DiffComponent { + /// + pub fn new(queue: Queue) -> Self { + Self { + focused: false, + queue, + current: Current::default(), + selected_hunk: None, + diff: FileDiff::default(), + scroll: 0, + } + } /// fn can_scroll(&self) -> bool { - self.diff.1 > 1 + self.diff.lines > 1 } /// pub fn current(&self) -> (String, bool) { - (self.current.0.clone(), self.current.1) + (self.current.path.clone(), self.current.is_stage) } /// pub fn clear(&mut self) { - self.current.0.clear(); - self.diff = Diff::default(); - self.current_hash = 0; + self.current = Current::default(); + self.diff = FileDiff::default(); + self.scroll = 0; + + self.selected_hunk = + Self::find_selected_hunk(&self.diff, self.scroll); } /// pub fn update( &mut self, path: String, is_stage: bool, - diff: Diff, + diff: FileDiff, ) { let hash = hash(&diff); - if self.current_hash != hash { - self.current = (path, is_stage); - self.current_hash = hash; + if self.current.hash != hash { + self.current = Current { + path, + is_stage, + hash, + }; self.diff = diff; self.scroll = 0; + + self.selected_hunk = + Self::find_selected_hunk(&self.diff, self.scroll); } } fn scroll(&mut self, inc: bool) { + let old = self.scroll; if inc { self.scroll = cmp::min( - self.diff.1.saturating_sub(1), + self.diff.lines.saturating_sub(1), self.scroll.saturating_add(1), ); } else { self.scroll = self.scroll.saturating_sub(1); } + + if old != self.scroll { + self.selected_hunk = + Self::find_selected_hunk(&self.diff, self.scroll); + } + } + + fn find_selected_hunk( + diff: &FileDiff, + line_selected: u16, + ) -> Option { + let mut line_cursor = 0_u16; + for (i, hunk) in diff.hunks.iter().enumerate() { + let hunk_len = u16::try_from(hunk.lines.len()).unwrap(); + let hunk_min = line_cursor; + let hunk_max = line_cursor + hunk_len; + + let hunk_selected = + hunk_min <= line_selected && hunk_max > line_selected; + + if hunk_selected { + return Some(u16::try_from(i).unwrap()); + } + + line_cursor += hunk_len; + } + + None } fn get_text(&self, width: u16, height: u16) -> Vec { @@ -79,19 +136,21 @@ impl DiffComponent { let mut line_cursor = 0_u16; let mut lines_added = 0_u16; - for hunk in &self.diff.0 { + for (i, hunk) in self.diff.hunks.iter().enumerate() { + let hunk_selected = self + .selected_hunk + .map_or(false, |s| s == u16::try_from(i).unwrap()); + if lines_added >= height { break; } - let hunk_len = u16::try_from(hunk.0.len()).unwrap(); + let hunk_len = u16::try_from(hunk.lines.len()).unwrap(); let hunk_min = line_cursor; let hunk_max = line_cursor + hunk_len; if Self::hunk_visible(hunk_min, hunk_max, min, max) { - let hunk_selected = - hunk_min <= selection && hunk_max > selection; - for (i, line) in hunk.0.iter().enumerate() { + for (i, line) in hunk.lines.iter().enumerate() { if line_cursor >= min { Self::add_line( &mut res, @@ -219,6 +278,17 @@ impl DiffComponent { false } + + fn add_hunk(&self) { + if let Some(hunk) = self.selected_hunk { + let hash = self.diff.hunks + [usize::try_from(hunk).unwrap()] + .header_hash; + self.queue + .borrow_mut() + .push_back(InternalEvent::AddHunk(hash)); + } + } } impl DrawableComponent for DiffComponent { @@ -255,6 +325,18 @@ impl Component for DiffComponent { self.focused, )); + let cmd_text = if self.current.is_stage { + commands::DIFF_HUNK_ADD + } else { + commands::DIFF_HUNK_REMOVE + }; + + out.push(CommandInfo::new( + cmd_text, + self.selected_hunk.is_some(), + self.focused, + )); + CommandBlocking::PassingOn } @@ -270,6 +352,10 @@ impl Component for DiffComponent { self.scroll(false); Some(EventUpdate::None) } + KeyCode::Enter => { + self.add_hunk(); + Some(EventUpdate::None) + } _ => None, }; } diff --git a/src/queue.rs b/src/queue.rs index 3f570a1a..62d9d5d3 100644 --- a/src/queue.rs +++ b/src/queue.rs @@ -6,6 +6,8 @@ pub enum InternalEvent { ConfirmResetFile(String), /// ResetFile(String), + /// + AddHunk(u64), } /// diff --git a/src/strings.rs b/src/strings.rs index 91572b4b..82518e59 100644 --- a/src/strings.rs +++ b/src/strings.rs @@ -18,6 +18,7 @@ pub mod commands { use crate::components::CommandText; static CMD_GROUP_GENERAL: &str = "General"; + static CMD_GROUP_DIFF: &str = "Diff"; static CMD_GROUP_CHANGES: &str = "Changes"; static CMD_GROUP_COMMIT: &str = "Commit"; @@ -34,6 +35,18 @@ pub mod commands { CMD_GROUP_GENERAL, ); /// + pub static DIFF_HUNK_ADD: CommandText = CommandText::new( + "Add hunk [enter]", + "adds selected hunk to stage", + CMD_GROUP_DIFF, + ); + /// + pub static DIFF_HUNK_REMOVE: CommandText = CommandText::new( + "Remove hunk [enter]", + "removes selected hunk from stage", + CMD_GROUP_DIFF, + ); + /// pub static CLOSE_POPUP: CommandText = CommandText::new( "Close [esc]", "close overlay (e.g commit, help)",