Skip to content
Open
23 changes: 16 additions & 7 deletions src/addBenchmarkEntry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Benchmark } from './extract';
import * as core from '@actions/core';
import { BenchmarkSuites } from './write';
import { normalizeBenchmark } from './normalizeBenchmark';
import { GitGraphAnalyzer } from './gitGraph';

export function addBenchmarkEntry(
benchName: string,
Expand All @@ -11,24 +12,32 @@ export function addBenchmarkEntry(
): { prevBench: Benchmark | null; normalizedCurrentBench: Benchmark } {
let prevBench: Benchmark | null = null;
let normalizedCurrentBench: Benchmark = benchEntry;
const gitAnalyzer = GitGraphAnalyzer.getInstance();

// Add benchmark result
if (entries[benchName] === undefined) {
entries[benchName] = [benchEntry];
core.debug(`No suite was found for benchmark '${benchName}' in existing data. Created`);
} else {
const suites = entries[benchName];
// Get the last suite which has different commit ID for alert comment
for (const e of [...suites].reverse()) {
if (e.commit.id !== benchEntry.commit.id) {
prevBench = e;
break;
}

// Find previous benchmark using git ancestry
core.debug(`Finding previous benchmark for commit: ${benchEntry.commit.id}`);

prevBench = gitAnalyzer.findPreviousBenchmark(suites, benchEntry.commit.id);
Copy link
Member

Choose a reason for hiding this comment

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

This is an interesting approach. I think it is a good one, but I'm not sure that we should make it the new default just yet. I'd prefer to add a config option to the action so that it's an opt-in feature unless we can comprehensively test it so that we are sure that it's backwards compatible.


if (prevBench) {
core.debug(`Found previous benchmark: ${prevBench.commit.id}`);
} else {
core.debug('No previous benchmark found');
}

normalizedCurrentBench = normalizeBenchmark(prevBench, benchEntry);

suites.push(normalizedCurrentBench);
// Insert at the correct position based on git ancestry
const insertionIndex = gitAnalyzer.findInsertionIndex(suites, benchEntry.commit.id);
core.debug(`Inserting benchmark at index ${insertionIndex} (of ${suites.length} existing entries)`);
suites.splice(insertionIndex, 0, normalizedCurrentBench);

if (maxItems !== null && suites.length > maxItems) {
suites.splice(0, suites.length - maxItems);
Expand Down
1 change: 1 addition & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import * as os from 'os';
import * as path from 'path';

export type ToolType = typeof VALID_TOOLS[number];

export interface Config {
name: string;
tool: ToolType;
Expand Down
13 changes: 8 additions & 5 deletions src/default_index_html.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,11 +161,14 @@ export const DEFAULT_INDEX_HTML = String.raw`<!DOCTYPE html>
a.click();
};

// Prepare data points for charts
return Object.keys(data.entries).map(name => ({
name,
dataSet: collectBenchesPerTestCase(data.entries[name]),
}));
// Prepare data points for charts (uses server-side ordering)
return Object.keys(data.entries).map(name => {
const entries = data.entries[name];
return {
name,
dataSet: collectBenchesPerTestCase(entries),
};
});
Comment on lines +164 to +171
Copy link
Member

Choose a reason for hiding this comment

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

This change seems to be just stylistic, right? Let's revert as it's not relevant to this change

}

function renderAllChars(dataSets) {
Expand Down
199 changes: 199 additions & 0 deletions src/gitGraph.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
import { spawnSync } from 'child_process';
import * as core from '@actions/core';
import { Benchmark } from './extract';

let cachedInstance: GitGraphAnalyzer | null = null;

export class GitGraphAnalyzer {
private readonly gitCliAvailable: boolean;

constructor() {
const result = spawnSync('git', ['--version'], { stdio: 'ignore' });
if (result.error) {
this.gitCliAvailable = false;
core.debug('Git CLI not available during initialization');
} else {
this.gitCliAvailable = true;
}
}

/**
* Check if git CLI is available
*/
isGitAvailable(): boolean {
return this.gitCliAvailable;
}

/**
* Validate that a ref matches expected git reference patterns.
* Accepts SHA hashes, branch names, and tag names.
*/
private isValidRef(ref: string): boolean {
if (!ref || ref.length === 0) {
return false;
}
if (ref.startsWith('-')) {
return false;
}
// Allow: hex SHA (short or full), branch/tag names (alphanumeric, dots, underscores, hyphens, slashes)
const validRefPattern = /^[a-zA-Z0-9._\-/]+$/;
return validRefPattern.test(ref);
}
Comment on lines +31 to +41
Copy link

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Consider blocking .. sequences in ref validation.

The current regex allows refs containing .. (e.g., main..feature), which is a range syntax in git and could produce unexpected results when passed to git log. While not a security vulnerability since you're using spawnSync with argument arrays, it could lead to confusing output.

🔎 Proposed enhancement
     private isValidRef(ref: string): boolean {
         if (!ref || ref.length === 0) {
             return false;
         }
         if (ref.startsWith('-')) {
             return false;
         }
+        // Block range syntax which could cause unexpected behavior
+        if (ref.includes('..')) {
+            return false;
+        }
         // Allow: hex SHA (short or full), branch/tag names (alphanumeric, dots, underscores, hyphens, slashes)
         const validRefPattern = /^[a-zA-Z0-9._\-/]+$/;
         return validRefPattern.test(ref);
     }
🤖 Prompt for AI Agents
In `@src/gitGraph.ts` around lines 31 - 41, The isValidRef function currently
permits refs containing consecutive dots (e.g., "main..feature") via
validRefPattern, which can be interpreted as a range by git; update isValidRef
(and the regex variable validRefPattern) to explicitly reject any ref containing
the substring ".." (or other consecutive-dot patterns) before returning true —
for example, add a check like if (ref.includes('..')) return false or
incorporate a negative lookahead into validRefPattern so that isValidRef returns
false for refs with ".." while preserving existing allowed characters and the
leading '-' check.


/**
* Get git ancestry using topological order
*/
getAncestry(ref: string): string[] {
core.debug(`GitGraphAnalyzer.getAncestry called with ref='${ref}'`);
core.debug(`GitGraphAnalyzer.getAncestry cwd='${process.cwd()}'`);
core.debug(`GitGraphAnalyzer.getAncestry GITHUB_WORKSPACE='${process.env.GITHUB_WORKSPACE ?? ''}'`);
if (!this.gitCliAvailable) {
core.warning('Git CLI not available, cannot determine ancestry');
return [];
}

if (!this.isValidRef(ref)) {
core.warning(`Invalid git ref format: ${ref}`);
return [];
}

try {
const args = ['log', '--oneline', '--topo-order', '--', ref];
const cwd = process.env.GITHUB_WORKSPACE ?? process.cwd();
core.debug(`GitGraphAnalyzer.getAncestry running: git ${args.join(' ')} (cwd='${cwd}')`);
const result = spawnSync('git', args, {
encoding: 'utf8',
cwd,
});
Comment on lines +60 to +67
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# First, let's examine the actual code in context
cd /tmp && find . -name "gitGraph.ts" -type f 2>/dev/null | head -1

Repository: benchmark-action/github-action-benchmark

Length of output: 66


🏁 Script executed:

# Search for gitGraph.ts in the repository
find . -name "gitGraph.ts" -type f 2>/dev/null

Repository: benchmark-action/github-action-benchmark

Length of output: 101


🏁 Script executed:

# If found, examine the file
if [ -f "src/gitGraph.ts" ]; then
  cat -n src/gitGraph.ts | sed -n '43,97p'
fi

Repository: benchmark-action/github-action-benchmark

Length of output: 2681


🏁 Script executed:

# Also test git log behavior with different argument placements
echo "=== Test 1: git log with -- before ref (current code pattern) ==="
git log --oneline --topo-order -- HEAD 2>&1 | head -3

echo ""
echo "=== Test 2: git log with ref before -- ==="
git log --oneline --topo-order HEAD -- 2>&1 | head -3

echo ""
echo "=== Test 3: git log with just ref (no --) ==="
git log --oneline --topo-order HEAD 2>&1 | head -3

echo ""
echo "=== Test 4: git log with ref argument (treating as revision) ==="
git log --oneline --topo-order -- main 2>&1 | head -3

Repository: benchmark-action/github-action-benchmark

Length of output: 386


🏁 Script executed:

cat -n src/gitGraph.ts | sed -n '1,150p' | grep -A 10 "isValidRef"

Repository: benchmark-action/github-action-benchmark

Length of output: 1127


🏁 Script executed:

# Search for isValidRef method implementation
rg -n "isValidRef" src/gitGraph.ts -A 5

Repository: benchmark-action/github-action-benchmark

Length of output: 451


Incorrect git command syntax: -- treats ref as a pathspec instead of a commit reference.

The command git log --oneline --topo-order -- <ref> treats <ref> as a file path (pathspec), not as a commit reference. Testing confirms this produces no output, causing getAncestry() to return an empty array for any ref, breaking the feature entirely.

In git commands, -- separates options from pathspecs (file paths). The ref argument must come before -- or omit it entirely when using a revision:

🐛 Fix
             const args = ['log', '--oneline', '--topo-order', '--', ref];
+            const args = ['log', '--oneline', '--topo-order', ref];
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
try {
const args = ['log', '--oneline', '--topo-order', '--', ref];
const cwd = process.env.GITHUB_WORKSPACE ?? process.cwd();
core.debug(`GitGraphAnalyzer.getAncestry running: git ${args.join(' ')} (cwd='${cwd}')`);
const result = spawnSync('git', args, {
encoding: 'utf8',
cwd,
});
try {
const args = ['log', '--oneline', '--topo-order', ref];
const cwd = process.env.GITHUB_WORKSPACE ?? process.cwd();
core.debug(`GitGraphAnalyzer.getAncestry running: git ${args.join(' ')} (cwd='${cwd}')`);
const result = spawnSync('git', args, {
encoding: 'utf8',
cwd,
});
🤖 Prompt for AI Agents
In `@src/gitGraph.ts` around lines 60 - 67, The git invocation in
GitGraphAnalyzer.getAncestry builds args as
['log','--oneline','--topo-order','--', ref], which makes git treat ref as a
pathspec and returns no commits; change the args so the revision comes before
any '--' (or remove the '--' entirely) — e.g. build args as
['log','--oneline','--topo-order', ref] (or ['log','--oneline','--topo-order',
ref, '--'] if you truly need a separator after revisions), update the spawnSync
call in getAncestry accordingly, and ensure any handling for undefined/empty ref
remains correct.


if (result.error) {
throw result.error;
}

if (result.status !== 0) {
core.warning(
`GitGraphAnalyzer.getAncestry git log exited with status ${result.status}. stderr='${result.stderr?.trim() ?? ''}'`,
);
}
if (result.stderr && result.stderr.trim().length > 0) {
core.debug(`GitGraphAnalyzer.getAncestry stderr: ${result.stderr.trim()}`);
}
core.debug(
`GitGraphAnalyzer.getAncestry stdout length=${result.stdout?.length ?? 0}`,
);
if (result.stdout) {
const preview = result.stdout.split('\n').slice(0, 5).join('\n');
core.debug(`GitGraphAnalyzer.getAncestry stdout preview:\n${preview}`);
}

return result.stdout
.split('\n')
.filter((line) => line.trim())
.map((line) => line.split(' ')[0]); // Extract SHA from "sha message"
Comment on lines +89 to +92
Copy link
Member

Choose a reason for hiding this comment

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

worth noting that for large repos, this might be huge. Something to keep an eye on. That's another reason why I'd like to have this feature opt-in until we verify it's not causing issues for current users

Copy link
Member

Choose a reason for hiding this comment

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

Alternatively, we could add a reasonable limit to the git command so that we don't query for all the commits until the beginning of the repo

} catch (error) {
core.warning(`Failed to get ancestry for ref ${ref}: ${error}`);
return [];
}
}

/**
* Find previous benchmark commit based on git ancestry.
* Falls back to execution time ordering if git ancestry is not available.
*/
findPreviousBenchmark(suites: Benchmark[], currentSha: string): Benchmark | null {
const ancestry = this.getAncestry(currentSha);

if (ancestry.length === 0) {
core.warning(`No ancestry found for commit ${currentSha}, falling back to execution time ordering`);
return this.findPreviousByExecutionTime(suites, currentSha);
Copy link
Member

Choose a reason for hiding this comment

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

I would suggest not intermingling the git graph logic with the previous logic, and instead return null here and move the fallback handling to the caller

}

// Find position of current commit in ancestry
const currentIndex = ancestry.indexOf(currentSha);
if (currentIndex === -1) {
core.warning(`Current commit ${currentSha} not found in ancestry, falling back to execution time ordering`);
return this.findPreviousByExecutionTime(suites, currentSha);
Copy link
Member

Choose a reason for hiding this comment

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

}

// Look for next commit in ancestry that exists in benchmarks
for (let i = currentIndex + 1; i < ancestry.length; i++) {
const previousSha = ancestry[i];
const previousBenchmark = suites.find((suite) => suite.commit.id === previousSha);

if (previousBenchmark) {
core.debug(`Found previous benchmark: ${previousSha} based on git ancestry`);
return previousBenchmark;
}
}

// Fallback: no previous commit found in ancestry
core.debug('No previous benchmark found in git ancestry');
return null;
}

/**
* Find the insertion index for a new benchmark entry based on git ancestry.
* Inserts after the most recent ancestor in the existing suites.
*/
findInsertionIndex(suites: Benchmark[], newCommitSha: string): number {
Copy link
Member

Choose a reason for hiding this comment

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

Just a thought. findInsertionIndex and findPreviousBenchmark feel like they are conceptually very similar. Couldn't we refactor so that we can reuse more logic?

something like

function findPreviousBenchmarkIndex(/*params*/) {
 // finding the previous bench idx
}

function findPreviousBenchmark(...) {
// simplified implementation
  return benches[findPreviousBenchmarkIndex()]
}

function findInsertionIndex() {
  // simplified
  return findPreviousBenchmark() + 1
}

if (!this.gitCliAvailable || suites.length === 0) {
return suites.length;
}

const ancestry = this.getAncestry(newCommitSha);
if (ancestry.length === 0) {
core.debug('No ancestry found, appending to end');
return suites.length;
Copy link
Member

Choose a reason for hiding this comment

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

similar to the logic used to find the previous benchmark. It think it's cleaner to separate the git graph logic from the "inserting at the end" as a fallback. If we return null or -1 here we could move the fallback logic to the addBenchmarkEntry

}

// Create a set of ancestor SHAs for quick lookup (excluding the commit itself)
// Skip first element only if it matches the commit (it should)
const startIndex = ancestry[0] === newCommitSha ? 1 : 0;
const ancestorSet = new Set(ancestry.slice(startIndex));

// Find the most recent ancestor in the existing suites
// Iterate through suites from end to beginning to find the most recent one
for (let i = suites.length - 1; i >= 0; i--) {
const suite = suites[i];
if (ancestorSet.has(suite.commit.id)) {
core.debug(`Found ancestor ${suite.commit.id} at index ${i}, inserting after it`);
return i + 1; // Insert after this ancestor
}
}

// No ancestor found in existing suites - this commit is likely from a different branch
// or is very old. Append to end as fallback.
core.debug('No ancestor found in existing suites, appending to end');
return suites.length;
}

/**
* Fallback method: find previous by execution time (original logic)
*/
private findPreviousByExecutionTime(suites: Benchmark[], currentSha: string): Benchmark | null {
for (const suite of [...suites].reverse()) {
if (suite.commit.id !== currentSha) {
return suite;
}
}
return null;
}

/**
* Get a cached singleton instance of GitGraphAnalyzer.
* Avoids repeated `git --version` checks across multiple calls.
*/
static getInstance(): GitGraphAnalyzer {
if (!cachedInstance) {
cachedInstance = new GitGraphAnalyzer();
}
return cachedInstance;
}

/**
* Reset the cached instance (useful for testing).
*/
static resetInstance(): void {
cachedInstance = null;
}
}
2 changes: 1 addition & 1 deletion src/write.ts
Original file line number Diff line number Diff line change
Expand Up @@ -490,7 +490,7 @@ async function writeBenchmarkToExternalJson(
jsonFilePath: string,
config: Config,
): Promise<{ prevBench: Benchmark | null; normalizedCurrentBench: Benchmark }> {
const { name, maxItemsInChart, saveDataFile } = config;
const { name, saveDataFile, maxItemsInChart } = config;
const data = await loadDataJson(jsonFilePath);
const { prevBench, normalizedCurrentBench } = addBenchmarkToDataJson(name, bench, data, maxItemsInChart);

Expand Down
Loading