Skip to main content

qtcloud_devops_cli/release/
status.rs

1use std::collections::HashMap;
2use std::path::Path;
3
4use crate::contract;
5
6pub fn status(repo_path: &Path) {
7    let mut stdout = std::io::stdout();
8    status_to(&mut stdout, repo_path).ok();
9}
10
11pub fn status_to(writer: &mut impl std::io::Write, repo_path: &Path) -> std::io::Result<()> {
12    let scopes_map = load_scopes_map(repo_path);
13    let latest_tags = get_latest_tags_by_scope(repo_path);
14    let dirty = is_dirty(repo_path);
15
16    let other_scope_dirs: Vec<std::path::PathBuf> = scopes_map
17        .iter()
18        .filter(|(k, _)| *k != "(root)")
19        .map(|(_, v)| repo_path.join(v))
20        .collect();
21
22    writeln!(writer, "发布状态")?;
23    writeln!(writer, "{}", "─".repeat(40))?;
24
25    if latest_tags.is_empty() {
26        writeln!(writer, "  最新标签:     (无)")?;
27        return Ok(());
28    }
29
30    for (scope, tag) in &latest_tags {
31        let tag_only = tag.split('/').last().unwrap_or(tag);
32        let ver = tag_only.strip_prefix('v').unwrap_or(tag_only);
33
34        let scope_dir = if scope == "(root)" {
35            repo_path.to_path_buf()
36        } else {
37            match scopes_map.get(scope) {
38                Some(rel) => repo_path.join(rel),
39                None => {
40                    let d = repo_path.join(scope);
41                    if d.is_dir() {
42                        d
43                    } else {
44                        repo_path.to_path_buf()
45                    }
46                }
47            }
48        };
49
50        writeln!(writer, "  [{}]", scope)?;
51        let rel_path = scopes_map.get(scope).cloned().unwrap_or_else(|| {
52            if scope == "(root)" {
53                ".".to_string()
54            } else {
55                scope.clone()
56            }
57        });
58        writeln!(writer, "    路径:         {}", rel_path)?;
59        writeln!(writer, "    最新标签:     {}", tag)?;
60
61        let unreleased = count_unreleased_in_dir(repo_path, tag, &scope_dir);
62        writeln!(writer, "    未发布提交:   {}", unreleased)?;
63
64        if check_changelog(&scope_dir, ver) {
65            writeln!(writer, "    CHANGELOG:    ✅")?;
66        } else {
67            writeln!(writer, "    CHANGELOG:    ❌ 缺少 {} 条目", ver)?;
68        }
69
70        check_github_release(writer, repo_path, tag, &scope_dir, ver)?;
71        check_all_configs(writer, &scope_dir, &other_scope_dirs, ver)?;
72    }
73
74    if dirty {
75        writeln!(writer, "  工作区:       ❌ 有未提交变更")?;
76    } else {
77        writeln!(writer, "  工作区:       ✅ 干净")?;
78    }
79
80    Ok(())
81}
82
83/// 检查 GitHub Release 是否存在,以及 body 是否与 CHANGELOG 同步。
84fn check_github_release(
85    writer: &mut impl std::io::Write,
86    repo_path: &Path,
87    tag: &str,
88    scope_dir: &Path,
89    _version: &str,
90) -> std::io::Result<()> {
91    // 解析 GitHub 仓库
92    let repo = get_github_repo(repo_path);
93    let repo = match repo {
94        Some(r) => r,
95        None => return Ok(()),
96    };
97
98    // 查询 Release
99    let out = std::process::Command::new("gh")
100        .args([
101            "release", "view", tag, "--repo", &repo, "--json", "body", "--jq", ".body",
102        ])
103        .output()
104        .ok();
105
106    let body = match out {
107        Some(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).trim().to_string(),
108        _ => {
109            writeln!(writer, "    GitHub Release: ❌ 不存在")?;
110            return Ok(());
111        }
112    };
113
114    // 从 CHANGELOG 提取当前版本的 notes
115    let changelog_path = scope_dir.join("CHANGELOG.md");
116    let notes = super::util::extract_notes(tag, &changelog_path);
117    let notes = notes.unwrap_or_default();
118
119    if body == notes {
120        writeln!(writer, "    GitHub Release: ✅ body 与 CHANGELOG 一致")?;
121    } else if body.trim().is_empty() {
122        writeln!(writer, "    GitHub Release: ⚠️ body 为空")?;
123    } else if notes.is_empty() {
124        writeln!(
125            writer,
126            "    GitHub Release: ✅ 已创建 (CHANGELOG 无此版本条目)"
127        )?;
128    } else {
129        writeln!(writer, "    GitHub Release: ⚠️ body 与 CHANGELOG 不同步")?;
130    }
131
132    Ok(())
133}
134
135/// 从契约加载 scope 列表,转为 (name → dir) 映射。
136fn load_scopes_map(repo_path: &Path) -> HashMap<String, String> {
137    let mut map: HashMap<String, String> = contract::load_scopes(repo_path)
138        .into_iter()
139        .map(|s| (s.name, s.dir))
140        .collect();
141    if !map.contains_key("(root)") {
142        map.insert("(root)".to_string(), "".to_string());
143    }
144    map
145}
146
147fn get_latest_tags_by_scope(repo_path: &Path) -> Vec<(String, String)> {
148    let repo = match git2::Repository::open(repo_path) {
149        Ok(r) => r,
150        Err(_) => return vec![],
151    };
152    let tag_names = match repo.tag_names(None) {
153        Ok(t) => t,
154        Err(_) => return vec![],
155    };
156    let mut tags: Vec<&str> = tag_names.iter().flatten().collect();
157    tags.sort_by(|a, b| b.cmp(a));
158    collect_latest_tags(&tags)
159}
160
161pub fn collect_latest_tags(tags: &[&str]) -> Vec<(String, String)> {
162    let mut scopes: Vec<(String, String)> = Vec::new();
163    for t in tags {
164        let scope = if t.contains('/') {
165            t.split('/').next().unwrap_or("").to_string()
166        } else {
167            "(root)".to_string()
168        };
169        if !scopes.iter().any(|(s, _)| s == &scope) {
170            scopes.push((scope, t.to_string()));
171        }
172    }
173    scopes
174}
175
176fn count_unreleased_in_dir(repo_path: &Path, tag: &str, scope_dir: &Path) -> usize {
177    // 如果 scope_dir 本身是 git 仓库(子模组),在子模组内计数
178    if is_git_repo(scope_dir) {
179        return count_unreleased_in_submodule(scope_dir, tag);
180    }
181    let repo = match git2::Repository::open(repo_path) {
182        Ok(r) => r,
183        Err(_) => return 0,
184    };
185    let tag_ref = format!("refs/tags/{}", tag);
186    let tag_oid = match repo.find_reference(&tag_ref).ok().and_then(|r| r.target()) {
187        Some(t) => t,
188        None => return 0,
189    };
190    let head_oid = match repo.head().ok().and_then(|h| h.target()) {
191        Some(t) => t,
192        None => return 0,
193    };
194    let mut revwalk = match repo.revwalk() {
195        Ok(w) => w,
196        Err(_) => return 0,
197    };
198    if revwalk.push(head_oid).is_err() || revwalk.hide(tag_oid).is_err() {
199        return 0;
200    }
201    if scope_dir == repo_path {
202        return revwalk.count();
203    }
204    let rel = scope_dir.strip_prefix(repo_path).unwrap_or(scope_dir);
205    let rel_str = rel.to_string_lossy().trim_start_matches('/').to_string();
206    revwalk
207        .filter_map(|oid| oid.ok())
208        .filter(|oid| {
209            if let Ok(commit) = repo.find_commit(*oid) {
210                if let Ok(tree) = commit.tree() {
211                    tree.iter().any(|entry| {
212                        entry.name().map_or(false, |n| {
213                            n == &rel_str || n.starts_with(&format!("{}/", rel_str))
214                        })
215                    })
216                } else {
217                    false
218                }
219            } else {
220                false
221            }
222        })
223        .count()
224}
225
226/// 检测路径是否为 git 仓库
227fn is_git_repo(path: &Path) -> bool {
228    let git_dir = path.join(".git");
229    git_dir.is_dir() || git_dir.is_file()
230}
231
232/// 在子模组内统计未发布提交数(子模组自己的 tag 和 HEAD)
233fn count_unreleased_in_submodule(submodule_path: &Path, tag: &str) -> usize {
234    let repo = match git2::Repository::open(submodule_path) {
235        Ok(r) => r,
236        Err(_) => return 0,
237    };
238    let tag_ref = format!("refs/tags/{}", tag);
239    let tag_oid = match repo.find_reference(&tag_ref).ok().and_then(|r| r.target()) {
240        Some(t) => t,
241        None => return 0,
242    };
243    let head_oid = match repo.head().ok().and_then(|h| h.target()) {
244        Some(t) => t,
245        None => return 0,
246    };
247    let mut revwalk = match repo.revwalk() {
248        Ok(w) => w,
249        Err(_) => return 0,
250    };
251    if revwalk.push(head_oid).is_err() || revwalk.hide(tag_oid).is_err() {
252        return 0;
253    }
254    revwalk.count()
255}
256
257fn get_github_repo(repo_path: &Path) -> Option<String> {
258    let repo = git2::Repository::open(repo_path).ok()?;
259    let remote = repo.find_remote("origin").ok()?;
260    let url = remote.url()?;
261    let re = regex::Regex::new(r"github\.com[/:]([^/]+/[^/]+?)(?:\.git)?$").ok()?;
262    let caps = re.captures(url)?;
263    Some(caps.get(1)?.as_str().to_string())
264}
265
266fn check_all_configs(
267    writer: &mut impl std::io::Write,
268    repo_path: &Path,
269    other_scope_dirs: &[std::path::PathBuf],
270    expected: &str,
271) -> std::io::Result<()> {
272    let checks: [(&str, fn(&str) -> Option<String>); 5] = [
273        ("Cargo.toml", |c| extract_kv(c, "version")),
274        ("pyproject.toml", |c| extract_kv(c, "version")),
275        ("package.json", extract_json_version),
276        ("pubspec.yaml", |c| extract_kv_yaml(c, "version")),
277        ("setup.cfg", |c| extract_kv(c, "version")),
278    ];
279    for (name, extract) in &checks {
280        let content = match std::fs::read_to_string(&repo_path.join(name)) {
281            Ok(c) => c,
282            Err(_) => continue,
283        };
284        match extract(&content) {
285            Some(v) if v == expected => {
286                writeln!(writer, "    {:<15} {} ✅", format!("{}:", name), v)?
287            }
288            Some(v) => writeln!(
289                writer,
290                "    {:<15} {} ❌ (期望 {})",
291                format!("{}:", name),
292                v,
293                expected
294            )?,
295            None => writeln!(writer, "    {:<15} (未找到版本字段)", format!("{}:", name))?,
296        }
297    }
298    let vf = repo_path.join("VERSION");
299    if let Ok(c) = std::fs::read_to_string(&vf) {
300        let v = c.trim().to_string();
301        if !v.is_empty() {
302            if v == expected {
303                writeln!(writer, "    VERSION          {} ✅", v)?;
304            } else {
305                writeln!(writer, "    VERSION          {} ❌ (期望 {})", v, expected)?;
306            }
307        }
308    }
309    for p in find_go_files(repo_path, other_scope_dirs) {
310        let content = match std::fs::read_to_string(&p) {
311            Ok(c) => c,
312            Err(_) => continue,
313        };
314        for prefix in &[
315            "var Version = \"",
316            "var VERSION = \"",
317            "const Version = \"",
318            "const VERSION = \"",
319        ] {
320            for line in content.lines() {
321                let t = line.trim();
322                if let Some(rest) = t.strip_prefix(prefix) {
323                    if let Some(end) = rest.find('"') {
324                        let v = rest[..end].to_string();
325                        if !v.is_empty() {
326                            let rel = p.strip_prefix(repo_path).unwrap_or(&p);
327                            let name = rel.to_string_lossy();
328                            if v == expected {
329                                writeln!(writer, "    {:<15} {} ✅", format!("{}:", name), v)?;
330                            } else {
331                                writeln!(
332                                    writer,
333                                    "    {:<15} {} ❌ (期望 {})",
334                                    format!("{}:", name),
335                                    v,
336                                    expected
337                                )?;
338                            }
339                        }
340                    }
341                }
342            }
343        }
344    }
345    Ok(())
346}
347
348fn find_go_files(dir: &Path, excludes: &[std::path::PathBuf]) -> Vec<std::path::PathBuf> {
349    let mut files = Vec::new();
350    let entries = match std::fs::read_dir(dir) {
351        Ok(e) => e,
352        Err(_) => return files,
353    };
354    for entry in entries.flatten() {
355        let p = entry.path();
356        if p.is_dir() {
357            if excludes.iter().any(|e| p == *e) {
358                continue;
359            }
360            let name = p.file_name().and_then(|n| n.to_str()).unwrap_or("");
361            if !name.starts_with('.')
362                && name != "node_modules"
363                && name != "target"
364                && name != "vendor"
365            {
366                files.extend(find_go_files(&p, excludes));
367            }
368        } else if p.extension().and_then(|e| e.to_str()) == Some("go") {
369            files.push(p);
370        }
371    }
372    files
373}
374
375fn extract_kv(content: &str, key: &str) -> Option<String> {
376    let p1 = format!("{} = \"", key);
377    let p2 = format!("{} = '", key);
378    for line in content.lines() {
379        let t = line.trim();
380        if let Some(r) = t.strip_prefix(&p1) {
381            if let Some(e) = r.find('"') {
382                let v = r[..e].to_string();
383                if !v.is_empty() {
384                    return Some(v);
385                }
386            }
387        }
388        if let Some(r) = t.strip_prefix(&p2) {
389            if let Some(e) = r.find('\'') {
390                let v = r[..e].to_string();
391                if !v.is_empty() {
392                    return Some(v);
393                }
394            }
395        }
396    }
397    None
398}
399
400fn extract_json_version(content: &str) -> Option<String> {
401    for line in content.lines() {
402        let t = line.trim();
403        // 用 find 而非 strip_prefix,支持单行 JSON("version": 不在行首)
404        if let Some(pos) = t.find("\"version\":") {
405            let after_colon = t[pos + "\"version\":".len()..].trim();
406            // 定位第一个引号 -> value 起点
407            let value_start = after_colon.find('"')?;
408            let after_open = &after_colon[value_start + 1..];
409            // 定位闭合引号 -> value 终点
410            let value_end = after_open.find('"')?;
411            let v = &after_open[..value_end];
412            if !v.is_empty() {
413                return Some(v.to_string());
414            }
415        }
416    }
417    None
418}
419
420fn extract_kv_yaml(content: &str, key: &str) -> Option<String> {
421    let p = format!("{}:", key);
422    for line in content.lines() {
423        let t = line.trim();
424        if let Some(r) = t.strip_prefix(&p) {
425            let v = r.trim();
426            if !v.is_empty() && !v.starts_with('#') {
427                return Some(v.to_string());
428            }
429        }
430    }
431    None
432}
433
434fn check_changelog(repo_path: &Path, version: &str) -> bool {
435    if version.is_empty() {
436        return false;
437    }
438    std::fs::read_to_string(repo_path.join("CHANGELOG.md"))
439        .unwrap_or_default()
440        .contains(&format!("[{}]", version))
441}
442
443fn is_dirty(repo_path: &Path) -> bool {
444    let repo = match git2::Repository::open(repo_path) {
445        Ok(r) => r,
446        Err(_) => return false,
447    };
448    repo.statuses(None).map_or(false, |s| !s.is_empty())
449}
450
451#[cfg(test)]
452mod tests {
453    use super::*;
454
455    // ── extract_kv ────────────────────────────────────────────
456
457    #[test]
458    fn test_extract_kv_double_quotes() {
459        assert_eq!(
460            extract_kv("version = \"1.0.0\"\n", "version"),
461            Some("1.0.0".into())
462        );
463    }
464
465    #[test]
466    fn test_extract_kv_single_quotes() {
467        assert_eq!(
468            extract_kv("version = '2.0.0'\n", "version"),
469            Some("2.0.0".into())
470        );
471    }
472
473    #[test]
474    fn test_extract_kv_missing_key() {
475        assert_eq!(extract_kv("name = \"foo\"\n", "version"), None);
476    }
477
478    #[test]
479    fn test_extract_kv_empty_value() {
480        assert_eq!(extract_kv("version = \"\"\n", "version"), None);
481    }
482
483    #[test]
484    fn test_extract_kv_indented() {
485        assert_eq!(
486            extract_kv("  version = \"0.5.0\"\n", "version"),
487            Some("0.5.0".into())
488        );
489    }
490
491    // ── extract_json_version ───────────────────────────────────
492
493    #[test]
494    fn test_extract_json_version_normal() {
495        let content = "{\n  \"version\": \"1.0.0\",\n}\n";
496        assert_eq!(extract_json_version(content), Some("1.0.0".into()));
497    }
498
499    #[test]
500    fn test_extract_json_version_single_line() {
501        let content = r#"{"name":"foo","version":"2.0.0"}"#;
502        assert_eq!(extract_json_version(content), Some("2.0.0".into()));
503    }
504
505    #[test]
506    fn test_extract_json_version_trailing_comma() {
507        let content = r#"{"version":"1.0.0",}"#;
508        assert_eq!(extract_json_version(content), Some("1.0.0".into()));
509    }
510
511    #[test]
512    fn test_extract_json_version_missing() {
513        let content = r#"{"name":"foo"}"#;
514        assert_eq!(extract_json_version(content), None);
515    }
516
517    #[test]
518    fn test_extract_json_version_empty() {
519        let content = r#"{"version":""}"#;
520        assert_eq!(extract_json_version(content), None);
521    }
522
523    // ── extract_kv_yaml ───────────────────────────────────────
524
525    #[test]
526    fn test_extract_kv_yaml_normal() {
527        assert_eq!(
528            extract_kv_yaml("version: 1.0.0\n", "version"),
529            Some("1.0.0".into())
530        );
531    }
532
533    #[test]
534    fn test_extract_kv_yaml_indented() {
535        assert_eq!(
536            extract_kv_yaml("  version: 3.0.0\n", "version"),
537            Some("3.0.0".into())
538        );
539    }
540
541    #[test]
542    fn test_extract_kv_yaml_ignores_comment() {
543        assert_eq!(extract_kv_yaml("version: # 注释\n", "version"), None);
544    }
545
546    #[test]
547    fn test_extract_kv_yaml_missing() {
548        assert_eq!(extract_kv_yaml("name: foo\n", "version"), None);
549    }
550
551    #[test]
552    fn test_extract_kv_yaml_empty_value() {
553        assert_eq!(extract_kv_yaml("version:\n", "version"), None);
554    }
555
556    #[test]
557    fn test_collect_tags_empty() {
558        assert!(collect_latest_tags(&[]).is_empty());
559    }
560
561    #[test]
562    fn test_collect_tags_root_only() {
563        let tags = collect_latest_tags(&["v2.0.0", "v1.0.0"]);
564        assert_eq!(tags.len(), 1);
565        assert_eq!(tags[0].0, "(root)");
566        assert_eq!(tags[0].1, "v2.0.0");
567    }
568
569    #[test]
570    fn test_collect_tags_scoped() {
571        let tags = collect_latest_tags(&["cli/v0.1.0", "web/v0.2.0"]);
572        assert_eq!(tags.len(), 2);
573        assert_eq!(tags[0].0, "cli");
574        assert_eq!(tags[1].0, "web");
575    }
576
577    #[test]
578    fn test_collect_tags_prerelease_is_kept() {
579        // 输入已按版本降序,首个 tag 胜出(含 prerelease)
580        let tags = collect_latest_tags(&["cli/v0.2.0-rc.1", "cli/v0.1.0"]);
581        assert_eq!(tags.len(), 1);
582        assert_eq!(tags[0].1, "cli/v0.2.0-rc.1");
583    }
584
585    #[test]
586    fn test_collect_tags_prerelease_as_fallback() {
587        let tags = collect_latest_tags(&["cli/v0.1.0-rc.2", "cli/v0.1.0-rc.1"]);
588        assert_eq!(tags.len(), 1);
589        assert_eq!(tags[0].1, "cli/v0.1.0-rc.2");
590    }
591
592    /// 创建 mock bin 脚本并前置到 PATH,测试结束后还原。
593    fn with_mock_path<F: FnOnce(&Path) -> R, R>(scripts: &[(&str, &str)], f: F) -> R {
594        let dir = tempfile::tempdir().unwrap();
595        let bin = dir.path().join("bin");
596        std::fs::create_dir(&bin).unwrap();
597        for (name, body) in scripts {
598            let path = bin.join(name);
599            std::fs::write(&path, body).unwrap();
600            #[cfg(unix)]
601            std::process::Command::new("chmod")
602                .args(["+x", path.to_str().unwrap()])
603                .output()
604                .unwrap();
605        }
606        let old_path = std::env::var("PATH").unwrap_or_default();
607        std::env::set_var("PATH", format!("{}:{}", bin.display(), old_path));
608        let result = f(dir.path());
609        std::env::set_var("PATH", &old_path);
610        result
611    }
612
613    const GH_NOT_FOUND: &str = "#!/bin/sh\nexit 1\n";
614    const GH_WITH_BODY: &str = "#!/bin/sh\necho '{\"body\":\"content\"}'\n";
615
616    #[test]
617    fn test_status_gh_not_found() {
618        // 有 GitHub remote 但 gh CLI 返回不存在 → 不 panic
619        let dir = tempfile::tempdir().unwrap();
620        git_init_test(dir.path());
621        git_tag_test(dir.path(), "v1.0.0");
622        set_remote(dir.path());
623        with_mock_path(&[("gh", GH_NOT_FOUND)], |_| {
624            status(dir.path());
625        });
626    }
627
628    #[test]
629    fn test_status_gh_with_body() {
630        // gh 返回 body,CHANGELOG 匹配 → 一致
631        let dir = tempfile::tempdir().unwrap();
632        git_init_test(dir.path());
633        std::fs::write(dir.path().join("CHANGELOG.md"), "## [1.0.0]\n\ncontent\n").unwrap();
634        git_commit_test(dir.path());
635        git_tag_test(dir.path(), "v1.0.0");
636        set_remote(dir.path());
637        with_mock_path(&[("gh", GH_WITH_BODY)], |_| {
638            status(dir.path());
639        });
640    }
641
642    #[test]
643    fn test_status_custom_tags() {
644        // 自定义 mock git 返回的标签列表,覆盖多 scope 路径
645        let dir = tempfile::tempdir().unwrap();
646        // 用真实 git repo + 标签,验证 status 不 panic
647        git_init_test(dir.path());
648        git_tag_test(dir.path(), "cli/v0.1.0");
649        git_tag_test(dir.path(), "web/v0.2.0");
650        set_remote(dir.path());
651        with_mock_path(&[("gh", GH_NOT_FOUND)], |_| {
652            status(dir.path());
653        });
654    }
655
656    // ── 测试辅助 ────────────────────────────────────────────────
657
658    fn git_init_test(path: &Path) {
659        std::process::Command::new("git")
660            .args(["init", "-b", "main"])
661            .current_dir(path)
662            .output()
663            .unwrap();
664        std::process::Command::new("git")
665            .args(["config", "user.email", "t@t"])
666            .current_dir(path)
667            .output()
668            .unwrap();
669        std::process::Command::new("git")
670            .args(["config", "user.name", "t"])
671            .current_dir(path)
672            .output()
673            .unwrap();
674        std::fs::write(path.join("f"), "").unwrap();
675        std::process::Command::new("git")
676            .args(["add", "."])
677            .current_dir(path)
678            .output()
679            .unwrap();
680        std::process::Command::new("git")
681            .args(["commit", "-m", "init"])
682            .current_dir(path)
683            .output()
684            .unwrap();
685    }
686
687    fn git_commit_test(path: &Path) {
688        std::fs::write(path.join("f"), "x").unwrap();
689        std::process::Command::new("git")
690            .args(["add", "."])
691            .current_dir(path)
692            .output()
693            .unwrap();
694        std::process::Command::new("git")
695            .args(["commit", "-m", "x"])
696            .current_dir(path)
697            .output()
698            .unwrap();
699    }
700
701    fn git_tag_test(path: &Path, tag: &str) {
702        std::process::Command::new("git")
703            .args(["-C", path.to_str().unwrap(), "tag", tag])
704            .output()
705            .unwrap();
706    }
707
708    fn set_remote(path: &Path) {
709        std::process::Command::new("git")
710            .args([
711                "-C",
712                path.to_str().unwrap(),
713                "remote",
714                "add",
715                "origin",
716                "https://github.com/owner/repo.git",
717            ])
718            .output()
719            .unwrap();
720    }
721
722    #[test]
723    fn test_collect_tags_mixed_root_and_scoped() {
724        let tags = collect_latest_tags(&["v1.0.0", "cli/v0.2.0", "cli/v0.1.0"]);
725        assert_eq!(tags.len(), 2);
726        let root = tags.iter().find(|(s, _)| s == "(root)").unwrap();
727        assert_eq!(root.1, "v1.0.0");
728        let cli = tags.iter().find(|(s, _)| s == "cli").unwrap();
729        assert_eq!(cli.1, "cli/v0.2.0");
730    }
731
732    #[test]
733    fn test_status_to_output() {
734        let d = tempfile::tempdir().unwrap();
735        // 初始化 git 仓库
736        std::process::Command::new("git")
737            .args(["init", "-b", "main"])
738            .current_dir(d.path())
739            .output()
740            .unwrap();
741        std::process::Command::new("git")
742            .args(["config", "user.email", "t@t"])
743            .current_dir(d.path())
744            .output()
745            .unwrap();
746        std::process::Command::new("git")
747            .args(["config", "user.name", "t"])
748            .current_dir(d.path())
749            .output()
750            .unwrap();
751        std::fs::write(d.path().join("f"), "").unwrap();
752        std::process::Command::new("git")
753            .args(["add", "."])
754            .current_dir(d.path())
755            .output()
756            .unwrap();
757        std::process::Command::new("git")
758            .args(["commit", "-m", "init"])
759            .current_dir(d.path())
760            .output()
761            .unwrap();
762        // 打一个 tag
763        std::process::Command::new("git")
764            .args(["tag", "v1.0.0"])
765            .current_dir(d.path())
766            .output()
767            .unwrap();
768        // 写 CHANGELOG
769        std::fs::write(d.path().join("CHANGELOG.md"), "## [1.0.0]\n\ncontent\n").unwrap();
770
771        let mut buf = Vec::new();
772        let result = status_to(&mut buf, d.path());
773        assert!(result.is_ok(), "status_to 应成功: {:?}", result);
774        let out = String::from_utf8_lossy(&buf);
775        assert!(out.contains("发布状态"), "应包含标题");
776        assert!(out.contains("v1.0.0"), "应包含 tag 信息");
777    }
778}