2 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
22 changed files with 3741 additions and 46 deletions

2
.gitignore vendored
View File

@@ -26,6 +26,4 @@ config.toml
.qoder/ .qoder/
CLAUDE.md CLAUDE.md
**/agents/ **/agents/
adr/
.scratch/ .scratch/
CONTEXT.md

View File

@@ -5,13 +5,24 @@ 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/), 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). 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` 重新选择或手动修改 - 修复 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(中/英文)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 ## [0.8.1] - 2026-08-31

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,6 +1,6 @@
[package] [package]
name = "quicommit" name = "quicommit"
version = "0.8.2" version = "0.9.0"
edition = "2024" edition = "2024"
authors = ["Sidney Zhang <zly@lyzhang.me>"] authors = ["Sidney Zhang <zly@lyzhang.me>"]
description = "A powerful Git assistant tool with AI-powered commit/tag/changelog generation" description = "A powerful Git assistant tool with AI-powered commit/tag/changelog generation"

View File

@@ -1,5 +1,8 @@
# QuiCommit Roadmap # QuiCommit Roadmap
> 本路线图按「已完成 / 进行中 / 计划中 / 长远规划」四档跟踪功能演进,并为"计划中"条目标注优先级(P0 = 核心必做,P1 = 重要增强,P2 = 探索性方向)。
> 更新约定:条目完成后请及时勾选并移入"已完成",避免已完成事项长期滞留在"计划中"栏目;新增条目请附带动机说明与优先级。
## 已完成 ✅ ## 已完成 ✅
- [x] 基础 Git 操作(commit、tag、changelog) - [x] 基础 Git 操作(commit、tag、changelog)
@@ -14,98 +17,82 @@
- [x] 7 种语言国际化支持 - [x] 7 种语言国际化支持
- [x] 配置导出/导入(支持加密保护) - [x] 配置导出/导入(支持加密保护)
- [x] Profile Token 管理(PAT 等) - [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. Git 凭证管理器 ### 1. Agent Skills 技能包完善 `P0`(新增)
将 Git 凭证管理集成到 QuiCommit 中,统一管理 HTTPS 仓库的身份认证。 **动机**:`.claude/skills`、`skills/` 目录下现有内容零散,需要系统化沉淀为可复用的 QuiCommit Agent 技能,让编码助手(Claude、Qoder 等)能够开箱用地安装、调用 QuiCommit。
- [x] **Git Credential Helper 集成** - [ ] **安装类技能:`install-quicommit`**
- 实现 `git credential-store` / `git-credential-libsecret` 等标准的 credential helper 协议 - 指导用户 / Agent 在 Windows / macOS / Linux 上安装 QuiCommit:Cargo 安装、预编译二进制、源码构建等途径
- 支持 `quicommit credential get|store|erase` 子命令 - 覆盖首次运行前的环境依赖检查(Git、系统密钥环服务等)
- 与系统密钥环无缝对接,复用已有的 `KeyringManager` - [ ] **使用指导类技能:`use-quicommit`**
- 面向 Agent 的命令速查与调用范式:`commit` / `tag` / `changelog` / `config` / `profile` / `credential`,以及新增的 `ginit`
- 说明各命令的输入前提(如需要先 `quicommit init` 配置 LLM Provider)、典型输出与常见失败排查
- [ ] **技能内容规范**
- 统一采用 SKILL.md 结构(触发场景 / 使用方式 / 注意事项),与 `.claude/skills` 现有约定保持一致
- 技能内示例命令、参数需要与 `src/commands/` 的实际实现保持同步,避免文档漂移
- [ ] **发布与复用**
- 评估随 crate 发布(打包进 `skills/`)还是通过独立仓库 / 技能市场分发
- [x] **跨平台支持** ### 2. 新增模型支持 `P1`
- Windows:集成 Windows Credential Manager
- macOS:集成 Keychain
- Linux:通过 Secret Service / D-Bus 对接 GNOME Keyring / KWallet
- [x] **安全增强**
- 支持 PAT(Personal Access Token)按 scope / 有效期管理
- 支持凭证过期检查和自动提醒
---
### 2. 新增模型支持
扩展 LLM 提供商和模型覆盖范围,满足更多场景和偏好。 扩展 LLM 提供商和模型覆盖范围,满足更多场景和偏好。
- [x] **新增 DeepSeek 最新模型**
- 支持 `deepseek-chat`(DeepSeek-V3)
- 支持 `deepseek-reasoner`(DeepSeek-R1)
- 支持 `deepseek-v4`
- [ ] **新增国内模型提供商** - [ ] **新增国内模型提供商**
- 通义千问 (Qwen) — 阿里云 DashScope API - 通义千问 (Qwen) — 阿里云 DashScope API
- 文心一言 (ERNIE) — 百度千帆 API - 文心一言 (ERNIE) — 百度千帆 API
- 智谱 GLM — ChatGLM API - 智谱 GLM — ChatGLM API
- 百川 (Baichuan) — Baichuan API - 百川 (Baichuan) — Baichuan API
- [ ] **新增国际模型提供商** - [ ] **新增国际模型提供商**
- Google Gemini API - Google Gemini API
- Mistral AI API - Mistral AI API
- Cohere API - Cohere API
- Groq (LPU 推理加速) - Groq (LPU 推理加速)
- [ ] **本地模型扩展** - [ ] **本地模型扩展**
- 支持 llama.cpp 服务端(兼容 OpenAI API 格式) - 支持 llama.cpp 服务端(兼容 OpenAI API 格式)
- 支持 vLLM 部署的模型 - 支持 vLLM 部署的模型
- 本地模型推荐列表与一键配置向导 - 本地模型推荐列表与一键配置向导
- [ ] **模型能力适配** - [ ] **模型能力适配**
- 不同模型的 token 限制自适应 - 不同模型的 token 限制自适应
- 模型特定的 prompt 模板优化 - 模型特定的 prompt 模板优化
- 支持 function calling / tool use(用于复杂生成场景) - 支持 function calling / tool use(用于复杂生成场景)
--- ### 3. 生成体验优化 `P1`
### 3. 生成体验优化
提升 AI 生成提交信息、标签说明和变更日志时的用户体验。 提升 AI 生成提交信息、标签说明和变更日志时的用户体验。
- [ ] **流式输出与实时反馈** - [ ] **流式输出与实时反馈**
- [x] 支持 SSE(Server-Sent Events)流式生成 - [ ] 终端打字机效果实时显示生成内容
- [ ]终端打字机效果实时显示生成内容 - [ ] 流式生成过程中支持 `Ctrl+C` 中断
- [ ]流式生成过程中支持 `Ctrl+C` 中断
- [ ] **生成质量提升** - [ ] **生成质量提升**
- 基于 commitlint 规则的后校验与自动修正 - 基于 commitlint 规则的后校验与自动修正
- 支持 Few-shot 示例引导(用户可自定义示例库) - 支持 Few-shot 示例引导(用户可自定义示例库)
- 生成结果的置信度评分与多候选方案 - 生成结果的置信度评分与多候选方案
- [ ] **Diff 上下文增强** - [ ] **Diff 上下文增强**
- 智能 diff 摘要(大改动时自动压缩关键信息) - 智能 diff 摘要(大改动时自动压缩关键信息)
- 支持 `.gitattributes` 排除/包含规则 - 支持 `.gitattributes` 排除 / 包含规则
- 按文件类型分组生成更精准的提交描述 - 按文件类型分组生成更精准的提交描述
- [ ] **交互式编辑增强** - [ ] **交互式编辑增强**
- 生成后支持内联编辑(类似 `git rebase -i` 体验) - 生成后支持内联编辑(类似 `git rebase -i` 体验)
- 支持重新生成指定部分(如 scope、description) - 支持重新生成指定部分(如 scope、description)
- 历史提交信息学习与风格适配 - 历史提交信息学习与风格适配
- [ ] **批量操作支持** - [ ] **批量操作支持**
- 批量生成多个 commit(分组暂存区变更) - 批量生成多个 commit(分组暂存区变更)
- `--dry-run` 预览模式(只生成本地查看,不写 Git) - `--dry-run` 预览模式(只生成本地查看,不写 Git)
- [ ] **性能优化** - [ ] **性能优化**
- API 请求并发优化(多个模型同时生成候选) - API 请求并发优化(多个模型同时生成候选)
- 本地缓存常用 prompt 模板 - 本地缓存常用 prompt 模板

View File

@@ -15,6 +15,7 @@ A powerful AI-powered Git assistant for generating conventional commits, tags, a
## Features ## 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 - **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 - **Conventional Commits**: Full support for Conventional Commits and commitlint formats
- **Profile Management**: Manage multiple Git identities with SSH keys and GPG signing support - **Profile Management**: Manage multiple Git identities with SSH keys and GPG signing support
@@ -48,6 +49,28 @@ Requirements: Rust 1.70+, Git 2.0+
## Quick Start ## 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 ### Initialize
```bash ```bash
@@ -307,6 +330,7 @@ quicommit config reset --force
| Command | Alias | Description | | Command | Alias | Description |
|---------|-------|-------------| |---------|-------|-------------|
| `quicommit init` | `i` | Initialize configuration | | `quicommit init` | `i` | Initialize configuration |
| `quicommit ginit` | `gi` | Initialize a local Git repository |
| `quicommit commit` | `c` | Generate and create commit | | `quicommit commit` | `c` | Generate and create commit |
| `quicommit tag` | `t` | Generate and create tag | | `quicommit tag` | `t` | Generate and create tag |
| `quicommit changelog` | `cl` | Generate changelog | | `quicommit changelog` | `cl` | Generate changelog |
@@ -314,6 +338,18 @@ quicommit config reset --force
| `quicommit config` | `cfg` | Manage settings | | `quicommit config` | `cfg` | Manage settings |
| `quicommit credential` | — | Git credential helper (hidden, invoked by Git) | | `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 ### Commit Options
| Option | Description | | Option | Description |
@@ -377,6 +413,8 @@ quicommit config reset --force
## Configuration File ## 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: Location:
- Linux: `~/.config/quicommit/config.toml` - Linux: `~/.config/quicommit/config.toml`
- macOS: `~/Library/Application Support/quicommit/config.toml` - macOS: `~/Library/Application Support/quicommit/config.toml`
@@ -551,6 +589,7 @@ src/
│ ├── changelog.rs │ ├── changelog.rs
│ ├── profile.rs │ ├── profile.rs
│ ├── config.rs │ ├── config.rs
│ ├── ginit.rs
│ └── init.rs │ └── init.rs
├── config/ # Configuration management ├── config/ # Configuration management
│ ├── manager.rs │ ├── manager.rs
@@ -559,7 +598,8 @@ src/
├── git/ # Git operations ├── git/ # Git operations
│ ├── commit.rs │ ├── commit.rs
│ ├── tag.rs │ ├── tag.rs
│ └── changelog.rs │ ├── changelog.rs
│ └── init.rs
├── llm/ # LLM integration (rig-core based) ├── llm/ # LLM integration (rig-core based)
│ ├── mod.rs │ ├── mod.rs
│ ├── prompts.rs │ ├── prompts.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

@@ -14,6 +14,7 @@
## 主要功能 ## 主要功能
- **仓库初始化**:用 `quicommit ginit` 一键起步本地 Git 仓库——建库、写最简 `README.md`、按识别出的生态生成 `.gitignore`、确立初始分支、添加远程。全程离线,不创建首个提交
- **AI智能生成**:使用LLM API(Ollama本地、OpenAI、Anthropic Claude、Kimi、DeepSeek、OpenRouter)生成提交信息、标签和变更日志 - **AI智能生成**:使用LLM API(Ollama本地、OpenAI、Anthropic Claude、Kimi、DeepSeek、OpenRouter)生成提交信息、标签和变更日志
- **规范化提交**:支持Conventional Commits和commitlint格式规范 - **规范化提交**:支持Conventional Commits和commitlint格式规范
- **多配置管理**:为不同场景管理多个Git身份,支持SSH密钥和GPG签名配置 - **多配置管理**:为不同场景管理多个Git身份,支持SSH密钥和GPG签名配置
@@ -47,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 ```bash
@@ -301,6 +324,7 @@ quicommit config reset --force
| 命令 | 别名 | 说明 | | 命令 | 别名 | 说明 |
|------|------|------| |------|------|------|
| `quicommit init` | `i` | 初始化配置 | | `quicommit init` | `i` | 初始化配置 |
| `quicommit ginit` | `gi` | 初始化本地 Git 仓库 |
| `quicommit commit` | `c` | 生成并执行提交 | | `quicommit commit` | `c` | 生成并执行提交 |
| `quicommit tag` | `t` | 生成并创建标签 | | `quicommit tag` | `t` | 生成并创建标签 |
| `quicommit changelog` | `cl` | 生成变更日志 | | `quicommit changelog` | `cl` | 生成变更日志 |
@@ -308,6 +332,18 @@ quicommit config reset --force
| `quicommit config` | `cfg` | 管理应用配置 | | `quicommit config` | `cfg` | 管理应用配置 |
| `quicommit credential` | — | Git凭据助手(隐藏,由Git调用) | | `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命令选项 ### commit命令选项
| 选项 | 说明 | | 选项 | 说明 |
@@ -371,6 +407,8 @@ quicommit config reset --force
## 配置文件 ## 配置文件
> 完整的分类配置说明(快速设置、逐字段详解、provider 默认值、常见配方与故障排除)见 [docs/configuration-guide.md](docs/configuration-guide.md)。
配置文件位置: 配置文件位置:
- Linux: `~/.config/quicommit/config.toml` - Linux: `~/.config/quicommit/config.toml`
- macOS: `~/Library/Application Support/quicommit/config.toml` - macOS: `~/Library/Application Support/quicommit/config.toml`
@@ -545,6 +583,7 @@ src/
│ ├── changelog.rs │ ├── changelog.rs
│ ├── profile.rs │ ├── profile.rs
│ ├── config.rs │ ├── config.rs
│ ├── ginit.rs
│ └── init.rs │ └── init.rs
├── config/ # 配置管理 ├── config/ # 配置管理
│ ├── manager.rs │ ├── manager.rs
@@ -553,7 +592,8 @@ src/
├── git/ # Git操作封装 ├── git/ # Git操作封装
│ ├── commit.rs │ ├── commit.rs
│ ├── tag.rs │ ├── tag.rs
│ └── changelog.rs │ ├── changelog.rs
│ └── init.rs
├── llm/ # LLM 集成(基于 rig-core) ├── llm/ # LLM 集成(基于 rig-core)
│ ├── mod.rs │ ├── mod.rs
│ ├── prompts.rs │ ├── prompts.rs

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

@@ -2,6 +2,7 @@ pub mod changelog;
pub mod commit; pub mod commit;
pub mod config; pub mod config;
pub mod credential; pub mod credential;
pub mod ginit;
pub mod init; pub mod init;
pub mod profile; pub mod profile;
pub mod tag; pub mod tag;

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"));
}
}

View File

@@ -5,6 +5,7 @@ use std::path::{Component, Path, PathBuf};
pub mod changelog; pub mod changelog;
pub mod commit; pub mod commit;
pub mod init;
pub mod tag; pub mod tag;
fn normalize_path_for_git2(path: &Path) -> PathBuf { fn normalize_path_for_git2(path: &Path) -> PathBuf {

View File

@@ -4982,6 +4982,502 @@ impl Messages {
} }
} }
} }
// ---- ginit command ----
pub fn ginit_title(&self) -> &str {
match self.language {
Language::English => "Local repository initialization",
Language::Chinese => "本地仓库初始化",
Language::Japanese => "ローカルリポジトリの初期化",
Language::Korean => "로컬 리포지토리 초기화",
Language::Spanish => "Inicialización del repositorio local",
Language::French => "Initialisation du dépôt local",
Language::German => "Lokale Repository-Initialisierung",
}
}
pub fn ginit_directory_label(&self) -> &str {
match self.language {
Language::English => "Directory",
Language::Chinese => "目录",
Language::Japanese => "ディレクトリ",
Language::Korean => "디렉터리",
Language::Spanish => "Directorio",
Language::French => "Répertoire",
Language::German => "Verzeichnis",
}
}
pub fn ginit_repository_label(&self) -> &str {
match self.language {
Language::English => "Repository",
Language::Chinese => "仓库",
Language::Japanese => "リポジトリ",
Language::Korean => "리포지토리",
Language::Spanish => "Repositorio",
Language::French => "Dépôt",
Language::German => "Repository",
}
}
pub fn ginit_repository_action_create(&self) -> &str {
match self.language {
Language::English => "will create a new repository",
Language::Chinese => "将创建新仓库",
Language::Japanese => "新しいリポジトリを作成します",
Language::Korean => "새 리포지토리 생성",
Language::Spanish => "creará un repositorio nuevo",
Language::French => "créera un dépôt",
Language::German => "erstellt ein neues Repository",
}
}
pub fn ginit_repository_action_keep(&self) -> &str {
match self.language {
Language::English => "already a repository, left as is",
Language::Chinese => "已是仓库,保持不变",
Language::Japanese => "既にリポジトリのため変更しません",
Language::Korean => "이미 리포지토리, 그대로 유지",
Language::Spanish => "ya es un repositorio, se mantiene",
Language::French => "déjà un dépôt, inchangé",
Language::German => "ist bereits ein Repository, bleibt unverändert",
}
}
pub fn ginit_branch_label(&self) -> &str {
match self.language {
Language::English => "Initial branch",
Language::Chinese => "初始分支",
Language::Japanese => "初期ブランチ",
Language::Korean => "초기 브랜치",
Language::Spanish => "Rama inicial",
Language::French => "Branche initiale",
Language::German => "Erster Branch",
}
}
pub fn ginit_branch_from_flag(&self) -> &str {
match self.language {
Language::English => "from --branch",
Language::Chinese => "来自 --branch",
Language::Japanese => "--branch で指定",
Language::Korean => "--branch 지정",
Language::Spanish => "de --branch",
Language::French => "indiquée par --branch",
Language::German => "aus --branch",
}
}
pub fn ginit_branch_from_git_config(&self) -> &str {
match self.language {
Language::English => "from init.defaultBranch",
Language::Chinese => "来自 init.defaultBranch",
Language::Japanese => "init.defaultBranch より",
Language::Korean => "init.defaultBranch 값",
Language::Spanish => "de init.defaultBranch",
Language::French => "issue de init.defaultBranch",
Language::German => "aus init.defaultBranch",
}
}
pub fn ginit_branch_from_default(&self) -> &str {
match self.language {
Language::English => "default",
Language::Chinese => "默认",
Language::Japanese => "既定",
Language::Korean => "기본값",
Language::Spanish => "predeterminada",
Language::French => "par défaut",
Language::German => "Standard",
}
}
pub fn ginit_branch_kept_commits(&self) -> &str {
match self.language {
Language::English => "the repository already has commits, branch unchanged",
Language::Chinese => "仓库已有提交,分支保持不变",
Language::Japanese => "リポジトリにコミットがあるためブランチは変更しません",
Language::Korean => "리포지토리에 커밋이 있어 브랜치를 변경하지 않음",
Language::Spanish => "el repositorio ya tiene commits, rama sin cambios",
Language::French => "le dépôt a déjà des commits, branche inchangée",
Language::German => "das Repository hat bereits Commits, Branch unverändert",
}
}
pub fn ginit_branch_unchanged(&self) -> &str {
match self.language {
Language::English => "existing repository, branch untouched",
Language::Chinese => "已是仓库,分支不变",
Language::Japanese => "既存のリポジトリのためブランチを変更しません",
Language::Korean => "이미 리포지토리, 브랜치를 변경하지 않음",
Language::Spanish => "repositorio existente, rama sin cambios",
Language::French => "dépôt existant, branche inchangée",
Language::German => "Repository vorhanden, Branch unverändert",
}
}
pub fn ginit_files_present(&self, count: usize) -> String {
match self.language {
Language::English => format!("the directory already contains {} entries", count),
Language::Chinese => format!("目录中已有 {} 个条目", count),
Language::Japanese => format!("ディレクトリに {} 件の項目があります", count),
Language::Korean => format!("디렉터리에 항목 {}개 있음", count),
Language::Spanish => format!("el directorio ya contiene {} entradas", count),
Language::French => format!("le répertoire contient déjà {} entrées", count),
Language::German => format!("das Verzeichnis enthält bereits {} Einträge", count),
}
}
pub fn ginit_status_will_create(&self) -> &str {
match self.language {
Language::English => "will be created",
Language::Chinese => "将创建",
Language::Japanese => "作成します",
Language::Korean => "생성 예정",
Language::Spanish => "se creará",
Language::French => "sera créé",
Language::German => "wird erstellt",
}
}
pub fn ginit_status_created(&self) -> &str {
match self.language {
Language::English => "created",
Language::Chinese => "已创建",
Language::Japanese => "作成しました",
Language::Korean => "생성함",
Language::Spanish => "creado",
Language::French => "créé",
Language::German => "erstellt",
}
}
pub fn ginit_status_exists(&self) -> &str {
match self.language {
Language::English => "already exists, untouched",
Language::Chinese => "已存在,未改动",
Language::Japanese => "既存のため変更なし",
Language::Korean => "이미 존재, 그대로 둠",
Language::Spanish => "ya existe, intacto",
Language::French => "existe déjà, inchangé",
Language::German => "existiert bereits, unverändert",
}
}
pub fn ginit_status_added(&self) -> &str {
match self.language {
Language::English => "added",
Language::Chinese => "已添加",
Language::Japanese => "追加しました",
Language::Korean => "추가함",
Language::Spanish => "añadido",
Language::French => "ajouté",
Language::German => "hinzugefügt",
}
}
pub fn ginit_status_applied(&self) -> &str {
match self.language {
Language::English => "applied",
Language::Chinese => "已应用",
Language::Japanese => "適用しました",
Language::Korean => "적용함",
Language::Spanish => "aplicado",
Language::French => "appliqué",
Language::German => "angewendet",
}
}
pub fn ginit_status_skipped_bare(&self) -> &str {
match self.language {
Language::English => "skipped, a bare repository has no working directory",
Language::Chinese => "已跳过:裸仓库没有工作目录",
Language::Japanese => "スキップ:ベアリポジトリに作業ディレクトリはありません",
Language::Korean => "건너뜀: 베어 리포지토리에는 작업 디렉터리가 없음",
Language::Spanish => "omitido, un repositorio bare no tiene directorio de trabajo",
Language::French => "ignoré, un dépôt nu n'a pas de répertoire de travail",
Language::German => "übersprungen, ein Bare Repository hat kein Arbeitsverzeichnis",
}
}
pub fn ginit_ecosystems_label(&self) -> &str {
match self.language {
Language::English => "Ecosystem markers",
Language::Chinese => "生态标志文件",
Language::Japanese => "エコシステムマーカー",
Language::Korean => "생태계 마커",
Language::Spanish => "Marcadores de ecosistema",
Language::French => "Marqueurs d'écosystème",
Language::German => "Ökosystem-Marker",
}
}
pub fn ginit_sections_label(&self) -> &str {
match self.language {
Language::English => ".gitignore sections",
Language::Chinese => ".gitignore 分段",
Language::Japanese => ".gitignore セクション",
Language::Korean => ".gitignore 섹션",
Language::Spanish => "Secciones de .gitignore",
Language::French => "Sections du .gitignore",
Language::German => ".gitignore-Abschnitte",
}
}
pub fn ginit_remotes_label(&self) -> &str {
match self.language {
Language::English => "Remotes",
Language::Chinese => "远程",
Language::Japanese => "リモート",
Language::Korean => "원격 저장소",
Language::Spanish => "Remotos",
Language::French => "Distants",
Language::German => "Remotes",
}
}
pub fn ginit_confirm_prompt(&self) -> &str {
match self.language {
Language::English => "Create these now?",
Language::Chinese => "现在执行以上操作?",
Language::Japanese => "今すぐこの操作を実行しますか?",
Language::Korean => "지금 이 작업을 실행할까요?",
Language::Spanish => "¿Ejecutar esto ahora?",
Language::French => "Exécuter ces opérations maintenant ?",
Language::German => "Diese Schritte jetzt ausführen?",
}
}
pub fn ginit_dry_run_note(&self) -> &str {
match self.language {
Language::English => "Dry run - nothing was written.",
Language::Chinese => "试运行 - 未写入任何内容。",
Language::Japanese => "ドライラン - 何も書き込まれませんでした。",
Language::Korean => "드라이 런 - 아무것도 작성되지 않음.",
Language::Spanish => "Ejecución en seco: no se escribió nada.",
Language::French => "Exécution à blanc - rien n'a été écrit.",
Language::German => "Testlauf - nichts wurde geschrieben.",
}
}
pub fn ginit_success(&self) -> &str {
match self.language {
Language::English => "Repository initialized.",
Language::Chinese => "仓库初始化完成。",
Language::Japanese => "リポジトリを初期化しました。",
Language::Korean => "리포지토리 초기화 완료.",
Language::Spanish => "Repositorio inicializado.",
Language::French => "Dépôt initialisé.",
Language::German => "Repository initialisiert.",
}
}
pub fn ginit_nested_repo(&self, outer: &str) -> String {
match self.language {
Language::English => format!(
"This directory is inside the repository at {outer}. Run ginit in that repository root or outside any repository.",
),
Language::Chinese => format!(
"当前目录位于仓库 {outer} 之内。请在该仓库根目录运行,或在任何仓库之外运行。",
),
Language::Japanese => format!(
"このディレクトリは {outer} のリポジトリ内にあります。そのリポジトリのルート、またはリポジトリ外で実行してください。",
),
Language::Korean => format!(
"이 디렉터리는 {outer} 리포지토리 안에 있습니다. 해당 리포지토리 루트 또는 리포지토리 밖에서 실행하세요.",
),
Language::Spanish => format!(
"Este directorio está dentro del repositorio {outer}. Ejecute ginit en la raíz de ese repositorio o fuera de cualquier repositorio.",
),
Language::French => format!(
"Ce répertoire est dans le dépôt {outer}. Exécutez ginit à la racine de ce dépôt ou hors de tout dépôt.",
),
Language::German => format!(
"Dieses Verzeichnis liegt im Repository {outer}. Führen Sie ginit im Stammverzeichnis dieses Repositories oder außerhalb jedes Repositories aus.",
),
}
}
pub fn ginit_profile_hint(&self) -> &str {
match self.language {
Language::English => "Create one with: quicommit profile add",
Language::Chinese => "可通过 quicommit profile add 创建一个",
Language::Japanese => "quicommit profile add で作成できます",
Language::Korean => "quicommit profile add로 프로필을 만들 수 있습니다",
Language::Spanish => "Cree uno con: quicommit profile add",
Language::French => "Créez-en un avec : quicommit profile add",
Language::German => "Erstellen Sie einen mit: quicommit profile add",
}
}
pub fn ginit_remote_missing_url(&self, value: &str) -> String {
match self.language {
Language::English => {
format!("--add '{value}' has no URL. Use --add <URL> or --add <NAME>=<URL>.",)
}
Language::Chinese => {
format!("--add '{value}' 缺少地址。请使用 --add <URL> 或 --add <名称>=<URL>。",)
}
Language::Japanese => format!(
"--add '{value}' に URL がありません。--add <URL> または --add <名前>=<URL> を使ってください。",
),
Language::Korean => format!(
"--add '{value}'에 URL이 없습니다. --add <URL> 또는 --add <이름>=<URL>을 사용하세요.",
),
Language::Spanish => {
format!("--add '{value}' no tiene URL. Use --add <URL> o --add <NOMBRE>=<URL>.",)
}
Language::French => {
format!("--add '{value}' n'a pas d'URL. Utilisez --add <URL> ou --add <NOM>=<URL>.",)
}
Language::German => format!(
"--add '{value}' enthält keine URL. Verwenden Sie --add <URL> oder --add <NAME>=<URL>.",
),
}
}
pub fn ginit_remote_duplicate(&self, name: &str) -> String {
match self.language {
Language::English => format!("Remote name '{name}' is given more than once."),
Language::Chinese => format!("远程名称 '{name}' 被重复指定。"),
Language::Japanese => format!("リモート名 '{name}' が複数回指定されています。"),
Language::Korean => format!("원격 저장소 이름 '{name}'이(가) 중복 지정되었습니다."),
Language::Spanish => format!("El nombre de remoto '{name}' se indica más de una vez."),
Language::French => format!("Le nom distant '{name}' est donné plusieurs fois."),
Language::German => format!("Remote-Name '{name}' wurde mehrfach angegeben."),
}
}
pub fn ginit_remote_invalid_name(&self, name: &str) -> String {
match self.language {
Language::English => format!("'{name}' is not a valid remote name."),
Language::Chinese => format!("'{name}' 不是合法的远程名称。"),
Language::Japanese => format!("'{name}' は有効なリモート名ではありません。"),
Language::Korean => format!("'{name}'은(는) 유효한 원격 이름이 아닙니다."),
Language::Spanish => format!("'{name}' no es un nombre de remoto válido."),
Language::French => format!("'{name}' n'est pas un nom distant valide."),
Language::German => format!("'{name}' ist kein gültiger Remote-Name."),
}
}
pub fn ginit_remote_exists(&self, name: &str) -> String {
match self.language {
Language::English => format!(
"Remote '{name}' already exists; its URL was not changed. Use git remote set-url {name} <url> to repoint it.",
),
Language::Chinese => format!(
"远程 '{name}' 已存在,其地址未被改动。如需修改请使用 git remote set-url {name} <URL>。",
),
Language::Japanese => format!(
"リモート '{name}' は既に存在し、URL は変更していません。変更するには git remote set-url {name} <URL> を使ってください。",
),
Language::Korean => format!(
"원격 저장소 '{name}'이(가) 이미 존재하며 URL을 변경하지 않았습니다. 변경하려면 git remote set-url {name} <URL>을 사용하세요.",
),
Language::Spanish => format!(
"El remoto '{name}' ya existe; su URL no se ha modificado. Use git remote set-url {name} <url> para cambiarla.",
),
Language::French => format!(
"Le dépôt distant '{name}' existe déjà ; son URL n'a pas été modifiée. Utilisez git remote set-url {name} <url> pour la changer.",
),
Language::German => format!(
"Remote '{name}' existiert bereits; seine URL wurde nicht geändert. Verwenden Sie git remote set-url {name} <url>, um sie zu ändern.",
),
}
}
pub fn ginit_readme_no_name(&self) -> &str {
match self.language {
Language::English => "README.md skipped: this directory has no usable name.",
Language::Chinese => "已跳过 README.md:当前目录没有可用的名称。",
Language::Japanese => {
"README.md をスキップしました:このディレクトリに使える名前がありません。"
}
Language::Korean => "README.md 건너뜀: 이 디렉터리에 사용할 수 있는 이름이 없습니다.",
Language::Spanish => {
"README.md omitido: este directorio no tiene un nombre utilizable."
}
Language::French => "README.md ignoré : ce répertoire n'a pas de nom utilisable.",
Language::German => {
"README.md übersprungen: dieses Verzeichnis hat keinen verwendbaren Namen."
}
}
}
pub fn ginit_next_identity(&self) -> &str {
match self.language {
Language::English => {
"Set a git identity first: git config user.name/user.email, or quicommit ginit --profile <name>"
}
Language::Chinese => {
"先配置 git 身份:git config user.name/user.email,或 quicommit ginit --profile <名称>"
}
Language::Japanese => {
"まず git の識別情報を設定してください: git config user.name/user.email、または quicommit ginit --profile <名前>"
}
Language::Korean => {
"먼저 git 신원을 설정하세요: git config user.name/user.email 또는 quicommit ginit --profile <이름>"
}
Language::Spanish => {
"Configure primero una identidad de git: git config user.name/user.email, o quicommit ginit --profile <nombre>"
}
Language::French => {
"Définissez d'abord une identité git : git config user.name/user.email, ou quicommit ginit --profile <nom>"
}
Language::German => {
"Zuerst eine Git-Identität festlegen: git config user.name/user.email, oder quicommit ginit --profile <Name>"
}
}
}
pub fn ginit_next_commit(&self) -> &str {
match self.language {
Language::English => "Create the first commit: quicommit commit",
Language::Chinese => "创建首个提交:quicommit commit",
Language::Japanese => "最初のコミットを作成: quicommit commit",
Language::Korean => "첫 커밋 생성: quicommit commit",
Language::Spanish => "Cree el primer commit: quicommit commit",
Language::French => "Créez le premier commit : quicommit commit",
Language::German => "Ersten Commit erstellen: quicommit commit",
}
}
pub fn ginit_next_push(&self) -> &str {
match self.language {
Language::English => "After that commit, push it: quicommit commit --push",
Language::Chinese => "完成首个提交后推送:quicommit commit --push",
Language::Japanese => "そのコミット後にプッシュ: quicommit commit --push",
Language::Korean => "해당 커밋 후 푸시: quicommit commit --push",
Language::Spanish => "Tras ese commit, envíelo: quicommit commit --push",
Language::French => "Après ce commit, poussez-le : quicommit commit --push",
Language::German => "Nach diesem Commit pushen: quicommit commit --push",
}
}
pub fn ginit_failed(&self, count: usize) -> String {
match self.language {
Language::English => format!("{} step(s) failed; completed steps were kept.", count),
Language::Chinese => format!("{} 个步骤失败;已完成的步骤已保留。", count),
Language::Japanese => format!(
"{} ステップが失敗しました。完了したステップは保持されています。",
count
),
Language::Korean => format!("실패한 단계 {}개; 완료된 단계는 유지됩니다.", count),
Language::Spanish => format!(
"{} paso(s) fallaron; los pasos completados se conservan.",
count
),
Language::French => format!(
"{} étape(s) échouée(s) ; les étapes terminées sont conservées.",
count
),
Language::German => format!(
"{} Schritt(e) fehlgeschlagen; abgeschlossene Schritte bleiben erhalten.",
count
),
}
}
} }
#[cfg(test)] #[cfg(test)]

View File

@@ -5,7 +5,8 @@ use tracing::debug;
use quicommit::commands::{ use quicommit::commands::{
changelog::ChangelogCommand, commit::CommitCommand, config::ConfigCommand, changelog::ChangelogCommand, commit::CommitCommand, config::ConfigCommand,
credential::CredentialCommand, init::InitCommand, profile::ProfileCommand, tag::TagCommand, credential::CredentialCommand, ginit::GinitCommand, init::InitCommand, profile::ProfileCommand,
tag::TagCommand,
}; };
/// QuiCommit - AI-powered Git assistant /// QuiCommit - AI-powered Git assistant
@@ -50,6 +51,10 @@ enum Commands {
#[command(alias = "i")] #[command(alias = "i")]
Init(InitCommand), Init(InitCommand),
/// Initialize a local Git repository
#[command(alias = "gi")]
Ginit(GinitCommand),
/// Generate and execute conventional commits /// Generate and execute conventional commits
#[command(alias = "c")] #[command(alias = "c")]
Commit(CommitCommand), Commit(CommitCommand),
@@ -129,6 +134,7 @@ async fn main() -> Result<()> {
match cli.command { match cli.command {
Commands::Init(cmd) => cmd.execute(config_path).await, 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::Commit(cmd) => cmd.execute(config_path).await,
Commands::Tag(cmd) => cmd.execute(config_path).await, Commands::Tag(cmd) => cmd.execute(config_path).await,
Commands::Changelog(cmd) => cmd.execute(config_path).await, Commands::Changelog(cmd) => cmd.execute(config_path).await,

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")
);
}