diff --git a/web/src/lib/api/repoCompare.ts b/web/src/lib/api/repoCompare.ts new file mode 100644 index 000000000..1f3197318 --- /dev/null +++ b/web/src/lib/api/repoCompare.ts @@ -0,0 +1,126 @@ +import type { Did } from "@atcute/lexicons/syntax"; +import type { GitRepoInfo } from "#lib/components/repo/types.ts"; +import { createBobbinClient } from "#lib/api/client.ts"; +import { + branches as listBranches, + tags as listTags, + gitTarget, + type GitServiceConfig +} from "#lib/api/gitclient.ts"; +import { + commitAtRef, + getMergeBase, + listCommits, + toMirrorCommitSummary +} from "#lib/api/knotmirror.ts"; +import { sortBranchesByRecency } from "#lib/api/pullCompose.ts"; +import { + branchSummaries, + toTagSummary, + type BranchSummary, + type CommitSummary, + type TagSummary +} from "#lib/api/repo.ts"; + +const REF_LIMIT = 100; +const COMMIT_LIMIT = 100; + +// the spec is `base...head` as on github. a git ref cannot contain `..`, thus the split is not ambiguous +export const parseCompareSpec = (spec: string): { base: string; head: string } | null => { + const at = spec.indexOf("..."); + if (at < 0) return null; + return { base: spec.slice(0, at), head: spec.slice(at + 3) }; +}; + +export const comparePath = (repoPath: string, base: string, head: string): string => + base && head + ? `${repoPath}/compare/${encodeURIComponent(base)}...${encodeURIComponent(head)}` + : `${repoPath}/compare?${new URLSearchParams({ ...(base && { base }), ...(head && { head }) })}`; + +export interface CompareData { + branches: BranchSummary[]; + tags: TagSummary[]; + base: string; + head: string; + baseHead: string; + headHead: string; + mergeBase: string; + commits: CommitSummary[]; + error: string; +} + +export const loadCompare = async (args: { + config: GitServiceConfig; + repo: GitRepoInfo; + base: string; + head: string; + fetch: typeof globalThis.fetch; +}): Promise => { + const { config, repo, fetch: fetchFn } = args; + const repoDid = repo.repoDid as Did; + const ctx = createBobbinClient({ serviceUrl: config.bobbinUrl, fetch: fetchFn }); + const git = gitTarget(config, repo, fetchFn); + + const [branchList, tagList] = await Promise.all([ + listBranches(git, REF_LIMIT) + .then(branchSummaries) + .catch(() => [] as BranchSummary[]), + listTags(git, REF_LIMIT) + .then((page) => page.tags.map(toTagSummary)) + .catch(() => [] as TagSummary[]) + ]); + + const base = args.base || repo.git.defaultBranch; + const head = + args.head || + sortBranchesByRecency(branchList).find((branch) => branch.name !== base)?.name || + ""; + + // a listed ref resolves without a request. other refs, for example a commit hash, go to the mirror + const resolveRef = async (ref: string): Promise => { + if (!ref) return ""; + const listed = + branchList.find((branch) => branch.name === ref)?.hash ?? + tagList.find((tag) => tag.name === ref)?.commitHash; + if (listed) return listed; + return (await commitAtRef(ctx, repoDid, ref))?.oid ?? ""; + }; + + let baseHead = ""; + let headHead = ""; + let mergeBase = ""; + let commits: CommitSummary[] = []; + let error = ""; + try { + [baseHead, headHead] = await Promise.all([resolveRef(base), resolveRef(head)]); + if (base && !baseHead) error = `Could not find ${base} in this repository.`; + else if (head && !headHead) error = `Could not find ${head} in this repository.`; + else if (baseHead && headHead && baseHead !== headHead) { + const [page, merge] = await Promise.all([ + listCommits(ctx, { + repos: [repoDid], + ranges: [`${baseHead}..${headHead}`], + limit: COMMIT_LIMIT, + includeStats: true + }), + getMergeBase(ctx, { repos: [repoDid], base: baseHead, head: headHead }) + ]); + commits = page.commits.map(toMirrorCommitSummary); + mergeBase = merge.commit; + } + } catch (cause) { + error = cause instanceof Error ? cause.message : String(cause); + } + + return { + branches: branchList, + tags: tagList, + base, + head, + baseHead, + headHead, + mergeBase, + commits, + error + }; +}; diff --git a/web/src/lib/components/repo/CompareView.svelte b/web/src/lib/components/repo/CompareView.svelte new file mode 100644 index 000000000..5eb2264ae --- /dev/null +++ b/web/src/lib/components/repo/CompareView.svelte @@ -0,0 +1,224 @@ + + +{#snippet notice(message: string, danger = false)} +
+ {message} +
+{/snippet} + +
+ +
+ + {#snippet children({ id })} + + {/snippet} + +
+ + {#if pullHref} + + {/if} +
+
+
+ + {#if compare.error} + {@render notice(compare.error, true)} + {:else if !compare.head} + {@render notice("Pick a ref to compare against the base.")} + {:else if commits.length === 0} + {@render notice( + compare.baseHead === compare.headHead + ? `${compare.base} and ${compare.head} point at the same commit, nothing to compare.` + : `${compare.base} is up to date with all commits from ${compare.head}.` + )} + {:else} + + + { + stagedRange = range; + stagedWhole = whole; + } + }} + /> + + + + {/if} +
diff --git a/web/src/routes/[handle]/[repo]/compare/[...spec]/+page.svelte b/web/src/routes/[handle]/[repo]/compare/[...spec]/+page.svelte new file mode 100644 index 000000000..5e7b34458 --- /dev/null +++ b/web/src/routes/[handle]/[repo]/compare/[...spec]/+page.svelte @@ -0,0 +1,11 @@ + + + + {@const repo = await data.repo} + + diff --git a/web/src/routes/[handle]/[repo]/compare/[...spec]/+page.ts b/web/src/routes/[handle]/[repo]/compare/[...spec]/+page.ts new file mode 100644 index 000000000..e4bc40f5f --- /dev/null +++ b/web/src/routes/[handle]/[repo]/compare/[...spec]/+page.ts @@ -0,0 +1,36 @@ +import { requireGit } from "#lib/api/gitclient.ts"; +import { settle, stream } from "#lib/api/load.ts"; +import { loadCompare, parseCompareSpec } from "#lib/api/repoCompare.ts"; +import type { PageLoad } from "./$types"; + +export const load: PageLoad = async (event) => { + const parent = await event.parent(); + const repo = stream(Promise.resolve(parent.repo).then(requireGit)); + const handle = decodeURIComponent(event.params.handle ?? ""); + const name = decodeURIComponent(event.params.repo ?? ""); + const spec = parseCompareSpec(event.params.spec ?? ""); + const base = spec?.base ?? event.url.searchParams.get("base") ?? ""; + const head = spec?.head ?? event.url.searchParams.get("head") ?? ""; + + return settle({ + repo, + compare: stream( + repo.then((info) => + loadCompare({ + config: parent.publicConfig, + repo: info, + base, + head, + fetch: event.fetch + }) + ) + ), + meta: { + noindex: true, + title: + base && head + ? `Comparing ${base}...${head} · ${handle}/${name}` + : `Compare · ${handle}/${name}` + } + }); +};