git.rs 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297
  1. //! Getting the Git status of files and directories.
  2. use std::path::{Path, PathBuf};
  3. use std::sync::Mutex;
  4. use git2;
  5. use fs::fields as f;
  6. /// A **Git cache** is assembled based on the user’s input arguments.
  7. ///
  8. /// This uses vectors to avoid the overhead of hashing: it’s not worth it when the
  9. /// expected number of Git repositories per exa invocation is 0 or 1...
  10. pub struct GitCache {
  11. /// A list of discovered Git repositories and their paths.
  12. repos: Vec<GitRepo>,
  13. /// Paths that we’ve confirmed do not have Git repositories underneath them.
  14. misses: Vec<PathBuf>,
  15. }
  16. impl GitCache {
  17. pub fn has_anything_for(&self, index: &Path) -> bool {
  18. self.repos.iter().any(|e| e.has_path(index))
  19. }
  20. pub fn get(&self, index: &Path, prefix_lookup: bool) -> f::Git {
  21. self.repos.iter()
  22. .find(|e| e.has_path(index))
  23. .map(|repo| repo.search(index, prefix_lookup))
  24. .unwrap_or_default()
  25. }
  26. }
  27. use std::iter::FromIterator;
  28. impl FromIterator<PathBuf> for GitCache {
  29. fn from_iter<I: IntoIterator<Item=PathBuf>>(iter: I) -> Self {
  30. let iter = iter.into_iter();
  31. let mut git = GitCache {
  32. repos: Vec::with_capacity(iter.size_hint().0),
  33. misses: Vec::new(),
  34. };
  35. for path in iter {
  36. if git.misses.contains(&path) {
  37. debug!("Skipping {:?} because it already came back Gitless", path);
  38. }
  39. else if git.repos.iter().any(|e| e.has_path(&path)) {
  40. debug!("Skipping {:?} because we already queried it", path);
  41. }
  42. else {
  43. match GitRepo::discover(path) {
  44. Ok(r) => {
  45. if let Some(mut r2) = git.repos.iter_mut().find(|e| e.has_workdir(&r.workdir)) {
  46. debug!("Adding to existing repo (workdir matches with {:?})", r2.workdir);
  47. r2.extra_paths.push(r.original_path);
  48. continue;
  49. }
  50. debug!("Discovered new Git repo");
  51. git.repos.push(r);
  52. },
  53. Err(miss) => git.misses.push(miss),
  54. }
  55. }
  56. }
  57. git
  58. }
  59. }
  60. /// A **Git repository** is one we’ve discovered somewhere on the filesystem.
  61. pub struct GitRepo {
  62. /// The queryable contents of the repository: either a `git2` repo, or the
  63. /// cached results from when we queried it last time.
  64. contents: Mutex<GitContents>,
  65. /// The working directory of this repository.
  66. /// This is used to check whether two repositories are the same.
  67. workdir: PathBuf,
  68. /// The path that was originally checked to discover this repository.
  69. /// This is as important as the extra_paths (it gets checked first), but
  70. /// is separate to avoid having to deal with a non-empty Vec.
  71. original_path: PathBuf,
  72. /// Any other paths that were checked only to result in this same
  73. /// repository.
  74. extra_paths: Vec<PathBuf>,
  75. }
  76. /// A repository’s queried state.
  77. enum GitContents {
  78. /// All the interesting Git stuff goes through this.
  79. Before { repo: git2::Repository },
  80. /// Temporary value used in `repo_to_statuses` so we can move the
  81. /// repository out of the `Before` variant.
  82. Processing,
  83. /// The data we’ve extracted from the repository, but only after we’ve
  84. /// actually done so.
  85. After { statuses: Git }
  86. }
  87. impl GitRepo {
  88. /// Searches through this repository for a path (to a file or directory,
  89. /// depending on the prefix-lookup flag) and returns its Git status.
  90. ///
  91. /// Actually querying the `git2` repository for the mapping of paths to
  92. /// Git statuses is only done once, and gets cached so we don't need to
  93. /// re-query the entire repository the times after that.
  94. ///
  95. /// The temporary `Processing` enum variant is used after the `git2`
  96. /// repository is moved out, but before the results have been moved in!
  97. /// See https://stackoverflow.com/q/45985827/3484614
  98. fn search(&self, index: &Path, prefix_lookup: bool) -> f::Git {
  99. use self::GitContents::*;
  100. use std::mem::replace;
  101. let mut contents = self.contents.lock().unwrap();
  102. if let After { ref statuses } = *contents {
  103. debug!("Git repo {:?} has been found in cache", &self.workdir);
  104. return statuses.status(index, prefix_lookup);
  105. }
  106. debug!("Querying Git repo {:?} for the first time", &self.workdir);
  107. let repo = replace(&mut *contents, Processing).inner_repo();
  108. let statuses = repo_to_statuses(&repo, &self.workdir);
  109. let result = statuses.status(index, prefix_lookup);
  110. let _processing = replace(&mut *contents, After { statuses });
  111. result
  112. }
  113. /// Whether this repository has the given working directory.
  114. fn has_workdir(&self, path: &Path) -> bool {
  115. self.workdir == path
  116. }
  117. /// Whether this repository cares about the given path at all.
  118. fn has_path(&self, path: &Path) -> bool {
  119. path.starts_with(&self.original_path) || self.extra_paths.iter().any(|e| path.starts_with(e))
  120. }
  121. /// Searches for a Git repository at any point above the given path.
  122. /// Returns the original buffer if none is found.
  123. fn discover(path: PathBuf) -> Result<GitRepo, PathBuf> {
  124. info!("Searching for Git repository above {:?}", path);
  125. let repo = match git2::Repository::discover(&path) {
  126. Ok(r) => r,
  127. Err(e) => {
  128. error!("Error discovering Git repositories: {:?}", e);
  129. return Err(path);
  130. }
  131. };
  132. match repo.workdir().map(|wd| wd.to_path_buf()) {
  133. Some(workdir) => {
  134. let contents = Mutex::new(GitContents::Before { repo });
  135. Ok(GitRepo { contents, workdir, original_path: path, extra_paths: Vec::new() })
  136. },
  137. None => {
  138. warn!("Repository has no workdir?");
  139. Err(path)
  140. }
  141. }
  142. }
  143. }
  144. impl GitContents {
  145. /// Assumes that the repository hasn’t been queried, and extracts it
  146. /// (consuming the value) if it has. This is needed because the entire
  147. /// enum variant gets replaced when a repo is queried (see above).
  148. fn inner_repo(self) -> git2::Repository {
  149. if let GitContents::Before { repo } = self {
  150. repo
  151. }
  152. else {
  153. unreachable!("Tried to extract a non-Repository")
  154. }
  155. }
  156. }
  157. /// Iterates through a repository’s statuses, consuming it and returning the
  158. /// mapping of files to their Git status.
  159. /// We will have already used the working directory at this point, so it gets
  160. /// passed in rather than deriving it from the `Repository` again.
  161. fn repo_to_statuses(repo: &git2::Repository, workdir: &Path) -> Git {
  162. let mut statuses = Vec::new();
  163. info!("Getting Git statuses for repo with workdir {:?}", workdir);
  164. match repo.statuses(None) {
  165. Ok(es) => {
  166. for e in es.iter() {
  167. let path = workdir.join(Path::new(e.path().unwrap()));
  168. let elem = (path, e.status());
  169. statuses.push(elem);
  170. }
  171. },
  172. Err(e) => error!("Error looking up Git statuses: {:?}", e),
  173. }
  174. Git { statuses }
  175. }
  176. // The `repo.statuses` call above takes a long time. exa debug output:
  177. //
  178. // 20.311276 INFO:exa::fs::feature::git: Getting Git statuses for repo with workdir "/vagrant/"
  179. // 20.799610 DEBUG:exa::output::table: Getting Git status for file "./Cargo.toml"
  180. //
  181. // Even inserting another logging line immediately afterwards doesn't make it
  182. // look any faster.
  183. /// Container of Git statuses for all the files in this folder’s Git repository.
  184. struct Git {
  185. statuses: Vec<(PathBuf, git2::Status)>,
  186. }
  187. impl Git {
  188. /// Get either the file or directory status for the given path.
  189. /// “Prefix lookup” means that it should report an aggregate status of all
  190. /// paths starting with the given prefix (in other words, a directory).
  191. fn status(&self, index: &Path, prefix_lookup: bool) -> f::Git {
  192. if prefix_lookup { self.dir_status(index) }
  193. else { self.file_status(index) }
  194. }
  195. /// Get the status for the file at the given path.
  196. fn file_status(&self, file: &Path) -> f::Git {
  197. let path = reorient(file);
  198. self.statuses.iter()
  199. .find(|p| p.0.as_path() == path)
  200. .map(|&(_, s)| f::Git { staged: index_status(s), unstaged: working_tree_status(s) })
  201. .unwrap_or_default()
  202. }
  203. /// Get the combined status for all the files whose paths begin with the
  204. /// path that gets passed in. This is used for getting the status of
  205. /// directories, which don’t really have an ‘official’ status.
  206. fn dir_status(&self, dir: &Path) -> f::Git {
  207. let path = reorient(dir);
  208. let s = self.statuses.iter()
  209. .filter(|p| p.0.starts_with(&path))
  210. .fold(git2::Status::empty(), |a, b| a | b.1);
  211. f::Git { staged: index_status(s), unstaged: working_tree_status(s) }
  212. }
  213. }
  214. /// Converts a path to an absolute path based on the current directory.
  215. /// Paths need to be absolute for them to be compared properly, otherwise
  216. /// you’d ask a repo about “./README.md” but it only knows about
  217. /// “/vagrant/REAMDE.md”, prefixed by the workdir.
  218. fn reorient(path: &Path) -> PathBuf {
  219. use std::env::current_dir;
  220. // I’m not 100% on this func tbh
  221. match current_dir() {
  222. Err(_) => Path::new(".").join(&path),
  223. Ok(dir) => dir.join(&path),
  224. }
  225. }
  226. /// The character to display if the file has been modified, but not staged.
  227. fn working_tree_status(status: git2::Status) -> f::GitStatus {
  228. match status {
  229. s if s.contains(git2::Status::WT_NEW) => f::GitStatus::New,
  230. s if s.contains(git2::Status::WT_MODIFIED) => f::GitStatus::Modified,
  231. s if s.contains(git2::Status::WT_DELETED) => f::GitStatus::Deleted,
  232. s if s.contains(git2::Status::WT_RENAMED) => f::GitStatus::Renamed,
  233. s if s.contains(git2::Status::WT_TYPECHANGE) => f::GitStatus::TypeChange,
  234. _ => f::GitStatus::NotModified,
  235. }
  236. }
  237. /// The character to display if the file has been modified and the change
  238. /// has been staged.
  239. fn index_status(status: git2::Status) -> f::GitStatus {
  240. match status {
  241. s if s.contains(git2::Status::INDEX_NEW) => f::GitStatus::New,
  242. s if s.contains(git2::Status::INDEX_MODIFIED) => f::GitStatus::Modified,
  243. s if s.contains(git2::Status::INDEX_DELETED) => f::GitStatus::Deleted,
  244. s if s.contains(git2::Status::INDEX_RENAMED) => f::GitStatus::Renamed,
  245. s if s.contains(git2::Status::INDEX_TYPECHANGE) => f::GitStatus::TypeChange,
  246. _ => f::GitStatus::NotModified,
  247. }
  248. }