Skip to content

Commit 813f37e

Browse files
committed
feat(core): add git types and extractor infrastructure (#91)
- Add GitCommit, GitFileChange, GitBlame, ContributorStats types - Implement LocalGitExtractor for shelling out to git commands - Support getCommits, getCommit, getBlame, getRepositoryInfo - Extract issue/PR references from commit messages - Parse numstat for file change details - Add 19 comprehensive unit tests Part of Epic: Intelligent Git History (v0.4.0) #90
1 parent 959bfe7 commit 813f37e

File tree

5 files changed

+955
-0
lines changed

5 files changed

+955
-0
lines changed
Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
import { execSync } from 'node:child_process';
2+
import * as fs from 'node:fs';
3+
import * as os from 'node:os';
4+
import * as path from 'node:path';
5+
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
6+
import { LocalGitExtractor } from '../extractor';
7+
8+
describe('LocalGitExtractor', () => {
9+
let testRepoPath: string;
10+
let extractor: LocalGitExtractor;
11+
12+
beforeAll(() => {
13+
// Create a temporary git repository for testing
14+
testRepoPath = fs.mkdtempSync(path.join(os.tmpdir(), 'git-extractor-test-'));
15+
16+
// Initialize git repo
17+
execSync('git init', { cwd: testRepoPath, stdio: 'pipe' });
18+
execSync('git config user.email "[email protected]"', { cwd: testRepoPath, stdio: 'pipe' });
19+
execSync('git config user.name "Test User"', { cwd: testRepoPath, stdio: 'pipe' });
20+
21+
// Create initial commit
22+
fs.writeFileSync(path.join(testRepoPath, 'README.md'), '# Test Repo\n');
23+
execSync('git add README.md', { cwd: testRepoPath, stdio: 'pipe' });
24+
execSync('git commit -m "Initial commit"', { cwd: testRepoPath, stdio: 'pipe' });
25+
26+
// Create a second commit with issue reference
27+
fs.writeFileSync(path.join(testRepoPath, 'file1.ts'), 'export const x = 1;\n');
28+
execSync('git add file1.ts', { cwd: testRepoPath, stdio: 'pipe' });
29+
execSync('git commit -m "feat: add file1 #123"', { cwd: testRepoPath, stdio: 'pipe' });
30+
31+
// Create a third commit with PR reference
32+
fs.writeFileSync(path.join(testRepoPath, 'file2.ts'), 'export const y = 2;\n');
33+
execSync('git add file2.ts', { cwd: testRepoPath, stdio: 'pipe' });
34+
execSync('git commit -m "fix: bug fix PR #456"', { cwd: testRepoPath, stdio: 'pipe' });
35+
36+
// Create a fourth commit modifying existing file
37+
fs.appendFileSync(path.join(testRepoPath, 'file1.ts'), 'export const z = 3;\n');
38+
execSync('git add file1.ts', { cwd: testRepoPath, stdio: 'pipe' });
39+
execSync('git commit -m "refactor: update file1"', { cwd: testRepoPath, stdio: 'pipe' });
40+
41+
extractor = new LocalGitExtractor(testRepoPath);
42+
});
43+
44+
afterAll(() => {
45+
// Cleanup
46+
fs.rmSync(testRepoPath, { recursive: true, force: true });
47+
});
48+
49+
describe('getCommits', () => {
50+
it('should return commits in reverse chronological order', async () => {
51+
const commits = await extractor.getCommits();
52+
53+
expect(commits.length).toBe(4);
54+
expect(commits[0].subject).toBe('refactor: update file1');
55+
expect(commits[3].subject).toBe('Initial commit');
56+
});
57+
58+
it('should respect limit option', async () => {
59+
const commits = await extractor.getCommits({ limit: 2 });
60+
61+
expect(commits.length).toBe(2);
62+
expect(commits[0].subject).toBe('refactor: update file1');
63+
expect(commits[1].subject).toBe('fix: bug fix PR #456');
64+
});
65+
66+
it('should include author information', async () => {
67+
const commits = await extractor.getCommits({ limit: 1 });
68+
69+
expect(commits[0].author.name).toBe('Test User');
70+
expect(commits[0].author.email).toBe('[email protected]');
71+
expect(commits[0].author.date).toMatch(/^\d{4}-\d{2}-\d{2}T/);
72+
});
73+
74+
it('should include file changes', async () => {
75+
const commits = await extractor.getCommits({ limit: 1 });
76+
77+
expect(commits[0].files.length).toBeGreaterThan(0);
78+
expect(commits[0].files[0].path).toBe('file1.ts');
79+
expect(commits[0].stats.filesChanged).toBe(1);
80+
});
81+
82+
it('should extract issue references from message', async () => {
83+
const commits = await extractor.getCommits();
84+
const issueCommit = commits.find((c) => c.subject.includes('#123'));
85+
86+
expect(issueCommit).toBeDefined();
87+
expect(issueCommit?.refs.issueRefs).toContain(123);
88+
});
89+
90+
it('should extract PR references from message', async () => {
91+
const commits = await extractor.getCommits();
92+
const prCommit = commits.find((c) => c.subject.includes('PR #456'));
93+
94+
expect(prCommit).toBeDefined();
95+
expect(prCommit?.refs.prRefs).toContain(456);
96+
});
97+
98+
it('should filter by path', async () => {
99+
const commits = await extractor.getCommits({ path: 'file1.ts' });
100+
101+
expect(commits.length).toBe(2); // Initial add and update
102+
expect(commits.every((c) => c.files.some((f) => f.path === 'file1.ts'))).toBe(true);
103+
});
104+
105+
it('should handle empty repository gracefully', async () => {
106+
const emptyRepoPath = fs.mkdtempSync(path.join(os.tmpdir(), 'git-empty-test-'));
107+
execSync('git init', { cwd: emptyRepoPath, stdio: 'pipe' });
108+
109+
const emptyExtractor = new LocalGitExtractor(emptyRepoPath);
110+
111+
// Should not throw, just return empty array
112+
const commits = await emptyExtractor.getCommits();
113+
expect(commits).toEqual([]);
114+
115+
fs.rmSync(emptyRepoPath, { recursive: true, force: true });
116+
});
117+
});
118+
119+
describe('getCommit', () => {
120+
it('should return a single commit by hash', async () => {
121+
const commits = await extractor.getCommits({ limit: 1 });
122+
const hash = commits[0].hash;
123+
124+
const commit = await extractor.getCommit(hash);
125+
126+
expect(commit).not.toBeNull();
127+
expect(commit?.hash).toBe(hash);
128+
expect(commit?.subject).toBe('refactor: update file1');
129+
});
130+
131+
it('should return null for non-existent hash', async () => {
132+
const commit = await extractor.getCommit('0000000000000000000000000000000000000000');
133+
134+
expect(commit).toBeNull();
135+
});
136+
137+
it('should work with short hash', async () => {
138+
const commits = await extractor.getCommits({ limit: 1 });
139+
const shortHash = commits[0].shortHash;
140+
141+
const commit = await extractor.getCommit(shortHash);
142+
143+
expect(commit).not.toBeNull();
144+
expect(commit?.shortHash).toBe(shortHash);
145+
});
146+
});
147+
148+
describe('getRepositoryInfo', () => {
149+
it('should return repository information', async () => {
150+
const info = await extractor.getRepositoryInfo();
151+
152+
expect(info.branch).toBeDefined();
153+
expect(info.head).toMatch(/^[0-9a-f]{40}$/);
154+
expect(info.dirty).toBe(false);
155+
});
156+
157+
it('should detect dirty state', async () => {
158+
// Create uncommitted change
159+
fs.writeFileSync(path.join(testRepoPath, 'uncommitted.txt'), 'dirty');
160+
161+
const info = await extractor.getRepositoryInfo();
162+
expect(info.dirty).toBe(true);
163+
164+
// Cleanup
165+
fs.unlinkSync(path.join(testRepoPath, 'uncommitted.txt'));
166+
});
167+
});
168+
169+
describe('getBlame', () => {
170+
it('should return blame information for a file', async () => {
171+
const blame = await extractor.getBlame('file1.ts');
172+
173+
expect(blame.file).toBe('file1.ts');
174+
expect(blame.lines.length).toBe(2); // Two lines in file
175+
expect(blame.lines[0].lineNumber).toBe(1);
176+
expect(blame.lines[0].content).toBe('export const x = 1;');
177+
expect(blame.lines[0].commit.author).toBe('Test User');
178+
});
179+
180+
it('should support line range', async () => {
181+
const blame = await extractor.getBlame('file1.ts', { startLine: 1, endLine: 1 });
182+
183+
expect(blame.lines.length).toBe(1);
184+
expect(blame.lines[0].lineNumber).toBe(1);
185+
});
186+
});
187+
188+
describe('reference extraction', () => {
189+
it('should extract multiple issue references', async () => {
190+
// Create commit with multiple refs
191+
fs.writeFileSync(path.join(testRepoPath, 'multi.ts'), 'multi');
192+
execSync('git add multi.ts', { cwd: testRepoPath, stdio: 'pipe' });
193+
execSync('git commit -m "fix: resolve #1, #2, and #3"', { cwd: testRepoPath, stdio: 'pipe' });
194+
195+
const commits = await extractor.getCommits({ limit: 1 });
196+
197+
expect(commits[0].refs.issueRefs).toContain(1);
198+
expect(commits[0].refs.issueRefs).toContain(2);
199+
expect(commits[0].refs.issueRefs).toContain(3);
200+
});
201+
202+
it('should not confuse PR refs with issue refs', async () => {
203+
fs.writeFileSync(path.join(testRepoPath, 'pr-test.ts'), 'pr');
204+
execSync('git add pr-test.ts', { cwd: testRepoPath, stdio: 'pipe' });
205+
execSync('git commit -m "Merge pull request #999 from branch"', {
206+
cwd: testRepoPath,
207+
stdio: 'pipe',
208+
});
209+
210+
const commits = await extractor.getCommits({ limit: 1 });
211+
212+
expect(commits[0].refs.prRefs).toContain(999);
213+
expect(commits[0].refs.issueRefs).not.toContain(999);
214+
});
215+
});
216+
217+
describe('file change parsing', () => {
218+
it('should track additions and deletions', async () => {
219+
const commits = await extractor.getCommits();
220+
const updateCommit = commits.find((c) => c.subject === 'refactor: update file1');
221+
222+
expect(updateCommit).toBeDefined();
223+
expect(updateCommit?.stats.additions).toBeGreaterThan(0);
224+
});
225+
226+
it('should handle file renames', async () => {
227+
// Create and rename a file
228+
fs.writeFileSync(path.join(testRepoPath, 'old-name.ts'), 'content');
229+
execSync('git add old-name.ts', { cwd: testRepoPath, stdio: 'pipe' });
230+
execSync('git commit -m "add file to rename"', { cwd: testRepoPath, stdio: 'pipe' });
231+
232+
fs.renameSync(path.join(testRepoPath, 'old-name.ts'), path.join(testRepoPath, 'new-name.ts'));
233+
execSync('git add -A', { cwd: testRepoPath, stdio: 'pipe' });
234+
execSync('git commit -m "rename file"', { cwd: testRepoPath, stdio: 'pipe' });
235+
236+
const commits = await extractor.getCommits({ limit: 1 });
237+
238+
// Note: git may or may not detect this as a rename depending on similarity
239+
// Just verify there are file changes
240+
expect(commits[0].files.length).toBeGreaterThan(0);
241+
});
242+
});
243+
});

0 commit comments

Comments
 (0)