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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@
var
*.lock
CLAUDE.md
.claude
115 changes: 113 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ A Symfony bundle that fetches commit history from GitLab or GitHub repositories
- **REST API Integration**: Uses official APIs with pagination to fetch all commits
- **Self-Hosted Support**: Works with GitLab self-hosted instances and GitHub Enterprise
- **Year Filtering**: Filter commits by year with a dropdown, fetched via API with per-year caching
- **Dependency Tracking**: Automatically detect and display dependency changes (composer.json, package.json, etc.)
- **Vertical Timeline UI**: Beautiful, responsive timeline inspired by Symfony releases
- **Caching**: 1-hour cache by default to reduce API calls (cached per year)
- **Private Repository Support**: Supports authentication tokens for private repositories
Expand Down Expand Up @@ -88,6 +89,8 @@ spiriit_commit_history:
| `feed_name` | string | `Commits` | Display name for the timeline |
| `cache_ttl` | integer | `3600` | Cache duration in seconds |
| `available_years_count` | integer | `6` | Number of years to show in the year filter dropdown |
| `track_dependency_changes` | boolean | `true` | Enable dependency change detection |
| `dependency_files` | array | `[composer.json, composer.lock, package.json, package-lock.json]` | Files to track for dependency changes |

#### GitLab Options

Expand Down Expand Up @@ -180,7 +183,9 @@ Then in your template:

### Console Commands

Refresh the commit cache manually:
#### Refresh Cache

Refresh the commit cache and re-detect dependency badges:

```bash
# Refresh current year (default)
Expand All @@ -194,7 +199,102 @@ php bin/console spiriit:commit-history:refresh --all
php bin/console spiriit:commit-history:refresh -a
```

This command clears the cache and fetches fresh commits from the provider. Each year is cached separately.
This command clears the commits list cache and dependency detection cache, then fetches fresh data from the provider. Each year is cached separately.

#### Clear Cache

Clear all caches (commits list, dependency detection, and dependency changes):

```bash
# Clear all caches for all years
php bin/console spiriit:commit-history:clear

# Clear caches for a specific year only
php bin/console spiriit:commit-history:clear 2024
```

This command clears all cached data without re-fetching. Useful if you want to force a complete refresh on the next page load.

## Dependency Tracking

The bundle automatically detects commits that modify dependency files (composer.json, package.json, etc.) and displays a "DEPENDENCIES" badge on these commits. Clicking the badge reveals the list of added, updated, or removed dependencies.

### How It Works

1. **Badge Detection**: When commits are loaded, the bundle checks if any dependency files were modified. This check is cached per-commit (forever, since commit content is immutable).

2. **Lazy Loading**: The dependency details are only fetched when a user clicks the badge, reducing initial page load time.

3. **Per-Commit Caching**: Both badge detection and dependency details are cached per-commit. Since commit IDs are immutable, this cache never needs to expire.

### Configuration

```yaml
spiriit_commit_history:
# Enable or disable dependency tracking entirely (default: true)
track_dependency_changes: true

# Files to track for dependency changes
dependency_files:
- composer.json
- composer.lock
- package.json
- package-lock.json
```

### Extending with Custom Parsers

The bundle uses a tagged service pattern for diff parsers. You can add support for additional dependency file formats by creating your own parser.

1. Create a parser class implementing `DiffParserInterface`:

```php
<?php

namespace App\Service\DiffParser;

use Spiriit\Bundle\CommitHistoryBundle\DTO\DependencyChange;
use Spiriit\Bundle\CommitHistoryBundle\Service\DiffParser\DiffParserInterface;

class GemfileDiffParser implements DiffParserInterface
{
public function supports(string $filename): bool
{
return basename($filename) === 'Gemfile.lock';
}

/**
* @return DependencyChange[]
*/
public function parse(string $diff, string $filename): array
{
// Parse the diff and return DependencyChange objects
$changes = [];
// ... your parsing logic
return $changes;
}
}
```

2. Tag your service:

```yaml
services:
App\Service\DiffParser\GemfileDiffParser:
tags: ['spiriit_commit_history.diff_parser']
```

3. Add the file to the tracked files:

```yaml
spiriit_commit_history:
dependency_files:
- composer.json
- composer.lock
- package.json
- package-lock.json
- Gemfile.lock # Your new file
```

## Authentication

Expand Down Expand Up @@ -247,6 +347,17 @@ The templates use BEM naming convention:
| `.timeline__hash` | Commit hash |
| `.timeline__date` | Commit date |
| `.timeline__author` | Author name |
| `.timeline__badge` | Dependency badge (DEPENDENCIES label) |
| `.timeline__badge--loading` | Loading state for badge |
| `.timeline__dependencies` | Dependencies detail container |
| `.timeline__dependencies-list` | List of dependency changes |
| `.timeline__dependencies-item` | Individual dependency change |
| `.timeline__dependencies-name` | Package name |
| `.timeline__dependencies-version` | Version information |
| `.timeline__dependencies-type` | Change type indicator dot |
| `.timeline__dependencies-type--added` | Added dependency (green) |
| `.timeline__dependencies-type--updated` | Updated dependency (orange) |
| `.timeline__dependencies-type--removed` | Removed dependency (red) |

## Testing

Expand Down
111 changes: 111 additions & 0 deletions src/Command/ClearCacheCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
<?php

declare(strict_types=1);

/*
* This file is part of the spiriitlabs/commit-history-bundle package.
* Copyright (c) SpiriitLabs <https://www.spiriit.com/>
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Spiriit\Bundle\CommitHistoryBundle\Command;

use Spiriit\Bundle\CommitHistoryBundle\Controller\DependenciesChangesController;
use Spiriit\Bundle\CommitHistoryBundle\Service\DependencyDetectionService;
use Spiriit\Bundle\CommitHistoryBundle\Service\FeedFetcherInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Contracts\Cache\CacheInterface;

#[AsCommand(
name: 'spiriit:commit-history:clear',
description: 'Clear all commit history caches (commits list, dependency detection, and dependency changes)',
)]
class ClearCacheCommand extends Command
{
public function __construct(
private readonly CacheInterface $cache,
private readonly FeedFetcherInterface $feedFetcher,
) {
parent::__construct();
}

protected function configure(): void
{
$this
->addArgument('year', InputArgument::OPTIONAL, 'The year to clear cache for')
->addOption('all', 'a', InputOption::VALUE_NONE, 'Clear cache for all years');
}

protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);

$yearArg = $input->getArgument('year') ?? (new \DateTimeImmutable('now'))->format('Y');
$clearAll = $input->getOption('all');

if ($clearAll) {
return $this->clearAllYears($io);
}

return $this->clearYear((int) $yearArg, $io);
}

private function clearYear(int $year, SymfonyStyle $io): int
{
$io->text(\sprintf('Clearing cache for year %d...', $year));

$commitCount = $this->doClearYear($year);

$io->success(\sprintf('Cache cleared for year %d (%d commits).', $year, $commitCount));

return Command::SUCCESS;
}

private function clearAllYears(SymfonyStyle $io): int
{
$years = $this->feedFetcher->getAvailableYears();
$totalCommits = 0;

$io->text('Clearing cache for all years...');

foreach ($years as $year) {
$totalCommits += $this->doClearYear($year);
$io->text(\sprintf(' Year %d: cleared.', $year));
}

$io->success(\sprintf('Cache cleared for %d years (%d total commits).', \count($years), $totalCommits));

return Command::SUCCESS;
}

private function doClearYear(int $year): int
{
$commits = $this->feedFetcher->fetch($year);

foreach ($commits as $commit) {
$this->clearDependencyCaches($commit->id);
}

$this->cache->delete($this->feedFetcher->getCacheKey($year));

return \count($commits);
}

private function clearDependencyCaches(string $commitId): void
{
// Clear dependency detection cache (badge)
$hasDepsKey = DependencyDetectionService::getCacheKeyPrefix().$commitId;
$this->cache->delete($hasDepsKey);

// Clear dependency changes cache (list)
$depsKey = DependenciesChangesController::getCacheKeyPrefix().$commitId;
$this->cache->delete($depsKey);
}
}
32 changes: 26 additions & 6 deletions src/Command/RefreshCacheCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

namespace Spiriit\Bundle\CommitHistoryBundle\Command;

use Spiriit\Bundle\CommitHistoryBundle\Service\DependencyDetectionService;
use Spiriit\Bundle\CommitHistoryBundle\Service\FeedFetcherInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
Expand All @@ -19,15 +20,17 @@
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Contracts\Cache\CacheInterface;

#[AsCommand(
name: 'spiriit:commit-history:refresh',
description: 'Refresh the commit history cache',
description: 'Refresh the commit history cache and dependency detection',
)]
class RefreshCacheCommand extends Command
{
public function __construct(
private readonly FeedFetcherInterface $feedFetcher,
private readonly CacheInterface $cache,
) {
parent::__construct();
}
Expand All @@ -51,21 +54,38 @@ protected function execute(InputInterface $input, OutputInterface $output): int
$totalCommits = 0;

foreach ($years as $year) {
$commits = $this->feedFetcher->refresh($year);
$commits = $this->refreshYear($year);
$count = \count($commits);
$totalCommits += $count;
$io->text(\sprintf('Year %d: %d commits fetched.', $year, $count));
}

$io->success(\sprintf('Cache refreshed for all %d years. %d total commits fetched.', \count($years), $totalCommits));
} else {
$year = null !== $yearArg ? (int) $yearArg : null;
$commits = $this->feedFetcher->refresh($year);
$displayYear = $year ?? (int) date('Y');
$year = null !== $yearArg ? (int) $yearArg : (int) date('Y');
$commits = $this->refreshYear($year);

$io->success(\sprintf('Cache refreshed for year %d. %d commits fetched.', $displayYear, \count($commits)));
$io->success(\sprintf('Cache refreshed for year %d. %d commits fetched.', $year, \count($commits)));
}

return Command::SUCCESS;
}

/**
* @return \Spiriit\Bundle\CommitHistoryBundle\DTO\Commit[]
*/
private function refreshYear(int $year): array
{
// Get existing commits to clear their dependency detection cache
$existingCommits = $this->feedFetcher->fetch($year);

// Clear dependency detection cache for existing commits
foreach ($existingCommits as $commit) {
$hasDepsKey = DependencyDetectionService::getCacheKeyPrefix().$commit->id;
$this->cache->delete($hasDepsKey);
}

// Refresh commits (clears commits cache, re-fetches, and re-detects dependencies)
return $this->feedFetcher->refresh($year);
}
}
Loading