43 Commits

Author SHA1 Message Date
7440523260 chore(release): 发布 0.9.0 版本并更新路线图 2026-10-09 10:19:08 +08:00
c2f914307d feat(ginit): 新增一键初始化本地 Git 仓库命令
`quicommit ginit`(别名 `gi`)离线完成仓库起步:用 git2 建库并确立初始分支、
写最简 README.md、按根目录标志文件装配 .gitignore、按 --add 添加远程,
`--profile` 可选套用已有 Git 配置。在已有仓库上重复执行只补齐缺失项:绝不覆盖
既有的 README.md/.gitignore、绝不改动已有 remote 的地址、不提供 --force、
不创建首个提交。边界取舍见 ADR 0006,命令命名见 ADR 0005。

实现落在新的 src/git/init.rs(9 个 git 层函数)与 src/commands/ginit.rs,
命令层不直接引用 git2,GitRepo 公开接口未改动,其余命令行为不变。
测试:src/git/init.rs 11 条单元测试、tests/ginit_tests.rs 18 条集成测试;
新增 35 个 ginit_* 消息覆盖 7 种语言。

另将 CONTEXT.md 与 docs/adr/ 纳入版本跟踪(.gitignore 放开 adr/ 与
CONTEXT.md 两条忽略规则);RAODMAP.md 除 ginit 条目外还包含此前未提交的
规划条目更新。
2026-10-09 09:36:04 +08:00
ef64e7d844 fix: 修正 DeepSeek 默认 base_url 去除多余 /v1 后缀 2026-09-21 17:28:50 +08:00
1dd98885da chore(release): 发布 v0.8.1 2026-08-31 14:47:23 +08:00
8ff7504d96 fix(cli): 未指定 --no-color 时恢复 colored 的 TTY 自动检测
set_override(!no_color) 在默认情况下无条件强制着色,导致管道/CI/
测试捕获的输出混入 ANSI 色码,违背 ADR-0003 "color keeps colored's
existing tty auto-detection" 的规定。改为仅在 --no-color 或 NO_COLOR
存在时强制关闭颜色,其余场景交给 colored 按 stdout 是否为 TTY 自动
判断:终端体验不变,管道输出恢复纯文本。
2026-08-31 14:37:10 +08:00
fac96021fb feat(commit,tag): 推送环节感知远程配置并支持多远程选择推送
- 仓库未配置任何远程时跳过推送询问并打印说明(--push 显式指定同样跳过)
- --remote 指定的目标不存在时回退到第一个可用远程(字母序)并明确提示
- 新增 --select-remote:多远程仓库弹出多选列表依次推送,失败即中止并汇总已推送/未尝试
- 优先级链:--yes > --select-remote > --push
- commit/tag 推送段收敛为 commands::run_push_flow 共享流程
- GitRepo 新增 list_remotes/resolve_push_target,i18n 新文案覆盖 7 种语言
2026-08-31 14:19:58 +08:00
b391bdabaf docs(changelog): 更新 0.7.0 版本发布说明 2026-08-21 14:43:38 +08:00
d6958dc41e chore: 升级版本至 0.7.0 并启用 regex unicode-perl 特性 2026-08-21 14:07:02 +08:00
a40c88c768 feat(changelog): 添加 --no-generate 标志以支持纯模板生成
- 新增 `--no-generate` 参数,强制使用模板生成而非 AI
- 调整 `--yes` 参数仅跳过交互提示,不改变生成行为
- 统一使用工具函数替代 println 输出
2026-08-21 13:57:25 +08:00
e6b8b344aa refactor: 将 lazy_static 和 atty 替换为标准库实现以减少依赖 2026-08-18 13:45:13 +08:00
a0ea03fd90 chore(release): bump version to 0.6.0 and update changelog
- CHANGELOG 记录 LLM 层迁移至 rig-core 0.41 及四项行为变化
- README(中/英)项目结构树更新为新 llm 模块布局
- 仓库内 AI 服务集成文档同步为新实现(.qoder,不入库)
2026-08-17 16:08:46 +08:00
3c2b96a4d1 refactor(llm): 删除手写 provider 实现与冗余依赖
- 删除 6 个手写 provider 文件、LlmProvider trait、动态分发、HTTP 客户端工厂与门面死代码
- llm 模块收敛为 rig 门面 + 提示词/解析/思考状态四个模块
- 移除 reqwest(0.12) 与 async-trait 直接依赖(futures-util 由流式消费保留)
- 依赖树确认无 rig-agent/fastembed/lancedb/milvus 等组件
2026-08-17 16:04:26 +08:00
16ffc94a06 feat(llm): 生成器与连接测试切换到新 rig 门面(全量上线)
- 新门面补齐 commit/tag/changelog 三个业务方法(提示词与解析复用迁移模块)
- ContentGenerator 改用 rig 门面(构造/可用性检查/三个生成方法)
- test_connection 走新门面公开接口,不再访问私有字段
- 新增离线业务方法测试与 6 家 provider 的 #[ignore] 实网冒烟用例
2026-08-17 15:56:56 +08:00
9f177f7a1f feat(llm): 新门面接入 OpenAI 与 OpenRouter
- OpenAI 使用 chat completions 协议面(completions_api),兼容第三方兼容网关
- o 系列模型非 thinking 传 reasoning_effort=none;thinking 走流式思考显示
- OpenRouter 携带 QuiCommit 应用标识头(X-OpenRouter-Title/HTTP-Referer)
- 新增 5 个测试:请求形状、o 系列规则、标识头、流式事件
2026-08-17 15:52:41 +08:00
18728a4a2e feat(llm): 新门面接入 Anthropic(thinking 预算与温度规则)
- provider 枚举增加 Anthropic 变体;base_url 由 rig 规范化(修复旧实现忽略配置)
- thinking 透传 thinking 块与预算 token(缺省 1024);thinking 时省略温度
- max_tokens 下限规则:不低于预算 + 100(沿用旧实现)
- 流式思考事件驱动显示与正文聚合与 DeepSeek/Kimi 机制一致
- 新增 5 个测试:请求形状、request_options 规则、SSE 事件序列
2026-08-17 15:47:31 +08:00
f534ccc698 feat(llm): 新门面接入 DeepSeek 与 Kimi,实现 thinking 流式机制
- provider 枚举增加 DeepSeek/Kimi 变体(base_url/API key 走配置)
- thinking 参数经 additional_params 透传(enabled/disabled 显式发送)
- Kimi 温度特例:thinking 恒 1.0、普通恒 0.6;DeepSeek thinking 省略温度
- 流式路径:Reasoning/ReasoningDelta 事件驱动思考状态显示,正文聚合,
  仅思考无正文时给出与旧实现一致的错误提示
- 新增 7 个测试:请求形状捕获 + SSE mock 事件序列验证
2026-08-17 15:42:12 +08:00
29c6ff3935 feat(llm): 引入 rig-core 并建立新 LLM 门面(Ollama 首个打通)
- 依赖改用 rig-core 0.41(仅 reqwest+rustls,不引入 agent/derive)
- 新门面:provider 枚举 + 统一配置 + 单一生成入口 + 凭据校验 + 错误映射
- Ollama 走 rig /api/chat,参数映射与错误文案与旧实现一致
- 用 rig 请求录制 mock 断言请求形状;8 个新单元测试
2026-08-17 15:33:00 +08:00
bba15501c6 refactor(llm): 拆分提示词与提交解析逻辑到独立模块
将 21 个多语言提示词及选择函数迁入 prompts 模块,将 commit 响应
解析与 GeneratedCommit 迁入 parsing 模块,为 rig 迁移期间新旧
实现共享同一份提示词与解析逻辑做准备。无行为变化。
2026-08-17 15:21:46 +08:00
cc80604710 feat(tag): 支持从多个配置文件读取版本并交互选择
将 `read_project_version` 重构为 `read_project_versions`,支持从 Cargo.toml、package.json、pyproject.toml 等多个配置文件同时读取版本。当检测到多个版本时,提供交互式选择界面让用户决定使用哪个版本。
2026-08-07 15:41:37 +08:00
35fe6e09b7 chore: 将 .qoder/ 添加到 .gitignore 2026-08-07 14:26:48 +08:00
349ff56299 chore(release): bump version to 0.5.0 and update changelog 2026-07-24 17:59:22 +08:00
206bde0786 fix: 修复 tag/changelog 生成范围,增加 --auto 模式
- fix(get_tags): 使用 find_object+peel_to_commit 支持 annotated tag
- fix(changelog): insert_changelog_entry 替换覆盖逻辑,保留已有章节
- feat: parse_changelog_versions 按 semver 降序提取版本
- feat: sort_tags_by_semver 取代纯时间排序
- feat(tag --auto): 优先读 Cargo.toml/pyproject.toml,回退 commit 分析
- feat(changelog): 无 --from 时自动检测 changelog 最高已有版本
- refactor: TagInfo::version_name(), GitRepo::find_tag_by_version()
2026-07-24 17:41:18 +08:00
995d263a48 feat(commit): 添加.gitignore文件过滤功能,自动跳过被忽略的文件
在自动暂存和`--all`模式下,检测并跳过被.gitignore规则匹配的文件,暂存完成后显示被移除的被忽略文件列表
2026-07-20 17:33:57 +08:00
19aff8a6c1 feat: 新增 Git credential helper 协议支持,实现 get|store|erase 子命令 2026-07-16 11:38:06 +08:00
b6bc091502 feat(commands): 添加 git credential helper 命令 2026-07-16 11:26:41 +08:00
14ebb6857a feat(commit): 添加提交消息模板支持
- 移除 config 命令中未使用的 List 子命令及相关显示字段
- 统一 ChangelogCommand 和 CommitCommand 的 ContentGenerator 初始化方式
2026-06-03 15:20:50 +08:00
459670f363 fix(llm): 修复 Kimi API temperature 参数配置 2026-06-01 17:48:42 +08:00
7636d0b5a6 feat(llm): 统一思考模式配置,支持显式禁用状态 2026-06-01 17:39:36 +08:00
928ebb61b4 refactor(llm): renumber system prompt rules 2026-05-27 15:37:51 +08:00
7e85cdd8b0 chore(release): 升级版本号至 0.3.0 2026-05-27 15:16:26 +08:00
90074e6e32 style: 格式化代码并优化导入顺序 2026-05-27 15:15:15 +08:00
b8182e7538 修复kimi返回信息的读取错误 2026-05-27 14:50:47 +08:00
4331b9306e LLM支持优化 2026-05-26 17:43:42 +08:00
a08bc809bb 修复bug 2026-05-26 16:30:28 +08:00
1063369d96 feat(deepseek): 添加 DeepSeek reasoning 模式支持 2026-05-26 16:27:49 +08:00
3a57d25a76 docs: 添加 QuiCommit 项目路线图文档 2026-05-14 17:04:13 +08:00
8152edba39 chore: 删除构建输出日志文件 2026-05-13 13:54:50 +08:00
679db5b1db chore: 清理大量未使用的变量、方法及结构体警告 2026-05-13 13:54:20 +08:00
b1ad68c7b5 build: 升级版本号至 0.2.0 2026-05-13 12:08:03 +08:00
280d6ec5c9 feat(generator): 按文件重要性对暂存差异排序 2026-05-13 12:07:01 +08:00
68427c4a11 chore: 发布 v0.1.11 并更新文档 2026-03-23 18:07:11 +08:00
8dd9e85b77 feat(config): 在加密导出/导入中包含个人访问令牌 2026-03-23 17:59:23 +08:00
0c7d2ad518 refactor: 移除未使用的代码和注释掉的辅助函数 2026-03-20 18:05:33 +08:00
62 changed files with 18840 additions and 4849 deletions

6
.gitignore vendored
View File

@@ -6,6 +6,7 @@ Cargo.lock
# IDE
.idea/
.trae/
.vscode/
*.swp
*.swo
@@ -21,3 +22,8 @@ test_output/
# Config (for development)
config.toml
.claude/
.qoder/
CLAUDE.md
**/agents/
.scratch/

81
AGENTS.md Normal file
View File

@@ -0,0 +1,81 @@
# AGENTS.md
Behavioral guidelines to reduce common LLM coding mistakes. Merge with project-specific instructions as needed.
**Tradeoff:** These guidelines bias toward caution over speed. For trivial tasks, use judgment.
**Important:** Use Chinese for information responses and thinking; use English for searching and querying.
## 1. Think Before Coding
**Don't assume. Don't hide confusion. Surface tradeoffs.**
Before implementing:
- State your assumptions explicitly. If uncertain, ask.
- If multiple interpretations exist, present them - don't pick silently.
- If a simpler approach exists, say so. Push back when warranted.
- If something is unclear, stop. Name what's confusing. Ask.
## 2. Simplicity First
**Minimum code that solves the problem. Nothing speculative.**
- No features beyond what was asked.
- No abstractions for single-use code.
- No "flexibility" or "configurability" that wasn't requested.
- No error handling for impossible scenarios.
- If you write 200 lines and it could be 50, rewrite it.
Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify.
## 3. Surgical Changes
**Touch only what you must. Clean up only your own mess.**
When editing existing code:
- Don't "improve" adjacent code, comments, or formatting.
- Don't refactor things that aren't broken.
- Match existing style, even if you'd do it differently.
- If you notice unrelated dead code, mention it - don't delete it.
When your changes create orphans:
- Remove imports/variables/functions that YOUR changes made unused.
- Don't remove pre-existing dead code unless asked.
The test: Every changed line should trace directly to the user's request.
## 4. Goal-Driven Execution
**Define success criteria. Loop until verified.**
Transform tasks into verifiable goals:
- "Add validation" → "Write tests for invalid inputs, then make them pass"
- "Fix the bug" → "Write a test that reproduces it, then make it pass"
- "Refactor X" → "Ensure tests pass before and after"
For multi-step tasks, state a brief plan:
```
1. [Step] → verify: [check]
2. [Step] → verify: [check]
3. [Step] → verify: [check]
```
Strong success criteria let you loop independently. Weak criteria ("make it work") require constant clarification.
---
**These guidelines are working if:** fewer unnecessary changes in diffs, fewer rewrites due to overcomplication, and clarifying questions come before implementation rather than after mistakes.
## Agent skills
### Issue tracker
Issues and specs live as local markdown files under `.scratch/`. See `docs/agents/issue-tracker.md`.
### Triage labels
The five canonical triage roles use their default label strings. See `docs/agents/triage-labels.md`.
### Domain docs
Single-context repo with `CONTEXT.md` and `docs/adr/` at the root. See `docs/agents/domain.md`.

View File

@@ -5,16 +5,161 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [0.9.0] - 2026-10-09
### ✨ 新功能
- 新增 `quicommit ginit`(别名 `gi`):一条命令完成本地 Git 仓库起步——建库、写最简 `README.md`、按根目录标志文件生成 `.gitignore`、确立初始分支、按 `--add` 添加远程,并可用 `--profile` 套用已有 Git 配置。命令全程离线,不创建首个提交,不覆盖已存在的 `README.md` 与 `.gitignore`;`--dry-run` 只预览,`--yes` 仅跳过确认
### 🐞 错误修复
- 修复 DeepSeek 默认 `base_url` 误带 `/v1` 后缀的问题:该后缀会使用于校验凭据的 `GET /user/balance` 被拼成 `/v1/user/balance` 而失败,并在生成前被笼统报为 `LLM provider 'deepseek' is not available`。默认地址改为 `https://api.deepseek.com`,与官方 API 路径一致;已有配置若显式写入过 `.../v1`,需用 `quicommit config set-llm deepseek` 重新选择或手动修改
### 📚 文档
- README(中/英文)DeepSeek 配置示例的 `--base-url` 同步去掉 `/v1`
- README(中/英文)命令参考新增 `quicommit ginit` 一行与 `ginit` 选项表
- 新增 ADR 0005(`init` 与 `ginit` 保持两个命令)与 ADR 0006(`ginit` 的产出边界);CONTEXT.md 补充 Repo init、Config init、Ecosystem marker、Initial branch 四个术语
- `.gitignore` 放开 `CONTEXT.md` 与 `adr/` 的忽略,使术语表与 ADR 进入版本跟踪
### 🔧 其他变更
- 新增 git 层 `src/git/init.rs`:`init_repo`、`apply_initial_branch`、`add_remote`、`open_repo_at`、`has_commits`、`is_bare`、`head_branch`、`configured_initial_branch`、`is_valid_remote_name`,全部走 git2 且不改动 `GitRepo` 的公开接口,命令层不直接引用 git2
- 新增 35 个 `ginit_*` 多语言消息,覆盖 7 种语言
- 新增 `tests/ginit_tests.rs` 的 18 个集成测试与 `src/git/init.rs` 的 11 个单元测试
## [0.8.1] - 2026-08-31
### ✨ 新功能
- `commit` 与 `tag` 命令新增 `--select-remote` 标志:仓库存在多个远程时,以多选形式交互式选择推送目标(可多选),与 `--push`、`--yes` 形成优先链(`--yes` > `--select-remote` > `--push`)
- 推送流程重构为共享的 `run_push_flow`,感知远程配置:请求的远程不存在时回退到按字母序首个可用远程并提示;多远程推送失败时输出已推送/未尝试清单
### 🐞 错误修复
- 修复未指定 `--no-color` 时对 `colored` 的强制颜色覆盖会破坏 TTY 自动检测的问题:现仅在明确禁用时才强制关闭颜色,管道输出保持无 ANSI 转义
### 📚 文档
- README(中/英文)同步补充 `--select-remote` 选项说明
### 🔧 其他变更
- 新增 `GitRepo::list_remotes()` 与 `GitRepo::resolve_push_target()` 辅助方法及单元测试
- 新增多语言推送消息(`push_skipped_no_remote`、`push_target_fallback`、`select_push_remotes`、`no_remotes_selected`、`push_multi_failed`),覆盖 7 种语言
- 新增 `tests/integration_tests.rs` 集成测试,覆盖远程解析与多远程推送流程
## [0.7.0] - 2026-08-21
### ✨ 新功能
- `changelog` 命令新增 `--no-generate` 标志:强制使用模板生成而非 AI 生成
- `tag` 命令支持从多个配置文件(`Cargo.toml` / `pyproject.toml`)读取版本并交互选择
### 🐞 错误修复
- 修复 `regex` 关闭默认 unicode 特性后 SEMVER 正则编译 panic:启用 `unicode-perl` 特性,恢复 `\d` 字符类可用性
### 🔧 其他变更
- `--yes` 参数行为调整:仅跳过交互提示,不再改变生成行为
- 统一使用工具函数替代 `println` 输出
## [0.6.1] - 2026-08-18
### 🔧 其他变更
- 依赖瘦身:移除未使用的直接依赖 `config`、`handlebars`、`shell-words`、`walkdir`、`console`、`lazy_static`、`atty`,crate 总数由 341 降至 296,release 二进制体积由 7.38 MB 降至 7.00 MB(-5.2%)
- `lazy_static!` 宏替换为标准库 `LazyLock`;`atty` 替换为标准库 `std::io::IsTerminal`
- `tokio` 特性由 `full` 精简为 `macros` + `rt-multi-thread`
- `regex` 关闭默认 unicode 特性(保留 `std` + `perf`),行为变化:`\s`/`\d`/`\w` 字符类仅匹配 ASCII,对提交信息、版本号、邮箱、GPG Key ID 校验无实质影响
## [0.6.0] - 2026-08-17
### ✨ 新功能
- LLM 层整体迁移至 rig-core 0.41:六家 provider(Ollama / OpenAI / Anthropic / Kimi / DeepSeek / OpenRouter)统一由 rig 原生客户端驱动,删除约 2100 行手写 HTTP/SSE/重试代码
- 新 LLM 门面(`src/llm/rig/`):provider 枚举 + 单一生成入口 + 凭据校验(rig VerifyClient)+ 错误映射层;提示词与提交解析拆分至独立模块
- Anthropic 支持自定义 `base_url`(此前被静默忽略,本次修复)
- 新增基于 rig mock 后端的 LLM 层离线测试(请求形状、流式事件、错误映射),并附 6 家 provider 的 `#[ignore]` 实网冒烟用例
### 🐞 错误修复
- 各 provider 超时统一由 `llm.timeout` 配置控制(此前默认超时 60/120/300 秒不一致)
- 消除 Ollama 客户端构造时的 panic(`expect` 式构造)
### 🔧 其他变更(行为变化)
- Ollama 请求端点由 `/api/generate` 改为 `/api/chat`(功能等价;自定义 Ollama 代理需兼容 chat 端点)
- 非流式请求不再做应用层重试(此前按错误文案字符串匹配重试 3 次);流式请求由 rig 的 SSE 机制自动重连
- 可用性校验端点变化:DeepSeek 改为 `/user/balance`、OpenRouter 改为 `/key`、Anthropic 改为 `/v1/models`(对外表现为 `LLM provider 'xxx' is not available` 语义不变)
- 依赖瘦身:移除 `reqwest`(0.12) 与 `async-trait` 直接依赖,不引入 rig agent/fastembed/lancedb 等组件
## [0.5.0] - 2026-07-24
### ✨ 新功能
- `commit` 命令新增 `.gitignore` 文件过滤:自动暂存与 `--all` 模式下自动跳过被 `.gitignore` 规则匹配的文件,并清理索引中已被忽略的已跟踪文件,暂存完成后列出被移除的文件
- `tag` 命令新增 `-A, --auto` 模式:优先从 `Cargo.toml`/`pyproject.toml` 读取项目版本,回退到基于 commit 的语义化升级分析(与 `--bump` 互斥)
- `changelog` 命令在未指定 `--from` 时,自动检测现有 changelog 中最高版本对应的 tag 作为起始点
- 新增 `parse_changelog_versions()`,按 semver 降序提取 changelog 中已有的版本
- 新增 `sort_tags_by_semver()`,取代纯时间排序,按语义版本正确排序标签
### 🐞 错误修复
- 修复 `get_tags()` 对 annotated tag 的解析:改用 `find_object`+`peel_to_commit` 正确取得标签指向的 commit
- 修复 `changelog` 写入时 `insert_changelog_entry` 覆盖已有章节的问题,现保留现有内容并按版本插入新条目
### 📚 文档
- 同步更新 README(中/英文):补充 `--think`、`tag --auto` 选项说明,移除已废弃的 `--prepend` 参数,修正 `set-llm` 的 `--base-url` 选项名
### 🔧 其他变更
- 新增 `TagInfo::version_name()` 与 `GitRepo::find_tag_by_version()` 辅助方法
- 新增 `tests/gitignore_tests.rs`,包含 7 个测试覆盖 `.gitignore` 过滤逻辑
## [0.4.0] - 2026-07-16
### ✨ 新功能
- 新增 `quicommit credential get|store|erase` 子命令,实现标准 Git credential helper 协议([gitcredentials](https://git-scm.com/docs/gitcredentials)),可与原生 `git` 命令无缝集成
- 新增 `host_to_service()` 主机名映射,将 github.com、gitlab.com、bitbucket.org 等常见托管平台映射为规范化服务名
- 新增 `get_pat_for_host()` 公共 API,支持从已保存的凭据中提取 PAT 用于登录验证
- 凭据存储复用现有基于系统密钥环的 PAT 与用户绑定逻辑,按 profile 维度管理
### 🐞 错误修复
- 移除 `keyring.rs` 中的调试 `eprintln!` 输出,避免污染 credential helper 的 stderr
### 📚 文档
- README(中/英文)新增 credential 命令使用说明
### 🔧 其他变更
- 新增 `src/lib.rs` 库目标,支持从 `tests/` 目录导入内部模块进行测试
- `src/main.rs` 重构为使用 `quicommit::` 库导入
- credential 命令及其子命令均使用 `#[command(hide = true)]` 隐藏,不在 `--help` 中显示
- 新增 `tests/credential_tests.rs`,包含 52 个测试用例覆盖协议解析、帮助可见性、完整存取周期及边界场景
## [0.3.1] - 2026-06-01
### ✨ 新功能
- 按文件重要性对暂存差异排序,优先处理核心变更
- DeepSeek 新增 reasoning 推理模式支持
- LLM 统一思考模式配置,支持显式启用/禁用思考状态
- 新增 `thinking.rs` 思考状态管理模块
### 🐞 错误修复
- 修复 Kimi 返回信息的读取错误
- 修复 DeepSeek 和 Kimi 流式响应的解析问题
### 📚 文档
- 新增 ROADMAP.md 项目路线图文档
### 🔧 其他变更
- LLM 模块大规模重构,所有提供商(Anthropic、DeepSeek、Kimi、Ollama、OpenAI、OpenRouter)适配流式响应处理
- 代码格式化并优化导入顺序
- 清理大量未使用的变量、方法及结构体警告
- 清理构建输出日志文件
- 重新编号 LLM 系统提示规则
- i18n 多语言消息格式修复
- 各命令模块(commit、tag、changelog、config、profile、init)持续优化
## [0.1.11] - 2026-03-23
### ✨ 新功能
- 新增配置导出导入功能,支持加密保护
- Profile 支持 Token 管理(PAT 等)
- 自动生成和维护 Keep a Changelog 格式的变更日志
- 交互式命令行界面,支持预览和确认
### 🔐 安全特性
- 敏感数据加密存储(API 密钥、SSH 密码等)
- 敏感数据加密存储(API 密钥等)
- 使用系统密钥环安全保存凭证
### 🔧 其他变更
- 优化 diff 截断逻辑,使用字符边界确保多字节字符安全
- 改进配置管理器,支持修改追踪
## [0.1.9] - 2026-03-06
### 🐞 错误修复

65
CONTEXT.md Normal file
View File

@@ -0,0 +1,65 @@
# QuiCommit
AI 辅助的 Git 提交、Tag 和 Changelog 管理 CLI 工具。
## Language
**Remote**:
Git 远程仓库配置名。QuiCommit 推送 commit/tag 的默认目标是 `origin`。仓库一个远程都没有时,创建后不询问推送、直接跳过并说明。`--select-remote` 开启后,在多远程仓库中弹出多选列表,可一次推送其中部分或全部远程。
_Avoid_: 远端、远程仓库地址
**Tag message**:
Tag 对象的注释消息。annotated tag 时写入,lightweight tag 时为空。QuiCommit 默认创建 annotated tag。
_Avoid_: Tag 消息、标签说明
**Changelog entry**:
一条版本发布记录,包含该版本所有变更按类别(Added / Changed / Fixed 等)分组。多个 entry 组成一个完整的 CHANGELOG.md 文件。
_Avoid_: 更新日志条目、发布说明
**Keep a Changelog**:
CHANGELOG.md 的标准格式。以 `# Changelog` 开头,版本章节按 `## [x.y.z] - YYYY-MM-DD` 排列,变更按 Added / Changed / Deprecated / Removed / Fixed / Security 分组。
_Avoid_: 标准 changelog
**Version bump**:
根据提交类型推断下一个版本号的操作。feat → Minor, fix → Patch, breaking change → Major。
_Avoid_: 版本升级、版本号递增
**--auto mode**:
从项目根目录的主流包管理器配置文件(Cargo.toml、package.json、pyproject.toml、pom.xml 等)中读取当前版本作为新 tag。若同时存在多个配置文件,列出所有发现的版本让用户选择。读取失败则回退到 commit 分析自动建议版本并提示用户确认。读取到的版本直接用作 tag name,不做自动 bump——假定用户已在配置文件中手动更新了版本号。
支持的项目配置文件:Cargo.toml、package.json、pyproject.toml、pom.xml、.gemspec、pubspec.yaml、build.sbt、project.clj、mix.exs、package.yaml(Haskell Stack)、Project.toml(Julia)、composer.json、.csproj、CMakeLists.txt、meson.build。
`go.mod` 存在时跳过配置文件读取,直接走 commit 分析——Go 项目约定以 git tag 为版本依据,go.mod 不含 version 字段。
_Avoid_: 自动模式
**Annotated tag**:
Git 中带有独立对象(包含 message、作者、时间戳)的 tag。`git tag -a` 或 `git2::Repository::tag()` 传入 message 时创建。
_Avoid_: 注释标签、带消息的 tag
**Lightweight tag**:
Git 中仅为一个 commit 指针的 tag,无独立对象。`git tag` 不带 `-a` 创建。
_Avoid_: 轻量标签
**Thinking mode**:
生成 commit/tag/changelog 内容时启用模型思考(reasoning)的生成模式,终端显示 "thinking..." 状态直到正文开始输出;仅部分 LLM provider 支持,不支持时自动降级为普通生成。通过 `--think/-t` 或配置 `thinking_enabled` 启用。
_Avoid_: 推理模式、reasoning mode
**Project config file**:
项目根目录下由包管理器/构建系统使用的配置文件,其中的 `version` 字段反映项目当前版本。结构化的(JSON/TOML/YAML/XML)用对应解析器读取,代码即配置的(.gemspec/build.sbt/project.clj/mix.exs)用正则提取。仅检查项目根目录,不递归子目录。解析失败静默跳过。
_Avoid_: 项目配置文件、版本文件
**Ecosystem marker**:
只凭「存在与否」就能判定目录属于哪个语言生态的根目录标志文件,不要求其内容可解析。与 Project config file 的区别是职责不同:后者要读出 version,前者只要认出生态,因此 versionless 的 package.json、workspace 继承版本的 Cargo.toml、go.mod 都是有效的 Ecosystem marker。仅检查项目根目录,不递归子目录。
_Avoid_: 语言检测文件、生态标志(口头可用,书面统一用 Ecosystem marker)
**Repo init**:
把一个普通目录变成 Git 仓库并备好起步内容的操作——建库、定 Initial branch、补 README 与忽略规则、按需挂 Remote。由 `ginit` 承担。
_Avoid_: 项目初始化、建仓、git 初始化
**Config init**:
创建或重置 QuiCommit 自身配置文件(LLM provider 与首个 Git Profile)的操作,由 `init` 承担。不创建 Git 仓库,也不写入工作目录任何文件。
_Avoid_: 初始化(单独使用时有歧义,必须区分 Config init 与 Repo init)
**Initial branch**:
新仓库里 HEAD 指向、尚无任何 commit 的第一个分支。QuiCommit 认定它是一次 Repo init 的产物,只能在尚不存在任何分支时确立,不得用于重命名已有仓库的当前分支。
_Avoid_: 默认分支、主分支、master 分支

View File

@@ -1,9 +1,9 @@
[package]
name = "quicommit"
version = "0.1.10"
version = "0.9.0"
edition = "2024"
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"
license = "MIT"
repository = "https://git.lyz.one/SidneyZhang/QuiCommit"
keywords = ["git", "commit", "ai", "cli", "automation"]
@@ -19,11 +19,9 @@ path = "src/main.rs"
clap = { version = "4.5", features = ["derive", "env", "wrap_help"] }
clap_complete = "4.5"
dialoguer = "0.11"
console = "0.15"
indicatif = "0.17"
# Configuration management
config = "0.14"
serde = { version = "1.0", features = ["derive"] }
toml = "0.8"
dirs = "5.0"
@@ -32,9 +30,7 @@ dirs = "5.0"
git2 = "0.20.3"
which = "6.0"
# HTTP client for LLM APIs
reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false }
tokio = { version = "1.35", features = ["full"] }
tokio = { version = "1.35", features = ["macros", "rt-multi-thread"] }
# Error handling
thiserror = "1.0"
@@ -46,19 +42,16 @@ tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
# Utilities
chrono = { version = "0.4", features = ["serde"] }
regex = "1.10"
lazy_static = "1.4"
regex = { version = "1.10", default-features = false, features = ["std", "perf", "unicode-perl"] }
roxmltree = "0.20"
colored = "2.1"
handlebars = "5.1"
semver = "1.0"
walkdir = "2.4"
tempfile = "3.9"
sha2 = "0.10"
hex = "0.4"
textwrap = "0.16"
async-trait = "0.1"
futures-util = "0.3"
serde_json = "1.0"
atty = "0.2"
# Encryption for sensitive data (SSH keys, GPG, etc.)
aes-gcm = "0.10"
@@ -72,8 +65,8 @@ keyring = { version = "3.6.3", features = ["apple-native", "windows-native", "sy
# Interactive editor
edit = "0.1"
# Shell completion generation
shell-words = "1.1"
# LLM integration (rig-core only: HTTP backend + rustls; no agent/derive)
rig-core = { version = "0.41", default-features = false, features = ["reqwest", "rustls"] }
[dev-dependencies]
assert_cmd = "2.0"
@@ -81,13 +74,18 @@ predicates = "3.1"
tempfile = "3.9"
mockall = "0.12"
wiremock = "0.6"
# rig mock HTTP backends for LLM layer tests
rig-core = { version = "0.41", default-features = false, features = ["test-utils"] }
http = "1"
[profile.release]
opt-level = 3
lto = true
opt-level = "s"
lto = "thin"
codegen-units = 1
panic = "abort"
strip = true
debug = false
[profile.dev]
opt-level = 0
opt-level = 1
debug = true

109
RAODMAP.md Normal file
View File

@@ -0,0 +1,109 @@
# QuiCommit Roadmap
> 本路线图按「已完成 / 进行中 / 计划中 / 长远规划」四档跟踪功能演进,并为"计划中"条目标注优先级(P0 = 核心必做,P1 = 重要增强,P2 = 探索性方向)。
> 更新约定:条目完成后请及时勾选并移入"已完成",避免已完成事项长期滞留在"计划中"栏目;新增条目请附带动机说明与优先级。
## 已完成 ✅
- [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 等)
- [x] Git 凭证管理器(Credential Helper 协议、Windows/macOS/Linux 跨平台密钥环对接、PAT scope/有效期与过期提醒)
- [x] DeepSeek 最新模型支持(`deepseek-chat` / `deepseek-reasoner` / `deepseek-v4`)
- [x] 流式生成的 SSE(Server-Sent Events)基础接入
- [x] 一键初始化本地 Git 仓库(`quicommit ginit` / `gi`):建库、写最简 `README.md`、按根目录标志文件生成 `.gitignore`、确立初始分支、按 `--add` 添加远程,可用 `--profile` 套用已有 Git 配置;全程离线、不创建首个提交、不覆盖已有 `README.md` 与 `.gitignore`
---
## 进行中 🚧
- (暂无)
---
## 计划中 📋
### 1. Agent Skills 技能包完善 `P0`(新增)
**动机**:`.claude/skills`、`skills/` 目录下现有内容零散,需要系统化沉淀为可复用的 QuiCommit Agent 技能,让编码助手(Claude、Qoder 等)能够开箱用地安装、调用 QuiCommit。
- [ ] **安装类技能:`install-quicommit`**
- 指导用户 / Agent 在 Windows / macOS / Linux 上安装 QuiCommit:Cargo 安装、预编译二进制、源码构建等途径
- 覆盖首次运行前的环境依赖检查(Git、系统密钥环服务等)
- [ ] **使用指导类技能:`use-quicommit`**
- 面向 Agent 的命令速查与调用范式:`commit` / `tag` / `changelog` / `config` / `profile` / `credential`,以及新增的 `ginit`
- 说明各命令的输入前提(如需要先 `quicommit init` 配置 LLM Provider)、典型输出与常见失败排查
- [ ] **技能内容规范**
- 统一采用 SKILL.md 结构(触发场景 / 使用方式 / 注意事项),与 `.claude/skills` 现有约定保持一致
- 技能内示例命令、参数需要与 `src/commands/` 的实际实现保持同步,避免文档漂移
- [ ] **发布与复用**
- 评估随 crate 发布(打包进 `skills/`)还是通过独立仓库 / 技能市场分发
### 2. 新增模型支持 `P1`
扩展 LLM 提供商和模型覆盖范围,满足更多场景和偏好。
- [ ] **新增国内模型提供商**
- 通义千问 (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. 生成体验优化 `P1`
提升 AI 生成提交信息、标签说明和变更日志时的用户体验。
- [ ] **流式输出与实时反馈**
- [ ] 终端打字机效果实时显示生成内容
- [ ] 流式生成过程中支持 `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 提供商和生成策略

234
README.md
View File

@@ -15,13 +15,16 @@ A powerful AI-powered Git assistant for generating conventional commits, tags, a
## Features
- **Repository Initialization**: Bootstrap a local Git repository with `quicommit ginit` — create `.git`, write a minimal `README.md`, generate a `.gitignore` from detected ecosystems, set the initial branch, and add remotes. Runs fully offline and never creates the first commit
- **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
- **Multi-language Support**: Output in 7 languages (English, Chinese, Japanese, Korean, Spanish, French, German)
- **Config Export/Import**: Backup and restore configuration with optional encryption
## Installation
@@ -46,6 +49,28 @@ Requirements: Rust 1.70+, Git 2.0+
## Quick Start
### Initialize a Git Repository
```bash
# Bootstrap the current directory as a Git repository
quicommit ginit
# Specify the initial branch (default: init.defaultBranch, then main)
quicommit ginit -b dev
# Add remotes, repeatable: --add <URL> or --add <NAME>=<URL>
quicommit ginit --add https://github.com/user/repo.git
quicommit ginit --add upstream=git@github.com:user/repo.git
# Apply an existing Git profile to the new repository
quicommit ginit --profile work
# Preview only, write nothing
quicommit ginit --dry-run
```
`ginit` runs fully offline and is safe to re-run: it never creates the first commit and never overwrites an existing `README.md` or `.gitignore`.
### Initialize
```bash
@@ -61,7 +86,7 @@ quicommit commit
# Manual commit
quicommit commit --manual -t feat -m "add new feature"
# Stage all and commit
# Stage all and commit (skips .gitignore-matched files automatically)
quicommit commit -a
# Skip confirmation
@@ -70,16 +95,25 @@ quicommit commit --yes
# Use date-based commit message
quicommit commit --date
# Enable LLM thinking/reasoning mode for this commit
quicommit commit --think
# Push after committing
quicommit commit --push
```
When staging changes (auto-stage or `--all`), files matched by `.gitignore` rules are skipped and any ignored files already in the index are removed; the skipped list is printed for review.
### Create Tag
```bash
# Auto-detect version bump
quicommit tag
# 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
quicommit tag --bump minor
@@ -96,11 +130,14 @@ quicommit tag --push
### Generate Changelog
```bash
# Generate for unreleased changes
# Generate for unreleased changes (auto-detects --from from the highest version in the existing changelog)
quicommit changelog
# Generate for specific version
quicommit changelog -v 1.0.0
quicommit changelog --version 1.0.0
# Generate from a specific tag
quicommit changelog --from v0.9.0
# AI-generate changelog
quicommit changelog --generate
@@ -158,12 +195,73 @@ quicommit profile stats
quicommit profile token
```
### Git Credential Helper
QuiCommit can act as a Git credential helper to securely store and retrieve
Personal Access Tokens (PATs) via the system keyring. The `credential` command
is hidden from `--help` because it is invoked automatically by Git, not by end
users.
#### Setup
Register QuiCommit as a credential helper for Git:
```bash
# Use the default QuiCommit config
git config --global credential.helper quicommit
# Or specify a custom config file
git config --global credential.helper "quicommit --config /path/to/config.toml"
```
You can also limit the helper to a specific host:
```bash
git config --global credential.https://github.com.helper quicommit
```
#### How It Works
When Git needs credentials (e.g. pushing to a remote), it calls the helper
following the [gitcredentials protocol](https://git-scm.com/docs/gitcredentials):
1. **`get`** — Git asks QuiCommit for a stored PAT matching the requested host.
QuiCommit searches all configured profiles in the keyring and returns the
PAT (plus username) if found.
2. **`store`** — After a successful authentication (e.g. you entered a PAT in
the Git prompt), Git asks QuiCommit to save it. The PAT is stored in the
system keyring, bound to the matching profile.
3. **`erase`** — Git asks QuiCommit to delete a stored PAT for the given host.
Host names are mapped to canonical service names (`github.com` → `github`,
`gitlab.com` → `gitlab`, `bitbucket.org` → `bitbucket`, etc.). Unknown hosts
are used as-is.
#### Removing Stored Credentials
To remove a stored PAT, you can either use Git's built-in mechanism:
```bash
echo "protocol=https
host=github.com" | git credential reject
```
Or remove it via the profile token management command:
```bash
quicommit profile token
```
### Configure LLM
```bash
# Configure Ollama (local)
quicommit config set-llm ollama
quicommit config set-llm ollama --url http://localhost:11434 --model llama2
quicommit config set-llm ollama --base-url http://localhost:11434 --model llama2
> ⚠️ **Security note**: passing your API key as a CLI argument puts it in your shell
> history and process list. Prefer running `quicommit config set-api-key` (or
> `config set-llm`) without the key to enter it via a hidden prompt instead.
# Configure OpenAI
quicommit config set-llm openai
@@ -181,7 +279,7 @@ quicommit config set-llm kimi --base-url https://api.moonshot.cn/v1 --model moon
# Configure DeepSeek
quicommit config set-llm deepseek
quicommit config set-api-key YOUR_API_KEY
quicommit config set-llm deepseek --base-url https://api.deepseek.com/v1 --model deepseek-chat
quicommit config set-llm deepseek --base-url https://api.deepseek.com --model deepseek-chat
# Configure OpenRouter
quicommit config set-llm openrouter
@@ -197,7 +295,7 @@ quicommit config set-version-prefix v
# Set changelog path
quicommit config set-changelog-path CHANGELOG.md
# Set output language
# Set output language (en, zh, ja, ko, es, fr, de)
quicommit config set-language en
# Set keep commit types in English
@@ -215,6 +313,14 @@ quicommit config check-keyring
# Show config file path
quicommit config path
# Export configuration (with optional encryption)
quicommit config export -o config-backup.toml
quicommit config export -o config-backup.enc --password
# Import configuration
quicommit config import -i config-backup.toml
quicommit config import -i config-backup.enc --password
# Reset configuration to defaults
quicommit config reset --force
```
@@ -224,11 +330,25 @@ quicommit config reset --force
| Command | Alias | Description |
|---------|-------|-------------|
| `quicommit init` | `i` | Initialize configuration |
| `quicommit ginit` | `gi` | Initialize a local Git repository |
| `quicommit commit` | `c` | Generate and create commit |
| `quicommit tag` | `t` | Generate and create tag |
| `quicommit changelog` | `cl` | Generate changelog |
| `quicommit profile` | `p` | Manage Git profiles |
| `quicommit config` | `cfg` | Manage settings |
| `quicommit credential` | — | Git credential helper (hidden, invoked by Git) |
### Ginit Options
| Option | Description |
|--------|-------------|
| `-b, --branch` | Initial branch name (default: global `init.defaultBranch`, then `main`) |
| `--add` | Add a remote: `--add <url>` (name defaults to `origin`) or `--add <name>=<url>`; repeatable |
| `--profile` | Apply an existing Git Profile to the new repository and record the directory mapping |
| `--dry-run` | Show the plan without creating anything |
| `-y, --yes` | Skip confirmation prompts only (output behavior unchanged) |
`ginit` runs fully offline: it never creates a remote repository on a hosting service, never checks remote reachability, never makes the first commit, and never overwrites an existing `README.md` or `.gitignore`.
### Commit Options
@@ -241,16 +361,18 @@ quicommit config reset --force
| `-b, --breaking` | Mark as breaking change |
| `-d, --date` | Use date-based commit message |
| `--manual` | Manual input, skip AI |
| `-a, --all` | Stage all changes |
| `-a, --all` | Stage all changes (skips `.gitignore`-matched files) |
| `-S, --sign` | GPG sign commit |
| `--amend` | Amend previous commit |
| `--dry-run` | Show without committing |
| `--conventional` | Use Conventional Commits format |
| `--commitlint` | Use commitlint format |
| `--no-verify` | Skip commit message verification |
| `-y, --yes` | Skip confirmation |
| `-t, --think` | Enable LLM thinking/reasoning mode (overrides config) |
| `-y, --yes` | Skip confirmation prompts only (generation behavior unchanged) |
| `--push` | Push after committing |
| `--remote` | Specify remote repository (default: origin) |
| `--select-remote` | Interactively select push target(s) when the repo has multiple remotes |
### Tag Options
@@ -258,6 +380,7 @@ quicommit config reset --force
|--------|-------------|
| `-n, --name` | Tag name |
| `-b, --bump` | Version bump (major/minor/patch) |
| `-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 |
@@ -265,28 +388,33 @@ quicommit config reset --force
| `-f, --force` | Force overwrite existing tag |
| `-p, --push` | Push to remote |
| `-r, --remote` | Specify remote repository (default: origin) |
| `--select-remote` | Interactively select push target(s) when the repo has multiple remotes |
| `--dry-run` | Dry run |
| `-y, --yes` | Skip confirmation |
| `-t, --think` | Enable LLM thinking/reasoning mode (overrides config) |
| `-y, --yes` | Skip confirmation prompts only (generation behavior unchanged) |
### Changelog Options
| Option | Description |
|--------|-------------|
| `-o, --output` | Output file path |
| `-v, --version` | Generate for specific version |
| `-f, --from` | Generate from specific tag |
| `--version` | Generate for specific version |
| `-f, --from` | Generate from specific tag (auto-detected from existing changelog if omitted) |
| `-t, --to` | Generate to specific ref (default: HEAD) |
| `-i, --init` | Initialize new changelog file |
| `-g, --generate` | AI-generate changelog |
| `--prepend` | Prepend to existing changelog |
| `--include-hashes` | Include commit hashes |
| `--include-authors` | Include authors |
| `--format` | Format (keep-a-changelog, github-releases) |
| `--dry-run` | Dry run (output to stdout) |
| `-y, --yes` | Skip confirmation |
| `--think` | Enable LLM thinking/reasoning mode (overrides config) |
| `--no-generate` | Generate deterministically from template (no AI call) |
| `-y, --yes` | Skip confirmation prompts only (generation behavior unchanged) |
## Configuration File
> For a complete categorized reference — quick setup, every field, provider defaults, recipes, and troubleshooting — see [docs/configuration-guide.en.md](docs/configuration-guide.en.md).
Location:
- Linux: `~/.config/quicommit/config.toml`
- macOS: `~/Library/Application Support/quicommit/config.toml`
@@ -322,58 +450,28 @@ use_agent = true
[llm]
provider = "ollama"
model = "llama2"
# base_url = "http://localhost:11434"
max_tokens = 500
temperature = 0.7
timeout = 30
[llm.ollama]
url = "http://localhost:11434"
model = "llama2"
[llm.openai]
model = "gpt-4"
base_url = "https://api.openai.com/v1"
[llm.anthropic]
model = "claude-3-sonnet-20240229"
[llm.kimi]
model = "moonshot-v1-8k"
[llm.deepseek]
model = "deepseek-chat"
[llm.openrouter]
model = "openai/gpt-4"
api_key_storage = "keyring"
thinking_enabled = false
[commit]
format = "conventional"
auto_generate = true
allow_empty = false
gpg_sign = false
max_subject_length = 100
require_scope = false
require_body = false
body_required_types = ["feat", "fix"]
[tag]
version_prefix = "v"
auto_generate = true
gpg_sign = false
include_changelog = true
[changelog]
path = "CHANGELOG.md"
auto_generate = true
format = "keep-a-changelog"
include_hashes = false
include_authors = false
group_by_type = true
[theme]
colors = true
icons = true
date_format = "%Y-%m-%d"
[output]
emoji = true
[repo_profiles]
"/path/to/work/project" = "work"
@@ -386,15 +484,20 @@ date_format = "%Y-%m-%d"
|----------|-------------|
| `QUICOMMIT_CONFIG` | Configuration file path |
| `EDITOR` | Default editor |
| `NO_COLOR` | Disable colored output |
| `NO_COLOR` | Disable colored output and emoji decorations (any value) |
| `RUST_LOG` | Overrides `-v` log filtering (e.g. `debug`) |
## Global Options
- `-v, --verbose` (repeatable): `-v` shows info logs, `-vv` debug, `-vvv` trace.
- `--no-color`: disable colors **and** emoji decorations (highest priority).
- `--emoji` / `--no-emoji`: force emoji decorations on/off, overriding the `output.emoji` config value.
- Emoji decorations can also be configured via `config set output.emoji true|false`.
## Troubleshooting
```bash
# View current configuration
quicommit config list
# Show configuration details
quicommit config show
# Edit configuration file
@@ -406,7 +509,7 @@ quicommit config set llm.provider ollama
# Get configuration value
quicommit config get llm.provider
# Set API key (stored in system keyring)
# Set API key (stored in system keyring; omit the value for hidden prompt input)
quicommit config set-api-key YOUR_API_KEY
# Delete API key from keyring
@@ -424,11 +527,13 @@ quicommit config check-keyring
# Show config file path
quicommit config path
# Export configuration
# Export configuration (with optional encryption)
quicommit config export -o config-backup.toml
quicommit config export -o config-backup.enc --password
# Import configuration
quicommit config import -i config-backup.toml
quicommit config import -i config-backup.enc --password
# Reset configuration
quicommit config reset --force
@@ -484,6 +589,7 @@ src/
│ ├── changelog.rs
│ ├── profile.rs
│ ├── config.rs
│ ├── ginit.rs
│ └── init.rs
├── config/ # Configuration management
│ ├── manager.rs
@@ -492,14 +598,14 @@ src/
├── git/ # Git operations
│ ├── commit.rs
│ ├── tag.rs
│ └── changelog.rs
├── llm/ # LLM provider implementations
│ ├── ollama.rs
│ ├── openai.rs
│ ├── anthropic.rs
│ ├── kimi.rs
│ ├── deepseek.rs
│ └── openrouter.rs
│ ├── changelog.rs
│ └── init.rs
├── llm/ # LLM integration (rig-core based)
│ ├── mod.rs
│ ├── prompts.rs
│ ├── parsing.rs
│ ├── thinking.rs
│ └── rig/ # rig provider facade
├── i18n/ # Internationalization support
│ ├── messages.rs
│ └── translator.rs

View File

@@ -1,12 +1,13 @@
use std::env;
fn main() {
// Only generate completions when explicitly requested
if env::var("GENERATE_COMPLETIONS").is_ok() {
println!("cargo:warning=To generate shell completions, run: cargo run --bin quicommit -- completions");
println!(
"cargo:warning=To generate shell completions, run: cargo run --bin quicommit -- completions"
);
}
// Rerun if build.rs changes
println!("cargo:rerun-if-changed=build.rs");
}

View File

@@ -0,0 +1,5 @@
# Multi-config version resolution for --auto mode
When `--auto` detects the project version, it reads from project config files (e.g. Cargo.toml, package.json). We extended this from 2 formats to 16. 11 formats are structured (JSON, TOML, XML), 5 are code-as-config (Ruby, Scala, Clojure, Elixir, CMake/Meson). We use structured parsers for the first group (adding only `roxmltree` for XML — YAML formats use regex to avoid a new dep) and regex for the second. When multiple config files coexist in the project root, we surface all found versions and let the user pick — rather than imposing a fixed priority order. A `go.mod` file causes an immediate skip to commit-analysis fallback (Go defines versions via git tags, not in go.mod). Only the project root is searched; subdirectories are not recursed.
**Considered Options**: A hardcoded priority list (e.g. Cargo.toml > package.json > …) was rejected because no ordering satisfies every polyglot project. Letting the user choose adds one interaction in the rare multi-config case and avoids surprising wrong picks.

View File

@@ -0,0 +1,7 @@
# LLM provider layer built on rig-core instead of hand-written clients
QuiCommit's LLM layer (commit/tag/changelog generation) moved from six hand-written HTTP clients to `rig-core` 0.41 (locked, `reqwest` + `rustls` features only, umbrella `rig` crate not used). Rationale: the hand-written layer accumulated duplicated SSE parsing, string-matching retry logic, inconsistent per-provider timeouts, and provider-specific bugs (Anthropic silently ignoring configured base_url, Ollama constructor panics); rig-core 0.41 natively supports all six providers and models reasoning/thinking as first-class typed content (streaming `Reasoning`/`ReasoningDelta` events), which matches QuiCommit's thinking-mode display feature. In exchange we accept rig's 0.x breaking-change velocity (upgrades require manual provider-diff review), no application-level retry on non-streaming requests, and the Ollama endpoint moving to `/api/chat`.
**Considered Options**: keep hand-writing (rejected — every new provider meant a full HTTP/SSE/retry suite; six near-duplicate implementations already); per-provider SDKs like async-openai plus hand-written rest (rejected — multiple API styles, no unified interface); rig-core (accepted — one dependency covers every current provider and normalizes reasoning content across them).
**Consequences**: user-visible behavior changes recorded in CHANGELOG for 0.6.0 (Ollama `/api/chat`, no non-streaming retry, credential-verify endpoint changes, Anthropic base_url now honored); rig-core upgrades must re-verify provider API changes before bumping the locked version.

View File

@@ -0,0 +1,7 @@
# Output channels, message prefixes, and the decoration switch
QuiCommit's user-facing output follows fixed conventions: stdout carries results and interactive content, stderr carries diagnostics; the hidden `credential` subcommand keeps stdout protocol-only (errors on stderr prefixed `[quicommit credential]`). Message text — `Messages` keys and inline command strings alike — never embeds emoji or status symbols; decoration prefixes (✓/✗/⚠/→, spinner frames) are applied only by the shared `utils` print helpers, so they can be toggled centrally. Decoration is configurable via `output.emoji` (bool, default on) with global `--emoji`/`--no-emoji` overrides (precedence: explicit flag > config > default); `--no-color`/`NO_COLOR` is highest priority and disables both color and decoration. When decorations are off, output is plain text with no severity labels — errors stay distinguishable via stderr and anyhow's `Error:` prefix. Emoji is not auto-disabled on non-tty (explicit control only), while color keeps `colored`'s existing tty auto-detection.
**Considered Options**: emoji embedded in message text per language (rejected — decoration duplicated across 7 languages, half-on states when toggling, and two competing styles already coexisted in the codebase); text severity labels (`Warning:`/`Error:`) substituted when decorations are off (rejected — 7-language label burden for marginal value; stream separation already distinguishes errors); auto-disabling emoji on non-tty (rejected — emoji is a content-level choice, not a terminal capability, and script authors get explicit control via `--no-emoji`).
**Consequences**: every future command author must route output through the helpers and keep message text decoration-free. Existing tests pinning English output stay valid because the default language is English. `--no-color` now also strips decorations — a behavior change from 0.6.x, where the flag was parsed but ignored (a review finding).

View File

@@ -0,0 +1,7 @@
# --yes only skips confirmation; explicit --no-generate for deterministic output
`--yes`/`-y` on commit, tag, and changelog now means only "skip interactive prompts" — generation behavior follows config and flags exactly as it would interactively. Previously `commit --yes` still AI-generated, while `tag --yes`/`changelog --yes` silently downgraded to a hardcoded `Release vX` message or template output when auto-generation was enabled — an undocumented three-way inconsistency. Deterministic non-AI output is now explicit: `tag --yes -m <message>` (the existing message flag) or `changelog --no-generate` (new flag, template-based). The tag message fallback (`Release {version}`) moves into `Messages` so it is localized.
**Considered Options**: keep the silent downgrade and only document it (rejected — a flag that claims to skip prompts must not also change what gets generated; it broke CI differently per command); make `--yes` always generate with no explicit opt-out (rejected — CI users need a deterministic, key-free path, which `--no-generate`/`-m` provide).
**Consequences**: users who relied on `tag --yes`/`changelog --yes` to avoid LLM calls must switch to `--no-generate` (changelog) or `-m` (tag). Help text and README update accordingly.

View File

@@ -0,0 +1,7 @@
# `init` and `ginit` stay two commands, one letter apart
QuiCommit's existing `init` bootstraps QuiCommit's own configuration (`config.toml`: LLM provider plus a first Git Profile), while the new `ginit` bootstraps a local Git repository in the current directory. The two names differ by a single character and appear next to each other in `quicommit --help`. We kept both rather than unifying them, because the CLI surface is public API: renaming `init` or folding repo creation into it would break existing users, existing alias tests, and ADR 0004's promise that `--yes` behaves identically per command. Disambiguation is carried by help text only — "Initialize quicommit configuration" versus "Initialize a local Git repository" — and by the CONTEXT.md pair **Config init** / **Repo init**.
**Considered Options**: rename the config flow to `config init` and hand `init` to repo creation (rejected — breaks existing users and their scripts, which is exactly the blast radius this release was scoped to avoid); nest it as `quicommit repo init` (rejected — would introduce a new top-level command group for a single command); pick a longer name like `gitinit` or `git-init` (rejected — no clearer than `ginit`, and less consistent with the existing terse alias set `i`/`c`/`t`/`cl`/`p`/`cfg`).
**Consequences**: `gi` is added as an alias, and every doc that lists commands must show the two help strings adjacent so the contrast is visible. Anyone later tempted to "clean up" this near-collision should read this ADR first: it is deliberate.

View File

@@ -0,0 +1,7 @@
# ginit prepares a repository and stops there
`quicommit ginit` writes `.git`, an Initial branch, `README.md`, `.gitignore` and Remote entries — and deliberately does not: create a first commit, apply a Git Profile unless `--profile` is passed, append anything to a pre-existing `README.md` or `.gitignore`, reuse QuiCommit's own root `.gitignore` as the template, contact a hosting API to create a remote repository, verify remote reachability, or set upstream tracking. Each of these was within reach and would have made the command look more capable, but all of them either destroy the property that makes `ginit` safe to run anywhere ("it only ever adds, never overwrites or deletes") or would silently pull the LLM pipeline, the network, and credential scopes into a command whose whole job is offline repo scaffolding. There is also no `--force`: `git init` is already idempotent on an existing repository, so the only thing `--force` could mean is deleting `.git`.
**Considered Options**: commit `README.md` + `.gitignore` as `chore: initial commit` (rejected — the user's week-old source files stay untracked, producing a repository that looks complete but is missing files, and it couples a local-only command to commit identity and message generation); apply `default_profile` automatically (rejected — it would rewrite signing keys and `core.sshCommand` in a repo the user did not ask us to configure, so the intent must be explicit); merge missing entries into an existing `.gitignore` (rejected — deciding whether `target/` is already covered means string-matching `/target`, `**/target/`, and directory rules, and a wrong match writes into a file the user maintains by hand); seed the template from the repository's own `.gitignore` (rejected — it carries QuiCommit-specific development artifacts: it ignores `Cargo.lock`, `config.toml`, `CONTEXT.md`, `adr/`, `.claude/`, plus `# OS` and `# IDE` sections that the roadmap entry explicitly rules out).
**Consequences**: after `ginit`, the first commit is still the user's action via `quicommit commit`, so `ginit` must print next-step guidance and must warn when no `user.name`/`user.email` can be resolved from either the repository or global config — otherwise the very next command fails with an error the user will blame on `ginit`. Users who want `.gitignore` entries added to an existing file must edit it themselves. Repo-creation capability lives in a new `src/git/init.rs` rather than on `GitRepo`, whose `open` diagnostic chain is built for repositories that already exist.

View File

@@ -0,0 +1,695 @@
# QuiCommit Configuration Guide
English | [简体中文](configuration-guide.md)
This guide is for readers who want a complete command of QuiCommit's configuration. It covers:
- **Quick setup**: get from "create a repo → configure an LLM → first commit" in three minutes
- **Detailed settings by category**: every field in the config file, its meaning, accepted values, and default
- **Command reference**: full tables for the `config` / `profile` subcommands
- **Environment variables and global options**
- **Common recipes** and **troubleshooting**
> For a feature overview, see [README.md](../README.md) (Chinese: [readme_zh.md](../readme_zh.md)).
> Definitions of configuration terms (Repo init, Config init, Initial branch, …) live in [CONTEXT.md](../CONTEXT.md).
---
## Table of Contents
1. [Quick Setup](#1-quick-setup)
2. [Config File Location and Precedence](#2-config-file-location-and-precedence)
3. [Detailed Settings by Category](#3-detailed-settings-by-category)
- [3.1 Application Metadata (version / default_profile)](#31-application-metadata-version--default_profile)
- [3.2 LLM and AI Generation ([llm])](#32-llm-and-ai-generation-llm)
- [3.3 Git Identities (profiles.*)](#33-git-identities-profiles)
- [3.4 Repository-to-Profile Mapping (repo_profiles)](#34-repository-to-profile-mapping-repo_profiles)
- [3.5 Commit Settings ([commit])](#35-commit-settings-commit)
- [3.6 Tag Settings ([tag])](#36-tag-settings-tag)
- [3.7 Changelog Settings ([changelog])](#37-changelog-settings-changelog)
- [3.8 Language Settings ([language])](#38-language-settings-language)
- [3.9 Output Settings ([output])](#39-output-settings-output)
4. [Config Command Reference (quicommit config)](#4-config-command-reference-quicommit-config)
5. [Profile Command Reference (quicommit profile)](#5-profile-command-reference-quicommit-profile)
6. [Tokens and Credentials](#6-tokens-and-credentials)
7. [Environment Variables](#7-environment-variables)
8. [Global Options](#8-global-options)
9. [Full Configuration Example](#9-full-configuration-example)
10. [Common Recipes](#10-common-recipes)
11. [Troubleshooting](#11-troubleshooting)
---
## 1. Quick Setup
### Prerequisites
| Requirement | Notes |
| --- | --- |
| Git | Any version works (repo initialization uses libgit2, so it does not rely on `git init -b`) |
| Rust / an installed `quicommit` | See the [README install section](../README.md#installation) |
| System keyring (recommended) | Windows Credential Manager / macOS Keychain / Linux Secret Service. Used to store API keys and PATs securely. Check with `quicommit config check-keyring` |
### Path A: Interactive wizard (recommended for first-time use)
```bash
quicommit init
```
The wizard asks, in order: output language → your first Git Profile (name / user name / email / description / work flag) → optional SSH → optional GPG → LLM provider → model → API key (hidden input, stored in the keyring). This is what CONTEXT.md calls **Config init** — it only creates QuiCommit's own config file and never touches a Git repository.
Skip every prompt and generate a minimal config from your local Git identity:
```bash
quicommit init --yes
```
### Path B: Bootstrap a repo + configure the LLM manually
```bash
# 1. Turn the current directory into a Git repository
# (creates .git, writes README, generates .gitignore, sets the initial branch)
quicommit ginit
# 2. Choose an LLM provider (interactive when no provider is given)
quicommit config set-llm deepseek
# 3. Set the API key (omit the value for hidden input; a literal value leaks into shell history)
quicommit config set-api-key
# 4. Verify connectivity
quicommit config test-llm
# 5. First commit
quicommit commit
```
### Verify the current configuration
```bash
quicommit config show # summarized view of all effective settings
quicommit config path # print the config file path
quicommit config get llm.provider
quicommit config check-keyring # check the keyring and related environment variables
```
---
## 2. Config File Location and Precedence
The configuration is a single TOML file whose default location is determined by the OS config directory:
| Platform | Default path |
| --- | --- |
| Linux | `~/.config/quicommit/config.toml` |
| macOS | `~/Library/Application Support/quicommit/config.toml` |
| Windows | `%APPDATA%\quicommit\config.toml` |
### Specifying a custom path
Precedence (highest to lowest):
1. The global CLI option `-c` / `--config <PATH>`
2. The environment variable `QUICOMMIT_CONFIG`
3. The platform default path above
```bash
quicommit -c ./my-config.toml config show
# or
QUICOMMIT_CONFIG=./my-config.toml quicommit config show
```
> This path is shared by every subcommand (`commit`, `tag`, `changelog`, `profile`, `ginit`, …).
> With a custom path, keyring entries are still keyed by provider / profile, independent of the config file path.
### Where each value comes from
| Setting | Precedence (high → low) |
| --- | --- |
| Config file path | `-c/--config` > `QUICOMMIT_CONFIG` > platform default |
| API key | Environment variable (`QUICOMMIT_API_KEY`, always first within a process) > keyring (default storage) > `llm.api_key` in the config file (only when `api_key_storage = "config"`) |
| Emoji decorations | `--no-color` (disables) > `--no-emoji` / `--emoji` > `output.emoji` config > on by default |
| Log level | `RUST_LOG` > number of `-v` flags |
| Initial branch (`ginit`) | `-b/--branch` > global `init.defaultBranch` > `main` |
---
## 3. Detailed Settings by Category
Each top-level table of the config file is documented field by field below. Every field lists **key / type / default**.
### 3.1 Application Metadata (version / default_profile)
| Key | Type | Default | Description |
| --- | --- | --- | --- |
| `version` | string | `"1"` | Config schema version, used for future migrations; normally left alone |
| `default_profile` | string (optional) | none | Default Git identity. Used when `profile apply` is called without a name and by `profile show` without a name |
### 3.2 LLM and AI Generation ([llm])
QuiCommit talks to six providers through [rig-core](https://github.com/0xPlaygrounds/rig): `ollama`, `openai`, `anthropic`, `kimi`, `deepseek`, `openrouter`.
| Key | Type | Default | Description |
| --- | --- | --- | --- |
| `provider` | string | `"ollama"` | Provider name, see the table below |
| `model` | string | `"llama2"` | Model name. When switching providers, if the current model is empty or still `llama2` it is replaced by that provider's default model |
| `base_url` | string (optional) | none (provider default) | Custom API base URL, for proxies, self-hosting, or regional endpoints |
| `max_tokens` | integer | `500` | Maximum output tokens per generation |
| `temperature` | float | `0.6` | Sampling temperature. Some providers override it (see "Temperature and thinking mode") |
| `timeout` | integer (seconds) | `30` | HTTP request timeout |
| `api_key_storage` | string | `"keyring"` | API key storage: `keyring` / `config` / `environment` |
| `api_key` | string (optional) | none | Only effective when `api_key_storage = "config"`; stored in plain text |
| `thinking_enabled` | bool | `false` | Whether thinking/reasoning mode is on by default (`--think` on `commit`/`tag`/`changelog` overrides it per run) |
| `thinking_budget_tokens` | integer (optional) | none (treated as `1024` at runtime) | Anthropic only: thinking budget in tokens |
#### Provider defaults at a glance
| provider | Default `base_url` | Default `model` | Needs an API key? |
| --- | --- | --- | --- |
| `ollama` | `http://localhost:11434` | `llama2` | No |
| `openai` | `https://api.openai.com/v1` | `gpt-4` | Yes |
| `anthropic` | `https://api.anthropic.com/v1` | `claude-3-sonnet-20240229` | Yes |
| `kimi` | `https://api.moonshot.cn/v1` | `kimi-k2.6` | Yes |
| `deepseek` | `https://api.deepseek.com` | `deepseek-v4-flash` | Yes |
| `openrouter` | `https://openrouter.ai/api/v1` | `openai/gpt-3.5-turbo` | Yes |
> `quicommit config list-models` prints a set of example model names for the current provider (for Ollama it tries to call the local `ollama list`).
#### The three API key storage modes
- `keyring` (default, recommended): stored in the system keyring; no plain text appears in `config.toml`. If the keyring is unavailable, `set-api-key` fails and suggests using an environment variable or `config`.
- `config`: written in plain text to `llm.api_key` in `config.toml`. Convenient but insecure; `set-api-key` prints a plain-text warning.
- `environment`: nothing is stored; you must set the environment variable yourself.
Setting and deleting:
```bash
# Option 1: interactively choose provider / model / base_url / API key
quicommit config set-llm deepseek
# Option 2: non-interactive, pass values directly
# (--api-key lands in shell history; prefer it only for CI)
quicommit config set-llm openai --model gpt-4o --base-url https://api.openai.com/v1
# Set/delete the API key on its own (omit the value for hidden input)
quicommit config set-api-key
quicommit config delete-api-key
```
#### Temperature and thinking mode
`--think` (or `thinking_enabled = true`) takes effect only on `deepseek`, `kimi`, `anthropic`, and `openai`; other providers silently fall back to normal generation. Per-provider parameter handling:
| provider | Normal mode | Thinking mode |
| --- | --- | --- |
| `ollama` | Uses `temperature` / `max_tokens` | Not supported, falls back |
| `deepseek` | Uses `temperature`; sends `thinking: disabled` explicitly | Omits temperature, streams |
| `kimi` | Forces `temperature = 0.6` (ignores config) | Forces `temperature = 1.0`, streams |
| `anthropic` | Uses `temperature` | Omits temperature; raises `max_tokens` to at least `thinking_budget_tokens + 100` (budget defaults to `1024`); streams |
| `openai` | Uses `temperature`; `o*` models explicitly disable reasoning | Omits temperature, streams |
| `openrouter` | Uses `temperature` / `max_tokens` | Not supported, falls back |
> CLI switches: `commit --think`, `tag --think`, `changelog --think`.
### 3.3 Git Identities (profiles.*)
Each profile is a set of Git identity + authentication settings you can switch on independently. Create them with `quicommit profile add` (interactive) or by editing the config file directly.
Top-level fields:
| Key | Type | Default | Description |
| --- | --- | --- | --- |
| `name` | string | none | Profile name; must match the TOML table name. Letters, digits, `-`, `_` only, max 50 chars |
| `user_name` | string | none | Written to `user.name` |
| `user_email` | string | none | Written to `user.email`; must be a valid email address |
| `description` | string (optional) | none | Free-form note |
| `is_work` | bool | `false` | Whether this is a work identity (a display/filter flag only) |
| `organization` | string (optional) | none | Organization name (meaningful when `is_work = true`) |
| `signing_key` | string (optional) | none | Explicit signing key; falls back to `gpg.key_id` when unset |
#### `[profiles.<name>.settings]` — behavioral preferences
| Key | Type | Default | Description |
| --- | --- | --- | --- |
| `auto_sign_commits` | bool | `false` | When the profile is applied, write `commit.gpgsign = true` |
| `auto_sign_tags` | bool | `false` | When the profile is applied, write `tag.gpgsign = true` |
| `default_commit_format` | string (optional) | none | Preferred commit format for this profile: `conventional` / `commitlint` |
| `repo_patterns` | array of strings | `[]` | Path fragments used to match repositories to this profile |
| `llm_provider` | string (optional) | none | Preferred LLM provider for this profile (metadata, reserved) |
| `commit_template` | string (optional) | none | Custom commit message template |
#### `[profiles.<name>.ssh]` — SSH settings (optional)
| Key | Type | Default | Description |
| --- | --- | --- | --- |
| `private_key_path` | path (optional) | none | Path to the SSH private key (existence is validated) |
| `public_key_path` | path (optional) | none | Public key path; derived as `<key>.pub` from the private key when unset |
| `passphrase` | string (optional) | none | Key passphrase (stored encrypted) |
| `agent_forwarding` | bool | `false` | Whether to add `-A` |
| `ssh_command` | string (optional) | none | Fully custom SSH command; overrides the other fields when set |
| `known_hosts_file` | path (optional) | none | Adds `-o UserKnownHostsFile=<path>` |
Applying writes `core.sshCommand` into the local Git config. Command assembly: `ssh` + (`-A`) + (`-i <private key>`) + (`-o UserKnownHostsFile=<file>`); if `ssh_command` is set, it is used as-is.
#### `[profiles.<name>.gpg]` — GPG settings (optional)
| Key | Type | Default | Description |
| --- | --- | --- | --- |
| `key_id` | string | none | GPG Key ID, 16–40 uppercase hex characters |
| `program` | string | Windows: `gpg.exe`; otherwise: `gpg` | Written to `gpg.program` |
| `home_dir` | path (optional) | none | GPG home directory |
| `passphrase` | string (optional) | none | Passphrase (stored encrypted) |
| `use_agent` | bool | `true` | Whether to use gpg-agent |
Applying writes `gpg.program`, `user.signingkey`, and — per `settings.auto_sign_commits` / `settings.auto_sign_tags` — `commit.gpgsign` / `tag.gpgsign` into the local Git config.
#### Applying (writing to Git) and display
`profile apply` / `profile set-repo` / `profile switch` write the profile into Git config:
- Repo-level: `user.name`, `user.email`, plus the SSH / GPG keys noted above
- Global (`profile apply --global`): written into the global Git config
> Applying a profile first clears "managed" keys that the new profile will not set (`core.sshCommand`, `user.signingkey`, `commit.gpgsign`, `tag.gpgsign`, `gpg.program`) so stale identities do not linger.
### 3.4 Repository-to-Profile Mapping (repo_profiles)
Records "repository absolute path → profile name" so commands such as `profile show` can tell which identity a repository should use. It is written automatically by `profile set-repo` or `ginit --profile`; hand-editing is rarely needed.
```toml
[repo_profiles]
"/home/me/work/project" = "work"
"/home/me/personal/project" = "personal"
```
### 3.5 Commit Settings ([commit])
| Key | Type | Default | Description |
| --- | --- | --- | --- |
| `format` | string | `"conventional"` | Commit format: `conventional` or `commitlint` |
| `auto_generate` | bool | `true` | Whether to generate commit messages with AI by default (`commit --manual` turns it off per run) |
Format differences (see [validators.rs](../src/utils/validators.rs)):
- `conventional`: `<type>[scope][!]: <description>`, where type is one of 11 (feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert).
- `commitlint`: extends the conventional types (adds wip, init, update, remove, security) and additionally requires the subject not to start with an uppercase letter and not to end with a period.
```bash
quicommit config set commit.format conventional
quicommit config set-commit-format commitlint
quicommit config set commit.auto_generate false
```
### 3.6 Tag Settings ([tag])
| Key | Type | Default | Description |
| --- | --- | --- | --- |
| `version_prefix` | string | `"v"` | Tag version prefix, e.g. `v1.2.3` |
| `auto_generate` | bool | `true` | Whether to generate tag messages with AI by default |
```bash
quicommit config set tag.version_prefix v
quicommit config set-version-prefix v
```
### 3.7 Changelog Settings ([changelog])
| Key | Type | Default | Description |
| --- | --- | --- | --- |
| `path` | string | `"CHANGELOG.md"` | Changelog file path |
| `auto_generate` | bool | `true` | Whether to generate changelogs with AI by default (`--no-generate` turns it off per run, using the template instead) |
```bash
quicommit config set changelog.path CHANGELOG.md
quicommit config set-changelog-path CHANGELOG.md
```
### 3.8 Language Settings ([language])
| Key | Type | Default | Description |
| --- | --- | --- | --- |
| `output_language` | string | `"en"` | Language for UI and generated text |
| `keep_types_english` | bool | `true` | Whether commit types (feat, fix, …) stay in English |
| `keep_changelog_types_english` | bool | `true` | Whether changelog categories (Added, Fixed, …) stay in English |
Supported language codes: `en`, `zh` (including `zh-cn` / `zh-tw`), `ja`, `ko`, `es`, `fr`, `de` (full names such as `english`, `chinese` are also accepted). An invalid code silently falls back to English.
```bash
quicommit config set-language zh # omit the argument for an interactive picker
quicommit config set language.output_language zh
quicommit config set-keep-types-english true
quicommit config set-keep-changelog-types-english true
```
### 3.9 Output Settings ([output])
| Key | Type | Default | Description |
| --- | --- | --- | --- |
| `emoji` | bool | `true` | Whether to show decorations such as ✓ / ✗ / ⚠ / → |
```bash
quicommit config set output.emoji false
```
CLI overrides: `--no-color` disables colors and decorations together; `--emoji` / `--no-emoji` control decorations alone.
---
## 4. Config Command Reference (quicommit config)
Alias: `quicommit cfg`. With no subcommand it behaves like `config show`.
| Subcommand | Description |
| --- | --- |
| `config show` | Summarized view of current settings (general / LLM / commit / tag / language / changelog / security) |
| `config path` | Print the config file path |
| `config edit` | Open the config file in `$EDITOR` (or `$VISUAL`) |
| `config get <key>` | Read a single setting |
| `config set <key> <value>` | Write a single setting (see the key table below) |
| `config set-llm [provider]` | Configure the LLM interactively; accepts `--model` / `--base-url` / `--api-key` |
| `config set-api-key [key]` | Set the current provider's API key (hidden input when omitted) |
| `config delete-api-key` | Delete the API key from the current storage backend |
| `config set-commit-format <fmt>` | Set `conventional` / `commitlint` |
| `config set-version-prefix <prefix>` | Set the tag version prefix |
| `config set-changelog-path <path>` | Set the changelog path |
| `config set-language [code]` | Set the output language (interactive when omitted) |
| `config set-keep-types-english <bool>` | Keep commit types in English |
| `config set-keep-changelog-types-english <bool>` | Keep changelog categories in English |
| `config list-models` | List example/installed models for the current provider |
| `config test-llm` | Send a test request to verify provider connectivity |
| `config check-keyring` | Check keyring availability and related environment variables |
| `config export -o <file> [--password]` | Export the config (optionally encrypted) |
| `config import -i <file> [--password]` | Import a config |
| `config reset [--force]` | Reset to defaults |
### Keys accepted by `config set` / `config get`
| Key | Values |
| --- | --- |
| `llm.provider` | `ollama` / `openai` / `anthropic` / `kimi` / `deepseek` / `openrouter` |
| `llm.model` | any model name |
| `llm.base_url` | a URL |
| `llm.max_tokens` | integer |
| `llm.temperature` | float |
| `llm.timeout` | integer (seconds) |
| `llm.thinking_enabled` | `true` / `false` |
| `llm.thinking_budget_tokens` | integer |
| `llm.api_key_storage` | `keyring` / `config` / `environment` |
| `commit.format` | `conventional` / `commitlint` |
| `commit.auto_generate` | `true` / `false` |
| `tag.version_prefix` | string |
| `tag.auto_generate` | `true` / `false` |
| `changelog.path` | path string |
| `changelog.auto_generate` | `true` / `false` |
| `language.output_language` | language code |
| `language.keep_types_english` | `true` / `false` |
| `language.keep_changelog_types_english` | `true` / `false` |
| `output.emoji` | `true` / `false` |
> `config set` accepts only the keys above; anything else reports an "unknown config key" error.
> **The API key is not among them** — use `config set-api-key` / `config delete-api-key`.
> Boolean keys accept only the literals `true` / `false`.
---
## 5. Profile Command Reference (quicommit profile)
Alias: `quicommit p`. With no subcommand it behaves like `profile list`.
| Subcommand | Description |
| --- | --- |
| `profile add` | Create a profile interactively (name / user name / email / description / work flag / SSH / GPG / tokens) |
| `profile list` | List all profiles (the default is marked `●`) |
| `profile show [name]` | Show the current repo's Git config, the matched profile, and details |
| `profile edit <name>` | Edit interactively, with SSH / GPG / signing / tokens submenus |
| `profile copy <from> <to>` | Duplicate a profile (usage stats are not copied) |
| `profile remove <name>` | Delete a profile (also clears its keyring PATs) |
| `profile set-default <name>` | Set the default profile |
| `profile set-repo <name>` | Record the mapping for the current repo and apply it immediately |
| `profile switch` | Switch the default profile interactively, optionally applying it to the current repo |
| `profile apply [name] [--global]` | Apply a profile (defaults to `default`) to the current repo or the global Git config |
| `profile check [name]` | Compare a profile against the current repo's Git config |
| `profile stats [name]` | Show usage statistics |
| `profile token add <profile> <service>` | Add a service token (stored in the keyring) |
| `profile token remove <profile> <service>` | Remove a service token |
| `profile token list <profile>` | List tokens and their storage status |
---
## 6. Tokens and Credentials
### PAT (Personal Access Token)
PATs are used to push to private repositories on GitHub / GitLab and similar platforms. They are stored in the system keyring keyed by **profile + service name** (`config.toml` only records metadata such as type, description, and whether it is stored).
```bash
quicommit profile token add personal github
quicommit profile token list personal
quicommit profile token remove personal github
```
The service name is free-form (`github`, `gitlab`, `bitbucket`, …).
### Git Credential Helper
QuiCommit can act as a Git credential helper; once registered, Git asks it for the PAT during a push:
```bash
git config --global credential.helper quicommit
# or limit it to one host
git config --global credential.https://github.com.helper quicommit
```
`credential get|store|erase` is invoked automatically by Git and hidden from `--help`. To delete a stored credential:
```bash
echo "protocol=https
host=github.com" | git credential reject
```
### Import, export, and backup
```bash
# Plain-text export (PATs in the keyring are not included)
quicommit config export -o config-backup.toml
# Encrypted export (profile PATs are encrypted and bundled too)
quicommit config export -o config-backup.enc --password
# Import
quicommit config import -i config-backup.toml
quicommit config import -i config-backup.enc --password
```
> The encrypted export uses AES-GCM + Argon2; the file is prefixed with `ENCRYPTED:`. If a target config holds PATs but encryption is not enabled, import/export prints a plain-text warning.
---
## 7. Environment Variables
| Variable | Description |
| --- | --- |
| `QUICOMMIT_CONFIG` | Config file path (equivalent to `-c/--config`) |
| `QUICOMMIT_API_KEY` | API key for any provider; **always first within the process**, ahead of the keyring and the config file |
| `QUICOMMIT_<PROVIDER>_API_KEY` | Provider-specific API key (e.g. `QUICOMMIT_OPENAI_API_KEY`). Detected by the interactive `init` and shown by `config check-keyring` |
| `EDITOR` / `VISUAL` | Editor used by `config edit` (`EDITOR` wins) |
| `NO_COLOR` | Setting any value disables colors and emoji decorations |
| `RUST_LOG` | Overrides `-v` log filtering, e.g. `RUST_LOG=debug` |
> Note: the key resolution path used for real generation requests is `QUICOMMIT_API_KEY` → keyring → (when `api_key_storage = "config"`) the config file.
> The provider-specific `QUICOMMIT_<PROVIDER>_API_KEY` is currently used only by `init`'s detection and the `config check-keyring` display; to have it feed generation, set `QUICOMMIT_API_KEY` instead.
---
## 8. Global Options
All subcommands share these global options:
| Option | Description |
| --- | --- |
| `-v, --verbose` (repeatable) | `-v` info, `-vv` debug, `-vvv` trace |
| `-c, --config <PATH>` | Config file path |
| `--no-color` | Disable colors **and** emoji decorations (highest priority) |
| `--emoji` / `--no-emoji` | Force emoji decorations on/off, overriding `output.emoji` |
---
## 9. Full Configuration Example
```toml
version = "1"
default_profile = "personal"
# -- LLM ------------------------------------------------------------
[llm]
provider = "deepseek"
model = "deepseek-v4-flash"
# base_url = "https://api.deepseek.com" # omit to use the provider default
max_tokens = 500
temperature = 0.6
timeout = 30
api_key_storage = "keyring"
thinking_enabled = false
# thinking_budget_tokens = 1024 # Anthropic only
# -- Commit / tag / changelog --------------------------------------
[commit]
format = "conventional"
auto_generate = true
[tag]
version_prefix = "v"
auto_generate = true
[changelog]
path = "CHANGELOG.md"
auto_generate = true
# -- Language and output -------------------------------------------
[language]
output_language = "en"
keep_types_english = true
keep_changelog_types_english = true
[output]
emoji = true
# -- Personal identity ---------------------------------------------
[profiles.personal]
name = "personal"
user_name = "Your Name"
user_email = "you@example.com"
description = "Personal projects"
is_work = false
[profiles.personal.settings]
auto_sign_commits = false
auto_sign_tags = false
repo_patterns = []
# -- Work identity (with SSH + GPG) --------------------------------
[profiles.work]
name = "work"
user_name = "Your Name"
user_email = "you@company.com"
description = "Work projects"
is_work = true
organization = "Your Company"
[profiles.work.settings]
auto_sign_commits = true
auto_sign_tags = true
default_commit_format = "conventional"
repo_patterns = ["/work/"]
[profiles.work.ssh]
private_key_path = "/home/user/.ssh/id_ed25519_work"
agent_forwarding = true
[profiles.work.gpg]
key_id = "0123456789ABCDEF"
program = "gpg"
use_agent = true
# -- Repo -> identity mapping --------------------------------------
[repo_profiles]
"/home/user/work/project" = "work"
"/home/user/personal/project" = "personal"
```
---
## 10. Common Recipes
### Local Ollama (no API key)
```bash
quicommit config set-llm ollama --base-url http://localhost:11434 --model llama2
quicommit config test-llm
```
### DeepSeek
```bash
quicommit config set-llm deepseek --model deepseek-v4-flash
quicommit config set-api-key # hidden input
# or in one shot: quicommit config set-llm deepseek --api-key <KEY>
```
### Dual work / personal identities
```bash
quicommit profile add # create "personal"
quicommit profile add # create "work" (optionally with SSH/GPG)
quicommit profile set-default personal
cd ~/work/project
quicommit profile set-repo work # record the mapping and apply it immediately
quicommit profile check work # verify it matches the repo's Git config
```
### In CI (no keyring)
```bash
export QUICOMMIT_API_KEY=<KEY> # the environment variable always wins
export QUICOMMIT_CONFIG=./ci-config.toml
quicommit commit --yes
```
### Enabling thinking mode
```bash
# Just this commit
quicommit commit --think
# On by default (takes effect for deepseek/kimi/anthropic/openai)
quicommit config set llm.thinking_enabled true
```
### Headless Linux (no Secret Service)
```bash
# Option 1: use an environment variable
export QUICOMMIT_API_KEY=<KEY>
# Option 2: store it in the config file (plain text; mind the file permissions)
quicommit config set llm.api_key_storage config
quicommit config set-api-key
```
---
## 11. Troubleshooting
### Corrupted config or an incompatible upgrade
```bash
quicommit config reset --force # reset to defaults
quicommit init # reconfigure
```
### API key errors
```bash
quicommit config check-keyring # is the keyring available, are env vars picked up?
quicommit config show # confirm provider and key mask status
quicommit config test-llm # end-to-end connectivity
```
- "keyring is not available": the system has no Secret Service (common on headless Linux). Use an environment variable or `api_key_storage = config`.
- "API key not configured": `set-api-key` did not take effect, or the environment variable is unset.
- A provider reports "not available": usually a `base_url` or credential problem. DeepSeek's default URL is `https://api.deepseek.com` (**without** `/v1`); if a legacy config explicitly wrote `/v1` and validation now fails, re-run `quicommit config set-llm deepseek`.
### Changes do not take effect
- Confirm you edited the effective config file: `quicommit config path` (remember `-c` / `QUICOMMIT_CONFIG` may change the path).
- Check whether a global option overrides the config: `--emoji`, `--no-emoji`, `--think`.
- Check environment-variable precedence: `QUICOMMIT_API_KEY`, `RUST_LOG`.
### Opening the config file
```bash
quicommit config edit # uses $EDITOR / $VISUAL
quicommit config show # read-only view
```

693
docs/configuration-guide.md Normal file
View File

@@ -0,0 +1,693 @@
# QuiCommit 配置指南
[English](configuration-guide.en.md) | 简体中文
本指南面向希望完整掌握 QuiCommit 配置的读者,覆盖:
- **快速设置**:三分钟跑通「建仓库 → 配 LLM → 第一次提交」
- **分类详细设置**:逐项说明配置文件里每个字段的含义、取值与默认值
- **命令参考**:`config` / `profile` 子命令全表
- **环境变量与全局选项**
- **常见场景配方**与**故障排除**
> 想先看功能全貌,请阅读 [README](../README.md)(中文版 [readme_zh.md](../readme_zh.md))。
> 配置术语(Repo init、Config init、Initial branch 等)的定义见 [CONTEXT.md](../CONTEXT.md)。
---
## 目录
1. [快速设置](#1-快速设置)
2. [配置文件位置与优先级](#2-配置文件位置与优先级)
3. [分类详细设置](#3-分类详细设置)
- [3.1 应用元信息(version / default_profile)](#31-应用元信息version--default_profile)
- [3.2 LLM 与 AI 生成(`[llm]`)](#32-llm-与-ai-生成llm)
- [3.3 Git 身份配置(`[profiles.*]`)](#33-git-身份配置profiles)
- [3.4 仓库到身份映射(`[repo_profiles]`)](#34-仓库到身份映射repo_profiles)
- [3.5 提交配置(`[commit]`)](#35-提交配置commit)
- [3.6 标签配置(`[tag]`)](#36-标签配置tag)
- [3.7 变更日志配置(`[changelog]`)](#37-变更日志配置changelog)
- [3.8 语言配置(`[language]`)](#38-语言配置language)
- [3.9 输出配置(`[output]`)](#39-输出配置output)
4. [配置命令参考(`quicommit config`)](#4-配置命令参考quicommit-config)
5. [身份配置命令参考(`quicommit profile`)](#5-身份配置命令参考quicommit-profile)
6. [令牌与凭据](#6-令牌与凭据)
7. [环境变量](#7-环境变量)
8. [全局选项](#8-全局选项)
9. [完整配置示例](#9-完整配置示例)
10. [常见场景配方](#10-常见场景配方)
11. [故障排除](#11-故障排除)
---
## 1. 快速设置
### 前置条件
| 依赖 | 说明 |
| --- | --- |
| Git | 任意版本均可(仓库初始化走 libgit2,不依赖 `git init -b`) |
| Rust / 已安装的 `quicommit` | 见 [README 安装章节](../README.md#installation) |
| 系统密钥环(推荐) | Windows 凭据管理器 / macOS Keychain / Linux Secret Service。用于安全存放 API Key 与 PAT。用 `quicommit config check-keyring` 检查 |
### 路线 A:交互式向导(推荐首次使用)
```bash
quicommit init
```
向导依次询问:输出语言 → 第一个 Git Profile(名称 / 用户名 / 邮箱 / 描述 / 是否工作身份)→ 可选 SSH → 可选 GPG → LLM provider → 模型 → API Key(隐藏输入,存入密钥环)。这与 CONTEXT.md 所说的 **Config init** 对应——只创建 QuiCommit 自己的配置文件,不碰 Git 仓库。
跳过所有提问、使用本机 Git 身份生成一份最小配置:
```bash
quicommit init --yes
```
### 路线 B:仓库起步 + 手动配 LLM
```bash
# ① 把当前目录变成 Git 仓库(建库、写 README、生成 .gitignore、定初始分支)
quicommit ginit
# ② 选择 LLM provider(无参数时进入交互选择)
quicommit config set-llm deepseek
# ③ 设置 API Key(省略值可隐藏输入;已带值时注意 shell 历史泄露)
quicommit config set-api-key
# ④ 验证连通性
quicommit config test-llm
# ⑤ 第一次提交
quicommit commit
```
### 验证当前配置
```bash
quicommit config show # 汇总展示所有生效配置
quicommit config path # 打印配置文件路径
quicommit config get llm.provider
quicommit config check-keyring # 检查密钥环与相关环境变量
```
---
## 2. 配置文件位置与优先级
配置文件是单个 TOML 文件,默认位置由操作系统的配置目录决定:
| 平台 | 默认路径 |
| --- | --- |
| Linux | `~/.config/quicommit/config.toml` |
| macOS | `~/Library/Application Support/quicommit/config.toml` |
| Windows | `%APPDATA%\quicommit\config.toml` |
### 指定自定义路径
优先级(从高到低):
1. 命令行全局选项 `-c` / `--config <PATH>`
2. 环境变量 `QUICOMMIT_CONFIG`
3. 上述平台默认路径
```bash
quicommit -c ./my-config.toml config show
# 或
QUICOMMIT_CONFIG=./my-config.toml quicommit config show
```
> 该路径会被所有子命令共享(`commit`、`tag`、`changelog`、`profile`、`ginit` 等)。
> 用自定义路径时,密钥环条目仍按 provider / profile 维度存取,与配置文件路径无关。
### 配置来源优先级汇总
| 配置项 | 优先级(高 → 低) |
| --- | --- |
| 配置文件路径 | `-c/--config` > `QUICOMMIT_CONFIG` > 平台默认 |
| API Key | 环境变量(`QUICOMMIT_API_KEY`,进程内始终优先) > 密钥环(默认存储方式) > 配置文件内 `llm.api_key`(仅当 `api_key_storage = "config"`) |
| Emoji 装饰 | `--no-color`(关闭) > `--no-emoji` / `--emoji` > 配置 `output.emoji` > 默认开启 |
| 日志级别 | `RUST_LOG` > `-v` 次数 |
| Initial branch(`ginit`) | `-b/--branch` > 全局 `init.defaultBranch` > `main` |
---
## 3. 分类详细设置
下面按配置文件的顶层表(table)逐项说明。每个字段都给出 **键名 / 类型 / 默认值**。
### 3.1 应用元信息(version / default_profile)
| 键 | 类型 | 默认值 | 说明 |
| --- | --- | --- | --- |
| `version` | string | `"1"` | 配置结构版本,用于未来迁移,一般不手动改动 |
| `default_profile` | string(可省略) | 无 | 默认 Git 身份名。`profile apply` 不带参数、`profile show` 不带参数时使用它 |
### 3.2 LLM 与 AI 生成(`[llm]`)
QuiCommit 通过 [rig-core](https://github.com/0xPlaygrounds/rig) 统一对接六家 provider:`ollama`、`openai`、`anthropic`、`kimi`、`deepseek`、`openrouter`。
| 键 | 类型 | 默认值 | 说明 |
| --- | --- | --- | --- |
| `provider` | string | `"ollama"` | provider 名称,见下表 |
| `model` | string | `"llama2"` | 模型名。切换 provider 时若当前模型为空或仍是 `llama2`,会自动替换为该 provider 的默认模型 |
| `base_url` | string(可省略) | 无(用 provider 默认) | 自定义 API 地址,用于代理、私有部署或国内镜像 |
| `max_tokens` | 整数 | `500` | 单次生成的最大输出 token 数 |
| `temperature` | 浮点 | `0.6` | 采样温度。部分 provider 会覆盖它(见「温度与思考模式」) |
| `timeout` | 整数(秒) | `30` | HTTP 请求超时 |
| `api_key_storage` | string | `"keyring"` | API Key 存储方式:`keyring` / `config` / `environment` |
| `api_key` | string(可省略) | 无 | 仅当 `api_key_storage = "config"` 时生效,明文存于配置文件 |
| `thinking_enabled` | 布尔 | `false` | 默认是否启用思考/推理模式(`commit`/`tag`/`changelog` 的 `--think` 可临时覆盖) |
| `thinking_budget_tokens` | 整数(可省略) | 无(运行期按 `1024` 处理) | 仅 Anthropic 使用:思考预算 token |
#### Provider 默认值速查
| provider | 默认 `base_url` | 默认 `model` | 是否需要 API Key |
| --- | --- | --- | --- |
| `ollama` | `http://localhost:11434` | `llama2` | 否 |
| `openai` | `https://api.openai.com/v1` | `gpt-4` | 是 |
| `anthropic` | `https://api.anthropic.com/v1` | `claude-3-sonnet-20240229` | 是 |
| `kimi` | `https://api.moonshot.cn/v1` | `kimi-k2.6` | 是 |
| `deepseek` | `https://api.deepseek.com` | `deepseek-v4-flash` | 是 |
| `openrouter` | `https://openrouter.ai/api/v1` | `openai/gpt-3.5-turbo` | 是 |
> `quicommit config list-models` 会按当前 provider 打印一批示例模型名(Ollama 会尝试直接调用本机 `ollama list`)。
#### API Key 的三种存储方式
- `keyring`(默认,推荐):存入系统密钥环,`config.toml` 中不出现明文。密钥环不可用时 `set-api-key` 会报错并提示改用环境变量或 `config`。
- `config`:明文写入 `config.toml` 的 `llm.api_key`。方便但不安全,`set-api-key` 会给出明文告警。
- `environment`:不存储,要求你自行设置环境变量。
设置与删除:
```bash
# 方式一:交互式选择 provider / 模型 / base_url / API Key
quicommit config set-llm deepseek
# 方式二:非交互,直接给出参数(--api-key 会进入 shell 历史,建议仅用于 CI)
quicommit config set-llm openai --model gpt-4o --base-url https://api.openai.com/v1
# 单独设置/删除 API Key(省略值则隐藏输入)
quicommit config set-api-key
quicommit config delete-api-key
```
#### 温度与思考模式
`--think`(或配置 `thinking_enabled = true`)只在 `deepseek`、`kimi`、`anthropic`、`openai` 上生效;其余 provider 会自动降级为普通生成。各 provider 的参数处理如下:
| provider | 普通模式 | 思考模式 |
| --- | --- | --- |
| `ollama` | 使用 `temperature` / `max_tokens` | 不支持,降级 |
| `deepseek` | 使用 `temperature`;显式传 `thinking: disabled` | 省略 temperature,走流式 |
| `kimi` | 强制 `temperature = 0.6`(忽略配置) | 强制 `temperature = 1.0`,走流式 |
| `anthropic` | 使用 `temperature` | 省略 temperature;`max_tokens` 提升到至少 `thinking_budget_tokens + 100`,预算缺省 `1024`;走流式 |
| `openai` | 使用 `temperature`;`o*` 系列模型显式关闭推理 | 省略 temperature,走流式 |
| `openrouter` | 使用 `temperature` / `max_tokens` | 不支持,降级 |
> 命令层开关:`commit --think`、`tag --think`、`changelog --think`。
### 3.3 Git 身份配置(`[profiles.*]`)
每个 profile 是一组可独立启用的 Git 身份 + 认证设置。用 `quicommit profile add`(交互式)创建,或直接编辑配置文件。
顶层字段:
| 键 | 类型 | 默认值 | 说明 |
| --- | --- | --- | --- |
| `name` | string | 无 | profile 名,须与 TOML 表名一致。只允许字母、数字、`-`、`_`,最长 50 字符 |
| `user_name` | string | 无 | 写入 `user.name` |
| `user_email` | string | 无 | 写入 `user.email`,须符合邮箱格式 |
| `description` | string(可省略) | 无 | 备注 |
| `is_work` | 布尔 | `false` | 是否工作身份(仅作展示/筛选标记) |
| `organization` | string(可省略) | 无 | 组织名(`is_work = true` 时有意义) |
| `signing_key` | string(可省略) | 无 | 直接指定签名 key;未设置时回退到 `gpg.key_id` |
#### `[profiles.<name>.settings]` — 行为偏好
| 键 | 类型 | 默认值 | 说明 |
| --- | --- | --- | --- |
| `auto_sign_commits` | 布尔 | `false` | 应用该 profile 时是否写入 `commit.gpgsign = true` |
| `auto_sign_tags` | 布尔 | `false` | 应用该 profile 时是否写入 `tag.gpgsign = true` |
| `default_commit_format` | string(可省略) | 无 | 该 profile 偏好的提交格式:`conventional` / `commitlint` |
| `repo_patterns` | 字符串数组 | `[]` | 用于把仓库路径匹配到该 profile 的路径片段 |
| `llm_provider` | string(可省略) | 无 | 该 profile 偏好的 LLM provider(元数据,供未来使用) |
| `commit_template` | string(可省略) | 无 | 自定义提交信息模板 |
#### `[profiles.<name>.ssh]` — SSH 配置(可选)
| 键 | 类型 | 默认值 | 说明 |
| --- | --- | --- | --- |
| `private_key_path` | 路径(可省略) | 无 | SSH 私钥路径(校验存在性) |
| `public_key_path` | 路径(可省略) | 无 | 公钥路径;未设置时按私钥推导 `<key>.pub` |
| `passphrase` | string(可省略) | 无 | 私钥口令(加密存储) |
| `agent_forwarding` | 布尔 | `false` | 是否加 `-A` |
| `ssh_command` | string(可省略) | 无 | 完全自定义的 SSH 命令;设置后覆盖其他字段 |
| `known_hosts_file` | 路径(可省略) | 无 | 追加 `-o UserKnownHostsFile=<path>` |
应用时写入 Git 本地配置 `core.sshCommand`。命令拼接规则:`ssh` +(`-A`)+(`-i <私钥>`)+(`-o UserKnownHostsFile=<文件>`);若 `ssh_command` 有值则直接使用它。
#### `[profiles.<name>.gpg]` — GPG 配置(可选)
| 键 | 类型 | 默认值 | 说明 |
| --- | --- | --- | --- |
| `key_id` | string | 无 | GPG Key ID,须为 16–40 位十六进制大写字符 |
| `program` | string | Windows:`gpg.exe`;其他:`gpg` | 写入 `gpg.program` |
| `home_dir` | 路径(可省略) | 无 | GPG home 目录 |
| `passphrase` | string(可省略) | 无 | 口令(加密存储) |
| `use_agent` | 布尔 | `true` | 是否使用 gpg-agent |
应用时写入 Git 本地配置 `gpg.program`、`user.signingkey`,并按 `settings.auto_sign_commits` / `auto_sign_tags` 写入 `commit.gpgsign` / `tag.gpgsign`。
#### 应用(写出到 Git)与展示
`profile apply` / `profile set-repo` / `profile switch` 会把 profile 写入 Git 配置:
- 仓库级:`user.name`、`user.email`,以及上面提到的 SSH / GPG 相关键
- 全局(`profile apply --global`):写入全局 Git 配置
> 应用 profile 时会先清理「新 profile 不再设置」的受管键(`core.sshCommand`、`user.signingkey`、`commit.gpgsign`、`tag.gpgsign`、`gpg.program`),避免残留旧身份。
### 3.4 仓库到身份映射(`[repo_profiles]`)
把「仓库绝对路径 → profile 名」记录下来,供 `profile show` 等命令识别当前仓库应使用哪个身份。由 `profile set-repo` 或 `ginit --profile` 自动写入,一般无需手改。
```toml
[repo_profiles]
"/home/me/work/project" = "work"
"/home/me/personal/project" = "personal"
```
### 3.5 提交配置(`[commit]`)
| 键 | 类型 | 默认值 | 说明 |
| --- | --- | --- | --- |
| `format` | string | `"conventional"` | 提交格式:`conventional` 或 `commitlint` |
| `auto_generate` | 布尔 | `true` | 是否默认用 AI 生成提交信息(`commit --manual` 可临时关闭) |
格式差异(见 [validators.rs](../src/utils/validators.rs)):
- `conventional`:`<type>[scope][!]: <description>`,type 取自 11 种(feat、fix、docs、style、refactor、perf、test、build、ci、chore、revert)。
- `commitlint`:在 conventional 基础上扩展 type(额外支持 wip、init、update、remove、security),并额外校验主题不以大写字母开头、不以句点结尾。
```bash
quicommit config set commit.format conventional
quicommit config set-commit-format commitlint
quicommit config set commit.auto_generate false
```
### 3.6 标签配置(`[tag]`)
| 键 | 类型 | 默认值 | 说明 |
| --- | --- | --- | --- |
| `version_prefix` | string | `"v"` | tag 版本前缀,如 `v1.2.3` |
| `auto_generate` | 布尔 | `true` | 是否默认用 AI 生成 tag message |
```bash
quicommit config set tag.version_prefix v
quicommit config set-version-prefix v
```
### 3.7 变更日志配置(`[changelog]`)
| 键 | 类型 | 默认值 | 说明 |
| --- | --- | --- | --- |
| `path` | string | `"CHANGELOG.md"` | 变更日志文件路径 |
| `auto_generate` | 布尔 | `true` | 是否默认用 AI 生成变更日志(`--no-generate` 可临时关闭,改用模版) |
```bash
quicommit config set changelog.path CHANGELOG.md
quicommit config set-changelog-path CHANGELOG.md
```
### 3.8 语言配置(`[language]`)
| 键 | 类型 | 默认值 | 说明 |
| --- | --- | --- | --- |
| `output_language` | string | `"en"` | 界面/生成文案语言 |
| `keep_types_english` | 布尔 | `true` | 提交类型(feat、fix…)是否保持英文 |
| `keep_changelog_types_english` | 布尔 | `true` | 变更日志分类(Added、Fixed…)是否保持英文 |
支持的语言代码:`en`、`zh`(含 `zh-cn` / `zh-tw`)、`ja`、`ko`、`es`、`fr`、`de`(也接受 `english`、`chinese` 等全称)。无效代码会静默回退为英文。
```bash
quicommit config set-language zh # 省略参数则交互式选择
quicommit config set language.output_language zh
quicommit config set-keep-types-english true
quicommit config set-keep-changelog-types-english true
```
### 3.9 输出配置(`[output]`)
| 键 | 类型 | 默认值 | 说明 |
| --- | --- | --- | --- |
| `emoji` | 布尔 | `true` | 是否显示 ✓ / ✗ / ⚠ / → 等装饰符号 |
```bash
quicommit config set output.emoji false
```
命令行覆盖:`--no-color` 会同时关闭颜色与装饰;`--emoji` / `--no-emoji` 单独控制装饰。
---
## 4. 配置命令参考(`quicommit config`)
别名:`quicommit cfg`。不带子命令时等价于 `config show`。
| 子命令 | 说明 |
| --- | --- |
| `config show` | 汇总展示当前配置(通用 / LLM / 提交 / 标签 / 语言 / 变更日志 / 安全) |
| `config path` | 打印配置文件路径 |
| `config edit` | 用 `$EDITOR`(或 `$VISUAL`)打开配置文件 |
| `config get <key>` | 读取单个配置项 |
| `config set <key> <value>` | 写入单个配置项(见下方键表) |
| `config set-llm [provider]` | 交互式配置 LLM;可带 `--model` / `--base-url` / `--api-key` |
| `config set-api-key [key]` | 设置当前 provider 的 API Key(省略则隐藏输入) |
| `config delete-api-key` | 从当前存储方式中删除 API Key |
| `config set-commit-format <fmt>` | 设置 `conventional` / `commitlint` |
| `config set-version-prefix <prefix>` | 设置 tag 版本前缀 |
| `config set-changelog-path <path>` | 设置变更日志路径 |
| `config set-language [code]` | 设置输出语言(省略则交互式选择) |
| `config set-keep-types-english <bool>` | 提交类型是否保持英文 |
| `config set-keep-changelog-types-english <bool>` | 变更日志分类是否保持英文 |
| `config list-models` | 按当前 provider 列出示例/已安装模型 |
| `config test-llm` | 发送一次测试请求,验证 provider 连通性 |
| `config check-keyring` | 检查密钥环可用性与相关环境变量 |
| `config export -o <file> [--password]` | 导出配置(可选加密) |
| `config import -i <file> [--password]` | 导入配置 |
| `config reset [--force]` | 重置为默认配置 |
### `config set` / `config get` 支持的键
| 键 | 取值 |
| --- | --- |
| `llm.provider` | `ollama` / `openai` / `anthropic` / `kimi` / `deepseek` / `openrouter` |
| `llm.model` | 任意模型名 |
| `llm.base_url` | URL |
| `llm.max_tokens` | 整数 |
| `llm.temperature` | 浮点 |
| `llm.timeout` | 整数(秒) |
| `llm.thinking_enabled` | `true` / `false` |
| `llm.thinking_budget_tokens` | 整数 |
| `llm.api_key_storage` | `keyring` / `config` / `environment` |
| `commit.format` | `conventional` / `commitlint` |
| `commit.auto_generate` | `true` / `false` |
| `tag.version_prefix` | 字符串 |
| `tag.auto_generate` | `true` / `false` |
| `changelog.path` | 路径字符串 |
| `changelog.auto_generate` | `true` / `false` |
| `language.output_language` | 语言代码 |
| `language.keep_types_english` | `true` / `false` |
| `language.keep_changelog_types_english` | `true` / `false` |
| `output.emoji` | `true` / `false` |
> `config set` 只接受上表键名,其他键会报「未知配置键」。
> **API Key 不在其中**——请用 `config set-api-key` / `config delete-api-key`。
> 布尔键只接受字面 `true` / `false`。
---
## 5. 身份配置命令参考(`quicommit profile`)
别名:`quicommit p`。不带子命令时等价于 `profile list`。
| 子命令 | 说明 |
| --- | --- |
| `profile add` | 交互式新建 profile(名称 / 用户名 / 邮箱 / 描述 / 工作标记 / SSH / GPG / 令牌) |
| `profile list` | 列出所有 profile(默认项标 `●`) |
| `profile show [name]` | 展示当前仓库 Git 配置、匹配到的 profile 及详情 |
| `profile edit <name>` | 交互式编辑,含 SSH / GPG / 签名偏好 / 令牌子菜单 |
| `profile copy <from> <to>` | 复制 profile(不复制使用统计) |
| `profile remove <name>` | 删除 profile(同时清理其密钥环 PAT) |
| `profile set-default <name>` | 设置默认 profile |
| `profile set-repo <name>` | 为当前仓库记录映射并立即应用 |
| `profile switch` | 交互式切换默认 profile,可选是否应用到当前仓库 |
| `profile apply [name] [--global]` | 把 profile(默认取 default)应用到当前仓库或全局 Git 配置 |
| `profile check [name]` | 对比 profile 与当前仓库 Git 配置的差异 |
| `profile stats [name]` | 查看使用统计 |
| `profile token add <profile> <service>` | 添加服务令牌(存入密钥环) |
| `profile token remove <profile> <service>` | 移除服务令牌 |
| `profile token list <profile>` | 列出令牌及其存储状态 |
---
## 6. 令牌与凭据
### PAT(Personal Access Token)
PAT 用于把私有仓库推送到 GitHub / GitLab 等平台,按 **profile + 服务名** 维度存入系统密钥环(`config.toml` 中只记录元信息如类型、描述、是否已存储)。
```bash
quicommit profile token add personal github
quicommit profile token list personal
quicommit profile token remove personal github
```
服务名可自由填写(`github`、`gitlab`、`bitbucket` 等)。
### Git Credential Helper
QuiCommit 可作为 Git 凭据助手,注册后 Git 推送时会自动向它索取 PAT:
```bash
git config --global credential.helper quicommit
# 或限定主机
git config --global credential.https://github.com.helper quicommit
```
`credential get|store|erase` 由 Git 自动调用,命令隐藏在 `--help` 之外。删除已存凭据:
```bash
echo "protocol=https
host=github.com" | git credential reject
```
### 导入导出与备份
```bash
# 明文导出(不含密钥环中的 PAT)
quicommit config export -o config-backup.toml
# 加密导出(把 profile 的 PAT 一并加密打包)
quicommit config export -o config-backup.enc --password
# 导入
quicommit config import -i config-backup.toml
quicommit config import -i config-backup.enc --password
```
> 加密导出使用 AES-GCM + Argon2;导出文件以 `ENCRYPTED:` 前缀标识。若目标配置含有 PAT 却未启用加密,导入/导出会给出明文告警。
---
## 7. 环境变量
| 变量 | 说明 |
| --- | --- |
| `QUICOMMIT_CONFIG` | 配置文件路径(等价于 `-c/--config`) |
| `QUICOMMIT_API_KEY` | 任意 provider 的 API Key,**进程内始终优先**于密钥环与配置文件 |
| `QUICOMMIT_<PROVIDER>_API_KEY` | provider 专属 API Key(如 `QUICOMMIT_OPENAI_API_KEY`)。交互式 `init` 会检测它,`config check-keyring` 会展示它 |
| `EDITOR` / `VISUAL` | `config edit` 使用的编辑器(`EDITOR` 优先) |
| `NO_COLOR` | 设置任意值即关闭彩色与 Emoji 装饰 |
| `RUST_LOG` | 覆盖 `-v` 的日志过滤,如 `RUST_LOG=debug` |
> 注意:真正参与生成请求的 Key 解析路径是 `QUICOMMIT_API_KEY` → 密钥环 →(`api_key_storage = "config"` 时)配置文件。
> provider 专属的 `QUICOMMIT_<PROVIDER>_API_KEY` 目前仅用于 `init` 的检测与 `config check-keyring` 的展示;若要让它生效参与生成,请改设 `QUICOMMIT_API_KEY`。
---
## 8. 全局选项
所有子命令共享以下全局选项:
| 选项 | 说明 |
| --- | --- |
| `-v, --verbose`(可叠加) | `-v` info、`-vv` debug、`-vvv` trace |
| `-c, --config <PATH>` | 配置文件路径 |
| `--no-color` | 关闭颜色**与** Emoji 装饰(最高优先级) |
| `--emoji` / `--no-emoji` | 强制开启/关闭 Emoji 装饰,覆盖 `output.emoji` |
---
## 9. 完整配置示例
```toml
version = "1"
default_profile = "personal"
# ── LLM ─────────────────────────────────────────────────────────────
[llm]
provider = "deepseek"
model = "deepseek-v4-flash"
# base_url = "https://api.deepseek.com" # 省略则用 provider 默认
max_tokens = 500
temperature = 0.6
timeout = 30
api_key_storage = "keyring"
thinking_enabled = false
# thinking_budget_tokens = 1024 # 仅 Anthropic 使用
# ── 提交 / 标签 / 变更日志 ──────────────────────────────────────────
[commit]
format = "conventional"
auto_generate = true
[tag]
version_prefix = "v"
auto_generate = true
[changelog]
path = "CHANGELOG.md"
auto_generate = true
# ── 语言与输出 ──────────────────────────────────────────────────────
[language]
output_language = "zh"
keep_types_english = true
keep_changelog_types_english = true
[output]
emoji = true
# ── 个人身份 ────────────────────────────────────────────────────────
[profiles.personal]
name = "personal"
user_name = "Your Name"
user_email = "you@example.com"
description = "个人项目"
is_work = false
[profiles.personal.settings]
auto_sign_commits = false
auto_sign_tags = false
repo_patterns = []
# ── 工作身份(含 SSH + GPG) ───────────────────────────────────────
[profiles.work]
name = "work"
user_name = "Your Name"
user_email = "you@company.com"
description = "工作项目"
is_work = true
organization = "Your Company"
[profiles.work.settings]
auto_sign_commits = true
auto_sign_tags = true
default_commit_format = "conventional"
repo_patterns = ["/work/"]
[profiles.work.ssh]
private_key_path = "/home/user/.ssh/id_ed25519_work"
agent_forwarding = true
[profiles.work.gpg]
key_id = "0123456789ABCDEF"
program = "gpg"
use_agent = true
# ── 仓库 → 身份映射 ────────────────────────────────────────────────
[repo_profiles]
"/home/user/work/project" = "work"
"/home/user/personal/project" = "personal"
```
---
## 10. 常见场景配方
### 用本地 Ollama(无需 API Key)
```bash
quicommit config set-llm ollama --base-url http://localhost:11434 --model llama2
quicommit config test-llm
```
### 用 DeepSeek
```bash
quicommit config set-llm deepseek --model deepseek-v4-flash
quicommit config set-api-key # 隐藏输入
# 或一次性:quicommit config set-llm deepseek --api-key <KEY>
```
### 工作 / 个人双身份
```bash
quicommit profile add # 建 personal
quicommit profile add # 再建 work(可配 SSH/GPG)
quicommit profile set-default personal
cd ~/work/project
quicommit profile set-repo work # 记录映射并立即应用到本仓库
quicommit profile check work # 校验是否与仓库 Git 配置一致
```
### 在 CI 中使用(无密钥环)
```bash
export QUICOMMIT_API_KEY=<KEY> # 环境变量始终优先
export QUICOMMIT_CONFIG=./ci-config.toml
quicommit commit --yes
```
### 启用思考模式
```bash
# 仅本次提交
quicommit commit --think
# 全局默认开启(对 deepseek/kimi/anthropic/openai 生效)
quicommit config set llm.thinking_enabled true
```
### Linux 无桌面环境(无 Secret Service)
```bash
# 方案一:改用环境变量
export QUICOMMIT_API_KEY=<KEY>
# 方案二:改存配置文件(明文,注意权限)
quicommit config set llm.api_key_storage config
quicommit config set-api-key
```
---
## 11. 故障排除
### 配置损坏或旧版本不兼容
```bash
quicommit config reset --force # 重置为默认
quicommit init # 重新配置
```
### API Key 相关报错
```bash
quicommit config check-keyring # 确认密钥环是否可用、环境变量是否被识别
quicommit config show # 确认 provider 与 Key 掩码状态
quicommit config test-llm # 端到端连通性
```
- 「keyring is not available」:系统无 Secret Service(常见于无桌面的 Linux)。改用环境变量或 `api_key_storage = config`。
- 「API key not configured」:`set-api-key` 未生效或环境变量未设置。
- provider 报「not available」:多为 `base_url` 或凭据问题。DeepSeek 的默认地址是 `https://api.deepseek.com`(**不带** `/v1`);若历史配置里显式写过 `/v1` 导致校验失败,请重新执行 `quicommit config set-llm deepseek`。
### 改了配置但行为没变
- 确认改的是生效的配置文件:`quicommit config path`(注意 `-c` / `QUICOMMIT_CONFIG` 可能改变了路径)。
- 检查全局选项是否覆盖了配置:`--emoji`、`--no-emoji`、`--think`。
- 检查环境变量优先级:`QUICOMMIT_API_KEY`、`RUST_LOG`。
### 打开配置文件
```bash
quicommit config edit # 使用 $EDITOR / $VISUAL
quicommit config show # 只读查看
```

View File

@@ -46,61 +46,35 @@ use_agent = true
# LLM Configuration
[llm]
# Provider: ollama, openai, or anthropic
# Provider: ollama, openai, anthropic, kimi, deepseek, openrouter
provider = "ollama"
# Model name (provider-appropriate)
model = "llama2"
# API base URL (optional, provider default will be used if not set)
# base_url = "http://localhost:11434"
max_tokens = 500
temperature = 0.7
timeout = 30
# Ollama settings (local LLM)
[llm.ollama]
url = "http://localhost:11434"
model = "llama2"
# OpenAI settings
[llm.openai]
# api_key = "sk-..." # Set via: quicommit config set-openai-key
model = "gpt-4"
base_url = "https://api.openai.com/v1"
# Anthropic settings
[llm.anthropic]
# api_key = "sk-ant-..." # Set via: quicommit config set-anthropic-key
model = "claude-3-sonnet-20240229"
# API key storage: keyring, config, environment
api_key_storage = "keyring"
# Enable thinking/reasoning mode (deepseek, kimi, anthropic)
thinking_enabled = false
# Commit settings
[commit]
# Format: conventional or commitlint
format = "conventional"
auto_generate = true
allow_empty = false
gpg_sign = false
max_subject_length = 100
require_scope = false
require_body = false
body_required_types = ["feat", "fix"]
# Tag settings
[tag]
version_prefix = "v"
auto_generate = true
gpg_sign = false
include_changelog = true
# Changelog settings
[changelog]
path = "CHANGELOG.md"
auto_generate = true
format = "keep-a-changelog" # or "github-releases"
include_hashes = false
include_authors = false
group_by_type = true
# Theme settings
[theme]
colors = true
icons = true
date_format = "%Y-%m-%d"
# Repository-specific profile mappings
# [repo_profiles]

View File

@@ -14,13 +14,16 @@
## 主要功能
- **仓库初始化**:用 `quicommit ginit` 一键起步本地 Git 仓库——建库、写最简 `README.md`、按识别出的生态生成 `.gitignore`、确立初始分支、添加远程。全程离线,不创建首个提交
- **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界面,支持预览和确认
- **多语言支持**:支持7种语言输出(中文、英语、日语、韩语、西班牙语、法语、德语)
- **配置导出导入**:备份和恢复配置,支持加密保护
## 安装
@@ -45,6 +48,28 @@ cargo install --path .
## 快速开始
### 初始化 Git 仓库
```bash
# 把当前目录初始化为 Git 仓库
quicommit ginit
# 指定初始分支(默认取全局 init.defaultBranch,缺省为 main)
quicommit ginit -b dev
# 添加远程,可重复传入:--add <URL> 或 --add <名称>=<URL>
quicommit ginit --add https://github.com/user/repo.git
quicommit ginit --add upstream=git@github.com:user/repo.git
# 把已有 Git 配置套用到新仓库
quicommit ginit --profile work
# 只预览,不写任何内容
quicommit ginit --dry-run
```
`ginit` 全程离线且可重复执行:不创建首个提交,也不覆盖已存在的 `README.md` 与 `.gitignore`。
### 初始化配置
```bash
@@ -60,7 +85,7 @@ quicommit commit
# 手动提交
quicommit commit --manual -t feat -m "添加新功能"
# 暂存所有文件并提交
# 暂存所有文件并提交(自动跳过 .gitignore 匹配的文件)
quicommit commit -a
# 跳过确认直接提交
@@ -69,16 +94,25 @@ quicommit commit --yes
# 使用日期格式的提交信息
quicommit commit --date
# 为本次提交启用 LLM 思考/推理模式
quicommit commit --think
# 提交后推送到远程
quicommit commit --push
```
在暂存更改时(自动暂存或 `--all` 模式),会自动跳过匹配 `.gitignore` 规则的文件,并清理索引中已被忽略的已跟踪文件,最后打印出被跳过的文件列表。
### 创建标签
```bash
# 自动检测版本升级
quicommit tag
# 从项目配置文件(Cargo.toml、package.json、pom.xml 等)自动检测版本,
# 回退到基于 commit 的分析
quicommit tag --auto
# 指定版本升级类型
quicommit tag --bump minor
@@ -95,11 +129,14 @@ quicommit tag --push
### 生成变更日志
```bash
# 生成未发布变更的变更日志
# 生成未发布变更的变更日志(未指定 --from 时自动从现有 changelog 的最高版本检测)
quicommit changelog
# 为特定版本生成
quicommit changelog -v 1.0.0
quicommit changelog --version 1.0.0
# 从指定标签生成
quicommit changelog --from v0.9.0
# AI生成变更日志
quicommit changelog --generate
@@ -157,12 +194,68 @@ quicommit profile stats
quicommit profile token
```
### Git 凭据助手(Credential Helper)
QuiCommit 可以作为 Git 凭据助手,通过系统密钥环安全地存储和读取个人访问令牌(PAT)。
`credential` 命令在 `--help` 中是隐藏的,因为它由 Git 自动调用,无需用户手动执行。
#### 配置方法
将 QuiCommit 注册为 Git 凭据助手:
```bash
# 使用默认的 QuiCommit 配置
git config --global credential.helper quicommit
# 或指定自定义配置文件路径
git config --global credential.helper "quicommit --config /path/to/config.toml"
```
也可以仅对特定主机启用:
```bash
git config --global credential.https://github.com.helper quicommit
```
#### 工作原理
当 Git 需要凭据(例如推送到远程仓库)时,会按照
[gitcredentials 协议](https://git-scm.com/docs/gitcredentials) 调用助手:
1. **`get`** — Git 向 QuiCommit 请求与目标主机匹配的已存储 PAT。
QuiCommit 在所有已配置的 profile 中搜索密钥环,找到则返回 PAT(及用户名)。
2. **`store`** — 认证成功后(例如你在 Git 提示中输入了 PAT),Git 要求
QuiCommit 保存该凭据。PAT 将存入系统密钥环,并与匹配的 profile 绑定。
3. **`erase`** — Git 要求 QuiCommit 删除指定主机的已存储 PAT。
主机名会被映射为规范化的服务名(`github.com` → `github`、`gitlab.com` →
`gitlab`、`bitbucket.org` → `bitbucket` 等),未知主机则原样使用。
#### 删除已存储的凭据
可以通过 Git 内置机制删除已存储的 PAT:
```bash
echo "protocol=https
host=github.com" | git credential reject
```
也可以通过 profile 令牌管理命令删除:
```bash
quicommit profile token
```
### LLM配置
```bash
# 配置Ollama(本地)
quicommit config set-llm ollama
quicommit config set-llm ollama --url http://localhost:11434 --model llama2
quicommit config set-llm ollama --base-url http://localhost:11434 --model llama2
> ⚠️ **安全提示**:把 API 密钥作为命令行参数传入会留在 shell history 与进程列表中。
> 建议直接运行 `quicommit config set-api-key`(或 `config set-llm`)不携带密钥,
> 以隐藏方式输入。
# 配置OpenAI
quicommit config set-llm openai
@@ -180,7 +273,7 @@ quicommit config set-llm kimi --base-url https://api.moonshot.cn/v1 --model moon
# 配置DeepSeek
quicommit config set-llm deepseek
quicommit config set-api-key YOUR_API_KEY
quicommit config set-llm deepseek --base-url https://api.deepseek.com/v1 --model deepseek-chat
quicommit config set-llm deepseek --base-url https://api.deepseek.com --model deepseek-chat
# 配置OpenRouter
quicommit config set-llm openrouter
@@ -196,7 +289,7 @@ quicommit config set-version-prefix v
# 设置变更日志路径
quicommit config set-changelog-path CHANGELOG.md
# 设置输出语言
# 设置输出语言(zh, en, ja, ko, es, fr, de)
quicommit config set-language zh
# 设置保持提交类型为英文
@@ -214,6 +307,14 @@ quicommit config check-keyring
# 显示配置文件路径
quicommit config path
# 导出配置(支持加密)
quicommit config export -o config-backup.toml
quicommit config export -o config-backup.enc --password
# 导入配置
quicommit config import -i config-backup.toml
quicommit config import -i config-backup.enc --password
# 重置配置为默认值
quicommit config reset --force
```
@@ -223,11 +324,25 @@ quicommit config reset --force
| 命令 | 别名 | 说明 |
|------|------|------|
| `quicommit init` | `i` | 初始化配置 |
| `quicommit ginit` | `gi` | 初始化本地 Git 仓库 |
| `quicommit commit` | `c` | 生成并执行提交 |
| `quicommit tag` | `t` | 生成并创建标签 |
| `quicommit changelog` | `cl` | 生成变更日志 |
| `quicommit profile` | `p` | 管理Git配置 |
| `quicommit config` | `cfg` | 管理应用配置 |
| `quicommit credential` | — | Git凭据助手(隐藏,由Git调用) |
### ginit命令选项
| 选项 | 说明 |
|------|------|
| `-b, --branch` | 初始分支名(默认取全局 `init.defaultBranch`,缺省时为 `main`) |
| `--add` | 添加远程:`--add <url>`(远程名默认 `origin`)或 `--add <name>=<url>`;可重复传入 |
| `--profile` | 把已有的 Git 配置应用到新仓库,并记录该目录到配置文件的映射 |
| `--dry-run` | 只展示计划,不实际创建任何内容 |
| `-y, --yes` | 仅跳过交互确认(产出内容不变) |
`ginit` 全程离线:不向托管平台创建远程仓库、不验证远程可达性、不创建首个提交,也不会覆盖已存在的 `README.md` 与 `.gitignore`。
### commit命令选项
@@ -240,16 +355,18 @@ quicommit config reset --force
| `-b, --breaking` | 标记为破坏性变更 |
| `-d, --date` | 使用日期格式的提交信息 |
| `--manual` | 手动输入,跳过AI生成 |
| `-a, --all` | 暂存所有更改 |
| `-a, --all` | 暂存所有更改(自动跳过 `.gitignore` 匹配的文件) |
| `-S, --sign` | GPG签名提交 |
| `--amend` | 修改上一次提交 |
| `--dry-run` | 试运行,不实际提交 |
| `--conventional` | 使用Conventional Commits格式 |
| `--commitlint` | 使用commitlint格式 |
| `--no-verify` | 不验证提交信息 |
| `-y, --yes` | 跳过确认提示 |
| `-t, --think` | 启用 LLM 思考/推理模式(覆盖配置) |
| `-y, --yes` | 仅跳过确认提示(生成行为不变) |
| `--push` | 提交后推送到远程 |
| `--remote` | 指定远程仓库(默认:origin) |
| `--select-remote` | 多远程仓库时交互式选择推送目标(可多选) |
### tag命令选项
@@ -257,6 +374,7 @@ quicommit config reset --force
|------|------|
| `-n, --name` | 标签名称 |
| `-b, --bump` | 版本升级类型(major/minor/patch) |
| `-A, --auto` | 从项目配置文件(Cargo.toml、package.json、pom.xml 等 16+ 种)自动检测版本,回退到基于 commit 的分析(与 `--bump` 互斥) |
| `-m, --message` | 标签信息 |
| `-g, --generate` | AI生成标签信息 |
| `-S, --sign` | GPG签名标签 |
@@ -264,28 +382,33 @@ quicommit config reset --force
| `-f, --force` | 强制覆盖已存在的标签 |
| `-p, --push` | 推送到远程 |
| `-r, --remote` | 指定远程仓库(默认:origin) |
| `--select-remote` | 多远程仓库时交互式选择推送目标(可多选) |
| `--dry-run` | 试运行 |
| `-y, --yes` | 跳过确认提示 |
| `-t, --think` | 启用 LLM 思考/推理模式(覆盖配置) |
| `-y, --yes` | 仅跳过确认提示(生成行为不变) |
### changelog命令选项
| 选项 | 说明 |
|------|------|
| `-o, --output` | 输出文件路径 |
| `-v, --version` | 为特定版本生成 |
| `-f, --from` | 从指定标签生成 |
| `--version` | 为特定版本生成 |
| `-f, --from` | 从指定标签生成(未指定时自动从现有 changelog 的最高版本检测) |
| `-t, --to` | 生成到指定引用(默认:HEAD) |
| `-i, --init` | 初始化新的变更日志文件 |
| `-g, --generate` | AI生成变更日志 |
| `--prepend` | 添加到现有变更日志开头 |
| `--include-hashes` | 包含提交哈希 |
| `--include-authors` | 包含作者信息 |
| `--format` | 格式(keep-a-changelog、github-releases) |
| `--dry-run` | 试运行(输出到stdout) |
| `-y, --yes` | 跳过确认提示 |
| `--think` | 启用 LLM 思考/推理模式(覆盖配置) |
| `--no-generate` | 使用模板确定性生成(不调用 AI) |
| `-y, --yes` | 仅跳过确认提示(生成行为不变) |
## 配置文件
> 完整的分类配置说明(快速设置、逐字段详解、provider 默认值、常见配方与故障排除)见 [docs/configuration-guide.md](docs/configuration-guide.md)。
配置文件位置:
- Linux: `~/.config/quicommit/config.toml`
- macOS: `~/Library/Application Support/quicommit/config.toml`
@@ -321,58 +444,28 @@ use_agent = true
[llm]
provider = "ollama"
model = "llama2"
# base_url = "http://localhost:11434"
max_tokens = 500
temperature = 0.7
timeout = 30
[llm.ollama]
url = "http://localhost:11434"
model = "llama2"
[llm.openai]
model = "gpt-4"
base_url = "https://api.openai.com/v1"
[llm.anthropic]
model = "claude-3-sonnet-20240229"
[llm.kimi]
model = "moonshot-v1-8k"
[llm.deepseek]
model = "deepseek-chat"
[llm.openrouter]
model = "openai/gpt-4"
api_key_storage = "keyring"
thinking_enabled = false
[commit]
format = "conventional"
auto_generate = true
allow_empty = false
gpg_sign = false
max_subject_length = 100
require_scope = false
require_body = false
body_required_types = ["feat", "fix"]
[tag]
version_prefix = "v"
auto_generate = true
gpg_sign = false
include_changelog = true
[changelog]
path = "CHANGELOG.md"
auto_generate = true
format = "keep-a-changelog"
include_hashes = false
include_authors = false
group_by_type = true
[theme]
colors = true
icons = true
date_format = "%Y-%m-%d"
[output]
emoji = true
[repo_profiles]
"/path/to/work/project" = "work"
@@ -385,15 +478,20 @@ date_format = "%Y-%m-%d"
|--------|------|
| `QUICOMMIT_CONFIG` | 配置文件路径 |
| `EDITOR` | 默认编辑器 |
| `NO_COLOR` | 禁用彩色输出 |
| `NO_COLOR` | 禁用彩色与 Emoji 装饰输出(任意值即生效) |
| `RUST_LOG` | 覆盖 `-v` 的日志过滤(例如 `debug`) |
## 全局选项
- `-v, --verbose`(可叠加):`-v` 显示 info 日志、`-vv` debug、`-vvv` trace。
- `--no-color`:禁用彩色**与** Emoji 装饰(最高优先级)。
- `--emoji` / `--no-emoji`:强制开启/关闭 Emoji 装饰,覆盖 `output.emoji` 配置。
- Emoji 装饰也可通过 `quicommit config set output.emoji true|false` 配置。
## 故障排除
```bash
# 查看当前配置
quicommit config list
# 显示配置详情
quicommit config show
# 编辑配置文件
@@ -405,7 +503,7 @@ quicommit config set llm.provider ollama
# 获取配置值
quicommit config get llm.provider
# 设置API密钥(存储在系统密钥环中)
# 设置API密钥(存储在系统密钥环中;省略密钥值可通过隐藏提示输入)
quicommit config set-api-key YOUR_API_KEY
# 从密钥环删除API密钥
@@ -423,11 +521,13 @@ quicommit config check-keyring
# 显示配置文件路径
quicommit config path
# 导出配置
# 导出配置(支持加密)
quicommit config export -o config-backup.toml
quicommit config export -o config-backup.enc --password
# 导入配置
quicommit config import -i config-backup.toml
quicommit config import -i config-backup.enc --password
# 重置配置
quicommit config reset --force
@@ -483,6 +583,7 @@ src/
│ ├── changelog.rs
│ ├── profile.rs
│ ├── config.rs
│ ├── ginit.rs
│ └── init.rs
├── config/ # 配置管理
│ ├── manager.rs
@@ -491,14 +592,14 @@ src/
├── git/ # Git操作封装
│ ├── commit.rs
│ ├── tag.rs
│ └── changelog.rs
├── llm/ # LLM提供商实现
│ ├── ollama.rs
│ ├── openai.rs
│ ├── anthropic.rs
│ ├── kimi.rs
│ ├── deepseek.rs
│ └── openrouter.rs
│ ├── changelog.rs
│ └── init.rs
├── llm/ # LLM 集成(基于 rig-core)
│ ├── mod.rs
│ ├── prompts.rs
│ ├── parsing.rs
│ ├── thinking.rs
│ └── rig/ # rig provider 门面
├── i18n/ # 国际化支持
│ ├── messages.rs
│ └── translator.rs

View File

@@ -1,4 +1,4 @@
use anyhow::{bail, Result};
use anyhow::{Result, bail};
use chrono::Utc;
use clap::Parser;
use colored::Colorize;
@@ -7,9 +7,11 @@ use std::path::PathBuf;
use crate::config::{Language, manager::ConfigManager};
use crate::generator::ContentGenerator;
use crate::git::GitRepo;
use crate::git::find_repo;
use crate::git::{changelog::*, CommitInfo};
use crate::git::{CommitInfo, changelog::*};
use crate::i18n::{Messages, translate_changelog_category};
use crate::utils::{print_progress, print_success, print_warning};
/// Generate changelog
#[derive(Parser)]
@@ -55,7 +57,15 @@ pub struct ChangelogCommand {
#[arg(long)]
dry_run: bool,
/// Skip interactive prompts
/// Enable thinking mode for this changelog (override config)
#[arg(long)]
think: bool,
/// Generate deterministically from template (no AI call)
#[arg(long, conflicts_with = "generate")]
no_generate: bool,
/// Skip interactive prompts only (generation behavior unchanged)
#[arg(short = 'y', long)]
yes: bool,
}
@@ -74,18 +84,20 @@ impl ChangelogCommand {
// Initialize changelog if requested
if self.init {
let path = self.output.as_ref()
.map(|p| p.clone())
let path = self
.output
.clone()
.unwrap_or_else(|| PathBuf::from(&config.changelog.path));
init_changelog(&path)?;
println!("{}", messages.initialized_changelog(&format!("{:?}", path)));
print_success(&messages.initialized_changelog(&path.display().to_string()));
return Ok(());
}
// Determine output path
let output_path = self.output.as_ref()
.map(|p| p.clone())
let output_path = self
.output
.clone()
.unwrap_or_else(|| PathBuf::from(&config.changelog.path));
// Determine format
@@ -94,7 +106,7 @@ impl ChangelogCommand {
Some("keep") | Some("keep-a-changelog") => ChangelogFormat::KeepAChangelog,
Some("custom") => ChangelogFormat::Custom,
None => ChangelogFormat::KeepAChangelog,
Some(f) => bail!("Unknown format: {}. Use: keep-a-changelog, github-releases", f),
Some(f) => bail!("{}", messages.unknown_changelog_format(f)),
};
// Get version
@@ -110,17 +122,20 @@ impl ChangelogCommand {
};
// Get commits
println!("{}", messages.fetching_commits());
let commits = generate_from_history(&repo, self.from.as_deref(), Some(&self.to))?;
print_progress(messages.fetching_commits());
// Determine from_tag: use explicit --from, or auto-detect from changelog
let from_tag = self.resolve_from_tag(&repo, &output_path, &messages);
let commits = generate_from_history(&repo, from_tag.as_deref(), Some(&self.to))?;
if commits.is_empty() {
bail!("{}", messages.no_commits_found());
}
println!("{}", messages.found_commits(commits.len()));
print_success(&messages.found_commits(commits.len()));
// Generate changelog
let changelog = if self.generate || (config.changelog.auto_generate && !self.yes) {
let changelog = if self.generate || (config.changelog.auto_generate && !self.no_generate) {
self.generate_with_ai(&version, &commits, &messages).await?
} else {
self.generate_with_template(format, &version, &commits, language)?
@@ -148,43 +163,23 @@ impl ChangelogCommand {
println!("{}", "─".repeat(60));
let confirm = Confirm::new()
.with_prompt(&messages.write_to_file(&format!("{:?}", output_path)))
.with_prompt(messages.write_to_file(&output_path.display().to_string()))
.default(true)
.interact()?;
if !confirm {
println!("{}", messages.cancelled().yellow());
print_warning(messages.cancelled());
return Ok(());
}
}
// Write to file (always prepend to preserve history)
// Write to file (always prepend new entry before existing versions)
if output_path.exists() {
let existing = std::fs::read_to_string(&output_path)?;
let new_content = if existing.is_empty() {
format!("{}{}", CHANGELOG_HEADER, changelog)
} else if existing.starts_with(CHANGELOG_HEADER) {
format!("{}{}", CHANGELOG_HEADER, changelog)
} else if existing.starts_with("# Changelog") {
let lines: Vec<&str> = existing.lines().collect();
let mut header_end = 0;
for (i, line) in lines.iter().enumerate() {
if i == 0 && line.starts_with('#') {
header_end = i + 1;
} else if line.trim().is_empty() {
header_end = i + 1;
} else {
break;
}
}
let header = lines[..header_end].join("\n");
let rest = lines[header_end..].join("\n");
format!("{}\n{}\n{}", header, changelog, rest)
} else {
format!("{}{}", CHANGELOG_HEADER, changelog)
insert_changelog_entry(&existing, &changelog)
};
std::fs::write(&output_path, new_content)?;
} else {
@@ -192,11 +187,37 @@ impl ChangelogCommand {
std::fs::write(&output_path, content)?;
}
println!("{} {:?}", messages.changelog_written(), output_path);
print_success(&format!(
"{} {}",
messages.changelog_written(),
output_path.display()
));
Ok(())
}
fn resolve_from_tag(
&self,
repo: &GitRepo,
output_path: &PathBuf,
messages: &Messages,
) -> Option<String> {
// Explicit --from always wins
if self.from.is_some() {
return self.from.clone();
}
// Auto-detect: find highest version already in changelog
let existing = std::fs::read_to_string(output_path).ok()?;
let versions = parse_changelog_versions(&existing);
let highest = versions.first()?;
// Match highest version to a git tag
let tag = repo.find_tag_by_version(highest)?;
println!(" {}: {}", messages.version(), tag.name);
Some(tag.name)
}
async fn generate_with_ai(
&self,
version: &str,
@@ -206,10 +227,13 @@ impl ChangelogCommand {
let manager = ConfigManager::new()?;
let language = manager.get_language().unwrap_or(Language::English);
println!("{}", messages.ai_generating_changelog());
let generator = ContentGenerator::new(&manager).await?;
generator.generate_changelog_entry(version, commits, language).await
let generator = ContentGenerator::new_with_think(&manager, self.think, None).await?;
let spinner = crate::utils::Spinner::start(messages.ai_generating_changelog());
let result = generator
.generate_changelog_entry(version, commits, language)
.await;
spinner.finish_clear();
result
}
fn generate_with_template(
@@ -220,14 +244,14 @@ impl ChangelogCommand {
language: Language,
) -> Result<String> {
let manager = ConfigManager::new()?;
let generator = ChangelogGenerator::new()
.format(format)
.include_hashes(self.include_hashes)
.include_authors(self.include_authors);
let changelog = generator.generate(version, Utc::now(), commits)?;
// Translate changelog categories if configured
if !manager.keep_changelog_types_english() {
Ok(self.translate_changelog_categories(&changelog, language))
@@ -235,14 +259,15 @@ impl ChangelogCommand {
Ok(changelog)
}
}
fn translate_changelog_categories(&self, changelog: &str, language: Language) -> String {
let translated = changelog
changelog
.lines()
.map(|line| {
if line.starts_with("## ") || line.starts_with("### ") {
let category = line.trim_start_matches("## ").trim_start_matches("### ");
let translated_category = translate_changelog_category(category, language, false);
let translated_category =
translate_changelog_category(category, language, false);
if line.starts_with("## ") {
format!("## {}", translated_category)
} else {
@@ -253,7 +278,6 @@ impl ChangelogCommand {
}
})
.collect::<Vec<_>>()
.join("\n");
translated
.join("\n")
}
}

View File

@@ -1,16 +1,17 @@
use anyhow::{bail, Context, Result};
use anyhow::{Context, Result, bail};
use clap::Parser;
use colored::Colorize;
use dialoguer::{Confirm, Input, Select};
use std::path::PathBuf;
use crate::config::{Language, manager::ConfigManager};
use crate::config::CommitFormat;
use crate::config::{Language, manager::ConfigManager};
use crate::generator::ContentGenerator;
use crate::git::{find_repo, GitRepo};
use crate::git::commit::{CommitBuilder, create_date_commit_message};
use crate::git::{GitRepo, find_repo};
use crate::i18n::Messages;
use crate::utils::validators::get_commit_types;
use crate::utils::{print_success, print_warning};
/// Generate and execute conventional commits
#[derive(Parser)]
@@ -71,7 +72,11 @@ pub struct CommitCommand {
#[arg(long)]
no_verify: bool,
/// Skip interactive prompts
/// Enable thinking mode for this commit (override config)
#[arg(short = 't', long)]
think: bool,
/// Skip interactive prompts only (generation behavior unchanged)
#[arg(short = 'y', long)]
yes: bool,
@@ -82,13 +87,17 @@ pub struct CommitCommand {
/// Remote to push to
#[arg(long, default_value = "origin")]
remote: String,
/// Interactively select push target(s) when the repo has multiple remotes
#[arg(long)]
select_remote: bool,
}
impl CommitCommand {
pub async fn execute(&self, config_path: Option<PathBuf>) -> Result<()> {
// Find git repository
let repo = find_repo(std::env::current_dir()?.as_path())?;
// Load configuration
let manager = if let Some(ref path) = config_path {
ConfigManager::with_path(path)?
@@ -98,7 +107,7 @@ impl CommitCommand {
let config = manager.config();
let language = manager.get_language().unwrap_or(Language::English);
let messages = Messages::new(language);
// Check for changes
let status = repo.status_summary()?;
if status.clean && !self.amend {
@@ -117,20 +126,38 @@ impl CommitCommand {
// Auto-add if no files are staged and there are unstaged/untracked changes
if status.staged == 0 && (status.unstaged > 0 || status.untracked > 0) && !self.all {
println!("{}", messages.auto_stage_changes().yellow());
repo.stage_all()?;
let removed = repo.stage_all(&messages)?;
println!("{}", messages.staged_all().green());
if !removed.is_empty() {
print_warning(&format!(
"Removed {} ignored files from staging:",
removed.len()
));
for file in &removed {
println!(" • {}", file);
}
}
// Re-check status after staging to ensure changes are detected
let new_status = repo.status_summary()?;
if new_status.staged == 0 {
bail!("Failed to stage changes. Please try running 'git add -A' manually.");
bail!("{}", messages.failed_to_stage());
}
}
// Stage all if requested
if self.all {
repo.stage_all()?;
let removed = repo.stage_all(&messages)?;
println!("{}", messages.staged_all().green());
if !removed.is_empty() {
print_warning(&format!(
"Removed {} ignored files from staging:",
removed.len()
));
for file in &removed {
println!(" • {}", file);
}
}
}
// Generate or build commit message
@@ -172,21 +199,21 @@ impl CommitCommand {
.interact()?;
if !confirm {
println!("{}", messages.commit_cancelled().yellow());
print_warning(messages.commit_cancelled());
return Ok(());
}
}
let result = if self.amend {
if self.dry_run {
println!("\n{} {}", messages.dry_run(), "- commit not amended.".yellow());
println!("\n{}", messages.dry_run_commit_not_amended().yellow());
return Ok(());
}
self.amend_commit(&repo, &commit_message)?;
None
} else {
if self.dry_run {
println!("\n{} {}", messages.dry_run(), "- commit not created.".yellow());
println!("\n{}", messages.dry_run_commit_not_created().yellow());
return Ok(());
}
CommitBuilder::new()
@@ -196,30 +223,32 @@ impl CommitCommand {
};
if let Some(commit_oid) = result {
println!("{} {}", messages.commit_created().green().bold(), commit_oid.to_string()[..8].to_string().cyan());
print_success(&format!(
"{} {}",
messages.commit_created().green().bold(),
commit_oid.to_string()[..8].to_string().cyan()
));
} else {
println!("{} {}", messages.commit_amended().green().bold(), "successfully");
print_success(messages.commit_amended_successfully());
}
// Push after commit if requested or ask user
if self.push {
println!("\n{}", messages.pushing_commit(&self.remote));
repo.push(&self.remote, "HEAD")?;
println!("{}", messages.pushed_commit(&self.remote));
} else if !self.yes && !self.dry_run {
let should_push = Confirm::new()
.with_prompt(messages.push_after_commit())
.default(false)
.interact()?;
let branch = repo
.current_branch()
.unwrap_or_else(|_| "HEAD (detached)".to_string());
if should_push {
println!("\n{}", messages.pushing_commit(&self.remote));
repo.push(&self.remote, "HEAD")?;
println!("{}", messages.pushed_commit(&self.remote));
}
}
Ok(())
super::run_push_flow(
&repo,
&self.remote,
self.select_remote,
self.push,
self.yes,
&messages,
&messages.push_after_commit(&branch),
"HEAD",
|r| messages.pushing_commit(r, &branch),
|r| messages.pushed_commit(r, &branch),
)
}
fn create_date_commit(&self) -> String {
@@ -228,8 +257,9 @@ impl CommitCommand {
}
fn create_manual_commit(&self, format: CommitFormat) -> Result<String> {
let description = self.message.clone()
.ok_or_else(|| anyhow::anyhow!("Description required for manual commit. Use -m <message>"))?;
let description = self.message.clone().ok_or_else(|| {
anyhow::anyhow!("Description required for manual commit. Use -m <message>")
})?;
// Try to extract commit type from message if not provided
let commit_type = if let Some(ref ct) = self.commit_type {
@@ -255,26 +285,45 @@ impl CommitCommand {
builder.build_message()
}
async fn generate_commit(&self, repo: &GitRepo, format: CommitFormat, messages: &Messages) -> Result<String> {
async fn generate_commit(
&self,
repo: &GitRepo,
format: CommitFormat,
messages: &Messages,
) -> Result<String> {
let manager = ConfigManager::new()?;
let generator = ContentGenerator::new(&manager).await
let template = manager
.default_profile()
.and_then(|p| p.commit_template().map(|t| t.to_string()));
let generator = ContentGenerator::new_with_think(&manager, self.think, template)
.await
.context("Failed to initialize LLM. Use --manual for manual commit.")?;
println!("{}", messages.ai_analyzing());
let spinner = crate::utils::Spinner::start(messages.ai_analyzing());
let language = manager.get_language().unwrap_or(Language::English);
let generated = if self.yes {
generator.generate_commit_from_repo(repo, format, language).await?
generator
.generate_commit_from_repo(repo, format, language)
.await
} else {
generator.generate_commit_interactive(repo, format, language).await?
generator
.generate_commit_interactive(repo, format, language, messages)
.await
};
spinner.finish_clear();
Ok(generated.to_conventional())
Ok(generated?.to_conventional())
}
async fn create_interactive_commit(&self, format: CommitFormat, messages: &Messages) -> Result<String> {
async fn create_interactive_commit(
&self,
format: CommitFormat,
messages: &Messages,
) -> Result<String> {
let types = get_commit_types(format == CommitFormat::Commitlint);
// Select type
@@ -352,20 +401,21 @@ impl CommitCommand {
if !output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let error_msg = if stderr.is_empty() {
if stdout.is_empty() {
"GPG signing failed. Please check:\n\
1. GPG signing key is configured (git config --get user.signingkey)\n\
2. GPG agent is running\n\
3. You can sign commits manually (try: git commit --amend -S)".to_string()
3. You can sign commits manually (try: git commit --amend -S)"
.to_string()
} else {
stdout.to_string()
}
} else {
stderr.to_string()
};
bail!("Failed to amend commit: {}", error_msg);
}
@@ -373,26 +423,26 @@ impl CommitCommand {
}
}
// Helper trait for optional builder methods
trait CommitBuilderExt {
fn scope_opt(self, scope: Option<String>) -> Self;
fn body_opt(self, body: Option<String>) -> Self;
}
// // Helper trait for optional builder methods
// trait CommitBuilderExt {
// fn scope_opt(self, scope: Option<String>) -> Self;
// fn body_opt(self, body: Option<String>) -> Self;
// }
impl CommitBuilderExt for CommitBuilder {
fn scope_opt(self, scope: Option<String>) -> Self {
if let Some(s) = scope {
self.scope(s)
} else {
self
}
}
// impl CommitBuilderExt for CommitBuilder {
// fn scope_opt(self, scope: Option<String>) -> Self {
// if let Some(s) = scope {
// self.scope(s)
// } else {
// self
// }
// }
fn body_opt(self, body: Option<String>) -> Self {
if let Some(b) = body {
self.body(b)
} else {
self
}
}
}
// fn body_opt(self, body: Option<String>) -> Self {
// if let Some(b) = body {
// self.body(b)
// } else {
// self
// }
// }
// }

File diff suppressed because it is too large Load Diff

457
src/commands/credential.rs Normal file
View File

@@ -0,0 +1,457 @@
use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use std::io::{self, BufRead, Write};
use std::path::PathBuf;
use crate::config::manager::ConfigManager;
use crate::config::{TokenConfig, TokenType};
/// Git credential helper command.
///
/// Implements the git credential helper protocol
/// (https://git-scm.com/docs/gitcredentials). Intended to be invoked by git
/// via `credential.helper` configuration, not by end users. Hidden from the
/// main help output.
#[derive(Parser)]
#[command(hide = true)]
pub struct CredentialCommand {
#[command(subcommand)]
command: CredentialSubcommand,
}
#[derive(Subcommand)]
enum CredentialSubcommand {
/// Read attributes on stdin and output credentials on stdout.
#[command(hide = true)]
Get,
/// Read attributes (including password) on stdin and store them.
#[command(hide = true)]
Store,
/// Read attributes on stdin and erase any matching stored credentials.
#[command(hide = true)]
Erase,
}
impl CredentialCommand {
pub async fn execute(&self, config_path: Option<PathBuf>) -> Result<()> {
match &self.command {
CredentialSubcommand::Get => Self::get(config_path),
CredentialSubcommand::Store => Self::store(config_path),
CredentialSubcommand::Erase => Self::erase(config_path),
}
}
fn get_manager(config_path: &Option<PathBuf>) -> Result<ConfigManager> {
match config_path {
Some(path) => ConfigManager::with_path(path),
None => ConfigManager::new(),
}
}
/// `git credential get`: look up a PAT for the requested host and emit it
/// on stdout following the git credential helper protocol.
fn get(config_path: Option<PathBuf>) -> Result<()> {
let attrs = CredentialAttributes::from_stdin()?;
let host = match attrs.host.as_deref() {
Some(h) if !h.is_empty() => h,
_ => return Ok(()),
};
let service = host_to_service(host);
let manager = match Self::get_manager(&config_path) {
Ok(m) => m,
Err(_) => return Ok(()),
};
let (profile_name, pat) = match find_pat_for_service(&manager, &service) {
Some(tuple) => tuple,
None => return Ok(()),
};
// Prefer a git-supplied username; fall back to the profile's user_name.
let username = attrs.username.clone().or_else(|| {
manager
.get_profile(&profile_name)
.map(|p| p.user_name.clone())
});
let output = CredentialAttributes {
protocol: attrs.protocol.clone(),
host: attrs.host.clone(),
path: attrs.path.clone(),
username,
password: Some(pat),
};
output.to_stdout()?;
Ok(())
}
/// `git credential store`: persist the PAT provided by git using the
/// existing keyring-backed storage, associated with a matching profile.
fn store(config_path: Option<PathBuf>) -> Result<()> {
let attrs = CredentialAttributes::from_stdin()?;
let host = match attrs.host.as_deref() {
Some(h) if !h.is_empty() => h,
_ => return Ok(()),
};
let service = host_to_service(host);
let password = match attrs.password.as_deref() {
Some(p) if !p.is_empty() => p,
_ => return Ok(()),
};
let mut manager = Self::get_manager(&config_path)?;
let profile_name = match find_profile_for_store(&manager, attrs.username.as_deref()) {
Some(name) => name,
None => return Ok(()),
};
// Store PAT in keyring using the existing profile-bound logic.
if let Err(e) = manager.store_pat_for_profile(&profile_name, &service, password) {
eprintln!("[quicommit credential] failed to store PAT: {}", e);
return Ok(());
}
// Register the token in the profile config if not already present.
let already_has = manager
.get_profile(&profile_name)
.map(|p| p.tokens.contains_key(&service))
.unwrap_or(false);
if !already_has {
let _ = manager.add_token_to_profile(
&profile_name,
service.clone(),
TokenConfig::new(TokenType::Personal),
);
}
manager.save()?;
Ok(())
}
/// `git credential erase`: remove any PAT stored for the requested host
/// from the keyring and the associated profile config entries.
fn erase(config_path: Option<PathBuf>) -> Result<()> {
let attrs = CredentialAttributes::from_stdin()?;
let host = match attrs.host.as_deref() {
Some(h) if !h.is_empty() => h,
_ => return Ok(()),
};
let service = host_to_service(host);
let mut manager = Self::get_manager(&config_path)?;
let profile_names: Vec<String> = manager.list_profiles().into_iter().cloned().collect();
for name in &profile_names {
let registered = manager
.get_profile(name)
.map(|p| p.tokens.contains_key(&service))
.unwrap_or(false);
if registered {
if let Err(e) = manager.remove_token_from_profile(name, &service) {
eprintln!(
"[quicommit credential] failed to erase PAT for '{}': {}",
name, e
);
}
} else if let Ok(true) = manager.delete_orphan_pat(name, &service) {
// Keyring PAT without a config token entry (issue 24).
eprintln!(
"[quicommit credential] erased unregistered PAT for '{}' (service {}) from keyring",
name, service
);
}
}
manager.save()?;
Ok(())
}
}
/// Credential attributes exchanged with git via stdin/stdout following the
/// git credential helper protocol (`key=value`, one per line, blank line ends).
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct CredentialAttributes {
pub protocol: Option<String>,
pub host: Option<String>,
pub path: Option<String>,
pub username: Option<String>,
pub password: Option<String>,
}
impl CredentialAttributes {
/// Read attributes from stdin following the git credential helper protocol.
pub fn from_stdin() -> Result<Self> {
let stdin = io::stdin();
let mut attrs = Self::default();
for line in stdin.lock().lines() {
let line = line.context("Failed to read credential attributes from stdin")?;
if line.is_empty() {
break;
}
if let Some((key, value)) = line.split_once('=') {
match key {
"protocol" => attrs.protocol = Some(value.to_string()),
"host" => attrs.host = Some(value.to_string()),
"path" => attrs.path = Some(value.to_string()),
"username" => attrs.username = Some(value.to_string()),
"password" => attrs.password = Some(value.to_string()),
_ => {} // ignore unknown keys
}
}
}
Ok(attrs)
}
/// Write attributes to stdout following the git credential helper protocol.
pub fn to_stdout(&self) -> Result<()> {
let stdout = io::stdout();
let mut handle = stdout.lock();
if let Some(ref v) = self.protocol {
writeln!(handle, "protocol={}", v)?;
}
if let Some(ref v) = self.host {
writeln!(handle, "host={}", v)?;
}
if let Some(ref v) = self.path {
writeln!(handle, "path={}", v)?;
}
if let Some(ref v) = self.username {
writeln!(handle, "username={}", v)?;
}
if let Some(ref v) = self.password {
writeln!(handle, "password={}", v)?;
}
writeln!(handle)?; // blank line terminates the attribute list
handle.flush()?;
Ok(())
}
/// Parse attributes from a text block following the git credential helper
/// protocol. Intended for testing and non-stdin input handling.
pub fn parse_str(input: &str) -> Self {
let mut attrs = Self::default();
for line in input.lines() {
if line.is_empty() {
break;
}
if let Some((key, value)) = line.split_once('=') {
match key {
"protocol" => attrs.protocol = Some(value.to_string()),
"host" => attrs.host = Some(value.to_string()),
"path" => attrs.path = Some(value.to_string()),
"username" => attrs.username = Some(value.to_string()),
"password" => attrs.password = Some(value.to_string()),
_ => {}
}
}
}
attrs
}
/// Serialize attributes to a string following the git credential helper
/// protocol. Intended for testing and non-stdout output handling.
pub fn serialize(&self) -> String {
let mut out = String::new();
if let Some(ref v) = self.protocol {
out.push_str(&format!("protocol={}\n", v));
}
if let Some(ref v) = self.host {
out.push_str(&format!("host={}\n", v));
}
if let Some(ref v) = self.path {
out.push_str(&format!("path={}\n", v));
}
if let Some(ref v) = self.username {
out.push_str(&format!("username={}\n", v));
}
if let Some(ref v) = self.password {
out.push_str(&format!("password={}\n", v));
}
out.push('\n');
out
}
}
/// Map a git host to a service name used by the keyring-backed PAT storage.
///
/// Common git hosting services are mapped to short canonical names. Unknown
/// hosts are used as-is (lowercased, trailing slash trimmed).
pub fn host_to_service(host: &str) -> String {
let host = host.to_lowercase();
let host = host.trim_end_matches('/');
match host {
"github.com" | "www.github.com" => "github".to_string(),
"gitlab.com" | "www.gitlab.com" => "gitlab".to_string(),
"bitbucket.org" | "www.bitbucket.org" => "bitbucket".to_string(),
"codeberg.org" | "www.codeberg.org" => "codeberg".to_string(),
"gitea.com" | "www.gitea.com" => "gitea".to_string(),
"gitee.com" | "www.gitee.com" => "gitee".to_string(),
other => other.to_string(),
}
}
/// Search all profiles for one that has a PAT stored for the given service.
/// Returns the profile name and the PAT value.
fn find_pat_for_service(manager: &ConfigManager, service: &str) -> Option<(String, String)> {
for profile_name in manager.list_profiles() {
if manager.has_pat_for_profile(profile_name, service) {
if let Ok(Some(pat)) = manager.get_pat_for_profile(profile_name, service) {
return Some((profile_name.clone(), pat));
}
}
}
None
}
/// Determine which profile to use when storing a credential.
///
/// Prefers a profile whose `user_name` or `user_email` matches the
/// git-supplied username; otherwise falls back to the default profile.
fn find_profile_for_store(manager: &ConfigManager, username: Option<&str>) -> Option<String> {
if let Some(username) = username {
for name in manager.list_profiles() {
if let Some(profile) = manager.get_profile(name) {
if profile.user_name == username || profile.user_email == username {
return Some(name.clone());
}
}
}
}
manager.default_profile_name().cloned()
}
/// Extract a PAT for the given host from saved credentials across all profiles.
///
/// This is intended for use by other parts of the application (e.g. when
/// verifying access to a git hosting service) and searches every configured
/// profile for a stored PAT matching the host.
pub fn get_pat_for_host(host: &str) -> Result<Option<String>> {
let manager = ConfigManager::new()?;
let service = host_to_service(host);
Ok(find_pat_for_service(&manager, &service).map(|(_, pat)| pat))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_host_to_service_known_hosts() {
assert_eq!(host_to_service("github.com"), "github");
assert_eq!(host_to_service("www.github.com"), "github");
assert_eq!(host_to_service("gitlab.com"), "gitlab");
assert_eq!(host_to_service("bitbucket.org"), "bitbucket");
assert_eq!(host_to_service("codeberg.org"), "codeberg");
assert_eq!(host_to_service("gitea.com"), "gitea");
assert_eq!(host_to_service("gitee.com"), "gitee");
}
#[test]
fn test_host_to_service_case_insensitive() {
assert_eq!(host_to_service("GitHub.Com"), "github");
assert_eq!(host_to_service("GITLAB.COM"), "gitlab");
}
#[test]
fn test_host_to_service_trailing_slash() {
assert_eq!(host_to_service("github.com/"), "github");
assert_eq!(host_to_service("gitlab.com//"), "gitlab");
}
#[test]
fn test_host_to_service_unknown_host() {
assert_eq!(host_to_service("example.com"), "example.com");
assert_eq!(host_to_service("git.internal.corp"), "git.internal.corp");
}
#[test]
fn test_credential_attributes_parse_basic() {
let input = "protocol=https\nhost=github.com\nusername=octocat\npassword=ghp_token123\n\n";
let attrs = CredentialAttributes::parse_str(input);
assert_eq!(attrs.protocol.as_deref(), Some("https"));
assert_eq!(attrs.host.as_deref(), Some("github.com"));
assert_eq!(attrs.username.as_deref(), Some("octocat"));
assert_eq!(attrs.password.as_deref(), Some("ghp_token123"));
assert!(attrs.path.is_none());
}
#[test]
fn test_credential_attributes_parse_with_path() {
let input = "protocol=https\nhost=github.com\npath=owner/repo.git\n\n";
let attrs = CredentialAttributes::parse_str(input);
assert_eq!(attrs.path.as_deref(), Some("owner/repo.git"));
}
#[test]
fn test_credential_attributes_parse_empty() {
let attrs = CredentialAttributes::parse_str("");
assert!(attrs.protocol.is_none());
assert!(attrs.host.is_none());
assert!(attrs.username.is_none());
assert!(attrs.password.is_none());
}
#[test]
fn test_credential_attributes_parse_ignores_unknown_keys() {
let input = "protocol=https\nhost=github.com\nunknown=value\nfoo=bar\n\n";
let attrs = CredentialAttributes::parse_str(input);
assert_eq!(attrs.protocol.as_deref(), Some("https"));
assert_eq!(attrs.host.as_deref(), Some("github.com"));
}
#[test]
fn test_credential_attributes_parse_stops_at_blank_line() {
let input = "protocol=https\nhost=github.com\n\npassword=should_be_ignored\n";
let attrs = CredentialAttributes::parse_str(input);
assert_eq!(attrs.protocol.as_deref(), Some("https"));
assert_eq!(attrs.host.as_deref(), Some("github.com"));
assert!(attrs.password.is_none());
}
#[test]
fn test_credential_attributes_serialize_roundtrip() {
let attrs = CredentialAttributes {
protocol: Some("https".to_string()),
host: Some("github.com".to_string()),
path: None,
username: Some("octocat".to_string()),
password: Some("ghp_token".to_string()),
};
let serialized = attrs.serialize();
let reparsed = CredentialAttributes::parse_str(&serialized);
assert_eq!(attrs, reparsed);
}
#[test]
fn test_credential_attributes_serialize_includes_blank_line() {
let attrs = CredentialAttributes {
protocol: Some("https".to_string()),
host: Some("github.com".to_string()),
path: None,
username: None,
password: None,
};
let serialized = attrs.serialize();
assert!(serialized.ends_with("\n\n"));
assert!(serialized.contains("protocol=https\n"));
assert!(serialized.contains("host=github.com\n"));
}
#[test]
fn test_credential_attributes_default() {
let attrs = CredentialAttributes::default();
assert!(attrs.protocol.is_none());
assert!(attrs.host.is_none());
assert!(attrs.path.is_none());
assert!(attrs.username.is_none());
assert!(attrs.password.is_none());
}
}

756
src/commands/ginit.rs Normal file
View File

@@ -0,0 +1,756 @@
use anyhow::{Result, anyhow, bail};
use clap::Parser;
use colored::Colorize;
use dialoguer::Confirm;
use std::fs;
use std::path::{Path, PathBuf};
use crate::config::Language;
use crate::config::manager::ConfigManager;
use crate::git::GitRepo;
use crate::git::init::{
add_remote, apply_initial_branch, configured_initial_branch, has_commits, head_branch,
init_repo, is_bare, is_valid_remote_name, open_repo_at,
};
use crate::i18n::Messages;
use crate::utils::{print_error, print_success, print_warning};
/// Initialize a local Git repository
#[derive(Parser)]
pub struct GinitCommand {
/// Name of the initial branch
#[arg(short = 'b', long, value_name = "NAME")]
branch: Option<String>,
/// Add a remote, repeatable: --add <URL> or --add <NAME>=<URL>
#[arg(long = "add", value_name = "NAME=URL")]
add: Vec<String>,
/// Apply a configured profile to the repository
#[arg(long, value_name = "NAME")]
profile: Option<String>,
/// Preview only, write nothing
#[arg(long)]
dry_run: bool,
/// Skip interactive prompts only (output unchanged)
#[arg(short = 'y', long)]
yes: bool,
}
#[derive(Clone, Copy)]
enum BranchSource {
Flag,
GitConfig,
Default,
Current,
KeptCommits,
}
impl BranchSource {
/// Why the preview shows the branch it shows.
fn reason(self, messages: &Messages) -> &str {
match self {
BranchSource::Flag => messages.ginit_branch_from_flag(),
BranchSource::GitConfig => messages.ginit_branch_from_git_config(),
BranchSource::Default => messages.ginit_branch_from_default(),
BranchSource::Current => messages.ginit_branch_unchanged(),
BranchSource::KeptCommits => messages.ginit_branch_kept_commits(),
}
}
}
struct Plan {
cwd: PathBuf,
dir_name: Option<String>,
create_repo: bool,
bare: bool,
branch: String,
branch_source: BranchSource,
apply_branch: bool,
readme_exists: bool,
gitignore_exists: bool,
ecosystems: Vec<&'static str>,
ignore_sections: Vec<&'static str>,
remotes: Vec<(String, String)>,
profile_name: Option<String>,
entries: usize,
}
impl GinitCommand {
pub async fn execute(&self, config_path: Option<PathBuf>) -> Result<()> {
let mut manager = match config_path.as_deref() {
Some(path) => ConfigManager::with_path(path)?,
None => ConfigManager::new()?,
};
let messages = Messages::new(manager.get_language().unwrap_or(Language::English));
let cwd = std::env::current_dir()?;
let existing = detect_repository(&cwd, &messages)?;
let plan = self.build_plan(&cwd, existing.as_ref(), &manager, &messages)?;
print_preview(&plan, &messages);
if self.dry_run {
println!("\n{}", messages.ginit_dry_run_note().yellow());
return Ok(());
}
if !self.yes {
let confirmed = Confirm::new()
.with_prompt(messages.ginit_confirm_prompt())
.default(true)
.interact()?;
if !confirmed {
print_warning(messages.cancelled());
return Ok(());
}
}
self.apply(&mut manager, existing, &plan, &messages)
}
fn build_plan(
&self,
cwd: &Path,
existing: Option<&GitRepo>,
manager: &ConfigManager,
messages: &Messages,
) -> Result<Plan> {
let create_repo = existing.is_none();
let bare = existing.is_some_and(is_bare);
let committed = existing.is_some_and(has_commits);
let computed = match self.branch.as_deref() {
Some(name) => (name.to_string(), BranchSource::Flag),
None => match configured_initial_branch() {
Some(name) => (name, BranchSource::GitConfig),
None => ("main".to_string(), BranchSource::Default),
},
};
// On an existing repository ginit only retargets an unborn HEAD: a
// branch that already carries commits is renamed by the user with
// `git branch -m`, and without `-b` the current branch is left alone.
let (branch, branch_source, apply_branch) = match existing {
None => (computed.0, computed.1, false),
Some(repo) if self.branch.is_none() => (
head_branch(repo).unwrap_or(computed.0),
BranchSource::Current,
false,
),
Some(repo) if committed => (
head_branch(repo).unwrap_or(computed.0),
BranchSource::KeptCommits,
false,
),
Some(_) => (computed.0, BranchSource::Flag, true),
};
let gitignore_exists = cwd.join(".gitignore").exists();
let sections = if bare || gitignore_exists {
Vec::new()
} else {
assemble_sections(cwd)
};
let profile_name = match self.profile.as_deref() {
Some(name) => {
if !manager.has_profile(name) {
bail!(
"{}\n{}",
messages.profile_not_found(name),
messages.ginit_profile_hint()
);
}
Some(name.to_string())
}
None => None,
};
Ok(Plan {
cwd: cwd.to_path_buf(),
dir_name: directory_name(cwd),
create_repo,
bare,
branch,
branch_source,
apply_branch,
readme_exists: cwd.join("README.md").exists(),
gitignore_exists,
ecosystems: detected_ecosystems(cwd),
ignore_sections: sections.iter().map(|(title, _)| *title).collect(),
remotes: parse_remotes(&self.add, messages)?,
profile_name,
entries: fs::read_dir(cwd)
.map(|read| read.filter_map(Result::ok).count())
.unwrap_or(0),
})
}
fn apply(
&self,
manager: &mut ConfigManager,
existing: Option<GitRepo>,
plan: &Plan,
messages: &Messages,
) -> Result<()> {
let mut results: Vec<(String, String)> = Vec::new();
let mut failures: Vec<String> = Vec::new();
// A repository is the foundation of every later step, so its failure
// ends the run instead of joining the summary of soft failures.
let repo = match existing {
Some(repo) => {
results.push((
messages.ginit_repository_label().to_string(),
messages.ginit_status_exists().to_string(),
));
repo
}
None => {
results.push((
messages.ginit_repository_label().to_string(),
messages.ginit_status_created().to_string(),
));
init_repo(&plan.cwd, &plan.branch)?
}
};
if plan.apply_branch {
if let Err(e) = apply_initial_branch(&repo, &plan.branch) {
failures.push(e.to_string());
} else {
results.push((
messages.ginit_branch_label().to_string(),
plan.branch.clone(),
));
}
}
for (file, exists, content) in [
(
"README.md",
plan.readme_exists,
plan.dir_name.as_ref().map(|name| format!("# {name}\n")),
),
(
".gitignore",
plan.gitignore_exists,
Some(gitignore_contents(&plan.cwd)),
),
] {
let status = write_start_file(&plan.cwd, plan.bare, file, exists, content, messages);
match status {
Ok(status) => results.push((file.to_string(), status)),
Err(e) => failures.push(format!("{file}: {e}")),
}
}
for (name, url) in &plan.remotes {
if repo.get_remote_url(name).is_ok() {
failures.push(messages.ginit_remote_exists(name));
} else if let Err(e) = add_remote(&repo, name, url) {
failures.push(e.to_string());
} else {
results.push((name.clone(), messages.ginit_status_added().to_string()));
}
}
if let Some(name) = &plan.profile_name {
match apply_profile(manager, &repo, name, messages) {
Ok(()) => results.push((
messages.profile_label().to_string(),
messages.ginit_status_applied().to_string(),
)),
Err(e) => failures.push(e.to_string()),
}
}
println!();
for (label, value) in &results {
println!(" {}: {}", label, value);
}
for failure in &failures {
print_error(failure);
}
// Completed steps are kept: ginit cannot tell its own output apart
// from content the user already had, so rolling back would delete work.
if !failures.is_empty() {
bail!("{}", messages.ginit_failed(failures.len()));
}
print_success(messages.ginit_success());
print_next_steps(&repo, messages);
Ok(())
}
}
/// The repository rooted exactly at `cwd`, or a refusal when `cwd` is merely
/// inside one: a nested run would add remotes to the outer repository.
fn detect_repository(cwd: &Path, messages: &Messages) -> Result<Option<GitRepo>> {
match open_repo_at(cwd) {
Ok(repo) => Ok(Some(repo)),
Err(_) => match enclosing_repo_path(cwd) {
Some(outer) => bail!(
"{}",
messages.ginit_nested_repo(&outer.display().to_string())
),
None => Ok(None),
},
}
}
fn enclosing_repo_path(cwd: &Path) -> Option<PathBuf> {
let mut dir = cwd.parent()?;
loop {
if dir.join(".git").exists() {
return Some(dir.to_path_buf());
}
dir = dir.parent()?;
}
}
fn directory_name(cwd: &Path) -> Option<String> {
let name = cwd.file_name()?.to_string_lossy().trim().to_string();
(!name.is_empty() && name != "." && name != "..").then_some(name)
}
/// Write one starter file, returning the status to report. Skipping a bare
/// repository, an existing file and an unusable directory name are decisions,
/// not failures, so they come back as `Ok`.
fn write_start_file(
cwd: &Path,
bare: bool,
file: &str,
exists: bool,
content: Option<String>,
messages: &Messages,
) -> Result<String> {
if bare {
return Ok(messages.ginit_status_skipped_bare().to_string());
}
if exists {
return Ok(messages.ginit_status_exists().to_string());
}
match content {
Some(content) => {
fs::write(cwd.join(file), content)?;
Ok(messages.ginit_status_created().to_string())
}
// Only README.md can lack a usable directory name.
None => Ok(messages.ginit_readme_no_name().to_string()),
}
}
fn parse_remotes(specs: &[String], messages: &Messages) -> Result<Vec<(String, String)>> {
let mut remotes: Vec<(String, String)> = Vec::new();
for spec in specs {
// A leading `NAME=` only counts as a name when git accepts it; that
// keeps URLs containing `=` on the URL side of the split.
let (name, url) = match spec.split_once('=') {
Some((name, url)) if is_valid_remote_name(name.trim()) => (name.trim(), url.trim()),
_ => ("origin", spec.trim()),
};
// A value with neither a path separator nor a scheme separator is a
// bare word, and a bare word means the user forgot the URL —
// `--add origin` would otherwise register a remote named origin
// pointing at a path called "origin".
let looks_like_address = url.contains('/') || url.contains(':');
let has_whitespace = url.chars().any(char::is_whitespace);
if name.is_empty() || !looks_like_address || has_whitespace {
bail!("{}", messages.ginit_remote_missing_url(spec));
}
if !is_valid_remote_name(name) {
bail!("{}", messages.ginit_remote_invalid_name(name));
}
if remotes.iter().any(|(given, _)| given == name) {
bail!("{}", messages.ginit_remote_duplicate(name));
}
remotes.push((name.to_string(), url.to_string()));
}
Ok(remotes)
}
fn apply_profile(
manager: &mut ConfigManager,
repo: &GitRepo,
name: &str,
messages: &Messages,
) -> Result<()> {
let profile = manager
.get_profile(name)
.ok_or_else(|| anyhow!("{}", messages.profile_not_found(name)))?;
profile.apply_to_repo(repo.inner())?;
manager.set_repo_profile(repo.path().to_string_lossy().to_string(), name.to_string())?;
manager.save()?;
Ok(())
}
fn print_preview(plan: &Plan, messages: &Messages) {
let rule = "─".repeat(60);
println!("\n{rule}");
println!("{}", messages.ginit_title().bold());
println!("{rule}");
println!(
" {}: {}",
messages.ginit_directory_label(),
plan.cwd.display()
);
if plan.entries > 0 {
println!(" {}", messages.ginit_files_present(plan.entries));
}
let repository = if plan.create_repo {
messages.ginit_repository_action_create()
} else {
messages.ginit_repository_action_keep()
};
println!(" {}: {}", messages.ginit_repository_label(), repository);
println!(
" {}: {} ({})",
messages.ginit_branch_label(),
plan.branch,
plan.branch_source.reason(messages)
);
let readme = if plan.bare {
messages.ginit_status_skipped_bare()
} else if plan.readme_exists {
messages.ginit_status_exists()
} else if plan.dir_name.is_none() {
messages.ginit_readme_no_name()
} else {
messages.ginit_status_will_create()
};
println!(" README.md: {readme}");
let gitignore = if plan.bare {
messages.ginit_status_skipped_bare()
} else if plan.gitignore_exists {
messages.ginit_status_exists()
} else {
messages.ginit_status_will_create()
};
println!(" .gitignore: {gitignore}");
if !plan.bare {
let ecosystems = if plan.ecosystems.is_empty() {
messages.none_value().to_string()
} else {
plan.ecosystems.join(", ")
};
println!(" {}: {}", messages.ginit_ecosystems_label(), ecosystems);
}
if !plan.ignore_sections.is_empty() {
println!(
" {}: {}",
messages.ginit_sections_label(),
plan.ignore_sections.join(", ")
);
}
if !plan.remotes.is_empty() {
println!(" {}:", messages.ginit_remotes_label());
for (name, url) in &plan.remotes {
println!(" {} {}", name.cyan(), url);
}
}
if let Some(name) = &plan.profile_name {
println!(" {}: {}", messages.profile_label(), name.cyan());
}
println!("{rule}");
}
fn print_next_steps(repo: &GitRepo, messages: &Messages) {
let mut steps: Vec<&str> = Vec::new();
if repo.get_user_name().is_err() || repo.get_user_email().is_err() {
steps.push(messages.ginit_next_identity());
}
if !has_commits(repo) {
steps.push(messages.ginit_next_commit());
if repo.list_remotes().is_ok_and(|remotes| !remotes.is_empty()) {
steps.push(messages.ginit_next_push());
}
}
if steps.is_empty() {
return;
}
println!("\n{}", messages.next_steps());
for (index, step) in steps.iter().enumerate() {
println!(" {}. {step}", index + 1);
}
}
/// Written whatever the directory contains. `.env` is here on purpose: an
/// unignored secrets file is the costliest mistake a starter template makes.
const BASELINE_SECTIONS: &[(&str, &[&str])] = &[
("# Build output", &["build/", "dist/"]),
("# Logs", &["*.log"]),
("# Temporary files", &["tmp/"]),
("# Environment files", &[".env", ".env.*"]),
];
/// Ecosystem markers → section title → entries. Table order is output order,
/// and only root-level exact file names count as markers.
const ECOSYSTEM_MARKERS: &[(&[&str], &str, &[&str])] = &[
(
&["Cargo.toml"],
"# Rust",
&["target/", "**/*.rs.bk", "*.pdb"],
),
(
&["package.json"],
"# Node",
&[
"node_modules/",
"npm-debug.log*",
"yarn-error.log",
"pnpm-debug.log*",
],
),
(
&["pyproject.toml", "requirements.txt", "setup.py"],
"# Python",
&["__pycache__/", "*.pyc", ".venv/", "venv/", ".pytest_cache/"],
),
(&["go.mod"], "# Go", &["vendor/", "*.out"]),
(&["pom.xml"], "# Maven", &["target/"]),
(
&["build.gradle", "build.gradle.kts"],
"# Gradle",
&[".gradle/"],
),
(&["Gemfile"], "# Ruby", &[".bundle/", "*.gem"]),
(&["composer.json"], "# PHP", &["vendor/"]),
(&["pubspec.yaml"], "# Flutter", &[".dart_tool/"]),
(
&["CMakeLists.txt"],
"# C/C++",
&["CMakeCache.txt", "CMakeFiles/"],
),
];
/// Markers present in the root of `dir`, in table order.
fn detected_ecosystems(dir: &Path) -> Vec<&'static str> {
ECOSYSTEM_MARKERS
.iter()
.flat_map(|(markers, ..)| markers.iter().copied())
.filter(|marker| dir.join(marker).exists())
.collect()
}
/// The sections a generated `.gitignore` carries, in output order: the baseline
/// plus every section whose markers are present, each entry kept only at its
/// first occurrence, and any section left with no entries dropped.
fn assemble_sections(dir: &Path) -> Vec<(&'static str, Vec<&'static str>)> {
let mut sections: Vec<(&'static str, Vec<&'static str>)> = Vec::new();
let mut seen: Vec<&'static str> = Vec::new();
for &(title, entries) in BASELINE_SECTIONS {
push_section(&mut sections, &mut seen, title, entries);
}
for &(markers, title, entries) in ECOSYSTEM_MARKERS {
if markers.iter().any(|marker| dir.join(marker).exists()) {
push_section(&mut sections, &mut seen, title, entries);
}
}
sections
}
/// Contents of a generated `.gitignore`.
fn gitignore_contents(dir: &Path) -> String {
assemble_sections(dir)
.into_iter()
.map(|(title, entries)| format!("{title}\n{}", entries.join("\n")))
.collect::<Vec<_>>()
.join("\n\n")
+ "\n"
}
fn push_section(
sections: &mut Vec<(&'static str, Vec<&'static str>)>,
seen: &mut Vec<&'static str>,
title: &'static str,
entries: &'static [&'static str],
) {
let kept: Vec<&'static str> = entries
.iter()
.copied()
.filter(|entry| !seen.contains(entry))
.collect();
if kept.is_empty() {
return;
}
seen.extend(kept.iter().copied());
sections.push((title, kept));
}
#[cfg(test)]
mod tests {
use crate::commands::ginit::{detected_ecosystems, gitignore_contents};
use std::fs;
use tempfile::TempDir;
const BASELINE_IGNORE: &str = r#"# Build output
build/
dist/
# Logs
*.log
# Temporary files
tmp/
# Environment files
.env
.env.*
"#;
fn dir_with(markers: &[&str]) -> TempDir {
let dir = TempDir::new().unwrap();
for marker in markers {
fs::write(dir.path().join(marker), "").unwrap();
}
dir
}
#[test]
fn test_gitignore_without_any_marker_is_the_baseline_only() {
let dir = dir_with(&[]);
let content = gitignore_contents(dir.path());
assert_eq!(content, BASELINE_IGNORE);
assert!(
content.contains(".env\n"),
"the baseline must ignore .env so secrets never reach the first commit"
);
}
#[test]
fn test_gitignore_adds_the_rust_section_after_the_baseline() {
let dir = dir_with(&["Cargo.toml"]);
let content = gitignore_contents(dir.path());
assert_eq!(
content,
format!("{BASELINE_IGNORE}\n# Rust\ntarget/\n**/*.rs.bk\n*.pdb\n")
);
assert!(
!content.contains("Cargo.lock"),
"Cargo.lock stays under version control so builds stay reproducible"
);
}
#[test]
fn test_gitignore_unions_two_ecosystems_and_keeps_duplicates_once() {
let dir = dir_with(&["Cargo.toml", "package.json"]);
let content = gitignore_contents(dir.path());
assert!(content.contains("# Rust\n"));
assert!(content.contains("# Node\nnode_modules/\n"));
assert_eq!(
content.matches("dist/").count(),
1,
"an entry owned by the baseline must not reappear in an ecosystem section"
);
assert_eq!(content.matches("target/").count(), 1);
}
#[test]
fn test_gitignore_omits_a_section_whose_entries_are_all_duplicates() {
let alone = dir_with(&["pom.xml"]);
assert_eq!(
gitignore_contents(alone.path()),
format!("{BASELINE_IGNORE}\n# Maven\ntarget/\n")
);
let with_rust = dir_with(&["Cargo.toml", "pom.xml"]);
let content = gitignore_contents(with_rust.path());
assert!(
!content.contains("# Maven"),
"a section left with no entries should disappear entirely, got: {content}"
);
}
#[test]
fn test_gitignore_hands_vendor_to_the_first_matching_ecosystem_only() {
let dir = dir_with(&["go.mod", "composer.json"]);
let content = gitignore_contents(dir.path());
assert!(content.contains("# Go\nvendor/\n*.out\n"));
assert!(
!content.contains("# PHP"),
"PHP has nothing left to contribute once Go owns vendor/, got: {content}"
);
}
#[test]
fn test_gitignore_content_is_lf_only_with_a_single_trailing_newline() {
let dir = dir_with(&["pyproject.toml"]);
let content = gitignore_contents(dir.path());
assert!(
!content.contains('\r'),
"generated files should use LF endings"
);
assert!(
!content.starts_with('\u{feff}'),
"generated files must not carry a BOM"
);
assert!(content.ends_with(".pytest_cache/\n"));
assert!(!content.ends_with("\n\n"));
}
#[test]
fn test_detected_ecosystems_lists_markers_in_the_directory_root() {
let dir = dir_with(&["package.json"]);
fs::create_dir(dir.path().join("sub")).unwrap();
fs::write(dir.path().join("sub").join("Cargo.toml"), "").unwrap();
let detected = detected_ecosystems(dir.path());
assert_eq!(detected, vec!["package.json"]);
}
}

View File

@@ -4,12 +4,13 @@ use colored::Colorize;
use dialoguer::{Confirm, Input, Select};
use std::path::PathBuf;
use crate::config::{GitProfile, Language};
use crate::config::manager::ConfigManager;
use crate::config::profile::{GpgConfig, SshConfig};
use crate::config::{GitProfile, Language};
use crate::i18n::Messages;
use crate::utils::keyring::{get_supported_providers, get_default_model, provider_needs_api_key};
use crate::utils::keyring::{get_default_model, get_supported_providers, provider_needs_api_key};
use crate::utils::validators::validate_email;
use crate::utils::{print_success, print_warning};
/// Initialize quicommit configuration
#[derive(Parser)]
@@ -28,23 +29,22 @@ impl InitCommand {
let messages = Messages::new(Language::English);
println!("{}", messages.initializing().bold().cyan());
let config_path = config_path.unwrap_or_else(|| {
crate::config::AppConfig::default_path().unwrap()
});
let config_path =
config_path.unwrap_or_else(|| crate::config::AppConfig::default_path().unwrap());
if config_path.exists() && !self.reset {
if !self.yes {
let overwrite = Confirm::new()
.with_prompt("Configuration already exists. Overwrite?")
.with_prompt(messages.config_exists_overwrite())
.default(false)
.interact()?;
if !overwrite {
println!("{}", "Initialization cancelled.".yellow());
print_warning(messages.init_cancelled());
return Ok(());
}
} else {
println!("{}", "Configuration already exists. Use --reset to overwrite.".yellow());
print_warning(messages.config_exists_use_reset());
return Ok(());
}
}
@@ -57,37 +57,62 @@ impl InitCommand {
let mut manager = ConfigManager::with_path_fresh(&config_path)?;
if self.yes {
self.quick_setup(&mut manager).await?;
self.quick_setup(&mut manager, &messages).await?;
} else {
self.interactive_setup(&mut manager).await?;
}
manager.save()?;
let language = manager.get_language().unwrap_or(Language::English);
let messages = Messages::new(language);
println!("{}", messages.init_success().bold().green());
print_success(messages.init_success());
println!("\n{}: {}", messages.config_file(), config_path.display());
println!("\n{}:", messages.next_steps());
println!(" 1. Create a profile: {}", "quicommit profile add".cyan());
println!(" 2. Configure LLM: {}", "quicommit config set-llm".cyan());
println!(" 3. Start committing: {}", "quicommit commit".cyan());
println!(
" 1. {}: {}",
messages.next_steps_create_profile(),
"quicommit profile add".cyan()
);
println!(
" 2. {}: {}",
messages.next_steps_configure_llm(),
"quicommit config set-llm".cyan()
);
println!(
" 3. {}: {}",
messages.next_steps_start_committing(),
"quicommit commit".cyan()
);
Ok(())
}
async fn quick_setup(&self, manager: &mut ConfigManager) -> Result<()> {
async fn quick_setup(&self, manager: &mut ConfigManager, messages: &Messages) -> Result<()> {
let git_config = git2::Config::open_default()?;
let user_name = git_config.get_string("user.name").unwrap_or_else(|_| "User".to_string());
let user_email = git_config.get_string("user.email").unwrap_or_else(|_| "user@example.com".to_string());
let profile = GitProfile::new(
"default".to_string(),
let name_result = git_config.get_string("user.name");
let email_result = git_config.get_string("user.email");
let name_missing = name_result.is_err();
let email_missing = email_result.is_err();
let user_name = name_result.unwrap_or_else(|_| "User".to_string());
let user_email = email_result.unwrap_or_else(|_| "user@example.com".to_string());
let profile = GitProfile::new("default".to_string(), user_name.clone(), user_email.clone());
println!("\n{}", messages.quick_setup_summary().bold());
println!(" {}: {}", messages.profile_label(), "default".cyan());
println!(
" {}: {} <{}>",
messages.identity_label(),
user_name,
user_email,
user_email
);
if name_missing || email_missing {
print_warning(messages.identity_placeholder_warning());
}
println!(" {}: {}", messages.llm_provider_label(), "ollama".cyan());
manager.add_profile("default".to_string(), profile)?;
manager.set_default_profile(Some("default".to_string()))?;
@@ -102,7 +127,7 @@ impl InitCommand {
println!("\n{}", messages.setup_profile().bold());
println!("\n{}", messages.select_output_language().bold());
let languages = vec![
let languages = [
Language::English,
Language::Chinese,
Language::Japanese,
@@ -111,11 +136,11 @@ impl InitCommand {
Language::French,
Language::German,
];
let language_names: Vec<String> = languages.iter().map(|l| l.display_name().to_string()).collect();
let language_idx = Select::new()
.items(&language_names)
.default(0)
.interact()?;
let language_names: Vec<String> = languages
.iter()
.map(|l| l.display_name().to_string())
.collect();
let language_idx = Select::new().items(&language_names).default(0).interact()?;
let selected_language = languages[language_idx];
manager.set_output_language(selected_language.to_code().to_string());
@@ -128,12 +153,14 @@ impl InitCommand {
.interact_text()?;
let git_config = git2::Config::open_default().ok();
let default_name = git_config.as_ref()
let default_name = git_config
.as_ref()
.and_then(|c| c.get_string("user.name").ok())
.unwrap_or_default();
let default_email = git_config.as_ref()
let default_email = git_config
.as_ref()
.and_then(|c| c.get_string("user.email").ok())
.unwrap_or_default();
@@ -145,9 +172,7 @@ impl InitCommand {
let user_email: String = Input::new()
.with_prompt(messages.git_user_email())
.default(default_email)
.validate_with(|input: &String| {
validate_email(input).map_err(|e| e.to_string())
})
.validate_with(|input: &String| validate_email(input).map_err(|e| e.to_string()))
.interact_text()?;
let description: String = Input::new()
@@ -161,9 +186,11 @@ impl InitCommand {
.interact()?;
let organization = if is_work {
Some(Input::new()
.with_prompt(messages.organization_name())
.interact_text()?)
Some(
Input::new()
.with_prompt(messages.organization_name())
.interact_text()?,
)
} else {
None
};
@@ -190,11 +217,7 @@ impl InitCommand {
None
};
let mut profile = GitProfile::new(
profile_name.clone(),
user_name,
user_email,
);
let mut profile = GitProfile::new(profile_name.clone(), user_name, user_email);
if !description.is_empty() {
profile.description = Some(description);
@@ -209,16 +232,16 @@ impl InitCommand {
manager.set_default_profile(Some(profile_name))?;
println!("\n{}", messages.select_llm_provider().bold());
let provider_display_names = vec![
"Ollama (local)",
"OpenAI",
"Anthropic Claude",
"Kimi (Moonshot AI)",
"DeepSeek",
"OpenRouter"
"OpenRouter",
];
let provider_idx = Select::new()
.items(&provider_display_names)
.default(0)
@@ -229,19 +252,21 @@ impl InitCommand {
let keyring = manager.keyring();
let keyring_available = keyring.is_available();
if !keyring_available {
println!("\n{}", "⚠ Keyring is not available on this system.".yellow());
println!("{}", keyring.get_status_message().yellow());
print_warning(messages.keyring_unavailable());
print_warning(&keyring.get_status_message());
}
let api_key = if provider_needs_api_key(&provider) {
let env_key = std::env::var("QUICOMMIT_API_KEY")
.or_else(|_| std::env::var(format!("QUICOMMIT_{}_API_KEY", provider.to_uppercase())))
.or_else(|_| {
std::env::var(format!("QUICOMMIT_{}_API_KEY", provider.to_uppercase()))
})
.ok();
if let Some(key) = env_key {
println!("\n{} {}", "✓".green(), "Found API key in environment variable.".green());
if let Some(_key) = env_key {
print_success(messages.api_key_found_env());
None
} else if keyring_available {
let prompt = match provider.as_str() {
@@ -250,15 +275,13 @@ impl InitCommand {
"kimi" => messages.kimi_api_key(),
"deepseek" => messages.deepseek_api_key(),
"openrouter" => messages.openrouter_api_key(),
_ => "API Key",
_ => messages.api_key_prompt_other(),
};
let key: String = Input::new()
.with_prompt(prompt)
.interact_text()?;
let key: String = crate::utils::password_input(prompt)?;
Some(key)
} else {
println!("\n{}", "Please set the QUICOMMIT_API_KEY environment variable.".yellow());
print_warning(messages.please_set_api_key_env());
None
}
} else {
@@ -267,25 +290,25 @@ impl InitCommand {
let default_model = get_default_model(&provider);
let model: String = Input::new()
.with_prompt("Model name")
.with_prompt(messages.model_name())
.default(default_model.to_string())
.interact_text()?;
let base_url: Option<String> = if provider == "ollama" {
let url: String = Input::new()
.with_prompt("Ollama server URL")
.with_prompt(messages.ollama_server_url())
.default("http://localhost:11434".to_string())
.interact_text()?;
Some(url)
} else {
let use_custom_url = Confirm::new()
.with_prompt("Use custom API base URL?")
.with_prompt(messages.use_custom_base_url())
.default(false)
.interact()?;
if use_custom_url {
let url: String = Input::new()
.with_prompt("Base URL")
.with_prompt(messages.base_url_plain())
.interact_text()?;
Some(url)
} else {
@@ -297,11 +320,11 @@ impl InitCommand {
manager.set_llm_model(model);
manager.set_llm_base_url(base_url);
if let Some(key) = api_key {
if provider_needs_api_key(&provider) {
manager.set_api_key(&key)?;
println!("\n{} {}", "✓".green(), "API key stored securely in system keyring.".green());
}
if let Some(key) = api_key
&& provider_needs_api_key(&provider)
{
manager.set_api_key(&key)?;
print_success(messages.api_key_stored_keyring());
}
Ok(())
@@ -319,6 +342,17 @@ impl InitCommand {
.default(ssh_dir.join("id_rsa").display().to_string())
.interact_text()?;
let pub_key_path: String = Input::new()
.with_prompt(messages.ssh_public_key_path())
.default(ssh_dir.join("id_rsa.pub").display().to_string())
.allow_empty(true)
.interact_text()?;
let public_key_path = if pub_key_path.is_empty() {
None
} else {
Some(PathBuf::from(pub_key_path))
};
let has_passphrase = Confirm::new()
.with_prompt(messages.has_passphrase())
.default(false)
@@ -330,13 +364,38 @@ impl InitCommand {
None
};
let agent_forwarding = Confirm::new()
.with_prompt(messages.ssh_agent_forwarding())
.default(false)
.interact()?;
let known_hosts: String = Input::new()
.with_prompt(messages.known_hosts_path())
.allow_empty(true)
.interact_text()?;
let known_hosts_file = if known_hosts.is_empty() {
None
} else {
Some(PathBuf::from(known_hosts))
};
let custom_cmd: String = Input::new()
.with_prompt(messages.custom_ssh_command())
.allow_empty(true)
.interact_text()?;
let ssh_command = if custom_cmd.is_empty() {
None
} else {
Some(custom_cmd)
};
Ok(SshConfig {
private_key_path: Some(PathBuf::from(key_path)),
public_key_path: None,
public_key_path,
passphrase,
agent_forwarding: false,
ssh_command: None,
known_hosts_file: None,
agent_forwarding,
ssh_command,
known_hosts_file,
})
}

View File

@@ -1,6 +1,106 @@
pub mod changelog;
pub mod commit;
pub mod config;
pub mod credential;
pub mod ginit;
pub mod init;
pub mod profile;
pub mod tag;
use anyhow::{Result, bail};
use dialoguer::{Confirm, MultiSelect};
use crate::git::GitRepo;
use crate::i18n::Messages;
use crate::utils::{print_progress, print_success, print_warning};
/// Shared push flow for commit and tag: resolve the push target, honor the
/// `--yes` > `--select-remote` > `--push` priority chain, then push to the
/// chosen target(s). `progress_msg`/`success_msg` render the per-remote
/// status lines; `refspec` is "HEAD" for commit, the tag ref for tag.
pub(crate) fn run_push_flow(
repo: &GitRepo,
requested_remote: &str,
select_remote: bool,
push: bool,
yes: bool,
messages: &Messages,
confirm_prompt: &str,
refspec: &str,
progress_msg: impl Fn(&str) -> String,
success_msg: impl Fn(&str) -> String,
) -> Result<()> {
let Some((target, fell_back)) = repo.resolve_push_target(requested_remote)? else {
print_warning(&messages.push_skipped_no_remote());
return Ok(());
};
if fell_back && (push || !yes) {
print_warning(&messages.push_target_fallback(requested_remote, &target));
}
let remotes = if select_remote && !yes {
Some(repo.list_remotes()?)
} else {
None
};
let use_multi_select = remotes.as_ref().is_some_and(|r| r.len() > 1);
let targets: Vec<String> = if use_multi_select {
let remotes = remotes.unwrap();
let items: Vec<(&String, bool)> = remotes.iter().map(|r| (r, *r == target)).collect();
let selected = MultiSelect::new()
.with_prompt(messages.select_push_remotes())
.items_checked(&items)
.interact()?;
if selected.is_empty() {
print_warning(&messages.no_remotes_selected());
return Ok(());
}
selected.iter().map(|&i| remotes[i].clone()).collect()
} else if push {
vec![target]
} else if !yes {
if Confirm::new()
.with_prompt(confirm_prompt)
.default(false)
.interact()?
{
vec![target]
} else {
Vec::new()
}
} else {
Vec::new()
};
if targets.len() == 1 {
print_progress(&progress_msg(&targets[0]));
repo.push(&targets[0], refspec)?;
print_success(&success_msg(&targets[0]));
} else if targets.len() > 1 {
let fmt_list = |items: &[String]| {
if items.is_empty() {
"-".to_string()
} else {
items.join(", ")
}
};
for (i, remote_name) in targets.iter().enumerate() {
print_progress(&progress_msg(remote_name));
if let Err(e) = repo.push(remote_name, refspec) {
bail!(
"{}\n{}",
messages.push_multi_failed(
remote_name,
&fmt_list(&targets[..i]),
&fmt_list(&targets[i + 1..])
),
e
);
}
print_success(&success_msg(remote_name));
}
}
Ok(())
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
use anyhow::{bail, Result};
use anyhow::{Result, bail};
use clap::Parser;
use colored::Colorize;
use dialoguer::{Confirm, Input, Select};
@@ -6,12 +6,14 @@ use semver::Version;
use std::path::PathBuf;
use crate::config::{Language, manager::ConfigManager};
use crate::git::{find_repo, GitRepo};
use crate::generator::ContentGenerator;
use crate::git::tag::{
bump_version, get_latest_version, suggest_version_bump, TagBuilder, VersionBump,
ConfigVersion, TagBuilder, VersionBump, bump_version, get_latest_version,
read_project_versions, suggest_version_bump,
};
use crate::git::{GitRepo, find_repo};
use crate::i18n::Messages;
use crate::utils::{print_success, print_warning};
/// Generate and create Git tags
#[derive(Parser)]
@@ -52,13 +54,26 @@ pub struct TagCommand {
#[arg(short, long, default_value = "origin")]
remote: String,
/// Interactively select push target(s) when the repo has multiple remotes
#[arg(long)]
select_remote: bool,
/// Dry run
#[arg(long)]
dry_run: bool,
/// Skip interactive prompts
/// Enable thinking mode for this tag (override config)
#[arg(short = 't', long)]
think: bool,
/// Skip interactive prompts only (generation behavior unchanged)
#[arg(short = 'y', long)]
yes: bool,
/// Auto-detect version from project config (Cargo.toml/pyproject.toml),
/// falling back to commit analysis with confirmation. Mutually exclusive with --bump.
#[arg(short = 'A', long, conflicts_with = "bump")]
auto: bool,
}
impl TagCommand {
@@ -76,33 +91,43 @@ impl TagCommand {
// Determine tag name
let tag_name = if let Some(name) = &self.name {
name.clone()
} else if self.auto {
self.auto_detect_version(&repo, &config.tag.version_prefix, &messages)
.await?
} else if let Some(bump_str) = &self.bump {
// Calculate bumped version
let prefix = &config.tag.version_prefix;
let latest = get_latest_version(&repo, prefix)?
.unwrap_or_else(|| Version::new(0, 0, 0));
let latest =
get_latest_version(&repo, prefix)?.unwrap_or_else(|| Version::new(0, 0, 0));
let bump = VersionBump::from_str(bump_str)?;
let new_version = bump_version(&latest, bump, None);
format!("{}{}", prefix, new_version)
} else {
// Interactive mode
self.select_version_interactive(&repo, &config.tag.version_prefix, &messages).await?
self.select_version_interactive(&repo, &config.tag.version_prefix, &messages)
.await?
};
// Validate tag name (if it looks like a version)
if tag_name.starts_with('v') || tag_name.chars().next().map(|c| c.is_ascii_digit()).unwrap_or(false) {
if tag_name.starts_with('v')
|| tag_name
.chars()
.next()
.map(|c| c.is_ascii_digit())
.unwrap_or(false)
{
let version_str = tag_name.trim_start_matches('v');
if let Err(e) = crate::utils::validators::validate_semver(version_str) {
println!("{}: {}", "Warning".yellow(), e);
print_warning(&format!("{}: {}", messages.warning(), e));
if !self.yes {
let proceed = Confirm::new()
.with_prompt("Proceed with this tag name anyway?")
.with_prompt(messages.proceed_invalid_tag_name())
.default(true)
.interact()?;
if !proceed {
bail!("{}", messages.tag_cancelled());
}
@@ -115,12 +140,15 @@ impl TagCommand {
None
} else if let Some(msg) = &self.message {
Some(msg.clone())
} else if self.generate || (config.tag.auto_generate && !self.yes) {
Some(self.generate_tag_message(&repo, &tag_name, &messages).await?)
} else if self.generate || config.tag.auto_generate {
Some(
self.generate_tag_message(&repo, &tag_name, &messages)
.await?,
)
} else if !self.yes {
Some(self.input_message_interactive(&tag_name, &messages)?)
} else {
Some(format!("Release {}", tag_name))
Some(messages.release_default(&tag_name))
};
// Show preview
@@ -142,13 +170,13 @@ impl TagCommand {
.interact()?;
if !confirm {
println!("{}", messages.tag_cancelled().yellow());
print_warning(messages.tag_cancelled());
return Ok(());
}
}
if self.dry_run {
println!("\n{} {}", messages.dry_run(), "- tag not created.".yellow());
println!("\n{}", messages.dry_run_tag_not_created().yellow());
return Ok(());
}
@@ -161,35 +189,34 @@ impl TagCommand {
builder.execute(&repo)?;
println!("{} {}", messages.tag_created().green(), tag_name.cyan());
print_success(&format!("{} {}", messages.tag_created(), tag_name.cyan()));
// Push if requested or ask user
if self.push {
println!("{}", messages.pushing_tag(&self.remote));
repo.push(&self.remote, &format!("refs/tags/{}", tag_name))?;
println!("{}", messages.pushed_tag(&self.remote));
} else if !self.yes && !self.dry_run {
let should_push = Confirm::new()
.with_prompt(messages.push_after_tag())
.default(false)
.interact()?;
if should_push {
println!("{}", messages.pushing_tag(&self.remote));
repo.push(&self.remote, &format!("refs/tags/{}", tag_name))?;
println!("{}", messages.pushed_tag(&self.remote));
}
}
Ok(())
super::run_push_flow(
&repo,
&self.remote,
self.select_remote,
self.push,
self.yes,
&messages,
messages.push_after_tag(),
&format!("refs/tags/{}", tag_name),
|r| messages.pushing_tag(r),
|r| messages.pushed_tag(r),
)
}
async fn select_version_interactive(&self, repo: &GitRepo, prefix: &str, messages: &Messages) -> Result<String> {
async fn select_version_interactive(
&self,
repo: &GitRepo,
prefix: &str,
messages: &Messages,
) -> Result<String> {
loop {
let latest = get_latest_version(repo, prefix)?;
println!("\n{}", messages.version_selection().bold());
if let Some(ref version) = latest {
println!("{} {}{}", messages.latest_version(), prefix, version);
} else {
@@ -216,36 +243,46 @@ impl TagCommand {
// Auto-detect
let commits = repo.get_commits(50)?;
let bump = suggest_version_bump(&commits);
let version = latest.as_ref()
let version = latest
.as_ref()
.map(|v| bump_version(v, bump, None))
.unwrap_or_else(|| Version::new(0, 1, 0));
println!("{} {:?} → {}{}", messages.suggested_bump(), bump, prefix, version);
println!(
"{} {:?} → {}{}",
messages.suggested_bump(),
bump,
prefix,
version
);
let confirm = Confirm::new()
.with_prompt(messages.use_this_version())
.default(true)
.interact()?;
if confirm {
return Ok(format!("{}{}", prefix, version));
}
// User rejected, continue the loop
}
1 => {
let version = latest.as_ref()
let version = latest
.as_ref()
.map(|v| bump_version(v, VersionBump::Major, None))
.unwrap_or_else(|| Version::new(1, 0, 0));
return Ok(format!("{}{}", prefix, version));
}
2 => {
let version = latest.as_ref()
let version = latest
.as_ref()
.map(|v| bump_version(v, VersionBump::Minor, None))
.unwrap_or_else(|| Version::new(0, 1, 0));
return Ok(format!("{}{}", prefix, version));
}
3 => {
let version = latest.as_ref()
let version = latest
.as_ref()
.map(|v| bump_version(v, VersionBump::Patch, None))
.unwrap_or_else(|| Version::new(0, 0, 1));
return Ok(format!("{}{}", prefix, version));
@@ -268,7 +305,12 @@ impl TagCommand {
}
}
async fn generate_tag_message(&self, repo: &GitRepo, version: &str, messages: &Messages) -> Result<String> {
async fn generate_tag_message(
&self,
repo: &GitRepo,
version: &str,
messages: &Messages,
) -> Result<String> {
let manager = ConfigManager::new()?;
let language = manager.get_language().unwrap_or(Language::English);
@@ -280,23 +322,109 @@ impl TagCommand {
};
if commits.is_empty() {
return Ok(format!("Release {}", version));
return Ok(messages.release_default(version));
}
println!("{}", messages.ai_generating_tag(commits.len()));
let generator = ContentGenerator::new_with_think(&manager, self.think, None).await?;
let spinner = crate::utils::Spinner::start(&messages.ai_generating_tag(commits.len()));
let result = generator
.generate_tag_message(version, &commits, language)
.await;
spinner.finish_clear();
result
}
let generator = ContentGenerator::new(&manager).await?;
generator.generate_tag_message(version, &commits, language).await
async fn auto_detect_version(
&self,
repo: &GitRepo,
prefix: &str,
messages: &Messages,
) -> Result<String> {
let project_dir = std::env::current_dir()?;
// 1. Try reading from project config files
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
println!("{}", messages.auto_detect_bump());
let commits = repo.get_commits(50)?;
let bump = suggest_version_bump(&commits);
let latest = get_latest_version(repo, prefix)?.unwrap_or_else(|| Version::new(0, 0, 0));
let version = bump_version(&latest, bump, None);
let tag_name = format!("{}{}", prefix, version);
println!(
"{} {:?} → {}",
messages.suggested_bump(),
bump,
tag_name.cyan()
);
if !self.yes {
let confirm = Confirm::new()
.with_prompt(messages.use_this_version())
.default(true)
.interact()?;
if !confirm {
// Fall through to interactive version selection
return self
.select_version_interactive(repo, prefix, messages)
.await;
}
}
Ok(tag_name)
}
fn select_config_version(
&self,
config_versions: &[ConfigVersion],
prefix: &str,
messages: &Messages,
) -> Result<String> {
println!("\n{}", messages.found_multiple_versions().bold());
let items: Vec<String> = 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<String> {
let default_msg = format!("Release {}", version);
let default_msg = messages.release_default(version);
let use_editor = Confirm::new()
.with_prompt(messages.open_editor())
.default(false)
.interact()?;
if use_editor {
crate::utils::editor::edit_content(&default_msg)
} else {

View File

@@ -1,7 +1,9 @@
use super::{AppConfig, GitProfile, TokenConfig};
use crate::utils::keyring::{KeyringManager, get_default_base_url, get_default_model, provider_needs_api_key};
use anyhow::{bail, Context, Result};
use std::collections::HashMap;
use crate::utils::keyring::{
KeyringManager, get_default_base_url, get_default_model, provider_needs_api_key,
};
use anyhow::{Context, Result, bail};
// use std::collections::HashMap;
use std::path::{Path, PathBuf};
/// Configuration manager
@@ -64,10 +66,10 @@ impl ConfigManager {
Ok(())
}
/// Force save configuration
pub fn force_save(&self) -> Result<()> {
self.config.save(&self.config_path)
}
// /// Force save configuration
// pub fn force_save(&self) -> Result<()> {
// self.config.save(&self.config_path)
// }
/// Get configuration file path
pub fn path(&self) -> &Path {
@@ -91,13 +93,13 @@ impl ConfigManager {
if !self.config.profiles.contains_key(name) {
bail!("Profile '{}' does not exist", name);
}
if self.config.default_profile.as_ref() == Some(&name.to_string()) {
self.config.default_profile = None;
}
self.config.repo_profiles.retain(|_, v| v != name);
self.config.profiles.remove(name);
self.modified = true;
Ok(())
@@ -118,11 +120,11 @@ impl ConfigManager {
self.config.profiles.get(name)
}
/// Get mutable profile
pub fn get_profile_mut(&mut self, name: &str) -> Option<&mut GitProfile> {
self.modified = true;
self.config.profiles.get_mut(name)
}
// /// Get mutable profile
// pub fn get_profile_mut(&mut self, name: &str) -> Option<&mut GitProfile> {
// self.modified = true;
// self.config.profiles.get_mut(name)
// }
/// List all profile names
pub fn list_profiles(&self) -> Vec<&String> {
@@ -136,10 +138,10 @@ impl ConfigManager {
/// Set default profile
pub fn set_default_profile(&mut self, name: Option<String>) -> Result<()> {
if let Some(ref n) = name {
if !self.config.profiles.contains_key(n) {
bail!("Profile '{}' does not exist", n);
}
if let Some(ref n) = name
&& !self.config.profiles.contains_key(n)
{
bail!("Profile '{}' does not exist", n);
}
self.config.default_profile = name;
self.modified = true;
@@ -170,54 +172,154 @@ impl ConfigManager {
}
}
/// Get profile usage statistics
pub fn get_profile_usage(&self, name: &str) -> Option<&super::UsageStats> {
self.config.profiles.get(name).map(|p| &p.usage)
}
// /// Get profile usage statistics
// pub fn get_profile_usage(&self, name: &str) -> Option<&super::UsageStats> {
// self.config.profiles.get(name).map(|p| &p.usage)
// }
// Token management
/// Add a token to a profile
pub fn add_token_to_profile(&mut self, profile_name: &str, service: String, token: TokenConfig) -> Result<()> {
/// Add a token to a profile (stores token in keyring)
pub fn add_token_to_profile(
&mut self,
profile_name: &str,
service: String,
token: TokenConfig,
) -> Result<()> {
if !self.config.profiles.contains_key(profile_name) {
bail!("Profile '{}' does not exist", profile_name);
}
if let Some(profile) = self.config.profiles.get_mut(profile_name) {
profile.add_token(service, token);
self.modified = true;
Ok(())
}
Ok(())
}
/// Store a PAT token in keyring for a profile
pub fn store_pat_for_profile(
&self,
profile_name: &str,
service: &str,
token_value: &str,
) -> Result<()> {
let profile = self
.get_profile(profile_name)
.ok_or_else(|| anyhow::anyhow!("Profile '{}' not found", profile_name))?;
let user_email = &profile.user_email;
self.keyring
.store_pat(profile_name, user_email, service, token_value)
}
/// Get a PAT token from keyring for a profile
pub fn get_pat_for_profile(&self, profile_name: &str, service: &str) -> Result<Option<String>> {
let profile = self
.get_profile(profile_name)
.ok_or_else(|| anyhow::anyhow!("Profile '{}' not found", profile_name))?;
let user_email = &profile.user_email;
self.keyring.get_pat(profile_name, user_email, service)
}
/// Check if a PAT token exists for a profile
pub fn has_pat_for_profile(&self, profile_name: &str, service: &str) -> bool {
if let Some(profile) = self.get_profile(profile_name) {
let user_email = &profile.user_email;
self.keyring.has_pat(profile_name, user_email, service)
} else {
bail!("Profile '{}' does not exist", profile_name);
false
}
}
/// Get a token from a profile
pub fn get_token_from_profile(&self, profile_name: &str, service: &str) -> Option<&TokenConfig> {
self.config.profiles.get(profile_name)?.get_token(service)
}
/// Remove a token from a profile
/// Remove a token from a profile (deletes from keyring)
pub fn remove_token_from_profile(&mut self, profile_name: &str, service: &str) -> Result<()> {
if !self.config.profiles.contains_key(profile_name) {
bail!("Profile '{}' does not exist", profile_name);
}
let user_email = self
.config
.profiles
.get(profile_name)
.unwrap()
.user_email
.clone();
let services: Vec<String> = self
.config
.profiles
.get(profile_name)
.unwrap()
.tokens
.keys()
.cloned()
.collect();
if !services.contains(&service.to_string()) {
bail!(
"Token for service '{}' not found in profile '{}'",
service,
profile_name
);
}
self.keyring
.delete_pat(profile_name, &user_email, service)?;
if let Some(profile) = self.config.profiles.get_mut(profile_name) {
profile.remove_token(service);
self.modified = true;
Ok(())
}
Ok(())
}
/// Delete a PAT from the keyring even when no config token entry exists.
/// Returns true if a keyring entry was deleted (issue 24).
pub fn delete_orphan_pat(&self, profile_name: &str, service: &str) -> Result<bool> {
if let Some(profile) = self.get_profile(profile_name) {
let user_email = &profile.user_email;
if self.keyring.has_pat(profile_name, user_email, service) {
self.keyring.delete_pat(profile_name, user_email, service)?;
Ok(true)
} else {
Ok(false)
}
} else {
bail!("Profile '{}' does not exist", profile_name);
Ok(false)
}
}
/// List all tokens in a profile
pub fn list_profile_tokens(&self, profile_name: &str) -> Option<Vec<&String>> {
self.config.profiles.get(profile_name).map(|p| p.tokens.keys().collect())
/// Delete all PAT tokens for a profile (used when removing a profile)
pub fn delete_all_pats_for_profile(&self, profile_name: &str) -> Result<()> {
if let Some(profile) = self.get_profile(profile_name) {
let user_email = &profile.user_email;
let services: Vec<String> = profile.tokens.keys().cloned().collect();
self.keyring
.delete_all_pats_for_profile(profile_name, user_email, &services)?;
}
Ok(())
}
// /// List all tokens in a profile
// pub fn list_profile_tokens(&self, profile_name: &str) -> Option<Vec<&String>> {
// self.config.profiles.get(profile_name).map(|p| p.tokens.keys().collect())
// }
// Repository profile management
/// Get profile for repository
pub fn get_repo_profile(&self, repo_path: &str) -> Option<&GitProfile> {
self.config
.repo_profiles
.get(repo_path)
.and_then(|name| self.config.profiles.get(name))
}
// /// Get profile for repository
// pub fn get_repo_profile(&self, repo_path: &str) -> Option<&GitProfile> {
// self.config
// .repo_profiles
// .get(repo_path)
// .and_then(|name| self.config.profiles.get(name))
// }
/// Set profile for repository
pub fn set_repo_profile(&mut self, repo_path: String, profile_name: String) -> Result<()> {
@@ -229,32 +331,91 @@ impl ConfigManager {
Ok(())
}
/// Remove repository profile mapping
pub fn remove_repo_profile(&mut self, repo_path: &str) {
self.config.repo_profiles.remove(repo_path);
self.modified = true;
// /// Remove repository profile mapping
// pub fn remove_repo_profile(&mut self, repo_path: &str) {
// self.config.repo_profiles.remove(repo_path);
// self.modified = true;
// }
// /// List repository profile mappings
// pub fn list_repo_profiles(&self) -> &HashMap<String, String> {
// &self.config.repo_profiles
// }
// /// Get effective profile for a repository (repo-specific -> default)
// pub fn get_effective_profile(&self, repo_path: Option<&str>) -> Option<&GitProfile> {
// if let Some(path) = repo_path {
// if let Some(profile) = self.get_repo_profile(path) {
// return Some(profile);
// }
// }
// self.default_profile()
// }
/// Check and compare profile with git configuration
pub fn check_profile_config(
&self,
profile_name: &str,
repo: &git2::Repository,
) -> Result<super::ProfileComparison> {
let profile = self
.get_profile(profile_name)
.ok_or_else(|| anyhow::anyhow!("Profile '{}' not found", profile_name))?;
profile.compare_with_git_config(repo)
}
/// List repository profile mappings
pub fn list_repo_profiles(&self) -> &HashMap<String, String> {
&self.config.repo_profiles
}
/// Find a profile that matches the given user config (name, email, signing_key)
pub fn find_matching_profile(
&self,
user_name: &str,
user_email: &str,
signing_key: Option<&str>,
) -> Option<&GitProfile> {
for profile in self.config.profiles.values() {
let name_match = profile.user_name == user_name;
let email_match = profile.user_email == user_email;
let key_match = match (signing_key, profile.signing_key()) {
(Some(git_key), Some(profile_key)) => git_key == profile_key,
(None, None) => true,
(Some(_), None) => false,
(None, Some(_)) => false,
};
/// Get effective profile for a repository (repo-specific -> default)
pub fn get_effective_profile(&self, repo_path: Option<&str>) -> Option<&GitProfile> {
if let Some(path) = repo_path {
if let Some(profile) = self.get_repo_profile(path) {
if name_match && email_match && key_match {
return Some(profile);
}
}
self.default_profile()
None
}
/// Check and compare profile with git configuration
pub fn check_profile_config(&self, profile_name: &str, repo: &git2::Repository) -> Result<super::ProfileComparison> {
let profile = self.get_profile(profile_name)
.ok_or_else(|| anyhow::anyhow!("Profile '{}' not found", profile_name))?;
profile.compare_with_git_config(repo)
/// Find profiles that partially match (same name or same email)
pub fn find_partial_matches(&self, user_name: &str, user_email: &str) -> Vec<&GitProfile> {
self.config
.profiles
.values()
.filter(|p| p.user_name == user_name || p.user_email == user_email)
.collect()
}
/// Get repo profile mapping
pub fn get_repo_profile_name(&self, repo_path: &str) -> Option<&String> {
self.config.repo_profiles.get(repo_path)
}
/// Find profiles whose repo_patterns match the given repo path
pub fn match_profiles_by_repo_pattern(&self, repo_path: &str) -> Vec<&GitProfile> {
self.config
.profiles
.values()
.filter(|p| {
p.settings.repo_patterns.iter().any(|pattern| {
let trimmed = pattern.trim_matches('*');
repo_path.ends_with(trimmed)
|| repo_path.starts_with(trimmed)
|| repo_path == trimmed
})
})
.collect()
}
// LLM configuration
@@ -302,7 +463,11 @@ impl ConfigManager {
/// Get API key from configured storage method
pub fn get_api_key(&self) -> Option<String> {
// First try environment variables (always checked)
if let Some(key) = self.keyring.get_api_key(&self.config.llm.provider).unwrap_or(None) {
if let Some(key) = self
.keyring
.get_api_key(&self.config.llm.provider)
.unwrap_or(None)
{
return Some(key);
}
@@ -319,20 +484,29 @@ impl ConfigManager {
match self.config.llm.api_key_storage.as_str() {
"keyring" => {
if !self.keyring.is_available() {
bail!("Keyring is not available. Set QUICOMMIT_API_KEY environment variable instead or change api_key_storage to 'config'.");
bail!(
"Keyring is not available. Set QUICOMMIT_API_KEY environment variable instead or change api_key_storage to 'config'."
);
}
self.keyring.store_api_key(&self.config.llm.provider, api_key)
},
self.keyring
.store_api_key(&self.config.llm.provider, api_key)
}
"config" => {
// We can't modify self.config here since self is immutable
// This will be handled by the caller updating the config
Ok(())
},
}
"environment" => {
bail!("API key storage set to 'environment'. Please set QUICOMMIT_{}_API_KEY environment variable.", self.config.llm.provider.to_uppercase());
},
bail!(
"API key storage set to 'environment'. Please set QUICOMMIT_{}_API_KEY environment variable.",
self.config.llm.provider.to_uppercase()
);
}
_ => {
bail!("Invalid API key storage method: {}", self.config.llm.api_key_storage);
bail!(
"Invalid API key storage method: {}",
self.config.llm.api_key_storage
);
}
}
}
@@ -344,16 +518,19 @@ impl ConfigManager {
if self.keyring.is_available() {
self.keyring.delete_api_key(&self.config.llm.provider)?;
}
},
}
"config" => {
// We can't modify self.config here since self is immutable
// This will be handled by the caller updating the config
},
}
"environment" => {
// Environment variables are not managed by the app
},
}
_ => {
bail!("Invalid API key storage method: {}", self.config.llm.api_key_storage);
bail!(
"Invalid API key storage method: {}",
self.config.llm.api_key_storage
);
}
}
Ok(())
@@ -366,7 +543,12 @@ impl ConfigManager {
}
// Check environment variables
if self.keyring.get_api_key(&self.config.llm.provider).unwrap_or(None).is_some() {
if self
.keyring
.get_api_key(&self.config.llm.provider)
.unwrap_or(None)
.is_some()
{
return true;
}
@@ -383,31 +565,31 @@ impl ConfigManager {
&self.keyring
}
/// Configure LLM provider with all settings
pub fn configure_llm(&mut self, provider: String, model: Option<String>, base_url: Option<String>, api_key: Option<&str>) -> Result<()> {
self.set_llm_provider(provider.clone());
if let Some(m) = model {
self.set_llm_model(m);
}
self.set_llm_base_url(base_url);
if let Some(key) = api_key {
if provider_needs_api_key(&provider) {
self.set_api_key(key)?;
}
}
Ok(())
}
// /// Configure LLM provider with all settings
// pub fn configure_llm(&mut self, provider: String, model: Option<String>, base_url: Option<String>, api_key: Option<&str>) -> Result<()> {
// self.set_llm_provider(provider.clone());
// if let Some(m) = model {
// self.set_llm_model(m);
// }
// self.set_llm_base_url(base_url);
// if let Some(key) = api_key {
// if provider_needs_api_key(&provider) {
// self.set_api_key(key)?;
// }
// }
// Ok(())
// }
// Commit configuration
/// Get commit format
pub fn commit_format(&self) -> super::CommitFormat {
self.config.commit.format
}
// /// Get commit format
// pub fn commit_format(&self) -> super::CommitFormat {
// self.config.commit.format
// }
/// Set commit format
pub fn set_commit_format(&mut self, format: super::CommitFormat) {
@@ -415,10 +597,10 @@ impl ConfigManager {
self.modified = true;
}
/// Check if auto-generate is enabled
pub fn auto_generate_commits(&self) -> bool {
self.config.commit.auto_generate
}
// /// Check if auto-generate is enabled
// pub fn auto_generate_commits(&self) -> bool {
// self.config.commit.auto_generate
// }
/// Set auto-generate commits
pub fn set_auto_generate_commits(&mut self, enabled: bool) {
@@ -428,10 +610,10 @@ impl ConfigManager {
// Tag configuration
/// Get version prefix
pub fn version_prefix(&self) -> &str {
&self.config.tag.version_prefix
}
// /// Get version prefix
// pub fn version_prefix(&self) -> &str {
// &self.config.tag.version_prefix
// }
/// Set version prefix
pub fn set_version_prefix(&mut self, prefix: String) {
@@ -441,10 +623,10 @@ impl ConfigManager {
// Changelog configuration
/// Get changelog path
pub fn changelog_path(&self) -> &str {
&self.config.changelog.path
}
// /// Get changelog path
// pub fn changelog_path(&self) -> &str {
// &self.config.changelog.path
// }
/// Set changelog path
pub fn set_changelog_path(&mut self, path: String) {
@@ -454,10 +636,10 @@ impl ConfigManager {
// Language configuration
/// Get output language
pub fn output_language(&self) -> &str {
&self.config.language.output_language
}
// /// Get output language
// pub fn output_language(&self) -> &str {
// &self.config.language.output_language
// }
/// Set output language
pub fn set_output_language(&mut self, language: String) {
@@ -492,16 +674,25 @@ impl ConfigManager {
self.modified = true;
}
/// Check if emoji decorations are enabled
pub fn emoji_enabled(&self) -> bool {
self.config.output.emoji
}
/// Set emoji decorations flag
pub fn set_emoji_enabled(&mut self, enabled: bool) {
self.config.output.emoji = enabled;
self.modified = true;
}
/// Export configuration to TOML string
pub fn export(&self) -> Result<String> {
toml::to_string_pretty(&self.config)
.context("Failed to serialize config")
toml::to_string_pretty(&self.config).context("Failed to serialize config")
}
/// Import configuration from TOML string
pub fn import(&mut self, toml_str: &str) -> Result<()> {
self.config = toml::from_str(toml_str)
.context("Failed to parse config")?;
self.config = toml::from_str(toml_str).context("Failed to parse config")?;
self.modified = true;
Ok(())
}

View File

@@ -7,10 +7,7 @@ use std::path::{Path, PathBuf};
pub mod manager;
pub mod profile;
pub use profile::{
GitProfile, TokenConfig, TokenType,
UsageStats, ProfileComparison
};
pub use profile::{GitProfile, ProfileComparison, TokenConfig, TokenType};
/// Application configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -20,10 +17,11 @@ pub struct AppConfig {
pub version: String,
/// Default profile name
#[serde(skip_serializing_if = "Option::is_none")]
pub default_profile: Option<String>,
/// All configured profiles
#[serde(default)]
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub profiles: HashMap<String, GitProfile>,
/// LLM configuration
@@ -43,20 +41,16 @@ pub struct AppConfig {
pub changelog: ChangelogConfig,
/// Repository-specific profile mappings
#[serde(default)]
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub repo_profiles: HashMap<String, String>,
/// Whether to encrypt sensitive data
#[serde(default = "default_true")]
pub encrypt_sensitive: bool,
/// Theme settings
#[serde(default)]
pub theme: ThemeConfig,
/// Language settings
#[serde(default)]
pub language: LanguageConfig,
/// Output settings
#[serde(default)]
pub output: OutputConfig,
}
impl Default for AppConfig {
@@ -70,9 +64,8 @@ impl Default for AppConfig {
tag: TagConfig::default(),
changelog: ChangelogConfig::default(),
repo_profiles: HashMap::new(),
encrypt_sensitive: true,
theme: ThemeConfig::default(),
language: LanguageConfig::default(),
output: OutputConfig::default(),
}
}
}
@@ -84,11 +77,12 @@ pub struct LlmConfig {
#[serde(default = "default_llm_provider")]
pub provider: String,
/// Model to use (stored in config, not in keyring)
/// Model to use
#[serde(default = "default_model")]
pub model: String,
/// API base URL (optional, will use provider default if not set)
#[serde(skip_serializing_if = "Option::is_none")]
pub base_url: Option<String>,
/// Maximum tokens for generation
@@ -107,9 +101,17 @@ pub struct LlmConfig {
#[serde(default = "default_api_key_storage")]
pub api_key_storage: String,
/// API key (stored in config for fallback, encrypted if encrypt_sensitive is true)
#[serde(default)]
/// API key (stored in config for fallback)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub api_key: Option<String>,
/// Enable thinking/reasoning mode (deepseek, kimi, anthropic)
#[serde(default)]
pub thinking_enabled: bool,
/// Budget tokens for thinking mode (Anthropic Claude 4)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub thinking_budget_tokens: Option<u32>,
}
fn default_api_key_storage() -> String {
@@ -127,6 +129,8 @@ impl Default for LlmConfig {
timeout: default_timeout(),
api_key_storage: default_api_key_storage(),
api_key: None,
thinking_enabled: false,
thinking_budget_tokens: None,
}
}
}
@@ -141,33 +145,6 @@ pub struct CommitConfig {
/// Enable AI generation by default
#[serde(default = "default_true")]
pub auto_generate: bool,
/// Allow empty commits
#[serde(default)]
pub allow_empty: bool,
/// Sign commits with GPG
#[serde(default)]
pub gpg_sign: bool,
/// Default scope (optional)
pub default_scope: Option<String>,
/// Maximum subject length
#[serde(default = "default_max_subject_length")]
pub max_subject_length: usize,
/// Require scope
#[serde(default)]
pub require_scope: bool,
/// Require body for certain types
#[serde(default)]
pub require_body: bool,
/// Types that require body
#[serde(default = "default_body_required_types")]
pub body_required_types: Vec<String>,
}
impl Default for CommitConfig {
@@ -175,13 +152,6 @@ impl Default for CommitConfig {
Self {
format: default_commit_format(),
auto_generate: true,
allow_empty: false,
gpg_sign: false,
default_scope: None,
max_subject_length: default_max_subject_length(),
require_scope: false,
require_body: false,
body_required_types: default_body_required_types(),
}
}
}
@@ -213,18 +183,6 @@ pub struct TagConfig {
/// Enable AI generation for tag messages
#[serde(default = "default_true")]
pub auto_generate: bool,
/// Sign tags with GPG
#[serde(default)]
pub gpg_sign: bool,
/// Include changelog in annotated tags
#[serde(default = "default_true")]
pub include_changelog: bool,
/// Default annotation template
#[serde(default)]
pub annotation_template: Option<String>,
}
impl Default for TagConfig {
@@ -232,9 +190,6 @@ impl Default for TagConfig {
Self {
version_prefix: default_version_prefix(),
auto_generate: true,
gpg_sign: false,
include_changelog: true,
annotation_template: None,
}
}
}
@@ -249,26 +204,6 @@ pub struct ChangelogConfig {
/// Enable AI generation for changelog entries
#[serde(default = "default_true")]
pub auto_generate: bool,
/// Changelog format
#[serde(default = "default_changelog_format")]
pub format: ChangelogFormat,
/// Include commit hashes
#[serde(default)]
pub include_hashes: bool,
/// Include authors
#[serde(default)]
pub include_authors: bool,
/// Group by type
#[serde(default = "default_true")]
pub group_by_type: bool,
/// Custom categories
#[serde(default)]
pub custom_categories: Vec<ChangelogCategory>,
}
impl Default for ChangelogConfig {
@@ -276,56 +211,6 @@ impl Default for ChangelogConfig {
Self {
path: default_changelog_path(),
auto_generate: true,
format: default_changelog_format(),
include_hashes: false,
include_authors: false,
group_by_type: true,
custom_categories: vec![],
}
}
}
/// Changelog format
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum ChangelogFormat {
KeepAChangelog,
GitHubReleases,
Custom,
}
/// Changelog category mapping
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChangelogCategory {
/// Category title
pub title: String,
/// Commit types included in this category
pub types: Vec<String>,
}
/// Theme configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ThemeConfig {
/// Enable colors
#[serde(default = "default_true")]
pub colors: bool,
/// Enable icons
#[serde(default = "default_true")]
pub icons: bool,
/// Preferred date format
#[serde(default = "default_date_format")]
pub date_format: String,
}
impl Default for ThemeConfig {
fn default() -> Self {
Self {
colors: true,
icons: true,
date_format: default_date_format(),
}
}
}
@@ -356,6 +241,20 @@ impl Default for LanguageConfig {
}
}
/// Output configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OutputConfig {
/// Show emoji/symbol decorations (✓/✗/⚠/→)
#[serde(default = "default_true")]
pub emoji: bool,
}
impl Default for OutputConfig {
fn default() -> Self {
Self { emoji: true }
}
}
/// Supported languages
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Language {
@@ -408,6 +307,7 @@ impl Language {
}
// Default value functions
fn default_version() -> String {
"1".to_string()
}
@@ -440,14 +340,6 @@ fn default_commit_format() -> CommitFormat {
CommitFormat::Conventional
}
fn default_max_subject_length() -> usize {
100
}
fn default_body_required_types() -> Vec<String> {
vec!["feat".to_string(), "fix".to_string()]
}
fn default_version_prefix() -> String {
"v".to_string()
}
@@ -456,14 +348,6 @@ fn default_changelog_path() -> String {
"CHANGELOG.md".to_string()
}
fn default_changelog_format() -> ChangelogFormat {
ChangelogFormat::KeepAChangelog
}
fn default_date_format() -> String {
"%Y-%m-%d".to_string()
}
fn default_output_language() -> String {
"en".to_string()
}
@@ -484,39 +368,74 @@ impl AppConfig {
/// Save configuration to file
pub fn save(&self, path: &Path) -> Result<()> {
let content = toml::to_string_pretty(self)
.context("Failed to serialize config")?;
let content = toml::to_string_pretty(self).context("Failed to serialize config")?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("Failed to create config directory: {:?}", parent))?;
}
fs::write(path, content)
.with_context(|| format!("Failed to write config file: {:?}", path))?;
Ok(())
}
/// Get default config path
pub fn default_path() -> Result<PathBuf> {
let config_dir = dirs::config_dir()
.context("Could not find config directory")?;
let config_dir = dirs::config_dir().context("Could not find config directory")?;
Ok(config_dir.join("quicommit").join("config.toml"))
}
}
/// Get profile for a repository
pub fn get_profile_for_repo(&self, repo_path: &str) -> Option<&GitProfile> {
let profile_name = self.repo_profiles.get(repo_path)?;
self.profiles.get(profile_name)
/// Encrypted PAT data for export
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EncryptedPat {
/// Profile name
pub profile_name: String,
/// Service name (e.g., github, gitlab)
pub service: String,
/// User email (for keyring lookup)
pub user_email: String,
/// Encrypted token value
pub encrypted_token: String,
}
/// Export data container with optional encrypted PATs
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExportData {
/// Configuration content (TOML string)
pub config: String,
/// Encrypted PATs (only present when exporting with encryption)
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub encrypted_pats: Vec<EncryptedPat>,
/// Export version for future compatibility
#[serde(default = "default_export_version")]
pub export_version: String,
}
fn default_export_version() -> String {
"1".to_string()
}
impl ExportData {
pub fn new(config: String) -> Self {
Self {
config,
encrypted_pats: Vec::new(),
export_version: default_export_version(),
}
}
/// Set profile for a repository
pub fn set_profile_for_repo(&mut self, repo_path: String, profile_name: String) -> Result<()> {
if !self.profiles.contains_key(&profile_name) {
anyhow::bail!("Profile '{}' does not exist", profile_name);
pub fn with_encrypted_pats(config: String, pats: Vec<EncryptedPat>) -> Self {
Self {
config,
encrypted_pats: pats,
export_version: default_export_version(),
}
self.repo_profiles.insert(repo_path, profile_name);
Ok(())
}
pub fn has_encrypted_pats(&self) -> bool {
!self.encrypted_pats.is_empty()
}
}

View File

@@ -1,4 +1,4 @@
use anyhow::{bail, Result};
use anyhow::{Result, bail};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
@@ -80,25 +80,25 @@ impl GitProfile {
if self.user_name.is_empty() {
bail!("User name cannot be empty");
}
if self.user_email.is_empty() {
bail!("User email cannot be empty");
}
crate::utils::validators::validate_email(&self.user_email)?;
if let Some(ref ssh) = self.ssh {
ssh.validate()?;
}
if let Some(ref gpg) = self.gpg {
gpg.validate()?;
}
for token in self.tokens.values() {
token.validate()?;
}
Ok(())
}
@@ -120,11 +120,15 @@ impl GitProfile {
/// Get signing key (from GPG config or direct)
pub fn signing_key(&self) -> Option<&str> {
self.signing_key
.as_ref()
.map(|s| s.as_str())
.as_deref()
.or_else(|| self.gpg.as_ref().map(|g| g.key_id.as_str()))
}
/// Get the commit template if set
pub fn commit_template(&self) -> Option<&str> {
self.settings.commit_template.as_deref()
}
/// Add a token to the profile
pub fn add_token(&mut self, service: String, token: TokenConfig) {
self.tokens.insert(service, token);
@@ -144,7 +148,7 @@ impl GitProfile {
pub fn record_usage(&mut self, repo_path: Option<String>) {
self.usage.last_used = Some(chrono::Utc::now().to_rfc3339());
self.usage.total_uses += 1;
if let Some(repo) = repo_path {
let count = self.usage.repo_usage.entry(repo).or_insert(0);
*count += 1;
@@ -159,93 +163,137 @@ impl GitProfile {
/// Apply this profile to a git repository (local config)
pub fn apply_to_repo(&self, repo: &git2::Repository) -> Result<()> {
let mut config = repo.config()?;
config.set_str("user.name", &self.user_name)?;
config.set_str("user.email", &self.user_email)?;
if let Some(key) = self.signing_key() {
config.set_str("user.signingkey", key)?;
if self.settings.auto_sign_commits {
config.set_bool("commit.gpgsign", true)?;
}
if self.settings.auto_sign_tags {
config.set_bool("tag.gpgsign", true)?;
}
// Clean up old managed keys that the new profile won't set
if self.ssh.as_ref().and_then(|s| s.git_ssh_command()).is_none() {
let _ = config.remove("core.sshCommand");
}
if let Some(ref ssh) = self.ssh {
if let Some(ref key_path) = ssh.private_key_path {
let path_str = key_path.display().to_string();
#[cfg(target_os = "windows")]
{
config.set_str("core.sshCommand",
&format!("ssh -i \"{}\"", path_str.replace('\\', "/")))?;
if self.signing_key().is_none() {
let _ = config.remove("user.signingkey");
let _ = config.remove("commit.gpgsign");
let _ = config.remove("tag.gpgsign");
}
if self.gpg.is_none() {
let _ = config.remove("gpg.program");
}
// Apply new values; track whether we've written past name/email for rollback
let mut wrote_optional = false;
let result = (|| -> Result<()> {
config.set_str("user.name", &self.user_name)?;
config.set_str("user.email", &self.user_email)?;
if let Some(ref gpg) = self.gpg {
config.set_str("gpg.program", &gpg.program)?;
wrote_optional = true;
}
if let Some(key) = self.signing_key() {
config.set_str("user.signingkey", key)?;
if self.settings.auto_sign_commits {
config.set_bool("commit.gpgsign", true)?;
}
#[cfg(not(target_os = "windows"))]
{
config.set_str("core.sshCommand",
&format!("ssh -i '{}'", path_str))?;
if self.settings.auto_sign_tags {
config.set_bool("tag.gpgsign", true)?;
}
wrote_optional = true;
}
if let Some(ref ssh) = self.ssh {
if let Some(ssh_cmd) = ssh.git_ssh_command() {
config.set_str("core.sshCommand", &ssh_cmd)?;
wrote_optional = true;
}
}
Ok(())
})();
if result.is_err() && wrote_optional {
let _ = config.remove("core.sshCommand");
let _ = config.remove("user.signingkey");
let _ = config.remove("commit.gpgsign");
let _ = config.remove("tag.gpgsign");
let _ = config.remove("gpg.program");
}
Ok(())
result
}
/// Apply this profile globally
pub fn apply_global(&self) -> Result<()> {
let mut config = git2::Config::open_default()?;
config.set_str("user.name", &self.user_name)?;
config.set_str("user.email", &self.user_email)?;
if let Some(key) = self.signing_key() {
config.set_str("user.signingkey", key)?;
if self.settings.auto_sign_commits {
config.set_bool("commit.gpgsign", true)?;
}
if self.settings.auto_sign_tags {
config.set_bool("tag.gpgsign", true)?;
}
// Clean up old managed keys that the new profile won't set
if self.ssh.as_ref().and_then(|s| s.git_ssh_command()).is_none() {
let _ = config.remove("core.sshCommand");
}
if let Some(ref ssh) = self.ssh {
if let Some(ref key_path) = ssh.private_key_path {
let path_str = key_path.display().to_string();
#[cfg(target_os = "windows")]
{
config.set_str("core.sshCommand",
&format!("ssh -i \"{}\"", path_str.replace('\\', "/")))?;
if self.signing_key().is_none() {
let _ = config.remove("user.signingkey");
let _ = config.remove("commit.gpgsign");
let _ = config.remove("tag.gpgsign");
}
if self.gpg.is_none() {
let _ = config.remove("gpg.program");
}
// Apply new values; track whether we've written past name/email for rollback
let mut wrote_optional = false;
let result = (|| -> Result<()> {
config.set_str("user.name", &self.user_name)?;
config.set_str("user.email", &self.user_email)?;
if let Some(ref gpg) = self.gpg {
config.set_str("gpg.program", &gpg.program)?;
wrote_optional = true;
}
if let Some(key) = self.signing_key() {
config.set_str("user.signingkey", key)?;
if self.settings.auto_sign_commits {
config.set_bool("commit.gpgsign", true)?;
}
#[cfg(not(target_os = "windows"))]
{
config.set_str("core.sshCommand",
&format!("ssh -i '{}'", path_str))?;
if self.settings.auto_sign_tags {
config.set_bool("tag.gpgsign", true)?;
}
wrote_optional = true;
}
if let Some(ref ssh) = self.ssh {
if let Some(ssh_cmd) = ssh.git_ssh_command() {
config.set_str("core.sshCommand", &ssh_cmd)?;
wrote_optional = true;
}
}
Ok(())
})();
if result.is_err() && wrote_optional {
let _ = config.remove("core.sshCommand");
let _ = config.remove("user.signingkey");
let _ = config.remove("commit.gpgsign");
let _ = config.remove("tag.gpgsign");
let _ = config.remove("gpg.program");
}
Ok(())
result
}
/// Compare with current git configuration
pub fn compare_with_git_config(&self, repo: &git2::Repository) -> Result<ProfileComparison> {
let config = repo.config()?;
let git_user_name = config.get_string("user.name").ok();
let git_user_email = config.get_string("user.email").ok();
let git_signing_key = config.get_string("user.signingkey").ok();
let mut comparison = ProfileComparison {
profile_name: self.name.clone(),
matches: true,
differences: vec![],
};
if git_user_name.as_deref() != Some(&self.user_name) {
comparison.matches = false;
comparison.differences.push(ConfigDifference {
@@ -254,7 +302,7 @@ impl GitProfile {
git_value: git_user_name.unwrap_or_else(|| "<not set>".to_string()),
});
}
if git_user_email.as_deref() != Some(&self.user_email) {
comparison.matches = false;
comparison.differences.push(ConfigDifference {
@@ -263,24 +311,24 @@ impl GitProfile {
git_value: git_user_email.unwrap_or_else(|| "<not set>".to_string()),
});
}
if let Some(profile_key) = self.signing_key() {
if git_signing_key.as_deref() != Some(profile_key) {
comparison.matches = false;
comparison.differences.push(ConfigDifference {
key: "user.signingkey".to_string(),
profile_value: profile_key.to_string(),
git_value: git_signing_key.unwrap_or_else(|| "<not set>".to_string()),
});
}
if let Some(profile_key) = self.signing_key()
&& git_signing_key.as_deref() != Some(profile_key)
{
comparison.matches = false;
comparison.differences.push(ConfigDifference {
key: "user.signingkey".to_string(),
profile_value: profile_key.to_string(),
git_value: git_signing_key.unwrap_or_else(|| "<not set>".to_string()),
});
}
Ok(comparison)
}
}
/// Profile settings
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ProfileSettings {
/// Automatically sign commits
#[serde(default)]
@@ -307,19 +355,6 @@ pub struct ProfileSettings {
pub commit_template: Option<String>,
}
impl Default for ProfileSettings {
fn default() -> Self {
Self {
auto_sign_commits: false,
auto_sign_tags: false,
default_commit_format: None,
repo_patterns: vec![],
llm_provider: None,
commit_template: None,
}
}
}
/// SSH configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SshConfig {
@@ -349,37 +384,84 @@ pub struct SshConfig {
impl SshConfig {
/// Validate SSH configuration
pub fn validate(&self) -> Result<()> {
if let Some(ref path) = self.private_key_path {
if !path.exists() {
bail!("SSH private key does not exist: {:?}", path);
}
if let Some(ref path) = self.private_key_path
&& !path.exists()
{
bail!("SSH private key does not exist: {:?}", path);
}
if let Some(ref path) = self.public_key_path {
if !path.exists() {
bail!("SSH public key does not exist: {:?}", path);
}
if let Some(ref path) = self.public_key_path
&& !path.exists()
{
bail!("SSH public key does not exist: {:?}", path);
}
if let Some(ref path) = self.known_hosts_file
&& !path.exists()
{
bail!("SSH known_hosts file does not exist: {:?}", path);
}
Ok(())
}
/// Get SSH command for git
/// Get the effective public key path, deriving from private key if not explicitly set
pub fn effective_public_key_path(&self) -> Option<std::path::PathBuf> {
self.public_key_path.clone().or_else(|| {
self.private_key_path.as_ref().map(|pk| {
let mut pub_path = pk.clone();
let ext = pk
.extension()
.map(|e| format!("{}.pub", e.to_string_lossy()))
.unwrap_or_else(|| "pub".to_string());
pub_path.set_extension(&ext);
pub_path
})
})
}
/// Get the effective SSH command for git config
///
/// Priority: custom `ssh_command` > constructed from key/agent/known_hosts
pub fn git_ssh_command(&self) -> Option<String> {
if let Some(ref cmd) = self.ssh_command {
Some(cmd.clone())
} else if let Some(ref key_path) = self.private_key_path {
return Some(cmd.clone());
}
let mut parts: Vec<String> = vec!["ssh".to_string()];
if self.agent_forwarding {
parts.push("-A".to_string());
}
if let Some(ref key_path) = self.private_key_path {
let path_str = key_path.display().to_string();
#[cfg(target_os = "windows")]
{
Some(format!("ssh -i \"{}\"", path_str.replace('\\', "/")))
parts.push(format!("-i \"{}\"", path_str.replace('\\', "/")));
}
#[cfg(not(target_os = "windows"))]
{
Some(format!("ssh -i '{}'", path_str))
parts.push(format!("-i '{}'", path_str));
}
} else {
}
if let Some(ref known_hosts) = self.known_hosts_file {
let kh_str = known_hosts.display().to_string();
#[cfg(target_os = "windows")]
{
parts.push(format!("-o UserKnownHostsFile=\"{}\"", kh_str.replace('\\', "/")));
}
#[cfg(not(target_os = "windows"))]
{
parts.push(format!("-o UserKnownHostsFile='{}'", kh_str));
}
}
if parts.len() == 1 {
None
} else {
Some(parts.join(" "))
}
}
}
@@ -423,10 +505,6 @@ impl GpgConfig {
/// Token configuration for services (GitHub, GitLab, etc.)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TokenConfig {
/// Token value (encrypted)
#[serde(skip_serializing_if = "Option::is_none")]
pub token: Option<String>,
/// Token type (personal, oauth, etc.)
#[serde(default)]
pub token_type: TokenType,
@@ -446,25 +524,41 @@ pub struct TokenConfig {
/// Description
#[serde(default)]
pub description: Option<String>,
/// Indicates if a token is stored in keyring
#[serde(default)]
pub has_token: bool,
}
impl TokenConfig {
/// Create a new token config
pub fn new(token: String, token_type: TokenType) -> Self {
/// Create a new token config (token stored separately in keyring)
pub fn new(token_type: TokenType) -> Self {
Self {
token: Some(token),
token_type,
scopes: vec![],
expires_at: None,
last_used: None,
description: None,
has_token: true,
}
}
/// Create a new token config without token
pub fn without_token(token_type: TokenType) -> Self {
Self {
token_type,
scopes: vec![],
expires_at: None,
last_used: None,
description: None,
has_token: false,
}
}
/// Validate token configuration
pub fn validate(&self) -> Result<()> {
if self.token.is_none() && self.token_type != TokenType::None {
bail!("Token value is required for {:?}", self.token_type);
if !self.has_token && self.token_type != TokenType::None {
bail!("Token is required for {:?}", self.token_type);
}
Ok(())
}
@@ -473,12 +567,19 @@ impl TokenConfig {
pub fn record_usage(&mut self) {
self.last_used = Some(chrono::Utc::now().to_rfc3339());
}
/// Mark that a token is stored
pub fn set_has_token(&mut self, has_token: bool) {
self.has_token = has_token;
}
}
/// Token type
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
#[derive(Default)]
pub enum TokenType {
#[default]
None,
Personal,
OAuth,
@@ -486,12 +587,6 @@ pub enum TokenType {
App,
}
impl Default for TokenType {
fn default() -> Self {
Self::None
}
}
impl std::fmt::Display for TokenType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
@@ -621,9 +716,15 @@ impl GitProfileBuilder {
}
pub fn build(self) -> Result<GitProfile> {
let name = self.name.ok_or_else(|| anyhow::anyhow!("Name is required"))?;
let user_name = self.user_name.ok_or_else(|| anyhow::anyhow!("User name is required"))?;
let user_email = self.user_email.ok_or_else(|| anyhow::anyhow!("User email is required"))?;
let name = self
.name
.ok_or_else(|| anyhow::anyhow!("Name is required"))?;
let user_name = self
.user_name
.ok_or_else(|| anyhow::anyhow!("User name is required"))?;
let user_email = self
.user_email
.ok_or_else(|| anyhow::anyhow!("User email is required"))?;
Ok(GitProfile {
name,
@@ -669,13 +770,13 @@ mod tests {
"".to_string(),
"invalid-email".to_string(),
);
assert!(profile.validate().is_err());
}
#[test]
fn test_token_config() {
let token = TokenConfig::new("test-token".to_string(), TokenType::Personal);
let token = TokenConfig::new(TokenType::Personal);
assert!(token.validate().is_ok());
}
}

View File

@@ -1,24 +1,61 @@
use crate::config::{CommitFormat, Language};
use crate::config::manager::ConfigManager;
use crate::config::{CommitFormat, Language};
use crate::git::{CommitInfo, GitRepo};
use crate::llm::{GeneratedCommit, LlmClient};
use crate::i18n::Messages;
use crate::llm::parsing::GeneratedCommit;
use crate::llm::rig::LlmClient;
use crate::utils::{eprint_warning, success_prefix};
use anyhow::{Context, Result};
/// Content generator using LLM
pub struct ContentGenerator {
llm_client: LlmClient,
template: Option<String>,
}
impl ContentGenerator {
/// Create new content generator
pub async fn new(manager: &ConfigManager) -> Result<Self> {
let llm_client = LlmClient::from_config(manager).await?;
Self::new_with_think(manager, false, None).await
}
/// Create new content generator with thinking override and optional commit template
pub async fn new_with_think(
manager: &ConfigManager,
think_override: bool,
template: Option<String>,
) -> Result<Self> {
let mut thinking_enabled = if think_override {
true
} else {
manager.config().llm.thinking_enabled
};
// Validate thinking support per provider
if thinking_enabled {
let provider = manager.llm_provider();
if !Self::supports_thinking(provider) {
let language = manager.get_language().unwrap_or(Language::English);
let messages = Messages::new(language);
eprint_warning(&messages.thinking_unsupported(provider));
thinking_enabled = false;
}
}
let llm_client = LlmClient::from_config_with_think(manager, thinking_enabled).await?;
if !llm_client.is_available().await {
anyhow::bail!("LLM provider '{}' is not available", manager.llm_provider());
}
Ok(Self { llm_client })
Ok(Self {
llm_client,
template,
})
}
fn supports_thinking(provider: &str) -> bool {
matches!(provider, "deepseek" | "kimi" | "anthropic" | "openai")
}
/// Generate commit message from diff
@@ -36,8 +73,10 @@ impl ContentGenerator {
} else {
diff.to_string()
};
self.llm_client.generate_commit_message(&truncated_diff, format, language).await
self.llm_client
.generate_commit_message(&truncated_diff, format, language, self.template.as_deref())
.await
}
/// Generate commit message from repository changes
@@ -47,13 +86,14 @@ impl ContentGenerator {
format: CommitFormat,
language: Language,
) -> Result<GeneratedCommit> {
let diff = repo.get_staged_diff()
let diff = repo
.get_staged_diff_sorted()
.context("Failed to get staged diff")?;
if diff.is_empty() {
anyhow::bail!("No staged changes to generate commit from");
}
self.generate_commit_message(&diff, format, language).await
}
@@ -64,12 +104,12 @@ impl ContentGenerator {
commits: &[CommitInfo],
language: Language,
) -> Result<String> {
let commit_messages: Vec<String> = commits
.iter()
.map(|c| c.subject().to_string())
.collect();
self.llm_client.generate_tag_message(version, &commit_messages, language).await
let commit_messages: Vec<String> =
commits.iter().map(|c| c.subject().to_string()).collect();
self.llm_client
.generate_tag_message(version, &commit_messages, language)
.await
}
/// Generate changelog entry
@@ -86,8 +126,10 @@ impl ContentGenerator {
(commit_type, c.subject().to_string())
})
.collect();
self.llm_client.generate_changelog_entry(version, &typed_commits, language).await
self.llm_client
.generate_changelog_entry(version, &typed_commits, language)
.await
}
/// Generate changelog from repository
@@ -103,8 +145,9 @@ impl ContentGenerator {
} else {
repo.get_commits(50)?
};
self.generate_changelog_entry(version, &commits, language).await
self.generate_changelog_entry(version, &commits, language)
.await
}
/// Interactive commit generation with user feedback
@@ -113,57 +156,62 @@ impl ContentGenerator {
repo: &GitRepo,
format: CommitFormat,
language: Language,
messages: &Messages,
) -> Result<GeneratedCommit> {
use dialoguer::Select;
let diff = repo.get_staged_diff()?;
let diff = repo.get_staged_diff_sorted()?;
if diff.is_empty() {
anyhow::bail!("No staged changes");
}
// Show diff summary
let files = repo.get_staged_files()?;
println!("\nStaged files ({}):", files.len());
println!("\n{}", messages.staged_files(files.len()));
for file in &files {
println!(" • {}", file);
}
// Generate initial commit
println!("\nGenerating commit message...");
let mut generated = self.generate_commit_message(&diff, format, language).await?;
println!("\n{}", messages.generating_commit_message());
let mut generated = self
.generate_commit_message(&diff, format, language)
.await?;
loop {
println!("\n{}", "─".repeat(60));
println!("Generated commit message:");
println!("{}", messages.generated_commit_message());
println!("{}", "─".repeat(60));
println!("{}", generated.to_conventional());
println!("{}", "─".repeat(60));
let options = vec![
"✓ Accept and commit",
"🔄 Regenerate",
"✏️ Edit",
"❌ Cancel",
format!("{} {}", success_prefix(), messages.accept_and_commit()),
messages.regenerate().to_string(),
messages.edit().to_string(),
messages.cancel().to_string(),
];
let selection = Select::new()
.with_prompt("What would you like to do?")
.with_prompt(messages.what_would_you_like_to_do())
.items(&options)
.default(0)
.interact()?;
match selection {
0 => return Ok(generated),
1 => {
println!("Regenerating...");
generated = self.generate_commit_message(&diff, format, language).await?;
println!("{}", messages.regenerating());
generated = self
.generate_commit_message(&diff, format, language)
.await?;
}
2 => {
let edited = crate::utils::editor::edit_content(&generated.to_conventional())?;
generated = self.parse_edited_commit(&edited, format)?;
}
3 => anyhow::bail!("Cancelled by user"),
3 => anyhow::bail!("{}", messages.cancelled_by_user()),
_ => {}
}
}
@@ -171,7 +219,7 @@ impl ContentGenerator {
fn parse_edited_commit(&self, edited: &str, _format: CommitFormat) -> Result<GeneratedCommit> {
let parsed = crate::git::commit::parse_commit_message(edited);
Ok(GeneratedCommit {
commit_type: parsed.commit_type.unwrap_or_else(|| "chore".to_string()),
scope: parsed.scope,
@@ -208,11 +256,15 @@ pub mod fallback {
let has_code = files.iter().any(|f| {
f.ends_with(".rs") || f.ends_with(".py") || f.ends_with(".js") || f.ends_with(".ts")
});
let has_docs = files.iter().any(|f| f.ends_with(".md") || f.contains("README"));
let has_tests = files.iter().any(|f| f.contains("test") || f.contains("spec"));
let has_docs = files
.iter()
.any(|f| f.ends_with(".md") || f.contains("README"));
let has_tests = files
.iter()
.any(|f| f.contains("test") || f.contains("spec"));
if has_tests {
"test: update tests".to_string()
} else if has_docs {

View File

@@ -95,9 +95,7 @@ impl ChangelogGenerator {
ChangelogFormat::GitHubReleases => {
self.generate_github_releases(version, date, commits)
}
ChangelogFormat::Custom => {
self.generate_custom(version, date, commits)
}
ChangelogFormat::Custom => self.generate_custom(version, date, commits),
}
}
@@ -110,13 +108,13 @@ impl ChangelogGenerator {
commits: &[CommitInfo],
) -> Result<()> {
let entry = self.generate(version, date, commits)?;
let existing = if changelog_path.exists() {
fs::read_to_string(changelog_path)?
} else {
String::new()
};
let new_content = if existing.is_empty() {
format!("{}{}", CHANGELOG_HEADER, entry)
} else if existing.starts_with(CHANGELOG_HEADER) {
@@ -124,7 +122,7 @@ impl ChangelogGenerator {
} else if existing.starts_with("# Changelog") {
let lines: Vec<&str> = existing.lines().collect();
let mut header_end = 0;
for (i, line) in lines.iter().enumerate() {
if i == 0 && line.starts_with('#') {
header_end = i + 1;
@@ -134,18 +132,18 @@ impl ChangelogGenerator {
break;
}
}
let header = lines[..header_end].join("\n");
let rest = lines[header_end..].join("\n");
format!("{}\n{}\n{}", header, entry, rest)
} else {
format!("{}{}", CHANGELOG_HEADER, entry)
};
fs::write(changelog_path, new_content)
.with_context(|| format!("Failed to write changelog: {:?}", changelog_path))?;
Ok(())
}
@@ -157,10 +155,10 @@ impl ChangelogGenerator {
) -> Result<String> {
let date_str = date.format("%Y-%m-%d").to_string();
let mut output = format!("## [{}] - {}\n\n", version, date_str);
if self.group_by_type {
let grouped = self.group_commits(commits);
let _grouped = self.group_commits(commits);
// Standard categories
let categories = vec![
("Added", vec!["feat"]),
@@ -170,7 +168,7 @@ impl ChangelogGenerator {
("Fixed", vec!["fix"]),
("Security", vec!["security"]),
];
for (title, types) in &categories {
let items: Vec<&CommitInfo> = commits
.iter()
@@ -182,7 +180,7 @@ impl ChangelogGenerator {
}
})
.collect();
if !items.is_empty() {
output.push_str(&format!("### {}\n\n", title));
for commit in items {
@@ -192,13 +190,13 @@ impl ChangelogGenerator {
output.push('\n');
}
}
// Other changes
let categorized: Vec<String> = categories
.iter()
.flat_map(|(_, types)| types.iter().map(|s| s.to_string()))
.collect();
let other: Vec<&CommitInfo> = commits
.iter()
.filter(|c| {
@@ -209,7 +207,7 @@ impl ChangelogGenerator {
}
})
.collect();
if !other.is_empty() {
output.push_str("### Other\n\n");
for commit in other {
@@ -224,30 +222,30 @@ impl ChangelogGenerator {
output.push('\n');
}
}
Ok(output)
}
fn generate_github_releases(
&self,
version: &str,
_version: &str,
_date: DateTime<Utc>,
commits: &[CommitInfo],
) -> Result<String> {
let mut output = format!("## What's Changed\n\n");
let mut output = "## What's Changed\n\n".to_string();
// Group by type
let mut features = vec![];
let mut fixes = vec![];
let mut docs = vec![];
let mut other = vec![];
let mut breaking = vec![];
for commit in commits {
if commit.message.contains("BREAKING CHANGE") {
breaking.push(commit);
}
if let Some(ref t) = commit.commit_type() {
match t.as_str() {
"feat" => features.push(commit),
@@ -259,7 +257,7 @@ impl ChangelogGenerator {
other.push(commit);
}
}
if !breaking.is_empty() {
output.push_str("### ⚠ Breaking Changes\n\n");
for commit in breaking {
@@ -267,7 +265,7 @@ impl ChangelogGenerator {
}
output.push('\n');
}
if !features.is_empty() {
output.push_str("### 🚀 Features\n\n");
for commit in features {
@@ -275,7 +273,7 @@ impl ChangelogGenerator {
}
output.push('\n');
}
if !fixes.is_empty() {
output.push_str("### 🐛 Bug Fixes\n\n");
for commit in fixes {
@@ -283,7 +281,7 @@ impl ChangelogGenerator {
}
output.push('\n');
}
if !docs.is_empty() {
output.push_str("### 📚 Documentation\n\n");
for commit in docs {
@@ -291,14 +289,14 @@ impl ChangelogGenerator {
}
output.push('\n');
}
if !other.is_empty() {
output.push_str("### Other Changes\n\n");
for commit in other {
output.push_str(&self.format_commit_github(commit));
}
}
Ok(output)
}
@@ -312,7 +310,7 @@ impl ChangelogGenerator {
if !self.custom_categories.is_empty() {
let date_str = date.format("%Y-%m-%d").to_string();
let mut output = format!("## [{}] - {}\n\n", version, date_str);
for category in &self.custom_categories {
let items: Vec<&CommitInfo> = commits
.iter()
@@ -324,7 +322,7 @@ impl ChangelogGenerator {
}
})
.collect();
if !items.is_empty() {
output.push_str(&format!("### {}\n\n", category.title));
for commit in items {
@@ -334,7 +332,7 @@ impl ChangelogGenerator {
output.push('\n');
}
}
Ok(output)
} else {
// Fall back to keep-a-changelog
@@ -344,30 +342,35 @@ impl ChangelogGenerator {
fn format_commit(&self, commit: &CommitInfo) -> String {
let mut line = format!("- {}", commit.subject());
if self.include_hashes {
line.push_str(&format!(" ({})", &commit.short_id));
}
if self.include_authors {
line.push_str(&format!(" - @{}", commit.author));
}
line
}
fn format_commit_github(&self, commit: &CommitInfo) -> String {
format!("- {} by @{} in {}\n", commit.subject(), commit.author, &commit.short_id)
format!(
"- {} by @{} in {}\n",
commit.subject(),
commit.author,
&commit.short_id
)
}
fn group_commits<'a>(&self, commits: &'a [CommitInfo]) -> HashMap<String, Vec<&'a CommitInfo>> {
let mut groups: HashMap<String, Vec<&'a CommitInfo>> = HashMap::new();
for commit in commits {
let commit_type = commit.commit_type().unwrap_or_else(|| "other".to_string());
groups.entry(commit_type).or_default().push(commit);
}
groups
}
}
@@ -380,8 +383,7 @@ impl Default for ChangelogGenerator {
/// Read existing changelog
pub fn read_changelog(path: &Path) -> Result<String> {
fs::read_to_string(path)
.with_context(|| format!("Failed to read changelog: {:?}", path))
fs::read_to_string(path).with_context(|| format!("Failed to read changelog: {:?}", path))
}
/// Initialize new changelog file
@@ -389,10 +391,10 @@ pub fn init_changelog(path: &Path) -> Result<()> {
if path.exists() {
anyhow::bail!("Changelog already exists at {:?}", path);
}
fs::write(path, CHANGELOG_HEADER)
.with_context(|| format!("Failed to create changelog: {:?}", path))?;
Ok(())
}
@@ -403,21 +405,17 @@ pub fn generate_from_history(
to_ref: Option<&str>,
) -> Result<Vec<CommitInfo>> {
let to_ref = to_ref.unwrap_or("HEAD");
if let Some(from) = from_tag {
repo.get_commits_between(from, to_ref)
} else {
// Get last 50 commits if no tag specified
repo.get_commits(50)
// Get all commits from the beginning (no from_tag = initial changelog)
repo.get_commits(usize::MAX)
}
}
/// Update version links in changelog
pub fn update_version_links(
changelog: &str,
version: &str,
compare_url: &str,
) -> String {
pub fn update_version_links(changelog: &str, version: &str, compare_url: &str) -> String {
// Add version link at the end of changelog
format!("{}\n[{}]: {}\n", changelog, version, compare_url)
}
@@ -425,30 +423,29 @@ pub fn update_version_links(
/// Parse changelog to extract versions
pub fn parse_versions(changelog: &str) -> Vec<(String, String)> {
let mut versions = vec![];
for line in changelog.lines() {
if line.starts_with("## [") {
if let Some(start) = line.find('[') {
if let Some(end) = line.find(']') {
let version = &line[start + 1..end];
if version != "Unreleased" {
if let Some(date_start) = line.find(" - ") {
let date = &line[date_start + 3..].trim();
versions.push((version.to_string(), date.to_string()));
}
}
}
if line.starts_with("## [")
&& let Some(start) = line.find('[')
&& let Some(end) = line.find(']')
{
let version = &line[start + 1..end];
if version != "Unreleased"
&& let Some(date_start) = line.find(" - ")
{
let date = &line[date_start + 3..].trim();
versions.push((version.to_string(), date.to_string()));
}
}
}
versions
}
/// Get unreleased changes
pub fn get_unreleased_changes(repo: &GitRepo) -> Result<Vec<CommitInfo>> {
let tags = repo.get_tags()?;
if let Some(latest_tag) = tags.first() {
repo.get_commits_between(&latest_tag.name, "HEAD")
} else {
@@ -479,3 +476,137 @@ impl ChangelogEntry {
self
}
}
/// Extract version strings from Keep a Changelog content.
/// Returns versions sorted by semver descending (highest first).
/// Excludes "Unreleased".
pub fn parse_changelog_versions(content: &str) -> Vec<String> {
use semver::Version;
let mut versions: Vec<String> = vec![];
for line in content.lines() {
let trimmed = line.trim();
if trimmed.starts_with("## [") {
if let Some(end) = trimmed.find(']') {
let version = &trimmed[4..end];
if version != "Unreleased" && !version.is_empty() {
versions.push(version.to_string());
}
}
}
}
// Sort by semver descending so highest version is first
versions.sort_by(|a, b| {
match (Version::parse(a), Version::parse(b)) {
(Ok(va), Ok(vb)) => vb.cmp(&va),
(Ok(_), Err(_)) => std::cmp::Ordering::Less,
(Err(_), Ok(_)) => std::cmp::Ordering::Greater,
(Err(_), Err(_)) => std::cmp::Ordering::Equal,
}
});
versions
}
/// Insert a new changelog entry into existing changelog content.
/// The new entry is placed after the header and before the first existing version section.
/// If no existing version sections are found, the new entry is appended after the header.
pub fn insert_changelog_entry(existing: &str, new_entry: &str) -> String {
// Find the first version section (## [x.y.z])
if let Some(first_ver_pos) = existing.find("\n## [") {
let (header, rest) = existing.split_at(first_ver_pos);
// Ensure new_entry ends with a blank line before the next section
let entry = new_entry.trim_end();
format!("{}\n{}\n{}", header.trim_end(), entry, rest)
} else {
// No existing version sections — append after header
format!("{}\n{}", existing.trim_end(), new_entry.trim_end())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_changelog_versions_extracts_versions() {
let content = "# Changelog\n\n## [0.2.0] - 2024-03-01\n### Added\n- feature B\n\n## [0.1.0] - 2024-02-01\n### Added\n- feature A\n";
let versions = parse_changelog_versions(content);
assert_eq!(versions, vec!["0.2.0", "0.1.0"]);
}
#[test]
fn test_parse_changelog_versions_sorts_highest_first() {
// Versions in reversed order — should still return highest first
let content = "# Changelog\n\n## [0.1.0] - 2024-02-01\n### Added\n- feature A\n\n## [0.3.0] - 2024-04-01\n### Added\n- feature C\n\n## [0.2.0] - 2024-03-01\n### Added\n- feature B\n";
let versions = parse_changelog_versions(content);
assert_eq!(versions, vec!["0.3.0", "0.2.0", "0.1.0"]);
}
#[test]
fn test_parse_changelog_versions_excludes_unreleased() {
let content = "# Changelog\n\n## [Unreleased]\n### Added\n- wip\n\n## [0.1.0] - 2024-02-01\n### Added\n- feature A\n";
let versions = parse_changelog_versions(content);
assert_eq!(versions, vec!["0.1.0"]);
}
#[test]
fn test_parse_changelog_versions_empty_content() {
let versions = parse_changelog_versions("");
assert!(versions.is_empty());
}
#[test]
fn test_parse_changelog_versions_no_versions() {
let content = "# Changelog\n\nSome description text.\n";
let versions = parse_changelog_versions(content);
assert!(versions.is_empty());
}
#[test]
fn test_insert_changelog_entry_between_header_and_existing() {
let existing = "# Changelog\n\nAll notable changes...\n\n## [0.1.0] - 2024-02-01\n### Added\n- feature A\n";
let new_entry = "## [0.2.0] - 2024-03-01\n### Added\n- feature B\n";
let result = insert_changelog_entry(existing, new_entry);
// New entry should appear after header, before 0.1.0
assert!(result.contains("## [0.2.0]"));
assert!(result.contains("## [0.1.0]"));
let pos_new = result.find("## [0.2.0]").unwrap();
let pos_old = result.find("## [0.1.0]").unwrap();
assert!(pos_new < pos_old, "new version should be before old version");
}
#[test]
fn test_insert_changelog_entry_preserves_all_existing_content() {
let existing = "# Changelog\n\nAll notable changes...\n\n## [0.2.0] - 2024-03-01\n### Added\n- feature B\n\n## [0.1.0] - 2024-02-01\n### Added\n- feature A\n";
let new_entry = "## [0.3.0] - 2024-04-01\n### Added\n- feature C\n";
let result = insert_changelog_entry(existing, new_entry);
assert!(result.contains("## [0.3.0]"));
assert!(result.contains("## [0.2.0]"));
assert!(result.contains("## [0.1.0]"));
assert!(result.contains("feature A"), "oldest content preserved");
assert!(result.contains("feature B"), "middle content preserved");
assert!(result.contains("feature C"), "new content present");
}
#[test]
fn test_insert_changelog_entry_empty_existing() {
let existing = "# Changelog\n\nAll notable changes...\n\n";
let new_entry = "## [0.1.0] - 2024-02-01\n### Added\n- feature A\n";
let result = insert_changelog_entry(existing, new_entry);
assert!(result.contains("## [0.1.0]"));
assert!(result.contains("# Changelog"));
}
#[test]
fn test_insert_changelog_entry_extra_blank_lines_in_header() {
let existing = "# Changelog\n\n\n\n## [0.1.0] - 2024-02-01\n### Added\n- feature A\n";
let new_entry = "## [0.2.0] - 2024-03-01\n### Added\n- feature B\n";
let result = insert_changelog_entry(existing, new_entry);
let pos_new = result.find("## [0.2.0]").unwrap();
let pos_old = result.find("## [0.1.0]").unwrap();
assert!(pos_new < pos_old, "new version should be before old version");
}
}

View File

@@ -1,5 +1,5 @@
use super::GitRepo;
use anyhow::{bail, Result};
use anyhow::{Result, bail};
use chrono::Local;
/// Commit builder for creating commits
@@ -119,10 +119,14 @@ impl CommitBuilder {
return Ok(msg.clone());
}
let commit_type = self.commit_type.as_ref()
let commit_type = self
.commit_type
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Commit type is required"))?;
let description = self.description.as_ref()
let description = self
.description
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Description is required"))?;
let message = match self.format {
@@ -166,45 +170,46 @@ impl CommitBuilder {
fn amend_commit(&self, repo: &GitRepo, message: &str) -> Result<()> {
use std::process::Command;
let mut args = vec!["commit", "--amend"];
if self.no_verify {
args.push("--no-verify");
}
args.push("-m");
args.push(message);
if self.sign {
args.push("-S");
}
let output = Command::new("git")
.args(&args)
.current_dir(repo.path())
.output()?;
if !output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let error_msg = if stderr.is_empty() {
if stdout.is_empty() {
"GPG signing failed. Please check:\n\
1. GPG signing key is configured (git config --get user.signingkey)\n\
2. GPG agent is running\n\
3. You can sign commits manually (try: git commit --amend -S)".to_string()
3. You can sign commits manually (try: git commit --amend -S)"
.to_string()
} else {
stdout.to_string()
}
} else {
stderr.to_string()
};
bail!("Failed to amend commit: {}", error_msg);
}
Ok(())
}
}
@@ -219,7 +224,7 @@ impl Default for CommitBuilder {
pub fn create_date_commit_message(prefix: Option<&str>) -> String {
let now = Local::now();
let date_str = now.format("%Y-%m-%d").to_string();
match prefix {
Some(p) => format!("{}: {}", p, date_str),
None => format!("chore: update {}", date_str),
@@ -229,58 +234,65 @@ pub fn create_date_commit_message(prefix: Option<&str>) -> String {
/// Commit type suggestions based on diff
pub fn suggest_commit_type(diff: &str) -> Vec<&'static str> {
let mut suggestions = vec![];
// Check for test files
if diff.contains("test") || diff.contains("spec") || diff.contains("__tests__") {
suggestions.push("test");
}
// Check for documentation
if diff.contains("README") || diff.contains(".md") || diff.contains("docs/") {
suggestions.push("docs");
}
// Check for configuration files
if diff.contains("config") || diff.contains(".json") || diff.contains(".yaml") || diff.contains(".toml") {
if diff.contains("config")
|| diff.contains(".json")
|| diff.contains(".yaml")
|| diff.contains(".toml")
{
suggestions.push("chore");
}
// Check for dependencies
if diff.contains("Cargo.toml") || diff.contains("package.json") || diff.contains("requirements.txt") {
if diff.contains("Cargo.toml")
|| diff.contains("package.json")
|| diff.contains("requirements.txt")
{
suggestions.push("build");
}
// Check for CI
if diff.contains(".github/") || diff.contains(".gitlab-") || diff.contains("Jenkinsfile") {
suggestions.push("ci");
}
// Default suggestions
if suggestions.is_empty() {
suggestions.extend(&["feat", "fix", "refactor"]);
}
suggestions
}
/// Parse existing commit message
pub fn parse_commit_message(message: &str) -> ParsedCommit {
let lines: Vec<&str> = message.lines().collect();
if lines.is_empty() {
return ParsedCommit::default();
}
let first_line = lines[0];
// Try to parse as conventional commit
if let Some(colon_pos) = first_line.find(':') {
let type_part = &first_line[..colon_pos];
let description = first_line[colon_pos + 1..].trim();
let breaking = type_part.ends_with('!');
let type_part = type_part.trim_end_matches('!');
let (commit_type, scope) = if let Some(open) = type_part.find('(') {
if let Some(close) = type_part.find(')') {
let t = &type_part[..open];
@@ -292,42 +304,51 @@ pub fn parse_commit_message(message: &str) -> ParsedCommit {
} else {
(Some(type_part.to_string()), None)
};
// Extract body and footer
let mut body_lines = vec![];
let mut footer_lines = vec![];
let mut in_footer = false;
for line in &lines[1..] {
if line.trim().is_empty() {
continue;
}
if line.starts_with("BREAKING CHANGE:") ||
line.starts_with("Closes") ||
line.starts_with("Fixes") ||
line.starts_with("Refs") ||
line.starts_with("Co-authored-by:") {
if line.starts_with("BREAKING CHANGE:")
|| line.starts_with("Closes")
|| line.starts_with("Fixes")
|| line.starts_with("Refs")
|| line.starts_with("Co-authored-by:")
{
in_footer = true;
}
if in_footer {
footer_lines.push(line.to_string());
} else {
body_lines.push(line.to_string());
}
}
return ParsedCommit {
commit_type,
scope,
description: Some(description.to_string()),
body: if body_lines.is_empty() { None } else { Some(body_lines.join("\n")) },
footer: if footer_lines.is_empty() { None } else { Some(footer_lines.join("\n")) },
body: if body_lines.is_empty() {
None
} else {
Some(body_lines.join("\n"))
},
footer: if footer_lines.is_empty() {
None
} else {
Some(footer_lines.join("\n"))
},
breaking,
};
}
// Non-conventional commit
ParsedCommit {
description: Some(first_line.to_string()),
@@ -351,7 +372,7 @@ impl ParsedCommit {
pub fn to_message(&self, format: crate::config::CommitFormat) -> String {
let commit_type = self.commit_type.as_deref().unwrap_or("chore");
let description = self.description.as_deref().unwrap_or("update");
match format {
crate::config::CommitFormat::Conventional => {
crate::utils::formatter::format_conventional_commit(

306
src/git/init.rs Normal file
View File

@@ -0,0 +1,306 @@
//! Repository creation primitives used by `quicommit ginit`.
//!
//! These live next to [`GitRepo`] rather than on it: opening an existing
//! repository and creating a new one share nothing but the handle they return.
use anyhow::{Context, Result, bail};
use git2::{Reference, Remote, Repository, RepositoryInitOptions};
use std::path::Path;
use super::GitRepo;
/// Create a repository at `path` with HEAD already attached to `initial_branch`.
pub fn init_repo<P: AsRef<Path>>(path: P, initial_branch: &str) -> Result<GitRepo> {
let path = path.as_ref();
ensure_valid_branch_name(initial_branch)?;
let mut opts = RepositoryInitOptions::new();
opts.initial_head(initial_branch);
Repository::init_opts(path, &opts).with_context(|| {
format!(
"Failed to create git repository at {} (initial branch: {})",
path.display(),
initial_branch
)
})?;
GitRepo::open(path)
.with_context(|| format!("Failed to open {} after creating it", path.display()))
}
/// Retarget HEAD to `branch`. Only meaningful while no commit exists yet.
pub fn apply_initial_branch(repo: &GitRepo, branch: &str) -> Result<()> {
ensure_valid_branch_name(branch)?;
repo.inner()
.set_head(&format!("refs/heads/{branch}"))
.with_context(|| format!("Failed to set the initial branch to {branch}"))
}
pub fn is_valid_remote_name(name: &str) -> bool {
// libgit2 accepts a leading '-', which would make the remote unusable as a
// command line argument (`git push --delete -lead-dash`).
!name.starts_with('-') && Remote::is_valid_name(name)
}
/// Register a remote. An existing name is refused rather than overwritten:
/// repointing a remote is the user's call, made with `git remote set-url`.
///
/// No name or existence pre-check lives here: `git_remote_create` errors on an
/// existing name, and the caller validates names before it gets this far.
pub fn add_remote(repo: &GitRepo, name: &str, url: &str) -> Result<()> {
repo.inner()
.remote(name, url)
.with_context(|| format!("Failed to add remote '{name}' pointing to {url}"))?;
Ok(())
}
fn ensure_valid_branch_name(branch: &str) -> Result<()> {
if Reference::is_valid_name(&format!("refs/heads/{branch}")) {
return Ok(());
}
bail!("Invalid branch name: {branch}")
}
/// Whether the repository already contains a commit.
///
/// `Repository::is_empty` is unusable here: it only reports empty when HEAD
/// matches `init.defaultbranch`, so a repository created on a custom initial
/// branch looks non-empty to it.
pub fn has_commits(repo: &GitRepo) -> bool {
repo.inner().head().is_ok()
}
/// The branch the repository is on, including one with no commit yet.
///
/// `GitRepo::current_branch` reads `Repository::head`, which fails on an unborn
/// branch even though HEAD still names the branch the repository starts on.
pub fn head_branch(repo: &GitRepo) -> Option<String> {
if let Ok(head) = repo.inner().head() {
return head.shorthand().map(str::to_string);
}
repo.inner()
.find_reference("HEAD")
.ok()?
.symbolic_target()
.and_then(|target| target.strip_prefix("refs/heads/"))
.map(str::to_string)
}
/// Open the repository rooted exactly at `path`, without searching upwards.
///
/// `GitRepo::open` discovers an enclosing repository, which is wrong here: a
/// plain subdirectory of a repository must read as "not a repository yet".
pub fn open_repo_at(path: &Path) -> Result<GitRepo> {
let repo = Repository::open(path)
.with_context(|| format!("No git repository rooted at {}", path.display()))?;
let workdir = repo
.workdir()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| path.to_path_buf());
let config = repo.config().ok();
Ok(GitRepo {
repo,
path: super::normalize_path_for_git2(&workdir),
config,
})
}
pub fn is_bare(repo: &GitRepo) -> bool {
repo.inner().is_bare()
}
/// The branch git would start a new repository on: `init.defaultBranch`, when
/// the user has set it to something non-empty.
pub fn configured_initial_branch() -> Option<String> {
git2::Config::open_default()
.ok()?
.get_string("init.defaultBranch")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
#[cfg(test)]
mod tests {
use crate::git::init::{
add_remote, apply_initial_branch, has_commits, head_branch, init_repo, is_bare,
is_valid_remote_name, open_repo_at,
};
use std::fs;
use tempfile::TempDir;
#[test]
fn test_init_repo_attaches_head_to_requested_initial_branch() {
let dir = TempDir::new().unwrap();
let repo = init_repo(dir.path(), "dev").unwrap();
let head = repo.inner().find_reference("HEAD").unwrap();
assert_eq!(
head.symbolic_target(),
Some("refs/heads/dev"),
"HEAD should be attached to the requested Initial branch"
);
assert!(
!has_commits(&repo),
"a freshly created repository has no commits yet"
);
assert!(
dir.path().join(".git").exists(),
"the repository should be created at the requested path"
);
}
#[test]
fn test_init_repo_reports_branch_name_when_branch_is_invalid() {
let dir = TempDir::new().unwrap();
let err = init_repo(dir.path(), "bad branch")
.err()
.expect("an invalid branch name should be refused")
.to_string();
assert!(
err.contains("bad branch"),
"error should name the offending branch so the user knows what to fix, got: {err}"
);
}
#[test]
fn test_apply_initial_branch_moves_unborn_head_only() {
let dir = TempDir::new().unwrap();
let repo = init_repo(dir.path(), "main").unwrap();
apply_initial_branch(&repo, "topic").unwrap();
let head = repo.inner().find_reference("HEAD").unwrap();
assert_eq!(head.symbolic_target(), Some("refs/heads/topic"));
}
#[test]
fn test_add_remote_records_name_and_url() {
let dir = TempDir::new().unwrap();
let repo = init_repo(dir.path(), "main").unwrap();
add_remote(&repo, "origin", "https://example.com/origin.git").unwrap();
assert_eq!(
repo.get_remote_url("origin").unwrap(),
"https://example.com/origin.git"
);
}
#[test]
fn test_add_remote_rejects_a_name_that_already_exists() {
let dir = TempDir::new().unwrap();
let repo = init_repo(dir.path(), "main").unwrap();
add_remote(&repo, "origin", "https://example.com/first.git").unwrap();
let err = add_remote(&repo, "origin", "https://example.com/second.git")
.unwrap_err()
.to_string();
assert!(
err.contains("origin"),
"error should name the conflicting remote, got: {err}"
);
assert_eq!(
repo.get_remote_url("origin").unwrap(),
"https://example.com/first.git",
"an existing remote URL must never be overwritten"
);
}
#[test]
fn test_has_commits_detects_the_first_commit() {
let dir = TempDir::new().unwrap();
let repo = init_repo(dir.path(), "main").unwrap();
let sig = git2::Signature::now("Test User", "test@example.com").unwrap();
let tree = {
let tree_id = repo.inner().index().unwrap().write_tree().unwrap();
repo.inner().find_tree(tree_id).unwrap()
};
assert!(!has_commits(&repo));
repo.inner()
.commit(Some("HEAD"), &sig, &sig, "first commit", &tree, &[])
.unwrap();
assert!(has_commits(&repo));
}
#[test]
fn test_head_branch_reads_an_unborn_branch() {
let dir = TempDir::new().unwrap();
let repo = init_repo(dir.path(), "dev").unwrap();
let sig = git2::Signature::now("Test User", "test@example.com").unwrap();
let tree = {
let tree_id = repo.inner().index().unwrap().write_tree().unwrap();
repo.inner().find_tree(tree_id).unwrap()
};
assert_eq!(head_branch(&repo).as_deref(), Some("dev"));
assert!(
repo.current_branch().is_err(),
"current_branch reads HEAD's commit, which an unborn branch does not have yet"
);
repo.inner()
.commit(Some("HEAD"), &sig, &sig, "first commit", &tree, &[])
.unwrap();
assert_eq!(head_branch(&repo).as_deref(), Some("dev"));
}
#[test]
fn test_open_repo_at_opens_the_repository_at_that_exact_path() {
let dir = TempDir::new().unwrap();
let repo = init_repo(dir.path(), "main").unwrap();
let reopened = open_repo_at(dir.path()).unwrap();
assert!(!is_bare(&reopened));
assert!(!has_commits(&reopened));
assert_eq!(reopened.path(), repo.path());
}
#[test]
fn test_open_repo_at_refuses_to_walk_up_to_a_parent_repository() {
let dir = TempDir::new().unwrap();
init_repo(dir.path(), "main").unwrap();
let nested = dir.path().join("sub");
fs::create_dir(&nested).unwrap();
assert!(
open_repo_at(&nested).is_err(),
"a plain subdirectory is not a repository of its own"
);
}
#[test]
fn test_is_bare_flags_a_repository_without_working_directory() {
let dir = TempDir::new().unwrap();
git2::Repository::init_bare(dir.path()).unwrap();
let repo = open_repo_at(dir.path()).unwrap();
assert!(is_bare(&repo));
}
#[test]
fn test_is_valid_remote_name_accepts_plain_names_and_rejects_unusable_ones() {
assert!(is_valid_remote_name("origin"));
assert!(is_valid_remote_name("upstream"));
assert!(!is_valid_remote_name(""));
assert!(!is_valid_remote_name("not allowed"));
assert!(!is_valid_remote_name("-lead-dash"));
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,7 @@
use super::GitRepo;
use anyhow::{bail, Result};
use anyhow::{Result, bail};
use semver::Version;
use std::path::Path;
/// Tag builder for creating tags
pub struct TagBuilder {
@@ -69,19 +70,19 @@ impl TagBuilder {
/// Build tag message
pub fn build_message(&self) -> Result<String> {
let message = self.message.as_ref()
.cloned()
.unwrap_or_else(|| {
let name = self.name.as_deref().unwrap_or("unknown");
format!("Release {}", name)
});
let message = self.message.as_ref().cloned().unwrap_or_else(|| {
let name = self.name.as_deref().unwrap_or("unknown");
format!("Release {}", name)
});
Ok(message)
}
/// Execute tag creation
pub fn execute(&self, repo: &GitRepo) -> Result<()> {
let name = self.name.as_ref()
let name = self
.name
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Tag name is required"))?;
if !self.force {
@@ -105,10 +106,10 @@ impl TagBuilder {
/// Execute and push tag
pub fn execute_and_push(&self, repo: &GitRepo, remote: &str) -> Result<()> {
self.execute(repo)?;
let name = self.name.as_ref().unwrap();
repo.push(remote, &format!("refs/tags/{}", name))?;
Ok(())
}
}
@@ -136,7 +137,10 @@ impl VersionBump {
"minor" => Ok(Self::Minor),
"patch" => Ok(Self::Patch),
"prerelease" | "pre" => Ok(Self::Prerelease),
_ => bail!("Invalid version bump: {}. Use: major, minor, patch, prerelease", s),
_ => bail!(
"Invalid version bump: {}. Use: major, minor, patch, prerelease",
s
),
}
}
@@ -149,7 +153,7 @@ impl VersionBump {
/// Get latest version tag from repository
pub fn get_latest_version(repo: &GitRepo, prefix: &str) -> Result<Option<Version>> {
let tags = repo.get_tags()?;
let mut versions: Vec<Version> = tags
.iter()
.filter_map(|t| {
@@ -158,9 +162,9 @@ pub fn get_latest_version(repo: &GitRepo, prefix: &str) -> Result<Option<Version
Version::parse(version_str).ok()
})
.collect();
versions.sort_by(|a, b| b.cmp(a)); // Descending order
Ok(versions.into_iter().next())
}
@@ -183,14 +187,17 @@ pub fn suggest_version_bump(commits: &[super::CommitInfo]) -> VersionBump {
let mut has_breaking = false;
let mut has_feature = false;
let mut has_fix = false;
for commit in commits {
let msg = commit.message.to_lowercase();
if msg.contains("breaking change") || msg.contains("breaking-change") || msg.contains("breaking_change") {
if msg.contains("breaking change")
|| msg.contains("breaking-change")
|| msg.contains("breaking_change")
{
has_breaking = true;
}
if let Some(commit_type) = commit.commit_type() {
match commit_type.as_str() {
"feat" => has_feature = true,
@@ -199,7 +206,7 @@ pub fn suggest_version_bump(commits: &[super::CommitInfo]) -> VersionBump {
}
}
}
if has_breaking {
VersionBump::Major
} else if has_feature {
@@ -214,20 +221,20 @@ pub fn suggest_version_bump(commits: &[super::CommitInfo]) -> VersionBump {
/// Generate tag message from commits
pub fn generate_tag_message(version: &str, commits: &[super::CommitInfo]) -> String {
let mut message = format!("Release {}\n\n", version);
// Group commits by type
let mut features = vec![];
let mut fixes = vec![];
let mut other = vec![];
let mut breaking = vec![];
for commit in commits {
let subject = commit.subject();
if commit.message.contains("BREAKING CHANGE") {
breaking.push(subject.to_string());
}
if let Some(commit_type) = commit.commit_type() {
match commit_type.as_str() {
"feat" => features.push(subject.to_string()),
@@ -238,7 +245,7 @@ pub fn generate_tag_message(version: &str, commits: &[super::CommitInfo]) -> Str
other.push(subject.to_string());
}
}
// Build message
if !breaking.is_empty() {
message.push_str("## Breaking Changes\n\n");
@@ -247,7 +254,7 @@ pub fn generate_tag_message(version: &str, commits: &[super::CommitInfo]) -> Str
}
message.push('\n');
}
if !features.is_empty() {
message.push_str("## Features\n\n");
for item in &features {
@@ -255,7 +262,7 @@ pub fn generate_tag_message(version: &str, commits: &[super::CommitInfo]) -> Str
}
message.push('\n');
}
if !fixes.is_empty() {
message.push_str("## Bug Fixes\n\n");
for item in &fixes {
@@ -263,36 +270,36 @@ pub fn generate_tag_message(version: &str, commits: &[super::CommitInfo]) -> Str
}
message.push('\n');
}
if !other.is_empty() {
message.push_str("## Other Changes\n\n");
for item in &other {
message.push_str(&format!("- {}\n", item));
}
}
message
}
/// Tag deletion helper
pub fn delete_tag(repo: &GitRepo, name: &str, remote: Option<&str>) -> Result<()> {
repo.delete_tag(name)?;
if let Some(remote) = remote {
use std::process::Command;
let refspec = format!(":refs/tags/{}", name);
let output = Command::new("git")
.args(&["push", remote, &refspec])
.args(["push", remote, &refspec])
.current_dir(repo.path())
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("Failed to delete remote tag: {}", stderr);
}
}
Ok(())
}
@@ -303,7 +310,7 @@ pub fn list_tags(
limit: Option<usize>,
) -> Result<Vec<super::TagInfo>> {
let tags = repo.get_tags()?;
let filtered: Vec<_> = tags
.into_iter()
.filter(|t| {
@@ -314,10 +321,463 @@ pub fn list_tags(
}
})
.collect();
if let Some(limit) = limit {
Ok(filtered.into_iter().take(limit).collect())
} else {
Ok(filtered)
}
}
/// Sort tags by semver version descending, then by time descending.
/// Non-semver tags are placed after semver tags, sorted by time descending.
pub fn sort_tags_by_semver(tags: &mut [super::TagInfo]) {
use semver::Version;
tags.sort_by(|a, b| {
match (
Version::parse(a.version_name()),
Version::parse(b.version_name()),
) {
(Ok(va), Ok(vb)) => {
// Both semver: version descending, then time descending as tiebreaker
vb.cmp(&va).then_with(|| b.time.cmp(&a.time))
}
(Ok(_), Err(_)) => std::cmp::Ordering::Less, // semver tags first
(Err(_), Ok(_)) => std::cmp::Ordering::Greater, // semver tags first
(Err(_), Err(_)) => b.time.cmp(&a.time), // both non-semver: time descending
}
});
}
/// 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<ConfigVersion> {
// 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<Version>)] = 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,
});
}
}
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<Version>;
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<Version> {
let content = std::fs::read_to_string(dir.join("Cargo.toml")).ok()?;
let value = content.parse::<toml::Value>().ok()?;
let version_str = value.get("package")?.get("version")?.as_str()?;
Version::parse(version_str).ok()
}
fn try_pyproject_toml(dir: &Path) -> Option<Version> {
let content = std::fs::read_to_string(dir.join("pyproject.toml")).ok()?;
let value = content.parse::<toml::Value>().ok()?;
let version_str = value.get("project")?.get("version")?.as_str()?;
Version::parse(version_str).ok()
}
fn try_project_toml_julia(dir: &Path) -> Option<Version> {
let content = std::fs::read_to_string(dir.join("Project.toml")).ok()?;
let value = content.parse::<toml::Value>().ok()?;
let version_str = value.get("version")?.as_str()?;
Version::parse(version_str).ok()
}
fn try_package_json(dir: &Path) -> Option<Version> {
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<Version> {
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<Version> {
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 <version> that is a direct child of <project>, not inside <parent>
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<Version> {
// 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 <Version> (preferred) or <VersionPrefix> 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<Version> {
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<Version> {
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<Version> {
// 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<Version> {
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<Version> {
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<Version> {
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<Version> {
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<Version> {
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::*;
use crate::git::TagInfo;
fn make_tag(name: &str, time: i64) -> TagInfo {
TagInfo {
name: name.to_string(),
target: "abc123".to_string(),
message: String::new(),
time,
}
}
#[test]
fn test_sort_by_semver_descending() {
let mut tags = vec![
make_tag("v1.0.0", 100),
make_tag("v2.0.0", 200),
make_tag("v1.5.0", 150),
];
sort_tags_by_semver(&mut tags);
let names: Vec<&str> = tags.iter().map(|t| t.name.as_str()).collect();
assert_eq!(names, vec!["v2.0.0", "v1.5.0", "v1.0.0"]);
}
#[test]
fn test_sort_semver_ties_broken_by_time() {
let mut tags = vec![
make_tag("v1.0.0", 100),
make_tag("v1.0.0", 300),
make_tag("v1.0.0", 200),
];
sort_tags_by_semver(&mut tags);
let times: Vec<i64> = tags.iter().map(|t| t.time).collect();
assert_eq!(times, vec![300, 200, 100]);
}
#[test]
fn test_sort_non_semver_tags_at_end() {
let mut tags = vec![
make_tag("release-2024", 400),
make_tag("v1.0.0", 100),
make_tag("staging", 300),
make_tag("v0.2.0", 200),
];
sort_tags_by_semver(&mut tags);
let names: Vec<&str> = tags.iter().map(|t| t.name.as_str()).collect();
assert_eq!(names[0], "v1.0.0");
assert_eq!(names[1], "v0.2.0");
assert_eq!(names[2], "release-2024");
assert_eq!(names[3], "staging");
}
#[test]
fn test_sort_no_prefix_semver() {
let mut tags = vec![
make_tag("1.0.0", 100),
make_tag("2.0.0", 200),
make_tag("0.1.0", 50),
];
sort_tags_by_semver(&mut tags);
let names: Vec<&str> = tags.iter().map(|t| t.name.as_str()).collect();
assert_eq!(names, vec!["2.0.0", "1.0.0", "0.1.0"]);
}
#[test]
fn test_sort_all_non_semver_by_time() {
let mut tags = vec![
make_tag("release-2024", 100),
make_tag("release-2023", 300),
make_tag("beta", 200),
];
sort_tags_by_semver(&mut tags);
let times: Vec<i64> = tags.iter().map(|t| t.time).collect();
assert_eq!(times, vec![300, 200, 100]);
}
#[test]
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 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_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 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_versions_no_config_files() {
let dir = tempfile::TempDir::new().unwrap();
let versions = read_project_versions(dir.path());
assert!(versions.is_empty());
}
#[test]
fn test_read_versions_multiple_configs_returns_all() {
let dir = tempfile::TempDir::new().unwrap();
std::fs::write(
dir.path().join("Cargo.toml"),
"[package]\nname = \"test\"\nversion = \"0.3.0\"\n",
)
.unwrap();
std::fs::write(
dir.path().join("pyproject.toml"),
"[project]\nname = \"test\"\nversion = \"0.2.1\"\n",
)
.unwrap();
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"),
"<project><modelVersion>4.0.0</modelVersion><version>2.0.0</version></project>",
)
.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"),
"<project><parent><version>3.0.0</version></parent><version>2.0.0</version></project>",
)
.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));
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,11 @@ pub struct Translator {
}
impl Translator {
pub fn new(language: Language, keep_types_english: bool, keep_changelog_types_english: bool) -> Self {
pub fn new(
language: Language,
keep_types_english: bool,
keep_changelog_types_english: bool,
) -> Self {
Self {
language,
keep_types_english,
@@ -227,7 +231,11 @@ pub fn translate_commit_type(commit_type: &str, language: Language, keep_english
translator.translate_commit_type(commit_type)
}
pub fn translate_changelog_category(category: &str, language: Language, keep_english: bool) -> String {
pub fn translate_changelog_category(
category: &str,
language: Language,
keep_english: bool,
) -> String {
let translator = Translator::new(language, true, keep_english);
translator.translate_changelog_category(category)
}

7
src/lib.rs Normal file
View File

@@ -0,0 +1,7 @@
pub mod commands;
pub mod config;
pub mod generator;
pub mod git;
pub mod i18n;
pub mod llm;
pub mod utils;

View File

@@ -1,233 +0,0 @@
use super::{create_http_client, LlmProvider};
use anyhow::{bail, Context, Result};
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use std::time::Duration;
/// Anthropic Claude API client
pub struct AnthropicClient {
api_key: String,
model: String,
client: reqwest::Client,
}
#[derive(Debug, Serialize)]
struct MessagesRequest {
model: String,
max_tokens: u32,
#[serde(skip_serializing_if = "Option::is_none")]
temperature: Option<f32>,
messages: Vec<AnthropicMessage>,
#[serde(skip_serializing_if = "Option::is_none")]
system: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
struct AnthropicMessage {
role: String,
content: String,
}
#[derive(Debug, Deserialize)]
struct MessagesResponse {
content: Vec<ContentBlock>,
}
#[derive(Debug, Deserialize)]
struct ContentBlock {
#[serde(rename = "type")]
content_type: String,
text: String,
}
#[derive(Debug, Deserialize)]
struct ErrorResponse {
error: AnthropicError,
}
#[derive(Debug, Deserialize)]
struct AnthropicError {
#[serde(rename = "type")]
error_type: String,
message: String,
}
impl AnthropicClient {
/// Create new Anthropic client
pub fn new(api_key: &str, model: &str) -> Result<Self> {
let client = create_http_client(Duration::from_secs(60))?;
Ok(Self {
api_key: api_key.to_string(),
model: model.to_string(),
client,
})
}
/// Set timeout
pub fn with_timeout(mut self, timeout: Duration) -> Result<Self> {
self.client = create_http_client(timeout)?;
Ok(self)
}
/// List available models
pub async fn list_models(&self) -> Result<Vec<String>> {
// Anthropic doesn't have a models API endpoint, return predefined list
Ok(ANTHROPIC_MODELS.iter().map(|&m| m.to_string()).collect())
}
/// Validate API key
pub async fn validate_key(&self) -> Result<bool> {
let url = "https://api.anthropic.com/v1/messages";
let request = MessagesRequest {
model: self.model.clone(),
max_tokens: 5,
temperature: Some(0.0),
messages: vec![AnthropicMessage {
role: "user".to_string(),
content: "Hi".to_string(),
}],
system: None,
};
let response = self.client
.post(url)
.header("x-api-key", &self.api_key)
.header("anthropic-version", "2023-06-01")
.header("Content-Type", "application/json")
.json(&request)
.send()
.await;
match response {
Ok(resp) => {
if resp.status().is_success() {
Ok(true)
} else {
let status = resp.status();
if status.as_u16() == 401 {
Ok(false)
} else {
let text = resp.text().await.unwrap_or_default();
bail!("Anthropic API error: {} - {}", status, text)
}
}
}
Err(e) => Err(e.into()),
}
}
}
#[async_trait]
impl LlmProvider for AnthropicClient {
async fn generate(&self, prompt: &str) -> Result<String> {
let messages = vec![AnthropicMessage {
role: "user".to_string(),
content: prompt.to_string(),
}];
self.messages_request(messages, None).await
}
async fn generate_with_system(&self, system: &str, user: &str) -> Result<String> {
let messages = vec![AnthropicMessage {
role: "user".to_string(),
content: user.to_string(),
}];
let system = if system.is_empty() {
None
} else {
Some(system.to_string())
};
self.messages_request(messages, system).await
}
async fn is_available(&self) -> bool {
self.validate_key().await.unwrap_or(false)
}
fn name(&self) -> &str {
"anthropic"
}
}
impl AnthropicClient {
async fn messages_request(
&self,
messages: Vec<AnthropicMessage>,
system: Option<String>,
) -> Result<String> {
let url = "https://api.anthropic.com/v1/messages";
let request = MessagesRequest {
model: self.model.clone(),
max_tokens: 500,
temperature: Some(0.7),
messages,
system,
};
let response = self.client
.post(url)
.header("x-api-key", &self.api_key)
.header("anthropic-version", "2023-06-01")
.header("Content-Type", "application/json")
.json(&request)
.send()
.await
.context("Failed to send request to Anthropic")?;
let status = response.status();
if !status.is_success() {
let text = response.text().await.unwrap_or_default();
// Try to parse error
if let Ok(error) = serde_json::from_str::<ErrorResponse>(&text) {
bail!("Anthropic API error: {} ({})", error.error.message, error.error.error_type);
}
bail!("Anthropic API error: {} - {}", status, text);
}
let result: MessagesResponse = response
.json()
.await
.context("Failed to parse Anthropic response")?;
result.content
.into_iter()
.find(|c| c.content_type == "text")
.map(|c| c.text.trim().to_string())
.ok_or_else(|| anyhow::anyhow!("No text response from Anthropic"))
}
}
/// Available Anthropic models
pub const ANTHROPIC_MODELS: &[&str] = &[
"claude-3-opus-20240229",
"claude-3-sonnet-20240229",
"claude-3-haiku-20240307",
"claude-2.1",
"claude-2.0",
"claude-instant-1.2",
];
/// Check if a model name is valid
pub fn is_valid_model(model: &str) -> bool {
ANTHROPIC_MODELS.contains(&model)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_model_validation() {
assert!(is_valid_model("claude-3-sonnet-20240229"));
assert!(!is_valid_model("invalid-model"));
}
}

View File

@@ -1,243 +0,0 @@
use super::{create_http_client, LlmProvider};
use anyhow::{bail, Context, Result};
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use std::time::Duration;
/// DeepSeek API client
pub struct DeepSeekClient {
base_url: String,
api_key: String,
model: String,
client: reqwest::Client,
}
#[derive(Debug, Serialize)]
struct ChatCompletionRequest {
model: String,
messages: Vec<Message>,
#[serde(skip_serializing_if = "Option::is_none")]
max_tokens: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
temperature: Option<f32>,
stream: bool,
}
#[derive(Debug, Serialize, Deserialize)]
struct Message {
role: String,
content: String,
}
#[derive(Debug, Deserialize)]
struct ChatCompletionResponse {
choices: Vec<Choice>,
}
#[derive(Debug, Deserialize)]
struct Choice {
message: Message,
}
#[derive(Debug, Deserialize)]
struct ErrorResponse {
error: ApiError,
}
#[derive(Debug, Deserialize)]
struct ApiError {
message: String,
#[serde(rename = "type")]
error_type: String,
}
impl DeepSeekClient {
/// Create new DeepSeek client
pub fn new(api_key: &str, model: &str) -> Result<Self> {
let client = create_http_client(Duration::from_secs(60))?;
Ok(Self {
base_url: "https://api.deepseek.com/v1".to_string(),
api_key: api_key.to_string(),
model: model.to_string(),
client,
})
}
/// Create with custom base URL
pub fn with_base_url(api_key: &str, model: &str, base_url: &str) -> Result<Self> {
let client = create_http_client(Duration::from_secs(60))?;
Ok(Self {
base_url: base_url.trim_end_matches('/').to_string(),
api_key: api_key.to_string(),
model: model.to_string(),
client,
})
}
/// Set timeout
pub fn with_timeout(mut self, timeout: Duration) -> Result<Self> {
self.client = create_http_client(timeout)?;
Ok(self)
}
/// List available models
pub async fn list_models(&self) -> Result<Vec<String>> {
let url = format!("{}/models", self.base_url);
let response = self.client
.get(&url)
.header("Authorization", format!("Bearer {}", self.api_key))
.send()
.await
.context("Failed to list DeepSeek models")?;
if !response.status().is_success() {
let status = response.status();
let text = response.text().await.unwrap_or_default();
bail!("DeepSeek API error: {} - {}", status, text);
}
#[derive(Deserialize)]
struct ModelsResponse {
data: Vec<Model>,
}
#[derive(Deserialize)]
struct Model {
id: String,
}
let result: ModelsResponse = response
.json()
.await
.context("Failed to parse DeepSeek response")?;
Ok(result.data.into_iter().map(|m| m.id).collect())
}
/// Validate API key
pub async fn validate_key(&self) -> Result<bool> {
match self.list_models().await {
Ok(_) => Ok(true),
Err(e) => {
let err_str = e.to_string();
if err_str.contains("401") || err_str.contains("Unauthorized") {
Ok(false)
} else {
Err(e)
}
}
}
}
}
#[async_trait]
impl LlmProvider for DeepSeekClient {
async fn generate(&self, prompt: &str) -> Result<String> {
let messages = vec![
Message {
role: "user".to_string(),
content: prompt.to_string(),
},
];
self.chat_completion(messages).await
}
async fn generate_with_system(&self, system: &str, user: &str) -> Result<String> {
let mut messages = vec![];
if !system.is_empty() {
messages.push(Message {
role: "system".to_string(),
content: system.to_string(),
});
}
messages.push(Message {
role: "user".to_string(),
content: user.to_string(),
});
self.chat_completion(messages).await
}
async fn is_available(&self) -> bool {
self.validate_key().await.unwrap_or(false)
}
fn name(&self) -> &str {
"deepseek"
}
}
impl DeepSeekClient {
async fn chat_completion(&self, messages: Vec<Message>) -> Result<String> {
let url = format!("{}/chat/completions", self.base_url);
let request = ChatCompletionRequest {
model: self.model.clone(),
messages,
max_tokens: Some(500),
temperature: Some(0.7),
stream: false,
};
let response = self.client
.post(&url)
.header("Authorization", format!("Bearer {}", self.api_key))
.header("Content-Type", "application/json")
.json(&request)
.send()
.await
.context("Failed to send request to DeepSeek")?;
let status = response.status();
if !status.is_success() {
let text = response.text().await.unwrap_or_default();
// Try to parse error
if let Ok(error) = serde_json::from_str::<ErrorResponse>(&text) {
bail!("DeepSeek API error: {} ({})", error.error.message, error.error.error_type);
}
bail!("DeepSeek API error: {} - {}", status, text);
}
let result: ChatCompletionResponse = response
.json()
.await
.context("Failed to parse DeepSeek response")?;
result.choices
.into_iter()
.next()
.map(|c| c.message.content.trim().to_string())
.ok_or_else(|| anyhow::anyhow!("No response from DeepSeek"))
}
}
/// Available DeepSeek models
pub const DEEPSEEK_MODELS: &[&str] = &[
"deepseek-chat",
"deepseek-coder",
];
/// Check if a model name is valid
pub fn is_valid_model(model: &str) -> bool {
DEEPSEEK_MODELS.contains(&model)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_model_validation() {
assert!(is_valid_model("deepseek-chat"));
assert!(!is_valid_model("invalid-model"));
}
}

View File

@@ -1,244 +0,0 @@
use super::{create_http_client, LlmProvider};
use anyhow::{bail, Context, Result};
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use std::time::Duration;
/// Kimi API client (Moonshot AI)
pub struct KimiClient {
base_url: String,
api_key: String,
model: String,
client: reqwest::Client,
}
#[derive(Debug, Serialize)]
struct ChatCompletionRequest {
model: String,
messages: Vec<Message>,
#[serde(skip_serializing_if = "Option::is_none")]
max_tokens: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
temperature: Option<f32>,
stream: bool,
}
#[derive(Debug, Serialize, Deserialize)]
struct Message {
role: String,
content: String,
}
#[derive(Debug, Deserialize)]
struct ChatCompletionResponse {
choices: Vec<Choice>,
}
#[derive(Debug, Deserialize)]
struct Choice {
message: Message,
}
#[derive(Debug, Deserialize)]
struct ErrorResponse {
error: ApiError,
}
#[derive(Debug, Deserialize)]
struct ApiError {
message: String,
#[serde(rename = "type")]
error_type: String,
}
impl KimiClient {
/// Create new Kimi client
pub fn new(api_key: &str, model: &str) -> Result<Self> {
let client = create_http_client(Duration::from_secs(60))?;
Ok(Self {
base_url: "https://api.moonshot.cn/v1".to_string(),
api_key: api_key.to_string(),
model: model.to_string(),
client,
})
}
/// Create with custom base URL
pub fn with_base_url(api_key: &str, model: &str, base_url: &str) -> Result<Self> {
let client = create_http_client(Duration::from_secs(60))?;
Ok(Self {
base_url: base_url.trim_end_matches('/').to_string(),
api_key: api_key.to_string(),
model: model.to_string(),
client,
})
}
/// Set timeout
pub fn with_timeout(mut self, timeout: Duration) -> Result<Self> {
self.client = create_http_client(timeout)?;
Ok(self)
}
/// List available models
pub async fn list_models(&self) -> Result<Vec<String>> {
let url = format!("{}/models", self.base_url);
let response = self.client
.get(&url)
.header("Authorization", format!("Bearer {}", self.api_key))
.send()
.await
.context("Failed to list Kimi models")?;
if !response.status().is_success() {
let status = response.status();
let text = response.text().await.unwrap_or_default();
bail!("Kimi API error: {} - {}", status, text);
}
#[derive(Deserialize)]
struct ModelsResponse {
data: Vec<Model>,
}
#[derive(Deserialize)]
struct Model {
id: String,
}
let result: ModelsResponse = response
.json()
.await
.context("Failed to parse Kimi response")?;
Ok(result.data.into_iter().map(|m| m.id).collect())
}
/// Validate API key
pub async fn validate_key(&self) -> Result<bool> {
match self.list_models().await {
Ok(_) => Ok(true),
Err(e) => {
let err_str = e.to_string();
if err_str.contains("401") || err_str.contains("Unauthorized") {
Ok(false)
} else {
Err(e)
}
}
}
}
}
#[async_trait]
impl LlmProvider for KimiClient {
async fn generate(&self, prompt: &str) -> Result<String> {
let messages = vec![
Message {
role: "user".to_string(),
content: prompt.to_string(),
},
];
self.chat_completion(messages).await
}
async fn generate_with_system(&self, system: &str, user: &str) -> Result<String> {
let mut messages = vec![];
if !system.is_empty() {
messages.push(Message {
role: "system".to_string(),
content: system.to_string(),
});
}
messages.push(Message {
role: "user".to_string(),
content: user.to_string(),
});
self.chat_completion(messages).await
}
async fn is_available(&self) -> bool {
self.validate_key().await.unwrap_or(false)
}
fn name(&self) -> &str {
"kimi"
}
}
impl KimiClient {
async fn chat_completion(&self, messages: Vec<Message>) -> Result<String> {
let url = format!("{}/chat/completions", self.base_url);
let request = ChatCompletionRequest {
model: self.model.clone(),
messages,
max_tokens: Some(500),
temperature: Some(0.7),
stream: false,
};
let response = self.client
.post(&url)
.header("Authorization", format!("Bearer {}", self.api_key))
.header("Content-Type", "application/json")
.json(&request)
.send()
.await
.context("Failed to send request to Kimi")?;
let status = response.status();
if !status.is_success() {
let text = response.text().await.unwrap_or_default();
// Try to parse error
if let Ok(error) = serde_json::from_str::<ErrorResponse>(&text) {
bail!("Kimi API error: {} ({})", error.error.message, error.error.error_type);
}
bail!("Kimi API error: {} - {}", status, text);
}
let result: ChatCompletionResponse = response
.json()
.await
.context("Failed to parse Kimi response")?;
result.choices
.into_iter()
.next()
.map(|c| c.message.content.trim().to_string())
.ok_or_else(|| anyhow::anyhow!("No response from Kimi"))
}
}
/// Available Kimi models
pub const KIMI_MODELS: &[&str] = &[
"moonshot-v1-8k",
"moonshot-v1-32k",
"moonshot-v1-128k",
];
/// Check if a model name is valid
pub fn is_valid_model(model: &str) -> bool {
KIMI_MODELS.contains(&model)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_model_validation() {
assert!(is_valid_model("moonshot-v1-8k"));
assert!(!is_valid_model("invalid-model"));
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,206 +0,0 @@
use super::{create_http_client, LlmProvider};
use anyhow::{Context, Result};
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use std::time::Duration;
/// Ollama API client
pub struct OllamaClient {
base_url: String,
model: String,
client: reqwest::Client,
}
#[derive(Debug, Serialize)]
struct GenerateRequest {
model: String,
prompt: String,
system: Option<String>,
stream: bool,
options: GenerationOptions,
}
#[derive(Debug, Serialize, Default)]
struct GenerationOptions {
#[serde(skip_serializing_if = "Option::is_none")]
temperature: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
num_predict: Option<u32>,
}
#[derive(Debug, Deserialize)]
struct GenerateResponse {
response: String,
done: bool,
}
#[derive(Debug, Deserialize)]
struct ListModelsResponse {
models: Vec<ModelInfo>,
}
#[derive(Debug, Deserialize)]
struct ModelInfo {
name: String,
}
impl OllamaClient {
/// Create new Ollama client
pub fn new(base_url: &str, model: &str) -> Self {
let client = create_http_client(Duration::from_secs(120))
.expect("Failed to create HTTP client");
Self {
base_url: base_url.trim_end_matches('/').to_string(),
model: model.to_string(),
client,
}
}
/// Set timeout
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.client = create_http_client(timeout)
.expect("Failed to create HTTP client");
self
}
/// List available models
pub async fn list_models(&self) -> Result<Vec<String>> {
let url = format!("{}/api/tags", self.base_url);
let response = self.client
.get(&url)
.send()
.await
.context("Failed to list Ollama models")?;
if !response.status().is_success() {
let status = response.status();
let text = response.text().await.unwrap_or_default();
anyhow::bail!("Ollama API error: {} - {}", status, text);
}
let result: ListModelsResponse = response
.json()
.await
.context("Failed to parse Ollama response")?;
Ok(result.models.into_iter().map(|m| m.name).collect())
}
/// Pull a model
pub async fn pull_model(&self, model: &str) -> Result<()> {
let url = format!("{}/api/pull", self.base_url);
let request = serde_json::json!({
"name": model,
"stream": false,
});
let response = self.client
.post(&url)
.json(&request)
.send()
.await
.context("Failed to pull Ollama model")?;
if !response.status().is_success() {
let status = response.status();
let text = response.text().await.unwrap_or_default();
anyhow::bail!("Ollama pull error: {} - {}", status, text);
}
Ok(())
}
/// Check if model exists
pub async fn model_exists(&self, model: &str) -> bool {
match self.list_models().await {
Ok(models) => models.contains(&model.to_string()),
Err(_) => false,
}
}
}
#[async_trait]
impl LlmProvider for OllamaClient {
async fn generate(&self, prompt: &str) -> Result<String> {
self.generate_with_system("", prompt).await
}
async fn generate_with_system(&self, system: &str, user: &str) -> Result<String> {
let url = format!("{}/api/generate", self.base_url);
let system = if system.is_empty() {
None
} else {
Some(system.to_string())
};
let request = GenerateRequest {
model: self.model.clone(),
prompt: user.to_string(),
system,
stream: false,
options: GenerationOptions {
temperature: Some(0.7),
num_predict: Some(500),
},
};
let response = self.client
.post(&url)
.json(&request)
.send()
.await
.context("Failed to send request to Ollama")?;
if !response.status().is_success() {
let status = response.status();
let text = response.text().await.unwrap_or_default();
anyhow::bail!("Ollama API error: {} - {}", status, text);
}
let result: GenerateResponse = response
.json()
.await
.context("Failed to parse Ollama response")?;
Ok(result.response.trim().to_string())
}
async fn is_available(&self) -> bool {
let url = format!("{}/api/tags", self.base_url);
match self.client.get(&url).send().await {
Ok(response) => response.status().is_success(),
Err(_) => false,
}
}
fn name(&self) -> &str {
"ollama"
}
}
#[cfg(test)]
mod tests {
use super::*;
// These tests require a running Ollama server
#[tokio::test]
#[ignore]
async fn test_ollama_connection() {
let client = OllamaClient::new("http://localhost:11434", "llama2");
assert!(client.is_available().await);
}
#[tokio::test]
#[ignore]
async fn test_ollama_generate() {
let client = OllamaClient::new("http://localhost:11434", "llama2");
let response = client.generate("Hello, how are you?").await;
assert!(response.is_ok());
println!("Response: {}", response.unwrap());
}
}

View File

@@ -1,345 +0,0 @@
use super::{create_http_client, LlmProvider};
use anyhow::{bail, Context, Result};
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use std::time::Duration;
/// OpenAI API client
pub struct OpenAiClient {
base_url: String,
api_key: String,
model: String,
client: reqwest::Client,
}
#[derive(Debug, Serialize)]
struct ChatCompletionRequest {
model: String,
messages: Vec<Message>,
#[serde(skip_serializing_if = "Option::is_none")]
max_tokens: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
temperature: Option<f32>,
stream: bool,
}
#[derive(Debug, Serialize, Deserialize)]
struct Message {
role: String,
content: String,
}
#[derive(Debug, Deserialize)]
struct ChatCompletionResponse {
choices: Vec<Choice>,
}
#[derive(Debug, Deserialize)]
struct Choice {
message: Message,
}
#[derive(Debug, Deserialize)]
struct ErrorResponse {
error: ApiError,
}
#[derive(Debug, Deserialize)]
struct ApiError {
message: String,
#[serde(rename = "type")]
error_type: String,
}
impl OpenAiClient {
/// Create new OpenAI client
pub fn new(base_url: &str, api_key: &str, model: &str) -> Result<Self> {
let client = create_http_client(Duration::from_secs(60))?;
Ok(Self {
base_url: base_url.trim_end_matches('/').to_string(),
api_key: api_key.to_string(),
model: model.to_string(),
client,
})
}
/// Set timeout
pub fn with_timeout(mut self, timeout: Duration) -> Result<Self> {
self.client = create_http_client(timeout)?;
Ok(self)
}
/// List available models
pub async fn list_models(&self) -> Result<Vec<String>> {
let url = format!("{}/models", self.base_url);
let response = self.client
.get(&url)
.header("Authorization", format!("Bearer {}", self.api_key))
.send()
.await
.context("Failed to list OpenAI models")?;
if !response.status().is_success() {
let status = response.status();
let text = response.text().await.unwrap_or_default();
bail!("OpenAI API error: {} - {}", status, text);
}
#[derive(Deserialize)]
struct ModelsResponse {
data: Vec<Model>,
}
#[derive(Deserialize)]
struct Model {
id: String,
}
let result: ModelsResponse = response
.json()
.await
.context("Failed to parse OpenAI response")?;
Ok(result.data.into_iter().map(|m| m.id).collect())
}
/// Validate API key
pub async fn validate_key(&self) -> Result<bool> {
match self.list_models().await {
Ok(_) => Ok(true),
Err(e) => {
let err_str = e.to_string();
if err_str.contains("401") || err_str.contains("Unauthorized") {
Ok(false)
} else {
Err(e)
}
}
}
}
}
#[async_trait]
impl LlmProvider for OpenAiClient {
async fn generate(&self, prompt: &str) -> Result<String> {
let messages = vec![
Message {
role: "user".to_string(),
content: prompt.to_string(),
},
];
self.chat_completion(messages).await
}
async fn generate_with_system(&self, system: &str, user: &str) -> Result<String> {
let mut messages = vec![];
if !system.is_empty() {
messages.push(Message {
role: "system".to_string(),
content: system.to_string(),
});
}
messages.push(Message {
role: "user".to_string(),
content: user.to_string(),
});
self.chat_completion(messages).await
}
async fn is_available(&self) -> bool {
self.validate_key().await.unwrap_or(false)
}
fn name(&self) -> &str {
"openai"
}
}
impl OpenAiClient {
async fn chat_completion(&self, messages: Vec<Message>) -> Result<String> {
let url = format!("{}/chat/completions", self.base_url);
let request = ChatCompletionRequest {
model: self.model.clone(),
messages,
max_tokens: Some(500),
temperature: Some(0.7),
stream: false,
};
let response = self.client
.post(&url)
.header("Authorization", format!("Bearer {}", self.api_key))
.header("Content-Type", "application/json")
.json(&request)
.send()
.await
.context("Failed to send request to OpenAI")?;
let status = response.status();
if !status.is_success() {
let text = response.text().await.unwrap_or_default();
// Try to parse error
if let Ok(error) = serde_json::from_str::<ErrorResponse>(&text) {
bail!("OpenAI API error: {} ({})", error.error.message, error.error.error_type);
}
bail!("OpenAI API error: {} - {}", status, text);
}
let result: ChatCompletionResponse = response
.json()
.await
.context("Failed to parse OpenAI response")?;
result.choices
.into_iter()
.next()
.map(|c| c.message.content.trim().to_string())
.ok_or_else(|| anyhow::anyhow!("No response from OpenAI"))
}
}
/// Azure OpenAI client (extends OpenAI with Azure-specific config)
pub struct AzureOpenAiClient {
endpoint: String,
api_key: String,
deployment: String,
api_version: String,
client: reqwest::Client,
}
impl AzureOpenAiClient {
/// Create new Azure OpenAI client
pub fn new(
endpoint: &str,
api_key: &str,
deployment: &str,
api_version: &str,
) -> Result<Self> {
let client = create_http_client(Duration::from_secs(60))?;
Ok(Self {
endpoint: endpoint.trim_end_matches('/').to_string(),
api_key: api_key.to_string(),
deployment: deployment.to_string(),
api_version: api_version.to_string(),
client,
})
}
async fn chat_completion(&self, messages: Vec<Message>) -> Result<String> {
let url = format!(
"{}/openai/deployments/{}/chat/completions?api-version={}",
self.endpoint, self.deployment, self.api_version
);
let request = ChatCompletionRequest {
model: self.deployment.clone(),
messages,
max_tokens: Some(500),
temperature: Some(0.7),
stream: false,
};
let response = self.client
.post(&url)
.header("api-key", &self.api_key)
.header("Content-Type", "application/json")
.json(&request)
.send()
.await
.context("Failed to send request to Azure OpenAI")?;
if !response.status().is_success() {
let status = response.status();
let text = response.text().await.unwrap_or_default();
bail!("Azure OpenAI API error: {} - {}", status, text);
}
let result: ChatCompletionResponse = response
.json()
.await
.context("Failed to parse Azure OpenAI response")?;
result.choices
.into_iter()
.next()
.map(|c| c.message.content.trim().to_string())
.ok_or_else(|| anyhow::anyhow!("No response from Azure OpenAI"))
}
}
#[async_trait]
impl LlmProvider for AzureOpenAiClient {
async fn generate(&self, prompt: &str) -> Result<String> {
let messages = vec![
Message {
role: "user".to_string(),
content: prompt.to_string(),
},
];
self.chat_completion(messages).await
}
async fn generate_with_system(&self, system: &str, user: &str) -> Result<String> {
let mut messages = vec![];
if !system.is_empty() {
messages.push(Message {
role: "system".to_string(),
content: system.to_string(),
});
}
messages.push(Message {
role: "user".to_string(),
content: user.to_string(),
});
self.chat_completion(messages).await
}
async fn is_available(&self) -> bool {
// Simple check - try to make a minimal request
let url = format!(
"{}/openai/deployments/{}/chat/completions?api-version={}",
self.endpoint, self.deployment, self.api_version
);
let request = ChatCompletionRequest {
model: self.deployment.clone(),
messages: vec![Message {
role: "user".to_string(),
content: "Hi".to_string(),
}],
max_tokens: Some(5),
temperature: Some(0.0),
stream: false,
};
match self.client
.post(&url)
.header("api-key", &self.api_key)
.json(&request)
.send()
.await
{
Ok(response) => response.status().is_success(),
Err(_) => false,
}
}
fn name(&self) -> &str {
"azure-openai"
}
}

View File

@@ -1,257 +0,0 @@
use super::{create_http_client, LlmProvider};
use anyhow::{bail, Context, Result};
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use std::time::Duration;
/// OpenRouter API client
pub struct OpenRouterClient {
base_url: String,
api_key: String,
model: String,
client: reqwest::Client,
}
#[derive(Debug, Serialize)]
struct ChatCompletionRequest {
model: String,
messages: Vec<Message>,
#[serde(skip_serializing_if = "Option::is_none")]
max_tokens: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
temperature: Option<f32>,
stream: bool,
}
#[derive(Debug, Serialize, Deserialize)]
struct Message {
role: String,
content: String,
}
#[derive(Debug, Deserialize)]
struct ChatCompletionResponse {
choices: Vec<Choice>,
}
#[derive(Debug, Deserialize)]
struct Choice {
message: Message,
}
#[derive(Debug, Deserialize)]
struct ErrorResponse {
error: ApiError,
}
#[derive(Debug, Deserialize)]
struct ApiError {
message: String,
#[serde(rename = "type")]
error_type: String,
}
impl OpenRouterClient {
/// Create new OpenRouter client
pub fn new(api_key: &str, model: &str) -> Result<Self> {
let client = create_http_client(Duration::from_secs(60))?;
Ok(Self {
base_url: "https://openrouter.ai/api/v1".to_string(),
api_key: api_key.to_string(),
model: model.to_string(),
client,
})
}
/// Create with custom base URL
pub fn with_base_url(api_key: &str, model: &str, base_url: &str) -> Result<Self> {
let client = create_http_client(Duration::from_secs(60))?;
Ok(Self {
base_url: base_url.trim_end_matches('/').to_string(),
api_key: api_key.to_string(),
model: model.to_string(),
client,
})
}
/// Set timeout
pub fn with_timeout(mut self, timeout: Duration) -> Result<Self> {
self.client = create_http_client(timeout)?;
Ok(self)
}
/// List available models
pub async fn list_models(&self) -> Result<Vec<String>> {
let url = format!("{}/models", self.base_url);
let response = self.client
.get(&url)
.header("Authorization", format!("Bearer {}", self.api_key))
.header("HTTP-Referer", "https://quicommit.dev")
.header("X-Title", "QuiCommit")
.send()
.await
.context("Failed to list OpenRouter models")?;
if !response.status().is_success() {
let status = response.status();
let text = response.text().await.unwrap_or_default();
bail!("OpenRouter API error: {} - {}", status, text);
}
#[derive(Deserialize)]
struct ModelsResponse {
data: Vec<Model>,
}
#[derive(Deserialize)]
struct Model {
id: String,
}
let result: ModelsResponse = response
.json()
.await
.context("Failed to parse OpenRouter response")?;
Ok(result.data.into_iter().map(|m| m.id).collect())
}
/// Validate API key
pub async fn validate_key(&self) -> Result<bool> {
match self.list_models().await {
Ok(_) => Ok(true),
Err(e) => {
let err_str = e.to_string();
if err_str.contains("401") || err_str.contains("Unauthorized") {
Ok(false)
} else {
Err(e)
}
}
}
}
}
#[async_trait]
impl LlmProvider for OpenRouterClient {
async fn generate(&self, prompt: &str) -> Result<String> {
let messages = vec![
Message {
role: "user".to_string(),
content: prompt.to_string(),
},
];
self.chat_completion(messages).await
}
async fn generate_with_system(&self, system: &str, user: &str) -> Result<String> {
let mut messages = vec![];
if !system.is_empty() {
messages.push(Message {
role: "system".to_string(),
content: system.to_string(),
});
}
messages.push(Message {
role: "user".to_string(),
content: user.to_string(),
});
self.chat_completion(messages).await
}
async fn is_available(&self) -> bool {
self.validate_key().await.unwrap_or(false)
}
fn name(&self) -> &str {
"openrouter"
}
}
impl OpenRouterClient {
async fn chat_completion(&self, messages: Vec<Message>) -> Result<String> {
let url = format!("{}/chat/completions", self.base_url);
let request = ChatCompletionRequest {
model: self.model.clone(),
messages,
max_tokens: Some(500),
temperature: Some(0.7),
stream: false,
};
let response = self.client
.post(&url)
.header("Authorization", format!("Bearer {}", self.api_key))
.header("Content-Type", "application/json")
.header("HTTP-Referer", "https://quicommit.dev")
.header("X-Title", "QuiCommit")
.json(&request)
.send()
.await
.context("Failed to send request to OpenRouter")?;
let status = response.status();
if !status.is_success() {
let text = response.text().await.unwrap_or_default();
// Try to parse error
if let Ok(error) = serde_json::from_str::<ErrorResponse>(&text) {
bail!("OpenRouter API error: {} ({})", error.error.message, error.error.error_type);
}
bail!("OpenRouter API error: {} - {}", status, text);
}
let result: ChatCompletionResponse = response
.json()
.await
.context("Failed to parse OpenRouter response")?;
result.choices
.into_iter()
.next()
.map(|c| c.message.content.trim().to_string())
.ok_or_else(|| anyhow::anyhow!("No response from OpenRouter"))
}
}
/// Popular OpenRouter models
pub const OPENROUTER_MODELS: &[&str] = &[
"openai/gpt-3.5-turbo",
"openai/gpt-4",
"openai/gpt-4-turbo",
"anthropic/claude-3-opus",
"anthropic/claude-3-sonnet",
"anthropic/claude-3-haiku",
"google/gemini-pro",
"meta-llama/llama-2-70b-chat",
"mistralai/mixtral-8x7b-instruct",
"01-ai/yi-34b-chat",
];
/// Check if a model name is valid
pub fn is_valid_model(_model: &str) -> bool {
// Since OpenRouter supports many models, we'll allow any model name
// but provide some popular ones as suggestions
true
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_model_validation() {
assert!(is_valid_model("openai/gpt-4"));
assert!(is_valid_model("custom/model"));
}
}

281
src/llm/parsing.rs Normal file
View File

@@ -0,0 +1,281 @@
use anyhow::{Result, bail};
/// Parse commit response from LLM
pub(crate) fn parse_commit_response(
response: &str,
format: crate::config::CommitFormat,
) -> Result<GeneratedCommit> {
// Clean markdown code fences from the response
let cleaned = strip_code_fences(response);
let lines: Vec<&str> = cleaned
.lines()
.map(|l| l.trim())
.filter(|l| !l.is_empty())
.collect();
if lines.is_empty() {
let preview: String = response.chars().take(200).collect();
bail!(
"LLM returned empty or whitespace-only response. \
Raw response preview: '{}'. \
Hint: If using DeepSeek/Kimi with thinking enabled, \
the model may have returned reasoning_content only. \
Try disabling thinking mode or switching models.",
preview
);
}
// Find the line most likely to be the commit subject
let first_line = find_commit_subject_line(&lines, format);
// Parse based on format
match format {
crate::config::CommitFormat::Conventional => {
parse_conventional_commit(first_line, &lines, response)
}
crate::config::CommitFormat::Commitlint => {
parse_commitlint_commit(first_line, &lines, response)
}
}
}
/// Remove surrounding markdown code fences (```) from LLM output
fn strip_code_fences(response: &str) -> String {
let mut lines: Vec<&str> = response.lines().collect();
// Strip leading fence lines (``` or ```lang)
while lines.first().map_or(false, |l| l.trim().starts_with("```")) {
lines.remove(0);
}
// Strip trailing fence lines
while lines.last().map_or(false, |l| l.trim() == "```") {
lines.pop();
}
lines.join("\n")
}
/// Find the line that is most likely the commit subject among extracted lines
fn find_commit_subject_line<'a>(
lines: &[&'a str],
format: crate::config::CommitFormat,
) -> &'a str {
let valid_types = crate::utils::validators::get_commit_types(matches!(
format,
crate::config::CommitFormat::Commitlint
));
// First pass: line starting with a known type that also has proper syntax
// (e.g. "type:", "type(scope):", "type!:")
for &line in lines {
let trimmed = line.trim();
for &t in valid_types {
if let Some(rest) = trimmed.strip_prefix(t) {
if rest.starts_with(':') || rest.starts_with('(') || rest.starts_with("!:") {
return trimmed;
}
}
}
}
// Second pass: any line containing a colon (generic "prefix: description")
for &line in lines {
if line.contains(':') {
return line.trim();
}
}
// Fallback: return the first line as-is
lines[0].trim()
}
fn parse_conventional_commit(
first_line: &str,
lines: &[&str],
raw_response: &str,
) -> Result<GeneratedCommit> {
// Parse: type(scope)!: description
let parts: Vec<&str> = first_line.splitn(2, ':').collect();
if parts.len() != 2 {
let preview: String = raw_response.chars().take(300).collect();
bail!(
"Invalid conventional commit format: missing colon.\n\
Parsed subject line: '{}'\n\
Raw response preview: '{}'\n\
Expected: <type>[optional scope]: <description>",
first_line,
preview
);
}
let type_part = parts[0];
let description = parts[1].trim();
// Extract type, scope, and breaking indicator
let breaking = type_part.ends_with('!');
let type_part = type_part.trim_end_matches('!');
let (commit_type, scope) = if let Some(start) = type_part.find('(') {
if let Some(end) = type_part.find(')') {
let t = &type_part[..start];
let s = &type_part[start + 1..end];
(t.to_string(), Some(s.to_string()))
} else {
bail!("Invalid scope format: missing closing parenthesis");
}
} else {
(type_part.to_string(), None)
};
// Extract body and footer
let (body, footer) = extract_body_footer(lines);
Ok(GeneratedCommit {
commit_type,
scope,
description: description.to_string(),
body,
footer,
breaking,
})
}
fn parse_commitlint_commit(
first_line: &str,
lines: &[&str],
raw_response: &str,
) -> Result<GeneratedCommit> {
// Similar parsing but with commitlint rules
let parts: Vec<&str> = first_line.splitn(2, ':').collect();
if parts.len() != 2 {
let preview: String = raw_response.chars().take(300).collect();
bail!(
"Invalid commit format: missing colon.\n\
Parsed subject line: '{}'\n\
Raw response preview: '{}'\n\
Expected: <type>[optional scope]: <subject>",
first_line,
preview
);
}
let type_part = parts[0];
let subject = parts[1].trim();
let (commit_type, scope) = if let Some(start) = type_part.find('(') {
if let Some(end) = type_part.find(')') {
let t = &type_part[..start];
let s = &type_part[start + 1..end];
(t.to_string(), Some(s.to_string()))
} else {
(type_part.to_string(), None)
}
} else {
(type_part.to_string(), None)
};
let (body, footer) = extract_body_footer(&lines);
Ok(GeneratedCommit {
commit_type,
scope,
description: subject.to_string(),
body,
footer,
breaking: false,
})
}
fn extract_body_footer(lines: &[&str]) -> (Option<String>, Option<String>) {
if lines.len() <= 1 {
return (None, None);
}
let rest: Vec<&str> = lines[1..]
.iter()
.skip_while(|l| l.trim().is_empty())
.copied()
.collect();
if rest.is_empty() {
return (None, None);
}
// Look for footer markers
let footer_markers = [
"BREAKING CHANGE:",
"Closes",
"Fixes",
"Refs",
"Co-authored-by:",
];
let mut body_lines = vec![];
let mut footer_lines = vec![];
let mut in_footer = false;
for line in &rest {
if footer_markers.iter().any(|m| line.starts_with(m)) {
in_footer = true;
}
if in_footer {
footer_lines.push(*line);
} else {
body_lines.push(*line);
}
}
let body = if body_lines.is_empty() {
None
} else {
Some(body_lines.join("\n"))
};
let footer = if footer_lines.is_empty() {
None
} else {
Some(footer_lines.join("\n"))
};
(body, footer)
}
/// Generated commit structure
#[derive(Debug, Clone)]
pub struct GeneratedCommit {
pub commit_type: String,
pub scope: Option<String>,
pub description: String,
pub body: Option<String>,
pub footer: Option<String>,
pub breaking: bool,
}
impl GeneratedCommit {
/// Format as conventional commit
pub fn to_conventional(&self) -> String {
crate::utils::formatter::format_conventional_commit(
&self.commit_type,
self.scope.as_deref(),
&self.description,
self.body.as_deref(),
self.footer.as_deref(),
self.breaking,
)
}
/// Format as commitlint commit
pub fn to_commitlint(&self) -> String {
crate::utils::formatter::format_commitlint_commit(
&self.commit_type,
self.scope.as_deref(),
&self.description,
self.body.as_deref(),
self.footer.as_deref(),
None,
)
}
}

618
src/llm/prompts.rs Normal file
View File

@@ -0,0 +1,618 @@
use crate::config::Language;
/// Get commit system prompt based on format and language
pub(crate) fn get_commit_system_prompt(
format: crate::config::CommitFormat,
language: Language,
) -> &'static str {
match (format, language) {
(crate::config::CommitFormat::Conventional, Language::Chinese) => {
CONVENTIONAL_COMMIT_SYSTEM_PROMPT_ZH
}
(crate::config::CommitFormat::Conventional, Language::Japanese) => {
CONVENTIONAL_COMMIT_SYSTEM_PROMPT_JA
}
(crate::config::CommitFormat::Conventional, Language::Korean) => {
CONVENTIONAL_COMMIT_SYSTEM_PROMPT_KO
}
(crate::config::CommitFormat::Conventional, Language::Spanish) => {
CONVENTIONAL_COMMIT_SYSTEM_PROMPT_ES
}
(crate::config::CommitFormat::Conventional, Language::French) => {
CONVENTIONAL_COMMIT_SYSTEM_PROMPT_FR
}
(crate::config::CommitFormat::Conventional, Language::German) => {
CONVENTIONAL_COMMIT_SYSTEM_PROMPT_DE
}
(crate::config::CommitFormat::Conventional, _) => CONVENTIONAL_COMMIT_SYSTEM_PROMPT,
(crate::config::CommitFormat::Commitlint, Language::Chinese) => COMMITLINT_SYSTEM_PROMPT_ZH,
(crate::config::CommitFormat::Commitlint, Language::Japanese) => {
COMMITLINT_SYSTEM_PROMPT_JA
}
(crate::config::CommitFormat::Commitlint, Language::Korean) => COMMITLINT_SYSTEM_PROMPT_KO,
(crate::config::CommitFormat::Commitlint, Language::Spanish) => COMMITLINT_SYSTEM_PROMPT_ES,
(crate::config::CommitFormat::Commitlint, Language::French) => COMMITLINT_SYSTEM_PROMPT_FR,
(crate::config::CommitFormat::Commitlint, Language::German) => COMMITLINT_SYSTEM_PROMPT_DE,
(crate::config::CommitFormat::Commitlint, _) => COMMITLINT_SYSTEM_PROMPT,
}
}
pub(crate) fn get_tag_system_prompt(language: Language) -> &'static str {
match language {
Language::Chinese => TAG_MESSAGE_SYSTEM_PROMPT_ZH,
Language::Japanese => TAG_MESSAGE_SYSTEM_PROMPT_JA,
Language::Korean => TAG_MESSAGE_SYSTEM_PROMPT_KO,
Language::Spanish => TAG_MESSAGE_SYSTEM_PROMPT_ES,
Language::French => TAG_MESSAGE_SYSTEM_PROMPT_FR,
Language::German => TAG_MESSAGE_SYSTEM_PROMPT_DE,
_ => TAG_MESSAGE_SYSTEM_PROMPT,
}
}
pub(crate) fn get_changelog_system_prompt(language: Language) -> &'static str {
match language {
Language::Chinese => CHANGELOG_SYSTEM_PROMPT_ZH,
Language::Japanese => CHANGELOG_SYSTEM_PROMPT_JA,
Language::Korean => CHANGELOG_SYSTEM_PROMPT_KO,
Language::Spanish => CHANGELOG_SYSTEM_PROMPT_ES,
Language::French => CHANGELOG_SYSTEM_PROMPT_FR,
Language::German => CHANGELOG_SYSTEM_PROMPT_DE,
_ => CHANGELOG_SYSTEM_PROMPT,
}
}
// System prompts for LLM
const CONVENTIONAL_COMMIT_SYSTEM_PROMPT: &str = r#"You are a helpful assistant that generates conventional commit messages.
Analyze the git diff provided and generate a commit message following the Conventional Commits specification.
Format: <type>[optional scope]: <description>
Types:
- feat: A new feature
- fix: A bug fix
- docs: Documentation only changes
- style: Changes that don't affect code meaning (formatting, semicolons, etc.)
- refactor: Code change that neither fixes a bug nor adds a feature
- perf: Code change that improves performance
- test: Adding or correcting tests
- build: Changes to build system or dependencies
- ci: Changes to CI configuration
- chore: Other changes that don't modify src or test files
- revert: Reverts a previous commit
Rules:
1. Use lowercase for type and scope
2. Keep description under 100 characters
3. Use imperative mood ("add" not "added")
4. Don't capitalize first letter
5. No period at the end
6. Include scope if the change is specific to a module/component
Output ONLY the commit message, nothing else.
"#;
const CONVENTIONAL_COMMIT_SYSTEM_PROMPT_ZH: &str = r#"你是一个生成符合 Conventional Commits 规范的提交消息的助手。
分析提供的 git diff,并生成符合 Conventional Commits 规范的提交消息。
格式: <type>[可选作用域]: <描述>
类型:
- feat: 新功能
- fix: 修复错误
- docs: 仅文档更改
- style: 不影响代码含义的更改(格式化、分号等)
- refactor: 既不修复错误也不添加功能的代码更改
- perf: 提高性能的代码更改
- test: 添加或更正测试
- build: 更改构建系统或依赖项
- ci: 更改 CI 配置
- chore: 其他不修改 src 或测试文件的更改
- revert: 撤销之前的提交
规则:
1. 类型和小写使用小写
2. 描述保持在 100 个字符以内
3. 使用祈使语气("添加"而不是"已添加")
4. 不要大写首字母
5. 结尾不要句号
6. 如果更改特定于模块/组件,请包含作用域
7. 仅输出提交消息,不要输出其他内容。
"#;
const CONVENTIONAL_COMMIT_SYSTEM_PROMPT_JA: &str = r#"あなたはConventional Commits仕様に従ったコミットメッセージを生成するアシスタントです。
提供されたgit diffを分析し、Conventional Commits仕様に従ったコミットメッセージを生成してください。
形式: <type>[オプションのスコープ]: <説明>
タイプ:
- feat: 新機能
- fix: バグ修正
- docs: ドキュメントのみの変更
- style: コードの意味に影響しない変更(フォーマット、セミコロンなど)
- refactor: バグ修正や機能追加を伴わないコード変更
- perf: パフォーマンスを向上させるコード変更
- test: テストの追加または修正
- build: ビルドシステムまたは依存関係の変更
- ci: CI設定の変更
- chore: srcやテストファイルを変更しないその他の変更
- revert: 以前のコミットを取り消す
ルール:
1. タイプとスコープは小文字を使用
2. 説明は100文字以内にする
3. 命令形を使用する("追加"ではなく"追加する")
4. 先頭を大文字にしない
5. 最後にピリオドを付けない
6. 変更がモジュール/コンポーネントに固有の場合はスコープを含める
7. コミットメッセージのみを出力し、それ以外は出力しないでください。
"#;
const CONVENTIONAL_COMMIT_SYSTEM_PROMPT_KO: &str = r#"당신은 Conventional Commits 사양에 따른 커밋 메시지를 생성하는 도우미입니다.
제공된 git diff를 분석하고 Conventional Commits 사양에 따른 커밋 메시지를 생성하세요.
형식: <type>[선택적 범위]: <설명>
유형:
- feat: 새 기능
- fix: 버그 수정
- docs: 문서 변경만
- style: 코드 의미에 영향을 주지 않는 변경(서식, 세미콜론 등)
- refactor: 버그를 수정하거나 기능을 추가하지 않는 코드 변경
- perf: 성능을 향상시키는 코드 변경
- test: 테스트 추가 또는 수정
- build: 빌드 시스템 또는 종속성 변경
- ci: CI 구성 변경
- chore: src 또는 테스트 파일을 수정하지 않는 기타 변경
- revert: 이전 커밋 되돌리기
규칙:
1. 유형과 범위는 소문자 사용
2. 설명은 100자 이내로 유지
3. 명령형 사용("추가"가 아닌 "추가하다")
4. 첫 글자 대문자화하지 않음
5. 끝에 마침표 사용하지 않음
6. 변경 사항이 모듈/구성 요소에 특정한 경우 범위 포함
7. 커밋 메시지만 출력하고 다른 내용은 출력하지 마세요.
"#;
const CONVENTIONAL_COMMIT_SYSTEM_PROMPT_ES: &str = r#"Eres un asistente que genera mensajes de commit siguiendo la especificación Conventional Commits.
Analiza el diff de git proporcionado y genera un mensaje de commit siguiendo la especificación Conventional Commits.
Formato: <tipo>[alcance opcional]: <descripción>
Tipos:
- feat: Una nueva característica
- fix: Una corrección de error
- docs: Solo cambios en documentación
- style: Cambios que no afectan el significado del código (formato, punto y coma, etc.)
- refactor: Cambio de código que no corrige un error ni agrega una característica
- perf: Cambio de código que mejora el rendimiento
- test: Agregar o corregir pruebas
- build: Cambios en el sistema de construcción o dependencias
- ci: Cambios en la configuración de CI
- chore: Otros cambios que no modifican archivos src o de prueba
- revert: Revierte un commit anterior
Reglas:
1. Usa minúsculas para tipo y alcance
2. Mantén la descripción bajo 100 caracteres
3. Usa modo imperativo ("agregar" no "agregado")
4. No capitalices la primera letra
5. Sin punto al final
6. Incluye alcance si el cambio es específico de un módulo/componente
7. Genera SOLO el mensaje de commit, nada más.
"#;
const CONVENTIONAL_COMMIT_SYSTEM_PROMPT_FR: &str = r#"Vous êtes un assistant qui génère des messages de commit suivant la spécification Conventional Commits.
Analysez le diff git fourni et générez un message de commit suivant la spécification Conventional Commits.
Format: <type>[portée optionnelle]: <description>
Types:
- feat: Une nouvelle fonctionnalité
- fix: Une correction de bug
- docs: Changements de documentation uniquement
- style: Changements qui n'affectent pas la signification du code (formatage, points-virgules, etc.)
- refactor: Changement de code qui ne corrige pas un bug ni n'ajoute une fonctionnalité
- perf: Changement de code qui améliore les performances
- test: Ajout ou correction de tests
- build: Changements du système de build ou des dépendances
- ci: Changements de la configuration CI
- chore: Autres changements qui ne modifient pas les fichiers src ou de test
- revert: Révertit un commit précédent
Règles:
1. Utilisez des minuscules pour le type et la portée
2. Gardez la description sous 100 caractères
3. Utilisez le mode impératif ("ajouter" non "ajouté")
4. Ne capitalisez pas la première lettre
5. Pas de point à la fin
6. Incluez la portée si le changement est spécifique à un module/composant
7. Générez SEULEMENT le message de commit, rien d'autre.
"#;
const CONVENTIONAL_COMMIT_SYSTEM_PROMPT_DE: &str = r#"Sie sind ein Assistent, der Commit-Nachrichten gemäß der Conventional Commits-Spezifikation generiert.
Analysieren Sie den bereitgestellten git diff und generieren Sie eine Commit-Nachricht gemäß der Conventional Commits-Spezifikation.
Format: <typ>[optionaler Bereich]: <beschreibung>
Typen:
- feat: Eine neue Funktion
- fix: Ein Bugfix
- docs: Nur Dokumentationsänderungen
- style: Änderungen, die die Code-Bedeutung nicht beeinflussen (Formatierung, Semikolons usw.)
- refactor: Code-Änderung, die weder einen Bug behebt noch eine Funktion hinzufügt
- perf: Code-Änderung, die die Leistung verbessert
- test: Hinzufügen oder Korrigieren von Tests
- build: Änderungen am Build-System oder Abhängigkeiten
- ci: Änderungen an der CI-Konfiguration
- chore: Andere Änderungen, die src- oder Testdateien nicht ändern
- revert: Setzt einen vorherigen Commit zurück
Regeln:
1. Verwenden Sie Kleinbuchstaben für Typ und Bereich
2. Halten Sie die Beschreibung unter 100 Zeichen
3. Verwenden Sie den Imperativ ("hinzufügen" nicht "hinzugefügt")
4. Großschreiben Sie den ersten Buchstaben nicht
5. Kein Punkt am Ende
6. Fügen Sie einen Bereich ein, wenn die Änderung spezifisch für ein Modul/Komponente ist
7. Geben Sie NUR die Commit-Nachricht aus, nichts anderes.
"#;
const COMMITLINT_SYSTEM_PROMPT: &str = r#"You are a helpful assistant that generates commit messages following @commitlint/config-conventional.
Analyze the git diff and generate a commit message.
Format: <type>[optional scope]: <subject>
Types: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert
Rules:
1. Subject should not start with uppercase
2. Subject should not end with period
3. Subject should be 4-100 characters
4. Use imperative mood
5. Be concise but descriptive
6. Output ONLY the commit message, nothing else.
"#;
const COMMITLINT_SYSTEM_PROMPT_ZH: &str = r#"你是一个生成符合 @commitlint/config-conventional 规范的提交消息的助手。
分析 git diff 并生成提交消息。
格式: <type>[可选作用域]: <主题>
类型: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert
规则:
1. 主题不应以大写字母开头
2. 主题不应以句号结尾
3. 主题应为 4-100 个字符
4. 使用祈使语气
5. 简洁但描述性强
6. 仅输出提交消息,不要输出其他额外内容。
"#;
const COMMITLINT_SYSTEM_PROMPT_JA: &str = r#"あなたは@commitlint/config-conventionalに従ったコミットメッセージを生成するアシスタントです。
git diffを分析し、コミットメッセージを生成してください。
形式: <type>[オプションのスコープ]: <件名>
タイプ: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert
ルール:
1. 件名は大文字で始めないでください
2. 件名はピリオドで終わらないでください
3. 件名は4-100文字である必要があります
4. 命令形を使用してください
5. 簡潔ですが説明的であること
6. コミットメッセージのみを出力し、それ以外は出力しないでください。
"#;
const COMMITLINT_SYSTEM_PROMPT_KO: &str = r#"당신은 @commitlint/config-conventional에 따른 커밋 메시지를 생성하는 도우미입니다.
git diff를 분석하고 커밋 메시지를 생성하세요.
형식: <type>[선택적 범위]: <제목>
유형: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert
규칙:
1. 제목은 대문자로 시작하지 않아야 합니다
2. 제목은 마침표로 끝나지 않아야 합니다
3. 제목은 4-100자여야 합니다
4. 명령형을 사용하세요
5. 간결하지만 설명적이어야 합니다
6. 커밋 메시지만 출력하고 다른 내용은 출력하지 마세요.
"#;
const COMMITLINT_SYSTEM_PROMPT_ES: &str = r#"Eres un asistente que genera mensajes de commit siguiendo @commitlint/config-conventional.
Analiza el diff de git y genera un mensaje de commit.
Formato: <tipo>[alcance opcional]: <asunto>
Tipos: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert
Reglas:
1. El asunto no debe comenzar con mayúscula
2. El asunto no debe terminar con punto
3. El asunto debe tener 4-100 caracteres
4. Usa modo imperativo
5. Sé conciso pero descriptivo
6. Genera SOLO el mensaje de commit, nada más.
"#;
const COMMITLINT_SYSTEM_PROMPT_FR: &str = r#"Vous êtes un assistant qui génère des messages de commit suivant @commitlint/config-conventional.
Analysez le diff git et générez un message de commit.
Format: <type>[portée optionnelle]: <sujet>
Types: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert
Règles:
1. Le sujet ne doit pas commencer par une majuscule
2. Le sujet ne doit pas se terminer par un point
3. Le sujet doit avoir 4-100 caractères
4. Utilisez le mode impératif
5. Soyez concis mais descriptif
6. Générez SEULEMENT le message de commit, rien d'autre.
"#;
const COMMITLINT_SYSTEM_PROMPT_DE: &str = r#"Sie sind ein Assistent, der Commit-Nachrichten gemäß @commitlint/config-conventional generiert.
Analysieren Sie den git diff und generieren Sie eine Commit-Nachricht.
Format: <typ>[optionaler Bereich]: <betreff>
Typen: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert
Regeln:
1. Der Betreff sollte nicht mit einem Großbuchstaben beginnen
2. Der Betreff sollte nicht mit einem Punkt enden
3. Der Betreff sollte 4-100 Zeichen haben
4. Verwenden Sie den Imperativ
5. Seien Sie prägnant aber beschreibend
6. Geben Sie NUR die Commit-Nachricht aus, nichts anderes.
"#;
const TAG_MESSAGE_SYSTEM_PROMPT: &str = r#"You are a helpful assistant that generates git tag annotation messages.
Given a version number and a list of commits, generate a concise but informative tag message.
The message should:
1. Start with a brief summary of the release
2. Group changes by type (features, fixes, etc.)
3. Be suitable for a git annotated tag
Format:
<version> Release
Summary of changes...
Changes:
- Feature: description
- Fix: description
...
"#;
const TAG_MESSAGE_SYSTEM_PROMPT_ZH: &str = r#"你是一个生成 git 标签注释消息的助手。
给定版本号和提交列表,生成简洁但信息丰富的标签消息。
消息应该:
1. 以发布的简要摘要开始
2. 按类型分组更改(功能、修复等)
3. 适合 git 标注标签
格式:
<version> 发布
更改摘要...
更改:
- 功能:描述
- 修复:描述
...
"#;
const TAG_MESSAGE_SYSTEM_PROMPT_JA: &str = r#"あなたはgitタグ注釈メッセージを生成するアシスタントです。
バージョン番号とコミットのリストを考慮して、簡潔ですが情報豊富なタグメッセージを生成してください。
メッセージは以下のようであるべきです:
1. リリースの簡単な要約から始める
2. タイプ別に変更をグループ化する(機能、修正など)
3. git注釈タグに適している
形式:
<version> リリース
変更の概要...
変更:
- 機能:説明
- 修正:説明
...
"#;
const TAG_MESSAGE_SYSTEM_PROMPT_KO: &str = r#"당신은 git 태그 주석 메시지를 생성하는 도우미입니다.
버전 번호와 커밋 목록을 고려하여 간결하지만 정보가 풍부한 태그 메시지를 생성하세요.
메시지는 다음과 같아야 합니다:
1. 릴리스의 간단한 요약으로 시작
2. 유형별로 변경 사항 그룹화(기능, 수정 등)
3. git 주석 태그에 적합
형식:
<version> 릴리스
변경 사항 요약...
변경 사항:
- 기능: 설명
- 수정: 설명
...
"#;
const TAG_MESSAGE_SYSTEM_PROMPT_ES: &str = r#"Eres un asistente que genera mensajes de anotación de etiquetas git.
Dado un número de versión y una lista de commits, genera un mensaje de etiqueta conciso pero informativo.
El mensaje debe:
1. Comenzar con un resumen breve de la versión
2. Agrupar cambios por tipo (características, correcciones, etc.)
3. Ser adecuado para una etiqueta git anotada
Formato:
<version> Versión
Resumen de cambios...
Cambios:
- Característica: descripción
- Corrección: descripción
...
"#;
const TAG_MESSAGE_SYSTEM_PROMPT_FR: &str = r#"Vous êtes un assistant qui génère des messages d'annotation de balises git.
Étant donné un numéro de version et une liste de commits, générez un message de balise concis mais informatif.
Le message doit :
1. Commencer par un bref résumé de la version
2. Grouper les changements par type (fonctionnalités, corrections, etc.)
3. Être adapté à une balise git annotée
Format :
<version> Version
Résumé des changements...
Changements :
- Fonctionnalité : description
- Correction : description
...
"#;
const TAG_MESSAGE_SYSTEM_PROMPT_DE: &str = r#"Sie sind ein Assistent, der git-Tag-Anmerkungsnachrichten generiert.
Gegeben eine Versionsnummer und eine Liste von Commits, generieren Sie eine prägnante aber informative Tag-Nachricht.
Die Nachricht sollte:
1. Mit einer kurzen Zusammenfassung der Version beginnen
2. Änderungen nach Typ gruppieren (Funktionen, Fixes, etc.)
3. Für ein git-annotiertes Tag geeignet sein
Format:
<version> Version
Zusammenfassung der Änderungen...
Änderungen:
- Funktion: Beschreibung
- Fix: Beschreibung
...
"#;
const CHANGELOG_SYSTEM_PROMPT: &str = r#"You are a helpful assistant that generates changelog entries.
Given a version and a list of commits, generate a well-formatted changelog section.
Group commits by:
- Features (feat)
- Bug Fixes (fix)
- Documentation (docs)
- Other Changes
Format in markdown with proper headings and bullet points.
"#;
const CHANGELOG_SYSTEM_PROMPT_ZH: &str = r#"你是一个生成变更日志条目的助手。
给定版本和提交列表,生成格式良好的变更日志部分。
按以下方式分组提交:
- 功能 (feat)
- 错误修复 (fix)
- 文档 (docs)
- 其他更改
使用适当的标题和项目符号以 markdown 格式输出。
"#;
const CHANGELOG_SYSTEM_PROMPT_JA: &str = r#"あなたは変更ログエントリを生成するアシスタントです。
バージョンとコミットのリストを考慮して、適切にフォーマットされた変更ログセクションを生成してください。
コミットを以下でグループ化してください:
- 機能 (feat)
- バグ修正 (fix)
- ドキュメント (docs)
- その他の変更
適切な見出しと箇条書きを使用してmarkdown形式でフォーマットしてください。
"#;
const CHANGELOG_SYSTEM_PROMPT_KO: &str = r#"당신은 변경 로그 항목을 생성하는 도우미입니다.
버전과 커밋 목록을 고려하여 잘 포맷된 변경 로그 섹션을 생성하세요.
다음으로 커밋을 그룹화하세요:
- 기능 (feat)
- 버그 수정 (fix)
- 문서 (docs)
- 기타 변경 사항
적절한 제목과 글머리 기호를 사용하여 markdown 형식으로 포맷하세요.
"#;
const CHANGELOG_SYSTEM_PROMPT_ES: &str = r#"Eres un asistente que genera entradas de registro de cambios.
Dada una versión y una lista de commits, genera una sección de registro de cambios bien formateada.
Agrupa los commits por:
- Características (feat)
- Correcciones de errores (fix)
- Documentación (docs)
- Otros cambios
Formatea en markdown con encabezados y viñetas apropiados.
"#;
const CHANGELOG_SYSTEM_PROMPT_FR: &str = r#"Vous êtes un assistant qui génère des entrées de journal des modifications.
Étant donné une version et une liste de commits, générez une section de journal des modifications bien formatée.
Groupez les commits par :
- Fonctionnalités (feat)
- Corrections de bugs (fix)
- Documentation (docs)
- Autres modifications
Formatez en markdown avec des en-têtes et des puces appropriés.
"#;
const CHANGELOG_SYSTEM_PROMPT_DE: &str = r#"Sie sind ein Assistent, der Changelog-Einträge generiert.
Gegeben eine Version und eine Liste von Commits, generieren Sie einen gut formatierten Changelog-Abschnitt.
Gruppieren Sie Commits nach:
- Funktionen (feat)
- Bugfixes (fix)
- Dokumentation (docs)
- Andere Änderungen
Formatieren Sie in Markdown mit geeigneten Überschriften und Aufzählungspunkten.
"#;

1528
src/llm/rig/mod.rs Normal file

File diff suppressed because it is too large Load Diff

143
src/llm/thinking.rs Normal file
View File

@@ -0,0 +1,143 @@
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
/// 统一的思考状态管理器,用于管理模型思考状态的显示与隐藏
pub struct ThinkingStateManager {
is_thinking: AtomicBool,
on_start: Option<Box<dyn Fn() + Send + Sync>>,
on_end: Option<Box<dyn Fn() + Send + Sync>>,
}
impl ThinkingStateManager {
pub fn new() -> Self {
Self {
is_thinking: AtomicBool::new(false),
on_start: None,
on_end: None,
}
}
/// 设置思考开始回调
pub fn on_thinking_start<F: Fn() + Send + Sync + 'static>(mut self, callback: F) -> Self {
self.on_start = Some(Box::new(callback));
self
}
/// 设置思考结束回调
pub fn on_thinking_end<F: Fn() + Send + Sync + 'static>(mut self, callback: F) -> Self {
self.on_end = Some(Box::new(callback));
self
}
/// 开始思考状态
pub fn start_thinking(&self) {
if !self.is_thinking.load(Ordering::SeqCst) {
self.is_thinking.store(true, Ordering::SeqCst);
if let Some(ref cb) = self.on_start {
cb();
}
}
}
/// 结束思考状态
pub fn end_thinking(&self) {
if self.is_thinking.load(Ordering::SeqCst) {
self.is_thinking.store(false, Ordering::SeqCst);
if let Some(ref cb) = self.on_end {
cb();
}
}
}
/// 当前是否处于思考状态
pub fn is_thinking(&self) -> bool {
self.is_thinking.load(Ordering::SeqCst)
}
}
impl Default for ThinkingStateManager {
fn default() -> Self {
Self::new()
}
}
/// 线程安全的思考状态管理器引用
pub type SharedThinkingState = Arc<ThinkingStateManager>;
/// 创建 LLM 流式使用的共享思考状态。
/// 进度显示由 AI 生成 spinner 负责(issue 21),此处不再附加控制台输出。
pub fn create_console_thinking_state() -> SharedThinkingState {
Arc::new(ThinkingStateManager::new())
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
#[test]
fn test_thinking_state_transitions() {
let manager = ThinkingStateManager::new();
assert!(!manager.is_thinking());
manager.start_thinking();
assert!(manager.is_thinking());
manager.end_thinking();
assert!(!manager.is_thinking());
}
#[test]
fn test_thinking_idempotent_start() {
let manager = ThinkingStateManager::new();
manager.start_thinking();
manager.start_thinking(); // 重复调用不应触发回调两次
assert!(manager.is_thinking());
}
#[test]
fn test_thinking_idempotent_end() {
let manager = ThinkingStateManager::new();
manager.end_thinking(); // 未开始时结束不应触发问题
assert!(!manager.is_thinking());
}
#[test]
fn test_thinking_callbacks() {
let events: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
let events_clone = events.clone();
let manager = ThinkingStateManager::new().on_thinking_start(move || {
events_clone.lock().unwrap().push("start".to_string());
});
let events_clone2 = events.clone();
let manager = manager.on_thinking_end(move || {
events_clone2.lock().unwrap().push("end".to_string());
});
manager.start_thinking();
manager.end_thinking();
let recorded = events.lock().unwrap();
assert_eq!(recorded.len(), 2);
assert_eq!(recorded[0], "start");
assert_eq!(recorded[1], "end");
}
#[test]
fn test_create_console_thinking_state() {
let state = create_console_thinking_state();
assert!(!state.is_thinking());
state.start_thinking();
assert!(state.is_thinking());
state.end_thinking();
assert!(!state.is_thinking());
}
#[test]
fn test_default() {
let manager = ThinkingStateManager::default();
assert!(!manager.is_thinking());
}
}

View File

@@ -3,21 +3,14 @@ use clap::{Parser, Subcommand};
use std::path::PathBuf;
use tracing::debug;
mod commands;
mod config;
mod generator;
mod git;
mod i18n;
mod llm;
mod utils;
use commands::{
use quicommit::commands::{
changelog::ChangelogCommand, commit::CommitCommand, config::ConfigCommand,
init::InitCommand, profile::ProfileCommand, tag::TagCommand,
credential::CredentialCommand, ginit::GinitCommand, init::InitCommand, profile::ProfileCommand,
tag::TagCommand,
};
/// QuiCommit - AI-powered Git assistant
///
///
/// A powerful tool that helps you generate conventional commits, tags, and changelogs
/// using AI (LLM APIs or local Ollama models). Manage multiple Git profiles for different
/// work contexts seamlessly.
@@ -28,7 +21,7 @@ use commands::{
#[command(propagate_version = true)]
#[command(arg_required_else_help = true)]
struct Cli {
/// Enable verbose output
/// Increase verbosity (-v: info, -vv: debug, -vvv: trace)
#[arg(short, long, global = true, action = clap::ArgAction::Count)]
verbose: u8,
@@ -37,9 +30,17 @@ struct Cli {
config: Option<String>,
/// Disable colored output
#[arg(long, global = true, env = "NO_COLOR")]
#[arg(long, global = true)]
no_color: bool,
/// Force emoji decorations on (overrides config)
#[arg(long, global = true, conflicts_with = "no_emoji")]
emoji: bool,
/// Disable emoji/symbol decorations
#[arg(long, global = true)]
no_emoji: bool,
#[command(subcommand)]
command: Commands,
}
@@ -50,6 +51,10 @@ enum Commands {
#[command(alias = "i")]
Init(InitCommand),
/// Initialize a local Git repository
#[command(alias = "gi")]
Ginit(GinitCommand),
/// Generate and execute conventional commits
#[command(alias = "c")]
Commit(CommitCommand),
@@ -69,21 +74,57 @@ enum Commands {
/// Manage configuration settings
#[command(alias = "cfg")]
Config(ConfigCommand),
/// Git credential helper (hidden, invoked by git)
#[command(hide = true)]
Credential(CredentialCommand),
}
#[tokio::main]
async fn main() -> Result<()> {
let cli = Cli::parse();
// Apply the global color decision before any output is produced.
// --no-color disables colors; NO_COLOR follows the no-color.org spec
// (any presence, regardless of value, disables color). Without them,
// color keeps colored's tty auto-detection (ADR-0003): piped output
// stays ANSI-free.
let no_color = cli.no_color || std::env::var_os("NO_COLOR").is_some();
if no_color {
colored::control::set_override(false);
}
// Resolve the decoration switch (issue 14): explicit flag > config >
// default; --no-color disables decorations too (ADR-0003).
let emoji_from_config = cli
.config
.as_deref()
.map(PathBuf::from)
.or_else(|| quicommit::config::AppConfig::default_path().ok())
.and_then(|path| quicommit::config::AppConfig::load(&path).ok())
.map(|config| config.output.emoji)
.unwrap_or(true);
let emoji_enabled = if no_color || cli.no_emoji {
false
} else if cli.emoji {
true
} else {
emoji_from_config
};
quicommit::utils::set_emoji_enabled(emoji_enabled);
let log_level = match cli.verbose {
0 => "warn",
1 => "info",
2 => "debug",
_ => "trace",
};
// RUST_LOG takes precedence when set; otherwise -v decides (issue 23).
let filter = tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(log_level));
tracing_subscriber::fmt()
.with_env_filter(log_level)
.with_env_filter(filter)
.with_target(false)
.init();
@@ -93,10 +134,12 @@ async fn main() -> Result<()> {
match cli.command {
Commands::Init(cmd) => cmd.execute(config_path).await,
Commands::Ginit(cmd) => cmd.execute(config_path).await,
Commands::Commit(cmd) => cmd.execute(config_path).await,
Commands::Tag(cmd) => cmd.execute(config_path).await,
Commands::Changelog(cmd) => cmd.execute(config_path).await,
Commands::Profile(cmd) => cmd.execute(config_path).await,
Commands::Config(cmd) => cmd.execute(config_path).await,
Commands::Credential(cmd) => cmd.execute(config_path).await,
}
}

View File

@@ -1,9 +1,9 @@
use aes_gcm::{
aead::{Aead, KeyInit},
Aes256Gcm, Nonce,
aead::{Aead, KeyInit},
};
use anyhow::{Context, Result};
use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
use rand::Rng;
use std::fs;
use std::path::Path;
@@ -18,63 +18,62 @@ pub fn encrypt(data: &[u8], password: &str) -> Result<String> {
rand::thread_rng().fill(&mut salt);
let mut nonce_bytes = [0u8; NONCE_LEN];
rand::thread_rng().fill(&mut nonce_bytes);
let key = derive_key(password, &salt)?;
let cipher = Aes256Gcm::new_from_slice(&key)
.context("Failed to create cipher")?;
let cipher = Aes256Gcm::new_from_slice(&key).context("Failed to create cipher")?;
let nonce = Nonce::from_slice(&nonce_bytes);
let encrypted = cipher
.encrypt(nonce, data)
.map_err(|e| anyhow::anyhow!("Encryption failed: {:?}", e))?;
// Combine salt + nonce + encrypted data
let mut result = Vec::with_capacity(SALT_LEN + NONCE_LEN + encrypted.len());
result.extend_from_slice(&salt);
result.extend_from_slice(&nonce_bytes);
result.extend_from_slice(&encrypted);
Ok(BASE64.encode(&result))
}
/// Decrypt data with password
pub fn decrypt(encrypted_data: &str, password: &str) -> Result<Vec<u8>> {
let data = BASE64.decode(encrypted_data)
let data = BASE64
.decode(encrypted_data)
.context("Invalid base64 encoding")?;
if data.len() < SALT_LEN + NONCE_LEN {
anyhow::bail!("Invalid encrypted data format");
}
let salt = &data[..SALT_LEN];
let nonce_bytes = &data[SALT_LEN..SALT_LEN + NONCE_LEN];
let encrypted = &data[SALT_LEN + NONCE_LEN..];
let key = derive_key(password, salt)?;
let cipher = Aes256Gcm::new_from_slice(&key)
.context("Failed to create cipher")?;
let cipher = Aes256Gcm::new_from_slice(&key).context("Failed to create cipher")?;
let nonce = Nonce::from_slice(nonce_bytes);
let decrypted = cipher
.decrypt(nonce, encrypted)
.map_err(|e| anyhow::anyhow!("Decryption failed: {:?}", e))?;
Ok(decrypted)
}
/// Derive key from password using simple method
fn derive_key(password: &str, salt: &[u8]) -> Result<[u8; KEY_LEN]> {
use sha2::{Sha256, Digest};
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(salt);
hasher.update(password.as_bytes());
hasher.update(b"quicommit_key_derivation_v1");
let hash = hasher.finalize();
let mut key = [0u8; KEY_LEN];
key.copy_from_slice(&hash[..KEY_LEN]);
Ok(key)
}
@@ -97,7 +96,7 @@ pub fn decrypt_from_file(path: &Path, password: &str) -> Result<Vec<u8>> {
pub fn generate_token(length: usize) -> String {
const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
let mut rng = rand::thread_rng();
(0..length)
.map(|_| {
let idx = rng.gen_range(0..CHARSET.len());
@@ -122,10 +121,10 @@ mod tests {
fn test_encrypt_decrypt() {
let data = b"Hello, World!";
let password = "my_secret_password";
let encrypted = encrypt(data, password).unwrap();
let decrypted = decrypt(&encrypted, password).unwrap();
assert_eq!(data.to_vec(), decrypted);
}
@@ -133,7 +132,7 @@ mod tests {
fn test_wrong_password() {
let data = b"Hello, World!";
let encrypted = encrypt(data, "correct_password").unwrap();
assert!(decrypt(&encrypted, "wrong_password").is_err());
}
}

View File

@@ -9,15 +9,12 @@ pub fn edit_content(initial_content: &str) -> Result<String> {
/// Edit file in user's default editor
pub fn edit_file(path: &Path) -> Result<String> {
let content = fs::read_to_string(path)
.unwrap_or_default();
let edited = edit::edit(&content)
.context("Failed to open editor")?;
fs::write(path, &edited)
.with_context(|| format!("Failed to write file: {:?}", path))?;
let content = fs::read_to_string(path).unwrap_or_default();
let edited = edit::edit(&content).context("Failed to open editor")?;
fs::write(path, &edited).with_context(|| format!("Failed to write file: {:?}", path))?;
Ok(edited)
}
@@ -27,11 +24,10 @@ pub fn edit_temp(initial_content: &str, extension: &str) -> Result<String> {
.suffix(&format!(".{}", extension))
.tempfile()
.context("Failed to create temp file")?;
let path = temp_file.path();
fs::write(path, initial_content)
.context("Failed to write temp file")?;
fs::write(path, initial_content).context("Failed to write temp file")?;
edit_file(path)
}
@@ -41,10 +37,10 @@ pub fn get_editor() -> String {
.or_else(|_| std::env::var("VISUAL"))
.unwrap_or_else(|_| {
if cfg!(target_os = "windows") {
if let Ok(code) = which::which("code") {
if let Ok(_code) = which::which("code") {
return "code --wait".to_string();
}
if let Ok(notepad) = which::which("notepad") {
if let Ok(_notepad) = which::which("notepad") {
return "notepad".to_string();
}
"notepad".to_string()
@@ -65,7 +61,6 @@ pub fn get_editor() -> String {
/// Check if editor is available
pub fn check_editor() -> Result<()> {
let editor = get_editor();
which::which(&editor)
.with_context(|| format!("Editor '{}' not found in PATH", editor))?;
which::which(&editor).with_context(|| format!("Editor '{}' not found in PATH", editor))?;
Ok(())
}

View File

@@ -10,7 +10,7 @@ pub fn format_conventional_commit(
breaking: bool,
) -> String {
let mut message = String::new();
message.push_str(commit_type);
if let Some(s) = scope {
message.push_str(&format!("({})", s));
@@ -19,15 +19,15 @@ pub fn format_conventional_commit(
message.push('!');
}
message.push_str(&format!(": {}", description));
if let Some(b) = body {
message.push_str(&format!("\n\n{}", b));
}
if let Some(f) = footer {
message.push_str(&format!("\n\n{}", f));
}
message
}
@@ -41,27 +41,27 @@ pub fn format_commitlint_commit(
references: Option<&[&str]>,
) -> String {
let mut message = String::new();
message.push_str(commit_type);
if let Some(s) = scope {
message.push_str(&format!("({})", s));
}
message.push_str(&format!(": {}", subject));
if let Some(refs) = references {
for reference in refs {
message.push_str(&format!(" #{}", reference));
}
}
if let Some(b) = body {
message.push_str(&format!("\n\n{}", b));
}
if let Some(f) = footer {
message.push_str(&format!("\n\n{}", f));
}
message
}
@@ -73,7 +73,7 @@ pub fn wrap_text(text: &str, width: usize) -> String {
/// Clean commit message (remove comments, extra whitespace)
pub fn clean_message(message: &str) -> String {
let comment_regex = Regex::new(r"^#.*$").unwrap();
message
.lines()
.filter(|line| !comment_regex.is_match(line.trim()))
@@ -97,7 +97,7 @@ mod tests {
Some("Closes #123"),
false,
);
assert!(msg.contains("feat(auth): add login functionality"));
assert!(msg.contains("This adds OAuth2 login support."));
assert!(msg.contains("Closes #123"));
@@ -113,7 +113,7 @@ mod tests {
Some("BREAKING CHANGE: response format changed"),
true,
);
assert!(msg.starts_with("feat!: change API response format"));
}
}

View File

@@ -1,9 +1,11 @@
use anyhow::{bail, Context, Result};
use anyhow::{Context, Result, bail};
use std::env;
const SERVICE_NAME: &str = "quicommit";
const ENV_API_KEY: &str = "QUICOMMIT_API_KEY";
const PAT_SERVICE_PREFIX: &str = "quicommit/pat";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum KeyringStatus {
Available,
@@ -76,26 +78,24 @@ impl KeyringManager {
let entry = keyring::Entry::new(SERVICE_NAME, provider)
.context("Failed to create keyring entry")?;
entry.set_password(api_key)
entry
.set_password(api_key)
.context("Failed to store API key")?;
Ok(())
}
pub fn get_api_key(&self, provider: &str) -> Result<Option<String>> {
// 优先从环境变量获取
if let Ok(key) = env::var(ENV_API_KEY) {
if !key.is_empty() {
return Ok(Some(key));
}
if let Ok(key) = env::var(ENV_API_KEY)
&& !key.is_empty()
{
return Ok(Some(key));
}
// keyring 不可用时直接返回
if !self.is_available() {
return Ok(None);
}
// 从 keyring 获取
let entry = keyring::Entry::new(SERVICE_NAME, provider)
.context("Failed to create keyring entry")?;
@@ -114,7 +114,8 @@ impl KeyringManager {
let entry = keyring::Entry::new(SERVICE_NAME, provider)
.context("Failed to create keyring entry")?;
entry.delete_credential()
entry
.delete_credential()
.context("Failed to delete API key")?;
Ok(())
@@ -124,6 +125,93 @@ impl KeyringManager {
self.get_api_key(provider).unwrap_or(None).is_some()
}
fn make_pat_service_name(profile_name: &str) -> String {
format!("{}/{}", PAT_SERVICE_PREFIX, profile_name)
}
pub fn store_pat(
&self,
profile_name: &str,
user_email: &str,
service: &str,
token: &str,
) -> Result<()> {
if !self.is_available() {
bail!("Keyring is not available on this system");
}
let keyring_service = Self::make_pat_service_name(profile_name);
let keyring_user = format!("{}:{}", user_email, service);
let entry = keyring::Entry::new(&keyring_service, &keyring_user)
.context("Failed to create keyring entry for PAT")?;
entry
.set_password(token)
.context("Failed to store PAT in keyring")?;
Ok(())
}
pub fn get_pat(
&self,
profile_name: &str,
user_email: &str,
service: &str,
) -> Result<Option<String>> {
if !self.is_available() {
return Ok(None);
}
let keyring_service = Self::make_pat_service_name(profile_name);
let keyring_user = format!("{}:{}", user_email, service);
let entry = keyring::Entry::new(&keyring_service, &keyring_user)
.context("Failed to create keyring entry for PAT")?;
match entry.get_password() {
Ok(token) => Ok(Some(token)),
Err(keyring::Error::NoEntry) => Ok(None),
Err(e) => Err(e.into()),
}
}
pub fn delete_pat(&self, profile_name: &str, user_email: &str, service: &str) -> Result<()> {
if !self.is_available() {
bail!("Keyring is not available on this system");
}
let keyring_service = Self::make_pat_service_name(profile_name);
let keyring_user = format!("{}:{}", user_email, service);
let entry = keyring::Entry::new(&keyring_service, &keyring_user)
.context("Failed to create keyring entry for PAT")?;
entry
.delete_credential()
.context("Failed to delete PAT from keyring")?;
Ok(())
}
pub fn has_pat(&self, profile_name: &str, user_email: &str, service: &str) -> bool {
self.get_pat(profile_name, user_email, service)
.unwrap_or(None)
.is_some()
}
pub fn delete_all_pats_for_profile(
&self,
profile_name: &str,
user_email: &str,
services: &[String],
) -> Result<()> {
for service in services {
let _ = self.delete_pat(profile_name, user_email, service);
}
Ok(())
}
pub fn get_status_message(&self) -> String {
match self.status {
KeyringStatus::Available => {
@@ -144,9 +232,7 @@ impl KeyringManager {
"Keyring is available".to_string()
}
}
KeyringStatus::Unavailable => {
"Keyring is not available. Set QUICOMMIT_API_KEY environment variable.".to_string()
}
KeyringStatus::Unavailable => "Keyring is not available on this system.".to_string(),
}
}
}
@@ -162,7 +248,7 @@ pub fn get_default_base_url(provider: &str) -> &'static str {
"openai" => "https://api.openai.com/v1",
"anthropic" => "https://api.anthropic.com/v1",
"kimi" => "https://api.moonshot.cn/v1",
"deepseek" => "https://api.deepseek.com/v1",
"deepseek" => "https://api.deepseek.com",
"openrouter" => "https://openrouter.ai/api/v1",
"ollama" => "http://localhost:11434",
_ => "",
@@ -173,8 +259,8 @@ pub fn get_default_model(provider: &str) -> &'static str {
match provider {
"openai" => "gpt-4",
"anthropic" => "claude-3-sonnet-20240229",
"kimi" => "moonshot-v1-8k",
"deepseek" => "deepseek-chat",
"kimi" => "kimi-k2.6",
"deepseek" => "deepseek-v4-flash",
"openrouter" => "openai/gpt-3.5-turbo",
"ollama" => "llama2",
_ => "",
@@ -182,7 +268,14 @@ pub fn get_default_model(provider: &str) -> &'static str {
}
pub fn get_supported_providers() -> &'static [&'static str] {
&["ollama", "openai", "anthropic", "kimi", "deepseek", "openrouter"]
&[
"ollama",
"openai",
"anthropic",
"kimi",
"deepseek",
"openrouter",
]
}
pub fn provider_needs_api_key(provider: &str) -> bool {
@@ -196,10 +289,16 @@ mod tests {
#[test]
fn test_get_default_base_url() {
assert_eq!(get_default_base_url("openai"), "https://api.openai.com/v1");
assert_eq!(get_default_base_url("anthropic"), "https://api.anthropic.com/v1");
assert_eq!(
get_default_base_url("anthropic"),
"https://api.anthropic.com/v1"
);
assert_eq!(get_default_base_url("kimi"), "https://api.moonshot.cn/v1");
assert_eq!(get_default_base_url("deepseek"), "https://api.deepseek.com/v1");
assert_eq!(get_default_base_url("openrouter"), "https://openrouter.ai/api/v1");
assert_eq!(get_default_base_url("deepseek"), "https://api.deepseek.com");
assert_eq!(
get_default_base_url("openrouter"),
"https://openrouter.ai/api/v1"
);
assert_eq!(get_default_base_url("ollama"), "http://localhost:11434");
}

View File

@@ -6,72 +6,152 @@ pub mod validators;
use anyhow::{Context, Result};
use colored::Colorize;
use std::io::{self, Write};
use std::io;
use std::sync::OnceLock;
use std::time::Duration;
/// Process-global decoration switch (issue 14).
static EMOJI_ENABLED: OnceLock<bool> = OnceLock::new();
/// Set whether emoji/symbol decorations are shown. Called once from `main`
/// after resolving config + flags.
pub fn set_emoji_enabled(enabled: bool) {
let _ = EMOJI_ENABLED.set(enabled);
}
/// Whether emoji/symbol decorations are shown.
pub fn emoji_enabled() -> bool {
*EMOJI_ENABLED.get_or_init(|| true)
}
/// Styled decoration prefix for status lines; empty when decorations are off
/// (ADR-0003: message text never embeds decorations).
pub fn success_prefix() -> colored::ColoredString {
if emoji_enabled() {
"✓".green().bold()
} else {
"".normal()
}
}
pub fn error_prefix() -> colored::ColoredString {
if emoji_enabled() {
"✗".red().bold()
} else {
"".normal()
}
}
pub fn warning_prefix() -> colored::ColoredString {
if emoji_enabled() {
"⚠".yellow().bold()
} else {
"".normal()
}
}
pub fn info_prefix() -> colored::ColoredString {
if emoji_enabled() {
"ℹ".blue().bold()
} else {
"".normal()
}
}
pub fn progress_prefix() -> colored::ColoredString {
if emoji_enabled() {
"→".cyan()
} else {
"".normal()
}
}
fn print_decorated(prefix: colored::ColoredString, msg: &str, to_stderr: bool) {
if emoji_enabled() {
if to_stderr {
eprintln!("{} {}", prefix, msg);
} else {
println!("{} {}", prefix, msg);
}
} else if to_stderr {
eprintln!("{}", msg);
} else {
println!("{}", msg);
}
}
/// Print success message
pub fn print_success(msg: &str) {
println!("{} {}", "✓".green().bold(), msg);
print_decorated(success_prefix(), msg, false);
}
/// Print error message
/// Print error message to stderr
pub fn print_error(msg: &str) {
eprintln!("{} {}", "✗".red().bold(), msg);
print_decorated(error_prefix(), msg, true);
}
/// Print warning message
pub fn print_warning(msg: &str) {
println!("{} {}", "⚠".yellow().bold(), msg);
print_decorated(warning_prefix(), msg, false);
}
/// Print warning message to stderr (stdout must stay clean, e.g. exports)
pub fn eprint_warning(msg: &str) {
print_decorated(warning_prefix(), msg, true);
}
/// Print info message
pub fn print_info(msg: &str) {
println!("{} {}", "ℹ".blue().bold(), msg);
print_decorated(info_prefix(), msg, false);
}
/// Confirm action with user
pub fn confirm(prompt: &str) -> Result<bool> {
print!("{} [y/N] ", prompt);
io::stdout().flush()?;
let mut input = String::new();
io::stdin().read_line(&mut input)?;
Ok(input.trim().to_lowercase().starts_with('y'))
/// Print progress/status message (→ prefix when decorations are on)
pub fn print_progress(msg: &str) {
print_decorated(progress_prefix(), msg, false);
}
/// Get user input
pub fn input(prompt: &str) -> Result<String> {
print!("{}: ", prompt);
io::stdout().flush()?;
let mut input = String::new();
io::stdin().read_line(&mut input)?;
Ok(input.trim().to_string())
/// Progress spinner for long-running operations (issue 21).
/// Degrades to a static line when stdout is not a terminal or when
/// decorations are disabled.
pub struct Spinner {
bar: Option<indicatif::ProgressBar>,
}
impl Spinner {
pub fn start(msg: &str) -> Self {
if emoji_enabled() && std::io::IsTerminal::is_terminal(&io::stdout()) {
let bar = indicatif::ProgressBar::new_spinner();
bar.set_message(msg.to_string());
bar.enable_steady_tick(Duration::from_millis(100));
Self { bar: Some(bar) }
} else {
println!("{}", msg);
Self { bar: None }
}
}
/// Stop the spinner, erasing it from the terminal (no completion line).
pub fn finish_clear(&self) {
if let Some(bar) = &self.bar {
bar.finish_and_clear();
}
}
/// Stop the spinner and print a completion line.
pub fn finish_with(&self, msg: &str) {
if let Some(bar) = &self.bar {
bar.finish_and_clear();
}
println!("{}", msg);
}
}
/// Get password input (hidden)
pub fn password_input(prompt: &str) -> Result<String> {
use dialoguer::Password;
Password::new()
.with_prompt(prompt)
.interact()
.context("Failed to read password")
}
/// Check if running in a terminal
pub fn is_terminal() -> bool {
atty::is(atty::Stream::Stdout)
}
/// Format duration in human-readable format
pub fn format_duration(secs: u64) -> String {
if secs < 60 {
format!("{}s", secs)
} else if secs < 3600 {
format!("{}m {}s", secs / 60, secs % 60)
} else {
format!("{}h {}m", secs / 3600, (secs % 3600) / 60)
}
}

View File

@@ -1,6 +1,6 @@
use anyhow::{bail, Result};
use lazy_static::lazy_static;
use anyhow::{Result, bail};
use regex::Regex;
use std::sync::LazyLock;
/// Conventional commit types
pub const CONVENTIONAL_TYPES: &[&str] = &[
@@ -37,37 +37,38 @@ pub const COMMITLINT_TYPES: &[&str] = &[
"security", // Security-related changes
];
lazy_static! {
/// Regex for conventional commit format
static ref CONVENTIONAL_COMMIT_REGEX: Regex = Regex::new(
r"^(?P<type>feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(?:\((?P<scope>[^)]+)\))?(?P<breaking>!)?: (?P<description>.+)$"
).unwrap();
/// Regex for conventional commit format
static CONVENTIONAL_COMMIT_REGEX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r"^(?P<type>feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(?:\((?P<scope>[^)]+)\))?(?P<breaking>!)?: (?P<description>.+)$",
)
.unwrap()
});
/// Regex for scope validation
static ref SCOPE_REGEX: Regex = Regex::new(
r"^[a-z0-9-]+$"
).unwrap();
/// Regex for scope validation
static SCOPE_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^[a-z0-9-]+$").unwrap());
/// Regex for version tag validation (semver)
static ref SEMVER_REGEX: Regex = Regex::new(
r"^v?(?P<major>0|[1-9]\d*)\.(?P<minor>0|[1-9]\d*)\.(?P<patch>0|[1-9]\d*)(?:-(?P<prerelease>(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+(?P<buildmetadata>[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$"
).unwrap();
/// Regex for version tag validation (semver)
static SEMVER_REGEX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r"^v?(?P<major>0|[1-9]\d*)\.(?P<minor>0|[1-9]\d*)\.(?P<patch>0|[1-9]\d*)(?:-(?P<prerelease>(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+(?P<buildmetadata>[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$",
)
.unwrap()
});
/// Regex for email validation
static ref EMAIL_REGEX: Regex = Regex::new(
r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"
).unwrap();
/// Regex for email validation
static EMAIL_REGEX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$").unwrap()
});
/// Regex for GPG key ID validation
static ref GPG_KEY_ID_REGEX: Regex = Regex::new(
r"^[A-F0-9]{16,40}$"
).unwrap();
}
/// Regex for GPG key ID validation
static GPG_KEY_ID_REGEX: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^[A-F0-9]{16,40}$").unwrap());
/// Validate conventional commit message
pub fn validate_conventional_commit(message: &str) -> Result<()> {
let first_line = message.lines().next().unwrap_or("");
if !CONVENTIONAL_COMMIT_REGEX.is_match(first_line) {
bail!(
"Invalid conventional commit format. Expected: <type>[optional scope]: <description>\n\
@@ -75,32 +76,32 @@ pub fn validate_conventional_commit(message: &str) -> Result<()> {
CONVENTIONAL_TYPES.join(", ")
);
}
if first_line.len() > 100 {
bail!("Commit subject too long (max 100 characters)");
}
Ok(())
}
/// Validate @commitlint commit message
pub fn validate_commitlint_commit(message: &str) -> Result<()> {
let first_line = message.lines().next().unwrap_or("");
let parts: Vec<&str> = first_line.splitn(2, ':').collect();
if parts.len() != 2 {
bail!("Invalid commit format. Expected: <type>[optional scope]: <subject>");
}
let type_part = parts[0];
let subject = parts[1].trim();
let commit_type = type_part
.split('(')
.next()
.unwrap_or("")
.trim_end_matches('!');
if !COMMITLINT_TYPES.contains(&commit_type) {
bail!(
"Invalid commit type: '{}'. Valid types: {}",
@@ -108,27 +109,32 @@ pub fn validate_commitlint_commit(message: &str) -> Result<()> {
COMMITLINT_TYPES.join(", ")
);
}
if subject.is_empty() {
bail!("Commit subject cannot be empty");
}
if subject.len() < 4 {
bail!("Commit subject too short (min 4 characters)");
}
if subject.len() > 100 {
bail!("Commit subject too long (max 100 characters)");
}
if subject.chars().next().map(|c| c.is_uppercase()).unwrap_or(false) {
if subject
.chars()
.next()
.map(|c| c.is_uppercase())
.unwrap_or(false)
{
bail!("Commit subject should not start with uppercase letter");
}
if subject.ends_with('.') {
bail!("Commit subject should not end with a period");
}
Ok(())
}
@@ -137,25 +143,25 @@ pub fn validate_scope(scope: &str) -> Result<()> {
if scope.is_empty() {
bail!("Scope cannot be empty");
}
if !SCOPE_REGEX.is_match(scope) {
bail!("Invalid scope format. Use lowercase letters, numbers, and hyphens only");
}
Ok(())
}
/// Validate semantic version tag
pub fn validate_semver(version: &str) -> Result<()> {
let version = version.trim_start_matches('v');
if !SEMVER_REGEX.is_match(version) {
bail!(
"Invalid semantic version format. Expected: MAJOR.MINOR.PATCH[-prerelease][+build]\n\
Examples: 1.0.0, 1.2.3-beta, v2.0.0+build123"
);
}
Ok(())
}
@@ -164,7 +170,7 @@ pub fn validate_email(email: &str) -> Result<()> {
if !EMAIL_REGEX.is_match(email) {
bail!("Invalid email address format");
}
Ok(())
}
@@ -173,7 +179,7 @@ pub fn validate_gpg_key_id(key_id: &str) -> Result<()> {
if !GPG_KEY_ID_REGEX.is_match(key_id) {
bail!("Invalid GPG key ID format. Expected 16-40 hexadecimal characters");
}
Ok(())
}
@@ -182,15 +188,18 @@ pub fn validate_profile_name(name: &str) -> Result<()> {
if name.is_empty() {
bail!("Profile name cannot be empty");
}
if name.len() > 50 {
bail!("Profile name too long (max 50 characters)");
}
if !name.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_') {
if !name
.chars()
.all(|c| c.is_alphanumeric() || c == '-' || c == '_')
{
bail!("Profile name can only contain letters, numbers, hyphens, and underscores");
}
Ok(())
}
@@ -201,7 +210,7 @@ pub fn is_valid_commit_type(commit_type: &str, use_commitlint: bool) -> bool {
} else {
CONVENTIONAL_TYPES
};
types.contains(&commit_type)
}

View File

@@ -1,38 +1,11 @@
use assert_cmd::Command;
use assert_cmd::cargo::cargo_bin_cmd;
use predicates::prelude::*;
use std::fs;
use std::path::PathBuf;
use tempfile::TempDir;
fn create_git_repo(dir: &PathBuf) -> std::process::Output {
std::process::Command::new("git")
.args(&["init"])
.current_dir(dir)
.output()
.expect("Failed to init git repo")
}
fn configure_git_user(dir: &PathBuf) {
std::process::Command::new("git")
.args(&["config", "user.name", "Test User"])
.current_dir(dir)
.output()
.expect("Failed to configure git user name");
std::process::Command::new("git")
.args(&["config", "user.email", "test@example.com"])
.current_dir(dir)
.output()
.expect("Failed to configure git user email");
}
fn setup_git_repo(dir: &PathBuf) {
create_git_repo(dir);
configure_git_user(dir);
}
fn init_quicommit(config_path: &PathBuf) {
let mut cmd = Command::cargo_bin("quicommit").unwrap();
let mut cmd = cargo_bin_cmd!("quicommit");
cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()]);
cmd.assert().success();
}
@@ -46,8 +19,13 @@ mod config_export {
let config_path = temp_dir.path().join("config.toml");
init_quicommit(&config_path);
let mut cmd = Command::cargo_bin("quicommit").unwrap();
cmd.args(&["config", "export", "--config", config_path.to_str().unwrap()]);
let mut cmd = cargo_bin_cmd!("quicommit");
cmd.args(&[
"config",
"export",
"--config",
config_path.to_str().unwrap(),
]);
cmd.assert()
.success()
@@ -62,12 +40,16 @@ mod config_export {
let export_path = temp_dir.path().join("exported.toml");
init_quicommit(&config_path);
let mut cmd = Command::cargo_bin("quicommit").unwrap();
let mut cmd = cargo_bin_cmd!("quicommit");
cmd.args(&[
"config", "export",
"--config", config_path.to_str().unwrap(),
"--output", export_path.to_str().unwrap(),
"--password", ""
"config",
"export",
"--config",
config_path.to_str().unwrap(),
"--output",
export_path.to_str().unwrap(),
"--password",
"",
]);
cmd.assert()
@@ -75,10 +57,13 @@ mod config_export {
.stdout(predicate::str::contains("Configuration exported"));
assert!(export_path.exists(), "Export file should be created");
let content = fs::read_to_string(&export_path).unwrap();
assert!(content.contains("version"), "Export should contain version");
assert!(content.contains("[llm]"), "Export should contain LLM config");
assert!(
content.contains("[llm]"),
"Export should contain LLM config"
);
}
#[test]
@@ -88,12 +73,16 @@ mod config_export {
let export_path = temp_dir.path().join("encrypted.toml");
init_quicommit(&config_path);
let mut cmd = Command::cargo_bin("quicommit").unwrap();
let mut cmd = cargo_bin_cmd!("quicommit");
cmd.args(&[
"config", "export",
"--config", config_path.to_str().unwrap(),
"--output", export_path.to_str().unwrap(),
"--password", "test_password_123"
"config",
"export",
"--config",
config_path.to_str().unwrap(),
"--output",
export_path.to_str().unwrap(),
"--password",
"test_password_123",
]);
cmd.assert()
@@ -101,10 +90,16 @@ mod config_export {
.stdout(predicate::str::contains("encrypted and exported"));
assert!(export_path.exists(), "Export file should be created");
let content = fs::read_to_string(&export_path).unwrap();
assert!(content.starts_with("ENCRYPTED:"), "Encrypted file should start with ENCRYPTED:");
assert!(!content.contains("[llm]"), "Encrypted content should not be readable");
assert!(
content.starts_with("ENCRYPTED:"),
"Encrypted file should start with ENCRYPTED:"
);
assert!(
!content.contains("[llm]"),
"Encrypted content should not be readable"
);
}
}
@@ -116,7 +111,7 @@ mod config_import {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("config.toml");
let import_path = temp_dir.path().join("import.toml");
let plain_config = r#"
version = "1"
@@ -131,31 +126,14 @@ api_key_storage = "keyring"
[commit]
format = "conventional"
auto_generate = true
allow_empty = false
gpg_sign = false
max_subject_length = 100
require_scope = false
require_body = false
body_required_types = ["feat", "fix"]
[tag]
version_prefix = "v"
auto_generate = true
gpg_sign = false
include_changelog = true
[changelog]
path = "CHANGELOG.md"
auto_generate = true
format = "keep-a-changelog"
include_hashes = false
include_authors = false
group_by_type = true
[theme]
colors = true
icons = true
date_format = "%Y-%m-%d"
[language]
output_language = "en"
@@ -164,19 +142,28 @@ keep_changelog_types_english = true
"#;
fs::write(&import_path, plain_config).unwrap();
let mut cmd = Command::cargo_bin("quicommit").unwrap();
let mut cmd = cargo_bin_cmd!("quicommit");
cmd.args(&[
"config", "import",
"--config", config_path.to_str().unwrap(),
"--file", import_path.to_str().unwrap()
"config",
"import",
"--config",
config_path.to_str().unwrap(),
"--file",
import_path.to_str().unwrap(),
]);
cmd.assert()
.success()
.stdout(predicate::str::contains("Configuration imported"));
let mut cmd = Command::cargo_bin("quicommit").unwrap();
cmd.args(&["config", "get", "llm.provider", "--config", config_path.to_str().unwrap()]);
let mut cmd = cargo_bin_cmd!("quicommit");
cmd.args(&[
"config",
"get",
"llm.provider",
"--config",
config_path.to_str().unwrap(),
]);
cmd.assert()
.success()
.stdout(predicate::str::contains("openai"));
@@ -188,38 +175,56 @@ keep_changelog_types_english = true
let config_path1 = temp_dir.path().join("config1.toml");
let config_path2 = temp_dir.path().join("config2.toml");
let export_path = temp_dir.path().join("encrypted.toml");
init_quicommit(&config_path1);
let mut cmd = Command::cargo_bin("quicommit").unwrap();
let mut cmd = cargo_bin_cmd!("quicommit");
cmd.args(&[
"config", "set", "llm.provider", "anthropic",
"--config", config_path1.to_str().unwrap()
"config",
"set",
"llm.provider",
"anthropic",
"--config",
config_path1.to_str().unwrap(),
]);
cmd.assert().success();
let mut cmd = Command::cargo_bin("quicommit").unwrap();
let mut cmd = cargo_bin_cmd!("quicommit");
cmd.args(&[
"config", "export",
"--config", config_path1.to_str().unwrap(),
"--output", export_path.to_str().unwrap(),
"--password", "secure_password"
"config",
"export",
"--config",
config_path1.to_str().unwrap(),
"--output",
export_path.to_str().unwrap(),
"--password",
"secure_password",
]);
cmd.assert().success();
let mut cmd = Command::cargo_bin("quicommit").unwrap();
let mut cmd = cargo_bin_cmd!("quicommit");
cmd.args(&[
"config", "import",
"--config", config_path2.to_str().unwrap(),
"--file", export_path.to_str().unwrap(),
"--password", "secure_password"
"config",
"import",
"--config",
config_path2.to_str().unwrap(),
"--file",
export_path.to_str().unwrap(),
"--password",
"secure_password",
]);
cmd.assert()
.success()
.stdout(predicate::str::contains("Configuration imported"));
let mut cmd = Command::cargo_bin("quicommit").unwrap();
cmd.args(&["config", "get", "llm.provider", "--config", config_path2.to_str().unwrap()]);
let mut cmd = cargo_bin_cmd!("quicommit");
cmd.args(&[
"config",
"get",
"llm.provider",
"--config",
config_path2.to_str().unwrap(),
]);
cmd.assert()
.success()
.stdout(predicate::str::contains("anthropic"));
@@ -230,24 +235,32 @@ keep_changelog_types_english = true
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("config.toml");
let export_path = temp_dir.path().join("encrypted.toml");
init_quicommit(&config_path);
let mut cmd = Command::cargo_bin("quicommit").unwrap();
let mut cmd = cargo_bin_cmd!("quicommit");
cmd.args(&[
"config", "export",
"--config", config_path.to_str().unwrap(),
"--output", export_path.to_str().unwrap(),
"--password", "correct_password"
"config",
"export",
"--config",
config_path.to_str().unwrap(),
"--output",
export_path.to_str().unwrap(),
"--password",
"correct_password",
]);
cmd.assert().success();
let mut cmd = Command::cargo_bin("quicommit").unwrap();
let mut cmd = cargo_bin_cmd!("quicommit");
cmd.args(&[
"config", "import",
"--config", config_path.to_str().unwrap(),
"--file", export_path.to_str().unwrap(),
"--password", "wrong_password"
"config",
"import",
"--config",
config_path.to_str().unwrap(),
"--file",
export_path.to_str().unwrap(),
"--password",
"wrong_password",
]);
cmd.assert()
.failure()
@@ -264,35 +277,52 @@ mod config_export_import_roundtrip {
let config_path1 = temp_dir.path().join("config1.toml");
let config_path2 = temp_dir.path().join("config2.toml");
let export_path = temp_dir.path().join("export.toml");
init_quicommit(&config_path1);
let mut cmd = Command::cargo_bin("quicommit").unwrap();
let mut cmd = cargo_bin_cmd!("quicommit");
cmd.args(&[
"config", "set", "llm.model", "gpt-4-turbo",
"--config", config_path1.to_str().unwrap()
"config",
"set",
"llm.model",
"gpt-4-turbo",
"--config",
config_path1.to_str().unwrap(),
]);
cmd.assert().success();
let mut cmd = Command::cargo_bin("quicommit").unwrap();
let mut cmd = cargo_bin_cmd!("quicommit");
cmd.args(&[
"config", "export",
"--config", config_path1.to_str().unwrap(),
"--output", export_path.to_str().unwrap(),
"--password", ""
"config",
"export",
"--config",
config_path1.to_str().unwrap(),
"--output",
export_path.to_str().unwrap(),
"--password",
"",
]);
cmd.assert().success();
let mut cmd = Command::cargo_bin("quicommit").unwrap();
let mut cmd = cargo_bin_cmd!("quicommit");
cmd.args(&[
"config", "import",
"--config", config_path2.to_str().unwrap(),
"--file", export_path.to_str().unwrap()
"config",
"import",
"--config",
config_path2.to_str().unwrap(),
"--file",
export_path.to_str().unwrap(),
]);
cmd.assert().success();
let mut cmd = Command::cargo_bin("quicommit").unwrap();
cmd.args(&["config", "get", "llm.model", "--config", config_path2.to_str().unwrap()]);
let mut cmd = cargo_bin_cmd!("quicommit");
cmd.args(&[
"config",
"get",
"llm.model",
"--config",
config_path2.to_str().unwrap(),
]);
cmd.assert()
.success()
.stdout(predicate::str::contains("gpt-4-turbo"));
@@ -305,29 +335,41 @@ mod config_export_import_roundtrip {
let config_path2 = temp_dir.path().join("config2.toml");
let export_path = temp_dir.path().join("encrypted.toml");
let password = "my_secure_password_123";
init_quicommit(&config_path1);
let mut cmd = Command::cargo_bin("quicommit").unwrap();
let mut cmd = cargo_bin_cmd!("quicommit");
cmd.args(&[
"config", "set", "llm.provider", "deepseek",
"--config", config_path1.to_str().unwrap()
"config",
"set",
"llm.provider",
"deepseek",
"--config",
config_path1.to_str().unwrap(),
]);
cmd.assert().success();
let mut cmd = Command::cargo_bin("quicommit").unwrap();
let mut cmd = cargo_bin_cmd!("quicommit");
cmd.args(&[
"config", "set", "llm.model", "deepseek-chat",
"--config", config_path1.to_str().unwrap()
"config",
"set",
"llm.model",
"deepseek-chat",
"--config",
config_path1.to_str().unwrap(),
]);
cmd.assert().success();
let mut cmd = Command::cargo_bin("quicommit").unwrap();
let mut cmd = cargo_bin_cmd!("quicommit");
cmd.args(&[
"config", "export",
"--config", config_path1.to_str().unwrap(),
"--output", export_path.to_str().unwrap(),
"--password", password
"config",
"export",
"--config",
config_path1.to_str().unwrap(),
"--output",
export_path.to_str().unwrap(),
"--password",
password,
]);
cmd.assert().success();
@@ -335,23 +377,39 @@ mod config_export_import_roundtrip {
assert!(exported_content.starts_with("ENCRYPTED:"));
assert!(!exported_content.contains("deepseek"));
let mut cmd = Command::cargo_bin("quicommit").unwrap();
let mut cmd = cargo_bin_cmd!("quicommit");
cmd.args(&[
"config", "import",
"--config", config_path2.to_str().unwrap(),
"--file", export_path.to_str().unwrap(),
"--password", password
"config",
"import",
"--config",
config_path2.to_str().unwrap(),
"--file",
export_path.to_str().unwrap(),
"--password",
password,
]);
cmd.assert().success();
let mut cmd = Command::cargo_bin("quicommit").unwrap();
cmd.args(&["config", "get", "llm.provider", "--config", config_path2.to_str().unwrap()]);
let mut cmd = cargo_bin_cmd!("quicommit");
cmd.args(&[
"config",
"get",
"llm.provider",
"--config",
config_path2.to_str().unwrap(),
]);
cmd.assert()
.success()
.stdout(predicate::str::contains("deepseek"));
let mut cmd = Command::cargo_bin("quicommit").unwrap();
cmd.args(&["config", "get", "llm.model", "--config", config_path2.to_str().unwrap()]);
let mut cmd = cargo_bin_cmd!("quicommit");
cmd.args(&[
"config",
"get",
"llm.model",
"--config",
config_path2.to_str().unwrap(),
]);
cmd.assert()
.success()
.stdout(predicate::str::contains("deepseek-chat"));

1124
tests/credential_tests.rs Normal file

File diff suppressed because it is too large Load Diff

560
tests/ginit_tests.rs Normal file
View File

@@ -0,0 +1,560 @@
use assert_cmd::Command;
use assert_cmd::cargo::cargo_bin_cmd;
use predicates::prelude::*;
use std::fs;
use std::path::{Path, PathBuf};
use tempfile::TempDir;
/// A ginit run isolated from the machine it happens on: `init.defaultBranch`,
/// `user.name`/`user.email` and QuiCommit's own config all come from throwaway
/// locations, so expectations hold both on a developer box and in CI.
struct Scenario {
work: TempDir,
home: TempDir,
config: PathBuf,
}
impl Scenario {
fn new() -> Scenario {
let work = TempDir::new().unwrap();
let home = TempDir::new().unwrap();
let config = home.path().join("config.toml");
Scenario { work, home, config }
}
fn dir(&self) -> &Path {
self.work.path()
}
fn command_in(&self, dir: &Path, args: &[&str]) -> Command {
let mut cmd = cargo_bin_cmd!("quicommit");
cmd.current_dir(dir)
.env("HOME", self.home.path())
.env("USERPROFILE", self.home.path())
.env("XDG_CONFIG_HOME", self.home.path())
.arg("--config")
.arg(&self.config)
.args(args);
cmd
}
fn command(&self, args: &[&str]) -> Command {
self.command_in(self.dir(), args)
}
/// `--yes` is the non-interactive side of ADR-0004; every case uses it
/// because dialoguer refuses to answer without a terminal.
fn ginit(&self, args: &[&str]) -> Command {
let mut args: Vec<&str> = args.to_vec();
args.push("--yes");
self.command(&args)
}
fn git(&self, args: &[&str]) -> String {
self.git_in(self.dir(), args)
}
fn git_in(&self, dir: &Path, args: &[&str]) -> String {
let output = std::process::Command::new("git")
.args(args)
.current_dir(dir)
.env("HOME", self.home.path())
.env("USERPROFILE", self.home.path())
.env("XDG_CONFIG_HOME", self.home.path())
.output()
.expect("git should be available");
assert!(
output.status.success(),
"git {args:?} failed: {}",
String::from_utf8_lossy(&output.stderr)
);
String::from_utf8_lossy(&output.stdout).trim().to_string()
}
fn read(&self, name: &str) -> String {
fs::read_to_string(self.dir().join(name)).unwrap_or_else(|e| panic!("{name}: {e}"))
}
/// QuiCommit's config is written by hand rather than produced by
/// `init --yes`, because on Windows libgit2 resolves the global git
/// config from the user profile folder and ignores `HOME`, so a profile
/// created by `init` would inherit this machine's real identity.
fn write_config(&self, contents: &str) {
fs::write(&self.config, contents).unwrap();
}
}
#[test]
fn test_ginit_creates_repository_readme_and_gitignore() {
let scenario = Scenario::new();
scenario
.ginit(&["ginit"])
.assert()
.success()
.stdout(predicate::str::contains("Repository initialized"));
assert!(scenario.dir().join(".git").exists());
assert_eq!(
scenario.git(&["symbolic-ref", "--short", "HEAD"]),
"main",
"with no init.defaultBranch configured, ginit starts on main"
);
let workdir = scenario.dir();
let name = workdir.file_name().unwrap().to_string_lossy().to_string();
assert_eq!(scenario.read("README.md"), format!("# {name}\n"));
let gitignore = scenario.read(".gitignore");
assert!(
gitignore.contains(".env\n"),
"the baseline must ignore .env, got: {gitignore}"
);
assert!(gitignore.contains("# Build output"));
}
#[test]
fn test_ginit_on_an_existing_repository_skips_creation_and_fills_gaps() {
let scenario = Scenario::new();
scenario.git(&["init"]);
fs::write(scenario.dir().join("README.md"), "# mine\n").unwrap();
// A repository whose HEAD has no commit yet still has a branch, and that
// branch is what ginit must report as untouched.
scenario.git(&["symbolic-ref", "HEAD", "refs/heads/trunk"]);
scenario
.ginit(&["ginit"])
.assert()
.success()
// The branch line must name the branch that exists, not the default it
// would have used for a fresh repository.
.stdout(predicate::str::contains(
"trunk (existing repository, branch untouched)",
));
assert_eq!(scenario.read("README.md"), "# mine\n");
assert!(
scenario.dir().join(".gitignore").exists(),
"the missing .gitignore should have been added"
);
}
#[test]
fn test_ginit_never_touches_existing_start_files() {
let scenario = Scenario::new();
fs::write(scenario.dir().join("README.md"), "# mine\n").unwrap();
fs::write(scenario.dir().join(".gitignore"), "keep-me/\n").unwrap();
scenario
.ginit(&["ginit"])
.assert()
.success()
.stdout(predicate::str::contains(".gitignore sections").not());
assert_eq!(scenario.read("README.md"), "# mine\n");
assert_eq!(
scenario.read(".gitignore"),
"keep-me/\n",
"ginit must not append to a maintained .gitignore"
);
}
#[test]
fn test_ginit_branch_flag_sets_the_initial_branch() {
let scenario = Scenario::new();
scenario.ginit(&["ginit", "-b", "dev"]).assert().success();
assert_eq!(scenario.git(&["symbolic-ref", "--short", "HEAD"]), "dev");
}
#[test]
fn test_ginit_refuses_to_rename_a_branch_that_has_commits() {
let scenario = Scenario::new();
scenario.git(&["init"]);
scenario.git(&["config", "user.name", "Test User"]);
scenario.git(&["config", "user.email", "test@example.com"]);
scenario.git(&["config", "commit.gpgsign", "false"]);
scenario.git(&["commit", "--allow-empty", "-m", "first"]);
let branch = scenario.git(&["symbolic-ref", "--short", "HEAD"]);
scenario
.ginit(&["ginit", "-b", "experiment"])
.assert()
.success()
.stdout(predicate::str::contains(
"already has commits, branch unchanged",
));
assert_eq!(scenario.git(&["symbolic-ref", "--short", "HEAD"]), branch);
}
#[test]
fn test_ginit_rejects_a_directory_inside_another_repository() {
let scenario = Scenario::new();
scenario.git(&["init"]);
let nested = scenario.dir().join("inner");
fs::create_dir(&nested).unwrap();
scenario
.command_in(&nested, &["ginit", "--yes"])
.assert()
.failure()
.stderr(predicate::str::contains("inside the repository"));
assert!(
!nested.join(".git").exists(),
"the nested directory must not become a repository"
);
assert!(
!nested.join("README.md").exists(),
"a refused run must write nothing at all"
);
}
#[test]
fn test_ginit_add_names_the_new_remote_origin() {
let scenario = Scenario::new();
scenario
.ginit(&["ginit", "--add", "https://example.com/origin.git"])
.assert()
.success();
let remotes = scenario.git(&["remote", "-v"]);
assert!(
remotes.starts_with("origin"),
"a bare URL should become origin, got: {remotes}"
);
assert!(remotes.contains("https://example.com/origin.git"));
}
#[test]
fn test_ginit_add_accepts_named_remotes_and_repeats() {
let scenario = Scenario::new();
scenario
.ginit(&[
"ginit",
"--add",
"upstream=https://example.com/upstream.git",
"--add",
"origin=https://example.com/origin.git",
])
.assert()
.success();
let remotes = scenario.git(&["remote", "-v"]);
assert!(remotes.contains("upstream"), "got: {remotes}");
assert!(remotes.contains("origin"), "got: {remotes}");
}
#[test]
fn test_ginit_refuses_to_rewrite_an_existing_remote_url() {
let scenario = Scenario::new();
scenario
.ginit(&["ginit", "--add", "https://example.com/first.git"])
.assert()
.success();
scenario
.ginit(&["ginit", "--add", "https://example.com/second.git"])
.assert()
.failure()
.stderr(predicate::str::contains("set-url"));
let remotes = scenario.git(&["remote", "-v"]);
assert!(
remotes.contains("https://example.com/first.git"),
"the existing URL must survive, got: {remotes}"
);
assert!(!remotes.contains("second.git"), "got: {remotes}");
}
#[test]
fn test_ginit_rejects_an_add_value_without_a_url() {
let scenario = Scenario::new();
scenario
.ginit(&["ginit", "--add", "origin"])
.assert()
.failure()
.stderr(predicate::str::contains("--add 'origin' has no URL"));
assert!(
!scenario.dir().join(".git").exists(),
"a rejected flag must stop the whole run"
);
}
#[test]
fn test_ginit_gitignore_sections_follow_the_markers_in_the_directory() {
struct Expected {
markers: &'static [&'static str],
present: &'static [&'static str],
absent: &'static [&'static str],
}
let cases = [
Expected {
markers: &[],
present: &["# Build output", "tmp/", ".env"],
absent: &["# Rust", "# Node"],
},
Expected {
markers: &["Cargo.toml"],
present: &["# Rust", "target/"],
absent: &["Cargo.lock", "# Node"],
},
Expected {
markers: &["Cargo.toml", "package.json"],
present: &["# Rust", "# Node", "node_modules/"],
absent: &[],
},
Expected {
markers: &["pyproject.toml"],
present: &["# Python", "__pycache__/"],
absent: &["# Ruby"],
},
Expected {
markers: &["go.mod", "composer.json"],
present: &["# Go", "vendor/"],
absent: &["# PHP"],
},
Expected {
markers: &["Cargo.toml", "pom.xml"],
present: &["# Rust"],
absent: &["# Maven"],
},
];
for case in cases {
let scenario = Scenario::new();
for marker in case.markers {
fs::write(scenario.dir().join(marker), "").unwrap();
}
let assertion = scenario.ginit(&["ginit"]).assert().success();
let stdout = String::from_utf8_lossy(&assertion.get_output().stdout).to_string();
let gitignore = scenario.read(".gitignore");
for expected in case.present {
assert!(
gitignore.contains(expected),
"{:?} should contain {expected}, got: {gitignore}",
case.markers
);
}
for unexpected in case.absent {
assert!(
!gitignore.contains(unexpected),
"{:?} should not contain {unexpected}, got: {gitignore}",
case.markers
);
}
// The preview promises exactly what the file delivers: a section the
// cross-section dedupe dropped (PHP behind Go, Maven behind Rust) may
// not be listed as if it were going to be written.
assert!(
stdout.contains(".gitignore sections:"),
"the preview should list the sections, got: {stdout}"
);
for title in case.present.iter().filter(|line| line.starts_with('#')) {
assert!(
stdout.contains(title),
"the preview should promise {title}, got: {stdout}"
);
}
for title in case.absent.iter().filter(|line| line.starts_with('#')) {
assert!(
!stdout.contains(title),
"the preview must not promise the dropped {title}, got: {stdout}"
);
}
assert_eq!(
gitignore.matches("dist/").count(),
1,
"entries shared with the baseline appear once, got: {gitignore}"
);
assert!(
!gitignore.contains("\n\n\n"),
"empty sections must be omitted, got: {gitignore}"
);
}
}
#[test]
fn test_ginit_dry_run_writes_nothing() {
let scenario = Scenario::new();
scenario
.ginit(&[
"ginit",
"--dry-run",
"-b",
"dev",
"--add",
"https://example.com/origin.git",
])
.assert()
.success()
.stdout(predicate::str::contains("Dry run"))
.stdout(predicate::str::contains("dev"));
assert!(
!scenario.dir().join(".git").exists(),
"a dry run must not even create the repository"
);
assert!(!scenario.dir().join("README.md").exists());
assert!(!scenario.dir().join(".gitignore").exists());
}
#[test]
fn test_ginit_yes_keeps_every_planned_line_of_the_output() {
let scenario = Scenario::new();
fs::write(scenario.dir().join("Cargo.toml"), "").unwrap();
let output = scenario
.ginit(&["ginit", "--add", "https://example.com/origin.git"])
.output()
.unwrap();
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
// ADR-0004: --yes only removes the question, so the same artifacts the
// preview promised are still reported and still on disk.
for expected in ["README.md", ".gitignore", "origin", "Initial branch"] {
assert!(stdout.contains(expected), "stdout was: {stdout}");
}
assert!(
!stdout.contains("Create these now?"),
"--yes must not prompt, stdout was: {stdout}"
);
assert!(scenario.read(".gitignore").contains("# Rust"));
assert!(scenario.git(&["remote", "-v"]).contains("origin"));
}
#[test]
fn test_ginit_readme_title_survives_spaces_and_unicode() {
for name in ["my project", "中文 项目", "Ünïcödé-ß"] {
let scenario = Scenario::new();
let titled = scenario.dir().join(name);
fs::create_dir(&titled).unwrap();
scenario
.command_in(&titled, &["ginit", "--yes"])
.assert()
.success();
let readme = fs::read_to_string(titled.join("README.md")).unwrap();
assert_eq!(readme, format!("# {name}\n"));
}
}
#[test]
fn test_ginit_profile_applies_identity_and_records_the_mapping() {
let scenario = Scenario::new();
scenario.write_config(
r#"version = "1"
[profiles.work]
name = "work"
user_name = "Work User"
user_email = "work@example.com"
"#,
);
scenario
.ginit(&["ginit", "--profile", "work"])
.assert()
.success();
assert_eq!(
scenario.git(&["config", "--local", "user.email"]),
"work@example.com"
);
let config = fs::read_to_string(&scenario.config).unwrap();
assert!(
config.contains("[repo_profiles]"),
"the directory should be mapped to the profile, got: {config}"
);
}
#[test]
fn test_ginit_missing_profile_fails_without_writing_config() {
let scenario = Scenario::new();
scenario.write_config(
r#"version = "1"
[profiles.work]
name = "work"
user_name = "Work User"
user_email = "work@example.com"
"#,
);
let config_before = fs::read_to_string(&scenario.config).unwrap();
scenario
.ginit(&["ginit", "--profile", "nope"])
.assert()
.failure()
.stderr(predicate::str::contains("profile add"));
assert_eq!(
fs::read_to_string(&scenario.config).unwrap(),
config_before,
"a rejected profile must leave the config untouched"
);
}
#[test]
fn test_ginit_hints_at_pushing_after_the_first_commit() {
let scenario = Scenario::new();
scenario
.ginit(&["ginit", "--add", "https://example.com/origin.git"])
.assert()
.success()
// The label already ends in a colon; matching the newline guards the
// doubled colon that `println!("{}:", …)` would produce.
.stdout(predicate::str::contains("Next steps:\n"))
.stdout(predicate::str::contains("quicommit commit --push"));
}
#[test]
fn test_ginit_on_a_bare_repository_skips_files_and_still_adds_the_remote() {
let scenario = Scenario::new();
scenario.git(&["init", "--bare"]);
scenario
.ginit(&["ginit", "--add", "https://example.com/mirror.git"])
.assert()
.success();
assert!(!scenario.dir().join("README.md").exists());
assert!(!scenario.dir().join(".gitignore").exists());
assert!(
scenario
.git(&["config", "--local", "remote.origin.url"])
.contains("https://example.com/mirror.git")
);
}

268
tests/gitignore_tests.rs Normal file
View File

@@ -0,0 +1,268 @@
use quicommit::git::GitRepo;
use std::fs;
use std::path::Path;
use std::process::Command;
use tempfile::TempDir;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/// Run a git command in the given directory, returning stdout as a String.
/// Panics if the command fails.
fn git(dir: &Path, args: &[&str]) -> String {
let output = Command::new("git")
.args(args)
.current_dir(dir)
.output()
.expect("Failed to execute git command");
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
panic!("git {:?} failed in {:?}: {}", args, dir, stderr);
}
String::from_utf8_lossy(&output.stdout).to_string()
}
/// Initialize a new git repo in the given directory and configure a local
/// user identity so commits can be created.
fn init_repo(dir: &Path) {
git(dir, &["init"]);
git(dir, &["config", "user.name", "Test User"]);
git(dir, &["config", "user.email", "test@example.com"]);
// Disable commit signing in case the global config enables it.
git(dir, &["config", "commit.gpgsign", "false"]);
}
/// Write a file with the given content, creating parent directories as needed.
fn write_file(dir: &Path, rel_path: &str, content: &str) {
let file_path = dir.join(rel_path);
if let Some(parent) = file_path.parent() {
fs::create_dir_all(parent).expect("Failed to create parent directories");
}
fs::write(&file_path, content).expect("Failed to write file");
}
/// Get the list of files in the index as a String (one path per line).
fn ls_files(dir: &Path) -> String {
git(dir, &["ls-files"])
}
// ---------------------------------------------------------------------------
// Tests for is_path_ignored
// ---------------------------------------------------------------------------
#[test]
fn test_is_path_ignored_with_ignored_path() {
let temp_dir = TempDir::new().unwrap();
let repo_path = temp_dir.path();
init_repo(repo_path);
write_file(repo_path, ".gitignore", "__pycache__/\n");
write_file(repo_path, "__pycache__/foo.pyc", "bytecode");
write_file(repo_path, "subdir/__pycache__/bar.pyc", "more bytecode");
let repo = GitRepo::open(repo_path).expect("Failed to open repo");
// Top-level ignored file
assert!(
repo.is_path_ignored("__pycache__/foo.pyc").unwrap(),
"__pycache__/foo.pyc should be ignored"
);
// Nested ignored file under a subdirectory
assert!(
repo.is_path_ignored("subdir/__pycache__/bar.pyc").unwrap(),
"subdir/__pycache__/bar.pyc should be ignored"
);
}
#[test]
fn test_is_path_ignored_with_non_ignored_path() {
let temp_dir = TempDir::new().unwrap();
let repo_path = temp_dir.path();
init_repo(repo_path);
write_file(repo_path, ".gitignore", "__pycache__/\n");
write_file(repo_path, "src/main.rs", "fn main() {}");
let repo = GitRepo::open(repo_path).expect("Failed to open repo");
assert!(
!repo.is_path_ignored("src/main.rs").unwrap(),
"src/main.rs should not be ignored"
);
}
// ---------------------------------------------------------------------------
// Tests for stage_all
// ---------------------------------------------------------------------------
#[test]
fn test_stage_all_removes_ignored_tracked_files() {
let temp_dir = TempDir::new().unwrap();
let repo_path = temp_dir.path();
init_repo(repo_path);
// Create and commit __pycache__/foo.pyc so it becomes a tracked file.
write_file(repo_path, "__pycache__/foo.pyc", "original bytecode");
git(repo_path, &["add", "__pycache__/foo.pyc"]);
git(repo_path, &["commit", "-m", "initial commit"]);
// Add a .gitignore that now ignores __pycache__/.
write_file(repo_path, ".gitignore", "__pycache__/\n");
// Modify the tracked file so the working tree has unstaged changes.
write_file(repo_path, "__pycache__/foo.pyc", "modified bytecode");
let repo = GitRepo::open(repo_path).expect("Failed to open repo");
let messages = quicommit::i18n::Messages::new(quicommit::config::Language::English);
let removed = repo.stage_all(&messages).expect("stage_all should succeed");
assert!(
removed.iter().any(|f| f == "__pycache__/foo.pyc"),
"stage_all should return __pycache__/foo.pyc in removed list, got: {:?}",
removed
);
// Verify the file is no longer in the index.
let files = ls_files(repo_path);
assert!(
!files.lines().any(|l| l == "__pycache__/foo.pyc"),
"__pycache__/foo.pyc should no longer be in the index, got: {}",
files
);
}
#[test]
fn test_stage_all_no_ignored_files_returns_empty() {
let temp_dir = TempDir::new().unwrap();
let repo_path = temp_dir.path();
init_repo(repo_path);
write_file(repo_path, ".gitignore", "*.log\n");
write_file(repo_path, "src/main.rs", "fn main() {}");
let repo = GitRepo::open(repo_path).expect("Failed to open repo");
let messages = quicommit::i18n::Messages::new(quicommit::config::Language::English);
let removed = repo.stage_all(&messages).expect("stage_all should succeed");
assert!(
removed.is_empty(),
"stage_all should return empty Vec when no ignored files are tracked, got: {:?}",
removed
);
// Verify src/main.rs was staged.
let files = ls_files(repo_path);
assert!(
files.lines().any(|l| l == "src/main.rs"),
"src/main.rs should be in the index, got: {}",
files
);
}
// ---------------------------------------------------------------------------
// Tests for stage_files
// ---------------------------------------------------------------------------
#[test]
fn test_stage_files_skips_ignored_paths() {
let temp_dir = TempDir::new().unwrap();
let repo_path = temp_dir.path();
init_repo(repo_path);
write_file(repo_path, ".gitignore", "__pycache__/\n");
write_file(repo_path, "__pycache__/foo.pyc", "bytecode");
write_file(repo_path, "src/main.rs", "fn main() {}");
let repo = GitRepo::open(repo_path).expect("Failed to open repo");
let skipped = repo
.stage_files(&["__pycache__/foo.pyc", "src/main.rs"])
.expect("stage_files should succeed");
assert!(
skipped.iter().any(|f| f == "__pycache__/foo.pyc"),
"skipped list should contain __pycache__/foo.pyc, got: {:?}",
skipped
);
assert!(
!skipped.iter().any(|f| f == "src/main.rs"),
"skipped list should not contain src/main.rs, got: {:?}",
skipped
);
let files = ls_files(repo_path);
assert!(
files.lines().any(|l| l == "src/main.rs"),
"src/main.rs should be staged, got: {}",
files
);
assert!(
!files.lines().any(|l| l == "__pycache__/foo.pyc"),
"__pycache__/foo.pyc should not be staged, got: {}",
files
);
}
#[test]
fn test_stage_files_all_paths_ignored() {
let temp_dir = TempDir::new().unwrap();
let repo_path = temp_dir.path();
init_repo(repo_path);
write_file(repo_path, ".gitignore", "__pycache__/\n");
write_file(repo_path, "__pycache__/foo.pyc", "bytecode");
let repo = GitRepo::open(repo_path).expect("Failed to open repo");
let skipped = repo
.stage_files(&["__pycache__/foo.pyc"])
.expect("stage_files should succeed");
assert!(
skipped.iter().any(|f| f == "__pycache__/foo.pyc"),
"skipped list should contain __pycache__/foo.pyc, got: {:?}",
skipped
);
// Verify the index remains empty (the file was not staged).
let files = ls_files(repo_path);
assert!(
files.trim().is_empty(),
"index should be empty, got: {}",
files
);
}
#[test]
fn test_stage_files_normal_paths_unchanged_behavior() {
let temp_dir = TempDir::new().unwrap();
let repo_path = temp_dir.path();
init_repo(repo_path);
// No .gitignore (or one that does not match these paths).
write_file(repo_path, "src/main.rs", "fn main() {}");
write_file(repo_path, "src/lib.rs", "pub fn lib() {}");
let repo = GitRepo::open(repo_path).expect("Failed to open repo");
let skipped = repo
.stage_files(&["src/main.rs", "src/lib.rs"])
.expect("stage_files should succeed");
assert!(
skipped.is_empty(),
"skipped list should be empty, got: {:?}",
skipped
);
let files = ls_files(repo_path);
assert!(
files.lines().any(|l| l == "src/main.rs"),
"src/main.rs should be staged, got: {}",
files
);
assert!(
files.lines().any(|l| l == "src/lib.rs"),
"src/lib.rs should be staged, got: {}",
files
);
}

File diff suppressed because it is too large Load Diff