diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 96ffb23..de775dd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,7 @@ jobs: strategy: matrix: - node-version: [18.x, 20.x] + node-version: [20.x, 22.x] steps: - name: Checkout repository diff --git a/.gitignore b/.gitignore index 9a5aced..e2273be 100644 --- a/.gitignore +++ b/.gitignore @@ -82,6 +82,9 @@ out .nuxt dist +# Local test build output +dist-test + # Gatsby files .cache/ # Comment in the public line in if your project uses Gatsby and not Next.js diff --git a/git-ai/LICENSE b/git-ai/LICENSE new file mode 100644 index 0000000..6a1df0d --- /dev/null +++ b/git-ai/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 BeyteFlow + +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. diff --git a/git-ai/README.md b/git-ai/README.md new file mode 100644 index 0000000..08e868e --- /dev/null +++ b/git-ai/README.md @@ -0,0 +1,80 @@ +# git-ai + +AI metadata for your git history. + +This CLI stores AI attribution (model, prompt, intent) in `git notes` so it can be queried later and shared across a team without changing code. + +## Install + +From the `git-ai/` folder: + +```bash +npm install +npm run build +npm link +``` + +## Commands + +This repo exposes both `ai-git` (standalone assistant) and `git-ai` (git subcommand). + +Use the git subcommand form: + +```bash +git ai log +git ai blame +git ai inspect +``` + +### Attribution Workflow + +1. Record attribution for a commit: + +```bash +git ai record --intent "refactor" --prompt "simplify parser" --path src/parser.ts --lines-from-file +``` + +2. View attribution timeline: + +```bash +git ai log -n 200 +git ai log --model gemini-1.5-flash --since 2026-01-01T00:00:00Z +``` + +3. Inspect the full record: + +```bash +git ai inspect --commit HEAD +``` + +4. AI blame ("why does this code exist"): + +```bash +git ai blame src/parser.ts +``` + +### Sharing Notes Across Remotes + +Git notes are stored in `refs/notes/git-ai`. + +```bash +git push origin refs/notes/git-ai +git fetch origin refs/notes/git-ai:refs/notes/git-ai +git config --add notes.displayRef refs/notes/git-ai +git config --add notes.rewriteRef refs/notes/git-ai +``` + +The `notes.rewriteRef` line helps preserve notes when commits are rewritten (rebase/cherry-pick). + +### Export/Import + +```bash +git ai export --out audit.jsonl +git ai import --file audit.jsonl +``` + +## Design Notes + +1. Storage: git notes (`refs/notes/git-ai`) +1. Versioning: schema has `v: 1` +1. Survivability: records can include line-hash anchors so attribution can be correlated even after refactors diff --git a/git-ai/package.json b/git-ai/package.json index f6f9ec0..333c66c 100644 --- a/git-ai/package.json +++ b/git-ai/package.json @@ -3,18 +3,30 @@ "version": "1.0.0", "description": "AI-Powered Visual Git CLI for modern developers", "type": "module", + "files": [ + "dist/", + "scripts/", + "package.json", + "README.md", + "LICENSE" + ], "bin": { - "ai-git": "./dist/index.js" + "ai-git": "./dist/index.js", + "git-ai": "./dist/index.js" }, "engines": { "node": ">=20.0.0" }, "scripts": { "dev": "tsx src/index.ts", - "build": "tsc", + "clean": "node ./scripts/clean.mjs", + "clean:dist": "node ./scripts/clean.mjs dist", + "clean:test": "node ./scripts/clean.mjs dist-test", + "build": "npm run clean:dist -s && tsc -p tsconfig.build.json", + "build:test": "npm run clean:test -s && tsc -p tsconfig.test.json", "start": "node dist/index.js", "lint": "tsc --noEmit", - "test": "npm run lint", + "test": "npm run build:test -s && node ./scripts/run-tests.mjs dist-test", "prepare": "npm run build" }, "keywords": [ @@ -52,4 +64,4 @@ "tsx": "^4.7.1", "typescript": "^5.4.0" } -} \ No newline at end of file +} diff --git a/git-ai/scripts/clean.mjs b/git-ai/scripts/clean.mjs new file mode 100644 index 0000000..98032dc --- /dev/null +++ b/git-ai/scripts/clean.mjs @@ -0,0 +1,17 @@ +import { rm } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const projectRoot = join(__dirname, '..'); +const target = process.argv[2] || 'dist'; + +const allowed = new Set(['dist', 'dist-test']); +if (!allowed.has(target)) { + console.error(`Refusing to delete unknown path: ${target}`); + process.exitCode = 1; +} else { + await rm(join(projectRoot, target), { recursive: true, force: true }); +} diff --git a/git-ai/scripts/run-tests.mjs b/git-ai/scripts/run-tests.mjs new file mode 100644 index 0000000..8aa1dd3 --- /dev/null +++ b/git-ai/scripts/run-tests.mjs @@ -0,0 +1,43 @@ +import { spawn } from 'node:child_process'; +import { readdir } from 'node:fs/promises'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const projectRoot = join(__dirname, '..'); +const distArg = process.argv[2]; +const distDir = distArg ? resolve(projectRoot, distArg) : join(projectRoot, 'dist'); + +async function collectTestFiles(dir) { + const entries = await readdir(dir, { withFileTypes: true }); + const files = []; + for (const e of entries) { + const p = join(dir, e.name); + if (e.isDirectory()) { + files.push(...(await collectTestFiles(p))); + } else if (e.isFile() && e.name.endsWith('.test.js')) { + files.push(p); + } + } + return files; +} + +const testFiles = await collectTestFiles(distDir); + +if (testFiles.length === 0) { + console.error('No test files found in dist. Expected at least one *.test.js'); + process.exitCode = 1; +} else { + const child = spawn(process.execPath, ['--test', ...testFiles], { + stdio: 'inherit', + cwd: projectRoot, + }); + + const code = await new Promise((resolve) => { + child.on('close', resolve); + }); + + process.exitCode = typeof code === 'number' ? code : 1; +} diff --git a/git-ai/src/ai/__tests__/notes-rewrite.test.ts b/git-ai/src/ai/__tests__/notes-rewrite.test.ts new file mode 100644 index 0000000..0133825 --- /dev/null +++ b/git-ai/src/ai/__tests__/notes-rewrite.test.ts @@ -0,0 +1,67 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import os from 'node:os'; +import path from 'node:path'; +import fs from 'node:fs/promises'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { GitService } from '../../core/GitService.js'; +import { AiNotesStore } from '../notes-store.js'; +import { AttributionService } from '../attribution.js'; + +const execFileAsync = promisify(execFile); + +async function runGit(cwd: string, args: string[]): Promise { + const { stdout } = await execFileAsync('git', args, { cwd }); + return String(stdout ?? ''); +} + +test('git notes rewrite (amend) carries refs/notes/git-ai', async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'git-ai-test-')); + try { + await runGit(dir, ['init']); + await runGit(dir, ['config', 'user.email', 'test@example.com']); + await runGit(dir, ['config', 'user.name', 'Test User']); + + await fs.writeFile(path.join(dir, 'file.txt'), 'hello\n', 'utf8'); + await runGit(dir, ['add', '.']); + await runGit(dir, ['commit', '-m', 'init']); + const sha1 = (await runGit(dir, ['rev-parse', 'HEAD'])).trim(); + + const git = new GitService(dir); + const store = new AiNotesStore(git); + const attribution = new AttributionService(git); + + const rec = await attribution.buildRecord(sha1, { + provider: 'gemini', + model: 'gemini-1.5-flash', + intent: 'test', + prompt: 'amend rewrite', + path: 'file.txt', + lines: ['hello'], + }); + await store.upsertAttribution(rec); + assert.equal((await store.listIndexForCommit(sha1)).length, 1); + + // Enable notes rewrite for our notes ref and amend. + await runGit(dir, ['config', '--add', 'notes.rewriteRef', 'refs/notes/git-ai']); + await runGit(dir, ['config', 'notes.rewrite.amend', 'true']); + + // Amend commit. Make a tiny content change so the new commit id is guaranteed + // to differ even if git's timestamp resolution is only 1 second. + await fs.writeFile(path.join(dir, 'file.txt'), 'hello!\n', 'utf8'); + await runGit(dir, ['add', '.']); + await runGit(dir, ['commit', '--amend', '--no-edit']); + const sha2 = (await runGit(dir, ['rev-parse', 'HEAD'])).trim(); + assert.notEqual(sha1, sha2); + + const idx2 = await store.listIndexForCommit(sha2); + // If git notes rewrite is working, the note should have been copied. + assert.equal(idx2.length, 1); + const loaded2 = await store.getRecord(sha2, idx2[0].id); + assert.ok(loaded2); + assert.equal(loaded2!.prompt, 'amend rewrite'); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +}); diff --git a/git-ai/src/ai/__tests__/notes-store.test.ts b/git-ai/src/ai/__tests__/notes-store.test.ts new file mode 100644 index 0000000..9d520a6 --- /dev/null +++ b/git-ai/src/ai/__tests__/notes-store.test.ts @@ -0,0 +1,58 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import os from 'node:os'; +import path from 'node:path'; +import fs from 'node:fs/promises'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { GitService } from '../../core/GitService.js'; +import { AiNotesStore } from '../notes-store.js'; +import { AttributionService } from '../attribution.js'; + +const execFileAsync = promisify(execFile); + +async function runGit(cwd: string, args: string[]): Promise { + const { stdout } = await execFileAsync('git', args, { cwd }); + return String(stdout ?? ''); +} + +test('notes store roundtrip', async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'git-ai-test-')); + try { + await runGit(dir, ['init']); + await runGit(dir, ['config', 'user.email', 'test@example.com']); + await runGit(dir, ['config', 'user.name', 'Test User']); + + await fs.writeFile(path.join(dir, 'file.txt'), 'hello\nworld\n', 'utf8'); + await runGit(dir, ['add', '.']); + await runGit(dir, ['commit', '-m', 'init']); + + const sha = (await runGit(dir, ['rev-parse', 'HEAD'])).trim(); + + const git = new GitService(dir); + const store = new AiNotesStore(git); + const attribution = new AttributionService(git); + + const rec = await attribution.buildRecord(sha, { + provider: 'gemini', + model: 'gemini-1.5-flash', + intent: 'test', + prompt: 'generate something', + author: 'Test User', + path: 'file.txt', + lines: ['hello', 'world'], + }); + await store.upsertAttribution(rec); + + const idx = await store.listIndexForCommit(sha); + assert.equal(idx.length, 1); + assert.equal(idx[0].id, rec.id); + + const loaded = await store.getRecord(sha, rec.id); + assert.ok(loaded); + assert.equal(loaded!.prompt, 'generate something'); + assert.equal(loaded!.anchors.lineHashes.length, 2); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +}); diff --git a/git-ai/src/ai/attribution.ts b/git-ai/src/ai/attribution.ts new file mode 100644 index 0000000..b96afdb --- /dev/null +++ b/git-ai/src/ai/attribution.ts @@ -0,0 +1,49 @@ +import { randomUUID } from 'crypto'; +import { GitService } from '../core/GitService.js'; +import { hashLines } from './line-hash.js'; +import { AiAttribution } from './schema.js'; + +export type AttributionInput = { + provider: string; + model: string; + intent: string; + prompt: string; + author?: string; + path?: string; + // Optional raw content lines to anchor the attribution. + lines?: string[]; +}; + +export class AttributionService { + constructor(private git: GitService) {} + + public async buildRecord(commit: string, input: AttributionInput): Promise { + const createdAt = new Date().toISOString(); + const id = randomUUID(); + + // Optional: capture the commit's tree for a second-order anchor. + let tree: string | undefined; + try { + tree = (await this.git.raw(['show', '-s', '--format=%T', commit])).trim() || undefined; + } catch { + tree = undefined; + } + + const lineHashes = input.lines ? hashLines(input.lines) : []; + + return { + v: 1, + id, + commit, + tree, + path: input.path, + provider: input.provider, + model: input.model, + intent: input.intent, + prompt: input.prompt, + author: input.author, + createdAt, + anchors: { lineHashes }, + }; + } +} diff --git a/git-ai/src/ai/line-hash.ts b/git-ai/src/ai/line-hash.ts new file mode 100644 index 0000000..a4a69e6 --- /dev/null +++ b/git-ai/src/ai/line-hash.ts @@ -0,0 +1,18 @@ +import { createHash } from 'crypto'; + +export function normalizeLineForHash(line: string): string { + // Normalize whitespace only; avoid language-specific parsing. + return line.replace(/\s+/g, ' ').trim(); +} + +export function hashLine(line: string): string { + return createHash('sha256').update(normalizeLineForHash(line), 'utf8').digest('hex'); +} + +export function hashLines(lines: string[]): string[] { + // Filter blank lines after normalization so we don't waste anchors on whitespace-only content. + return lines + .map((l) => normalizeLineForHash(l)) + .filter((l) => l.length > 0) + .map((l) => createHash('sha256').update(l, 'utf8').digest('hex')); +} diff --git a/git-ai/src/ai/notes-store.ts b/git-ai/src/ai/notes-store.ts new file mode 100644 index 0000000..d4785f6 --- /dev/null +++ b/git-ai/src/ai/notes-store.ts @@ -0,0 +1,113 @@ +import { z } from 'zod'; +import { GitService } from '../core/GitService.js'; +import { AiAttribution, AiAttributionSchema, AiIndexEntry, AiIndexEntrySchema } from './schema.js'; + +const NOTES_REF = 'refs/notes/git-ai'; + +const NotesPayloadSchema = z.object({ + v: z.literal(1), + // Minimal searchable index for the commit. + index: z.array(AiIndexEntrySchema).default([]), + // Full records keyed by id. + records: z.record(z.string(), AiAttributionSchema).default({}), +}); + +type NotesPayload = z.infer; + +function emptyPayload(): NotesPayload { + return { v: 1, index: [], records: {} }; +} + +function parseJsonOrNull(raw: string): unknown | null { + const trimmed = raw.trim(); + if (!trimmed) return null; + try { + return JSON.parse(trimmed); + } catch { + return null; + } +} + +export class AiNotesStore { + constructor(private git: GitService) {} + + public getNotesRef(): string { + return NOTES_REF; + } + + public async ensureNotesRefExists(): Promise { + // Best-effort: git notes will create the ref on first add. + // This exists mostly for UX checks. + return; + } + + public async readCommitNote(commit: string): Promise { + // `git notes --ref show ` exits non-zero when no note exists. + let out = ''; + try { + out = await this.git.rawQuiet(['notes', '--ref', NOTES_REF, 'show', commit]); + } catch { + return emptyPayload(); + } + + const parsed = parseJsonOrNull(out); + if (!parsed) return emptyPayload(); + + const validated = NotesPayloadSchema.safeParse(parsed); + if (!validated.success) return emptyPayload(); + return validated.data; + } + + public async writeCommitNote(commit: string, payload: NotesPayload): Promise { + const serialized = JSON.stringify(payload); + // Replace existing note. + await this.git.raw(['notes', '--ref', NOTES_REF, 'add', '-f', '-m', serialized, commit]); + } + + public async upsertAttribution(record: AiAttribution): Promise { + const validated = AiAttributionSchema.parse(record); + const existing = await this.readCommitNote(validated.commit); + + const next: NotesPayload = { + ...existing, + v: 1, + records: { ...existing.records, [validated.id]: validated }, + index: this.upsertIndex(existing.index, validated), + }; + + await this.writeCommitNote(validated.commit, next); + } + + private upsertIndex(index: AiIndexEntry[], rec: AiAttribution): AiIndexEntry[] { + const entry: AiIndexEntry = { + id: rec.id, + commit: rec.commit, + path: rec.path, + provider: rec.provider, + model: rec.model, + intent: rec.intent, + author: rec.author, + createdAt: rec.createdAt, + }; + + const next = index.filter((e) => e.id !== rec.id); + next.push(entry); + // Keep stable ordering by createdAt then id. + next.sort((a, b) => (a.createdAt === b.createdAt ? a.id.localeCompare(b.id) : a.createdAt.localeCompare(b.createdAt))); + return next; + } + + public async listIndexForCommit(commit: string): Promise { + const payload = await this.readCommitNote(commit); + return payload.index.map((e) => ({ ...e, commit })); + } + + public async getRecord(commit: string, id: string): Promise { + const payload = await this.readCommitNote(commit); + const rec = payload.records[id]; + if (!rec) return null; + // Validate on read to avoid propagating corrupted notes. + const validated = AiAttributionSchema.safeParse(rec); + return validated.success ? { ...validated.data, commit } : null; + } +} diff --git a/git-ai/src/ai/query.ts b/git-ai/src/ai/query.ts new file mode 100644 index 0000000..fa445ba --- /dev/null +++ b/git-ai/src/ai/query.ts @@ -0,0 +1,20 @@ +import { AiAttribution } from './schema.js'; + +export type AiFilter = { + model?: string; + provider?: string; + author?: string; + intent?: string; + since?: string; // ISO date + until?: string; // ISO date +}; + +export function matchesFilter(rec: Pick, filter: AiFilter): boolean { + if (filter.model && rec.model !== filter.model) return false; + if (filter.provider && rec.provider !== filter.provider) return false; + if (filter.author && (rec.author ?? '') !== filter.author) return false; + if (filter.intent && rec.intent !== filter.intent) return false; + if (filter.since && rec.createdAt < filter.since) return false; + if (filter.until && rec.createdAt > filter.until) return false; + return true; +} diff --git a/git-ai/src/ai/schema.ts b/git-ai/src/ai/schema.ts new file mode 100644 index 0000000..810891a --- /dev/null +++ b/git-ai/src/ai/schema.ts @@ -0,0 +1,50 @@ +import { z } from 'zod'; + +export const AiAttributionSchema = z.object({ + // Schema version for forwards/backwards compatibility. + v: z.literal(1), + + // Stable ID for the attribution record. + id: z.string().min(1), + + // Git object linkage. + commit: z.string().min(7), + tree: z.string().min(7).optional(), + + // Optional file linkage. + // Note: line-level attribution is stored separately via line hashes. + path: z.string().min(1).optional(), + + // Human-level metadata. + provider: z.string().min(1), + model: z.string().min(1), + intent: z.string().min(1), + prompt: z.string().min(1), + + // Actors and time. + author: z.string().min(1).optional(), + createdAt: z.string().min(1), + + // Content anchors for survivability across history rewrites/refactors. + // These are best-effort and allow correlation even when commit ids change. + anchors: z.object({ + // Hashes of the final version lines (or a representative subset). + lineHashes: z.array(z.string().min(1)).default([]), + }).default({ lineHashes: [] }), +}); + +export type AiAttribution = z.infer; + +export const AiIndexEntrySchema = z.object({ + // Minimal entry for indexing and filtering. + id: z.string().min(1), + commit: z.string().min(7), + path: z.string().min(1).optional(), + provider: z.string().min(1), + model: z.string().min(1), + intent: z.string().min(1), + author: z.string().min(1).optional(), + createdAt: z.string().min(1), +}); + +export type AiIndexEntry = z.infer; diff --git a/git-ai/src/cli/pr-command.ts b/git-ai/src/cli/pr-command.ts index fa161b2..6cc1f95 100644 --- a/git-ai/src/cli/pr-command.ts +++ b/git-ai/src/cli/pr-command.ts @@ -15,8 +15,7 @@ export async function runPRCommand(): Promise { 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 remotes = await gitService.getRemotes(true); const origin = remotes.find((r: any) => r.name === 'origin'); if (!origin || !origin.refs.fetch) { @@ -49,4 +48,4 @@ export async function runPRCommand(): Promise { console.error('āŒ Critical Error: Could not launch PR interface.'); process.exit(1); } -} \ No newline at end of file +} diff --git a/git-ai/src/commands/InitCommand.ts b/git-ai/src/commands/InitCommand.ts index 23398e1..3ee8d34 100644 --- a/git-ai/src/commands/InitCommand.ts +++ b/git-ai/src/commands/InitCommand.ts @@ -10,16 +10,22 @@ import { logger } from '../utils/logger.js'; * Falls back to normal readline if stdin is not a TTY (e.g. piped input). */ async function readSecretInput(rl: readline.Interface, prompt: string): Promise { + if (!process.stdin.isTTY) { + return rl.question(prompt); + } + const rlAny = rl as any; const originalWrite = rlAny._writeToOutput; let promptWritten = false; + rlAny._writeToOutput = function _writeToOutput(str: string) { if (!promptWritten) { promptWritten = true; process.stdout.write(str); } - // Suppress all subsequent echoed characters + // Suppress all subsequent echoed characters. }; + try { return await rl.question(prompt); } finally { @@ -45,7 +51,11 @@ function atomicWriteFileSync(filePath: string, content: string): void { } fs.renameSync(tempPath, filePath); } catch (error) { - try { fs.unlinkSync(tempPath); } catch { /* best-effort cleanup */ } + try { + fs.unlinkSync(tempPath); + } catch { + // best-effort cleanup + } throw error; } } @@ -59,33 +69,33 @@ export async function initCommand() { console.log('šŸš€ Welcome to AI-Git-Terminal Setup\n'); try { - // --- Step 1: Read API Key --- + // Gemini API Key (required) let apiKey = ''; while (!apiKey) { const apiKeyInput = await readSecretInput(rl, 'šŸ”‘ Enter your Gemini API Key: '); apiKey = apiKeyInput.trim(); - if (!apiKey) { - console.error('āŒ API key cannot be empty. Please enter a valid key.'); - } + if (!apiKey) console.error('āŒ API key cannot be empty.'); } - // --- Step 2: Read model name --- + // GitHub token (optional; required only for "prs") + const githubTokenInput = await readSecretInput(rl, 'šŸ™ Enter GitHub Personal Access Token (optional): '); + const githubToken = githubTokenInput.trim(); + const modelInput = await rl.question('šŸ¤– Enter model name (default: gemini-1.5-flash): '); const model = modelInput.trim() || 'gemini-1.5-flash'; - // --- Step 3: Build config object --- const newConfig: Config = { ai: { provider: 'gemini', apiKey, model }, + ...(githubToken ? { github: { token: githubToken } } : {}), git: { autoStage: false }, ui: { theme: 'dark', showIcons: true }, }; - // Validate with Zod ConfigSchema.parse(newConfig); const configPath = path.join(os.homedir(), '.aigitrc'); - // --- Step 4: Attempt atomic creation --- + // Attempt atomic create first. try { const fd = fs.openSync(configPath, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_RDWR, 0o600); try { @@ -99,10 +109,8 @@ export async function initCommand() { return; } catch (err: any) { if (err.code !== 'EEXIST') throw err; - // File already exists, proceed to backup/overwrite prompt } - // --- Step 5: Handle existing file --- const overwriteChoice = (await rl.question( 'āš ļø Existing config found. Choose [o]verwrite, [b]ackup then replace, or [c]ancel: ' )).trim().toLowerCase(); @@ -123,10 +131,9 @@ export async function initCommand() { console.log(`\nāœ… Configuration saved to ${configPath}`); console.log('Try running: ai-git commit'); - } catch (error) { - logger.error('Failed to save configuration: ' + (error instanceof Error ? error.message : String(error))); - console.error('\nāŒ Invalid input or failed to write config file.'); + logger.error('Init failed', error as any); + console.error('\nāŒ Setup failed. Check logs for details.'); } finally { rl.close(); } diff --git a/git-ai/src/commands/ResolveCommand.ts b/git-ai/src/commands/ResolveCommand.ts index c8756c4..6dc0599 100644 --- a/git-ai/src/commands/ResolveCommand.ts +++ b/git-ai/src/commands/ResolveCommand.ts @@ -1,60 +1 @@ -import { GitService } from '../core/GitService.js'; -import { AIService } from '../services/AIService.js'; -import { ConfigService } from '../services/ConfigService.js'; -import { ConflictResolver } from '../services/ConflictResolver.js'; - -export async function runResolveCommand() { - const config = new ConfigService(); - const git = new GitService(); - const ai = new AIService(config); - const resolver = new ConflictResolver(ai, git); - - let conflicts; - let skippedFiles: string[] = []; - try { - ({ conflicts, skippedFiles } = await resolver.getConflicts()); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - console.error(`āŒ Failed to list merge conflicts in runResolveCommand: ${message}`); - process.exitCode = 1; - return; - } - - if (skippedFiles.length > 0) { - console.warn(`āš ļø Could not read ${skippedFiles.length} conflicted file(s): ${skippedFiles.join(', ')}`); - } - - if (conflicts.length === 0) { - console.log('āœ… No conflicts found.'); - if (skippedFiles.length > 0) process.exitCode = 1; - return; - } - - const failedFiles: string[] = []; - let successCount = 0; - - for (const conflict of conflicts) { - console.log(`šŸ¤– Resolving: ${conflict.file}...`); - try { - const solution = await resolver.suggestResolution(conflict); - await resolver.applyResolution(conflict.file, solution); - console.log(`āœ… Applied AI fix to ${conflict.file}`); - successCount++; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - console.error(`āŒ Failed to resolve ${conflict.file}: ${message}`); - failedFiles.push(conflict.file); - } - } - - if (successCount > 0) { - console.log(`\nšŸŽ‰ Successfully resolved ${successCount} file(s).`); - } - - if (failedFiles.length > 0 || skippedFiles.length > 0) { - if (failedFiles.length > 0) { - console.error(`āš ļø Resolution failed for ${failedFiles.length} file(s): ${failedFiles.join(', ')}`); - } - process.exitCode = 1; - } -} \ No newline at end of file +export { runResolveCommand } from '../cli/resolve-command.js'; diff --git a/git-ai/src/commands/ai/AiBlameCommand.ts b/git-ai/src/commands/ai/AiBlameCommand.ts new file mode 100644 index 0000000..b518a97 --- /dev/null +++ b/git-ai/src/commands/ai/AiBlameCommand.ts @@ -0,0 +1,189 @@ +import { Command } from 'commander'; +import { GitService } from '../../core/GitService.js'; +import { AiNotesStore } from '../../ai/notes-store.js'; +import { hashLine } from '../../ai/line-hash.js'; + +type BlameOptions = { + commit?: string; + maxCommits?: string; +}; + +// Strip ANSI escape sequences and C0/C1 control characters, then truncate. +function sanitizeForTerminal(value: string, maxLen = 200): string { + // eslint-disable-next-line no-control-regex + const cleaned = value.replace(/\x1b\[[0-9;]*[A-Za-z]|\x1b\].*?(?:\x07|\x1b\\)|[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]/g, ''); + return cleaned.length > maxLen ? cleaned.slice(0, maxLen) + '…' : cleaned; +} + +function toNum(value: string | undefined, fallback: number): number { + if (!value) return fallback; + const n = Number(value); + return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback; +} + +export function buildAiBlameCommand(): Command { + const cmd = new Command('blame') + .description('Explain why code exists by correlating current lines with AI attribution anchors') + .argument('', 'File to blame') + .option('-c, --commit ', 'Commit to blame (default: HEAD)', 'HEAD') + .option('--max-commits ', 'Max commits to scan for notes correlation (default: 200)', '200') + .addHelpText( + 'after', + [ + '', + 'Examples:', + ' ai-git ai blame src/index.ts', + ' ai-git ai blame src/index.ts --commit main --max-commits 500', + ].join('\n') + ) + .action(async (file: string, opts: BlameOptions) => { + const git = new GitService(); + const store = new AiNotesStore(git); + const commit = (opts.commit ?? 'HEAD').trim(); + const maxCommits = toNum(opts.maxCommits, 200); + + // 1) Load file content from the selected commit. + let fileContent = ''; + try { + fileContent = await git.raw(['show', `${commit}:${file}`]); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + console.error(`Failed to read ${file} at ${commit}: ${msg}`); + console.error('Recovery: verify the file exists in that commit.'); + process.exitCode = 1; + return; + } + + // Strip a single trailing newline so the split does not produce a synthetic empty last element. + const trimmedContent = fileContent.replace(/\r?\n$/, ''); + const lines = trimmedContent.split(/\r?\n/); + const lineHashes = lines.map(hashLine); + + // 2) Get commits that last touched the file. + let commits: string[] = []; + try { + const out = await git.raw(['rev-list', `--max-count=${maxCommits}`, commit, '--', file]); + commits = out.split(/\r?\n/).map((l) => l.trim()).filter(Boolean); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + console.error(`Failed to list commits for ${file}: ${msg}`); + process.exitCode = 1; + return; + } + + // 3) Load notes records for those commits and build a reverse index of hash -> record id. + const hashToRecord: Map = new Map(); + const recordMeta: Map< + string, + { commit: string; createdAt: string; intent: string; model: string; provider: string; prompt: string } + > = new Map(); + + for (const c of commits) { + const idx = await store.listIndexForCommit(c); + for (const entry of idx) { + const rec = await store.getRecord(c, entry.id); + if (!rec) continue; + const compositeKey = `${c}:${entry.id}`; + recordMeta.set(compositeKey, { + commit: c, + createdAt: rec.createdAt, + intent: rec.intent, + model: rec.model, + provider: rec.provider, + prompt: rec.prompt, + }); + for (const h of rec.anchors.lineHashes) { + const prev = hashToRecord.get(h) ?? []; + prev.push({ commit: c, id: entry.id }); + hashToRecord.set(h, prev); + } + } + } + + // 4) Annotate each line with best match (first record that claims the hash). + const annotated: { lineNo: number; line: string; recordKey?: string }[] = []; + for (let i = 0; i < lines.length; i++) { + const h = lineHashes[i]; + const matches = hashToRecord.get(h); + const recordKey = pickDeterministicRecordKey(matches, recordMeta); + annotated.push({ lineNo: i + 1, line: lines[i], recordKey }); + } + + const any = annotated.some((a) => a.recordKey); + if (!any) { + console.log('No AI attribution correlated to current file content.'); + console.log('Tip: record attributions with `ai-git ai record --lines-from-file --path ...`'); + return; + } + + // Print a compact blame-like output. Group consecutive lines by record. + let currentKey: string | undefined; + let blockStart = 1; + for (let i = 0; i < annotated.length; i++) { + const key = annotated[i].recordKey; + if (i === 0) { + currentKey = key; + blockStart = 1; + continue; + } + if (key !== currentKey) { + printBlock(blockStart, i, currentKey, recordMeta); + currentKey = key; + blockStart = i + 1; + } + } + printBlock(blockStart, annotated.length, currentKey, recordMeta); + }); + + return cmd; +} + +function printBlock( + startLine: number, + endLine: number, + recordKey: string | undefined, + recordMeta: Map +): void { + const range = startLine === endLine ? `${startLine}` : `${startLine}-${endLine}`; + if (!recordKey) { + console.log(`${range} (no-ai) `); + return; + } + const meta = recordMeta.get(recordKey); + if (!meta) { + console.log(`${range} ${sanitizeForTerminal(recordKey)} (missing metadata)`); + return; + } + const short = meta.commit.substring(0, 12); + const provider = sanitizeForTerminal(meta.provider); + const model = sanitizeForTerminal(meta.model); + const intent = sanitizeForTerminal(meta.intent); + const prompt = sanitizeForTerminal(meta.prompt.replace(/\s+/g, ' ').trim()); + const colonIdx = recordKey.indexOf(':'); + const recordId = colonIdx >= 0 ? recordKey.substring(colonIdx + 1) : recordKey; + console.log(`${range} ${sanitizeForTerminal(recordId)} ${provider}/${model} intent=${intent} commit=${short}`); + // Print prompt on its own line to keep the blame output readable. + console.log(` prompt: ${prompt}`); +} + +function pickDeterministicRecordKey( + matches: { commit: string; id: string }[] | undefined, + recordMeta: Map +): string | undefined { + if (!matches || matches.length === 0) return undefined; + + // Deterministic selection when multiple records claim the same line hash. + // Prefer the newest record by createdAt (numeric timestamp), then by id. + const sorted = [...matches].sort((a, b) => { + const aKey = `${a.commit}:${a.id}`; + const bKey = `${b.commit}:${b.id}`; + const aRaw = recordMeta.get(aKey)?.createdAt ?? ''; + const bRaw = recordMeta.get(bKey)?.createdAt ?? ''; + const aTime = Number.isFinite(Date.parse(aRaw)) ? Date.parse(aRaw) : 0; + const bTime = Number.isFinite(Date.parse(bRaw)) ? Date.parse(bRaw) : 0; + if (aTime !== bTime) return bTime - aTime; // newest first + return a.id.localeCompare(b.id); + }); + const best = sorted[0]; + return best ? `${best.commit}:${best.id}` : undefined; +} diff --git a/git-ai/src/commands/ai/AiExplainCommand.ts b/git-ai/src/commands/ai/AiExplainCommand.ts new file mode 100644 index 0000000..2d1beec --- /dev/null +++ b/git-ai/src/commands/ai/AiExplainCommand.ts @@ -0,0 +1,117 @@ +import { Command } from 'commander'; +import { GitService } from '../../core/GitService.js'; +import { ConfigService } from '../../services/ConfigService.js'; +import { AIService } from '../../services/AIService.js'; +import { AiNotesStore } from '../../ai/notes-store.js'; + +type ExplainOptions = { + commit?: string; + maxCommits?: string; +}; + +function toNum(value: string | undefined, fallback: number): number { + if (!value) return fallback; + const n = Number(value); + return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback; +} + +export function buildAiExplainCommand(): Command { + const cmd = new Command('explain') + .description('Reconstruct high-level reasoning behind a file using stored AI metadata') + .argument('', 'File to explain') + .option('-c, --commit ', 'Commit to use (default: HEAD)', 'HEAD') + .option('--max-commits ', 'Max commits to scan for attribution (default: 200)', '200') + .addHelpText( + 'after', + [ + '', + 'Examples:', + ' ai-git ai explain src/parser.ts', + ].join('\n') + ) + .action(async (file: string, opts: ExplainOptions) => { + const git = new GitService(); + const store = new AiNotesStore(git); + const commit = (opts.commit ?? 'HEAD').trim(); + const maxCommits = toNum(opts.maxCommits, 200); + + let fileContent = ''; + try { + fileContent = await git.raw(['show', `${commit}:${file}`]); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + console.error(`Failed to read ${file} at ${commit}: ${msg}`); + process.exitCode = 1; + return; + } + + let commits: string[] = []; + try { + const out = await git.raw(['rev-list', `--max-count=${maxCommits}`, commit, '--', file]); + commits = out.split(/\r?\n/).map((l) => l.trim()).filter(Boolean); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + console.error(`Failed to list commits for ${file}: ${msg}`); + process.exitCode = 1; + return; + } + + // Collect relevant records (best-effort: those that either target the path or exist on touching commits). + const records: any[] = []; + for (const c of commits) { + const idx = await store.listIndexForCommit(c); + for (const entry of idx) { + if (entry.path && entry.path !== file) continue; + const rec = await store.getRecord(c, entry.id); + if (!rec) continue; + records.push({ noteCommit: c, ...rec }); + } + } + + if (records.length === 0) { + console.log('No AI attribution records found for this file.'); + console.log('Tip: attach one with `ai-git ai record --path --lines-from-file ...`'); + return; + } + + let ai: AIService; + try { + const config = new ConfigService(); + ai = new AIService(config); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + console.error(`AI not configured: ${msg}`); + console.error('Recovery: run `ai-git init` (or set ~/.aigitrc) to configure an AI provider.'); + process.exitCode = 1; + return; + } + + const prompt = [ + 'You are helping a developer understand why a file exists and how it evolved.', + 'Summarize intent and reasoning based on the AI attribution records and the current file content.', + '', + 'AI ATTRIBUTION RECORDS (JSON):', + JSON.stringify(records, null, 2), + '', + 'CURRENT FILE CONTENT:', + fileContent, + '', + 'Output:', + '- 3-6 bullet points of why the code exists / intent', + '- note any prompt evolution you can infer', + '- call out risky areas (security/maintainability) if obvious', + '- keep it concise', + ].join('\n'); + + try { + const out = await ai.generateContent(prompt); + console.log(out.trim()); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + console.error(`Failed to generate explanation: ${msg}`); + process.exitCode = 1; + } + }); + + return cmd; +} diff --git a/git-ai/src/commands/ai/AiExportCommand.ts b/git-ai/src/commands/ai/AiExportCommand.ts new file mode 100644 index 0000000..11ebfa5 --- /dev/null +++ b/git-ai/src/commands/ai/AiExportCommand.ts @@ -0,0 +1,70 @@ +import { Command } from 'commander'; +import { GitService } from '../../core/GitService.js'; +import { AiNotesStore } from '../../ai/notes-store.js'; + +type ExportOptions = { + out?: string; + limit?: string; +}; + +function toNum(value: string | undefined, fallback: number): number { + if (!value) return fallback; + const n = Number(value); + return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback; +} + +export function buildAiExportCommand(): Command { + const cmd = new Command('export') + .description('Export AI attribution notes to a JSONL file for audits/analytics') + .option('-o, --out ', 'Output file (default: git-ai-attribution.jsonl)', 'git-ai-attribution.jsonl') + .option('-n, --limit ', 'Number of commits to scan (default: 5000)', '5000') + .addHelpText( + 'after', + [ + '', + 'Examples:', + ' ai-git ai export --out audit.jsonl', + ' ai-git ai export -n 10000', + ].join('\n') + ) + .action(async (opts: ExportOptions) => { + const git = new GitService(); + const store = new AiNotesStore(git); + const outFile = (opts.out ?? 'git-ai-attribution.jsonl').trim(); + const limit = toNum(opts.limit, 5000); + + let commits: string[] = []; + try { + const out = await git.raw(['rev-list', `--max-count=${limit}`, 'HEAD']); + commits = out.split(/\r?\n/).map((l) => l.trim()).filter(Boolean); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + console.error(`āŒ Failed to list commits: ${msg}`); + process.exitCode = 1; + return; + } + + const fs = await import('fs/promises'); + const lines: string[] = []; + + for (const c of commits) { + const idx = await store.listIndexForCommit(c); + for (const entry of idx) { + const rec = await store.getRecord(c, entry.id); + if (!rec) continue; + lines.push(JSON.stringify(rec)); + } + } + + try { + await fs.writeFile(outFile, lines.join('\n') + (lines.length ? '\n' : ''), 'utf8'); + console.log(`Exported ${lines.length} record(s) to ${outFile}`); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + console.error(`āŒ Failed to write ${outFile}: ${msg}`); + process.exitCode = 1; + } + }); + + return cmd; +} diff --git a/git-ai/src/commands/ai/AiImportCommand.ts b/git-ai/src/commands/ai/AiImportCommand.ts new file mode 100644 index 0000000..a365365 --- /dev/null +++ b/git-ai/src/commands/ai/AiImportCommand.ts @@ -0,0 +1,65 @@ +import { Command } from 'commander'; +import { GitService } from '../../core/GitService.js'; +import { AiNotesStore } from '../../ai/notes-store.js'; +import { AiAttributionSchema } from '../../ai/schema.js'; + +type ImportOptions = { + file?: string; +}; + +export function buildAiImportCommand(): Command { + const cmd = new Command('import') + .description('Import AI attribution records from a JSONL export into git notes') + .option('-f, --file ', 'Input JSONL file', 'git-ai-attribution.jsonl') + .addHelpText( + 'after', + [ + '', + 'Examples:', + ' ai-git ai import --file audit.jsonl', + ].join('\n') + ) + .action(async (opts: ImportOptions) => { + const git = new GitService(); + const store = new AiNotesStore(git); + const file = (opts.file ?? 'git-ai-attribution.jsonl').trim(); + + const fs = await import('fs/promises'); + let raw = ''; + try { + raw = await fs.readFile(file, 'utf8'); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + console.error(`Failed to read ${file}: ${msg}`); + process.exitCode = 1; + return; + } + + const lines = raw.split(/\r?\n/).map((l) => l.trim()).filter(Boolean); + let ok = 0; + let bad = 0; + + let lineNum = 0; + for (const line of lines) { + lineNum++; + try { + const parsed = JSON.parse(line); + const rec = AiAttributionSchema.parse(parsed); + await store.upsertAttribution(rec); + ok++; + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + console.warn(`Line ${lineNum}: invalid or unparseable (${msg})`); + bad++; + } + } + + console.log(`Imported ${ok} record(s) into ${store.getNotesRef()}`); + if (bad > 0) { + console.warn(`Skipped ${bad} invalid line(s).`); + process.exitCode = 1; + } + }); + + return cmd; +} diff --git a/git-ai/src/commands/ai/AiInspectCommand.ts b/git-ai/src/commands/ai/AiInspectCommand.ts new file mode 100644 index 0000000..979c89b --- /dev/null +++ b/git-ai/src/commands/ai/AiInspectCommand.ts @@ -0,0 +1,45 @@ +import { Command } from 'commander'; +import { GitService } from '../../core/GitService.js'; +import { AiNotesStore } from '../../ai/notes-store.js'; + +type InspectOptions = { + commit?: string; +}; + +export function buildAiInspectCommand(): Command { + const cmd = new Command('inspect') + .description('Show full AI attribution record by id') + .argument('', 'Attribution record id') + .option('-c, --commit ', 'Commit containing the record (default: HEAD)', 'HEAD') + .addHelpText( + 'after', + [ + '', + 'Examples:', + ' ai-git ai inspect 2b3c... --commit HEAD', + ].join('\n') + ) + .action(async (id: string, opts: InspectOptions) => { + const git = new GitService(); + const store = new AiNotesStore(git); + const commit = (opts.commit ?? 'HEAD').trim(); + + try { + const resolved = (await git.raw(['rev-parse', commit])).trim(); + const rec = await store.getRecord(resolved, id); + if (!rec) { + console.error(`Not found: ${id} on ${resolved}`); + console.error('Recovery: use `git ai log` (or `ai-git ai log`) to list records and confirm commit/id.'); + process.exitCode = 2; + return; + } + console.log(JSON.stringify(rec, null, 2)); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + console.error(`Failed to inspect record: ${msg}`); + process.exitCode = 1; + } + }); + + return cmd; +} diff --git a/git-ai/src/commands/ai/AiInteractiveCommand.ts b/git-ai/src/commands/ai/AiInteractiveCommand.ts new file mode 100644 index 0000000..b6ce00f --- /dev/null +++ b/git-ai/src/commands/ai/AiInteractiveCommand.ts @@ -0,0 +1,20 @@ +import React from 'react'; +import { Command } from 'commander'; +import { render } from 'ink'; +import { GitService } from '../../core/GitService.js'; +import { AiNotesStore } from '../../ai/notes-store.js'; +import { AiExplorer } from '../../ui/AiExplorer.js'; + +export function buildAiInteractiveCommand(): Command { + const cmd = new Command('explore') + .description('Interactive exploration mode for AI attribution') + .action(async () => { + const git = new GitService(); + const store = new AiNotesStore(git); + + const { waitUntilExit } = render(React.createElement(AiExplorer, { git, store })); + await waitUntilExit(); + }); + + return cmd; +} diff --git a/git-ai/src/commands/ai/AiLogCommand.ts b/git-ai/src/commands/ai/AiLogCommand.ts new file mode 100644 index 0000000..82d23da --- /dev/null +++ b/git-ai/src/commands/ai/AiLogCommand.ts @@ -0,0 +1,106 @@ +import { Command } from 'commander'; +import { GitService } from '../../core/GitService.js'; +import { AiNotesStore } from '../../ai/notes-store.js'; +import { matchesFilter, AiFilter } from '../../ai/query.js'; +import { AiIndexEntry } from '../../ai/schema.js'; + +type LogOptions = { + limit?: string; + model?: string; + provider?: string; + author?: string; + intent?: string; + since?: string; + until?: string; +}; + +function toNum(value: string | undefined, fallback: number): number { + if (!value) return fallback; + const n = Number(value); + return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback; +} + +export function buildAiLogCommand(): Command { + const cmd = new Command('log') + .description('Show AI attribution attached to commits (from git notes)') + .option('-n, --limit ', 'Number of commits to scan (default: 50)', '50') + .option('--model ', 'Filter by model') + .option('--provider ', 'Filter by provider') + .option('--author ', 'Filter by author') + .option('--intent ', 'Filter by intent') + .option('--since ', 'Filter by createdAt >= ISO time') + .option('--until ', 'Filter by createdAt <= ISO time') + .addHelpText( + 'after', + [ + '', + 'Examples:', + ' ai-git ai log -n 100', + ' ai-git ai log --model gemini-1.5-flash --since 2026-01-01T00:00:00Z', + ].join('\n') + ) + .action(async (opts: LogOptions) => { + const git = new GitService(); + const store = new AiNotesStore(git); + const limit = toNum(opts.limit, 50); + const filter: AiFilter = { + model: opts.model, + provider: opts.provider, + author: opts.author, + intent: opts.intent, + since: opts.since, + until: opts.until, + }; + + // Get recent commits quickly. + let commits: string[] = []; + try { + const out = await git.raw(['rev-list', `--max-count=${limit}`, 'HEAD']); + commits = out.split(/\r?\n/).map((l) => l.trim()).filter(Boolean); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + console.error(`Failed to list commits: ${msg}`); + process.exitCode = 1; + return; + } + + // Reading notes can be I/O-bound; do it concurrently with a small cap. + const concurrency = 8; + const rows: { commit: string; entry: AiIndexEntry }[] = []; + for (let i = 0; i < commits.length; i += concurrency) { + const slice = commits.slice(i, i + concurrency); + const results = await Promise.all( + slice.map(async (commit) => { + const idx = await store.listIndexForCommit(commit); + return { commit, idx }; + }) + ); + + for (const r of results) { + for (const entry of r.idx) { + if (matchesFilter(entry, filter)) rows.push({ commit: r.commit, entry }); + } + } + } + + if (rows.length === 0) { + console.log('No AI attribution found in scanned commits.'); + console.log('Tip: use `ai-git ai record ...` to attach metadata to a commit.'); + return; + } + + for (const row of rows) { + const e = row.entry; + const meta = [ + `${e.provider}/${e.model}`, + e.intent, + e.author ? `by ${e.author}` : undefined, + e.path ? `path=${e.path}` : undefined, + e.createdAt, + ].filter(Boolean).join(' | '); + console.log(`${row.commit.substring(0, 12)} ${e.id} ${meta}`); + } + }); + + return cmd; +} diff --git a/git-ai/src/commands/ai/AiNotesCommand.ts b/git-ai/src/commands/ai/AiNotesCommand.ts new file mode 100644 index 0000000..29eb675 --- /dev/null +++ b/git-ai/src/commands/ai/AiNotesCommand.ts @@ -0,0 +1,63 @@ +import { Command } from 'commander'; +import { GitService } from '../../core/GitService.js'; + +type NotesOptions = { + remote?: string; +}; + +export function buildAiNotesCommand(): Command { + const cmd = new Command('notes') + .description('Help manage sharing AI notes refs across remotes') + .option('--remote ', 'Remote name (default: origin)', 'origin') + .addHelpText( + 'after', + [ + '', + 'Examples:', + ' ai-git ai notes', + ' ai-git ai notes --remote upstream', + '', + 'Suggested commands (run yourself):', + ' git push refs/notes/git-ai', + ' git fetch refs/notes/git-ai:refs/notes/git-ai', + ' git config --add notes.displayRef refs/notes/git-ai', + '', + 'To carry notes through rebases/cherry-picks:', + ' git config --add notes.rewriteRef refs/notes/git-ai', + '', + 'Why: git notes are separate objects; rewriteRef tells git to copy notes when commits are rewritten.', + ].join('\n') + ) + .action(async (opts: NotesOptions) => { + const git = new GitService(); + const remote = (opts.remote ?? 'origin').trim() || 'origin'; + + type GitRemote = { name: string; refs?: { fetch?: string; push?: string } }; + + let remotes: GitRemote[] = []; + try { + remotes = (await git.getRemotes(true)) as GitRemote[]; + } catch { + // ignore + } + const found = remotes.find((r) => r.name === remote); + + console.log('AI notes ref: refs/notes/git-ai'); + console.log(''); + console.log('Share notes with teammates:'); + console.log(` git push ${remote} refs/notes/git-ai`); + console.log(` git fetch ${remote} refs/notes/git-ai:refs/notes/git-ai`); + console.log(''); + console.log('Make notes visible by default:'); + console.log(' git config --add notes.displayRef refs/notes/git-ai'); + console.log(''); + console.log('Preserve notes across rebases/cherry-picks:'); + console.log(' git config --add notes.rewriteRef refs/notes/git-ai'); + console.log(''); + if (!found) { + console.log(`Note: remote '${remote}' not found in this repo.`); + } + }); + + return cmd; +} diff --git a/git-ai/src/commands/ai/AiRecordCommand.ts b/git-ai/src/commands/ai/AiRecordCommand.ts new file mode 100644 index 0000000..02eade8 --- /dev/null +++ b/git-ai/src/commands/ai/AiRecordCommand.ts @@ -0,0 +1,108 @@ +import { Command } from 'commander'; +import { ConfigService } from '../../services/ConfigService.js'; +import { GitService } from '../../core/GitService.js'; +import { AiNotesStore } from '../../ai/notes-store.js'; +import { AttributionService } from '../../ai/attribution.js'; +import { logger } from '../../utils/logger.js'; + +type RecordOptions = { + commit?: string; + path?: string; + intent: string; + prompt: string; + model?: string; + provider?: string; + author?: string; + linesFromFile?: boolean; +}; + +export function buildAiRecordCommand(): Command { + const cmd = new Command('record') + .description('Attach AI attribution metadata to a commit (stored in git notes)') + .option('-c, --commit ', 'Commit to annotate (default: HEAD)') + .option('-p, --path ', 'File path this attribution primarily relates to') + .requiredOption('--intent ', 'Intent (why the code exists)') + .requiredOption('--prompt ', 'Prompt used to generate the code') + .option('--provider ', 'AI provider (default: from config)', undefined) + .option('--model ', 'Model name (default: from config)', undefined) + .option('--author ', 'Human author (default: git user.name)', undefined) + .option('--lines-from-file', 'Anchor using current file lines (best-effort)', false) + .addHelpText( + 'after', + [ + '', + 'Examples:', + ' ai-git ai record --intent "refactor" --prompt "simplify parser" --path src/parser.ts', + ' ai-git ai record --commit HEAD~1 --intent "bugfix" --prompt "fix null deref"', + ].join('\n') + ) + .action(async (opts: RecordOptions) => { + const git = new GitService(); + const store = new AiNotesStore(git); + const attribution = new AttributionService(git); + + const commitish = (opts.commit ?? 'HEAD').trim(); + let commit = commitish; + try { + commit = (await git.raw(['rev-parse', commitish])).trim(); + } catch { + // keep commitish for error reporting below + commit = commitish; + } + + let author = opts.author?.trim(); + if (!author) { + try { + author = (await git.raw(['config', '--get', 'user.name'])).trim() || undefined; + } catch { + author = undefined; + } + } + + // Provider/model defaults come from config when possible. + let provider = opts.provider?.trim(); + let model = opts.model?.trim(); + try { + const config = new ConfigService().getConfig(); + provider ||= config.ai.provider; + model ||= config.ai.model ?? 'unknown'; + } catch { + provider ||= 'unknown'; + model ||= 'unknown'; + } + + let lines: string[] | undefined; + if (opts.linesFromFile && opts.path) { + try { + // Anchor to the version of the file in the annotated commit. + // This improves survivability and makes the attribution reproducible. + const raw = await git.raw(['show', `${commit}:${opts.path}`]); + lines = raw.split(/\r?\n/); + } catch (error) { + logger.warn({ err: error }, `Failed to read --lines-from-file path: ${opts.path}`); + } + } + + try { + const rec = await attribution.buildRecord(commit, { + provider, + model, + intent: opts.intent, + prompt: opts.prompt, + author, + path: opts.path, + lines, + }); + await store.upsertAttribution(rec); + console.log(`Recorded AI attribution on ${commit}: ${rec.id}`); + console.log(`Notes ref: ${store.getNotesRef()}`); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + console.error(`Failed to record attribution: ${msg}`); + console.error(`Recovery: ensure you are in a git repository and the commit exists (${commitish}).`); + process.exitCode = 1; + } + }); + + return cmd; +} diff --git a/git-ai/src/commands/ai/AiScanCommand.ts b/git-ai/src/commands/ai/AiScanCommand.ts new file mode 100644 index 0000000..730371c --- /dev/null +++ b/git-ai/src/commands/ai/AiScanCommand.ts @@ -0,0 +1,78 @@ +import { Command } from 'commander'; +import { GitService } from '../../core/GitService.js'; + +type ScanOptions = { + commit?: string; +}; + +const RISK_RULES: { id: string; pattern: RegExp; message: string }[] = [ + { id: 'eval', pattern: /\beval\s*\(/, message: 'Use of eval() can lead to code injection.' }, + { id: 'child_process', pattern: /\bchild_process\b|\bexec\s*\(|\bexecSync\s*\(|\bspawn\s*\(/, message: 'Spawning processes is risky; validate inputs.' }, + { id: 'shell', pattern: /\bshell\s*:\s*true/, message: 'shell:true can enable command injection.' }, + { id: 'insecure_random', pattern: /\bMath\.random\b/, message: 'Math.random is not cryptographically secure.' }, + { id: 'deserialize', pattern: /\b(YAML\.load|pickle\.loads|Marshal\.load)\b/, message: 'Unsafe deserialization is a common RCE vector.' }, + { id: 'sql_concat', pattern: /SELECT\s+.*\+\s*\w+|INSERT\s+.*\+\s*\w+|UPDATE\s+.*\+\s*\w+|DELETE\s+.*\+\s*\w+/i, message: 'String-concatenated SQL can enable injection; use parameters.' }, +]; + +function toGlobal(pattern: RegExp): RegExp { + return pattern.global ? pattern : new RegExp(pattern.source, `${pattern.flags}g`); +} + +export function buildAiScanCommand(): Command { + const cmd = new Command('scan') + .description('Heuristic scan for risky patterns in current staged diff (best-effort)') + .option('-c, --commit ', 'Commit to scan diff for (default: HEAD)', 'HEAD') + .addHelpText( + 'after', + [ + '', + 'Examples:', + ' ai-git ai scan', + ].join('\n') + ) + .action(async (opts: ScanOptions, command: Command) => { + const git = new GitService(); + const commit = (opts.commit ?? 'HEAD').trim(); + const commitExplicit = command.getOptionValueSource('commit') === 'cli'; + + let diff = ''; + try { + if (commitExplicit) { + // User explicitly passed --commit; scan that commit's diff directly. + diff = await git.raw(['show', '--format=', commit]); + } else { + // Default: scan staged diff. If empty, fall back to last commit patch. + diff = await git.raw(['diff', '--cached']); + if (!diff.trim()) { + diff = await git.raw(['show', '--format=', commit]); + } + } + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + console.error(`Failed to obtain diff: ${msg}`); + process.exitCode = 1; + return; + } + + const hits: { rule: string; message: string; sample: string }[] = []; + for (const rule of RISK_RULES) { + for (const m of diff.matchAll(toGlobal(rule.pattern))) { + hits.push({ rule: rule.id, message: rule.message, sample: m[0] }); + } + } + + if (hits.length === 0) { + console.log('No risky patterns detected (heuristic scan).'); + return; + } + + console.log('Potential risks detected:'); + for (const hit of hits) { + console.log(`- ${hit.rule}: ${hit.message} (e.g. ${hit.sample})`); + } + + process.exitCode = 2; + }); + + return cmd; +} diff --git a/git-ai/src/commands/ai/AiValidateCommand.ts b/git-ai/src/commands/ai/AiValidateCommand.ts new file mode 100644 index 0000000..df7392a --- /dev/null +++ b/git-ai/src/commands/ai/AiValidateCommand.ts @@ -0,0 +1,64 @@ +import { Command } from 'commander'; +import { GitService } from '../../core/GitService.js'; +import { AiNotesStore } from '../../ai/notes-store.js'; + +type ValidateOptions = { + limit?: string; +}; + +function toNum(value: string | undefined, fallback: number): number { + if (!value) return fallback; + const n = Number(value); + return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback; +} + +export function buildAiValidateCommand(): Command { + const cmd = new Command('validate') + .description('Validate AI notes schema (useful in CI)') + .option('-n, --limit ', 'Number of commits to scan (default: 500)', '500') + .addHelpText( + 'after', + [ + '', + 'Examples:', + ' ai-git ai validate -n 2000', + ].join('\n') + ) + .action(async (opts: ValidateOptions) => { + const git = new GitService(); + const store = new AiNotesStore(git); + const limit = toNum(opts.limit, 500); + + let commits: string[] = []; + try { + const out = await git.raw(['rev-list', `--max-count=${limit}`, 'HEAD']); + commits = out.split(/\r?\n/).map((l) => l.trim()).filter(Boolean); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + console.error(`Failed to list commits: ${msg}`); + process.exitCode = 1; + return; + } + + let ok = 0; + let bad = 0; + + for (const c of commits) { + const idx = await store.listIndexForCommit(c); + for (const entry of idx) { + const rec = await store.getRecord(c, entry.id); + if (!rec) { + bad++; + console.error(`Invalid: missing record payload for ${entry.id} on ${c}`); + continue; + } + ok++; + } + } + + console.log(`Validated records: ok=${ok} bad=${bad}`); + if (bad > 0) process.exitCode = 2; + }); + + return cmd; +} diff --git a/git-ai/src/commands/ai/index.ts b/git-ai/src/commands/ai/index.ts new file mode 100644 index 0000000..67905a2 --- /dev/null +++ b/git-ai/src/commands/ai/index.ts @@ -0,0 +1,45 @@ +import { Command } from 'commander'; +import { buildAiLogCommand } from './AiLogCommand.js'; +import { buildAiInspectCommand } from './AiInspectCommand.js'; +import { buildAiBlameCommand } from './AiBlameCommand.js'; +import { buildAiRecordCommand } from './AiRecordCommand.js'; +import { buildAiExportCommand } from './AiExportCommand.js'; +import { buildAiImportCommand } from './AiImportCommand.js'; +import { buildAiInteractiveCommand } from './AiInteractiveCommand.js'; +import { buildAiNotesCommand } from './AiNotesCommand.js'; +import { buildAiExplainCommand } from './AiExplainCommand.js'; +import { buildAiValidateCommand } from './AiValidateCommand.js'; +import { buildAiScanCommand } from './AiScanCommand.js'; + +export function buildAiCommand(): Command { + const ai = new Command('ai') + .description('AI attribution and reasoning metadata (git notes based)'); + + ai.addCommand(buildAiLogCommand()); + ai.addCommand(buildAiBlameCommand()); + ai.addCommand(buildAiInspectCommand()); + ai.addCommand(buildAiRecordCommand()); + ai.addCommand(buildAiExportCommand()); + ai.addCommand(buildAiImportCommand()); + ai.addCommand(buildAiInteractiveCommand()); + ai.addCommand(buildAiNotesCommand()); + ai.addCommand(buildAiExplainCommand()); + ai.addCommand(buildAiValidateCommand()); + ai.addCommand(buildAiScanCommand()); + + ai.addHelpText( + 'after', + [ + '', + 'Notes storage:', + ' This tool stores metadata in `git notes --ref refs/notes/git-ai`.', + ' Preserve notes across rebases/amends with:', + ' git config --add notes.rewriteRef refs/notes/git-ai', + ' Share notes with teammates by pushing/fetching that ref:', + ' git push origin refs/notes/git-ai', + ' git fetch origin refs/notes/git-ai:refs/notes/git-ai', + ].join('\n') + ); + + return ai; +} diff --git a/git-ai/src/core/GitService.ts b/git-ai/src/core/GitService.ts index a8b2d98..72116e4 100644 --- a/git-ai/src/core/GitService.ts +++ b/git-ai/src/core/GitService.ts @@ -1,5 +1,23 @@ import { simpleGit, SimpleGit, StatusResult, LogResult, BranchSummary } from 'simple-git'; -import { logger } from './../utils/logger.js'; +import { logger } from '../utils/logger.js'; + +function redactGitArgs(args: string[]): string[] { + // `git notes add -m ` can contain prompts/metadata. Avoid logging secrets. + const redacted: string[] = []; + for (let i = 0; i < args.length; i++) { + const a = args[i] ?? ''; + redacted.push(a); + + if (a === '-m' || a === '--message' || a === '-F' || a === '--file') { + const next = args[i + 1]; + if (typeof next === 'string') { + redacted.push(''); + i++; + } + } + } + return redacted; +} export class GitService { private git: SimpleGit; @@ -8,6 +26,28 @@ export class GitService { this.git = simpleGit(workingDir); } + /** Run an arbitrary git command and capture stdout. */ + public async raw(args: string[]): Promise { + try { + return await this.git.raw(args); + } catch (error) { + const details = error instanceof Error ? error.message : String(error); + logger.error(`Failed to run git ${redactGitArgs(args).join(' ')}: ${details}`); + throw error; + } + } + + /** Run an arbitrary git command without logging on failure (caller handles errors). */ + public async rawQuiet(args: string[]): Promise { + return this.git.raw(args); + } + + public async getRemotes(verbose: boolean = true): Promise { + // simple-git uses overloads for verbose true/false; keep a single ergonomic API. + const v = Boolean(verbose); + return v ? this.git.getRemotes(true) : this.git.getRemotes(false); + } + public async getStatus(): Promise { try { return await this.git.status(); @@ -52,4 +92,4 @@ export class GitService { throw error; } } -} \ No newline at end of file +} diff --git a/git-ai/src/index.ts b/git-ai/src/index.ts index 718b4e2..a510cca 100644 --- a/git-ai/src/index.ts +++ b/git-ai/src/index.ts @@ -1,35 +1,91 @@ #!/usr/bin/env node import { Command } from 'commander'; +import chalk from 'chalk'; +import path from 'path'; import { commitCommand } from './commands/CommitCommand.js'; import { runPRCommand } from './cli/pr-command.js'; import { runResolveCommand } from './commands/ResolveCommand.js'; import { initCommand } from './commands/InitCommand.js'; +import { treeCommand } from './commands/TreeCommand.js'; +import { buildAiCommand } from './commands/ai/index.js'; +import { buildAiLogCommand } from './commands/ai/AiLogCommand.js'; +import { buildAiBlameCommand } from './commands/ai/AiBlameCommand.js'; +import { buildAiInspectCommand } from './commands/ai/AiInspectCommand.js'; +import { buildAiRecordCommand } from './commands/ai/AiRecordCommand.js'; +import { buildAiExportCommand } from './commands/ai/AiExportCommand.js'; +import { buildAiImportCommand } from './commands/ai/AiImportCommand.js'; +import { buildAiInteractiveCommand } from './commands/ai/AiInteractiveCommand.js'; +import { buildAiNotesCommand } from './commands/ai/AiNotesCommand.js'; +import { buildAiExplainCommand } from './commands/ai/AiExplainCommand.js'; +import { buildAiValidateCommand } from './commands/ai/AiValidateCommand.js'; +import { buildAiScanCommand } from './commands/ai/AiScanCommand.js'; const program = new Command(); -program - .name('ai-git') - .description('AI-Powered Git CLI Assistant') - .version('1.0.0'); +const VERSION = '1.0.0'; -program - .command('commit') - .description('Generate AI commit message for staged changes') - .action(commitCommand); +const binPath = process.argv[1] ? path.basename(process.argv[1]) : 'ai-git'; +const nameFromBin = binPath.toLowerCase().includes('git-ai') ? 'git-ai' : 'ai-git'; -program - .command('prs') - .description('Interactively list and view GitHub PRs') - .action(runPRCommand); +// Avoid noisy banners for non-interactive usage. +if (process.stdout.isTTY) { + const banner = ` + ${chalk.bold.magenta('ā—')} ${chalk.bold(nameFromBin.toUpperCase())} ${chalk.dim(`v${VERSION}`)} + ${chalk.dim('————————————————————————————————')} +`; + console.log(banner); +} program - .command('resolve') - .description('Analyze and resolve merge conflicts using AI') - .action(runResolveCommand); + .name(nameFromBin) + .description( + nameFromBin === 'git-ai' + ? 'AI attribution metadata for git (use via: git ai )' + : 'AI-Powered Git CLI Assistant' + ) + .version(VERSION); -program - .command('init') - .description('Initialize AI-Git-Terminal with API keys and preferences') - .action(initCommand); +if (nameFromBin === 'git-ai') { + // Git subcommand mode: `git ai ` executes `git-ai `. + program.addCommand(buildAiLogCommand()); + program.addCommand(buildAiBlameCommand()); + program.addCommand(buildAiInspectCommand()); + program.addCommand(buildAiRecordCommand()); + program.addCommand(buildAiExportCommand()); + program.addCommand(buildAiImportCommand()); + program.addCommand(buildAiInteractiveCommand()); + program.addCommand(buildAiNotesCommand()); + program.addCommand(buildAiExplainCommand()); + program.addCommand(buildAiValidateCommand()); + program.addCommand(buildAiScanCommand()); +} else { + // Standalone mode: keep existing commands and also provide `ai` namespace. + program.addCommand(buildAiCommand()); + + program + .command('commit') + .description('Generate AI commit message for staged changes') + .action(commitCommand); + + program + .command('prs') + .description('Interactively list and view GitHub PRs') + .action(runPRCommand); + + program + .command('resolve') + .description('Analyze and resolve merge conflicts using AI') + .action(runResolveCommand); + + program + .command('init') + .description('Initialize AI-Git-Terminal with API keys and preferences') + .action(initCommand); + + program + .command('tree') + .description('Visualize git branches') + .action(treeCommand); +} -program.parse(process.argv); \ No newline at end of file +program.parse(process.argv); diff --git a/git-ai/src/ui/AiExplorer.tsx b/git-ai/src/ui/AiExplorer.tsx new file mode 100644 index 0000000..5d5797b --- /dev/null +++ b/git-ai/src/ui/AiExplorer.tsx @@ -0,0 +1,153 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { Box, Text, useInput } from 'ink'; +import { GitService } from '../core/GitService.js'; +import { AiNotesStore } from '../ai/notes-store.js'; +import type { AiIndexEntry } from '../ai/schema.js'; + +type Props = { + git: GitService; + store: AiNotesStore; +}; + +type Row = { + commit: string; + entry: AiIndexEntry; +}; + +export const AiExplorer: React.FC = ({ git, store }) => { + const [rows, setRows] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [selected, setSelected] = useState(0); + const [details, setDetails] = useState(''); + const [listOffset, setListOffset] = useState(0); + + const rowsRef = useRef([]); + useEffect(() => { + rowsRef.current = rows; + }, [rows]); + + const selectedRef = useRef(0); + useEffect(() => { + selectedRef.current = selected; + }, [selected]); + + useEffect(() => { + let alive = true; + async function load(): Promise { + try { + const out = await git.raw(['rev-list', '--max-count=200', 'HEAD']); + const commits = out.split(/\r?\n/).map((l) => l.trim()).filter(Boolean); + const loaded: Row[] = []; + + for (const c of commits) { + const idx = await store.listIndexForCommit(c); + for (const entry of idx) loaded.push({ commit: c, entry }); + } + + if (!alive) return; + setRows(loaded); + setError(null); + } catch (e) { + if (!alive) return; + setError(e instanceof Error ? e.message : String(e)); + } finally { + if (alive) setLoading(false); + } + } + load(); + return () => { + alive = false; + }; + }, [git, store]); + + const selectedRow = useMemo(() => rows[selected] ?? null, [rows, selected]); + + useEffect(() => { + let alive = true; + async function loadDetails(): Promise { + if (!selectedRow) { + setDetails(''); + return; + } + try { + const rec = await store.getRecord(selectedRow.commit, selectedRow.entry.id); + if (!alive) return; + setDetails(rec ? JSON.stringify(rec, null, 2) : '(missing record payload)'); + } catch (e) { + if (!alive) return; + setDetails(e instanceof Error ? e.message : String(e)); + } + } + loadDetails(); + return () => { + alive = false; + }; + }, [selectedRow, store]); + + useEffect(() => { + // Keep selected row within the visible window. + const windowSize = 30; + setListOffset((prev) => { + const maxStart = Math.max(0, rows.length - windowSize); + const clampedPrev = Math.min(Math.max(0, prev), maxStart); + + if (selected < clampedPrev) return selected; + if (selected >= clampedPrev + windowSize) { + return Math.min(maxStart, selected - windowSize + 1); + } + return clampedPrev; + }); + }, [rows.length, selected]); + + useInput((_input, key) => { + const current = rowsRef.current; + if (key.escape || (key.ctrl && _input === 'c')) { + process.exit(0); + } + if (current.length === 0) return; + if (key.upArrow) { + setSelected((prev) => (prev > 0 ? prev - 1 : current.length - 1)); + } + if (key.downArrow) { + setSelected((prev) => (prev < current.length - 1 ? prev + 1 : 0)); + } + }); + + if (loading) return Loading AI attribution...; + if (error) return āŒ {error}; + if (rows.length === 0) { + return ( + + No AI attribution found. + Tip: use `ai-git ai record ...` then come back here. + + ); + } + + return ( + + + AI Attribution + {rows.slice(listOffset, listOffset + 30).map((r, idx) => { + const i = listOffset + idx; + const isSelected = i === selected; + return ( + + {isSelected ? 'āÆ ' : ' '} + {r.commit.substring(0, 8)} {r.entry.provider}/{r.entry.model} {r.entry.intent} + + ); + })} + + ↑/↓ navigate, Esc exit ({Math.min(rows.length, listOffset + 30)}/{rows.length}) + + + + + Details + {details} + + + ); +}; diff --git a/git-ai/src/ui/PRList.tsx b/git-ai/src/ui/PRList.tsx index a95da6c..832b068 100644 --- a/git-ai/src/ui/PRList.tsx +++ b/git-ai/src/ui/PRList.tsx @@ -63,8 +63,8 @@ export const PRList: React.FC = ({ githubService, onSelect }) => { } }); - if (loading) return ā³ Loading Pull Requests...; - if (error) return āœ– {error}; + if (loading) return Loading Pull Requests...; + if (error) return {error}; if (prs.length === 0) return No open Pull Requests found.; return ( @@ -89,8 +89,8 @@ export const PRList: React.FC = ({ githubService, onSelect }) => { })} - Use ↑/↓ to navigate, Enter to select + Use Up/Down to navigate, Enter to select ); -}; \ No newline at end of file +}; diff --git a/git-ai/src/ui/TreeUI.tsx b/git-ai/src/ui/TreeUI.tsx index 238a7fe..3e5713c 100644 --- a/git-ai/src/ui/TreeUI.tsx +++ b/git-ai/src/ui/TreeUI.tsx @@ -53,8 +53,8 @@ export const TreeUI: React.FC = ({ gitService }) => { } }, [loading, exit]); - if (loading) return ā³ Mapping branches...; - if (error) return āŒ Failed to build branch tree: {error}; + if (loading) return Mapping branches...; + if (error) return Failed to build branch tree: {error}; return ( @@ -65,4 +65,4 @@ export const TreeUI: React.FC = ({ gitService }) => { Hint: Use "ai-git prs" to see remote PRs for these branches. ); -}; \ No newline at end of file +}; diff --git a/git-ai/tsconfig.build.json b/git-ai/tsconfig.build.json new file mode 100644 index 0000000..4c101e5 --- /dev/null +++ b/git-ai/tsconfig.build.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"], + "exclude": [ + "node_modules", + "dist", + "dist-test", + "src/**/__tests__/**/*", + "src/**/*.test.ts", + "src/**/*.spec.ts" + ] +} diff --git a/git-ai/tsconfig.json b/git-ai/tsconfig.json index 7bea1a6..db109eb 100644 --- a/git-ai/tsconfig.json +++ b/git-ai/tsconfig.json @@ -30,5 +30,5 @@ "sourceMap": true }, "include": ["src/**/*"], - "exclude": ["node_modules", "dist", "**/*.test.ts"] -} \ No newline at end of file + "exclude": ["node_modules", "dist"] +} diff --git a/git-ai/tsconfig.test.json b/git-ai/tsconfig.test.json new file mode 100644 index 0000000..a88f515 --- /dev/null +++ b/git-ai/tsconfig.test.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./dist-test", + "rootDir": "./src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "dist-test"] +}