rs_web/
git.rs

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/// Git info for a specific file
130#[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
139/// Get git info for a specific file or directory (last commit that modified it)
140/// For directories, finds the most recent commit that modified any file within
141pub 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    // Get the relative path from repo root
148    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    // Check if file/directory has uncommitted changes
161    let is_dirty = if is_directory {
162        // For directories, check if any file within has changes
163        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    // Use git log to find the last commit that modified this file/directory
179    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        // Check if this commit modified our file/directory
195        let tree = match commit.tree() {
196            Ok(t) => t,
197            Err(_) => continue,
198        };
199
200        // Get parent tree (if exists)
201        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            // For directories, check if any file within the directory was changed
210            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            // For files, exact match
224            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}