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 #[arg(value_name = "VERSION")]
20 version: String,
21
22 #[arg(long, short = 'r')]
24 re_publish: bool,
25
26 #[arg(long, short = 'f')]
28 force: bool,
29
30 #[arg(
32 long,
33 short = 'm',
34 default_value = "Release version {version}",
35 value_name = "MESSAGE"
36 )]
37 message: String,
38
39 #[arg(long, default_value = "v", value_name = "PREFIX")]
41 tag_prefix: String,
42
43 #[arg(long)]
45 dry_run: bool,
46
47 #[arg(long, value_name = "CRATE")]
49 exclude: Vec<String>,
50
51 #[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 if !self.args.force {
112 self.validate_version_format()?;
113 }
114
115 self.check_git_repo()?;
117
118 if !self.is_working_tree_clean()? {
120 return Err(anyhow!("工作区有未提交的更改,请先提交或暂存更改"));
121 }
122
123 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 self.commit_changes()?;
136
137 self.handle_tag()?;
139
140 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 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 self.update_single_crate(root_cargo_path)?;
194 }
195 } else {
196 return Err(anyhow!("未找到 Cargo.toml 文件"));
197 }
198
199 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 self.update_root_workspace_version()?;
218
219 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 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 if let Some(ref package) = cargo.package {
280 let crate_name = &package.name;
281
282 if !self.args.exclude.is_empty() && self.args.exclude.contains(crate_name) {
284 println!("⏭️ 跳过 crate: {}", crate_name);
285 return Ok(());
286 }
287
288 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 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 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 StdCommand::new("git").arg("add").arg("-A").status()?;
357
358 let commit_message = self.args.message.replace("{version}", &self.args.version);
360
361 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 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 StdCommand::new("git")
390 .arg("tag")
391 .arg("-d")
392 .arg(&tag_name)
393 .status()?;
394
395 self.delete_remote_tags(&tag_name)?;
397 } else {
398 return Err(anyhow!(
399 "标签 {} 已存在,使用 --re-publish 重新发布",
400 tag_name
401 ));
402 }
403 }
404
405 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 StdCommand::new("git")
448 .arg("push")
449 .arg(remote)
450 .arg("HEAD")
451 .status()?;
452
453 StdCommand::new("git")
455 .arg("push")
456 .arg(remote)
457 .arg("--tags")
458 .status()?;
459 }
460
461 Ok(())
462 }
463}