git_warp_time/
lib.rs

1// SPDX-FileCopyrightText: © 2021 Caleb Maclennan <caleb@alerque.com>
2// SPDX-License-Identifier: GPL-3.0-only
3
4#![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
44// Clap CLI errors are reported using the Debug trait, but Snafu sets up the Display trait.
45// So we delegate. c.f. https://github.com/shepmaster/snafu/issues/110
46impl 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/// Options passed to `reset_mtimes()`
57#[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
66/// foo
67impl Default for Options {
68    /// Return a new options strut with default values.
69    fn default() -> Self {
70        Self::new()
71    }
72}
73
74impl Options {
75    /// Return a set of default options.
76    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    /// Whether or not to touch locally modified files, default is false
87    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    /// Whether or not to touch ignored files, default is false
98    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    /// Whether or not to touch files older than history, default is true
109    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    /// Whether or not to print output when touching or skipping files, default is false
120    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    /// List of paths to operate on instead of scanning repository
131    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
142/// Iterate over either the explicit file list or the working directory files, filter out any that
143/// have local modifications, are ignored by Git, or are in submodules and reset the file metadata
144/// mtime to the commit date of the last commit that affected the file in question.
145pub 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
165/// Return a repository discovered from from the current working directory or $GIT_DIR settings.
166pub fn get_repo() -> Result<Repository> {
167    let repo = Repository::open_from_env().context(LibGitSnafu)?;
168    Ok(repo)
169}
170
171/// Convert a path relative to the current working directory to be relative to the repository root
172pub 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    // See https://github.com/arkark/git-hist/blob/main/src/app/git.rs
283    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}