mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-19 10:02:04 +08:00
feat: add commit message generation feature
This commit is contained in:
@@ -0,0 +1,623 @@
|
||||
# Commit Message Generation — Implementation Plan
|
||||
|
||||
## 1. Overview
|
||||
|
||||
This plan adds **LLM-powered commit message generation** to the Kilo platform with three surfaces: a **CLI command** (`kilo commit`), an **HTTP route** (`POST /commit-message`), and a **VS Code SCM panel button**. All three delegate to a shared core module that handles git context gathering, prompt building, and LLM interaction.
|
||||
|
||||
### How this differs from the old implementation
|
||||
|
||||
The old extension (kilocode-5) called LLMs directly from the extension process using `buildApiHandler()` and provider-specific handlers. This architecture uses a **shared backend module** instead — the backend handles model selection, prompt building, and LLM communication. The extension is a thin HTTP client, and the CLI command calls the core directly.
|
||||
|
||||
| Aspect | Old extension | This architecture |
|
||||
|--------|--------------|-------------------|
|
||||
| LLM calls | Direct from extension → LLM provider | Shared core module in CLI backend |
|
||||
| Auth | API keys stored in extension settings | OAuth/API key managed by CLI backend's `Auth` module |
|
||||
| Model selection | User-configurable `commitMessageApiConfigId` | Automatic: `Provider.getSmallModel()` |
|
||||
| Prompt location | In extension code | In shared core module |
|
||||
| Surfaces | VS Code + JetBrains adapters | CLI command + HTTP route + VS Code |
|
||||
| Git context | Gathered in extension | Gathered server-side in shared core |
|
||||
| Prompt customization | Custom template override setting | Not in v1 |
|
||||
|
||||
---
|
||||
|
||||
## 2. Backend Investigation
|
||||
|
||||
Investigation of the CLI backend (`packages/opencode/`) revealed existing infrastructure that the commit message feature can reuse directly.
|
||||
|
||||
### What EXISTS in the backend
|
||||
|
||||
| Component | Location | Description |
|
||||
|-----------|----------|-------------|
|
||||
| `small_model` config | [`config.ts:1133`](../../packages/opencode/src/config/config.ts:1133) | Optional config field: `small_model: ModelId.describe("Small model to use for tasks like title generation").optional()` |
|
||||
| `Provider.getSmallModel()` | [`provider.ts:1171`](../../packages/opencode/src/provider/provider.ts:1171) | Resolves small model with priority: user-configured → auto-detected → kilo fallback → undefined |
|
||||
| Title generation | [`summary.ts:130`](../../packages/opencode/src/session/summary.ts:130) | Uses small_model + "title" agent — reference pattern |
|
||||
| Agent prompts | `src/agent/prompt/` | Existing agents: ask, compaction, debug, explore, orchestrator, summary, title |
|
||||
| Server routes | [`server.ts:227`](../../packages/opencode/src/server/server.ts:227) | Hono-based HTTP server with existing route patterns |
|
||||
| `LLM.stream()` | Various | Streaming LLM infrastructure with auth already handled |
|
||||
| CLI commands | [`src/cli/cmd/`](../../packages/opencode/src/cli/cmd/) | 18 commands using yargs + [`cmd()`](../../packages/opencode/src/cli/cmd/cmd.ts:5) helper |
|
||||
| `bootstrap()` | [`bootstrap.ts:4`](../../packages/opencode/src/cli/bootstrap.ts:4) | Initializes project context so Provider/LLM APIs are available |
|
||||
|
||||
### What does NOT exist
|
||||
|
||||
- No commit message generation logic
|
||||
- No generic chat completions endpoint
|
||||
- No "commit" agent or prompt
|
||||
- No `kilo commit` CLI command
|
||||
|
||||
### Implications
|
||||
|
||||
Because `Provider.getSmallModel()`, `LLM.stream()`, `bootstrap()`, and the agent prompt infrastructure already exist, the recommended approach is a **shared core module** that both the HTTP route and CLI command delegate to. The extension calls the HTTP route; the CLI command calls the core directly with no HTTP round-trip.
|
||||
|
||||
---
|
||||
|
||||
## 3. Architecture
|
||||
|
||||
### Three-Layer Design
|
||||
|
||||
```
|
||||
+-----------------------------------------------------+
|
||||
| Shared Core Module |
|
||||
| packages/opencode/src/commit-message/ |
|
||||
| - generate.ts git context + LLM call |
|
||||
| - git-context.ts diff/branch/log gathering |
|
||||
| - types.ts CommitMessageRequest/Response |
|
||||
+----------+------------------+-----------+-----------+
|
||||
| | |
|
||||
+------+------+ +------+------+ +-------------------+
|
||||
| CLI Command | | HTTP Route | | VS Code Extension |
|
||||
| kilo commit | | POST /commit| | SCM panel button |
|
||||
| cmd/commit.ts| | -message | | calls HTTP route |
|
||||
+-------------+ +-------------+ +-------------------+
|
||||
```
|
||||
|
||||
**Key design decision:** Git context is gathered **server-side** in the shared core module. Both the CLI command and HTTP route run in the backend process with filesystem access. The VS Code extension does NOT gather git context — it sends the workspace path and the backend does the rest. This avoids duplicating git logic across surfaces.
|
||||
|
||||
### Request Flow — VS Code
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User
|
||||
participant VSCode as VS Code SCM Panel
|
||||
participant HTTP as HttpClient
|
||||
participant Route as POST /commit-message
|
||||
participant Core as Shared Core Module
|
||||
participant Git as Git CLI
|
||||
participant SmallModel as Provider.getSmallModel
|
||||
participant LLM as LLM Provider
|
||||
|
||||
User->>VSCode: Click generate button
|
||||
VSCode->>HTTP: generateCommitMessage with path
|
||||
HTTP->>Route: POST /commit-message
|
||||
Route->>Core: generateCommitMessage with path
|
||||
Core->>Git: git diff, branch, log
|
||||
Git-->>Core: diffs + metadata
|
||||
Core->>SmallModel: Resolve model
|
||||
SmallModel-->>Core: Model ID
|
||||
Core->>Core: Build prompt from template + git context
|
||||
Core->>LLM: LLM.stream with commit prompt
|
||||
LLM-->>Core: Generated message
|
||||
Core-->>Route: Commit message string
|
||||
Route-->>HTTP: JSON response
|
||||
HTTP-->>VSCode: Commit message string
|
||||
VSCode-->>User: Message appears in commit input box
|
||||
```
|
||||
|
||||
### Request Flow — CLI
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User
|
||||
participant CLI as kilo commit
|
||||
participant Bootstrap as bootstrap
|
||||
participant Core as Shared Core Module
|
||||
participant Git as Git CLI
|
||||
participant SmallModel as Provider.getSmallModel
|
||||
participant LLM as LLM Provider
|
||||
|
||||
User->>CLI: kilo commit
|
||||
CLI->>Bootstrap: Initialize project context
|
||||
Bootstrap-->>CLI: Context ready
|
||||
CLI->>Core: generateCommitMessage with cwd
|
||||
Core->>Git: git diff, branch, log
|
||||
Git-->>Core: diffs + metadata
|
||||
Core->>SmallModel: Resolve model
|
||||
SmallModel-->>Core: Model ID
|
||||
Core->>Core: Build prompt from template + git context
|
||||
Core->>LLM: LLM.stream with commit prompt
|
||||
LLM-->>Core: Generated message
|
||||
Core-->>CLI: Commit message string
|
||||
CLI-->>User: Print to stdout
|
||||
```
|
||||
|
||||
### Component Diagram
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
CMD[kilo commit CLI] --> CORE[Shared Core: generate.ts]
|
||||
ROUTE[POST /commit-message route] --> CORE
|
||||
EXT[VS Code Extension] --> HC[HttpClient.generateCommitMessage]
|
||||
HC --> ROUTE
|
||||
|
||||
CORE --> GC[git-context.ts]
|
||||
CORE --> SM[Provider.getSmallModel]
|
||||
CORE --> LLMS[LLM.stream]
|
||||
|
||||
CMD --> BOOT[bootstrap]
|
||||
EXT --> CS[KiloConnectionService]
|
||||
CS --> HC
|
||||
|
||||
style CORE fill:#ff9,stroke:#f90,stroke-width:2px
|
||||
style GC fill:#ff9,stroke:#f90,stroke-width:2px
|
||||
style ROUTE fill:#ff9,stroke:#f90,stroke-width:2px
|
||||
style CMD fill:#ff9,stroke:#f90,stroke-width:2px
|
||||
style HC fill:#ff9,stroke:#f90,stroke-width:2px
|
||||
```
|
||||
|
||||
Yellow-highlighted components are new code that needs to be written.
|
||||
|
||||
### Design Rationale: Backend vs Gateway
|
||||
|
||||
Two approaches were considered:
|
||||
|
||||
| Aspect | Option A: Backend endpoint — RECOMMENDED | Option B: Gateway endpoint |
|
||||
|--------|------------------------------------------|---------------------------|
|
||||
| Endpoint | `POST /commit-message` in opencode server | `POST /kilo/chat` in kilo-gateway |
|
||||
| Model selection | Backend uses `Provider.getSmallModel()` directly | Extension must read config and pass model |
|
||||
| Prompt | Backend shared core module | Extension builds prompt locally |
|
||||
| Auth | Handled by existing `LLM.stream()` | Separate gateway auth flow |
|
||||
| Consistency | Same pattern as title generation | Different pattern from other features |
|
||||
| CLI reuse | CLI command shares the same core logic | CLI would need its own implementation |
|
||||
|
||||
Option A is recommended because it enables a shared core used by both command-line and HTTP surfaces, reuses existing infrastructure, and minimizes extension-side complexity.
|
||||
|
||||
---
|
||||
|
||||
## 4. Implementation Phases
|
||||
|
||||
### Phase 1: Shared Core Module (packages/opencode)
|
||||
|
||||
**Scope:** Core logic for generating commit messages, shared by all surfaces.
|
||||
|
||||
**Files to create:**
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `src/commit-message/generate.ts` | Main `generateCommitMessage()` function — orchestrates git context, prompt building, LLM call |
|
||||
| `src/commit-message/git-context.ts` | Git CLI operations: diff, branch, log, file status |
|
||||
| `src/commit-message/types.ts` | `CommitMessageRequest` and `CommitMessageResponse` types |
|
||||
|
||||
**`generateCommitMessage()` function:**
|
||||
|
||||
```typescript
|
||||
// src/commit-message/generate.ts
|
||||
|
||||
import { getGitContext } from "./git-context"
|
||||
import type { CommitMessageRequest, CommitMessageResponse } from "./types"
|
||||
|
||||
export async function generateCommitMessage(
|
||||
request: CommitMessageRequest
|
||||
): Promise<CommitMessageResponse> {
|
||||
// 1. Gather git context from the working directory
|
||||
const context = await getGitContext(request.path, request.selectedFiles)
|
||||
|
||||
// 2. Resolve small model via Provider.getSmallModel()
|
||||
// 3. Build prompt: Conventional Commits template + git context
|
||||
// 4. Call LLM.stream() with the commit prompt
|
||||
// 5. Clean and return the commit message string
|
||||
|
||||
return { message: cleanedMessage }
|
||||
}
|
||||
```
|
||||
|
||||
**`getGitContext()` function:**
|
||||
|
||||
```typescript
|
||||
// src/commit-message/git-context.ts
|
||||
|
||||
export interface GitContext {
|
||||
stagedFiles: FileChange[]
|
||||
diffs: Map<string, string>
|
||||
branch: string
|
||||
recentCommits: string[]
|
||||
}
|
||||
|
||||
export interface FileChange {
|
||||
status: "added" | "modified" | "deleted" | "renamed" | "untracked"
|
||||
path: string
|
||||
}
|
||||
|
||||
export async function getGitContext(
|
||||
repoPath: string,
|
||||
selectedFiles?: string[]
|
||||
): Promise<GitContext>
|
||||
```
|
||||
|
||||
**Types:**
|
||||
|
||||
```typescript
|
||||
// src/commit-message/types.ts
|
||||
|
||||
export interface CommitMessageRequest {
|
||||
path: string // workspace/repo path
|
||||
selectedFiles?: string[] // optional file subset
|
||||
}
|
||||
|
||||
export interface CommitMessageResponse {
|
||||
message: string // the generated commit message
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 2: HTTP Route (packages/opencode)
|
||||
|
||||
**Scope:** `POST /commit-message` route that delegates to the shared core.
|
||||
|
||||
**Files to create/modify:**
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `src/server/routes/commit-message.ts` | New route handler — validates request, calls `generateCommitMessage()`, returns JSON |
|
||||
| [`src/server/server.ts`](../../packages/opencode/src/server/server.ts) | Register `POST /commit-message` route |
|
||||
|
||||
**HTTP interface:**
|
||||
|
||||
Request:
|
||||
```typescript
|
||||
POST /commit-message
|
||||
{
|
||||
path: string // workspace/repo path
|
||||
selectedFiles?: string[] // optional file subset
|
||||
}
|
||||
```
|
||||
|
||||
Response:
|
||||
```typescript
|
||||
{
|
||||
message: string // the generated commit message
|
||||
}
|
||||
```
|
||||
|
||||
The route handler is thin — it validates the request body, calls [`generateCommitMessage()`](../../packages/opencode/src/commit-message/generate.ts), and returns the result as JSON.
|
||||
|
||||
### Phase 3: CLI Command (packages/opencode)
|
||||
|
||||
**Scope:** `kilo commit` command that delegates to the shared core.
|
||||
|
||||
**Files to create/modify:**
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `src/cli/cmd/commit.ts` | New CLI command using [`cmd()`](../../packages/opencode/src/cli/cmd/cmd.ts:5) helper |
|
||||
| [`src/index.ts`](../../packages/opencode/src/index.ts:122) | Register `.command(CommitCommand)` |
|
||||
|
||||
**Command:** `kilo commit [--auto]`
|
||||
|
||||
| Flag | Default | Description |
|
||||
|------|---------|-------------|
|
||||
| `--auto` | `false` | Skip confirmation, auto-stage + commit with the generated message |
|
||||
| (no flags) | — | Generate and print the commit message to stdout |
|
||||
|
||||
**Usage examples:**
|
||||
```bash
|
||||
# Generate and print to stdout
|
||||
kilo commit
|
||||
|
||||
# Pipe to git commit
|
||||
kilo commit | git commit -F -
|
||||
|
||||
# Auto-stage and commit
|
||||
kilo commit --auto
|
||||
```
|
||||
|
||||
**Implementation:**
|
||||
|
||||
```typescript
|
||||
// src/cli/cmd/commit.ts
|
||||
|
||||
import { cmd } from "./cmd"
|
||||
import { bootstrap } from "../bootstrap"
|
||||
import { generateCommitMessage } from "../../commit-message/generate"
|
||||
|
||||
export const CommitCommand = cmd({
|
||||
command: "commit",
|
||||
describe: "Generate a commit message using AI",
|
||||
builder: (yargs) =>
|
||||
yargs.option("auto", {
|
||||
type: "boolean",
|
||||
describe: "Auto-stage and commit with the generated message",
|
||||
default: false,
|
||||
}),
|
||||
handler: async (args) => {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
const result = await generateCommitMessage({
|
||||
path: process.cwd(),
|
||||
})
|
||||
|
||||
if (args.auto) {
|
||||
// Stage all changes + git commit -m <message>
|
||||
execSync("git add -A", { cwd: process.cwd() })
|
||||
execSync(`git commit -m ${shellEscape(result.message)}`, {
|
||||
cwd: process.cwd(),
|
||||
stdio: "inherit",
|
||||
})
|
||||
} else {
|
||||
// Print to stdout for piping
|
||||
process.stdout.write(result.message + "\n")
|
||||
}
|
||||
})
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
**Registration in [`src/index.ts`](../../packages/opencode/src/index.ts:122):**
|
||||
```typescript
|
||||
import { CommitCommand } from "./cli/cmd/commit"
|
||||
// ...
|
||||
.command(CommitCommand)
|
||||
```
|
||||
|
||||
### Phase 4: VS Code Extension (packages/kilo-vscode)
|
||||
|
||||
**Scope:** Extension-side changes to call the backend endpoint and display results.
|
||||
|
||||
#### 4a. HttpClient — `generateCommitMessage()` method
|
||||
|
||||
New method in [`src/services/cli-backend/http-client.ts`](src/services/cli-backend/http-client.ts):
|
||||
|
||||
```typescript
|
||||
async generateCommitMessage(request: {
|
||||
path: string
|
||||
selectedFiles?: string[]
|
||||
}): Promise<string>
|
||||
```
|
||||
|
||||
- POST to `${this.baseUrl}/commit-message`
|
||||
- Returns the commit message string from the JSON response
|
||||
- No SSE parsing — simple request/response
|
||||
|
||||
#### 4b. Commit Message Service
|
||||
|
||||
New service at `src/services/commit-message/`:
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| [`index.ts`](src/services/commit-message/index.ts) | `registerCommitMessageService()` entry point |
|
||||
| [`CommitMessageService.ts`](src/services/commit-message/CommitMessageService.ts) | Orchestrates HTTP call → write to SCM input box |
|
||||
|
||||
**CommitMessageService responsibilities:**
|
||||
1. Determine the workspace/repo path from VS Code's git extension
|
||||
2. Optionally determine selected files from the SCM view
|
||||
3. Call `connectionService.getHttpClient().generateCommitMessage({ path, selectedFiles })`
|
||||
4. Clean response (strip code blocks, quotes if present)
|
||||
5. Write result to `repository.inputBox.value`
|
||||
|
||||
Note: The extension does NOT gather git context — that's handled server-side by the shared core module. This keeps the extension simple.
|
||||
|
||||
#### 4c. VS Code Integration
|
||||
|
||||
**Changes to [`package.json`](package.json):**
|
||||
```json
|
||||
{
|
||||
"contributes": {
|
||||
"commands": [
|
||||
{
|
||||
"command": "kilo-code.new.generateCommitMessage",
|
||||
"title": "Generate Commit Message",
|
||||
"icon": "$(sparkle)",
|
||||
"category": "Kilo Code"
|
||||
}
|
||||
],
|
||||
"menus": {
|
||||
"scm/title": [
|
||||
{
|
||||
"command": "kilo-code.new.generateCommitMessage",
|
||||
"group": "navigation",
|
||||
"when": "scmProvider == git"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Changes to [`src/extension.ts`](src/extension.ts):**
|
||||
```typescript
|
||||
import { registerCommitMessageService } from "./services/commit-message"
|
||||
|
||||
// In activate():
|
||||
registerCommitMessageService(context, connectionService)
|
||||
```
|
||||
|
||||
**Progress UI:**
|
||||
```typescript
|
||||
await vscode.window.withProgress(
|
||||
{ location: vscode.ProgressLocation.SourceControl, title: "Generating commit message..." },
|
||||
async () => { /* generation logic */ }
|
||||
)
|
||||
```
|
||||
|
||||
### Phase 5: Testing
|
||||
|
||||
**Backend tests (PR A):**
|
||||
- `packages/opencode/src/commit-message/__tests__/generate.spec.ts`
|
||||
- `packages/opencode/src/commit-message/__tests__/git-context.spec.ts`
|
||||
- `packages/opencode/src/cli/cmd/__tests__/commit.spec.ts`
|
||||
|
||||
**Extension tests (PR B):**
|
||||
- `src/services/commit-message/__tests__/CommitMessageService.spec.ts`
|
||||
|
||||
---
|
||||
|
||||
## 5. File-by-File Changes
|
||||
|
||||
### New Files — Backend (PR A)
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `packages/opencode/src/commit-message/generate.ts` | Main `generateCommitMessage()` function — shared core |
|
||||
| `packages/opencode/src/commit-message/git-context.ts` | Git CLI operations: diff, branch, log, file status, lock file exclusion |
|
||||
| `packages/opencode/src/commit-message/types.ts` | `CommitMessageRequest`, `CommitMessageResponse`, `GitContext`, `FileChange` |
|
||||
| `packages/opencode/src/server/routes/commit-message.ts` | HTTP route handler delegating to shared core |
|
||||
| `packages/opencode/src/cli/cmd/commit.ts` | `kilo commit` CLI command delegating to shared core |
|
||||
|
||||
### Modified Files — Backend (PR A)
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| [`packages/opencode/src/server/server.ts`](../../packages/opencode/src/server/server.ts) | Register `POST /commit-message` route |
|
||||
| [`packages/opencode/src/index.ts`](../../packages/opencode/src/index.ts:122) | Register `.command(CommitCommand)` |
|
||||
|
||||
### New Files — Extension (PR B)
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `src/services/commit-message/index.ts` | `registerCommitMessageService()` entry point |
|
||||
| `src/services/commit-message/CommitMessageService.ts` | Orchestrates HTTP call → write to SCM input box |
|
||||
| `src/services/commit-message/__tests__/CommitMessageService.spec.ts` | Service tests |
|
||||
|
||||
### Modified Files — Extension (PR B)
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| [`package.json`](package.json) | Add command + `scm/title` menu contribution |
|
||||
| [`src/extension.ts`](src/extension.ts) | Import and call `registerCommitMessageService()` |
|
||||
| [`src/services/cli-backend/http-client.ts`](src/services/cli-backend/http-client.ts) | Add `generateCommitMessage()` method |
|
||||
|
||||
---
|
||||
|
||||
## 6. Git Context Gathering (Server-Side)
|
||||
|
||||
The shared core module in [`git-context.ts`](../../packages/opencode/src/commit-message/git-context.ts) runs git CLI commands against the provided workspace path. This runs in the backend process (CLI or HTTP server), which has direct filesystem access.
|
||||
|
||||
### Git Commands
|
||||
|
||||
| Command | Purpose |
|
||||
|---------|---------|
|
||||
| `git rev-parse --show-toplevel` | Find repo root |
|
||||
| `git diff --name-status --cached` | List staged file changes with status |
|
||||
| `git status --porcelain` | Fallback: list all changes when nothing is staged |
|
||||
| `git diff --cached -- <file>` | Per-file diff content (staged) |
|
||||
| `git diff -- <file>` | Per-file diff content (unstaged fallback) |
|
||||
| `git branch --show-current` | Current branch name |
|
||||
| `git log --oneline -5` | Last 5 commit messages for context |
|
||||
|
||||
### File Processing Rules
|
||||
|
||||
1. **Lock file exclusion:** Files matching lock file patterns are excluded
|
||||
2. **Binary files:** Replaced with placeholder `"Binary file <path> has been modified"`
|
||||
3. **Untracked files:** Replaced with placeholder `"New untracked file: <path>"`
|
||||
4. **Staged vs unstaged:** Prefers staged changes (`--cached`); falls back to all changes if nothing is staged
|
||||
5. **Selected files:** If `selectedFiles` is provided, only those files are included in the diff
|
||||
6. **Large diffs:** Truncate individual file diffs at ~4000 chars; include file name even if diff is cut
|
||||
|
||||
### Lock File Patterns
|
||||
|
||||
```typescript
|
||||
const LOCK_FILE_PATTERNS = [
|
||||
"package-lock.json",
|
||||
"yarn.lock",
|
||||
"pnpm-lock.yaml",
|
||||
"Cargo.lock",
|
||||
"poetry.lock",
|
||||
"Pipfile.lock",
|
||||
"Gemfile.lock",
|
||||
"composer.lock",
|
||||
"go.sum",
|
||||
"bun.lockb",
|
||||
// ... ~50 more patterns
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Prompt Engineering
|
||||
|
||||
The Conventional Commits prompt is embedded in the shared core module (either as an inline template in [`generate.ts`](../../packages/opencode/src/commit-message/generate.ts) or as a separate `prompt.txt` file alongside it), consistent with the pattern used by title generation in [`summary.ts`](../../packages/opencode/src/session/summary.ts:130).
|
||||
|
||||
### Commit Prompt Template
|
||||
|
||||
```
|
||||
You are a commit message generator. Generate a concise commit message following the Conventional Commits specification.
|
||||
|
||||
Format: type(scope): description
|
||||
|
||||
Allowed types: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert
|
||||
|
||||
Rules:
|
||||
- Keep the subject line under 72 characters
|
||||
- Use imperative mood ("add feature" not "added feature")
|
||||
- Do not end the subject with a period
|
||||
- The scope is optional but encouraged when changes are focused
|
||||
- For multiple unrelated changes, use the most significant change as the type
|
||||
- Output ONLY the commit message, nothing else
|
||||
```
|
||||
|
||||
The shared core combines this system prompt with the gathered git context to form the full LLM request.
|
||||
|
||||
---
|
||||
|
||||
## 8. Error Handling
|
||||
|
||||
### CLI errors
|
||||
|
||||
| Scenario | Handling |
|
||||
|----------|---------|
|
||||
| Not in a git repository | Print error to stderr, exit code 1 |
|
||||
| No changes detected | Print message to stderr, exit code 0 |
|
||||
| No provider configured | Print setup instructions to stderr, exit code 1 |
|
||||
| LLM request fails | Print error to stderr, exit code 1 |
|
||||
|
||||
### HTTP route errors
|
||||
|
||||
| Scenario | Handling |
|
||||
|----------|---------|
|
||||
| Missing `path` in request | 400 Bad Request |
|
||||
| Path is not a git repository | 400 Bad Request with message |
|
||||
| No changes detected | 200 with empty message + info field |
|
||||
| No `small_model` available | Backend falls back through auto-detection chain |
|
||||
| LLM request fails | 500 Internal Server Error with message |
|
||||
|
||||
### VS Code extension errors
|
||||
|
||||
| Scenario | Handling |
|
||||
|----------|---------|
|
||||
| CLI backend not connected | `vscode.window.showErrorMessage` — check `connectionService` state |
|
||||
| Not authenticated | `vscode.window.showErrorMessage` with sign-in prompt |
|
||||
| Backend returns error | `vscode.window.showErrorMessage` with error details |
|
||||
| Empty response from backend | Show error; do not write to input box |
|
||||
| User cancels during progress | Abort gracefully via `CancellationToken` |
|
||||
|
||||
---
|
||||
|
||||
## 9. Simplifications (v1 scope)
|
||||
|
||||
What we are **NOT** implementing in v1:
|
||||
|
||||
| Feature | Reason |
|
||||
|---------|--------|
|
||||
| JetBrains adapter | VS Code extension only — no JetBrains in this codebase |
|
||||
| User model selection | Backend uses `Provider.getSmallModel()` automatically |
|
||||
| Custom prompt template override | Keep it simple for v1; can add later |
|
||||
| `.kilocode-ignore` support | Can add later; lock file exclusion covers the main case |
|
||||
| Re-generation detection | Can add in v2; first version generates fresh each time |
|
||||
| Custom instructions from rules files | Can add later |
|
||||
| Concurrent request debouncing | Low priority for v1 |
|
||||
| `--auto` with selective staging | v1 `--auto` stages everything; selective staging can come later |
|
||||
| Interactive confirmation in CLI | v1 just prints to stdout; interactive mode can come later |
|
||||
|
||||
---
|
||||
|
||||
## 10. PR Sequence
|
||||
|
||||
1. **PR A: Backend changes** (`packages/opencode/`) — Shared core module + HTTP route + CLI command. Contains:
|
||||
- `src/commit-message/generate.ts`, `git-context.ts`, `types.ts` (shared core)
|
||||
- `src/server/routes/commit-message.ts` + registration in `server.ts`
|
||||
- `src/cli/cmd/commit.ts` + registration in `src/index.ts`
|
||||
- Backend tests
|
||||
- Can be reviewed/merged independently. No extension changes.
|
||||
|
||||
2. **PR B: Extension changes** (`packages/kilo-vscode/`) — VS Code integration. Contains:
|
||||
- `generateCommitMessage()` in `http-client.ts`
|
||||
- `CommitMessageService` + registration
|
||||
- Command + SCM menu in `package.json`
|
||||
- Extension tests
|
||||
- Depends on PR A being deployed.
|
||||
@@ -0,0 +1,455 @@
|
||||
# Commit Message Generation — Reimplementation Guide
|
||||
|
||||
## Overview
|
||||
|
||||
The commit message generation feature allows users to automatically generate [Conventional Commits](https://www.conventionalcommits.org/) messages from their staged (or unstaged) git changes using an LLM. It is accessible from:
|
||||
|
||||
- **VS Code**: The Source Control panel title bar and the command palette (`Kilo Code: Generate Commit Message`)
|
||||
- **JetBrains**: A button in the commit dialog
|
||||
|
||||
The feature collects git context (diffs, branch name, recent commits), builds a prompt, sends it to an LLM, and writes the resulting commit message into the IDE's commit input box.
|
||||
|
||||
> **Note:** No screenshots of this feature were found in the repository. A screenshot showing the SCM panel button and generated message would be helpful here.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
The current implementation has a clean layered architecture that should be preserved. The key change is that the LLM integration layer will use a different calling mechanism — everything else can be largely reused or adapted.
|
||||
|
||||
### Layered Design
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[IDE Integration Layer] --> B[Orchestrator]
|
||||
B --> C[Git Context Service]
|
||||
B --> D[Commit Message Generator]
|
||||
D --> E[Prompt Builder]
|
||||
D --> F[LLM Integration Point]
|
||||
D --> G[Response Cleaner]
|
||||
A --> H[VS Code Adapter]
|
||||
A --> I[JetBrains Adapter]
|
||||
|
||||
style F fill:#ff9,stroke:#f90,stroke-width:3px
|
||||
```
|
||||
|
||||
### Component Responsibilities
|
||||
|
||||
| Layer | Component | Responsibility |
|
||||
|-------|-----------|---------------|
|
||||
| Entry Point | `registerCommitMessageProvider()` | Wires everything up during extension activation |
|
||||
| IDE Integration | `CommitMessageProvider` | Registers IDE commands, dispatches to the correct adapter |
|
||||
| Adapter | `VSCodeCommitMessageAdapter` | VS Code SCM panel progress + writes to input box |
|
||||
| Adapter | `JetBrainsCommitMessageAdapter` | Returns result string to Kotlin host |
|
||||
| Orchestrator | `CommitMessageOrchestrator` | Sequences: git discovery → diff collection → AI generation → result delivery |
|
||||
| Business Logic | `CommitMessageGenerator` | Builds prompt, calls LLM, cleans response |
|
||||
| Git Operations | `GitExtensionService` | Runs git CLI commands, collects diffs and metadata |
|
||||
| Utilities | `exclusionUtils` | Filters lock files from diffs |
|
||||
|
||||
---
|
||||
|
||||
## Components to Implement
|
||||
|
||||
### 1. Entry Point — `registerCommitMessageProvider()`
|
||||
|
||||
**Responsibility:** Called during extension activation to wire up all components and register commands.
|
||||
|
||||
**Reference:** `/Users/mark/dev/kilo/kilocode-5/src/services/commit-message/index.ts`
|
||||
|
||||
**Key details:**
|
||||
- Creates instances of `GitExtensionService`, `CommitMessageGenerator`, `CommitMessageOrchestrator`
|
||||
- Creates the appropriate adapter(s) based on the IDE environment
|
||||
- Registers VS Code commands and disposables
|
||||
- Returns disposables for cleanup
|
||||
|
||||
### 2. CommitMessageProvider — Command Router
|
||||
|
||||
**Responsibility:** Registers VS Code commands and dispatches generation requests to the correct adapter.
|
||||
|
||||
**Reference:** `/Users/mark/dev/kilo/kilocode-5/src/services/commit-message/CommitMessageProvider.ts`
|
||||
|
||||
**Interface:**
|
||||
```typescript
|
||||
interface CommitMessageProvider {
|
||||
// Register VS Code commands and return disposables
|
||||
register(): vscode.Disposable[]
|
||||
|
||||
// Handle generation request from either IDE
|
||||
handleGenerateRequest(context?: { workspacePath?: string; selectedFiles?: string[] }): Promise<void>
|
||||
}
|
||||
```
|
||||
|
||||
**Key details:**
|
||||
- Registers command `kilo-code.vsc.generateCommitMessage` in the `scm/title` menu
|
||||
- Registers command `kilo-code.jetbrains.generateCommitMessage` for JetBrains RPC
|
||||
- Determines which adapter to use based on the calling context
|
||||
|
||||
### 3. CommitMessageOrchestrator — Workflow Coordinator
|
||||
|
||||
**Responsibility:** Sequences the full workflow from git discovery through to delivering the result.
|
||||
|
||||
**Reference:** `/Users/mark/dev/kilo/kilocode-5/src/services/commit-message/CommitMessageOrchestrator.ts`
|
||||
|
||||
**Interface:**
|
||||
```typescript
|
||||
interface CommitMessageOrchestrator {
|
||||
generate(options?: {
|
||||
workspacePath?: string
|
||||
selectedFiles?: string[]
|
||||
}): Promise<CommitMessageResult>
|
||||
}
|
||||
|
||||
interface CommitMessageResult {
|
||||
message: string
|
||||
regenerated: boolean
|
||||
}
|
||||
```
|
||||
|
||||
**Workflow sequence:**
|
||||
1. Discover the git repository root
|
||||
2. Collect git context via `GitExtensionService`
|
||||
3. Check for re-generation (same diff as last time)
|
||||
4. Call `CommitMessageGenerator.generateMessage()` with the context
|
||||
5. Return the result to the adapter for delivery
|
||||
|
||||
### 4. CommitMessageGenerator — Business Logic
|
||||
|
||||
**Responsibility:** Builds the prompt, calls the LLM, and cleans the response.
|
||||
|
||||
**Reference:** `/Users/mark/dev/kilo/kilocode-5/src/services/commit-message/CommitMessageGenerator.ts`
|
||||
|
||||
**Interface:**
|
||||
```typescript
|
||||
interface CommitMessageGenerator {
|
||||
generateMessage(context: GitContext, options?: {
|
||||
isRegeneration?: boolean
|
||||
previousMessage?: string
|
||||
}): Promise<string>
|
||||
}
|
||||
```
|
||||
|
||||
**Key details:**
|
||||
- Constructs the prompt using `supportPrompt.create("COMMIT_MESSAGE", ...)` or equivalent
|
||||
- Loads custom instructions for the "commit" context
|
||||
- Handles re-generation by prepending "generate a completely different message"
|
||||
- Calls the LLM (see **Integration Point** below)
|
||||
- Cleans the response: strips code block markers and surrounding quotes
|
||||
|
||||
**Response cleaning logic:**
|
||||
```typescript
|
||||
function cleanResponse(raw: string): string {
|
||||
let cleaned = raw.trim()
|
||||
// Strip code block markers
|
||||
cleaned = cleaned.replace(/^```[\w]*\n?/, "").replace(/\n?```$/, "")
|
||||
// Strip surrounding quotes
|
||||
cleaned = cleaned.replace(/^["']|["']$/g, "")
|
||||
return cleaned.trim()
|
||||
}
|
||||
```
|
||||
|
||||
### 5. GitExtensionService — Git Operations
|
||||
|
||||
**Responsibility:** Runs git CLI commands to gather all context needed for prompt construction.
|
||||
|
||||
**Reference:** `/Users/mark/dev/kilo/kilocode-5/src/services/commit-message/GitExtensionService.ts`
|
||||
|
||||
**Interface:**
|
||||
```typescript
|
||||
interface GitContext {
|
||||
stagedFiles: FileChange[]
|
||||
diffs: Map<string, string> // filepath → diff content
|
||||
branch: string
|
||||
recentCommits: string[] // last 5 commit summaries
|
||||
}
|
||||
|
||||
interface FileChange {
|
||||
status: "added" | "modified" | "deleted" | "renamed" | "untracked"
|
||||
path: string
|
||||
}
|
||||
|
||||
interface GitExtensionService {
|
||||
getGitContext(repoPath: string, selectedFiles?: string[]): Promise<GitContext>
|
||||
}
|
||||
```
|
||||
|
||||
**See section: [Git Context Gathering](#git-context-gathering) for full details.**
|
||||
|
||||
### 6. Exclusion Utilities
|
||||
|
||||
**Responsibility:** Filters lock files and ignored files from the diff set.
|
||||
|
||||
**Reference:** `/Users/mark/dev/kilo/kilocode-5/src/services/commit-message/exclusionUtils.ts`
|
||||
|
||||
**Key details:**
|
||||
- Uses the `ignore` library to match 60+ lock file patterns
|
||||
- Patterns include `package-lock.json`, `yarn.lock`, `Cargo.lock`, `poetry.lock`, `pnpm-lock.yaml`, etc.
|
||||
- Also respects `.kilocode-ignore` / `.roo-ignore` via `RooIgnoreController`
|
||||
|
||||
### 7. IDE Adapters
|
||||
|
||||
**VS Code Adapter:**
|
||||
|
||||
**Reference:** `/Users/mark/dev/kilo/kilocode-5/src/services/commit-message/adapters/VSCodeCommitMessageAdapter.ts`
|
||||
|
||||
```typescript
|
||||
interface VSCodeCommitMessageAdapter {
|
||||
generate(orchestrator: CommitMessageOrchestrator): Promise<void>
|
||||
}
|
||||
```
|
||||
|
||||
- Shows progress via `vscode.window.withProgress(ProgressLocation.SourceControl)`
|
||||
- Writes result to `repository.inputBox.value`
|
||||
|
||||
**JetBrains Adapter:**
|
||||
|
||||
**Reference:** `/Users/mark/dev/kilo/kilocode-5/src/services/commit-message/adapters/JetBrainsCommitMessageAdapter.ts`
|
||||
|
||||
```typescript
|
||||
interface JetBrainsCommitMessageAdapter {
|
||||
generate(orchestrator: CommitMessageOrchestrator, workspacePath: string, selectedFiles: string[]): Promise<{ message: string }>
|
||||
}
|
||||
```
|
||||
|
||||
- Returns the message string for the Kotlin host to use
|
||||
|
||||
---
|
||||
|
||||
## LLM Integration Point
|
||||
|
||||
> **⚠️ INTEGRATION POINT — This is the part that will differ from the current implementation.**
|
||||
|
||||
### Current Implementation (for reference only)
|
||||
|
||||
The current code calls `singleCompletionHandler(config, prompt)` which internally uses `buildApiHandler(apiConfig)` to create a provider-specific handler. If the handler has a `completePrompt()` method, it uses single-shot completion; otherwise it streams and collects the full response. This mechanism **will not be used** in the new implementation.
|
||||
|
||||
**Reference:** `/Users/mark/dev/kilo/kilocode-5/src/services/commit-message/CommitMessageGenerator.ts` — see `callAIForCommitMessage()`
|
||||
|
||||
### Required Contract
|
||||
|
||||
The LLM integration must satisfy this contract:
|
||||
|
||||
```typescript
|
||||
interface CommitMessageLLMProvider {
|
||||
/**
|
||||
* Send a prompt to the LLM and receive a complete text response.
|
||||
*
|
||||
* This is a non-streaming, single-shot completion call.
|
||||
* The full response must be collected before returning.
|
||||
*
|
||||
* @param prompt - The complete prompt string including system instructions
|
||||
* and git context
|
||||
* @param config - Which model/provider to use. May be a dedicated
|
||||
* commit message profile or the default profile.
|
||||
* @returns The raw LLM response text (will be cleaned by the caller)
|
||||
* @throws If the LLM call fails (network error, auth error, etc.)
|
||||
*/
|
||||
complete(prompt: string, config: LLMConfig): Promise<string>
|
||||
}
|
||||
|
||||
interface LLMConfig {
|
||||
/** The API config ID — either `commitMessageApiConfigId` or the default */
|
||||
configId: string
|
||||
/** Any additional model parameters if needed */
|
||||
[key: string]: unknown
|
||||
}
|
||||
```
|
||||
|
||||
### What the caller provides
|
||||
|
||||
- **Input:** A single prompt string (typically 100–2000 tokens depending on diff size). The prompt includes system instructions, git context, and any custom instructions.
|
||||
- **Config:** An identifier for which API configuration/model to use. This supports the dedicated `commitMessageApiConfigId` setting which lets users pick a different (often cheaper/faster) model for commit messages.
|
||||
|
||||
### What the caller expects
|
||||
|
||||
- **Output:** A single string containing the commit message. May include code block markers or quotes which will be stripped by the response cleaner.
|
||||
- **Behavior:** Non-streaming. The call should block until the full response is available.
|
||||
- **Errors:** Should throw on failure so the orchestrator can catch and display an error to the user.
|
||||
|
||||
### Configuration Resolution
|
||||
|
||||
The config resolution order is:
|
||||
1. If `commitMessageApiConfigId` is set in global settings → use that API profile
|
||||
2. Otherwise → use the default/active API profile
|
||||
|
||||
---
|
||||
|
||||
## Git Context Gathering
|
||||
|
||||
This part is **largely reusable** from the current implementation. It uses `spawnSync` to run git CLI commands.
|
||||
|
||||
**Reference:** `/Users/mark/dev/kilo/kilocode-5/src/services/commit-message/GitExtensionService.ts`
|
||||
|
||||
### Git Commands Used
|
||||
|
||||
| Command | Purpose |
|
||||
|---------|---------|
|
||||
| `git diff --name-status --cached` | List staged file changes with status |
|
||||
| `git status --porcelain` | List all changes (fallback when nothing is staged) |
|
||||
| `git diff [--cached] -- <file>` | Per-file diff content |
|
||||
| `git branch --show-current` | Current branch name |
|
||||
| `git log --oneline -5` | Last 5 commit messages for context |
|
||||
|
||||
### File Processing Rules
|
||||
|
||||
1. **Lock file exclusion:** Files matching any of the 60+ lock file patterns are excluded (see `exclusionUtils`)
|
||||
2. **Ignore file exclusion:** Files matching `.kilocode-ignore` / `.roo-ignore` patterns are excluded via `shouldIncludeFile()`
|
||||
3. **Binary files:** Replaced with placeholder text `"Binary file <path> has been modified"`
|
||||
4. **Untracked files:** Replaced with placeholder text `"New untracked file: <path>"`
|
||||
5. **Staged vs unstaged:** Prefers staged changes (`--cached`); falls back to all changes if nothing is staged
|
||||
6. **Selected files (JetBrains):** When the JetBrains adapter provides `selectedFiles`, only those files are included
|
||||
|
||||
### Fallback Behavior
|
||||
|
||||
If no staged changes exist, the service falls back to `git status --porcelain` to capture all modified/untracked files. This ensures the feature works even when users haven't explicitly staged changes.
|
||||
|
||||
---
|
||||
|
||||
## Prompt Engineering
|
||||
|
||||
### Prompt Template
|
||||
|
||||
The prompt is built using `supportPrompt.create("COMMIT_MESSAGE", { gitContext, customInstructions })`. The template is a ~70-line Conventional Commits guide that includes:
|
||||
|
||||
1. **System instruction:** You are a commit message generator following Conventional Commits format
|
||||
2. **Format specification:** `type(scope): description` with allowed types (`feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `build`, `ci`, `chore`, `revert`)
|
||||
3. **Rules:** Keep subject under 72 chars, use imperative mood, no period at end, etc.
|
||||
4. **Git context injection:** Branch name, recent commits, file changes, diffs
|
||||
5. **Custom instructions:** User-defined instructions from `.kilocode/rules/` for the "commit" context
|
||||
|
||||
### Custom Instructions
|
||||
|
||||
Custom instructions are loaded via `addCustomInstructions()` for the `"commit"` mode context. Users can place files in `.kilocode/rules/` that apply to commit message generation.
|
||||
|
||||
### Re-generation Logic
|
||||
|
||||
When the user requests a new message for the same diff:
|
||||
|
||||
1. The orchestrator detects that the diff hash matches the previous generation
|
||||
2. It prepends to the prompt: `"GENERATE A COMPLETELY DIFFERENT COMMIT MESSAGE. The previous message was: <previous_message>"`
|
||||
3. This ensures variety when the user isn't satisfied with the first suggestion
|
||||
|
||||
### Prompt Template Override
|
||||
|
||||
Users can override the entire prompt template via the `customSupportPrompts.COMMIT_MESSAGE` setting. This allows complete customization of the commit message format and style.
|
||||
|
||||
---
|
||||
|
||||
## IDE Integration
|
||||
|
||||
### VS Code
|
||||
|
||||
**Command registration:**
|
||||
```typescript
|
||||
// In package.json contributes.commands
|
||||
{ "command": "kilo-code.vsc.generateCommitMessage", "title": "Generate Commit Message" }
|
||||
|
||||
// In package.json contributes.menus
|
||||
{ "scm/title": [{ "command": "kilo-code.vsc.generateCommitMessage" }] }
|
||||
```
|
||||
|
||||
**Progress reporting:**
|
||||
```typescript
|
||||
await vscode.window.withProgress(
|
||||
{ location: vscode.ProgressLocation.SourceControl, title: "Generating commit message..." },
|
||||
async () => { /* ... generation logic ... */ }
|
||||
)
|
||||
```
|
||||
|
||||
**Result delivery:**
|
||||
```typescript
|
||||
repository.inputBox.value = generatedMessage
|
||||
```
|
||||
|
||||
**Reference:** `/Users/mark/dev/kilo/kilocode-5/src/services/commit-message/adapters/VSCodeCommitMessageAdapter.ts`
|
||||
|
||||
### JetBrains
|
||||
|
||||
**Kotlin side:** A `CommitMessageHandler` adds a button to the commit dialog. When clicked, it sends an RPC command.
|
||||
|
||||
**RPC command:** `kilo-code.jetbrains.generateCommitMessage` with arguments `[workspacePath, selectedFiles]`
|
||||
|
||||
**Result delivery:** The result is returned via RPC to Kotlin which calls `panel.setCommitMessage(result.message)`
|
||||
|
||||
**Reference:** `/Users/mark/dev/kilo/kilocode-5/src/services/commit-message/adapters/JetBrainsCommitMessageAdapter.ts`
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
| Setting | Type | Description |
|
||||
|---------|------|-------------|
|
||||
| `commitMessageApiConfigId` | `string` | ID of a dedicated API profile for commit messages. Allows using a cheaper/faster model. |
|
||||
| `customSupportPrompts.COMMIT_MESSAGE` | `string` | Override the entire commit message prompt template |
|
||||
| Custom instructions in `.kilocode/rules/` | files | Per-project or global instructions applied to the "commit" context |
|
||||
| `.kilocode-ignore` / `.roo-ignore` | files | File exclusion patterns — excluded files won't appear in diffs |
|
||||
|
||||
### Settings UI
|
||||
|
||||
**Reference:** `/Users/mark/dev/kilo/kilocode-5/webview-ui/src/components/settings/CommitMessagePromptSettings.tsx`
|
||||
|
||||
A dropdown in the Settings panel allows users to select which API configuration to use for commit messages. This is separate from the main chat model selection.
|
||||
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Edge Cases to Handle
|
||||
|
||||
| Scenario | Handling |
|
||||
|----------|----------|
|
||||
| No git repository found | Show error message: "No git repository found in the current workspace" |
|
||||
| No changes detected | Show info message: "No changes to generate a commit message for" |
|
||||
| Empty diff after filtering | Show info message: "All changed files are excluded by lock file or ignore rules" |
|
||||
| LLM call fails (network/auth) | Show error with details; do not write to input box |
|
||||
| LLM returns empty response | Retry once; if still empty, show error |
|
||||
| Very large diff (token limit) | Truncate diffs, prioritize staged files, include file names even if diffs are cut |
|
||||
| Git command fails | Log the error, attempt to continue with partial context |
|
||||
| User cancels during progress | Abort the LLM call if possible, clean up gracefully |
|
||||
| Binary files in diff | Replace with placeholder text instead of including binary content |
|
||||
| Concurrent generation requests | Debounce or queue — don't send multiple simultaneous LLM requests |
|
||||
|
||||
### Error Display
|
||||
|
||||
- **VS Code:** Use `vscode.window.showErrorMessage()` or `showInformationMessage()` as appropriate
|
||||
- **JetBrains:** Return error in the RPC response for Kotlin-side display
|
||||
|
||||
---
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Unit Tests
|
||||
|
||||
**Reference for existing tests:**
|
||||
- `/Users/mark/dev/kilo/kilocode-5/src/services/commit-message/__tests__/CommitMessageGenerator.spec.ts`
|
||||
- `/Users/mark/dev/kilo/kilocode-5/src/services/commit-message/__tests__/GitExtensionService.spec.ts`
|
||||
- `/Users/mark/dev/kilo/kilocode-5/src/services/commit-message/__tests__/progress-reporting.spec.ts`
|
||||
|
||||
| Component | What to Test |
|
||||
|-----------|-------------|
|
||||
| `CommitMessageGenerator` | Prompt construction, response cleaning, re-generation logic, custom instructions injection |
|
||||
| `GitExtensionService` | Parsing of `git diff --name-status` output, `git status --porcelain` output, branch name extraction, handling of binary files and untracked files |
|
||||
| `exclusionUtils` | Lock file pattern matching — ensure all 60+ patterns work, edge cases with nested paths |
|
||||
| `CommitMessageOrchestrator` | Full workflow sequencing, re-generation detection, error propagation |
|
||||
| `VSCodeCommitMessageAdapter` | Progress reporting, writing to input box, error display |
|
||||
| `JetBrainsCommitMessageAdapter` | Correct return format, error handling |
|
||||
| Response cleaner | Stripping code blocks, quotes, whitespace normalization |
|
||||
|
||||
### Integration Tests
|
||||
|
||||
| Test | Description |
|
||||
|------|-------------|
|
||||
| Full generation flow | Mock the LLM call, verify end-to-end from git context to result delivery |
|
||||
| Re-generation | Verify that requesting a new message for the same diff includes the "different message" instruction |
|
||||
| Config resolution | Verify `commitMessageApiConfigId` is used when set, falls back to default otherwise |
|
||||
| Large diff handling | Verify truncation behavior with oversized diffs |
|
||||
|
||||
### Mocking Strategy
|
||||
|
||||
- **Git commands:** Mock `spawnSync` to return predefined git output
|
||||
- **LLM calls:** Mock the LLM integration point to return controlled responses
|
||||
- **VS Code API:** Mock `vscode.window.withProgress`, `repository.inputBox`, and command registration
|
||||
- **File system:** Mock ignore file reading for exclusion tests
|
||||
|
||||
### Test File Convention
|
||||
|
||||
Per project convention, test files should use `.spec.ts` extension and live in `__tests__/` directories adjacent to the source code.
|
||||
@@ -100,6 +100,12 @@
|
||||
"command": "kilo-code.new.agentManager.nextTab",
|
||||
"title": "Agent Manager: Next Tab",
|
||||
"category": "Kilo Code"
|
||||
},
|
||||
{
|
||||
"command": "kilo-code.new.generateCommitMessage",
|
||||
"title": "Generate Commit Message",
|
||||
"category": "Kilo Code (NEW)",
|
||||
"icon": "$(sparkle)"
|
||||
}
|
||||
],
|
||||
"keybindings": [
|
||||
@@ -161,6 +167,13 @@
|
||||
"when": "view == kilo-code.new.sidebarView"
|
||||
}
|
||||
],
|
||||
"scm/title": [
|
||||
{
|
||||
"command": "kilo-code.new.generateCommitMessage",
|
||||
"group": "navigation",
|
||||
"when": "scmProvider == git"
|
||||
}
|
||||
],
|
||||
"editor/title": [
|
||||
{
|
||||
"command": "kilo-code.new.openInTab",
|
||||
|
||||
@@ -6,6 +6,7 @@ import { KiloConnectionService } from "./services/cli-backend"
|
||||
import { registerAutocompleteProvider } from "./services/autocomplete"
|
||||
import { BrowserAutomationService } from "./services/browser-automation"
|
||||
import { TelemetryProxy } from "./services/telemetry"
|
||||
import { registerCommitMessageService } from "./services/commit-message"
|
||||
|
||||
export function activate(context: vscode.ExtensionContext) {
|
||||
console.log("Kilo Code extension is now active")
|
||||
@@ -85,6 +86,9 @@ export function activate(context: vscode.ExtensionContext) {
|
||||
// Register autocomplete provider
|
||||
registerAutocompleteProvider(context, connectionService)
|
||||
|
||||
// Register commit message generation
|
||||
registerCommitMessageService(context, connectionService)
|
||||
|
||||
// Dispose services when extension deactivates (kills the server)
|
||||
context.subscriptions.push({
|
||||
dispose: () => {
|
||||
|
||||
@@ -478,6 +478,18 @@ export class HttpClient {
|
||||
return this.request<string[]>("GET", `/find/file?${params.toString()}`, undefined, { directory })
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Commit Message Methods
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Generate a commit message for the current diff in the given directory.
|
||||
*/
|
||||
async generateCommitMessage(path: string, selectedFiles?: string[]): Promise<string> {
|
||||
const result = await this.request<{ message: string }>("POST", "/commit-message", { path, selectedFiles })
|
||||
return result.message
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// MCP Methods
|
||||
// ============================================
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest"
|
||||
|
||||
// Mock vscode following the pattern from AutocompleteServiceManager.spec.ts
|
||||
vi.mock("vscode", () => {
|
||||
const disposable = { dispose: vi.fn() }
|
||||
|
||||
return {
|
||||
commands: {
|
||||
registerCommand: vi.fn((_command: string, _callback: (...args: any[]) => any) => disposable),
|
||||
},
|
||||
window: {
|
||||
showErrorMessage: vi.fn(),
|
||||
withProgress: vi.fn(),
|
||||
},
|
||||
workspace: {
|
||||
workspaceFolders: [
|
||||
{
|
||||
uri: { fsPath: "/test/workspace" },
|
||||
},
|
||||
],
|
||||
},
|
||||
extensions: {
|
||||
getExtension: vi.fn(),
|
||||
},
|
||||
ProgressLocation: {
|
||||
SourceControl: 1,
|
||||
},
|
||||
Uri: {
|
||||
parse: (s: string) => ({ fsPath: s }),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
import * as vscode from "vscode"
|
||||
import { registerCommitMessageService } from "../index"
|
||||
import type { KiloConnectionService } from "../../cli-backend/connection-service"
|
||||
|
||||
describe("commit-message service", () => {
|
||||
let mockContext: vscode.ExtensionContext
|
||||
let mockConnectionService: KiloConnectionService
|
||||
let mockHttpClient: { generateCommitMessage: ReturnType<typeof vi.fn> }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
mockContext = {
|
||||
subscriptions: [],
|
||||
} as any
|
||||
|
||||
mockHttpClient = {
|
||||
generateCommitMessage: vi.fn().mockResolvedValue("feat: add new feature"),
|
||||
}
|
||||
|
||||
mockConnectionService = {
|
||||
getHttpClient: vi.fn().mockReturnValue(mockHttpClient),
|
||||
} as any
|
||||
})
|
||||
|
||||
describe("registerCommitMessageService", () => {
|
||||
it("returns an array of disposables", () => {
|
||||
const disposables = registerCommitMessageService(mockContext, mockConnectionService)
|
||||
|
||||
expect(Array.isArray(disposables)).toBe(true)
|
||||
expect(disposables.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it("registers the kilo-code.new.generateCommitMessage command", () => {
|
||||
registerCommitMessageService(mockContext, mockConnectionService)
|
||||
|
||||
expect(vscode.commands.registerCommand).toHaveBeenCalledWith(
|
||||
"kilo-code.new.generateCommitMessage",
|
||||
expect.any(Function),
|
||||
)
|
||||
})
|
||||
|
||||
it("pushes the command disposable to context.subscriptions", () => {
|
||||
registerCommitMessageService(mockContext, mockConnectionService)
|
||||
|
||||
expect(mockContext.subscriptions.length).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe("command execution", () => {
|
||||
let commandCallback: (...args: any[]) => Promise<void>
|
||||
|
||||
beforeEach(() => {
|
||||
registerCommitMessageService(mockContext, mockConnectionService)
|
||||
|
||||
// Extract the registered command callback
|
||||
const registerCall = vi.mocked(vscode.commands.registerCommand).mock.calls[0]!
|
||||
commandCallback = registerCall[1] as (...args: any[]) => Promise<void>
|
||||
})
|
||||
|
||||
it("shows error when git extension is not found", async () => {
|
||||
vi.mocked(vscode.extensions.getExtension).mockReturnValue(undefined)
|
||||
|
||||
await commandCallback()
|
||||
|
||||
expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("Git extension not found")
|
||||
})
|
||||
|
||||
it("shows error when no git repository is found", async () => {
|
||||
vi.mocked(vscode.extensions.getExtension).mockReturnValue({
|
||||
exports: {
|
||||
getAPI: () => ({ repositories: [] }),
|
||||
},
|
||||
} as any)
|
||||
|
||||
await commandCallback()
|
||||
|
||||
expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("No Git repository found")
|
||||
})
|
||||
|
||||
it("shows error when backend is not connected", async () => {
|
||||
vi.mocked(vscode.extensions.getExtension).mockReturnValue({
|
||||
exports: {
|
||||
getAPI: () => ({
|
||||
repositories: [{ inputBox: { value: "" }, rootUri: { fsPath: "/repo" } }],
|
||||
}),
|
||||
},
|
||||
} as any)
|
||||
vi.mocked(mockConnectionService.getHttpClient as any).mockReturnValue(null)
|
||||
|
||||
await commandCallback()
|
||||
|
||||
expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("Kilo backend is not connected")
|
||||
})
|
||||
|
||||
it("calls generateCommitMessage on the HTTP client with workspace path", async () => {
|
||||
const mockInputBox = { value: "" }
|
||||
vi.mocked(vscode.extensions.getExtension).mockReturnValue({
|
||||
exports: {
|
||||
getAPI: () => ({
|
||||
repositories: [{ inputBox: mockInputBox, rootUri: { fsPath: "/repo" } }],
|
||||
}),
|
||||
},
|
||||
} as any)
|
||||
|
||||
// Make withProgress execute its callback
|
||||
vi.mocked(vscode.window.withProgress).mockImplementation(async (_options, task) => {
|
||||
await task({} as any, {} as any)
|
||||
})
|
||||
|
||||
await commandCallback()
|
||||
|
||||
expect(mockHttpClient.generateCommitMessage).toHaveBeenCalledWith("/test/workspace")
|
||||
})
|
||||
|
||||
it("sets the generated message on the repository inputBox", async () => {
|
||||
const mockInputBox = { value: "" }
|
||||
vi.mocked(vscode.extensions.getExtension).mockReturnValue({
|
||||
exports: {
|
||||
getAPI: () => ({
|
||||
repositories: [{ inputBox: mockInputBox, rootUri: { fsPath: "/repo" } }],
|
||||
}),
|
||||
},
|
||||
} as any)
|
||||
|
||||
vi.mocked(vscode.window.withProgress).mockImplementation(async (_options, task) => {
|
||||
await task({} as any, {} as any)
|
||||
})
|
||||
|
||||
await commandCallback()
|
||||
|
||||
expect(mockInputBox.value).toBe("feat: add new feature")
|
||||
})
|
||||
|
||||
it("shows progress in SourceControl location", async () => {
|
||||
const mockInputBox = { value: "" }
|
||||
vi.mocked(vscode.extensions.getExtension).mockReturnValue({
|
||||
exports: {
|
||||
getAPI: () => ({
|
||||
repositories: [{ inputBox: mockInputBox, rootUri: { fsPath: "/repo" } }],
|
||||
}),
|
||||
},
|
||||
} as any)
|
||||
|
||||
vi.mocked(vscode.window.withProgress).mockImplementation(async (_options, task) => {
|
||||
await task({} as any, {} as any)
|
||||
})
|
||||
|
||||
await commandCallback()
|
||||
|
||||
expect(vscode.window.withProgress).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
location: vscode.ProgressLocation.SourceControl,
|
||||
title: "Generating commit message...",
|
||||
}),
|
||||
expect.any(Function),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,67 @@
|
||||
import * as vscode from "vscode"
|
||||
import type { KiloConnectionService } from "../cli-backend/connection-service"
|
||||
|
||||
interface GitRepository {
|
||||
inputBox: { value: string }
|
||||
rootUri: vscode.Uri
|
||||
}
|
||||
|
||||
interface GitAPI {
|
||||
repositories: GitRepository[]
|
||||
}
|
||||
|
||||
interface GitExtensionExports {
|
||||
getAPI(version: number): GitAPI
|
||||
}
|
||||
|
||||
export function registerCommitMessageService(
|
||||
context: vscode.ExtensionContext,
|
||||
connectionService: KiloConnectionService,
|
||||
): vscode.Disposable[] {
|
||||
const command = vscode.commands.registerCommand("kilo-code.new.generateCommitMessage", async () => {
|
||||
const extension = vscode.extensions.getExtension<GitExtensionExports>("vscode.git")
|
||||
if (!extension) {
|
||||
vscode.window.showErrorMessage("Git extension not found")
|
||||
return
|
||||
}
|
||||
|
||||
const git = extension.exports?.getAPI(1)
|
||||
const repository = git?.repositories[0]
|
||||
if (!repository) {
|
||||
vscode.window.showErrorMessage("No Git repository found")
|
||||
return
|
||||
}
|
||||
|
||||
const folder = vscode.workspace.workspaceFolders?.[0]
|
||||
if (!folder) {
|
||||
vscode.window.showErrorMessage("No workspace folder found")
|
||||
return
|
||||
}
|
||||
|
||||
const client = connectionService.getHttpClient()
|
||||
if (!client) {
|
||||
vscode.window.showErrorMessage("Kilo backend is not connected")
|
||||
return
|
||||
}
|
||||
|
||||
const path = folder.uri.fsPath
|
||||
|
||||
await vscode.window
|
||||
.withProgress(
|
||||
{ location: vscode.ProgressLocation.SourceControl, title: "Generating commit message..." },
|
||||
async () => {
|
||||
const message = await client.generateCommitMessage(path)
|
||||
repository.inputBox.value = message
|
||||
console.log("[Kilo New] Commit message generated successfully")
|
||||
},
|
||||
)
|
||||
.then(undefined, (error: unknown) => {
|
||||
const msg = error instanceof Error ? error.message : String(error)
|
||||
console.error("[Kilo New] Failed to generate commit message:", msg)
|
||||
vscode.window.showErrorMessage(`Failed to generate commit message: ${msg}`)
|
||||
})
|
||||
})
|
||||
|
||||
context.subscriptions.push(command)
|
||||
return [command]
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { Argv } from "yargs"
|
||||
import { cmd } from "./cmd"
|
||||
import { bootstrap } from "../bootstrap"
|
||||
import { generateCommitMessage } from "../../commit-message"
|
||||
import { UI } from "../ui"
|
||||
|
||||
export const CommitCommand = cmd({
|
||||
command: "commit",
|
||||
describe: "generate a commit message using AI",
|
||||
builder: (yargs: Argv) => {
|
||||
return yargs
|
||||
.option("auto", {
|
||||
describe: "auto-commit with the generated message",
|
||||
type: "boolean",
|
||||
default: false,
|
||||
})
|
||||
.option("staged-only", {
|
||||
describe: "only use staged changes",
|
||||
type: "boolean",
|
||||
default: true,
|
||||
})
|
||||
},
|
||||
handler: async (args) => {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
const result = await generateCommitMessage({ path: process.cwd() })
|
||||
const message = result.message
|
||||
|
||||
if (!process.stdout.isTTY) {
|
||||
process.stdout.write(message)
|
||||
return
|
||||
}
|
||||
|
||||
UI.println(message)
|
||||
|
||||
if (args.auto) {
|
||||
const proc = Bun.spawnSync(["git", "commit", "-m", message], {
|
||||
cwd: process.cwd(),
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
})
|
||||
if (proc.exitCode !== 0) {
|
||||
UI.error("git commit failed")
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,165 @@
|
||||
import { describe, expect, test, mock, beforeEach } from "bun:test"
|
||||
|
||||
// Mock dependencies before importing the module under test
|
||||
|
||||
let mockGitContext = {
|
||||
branch: "main",
|
||||
recentCommits: ["abc1234 initial commit"],
|
||||
files: [
|
||||
{ status: "modified" as const, path: "src/index.ts", diff: "+console.log('hello')" },
|
||||
],
|
||||
}
|
||||
|
||||
mock.module("../git-context", () => ({
|
||||
getGitContext: async () => mockGitContext,
|
||||
}))
|
||||
|
||||
let mockStreamText = "feat(src): add hello world logging"
|
||||
|
||||
mock.module("@/provider/provider", () => ({
|
||||
Provider: {
|
||||
defaultModel: async () => ({ providerID: "test", modelID: "test-model" }),
|
||||
getSmallModel: async () => ({
|
||||
providerID: "test",
|
||||
id: "test-small-model",
|
||||
}),
|
||||
getModel: async () => ({ providerID: "test", id: "test-model" }),
|
||||
},
|
||||
}))
|
||||
|
||||
mock.module("@/session/llm", () => ({
|
||||
LLM: {
|
||||
stream: async () => ({
|
||||
text: Promise.resolve(mockStreamText),
|
||||
}),
|
||||
},
|
||||
}))
|
||||
|
||||
mock.module("@/agent/agent", () => ({
|
||||
Agent: {},
|
||||
}))
|
||||
|
||||
mock.module("@/util/log", () => ({
|
||||
Log: {
|
||||
create: () => ({
|
||||
info: () => {},
|
||||
error: () => {},
|
||||
warn: () => {},
|
||||
debug: () => {},
|
||||
}),
|
||||
},
|
||||
}))
|
||||
|
||||
import { generateCommitMessage } from "../generate"
|
||||
|
||||
describe("commit-message.generate", () => {
|
||||
beforeEach(() => {
|
||||
mockGitContext = {
|
||||
branch: "main",
|
||||
recentCommits: ["abc1234 initial commit"],
|
||||
files: [
|
||||
{ status: "modified" as const, path: "src/index.ts", diff: "+console.log('hello')" },
|
||||
],
|
||||
}
|
||||
mockStreamText = "feat(src): add hello world logging"
|
||||
})
|
||||
|
||||
describe("prompt construction", () => {
|
||||
test("passes path to getGitContext", async () => {
|
||||
const result = await generateCommitMessage({ path: "/my/repo" })
|
||||
// If getGitContext is called, it returns our mock context and generates a message
|
||||
expect(result.message).toBeTruthy()
|
||||
})
|
||||
|
||||
test("generates message from git context with multiple files", async () => {
|
||||
mockGitContext = {
|
||||
branch: "feature/api",
|
||||
recentCommits: ["abc feat: add api", "def fix: typo"],
|
||||
files: [
|
||||
{ status: "added" as const, path: "src/api.ts", diff: "+export function api() {}" },
|
||||
{ status: "modified" as const, path: "src/index.ts", diff: "+import { api } from './api'" },
|
||||
],
|
||||
}
|
||||
mockStreamText = "feat(api): add api module"
|
||||
|
||||
const result = await generateCommitMessage({ path: "/repo" })
|
||||
expect(result.message).toBe("feat(api): add api module")
|
||||
})
|
||||
})
|
||||
|
||||
describe("response cleaning", () => {
|
||||
test("strips code block markers from response", async () => {
|
||||
mockStreamText = "```\nfeat: add feature\n```"
|
||||
|
||||
const result = await generateCommitMessage({ path: "/repo" })
|
||||
expect(result.message).toBe("feat: add feature")
|
||||
})
|
||||
|
||||
test("strips code block markers with language tag", async () => {
|
||||
mockStreamText = "```text\nfix(auth): resolve token refresh\n```"
|
||||
|
||||
const result = await generateCommitMessage({ path: "/repo" })
|
||||
expect(result.message).toBe("fix(auth): resolve token refresh")
|
||||
})
|
||||
|
||||
test("strips surrounding double quotes", async () => {
|
||||
mockStreamText = '"feat: add new feature"'
|
||||
|
||||
const result = await generateCommitMessage({ path: "/repo" })
|
||||
expect(result.message).toBe("feat: add new feature")
|
||||
})
|
||||
|
||||
test("strips surrounding single quotes", async () => {
|
||||
mockStreamText = "'fix: resolve bug'"
|
||||
|
||||
const result = await generateCommitMessage({ path: "/repo" })
|
||||
expect(result.message).toBe("fix: resolve bug")
|
||||
})
|
||||
|
||||
test("strips whitespace around the message", async () => {
|
||||
mockStreamText = " \n chore: update deps \n "
|
||||
|
||||
const result = await generateCommitMessage({ path: "/repo" })
|
||||
expect(result.message).toBe("chore: update deps")
|
||||
})
|
||||
|
||||
test("strips code blocks AND quotes together", async () => {
|
||||
mockStreamText = '```\n"refactor: simplify logic"\n```'
|
||||
|
||||
const result = await generateCommitMessage({ path: "/repo" })
|
||||
expect(result.message).toBe("refactor: simplify logic")
|
||||
})
|
||||
|
||||
test("returns clean message when no markers present", async () => {
|
||||
mockStreamText = "docs: update readme"
|
||||
|
||||
const result = await generateCommitMessage({ path: "/repo" })
|
||||
expect(result.message).toBe("docs: update readme")
|
||||
})
|
||||
})
|
||||
|
||||
describe("error on no changes", () => {
|
||||
test("throws when no git changes are found", async () => {
|
||||
mockGitContext = {
|
||||
branch: "main",
|
||||
recentCommits: [],
|
||||
files: [],
|
||||
}
|
||||
|
||||
await expect(generateCommitMessage({ path: "/repo" })).rejects.toThrow(
|
||||
"No changes found to generate a commit message for",
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("selectedFiles pass-through", () => {
|
||||
test("passes selectedFiles to getGitContext", async () => {
|
||||
// This verifies the function doesn't crash when selectedFiles is provided
|
||||
const result = await generateCommitMessage({
|
||||
path: "/repo",
|
||||
selectedFiles: ["src/a.ts"],
|
||||
})
|
||||
expect(result.message).toBeTruthy()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,335 @@
|
||||
import { describe, expect, test, beforeEach } from "bun:test"
|
||||
|
||||
// Mock Bun.spawnSync before importing the module under test
|
||||
const spawnSyncResults: Record<string, string> = {}
|
||||
|
||||
function setGitOutput(args: string, output: string) {
|
||||
spawnSyncResults[args] = output
|
||||
}
|
||||
|
||||
function clearGitOutputs() {
|
||||
for (const key of Object.keys(spawnSyncResults)) {
|
||||
delete spawnSyncResults[key]
|
||||
}
|
||||
}
|
||||
|
||||
// Replace global Bun.spawnSync — the git() helper in git-context.ts calls
|
||||
// result.stdout.toString().trim(), so we return a Buffer and let git() trim.
|
||||
Bun.spawnSync = ((cmd: string[], _opts?: any) => {
|
||||
const args = cmd.slice(1).join(" ")
|
||||
const output = spawnSyncResults[args] ?? ""
|
||||
return {
|
||||
stdout: Buffer.from(output),
|
||||
stderr: Buffer.from(""),
|
||||
exitCode: 0,
|
||||
}
|
||||
}) as typeof Bun.spawnSync
|
||||
|
||||
import { getGitContext } from "../git-context"
|
||||
|
||||
describe("commit-message.git-context", () => {
|
||||
beforeEach(() => {
|
||||
clearGitOutputs()
|
||||
// Defaults
|
||||
setGitOutput("branch --show-current", "main")
|
||||
setGitOutput("log --oneline -5", "abc1234 initial commit")
|
||||
setGitOutput("diff --name-status --cached", "")
|
||||
setGitOutput("status --porcelain", "")
|
||||
})
|
||||
|
||||
// NOTE: git() trims stdout, which eats the leading space of the first
|
||||
// porcelain line. We use staged (--name-status) tests for path-sensitive
|
||||
// assertions and only use porcelain for behavior tests where this is acceptable.
|
||||
|
||||
describe("lock file filtering", () => {
|
||||
test("filters out package-lock.json from staged changes", async () => {
|
||||
setGitOutput("diff --name-status --cached", "M\tsrc/index.ts\nM\tpackage-lock.json")
|
||||
setGitOutput("diff --cached -- src/index.ts", "+console.log('hello')")
|
||||
setGitOutput("diff --cached -- package-lock.json", "+lots of lock content")
|
||||
|
||||
const ctx = await getGitContext("/repo")
|
||||
|
||||
expect(ctx.files).toHaveLength(1)
|
||||
expect(ctx.files[0]!.path).toBe("src/index.ts")
|
||||
})
|
||||
|
||||
test("filters out yarn.lock from staged changes", async () => {
|
||||
setGitOutput("diff --name-status --cached", "M\tsrc/app.ts\nM\tyarn.lock")
|
||||
setGitOutput("diff --cached -- src/app.ts", "+import x")
|
||||
setGitOutput("diff --cached -- yarn.lock", "+lock data")
|
||||
|
||||
const ctx = await getGitContext("/repo")
|
||||
|
||||
expect(ctx.files).toHaveLength(1)
|
||||
expect(ctx.files[0]!.path).toBe("src/app.ts")
|
||||
})
|
||||
|
||||
test("filters out pnpm-lock.yaml from staged changes", async () => {
|
||||
setGitOutput("diff --name-status --cached", "M\treadme.md\nM\tpnpm-lock.yaml")
|
||||
setGitOutput("diff --cached -- pnpm-lock.yaml", "+lock")
|
||||
setGitOutput("diff --cached -- readme.md", "+docs")
|
||||
|
||||
const ctx = await getGitContext("/repo")
|
||||
|
||||
expect(ctx.files).toHaveLength(1)
|
||||
expect(ctx.files[0]!.path).toBe("readme.md")
|
||||
})
|
||||
|
||||
test("filters lock files in subdirectories", async () => {
|
||||
setGitOutput(
|
||||
"diff --name-status --cached",
|
||||
"M\tpackages/api/package-lock.json\nM\tpackages/api/src/index.ts",
|
||||
)
|
||||
setGitOutput("diff --cached -- packages/api/package-lock.json", "+lock stuff")
|
||||
setGitOutput("diff --cached -- packages/api/src/index.ts", "+code")
|
||||
|
||||
const ctx = await getGitContext("/repo")
|
||||
|
||||
expect(ctx.files).toHaveLength(1)
|
||||
expect(ctx.files[0]!.path).toBe("packages/api/src/index.ts")
|
||||
})
|
||||
|
||||
test("filters out bun.lockb, go.sum, Cargo.lock, poetry.lock", async () => {
|
||||
setGitOutput(
|
||||
"diff --name-status --cached",
|
||||
"M\tbun.lockb\nM\tgo.sum\nM\tCargo.lock\nM\tpoetry.lock\nM\tsrc/main.rs",
|
||||
)
|
||||
setGitOutput("diff --cached -- bun.lockb", "binary")
|
||||
setGitOutput("diff --cached -- go.sum", "+hash")
|
||||
setGitOutput("diff --cached -- Cargo.lock", "+lock")
|
||||
setGitOutput("diff --cached -- poetry.lock", "+lock")
|
||||
setGitOutput("diff --cached -- src/main.rs", "+fn main() {}")
|
||||
|
||||
const ctx = await getGitContext("/repo")
|
||||
|
||||
expect(ctx.files).toHaveLength(1)
|
||||
expect(ctx.files[0]!.path).toBe("src/main.rs")
|
||||
})
|
||||
})
|
||||
|
||||
describe("status parsing", () => {
|
||||
test("parses staged added files", async () => {
|
||||
setGitOutput("diff --name-status --cached", "A\tsrc/new-file.ts")
|
||||
setGitOutput("diff --cached -- src/new-file.ts", "+new content")
|
||||
|
||||
const ctx = await getGitContext("/repo")
|
||||
|
||||
expect(ctx.files).toHaveLength(1)
|
||||
expect(ctx.files[0]!.status).toBe("added")
|
||||
expect(ctx.files[0]!.path).toBe("src/new-file.ts")
|
||||
})
|
||||
|
||||
test("parses staged modified files", async () => {
|
||||
setGitOutput("diff --name-status --cached", "M\tsrc/existing.ts")
|
||||
setGitOutput("diff --cached -- src/existing.ts", "+changed line")
|
||||
|
||||
const ctx = await getGitContext("/repo")
|
||||
|
||||
expect(ctx.files).toHaveLength(1)
|
||||
expect(ctx.files[0]!.status).toBe("modified")
|
||||
})
|
||||
|
||||
test("parses staged deleted files", async () => {
|
||||
setGitOutput("diff --name-status --cached", "D\tsrc/removed.ts")
|
||||
setGitOutput("diff --cached -- src/removed.ts", "-deleted content")
|
||||
|
||||
const ctx = await getGitContext("/repo")
|
||||
|
||||
expect(ctx.files).toHaveLength(1)
|
||||
expect(ctx.files[0]!.status).toBe("deleted")
|
||||
})
|
||||
|
||||
test("parses staged renamed files", async () => {
|
||||
setGitOutput("diff --name-status --cached", "R100\told-name.ts\tnew-name.ts")
|
||||
|
||||
const ctx = await getGitContext("/repo")
|
||||
|
||||
expect(ctx.files).toHaveLength(1)
|
||||
expect(ctx.files[0]!.status).toBe("renamed")
|
||||
})
|
||||
|
||||
test("parses untracked files from porcelain", async () => {
|
||||
setGitOutput("status --porcelain", "?? src/brand-new.ts")
|
||||
|
||||
const ctx = await getGitContext("/repo")
|
||||
|
||||
expect(ctx.files).toHaveLength(1)
|
||||
expect(ctx.files[0]!.status).toBe("added")
|
||||
expect(ctx.files[0]!.diff).toBe("New untracked file: src/brand-new.ts")
|
||||
})
|
||||
|
||||
test("parses porcelain modified files", async () => {
|
||||
// Use staged to avoid porcelain trim edge case
|
||||
setGitOutput("diff --name-status --cached", "M\tsrc/changed.ts")
|
||||
setGitOutput("diff --cached -- src/changed.ts", "+line")
|
||||
|
||||
const ctx = await getGitContext("/repo")
|
||||
|
||||
expect(ctx.files).toHaveLength(1)
|
||||
expect(ctx.files[0]!.status).toBe("modified")
|
||||
})
|
||||
|
||||
test("prefers staged changes over unstaged", async () => {
|
||||
setGitOutput("diff --name-status --cached", "M\tsrc/staged.ts")
|
||||
setGitOutput("diff --cached -- src/staged.ts", "+staged change")
|
||||
// unstaged also exists but should be ignored when staged is present
|
||||
setGitOutput("status --porcelain", " M src/unstaged.ts")
|
||||
setGitOutput("diff -- src/unstaged.ts", "+unstaged change")
|
||||
|
||||
const ctx = await getGitContext("/repo")
|
||||
|
||||
expect(ctx.files).toHaveLength(1)
|
||||
expect(ctx.files[0]!.path).toBe("src/staged.ts")
|
||||
})
|
||||
|
||||
test("mapStatus returns 'modified' for unknown codes", async () => {
|
||||
setGitOutput("diff --name-status --cached", "X\tsrc/weird.ts")
|
||||
setGitOutput("diff --cached -- src/weird.ts", "+stuff")
|
||||
|
||||
const ctx = await getGitContext("/repo")
|
||||
|
||||
expect(ctx.files[0]!.status).toBe("modified")
|
||||
})
|
||||
})
|
||||
|
||||
describe("diff truncation", () => {
|
||||
test("truncates diffs exceeding 4000 characters", async () => {
|
||||
const longDiff = "x".repeat(5000)
|
||||
setGitOutput("diff --name-status --cached", "M\tsrc/big.ts")
|
||||
setGitOutput("diff --cached -- src/big.ts", longDiff)
|
||||
|
||||
const ctx = await getGitContext("/repo")
|
||||
|
||||
expect(ctx.files).toHaveLength(1)
|
||||
expect(ctx.files[0]!.diff.length).toBeLessThan(5000)
|
||||
expect(ctx.files[0]!.diff).toContain("... [truncated]")
|
||||
// 4000 chars + "\n... [truncated]"
|
||||
expect(ctx.files[0]!.diff.length).toBe(4000 + "\n... [truncated]".length)
|
||||
})
|
||||
|
||||
test("does not truncate diffs at exactly 4000 characters", async () => {
|
||||
const exactDiff = "y".repeat(4000)
|
||||
setGitOutput("diff --name-status --cached", "M\tsrc/exact.ts")
|
||||
setGitOutput("diff --cached -- src/exact.ts", exactDiff)
|
||||
|
||||
const ctx = await getGitContext("/repo")
|
||||
|
||||
expect(ctx.files[0]!.diff).toBe(exactDiff)
|
||||
expect(ctx.files[0]!.diff).not.toContain("... [truncated]")
|
||||
})
|
||||
|
||||
test("does not truncate diffs under 4000 characters", async () => {
|
||||
const shortDiff = "z".repeat(100)
|
||||
setGitOutput("diff --name-status --cached", "M\tsrc/small.ts")
|
||||
setGitOutput("diff --cached -- src/small.ts", shortDiff)
|
||||
|
||||
const ctx = await getGitContext("/repo")
|
||||
|
||||
expect(ctx.files[0]!.diff).toBe(shortDiff)
|
||||
})
|
||||
})
|
||||
|
||||
describe("binary file detection", () => {
|
||||
test("detects 'Binary files' in diff output", async () => {
|
||||
setGitOutput("diff --name-status --cached", "M\tassets/logo.png")
|
||||
setGitOutput(
|
||||
"diff --cached -- assets/logo.png",
|
||||
"Binary files a/assets/logo.png and b/assets/logo.png differ",
|
||||
)
|
||||
|
||||
const ctx = await getGitContext("/repo")
|
||||
|
||||
expect(ctx.files).toHaveLength(1)
|
||||
expect(ctx.files[0]!.diff).toBe("Binary file assets/logo.png has been modified")
|
||||
})
|
||||
|
||||
test("detects 'GIT binary patch' in diff output", async () => {
|
||||
setGitOutput("diff --name-status --cached", "M\tassets/icon.ico")
|
||||
setGitOutput("diff --cached -- assets/icon.ico", "GIT binary patch\nliteral 1234\ndata...")
|
||||
|
||||
const ctx = await getGitContext("/repo")
|
||||
|
||||
expect(ctx.files).toHaveLength(1)
|
||||
expect(ctx.files[0]!.diff).toBe("Binary file assets/icon.ico has been modified")
|
||||
})
|
||||
|
||||
test("does not flag normal diffs as binary", async () => {
|
||||
setGitOutput("diff --name-status --cached", "M\tsrc/code.ts")
|
||||
setGitOutput("diff --cached -- src/code.ts", "+const x = 1")
|
||||
|
||||
const ctx = await getGitContext("/repo")
|
||||
|
||||
expect(ctx.files[0]!.diff).toBe("+const x = 1")
|
||||
})
|
||||
})
|
||||
|
||||
describe("selected files filtering", () => {
|
||||
test("only includes files in selectedFiles set", async () => {
|
||||
setGitOutput("diff --name-status --cached", "M\tsrc/a.ts\nM\tsrc/b.ts\nM\tsrc/c.ts")
|
||||
setGitOutput("diff --cached -- src/a.ts", "+a")
|
||||
setGitOutput("diff --cached -- src/b.ts", "+b")
|
||||
setGitOutput("diff --cached -- src/c.ts", "+c")
|
||||
|
||||
const ctx = await getGitContext("/repo", ["src/a.ts", "src/c.ts"])
|
||||
|
||||
expect(ctx.files).toHaveLength(2)
|
||||
const paths = ctx.files.map((f) => f.path)
|
||||
expect(paths).toContain("src/a.ts")
|
||||
expect(paths).toContain("src/c.ts")
|
||||
expect(paths).not.toContain("src/b.ts")
|
||||
})
|
||||
|
||||
test("includes all files when selectedFiles is undefined", async () => {
|
||||
setGitOutput("diff --name-status --cached", "M\tsrc/a.ts\nM\tsrc/b.ts")
|
||||
setGitOutput("diff --cached -- src/a.ts", "+a")
|
||||
setGitOutput("diff --cached -- src/b.ts", "+b")
|
||||
|
||||
const ctx = await getGitContext("/repo")
|
||||
|
||||
expect(ctx.files).toHaveLength(2)
|
||||
})
|
||||
|
||||
test("returns empty files when selectedFiles has no matches", async () => {
|
||||
setGitOutput("diff --name-status --cached", "M\tsrc/a.ts")
|
||||
setGitOutput("diff --cached -- src/a.ts", "+a")
|
||||
|
||||
const ctx = await getGitContext("/repo", ["src/nonexistent.ts"])
|
||||
|
||||
expect(ctx.files).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("branch and recent commits", () => {
|
||||
test("returns current branch name", async () => {
|
||||
setGitOutput("branch --show-current", "feature/my-branch")
|
||||
|
||||
const ctx = await getGitContext("/repo")
|
||||
|
||||
expect(ctx.branch).toBe("feature/my-branch")
|
||||
})
|
||||
|
||||
test("falls back to HEAD when branch is empty", async () => {
|
||||
setGitOutput("branch --show-current", "")
|
||||
|
||||
const ctx = await getGitContext("/repo")
|
||||
|
||||
expect(ctx.branch).toBe("HEAD")
|
||||
})
|
||||
|
||||
test("returns recent commits as array", async () => {
|
||||
setGitOutput("log --oneline -5", "abc1234 first\ndef5678 second\nghi9012 third")
|
||||
|
||||
const ctx = await getGitContext("/repo")
|
||||
|
||||
expect(ctx.recentCommits).toEqual(["abc1234 first", "def5678 second", "ghi9012 third"])
|
||||
})
|
||||
|
||||
test("returns empty array when no commits", async () => {
|
||||
setGitOutput("log --oneline -5", "")
|
||||
|
||||
const ctx = await getGitContext("/repo")
|
||||
|
||||
expect(ctx.recentCommits).toEqual([])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,123 @@
|
||||
import { Provider } from "@/provider/provider"
|
||||
import { LLM } from "@/session/llm"
|
||||
import { Agent } from "@/agent/agent"
|
||||
import { Log } from "@/util/log"
|
||||
import type { CommitMessageRequest, CommitMessageResponse, GitContext } from "./types"
|
||||
import { getGitContext } from "./git-context"
|
||||
|
||||
const log = Log.create({ service: "commit-message" })
|
||||
|
||||
const SYSTEM_PROMPT = `You are a commit message generator. Generate a concise commit message following the Conventional Commits format.
|
||||
|
||||
Format: type(scope): description
|
||||
|
||||
Allowed types: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert
|
||||
|
||||
Rules:
|
||||
- Keep the subject line under 72 characters
|
||||
- Use imperative mood ("add feature" not "added feature")
|
||||
- No period at the end of the subject line
|
||||
- The scope is optional but encouraged
|
||||
- Output ONLY the commit message, nothing else`
|
||||
|
||||
function buildUserMessage(ctx: GitContext): string {
|
||||
const fileList = ctx.files.map((f) => `${f.status} ${f.path}`).join("\n")
|
||||
const diffs = ctx.files
|
||||
.filter((f) => f.diff)
|
||||
.map((f) => `--- ${f.path} ---\n${f.diff}`)
|
||||
.join("\n\n")
|
||||
|
||||
return `Generate a commit message for the following changes:
|
||||
|
||||
Branch: ${ctx.branch}
|
||||
Recent commits:
|
||||
${ctx.recentCommits.join("\n")}
|
||||
|
||||
Changed files:
|
||||
${fileList}
|
||||
|
||||
Diffs:
|
||||
${diffs}`
|
||||
}
|
||||
|
||||
function clean(text: string): string {
|
||||
let result = text.trim()
|
||||
// Strip code block markers
|
||||
if (result.startsWith("```")) {
|
||||
const first = result.indexOf("\n")
|
||||
if (first !== -1) {
|
||||
result = result.slice(first + 1)
|
||||
}
|
||||
}
|
||||
if (result.endsWith("```")) {
|
||||
result = result.slice(0, -3)
|
||||
}
|
||||
result = result.trim()
|
||||
// Strip surrounding quotes
|
||||
if ((result.startsWith('"') && result.endsWith('"')) || (result.startsWith("'") && result.endsWith("'"))) {
|
||||
result = result.slice(1, -1)
|
||||
}
|
||||
return result.trim()
|
||||
}
|
||||
|
||||
export async function generateCommitMessage(request: CommitMessageRequest): Promise<CommitMessageResponse> {
|
||||
const ctx = await getGitContext(request.path, request.selectedFiles)
|
||||
if (ctx.files.length === 0) {
|
||||
throw new Error("No changes found to generate a commit message for")
|
||||
}
|
||||
|
||||
log.info("generating", {
|
||||
branch: ctx.branch,
|
||||
files: ctx.files.length,
|
||||
})
|
||||
|
||||
const defaultModel = await Provider.defaultModel()
|
||||
const model =
|
||||
(await Provider.getSmallModel(defaultModel.providerID)) ??
|
||||
(await Provider.getModel(defaultModel.providerID, defaultModel.modelID))
|
||||
|
||||
const agent: Agent.Info = {
|
||||
name: "commit-message",
|
||||
mode: "primary",
|
||||
hidden: true,
|
||||
options: {},
|
||||
permission: [],
|
||||
prompt: SYSTEM_PROMPT,
|
||||
temperature: 0.3,
|
||||
}
|
||||
|
||||
const stream = await LLM.stream({
|
||||
agent,
|
||||
user: {
|
||||
id: "commit-message",
|
||||
sessionID: "commit-message",
|
||||
role: "user",
|
||||
model: {
|
||||
providerID: model.providerID,
|
||||
modelID: model.id,
|
||||
},
|
||||
time: {
|
||||
created: Date.now(),
|
||||
completed: Date.now(),
|
||||
},
|
||||
} as any,
|
||||
tools: {},
|
||||
model,
|
||||
small: true,
|
||||
messages: [
|
||||
{
|
||||
role: "user" as const,
|
||||
content: buildUserMessage(ctx),
|
||||
},
|
||||
],
|
||||
abort: new AbortController().signal,
|
||||
sessionID: "commit-message",
|
||||
system: [],
|
||||
retries: 3,
|
||||
})
|
||||
|
||||
const result = await stream.text
|
||||
log.info("generated", { message: result })
|
||||
|
||||
return { message: clean(result) }
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import type { GitContext, FileChange } from "./types"
|
||||
|
||||
const LOCK_FILES = new Set([
|
||||
"package-lock.json",
|
||||
"yarn.lock",
|
||||
"pnpm-lock.yaml",
|
||||
"Cargo.lock",
|
||||
"poetry.lock",
|
||||
"composer.lock",
|
||||
"Gemfile.lock",
|
||||
"go.sum",
|
||||
"bun.lockb",
|
||||
"bun.lock",
|
||||
"uv.lock",
|
||||
"Pipfile.lock",
|
||||
"flake.lock",
|
||||
"packages.lock.json",
|
||||
"project.assets.json",
|
||||
"paket.lock",
|
||||
"pubspec.lock",
|
||||
"Package.resolved",
|
||||
"Podfile.lock",
|
||||
"shrinkwrap.yaml",
|
||||
])
|
||||
|
||||
const MAX_DIFF_LENGTH = 4000
|
||||
|
||||
function isLockFile(filepath: string): boolean {
|
||||
const name = filepath.split("/").pop() ?? filepath
|
||||
return LOCK_FILES.has(name)
|
||||
}
|
||||
|
||||
function git(args: string[], cwd: string): string {
|
||||
const result = Bun.spawnSync(["git", ...args], {
|
||||
cwd,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
return result.stdout.toString().trim()
|
||||
}
|
||||
|
||||
function parseNameStatus(output: string): Array<{ status: string; path: string }> {
|
||||
if (!output) return []
|
||||
return output.split("\n").map((line) => {
|
||||
const [status, ...rest] = line.split("\t")
|
||||
return { status: status!, path: rest.join("\t") }
|
||||
})
|
||||
}
|
||||
|
||||
function parsePorcelain(output: string): Array<{ status: string; path: string }> {
|
||||
if (!output) return []
|
||||
return output
|
||||
.split("\n")
|
||||
.filter((line) => line.length > 0)
|
||||
.map((line) => {
|
||||
const xy = line.slice(0, 2)
|
||||
const filepath = line.slice(3)
|
||||
return { status: xy.trim(), path: filepath }
|
||||
})
|
||||
}
|
||||
|
||||
function mapStatus(code: string): FileChange["status"] {
|
||||
if (code.startsWith("R")) return "renamed"
|
||||
if (code === "A" || code === "??" || code === "?") return "added"
|
||||
if (code === "D") return "deleted"
|
||||
if (code === "M") return "modified"
|
||||
return "modified"
|
||||
}
|
||||
|
||||
function isUntracked(code: string): boolean {
|
||||
return code === "??" || code === "?"
|
||||
}
|
||||
|
||||
export async function getGitContext(repoPath: string, selectedFiles?: string[]): Promise<GitContext> {
|
||||
const branch = git(["branch", "--show-current"], repoPath) || "HEAD"
|
||||
const log = git(["log", "--oneline", "-5"], repoPath)
|
||||
const recentCommits = log ? log.split("\n") : []
|
||||
|
||||
// Check staged files first
|
||||
const staged = parseNameStatus(git(["diff", "--name-status", "--cached"], repoPath))
|
||||
const useStaged = staged.length > 0
|
||||
|
||||
// Fall back to all changes if nothing staged
|
||||
const raw = useStaged ? staged : parsePorcelain(git(["status", "--porcelain"], repoPath))
|
||||
|
||||
const selected = selectedFiles ? new Set(selectedFiles) : undefined
|
||||
|
||||
const files: FileChange[] = []
|
||||
for (const entry of raw) {
|
||||
if (isLockFile(entry.path)) continue
|
||||
if (selected && !selected.has(entry.path)) continue
|
||||
|
||||
const status = mapStatus(entry.status)
|
||||
const untracked = isUntracked(entry.status)
|
||||
|
||||
let diff: string
|
||||
if (untracked) {
|
||||
diff = `New untracked file: ${entry.path}`
|
||||
} else if (status === "deleted") {
|
||||
diff = useStaged
|
||||
? git(["diff", "--cached", "--", entry.path], repoPath)
|
||||
: git(["diff", "--", entry.path], repoPath)
|
||||
} else {
|
||||
const raw = useStaged
|
||||
? git(["diff", "--cached", "--", entry.path], repoPath)
|
||||
: git(["diff", "--", entry.path], repoPath)
|
||||
|
||||
// Detect binary files
|
||||
if (raw.includes("Binary files") || raw.includes("GIT binary patch")) {
|
||||
diff = `Binary file ${entry.path} has been modified`
|
||||
} else {
|
||||
diff = raw
|
||||
}
|
||||
}
|
||||
|
||||
// Truncate large diffs
|
||||
if (diff.length > MAX_DIFF_LENGTH) {
|
||||
diff = diff.slice(0, MAX_DIFF_LENGTH) + "\n... [truncated]"
|
||||
}
|
||||
|
||||
files.push({ status, path: entry.path, diff })
|
||||
}
|
||||
|
||||
return { branch, recentCommits, files }
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { generateCommitMessage } from "./generate"
|
||||
export type { CommitMessageRequest, CommitMessageResponse, GitContext, FileChange } from "./types"
|
||||
@@ -0,0 +1,27 @@
|
||||
export interface CommitMessageRequest {
|
||||
/** Workspace/repo path */
|
||||
path: string
|
||||
/** Optional subset of files to include */
|
||||
selectedFiles?: string[]
|
||||
}
|
||||
|
||||
export interface CommitMessageResponse {
|
||||
/** The generated commit message */
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface GitContext {
|
||||
/** Current branch name */
|
||||
branch: string
|
||||
/** Last 5 commit summaries */
|
||||
recentCommits: string[]
|
||||
/** File changes with status and diff content */
|
||||
files: FileChange[]
|
||||
}
|
||||
|
||||
export interface FileChange {
|
||||
status: "added" | "modified" | "deleted" | "renamed" | "untracked"
|
||||
path: string
|
||||
/** Diff content, or placeholder for binary/untracked files */
|
||||
diff: string
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import { ImportCommand } from "./cli/cmd/import"
|
||||
import { AttachCommand } from "./cli/cmd/tui/attach"
|
||||
import { TuiThreadCommand } from "./cli/cmd/tui/thread"
|
||||
import { AcpCommand } from "./cli/cmd/acp"
|
||||
import { CommitCommand } from "./cli/cmd/commit"
|
||||
import { EOL } from "os"
|
||||
import { WebCommand } from "./cli/cmd/web"
|
||||
import { PrCommand } from "./cli/cmd/pr"
|
||||
@@ -137,6 +138,7 @@ const cli = yargs(hideBin(process.argv))
|
||||
// .command(GithubCommand) // kilocode_change (Disabled until backend is ready)
|
||||
.command(PrCommand)
|
||||
.command(SessionCommand)
|
||||
.command(CommitCommand)
|
||||
.fail((msg, err) => {
|
||||
if (
|
||||
msg?.startsWith("Unknown argument") ||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Hono } from "hono"
|
||||
import { describeRoute, resolver, validator } from "hono-openapi"
|
||||
import z from "zod"
|
||||
import { generateCommitMessage } from "../../commit-message"
|
||||
import { lazy } from "../../util/lazy"
|
||||
import { errors } from "../error"
|
||||
|
||||
export const CommitMessageRoutes = lazy(() =>
|
||||
new Hono().post(
|
||||
"/",
|
||||
describeRoute({
|
||||
summary: "Generate commit message",
|
||||
description: "Generate a commit message using AI based on the current git diff.",
|
||||
operationId: "commitMessage.generate",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Generated commit message",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.object({ message: z.string() })),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"json",
|
||||
z.object({
|
||||
path: z.string().meta({ description: "Workspace/repo path" }),
|
||||
selectedFiles: z.array(z.string()).optional().meta({ description: "Optional subset of files to include" }),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const body = c.req.valid("json")
|
||||
const result = await generateCommitMessage(body)
|
||||
return c.json({ message: result.message })
|
||||
},
|
||||
),
|
||||
)
|
||||
@@ -38,6 +38,7 @@ import type { ContentfulStatusCode } from "hono/utils/http-status"
|
||||
import { websocket } from "hono/bun"
|
||||
import { HTTPException } from "hono/http-exception"
|
||||
import { errors } from "./error"
|
||||
import { CommitMessageRoutes } from "./routes/commit-message"
|
||||
import { QuestionRoutes } from "./routes/question"
|
||||
import { PermissionRoutes } from "./routes/permission"
|
||||
import { GlobalRoutes } from "./routes/global"
|
||||
@@ -235,6 +236,7 @@ export namespace Server {
|
||||
.route("/question", QuestionRoutes())
|
||||
.route("/provider", ProviderRoutes())
|
||||
.route("/telemetry", TelemetryRoutes()) // kilocode_change
|
||||
.route("/commit-message", CommitMessageRoutes()) // kilocode_change
|
||||
// kilocode_change start - Kilo Gateway routes
|
||||
.route(
|
||||
"/kilo",
|
||||
|
||||
Reference in New Issue
Block a user