gnostr_asyncgit/sync/
commit.rs

1//! Git Api for Commits
2//use anyhow::anyhow;
3use git2::{message_prettify, Commit, ErrorCode, ObjectType, Oid, Repository, Signature};
4
5use serde::{Deserialize, Serialize};
6use serde_json;
7//?use nostr_sdk::serde_json;
8//use serde_json::{Result as SerdeJsonResult, Value};
9use log::debug;
10use scopetime::scope_time;
11
12use super::{CommitId, RepoPath};
13use crate::{
14    error::{Error, Result},
15    sync::{
16        repository::repo,
17        sign::{SignBuilder, SignError},
18        utils::get_head_repo,
19    },
20};
21
22///
23pub fn amend(repo_path: &RepoPath, id: CommitId, msg: &str) -> Result<CommitId> {
24    scope_time!("amend");
25
26    let repo = repo(repo_path)?;
27    let config = repo.config()?;
28
29    let commit = repo.find_commit(id.into())?;
30
31    let mut index = repo.index()?;
32    let tree_id = index.write_tree()?;
33    let tree = repo.find_tree(tree_id)?;
34
35    if config.get_bool("commit.gpgsign").unwrap_or(false) {
36        // HACK: we undo the last commit and create a new one
37        use crate::sync::utils::undo_last_commit;
38
39        let head = get_head_repo(&repo)?;
40        if head == commit.id().into() {
41            undo_last_commit(repo_path)?;
42            return self::commit(repo_path, msg);
43        }
44
45        return Err(Error::SignAmendNonLastCommit);
46    }
47
48    let new_id = commit.amend(Some("HEAD"), None, None, None, Some(msg), Some(&tree))?;
49
50    Ok(CommitId::new(new_id))
51}
52
53/// Wrap `Repository::signature` to allow unknown user.name.
54///
55/// See <https://github.com/extrawurst/gitui/issues/79>.
56#[allow(clippy::redundant_pub_crate)]
57pub(crate) fn signature_allow_undefined_name(
58    repo: &Repository,
59) -> std::result::Result<Signature<'_>, git2::Error> {
60    let signature = repo.signature();
61
62    if let Err(ref e) = signature {
63        if e.code() == ErrorCode::NotFound {
64            let config = repo.config()?;
65
66            if let (Err(_), Ok(email_entry)) = (
67                config.get_entry("user.name"),
68                config.get_entry("user.email"),
69            ) {
70                if let Some(email) = email_entry.value() {
71                    return Signature::now("unknown", email);
72                }
73            };
74        }
75    }
76
77    signature
78}
79
80#[derive(Serialize, Deserialize, Debug)]
81struct SerializableCommit {
82    id: String,
83    tree: String,
84    parents: Vec<String>,
85    author_name: String,
86    author_email: String,
87    committer_name: String,
88    committer_email: String,
89    message: String,
90    time: i64,
91}
92///
93pub fn serialize_commit(commit: &Commit) -> Result<String> {
94    let id = commit.id().to_string();
95    let tree = commit.tree_id().to_string();
96    let parents = commit.parent_ids().map(|oid| oid.to_string()).collect();
97    let author = commit.author();
98    let committer = commit.committer();
99    let message = commit
100        .message()
101        .ok_or(log::debug!("No commit message"))
102        .expect("")
103        .to_string();
104    log::debug!("message:\n{:?}", message);
105    let time = commit.time().seconds();
106    debug!("time: {:?}", time);
107
108    let serializable_commit = SerializableCommit {
109        id,
110        tree,
111        parents,
112        author_name: author.name().unwrap_or_default().to_string(),
113        author_email: author.email().unwrap_or_default().to_string(),
114        committer_name: committer.name().unwrap_or_default().to_string(),
115        committer_email: committer.email().unwrap_or_default().to_string(),
116        message,
117        time,
118    };
119
120    let serialized = serde_json::to_string(&serializable_commit).expect("");
121    debug!("serialized_commit: {:?}", serialized);
122    Ok(serialized)
123}
124///
125pub fn deserialize_commit<'a>(repo: &'a Repository, data: &'a str) -> Result<Commit<'a>> {
126    //we serialize the commit data
127    //easier to grab the commit.id
128    let serializable_commit: SerializableCommit = serde_json::from_str(data).expect("");
129    //grab the commit.id
130    let oid = Oid::from_str(&serializable_commit.id)?;
131    //oid used to search the repo
132    let commit_obj = repo.find_object(oid, Some(ObjectType::Commit))?;
133    //grab the commit
134    let commit = commit_obj.peel_to_commit()?;
135    //confirm we grabbed the correct commit
136    //if commit.id().to_string() != serializable_commit.id {
137    //    return Err(eprintln!("Commit ID mismatch during deserialization"));
138    //}
139    //return the commit
140    Ok(commit)
141}
142
143/// this does not run any git hooks, git-hooks have to be executed
144/// manually, checkout `hooks_commit_msg` for example
145pub fn commit(repo_path: &RepoPath, msg: &str) -> Result<CommitId> {
146    scope_time!("commit");
147
148    let repo = repo(repo_path)?;
149    let config = repo.config()?;
150    let signature = signature_allow_undefined_name(&repo)?;
151    let mut index = repo.index()?;
152    let tree_id = index.write_tree()?;
153    let tree = repo.find_tree(tree_id)?;
154
155    let parents = if let Ok(id) = get_head_repo(&repo) {
156        vec![repo.find_commit(id.into())?]
157    } else {
158        Vec::new()
159    };
160
161    let parents = parents.iter().collect::<Vec<_>>();
162
163    let commit_id = if config.get_bool("commit.gpgsign").unwrap_or(false) {
164        let buffer =
165            repo.commit_create_buffer(&signature, &signature, msg, &tree, parents.as_slice())?;
166
167        let commit = std::str::from_utf8(&buffer)
168            .map_err(|_e| SignError::Shellout("utf8 conversion error".to_string()))?;
169
170        let signer = SignBuilder::from_gitconfig(&repo, &config)?;
171        let (signature, signature_field) = signer.sign(&buffer)?;
172        let commit_id = repo.commit_signed(commit, &signature, signature_field.as_deref())?;
173
174        // manually advance to the new commit ID
175        // repo.commit does that on its own, repo.commit_signed does
176        // not if there is no head, read default branch or default
177        // to "master"
178        if let Ok(mut head) = repo.head() {
179            head.set_target(commit_id, msg)?;
180        } else {
181            let default_branch_name = config.get_str("init.defaultBranch").unwrap_or("master");
182            repo.reference(
183                &format!("refs/heads/{default_branch_name}"),
184                commit_id,
185                true,
186                msg,
187            )?;
188        }
189
190        commit_id
191    } else {
192        repo.commit(
193            Some("HEAD"),
194            &signature,
195            &signature,
196            msg,
197            &tree,
198            parents.as_slice(),
199        )?
200    };
201
202    Ok(commit_id.into())
203}
204
205/// Tag a commit.
206///
207/// This function will return an `Err(…)` variant if the tag’s name is
208/// refused by git or if the tag already exists.
209pub fn tag_commit(
210    repo_path: &RepoPath,
211    commit_id: &CommitId,
212    tag: &str,
213    message: Option<&str>,
214) -> Result<CommitId> {
215    scope_time!("tag_commit");
216
217    let repo = repo(repo_path)?;
218
219    let object_id = commit_id.get_oid();
220    let target = repo.find_object(object_id, Some(ObjectType::Commit))?;
221
222    let c = if let Some(message) = message {
223        let signature = signature_allow_undefined_name(&repo)?;
224        repo.tag(tag, &target, &signature, message, false)?.into()
225    } else {
226        repo.tag_lightweight(tag, &target, false)?.into()
227    };
228
229    Ok(c)
230}
231
232/// Loads the comment prefix from config & uses it to prettify commit
233/// messages
234pub fn commit_message_prettify(repo_path: &RepoPath, message: String) -> Result<String> {
235    let comment_char = repo(repo_path)?
236        .config()?
237        .get_string("core.commentChar")
238        .ok()
239        .and_then(|char_string| char_string.chars().next())
240        .unwrap_or('#') as u8;
241
242    Ok(message_prettify(message, Some(comment_char))?)
243}
244
245#[cfg(test)]
246mod tests {
247    use std::{fs::File, io::Write, path::Path};
248
249    use commit::{amend, commit_message_prettify, tag_commit};
250    use git2::Repository;
251
252    use crate::{
253        error::Result,
254        sync::{
255            commit, get_commit_details, get_commit_files, stage_add_file,
256            tags::{get_tags, Tag},
257            tests::{get_statuses, repo_init, repo_init_empty},
258            utils::get_head,
259            LogWalker, RepoPath,
260        },
261    };
262
263    fn count_commits(repo: &Repository, max: usize) -> usize {
264        let mut items = Vec::new();
265        let mut walk = LogWalker::new(repo, max).unwrap();
266        walk.read(&mut items).unwrap();
267        items.len()
268    }
269
270    #[test]
271    fn test_commit() {
272        let file_path = Path::new("foo");
273        let (_td, repo) = repo_init().unwrap();
274        let root = repo.path().parent().unwrap();
275        let repo_path: &RepoPath = &root.as_os_str().to_str().unwrap().into();
276
277        File::create(root.join(file_path))
278            .unwrap()
279            .write_all(b"test\nfoo")
280            .unwrap();
281
282        assert_eq!(get_statuses(repo_path), (1, 0));
283
284        stage_add_file(repo_path, file_path).unwrap();
285
286        assert_eq!(get_statuses(repo_path), (0, 1));
287
288        commit(repo_path, "commit msg").unwrap();
289
290        assert_eq!(get_statuses(repo_path), (0, 0));
291    }
292
293    #[test]
294    fn test_commit_in_empty_repo() {
295        let file_path = Path::new("foo");
296        let (_td, repo) = repo_init_empty().unwrap();
297        let root = repo.path().parent().unwrap();
298        let repo_path: &RepoPath = &root.as_os_str().to_str().unwrap().into();
299
300        assert_eq!(get_statuses(repo_path), (0, 0));
301
302        File::create(root.join(file_path))
303            .unwrap()
304            .write_all(b"test\nfoo")
305            .unwrap();
306
307        assert_eq!(get_statuses(repo_path), (1, 0));
308
309        stage_add_file(repo_path, file_path).unwrap();
310
311        assert_eq!(get_statuses(repo_path), (0, 1));
312
313        commit(repo_path, "commit msg").unwrap();
314
315        assert_eq!(get_statuses(repo_path), (0, 0));
316    }
317
318    #[test]
319    fn test_amend() -> Result<()> {
320        let file_path1 = Path::new("foo");
321        let file_path2 = Path::new("foo2");
322        let (_td, repo) = repo_init_empty()?;
323        let root = repo.path().parent().unwrap();
324        let repo_path: &RepoPath = &root.as_os_str().to_str().unwrap().into();
325
326        File::create(root.join(file_path1))?.write_all(b"test1")?;
327
328        stage_add_file(repo_path, file_path1)?;
329        let id = commit(repo_path, "commit msg")?;
330
331        assert_eq!(count_commits(&repo, 10), 1);
332
333        File::create(root.join(file_path2))?.write_all(b"test2")?;
334
335        stage_add_file(repo_path, file_path2)?;
336
337        let new_id = amend(repo_path, id, "amended")?;
338
339        assert_eq!(count_commits(&repo, 10), 1);
340
341        let details = get_commit_details(repo_path, new_id)?;
342        assert_eq!(details.message.unwrap().subject, "amended");
343
344        let files = get_commit_files(repo_path, new_id, None)?;
345
346        assert_eq!(files.len(), 2);
347
348        let head = get_head(repo_path)?;
349
350        assert_eq!(head, new_id);
351
352        Ok(())
353    }
354
355    #[test]
356    fn test_tag() -> Result<()> {
357        let file_path = Path::new("foo");
358        let (_td, repo) = repo_init_empty().unwrap();
359        let root = repo.path().parent().unwrap();
360        let repo_path: &RepoPath = &root.as_os_str().to_str().unwrap().into();
361
362        File::create(root.join(file_path))?.write_all(b"test\nfoo")?;
363
364        stage_add_file(repo_path, file_path)?;
365
366        let new_id = commit(repo_path, "commit msg")?;
367
368        tag_commit(repo_path, &new_id, "tag", None)?;
369
370        assert_eq!(get_tags(repo_path).unwrap()[&new_id], vec![Tag::new("tag")]);
371
372        assert!(matches!(
373            tag_commit(repo_path, &new_id, "tag", None),
374            Err(_)
375        ));
376
377        assert_eq!(get_tags(repo_path).unwrap()[&new_id], vec![Tag::new("tag")]);
378
379        tag_commit(repo_path, &new_id, "second-tag", None)?;
380
381        assert_eq!(
382            get_tags(repo_path).unwrap()[&new_id],
383            vec![Tag::new("second-tag"), Tag::new("tag")]
384        );
385
386        Ok(())
387    }
388
389    #[test]
390    fn test_tag_with_message() -> Result<()> {
391        let file_path = Path::new("foo");
392        let (_td, repo) = repo_init_empty().unwrap();
393        let root = repo.path().parent().unwrap();
394        let repo_path: &RepoPath = &root.as_os_str().to_str().unwrap().into();
395
396        File::create(root.join(file_path))?.write_all(b"test\nfoo")?;
397
398        stage_add_file(repo_path, file_path)?;
399
400        let new_id = commit(repo_path, "commit msg")?;
401
402        tag_commit(repo_path, &new_id, "tag", Some("tag-message"))?;
403
404        assert_eq!(
405            get_tags(repo_path).unwrap()[&new_id][0]
406                .annotation
407                .as_ref()
408                .unwrap(),
409            "tag-message"
410        );
411
412        Ok(())
413    }
414
415    /// Beware: this test has to be run with a `$HOME/.gitconfig` that
416    /// has `user.email` not set. Otherwise, git falls back to the
417    /// value of `user.email` in `$HOME/.gitconfig` and this test
418    /// fails.
419    ///
420    /// As of February 2021, `repo_init_empty` sets all git config
421    /// locations to an empty temporary directory, so this constraint
422    /// is met.
423    #[test]
424    fn test_empty_email() -> Result<()> {
425        let file_path = Path::new("foo");
426        let (_td, repo) = repo_init_empty().unwrap();
427        let root = repo.path().parent().unwrap();
428        let repo_path: &RepoPath = &root.as_os_str().to_str().unwrap().into();
429
430        File::create(root.join(file_path))?.write_all(b"test\nfoo")?;
431
432        stage_add_file(repo_path, file_path)?;
433
434        repo.config()?.remove("user.email")?;
435
436        let error = commit(repo_path, "commit msg");
437
438        assert!(matches!(error, Err(_)));
439
440        repo.config()?.set_str("user.email", "email")?;
441
442        let success = commit(repo_path, "commit msg");
443
444        assert!(matches!(success, Ok(_)));
445        assert_eq!(count_commits(&repo, 10), 1);
446
447        let details = get_commit_details(repo_path, success.unwrap()).unwrap();
448
449        assert_eq!(details.author.name, "name");
450        assert_eq!(details.author.email, "email");
451
452        Ok(())
453    }
454
455    /// See comment to `test_empty_email`.
456    #[test]
457    fn test_empty_name() -> Result<()> {
458        let file_path = Path::new("foo");
459        let (_td, repo) = repo_init_empty().unwrap();
460        let root = repo.path().parent().unwrap();
461        let repo_path: &RepoPath = &root.as_os_str().to_str().unwrap().into();
462
463        File::create(root.join(file_path))?.write_all(b"test\nfoo")?;
464
465        stage_add_file(repo_path, file_path)?;
466
467        repo.config()?.remove("user.name")?;
468
469        let mut success = commit(repo_path, "commit msg");
470
471        assert!(matches!(success, Ok(_)));
472        assert_eq!(count_commits(&repo, 10), 1);
473
474        let mut details = get_commit_details(repo_path, success.unwrap()).unwrap();
475
476        assert_eq!(details.author.name, "unknown");
477        assert_eq!(details.author.email, "email");
478
479        repo.config()?.set_str("user.name", "name")?;
480
481        success = commit(repo_path, "commit msg");
482
483        assert!(matches!(success, Ok(_)));
484        assert_eq!(count_commits(&repo, 10), 2);
485
486        details = get_commit_details(repo_path, success.unwrap()).unwrap();
487
488        assert_eq!(details.author.name, "name");
489        assert_eq!(details.author.email, "email");
490
491        Ok(())
492    }
493
494    #[test]
495    fn test_empty_comment_char() -> Result<()> {
496        let (_td, repo) = repo_init_empty().unwrap();
497
498        let root = repo.path().parent().unwrap();
499        let repo_path: &RepoPath = &root.as_os_str().to_str().unwrap().into();
500
501        let message =
502            commit_message_prettify(repo_path, "#This is a test message\nTest".to_owned())?;
503
504        assert_eq!(message, "Test\n");
505        Ok(())
506    }
507
508    #[test]
509    fn test_with_comment_char() -> Result<()> {
510        let (_td, repo) = repo_init_empty().unwrap();
511
512        let root = repo.path().parent().unwrap();
513        let repo_path: &RepoPath = &root.as_os_str().to_str().unwrap().into();
514
515        repo.config()?.set_str("core.commentChar", ";")?;
516
517        let message =
518            commit_message_prettify(repo_path, ";This is a test message\nTest".to_owned())?;
519
520        assert_eq!(message, "Test\n");
521
522        Ok(())
523    }
524}