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
202 println!(
203 "✅ 版本号更新完成,共更新 {} 个文件",
204 self.updated_files.len()
205 );
206 Ok(())
207 }
208
209 fn update_workspace_versions(&mut self) -> Result<()> {
210 self.update_root_workspace_version()?;
212
213 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 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 if let Some(ref package) = cargo.package {
273 let crate_name = &package.name;
274
275 if !self.args.exclude.is_empty() && self.args.exclude.contains(crate_name) {
277 println!("⏭️ 跳过 crate: {}", crate_name);
278 return Ok(());
279 }
280
281 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 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 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 StdCommand::new("git").arg("add").arg("-A").status()?;
350
351 let commit_message = self.args.message.replace("{version}", &self.args.version);
353
354 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 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 StdCommand::new("git")
382 .arg("tag")
383 .arg("-d")
384 .arg(&tag_name)
385 .status()?;
386
387 self.delete_remote_tags(&tag_name)?;
389 } else {
390 return Err(anyhow!(
391 "标签 {} 已存在,使用 --re-publish 重新发布",
392 tag_name
393 ));
394 }
395 }
396
397 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 StdCommand::new("git")
440 .arg("push")
441 .arg(remote)
442 .arg("HEAD")
443 .status()?;
444
445 StdCommand::new("git")
447 .arg("push")
448 .arg(remote)
449 .arg("--tags")
450 .status()?;
451 }
452
453 Ok(())
454 }
455}