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