Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions common/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,4 @@ config = { workspace = true }
envsubst = "0.2.1"
rand = { workspace = true }
serde_json = { workspace = true }
regex.workspace = true
125 changes: 125 additions & 0 deletions common/src/utils.rs
Original file line number Diff line number Diff line change
@@ -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]) {
Expand Down Expand Up @@ -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<br>
/// There must be a `blank line`(\n) before `message`, or remote unpack failed.<br>
/// 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<String>) {
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::<Vec<_>>().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<type>[\p{{L}}\p{{N}}_-]+)(?:\((?P<scope>[{unicode}]+)\))?!?: (?P<description>[{unicode}]+)$",
unicode = unicode_pattern
);

let re = Regex::new(&regex_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));
}
}
1 change: 1 addition & 0 deletions libra/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
4 changes: 4 additions & 0 deletions libra/src/command/branch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -234,13 +234,15 @@ 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;

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;
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -349,6 +352,7 @@ mod tests {
let args = CommitArgs {
message: "first".to_string(),
allow_empty: true,
conventional: false,
};
commit::execute(args).await;

Expand Down
43 changes: 38 additions & 5 deletions libra/src/command/commit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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) {
Expand All @@ -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 */
Expand All @@ -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

Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -191,6 +221,7 @@ mod test {
let args = CommitArgs {
message: "init".to_string(),
allow_empty: false,
conventional: false,
};
execute(args).await;
}
Expand All @@ -203,6 +234,7 @@ mod test {
let args = CommitArgs {
message: "init".to_string(),
allow_empty: true,
conventional: false,
};
execute(args).await;

Expand All @@ -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`
Expand All @@ -239,6 +271,7 @@ mod test {
let args = CommitArgs {
message: "add some files".to_string(),
allow_empty: false,
conventional: false,
};
execute(args).await;

Expand Down
2 changes: 1 addition & 1 deletion libra/src/command/log.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
48 changes: 1 addition & 47 deletions libra/src/command/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -86,53 +86,6 @@ pub fn ask_basic_auth() -> BasicAuth {
BasicAuth { username, password }
}

/// Format commit message with GPG signature<br>
/// There must be a `blank line`(\n) before `message`, or remote unpack failed.<br>
/// 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<String>) {
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<Path>) -> io::Result<SHA1> {
Expand Down Expand Up @@ -174,6 +127,7 @@ pub async fn get_target_commit(branch_or_commit: &str) -> Result<SHA1, Box<dyn s

#[cfg(test)]
mod test {
use common::utils::{format_commit_msg, parse_commit_msg};
use mercury::internal::object::commit::Commit;

use super::*;
Expand Down