diff --git a/asyncgit/src/asyncjob/mod.rs b/asyncgit/src/asyncjob/mod.rs index 19058f9bac..a573a01683 100644 --- a/asyncgit/src/asyncjob/mod.rs +++ b/asyncgit/src/asyncjob/mod.rs @@ -7,6 +7,7 @@ use crossbeam_channel::Sender; use std::sync::{Arc, Mutex, RwLock}; /// Passed to `AsyncJob::run` allowing sending intermediate progress notifications +#[derive(Clone)] pub struct RunParams< T: Copy + Send, P: Clone + Send + Sync + PartialEq, diff --git a/asyncgit/src/file_history.rs b/asyncgit/src/file_history.rs new file mode 100644 index 0000000000..e1a2e74bc5 --- /dev/null +++ b/asyncgit/src/file_history.rs @@ -0,0 +1,299 @@ +use git2::Repository; + +use crate::{ + asyncjob::{AsyncJob, RunParams}, + error::Result, + sync::{ + self, + commit_files::{ + commit_contains_file, commit_detect_file_rename, + }, + CommitId, CommitInfo, LogWalker, RepoPath, + SharedCommitFilterFn, + }, + AsyncGitNotification, +}; +use std::{ + sync::{Arc, Mutex, RwLock}, + time::{Duration, Instant}, +}; + +/// +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum FileHistoryEntryDelta { + /// + None, + /// + Added, + /// + Deleted, + /// + Modified, + /// + Renamed, + /// + Copied, + /// + Typechange, +} + +impl From for FileHistoryEntryDelta { + fn from(value: git2::Delta) -> Self { + match value { + git2::Delta::Unmodified + | git2::Delta::Ignored + | git2::Delta::Unreadable + | git2::Delta::Conflicted + | git2::Delta::Untracked => Self::None, + git2::Delta::Added => Self::Added, + git2::Delta::Deleted => Self::Deleted, + git2::Delta::Modified => Self::Modified, + git2::Delta::Renamed => Self::Renamed, + git2::Delta::Copied => Self::Copied, + git2::Delta::Typechange => Self::Typechange, + } + } +} + +/// +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FileHistoryEntry { + /// + pub commit: CommitId, + /// + pub delta: FileHistoryEntryDelta, + //TODO: arc and share since most will be the same over the history + /// + pub file_path: String, + /// + pub info: CommitInfo, +} + +/// +pub struct CommitFilterResult { + /// + pub duration: Duration, +} + +enum JobState { + Request { + file_path: String, + repo_path: RepoPath, + }, + Response(Result), +} + +#[derive(Clone, Default)] +pub struct AsyncFileHistoryResults(Arc>>); + +impl PartialEq for AsyncFileHistoryResults { + fn eq(&self, other: &Self) -> bool { + if Arc::ptr_eq(&self.0, &other.0) { + return true; + } + + if let Ok(left) = self.0.lock() { + if let Ok(right) = other.0.lock() { + return *left == *right; + } + } + + false + } +} + +impl AsyncFileHistoryResults { + /// + pub fn extract_results(&self) -> Result> { + let mut results = self.0.lock()?; + log::trace!("pull entries {}", results.len()); + let results = + std::mem::replace(&mut *results, Vec::with_capacity(1)); + Ok(results) + } +} + +/// +#[derive(Clone)] +pub struct AsyncFileHistoryJob { + state: Arc>>, + results: AsyncFileHistoryResults, +} + +/// +impl AsyncFileHistoryJob { + /// + pub fn new(repo_path: RepoPath, file_path: String) -> Self { + Self { + state: Arc::new(Mutex::new(Some(JobState::Request { + repo_path, + file_path, + }))), + results: AsyncFileHistoryResults::default(), + } + } + + /// + pub fn result(&self) -> Option> { + if let Ok(mut state) = self.state.lock() { + if let Some(state) = state.take() { + return match state { + JobState::Request { .. } => None, + JobState::Response(result) => Some(result), + }; + } + } + + None + } + + /// + pub fn extract_results(&self) -> Result> { + self.results.extract_results() + } + + fn file_history_filter( + file_path: Arc>, + results: Arc>>, + params: &RunParams< + AsyncGitNotification, + AsyncFileHistoryResults, + >, + ) -> SharedCommitFilterFn { + let params = params.clone(); + + Arc::new(Box::new( + move |repo: &Repository, + commit_id: &CommitId| + -> Result { + let file_path = file_path.clone(); + + if fun_name(&file_path, &results, repo, commit_id)? { + params.send(AsyncGitNotification::FileHistory)?; + params.set_progress(AsyncFileHistoryResults( + results.clone(), + ))?; + Ok(true) + } else { + Ok(false) + } + }, + )) + } + + fn run_request( + &self, + repo_path: &RepoPath, + file_path: String, + params: &RunParams< + AsyncGitNotification, + AsyncFileHistoryResults, + >, + ) -> Result { + let start = Instant::now(); + + let file_name = Arc::new(RwLock::new(file_path)); + + let filter = Self::file_history_filter( + file_name, + self.results.0.clone(), + params, + ); + + let repo = sync::repo(repo_path)?; + let mut walker = + LogWalker::new(&repo, None)?.filter(Some(filter)); + + walker.read(None)?; + + let result = CommitFilterResult { + duration: start.elapsed(), + }; + + Ok(result) + } +} + +fn fun_name( + file_path: &Arc>, + results: &Arc>>, + repo: &Repository, + commit_id: &CommitId, +) -> Result { + let current_file_path = file_path.read()?.to_string(); + + if let Some(delta) = commit_contains_file( + repo, + *commit_id, + current_file_path.as_str(), + )? { + log::info!( + "[history] edit: [{}] ({:?}) - {}", + commit_id.get_short_string(), + delta, + ¤t_file_path + ); + + let commit_info = + sync::get_commit_info_repo(repo, commit_id)?; + + let entry = FileHistoryEntry { + commit: *commit_id, + delta: delta.into(), + info: commit_info, + file_path: current_file_path.clone(), + }; + + //note: only do rename test in case file looks like being added in this commit + if matches!(delta, git2::Delta::Added) { + let rename = commit_detect_file_rename( + repo, + *commit_id, + current_file_path.as_str(), + )?; + + if let Some(old_name) = rename { + // log::info!( + // "rename: [{}] {:?} <- {:?}", + // commit_id.get_short_string(), + // current_file_path, + // old_name, + // ); + + (*file_path.write()?) = old_name; + } + } + results.lock()?.push(entry); + log::trace!("push entry {}", results.lock()?.len()); + + return Ok(true); + } + + Ok(false) +} + +impl AsyncJob for AsyncFileHistoryJob { + type Notification = AsyncGitNotification; + type Progress = AsyncFileHistoryResults; + + fn run( + &mut self, + params: RunParams, + ) -> Result { + if let Ok(mut state) = self.state.lock() { + *state = state.take().map(|state| match state { + JobState::Request { + file_path, + repo_path, + } => JobState::Response( + self.run_request(&repo_path, file_path, ¶ms), + ), + JobState::Response(result) => { + JobState::Response(result) + } + }); + } + + Ok(AsyncGitNotification::FileHistory) + } +} diff --git a/asyncgit/src/lib.rs b/asyncgit/src/lib.rs index fa022e4020..e77a6bac0f 100644 --- a/asyncgit/src/lib.rs +++ b/asyncgit/src/lib.rs @@ -40,6 +40,7 @@ mod commit_files; mod diff; mod error; mod fetch_job; +mod file_history; mod filter_commits; mod progress; mod pull; @@ -60,6 +61,9 @@ pub use crate::{ diff::{AsyncDiff, DiffParams, DiffType}, error::{Error, Result}, fetch_job::AsyncFetchJob, + file_history::{ + AsyncFileHistoryJob, FileHistoryEntry, FileHistoryEntryDelta, + }, filter_commits::{AsyncCommitFilterJob, CommitFilterResult}, progress::ProgressPercent, pull::{AsyncPull, FetchRequest}, @@ -117,6 +121,8 @@ pub enum AsyncGitNotification { TreeFiles, /// CommitFilter, + /// + FileHistory, } /// helper function to calculate the hash of an arbitrary type that implements the `Hash` trait diff --git a/asyncgit/src/revlog.rs b/asyncgit/src/revlog.rs index 38febb84a4..f6c04623db 100644 --- a/asyncgit/src/revlog.rs +++ b/asyncgit/src/revlog.rs @@ -9,6 +9,7 @@ use crate::{ use crossbeam_channel::Sender; use scopetime::scope_time; use std::{ + cell::RefCell, sync::{ atomic::{AtomicBool, Ordering}, Arc, Mutex, @@ -230,19 +231,20 @@ impl AsyncLog { ) -> Result<()> { let start_time = Instant::now(); - let mut entries = vec![CommitId::default(); LIMIT_COUNT]; - entries.resize(0, CommitId::default()); + let entries = + RefCell::new(vec![CommitId::default(); LIMIT_COUNT]); + entries.borrow_mut().resize(0, CommitId::default()); let r = repo(repo_path)?; - let mut walker = - LogWalker::new(&r, LIMIT_COUNT)?.filter(Some(filter)); + let mut walker = LogWalker::new(&r, Some(LIMIT_COUNT))? + .filter(Some(filter)); loop { - entries.clear(); - let read = walker.read(&mut entries)?; + entries.borrow_mut().clear(); + let read = walker.read(Some(&entries))?; let mut current = arc_current.lock()?; - current.commits.extend(entries.iter()); + current.commits.extend(entries.borrow().iter()); current.duration = start_time.elapsed(); if read == 0 { diff --git a/asyncgit/src/sync/commit.rs b/asyncgit/src/sync/commit.rs index bb7e6384c5..74c08a9052 100644 --- a/asyncgit/src/sync/commit.rs +++ b/asyncgit/src/sync/commit.rs @@ -211,13 +211,14 @@ mod tests { }; use commit::{amend, commit_message_prettify, tag_commit}; use git2::Repository; + use std::cell::RefCell; 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, max).unwrap(); - walk.read(&mut items).unwrap(); - items.len() + let items = RefCell::new(Vec::new()); + let mut walk = LogWalker::new(repo, Some(max)).unwrap(); + walk.read(Some(&items)).unwrap(); + items.take().len() } #[test] diff --git a/asyncgit/src/sync/commit_files.rs b/asyncgit/src/sync/commit_files.rs index c03d7c13cf..a70270fdce 100644 --- a/asyncgit/src/sync/commit_files.rs +++ b/asyncgit/src/sync/commit_files.rs @@ -3,10 +3,10 @@ use super::{diff::DiffOptions, CommitId, RepoPath}; use crate::{ error::Result, - sync::{get_stashes, repository::repo}, - StatusItem, StatusItemType, + sync::{get_stashes, repository::repo, utils::bytes2string}, + Error, StatusItem, StatusItemType, }; -use git2::{Diff, Repository}; +use git2::{Diff, DiffFindOptions, Repository}; use scopetime::scope_time; use std::collections::HashSet; @@ -179,14 +179,93 @@ pub(crate) fn get_commit_diff<'a>( Ok(diff) } +/// +pub(crate) fn commit_contains_file( + repo: &Repository, + id: CommitId, + pathspec: &str, +) -> Result> { + let commit = repo.find_commit(id.into())?; + let commit_tree = commit.tree()?; + + let parent = if commit.parent_count() > 0 { + repo.find_commit(commit.parent_id(0)?) + .ok() + .and_then(|c| c.tree().ok()) + } else { + None + }; + + let mut opts = git2::DiffOptions::new(); + opts.pathspec(pathspec.to_string()) + .skip_binary_check(true) + .context_lines(0); + + let diff = repo.diff_tree_to_tree( + parent.as_ref(), + Some(&commit_tree), + Some(&mut opts), + )?; + + if diff.stats()?.files_changed() == 0 { + return Ok(None); + } + + Ok(diff.deltas().map(|delta| delta.status()).next()) +} + +/// +pub(crate) fn commit_detect_file_rename( + repo: &Repository, + id: CommitId, + pathspec: &str, +) -> Result> { + scope_time!("commit_detect_file_rename"); + + let mut diff = get_commit_diff(repo, id, None, None, None)?; + + diff.find_similar(Some( + DiffFindOptions::new() + .renames(true) + .renames_from_rewrites(true) + .rename_from_rewrite_threshold(100), + ))?; + + let current_path = std::path::Path::new(pathspec); + + for delta in diff.deltas() { + let new_file_matches = delta + .new_file() + .path() + .is_some_and(|path| path == current_path); + + if new_file_matches + && matches!(delta.status(), git2::Delta::Renamed) + { + return Ok(Some(bytes2string( + delta.old_file().path_bytes().ok_or_else(|| { + Error::Generic(String::from("old_file error")) + })?, + )?)); + } + } + + Ok(None) +} + #[cfg(test)] mod tests { use super::get_commit_files; use crate::{ error::Result, sync::{ - commit, stage_add_file, stash_save, - tests::{get_statuses, repo_init}, + commit, + commit_files::commit_detect_file_rename, + stage_add_all, stage_add_file, stash_save, + tests::{ + get_statuses, rename_file, repo_init, + repo_init_empty, write_commit_file, + }, RepoPath, }, StatusItemType, @@ -266,4 +345,28 @@ mod tests { Ok(()) } + + #[test] + fn test_rename_detection() { + let (td, repo) = repo_init_empty().unwrap(); + let repo_path: RepoPath = td.path().into(); + + write_commit_file(&repo, "foo.txt", "foobar", "c1"); + rename_file(&repo, "foo.txt", "bar.txt"); + stage_add_all( + &repo_path, + "*", + Some(crate::sync::ShowUntrackedFilesConfig::All), + ) + .unwrap(); + let rename_commit = commit(&repo_path, "c2").unwrap(); + + let rename = commit_detect_file_rename( + &repo, + rename_commit, + "bar.txt", + ) + .unwrap(); + assert_eq!(rename, Some(String::from("foo.txt"))); + } } diff --git a/asyncgit/src/sync/commit_filter.rs b/asyncgit/src/sync/commit_filter.rs index f4b3e8a6c6..805a1b3afd 100644 --- a/asyncgit/src/sync/commit_filter.rs +++ b/asyncgit/src/sync/commit_filter.rs @@ -13,27 +13,6 @@ pub type SharedCommitFilterFn = Arc< Box Result + Send + Sync>, >; -/// -pub fn diff_contains_file(file_path: String) -> SharedCommitFilterFn { - Arc::new(Box::new( - move |repo: &Repository, - commit_id: &CommitId| - -> Result { - let diff = get_commit_diff( - repo, - *commit_id, - Some(file_path.clone()), - None, - None, - )?; - - let contains_file = diff.deltas().len() > 0; - - Ok(contains_file) - }, - )) -} - bitflags! { /// #[derive(Debug, Clone, Copy)] diff --git a/asyncgit/src/sync/commits_info.rs b/asyncgit/src/sync/commits_info.rs index df426249e7..cbeb283591 100644 --- a/asyncgit/src/sync/commits_info.rs +++ b/asyncgit/src/sync/commits_info.rs @@ -5,7 +5,7 @@ use crate::{ error::Result, sync::{commit_details::get_author_of_commit, repository::repo}, }; -use git2::{Commit, Error, Oid}; +use git2::{Commit, Error, Oid, Repository}; use scopetime::scope_time; use unicode_truncate::UnicodeTruncateStr; @@ -73,7 +73,7 @@ impl From for CommitId { } /// -#[derive(Debug)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct CommitInfo { /// pub message: String, @@ -131,9 +131,17 @@ pub fn get_commit_info( scope_time!("get_commit_info"); let repo = repo(repo_path)?; - let mailmap = repo.mailmap()?; + get_commit_info_repo(&repo, commit_id) +} + +/// +pub fn get_commit_info_repo( + repo: &Repository, + commit_id: &CommitId, +) -> Result { let commit = repo.find_commit((*commit_id).into())?; + let mailmap = repo.mailmap()?; let author = get_author_of_commit(&commit, &mailmap); Ok(CommitInfo { diff --git a/asyncgit/src/sync/logwalker.rs b/asyncgit/src/sync/logwalker.rs index 743376d34f..4f9871ecbf 100644 --- a/asyncgit/src/sync/logwalker.rs +++ b/asyncgit/src/sync/logwalker.rs @@ -4,6 +4,7 @@ use crate::error::Result; use git2::{Commit, Oid, Repository}; use gix::revision::Walk; use std::{ + cell::RefCell, cmp::Ordering, collections::{BinaryHeap, HashSet}, }; @@ -34,14 +35,17 @@ impl Ord for TimeOrderedCommit<'_> { pub struct LogWalker<'a> { commits: BinaryHeap>, visited: HashSet, - limit: usize, + limit: Option, repo: &'a Repository, filter: Option, } impl<'a> LogWalker<'a> { /// - pub fn new(repo: &'a Repository, limit: usize) -> Result { + pub fn new( + repo: &'a Repository, + limit: Option, + ) -> Result { let c = repo.head()?.peel_to_commit()?; let mut commits = BinaryHeap::with_capacity(10); @@ -71,7 +75,10 @@ impl<'a> LogWalker<'a> { } /// - pub fn read(&mut self, out: &mut Vec) -> Result { + pub fn read( + &mut self, + out: Option<&RefCell>>, + ) -> Result { let mut count = 0_usize; while let Some(c) = self.commits.pop() { @@ -88,11 +95,13 @@ impl<'a> LogWalker<'a> { }; if commit_should_be_included { - out.push(id); + if let Some(out) = out { + out.borrow_mut().push(id); + } } count += 1; - if count == self.limit { + if self.limit.is_some_and(|limit| limit == count) { break; } } @@ -184,19 +193,57 @@ impl<'a> LogWalkerWithoutFilter<'a> { mod tests { use super::*; use crate::error::Result; + use crate::sync::commit_files::{ + commit_contains_file, commit_detect_file_rename, + }; use crate::sync::commit_filter::{SearchFields, SearchOptions}; - use crate::sync::tests::write_commit_file; + use crate::sync::tests::{rename_file, write_commit_file}; use crate::sync::{ commit, get_commits_info, stage_add_file, tests::repo_init_empty, }; use crate::sync::{ - diff_contains_file, filter_commit_by_search, LogFilterSearch, + filter_commit_by_search, stage_add_all, LogFilterSearch, LogFilterSearchOptions, RepoPath, }; use pretty_assertions::assert_eq; + use std::sync::{Arc, RwLock}; use std::{fs::File, io::Write, path::Path}; + fn diff_contains_file( + file_path: Arc>, + ) -> SharedCommitFilterFn { + Arc::new(Box::new( + move |repo: &Repository, + commit_id: &CommitId| + -> Result { + let current_file_path = file_path.read()?.to_string(); + + if let Some(delta) = commit_contains_file( + repo, + *commit_id, + current_file_path.as_str(), + )? { + if matches!(delta, git2::Delta::Added) { + let rename = commit_detect_file_rename( + repo, + *commit_id, + current_file_path.as_str(), + )?; + + if let Some(old_name) = rename { + (*file_path.write()?) = old_name; + } + } + + return Ok(true); + } + + Ok(false) + }, + )) + } + #[test] fn test_limit() -> Result<()> { let file_path = Path::new("foo"); @@ -212,9 +259,10 @@ mod tests { stage_add_file(repo_path, file_path).unwrap(); let oid2 = commit(repo_path, "commit2").unwrap(); - let mut items = Vec::new(); - let mut walk = LogWalker::new(&repo, 1)?; - walk.read(&mut items).unwrap(); + let items = RefCell::new(Vec::new()); + let mut walk = LogWalker::new(&repo, Some(1))?; + walk.read(Some(&items)).unwrap(); + let items = items.take(); assert_eq!(items.len(), 1); assert_eq!(items[0], oid2); @@ -237,9 +285,10 @@ mod tests { stage_add_file(repo_path, file_path).unwrap(); let oid2 = commit(repo_path, "commit2").unwrap(); - let mut items = Vec::new(); - let mut walk = LogWalker::new(&repo, 100)?; - walk.read(&mut items).unwrap(); + let items = RefCell::new(Vec::new()); + let mut walk = LogWalker::new(&repo, Some(100))?; + walk.read(Some(&items)).unwrap(); + let items = items.take(); let info = get_commits_info(repo_path, &items, 50).unwrap(); dbg!(&info); @@ -247,8 +296,9 @@ mod tests { assert_eq!(items.len(), 2); assert_eq!(items[0], oid2); - let mut items = Vec::new(); - walk.read(&mut items).unwrap(); + let items = RefCell::new(Vec::new()); + walk.read(Some(&items)).unwrap(); + let items = items.take(); assert_eq!(items.len(), 0); @@ -316,27 +366,32 @@ mod tests { let _third_commit_id = commit(&repo_path, "commit3").unwrap(); - let diff_contains_baz = diff_contains_file("baz".into()); + let file_path = Arc::new(RwLock::new(String::from("baz"))); + let diff_contains_baz = diff_contains_file(file_path); - let mut items = Vec::new(); - let mut walker = LogWalker::new(&repo, 100)? + let items = RefCell::new(Vec::new()); + let mut walker = LogWalker::new(&repo, Some(100))? .filter(Some(diff_contains_baz)); - walker.read(&mut items).unwrap(); + walker.read(Some(&items)).unwrap(); + let items = items.take(); assert_eq!(items.len(), 1); assert_eq!(items[0], second_commit_id); - let mut items = Vec::new(); - walker.read(&mut items).unwrap(); + let items = RefCell::new(Vec::new()); + walker.read(Some(&items)).unwrap(); + let items = items.take(); assert_eq!(items.len(), 0); - let diff_contains_bar = diff_contains_file("bar".into()); + let file_path = Arc::new(RwLock::new(String::from("bar"))); + let diff_contains_bar = diff_contains_file(file_path); - let mut items = Vec::new(); - let mut walker = LogWalker::new(&repo, 100)? + let items = RefCell::new(Vec::new()); + let mut walker = LogWalker::new(&repo, Some(100))? .filter(Some(diff_contains_bar)); - walker.read(&mut items).unwrap(); + walker.read(Some(&items)).unwrap(); + let items = items.take(); assert_eq!(items.len(), 0); @@ -364,11 +419,12 @@ mod tests { }), ); - let mut items = Vec::new(); - let mut walker = LogWalker::new(&repo, 100) + let items = RefCell::new(Vec::new()); + let mut walker = LogWalker::new(&repo, Some(100)) .unwrap() .filter(Some(log_filter)); - walker.read(&mut items).unwrap(); + walker.read(Some(&items)).unwrap(); + let items = items.take(); assert_eq!(items.len(), 1); assert_eq!(items[0], second_commit_id); @@ -381,12 +437,46 @@ mod tests { }), ); - let mut items = Vec::new(); - let mut walker = LogWalker::new(&repo, 100) + let items = RefCell::new(Vec::new()); + let mut walker = LogWalker::new(&repo, Some(100)) .unwrap() .filter(Some(log_filter)); - walker.read(&mut items).unwrap(); + walker.read(Some(&items)).unwrap(); - assert_eq!(items.len(), 2); + assert_eq!(items.take().len(), 2); + } + + #[test] + fn test_logwalker_with_filter_rename() { + let (td, repo) = repo_init_empty().unwrap(); + let repo_path: RepoPath = td.path().into(); + + write_commit_file(&repo, "foo.txt", "foobar", "c1"); + rename_file(&repo, "foo.txt", "bar.txt"); + stage_add_all( + &repo_path, + "*", + Some(crate::sync::ShowUntrackedFilesConfig::All), + ) + .unwrap(); + let rename_commit = commit(&repo_path, "c2").unwrap(); + + write_commit_file(&repo, "bar.txt", "new content", "c3"); + + let file_path = + Arc::new(RwLock::new(String::from("bar.txt"))); + let log_filter = diff_contains_file(file_path.clone()); + + let items = RefCell::new(Vec::new()); + let mut walker = LogWalker::new(&repo, Some(100)) + .unwrap() + .filter(Some(log_filter)); + walker.read(Some(&items)).unwrap(); + let items = items.take(); + + assert_eq!(items.len(), 3); + assert_eq!(items[1], rename_commit); + + assert_eq!(file_path.read().unwrap().as_str(), "foo.txt"); } } diff --git a/asyncgit/src/sync/mod.rs b/asyncgit/src/sync/mod.rs index c52a556aad..e2073d5cc9 100644 --- a/asyncgit/src/sync/mod.rs +++ b/asyncgit/src/sync/mod.rs @@ -51,11 +51,11 @@ pub use commit_details::{ }; pub use commit_files::get_commit_files; pub use commit_filter::{ - diff_contains_file, filter_commit_by_search, LogFilterSearch, - LogFilterSearchOptions, SearchFields, SearchOptions, - SharedCommitFilterFn, + filter_commit_by_search, LogFilterSearch, LogFilterSearchOptions, + SearchFields, SearchOptions, SharedCommitFilterFn, }; pub use commit_revert::{commit_revert, revert_commit, revert_head}; +pub(crate) use commits_info::get_commit_info_repo; pub use commits_info::{ get_commit_info, get_commits_info, CommitId, CommitInfo, }; @@ -123,7 +123,7 @@ pub mod tests { }; use crate::error::Result; use git2::Repository; - use std::{path::Path, process::Command}; + use std::{cell::RefCell, path::Path, process::Command}; use tempfile::TempDir; /// @@ -252,13 +252,21 @@ pub mod tests { r: &Repository, max_count: usize, ) -> Vec { - let mut commit_ids = Vec::::new(); - LogWalker::new(r, max_count) + let commit_ids = RefCell::new(Vec::::new()); + LogWalker::new(r, Some(max_count)) .unwrap() - .read(&mut commit_ids) + .read(Some(&commit_ids)) .unwrap(); - commit_ids + commit_ids.take() + } + + /// + pub fn rename_file(repo: &Repository, old: &str, new: &str) { + let dir = repo.workdir().unwrap(); + let old = dir.join(old); + let new = dir.join(new); + std::fs::rename(old, new).unwrap(); } /// Same as `repo_init`, but the repo is a bare repo (--bare) diff --git a/asyncgit/src/sync/repository.rs b/asyncgit/src/sync/repository.rs index 2a0af47dbd..6a5f745754 100644 --- a/asyncgit/src/sync/repository.rs +++ b/asyncgit/src/sync/repository.rs @@ -47,6 +47,11 @@ impl From<&str> for RepoPath { Self::Path(PathBuf::from(p)) } } +impl From<&Path> for RepoPath { + fn from(p: &Path) -> Self { + Self::Path(PathBuf::from(p)) + } +} pub fn repo(repo_path: &RepoPath) -> Result { let repo = Repository::open_ext( diff --git a/src/components/mod.rs b/src/components/mod.rs index f20d3f981a..342169475d 100644 --- a/src/components/mod.rs +++ b/src/components/mod.rs @@ -21,9 +21,8 @@ pub use revision_files::RevisionFilesComponent; pub use syntax_text::SyntaxTextComponent; pub use textinput::{InputType, TextInputComponent}; pub use utils::{ - filetree::FileTreeItemKind, logitems::ItemBatch, - scroll_vertical::VerticalScroll, string_width_align, - time_to_string, + filetree::FileTreeItemKind, scroll_vertical::VerticalScroll, + string_width_align, time_to_string, }; use crate::ui::style::Theme; diff --git a/src/components/utils/logitems.rs b/src/components/utils/logitems.rs index 4c980b65fa..b8e39df078 100644 --- a/src/components/utils/logitems.rs +++ b/src/components/utils/logitems.rs @@ -61,7 +61,21 @@ impl From for LogEntry { impl LogEntry { pub fn time_to_string(&self, now: DateTime) -> String { - let delta = now - self.time; + Self::time_as_string(self.time, now) + } + + pub fn timestamp_to_datetime( + time: i64, + ) -> Option> { + Some(DateTime::<_>::from(DateTime::from_timestamp(time, 0)?)) + } + + /// + pub fn time_as_string( + time: DateTime, + now: DateTime, + ) -> String { + let delta = now - time; if delta < Duration::try_minutes(30).unwrap_or_default() { let delta_str = if delta < Duration::try_minutes(1).unwrap_or_default() @@ -71,10 +85,10 @@ impl LogEntry { format!("{:0>2}m ago", delta.num_minutes()) }; format!("{delta_str: <10}") - } else if self.time.date_naive() == now.date_naive() { - self.time.format("%T ").to_string() + } else if time.date_naive() == now.date_naive() { + time.format("%T ").to_string() } else { - self.time.format("%Y-%m-%d").to_string() + time.format("%Y-%m-%d").to_string() } } } diff --git a/src/popups/file_revlog.rs b/src/popups/file_revlog.rs index c946323b5d..d9bd31a165 100644 --- a/src/popups/file_revlog.rs +++ b/src/popups/file_revlog.rs @@ -3,7 +3,7 @@ use crate::{ components::{ event_pump, visibility_blocking, CommandBlocking, CommandInfo, Component, DiffComponent, DrawableComponent, - EventState, ItemBatch, ScrollType, + EventState, ScrollType, }, keys::{key_match, SharedKeyConfig}, options::SharedOptions, @@ -12,13 +12,13 @@ use crate::{ ui::{draw_scrollbar, style::SharedTheme, Orientation}, }; use anyhow::Result; +use asyncgit::asyncjob::AsyncSingleJob; use asyncgit::{ - sync::{ - diff_contains_file, get_commits_info, CommitId, RepoPathRef, - }, - AsyncDiff, AsyncGitNotification, AsyncLog, DiffParams, DiffType, + sync::{CommitId, RepoPathRef}, + AsyncDiff, AsyncGitNotification, DiffParams, DiffType, }; -use chrono::{DateTime, Local}; +use asyncgit::{AsyncFileHistoryJob, FileHistoryEntry}; +use chrono::{DateTime, Duration, Local}; use crossbeam_channel::Sender; use crossterm::event::Event; use ratatui::{ @@ -30,8 +30,6 @@ use ratatui::{ use super::{BlameFileOpen, InspectCommitOpen}; -const SLICE_SIZE: usize = 1200; - #[derive(Clone, Debug)] pub struct FileRevOpen { pub file_path: String, @@ -49,7 +47,7 @@ impl FileRevOpen { /// pub struct FileRevlogPopup { - git_log: Option, + git_history: Option>, git_diff: AsyncDiff, theme: SharedTheme, queue: Queue, @@ -59,7 +57,7 @@ pub struct FileRevlogPopup { repo_path: RepoPathRef, open_request: Option, table_state: std::cell::Cell, - items: ItemBatch, + items: Vec, count_total: usize, key_config: SharedKeyConfig, options: SharedOptions, @@ -75,16 +73,16 @@ impl FileRevlogPopup { queue: env.queue.clone(), sender: env.sender_git.clone(), diff: DiffComponent::new(env, true), - git_log: None, git_diff: AsyncDiff::new( env.repo.borrow().clone(), &env.sender_git, ), + git_history: None, visible: false, repo_path: env.repo.clone(), open_request: None, table_state: std::cell::Cell::new(TableState::default()), - items: ItemBatch::default(), + items: Vec::new(), count_total: 0, key_config: env.key_config.clone(), current_width: std::cell::Cell::new(0), @@ -99,16 +97,17 @@ impl FileRevlogPopup { /// pub fn open(&mut self, open_request: FileRevOpen) -> Result<()> { + self.items.clear(); self.open_request = Some(open_request.clone()); - let filter = diff_contains_file(open_request.file_path); - self.git_log = Some(AsyncLog::new( + let job = AsyncSingleJob::new(self.sender.clone()); + job.spawn(AsyncFileHistoryJob::new( self.repo_path.borrow().clone(), - &self.sender, - Some(filter), + open_request.file_path, )); - self.items.clear(); + self.git_history = Some(job); + self.set_selection(open_request.selection.unwrap_or(0)); self.show()?; @@ -124,17 +123,16 @@ impl FileRevlogPopup { /// pub fn any_work_pending(&self) -> bool { self.git_diff.is_pending() - || self.git_log.as_ref().is_some_and(AsyncLog::is_pending) + || self + .git_history + .as_ref() + .is_some_and(AsyncSingleJob::is_pending) } /// + //TODO: needed? pub fn update(&mut self) -> Result<()> { - if let Some(ref mut git_log) = self.git_log { - git_log.fetch()?; - - self.fetch_commits_if_needed()?; - self.update_diff()?; - } + self.update_list()?; Ok(()) } @@ -146,8 +144,9 @@ impl FileRevlogPopup { ) -> Result<()> { if self.visible { match event { - AsyncGitNotification::CommitFiles - | AsyncGitNotification::Log => self.update()?, + AsyncGitNotification::FileHistory => { + self.update_list()?; + } AsyncGitNotification::Diff => self.update_diff()?, _ => (), } @@ -158,33 +157,29 @@ impl FileRevlogPopup { pub fn update_diff(&mut self) -> Result<()> { if self.is_visible() { - if let Some(commit_id) = self.selected_commit() { - if let Some(open_request) = &self.open_request { - let diff_params = DiffParams { - path: open_request.file_path.clone(), - diff_type: DiffType::Commit(commit_id), - options: self.options.borrow().diff_options(), - }; - - if let Some((params, last)) = - self.git_diff.last()? - { - if params == diff_params { - self.diff.update( - open_request.file_path.to_string(), - false, - last, - ); - - return Ok(()); - } + if let Some(item) = self.selected_item() { + let diff_params = DiffParams { + path: item.file_path.clone(), + diff_type: DiffType::Commit(item.commit), + options: self.options.borrow().diff_options(), + }; + + if let Some((params, last)) = self.git_diff.last()? { + if params == diff_params { + self.diff.update( + item.file_path.clone(), + false, + last, + ); + + return Ok(()); } + } - self.git_diff.request(diff_params)?; - self.diff.clear(true); + self.git_diff.request(diff_params)?; + self.diff.clear(true); - return Ok(()); - } + return Ok(()); } self.diff.clear(false); @@ -193,49 +188,46 @@ impl FileRevlogPopup { Ok(()) } - fn fetch_commits( - &mut self, - new_offset: usize, - new_max_offset: usize, - ) -> Result<()> { - if let Some(git_log) = &mut self.git_log { - let amount = new_max_offset - .saturating_sub(new_offset) - .max(SLICE_SIZE); - - let commits = get_commits_info( - &self.repo_path.borrow(), - &git_log.get_slice(new_offset, amount)?, - self.current_width.get(), + pub fn update_list(&mut self) -> Result<()> { + if let Some(progress) = self + .git_history + .as_ref() + .and_then(asyncgit::asyncjob::AsyncSingleJob::progress) + { + let result = progress.extract_results()?; + + log::info!( + "file history update in progress: {}", + result.len() ); - if let Ok(commits) = commits { - self.items.set_items(new_offset, commits, None); - } + let was_empty = self.items.is_empty(); - self.count_total = git_log.count()?; + self.items.extend(result); + + if was_empty && !self.items.is_empty() { + self.queue + .push(InternalEvent::Update(NeedsUpdate::DIFF)); + } } Ok(()) } - fn selected_commit(&self) -> Option { + fn selected_item(&self) -> Option<&FileHistoryEntry> { let table_state = self.table_state.take(); - let commit_id = table_state.selected().and_then(|selected| { - self.items - .iter() - .nth( - selected - .saturating_sub(self.items.index_offset()), - ) - .as_ref() - .map(|entry| entry.id) - }); + let item = table_state + .selected() + .and_then(|selected| self.items.get(selected)); self.table_state.set(table_state); - commit_id + item + } + + fn selected_commit(&self) -> Option { + Some(self.selected_item()?.commit) } fn can_focus_diff(&self) -> bool { @@ -249,43 +241,78 @@ impl FileRevlogPopup { self.table_state.set(table); res }; - let revisions = self.get_max_selection(); + let revisions = self.items.len(); self.open_request.as_ref().map_or( "".into(), |open_request| { strings::file_log_title( &open_request.file_path, - selected, + selected + 1, revisions, ) }, ) } + fn time_as_string( + time: DateTime, + now: DateTime, + ) -> String { + let delta = now - time; + if delta < Duration::try_minutes(30).unwrap_or_default() { + let delta_str = if delta + < Duration::try_minutes(1).unwrap_or_default() + { + "<1m ago".to_string() + } else { + format!("{:0>2}m ago", delta.num_minutes()) + }; + format!("{delta_str: <10}") + } else if time.date_naive() == now.date_naive() { + time.format("%T ").to_string() + } else { + time.format("%Y-%m-%d").to_string() + } + } + + pub fn timestamp_to_datetime( + time: i64, + ) -> Option> { + Some(DateTime::<_>::from(DateTime::from_timestamp(time, 0)?)) + } + fn get_rows(&self, now: DateTime) -> Vec { self.items .iter() .map(|entry| { let spans = Line::from(vec![ Span::styled( - entry.hash_short.to_string(), + entry.commit.get_short_string(), self.theme.commit_hash(false), ), Span::raw(" "), Span::styled( - entry.time_to_string(now), + Self::time_as_string( + Self::timestamp_to_datetime( + entry.info.time, + ) + .unwrap_or_default(), + now, + ), self.theme.commit_time(false), ), Span::raw(" "), Span::styled( - entry.author.to_string(), + entry.info.author.clone(), self.theme.commit_author(false), ), ]); let mut text = Text::from(spans); - text.extend(Text::raw(entry.msg.to_string())); + text.extend(Text::raw( + entry.info.message.to_string(), + )); let cells = vec![Cell::from(""), Cell::from(text)]; @@ -294,19 +321,10 @@ impl FileRevlogPopup { .collect() } - fn get_max_selection(&self) -> usize { - self.git_log.as_ref().map_or(0, |log| { - log.count().unwrap_or(0).saturating_sub(1) - }) - } - - fn move_selection( - &mut self, - scroll_type: ScrollType, - ) -> Result<()> { + fn move_selection(&mut self, scroll_type: ScrollType) { let old_selection = self.table_state.get_mut().selected().unwrap_or(0); - let max_selection = self.get_max_selection(); + let max_selection = self.items.len().saturating_sub(1); let height_in_items = self.current_height.get() / 2; let new_selection = match scroll_type { @@ -330,9 +348,6 @@ impl FileRevlogPopup { } self.set_selection(new_selection); - self.fetch_commits_if_needed()?; - - Ok(()) } fn set_selection(&mut self, selection: usize) { @@ -349,22 +364,6 @@ impl FileRevlogPopup { self.table_state.get_mut().select(Some(selection)); } - fn fetch_commits_if_needed(&mut self) -> Result<()> { - let selection = - self.table_state.get_mut().selected().unwrap_or(0); - let offset = *self.table_state.get_mut().offset_mut(); - let height_in_items = - (self.current_height.get().saturating_sub(2)) / 2; - let new_max_offset = - selection.saturating_add(height_in_items); - - if self.items.needs_data(offset, new_max_offset) { - self.fetch_commits(offset, new_max_offset)?; - } - - Ok(()) - } - fn get_selection(&self) -> Option { let table_state = self.table_state.take(); let selection = table_state.selected(); @@ -410,14 +409,8 @@ impl FileRevlogPopup { // at index 50. Subtracting the current offset from the selected index // yields the correct index in `self.items`, in this case 0. let mut adjusted_table_state = TableState::default() - .with_selected(table_state.selected().map(|selected| { - selected.saturating_sub(self.items.index_offset()) - })) - .with_offset( - table_state - .offset() - .saturating_sub(self.items.index_offset()), - ); + .with_selected(table_state.selected()) + .with_offset(table_state.offset()); f.render_widget(Clear, area); f.render_stateful_widget( @@ -523,15 +516,19 @@ impl Component for FileRevlogPopup { )); }; } else if key_match(key, self.key_config.keys.blame) { - if let Some(open_request) = - self.open_request.clone() + if let Some(selected_item) = + self.selected_item().map(ToOwned::to_owned) { self.hide_stacked(true); self.queue.push(InternalEvent::OpenPopup( StackablePopupOpen::BlameFile( BlameFileOpen { - file_path: open_request.file_path, - commit_id: self.selected_commit(), + file_path: selected_item + .file_path + .clone(), + commit_id: Some( + selected_item.commit, + ), selection: None, }, ), @@ -539,12 +536,12 @@ impl Component for FileRevlogPopup { } } else if key_match(key, self.key_config.keys.move_up) { - self.move_selection(ScrollType::Up)?; + self.move_selection(ScrollType::Up); } else if key_match( key, self.key_config.keys.move_down, ) { - self.move_selection(ScrollType::Down)?; + self.move_selection(ScrollType::Down); } else if key_match( key, self.key_config.keys.shift_up, @@ -552,7 +549,7 @@ impl Component for FileRevlogPopup { key, self.key_config.keys.home, ) { - self.move_selection(ScrollType::Home)?; + self.move_selection(ScrollType::Home); } else if key_match( key, self.key_config.keys.shift_down, @@ -560,15 +557,15 @@ impl Component for FileRevlogPopup { key, self.key_config.keys.end, ) { - self.move_selection(ScrollType::End)?; + self.move_selection(ScrollType::End); } else if key_match(key, self.key_config.keys.page_up) { - self.move_selection(ScrollType::PageUp)?; + self.move_selection(ScrollType::PageUp); } else if key_match( key, self.key_config.keys.page_down, ) { - self.move_selection(ScrollType::PageDown)?; + self.move_selection(ScrollType::PageDown); } }