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 specified remote.
329///
330/// # Arguments
331///
332/// * `remote_name` - The name of the remote (e.g., "origin", "upstream"). Defaults to "origin" if None.
333///
334/// # Errors
335///
336/// Returns an error if the specified remote does not exist or if the URL cannot be retrieved.
337///
338/// # Examples
339///
340/// ```no_run
341/// use rusty_commit::git;
342///
343/// // Get URL for "origin" remote (default)
344/// let url = git::get_remote_url(None).unwrap();
345///
346/// // Get URL for "upstream" remote
347/// let url = git::get_remote_url(Some("upstream")).unwrap();
348/// println!("Remote URL: {}", url);
349/// ```
350pub fn get_remote_url(remote_name: Option<&str>) -> Result<String> {
351 let repo = Repository::open_from_env()?;
352 let remote = repo.find_remote(remote_name.unwrap_or("origin"))?;
353 let url = remote
354 .url()
355 .context("Could not get remote URL")?
356 .to_string();
357
358 Ok(url)
359}
360
361/// Pushes the current branch to the remote repository.
362///
363/// # Arguments
364///
365/// * `remote` - The name of the remote to push to (e.g., "origin")
366/// * `branch` - The name of the branch to push
367///
368/// # Errors
369///
370/// Returns an error if the push fails or if the repository cannot be accessed.
371///
372/// # Examples
373///
374/// ```no_run
375/// use rusty_commit::git;
376///
377/// git::git_push("origin", "main").unwrap();
378/// ```
379pub fn git_push(remote: &str, branch: &str) -> Result<()> {
380 let output = Command::new("git")
381 .args(["push", remote, branch])
382 .output()
383 .context("Failed to execute git push")?;
384
385 if !output.status.success() {
386 let stderr = String::from_utf8_lossy(&output.stderr);
387 anyhow::bail!("Git push failed: {}", stderr);
388 }
389
390 Ok(())
391}
392
393/// Pushes the current branch to its upstream remote.
394///
395/// # Errors
396///
397/// Returns an error if the push fails, if there is no upstream configured,
398/// or if the repository cannot be accessed.
399///
400/// # Examples
401///
402/// ```no_run
403/// use rusty_commit::git;
404///
405/// git::git_push_upstream().unwrap();
406/// ```
407#[allow(dead_code)]
408pub fn git_push_upstream() -> Result<()> {
409 let output = Command::new("git")
410 .args(["push", "--set-upstream"])
411 .output()
412 .context("Failed to execute git push")?;
413
414 if !output.status.success() {
415 let stderr = String::from_utf8_lossy(&output.stderr);
416 anyhow::bail!("Git push failed: {}", stderr);
417 }
418
419 Ok(())
420}
421
422/// Returns recent commit messages for style analysis.
423///
424/// # Arguments
425///
426/// * `count` - Maximum number of recent commit messages to retrieve
427///
428/// # Errors
429///
430/// Returns an error if the repository cannot be accessed or has no commits.
431///
432/// # Examples
433///
434/// ```no_run
435/// use rusty_commit::git;
436///
437/// let messages = git::get_recent_commit_messages(5).unwrap();
438/// for msg in messages {
439/// println!("{}", msg);
440/// }
441/// ```
442pub fn get_recent_commit_messages(count: usize) -> Result<Vec<String>> {
443 let repo = Repository::open_from_env()?;
444
445 // Get HEAD reference
446 let head = repo.head()?;
447
448 // Get the commit
449 let commit = head.peel_to_commit()?;
450
451 // Walk back through history, following all parents (handles merge commits)
452 let mut commits = Vec::new();
453 let mut queue = vec![commit];
454
455 while let Some(c) = queue.pop() {
456 if commits.len() >= count {
457 break;
458 }
459
460 if let Some(msg) = c.message() {
461 commits.push(msg.to_string());
462 }
463
464 // Add all parents to queue (reverse order to maintain commit order)
465 let parents: Result<Vec<_>, anyhow::Error> = (0..c.parent_count())
466 .map(|i| c.parent(i).map_err(anyhow::Error::from))
467 .collect();
468 if let Ok(parents) = parents {
469 for parent in parents.into_iter().rev() {
470 queue.push(parent);
471 }
472 }
473 }
474
475 Ok(commits)
476}