refactor(core): extract hasRecentCommits helper into @aoagents/ao-core (#1437)

* refactor(core): extract hasRecentCommits helper into @aoagents/ao-core

Deduplicate the byte-identical hasRecentCommits(workspacePath) helper
that was copy-pasted between agent-aider and agent-cursor. Exposes the
helper from core with a parameterized window so future agent plugins
using git-commit-based activity detection can share it.

Closes #1423

* test(core): make hasRecentCommits custom-window test discriminate on the parameter

The previous assertion passed the default (60) to hasRecentCommits, so a
bug where windowSeconds was silently ignored would still have passed.
Use a commit backdated ~2 minutes and assert both a 30s window excludes
it and a 600s window includes it — proving the parameter is forwarded
to `git log --since=...`.

Addresses Greptile review on #1437.

---------

Co-authored-by: Prateek <karnalprateek@gmail.com>
This commit is contained in:
i-trytoohard 2026-05-05 18:58:53 +05:30 committed by GitHub
parent 3a69722940
commit ea320312db
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 103 additions and 32 deletions

View File

@ -0,0 +1,64 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import { execFileSync } from "node:child_process";
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { hasRecentCommits } from "../git-activity.js";
describe("hasRecentCommits", () => {
let repoDir: string;
beforeEach(() => {
repoDir = mkdtempSync(join(tmpdir(), "ao-git-activity-"));
execFileSync("git", ["init", "-q", "-b", "main"], { cwd: repoDir });
execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: repoDir });
execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir });
execFileSync("git", ["config", "commit.gpgsign", "false"], { cwd: repoDir });
});
afterEach(() => {
if (repoDir) rmSync(repoDir, { recursive: true, force: true });
});
it("returns true when a commit exists within the default 60s window", async () => {
await writeFile(join(repoDir, "a.txt"), "hello");
execFileSync("git", ["add", "."], { cwd: repoDir });
execFileSync("git", ["commit", "-q", "-m", "initial"], { cwd: repoDir });
expect(await hasRecentCommits(repoDir)).toBe(true);
});
it("returns false when no commits have been made", async () => {
expect(await hasRecentCommits(repoDir)).toBe(false);
});
it("returns false when the path is not a git repo", async () => {
const notARepo = mkdtempSync(join(tmpdir(), "ao-git-activity-notrepo-"));
try {
expect(await hasRecentCommits(notARepo)).toBe(false);
} finally {
rmSync(notARepo, { recursive: true, force: true });
}
});
it("respects a custom window — excludes commits outside it, includes them inside it", async () => {
await writeFile(join(repoDir, "a.txt"), "hello");
execFileSync("git", ["add", "."], { cwd: repoDir });
// Backdate the commit by ~2 minutes so a 30s window excludes it but a
// 10-minute window includes it — this discriminates between "parameter
// forwarded" and "parameter silently ignored / hardcoded".
const twoMinAgo = new Date(Date.now() - 120_000).toISOString();
execFileSync("git", ["commit", "-q", "-m", "two-min-ago"], {
cwd: repoDir,
env: {
...process.env,
GIT_AUTHOR_DATE: twoMinAgo,
GIT_COMMITTER_DATE: twoMinAgo,
},
});
expect(await hasRecentCommits(repoDir, 30)).toBe(false);
expect(await hasRecentCommits(repoDir, 600)).toBe(true);
});
});

View File

@ -0,0 +1,34 @@
/**
* Git-commit-based activity detection helpers for agent plugins.
*
* Agents without native JSONL introspection (Aider, Cursor, etc.) can use
* recent git commits as a signal that the agent has been actively working.
*/
import { execFile } from "node:child_process";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
/**
* Check whether the given workspace has any git commits within the last
* `windowSeconds` seconds. Swallows errors (e.g. not a git repo, git missing)
* and returns `false` so callers can use this as a best-effort liveness signal.
*
* @param workspacePath Absolute path to the workspace (must be a git repo).
* @param windowSeconds How far back to look for commits. Defaults to 60s.
*/
export async function hasRecentCommits(
workspacePath: string,
windowSeconds = 60,
): Promise<boolean> {
try {
const { stdout } = await execFileAsync(
"git",
["log", `--since=${windowSeconds} seconds ago`, "--format=%H"],
{ cwd: workspacePath, timeout: 5_000 },
);
return stdout.trim().length > 0;
} catch {
return false;
}
}

View File

@ -204,6 +204,9 @@ export {
buildAgentPath,
PREFERRED_GH_PATH,
} from "./agent-workspace-hooks.js";
// Git-based activity helpers — recent-commit liveness signal for agent plugins
export { hasRecentCommits } from "./git-activity.js";
export type { NormalizedOrchestratorSessionStrategy } from "./orchestrator-session-strategy.js";
export {

View File

@ -5,6 +5,7 @@ import {
checkActivityLogState,
getActivityFallbackState,
recordTerminalActivity,
hasRecentCommits,
DEFAULT_READY_THRESHOLD_MS,
DEFAULT_ACTIVE_WINDOW_MS,
type Agent,
@ -29,22 +30,6 @@ const execFileAsync = promisify(execFile);
// Aider Activity Detection Helpers
// =============================================================================
/**
* Check if Aider has made recent commits (within last 60 seconds).
*/
async function hasRecentCommits(workspacePath: string): Promise<boolean> {
try {
const { stdout } = await execFileAsync(
"git",
["log", "--since=60 seconds ago", "--format=%H"],
{ cwd: workspacePath, timeout: 5_000 },
);
return stdout.trim().length > 0;
} catch {
return false;
}
}
/**
* Get modification time of Aider chat history file.
*/

View File

@ -5,6 +5,7 @@ import {
checkActivityLogState,
getActivityFallbackState,
recordTerminalActivity,
hasRecentCommits,
DEFAULT_READY_THRESHOLD_MS,
DEFAULT_ACTIVE_WINDOW_MS,
type Agent,
@ -30,22 +31,6 @@ const execFileAsync = promisify(execFile);
// Cursor Activity Detection Helpers
// =============================================================================
/**
* Check if Cursor has made recent commits (within last 60 seconds).
*/
async function hasRecentCommits(workspacePath: string): Promise<boolean> {
try {
const { stdout } = await execFileAsync(
"git",
["log", "--since=60 seconds ago", "--format=%H"],
{ cwd: workspacePath, timeout: 5_000 },
);
return stdout.trim().length > 0;
} catch {
return false;
}
}
/**
* Get modification time of Cursor session file if it exists.
* Cursor may create a .cursor directory with session data.