diff --git a/modules/gitea/client.ts b/modules/gitea/client.ts index a36e113f..2a6fe912 100644 --- a/modules/gitea/client.ts +++ b/modules/gitea/client.ts @@ -540,7 +540,7 @@ 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> }> { + async commitTrees(owner: string, repo: string, sha: string, paths?: Record, listed?: string[]): Promise<{ root: string; trees: Record; paths?: Record>; listed?: Record> }> { 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[] = []; @@ -567,7 +567,23 @@ 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 }; + // **Listed directories** (novox/hq ADR 0549 §2, issue 1126): each asked directory's files directly under it, by name + // to blob id, from the same listing: how the forge bridge finds the catalogue's placement files, one per node, at a + // merge. A directory the commit lacks lists nothing (the catalogue before its first placement file). + let files: Record> | undefined; + if (listed !== undefined) { + files = {}; + for (const dir of listed) { + files[dir] = {}; + const prefix = dir === "" ? "" : `${dir}/`; + for (const e of entries) { + if (e?.type !== "blob" || typeof e.path !== "string" || !e.path.startsWith(prefix)) continue; + const name = e.path.slice(prefix.length); + if (name !== "" && !name.includes("/")) files[dir][name] = String(e.sha); + } + } + } + if (paths === undefined) return files === undefined ? { root, trees } : { root, trees, listed: files }; // **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. @@ -582,7 +598,7 @@ export class GiteaClient { named[dir][p] = id; } } - return { root, trees, paths: named }; + return files === undefined ? { root, trees, paths: named } : { root, trees, paths: named, listed: files }; } /** 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 e685a5c0..f1ae6b7e 100644 --- a/modules/gitea/test/commit-trees.test.ts +++ b/modules/gitea/test/commit-trees.test.ts @@ -183,6 +183,15 @@ test("named paths: each asked path's git object id at the commit, a file's or a }); assert.deepEqual(got.trees, { "": ROOT, "modules/a": h("4"), "modules/b/c": h("8") }); assert.equal((await client.commitTrees("novox", "hq", COMMIT)).paths, undefined, "no paths asked, none answered"); + // Listed directories (ADR 0549 §2, issue 1126): the files directly under each, not deeper; a missing one lists none. + const listed = await client.commitTrees("novox", "hq", COMMIT, undefined, ["modules/a", "modules/b", "", "placement"]); + assert.deepEqual(listed.listed, { + "modules/a": { "main.go": h("6"), "module.json": h("5") }, + "modules/b": {}, + "": { "README.md": h("1"), "module.json": h("2") }, + placement: {}, + }); + assert.equal((await client.commitTrees("novox", "hq", COMMIT)).listed, undefined, "no directory asked, none listed"); } finally { server.close(); } @@ -209,7 +218,12 @@ test("gitea_commit_trees takes paths as {directory: [paths]} and refuses another 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("paths" in tool.input, "the tool names the argument"); + assert.ok("paths" in tool.input && "listed" in tool.input, "the tool names the arguments"); + const listedAnswer: any = await tool.run({ owner: "novox", repo: "hq", sha: COMMIT, listed: ["modules/d"] } as any); + assert.deepEqual(listedAnswer.listed, { "modules/d": { "notes.md": h("b") } }); + for (const bad of ["modules/d", ["../x"], ["/abs"], [1]]) { + await assert.rejects(tool.run({ owner: "novox", repo: "hq", sha: COMMIT, listed: bad } as any), /listed/, JSON.stringify(bad)); + } const got: any = await tool.run({ owner: "novox", repo: "hq", sha: COMMIT, paths: { "modules/a": ["main.go"] } } as any); assert.deepEqual(got.paths, { "modules/a": { "main.go": h("6") } }); const before = asked.length; diff --git a/modules/gitea/tools/index.ts b/modules/gitea/tools/index.ts index 8f21cdb1..cfacfce5 100644 --- a/modules/gitea/tools/index.ts +++ b/modules/gitea/tools/index.ts @@ -76,6 +76,17 @@ function namedPaths(raw: unknown): Record | undefined { return out; } +/** The `listed` argument of gitea_commit_trees: [directory], each plain (no `.` or `..` segment, not absolute, the root + * the empty name); anything else is refused before the forge is asked. Absent is absent. */ +function listedDirs(raw: unknown): string[] | undefined { + if (raw === undefined || raw === null) return undefined; + if (!Array.isArray(raw) || raw.some((d) => typeof d !== "string")) throw new Error("listed is [directory, ...]"); + for (const d of raw as string[]) { + if (d !== "" && d.split("/").some((seg) => seg === "" || seg === "." || seg === "..")) throw new Error(`listed: ${JSON.stringify(d)} is not a plain relative directory`); + } + return raw as string[]; +} + export function getGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefinition[] { return namesChecked(allGiteaTools(gitea, admin)); } @@ -550,19 +561,20 @@ function allGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefini }, { 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 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. With listed, [directory], the answer also carries listed, {directory: {name: git blob id}} of the files directly under each (the catalogue's placement files, novox/hq ADR 0549 §2). 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" }, + listed: { type: "array", items: { type: "string" }, description: "optional, [directory]: the files directly under each, {directory: {name: git blob id}}, are answered under listed; a directory the commit lacks lists none" }, }, 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), listedDirs(args.listed)); }, }, {