Skip to main content

git_quick_add/git/
commit.rs

1use dialoguer::Input;
2use git2::Repository;
3use regex::Regex;
4use crate::config::AppConfig;
5use std::process::{Command, Stdio};
6use std::str;
7
8pub fn commit(repo: &Repository, config: AppConfig) {
9    // 0: We have our staged changes
10    // 1: Prompt the user for a commit message
11    let commit_message: String = Input::new()
12        .with_prompt("Enter Commit Message")
13        .interact_text()
14        .unwrap();
15    // 2: Get branch id
16    let branch_name = repo
17        .head()
18        .ok()
19        .and_then(|head| head.shorthand().map(str::to_owned))
20        .unwrap_or_else(|| "HEAD".to_string());
21    let branch_segment = branch_name.rsplit('/').next().unwrap_or(&branch_name);
22    let reference_id = Regex::new(r"^([^\d]*)(\d+)")
23        .unwrap()
24        .captures(branch_segment)
25        .map(|captures| format_reference_id(&captures[1], &captures[2], config.uppercase))
26        .unwrap_or(branch_name);
27    // 3: Create the commit
28    // git commit -S -m "{reference_id}: {commit_message}"
29    let full_commit_message = format!("{reference_id}: {commit_message}");
30    let mut index = repo.index().unwrap();
31    let tree_oid = index.write_tree().unwrap();
32    let tree = repo.find_tree(tree_oid).unwrap();
33    let signature = repo.signature().unwrap();
34    let parent_commit = repo
35        .head()
36        .ok()
37        .and_then(|head| head.target())
38        .and_then(|oid| repo.find_commit(oid).ok());
39    let parents = parent_commit.iter().collect::<Vec<_>>();
40    let commit_result = repo
41        .commit_create_buffer(
42            &signature,
43            &signature,
44            &full_commit_message,
45            &tree,
46            &parents,
47        )
48        .ok()
49        .and_then(|commit_buffer| {
50            let commit_content = str::from_utf8(&commit_buffer).ok()?;
51            let signed_commit = sign_commit_buffer(repo, commit_content).ok()?;
52            let commit_oid = repo.commit_signed(commit_content, &signed_commit, None).ok()?;
53            update_head_to_commit(repo, commit_oid, &full_commit_message).ok()?;
54            Some(commit_oid)
55        });
56
57    if commit_result.is_some() {
58        println!(
59            "{}",
60            console::style("Signed commit created successfully").green()
61        );
62    } else {
63        repo.commit(
64            Some("HEAD"),
65            &signature,
66            &signature,
67            &full_commit_message,
68            &tree,
69            &parents,
70        )
71        .unwrap();
72        println!(
73            "{}",
74            console::style("Commit created successfully (unsigned)").yellow()
75        );
76    }
77
78    println!("Commit message: {full_commit_message}");
79    // 5: Optional push
80}
81
82fn format_reference_id(prefix: &str, number: &str, uppercase: bool) -> String {
83    let reference_id = format!("{prefix}{number}");
84    if uppercase {
85        reference_id.to_uppercase()
86    } else {
87        reference_id
88    }
89}
90
91fn sign_commit_buffer(repo: &Repository, commit_content: &str) -> Result<String, git2::Error> {
92    let config = repo.config()?;
93    let signing_key = config.get_string("user.signingkey").ok();
94    let gpg_program = config
95        .get_string("gpg.program")
96        .unwrap_or_else(|_| "gpg".to_string());
97
98    let mut command = Command::new(gpg_program);
99    command.arg("--armor").arg("--detach-sign");
100
101    if let Some(signing_key) = signing_key.as_deref() {
102        command.arg("--local-user").arg(signing_key);
103    }
104
105    let mut child = command
106        .stdin(Stdio::piped())
107        .stdout(Stdio::piped())
108        .spawn()
109        .map_err(|err| git2::Error::from_str(&format!("failed to start gpg: {err}")))?;
110
111    {
112        let mut stdin = child
113            .stdin
114            .take()
115            .ok_or_else(|| git2::Error::from_str("failed to open gpg stdin"))?;
116        use std::io::Write;
117        stdin
118            .write_all(commit_content.as_bytes())
119            .map_err(|err| git2::Error::from_str(&format!("failed to write commit to gpg: {err}")))?;
120    }
121
122    let output = child
123        .wait_with_output()
124        .map_err(|err| git2::Error::from_str(&format!("failed to wait for gpg: {err}")))?;
125
126    if !output.status.success() {
127        let stderr = String::from_utf8_lossy(&output.stderr);
128        return Err(git2::Error::from_str(&format!(
129            "gpg failed to sign commit: {}",
130            stderr.trim()
131        )));
132    }
133
134    String::from_utf8(output.stdout)
135        .map_err(|err| git2::Error::from_str(&format!("invalid gpg output: {err}")))
136}
137
138fn update_head_to_commit(
139    repo: &Repository,
140    commit_oid: git2::Oid,
141    reflog_message: &str,
142) -> Result<(), git2::Error> {
143    let head = repo.head()?;
144
145    match head.resolve() {
146        Ok(mut direct_ref) => {
147            direct_ref.set_target(commit_oid, reflog_message)?;
148            Ok(())
149        }
150        Err(_) => repo.set_head_detached(commit_oid),
151    }
152}
153
154#[cfg(test)]
155mod tests {
156    use super::format_reference_id;
157
158    #[test]
159    fn leaves_reference_id_unchanged_by_default() {
160        assert_eq!(format_reference_id("abc-", "123", false), "abc-123");
161    }
162
163    #[test]
164    fn uppercases_reference_id_when_enabled() {
165        assert_eq!(format_reference_id("abc-", "123", true), "ABC-123");
166    }
167}