@wmfs/lerna-sync 1.38.0 → 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/.circleci/config.yml +21 -0
- package/CHANGELOG.md +15 -0
- package/README.md +147 -59
- package/lib/discover.js +52 -0
- package/lib/execute-plan.js +141 -0
- package/lib/github-graphql-query.js +39 -36
- package/lib/index.js +78 -345
- package/lib/inspect.js +99 -0
- package/lib/plan-sync.js +182 -0
- package/lib/report.js +195 -0
- package/lib/utils/git.js +59 -0
- package/lib/utils/map-limit.js +17 -0
- package/lib/utils/package-folders-from-patterns.js +25 -0
- package/package.json +7 -7
- package/test/discover-spec.js +128 -0
- package/test/execute-plan-spec.js +233 -0
- package/test/helpers/fixtures.js +64 -0
- package/test/helpers/git-sandbox.js +87 -0
- package/test/inspect-spec.js +148 -0
- package/test/plan-sync-spec.js +247 -0
- package/test/report-spec.js +178 -0
- package/test/sync-spec.js +130 -8
- package/test/utils-spec.js +136 -0
- package/lib/actions/clone.js +0 -46
- package/lib/actions/index.js +0 -4
- package/lib/actions/pull.js +0 -52
- package/lib/utils/Messages.js +0 -69
- package/lib/utils/run.js +0 -11
package/lib/plan-sync.js
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
const ACTIONS = Object.freeze({
|
|
2
|
+
CLONE: 'CLONE',
|
|
3
|
+
FAST_FORWARD: 'FAST_FORWARD',
|
|
4
|
+
FETCH_ONLY: 'FETCH_ONLY',
|
|
5
|
+
NONE: 'NONE'
|
|
6
|
+
})
|
|
7
|
+
|
|
8
|
+
const REASONS = Object.freeze({
|
|
9
|
+
NEW: 'NEW',
|
|
10
|
+
BEHIND: 'BEHIND',
|
|
11
|
+
UP_TO_DATE: 'UP_TO_DATE',
|
|
12
|
+
ON_BRANCH: 'ON_BRANCH',
|
|
13
|
+
DETACHED: 'DETACHED',
|
|
14
|
+
LOCAL_CHANGES: 'LOCAL_CHANGES',
|
|
15
|
+
UNSAFE_STATE: 'UNSAFE_STATE',
|
|
16
|
+
NOT_A_REPO: 'NOT_A_REPO',
|
|
17
|
+
FOREIGN_ORIGIN: 'FOREIGN_ORIGIN',
|
|
18
|
+
ORPHANED: 'ORPHANED',
|
|
19
|
+
NO_LONGER_QUALIFIES: 'NO_LONGER_QUALIFIES',
|
|
20
|
+
DUPLICATE: 'DUPLICATE'
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
function planSync ({ remoteRepos, localRepos, router, orgName, packageFolders }) {
|
|
24
|
+
const key = name => name.toLowerCase() // GitHub repo names are case-insensitive
|
|
25
|
+
const knownFolders = new Set(packageFolders)
|
|
26
|
+
|
|
27
|
+
// 1. Route every remote repo once
|
|
28
|
+
const remoteByName = new Map()
|
|
29
|
+
const ignored = []
|
|
30
|
+
|
|
31
|
+
for (const remote of remoteRepos) {
|
|
32
|
+
const route = routeRepo(remote, router, knownFolders)
|
|
33
|
+
remoteByName.set(key(remote.name), { remote, route })
|
|
34
|
+
if (!route.folder) {
|
|
35
|
+
ignored.push({ repoName: remote.name, reason: route.reason })
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// 2. Index local repos by the name origin points at, falling back to folder name
|
|
40
|
+
const localByName = new Map()
|
|
41
|
+
const decisions = []
|
|
42
|
+
|
|
43
|
+
for (const local of localRepos) {
|
|
44
|
+
const name = key(local.origin?.name || local.repoName)
|
|
45
|
+
if (localByName.has(name)) {
|
|
46
|
+
decisions.push(
|
|
47
|
+
decision(
|
|
48
|
+
local.repoName,
|
|
49
|
+
ACTIONS.NONE,
|
|
50
|
+
REASONS.DUPLICATE,
|
|
51
|
+
{
|
|
52
|
+
local,
|
|
53
|
+
folder: local.folder,
|
|
54
|
+
warnings: [`Also found at ${localByName.get(name).folder}/${localByName.get(name).repoName}`]
|
|
55
|
+
}
|
|
56
|
+
)
|
|
57
|
+
)
|
|
58
|
+
continue
|
|
59
|
+
}
|
|
60
|
+
localByName.set(name, local)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// 3. Repos that exist locally
|
|
64
|
+
for (const [name, local] of localByName) {
|
|
65
|
+
const entry = remoteByName.get(name)
|
|
66
|
+
decisions.push(planLocal(local, entry, orgName))
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// 4. Qualifying remote repos with no local copy
|
|
70
|
+
for (const [name, { remote, route }] of remoteByName) {
|
|
71
|
+
if (!route.folder || localByName.has(name)) continue
|
|
72
|
+
decisions.push(decision(remote.name, ACTIONS.CLONE, REASONS.NEW, {
|
|
73
|
+
remote,
|
|
74
|
+
folder: route.folder,
|
|
75
|
+
defaultBranch: remote.defaultBranch
|
|
76
|
+
}))
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
decisions.sort((a, b) =>
|
|
80
|
+
a.folder.localeCompare(b.folder) || a.repoName.localeCompare(b.repoName)
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
return { decisions, ignored }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function planLocal (local, entry, orgName) {
|
|
87
|
+
const base = { local, folder: local.folder }
|
|
88
|
+
|
|
89
|
+
if (!local.isGitRepo) {
|
|
90
|
+
// A plain folder where a qualifying repo would be cloned: don't overwrite it
|
|
91
|
+
return decision(local.repoName, ACTIONS.NONE, REASONS.NOT_A_REPO, {
|
|
92
|
+
...base,
|
|
93
|
+
remote: entry?.remote || null,
|
|
94
|
+
warnings: entry?.route.folder ? ['Folder exists but is not a git repo, so it cannot be cloned into'] : []
|
|
95
|
+
})
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (local.origin?.owner && local.origin.owner.toLowerCase() !== orgName.toLowerCase()) {
|
|
99
|
+
return decision(local.repoName, ACTIONS.NONE, REASONS.FOREIGN_ORIGIN, {
|
|
100
|
+
...base,
|
|
101
|
+
warnings: [`origin points at ${local.origin.url}`]
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (!entry) {
|
|
106
|
+
return decision(local.repoName, ACTIONS.NONE, REASONS.ORPHANED, base)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const { remote, route } = entry
|
|
110
|
+
const withRemote = { ...base, remote, defaultBranch: remote.defaultBranch, warnings: [] }
|
|
111
|
+
|
|
112
|
+
if (!route.folder) {
|
|
113
|
+
return decision(local.repoName, ACTIONS.NONE, REASONS.NO_LONGER_QUALIFIES, {
|
|
114
|
+
...withRemote,
|
|
115
|
+
warnings: [route.reason]
|
|
116
|
+
})
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (route.folder !== local.folder) {
|
|
120
|
+
withRemote.warnings.push(`Lives in /${local.folder} but is now routed to /${route.folder}`)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (!local.statusKnown || local.operationInProgress || !local.headSha) {
|
|
124
|
+
return decision(local.repoName, ACTIONS.NONE, REASONS.UNSAFE_STATE, withRemote)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (local.isDetached) {
|
|
128
|
+
return decision(local.repoName, ACTIONS.FETCH_ONLY, REASONS.DETACHED, withRemote)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (local.branch !== remote.defaultBranch) {
|
|
132
|
+
return decision(local.repoName, ACTIONS.FETCH_ONLY, REASONS.ON_BRANCH, withRemote)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (local.headSha === remote.sha) {
|
|
136
|
+
return decision(local.repoName, ACTIONS.NONE, REASONS.UP_TO_DATE, withRemote)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (local.trackedChanges > 0) {
|
|
140
|
+
return decision(local.repoName, ACTIONS.FETCH_ONLY, REASONS.LOCAL_CHANGES, withRemote)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// On the default branch, clean, and HEAD differs from GitHub.
|
|
144
|
+
// Usually that means behind. If it has local commits instead, --ff-only
|
|
145
|
+
// will refuse and the executor reports it as diverged.
|
|
146
|
+
return decision(local.repoName, ACTIONS.FAST_FORWARD, REASONS.BEHIND, withRemote)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function routeRepo (remote, router, knownFolders) {
|
|
150
|
+
if (!remote.packageJson) return { folder: null, reason: 'No package.json' }
|
|
151
|
+
|
|
152
|
+
let result
|
|
153
|
+
try {
|
|
154
|
+
result = router(remote.packageJson)
|
|
155
|
+
} catch (e) {
|
|
156
|
+
return { folder: null, reason: `Router threw: ${e.message}` }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Backwards compatible: a string means "route here", null/undefined means "not ours".
|
|
160
|
+
// A router can also return { skip: 'reason' } so the report can say why.
|
|
161
|
+
if (result == null) return { folder: null, reason: 'Not selected by router' }
|
|
162
|
+
if (typeof result === 'object' && result.skip) return { folder: null, reason: result.skip }
|
|
163
|
+
if (typeof result !== 'string') return { folder: null, reason: `Router returned ${JSON.stringify(result)}` }
|
|
164
|
+
if (!knownFolders.has(result)) return { folder: null, reason: `Router chose "${result}", which is not in lerna.json` }
|
|
165
|
+
|
|
166
|
+
return { folder: result, reason: null }
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function decision (repoName, action, reason, extra = {}) {
|
|
170
|
+
return {
|
|
171
|
+
repoName,
|
|
172
|
+
action,
|
|
173
|
+
reason,
|
|
174
|
+
folder: extra.folder,
|
|
175
|
+
defaultBranch: extra.defaultBranch || extra.remote?.defaultBranch || null,
|
|
176
|
+
remote: extra.remote || null,
|
|
177
|
+
local: extra.local || null,
|
|
178
|
+
warnings: extra.warnings || []
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
module.exports = { planSync, ACTIONS, REASONS }
|
package/lib/report.js
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
const chalk = require('chalk')
|
|
2
|
+
const { ACTIONS, REASONS } = require('./plan-sync')
|
|
3
|
+
const { OUTCOMES } = require('./execute-plan')
|
|
4
|
+
|
|
5
|
+
const attention = (name, detail, commits) => ({ group: 'attention', name, detail, commits })
|
|
6
|
+
const housekeeping = (name, detail) => ({ group: 'housekeeping', name, detail })
|
|
7
|
+
const firstLine = s => (s || '').split('\n')[0]
|
|
8
|
+
|
|
9
|
+
function report (plan, results, { verbose = false } = {}) {
|
|
10
|
+
const resultFor = new Map(results.map(r => [r.decision, r]))
|
|
11
|
+
const groups = { branches: [], cloned: [], updated: [], attention: [], housekeeping: [], errors: [] }
|
|
12
|
+
let okCount = 0
|
|
13
|
+
let depsMayHaveChanged = false
|
|
14
|
+
|
|
15
|
+
for (const d of plan.decisions) {
|
|
16
|
+
const r = resultFor.get(d)
|
|
17
|
+
const entry = classify(d, r)
|
|
18
|
+
if (!entry) {
|
|
19
|
+
okCount++
|
|
20
|
+
} else {
|
|
21
|
+
groups[entry.group].push({ ...entry, warnings: d.warnings })
|
|
22
|
+
}
|
|
23
|
+
if (r && (r.outcome === OUTCOMES.CLONED || r.packageJsonChanged)) depsMayHaveChanged = true
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
section('Cloned', groups.cloned, chalk.green)
|
|
27
|
+
section('Updated', groups.updated, chalk.cyan)
|
|
28
|
+
section('On branches (left alone)', groups.branches, chalk.blue)
|
|
29
|
+
section('Needs your attention', groups.attention, chalk.yellow)
|
|
30
|
+
section('Housekeeping', groups.housekeeping, chalk.gray)
|
|
31
|
+
section('Errors', groups.errors, chalk.red)
|
|
32
|
+
|
|
33
|
+
if (verbose && plan.ignored.length) {
|
|
34
|
+
console.log(chalk.gray(`\nIgnored ${plan.ignored.length} repos:`))
|
|
35
|
+
plan.ignored.forEach(i => console.log(chalk.gray(` ${i.repoName}: ${i.reason}`)))
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
console.log('')
|
|
39
|
+
console.log(
|
|
40
|
+
`${chalk.green(okCount)} up to date, ${groups.cloned.length} cloned, ${groups.updated.length} updated, ` +
|
|
41
|
+
`${chalk.yellow(groups.attention.length)} need attention, ${chalk.red(groups.errors.length)} errors`
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
if (depsMayHaveChanged) {
|
|
45
|
+
console.log(chalk.yellow('\nDependencies may have changed. Reinstall/relink before running anything.'))
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return { errorCount: groups.errors.length, attentionCount: groups.attention.length }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function classify (d, r) {
|
|
52
|
+
const name = `${d.folder}/${d.repoName}`
|
|
53
|
+
|
|
54
|
+
if (!r) {
|
|
55
|
+
switch (d.reason) {
|
|
56
|
+
case REASONS.UP_TO_DATE: return null
|
|
57
|
+
case REASONS.UNSAFE_STATE: return attention(name, 'Mid-merge/rebase or unreadable status, skipped')
|
|
58
|
+
case REASONS.FOREIGN_ORIGIN: return attention(name, 'origin is not this org, skipped')
|
|
59
|
+
case REASONS.NOT_A_REPO:
|
|
60
|
+
return d.warnings.length
|
|
61
|
+
? attention(name, 'Folder exists but is not a git repo, cannot clone')
|
|
62
|
+
: housekeeping(name, 'Not a git repo')
|
|
63
|
+
case REASONS.ORPHANED: return housekeeping(name, 'No longer in the org (deleted, archived or renamed)')
|
|
64
|
+
case REASONS.NO_LONGER_QUALIFIES: return housekeeping(name, 'No longer selected for sync')
|
|
65
|
+
case REASONS.DUPLICATE: return housekeeping(name, 'Duplicate clone')
|
|
66
|
+
default: return housekeeping(name, d.reason)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const branch = d.local?.branch
|
|
71
|
+
switch (r.outcome) {
|
|
72
|
+
case OUTCOMES.CLONED:
|
|
73
|
+
return { group: 'cloned', name, detail: d.remote.packageJson?.description }
|
|
74
|
+
case OUTCOMES.UPDATED:
|
|
75
|
+
return { group: 'updated', name, detail: `${r.behind} new commit${r.behind === 1 ? '' : 's'}`, commits: r.commits }
|
|
76
|
+
case OUTCOMES.CURRENT:
|
|
77
|
+
if (d.reason === REASONS.ON_BRANCH) return { group: 'branches', name, detail: `on ${branch}, nothing new on ${d.defaultBranch}` }
|
|
78
|
+
if (d.reason === REASONS.DETACHED) return { group: 'branches', name, detail: 'detached HEAD, nothing new' }
|
|
79
|
+
return null
|
|
80
|
+
case OUTCOMES.BEHIND:
|
|
81
|
+
if (d.reason === REASONS.ON_BRANCH) return attention(name, `On ${branch}: ${d.defaultBranch} has ${r.behind} commit(s) you don't`, r.commits)
|
|
82
|
+
if (d.reason === REASONS.DETACHED) return attention(name, `Detached HEAD: ${d.defaultBranch} is ${r.behind} commit(s) ahead`, r.commits)
|
|
83
|
+
return attention(name, `Uncommitted changes on ${d.defaultBranch}: ${r.behind} commit(s) not pulled`, r.commits)
|
|
84
|
+
case OUTCOMES.LOCAL_AHEAD:
|
|
85
|
+
return attention(name, `${r.ahead} unpushed commit(s) on ${d.defaultBranch}`)
|
|
86
|
+
case OUTCOMES.DIVERGED:
|
|
87
|
+
return attention(name, `${d.defaultBranch} has diverged (${r.ahead} ahead, ${r.behind} behind), not pulled`, r.commits)
|
|
88
|
+
case OUTCOMES.BLOCKED_BY_UNTRACKED:
|
|
89
|
+
return attention(name, 'Untracked files would be overwritten, not pulled', r.commits)
|
|
90
|
+
case OUTCOMES.FAILED:
|
|
91
|
+
return { group: 'errors', name, detail: r.error }
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function section (title, entries, colour) {
|
|
96
|
+
if (!entries.length) return
|
|
97
|
+
console.log('')
|
|
98
|
+
console.log(chalk.underline(`${title} (${entries.length})`))
|
|
99
|
+
for (const e of entries) {
|
|
100
|
+
console.log(` ${colour(e.name)}${e.detail ? chalk.gray(` ${e.detail}`) : ''}`)
|
|
101
|
+
for (const w of e.warnings || []) console.log(chalk.yellow(` ! ${w}`))
|
|
102
|
+
for (const c of e.commits || []) console.log(chalk.gray(` ${c.sha} ${c.subject} (${c.author}, ${c.when})`))
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function printPlan (plan, { verbose = false } = {}) {
|
|
107
|
+
const groups = { clone: [], fastForward: [], fetchOnly: [], attention: [], housekeeping: [] }
|
|
108
|
+
|
|
109
|
+
let okCount = 0
|
|
110
|
+
|
|
111
|
+
for (const d of plan.decisions) {
|
|
112
|
+
const name = `${d.folder}/${d.repoName}`
|
|
113
|
+
const warnings = d.warnings
|
|
114
|
+
|
|
115
|
+
switch (d.action) {
|
|
116
|
+
case ACTIONS.CLONE:
|
|
117
|
+
groups.clone.push({ name, detail: d.remote.packageJson?.description, warnings })
|
|
118
|
+
break
|
|
119
|
+
case ACTIONS.FAST_FORWARD:
|
|
120
|
+
groups.fastForward.push({ name, warnings })
|
|
121
|
+
break
|
|
122
|
+
case ACTIONS.FETCH_ONLY:
|
|
123
|
+
groups.fetchOnly.push({ name, detail: fetchOnlyDetail(d), warnings })
|
|
124
|
+
break
|
|
125
|
+
default: {
|
|
126
|
+
// NONE decisions: the same wording as the real report
|
|
127
|
+
const entry = classify(d, undefined)
|
|
128
|
+
if (!entry) okCount++
|
|
129
|
+
else groups[entry.group].push({ ...entry, warnings })
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
console.log(chalk.bold('\nDry run: nothing has been changed'))
|
|
135
|
+
section('Would clone', groups.clone, chalk.green)
|
|
136
|
+
section('Would fast-forward', groups.fastForward, chalk.cyan)
|
|
137
|
+
section('Would fetch only (your branch is left alone)', groups.fetchOnly, chalk.yellow)
|
|
138
|
+
section('Needs your attention', groups.attention, chalk.yellow)
|
|
139
|
+
section('Housekeeping', groups.housekeeping, chalk.gray)
|
|
140
|
+
|
|
141
|
+
if (verbose && plan.ignored.length) {
|
|
142
|
+
console.log(chalk.gray(`\nIgnored ${plan.ignored.length} repos:`))
|
|
143
|
+
plan.ignored.forEach(i => console.log(chalk.gray(` ${i.repoName}: ${i.reason}`)))
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
console.log('')
|
|
147
|
+
console.log(
|
|
148
|
+
`${chalk.green(okCount)} up to date, ${groups.clone.length} to clone, ` +
|
|
149
|
+
`${groups.fastForward.length} to fast-forward, ${groups.fetchOnly.length} to fetch`
|
|
150
|
+
)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function fetchOnlyDetail (d) {
|
|
154
|
+
switch (d.reason) {
|
|
155
|
+
case REASONS.ON_BRANCH: return `on ${d.local.branch}`
|
|
156
|
+
case REASONS.DETACHED: return 'detached HEAD'
|
|
157
|
+
case REASONS.LOCAL_CHANGES: return `uncommitted changes on ${d.defaultBranch}`
|
|
158
|
+
default: return d.reason
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function describeResult (r) {
|
|
163
|
+
const d = r.decision
|
|
164
|
+
const base = d.defaultBranch || 'master'
|
|
165
|
+
const branch = d.local?.branch
|
|
166
|
+
const n = (count, word) => `${count} ${word}${count === 1 ? '' : 's'}`
|
|
167
|
+
|
|
168
|
+
switch (r.outcome) {
|
|
169
|
+
case OUTCOMES.CLONED:
|
|
170
|
+
return { label: 'Cloned', colour: chalk.green }
|
|
171
|
+
case OUTCOMES.UPDATED:
|
|
172
|
+
return { label: 'Updated', colour: chalk.cyan, detail: `pulled ${n(r.behind, 'commit')}` }
|
|
173
|
+
case OUTCOMES.CURRENT:
|
|
174
|
+
if (d.reason === REASONS.ON_BRANCH) return { label: 'On branch', colour: chalk.blue, detail: `${branch}, nothing new on ${base}` }
|
|
175
|
+
if (d.reason === REASONS.DETACHED) return { label: 'Detached', colour: chalk.blue, detail: `nothing new on ${base}` }
|
|
176
|
+
if (d.reason === REASONS.LOCAL_CHANGES) return { label: 'Up to date', colour: chalk.green, detail: 'has uncommitted changes' }
|
|
177
|
+
return { label: 'Up to date', colour: chalk.green }
|
|
178
|
+
case OUTCOMES.BEHIND:
|
|
179
|
+
if (d.reason === REASONS.ON_BRANCH) return { label: 'On branch', colour: chalk.yellow, detail: `${branch}, ${base} has ${n(r.behind, 'new commit')}` }
|
|
180
|
+
if (d.reason === REASONS.DETACHED) return { label: 'Detached', colour: chalk.yellow, detail: `${base} has ${n(r.behind, 'new commit')}` }
|
|
181
|
+
return { label: 'Not pulled', colour: chalk.yellow, detail: `uncommitted changes, ${base} has ${n(r.behind, 'new commit')}` }
|
|
182
|
+
case OUTCOMES.LOCAL_AHEAD:
|
|
183
|
+
return { label: 'Unpushed', colour: chalk.yellow, detail: `${n(r.ahead, 'local commit')} on ${base}` }
|
|
184
|
+
case OUTCOMES.DIVERGED:
|
|
185
|
+
return { label: 'Diverged', colour: chalk.yellow, detail: `${r.ahead} ahead, ${r.behind} behind, not pulled` }
|
|
186
|
+
case OUTCOMES.BLOCKED_BY_UNTRACKED:
|
|
187
|
+
return { label: 'Not pulled', colour: chalk.yellow, detail: 'untracked files would be overwritten' }
|
|
188
|
+
case OUTCOMES.FAILED:
|
|
189
|
+
return { label: 'Failed', colour: chalk.red, detail: firstLine(r.error) }
|
|
190
|
+
default:
|
|
191
|
+
return { label: r.outcome, colour: chalk.gray }
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
module.exports = { report, printPlan, describeResult }
|
package/lib/utils/git.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
const { execFile } = require('child_process')
|
|
2
|
+
|
|
3
|
+
const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000
|
|
4
|
+
|
|
5
|
+
// Sync runs many git commands in parallel with no one watching their output,
|
|
6
|
+
// so git must fail fast rather than wait for input that will never come.
|
|
7
|
+
function nonInteractiveEnv () {
|
|
8
|
+
const env = {
|
|
9
|
+
...process.env,
|
|
10
|
+
// Never prompt in the terminal for a username/password
|
|
11
|
+
GIT_TERMINAL_PROMPT: '0'
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Never prompt for an SSH passphrase or to accept an unknown host key.
|
|
15
|
+
// Keys loaded in ssh-agent still work. Respect a command the user already set.
|
|
16
|
+
if (!env.GIT_SSH_COMMAND && !env.GIT_SSH) {
|
|
17
|
+
env.GIT_SSH_COMMAND = 'ssh -o BatchMode=yes'
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return env
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function git (args, cwd, { timeout = DEFAULT_TIMEOUT_MS } = {}) {
|
|
24
|
+
return new Promise((resolve, reject) => {
|
|
25
|
+
execFile(
|
|
26
|
+
'git',
|
|
27
|
+
['-c', 'core.quotepath=false', ...args],
|
|
28
|
+
{
|
|
29
|
+
cwd,
|
|
30
|
+
timeout,
|
|
31
|
+
windowsHide: true,
|
|
32
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
33
|
+
env: nonInteractiveEnv()
|
|
34
|
+
},
|
|
35
|
+
(err, stdout, stderr) => {
|
|
36
|
+
if (err) {
|
|
37
|
+
err.stderr = stderr
|
|
38
|
+
if (err.killed) {
|
|
39
|
+
const limit = timeout >= 1000 ? `${Math.round(timeout / 1000)}s` : `${timeout}ms`
|
|
40
|
+
err.message = `git ${args[0]} timed out after ${limit}`
|
|
41
|
+
}
|
|
42
|
+
return reject(err)
|
|
43
|
+
}
|
|
44
|
+
// trimEnd, not trim: `git status --porcelain` lines can start with a space
|
|
45
|
+
resolve(stdout.trimEnd())
|
|
46
|
+
}
|
|
47
|
+
)
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function tryGit (args, cwd, options) {
|
|
52
|
+
try {
|
|
53
|
+
return await git(args, cwd, options)
|
|
54
|
+
} catch (e) {
|
|
55
|
+
return null
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
module.exports = { git, tryGit, DEFAULT_TIMEOUT_MS }
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
module.exports = async function mapLimit (items, limit, fn) {
|
|
2
|
+
// A NaN or 0 limit would start no workers and silently return an array of holes
|
|
3
|
+
if (!Number.isInteger(limit) || limit < 1) {
|
|
4
|
+
throw new RangeError(`mapLimit: limit must be a positive integer, got ${limit}`)
|
|
5
|
+
}
|
|
6
|
+
const results = new Array(items.length)
|
|
7
|
+
let next = 0
|
|
8
|
+
async function worker () {
|
|
9
|
+
while (next < items.length) {
|
|
10
|
+
const i = next++
|
|
11
|
+
results[i] = await fn(items[i], i)
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
const workers = Array.from({ length: Math.min(limit, items.length) }, worker)
|
|
15
|
+
await Promise.all(workers)
|
|
16
|
+
return results
|
|
17
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
const GLOB_CHARS = /[*?[\]{}!]/
|
|
2
|
+
|
|
3
|
+
function packageFoldersFromPatterns (patterns) {
|
|
4
|
+
const folders = new Set()
|
|
5
|
+
|
|
6
|
+
for (const raw of patterns) {
|
|
7
|
+
const pattern = raw.trim().replace(/\\/g, '/').replace(/\/+$/, '')
|
|
8
|
+
|
|
9
|
+
// Negations like "!packages/legacy" exclude folders, they don't add one
|
|
10
|
+
if (pattern.startsWith('!')) continue
|
|
11
|
+
|
|
12
|
+
// Only "<folder>/*" can be a clone target: lerna-sync clones to <folder>/<repoName>
|
|
13
|
+
const match = pattern.match(/^(.+)\/\*$/)
|
|
14
|
+
if (!match || GLOB_CHARS.test(match[1])) {
|
|
15
|
+
console.warn(`lerna-sync: ignoring package pattern "${raw}", expected "<folder>/*"`)
|
|
16
|
+
continue
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
folders.add(match[1].replace(/^\.\//, ''))
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return [...folders]
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
module.exports = packageFoldersFromPatterns
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wmfs/lerna-sync",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.39.0",
|
|
4
4
|
"description": "A package to synchronize distributed GitHub repos inside a Lerna monorepo.",
|
|
5
5
|
"author": "West Midlands Fire Service",
|
|
6
6
|
"homepage": "https://github.com/wmfs/lerna-sync#readme",
|
|
@@ -22,22 +22,22 @@
|
|
|
22
22
|
"directories": {
|
|
23
23
|
"lib": "lib"
|
|
24
24
|
},
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": ">=20"
|
|
27
|
+
},
|
|
25
28
|
"dependencies": {
|
|
26
|
-
"@octokit/graphql": "
|
|
29
|
+
"@octokit/graphql": "7.1.1",
|
|
27
30
|
"chalk": "4.1.2",
|
|
28
|
-
"dottie": "2.0.6",
|
|
29
|
-
"git-repo-info": "2.1.1",
|
|
30
31
|
"glob": "11.1.0",
|
|
31
32
|
"humanize-duration": "3.33.0",
|
|
32
|
-
"jsonfile": "6.1.0"
|
|
33
|
-
"lodash": "4.17.21"
|
|
33
|
+
"jsonfile": "6.1.0"
|
|
34
34
|
},
|
|
35
35
|
"devDependencies": {
|
|
36
36
|
"chai": "4.5.0",
|
|
37
37
|
"codecov": "3.8.3",
|
|
38
38
|
"conventional-changelog-conventionalcommits": "9.3.1",
|
|
39
39
|
"cz-conventional-changelog": "3.3.0",
|
|
40
|
-
"mocha": "12.0.
|
|
40
|
+
"mocha": "12.0.3",
|
|
41
41
|
"nyc": "18.0.0",
|
|
42
42
|
"semantic-release": "25.0.9",
|
|
43
43
|
"standard": "17.1.2",
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/* eslint-env mocha */
|
|
2
|
+
|
|
3
|
+
const { expect } = require('chai')
|
|
4
|
+
const { captureConsole } = require('./helpers/fixtures')
|
|
5
|
+
const discover = require('../lib/discover')
|
|
6
|
+
|
|
7
|
+
// A GraphQL node as GitHub returns it
|
|
8
|
+
function node (name, overrides = {}) {
|
|
9
|
+
return {
|
|
10
|
+
name,
|
|
11
|
+
isArchived: false,
|
|
12
|
+
isPrivate: false,
|
|
13
|
+
packageJson: { text: JSON.stringify({ name, keywords: ['tymly'] }), isTruncated: false },
|
|
14
|
+
defaultBranchRef: {
|
|
15
|
+
name: 'master',
|
|
16
|
+
target: {
|
|
17
|
+
oid: `${name}-sha`,
|
|
18
|
+
messageHeadline: `Commit on ${name}`,
|
|
19
|
+
author: { name: 'Someone', date: '2026-01-01T00:00:00Z' }
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
...overrides
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Stand-in for the octokit client: serves pages in order and records calls
|
|
27
|
+
function fakeGraphql (pages) {
|
|
28
|
+
const calls = []
|
|
29
|
+
const fn = async (query, variables) => {
|
|
30
|
+
calls.push(variables)
|
|
31
|
+
const index = calls.length - 1
|
|
32
|
+
const nodes = pages[index]
|
|
33
|
+
const hasNextPage = index < pages.length - 1
|
|
34
|
+
return {
|
|
35
|
+
organization: {
|
|
36
|
+
repositories: {
|
|
37
|
+
nodes,
|
|
38
|
+
pageInfo: { hasNextPage, endCursor: hasNextPage ? `cursor-${index + 1}` : null }
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
fn.calls = calls
|
|
44
|
+
return fn
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe('discover', () => {
|
|
48
|
+
let output
|
|
49
|
+
beforeEach(() => { output = captureConsole() })
|
|
50
|
+
afterEach(() => output.restore())
|
|
51
|
+
|
|
52
|
+
it('maps GitHub nodes to plain remote records', async () => {
|
|
53
|
+
const [repo] = await discover(fakeGraphql([[node('a')]]), 'wmfs')
|
|
54
|
+
expect(repo).to.deep.equal({
|
|
55
|
+
name: 'a',
|
|
56
|
+
isPrivate: false,
|
|
57
|
+
defaultBranch: 'master',
|
|
58
|
+
sha: 'a-sha',
|
|
59
|
+
messageHeadline: 'Commit on a',
|
|
60
|
+
author: { name: 'Someone', date: '2026-01-01T00:00:00Z' },
|
|
61
|
+
packageJson: { name: 'a', keywords: ['tymly'] }
|
|
62
|
+
})
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('follows pagination cursors until the last page', async () => {
|
|
66
|
+
const graphql = fakeGraphql([[node('a'), node('b')], [node('c')], [node('d')]])
|
|
67
|
+
const repos = await discover(graphql, 'wmfs')
|
|
68
|
+
|
|
69
|
+
expect(repos.map(r => r.name)).to.deep.equal(['a', 'b', 'c', 'd'])
|
|
70
|
+
expect(graphql.calls).to.deep.equal([
|
|
71
|
+
{ org: 'wmfs', after: null },
|
|
72
|
+
{ org: 'wmfs', after: 'cursor-1' },
|
|
73
|
+
{ org: 'wmfs', after: 'cursor-2' }
|
|
74
|
+
])
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('uses each repo\'s real default branch', async () => {
|
|
78
|
+
const main = node('a')
|
|
79
|
+
main.defaultBranchRef.name = 'main'
|
|
80
|
+
const [repo] = await discover(fakeGraphql([[main]]), 'wmfs')
|
|
81
|
+
expect(repo.defaultBranch).to.equal('main')
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('skips archived and empty repos', async () => {
|
|
85
|
+
const repos = await discover(fakeGraphql([[
|
|
86
|
+
node('archived', { isArchived: true }),
|
|
87
|
+
node('empty', { defaultBranchRef: null }),
|
|
88
|
+
node('kept')
|
|
89
|
+
]]), 'wmfs')
|
|
90
|
+
expect(repos.map(r => r.name)).to.deep.equal(['kept'])
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('keeps repos with no package.json, with packageJson null', async () => {
|
|
94
|
+
const [repo] = await discover(fakeGraphql([[node('a', { packageJson: null })]]), 'wmfs')
|
|
95
|
+
expect(repo.packageJson).to.equal(null)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('treats invalid JSON as no package.json, with a warning', async () => {
|
|
99
|
+
const [repo] = await discover(fakeGraphql([[
|
|
100
|
+
node('broken', { packageJson: { text: '{ not json', isTruncated: false } })
|
|
101
|
+
]]), 'wmfs')
|
|
102
|
+
expect(repo.packageJson).to.equal(null)
|
|
103
|
+
expect(output.text()).to.include('Unable to parse package.json for broken')
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('treats a truncated blob as no package.json, with a warning', async () => {
|
|
107
|
+
const [repo] = await discover(fakeGraphql([[
|
|
108
|
+
node('huge', { packageJson: { text: '{"name":', isTruncated: true } })
|
|
109
|
+
]]), 'wmfs')
|
|
110
|
+
expect(repo.packageJson).to.equal(null)
|
|
111
|
+
expect(output.text()).to.include('truncated')
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('returns an empty list for an org with no repos', async () => {
|
|
115
|
+
expect(await discover(fakeGraphql([[]]), 'wmfs')).to.deep.equal([])
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('lets GraphQL errors propagate', async () => {
|
|
119
|
+
const failing = async () => { throw new Error('Bad credentials') }
|
|
120
|
+
let error
|
|
121
|
+
try {
|
|
122
|
+
await discover(failing, 'wmfs')
|
|
123
|
+
} catch (e) {
|
|
124
|
+
error = e
|
|
125
|
+
}
|
|
126
|
+
expect(error && error.message).to.equal('Bad credentials')
|
|
127
|
+
})
|
|
128
|
+
})
|