repository.rs

  1use crate::status::FileStatus;
  2use crate::GitHostingProviderRegistry;
  3use crate::{blame::Blame, status::GitStatus};
  4use anyhow::{anyhow, Context as _, Result};
  5use collections::{HashMap, HashSet};
  6use git2::BranchType;
  7use gpui::SharedString;
  8use parking_lot::Mutex;
  9use rope::Rope;
 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::command::new_std_command;
 19use util::ResultExt;
 20
 21#[derive(Clone, Debug, Hash, PartialEq)]
 22pub struct Branch {
 23    pub is_head: bool,
 24    pub name: SharedString,
 25    /// Timestamp of most recent commit, normalized to Unix Epoch format.
 26    pub unix_timestamp: Option<i64>,
 27}
 28
 29pub trait GitRepository: Send + Sync {
 30    fn reload_index(&self);
 31
 32    /// Returns the contents of an entry in the repository's index, or None if there is no entry for the given path.
 33    ///
 34    /// Note that for symlink entries, this will return the contents of the symlink, not the target.
 35    fn load_index_text(&self, path: &RepoPath) -> Option<String>;
 36
 37    /// Returns the contents of an entry in the repository's HEAD, or None if HEAD does not exist or has no entry for the given path.
 38    ///
 39    /// Note that for symlink entries, this will return the contents of the symlink, not the target.
 40    fn load_committed_text(&self, path: &RepoPath) -> Option<String>;
 41
 42    /// Returns the URL of the remote with the given name.
 43    fn remote_url(&self, name: &str) -> Option<String>;
 44    fn branch_name(&self) -> Option<String>;
 45
 46    /// Returns the SHA of the current HEAD.
 47    fn head_sha(&self) -> Option<String>;
 48
 49    fn merge_head_shas(&self) -> Vec<String>;
 50
 51    /// Returns the list of git statuses, sorted by path
 52    fn status(&self, path_prefixes: &[RepoPath]) -> Result<GitStatus>;
 53
 54    fn branches(&self) -> Result<Vec<Branch>>;
 55    fn change_branch(&self, _: &str) -> Result<()>;
 56    fn create_branch(&self, _: &str) -> Result<()>;
 57    fn branch_exits(&self, _: &str) -> Result<bool>;
 58
 59    fn blame(&self, path: &Path, content: Rope) -> Result<crate::blame::Blame>;
 60
 61    /// Returns the path to the repository, typically the `.git` folder.
 62    fn dot_git_dir(&self) -> PathBuf;
 63
 64    /// Updates the index to match the worktree at the given paths.
 65    ///
 66    /// If any of the paths have been deleted from the worktree, they will be removed from the index if found there.
 67    fn stage_paths(&self, paths: &[RepoPath]) -> Result<()>;
 68    /// Updates the index to match HEAD at the given paths.
 69    ///
 70    /// If any of the paths were previously staged but do not exist in HEAD, they will be removed from the index.
 71    fn unstage_paths(&self, paths: &[RepoPath]) -> Result<()>;
 72
 73    fn commit(&self, message: &str, name_and_email: Option<(&str, &str)>) -> Result<()>;
 74}
 75
 76impl std::fmt::Debug for dyn GitRepository {
 77    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
 78        f.debug_struct("dyn GitRepository<...>").finish()
 79    }
 80}
 81
 82pub struct RealGitRepository {
 83    pub repository: Mutex<git2::Repository>,
 84    pub git_binary_path: PathBuf,
 85    hosting_provider_registry: Arc<GitHostingProviderRegistry>,
 86}
 87
 88impl RealGitRepository {
 89    pub fn new(
 90        repository: git2::Repository,
 91        git_binary_path: Option<PathBuf>,
 92        hosting_provider_registry: Arc<GitHostingProviderRegistry>,
 93    ) -> Self {
 94        Self {
 95            repository: Mutex::new(repository),
 96            git_binary_path: git_binary_path.unwrap_or_else(|| PathBuf::from("git")),
 97            hosting_provider_registry,
 98        }
 99    }
100}
101
102// https://git-scm.com/book/en/v2/Git-Internals-Git-Objects
103const GIT_MODE_SYMLINK: u32 = 0o120000;
104
105impl GitRepository for RealGitRepository {
106    fn reload_index(&self) {
107        if let Ok(mut index) = self.repository.lock().index() {
108            _ = index.read(false);
109        }
110    }
111
112    fn dot_git_dir(&self) -> PathBuf {
113        let repo = self.repository.lock();
114        repo.path().into()
115    }
116
117    fn load_index_text(&self, path: &RepoPath) -> Option<String> {
118        fn logic(repo: &git2::Repository, path: &RepoPath) -> Result<Option<String>> {
119            const STAGE_NORMAL: i32 = 0;
120            let index = repo.index()?;
121
122            // This check is required because index.get_path() unwraps internally :(
123            check_path_to_repo_path_errors(path)?;
124
125            let oid = match index.get_path(path, STAGE_NORMAL) {
126                Some(entry) if entry.mode != GIT_MODE_SYMLINK => entry.id,
127                _ => return Ok(None),
128            };
129
130            let content = repo.find_blob(oid)?.content().to_owned();
131            Ok(Some(String::from_utf8(content)?))
132        }
133
134        match logic(&self.repository.lock(), path) {
135            Ok(value) => return value,
136            Err(err) => log::error!("Error loading index text: {:?}", err),
137        }
138        None
139    }
140
141    fn load_committed_text(&self, path: &RepoPath) -> Option<String> {
142        let repo = self.repository.lock();
143        let head = repo.head().ok()?.peel_to_tree().log_err()?;
144        let oid = head.get_path(path).ok()?.id();
145        let content = repo.find_blob(oid).log_err()?.content().to_owned();
146        let content = String::from_utf8(content).log_err()?;
147        Some(content)
148    }
149
150    fn remote_url(&self, name: &str) -> Option<String> {
151        let repo = self.repository.lock();
152        let remote = repo.find_remote(name).ok()?;
153        remote.url().map(|url| url.to_string())
154    }
155
156    fn branch_name(&self) -> Option<String> {
157        let repo = self.repository.lock();
158        let head = repo.head().log_err()?;
159        let branch = String::from_utf8_lossy(head.shorthand_bytes());
160        Some(branch.to_string())
161    }
162
163    fn head_sha(&self) -> Option<String> {
164        Some(self.repository.lock().head().ok()?.target()?.to_string())
165    }
166
167    fn merge_head_shas(&self) -> Vec<String> {
168        let mut shas = Vec::default();
169        self.repository
170            .lock()
171            .mergehead_foreach(|oid| {
172                shas.push(oid.to_string());
173                true
174            })
175            .ok();
176        shas
177    }
178
179    fn status(&self, path_prefixes: &[RepoPath]) -> Result<GitStatus> {
180        let working_directory = self
181            .repository
182            .lock()
183            .workdir()
184            .context("failed to read git work directory")?
185            .to_path_buf();
186        GitStatus::new(&self.git_binary_path, &working_directory, path_prefixes)
187    }
188
189    fn branch_exits(&self, name: &str) -> Result<bool> {
190        let repo = self.repository.lock();
191        let branch = repo.find_branch(name, BranchType::Local);
192        match branch {
193            Ok(_) => Ok(true),
194            Err(e) => match e.code() {
195                git2::ErrorCode::NotFound => Ok(false),
196                _ => Err(anyhow!(e)),
197            },
198        }
199    }
200
201    fn branches(&self) -> Result<Vec<Branch>> {
202        let repo = self.repository.lock();
203        let local_branches = repo.branches(Some(BranchType::Local))?;
204        let valid_branches = local_branches
205            .filter_map(|branch| {
206                branch.ok().and_then(|(branch, _)| {
207                    let is_head = branch.is_head();
208                    let name = branch
209                        .name()
210                        .ok()
211                        .flatten()
212                        .map(|name| name.to_string().into())?;
213                    let timestamp = branch.get().peel_to_commit().ok()?.time();
214                    let unix_timestamp = timestamp.seconds();
215                    let timezone_offset = timestamp.offset_minutes();
216                    let utc_offset =
217                        time::UtcOffset::from_whole_seconds(timezone_offset * 60).ok()?;
218                    let unix_timestamp =
219                        time::OffsetDateTime::from_unix_timestamp(unix_timestamp).ok()?;
220                    Some(Branch {
221                        is_head,
222                        name,
223                        unix_timestamp: Some(unix_timestamp.to_offset(utc_offset).unix_timestamp()),
224                    })
225                })
226            })
227            .collect();
228        Ok(valid_branches)
229    }
230
231    fn change_branch(&self, name: &str) -> Result<()> {
232        let repo = self.repository.lock();
233        let revision = repo.find_branch(name, BranchType::Local)?;
234        let revision = revision.get();
235        let as_tree = revision.peel_to_tree()?;
236        repo.checkout_tree(as_tree.as_object(), None)?;
237        repo.set_head(
238            revision
239                .name()
240                .ok_or_else(|| anyhow!("Branch name could not be retrieved"))?,
241        )?;
242        Ok(())
243    }
244
245    fn create_branch(&self, name: &str) -> Result<()> {
246        let repo = self.repository.lock();
247        let current_commit = repo.head()?.peel_to_commit()?;
248        repo.branch(name, &current_commit, false)?;
249        Ok(())
250    }
251
252    fn blame(&self, path: &Path, content: Rope) -> Result<crate::blame::Blame> {
253        let working_directory = self
254            .repository
255            .lock()
256            .workdir()
257            .with_context(|| format!("failed to get git working directory for file {:?}", path))?
258            .to_path_buf();
259
260        const REMOTE_NAME: &str = "origin";
261        let remote_url = self.remote_url(REMOTE_NAME);
262
263        crate::blame::Blame::for_path(
264            &self.git_binary_path,
265            &working_directory,
266            path,
267            &content,
268            remote_url,
269            self.hosting_provider_registry.clone(),
270        )
271    }
272
273    fn stage_paths(&self, paths: &[RepoPath]) -> Result<()> {
274        let working_directory = self
275            .repository
276            .lock()
277            .workdir()
278            .context("failed to read git work directory")?
279            .to_path_buf();
280
281        if !paths.is_empty() {
282            let status = new_std_command(&self.git_binary_path)
283                .current_dir(&working_directory)
284                .args(["update-index", "--add", "--remove", "--"])
285                .args(paths.iter().map(|p| p.as_ref()))
286                .status()?;
287            if !status.success() {
288                return Err(anyhow!("Failed to stage paths: {status}"));
289            }
290        }
291        Ok(())
292    }
293
294    fn unstage_paths(&self, paths: &[RepoPath]) -> Result<()> {
295        let working_directory = self
296            .repository
297            .lock()
298            .workdir()
299            .context("failed to read git work directory")?
300            .to_path_buf();
301
302        if !paths.is_empty() {
303            let cmd = new_std_command(&self.git_binary_path)
304                .current_dir(&working_directory)
305                .args(["reset", "--quiet", "--"])
306                .args(paths.iter().map(|p| p.as_ref()))
307                .status()?;
308            if !cmd.success() {
309                return Err(anyhow!("Failed to unstage paths: {cmd}"));
310            }
311        }
312        Ok(())
313    }
314
315    fn commit(&self, message: &str, name_and_email: Option<(&str, &str)>) -> Result<()> {
316        let working_directory = self
317            .repository
318            .lock()
319            .workdir()
320            .context("failed to read git work directory")?
321            .to_path_buf();
322        let mut args = vec!["commit", "--quiet", "-m", message, "--cleanup=strip"];
323        let author = name_and_email.map(|(name, email)| format!("{name} <{email}>"));
324        if let Some(author) = author.as_deref() {
325            args.push("--author");
326            args.push(author);
327        }
328
329        let cmd = new_std_command(&self.git_binary_path)
330            .current_dir(&working_directory)
331            .args(args)
332            .status()?;
333        if !cmd.success() {
334            return Err(anyhow!("Failed to commit: {cmd}"));
335        }
336        Ok(())
337    }
338}
339
340#[derive(Debug, Clone)]
341pub struct FakeGitRepository {
342    state: Arc<Mutex<FakeGitRepositoryState>>,
343}
344
345#[derive(Debug, Clone)]
346pub struct FakeGitRepositoryState {
347    pub dot_git_dir: PathBuf,
348    pub event_emitter: smol::channel::Sender<PathBuf>,
349    pub head_contents: HashMap<RepoPath, String>,
350    pub index_contents: HashMap<RepoPath, String>,
351    pub blames: HashMap<RepoPath, Blame>,
352    pub statuses: HashMap<RepoPath, FileStatus>,
353    pub current_branch_name: Option<String>,
354    pub branches: HashSet<String>,
355}
356
357impl FakeGitRepository {
358    pub fn open(state: Arc<Mutex<FakeGitRepositoryState>>) -> Arc<dyn GitRepository> {
359        Arc::new(FakeGitRepository { state })
360    }
361}
362
363impl FakeGitRepositoryState {
364    pub fn new(dot_git_dir: PathBuf, event_emitter: smol::channel::Sender<PathBuf>) -> Self {
365        FakeGitRepositoryState {
366            dot_git_dir,
367            event_emitter,
368            head_contents: Default::default(),
369            index_contents: Default::default(),
370            blames: Default::default(),
371            statuses: Default::default(),
372            current_branch_name: Default::default(),
373            branches: Default::default(),
374        }
375    }
376}
377
378impl GitRepository for FakeGitRepository {
379    fn reload_index(&self) {}
380
381    fn load_index_text(&self, path: &RepoPath) -> Option<String> {
382        let state = self.state.lock();
383        state.index_contents.get(path.as_ref()).cloned()
384    }
385
386    fn load_committed_text(&self, path: &RepoPath) -> Option<String> {
387        let state = self.state.lock();
388        state.head_contents.get(path.as_ref()).cloned()
389    }
390
391    fn remote_url(&self, _name: &str) -> Option<String> {
392        None
393    }
394
395    fn branch_name(&self) -> Option<String> {
396        let state = self.state.lock();
397        state.current_branch_name.clone()
398    }
399
400    fn head_sha(&self) -> Option<String> {
401        None
402    }
403
404    fn merge_head_shas(&self) -> Vec<String> {
405        vec![]
406    }
407
408    fn dot_git_dir(&self) -> PathBuf {
409        let state = self.state.lock();
410        state.dot_git_dir.clone()
411    }
412
413    fn status(&self, path_prefixes: &[RepoPath]) -> Result<GitStatus> {
414        let state = self.state.lock();
415
416        let mut entries = state
417            .statuses
418            .iter()
419            .filter_map(|(repo_path, status)| {
420                if path_prefixes
421                    .iter()
422                    .any(|path_prefix| repo_path.0.starts_with(path_prefix))
423                {
424                    Some((repo_path.to_owned(), *status))
425                } else {
426                    None
427                }
428            })
429            .collect::<Vec<_>>();
430        entries.sort_unstable_by(|(a, _), (b, _)| a.cmp(&b));
431
432        Ok(GitStatus {
433            entries: entries.into(),
434        })
435    }
436
437    fn branches(&self) -> Result<Vec<Branch>> {
438        let state = self.state.lock();
439        let current_branch = &state.current_branch_name;
440        Ok(state
441            .branches
442            .iter()
443            .map(|branch_name| Branch {
444                is_head: Some(branch_name) == current_branch.as_ref(),
445                name: branch_name.into(),
446                unix_timestamp: None,
447            })
448            .collect())
449    }
450
451    fn branch_exits(&self, name: &str) -> Result<bool> {
452        let state = self.state.lock();
453        Ok(state.branches.contains(name))
454    }
455
456    fn change_branch(&self, name: &str) -> Result<()> {
457        let mut state = self.state.lock();
458        state.current_branch_name = Some(name.to_owned());
459        state
460            .event_emitter
461            .try_send(state.dot_git_dir.clone())
462            .expect("Dropped repo change event");
463        Ok(())
464    }
465
466    fn create_branch(&self, name: &str) -> Result<()> {
467        let mut state = self.state.lock();
468        state.branches.insert(name.to_owned());
469        state
470            .event_emitter
471            .try_send(state.dot_git_dir.clone())
472            .expect("Dropped repo change event");
473        Ok(())
474    }
475
476    fn blame(&self, path: &Path, _content: Rope) -> Result<crate::blame::Blame> {
477        let state = self.state.lock();
478        state
479            .blames
480            .get(path)
481            .with_context(|| format!("failed to get blame for {:?}", path))
482            .cloned()
483    }
484
485    fn stage_paths(&self, _paths: &[RepoPath]) -> Result<()> {
486        unimplemented!()
487    }
488
489    fn unstage_paths(&self, _paths: &[RepoPath]) -> Result<()> {
490        unimplemented!()
491    }
492
493    fn commit(&self, _message: &str, _name_and_email: Option<(&str, &str)>) -> Result<()> {
494        unimplemented!()
495    }
496}
497
498fn check_path_to_repo_path_errors(relative_file_path: &Path) -> Result<()> {
499    match relative_file_path.components().next() {
500        None => anyhow::bail!("repo path should not be empty"),
501        Some(Component::Prefix(_)) => anyhow::bail!(
502            "repo path `{}` should be relative, not a windows prefix",
503            relative_file_path.to_string_lossy()
504        ),
505        Some(Component::RootDir) => {
506            anyhow::bail!(
507                "repo path `{}` should be relative",
508                relative_file_path.to_string_lossy()
509            )
510        }
511        Some(Component::CurDir) => {
512            anyhow::bail!(
513                "repo path `{}` should not start with `.`",
514                relative_file_path.to_string_lossy()
515            )
516        }
517        Some(Component::ParentDir) => {
518            anyhow::bail!(
519                "repo path `{}` should not start with `..`",
520                relative_file_path.to_string_lossy()
521            )
522        }
523        _ => Ok(()),
524    }
525}
526
527pub static WORK_DIRECTORY_REPO_PATH: LazyLock<RepoPath> =
528    LazyLock::new(|| RepoPath(Path::new("").into()));
529
530#[derive(Clone, Debug, Ord, Hash, PartialOrd, Eq, PartialEq)]
531pub struct RepoPath(pub Arc<Path>);
532
533impl RepoPath {
534    pub fn new(path: PathBuf) -> Self {
535        debug_assert!(path.is_relative(), "Repo paths must be relative");
536
537        RepoPath(path.into())
538    }
539
540    pub fn from_str(path: &str) -> Self {
541        let path = Path::new(path);
542        debug_assert!(path.is_relative(), "Repo paths must be relative");
543
544        RepoPath(path.into())
545    }
546
547    pub fn to_proto(&self) -> String {
548        self.0.to_string_lossy().to_string()
549    }
550}
551
552impl std::fmt::Display for RepoPath {
553    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
554        self.0.to_string_lossy().fmt(f)
555    }
556}
557
558impl From<&Path> for RepoPath {
559    fn from(value: &Path) -> Self {
560        RepoPath::new(value.into())
561    }
562}
563
564impl From<Arc<Path>> for RepoPath {
565    fn from(value: Arc<Path>) -> Self {
566        RepoPath(value)
567    }
568}
569
570impl From<PathBuf> for RepoPath {
571    fn from(value: PathBuf) -> Self {
572        RepoPath::new(value)
573    }
574}
575
576impl From<&str> for RepoPath {
577    fn from(value: &str) -> Self {
578        Self::from_str(value)
579    }
580}
581
582impl Default for RepoPath {
583    fn default() -> Self {
584        RepoPath(Path::new("").into())
585    }
586}
587
588impl AsRef<Path> for RepoPath {
589    fn as_ref(&self) -> &Path {
590        self.0.as_ref()
591    }
592}
593
594impl std::ops::Deref for RepoPath {
595    type Target = Path;
596
597    fn deref(&self) -> &Self::Target {
598        &self.0
599    }
600}
601
602impl Borrow<Path> for RepoPath {
603    fn borrow(&self) -> &Path {
604        self.0.as_ref()
605    }
606}
607
608#[derive(Debug)]
609pub struct RepoPathDescendants<'a>(pub &'a Path);
610
611impl<'a> MapSeekTarget<RepoPath> for RepoPathDescendants<'a> {
612    fn cmp_cursor(&self, key: &RepoPath) -> Ordering {
613        if key.starts_with(self.0) {
614            Ordering::Greater
615        } else {
616            self.0.cmp(key)
617        }
618    }
619}