diff --git a/__tests__/util/git.js b/__tests__/util/git.js index 7b6df8bff5..d561bef2e0 100644 --- a/__tests__/util/git.js +++ b/__tests__/util/git.js @@ -1,15 +1,21 @@ /* @flow */ -jest.mock('../../src/util/child.js', () => { - const realChild = (require: any).requireActual('../../src/util/child.js'); - - realChild.spawn = jest.fn(() => Promise.resolve('')); - - return realChild; -}); - +jest.mock('../../src/util/git/git-spawn.js', () => ({ + spawn: jest.fn(([command]) => { + switch (command) { + case 'ls-remote': + return `ref: refs/heads/master HEAD +7a053e2ca07d19b2e2eebeeb0c27edaacfd67904 HEAD`; + case 'rev-list': + return Promise.resolve('7a053e2ca07d19b2e2eebeeb0c27edaacfd67904 Fix ...'); + } + return Promise.resolve(''); + }), +})); + +import Config from '../../src/config.js'; import Git from '../../src/util/git.js'; -import {spawn} from '../../src/util/child.js'; +import {spawn as spawnGit} from '../../src/util/git/git-spawn.js'; import {NoopReporter} from '../../src/reporters/index.js'; jasmine.DEFAULT_TIMEOUT_INTERVAL = 90000; @@ -72,15 +78,6 @@ test('npmUrlToGitUrl', () => { }); }); -test('isCommitHash', () => { - expect(Git.isCommitHash('ca82a6dff817ec66f44312307202690a93763949')).toBeTruthy(); - expect(Git.isCommitHash('abc12')).toBeTruthy(); - expect(Git.isCommitHash('')).toBeFalsy(); - expect(Git.isCommitHash('abc12_')).toBeFalsy(); - expect(Git.isCommitHash('gccda')).toBeFalsy(); - expect(Git.isCommitHash('abC12')).toBeFalsy(); -}); - test('secureGitUrl', async function(): Promise { const reporter = new NoopReporter(); @@ -109,44 +106,43 @@ test('secureGitUrl', async function(): Promise { expect(gitURL.repository).toEqual('https://github.com/yarnpkg/yarn.git'); }); -test('parseRefs', () => { - expect(Git.parseRefs(`64b2c0cee9e829f73c5ad32b8cc8cb6f3bec65bb refs/tags/v4.2.2`)).toMatchObject({ - 'v4.2.2': '64b2c0cee9e829f73c5ad32b8cc8cb6f3bec65bb', - }); - - expect( - Git.parseRefs(`ebeb6eafceb61dd08441ffe086c77eb472842494 refs/tags/v0.21.0 -70e76d174b0c7d001d2cd608a16c94498496e92d refs/tags/v0.21.0^{} -de43f4a993d1e08cd930ee22ecb2bac727f53449 refs/tags/v0.21.0-pre`), - ).toMatchObject({ - 'v0.21.0': '70e76d174b0c7d001d2cd608a16c94498496e92d', - 'v0.21.0-pre': 'de43f4a993d1e08cd930ee22ecb2bac727f53449', - }); - - expect( - Git.parseRefs(`********** -This is a custom response header - as described in: https://github.com/yarnpkg/yarn/issues/3325 -********** - -ebeb6eafceb61dd08441ffe086c77eb472842494 refs/tags/v0.21.0 -70e76d174b0c7d001d2cd608a16c94498496e92d refs/tags/v0.21.0^{} -de43f4a993d1e08cd930ee22ecb2bac727f53449 refs/tags/v0.21.0-pre`), - ).toMatchObject({ - 'v0.21.0': '70e76d174b0c7d001d2cd608a16c94498496e92d', - 'v0.21.0-pre': 'de43f4a993d1e08cd930ee22ecb2bac727f53449', - }); +test('resolveDefaultBranch', async () => { + const spawnGitMock = (spawnGit: any).mock; + const config = await Config.create(); + const git = new Git( + config, + { + protocol: '', + hostname: undefined, + repository: '', + }, + '', + ); + expect(await git.resolveDefaultBranch()).toEqual({ + sha: '7a053e2ca07d19b2e2eebeeb0c27edaacfd67904', + ref: 'refs/heads/master', + }); + const lastCall = spawnGitMock.calls[spawnGitMock.calls.length - 1]; + expect(lastCall[0]).toContain('ls-remote'); }); -test('spawn', () => { - const spawnMock = (spawn: any).mock; - - Git.spawn(['status']); - - expect(spawnMock.calls[0][2].env).toMatchObject({ - GIT_ASKPASS: '', - GIT_TERMINAL_PROMPT: 0, - GIT_SSH_COMMAND: 'ssh -oBatchMode=yes', - ...process.env, - }); +test('resolveCommit', async () => { + const spawnGitMock = (spawnGit: any).mock; + const config = await Config.create(); + const git = new Git( + config, + { + protocol: '', + hostname: undefined, + repository: '', + }, + '', + ); + expect(await git.resolveCommit('7a053e2')).toEqual({ + sha: '7a053e2ca07d19b2e2eebeeb0c27edaacfd67904', + ref: undefined, + }); + const lastCall = spawnGitMock.calls[spawnGitMock.calls.length - 1]; + expect(lastCall[0]).toContain('rev-list'); + expect(lastCall[0]).toContain('7a053e2'); }); diff --git a/__tests__/util/git/git-ref-resolver.js b/__tests__/util/git/git-ref-resolver.js new file mode 100644 index 0000000000..0006f96ca6 --- /dev/null +++ b/__tests__/util/git/git-ref-resolver.js @@ -0,0 +1,154 @@ +/* @flow */ + +import Config from '../../../src/config.js'; +import type {ResolvedSha, GitRefResolvingInterface} from '../../../src/util/git/git-ref-resolver.js'; +import {resolveVersion, isCommitSha, parseRefs} from '../../../src/util/git/git-ref-resolver.js'; + +class GitMock implements GitRefResolvingInterface { + resolveDefaultBranch(): Promise { + return Promise.resolve({sha: '8a41a314e23dc566a6b7e73c757a10d13e3320cf', ref: 'refs/heads/main'}); + } + resolveCommit(sha: string): Promise { + if (sha.startsWith('003ae60')) { + return Promise.resolve({sha: '003ae6063f23a4184736610361f14438a3257c83', ref: undefined}); + } + return Promise.resolve(null); + } +} + +test('resolveVersion', async () => { + const config = await Config.create(); + + const refs = { + 'refs/heads/1.1': 'eaa56cb34863810060abbec2d755ba51508afedc', + 'refs/heads/3.3': '4cff93aa6e8270c3bec988af464d28a164bc3cb2', + 'refs/heads/main': '8a41a314e23dc566a6b7e73c757a10d13e3320cf', + 'refs/heads/both': '106c28537be070b98ca1effaef6a2bf6414e1e49', + 'refs/tags/v1.1.0': '37d5ed001dc4402d5446911c4e1cb589449e7d8d', + 'refs/tags/v2.2.0': 'e88209b9513544a22fc3f8660e3d829281dc2c9f', + 'refs/tags/both': 'f0dbab0a4345a64f544af37e24fc8187176936a4', + }; + const emptyRefs = {}; + const git = new GitMock(); + + const resolve = version => resolveVersion({config, version, refs, git}); + + expect(await resolve('')).toEqual({ + sha: '8a41a314e23dc566a6b7e73c757a10d13e3320cf', + ref: 'refs/heads/main', + }); + expect(await resolve('003ae6063f23a4184736610361f14438a3257c83')).toEqual({ + sha: '003ae6063f23a4184736610361f14438a3257c83', + ref: undefined, + }); + expect(await resolve('003ae60')).toEqual({ + sha: '003ae6063f23a4184736610361f14438a3257c83', + ref: undefined, + }); + // Test uppercase + expect(await resolve('003AE60')).toEqual({ + sha: '003ae6063f23a4184736610361f14438a3257c83', + ref: undefined, + }); + expect(await resolve('4cff93aa6e8270c3bec988af464d28a164bc3cb2')).toEqual({ + sha: '4cff93aa6e8270c3bec988af464d28a164bc3cb2', + ref: 'refs/heads/3.3', + }); + expect(await resolve('4cff93a')).toEqual({ + sha: '4cff93aa6e8270c3bec988af464d28a164bc3cb2', + ref: 'refs/heads/3.3', + }); + expect(await resolve('main')).toEqual({ + sha: '8a41a314e23dc566a6b7e73c757a10d13e3320cf', + ref: 'refs/heads/main', + }); + expect(await resolve('1.1')).toEqual({ + sha: 'eaa56cb34863810060abbec2d755ba51508afedc', + ref: 'refs/heads/1.1', + }); + expect(await resolve('v1.1.0')).toEqual({ + sha: '37d5ed001dc4402d5446911c4e1cb589449e7d8d', + ref: 'refs/tags/v1.1.0', + }); + // not-existing sha + expect(await resolve('0123456')).toEqual(null); + + // Test tags precedence over branches + expect(await resolve('both')).toEqual({ + sha: 'f0dbab0a4345a64f544af37e24fc8187176936a4', + ref: 'refs/tags/both', + }); + expect(await resolve('refs/heads/both')).toEqual({ + sha: '106c28537be070b98ca1effaef6a2bf6414e1e49', + ref: 'refs/heads/both', + }); + // Test no match + expect(await resolve('unknown')).toEqual(null); + + // Test SemVer + + // prefix space to force semver + expect(await resolve(' 1.1')).toEqual({ + sha: '37d5ed001dc4402d5446911c4e1cb589449e7d8d', + ref: 'refs/tags/v1.1.0', + }); + expect(await resolve('~1.1')).toEqual({ + sha: '37d5ed001dc4402d5446911c4e1cb589449e7d8d', + ref: 'refs/tags/v1.1.0', + }); + // test on tags first, should not match 3.3 + expect(await resolve('*')).toEqual({ + sha: 'e88209b9513544a22fc3f8660e3d829281dc2c9f', + ref: 'refs/tags/v2.2.0', + }); + // Test * without tags, use default branch + expect(await resolveVersion({config, version: '*', refs: emptyRefs, git})).toEqual({ + sha: '8a41a314e23dc566a6b7e73c757a10d13e3320cf', + ref: 'refs/heads/main', + }); +}); + +test('isCommitSha', () => { + expect(isCommitSha('ca82a6dff817ec66f44312307202690a93763949')).toBeTruthy(); + expect(isCommitSha('abc12')).toBeTruthy(); + expect(isCommitSha('')).toBeFalsy(); + expect(isCommitSha('abc12_')).toBeFalsy(); + expect(isCommitSha('gccda')).toBeFalsy(); + expect(isCommitSha('abC12')).toBeFalsy(); +}); + +test('parseRefs', () => { + expect(parseRefs(`64b2c0cee9e829f73c5ad32b8cc8cb6f3bec65bb refs/tags/v4.2.2`)).toMatchObject({ + 'refs/tags/v4.2.2': '64b2c0cee9e829f73c5ad32b8cc8cb6f3bec65bb', + }); + + expect( + parseRefs(`ebeb6eafceb61dd08441ffe086c77eb472842494 refs/tags/v0.21.0 +70e76d174b0c7d001d2cd608a16c94498496e92d refs/tags/v0.21.0^{} +de43f4a993d1e08cd930ee22ecb2bac727f53449 refs/tags/v0.21.0-pre`), + ).toMatchObject({ + 'refs/tags/v0.21.0': '70e76d174b0c7d001d2cd608a16c94498496e92d', + 'refs/tags/v0.21.0-pre': 'de43f4a993d1e08cd930ee22ecb2bac727f53449', + }); + + expect( + parseRefs(`ebeb6eafceb61dd08441ffe086c77eb472842494 refs/tags/tag +70e76d174b0c7d001d2cd608a16c94498496e92d refs/merge-requests/38/head`), + ).toMatchObject({ + 'refs/tags/tag': 'ebeb6eafceb61dd08441ffe086c77eb472842494', + }); + + expect( + parseRefs(`********** +This is a custom response header + as described in: https://github.com/yarnpkg/yarn/issues/3325 +********** + +ebeb6eafceb61dd08441ffe086c77eb472842494 refs/tags/v0.21.0 +70e76d174b0c7d001d2cd608a16c94498496e92d refs/tags/v0.21.0^{} +de43f4a993d1e08cd930ee22ecb2bac727f53449 refs/tags/v0.21.0-pre`), + ).toMatchObject({ + 'refs/tags/v0.21.0': '70e76d174b0c7d001d2cd608a16c94498496e92d', + 'refs/tags/v0.21.0-pre': 'de43f4a993d1e08cd930ee22ecb2bac727f53449', + }); +}); diff --git a/__tests__/util/git/git-spawn.js b/__tests__/util/git/git-spawn.js new file mode 100644 index 0000000000..cee9692714 --- /dev/null +++ b/__tests__/util/git/git-spawn.js @@ -0,0 +1,25 @@ +/* @flow */ + +jest.mock('../../../src/util/child.js', () => { + const realChild = (require: any).requireActual('../../../src/util/child.js'); + + realChild.spawn = jest.fn(() => Promise.resolve('')); + + return realChild; +}); + +import {spawn as spawnGit} from '../../../src/util/git/git-spawn.js'; +import {spawn} from '../../../src/util/child.js'; + +test('spawn', () => { + const spawnMock = (spawn: any).mock; + + spawnGit(['status']); + + expect(spawnMock.calls[0][2].env).toMatchObject({ + GIT_ASKPASS: '', + GIT_TERMINAL_PROMPT: 0, + GIT_SSH_COMMAND: 'ssh -oBatchMode=yes', + ...process.env, + }); +}); diff --git a/src/resolvers/exotics/hosted-git-resolver.js b/src/resolvers/exotics/hosted-git-resolver.js index 18944d4380..49bc1a436e 100644 --- a/src/resolvers/exotics/hosted-git-resolver.js +++ b/src/resolvers/exotics/hosted-git-resolver.js @@ -136,8 +136,7 @@ export default class HostedGitResolver extends ExoticResolver { throw new Error(this.reporter.lang('hostedGitResolveError')); } - const refs = Git.parseRefs(out); - return client.setRef(refs); + return client.setRefHosted(out); } async resolveOverHTTP(url: string): Promise { diff --git a/src/util/git.js b/src/util/git.js index 73cd1beec9..697be38388 100644 --- a/src/util/git.js +++ b/src/util/git.js @@ -2,25 +2,21 @@ import type Config from '../config.js'; import type {Reporter} from '../reporters/index.js'; +import type {ResolvedSha, GitRefResolvingInterface, GitRefs} from './git/git-ref-resolver.js'; import {MessageError, SecurityError} from '../errors.js'; -import {removeSuffix} from './misc.js'; +import {spawn as spawnGit} from './git/git-spawn.js'; +import {resolveVersion, isCommitSha, parseRefs} from './git/git-ref-resolver.js'; import * as crypto from './crypto.js'; -import * as child from './child.js'; import * as fs from './fs.js'; import map from './map.js'; const invariant = require('invariant'); -const semver = require('semver'); const StringDecoder = require('string_decoder').StringDecoder; const tarFs = require('tar-fs'); const tarStream = require('tar-stream'); const url = require('url'); import {createWriteStream} from 'fs'; -type GitRefs = { - [name: string]: string, -}; - type GitUrl = { protocol: string, // parsed from URL hostname: ?string, @@ -31,20 +27,7 @@ const supportsArchiveCache: {[key: string]: boolean} = map({ 'github.com': false, // not support, doubt they will ever support it }); -// Suppress any password prompts since we run these in the background -const env = { - GIT_ASKPASS: '', - GIT_TERMINAL_PROMPT: 0, - GIT_SSH_COMMAND: 'ssh -oBatchMode=yes', - ...process.env, -}; - -// This regex is designed to match output from git of the style: -// ebeb6eafceb61dd08441ffe086c77eb472842494 refs/tags/v0.21.0 -// and extract the hash and tag name as capture groups -const gitRefLineRegex = /^([a-fA-F0-9]+)\s+(?:[^/]+\/){2}(.*)$/; - -export default class Git { +export default class Git implements GitRefResolvingInterface { constructor(config: Config, gitUrl: GitUrl, hash: string) { this.supportsArchive = false; this.fetched = false; @@ -95,10 +78,6 @@ export default class Git { }; } - static spawn(args: Array, opts?: child_process$spawnOpts = {}): Promise { - return child.spawn('git', args, {...opts, env}); - } - /** * Check if the host specified in the input `gitUrl` has archive capability. */ @@ -114,7 +93,7 @@ export default class Git { } try { - await Git.spawn(['archive', `--remote=${ref.repository}`, 'HEAD', Date.now() + '']); + await spawnGit(['archive', `--remote=${ref.repository}`, 'HEAD', Date.now() + '']); throw new Error(); } catch (err) { const supports = err.message.indexOf('did not match any files') >= 0; @@ -126,13 +105,9 @@ export default class Git { * Check if the input `target` is a 5-40 character hex commit hash. */ - static isCommitHash(target: string): boolean { - return !!target && /^[a-f0-9]{5,40}$/.test(target); - } - static async repoExists(ref: GitUrl): Promise { try { - await Git.spawn(['ls-remote', '-t', ref.repository]); + await spawnGit(['ls-remote', '-t', ref.repository]); return true; } catch (err) { return false; @@ -151,7 +126,7 @@ export default class Git { * Attempt to upgrade insecure protocols to secure protocol */ static async secureGitUrl(ref: GitUrl, hash: string, reporter: Reporter): Promise { - if (Git.isCommitHash(hash)) { + if (isCommitSha(hash)) { // this is cryptographically secure return ref; } @@ -203,7 +178,7 @@ export default class Git { async _archiveViaRemoteArchive(dest: string): Promise { const hashStream = new crypto.HashStream(); - await Git.spawn(['archive', `--remote=${this.gitUrl.repository}`, this.ref], { + await spawnGit(['archive', `--remote=${this.gitUrl.repository}`, this.ref], { process(proc, resolve, reject, done) { const writeStream = createWriteStream(dest); proc.on('error', reject); @@ -220,7 +195,7 @@ export default class Git { async _archiveViaLocalFetched(dest: string): Promise { const hashStream = new crypto.HashStream(); - await Git.spawn(['archive', this.hash], { + await spawnGit(['archive', this.hash], { cwd: this.cwd, process(proc, resolve, reject, done) { const writeStream = createWriteStream(dest); @@ -249,7 +224,7 @@ export default class Git { } async _cloneViaRemoteArchive(dest: string): Promise { - await Git.spawn(['archive', `--remote=${this.gitUrl.repository}`, this.ref], { + await spawnGit(['archive', `--remote=${this.gitUrl.repository}`, this.ref], { process(proc, update, reject, done) { const extractor = tarFs.extract(dest, { dmode: 0o555, // all dirs should be readable @@ -265,7 +240,7 @@ export default class Git { } async _cloneViaLocalFetched(dest: string): Promise { - await Git.spawn(['archive', this.hash], { + await spawnGit(['archive', this.hash], { cwd: this.cwd, process(proc, resolve, reject, done) { const extractor = tarFs.extract(dest, { @@ -290,34 +265,15 @@ export default class Git { return fs.lockQueue.push(gitUrl.repository, async () => { if (await fs.exists(cwd)) { - await Git.spawn(['pull'], {cwd}); + await spawnGit(['pull'], {cwd}); } else { - await Git.spawn(['clone', gitUrl.repository, cwd]); + await spawnGit(['clone', gitUrl.repository, cwd]); } this.fetched = true; }); } - /** - * Given a list of tags/branches from git, check if they match an input range. - */ - - async findResolution(range: ?string, tags: Array): Promise { - // If there are no tags and target is *, fallback to the latest commit on master - // or if we have no target. - if (!range || (!tags.length && range === '*')) { - return 'master'; - } - - return ( - (await this.config.resolveConstraints( - tags.filter((tag): boolean => !!semver.valid(tag, this.config.looseSemver)), - range, - )) || range - ); - } - /** * Fetch the file by cloning the repo and reading it. */ @@ -332,7 +288,7 @@ export default class Git { async _getFileFromArchive(filename: string): Promise { try { - return await Git.spawn(['archive', `--remote=${this.gitUrl.repository}`, this.ref, filename], { + return await spawnGit(['archive', `--remote=${this.gitUrl.repository}`, this.ref, filename], { process(proc, update, reject, done) { const parser = tarStream.extract(); @@ -370,7 +326,7 @@ export default class Git { invariant(this.fetched, 'Repo not fetched'); try { - return await Git.spawn(['show', `${this.hash}:${filename}`], { + return await spawnGit(['show', `${this.hash}:${filename}`], { cwd: this.cwd, }); } catch (err) { @@ -385,89 +341,80 @@ export default class Git { */ async init(): Promise { this.gitUrl = await Git.secureGitUrl(this.gitUrl, this.hash, this.reporter); + + await this.setRefRemote(); + // check capabilities - if (await Git.hasArchiveCapability(this.gitUrl)) { + if (this.ref !== '' && (await Git.hasArchiveCapability(this.gitUrl))) { this.supportsArchive = true; } else { await this.fetch(); } - return this.setRefRemote(); + return this.hash; } async setRefRemote(): Promise { - const stdout = await Git.spawn(['ls-remote', '--tags', '--heads', this.gitUrl.repository]); - const refs = Git.parseRefs(stdout); + const stdout = await spawnGit(['ls-remote', '--tags', '--heads', this.gitUrl.repository]); + const refs = parseRefs(stdout); return this.setRef(refs); } - /** - * TODO description - */ - - async setRef(refs: GitRefs): Promise { - // get commit ref - const {hash} = this; - - const names = Object.keys(refs); - - if (Git.isCommitHash(hash)) { - for (const name in refs) { - if (refs[name] === hash) { - this.ref = name; - return hash; - } - } - - // `git archive` only accepts a treeish and we have no ref to this commit - this.supportsArchive = false; + setRefHosted(hostedRefsList: string): Promise { + const refs = parseRefs(hostedRefsList); + return this.setRef(refs); + } - if (!this.fetched) { - // in fact, `git archive` can't be used, and we haven't fetched the project yet. Do it now. - await this.fetch(); - } - return (this.ref = this.hash = hash); + async resolveDefaultBranch(): Promise { + try { + const stdout = await spawnGit(['ls-remote', '--symref', this.gitUrl.repository, 'HEAD']); + const lines = stdout.split('\n'); + const [, ref] = lines[0].split(/\s+/); + const [sha] = lines[1].split(/\s+/); + return {sha, ref}; + } catch (err) { + // older versions of git don't support "--symref" + const stdout = await spawnGit(['ls-remote', this.gitUrl.repository, 'HEAD']); + const [sha] = stdout.split(/\s+/); + return {sha, ref: undefined}; } + } - const ref = await this.findResolution(hash, names); - const commit = refs[ref]; - if (commit) { - this.ref = ref; - return (this.hash = commit); - } else { - throw new MessageError(this.reporter.lang('couldntFindMatch', ref, names.join(','), this.gitUrl.repository)); + async resolveCommit(shaToResolve: string): Promise { + try { + await this.fetch(); + const revListArgs = ['rev-list', '-n', '1', '--no-abbrev-commit', '--format=oneline', shaToResolve]; + const stdout = await spawnGit(revListArgs, {cwd: this.cwd}); + const [sha] = stdout.split(/\s+/); + return {sha, ref: undefined}; + } catch (err) { + // assuming commit not found, let's try something else + return null; } } /** - * Parse Git ref lines into hash of tag names to SHA hashes + * TODO description */ - static parseRefs(stdout: string): GitRefs { - // store references - const refs = {}; - - // line delimited - const refLines = stdout.split('\n'); - - for (const line of refLines) { - const match = gitRefLineRegex.exec(line); - - if (match) { - const [, sha, tagName] = match; - - // As documented in gitrevisions: - // https://www.kernel.org/pub/software/scm/git/docs/gitrevisions.html#_specifying_revisions - // "A suffix ^ followed by an empty brace pair means the object could be a tag, - // and dereference the tag recursively until a non-tag object is found." - // In other words, the hash without ^{} is the hash of the tag, - // and the hash with ^{} is the hash of the commit at which the tag was made. - const name = removeSuffix(tagName, '^{}'); + async setRef(refs: GitRefs): Promise { + // get commit ref + const {hash: version} = this; - refs[name] = sha; - } + const resolvedResult = await resolveVersion({ + config: this.config, + git: this, + version, + refs, + }); + if (!resolvedResult) { + throw new MessageError( + this.reporter.lang('couldntFindMatch', version, Object.keys(refs).join(','), this.gitUrl.repository), + ); } - return refs; + this.hash = resolvedResult.sha; + this.ref = resolvedResult.ref || ''; + return this.hash; } } diff --git a/src/util/git/git-ref-resolver.js b/src/util/git/git-ref-resolver.js new file mode 100644 index 0000000000..0f56190c48 --- /dev/null +++ b/src/util/git/git-ref-resolver.js @@ -0,0 +1,195 @@ +/* @flow */ + +import type Config from '../../config.js'; +import {removeSuffix} from '../misc.js'; + +const semver = require('semver'); + +export type ResolvedSha = {sha: string, ref: ?string}; +export interface GitRefResolvingInterface { + resolveDefaultBranch(): Promise, + resolveCommit(sha: string): Promise, +} +export type GitRefs = { + [name: string]: string, +}; +export type ResolveVersionOptions = { + version: string, + config: Config, + git: GitRefResolvingInterface, + refs: GitRefs, +}; +type Names = {tags: Array, branches: Array}; + +export const isCommitSha = (target: string): boolean => Boolean(target) && /^[a-f0-9]{5,40}$/.test(target); + +const REF_TAG_PREFIX = 'refs/tags/'; +const REF_BRANCH_PREFIX = 'refs/heads/'; + +// This regex is designed to match output from git of the style: +// ebeb6eafceb61dd08441ffe086c77eb472842494 refs/tags/v0.21.0 +// and extract the hash and ref name as capture groups +const gitRefLineRegex = /^([a-fA-F0-9]+)\s+(refs\/(?:tags|heads)\/.*)$/; + +const refNameRegexp = /^refs\/(tags|heads)\/(.+)$/; + +const tryVersionAsGitCommit = ({version, refs, git}: ResolveVersionOptions): Promise => { + const lowercaseVersion = version.toLowerCase(); + if (!isCommitSha(lowercaseVersion)) { + return Promise.resolve(null); + } + for (const ref in refs) { + const sha = refs[ref]; + if (sha.startsWith(lowercaseVersion)) { + return Promise.resolve({sha, ref}); + } + } + return git.resolveCommit(lowercaseVersion); +}; + +const tryWildcardVersionAsDefaultBranch = ({version, git}: ResolveVersionOptions): Promise => + version === '*' ? git.resolveDefaultBranch() : Promise.resolve(null); + +const tryRef = (refs: GitRefs, ref: string): ?ResolvedSha => { + if (refs[ref]) { + return { + sha: refs[ref], + ref, + }; + } + return null; +}; + +const tryVersionAsFullRef = ({version, refs}: ResolveVersionOptions): ?ResolvedSha => { + if (version.startsWith('refs/')) { + return tryRef(refs, version); + } + return null; +}; + +const tryVersionAsTagName = ({version, refs}: ResolveVersionOptions): ?ResolvedSha => { + const ref = `${REF_TAG_PREFIX}${version}`; + return tryRef(refs, ref); +}; + +const tryVersionAsBranchName = ({version, refs}: ResolveVersionOptions): ?ResolvedSha => { + const ref = `${REF_BRANCH_PREFIX}${version}`; + return tryRef(refs, ref); +}; + +const computeSemverNames = ({config, refs}: ResolveVersionOptions): Names => { + const names = { + tags: [], + branches: [], + }; + for (const ref in refs) { + const match = refNameRegexp.exec(ref); + if (match) { + const [, type, name] = match; + if (semver.valid(name, config.looseSemver)) { + switch (type) { + case 'tags': + names.tags.push(name); + break; + case 'heads': + names.branches.push(name); + break; + } + } + } + } + return names; +}; + +const findSemver = (version: string, config: Config, namesList: Array): Promise => + config.resolveConstraints(namesList, version); + +const tryVersionAsTagSemver = async ( + {version, config, refs}: ResolveVersionOptions, + names: Names, +): Promise => { + const result = await findSemver(version, config, names.tags); + if (result) { + const ref = `${REF_TAG_PREFIX}${result}`; + return {sha: refs[ref], ref}; + } + return null; +}; + +const tryVersionAsBranchSemver = async ( + {version, config, refs}: ResolveVersionOptions, + names: Names, +): Promise => { + const result = await findSemver(version, config, names.branches); + if (result) { + const ref = `${REF_BRANCH_PREFIX}${result}`; + return {sha: refs[ref], ref}; + } + return null; +}; + +const tryVersionAsSemverRange = async (options: ResolveVersionOptions): Promise => { + const names = computeSemverNames(options); + return (await tryVersionAsTagSemver(options, names)) || tryVersionAsBranchSemver(options, names); +}; + +const VERSION_RESOLUTION_STEPS: Array<(ResolveVersionOptions) => ?ResolvedSha | Promise> = [ + tryVersionAsGitCommit, + tryVersionAsFullRef, + tryVersionAsTagName, + tryVersionAsBranchName, + tryVersionAsSemverRange, + tryWildcardVersionAsDefaultBranch, +]; + +/** + * Resolve a git-url hash (version) to a git commit sha and branch/tag ref + * Returns null if the version cannot be resolved to any commit + */ + +export const resolveVersion = async (options: ResolveVersionOptions): Promise => { + const {version, git} = options; + if (version.trim() === '') { + return git.resolveDefaultBranch(); + } + + for (const testFunction of VERSION_RESOLUTION_STEPS) { + const result = await testFunction(options); + if (result !== null) { + return result; + } + } + return null; +}; + +/** + * Parse Git ref lines into hash of ref names to SHA hashes + */ + +export const parseRefs = (stdout: string): GitRefs => { + // store references + const refs = {}; + + // line delimited + const refLines = stdout.split('\n'); + + for (const line of refLines) { + const match = gitRefLineRegex.exec(line); + + if (match) { + const [, sha, tagName] = match; + + // As documented in gitrevisions: + // https://www.kernel.org/pub/software/scm/git/docs/gitrevisions.html#_specifying_revisions + // "A suffix ^ followed by an empty brace pair means the object could be a tag, + // and dereference the tag recursively until a non-tag object is found." + // In other words, the hash without ^{} is the hash of the tag, + // and the hash with ^{} is the hash of the commit at which the tag was made. + const name = removeSuffix(tagName, '^{}'); + + refs[name] = sha; + } + } + + return refs; +}; diff --git a/src/util/git/git-spawn.js b/src/util/git/git-spawn.js new file mode 100644 index 0000000000..fde3f4d7f1 --- /dev/null +++ b/src/util/git/git-spawn.js @@ -0,0 +1,15 @@ +/* @flow */ + +import * as child from '../child.js'; + +// Suppress any password prompts since we run these in the background +const env = { + GIT_ASKPASS: '', + GIT_TERMINAL_PROMPT: 0, + GIT_SSH_COMMAND: 'ssh -oBatchMode=yes', + ...process.env, +}; + +export const spawn = (args: Array, opts?: child_process$spawnOpts = {}): Promise => { + return child.spawn('git', args, {...opts, env}); +};