@antora/content-aggregator 3.2.0-alpha.1 → 3.2.0-alpha.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,32 +1,32 @@
1
1
  'use strict'
2
2
 
3
3
  const computeOrigin = require('./compute-origin')
4
- const { createHash } = require('crypto')
4
+ const { createHash } = require('node:crypto')
5
5
  const createGitHttpPlugin = require('./git-plugin-http')
6
6
  const decodeUint8Array = require('./decode-uint8-array')
7
7
  const deepClone = require('./deep-clone')
8
- const deepFlatten = require('./deep-flatten')
9
- const EventEmitter = require('events')
8
+ const EventEmitter = require('node:events')
10
9
  const expandPath = require('@antora/expand-path-helper')
11
10
  const File = require('./file')
12
11
  const filterRefs = require('./filter-refs')
13
- const fs = require('fs')
12
+ const fs = require('node:fs')
14
13
  const { promises: fsp } = fs
15
14
  const getCacheDir = require('cache-directory')
16
15
  const GitCredentialManagerStore = require('./git-credential-manager-store')
17
16
  const git = require('./git')
18
17
  const { NotFoundError, ObjectTypeError, UnknownTransportError, UrlParseError } = git.Errors
19
- const globStream = require('glob-stream')
18
+ const { globStream } = require('fast-glob')
19
+ const { inspect } = require('node:util')
20
20
  const invariably = require('./invariably')
21
21
  const logger = require('./logger')
22
22
  const { makeMatcherRx, versionMatcherOpts: VERSION_MATCHER_OPTS } = require('./matcher')
23
23
  const MultiProgress = require('multi-progress') // calls require('progress') as a peer dependencies
24
- const ospath = require('path')
24
+ const ospath = require('node:path')
25
25
  const { posix: path } = ospath
26
26
  const posixify = require('./posixify')
27
27
  const removeGitSuffix = require('./remove-git-suffix')
28
28
  const { fs: resolvePathGlobsFs, git: resolvePathGlobsGit } = require('./resolve-path-globs')
29
- const { pipeline, Writable } = require('stream')
29
+ const { pipeline, Writable } = require('node:stream')
30
30
  const forEach = (write) => new Writable({ objectMode: true, write })
31
31
  const userRequire = require('@antora/user-require-helper')
32
32
  const yaml = require('js-yaml')
@@ -87,138 +87,132 @@ const URL_PORT_CLEANER_RX = /^([^/]+):[0-9]+(?=\/)/
87
87
  */
88
88
  function aggregateContent (playbook) {
89
89
  const startDir = playbook.dir || '.'
90
- const { branches, editUrl, tags, sources } = playbook.content
91
- const sourceDefaults = { branches, editUrl, tags }
90
+ const { branches, editUrl, tags, sources, worktrees } = playbook.content
91
+ const sourceDefaults = { branches, editUrl, tags, worktrees }
92
92
  const { cacheDir: requestedCacheDir, fetch, quiet } = playbook.runtime
93
93
  return ensureCacheDir(requestedCacheDir, startDir).then((cacheDir) => {
94
94
  const gitConfig = Object.assign({ ensureGitSuffix: true }, playbook.git)
95
95
  const gitPlugins = loadGitPlugins(gitConfig, playbook.network || {}, startDir)
96
- const fetchConcurrency = Math.max(gitConfig.fetchConcurrency || Infinity, 1)
96
+ const concurrency = {
97
+ fetch: Math.max(gitConfig.fetchConcurrency || Infinity, 1),
98
+ read: Math.max(gitConfig.readConcurrency || Infinity, 1),
99
+ }
97
100
  const sourcesByUrl = sources.reduce((accum, source) => {
98
101
  return accum.set(source.url, [...(accum.get(source.url) || []), Object.assign({}, sourceDefaults, source)])
99
102
  }, new Map())
100
- const progress = !quiet && createProgress(sourcesByUrl.keys(), process.stdout)
103
+ const progress = quiet ? undefined : createProgress(sourcesByUrl.keys(), process.stdout)
101
104
  const refPatternCache = Object.assign(new Map(), { braces: new Map() })
102
- const loadOpts = { cacheDir, fetch, gitPlugins, progress, startDir, refPatternCache }
103
- return collectFiles(sourcesByUrl, loadOpts, fetchConcurrency).then(buildAggregate, (err) => {
104
- progress && progress.terminate()
105
+ const fetchConfig = { always: fetch, depth: Math.max(0, gitConfig.fetchDepth ?? 1) }
106
+ const loadOpts = { cacheDir, fetch: fetchConfig, gitPlugins, progress, startDir, refPatternCache }
107
+ return collectFiles(sourcesByUrl, loadOpts, concurrency).then(buildAggregate, (err) => {
108
+ progress?.terminate()
105
109
  throw err
106
110
  })
107
111
  })
108
112
  }
109
113
 
110
- async function collectFiles (sourcesByUrl, loadOpts, concurrency) {
111
- const tasks = [...sourcesByUrl.entries()].map(([url, sources]) => [
112
- () => loadRepository(url, Object.assign({ fetchTags: tagsSpecified(sources) }, loadOpts)),
113
- ({ repo, authStatus }) =>
114
- Promise.all(
115
- sources.map((source) => {
116
- // NOTE if repository is managed (has a url property), we can assume the remote name is origin
117
- // TODO if the repo has no remotes, then remoteName should be undefined
118
- const remoteName = repo.url ? 'origin' : source.remote || 'origin'
119
- return collectFilesFromSource(source, repo, remoteName, authStatus)
120
- })
121
- ),
122
- ])
123
- let rejection, started
124
- const startedContinuations = []
125
- const recordRejection = (err) => {
126
- rejection = err
127
- }
128
- const runTask = (primary, continuation, idx) =>
129
- primary().then((value) => {
130
- if (!rejection) startedContinuations[idx] = continuation(value).catch(recordRejection)
131
- }, recordRejection)
132
- if (tasks.length > concurrency) {
133
- started = []
134
- const pending = []
135
- for (const [primary, continuation] of tasks) {
136
- const current = runTask(primary, continuation, started.length).finally(() =>
137
- pending.splice(pending.indexOf(current), 1)
138
- )
139
- started.push(current)
140
- if (pending.push(current) < concurrency) continue
141
- await Promise.race(pending)
142
- if (rejection) break
114
+ async function collectFiles (sourcesByUrl, loadOpts, concurrency, fetchedUrls = []) {
115
+ const loadTasks = [...sourcesByUrl.entries()].map(([url, sources]) => {
116
+ const loadOptsForUrl = Object.assign({}, loadOpts)
117
+ if (loadOpts.fetch.always && fetchedUrls.length && ~fetchedUrls.indexOf(url)) loadOptsForUrl.fetch.always = false
118
+ if (tagsSpecified(sources)) loadOptsForUrl.fetch.tags = true
119
+ const commits = commitsRequested(sources)
120
+ if (commits) loadOptsForUrl.fetch.commits = commits
121
+ return loadRepository.bind(null, url, loadOptsForUrl, { url, sources })
122
+ })
123
+ return gracefulPromiseAllWithLimit(loadTasks, concurrency.fetch).then(([results, rejections]) => {
124
+ if (rejections.length) {
125
+ if (concurrency.fetch > 1 && results.length > 1 && rejections.every(({ recoverable }) => recoverable)) {
126
+ if (loadOpts.progress) loadOpts.progress.terminate() // reset cursor position and allow it be reused
127
+ const msg0 = 'An unexpected error occurred while fetching content sources concurrently.'
128
+ const msg1 = 'Retrying with git.fetch_concurrency value of 1.'
129
+ logger.warn(rejections[0], msg0 + ' ' + msg1)
130
+ const fulfilledUrls = results.filter((it) => it?.repo.url).map((it) => it.url)
131
+ return collectFiles(sourcesByUrl, loadOpts, Object.assign(concurrency, { fetch: 1 }), fulfilledUrls)
132
+ }
133
+ throw rejections[0]
143
134
  }
144
- } else {
145
- started = tasks.map(([primary, continuation], idx) => runTask(primary, continuation, idx))
146
- }
147
- return Promise.all(started).then(() =>
148
- Promise.all(startedContinuations).then((result) => {
149
- if (rejection) throw rejection
150
- return result
151
- })
152
- )
135
+ return Promise.all(
136
+ results.map(({ repo, authStatus, sources }) =>
137
+ selectStartPathsForRepository(repo, sources).then((startPaths) =>
138
+ collectFilesFromStartPaths.bind(null, startPaths, repo, authStatus)
139
+ )
140
+ )
141
+ ).then((collectTasks) => promiseAllWithLimit(collectTasks, concurrency.read))
142
+ })
153
143
  }
154
144
 
155
145
  function buildAggregate (componentVersionBuckets) {
156
- return [
157
- ...deepFlatten(componentVersionBuckets)
158
- .reduce((accum, batch) => {
159
- const key = batch.version + '@' + batch.name
160
- const entry = accum.get(key)
161
- if (!entry) return accum.set(key, batch)
146
+ const entries = Object.assign(new Map(), { accum: [] })
147
+ for (const batchesForOrigin of componentVersionBuckets) {
148
+ for (const batch of batchesForOrigin) {
149
+ let key, entry
150
+ if ((entry = entries.get((key = batch.version + '@' + batch.name)))) {
162
151
  const { files, origins } = batch
163
152
  ;(batch.files = entry.files).push(...files)
164
153
  ;(batch.origins = entry.origins).push(origins[0])
165
154
  Object.assign(entry, batch)
166
- return accum
167
- }, new Map())
168
- .values(),
169
- ]
155
+ } else {
156
+ entries.set(key, batch).accum.push(batch)
157
+ }
158
+ }
159
+ }
160
+ return entries.accum
170
161
  }
171
162
 
172
- async function loadRepository (url, opts) {
163
+ async function loadRepository (url, opts, result = {}) {
173
164
  let authStatus, dir, repo
174
165
  const cache = { [REF_PATTERN_CACHE_KEY]: opts.refPatternCache }
175
166
  if (~url.indexOf(':') && GIT_URI_DETECTOR_RX.test(url)) {
176
167
  let credentials, displayUrl
177
168
  ;({ displayUrl, url, credentials } = extractCredentials(url))
178
- const { cacheDir, fetch, fetchTags, gitPlugins, progress } = opts
169
+ const { cacheDir, fetch, gitPlugins, progress } = opts
179
170
  dir = ospath.join(cacheDir, generateCloneFolderName(displayUrl))
180
171
  // NOTE the presence of the url property on the repo object implies the repository is remote
181
172
  repo = { cache, dir, fs, gitdir: dir, noCheckout: true, url }
173
+ const { credentialManager } = gitPlugins
182
174
  const validStateFile = ospath.join(dir, VALID_STATE_FILENAME)
183
175
  try {
184
176
  await fsp.access(validStateFile)
185
- if (fetch) {
177
+ if (fetch.always) {
186
178
  await fsp.unlink(validStateFile)
187
- const fetchOpts = buildFetchOptions(repo, progress, displayUrl, credentials, gitPlugins, fetchTags, 'fetch')
179
+ const fetchOpts = buildFetchOptions(repo, progress, displayUrl, credentials, gitPlugins, fetch, 'fetch')
188
180
  await git
189
181
  .fetch(fetchOpts)
182
+ .then(() => ensureOids(fetchOpts))
190
183
  .then(() => {
191
- authStatus = identifyAuthStatus(gitPlugins.credentialManager, credentials, url)
184
+ authStatus = identifyAuthStatus(credentialManager, credentials, url)
192
185
  return git.setConfig(Object.assign({ path: 'remote.origin.private', value: authStatus }, repo))
193
186
  })
194
187
  .catch((fetchErr) => {
195
- if (fetchOpts.onProgress) fetchOpts.onProgress.finish(fetchErr)
188
+ fetchOpts.onProgress?.finish(fetchErr)
196
189
  if (HTTP_ERROR_CODE_RX.test(fetchErr.code) && fetchErr.data.statusCode === 401) fetchErr.rethrow = true
197
190
  throw fetchErr
198
191
  })
199
192
  .then(() => fsp.writeFile(validStateFile, '').catch(invariably.void))
200
- .then(() => fetchOpts.onProgress && fetchOpts.onProgress.finish())
193
+ .then(() => fetchOpts.onProgress?.finish())
201
194
  } else {
202
195
  authStatus = await git.getConfig(Object.assign({ path: 'remote.origin.private' }, repo))
203
196
  }
204
197
  } catch (gitErr) {
205
198
  await fsp['rm' in fsp ? 'rm' : 'rmdir'](dir, { recursive: true, force: true })
206
199
  if (gitErr.rethrow) throw transformGitCloneError(gitErr, displayUrl)
207
- const fetchOpts = buildFetchOptions(repo, progress, displayUrl, credentials, gitPlugins, fetchTags, 'clone')
200
+ const fetchOpts = buildFetchOptions(repo, progress, displayUrl, credentials, gitPlugins, fetch, 'clone')
208
201
  await git
209
202
  .clone(fetchOpts)
210
203
  .then(() => git.resolveRef(Object.assign({ ref: 'HEAD', depth: 1 }, repo)))
204
+ .then(() => ensureOids(fetchOpts))
211
205
  .then(() => {
212
- authStatus = identifyAuthStatus(gitPlugins.credentialManager, credentials, url)
206
+ authStatus = identifyAuthStatus(credentialManager, credentials, url)
213
207
  return git.setConfig(Object.assign({ path: 'remote.origin.private', value: authStatus }, repo))
214
208
  })
215
209
  .catch((cloneErr) => {
216
- // FIXME triggering the error handler here causes assertion problems in the test suite
217
- //if (fetchOpts.onProgress) fetchOpts.onProgress.finish(cloneErr)
218
- throw transformGitCloneError(cloneErr, displayUrl)
210
+ fetchOpts.onProgress?.finish(cloneErr)
211
+ const authRequested = credentialManager.status({ url }) === 'requested'
212
+ throw transformGitCloneError(cloneErr, displayUrl, authRequested)
219
213
  })
220
214
  .then(() => fsp.writeFile(validStateFile, '').catch(invariably.void))
221
- .then(() => fetchOpts.onProgress && fetchOpts.onProgress.finish())
215
+ .then(() => fetchOpts.onProgress?.finish())
222
216
  }
223
217
  } else if (await isDirectory((dir = expandPath(url, { dot: opts.startDir })))) {
224
218
  const dotgit = ospath.join(dir, '.git')
@@ -239,7 +233,7 @@ async function loadRepository (url, opts) {
239
233
  } else {
240
234
  throw new Error(`Local content source does not exist: ${dir}${url !== dir ? ' (url: ' + url + ')' : ''}`)
241
235
  }
242
- return { repo, authStatus }
236
+ return Object.assign(result, { repo, authStatus })
243
237
  }
244
238
 
245
239
  function extractCredentials (url) {
@@ -254,34 +248,61 @@ function extractCredentials (url) {
254
248
  // NOTE if only username is present, assume it's an oauth token and set password to empty string
255
249
  const credentials = username ? { username, password: password || '' } : {}
256
250
  return { displayUrl, url, credentials }
257
- } else if (url.startsWith('git@')) {
258
- return { displayUrl: url, url: 'https://' + url.substr(4).replace(':', '/') }
259
- } else {
260
- return { displayUrl: url, url }
261
251
  }
252
+ if (url.startsWith('git@')) return { displayUrl: url, url: 'https://' + url.substr(4).replace(':', '/') }
253
+ return { displayUrl: url, url }
262
254
  }
263
255
 
264
- async function collectFilesFromSource (source, repo, remoteName, authStatus) {
265
- const originUrl = repo.url || (await resolveRemoteUrl(repo, remoteName))
266
- return selectReferences(source, repo, remoteName).then((refs) => {
267
- if (!refs.length) {
268
- const { url, branches, tags, startPath, startPaths } = source
256
+ async function selectStartPathsForRepository (repo, sources) {
257
+ const startPaths = []
258
+ const originUrls = {}
259
+ for (const source of sources) {
260
+ const { version, editUrl } = source
261
+ let remoteName, originUrl
262
+ if (repo.url) {
263
+ remoteName = 'origin' // NOTE if repository is managed (has url property), we can assume remote name is origin
264
+ originUrl = repo.url
265
+ } else {
266
+ remoteName = source.remote || 'origin'
267
+ originUrl =
268
+ remoteName in originUrls
269
+ ? originUrls[remoteName]
270
+ : (originUrls[remoteName] = await resolveRemoteUrl(repo, remoteName))
271
+ if (!originUrl) {
272
+ remoteName = undefined
273
+ if ((originUrl = posixify ? 'file:///' + posixify(repo.dir) : 'file://' + repo.dir).indexOf(' ')) {
274
+ originUrl = originUrl.replace(SPACE_RX, '%20')
275
+ }
276
+ }
277
+ }
278
+ const refs = await selectReferences(source, repo, remoteName)
279
+ if (refs.length) {
280
+ for (const ref of refs) {
281
+ for (const startPath of await selectStartPaths(source, repo, ref)) {
282
+ startPaths.push({ startPath, ref, originUrl, editUrl, version })
283
+ }
284
+ }
285
+ } else {
286
+ const { url, branches, tags, commits } = source
269
287
  const startPathInfo =
270
- 'startPaths' in source ? { 'start paths': startPaths || undefined } : { 'start path': startPath || undefined }
271
- const sourceInfo = yaml.dump({ url, branches, tags, ...startPathInfo }, { flowLevel: 1 }).trimRight()
288
+ 'startPaths' in source
289
+ ? { 'start paths': source.startPaths || undefined }
290
+ : { 'start path': source.startPath || undefined }
291
+ const sourceInfo = yaml.dump({ url, branches, tags, commits, ...startPathInfo }, { flowLevel: 1 }).trimRight()
272
292
  logger.info(`No matching references found for content source entry (${sourceInfo.replace(NEWLINE_RX, ' | ')})`)
273
- return []
274
293
  }
275
- return Promise.all(refs.map((it) => collectFilesFromReference(source, repo, remoteName, authStatus, it, originUrl)))
276
- })
294
+ }
295
+ return startPaths
277
296
  }
278
297
 
279
298
  // QUESTION should we resolve HEAD to a ref eagerly to avoid having to do a match on it?
280
299
  async function selectReferences (source, repo, remote) {
281
- let { branches: branchPatterns, tags: tagPatterns, worktrees: worktreePatterns } = source
282
- const isBare = repo.noCheckout
300
+ let { branches: branchPatterns, tags: tagPatterns, commits, worktrees: worktreePatterns } = source
301
+ const managed = 'url' in repo
302
+ const isBare = managed || repo.noCheckout
283
303
  const patternCache = repo.cache[REF_PATTERN_CACHE_KEY]
284
- const noWorktree = repo.url ? undefined : false
304
+ const noWorktree = managed ? undefined : false
305
+ const isLinkedWorktree = repo.worktree?.name
285
306
  const refs = new Map()
286
307
  if (
287
308
  tagPatterns &&
@@ -292,51 +313,77 @@ async function selectReferences (source, repo, remote) {
292
313
  const tags = await git.listTags(repo)
293
314
  if (tags.length) {
294
315
  for (const shortname of filterRefs(tags, tagPatterns, patternCache)) {
295
- // NOTE tags are stored using symbol keys to distinguish them from branches
296
- refs.set(Symbol(shortname), { shortname, fullname: 'tags/' + shortname, type: 'tag', head: noWorktree })
316
+ // NOTE tags are stored using Buffer keys to distinguish them from commits and branches
317
+ refs.set(Buffer.from(shortname), { shortname, fullname: 'tags/' + shortname, type: 'tag', head: noWorktree })
297
318
  }
298
319
  }
299
320
  }
300
- if (!branchPatterns) return [...refs.values()]
301
- branchPatterns = Array.isArray(branchPatterns)
302
- ? branchPatterns.map((pattern) => String(pattern))
303
- : splitRefPatterns(String(branchPatterns))
304
- if (!branchPatterns.length) return [...refs.values()]
305
- const worktreeName = repo.worktreeName // possibly switch to worktree property ({ name, dir}) in future
306
- if (worktreeName) branchPatterns = branchPatterns.map((it) => (it === 'HEAD' ? 'HEAD@' + worktreeName : it))
307
- if (worktreePatterns) {
321
+ if (
322
+ commits &&
323
+ (commits = Array.isArray(commits) ? commits.map((commit) => String(commit)) : commits.split(CSV_RX)).length
324
+ ) {
325
+ for (const oid of commits) {
326
+ // NOTE commits are stored using Symbol keys to distinguish them from tags and branches
327
+ refs.set(Symbol(oid), { oid, shortname: oid, fullname: 'commits/' + oid, type: 'commit' })
328
+ }
329
+ }
330
+ if (
331
+ !branchPatterns ||
332
+ !(branchPatterns = Array.isArray(branchPatterns)
333
+ ? branchPatterns.map((pattern) => String(pattern))
334
+ : splitRefPatterns(String(branchPatterns))).length
335
+ ) {
336
+ return [...refs.values()]
337
+ }
338
+ let useWorktree = false
339
+ if (!managed && (useWorktree = {})) {
308
340
  if (worktreePatterns === '.') {
309
- worktreePatterns = ['.']
341
+ isLinkedWorktree ? (useWorktree.linked = isLinkedWorktree) : isBare || (useWorktree.main = true)
342
+ worktreePatterns = []
343
+ } else if (!worktreePatterns) {
344
+ worktreePatterns = []
310
345
  } else if (worktreePatterns === true) {
311
- worktreePatterns = ['.', '*']
346
+ if (!isBare) useWorktree.main = true
347
+ // NOTE if we don't start at a linked worktree, linked worktree cannot be current worktree
348
+ if (isLinkedWorktree) useWorktree.linked = isLinkedWorktree
349
+ worktreePatterns = ['*']
350
+ } else if (worktreePatterns === '/.') {
351
+ if (!isBare) useWorktree.main = true
352
+ worktreePatterns = []
312
353
  } else {
313
- worktreePatterns = Array.isArray(worktreePatterns)
314
- ? worktreePatterns.map((pattern) => String(pattern))
315
- : splitRefPatterns(String(worktreePatterns))
316
- if (worktreeName) worktreePatterns = worktreePatterns.map((it) => (it === '@' ? worktreeName : it))
354
+ worktreePatterns = (
355
+ Array.isArray(worktreePatterns)
356
+ ? worktreePatterns.map((pattern) => String(pattern))
357
+ : splitRefPatterns(String(worktreePatterns))
358
+ ).reduce((accum, it) => {
359
+ if (it === '/.') return (isBare || (useWorktree.main = true)) && accum
360
+ if (it === '.') {
361
+ isLinkedWorktree ? (useWorktree.linked = isLinkedWorktree) : isBare || (useWorktree.main = true)
362
+ } else {
363
+ accum.push(it)
364
+ }
365
+ return accum
366
+ }, [])
317
367
  }
318
- } else {
319
- worktreePatterns = worktreePatterns === undefined ? [worktreeName || '.'] : []
368
+ if (!(useWorktree.main || useWorktree.linked)) useWorktree = false
320
369
  }
321
- if (branchPatterns.length === 1 && (branchPatterns[0] === 'HEAD' || branchPatterns[0] === '.')) {
322
- const currentBranch = await getCurrentBranchName(repo, remote)
323
- if (currentBranch) {
324
- branchPatterns = [currentBranch]
325
- } else if (isBare) {
326
- return [...refs.values()]
327
- } else {
328
- // NOTE current branch is undefined when HEAD is detached
329
- const head = worktreePatterns[0] === '.' ? repo.dir : noWorktree
330
- refs.set('HEAD', { shortname: 'HEAD', fullname: 'HEAD', type: 'branch', detached: true, head })
331
- return [...refs.values()]
332
- }
333
- } else {
370
+ let currentBranch
371
+ if (!isLinkedWorktree) {
334
372
  let headBranchIdx
335
- // NOTE we can assume at least two entries if HEAD or . are present
336
- if (~(headBranchIdx = branchPatterns.indexOf('HEAD')) || ~(headBranchIdx = branchPatterns.indexOf('.'))) {
337
- const currentBranch = await getCurrentBranchName(repo, remote)
338
- if (currentBranch) {
339
- // NOTE ignore if current branch is already in list
373
+ if (branchPatterns.length === 1 && (branchPatterns[0] === 'HEAD' || branchPatterns[0] === '.')) {
374
+ if ((currentBranch = await getCurrentBranchName(repo, remote).then((branch) => branch ?? false))) {
375
+ branchPatterns = [currentBranch]
376
+ } else if (isBare) {
377
+ return [...refs.values()]
378
+ } else {
379
+ // NOTE current branch is undefined when HEAD is detached
380
+ const head = useWorktree.main ? repo.dir : noWorktree
381
+ refs.set('HEAD', { shortname: 'HEAD', fullname: 'HEAD', type: 'branch', detached: true, head })
382
+ return [...refs.values()]
383
+ }
384
+ } else if (~(headBranchIdx = branchPatterns.indexOf('HEAD')) || ~(headBranchIdx = branchPatterns.indexOf('.'))) {
385
+ // NOTE we can assume at least two entries if HEAD or . are present
386
+ if ((currentBranch = await getCurrentBranchName(repo, remote).then((branch) => branch ?? false))) {
340
387
  if (~branchPatterns.indexOf(currentBranch)) {
341
388
  branchPatterns.splice(headBranchIdx, 1)
342
389
  } else {
@@ -345,11 +392,7 @@ async function selectReferences (source, repo, remote) {
345
392
  } else if (isBare) {
346
393
  branchPatterns.splice(headBranchIdx, 1)
347
394
  } else {
348
- let head = noWorktree
349
- if (worktreePatterns[0] === '.') {
350
- worktreePatterns = worktreePatterns.slice(1)
351
- head = repo.dir
352
- }
395
+ const head = useWorktree.main ? repo.dir : noWorktree
353
396
  // NOTE current branch is undefined when HEAD is detached
354
397
  refs.set('HEAD', { shortname: 'HEAD', fullname: 'HEAD', type: 'branch', detached: true, head })
355
398
  branchPatterns.splice(headBranchIdx, 1)
@@ -357,40 +400,51 @@ async function selectReferences (source, repo, remote) {
357
400
  }
358
401
  }
359
402
  // NOTE isomorphic-git includes HEAD in list of remote branches (see https://isomorphic-git.org/docs/listBranches)
360
- const remoteBranches = (await git.listBranches(Object.assign({ remote }, repo))).filter((it) => it !== 'HEAD')
403
+ const remoteBranches = remote
404
+ ? (await git.listBranches(Object.assign({ remote }, repo))).filter((it) => it !== 'HEAD')
405
+ : []
361
406
  if (remoteBranches.length) {
362
407
  for (const shortname of filterRefs(remoteBranches, branchPatterns, patternCache)) {
363
408
  const fullname = 'remotes/' + remote + '/' + shortname
364
409
  refs.set(shortname, { shortname, fullname, type: 'branch', remote, head: noWorktree })
365
410
  }
366
411
  }
367
- // NOTE only consider local branches if repo has a worktree or there are no remote tracking branches
368
- if (!isBare) {
369
- const localBranches = await git.listBranches(repo)
370
- if (localBranches.length) {
371
- const worktrees = await findWorktrees(repo, worktreePatterns)
372
- let onMatch
373
- if ((worktreePatterns.join('') || '.') !== '.') {
374
- const symbolicNames = new Map()
375
- worktrees.forEach(({ name, symbolicName = 'HEAD@' + name }, shortname) => {
376
- localBranches.push(symbolicName)
377
- symbolicNames.set(symbolicName, shortname)
378
- })
379
- onMatch = (candidate, { pattern }) => {
380
- const shortname = symbolicNames.get(candidate)
381
- return shortname ? (pattern.startsWith('HEAD@') ? shortname : undefined) : candidate
412
+ if (!managed) {
413
+ const localBranches = await git.listBranches(repo).then((branches) => {
414
+ if (branches.length || isBare) return branches
415
+ if (currentBranch != null) return [currentBranch]
416
+ return getCurrentBranchName(repo).then((branch) => (branch ? [branch] : []))
417
+ })
418
+ const worktrees = await findWorktrees(repo, worktreePatterns, useWorktree)
419
+ let onMatch
420
+ if ((useWorktree || worktreePatterns.length) && worktrees.size) {
421
+ const headNames = new Map()
422
+ worktrees.forEach(({ name, symbolicNames }, shortname) => {
423
+ if (name) {
424
+ const headName = 'HEAD@' + name
425
+ localBranches.push(headName)
426
+ headNames.set(headName, shortname)
382
427
  }
383
- }
384
- for (const shortname of filterRefs(localBranches, branchPatterns, patternCache, onMatch)) {
385
- const head = (worktrees.get(shortname) || { head: noWorktree }).head
386
- refs.set(shortname, { shortname, fullname: 'heads/' + shortname, type: 'branch', head })
428
+ if (symbolicNames) {
429
+ for (const symbolicName of symbolicNames) {
430
+ const symbolicHeadName = symbolicName === 'HEAD' ? symbolicName : 'HEAD@' + symbolicName
431
+ localBranches.push(symbolicHeadName)
432
+ headNames.set(symbolicHeadName, shortname)
433
+ }
434
+ }
435
+ })
436
+ onMatch = (candidate, { pattern }) => {
437
+ const shortname = headNames.get(candidate)
438
+ if (!shortname) return candidate
439
+ if (pattern === 'HEAD' || pattern.startsWith('HEAD@')) return shortname
387
440
  }
388
441
  }
389
- } else if (!remoteBranches.length) {
390
- const localBranches = await git.listBranches(repo)
391
442
  if (localBranches.length) {
392
- for (const shortname of filterRefs(localBranches, branchPatterns, patternCache)) {
393
- refs.set(shortname, { shortname, fullname: 'heads/' + shortname, type: 'branch', head: noWorktree })
443
+ const preferRemote = isBare && remoteBranches.length > 0
444
+ for (const shortname of filterRefs(localBranches, branchPatterns, patternCache, onMatch)) {
445
+ if (preferRemote && refs.has(shortname)) continue
446
+ const head = (worktrees.get(shortname) || { head: false }).head
447
+ refs.set(shortname, { shortname, fullname: 'heads/' + shortname, type: 'branch', head })
394
448
  }
395
449
  }
396
450
  }
@@ -398,27 +452,28 @@ async function selectReferences (source, repo, remote) {
398
452
  }
399
453
 
400
454
  /**
401
- * Returns the current branch name unless the HEAD is detached.
455
+ * Returns the current branch name or undefined if the HEAD is detached.
402
456
  */
403
457
  function getCurrentBranchName (repo, remote) {
404
458
  return (
405
- repo.noCheckout && remote
459
+ remote && repo.noCheckout
406
460
  ? git
407
- .resolveRef(Object.assign({ ref: 'refs/remotes/' + remote + '/HEAD', depth: 2 }, repo))
408
- .catch(() => git.resolveRef(Object.assign({ ref: 'HEAD', depth: 2 }, repo)))
461
+ .resolveRef(Object.assign({ ref: 'refs/remotes/' + remote + '/HEAD', depth: 2 }, repo))
462
+ .catch(() => git.resolveRef(Object.assign({ ref: 'HEAD', depth: 2 }, repo)))
409
463
  : git.resolveRef(Object.assign({ ref: 'HEAD', depth: 2 }, repo))
410
464
  ).then((ref) => (ref.startsWith('refs/') ? ref.replace(SHORTEN_REF_RX, '') : undefined))
411
465
  }
412
466
 
413
- async function collectFilesFromReference (source, repo, remoteName, authStatus, ref, originUrl) {
467
+ async function selectStartPaths (source, repo, ref) {
414
468
  const url = repo.url
415
469
  const displayUrl = url || repo.dir
416
- const { version, editUrl } = source
417
470
  const worktreePath = ref.head
418
471
  if (!worktreePath) {
419
- ref.oid = await git.resolveRef(
420
- Object.assign(ref.detached ? { ref: 'HEAD', depth: 1 } : { ref: 'refs/' + ref.fullname }, repo)
421
- )
472
+ ref.oid = ref.oid
473
+ ? await git.expandOid(Object.assign({ oid: ref.oid }, repo)).catch(() => ref.oid)
474
+ : await git.resolveRef(
475
+ Object.assign(ref.detached ? { ref: 'HEAD', depth: 1 } : { ref: 'refs/' + ref.fullname }, repo)
476
+ )
422
477
  }
423
478
  if ('startPaths' in source) {
424
479
  let startPaths
@@ -433,25 +488,31 @@ async function collectFilesFromReference (source, repo, remoteName, authStatus,
433
488
  const flag = worktreePath ? ' <worktree>' : ref.remote && worktreePath === false ? ` <remotes/${ref.remote}>` : ''
434
489
  throw new Error(`no start paths found in ${where} (${ref.type}: ${ref.shortname}${flag})`)
435
490
  }
436
- return Promise.all(
437
- startPaths.map((startPath) =>
438
- collectFilesFromStartPath(startPath, repo, authStatus, ref, worktreePath, originUrl, editUrl, version)
439
- )
440
- )
491
+ return startPaths
441
492
  }
442
- const startPath = cleanStartPath(coerceToString(source.startPath))
443
- return collectFilesFromStartPath(startPath, repo, authStatus, ref, worktreePath, originUrl, editUrl, version)
493
+ return [cleanStartPath(coerceToString(source.startPath))]
444
494
  }
445
495
 
446
- function collectFilesFromStartPath (startPath, repo, authStatus, ref, worktreePath, originUrl, editUrl, version) {
496
+ async function collectFilesFromStartPaths (startPaths, repo, authStatus) {
497
+ const buckets = []
498
+ for (const { startPath, ref, originUrl, editUrl, version } of startPaths) {
499
+ buckets.push(await collectFilesFromStartPath(startPath, repo, authStatus, ref, originUrl, editUrl, version))
500
+ }
501
+ repo.cache = undefined
502
+ return buckets
503
+ }
504
+
505
+ function collectFilesFromStartPath (startPath, repo, authStatus, ref, originUrl, editUrl, version) {
506
+ const worktreePath = ref.head
447
507
  const origin = computeOrigin(originUrl, authStatus, repo.gitdir, ref, startPath, worktreePath, editUrl)
448
508
  return (worktreePath ? readFilesFromWorktree(origin) : readFilesFromGitTree(repo, ref.oid, startPath))
449
- .then((files) =>
450
- Object.assign(deepClone((origin.descriptor = loadComponentDescriptor(files, ref, version))), {
451
- files: files.map((file) => assignFileProperties(file, origin)),
452
- origins: [origin],
453
- })
454
- )
509
+ .then((files) => {
510
+ const batch = deepClone((origin.descriptor = loadComponentDescriptor(files, ref, version)))
511
+ if ('nav' in batch && Array.isArray(batch.nav)) batch.nav.origin = origin
512
+ batch.files = files.map((file) => assignFileProperties(file, origin))
513
+ batch.origins = [origin]
514
+ return batch
515
+ })
455
516
  .catch((err) => {
456
517
  const where = worktreePath || (worktreePath === false ? repo.gitdir : repo.url || repo.dir)
457
518
  const flag = worktreePath ? ' <worktree>' : ref.remote && worktreePath === false ? ` <remotes/${ref.remote}>` : ''
@@ -476,19 +537,18 @@ function readFilesFromWorktree (origin) {
476
537
  }
477
538
 
478
539
  function srcFs (cwd, origin) {
479
- return new Promise((resolve, reject, cache = Object.create(null), files = [], relpathStart = cwd.length + 1) =>
540
+ return new Promise((resolve, reject, files = []) =>
480
541
  pipeline(
481
- globStream(CONTENT_SRC_GLOB, Object.assign({ cache, cwd }, CONTENT_SRC_OPTS)),
482
- forEach(({ path: abspathPosix }, _, done) => {
483
- if ((cache[abspathPosix] || {}).constructor === Array) return done() // detects some directories
484
- const abspath = posixify ? ospath.normalize(abspathPosix) : abspathPosix
485
- const relpath = abspath.substr(relpathStart)
486
- symlinkAwareStat(abspath).then(
487
- (stat) => {
488
- if (stat.isDirectory()) return done() // detects directories that slipped through cache check
542
+ globStream(CONTENT_SRC_GLOB, Object.assign({ cwd }, CONTENT_SRC_OPTS)),
543
+ forEach(({ path: relpath, dirent }, _, done) => {
544
+ if (dirent.isDirectory()) return done()
545
+ const relpathPosix = relpath
546
+ const abspath = posixify ? ospath.join(cwd, (relpath = ospath.normalize(relpath))) : cwd + '/' + relpath
547
+ fsp.stat(abspath).then(
548
+ (stat) =>
489
549
  fsp.readFile(abspath).then(
490
550
  (contents) => {
491
- files.push(new File({ path: posixify ? posixify(relpath) : relpath, contents, stat, src: { abspath } }))
551
+ files.push(new File({ path: relpathPosix, contents, stat, src: { abspath } }))
492
552
  done()
493
553
  },
494
554
  (readErr) => {
@@ -498,22 +558,28 @@ function srcFs (cwd, origin) {
498
558
  : logger.error(logObject, readErr.message.replace(`'${abspath}'`, relpath))
499
559
  done()
500
560
  }
501
- )
502
- },
561
+ ),
503
562
  (statErr) => {
504
563
  const logObject = { file: { abspath, origin } }
505
- if (statErr.symlink) {
506
- logger.error(
507
- logObject,
508
- (statErr.code === 'ELOOP' ? 'ELOOP: symbolic link cycle, ' : 'ENOENT: broken symbolic link, ') +
509
- `${relpath} -> ${statErr.symlink}`
510
- )
511
- } else if (statErr.code === 'ENOENT') {
512
- logger.warn(logObject, `ENOENT: file or directory disappeared, ${statErr.syscall} ${relpath}`)
564
+ if (dirent.isSymbolicLink()) {
565
+ fsp
566
+ .readlink(abspath)
567
+ .then(
568
+ (symlink) =>
569
+ (statErr.code === 'ELOOP' ? 'ELOOP: symbolic link cycle, ' : 'ENOENT: broken symbolic link, ') +
570
+ `${relpath} -> ${symlink}`,
571
+ () => statErr.message.replace(`'${abspath}'`, relpath)
572
+ )
573
+ .then((message) => {
574
+ logger.error(logObject, message)
575
+ done()
576
+ })
513
577
  } else {
514
- logger.error(logObject, statErr.message.replace(`'${abspath}'`, relpath))
578
+ statErr.code === 'ENOENT'
579
+ ? logger.warn(logObject, `ENOENT: file or directory disappeared, ${statErr.syscall} ${relpath}`)
580
+ : logger.error(logObject, statErr.message.replace(`'${abspath}'`, relpath))
581
+ done()
515
582
  }
516
- done()
517
583
  }
518
584
  )
519
585
  }),
@@ -523,26 +589,25 @@ function srcFs (cwd, origin) {
523
589
  }
524
590
 
525
591
  function readFilesFromGitTree (repo, oid, startPath) {
526
- return git
527
- .readTree(Object.assign({ oid }, repo))
528
- .then((root) =>
529
- getGitTreeAtStartPath(repo, oid, startPath).then((start) =>
530
- srcGitTree(repo, Object.assign(root, { dirname: '' }), start)
531
- )
532
- )
592
+ return git.readTree(Object.assign({ oid }, repo)).then((root) => {
593
+ Object.assign(root, { dirname: '' })
594
+ return startPath
595
+ ? getGitTreeAtStartPath(repo, oid, startPath).then((start) => {
596
+ Object.assign(start, { dirname: startPath })
597
+ return srcGitTree(repo, root, start)
598
+ })
599
+ : srcGitTree(repo, root)
600
+ })
533
601
  }
534
602
 
535
603
  function getGitTreeAtStartPath (repo, oid, startPath) {
536
- return git.readTree(Object.assign({ oid, filepath: startPath }, repo)).then(
537
- (result) => Object.assign(result, { dirname: startPath }),
538
- (err) => {
539
- const m = err instanceof ObjectTypeError && err.data.expected === 'tree' ? 'is not a directory' : 'does not exist'
540
- throw new Error(`the start path '${startPath}' ${m}`)
541
- }
542
- )
604
+ return git.readTree(Object.assign({ oid, filepath: startPath }, repo)).catch((err) => {
605
+ const m = err instanceof ObjectTypeError && err.data.expected === 'tree' ? 'is not a directory' : 'does not exist'
606
+ throw new Error(`the start path '${startPath}' ${m}`)
607
+ })
543
608
  }
544
609
 
545
- function srcGitTree (repo, root, start) {
610
+ function srcGitTree (repo, root, start = root) {
546
611
  return new Promise((resolve, reject) => {
547
612
  const files = []
548
613
  createGitTreeWalker(repo, root, filterGitEntry, gitEntryToFile)
@@ -585,7 +650,8 @@ function visitGitTree (emitter, repo, root, filter, convert, parent, dirname = '
585
650
  (target) => {
586
651
  if (target.type === 'tree') {
587
652
  return visitGitTree(emitter, repo, root, filter, convert, target, vfilePath, target.following)
588
- } else if (target.type === 'blob' && filterVerdict === true && (mode = FILE_MODES[target.mode])) {
653
+ }
654
+ if (target.type === 'blob' && filterVerdict === true && (mode = FILE_MODES[target.mode])) {
589
655
  return convert(Object.assign({ mode, oid: target.oid, path: vfilePath }, repo)).then((result) =>
590
656
  emitter.emit('entry', result)
591
657
  )
@@ -657,11 +723,11 @@ function readGitObjectAtPath (repo, root, parent, pathSegments, following) {
657
723
  if (entry.path === firstPathSegment) {
658
724
  return entry.type === 'tree'
659
725
  ? git.readTree(Object.assign({ oid: entry.oid }, repo)).then((subtree) => {
660
- Object.assign(subtree, { dirname: path.join(parent.dirname, entry.path) })
661
- return (pathSegments = pathSegments.slice(1)).length
662
- ? readGitObjectAtPath(repo, root, subtree, pathSegments, following)
663
- : Object.assign(subtree, { type: 'tree', following }) // Q: should this create copy?
664
- })
726
+ Object.assign(subtree, { dirname: path.join(parent.dirname, entry.path) })
727
+ return (pathSegments = pathSegments.slice(1)).length
728
+ ? readGitObjectAtPath(repo, root, subtree, pathSegments, following)
729
+ : Object.assign(subtree, { type: 'tree', following }) // Q: should this create copy?
730
+ })
665
731
  : entry.mode === SYMLINK_FILE_MODE
666
732
  ? readGitSymlink(repo, root, parent, entry, following)
667
733
  : Promise.resolve(entry)
@@ -684,8 +750,13 @@ function filterGitEntry (entry) {
684
750
 
685
751
  function gitEntryToFile (entry) {
686
752
  return git.readBlob(entry).then(({ blob: contents }) => {
687
- contents = Buffer.from(contents.buffer)
688
- const stat = Object.assign(new fs.Stats(), { mode: entry.mode, mtime: undefined, size: contents.byteLength })
753
+ const stat = {
754
+ mode: entry.mode,
755
+ size: (contents = Buffer.from(contents.buffer)).byteLength,
756
+ isDirectory: invariably.false,
757
+ isFile: invariably.true,
758
+ isSymbolicLink: invariably.false,
759
+ }
689
760
  return new File({ path: entry.path, contents, stat })
690
761
  })
691
762
  }
@@ -697,7 +768,7 @@ function loadComponentDescriptor (files, ref, version) {
697
768
  files.splice(descriptorFileIdx, 1)
698
769
  let data
699
770
  try {
700
- data = yaml.load(descriptorFile.contents.toString(), { schema: yaml.CORE_SCHEMA })
771
+ data = Object(yaml.load(descriptorFile.contents.toString(), { schema: yaml.CORE_SCHEMA }))
701
772
  } catch (err) {
702
773
  throw Object.assign(err, { message: `${COMPONENT_DESC_FILENAME} has invalid syntax; ${err.message}` })
703
774
  }
@@ -710,18 +781,18 @@ function loadComponentDescriptor (files, ref, version) {
710
781
  if (!version) {
711
782
  if (version === undefined) throw new Error(`${COMPONENT_DESC_FILENAME} is missing a version`)
712
783
  if (version === false) throw new Error(`${COMPONENT_DESC_FILENAME} has an invalid version`)
713
- version = '' + (typeof version === 'number' ? version : '')
784
+ version = typeof version === 'number' ? '' + version : ''
714
785
  } else if (version === true) {
715
786
  version = ref.shortname.replace(PATH_SEPARATOR_RX, '-')
716
787
  } else if (version.constructor === Object) {
717
788
  const refname = ref.shortname
718
789
  let matched
719
790
  if (refname in version) {
720
- matched = version[refname]
791
+ matched = '' + (version[refname] ?? '')
721
792
  } else if (
722
793
  !Object.entries(version).some(([pattern, replacement]) => {
723
- const result = refname.replace(makeMatcherRx(pattern, VERSION_MATCHER_OPTS), '\0' + replacement)
724
- if (result === refname) return false
794
+ const result = refname.replace(makeMatcherRx(pattern, VERSION_MATCHER_OPTS), '\0' + (replacement ?? ''))
795
+ if (result === refname) return false // no match
725
796
  matched = result.substr(1)
726
797
  return true
727
798
  })
@@ -736,7 +807,7 @@ function loadComponentDescriptor (files, ref, version) {
736
807
  throw new Error(`version in ${COMPONENT_DESC_FILENAME} cannot have path segments: ${version}`)
737
808
  }
738
809
  data.version = version
739
- return camelCaseKeys(data, ['asciidoc'])
810
+ return camelCaseKeys(data, ['asciidoc', 'ext'])
740
811
  }
741
812
 
742
813
  function assignFileProperties (file, origin) {
@@ -753,20 +824,23 @@ function assignFileProperties (file, origin) {
753
824
  return file
754
825
  }
755
826
 
756
- function buildFetchOptions (repo, progress, displayUrl, credentialsFromUrl, gitPlugins, fetchTags, operation) {
827
+ function buildFetchOptions (repo, progress, displayUrl, credentialsFromUrl, gitPlugins, fetch, operation) {
757
828
  const { credentialManager, http, urlRouter } = gitPlugins
829
+ const corsProxy = false
830
+ const depth = fetch.depth || undefined
758
831
  const onAuth = resolveCredentials.bind(credentialManager, new Map().set(undefined, credentialsFromUrl))
759
832
  const onAuthFailure = onAuth
760
833
  const onAuthSuccess = (url) => credentialManager.approved({ url })
761
- const opts = Object.assign({ corsProxy: false, depth: 1, http, onAuth, onAuthFailure, onAuthSuccess }, repo)
834
+ const opts = Object.assign({ corsProxy, depth, http, onAuth, onAuthFailure, onAuthSuccess }, repo)
762
835
  if (urlRouter) opts.url = urlRouter.ensureGitSuffix(opts.url)
763
836
  if (progress) opts.onProgress = createProgressListener(progress, displayUrl, operation)
764
837
  if (operation === 'fetch') {
765
838
  opts.prune = true
766
- if (fetchTags) opts.tags = opts.pruneTags = true
767
- } else if (!fetchTags) {
839
+ if (fetch.tags) opts.tags = opts.pruneTags = true
840
+ } else if (!fetch.tags) {
768
841
  opts.noTags = true
769
842
  }
843
+ if (fetch.commits) opts.oids = fetch.commits
770
844
  return opts
771
845
  }
772
846
 
@@ -801,7 +875,10 @@ function createProgressListener (progress, progressLabel, operation) {
801
875
  // NOTE leave room for indeterminate progress at end of bar; this isn't strictly needed for a bare clone
802
876
  progressBar.scaleFactor = Math.max(0, (ticks - 1) / ticks)
803
877
  progressBar.tick(0)
804
- return Object.assign(onGitProgress.bind(progressBar), { finish: onGitComplete.bind(progressBar) })
878
+ return Object.assign(onGitProgress.bind(progressBar), {
879
+ finish: onGitComplete.bind(progressBar),
880
+ reset: () => progressBar.update(0),
881
+ })
805
882
  }
806
883
 
807
884
  function formatProgressBar (label, maxLabelWidth, operation) {
@@ -829,7 +906,6 @@ function onGitProgress ({ phase, loaded, total }) {
829
906
 
830
907
  function onGitComplete (err) {
831
908
  if (err) {
832
- // TODO could use progressBar.interrupt() to replace bar with message instead
833
909
  this.chars.incomplete = '?'
834
910
  this.update(0)
835
911
  // NOTE force progress bar to update regardless of throttle setting
@@ -863,9 +939,8 @@ function identifyAuthStatus (credentialManager, credentials, url) {
863
939
  const status = credentialManager.status({ url })
864
940
  if (credentials) {
865
941
  return typeof status === 'string' && status.startsWith('requested,') ? 'auth-required' : 'auth-embedded'
866
- } else if (status != null) {
867
- return 'auth-required'
868
942
  }
943
+ if (status != null) return 'auth-required'
869
944
  }
870
945
 
871
946
  /**
@@ -874,7 +949,7 @@ function identifyAuthStatus (credentialManager, credentials, url) {
874
949
  * The purpose of this function is generate a safe, unique folder name for the cloned
875
950
  * repository that gets stored in the cache directory.
876
951
  *
877
- * The generated folder name follows the pattern: <basename>-<sha1>-<version>.git
952
+ * The generated folder name follows the pattern: <basename>-<sha1-of-normalized-url>.git
878
953
  *
879
954
  * @param {String} url - The repository URL to convert.
880
955
  * @returns {String} The generated folder name.
@@ -890,21 +965,18 @@ function generateCloneFolderName (url) {
890
965
  *
891
966
  * @param {Repository} repo - The repository on which to operate.
892
967
  * @param {String} remoteName - The name of the remote to resolve.
893
- * @returns {String} The URL of the specified remote, if defined, or the file URI to the local repository.
968
+ * @returns {String} The URL of the specified remote, if defined
894
969
  */
895
970
  function resolveRemoteUrl (repo, remoteName) {
896
971
  return git.getConfig(Object.assign({ path: 'remote.' + remoteName + '.url' }, repo)).then((url) => {
897
- if (url) {
898
- if (url.startsWith('https://') || url.startsWith('http://')) {
899
- return ~url.indexOf('@') ? url.replace(URL_AUTH_CLEANER_RX, '$1') : url
900
- } else if (url.startsWith('git@')) {
901
- return 'https://' + url.substr(4).replace(':', '/')
902
- } else if (url.startsWith('ssh://')) {
903
- return 'https://' + url.substr(url.indexOf('@') + 1 || 6).replace(URL_PORT_CLEANER_RX, '$1')
904
- }
972
+ if (!url) return
973
+ if (url.startsWith('https://') || url.startsWith('http://')) {
974
+ return ~url.indexOf('@') ? url.replace(URL_AUTH_CLEANER_RX, '$1') : url
975
+ }
976
+ if (url.startsWith('git@')) return 'https://' + url.substr(4).replace(':', '/')
977
+ if (url.startsWith('ssh://')) {
978
+ return 'https://' + url.substr(url.indexOf('@') + 1 || 6).replace(URL_PORT_CLEANER_RX, '$1')
905
979
  }
906
- url = posixify ? 'file:///' + posixify(repo.dir) : 'file://' + repo.dir
907
- return ~url.indexOf(' ') ? url.replace(SPACE_RX, '%20') : url
908
980
  })
909
981
  }
910
982
 
@@ -918,24 +990,20 @@ function isDirectory (url) {
918
990
  return fsp.stat(url).then((stat) => stat.isDirectory(), invariably.false)
919
991
  }
920
992
 
921
- function symlinkAwareStat (path_) {
922
- return fsp.lstat(path_).then((lstat) => {
923
- if (!lstat.isSymbolicLink()) return lstat
924
- return fsp.stat(path_).catch((statErr) =>
925
- fsp
926
- .readlink(path_)
927
- .catch(invariably.void)
928
- .then((symlink) => {
929
- throw Object.assign(statErr, { symlink })
930
- })
931
- )
932
- })
933
- }
934
-
935
993
  function tagsSpecified (sources) {
936
994
  return sources.some(({ tags }) => tags && (Array.isArray(tags) ? tags.length : true))
937
995
  }
938
996
 
997
+ function commitsRequested (sources) {
998
+ if (!sources.some(({ commits }) => commits && (Array.isArray(commits) ? commits.length : true))) return
999
+ const result = new Set()
1000
+ for (const { commits } of sources) {
1001
+ if (!commits) continue
1002
+ for (const commit of Array.isArray(commits) ? commits : commits.split(CSV_RX)) result.add(String(commit))
1003
+ }
1004
+ return [...result]
1005
+ }
1006
+
939
1007
  function loadGitPlugins (gitConfig, networkConfig, startDir) {
940
1008
  const plugins = new Map((git.cores || git.default.cores || new Map()).get(GIT_CORE))
941
1009
  for (const [name, request] of Object.entries(gitConfig.plugins || {})) {
@@ -946,7 +1014,7 @@ function loadGitPlugins (gitConfig, networkConfig, startDir) {
946
1014
  if (typeof credentialManager.configure === 'function') {
947
1015
  credentialManager.configure({ config: gitConfig.credentials, startDir })
948
1016
  }
949
- if (typeof credentialManager.status !== 'function') Object.assign(credentialManager, { status () {} })
1017
+ if (typeof credentialManager.status !== 'function') Object.assign(credentialManager, { status: invariably.void })
950
1018
  } else {
951
1019
  credentialManager = new GitCredentialManagerStore().configure({ config: gitConfig.credentials, startDir })
952
1020
  }
@@ -979,8 +1047,8 @@ function ensureCacheDir (preferredCacheDir, startDir) {
979
1047
  )
980
1048
  }
981
1049
 
982
- function transformGitCloneError (err, displayUrl) {
983
- let wrappedMsg, trimMessage
1050
+ function transformGitCloneError (err, displayUrl, authRequested) {
1051
+ let wrappedMsg, recoverable, trimMessage
984
1052
  if (HTTP_ERROR_CODE_RX.test(err.code)) {
985
1053
  switch (err.data.statusCode) {
986
1054
  case 401:
@@ -989,30 +1057,31 @@ function transformGitCloneError (err, displayUrl) {
989
1057
  : 'Content repository not found or requires credentials'
990
1058
  break
991
1059
  case 404:
992
- wrappedMsg = 'Content repository not found'
1060
+ wrappedMsg = authRequested
1061
+ ? 'Content repository not found or credentials were rejected'
1062
+ : 'Content repository not found'
993
1063
  break
994
1064
  default:
995
1065
  wrappedMsg = err.message
996
- trimMessage = true
1066
+ recoverable = trimMessage = true
997
1067
  }
998
1068
  } else if (err instanceof UrlParseError || err instanceof UnknownTransportError) {
999
1069
  wrappedMsg = 'Content source uses an unsupported transport protocol'
1000
1070
  } else if (err.code === 'ENOTFOUND') {
1001
1071
  wrappedMsg = `Content repository host could not be resolved: ${err.hostname}`
1002
1072
  } else {
1003
- wrappedMsg = `${err.name}: ${err.message}`
1004
- trimMessage = true
1005
- }
1006
- if (trimMessage) {
1007
- wrappedMsg = ~(wrappedMsg = wrappedMsg.trimRight()).indexOf('. ') ? wrappedMsg : wrappedMsg.replace(/\.$/, '')
1073
+ wrappedMsg = err.message || String(err)
1074
+ recoverable = trimMessage = true
1008
1075
  }
1076
+ if (trimMessage && !~(wrappedMsg = wrappedMsg.trimEnd()).indexOf('. ')) wrappedMsg = wrappedMsg.replace(/\.$/, '')
1009
1077
  const errWrapper = new Error(`${wrappedMsg} (url: ${displayUrl})`)
1010
- errWrapper.stack += `\nCaused by: ${err.stack || 'unknown'}`
1078
+ errWrapper.stack += `\nCaused by: ${err.stack ? inspect(err).replace(/^Error \[(.+?)\](?=: )/, '$1') : err}`
1079
+ if (recoverable) Object.defineProperty(errWrapper, 'recoverable', { value: true })
1011
1080
  return errWrapper
1012
1081
  }
1013
1082
 
1014
1083
  function splitRefPatterns (str) {
1015
- return ~str.indexOf('{') ? str.split(VENTILATED_CSV_RX) : str.split(CSV_RX)
1084
+ return str.split(~str.indexOf('{') ? VENTILATED_CSV_RX : CSV_RX)
1016
1085
  }
1017
1086
 
1018
1087
  function camelCaseKeys (o, stopPaths = [], p = '') {
@@ -1035,54 +1104,116 @@ function coerceToString (value) {
1035
1104
  return value == null ? '' : String(value)
1036
1105
  }
1037
1106
 
1038
- async function resolveRepositoryFromWorktree (repo) {
1107
+ function resolveRepositoryFromWorktree (repo) {
1039
1108
  return fsp
1040
1109
  .readFile(repo.gitdir, 'utf8')
1041
- .then((contents) => contents.trimRight().substr(8))
1042
- .then((worktreeGitdir) => {
1043
- const worktreeName = ospath.basename(worktreeGitdir)
1044
- return fsp.readFile(ospath.join(worktreeGitdir, 'commondir'), 'utf8').then(
1110
+ .then((contents) => contents.substr(8).trimEnd())
1111
+ .then((worktreeGitdir) =>
1112
+ fsp.readFile(ospath.join(worktreeGitdir, 'commondir'), 'utf8').then(
1045
1113
  (contents) => {
1046
- const gitdir = ospath.join(worktreeGitdir, contents.trimRight())
1047
- return ospath.basename(gitdir) === '.git'
1048
- ? Object.assign(repo, { dir: ospath.dirname(gitdir), gitdir, worktreeName })
1049
- : Object.assign(repo, { dir: gitdir, gitdir, worktreeName })
1114
+ const gitdir = ospath.join(worktreeGitdir, contents.trimEnd())
1115
+ const dir = ospath.basename(gitdir) === '.git' ? ospath.dirname(gitdir) : gitdir
1116
+ const name = ospath.basename(worktreeGitdir)
1117
+ return Object.assign(repo, { dir, gitdir, worktree: { gitdir: worktreeGitdir, name } })
1050
1118
  },
1051
1119
  () => repo
1052
1120
  )
1053
- })
1121
+ )
1054
1122
  }
1055
1123
 
1056
- function findWorktrees (repo, patterns) {
1057
- if (!patterns.length) return new Map()
1058
- const mainWorktree =
1059
- patterns[0] === '.' && (patterns = patterns.slice(1))
1060
- ? getCurrentBranchName(repo).then((branch) => (branch ? [branch, { head: repo.dir, name: '.' }] : undefined))
1061
- : Promise.resolve()
1062
- const worktreesDir = patterns.length ? ospath.join(repo.dir, '.git', 'worktrees') : undefined
1124
+ function findWorktrees (repo, patterns, useWorktree) {
1125
+ const useLinkedWorktree = !!useWorktree.linked
1126
+ const mainWorktree = useWorktree.main
1127
+ ? getCurrentBranchName(repo).then((branch) => {
1128
+ if (!branch) return
1129
+ return [branch, { head: repo.dir, name: undefined, symbolicNames: useLinkedWorktree ? ['/.'] : ['/.', '.'] }]
1130
+ })
1131
+ : Promise.resolve()
1132
+ if (!(useLinkedWorktree || patterns.length)) return mainWorktree.then((entry) => new Map(entry && [entry]))
1133
+ const worktreesDir = ospath.join(repo.dir, repo.dir === repo.gitdir ? '' : '.git', 'worktrees')
1063
1134
  const patternCache = repo.cache[REF_PATTERN_CACHE_KEY]
1064
- return (
1065
- worktreesDir
1066
- ? fsp
1135
+ const scanWorktrees = patterns.length
1136
+ ? fsp
1067
1137
  .readdir(worktreesDir)
1068
1138
  .then((worktreeNames) => filterRefs(worktreeNames, patterns, patternCache), invariably.emptyArray)
1069
- .then((worktreeNames) =>
1070
- Promise.all(
1071
- worktreeNames.map((worktreeName) => {
1072
- const gitdir = ospath.resolve(worktreesDir, worktreeName)
1073
- // NOTE branch name defaults to worktree name if HEAD is detached
1074
- return git
1075
- .currentBranch(Object.assign({}, repo, { gitdir }))
1076
- .then((branch = worktreeName) =>
1077
- fsp
1078
- .readFile(ospath.join(gitdir, 'gitdir'), 'utf8')
1079
- .then((contents) => [branch, { head: ospath.dirname(contents.trimRight()), name: worktreeName }])
1080
- )
1081
- })
1139
+ .then((worktreeNames) => {
1140
+ if (useLinkedWorktree && !~worktreeNames.indexOf(useWorktree.linked)) worktreeNames.push(useWorktree.linked)
1141
+ return worktreeNames
1142
+ })
1143
+ : Promise.resolve(useLinkedWorktree ? [useWorktree.linked] : [])
1144
+ return scanWorktrees
1145
+ .then((worktreeNames) =>
1146
+ Promise.all(
1147
+ worktreeNames.map((name) => {
1148
+ const symbolicNames = useLinkedWorktree && name === useWorktree.linked ? ['.', 'HEAD'] : undefined
1149
+ const gitdir = ospath.resolve(worktreesDir, name)
1150
+ // NOTE branch name defaults to worktree name if HEAD is detached
1151
+ return getCurrentBranchName(Object.assign({}, repo, { gitdir })).then((branch = name) =>
1152
+ fsp
1153
+ .readFile(ospath.join(gitdir, 'gitdir'), 'utf8')
1154
+ .then((contents) => [branch, { head: ospath.dirname(contents.trimEnd()), name, symbolicNames }])
1082
1155
  )
1083
- )
1084
- : Promise.resolve()
1085
- ).then((entries = []) => mainWorktree.then((entry) => new Map(entry ? entries.push(entry) && entries : entries)))
1156
+ })
1157
+ )
1158
+ )
1159
+ .then((entries) => new Map(entries))
1160
+ .then((worktrees) => mainWorktree.then((result) => (result ? worktrees.set(result[0], result[1]) : worktrees)))
1161
+ }
1162
+
1163
+ async function gracefulPromiseAllWithLimit (tasks, limit = Infinity) {
1164
+ const rejections = []
1165
+ const recordRejection = (err) => rejections.push(err) && undefined
1166
+ const started = []
1167
+ if (tasks.length <= limit) {
1168
+ for (const task of tasks) started.push(task().catch(recordRejection))
1169
+ } else {
1170
+ const pending = []
1171
+ for (const task of tasks) {
1172
+ const current = task()
1173
+ .catch(recordRejection)
1174
+ .finally(() => pending.splice(pending.indexOf(current), 1))
1175
+ started.push(current)
1176
+ if (pending.push(current) < limit) continue
1177
+ await Promise.race(pending)
1178
+ if (rejections.length) break
1179
+ }
1180
+ }
1181
+ return Promise.all(started).then((results) => [results, rejections])
1182
+ }
1183
+
1184
+ async function promiseAllWithLimit (tasks, limit = Infinity) {
1185
+ if (tasks.length <= limit) return Promise.all(tasks.map((task) => task()))
1186
+ const started = []
1187
+ const pending = []
1188
+ for (const task of tasks) {
1189
+ const current = task().finally(() => pending.splice(pending.indexOf(current), 1))
1190
+ started.push(current)
1191
+ if (pending.push(current) >= limit) await Promise.race(pending)
1192
+ }
1193
+ return Promise.all(started)
1194
+ }
1195
+
1196
+ async function ensureOids (opts) {
1197
+ if (!opts.oids) return
1198
+ let prevShallowCommits = await getShallowCommits(opts)
1199
+ if (prevShallowCommits == null) return
1200
+ let oids = opts.oids.slice()
1201
+ const deepenOpts = Object.assign({}, opts, { relative: true })
1202
+ const format = 'deflated'
1203
+ while (oids.length) {
1204
+ deepenOpts.onProgress?.reset()
1205
+ await git.fetch(deepenOpts)
1206
+ const shallowCommits = await getShallowCommits(opts)
1207
+ if (shallowCommits == null || shallowCommits === prevShallowCommits) break
1208
+ prevShallowCommits = shallowCommits
1209
+ oids = await Promise.all(
1210
+ oids.map((oid) => git.readObject(Object.assign({ oid, format }, opts)).then(invariably.void, () => oid))
1211
+ ).then((results) => results.filter((it) => it))
1212
+ }
1213
+ }
1214
+
1215
+ function getShallowCommits ({ gitdir }) {
1216
+ return fsp.readFile(ospath.join(gitdir, 'shallow'), 'utf8').catch(invariably.void)
1086
1217
  }
1087
1218
 
1088
1219
  module.exports = aggregateContent