前言
gitlib 本身没有比较详细的代码贡献视图,如果我们想查看每一个开发人员在不同项目上的贡献情况,包括提交次数、删除数、增加数、代码总量等等指标,以及拉出来每次提交的具体信息来看看是否符合团队规范,目前存在的一些开源工具是完全无法实现的。
所以这种高度定制化的需求那就必须要自己写一个脚本去跑数据了。
最初,我用js实现了一版,可以直接在node中使用,是很方便,但是性能太差,如果需要统计的仓库很多,那么会需要很长时间,所以索性我们将它改为Rust版本的试试,整体功能保持和js版本一致。
需求开发
功能描述
那么我们要实现哪些功能呢?
首先,按作者统计代码提交量,统计不同开发人员在一个项目组内不同项目上的贡献量和总计,如下表格:
作者 | 邮箱 | 项目 | 提交次数 | 增加行数 | 删除行数 | 变更行数 | 文件数 | 代码量(KB) |
---|---|---|---|---|---|---|---|---|
zhangsan | zhangsan@gmail.com | 【总计】 | 39 | 9520 | 1900 | 11420 | 151 | 352.17 |
zhangsan | zhangsan@gmail.com | A | 7 | 65 | 98 | 163 | 10 | 10.79 |
zhangsan | zhangsan@gmail.com | B | 26 | 8953 | 1480 | 10433 | 118 | 307.64 |
zhangsan | zhangsan@gmail.com | C | 6 | 502 | 322 | 824 | 23 | 33.74 |
按作者列出所有提交,可以查看具体提交信息,如下表格:
作者 | 邮箱 | 项目 | 分支名 | 标签 | 提交时间 | 提交信息 |
---|---|---|---|---|---|---|
zhangsan | zhangsan@gmail.com | A | dev | v1.0.0 | 2024-12-04 10:12:44 | feat: xxx |
zhangsan | zhangsan@gmail.com | B | dev | v1.0.1 | 2024-12-03 17:01:12 | chore: xxx |
zhangsan | zhangsan@gmail.com | C | dev | v1.0.0 | 2024-12-03 18:06:50 | fix: xxx |
当你发现某个人在某个项目上有大量代码提交,就可以查看他的提交都是什么,或者某个人在多个项目上有相同的代码量提交,也可以看一下具体提交信息是否是对框架进行了统一改动等等。以上两种统计结果可以配合使用。
开发思路
gitlib 提供 api 可以让我们在脚本中调用,来获取仓库、代码提交等信息
具体用到的 api 有:
- 根据组id获取该组下的所有项目,最大返回100个,可以分页获取,按最后活跃时间排序
bash
GET /groups/:id/projects?per_page=100&include_subgroups=true&order_by=last_activity_at&sort=desc
- 获取该组下所有项目在指定时间范围内的提交信息,默认返回100个,可以分页获取,按提交时间排序,获取所有分支(因为此时有些开发分支还未合并),并带上统计信息
bash
GET /projects/:id/repository/commits?since=2024-12-01&until=2024-12-31&per_page=100&page=1&all=true&with_stats=true
- 获取某个提交的变更信息,统计新增、删除、修改行数,文件数
bash
GET /projects/:id/repository/commits/:sha/diff
- 获取提交对应的分支信息,拿到分支名、标签名
bash
GET /projects/:id/repository/commits/:sha/refs
其他api的用法可以参考 gitlab.cn/docs/jh/api...
准备好这些api,还不着急开发,还有一些点需要考虑:
- 并发请求控制
项目几十个,提交信息几百条,一个一个请求分析那得搞到猴年马月,所以需要并发请求。
- 自动重试机制
但是并发请求如果服务器qps不高的话很容易超时,又会导致请求失败,那么统计的结果可能就是不准确的,所以要进行多次重试。多次重试仍旧失败的,则记录下来,后续人工处理。
- 生成 Markdown 格式报告
再加一个错误报告
项目 | 作者 | 操作 | URL | 错误信息 |
---|
- 过滤掉一些不关心的项目
- 支持自定义文件类型过滤
例如有些文件如 package.json
等,统计意义不大。
- 支持忽略特定路径文件
例如有些文件夹如 dist
等,完全没必要统计。
用Rust开发
创建一个Rust项目:
- 新建文件夹
gitlab-analysis
- 在文件夹中执行
cargo init
- 在
Cargo.toml
中添加依赖 - 在
src/main.rs
中编写主入口 - 在
src/gitlab.rs
中编写gitlab api 请求 - 在
src/models.rs
中编写配置和模型 - 在
src/report.rs
中编写报告生成逻辑
toml
# Cargo.toml
[package]
name = "gitlab_analysis"
version = "0.1.0"
edition = "2021"
[dependencies]
tokio = { version = "1.36", features = ["full"] }
reqwest = { version = "0.11", features = ["json"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
anyhow = "1.0"
chrono = { version = "0.4", features = ["serde"] }
async-trait = "0.1"
futures = "0.3"
regex = "1.5"
lazy_static = "1.4"
rust
// src/main.rs
mod gitlab;
mod models;
mod report;
use anyhow::Result;
use gitlab::GitLabClient;
use models::Config;
#[tokio::main]
async fn main() -> Result<()> {
let start_time = std::time::Instant::now();
let config = Config::default();
let client = GitLabClient::new(config);
println!("开始分析 GitLab 仓库...");
client.analyze_gitlab_projects().await?;
let duration = start_time.elapsed();
println!("\n分析完成! 总耗时: {:.2}秒", duration.as_secs_f64());
Ok(())
}
rust
// src/gitlab.rs
use crate::models::*;
use crate::report::generate_report;
use anyhow::Result;
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::Mutex;
use tokio::sync::Semaphore;
#[derive(Clone)]
pub struct GitLabClient {
client: reqwest::Client,
config: Config,
failure_stats: Arc<Mutex<FailureStats>>,
}
// 添加预编译的正则表达式
lazy_static::lazy_static! {
static ref MERGE_BRANCH_RE: regex::Regex = regex::Regex::new(r"Merge branch '([^']+)'").unwrap();
}
impl GitLabClient {
pub fn new(config: Config) -> Self {
let client = reqwest::Client::builder()
.connect_timeout(std::time::Duration::from_secs(2))
.timeout(std::time::Duration::from_secs(3))
.build()
.expect("Failed to create HTTP client");
Self {
client,
config,
failure_stats: Arc::new(Mutex::new(FailureStats::default())),
}
}
async fn record_failure(&self, context: &RequestContext, url: &str, error: &str) {
let mut stats = self.failure_stats.lock().await;
let record = FailureRecord {
url: url.to_string(),
operation: context.operation.clone(),
project_name: context.project_name.clone(),
author_email: context.author_email.clone(),
error: error.to_string(),
};
match context.operation.as_str() {
"获取项目列表" => stats.projects.push(record),
"获取提交记录" => stats.commits.push(record),
"获取提交差异" => stats.diffs.push(record),
_ => {}
}
}
async fn fetch_with_retry<T>(&self, url: &str, context: RequestContext) -> Result<T>
where
T: for<'de> serde::de::Deserialize<'de>,
{
let mut attempts = 0;
let max_attempts = 30;
loop {
attempts += 1;
println!(
"\n[{}请求开始] 项目: {}, 第 {} 次尝试",
context.operation,
context.project_name.clone().unwrap_or_default(),
attempts
);
println!("URL: {}", url);
let start = std::time::Instant::now();
match self
.client
.get(url)
.header("Private-Token", self.config.gitlab_token.clone())
// .header("Cookie", self.config.gitlab_cookie.clone())
.header("Content-Type", "application/json")
.send()
.await
{
Ok(response) => {
// let duration = start.elapsed();
// println!("[请求完成] 耗时: {}ms", duration.as_millis());
if response.status().is_success() {
match response.json::<T>().await {
Ok(data) => return Ok(data),
Err(e) => {
println!("解析响应失败: {}", e);
if attempts == max_attempts {
self.record_failure(&context, url, &e.to_string()).await;
return Err(e.into());
}
}
}
} else {
println!("HTTP 错误: {}", response.status());
if attempts == max_attempts {
self.record_failure(
&context,
url,
&format!("HTTP {}", response.status()),
)
.await;
return Err(anyhow::anyhow!("HTTP error: {}", response.status()));
}
}
}
Err(e) => {
let duration = start.elapsed();
println!(
"[请求失败] 第 {} 次尝试,耗时: {}ms",
attempts,
duration.as_millis()
);
println!("请求失败: {}", e);
if attempts == max_attempts {
self.record_failure(&context, url, &e.to_string()).await;
return Err(e.into());
}
}
}
println!("立即开始第 {} 次重试...", attempts + 1);
}
}
async fn get_group_projects(&self) -> Result<Vec<Project>> {
let url = format!(
"{}/groups/{}/projects?per_page={}&include_subgroups=true&order_by=last_activity_at&sort=desc",
self.config.gitlab_api, self.config.group_id, self.config.projects_num
);
let context = RequestContext {
operation: "获取项目列表".to_string(),
project_name: None,
author_email: None,
};
let projects: Vec<Project> = self.fetch_with_retry(&url, context).await?;
println!("[获取成功] 找到 {} 个项目", projects.len());
Ok(projects)
}
async fn get_project_commits(
&self,
project_id: i64,
project_name: &str,
) -> Result<Vec<Commit>> {
let mut all_commits = Vec::new();
let mut page = 1;
loop {
let url = format!(
"{}/projects/{}/repository/commits?since={}&until={}&per_page=100&page={}&all=true",
self.config.gitlab_api,
project_id,
self.config.start_date,
self.config.end_date,
page
);
let context = RequestContext {
operation: "获取提交记录".to_string(),
project_name: Some(project_name.to_string()),
author_email: None,
};
let commits: Vec<Commit> = self.fetch_with_retry(&url, context).await?;
let commits_len = commits.len();
all_commits.extend(commits);
if commits_len < 100 {
break;
}
page += 1;
println!(
"正在获取第 {} 页的提交记录...,本页 {} 条",
page, commits_len
);
}
println!(
"找到 projectId: {} 的 {} 个提交",
project_id,
all_commits.len()
);
Ok(all_commits)
}
async fn analyze_commit_diffs(
&self,
project_id: i64,
sha: &str,
project_name: &str,
author_email: &str,
) -> Result<ProjectStats> {
let url = format!(
"{}/projects/{}/repository/commits/{}/diff",
self.config.gitlab_api, project_id, sha
);
let context = RequestContext {
operation: "获取提交差异".to_string(),
project_name: Some(project_name.to_string()),
author_email: Some(author_email.to_string()),
};
let diffs: Vec<Diff> = self.fetch_with_retry(&url, context).await?;
let mut stats = ProjectStats::default();
for diff in diffs {
let file_path = diff.new_path.or(diff.old_path).unwrap_or_default();
if self
.config
.ignored_paths
.iter()
.any(|path| file_path.contains(path))
{
continue;
}
let ext = file_path
.split('.')
.last()
.map(|s| format!(".{}", s))
.unwrap_or_default();
if !self.config.valid_extensions.contains(&ext) {
continue;
}
stats.files += 1;
if let Some(diff_content) = diff.diff {
let lines = diff_content.lines();
for line in lines {
if line.starts_with('+') && !line.starts_with("+++") {
stats.lines += 1;
stats.added_lines += 1;
} else if line.starts_with('-') && !line.starts_with("---") {
stats.lines += 1;
stats.deleted_lines += 1;
}
}
stats.size += diff_content.len() as i64;
}
}
Ok(stats)
}
async fn get_commit_branches(
&self,
project_id: i64,
commit_sha: &str,
project_name: &str,
author_email: &str,
) -> Result<(String, String)> {
let url = format!(
"{}/projects/{}/repository/commits/{}/refs",
self.config.gitlab_api, project_id, commit_sha
);
let context = RequestContext {
operation: "获取提交对应的分支信息".to_string(),
project_name: Some(project_name.to_string()),
author_email: Some(author_email.to_string()),
};
let refs: Vec<Ref> = self.fetch_with_retry(&url, context).await?;
let branch = refs
.iter()
.find(|r| r.ref_type == "branch")
.map(|r| r.name.clone())
.unwrap_or_else(|| "unknown".to_string());
let tag = refs
.iter()
.find(|r| r.ref_type == "tag")
.map(|r| r.name.clone())
.unwrap_or_else(|| "unknown".to_string());
Ok((branch, tag))
}
pub async fn analyze_gitlab_projects(&self) -> Result<()> {
let mut projects = self.get_group_projects().await?;
projects.retain(|p| !self.config.excluded_projects.contains(&p.name));
println!(
"排除 {} 个项目,实际分析 {} 个项目",
self.config.excluded_projects.len(),
projects.len()
);
let author_stats = Arc::new(Mutex::new(HashMap::new()));
// 创建信号量来限制并发,使用配置的并发量
let semaphore = Arc::new(Semaphore::new(self.config.concurrency as usize));
// 并发处理项目
let project_futures: Vec<_> = projects
.into_iter()
.map(|project| {
let author_stats = Arc::clone(&author_stats);
let self_clone = self.clone();
let sem = Arc::clone(&semaphore);
async move {
// 获取信号量许可
let _permit = sem.acquire().await.unwrap();
println!("-----------正在分析项目------------: {}", project.name);
if let Ok(commits) = self_clone
.get_project_commits(project.id, &project.name)
.await
{
// 创建信号量来限制并发,使用配置的并发量
let commit_semaphore = Arc::new(Semaphore::new(self.config.concurrency as usize));
// 并发处理每个项目的提交
let commit_futures: Vec<_> = commits
.into_iter()
.map(|commit: Commit| {
let author_stats = Arc::clone(&author_stats);
let self_clone = self_clone.clone();
let project_name = project.name.clone();
let commit_sem = Arc::clone(&commit_semaphore);
async move {
// 获取提交级别的信号量许可
let _commit_permit = commit_sem.acquire().await.unwrap();
let author_email = commit.author_email.clone();
// 并发获取提交差异和分支信息
let (diff_stats, branch_info) = tokio::join!(
self_clone.analyze_commit_diffs(
project.id,
&commit.id,
&project_name,
&author_email
),
self_clone.get_commit_branches(
project.id,
&commit.id,
&project_name,
&author_email
)
);
if let (Ok(stats), Ok((branch, tag))) =
(diff_stats, branch_info)
{
let mut author_stats = author_stats.lock().await;
let author_stat = author_stats
.entry(commit.author_name.clone())
.or_insert_with(|| AuthorStats {
author_name: commit.author_name.clone(),
author_email: author_email.clone(),
..Default::default()
});
let project_stat = author_stat
.projects
.entry(project_name.clone())
.or_default();
// 更新项目统计
project_stat.commits += 1;
project_stat.lines += stats.lines;
project_stat.added_lines += stats.added_lines;
project_stat.deleted_lines += stats.deleted_lines;
project_stat.files += stats.files;
project_stat.size += stats.size;
// 更新作者总计
author_stat.total_commits += 1;
author_stat.total_lines += stats.lines;
author_stat.total_added_lines += stats.added_lines;
author_stat.total_deleted_lines += stats.deleted_lines;
author_stat.total_files += stats.files;
author_stat.total_size += stats.size;
let mut branch_name = branch;
if commit.message.starts_with("Merge branch") {
if let Some(captures) =
MERGE_BRANCH_RE.captures(&commit.message)
{
if let Some(matched_branch) = captures.get(1) {
branch_name =
matched_branch.as_str().to_string();
}
}
}
author_stat.commit_details.push(CommitDetail {
project: project_name,
branch: branch_name,
tag,
message: commit.message,
committed_date: commit.committed_date,
});
}
}
})
.collect();
// 等待所有提交处理完成
futures::future::join_all(commit_futures).await;
}
}
})
.collect();
// 等待所有项目处理完成
futures::future::join_all(project_futures).await;
let failure_stats = self.failure_stats.lock().await.clone();
// 获取最终的统计数据
let author_stats_map = {
let lock = author_stats.lock().await;
lock.clone() // 克隆 HashMap 的内容
};
generate_report(&author_stats_map, &self.config, &failure_stats).await?;
Ok(())
}
}
rust
// src/models.rs
use serde::Deserialize;
use std::collections::HashMap;
#[derive(Debug, Clone)]
pub struct Config {
pub gitlab_api: String,
// pub gitlab_cookie: String,
pub gitlab_token: String,
pub group_id: String,
pub start_date: String,
pub end_date: String,
pub excluded_projects: Vec<String>,
pub projects_num: i32,
pub valid_extensions: Vec<String>, // 支持的文件扩展名配置
pub ignored_paths: Vec<String>,
pub concurrency: i32, // 并发量配置
}
impl Default for Config {
fn default() -> Self {
Self {
gitlab_api: "http://gitlab.xxx/api/v4".to_string(),
// gitlab_cookie: "xxx".to_string(), // 如果需要使用cookie,请在这里填写,cookie token 二选一
gitlab_token: "xxx".to_string(),
group_id: "2177".to_string(),
start_date: "2024-12-01".to_string(),
end_date: "2025-01-31".to_string(),
excluded_projects: vec!["project1".to_string(), "project2".to_string()],
projects_num: 10, // 获取该组下的项目数量最大100
valid_extensions: vec![
".js".to_string(),
".cjs".to_string(),
".mjs".to_string(),
".ts".to_string(),
".jsx".to_string(),
".tsx".to_string(),
".css".to_string(),
".scss".to_string(),
".sass".to_string(),
".html".to_string(),
".sh".to_string(),
".vue".to_string(),
".svelte".to_string(),
".rs".to_string(),
],
ignored_paths: vec![
"dist".to_string(),
"node_modules/".to_string(),
"build/".to_string(),
".husky".to_string(),
"lintrc".to_string(),
"public/".to_string(),
],
concurrency: 20, // 默认并发量
}
}
}
#[derive(Debug, Default, Clone)]
pub struct FailureStats {
pub projects: Vec<FailureRecord>,
pub commits: Vec<FailureRecord>,
pub diffs: Vec<FailureRecord>,
pub refs: Vec<FailureRecord>,
}
#[derive(Debug, Clone)]
pub struct FailureRecord {
pub url: String,
pub operation: String,
pub project_name: Option<String>,
pub error: String,
pub author_email: Option<String>,
}
#[derive(Debug, Default, Clone)]
pub struct ProjectStats {
pub commits: i32,
pub lines: i32,
pub added_lines: i32,
pub deleted_lines: i32,
pub files: i32,
pub size: i64,
}
#[derive(Debug, Default, Clone)]
pub struct AuthorStats {
pub author_name: String,
pub author_email: String,
pub projects: HashMap<String, ProjectStats>,
pub total_commits: i32,
pub total_lines: i32,
pub total_added_lines: i32,
pub total_deleted_lines: i32,
pub total_files: i32,
pub total_size: i64,
pub commit_details: Vec<CommitDetail>,
}
#[derive(Debug, Deserialize)]
pub struct Project {
pub id: i64,
pub name: String,
}
#[derive(Debug, Deserialize)]
pub struct Commit {
pub id: String,
pub author_email: String,
pub author_name: String,
pub message: String,
pub committed_date: String
}
#[derive(Debug, Deserialize)]
pub struct Diff {
pub old_path: Option<String>,
pub new_path: Option<String>,
pub diff: Option<String>,
}
#[derive(Debug)]
pub struct RequestContext {
pub operation: String,
pub project_name: Option<String>,
pub author_email: Option<String>,
}
#[derive(Debug, Clone)]
pub struct CommitDetail {
pub project: String,
pub branch: String,
pub tag: String,
pub message: String,
pub committed_date: String,
}
#[derive(Debug, Deserialize)]
pub struct Ref {
pub name: String,
#[serde(rename = "type")]
pub ref_type: String,
}
rust
// src/report.rs
use crate::models::*;
use anyhow::Result;
use std::collections::HashMap;
use tokio::fs;
use chrono::DateTime;
pub async fn generate_report(
author_stats: &HashMap<String, AuthorStats>,
config: &Config,
failure_stats: &FailureStats,
) -> Result<()> {
let mut sorted_authors: Vec<_> = author_stats.iter().collect();
sorted_authors.sort_by_key(|(_, stats)| -stats.total_size);
let mut report = vec![
"# GitLab 代码提交统计报告\n".to_string(),
format!("统计期间: {} 至 {}\n", config.start_date, config.end_date),
"## 按作者统计代码信息\n".to_string(),
"| 作者 | 邮箱 | 项目 | 提交次数 | 变更行数 | 增加行数 | 删除行数 | 文件数 | 代码量(KB) |".to_string(),
"|--------|------|------|----------|----------|----------|----------|---------|------------|".to_string(),
];
// 先生成代码统计信息表格
for (_, stats) in &sorted_authors {
report.push(format!(
"| 【{}】 | {} | 【总计】 | {} | {} | {} | {} | {} | {:.2} |",
stats.author_name,
stats.author_email,
stats.total_commits,
stats.total_lines,
stats.total_added_lines,
stats.total_deleted_lines,
stats.total_files,
stats.total_size as f64 / 1024.0
));
for (project, data) in &stats.projects {
report.push(format!(
"| {} | {} | {} | {} | {} | {} | {} | {} | {:.2} |",
stats.author_name,
stats.author_email,
project,
data.commits,
data.lines,
data.added_lines,
data.deleted_lines,
data.files,
data.size as f64 / 1024.0
));
}
report.push("|--------|------|------|----------|----------|----------|----------|---------|------------|".to_string());
}
// 添加提交信息表格
report.push("\n## 按作者统计提交信息\n".to_string());
report.push("| 作者 | 邮箱 | 项目 | 分支名 | 标签 | 提交时间 | 提交信息 |".to_string());
report.push("|--------|------|------|--------|------|----------|------------|".to_string());
// 生成提交信息表格
for (_, stats) in &sorted_authors {
for detail in &stats.commit_details {
let sanitized_message = detail.message
.replace("|", "\\|")
.replace("\n", " ");
// 解析并格式化时间
let datetime = DateTime::parse_from_rfc3339(&detail.committed_date)
.map(|dt| dt.format("%Y-%m-%d %H:%M:%S").to_string())
.unwrap_or_else(|_| detail.committed_date.clone());
report.push(format!(
"| {} | {} | {} | {} | {} | {} | {} |",
stats.author_name,
stats.author_email,
detail.project,
detail.branch,
detail.tag,
datetime,
sanitized_message
));
}
}
// 添加失败统计
if !failure_stats.projects.is_empty() || !failure_stats.commits.is_empty() || !failure_stats.diffs.is_empty() {
report.push("\n## 统计失败记录\n".to_string());
if !failure_stats.projects.is_empty() {
report.push("### 项目列表获取失败".to_string());
report.push("| 操作 | URL | 错误信息 |".to_string());
report.push("|------|-----|------------|".to_string());
for failure in &failure_stats.projects {
report.push(format!(
"| {} | {} | {} |",
failure.operation, failure.url, failure.error
));
}
}
if !failure_stats.commits.is_empty() {
report.push("\n### 提交记录获取失败".to_string());
report.push("| 项目 | 操作 | URL | 错误信息 |".to_string());
report.push("|------|------|-----|------------|".to_string());
for failure in &failure_stats.commits {
report.push(format!(
"| {} | {} | {} | {} |",
failure.project_name.as_deref().unwrap_or("-"),
failure.operation,
failure.url,
failure.error
));
}
}
if !failure_stats.diffs.is_empty() {
report.push("\n### 提交差异获取失败".to_string());
report.push("| 项目 | 作者 | 操作 | URL | 错误信息 |".to_string());
report.push("|------|------------|------|-----|------------|".to_string());
for failure in &failure_stats.diffs {
report.push(format!(
"| {} | {} | {} | {} | {} |",
failure.project_name.as_deref().unwrap_or("-"),
failure.author_email.as_deref().unwrap_or("-"),
failure.operation,
failure.url,
failure.error
));
}
}
if !failure_stats.refs.is_empty() {
report.push("\n### 提交分支信息获取失败".to_string());
report.push("| 项目 | 作者 | 操作 | URL | 错误信息 |".to_string());
report.push("|------|------------|------|-----|------------|".to_string());
for failure in &failure_stats.refs {
report.push(format!(
"| {} | {} | {} | {} | {} |",
failure.project_name.as_deref().unwrap_or("-"),
failure.author_email.as_deref().unwrap_or("-"),
failure.operation,
failure.url,
failure.error
));
}
}
}
fs::write("gitlab-stats.md", report.join("\n")).await?;
println!("报告已生成: gitlab-stats.md");
Ok(())
}
大功告成,经过多次测试,rust版本平均比js快5倍以上,当然此处的性能提升只是在处理循环遍历和并发上,对于接口本身的耗时肯定是没法提升的,即使这样,我们也看到了rust相较于js之间巨大的性能差异。
另外两个版本实现参考另一篇文章Rust + wasm-pack + WebAssembly 实现 Gitlab 代码统计,比JS快太多了
总结
以上是该工具的完整 Rust 代码,感兴趣的可以研究研究,自己尝试一下。