From ea320312dbb5afeabe9086a40afad1d3801dec6c Mon Sep 17 00:00:00 2001 From: i-trytoohard Date: Tue, 5 May 2026 18:58:53 +0530 Subject: [PATCH] refactor(core): extract hasRecentCommits helper into @aoagents/ao-core (#1437) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- .../core/src/__tests__/git-activity.test.ts | 64 +++++++++++++++++++ packages/core/src/git-activity.ts | 34 ++++++++++ packages/core/src/index.ts | 3 + packages/plugins/agent-aider/src/index.ts | 17 +---- packages/plugins/agent-cursor/src/index.ts | 17 +---- 5 files changed, 103 insertions(+), 32 deletions(-) create mode 100644 packages/core/src/__tests__/git-activity.test.ts create mode 100644 packages/core/src/git-activity.ts diff --git a/packages/core/src/__tests__/git-activity.test.ts b/packages/core/src/__tests__/git-activity.test.ts new file mode 100644 index 000000000..924cdf6c3 --- /dev/null +++ b/packages/core/src/__tests__/git-activity.test.ts @@ -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); + }); +}); diff --git a/packages/core/src/git-activity.ts b/packages/core/src/git-activity.ts new file mode 100644 index 000000000..a29bbbb43 --- /dev/null +++ b/packages/core/src/git-activity.ts @@ -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 { + 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; + } +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index fd391ed52..0785091ec 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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 { diff --git a/packages/plugins/agent-aider/src/index.ts b/packages/plugins/agent-aider/src/index.ts index 87388dfda..be455acd6 100644 --- a/packages/plugins/agent-aider/src/index.ts +++ b/packages/plugins/agent-aider/src/index.ts @@ -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 { - 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. */ diff --git a/packages/plugins/agent-cursor/src/index.ts b/packages/plugins/agent-cursor/src/index.ts index 2eb5fb56e..3f68f3a0d 100644 --- a/packages/plugins/agent-cursor/src/index.ts +++ b/packages/plugins/agent-cursor/src/index.ts @@ -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 { - 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.