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