Skip to main content

vcs_modify_guard/vcs/
git_libgit2.rs

1use std::{
2    fmt,
3    path::{Path, PathBuf},
4};
5
6use snafu::{IntoError as _, ResultExt as _, Snafu};
7
8use super::VcsRepository;
9use crate::{
10    error::{self, ModifyGuardError},
11    repository::{FileChange, RepositoryChanges},
12    util::{self, WorktreeRelativePath},
13    vcs::VcsBackend,
14};
15
16pub(super) const BACKEND: Libgit2Backend = Libgit2Backend;
17
18#[derive(Debug)]
19pub(super) struct Libgit2Backend;
20
21/// Errors returned by `libgit2` backend operations.
22#[derive(Debug, Snafu)]
23#[non_exhaustive]
24pub enum Libgit2BackendError {
25    /// Searching for a Git repository failed.
26    #[snafu(display("failed while searching for a git repository at or above path: {}", path.display()))]
27    Discover {
28        /// The path that was being searched for a Git repository.
29        path: PathBuf,
30        /// The underlying error from `libgit2`.
31        source: git2::Error,
32    },
33    /// Opening a Git repository failed.
34    #[snafu(display("failed to open git repository at path: {}", path.display()))]
35    Open {
36        /// The path that was being opened as a Git repository.
37        path: PathBuf,
38        /// The underlying error from `libgit2`.
39        source: git2::Error,
40    },
41    /// Querying repository changes failed.
42    #[snafu(display("failed to query git repository changes for worktree: {}", worktree.display()))]
43    QueryRepositoryChanges {
44        /// The worktree of the Git repository.
45        worktree: PathBuf,
46        /// The underlying error from `libgit2`.
47        source: git2::Error,
48    },
49    /// Querying file change failed.
50    #[snafu(display("failed to query git file change for worktree-relative path: {}", wt_path.display()))]
51    QueryFileChange {
52        /// The worktree-relative path of the file whose change was being retrieved.
53        wt_path: PathBuf,
54        /// The underlying error from `libgit2`.
55        source: git2::Error,
56    },
57}
58
59impl From<Libgit2BackendError> for ModifyGuardError {
60    #[inline]
61    fn from(source: Libgit2BackendError) -> Self {
62        Self::Backend {
63            source: source.into(),
64        }
65    }
66}
67
68impl VcsBackend for Libgit2Backend {
69    fn discover(&self, path: &Path) -> Result<Option<Box<dyn VcsRepository>>, ModifyGuardError> {
70        util::ensure_path_exists(path)?;
71        let repo = match git2::Repository::discover(path) {
72            Ok(repo) => repo,
73            Err(source) if source.code() == git2::ErrorCode::NotFound => return Ok(None),
74            Err(source) => {
75                return Err(DiscoverSnafu { path }.into_error(source).into());
76            }
77        };
78        let Some(worktree) = repo.workdir() else {
79            return Err(error::RepositoryWithoutWorktreeSnafu { path: repo.path() }.build());
80        };
81        let worktree = worktree.to_owned();
82        Ok(Some(Box::new(Libgit2Repository { repo, worktree })))
83    }
84
85    fn open(&self, path: &Path) -> Result<Option<Box<dyn VcsRepository>>, ModifyGuardError> {
86        util::ensure_path_is_directory(path)?;
87        let repo = match git2::Repository::open(path) {
88            Ok(repo) => repo,
89            Err(source) if source.code() == git2::ErrorCode::NotFound => return Ok(None),
90            Err(source) => {
91                return Err(OpenSnafu { path }.into_error(source).into());
92            }
93        };
94        let Some(worktree) = repo.workdir() else {
95            return Err(error::RepositoryWithoutWorktreeSnafu { path: repo.path() }.build());
96        };
97        let worktree = worktree.to_owned();
98        Ok(Some(Box::new(Libgit2Repository { repo, worktree })))
99    }
100}
101
102struct Libgit2Repository {
103    repo: git2::Repository,
104    worktree: PathBuf,
105}
106
107impl fmt::Debug for Libgit2Repository {
108    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
109        f.debug_struct("Libgit2Repository")
110            .field("repo", &"<git2::Repository>")
111            .field("worktree", &self.worktree)
112            .finish()
113    }
114}
115
116impl VcsRepository for Libgit2Repository {
117    fn worktree(&self) -> &Path {
118        &self.worktree
119    }
120
121    fn repository_changes(&self) -> Result<Option<RepositoryChanges>, ModifyGuardError> {
122        self.collect_dir_changes(None)
123    }
124
125    fn path_changes(&self, wt_path: &Path) -> Result<Option<RepositoryChanges>, ModifyGuardError> {
126        let wt_path = WorktreeRelativePath::from_wt_path(&self.worktree, wt_path)?;
127        let is_dir = match &wt_path {
128            WorktreeRelativePath::Existing(wt_path) => {
129                let fs_path = self.worktree.join(wt_path);
130                let metadata = util::read_path_metadata(&fs_path)?;
131                metadata.is_dir()
132            }
133            WorktreeRelativePath::Missing(_) => true,
134        };
135        if wt_path.as_path().as_os_str().is_empty() {
136            return self.collect_dir_changes(None);
137        }
138        if is_dir {
139            return self.collect_dir_changes(Some(&wt_path));
140        }
141        let change = self.query_file_change(wt_path)?;
142        Ok(change.and_then(|change| RepositoryChanges::new([change])))
143    }
144
145    fn file_change(&self, wt_path: &Path) -> Result<Option<FileChange>, ModifyGuardError> {
146        let wt_path = WorktreeRelativePath::from_wt_path(&self.worktree, wt_path)?;
147        match &wt_path {
148            WorktreeRelativePath::Existing(wt_path) => {
149                let fs_path = self.worktree.join(wt_path);
150                util::ensure_path_is_file(&fs_path)?;
151            }
152            WorktreeRelativePath::Missing(_) => {}
153        }
154        self.query_file_change(wt_path)
155    }
156}
157
158impl Libgit2Repository {
159    fn collect_dir_changes(
160        &self,
161        wt_path: Option<&WorktreeRelativePath>,
162    ) -> Result<Option<RepositoryChanges>, ModifyGuardError> {
163        let mut repo_opts = git2::StatusOptions::new();
164        if let Some(wt_path) = wt_path {
165            repo_opts.pathspec(wt_path.as_path());
166            repo_opts.disable_pathspec_match(true);
167        }
168        repo_opts.include_untracked(true);
169        repo_opts.recurse_untracked_dirs(true);
170        let entries =
171            self.repo
172                .statuses(Some(&mut repo_opts))
173                .context(QueryRepositoryChangesSnafu {
174                    worktree: &self.worktree,
175                })?;
176        let mut file_entries = entries
177            .iter()
178            .filter_map(|entry| {
179                // Like `cargo fix`, aggregate queries ignore paths that cannot
180                // be represented on this platform instead of failing the whole
181                // query.
182                let wt_path = util::bytes_to_os_str(entry.path_bytes()).ok()?;
183                StatusFlags::from(entry.status()).build(wt_path)
184            })
185            .peekable();
186
187        if file_entries.peek().is_none()
188            && let Some(WorktreeRelativePath::Missing(wt_path)) = &wt_path
189        {
190            return Err(error::PathNotFoundSnafu { path: wt_path }.build());
191        }
192
193        Ok(RepositoryChanges::new(file_entries))
194    }
195
196    fn query_file_change(
197        &self,
198        wt_path: WorktreeRelativePath,
199    ) -> Result<Option<FileChange>, ModifyGuardError> {
200        let status = match self.repo.status_file(wt_path.as_path()) {
201            Ok(status) => status,
202            Err(source) if source.code() == git2::ErrorCode::Ambiguous => {
203                return Err(error::AmbiguousFilePathSnafu { wt_path }.build());
204            }
205            Err(source) if source.code() == git2::ErrorCode::NotFound => {
206                match &wt_path {
207                    WorktreeRelativePath::Existing(wt_path) => {
208                        // At this point `wt_path` has already been resolved to an
209                        // existing file within the worktree, so `NotFound` means the
210                        // file is untracked by Git rather than missing from disk.
211                        return Ok(StatusFlags {
212                            dirty: true,
213                            staged: false,
214                        }
215                        .build(wt_path));
216                    }
217                    WorktreeRelativePath::Missing(wt_path) => {
218                        return Err(error::PathNotFoundSnafu { path: wt_path }.build());
219                    }
220                }
221            }
222            Err(source) => {
223                return Err(QueryFileChangeSnafu { wt_path }.into_error(source).into());
224            }
225        };
226        Ok(StatusFlags::from(status).build(wt_path))
227    }
228}
229
230#[derive(Debug, Clone, Copy)]
231struct StatusFlags {
232    dirty: bool,
233    staged: bool,
234}
235
236impl From<git2::Status> for StatusFlags {
237    fn from(status: git2::Status) -> Self {
238        let dirty = status.is_conflicted()
239            || status.is_wt_new()
240            || status.is_wt_modified()
241            || status.is_wt_deleted()
242            || status.is_wt_renamed()
243            || status.is_wt_typechange();
244        let staged = status.is_conflicted()
245            || status.is_index_new()
246            || status.is_index_modified()
247            || status.is_index_deleted()
248            || status.is_index_renamed()
249            || status.is_index_typechange();
250        Self { dirty, staged }
251    }
252}
253
254impl StatusFlags {
255    fn build<P>(self, wt_path: P) -> Option<FileChange>
256    where
257        P: Into<PathBuf>,
258    {
259        let Self { dirty, staged } = self;
260        if !dirty && !staged {
261            return None;
262        }
263
264        let wt_path = wt_path.into();
265        Some(FileChange {
266            wt_path,
267            dirty,
268            staged,
269        })
270    }
271}