summaryrefslogtreecommitdiffstats
path: root/asyncgit
diff options
context:
space:
mode:
Diffstat (limited to 'asyncgit')
-rw-r--r--asyncgit/Cargo.toml6
-rw-r--r--asyncgit/src/error.rs20
-rw-r--r--asyncgit/src/sync/branch/mod.rs14
-rw-r--r--asyncgit/src/sync/commit.rs129
-rw-r--r--asyncgit/src/sync/commit_details.rs2
-rw-r--r--asyncgit/src/sync/cred.rs51
-rw-r--r--asyncgit/src/sync/mod.rs7
-rw-r--r--asyncgit/src/sync/remotes/mod.rs140
-rw-r--r--asyncgit/src/sync/remotes/push.rs4
-rw-r--r--asyncgit/src/sync/reword.rs28
-rw-r--r--asyncgit/src/sync/sign.rs440
-rw-r--r--asyncgit/src/sync/tags.rs2
12 files changed, 813 insertions, 30 deletions
diff --git a/asyncgit/Cargo.toml b/asyncgit/Cargo.toml
index deb9beae..40a83fff 100644
--- a/asyncgit/Cargo.toml
+++ b/asyncgit/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "asyncgit"
-version = "0.25.0"
+version = "0.26.0"
authors = ["extrawurst <mail@rusticorn.com>"]
edition = "2021"
description = "allow using git2 in a asynchronous context"
@@ -14,6 +14,7 @@ keywords = ["git"]
[dependencies]
bitflags = "2"
crossbeam-channel = "0.5"
+dirs = "5.0"
easy-cast = "0.5"
fuzzy-matcher = "0.3"
git2 = "0.18"
@@ -23,10 +24,11 @@ log = "0.4"
# git2 = { git="https://github.com/extrawurst/git2-rs.git", rev="fc13dcc", features = ["vendored-openssl"]}
# pinning to vendored openssl, using the git2 feature this gets lost with new resolver
openssl-sys = { version = '0.9', features = ["vendored"], optional = true }
-rayon = "1.8"
+rayon = "1.10"
rayon-core = "1.12"
scopetime = { path = "../scopetime", version = "0.1" }
serde = { version = "1.0", features = ["derive"] }
+ssh-key = { version = "0.6.6", features = ["crypto", "encryption"] }
thiserror = "1.0"
unicode-truncate = "0.2"
url = "2.5"
diff --git a/asyncgit/src/error.rs b/asyncgit/src/error.rs
index 6ede0422..3dba480b 100644
--- a/asyncgit/src/error.rs
+++ b/asyncgit/src/error.rs
@@ -84,6 +84,26 @@ pub enum Error {
///
#[error("git hook error: {0}")]
Hooks(#[from] git2_hooks::HooksError),
+
+ ///
+ #[error("sign builder error: {0}")]
+ SignBuilder(#[from] crate::sync::sign::SignBuilderError),
+
+ ///
+ #[error("sign error: {0}")]
+ Sign(#[from] crate::sync::sign::SignError),
+
+ ///
+ #[error("amend error: config commit.gpgsign=true detected.\ngpg signing is not supported for amending non-last commits")]
+ SignAmendNonLastCommit,
+
+ ///
+ #[error("reword error: config commit.gpgsign=true detected.\ngpg signing is not supported for rewording non-last commits")]
+ SignRewordNonLastCommit,
+
+ ///
+ #[error("reword error: config commit.gpgsign=true detected.\ngpg signing is not supported for rewording commits with staged changes\ntry unstaging or stashing your changes")]
+ SignRewordLastCommitStaged,
}
///
diff --git a/asyncgit/src/sync/branch/mod.rs b/asyncgit/src/sync/branch/mod.rs
index 4dbe89aa..02645cf5 100644
--- a/asyncgit/src/sync/branch/mod.rs
+++ b/asyncgit/src/sync/branch/mod.rs
@@ -5,13 +5,13 @@ pub mod merge_ff;
pub mod merge_rebase;
pub mod rename;
-use super::{
- remotes::get_default_remote_in_repo, utils::bytes2string,
- RepoPath,
-};
+use super::{utils::bytes2string, RepoPath};
use crate::{
error::{Error, Result},
- sync::{repository::repo, utils::get_head_repo, CommitId},
+ sync::{
+ remotes::get_default_remote_for_push_in_repo,
+ repository::repo, utils::get_head_repo, CommitId,
+ },
};
use git2::{Branch, BranchType, Repository};
use scopetime::scope_time;
@@ -209,7 +209,7 @@ pub struct BranchCompare {
}
///
-pub(crate) fn branch_set_upstream(
+pub(crate) fn branch_set_upstream_after_push(
repo: &Repository,
branch_name: &str,
) -> Result<()> {
@@ -219,7 +219,7 @@ pub(crate) fn branch_set_upstream(
repo.find_branch(branch_name, BranchType::Local)?;
if branch.upstream().is_err() {
- let remote = get_default_remote_in_repo(repo)?;
+ let remote = get_default_remote_for_push_in_repo(repo)?;
let upstream_name = format!("{remote}/{branch_name}");
branch.set_upstream(Some(upstream_name.as_str()))?;
}
diff --git a/asyncgit/src/sync/commit.rs b/asyncgit/src/sync/commit.rs
index b2b7a474..2b1d78fe 100644
--- a/asyncgit/src/sync/commit.rs
+++ b/asyncgit/src/sync/commit.rs
@@ -1,9 +1,13 @@
+//! Git Api for Commits
use super::{CommitId, RepoPath};
+use crate::sync::sign::{SignBuilder, SignError};
use crate::{
- error::Result,
+ error::{Error, Result},
sync::{repository::repo, utils::get_head_repo},
};
-use git2::{ErrorCode, ObjectType, Repository, Signature};
+use git2::{
+ message_prettify, ErrorCode, ObjectType, Repository, Signature,
+};
use scopetime::scope_time;
///
@@ -15,12 +19,27 @@ pub fn amend(
scope_time!("amend");
let repo = repo(repo_path)?;
+ let config = repo.config()?;
+
let commit = repo.find_commit(id.into())?;
let mut index = repo.index()?;
let tree_id = index.write_tree()?;
let tree = repo.find_tree(tree_id)?;
+ if config.get_bool("commit.gpgsign").unwrap_or(false) {
+ // HACK: we undo the last commit and create a new one
+ use crate::sync::utils::undo_last_commit;
+
+ let head = get_head_repo(&repo)?;
+ if head == commit.id().into() {
+ undo_last_commit(repo_path)?;
+ return self::commit(repo_path, msg);
+ }
+
+ return Err(Error::SignAmendNonLastCommit);
+ }
+
let new_id = commit.amend(
Some("HEAD"),
None,
@@ -65,7 +84,7 @@ pub fn commit(repo_path: &RepoPath, msg: &str) -> Result<CommitId> {
scope_time!("commit");
let repo = repo(repo_path)?;
-
+ let config = repo.config()?;
let signature = signature_allow_undefined_name(&repo)?;
let mut index = repo.index()?;
let tree_id = index.write_tree()?;
@@ -79,8 +98,50 @@ pub fn commit(repo_path: &RepoPath, msg: &str) -> Result<CommitId> {
let parents = parents.iter().collect::<Vec<_>>();
- Ok(repo
- .commit(
+ let commit_id = if config
+ .get_bool("commit.gpgsign")
+ .unwrap_or(false)
+ {
+ let buffer = repo.commit_create_buffer(
+ &signature,
+ &signature,
+ msg,
+ &tree,
+ parents.as_slice(),
+ )?;
+
+ let commit = std::str::from_utf8(&buffer).map_err(|_e| {
+ SignError::Shellout("utf8 conversion error".to_string())
+ })?;
+
+ let signer = SignBuilder::from_gitconfig(&repo, &config)?;
+ let (signature, signature_field) = signer.sign(&buffer)?;
+ let commit_id = repo.commit_signed(
+ commit,
+ &signature,
+ signature_field.as_deref(),
+ )?;
+
+ // manually advance to the new commit ID
+ // repo.commit does that on its own, repo.commit_signed does not
+ // if there is no head, read default branch or default to "master"
+ if let Ok(mut head) = repo.head() {
+ head.set_target(commit_id, msg)?;
+ } else {
+ let default_branch_name = config
+ .get_str("init.defaultBranch")
+ .unwrap_or("master");
+ repo.reference(
+ &format!("refs/heads/{default_branch_name}"),
+ commit_id,
+ true,
+ msg,
+ )?;
+ }
+
+ commit_id
+ } else {
+ repo.commit(
Some("HEAD"),
&signature,
&signature,
@@ -88,7 +149,9 @@ pub fn commit(repo_path: &RepoPath, msg: &str) -> Result<CommitId> {
&tree,
parents.as_slice(),
)?
- .into())
+ };
+
+ Ok(commit_id.into())
}
/// Tag a commit.
@@ -119,6 +182,21 @@ pub fn tag_commit(
Ok(c)
}
+/// Loads the comment prefix from config & uses it to prettify commit messages
+pub fn commit_message_prettify(
+ repo_path: &RepoPath,
+ message: String,
+) -> Result<String> {
+ let comment_char = repo(repo_path)?
+ .config()?
+ .get_string("core.commentChar")
+ .ok()
+ .and_then(|char_string| char_string.chars().next())
+ .unwrap_or('#') as u8;
+
+ Ok(message_prettify(message, Some(comment_char))?)
+}
+
#[cfg(test)]
mod tests {
use crate::error::Result;
@@ -131,7 +209,7 @@ mod tests {
utils::get_head,
LogWalker,
};
- use commit::{amend, tag_commit};
+ use commit::{amend, commit_message_prettify, tag_commit};
use git2::Repository;
use std::{fs::File, io::Write, path::Path};
@@ -383,4 +461,41 @@ mod tests {
Ok(())
}
+
+ #[test]
+ fn test_empty_comment_char() -> Result<()> {
+ let (_td, repo) = repo_init_empty().unwrap();
+
+ let root = repo.path().parent().unwrap();
+ let repo_path: &RepoPath =
+ &root.as_os_str().to_str().unwrap().into();
+
+ let message = commit_message_prettify(
+ repo_path,
+ "#This is a test message\nTest".to_owned(),
+ )?;
+
+ assert_eq!(message, "Test\n");
+ Ok(())
+ }
+
+ #[test]
+ fn test_with_comment_char() -> Result<()> {
+ let (_td, repo) = repo_init_empty().unwrap();
+
+ let root = repo.path().parent().unwrap();
+ let repo_path: &RepoPath =
+ &root.as_os_str().to_str().unwrap().into();
+
+ repo.config()?.set_str("core.commentChar", ";")?;
+
+ let message = commit_message_prettify(
+ repo_path,
+ ";This is a test message\nTest".to_owned(),
+ )?;
+
+ assert_eq!(message, "Test\n");
+
+ Ok(())
+ }
}
diff --git a/asyncgit/src/sync/commit_details.rs b/asyncgit/src/sync/commit_details.rs
index 8de0894b..cc60a3c6 100644
--- a/asyncgit/src/sync/commit_details.rs
+++ b/asyncgit/src/sync/commit_details.rs
@@ -82,8 +82,6 @@ pub struct CommitDetails {
impl CommitDetails {
///
- #[allow(clippy::missing_const_for_fn)]
- // clippy doesn't realise indexing a String is not const
pub fn short_hash(&self) -> &str {
&self.hash[0..7]
}
diff --git a/asyncgit/src/sync/cred.rs b/asyncgit/src/sync/cred.rs
index 3f908a63..54eb2cce 100644
--- a/asyncgit/src/sync/cred.rs
+++ b/asyncgit/src/sync/cred.rs
@@ -1,7 +1,12 @@
//! credentials git helper
use super::{
- remotes::get_default_remote_in_repo, repository::repo, RepoPath,
+ remotes::{
+ get_default_remote_for_push_in_repo,
+ get_default_remote_in_repo,
+ },
+ repository::repo,
+ RepoPath,
};
use crate::error::{Error, Result};
use git2::CredentialHelper;
@@ -43,6 +48,22 @@ pub fn need_username_password(repo_path: &RepoPath) -> Result<bool> {
Ok(is_http)
}
+/// know if username and password are needed for this url
+pub fn need_username_password_for_push(
+ repo_path: &RepoPath,
+) -> Result<bool> {
+ let repo = repo(repo_path)?;
+ let remote = repo
+ .find_remote(&get_default_remote_for_push_in_repo(&repo)?)?;
+ let url = remote
+ .pushurl()
+ .or_else(|| remote.url())
+ .ok_or(Error::UnknownRemote)?
+ .to_owned();
+ let is_http = url.starts_with("http");
+ Ok(is_http)
+}
+
/// extract username and password
pub fn extract_username_password(
repo_path: &RepoPath,
@@ -71,6 +92,34 @@ pub fn extract_username_password(
})
}
+/// extract username and password
+pub fn extract_username_password_for_push(
+ repo_path: &RepoPath,
+) -> Result<BasicAuthCredential> {
+ let repo = repo(repo_path)?;
+ let url = repo
+ .find_remote(&get_default_remote_for_push_in_repo(&repo)?)?
+ .url()
+ .ok_or(Error::UnknownRemote)?
+ .to_owned();
+ let mut helper = CredentialHelper::new(&url);
+
+ //TODO: look at Cred::credential_helper,
+ //if the username is in the url we need to set it here,
+ //I dont think `config` will pick it up
+
+ if let Ok(config) = repo.config() {
+ helper.config(&config);
+ }
+
+ Ok(match helper.execute() {
+ Some((username, password)) => {
+ BasicAuthCredential::new(Some(username), Some(password))
+ }
+ None => extract_cred_from_url(&url),
+ })
+}
+
/// extract credentials from url
pub fn extract_cred_from_url(url: &str) -> BasicAuthCredential {
url::Url::parse(url).map_or_else(
diff --git a/asyncgit/src/sync/mod.rs b/asyncgit/src/sync/mod.rs
index c188f28c..f5334ba4 100644
--- a/asyncgit/src/sync/mod.rs
+++ b/asyncgit/src/sync/mod.rs
@@ -5,7 +5,7 @@
pub mod blame;
pub mod branch;
-mod commit;
+pub mod commit;
mod commit_details;
pub mod commit_files;
mod commit_filter;
@@ -25,6 +25,7 @@ pub mod remotes;
mod repository;
mod reset;
mod reword;
+pub mod sign;
mod staging;
mod stash;
mod state;
@@ -78,8 +79,8 @@ pub use merge::{
};
pub use rebase::rebase_branch;
pub use remotes::{
- get_default_remote, get_remotes, push::AsyncProgress,
- tags::PushTagsProgress,
+ get_default_remote, get_default_remote_for_push, get_remotes,
+ push::AsyncProgress, tags::PushTagsProgress,
};
pub(crate) use repository::repo;
pub use repository::{RepoPath, RepoPathRef};
diff --git a/asyncgit/src/sync/remotes/mod.rs b/asyncgit/src/sync/remotes/mod.rs
index 3c68dcec..a271efc0 100644
--- a/asyncgit/src/sync/remotes/mod.rs
+++ b/asyncgit/src/sync/remotes/mod.rs
@@ -51,6 +51,81 @@ pub fn get_default_remote(repo_path: &RepoPath) -> Result<String> {
get_default_remote_in_repo(&repo)
}
+/// Gets the current branch the user is on.
+/// Returns none if they are not on a branch
+/// and Err if there was a problem finding the branch
+fn get_current_branch(
+ repo: &Repository,
+) -> Result<Option<git2::Branch>> {
+ for b in repo.branches(None)? {
+ let branch = b?.0;
+ if branch.is_head() {
+ return Ok(Some(branch));
+ }
+ }
+ Ok(None)
+}
+
+/// Tries to find the default repo to push to based on configuration.
+///
+/// > remote.pushDefault
+/// >
+/// > The remote to push to by default. Overrides `branch.<name>.remote` for all branches, and is
+/// > overridden by `branch.<name>.pushRemote` for specific branches.
+///
+/// > branch.<name>.remote
+/// >
+/// > When on branch `<name>`, it tells `git fetch` and `git push` which remote to fetch from or
+/// > push to. The remote to push to may be overridden with `remote.pushDefault` (for all
+/// > branches). The remote to push to, for the current branch, may be further overridden by
+/// > `branch.<name>.pushRemote`. If no remote is configured, or if you are not on any branch and
+/// > there is more than one remote defined in the repository, it defaults to `origin` for fetching
+/// > and `remote.pushDefault` for pushing.
+///
+/// [git-config-remote-push-default]: https://git-scm.com/docs/git-config#Documentation/git-config.txt-remotepushDefault
+/// [git-config-branch-name-remote]: https://git-scm.com/docs/git-config#Documentation/git-config.txt-branchltnamegtremote
+///
+/// Falls back to `get_default_remote_in_repo`.
+pub fn get_default_remote_for_push(
+ repo_path: &RepoPath,
+) -> Result<String> {
+ let repo = repo(repo_path)?;
+ get_default_remote_for_push_in_repo(&repo)
+}
+
+pub(crate) fn get_default_remote_for_push_in_repo(
+ repo: &Repository,
+) -> Result<String> {
+ scope_time!("get_default_remote_for_push_in_repo");
+
+ let config = repo.config()?;
+
+ let branch = get_current_branch(repo)?;
+
+ if let Some(branch) = branch {
+ let remote_name = bytes2string(branch.name_bytes()?)?;
+
+ let entry_name =
+ format!("branch.{}.pushRemote", &remote_name);
+
+ if let Ok(entry) = config.get_entry(&entry_name) {
+ return bytes2string(entry.value_bytes());
+ }
+
+ if let Ok(entry) = config.get_entry("remote.pushDefault") {
+ return bytes2string(entry.value_bytes());
+ }
+
+ let entry_name = format!("branch.{}.remote", &remote_name);
+
+ if let Ok(entry) = config.get_entry(&entry_name) {
+ return bytes2string(entry.value_bytes());
+ }
+ }
+
+ get_default_remote_in_repo(repo)
+}
+
/// see `get_default_remote`
pub(crate) fn get_default_remote_in_repo(
repo: &Repository,
@@ -299,9 +374,68 @@ mod tests {
]
);
- let res =
+ let default_remote =
get_default_remote_in_repo(&repo(repo_path).unwrap());
- assert_eq!(res.is_err(), true);
- assert!(matches!(res, Err(Error::NoDefaultRemoteFound)));
+
+ assert!(matches!(
+ default_remote,
+ Err(Error::NoDefaultRemoteFound)
+ ));
+ }
+
+ #[test]
+ fn test_default_remote_for_push() {
+ let (remote_dir, _remote) = repo_init().unwrap();
+ let remote_path = remote_dir.path().to_str().unwrap();
+ let (repo_dir, repo) = repo_clone(remote_path).unwrap();
+ let repo_path: &RepoPath = &repo_dir
+ .into_path()
+ .as_os_str()
+ .to_str()
+ .unwrap()
+ .into();
+
+ debug_cmd_print(
+ repo_path,
+ "git remote rename origin alternate",
+ );
+
+ debug_cmd_print(
+ repo_path,
+ &format!("git remote add someremote {remote_path}")[..],
+ );
+
+ let mut config = repo.config().unwrap();
+
+ config
+ .set_str("branch.master.remote", "branchremote")
+ .unwrap();
+
+ let default_push_remote =
+ get_default_remote_for_push_in_repo(&repo);
+
+ assert!(
+ matches!(default_push_remote, Ok(remote_name) if remote_name == "branchremote")
+ );
+
+ config.set_str("remote.pushDefault", "pushdefault").unwrap();
+
+ let default_push_remote =
+ get_default_remote_for_push_in_repo(&repo);
+
+ assert!(
+ matches!(default_push_remote, Ok(remote_name) if remote_name == "pushdefault")
+ );
+
+ config
+ .set_str("branch.master.pushRemote", "branchpushremote")
+ .unwrap();
+
+ let default_push_remote =
+ get_default_remote_for_push_in_repo(&repo);
+
+ assert!(
+ matches!(default_push_remote, Ok(remote_name) if remote_name == "branchpushremote")
+ );
}
}
diff --git a/asyncgit/src/sync/remotes/push.rs b/asyncgit/src/sync/remotes/push.rs
index 37cdd4a9..dab50f79 100644
--- a/asyncgit/src/sync/remotes/push.rs
+++ b/asyncgit/src/sync/remotes/push.rs
@@ -2,7 +2,7 @@ use crate::{
error::{Error, Result},
progress::ProgressPercent,
sync::{
- branch::branch_set_upstream,
+ branch::branch_set_upstream_after_push,
cred::BasicAuthCredential,
remotes::{proxy_auto, Callbacks},
repository::repo,
@@ -176,7 +176,7 @@ pub fn push_raw(
}
if !delete {
- branch_set_upstream(&repo, branch)?;
+ branch_set_upstream_after_push(&repo, branch)?;
}
Ok(())
diff --git a/asyncgit/src/sync/reword.rs b/asyncgit/src/sync/reword.rs
index aa1786fc..c20d252b 100644
--- a/asyncgit/src/sync/reword.rs
+++ b/asyncgit/src/sync/reword.rs
@@ -3,7 +3,7 @@ use git2::{Oid, RebaseOptions, Repository};
use super::{
commit::signature_allow_undefined_name,
repo,
- utils::{bytes2string, get_head_refname},
+ utils::{bytes2string, get_head_refname, get_head_repo},
CommitId, RepoPath,
};
use crate::error::{Error, Result};
@@ -15,6 +15,32 @@ pub fn reword(
message: &str,
) -> Result<CommitId> {
let repo = repo(repo_path)?;
+ let config = repo.config()?;
+
+ if config.get_bool("commit.gpgsign").unwrap_or(false) {
+ // HACK: we undo the last commit and create a new one
+ use crate::sync::utils::undo_last_commit;
+
+ let head = get_head_repo(&repo)?;
+ if head == commit {
+ // Check if there are any staged changes
+ let parent = repo.find_commit(head.into())?;
+ let tree = parent.tree()?;
+ if repo
+ .diff_tree_to_index(Some(&tree), None, None)?
+ .deltas()
+ .len() == 0
+ {
+ undo_last_commit(repo_path)?;
+ return super::commit(repo_path, message);
+ }
+
+ return Err(Error::SignRewordLastCommitStaged);
+ }
+
+ return Err(Error::SignRewordNonLastCommit);
+ }
+
let cur_branch_ref = get_head_refname(&repo)?;
match reword_internal(&repo, commit.get_oid(), message) {
diff --git a/asyncgit/src/sync/sign.rs b/asyncgit/src/sync/sign.rs
new file mode 100644
index 00000000..53effe24
--- /dev/null
+++ b/asyncgit/src/sync/sign.rs
@@ -0,0 +1,440 @@
+//! Sign commit data.
+
+use ssh_key::{HashAlg, LineEnding, PrivateKey};
+use std::path::PathBuf;
+
+/// Error type for [`SignBuilder`], used to create [`Sign`]'s
+#[derive(thiserror::Error, Debug)]
+pub enum SignBuilderError {
+ /// The given format is invalid
+ #[error("Failed to derive a commit signing method from git configuration 'gpg.format': {0}")]
+ InvalidFormat(String),
+
+ /// The GPG signing key could
+ #[error("Failed to retrieve 'user.signingkey' from the git configuration: {0}")]
+ GPGSigningKey(String),
+
+ /// The SSH signing key could
+ #[error("Failed to retrieve 'user.signingkey' from the git configuration: {0}")]
+ SSHSigningKey(String),
+
+ /// No signing signature could be built from the configuration data present
+ #[error("Failed to build signing signature: {0}")]
+ Signature(String),
+
+ /// Failure on unimplemented signing methods
+ /// to be removed once all methods have been implemented
+ #[error("Select signing method '{0}' has not been implemented")]
+ MethodNotImplemented(String),
+}
+
+/// Error type for [`Sign`], used to sign data
+#[derive(thiserror::Error, Debug)]
+pub enum SignError {
+ /// Unable to spawn process
+ #[error("Failed to spawn signing process: {0}")]
+ Spawn(String),
+
+ /// Unable to acquire the child process' standard input to write the commit data for signing
+ #[error("Failed to acquire standard input handler")]
+ Stdin,
+
+ /// Unable to write commit data to sign to standard input of the child process
+ #[error("Failed to write buffer to standard input of signing process: {0}")]
+ WriteBuffer(String),
+
+ /// Unable to retrieve the signed data from the child process
+ #[error("Failed to get output of signing process call: {0}")]
+ Output(String),
+
+ /// Failure of the child process
+ #[error("Failed to execute signing process: {0}")]
+ Shellout(String),
+}
+
+/// Sign commit data using various methods
+pub trait Sign {
+ /// Sign commit with the respective implementation.
+ ///
+ /// Retrieve an implementation using [`SignBuilder::from_gitconfig`].
+ ///
+ /// The `commit` buffer can be created using the following steps:
+ /// - create a buffer using [`git2::Repository::commit_create_buffer`]
+ ///
+ /// The function returns a tuple of `signature` and `signature_field`.
+ /// These values can then be passed into [`git2::Repository::commit_signed`].
+ /// Finally, the repository head needs to be advanced to the resulting commit ID
+ /// using [`git2::Reference::set_target`].
+ fn sign(
+ &self,
+ commit: &[u8],
+ ) -> Result<(String, Option<String>), SignError>;
+
+ #[cfg(test)]
+ fn program(&self) -> &String;
+
+ #[cfg(test)]
+ fn signing_key(&self) -> &String;
+}
+
+/// A builder to facilitate the creation of a signing method ([`Sign`]) by examining the git configuration.
+pub struct SignBuilder;
+
+impl SignBuilder {
+ /// Get a [`Sign`] from the given repository configuration to sign commit data
+ ///
+ ///
+ /// ```no_run
+ /// use asyncgit::sync::sign::SignBuilder;
+ /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
+ ///
+ /// /// Repo in a temporary directory for demonstration
+ /// let dir = std::env::temp_dir();
+ /// let repo = git2::Repository::init(dir)?;
+ ///
+ /// /// Get the config from the repository
+ /// let config = repo.config()?;
+ ///
+ /// /// Retrieve a `Sign` implementation
+ /// let sign = SignBuilder::from_gitconfig(&repo, &config)?;
+ /// # Ok(())
+ /// # }
+ /// ```
+ pub fn from_gitconfig(
+ repo: &git2::Repository,
+ config: &git2::Config,
+ ) -> Result<Box<dyn Sign>, SignBuilderError> {
+ let format = config
+ .get_string("gpg.format")
+ .unwrap_or_else(|_| "openpgp".to_string());
+
+ // Variants are described in the git config documentation
+ // https://git-scm.com/docs/git-config#Documentation/git-config.txt-gpgformat
+ match format.as_str() {
+ "openpgp" => {
+ // Try to retrieve the gpg program from the git configuration,
+ // moving from the least to the most specific config key,
+ // defaulting to "gpg" if nothing is explicitly defined (per git's implementation)
+ // https://git-scm.com/docs/git-config#Documentation/git-config.txt-gpgprogram
+ // https://git-scm.com/docs/git-config#Documentation/git-config.txt-gpgprogram
+ let program = config
+ .get_string("gpg.openpgp.program")
+ .or_else(|_| config.get_string("gpg.program"))
+ .unwrap_or_else(|_| "gpg".to_string());
+
+ // Optional signing key.
+ // If 'user.signingKey' is not set, we'll use 'user.name' and 'user.email'
+ // to build a default signature in the format 'name <email>'.
+ // https://git-scm.com/docs/git-config#Documentation/git-config.txt-usersigningKey
+ let signing_key = config
+ .get_string("user.signingKey")
+ .or_else(
+ |_| -> Result<String, SignBuilderError> {
+ Ok(crate::sync::commit::signature_allow_undefined_name(repo)
+ .map_err(|err| {
+ SignBuilderError::Signature(
+ err.to_string(),
+ )
+ })?
+ .to_string())
+ },
+ )
+ .map_err(|err| {
+ SignBuilderError::GPGSigningKey(
+ err.to_string(),
+ )
+ })?;
+
+ Ok(Box::new(GPGSign {
+ program,
+ signing_key,
+ }))
+ }
+ "x509" => Err(SignBuilderError::MethodNotImplemented(
+ String::from("x509"),
+ )),
+ "ssh" => {
+ let ssh_signer = config
+ .get_string("user.signingKey")
+ .ok()
+ .and_then(|key_path| {
+ key_path.strip_prefix('~').map_or_else(
+ || Some(PathBuf::from(&key_path)),
+ |ssh_key_path| {
+ dirs::home_dir().map(|home| {
+ home.join(
+ ssh_key_path
+ .strip_prefix('/')
+ .unwrap_or(ssh_key_path),
+ )
+ })
+ },
+ )
+ })
+ .ok_or_else(|| {
+ SignBuilderError::SSHSigningKey(String::from(
+ "ssh key setting absent",
+ ))
+ })
+ .and_then(SSHSign::new)?;
+ let signer: Box<dyn Sign> = Box::new(ssh_signer);
+ Ok(signer)
+ }
+ _ => Err(SignBuilderError::InvalidFormat(format)),
+ }
+ }
+}
+
+/// Sign commit data using `OpenPGP`
+pub struct GPGSign {
+ program: String,
+ signing_key: String,
+}
+
+impl GPGSign {
+ /// Create new [`GPGSign`] using given program and signing key.
+ pub fn new(program: &str, signing_key: &str) -> Self {
+ Self {
+ program: program.to_string(),
+ signing_key: signing_key.to_string(),
+ }
+ }
+}
+
+impl Sign for GPGSign {
+ fn sign(
+ &self,
+ commit: &[u8],
+ ) -> Result<(String, Option<String>), SignError> {
+ use std::io::Write;
+ use std::process::{Command, Stdio};
+
+ let mut cmd = Command::new(&self.program);
+ cmd.stdin(Stdio::piped())
+ .stdout(Stdio::piped())
+ .stderr(Stdio::piped())
+ .arg("--status-fd=2")
+ .arg("-bsau")
+ .arg(&self.signing_key);
+
+ log::trace!("signing command: {cmd:?}");
+
+ let mut child = cmd
+ .spawn()
+ .map_err(|e| SignError::Spawn(e.to_string()))?;
+
+ let mut stdin = child.stdin.take().ok_or(SignError::Stdin)?;
+
+ stdin
+ .write_all(commit)
+ .map_err(|e| SignError::WriteBuffer(e.to_string()))?;
+ drop(stdin); // close stdin to not block indefinitely
+
+ let output = child
+ .wait_with_output()
+ .map_err(|e| SignError::Output(e.to_string()))?;
+
+ if !output.status.success() {
+ return Err(SignError::Shellout(format!(
+ "failed to sign data, program '{}' exited non-zero: {}",
+ &self.program,
+ std::str::from_utf8(&output.stderr)
+ .unwrap_or("[error could not be read from stderr]")
+ )));
+ }
+
+ let stderr = std::str::from_utf8(&output.stderr)
+ .map_err(|e| SignError::Shellout(e.to_string()))?;
+
+ if !stderr.contains("\n[GNUPG:] SIG_CREATED ") {
+ return Err(SignError::Shellout(
+ format!("failed to sign data, program '{}' failed, SIG_CREATED not seen in stderr", &self.program),
+ ));
+ }
+
+ let signed_commit = std::str::from_utf8(&output.stdout)
+ .map_err(|e| SignError::Shellout(e.to_string()))?;
+
+ Ok((signed_commit.to_string(), Some("gpgsig".to_string())))
+ }
+
+ #[cfg(test)]
+ fn program(&self) -> &String {
+ &self.program
+ }
+
+ #[cfg(test)]
+ fn signing_key(&self) -> &String {
+ &self.signing_key
+ }
+}
+
+/// Sign commit data using `SSHDiskKeySign`
+pub struct SSHSign {
+ #[cfg(test)]
+ program: String,
+ #[cfg(test)]
+ key_path: String,
+ secret_key: PrivateKey,
+}
+
+impl SSHSign {
+ /// Create new [`SSHDiskKeySign`] for sign.
+ pub fn new(mut key: PathBuf) -> Result<Self, SignBuilderError> {
+ key.set_extension("");
+ if key.is_file() {
+ #[cfg(test)]
+ let key_path = format!("{}", &key.display());
+ std::fs::read(key)
+ .ok()
+ .and_then(|bytes| {
+ PrivateKey::from_openssh(bytes).ok()
+ })
+ .map(|secret_key| Self {
+ #[cfg(test)]
+ program: "ssh".to_string(),
+ #[cfg(test)]
+ key_path,
+ secret_key,
+ })
+ .ok_or_else(|| {
+ SignBuilderError::SSHSigningKey(String::from(
+ "Fail to read the private key for sign.",
+ ))
+ })
+ } else {
+ Err(SignBuilderError::SSHSigningKey(
+ String::from("Currently, we only support a pair of ssh key in disk."),
+ ))
+ }
+ }
+}
+
+impl Sign for SSHSign {
+ fn sign(
+ &self,
+ commit: &[u8],
+ ) -> Result<(String, Option<String>), SignError> {
+ let sig = self
+ .secret_key
+ .sign("git", HashAlg::Sha256, commit)
+ .map_err(|err| SignError::Spawn(err.to_string()))?
+ .to_pem(LineEnding::LF)
+ .map_err(|err| SignError::Spawn(err.to_string()))?;
+ Ok((sig, None))
+ }
+
+ #[cfg(test)]
+ fn program(&self) -> &String {
+ &self.program
+ }
+
+ #[cfg(test)]
+ fn signing_key(&self) -> &String {
+ &self.key_path
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::error::Result;
+ use crate::sync::tests::repo_init_empty;
+
+ #[test]
+ fn test_invalid_signing_format() -> Result<()> {
+ let (_temp_dir, repo) = repo_init_empty()?;
+
+ {
+ let mut config = repo.config()?;
+ config.set_str("gpg.format", "INVALID_SIGNING_FORMAT")?;
+ }
+
+ let sign =
+ SignBuilder::from_gitconfig(&repo, &repo.config()?);
+
+ assert!(sign.is_err());
+
+ Ok(())
+ }
+
+ #[test]
+ fn test_program_and_signing_key_defaults() -> Result<()> {
+ let (_tmp_dir, repo) = repo_init_empty()?;
+ let sign =
+ SignBuilder::from_gitconfig(&repo, &repo.config()?)?;
+
+ assert_eq!("gpg", sign.program());
+ assert_eq!("name <email>", sign.signing_key());
+
+ Ok(())
+ }
+
+ #[test]
+ fn test_gpg_program_configs() -> Result<()> {
+ let (_tmp_dir, repo) = repo_init_empty()?;
+
+ {
+ let mut config = repo.config()?;
+ config.set_str("gpg.program", "GPG_PROGRAM_TEST")?;
+ }
+
+ let sign =
+ SignBuilder::from_gitconfig(&am