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
202        println!(
203            "✅ 版本号更新完成,共更新 {} 个文件",
204            self.updated_files.len()
205        );
206        Ok(())
207    }
208
209    fn update_workspace_versions(&mut self) -> Result<()> {
210        // 首先更新根 Cargo.toml 中的 workspace.package.version(如果存在)
211        self.update_root_workspace_version()?;
212
213        // 查找并更新所有成员的 Cargo.toml
214        let cargo_toml_files = self.find_all_cargo_toml()?;
215
216        for cargo_path in cargo_toml_files {
217            self.update_single_crate(&cargo_path)?;
218        }
219
220        Ok(())
221    }
222
223    fn find_all_cargo_toml(&self) -> Result<Vec<PathBuf>> {
224        let mut cargo_files = Vec::new();
225
226        for entry in WalkDir::new(".")
227            .follow_links(true)
228            .into_iter()
229            .filter_map(|e| e.ok())
230        {
231            let path = entry.path();
232            if path.file_name().and_then(|s| s.to_str()) == Some("Cargo.toml") {
233                cargo_files.push(path.to_path_buf());
234            }
235        }
236
237        Ok(cargo_files)
238    }
239
240    fn update_root_workspace_version(&mut self) -> Result<()> {
241        let root_cargo_path = Path::new("Cargo.toml");
242        let content = fs::read_to_string(root_cargo_path)?;
243        let mut cargo: CargoToml = toml::from_str(&content)?;
244
245        // 更新 workspace.package.version
246        let mut old_version = None;
247        if let Some(ref mut workspace) = cargo.workspace
248            && let Some(ref mut workspace_package) = workspace.package
249                && let Some(ref mut version) = workspace_package.version {
250                    old_version = Some(version.clone());
251                    *version = self.args.version.clone();
252                }
253
254        if let Some(old_version) = old_version {
255            let new_content = toml::to_string_pretty(&cargo)?;
256            fs::write(root_cargo_path, new_content)?;
257            self.updated_files.push(root_cargo_path.to_path_buf());
258            println!(
259                "✅ 更新 workspace 版本: {} -> {}",
260                old_version, self.args.version
261            );
262        }
263
264        Ok(())
265    }
266
267    fn update_single_crate(&mut self, cargo_path: &Path) -> Result<()> {
268        let content = fs::read_to_string(cargo_path)?;
269        let cargo: CargoToml = toml::from_str(&content)?;
270
271        // 检查是否需要跳过此 crate
272        if let Some(ref package) = cargo.package {
273            let crate_name = &package.name;
274
275            // 检查排除列表
276            if !self.args.exclude.is_empty() && self.args.exclude.contains(crate_name) {
277                println!("⏭️  跳过 crate: {}", crate_name);
278                return Ok(());
279            }
280
281            // 检查 only 列表
282            if !self.args.only.is_empty() && !self.args.only.contains(crate_name) {
283                println!("⏭️  跳过 crate (不在 --only 列表中): {}", crate_name);
284                return Ok(());
285            }
286
287            let old_version = package.version.clone();
288
289            // 创建新的 CargoToml 结构体来更新版本
290            let new_cargo_toml = self.create_updated_cargo_toml(&cargo)?;
291
292            let new_content = toml::to_string_pretty(&new_cargo_toml)?;
293            fs::write(cargo_path, new_content)?;
294            self.updated_files.push(cargo_path.to_path_buf());
295
296            let relative_path = cargo_path.strip_prefix(".").unwrap_or(cargo_path);
297            println!(
298                "✅ 更新 {} ({}): {} -> {}",
299                relative_path.display(),
300                crate_name,
301                old_version,
302                self.args.version
303            );
304        }
305
306        Ok(())
307    }
308
309    fn create_updated_cargo_toml(&self, cargo: &CargoToml) -> Result<CargoToml> {
310        let content = toml::to_string(cargo)?;
311        let mut updated: CargoToml = toml::from_str(&content)?;
312
313        // 更新 package.version
314        if let Some(ref mut package) = updated.package {
315            package.version = self.args.version.clone();
316        }
317
318        Ok(updated)
319    }
320
321    fn update_tauri_config(&mut self) -> Result<()> {
322        let tauri_paths = ["tauri.conf.json", "src-tauri/tauri.conf.json"];
323
324        for path in tauri_paths {
325            let tauri_path = Path::new(path);
326            if tauri_path.exists() {
327                let content = fs::read_to_string(tauri_path)?;
328                let mut tauri_config: TauriConfig = serde_json::from_str(&content)?;
329
330                let old_version = tauri_config.version.clone();
331                tauri_config.version = self.args.version.clone();
332                println!("✅ 更新 {}: {} -> {}", path, old_version, self.args.version);
333
334                let new_content = serde_json::to_string_pretty(&tauri_config)?;
335                fs::write(tauri_path, new_content)?;
336                self.updated_files.push(tauri_path.to_path_buf());
337                return Ok(());
338            }
339        }
340
341        println!("⚠️  未找到 tauri.conf.json,跳过");
342        Ok(())
343    }
344
345    fn commit_changes(&self) -> Result<()> {
346        println!("💾 提交更改...");
347
348        // 添加所有更改的文件
349        StdCommand::new("git").arg("add").arg("-A").status()?;
350
351        // 生成提交信息
352        let commit_message = self.args.message.replace("{version}", &self.args.version);
353
354        // 提交
355        StdCommand::new("git")
356            .arg("commit")
357            .arg("-m")
358            .arg(&commit_message)
359            .status()?;
360
361        println!("✅ 提交完成: {}", commit_message);
362        Ok(())
363    }
364
365    fn handle_tag(&self) -> Result<()> {
366        let tag_name = format!("{}{}", self.args.tag_prefix, self.args.version);
367
368        // 检查标签是否已存在
369        let tag_exists = !StdCommand::new("git")
370            .arg("tag")
371            .arg("-l")
372            .arg(&tag_name)
373            .output()?
374            .stdout.is_empty();
375
376        if tag_exists {
377            if self.args.re_publish {
378                println!("🔄 重新发布版本,删除旧标签...");
379
380                // 删除本地标签
381                StdCommand::new("git")
382                    .arg("tag")
383                    .arg("-d")
384                    .arg(&tag_name)
385                    .status()?;
386
387                // 删除所有远程仓库的标签
388                self.delete_remote_tags(&tag_name)?;
389            } else {
390                return Err(anyhow!(
391                    "标签 {} 已存在,使用 --re-publish 重新发布",
392                    tag_name
393                ));
394            }
395        }
396
397        // 创建新标签
398        println!("🏷️  创建标签: {}", tag_name);
399        StdCommand::new("git")
400            .arg("tag")
401            .arg("-a")
402            .arg(&tag_name)
403            .arg("-m")
404            .arg(format!("Version {}", self.args.version))
405            .status()?;
406
407        Ok(())
408    }
409
410    fn delete_remote_tags(&self, tag_name: &str) -> Result<()> {
411        let remotes_output = StdCommand::new("git").arg("remote").output()?;
412
413        let remotes = String::from_utf8(remotes_output.stdout)?;
414
415        for remote in remotes.lines() {
416            println!("🗑️  删除远程标签 {}/{}", remote, tag_name);
417            let _ = StdCommand::new("git")
418                .arg("push")
419                .arg(remote)
420                .arg("--delete")
421                .arg(tag_name)
422                .status();
423        }
424
425        Ok(())
426    }
427
428    fn push_to_remotes(&self) -> Result<()> {
429        println!("📤 推送到远程仓库...");
430
431        let remotes_output = StdCommand::new("git").arg("remote").output()?;
432
433        let remotes = String::from_utf8(remotes_output.stdout)?;
434
435        for remote in remotes.lines() {
436            println!("⬆️  推送到 {}", remote);
437
438            // 推送提交
439            StdCommand::new("git")
440                .arg("push")
441                .arg(remote)
442                .arg("HEAD")
443                .status()?;
444
445            // 推送标签
446            StdCommand::new("git")
447                .arg("push")
448                .arg(remote)
449                .arg("--tags")
450                .status()?;
451        }
452
453        Ok(())
454    }
455}