forked from farhoodlabs/paperclip
80f7d8270c
Mirrors the skills refactor: company-portability was the second user of
the per-host REST shim (its own parallel parseGitHubSourceUrl + fetch
helpers + raw.githubusercontent URL builder), so importing a company
package from a non-github URL hit the same Gitea 404 the skills path did.
- Extend git-source.ts:
- parseGitSourceUrl: also recognises query-string shape
(?ref=...&path=...) used by portability URLs, with precedence over
path-style segments when both are present.
- RepoSnapshot: add readBinary (Uint8Array for the company logo
fetch) and readFileOptional (null on NotFoundError, for the
COMPANY.md probe + main->master fallback).
- Rewrite resolveSource in company-portability.ts to open a single
in-memory snapshot per import and serve all reads (COMPANY.md,
candidate tree, includes, logo) from it. Drops fetchText/fetchJson/
fetchBinary/fetchOptionalText.
- parseGitHubSourceUrl stays exported with its original return shape
({hostname, owner, repo, ref, basePath, companyPath}) so the existing
test suite passes unchanged. It now delegates URL parsing to
parseGitSourceUrl and layers companyPath derivation on top.
- Delete server/src/services/github-fetch.ts: zero remaining callers.
Test coverage:
- 7 new git-source tests (query-string parse variants, query-string
precedence over path style, readBinary, readFileOptional NotFound
null + non-NotFound rethrow) — 34/34 passing.
- 52 existing company-portability tests still pass via the
parseGitHubSourceUrl shim contract.
- Smoke-tested end-to-end against https://git.farh.net/.../?ref=main:
ref resolves, snapshot opens, readFile/readBinary/readFileOptional
all return expected results.
Note: two pre-existing failures in company-skills-routes.test.ts
("does not expose a skill reference...") exist on dev too and are
unrelated to this change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
411 lines
15 KiB
TypeScript
411 lines
15 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
const listServerRefs = vi.fn();
|
|
const cloneFn = vi.fn();
|
|
const walkFn = vi.fn();
|
|
const readBlobFn = vi.fn();
|
|
const resolveRefFn = vi.fn();
|
|
const treeFn = vi.fn((args: unknown) => ({ __tree: args }));
|
|
|
|
vi.mock("isomorphic-git", () => ({
|
|
default: {
|
|
listServerRefs: (...args: unknown[]) => listServerRefs(...args),
|
|
clone: (...args: unknown[]) => cloneFn(...args),
|
|
walk: (...args: unknown[]) => walkFn(...args),
|
|
readBlob: (...args: unknown[]) => readBlobFn(...args),
|
|
resolveRef: (...args: unknown[]) => resolveRefFn(...args),
|
|
TREE: (...args: unknown[]) => treeFn(...args),
|
|
},
|
|
}));
|
|
|
|
vi.mock("isomorphic-git/http/node", () => ({
|
|
default: { request: vi.fn() },
|
|
}));
|
|
|
|
const { parseGitSourceUrl, resolveGitRef, openRepoSnapshot, buildCloneUrl } =
|
|
await import("../services/git-source.js");
|
|
|
|
beforeEach(() => {
|
|
listServerRefs.mockReset();
|
|
cloneFn.mockReset();
|
|
walkFn.mockReset();
|
|
readBlobFn.mockReset();
|
|
resolveRefFn.mockReset();
|
|
treeFn.mockClear();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
describe("parseGitSourceUrl", () => {
|
|
it("parses a bare github repo URL", () => {
|
|
expect(parseGitSourceUrl("https://github.com/anthropics/claude-code")).toMatchObject({
|
|
cloneUrl: "https://github.com/anthropics/claude-code.git",
|
|
hostname: "github.com",
|
|
owner: "anthropics",
|
|
repo: "claude-code",
|
|
ref: null,
|
|
basePath: "",
|
|
filePath: null,
|
|
explicitRef: false,
|
|
});
|
|
});
|
|
|
|
it("strips trailing .git from the repo segment", () => {
|
|
expect(parseGitSourceUrl("https://example.com/o/r.git")).toMatchObject({
|
|
cloneUrl: "https://example.com/o/r.git",
|
|
repo: "r",
|
|
});
|
|
});
|
|
|
|
it("parses a github tree URL with subpath", () => {
|
|
expect(
|
|
parseGitSourceUrl("https://github.com/o/r/tree/develop/sub/dir"),
|
|
).toMatchObject({
|
|
ref: "develop",
|
|
basePath: "sub/dir",
|
|
filePath: null,
|
|
explicitRef: true,
|
|
});
|
|
});
|
|
|
|
it("parses a github blob URL as a file path", () => {
|
|
expect(
|
|
parseGitSourceUrl("https://github.com/o/r/blob/main/path/to/file.md"),
|
|
).toMatchObject({
|
|
ref: "main",
|
|
basePath: "path/to",
|
|
filePath: "path/to/file.md",
|
|
explicitRef: true,
|
|
});
|
|
});
|
|
|
|
it("parses a gitea src/branch URL with subpath", () => {
|
|
expect(
|
|
parseGitSourceUrl("https://git.example.com/o/r/src/branch/main/skills"),
|
|
).toMatchObject({
|
|
cloneUrl: "https://git.example.com/o/r.git",
|
|
ref: "main",
|
|
basePath: "skills",
|
|
filePath: null,
|
|
explicitRef: true,
|
|
});
|
|
});
|
|
|
|
it("parses a gitea src/tag URL", () => {
|
|
expect(
|
|
parseGitSourceUrl("https://git.example.com/o/r/src/tag/v1.2.3"),
|
|
).toMatchObject({
|
|
ref: "v1.2.3",
|
|
basePath: "",
|
|
explicitRef: true,
|
|
});
|
|
});
|
|
|
|
it("parses a gitea src/commit URL with file", () => {
|
|
expect(
|
|
parseGitSourceUrl("https://git.example.com/o/r/src/commit/abc123/dir/SKILL.md"),
|
|
).toMatchObject({
|
|
ref: "abc123",
|
|
basePath: "dir",
|
|
filePath: "dir/SKILL.md",
|
|
});
|
|
});
|
|
|
|
it("parses a gitlab tree URL", () => {
|
|
expect(
|
|
parseGitSourceUrl("https://gitlab.com/group/proj/-/tree/main/sub"),
|
|
).toMatchObject({
|
|
cloneUrl: "https://gitlab.com/group/proj.git",
|
|
ref: "main",
|
|
basePath: "sub",
|
|
explicitRef: true,
|
|
});
|
|
});
|
|
|
|
it("parses a gitlab blob URL", () => {
|
|
expect(
|
|
parseGitSourceUrl("https://gitlab.com/group/proj/-/blob/main/sub/file.md"),
|
|
).toMatchObject({
|
|
ref: "main",
|
|
filePath: "sub/file.md",
|
|
basePath: "sub",
|
|
});
|
|
});
|
|
|
|
it("rejects non-https URLs", () => {
|
|
expect(() => parseGitSourceUrl("http://github.com/o/r")).toThrow(/HTTPS/);
|
|
});
|
|
|
|
it("rejects URLs without owner/repo", () => {
|
|
expect(() => parseGitSourceUrl("https://github.com/o")).toThrow();
|
|
});
|
|
|
|
it("rejects malformed URLs", () => {
|
|
expect(() => parseGitSourceUrl("not a url")).toThrow();
|
|
});
|
|
|
|
it("parses a query-string URL with ?ref= and ?path=", () => {
|
|
expect(
|
|
parseGitSourceUrl("https://github.com/o/r?ref=feature%2Fdemo&path=subdir"),
|
|
).toMatchObject({
|
|
cloneUrl: "https://github.com/o/r.git",
|
|
ref: "feature/demo",
|
|
basePath: "subdir",
|
|
filePath: null,
|
|
explicitRef: true,
|
|
});
|
|
});
|
|
|
|
it("parses a query-string URL with only ?ref=", () => {
|
|
expect(parseGitSourceUrl("https://github.com/o/r?ref=develop")).toMatchObject({
|
|
ref: "develop",
|
|
basePath: "",
|
|
explicitRef: true,
|
|
});
|
|
});
|
|
|
|
it("parses a query-string URL with only ?path=", () => {
|
|
expect(parseGitSourceUrl("https://github.com/o/r?path=sub")).toMatchObject({
|
|
ref: null,
|
|
basePath: "sub",
|
|
explicitRef: false,
|
|
});
|
|
});
|
|
|
|
it("query-string parsing takes precedence over path-style segments", () => {
|
|
expect(
|
|
parseGitSourceUrl("https://github.com/o/r/tree/main/old?ref=newref&path=newpath"),
|
|
).toMatchObject({
|
|
ref: "newref",
|
|
basePath: "newpath",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("buildCloneUrl", () => {
|
|
it("produces a .git suffix URL on the given host", () => {
|
|
expect(buildCloneUrl("git.example.com", "o", "r")).toBe(
|
|
"https://git.example.com/o/r.git",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("resolveGitRef", () => {
|
|
it("passes through a 40-hex SHA without hitting the network", async () => {
|
|
const parsed = parseGitSourceUrl(
|
|
"https://github.com/o/r/tree/0123456789abcdef0123456789abcdef01234567",
|
|
);
|
|
const result = await resolveGitRef(parsed);
|
|
expect(result).toEqual({
|
|
pinnedSha: "0123456789abcdef0123456789abcdef01234567",
|
|
trackingRef: "0123456789abcdef0123456789abcdef01234567",
|
|
});
|
|
expect(listServerRefs).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("returns default branch via HEAD symref when ref is absent", async () => {
|
|
listServerRefs.mockResolvedValue([
|
|
{ ref: "HEAD", oid: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", target: "refs/heads/main" },
|
|
{ ref: "refs/heads/main", oid: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" },
|
|
{ ref: "refs/heads/chore", oid: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" },
|
|
]);
|
|
const parsed = parseGitSourceUrl("https://git.example.com/o/r");
|
|
const result = await resolveGitRef(parsed);
|
|
expect(result).toEqual({
|
|
pinnedSha: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
|
trackingRef: "main",
|
|
});
|
|
expect(listServerRefs).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
url: "https://git.example.com/o/r.git",
|
|
symrefs: true,
|
|
protocolVersion: 2,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("resolves a named branch to its SHA", async () => {
|
|
listServerRefs.mockResolvedValue([
|
|
{ ref: "HEAD", oid: "1111111111111111111111111111111111111111", target: "refs/heads/main" },
|
|
{ ref: "refs/heads/main", oid: "1111111111111111111111111111111111111111" },
|
|
{ ref: "refs/heads/develop", oid: "2222222222222222222222222222222222222222" },
|
|
]);
|
|
const parsed = parseGitSourceUrl("https://git.example.com/o/r/src/branch/develop");
|
|
const result = await resolveGitRef(parsed);
|
|
expect(result).toEqual({
|
|
pinnedSha: "2222222222222222222222222222222222222222",
|
|
trackingRef: "develop",
|
|
});
|
|
});
|
|
|
|
it("prefers a peeled annotated tag over the tag object", async () => {
|
|
listServerRefs.mockResolvedValue([
|
|
{ ref: "refs/tags/v1.0", oid: "tttttttttttttttttttttttttttttttttttttttt" },
|
|
{ ref: "refs/tags/v1.0^{}", oid: "cccccccccccccccccccccccccccccccccccccccc" },
|
|
]);
|
|
const parsed = parseGitSourceUrl("https://git.example.com/o/r/src/tag/v1.0");
|
|
const result = await resolveGitRef(parsed);
|
|
expect(result.pinnedSha).toBe("cccccccccccccccccccccccccccccccccccccccc");
|
|
expect(result.trackingRef).toBe("v1.0");
|
|
});
|
|
|
|
it("resolves a lightweight tag when no peeled entry exists", async () => {
|
|
listServerRefs.mockResolvedValue([
|
|
{ ref: "refs/tags/v2.0", oid: "dddddddddddddddddddddddddddddddddddddddd" },
|
|
]);
|
|
const parsed = parseGitSourceUrl("https://git.example.com/o/r/src/tag/v2.0");
|
|
const result = await resolveGitRef(parsed);
|
|
expect(result.pinnedSha).toBe("dddddddddddddddddddddddddddddddddddddddd");
|
|
});
|
|
|
|
it("throws when an explicit ref does not exist", async () => {
|
|
listServerRefs.mockResolvedValue([
|
|
{ ref: "HEAD", oid: "9999999999999999999999999999999999999999", target: "refs/heads/main" },
|
|
{ ref: "refs/heads/main", oid: "9999999999999999999999999999999999999999" },
|
|
]);
|
|
const parsed = parseGitSourceUrl("https://git.example.com/o/r/src/branch/missing");
|
|
await expect(resolveGitRef(parsed)).rejects.toThrow(/Ref 'missing' not found/);
|
|
});
|
|
|
|
it("translates network errors into a user-facing message", async () => {
|
|
listServerRefs.mockRejectedValue(new Error("ENOTFOUND git.invalid"));
|
|
const parsed = parseGitSourceUrl("https://git.invalid/o/r");
|
|
await expect(resolveGitRef(parsed)).rejects.toThrow(/could not connect/i);
|
|
});
|
|
|
|
it("translates 401 errors into an auth message", async () => {
|
|
listServerRefs.mockRejectedValue(new Error("HTTP Error: 401 Unauthorized"));
|
|
const parsed = parseGitSourceUrl("https://git.example.com/o/r");
|
|
await expect(resolveGitRef(parsed)).rejects.toThrow(/authentication/i);
|
|
});
|
|
|
|
it("translates 404 errors into a repo-not-found message", async () => {
|
|
listServerRefs.mockRejectedValue(new Error("HTTP Error: 404 Not Found"));
|
|
const parsed = parseGitSourceUrl("https://git.example.com/o/r");
|
|
await expect(resolveGitRef(parsed)).rejects.toThrow(/repository not found/i);
|
|
});
|
|
|
|
it("sends an onAuth callback when a token is supplied", async () => {
|
|
listServerRefs.mockResolvedValue([
|
|
{ ref: "HEAD", oid: "1111111111111111111111111111111111111111", target: "refs/heads/main" },
|
|
{ ref: "refs/heads/main", oid: "1111111111111111111111111111111111111111" },
|
|
]);
|
|
const parsed = parseGitSourceUrl("https://git.example.com/o/r");
|
|
await resolveGitRef(parsed, "tok_abc");
|
|
const callArgs = listServerRefs.mock.calls[0]![0] as { onAuth: () => unknown };
|
|
expect(typeof callArgs.onAuth).toBe("function");
|
|
expect(callArgs.onAuth()).toEqual({ username: "tok_abc", password: "x-oauth-basic" });
|
|
});
|
|
|
|
it("omits onAuth when no token is supplied", async () => {
|
|
listServerRefs.mockResolvedValue([
|
|
{ ref: "HEAD", oid: "1111111111111111111111111111111111111111", target: "refs/heads/main" },
|
|
{ ref: "refs/heads/main", oid: "1111111111111111111111111111111111111111" },
|
|
]);
|
|
const parsed = parseGitSourceUrl("https://git.example.com/o/r");
|
|
await resolveGitRef(parsed);
|
|
const callArgs = listServerRefs.mock.calls[0]![0] as { onAuth?: unknown };
|
|
expect(callArgs.onAuth).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("openRepoSnapshot", () => {
|
|
it("clones at the tracking ref and walks the tree at the resolved SHA", async () => {
|
|
cloneFn.mockResolvedValue(undefined);
|
|
resolveRefFn.mockResolvedValue("ffffffffffffffffffffffffffffffffffffffff");
|
|
walkFn.mockImplementation(async ({ map }: { map: (filepath: string, entries: Array<{ type: () => Promise<string> }>) => Promise<void> }) => {
|
|
await map(".", [{ type: () => Promise.resolve("tree") }]);
|
|
await map("README.md", [{ type: () => Promise.resolve("blob") }]);
|
|
await map("skills/x/SKILL.md", [{ type: () => Promise.resolve("blob") }]);
|
|
await map("skills/x", [{ type: () => Promise.resolve("tree") }]);
|
|
});
|
|
readBlobFn.mockResolvedValue({ blob: new TextEncoder().encode("hello") });
|
|
|
|
const parsed = parseGitSourceUrl("https://git.example.com/o/r");
|
|
const snap = await openRepoSnapshot(parsed, "main", "ffffffffffffffffffffffffffffffffffffffff", "tok");
|
|
|
|
expect(cloneFn).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
url: "https://git.example.com/o/r.git",
|
|
ref: "main",
|
|
singleBranch: true,
|
|
depth: 1,
|
|
noCheckout: true,
|
|
}),
|
|
);
|
|
expect(snap.sha).toBe("ffffffffffffffffffffffffffffffffffffffff");
|
|
|
|
const files = await snap.listFiles();
|
|
expect(files).toEqual(["README.md", "skills/x/SKILL.md"]);
|
|
|
|
const content = await snap.readFile("README.md");
|
|
expect(content).toBe("hello");
|
|
expect(readBlobFn).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
oid: "ffffffffffffffffffffffffffffffffffffffff",
|
|
filepath: "README.md",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("falls back to the expected SHA as ref when no tracking ref is known", async () => {
|
|
cloneFn.mockResolvedValue(undefined);
|
|
resolveRefFn.mockResolvedValue("abc1234567890abc1234567890abc1234567890a");
|
|
walkFn.mockImplementation(async () => {});
|
|
|
|
const parsed = parseGitSourceUrl("https://git.example.com/o/r");
|
|
await openRepoSnapshot(parsed, null, "abc1234567890abc1234567890abc1234567890a");
|
|
|
|
expect(cloneFn).toHaveBeenCalledWith(
|
|
expect.objectContaining({ ref: "abc1234567890abc1234567890abc1234567890a" }),
|
|
);
|
|
});
|
|
|
|
it("surfaces a 404 from clone as repository-not-found", async () => {
|
|
cloneFn.mockRejectedValue(new Error("HTTP Error: 404 Not Found"));
|
|
const parsed = parseGitSourceUrl("https://git.example.com/o/r");
|
|
await expect(
|
|
openRepoSnapshot(parsed, "main", "1111111111111111111111111111111111111111"),
|
|
).rejects.toThrow(/repository not found/i);
|
|
});
|
|
|
|
it("readBinary returns the raw blob bytes", async () => {
|
|
cloneFn.mockResolvedValue(undefined);
|
|
resolveRefFn.mockResolvedValue("ffffffffffffffffffffffffffffffffffffffff");
|
|
walkFn.mockImplementation(async () => {});
|
|
const bytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47]);
|
|
readBlobFn.mockResolvedValue({ blob: bytes });
|
|
|
|
const parsed = parseGitSourceUrl("https://git.example.com/o/r");
|
|
const snap = await openRepoSnapshot(parsed, "main", "ffffffffffffffffffffffffffffffffffffffff");
|
|
const result = await snap.readBinary("logo.png");
|
|
expect(result).toBe(bytes);
|
|
});
|
|
|
|
it("readFileOptional returns null on NotFoundError", async () => {
|
|
cloneFn.mockResolvedValue(undefined);
|
|
resolveRefFn.mockResolvedValue("ffffffffffffffffffffffffffffffffffffffff");
|
|
walkFn.mockImplementation(async () => {});
|
|
const err = Object.assign(new Error("missing"), { code: "NotFoundError" });
|
|
readBlobFn.mockRejectedValue(err);
|
|
|
|
const parsed = parseGitSourceUrl("https://git.example.com/o/r");
|
|
const snap = await openRepoSnapshot(parsed, "main", "ffffffffffffffffffffffffffffffffffffffff");
|
|
const result = await snap.readFileOptional("missing.md");
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it("readFileOptional rethrows non-NotFound errors", async () => {
|
|
cloneFn.mockResolvedValue(undefined);
|
|
resolveRefFn.mockResolvedValue("ffffffffffffffffffffffffffffffffffffffff");
|
|
walkFn.mockImplementation(async () => {});
|
|
readBlobFn.mockRejectedValue(new Error("disk explosion"));
|
|
|
|
const parsed = parseGitSourceUrl("https://git.example.com/o/r");
|
|
const snap = await openRepoSnapshot(parsed, "main", "ffffffffffffffffffffffffffffffffffffffff");
|
|
await expect(snap.readFileOptional("any.md")).rejects.toThrow(/disk explosion/);
|
|
});
|
|
});
|