vcs_modify_guard/repository/mod.rs
1//! Lower-level repository change query APIs.
2//!
3//! Most users should start with [`crate::AllowOptions`], which implements the
4//! crate's built-in `--allow-*` safe-to-modify policy.
5//!
6//! This module provides the [`Repository`] type for tools that need to
7//! discover a VCS repository and inspect whether files are dirty and/or staged
8//! directly in order to implement custom policy logic. Dirty files include
9//! modified tracked files and untracked files.
10//!
11//! Paths returned by change queries are relative to the repository worktree.
12//! Query methods that take a `wt_path` argument interpret it relative to that
13//! worktree. If you start with an absolute path or a path relative to the
14//! current working directory, use [`Repository::resolve_path`] first.
15//!
16//! Start with [`Repository::discover`] or [`Repository::open`], then query
17//! repository-wide changes directly or resolve a path and query it within the
18//! worktree.
19//!
20//! # Example
21//!
22//! The following example shows how to implement a custom safe-to-modify policy
23//! by querying repository changes directly.
24//!
25//! ```no_run
26//! use std::{error::Error, path::Path};
27//!
28//! use vcs_modify_guard::repository::Repository;
29//!
30//! struct PolicyOptions {
31//! allow_no_vcs: bool,
32//! allow_dirty: bool,
33//! allow_staged: bool,
34//! }
35//!
36//! fn ensure_safe_to_modify(target: &Path, options: &PolicyOptions) -> Result<(), Box<dyn Error>> {
37//! // Match `cargo fix` exactly:
38//! // - `--allow-no-vcs` allows running even when no repository is found.
39//! // - `--allow-dirty` allows worktree changes, staged changes, and
40//! // untracked files.
41//! // - `--allow-staged` allows staged changes, but still rejects
42//! // worktree changes and untracked files.
43//! if options.allow_no_vcs {
44//! return Ok(());
45//! }
46//!
47//! let Some(repo) = Repository::discover(target)? else {
48//! return Err("no VCS found for the target path".into());
49//! };
50//!
51//! let Some(changes) = repo.repository_changes()? else {
52//! return Ok(());
53//! };
54//!
55//! if options.allow_dirty {
56//! return Ok(());
57//! }
58//!
59//! if changes.has_dirty_files() {
60//! return Err("the repository containing the target path has uncommitted changes".into());
61//! }
62//!
63//! if options.allow_staged {
64//! return Ok(());
65//! }
66//!
67//! if changes.has_staged_files() {
68//! return Err("the repository containing the target path has staged changes".into());
69//! }
70//!
71//! Ok(())
72//! }
73//! ```
74//!
75//! See the `repository` example for a complete command-line application.
76
77use std::{
78 path::{Path, PathBuf},
79 slice,
80};
81
82use crate::{
83 ModifyGuardError,
84 vcs::{self, VcsRepository},
85};
86
87#[cfg(test)]
88mod tests;
89
90/// A lower-level handle for querying changes in a VCS repository worktree.
91///
92/// Most users should start with [`crate::AllowOptions`], which implements the
93/// crate's built-in `--allow-*` safe-to-modify policy.
94///
95/// Use this type when you need to discover a repository and inspect whether
96/// files are dirty and/or staged directly in order to implement custom policy
97/// logic. Dirty files include modified tracked files and untracked files.
98///
99/// Repositories without a worktree, such as Git bare repositories, are not
100/// represented by this type.
101///
102/// # Example
103///
104/// The following example shows how to implement a custom safe-to-modify
105/// policy by querying repository changes directly.
106///
107/// ```no_run
108/// use std::{error::Error, path::Path};
109///
110/// use vcs_modify_guard::repository::Repository;
111///
112/// struct PolicyOptions {
113/// allow_no_vcs: bool,
114/// allow_dirty: bool,
115/// allow_staged: bool,
116/// }
117///
118/// fn ensure_safe_to_modify(target: &Path, options: &PolicyOptions) -> Result<(), Box<dyn Error>> {
119/// // Match `cargo fix` exactly:
120/// // - `--allow-no-vcs` allows running even when no repository is found.
121/// // - `--allow-dirty` allows worktree changes, staged changes, and
122/// // untracked files.
123/// // - `--allow-staged` allows staged changes, but still rejects
124/// // worktree changes and untracked files.
125/// if options.allow_no_vcs {
126/// return Ok(());
127/// }
128///
129/// let Some(repo) = Repository::discover(target)? else {
130/// return Err("no VCS found for the target path".into());
131/// };
132///
133/// let Some(changes) = repo.repository_changes()? else {
134/// return Ok(());
135/// };
136///
137/// if options.allow_dirty {
138/// return Ok(());
139/// }
140///
141/// if changes.has_dirty_files() {
142/// return Err("the repository containing the target path has uncommitted changes".into());
143/// }
144///
145/// if options.allow_staged {
146/// return Ok(());
147/// }
148///
149/// if changes.has_staged_files() {
150/// return Err("the repository containing the target path has staged changes".into());
151/// }
152///
153/// Ok(())
154/// }
155/// ```
156///
157/// See the `repository` example for a complete command-line application.
158#[derive(Debug)]
159pub struct Repository {
160 inner: Box<dyn VcsRepository>,
161}
162
163impl Repository {
164 /// Discovers the repository containing `path`.
165 ///
166 /// This searches `path` and its parent directories for a repository
167 /// supported by one of the enabled backends.
168 ///
169 /// Returns `Ok(Some(_))` if a supported repository worktree is found, or
170 /// `Ok(None)` if no supported repository is found.
171 ///
172 /// # Errors
173 ///
174 /// Returns an error if:
175 ///
176 /// - a backend fails while probing `path`
177 /// - the discovered repository does not provide a worktree for file
178 /// change checks
179 #[inline]
180 pub fn discover<P>(path: P) -> Result<Option<Self>, ModifyGuardError>
181 where
182 P: AsRef<Path>,
183 {
184 let Some(inner) = vcs::discover(path.as_ref())? else {
185 return Ok(None);
186 };
187 Ok(Some(Self { inner }))
188 }
189
190 /// Opens the repository at `path`.
191 ///
192 /// Unlike [`Self::discover`], this does not search parent directories.
193 /// `path` must identify a repository directly according to the enabled
194 /// backend.
195 ///
196 /// # Errors
197 ///
198 /// Returns an error if:
199 ///
200 /// - `path` does not refer to a supported repository worktree
201 /// - the backend fails to open it
202 #[inline]
203 pub fn open<P>(path: P) -> Result<Self, ModifyGuardError>
204 where
205 P: AsRef<Path>,
206 {
207 let inner = vcs::open(path.as_ref())?;
208 Ok(Self { inner })
209 }
210
211 /// Returns the root directory of the repository worktree.
212 #[inline]
213 #[must_use]
214 pub fn worktree(&self) -> &Path {
215 self.inner.worktree()
216 }
217
218 /// Resolves a path to a repository worktree-relative path.
219 ///
220 /// This follows symlinks and canonicalizes the existing prefix of `path`.
221 /// The returned path is relative to [`Self::worktree`].
222 ///
223 /// If `path` does not exist in the worktree, this method may still succeed
224 /// when the missing path is lexically within the worktree, such as a path
225 /// to a deleted tracked file.
226 ///
227 /// Use this when you start with an absolute path or a path relative to the
228 /// current working directory and need the corresponding worktree-relative
229 /// path for [`Self::path_changes`] or [`Self::file_change`].
230 ///
231 /// # Errors
232 ///
233 /// Returns an error if:
234 ///
235 /// - `path` does not resolve to a path within [`Self::worktree`]
236 /// - `path` could not be resolved to a canonical path for any other reason
237 #[inline]
238 pub fn resolve_path<P>(&self, path: P) -> Result<PathBuf, ModifyGuardError>
239 where
240 P: AsRef<Path>,
241 {
242 self.inner.resolve_path(path.as_ref())
243 }
244
245 /// Returns the aggregate file changes in the repository worktree.
246 ///
247 /// Returns `Ok(None)` if the repository has no dirty or staged files.
248 /// Clean tracked files are not included in this aggregate
249 /// change set. Files ignored by the VCS are also omitted because this
250 /// crate is intended for `--allow-*` style checks, which treat them the
251 /// same as clean files.
252 ///
253 /// Paths in the returned changes are relative to [`Self::worktree`].
254 ///
255 /// # Errors
256 ///
257 /// Returns an error if the backend fails to query repository changes.
258 #[inline]
259 pub fn repository_changes(&self) -> Result<Option<RepositoryChanges>, ModifyGuardError> {
260 self.inner.repository_changes()
261 }
262
263 /// Returns the aggregate file changes for the worktree-relative `wt_path`
264 /// within the repository.
265 ///
266 /// If `wt_path` resolves to a file path, the returned change set contains at
267 /// most that file. If `wt_path` resolves to a directory path, the returned
268 /// change set contains changes for files under that directory. The
269 /// repository worktree root is also accepted.
270 ///
271 /// Returns `Ok(None)` if the resolved path has no dirty or staged files.
272 /// Clean tracked files are not included in this aggregate
273 /// change set. Files ignored by the VCS are also omitted because this
274 /// crate is intended for `--allow-*` style checks, which treat them the
275 /// same as clean files.
276 ///
277 /// Paths in the returned changes are relative to [`Self::worktree`].
278 ///
279 /// `wt_path` is interpreted relative to [`Self::worktree`]. If you have an
280 /// absolute path or a path relative to the current working directory, use
281 /// [`Self::resolve_path`] first. Symlinks are followed.
282 ///
283 /// If the resolved path does not exist in the worktree, this method may
284 /// still return changes when the path refers to paths known to the VCS,
285 /// such as a deleted tracked file path.
286 ///
287 /// # Errors
288 ///
289 /// Returns an error if:
290 ///
291 /// - `wt_path` does not resolve to a path within [`Self::worktree`]
292 /// - `wt_path` does not exist in the worktree and does not refer to a path
293 /// known to the VCS
294 /// - `wt_path` could not be resolved to a canonical path for any other reason
295 /// - the backend fails to query changes for `wt_path` for any other reason
296 #[inline]
297 pub fn path_changes<P>(&self, wt_path: P) -> Result<Option<RepositoryChanges>, ModifyGuardError>
298 where
299 P: AsRef<Path>,
300 {
301 self.inner.path_changes(wt_path.as_ref())
302 }
303
304 /// Returns the dirty and/or staged change for the worktree-relative file
305 /// path `wt_path` within the repository, if any.
306 ///
307 /// Returns `Ok(None)` if the resolved file path is clean or ignored by the
308 /// VCS.
309 ///
310 /// If this method returns `Ok(Some(change))`, the returned [`FileChange`]
311 /// describes the resolved file path.
312 ///
313 /// `wt_path` is interpreted relative to [`Self::worktree`]. If you have an
314 /// absolute path or a path relative to the current working directory, use
315 /// [`Self::resolve_path`] first. Symlinks are followed.
316 ///
317 /// If the resolved path exists in the worktree, it must be a file.
318 /// If it does not exist in the worktree, this method may still return a
319 /// change when the path refers to a tracked file path known to the VCS,
320 /// such as a deletion change. [`FileChange::wt_path`] may differ from the
321 /// path passed to this method when the input reaches the same file path
322 /// through symlinks or other equivalent non-canonical forms.
323 ///
324 /// A file may be both staged and dirty at the same time if it has staged
325 /// changes and additional unstaged tracked changes.
326 ///
327 /// This operation is intended for file paths. It does not perform rename
328 /// detection.
329 ///
330 /// # Errors
331 ///
332 /// Returns an error if:
333 ///
334 /// - `wt_path` does not resolve to a path within [`Self::worktree`]
335 /// - the resolved path exists in the worktree but does not resolve to a
336 /// file
337 /// - `wt_path` does not exist in the worktree and does not refer to a tracked
338 /// path known to the VCS
339 /// - `wt_path` could not be resolved to a canonical path for any other reason
340 /// - the backend fails to query file changes for any other reason
341 #[inline]
342 pub fn file_change<P>(&self, wt_path: P) -> Result<Option<FileChange>, ModifyGuardError>
343 where
344 P: AsRef<Path>,
345 {
346 self.inner.file_change(wt_path.as_ref())
347 }
348}
349
350/// A non-empty set of file changes.
351///
352/// Values of this type are returned by [`Repository::repository_changes`] and
353/// [`Repository::path_changes`].
354///
355/// This type contains only dirty and/or staged files. Dirty files have
356/// unstaged modifications or are untracked. Clean tracked files are not
357/// included. Files ignored by the VCS are also omitted
358/// because this crate is intended for `--allow-*` style checks, which treat
359/// them the same as clean files.
360///
361/// [`Repository::repository_changes`] and [`Repository::path_changes`] return
362/// `None` instead of an empty change set.
363///
364/// File changes are ordered by ascending worktree-relative path. Entries may
365/// include tracked paths that are no longer present in the worktree.
366#[derive(Debug, Clone)]
367pub struct RepositoryChanges {
368 files: Vec<FileChange>,
369 num_dirty_files: usize,
370 num_staged_files: usize,
371}
372
373impl RepositoryChanges {
374 #[cfg(any(test, vcs_backend_enabled))]
375 pub(crate) fn new<I>(files: I) -> Option<Self>
376 where
377 I: IntoIterator<Item = FileChange>,
378 {
379 let mut files = files.into_iter().collect::<Vec<_>>();
380 files.sort_by(|a, b| a.wt_path().cmp(b.wt_path()));
381 assert!(
382 files
383 .array_windows()
384 .all(|[a, b]| a.wt_path() != b.wt_path()),
385 "repository change entries must be unique by worktree-relative path",
386 );
387
388 if files.is_empty() {
389 return None;
390 }
391
392 let mut num_dirty_files = 0;
393 let mut num_staged_files = 0;
394
395 for file in &files {
396 num_dirty_files += usize::from(file.is_dirty());
397 num_staged_files += usize::from(file.is_staged());
398 }
399
400 Some(Self {
401 files,
402 num_dirty_files,
403 num_staged_files,
404 })
405 }
406
407 /// Returns an iterator over all file changes in this change set.
408 ///
409 /// Files are yielded in ascending worktree-relative path order.
410 #[inline]
411 #[must_use]
412 pub fn files(&self) -> Files<'_> {
413 Files {
414 iter: self.files.iter(),
415 }
416 }
417
418 /// Returns an iterator over dirty files in this change set.
419 ///
420 /// A dirty file has unstaged modifications or is untracked.
421 /// Staged-only changes are not considered dirty.
422 /// Files are yielded in ascending worktree-relative path order.
423 #[inline]
424 #[must_use]
425 pub fn dirty_files(&self) -> DirtyFiles<'_> {
426 DirtyFiles {
427 iter: self.files.iter(),
428 len: self.num_dirty_files,
429 }
430 }
431
432 /// Returns an iterator over files with staged changes in this change set.
433 ///
434 /// Files are yielded in ascending worktree-relative path order.
435 #[inline]
436 #[must_use]
437 pub fn staged_files(&self) -> StagedFiles<'_> {
438 StagedFiles {
439 iter: self.files.iter(),
440 len: self.num_staged_files,
441 }
442 }
443
444 /// Returns whether this change set contains any dirty files.
445 ///
446 /// A dirty file has unstaged modifications or is untracked.
447 /// Staged-only changes are not considered dirty.
448 #[inline]
449 #[must_use]
450 pub fn has_dirty_files(&self) -> bool {
451 self.num_dirty_files > 0
452 }
453
454 /// Returns whether this change set contains any files with staged changes.
455 #[inline]
456 #[must_use]
457 pub fn has_staged_files(&self) -> bool {
458 self.num_staged_files > 0
459 }
460}
461
462/// A file change within a repository.
463///
464/// Values of this type are returned by [`Repository::file_change`] and yielded
465/// by iterators over [`RepositoryChanges`] returned by
466/// [`Repository::repository_changes`] and [`Repository::path_changes`].
467///
468/// Instances of this type always represent a dirty file, a staged file, or
469/// both. Dirty files have unstaged modifications or are untracked. Clean
470/// tracked files and files ignored by the VCS are not represented;
471/// [`Repository::file_change`] returns `None` for those cases.
472///
473/// The stored path is the worktree-relative path associated with this change
474/// in the VCS. It may refer to a tracked path that is no longer present in the
475/// worktree.
476///
477/// More than one predicate may return `true` for the same file. For example,
478/// a file may have staged changes and additional unstaged tracked
479/// modifications.
480#[derive(Debug, Clone)]
481pub struct FileChange {
482 pub(crate) wt_path: PathBuf,
483 pub(crate) dirty: bool,
484 pub(crate) staged: bool,
485}
486
487impl FileChange {
488 /// Returns the worktree-relative path associated with this file change in
489 /// the VCS.
490 ///
491 /// This may refer to a tracked path that is no longer present in the
492 /// worktree.
493 ///
494 /// When this change is returned by [`Repository::file_change`], the
495 /// returned path may differ from the path passed to that method when the
496 /// input reaches the same path through symlinks or other equivalent
497 /// non-canonical forms.
498 #[inline]
499 #[must_use]
500 pub fn wt_path(&self) -> &Path {
501 &self.wt_path
502 }
503
504 /// Returns whether the file is dirty.
505 ///
506 /// A dirty file has unstaged modifications or is untracked.
507 /// Staged-only changes are not considered dirty.
508 #[inline]
509 #[must_use]
510 pub fn is_dirty(&self) -> bool {
511 self.dirty
512 }
513
514 /// Returns whether the file has staged changes in the index.
515 #[inline]
516 #[must_use]
517 pub fn is_staged(&self) -> bool {
518 self.staged
519 }
520}
521
522/// An iterator over all file changes in a [`RepositoryChanges`].
523///
524/// This struct is created by the [`RepositoryChanges::files`] method.
525/// Files are yielded in ascending worktree-relative path order.
526#[derive(Debug, Clone)]
527pub struct Files<'a> {
528 iter: slice::Iter<'a, FileChange>,
529}
530
531impl<'a> Iterator for Files<'a> {
532 type Item = &'a FileChange;
533
534 #[inline]
535 fn next(&mut self) -> Option<Self::Item> {
536 self.iter.next()
537 }
538
539 #[inline]
540 fn size_hint(&self) -> (usize, Option<usize>) {
541 self.iter.size_hint()
542 }
543}
544
545impl DoubleEndedIterator for Files<'_> {
546 #[inline]
547 fn next_back(&mut self) -> Option<Self::Item> {
548 self.iter.next_back()
549 }
550}
551
552impl ExactSizeIterator for Files<'_> {
553 #[inline]
554 fn len(&self) -> usize {
555 self.iter.len()
556 }
557}
558
559/// An iterator over dirty files in a [`RepositoryChanges`].
560///
561/// This struct is created by the [`RepositoryChanges::dirty_files`] method.
562/// Files are yielded in ascending worktree-relative path order.
563#[derive(Debug, Clone)]
564pub struct DirtyFiles<'a> {
565 iter: slice::Iter<'a, FileChange>,
566 len: usize,
567}
568
569impl<'a> Iterator for DirtyFiles<'a> {
570 type Item = &'a FileChange;
571
572 #[inline]
573 fn next(&mut self) -> Option<Self::Item> {
574 let file = self.iter.find(|file| file.is_dirty())?;
575 self.len -= 1;
576 Some(file)
577 }
578
579 #[inline]
580 fn size_hint(&self) -> (usize, Option<usize>) {
581 (self.len, Some(self.len))
582 }
583}
584
585impl DoubleEndedIterator for DirtyFiles<'_> {
586 #[inline]
587 fn next_back(&mut self) -> Option<Self::Item> {
588 let file = self.iter.rfind(|file| file.is_dirty())?;
589 self.len -= 1;
590 Some(file)
591 }
592}
593
594impl ExactSizeIterator for DirtyFiles<'_> {
595 #[inline]
596 fn len(&self) -> usize {
597 self.len
598 }
599}
600
601/// An iterator over files with staged changes in a [`RepositoryChanges`].
602///
603/// This struct is created by the [`RepositoryChanges::staged_files`] method.
604/// Files are yielded in ascending worktree-relative path order.
605#[derive(Debug, Clone)]
606pub struct StagedFiles<'a> {
607 iter: slice::Iter<'a, FileChange>,
608 len: usize,
609}
610
611impl<'a> Iterator for StagedFiles<'a> {
612 type Item = &'a FileChange;
613
614 #[inline]
615 fn next(&mut self) -> Option<Self::Item> {
616 let file = self.iter.find(|file| file.is_staged())?;
617 self.len -= 1;
618 Some(file)
619 }
620
621 #[inline]
622 fn size_hint(&self) -> (usize, Option<usize>) {
623 (self.len, Some(self.len))
624 }
625}
626
627impl DoubleEndedIterator for StagedFiles<'_> {
628 #[inline]
629 fn next_back(&mut self) -> Option<Self::Item> {
630 let file = self.iter.rfind(|file| file.is_staged())?;
631 self.len -= 1;
632 Some(file)
633 }
634}
635
636impl ExactSizeIterator for StagedFiles<'_> {
637 #[inline]
638 fn len(&self) -> usize {
639 self.len
640 }
641}