gcop_rs/git/mod.rs
1//! Git abstractions and repository operations.
2//!
3//! Provides the `GitOperations` trait, common data types, and helpers used by
4//! command flows.
5
6/// Commit writing helpers.
7pub mod commit;
8/// Diff parsing and per-file statistics helpers.
9pub mod diff;
10/// `git2`-backed repository implementation of [`GitOperations`].
11pub mod repository;
12
13use std::path::PathBuf;
14
15use crate::error::Result;
16use chrono::{DateTime, Local};
17use serde::Serialize;
18
19#[cfg(any(test, feature = "test-utils"))]
20use mockall::automock;
21
22/// Git commit metadata.
23///
24/// Contains commit hash, parent information, author details, timestamp, and message summary.
25///
26/// # Fields
27/// - `hash`: commit SHA hex string
28/// - `parent_count`: number of parent commits (>1 means merge commit)
29/// - `author_name`: author name
30/// - `author_email`: author email address
31/// - `timestamp`: commit timestamp (local timezone)
32/// - `message`: first line of commit message
33#[derive(Debug, Clone)]
34pub struct CommitInfo {
35 /// Commit SHA hex string.
36 pub hash: String,
37 /// Number of parent commits (>1 means merge commit).
38 pub parent_count: usize,
39 /// Commit author name.
40 pub author_name: String,
41 /// Commit author email.
42 pub author_email: String,
43 /// Commit timestamp in local timezone.
44 pub timestamp: DateTime<Local>,
45 /// First line of the commit message.
46 #[allow(dead_code)]
47 // Reserved for future commit-message analytics.
48 pub message: String,
49}
50
51/// Unified interface for Git operations.
52///
53/// This trait abstracts all Git repository operations, making it easier to test and extend.
54/// Main implementation: [`GitRepository`](repository::GitRepository).
55///
56/// # Design
57/// - Pure Rust interface, independent of concrete backend implementation.
58/// - Supports mocking in tests (via `mockall`).
59/// - Uses unified error handling via [`GcopError`](crate::error::GcopError).
60///
61/// # Example
62/// ```no_run
63/// use gcop_rs::git::{GitOperations, repository::GitRepository};
64///
65/// # fn main() -> anyhow::Result<()> {
66/// let repo = GitRepository::open(None)?;
67/// let diff = repo.get_staged_diff()?;
68/// println!("Staged changes:\n{}", diff);
69/// # Ok(())
70/// # }
71/// ```
72#[cfg_attr(any(test, feature = "test-utils"), automock)]
73pub trait GitOperations {
74 /// Returns the diff for staged changes.
75 ///
76 /// Equivalent to `git diff --cached --unified=3`.
77 ///
78 /// # Returns
79 /// - `Ok(diff)` - diff text (possibly empty)
80 /// - `Err(_)` - git operation failed
81 ///
82 /// # Errors
83 /// - Repository is not initialized
84 /// - Insufficient permissions
85 fn get_staged_diff(&self) -> Result<String>;
86
87 /// Returns the diff for unstaged changes.
88 ///
89 /// Contains only `index -> workdir` changes (unstaged),
90 /// equivalent to `git diff` (without `--cached`).
91 ///
92 /// # Returns
93 /// - `Ok(diff)` - diff text (possibly empty)
94 /// - `Err(_)` - git operation failed
95 fn get_uncommitted_diff(&self) -> Result<String>;
96
97 /// Returns the diff for a specific commit.
98 ///
99 /// Equivalent to `git diff <commit_hash>^!` (returns only the diff content).
100 ///
101 /// # Parameters
102 /// - `commit_hash`: commit SHA (supports short hash)
103 ///
104 /// # Returns
105 /// - `Ok(diff)` - diff text
106 /// - `Err(_)` - commit does not exist or git operation failed
107 fn get_commit_diff(&self, commit_hash: &str) -> Result<String>;
108
109 /// Returns the diff for a commit range.
110 ///
111 /// Supports multiple formats:
112 /// - `HEAD~3..HEAD` - last 3 commits
113 /// - `main..feature` - difference between branches
114 /// - `abc123..def456` - difference between two commits
115 ///
116 /// # Parameters
117 /// - `range`: Git range expression
118 ///
119 /// # Returns
120 /// - `Ok(diff)` - diff text
121 /// - `Err(_)` - invalid range or git operation failed
122 fn get_range_diff(&self, range: &str) -> Result<String>;
123
124 /// Reads the complete content of a file.
125 ///
126 /// Reads file contents from the working tree (not from git objects).
127 ///
128 /// # Parameters
129 /// - `path`: file path (relative to the current working directory or absolute path)
130 ///
131 /// # Returns
132 /// - `Ok(content)` - file contents
133 /// - `Err(_)` - file does not exist, is not a regular file, or read failed
134 fn get_file_content(&self, path: &str) -> Result<String>;
135
136 /// Executes `git commit`.
137 ///
138 /// Commits staged changes to the repository.
139 ///
140 /// # Parameters
141 /// - `message`: commit message (supports multiple lines)
142 ///
143 /// # Returns
144 /// - `Ok(())` - commit succeeded
145 /// - `Err(_)` - no staged changes, hook failure, or another git error
146 ///
147 /// # Errors
148 /// - [`GcopError::GitCommand`] - no staged changes
149 /// - [`GcopError::Git`] - libgit2 error
150 ///
151 /// # Notes
152 /// - Triggers pre-commit and commit-msg hooks.
153 /// - Uses name/email configured in git config.
154 ///
155 /// [`GcopError::GitCommand`]: crate::error::GcopError::GitCommand
156 /// [`GcopError::Git`]: crate::error::GcopError::Git
157 fn commit(&self, message: &str) -> Result<()>;
158
159 /// Executes `git commit --amend`.
160 ///
161 /// Amends the most recent commit with a new message.
162 /// If there are staged changes, they are included in the amended commit.
163 ///
164 /// # Parameters
165 /// - `message`: new commit message
166 ///
167 /// # Returns
168 /// - `Ok(())` - amend succeeded
169 /// - `Err(_)` - no commits to amend, hook failure, or another git error
170 fn commit_amend(&self, message: &str) -> Result<()>;
171
172 /// Returns the current branch name.
173 ///
174 /// # Returns
175 /// - `Ok(Some(name))` - current branch name (for example `"main"`)
176 /// - `Ok(None)` - detached HEAD
177 /// - `Err(_)` - git operation failed
178 ///
179 /// # Example
180 /// ```no_run
181 /// # use gcop_rs::git::{GitOperations, repository::GitRepository};
182 /// # fn main() -> anyhow::Result<()> {
183 /// let repo = GitRepository::open(None)?;
184 /// if let Some(branch) = repo.get_current_branch()? {
185 /// println!("On branch: {}", branch);
186 /// } else {
187 /// println!("Detached HEAD");
188 /// }
189 /// # Ok(())
190 /// # }
191 /// ```
192 fn get_current_branch(&self) -> Result<Option<String>>;
193
194 /// Calculates diff statistics.
195 ///
196 /// Parses diff text and extracts changed files plus insert/delete counts.
197 ///
198 /// # Parameters
199 /// - `diff`: diff text (from `get_*_diff()` methods)
200 ///
201 /// # Returns
202 /// - `Ok(stats)` - parsed statistics
203 /// - `Err(_)` - invalid diff format
204 ///
205 /// # Example
206 /// ```no_run
207 /// # use gcop_rs::git::{GitOperations, repository::GitRepository};
208 /// # fn main() -> anyhow::Result<()> {
209 /// let repo = GitRepository::open(None)?;
210 /// let diff = repo.get_staged_diff()?;
211 /// let stats = repo.get_diff_stats(&diff)?;
212 /// println!("{} files, +{} -{}",
213 /// stats.files_changed.len(), stats.insertions, stats.deletions);
214 /// # Ok(())
215 /// # }
216 /// ```
217 fn get_diff_stats(&self, diff: &str) -> Result<DiffStats>;
218
219 /// Checks whether the index contains staged changes.
220 ///
221 /// Fast check for files added to the index with `git add`.
222 ///
223 /// # Returns
224 /// - `Ok(true)` - staged changes exist
225 /// - `Ok(false)` - staging area is empty
226 /// - `Err(_)` - git operation failed
227 fn has_staged_changes(&self) -> Result<bool>;
228
229 /// Returns commit history for the current branch.
230 ///
231 /// Returns commit entries in reverse chronological order.
232 ///
233 /// # Returns
234 /// - `Ok(history)` - commit list (newest first)
235 /// - `Err(_)` - git operation failed
236 ///
237 /// # Notes
238 /// - Only includes history reachable from the current branch HEAD.
239 /// - Empty repositories return an empty list.
240 fn get_commit_history(&self) -> Result<Vec<CommitInfo>>;
241
242 /// Returns line-level diff statistics for a single commit.
243 ///
244 /// Diffs the commit tree against its first parent (or empty tree for root commits).
245 /// Uses git2's native `Diff::stats()` for performance.
246 ///
247 /// # Parameters
248 /// - `hash`: commit SHA hex string
249 ///
250 /// # Returns
251 /// - `Ok((insertions, deletions))` - line counts
252 /// - `Err(_)` - commit not found or git error
253 fn get_commit_line_stats(&self, hash: &str) -> Result<(usize, usize)>;
254
255 /// Checks whether the repository has no commits.
256 ///
257 /// # Returns
258 /// - `Ok(true)` - repository is empty (no commits yet)
259 /// - `Ok(false)` - repository has at least one commit
260 /// - `Err(_)` - git operation failed
261 fn is_empty(&self) -> Result<bool>;
262
263 /// Returns the list of currently staged file paths.
264 ///
265 /// Equivalent to collecting filenames from `git diff --cached --name-only`.
266 fn get_staged_files(&self) -> Result<Vec<String>>;
267
268 /// Unstages all currently staged files.
269 ///
270 /// Equivalent to `git reset HEAD`. For empty repositories (no commits),
271 /// uses `git rm --cached -r .` instead.
272 fn unstage_all(&self) -> Result<()>;
273
274 /// Stages the specified files.
275 ///
276 /// Equivalent to `git add <files>`.
277 fn stage_files(&self, files: &[String]) -> Result<()>;
278
279 /// Returns the repository working directory path.
280 ///
281 /// # Returns
282 /// - `Ok(path)` - absolute path to the repository working directory
283 /// - `Err(_)` - bare repository or git operation failed
284 fn get_workdir(&self) -> Result<PathBuf>;
285}
286
287/// Diff statistics.
288///
289/// Contains changed files and insert/delete counts.
290///
291/// # Fields
292/// - `files_changed`: changed file paths (relative to repository root)
293/// - `insertions`: number of inserted lines
294/// - `deletions`: number of deleted lines
295///
296/// # Example
297/// ```
298/// use gcop_rs::git::DiffStats;
299///
300/// let stats = DiffStats {
301/// files_changed: vec!["src/main.rs".to_string(), "README.md".to_string()],
302/// insertions: 42,
303/// deletions: 13,
304/// };
305/// assert_eq!(stats.files_changed.len(), 2);
306/// ```
307#[derive(Debug, Clone, Serialize)]
308pub struct DiffStats {
309 /// Paths of files changed in the diff.
310 pub files_changed: Vec<String>,
311 /// Number of inserted lines.
312 pub insertions: usize,
313 /// Number of deleted lines.
314 pub deletions: usize,
315}
316
317/// Finds the git repository root by walking upward from the current directory.
318///
319/// Equivalent to `git rev-parse --show-toplevel`.
320/// Checks whether `.git` (directory or file, for submodule/worktree compatibility)
321/// exists at each level.
322pub fn find_git_root() -> Option<PathBuf> {
323 let mut dir = std::env::current_dir().ok()?;
324 loop {
325 if dir.join(".git").exists() {
326 return Some(dir);
327 }
328 if !dir.pop() {
329 return None;
330 }
331 }
332}