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 #[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, 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 if !self.args.force {
119 self.validate_version_format()?;
120 }
121
122 self.check_git_repo()?;
124
125 if !self.is_working_tree_clean()? {
127 return Err(anyhow!("工作区有未提交的更改,请先提交或暂存更改"));
128 }
129
130 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 self.commit_changes()?;
143
144 self.handle_tag()?;
146
147 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 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 run_cmd(Command::new("cargo").arg("check"))?;
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 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 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 if let Some(ref package) = cargo.package {
284 let crate_name = &package.name;
285
286 if !self.args.exclude.is_empty() && self.args.exclude.contains(crate_name) {
288 println!("⏭️ 跳过 crate: {}", crate_name);
289 return Ok(());
290 }
291
292 if !self.args.only.is_empty() && !self.args.only.contains(crate_name) {
294 println!("⏭️ 跳过 crate (不在 --only 列表中): {}", crate_name);
295 return Ok(());
296 }
297
298 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 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}