1use anyhow::{Context, Result};
2use collections::HashMap;
3use git::blame::Blame;
4use git2::{BranchType, StatusShow};
5use parking_lot::Mutex;
6use rope::Rope;
7use serde_derive::{Deserialize, Serialize};
8use std::{
9 cmp::Ordering,
10 path::{Component, Path, PathBuf},
11 sync::Arc,
12 time::SystemTime,
13};
14use sum_tree::{MapSeekTarget, TreeMap};
15use util::{paths::PathExt, ResultExt};
16
17pub use git2::Repository as LibGitRepository;
18
19#[derive(Clone, Debug, Hash, PartialEq)]
20pub struct Branch {
21 pub name: Box<str>,
22 /// Timestamp of most recent commit, normalized to Unix Epoch format.
23 pub unix_timestamp: Option<i64>,
24}
25
26pub trait GitRepository: Send {
27 fn reload_index(&self);
28 fn load_index_text(&self, relative_file_path: &Path) -> Option<String>;
29
30 /// Returns the URL of the remote with the given name.
31 fn remote_url(&self, name: &str) -> Option<String>;
32 fn branch_name(&self) -> Option<String>;
33
34 /// Returns the SHA of the current HEAD.
35 fn head_sha(&self) -> Option<String>;
36
37 /// Get the statuses of all of the files in the index that start with the given
38 /// path and have changes with respect to the HEAD commit. This is fast because
39 /// the index stores hashes of trees, so that unchanged directories can be skipped.
40 fn staged_statuses(&self, path_prefix: &Path) -> TreeMap<RepoPath, GitFileStatus>;
41
42 /// Get the status of a given file in the working directory with respect to
43 /// the index. In the common case, when there are no changes, this only requires
44 /// an index lookup. The index stores the mtime of each file when it was added,
45 /// so there's no work to do if the mtime matches.
46 fn unstaged_status(&self, path: &RepoPath, mtime: SystemTime) -> Option<GitFileStatus>;
47
48 /// Get the status of a given file in the working directory with respect to
49 /// the HEAD commit. In the common case, when there are no changes, this only
50 /// requires an index lookup and blob comparison between the index and the HEAD
51 /// commit. The index stores the mtime of each file when it was added, so there's
52 /// no need to consider the working directory file if the mtime matches.
53 fn status(&self, path: &RepoPath, mtime: SystemTime) -> Option<GitFileStatus>;
54
55 fn branches(&self) -> Result<Vec<Branch>>;
56 fn change_branch(&self, _: &str) -> Result<()>;
57 fn create_branch(&self, _: &str) -> Result<()>;
58
59 fn blame(&self, path: &Path, content: Rope) -> Result<git::blame::Blame>;
60}
61
62impl std::fmt::Debug for dyn GitRepository {
63 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64 f.debug_struct("dyn GitRepository<...>").finish()
65 }
66}
67
68pub struct RealGitRepository {
69 pub repository: LibGitRepository,
70 pub git_binary_path: PathBuf,
71}
72
73impl RealGitRepository {
74 pub fn new(repository: LibGitRepository, git_binary_path: Option<PathBuf>) -> Self {
75 Self {
76 repository,
77 git_binary_path: git_binary_path.unwrap_or_else(|| PathBuf::from("git")),
78 }
79 }
80}
81
82impl GitRepository for RealGitRepository {
83 fn reload_index(&self) {
84 if let Ok(mut index) = self.repository.index() {
85 _ = index.read(false);
86 }
87 }
88
89 fn load_index_text(&self, relative_file_path: &Path) -> Option<String> {
90 fn logic(repo: &LibGitRepository, relative_file_path: &Path) -> Result<Option<String>> {
91 const STAGE_NORMAL: i32 = 0;
92 let index = repo.index()?;
93
94 // This check is required because index.get_path() unwraps internally :(
95 check_path_to_repo_path_errors(relative_file_path)?;
96
97 let oid = match index.get_path(relative_file_path, STAGE_NORMAL) {
98 Some(entry) => entry.id,
99 None => return Ok(None),
100 };
101
102 let content = repo.find_blob(oid)?.content().to_owned();
103 Ok(Some(String::from_utf8(content)?))
104 }
105
106 match logic(&self.repository, relative_file_path) {
107 Ok(value) => return value,
108 Err(err) => log::error!("Error loading head text: {:?}", err),
109 }
110 None
111 }
112
113 fn remote_url(&self, name: &str) -> Option<String> {
114 let remote = self.repository.find_remote(name).ok()?;
115 remote.url().map(|url| url.to_string())
116 }
117
118 fn branch_name(&self) -> Option<String> {
119 let head = self.repository.head().log_err()?;
120 let branch = String::from_utf8_lossy(head.shorthand_bytes());
121 Some(branch.to_string())
122 }
123
124 fn head_sha(&self) -> Option<String> {
125 let head = self.repository.head().ok()?;
126 head.target().map(|oid| oid.to_string())
127 }
128
129 fn staged_statuses(&self, path_prefix: &Path) -> TreeMap<RepoPath, GitFileStatus> {
130 let mut map = TreeMap::default();
131
132 let mut options = git2::StatusOptions::new();
133 options.pathspec(path_prefix);
134 options.show(StatusShow::Index);
135
136 if let Some(statuses) = self.repository.statuses(Some(&mut options)).log_err() {
137 for status in statuses.iter() {
138 let path = RepoPath(PathBuf::try_from_bytes(status.path_bytes()).unwrap());
139 let status = status.status();
140 if !status.contains(git2::Status::IGNORED) {
141 if let Some(status) = read_status(status) {
142 map.insert(path, status)
143 }
144 }
145 }
146 }
147 map
148 }
149
150 fn unstaged_status(&self, path: &RepoPath, mtime: SystemTime) -> Option<GitFileStatus> {
151 // If the file has not changed since it was added to the index, then
152 // there can't be any changes.
153 if matches_index(&self.repository, path, mtime) {
154 return None;
155 }
156
157 let mut options = git2::StatusOptions::new();
158 options.pathspec(&path.0);
159 options.disable_pathspec_match(true);
160 options.include_untracked(true);
161 options.recurse_untracked_dirs(true);
162 options.include_unmodified(true);
163 options.show(StatusShow::Workdir);
164
165 let statuses = self.repository.statuses(Some(&mut options)).log_err()?;
166 let status = statuses.get(0).and_then(|s| read_status(s.status()));
167 status
168 }
169
170 fn status(&self, path: &RepoPath, mtime: SystemTime) -> Option<GitFileStatus> {
171 let mut options = git2::StatusOptions::new();
172 options.pathspec(&path.0);
173 options.disable_pathspec_match(true);
174 options.include_untracked(true);
175 options.recurse_untracked_dirs(true);
176 options.include_unmodified(true);
177
178 // If the file has not changed since it was added to the index, then
179 // there's no need to examine the working directory file: just compare
180 // the blob in the index to the one in the HEAD commit.
181 if matches_index(&self.repository, path, mtime) {
182 options.show(StatusShow::Index);
183 }
184
185 let statuses = self.repository.statuses(Some(&mut options)).log_err()?;
186 let status = statuses.get(0).and_then(|s| read_status(s.status()));
187 status
188 }
189
190 fn branches(&self) -> Result<Vec<Branch>> {
191 let local_branches = self.repository.branches(Some(BranchType::Local))?;
192 let valid_branches = local_branches
193 .filter_map(|branch| {
194 branch.ok().and_then(|(branch, _)| {
195 let name = branch.name().ok().flatten().map(Box::from)?;
196 let timestamp = branch.get().peel_to_commit().ok()?.time();
197 let unix_timestamp = timestamp.seconds();
198 let timezone_offset = timestamp.offset_minutes();
199 let utc_offset =
200 time::UtcOffset::from_whole_seconds(timezone_offset * 60).ok()?;
201 let unix_timestamp =
202 time::OffsetDateTime::from_unix_timestamp(unix_timestamp).ok()?;
203 Some(Branch {
204 name,
205 unix_timestamp: Some(unix_timestamp.to_offset(utc_offset).unix_timestamp()),
206 })
207 })
208 })
209 .collect();
210 Ok(valid_branches)
211 }
212 fn change_branch(&self, name: &str) -> Result<()> {
213 let revision = self.repository.find_branch(name, BranchType::Local)?;
214 let revision = revision.get();
215 let as_tree = revision.peel_to_tree()?;
216 self.repository.checkout_tree(as_tree.as_object(), None)?;
217 self.repository.set_head(
218 revision
219 .name()
220 .ok_or_else(|| anyhow::anyhow!("Branch name could not be retrieved"))?,
221 )?;
222 Ok(())
223 }
224 fn create_branch(&self, name: &str) -> Result<()> {
225 let current_commit = self.repository.head()?.peel_to_commit()?;
226 self.repository.branch(name, ¤t_commit, false)?;
227
228 Ok(())
229 }
230
231 fn blame(&self, path: &Path, content: Rope) -> Result<git::blame::Blame> {
232 let git_dir_path = self.repository.path();
233 let working_directory = git_dir_path.parent().with_context(|| {
234 format!("failed to get git working directory for {:?}", git_dir_path)
235 })?;
236
237 const REMOTE_NAME: &str = "origin";
238 let remote_url = self.remote_url(REMOTE_NAME);
239
240 git::blame::Blame::for_path(
241 &self.git_binary_path,
242 working_directory,
243 path,
244 &content,
245 remote_url,
246 )
247 }
248}
249
250fn matches_index(repo: &LibGitRepository, path: &RepoPath, mtime: SystemTime) -> bool {
251 if let Some(index) = repo.index().log_err() {
252 if let Some(entry) = index.get_path(path, 0) {
253 if let Some(mtime) = mtime.duration_since(SystemTime::UNIX_EPOCH).log_err() {
254 if entry.mtime.seconds() == mtime.as_secs() as i32
255 && entry.mtime.nanoseconds() == mtime.subsec_nanos()
256 {
257 return true;
258 }
259 }
260 }
261 }
262 false
263}
264
265fn read_status(status: git2::Status) -> Option<GitFileStatus> {
266 if status.contains(git2::Status::CONFLICTED) {
267 Some(GitFileStatus::Conflict)
268 } else if status.intersects(
269 git2::Status::WT_MODIFIED
270 | git2::Status::WT_RENAMED
271 | git2::Status::INDEX_MODIFIED
272 | git2::Status::INDEX_RENAMED,
273 ) {
274 Some(GitFileStatus::Modified)
275 } else if status.intersects(git2::Status::WT_NEW | git2::Status::INDEX_NEW) {
276 Some(GitFileStatus::Added)
277 } else {
278 None
279 }
280}
281
282#[derive(Debug, Clone, Default)]
283pub struct FakeGitRepository {
284 state: Arc<Mutex<FakeGitRepositoryState>>,
285}
286
287#[derive(Debug, Clone, Default)]
288pub struct FakeGitRepositoryState {
289 pub index_contents: HashMap<PathBuf, String>,
290 pub blames: HashMap<PathBuf, Blame>,
291 pub worktree_statuses: HashMap<RepoPath, GitFileStatus>,
292 pub branch_name: Option<String>,
293}
294
295impl FakeGitRepository {
296 pub fn open(state: Arc<Mutex<FakeGitRepositoryState>>) -> Arc<Mutex<dyn GitRepository>> {
297 Arc::new(Mutex::new(FakeGitRepository { state }))
298 }
299}
300
301impl GitRepository for FakeGitRepository {
302 fn reload_index(&self) {}
303
304 fn load_index_text(&self, path: &Path) -> Option<String> {
305 let state = self.state.lock();
306 state.index_contents.get(path).cloned()
307 }
308
309 fn remote_url(&self, _name: &str) -> Option<String> {
310 None
311 }
312
313 fn branch_name(&self) -> Option<String> {
314 let state = self.state.lock();
315 state.branch_name.clone()
316 }
317
318 fn head_sha(&self) -> Option<String> {
319 None
320 }
321
322 fn staged_statuses(&self, path_prefix: &Path) -> TreeMap<RepoPath, GitFileStatus> {
323 let mut map = TreeMap::default();
324 let state = self.state.lock();
325 for (repo_path, status) in state.worktree_statuses.iter() {
326 if repo_path.0.starts_with(path_prefix) {
327 map.insert(repo_path.to_owned(), status.to_owned());
328 }
329 }
330 map
331 }
332
333 fn unstaged_status(&self, _path: &RepoPath, _mtime: SystemTime) -> Option<GitFileStatus> {
334 None
335 }
336
337 fn status(&self, path: &RepoPath, _mtime: SystemTime) -> Option<GitFileStatus> {
338 let state = self.state.lock();
339 state.worktree_statuses.get(path).cloned()
340 }
341
342 fn branches(&self) -> Result<Vec<Branch>> {
343 Ok(vec![])
344 }
345
346 fn change_branch(&self, name: &str) -> Result<()> {
347 let mut state = self.state.lock();
348 state.branch_name = Some(name.to_owned());
349 Ok(())
350 }
351
352 fn create_branch(&self, name: &str) -> Result<()> {
353 let mut state = self.state.lock();
354 state.branch_name = Some(name.to_owned());
355 Ok(())
356 }
357
358 fn blame(&self, path: &Path, _content: Rope) -> Result<git::blame::Blame> {
359 let state = self.state.lock();
360 state
361 .blames
362 .get(path)
363 .with_context(|| format!("failed to get blame for {:?}", path))
364 .cloned()
365 }
366}
367
368fn check_path_to_repo_path_errors(relative_file_path: &Path) -> Result<()> {
369 match relative_file_path.components().next() {
370 None => anyhow::bail!("repo path should not be empty"),
371 Some(Component::Prefix(_)) => anyhow::bail!(
372 "repo path `{}` should be relative, not a windows prefix",
373 relative_file_path.to_string_lossy()
374 ),
375 Some(Component::RootDir) => {
376 anyhow::bail!(
377 "repo path `{}` should be relative",
378 relative_file_path.to_string_lossy()
379 )
380 }
381 Some(Component::CurDir) => {
382 anyhow::bail!(
383 "repo path `{}` should not start with `.`",
384 relative_file_path.to_string_lossy()
385 )
386 }
387 Some(Component::ParentDir) => {
388 anyhow::bail!(
389 "repo path `{}` should not start with `..`",
390 relative_file_path.to_string_lossy()
391 )
392 }
393 _ => Ok(()),
394 }
395}
396
397#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
398pub enum GitFileStatus {
399 Added,
400 Modified,
401 Conflict,
402}
403
404impl GitFileStatus {
405 pub fn merge(
406 this: Option<GitFileStatus>,
407 other: Option<GitFileStatus>,
408 prefer_other: bool,
409 ) -> Option<GitFileStatus> {
410 if prefer_other {
411 return other;
412 }
413
414 match (this, other) {
415 (Some(GitFileStatus::Conflict), _) | (_, Some(GitFileStatus::Conflict)) => {
416 Some(GitFileStatus::Conflict)
417 }
418 (Some(GitFileStatus::Modified), _) | (_, Some(GitFileStatus::Modified)) => {
419 Some(GitFileStatus::Modified)
420 }
421 (Some(GitFileStatus::Added), _) | (_, Some(GitFileStatus::Added)) => {
422 Some(GitFileStatus::Added)
423 }
424 _ => None,
425 }
426 }
427}
428
429#[derive(Clone, Debug, Ord, Hash, PartialOrd, Eq, PartialEq)]
430pub struct RepoPath(pub PathBuf);
431
432impl RepoPath {
433 pub fn new(path: PathBuf) -> Self {
434 debug_assert!(path.is_relative(), "Repo paths must be relative");
435
436 RepoPath(path)
437 }
438}
439
440impl From<&Path> for RepoPath {
441 fn from(value: &Path) -> Self {
442 RepoPath::new(value.to_path_buf())
443 }
444}
445
446impl From<PathBuf> for RepoPath {
447 fn from(value: PathBuf) -> Self {
448 RepoPath::new(value)
449 }
450}
451
452impl Default for RepoPath {
453 fn default() -> Self {
454 RepoPath(PathBuf::new())
455 }
456}
457
458impl AsRef<Path> for RepoPath {
459 fn as_ref(&self) -> &Path {
460 self.0.as_ref()
461 }
462}
463
464impl std::ops::Deref for RepoPath {
465 type Target = PathBuf;
466
467 fn deref(&self) -> &Self::Target {
468 &self.0
469 }
470}
471
472#[derive(Debug)]
473pub struct RepoPathDescendants<'a>(pub &'a Path);
474
475impl<'a> MapSeekTarget<RepoPath> for RepoPathDescendants<'a> {
476 fn cmp_cursor(&self, key: &RepoPath) -> Ordering {
477 if key.starts_with(self.0) {
478 Ordering::Greater
479 } else {
480 self.0.cmp(key)
481 }
482 }
483}