rs_web/
git.rs

1//! Git integration for commit info and file history
2
3use git2::Repository;
4use std::collections::HashMap;
5use std::path::Path;
6use std::sync::OnceLock;
7use tera::{Function, Value};
8
9pub struct GitInfo {
10    pub hash: Option<String>,
11    pub short_hash: Option<String>,
12    pub branch: Option<String>,
13    pub commit_timestamp: Option<i64>,
14    pub commit_message: Option<String>,
15    pub is_dirty: bool,
16}
17
18static GIT_INFO: OnceLock<GitInfo> = OnceLock::new();
19
20pub fn 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_timestamp: 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_timestamp = 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_timestamp,
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_timestamp() -> impl Function {
100    |_: &HashMap<String, Value>| -> tera::Result<Value> {
101        Ok(get_git_info()
102            .commit_timestamp
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_timestamp", make_git_commit_timestamp());
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/// Git info for a specific file
132#[derive(Debug, Clone, Default)]
133pub struct FileGitInfo {
134    pub hash: Option<String>,
135    pub short_hash: Option<String>,
136    pub commit_timestamp: Option<i64>,
137    pub author: Option<String>,
138    pub is_dirty: bool,
139}
140
141/// Get git info for a specific file or directory (last commit that modified it)
142/// For directories, finds the most recent commit that modified any file within
143pub 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    // Get the relative path from repo root
150    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    // Check if file/directory has uncommitted changes
163    let is_dirty = if is_directory {
164        // For directories, check if any file within has changes
165        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    // Use git log to find the last commit that modified this file/directory
181    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        // Check if this commit modified our file/directory
197        let tree = match commit.tree() {
198            Ok(t) => t,
199            Err(_) => continue,
200        };
201
202        // Get parent tree (if exists)
203        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            // For directories, check if any file within the directory was changed
212            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            // For files, exact match
226            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_timestamp = 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_timestamp: Some(commit_timestamp),
250                author,
251                is_dirty,
252            };
253        }
254    }
255
256    FileGitInfo {
257        is_dirty,
258        ..Default::default()
259    }
260}