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    let repo = match git2::Repository::open(repo_path) {
178        Ok(r) => r,
179        Err(_) => return 0,
180    };
181    let tag_ref = format!("refs/tags/{}", tag);
182    let tag_oid = match repo.find_reference(&tag_ref).ok().and_then(|r| r.target()) {
183        Some(t) => t,
184        None => return 0,
185    };
186    let head_oid = match repo.head().ok().and_then(|h| h.target()) {
187        Some(t) => t,
188        None => return 0,
189    };
190    let mut revwalk = match repo.revwalk() {
191        Ok(w) => w,
192        Err(_) => return 0,
193    };
194    if revwalk.push(head_oid).is_err() || revwalk.hide(tag_oid).is_err() {
195        return 0;
196    }
197    if scope_dir == repo_path {
198        return revwalk.count();
199    }
200    let rel = scope_dir.strip_prefix(repo_path).unwrap_or(scope_dir);
201    let rel_str = rel.to_string_lossy().trim_start_matches('/').to_string();
202    revwalk
203        .filter_map(|oid| oid.ok())
204        .filter(|oid| {
205            if let Ok(commit) = repo.find_commit(*oid) {
206                if let Ok(tree) = commit.tree() {
207                    tree.iter().any(|entry| {
208                        entry.name().map_or(false, |n| {
209                            n == &rel_str || n.starts_with(&format!("{}/", rel_str))
210                        })
211                    })
212                } else {
213                    false
214                }
215            } else {
216                false
217            }
218        })
219        .count()
220}
221
222fn get_github_repo(repo_path: &Path) -> Option<String> {
223    let repo = git2::Repository::open(repo_path).ok()?;
224    let remote = repo.find_remote("origin").ok()?;
225    let url = remote.url()?;
226    let re = regex::Regex::new(r"github\.com[/:]([^/]+/[^/]+?)(?:\.git)?$").ok()?;
227    let caps = re.captures(url)?;
228    Some(caps.get(1)?.as_str().to_string())
229}
230
231fn check_all_configs(
232    writer: &mut impl std::io::Write,
233    repo_path: &Path,
234    other_scope_dirs: &[std::path::PathBuf],
235    expected: &str,
236) -> std::io::Result<()> {
237    let checks: [(&str, fn(&str) -> Option<String>); 5] = [
238        ("Cargo.toml", |c| extract_kv(c, "version")),
239        ("pyproject.toml", |c| extract_kv(c, "version")),
240        ("package.json", extract_json_version),
241        ("pubspec.yaml", |c| extract_kv_yaml(c, "version")),
242        ("setup.cfg", |c| extract_kv(c, "version")),
243    ];
244    for (name, extract) in &checks {
245        let content = match std::fs::read_to_string(&repo_path.join(name)) {
246            Ok(c) => c,
247            Err(_) => continue,
248        };
249        match extract(&content) {
250            Some(v) if v == expected => {
251                writeln!(writer, "    {:<15} {} ✅", format!("{}:", name), v)?
252            }
253            Some(v) => writeln!(
254                writer,
255                "    {:<15} {} ❌ (期望 {})",
256                format!("{}:", name),
257                v,
258                expected
259            )?,
260            None => writeln!(writer, "    {:<15} (未找到版本字段)", format!("{}:", name))?,
261        }
262    }
263    let vf = repo_path.join("VERSION");
264    if let Ok(c) = std::fs::read_to_string(&vf) {
265        let v = c.trim().to_string();
266        if !v.is_empty() {
267            if v == expected {
268                writeln!(writer, "    VERSION          {} ✅", v)?;
269            } else {
270                writeln!(writer, "    VERSION          {} ❌ (期望 {})", v, expected)?;
271            }
272        }
273    }
274    for p in find_go_files(repo_path, other_scope_dirs) {
275        let content = match std::fs::read_to_string(&p) {
276            Ok(c) => c,
277            Err(_) => continue,
278        };
279        for prefix in &[
280            "var Version = \"",
281            "var VERSION = \"",
282            "const Version = \"",
283            "const VERSION = \"",
284        ] {
285            for line in content.lines() {
286                let t = line.trim();
287                if let Some(rest) = t.strip_prefix(prefix) {
288                    if let Some(end) = rest.find('"') {
289                        let v = rest[..end].to_string();
290                        if !v.is_empty() {
291                            let rel = p.strip_prefix(repo_path).unwrap_or(&p);
292                            let name = rel.to_string_lossy();
293                            if v == expected {
294                                writeln!(writer, "    {:<15} {} ✅", format!("{}:", name), v)?;
295                            } else {
296                                writeln!(
297                                    writer,
298                                    "    {:<15} {} ❌ (期望 {})",
299                                    format!("{}:", name),
300                                    v,
301                                    expected
302                                )?;
303                            }
304                        }
305                    }
306                }
307            }
308        }
309    }
310    Ok(())
311}
312
313fn find_go_files(dir: &Path, excludes: &[std::path::PathBuf]) -> Vec<std::path::PathBuf> {
314    let mut files = Vec::new();
315    let entries = match std::fs::read_dir(dir) {
316        Ok(e) => e,
317        Err(_) => return files,
318    };
319    for entry in entries.flatten() {
320        let p = entry.path();
321        if p.is_dir() {
322            if excludes.iter().any(|e| p == *e) {
323                continue;
324            }
325            let name = p.file_name().and_then(|n| n.to_str()).unwrap_or("");
326            if !name.starts_with('.')
327                && name != "node_modules"
328                && name != "target"
329                && name != "vendor"
330            {
331                files.extend(find_go_files(&p, excludes));
332            }
333        } else if p.extension().and_then(|e| e.to_str()) == Some("go") {
334            files.push(p);
335        }
336    }
337    files
338}
339
340fn extract_kv(content: &str, key: &str) -> Option<String> {
341    let p1 = format!("{} = \"", key);
342    let p2 = format!("{} = '", key);
343    for line in content.lines() {
344        let t = line.trim();
345        if let Some(r) = t.strip_prefix(&p1) {
346            if let Some(e) = r.find('"') {
347                let v = r[..e].to_string();
348                if !v.is_empty() {
349                    return Some(v);
350                }
351            }
352        }
353        if let Some(r) = t.strip_prefix(&p2) {
354            if let Some(e) = r.find('\'') {
355                let v = r[..e].to_string();
356                if !v.is_empty() {
357                    return Some(v);
358                }
359            }
360        }
361    }
362    None
363}
364
365fn extract_json_version(content: &str) -> Option<String> {
366    for line in content.lines() {
367        let t = line.trim();
368        // 用 find 而非 strip_prefix,支持单行 JSON("version": 不在行首)
369        if let Some(pos) = t.find("\"version\":") {
370            let after_colon = t[pos + "\"version\":".len()..].trim();
371            // 定位第一个引号 -> value 起点
372            let value_start = after_colon.find('"')?;
373            let after_open = &after_colon[value_start + 1..];
374            // 定位闭合引号 -> value 终点
375            let value_end = after_open.find('"')?;
376            let v = &after_open[..value_end];
377            if !v.is_empty() {
378                return Some(v.to_string());
379            }
380        }
381    }
382    None
383}
384
385fn extract_kv_yaml(content: &str, key: &str) -> Option<String> {
386    let p = format!("{}:", key);
387    for line in content.lines() {
388        let t = line.trim();
389        if let Some(r) = t.strip_prefix(&p) {
390            let v = r.trim();
391            if !v.is_empty() && !v.starts_with('#') {
392                return Some(v.to_string());
393            }
394        }
395    }
396    None
397}
398
399fn check_changelog(repo_path: &Path, version: &str) -> bool {
400    if version.is_empty() {
401        return false;
402    }
403    std::fs::read_to_string(repo_path.join("CHANGELOG.md"))
404        .unwrap_or_default()
405        .contains(&format!("[{}]", version))
406}
407
408fn is_dirty(repo_path: &Path) -> bool {
409    let repo = match git2::Repository::open(repo_path) {
410        Ok(r) => r,
411        Err(_) => return false,
412    };
413    repo.statuses(None).map_or(false, |s| !s.is_empty())
414}
415
416#[cfg(test)]
417mod tests {
418    use super::*;
419
420    // ── extract_kv ────────────────────────────────────────────
421
422    #[test]
423    fn test_extract_kv_double_quotes() {
424        assert_eq!(
425            extract_kv("version = \"1.0.0\"\n", "version"),
426            Some("1.0.0".into())
427        );
428    }
429
430    #[test]
431    fn test_extract_kv_single_quotes() {
432        assert_eq!(
433            extract_kv("version = '2.0.0'\n", "version"),
434            Some("2.0.0".into())
435        );
436    }
437
438    #[test]
439    fn test_extract_kv_missing_key() {
440        assert_eq!(extract_kv("name = \"foo\"\n", "version"), None);
441    }
442
443    #[test]
444    fn test_extract_kv_empty_value() {
445        assert_eq!(extract_kv("version = \"\"\n", "version"), None);
446    }
447
448    #[test]
449    fn test_extract_kv_indented() {
450        assert_eq!(
451            extract_kv("  version = \"0.5.0\"\n", "version"),
452            Some("0.5.0".into())
453        );
454    }
455
456    // ── extract_json_version ───────────────────────────────────
457
458    #[test]
459    fn test_extract_json_version_normal() {
460        let content = "{\n  \"version\": \"1.0.0\",\n}\n";
461        assert_eq!(extract_json_version(content), Some("1.0.0".into()));
462    }
463
464    #[test]
465    fn test_extract_json_version_single_line() {
466        let content = r#"{"name":"foo","version":"2.0.0"}"#;
467        assert_eq!(extract_json_version(content), Some("2.0.0".into()));
468    }
469
470    #[test]
471    fn test_extract_json_version_trailing_comma() {
472        let content = r#"{"version":"1.0.0",}"#;
473        assert_eq!(extract_json_version(content), Some("1.0.0".into()));
474    }
475
476    #[test]
477    fn test_extract_json_version_missing() {
478        let content = r#"{"name":"foo"}"#;
479        assert_eq!(extract_json_version(content), None);
480    }
481
482    #[test]
483    fn test_extract_json_version_empty() {
484        let content = r#"{"version":""}"#;
485        assert_eq!(extract_json_version(content), None);
486    }
487
488    // ── extract_kv_yaml ───────────────────────────────────────
489
490    #[test]
491    fn test_extract_kv_yaml_normal() {
492        assert_eq!(
493            extract_kv_yaml("version: 1.0.0\n", "version"),
494            Some("1.0.0".into())
495        );
496    }
497
498    #[test]
499    fn test_extract_kv_yaml_indented() {
500        assert_eq!(
501            extract_kv_yaml("  version: 3.0.0\n", "version"),
502            Some("3.0.0".into())
503        );
504    }
505
506    #[test]
507    fn test_extract_kv_yaml_ignores_comment() {
508        assert_eq!(extract_kv_yaml("version: # 注释\n", "version"), None);
509    }
510
511    #[test]
512    fn test_extract_kv_yaml_missing() {
513        assert_eq!(extract_kv_yaml("name: foo\n", "version"), None);
514    }
515
516    #[test]
517    fn test_extract_kv_yaml_empty_value() {
518        assert_eq!(extract_kv_yaml("version:\n", "version"), None);
519    }
520
521    #[test]
522    fn test_collect_tags_empty() {
523        assert!(collect_latest_tags(&[]).is_empty());
524    }
525
526    #[test]
527    fn test_collect_tags_root_only() {
528        let tags = collect_latest_tags(&["v2.0.0", "v1.0.0"]);
529        assert_eq!(tags.len(), 1);
530        assert_eq!(tags[0].0, "(root)");
531        assert_eq!(tags[0].1, "v2.0.0");
532    }
533
534    #[test]
535    fn test_collect_tags_scoped() {
536        let tags = collect_latest_tags(&["cli/v0.1.0", "web/v0.2.0"]);
537        assert_eq!(tags.len(), 2);
538        assert_eq!(tags[0].0, "cli");
539        assert_eq!(tags[1].0, "web");
540    }
541
542    #[test]
543    fn test_collect_tags_prerelease_is_kept() {
544        // 输入已按版本降序,首个 tag 胜出(含 prerelease)
545        let tags = collect_latest_tags(&["cli/v0.2.0-rc.1", "cli/v0.1.0"]);
546        assert_eq!(tags.len(), 1);
547        assert_eq!(tags[0].1, "cli/v0.2.0-rc.1");
548    }
549
550    #[test]
551    fn test_collect_tags_prerelease_as_fallback() {
552        let tags = collect_latest_tags(&["cli/v0.1.0-rc.2", "cli/v0.1.0-rc.1"]);
553        assert_eq!(tags.len(), 1);
554        assert_eq!(tags[0].1, "cli/v0.1.0-rc.2");
555    }
556
557    /// 创建 mock bin 脚本并前置到 PATH,测试结束后还原。
558    fn with_mock_path<F: FnOnce(&Path) -> R, R>(scripts: &[(&str, &str)], f: F) -> R {
559        let dir = tempfile::tempdir().unwrap();
560        let bin = dir.path().join("bin");
561        std::fs::create_dir(&bin).unwrap();
562        for (name, body) in scripts {
563            let path = bin.join(name);
564            std::fs::write(&path, body).unwrap();
565            #[cfg(unix)]
566            std::process::Command::new("chmod")
567                .args(["+x", path.to_str().unwrap()])
568                .output()
569                .unwrap();
570        }
571        let old_path = std::env::var("PATH").unwrap_or_default();
572        std::env::set_var("PATH", format!("{}:{}", bin.display(), old_path));
573        let result = f(dir.path());
574        std::env::set_var("PATH", &old_path);
575        result
576    }
577
578    const GH_NOT_FOUND: &str = "#!/bin/sh\nexit 1\n";
579    const GH_WITH_BODY: &str = "#!/bin/sh\necho '{\"body\":\"content\"}'\n";
580
581    #[test]
582    fn test_status_gh_not_found() {
583        // 有 GitHub remote 但 gh CLI 返回不存在 → 不 panic
584        let dir = tempfile::tempdir().unwrap();
585        git_init_test(dir.path());
586        git_tag_test(dir.path(), "v1.0.0");
587        set_remote(dir.path());
588        with_mock_path(&[("gh", GH_NOT_FOUND)], |_| {
589            status(dir.path());
590        });
591    }
592
593    #[test]
594    fn test_status_gh_with_body() {
595        // gh 返回 body,CHANGELOG 匹配 → 一致
596        let dir = tempfile::tempdir().unwrap();
597        git_init_test(dir.path());
598        std::fs::write(dir.path().join("CHANGELOG.md"), "## [1.0.0]\n\ncontent\n").unwrap();
599        git_commit_test(dir.path());
600        git_tag_test(dir.path(), "v1.0.0");
601        set_remote(dir.path());
602        with_mock_path(&[("gh", GH_WITH_BODY)], |_| {
603            status(dir.path());
604        });
605    }
606
607    #[test]
608    fn test_status_custom_tags() {
609        // 自定义 mock git 返回的标签列表,覆盖多 scope 路径
610        let dir = tempfile::tempdir().unwrap();
611        // 用真实 git repo + 标签,验证 status 不 panic
612        git_init_test(dir.path());
613        git_tag_test(dir.path(), "cli/v0.1.0");
614        git_tag_test(dir.path(), "web/v0.2.0");
615        set_remote(dir.path());
616        with_mock_path(&[("gh", GH_NOT_FOUND)], |_| {
617            status(dir.path());
618        });
619    }
620
621    // ── 测试辅助 ────────────────────────────────────────────────
622
623    fn git_init_test(path: &Path) {
624        std::process::Command::new("git")
625            .args(["init", "-b", "main"])
626            .current_dir(path)
627            .output()
628            .unwrap();
629        std::process::Command::new("git")
630            .args(["config", "user.email", "t@t"])
631            .current_dir(path)
632            .output()
633            .unwrap();
634        std::process::Command::new("git")
635            .args(["config", "user.name", "t"])
636            .current_dir(path)
637            .output()
638            .unwrap();
639        std::fs::write(path.join("f"), "").unwrap();
640        std::process::Command::new("git")
641            .args(["add", "."])
642            .current_dir(path)
643            .output()
644            .unwrap();
645        std::process::Command::new("git")
646            .args(["commit", "-m", "init"])
647            .current_dir(path)
648            .output()
649            .unwrap();
650    }
651
652    fn git_commit_test(path: &Path) {
653        std::fs::write(path.join("f"), "x").unwrap();
654        std::process::Command::new("git")
655            .args(["add", "."])
656            .current_dir(path)
657            .output()
658            .unwrap();
659        std::process::Command::new("git")
660            .args(["commit", "-m", "x"])
661            .current_dir(path)
662            .output()
663            .unwrap();
664    }
665
666    fn git_tag_test(path: &Path, tag: &str) {
667        std::process::Command::new("git")
668            .args(["-C", path.to_str().unwrap(), "tag", tag])
669            .output()
670            .unwrap();
671    }
672
673    fn set_remote(path: &Path) {
674        std::process::Command::new("git")
675            .args([
676                "-C",
677                path.to_str().unwrap(),
678                "remote",
679                "add",
680                "origin",
681                "https://github.com/owner/repo.git",
682            ])
683            .output()
684            .unwrap();
685    }
686
687    #[test]
688    fn test_collect_tags_mixed_root_and_scoped() {
689        let tags = collect_latest_tags(&["v1.0.0", "cli/v0.2.0", "cli/v0.1.0"]);
690        assert_eq!(tags.len(), 2);
691        let root = tags.iter().find(|(s, _)| s == "(root)").unwrap();
692        assert_eq!(root.1, "v1.0.0");
693        let cli = tags.iter().find(|(s, _)| s == "cli").unwrap();
694        assert_eq!(cli.1, "cli/v0.2.0");
695    }
696
697    #[test]
698    fn test_status_to_output() {
699        let d = tempfile::tempdir().unwrap();
700        // 初始化 git 仓库
701        std::process::Command::new("git")
702            .args(["init", "-b", "main"])
703            .current_dir(d.path())
704            .output()
705            .unwrap();
706        std::process::Command::new("git")
707            .args(["config", "user.email", "t@t"])
708            .current_dir(d.path())
709            .output()
710            .unwrap();
711        std::process::Command::new("git")
712            .args(["config", "user.name", "t"])
713            .current_dir(d.path())
714            .output()
715            .unwrap();
716        std::fs::write(d.path().join("f"), "").unwrap();
717        std::process::Command::new("git")
718            .args(["add", "."])
719            .current_dir(d.path())
720            .output()
721            .unwrap();
722        std::process::Command::new("git")
723            .args(["commit", "-m", "init"])
724            .current_dir(d.path())
725            .output()
726            .unwrap();
727        // 打一个 tag
728        std::process::Command::new("git")
729            .args(["tag", "v1.0.0"])
730            .current_dir(d.path())
731            .output()
732            .unwrap();
733        // 写 CHANGELOG
734        std::fs::write(d.path().join("CHANGELOG.md"), "## [1.0.0]\n\ncontent\n").unwrap();
735
736        let mut buf = Vec::new();
737        let result = status_to(&mut buf, d.path());
738        assert!(result.is_ok(), "status_to 应成功: {:?}", result);
739        let out = String::from_utf8_lossy(&buf);
740        assert!(out.contains("发布状态"), "应包含标题");
741        assert!(out.contains("v1.0.0"), "应包含 tag 信息");
742    }
743}