Skip to main content

cargo_git_release/
lib.rs

1use anyhow::{Result, anyhow};
2use clap::Parser;
3use regex::Regex;
4use serde::{Deserialize, Serialize};
5use std::fs;
6use std::path::{Path, PathBuf};
7use std::process::Command;
8use walkdir::WalkDir;
9
10#[derive(Parser, Debug)]
11#[command(
12    name = "git-release",
13    author = "thlstsul",
14    about = "自动化 Git 项目发布流程",
15    long_about = "一个用于自动化 Git 项目发布流程的工具,支持版本号更新、提交、打标签和推送到所有远程仓库。支持 workspace 项目。"
16)]
17pub struct Cli {
18    /// 新版本号 (例如: 1.2.3)
19    #[arg(value_name = "VERSION")]
20    version: String,
21
22    /// 重新发布版本(如果标签已存在则删除重新创建)
23    #[arg(long, short = 'r')]
24    re_publish: bool,
25
26    /// 跳过版本号格式验证
27    #[arg(long, short = 'f')]
28    force: bool,
29
30    /// 提交信息模板,{version} 会被替换为实际版本号
31    #[arg(
32        long,
33        short = 'm',
34        default_value = "Release version {version}",
35        value_name = "MESSAGE"
36    )]
37    message: String,
38
39    /// 标签前缀,默认为 'v'
40    #[arg(long, default_value = "v", value_name = "PREFIX")]
41    tag_prefix: String,
42
43    /// 只更新版本号,不执行 Git 操作
44    #[arg(long)]
45    dry_run: bool,
46
47    /// 排除更新的 crate 名称(可多次使用)
48    #[arg(long, value_name = "CRATE")]
49    exclude: Vec<String>,
50
51    /// 只更新指定的 crate(可多次使用),默认更新所有
52    #[arg(long, value_name = "CRATE")]
53    only: Vec<String>,
54}
55
56#[derive(Debug, Clone, Serialize, Deserialize)]
57struct CargoToml {
58    package: Option<CargoPackage>,
59    workspace: Option<CargoWorkspace>,
60    #[serde(flatten)]
61    other: toml::Value,
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize)]
65#[serde(untagged)]
66enum PackageVersion {
67    Direct(String),
68    Workspace { workspace: bool },
69}
70
71#[derive(Debug, Clone, Serialize, Deserialize)]
72struct CargoPackage {
73    name: String,
74    version: PackageVersion,
75    #[serde(flatten)]
76    other: toml::Value,
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize)]
80struct CargoWorkspace {
81    members: Option<Vec<String>>,
82    package: Option<WorkspacePackage>,
83    #[serde(flatten)]
84    other: toml::Value,
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize)]
88struct WorkspacePackage {
89    version: Option<String>,
90    #[serde(flatten)]
91    other: toml::Value,
92}
93
94#[derive(Debug, Deserialize, Serialize)]
95struct TauriConfig {
96    #[serde(flatten)]
97    other: serde_json::Value,
98    version: String,
99}
100
101pub struct ReleaseTool {
102    args: Cli,
103    updated_files: Vec<PathBuf>,
104}
105
106impl ReleaseTool {
107    pub fn new(args: Cli) -> Self {
108        Self {
109            args,
110            updated_files: Vec::new(),
111        }
112    }
113
114    pub fn run(&mut self) -> Result<()> {
115        println!("🚀 开始发布版本: {}", self.args.version);
116
117        // 验证版本号格式
118        if !self.args.force {
119            self.validate_version_format()?;
120        }
121
122        // 1. 检查是否是 git 仓库
123        self.check_git_repo()?;
124
125        // 2. 检查工作区是否干净
126        if !self.is_working_tree_clean()? {
127            return Err(anyhow!("工作区有未提交的更改,请先提交或暂存更改"));
128        }
129
130        // 3. 更新版本号
131        self.update_versions()?;
132
133        if self.args.dry_run {
134            println!("✅ 干运行模式完成 - 更新了以下文件:");
135            for file in &self.updated_files {
136                println!("   - {}", file.display());
137            }
138            return Ok(());
139        }
140
141        // 4. 提交更改
142        self.commit_changes()?;
143
144        // 5. 处理标签
145        self.handle_tag()?;
146
147        // 6. 推送到所有远程仓库
148        self.push_to_remotes()?;
149
150        println!("✅ 版本发布成功: {}", self.args.version);
151        Ok(())
152    }
153
154    fn validate_version_format(&self) -> Result<()> {
155        let version_re = Regex::new(r"^\d+\.\d+\.\d+(-[a-zA-Z0-9\.]+)?(\+[a-zA-Z0-9\.]+)?$")?;
156        if !version_re.is_match(&self.args.version) {
157            return Err(anyhow!(
158                "版本号格式不正确,请使用语义化版本号 (例如: 1.2.3, 2.0.0-beta.1)\n\
159                 使用 --force 跳过此验证"
160            ));
161        }
162        Ok(())
163    }
164
165    fn check_git_repo(&self) -> Result<()> {
166        let output = run_git_cmd(&["rev-parse", "--is-inside-work-tree"])?;
167
168        if !output.status.success() {
169            return Err(anyhow!("当前目录不是 git 仓库"));
170        }
171        Ok(())
172    }
173
174    fn is_working_tree_clean(&self) -> Result<bool> {
175        let output = run_git_cmd(&["status", "--porcelain"])?;
176        Ok(output.stdout.is_empty())
177    }
178
179    fn update_versions(&mut self) -> Result<()> {
180        println!("📝 更新版本号...");
181
182        // 检查是否是 workspace 项目
183        let root_cargo_path = Path::new("Cargo.toml");
184        if root_cargo_path.exists() {
185            let content = fs::read_to_string(root_cargo_path)?;
186            let cargo: CargoToml = toml::from_str(&content)?;
187
188            if cargo.workspace.is_some() {
189                println!("🔍 检测到 workspace 项目,更新所有成员...");
190                self.update_workspace_versions()?;
191            } else {
192                // 单个项目
193                self.update_single_crate(root_cargo_path)?;
194            }
195        } else {
196            return Err(anyhow!("未找到 Cargo.toml 文件"));
197        }
198
199        // 更新 tauri.conf.json
200        self.update_tauri_config()?;
201        Self::cargo_check()?;
202
203        println!(
204            "✅ 版本号更新完成,共更新 {} 个文件",
205            self.updated_files.len()
206        );
207        Ok(())
208    }
209
210    fn cargo_check() -> Result<()> {
211        run_cmd(Command::new("cargo").arg("check"))?;
212        Ok(())
213    }
214
215    fn update_workspace_versions(&mut self) -> Result<()> {
216        // 首先更新根 Cargo.toml 中的 workspace.package.version(如果存在)
217        self.update_root_workspace_version()?;
218
219        // 查找并更新所有成员的 Cargo.toml
220        let cargo_toml_files = self.find_all_cargo_toml()?;
221
222        for cargo_path in cargo_toml_files {
223            self.update_single_crate(&cargo_path)?;
224        }
225
226        Ok(())
227    }
228
229    fn find_all_cargo_toml(&self) -> Result<Vec<PathBuf>> {
230        let mut cargo_files = Vec::new();
231        let root_cargo = Path::new("Cargo.toml").canonicalize()?;
232
233        for entry in WalkDir::new(".")
234            .follow_links(true)
235            .into_iter()
236            .filter_map(|e| e.ok())
237        {
238            let path = entry.path();
239            if path.file_name().and_then(|s| s.to_str()) == Some("Cargo.toml") {
240                let full_path = path.canonicalize()?;
241                if full_path != root_cargo {
242                    cargo_files.push(path.to_path_buf());
243                }
244            }
245        }
246
247        Ok(cargo_files)
248    }
249
250    fn update_root_workspace_version(&mut self) -> Result<()> {
251        let root_cargo_path = Path::new("Cargo.toml");
252        let content = fs::read_to_string(root_cargo_path)?;
253        let mut cargo: CargoToml = toml::from_str(&content)?;
254
255        // 更新 workspace.package.version
256        let mut old_version = None;
257        if let Some(ref mut workspace) = cargo.workspace
258            && let Some(ref mut workspace_package) = workspace.package
259            && let Some(ref mut version) = workspace_package.version
260        {
261            old_version = Some(version.clone());
262            *version = self.args.version.clone();
263        }
264
265        if let Some(old_version) = old_version {
266            let new_content = toml::to_string_pretty(&cargo)?;
267            fs::write(root_cargo_path, new_content)?;
268            self.updated_files.push(root_cargo_path.to_path_buf());
269            println!(
270                "✅ 更新 workspace 版本: {} -> {}",
271                old_version, self.args.version
272            );
273        }
274
275        Ok(())
276    }
277
278    fn update_single_crate(&mut self, cargo_path: &Path) -> Result<()> {
279        let content = fs::read_to_string(cargo_path)?;
280        let cargo: CargoToml = toml::from_str(&content)?;
281
282        // 检查是否需要跳过此 crate
283        if let Some(ref package) = cargo.package {
284            let crate_name = &package.name;
285
286            // 检查排除列表
287            if !self.args.exclude.is_empty() && self.args.exclude.contains(crate_name) {
288                println!("⏭️  跳过 crate: {}", crate_name);
289                return Ok(());
290            }
291
292            // 检查 only 列表
293            if !self.args.only.is_empty() && !self.args.only.contains(crate_name) {
294                println!("⏭️  跳过 crate (不在 --only 列表中): {}", crate_name);
295                return Ok(());
296            }
297
298            // 检查版本是否继承自 workspace
299            let old_version = match &package.version {
300                PackageVersion::Direct(v) => v.clone(),
301                PackageVersion::Workspace { .. } => {
302                    println!("⏭️  跳过 crate {} (版本继承自 workspace)", crate_name);
303                    return Ok(());
304                }
305            };
306
307            // 创建新的 CargoToml 结构体来更新版本
308            let new_cargo_toml = self.create_updated_cargo_toml(&cargo)?;
309
310            let new_content = toml::to_string_pretty(&new_cargo_toml)?;
311            fs::write(cargo_path, new_content)?;
312            self.updated_files.push(cargo_path.to_path_buf());
313
314            let relative_path = cargo_path.strip_prefix(".").unwrap_or(cargo_path);
315            println!(
316                "✅ 更新 {} ({}): {} -> {}",
317                relative_path.display(),
318                crate_name,
319                old_version,
320                self.args.version
321            );
322        }
323
324        Ok(())
325    }
326
327    fn create_updated_cargo_toml(&self, cargo: &CargoToml) -> Result<CargoToml> {
328        let mut updated = cargo.clone();
329
330        if let Some(ref mut package) = updated.package {
331            package.version = PackageVersion::Direct(self.args.version.clone());
332        }
333
334        Ok(updated)
335    }
336
337    fn update_tauri_config(&mut self) -> Result<()> {
338        let tauri_paths = ["tauri.conf.json", "src-tauri/tauri.conf.json"];
339
340        for path in tauri_paths {
341            let tauri_path = Path::new(path);
342            if tauri_path.exists() {
343                let content = fs::read_to_string(tauri_path)?;
344                let mut tauri_config: TauriConfig = serde_json::from_str(&content)?;
345
346                let old_version = tauri_config.version.clone();
347                tauri_config.version = self.args.version.clone();
348                println!("✅ 更新 {}: {} -> {}", path, old_version, self.args.version);
349
350                let new_content = serde_json::to_string_pretty(&tauri_config)?;
351                fs::write(tauri_path, new_content)?;
352                self.updated_files.push(tauri_path.to_path_buf());
353                return Ok(());
354            }
355        }
356
357        println!("⚠️  未找到 tauri.conf.json,跳过");
358        Ok(())
359    }
360
361    fn commit_changes(&self) -> Result<()> {
362        println!("💾 提交更改...");
363
364        run_git_cmd(&["add", "-A"])?;
365
366        let commit_message = self.args.message.replace("{version}", &self.args.version);
367        run_git_cmd(&["commit", "-m", &commit_message])?;
368
369        println!("✅ 提交完成: {}", commit_message);
370        Ok(())
371    }
372
373    fn handle_tag(&self) -> Result<()> {
374        let tag_name = format!("{}{}", self.args.tag_prefix, self.args.version);
375
376        let output = run_git_cmd(&["tag", "-l", &tag_name])?;
377        let tag_exists = !output.stdout.is_empty();
378
379        if tag_exists {
380            if self.args.re_publish {
381                println!("🔄 重新发布版本,删除旧标签...");
382                run_git_cmd(&["tag", "-d", &tag_name])?;
383                self.delete_remote_tags(&tag_name)?;
384            } else {
385                return Err(anyhow!(
386                    "标签 {} 已存在,使用 --re-publish 重新发布",
387                    tag_name
388                ));
389            }
390        }
391
392        println!("🏷️  创建标签: {}", tag_name);
393        let message = format!("Version {}", self.args.version);
394        run_git_cmd(&["tag", "-a", &tag_name, "-m", &message])?;
395
396        Ok(())
397    }
398
399    fn delete_remote_tags(&self, tag_name: &str) -> Result<()> {
400        let output = run_git_cmd(&["remote"])?;
401        let remotes = String::from_utf8(output.stdout)?;
402
403        for remote in remotes.lines() {
404            println!("🗑️  删除远程标签 {}/{}", remote, tag_name);
405            let _ = Command::new("git")
406                .arg("push")
407                .arg(remote)
408                .arg("--delete")
409                .arg(tag_name)
410                .status();
411        }
412
413        Ok(())
414    }
415
416    fn push_to_remotes(&self) -> Result<()> {
417        println!("📤 推送到远程仓库...");
418
419        let output = run_git_cmd(&["remote"])?;
420        let remotes = String::from_utf8(output.stdout)?;
421
422        for remote in remotes.lines() {
423            println!("⬆️  推送到 {}", remote);
424            run_git_cmd(&["push", remote, "HEAD"])?;
425            run_git_cmd(&["push", remote, "--tags"])?;
426        }
427
428        Ok(())
429    }
430}
431
432fn run_git_cmd(args: &[&str]) -> Result<std::process::Output> {
433    let output = Command::new("git").args(args).output()?;
434    if !output.status.success() {
435        let stderr = String::from_utf8_lossy(&output.stderr);
436        return Err(anyhow!("git 命令失败: {}\n{}", args.join(" "), stderr));
437    }
438    Ok(output)
439}
440
441fn run_cmd(command: &mut Command) -> Result<()> {
442    let status = command.status()?;
443    if !status.success() {
444        return Err(anyhow!("命令执行失败: {:?}", command));
445    }
446    Ok(())
447}