Skip to content

Commit 74c45f5

Browse files
authored
Merge pull request #13 from BeyteFlow/code
Code
2 parents 83b282c + e9ea43f commit 74c45f5

13 files changed

Lines changed: 718 additions & 201 deletions

File tree

git-ai/.npmignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
node_modules/
2+
src/
3+
tsconfig.json
4+
.aigitrc
5+
*.log

git-ai/package-lock.json

Lines changed: 289 additions & 154 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

git-ai/package.json

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,47 +4,51 @@
44
"description": "AI-Powered Visual Git CLI for modern developers",
55
"type": "module",
66
"bin": {
7-
"ai-git": "./dist/cli.js"
7+
"ai-git": "./dist/index.js"
88
},
99
"engines": {
1010
"node": ">=20.0.0"
1111
},
1212
"scripts": {
13-
"dev": "tsx src/cli.ts",
13+
"dev": "tsx src/index.ts",
1414
"build": "tsc",
15-
"start": "node dist/cli.js",
15+
"start": "node dist/index.js",
1616
"lint": "tsc --noEmit",
17-
"test": "echo \"No tests specified\""
17+
"test": "npm run lint",
18+
"prepare": "npm run build"
1819
},
1920
"keywords": [
2021
"git",
2122
"cli",
2223
"terminal",
2324
"ai",
25+
"gemini",
2426
"automation",
2527
"github",
2628
"commit",
2729
"merge",
2830
"workflow",
2931
"developer-tools"
3032
],
31-
"author": "",
32-
"license": "ISC",
33+
"author": "Naheel",
34+
"license": "MIT",
3335
"dependencies": {
34-
"@google/generative-ai": "^0.24.1",
35-
"@octokit/rest": "^22.0.1",
36+
"@google/generative-ai": "^0.21.0",
37+
"@octokit/rest": "^20.1.1",
38+
"archy": "^1.0.0",
3639
"chalk": "^5.3.0",
3740
"commander": "^12.1.0",
3841
"ink": "^5.0.0",
39-
"pino": "^10.3.1",
40-
"pino-pretty": "^13.1.3",
41-
"react": "^18.2.0",
42+
"pino": "^9.0.0",
43+
"pino-pretty": "^11.0.0",
44+
"react": "^18.3.1",
4245
"simple-git": "^3.27.0",
4346
"zod": "^3.23.8"
4447
},
4548
"devDependencies": {
49+
"@types/archy": "^0.0.34",
4650
"@types/node": "^20.11.0",
47-
"@types/react": "^18.2.0",
51+
"@types/react": "^18.3.3",
4852
"tsx": "^4.7.1",
4953
"typescript": "^5.4.0"
5054
}

git-ai/src/cli/resolve-command.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,11 @@ export async function runResolveCommand() {
1010
const ai = new AIService(config);
1111
const resolver = new ConflictResolver(ai, git);
1212

13-
const conflicts = await resolver.getConflicts();
13+
const { conflicts, skippedFiles } = await resolver.getConflicts();
14+
15+
if (skippedFiles.length > 0) {
16+
console.warn(`⚠️ Could not read ${skippedFiles.length} conflicted file(s): ${skippedFiles.join(', ')}`);
17+
}
1418

1519
if (conflicts.length === 0) {
1620
console.log('✅ No merge conflicts detected.');
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import { GitService } from '../core/GitService.js';
2+
import { AIService } from '../services/AIService.js';
3+
import { ConfigService } from '../services/ConfigService.js';
4+
import { logger } from '../utils/logger.js';
5+
import readline from 'readline/promises';
6+
7+
function validateCommitMessage(message: string): string | null {
8+
const normalizedMessage = message.trim();
9+
10+
if (!normalizedMessage) {
11+
return 'Commit message cannot be empty or whitespace.';
12+
}
13+
14+
if (normalizedMessage.length > 72) {
15+
return 'Commit message must be 72 characters or fewer.';
16+
}
17+
18+
if (/[\x00-\x1F\x7F]/.test(normalizedMessage)) {
19+
return 'Commit message contains invalid control characters.';
20+
}
21+
22+
return null;
23+
}
24+
25+
export async function commitCommand() {
26+
const rl = readline.createInterface({
27+
input: process.stdin,
28+
output: process.stdout,
29+
});
30+
31+
try {
32+
const config = new ConfigService();
33+
const git = new GitService();
34+
const ai = new AIService(config);
35+
const diff = await git.getDiff();
36+
if (!diff) {
37+
console.log('⚠️ No staged changes found. Use "git add" first.');
38+
return;
39+
}
40+
41+
console.log('🤖 Analyzing changes with Gemini...');
42+
const suggestedMessage = await ai.generateCommitMessage(diff);
43+
const suggestedValidationError = validateCommitMessage(suggestedMessage);
44+
let commitMessage: string | null = null;
45+
46+
if (suggestedValidationError) {
47+
logger.warn(`Invalid AI commit message: ${suggestedValidationError}`);
48+
console.warn(`⚠️ AI returned an invalid message: ${suggestedValidationError}`);
49+
} else {
50+
commitMessage = suggestedMessage.trim();
51+
console.log(`\n✨ Suggested message: "${commitMessage}"`);
52+
}
53+
54+
while (true) {
55+
const choice = (await rl.question('Choose [a]ccept, [e]dit, or [r]eject: ')).trim().toLowerCase();
56+
57+
if (choice === 'a' || choice === 'accept' || choice === '') {
58+
if (!commitMessage) {
59+
console.log('No valid commit message available. Please [e]dit to enter one or [r]eject to cancel.');
60+
continue;
61+
}
62+
break;
63+
}
64+
65+
if (choice === 'e' || choice === 'edit') {
66+
while (true) {
67+
const editedMessage = await rl.question('✏️ Enter commit message: ');
68+
const editedValidationError = validateCommitMessage(editedMessage);
69+
if (!editedValidationError) {
70+
commitMessage = editedMessage.trim();
71+
break;
72+
}
73+
console.error(`❌ Invalid commit message: ${editedValidationError}`);
74+
}
75+
break;
76+
}
77+
78+
if (choice === 'r' || choice === 'reject') {
79+
console.log('🚫 Commit canceled.');
80+
return;
81+
}
82+
83+
console.log('Please choose "a", "e", or "r".');
84+
}
85+
86+
if (!commitMessage) {
87+
// This should not be reachable: the loop only breaks after commitMessage is set,
88+
// and reject returns early. Guard defensively.
89+
console.error('❌ No commit message was provided.');
90+
return;
91+
}
92+
await git.commit(commitMessage);
93+
console.log('✅ Committed successfully!');
94+
} catch (error) {
95+
const errorDetails = error instanceof Error ? error.message : String(error);
96+
console.error(`❌ Commit failed: ${errorDetails}`);
97+
logger.error(error);
98+
} finally {
99+
rl.close();
100+
}
101+
}

git-ai/src/commands/InitCommand.ts

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import fs from 'fs';
2+
import path from 'path';
3+
import os from 'os';
4+
import readline from 'readline/promises';
5+
import { ConfigSchema, Config } from '../services/ConfigService.js';
6+
import { logger } from '../utils/logger.js';
7+
8+
/**
9+
* Reads a secret/password from the terminal without echoing characters.
10+
* Falls back to normal readline if stdin is not a TTY (e.g. piped input).
11+
*/
12+
async function readSecretInput(rl: readline.Interface, prompt: string): Promise<string> {
13+
const rlAny = rl as any;
14+
const originalWrite = rlAny._writeToOutput;
15+
// Suppress character echoing: only allow the initial prompt to be written
16+
let promptWritten = false;
17+
rlAny._writeToOutput = function _writeToOutput(str: string) {
18+
if (!promptWritten) {
19+
promptWritten = true;
20+
process.stdout.write(str);
21+
}
22+
// Suppress all subsequent echoed characters
23+
};
24+
try {
25+
return await rl.question(prompt);
26+
} finally {
27+
process.stdout.write('\n');
28+
rlAny._writeToOutput = originalWrite;
29+
}
30+
}
31+
32+
export async function initCommand() {
33+
const rl = readline.createInterface({
34+
input: process.stdin,
35+
output: process.stdout,
36+
});
37+
38+
console.log('🚀 Welcome to AI-Git-Terminal Setup\n');
39+
40+
try {
41+
let apiKey = '';
42+
while (!apiKey) {
43+
const apiKeyInput = await readSecretInput(rl, '🔑 Enter your Gemini API Key: ');
44+
apiKey = apiKeyInput.trim();
45+
if (!apiKey) {
46+
console.error('❌ API key cannot be empty. Please enter a valid key.');
47+
}
48+
}
49+
50+
const modelInput = await rl.question('🤖 Enter model name (default: gemini-1.5-flash): ');
51+
const model = modelInput.trim() || 'gemini-1.5-flash';
52+
53+
const newConfig: Config = {
54+
ai: {
55+
provider: 'gemini',
56+
apiKey,
57+
model: model,
58+
},
59+
git: {
60+
autoStage: false,
61+
},
62+
ui: {
63+
theme: 'dark',
64+
showIcons: true,
65+
},
66+
};
67+
68+
// Validate with Zod and persist with restricted permissions (mode 0o600)
69+
ConfigSchema.parse(newConfig);
70+
71+
const configPath = path.join(os.homedir(), '.aigitrc');
72+
73+
if (fs.existsSync(configPath)) {
74+
const overwriteChoice = (await rl.question(
75+
'⚠️ Existing config found. Choose [o]verwrite, [b]ackup then replace, or [c]ancel: '
76+
)).trim().toLowerCase();
77+
78+
if (overwriteChoice === 'b' || overwriteChoice === 'backup') {
79+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
80+
const backupPath = `${configPath}.bak-${timestamp}`;
81+
fs.renameSync(configPath, backupPath);
82+
console.log(`📦 Existing config backed up to ${backupPath}`);
83+
} else if (overwriteChoice === 'o' || overwriteChoice === 'overwrite') {
84+
console.log('📝 Overwriting existing config file.');
85+
} else {
86+
console.log('🚫 Initialization canceled. Existing config left unchanged.');
87+
return;
88+
}
89+
}
90+
91+
fs.writeFileSync(configPath, JSON.stringify(newConfig, null, 2), { mode: 0o600 });
92+
fs.chmodSync(configPath, 0o600);
93+
94+
console.log(`\n✅ Configuration saved to ${configPath}`);
95+
console.log('Try running: ai-git commit');
96+
} catch (error) {
97+
logger.error('Failed to save configuration: ' + (error instanceof Error ? error.message : String(error)));
98+
console.error('\n❌ Invalid input or failed to write config file.');
99+
} finally {
100+
rl.close();
101+
}
102+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { GitService } from '../core/GitService.js';
2+
import { AIService } from '../services/AIService.js';
3+
import { ConfigService } from '../services/ConfigService.js';
4+
import { ConflictResolver } from '../services/ConflictResolver.js';
5+
6+
export async function runResolveCommand() {
7+
const config = new ConfigService();
8+
const git = new GitService();
9+
const ai = new AIService(config);
10+
const resolver = new ConflictResolver(ai, git);
11+
12+
let conflicts;
13+
let skippedFiles: string[] = [];
14+
try {
15+
({ conflicts, skippedFiles } = await resolver.getConflicts());
16+
} catch (error) {
17+
const message = error instanceof Error ? error.message : String(error);
18+
console.error(`❌ Failed to list merge conflicts in runResolveCommand: ${message}`);
19+
process.exitCode = 1;
20+
return;
21+
}
22+
23+
if (skippedFiles.length > 0) {
24+
console.warn(`⚠️ Could not read ${skippedFiles.length} conflicted file(s): ${skippedFiles.join(', ')}`);
25+
}
26+
27+
if (conflicts.length === 0) {
28+
console.log('✅ No conflicts found.');
29+
if (skippedFiles.length > 0) process.exitCode = 1;
30+
return;
31+
}
32+
33+
const failedFiles: string[] = [];
34+
let successCount = 0;
35+
36+
for (const conflict of conflicts) {
37+
console.log(`🤖 Resolving: ${conflict.file}...`);
38+
try {
39+
const solution = await resolver.suggestResolution(conflict);
40+
await resolver.applyResolution(conflict.file, solution);
41+
console.log(`✅ Applied AI fix to ${conflict.file}`);
42+
successCount++;
43+
} catch (error) {
44+
const message = error instanceof Error ? error.message : String(error);
45+
console.error(`❌ Failed to resolve ${conflict.file}: ${message}`);
46+
failedFiles.push(conflict.file);
47+
}
48+
}
49+
50+
if (successCount > 0) {
51+
console.log(`\n🎉 Successfully resolved ${successCount} file(s).`);
52+
}
53+
54+
if (failedFiles.length > 0 || skippedFiles.length > 0) {
55+
if (failedFiles.length > 0) {
56+
console.error(`⚠️ Resolution failed for ${failedFiles.length} file(s): ${failedFiles.join(', ')}`);
57+
}
58+
process.exitCode = 1;
59+
}
60+
}

git-ai/src/commands/TreeCommand.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import React from 'react';
2+
import { render } from 'ink';
3+
import { GitService } from '../core/GitService.js';
4+
import { TreeUI } from '../ui/TreeUI.js';
5+
6+
export async function treeCommand() {
7+
const gitService = new GitService();
8+
9+
const { waitUntilExit } = render(
10+
React.createElement(TreeUI, { gitService })
11+
);
12+
13+
await waitUntilExit();
14+
}

git-ai/src/core/GitService.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { simpleGit, SimpleGit, StatusResult, LogResult } from 'simple-git';
1+
import { simpleGit, SimpleGit, StatusResult, LogResult, BranchSummary } from 'simple-git';
22
import { logger } from './../utils/logger.js';
33

44
export class GitService {
@@ -43,4 +43,13 @@ export class GitService {
4343
const branchData = await this.git.branch();
4444
return branchData.current;
4545
}
46+
47+
public async getBranches(): Promise<BranchSummary> {
48+
try {
49+
return await this.git.branch();
50+
} catch (error) {
51+
logger.error(`Failed to fetch git branches: ${error instanceof Error ? error.message : String(error)}`);
52+
throw error;
53+
}
54+
}
4655
}

0 commit comments

Comments
 (0)