1const Fetcher = require('./fetcher.js')
2const FileFetcher = require('./file.js')
3const RemoteFetcher = require('./remote.js')
4const DirFetcher = require('./dir.js')
5const hashre = /^[a-f0-9]{40}$/
6const git = require('@npmcli/git')
7const pickManifest = require('npm-pick-manifest')
8const npa = require('npm-package-arg')
9const { Minipass } = require('minipass')
10const cacache = require('cacache')
11const log = require('proc-log')
12const npm = require('./util/npm.js')
13
14const _resolvedFromRepo = Symbol('_resolvedFromRepo')
15const _resolvedFromHosted = Symbol('_resolvedFromHosted')
16const _resolvedFromClone = Symbol('_resolvedFromClone')
17const _tarballFromResolved = Symbol.for('pacote.Fetcher._tarballFromResolved')
18const _addGitSha = Symbol('_addGitSha')
19const addGitSha = require('./util/add-git-sha.js')
20const _clone = Symbol('_clone')
21const _cloneHosted = Symbol('_cloneHosted')
22const _cloneRepo = Symbol('_cloneRepo')
23const _setResolvedWithSha = Symbol('_setResolvedWithSha')
24const _prepareDir = Symbol('_prepareDir')
25const _readPackageJson = Symbol.for('package.Fetcher._readPackageJson')
26
27// get the repository url.
28// prefer https if there's auth, since ssh will drop that.
29// otherwise, prefer ssh if available (more secure).
30// We have to add the git+ back because npa suppresses it.
31const repoUrl = (h, opts) =>
32  h.sshurl && !(h.https && h.auth) && addGitPlus(h.sshurl(opts)) ||
33  h.https && addGitPlus(h.https(opts))
34
35// add git+ to the url, but only one time.
36const addGitPlus = url => url && `git+${url}`.replace(/^(git\+)+/, 'git+')
37
38class GitFetcher extends Fetcher {
39  constructor (spec, opts) {
40    super(spec, opts)
41
42    // we never want to compare integrity for git dependencies: npm/rfcs#525
43    if (this.opts.integrity) {
44      delete this.opts.integrity
45      log.warn(`skipping integrity check for git dependency ${this.spec.fetchSpec}`)
46    }
47
48    this.resolvedRef = null
49    if (this.spec.hosted) {
50      this.from = this.spec.hosted.shortcut({ noCommittish: false })
51    }
52
53    // shortcut: avoid full clone when we can go straight to the tgz
54    // if we have the full sha and it's a hosted git platform
55    if (this.spec.gitCommittish && hashre.test(this.spec.gitCommittish)) {
56      this.resolvedSha = this.spec.gitCommittish
57      // use hosted.tarball() when we shell to RemoteFetcher later
58      this.resolved = this.spec.hosted
59        ? repoUrl(this.spec.hosted, { noCommittish: false })
60        : this.spec.rawSpec
61    } else {
62      this.resolvedSha = ''
63    }
64
65    this.Arborist = opts.Arborist || null
66  }
67
68  // just exposed to make it easier to test all the combinations
69  static repoUrl (hosted, opts) {
70    return repoUrl(hosted, opts)
71  }
72
73  get types () {
74    return ['git']
75  }
76
77  resolve () {
78    // likely a hosted git repo with a sha, so get the tarball url
79    // but in general, no reason to resolve() more than necessary!
80    if (this.resolved) {
81      return super.resolve()
82    }
83
84    // fetch the git repo and then look at the current hash
85    const h = this.spec.hosted
86    // try to use ssh, fall back to git.
87    return h ? this[_resolvedFromHosted](h)
88      : this[_resolvedFromRepo](this.spec.fetchSpec)
89  }
90
91  // first try https, since that's faster and passphrase-less for
92  // public repos, and supports private repos when auth is provided.
93  // Fall back to SSH to support private repos
94  // NB: we always store the https url in resolved field if auth
95  // is present, otherwise ssh if the hosted type provides it
96  [_resolvedFromHosted] (hosted) {
97    return this[_resolvedFromRepo](hosted.https && hosted.https())
98      .catch(er => {
99        // Throw early since we know pathspec errors will fail again if retried
100        if (er instanceof git.errors.GitPathspecError) {
101          throw er
102        }
103        const ssh = hosted.sshurl && hosted.sshurl()
104        // no fallthrough if we can't fall through or have https auth
105        if (!ssh || hosted.auth) {
106          throw er
107        }
108        return this[_resolvedFromRepo](ssh)
109      })
110  }
111
112  [_resolvedFromRepo] (gitRemote) {
113    // XXX make this a custom error class
114    if (!gitRemote) {
115      return Promise.reject(new Error(`No git url for ${this.spec}`))
116    }
117    const gitRange = this.spec.gitRange
118    const name = this.spec.name
119    return git.revs(gitRemote, this.opts).then(remoteRefs => {
120      return gitRange ? pickManifest({
121        versions: remoteRefs.versions,
122        'dist-tags': remoteRefs['dist-tags'],
123        name,
124      }, gitRange, this.opts)
125        : this.spec.gitCommittish ?
126          remoteRefs.refs[this.spec.gitCommittish] ||
127          remoteRefs.refs[remoteRefs.shas[this.spec.gitCommittish]]
128          : remoteRefs.refs.HEAD // no git committish, get default head
129    }).then(revDoc => {
130      // the committish provided isn't in the rev list
131      // things like HEAD~3 or @yesterday can land here.
132      if (!revDoc || !revDoc.sha) {
133        return this[_resolvedFromClone]()
134      }
135
136      this.resolvedRef = revDoc
137      this.resolvedSha = revDoc.sha
138      this[_addGitSha](revDoc.sha)
139      return this.resolved
140    })
141  }
142
143  [_setResolvedWithSha] (withSha) {
144    // we haven't cloned, so a tgz download is still faster
145    // of course, if it's not a known host, we can't do that.
146    this.resolved = !this.spec.hosted ? withSha
147      : repoUrl(npa(withSha).hosted, { noCommittish: false })
148  }
149
150  // when we get the git sha, we affix it to our spec to build up
151  // either a git url with a hash, or a tarball download URL
152  [_addGitSha] (sha) {
153    this[_setResolvedWithSha](addGitSha(this.spec, sha))
154  }
155
156  [_resolvedFromClone] () {
157    // do a full or shallow clone, then look at the HEAD
158    // kind of wasteful, but no other option, really
159    return this[_clone](dir => this.resolved)
160  }
161
162  [_prepareDir] (dir) {
163    return this[_readPackageJson](dir + '/package.json').then(mani => {
164      // no need if we aren't going to do any preparation.
165      const scripts = mani.scripts
166      if (!mani.workspaces && (!scripts || !(
167        scripts.postinstall ||
168          scripts.build ||
169          scripts.preinstall ||
170          scripts.install ||
171          scripts.prepack ||
172          scripts.prepare))) {
173        return
174      }
175
176      // to avoid cases where we have an cycle of git deps that depend
177      // on one another, we only ever do preparation for one instance
178      // of a given git dep along the chain of installations.
179      // Note that this does mean that a dependency MAY in theory end up
180      // trying to run its prepare script using a dependency that has not
181      // been properly prepared itself, but that edge case is smaller
182      // and less hazardous than a fork bomb of npm and git commands.
183      const noPrepare = !process.env._PACOTE_NO_PREPARE_ ? []
184        : process.env._PACOTE_NO_PREPARE_.split('\n')
185      if (noPrepare.includes(this.resolved)) {
186        log.info('prepare', 'skip prepare, already seen', this.resolved)
187        return
188      }
189      noPrepare.push(this.resolved)
190
191      // the DirFetcher will do its own preparation to run the prepare scripts
192      // All we have to do is put the deps in place so that it can succeed.
193      return npm(
194        this.npmBin,
195        [].concat(this.npmInstallCmd).concat(this.npmCliConfig),
196        dir,
197        { ...process.env, _PACOTE_NO_PREPARE_: noPrepare.join('\n') },
198        { message: 'git dep preparation failed' }
199      )
200    })
201  }
202
203  [_tarballFromResolved] () {
204    const stream = new Minipass()
205    stream.resolved = this.resolved
206    stream.from = this.from
207
208    // check it out and then shell out to the DirFetcher tarball packer
209    this[_clone](dir => this[_prepareDir](dir)
210      .then(() => new Promise((res, rej) => {
211        if (!this.Arborist) {
212          throw new Error('GitFetcher requires an Arborist constructor to pack a tarball')
213        }
214        const df = new DirFetcher(`file:${dir}`, {
215          ...this.opts,
216          Arborist: this.Arborist,
217          resolved: null,
218          integrity: null,
219        })
220        const dirStream = df[_tarballFromResolved]()
221        dirStream.on('error', rej)
222        dirStream.on('end', res)
223        dirStream.pipe(stream)
224      }))).catch(
225      /* istanbul ignore next: very unlikely and hard to test */
226      er => stream.emit('error', er)
227    )
228    return stream
229  }
230
231  // clone a git repo into a temp folder (or fetch and unpack if possible)
232  // handler accepts a directory, and returns a promise that resolves
233  // when we're done with it, at which point, cacache deletes it
234  //
235  // TODO: after cloning, create a tarball of the folder, and add to the cache
236  // with cacache.put.stream(), using a key that's deterministic based on the
237  // spec and repo, so that we don't ever clone the same thing multiple times.
238  [_clone] (handler, tarballOk = true) {
239    const o = { tmpPrefix: 'git-clone' }
240    const ref = this.resolvedSha || this.spec.gitCommittish
241    const h = this.spec.hosted
242    const resolved = this.resolved
243
244    // can be set manually to false to fall back to actual git clone
245    tarballOk = tarballOk &&
246      h && resolved === repoUrl(h, { noCommittish: false }) && h.tarball
247
248    return cacache.tmp.withTmp(this.cache, o, async tmp => {
249      // if we're resolved, and have a tarball url, shell out to RemoteFetcher
250      if (tarballOk) {
251        const nameat = this.spec.name ? `${this.spec.name}@` : ''
252        return new RemoteFetcher(h.tarball({ noCommittish: false }), {
253          ...this.opts,
254          allowGitIgnore: true,
255          pkgid: `git:${nameat}${this.resolved}`,
256          resolved: this.resolved,
257          integrity: null, // it'll always be different, if we have one
258        }).extract(tmp).then(() => handler(tmp), er => {
259          // fall back to ssh download if tarball fails
260          if (er.constructor.name.match(/^Http/)) {
261            return this[_clone](handler, false)
262          } else {
263            throw er
264          }
265        })
266      }
267
268      const sha = await (
269        h ? this[_cloneHosted](ref, tmp)
270        : this[_cloneRepo](this.spec.fetchSpec, ref, tmp)
271      )
272      this.resolvedSha = sha
273      if (!this.resolved) {
274        await this[_addGitSha](sha)
275      }
276      return handler(tmp)
277    })
278  }
279
280  // first try https, since that's faster and passphrase-less for
281  // public repos, and supports private repos when auth is provided.
282  // Fall back to SSH to support private repos
283  // NB: we always store the https url in resolved field if auth
284  // is present, otherwise ssh if the hosted type provides it
285  [_cloneHosted] (ref, tmp) {
286    const hosted = this.spec.hosted
287    return this[_cloneRepo](hosted.https({ noCommittish: true }), ref, tmp)
288      .catch(er => {
289        // Throw early since we know pathspec errors will fail again if retried
290        if (er instanceof git.errors.GitPathspecError) {
291          throw er
292        }
293        const ssh = hosted.sshurl && hosted.sshurl({ noCommittish: true })
294        // no fallthrough if we can't fall through or have https auth
295        if (!ssh || hosted.auth) {
296          throw er
297        }
298        return this[_cloneRepo](ssh, ref, tmp)
299      })
300  }
301
302  [_cloneRepo] (repo, ref, tmp) {
303    const { opts, spec } = this
304    return git.clone(repo, ref, tmp, { ...opts, spec })
305  }
306
307  manifest () {
308    if (this.package) {
309      return Promise.resolve(this.package)
310    }
311
312    return this.spec.hosted && this.resolved
313      ? FileFetcher.prototype.manifest.apply(this)
314      : this[_clone](dir =>
315        this[_readPackageJson](dir + '/package.json')
316          .then(mani => this.package = {
317            ...mani,
318            _resolved: this.resolved,
319            _from: this.from,
320          }))
321  }
322
323  packument () {
324    return FileFetcher.prototype.packument.apply(this)
325  }
326}
327module.exports = GitFetcher
328