git_commit_helper/
git.rs

1// ************************************************************************** //
2//                                                                            //
3//                                                        :::      ::::::::   //
4//   git.rs                                             :+:      :+:    :+:   //
5//                                                    +:+ +:+         +:+     //
6//   By: dfine <coding@dfine.tech>                  +#+  +:+       +#+        //
7//                                                +#+#+#+#+#+   +#+           //
8//   Created: 2025/05/10 19:12:46 by dfine             #+#    #+#             //
9//   Updated: 2025/06/02 02:00:48 by dfine            ###   ########.fr       //
10//                                                                            //
11// ************************************************************************** //
12
13use git2::{DiffOptions, Repository};
14use std::{
15    error::Error,
16    io::Write,
17    process::{Command, Stdio},
18};
19
20/// Returns the staged diff of the current Git repository (i.e., changes staged for commit).
21///
22/// This compares the staged index against the current `HEAD`.
23///
24/// # Arguments
25///
26/// * `repo` - A reference to an open `git2::Repository` instance.
27///
28/// # Returns
29///
30/// A `String` containing the unified diff. If the diff cannot be generated, it returns `"None"`.
31///
32/// # Example
33///
34/// ```
35/// use git_commit_helper::get_staged_diff;
36/// use git2::Repository;
37///
38/// let repo = Repository::discover(".").expect("Not a git repository");
39/// let diff = get_staged_diff(&repo);
40/// println!("{:?}", diff);
41/// ```
42pub fn get_staged_diff(repo: &Repository) -> Option<String> {
43    let index = repo.index().ok()?;
44    let tree = repo.head().ok().and_then(|head| head.peel_to_tree().ok());
45    let mut diff_opts = DiffOptions::new();
46    let diff = repo
47        .diff_tree_to_index(tree.as_ref(), Some(&index), Some(&mut diff_opts))
48        .ok()?;
49    let mut buf = Vec::new();
50    if let Err(e) = diff.print(git2::DiffFormat::Patch, |_d, _h, _l| {
51        buf.extend_from_slice(_l.content());
52        true
53    }) {
54        eprintln!("failed to print diff: {}", e);
55        return None;
56    }
57    let result = String::from_utf8_lossy(&buf).to_string();
58    if result.trim().is_empty() {
59        return None;
60    }
61    Some(result)
62}
63
64/// Returns the messages of the most recent commits (up to 3).
65///
66/// Useful for providing context to an LLM or for generating summaries.
67///
68/// # Arguments
69///
70/// * `repo` - A reference to an open `git2::Repository` instance.
71///
72/// # Returns
73///
74/// A newline-separated string of the latest commit messages. If no commits exist, returns `"None"`.
75///
76/// # Example
77///
78/// ```
79/// use git_commit_helper::get_recent_commit_message;
80/// use git2::Repository;
81///
82/// let repo = Repository::discover(".").expect("Not a git repository");
83/// let messages = get_recent_commit_message(&repo);
84/// println!("{:?}", messages);
85/// ```
86pub fn get_recent_commit_message(repo: &Repository) -> Option<String> {
87    let mut revwalk = repo.revwalk().ok()?;
88    revwalk.push_head().ok()?;
89    let commits: Vec<String> = revwalk
90        .take(3)
91        .filter_map(|oid| oid.ok())
92        .filter_map(|oid| repo.find_commit(oid).ok())
93        .map(|commit| commit.message().unwrap_or("").trim().replace('"', "\\\""))
94        .collect();
95    if commits.is_empty() {
96        return None;
97    }
98    Some(commits.join("\n\n"))
99}
100
101pub fn gpg_sign(data: &[u8], key: Option<&str>) -> Result<String, Box<dyn std::error::Error>> {
102    let mut cmd = Command::new("gpg");
103    cmd.args(["--armor", "--detach-sign"]);
104
105    if let Some(k) = key {
106        cmd.args(["--local-user", k]);
107    }
108
109    let mut child = cmd.stdin(Stdio::piped()).stdout(Stdio::piped()).spawn()?;
110    child.stdin.as_mut().unwrap().write_all(data)?;
111    let output = child.wait_with_output()?;
112
113    if !output.status.success() {
114        return Err(format!(
115            "GPG signing failed: {}",
116            String::from_utf8_lossy(&output.stderr)
117        )
118        .into());
119    }
120
121    Ok(String::from_utf8(output.stdout)?)
122}
123
124/// Commits the currently staged changes with the provided commit message.
125///
126/// This function handles both initial and regular commits, constructing the commit tree
127/// and linking to the correct parent if available.
128///
129/// # Arguments
130///
131/// * `repo` - A reference to an open `git2::Repository` instance.
132/// * `message` - The commit message to use.
133///
134/// # Errors
135///
136/// Returns a boxed `Error` if Git operations (e.g., getting the index, writing tree, or committing) fail.
137///
138/// # Example
139///
140/// ```
141/// use git_commit_helper::commit_with_git;
142/// use git2::Repository;
143///
144/// let repo = Repository::discover(".").expect("Not a git repository");
145/// let message = "Add README and initial setup";
146/// if let Err(err) = commit_with_git(&repo, message) {
147///     eprintln!("Commit failed: {}", err);
148/// }
149/// ```
150pub fn commit_with_git(
151    repo: &Repository,
152    message: &str,
153    gpgsign: bool,
154    signkey: Option<&str>,
155) -> Result<(), Box<dyn Error>> {
156    let sig = repo.signature()?;
157
158    let tree_oid = {
159        let mut index = repo.index()?;
160        let oid = index.write_tree()?;
161        repo.find_tree(oid)?
162    };
163
164    let head = repo.head().ok();
165    let parent_commit = head
166        .as_ref()
167        .and_then(|h| h.target())
168        .and_then(|oid| repo.find_commit(oid).ok());
169    let parents = parent_commit.iter().collect::<Vec<_>>();
170
171    let tree = repo.find_tree(tree_oid.id())?;
172    let buf = repo.commit_create_buffer(&sig, &sig, message, &tree, &parents)?;
173
174    if !gpgsign {
175        let commit_oid = repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &parents)?;
176        println!("✅ Commit created: {}", commit_oid);
177        return Ok(());
178    }
179    let signature = gpg_sign(&buf, signkey);
180    let commit_oid =
181        repo.commit_signed(buf.as_str().unwrap(), signature.unwrap().as_str(), None)?;
182    // let commit = repo.find_commit(commit_oid)?;
183    // repo.branch(head.unwrap().shorthand().unwrap(), &commit, false)?;
184
185    let head_ref = repo.find_reference("HEAD")?;
186
187    repo.reference(
188        head_ref.symbolic_target().unwrap(),
189        // head.unwrap().name().unwrap(),
190        commit_oid,
191        true,
192        "update ref",
193    )?;
194
195    println!("✅ Commit created: {}", commit_oid);
196    Ok(())
197}