Skip to main content

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}