Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
482 changes: 481 additions & 1 deletion git-ai/package-lock.json

Large diffs are not rendered by default.

7 changes: 6 additions & 1 deletion git-ai/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,16 @@
"author": "",
"license": "ISC",
"dependencies": {
"@google/generative-ai": "^0.24.1",
"@octokit/rest": "^22.0.1",
"chalk": "^5.3.0",
"commander": "^12.1.0",
"ink": "^5.0.0",
"pino": "^10.3.1",
"pino-pretty": "^13.1.3",
"react": "^18.2.0",
"simple-git": "^3.27.0"
"simple-git": "^3.27.0",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/node": "^20.11.0",
Expand Down
52 changes: 52 additions & 0 deletions git-ai/src/cli/pr-command.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import React from 'react';
import { render } from 'ink';
import { GitService } from '../core/GitService.js';
import { ConfigService } from '../services/ConfigService.js';
import { GitHubService, PullRequestMetadata } from '../services/GitHubService.js';
import { PRList } from '../ui/PRList.js';
import { logger } from '../utils/logger.js';

/**
* Orchestrates the Interactive PR Selection UI
*/
export async function runPRCommand(): Promise<void> {
try {
const configService = new ConfigService();
const gitService = new GitService();

// 1. Get the remote URL to identify the GitHub repository
// Accessing the underlying git instance safely:
const remotes = await (gitService as any).git.getRemotes(true);
const origin = remotes.find((r: any) => r.name === 'origin');

if (!origin || !origin.refs.fetch) {
console.error('❌ Error: No remote "origin" found. Ensure your repo is hosted on GitHub.');
process.exit(1);
}

const githubService = new GitHubService(configService, origin.refs.fetch);

// 2. Launch the Ink TUI
const renderInstance = render(
React.createElement(PRList, {
githubService,
onSelect: (pr: PullRequestMetadata) => {
console.log('\n-----------------------------------');
console.log(`🚀 Selected PR: #${pr.number}`);
console.log(`🔗 URL: ${pr.url}`);
console.log(`🌿 Branch: ${pr.branch} -> ${pr.base}`);
console.log('-----------------------------------\n');
// In a future update, we can trigger gitService.checkout(pr.branch)
renderInstance.unmount();
}
})
);

// 3. Await clean exit
await renderInstance.waitUntilExit();
} catch (error) {
logger.error(error instanceof Error ? error : new Error(String(error)), 'Failed to initialize PR command');
console.error('❌ Critical Error: Could not launch PR interface.');
process.exit(1);
}
}
42 changes: 42 additions & 0 deletions git-ai/src/cli/resolve-command.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { ConflictResolver } from '../services/ConflictResolver.js';
import { AIService } from '../services/AIService.js';
import { GitService } from '../core/GitService.js';
import { ConfigService } from '../services/ConfigService.js';

export async function runResolveCommand() {
try {
const config = new ConfigService();
const git = new GitService();
const ai = new AIService(config);
const resolver = new ConflictResolver(ai, git);

const conflicts = await resolver.getConflicts();

if (conflicts.length === 0) {
console.log('✅ No merge conflicts detected.');
return;
}

console.log(`🔍 Found ${conflicts.length} files with conflicts.`);

for (const conflict of conflicts) {
try {
console.log(`🤖 Analyzing ${conflict.file}...`);
const suggestion = await resolver.suggestResolution(conflict);

console.log(`\n--- AI Suggested Resolution for ${conflict.file} ---`);
console.log(suggestion);
console.log('--------------------------------------------------\n');
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`⚠️ Failed to process conflict for ${conflict.file}: ${message}`);
}

// In the final UI/Ink phase, we would add a [Apply] / [Skip] prompt here.
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`❌ Failed to initialize conflict resolution: ${message}`);
process.exit(1);
}
}
46 changes: 46 additions & 0 deletions git-ai/src/core/GitService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { simpleGit, SimpleGit, StatusResult, LogResult } from 'simple-git';
import { logger } from './../utils/logger.js';

export class GitService {
private git: SimpleGit;

constructor(workingDir: string = process.cwd()) {
this.git = simpleGit(workingDir);
}

public async getStatus(): Promise<StatusResult> {
try {
return await this.git.status();
} catch (error) {
logger.error(`Failed to fetch git status: ${error instanceof Error ? error.message : String(error)}`);
throw error;
}
}

public async getDiff(): Promise<string> {
return await this.git.diff(['--cached']);
}

public async commit(message: string): Promise<void> {
const normalizedMessage = message.trim();
if (!normalizedMessage) {
throw new Error('Commit message cannot be empty or whitespace.');
}

try {
await this.git.commit(normalizedMessage);
} catch (error) {
const original = error instanceof Error ? error.message : String(error);
throw new Error(`Failed to create git commit: ${original}`);
}
}

public async getLog(limit: number = 10): Promise<LogResult> {
return await this.git.log({ maxCount: limit });
}

public async getCurrentBranch(): Promise<string> {
const branchData = await this.git.branch();
return branchData.current;
}
}
39 changes: 39 additions & 0 deletions git-ai/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { Command } from 'commander';
import { GitService } from './core/GitService.js';
import { ConfigService } from './services/ConfigService.js';
import { AIService } from './services/AIService.js';
import { logger } from './utils/logger.js';

const program = new Command();
const configService = new ConfigService();
const gitService = new GitService();

program
.name('ai-git')
.version('0.1.0');

program
.command('ai-commit')
.description('Generate a commit message using AI and commit staged changes')
.action(async () => {
try {
const aiService = new AIService(configService);
const diff = await gitService.getDiff();
if (!diff) {
console.log('No staged changes found. Please stage files first.');
return;
}

console.log('🤖 Generating commit message...');
const message = await aiService.generateCommitMessage(diff);

console.log(`\nSuggested Message: "${message}"`);
await gitService.commit(message);
console.log('✅ Changes committed successfully.');
} catch (err) {
logger.error(`AI Commit failed: ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
}
});

program.parse(process.argv);
120 changes: 120 additions & 0 deletions git-ai/src/services/AIService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { GoogleGenerativeAI, GenerativeModel } from "@google/generative-ai";
import { ConfigService } from "./ConfigService.js";
import { logger } from "../utils/logger.js";

export interface AIProvider {
generateCommitMessage(diff: string): Promise<string>;
analyzeConflicts(conflictFileContents: Record<string, string>): Promise<string>;
generateContent(prompt: string): Promise<string>;
}

export class AIService implements AIProvider {
private genAI: GoogleGenerativeAI | null = null;
private model: GenerativeModel | null = null;
private configService: ConfigService;

constructor(configService: ConfigService) {
this.configService = configService;
this.initClient();
}

private initClient(): void {
const config = this.configService.getConfig();

// Ensure we only init if the provider is gemini
if (config.ai.provider === "gemini") {
if (!config.ai.apiKey) {
throw new Error("Gemini API Key is missing in .aigitrc");
}

this.genAI = new GoogleGenerativeAI(config.ai.apiKey);
this.model = this.genAI.getGenerativeModel({
model: config.ai.model || "gemini-1.5-flash",
generationConfig: {
temperature: 0.2,
topP: 0.8,
maxOutputTokens: 200,
},
});
} else {
throw new Error(`Unsupported AI provider: ${config.ai.provider}`);
}
}

/**
* Generates a conventional commit message based on git diff
*/
public async generateCommitMessage(diff: string): Promise<string> {
if (!this.model) {
throw new Error("Gemini AI model not initialized. Check your config.");
}

const prompt = `
You are an expert software engineer.
Generate a professional, concise conventional commit message based on this git diff:

"${diff}"

Instructions:
1. Use the format: <type>(<scope>): <description>
2. Common types: feat, fix, docs, style, refactor, test, chore.
3. Description should be in present tense and lowercase.
4. Return ONLY the commit message text.
`;

try {
const result = await this.model.generateContent(prompt);
const response = await result.response;
const text = response.text().trim();

// Clean up potential markdown formatting if Gemini returns backticks
return text.replace(/`/g, "");
} catch (error) {
logger.error(
`Gemini API Error: ${error instanceof Error ? error.message : String(error)}`,
);
throw new Error("Failed to generate commit message via Gemini.");
}
}

/**
* Analyzes merge conflicts and suggests resolutions
*/
public async analyzeConflicts(conflictFileContents: Record<string, string>): Promise<string> {
if (!this.model) throw new Error("AI Service not ready");

const conflictsWithContent = Object.entries(conflictFileContents)
.map(([fileName, content]) => `FILE: ${fileName}\n${content}`)
.join("\n\n");

const prompt = `Analyze the following files currently in a git conflict state and provide a high-level summary of the clashing changes. Include key differences and likely intent from both sides of each conflict marker block.\n\n${conflictsWithContent}`;

try {
const result = await this.model.generateContent(prompt);
return result.response.text();
} catch (error) {
logger.error(
`Conflict Analysis Error: ${error instanceof Error ? error.message : String(error)}`,
);
return "Could not analyze conflicts at this time.";
}
}

public async generateContent(prompt: string): Promise<string> {
if (!this.model) {
throw new Error("Gemini AI model not initialized. Check your config.");
}

try {
const result = await this.model.generateContent(prompt);
const response = await result.response;
return response.text();
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
logger.error(
`AI generateContent Error: ${errorMsg}`,
);
throw new Error("Failed to generate content via AI service.");
}
}
}
61 changes: 61 additions & 0 deletions git-ai/src/services/ConfigService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import fs from 'fs';
import path from 'path';
import os from 'os';
import { z } from 'zod';

export const ConfigSchema = z.object({
ai: z.object({
provider: z.enum(['openai', 'gemini']),
apiKey: z.string(),
model: z.string().optional(),
}),
github: z.object({
token: z.string().min(1),
}).optional(),
git: z.object({
autoStage: z.boolean().default(false),
messagePrefix: z.string().optional(),
}),
ui: z.object({
theme: z.enum(['dark', 'light', 'system']).default('dark'),
showIcons: z.boolean().default(true),
}),
});

export type Config = z.infer<typeof ConfigSchema>;

export class ConfigService {
private static readonly CONFIG_PATH = path.join(os.homedir(), '.aigitrc');
private config: Config | null = null;

constructor() {
this.loadConfig();
}

private loadConfig(): void {
if (!fs.existsSync(ConfigService.CONFIG_PATH)) {
this.config = null;
return;
}

try {
const rawConfig = JSON.parse(fs.readFileSync(ConfigService.CONFIG_PATH, 'utf-8'));
this.config = ConfigSchema.parse(rawConfig);
} catch (error) {
throw new Error(`Invalid configuration file at ${ConfigService.CONFIG_PATH}: ${error}`);
}
}

public getConfig(): Config {
if (!this.config) {
throw new Error("Configuration not initialized. Please run 'ai-git init'.");
}
return this.config;
}

public saveConfig(newConfig: Config): void {
const validated = ConfigSchema.parse(newConfig);
fs.writeFileSync(ConfigService.CONFIG_PATH, JSON.stringify(validated, null, 2), { mode: 0o600 });
this.config = validated;
}
}
Loading
Loading