17 Commits

Author SHA1 Message Date
Mplan 9885096229 feat(cli): add branch push check before PR creation 2026-06-11 18:24:53 +08:00
Mplan 1ea180387c refactor(cli): extract interactive menu into reusable module (#3)
Extract duplicate menu rendering logic from `index.ts` into a new `src/menu.ts` module, providing generic `selectOne` and `selectMany` functions. This reduces code duplication, improves maintainability, and adds consistent UI controls display across the commit flow and platform selection.

Reviewed-on: #3
2026-06-11 18:12:12 +08:00
Mplan 6ff541284e feat(cli): add pull request creation with AI-generated messages (#2)
Add a new `gai pr` subcommand that generates pull request titles and descriptions using AI, then creates the PR via GitHub CLI (`gh`) or Gitea CLI (`tea`). This extends the existing commit-generation system by reusing retry logic and prompt infrastructure, and introduces a `callAI` function that returns raw output (instead of pre-cleaned messages) to support structured PR responses.

Reviewed-on: #2
2026-06-11 00:39:20 +08:00
Mplan 76a5bac11a chore(config): ignore bun.lock 2026-06-11 00:01:40 +08:00
Mplan 0bfde59725 chore(config): rename project to gai 2026-06-10 23:57:25 +08:00
Mplan 890a192157 feat(cli): add help subcommand to show usage information 2026-06-09 18:52:03 +08:00
Mplan a4e0b6f747 feat: implement gai CLI for AI-generated Conventional Commits (#1)
Reviewed-on: #1
2026-06-09 18:21:23 +08:00
Mplan c2e60db196 chore(config): update default model to deepseek-v4-flash 2026-06-09 17:43:00 +08:00
Mplan 647d1096ba refactor(cli): extract commit result display and extend commit return with stats 2026-06-09 17:38:23 +08:00
Mplan 6d007698f9 feat(cli): show commit hash and summary on success 2026-06-09 17:38:00 +08:00
Mplan 68e98be653 refactor(cli): replace external editor with inline terminal editing 2026-06-09 17:31:52 +08:00
Mplan 37916f6c49 feat(cli): use external editor for commit message editing 2026-06-09 17:24:07 +08:00
Mplan 8be9f51532 docs: add comprehensive project documentation 2026-06-09 17:22:03 +08:00
Mplan 455c5d9b41 docs: add MIT license 2026-06-09 17:20:53 +08:00
Mplan de96c8862e feat: add retry logic to AI client and interactive unstaged file selector 2026-06-09 17:13:34 +08:00
Mplan 9f33d0f2ed chore: update default API and model to DeepSeek 2026-06-09 17:09:32 +08:00
Mplan 401f6ccbe0 test: add unit tests for config and prompt modules 2026-06-09 17:09:06 +08:00
17 changed files with 1554 additions and 139 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.
+191 -5
View File
@@ -1,15 +1,201 @@
# 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
Choose a workflow
↑/↓ navigate · enter/space select · ctrl+c cancel
● commit Generate AI commit message
○ pr Create a PR with AI-generated title
○ config Configure API settings
```
### Commit Flow
```
$ gai commit
Staged files (will be included):
✓ src/git.ts (modified)
Select files to stage:
2 unstaged files available
↑/↓ navigate · space toggle · enter confirm · ctrl+c cancel
□ 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=="],
}
}
+461 -27
View File
@@ -13,12 +13,29 @@ import {
commit,
} from "./src/git";
import { selectFiles } from "./src/selector";
import { selectOne } from "./src/menu";
import { collectProjectContext } from "./src/context";
import { buildPrompt, SYSTEM_PROMPT } from "./src/prompt";
import { generateCommitMessage } from "./src/ai";
import { copyToClipboard } from "./src/clipboard";
import { BOLD, GREEN, YELLOW, CYAN, RED, DIM, RESET } from "./src/terminal";
import type { Config } from "./src/types";
import {
getDefaultBranch,
getBranchName,
getBranchPushStatus,
pushCurrentBranch,
getBranchCommits,
getBranchDiff,
detectPlatform,
getRemoteHostname,
checkCLI,
checkAuth,
createPR,
} from "./src/pr";
import type { Platform } from "./src/pr";
import { PR_SYSTEM_PROMPT, buildPRPrompt } from "./src/prompt";
import { generatePRMessage } from "./src/ai";
const args = process.argv.slice(2);
@@ -27,9 +44,12 @@ 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 pr Create a PR with AI-generated title and body
gai pr --draft Create a draft PR
gai config Configure API settings
gai --help Show this help message
gai --version Show version
@@ -37,8 +57,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 +125,219 @@ 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();
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(", ")}`);
}
interface MenuAction {
key: "commit" | "pr" | "config";
label: string;
description: string;
}
const MENU_ACTIONS: MenuAction[] = [
{ key: "commit", label: "commit", description: "Generate AI commit message" },
{ key: "pr", label: "pr", description: "Create a PR with AI-generated title" },
{ key: "config", label: "config", description: "Configure API settings" },
];
async function showMenu(): Promise<void> {
if (!process.stdin.isTTY) {
console.error(` ${RED}Error: Interactive menu requires a TTY.${RESET}`);
process.exit(1);
}
if (args.includes("--version") || args.includes("-v")) {
console.log("gai v0.1.0");
return;
const selected = await selectOne({
title: "gai",
subtitle: "Choose a workflow",
items: MENU_ACTIONS.map((action) => ({
label: action.label,
value: action.key,
description: action.description,
})),
});
if (selected === "commit") await handleCommit(false, false);
if (selected === "pr") await handlePR(false);
if (selected === "config") await handleConfig();
}
async function selectPlatform(hostname: string): Promise<Platform | null> {
if (!process.stdin.isTTY) {
console.error(` ${RED}Error: Platform selection requires a TTY.${RESET}`);
process.exit(1);
}
if (args[0] === "config") {
await handleConfig();
return;
}
const autoMode = args.includes("--auto") || args.includes("-a");
const dryRun = args.includes("--dry-run") || args.includes("-d");
return selectOne({
title: "Select remote platform",
subtitle: `Remote ${hostname} could not be auto-detected`,
items: [
{ label: "GitHub", value: "github" as Platform, description: "Use gh CLI" },
{ label: "Gitea", value: "gitea" as Platform, description: "Use tea CLI" },
],
});
}
async function handleCommit(autoMode: boolean, dryRun: boolean): Promise<void> {
const config = await loadConfig();
if (!config.apiKey) {
@@ -217,8 +425,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 +437,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 +458,232 @@ async function main() {
}
}
async function handlePR(draft: boolean): Promise<void> {
const config = await loadConfig();
if (!config.apiKey) {
console.error(
` ${RED}Error: API key not set. Run ${BOLD}gai config${RESET}${RED} to configure.${RESET}`,
);
process.exit(1);
}
if (!(await isGitRepo())) {
console.error(` ${RED}Error: Not a git repository.${RESET}`);
process.exit(1);
}
let platform = await detectPlatform();
if (!platform) {
const hostname = (await getRemoteHostname()) || "unknown";
const chosen = await selectPlatform(hostname);
if (!chosen) {
console.log(" Aborted.");
process.exit(0);
}
platform = chosen;
}
const platformLabel = platform === "github" ? "GitHub" : "Gitea";
console.log(` Using: ${CYAN}${platformLabel}${RESET}`);
const cliError = checkCLI(platform);
if (cliError) {
console.error(` ${RED}Error: ${cliError}${RESET}`);
process.exit(1);
}
const authError = await checkAuth(platform);
if (authError) {
console.error(` ${RED}Error: ${authError}${RESET}`);
process.exit(1);
}
const baseBranch = await getDefaultBranch();
const branchName = await getBranchName();
if (branchName === baseBranch) {
console.error(
` ${RED}Error: You are on the default branch (${baseBranch}). Switch to a feature branch first.${RESET}`,
);
process.exit(1);
}
console.log(
` Branch: ${CYAN}${branchName}${RESET} → base: ${CYAN}${baseBranch}${RESET}`,
);
const commits = await getBranchCommits(baseBranch);
if (commits.length === 0) {
console.error(
` ${RED}Error: No commits on ${branchName} compared to ${baseBranch}. Commit something first.${RESET}`,
);
process.exit(1);
}
console.log(
` ${commits.length} commit${commits.length > 1 ? "s" : ""} on this branch`,
);
const pushStatus = await getBranchPushStatus();
if (!pushStatus.pushed) {
const target = pushStatus.upstream ?? `origin/${branchName}`;
const answer = await ask(
` Branch is not pushed to ${CYAN}${target}${RESET}. Push now? [${GREEN}Y${RESET}/n] `,
);
if (answer.toLowerCase() === "n") {
console.log(" Aborted.");
return;
}
console.log(` Pushing ${CYAN}${branchName}${RESET}...`);
try {
await pushCurrentBranch(branchName, pushStatus.upstream);
console.log(` ${GREEN}Pushed ${branchName}.${RESET}`);
} catch (err) {
console.error(
` ${RED}Push failed: ${err instanceof Error ? err.message : err}${RESET}`,
);
process.exit(1);
}
}
const diff = await getBranchDiff(baseBranch);
if (!diff) {
console.error(` ${RED}Error: No diff from base branch.${RESET}`);
process.exit(1);
}
const MAX_DIFF_SIZE = 15000;
const truncatedDiff =
diff.length > MAX_DIFF_SIZE
? diff.substring(0, MAX_DIFF_SIZE) + "\n... (truncated)"
: diff;
const repoRoot = await getRepoRoot();
const projectCtx = await collectProjectContext(repoRoot);
const userPrompt = buildPRPrompt({
readme: projectCtx.readme,
packageDescription: projectCtx.packageDescription,
structure: projectCtx.structure,
branchName,
baseBranch,
branchCommits: commits,
diff: truncatedDiff,
});
console.log("\n Generating PR title...");
let title: string;
let body: string;
try {
const result = await generatePRMessage(config, PR_SYSTEM_PROMPT, userPrompt);
title = result.title;
body = result.body;
} catch (err) {
console.error(
` ${RED}AI request failed: ${err instanceof Error ? err.message : err}${RESET}`,
);
process.exit(1);
}
console.log(`\n ${BOLD}Generated PR:${RESET}`);
console.log(` Title: ${GREEN}${title}${RESET}`);
if (body) {
console.log(
` Body: ${DIM}${body.replace(/\n/g, "\n ")}${RESET}`,
);
}
console.log("");
const answer = await ask(` Create this PR? [${GREEN}Y${RESET}/n/e] `);
const lower = answer.toLowerCase();
if (lower === "n") {
console.log(" Aborted.");
return;
}
if (lower === "e") {
const newTitle = await ask(" Title: ");
const newBody = await ask(" Body (optional): ");
if (!newTitle.trim()) {
console.log(" Aborted.");
return;
}
title = newTitle;
body = newBody;
}
console.log(`\n Creating PR...`);
try {
const url = await createPR(platform, title, body, baseBranch, draft);
console.log(` ${GREEN}${BOLD}✔ PR created!${RESET}`);
console.log(` ${CYAN}${url}${RESET}`);
} catch (err) {
console.error(
` ${RED}PR creation failed: ${err instanceof Error ? err.message : err}${RESET}`,
);
process.exit(1);
}
}
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 === "pr") {
const draft = args.includes("--draft");
await handlePR(draft);
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",
+124 -26
View File
@@ -8,12 +8,38 @@ interface ChatMessage {
interface ChatCompletionResponse {
choices?: Array<{
message?: {
content?: string;
content?: string | null;
};
finish_reason?: string;
}>;
error?: {
message?: string;
type?: string;
code?: string;
};
}
export async function generateCommitMessage(
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 callAI(
config: Config,
systemPrompt: string,
userPrompt: string,
@@ -25,31 +51,103 @@ export async function generateCommitMessage(
{ role: "user", content: userPrompt },
];
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${config.apiKey}`,
},
body: JSON.stringify({
model: config.model,
max_tokens: config.maxTokens,
temperature: config.temperature,
messages,
}),
});
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
try {
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${config.apiKey}`,
},
body: JSON.stringify({
model: config.model,
max_tokens: config.maxTokens,
temperature: config.temperature,
messages,
}),
});
if (!response.ok) {
const text = await response.text();
throw new Error(`API request failed (${response.status}): ${text}`);
if (!response.ok) {
const text = await response.text();
if (response.status === 429 && attempt < MAX_RETRIES) {
await sleep(RETRY_DELAY * attempt);
continue;
}
throw new Error(`API request failed (${response.status}): ${text}`);
}
const data = (await response.json()) as ChatCompletionResponse;
if (data.error) {
throw new Error(
`API error: ${data.error.message ?? JSON.stringify(data.error)}`,
);
}
const raw = data.choices?.[0]?.message?.content;
const finishReason = data.choices?.[0]?.finish_reason;
if (raw && raw.trim()) {
return 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 < MAX_RETRIES) {
await sleep(RETRY_DELAY * attempt);
continue;
}
throw new Error(
`Empty response from AI after ${MAX_RETRIES} attempts. finish_reason: ${finishReason ?? "unknown"}`,
);
} catch (err) {
if (attempt >= MAX_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);
}
}
const data = (await response.json()) as ChatCompletionResponse;
const message = data.choices?.[0]?.message?.content?.trim();
if (!message) {
throw new Error("Empty response from AI");
}
return message;
throw new Error("Failed to generate response");
}
export async function generateCommitMessage(
config: Config,
systemPrompt: string,
userPrompt: string,
): Promise<string> {
const raw = await callAI(config, systemPrompt, userPrompt);
return cleanMessage(raw);
}
export async function generatePRMessage(
config: Config,
systemPrompt: string,
userPrompt: string,
): Promise<{ title: string; body: string }> {
const raw = await callAI(config, systemPrompt, userPrompt);
const cleaned = cleanMessage(raw);
const lines = cleaned.split("\n");
const title = lines[0]?.trim() || "Update";
let bodyStart = 1;
while (bodyStart < lines.length && lines[bodyStart]?.trim() === "") {
bodyStart++;
}
const body = lines.slice(bodyStart).join("\n").trim();
return { title, body };
}
+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"),
};
}
+307
View File
@@ -0,0 +1,307 @@
import { BOLD, GREEN, CYAN, DIM, RESET } from "./terminal";
const UP = "\x1b[A";
const DOWN = "\x1b[B";
const ALT_UP = "\x1bOA";
const ALT_DOWN = "\x1bOB";
const SPACE = " ";
const ENTER = "\r";
const CTRL_C = "\x03";
export interface Choice<T> {
label: string;
value: T;
description?: string;
selected?: boolean;
}
interface BasePromptOptions {
title: string;
subtitle?: string;
cancelMessage?: string;
}
interface SinglePromptOptions<T> extends BasePromptOptions {
items: Choice<T>[];
}
interface MultiPromptOptions<T> extends BasePromptOptions {
items: Choice<T>[];
selectAllLabel?: string;
doneLabel?: string;
}
function hideCursor() {
process.stdout.write("\x1b[?25l");
}
function showCursor() {
process.stdout.write("\x1b[?25h");
}
function moveUp(lines: number) {
if (lines > 0) process.stdout.write(`\x1b[${lines}A`);
}
function clearLine() {
process.stdout.write("\r\x1b[2K");
}
function visibleLength(value: string) {
return value.replace(/\x1b\[[0-9;]*m/g, "").length;
}
function padLabel(label: string, width: number) {
return label + " ".repeat(Math.max(1, width - visibleLength(label)));
}
function controls(mode: "single" | "multi") {
if (mode === "single") {
return `${DIM}↑/↓ navigate · enter/space select · ctrl+c cancel${RESET}`;
}
return `${DIM}↑/↓ navigate · space toggle · enter confirm · ctrl+c cancel${RESET}`;
}
function renderPrompt(lines: string[], previousLines: number) {
if (previousLines > 0) {
for (let i = 0; i < previousLines; i++) {
clearLine();
process.stdout.write("\n");
}
moveUp(previousLines);
}
for (const line of lines) {
process.stdout.write(`${line}\n`);
}
moveUp(lines.length);
return lines.length;
}
function clearPrompt(lines: number) {
for (let i = 0; i < lines; i++) {
clearLine();
process.stdout.write("\n");
}
moveUp(lines);
}
function normalizeKey(key: string, escapeBuf: string) {
if (key === UP || key === ALT_UP) return { action: "up", escapeBuf: "" };
if (key === DOWN || key === ALT_DOWN) return { action: "down", escapeBuf: "" };
if (key === SPACE) return { action: "space", escapeBuf: "" };
if (key === ENTER) return { action: "enter", escapeBuf: "" };
if (key === CTRL_C) return { action: "cancel", escapeBuf: "" };
if (key === "\x1b" || key.startsWith("\x1b[")) {
return { action: null, escapeBuf: key };
}
if (escapeBuf) {
const next = escapeBuf + key;
if (next === UP || next === ALT_UP) return { action: "up", escapeBuf: "" };
if (next === DOWN || next === ALT_DOWN) return { action: "down", escapeBuf: "" };
return {
action: null,
escapeBuf: /^[A-Za-z~]$/.test(key) || next.length > 8 ? "" : next,
};
}
return { action: null, escapeBuf: "" };
}
function createLines<T>(
options: BasePromptOptions & { items: Choice<T>[] },
mode: "single" | "multi",
cursor: number,
) {
const labelWidth = Math.max(
...options.items.map((item) => visibleLength(item.label)),
0,
) + 2;
const lines = [
"",
` ${BOLD}${options.title}${RESET}`,
];
if (options.subtitle) lines.push(` ${DIM}${options.subtitle}${RESET}`);
lines.push(` ${controls(mode)}`, "");
for (let i = 0; i < options.items.length; i++) {
const item = options.items[i]!;
const active = i === cursor;
const pointer = active ? `${CYAN}${RESET}` : " ";
const marker = mode === "single"
? active ? `${GREEN}${RESET}` : `${DIM}${RESET}`
: item.selected ? `${GREEN}${RESET}` : `${DIM}${RESET}`;
const label = active ? `${BOLD}${item.label}${RESET}` : item.label;
const description = item.description
? active ? item.description : `${DIM}${item.description}${RESET}`
: "";
lines.push(
` ${pointer} ${marker} ${padLabel(label, labelWidth)}${description}`,
);
}
return lines;
}
function ensureTTY(title: string) {
if (process.stdin.isTTY !== true) {
throw new Error(`${title} requires a TTY.`);
}
}
export async function selectOne<T>(
options: SinglePromptOptions<T>,
): Promise<T | null> {
ensureTTY(options.title);
let cursor = 0;
let renderedLines = 0;
let escapeBuf = "";
const wasRaw = process.stdin.isRaw;
if (wasRaw !== true) process.stdin.setRawMode(true);
process.stdin.resume();
hideCursor();
const render = () => {
renderedLines = renderPrompt(
createLines(options, "single", cursor),
renderedLines,
);
};
render();
return new Promise((resolve) => {
const finish = (value: T | null) => {
process.stdin.setRawMode(wasRaw === true);
process.stdin.pause();
process.stdin.removeListener("data", onData);
clearPrompt(renderedLines);
showCursor();
if (value === null && options.cancelMessage) {
process.stdout.write(` ${options.cancelMessage}\n`);
}
resolve(value);
};
const onData = (data: Buffer) => {
const result = normalizeKey(data.toString(), escapeBuf);
escapeBuf = result.escapeBuf;
if (result.action === "cancel") return finish(null);
if (result.action === "up" && cursor > 0) {
cursor--;
render();
} else if (result.action === "down" && cursor < options.items.length - 1) {
cursor++;
render();
} else if (result.action === "space" || result.action === "enter") {
finish(options.items[cursor]!.value);
}
};
process.stdin.on("data", onData);
});
}
export async function selectMany<T>(
options: MultiPromptOptions<T>,
): Promise<T[] | null> {
ensureTTY(options.title);
const items: Choice<T | null>[] = options.selectAllLabel
? [
{ label: options.selectAllLabel, value: null, selected: false },
...options.items.map((item) => ({ ...item })),
]
: options.items.map((item) => ({ ...item }));
let cursor = 0;
let renderedLines = 0;
let escapeBuf = "";
const wasRaw = process.stdin.isRaw;
if (wasRaw !== true) process.stdin.setRawMode(true);
process.stdin.resume();
hideCursor();
const syncSelectAll = () => {
if (!options.selectAllLabel) return;
items[0]!.selected = items.slice(1).every((item) => item.selected);
};
const toggle = (index: number) => {
const item = items[index]!;
item.selected = !item.selected;
if (options.selectAllLabel && index === 0) {
for (let i = 1; i < items.length; i++) {
items[i]!.selected = item.selected;
}
} else {
syncSelectAll();
}
};
const render = () => {
renderedLines = renderPrompt(
createLines(
{
title: options.title,
subtitle: options.subtitle,
items,
},
"multi",
cursor,
),
renderedLines,
);
};
render();
return new Promise((resolve) => {
const finish = (value: T[] | null) => {
process.stdin.setRawMode(wasRaw === true);
process.stdin.pause();
process.stdin.removeListener("data", onData);
clearPrompt(renderedLines);
showCursor();
if (value === null && options.cancelMessage) {
process.stdout.write(` ${options.cancelMessage}\n`);
}
resolve(value);
};
const onData = (data: Buffer) => {
const result = normalizeKey(data.toString(), escapeBuf);
escapeBuf = result.escapeBuf;
if (result.action === "cancel") return finish(null);
if (result.action === "up" && cursor > 0) {
cursor--;
render();
} else if (result.action === "down" && cursor < items.length - 1) {
cursor++;
render();
} else if (result.action === "space") {
toggle(cursor);
render();
} else if (result.action === "enter") {
finish(
items
.filter((item) => item.selected && item.value !== null)
.map((item) => item.value as T),
);
}
};
process.stdin.on("data", onData);
});
}
+216
View File
@@ -0,0 +1,216 @@
export type Platform = "github" | "gitea";
export async function getDefaultBranch(): Promise<string> {
try {
const result =
await Bun.$`git symbolic-ref refs/remotes/origin/HEAD`.quiet().text();
return result.trim().replace("refs/remotes/origin/", "");
} catch {
try {
const branches = await Bun.$`git branch -r`.quiet().text();
for (const line of branches.split("\n")) {
const trimmed = line.trim();
if (trimmed === "origin/main" || trimmed === "origin/master") {
return trimmed.replace("origin/", "");
}
}
} catch {}
return "main";
}
}
export async function getBranchName(): Promise<string> {
const result =
await Bun.$`git rev-parse --abbrev-ref HEAD`.quiet().text();
return result.trim();
}
export async function getBranchPushStatus(): Promise<{
pushed: boolean;
upstream: string | null;
}> {
try {
const upstream = (await Bun.$`git rev-parse --abbrev-ref --symbolic-full-name @{u}`.quiet().text()).trim();
const localHead = (await Bun.$`git rev-parse HEAD`.quiet().text()).trim();
const remoteHead = (await Bun.$`git rev-parse @{u}`.quiet().text()).trim();
return { pushed: localHead === remoteHead, upstream };
} catch {
return { pushed: false, upstream: null };
}
}
export async function pushCurrentBranch(branch: string, upstream: string | null): Promise<void> {
const args = upstream
? ["push"]
: ["push", "-u", "origin", branch];
const proc = Bun.spawn(["git", ...args], {
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(stderr.trim() || stdout.trim() || `git push failed (exit code ${exitCode})`);
}
}
export async function getBranchCommits(base: string): Promise<string[]> {
try {
const result =
await Bun.$`git log --oneline origin/${base}..HEAD`.quiet().text();
return result.trim().split("\n").filter(Boolean);
} catch {
try {
const result =
await Bun.$`git log --oneline ${base}..HEAD`.quiet().text();
return result.trim().split("\n").filter(Boolean);
} catch {
return [];
}
}
}
export async function getBranchDiff(base: string): Promise<string> {
try {
const result =
await Bun.$`git diff ${base}...HEAD`.quiet().text();
return result.trim();
} catch {
return "";
}
}
function parseRemoteHostname(url: string): string | null {
const hostname = url
.trim()
.toLowerCase()
.replace(/^(https?:\/\/|ssh:\/\/|git:\/\/)/, "")
.replace(/^[^@]+@/, "")
.split(/[:/]/)[0];
return hostname || null;
}
export async function detectPlatform(): Promise<Platform | null> {
try {
const url = await Bun.$`git remote get-url origin`.quiet().text();
const hostname = parseRemoteHostname(url);
if (!hostname) return null;
if (hostname === "github.com") return "github";
if (hostname.includes("gitea")) return "gitea";
return null;
} catch {
return null;
}
}
export async function getRemoteHostname(): Promise<string | null> {
try {
const url = await Bun.$`git remote get-url origin`.quiet().text();
return parseRemoteHostname(url);
} catch {
return null;
}
}
export function checkCLI(platform: Platform): string | null {
const bin = platform === "github" ? "gh" : "tea";
const path = Bun.which(bin);
if (!path) {
if (platform === "github") {
return "GitHub CLI (gh) not found. Install: brew install gh";
}
return "Gitea CLI (tea) not found. Install from: https://gitea.com/gitea/tea";
}
return null;
}
export async function checkAuth(platform: Platform): Promise<string | null> {
if (platform === "github") {
try {
await Bun.$`gh auth status`.quiet();
return null;
} catch {
return "Not authenticated with GitHub CLI. Run: gh auth login";
}
}
try {
const result = await Bun.$`tea logins list`.quiet().text();
if (result.trim()) return null;
return "Not authenticated with Gitea CLI. Run: tea login add";
} catch {
return "Not authenticated with Gitea CLI. Run: tea login add";
}
}
export async function createPR(
platform: Platform,
title: string,
body: string,
base: string,
draft: boolean,
): Promise<string> {
if (platform === "github") {
const args = [
"pr",
"create",
"--title",
title,
"--body",
body,
"--base",
base,
];
if (draft) args.push("--draft");
const proc = Bun.spawn(["gh", ...args], {
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(
stderr.trim() || `gh pr create failed (exit code ${exitCode})`,
);
}
const match = stdout.match(/(https?:\/\/[^\s]+)/);
return match?.[1] ?? stdout.trim();
}
const args = [
"pulls",
"create",
"--title",
title,
"--description",
body,
"--base",
base,
];
const proc = Bun.spawn(["tea", ...args], {
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(
stderr.trim() || `tea pulls create failed (exit code ${exitCode})`,
);
}
const match = stdout.match(/(https?:\/\/[^\s]+)/);
return match?.[1] ?? stdout.trim();
}
+63 -1
View File
@@ -1,4 +1,4 @@
import type { ProjectContext } from "./types";
import type { PRContext, ProjectContext } from "./types";
export const SYSTEM_PROMPT = `You are an expert at writing concise, meaningful git commit messages following the Conventional Commits specification.
@@ -54,3 +54,65 @@ export function buildPrompt(context: ProjectContext): string {
return parts.join("\n");
}
export const PR_SYSTEM_PROMPT = `You are an expert at writing clear, concise pull request titles and descriptions.
Format:
<pr title>
<blank line>
<pr body>
Rules:
1. Title must be under 72 characters, in imperative mood
2. Follow the Conventional Commits style for the title (e.g., "feat(api): add user authentication")
3. Body should be 2-3 sentences in plain text explaining WHAT was changed and WHY
4. Be specific — avoid vague messages
5. Match the language and style of recent commits if provided
6. If the branch name hints at the type (e.g., "feat/..." or "fix/..."), reflect that in the title
7. Output ONLY the PR text — no markdown, no code blocks, no prefixes`;
export function buildPRPrompt(context: PRContext): string {
const parts: string[] = [];
if (
context.packageDescription ||
context.readme ||
context.structure
) {
parts.push("## Project Context");
if (context.packageDescription) {
parts.push(`Description: ${context.packageDescription}`);
}
if (context.structure) {
parts.push(`Structure: ${context.structure}`);
}
if (context.readme) {
parts.push(`README:\n${context.readme}`);
}
parts.push("");
}
parts.push("## Branch Info");
parts.push(`Branch: ${context.branchName}`);
parts.push(`Target base: ${context.baseBranch}`);
parts.push("");
if (context.branchCommits.length > 0) {
parts.push("## Commits on This Branch");
for (const c of context.branchCommits) {
parts.push(c);
}
parts.push("");
}
parts.push("## Changes (diff from base)");
parts.push("```diff");
parts.push(context.diff);
parts.push("```");
parts.push("");
parts.push(
"Generate a pull request title and brief body for the above changes.",
);
return parts.join("\n");
}
+24 -45
View File
@@ -1,6 +1,6 @@
import * as readline from "node:readline";
import type { FileEntry } from "./types";
import { BOLD, GREEN, YELLOW, CYAN, RESET } from "./terminal";
import { BOLD, GREEN, YELLOW, RESET } from "./terminal";
import { selectMany } from "./menu";
export async function selectFiles(
stagedFiles: FileEntry[],
@@ -9,54 +9,33 @@ 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})`,
if (process.stdin.isTTY !== true) return [];
const selected = await selectMany({
title: "Select files to stage",
subtitle: `${unstagedFiles.length} unstaged file${unstagedFiles.length > 1 ? "s" : ""} available`,
selectAllLabel: "Select all",
cancelMessage: "Aborted.",
items: unstagedFiles.map((f) => ({
label: f.path,
value: f.path,
description: f.label,
})),
});
if (selected === null) process.exit(1);
if (selected.length > 0) {
process.stdout.write(
` ${GREEN}Staged ${selected.length} file(s):${RESET} ${selected.join(", ")}\n`,
);
}
console.log("");
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
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 === "") {
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 uniqueIndices = [...new Set(indices)];
resolve(uniqueIndices.map((i) => unstagedFiles[i]!.path));
},
);
});
return selected;
}
+10
View File
@@ -19,3 +19,13 @@ export interface ProjectContext {
recentCommits: string[];
diff: string;
}
export interface PRContext {
readme: string | null;
packageDescription: string | null;
structure: string | null;
branchName: string;
baseBranch: string;
branchCommits: string[];
diff: string;
}
+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);
});
});