Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -44,12 +44,11 @@ function commitAll(repoDir: string, message: string): string {
return git(repoDir, "rev-parse", "HEAD");
}

function createRepository(
initialFiles: Record<string, string> = {
"src/index.ts": "export const value = 1;\n",
},
): { repoDir: string; baseline: string } {
const repoDir = createTemporaryDirectory();
function initRepositoryAt(
repoDir: string,
initialFiles: Record<string, string>,
): string {
mkdirSync(repoDir, { recursive: true });
git(repoDir, "init");
git(repoDir, "config", "user.email", "freshness-tests@example.com");
git(repoDir, "config", "user.name", "Freshness Tests");
Expand All @@ -58,9 +57,19 @@ function createRepository(
writeProjectFile(repoDir, relativePath, contents);
}

return commitAll(repoDir, "baseline");
}

function createRepository(
initialFiles: Record<string, string> = {
"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),
};
}

Expand Down Expand Up @@ -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",
});
});
},
);
115 changes: 112 additions & 3 deletions understand-anything-plugin/packages/core/src/staleness.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { execFile, execFileSync } from "child_process";
import { existsSync, readdirSync, type Dirent } from "node:fs";
import { join } from "node:path";
Comment on lines 1 to +3
import type { KnowledgeGraph, GraphNode, GraphEdge } from "./types.js";

export interface StalenessResult {
Expand Down Expand Up @@ -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 = [
"--",
Expand Down Expand Up @@ -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<ProjectGitSnapshot[]> {
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;
Comment on lines +234 to +238
}
}),
);

return snapshots.filter(
(snapshot): snapshot is ProjectGitSnapshot => snapshot !== undefined,
);
}

async function containsCommit(
snapshot: ProjectGitSnapshot,
commitHash: string,
): Promise<boolean> {
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<ProjectGitSnapshot | undefined> {
for (const snapshot of snapshots) {
if (await containsCommit(snapshot, commitHash)) return snapshot;
}
return undefined;
}

async function isAncestor(
projectDir: string,
ancestor: string,
Expand Down Expand Up @@ -417,11 +502,21 @@ export async function getGraphFreshnessBatch<T extends string>(

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",
Expand All @@ -435,8 +530,22 @@ export async function getGraphFreshnessBatch<T extends string>(

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,
);
Expand Down