From 7aad491321920176750b140564d36540fb9bbb20 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 2 Nov 2026 18:31:29 +0100 Subject: [PATCH] =?UTF-8?q?gitea=5Fcommit=5Ftrees=20answers=20the=20files?= =?UTF-8?q?=20of=20whole=20directories,=20so=20the=20indexer=20can=20diges?= =?UTF-8?q?t=20a=20mesh=20package=20without=20a=20clone=20(ADR=200593=20?= =?UTF-8?q?=C2=A72)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new argument files, a list of directories (the root as the empty name), adds files, {directory: {path relative to it: blob id}}, every blob under each directory from the call's one recursive listing. A directory the commit lacks fails naming it, a cut listing still fails whole, and a badly spelled directory is refused before the forge is asked. Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL --- modules/gitea/client.ts | 29 +++++++++++-- modules/gitea/test/commit-trees.test.ts | 55 +++++++++++++++++++++++++ modules/gitea/tools/index.ts | 16 ++++++- 3 files changed, 94 insertions(+), 6 deletions(-) diff --git a/modules/gitea/client.ts b/modules/gitea/client.ts index 14ced5b1..9ccf741c 100644 --- a/modules/gitea/client.ts +++ b/modules/gitea/client.ts @@ -559,8 +559,14 @@ export class GiteaClient { * * **A listing the forge cut is no listing** (it says `truncated` on a page with nothing more to read, or the pages * run past a bound): the call fails naming the repository and the commit, never answering the directories it did - * see, since a directory left out would be taken for one without a module. Reads only. */ - async commitTrees(owner: string, repo: string, sha: string, paths?: Record): Promise<{ root: string; trees: Record; paths?: Record> }> { + * see, since a directory left out would be taken for one without a module. With `files`, a list of directories (the + * root as `""`), the answer also carries, of each, every blob under it, recursively, as its path relative to the + * directory with its git object id (the indexer digests the files of a mesh package from it, novox/hq ADR 0593 §2); a + * directory the commit lacks fails naming it. Reads only. */ + async commitTrees(owner: string, repo: string, sha: string, paths?: Record, files?: string[]): Promise<{ root: string; trees: Record; paths?: Record>; files?: Record> }> { + for (const dir of files ?? []) { + if (dir !== "" && dir.split("/").some((seg) => seg === "" || seg === "." || seg === "..")) throw new Error(`files: ${JSON.stringify(dir)} is not a plain relative directory`); + } const base = `${repoPath(owner, repo)}/git/trees/${commitSegment(sha)}?recursive=true`; const cut = (why: string) => new Error(`the forge's tree listing of ${owner}/${repo} at ${sha} was cut (${why}); no trees are answered from a part`); const entries: any[] = []; @@ -587,7 +593,22 @@ export class GiteaClient { if (!tree) throw new Error(`the forge's tree listing of ${owner}/${repo} at ${sha} holds ${e.path} but not the tree of ${dir}`); trees[dir] = tree; } - if (paths === undefined) return { root, trees }; + let filesOf: Record> | undefined; + if (files !== undefined) { + // **The files of whole directories** (novox/hq ADR 0593 §2): every blob under each, from the same listing. The listing + // is whole (a cut one failed above), so a directory absent from it is absent at the commit. + filesOf = {}; + for (const dir of files) { + if (dir !== "" && dirOf.get(dir) === undefined) throw new Error(`the directory ${dir} is not in ${owner}/${repo} at ${sha}`); + const prefix = dir === "" ? "" : `${dir}/`; + const blobs: Record = {}; + for (const e of entries) { + if (e?.type === "blob" && typeof e.path === "string" && e.path.startsWith(prefix)) blobs[e.path.slice(prefix.length)] = String(e.sha); + } + filesOf[dir] = blobs; + } + } + if (paths === undefined) return filesOf === undefined ? { root, trees } : { root, trees, files: filesOf }; // **Named paths** (novox/hq ADR 0499 §3, issue 1758): each asked path's git object id at the commit, a file's or a // directory's, from the same listing, by directory and by path relative to it. A path the listing lacks fails // naming both, and nothing is answered in part. @@ -602,7 +623,7 @@ export class GiteaClient { named[dir][p] = id; } } - return { root, trees, paths: named }; + return filesOf === undefined ? { root, trees, paths: named } : { root, trees, paths: named, files: filesOf }; } /** Whether a repository holds a file at a commit: true, false for a 404, thrown when the forge cannot say. */ diff --git a/modules/gitea/test/commit-trees.test.ts b/modules/gitea/test/commit-trees.test.ts index 0c36f409..2be41705 100644 --- a/modules/gitea/test/commit-trees.test.ts +++ b/modules/gitea/test/commit-trees.test.ts @@ -221,3 +221,58 @@ test("gitea_commit_trees takes paths as {directory: [paths]} and refuses another server.close(); } }); + +test("files: every blob under each asked directory, recursively, as its path relative to it with the blob's object id (ADR 0593 §2)", async () => { + const { client, server } = await serve([{ sha: COMMIT, url: "u", tree: LISTING, truncated: false, page: 1, total_count: LISTING.length }]); + try { + const got = await client.commitTrees("novox", "hq", COMMIT, undefined, ["modules/a", "modules/b", ""]); + assert.deepEqual(got.files, { + "modules/a": { "main.go": h("6"), "module.json": h("5") }, + "modules/b": { "c/module.json": h("9") }, + "": { "README.md": h("1"), "module.json": h("2"), "modules/a/module.json": h("5"), "modules/a/main.go": h("6"), "modules/b/c/module.json": h("9"), "modules/d/notes.md": h("b") }, + }); + assert.equal((await client.commitTrees("novox", "hq", COMMIT)).files, undefined, "no files asked, none answered"); + } finally { + server.close(); + } +}); + +test("a directory of files the commit lacks, or spelled badly, or a listing the forge cut, fails and answers nothing", async () => { + const { client, server } = await serve([{ sha: COMMIT, url: "u", tree: LISTING, truncated: false, page: 1, total_count: LISTING.length }]); + try { + await assert.rejects(client.commitTrees("novox", "hq", COMMIT, undefined, ["modules/a", "modules/gone"]), (err: Error) => { + assert.match(err.message, /modules\/gone/); + return true; + }); + await assert.rejects(client.commitTrees("novox", "hq", COMMIT, undefined, ["README.md"]), /README\.md/, "a file is no directory"); + for (const bad of ["../x", "/abs", "a//b", "modules/a/", "."]) { + await assert.rejects(client.commitTrees("novox", "hq", COMMIT, undefined, [bad]), /files/, bad); + } + } finally { + server.close(); + } + const cut = await serve([{ sha: COMMIT, tree: LISTING, truncated: true, page: 1, total_count: LISTING.length }]); + try { + await assert.rejects(cut.client.commitTrees("novox", "hq", COMMIT, undefined, ["modules/a"]), /cut/); + } finally { + cut.server.close(); + } +}); + +test("gitea_commit_trees takes files as a list of directories and refuses another shape before the forge is asked", async () => { + const { getGiteaTools } = await import("../tools/index.ts"); + const { client, server, asked } = await serve([{ sha: COMMIT, url: "u", tree: LISTING, truncated: false, page: 1, total_count: LISTING.length }]); + try { + const tool = getGiteaTools(client).find((t) => t.name === "gitea_commit_trees")!; + assert.ok("files" in tool.input, "the tool names the argument"); + const got: any = await tool.run({ owner: "novox", repo: "hq", sha: COMMIT, files: ["modules/a"] } as any); + assert.deepEqual(got.files, { "modules/a": { "main.go": h("6"), "module.json": h("5") } }); + const before = asked.length; + for (const files of ["modules/a", { "modules/a": [] }, [1], ["../x"]]) { + await assert.rejects(tool.run({ owner: "novox", repo: "hq", sha: COMMIT, files } as any), /files/, JSON.stringify(files)); + } + assert.equal(asked.length, before, "the forge was asked for a refused call"); + } finally { + server.close(); + } +}); diff --git a/modules/gitea/tools/index.ts b/modules/gitea/tools/index.ts index 4d1a94db..9b183a55 100644 --- a/modules/gitea/tools/index.ts +++ b/modules/gitea/tools/index.ts @@ -79,6 +79,17 @@ function namedPaths(raw: unknown): Record | undefined { return out; } +/** The `files` argument of gitea_commit_trees: a list of directories, each plain ("" is the root; no empty, `.` or `..` + * segment, not absolute, no trailing slash); anything else is refused before the forge is asked. Absent is absent. */ +function filesDirs(raw: unknown): string[] | undefined { + if (raw === undefined || raw === null) return undefined; + if (!Array.isArray(raw) || raw.some((d) => typeof d !== "string")) throw new Error("files is a list of directories"); + for (const d of raw as string[]) { + if (d !== "" && d.split("/").some((seg) => seg === "" || seg === "." || seg === "..")) throw new Error(`files: ${JSON.stringify(d)} is not a plain relative directory`); + } + return raw as string[]; +} + export function getGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin, source: SourceRead = sourceFromBus(state)): ToolDefinition[] { return namesChecked(allGiteaTools(gitea, admin, source)); } @@ -722,19 +733,20 @@ function allGiteaTools(gitea: GiteaClient, admin: (() => GiteaAdmin) | undefined }, { name: "gitea_commit_trees", - description: "A commit's trees from the forge's one recursive listing: the root tree, and the tree of every directory that holds a module.json (the root as an empty name), each by its git hash. What the forge bridge puts in a merge's message (novox/hq ADR 0437 §5). With paths, {directory: [path relative to it]}, the answer also carries paths, {directory: {path: git object id}} of each named file or directory at the commit (novox/hq ADR 0499 §3); a path the commit lacks fails naming the directory and the path. The commit is a full 40-hex sha. A listing the forge cut is refused, never answered in part. Reads only.", + description: "A commit's trees from the forge's one recursive listing: the root tree, and the tree of every directory that holds a module.json (the root as an empty name), each by its git hash. What the forge bridge puts in a merge's message (novox/hq ADR 0437 §5). With files, a list of directories, the answer also carries files, {directory: {path relative to it: git blob id}}, every blob under each directory, recursively, from the same listing (novox/hq ADR 0593 §2); a directory the commit lacks fails naming it. With paths, {directory: [path relative to it]}, the answer also carries paths, {directory: {path: git object id}} of each named file or directory at the commit (novox/hq ADR 0499 §3); a path the commit lacks fails naming the directory and the path. The commit is a full 40-hex sha. A listing the forge cut is refused, never answered in part. Reads only.", input: { owner: { type: "string", description: "the repository owner" }, repo: { type: "string", description: "the repository name" }, sha: { type: "string", description: "the commit, its full 40-hex sha" }, paths: { type: "object", description: "optional, {directory: [path relative to it]}: the git object id of each named file or directory is answered under paths; the root directory is the empty name" }, + files: { type: "array", description: "optional, a list of directories (the root is the empty name): every blob under each, recursively, is answered under files as {directory: {path relative to it: git blob id}}" }, }, run: async (args) => { const owner = String(args.owner ?? "").trim(), repo = String(args.repo ?? "").trim(); const sha = String(args.sha ?? "").trim(); if (!owner || !repo) throw new Error("name the repository: owner and repo"); if (!/^[0-9a-f]{40}$/i.test(sha)) throw new Error("sha is a commit's full 40-hex sha"); - return gitea.commitTrees(owner, repo, sha, namedPaths(args.paths)); + return gitea.commitTrees(owner, repo, sha, namedPaths(args.paths), filesDirs(args.files)); }, }, {