14 Commits

13 changed files with 924 additions and 129 deletions
+2 -2
View File
@@ -1,6 +1,6 @@
# gai configuration
GAI_API_KEY=sk-your-api-key-here
GAI_API_BASE=https://api.openai.com/v1
GAI_MODEL=gpt-4o
GAI_API_BASE=https://api.deepseek.com/v1
GAI_MODEL=deepseek-v4-flash
GAI_MAX_TOKENS=500
GAI_TEMPERATURE=0.7
+3
View File
@@ -10,6 +10,9 @@ dist
coverage
*.lcov
# lock
bun.lock
# logs
logs
_.log
+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.
+190 -5
View File
@@ -1,15 +1,200 @@
# 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
- **Interactive menu** — `gai` opens a menu, select actions with ↑/↓ + space/enter
- **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"
- **Inline editing** — edit AI-generated messages right in the terminal with cursor movement
- **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
# Install dependencies
bun install
# Configure your API key
gai config
# Open interactive menu
gai
# Or directly generate a commit message
gai commit
```
To run:
## Usage
```
gai Open interactive menu
gai commit Generate commit message (interactive file selection)
gai commit --auto Auto-stage all changed files
gai commit -d Generate message without committing
gai config Configure API settings
gai --help Show help
gai --version Show version
```
### Interactive Menu
```
$ gai
gai
↑/↓ navigate, space/enter select
◉ commit Generate AI commit message
```
### Commit Flow
```
$ gai commit
Staged files (will be included):
✓ src/git.ts (modified)
Unstaged files:
1. src/ai.ts (modified)
2. src/newfile.ts (new)
Select files to stage:
◉ Select all
○ src/ai.ts (modified)
◉ src/newfile.ts (new)
Generating commit message...
Generated commit message:
feat(git): add interactive file staging and commit wrapper
Use this message? [Y/n/e] Y
✔ Committed successfully!
[main a3f7c2b] feat(git): add interactive file staging and commit wrapper
1 file changed, 45 insertions(+), 12 deletions(-)
```
## Configuration
### Via `gai config` (interactive)
```bash
bun run index.ts
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-chat` | 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-chat
```
### 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)
-26
View File
@@ -1,26 +0,0 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "git-ai",
"devDependencies": {
"@types/bun": "latest",
},
"peerDependencies": {
"typescript": "^5",
},
},
},
"packages": {
"@types/bun": ["@types/bun@1.3.14", "https://registry.npmmirror.com/@types/bun/-/bun-1.3.14.tgz", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
"@types/node": ["@types/node@25.9.2", "https://registry.npmmirror.com/@types/node/-/node-25.9.2.tgz", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw=="],
"bun-types": ["bun-types@1.3.14", "https://registry.npmmirror.com/bun-types/-/bun-types-1.3.14.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
"typescript": ["typescript@5.9.3", "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@7.24.6", "https://registry.npmmirror.com/undici-types/-/undici-types-7.24.6.tgz", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
}
}
+335 -22
View File
@@ -27,9 +27,10 @@ function showHelp() {
${BOLD}gai${RESET} — AI-powered git commit message generator
${BOLD}Usage:${RESET}
gai Generate commit message for staged/changed files
gai --auto Auto-stage all changed files
gai --dry-run Generate message without committing
gai Open interactive menu
gai commit Generate commit message for staged/changed files
gai commit --auto Auto-stage all changed files
gai commit -d Generate message without committing
gai config Configure API settings
gai --help Show this help message
gai --version Show version
@@ -37,8 +38,8 @@ ${BOLD}Usage:${RESET}
${BOLD}Configuration:${RESET}
Set via ${CYAN}gai config${RESET} or environment variables:
GAI_API_KEY OpenAI-compatible API key
GAI_API_BASE API base URL (default: https://api.openai.com/v1)
GAI_MODEL Model name (default: gpt-4o)
GAI_API_BASE API base URL (default: https://api.deepseek.com/v1)
GAI_MODEL Model name (default: deepseek-chat)
GAI_MAX_TOKENS Max tokens (default: 500)
GAI_TEMPERATURE Temperature (default: 0.7)
`);
@@ -105,31 +106,297 @@ async function confirmCommit(message: string): Promise<"y" | "n" | "e"> {
}
async function editMessage(current: string): Promise<string | null> {
console.log(` Current: ${DIM}${current}${RESET}`);
console.log(` Enter new message (empty to abort):`);
const newMsg = await ask(" > ");
return newMsg || null;
if (!process.stdin.isTTY) return null;
process.stdout.write(` ${DIM}Edit message (Enter to confirm, Esc to abort):${RESET}\n`);
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";
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() {
if (args.includes("--help") || args.includes("-h")) {
showHelp();
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(", ")}`);
}
interface MenuAction {
key: string;
label: string;
description: string;
}
const MENU_ACTIONS: MenuAction[] = [
{ key: "commit", label: "commit", description: "Generate AI commit message" },
{ key: "config", label: "config", description: "Configure API settings" },
];
async function showMenu(): Promise<void> {
const actions = MENU_ACTIONS;
let cursor = 0;
const headerLines = 4;
process.stdout.write(`\n ${BOLD}gai${RESET}\n`);
process.stdout.write(` ${DIM}↑/↓ navigate, space/enter select${RESET}\n\n`);
const totalLines = headerLines + actions.length;
function render() {
for (let i = 0; i < actions.length; i++) {
process.stdout.write("\x1b[2K\r");
const a = actions[i]!;
const pointer = i === cursor ? `${CYAN}${RESET} ` : " ";
const dot = i === cursor ? `${GREEN}${RESET}` : `${DIM}${RESET}`;
const name = i === cursor ? `${BOLD}${a.label}${RESET}` : a.label;
const desc = i === cursor ? a.description : `${DIM}${a.description}${RESET}`;
process.stdout.write(`${pointer} ${dot} ${name}${" ".repeat(Math.max(1, 14 - a.label.length))}${desc}\n`);
}
process.stdout.write(`\x1b[${actions.length}A`);
}
function clearMenu() {
process.stdout.write(`\x1b[${headerLines}A`);
for (let i = 0; i < totalLines; i++) {
process.stdout.write("\r\x1b[2K\n");
}
process.stdout.write(`\x1b[${totalLines}A`);
}
if (!process.stdin.isTTY) {
console.error(` ${RED}Error: Interactive menu requires a TTY.${RESET}`);
process.exit(1);
}
const savedRaw = process.stdin.isRaw;
process.stdin.setRawMode(true);
process.stdin.resume();
process.stdout.write("\x1b[?25l");
render();
return new Promise((resolve) => {
let escapeBuf = "";
function handleSeq(seq: string) {
if (seq === "\x1b[A" || seq === "\x1bOA") {
if (cursor > 0) {
cursor--;
render();
}
} else if (seq === "\x1b[B" || seq === "\x1bOB") {
if (cursor < actions.length - 1) {
cursor++;
render();
}
}
}
process.stdin.on("data", (data: Buffer) => {
const key = data.toString();
if (key === "\x03") {
process.stdin.setRawMode(savedRaw === true);
process.stdin.pause();
process.stdin.removeAllListeners("data");
clearMenu();
process.stdout.write("\x1b[?25h");
resolve();
return;
}
if (args.includes("--version") || args.includes("-v")) {
console.log("gai v0.1.0");
if (key === "\x1b" || key.startsWith("\x1b[")) {
escapeBuf = key;
if (key.length >= 3) {
handleSeq(key);
escapeBuf = "";
}
return;
}
if (args[0] === "config") {
await handleConfig();
if (escapeBuf) {
escapeBuf += key;
if (/^[A-Za-z~]$/.test(key)) {
handleSeq(escapeBuf);
escapeBuf = "";
} else if (escapeBuf.length > 8) {
escapeBuf = "";
}
return;
}
const autoMode = args.includes("--auto") || args.includes("-a");
const dryRun = args.includes("--dry-run") || args.includes("-d");
if (key === " " || key === "\r") {
const selected = actions[cursor]!;
process.stdin.setRawMode(savedRaw === true);
process.stdin.pause();
process.stdin.removeAllListeners("data");
clearMenu();
process.stdout.write("\x1b[?25h");
if (selected.key === "commit") {
handleCommit(false, false).then(resolve);
} else if (selected.key === "config") {
handleConfig().then(resolve);
} else {
resolve();
}
return;
}
});
});
}
async function handleCommit(autoMode: boolean, dryRun: boolean): Promise<void> {
const config = await loadConfig();
if (!config.apiKey) {
@@ -217,8 +484,8 @@ async function main() {
if (action === "y") {
try {
await commit(message);
console.log(`\n ${GREEN}Committed successfully!${RESET}`);
const result = await commit(message);
printCommitResult(result, message);
} catch (err) {
console.error(
` ${RED}Commit failed: ${err instanceof Error ? err.message : err}${RESET}`,
@@ -229,8 +496,8 @@ async function main() {
const edited = await editMessage(message);
if (edited) {
try {
await commit(edited);
console.log(`\n ${GREEN}Committed successfully!${RESET}`);
const result = await commit(edited);
printCommitResult(result, edited);
} catch (err) {
console.error(
` ${RED}Commit failed: ${err instanceof Error ? err.message : err}${RESET}`,
@@ -250,6 +517,52 @@ async function main() {
}
}
async function main() {
if (args.includes("--help") || args.includes("-h")) {
showHelp();
return;
}
if (args.includes("--version") || args.includes("-v")) {
console.log("gai v0.1.0");
return;
}
const subcommand = args[0];
if (subcommand === "config") {
await handleConfig();
return;
}
if (subcommand === "help") {
showHelp();
return;
}
if (subcommand === "commit") {
const autoMode = args.includes("--auto") || args.includes("-a");
const dryRun = args.includes("--dry-run") || args.includes("-d");
await handleCommit(autoMode, dryRun);
return;
}
if (!subcommand) {
await showMenu();
return;
}
console.error(` ${RED}Unknown command: ${subcommand}${RESET}`);
showHelp();
process.exit(1);
}
process.on("SIGINT", () => {
process.stdout.write("\x1b[?25h");
process.stdout.write("\n");
process.exit(130);
});
main().catch((err) => {
console.error(` ${RED}Unexpected error: ${err}${RESET}`);
process.exit(1);
+1 -1
View File
@@ -1,5 +1,5 @@
{
"name": "git-ai",
"name": "gai",
"version": "0.1.0",
"description": "AI-powered git commit message generator",
"module": "index.ts",
+73 -5
View File
@@ -8,15 +8,42 @@ interface ChatMessage {
interface ChatCompletionResponse {
choices?: Array<{
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(
config: Config,
systemPrompt: string,
userPrompt: string,
retries = MAX_RETRIES,
): Promise<string> {
const url = `${config.apiBase.replace(/\/$/, "")}/chat/completions`;
@@ -25,6 +52,8 @@ export async function generateCommitMessage(
{ role: "user", content: userPrompt },
];
for (let attempt = 1; attempt <= retries; attempt++) {
try {
const response = await fetch(url, {
method: "POST",
headers: {
@@ -41,15 +70,54 @@ export async function generateCommitMessage(
if (!response.ok) {
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}`);
}
const data = (await response.json()) as ChatCompletionResponse;
const message = data.choices?.[0]?.message?.content?.trim();
if (!message) {
throw new Error("Empty response from AI");
if (data.error) {
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 = {
apiKey: "",
apiBase: "https://api.openai.com/v1",
model: "gpt-4o",
apiBase: "https://api.deepseek.com/v1",
model: "deepseek-v4-flash",
maxTokens: 500,
temperature: 0.7,
};
+25 -4
View File
@@ -98,13 +98,34 @@ export async function stageFiles(paths: string[]): Promise<void> {
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], {
stdout: "inherit",
stderr: "inherit",
stdout: "pipe",
stderr: "pipe",
});
const exitCode = await proc.exited;
const stdout = await new Response(proc.stdout).text();
const stderr = await new Response(proc.stderr).text();
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 { 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(
stagedFiles: FileEntry[],
@@ -9,54 +40,129 @@ export async function selectFiles(
if (unstagedFiles.length === 0) return [];
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) {
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}`);
for (let i = 0; i < unstagedFiles.length; i++) {
const f = unstagedFiles[i]!;
console.log(
` ${CYAN}${i + 1}.${RESET} ${f.path} (${YELLOW}${f.label}${RESET})`,
);
const items: SelectItem[] = [
{ label: "Select all", path: "__all__", selected: false },
...unstagedFiles.map((f) => ({
label: `${f.path} (${f.label})`,
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("");
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
function toggleItem(index: number) {
const item = items[index]!;
item.selected = !item.selected;
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) => {
rl.question(
" 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 === "") {
if (process.stdin.isTTY !== true) {
resolve([]);
return;
}
const indices = trimmed
.split(/[,\s]+/)
.map((s) => parseInt(s.trim()))
.filter(
(n) => !isNaN(n) && n >= 1 && n <= unstagedFiles.length,
)
.map((n) => n - 1);
const wasRaw = process.stdin.isRaw;
if (wasRaw !== true) {
process.stdin.setRawMode(true);
}
process.stdin.resume();
hideCursor();
const uniqueIndices = [...new Set(indices)];
resolve(uniqueIndices.map((i) => unstagedFiles[i]!.path));
},
render();
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);
});
});