Skip to main content

fff_search/
git.rs

1use crate::error::Result;
2use git2::{Repository, Status, StatusOptions};
3use std::{
4    fmt::Debug,
5    path::{Path, PathBuf},
6};
7use tracing::debug;
8
9/// Represents a cache of a single git status query, if there is no
10/// status aka file is clear but it was specifically requested to updated
11/// the status is `None` otherwise contains only actual file statuses.
12#[derive(Debug, Clone)]
13pub struct GitStatusCache(Vec<(PathBuf, Status)>);
14
15impl IntoIterator for GitStatusCache {
16    type Item = (PathBuf, Status);
17    type IntoIter = std::vec::IntoIter<Self::Item>;
18
19    fn into_iter(self) -> Self::IntoIter {
20        self.0.into_iter()
21    }
22}
23
24impl GitStatusCache {
25    pub fn statuses_len(&self) -> usize {
26        self.0.len()
27    }
28
29    pub fn lookup_status(&self, full_path: &Path) -> Option<Status> {
30        self.0
31            .binary_search_by(|(path, _)| path.as_path().cmp(full_path))
32            .ok()
33            .and_then(|idx| self.0.get(idx).map(|(_, status)| *status))
34    }
35
36    #[tracing::instrument(skip(repo, status_options))]
37    fn read_status_impl(repo: &Repository, status_options: &mut StatusOptions) -> Result<Self> {
38        let statuses = repo.statuses(Some(status_options))?;
39        let Some(repo_path) = repo.workdir() else {
40            return Ok(Self(vec![])); // repo is bare
41        };
42
43        let mut entries = Vec::with_capacity(statuses.len());
44        for entry in &statuses {
45            if let Some(entry_path) = entry.path() {
46                let full_path = repo_path.join(entry_path);
47                entries.push((full_path, entry.status()));
48            }
49        }
50
51        Ok(Self(entries))
52    }
53
54    pub fn read_git_status(
55        git_workdir: Option<&Path>,
56        status_options: &mut StatusOptions,
57    ) -> Option<Self> {
58        let git_workdir = git_workdir.as_ref()?;
59        let repository = Repository::open(git_workdir).ok()?;
60
61        let status = Self::read_status_impl(&repository, status_options);
62
63        match status {
64            Ok(status) => Some(status),
65            Err(e) => {
66                tracing::error!(?e, "Failed to read git status");
67
68                None
69            }
70        }
71    }
72
73    #[tracing::instrument(skip(repo), level = tracing::Level::DEBUG)]
74    pub fn git_status_for_paths<TPath: AsRef<Path> + Debug>(
75        repo: &Repository,
76        paths: &[TPath],
77    ) -> Result<Self> {
78        if paths.is_empty() {
79            return Ok(Self(vec![]));
80        }
81
82        let Some(workdir) = repo.workdir() else {
83            return Ok(Self(vec![]));
84        };
85
86        // git pathspec is pretty slow and requires to walk the whole directory
87        // so for a single file which is the most general use case we query directly the file
88        if paths.len() == 1 {
89            let full_path = paths[0].as_ref();
90            let relative_path = full_path.strip_prefix(workdir)?;
91            let status = repo.status_file(relative_path)?;
92
93            return Ok(Self(vec![(full_path.to_path_buf(), status)]));
94        }
95
96        let mut status_options = StatusOptions::new();
97        status_options
98            .include_untracked(true)
99            .recurse_untracked_dirs(true)
100            // when reading partial status it's important to include all files requested
101            .include_unmodified(true);
102
103        for path in paths {
104            status_options.pathspec(path.as_ref().strip_prefix(workdir)?);
105        }
106
107        let git_status_cache = Self::read_status_impl(repo, &mut status_options)?;
108        debug!(
109            status_len = git_status_cache.statuses_len(),
110            "Multiple files git status"
111        );
112
113        Ok(git_status_cache)
114    }
115}
116
117#[inline]
118pub fn is_modified_status(status: Status) -> bool {
119    status.intersects(
120        Status::WT_MODIFIED
121            | Status::INDEX_MODIFIED
122            | Status::WT_NEW
123            | Status::INDEX_NEW
124            | Status::WT_RENAMED,
125    )
126}
127
128pub fn format_git_status_opt(status: Option<Status>) -> Option<&'static str> {
129    match status {
130        None => Some("clean"),
131        Some(status) => {
132            if status.contains(Status::WT_NEW) {
133                Some("untracked")
134            } else if status.contains(Status::WT_MODIFIED) {
135                Some("modified")
136            } else if status.contains(Status::WT_DELETED) {
137                Some("deleted")
138            } else if status.contains(Status::WT_RENAMED) {
139                Some("renamed")
140            } else if status.contains(Status::INDEX_NEW) {
141                Some("staged_new")
142            } else if status.contains(Status::INDEX_MODIFIED) {
143                Some("staged_modified")
144            } else if status.contains(Status::INDEX_DELETED) {
145                Some("staged_deleted")
146            } else if status.contains(Status::IGNORED) {
147                Some("ignored")
148            } else if status.contains(Status::CURRENT) || status.is_empty() {
149                Some("clean")
150            } else {
151                None
152            }
153        }
154    }
155}
156
157pub fn format_git_status(status: Option<Status>) -> &'static str {
158    format_git_status_opt(status).unwrap_or("unknown")
159}