diff --git a/CHANGELOG.md b/CHANGELOG.md index f8cf866..e4dd817 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +## next + +- Added `repo.describeRef(ref)` method, which returns an information object about the reference + ## 0.1.3 (2023-10-13) - Fixed size computation of on-disk reverse index (`.rev` files) diff --git a/README.md b/README.md index 325a214..b72e8a7 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,6 @@ const commits = await repo.log({ ref: 'my-branch', depth: 10 }); console.log(commits); await repo.dispose(); -}); ``` #### createGitReader(gitdir, options?) @@ -58,6 +57,15 @@ The algorithm to identify a default branch name: - `main` - `master` +#### repo.isRefExists(ref) + +Checks if a `ref` exists. + +```js +const isValidRef = repo.isRefExists('main'); +// true +``` + #### repo.expandRef(ref) Expands a `ref` into a full form, e.g. `'main'` -> `'refs/heads/main'`. @@ -78,13 +86,50 @@ const oid = repo.resolveRef('main'); // '8bb6e23769902199e39ab70f2441841712cbdd62' ``` -#### repo.isRefExists(ref) +#### repo.describeRef(ref) -Checks if a `ref` exists. +Returns an info object for provided `ref`. ```js -const isValidRef = repo.isRefExists('main'); -// true +const info = repo.describeRef('HEAD'); +// { +// path: 'HEAD', +// name: 'HEAD', +// symbolic: true, +// ref: 'refs/heads/test', +// oid: '2dbee47a8d4f8d39e1168fad951b703ee05614d6' +// } +``` + +```js +const info = repo.describeRef('main'); +// { +// path: 'refs/heads/main', +// name: 'main', +// symbolic: false, +// scope: 'refs/heads', +// namespace: 'refs', +// category: 'heads', +// remote: null, +// ref: null, +// oid: '7b84f676f2fbea2a3c6d83924fa63059c7bdfbe2' +// } +``` + +```js +const info = repo.describeRef('origin/HEAD'); +// { +// path: 'refs/remotes/origin/HEAD', +// name: 'HEAD', +// symbolic: false, +// scope: 'refs/remotes', +// namespace: 'refs', +// category: 'remotes', +// remote: 'origin', +// ref: 'refs/remotes/origin/main', +// oid: '7b84f676f2fbea2a3c6d83924fa63059c7bdfbe2' +// } +``` ``` #### repo.listRemotes() diff --git a/src/resolve-ref.ts b/src/resolve-ref.ts index e1cf0c6..bf03477 100644 --- a/src/resolve-ref.ts +++ b/src/resolve-ref.ts @@ -2,8 +2,36 @@ import { promises as fsPromises, existsSync } from 'fs'; import { join as pathJoin, basename, sep as pathSep } from 'path'; import { scanFs } from '@discoveryjs/scan-fs'; -type Ref = { name: string; oid: string }; -type LooseRefFile = { path: string; content: string | null }; +type Ref = { + name: string; + oid: string; +}; +type RefSourceInfo = { + ref: string | null; + oid: string | null; +}; +type LooseRefFile = { + path: string; + content: string | null; +}; +type RefResolver = { + remotes: string[]; + names: string[]; + exists(ref: string): boolean; + resolveOid(ref: string): Promise; + resolve(ref: string): Promise; +}; +type RefInfo = { + path: string; + name: string; + symbolic: boolean; + scope: string; + namespace: string; + category: string; + remote: string | null; + ref: string | null; + oid: string | null; +}; // NOTICE: Don't forget to update README.md when change the values const symbolicRefs = new Set(['HEAD', 'FETCH_HEAD', 'CHERRY_PICK_HEAD', 'MERGE_HEAD', 'ORIG_HEAD']); @@ -18,7 +46,7 @@ const refpaths = (ref: string) => [ ]; function isOid(value: unknown) { - return typeof value === 'string' && value.length === 40 && /[0-9a-f]{40}/.test(value); + return typeof value === 'string' && value.length === 40 && /^[0-9a-f]{40}$/.test(value); } export async function createRefIndex(gitdir: string) { @@ -40,19 +68,49 @@ export async function createRefIndex(gitdir: string) { // Nothing found return null; }; - const resolveRef = async (ref: string) => { + const resolveRef = async (ref: string): Promise => { // Is it a complete and valid SHA? if (isOid(ref)) { return ref; } - const expandedRef = await expandRef(ref); + const expandedRef = expandRef(ref); if (expandedRef === null) { throw new Error(`Reference "${ref}" is not found`); } - return refResolver.resolve(expandedRef); + return refResolver.resolveOid(expandedRef); + }; + const describeRef = async (ref: string): Promise => { + const expandedRef = expandRef(ref); + + if (expandedRef === null) { + throw new Error(`Reference "${ref}" is not found`); + } + + const refInfo = await refResolver.resolve(expandedRef); + + const [, scope, path] = expandedRef.match(/^([^/]+\/[^/]+)\/(.+)$/) || [ + '', + 'refs/heads', + expandedRef + ]; + const [namespace, category] = scope.split('/'); + const remoteMatch = scope === 'refs/remotes' ? path.match(/^([^/]+)\/(.+)$/) : null; + const [remote = null, name] = remoteMatch ? remoteMatch.slice(1) : [null, path]; + + return { + path: expandedRef, + name, + symbolic: symbolicRefs.has(name), + scope, + namespace, + category, + remote, + ref: refInfo.ref, + oid: refInfo.oid + } satisfies RefInfo; }; const listRemotes = () => refResolver.remotes.slice(); @@ -79,14 +137,14 @@ export async function createRefIndex(gitdir: string) { let cachedRefsWithOid = listRefsWithOidCache.get(prefix); if (cachedRefsWithOid === undefined) { - cachedRefsWithOid = []; + const oids = await Promise.all( + cachedRefs.map((name) => refResolver.resolveOid(prefix + name)) + ); - for (const name of cachedRefs) { - cachedRefsWithOid.push({ - name, - oid: await refResolver.resolve(prefix + name) - }); - } + cachedRefsWithOid = cachedRefs.map((name, index) => ({ + name, + oid: oids[index] + })); listRefsWithOidCache.set(prefix, cachedRefsWithOid); } @@ -106,13 +164,10 @@ export async function createRefIndex(gitdir: string) { ); return { + isRefExists: (ref: string) => expandRef(ref) !== null, resolveRef, - expandRef(ref: string) { - return isOid(ref) ? ref : expandRef(ref); - }, - async isRefExists(ref: string) { - return (await expandRef(ref)) !== null; - }, + expandRef: (ref: string) => (isOid(ref) ? ref : expandRef(ref)), + describeRef, listRemotes, listRemoteBranches, @@ -162,16 +217,12 @@ export async function createRefIndex(gitdir: string) { async function resolveRef( ref: string, - resolvedRefs: Map, + resolvedRefs: Map, looseRefs: Map -): Promise { - const resolvedRef = resolvedRefs.get(ref); - - if (resolvedRef !== null) { - if (resolvedRef !== undefined) { - return resolvedRef; - } +): Promise { + let resolvedRef = resolvedRefs.get(ref); + if (resolvedRef === undefined) { const looseRef = looseRefs.get(ref); if (looseRef !== undefined) { @@ -179,36 +230,49 @@ async function resolveRef( looseRef.content = (await fsPromises.readFile(looseRef.path, 'utf8')).trim(); } - let value = looseRef.content; + let refValue = looseRef.content; - while (!isOid(value)) { + while (!isOid(refValue)) { // Is it a ref pointer? - if (value.startsWith('ref: ')) { - value = value.slice(5); // 'ref: '.length == 5 + if (refValue.startsWith('ref: ')) { + refValue = refValue.replace(/^ref:\s+/, ''); continue; } // Sometimes an additional information is appended such as tags, branch names or comments - if (/\s/.test(value)) { - value = value.split(/\s+/)[0]; + const spaceIndex = refValue.search(/\s/); + if (spaceIndex !== -1) { + refValue = refValue.slice(0, spaceIndex); continue; } - value = await resolveRef(value, resolvedRefs, looseRefs); break; } - resolvedRefs.set(ref, value); - return value; + const oid = isOid(refValue) + ? refValue + : (await resolveRef(refValue, resolvedRefs, looseRefs)).oid; + + resolvedRef = { + ref: refValue !== oid ? refValue : null, + oid + }; + } else { + resolvedRef = { ref: null, oid: null }; } + + resolvedRefs.set(ref, resolvedRef); + } + + if (resolvedRef.oid !== null) { + return resolvedRef; } - resolvedRefs.set(ref, null); throw new Error(`Reference "${ref}" can't be resolved into oid`); } async function createRefResolver(gitdir: string) { - const resolvedRefs = new Map(); + const resolvedRefs = new Map(); const refNames = new Set(); const remotes = await readRemotes(gitdir); const [packedRefs, looseRefs] = await Promise.all([ @@ -225,7 +289,7 @@ async function createRefResolver(gitdir: string) { const oid = packedRefs.get(ref); if (oid !== undefined) { - resolvedRefs.set(ref, oid); + resolvedRefs.set(ref, { ref: null, oid }); } refNames.add(ref); @@ -236,8 +300,10 @@ async function createRefResolver(gitdir: string) { remotes, names: [...refNames].sort((a, b) => (a < b ? -1 : 1)), exists: (ref: string) => refNames.has(ref), + resolveOid: async (ref: string) => + (await resolveRef(ref, resolvedRefs, looseRefs)).oid as string, resolve: (ref: string) => resolveRef(ref, resolvedRefs, looseRefs) - }; + } satisfies RefResolver; } async function readRemotes(gitdir: string) { diff --git a/test/resolve-ref.ts b/test/resolve-ref.ts index 234c11f..1b3e57b 100644 --- a/test/resolve-ref.ts +++ b/test/resolve-ref.ts @@ -301,4 +301,76 @@ describe('resolve-ref', () => { assert.strictEqual(actual, false); }); }); + + describe('describeRef()', () => { + it('non-exists reference', () => + assert.rejects( + () => repo.describeRef('non-exists'), + /Reference "non-exists" is not found/ + )); + + it('symbolic', async () => { + const actual = await repo.describeRef('HEAD'); + + assert.deepStrictEqual(actual, { + path: 'HEAD', + name: 'HEAD', + symbolic: true, + scope: 'refs/heads', + namespace: 'refs', + category: 'heads', + remote: null, + ref: 'refs/heads/test', + oid: '2dbee47a8d4f8d39e1168fad951b703ee05614d6' + }); + }); + + it('remotes symbolic', async () => { + const actual = await repo.describeRef('origin/HEAD'); + + assert.deepStrictEqual(actual, { + path: 'refs/remotes/origin/HEAD', + name: 'HEAD', + symbolic: true, + scope: 'refs/remotes', + namespace: 'refs', + category: 'remotes', + remote: 'origin', + ref: 'refs/remotes/origin/main', + oid: '7b84f676f2fbea2a3c6d83924fa63059c7bdfbe2' + }); + }); + + it('branch ref', async () => { + const actual = await repo.describeRef('main'); + + assert.deepStrictEqual(actual, { + path: 'refs/heads/main', + name: 'main', + symbolic: false, + scope: 'refs/heads', + namespace: 'refs', + category: 'heads', + remote: null, + ref: null, + oid: '7b84f676f2fbea2a3c6d83924fa63059c7bdfbe2' + }); + }); + + it('tag ref', async () => { + const actual = await repo.describeRef('refs/tags/test-tag'); + + assert.deepStrictEqual(actual, { + path: 'refs/tags/test-tag', + name: 'test-tag', + symbolic: false, + scope: 'refs/tags', + namespace: 'refs', + category: 'tags', + remote: null, + ref: null, + oid: '2dbee47a8d4f8d39e1168fad951b703ee05614d6' + }); + }); + }); });