diff --git a/common/Cargo.toml b/common/Cargo.toml
index 561b4b6be..599c6ee95 100644
--- a/common/Cargo.toml
+++ b/common/Cargo.toml
@@ -21,3 +21,4 @@ config = { workspace = true }
envsubst = "0.2.1"
rand = { workspace = true }
serde_json = { workspace = true }
+regex.workspace = true
diff --git a/common/src/utils.rs b/common/src/utils.rs
index 55982ebb9..b67fe4dda 100644
--- a/common/src/utils.rs
+++ b/common/src/utils.rs
@@ -1,5 +1,6 @@
use idgenerator::IdInstance;
use rand::{distributions::Alphanumeric, thread_rng, Rng};
+use regex::Regex;
use serde_json::{json, Value};
pub const ZERO_ID: &str = match std::str::from_utf8(&[b'0'; 40]) {
@@ -44,3 +45,127 @@ pub fn generate_rich_text(content: &str) -> String {
pub fn mr_ref_name(mr_link: &str) -> String {
format!("refs/heads/{}", mr_link)
}
+
+/// Format commit message with GPG signature
+/// There must be a `blank line`(\n) before `message`, or remote unpack failed.
+/// If there is `GPG signature`,
+/// `blank line` should be placed between `signature` and `message`
+pub fn format_commit_msg(msg: &str, gpg_sig: Option<&str>) -> String {
+ match gpg_sig {
+ None => {
+ format!("\n{}", msg)
+ }
+ Some(gpg) => {
+ format!("{}\n\n{}", gpg, msg)
+ }
+ }
+}
+
+/// parse commit message
+pub fn parse_commit_msg(msg_gpg: &str) -> (String, Option) {
+ const GPG_SIG_START: &str = "gpgsig -----BEGIN PGP SIGNATURE-----";
+ const GPG_SIG_END: &str = "-----END PGP SIGNATURE-----";
+ let gpg_start = msg_gpg.find(GPG_SIG_START);
+ let gpg_end = msg_gpg.find(GPG_SIG_END).map(|end| end + GPG_SIG_END.len());
+ let gpg_sig = match (gpg_start, gpg_end) {
+ (Some(start), Some(end)) => {
+ if start < end {
+ Some(msg_gpg[start..end].to_string())
+ } else {
+ None
+ }
+ }
+ _ => None,
+ };
+ match gpg_sig {
+ Some(gpg) => {
+ // Skip the leading '\n\n' (blank lines).
+ // Some commit messages may use '\n \n\n' or similar patterns.
+ // To handle such cases, remove all leading blank lines from the message.
+ let msg = msg_gpg[gpg_end.unwrap()..].trim_start().to_string();
+ (msg, Some(gpg))
+ }
+ None => {
+ assert!(msg_gpg.starts_with('\n'), "commit message format error");
+ let msg = msg_gpg[1..].to_string(); // skip the leading '\n' (blank line)
+ (msg, None)
+ }
+ }
+}
+
+// check if the commit message is conventional commit
+// ref: https://www.conventionalcommits.org/en/v1.0.0/
+pub fn check_conventional_commits_message(msg: &str) -> bool {
+ let first_line = msg.lines().next().unwrap_or_default();
+ #[allow(unused_variables)]
+ let body_footer = msg.lines().skip(1).collect::>().join("\n");
+
+ let unicode_pattern = r"\p{L}\p{N}\p{P}\p{S}\p{Z}";
+ // type only support characters&numbers, others fields support all unicode characters
+ let regex_str = format!(
+ r"^(?P[\p{{L}}\p{{N}}_-]+)(?:\((?P[{unicode}]+)\))?!?: (?P[{unicode}]+)$",
+ unicode = unicode_pattern
+ );
+
+ let re = Regex::new(®ex_str).unwrap();
+ const RECOMMENDED_TYPES: [&str; 8] = [
+ "build", "chore", "ci", "docs", "feat", "fix", "perf", "refactor",
+ ];
+
+ if let Some(captures) = re.captures(first_line) {
+ let commit_type = captures.name("type").map(|m| m.as_str().to_string());
+ #[allow(unused_variables)]
+ let scope = captures.name("scope").map(|m| m.as_str().to_string());
+ let description = captures.name("description").map(|m| m.as_str().to_string());
+ if commit_type.is_none() || description.is_none() {
+ return false;
+ }
+
+ let commit_type = commit_type.unwrap();
+ if !RECOMMENDED_TYPES.contains(&commit_type.to_lowercase().as_str()) {
+ println!("`{}` is not a recommended commit type, refer to https://www.conventionalcommits.org/en/v1.0.0/ for more information", commit_type);
+ }
+
+ // println!("{}({}): {}\n{}", commit_type, scope.unwrap_or("None".to_string()), description.unwrap(), body_footer);
+
+ return true;
+ }
+ false
+}
+
+#[cfg(test)]
+mod test {
+ use super::*;
+
+ #[test]
+ fn test_check_conventional_commits() {
+ // successfull cases
+ let msg = "feat: add new feature";
+ assert!(check_conventional_commits_message(msg));
+
+ let msg = "fix(common crate): bug fix";
+ assert!(check_conventional_commits_message(msg));
+
+ let msg = "chore(范围)!: 依存関係を更新する";
+ assert!(check_conventional_commits_message(msg));
+
+ let msg = "se_lf-ty9pe(scope)!: Description\n\n여기 시체가 있어요\n\nвот нога";
+ assert!(check_conventional_commits_message(msg));
+
+ let msg = "feat(scope)!: Description\n\n\nbody one\n\nbody two\n\nfooter";
+ assert!(check_conventional_commits_message(msg));
+
+ // failed casesmsg
+ let msg = "feat:add new feature"; // missing ' ' before ':'
+ assert!(!check_conventional_commits_message(msg));
+
+ let msg = "fix(common crate)bug fix"; // missing ':'
+ assert!(!check_conventional_commits_message(msg));
+
+ let msg = "類@型(common): add new feature"; // unssupported characters in type
+ assert!(!check_conventional_commits_message(msg));
+
+ let msg = "()(common): add new feature"; // unssupported characters in type
+ assert!(!check_conventional_commits_message(msg));
+ }
+}
diff --git a/libra/Cargo.toml b/libra/Cargo.toml
index 3b6346a9f..ce6348bf1 100644
--- a/libra/Cargo.toml
+++ b/libra/Cargo.toml
@@ -17,6 +17,7 @@ chrono = { workspace = true }
clap = { workspace = true, features = ["derive"] }
color-backtrace = "0.6.1"
colored = { workspace = true }
+common = { workspace = true }
flate2 = { workspace = true } # add features = ["zlib"] if slow
futures = { workspace = true }
futures-util = { workspace = true }
diff --git a/libra/src/command/branch.rs b/libra/src/command/branch.rs
index 0314f10d8..2b3d7cabf 100644
--- a/libra/src/command/branch.rs
+++ b/libra/src/command/branch.rs
@@ -234,6 +234,7 @@ mod tests {
let commit_args = CommitArgs {
message: "first".to_string(),
allow_empty: true,
+ conventional: false,
};
commit::execute(commit_args).await;
let first_commit_id = Branch::find_branch("master", None).await.unwrap().commit;
@@ -241,6 +242,7 @@ mod tests {
let commit_args = CommitArgs {
message: "second".to_string(),
allow_empty: true,
+ conventional: false,
};
commit::execute(commit_args).await;
let second_commit_id = Branch::find_branch("master", None).await.unwrap().commit;
@@ -318,6 +320,7 @@ mod tests {
let args = CommitArgs {
message: "first".to_string(),
allow_empty: true,
+ conventional: false,
};
commit::execute(args).await;
let hash = Head::current_commit().await.unwrap();
@@ -349,6 +352,7 @@ mod tests {
let args = CommitArgs {
message: "first".to_string(),
allow_empty: true,
+ conventional: false,
};
commit::execute(args).await;
diff --git a/libra/src/command/commit.rs b/libra/src/command/commit.rs
index b87888c46..7d5f1fdde 100644
--- a/libra/src/command/commit.rs
+++ b/libra/src/command/commit.rs
@@ -6,14 +6,15 @@ use crate::internal::head::Head;
use crate::utils::client_storage::ClientStorage;
use crate::utils::path;
use crate::utils::util;
-use mercury::internal::index::Index;
use clap::Parser;
+use common::utils::{check_conventional_commits_message, format_commit_msg};
use mercury::hash::SHA1;
+use mercury::internal::index::Index;
use mercury::internal::object::commit::Commit;
use mercury::internal::object::tree::{Tree, TreeItem, TreeItemMode};
use mercury::internal::object::ObjectTrait;
-use super::{format_commit_msg, save_object};
+use super::save_object;
#[derive(Parser, Debug)]
pub struct CommitArgs {
@@ -22,6 +23,9 @@ pub struct CommitArgs {
#[arg(long)]
pub allow_empty: bool,
+
+ #[arg(long, requires("message"))]
+ pub conventional: bool,
}
pub async fn execute(args: CommitArgs) {
@@ -30,7 +34,12 @@ pub async fn execute(args: CommitArgs) {
let storage = ClientStorage::init(path::objects());
let tracked_entries = index.tracked_entries(0);
if tracked_entries.is_empty() && !args.allow_empty {
- panic!("fatal: no changes added to commit, use --allow-empty to override");
+ println!("fatal: no changes added to commit, use --allow-empty to override");
+ return;
+ }
+ if args.conventional && !check_conventional_commits_message(&args.message) {
+ println!("fatal: commit message does not follow conventional commits");
+ return;
}
/* Create tree */
@@ -39,7 +48,11 @@ pub async fn execute(args: CommitArgs) {
/* Create & save commit objects */
let parents_commit_ids = get_parents_ids().await;
// There must be a `blank line`(\n) before `message`, or remote unpack failed
- let commit = Commit::from_tree_id(tree.id, parents_commit_ids, &format_commit_msg(&args.message, None));
+ let commit = Commit::from_tree_id(
+ tree.id,
+ parents_commit_ids,
+ &format_commit_msg(&args.message, None),
+ );
// TODO default signature created in `from_tree_id`, wait `git config` to set correct user info
@@ -161,6 +174,23 @@ mod test {
};
use super::*;
+ #[test]
+ fn test_parse_args() {
+ let args = CommitArgs::try_parse_from(["commit", "-m", "init"]);
+ assert!(args.is_ok());
+
+ let args = CommitArgs::try_parse_from(["commit", "-m", "init", "--allow-empty"]);
+ assert!(args.is_ok());
+
+ let args = CommitArgs::try_parse_from(["commit", "--conventional", "-m", "init"]);
+ assert!(args.is_ok());
+
+ let args = CommitArgs::try_parse_from(["commit", "--conventional"]);
+ assert!(args.is_err(), "conventional should require message");
+
+ let args = CommitArgs::try_parse_from(["commit"]);
+ assert!(args.is_err(), "message is required");
+ }
#[tokio::test]
async fn test_create_tree() {
@@ -191,6 +221,7 @@ mod test {
let args = CommitArgs {
message: "init".to_string(),
allow_empty: false,
+ conventional: false,
};
execute(args).await;
}
@@ -203,6 +234,7 @@ mod test {
let args = CommitArgs {
message: "init".to_string(),
allow_empty: true,
+ conventional: false,
};
execute(args).await;
@@ -219,7 +251,7 @@ mod test {
let branch = Branch::find_branch(&branch_name, None).await.unwrap();
assert_eq!(branch.commit, commit.id);
}
-
+
// create a new commit
{
// create `a.txt` `bb/b.txt` `bb/c.txt`
@@ -239,6 +271,7 @@ mod test {
let args = CommitArgs {
message: "add some files".to_string(),
allow_empty: false,
+ conventional: false,
};
execute(args).await;
diff --git a/libra/src/command/log.rs b/libra/src/command/log.rs
index 8524555ee..bb1531741 100644
--- a/libra/src/command/log.rs
+++ b/libra/src/command/log.rs
@@ -16,7 +16,7 @@ use std::str::FromStr;
use mercury::hash::SHA1;
use mercury::internal::object::commit::Commit;
-use super::parse_commit_msg;
+use common::utils::parse_commit_msg;
#[derive(Parser, Debug)]
pub struct LogArgs {
/// Limit the number of output
diff --git a/libra/src/command/mod.rs b/libra/src/command/mod.rs
index a882c5dc7..a09bb9ef8 100644
--- a/libra/src/command/mod.rs
+++ b/libra/src/command/mod.rs
@@ -86,53 +86,6 @@ pub fn ask_basic_auth() -> BasicAuth {
BasicAuth { username, password }
}
-/// Format commit message with GPG signature
-/// There must be a `blank line`(\n) before `message`, or remote unpack failed.
-/// If there is `GPG signature`,
-/// `blank line` should be placed between `signature` and `message`
-pub fn format_commit_msg(msg: &str, gpg_sig: Option<&str>) -> String {
- match gpg_sig {
- None => {
- format!("\n{}", msg)
- }
- Some(gpg) => {
- format!("{}\n\n{}", gpg, msg)
- }
- }
-}
-
-/// parse commit message
-pub fn parse_commit_msg(msg_gpg: &str) -> (String, Option) {
- const GPG_SIG_START: &str = "gpgsig -----BEGIN PGP SIGNATURE-----";
- const GPG_SIG_END: &str = "-----END PGP SIGNATURE-----";
- let gpg_start = msg_gpg.find(GPG_SIG_START);
- let gpg_end = msg_gpg.find(GPG_SIG_END).map(|end| end + GPG_SIG_END.len());
- let gpg_sig = match (gpg_start, gpg_end) {
- (Some(start), Some(end)) => {
- if start < end {
- Some(msg_gpg[start..end].to_string())
- } else {
- None
- }
- }
- _ => None,
- };
- match gpg_sig {
- Some(gpg) => {
- // Skip the leading '\n\n' (blank lines).
- // Some commit messages may use '\n \n\n' or similar patterns.
- // To handle such cases, remove all leading blank lines from the message.
- let msg = msg_gpg[gpg_end.unwrap()..].trim_start().to_string();
- (msg, Some(gpg))
- }
- None => {
- assert!(msg_gpg.starts_with('\n'), "commit message format error");
- let msg = msg_gpg[1..].to_string(); // skip the leading '\n' (blank line)
- (msg, None)
- }
- }
-}
-
/// Calculate the hash of a file blob
/// - for `lfs` file: calculate hash of the pointer data
pub fn calc_file_blob_hash(path: impl AsRef) -> io::Result {
@@ -174,6 +127,7 @@ pub async fn get_target_commit(branch_or_commit: &str) -> Result