rusty_commit/git.rs
1//! Git repository operations and utilities.
2//!
3//! This module provides functions for interacting with Git repositories,
4//! including staging files, getting diffs, and querying repository status.
5
6use anyhow::{Context, Result};
7use git2::{DiffOptions, Repository, StatusOptions};
8use std::process::Command;
9
10/// Ensures the current directory is within a Git repository.
11///
12/// # Errors
13///
14/// Returns an error if the current directory is not within a Git repository.
15///
16/// # Examples
17///
18/// ```no_run
19/// use rusty_commit::git;
20///
21/// git::assert_git_repo().expect("Not in a git repository");
22/// ```
23pub fn assert_git_repo() -> Result<()> {
24 Repository::open_from_env().context(
25 "Not in a git repository. Please run this command from within a git repository.",
26 )?;
27 Ok(())
28}
29
30/// Returns a list of files that are currently staged for commit.
31///
32/// # Errors
33///
34/// Returns an error if the repository cannot be accessed.
35///
36/// # Examples
37///
38/// ```no_run
39/// use rusty_commit::git;
40///
41/// let staged = git::get_staged_files().unwrap();
42/// for file in staged {
43/// println!("Staged: {}", file);
44/// }
45/// ```
46pub fn get_staged_files() -> Result<Vec<String>> {
47 let repo = Repository::open_from_env()?;
48 let mut status_opts = StatusOptions::new();
49 status_opts.include_untracked(false);
50
51 let statuses = repo.statuses(Some(&mut status_opts))?;
52
53 let mut staged_files = Vec::new();
54 for entry in statuses.iter() {
55 let status = entry.status();
56 if status.contains(git2::Status::INDEX_NEW)
57 || status.contains(git2::Status::INDEX_MODIFIED)
58 || status.contains(git2::Status::INDEX_DELETED)
59 || status.contains(git2::Status::INDEX_RENAMED)
60 || status.contains(git2::Status::INDEX_TYPECHANGE)
61 {
62 if let Some(path) = entry.path() {
63 staged_files.push(path.to_string());
64 }
65 }
66 }
67
68 Ok(staged_files)
69}
70
71/// Returns a list of all changed files (staged, modified, and untracked).
72///
73/// # Errors
74///
75/// Returns an error if the repository cannot be accessed.
76///
77/// # Examples
78///
79/// ```no_run
80/// use rusty_commit::git;
81///
82/// let changed = git::get_changed_files().unwrap();
83/// println!("Found {} changed files", changed.len());
84/// ```
85pub fn get_changed_files() -> Result<Vec<String>> {
86 let repo = Repository::open_from_env()?;
87 let mut status_opts = StatusOptions::new();
88 status_opts.include_untracked(true);
89
90 let statuses = repo.statuses(Some(&mut status_opts))?;
91
92 let mut changed_files = Vec::new();
93 for entry in statuses.iter() {
94 let status = entry.status();
95 // Include files that are modified in working tree or untracked, but not ignored
96 if !status.contains(git2::Status::IGNORED) && !status.is_empty() {
97 if let Some(path) = entry.path() {
98 changed_files.push(path.to_string());
99 }
100 }
101 }
102
103 Ok(changed_files)
104}
105
106/// Stages the specified files for commit.
107///
108/// # Arguments
109///
110/// * `files` - A slice of file paths to stage
111///
112/// # Errors
113///
114/// Returns an error if the git add command fails.
115///
116/// # Examples
117///
118/// ```no_run
119/// use rusty_commit::git;
120///
121/// let files = vec!["src/main.rs".to_string(), "Cargo.toml".to_string()];
122/// git::stage_files(&files).unwrap();
123/// ```
124pub fn stage_files(files: &[String]) -> Result<()> {
125 if files.is_empty() {
126 return Ok(());
127 }
128
129 let output = Command::new("git")
130 .arg("add")
131 .args(files)
132 .output()
133 .context("Failed to stage files")?;
134
135 if !output.status.success() {
136 let stderr = String::from_utf8_lossy(&output.stderr);
137 anyhow::bail!("Failed to stage files: {}", stderr);
138 }
139
140 Ok(())
141}
142
143/// Returns the diff of all staged changes.
144///
145/// This compares the staging area (index) with HEAD to show what will be committed.
146///
147/// # Errors
148///
149/// Returns an error if the diff cannot be generated.
150///
151/// # Examples
152///
153/// ```no_run
154/// use rusty_commit::git;
155///
156/// let diff = git::get_staged_diff().unwrap();
157/// println!("Staged changes:\n{}", diff);
158/// ```
159pub fn get_staged_diff() -> Result<String> {
160 let repo = Repository::open_from_env()?;
161
162 // Get HEAD tree
163 let head = repo.head()?;
164 let head_tree = head.peel_to_tree()?;
165
166 // Get index (staging area)
167 let mut index = repo.index()?;
168 let oid = index.write_tree()?;
169 let index_tree = repo.find_tree(oid)?;
170
171 // Create diff between HEAD and index
172 let mut diff_opts = DiffOptions::new();
173 let diff = repo.diff_tree_to_tree(Some(&head_tree), Some(&index_tree), Some(&mut diff_opts))?;
174
175 // Convert diff to string
176 let mut diff_text = String::new();
177 diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
178 // Use lossy conversion to preserve content even with invalid UTF-8
179 let content = String::from_utf8_lossy(line.content());
180 diff_text.push_str(&content);
181 true
182 })?;
183
184 Ok(diff_text)
185}
186
187/// Returns the absolute path to the repository root.
188///
189/// # Errors
190///
191/// Returns an error if not in a Git repository or if the path cannot be determined.
192///
193/// # Examples
194///
195/// ```no_run
196/// use rusty_commit::git;
197///
198/// let root = git::get_repo_root().unwrap();
199/// println!("Repository root: {}", root);
200/// ```
201pub fn get_repo_root() -> Result<String> {
202 let repo = Repository::open_from_env()?;
203 let workdir = repo
204 .workdir()
205 .context("Could not find repository working directory")?;
206 Ok(workdir.to_string_lossy().to_string())
207}
208
209/// Returns the current branch name.
210///
211/// # Errors
212///
213/// Returns an error if the repository has no HEAD or if the branch name cannot be determined.
214///
215/// # Examples
216///
217/// ```no_run
218/// use rusty_commit::git;
219///
220/// let branch = git::get_current_branch().unwrap();
221/// println!("Current branch: {}", branch);
222/// ```
223pub fn get_current_branch() -> Result<String> {
224 let repo = Repository::open_from_env()?;
225 let head = repo.head()?;
226 let branch_name = head
227 .shorthand()
228 .context("Could not get current branch name")?
229 .to_string();
230 Ok(branch_name)
231}
232
233/// Returns a list of commit hashes and messages between two branches.
234///
235/// The output format is `"<hash> - <message>"` for each commit, with the hash
236/// truncated to 7 characters.
237///
238/// # Arguments
239///
240/// * `base` - The base branch/commit (exclusive)
241/// * `head` - The head branch/commit (inclusive)
242///
243/// # Errors
244///
245/// Returns an error if the branches cannot be parsed or the repository cannot be accessed.
246///
247/// # Examples
248///
249/// ```no_run
250/// use rusty_commit::git;
251///
252/// let commits = git::get_commits_between("main", "feature-branch").unwrap();
253/// for commit in commits {
254/// println!("{}", commit);
255/// }
256/// ```
257pub fn get_commits_between(base: &str, head: &str) -> Result<Vec<String>> {
258 let repo = Repository::open_from_env()?;
259
260 let base_commit = repo.revparse_single(base)?;
261 let head_commit = repo.revparse_single(head)?;
262
263 let mut revwalk = repo.revwalk()?;
264 revwalk.push(head_commit.id())?;
265 revwalk.hide(base_commit.id())?;
266
267 let mut commits = Vec::new();
268 for oid in revwalk {
269 let oid = oid?;
270 if let Ok(commit) = repo.find_commit(oid) {
271 commits.push(format!(
272 "{} - {}",
273 commit.id().to_string().chars().take(7).collect::<String>(),
274 commit.message().unwrap_or("")
275 ));
276 }
277 }
278
279 Ok(commits)
280}
281
282/// Returns the diff between two branches or commits.
283///
284/// # Arguments
285///
286/// * `base` - The base branch/commit
287/// * `head` - The head branch/commit to compare against base
288///
289/// # Errors
290///
291/// Returns an error if the commits cannot be parsed or the diff cannot be generated.
292///
293/// # Examples
294///
295/// ```no_run
296/// use rusty_commit::git;
297///
298/// let diff = git::get_diff_between("main", "HEAD").unwrap();
299/// println!("{}", diff);
300/// ```
301pub fn get_diff_between(base: &str, head: &str) -> Result<String> {
302 let repo = Repository::open_from_env()?;
303
304 let base_commit = repo.revparse_single(base)?;
305 let head_commit = repo.revparse_single(head)?;
306
307 let base_tree = base_commit
308 .as_tree()
309 .ok_or(anyhow::anyhow!("Failed to get base commit tree"))?;
310 let head_tree = head_commit
311 .as_tree()
312 .ok_or(anyhow::anyhow!("Failed to get head commit tree"))?;
313
314 let mut diff_opts = DiffOptions::new();
315 let diff = repo.diff_tree_to_tree(Some(base_tree), Some(head_tree), Some(&mut diff_opts))?;
316
317 let mut diff_text = String::new();
318 diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
319 // Use lossy conversion to preserve content even with invalid UTF-8
320 let content = String::from_utf8_lossy(line.content());
321 diff_text.push_str(&content);
322 true
323 })?;
324
325 Ok(diff_text)
326}
327
328/// Returns the remote URL for the origin remote.
329///
330/// # Errors
331///
332/// Returns an error if no remote named "origin" exists or if the URL cannot be retrieved.
333///
334/// # Examples
335///
336/// ```no_run
337/// use rusty_commit::git;
338///
339/// let url = git::get_remote_url().unwrap();
340/// println!("Remote URL: {}", url);
341/// ```
342pub fn get_remote_url() -> Result<String> {
343 let repo = Repository::open_from_env()?;
344
345 let remote = repo.find_remote("origin")?;
346 let url = remote
347 .url()
348 .context("Could not get remote URL")?
349 .to_string();
350
351 Ok(url)
352}
353
354/// Returns recent commit messages for style analysis.
355///
356/// # Arguments
357///
358/// * `count` - Maximum number of recent commit messages to retrieve
359///
360/// # Errors
361///
362/// Returns an error if the repository cannot be accessed or has no commits.
363///
364/// # Examples
365///
366/// ```no_run
367/// use rusty_commit::git;
368///
369/// let messages = git::get_recent_commit_messages(5).unwrap();
370/// for msg in messages {
371/// println!("{}", msg);
372/// }
373/// ```
374pub fn get_recent_commit_messages(count: usize) -> Result<Vec<String>> {
375 let repo = Repository::open_from_env()?;
376
377 // Get HEAD reference
378 let head = repo.head()?;
379
380 // Get the commit
381 let commit = head.peel_to_commit()?;
382
383 // Walk back through history, following all parents (handles merge commits)
384 let mut commits = Vec::new();
385 let mut queue = vec![commit];
386
387 while let Some(c) = queue.pop() {
388 if commits.len() >= count {
389 break;
390 }
391
392 if let Some(msg) = c.message() {
393 commits.push(msg.to_string());
394 }
395
396 // Add all parents to queue (reverse order to maintain commit order)
397 let parents: Result<Vec<_>, anyhow::Error> = (0..c.parent_count())
398 .map(|i| c.parent(i).map_err(anyhow::Error::from))
399 .collect();
400 if let Ok(parents) = parents {
401 for parent in parents.into_iter().rev() {
402 queue.push(parent);
403 }
404 }
405 }
406
407 Ok(commits)
408}