Skip to main content

vcs_modify_guard/vcs/
git_gix.rs

1use std::{
2    collections::BTreeMap,
3    fmt,
4    path::{Path, PathBuf},
5};
6
7use gix::{bstr::BString, status::index_worktree::iter::Summary};
8use snafu::{IntoError as _, ResultExt as _, Snafu};
9
10use super::VcsRepository;
11use crate::{
12    ModifyGuardError,
13    error::{self},
14    repository::{FileChange, RepositoryChanges},
15    util::{self, WorktreeRelativePath},
16    vcs::VcsBackend,
17};
18
19pub(super) const BACKEND: GixBackend = GixBackend;
20
21#[derive(Debug)]
22pub(super) struct GixBackend;
23
24/// Errors returned by `gix` backend operations.
25#[derive(Debug, Snafu)]
26#[non_exhaustive]
27pub enum GixBackendError {
28    /// Searching for a Git repository failed.
29    #[snafu(display("failed while searching for a git repository at or above path: {}", path.display()))]
30    Discover {
31        /// The path that was being searched for a Git repository.
32        path: PathBuf,
33        /// The underlying error from `gix`.
34        source: gix::discover::Error,
35    },
36    /// Opening a Git repository failed.
37    #[snafu(display("failed to open git repository at path: {}", path.display()))]
38    Open {
39        /// The path that was being opened as a Git repository.
40        path: PathBuf,
41        /// The underlying error from `gix`.
42        source: gix::open::Error,
43    },
44    /// Querying the status of a Git repository failed.
45    #[snafu(display("failed to query git repository status for worktree: {}", worktree.display()))]
46    Status {
47        /// The worktree of the Git repository.
48        worktree: PathBuf,
49        /// The underlying error from `gix`.
50        source: gix::status::Error,
51    },
52    /// Converting the status of a Git repository into an iterator failed.
53    #[snafu(display("failed to convert git repository status into iterator for worktree: {}", worktree.display()))]
54    StatusIntoIter {
55        /// The worktree of the Git repository.
56        worktree: PathBuf,
57        /// The underlying error from `gix`.
58        source: gix::status::into_iter::Error,
59    },
60    /// Iterating over the status of a Git repository failed.
61    #[snafu(display("failed to iterate git repository status for worktree: {}", worktree.display()))]
62    IterateStatus {
63        /// The worktree of the Git repository.
64        worktree: PathBuf,
65        /// The underlying error from `gix`.
66        source: gix::status::iter::Error,
67    },
68}
69
70impl From<GixBackendError> for ModifyGuardError {
71    #[inline]
72    fn from(source: GixBackendError) -> Self {
73        Self::Backend {
74            source: source.into(),
75        }
76    }
77}
78
79impl VcsBackend for GixBackend {
80    fn discover(
81        &self,
82        mut path: &Path,
83    ) -> Result<Option<Box<dyn VcsRepository>>, ModifyGuardError> {
84        util::ensure_path_exists(path)?;
85        #[expect(
86            clippy::unwrap_used,
87            reason = "path is guaranteed to have a parent because it exists and is a file"
88        )]
89        if path.is_file() {
90            path = path.parent().unwrap();
91        }
92
93        let repo = match gix::discover(path) {
94            Ok(repo) => repo,
95            Err(gix::discover::Error::Discover(
96                gix::discover::upwards::Error::NoGitRepository { .. }
97                | gix::discover::upwards::Error::NoGitRepositoryWithinCeiling { .. }
98                | gix::discover::upwards::Error::NoGitRepositoryWithinFs { .. },
99            )) => return Ok(None),
100            Err(source) => return Err(DiscoverSnafu { path }.into_error(source).into()),
101        };
102        let Some(worktree) = repo.workdir().map(Path::to_owned) else {
103            return Err(error::RepositoryWithoutWorktreeSnafu {
104                path: repo.git_dir(),
105            }
106            .build());
107        };
108        Ok(Some(Box::new(GixRepository { repo, worktree })))
109    }
110
111    fn open(&self, path: &Path) -> Result<Option<Box<dyn VcsRepository>>, ModifyGuardError> {
112        util::ensure_path_is_directory(path)?;
113
114        let repo = match gix::open(path) {
115            Ok(repo) => repo,
116            Err(gix::open::Error::NotARepository { .. }) => return Ok(None),
117            Err(source) => return Err(OpenSnafu { path }.into_error(source).into()),
118        };
119        let Some(worktree) = repo.workdir().map(Path::to_owned) else {
120            return Err(error::RepositoryWithoutWorktreeSnafu { path }.build());
121        };
122        Ok(Some(Box::new(GixRepository { repo, worktree })))
123    }
124}
125
126struct GixRepository {
127    repo: gix::Repository,
128    worktree: PathBuf,
129}
130
131impl fmt::Debug for GixRepository {
132    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
133        f.debug_struct("GixRepository")
134            .field("repo", &"<gix::Repository>")
135            .field("worktree", &self.worktree)
136            .finish()
137    }
138}
139
140impl VcsRepository for GixRepository {
141    fn worktree(&self) -> &Path {
142        &self.worktree
143    }
144
145    fn repository_changes(&self) -> Result<Option<RepositoryChanges>, ModifyGuardError> {
146        Ok(RepositoryChanges::new(self.collect_changes(None)?))
147    }
148
149    fn path_changes(&self, wt_path: &Path) -> Result<Option<RepositoryChanges>, ModifyGuardError> {
150        let wt_path = WorktreeRelativePath::from_wt_path(&self.worktree, wt_path)?;
151        if wt_path.is_empty() {
152            return self.repository_changes();
153        }
154
155        let changes = self.collect_changes(Some(&wt_path))?;
156
157        if changes.is_empty() {
158            if matches!(wt_path, WorktreeRelativePath::Missing(_)) {
159                return Err(error::PathNotFoundSnafu {
160                    path: wt_path.as_path(),
161                }
162                .build());
163            }
164            return Ok(None);
165        }
166
167        Ok(RepositoryChanges::new(changes))
168    }
169
170    fn file_change(&self, wt_path: &Path) -> Result<Option<FileChange>, ModifyGuardError> {
171        let wt_path = WorktreeRelativePath::from_wt_path(&self.worktree, wt_path)?;
172        match &wt_path {
173            WorktreeRelativePath::Existing(wt_path) => {
174                let fs_path = self.worktree.join(wt_path);
175                util::ensure_path_is_file(&fs_path)?;
176            }
177            WorktreeRelativePath::Missing(_) => {}
178        }
179
180        let changes = self.collect_changes(Some(&wt_path))?;
181
182        match changes.as_slice() {
183            [] => match wt_path {
184                WorktreeRelativePath::Existing(_) => Ok(None),
185                WorktreeRelativePath::Missing(_) => {
186                    Err(error::PathNotFoundSnafu { path: wt_path }.build())
187                }
188            },
189            [change] if change.wt_path() == wt_path.as_path() => Ok(Some(change.clone())),
190            [..] => Err(error::AmbiguousFilePathSnafu { wt_path }.build()),
191        }
192    }
193}
194
195impl GixRepository {
196    fn collect_changes(
197        &self,
198        wt_path: Option<&WorktreeRelativePath>,
199    ) -> Result<Vec<FileChange>, ModifyGuardError> {
200        let worktree = &self.worktree;
201        let status_platform = self
202            .repo
203            .status(gix::progress::Discard)
204            .context(StatusSnafu { worktree })?
205            .untracked_files(gix::status::UntrackedFiles::Files)
206            .index_worktree_rewrites(None)
207            .tree_index_track_renames(gix::status::tree_index::TrackRenames::Disabled);
208        let patterns = wt_path.map(|wt_path| literal_pathspec(wt_path.as_path()));
209        let status_iter = status_platform
210            .into_iter(patterns)
211            .context(StatusIntoIterSnafu { worktree })?;
212
213        let mut changes = BTreeMap::<PathBuf, StatusFlags>::new();
214        for item in status_iter {
215            let item = item.context(IterateStatusSnafu { worktree })?;
216            let wt_path = item.location();
217            let status = match &item {
218                gix::status::Item::TreeIndex(_change) => StatusFlags::STAGED,
219                gix::status::Item::IndexWorktree(item) => {
220                    let Some(summary) = item.summary() else {
221                        continue;
222                    };
223                    match summary {
224                        Summary::Removed
225                        | Summary::Added
226                        | Summary::Modified
227                        | Summary::TypeChange
228                        | Summary::Renamed
229                        | Summary::Copied
230                        | Summary::IntentToAdd => StatusFlags::DIRTY,
231                        Summary::Conflict => StatusFlags::DIRTY_AND_STAGED,
232                    }
233                }
234            };
235            changes
236                .entry(gix::path::from_bstring(wt_path))
237                .or_default()
238                .merge(status);
239        }
240
241        Ok(changes
242            .into_iter()
243            .filter_map(|(wt_path, status)| status.build(wt_path))
244            .collect())
245    }
246}
247
248#[derive(Debug, Default, Clone, Copy)]
249struct StatusFlags {
250    dirty: bool,
251    staged: bool,
252}
253
254impl StatusFlags {
255    const DIRTY: Self = Self {
256        dirty: true,
257        staged: false,
258    };
259    const STAGED: Self = Self {
260        dirty: false,
261        staged: true,
262    };
263    const DIRTY_AND_STAGED: Self = Self {
264        dirty: true,
265        staged: true,
266    };
267
268    fn merge(&mut self, other: Self) {
269        self.dirty |= other.dirty;
270        self.staged |= other.staged;
271    }
272
273    fn build(self, wt_path: PathBuf) -> Option<FileChange> {
274        let Self { dirty, staged } = self;
275        if !dirty && !staged {
276            return None;
277        }
278        Some(FileChange {
279            wt_path,
280            dirty,
281            staged,
282        })
283    }
284}
285
286fn literal_pathspec(path: &Path) -> BString {
287    let mut pattern = BString::from(":(top,literal)");
288    pattern.extend_from_slice(&gix::path::into_bstr(path));
289    pattern
290}