1use git2::Repository;
2use std::collections::HashMap;
3use std::path::Path;
4use std::sync::OnceLock;
5use tera::{Function, Value};
6
7struct GitInfo {
8 hash: Option<String>,
9 short_hash: Option<String>,
10 branch: Option<String>,
11 commit_date: Option<i64>,
12 commit_message: Option<String>,
13 is_dirty: bool,
14}
15
16static GIT_INFO: OnceLock<GitInfo> = OnceLock::new();
17
18fn get_git_info() -> &'static GitInfo {
19 GIT_INFO.get_or_init(|| {
20 let repo = match Repository::discover(".") {
21 Ok(r) => r,
22 Err(_) => {
23 return GitInfo {
24 hash: None,
25 short_hash: None,
26 branch: None,
27 commit_date: None,
28 commit_message: None,
29 is_dirty: false,
30 };
31 }
32 };
33
34 let head = repo.head().ok();
35 let oid = head.as_ref().and_then(|h| h.target());
36 let commit = oid.and_then(|o| repo.find_commit(o).ok());
37
38 let hash = oid.map(|o| o.to_string());
39 let short_hash = hash.as_ref().map(|h| h[..7].to_string());
40
41 let branch = head.as_ref().and_then(|h| {
42 if h.is_branch() {
43 h.shorthand().map(|s| s.to_string())
44 } else {
45 None
46 }
47 });
48
49 let commit_date = commit.as_ref().map(|c| c.time().seconds());
50 let commit_message = commit
51 .as_ref()
52 .and_then(|c| c.message().map(|m| m.trim().to_string()));
53
54 let is_dirty = repo.statuses(None).map(|s| !s.is_empty()).unwrap_or(false);
55
56 GitInfo {
57 hash,
58 short_hash,
59 branch,
60 commit_date,
61 commit_message,
62 is_dirty,
63 }
64 })
65}
66
67pub fn make_git_hash() -> impl Function {
68 |_: &HashMap<String, Value>| -> tera::Result<Value> {
69 Ok(get_git_info()
70 .hash
71 .clone()
72 .map(Value::String)
73 .unwrap_or(Value::Null))
74 }
75}
76
77pub fn make_git_short_hash() -> impl Function {
78 |_: &HashMap<String, Value>| -> tera::Result<Value> {
79 Ok(get_git_info()
80 .short_hash
81 .clone()
82 .map(Value::String)
83 .unwrap_or(Value::Null))
84 }
85}
86
87pub fn make_git_branch() -> impl Function {
88 |_: &HashMap<String, Value>| -> tera::Result<Value> {
89 Ok(get_git_info()
90 .branch
91 .clone()
92 .map(Value::String)
93 .unwrap_or(Value::Null))
94 }
95}
96
97pub fn make_git_commit_date() -> impl Function {
98 |_: &HashMap<String, Value>| -> tera::Result<Value> {
99 Ok(get_git_info()
100 .commit_date
101 .map(|ts| Value::Number(ts.into()))
102 .unwrap_or(Value::Null))
103 }
104}
105
106pub fn make_git_commit_message() -> impl Function {
107 |_: &HashMap<String, Value>| -> tera::Result<Value> {
108 Ok(get_git_info()
109 .commit_message
110 .clone()
111 .map(Value::String)
112 .unwrap_or(Value::Null))
113 }
114}
115
116pub fn make_git_is_dirty() -> impl Function {
117 |_: &HashMap<String, Value>| -> tera::Result<Value> { Ok(Value::Bool(get_git_info().is_dirty)) }
118}
119
120pub fn register_git_functions(tera: &mut tera::Tera) {
121 tera.register_function("git_hash", make_git_hash());
122 tera.register_function("git_short_hash", make_git_short_hash());
123 tera.register_function("git_branch", make_git_branch());
124 tera.register_function("git_commit_date", make_git_commit_date());
125 tera.register_function("git_commit_message", make_git_commit_message());
126 tera.register_function("git_is_dirty", make_git_is_dirty());
127}
128
129#[derive(Debug, Clone, Default)]
131pub struct FileGitInfo {
132 pub hash: Option<String>,
133 pub short_hash: Option<String>,
134 pub commit_date: Option<i64>,
135 pub author: Option<String>,
136 pub is_dirty: bool,
137}
138
139pub fn get_file_git_info(path: &Path) -> FileGitInfo {
142 let repo = match Repository::discover(".") {
143 Ok(r) => r,
144 Err(_) => return FileGitInfo::default(),
145 };
146
147 let workdir = match repo.workdir() {
149 Some(w) => w,
150 None => return FileGitInfo::default(),
151 };
152
153 let relative_path = match path.strip_prefix(workdir) {
154 Ok(p) => p,
155 Err(_) => path,
156 };
157
158 let is_directory = path.is_dir();
159
160 let is_dirty = if is_directory {
162 repo.statuses(None)
164 .map(|statuses| {
165 statuses.iter().any(|s| {
166 s.path()
167 .map(|p| Path::new(p).starts_with(relative_path))
168 .unwrap_or(false)
169 })
170 })
171 .unwrap_or(false)
172 } else {
173 repo.status_file(relative_path)
174 .map(|s| !s.is_empty())
175 .unwrap_or(false)
176 };
177
178 let mut revwalk = match repo.revwalk() {
180 Ok(r) => r,
181 Err(_) => return FileGitInfo::default(),
182 };
183
184 if revwalk.push_head().is_err() {
185 return FileGitInfo::default();
186 }
187
188 for oid in revwalk.flatten() {
189 let commit = match repo.find_commit(oid) {
190 Ok(c) => c,
191 Err(_) => continue,
192 };
193
194 let tree = match commit.tree() {
196 Ok(t) => t,
197 Err(_) => continue,
198 };
199
200 let parent_tree = commit.parent(0).ok().and_then(|p| p.tree().ok());
202
203 let diff = match repo.diff_tree_to_tree(parent_tree.as_ref(), Some(&tree), None) {
204 Ok(d) => d,
205 Err(_) => continue,
206 };
207
208 let path_changed = if is_directory {
209 diff.deltas().any(|delta| {
211 delta
212 .new_file()
213 .path()
214 .map(|p| p.starts_with(relative_path))
215 .unwrap_or(false)
216 || delta
217 .old_file()
218 .path()
219 .map(|p| p.starts_with(relative_path))
220 .unwrap_or(false)
221 })
222 } else {
223 diff.deltas().any(|delta| {
225 delta
226 .new_file()
227 .path()
228 .map(|p| p == relative_path)
229 .unwrap_or(false)
230 || delta
231 .old_file()
232 .path()
233 .map(|p| p == relative_path)
234 .unwrap_or(false)
235 })
236 };
237
238 if path_changed {
239 let hash = oid.to_string();
240 let short_hash = hash[..7].to_string();
241 let commit_date = commit.time().seconds();
242 let author = commit.author().name().map(|s| s.to_string());
243
244 return FileGitInfo {
245 hash: Some(hash),
246 short_hash: Some(short_hash),
247 commit_date: Some(commit_date),
248 author,
249 is_dirty,
250 };
251 }
252 }
253
254 FileGitInfo {
255 is_dirty,
256 ..Default::default()
257 }
258}