10 Commits

10 changed files with 730 additions and 87 deletions
+2 -2
View File
@@ -1,6 +1,6 @@
# gai configuration # gai configuration
GAI_API_KEY=sk-your-api-key-here GAI_API_KEY=sk-your-api-key-here
GAI_API_BASE=https://api.openai.com/v1 GAI_API_BASE=https://api.deepseek.com/v1
GAI_MODEL=gpt-4o GAI_MODEL=deepseek-v4-flash
GAI_MAX_TOKENS=500 GAI_MAX_TOKENS=500
GAI_TEMPERATURE=0.7 GAI_TEMPERATURE=0.7
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 mplan
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+166 -5
View File
@@ -1,15 +1,176 @@
# git-ai <div align="center">
To install dependencies: # gai
**AI-powered git commit message generator**
[![Version](https://img.shields.io/badge/version-0.1.0-blue.svg)](https://github.com/mplan/git-ai)
[![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](./LICENSE)
[![Bun](https://img.shields.io/badge/runtime-Bun-f9d71c.svg)](https://bun.sh)
[![TypeScript](https://img.shields.io/badge/lang-TypeScript-3178c6.svg)](https://www.typescriptlang.org/)
Generate **Conventional Commits** messages using AI — based on your project context, code diff, and commit history.
</div>
---
## Features
- **3-layer context** — project overview, staged diff, and recent commit history
- **Conventional Commits** — `feat(scope): description` format by default
- **Interactive file selection** — ↑/↓ to navigate, space to select, top-level "Select all"
- **OpenAI-compatible API** — works with DeepSeek, OpenAI, Ollama, and more
- **Review before commit** — confirm, edit, or abort the generated message
- **Zero dependencies** — built entirely on Bun native APIs
## Quick Start
```bash ```bash
# Install dependencies
bun install bun install
# Configure your API key
bun run gai config
# Generate a commit message
bun run gai
``` ```
To run: ## Usage
```
gai Generate commit message (interactive file selection)
gai --auto Auto-stage all changed files
gai --dry-run Generate message without committing
gai config Configure API settings
gai --help Show help
gai --version Show version
```
### Interactive Flow
```
$ gai
Select files to stage:
◉ Select all
◉ src/git.ts (modified)
○ src/ai.ts (modified)
◉ src/newfile.ts (new)
↑/↓ navigate, space select, enter confirm
Generating commit message...
Generated commit message:
feat(git): add interactive file staging and commit wrapper
Use this message? [Y/n/e] Y
Committed successfully!
```
## Configuration
### Via `gai config` (interactive)
```bash ```bash
bun run index.ts bun run gai config
``` ```
This project was created using `bun init` in bun v1.3.14. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime. ### Via environment variables
| Variable | Default | Description |
|---|---|---|
| `GAI_API_KEY` | — | **Required.** Your API key |
| `GAI_API_BASE` | `https://api.deepseek.com/v1` | API base URL |
| `GAI_MODEL` | `deepseek-v4-flash` | Model name |
| `GAI_MAX_TOKENS` | `500` | Max response tokens |
| `GAI_TEMPERATURE` | `0.7` | Sampling temperature |
### Via `.env` file
Bun auto-loads `.env` — no dotenv needed:
```bash
GAI_API_KEY=sk-your-key
GAI_API_BASE=https://api.deepseek.com/v1
GAI_MODEL=deepseek-v4-flash
```
### Using other providers
<details>
<summary><strong>OpenAI</strong></summary>
```bash
GAI_API_KEY=sk-xxx
GAI_API_BASE=https://api.openai.com/v1
GAI_MODEL=gpt-4o
```
</details>
<details>
<summary><strong>Ollama (local)</strong></summary>
```bash
GAI_API_KEY=ollama
GAI_API_BASE=http://localhost:11434/v1
GAI_MODEL=llama3
```
</details>
<details>
<summary><strong>OpenRouter</strong></summary>
```bash
GAI_API_KEY=sk-or-xxx
GAI_API_BASE=https://openrouter.ai/api/v1
GAI_MODEL=anthropic/claude-sonnet-4
```
</details>
## How It Works
```
┌─────────────────────────────────────────────┐
│ gai │
├─────────────────────────────────────────────┤
│ 1. Collect project context │
│ ├─ README.md / package.json │
│ └─ Directory structure │
│ │
│ 2. Collect code changes │
│ └─ git diff --staged │
│ │
│ 3. Collect commit history │
│ └─ git log --oneline -10 │
│ │
│ 4. Build prompt → Call AI API │
│ │
│ 5. Review → Confirm → Commit │
└─────────────────────────────────────────────┘
```
## Build
Compile to a standalone binary:
```bash
bun run build
# Output: ./gai
```
## Test
```bash
bun test
```
## License
[MIT](./LICENSE)
+172 -10
View File
@@ -37,8 +37,8 @@ ${BOLD}Usage:${RESET}
${BOLD}Configuration:${RESET} ${BOLD}Configuration:${RESET}
Set via ${CYAN}gai config${RESET} or environment variables: Set via ${CYAN}gai config${RESET} or environment variables:
GAI_API_KEY OpenAI-compatible API key GAI_API_KEY OpenAI-compatible API key
GAI_API_BASE API base URL (default: https://api.openai.com/v1) GAI_API_BASE API base URL (default: https://api.deepseek.com/v1)
GAI_MODEL Model name (default: gpt-4o) GAI_MODEL Model name (default: deepseek-v4-flash)
GAI_MAX_TOKENS Max tokens (default: 500) GAI_MAX_TOKENS Max tokens (default: 500)
GAI_TEMPERATURE Temperature (default: 0.7) GAI_TEMPERATURE Temperature (default: 0.7)
`); `);
@@ -105,10 +105,152 @@ async function confirmCommit(message: string): Promise<"y" | "n" | "e"> {
} }
async function editMessage(current: string): Promise<string | null> { async function editMessage(current: string): Promise<string | null> {
console.log(` Current: ${DIM}${current}${RESET}`); if (!process.stdin.isTTY) return null;
console.log(` Enter new message (empty to abort):`);
const newMsg = await ask(" > "); process.stdout.write(` ${DIM}Edit message (Enter to confirm, Esc to abort):${RESET}\n`);
return newMsg || null;
const savedRaw = process.stdin.isRaw;
process.stdin.setRawMode(true);
process.stdin.resume();
let buffer = current;
let cursor = current.length;
const ESC = "\x1b";
const ENTER = "\r";
const CTRL_C = "\x03";
const BACKSPACE = "\x7f";
const DELETE = "\x1b[3~";
const LEFT = "\x1b[D";
const RIGHT = "\x1b[C";
const HOME = "\x1b[H" /* or \x01 */;
const END = "\x1b[F" /* or \x05 */;
function render() {
process.stdout.write("\x1b[2K\r > " + buffer);
if (cursor < buffer.length) {
process.stdout.write(`\x1b[${buffer.length - cursor}D`);
}
}
process.stdout.write(" > ");
process.stdout.write(buffer);
return new Promise((resolve) => {
let escapeBuf = "";
function handleSeq(seq: string) {
if (seq === "\x1b[D" || seq === "\x1bOD") {
if (cursor > 0) {
cursor--;
process.stdout.write("\x1b[D");
}
} else if (seq === "\x1b[C" || seq === "\x1bOC") {
if (cursor < buffer.length) {
cursor++;
process.stdout.write("\x1b[C");
}
} else if (seq === "\x1b[H" || seq === "\x1b[1~" || seq === "\x1bOH") {
if (cursor > 0) {
process.stdout.write(`\x1b[${cursor}D`);
cursor = 0;
}
} else if (seq === "\x1b[F" || seq === "\x1b[4~" || seq === "\x1bOF") {
if (cursor < buffer.length) {
process.stdout.write(`\x1b[${buffer.length - cursor}C`);
cursor = buffer.length;
}
} else if (seq === "\x1b[3~") {
if (cursor < buffer.length) {
buffer = buffer.slice(0, cursor) + buffer.slice(cursor + 1);
render();
}
}
}
process.stdin.on("data", (data: Buffer) => {
const key = data.toString();
if (key === CTRL_C) {
process.stdin.setRawMode(savedRaw === true);
process.stdin.pause();
process.stdin.removeAllListeners("data");
process.stdout.write("\n");
resolve(null);
return;
}
if (key === ESC || key.startsWith("\x1b[")) {
escapeBuf = key;
if (key.length >= 3) {
handleSeq(key);
escapeBuf = "";
}
return;
}
if (escapeBuf) {
escapeBuf += key;
if (/^[A-Za-z~]$/.test(key)) {
handleSeq(escapeBuf);
escapeBuf = "";
} else if (escapeBuf.length > 8) {
escapeBuf = "";
}
return;
}
if (key === ENTER) {
process.stdin.setRawMode(savedRaw === true);
process.stdin.pause();
process.stdin.removeAllListeners("data");
process.stdout.write("\n");
const result = buffer.trim();
resolve(result || null);
return;
}
if (key === BACKSPACE) {
if (cursor > 0) {
buffer = buffer.slice(0, cursor - 1) + buffer.slice(cursor);
cursor--;
render();
}
return;
}
if (key === "\x01") {
if (cursor > 0) {
process.stdout.write(`\x1b[${cursor}D`);
cursor = 0;
}
return;
}
if (key === "\x05") {
if (cursor < buffer.length) {
process.stdout.write(`\x1b[${buffer.length - cursor}C`);
cursor = buffer.length;
}
return;
}
if (key === "\x0b") {
buffer = buffer.slice(0, cursor);
render();
return;
}
if (key === "\x15") {
buffer = buffer.slice(cursor);
cursor = 0;
render();
return;
}
if (key.charCodeAt(0) >= 32 && key.charCodeAt(0) < 127) {
buffer = buffer.slice(0, cursor) + key + buffer.slice(cursor);
cursor++;
render();
}
});
});
} }
async function main() { async function main() {
@@ -213,12 +355,32 @@ async function main() {
return; return;
} }
function printCommitResult(
result: { branch: string; hash: string; files: number; insertions: number; deletions: number },
msg: string,
) {
console.log(`\n ${GREEN}${BOLD}✔ Committed successfully!${RESET}`);
const id = result.branch && result.hash
? `${YELLOW}[${result.branch} ${result.hash}]${RESET}`
: result.hash
? `${YELLOW}${result.hash}${RESET}`
: "";
console.log(` ${id} ${msg}`);
const parts: string[] = [];
if (result.files > 0) parts.push(`${YELLOW}${result.files} file${result.files > 1 ? "s" : ""} changed${RESET}`);
if (result.insertions > 0) parts.push(`${GREEN}${result.insertions} insertion${result.insertions > 1 ? "s" : ""}(+)${RESET}`);
if (result.deletions > 0) parts.push(`${RED}${result.deletions} deletion${result.deletions > 1 ? "s" : ""}(-)${RESET}`);
if (parts.length > 0) console.log(` ${parts.join(", ")}`);
}
const action = await confirmCommit(message); const action = await confirmCommit(message);
if (action === "y") { if (action === "y") {
try { try {
await commit(message); const result = await commit(message);
console.log(`\n ${GREEN}Committed successfully!${RESET}`); printCommitResult(result, message);
} catch (err) { } catch (err) {
console.error( console.error(
` ${RED}Commit failed: ${err instanceof Error ? err.message : err}${RESET}`, ` ${RED}Commit failed: ${err instanceof Error ? err.message : err}${RESET}`,
@@ -229,8 +391,8 @@ async function main() {
const edited = await editMessage(message); const edited = await editMessage(message);
if (edited) { if (edited) {
try { try {
await commit(edited); const result = await commit(edited);
console.log(`\n ${GREEN}Committed successfully!${RESET}`); printCommitResult(result, edited);
} catch (err) { } catch (err) {
console.error( console.error(
` ${RED}Commit failed: ${err instanceof Error ? err.message : err}${RESET}`, ` ${RED}Commit failed: ${err instanceof Error ? err.message : err}${RESET}`,
+73 -5
View File
@@ -8,15 +8,42 @@ interface ChatMessage {
interface ChatCompletionResponse { interface ChatCompletionResponse {
choices?: Array<{ choices?: Array<{
message?: { message?: {
content?: string; content?: string | null;
}; };
finish_reason?: string;
}>; }>;
error?: {
message?: string;
type?: string;
code?: string;
};
}
const MAX_RETRIES = 3;
const RETRY_DELAY = 1000;
function cleanMessage(raw: string): string {
let msg = raw.trim();
if (msg.startsWith("```") && msg.endsWith("```")) {
const lines = msg.split("\n");
if (lines.length > 2) {
lines.shift();
lines.pop();
msg = lines.join("\n").trim();
}
}
return msg;
}
async function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
} }
export async function generateCommitMessage( export async function generateCommitMessage(
config: Config, config: Config,
systemPrompt: string, systemPrompt: string,
userPrompt: string, userPrompt: string,
retries = MAX_RETRIES,
): Promise<string> { ): Promise<string> {
const url = `${config.apiBase.replace(/\/$/, "")}/chat/completions`; const url = `${config.apiBase.replace(/\/$/, "")}/chat/completions`;
@@ -25,6 +52,8 @@ export async function generateCommitMessage(
{ role: "user", content: userPrompt }, { role: "user", content: userPrompt },
]; ];
for (let attempt = 1; attempt <= retries; attempt++) {
try {
const response = await fetch(url, { const response = await fetch(url, {
method: "POST", method: "POST",
headers: { headers: {
@@ -41,15 +70,54 @@ export async function generateCommitMessage(
if (!response.ok) { if (!response.ok) {
const text = await response.text(); const text = await response.text();
if (response.status === 429 && attempt < retries) {
await sleep(RETRY_DELAY * attempt);
continue;
}
throw new Error(`API request failed (${response.status}): ${text}`); throw new Error(`API request failed (${response.status}): ${text}`);
} }
const data = (await response.json()) as ChatCompletionResponse; const data = (await response.json()) as ChatCompletionResponse;
const message = data.choices?.[0]?.message?.content?.trim();
if (!message) { if (data.error) {
throw new Error("Empty response from AI"); throw new Error(
`API error: ${data.error.message ?? JSON.stringify(data.error)}`,
);
} }
return message; const raw = data.choices?.[0]?.message?.content;
const finishReason = data.choices?.[0]?.finish_reason;
if (raw && raw.trim()) {
return cleanMessage(raw);
}
if (finishReason === "length") {
throw new Error(
"Response truncated (max_tokens too low). Try increasing GAI_MAX_TOKENS.",
);
}
if (finishReason === "content_filter") {
throw new Error("Response blocked by content filter.");
}
if (attempt < retries) {
await sleep(RETRY_DELAY * attempt);
continue;
}
throw new Error(
`Empty response from AI after ${retries} attempts. finish_reason: ${finishReason ?? "unknown"}`,
);
} catch (err) {
if (attempt >= retries) throw err;
if (err instanceof Error && err.message.startsWith("API error")) throw err;
if (err instanceof Error && err.message.includes("max_tokens")) throw err;
if (err instanceof Error && err.message.includes("content filter")) throw err;
await sleep(RETRY_DELAY * attempt);
}
}
throw new Error("Failed to generate commit message");
} }
+2 -2
View File
@@ -5,8 +5,8 @@ import type { Config } from "./types";
const DEFAULT_CONFIG: Config = { const DEFAULT_CONFIG: Config = {
apiKey: "", apiKey: "",
apiBase: "https://api.openai.com/v1", apiBase: "https://api.deepseek.com/v1",
model: "gpt-4o", model: "deepseek-v4-flash",
maxTokens: 500, maxTokens: 500,
temperature: 0.7, temperature: 0.7,
}; };
+25 -4
View File
@@ -98,13 +98,34 @@ export async function stageFiles(paths: string[]): Promise<void> {
await Bun.$`git add -- ${paths}`; await Bun.$`git add -- ${paths}`;
} }
export async function commit(message: string): Promise<void> { export async function commit(
message: string,
): Promise<{ branch: string; hash: string; files: number; insertions: number; deletions: number }> {
const proc = Bun.spawn(["git", "commit", "-m", message], { const proc = Bun.spawn(["git", "commit", "-m", message], {
stdout: "inherit", stdout: "pipe",
stderr: "inherit", stderr: "pipe",
}); });
const exitCode = await proc.exited; const exitCode = await proc.exited;
const stdout = await new Response(proc.stdout).text();
const stderr = await new Response(proc.stderr).text();
if (exitCode !== 0) { if (exitCode !== 0) {
throw new Error(`git commit failed (exit code ${exitCode})`); throw new Error(stderr.trim() || `git commit failed (exit code ${exitCode})`);
} }
const branchHashMatch = stdout.match(/\[(\S+)\s+([0-9a-f]{7,})/);
const branch = branchHashMatch?.[1] ?? "";
const hash = branchHashMatch?.[2] ?? "";
const filesMatch = stdout.match(/(\d+)\s+file/);
const insertionsMatch = stdout.match(/(\d+)\s+insertion/);
const deletionsMatch = stdout.match(/(\d+)\s+deletion/);
return {
branch,
hash,
files: parseInt(filesMatch?.[1] ?? "0"),
insertions: parseInt(insertionsMatch?.[1] ?? "0"),
deletions: parseInt(deletionsMatch?.[1] ?? "0"),
};
} }
+143 -37
View File
@@ -1,6 +1,37 @@
import * as readline from "node:readline";
import type { FileEntry } from "./types"; import type { FileEntry } from "./types";
import { BOLD, GREEN, YELLOW, CYAN, RESET } from "./terminal"; import { BOLD, GREEN, YELLOW, CYAN, DIM, RESET } from "./terminal";
const UP = "\x1b[A";
const DOWN = "\x1b[B";
const SPACE = " ";
const ENTER = "\r";
const CTRL_C = "\x03";
function hideCursor() {
process.stdout.write("\x1b[?25l");
}
function showCursor() {
process.stdout.write("\x1b[?25h");
}
function moveUp(n: number) {
process.stdout.write(`\x1b[${n}A`);
}
function moveDown(n: number) {
process.stdout.write(`\x1b[${n}B`);
}
function clearLine() {
process.stdout.write("\x1b[2K\r");
}
interface SelectItem {
label: string;
path: string;
selected: boolean;
}
export async function selectFiles( export async function selectFiles(
stagedFiles: FileEntry[], stagedFiles: FileEntry[],
@@ -9,54 +40,129 @@ export async function selectFiles(
if (unstagedFiles.length === 0) return []; if (unstagedFiles.length === 0) return [];
if (stagedFiles.length > 0) { if (stagedFiles.length > 0) {
console.log(`\n ${BOLD}Staged files (will be included):${RESET}`); process.stdout.write(`\n ${BOLD}Staged files (will be included):${RESET}\n`);
for (const f of stagedFiles) { for (const f of stagedFiles) {
console.log(` ${GREEN}${RESET} ${f.path} (${YELLOW}${f.label}${RESET})`); process.stdout.write(` ${GREEN}${RESET} ${f.path} (${YELLOW}${f.label}${RESET})\n`);
} }
} }
console.log(`\n ${BOLD}Unstaged files:${RESET}`); const items: SelectItem[] = [
for (let i = 0; i < unstagedFiles.length; i++) { { label: "Select all", path: "__all__", selected: false },
const f = unstagedFiles[i]!; ...unstagedFiles.map((f) => ({
console.log( label: `${f.path} (${f.label})`,
` ${CYAN}${i + 1}.${RESET} ${f.path} (${YELLOW}${f.label}${RESET})`, path: f.path,
); selected: false,
})),
];
let cursor = 0;
process.stdout.write(`\n ${BOLD}Select files to stage:${RESET}\n`);
process.stdout.write(` ${DIM}↑/↓ navigate, space select, enter confirm${RESET}\n\n`);
const itemStartRow = 4 + (stagedFiles.length > 0 ? stagedFiles.length + 2 : 0);
function render() {
for (let i = 0; i < items.length; i++) {
process.stdout.write("\x1b[2K\r");
const item = items[i]!;
const isAll = i === 0;
const cursor_ = i === cursor ? `${CYAN}${RESET} ` : " ";
const checkbox = item.selected ? `${GREEN}${RESET}` : `${DIM}${RESET}`;
if (isAll) {
process.stdout.write(`${cursor_} ${checkbox} ${BOLD}${item.label}${RESET}\n`);
} else {
process.stdout.write(`${cursor_} ${checkbox} ${item.path.includes("(") ? item.label : `${item.label}`}\n`);
}
}
moveUp(items.length);
} }
console.log(""); function toggleItem(index: number) {
const rl = readline.createInterface({ const item = items[index]!;
input: process.stdin, item.selected = !item.selected;
output: process.stdout,
}); if (index === 0) {
for (let i = 1; i < items.length; i++) {
items[i]!.selected = item.selected;
}
} else {
const allSelected = items.slice(1).every((it) => it.selected);
items[0]!.selected = allSelected;
}
}
return new Promise((resolve) => { return new Promise((resolve) => {
rl.question( if (process.stdin.isTTY !== true) {
" Enter files to stage (e.g. 1,3) or 'a' for all: ",
(answer) => {
rl.close();
const trimmed = answer.trim().toLowerCase();
if (trimmed === "a" || trimmed === "all") {
resolve(unstagedFiles.map((f) => f.path));
return;
}
if (trimmed === "") {
resolve([]); resolve([]);
return; return;
} }
const indices = trimmed const wasRaw = process.stdin.isRaw;
.split(/[,\s]+/) if (wasRaw !== true) {
.map((s) => parseInt(s.trim())) process.stdin.setRawMode(true);
.filter( }
(n) => !isNaN(n) && n >= 1 && n <= unstagedFiles.length, process.stdin.resume();
) hideCursor();
.map((n) => n - 1);
const uniqueIndices = [...new Set(indices)]; render();
resolve(uniqueIndices.map((i) => unstagedFiles[i]!.path));
}, const onData = (data: Buffer) => {
const key = data.toString();
if (key === CTRL_C) {
process.stdin.setRawMode(wasRaw === true);
process.stdin.pause();
process.stdin.removeListener("data", onData);
showCursor();
for (let i = 0; i < items.length; i++) {
process.stdout.write("\x1b[2K\n");
}
moveUp(items.length);
process.stdout.write(`\n Aborted.\n`);
process.exit(1);
}
if (key === UP) {
if (cursor > 0) {
cursor--;
render();
}
} else if (key === DOWN) {
if (cursor < items.length - 1) {
cursor++;
render();
}
} else if (key === SPACE) {
toggleItem(cursor);
render();
} else if (key === ENTER) {
process.stdin.setRawMode(wasRaw === true);
process.stdin.pause();
process.stdin.removeListener("data", onData);
for (let i = 0; i < items.length; i++) {
process.stdout.write("\x1b[2K\n");
}
moveUp(items.length);
const selected = items
.slice(1)
.filter((it) => it.selected)
.map((it) => it.path);
if (selected.length > 0) {
process.stdout.write(
` ${GREEN}Staged ${selected.length} file(s):${RESET} ${selected.join(", ")}\n`,
); );
}
showCursor();
resolve(selected);
}
};
process.stdin.on("data", onData);
}); });
} }
+40
View File
@@ -0,0 +1,40 @@
import { test, expect, describe } from "bun:test";
import { loadConfig } from "../src/config";
describe("config", () => {
test("loadConfig env variables override config file and defaults", async () => {
const origBase = process.env.GAI_API_BASE;
const origModel = process.env.GAI_MODEL;
process.env.GAI_API_BASE = "https://custom.api.com/v1";
process.env.GAI_MODEL = "custom-model";
const config = await loadConfig();
expect(config.apiBase).toBe("https://custom.api.com/v1");
expect(config.model).toBe("custom-model");
if (origBase) process.env.GAI_API_BASE = origBase;
else delete process.env.GAI_API_BASE;
if (origModel) process.env.GAI_MODEL = origModel;
else delete process.env.GAI_MODEL;
});
test("loadConfig reads from environment variables", async () => {
const origBase = process.env.GAI_API_BASE;
const origModel = process.env.GAI_MODEL;
process.env.GAI_API_BASE = "https://api.deepseek.com/v1";
process.env.GAI_MODEL = "deepseek-v4-flash";
const config = await loadConfig();
expect(config.apiBase).toBe("https://api.deepseek.com/v1");
expect(config.model).toBe("deepseek-v4-flash");
if (origBase) process.env.GAI_API_BASE = origBase;
else delete process.env.GAI_API_BASE;
if (origModel) process.env.GAI_MODEL = origModel;
else delete process.env.GAI_MODEL;
});
});
+64
View File
@@ -0,0 +1,64 @@
import { test, expect, describe } from "bun:test";
import { buildPrompt, SYSTEM_PROMPT } from "../src/prompt";
import type { ProjectContext } from "../src/types";
describe("prompt", () => {
test("SYSTEM_PROMPT contains Conventional Commits format", () => {
expect(SYSTEM_PROMPT).toContain("<type>(<scope>)");
expect(SYSTEM_PROMPT).toContain("feat");
expect(SYSTEM_PROMPT).toContain("fix");
});
test("buildPrompt with full context", () => {
const ctx: ProjectContext = {
readme: "A test project",
packageDescription: "Test package",
structure: "src/, tests/",
recentCommits: ["abc123 feat: initial commit"],
diff: "+new line\n-old line",
};
const prompt = buildPrompt(ctx);
expect(prompt).toContain("## Project Context");
expect(prompt).toContain("Test package");
expect(prompt).toContain("src/, tests/");
expect(prompt).toContain("A test project");
expect(prompt).toContain("## Recent Commits");
expect(prompt).toContain("abc123 feat: initial commit");
expect(prompt).toContain("## Staged Changes");
expect(prompt).toContain("+new line");
expect(prompt).toContain("Generate a commit message");
});
test("buildPrompt with minimal context (only diff)", () => {
const ctx: ProjectContext = {
readme: null,
packageDescription: null,
structure: null,
recentCommits: [],
diff: "+added code",
};
const prompt = buildPrompt(ctx);
expect(prompt).not.toContain("## Project Context");
expect(prompt).not.toContain("## Recent Commits");
expect(prompt).toContain("## Staged Changes");
expect(prompt).toContain("+added code");
});
test("buildPrompt includes diff as-is (truncation is caller's responsibility)", () => {
const diff = "+short diff content";
const ctx: ProjectContext = {
readme: null,
packageDescription: null,
structure: null,
recentCommits: [],
diff,
};
const prompt = buildPrompt(ctx);
expect(prompt).toContain(diff);
});
});