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:
parent
3a69722940
commit
ea320312db
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in New Issue