Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ jobs:

strategy:
matrix:
node-version: [18.x, 20.x]
node-version: [20.x, 22.x]

steps:
- name: Checkout repository
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions git-ai/LICENSE
Original file line number Diff line number Diff line change
@@ -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.
80 changes: 80 additions & 0 deletions git-ai/README.md
Original file line number Diff line number Diff line change
@@ -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 <file>
git ai inspect <id>
```

### 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 <record-id> --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
20 changes: 16 additions & 4 deletions git-ai/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down Expand Up @@ -52,4 +64,4 @@
"tsx": "^4.7.1",
"typescript": "^5.4.0"
}
}
}
17 changes: 17 additions & 0 deletions git-ai/scripts/clean.mjs
Original file line number Diff line number Diff line change
@@ -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 });
}
43 changes: 43 additions & 0 deletions git-ai/scripts/run-tests.mjs
Original file line number Diff line number Diff line change
@@ -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;
}
67 changes: 67 additions & 0 deletions git-ai/src/ai/__tests__/notes-rewrite.test.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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 });
}
});
58 changes: 58 additions & 0 deletions git-ai/src/ai/__tests__/notes-store.test.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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 });
}
});
Loading
Loading