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 as StdCommand;
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, Serialize, Deserialize)]
57struct CargoToml {
58    package: Option<CargoPackage>,
59    workspace: Option<CargoWorkspace>,
60    #[serde(flatten)]
61    other: toml::Value,
62}
63
64#[derive(Debug, Serialize, Deserialize)]
65struct CargoPackage {
66    name: String,
67    version: String,
68    #[serde(flatten)]
69    other: toml::Value,
70}
71
72#[derive(Debug, Serialize, Deserialize)]
73struct CargoWorkspace {
74    members: Option<Vec<String>>,
75    package: Option<WorkspacePackage>,
76    #[serde(flatten)]
77    other: toml::Value,
78}
79
80#[derive(Debug, Serialize, Deserialize)]
81struct WorkspacePackage {
82    version: Option<String>,
83    #[serde(flatten)]
84    other: toml::Value,
85}
86
87#[derive(Debug, Deserialize, Serialize)]
88struct TauriConfig {
89    #[serde(flatten)]
90    other: serde_json::Value,
91    version: String,
92}
93
94pub struct ReleaseTool {
95    args: Cli,
96    updated_files: Vec<PathBuf>,
97}
98
99impl ReleaseTool {
100    pub fn new(args: Cli) -> Self {
101        Self {
102            args,
103            updated_files: Vec::new(),
104        }
105    }
106
107    pub fn run(&mut self) -> Result<()> {
108        println!("🚀 开始发布版本: {}", self.args.version);
109
110        // 验证版本号格式
111        if !self.args.force {
112            self.validate_version_format()?;
113        }
114
115        // 1. 检查是否是 git 仓库
116        self.check_git_repo()?;
117
118        // 2. 检查工作区是否干净
119        if !self.is_working_tree_clean()? {
120            return Err(anyhow!("工作区有未提交的更改,请先提交或暂存更改"));
121        }
122
123        // 3. 更新版本号
124        self.update_versions()?;
125
126        if self.args.dry_run {
127            println!("✅ 干运行模式完成 - 更新了以下文件:");
128            for file in &self.updated_files {
129                println!("   - {}", file.display());
130            }
131            return Ok(());
132        }
133
134        // 4. 提交更改
135        self.commit_changes()?;
136
137        // 5. 处理标签
138        self.handle_tag()?;
139
140        // 6. 推送到所有远程仓库
141        self.push_to_remotes()?;
142
143        println!("✅ 版本发布成功: {}", self.args.version);
144        Ok(())
145    }
146
147    fn validate_version_format(&self) -> Result<()> {
148        let version_re = Regex::new(r"^\d+\.\d+\.\d+(-[a-zA-Z0-9\.]+)?(\+[a-zA-Z0-9\.]+)?$")?;
149        if !version_re.is_match(&self.args.version) {
150            return Err(anyhow!(
151                "版本号格式不正确,请使用语义化版本号 (例如: 1.2.3, 2.0.0-beta.1)\n\
152                 使用 --force 跳过此验证"
153            ));
154        }
155        Ok(())
156    }
157
158    fn check_git_repo(&self) -> Result<()> {
159        let output = StdCommand::new("git")
160            .arg("rev-parse")
161            .arg("--is-inside-work-tree")
162            .output()?;
163
164        if !output.status.success() {
165            return Err(anyhow!("当前目录不是 git 仓库"));
166        }
167        Ok(())
168    }
169
170    fn is_working_tree_clean(&self) -> Result<bool> {
171        let output = StdCommand::new("git")
172            .arg("status")
173            .arg("--porcelain")
174            .output()?;
175
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        StdCommand::new("cargo").arg("check").status()?;
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
232        for entry in WalkDir::new(".")
233            .follow_links(true)
234            .into_iter()
235            .filter_map(|e| e.ok())
236        {
237            let path = entry.path();
238            if path.file_name().and_then(|s| s.to_str()) == Some("Cargo.toml") {
239                cargo_files.push(path.to_path_buf());
240            }
241        }
242
243        Ok(cargo_files)
244    }
245
246    fn update_root_workspace_version(&mut self) -> Result<()> {
247        let root_cargo_path = Path::new("Cargo.toml");
248        let content = fs::read_to_string(root_cargo_path)?;
249        let mut cargo: CargoToml = toml::from_str(&content)?;
250
251        // 更新 workspace.package.version
252        let mut old_version = None;
253        if let Some(ref mut workspace) = cargo.workspace
254            && let Some(ref mut workspace_package) = workspace.package
255            && let Some(ref mut version) = workspace_package.version
256        {
257            old_version = Some(version.clone());
258            *version = self.args.version.clone();
259        }
260
261        if let Some(old_version) = old_version {
262            let new_content = toml::to_string_pretty(&cargo)?;
263            fs::write(root_cargo_path, new_content)?;
264            self.updated_files.push(root_cargo_path.to_path_buf());
265            println!(
266                "✅ 更新 workspace 版本: {} -> {}",
267                old_version, self.args.version
268            );
269        }
270
271        Ok(())
272    }
273
274    fn update_single_crate(&mut self, cargo_path: &Path) -> Result<()> {
275        let content = fs::read_to_string(cargo_path)?;
276        let cargo: CargoToml = toml::from_str(&content)?;
277
278        // 检查是否需要跳过此 crate
279        if let Some(ref package) = cargo.package {
280            let crate_name = &package.name;
281
282            // 检查排除列表
283            if !self.args.exclude.is_empty() && self.args.exclude.contains(crate_name) {
284                println!("⏭️  跳过 crate: {}", crate_name);
285                return Ok(());
286            }
287
288            // 检查 only 列表
289            if !self.args.only.is_empty() && !self.args.only.contains(crate_name) {
290                println!("⏭️  跳过 crate (不在 --only 列表中): {}", crate_name);
291                return Ok(());
292            }
293
294            let old_version = package.version.clone();
295
296            // 创建新的 CargoToml 结构体来更新版本
297            let new_cargo_toml = self.create_updated_cargo_toml(&cargo)?;
298
299            let new_content = toml::to_string_pretty(&new_cargo_toml)?;
300            fs::write(cargo_path, new_content)?;
301            self.updated_files.push(cargo_path.to_path_buf());
302
303            let relative_path = cargo_path.strip_prefix(".").unwrap_or(cargo_path);
304            println!(
305                "✅ 更新 {} ({}): {} -> {}",
306                relative_path.display(),
307                crate_name,
308                old_version,
309                self.args.version
310            );
311        }
312
313        Ok(())
314    }
315
316    fn create_updated_cargo_toml(&self, cargo: &CargoToml) -> Result<CargoToml> {
317        let content = toml::to_string(cargo)?;
318        let mut updated: CargoToml = toml::from_str(&content)?;
319
320        // 更新 package.version
321        if let Some(ref mut package) = updated.package {
322            package.version = self.args.version.clone();
323        }
324
325        Ok(updated)
326    }
327
328    fn update_tauri_config(&mut self) -> Result<()> {
329        let tauri_paths = ["tauri.conf.json", "src-tauri/tauri.conf.json"];
330
331        for path in tauri_paths {
332            let tauri_path = Path::new(path);
333            if tauri_path.exists() {
334                let content = fs::read_to_string(tauri_path)?;
335                let mut tauri_config: TauriConfig = serde_json::from_str(&content)?;
336
337                let old_version = tauri_config.version.clone();
338                tauri_config.version = self.args.version.clone();
339                println!("✅ 更新 {}: {} -> {}", path, old_version, self.args.version);
340
341                let new_content = serde_json::to_string_pretty(&tauri_config)?;
342                fs::write(tauri_path, new_content)?;
343                self.updated_files.push(tauri_path.to_path_buf());
344                return Ok(());
345            }
346        }
347
348        println!("⚠️  未找到 tauri.conf.json,跳过");
349        Ok(())
350    }
351
352    fn commit_changes(&self) -> Result<()> {
353        println!("💾 提交更改...");
354
355        // 添加所有更改的文件
356        StdCommand::new("git").arg("add").arg("-A").status()?;
357
358        // 生成提交信息
359        let commit_message = self.args.message.replace("{version}", &self.args.version);
360
361        // 提交
362        StdCommand::new("git")
363            .arg("commit")
364            .arg("-m")
365            .arg(&commit_message)
366            .status()?;
367
368        println!("✅ 提交完成: {}", commit_message);
369        Ok(())
370    }
371
372    fn handle_tag(&self) -> Result<()> {
373        let tag_name = format!("{}{}", self.args.tag_prefix, self.args.version);
374
375        // 检查标签是否已存在
376        let tag_exists = !StdCommand::new("git")
377            .arg("tag")
378            .arg("-l")
379            .arg(&tag_name)
380            .output()?
381            .stdout
382            .is_empty();
383
384        if tag_exists {
385            if self.args.re_publish {
386                println!("🔄 重新发布版本,删除旧标签...");
387
388                // 删除本地标签
389                StdCommand::new("git")
390                    .arg("tag")
391                    .arg("-d")
392                    .arg(&tag_name)
393                    .status()?;
394
395                // 删除所有远程仓库的标签
396                self.delete_remote_tags(&tag_name)?;
397            } else {
398                return Err(anyhow!(
399                    "标签 {} 已存在,使用 --re-publish 重新发布",
400                    tag_name
401                ));
402            }
403        }
404
405        // 创建新标签
406        println!("🏷️  创建标签: {}", tag_name);
407        StdCommand::new("git")
408            .arg("tag")
409            .arg("-a")
410            .arg(&tag_name)
411            .arg("-m")
412            .arg(format!("Version {}", self.args.version))
413            .status()?;
414
415        Ok(())
416    }
417
418    fn delete_remote_tags(&self, tag_name: &str) -> Result<()> {
419        let remotes_output = StdCommand::new("git").arg("remote").output()?;
420
421        let remotes = String::from_utf8(remotes_output.stdout)?;
422
423        for remote in remotes.lines() {
424            println!("🗑️  删除远程标签 {}/{}", remote, tag_name);
425            let _ = StdCommand::new("git")
426                .arg("push")
427                .arg(remote)
428                .arg("--delete")
429                .arg(tag_name)
430                .status();
431        }
432
433        Ok(())
434    }
435
436    fn push_to_remotes(&self) -> Result<()> {
437        println!("📤 推送到远程仓库...");
438
439        let remotes_output = StdCommand::new("git").arg("remote").output()?;
440
441        let remotes = String::from_utf8(remotes_output.stdout)?;
442
443        for remote in remotes.lines() {
444            println!("⬆️  推送到 {}", remote);
445
446            // 推送提交
447            StdCommand::new("git")
448                .arg("push")
449                .arg(remote)
450                .arg("HEAD")
451                .status()?;
452
453            // 推送标签
454            StdCommand::new("git")
455                .arg("push")
456                .arg(remote)
457                .arg("--tags")
458                .status()?;
459        }
460
461        Ok(())
462    }
463}