Skip to main content

quanttide_devops/source/
git.rs

1use std::path::Path;
2
3use crate::contract::Scope;
4use crate::contract::version::{normalize_version, read_all_config_versions};
5
6// ═══════════════════════════════════════════════════════════════════════
7// 错误类型
8// ═══════════════════════════════════════════════════════════════════════
9
10/// Git 源操作错误。
11#[derive(Debug)]
12pub enum GitSourceError {
13    /// 仓库打开失败。
14    RepoOpen(String),
15    /// git2 内部错误。
16    Git2(git2::Error),
17}
18
19impl std::fmt::Display for GitSourceError {
20    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
21        match self {
22            Self::RepoOpen(p) => write!(f, "无法打开仓库: {}", p),
23            Self::Git2(e) => write!(f, "git2 错误: {}", e),
24        }
25    }
26}
27
28impl std::error::Error for GitSourceError {}
29
30impl From<git2::Error> for GitSourceError {
31    fn from(e: git2::Error) -> Self {
32        Self::Git2(e)
33    }
34}
35
36// ═══════════════════════════════════════════════════════════════════════
37// 版本一致性
38// ═══════════════════════════════════════════════════════════════════════
39
40/// 版本一致性检查结果。
41#[derive(Debug)]
42pub struct VersionStatus {
43    /// 最新 git tag 的版本号(已标准化)。
44    pub tag_version: Option<String>,
45    /// 配置文件中找到的第一个非空版本号。
46    pub config_version: Option<String>,
47    /// tag 与配置文件版本是否一致。
48    pub consistent: bool,
49    /// 所有配置文件的版本号明细。(文件名, 版本号)
50    pub config_files: Vec<(String, Option<String>)>,
51}
52
53// ═══════════════════════════════════════════════════════════════════════
54// tag 读取
55// ═══════════════════════════════════════════════════════════════════════
56
57/// 获取指定 scope 的最新 tag,标准化后返回。
58///
59/// scope 匹配规则:
60/// - `cli/v0.1.0` → scope `cli` 匹配,返回 `0.1.0`
61/// - `v0.1.0`(无前缀)→ 任何 scope 都不匹配,仅在 scope 无专属 tag 时作为兜底
62/// - 使用 semver 排序,修复字符串排序 `v10 < v9` 的问题
63pub fn latest_tag(repo_path: &Path, scope_name: &str) -> Result<Option<String>, GitSourceError> {
64    let tags = all_tags(repo_path)?;
65    let prefix = format!("{}/", scope_name);
66
67    let mut scoped: Vec<&str> = Vec::new();
68    let mut unscoped: Vec<&str> = Vec::new();
69    for tag in &tags {
70        if let Some(rest) = tag.strip_prefix(&prefix) {
71            if !rest.is_empty() {
72                scoped.push(tag);
73            }
74        } else if !tag.contains('/') {
75            unscoped.push(tag);
76        }
77    }
78
79    scoped.sort_by(|a, b| semver_desc(a, b));
80    unscoped.sort_by(|a, b| semver_desc(a, b));
81
82    match scoped.first() {
83        Some(t) => Ok(Some(normalize_version(t))),
84        None => Ok(unscoped.first().map(|t| normalize_version(t))),
85    }
86}
87
88/// 获取指定 scope 的所有 tag(原始格式,未标准化)。
89pub fn tags_for_scope(repo_path: &Path, scope_name: &str) -> Result<Vec<String>, GitSourceError> {
90    let tags = all_tags(repo_path)?;
91    let prefix = format!("{}/", scope_name);
92    Ok(tags
93        .into_iter()
94        .filter(|t| t.starts_with(&prefix))
95        .collect())
96}
97
98/// 读取仓库中所有 tag 名称。
99fn all_tags(repo_path: &Path) -> Result<Vec<String>, GitSourceError> {
100    let repo = git2::Repository::open(repo_path)
101        .map_err(|_| GitSourceError::RepoOpen(repo_path.display().to_string()))?;
102    let tag_names = repo.tag_names(None)?;
103    Ok(tag_names.iter().flatten().map(String::from).collect())
104}
105
106/// 检查 scope 配置文件版本与最新 git tag 是否一致。
107pub fn version_status(repo_path: &Path, scope: &Scope) -> Result<VersionStatus, GitSourceError> {
108    let tag_version = latest_tag(repo_path, &scope.name)?;
109    let scope_dir = repo_path.join(&scope.dir);
110    let config_files = read_all_config_versions(&scope_dir);
111    let config_version = config_files
112        .iter()
113        .find(|(_, v)| v.is_some())
114        .and_then(|(_, v)| v.clone());
115
116    let consistent = match &tag_version {
117        Some(t) => config_files.iter().all(|(_, v)| match v {
118            Some(cv) => cv == t,
119            None => true,
120        }),
121        None => config_version.is_none(),
122    };
123
124    Ok(VersionStatus {
125        tag_version,
126        config_version,
127        consistent,
128        config_files,
129    })
130}
131
132// ═══════════════════════════════════════════════════════════════════════
133// semver 比较(内联,不引入 semver crate)
134// ═══════════════════════════════════════════════════════════════════════
135
136fn parse_semver(tag: &str) -> (u64, u64, u64) {
137    let after_scope = tag.split('/').next_back().unwrap_or(tag);
138    let ver = after_scope.strip_prefix('v').unwrap_or(after_scope);
139    let parts: Vec<&str> = ver.split('.').collect();
140    if parts.len() < 3 {
141        return (0, 0, 0);
142    }
143    let major = parts[0].parse().unwrap_or(0);
144    let minor = parts[1].parse().unwrap_or(0);
145    let patch_str: String = parts[2]
146        .chars()
147        .take_while(|c| c.is_ascii_digit())
148        .collect();
149    let patch = patch_str.parse().unwrap_or(0);
150    (major, minor, patch)
151}
152
153fn semver_desc(a: &str, b: &str) -> std::cmp::Ordering {
154    let va = parse_semver(a);
155    let vb = parse_semver(b);
156    vb.cmp(&va) // 降序:v0.2.0 < v0.1.0 → Less → v0.2.0 排在 v0.1.0 前
157}
158
159// ═══════════════════════════════════════════════════════════════════════
160// 测试
161// ═══════════════════════════════════════════════════════════════════════
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166
167    fn init_repo_with_tags(dir: &Path, tags: &[&str]) {
168        let repo = git2::Repository::init(dir).unwrap();
169        let sig = git2::Signature::now("test", "test@test.com").unwrap();
170        let tree = {
171            let mut index = repo.index().unwrap();
172            let oid = index.write_tree().unwrap();
173            repo.find_tree(oid).unwrap()
174        };
175        let commit = repo
176            .commit(Some("HEAD"), &sig, &sig, "init", &tree, &[])
177            .unwrap();
178        for tag in tags {
179            repo.tag_lightweight(tag, &repo.find_object(commit, None).unwrap(), false)
180                .unwrap();
181        }
182    }
183
184    // ── latest_tag ────────────────────────────────────────────
185
186    #[test]
187    fn test_latest_tag_no_tags() {
188        let d = tempfile::tempdir().unwrap();
189        git2::Repository::init(d.path()).unwrap();
190        assert_eq!(latest_tag(d.path(), "cli").unwrap(), None);
191    }
192
193    #[test]
194    fn test_latest_tag_scoped() {
195        let d = tempfile::tempdir().unwrap();
196        init_repo_with_tags(d.path(), &["cli/v0.2.0", "cli/v0.1.0", "v1.0.0"]);
197        assert_eq!(latest_tag(d.path(), "cli").unwrap(), Some("0.2.0".into()));
198    }
199
200    #[test]
201    fn test_latest_tag_unscoped_fallback() {
202        let d = tempfile::tempdir().unwrap();
203        init_repo_with_tags(d.path(), &["v1.0.0"]);
204        assert_eq!(latest_tag(d.path(), "cli").unwrap(), Some("1.0.0".into()));
205    }
206
207    #[test]
208    fn test_latest_tag_semver_sort() {
209        let d = tempfile::tempdir().unwrap();
210        init_repo_with_tags(d.path(), &["cli/v9.0.0", "cli/v10.0.0"]);
211        assert_eq!(latest_tag(d.path(), "cli").unwrap(), Some("10.0.0".into()));
212    }
213
214    #[test]
215    fn test_latest_tag_multiple_scopes() {
216        let d = tempfile::tempdir().unwrap();
217        init_repo_with_tags(d.path(), &["cli/v0.2.0", "studio/v0.3.0", "cli/v0.1.0"]);
218        assert_eq!(latest_tag(d.path(), "cli").unwrap(), Some("0.2.0".into()));
219        assert_eq!(
220            latest_tag(d.path(), "studio").unwrap(),
221            Some("0.3.0".into())
222        );
223    }
224
225    // ── tags_for_scope ─────────────────────────────────────────
226
227    #[test]
228    fn test_tags_for_scope() {
229        let d = tempfile::tempdir().unwrap();
230        init_repo_with_tags(d.path(), &["cli/v0.1.0", "cli/v0.2.0", "studio/v0.1.0"]);
231        let tags = tags_for_scope(d.path(), "cli").unwrap();
232        assert_eq!(tags.len(), 2);
233        assert!(tags.contains(&"cli/v0.1.0".to_string()));
234        assert!(tags.contains(&"cli/v0.2.0".to_string()));
235    }
236
237    #[test]
238    fn test_tags_for_scope_no_match() {
239        let d = tempfile::tempdir().unwrap();
240        init_repo_with_tags(d.path(), &["v1.0.0"]);
241        assert!(tags_for_scope(d.path(), "cli").unwrap().is_empty());
242    }
243
244    // ── parse_semver ───────────────────────────────────────────
245
246    #[test]
247    fn test_parse_semver_standard() {
248        assert_eq!(parse_semver("v1.2.3"), (1, 2, 3));
249    }
250
251    #[test]
252    fn test_parse_semver_scoped() {
253        assert_eq!(parse_semver("cli/v0.5.0"), (0, 5, 0));
254    }
255
256    #[test]
257    fn test_parse_semver_prerelease() {
258        assert_eq!(parse_semver("v1.0.0-rc.1"), (1, 0, 0));
259    }
260
261    #[test]
262    fn test_parse_semver_no_v() {
263        assert_eq!(parse_semver("1.2.3"), (1, 2, 3));
264    }
265
266    #[test]
267    fn test_parse_semver_invalid() {
268        assert_eq!(parse_semver("not-a-version"), (0, 0, 0));
269    }
270}