From e8d7f658b40785d85d1ad59137b00ee5150c2686 Mon Sep 17 00:00:00 2001 From: Serdar Karaman Date: Thu, 13 Aug 2026 15:49:02 +0200 Subject: [PATCH] fix: resolve graph freshness from repos nested under the project root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. `getGraphFreshnessBatch` built a single snapshot with `git rev-parse` in the project directory, so every graph analyzed from such a root reported `git-head-unavailable` and the dashboard showed a permanent "freshness could not be verified" banner. Re-running `/understand` could not clear it, because the failure depends on where the dashboard is pointed rather than on the age of the data. When the root snapshot fails, scan one level down for directories containing `.git` and resolve each graph against the nested repo that can verify its commit hash. Hashes are unique, so ownership is unambiguous and no new metadata is required — existing graphs resolve as-is. Resolution is per graph, so knowledge and domain graphs may live in different sibling repos. Timeouts do not trigger the scan, since it would multiply the wait by the number of nested repos. When nested repos exist but none owns the commit, the reason is now `graph-commit-unavailable`, which is more accurate than `git-head-unavailable` once repos are known to exist. Co-Authored-By: Claude Opus 5 --- .../graph-freshness.integration.test.ts | 112 +++++++++++++++-- .../packages/core/src/staleness.ts | 115 +++++++++++++++++- 2 files changed, 217 insertions(+), 10 deletions(-) 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, );