@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.
@@ -0,0 +1,247 @@
1
+ /* eslint-env mocha */
2
+
3
+ const { expect } = require('chai')
4
+ const { router, remote, local } = require('./helpers/fixtures')
5
+ const { planSync, ACTIONS, REASONS } = require('../lib/plan-sync')
6
+
7
+ const plan = (remoteRepos, localRepos, overrides = {}) => planSync({
8
+ remoteRepos,
9
+ localRepos,
10
+ router,
11
+ orgName: 'wmfs',
12
+ packageFolders: ['plugins', 'blueprints'],
13
+ ...overrides
14
+ })
15
+
16
+ const only = result => {
17
+ expect(result.decisions).to.have.length(1)
18
+ return result.decisions[0]
19
+ }
20
+
21
+ describe('planSync', () => {
22
+ describe('routing remote repos', () => {
23
+ it('ignores repos without a package.json', () => {
24
+ const { decisions, ignored } = plan([remote('a', { packageJson: null })], [])
25
+ expect(decisions).to.have.length(0)
26
+ expect(ignored).to.deep.equal([{ repoName: 'a', reason: 'No package.json' }])
27
+ })
28
+
29
+ it('ignores repos the router does not select', () => {
30
+ const { decisions, ignored } = plan([remote('a', { packageJson: { keywords: ['other'] } })], [])
31
+ expect(decisions).to.have.length(0)
32
+ expect(ignored[0].reason).to.equal('Not selected by router')
33
+ })
34
+
35
+ it('uses the skip reason a router returns', () => {
36
+ const optedOut = remote('a', { packageJson: { keywords: ['tymly', 'plugin'], config: { tymly: { sync: false } } } })
37
+ const { ignored } = plan([optedOut], [])
38
+ expect(ignored[0].reason).to.equal('Opted out (config.tymly.sync = false)')
39
+ })
40
+
41
+ it('ignores a repo when the router throws, instead of failing the sync', () => {
42
+ const throwing = () => { throw new Error('boom') }
43
+ const { decisions, ignored } = plan([remote('a'), remote('b')], [], { router: throwing })
44
+ expect(decisions).to.have.length(0)
45
+ expect(ignored.map(i => i.reason)).to.deep.equal(['Router threw: boom', 'Router threw: boom'])
46
+ })
47
+
48
+ it('rejects a folder that is not in lerna.json', () => {
49
+ const { decisions, ignored } = plan([remote('a')], [], { router: () => 'mods' })
50
+ expect(decisions).to.have.length(0)
51
+ expect(ignored[0].reason).to.include('"mods", which is not in lerna.json')
52
+ })
53
+
54
+ it('rejects router results that are not strings', () => {
55
+ const { ignored } = plan([remote('a')], [], { router: () => 42 })
56
+ expect(ignored[0].reason).to.equal('Router returned 42')
57
+ })
58
+
59
+ it('calls the router exactly once per remote repo', () => {
60
+ let calls = 0
61
+ const counting = pkg => { calls++; return router(pkg) }
62
+ plan([remote('a'), remote('b')], [local('a')], { router: counting })
63
+ expect(calls).to.equal(2)
64
+ })
65
+ })
66
+
67
+ describe('repos missing locally', () => {
68
+ it('clones a qualifying repo into its routed folder', () => {
69
+ const d = only(plan([remote('bp', { packageJson: { keywords: ['tymly', 'blueprint'] } })], []))
70
+ expect(d).to.include({ repoName: 'bp', action: ACTIONS.CLONE, reason: REASONS.NEW, folder: 'blueprints', defaultBranch: 'master' })
71
+ expect(d.local).to.equal(null)
72
+ })
73
+
74
+ it('carries a non-master default branch through', () => {
75
+ const d = only(plan([remote('a', { defaultBranch: 'main' })], []))
76
+ expect(d.defaultBranch).to.equal('main')
77
+ })
78
+ })
79
+
80
+ describe('repos on the default branch', () => {
81
+ it('does nothing when HEAD matches GitHub', () => {
82
+ const d = only(plan([remote('a', { sha: 'same' })], [local('a', { headSha: 'same' })]))
83
+ expect(d).to.include({ action: ACTIONS.NONE, reason: REASONS.UP_TO_DATE })
84
+ })
85
+
86
+ it('fast-forwards when clean and HEAD differs', () => {
87
+ const d = only(plan([remote('a')], [local('a')]))
88
+ expect(d).to.include({ action: ACTIONS.FAST_FORWARD, reason: REASONS.BEHIND })
89
+ })
90
+
91
+ it('only fetches when there are uncommitted changes', () => {
92
+ const d = only(plan([remote('a')], [local('a', { trackedChanges: 2 })]))
93
+ expect(d).to.include({ action: ACTIONS.FETCH_ONLY, reason: REASONS.LOCAL_CHANGES })
94
+ })
95
+
96
+ it('reports up to date, not local changes, when dirty but already current', () => {
97
+ const d = only(plan([remote('a', { sha: 'same' })], [local('a', { headSha: 'same', trackedChanges: 1 })]))
98
+ expect(d.reason).to.equal(REASONS.UP_TO_DATE)
99
+ })
100
+
101
+ it('does not treat untracked files alone as local changes', () => {
102
+ const d = only(plan([remote('a')], [local('a', { untrackedFiles: 3 })]))
103
+ expect(d.action).to.equal(ACTIONS.FAST_FORWARD)
104
+ })
105
+
106
+ it('treats "main" as the default branch when GitHub says so', () => {
107
+ const d = only(plan([remote('a', { defaultBranch: 'main' })], [local('a', { branch: 'main' })]))
108
+ expect(d.action).to.equal(ACTIONS.FAST_FORWARD)
109
+ })
110
+ })
111
+
112
+ describe('repos the developer is working in', () => {
113
+ it('only fetches when on a feature branch', () => {
114
+ const d = only(plan([remote('a')], [local('a', { branch: 'feature/x' })]))
115
+ expect(d).to.include({ action: ACTIONS.FETCH_ONLY, reason: REASONS.ON_BRANCH })
116
+ })
117
+
118
+ it('only fetches on a feature branch even if HEAD matches GitHub', () => {
119
+ const d = only(plan([remote('a', { sha: 'same' })], [local('a', { branch: 'feature/x', headSha: 'same' })]))
120
+ expect(d.reason).to.equal(REASONS.ON_BRANCH)
121
+ })
122
+
123
+ it('treats local "master" as a branch when the default is "main"', () => {
124
+ const d = only(plan([remote('a', { defaultBranch: 'main' })], [local('a', { branch: 'master' })]))
125
+ expect(d.reason).to.equal(REASONS.ON_BRANCH)
126
+ })
127
+
128
+ it('only fetches on a detached HEAD', () => {
129
+ const d = only(plan([remote('a')], [local('a', { branch: null, isDetached: true })]))
130
+ expect(d).to.include({ action: ACTIONS.FETCH_ONLY, reason: REASONS.DETACHED })
131
+ })
132
+ })
133
+
134
+ describe('unsafe local state', () => {
135
+ for (const [label, overrides] of [
136
+ ['mid-merge or mid-rebase', { operationInProgress: true }],
137
+ ['git status could not be read', { statusKnown: false }],
138
+ ['no commits yet', { headSha: null }]
139
+ ]) {
140
+ it(`does nothing when ${label}`, () => {
141
+ const d = only(plan([remote('a')], [local('a', overrides)]))
142
+ expect(d).to.include({ action: ACTIONS.NONE, reason: REASONS.UNSAFE_STATE })
143
+ })
144
+ }
145
+
146
+ it('checks unsafe state before branch state', () => {
147
+ const d = only(plan([remote('a')], [local('a', { branch: 'feature/x', operationInProgress: true })]))
148
+ expect(d.reason).to.equal(REASONS.UNSAFE_STATE)
149
+ })
150
+ })
151
+
152
+ describe('folders that are not plain clones of an org repo', () => {
153
+ it('does not clone over a plain folder where a qualifying repo would go', () => {
154
+ const d = only(plan([remote('a')], [local('a', { isGitRepo: false, origin: undefined })]))
155
+ expect(d).to.include({ action: ACTIONS.NONE, reason: REASONS.NOT_A_REPO })
156
+ expect(d.warnings).to.have.length(1)
157
+ })
158
+
159
+ it('notes an unrelated plain folder without a warning', () => {
160
+ const d = only(plan([], [local('scratch', { isGitRepo: false, origin: undefined })]))
161
+ expect(d.reason).to.equal(REASONS.NOT_A_REPO)
162
+ expect(d.warnings).to.deep.equal([])
163
+ })
164
+
165
+ it('leaves alone a repo whose origin is another owner', () => {
166
+ const fork = local('a', { origin: { owner: 'someone', name: 'a', url: 'https://github.com/someone/a.git' } })
167
+ const d = only(plan([remote('a')], [fork]))
168
+ expect(d).to.include({ action: ACTIONS.NONE, reason: REASONS.FOREIGN_ORIGIN })
169
+ expect(d.warnings[0]).to.include('github.com/someone/a.git')
170
+ })
171
+
172
+ it('compares the origin owner case-insensitively', () => {
173
+ const d = only(plan([remote('a')], [local('a', { origin: { owner: 'WMFS', name: 'a', url: 'x' } })]))
174
+ expect(d.reason).to.not.equal(REASONS.FOREIGN_ORIGIN)
175
+ })
176
+
177
+ it('reports local repos that are gone from the org', () => {
178
+ const d = only(plan([], [local('gone')]))
179
+ expect(d).to.include({ action: ACTIONS.NONE, reason: REASONS.ORPHANED })
180
+ })
181
+
182
+ it('reports local repos that no longer qualify, with the reason', () => {
183
+ const optedOut = remote('a', { packageJson: { keywords: ['tymly', 'plugin'], config: { tymly: { sync: false } } } })
184
+ const d = only(plan([optedOut], [local('a')]))
185
+ expect(d).to.include({ action: ACTIONS.NONE, reason: REASONS.NO_LONGER_QUALIFIES })
186
+ expect(d.warnings).to.deep.equal(['Opted out (config.tymly.sync = false)'])
187
+ })
188
+
189
+ it('still updates a misplaced repo, with a warning', () => {
190
+ const bp = remote('a', { packageJson: { keywords: ['tymly', 'blueprint'] } })
191
+ const d = only(plan([bp], [local('a', { folder: 'plugins' })]))
192
+ expect(d.action).to.equal(ACTIONS.FAST_FORWARD)
193
+ expect(d.warnings).to.deep.equal(['Lives in /plugins but is now routed to /blueprints'])
194
+ })
195
+
196
+ it('reports a second clone of the same repo as a duplicate', () => {
197
+ const result = plan([remote('a')], [
198
+ local('a'),
199
+ local('a-copy', { folder: 'blueprints', origin: { owner: 'wmfs', name: 'a', url: 'x' } })
200
+ ])
201
+ const duplicate = result.decisions.find(d => d.reason === REASONS.DUPLICATE)
202
+ expect(duplicate).to.include({ repoName: 'a-copy', action: ACTIONS.NONE })
203
+ expect(duplicate.warnings[0]).to.equal('Also found at plugins/a')
204
+ expect(result.decisions.filter(d => d.action !== ACTIONS.NONE)).to.have.length(1)
205
+ })
206
+ })
207
+
208
+ describe('matching local folders to remote repos', () => {
209
+ it('matches on the origin name, not the folder name', () => {
210
+ const renamed = local('my-local-name', { origin: { owner: 'wmfs', name: 'a', url: 'x' } })
211
+ const result = plan([remote('a')], [renamed])
212
+ const d = only(result)
213
+ expect(d.action).to.equal(ACTIONS.FAST_FORWARD)
214
+ expect(d.remote.name).to.equal('a')
215
+ })
216
+
217
+ it('falls back to the folder name when there is no origin', () => {
218
+ const d = only(plan([remote('a')], [local('a', { origin: null })]))
219
+ expect(d.action).to.equal(ACTIONS.FAST_FORWARD)
220
+ })
221
+
222
+ it('matches names case-insensitively', () => {
223
+ const d = only(plan([remote('Tymly-Core')], [local('tymly-core')]))
224
+ expect(d.action).to.equal(ACTIONS.FAST_FORWARD)
225
+ })
226
+
227
+ it('never plans a clone for a repo that already exists locally', () => {
228
+ const result = plan([remote('a')], [local('a', { branch: 'feature/x' })])
229
+ expect(result.decisions.filter(d => d.action === ACTIONS.CLONE)).to.have.length(0)
230
+ })
231
+ })
232
+
233
+ it('sorts decisions by folder, then repo name', () => {
234
+ const bp = name => remote(name, { packageJson: { keywords: ['tymly', 'blueprint'] } })
235
+ const { decisions } = plan([remote('z'), bp('c'), remote('a'), bp('b')], [])
236
+ expect(decisions.map(d => `${d.folder}/${d.repoName}`))
237
+ .to.deep.equal(['blueprints/b', 'blueprints/c', 'plugins/a', 'plugins/z'])
238
+ })
239
+
240
+ it('does not mutate its inputs', () => {
241
+ const remotes = [remote('a'), remote('b')]
242
+ const locals = [local('a'), local('c')]
243
+ const before = JSON.stringify({ remotes, locals })
244
+ plan(remotes, locals)
245
+ expect(JSON.stringify({ remotes, locals })).to.equal(before)
246
+ })
247
+ })
@@ -0,0 +1,178 @@
1
+ /* eslint-env mocha */
2
+
3
+ const { expect } = require('chai')
4
+ const { router, remote, local, captureConsole } = require('./helpers/fixtures')
5
+ const { planSync } = require('../lib/plan-sync')
6
+ const { OUTCOMES } = require('../lib/execute-plan')
7
+ const { report, printPlan, describeResult } = require('../lib/report')
8
+
9
+ const makePlan = (remoteRepos, localRepos) => planSync({
10
+ remoteRepos,
11
+ localRepos,
12
+ router,
13
+ orgName: 'wmfs',
14
+ packageFolders: ['plugins', 'blueprints']
15
+ })
16
+
17
+ const find = (plan, repoName) => plan.decisions.find(d => d.repoName === repoName)
18
+
19
+ describe('describeResult', () => {
20
+ const d = overrides => ({ repoName: 'a', folder: 'plugins', defaultBranch: 'master', local: { branch: 'master' }, ...overrides })
21
+
22
+ it('names the branch when on a feature branch with nothing new', () => {
23
+ const r = describeResult({ decision: d({ reason: 'ON_BRANCH', local: { branch: 'feature/x' } }), outcome: OUTCOMES.CURRENT })
24
+ expect(r.label).to.equal('On branch')
25
+ expect(r.detail).to.equal('feature/x, nothing new on master')
26
+ })
27
+
28
+ it('counts new commits on the default branch when on a feature branch', () => {
29
+ const r = describeResult({ decision: d({ reason: 'ON_BRANCH', local: { branch: 'feature/x' } }), outcome: OUTCOMES.BEHIND, behind: 3 })
30
+ expect(r.detail).to.equal('feature/x, master has 3 new commits')
31
+ })
32
+
33
+ it('uses the singular for one commit', () => {
34
+ const r = describeResult({ decision: d({ reason: 'BEHIND' }), outcome: OUTCOMES.UPDATED, behind: 1 })
35
+ expect(r).to.include({ label: 'Updated', detail: 'pulled 1 commit' })
36
+ })
37
+
38
+ it('uses the repo\'s default branch name', () => {
39
+ const r = describeResult({ decision: d({ reason: 'DETACHED', defaultBranch: 'main', local: { branch: null } }), outcome: OUTCOMES.BEHIND, behind: 2 })
40
+ expect(r.detail).to.equal('main has 2 new commits')
41
+ })
42
+
43
+ it('describes each outcome without falling back to the raw enum', () => {
44
+ const cases = [
45
+ [OUTCOMES.CLONED, 'NEW', 'Cloned'],
46
+ [OUTCOMES.CURRENT, 'BEHIND', 'Up to date'],
47
+ [OUTCOMES.CURRENT, 'LOCAL_CHANGES', 'Up to date'],
48
+ [OUTCOMES.CURRENT, 'DETACHED', 'Detached'],
49
+ [OUTCOMES.BEHIND, 'LOCAL_CHANGES', 'Not pulled'],
50
+ [OUTCOMES.LOCAL_AHEAD, 'BEHIND', 'Unpushed'],
51
+ [OUTCOMES.DIVERGED, 'BEHIND', 'Diverged'],
52
+ [OUTCOMES.BLOCKED_BY_UNTRACKED, 'BEHIND', 'Not pulled'],
53
+ [OUTCOMES.FAILED, 'BEHIND', 'Failed']
54
+ ]
55
+ for (const [outcome, reason, label] of cases) {
56
+ const r = describeResult({ decision: d({ reason }), outcome, ahead: 1, behind: 1, error: 'x' })
57
+ expect(r.label, `${outcome}/${reason}`).to.equal(label)
58
+ }
59
+ })
60
+
61
+ it('shows only the first line of an error', () => {
62
+ const r = describeResult({ decision: d({ reason: 'NEW' }), outcome: OUTCOMES.FAILED, error: 'fatal: nope\nmore detail' })
63
+ expect(r.detail).to.equal('fatal: nope')
64
+ })
65
+ })
66
+
67
+ describe('report', () => {
68
+ let output
69
+ beforeEach(() => { output = captureConsole() })
70
+ afterEach(() => output.restore())
71
+
72
+ it('counts errors and items needing attention', () => {
73
+ const plan = makePlan(
74
+ [remote('ok', { sha: 'same' }), remote('new'), remote('branch'), remote('broken')],
75
+ [local('ok', { headSha: 'same' }), local('branch', { branch: 'feature/x' }), local('broken'), local('mid', { operationInProgress: true })]
76
+ )
77
+ const results = [
78
+ { decision: find(plan, 'new'), outcome: OUTCOMES.CLONED },
79
+ { decision: find(plan, 'branch'), outcome: OUTCOMES.BEHIND, behind: 2, commits: [] },
80
+ { decision: find(plan, 'broken'), outcome: OUTCOMES.FAILED, error: 'fatal: boom' }
81
+ ]
82
+
83
+ const counts = report(plan, results)
84
+
85
+ // "branch" is behind (attention); "mid" is ORPHANED (not in remotes), so housekeeping
86
+ expect(counts).to.deep.equal({ errorCount: 1, attentionCount: 1 })
87
+ expect(output.text()).to.include('Errors (1)')
88
+ expect(output.text()).to.include('plugins/broken fatal: boom')
89
+ })
90
+
91
+ it('lists repos left alone on a branch, rather than counting them as up to date', () => {
92
+ const plan = makePlan([remote('a')], [local('a', { branch: 'feature/x' })])
93
+ report(plan, [{ decision: plan.decisions[0], outcome: OUTCOMES.CURRENT, ahead: 0, behind: 0, commits: [] }])
94
+
95
+ expect(output.text()).to.include('On branches (left alone) (1)')
96
+ expect(output.text()).to.include('plugins/a on feature/x, nothing new on master')
97
+ expect(output.text()).to.include('0 up to date')
98
+ })
99
+
100
+ it('shows incoming commits for a branch that is behind', () => {
101
+ const plan = makePlan([remote('a')], [local('a', { branch: 'feature/x' })])
102
+ report(plan, [{
103
+ decision: plan.decisions[0],
104
+ outcome: OUTCOMES.BEHIND,
105
+ behind: 1,
106
+ commits: [{ sha: 'abc123', subject: 'Fix the thing', author: 'Ann', when: '2 hours ago' }]
107
+ }])
108
+
109
+ expect(output.text()).to.include('Needs your attention (1)')
110
+ expect(output.text()).to.include('abc123 Fix the thing (Ann, 2 hours ago)')
111
+ })
112
+
113
+ it('prints warnings under the repo they belong to', () => {
114
+ const bp = remote('a', { packageJson: { keywords: ['tymly', 'blueprint'] } })
115
+ const plan = makePlan([bp], [local('a', { folder: 'plugins' })])
116
+ report(plan, [{ decision: plan.decisions[0], outcome: OUTCOMES.UPDATED, behind: 1, commits: [] }])
117
+
118
+ expect(output.text()).to.include('! Lives in /plugins but is now routed to /blueprints')
119
+ })
120
+
121
+ it('reports NONE decisions without needing a result', () => {
122
+ const plan = makePlan([], [local('gone'), local('scratch', { isGitRepo: false, origin: undefined })])
123
+ const counts = report(plan, [])
124
+
125
+ expect(counts).to.deep.equal({ errorCount: 0, attentionCount: 0 })
126
+ expect(output.text()).to.include('Housekeeping (2)')
127
+ expect(output.text()).to.include('No longer in the org')
128
+ })
129
+
130
+ it('suggests reinstalling only when dependencies may have changed', () => {
131
+ const plan = makePlan([remote('a'), remote('b')], [local('a'), local('b')])
132
+ report(plan, plan.decisions.map(d => ({ decision: d, outcome: OUTCOMES.UPDATED, behind: 1, commits: [], packageJsonChanged: false })))
133
+ expect(output.text()).to.not.include('Dependencies may have changed')
134
+
135
+ output.lines.length = 0
136
+ report(plan, [{ decision: plan.decisions[0], outcome: OUTCOMES.UPDATED, behind: 1, commits: [], packageJsonChanged: true }])
137
+ expect(output.text()).to.include('Dependencies may have changed')
138
+ })
139
+
140
+ it('lists ignored repos only when verbose', () => {
141
+ const plan = makePlan([remote('other', { packageJson: { keywords: [] } })], [])
142
+ report(plan, [])
143
+ expect(output.text()).to.not.include('Ignored')
144
+
145
+ report(plan, [], { verbose: true })
146
+ expect(output.text()).to.include('Ignored 1 repos')
147
+ expect(output.text()).to.include('other: Not selected by router')
148
+ })
149
+ })
150
+
151
+ describe('printPlan', () => {
152
+ let output
153
+ beforeEach(() => { output = captureConsole() })
154
+ afterEach(() => output.restore())
155
+
156
+ it('describes what would happen, grouped by action', () => {
157
+ const plan = makePlan(
158
+ [remote('new'), remote('behind'), remote('branch'), remote('ok', { sha: 'same' })],
159
+ [local('behind'), local('branch', { branch: 'feature/x' }), local('ok', { headSha: 'same' })]
160
+ )
161
+ printPlan(plan)
162
+ const text = output.text()
163
+
164
+ expect(text).to.include('Dry run: nothing has been changed')
165
+ expect(text).to.include('Would clone (1)')
166
+ expect(text).to.include('plugins/new new description')
167
+ expect(text).to.include('Would fast-forward (1)')
168
+ expect(text).to.include('Would fetch only (your branch is left alone) (1)')
169
+ expect(text).to.include('plugins/branch on feature/x')
170
+ expect(text).to.include('1 up to date, 1 to clone, 1 to fast-forward, 1 to fetch')
171
+ })
172
+
173
+ it('includes attention items from the plan itself', () => {
174
+ printPlan(makePlan([remote('a')], [local('a', { operationInProgress: true })]))
175
+ expect(output.text()).to.include('Needs your attention (1)')
176
+ expect(output.text()).to.include('Mid-merge/rebase')
177
+ })
178
+ })
package/test/sync-spec.js CHANGED
@@ -1,12 +1,134 @@
1
1
  /* eslint-env mocha */
2
2
 
3
- const process = require('process')
4
- describe('Test sync', function () {
5
- this.timeout(process.env.TIMEOUT || 5000)
6
- it('oh dear...', () => {
7
- // TODO: Sort this.
8
- console.log('Not sure what to do')
9
- console.log('requiring @octokit/graphql inside Mocha causes the following on Windows (at least)')
10
- console.log('--> spawnSync cmd.exe ENOENT')
3
+ const fs = require('fs')
4
+ const os = require('os')
5
+ const path = require('path')
6
+ const { expect } = require('chai')
7
+ const { Sandbox, git } = require('./helpers/git-sandbox')
8
+ const { router, captureConsole } = require('./helpers/fixtures')
9
+ const LernaSync = require('../lib')
10
+ const { ACTIONS, REASONS } = require('../lib/plan-sync')
11
+ const { OUTCOMES } = require('../lib/execute-plan')
12
+
13
+ // Answers the org query from the sandbox's bare repos, as GitHub would
14
+ function fakeGitHub (origins) {
15
+ return async () => ({
16
+ organization: {
17
+ repositories: {
18
+ pageInfo: { hasNextPage: false, endCursor: null },
19
+ nodes: origins.map(o => ({
20
+ name: o.name,
21
+ isArchived: false,
22
+ isPrivate: false,
23
+ packageJson: { text: git(o.bare, 'show', 'master:package.json'), isTruncated: false },
24
+ defaultBranchRef: {
25
+ name: 'master',
26
+ target: {
27
+ oid: git(o.bare, 'rev-parse', 'master'),
28
+ messageHeadline: git(o.bare, 'log', '-1', '--format=%s', 'master'),
29
+ author: { name: 'Test Author', date: new Date().toISOString() }
30
+ }
31
+ }
32
+ }))
33
+ }
34
+ }
11
35
  })
36
+ }
37
+
38
+ describe('LernaSync.sync (end to end)', function () {
39
+ this.timeout(30000)
40
+
41
+ let sandbox, mono, output, existing, fresh, lernaSync
42
+
43
+ beforeEach(() => {
44
+ sandbox = new Sandbox()
45
+ mono = sandbox.dir('mono')
46
+ fs.writeFileSync(path.join(mono, 'lerna.json'), JSON.stringify({ packages: ['plugins/*', 'blueprints/*'] }))
47
+ sandbox.dir('mono', 'plugins')
48
+ sandbox.dir('mono', 'blueprints')
49
+
50
+ existing = sandbox.createOrigin('tymly-existing-plugin')
51
+ fresh = sandbox.createOrigin('tymly-fresh-plugin')
52
+
53
+ // The developer already has one repo, on a feature branch.
54
+ // Its origin is the local bare repo, so fetches work offline.
55
+ const repo = sandbox.cloneInto(existing, 'mono', 'plugins')
56
+ git(repo, 'checkout', '--quiet', '-b', 'feature/x')
57
+
58
+ lernaSync = new LernaSync({
59
+ monorepoPath: mono,
60
+ gitHubToken: 'not-used',
61
+ gitHubOrgName: 'wmfs',
62
+ lernaPackageRouterFunction: router,
63
+ cloneUrl: (org, name) => path.join(sandbox.root, 'remotes', org, `${name}.git`)
64
+ })
65
+ lernaSync.graphql = fakeGitHub([existing, fresh])
66
+
67
+ output = captureConsole()
68
+ })
69
+
70
+ afterEach(() => {
71
+ output.restore()
72
+ sandbox.cleanup()
73
+ })
74
+
75
+ it('plans without changing anything on a dry run', async () => {
76
+ lernaSync.dryRun = true
77
+ const { plan, results } = await lernaSync.sync()
78
+
79
+ expect(plan.decisions.map(d => [d.repoName, d.action, d.reason])).to.deep.equal([
80
+ [existing.name, ACTIONS.FETCH_ONLY, REASONS.ON_BRANCH],
81
+ [fresh.name, ACTIONS.CLONE, REASONS.NEW]
82
+ ])
83
+ expect(results).to.deep.equal([])
84
+ expect(fs.existsSync(path.join(mono, 'plugins', fresh.name))).to.equal(false)
85
+ expect(output.text()).to.include('Dry run')
86
+ })
87
+
88
+ it('clones new repos and leaves feature branches alone', async () => {
89
+ const { results, errorCount } = await lernaSync.sync()
90
+
91
+ expect(errorCount).to.equal(0)
92
+ expect(results.map(r => [r.decision.repoName, r.outcome])).to.deep.equal([
93
+ [existing.name, OUTCOMES.CURRENT],
94
+ [fresh.name, OUTCOMES.CLONED]
95
+ ])
96
+ expect(fs.existsSync(path.join(mono, 'plugins', fresh.name, 'package.json'))).to.equal(true)
97
+ expect(git(path.join(mono, 'plugins', existing.name), 'rev-parse', '--abbrev-ref', 'HEAD')).to.equal('feature/x')
98
+ expect(output.text()).to.include('On branches (left alone) (1)')
99
+ })
100
+ })
101
+
102
+ describe('LernaSync options', () => {
103
+ let dir
104
+
105
+ before(() => {
106
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), 'lerna-sync-opts-'))
107
+ fs.writeFileSync(path.join(dir, 'lerna.json'), JSON.stringify({ packages: ['packages/*'] }))
108
+ })
109
+
110
+ after(() => fs.rmSync(dir, { recursive: true, force: true }))
111
+
112
+ const create = concurrency => new LernaSync({
113
+ monorepoPath: dir,
114
+ gitHubToken: 'not-used',
115
+ gitHubOrgName: 'wmfs',
116
+ lernaPackageRouterFunction: () => null,
117
+ concurrency
118
+ })
119
+
120
+ it('leaves concurrency unset when not given, so each stage uses its default', () => {
121
+ expect(create(undefined).concurrency).to.equal(undefined)
122
+ expect(create(null).concurrency).to.equal(undefined)
123
+ })
124
+
125
+ it('accepts a positive integer', () => {
126
+ expect(create(4).concurrency).to.equal(4)
127
+ })
128
+
129
+ for (const bad of [0, -2, 2.5, NaN, '4']) {
130
+ it(`rejects a concurrency of ${String(bad)}`, () => {
131
+ expect(() => create(bad)).to.throw(TypeError, /concurrency must be a positive integer/)
132
+ })
133
+ }
12
134
  })