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