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#[derive(Debug, Snafu)]
26#[non_exhaustive]
27pub enum GixBackendError {
28 #[snafu(display("failed while searching for a git repository at or above path: {}", path.display()))]
30 Discover {
31 path: PathBuf,
33 source: gix::discover::Error,
35 },
36 #[snafu(display("failed to open git repository at path: {}", path.display()))]
38 Open {
39 path: PathBuf,
41 source: gix::open::Error,
43 },
44 #[snafu(display("failed to query git repository status for worktree: {}", worktree.display()))]
46 Status {
47 worktree: PathBuf,
49 source: gix::status::Error,
51 },
52 #[snafu(display("failed to convert git repository status into iterator for worktree: {}", worktree.display()))]
54 StatusIntoIter {
55 worktree: PathBuf,
57 source: gix::status::into_iter::Error,
59 },
60 #[snafu(display("failed to iterate git repository status for worktree: {}", worktree.display()))]
62 IterateStatus {
63 worktree: PathBuf,
65 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}