diff --git a/.gitignore b/.gitignore index 7b8457d..bbb36cd 100644 --- a/.gitignore +++ b/.gitignore @@ -26,5 +26,6 @@ config.toml .qoder/ CLAUDE.md **/agents/ +adr/ .scratch/ CONTEXT.md \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml index ba6af9f..fc193ad 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "quicommit" -version = "0.5.0" +version = "0.5.5" edition = "2024" authors = ["Sidney Zhang "] description = "A powerful Git assistant tool with AI-powered commit/tag/changelog generation" @@ -47,6 +47,7 @@ tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } # Utilities chrono = { version = "0.4", features = ["serde"] } regex = "1.10" +roxmltree = "0.20" lazy_static = "1.4" colored = "2.1" handlebars = "5.1" diff --git a/README.md b/README.md index b8c8db6..4c9278d 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ A powerful AI-powered Git assistant for generating conventional commits, tags, a - **AI-Powered Generation**: Generate commits, tags, and changelogs using LLM APIs (Ollama, OpenAI, Anthropic, Kimi, DeepSeek, OpenRouter) or local models - **Conventional Commits**: Full support for Conventional Commits and commitlint formats - **Profile Management**: Manage multiple Git identities with SSH keys and GPG signing support -- **Smart Tagging**: Semantic version bumping with AI-generated release notes +- **Smart Tagging**: Semantic version bumping with AI-generated release notes; auto-detects version from 16+ project config files (Cargo.toml, package.json, pom.xml, pubspec.yaml, etc.) - **Changelog Generation**: Automatic changelog generation in Keep a Changelog format - **Security**: Use system keyring to store API keys securely - **Interactive UI**: Beautiful CLI with previews and confirmations @@ -87,7 +87,8 @@ When staging changes (auto-stage or `--all`), files matched by `.gitignore` rule # Auto-detect version bump quicommit tag -# Auto-detect version from Cargo.toml/pyproject.toml, fall back to commit analysis +# Auto-detect version from project config files (Cargo.toml, package.json, pom.xml, etc.), +# fall back to commit analysis quicommit tag --auto # Specify bump type @@ -338,7 +339,7 @@ quicommit config reset --force |--------|-------------| | `-n, --name` | Tag name | | `-b, --bump` | Version bump (major/minor/patch) | -| `-A, --auto` | Auto-detect version from Cargo.toml/pyproject.toml, fall back to commit analysis (conflicts with `--bump`) | +| `-A, --auto` | Auto-detect version from project config files (Cargo.toml, package.json, pom.xml, etc.), fall back to commit analysis (conflicts with `--bump`) | | `-m, --message` | Tag message | | `-g, --generate` | AI-generate message | | `-S, --sign` | GPG sign tag | diff --git a/readme_zh.md b/readme_zh.md index 0eb6e70..d3a95d6 100644 --- a/readme_zh.md +++ b/readme_zh.md @@ -17,7 +17,7 @@ - **AI智能生成**:使用LLM API(Ollama本地、OpenAI、Anthropic Claude、Kimi、DeepSeek、OpenRouter)生成提交信息、标签和变更日志 - **规范化提交**:支持Conventional Commits和commitlint格式规范 - **多配置管理**:为不同场景管理多个Git身份,支持SSH密钥和GPG签名配置 -- **智能标签管理**:基于语义版本自动检测升级,AI生成标签信息 +- **智能标签管理**:基于语义版本自动检测升级,AI生成标签信息;支持从 16+ 种项目配置文件(Cargo.toml、package.json、pom.xml、pubspec.yaml 等)自动读取版本 - **变更日志生成**:自动生成Keep a Changelog格式的变更日志 - **安全保护**:使用系统密钥环进行安全存储 - **交互式界面**:美观的CLI界面,支持预览和确认 @@ -86,7 +86,8 @@ quicommit commit --push # 自动检测版本升级 quicommit tag -# 从 Cargo.toml/pyproject.toml 自动检测版本,回退到基于 commit 的分析 +# 从项目配置文件(Cargo.toml、package.json、pom.xml 等)自动检测版本, +# 回退到基于 commit 的分析 quicommit tag --auto # 指定版本升级类型 @@ -332,7 +333,7 @@ quicommit config reset --force |------|------| | `-n, --name` | 标签名称 | | `-b, --bump` | 版本升级类型(major/minor/patch) | -| `-A, --auto` | 从 Cargo.toml/pyproject.toml 自动检测版本,回退到基于 commit 的分析(与 `--bump` 互斥) | +| `-A, --auto` | 从项目配置文件(Cargo.toml、package.json、pom.xml 等 16+ 种)自动检测版本,回退到基于 commit 的分析(与 `--bump` 互斥) | | `-m, --message` | 标签信息 | | `-g, --generate` | AI生成标签信息 | | `-S, --sign` | GPG签名标签 | diff --git a/src/commands/tag.rs b/src/commands/tag.rs index c1af793..a203013 100644 --- a/src/commands/tag.rs +++ b/src/commands/tag.rs @@ -8,8 +8,8 @@ use std::path::PathBuf; use crate::config::{Language, manager::ConfigManager}; use crate::generator::ContentGenerator; use crate::git::tag::{ - TagBuilder, VersionBump, bump_version, get_latest_version, read_project_version, - suggest_version_bump, + ConfigVersion, TagBuilder, VersionBump, bump_version, get_latest_version, + read_project_versions, suggest_version_bump, }; use crate::git::{GitRepo, find_repo}; use crate::i18n::Messages; @@ -343,14 +343,23 @@ impl TagCommand { let project_dir = std::env::current_dir()?; // 1. Try reading from project config files - if let Some(version) = read_project_version(&project_dir) { - let tag_name = format!("{}{}", prefix, version); - println!( - "{} {}", - messages.latest_version(), - tag_name.cyan() - ); - return Ok(tag_name); + let config_versions = read_project_versions(&project_dir); + + if !config_versions.is_empty() { + if config_versions.len() == 1 { + let cv = &config_versions[0]; + let tag_name = format!("{}{}", prefix, cv.version); + println!( + "{} ({}): {}", + messages.found_version_in(), + cv.source, + tag_name.cyan() + ); + return Ok(tag_name); + } + + // Multiple config files: let user choose + return self.select_config_version(&config_versions, prefix, messages); } // 2. Fall back to commit analysis @@ -385,6 +394,29 @@ impl TagCommand { Ok(tag_name) } + fn select_config_version( + &self, + config_versions: &[ConfigVersion], + prefix: &str, + messages: &Messages, + ) -> Result { + println!("\n{}", messages.found_multiple_versions().bold()); + + let items: Vec = config_versions + .iter() + .map(|cv| format!("{} → {}{}", cv.source, prefix, cv.version)) + .collect(); + + let selection = Select::new() + .with_prompt(messages.select_version_to_use()) + .items(&items) + .default(0) + .interact()?; + + let cv = &config_versions[selection]; + Ok(format!("{}{}", prefix, cv.version)) + } + fn input_message_interactive(&self, version: &str, messages: &Messages) -> Result { let default_msg = format!("Release {}", version); diff --git a/src/git/tag.rs b/src/git/tag.rs index db9444c..ab4e7f8 100644 --- a/src/git/tag.rs +++ b/src/git/tag.rs @@ -350,48 +350,216 @@ pub fn sort_tags_by_semver(tags: &mut [super::TagInfo]) { }); } -/// Read the project version from Cargo.toml or pyproject.toml. -/// Returns None if no project config file is found or version cannot be read. -pub fn read_project_version(project_dir: &Path) -> Option { - // Try Cargo.toml first - let cargo_path = project_dir.join("Cargo.toml"); - if cargo_path.exists() { - if let Ok(content) = std::fs::read_to_string(&cargo_path) { - if let Ok(value) = content.parse::() { - if let Some(version) = value - .get("package") - .and_then(|p| p.get("version")) - .and_then(|v| v.as_str()) - { - if let Ok(ver) = Version::parse(version) { - return Some(ver); - } - } - } +/// A version found in a project config file, tagged with its source. +#[derive(Debug, Clone)] +pub struct ConfigVersion { + /// Source file name, e.g. "Cargo.toml", "package.json" + pub source: String, + /// Parsed semantic version + pub version: Version, +} + +/// Read project versions from all supported config files in the project root. +/// +/// If `go.mod` exists the function returns an empty `Vec` immediately — Go projects +/// use git tags as the version authority and store nothing in go.mod. +/// +/// Otherwise every supported format is tried. Results are returned in a stable +/// order (see `PARSER_ORDER`). Files that don't exist or whose version can't be +/// parsed are silently skipped. +pub fn read_project_versions(project_dir: &Path) -> Vec { + // Go projects: go.mod has no version field; skip directly to commit analysis + if project_dir.join("go.mod").exists() { + return vec![]; + } + + let parsers: &[(&str, fn(&Path) -> Option)] = PARSER_ORDER; + let mut results = Vec::new(); + + for &(source, parser) in parsers { + if let Some(v) = parser(project_dir) { + results.push(ConfigVersion { + source: source.to_string(), + version: v, + }); } } - // Try pyproject.toml - let pyproject_path = project_dir.join("pyproject.toml"); - if pyproject_path.exists() { - if let Ok(content) = std::fs::read_to_string(&pyproject_path) { - if let Ok(value) = content.parse::() { - if let Some(version) = value - .get("project") - .and_then(|p| p.get("version")) - .and_then(|v| v.as_str()) - { - if let Ok(ver) = Version::parse(version) { - return Some(ver); - } - } + results +} + +/// Stable order for parser dispatch. No priority implied — all results are +/// surfaced to the user when more than one is found. +type Parser = fn(&Path) -> Option; +const PARSER_ORDER: &[(&str, Parser)] = &[ + ("Cargo.toml", try_cargo_toml), + ("package.json", try_package_json), + ("pyproject.toml", try_pyproject_toml), + ("pom.xml", try_pom_xml), + ("pubspec.yaml", try_pubspec_yaml), + ("build.sbt", try_build_sbt), + ("project.clj", try_project_clj), + ("mix.exs", try_mix_exs), + ("package.yaml", try_package_yaml), + ("Project.toml", try_project_toml_julia), + ("composer.json", try_composer_json), + (".csproj", try_csproj), + ("CMakeLists.txt", try_cmake_lists), + ("meson.build", try_meson_build), + (".gemspec", try_gemspec), +]; + +// ── Structured parsers ────────────────────────────────────────────── + +fn try_cargo_toml(dir: &Path) -> Option { + let content = std::fs::read_to_string(dir.join("Cargo.toml")).ok()?; + let value = content.parse::().ok()?; + let version_str = value.get("package")?.get("version")?.as_str()?; + Version::parse(version_str).ok() +} + +fn try_pyproject_toml(dir: &Path) -> Option { + let content = std::fs::read_to_string(dir.join("pyproject.toml")).ok()?; + let value = content.parse::().ok()?; + let version_str = value.get("project")?.get("version")?.as_str()?; + Version::parse(version_str).ok() +} + +fn try_project_toml_julia(dir: &Path) -> Option { + let content = std::fs::read_to_string(dir.join("Project.toml")).ok()?; + let value = content.parse::().ok()?; + let version_str = value.get("version")?.as_str()?; + Version::parse(version_str).ok() +} + +fn try_package_json(dir: &Path) -> Option { + let content = std::fs::read_to_string(dir.join("package.json")).ok()?; + let value: serde_json::Value = serde_json::from_str(&content).ok()?; + let version_str = value.get("version")?.as_str()?; + Version::parse(version_str).ok() +} + +fn try_composer_json(dir: &Path) -> Option { + let content = std::fs::read_to_string(dir.join("composer.json")).ok()?; + let value: serde_json::Value = serde_json::from_str(&content).ok()?; + let version_str = value.get("version")?.as_str()?; + Version::parse(version_str).ok() +} + +fn try_pom_xml(dir: &Path) -> Option { + let content = std::fs::read_to_string(dir.join("pom.xml")).ok()?; + let doc = roxmltree::Document::parse(&content).ok()?; + let project = doc.root_element(); + // Read that is a direct child of , not inside + for child in project.children() { + if child.is_element() && child.tag_name().name() == "version" { + if let Some(t) = child.text() { + return Version::parse(t.trim()).ok(); } } } - None } +fn try_csproj(dir: &Path) -> Option { + // Find any .csproj file in the root (take the first) + let csproj = std::fs::read_dir(dir) + .ok()? + .filter_map(|e| e.ok()) + .map(|e| e.path()) + .find(|p| p.extension().map_or(false, |ext| ext == "csproj"))?; + let content = std::fs::read_to_string(&csproj).ok()?; + let doc = roxmltree::Document::parse(&content).ok()?; + // Look for (preferred) or anywhere in the document + for node in doc.descendants() { + if node.is_element() { + match node.tag_name().name() { + "Version" => { + if let Some(t) = node.text() { + return Version::parse(t.trim()).ok(); + } + } + "VersionPrefix" => { + if let Some(t) = node.text() { + return Version::parse(t.trim()).ok(); + } + } + _ => {} + } + } + } + None +} + +// ── Regex-based parsers (code-as-config or simple data formats) ───── + +fn try_pubspec_yaml(dir: &Path) -> Option { + let content = std::fs::read_to_string(dir.join("pubspec.yaml")).ok()?; + let re = + regex::Regex::new(r##"(?m)^version\s*:\s*['"]?(\d+\.\d+\.\d+[^'"#\s]*)['"]?"##).ok()?; + let caps = re.captures(&content)?; + Version::parse(caps.get(1)?.as_str()).ok() +} + +fn try_package_yaml(dir: &Path) -> Option { + let content = std::fs::read_to_string(dir.join("package.yaml")).ok()?; + let re = + regex::Regex::new(r##"(?m)^version\s*:\s*['"]?(\d+\.\d+\.\d+[^'"#\s]*)['"]?"##).ok()?; + let caps = re.captures(&content)?; + Version::parse(caps.get(1)?.as_str()).ok() +} + +fn try_gemspec(dir: &Path) -> Option { + // A directory may contain multiple .gemspec files; take the first. + let gemspec = std::fs::read_dir(dir) + .ok()? + .filter_map(|e| e.ok()) + .map(|e| e.path()) + .find(|p| p.extension().map_or(false, |ext| ext == "gemspec"))?; + let content = std::fs::read_to_string(&gemspec).ok()?; + let re = + regex::Regex::new(r#"spec\.version\s*=\s*["'](\d+\.\d+\.\d+[^"']*)["']"#).ok()?; + let caps = re.captures(&content)?; + Version::parse(caps.get(1)?.as_str()).ok() +} + +fn try_build_sbt(dir: &Path) -> Option { + let content = std::fs::read_to_string(dir.join("build.sbt")).ok()?; + let re = regex::Regex::new(r#"version\s*:=\s*["'](\d+\.\d+\.\d+[^"']*)["']"#).ok()?; + let caps = re.captures(&content)?; + Version::parse(caps.get(1)?.as_str()).ok() +} + +fn try_project_clj(dir: &Path) -> Option { + let content = std::fs::read_to_string(dir.join("project.clj")).ok()?; + let re = regex::Regex::new(r#"\(defproject\s+[^\s]+\s+"(\d+\.\d+\.\d+[^"]*)""#).ok()?; + let caps = re.captures(&content)?; + Version::parse(caps.get(1)?.as_str()).ok() +} + +fn try_mix_exs(dir: &Path) -> Option { + let content = std::fs::read_to_string(dir.join("mix.exs")).ok()?; + let re = regex::Regex::new(r#"version\s*:\s*["'](\d+\.\d+\.\d+[^"']*)["']"#).ok()?; + let caps = re.captures(&content)?; + Version::parse(caps.get(1)?.as_str()).ok() +} + +fn try_cmake_lists(dir: &Path) -> Option { + let content = std::fs::read_to_string(dir.join("CMakeLists.txt")).ok()?; + let re = + regex::Regex::new(r"project\s*\([^)]*VERSION\s+(\d+\.\d+\.\d+[^\s)]*)").ok()?; + let caps = re.captures(&content)?; + Version::parse(caps.get(1)?.as_str()).ok() +} + +fn try_meson_build(dir: &Path) -> Option { + let content = std::fs::read_to_string(dir.join("meson.build")).ok()?; + let re = + regex::Regex::new(r#"version\s*:\s*["'](\d+\.\d+\.\d+[^"']*)["']"#).ok()?; + let caps = re.captures(&content)?; + Version::parse(caps.get(1)?.as_str()).ok() +} + #[cfg(test)] mod tests { use super::*; @@ -471,38 +639,42 @@ mod tests { } #[test] - fn test_read_project_version_from_cargo_toml() { + fn test_read_versions_cargo_toml() { let dir = tempfile::TempDir::new().unwrap(); std::fs::write( dir.path().join("Cargo.toml"), "[package]\nname = \"test\"\nversion = \"0.3.0\"\n", ) .unwrap(); - let version = read_project_version(dir.path()); - assert_eq!(version, Some(Version::new(0, 3, 0))); + let versions = read_project_versions(dir.path()); + assert_eq!(versions.len(), 1); + assert_eq!(versions[0].source, "Cargo.toml"); + assert_eq!(versions[0].version, Version::new(0, 3, 0)); } #[test] - fn test_read_project_version_from_pyproject_toml() { + fn test_read_versions_pyproject_toml() { let dir = tempfile::TempDir::new().unwrap(); std::fs::write( dir.path().join("pyproject.toml"), "[project]\nname = \"test\"\nversion = \"0.2.1\"\n", ) .unwrap(); - let version = read_project_version(dir.path()); - assert_eq!(version, Some(Version::new(0, 2, 1))); + let versions = read_project_versions(dir.path()); + assert_eq!(versions.len(), 1); + assert_eq!(versions[0].source, "pyproject.toml"); + assert_eq!(versions[0].version, Version::new(0, 2, 1)); } #[test] - fn test_read_project_version_no_config_files() { + fn test_read_versions_no_config_files() { let dir = tempfile::TempDir::new().unwrap(); - let version = read_project_version(dir.path()); - assert_eq!(version, None); + let versions = read_project_versions(dir.path()); + assert!(versions.is_empty()); } #[test] - fn test_read_project_version_cargo_takes_priority() { + fn test_read_versions_multiple_configs_returns_all() { let dir = tempfile::TempDir::new().unwrap(); std::fs::write( dir.path().join("Cargo.toml"), @@ -514,7 +686,98 @@ mod tests { "[project]\nname = \"test\"\nversion = \"0.2.1\"\n", ) .unwrap(); - let version = read_project_version(dir.path()); - assert_eq!(version, Some(Version::new(0, 3, 0))); + let versions = read_project_versions(dir.path()); + assert_eq!(versions.len(), 2); + // Order is defined by PARSER_ORDER: Cargo.toml before pyproject.toml + assert_eq!(versions[0].source, "Cargo.toml"); + assert_eq!(versions[0].version, Version::new(0, 3, 0)); + assert_eq!(versions[1].source, "pyproject.toml"); + assert_eq!(versions[1].version, Version::new(0, 2, 1)); + } + + #[test] + fn test_read_versions_package_json() { + let dir = tempfile::TempDir::new().unwrap(); + std::fs::write( + dir.path().join("package.json"), + r#"{"name": "test", "version": "1.2.3"}"#, + ) + .unwrap(); + let versions = read_project_versions(dir.path()); + assert_eq!(versions.len(), 1); + assert_eq!(versions[0].source, "package.json"); + assert_eq!(versions[0].version, Version::new(1, 2, 3)); + } + + #[test] + fn test_read_versions_pom_xml() { + let dir = tempfile::TempDir::new().unwrap(); + std::fs::write( + dir.path().join("pom.xml"), + "4.0.02.0.0", + ) + .unwrap(); + let versions = read_project_versions(dir.path()); + assert_eq!(versions.len(), 1); + assert_eq!(versions[0].source, "pom.xml"); + assert_eq!(versions[0].version, Version::new(2, 0, 0)); + } + + #[test] + fn test_read_versions_pom_xml_ignores_parent_version() { + let dir = tempfile::TempDir::new().unwrap(); + std::fs::write( + dir.path().join("pom.xml"), + "3.0.02.0.0", + ) + .unwrap(); + let versions = read_project_versions(dir.path()); + assert_eq!(versions.len(), 1); + assert_eq!(versions[0].version, Version::new(2, 0, 0)); + } + + #[test] + fn test_read_versions_pubspec_yaml() { + let dir = tempfile::TempDir::new().unwrap(); + std::fs::write( + dir.path().join("pubspec.yaml"), + "name: myapp\nversion: 1.0.0+1\n", + ) + .unwrap(); + let versions = read_project_versions(dir.path()); + assert_eq!(versions.len(), 1); + assert_eq!(versions[0].source, "pubspec.yaml"); + assert_eq!( + versions[0].version, + Version::parse("1.0.0+1").unwrap() + ); + } + + #[test] + fn test_read_versions_go_mod_skips_all() { + let dir = tempfile::TempDir::new().unwrap(); + std::fs::write(dir.path().join("go.mod"), "module example.com/m\n").unwrap(); + std::fs::write( + dir.path().join("Cargo.toml"), + "[package]\nname = \"test\"\nversion = \"0.3.0\"\n", + ) + .unwrap(); + // go.mod triggers immediate return — Cargo.toml is ignored + let versions = read_project_versions(dir.path()); + assert!(versions.is_empty()); + } + + #[test] + fn test_read_versions_project_toml_julia() { + let dir = tempfile::TempDir::new().unwrap(); + std::fs::write( + dir.path().join("Project.toml"), + "name = \"MyPkg\"\nversion = \"0.1.0\"\n", + ) + .unwrap(); + let versions = read_project_versions(dir.path()); + assert_eq!(versions.len(), 1); + assert_eq!(versions[0].source, "Project.toml"); + assert_eq!(versions[0].version, Version::new(0, 1, 0)); } } diff --git a/src/i18n/messages.rs b/src/i18n/messages.rs index 6aac085..87714ad 100644 --- a/src/i18n/messages.rs +++ b/src/i18n/messages.rs @@ -930,6 +930,42 @@ impl Messages { } } + pub fn found_version_in(&self) -> &str { + match self.language { + Language::English => "Found version in", + Language::Chinese => "在配置文件中找到版本", + Language::Japanese => "設定ファイルでバージョンが見つかりました", + Language::Korean => "구성 파일에서 버전을 찾음", + Language::Spanish => "Versión encontrada en", + Language::French => "Version trouvée dans", + Language::German => "Version gefunden in", + } + } + + pub fn found_multiple_versions(&self) -> &str { + match self.language { + Language::English => "Found versions in multiple config files:", + Language::Chinese => "在多个配置文件中找到版本:", + Language::Japanese => "複数の設定ファイルでバージョンが見つかりました:", + Language::Korean => "여러 구성 파일에서 버전을 찾음:", + Language::Spanish => "Versiones encontradas en múltiples archivos:", + Language::French => "Versions trouvées dans plusieurs fichiers :", + Language::German => "Versionen in mehreren Dateien gefunden:", + } + } + + pub fn select_version_to_use(&self) -> &str { + match self.language { + Language::English => "Select version to use", + Language::Chinese => "选择要使用的版本", + Language::Japanese => "使用するバージョンを選択", + Language::Korean => "사용할 버전 선택", + Language::Spanish => "Seleccionar versión a usar", + Language::French => "Sélectionner la version à utiliser", + Language::German => "Zu verwendende Version auswählen", + } + } + pub fn profile_description(&self) -> &str { match self.language { Language::English => "Profile description (optional)",