diff --git a/asyncgit/src/sync/diff.rs b/asyncgit/src/sync/diff.rs index 84a02f61..54d939e0 100644 --- a/asyncgit/src/sync/diff.rs +++ b/asyncgit/src/sync/diff.rs @@ -238,7 +238,7 @@ fn new_file_content(path: &Path) -> String { mod tests { use super::get_diff; use crate::sync::{ - stage_add, + stage_add_file, status::{get_status, StatusType}, tests::{repo_init, repo_init_empty}, }; @@ -288,7 +288,7 @@ mod tests { .write_all(b"test\nfoo") .unwrap(); - assert_eq!(stage_add(repo_path, file_path), true); + assert_eq!(stage_add_file(repo_path, file_path), true); let diff = get_diff( repo_path, @@ -347,7 +347,7 @@ mod tests { assert_eq!(res.len(), 1); assert_eq!(res[0].path, "bar.txt"); - let res = stage_add(repo_path, Path::new("bar.txt")); + let res = stage_add_file(repo_path, Path::new("bar.txt")); assert_eq!(res, true); assert_eq!(get_status(repo_path, StatusType::Stage).len(), 1); assert_eq!( diff --git a/asyncgit/src/sync/mod.rs b/asyncgit/src/sync/mod.rs index 49223306..ce3f63d3 100644 --- a/asyncgit/src/sync/mod.rs +++ b/asyncgit/src/sync/mod.rs @@ -9,8 +9,12 @@ pub mod utils; pub use hooks::{hooks_commit_msg, hooks_post_commit, HookResult}; pub use hunks::{stage_hunk, unstage_hunk}; -pub use reset::{reset_stage, reset_workdir}; -pub use utils::{commit, stage_add, stage_addremoved}; +pub use reset::{ + reset_stage, reset_workdir_file, reset_workdir_folder, +}; +pub use utils::{ + commit, stage_add_all, stage_add_file, stage_addremoved, +}; #[cfg(test)] mod tests { diff --git a/asyncgit/src/sync/reset.rs b/asyncgit/src/sync/reset.rs index 77f2791c..14fed1fa 100644 --- a/asyncgit/src/sync/reset.rs +++ b/asyncgit/src/sync/reset.rs @@ -26,14 +26,14 @@ pub fn reset_stage(repo_path: &str, path: &Path) -> bool { } /// -pub fn reset_workdir(repo_path: &str, path: &Path) -> bool { - scope_time!("reset_workdir"); +pub fn reset_workdir_file(repo_path: &str, path: &str) -> bool { + scope_time!("reset_workdir_file"); let repo = repo(repo_path); // Note: early out for removing untracked files, due to bug in checkout_head code: // see https://github.com/libgit2/libgit2/issues/5089 - if let Ok(status) = repo.status_file(&path) { + if let Ok(status) = repo.status_file(Path::new(path)) { let removed_file_wd = if status == Status::WT_NEW || (status == Status::WT_MODIFIED | Status::INDEX_NEW) { @@ -51,7 +51,7 @@ pub fn reset_workdir(repo_path: &str, path: &Path) -> bool { .update_index(true) // windows: needs this to be true WTF?! .allow_conflicts(true) .force() - .path(&path); + .path(path); repo.checkout_index(None, Some(&mut checkout_opts)).is_ok() } else { @@ -59,17 +59,36 @@ pub fn reset_workdir(repo_path: &str, path: &Path) -> bool { } } +/// +pub fn reset_workdir_folder(repo_path: &str, path: &str) -> bool { + scope_time!("reset_workdir_folder"); + + let repo = repo(repo_path); + + let mut checkout_opts = CheckoutBuilder::new(); + checkout_opts + .update_index(true) // windows: needs this to be true WTF?! + .allow_conflicts(true) + .remove_untracked(true) + .force() + .path(path); + + repo.checkout_index(None, Some(&mut checkout_opts)).is_ok() +} + #[cfg(test)] mod tests { - use super::{reset_stage, reset_workdir}; + use super::{ + reset_stage, reset_workdir_file, reset_workdir_folder, + }; use crate::sync::{ status::{get_status, StatusType}, tests::{debug_cmd_print, repo_init, repo_init_empty}, - utils::stage_add, + utils::{commit, stage_add_all, stage_add_file}, }; use std::{ fs::{self, File}, - io::Write, + io::{Error, Write}, path::Path, }; @@ -119,7 +138,7 @@ mod tests { debug_cmd_print(repo_path, "git status"); - stage_add(repo_path, Path::new("bar.txt")); + stage_add_file(repo_path, Path::new("bar.txt")); debug_cmd_print(repo_path, "git status"); @@ -139,7 +158,7 @@ mod tests { 1 ); - let res = reset_workdir(repo_path, Path::new("bar.txt")); + let res = reset_workdir_file(repo_path, "bar.txt"); assert_eq!(res, true); debug_cmd_print(repo_path, "git status"); @@ -172,7 +191,7 @@ mod tests { 1 ); - let res = reset_workdir(repo_path, Path::new("foo/bar.txt")); + let res = reset_workdir_file(repo_path, "foo/bar.txt"); assert_eq!(res, true); debug_cmd_print(repo_path, "git status"); @@ -183,6 +202,56 @@ mod tests { ); } + #[test] + fn test_reset_folder() -> Result<(), Error> { + let (_td, repo) = repo_init(); + let root = repo.path().parent().unwrap(); + let repo_path = root.as_os_str().to_str().unwrap(); + + { + fs::create_dir(&root.join("foo"))?; + File::create(&root.join("foo/file1.txt"))? + .write_all(b"file1")?; + File::create(&root.join("foo/file2.txt"))? + .write_all(b"file1")?; + File::create(&root.join("file3.txt"))? + .write_all(b"file3")?; + } + + assert!(stage_add_all(repo_path, "*")); + commit(repo_path, "msg"); + + { + File::create(&root.join("foo/file1.txt"))? + .write_all(b"file1\nadded line")?; + fs::remove_file(&root.join("foo/file2.txt"))?; + File::create(&root.join("foo/file4.txt"))? + .write_all(b"file4")?; + File::create(&root.join("foo/file5.txt"))? + .write_all(b"file5")?; + File::create(&root.join("file3.txt"))? + .write_all(b"file3\nadded line")?; + } + + stage_add_file(repo_path, Path::new("foo/file5.txt")); + + assert_eq!( + get_status(repo_path, StatusType::WorkingDir).len(), + 4 + ); + assert_eq!(get_status(repo_path, StatusType::Stage).len(), 1); + + assert!(reset_workdir_folder(repo_path, "foo")); + + assert_eq!( + get_status(repo_path, StatusType::WorkingDir).len(), + 1 + ); + assert_eq!(get_status(repo_path, StatusType::Stage).len(), 1); + + Ok(()) + } + #[test] fn test_reset_untracked_in_subdir_and_index() { let (_td, repo) = repo_init(); @@ -219,7 +288,7 @@ mod tests { 1 ); - let res = reset_workdir(repo_path, Path::new(file)); + let res = reset_workdir_file(repo_path, file); assert_eq!(res, true); debug_cmd_print(repo_path, "git status"); @@ -243,7 +312,7 @@ mod tests { .write_all(b"test\nfoo") .unwrap(); - assert_eq!(stage_add(repo_path, file_path), true); + assert_eq!(stage_add_file(repo_path, file_path), true); assert_eq!(reset_stage(repo_path, file_path), true); } diff --git a/asyncgit/src/sync/utils.rs b/asyncgit/src/sync/utils.rs index 27942790..38373e7d 100644 --- a/asyncgit/src/sync/utils.rs +++ b/asyncgit/src/sync/utils.rs @@ -1,6 +1,6 @@ //! sync git api (various methods) -use git2::{Repository, RepositoryOpenFlags}; +use git2::{IndexAddOption, Repository, RepositoryOpenFlags}; use scopetime::scope_time; use std::path::Path; @@ -63,8 +63,8 @@ pub fn commit(repo_path: &str, msg: &str) { } /// add a file diff from workingdir to stage (will not add removed files see `stage_addremoved`) -pub fn stage_add(repo_path: &str, path: &Path) -> bool { - scope_time!("stage_add"); +pub fn stage_add_file(repo_path: &str, path: &Path) -> bool { + scope_time!("stage_add_file"); let repo = repo(repo_path); @@ -78,6 +78,25 @@ pub fn stage_add(repo_path: &str, path: &Path) -> bool { false } +/// like `stage_add_file` but uses a pattern to match/glob multiple files/folders +pub fn stage_add_all(repo_path: &str, pattern: &str) -> bool { + scope_time!("stage_add_all"); + + let repo = repo(repo_path); + + let mut index = repo.index().unwrap(); + + if index + .add_all(vec![pattern], IndexAddOption::DEFAULT, None) + .is_ok() + { + index.write().unwrap(); + return true; + } + + false +} + /// stage a removed file pub fn stage_addremoved(repo_path: &str, path: &Path) -> bool { scope_time!("stage_addremoved"); @@ -98,13 +117,12 @@ pub fn stage_addremoved(repo_path: &str, path: &Path) -> bool { mod tests { use super::*; use crate::sync::{ - stage_add, status::{get_status, StatusType}, tests::{repo_init, repo_init_empty}, }; use std::{ - fs::{remove_file, File}, - io::Write, + fs::{self, remove_file, File}, + io::{Error, Write}, path::Path, }; @@ -126,7 +144,7 @@ mod tests { assert_eq!(status_count(StatusType::WorkingDir), 1); - assert_eq!(stage_add(repo_path, file_path), true); + assert_eq!(stage_add_file(repo_path, file_path), true); assert_eq!(status_count(StatusType::WorkingDir), 0); assert_eq!(status_count(StatusType::Stage), 1); @@ -149,7 +167,7 @@ mod tests { .write_all(b"test\nfoo") .unwrap(); - assert_eq!(stage_add(repo_path, file_path), true); + assert_eq!(stage_add_file(repo_path, file_path), true); commit(repo_path, "commit msg"); } @@ -161,7 +179,7 @@ mod tests { let root = repo.path().parent().unwrap(); let repo_path = root.as_os_str().to_str().unwrap(); - assert_eq!(stage_add(repo_path, file_path), false); + assert_eq!(stage_add_file(repo_path, file_path), false); } #[test] @@ -187,12 +205,40 @@ mod tests { assert_eq!(status_count(StatusType::WorkingDir), 2); - assert_eq!(stage_add(repo_path, file_path), true); + assert_eq!(stage_add_file(repo_path, file_path), true); assert_eq!(status_count(StatusType::WorkingDir), 1); assert_eq!(status_count(StatusType::Stage), 1); } + #[test] + fn test_staging_folder() -> Result<(), Error> { + let (_td, repo) = repo_init(); + let root = repo.path().parent().unwrap(); + let repo_path = root.as_os_str().to_str().unwrap(); + + let status_count = |s: StatusType| -> usize { + get_status(repo_path, s).len() + }; + + fs::create_dir_all(&root.join("a/d"))?; + File::create(&root.join(Path::new("a/d/f1.txt")))? + .write_all(b"foo")?; + File::create(&root.join(Path::new("a/d/f2.txt")))? + .write_all(b"foo")?; + File::create(&root.join(Path::new("a/f3.txt")))? + .write_all(b"foo")?; + + assert_eq!(status_count(StatusType::WorkingDir), 3); + + assert_eq!(stage_add_all(repo_path, "a/d"), true); + + assert_eq!(status_count(StatusType::WorkingDir), 1); + assert_eq!(status_count(StatusType::Stage), 2); + + Ok(()) + } + #[test] fn test_staging_deleted_file() { let file_path = Path::new("file1.txt"); @@ -211,7 +257,7 @@ mod tests { .write_all(b"test file1 content") .unwrap(); - assert_eq!(stage_add(repo_path, file_path), true); + assert_eq!(stage_add_file(repo_path, file_path), true); commit(repo_path, "commit msg"); diff --git a/src/app.rs b/src/app.rs index e88a3bbe..76303d9e 100644 --- a/src/app.rs +++ b/src/app.rs @@ -17,7 +17,7 @@ use crossbeam_channel::Sender; use crossterm::event::Event; use itertools::Itertools; use log::trace; -use std::{borrow::Cow, path::Path}; +use std::borrow::Cow; use strings::commands; use tui::{ backend::Backend, @@ -299,7 +299,7 @@ impl App { loop { let front = self.queue.borrow_mut().pop_front(); if let Some(e) = front { - flags.insert(self.process_internal_event(&e)); + flags.insert(self.process_internal_event(e)); } else { break; } @@ -311,35 +311,45 @@ impl App { fn process_internal_event( &mut self, - ev: &InternalEvent, + ev: InternalEvent, ) -> NeedsUpdate { let mut flags = NeedsUpdate::empty(); match ev { - InternalEvent::ResetFile(p) => { - if sync::reset_workdir(CWD, Path::new(p.as_str())) { + InternalEvent::ResetItem(reset_item) => { + if reset_item.is_folder { + if sync::reset_workdir_folder( + CWD, + reset_item.path.as_str(), + ) { + flags.insert(NeedsUpdate::ALL); + } + } else if sync::reset_workdir_file( + CWD, + reset_item.path.as_str(), + ) { flags.insert(NeedsUpdate::ALL); } } - InternalEvent::ConfirmResetFile(p) => { - self.reset.open_for_path(p); + InternalEvent::ConfirmResetItem(reset_item) => { + self.reset.open_for_path(reset_item); flags.insert(NeedsUpdate::COMMANDS); } InternalEvent::AddHunk(hash) => { if let Some((path, is_stage)) = self.selected_path() { if is_stage { - if sync::unstage_hunk(CWD, path, *hash) { + if sync::unstage_hunk(CWD, path, hash) { flags.insert(NeedsUpdate::ALL); } - } else if sync::stage_hunk(CWD, path, *hash) { + } else if sync::stage_hunk(CWD, path, hash) { flags.insert(NeedsUpdate::ALL); } } } InternalEvent::ShowMsg(msg) => { - self.msg.show_msg(msg); + self.msg.show_msg(msg.as_str()); flags.insert(NeedsUpdate::ALL); } - InternalEvent::Update(u) => flags.insert(*u), + InternalEvent::Update(u) => flags.insert(u), }; flags diff --git a/src/components/changes.rs b/src/components/changes.rs index 65b3ecff..2c94a9c5 100644 --- a/src/components/changes.rs +++ b/src/components/changes.rs @@ -6,12 +6,11 @@ use super::{ use crate::{ components::{CommandInfo, Component}, keys, - queue::{InternalEvent, NeedsUpdate, Queue}, + queue::{InternalEvent, NeedsUpdate, Queue, ResetItem}, strings, ui, }; use asyncgit::{hash, sync, StatusItem, StatusItemType, CWD}; use crossterm::event::Event; -use log::trace; use std::{borrow::Cow, convert::From, path::Path}; use strings::commands; use tui::{ @@ -103,25 +102,28 @@ impl ChangesComponent { fn index_add_remove(&mut self) -> bool { if let Some(tree_item) = self.selection() { - if let FileTreeItemKind::File(i) = tree_item.kind { - if self.is_working_dir { + if self.is_working_dir { + if let FileTreeItemKind::File(i) = tree_item.kind { if let Some(status) = i.status { let path = Path::new(i.path.as_str()); return match status { StatusItemType::Deleted => { sync::stage_addremoved(CWD, path) } - _ => sync::stage_add(CWD, path), + _ => sync::stage_add_file(CWD, path), }; } } else { - let path = Path::new(i.path.as_str()); - - return sync::reset_stage(CWD, path); + //TODO: check if we can handle the one file case with it aswell + return sync::stage_add_all( + CWD, + tree_item.info.full_path.as_str(), + ); } } else { - //TODO: - trace!("tbd"); + let path = + Path::new(tree_item.info.full_path.as_str()); + return sync::reset_stage(CWD, path); } } @@ -130,16 +132,16 @@ impl ChangesComponent { fn dispatch_reset_workdir(&mut self) -> bool { if let Some(tree_item) = self.selection() { - if let FileTreeItemKind::File(i) = tree_item.kind { - self.queue.borrow_mut().push_back( - InternalEvent::ConfirmResetFile(i.path), - ); + let is_folder = + matches!(tree_item.kind, FileTreeItemKind::Path(_)); + self.queue.borrow_mut().push_back( + InternalEvent::ConfirmResetItem(ResetItem { + path: tree_item.info.full_path, + is_folder, + }), + ); - return true; - } else { - //TODO: - trace!("tbd"); - } + return true; } false } @@ -282,22 +284,22 @@ impl Component for ChangesComponent { out: &mut Vec, _force_all: bool, ) -> CommandBlocking { - let some_selection = - self.selection().is_some() && self.is_file_seleted(); + let some_selection = self.selection().is_some(); + if self.is_working_dir { out.push(CommandInfo::new( - commands::STAGE_FILE, + commands::STAGE_ITEM, some_selection, self.focused, )); out.push(CommandInfo::new( - commands::RESET_FILE, + commands::RESET_ITEM, some_selection, self.focused, )); } else { out.push(CommandInfo::new( - commands::UNSTAGE_FILE, + commands::UNSTAGE_ITEM, some_selection, self.focused, )); diff --git a/src/components/filetree.rs b/src/components/filetree.rs index 94d09aa8..1a873be4 100644 --- a/src/components/filetree.rs +++ b/src/components/filetree.rs @@ -151,17 +151,37 @@ impl FileTreeItems { self.0.len() } - fn push_dirs( - item_path: &Path, + /// + pub(crate) fn find_parent_index( + &self, + path: &str, + index: usize, + ) -> usize { + if let Some(parent_path) = Path::new(path).parent() { + let parent_path = parent_path.to_str().unwrap(); + for i in (0..=index).rev() { + let item = &self.0[i]; + let item_path = &item.info.full_path; + if item_path == parent_path { + return i; + } + } + } + + 0 + } + + fn push_dirs<'a>( + item_path: &'a Path, nodes: &mut BinaryHeap, - paths_added: &mut BTreeSet, //TODO: use a ref string here + paths_added: &mut BTreeSet<&'a Path>, collapsed: &BTreeSet<&String>, ) { for c in item_path.ancestors().skip(1) { if c.parent().is_some() { let path_string = String::from(c.to_str().unwrap()); - if !paths_added.contains(&path_string) { - paths_added.insert(path_string.clone()); + if !paths_added.contains(c) { + paths_added.insert(c); let is_collapsed = collapsed.contains(&path_string); nodes.push(FileTreeItem::new_path( @@ -292,4 +312,25 @@ mod tests { ] ); } + + #[test] + fn test_find_parent() { + //0 a/ + //1 b/ + //2 c + //3 d + + let res = FileTreeItems::new( + &string_vec_to_status(&[ + "a/b/c", // + "a/b/d", // + ]), + &BTreeSet::new(), + ); + + assert_eq!( + res.find_parent_index(&String::from("a/b/c"), 3), + 1 + ); + } } diff --git a/src/components/reset.rs b/src/components/reset.rs index 0b6a32f0..d161ca02 100644 --- a/src/components/reset.rs +++ b/src/components/reset.rs @@ -3,7 +3,7 @@ use super::{ DrawableComponent, }; use crate::{ - queue::{InternalEvent, Queue}, + queue::{InternalEvent, Queue, ResetItem}, strings, ui, }; @@ -20,7 +20,7 @@ use tui::{ /// pub struct ResetComponent { - path: String, + target: Option, visible: bool, queue: Queue, } @@ -107,21 +107,24 @@ impl ResetComponent { /// pub fn new(queue: Queue) -> Self { Self { - path: String::default(), + target: None, visible: false, queue, } } /// - pub fn open_for_path(&mut self, path: &str) { - self.path = path.to_string(); + pub fn open_for_path(&mut self, item: ResetItem) { + self.target = Some(item); self.show(); } /// pub fn confirm(&mut self) { + if let Some(target) = self.target.take() { + self.queue + .borrow_mut() + .push_back(InternalEvent::ResetItem(target)); + } + self.hide(); - self.queue - .borrow_mut() - .push_back(InternalEvent::ResetFile(self.path.clone())); } } diff --git a/src/components/statustree.rs b/src/components/statustree.rs index ce3aa6bb..8ae4410a 100644 --- a/src/components/statustree.rs +++ b/src/components/statustree.rs @@ -2,7 +2,7 @@ use super::filetree::{ FileTreeItem, FileTreeItemKind, FileTreeItems, PathCollapsed, }; use asyncgit::StatusItem; -use std::{cmp, collections::BTreeSet, path::Path}; +use std::{cmp, collections::BTreeSet}; /// #[derive(Default)] @@ -193,7 +193,8 @@ impl StatusTree { if collapsed) { SelectionChange::new( - self.find_parent_index(&item_path, current_selection), + self.tree + .find_parent_index(&item_path, current_selection), false, ) } else if matches!(item_kind, FileTreeItemKind::Path(PathCollapsed(collapsed)) @@ -290,27 +291,6 @@ impl StatusTree { } } } - - fn find_parent_index( - &self, - path: &str, - current_index: usize, - ) -> usize { - let path = Path::new(path); - - if let Some(path) = path.parent() { - for i in (0..=current_index).rev() { - let item = self.tree.items().get(i).unwrap(); - let item_path = &item.info.full_path; - //TODO: use parameter path here - if item_path.ends_with(path.to_str().unwrap()) { - return i; - } - } - } - - 0 - } } #[cfg(test)] @@ -353,27 +333,6 @@ mod tests { assert_eq!(res.selection, Some(0)); } - #[test] - fn test_select_parent() { - let items = string_vec_to_status(&[ - "a/b/c", // - "a/b/d", // - ]); - - //0 a/ - //1 b/ - //2 c - //3 d - - let mut res = StatusTree::default(); - res.update(&items); - - assert_eq!( - res.find_parent_index(&String::from("a/b/c"), 3), - 1 - ); - } - #[test] fn test_keep_selected_item() { let mut res = StatusTree::default(); diff --git a/src/queue.rs b/src/queue.rs index ff600f55..7351d136 100644 --- a/src/queue.rs +++ b/src/queue.rs @@ -13,12 +13,20 @@ bitflags! { } } +/// data of item that is supposed to be reset +pub struct ResetItem { + /// path to the item (folder/file) + pub path: String, + /// are talking about a folder here? otherwise it's a single file + pub is_folder: bool, +} + /// pub enum InternalEvent { /// - ConfirmResetFile(String), + ConfirmResetItem(ResetItem), /// - ResetFile(String), + ResetItem(ResetItem), /// AddHunk(u64), /// diff --git a/src/strings.rs b/src/strings.rs index 9c256e2e..8d6a9848 100644 --- a/src/strings.rs +++ b/src/strings.rs @@ -79,21 +79,21 @@ pub mod commands { CMD_GROUP_COMMIT, ); /// - pub static STAGE_FILE: CommandText = CommandText::new( - "Stage File [enter]", - "stage currently selected file", + pub static STAGE_ITEM: CommandText = CommandText::new( + "Stage Item [enter]", + "stage currently selected file or entire path", CMD_GROUP_CHANGES, ); /// - pub static UNSTAGE_FILE: CommandText = CommandText::new( - "Unstage File [enter]", - "remove currently selected file from stage", + pub static UNSTAGE_ITEM: CommandText = CommandText::new( + "Unstage Item [enter]", + "unstage currently selected file or entire path", CMD_GROUP_CHANGES, ); /// - pub static RESET_FILE: CommandText = CommandText::new( - "Reset File [D]", - "revert changes in selected file", + pub static RESET_ITEM: CommandText = CommandText::new( + "Reset Item [D]", + "revert changes in selected file or entire path", CMD_GROUP_CHANGES, ); ///