diff --git a/src/core/source/github-submodules.ts b/src/core/source/github-submodules.ts index 55d136d..c8f4a21 100644 --- a/src/core/source/github-submodules.ts +++ b/src/core/source/github-submodules.ts @@ -58,7 +58,17 @@ export async function expandSubmodules(files: IFile[], data: GitHubBaseData, await waitForTokenGate(""); const child = await octokit("").git.getTree({ owner: repository.organization, repo: repository.repoName, tree_sha: mount.sha, recursive: "1", + }).catch((error: unknown) => { + const upstream = error as { status?: number; response?: { data?: { message?: string } } }; + // GitHub also uses 422 for a gitlink whose pinned object is missing. + // Only classify this child-tree response, not arbitrary validation errors. + if (upstream.status === 422 && /^Invalid object requested\. SHA must identify a commit or a tree\b/.test(upstream.response?.data?.message || "")) { + mount.submodule = "unavailable"; + return null; + } + throw error; }); + if (!child) continue; if (child.data.truncated || child.data.tree.length > budget.remaining) { mount.submodule = "limit"; continue; } const entries = child.data.tree; if (entries.some(entry => !safeRepositoryPath(entry.path))) { mount.submodule = "unsupported"; continue; } diff --git a/test/github-submodules.test.js b/test/github-submodules.test.js index d011474..4afbf79 100644 --- a/test/github-submodules.test.js +++ b/test/github-submodules.test.js @@ -138,6 +138,40 @@ describe("public GitHub submodules", function () { expect(calls).to.have.length(1); }); + it("keeps parent files when a child gitlink points to an invalid Git object", async () => { + const error = Object.assign(new Error("invalid object"), { status: 422, response: { data: { + message: "Invalid object requested. SHA must identify a commit or a tree.", + } } }); + api([], { error }); + const source = new GitHubStream(parent); + source.getGHTree = async () => ({ tree: [ + { path: "vendor/dependency", type: "commit", mode: "160000", sha }, + { path: "README.md", type: "blob", sha: blob, size: 5 }, + ] }); + const files = await source.getFiles(); + expect(files.find(file => file.name === "dependency").submodule).to.equal("unavailable"); + expect(files.find(file => file.name === "dependency").upstream).not.to.exist; + expect(files.find(file => file.name === "README.md").sha).to.equal(blob); + }); + + it("does not suppress invalid-object errors from parent metadata", async () => { + const error = Object.assign(new Error("invalid parent"), { status: 422, response: { data: { + message: "Invalid object requested. SHA must identify a commit or a tree.", + } } }); + stub(utils, "octokit", () => ({ repos: { getContent: async () => { throw error; } } })); + let caught; try { await expandSubmodules([mount()], parent); } catch (err) { caught = err; } + expect(caught).to.equal(error); + }); + + for (const [status, message] of [[422, "Validation Failed"], [422, ""], [429, "rate limited"], [500, "server error"]]) { + it(`preserves child failure ${status}: ${message}`, async () => { + const error = Object.assign(new Error(message), { status, response: { data: { message } } }); + api([], { error }); + let caught; try { await expandSubmodules([mount()], parent); } catch (err) { caught = err; } + expect(caught).to.equal(error); + }); + } + it("does not persist transient failures as permanently missing dependencies", async () => { const error = Object.assign(new Error("rate limited"), { status: 403 }); api([], { error }); let caught; try { await expandSubmodules([mount()], parent); } catch (err) { caught = err; }