@wmfs/lerna-sync 1.37.1 → 1.39.0

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.
package/lib/index.js CHANGED
@@ -1,373 +1,106 @@
1
1
  const jsonfile = require('jsonfile')
2
2
  const path = require('path')
3
- const fs = require('fs')
4
- const glob = require('glob')
5
3
  const humanizeDuration = require('humanize-duration')
6
4
  const chalk = require('chalk')
7
- const getLocalGitInfo = require('git-repo-info')
8
- const dottie = require('dottie')
9
5
  const { graphql } = require('@octokit/graphql')
10
- const _ = require('lodash')
11
- const query = require('./github-graphql-query')
12
- const actionFunctions = require('./actions')
13
- const Messages = require('./utils/Messages')
6
+ const discover = require('./discover')
7
+ const inspect = require('./inspect')
8
+ const planSync = require('./plan-sync')
9
+ const { report, printPlan, describeResult } = require('./report')
10
+ const { executePlan } = require('./execute-plan')
11
+ const packageFoldersFromPatterns = require('./utils/package-folders-from-patterns')
14
12
 
15
13
  class LernaSync {
16
14
  constructor (options) {
17
- this.options = options
18
- this.lernaJson = jsonfile.readFileSync(path.join(this.options.monorepoPath, 'lerna.json'))
19
- }
20
-
21
- async discoverGithubRepos () {
22
- const gitHubOrgName = this.options.gitHubOrgName
23
- console.log(chalk.gray(`Downloading repo information from https://github.com/${gitHubOrgName}`))
24
- let afterValue = null
25
- let hasNextPage = true
26
- while (hasNextPage) {
27
- const result = await graphql(
28
- query,
29
- {
30
- afterValue,
31
- queryString: `org:${this.options.gitHubOrgName}`,
32
- headers: {
33
- authorization: `token ${this.options.gitHubToken}`
34
- }
35
- })
36
- result.search.edges.forEach(
37
- (edge) => {
38
- const repoName = dottie.get(edge, 'node.name')
39
- const historyEdges = dottie.get(edge, 'node.ref.target.history.edges')
40
- if (historyEdges && historyEdges.length > 0) {
41
- let packageJson = dottie.get(edge, 'node.object.text')
42
- if (packageJson) {
43
- packageJson = JSON.parse(packageJson)
44
- }
45
- this.applyRepo(
46
- repoName,
47
- {
48
- gitHubSha: historyEdges[0].node.oid,
49
- gitHubMessageHeadline: historyEdges[0].node.messageHeadline,
50
- gitHubAuthor: historyEdges[0].node.author,
51
- gitHubPackageJson: packageJson,
52
- gitHubLernaPackageName: this.options.lernaPackageRouterFunction(packageJson)
53
- }
54
- )
55
- }
56
- }
57
- )
58
- hasNextPage = result.search.pageInfo.hasNextPage
59
- afterValue = result.search.pageInfo.endCursor
60
- }
61
- }
62
-
63
- collectLocalGitInfo () {
64
- console.log(chalk.gray('Collecting local Git information'))
65
- this.lernaJson.packages.forEach(
66
- (relPackagePath) => {
67
- const fullPackagePath = path.resolve(this.options.monorepoPath, relPackagePath + '/')
68
- const packageRepoPaths = glob.sync(fullPackagePath, {
69
- windowsPathsNoEscape: true
70
- })
71
- packageRepoPaths.forEach(
72
- (repoPath) => {
73
- const localRepoInfo = {
74
- localGit: getLocalGitInfo(repoPath),
75
- localLernaPackageName: path.basename(path.resolve(repoPath, '..'))
76
- }
77
-
78
- const packageJsonPath = path.join(repoPath, 'package.json')
79
- try {
80
- localRepoInfo.localPackageJson = jsonfile.readFileSync(packageJsonPath)
81
- } catch (e) {
82
- // Just ignore failure to grab package.json.
83
- console.error(e)
84
- }
85
- this.applyRepo(
86
- path.basename(repoPath),
87
- localRepoInfo
88
- )
89
- }
90
- )
15
+ this.lernaPackageRouterFunction = options.lernaPackageRouterFunction
16
+ this.cloneUrl = options.cloneUrl
17
+ this.updateLocalDefaultBranch = options.updateLocalDefaultBranch
18
+ this.verbose = options.verbose
19
+ this.dryRun = options.dryRun
20
+ this.monorepoPath = options.monorepoPath
21
+ this.concurrency = validateConcurrency(options.concurrency)
22
+ this.gitHubOrgName = options.gitHubOrgName
23
+ this.lernaJson = jsonfile.readFileSync(path.join(options.monorepoPath, 'lerna.json'))
24
+ this.graphql = graphql.defaults({
25
+ headers: {
26
+ authorization: `token ${options.gitHubToken}`
91
27
  }
92
- )
93
- }
94
-
95
- applyRepo (repoName, info) {
96
- if (Object.prototype.hasOwnProperty.call(this.repos, repoName)) {
97
- const existingInfo = this.repos[repoName]
98
- this.repos[repoName] = _.defaults(info, existingInfo)
99
- } else {
100
- this.repos[repoName] = info
101
- }
28
+ })
102
29
  }
103
30
 
104
- process () {
105
- const processed = {
106
- containingDirName: path.basename(this.options.monorepoPath),
107
- packages: {},
108
- flattenedPackages: [],
109
- totalPackageCount: 0,
110
- rejectedCount: 0,
111
- actions: [],
112
- allNpmPackageNames: [],
113
- gitHubDeps: {
114
- dependencyPackageNames: [],
115
- devDependencyPackageNames: []
116
- },
117
- existingLocalDeps: {
118
- dependencyPackageNames: [],
119
- devDependencyPackageNames: []
120
- }
121
- }
122
-
123
- function applyDeps (target, packageJson) {
124
- if (packageJson) {
125
- if (packageJson.dependencies) {
126
- target.dependencyPackageNames = _.union(
127
- target.dependencyPackageNames,
128
- _.keys(packageJson.dependencies)
129
- )
130
- }
131
- if (packageJson.devDependencies) {
132
- target.devDependencyPackageNames = _.union(
133
- target.devDependencyPackageNames,
134
- _.keys(packageJson.devDependencies)
135
- )
136
- }
137
- }
138
- }
139
-
140
- function applyNpmPackageName (packageJson) {
141
- if (packageJson) {
142
- const name = packageJson.name
143
- if (processed.allNpmPackageNames.indexOf(name) === -1) {
144
- processed.allNpmPackageNames.push(name)
145
- }
146
- }
147
- }
31
+ async sync () {
32
+ const start = Date.now()
148
33
 
149
- for (const [repoName, rawRepo] of Object.entries(this.repos)) {
150
- const pkgName = rawRepo.gitHubLernaPackageName
151
- if (pkgName) {
152
- processed.flattenedPackages.push({ repoName, pkgName })
34
+ const packagePatterns = this.lernaJson.packages
35
+ const packageFolders = packageFoldersFromPatterns(packagePatterns)
153
36
 
154
- let pkgInfo
155
- if (!Object.prototype.hasOwnProperty.call(processed.packages, pkgName)) {
156
- pkgInfo = {
157
- name: pkgName,
158
- repos: {}
159
- }
160
- processed.packages[pkgName] = pkgInfo
161
- } else {
162
- pkgInfo = processed.packages[pkgName]
163
- }
37
+ // 1. Discover (remote)
38
+ // Make one paginated GraphQL query that returns the name,
39
+ // default branch, head commit and package.json
40
+ // for every repo in the org.
164
41
 
165
- const rep = {
166
- name: repoName,
167
- gitHubSha: rawRepo.gitHubSha,
168
- gitHubMessageHeadline: rawRepo.gitHubMessageHeadline,
169
- gitHubAuthor: rawRepo.gitHubAuthor,
170
- localSha: dottie.get(rawRepo, 'localGit.sha'),
171
- localLernaPackageName: rawRepo.localLernaPackageName,
172
- gitHubLernaPackageName: rawRepo.gitHubLernaPackageName,
173
- githubPackageJsonDescription: dottie.get(rawRepo, 'gitHubPackageJson.description')
174
- }
42
+ const remoteRepos = await discover(this.graphql, this.gitHubOrgName)
175
43
 
176
- applyDeps(processed.existingLocalDeps, rawRepo.localPackageJson)
177
- applyNpmPackageName(rawRepo.localPackageJson)
178
- if (rep.gitHubSha === rep.localSha) {
179
- rep.syncAction = 'SKIP'
180
- } else if (!rep.localSha) {
181
- rep.syncAction = 'CLONE'
182
- applyDeps(processed.gitHubDeps, rawRepo.gitHubPackageJson)
183
- applyNpmPackageName(rawRepo.gitHubPackageJson)
184
- processed.actions.push(
185
- {
186
- type: 'CLONE',
187
- config: {
188
- packageName: rep.gitHubLernaPackageName,
189
- repoName
190
- }
191
- }
192
- )
193
- } else {
194
- rep.syncAction = 'PULL'
195
- applyDeps(processed.gitHubDeps, rawRepo.gitHubPackageJson)
196
- applyNpmPackageName(rawRepo.gitHubPackageJson)
197
- processed.actions.push(
198
- {
199
- type: 'PULL',
200
- config: {
201
- packageName: rep.localLernaPackageName,
202
- repoName
203
- }
204
- }
205
- )
206
- }
207
- pkgInfo.repos[repoName] = rep
208
- processed.totalPackageCount++
209
- } else {
210
- processed.rejectedCount++
211
- }
212
- }
44
+ // 2. Inspect (local)
45
+ // For each folder that already exists, read its
46
+ // current branch, whether it has uncommitted changes,
47
+ // its HEAD SHA and its folder name.
213
48
 
214
- processed.newDependencies = _.difference(
215
- _.union(processed.gitHubDeps.dependencyPackageNames, processed.gitHubDeps.devDependencyPackageNames),
216
- _.union(processed.existingLocalDeps.dependencyPackageNames, processed.existingLocalDeps.devDependencyPackageNames)
49
+ const localRepos = await inspect.inspectLocalRepos(
50
+ this.monorepoPath,
51
+ this.lernaJson.packages,
52
+ { concurrency: this.concurrency }
217
53
  )
218
- processed.newDependenciesWithoutLinkables = _.difference(
219
- processed.newDependencies,
220
- processed.allNpmPackageNames
221
- )
222
- return processed
223
- }
224
54
 
225
- outputSummary (info) {
226
- console.log(chalk.underline('Lerna Sync summary'))
227
- console.log('')
228
- console.log(`/${info.containingDirName}`)
229
-
230
- for (const [packageName, packageInfo] of Object.entries(info.packages)) {
231
- console.log('')
232
- console.log(` /${packageName}`)
233
- let sameCount = 0
234
- for (const [repoName, repoInfo] of Object.entries(packageInfo.repos)) {
235
- // Running an action might have revised its type, so find it in actions array
236
- let syncAction
237
- info.actions.forEach(
238
- action => {
239
- if (action.config.repoName === repoName) {
240
- syncAction = action.type
241
- }
242
- }
243
- )
244
-
245
- if (syncAction && syncAction !== 'SKIP') {
246
- let repoLine = ` /${chalk.white(chalk.bold(repoName))} `
247
-
248
- if (syncAction === 'CLONE') {
249
- repoLine += chalk.green(chalk.inverse('Clone'))
250
- } else {
251
- repoLine += chalk.cyan(chalk.inverse('Pull'))
252
- }
253
- console.log(repoLine)
254
-
255
- if (syncAction === 'CLONE') {
256
- console.log(` ${chalk.green(repoInfo.githubPackageJsonDescription)}`)
257
- }
258
-
259
- let agoLabel
260
- const rawAuthorDate = dottie.get(repoInfo, 'gitHubAuthor.date')
261
- if (rawAuthorDate) {
262
- const authorDate = new Date(rawAuthorDate)
263
- agoLabel = humanizeDuration(
264
- new Date() - authorDate,
265
- {
266
- largest: 1
267
- }
268
- )
269
- agoLabel = `{${agoLabel} ago)`
270
- }
271
- console.log(` ${chalk.yellow(repoInfo.gitHubAuthor.name + ':')} ${chalk.gray(agoLabel)}`)
272
- console.log(chalk.magenta(` ${repoInfo.gitHubMessageHeadline}`))
273
- } else {
274
- sameCount++
275
- }
276
- }
277
- let sameLine = chalk.gray(' OK count: ')
278
- sameLine += chalk.green(sameCount)
279
- console.log(sameLine)
55
+ // 3. Plan (pure function)
56
+ // Take `(remoteRepos, localRepos, router)` and
57
+ // return a list of decisions, each with a reason.
58
+ // This stage does no I/O.
59
+
60
+ const plan = planSync.planSync({
61
+ remoteRepos,
62
+ localRepos,
63
+ router: this.lernaPackageRouterFunction,
64
+ orgName: this.gitHubOrgName,
65
+ packageFolders
66
+ })
67
+
68
+ // 4. Execute and report
69
+ // Run the decisions, then print a summary
70
+ // grouped by outcome.
71
+
72
+ if (this.dryRun) {
73
+ printPlan(plan, { verbose: this.verbose })
74
+ return { plan, results: [], errorCount: 0, attentionCount: 0 }
280
75
  }
281
- }
282
76
 
283
- outputSuggestedNextSteps (newDependencies, newDependenciesWithoutLinkables) {
284
- console.log('')
285
- if (newDependenciesWithoutLinkables.length === 0) {
286
- if (newDependencies.length === 0) {
287
- console.log(chalk.green('Good times, no Lerna bootstrapping/linking required!'))
288
- } else {
289
- console.log(chalk.yellow('You\'ll need to \'lerna link\' because synced-packages need links to:'))
290
- newDependencies.forEach(
291
- (packageName) => {
292
- console.log(chalk.yellow(` - ${packageName}`))
293
- }
294
- )
77
+ const results = await executePlan(plan, {
78
+ monorepoPath: this.monorepoPath,
79
+ gitHubOrgName: this.gitHubOrgName,
80
+ concurrency: this.concurrency,
81
+ cloneUrl: this.cloneUrl,
82
+ updateLocalDefaultBranch: this.updateLocalDefaultBranch,
83
+ onResult: r => {
84
+ const { label, colour, detail } = describeResult(r)
85
+ const name = `${r.decision.folder}/${r.decision.repoName}`
86
+ console.log(` ${colour(label.padEnd(11))} ${name}${detail ? chalk.gray(` (${detail})`) : ''}`)
295
87
  }
296
- } else {
297
- console.log(chalk.red('Oh no! You need to \'lerna bootstrap\', because these are unheard of:'))
298
- newDependenciesWithoutLinkables.forEach(
299
- (packageName) => {
300
- console.log(chalk.red(` - ${packageName}`))
301
- }
302
- )
303
- }
304
- }
88
+ })
305
89
 
306
- async performActions (actions, messages) {
307
- console.log('')
308
- if (actions.length > 0) {
309
- console.log(chalk.underline(`Identified ${actions.length} action${actions.length === 1 ? '' : 's'} to run`))
310
- console.log('')
311
- // let idx = 0
312
- // const totalActionCount = actions.length
313
- for (const action of actions) {
314
- await actionFunctions[action.type](action, this.options, messages)
315
- }
316
- }
317
- console.log('')
318
- }
90
+ const { errorCount, attentionCount } = report(plan, results, { verbose: this.verbose })
91
+ console.log(`\nDone (${humanizeDuration(Date.now() - start, { largest: 2, round: true })}).`)
319
92
 
320
- async sync () {
321
- const messages = new Messages()
322
- this.syncStart = new Date()
323
- this.repos = {}
324
- await this.discoverGithubRepos()
325
- this.collectLocalGitInfo()
326
- this.processed = this.process()
327
- if (this.processed.actions.length > 0) {
328
- await this.performActions(
329
- this.processed.actions,
330
- messages
331
- )
332
- }
333
- this.outputSummary(this.processed)
334
- messages.output()
335
-
336
- const localNonMasterBranches = this.processed.flattenedPackages
337
- .map(({ repoName, pkgName }) => {
338
- const destPath = path.join(this.options.monorepoPath, pkgName, repoName)
339
- const gitHeadPath = path.join(destPath, '.git', 'HEAD')
340
-
341
- if (fs.existsSync(destPath)) {
342
- if (fs.existsSync(gitHeadPath)) {
343
- const currentBranch = fs.readFileSync(gitHeadPath, 'utf-8').trim().split('refs/heads/')[1]
344
-
345
- if (currentBranch !== 'master') {
346
- return { repoName, pkgName, currentBranch }
347
- }
348
- }
349
- }
350
-
351
- return null
352
- })
353
- .filter(p => p)
354
-
355
- if (localNonMasterBranches.length > 0) {
356
- console.log(chalk.bgYellow.gray('\n\n ----- Found some non-master branches, best double check that you should be on these! ----- '))
357
- localNonMasterBranches.forEach(branch => {
358
- console.log(chalk.yellow.underline(`${branch.repoName}:`))
359
- console.log(chalk.yellow(`Branch name: ${branch.currentBranch}\n`))
360
- })
361
- }
93
+ return { plan, results, errorCount, attentionCount }
94
+ }
95
+ }
362
96
 
363
- this.outputSuggestedNextSteps(
364
- this.processed.newDependencies,
365
- this.processed.newDependenciesWithoutLinkables
366
- )
367
- this.syncEnd = new Date()
368
- console.log('')
369
- console.log(`Done (${humanizeDuration(this.syncEnd - this.syncStart)}).`)
97
+ // undefined/null means "use each stage's default"; anything else must be a positive integer
98
+ function validateConcurrency (value) {
99
+ if (value == null) return undefined
100
+ if (!Number.isInteger(value) || value < 1) {
101
+ throw new TypeError(`concurrency must be a positive integer, got ${JSON.stringify(value)}`)
370
102
  }
103
+ return value
371
104
  }
372
105
 
373
106
  module.exports = LernaSync
package/lib/inspect.js ADDED
@@ -0,0 +1,99 @@
1
+ const fs = require('fs')
2
+ const path = require('path')
3
+ const { globSync } = require('glob')
4
+ const jsonfile = require('jsonfile')
5
+ const { tryGit } = require('./utils/git')
6
+ const mapLimit = require('./utils/map-limit')
7
+
8
+ async function inspectLocalRepos (monorepoPath, lernaPackagePatterns, { concurrency = 8 } = {}) {
9
+ const dirs = new Set()
10
+
11
+ for (const pattern of lernaPackagePatterns) {
12
+ // A trailing slash makes glob match directories only
13
+ const matches = globSync(`${pattern.replace(/\/+$/, '')}/`, { cwd: monorepoPath, absolute: true })
14
+ matches.forEach(m => dirs.add(path.resolve(m)))
15
+ }
16
+
17
+ return mapLimit([...dirs].sort(), concurrency, dir => inspectRepo(monorepoPath, dir))
18
+ }
19
+
20
+ async function inspectRepo (monorepoPath, repoPath) {
21
+ const record = {
22
+ path: repoPath,
23
+ repoName: path.basename(repoPath),
24
+ folder: path.relative(monorepoPath, path.dirname(repoPath)).split(path.sep).join('/'),
25
+ isGitRepo: false
26
+ }
27
+
28
+ // Each package folder sits inside the husk's own repo, so in a folder that
29
+ // isn't a repo of its own, git would happily answer for the husk instead.
30
+ // Only trust the result if this folder is the top level.
31
+ const toplevel = await tryGit(['rev-parse', '--show-toplevel'], repoPath)
32
+ if (!toplevel || !samePath(toplevel, repoPath)) {
33
+ return record
34
+ }
35
+
36
+ const [
37
+ branch,
38
+ headSha,
39
+ status,
40
+ originUrl,
41
+ gitPaths
42
+ ] = await Promise.all([
43
+ tryGit(['symbolic-ref', '--quiet', '--short', 'HEAD'], repoPath),
44
+ tryGit(['rev-parse', '--verify', '--quiet', 'HEAD'], repoPath),
45
+ tryGit(['status', '--porcelain'], repoPath),
46
+ tryGit(['remote', 'get-url', 'origin'], repoPath),
47
+ tryGit(['rev-parse', '--git-path', 'MERGE_HEAD', '--git-path', 'rebase-merge', '--git-path', 'rebase-apply'], repoPath)
48
+ ])
49
+
50
+ const statusLines = status === null ? null : status.split('\n').filter(Boolean)
51
+
52
+ const operationInProgress = (gitPaths || '')
53
+ .split('\n')
54
+ .filter(Boolean)
55
+ .some(p => fs.existsSync(path.resolve(repoPath, p)))
56
+
57
+ return {
58
+ ...record,
59
+ isGitRepo: true,
60
+ branch, // null when HEAD is detached
61
+ isDetached: branch === null,
62
+ headSha, // null in a repo with no commits yet
63
+ statusKnown: statusLines !== null,
64
+ trackedChanges: statusLines ? statusLines.filter(l => !l.startsWith('??')).length : 0,
65
+ untrackedFiles: statusLines ? statusLines.filter(l => l.startsWith('??')).length : 0,
66
+ operationInProgress, // mid-merge or mid-rebase
67
+ origin: parseGitHubRemote(originUrl),
68
+ packageJson: readJsonOrNull(path.join(repoPath, 'package.json'))
69
+ }
70
+ }
71
+
72
+ function samePath (a, b) {
73
+ const norm = p => {
74
+ const real = path.resolve(fs.realpathSync.native(p))
75
+ return process.platform === 'win32' ? real.toLowerCase() : real
76
+ }
77
+ try {
78
+ return norm(a) === norm(b)
79
+ } catch (e) {
80
+ return false
81
+ }
82
+ }
83
+
84
+ function parseGitHubRemote (url) {
85
+ if (!url) return null
86
+ // https://github.com/wmfs/repo(.git) or git@github.com:wmfs/repo(.git)
87
+ const m = url.match(/github\.com[/:]([^/]+)\/(.+?)(?:\.git)?\/?$/i)
88
+ return m ? { owner: m[1], name: m[2], url } : { owner: null, name: null, url }
89
+ }
90
+
91
+ function readJsonOrNull (file) {
92
+ try {
93
+ return jsonfile.readFileSync(file)
94
+ } catch (e) {
95
+ return null
96
+ }
97
+ }
98
+
99
+ module.exports = { inspectLocalRepos, inspectRepo }