vcs_modify_guard/vcs/
git_libgit2.rs1use 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#[derive(Debug, Snafu)]
23#[non_exhaustive]
24pub enum Libgit2BackendError {
25 #[snafu(display("failed while searching for a git repository at or above path: {}", path.display()))]
27 Discover {
28 path: PathBuf,
30 source: git2::Error,
32 },
33 #[snafu(display("failed to open git repository at path: {}", path.display()))]
35 Open {
36 path: PathBuf,
38 source: git2::Error,
40 },
41 #[snafu(display("failed to query git repository changes for worktree: {}", worktree.display()))]
43 QueryRepositoryChanges {
44 worktree: PathBuf,
46 source: git2::Error,
48 },
49 #[snafu(display("failed to query git file change for worktree-relative path: {}", wt_path.display()))]
51 QueryFileChange {
52 wt_path: PathBuf,
54 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 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 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}