1use crate::error::Result;
2use git2::{Repository, Status, StatusOptions};
3use std::{
4 fmt::Debug,
5 path::{Path, PathBuf},
6};
7use tracing::debug;
8
9#[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![])); };
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 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 .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}