1#![doc = include_str!("../README.md")]
5
6use snafu::prelude::*;
7
8use camino::{Utf8Path, Utf8PathBuf};
9use filetime::FileTime;
10use git2::{Diff, Oid, Repository};
11use std::collections::{HashMap, HashSet};
12use std::convert::TryInto;
13use std::sync::{Arc, RwLock};
14use std::{env, fs};
15
16#[cfg(feature = "cli")]
17pub mod cli;
18
19#[derive(Snafu)]
20pub enum Error {
21 #[snafu(display("std::io::Error {}", source))]
22 IoError {
23 source: std::io::Error,
24 },
25 #[snafu(display("git2::Error {}", source))]
26 LibGitError {
27 source: git2::Error,
28 },
29 #[snafu(display("Paths {} are not tracked in the repository.", paths))]
30 PathNotTracked {
31 paths: String,
32 },
33 #[snafu(display("Cannot remove prefix from path:\n{}", source))]
34 PathError {
35 source: std::path::StripPrefixError,
36 },
37 #[snafu(display("Path contains invalid Unicode:\n{}", source))]
38 PathEncodingError {
39 source: camino::FromPathBufError,
40 },
41 UnresolvedError {},
42}
43
44impl std::fmt::Debug for Error {
47 fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
48 std::fmt::Display::fmt(self, fmt)
49 }
50}
51
52pub type Result<T, E = Error> = std::result::Result<T, E>;
53
54pub type FileSet = HashSet<Utf8PathBuf>;
55
56#[derive(Clone, Debug)]
58pub struct Options {
59 paths: Option<FileSet>,
60 dirty: bool,
61 ignored: bool,
62 ignore_older: bool,
63 verbose: bool,
64}
65
66impl Default for Options {
68 fn default() -> Self {
70 Self::new()
71 }
72}
73
74impl Options {
75 pub fn new() -> Options {
77 Options {
78 paths: None,
79 dirty: false,
80 ignored: false,
81 ignore_older: false,
82 verbose: false,
83 }
84 }
85
86 pub fn dirty(&self, flag: bool) -> Options {
88 Options {
89 paths: self.paths.clone(),
90 dirty: flag,
91 ignored: self.ignored,
92 ignore_older: self.ignore_older,
93 verbose: self.verbose,
94 }
95 }
96
97 pub fn ignored(&self, flag: bool) -> Options {
99 Options {
100 paths: self.paths.clone(),
101 dirty: self.dirty,
102 ignored: flag,
103 ignore_older: self.ignore_older,
104 verbose: self.verbose,
105 }
106 }
107
108 pub fn ignore_older(&self, flag: bool) -> Options {
110 Options {
111 paths: self.paths.clone(),
112 dirty: self.dirty,
113 ignored: self.ignored,
114 ignore_older: flag,
115 verbose: self.verbose,
116 }
117 }
118
119 pub fn verbose(&self, flag: bool) -> Options {
121 Options {
122 paths: self.paths.clone(),
123 dirty: self.dirty,
124 ignored: self.ignored,
125 ignore_older: self.ignore_older,
126 verbose: flag,
127 }
128 }
129
130 pub fn paths(&self, input: Option<FileSet>) -> Options {
132 Options {
133 paths: input,
134 dirty: self.dirty,
135 ignored: self.ignored,
136 ignore_older: self.ignore_older,
137 verbose: self.verbose,
138 }
139 }
140}
141
142pub fn reset_mtimes(repo: Repository, opts: Options) -> Result<FileSet> {
146 let workdir_files = gather_workdir_files(&repo)?;
147 let touchables: FileSet = match opts.paths {
148 Some(ref paths) => {
149 let not_tracked = paths.difference(&workdir_files);
150 if not_tracked.clone().count() > 0 {
151 let not_tracked = format!("{not_tracked:?}");
152 return PathNotTrackedSnafu { paths: not_tracked }.fail();
153 }
154 workdir_files.intersection(paths).cloned().collect()
155 }
156 None => {
157 let candidates = gather_index_files(&repo, &opts)?;
158 workdir_files.intersection(&candidates).cloned().collect()
159 }
160 };
161 let touched = process_touchables(&repo, touchables, &opts)?;
162 Ok(touched)
163}
164
165pub fn get_repo() -> Result<Repository> {
167 let repo = Repository::open_from_env().context(LibGitSnafu)?;
168 Ok(repo)
169}
170
171pub fn resolve_repo_path(repo: &Repository, path: impl Into<Utf8PathBuf>) -> Result<Utf8PathBuf> {
173 let path: Utf8PathBuf = path.into();
174 let cwd: Utf8PathBuf = env::current_dir()
175 .context(IoSnafu)?
176 .try_into()
177 .context(PathEncodingSnafu)?;
178 let root = repo.workdir().context(UnresolvedSnafu)?;
179 let prefix: Utf8PathBuf = cwd.strip_prefix(root).context(PathSnafu)?.into();
180 let resolved_path = if path.is_absolute() {
181 path
182 } else {
183 prefix.join(path)
184 };
185 Ok(resolved_path)
186}
187
188fn gather_index_files(repo: &Repository, opts: &Options) -> Result<FileSet> {
189 let mut candidates = FileSet::new();
190 let mut status_options = git2::StatusOptions::new();
191 status_options
192 .include_unmodified(true)
193 .exclude_submodules(true)
194 .include_ignored(opts.ignored)
195 .show(git2::StatusShow::IndexAndWorkdir);
196 let statuses = repo
197 .statuses(Some(&mut status_options))
198 .context(LibGitSnafu)?;
199 for entry in statuses.iter() {
200 let path = entry.path().context(UnresolvedSnafu)?;
201 match entry.status() {
202 git2::Status::CURRENT => {
203 candidates.insert(path.into());
204 }
205 git2::Status::INDEX_MODIFIED => {
206 if opts.dirty {
207 candidates.insert(path.into());
208 } else if opts.verbose {
209 println!("Ignored file with staged modifications: {path}");
210 }
211 }
212 git2::Status::WT_MODIFIED => {
213 if opts.dirty {
214 candidates.insert(path.into());
215 } else if opts.verbose {
216 println!("Ignored file with local modifications: {path}");
217 }
218 }
219 git_state => {
220 if opts.verbose {
221 println!("Ignored file in state {git_state:?}: {path}");
222 }
223 }
224 }
225 }
226 Ok(candidates)
227}
228
229fn gather_workdir_files(repo: &Repository) -> Result<FileSet> {
230 let mut workdir_files = FileSet::new();
231 let head = repo.head().context(LibGitSnafu)?;
232 let tree = head.peel_to_tree().context(LibGitSnafu)?;
233 tree.walk(git2::TreeWalkMode::PostOrder, |dir, entry| {
234 if let Some(name) = entry.name() {
235 let file = format!("{}{}", dir, name);
236 let path = Utf8Path::new(&file);
237 if path.is_dir() {
238 return git2::TreeWalkResult::Skip;
239 }
240 workdir_files.insert(file.into());
241 }
242 git2::TreeWalkResult::Ok
243 })
244 .context(LibGitSnafu)?;
245 Ok(workdir_files)
246}
247
248fn diff_affects_oid(diff: &Diff, oid: &Oid, touchable_path: &mut Utf8PathBuf) -> bool {
249 diff.deltas().any(|delta| {
250 delta.new_file().id() == *oid
251 && delta
252 .new_file()
253 .path()
254 .filter(|path| *path == touchable_path)
255 .is_some()
256 })
257}
258
259fn touch_if_time_mismatch(
260 path: Utf8PathBuf,
261 time: i64,
262 verbose: bool,
263 ignore_older: bool,
264) -> Result<bool> {
265 let commit_time = FileTime::from_unix_time(time, 0);
266 let metadata = fs::metadata(&path).context(IoSnafu)?;
267 let file_mtime = FileTime::from_last_modification_time(&metadata);
268 if file_mtime > commit_time || (!ignore_older && file_mtime < commit_time) {
269 filetime::set_file_mtime(&path, commit_time).context(IoSnafu)?;
270 if verbose {
271 println!("Rewound the clock: {path}");
272 }
273 return Ok(true);
274 }
275 Ok(false)
276}
277
278fn process_touchables(repo: &Repository, touchables: FileSet, opts: &Options) -> Result<FileSet> {
279 let touched = Arc::new(RwLock::new(FileSet::new()));
280 let mut touchable_oids: HashMap<Oid, Utf8PathBuf> = HashMap::new();
281 let mut revwalk = repo.revwalk().context(LibGitSnafu)?;
282 revwalk.push_head().context(LibGitSnafu)?;
284 revwalk.simplify_first_parent().context(LibGitSnafu)?;
285 let commits: Vec<_> = revwalk
286 .map(|oid| oid.and_then(|oid| repo.find_commit(oid)).unwrap())
287 .collect();
288 let latest_tree = commits
289 .first()
290 .context(UnresolvedSnafu)?
291 .tree()
292 .context(LibGitSnafu)?;
293 touchables.iter().for_each(|path| {
294 let touchable_path: Utf8PathBuf = path.into();
295 let current_oid = latest_tree
296 .get_path(&touchable_path.clone().into_std_path_buf())
297 .and_then(|entry| {
298 if let Some(git2::ObjectType::Blob) = entry.kind() {
299 Ok(entry)
300 } else {
301 Err(git2::Error::new(
302 git2::ErrorCode::NotFound,
303 git2::ErrorClass::Tree,
304 "no blob",
305 ))
306 }
307 })
308 .unwrap()
309 .id();
310 touchable_oids.insert(current_oid, touchable_path);
311 });
312 commits.iter().try_for_each(|commit| {
313 let old_tree = commit.parent(0).and_then(|p| p.tree()).ok();
314 let new_tree = commit.tree().ok();
315 let mut diff = repo
316 .diff_tree_to_tree(old_tree.as_ref(), new_tree.as_ref(), None)
317 .unwrap();
318 diff.find_similar(Some(git2::DiffFindOptions::new().renames(true)))
319 .unwrap();
320 touchable_oids.retain(|oid, touchable_path| {
321 let affected = diff_affects_oid(&diff, oid, touchable_path);
322 if affected {
323 let time = commit.time().seconds();
324 if let Ok(true) = touch_if_time_mismatch(
325 touchable_path.to_path_buf(),
326 time,
327 opts.verbose,
328 opts.ignore_older,
329 ) {
330 touched
331 .write()
332 .unwrap()
333 .insert(touchable_path.to_path_buf());
334 }
335 }
336 !affected
337 });
338 if !touchable_oids.is_empty() {
339 Some(())
340 } else {
341 None
342 }
343 });
344 let touched: RwLock<FileSet> = Arc::into_inner(touched).unwrap();
345 let touched: FileSet = RwLock::into_inner(touched).unwrap();
346 Ok(touched)
347}