ast_doc_core/ingestion/
git.rs1use std::path::{Path, PathBuf};
7
8use tracing::{debug, info, warn};
9
10use crate::{error::AstDocError, ingestion::GitContext};
11
12pub trait GitContextProvider {
14 fn get_branch(&self) -> Result<String, AstDocError>;
20
21 fn get_latest_commit(&self) -> Result<String, AstDocError>;
27
28 fn get_diff(&self) -> Result<Option<String>, AstDocError>;
34
35 fn extract(&self) -> Result<GitContext, AstDocError> {
41 Ok(GitContext {
42 branch: self.get_branch()?,
43 latest_commit: self.get_latest_commit()?,
44 diff: self.get_diff()?,
45 })
46 }
47}
48
49#[derive(Debug)]
51pub struct Git2Context {
52 repo_path: PathBuf,
53}
54
55impl Git2Context {
56 pub fn new(repo_path: &Path) -> Result<Self, AstDocError> {
62 let _repo = git2::Repository::discover(repo_path)?;
64 Ok(Self { repo_path: repo_path.to_path_buf() })
65 }
66}
67
68impl GitContextProvider for Git2Context {
69 fn get_branch(&self) -> Result<String, AstDocError> {
70 let repo = git2::Repository::open(&self.repo_path)?;
71 let head = repo.head()?;
72
73 if let Some(name) = head.shorthand() {
74 debug!(branch = name, "detected branch");
75 Ok(name.to_string())
76 } else {
77 Ok("HEAD (detached)".to_string())
78 }
79 }
80
81 fn get_latest_commit(&self) -> Result<String, AstDocError> {
82 let repo = git2::Repository::open(&self.repo_path)?;
83 let head = repo.head()?;
84 let commit = head.peel_to_commit()?;
85
86 let short_id = commit.as_object().short_id()?.as_str().unwrap_or("???????").to_string();
87
88 let summary = commit.summary().unwrap_or("(no message)").to_string();
89
90 let result = format!("{short_id} {summary}");
91 debug!(commit = %result, "latest commit");
92 Ok(result)
93 }
94
95 fn get_diff(&self) -> Result<Option<String>, AstDocError> {
96 let repo = git2::Repository::open(&self.repo_path)?;
97
98 let head = repo.head()?;
99 let head_tree = head.peel_to_tree()?;
100
101 let mut diff_opts = git2::DiffOptions::new();
102 let diff = repo.diff_tree_to_workdir_with_index(
103 Some(&head_tree),
104 Some(diff_opts.include_untracked(true)),
105 )?;
106
107 if diff.stats()?.files_changed() == 0 {
108 info!("no uncommitted changes");
109 return Ok(None);
110 }
111
112 let mut diff_text = Vec::new();
113 diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
114 diff_text.extend_from_slice(line.content());
115 true
116 })?;
117
118 let diff_str = String::from_utf8_lossy(&diff_text).to_string();
119 if diff_str.is_empty() {
120 return Ok(None);
121 }
122
123 const MAX_DIFF_SIZE: usize = 50_000;
125 let diff_str = if diff_str.len() > MAX_DIFF_SIZE {
126 warn!(size = diff_str.len(), limit = MAX_DIFF_SIZE, "truncating large diff");
127 format!("{}...[truncated]", &diff_str[..MAX_DIFF_SIZE])
128 } else {
129 diff_str
130 };
131
132 debug!(len = diff_str.len(), "captured diff");
133 Ok(Some(diff_str))
134 }
135}
136
137#[cfg(test)]
139#[derive(Debug, Clone)]
140pub struct MockGitContext {
141 branch: String,
142 commit: String,
143 diff: Option<String>,
144}
145
146#[cfg(test)]
147impl MockGitContext {
148 pub fn new(branch: &str, commit: &str, diff: Option<&str>) -> Self {
150 Self {
151 branch: branch.to_string(),
152 commit: commit.to_string(),
153 diff: diff.map(String::from),
154 }
155 }
156}
157
158#[cfg(test)]
159impl GitContextProvider for MockGitContext {
160 fn get_branch(&self) -> Result<String, AstDocError> {
161 Ok(self.branch.clone())
162 }
163
164 fn get_latest_commit(&self) -> Result<String, AstDocError> {
165 Ok(self.commit.clone())
166 }
167
168 fn get_diff(&self) -> Result<Option<String>, AstDocError> {
169 Ok(self.diff.clone())
170 }
171}
172
173pub fn extract_git_context(repo_path: &Path) -> Result<Option<GitContext>, AstDocError> {
177 match git2::Repository::discover(repo_path) {
178 Ok(_) => {
179 let provider = Git2Context::new(repo_path)?;
180 Ok(Some(provider.extract()?))
181 }
182 Err(err) => {
183 debug!(path = %repo_path.display(), error = %err, "not a git repo");
184 Ok(None)
185 }
186 }
187}
188
189#[cfg(test)]
190#[expect(clippy::unwrap_used)]
191mod tests {
192 use tempfile::TempDir;
193
194 use super::*;
195
196 #[test]
197 fn test_mock_git_context() {
198 let mock = MockGitContext::new(
199 "main",
200 "abc1234 feat: add feature",
201 Some("diff --git a/file.rs b/file.rs\n+added line"),
202 );
203
204 assert_eq!(mock.get_branch().unwrap(), "main");
205 assert_eq!(mock.get_latest_commit().unwrap(), "abc1234 feat: add feature");
206 assert!(mock.get_diff().unwrap().is_some());
207
208 let ctx = mock.extract().unwrap();
209 assert_eq!(ctx.branch, "main");
210 assert_eq!(ctx.latest_commit, "abc1234 feat: add feature");
211 }
212
213 #[test]
214 fn test_mock_git_context_clean() {
215 let mock = MockGitContext::new("develop", "def5678 fix: bug fix", None);
216
217 assert_eq!(mock.get_branch().unwrap(), "develop");
218 assert!(mock.get_diff().unwrap().is_none());
219
220 let ctx = mock.extract().unwrap();
221 assert!(ctx.diff.is_none());
222 }
223
224 #[test]
225 fn test_extract_git_context_non_repo() {
226 let dir = TempDir::new().unwrap();
227 let result = extract_git_context(dir.path()).unwrap();
228 assert!(result.is_none());
229 }
230
231 #[test]
232 fn test_extract_git_context_in_repo() {
233 let dir = TempDir::new().unwrap();
235 let repo = git2::Repository::init(dir.path()).unwrap();
236
237 let mut cfg = repo.config().unwrap();
239 cfg.set_str("user.name", "Test").unwrap();
240 cfg.set_str("user.email", "test@test.com").unwrap();
241
242 let sig = repo.signature().unwrap();
244 let tree_id = {
245 let mut index = repo.index().unwrap();
246 index.write_tree().unwrap()
247 };
248 let tree = repo.find_tree(tree_id).unwrap();
249 repo.commit(Some("HEAD"), &sig, &sig, "initial commit", &tree, &[]).unwrap();
250
251 let result = extract_git_context(dir.path()).unwrap();
252 assert!(result.is_some());
253 let ctx = result.unwrap();
254 assert!(!ctx.branch.is_empty());
255 assert!(ctx.latest_commit.contains("initial commit"));
256 }
257
258 #[test]
259 fn test_git2_context_nonexistent_repo() {
260 let dir = TempDir::new().unwrap();
261 let nested = dir.path().join("not-a-repo");
262 std::fs::create_dir_all(&nested).unwrap();
263 let result = Git2Context::new(&nested);
264 assert!(result.is_err());
265 }
266
267 #[test]
268 fn test_git2_context_branch_detached() {
269 let dir = TempDir::new().unwrap();
270 let _repo = git2::Repository::init(dir.path()).unwrap();
271
272 let ctx = Git2Context::new(dir.path()).unwrap();
274 let _ = ctx.get_branch();
276 }
277
278 #[test]
279 fn test_git2_context_with_diff() {
280 let dir = TempDir::new().unwrap();
281 let repo = git2::Repository::init(dir.path()).unwrap();
282
283 let mut cfg = repo.config().unwrap();
285 cfg.set_str("user.name", "Test").unwrap();
286 cfg.set_str("user.email", "test@test.com").unwrap();
287
288 let file_path = dir.path().join("test.rs");
290 std::fs::write(&file_path, "fn main() {}\n").unwrap();
291
292 let mut index = repo.index().unwrap();
293 index.add_path(std::path::Path::new("test.rs")).unwrap();
294 index.write().unwrap();
295
296 let tree_id = index.write_tree().unwrap();
297 let tree = repo.find_tree(tree_id).unwrap();
298 let sig = repo.signature().unwrap();
299 repo.commit(Some("HEAD"), &sig, &sig, "add test.rs", &tree, &[]).unwrap();
300
301 std::fs::write(&file_path, "fn main() {\n println!(\"hello\");\n}\n").unwrap();
303
304 let ctx = Git2Context::new(dir.path()).unwrap();
305 let diff = ctx.get_diff().unwrap();
306 assert!(diff.is_some());
307 assert!(diff.unwrap().contains("hello"));
308 }
309}