Skip to content

Conversation

@ankitml
Copy link

@ankitml ankitml commented Jan 6, 2026

This PR adds git-graph awareness to the benchmark action, addressing a critical issue where benchmark data points were sorted by commit timestamp rather than git topology. This caused incorrect graph visualizations and PR comparisons for release branches.

Problem

Previously, benchmark data was sorted by commit.author.timestamp, which meant:

  • Recording new data for a release branch (e.g., 0.20.x) would insert it at a time-based position
  • Data from different branches would intermingle in the visualization
  • PR comments would compare against the most recently inserted data (likely from main), not the most recent commit on the same branch

Solution

  • Added gitGraph.ts: A new module that analyzes git topology to determine the "mainline" path
  • Updated sorting logic: Benchmark entries are now sorted by their git-graph position (depth-first traversal) rather than commit timestamp
  • Fixed PR comparisons: The action now finds the most recent benchmark data for commits that are actual git ancestors of the current PR

Changes

  • Added gitGraph.ts with git topology analysis functions
  • Updated addBenchmarkEntry.ts to use git-graph aware sorting
  • Added configuration option to enable git-graph sorting
  • Updated HTML template to properly visualize git-graph sorted data
  • Added comprehensive unit tests for git-graph functionality

Related

Closes paradedb/paradedb#3769

ankitml and others added 11 commits December 19, 2025 11:26
Add GitGraphAnalyzer class for finding previous benchmarks based on git ancestry
instead of execution time. This fixes release branch comparisons where PRs
were comparing against wrong baseline commits.

- Add src/gitGraph.ts with git analysis functions
- Modify addBenchmarkEntry.ts to use git-graph aware previous benchmark selection
- Update default_index_html.ts to sort visualization data by commit timestamp

Fixes #3769
Add comprehensive tests for GitGraphAnalyzer and addBenchmarkEntry integration.
All 227 tests pass successfully.
Co-authored-by: Stu Hood <[email protected]>
Signed-off-by: Ankit  <[email protected]>
@coderabbitai
Copy link

coderabbitai bot commented Jan 6, 2026

📝 Walkthrough

Summary by CodeRabbit

  • New Features

    • Benchmark entries are now positioned using git commit ancestry for more accurate placement.
  • Improvements

    • Added debug and warning logs to make lookup and insertion behavior transparent.
    • Minor refactors improving readability without changing public behavior.
  • Tests

    • Added comprehensive unit and integration tests covering ancestry analysis, insertion indexing, and truncation behavior.

✏️ Tip: You can customize this high-level summary in your review settings.

Walkthrough

Introduces GitGraphAnalyzer to use git ancestry when locating previous benchmarks and computing insertion indices; addBenchmarkEntry now inserts new benchmark at the ancestry-derived position (with existing truncation), and tests for the analyzer and integration were added.

Changes

Cohort / File(s) Summary
Git Graph Analysis
src/gitGraph.ts
New GitGraphAnalyzer class: checks git availability, getAncestry(ref) returns topologically-ordered SHAs, findPreviousBenchmark(suites, currentSha) finds the most recent benchmark in ancestry (with execution-time fallback), and findInsertionIndex(suites, newCommitSha) computes insertion index. Handles errors and logs warnings/debug.
Benchmark Entry Logic
src/addBenchmarkEntry.ts
Replaced end-append heuristic with ancestry-based insertion using findInsertionIndex, added debug logs for ancestor lookup; truncation of old entries still applied after insertion.
Write Helper
src/write.ts
Reordered destructuring of config properties (no behavioral change).
Default HTML Data Mapping
src/default_index_html.ts
Localized entries variable in map callback for readability; no behavioral change.
Minor Formatting
src/config.ts
Formatting blank-line change only.
Git Graph Unit Tests
test/gitGraphAnalyzer.test.ts
New comprehensive tests for GitGraphAnalyzer: git command parsing, error handling, ancestry-based previous-benchmark resolution, insertion index logic, and fallbacks; mocks @actions/core and child_process.execSync.
Benchmark Integration Tests
test/addBenchmarkEntryGitGraph.test.ts
New integration tests mocking GitGraphAnalyzer and asserting insertion index usage, suite creation/appending, maxItems truncation, and debug logging.
Test Mock Configuration
test/write.spec.ts
Adds Jest mock for ../src/gitGraph returning deterministic analyzer behavior for tests.

Sequence Diagram

sequenceDiagram
    participant Action as GitHub Action
    participant Add as addBenchmarkEntry
    participant Analyzer as GitGraphAnalyzer
    participant Git as Git CLI
    participant Data as Benchmark Data Store

    Action->>Add: invoke addBenchmarkEntry(newBenchmark)
    Add->>Analyzer: determine insertion (newCommitSha)
    Analyzer->>Git: git log --oneline --topo-order (currentSha)
    Git-->>Analyzer: ancestry array (or error)

    rect rgb(230,240,250)
    Note over Analyzer,Data: Resolve previous benchmark via ancestry
    Analyzer->>Data: findPreviousBenchmark(suites, currentSha)
    Data-->>Analyzer: previousBenchmark or null
    end

    rect rgb(240,250,230)
    Note over Analyzer,Add: Compute insertion index
    Analyzer->>Data: findInsertionIndex(suites, newCommitSha)
    Data-->>Analyzer: insertIndex
    end

    Add->>Data: insert newBenchmark at insertIndex
    Data->>Data: truncate oldest entries if > maxItems
    Data-->>Action: return/emit updated benchmark data
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~30 minutes

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The PR title accurately summarizes the main change: adding git-graph aware sorting for benchmark data and comparisons, which is the core objective across all file changes.
Description check ✅ Passed The PR description comprehensively explains the problem (time-based sorting issues), solution (git-graph aware sorting), and lists all major code changes. It is directly related to the changeset.
Linked Issues check ✅ Passed The PR addresses all requirements from #3769: introduces GitGraphAnalyzer to sort datapoints by git-graph position [src/gitGraph.ts], finds most-recent ancestor commits for PR comparisons [findPreviousBenchmark, findInsertionIndex], and integrates git-aware logic into addBenchmarkEntry.ts.
Out of Scope Changes check ✅ Passed All changes are scoped to implementing git-graph awareness: GitGraphAnalyzer implementation, integration into addBenchmarkEntry, HTML refactoring for clarity, property reordering in write.ts, and comprehensive test coverage. No unrelated changes detected.
✨ Finishing touches
  • 📝 Generate docstrings

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Fix all issues with AI Agents
In @src/gitGraph.ts:
- Around line 88-116: The code in findInsertionIndex relies on getAncestry
returning an array whose first element is the commit itself and does slice(1) to
exclude it; add a defensive check: call getAncestry(newCommitSha) as before,
verify ancestry.length>0 (already done) and then ensure the first element equals
newCommitSha before skipping it — if ancestry[0] === newCommitSha use
ancestry.slice(1) or otherwise build ancestorSet by filtering out newCommitSha
(or other unexpected values) so the commit never appears in ancestorSet; update
the variable ancestorSet creation near findInsertionIndex / getAncestry usage to
use this robust logic (reference: findInsertionIndex, getAncestry, ancestorSet).
- Around line 27-47: getAncestry currently interpolates the ref into a shell
string, enabling command injection; fix it by validating/sanitizing ref and
calling a non-shell exec variant with args instead of string interpolation:
ensure getAncestry checks ref against allowed git-ref patterns (hex SHA,
branch/tag name regex) and then invoke child_process.execFileSync or spawnSync
passing ['log','--oneline','--topo-order', ref] (or equivalent arg array) with
cwd and encoding to avoid a shell, and keep the existing try/catch and warnings
on failure.

In @test/write.spec.ts:
- Around line 119-131: The mock for GitGraphAnalyzer is incorrect: remove
non-existent methods getCurrentBranch and sortByGitOrder, and update the
signatures of existing mocks to match the real class by changing
findPreviousBenchmark(suites) to findPreviousBenchmark(suites, currentSha) and
findInsertionIndex(suites) to findInsertionIndex(suites, newCommitSha); ensure
the mocked implementations use the new parameters (currentSha/newCommitSha) so
tests exercise the same parameter-dependent logic as the real GitGraphAnalyzer.
🧹 Nitpick comments (5)
src/addBenchmarkEntry.ts (1)

15-15: Consider caching or injecting GitGraphAnalyzer to avoid repeated git --version checks.

A new GitGraphAnalyzer instance is created on every addBenchmarkEntry call. The constructor runs execSync('git --version') to detect git availability. For workflows that process multiple benchmarks, this adds unnecessary overhead.

🔎 Suggested approaches

Option 1: Module-level singleton

 import { GitGraphAnalyzer } from './gitGraph';
+
+// Reuse analyzer instance across calls
+const gitAnalyzer = new GitGraphAnalyzer();

 export function addBenchmarkEntry(
     benchName: string,
     benchEntry: Benchmark,
     entries: BenchmarkSuites,
     maxItems: number | null,
 ): { prevBench: Benchmark | null; normalizedCurrentBench: Benchmark } {
     let prevBench: Benchmark | null = null;
     let normalizedCurrentBench: Benchmark = benchEntry;
-    const gitAnalyzer = new GitGraphAnalyzer();

Option 2: Dependency injection

 export function addBenchmarkEntry(
     benchName: string,
     benchEntry: Benchmark,
     entries: BenchmarkSuites,
     maxItems: number | null,
+    gitAnalyzer: GitGraphAnalyzer = new GitGraphAnalyzer(),
 ): { prevBench: Benchmark | null; normalizedCurrentBench: Benchmark } {
test/addBenchmarkEntryGitGraph.test.ts (1)

82-97: Consider using BenchmarkSuites type instead of any.

Line 85 uses any type for entries. Using the actual BenchmarkSuites type from ../src/write would provide better type safety.

🔎 Suggested fix
+import { BenchmarkSuites } from '../src/write';
+
 it('should create new benchmark suite when none exists', () => {
     const benchName = 'test-suite';
     const benchEntry = createMockBenchmark('abc123');
-    const entries: any = {};
+    const entries: BenchmarkSuites = {};
test/gitGraphAnalyzer.test.ts (1)

116-129: Consider extracting duplicate createMockBenchmark helper.

The createMockBenchmark helper is defined identically in both findPreviousBenchmark and findInsertionIndex describe blocks (lines 117-129 and 210-222). Consider extracting it to the top level of the describe block to reduce duplication.

🔎 Suggested refactor

Move the helper to the top of the GitGraphAnalyzer describe block:

 describe('GitGraphAnalyzer', () => {
     let analyzer: GitGraphAnalyzer;
     const originalEnv = process.env;
+
+    const createMockBenchmark = (id: string, timestamp: string): Benchmark => ({
+        commit: {
+            id,
+            timestamp,
+            message: `Commit ${id}`,
+            url: `https://github.com/test/repo/commit/${id}`,
+            author: { username: 'testuser' },
+            committer: { username: 'testuser' },
+        },
+        date: Date.now(),
+        tool: 'cargo',
+        benches: [],
+    });

     // ... rest of tests using shared helper
src/gitGraph.ts (2)

8-15: Consider logging git CLI availability during construction.

Currently, the constructor silently catches errors when checking git availability. Adding a debug log when git is unavailable could help with troubleshooting, though deferring the warning until actual usage (as done in getAncestry) is also reasonable.

🔎 Optional logging enhancement
    constructor() {
        try {
            execSync('git --version', { stdio: 'ignore' });
            this.gitCliAvailable = true;
        } catch (e) {
            this.gitCliAvailable = false;
+           core.debug('Git CLI not available during initialization');
        }
    }

5-129: Consider caching ancestry results for performance.

The getAncestry method executes a git command on every call. If the method is invoked multiple times with the same ref during a single action run, caching the results could improve performance. However, given that GitHub Actions run infrequently and this is likely not a hot path, this optimization can be deferred.

🔎 Example caching implementation
 export class GitGraphAnalyzer {
     private readonly gitCliAvailable: boolean;
+    private ancestryCache: Map<string, string[]> = new Map();

     // ... constructor ...

     getAncestry(ref: string): string[] {
         if (!this.gitCliAvailable) {
             core.warning('Git CLI not available, cannot determine ancestry');
             return [];
         }

+        // Check cache first
+        if (this.ancestryCache.has(ref)) {
+            return this.ancestryCache.get(ref)!;
+        }
+
         try {
             const output = execSync(`git log --oneline --topo-order ${ref}`, {
                 encoding: 'utf8',
                 cwd: process.env.GITHUB_WORKSPACE ?? process.cwd(),
             });

-            return output
+            const result = output
                 .split('\n')
                 .filter((line) => line.trim())
                 .map((line) => line.split(' ')[0]);
+            
+            this.ancestryCache.set(ref, result);
+            return result;
         } catch (error) {
             core.warning(`Failed to get ancestry for ref ${ref}: ${error}`);
             return [];
         }
     }
📜 Review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 2e6f4e3 and e936a38.

📒 Files selected for processing (8)
  • src/addBenchmarkEntry.ts
  • src/config.ts
  • src/default_index_html.ts
  • src/gitGraph.ts
  • src/write.ts
  • test/addBenchmarkEntryGitGraph.test.ts
  • test/gitGraphAnalyzer.test.ts
  • test/write.spec.ts
🧰 Additional context used
🧬 Code graph analysis (4)
test/addBenchmarkEntryGitGraph.test.ts (1)
src/addBenchmarkEntry.ts (1)
  • addBenchmarkEntry (7-50)
test/gitGraphAnalyzer.test.ts (1)
src/gitGraph.ts (1)
  • GitGraphAnalyzer (5-129)
src/addBenchmarkEntry.ts (2)
src/gitGraph.ts (1)
  • GitGraphAnalyzer (5-129)
src/normalizeBenchmark.ts (1)
  • normalizeBenchmark (4-20)
src/gitGraph.ts (1)
examples/benchmarkjs/bench.js (1)
  • suite (2-2)
🔇 Additional comments (13)
src/config.ts (1)

6-7: LGTM!

Trivial formatting change - no impact on functionality.

src/write.ts (1)

493-493: LGTM!

Destructuring reorder has no functional impact - object property access is order-independent.

src/default_index_html.ts (1)

164-171: LGTM!

Good refactor - the local variable improves readability and the comment clarifies that chart data ordering relies on server-side (git-graph) sorting, aligning with the PR's objectives.

src/addBenchmarkEntry.ts (1)

37-47: LGTM - Insertion and truncation logic correctly handles git-graph ordering.

The insertion at the computed index maintains topological order, and truncation from the beginning removes entries that are earliest in the git history. This aligns with the PR's objective to order by git ancestry rather than timestamp.

test/addBenchmarkEntryGitGraph.test.ts (2)

1-15: LGTM - Good mock setup pattern.

Mocks are correctly defined before imports, ensuring proper module hoisting behavior with Jest.


122-151: Good edge case coverage for maxItems truncation.

This test validates that truncation works correctly with git-graph ordering - the oldest entries (by position) are removed, and the newest entry is inserted at the correct position. The assertion on line 147 properly verifies that old1 was removed.

test/gitGraphAnalyzer.test.ts (3)

1-16: LGTM - Proper mock isolation for system-level dependencies.

The mock setup correctly isolates @actions/core and child_process.execSync, ensuring tests don't make actual git calls. Mocks are defined before imports as required.


18-30: Good environment isolation pattern.

Preserving and restoring process.env prevents test pollution. The beforeEach/afterEach pattern ensures each test starts with a clean state.


170-206: Good fallback behavior coverage.

These tests verify that when git ancestry fails or returns unexpected results, the analyzer correctly falls back to execution-time ordering. This ensures graceful degradation in edge cases.

src/gitGraph.ts (4)

17-22: LGTM!

Simple getter with clear purpose and correct implementation.


49-82: LGTM with good defensive programming.

The logic correctly:

  • Retrieves ancestry for the current commit
  • Verifies the commit exists in ancestry before proceeding
  • Iterates through ancestors to find the most recent benchmark
  • Falls back gracefully to execution-time ordering when ancestry is unavailable

The multiple fallback paths ensure robustness.


118-128: LGTM!

The fallback method correctly iterates through benchmarks in reverse execution order (most recent first) and returns the first benchmark that isn't the current commit. Good use of array spread to avoid mutating the original array.


34-34: --topo-order is the correct flag for this use case.

You're correct that --topo-order differs from depth-first traversal (which interleaves commits from parallel branches in timestamp order), but for ancestry tracking, topological ordering is appropriate—it ensures parent-child relationships are respected and avoids interleaving commits from different branches. If the PR description mentions "depth-first," the terminology may be imprecise, but the implementation with --topo-order is sound.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

🧹 Nitpick comments (2)
src/gitGraph.ts (1)

27-51: Excellent fix: Command injection vulnerability resolved.

The method now correctly uses spawnSync with an argument array instead of string interpolation, eliminating the command injection risk. The error handling and fallback behavior are appropriate.

Optional: Consider adding ref validation for clearer error messages

While command injection is no longer possible, validating that ref matches expected git reference patterns (SHA-1 hash, branch name, tag name) before calling git could provide clearer error messages for invalid inputs:

/**
 * Get git ancestry using topological order
 */
getAncestry(ref: string): string[] {
    if (!this.gitCliAvailable) {
        core.warning('Git CLI not available, cannot determine ancestry');
        return [];
    }

    // Optional: Validate ref format
    const validRefPattern = /^[a-zA-Z0-9._\-\/]+$/;
    if (!validRefPattern.test(ref)) {
        core.warning(`Invalid git ref format: ${ref}`);
        return [];
    }

    try {
        const result = spawnSync('git', ['log', '--oneline', '--topo-order', ref], {
            encoding: 'utf8',
            cwd: process.env.GITHUB_WORKSPACE ?? process.cwd(),
        });
        // ... rest of implementation

This is a nice-to-have improvement, not a requirement.

test/write.spec.ts (1)

119-131: Mock parameters are unused, but this appears intentional for general write tests.

The method signatures correctly include currentSha and newCommitSha parameters, but the mock implementations ignore them—findPreviousBenchmark always returns the last benchmark regardless of the commit SHA, and findInsertionIndex always returns the array length.

However, this simplified behavior appears intentional: write.spec.ts tests general writeBenchmark functionality, not git-graph-specific logic. The real GitGraphAnalyzer.findPreviousBenchmark implementation uses currentSha to traverse git ancestry and find related commits, but that behavior is tested in dedicated files (gitGraphAnalyzer.test.ts and addBenchmarkEntryGitGraph.test.ts), which use proper spy mocks with assertions.

Consider adding a comment above the mock definition explaining that it's intentionally simplified for general write tests while git-graph-specific logic is verified in dedicated test files. This would clarify the distinction and prevent confusion.

📜 Review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between e936a38 and e990f9b.

📒 Files selected for processing (2)
  • src/gitGraph.ts
  • test/write.spec.ts
🧰 Additional context used
🧬 Code graph analysis (1)
src/gitGraph.ts (1)
examples/benchmarkjs/bench.js (1)
  • suite (2-2)
🔇 Additional comments (5)
src/gitGraph.ts (5)

8-15: LGTM: Git availability check is well-implemented.

The constructor safely checks for git CLI availability using spawnSync with array arguments and handles errors gracefully by setting the availability flag.


17-22: LGTM: Clean getter implementation.

The method provides appropriate public access to the git availability status.


53-86: LGTM: Well-structured ancestry-based benchmark lookup.

The method correctly traverses the git ancestry to find the most recent previous benchmark, with appropriate fallback logic when ancestry information is unavailable. The logging is well-placed and the edge cases are handled properly.


88-122: Excellent implementation: Robust ancestry-based insertion logic.

The method correctly determines the insertion index by finding the most recent ancestor in the existing suite. The defensive check on lines 104-106 ensures robustness even if the ancestry array structure is unexpected—it only skips the first element if it matches the commit being inserted. The backward iteration and fallback behavior are both appropriate.


124-134: LGTM: Simple and correct fallback implementation.

The private fallback method correctly finds the most recent benchmark by execution time (excluding the current commit) when git ancestry is unavailable. The use of spread operator prevents mutation of the original array.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Sort benchmark graphs and PR comments by the git-graph

1 participant