Skip to content

Commit 3c29a81

Browse files
Naorayclaude
andauthored
feat(tracing): auto-detect git information (#48)
* feat(tracing): auto-detect git information Closes #38 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: use Laravel Process facade instead of proc_open Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent d20747b commit 3c29a81

File tree

5 files changed

+288
-5
lines changed

5 files changed

+288
-5
lines changed

config/github-monolog.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,9 @@
150150
// Collect Inertia.js request context (auto-detects Inertia requests)
151151
'inertia' => true,
152152

153+
// Auto-detect git information (hash, branch, tag, dirty status)
154+
'git' => true,
155+
153156
/*
154157
|--------------------------------------------------------------------------
155158
| Query Collector Settings

src/Tracing/EnvironmentCollector.php

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,12 @@ class EnvironmentCollector implements DataCollectorInterface
1010
{
1111
use ResolvesTracingConfig;
1212

13+
public function __construct(
14+
protected ?GitInfoDetector $gitInfoDetector = null,
15+
) {
16+
$this->gitInfoDetector ??= new GitInfoDetector;
17+
}
18+
1319
public function isEnabled(): bool
1420
{
1521
return $this->isTracingFeatureEnabled('environment');
@@ -20,15 +26,43 @@ public function isEnabled(): bool
2026
*/
2127
public function collect(): void
2228
{
23-
Context::add('environment', [
29+
$environment = [
2430
'app_env' => config('app.env'),
2531
'app_debug' => config('app.debug'),
2632
'app_version' => config('app.version'),
2733
'laravel_version' => app()->version(),
2834
'php_version' => PHP_VERSION,
2935
'php_os' => PHP_OS,
3036
'hostname' => gethostname() ?: null,
31-
'git_commit' => config('app.git_commit'),
32-
]);
37+
];
38+
39+
$environment = array_merge($environment, $this->collectGitInfo());
40+
41+
Context::add('environment', $environment);
42+
}
43+
44+
/**
45+
* Collect git information if enabled.
46+
*
47+
* @return array<string, string|bool|null>
48+
*/
49+
protected function collectGitInfo(): array
50+
{
51+
if (! $this->getTracingConfig('git', true)) {
52+
return ['git_commit' => config('app.git_commit')];
53+
}
54+
55+
$gitInfo = $this->gitInfoDetector->detect();
56+
57+
// config('app.git_commit') overrides auto-detected git_hash
58+
$configCommit = config('app.git_commit');
59+
if ($configCommit !== null) {
60+
$gitInfo['git_hash'] = $configCommit;
61+
}
62+
63+
// Keep backward compatibility: include git_commit key
64+
$gitInfo['git_commit'] = $gitInfo['git_hash'] ?? $configCommit;
65+
66+
return $gitInfo;
3367
}
3468
}

src/Tracing/GitInfoDetector.php

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
<?php
2+
3+
namespace Naoray\LaravelGithubMonolog\Tracing;
4+
5+
use Illuminate\Support\Facades\Process;
6+
7+
class GitInfoDetector
8+
{
9+
/**
10+
* Cached git information (null means not yet resolved).
11+
*
12+
* @var array<string, string|bool|null>|null
13+
*/
14+
protected static ?array $cachedGitInfo = null;
15+
16+
/**
17+
* Detect git information for the current working directory.
18+
*
19+
* Results are cached statically so git commands only run once per process.
20+
*
21+
* @return array<string, string|bool|null>
22+
*/
23+
public function detect(): array
24+
{
25+
if (static::$cachedGitInfo !== null) {
26+
return static::$cachedGitInfo;
27+
}
28+
29+
static::$cachedGitInfo = [];
30+
31+
$hash = $this->runGitCommand('git log --pretty="%h" -n1 HEAD');
32+
if ($hash !== null) {
33+
static::$cachedGitInfo['git_hash'] = $hash;
34+
}
35+
36+
$branch = $this->runGitCommand('git rev-parse --abbrev-ref HEAD');
37+
if ($branch !== null) {
38+
static::$cachedGitInfo['git_branch'] = $branch;
39+
}
40+
41+
$tag = $this->runGitCommand('git describe --tags --abbrev=0 2>/dev/null');
42+
if ($tag !== null) {
43+
static::$cachedGitInfo['git_tag'] = $tag;
44+
}
45+
46+
$porcelain = $this->runGitCommand('git status --porcelain');
47+
if ($porcelain !== null) {
48+
static::$cachedGitInfo['git_dirty'] = $porcelain !== '';
49+
}
50+
51+
return static::$cachedGitInfo;
52+
}
53+
54+
/**
55+
* Run a git command with a 1-second timeout.
56+
*
57+
* Returns the trimmed output on success, or null on failure.
58+
*/
59+
protected function runGitCommand(string $command): ?string
60+
{
61+
try {
62+
$result = Process::timeout(1)->path(base_path())->run($command);
63+
64+
return $result->successful() ? trim($result->output()) : null;
65+
} catch (\Throwable) {
66+
return null;
67+
}
68+
}
69+
70+
/**
71+
* Reset the cached git information.
72+
*
73+
* Useful for testing or when the working directory changes.
74+
*/
75+
public static function resetCache(): void
76+
{
77+
static::$cachedGitInfo = null;
78+
}
79+
}

tests/Tracing/EnvironmentCollectorTest.php

Lines changed: 115 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,20 @@
22

33
use Illuminate\Support\Facades\Context;
44
use Naoray\LaravelGithubMonolog\Tracing\EnvironmentCollector;
5+
use Naoray\LaravelGithubMonolog\Tracing\GitInfoDetector;
56

67
beforeEach(function () {
7-
$this->collector = new EnvironmentCollector;
8+
GitInfoDetector::resetCache();
89
});
910

1011
afterEach(function () {
1112
Context::flush();
13+
GitInfoDetector::resetCache();
1214
});
1315

1416
it('collects environment data', function () {
15-
$this->collector->collect();
17+
$collector = new EnvironmentCollector;
18+
$collector->collect();
1619

1720
$environment = Context::get('environment');
1821

@@ -22,3 +25,113 @@
2225
expect($environment['php_version'])->toBe(PHP_VERSION);
2326
expect($environment['php_os'])->toBe(PHP_OS);
2427
});
28+
29+
it('includes git data when available', function () {
30+
$detector = Mockery::mock(GitInfoDetector::class);
31+
$detector->shouldReceive('detect')->once()->andReturn([
32+
'git_hash' => 'abc1234',
33+
'git_branch' => 'main',
34+
'git_tag' => 'v1.0.0',
35+
'git_dirty' => false,
36+
]);
37+
38+
$collector = new EnvironmentCollector($detector);
39+
$collector->collect();
40+
41+
$environment = Context::get('environment');
42+
43+
expect($environment)
44+
->toHaveKey('git_hash', 'abc1234')
45+
->toHaveKey('git_branch', 'main')
46+
->toHaveKey('git_tag', 'v1.0.0')
47+
->toHaveKey('git_dirty', false)
48+
->toHaveKey('git_commit', 'abc1234');
49+
});
50+
51+
it('falls back gracefully when git is not available', function () {
52+
$detector = Mockery::mock(GitInfoDetector::class);
53+
$detector->shouldReceive('detect')->once()->andReturn([]);
54+
55+
$collector = new EnvironmentCollector($detector);
56+
$collector->collect();
57+
58+
$environment = Context::get('environment');
59+
60+
expect($environment)
61+
->toHaveKey('git_commit')
62+
->toHaveKey('app_env')
63+
->toHaveKey('php_version');
64+
65+
expect($environment)->not->toHaveKey('git_hash');
66+
expect($environment)->not->toHaveKey('git_branch');
67+
});
68+
69+
it('uses config override for git_hash when app.git_commit is set', function () {
70+
config(['app.git_commit' => 'config-hash-override']);
71+
72+
$detector = Mockery::mock(GitInfoDetector::class);
73+
$detector->shouldReceive('detect')->once()->andReturn([
74+
'git_hash' => 'auto-detected-hash',
75+
'git_branch' => 'main',
76+
]);
77+
78+
$collector = new EnvironmentCollector($detector);
79+
$collector->collect();
80+
81+
$environment = Context::get('environment');
82+
83+
expect($environment)
84+
->toHaveKey('git_hash', 'config-hash-override')
85+
->toHaveKey('git_commit', 'config-hash-override')
86+
->toHaveKey('git_branch', 'main');
87+
});
88+
89+
it('skips git detection when git tracing is disabled', function () {
90+
config(['github-monolog.tracing.git' => false]);
91+
92+
$detector = Mockery::mock(GitInfoDetector::class);
93+
$detector->shouldNotReceive('detect');
94+
95+
$collector = new EnvironmentCollector($detector);
96+
$collector->collect();
97+
98+
$environment = Context::get('environment');
99+
100+
expect($environment)->toHaveKey('git_commit');
101+
expect($environment)->not->toHaveKey('git_hash');
102+
expect($environment)->not->toHaveKey('git_branch');
103+
expect($environment)->not->toHaveKey('git_tag');
104+
expect($environment)->not->toHaveKey('git_dirty');
105+
});
106+
107+
it('sets git_commit from git_hash when config override is not set', function () {
108+
config(['app.git_commit' => null]);
109+
110+
$detector = Mockery::mock(GitInfoDetector::class);
111+
$detector->shouldReceive('detect')->once()->andReturn([
112+
'git_hash' => 'detected-abc',
113+
]);
114+
115+
$collector = new EnvironmentCollector($detector);
116+
$collector->collect();
117+
118+
$environment = Context::get('environment');
119+
120+
expect($environment)
121+
->toHaveKey('git_hash', 'detected-abc')
122+
->toHaveKey('git_commit', 'detected-abc');
123+
});
124+
125+
it('enables git tracing by default', function () {
126+
$detector = Mockery::mock(GitInfoDetector::class);
127+
$detector->shouldReceive('detect')->once()->andReturn([
128+
'git_hash' => 'abc1234',
129+
]);
130+
131+
$collector = new EnvironmentCollector($detector);
132+
$collector->collect();
133+
134+
$environment = Context::get('environment');
135+
136+
expect($environment)->toHaveKey('git_hash', 'abc1234');
137+
});
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
<?php
2+
3+
use Naoray\LaravelGithubMonolog\Tracing\GitInfoDetector;
4+
5+
beforeEach(function () {
6+
GitInfoDetector::resetCache();
7+
});
8+
9+
afterEach(function () {
10+
GitInfoDetector::resetCache();
11+
});
12+
13+
it('detects git information from current repository', function () {
14+
$detector = new GitInfoDetector;
15+
$info = $detector->detect();
16+
17+
// We are in a git repo, so at minimum git_hash and git_branch should be present
18+
expect($info)->toHaveKey('git_hash');
19+
expect($info)->toHaveKey('git_branch');
20+
expect($info)->toHaveKey('git_dirty');
21+
22+
expect($info['git_hash'])->toBeString()->not->toBeEmpty();
23+
expect($info['git_branch'])->toBeString()->not->toBeEmpty();
24+
expect($info['git_dirty'])->toBeBool();
25+
});
26+
27+
it('caches results across multiple calls', function () {
28+
$detector = new GitInfoDetector;
29+
$first = $detector->detect();
30+
$second = $detector->detect();
31+
32+
expect($first)->toBe($second);
33+
});
34+
35+
it('resets cache when resetCache is called', function () {
36+
$detector = new GitInfoDetector;
37+
$first = $detector->detect();
38+
39+
GitInfoDetector::resetCache();
40+
41+
$second = $detector->detect();
42+
43+
// Results should be identical since we are in the same repo
44+
expect($first)->toBe($second);
45+
});
46+
47+
it('returns short hash format', function () {
48+
$detector = new GitInfoDetector;
49+
$info = $detector->detect();
50+
51+
// Short hash is typically 7-12 characters
52+
expect(strlen($info['git_hash']))->toBeLessThanOrEqual(12);
53+
expect(strlen($info['git_hash']))->toBeGreaterThanOrEqual(7);
54+
});

0 commit comments

Comments
 (0)