Skip to main content

ast_doc_core/ingestion/
git.rs

1//! Git context extraction.
2//!
3//! Provides `GitContextProvider` trait and `Git2Context` implementation
4//! for extracting branch, commit, and diff information from a git repository.
5
6use std::path::{Path, PathBuf};
7
8use tracing::{debug, info, warn};
9
10use crate::{error::AstDocError, ingestion::GitContext};
11
12/// Trait for extracting git context, enabling testability.
13pub trait GitContextProvider {
14    /// Get the current branch name.
15    ///
16    /// # Errors
17    ///
18    /// Returns an error if the branch cannot be determined.
19    fn get_branch(&self) -> Result<String, AstDocError>;
20
21    /// Get the latest commit summary (short hash + subject).
22    ///
23    /// # Errors
24    ///
25    /// Returns an error if the commit cannot be read.
26    fn get_latest_commit(&self) -> Result<String, AstDocError>;
27
28    /// Get the uncommitted diff as a string, or `None` if clean.
29    ///
30    /// # Errors
31    ///
32    /// Returns an error if the diff cannot be computed.
33    fn get_diff(&self) -> Result<Option<String>, AstDocError>;
34
35    /// Extract all git context into a `GitContext` struct.
36    ///
37    /// # Errors
38    ///
39    /// Returns an error if any git operation fails.
40    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/// Git context provider backed by `git2`.
50#[derive(Debug)]
51pub struct Git2Context {
52    repo_path: PathBuf,
53}
54
55impl Git2Context {
56    /// Open a git repository at the given path.
57    ///
58    /// # Errors
59    ///
60    /// Returns an error if the path is not a git repository.
61    pub fn new(repo_path: &Path) -> Result<Self, AstDocError> {
62        // Validate that the path is a git repo
63        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        // Truncate large diffs to avoid bloating output
124        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/// A mock `GitContextProvider` for testing.
138#[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    /// Create a new mock git context.
149    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
173/// Helper to extract git context from a repository path.
174///
175/// Returns `Ok(None)` if the path is not a git repository.
176pub 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        // Init a temporary git repo for testing
234        let dir = TempDir::new().unwrap();
235        let repo = git2::Repository::init(dir.path()).unwrap();
236
237        // Configure git user for commit
238        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        // Create an initial commit
243        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        // Without any commits, HEAD is unborn; get_branch should handle it
273        let ctx = Git2Context::new(dir.path()).unwrap();
274        // This may return an error for a repo with no commits
275        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        // Configure git user
284        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        // Create a file and make an initial commit
289        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        // Modify the file to create a diff
302        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}