Skip to content

Commit 48bdc14

Browse files
committed
Add Git commit diff source for tracking code changes
1 parent a520bce commit 48bdc14

File tree

10 files changed

+1113
-5
lines changed

10 files changed

+1113
-5
lines changed

context.json

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,28 @@
11
{
22
"documents": [
3+
{
4+
"description": "Recent Git Changes",
5+
"outputPath": "docs/recent-changes.md",
6+
"sources": [
7+
{
8+
"type": "text",
9+
"description": "Documentation Header",
10+
"content": "# Recent Git Changes\n\nThis document contains recent changes from the git repository.\n"
11+
},
12+
{
13+
"type": "git_diff",
14+
"description": "Recent Commits (Last 1)",
15+
"repository": ".",
16+
"commitRange": "unstaged",
17+
"filePattern": "*.php",
18+
"notPath": [
19+
"vendor",
20+
"tests"
21+
],
22+
"showStats": true
23+
}
24+
]
25+
},
326
{
427
"description": "Context generator code.",
528
"outputPath": "code.md",

json-schema.json

Lines changed: 126 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,8 @@
6161
"php_class",
6262
"url",
6363
"text",
64-
"github"
64+
"github",
65+
"git_commit_diff"
6566
],
6667
"description": "Type of content source"
6768
},
@@ -158,6 +159,18 @@
158159
"then": {
159160
"$ref": "#/definitions/githubSource"
160161
}
162+
},
163+
{
164+
"if": {
165+
"properties": {
166+
"type": {
167+
"const": "git_commit_diff"
168+
}
169+
}
170+
},
171+
"then": {
172+
"$ref": "#/definitions/gitCommitDiffSource"
173+
}
161174
}
162175
]
163176
},
@@ -444,6 +457,118 @@
444457
"description": "GitHub API token for private repositories (can use env var pattern ${TOKEN_NAME})"
445458
}
446459
}
460+
},
461+
"gitCommitDiffSource": {
462+
"required": [
463+
"repository"
464+
],
465+
"properties": {
466+
"repository": {
467+
"type": "string",
468+
"description": "Path to the git repository"
469+
},
470+
"commitRange": {
471+
"oneOf": [
472+
{
473+
"type": "string",
474+
"description": "Git commit range (e.g., 'HEAD~5..HEAD') or preset ('last', 'last-5', 'last-10', 'last-week', 'last-month', 'unstaged')"
475+
},
476+
{
477+
"type": "array",
478+
"description": "List of specific commits",
479+
"items": {
480+
"type": "string"
481+
}
482+
}
483+
],
484+
"default": "HEAD~1..HEAD"
485+
},
486+
"filePattern": {
487+
"oneOf": [
488+
{
489+
"type": "string",
490+
"description": "Pattern to match files (e.g., *.php)"
491+
},
492+
{
493+
"type": "array",
494+
"description": "List of patterns to match files (e.g., ['*.php', '*.md'])",
495+
"items": {
496+
"type": "string"
497+
}
498+
}
499+
],
500+
"default": "*.*"
501+
},
502+
"path": {
503+
"oneOf": [
504+
{
505+
"type": "string",
506+
"description": "Pattern to include only files in specific paths"
507+
},
508+
{
509+
"type": "array",
510+
"description": "List of patterns to include only files in specific paths",
511+
"items": {
512+
"type": "string"
513+
}
514+
}
515+
],
516+
"default": []
517+
},
518+
"notPath": {
519+
"type": "array",
520+
"description": "Patterns to exclude files by path",
521+
"items": {
522+
"type": "string"
523+
},
524+
"default": []
525+
},
526+
"contains": {
527+
"oneOf": [
528+
{
529+
"type": "string",
530+
"description": "Pattern to include only diffs containing specific content"
531+
},
532+
{
533+
"type": "array",
534+
"description": "List of patterns to include only diffs containing specific content",
535+
"items": {
536+
"type": "string"
537+
}
538+
}
539+
],
540+
"default": []
541+
},
542+
"notContains": {
543+
"oneOf": [
544+
{
545+
"type": "string",
546+
"description": "Pattern to exclude diffs containing specific content"
547+
},
548+
{
549+
"type": "array",
550+
"description": "List of patterns to exclude diffs containing specific content",
551+
"items": {
552+
"type": "string"
553+
}
554+
}
555+
],
556+
"default": []
557+
},
558+
"showStats": {
559+
"type": "boolean",
560+
"description": "Whether to show commit stats in output",
561+
"default": true
562+
},
563+
"modifiers": {
564+
"type": "array",
565+
"description": "List of content modifiers to apply",
566+
"items": {
567+
"type": "string"
568+
},
569+
"default": []
570+
}
571+
}
447572
}
448573
}
449574
}

runtime/php-cs-fixer.cache

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

src/Cli/ContextGenerator.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
use Butschster\ContextGenerator\Fetcher\Finder\GithubFinder;
1010
use Butschster\ContextGenerator\Fetcher\Github\GithubContentFetcher;
1111
use Butschster\ContextGenerator\Fetcher\GithubSourceFetcher;
12+
use Butschster\ContextGenerator\Fetcher\CommitDiffSourceFetcher;
1213
use Butschster\ContextGenerator\Fetcher\SourceFetcherRegistry;
1314
use Butschster\ContextGenerator\Fetcher\TextSourceFetcher;
1415
use Butschster\ContextGenerator\Fetcher\UrlSourceFetcher;
@@ -81,6 +82,9 @@ protected function execute(InputInterface $input, OutputInterface $output): int
8182
modifiers: $modifiers,
8283
contentFetcher: $githubContentFetcher,
8384
),
85+
new CommitDiffSourceFetcher(
86+
modifiers: $modifiers,
87+
),
8488
],
8589
);
8690

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Butschster\ContextGenerator\Fetcher;
6+
7+
use Butschster\ContextGenerator\Fetcher\Finder\FinderResult;
8+
use Butschster\ContextGenerator\Fetcher\Finder\CommitDiffFinder;
9+
use Butschster\ContextGenerator\Fetcher\Git\CommitRangeParser;
10+
use Butschster\ContextGenerator\Source\CommitDiffSource;
11+
use Butschster\ContextGenerator\Source\SourceModifierRegistry;
12+
use Butschster\ContextGenerator\SourceInterface;
13+
use Symfony\Component\Finder\SplFileInfo;
14+
15+
/**
16+
* Fetcher for git commit diffs
17+
* @implements SourceFetcherInterface<CommitDiffSource>
18+
*/
19+
final readonly class CommitDiffSourceFetcher implements SourceFetcherInterface
20+
{
21+
/**
22+
* @param SourceModifierRegistry $modifiers Registry of content modifiers
23+
* @param FinderInterface $finder Finder for filtering diffs
24+
* @param CommitRangeParser $rangeParser Parser for commit range expressions
25+
*/
26+
public function __construct(
27+
private SourceModifierRegistry $modifiers,
28+
private CommitRangeParser $rangeParser = new CommitRangeParser(),
29+
private FinderInterface $finder = new CommitDiffFinder(),
30+
) {}
31+
32+
public function supports(SourceInterface $source): bool
33+
{
34+
return $source instanceof CommitDiffSource;
35+
}
36+
37+
public function fetch(SourceInterface $source): string
38+
{
39+
if (!$source instanceof CommitDiffSource) {
40+
throw new \InvalidArgumentException('Source must be an instance of CommitDiffSource');
41+
}
42+
43+
// Ensure the repository exists
44+
if (!\is_dir($source->repository)) {
45+
throw new \RuntimeException(\sprintf('Git repository "%s" does not exist', $source->repository));
46+
}
47+
48+
// Parse and resolve the commit range
49+
$resolvedCommitRange = $this->rangeParser->resolve($source->getCommit());
50+
51+
// Use the finder to get the diffs (passing the resolved range)
52+
$finderResult = $this->findDiffs($source, $resolvedCommitRange);
53+
54+
// Extract diffs from the finder result
55+
$diffs = $this->extractDiffsFromFinderResult($finderResult);
56+
57+
// Format the output
58+
return $this->formatOutput($diffs, $finderResult->treeView, $source, $resolvedCommitRange);
59+
}
60+
61+
/**
62+
* Find diffs for the given source and commit range
63+
*/
64+
private function findDiffs(CommitDiffSource $source, string|array $commitRange): FinderResult
65+
{
66+
// Create a source with the resolved commit range to pass to the finder
67+
$finderSource = new class($source, $commitRange) extends CommitDiffSource {
68+
public function __construct(CommitDiffSource $original, private readonly string|array $resolvedCommitRange)
69+
{
70+
parent::__construct(
71+
repository: $original->repository,
72+
description: $original->getDescription(),
73+
commit: $original->commit,
74+
filePattern: $original->filePattern,
75+
notPath: $original->notPath,
76+
path: $original->path,
77+
contains: $original->contains,
78+
notContains: $original->notContains,
79+
showStats: $original->showStats,
80+
);
81+
}
82+
83+
public function getCommitRange(): string|array
84+
{
85+
return $this->resolvedCommitRange;
86+
}
87+
};
88+
89+
return $this->finder->find($finderSource);
90+
}
91+
92+
/**
93+
* Extract diffs from the finder result
94+
*
95+
* @return array<string, array{file: string, diff: string, stats: string}>
96+
*/
97+
private function extractDiffsFromFinderResult(FinderResult $finderResult): array
98+
{
99+
$diffs = [];
100+
foreach ($finderResult->files as $file) {
101+
if (!$file instanceof SplFileInfo) {
102+
continue;
103+
}
104+
105+
// Get the original path and diff content
106+
$originalPath = \method_exists($file, 'getOriginalPath')
107+
? $file->getOriginalPath()
108+
: $file->getRelativePathname();
109+
110+
$diffContent = $file->getContents();
111+
112+
// Get the stats for this file
113+
$stats = '';
114+
if (\method_exists($file, 'getStats')) {
115+
$stats = $file->getStats();
116+
} else {
117+
// Try to extract stats from the diff content
118+
\preg_match('/^(.*?)(?=diff --git)/s', $diffContent, $matches);
119+
if (!empty($matches[1])) {
120+
$stats = \trim($matches[1]);
121+
}
122+
}
123+
124+
$diffs[$originalPath] = [
125+
'file' => $originalPath,
126+
'diff' => $diffContent,
127+
'stats' => $stats,
128+
];
129+
}
130+
131+
return $diffs;
132+
}
133+
134+
/**
135+
* Format the diffs for output
136+
*
137+
* @param array<string, array{file: string, diff: string, stats: string}> $diffs
138+
*/
139+
private function formatOutput(
140+
array $diffs,
141+
string $treeView,
142+
CommitDiffSource $source,
143+
string|array $resolvedCommitRange,
144+
): string {
145+
$content = '';
146+
147+
// Handle empty diffs case
148+
if (empty($diffs)) {
149+
$formattedRange = $this->rangeParser->formatForDisplay($resolvedCommitRange);
150+
return "# Git Diff for Commit Range: {$formattedRange}\n\nNo changes found in this commit range.\n";
151+
}
152+
153+
// Add a header with the commit range
154+
$formattedRange = $this->rangeParser->formatForDisplay($resolvedCommitRange);
155+
$content .= "# Git Diff for Commit Range: {$formattedRange}\n\n";
156+
157+
// Add a tree view summary of changed files
158+
$content .= "## Summary of Changes\n\n";
159+
$content .= "```\n";
160+
$content .= $treeView;
161+
$content .= "```\n\n";
162+
163+
// Add each diff
164+
foreach ($diffs as $file => $diffData) {
165+
// Add stats if requested
166+
if ($source->showStats && !empty($diffData['stats'])) {
167+
$content .= "## Stats for {$file}\n\n";
168+
$content .= "```\n{$diffData['stats']}\n```\n\n";
169+
}
170+
171+
// Add the diff
172+
$content .= "## Diff for {$file}\n\n";
173+
$content .= "```diff\n{$diffData['diff']}\n```\n\n";
174+
175+
// Apply modifiers if available
176+
if (!empty($source->modifiers)) {
177+
foreach ($source->modifiers as $modifierId) {
178+
if ($this->modifiers->has($modifierId)) {
179+
$modifier = $this->modifiers->get($modifierId);
180+
if ($modifier->supports($file)) {
181+
$context = [
182+
'file' => $file,
183+
'source' => $source,
184+
];
185+
$content = $modifier->modify($content, $context);
186+
}
187+
}
188+
}
189+
}
190+
}
191+
192+
return $content;
193+
}
194+
}

0 commit comments

Comments
 (0)