|
| 1 | +use std::{ |
| 2 | + collections::HashMap, |
| 3 | + ops::Range, |
| 4 | + path::{Path, PathBuf}, |
| 5 | +}; |
| 6 | + |
| 7 | +use git2::{Oid, Repository}; |
| 8 | +use similar::DiffTag; |
| 9 | + |
| 10 | +use crate::{LineDiff, LineDiffs, RepoRoot}; |
| 11 | + |
| 12 | +pub struct Git { |
| 13 | + repo: Repository, |
| 14 | + /// Absolute path to root of the repo |
| 15 | + root: RepoRoot, |
| 16 | + head: Oid, |
| 17 | + |
| 18 | + /// A cache mapping absolute file paths to file contents |
| 19 | + /// in the HEAD commit. |
| 20 | + head_cache: HashMap<PathBuf, String>, |
| 21 | +} |
| 22 | + |
| 23 | +impl std::fmt::Debug for Git { |
| 24 | + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
| 25 | + f.debug_struct("Git").field("root", &self.root).finish() |
| 26 | + } |
| 27 | +} |
| 28 | + |
| 29 | +impl Git { |
| 30 | + pub fn head_commit_id(repo: &Repository) -> Option<Oid> { |
| 31 | + repo.head() |
| 32 | + .and_then(|gitref| gitref.peel_to_commit()) |
| 33 | + .map(|commit| commit.id()) |
| 34 | + .ok() |
| 35 | + } |
| 36 | + |
| 37 | + pub fn discover_from_path(file: &Path) -> Option<Self> { |
| 38 | + let repo = Repository::discover(file).ok()?; |
| 39 | + let root = repo.workdir()?.to_path_buf(); |
| 40 | + let head_oid = Self::head_commit_id(&repo)?; |
| 41 | + Some(Self { |
| 42 | + repo, |
| 43 | + root, |
| 44 | + head: head_oid, |
| 45 | + head_cache: HashMap::new(), |
| 46 | + }) |
| 47 | + } |
| 48 | + |
| 49 | + pub fn root(&self) -> &Path { |
| 50 | + &self.root |
| 51 | + } |
| 52 | + |
| 53 | + fn relative_to_root<'p>(&self, path: &'p Path) -> Option<&'p Path> { |
| 54 | + path.strip_prefix(&self.root).ok() |
| 55 | + } |
| 56 | + |
| 57 | + pub fn read_file_from_head(&mut self, file: &Path) -> Option<&str> { |
| 58 | + let current_head = Self::head_commit_id(&self.repo)?; |
| 59 | + // TODO: Check cache validity on events like WindowChange |
| 60 | + // instead of on every keypress ? Will require hooks. |
| 61 | + if current_head != self.head { |
| 62 | + self.head_cache.clear(); |
| 63 | + self.head = current_head; |
| 64 | + } |
| 65 | + |
| 66 | + if !self.head_cache.contains_key(file) { |
| 67 | + let relative = self.relative_to_root(file)?; |
| 68 | + let revision = &format!("HEAD:{}", relative.display()); |
| 69 | + let object = self.repo.revparse_single(revision).ok()?; |
| 70 | + let blob = object.peel_to_blob().ok()?; |
| 71 | + let contents = std::str::from_utf8(blob.content()).ok()?; |
| 72 | + self.head_cache |
| 73 | + .insert(file.to_path_buf(), contents.to_string()); |
| 74 | + } |
| 75 | + |
| 76 | + self.head_cache.get(file).map(|s| s.as_str()) |
| 77 | + } |
| 78 | + |
| 79 | + pub fn line_diff_with_head(&mut self, file: &Path, contents: &str) -> LineDiffs { |
| 80 | + let base = match self.read_file_from_head(file) { |
| 81 | + Some(b) => b, |
| 82 | + None => return LineDiffs::new(), |
| 83 | + }; |
| 84 | + let mut config = similar::TextDiff::configure(); |
| 85 | + config.timeout(std::time::Duration::from_millis(250)); |
| 86 | + |
| 87 | + let mut line_diffs: LineDiffs = HashMap::new(); |
| 88 | + |
| 89 | + let mut mark_lines = |range: Range<usize>, change: LineDiff| { |
| 90 | + for line in range { |
| 91 | + line_diffs.insert(line, change); |
| 92 | + } |
| 93 | + }; |
| 94 | + |
| 95 | + let diff = config.diff_lines(base, contents); |
| 96 | + for op in diff.ops() { |
| 97 | + let (tag, _, line_range) = op.as_tag_tuple(); |
| 98 | + let start = line_range.start; |
| 99 | + match tag { |
| 100 | + DiffTag::Insert => mark_lines(line_range, LineDiff::Added), |
| 101 | + DiffTag::Replace => mark_lines(line_range, LineDiff::Modified), |
| 102 | + DiffTag::Delete => mark_lines(start..start + 1, LineDiff::Deleted), |
| 103 | + DiffTag::Equal => (), |
| 104 | + } |
| 105 | + } |
| 106 | + |
| 107 | + line_diffs |
| 108 | + } |
| 109 | +} |
| 110 | + |
| 111 | +#[cfg(test)] |
| 112 | +mod test { |
| 113 | + use std::{ |
| 114 | + fs::{self, File}, |
| 115 | + process::Command, |
| 116 | + }; |
| 117 | + |
| 118 | + use tempfile::TempDir; |
| 119 | + |
| 120 | + use super::*; |
| 121 | + |
| 122 | + fn empty_git_repo() -> TempDir { |
| 123 | + let tmp = tempfile::tempdir().expect("Could not create temp dir for git testing"); |
| 124 | + exec_git_cmd("init", tmp.path()); |
| 125 | + tmp |
| 126 | + } |
| 127 | + |
| 128 | + fn exec_git_cmd(args: &str, git_dir: &Path) { |
| 129 | + Command::new("git") |
| 130 | + .arg("-C") |
| 131 | + .arg(git_dir) // execute the git command in this directory |
| 132 | + .args(args.split_whitespace()) |
| 133 | + .status() |
| 134 | + .expect(&format!("`git {args}` failed")) |
| 135 | + .success() |
| 136 | + .then(|| ()) |
| 137 | + .expect(&format!("`git {args}` failed")); |
| 138 | + } |
| 139 | + |
| 140 | + #[test] |
| 141 | + fn test_cannot_discover_bare_git_repo() { |
| 142 | + let temp_git = empty_git_repo(); |
| 143 | + let file = temp_git.path().join("file.txt"); |
| 144 | + File::create(&file).expect("Could not create file"); |
| 145 | + |
| 146 | + assert!(Git::discover_from_path(&file).is_none()); |
| 147 | + } |
| 148 | + |
| 149 | + #[test] |
| 150 | + fn test_discover_git_repo() { |
| 151 | + let temp_git = empty_git_repo(); |
| 152 | + let file = temp_git.path().join("file.txt"); |
| 153 | + File::create(&file).expect("Could not create file"); |
| 154 | + exec_git_cmd("add file.txt", temp_git.path()); |
| 155 | + exec_git_cmd("commit -m message", temp_git.path()); |
| 156 | + |
| 157 | + let root = Git::discover_from_path(&file).map(|g| g.root().to_owned()); |
| 158 | + assert_eq!(Some(temp_git.path().to_owned()), root); |
| 159 | + } |
| 160 | + |
| 161 | + #[test] |
| 162 | + fn test_read_file_from_head() { |
| 163 | + let tmp_repo = empty_git_repo(); |
| 164 | + let git_dir = tmp_repo.path(); |
| 165 | + let file = git_dir.join("file.txt"); |
| 166 | + |
| 167 | + let contents = r#" |
| 168 | + a file with unnecessary |
| 169 | + indent and text. |
| 170 | + "#; |
| 171 | + fs::write(&file, contents).expect("Could not write to file"); |
| 172 | + exec_git_cmd("add file.txt", git_dir); |
| 173 | + exec_git_cmd("commit -m message", git_dir); |
| 174 | + |
| 175 | + let mut git = Git::discover_from_path(&file).unwrap(); |
| 176 | + assert_eq!( |
| 177 | + Some(contents), |
| 178 | + git.read_file_from_head(&file), |
| 179 | + "Wrong blob contents from HEAD on clean index" |
| 180 | + ); |
| 181 | + |
| 182 | + fs::write(&file, "new text").expect("Could not write to file"); |
| 183 | + assert_eq!( |
| 184 | + Some(contents), |
| 185 | + git.read_file_from_head(&file), |
| 186 | + "Wrong blob contents from HEAD when index is dirty" |
| 187 | + ); |
| 188 | + } |
| 189 | +} |
0 commit comments