Skip to main content

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}