diff --git a/understand-anything-plugin/packages/core/src/__tests__/graph-freshness.integration.test.ts b/understand-anything-plugin/packages/core/src/__tests__/graph-freshness.integration.test.ts index e79b1a218..d1de462e8 100644 --- a/understand-anything-plugin/packages/core/src/__tests__/graph-freshness.integration.test.ts +++ b/understand-anything-plugin/packages/core/src/__tests__/graph-freshness.integration.test.ts @@ -44,12 +44,11 @@ function commitAll(repoDir: string, message: string): string { return git(repoDir, "rev-parse", "HEAD"); } -function createRepository( - initialFiles: Record = { - "src/index.ts": "export const value = 1;\n", - }, -): { repoDir: string; baseline: string } { - const repoDir = createTemporaryDirectory(); +function initRepositoryAt( + repoDir: string, + initialFiles: Record, +): string { + mkdirSync(repoDir, { recursive: true }); git(repoDir, "init"); git(repoDir, "config", "user.email", "freshness-tests@example.com"); git(repoDir, "config", "user.name", "Freshness Tests"); @@ -58,9 +57,19 @@ function createRepository( writeProjectFile(repoDir, relativePath, contents); } + return commitAll(repoDir, "baseline"); +} + +function createRepository( + initialFiles: Record = { + "src/index.ts": "export const value = 1;\n", + }, +): { repoDir: string; baseline: string } { + const repoDir = createTemporaryDirectory(); + return { repoDir, - baseline: commitAll(repoDir, "baseline"), + baseline: initRepositoryAt(repoDir, initialFiles), }; } @@ -483,3 +492,92 @@ describe( }); }, ); + +describe( + "getGraphFreshness with repositories nested under the project root", + { timeout: 20_000 }, + () => { + it("falls back to the nested repository that owns the graph commit", async () => { + const projectDir = createTemporaryDirectory("ua-freshness-parent-"); + const baseline = initRepositoryAt(join(projectDir, "api"), { + "src/index.ts": "export const value = 1;\n", + }); + + await expect( + getGraphFreshness(projectDir, { + graphCommitHash: baseline, + lastAnalyzedAt: "2026-07-10T00:00:00.000Z", + }), + ).resolves.toEqual({ + status: "fresh", + graphCommitHash: baseline, + headCommitHash: baseline, + changedFileCount: 0, + changedFiles: [], + commitsBehind: 0, + commitsAhead: 0, + lastAnalyzedAt: "2026-07-10T00:00:00.000Z", + }); + }); + + it("selects the owning sibling when several repositories are nested", async () => { + const projectDir = createTemporaryDirectory("ua-freshness-parent-"); + initRepositoryAt(join(projectDir, "api"), { + "src/api.ts": "export const api = 1;\n", + }); + const webDir = join(projectDir, "web"); + const webBaseline = initRepositoryAt(webDir, { + "src/web.ts": "export const web = 1;\n", + }); + + writeProjectFile(webDir, "src/web.ts", "export const web = 2;\n"); + const webHead = commitAll(webDir, "update web"); + + await expect( + getGraphFreshness(projectDir, { graphCommitHash: webBaseline }), + ).resolves.toMatchObject({ + status: "stale", + relation: "behind", + graphCommitHash: webBaseline, + headCommitHash: webHead, + commitsBehind: 1, + changedFiles: ["src/web.ts"], + }); + }); + + it("resolves each graph against its own nested repository", async () => { + const projectDir = createTemporaryDirectory("ua-freshness-parent-"); + const apiBaseline = initRepositoryAt(join(projectDir, "api"), { + "src/api.ts": "export const api = 1;\n", + }); + const webBaseline = initRepositoryAt(join(projectDir, "web"), { + "src/web.ts": "export const web = 1;\n", + }); + + await expect( + getGraphFreshnessBatch(projectDir, { + knowledge: { graphCommitHash: apiBaseline }, + domain: { graphCommitHash: webBaseline }, + }), + ).resolves.toMatchObject({ + knowledge: { status: "fresh", headCommitHash: apiBaseline }, + domain: { status: "fresh", headCommitHash: webBaseline }, + }); + }); + + it("returns graph-commit-unavailable when no nested repository owns the commit", async () => { + const projectDir = createTemporaryDirectory("ua-freshness-parent-"); + initRepositoryAt(join(projectDir, "api"), { + "src/api.ts": "export const api = 1;\n", + }); + + await expect( + getGraphFreshness(projectDir, { graphCommitHash: "deadbeef" }), + ).resolves.toEqual({ + status: "unknown", + reason: "graph-commit-unavailable", + graphCommitHash: "deadbeef", + }); + }); + }, +); diff --git a/understand-anything-plugin/packages/core/src/staleness.ts b/understand-anything-plugin/packages/core/src/staleness.ts index 93bb06d8d..3a90b52c9 100644 --- a/understand-anything-plugin/packages/core/src/staleness.ts +++ b/understand-anything-plugin/packages/core/src/staleness.ts @@ -1,4 +1,6 @@ import { execFile, execFileSync } from "child_process"; +import { existsSync, readdirSync, type Dirent } from "node:fs"; +import { join } from "node:path"; import type { KnowledgeGraph, GraphNode, GraphEdge } from "./types.js"; export interface StalenessResult { @@ -68,6 +70,7 @@ interface ProjectGitSnapshot { } const GIT_TIMEOUT_MS = 5_000; +const NESTED_REPO_SCAN_LIMIT = 32; const GIT_MAX_BUFFER_BYTES = 4 * 1024 * 1024; const PROJECT_PATHSPEC = [ "--", @@ -193,6 +196,88 @@ async function createProjectGitSnapshot( }; } +/** + * List immediate subdirectories of `projectDir` that are themselves Git repos. + * + * A parent directory holding several checkouts (e.g. `~/Code/Acme/{api,web}`) + * is a valid analysis root, but it has no HEAD of its own. Scanning one level + * down recovers the repos the graph was actually built from. + */ +function findNestedRepoDirs(projectDir: string): string[] { + let entries: Dirent[]; + try { + entries = readdirSync(projectDir, { withFileTypes: true }); + } catch { + return []; + } + + return entries + .filter( + (entry) => + entry.isDirectory() && + !entry.name.startsWith(".") && + entry.name !== "node_modules", + ) + .map((entry) => join(projectDir, entry.name)) + .filter((dir) => existsSync(join(dir, ".git"))) + .sort() + .slice(0, NESTED_REPO_SCAN_LIMIT); +} + +async function createNestedRepoSnapshots( + projectDir: string, +): Promise { + const repoDirs = findNestedRepoDirs(projectDir); + if (repoDirs.length === 0) return []; + + const snapshots = await Promise.all( + repoDirs.map(async (repoDir) => { + try { + return await createProjectGitSnapshot(repoDir); + } catch { + return undefined; + } + }), + ); + + return snapshots.filter( + (snapshot): snapshot is ProjectGitSnapshot => snapshot !== undefined, + ); +} + +async function containsCommit( + snapshot: ProjectGitSnapshot, + commitHash: string, +): Promise { + try { + await runGit(snapshot.projectDir, [ + "rev-parse", + "--verify", + "--end-of-options", + `${commitHash}^{commit}`, + ]); + return true; + } catch { + return false; + } +} + +/** + * Pick the nested repo that actually holds the commit a graph was built from. + * + * Commit hashes are unique, so ownership is unambiguous — and each graph + * resolves on its own, which keeps sibling checkouts from being conflated. + */ +async function resolveSnapshotForCommit( + snapshots: ProjectGitSnapshot[], + commitHash: string, +): Promise { + for (const snapshot of snapshots) { + if (await containsCommit(snapshot, commitHash)) return snapshot; + } + return undefined; +} + async function isAncestor( projectDir: string, ancestor: string, @@ -417,11 +502,21 @@ export async function getGraphFreshnessBatch( if (comparableEntries.length === 0) return results; - let snapshot: ProjectGitSnapshot; + let snapshot: ProjectGitSnapshot | undefined; + let nestedSnapshots: ProjectGitSnapshot[] = []; + let snapshotError: unknown; try { snapshot = await createProjectGitSnapshot(projectDir); } catch (error) { - const reason = unknownReason(error, "git-head-unavailable"); + snapshotError = error; + // A timeout will only repeat one level down, so don't multiply the wait. + if (!(error instanceof GitCommandError && error.timedOut)) { + nestedSnapshots = await createNestedRepoSnapshots(projectDir); + } + } + + if (snapshot === undefined && nestedSnapshots.length === 0) { + const reason = unknownReason(snapshotError, "git-head-unavailable"); for (const [key, input, graphCommitHash] of comparableEntries) { results[key] = { status: "unknown", @@ -435,8 +530,22 @@ export async function getGraphFreshnessBatch( await Promise.all( comparableEntries.map(async ([key, input, graphCommitHash]) => { + const resolved = + snapshot ?? + (await resolveSnapshotForCommit(nestedSnapshots, graphCommitHash)); + + if (resolved === undefined) { + results[key] = { + status: "unknown", + reason: "graph-commit-unavailable", + graphCommitHash, + ...optionalAnalysisTime(input), + }; + return; + } + results[key] = await evaluateGraphFreshness( - snapshot, + resolved, input, graphCommitHash, );