1use crate::GitHostingProviderRegistry;
2use crate::{blame::Blame, status::GitStatus};
3use anyhow::{Context, Result};
4use collections::{HashMap, HashSet};
5use git2::BranchType;
6use gpui::SharedString;
7use parking_lot::Mutex;
8use rope::Rope;
9use serde::{Deserialize, Serialize};
10use std::borrow::Borrow;
11use std::sync::LazyLock;
12use std::{
13 cmp::Ordering,
14 path::{Component, Path, PathBuf},
15 sync::Arc,
16};
17use sum_tree::MapSeekTarget;
18use util::ResultExt;
19
20#[derive(Clone, Debug, Hash, PartialEq)]
21pub struct Branch {
22 pub is_head: bool,
23 pub name: SharedString,
24 /// Timestamp of most recent commit, normalized to Unix Epoch format.
25 pub unix_timestamp: Option<i64>,
26}
27
28pub trait GitRepository: Send + Sync {
29 fn reload_index(&self);
30
31 /// Loads a git repository entry's contents.
32 /// Note that for symlink entries, this will return the contents of the symlink, not the target.
33 fn load_index_text(&self, relative_file_path: &Path) -> Option<String>;
34
35 /// Returns the URL of the remote with the given name.
36 fn remote_url(&self, name: &str) -> Option<String>;
37 fn branch_name(&self) -> Option<String>;
38
39 /// Returns the SHA of the current HEAD.
40 fn head_sha(&self) -> Option<String>;
41
42 /// Returns the list of git statuses, sorted by path
43 fn status(&self, path_prefixes: &[RepoPath]) -> Result<GitStatus>;
44
45 fn branches(&self) -> Result<Vec<Branch>>;
46 fn change_branch(&self, _: &str) -> Result<()>;
47 fn create_branch(&self, _: &str) -> Result<()>;
48 fn branch_exits(&self, _: &str) -> Result<bool>;
49
50 fn blame(&self, path: &Path, content: Rope) -> Result<crate::blame::Blame>;
51
52 /// Returns the path to the repository, typically the `.git` folder.
53 fn dot_git_dir(&self) -> PathBuf;
54}
55
56impl std::fmt::Debug for dyn GitRepository {
57 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
58 f.debug_struct("dyn GitRepository<...>").finish()
59 }
60}
61
62pub struct RealGitRepository {
63 pub repository: Mutex<git2::Repository>,
64 pub git_binary_path: PathBuf,
65 hosting_provider_registry: Arc<GitHostingProviderRegistry>,
66}
67
68impl RealGitRepository {
69 pub fn new(
70 repository: git2::Repository,
71 git_binary_path: Option<PathBuf>,
72 hosting_provider_registry: Arc<GitHostingProviderRegistry>,
73 ) -> Self {
74 Self {
75 repository: Mutex::new(repository),
76 git_binary_path: git_binary_path.unwrap_or_else(|| PathBuf::from("git")),
77 hosting_provider_registry,
78 }
79 }
80}
81
82// https://git-scm.com/book/en/v2/Git-Internals-Git-Objects
83const GIT_MODE_SYMLINK: u32 = 0o120000;
84
85impl GitRepository for RealGitRepository {
86 fn reload_index(&self) {
87 if let Ok(mut index) = self.repository.lock().index() {
88 _ = index.read(false);
89 }
90 }
91
92 fn dot_git_dir(&self) -> PathBuf {
93 let repo = self.repository.lock();
94 repo.path().into()
95 }
96
97 fn load_index_text(&self, relative_file_path: &Path) -> Option<String> {
98 fn logic(repo: &git2::Repository, relative_file_path: &Path) -> Result<Option<String>> {
99 const STAGE_NORMAL: i32 = 0;
100 let index = repo.index()?;
101
102 // This check is required because index.get_path() unwraps internally :(
103 check_path_to_repo_path_errors(relative_file_path)?;
104
105 let oid = match index.get_path(relative_file_path, STAGE_NORMAL) {
106 Some(entry) if entry.mode != GIT_MODE_SYMLINK => entry.id,
107 _ => return Ok(None),
108 };
109
110 let content = repo.find_blob(oid)?.content().to_owned();
111 Ok(Some(String::from_utf8(content)?))
112 }
113
114 match logic(&self.repository.lock(), relative_file_path) {
115 Ok(value) => return value,
116 Err(err) => log::error!("Error loading head text: {:?}", err),
117 }
118 None
119 }
120
121 fn remote_url(&self, name: &str) -> Option<String> {
122 let repo = self.repository.lock();
123 let remote = repo.find_remote(name).ok()?;
124 remote.url().map(|url| url.to_string())
125 }
126
127 fn branch_name(&self) -> Option<String> {
128 let repo = self.repository.lock();
129 let head = repo.head().log_err()?;
130 let branch = String::from_utf8_lossy(head.shorthand_bytes());
131 Some(branch.to_string())
132 }
133
134 fn head_sha(&self) -> Option<String> {
135 Some(self.repository.lock().head().ok()?.target()?.to_string())
136 }
137
138 fn status(&self, path_prefixes: &[RepoPath]) -> Result<GitStatus> {
139 let working_directory = self
140 .repository
141 .lock()
142 .workdir()
143 .context("failed to read git work directory")?
144 .to_path_buf();
145 GitStatus::new(&self.git_binary_path, &working_directory, path_prefixes)
146 }
147
148 fn branch_exits(&self, name: &str) -> Result<bool> {
149 let repo = self.repository.lock();
150 let branch = repo.find_branch(name, BranchType::Local);
151 match branch {
152 Ok(_) => Ok(true),
153 Err(e) => match e.code() {
154 git2::ErrorCode::NotFound => Ok(false),
155 _ => Err(anyhow::anyhow!(e)),
156 },
157 }
158 }
159
160 fn branches(&self) -> Result<Vec<Branch>> {
161 let repo = self.repository.lock();
162 let local_branches = repo.branches(Some(BranchType::Local))?;
163 let valid_branches = local_branches
164 .filter_map(|branch| {
165 branch.ok().and_then(|(branch, _)| {
166 let is_head = branch.is_head();
167 let name = branch
168 .name()
169 .ok()
170 .flatten()
171 .map(|name| name.to_string().into())?;
172 let timestamp = branch.get().peel_to_commit().ok()?.time();
173 let unix_timestamp = timestamp.seconds();
174 let timezone_offset = timestamp.offset_minutes();
175 let utc_offset =
176 time::UtcOffset::from_whole_seconds(timezone_offset * 60).ok()?;
177 let unix_timestamp =
178 time::OffsetDateTime::from_unix_timestamp(unix_timestamp).ok()?;
179 Some(Branch {
180 is_head,
181 name,
182 unix_timestamp: Some(unix_timestamp.to_offset(utc_offset).unix_timestamp()),
183 })
184 })
185 })
186 .collect();
187 Ok(valid_branches)
188 }
189
190 fn change_branch(&self, name: &str) -> Result<()> {
191 let repo = self.repository.lock();
192 let revision = repo.find_branch(name, BranchType::Local)?;
193 let revision = revision.get();
194 let as_tree = revision.peel_to_tree()?;
195 repo.checkout_tree(as_tree.as_object(), None)?;
196 repo.set_head(
197 revision
198 .name()
199 .ok_or_else(|| anyhow::anyhow!("Branch name could not be retrieved"))?,
200 )?;
201 Ok(())
202 }
203
204 fn create_branch(&self, name: &str) -> Result<()> {
205 let repo = self.repository.lock();
206 let current_commit = repo.head()?.peel_to_commit()?;
207 repo.branch(name, ¤t_commit, false)?;
208 Ok(())
209 }
210
211 fn blame(&self, path: &Path, content: Rope) -> Result<crate::blame::Blame> {
212 let working_directory = self
213 .repository
214 .lock()
215 .workdir()
216 .with_context(|| format!("failed to get git working directory for file {:?}", path))?
217 .to_path_buf();
218
219 const REMOTE_NAME: &str = "origin";
220 let remote_url = self.remote_url(REMOTE_NAME);
221
222 crate::blame::Blame::for_path(
223 &self.git_binary_path,
224 &working_directory,
225 path,
226 &content,
227 remote_url,
228 self.hosting_provider_registry.clone(),
229 )
230 }
231}
232
233#[derive(Debug, Clone)]
234pub struct FakeGitRepository {
235 state: Arc<Mutex<FakeGitRepositoryState>>,
236}
237
238#[derive(Debug, Clone)]
239pub struct FakeGitRepositoryState {
240 pub dot_git_dir: PathBuf,
241 pub event_emitter: smol::channel::Sender<PathBuf>,
242 pub index_contents: HashMap<PathBuf, String>,
243 pub blames: HashMap<PathBuf, Blame>,
244 pub worktree_statuses: HashMap<RepoPath, GitFileStatus>,
245 pub current_branch_name: Option<String>,
246 pub branches: HashSet<String>,
247}
248
249impl FakeGitRepository {
250 pub fn open(state: Arc<Mutex<FakeGitRepositoryState>>) -> Arc<dyn GitRepository> {
251 Arc::new(FakeGitRepository { state })
252 }
253}
254
255impl FakeGitRepositoryState {
256 pub fn new(dot_git_dir: PathBuf, event_emitter: smol::channel::Sender<PathBuf>) -> Self {
257 FakeGitRepositoryState {
258 dot_git_dir,
259 event_emitter,
260 index_contents: Default::default(),
261 blames: Default::default(),
262 worktree_statuses: Default::default(),
263 current_branch_name: Default::default(),
264 branches: Default::default(),
265 }
266 }
267}
268
269impl GitRepository for FakeGitRepository {
270 fn reload_index(&self) {}
271
272 fn load_index_text(&self, path: &Path) -> Option<String> {
273 let state = self.state.lock();
274 state.index_contents.get(path).cloned()
275 }
276
277 fn remote_url(&self, _name: &str) -> Option<String> {
278 None
279 }
280
281 fn branch_name(&self) -> Option<String> {
282 let state = self.state.lock();
283 state.current_branch_name.clone()
284 }
285
286 fn head_sha(&self) -> Option<String> {
287 None
288 }
289
290 fn dot_git_dir(&self) -> PathBuf {
291 let state = self.state.lock();
292 state.dot_git_dir.clone()
293 }
294
295 fn status(&self, path_prefixes: &[RepoPath]) -> Result<GitStatus> {
296 let state = self.state.lock();
297
298 let mut entries = state
299 .worktree_statuses
300 .iter()
301 .filter_map(|(repo_path, status)| {
302 if path_prefixes
303 .iter()
304 .any(|path_prefix| repo_path.0.starts_with(path_prefix))
305 {
306 Some((repo_path.to_owned(), *status))
307 } else {
308 None
309 }
310 })
311 .collect::<Vec<_>>();
312 entries.sort_unstable_by(|a, b| a.0.cmp(&b.0));
313
314 Ok(GitStatus {
315 entries: entries.into(),
316 })
317 }
318
319 fn branches(&self) -> Result<Vec<Branch>> {
320 let state = self.state.lock();
321 let current_branch = &state.current_branch_name;
322 Ok(state
323 .branches
324 .iter()
325 .map(|branch_name| Branch {
326 is_head: Some(branch_name) == current_branch.as_ref(),
327 name: branch_name.into(),
328 unix_timestamp: None,
329 })
330 .collect())
331 }
332
333 fn branch_exits(&self, name: &str) -> Result<bool> {
334 let state = self.state.lock();
335 Ok(state.branches.contains(name))
336 }
337
338 fn change_branch(&self, name: &str) -> Result<()> {
339 let mut state = self.state.lock();
340 state.current_branch_name = Some(name.to_owned());
341 state
342 .event_emitter
343 .try_send(state.dot_git_dir.clone())
344 .expect("Dropped repo change event");
345 Ok(())
346 }
347
348 fn create_branch(&self, name: &str) -> Result<()> {
349 let mut state = self.state.lock();
350 state.branches.insert(name.to_owned());
351 state
352 .event_emitter
353 .try_send(state.dot_git_dir.clone())
354 .expect("Dropped repo change event");
355 Ok(())
356 }
357
358 fn blame(&self, path: &Path, _content: Rope) -> Result<crate::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, Hash, Serialize, Deserialize)]
398pub enum GitFileStatus {
399 Added,
400 Modified,
401 Conflict,
402 Deleted,
403 Untracked,
404}
405
406impl GitFileStatus {
407 pub fn merge(
408 this: Option<GitFileStatus>,
409 other: Option<GitFileStatus>,
410 prefer_other: bool,
411 ) -> Option<GitFileStatus> {
412 if prefer_other {
413 return other;
414 }
415
416 match (this, other) {
417 (Some(GitFileStatus::Conflict), _) | (_, Some(GitFileStatus::Conflict)) => {
418 Some(GitFileStatus::Conflict)
419 }
420 (Some(GitFileStatus::Modified), _) | (_, Some(GitFileStatus::Modified)) => {
421 Some(GitFileStatus::Modified)
422 }
423 (Some(GitFileStatus::Added), _) | (_, Some(GitFileStatus::Added)) => {
424 Some(GitFileStatus::Added)
425 }
426 _ => None,
427 }
428 }
429}
430
431pub static WORK_DIRECTORY_REPO_PATH: LazyLock<RepoPath> =
432 LazyLock::new(|| RepoPath(Path::new("").into()));
433
434#[derive(Clone, Debug, Ord, Hash, PartialOrd, Eq, PartialEq)]
435pub struct RepoPath(pub Arc<Path>);
436
437impl RepoPath {
438 pub fn new(path: PathBuf) -> Self {
439 debug_assert!(path.is_relative(), "Repo paths must be relative");
440
441 RepoPath(path.into())
442 }
443
444 pub fn from_str(path: &str) -> Self {
445 let path = Path::new(path);
446 debug_assert!(path.is_relative(), "Repo paths must be relative");
447
448 RepoPath(path.into())
449 }
450
451 pub fn to_proto(&self) -> String {
452 self.0.to_string_lossy().to_string()
453 }
454}
455
456impl From<&Path> for RepoPath {
457 fn from(value: &Path) -> Self {
458 RepoPath::new(value.into())
459 }
460}
461
462impl From<PathBuf> for RepoPath {
463 fn from(value: PathBuf) -> Self {
464 RepoPath::new(value)
465 }
466}
467
468impl From<&str> for RepoPath {
469 fn from(value: &str) -> Self {
470 Self::from_str(value)
471 }
472}
473
474impl Default for RepoPath {
475 fn default() -> Self {
476 RepoPath(Path::new("").into())
477 }
478}
479
480impl AsRef<Path> for RepoPath {
481 fn as_ref(&self) -> &Path {
482 self.0.as_ref()
483 }
484}
485
486impl std::ops::Deref for RepoPath {
487 type Target = Path;
488
489 fn deref(&self) -> &Self::Target {
490 &self.0
491 }
492}
493
494impl Borrow<Path> for RepoPath {
495 fn borrow(&self) -> &Path {
496 self.0.as_ref()
497 }
498}
499
500#[derive(Debug)]
501pub struct RepoPathDescendants<'a>(pub &'a Path);
502
503impl<'a> MapSeekTarget<RepoPath> for RepoPathDescendants<'a> {
504 fn cmp_cursor(&self, key: &RepoPath) -> Ordering {
505 if key.starts_with(self.0) {
506 Ordering::Greater
507 } else {
508 self.0.cmp(key)
509 }
510 }
511}