Compare commits
2 Commits
d01d6a38b5
...
662609a78e
| Author | SHA1 | Date | |
|---|---|---|---|
| 662609a78e | |||
| df10ce5b0e |
@@ -121,23 +121,26 @@ git diff | gai suggest branch
|
|||||||
Run `gai` without arguments to open the mole-style interactive menu:
|
Run `gai` without arguments to open the mole-style interactive menu:
|
||||||
|
|
||||||
```
|
```
|
||||||
██████╗ █████╗ ██╗
|
gai v0.1.3
|
||||||
██╔════╝ ██╔══██╗██║
|
AI-powered git helper for commits, PRs, reviews, and changelogs
|
||||||
██║ ██╗ ███████║██║
|
────────────────────────────────────────────────────────────────────────
|
||||||
██║ ██║ ██╔══██║██║
|
|
||||||
╚██████╝ ██║ ██║██║ AI-powered git helper
|
|
||||||
╚═════╝ ╚═╝ ╚═╝╚═╝ v0.1.3
|
|
||||||
|
|
||||||
➤ 1. Commit Generate AI commit message
|
CREATE
|
||||||
2. PR Create a PR with AI-generated title
|
│ › 1 Commit Generate AI commit message
|
||||||
3. Explain Explain staged changes in plain language
|
2 PR Create a PR with AI-generated title
|
||||||
4. Review AI code review of staged changes
|
3 Amend Amend last commit with AI message
|
||||||
5. Changelog Generate changelog from commits
|
|
||||||
6. Suggest Suggest branch name or commit type
|
|
||||||
7. Amend Amend last commit with AI message
|
|
||||||
8. Config Configure API settings
|
|
||||||
|
|
||||||
↑↓ | Enter | H Help | V Version | Q Quit
|
INSPECT
|
||||||
|
4 Explain Explain staged changes in plain language
|
||||||
|
5 Review AI code review of staged changes
|
||||||
|
6 Changelog Generate changelog from commits
|
||||||
|
7 Suggest Suggest branch name or commit type
|
||||||
|
|
||||||
|
PROJECT
|
||||||
|
8 Config Configure API settings
|
||||||
|
|
||||||
|
────────────────────────────────────────────────────────────────────────
|
||||||
|
↑/↓ navigate enter run 1-8 jump h help v version q quit
|
||||||
```
|
```
|
||||||
|
|
||||||
Number keys `1`–`8` jump directly to the corresponding action. After a command finishes, press Enter to return to the menu.
|
Number keys `1`–`8` jump directly to the corresponding action. After a command finishes, press Enter to return to the menu.
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { handleSuggest } from "./src/commands/suggest";
|
|||||||
import { setColorEnabled } from "./src/terminal";
|
import { setColorEnabled } from "./src/terminal";
|
||||||
import { BOLD, GREEN, CYAN, DIM, RESET } from "./src/terminal";
|
import { BOLD, GREEN, CYAN, DIM, RESET } from "./src/terminal";
|
||||||
import { isStdinTTY, initTTY } from "./src/tty";
|
import { isStdinTTY, initTTY } from "./src/tty";
|
||||||
import { showBanner } from "./src/brand";
|
import { VERSION } from "./src/brand";
|
||||||
import { SKIP_WAIT } from "./src/menu";
|
import { SKIP_WAIT } from "./src/menu";
|
||||||
|
|
||||||
// ── Interactive Menu (mole-style) ─────────────────────────────────────
|
// ── Interactive Menu (mole-style) ─────────────────────────────────────
|
||||||
@@ -23,17 +23,18 @@ interface MenuItem {
|
|||||||
key: string;
|
key: string;
|
||||||
label: string;
|
label: string;
|
||||||
description: string;
|
description: string;
|
||||||
|
group: "Create" | "Inspect" | "Project";
|
||||||
}
|
}
|
||||||
|
|
||||||
const MENU_ITEMS: MenuItem[] = [
|
const MENU_ITEMS: MenuItem[] = [
|
||||||
{ key: "commit", label: "Commit", description: "Generate AI commit message" },
|
{ key: "commit", label: "Commit", description: "Generate AI commit message", group: "Create" },
|
||||||
{ key: "pr", label: "PR", description: "Create a PR with AI-generated title" },
|
{ key: "pr", label: "PR", description: "Create a PR with AI-generated title", group: "Create" },
|
||||||
{ key: "explain", label: "Explain", description: "Explain staged changes in plain language" },
|
{ key: "amend", label: "Amend", description: "Amend last commit with AI message", group: "Create" },
|
||||||
{ key: "review", label: "Review", description: "AI code review of staged changes" },
|
{ key: "explain", label: "Explain", description: "Explain staged changes in plain language", group: "Inspect" },
|
||||||
{ key: "changelog", label: "Changelog", description: "Generate changelog from commits" },
|
{ key: "review", label: "Review", description: "AI code review of staged changes", group: "Inspect" },
|
||||||
{ key: "suggest", label: "Suggest", description: "Suggest branch name or commit type" },
|
{ key: "changelog", label: "Changelog", description: "Generate changelog from commits", group: "Inspect" },
|
||||||
{ key: "amend", label: "Amend", description: "Amend last commit with AI message" },
|
{ key: "suggest", label: "Suggest", description: "Suggest branch name or commit type", group: "Inspect" },
|
||||||
{ key: "config", label: "Config", description: "Configure API settings" },
|
{ key: "config", label: "Config", description: "Configure API settings", group: "Project" },
|
||||||
];
|
];
|
||||||
|
|
||||||
function hideCursor() { process.stdout.write("\x1b[?25l"); }
|
function hideCursor() { process.stdout.write("\x1b[?25l"); }
|
||||||
@@ -55,51 +56,70 @@ function visibleLen(s: string): number {
|
|||||||
return s.replace(/\x1b\[[0-9;]*m/g, "").length;
|
return s.replace(/\x1b\[[0-9;]*m/g, "").length;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderMenu(banner: string, cursor: number): number {
|
function padRight(value: string, width: number): string {
|
||||||
|
return value + " ".repeat(Math.max(0, width - visibleLen(value)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMenu(cursor: number): number {
|
||||||
process.stdout.write("\x1b[H"); // cursor home
|
process.stdout.write("\x1b[H"); // cursor home
|
||||||
|
|
||||||
let lineCount = 0;
|
let lineCount = 0;
|
||||||
for (const line of banner.split("\n")) {
|
|
||||||
clearLine();
|
|
||||||
process.stdout.write(line + "\n");
|
|
||||||
lineCount++;
|
|
||||||
}
|
|
||||||
|
|
||||||
const G = GREEN();
|
|
||||||
const C = CYAN();
|
const C = CYAN();
|
||||||
const D = DIM();
|
const D = DIM();
|
||||||
|
const G = GREEN();
|
||||||
const R = RESET();
|
const R = RESET();
|
||||||
const ARROW = "➤";
|
const width = 72;
|
||||||
|
const separator = `${D}${"─".repeat(width)}${R}`;
|
||||||
|
|
||||||
// Calculate padding
|
const write = (line = "") => {
|
||||||
|
clearLine();
|
||||||
|
process.stdout.write(`${line}\n`);
|
||||||
|
lineCount++;
|
||||||
|
};
|
||||||
|
|
||||||
|
write("");
|
||||||
|
write(` ${BOLD()}gai${R} ${D}v${VERSION}${R}`);
|
||||||
|
write(` ${D}AI-powered git helper for commits, PRs, reviews, and changelogs${R}`);
|
||||||
|
write(` ${separator}`);
|
||||||
|
write("");
|
||||||
|
|
||||||
|
const keyWidth = 3;
|
||||||
const labelWidth = Math.max(...MENU_ITEMS.map((m) => visibleLen(m.label))) + 2;
|
const labelWidth = Math.max(...MENU_ITEMS.map((m) => visibleLen(m.label))) + 2;
|
||||||
|
let currentGroup: MenuItem["group"] | null = null;
|
||||||
|
|
||||||
for (let i = 0; i < MENU_ITEMS.length; i++) {
|
for (let i = 0; i < MENU_ITEMS.length; i++) {
|
||||||
const item = MENU_ITEMS[i]!;
|
const item = MENU_ITEMS[i]!;
|
||||||
const num = String(i + 1);
|
const num = String(i + 1);
|
||||||
const active = i === cursor;
|
const active = i === cursor;
|
||||||
|
|
||||||
clearLine();
|
if (item.group !== currentGroup) {
|
||||||
if (active) {
|
if (currentGroup !== null) write("");
|
||||||
const padding = " ".repeat(Math.max(1, labelWidth - visibleLen(item.label)));
|
write(` ${D}${item.group.toUpperCase()}${R}`);
|
||||||
process.stdout.write(` ${C}${ARROW} ${num}. ${item.label}${R}${padding}${D}${item.description}${R}\n`);
|
currentGroup = item.group;
|
||||||
} else {
|
}
|
||||||
const padding = " ".repeat(labelWidth - visibleLen(item.label) + 3);
|
|
||||||
process.stdout.write(` ${num}. ${item.label}${padding}${D}${item.description}${R}\n`);
|
const pointer = active ? `${C}›${R}` : " ";
|
||||||
|
const key = active ? `${G}${num}${R}` : `${D}${num}${R}`;
|
||||||
|
const label = active ? `${BOLD()}${item.label}${R}` : item.label;
|
||||||
|
const description = active ? item.description : `${D}${item.description}${R}`;
|
||||||
|
const row = [
|
||||||
|
pointer,
|
||||||
|
padRight(key, keyWidth),
|
||||||
|
padRight(label, labelWidth),
|
||||||
|
description,
|
||||||
|
].join(" ");
|
||||||
|
|
||||||
|
if (active) {
|
||||||
|
write(` ${C}│${R} ${row}`);
|
||||||
|
} else {
|
||||||
|
write(` ${row}`);
|
||||||
}
|
}
|
||||||
lineCount++;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Footer
|
write("");
|
||||||
clearLine();
|
write(` ${separator}`);
|
||||||
process.stdout.write("\n");
|
write(` ${D}↑/↓ navigate enter run ${G}1-8${D} jump ${G}h${D} help ${G}v${D} version ${G}q${D} quit${R}`);
|
||||||
lineCount++;
|
write("");
|
||||||
clearLine();
|
|
||||||
process.stdout.write(` ${D}↑↓ | Enter | ${G}H${D} Help | ${G}V${D} Version | ${G}Q${D} Quit${R}\n`);
|
|
||||||
lineCount++;
|
|
||||||
clearLine();
|
|
||||||
process.stdout.write("\n");
|
|
||||||
lineCount++;
|
|
||||||
|
|
||||||
// Clear rest of screen
|
// Clear rest of screen
|
||||||
process.stdout.write("\x1b[J");
|
process.stdout.write("\x1b[J");
|
||||||
@@ -167,7 +187,6 @@ async function showMenu(): Promise<number> {
|
|||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
const banner = showBanner();
|
|
||||||
let cursor = 0;
|
let cursor = 0;
|
||||||
const wasRaw = process.stdin.isRaw;
|
const wasRaw = process.stdin.isRaw;
|
||||||
|
|
||||||
@@ -176,7 +195,7 @@ async function showMenu(): Promise<number> {
|
|||||||
hideCursor();
|
hideCursor();
|
||||||
|
|
||||||
// Initial render
|
// Initial render
|
||||||
renderMenu(banner, cursor);
|
renderMenu(cursor);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
while (true) {
|
while (true) {
|
||||||
@@ -184,11 +203,11 @@ async function showMenu(): Promise<number> {
|
|||||||
|
|
||||||
// Escape sequences (arrows)
|
// Escape sequences (arrows)
|
||||||
if (raw === "\x1b[A" || raw === "\x1bOA") {
|
if (raw === "\x1b[A" || raw === "\x1bOA") {
|
||||||
if (cursor > 0) { cursor--; renderMenu(banner, cursor); }
|
if (cursor > 0) { cursor--; renderMenu(cursor); }
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (raw === "\x1b[B" || raw === "\x1bOB") {
|
if (raw === "\x1b[B" || raw === "\x1bOB") {
|
||||||
if (cursor < MENU_ITEMS.length - 1) { cursor++; renderMenu(banner, cursor); }
|
if (cursor < MENU_ITEMS.length - 1) { cursor++; renderMenu(cursor); }
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,7 +218,7 @@ async function showMenu(): Promise<number> {
|
|||||||
hideCursor();
|
hideCursor();
|
||||||
if (wasRaw !== true) process.stdin.setRawMode(true);
|
if (wasRaw !== true) process.stdin.setRawMode(true);
|
||||||
process.stdin.resume();
|
process.stdin.resume();
|
||||||
renderMenu(banner, cursor);
|
renderMenu(cursor);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -217,13 +236,13 @@ async function showMenu(): Promise<number> {
|
|||||||
const idx = parseInt(raw) - 1;
|
const idx = parseInt(raw) - 1;
|
||||||
if (idx < MENU_ITEMS.length) {
|
if (idx < MENU_ITEMS.length) {
|
||||||
cursor = idx;
|
cursor = idx;
|
||||||
renderMenu(banner, cursor);
|
renderMenu(cursor);
|
||||||
const result = await dispatchAndWait(MENU_ITEMS[idx]!, wasRaw);
|
const result = await dispatchAndWait(MENU_ITEMS[idx]!, wasRaw);
|
||||||
if (result !== 0) return result;
|
if (result !== 0) return result;
|
||||||
hideCursor();
|
hideCursor();
|
||||||
if (wasRaw !== true) process.stdin.setRawMode(true);
|
if (wasRaw !== true) process.stdin.setRawMode(true);
|
||||||
process.stdin.resume();
|
process.stdin.resume();
|
||||||
renderMenu(banner, cursor);
|
renderMenu(cursor);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -243,7 +262,7 @@ async function showMenu(): Promise<number> {
|
|||||||
process.stdin.setRawMode(wasRaw === true);
|
process.stdin.setRawMode(wasRaw === true);
|
||||||
process.stdin.pause();
|
process.stdin.pause();
|
||||||
process.stdout.write("\x1b[2J\x1b[H");
|
process.stdout.write("\x1b[2J\x1b[H");
|
||||||
console.log("gai v0.1.3");
|
console.log(`gai v${VERSION}`);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
if (lower === "q") {
|
if (lower === "q") {
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { GREEN, CYAN, RESET } from "./terminal";
|
import { GREEN, CYAN, RESET } from "./terminal";
|
||||||
|
|
||||||
const VERSION = "0.1.3";
|
export const VERSION = "0.1.3";
|
||||||
|
|
||||||
export function showBanner(): string {
|
export function showBanner(): string {
|
||||||
const G = GREEN();
|
const G = GREEN();
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ export async function handleChangelog(args: ParsedArgs): Promise<number> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const callbacks: StreamCallbacks = tty ? {
|
const callbacks: StreamCallbacks | undefined = tty ? {
|
||||||
onToken: (token) => process.stdout.write(token),
|
onToken: (token) => process.stdout.write(token),
|
||||||
} : undefined;
|
} : undefined;
|
||||||
|
|
||||||
|
|||||||
+35
-20
@@ -7,6 +7,7 @@ import {
|
|||||||
getStagedDiff,
|
getStagedDiff,
|
||||||
getRecentCommits,
|
getRecentCommits,
|
||||||
stageFiles,
|
stageFiles,
|
||||||
|
applyFileSelection,
|
||||||
commit,
|
commit,
|
||||||
} from "../git";
|
} from "../git";
|
||||||
import { selectFiles } from "../selector";
|
import { selectFiles } from "../selector";
|
||||||
@@ -50,6 +51,19 @@ function printCommitResult(result: CommitResult, msg: string) {
|
|||||||
if (parts.length > 0) console.log(` ${parts.join(", ")}`);
|
if (parts.length > 0) console.log(` ${parts.join(", ")}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function printSelectionResult(result: { staged: string[]; unstaged: string[] }) {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (result.staged.length > 0) {
|
||||||
|
parts.push(`${GREEN()}staged ${result.staged.length}${RESET()}`);
|
||||||
|
}
|
||||||
|
if (result.unstaged.length > 0) {
|
||||||
|
parts.push(`${YELLOW()}unstaged ${result.unstaged.length}${RESET()}`);
|
||||||
|
}
|
||||||
|
if (parts.length > 0) {
|
||||||
|
console.log(` Updated staging area: ${parts.join(", ")}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function confirmCommit(message: string): Promise<"y" | "n" | "e"> {
|
async function confirmCommit(message: string): Promise<"y" | "n" | "e"> {
|
||||||
console.log(`\n ${BOLD()}Generated commit message:${RESET()}`);
|
console.log(`\n ${BOLD()}Generated commit message:${RESET()}`);
|
||||||
console.log(` ${GREEN()}${message}${RESET()}\n`);
|
console.log(` ${GREEN()}${message}${RESET()}\n`);
|
||||||
@@ -173,16 +187,22 @@ export async function handleCommit(args: ParsedArgs): Promise<number> {
|
|||||||
const stagedFiles = await getStagedFiles();
|
const stagedFiles = await getStagedFiles();
|
||||||
const unstagedFiles = await getUnstagedFiles();
|
const unstagedFiles = await getUnstagedFiles();
|
||||||
|
|
||||||
if (stagedFiles.length === 0 && !amend) {
|
if (!amend && stagedFiles.length === 0 && unstagedFiles.length === 0) {
|
||||||
if (autoMode && unstagedFiles.length > 0) {
|
console.error(`\n ${RED()}Error: Nothing to commit.${RESET()}\n`);
|
||||||
await stageFiles(unstagedFiles.map((f) => f.path));
|
return 1;
|
||||||
console.log(` ${GREEN()}Auto-staged ${unstagedFiles.length} file(s).${RESET()}`);
|
}
|
||||||
} else if (unstagedFiles.length > 0) {
|
|
||||||
|
if (!amend && autoMode && unstagedFiles.length > 0) {
|
||||||
|
await stageFiles(unstagedFiles.map((f) => f.path));
|
||||||
|
console.log(` ${GREEN()}Auto-staged ${unstagedFiles.length} file(s).${RESET()}`);
|
||||||
|
} else if (!amend) {
|
||||||
|
if (isStdinTTY()) {
|
||||||
|
const selected = await selectFiles(stagedFiles, unstagedFiles);
|
||||||
|
if (selected === BACK) return SKIP_WAIT as unknown as number;
|
||||||
|
printSelectionResult(await applyFileSelection(stagedFiles, unstagedFiles, selected));
|
||||||
|
} else if (stagedFiles.length === 0 && unstagedFiles.length > 0) {
|
||||||
console.error(`\n ${RED()}Error: No staged changes. Use -a to auto-stage, or stage files manually.${RESET()}\n`);
|
console.error(`\n ${RED()}Error: No staged changes. Use -a to auto-stage, or stage files manually.${RESET()}\n`);
|
||||||
return 1;
|
return 1;
|
||||||
} else {
|
|
||||||
console.error(`\n ${RED()}Error: Nothing to commit.${RESET()}\n`);
|
|
||||||
return 1;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,18 +235,13 @@ export async function handleCommit(args: ParsedArgs): Promise<number> {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (unstagedFiles.length > 0) {
|
if (autoMode && unstagedFiles.length > 0) {
|
||||||
if (autoMode) {
|
await stageFiles(unstagedFiles.map((f) => f.path));
|
||||||
await stageFiles(unstagedFiles.map((f) => f.path));
|
console.log(` ${GREEN()}Auto-staged ${unstagedFiles.length} file(s).${RESET()}`);
|
||||||
console.log(` ${GREEN()}Auto-staged ${unstagedFiles.length} file(s).${RESET()}`);
|
} else {
|
||||||
} else {
|
const selected = await selectFiles(stagedFiles, unstagedFiles);
|
||||||
const selected = await selectFiles(stagedFiles, unstagedFiles);
|
if (selected === BACK) return SKIP_WAIT as unknown as number;
|
||||||
if (selected === BACK) return SKIP_WAIT as unknown as number;
|
printSelectionResult(await applyFileSelection(stagedFiles, unstagedFiles, selected));
|
||||||
if (selected.length > 0) {
|
|
||||||
await stageFiles(selected);
|
|
||||||
console.log(` ${GREEN()}Staged ${selected.length} file(s).${RESET()}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const diff = await getStagedDiff();
|
const diff = await getStagedDiff();
|
||||||
|
|||||||
+17
-17
@@ -1,5 +1,12 @@
|
|||||||
import { loadConfig } from "../config";
|
import { loadConfig } from "../config";
|
||||||
import { isGitRepo, getStagedDiff, getUnstagedFiles, getRepoRoot, stageFiles } from "../git";
|
import {
|
||||||
|
isGitRepo,
|
||||||
|
getStagedFiles,
|
||||||
|
getStagedDiff,
|
||||||
|
getUnstagedFiles,
|
||||||
|
getRepoRoot,
|
||||||
|
applyFileSelection,
|
||||||
|
} from "../git";
|
||||||
import { selectFiles } from "../selector";
|
import { selectFiles } from "../selector";
|
||||||
import { BACK, SKIP_WAIT } from "../menu";
|
import { BACK, SKIP_WAIT } from "../menu";
|
||||||
import { collectProjectContext } from "../context";
|
import { collectProjectContext } from "../context";
|
||||||
@@ -19,7 +26,6 @@ export async function handleExplain(args: ParsedArgs): Promise<number> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const unstaged = args.flags["unstaged"] as boolean;
|
const unstaged = args.flags["unstaged"] as boolean;
|
||||||
const staged = args.flags["staged"] as boolean;
|
|
||||||
const verbose = args.flags["verbose"] as boolean;
|
const verbose = args.flags["verbose"] as boolean;
|
||||||
|
|
||||||
// Determine which diff to explain
|
// Determine which diff to explain
|
||||||
@@ -44,22 +50,16 @@ export async function handleExplain(args: ParsedArgs): Promise<number> {
|
|||||||
console.error(`\n ${RED()}Error: Not a git repository.${RESET()}\n`);
|
console.error(`\n ${RED()}Error: Not a git repository.${RESET()}\n`);
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
diff = await getStagedDiff();
|
const stagedFiles = await getStagedFiles();
|
||||||
sourceLabel = "staged changes";
|
const unstagedFiles = await getUnstagedFiles();
|
||||||
|
sourceLabel = "selected changes";
|
||||||
|
|
||||||
// If no staged changes, offer to stage unstaged files
|
if (stagedFiles.length > 0 || unstagedFiles.length > 0) {
|
||||||
if (!diff) {
|
const selected = await selectFiles(stagedFiles, unstagedFiles);
|
||||||
const unstagedFiles = await getUnstagedFiles();
|
if (selected === BACK) return SKIP_WAIT as unknown as number;
|
||||||
if (unstagedFiles.length > 0) {
|
await applyFileSelection(stagedFiles, unstagedFiles, selected);
|
||||||
const selected = await selectFiles([], unstagedFiles);
|
|
||||||
if (selected === BACK) return SKIP_WAIT as unknown as number;
|
|
||||||
if (selected.length > 0) {
|
|
||||||
await stageFiles(selected);
|
|
||||||
console.log(` ${GREEN()}Staged ${selected.length} file(s).${RESET()}`);
|
|
||||||
diff = await getStagedDiff();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
diff = await getStagedDiff();
|
||||||
} else {
|
} else {
|
||||||
// Read from pipe
|
// Read from pipe
|
||||||
const chunks: Buffer[] = [];
|
const chunks: Buffer[] = [];
|
||||||
@@ -109,7 +109,7 @@ export async function handleExplain(args: ParsedArgs): Promise<number> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const callbacks: StreamCallbacks = tty ? {
|
const callbacks: StreamCallbacks | undefined = tty ? {
|
||||||
onToken: (token) => process.stdout.write(token),
|
onToken: (token) => process.stdout.write(token),
|
||||||
} : undefined;
|
} : undefined;
|
||||||
|
|
||||||
|
|||||||
+17
-16
@@ -1,5 +1,12 @@
|
|||||||
import { loadConfig } from "../config";
|
import { loadConfig } from "../config";
|
||||||
import { isGitRepo, getStagedDiff, getUnstagedFiles, getRepoRoot, stageFiles } from "../git";
|
import {
|
||||||
|
isGitRepo,
|
||||||
|
getStagedFiles,
|
||||||
|
getStagedDiff,
|
||||||
|
getUnstagedFiles,
|
||||||
|
getRepoRoot,
|
||||||
|
applyFileSelection,
|
||||||
|
} from "../git";
|
||||||
import { selectFiles } from "../selector";
|
import { selectFiles } from "../selector";
|
||||||
import { BACK, SKIP_WAIT } from "../menu";
|
import { BACK, SKIP_WAIT } from "../menu";
|
||||||
import { collectProjectContext } from "../context";
|
import { collectProjectContext } from "../context";
|
||||||
@@ -53,22 +60,16 @@ export async function handleReview(args: ParsedArgs): Promise<number> {
|
|||||||
console.error(`\n ${RED()}Error: Not a git repository.${RESET()}\n`);
|
console.error(`\n ${RED()}Error: Not a git repository.${RESET()}\n`);
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
diff = await getStagedDiff();
|
const stagedFiles = await getStagedFiles();
|
||||||
sourceLabel = "staged changes";
|
const unstagedFiles = await getUnstagedFiles();
|
||||||
|
sourceLabel = "selected changes";
|
||||||
|
|
||||||
// If no staged changes, offer to stage unstaged files
|
if (stagedFiles.length > 0 || unstagedFiles.length > 0) {
|
||||||
if (!diff) {
|
const selected = await selectFiles(stagedFiles, unstagedFiles);
|
||||||
const unstagedFiles = await getUnstagedFiles();
|
if (selected === BACK) return SKIP_WAIT as unknown as number;
|
||||||
if (unstagedFiles.length > 0) {
|
await applyFileSelection(stagedFiles, unstagedFiles, selected);
|
||||||
const selected = await selectFiles([], unstagedFiles);
|
|
||||||
if (selected === BACK) return SKIP_WAIT as unknown as number;
|
|
||||||
if (selected.length > 0) {
|
|
||||||
await stageFiles(selected);
|
|
||||||
console.log(` ${GREEN()}Staged ${selected.length} file(s).${RESET()}`);
|
|
||||||
diff = await getStagedDiff();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
diff = await getStagedDiff();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!diff) {
|
if (!diff) {
|
||||||
@@ -110,7 +111,7 @@ export async function handleReview(args: ParsedArgs): Promise<number> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const callbacks: StreamCallbacks = tty ? {
|
const callbacks: StreamCallbacks | undefined = tty ? {
|
||||||
onToken: (token) => process.stdout.write(token),
|
onToken: (token) => process.stdout.write(token),
|
||||||
} : undefined;
|
} : undefined;
|
||||||
|
|
||||||
|
|||||||
+14
-14
@@ -1,5 +1,11 @@
|
|||||||
import { loadConfig } from "../config";
|
import { loadConfig } from "../config";
|
||||||
import { isGitRepo, getStagedDiff, getUnstagedFiles, stageFiles } from "../git";
|
import {
|
||||||
|
isGitRepo,
|
||||||
|
getStagedFiles,
|
||||||
|
getStagedDiff,
|
||||||
|
getUnstagedFiles,
|
||||||
|
applyFileSelection,
|
||||||
|
} from "../git";
|
||||||
import { selectFiles } from "../selector";
|
import { selectFiles } from "../selector";
|
||||||
import { BACK, SKIP_WAIT } from "../menu";
|
import { BACK, SKIP_WAIT } from "../menu";
|
||||||
import {
|
import {
|
||||||
@@ -51,21 +57,15 @@ export async function handleSuggest(args: ParsedArgs): Promise<number> {
|
|||||||
diff = "";
|
diff = "";
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
diff = await getStagedDiff();
|
const stagedFiles = await getStagedFiles();
|
||||||
|
const unstagedFiles = await getUnstagedFiles();
|
||||||
|
|
||||||
// If no staged changes, offer to stage unstaged files
|
if (stagedFiles.length > 0 || unstagedFiles.length > 0) {
|
||||||
if (!diff) {
|
const selected = await selectFiles(stagedFiles, unstagedFiles);
|
||||||
const unstagedFiles = await getUnstagedFiles();
|
if (selected === BACK) return SKIP_WAIT as unknown as number;
|
||||||
if (unstagedFiles.length > 0) {
|
await applyFileSelection(stagedFiles, unstagedFiles, selected);
|
||||||
const selected = await selectFiles([], unstagedFiles);
|
|
||||||
if (selected === BACK) return SKIP_WAIT as unknown as number;
|
|
||||||
if (selected.length > 0) {
|
|
||||||
await stageFiles(selected);
|
|
||||||
console.log(` ${GREEN()}Staged ${selected.length} file(s).${RESET()}`);
|
|
||||||
diff = await getStagedDiff();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
diff = await getStagedDiff();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+27
@@ -98,6 +98,33 @@ export async function stageFiles(paths: string[]): Promise<void> {
|
|||||||
await Bun.$`git add -- ${paths}`;
|
await Bun.$`git add -- ${paths}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function unstageFiles(paths: string[]): Promise<void> {
|
||||||
|
if (paths.length === 0) return;
|
||||||
|
try {
|
||||||
|
await Bun.$`git restore --staged -- ${paths}`.quiet();
|
||||||
|
} catch {
|
||||||
|
await Bun.$`git rm --cached -r -- ${paths}`.quiet();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function applyFileSelection(
|
||||||
|
stagedFiles: FileEntry[],
|
||||||
|
unstagedFiles: FileEntry[],
|
||||||
|
selectedPaths: string[],
|
||||||
|
): Promise<{ staged: string[]; unstaged: string[] }> {
|
||||||
|
const selected = new Set(selectedPaths);
|
||||||
|
const stagedPaths = new Set(stagedFiles.map((file) => file.path));
|
||||||
|
const unstagedPaths = new Set(unstagedFiles.map((file) => file.path));
|
||||||
|
|
||||||
|
const toUnstage = [...stagedPaths].filter((path) => !selected.has(path));
|
||||||
|
const toStage = [...selected].filter((path) => unstagedPaths.has(path));
|
||||||
|
|
||||||
|
await unstageFiles(toUnstage);
|
||||||
|
await stageFiles(toStage);
|
||||||
|
|
||||||
|
return { staged: toStage, unstaged: toUnstage };
|
||||||
|
}
|
||||||
|
|
||||||
export async function commit(
|
export async function commit(
|
||||||
message: string,
|
message: string,
|
||||||
): Promise<{ branch: string; hash: string; files: number; insertions: number; deletions: number }> {
|
): Promise<{ branch: string; hash: string; files: number; insertions: number; deletions: number }> {
|
||||||
|
|||||||
@@ -225,6 +225,7 @@ export async function selectMany<T>(
|
|||||||
if (!options.selectAllLabel) return;
|
if (!options.selectAllLabel) return;
|
||||||
items[0]!.selected = items.slice(1).every((item) => item.selected);
|
items[0]!.selected = items.slice(1).every((item) => item.selected);
|
||||||
};
|
};
|
||||||
|
syncSelectAll();
|
||||||
|
|
||||||
const toggle = (index: number) => {
|
const toggle = (index: number) => {
|
||||||
const item = items[index]!;
|
const item = items[index]!;
|
||||||
|
|||||||
+29
-19
@@ -1,44 +1,54 @@
|
|||||||
import type { FileEntry } from "./types";
|
import type { FileEntry } from "./types";
|
||||||
import { BOLD, GREEN, YELLOW, RESET } from "./terminal";
|
|
||||||
import { isStdinTTY } from "./tty";
|
import { isStdinTTY } from "./tty";
|
||||||
import { BACK, selectMany } from "./menu";
|
import { BACK, selectMany } from "./menu";
|
||||||
import type { PromptBack } from "./menu";
|
import type { PromptBack } from "./menu";
|
||||||
|
|
||||||
|
function mergeFiles(stagedFiles: FileEntry[], unstagedFiles: FileEntry[]) {
|
||||||
|
const files = new Map<string, FileEntry & { staged: boolean; unstaged: boolean }>();
|
||||||
|
|
||||||
|
for (const file of stagedFiles) {
|
||||||
|
files.set(file.path, { ...file, staged: true, unstaged: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const file of unstagedFiles) {
|
||||||
|
const existing = files.get(file.path);
|
||||||
|
if (existing) {
|
||||||
|
existing.unstaged = true;
|
||||||
|
existing.label = existing.label === file.label
|
||||||
|
? existing.label
|
||||||
|
: `${existing.label}, ${file.label}`;
|
||||||
|
} else {
|
||||||
|
files.set(file.path, { ...file, staged: false, unstaged: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...files.values()];
|
||||||
|
}
|
||||||
|
|
||||||
export async function selectFiles(
|
export async function selectFiles(
|
||||||
stagedFiles: FileEntry[],
|
stagedFiles: FileEntry[],
|
||||||
unstagedFiles: FileEntry[],
|
unstagedFiles: FileEntry[],
|
||||||
): Promise<string[] | PromptBack> {
|
): Promise<string[] | PromptBack> {
|
||||||
if (unstagedFiles.length === 0) return [];
|
const files = mergeFiles(stagedFiles, unstagedFiles);
|
||||||
|
if (files.length === 0) return [];
|
||||||
|
|
||||||
if (stagedFiles.length > 0) {
|
if (!isStdinTTY()) return stagedFiles.map((file) => file.path);
|
||||||
process.stdout.write(`\n ${BOLD()}Staged files (will be included):${RESET()}\n`);
|
|
||||||
for (const f of stagedFiles) {
|
|
||||||
process.stdout.write(` ${GREEN()}✓${RESET()} ${f.path} (${YELLOW()}${f.label}${RESET()})\n`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isStdinTTY()) return [];
|
|
||||||
|
|
||||||
const selected = await selectMany({
|
const selected = await selectMany({
|
||||||
title: "Select files to stage",
|
title: "Select files for this action",
|
||||||
subtitle: `${unstagedFiles.length} unstaged file${unstagedFiles.length > 1 ? "s" : ""} available`,
|
subtitle: `${stagedFiles.length} staged, ${unstagedFiles.length} unstaged`,
|
||||||
selectAllLabel: "Select all",
|
selectAllLabel: "Select all",
|
||||||
cancelMessage: "Aborted.",
|
cancelMessage: "Aborted.",
|
||||||
items: unstagedFiles.map((f) => ({
|
items: files.map((f) => ({
|
||||||
label: f.path,
|
label: f.path,
|
||||||
value: f.path,
|
value: f.path,
|
||||||
description: f.label,
|
description: f.label,
|
||||||
|
selected: f.staged,
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (selected === null) process.exit(1);
|
if (selected === null) process.exit(1);
|
||||||
if (selected === BACK) return BACK;
|
if (selected === BACK) return BACK;
|
||||||
|
|
||||||
if (selected.length > 0) {
|
|
||||||
process.stdout.write(
|
|
||||||
` ${GREEN()}Staged ${selected.length} file(s):${RESET()} ${selected.join(", ")}\n`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return selected;
|
return selected;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user