import { ok } from '@atcute/client';
import type { ResolvedActor } from '@atcute/identity-resolver';
import type { Did, Nsid, ResourceUri } from '@atcute/lexicons/syntax';
import { ShTangledRepo } from '@atcute/tangled';
import {
appviewFallback,
appviewJson,
appviewOptionalKnotRequest,
getRepoViaAppview,
listAllAppviewRecords,
type AppviewRecordView,
} from './appview';
import { getBacklinksPage } from './backlinks';
import type { OAuthUserAgent } from '@atcute/oauth-browser-client';
import { getRpc, normalizeServiceUrl, createServiceAuthRpc, createAuthRpc } from './client';
import { REPO_COLLECTION } from './constants';
import { resolveActor } from './identity';
import {
maxTimestamp,
parseAtUri,
timestampFromDate,
type RecordCreationResult,
} from './records';
export interface RepoContext {
owner: ResolvedActor;
record: RepoRecord;
repoDid: Did;
slug: string;
rkey: string;
knot: string;
}
export interface RepoRecord {
uri: ResourceUri;
cid: string;
rkey: string;
value: ShTangledRepo.Main;
}
export interface TreeResponse {
ref: string;
parent?: string;
dotdot?: string;
lastCommit?: CommitHeadline;
readme?: {
contents: string;
filename: string;
};
files: TreeEntry[];
}
export interface TreeEntry {
name: string;
mode: string;
size: number;
last_commit?: CommitHeadline;
}
export interface CommitHeadline {
hash: string;
message: string;
when: string;
author?: {
email: string;
name: string;
when: string;
};
}
export interface BranchResponse {
branches: BranchEntry[];
}
export interface DefaultBranchResponse {
name?: string;
branch?: string;
reference?: {
name?: string;
};
hash?: string;
when?: string;
}
export interface BranchEntry {
reference: {
name: string;
hash: string;
};
commit?: {
Message: string;
Author?: {
Name: string;
Email: string;
When: string;
};
Committer?: {
Name: string;
Email: string;
When: string;
};
};
is_default?: boolean;
}
export interface TagResponse {
tags: Array<{
name: string;
hash: string;
message?: string;
tag?: {
Name: string;
Message: string;
Tagger?: {
Name: string;
Email: string;
When: string;
};
};
}>;
}
export interface LanguageResponse {
ref: string;
totalFiles?: number;
totalSize?: number;
languages: Array<{
name: string;
percentage: number;
size: number;
color?: string;
}>;
}
export type GitHashValue =
| string
| number[]
| {
String?: string;
string?: string;
};
export interface CommitSignature {
Name: string;
Email: string;
When: string;
}
export interface CommitEntry {
hash?: GitHashValue;
this: string;
message: string;
tree?: string;
author?: CommitSignature;
committer?: CommitSignature;
pgp_signature?: string;
parent?: string;
parent_hashes?: GitHashValue[];
}
export interface LogResponse {
ref: string;
total: number;
page: number;
per_page: number;
log: boolean;
commits: CommitEntry[];
}
export interface DiffTextLine {
Op: 0 | 1 | 2 | ' ' | '-' | '+';
Line: string;
}
export interface DiffTextFragment {
Comment?: string;
OldPosition: number;
OldLines: number;
NewPosition: number;
NewLines: number;
LinesAdded: number;
LinesDeleted: number;
LeadingContext?: number;
TrailingContext?: number;
Lines: DiffTextLine[];
}
export interface RepoDiffFile {
name: {
old: string;
new: string;
};
text_fragments: DiffTextFragment[];
is_binary: boolean;
is_new: boolean;
is_delete: boolean;
is_copy: boolean;
is_rename: boolean;
}
export interface RepoDiffStat {
insertions: number;
deletions: number;
files_changed: number;
}
export interface RepoDiff {
commit: CommitEntry;
stat: RepoDiffStat;
diff: RepoDiffFile[];
}
export interface RepoCommitResponse {
ref: string;
diff?: RepoDiff;
}
export interface BlobResponse {
path: string;
ref: string;
content?: string;
encoding?: 'base64' | 'utf-8';
fileTooLarge?: boolean;
isBinary?: boolean;
mimeType?: string;
size?: number;
lastCommit?: CommitHeadline;
submodule?: {
name: string;
url: string;
branch?: string;
};
}
export interface ComparePatch {
Title: string;
Body: string;
Raw: string;
SHA: string;
Author?: string;
Committer?: string;
AuthorDate?: string;
CommitterDate?: string;
SubjectPrefix?: string;
RawHeaders?: string;
BodyAppendix?: string;
}
export interface CompareResponse {
rev1: string;
rev2: string;
patch: string;
format_patch: ComparePatch[];
}
const fetchAllRepoRecords = async (
actor: ResolvedActor,
collection: Nsid,
): Promise<Array<{ uri: ResourceUri; cid: string; value: unknown }>> => {
const rpc = getRpc(actor.pds);
const records: Array<{ uri: ResourceUri; cid: string; value: unknown }> = [];
let cursor: string | undefined;
do {
const page = await ok(
rpc.get('com.atproto.repo.listRecords', {
params: {
repo: actor.did,
collection,
limit: 100,
cursor,
reverse: true,
},
}),
);
records.push(...page.records);
cursor = page.cursor;
} while (cursor);
return records;
};
const listRepoRecordsFromAppview = async (owner: string | ResolvedActor): Promise<RepoRecord[]> => {
const actor = typeof owner === 'string' ? await resolveActor(owner) : owner;
const records = await listAllAppviewRecords<ShTangledRepo.Main>('sh.tangled.repo.listRepos', actor.did);
return records.map((record) => ({
uri: record.uri,
cid: record.cid ?? '',
rkey: parseAtUri(record.uri).rkey,
value: record.value,
}));
};
const listRepoRecordsFromPds = async (owner: string | ResolvedActor): Promise<RepoRecord[]> => {
const actor = typeof owner === 'string' ? await resolveActor(owner) : owner;
const records = await fetchAllRepoRecords(actor, REPO_COLLECTION);
return records.map((record) => ({
uri: record.uri,
cid: record.cid,
rkey: parseAtUri(record.uri).rkey,
value: record.value as ShTangledRepo.Main,
}));
};
export const listRepoRecords = async (owner: string | ResolvedActor): Promise<RepoRecord[]> =>
appviewFallback(
() => listRepoRecordsFromAppview(owner),
() => listRepoRecordsFromPds(owner),
);
export const getRepo = async (ownerIdentifier: string, repoSlug: string): Promise<RepoContext> => {
const owner = await resolveActor(ownerIdentifier);
const all = await listRepoRecords(owner);
const wanted = repoSlug.trim().toLowerCase();
const matches = all.filter((record) => {
const name = record.value.name?.trim().toLowerCase();
return name === wanted || record.rkey.toLowerCase() === wanted;
});
if (matches.length === 0) {
throw new Error(`Repository not found: ${ownerIdentifier}/${repoSlug}`);
}
// Prefer a record that doesn't use a localhost knot if multiple match
const match = matches.find((r) => !r.value.knot?.includes('localhost')) || matches[0];
if (!match.value.repoDid) {
throw new Error(`Repository is missing repoDid metadata`);
}
const slug = match.value.name?.trim() || match.rkey;
return {
owner,
record: match,
repoDid: match.value.repoDid,
slug,
rkey: match.rkey,
knot: normalizeServiceUrl(match.value.knot),
};
};
export const getRepoByDid = async (repoDid: Did): Promise<RepoContext> => {
const record = await appviewJson<AppviewRecordView<ShTangledRepo.Main>>(
'sh.tangled.repo.getRepoByRepoDid',
{ repoDid },
);
const parsed = parseAtUri(record.uri);
const owner = await resolveActor(parsed.did);
const slug = record.value.name?.trim() || parsed.rkey;
if (!record.value.repoDid) {
throw new Error(`Repository is missing repoDid metadata`);
}
return {
owner,
record: {
uri: record.uri,
cid: record.cid ?? '',
rkey: parsed.rkey,
value: record.value,
},
repoDid: record.value.repoDid,
slug,
rkey: parsed.rkey,
knot: normalizeServiceUrl(record.value.knot),
};
};
const getRepoTreeFromKnot = async (
repo: RepoContext,
ref: string,
path = '',
): Promise<TreeResponse> =>
(await ok(
getRpc(repo.knot).get('sh.tangled.repo.tree', {
params: {
repo: repo.repoDid,
ref,
path,
},
}),
)) as TreeResponse;
const getRepoBranchesFromKnot = async (repo: RepoContext): Promise<BranchResponse> =>
(await ok(
getRpc(repo.knot).get('sh.tangled.repo.branches', {
params: {
repo: repo.repoDid,
},
as: 'json',
}),
)) as BranchResponse;
const branchUpdatedTimestamp = (branch: BranchEntry): number =>
maxTimestamp(
timestampFromDate(branch.commit?.Committer?.When),
timestampFromDate(branch.commit?.Author?.When),
);
const sortBranchResponseByUpdatedAt = (response: BranchResponse): BranchResponse => ({
...response,
branches: Array.isArray(response?.branches)
? [...response.branches].sort((left, right) => {
const updatedDiff = branchUpdatedTimestamp(right) - branchUpdatedTimestamp(left);
if (updatedDiff !== 0) {
return updatedDiff;
}
if (left.is_default !== right.is_default) {
return left.is_default ? -1 : 1;
}
return left.reference.name.localeCompare(right.reference.name);
})
: [],
});
const getRepoDefaultBranchFromKnot = async (repo: RepoContext): Promise<DefaultBranchResponse> =>
(await ok(
getRpc(repo.knot).get('sh.tangled.repo.getDefaultBranch', {
params: {
repo: repo.repoDid,
},
}),
)) as DefaultBranchResponse;
const getRepoTagsFromKnot = async (repo: RepoContext): Promise<TagResponse> =>
(await ok(
getRpc(repo.knot).get('sh.tangled.repo.tags', {
params: {
repo: repo.repoDid,
},
as: 'json',
}),
)) as TagResponse;
const getRepoLanguagesFromKnot = async (repo: RepoContext, ref: string): Promise<LanguageResponse> =>
(await ok(
getRpc(repo.knot).get('sh.tangled.repo.languages', {
params: {
repo: repo.repoDid,
ref,
},
}),
)) as LanguageResponse;
export interface RepoLogOptions {
limit?: number;
cursor?: string;
path?: string;
}
const getRepoLogFromKnot = async (
repo: RepoContext,
ref: string,
options: RepoLogOptions = {},
): Promise<LogResponse> =>
(await ok(
getRpc(repo.knot).get('sh.tangled.repo.log', {
params: {
repo: repo.repoDid,
ref,
limit: options.limit ?? 8,
...(options.cursor ? { cursor: options.cursor } : {}),
...(options.path ? { path: options.path } : {}),
},
as: 'json',
}),
)) as LogResponse;
const getRepoCommitFromKnot = async (
repo: RepoContext,
ref: string,
): Promise<RepoCommitResponse> =>
(await ok(
getRpc(repo.knot).get('sh.tangled.repo.diff', {
params: {
repo: repo.repoDid,
ref,
},
as: 'json',
}),
)) as RepoCommitResponse;
const getRepoBlobFromKnot = async (
repo: RepoContext,
ref: string,
path: string,
): Promise<BlobResponse> =>
(await ok(
getRpc(repo.knot).get('sh.tangled.repo.blob', {
params: {
repo: repo.repoDid,
ref,
path,
},
}),
)) as BlobResponse;
const compareBranchesFromKnot = async (
repo: RepoContext,
rev1: string,
rev2: string,
): Promise<CompareResponse> =>
(await ok(
getRpc(repo.knot).get('sh.tangled.repo.compare', {
params: {
repo: repo.repoDid,
rev1,
rev2,
},
as: 'json',
}),
)) as CompareResponse;
export const getRepoTree = async (
repo: RepoContext,
ref: string,
path = '',
): Promise<TreeResponse> =>
appviewOptionalKnotRequest(
() => getRepoViaAppview<TreeResponse>('sh.tangled.repo.tree', repo, { ref, path }),
() => getRepoTreeFromKnot(repo, ref, path),
);
const isRefNotFoundError = (err: unknown): boolean => {
if (!err) return false;
if (typeof err === 'object') {
const e = err as any;
if (e.error === 'RefNotFound' || e.status === 404 || e.name === 'RefNotFound' || e.code === 'RefNotFound') {
return true;
}
if (e.message) {
const lower = String(e.message).toLowerCase();
if (
lower.includes('refnotfound') ||
lower.includes('failed to access ref') ||
lower.includes('reference not found') ||
lower.includes('ref not found')
) {
return true;
}
}
}
const msg = String(err).toLowerCase();
return (
msg.includes('refnotfound') ||
msg.includes('failed to access ref') ||
msg.includes('reference not found') ||
msg.includes('ref not found')
);
};
export const getRepoBranches = async (repo: RepoContext): Promise<BranchResponse> =>
appviewOptionalKnotRequest(
() => getRepoViaAppview<BranchResponse>('sh.tangled.repo.branches', repo),
() => getRepoBranchesFromKnot(repo),
)
.then(sortBranchResponseByUpdatedAt)
.catch((err) => {
if (isRefNotFoundError(err)) {
return { branches: [] };
}
throw err;
});
export const getRepoDefaultBranch = async (repo: RepoContext): Promise<DefaultBranchResponse> =>
appviewOptionalKnotRequest(
() => getRepoViaAppview<DefaultBranchResponse>('sh.tangled.repo.getDefaultBranch', repo),
() => getRepoDefaultBranchFromKnot(repo),
);
export const getRepoTags = async (repo: RepoContext): Promise<TagResponse> =>
appviewOptionalKnotRequest(
() => getRepoViaAppview<TagResponse>('sh.tangled.repo.tags', repo),
() => getRepoTagsFromKnot(repo),
);
export const getRepoLanguages = async (repo: RepoContext, ref: string): Promise<LanguageResponse> =>
appviewOptionalKnotRequest(
() => getRepoViaAppview<LanguageResponse>('sh.tangled.repo.languages', repo, { ref }),
() => getRepoLanguagesFromKnot(repo, ref),
);
export const getRepoLog = async (
repo: RepoContext,
ref: string,
options: RepoLogOptions = {},
): Promise<LogResponse> =>
appviewOptionalKnotRequest(
() => getRepoViaAppview<LogResponse>('sh.tangled.repo.log', repo, { ref, ...options, limit: options.limit ?? 8 }),
() => getRepoLogFromKnot(repo, ref, options),
);
export const getRepoCommit = async (
repo: RepoContext,
ref: string,
): Promise<RepoCommitResponse> =>
appviewOptionalKnotRequest(
() => getRepoViaAppview<RepoCommitResponse>('sh.tangled.repo.diff', repo, { ref }),
() => getRepoCommitFromKnot(repo, ref),
);
export const getRepoBlob = async (
repo: RepoContext,
ref: string,
path: string,
): Promise<BlobResponse> =>
appviewOptionalKnotRequest(
() => getRepoViaAppview<BlobResponse>('sh.tangled.repo.blob', repo, { ref, path }),
() => getRepoBlobFromKnot(repo, ref, path),
);
export const compareBranches = async (
repo: RepoContext,
rev1: string,
rev2: string,
): Promise<CompareResponse> =>
appviewOptionalKnotRequest(
() => getRepoViaAppview<CompareResponse>('sh.tangled.repo.compare', repo, { rev1, rev2 }),
() => compareBranchesFromKnot(repo, rev1, rev2),
);
export const decodeBlobText = (blob: BlobResponse): string => {
if (!blob.content) {
return '';
}
if (blob.encoding === 'base64') {
const buffer = Uint8Array.from(atob(blob.content), (char) => char.charCodeAt(0));
return new TextDecoder().decode(buffer);
}
return blob.content;
};
export const decodeBlobBytes = (blob: BlobResponse): Uint8Array => {
if (!blob.content) {
return new Uint8Array();
}
if (blob.encoding === 'base64') {
return Uint8Array.from(atob(blob.content), (char) => char.charCodeAt(0));
}
return new TextEncoder().encode(blob.content);
};
export const buildBlobDataUrl = (blob: BlobResponse): string | null => {
if (!blob.content || !blob.mimeType) {
return null;
}
if (blob.encoding === 'base64') {
return `data:${blob.mimeType};base64,${blob.content}`;
}
return `data:${blob.mimeType};charset=utf-8,${encodeURIComponent(blob.content)}`;
};
export const createHiddenRef = async (
agent: OAuthUserAgent,
forkKnot: string,
input: {
forkRef: string;
remoteRef: string;
repo: ResourceUri;
},
): Promise<void> => {
const rpc = await createServiceAuthRpc(agent, forkKnot, 'sh.tangled.repo.hiddenRef');
await ok(
rpc.post('sh.tangled.repo.hiddenRef', {
input: {
forkRef: input.forkRef,
remoteRef: input.remoteRef,
repo: input.repo,
},
}),
);
};
export const compareFork = async (
agent: OAuthUserAgent,
forkRepo: RepoContext,
targetBranch: string,
sourceBranch: string,
): Promise<CompareResponse> => {
await createHiddenRef(agent, forkRepo.knot, {
forkRef: sourceBranch,
remoteRef: targetBranch,
repo: forkRepo.record.uri,
});
const hiddenRef = `hidden/${sourceBranch}/${targetBranch}`;
return compareBranches(forkRepo, hiddenRef, sourceBranch);
};
export const getRepoForkCount = async (repo: RepoContext): Promise<number> => {
try {
const source = 'sh.tangled.repo:source';
const [didPage, uriPage] = await Promise.all([
getBacklinksPage(repo.repoDid, source, 1),
getBacklinksPage(repo.record.uri, source, 1),
]);
const didCount = didPage.total ?? didPage.records.length;
const uriCount = uriPage.total ?? uriPage.records.length;
return didCount + uriCount;
} catch {
return 0;
}
};
export const listUserKnots = async (actorIdentifier: string | ResolvedActor): Promise<string[]> => {
const actor = typeof actorIdentifier === 'string' ? await resolveActor(actorIdentifier) : actorIdentifier;
const rpc = getRpc(actor.pds);
const records: string[] = [];
let cursor: string | undefined;
try {
do {
const page = await ok(
rpc.get('com.atproto.repo.listRecords', {
params: {
repo: actor.did,
collection: 'sh.tangled.knot',
limit: 100,
cursor,
reverse: true,
},
}),
);
for (const r of page.records) {
const rkey = parseAtUri(r.uri).rkey;
records.push(rkey);
}
cursor = page.cursor;
} while (cursor);
} catch (err) {
console.error('Failed to list knots from PDS:', err);
}
return records;
};
const cleanedRepoName = (name: string): string => {
const repoName = name.trim();
return repoName.endsWith('.git') ? repoName.slice(0, -4) : repoName;
};
const repoRkeyFromName = (name: string): string => cleanedRepoName(name).toLowerCase();
const forkSourceUrl = (sourceRepo: RepoContext): string =>
`${normalizeServiceUrl(sourceRepo.knot).replace(/\/+$/, '')}/${sourceRepo.repoDid}`;
const deleteRepoFromKnot = async (
agent: OAuthUserAgent,
knot: string,
rkey: string,
): Promise<void> => {
const knotDeleteClient = await createServiceAuthRpc(agent, knot, 'sh.tangled.repo.delete');
await ok(
knotDeleteClient.post('sh.tangled.repo.delete', {
input: {
did: agent.sub,
name: rkey,
rkey,
},
as: null,
}),
);
};
export const createRepo = async (
agent: OAuthUserAgent,
input: {
name: string;
description?: string;
defaultBranch: string;
knot: string;
},
): Promise<RecordCreationResult> => {
const cleanedName = cleanedRepoName(input.name);
const rkey = repoRkeyFromName(input.name);
const knotClient = await createServiceAuthRpc(agent, input.knot, 'sh.tangled.repo.create');
const createResult = (await ok(
knotClient.post('sh.tangled.repo.create', {
input: {
rkey,
name: rkey,
defaultBranch: input.defaultBranch || 'main',
},
}),
)) as { repoDid?: string };
const repoDid = createResult.repoDid;
if (!repoDid) {
throw new Error('Knot failed to mint a repo DID.');
}
const pdsRpc = createAuthRpc(agent);
const record = {
$type: 'sh.tangled.repo',
name: cleanedName,
knot: input.knot,
repoDid: repoDid,
description: input.description?.trim() || '',
createdAt: new Date().toISOString(),
};
try {
const result = await ok(
pdsRpc.post('com.atproto.repo.createRecord', {
input: {
repo: agent.sub,
collection: REPO_COLLECTION,
rkey: rkey,
record,
},
}),
);
return {
uri: result.uri,
rkey: parseAtUri(result.uri).rkey,
};
} catch (pdsErr) {
try {
await deleteRepoFromKnot(agent, input.knot, rkey);
} catch (deleteErr) {
console.error('Failed to clean up repo on knot:', deleteErr);
}
throw pdsErr;
}
};
export const forkRepo = async (
agent: OAuthUserAgent,
input: {
sourceRepo: RepoContext;
name: string;
description?: string;
knot: string;
},
): Promise<RecordCreationResult> => {
const cleanedName = cleanedRepoName(input.name);
const rkey = repoRkeyFromName(input.name);
const knotClient = await createServiceAuthRpc(agent, input.knot, 'sh.tangled.repo.create');
const createResult = (await ok(
knotClient.post('sh.tangled.repo.create', {
input: {
rkey,
name: rkey,
source: forkSourceUrl(input.sourceRepo),
},
}),
)) as { repoDid?: string };
const repoDid = createResult.repoDid;
if (!repoDid) {
throw new Error('Knot failed to mint a repo DID.');
}
const pdsRpc = createAuthRpc(agent);
const record = {
$type: 'sh.tangled.repo',
name: cleanedName,
knot: input.knot,
repoDid,
source: input.sourceRepo.repoDid || input.sourceRepo.record.uri,
description: input.description?.trim() || '',
createdAt: new Date().toISOString(),
};
try {
const result = await ok(
pdsRpc.post('com.atproto.repo.createRecord', {
input: {
repo: agent.sub,
collection: REPO_COLLECTION,
rkey,
record,
},
}),
);
return {
uri: result.uri,
rkey: parseAtUri(result.uri).rkey,
};
} catch (pdsErr) {
try {
await deleteRepoFromKnot(agent, input.knot, rkey);
} catch (deleteErr) {
console.error('Failed to clean up fork on knot:', deleteErr);
}
throw pdsErr;
}
};