git.rs 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411
  1. //! Getting the Git status of files and directories.
  2. use std::env;
  3. use std::ffi::OsStr;
  4. #[cfg(target_family = "unix")]
  5. use std::os::unix::ffi::OsStrExt;
  6. use std::path::{Path, PathBuf};
  7. use std::sync::Mutex;
  8. use log::*;
  9. use crate::fs::fields as f;
  10. /// A **Git cache** is assembled based on the user’s input arguments.
  11. ///
  12. /// This uses vectors to avoid the overhead of hashing: it’s not worth it when the
  13. /// expected number of Git repositories per exa invocation is 0 or 1...
  14. pub struct GitCache {
  15. /// A list of discovered Git repositories and their paths.
  16. repos: Vec<GitRepo>,
  17. /// Paths that we’ve confirmed do not have Git repositories underneath them.
  18. misses: Vec<PathBuf>,
  19. }
  20. impl GitCache {
  21. pub fn has_anything_for(&self, index: &Path) -> bool {
  22. self.repos.iter().any(|e| e.has_path(index))
  23. }
  24. pub fn get(&self, index: &Path, prefix_lookup: bool) -> f::Git {
  25. self.repos.iter()
  26. .find(|e| e.has_path(index))
  27. .map(|repo| repo.search(index, prefix_lookup))
  28. .unwrap_or_default()
  29. }
  30. }
  31. use std::iter::FromIterator;
  32. impl FromIterator<PathBuf> for GitCache {
  33. fn from_iter<I>(iter: I) -> Self
  34. where I: IntoIterator<Item=PathBuf>
  35. {
  36. let iter = iter.into_iter();
  37. let mut git = Self {
  38. repos: Vec::with_capacity(iter.size_hint().0),
  39. misses: Vec::new(),
  40. };
  41. if let Ok(path) = env::var("GIT_DIR") {
  42. // These flags are consistent with how `git` uses GIT_DIR:
  43. let flags = git2::RepositoryOpenFlags::NO_SEARCH | git2::RepositoryOpenFlags::NO_DOTGIT;
  44. match GitRepo::discover(path.into(), flags) {
  45. Ok(repo) => {
  46. debug!("Opened GIT_DIR repo");
  47. git.repos.push(repo);
  48. }
  49. Err(miss) => {
  50. git.misses.push(miss);
  51. }
  52. }
  53. }
  54. for path in iter {
  55. if git.misses.contains(&path) {
  56. debug!("Skipping {:?} because it already came back Gitless", path);
  57. }
  58. else if git.repos.iter().any(|e| e.has_path(&path)) {
  59. debug!("Skipping {:?} because we already queried it", path);
  60. }
  61. else {
  62. let flags = git2::RepositoryOpenFlags::FROM_ENV;
  63. match GitRepo::discover(path, flags) {
  64. Ok(r) => {
  65. if let Some(r2) = git.repos.iter_mut().find(|e| e.has_workdir(&r.workdir)) {
  66. debug!("Adding to existing repo (workdir matches with {:?})", r2.workdir);
  67. r2.extra_paths.push(r.original_path);
  68. continue;
  69. }
  70. debug!("Discovered new Git repo");
  71. git.repos.push(r);
  72. }
  73. Err(miss) => {
  74. git.misses.push(miss)
  75. }
  76. }
  77. }
  78. }
  79. git
  80. }
  81. }
  82. /// A **Git repository** is one we’ve discovered somewhere on the filesystem.
  83. pub struct GitRepo {
  84. /// The queryable contents of the repository: either a `git2` repo, or the
  85. /// cached results from when we queried it last time.
  86. contents: Mutex<GitContents>,
  87. /// The working directory of this repository.
  88. /// This is used to check whether two repositories are the same.
  89. workdir: PathBuf,
  90. /// The path that was originally checked to discover this repository.
  91. /// This is as important as the extra_paths (it gets checked first), but
  92. /// is separate to avoid having to deal with a non-empty Vec.
  93. original_path: PathBuf,
  94. /// Any other paths that were checked only to result in this same
  95. /// repository.
  96. extra_paths: Vec<PathBuf>,
  97. }
  98. /// A repository’s queried state.
  99. enum GitContents {
  100. /// All the interesting Git stuff goes through this.
  101. Before {
  102. repo: git2::Repository,
  103. },
  104. /// Temporary value used in `repo_to_statuses` so we can move the
  105. /// repository out of the `Before` variant.
  106. Processing,
  107. /// The data we’ve extracted from the repository, but only after we’ve
  108. /// actually done so.
  109. After {
  110. statuses: Git,
  111. },
  112. }
  113. impl GitRepo {
  114. /// Searches through this repository for a path (to a file or directory,
  115. /// depending on the prefix-lookup flag) and returns its Git status.
  116. ///
  117. /// Actually querying the `git2` repository for the mapping of paths to
  118. /// Git statuses is only done once, and gets cached so we don’t need to
  119. /// re-query the entire repository the times after that.
  120. ///
  121. /// The temporary `Processing` enum variant is used after the `git2`
  122. /// repository is moved out, but before the results have been moved in!
  123. /// See <https://stackoverflow.com/q/45985827/3484614>
  124. fn search(&self, index: &Path, prefix_lookup: bool) -> f::Git {
  125. use std::mem::replace;
  126. let mut contents = self.contents.lock().unwrap();
  127. if let GitContents::After { ref statuses } = *contents {
  128. debug!("Git repo {:?} has been found in cache", &self.workdir);
  129. return statuses.status(index, prefix_lookup);
  130. }
  131. debug!("Querying Git repo {:?} for the first time", &self.workdir);
  132. let repo = replace(&mut *contents, GitContents::Processing).inner_repo();
  133. let statuses = repo_to_statuses(&repo, &self.workdir);
  134. let result = statuses.status(index, prefix_lookup);
  135. let _processing = replace(&mut *contents, GitContents::After { statuses });
  136. result
  137. }
  138. /// Whether this repository has the given working directory.
  139. fn has_workdir(&self, path: &Path) -> bool {
  140. self.workdir == path
  141. }
  142. /// Whether this repository cares about the given path at all.
  143. fn has_path(&self, path: &Path) -> bool {
  144. path.starts_with(&self.original_path) || self.extra_paths.iter().any(|e| path.starts_with(e))
  145. }
  146. /// Open a Git repository. Depending on the flags, the path is either
  147. /// the repository's "gitdir" (or a "gitlink" to the gitdir), or the
  148. /// path is the start of a rootwards search for the repository.
  149. fn discover(path: PathBuf, flags: git2::RepositoryOpenFlags) -> Result<Self, PathBuf> {
  150. info!("Opening Git repository for {:?} ({:?})", path, flags);
  151. let repo = match git2::Repository::open_ext(&path, flags, [] as [&OsStr; 0]) {
  152. Ok(r) => r,
  153. Err(e) => {
  154. error!("Error opening Git repository for {:?}: {:?}", path, e);
  155. return Err(path);
  156. }
  157. };
  158. if let Some(workdir) = repo.workdir() {
  159. let workdir = workdir.to_path_buf();
  160. let contents = Mutex::new(GitContents::Before { repo });
  161. Ok(Self { contents, workdir, original_path: path, extra_paths: Vec::new() })
  162. }
  163. else {
  164. warn!("Repository has no workdir?");
  165. Err(path)
  166. }
  167. }
  168. }
  169. impl GitContents {
  170. /// Assumes that the repository hasn’t been queried, and extracts it
  171. /// (consuming the value) if it has. This is needed because the entire
  172. /// enum variant gets replaced when a repo is queried (see above).
  173. fn inner_repo(self) -> git2::Repository {
  174. if let Self::Before { repo } = self {
  175. repo
  176. }
  177. else {
  178. unreachable!("Tried to extract a non-Repository")
  179. }
  180. }
  181. }
  182. /// Iterates through a repository’s statuses, consuming it and returning the
  183. /// mapping of files to their Git status.
  184. /// We will have already used the working directory at this point, so it gets
  185. /// passed in rather than deriving it from the `Repository` again.
  186. fn repo_to_statuses(repo: &git2::Repository, workdir: &Path) -> Git {
  187. let mut statuses = Vec::new();
  188. info!("Getting Git statuses for repo with workdir {:?}", workdir);
  189. match repo.statuses(None) {
  190. Ok(es) => {
  191. for e in es.iter() {
  192. #[cfg(target_family = "unix")]
  193. let path = workdir.join(Path::new(OsStr::from_bytes(e.path_bytes())));
  194. // TODO: handle non Unix systems better:
  195. // https://github.com/ogham/exa/issues/698
  196. #[cfg(not(target_family = "unix"))]
  197. let path = workdir.join(Path::new(e.path().unwrap()));
  198. let elem = (path, e.status());
  199. statuses.push(elem);
  200. }
  201. }
  202. Err(e) => {
  203. error!("Error looking up Git statuses: {:?}", e);
  204. }
  205. }
  206. Git { statuses }
  207. }
  208. // The `repo.statuses` call above takes a long time. exa debug output:
  209. //
  210. // 20.311276 INFO:exa::fs::feature::git: Getting Git statuses for repo with workdir "/vagrant/"
  211. // 20.799610 DEBUG:exa::output::table: Getting Git status for file "./Cargo.toml"
  212. //
  213. // Even inserting another logging line immediately afterwards doesn’t make it
  214. // look any faster.
  215. /// Container of Git statuses for all the files in this folder’s Git repository.
  216. struct Git {
  217. statuses: Vec<(PathBuf, git2::Status)>,
  218. }
  219. impl Git {
  220. /// Get either the file or directory status for the given path.
  221. /// “Prefix lookup” means that it should report an aggregate status of all
  222. /// paths starting with the given prefix (in other words, a directory).
  223. fn status(&self, index: &Path, prefix_lookup: bool) -> f::Git {
  224. if prefix_lookup { self.dir_status(index) }
  225. else { self.file_status(index) }
  226. }
  227. /// Get the user-facing status of a file.
  228. /// We check the statuses directly applying to a file, and for the ignored
  229. /// status we check if any of its parents directories is ignored by git.
  230. fn file_status(&self, file: &Path) -> f::Git {
  231. let path = reorient(file);
  232. let s = self.statuses.iter()
  233. .filter(|p| if p.1 == git2::Status::IGNORED {
  234. path.starts_with(&p.0)
  235. } else {
  236. p.0 == path
  237. })
  238. .fold(git2::Status::empty(), |a, b| a | b.1);
  239. let staged = index_status(s);
  240. let unstaged = working_tree_status(s);
  241. f::Git { staged, unstaged }
  242. }
  243. /// Get the combined, user-facing status of a directory.
  244. /// Statuses are aggregating (for example, a directory is considered
  245. /// modified if any file under it has the status modified), except for
  246. /// ignored status which applies to files under (for example, a directory
  247. /// is considered ignored if one of its parent directories is ignored).
  248. fn dir_status(&self, dir: &Path) -> f::Git {
  249. let path = reorient(dir);
  250. let s = self.statuses.iter()
  251. .filter(|p| if p.1 == git2::Status::IGNORED {
  252. path.starts_with(&p.0)
  253. } else {
  254. p.0.starts_with(&path)
  255. })
  256. .fold(git2::Status::empty(), |a, b| a | b.1);
  257. let staged = index_status(s);
  258. let unstaged = working_tree_status(s);
  259. f::Git { staged, unstaged }
  260. }
  261. }
  262. /// Converts a path to an absolute path based on the current directory.
  263. /// Paths need to be absolute for them to be compared properly, otherwise
  264. /// you’d ask a repo about “./README.md” but it only knows about
  265. /// “/vagrant/README.md”, prefixed by the workdir.
  266. #[cfg(unix)]
  267. fn reorient(path: &Path) -> PathBuf {
  268. use std::env::current_dir;
  269. // TODO: I’m not 100% on this func tbh
  270. let path = match current_dir() {
  271. Err(_) => Path::new(".").join(&path),
  272. Ok(dir) => dir.join(&path),
  273. };
  274. path.canonicalize().unwrap_or(path)
  275. }
  276. #[cfg(windows)]
  277. fn reorient(path: &Path) -> PathBuf {
  278. let unc_path = path.canonicalize().unwrap();
  279. // On Windows UNC path is returned. We need to strip the prefix for it to work.
  280. let normal_path = unc_path.as_os_str().to_str().unwrap().trim_left_matches("\\\\?\\");
  281. return PathBuf::from(normal_path);
  282. }
  283. /// The character to display if the file has been modified, but not staged.
  284. fn working_tree_status(status: git2::Status) -> f::GitStatus {
  285. match status {
  286. s if s.contains(git2::Status::WT_NEW) => f::GitStatus::New,
  287. s if s.contains(git2::Status::WT_MODIFIED) => f::GitStatus::Modified,
  288. s if s.contains(git2::Status::WT_DELETED) => f::GitStatus::Deleted,
  289. s if s.contains(git2::Status::WT_RENAMED) => f::GitStatus::Renamed,
  290. s if s.contains(git2::Status::WT_TYPECHANGE) => f::GitStatus::TypeChange,
  291. s if s.contains(git2::Status::IGNORED) => f::GitStatus::Ignored,
  292. s if s.contains(git2::Status::CONFLICTED) => f::GitStatus::Conflicted,
  293. _ => f::GitStatus::NotModified,
  294. }
  295. }
  296. /// The character to display if the file has been modified and the change
  297. /// has been staged.
  298. fn index_status(status: git2::Status) -> f::GitStatus {
  299. match status {
  300. s if s.contains(git2::Status::INDEX_NEW) => f::GitStatus::New,
  301. s if s.contains(git2::Status::INDEX_MODIFIED) => f::GitStatus::Modified,
  302. s if s.contains(git2::Status::INDEX_DELETED) => f::GitStatus::Deleted,
  303. s if s.contains(git2::Status::INDEX_RENAMED) => f::GitStatus::Renamed,
  304. s if s.contains(git2::Status::INDEX_TYPECHANGE) => f::GitStatus::TypeChange,
  305. _ => f::GitStatus::NotModified,
  306. }
  307. }
  308. fn current_branch(repo: &git2::Repository) -> Option<String>{
  309. let head = match repo.head() {
  310. Ok(head) => Some(head),
  311. Err(ref e) if e.code() == git2::ErrorCode::UnbornBranch || e.code() == git2::ErrorCode::NotFound => return None,
  312. Err(e) => {
  313. error!("Error looking up Git branch: {:?}", e);
  314. return None
  315. }
  316. };
  317. if let Some(h) = head{
  318. if let Some(s) = h.shorthand(){
  319. let branch_name = s.to_owned();
  320. if branch_name.len() > 10 {
  321. return Some(branch_name[..8].to_string()+"..");
  322. }
  323. return Some(branch_name);
  324. }
  325. }
  326. None
  327. }
  328. impl f::SubdirGitRepo{
  329. pub fn from_path(dir : &Path, status : bool) -> Self{
  330. let path = &reorient(&dir);
  331. let g = git2::Repository::open(path);
  332. if let Ok(repo) = g{
  333. let branch = current_branch(&repo);
  334. if !status{
  335. return Self{status : f::SubdirGitRepoStatus::GitUnknown, branch};
  336. }
  337. match repo.statuses(None) {
  338. Ok(es) => {
  339. if es.iter().filter(|s| s.status() != git2::Status::IGNORED).any(|_| true){
  340. return Self{status : f::SubdirGitRepoStatus::GitDirty, branch};
  341. }
  342. return Self{status : f::SubdirGitRepoStatus::GitClean, branch};
  343. }
  344. Err(e) => {
  345. error!("Error looking up Git statuses: {:?}", e)
  346. }
  347. }
  348. }
  349. Self::default()
  350. }
  351. }