Skip to main content

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}