Compare commits
5 Commits
68427c4a11
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
3a57d25a76
|
|||
|
8152edba39
|
|||
|
679db5b1db
|
|||
|
b1ad68c7b5
|
|||
|
280d6ec5c9
|
12
Cargo.toml
12
Cargo.toml
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "quicommit"
|
name = "quicommit"
|
||||||
version = "0.1.11"
|
version = "0.2.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
authors = ["Sidney Zhang <zly@lyzhang.me>"]
|
authors = ["Sidney Zhang <zly@lyzhang.me>"]
|
||||||
description = "A powerful Git assistant tool with AI-powered commit/tag/changelog generation(alpha version)"
|
description = "A powerful Git assistant tool with AI-powered commit/tag/changelog generation(alpha version)"
|
||||||
@@ -83,11 +83,13 @@ mockall = "0.12"
|
|||||||
wiremock = "0.6"
|
wiremock = "0.6"
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
opt-level = 3
|
opt-level = "s"
|
||||||
lto = true
|
lto = "thin"
|
||||||
codegen-units = 1
|
codegen-units = 2
|
||||||
|
panic = "abort"
|
||||||
strip = true
|
strip = true
|
||||||
|
debug = false
|
||||||
|
|
||||||
[profile.dev]
|
[profile.dev]
|
||||||
opt-level = 0
|
opt-level = 1
|
||||||
debug = true
|
debug = true
|
||||||
|
|||||||
128
RAODMAP.md
Normal file
128
RAODMAP.md
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
# QuiCommit Roadmap
|
||||||
|
|
||||||
|
## 已完成 ✅
|
||||||
|
|
||||||
|
- [x] 基础 Git 操作(commit、tag、changelog)
|
||||||
|
- [x] AI 驱动提交信息生成(Conventional Commits / commitlint 格式)
|
||||||
|
- [x] 多 LLM 提供商支持:Ollama、OpenAI、Anthropic、Kimi、DeepSeek、OpenRouter
|
||||||
|
- [x] 多 Git Profile 管理(SSH 密钥 + GPG 签名)
|
||||||
|
- [x] 语义化版本自动升级与 AI 发布说明
|
||||||
|
- [x] Keep a Changelog 格式自动生成
|
||||||
|
- [x] 系统密钥环安全存储 API Key
|
||||||
|
- [x] 敏感数据加密存储(AES-GCM + Argon2)
|
||||||
|
- [x] 交互式 CLI 预览与确认
|
||||||
|
- [x] 7 种语言国际化支持
|
||||||
|
- [x] 配置导出/导入(支持加密保护)
|
||||||
|
- [x] Profile Token 管理(PAT 等)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 进行中 🚧
|
||||||
|
|
||||||
|
暂无。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 计划中 📋
|
||||||
|
|
||||||
|
### 1. Git 凭证管理器
|
||||||
|
|
||||||
|
将 Git 凭证管理集成到 QuiCommit 中,统一管理 HTTPS 仓库的身份认证。
|
||||||
|
|
||||||
|
- [ ] **Git Credential Helper 集成**
|
||||||
|
- 实现 `git credential-store` / `git-credential-libsecret` 等标准的 credential helper 协议
|
||||||
|
- 支持 `quicommit credential get|store|erase` 子命令
|
||||||
|
- 与系统密钥环无缝对接,复用已有的 `KeyringManager`
|
||||||
|
|
||||||
|
- [ ] **凭证管理 CLI**
|
||||||
|
- `quicommit credential list` — 列出所有已存储的凭证
|
||||||
|
- `quicommit credential add` — 手动添加凭证(用户名 + 密码/Token)
|
||||||
|
- `quicommit credential remove` — 删除指定凭证
|
||||||
|
- `quicommit credential status` — 查看凭证管理状态
|
||||||
|
|
||||||
|
- [ ] **跨平台支持**
|
||||||
|
- Windows:集成 Windows Credential Manager
|
||||||
|
- macOS:集成 Keychain
|
||||||
|
- Linux:通过 Secret Service / D-Bus 对接 GNOME Keyring / KWallet
|
||||||
|
|
||||||
|
- [ ] **安全增强**
|
||||||
|
- 支持 PAT(Personal Access Token)按 scope / 有效期管理
|
||||||
|
- 支持凭证过期检查和自动提醒
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. 新增模型支持
|
||||||
|
|
||||||
|
扩展 LLM 提供商和模型覆盖范围,满足更多场景和偏好。
|
||||||
|
|
||||||
|
- [ ] **新增 DeepSeek 最新模型**
|
||||||
|
- 支持 `deepseek-chat`(DeepSeek-V3)
|
||||||
|
- 支持 `deepseek-reasoner`(DeepSeek-R1)
|
||||||
|
- 支持 `deepseek-v4`(待官方发布后跟进)
|
||||||
|
|
||||||
|
- [ ] **新增国内模型提供商**
|
||||||
|
- 通义千问 (Qwen) — 阿里云 DashScope API
|
||||||
|
- 文心一言 (ERNIE) — 百度千帆 API
|
||||||
|
- 智谱 GLM — ChatGLM API
|
||||||
|
- 百川 (Baichuan) — Baichuan API
|
||||||
|
|
||||||
|
- [ ] **新增国际模型提供商**
|
||||||
|
- Google Gemini API
|
||||||
|
- Mistral AI API
|
||||||
|
- Cohere API
|
||||||
|
- Groq (LPU 推理加速)
|
||||||
|
|
||||||
|
- [ ] **本地模型扩展**
|
||||||
|
- 支持 llama.cpp 服务端(兼容 OpenAI API 格式)
|
||||||
|
- 支持 vLLM 部署的模型
|
||||||
|
- 本地模型推荐列表与一键配置向导
|
||||||
|
|
||||||
|
- [ ] **模型能力适配**
|
||||||
|
- 不同模型的 token 限制自适应
|
||||||
|
- 模型特定的 prompt 模板优化
|
||||||
|
- 支持 function calling / tool use(用于复杂生成场景)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. 生成体验优化
|
||||||
|
|
||||||
|
提升 AI 生成提交信息、标签说明和变更日志时的用户体验。
|
||||||
|
|
||||||
|
- [ ] **流式输出与实时反馈**
|
||||||
|
- 支持 SSE(Server-Sent Events)流式生成
|
||||||
|
- 终端打字机效果实时显示生成内容
|
||||||
|
- 流式生成过程中支持 `Ctrl+C` 中断
|
||||||
|
|
||||||
|
- [ ] **生成质量提升**
|
||||||
|
- 基于 commitlint 规则的后校验与自动修正
|
||||||
|
- 支持 Few-shot 示例引导(用户可自定义示例库)
|
||||||
|
- 生成结果的置信度评分与多候选方案
|
||||||
|
|
||||||
|
- [ ] **Diff 上下文增强**
|
||||||
|
- 智能 diff 摘要(大改动时自动压缩关键信息)
|
||||||
|
- 支持 `.gitattributes` 排除/包含规则
|
||||||
|
- 按文件类型分组生成更精准的提交描述
|
||||||
|
|
||||||
|
- [ ] **交互式编辑增强**
|
||||||
|
- 生成后支持内联编辑(类似 `git rebase -i` 体验)
|
||||||
|
- 支持重新生成指定部分(如 scope、description)
|
||||||
|
- 历史提交信息学习与风格适配
|
||||||
|
|
||||||
|
- [ ] **批量操作支持**
|
||||||
|
- 批量生成多个 commit(分组暂存区变更)
|
||||||
|
- `--dry-run` 预览模式(只生成本地查看,不写 Git)
|
||||||
|
|
||||||
|
- [ ] **性能优化**
|
||||||
|
- API 请求并发优化(多个模型同时生成候选)
|
||||||
|
- 本地缓存常用 prompt 模板
|
||||||
|
- 减少不必要的 diff 计算
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 长远规划 🌟
|
||||||
|
|
||||||
|
- [ ] **VS Code 扩展** — 在 IDE 内直接使用 QuiCommit
|
||||||
|
- [ ] **GitHub Action / GitLab CI 集成** — 自动化 PR 标题和描述生成
|
||||||
|
- [ ] **团队协作** — 共享 commit 风格配置、prompt 模板库
|
||||||
|
- [ ] **Web Dashboard** — 可视化管理多仓库的 Git 活动与 AI 生成统计
|
||||||
|
- [ ] **插件系统** — 允许社区贡献自定义 LLM 提供商和生成策略
|
||||||
@@ -47,7 +47,7 @@ impl ContentGenerator {
|
|||||||
format: CommitFormat,
|
format: CommitFormat,
|
||||||
language: Language,
|
language: Language,
|
||||||
) -> Result<GeneratedCommit> {
|
) -> Result<GeneratedCommit> {
|
||||||
let diff = repo.get_staged_diff()
|
let diff = repo.get_staged_diff_sorted()
|
||||||
.context("Failed to get staged diff")?;
|
.context("Failed to get staged diff")?;
|
||||||
|
|
||||||
if diff.is_empty() {
|
if diff.is_empty() {
|
||||||
@@ -116,7 +116,7 @@ impl ContentGenerator {
|
|||||||
) -> Result<GeneratedCommit> {
|
) -> Result<GeneratedCommit> {
|
||||||
use dialoguer::Select;
|
use dialoguer::Select;
|
||||||
|
|
||||||
let diff = repo.get_staged_diff()?;
|
let diff = repo.get_staged_diff_sorted()?;
|
||||||
|
|
||||||
if diff.is_empty() {
|
if diff.is_empty() {
|
||||||
anyhow::bail!("No staged changes");
|
anyhow::bail!("No staged changes");
|
||||||
|
|||||||
@@ -159,7 +159,7 @@ impl ChangelogGenerator {
|
|||||||
let mut output = format!("## [{}] - {}\n\n", version, date_str);
|
let mut output = format!("## [{}] - {}\n\n", version, date_str);
|
||||||
|
|
||||||
if self.group_by_type {
|
if self.group_by_type {
|
||||||
let grouped = self.group_commits(commits);
|
let _grouped = self.group_commits(commits);
|
||||||
|
|
||||||
// Standard categories
|
// Standard categories
|
||||||
let categories = vec![
|
let categories = vec![
|
||||||
@@ -230,7 +230,7 @@ impl ChangelogGenerator {
|
|||||||
|
|
||||||
fn generate_github_releases(
|
fn generate_github_releases(
|
||||||
&self,
|
&self,
|
||||||
version: &str,
|
_version: &str,
|
||||||
_date: DateTime<Utc>,
|
_date: DateTime<Utc>,
|
||||||
commits: &[CommitInfo],
|
commits: &[CommitInfo],
|
||||||
) -> Result<String> {
|
) -> Result<String> {
|
||||||
|
|||||||
115
src/git/mod.rs
115
src/git/mod.rs
@@ -8,8 +8,6 @@ pub mod changelog;
|
|||||||
pub mod commit;
|
pub mod commit;
|
||||||
pub mod tag;
|
pub mod tag;
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
use std::os::windows::ffi::OsStringExt;
|
|
||||||
|
|
||||||
fn normalize_path_for_git2(path: &Path) -> PathBuf {
|
fn normalize_path_for_git2(path: &Path) -> PathBuf {
|
||||||
let mut normalized = path.to_path_buf();
|
let mut normalized = path.to_path_buf();
|
||||||
@@ -346,6 +344,119 @@ impl GitRepo {
|
|||||||
Ok(diff_text)
|
Ok(diff_text)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get staged diff with files sorted by importance
|
||||||
|
/// Important files (source code) come first, then config files like Cargo.toml,
|
||||||
|
/// then lock files like Cargo.lock
|
||||||
|
pub fn get_staged_diff_sorted(&self) -> Result<String> {
|
||||||
|
let diff = self.get_staged_diff()?;
|
||||||
|
|
||||||
|
if diff.is_empty() {
|
||||||
|
return Ok(diff);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut file_diffs = Vec::new();
|
||||||
|
let mut current_file_diff = String::new();
|
||||||
|
let mut current_file = String::new();
|
||||||
|
|
||||||
|
for line in diff.lines() {
|
||||||
|
if line.starts_with("diff --git") {
|
||||||
|
// Save previous file diff if any
|
||||||
|
if !current_file_diff.is_empty() && !current_file.is_empty() {
|
||||||
|
file_diffs.push((current_file.clone(), current_file_diff.clone()));
|
||||||
|
}
|
||||||
|
current_file = extract_file_from_diff_line(line);
|
||||||
|
current_file_diff = format!("{}\n", line);
|
||||||
|
} else {
|
||||||
|
current_file_diff.push_str(line);
|
||||||
|
current_file_diff.push('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add the last file diff
|
||||||
|
if !current_file_diff.is_empty() && !current_file.is_empty() {
|
||||||
|
file_diffs.push((current_file, current_file_diff));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by file importance
|
||||||
|
file_diffs.sort_by(|a, b| {
|
||||||
|
let score_a = file_importance_score(&a.0);
|
||||||
|
let score_b = file_importance_score(&b.0);
|
||||||
|
score_b.cmp(&score_a) // Descending order
|
||||||
|
});
|
||||||
|
|
||||||
|
// Combine sorted diffs
|
||||||
|
let sorted_diff: String = file_diffs.into_iter()
|
||||||
|
.map(|(_, diff)| diff)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(sorted_diff)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract filename from diff --git line
|
||||||
|
fn extract_file_from_diff_line(line: &str) -> String {
|
||||||
|
// Format: "diff --git a/path/to/file b/path/to/file"
|
||||||
|
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||||
|
if parts.len() >= 3 {
|
||||||
|
// Return the second path (after b/)
|
||||||
|
if let Some(path) = parts[2].strip_prefix("b/") {
|
||||||
|
return path.to_string();
|
||||||
|
}
|
||||||
|
// Fallback to first path (after a/)
|
||||||
|
if let Some(path) = parts[1].strip_prefix("a/") {
|
||||||
|
return path.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
line.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate file importance score
|
||||||
|
/// Higher score = more important
|
||||||
|
fn file_importance_score(filename: &str) -> i32 {
|
||||||
|
// Priority list for important file types
|
||||||
|
let important_extensions = [
|
||||||
|
".rs", ".py", ".js", ".ts", ".tsx", ".jsx", ".go", ".java", ".cpp", ".c", ".rust",
|
||||||
|
".vue", ".svelte", ".html", ".css", ".scss", ".sass", ".less",
|
||||||
|
];
|
||||||
|
|
||||||
|
// Config files that are important but less than source code
|
||||||
|
let config_files = [
|
||||||
|
"Cargo.toml", "package.json", "go.mod", "go.sum", "pom.xml",
|
||||||
|
"Makefile", "CMakeLists.txt", "build.gradle", "gradle.properties",
|
||||||
|
];
|
||||||
|
|
||||||
|
// Lock files - lowest priority
|
||||||
|
let lock_files = [
|
||||||
|
"Cargo.lock", "package-lock.json", "yarn.lock", "pnpm-lock.yaml",
|
||||||
|
"Gemfile.lock", "composer.lock",
|
||||||
|
];
|
||||||
|
|
||||||
|
// Check lock files first (lowest priority)
|
||||||
|
for lock in lock_files.iter() {
|
||||||
|
if filename.ends_with(lock) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check config files (medium priority)
|
||||||
|
for config in config_files.iter() {
|
||||||
|
if filename.ends_with(config) {
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check important source files (highest priority)
|
||||||
|
for ext in important_extensions.iter() {
|
||||||
|
if filename.ends_with(ext) {
|
||||||
|
return 3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default priority for other files
|
||||||
|
2
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GitRepo {
|
||||||
/// Get unstaged diff
|
/// Get unstaged diff
|
||||||
pub fn get_unstaged_diff(&self) -> Result<String> {
|
pub fn get_unstaged_diff(&self) -> Result<String> {
|
||||||
let diff = self.repo.diff_index_to_workdir(None, None)?;
|
let diff = self.repo.diff_index_to_workdir(None, None)?;
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
#![allow(dead_code)]
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use clap::{Parser, Subcommand};
|
use clap::{Parser, Subcommand};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|||||||
@@ -41,10 +41,10 @@ pub fn get_editor() -> String {
|
|||||||
.or_else(|_| std::env::var("VISUAL"))
|
.or_else(|_| std::env::var("VISUAL"))
|
||||||
.unwrap_or_else(|_| {
|
.unwrap_or_else(|_| {
|
||||||
if cfg!(target_os = "windows") {
|
if cfg!(target_os = "windows") {
|
||||||
if let Ok(code) = which::which("code") {
|
if let Ok(_code) = which::which("code") {
|
||||||
return "code --wait".to_string();
|
return "code --wait".to_string();
|
||||||
}
|
}
|
||||||
if let Ok(notepad) = which::which("notepad") {
|
if let Ok(_notepad) = which::which("notepad") {
|
||||||
return "notepad".to_string();
|
return "notepad".to_string();
|
||||||
}
|
}
|
||||||
"notepad".to_string()
|
"notepad".to_string()
|
||||||
|
|||||||
Reference in New Issue
Block a user