Skip to main content

qtcloud_devops_cli/commands/
release.rs

1use std::path::Path;
2use std::process::Command;
3
4use crate::model::release::{FileStorage, ReleaseRecord, ReleaseStatus, Storage, TransitionError};
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
7pub enum Registry {
8    PyPI,
9    PubDev,
10    Crates,
11}
12
13// ===== utility functions =====
14
15pub fn validate_version(version: &str) -> bool {
16    let re = regex::Regex::new(
17        r"^(v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?|[a-zA-Z0-9_.-]+/v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?)$",
18    ).unwrap();
19    re.is_match(version)
20}
21
22fn normalize_version(version: &str) -> String {
23    let s = version.strip_prefix('v').unwrap_or(version);
24    s.split("/v").last().unwrap_or(s).to_string()
25}
26
27pub fn precheck_version_changelog(version: &str, changelog_path: &Path) -> Vec<String> {
28    let mut errors = Vec::new();
29    if !validate_version(version) {
30        errors.push(format!("版本号格式错误: {}", version));
31    }
32    if changelog_path.exists() {
33        let content = std::fs::read_to_string(changelog_path).unwrap_or_default();
34        let ver = normalize_version(version);
35        let marker = format!("## [{}]", ver);
36        if !content.contains(&marker) {
37            errors.push(format!("CHANGELOG.md 未找到 {} 版本记录", ver));
38        }
39    } else {
40        errors.push(format!("CHANGELOG.md 不存在: {}", changelog_path.display()));
41    }
42    errors
43}
44
45pub fn extract_notes(version: &str, changelog_path: &Path) -> Option<String> {
46    let content = std::fs::read_to_string(changelog_path).ok()?;
47    let ver = normalize_version(version);
48    let start_marker = format!("## [{}]", ver);
49    let mut capture = false;
50    let mut notes: Vec<&str> = Vec::new();
51    for line in content.lines() {
52        if line.trim().starts_with(&start_marker) {
53            capture = true;
54            continue;
55        }
56        if capture {
57            if line.starts_with("## [") {
58                break;
59            }
60            notes.push(line);
61        }
62    }
63    let text = notes.join("\n").trim().to_string();
64    if text.is_empty() { None } else { Some(text) }
65}
66
67pub fn confirm_release(version: &str, yes: bool) -> bool {
68    if yes { return true; }
69    use std::io::Write;
70    println!("\n发布版本: {}", version);
71    print!("确认发布? (y/N): ");
72    std::io::stdout().flush().ok();
73    let mut input = String::new();
74    std::io::stdin().read_line(&mut input).ok();
75    let input = input.trim().to_lowercase();
76    input == "y" || input == "yes"
77}
78
79fn git_args(args: &[&str], repo_path: &Path) -> Command {
80    let mut cmd = Command::new("git");
81    cmd.arg("-C");
82    cmd.arg(repo_path);
83    cmd.args(args);
84    cmd
85}
86
87pub fn create_tag(version: &str, repo_path: &Path) -> bool {
88    match git_args(&["tag", version], repo_path).output() {
89        Ok(out) if out.status.success() => true,
90        Ok(out) => { eprintln!("创建标签失败: {}", String::from_utf8_lossy(&out.stderr).trim()); false }
91        Err(e) => { eprintln!("创建标签失败: {}", e); false }
92    }
93}
94
95pub fn push_tag(version: &str, repo_path: &Path) -> bool {
96    match git_args(&["push", "origin", version], repo_path).output() {
97        Ok(out) if out.status.success() => true,
98        Ok(out) => {
99            let msg = String::from_utf8_lossy(&out.stderr).trim().to_string();
100            if msg.contains("does not appear") || msg.contains("repository '' does not exist") {
101                return true; // 没有 remote 时静默跳过(本地 tag 已创建)
102            }
103            eprintln!("推送标签失败: {}", msg);
104            false
105        }
106        Err(e) => { eprintln!("推送标签失败: {}", e); false }
107    }
108}
109
110pub fn get_remote_repo(repo_path: &Path) -> Option<String> {
111    let result = git_args(&["remote", "get-url", "origin"], repo_path).output().ok()?;
112    if !result.status.success() { return None; }
113    let url = String::from_utf8_lossy(&result.stdout).trim().to_string();
114    parse_github_repo(&url)
115}
116
117pub fn parse_github_repo(url: &str) -> Option<String> {
118    let re = regex::Regex::new(r"github\.com[/:]([^/]+/[^/]+?)(?:\.git)?$").ok()?;
119    let caps = re.captures(url)?;
120    Some(caps.get(1)?.as_str().to_string())
121}
122
123pub fn create_release(version: &str, notes: &str, repo: &str) -> bool {
124    match Command::new("gh")
125        .args(["release", "create", version, "--title", version, "--notes", notes, "--repo", repo])
126        .output()
127    {
128        Ok(out) if out.status.success() => true,
129        Ok(out) => { eprintln!("创建 Release 失败: {}", String::from_utf8_lossy(&out.stderr).trim()); false }
130        Err(e) => { eprintln!("创建 Release 失败: {}", e); false }
131    }
132}
133
134pub fn rollback_tag(version: &str, repo_path: &Path) {
135    git_args(&["tag", "-d", version], repo_path).output().ok();
136    git_args(&["push", "origin", "--delete", version], repo_path).output().ok();
137    println!("↻ 标签 {} 已回滚", version);
138}
139
140// ===== stage =====
141
142fn is_prerelease(version: &str) -> bool {
143    let base = version.split('/').last().unwrap_or(version);
144    base.contains('-')
145}
146
147pub fn stage(version: &str, repo_path: &Path) -> Result<String, Box<dyn std::error::Error>> {
148    if !validate_version(version) {
149        return Err(format!("版本号格式错误: {}", version).into());
150    }
151    if !is_prerelease(version) {
152        return Err(format!("stage 仅用于预发布版本(含 -rc.N、-alpha.N 等后缀),正式版请直接 publish: {}", version).into());
153    }
154    let mut storage = FileStorage::new(repo_path);
155    if let Some(existing) = storage.load(version) {
156        match existing.status {
157            ReleaseStatus::Published => return Err(format!("版本 {} 已发布,不可重复 stage", version).into()),
158            ReleaseStatus::Staged => {
159                let now = std::time::SystemTime::now()
160                    .duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs().to_string();
161                let mut updated = existing.clone();
162                updated.updated_at = now;
163                storage.save(&updated)?;
164                // re-push tag (idempotent)
165                if create_tag(version, repo_path) && push_tag(version, repo_path) {
166                    println!("✓ 标签 {} 已更新并推送", version);
167                }
168                return Ok(updated.id);
169            }
170            ReleaseStatus::Cancelled => {}
171            ReleaseStatus::Retired => return Err(format!("版本 {} 已退役,不可重复 stage", version).into()),
172        }
173    }
174    let record = ReleaseRecord::new_staged(version);
175    storage.save(&record)?;
176    if !create_tag(version, repo_path) {
177        return Err(format!("创建标签 {} 失败", version).into());
178    }
179    if !push_tag(version, repo_path) {
180        rollback_tag(version, repo_path);
181        return Err(format!("推送标签 {} 失败", version).into());
182    }
183    println!("✓ 版本 {} 已进入 Staged 状态 (发布尝试 ID: {})", version, record.id);
184    println!("✓ 标签 {} 已创建并推送", version);
185    Ok(record.id)
186}
187
188// ===== publish =====
189
190pub fn publish(version: &str, repo_path: &Path, yes: bool, registry: Option<Registry>) -> Result<String, Box<dyn std::error::Error>> {
191    let mut storage = FileStorage::new(repo_path);
192    let mut record = storage
193        .load(version)
194        .ok_or_else(|| format!("版本 {} 不存在,请先执行 stage", version))?;
195    if record.status != ReleaseStatus::Staged {
196        return Err(format!("版本 {} 不处于 Staged 状态 (当前: {:?})", version, record.status).into());
197    }
198    if !confirm_release(version, yes) {
199        return Err("已取消发布".into());
200    }
201    if !create_tag(version, repo_path) {
202        return Err(format!("创建标签 {} 失败", version).into());
203    }
204    if !push_tag(version, repo_path) {
205        rollback_tag(version, repo_path);
206        return Err(format!("推送标签 {} 失败", version).into());
207    }
208    println!("✓ 标签 {} 已创建并推送", version);
209
210    let changelog_path = repo_path.join("CHANGELOG.md");
211    let notes = extract_notes(version, &changelog_path);
212    if let Some(repo) = get_remote_repo(repo_path) {
213        if !create_release(version, notes.as_deref().unwrap_or(""), &repo) {
214            rollback_tag(version, repo_path);
215            return Err("创建 GitHub Release 失败".into());
216        }
217        println!("✓ GitHub Release {} 已创建", version);
218        println!("  https://github.com/{}/releases/tag/{}", repo, version);
219    }
220
221    if let Some(reg) = registry {
222        println!("  {:?} 由 CI 自动发布,无需本地操作", reg);
223    }
224
225    record.status = ReleaseStatus::Published;
226    record.updated_at = std::time::SystemTime::now()
227        .duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs().to_string();
228    storage.save(&record)?;
229    let id = record.id.clone();
230    println!("✓ 版本 {} 已发布 (发布尝试 ID: {})", version, id);
231    Ok(id)
232}
233
234// ===== retire =====
235
236pub fn retire(version: &str, repo_path: &Path) -> Result<String, Box<dyn std::error::Error>> {
237    let mut storage = FileStorage::new(repo_path);
238    let mut record = storage
239        .load(version)
240        .ok_or_else(|| format!("版本 {} 不存在", version))?;
241    if record.status != ReleaseStatus::Published {
242        return Err(Box::new(TransitionError::NotPublished(version.to_string())));
243    }
244    record.status = ReleaseStatus::Retired;
245    record.updated_at = std::time::SystemTime::now()
246        .duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs().to_string();
247    storage.save(&record)?;
248    let id = record.id.clone();
249    println!("✓ 版本 {} 已退役 (发布尝试 ID: {})", version, id);
250    Ok(id)
251}
252
253// ===== release_status =====
254
255pub fn release_status(repo_path: &Path) -> Result<String, Box<dyn std::error::Error>> {
256    let storage = FileStorage::new(repo_path);
257    let mut records = storage.list();
258    if records.is_empty() {
259        println!("当前无发布记录");
260        return Ok(String::new());
261    }
262
263    records.sort_by(|a, b| b.created_at.cmp(&a.created_at));
264
265    let staged: Vec<&ReleaseRecord> = records.iter().filter(|r| r.status == ReleaseStatus::Staged).collect();
266    let published: Vec<&ReleaseRecord> = records.iter().filter(|r| r.status == ReleaseStatus::Published).collect();
267
268    println!("发布状态报告");
269    println!("{}", "-".repeat(40));
270    println!("待发布: {}", staged.len());
271    for r in &staged {
272        println!("  {} (尝试: {})", r.version, &r.id[..8]);
273    }
274    println!("已发布: {}", published.len());
275    for r in &published {
276        println!("  {} (尝试: {})", r.version, &r.id[..8]);
277    }
278    println!();
279
280    println!("最新发布:");
281    for r in records.iter().take(5) {
282        let status_str = match r.status {
283            ReleaseStatus::Staged => "Staged",
284            ReleaseStatus::Published => "Published",
285            ReleaseStatus::Cancelled => "Cancelled",
286            ReleaseStatus::Retired => "Retired",
287        };
288        println!("  {:<25} {:<12} {}", r.version, status_str, r.updated_at);
289    }
290
291    Ok(records.len().to_string())
292}
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297
298    fn make_record(version: &str, status: ReleaseStatus) -> ReleaseRecord {
299        let now = std::time::SystemTime::now()
300            .duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs().to_string();
301        ReleaseRecord {
302            id: uuid::Uuid::new_v4().to_string(),
303            version: version.to_string(),
304            status,
305            created_at: now.clone(),
306            updated_at: now,
307        }
308    }
309
310    // validate_version
311
312    #[test]
313    fn test_validate_version_v_prefix() { assert!(validate_version("v1.2.3")); }
314    #[test]
315    fn test_validate_version_with_suffix() { assert!(validate_version("v1.2.3-alpha.1")); assert!(validate_version("v1.2.3-rc1")); }
316    #[test]
317    fn test_validate_version_pkg() { assert!(validate_version("pkg/v1.2.3")); assert!(validate_version("cli/v0.1.0")); }
318    #[test]
319    fn test_validate_version_invalid() { assert!(!validate_version("1.2.3")); assert!(!validate_version("v1.2")); assert!(!validate_version("abc")); }
320
321    // parse_github_repo
322
323    #[test]
324    fn test_parse_github_repo_https() { assert_eq!(parse_github_repo("https://github.com/owner/repo.git"), Some("owner/repo".into())); }
325    #[test]
326    fn test_parse_github_repo_ssh() { assert_eq!(parse_github_repo("git@github.com:owner/repo.git"), Some("owner/repo".into())); }
327    #[test]
328    fn test_parse_github_repo_not_github() { assert_eq!(parse_github_repo("https://gitlab.com/owner/repo.git"), None); }
329
330    // extract_notes
331
332    #[test]
333    fn test_extract_notes_found() {
334        let dir = tempfile::tempdir().unwrap();
335        std::fs::write(dir.path().join("CHANGELOG.md"), "## [1.0.0]\n\ncontent").unwrap();
336        assert!(extract_notes("v1.0.0", &dir.path().join("CHANGELOG.md")).is_some());
337    }
338
339    #[test]
340    fn test_extract_notes_not_found() {
341        let dir = tempfile::tempdir().unwrap();
342        std::fs::write(dir.path().join("CHANGELOG.md"), "## [1.0.0]\n\ncontent").unwrap();
343        assert!(extract_notes("v2.0.0", &dir.path().join("CHANGELOG.md")).is_none());
344    }
345
346    // confirm_release
347
348    #[test]
349    fn test_confirm_release_yes_flag() { assert!(confirm_release("v1.0.0", true)); }
350
351    // precheck_changelog
352
353    #[test]
354    fn test_precheck_changelog_no_errors() {
355        let dir = tempfile::tempdir().unwrap();
356        std::fs::write(dir.path().join("CHANGELOG.md"), "## [1.0.0]\n\ncontent").unwrap();
357        assert!(precheck_version_changelog("v1.0.0", &dir.path().join("CHANGELOG.md")).is_empty());
358    }
359
360    #[test]
361    fn test_precheck_changelog_missing_entry() {
362        let dir = tempfile::tempdir().unwrap();
363        std::fs::write(dir.path().join("CHANGELOG.md"), "## [1.0.0]\n\ncontent").unwrap();
364        assert!(precheck_version_changelog("v2.0.0", &dir.path().join("CHANGELOG.md")).iter().any(|e| e.contains("未找到")));
365    }
366
367    // stage helpers
368
369    fn git_init(path: &std::path::Path) {
370        std::process::Command::new("git")
371            .args(["init", "-b", "main"])
372            .current_dir(path)
373            .output().unwrap();
374        std::process::Command::new("git")
375            .args(["config", "user.email", "t@t"])
376            .current_dir(path).output().unwrap();
377        std::process::Command::new("git")
378            .args(["config", "user.name", "t"])
379            .current_dir(path).output().unwrap();
380        std::fs::write(path.join("f"), "").unwrap();
381        std::process::Command::new("git")
382            .args(["add", "."]).current_dir(path).output().unwrap();
383        std::process::Command::new("git")
384            .args(["commit", "-m", "init"]).current_dir(path).output().unwrap();
385    }
386
387    #[test]
388    fn test_stage_new_version() {
389        let dir = tempfile::tempdir().unwrap();
390        git_init(dir.path());
391        let id = stage("v1.0.0-rc.1", dir.path()).unwrap();
392        assert!(!id.is_empty());
393        let s = FileStorage::new(dir.path());
394        assert_eq!(s.load("v1.0.0-rc.1").unwrap().status, ReleaseStatus::Staged);
395    }
396
397    #[test]
398    fn test_stage_invalid_version() { assert!(stage("bad", tempfile::tempdir().unwrap().path()).is_err()); }
399
400    #[test]
401    fn test_stage_formal_rejected() {
402        let dir = tempfile::tempdir().unwrap();
403        git_init(dir.path());
404        let err = stage("v1.0.0", dir.path()).unwrap_err().to_string();
405        assert!(err.contains("仅用于预发布"));
406    }
407
408    #[test]
409    fn test_stage_published_rejected() {
410        let dir = tempfile::tempdir().unwrap();
411        git_init(dir.path());
412        let mut s = FileStorage::new(dir.path());
413        s.save(&make_record("v1.0.0-rc.1", ReleaseStatus::Published)).unwrap();
414        assert!(stage("v1.0.0-rc.1", dir.path()).unwrap_err().to_string().contains("已发布"));
415    }
416
417    #[test]
418    fn test_stage_cancelled_restage() {
419        let dir = tempfile::tempdir().unwrap();
420        git_init(dir.path());
421        let old_id;
422        {
423            let mut s = FileStorage::new(dir.path());
424            let r = make_record("v1.0.0-rc.1", ReleaseStatus::Cancelled);
425            old_id = r.id.clone();
426            s.save(&r).unwrap();
427        }
428        let id = stage("v1.0.0-rc.1", dir.path()).unwrap();
429        assert_ne!(id, old_id);
430    }
431
432    #[test]
433    fn test_stage_retired_rejected() {
434        let dir = tempfile::tempdir().unwrap();
435        git_init(dir.path());
436        let mut s = FileStorage::new(dir.path());
437        s.save(&make_record("v1.0.0-rc.1", ReleaseStatus::Retired)).unwrap();
438        assert!(stage("v1.0.0-rc.1", dir.path()).unwrap_err().to_string().contains("退役"));
439    }
440
441    #[test]
442    fn test_stage_idempotent() {
443        let dir = tempfile::tempdir().unwrap();
444        git_init(dir.path());
445        assert_eq!(stage("v1.0.0-rc.1", dir.path()).unwrap(), stage("v1.0.0-rc.1", dir.path()).unwrap());
446    }
447
448    // publish
449
450    #[test]
451    fn test_publish_not_found() {
452        let dir = tempfile::tempdir().unwrap();
453        assert!(publish("v1.0.0", dir.path(), true, None).unwrap_err().to_string().contains("请先执行 stage"));
454    }
455
456    #[test]
457    fn test_publish_not_staged() {
458        let dir = tempfile::tempdir().unwrap();
459        let mut s = FileStorage::new(dir.path());
460        let mut r = ReleaseRecord::new_staged("v1.0.0");
461        r.status = ReleaseStatus::Cancelled;
462        s.save(&r).unwrap();
463        assert!(publish("v1.0.0", dir.path(), true, None).is_err());
464    }
465
466    // retire
467
468    #[test]
469    fn test_retire_nonexistent() { assert!(retire("v9.9.9", tempfile::tempdir().unwrap().path()).is_err()); }
470
471    #[test]
472    fn test_retire_not_published() {
473        let dir = tempfile::tempdir().unwrap();
474        let mut s = FileStorage::new(dir.path());
475        s.save(&make_record("v1.0.0", ReleaseStatus::Staged)).unwrap();
476        assert!(retire("v1.0.0", dir.path()).is_err());
477    }
478
479    #[test]
480    fn test_retire_from_published() {
481        let dir = tempfile::tempdir().unwrap();
482        {
483            let mut s = FileStorage::new(dir.path());
484            s.save(&make_record("v1.0.0", ReleaseStatus::Published)).unwrap();
485        }
486        retire("v1.0.0", dir.path()).unwrap();
487        assert_eq!(FileStorage::new(dir.path()).load("v1.0.0").unwrap().status, ReleaseStatus::Retired);
488    }
489
490    // release_status
491
492    #[test]
493    fn test_release_status_empty() {
494        let dir = tempfile::tempdir().unwrap();
495        assert_eq!(release_status(dir.path()).unwrap(), "");
496    }
497
498    #[test]
499    fn test_release_status_with_records() {
500        let dir = tempfile::tempdir().unwrap();
501        let mut s = FileStorage::new(dir.path());
502        s.save(&make_record("v1.0.0", ReleaseStatus::Staged)).unwrap();
503        s.save(&make_record("v2.0.0", ReleaseStatus::Published)).unwrap();
504        assert_eq!(release_status(dir.path()).unwrap(), "2");
505    }
506
507    #[test]
508    fn test_release_status_multiple_staged() {
509        let dir = tempfile::tempdir().unwrap();
510        let mut s = FileStorage::new(dir.path());
511        s.save(&make_record("v1.0.0", ReleaseStatus::Staged)).unwrap();
512        s.save(&make_record("v2.0.0", ReleaseStatus::Staged)).unwrap();
513        s.save(&make_record("v3.0.0", ReleaseStatus::Published)).unwrap();
514        assert_eq!(release_status(dir.path()).unwrap(), "3");
515    }
516
517    // is_prerelease
518
519    #[test]
520    fn test_is_prerelease_rc() { assert!(is_prerelease("v1.0.0-rc.1")); }
521    #[test]
522    fn test_is_prerelease_alpha() { assert!(is_prerelease("v1.0.0-alpha.1")); }
523    #[test]
524    fn test_is_prerelease_beta() { assert!(is_prerelease("v1.0.0-beta.2")); }
525    #[test]
526    fn test_is_prerelease_scoped() { assert!(is_prerelease("cli/v0.3.2-rc.1")); }
527    #[test]
528    fn test_is_prerelease_formal() { assert!(!is_prerelease("v1.0.0")); }
529    #[test]
530    fn test_is_prerelease_formal_scoped() { assert!(!is_prerelease("cli/v0.3.2")); }
531
532    // Registry
533
534    #[test]
535    fn test_registry_debug() {
536        assert_eq!(format!("{:?}", Registry::PyPI), "PyPI");
537        assert_eq!(format!("{:?}", Registry::PubDev), "PubDev");
538        assert_eq!(format!("{:?}", Registry::Crates), "Crates");
539    }
540
541    #[test]
542    fn test_registry_clone_eq() {
543        assert_eq!(Registry::PyPI, Registry::PyPI);
544        assert_ne!(Registry::PyPI, Registry::PubDev);
545    }
546
547    // normalize_version
548
549    #[test]
550    fn test_normalize_version_v_prefix() { assert_eq!(normalize_version("v1.2.3"), "1.2.3"); }
551
552    #[test]
553    fn test_normalize_version_pkg() { assert_eq!(normalize_version("pkg/v1.2.3"), "1.2.3"); }
554
555    #[test]
556    fn test_normalize_version_no_prefix() { assert_eq!(normalize_version("1.2.3"), "1.2.3"); }
557
558    #[test]
559    fn test_normalize_version_scoped() { assert_eq!(normalize_version("cli/v0.3.2"), "0.3.2"); }
560
561    // validate_version edge cases
562
563    #[test]
564    fn test_validate_version_formal() { assert!(validate_version("v1.0.0")); }
565    #[test]
566    fn test_validate_version_prerelease() { assert!(validate_version("v1.0.0-rc.1")); }
567    #[test]
568    fn test_validate_version_no_v() { assert!(!validate_version("1.0.0")); }
569    #[test]
570    fn test_validate_version_empty() { assert!(!validate_version("")); }
571    #[test]
572    fn test_validate_version_scope_only() { assert!(!validate_version("cli/")); }
573
574    // get_remote_repo (no remote)
575
576    #[test]
577    fn test_get_remote_repo_no_git_repo() { assert_eq!(get_remote_repo(tempfile::tempdir().unwrap().path()), None); }
578
579    // create_release without gh (should fail gracefully)
580
581    #[test]
582    fn test_create_release_no_gh() { assert!(!create_release("v0.0.0-test", "", "no/repo")); }
583
584    // rollback_tag in temp repo
585
586    #[test]
587    fn test_precheck_changelog_file_not_found() {
588        let dir = tempfile::tempdir().unwrap();
589        let errors = precheck_version_changelog("v1.0.0", &dir.path().join("NONEXISTENT.md"));
590        assert!(errors.iter().any(|e| e.contains("不存在")));
591    }
592
593    #[test]
594    fn test_precheck_changelog_version_invalid() {
595        let dir = tempfile::tempdir().unwrap();
596        let errors = precheck_version_changelog("bad", &dir.path().join("CHANGELOG.md"));
597        assert!(errors.iter().any(|e| e.contains("格式错误")));
598    }
599
600    #[test]
601    fn test_create_tag_in_non_git_dir() {
602        assert!(!create_tag("v0.0.0-test", tempfile::tempdir().unwrap().path()));
603    }
604
605    #[test]
606    fn test_create_tag_duplicate_fails() {
607        let dir = tempfile::tempdir().unwrap();
608        git_init(dir.path());
609        assert!(create_tag("v0.0.0-test", dir.path()));
610        // second create should fail (tag already exists)
611        assert!(!create_tag("v0.0.0-test", dir.path()));
612    }
613
614    #[test]
615    fn test_push_tag_in_non_git_dir() {
616        assert!(!push_tag("v0.0.0-test", tempfile::tempdir().unwrap().path()));
617    }
618
619    #[test]
620    fn test_push_tag_fails_with_non_existent_remote() {
621        let dir = tempfile::tempdir().unwrap();
622        git_init(dir.path());
623        assert!(create_tag("v0.0.0-test-remote", dir.path()));
624        // add a non-existent remote so push fails with real error, not "no remote"
625        std::process::Command::new("git")
626            .args(["remote", "add", "origin", "https://nonexistent.invalid/repo.git"])
627            .current_dir(dir.path()).output().unwrap();
628        assert!(!push_tag("v0.0.0-test-remote", dir.path()));
629    }
630
631    #[test]
632    fn test_get_remote_repo_in_git_without_remote() {
633        let dir = tempfile::tempdir().unwrap();
634        std::process::Command::new("git")
635            .args(["init", "-b", "main"]).current_dir(dir.path()).output().unwrap();
636        assert_eq!(get_remote_repo(dir.path()), None);
637    }
638
639    #[test]
640    fn test_rollback_tag_removes_tag() {
641        let dir = tempfile::tempdir().unwrap();
642        std::process::Command::new("git")
643            .args(["init", "-b", "main"]).current_dir(dir.path()).output().unwrap();
644        std::fs::write(dir.path().join("f"), "").unwrap();
645        std::process::Command::new("git")
646            .args(["add", "."]).current_dir(dir.path()).output().unwrap();
647        std::process::Command::new("git")
648            .args(["-c", "user.name=t", "-c", "user.email=t@t", "commit", "-m", "x"])
649            .current_dir(dir.path()).output().unwrap();
650        assert!(create_tag("v0.0.0-test-rollback", dir.path()));
651        rollback_tag("v0.0.0-test-rollback", dir.path());
652        let output = std::process::Command::new("git")
653            .args(["tag", "-l"]).current_dir(dir.path()).output().unwrap();
654        assert!(!String::from_utf8_lossy(&output.stdout).contains("v0.0.0-test-rollback"));
655    }
656}