polyrepo-cli 1.0.0 → 1.0.2
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/README.md +139 -38
- package/package.json +1 -1
- package/src/changes.js +14 -14
- package/src/commands/bump.js +9 -8
- package/src/commands/doctor.js +272 -6
- package/src/commands/list.js +1 -2
- package/src/commands/publish.js +41 -4
- package/src/commands/release.js +16 -3
- package/src/commands/switchMaster.js +24 -11
- package/src/commands/tag.js +22 -9
- package/src/config.js +11 -2
- package/src/github.js +1 -1
- package/src/index.js +92 -21
- package/src/masterSync.js +26 -9
- package/src/repos.js +44 -5
- package/test/config.test.js +22 -0
package/src/commands/doctor.js
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
|
+
import { checkbox, confirm } from '@inquirer/prompts'
|
|
1
2
|
import pc from 'picocolors'
|
|
2
3
|
import { discoverRepos, inspectRepos } from '../repos.js'
|
|
3
4
|
import { loadConfig } from '../loadConfig.js'
|
|
4
|
-
import { run } from '../exec.js'
|
|
5
|
+
import { run, git, gitAsync, ghAsync } from '../exec.js'
|
|
5
6
|
import { findStaleLocalDeps } from '../crossDeps.js'
|
|
6
|
-
import {
|
|
7
|
+
import { isBumpBranchName } from '../config.js'
|
|
8
|
+
import { pMap } from '../pMap.js'
|
|
9
|
+
import { heading, ok, fail, warn, columnWidths, formatRow, promptTheme } from '../ui.js'
|
|
7
10
|
import { startSpinner } from '../spinner.js'
|
|
8
11
|
|
|
9
|
-
export async function doctorCommand({ configPath } = {}) {
|
|
12
|
+
export async function doctorCommand({ configPath, cleanBranches = false } = {}) {
|
|
10
13
|
heading('Environment')
|
|
11
14
|
checkNode()
|
|
12
15
|
checkGit()
|
|
@@ -28,10 +31,42 @@ export async function doctorCommand({ configPath } = {}) {
|
|
|
28
31
|
if (dirty.length > 0) {
|
|
29
32
|
warn(`${dirty.length} repo(s) have uncommitted changes: ${dirty.map((r) => r.dir).join(', ')}`)
|
|
30
33
|
}
|
|
31
|
-
const
|
|
32
|
-
if (
|
|
33
|
-
warn(`${
|
|
34
|
+
const detached = repos.filter((r) => r.branch === null)
|
|
35
|
+
if (detached.length > 0) {
|
|
36
|
+
warn(`${detached.length} repo(s) are in a detached HEAD state: ${detached.map((r) => r.dir).join(', ')}`)
|
|
34
37
|
}
|
|
38
|
+
const offDefault = repos.filter((r) => r.branch !== null && r.branch !== r.defaultBranch)
|
|
39
|
+
if (offDefault.length > 0) {
|
|
40
|
+
warn(`${offDefault.length} repo(s) are not on their default branch: ${offDefault.map((r) => r.dir).join(', ')}`)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
heading('Remote sync')
|
|
45
|
+
if (repos.length > 0) {
|
|
46
|
+
await checkRemoteSync(repos)
|
|
47
|
+
} else {
|
|
48
|
+
console.log(pc.dim('Skipped — no packages discovered.'))
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
heading('Branch sync')
|
|
52
|
+
if (repos.length > 0) {
|
|
53
|
+
await checkBranchSync(repos)
|
|
54
|
+
} else {
|
|
55
|
+
console.log(pc.dim('Skipped — no packages discovered.'))
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
heading('Branch protection')
|
|
59
|
+
if (repos.length > 0) {
|
|
60
|
+
await checkBranchProtection(repos)
|
|
61
|
+
} else {
|
|
62
|
+
console.log(pc.dim('Skipped — no packages discovered.'))
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
heading('Stale bump branches')
|
|
66
|
+
if (repos.length > 0) {
|
|
67
|
+
await checkStaleBumpBranches(repos, { cleanBranches })
|
|
68
|
+
} else {
|
|
69
|
+
console.log(pc.dim('Skipped — no packages discovered.'))
|
|
35
70
|
}
|
|
36
71
|
|
|
37
72
|
heading('Cross-package dependencies')
|
|
@@ -51,6 +86,237 @@ export async function doctorCommand({ configPath } = {}) {
|
|
|
51
86
|
}
|
|
52
87
|
}
|
|
53
88
|
|
|
89
|
+
// Every other command trusts the locally cached `origin/HEAD` ref as the
|
|
90
|
+
// fast, no-network path for "what's this repo's default branch" (see
|
|
91
|
+
// detectDefaultBranchAsync in repos.js) — but git never refreshes that
|
|
92
|
+
// cache on its own, so if the default branch gets renamed on GitHub after
|
|
93
|
+
// a repo was cloned, every command would keep using the old name forever
|
|
94
|
+
// with nothing to notice or fix it. This repairs it with `git remote
|
|
95
|
+
// set-head origin --auto` wherever it's drifted — a safe, non-destructive
|
|
96
|
+
// pointer refresh, not a change to any file, branch, or commit. Bundled
|
|
97
|
+
// with `git remote prune origin` (same "keep local remote-tracking state
|
|
98
|
+
// honest" spirit): drops local `remotes/origin/x` refs for branches
|
|
99
|
+
// already deleted on GitHub — also just local bookkeeping, touches
|
|
100
|
+
// nothing shared.
|
|
101
|
+
async function checkRemoteSync(repos) {
|
|
102
|
+
const spinner = startSpinner(`Checking ${repos.length} package(s) against GitHub, and pruning stale remote-tracking refs...`)
|
|
103
|
+
const results = await pMap(repos, async (r) => {
|
|
104
|
+
const ghResult = await ghAsync(r.path, [
|
|
105
|
+
'repo',
|
|
106
|
+
'view',
|
|
107
|
+
'--json',
|
|
108
|
+
'defaultBranchRef',
|
|
109
|
+
'-q',
|
|
110
|
+
'.defaultBranchRef.name',
|
|
111
|
+
])
|
|
112
|
+
const actual = ghResult.ok && ghResult.stdout ? ghResult.stdout : null
|
|
113
|
+
|
|
114
|
+
const pruneResult = await gitAsync(r.path, ['remote', 'prune', 'origin'])
|
|
115
|
+
const pruned = pruneResult.ok ? [...pruneResult.stdout.matchAll(/\[pruned\] origin\/(\S+)/g)].map((m) => m[1]) : []
|
|
116
|
+
|
|
117
|
+
return { repo: r, actual, pruned }
|
|
118
|
+
})
|
|
119
|
+
spinner.stop()
|
|
120
|
+
|
|
121
|
+
const checked = results.filter((r) => r.actual)
|
|
122
|
+
if (checked.length === 0) {
|
|
123
|
+
console.log(pc.dim('Default branch: skipped — could not reach GitHub for any package (offline, or `gh` not authenticated).'))
|
|
124
|
+
} else {
|
|
125
|
+
const drifted = checked.filter((r) => r.actual !== r.repo.defaultBranch)
|
|
126
|
+
for (const { repo, actual } of drifted) {
|
|
127
|
+
const fixResult = git(repo.path, ['remote', 'set-head', 'origin', '--auto'])
|
|
128
|
+
if (fixResult.ok) {
|
|
129
|
+
ok(`${repo.dir}: local cache said "${repo.defaultBranch}", GitHub says "${actual}" — refreshed the local cache.`)
|
|
130
|
+
} else {
|
|
131
|
+
fail(`${repo.dir}: local cache said "${repo.defaultBranch}", GitHub says "${actual}" — could not refresh it (git remote set-head failed).`)
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
if (drifted.length === 0) {
|
|
135
|
+
ok(`${checked.length} package(s) checked — local default-branch cache matches GitHub.`)
|
|
136
|
+
}
|
|
137
|
+
const uncheckedCount = repos.length - checked.length
|
|
138
|
+
if (uncheckedCount > 0) {
|
|
139
|
+
console.log(pc.dim(` (${uncheckedCount} package(s) could not be checked against GitHub.)`))
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const pruned = results.filter((r) => r.pruned.length > 0)
|
|
144
|
+
if (pruned.length > 0) {
|
|
145
|
+
for (const { repo, pruned: branches } of pruned) {
|
|
146
|
+
ok(`${repo.dir}: pruned ${branches.length} stale remote-tracking ref(s) (${branches.join(', ')}).`)
|
|
147
|
+
}
|
|
148
|
+
} else {
|
|
149
|
+
ok('No stale remote-tracking refs to prune.')
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Whether each repo's local default branch actually matches origin —
|
|
154
|
+
// surfaced here proactively so it's known before a `switch-master` run
|
|
155
|
+
// fails partway on a fast-forward it can't do, or before `bump`/`tag`
|
|
156
|
+
// build a branch from a base that isn't what it looks like locally.
|
|
157
|
+
async function checkBranchSync(repos) {
|
|
158
|
+
const spinner = startSpinner(`Fetching and comparing ${repos.length} package(s) against origin...`)
|
|
159
|
+
const results = await pMap(repos, async (r) => {
|
|
160
|
+
const fetchResult = await gitAsync(r.path, ['fetch', 'origin'])
|
|
161
|
+
if (!fetchResult.ok) return { repo: r, error: 'git fetch failed' }
|
|
162
|
+
|
|
163
|
+
const branch = r.defaultBranch
|
|
164
|
+
const countResult = await gitAsync(r.path, ['rev-list', '--left-right', '--count', `${branch}...origin/${branch}`])
|
|
165
|
+
if (!countResult.ok) return { repo: r, error: `no local branch "${branch}" to compare` }
|
|
166
|
+
|
|
167
|
+
const [ahead, behind] = countResult.stdout.split(/\s+/).map(Number)
|
|
168
|
+
return { repo: r, ahead, behind }
|
|
169
|
+
})
|
|
170
|
+
spinner.stop()
|
|
171
|
+
|
|
172
|
+
for (const { repo, error } of results.filter((r) => r.error)) {
|
|
173
|
+
warn(`${repo.dir}: could not check sync status (${error}).`)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const withCounts = results.filter((r) => !r.error)
|
|
177
|
+
const diverged = withCounts.filter((r) => r.ahead > 0 && r.behind > 0)
|
|
178
|
+
const behindOnly = withCounts.filter((r) => r.ahead === 0 && r.behind > 0)
|
|
179
|
+
const aheadOnly = withCounts.filter((r) => r.ahead > 0 && r.behind === 0)
|
|
180
|
+
const inSync = withCounts.filter((r) => r.ahead === 0 && r.behind === 0)
|
|
181
|
+
|
|
182
|
+
for (const { repo, ahead, behind } of diverged) {
|
|
183
|
+
warn(
|
|
184
|
+
`${repo.dir}: diverged from origin/${repo.defaultBranch} — ${ahead} local commit(s) not on origin, ${behind} commit(s) behind. A fast-forward won't work; resolve by hand.`,
|
|
185
|
+
)
|
|
186
|
+
}
|
|
187
|
+
if (behindOnly.length > 0) {
|
|
188
|
+
warn(
|
|
189
|
+
`${behindOnly.length} repo(s) are behind origin (safe to fast-forward with \`polyrepo switch-master\`): ${behindOnly
|
|
190
|
+
.map((r) => r.repo.dir)
|
|
191
|
+
.join(', ')}`,
|
|
192
|
+
)
|
|
193
|
+
}
|
|
194
|
+
if (aheadOnly.length > 0) {
|
|
195
|
+
console.log(
|
|
196
|
+
pc.dim(
|
|
197
|
+
`${aheadOnly.length} repo(s) have local commits not yet pushed to origin: ${aheadOnly.map((r) => r.repo.dir).join(', ')}`,
|
|
198
|
+
),
|
|
199
|
+
)
|
|
200
|
+
}
|
|
201
|
+
if (inSync.length === withCounts.length && withCounts.length > 0) {
|
|
202
|
+
ok(`${inSync.length} package(s) checked — all in sync with origin.`)
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Not auto-fixable — enabling branch protection is a deliberate policy
|
|
207
|
+
// choice (required reviewers, status checks, etc.), not something to
|
|
208
|
+
// configure on someone's behalf. Just surfaces it, since this whole tool
|
|
209
|
+
// assumes every managed repo requires a PR to reach its default branch.
|
|
210
|
+
async function checkBranchProtection(repos) {
|
|
211
|
+
const spinner = startSpinner(`Checking ${repos.length} package(s) for branch protection...`)
|
|
212
|
+
const results = await pMap(repos, async (r) => {
|
|
213
|
+
const result = await ghAsync(r.path, ['api', `repos/{owner}/{repo}/branches/${r.defaultBranch}`, '--jq', '.protected'])
|
|
214
|
+
return result.ok && result.stdout ? { repo: r, protected: result.stdout === 'true' } : null
|
|
215
|
+
})
|
|
216
|
+
spinner.stop()
|
|
217
|
+
|
|
218
|
+
const checked = results.filter(Boolean)
|
|
219
|
+
if (checked.length === 0) {
|
|
220
|
+
console.log(pc.dim('Skipped — could not reach GitHub for any package (offline, or `gh` not authenticated).'))
|
|
221
|
+
return
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const unprotected = checked.filter((r) => !r.protected)
|
|
225
|
+
for (const { repo } of unprotected) {
|
|
226
|
+
warn(`${repo.dir}: default branch "${repo.defaultBranch}" has no branch protection — pushes/merges to it aren't guarded.`)
|
|
227
|
+
}
|
|
228
|
+
if (unprotected.length === 0) {
|
|
229
|
+
ok(`${checked.length} package(s) checked — default branch is protected on all of them.`)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const uncheckedCount = repos.length - checked.length
|
|
233
|
+
if (uncheckedCount > 0) {
|
|
234
|
+
console.log(pc.dim(` (${uncheckedCount} package(s) could not be checked against GitHub.)`))
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// `bump` merges through a PR with --delete-branch=false (see github.js),
|
|
239
|
+
// so every completed bump leaves its branch behind, locally and on
|
|
240
|
+
// origin, forever. This only ever offers to delete the *local* copy —
|
|
241
|
+
// deleting the one on origin is more sensitive shared state, not
|
|
242
|
+
// something to fold into an opt-in local cleanup.
|
|
243
|
+
async function findStaleBumpBranches(repos) {
|
|
244
|
+
const perRepo = await pMap(repos, async (r) => {
|
|
245
|
+
const branchesResult = await gitAsync(r.path, ['for-each-ref', 'refs/heads', '--format=%(refname:short)'])
|
|
246
|
+
if (!branchesResult.ok) return []
|
|
247
|
+
const candidates = branchesResult.stdout.split('\n').filter(Boolean).filter(isBumpBranchName)
|
|
248
|
+
if (candidates.length === 0) return []
|
|
249
|
+
|
|
250
|
+
const withStatus = await pMap(candidates, async (branch) => {
|
|
251
|
+
const prResult = await ghAsync(r.path, ['pr', 'list', '--head', branch, '--state', 'merged', '--json', 'number'])
|
|
252
|
+
let merged = false
|
|
253
|
+
if (prResult.ok && prResult.stdout) {
|
|
254
|
+
try {
|
|
255
|
+
merged = JSON.parse(prResult.stdout).length > 0
|
|
256
|
+
} catch {
|
|
257
|
+
merged = false
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return { repo: r, branch, merged }
|
|
261
|
+
})
|
|
262
|
+
return withStatus.filter((b) => b.merged)
|
|
263
|
+
})
|
|
264
|
+
return perRepo.flat()
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async function checkStaleBumpBranches(repos, { cleanBranches }) {
|
|
268
|
+
const spinner = startSpinner(`Checking ${repos.length} package(s) for leftover bump branches with a merged PR...`)
|
|
269
|
+
const stale = await findStaleBumpBranches(repos)
|
|
270
|
+
spinner.stop()
|
|
271
|
+
|
|
272
|
+
if (stale.length === 0) {
|
|
273
|
+
ok('No stale bump branches found.')
|
|
274
|
+
return
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (!cleanBranches) {
|
|
278
|
+
warn(
|
|
279
|
+
`${stale.length} stale bump branch(es) found across ${new Set(stale.map((s) => s.repo.dir)).size} package(s) — run \`polyrepo doctor --clean-branches\` to review and delete them.`,
|
|
280
|
+
)
|
|
281
|
+
return
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const columns = [{ value: (s) => s.repo.dir }, { value: (s) => s.branch, style: (s, t) => pc.dim(t) }]
|
|
285
|
+
const widths = columnWidths(stale, columns)
|
|
286
|
+
const choices = stale.map((s) => ({
|
|
287
|
+
name: formatRow(s, columns, widths),
|
|
288
|
+
value: s,
|
|
289
|
+
checked: true,
|
|
290
|
+
}))
|
|
291
|
+
|
|
292
|
+
const selected = await checkbox({
|
|
293
|
+
message: 'Pick stale bump branches to delete locally (their PR is already merged):',
|
|
294
|
+
pageSize: 20,
|
|
295
|
+
theme: promptTheme,
|
|
296
|
+
choices,
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
if (selected.length === 0) {
|
|
300
|
+
console.log(pc.dim('Nothing selected.'))
|
|
301
|
+
return
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const proceed = await confirm({
|
|
305
|
+
message: `Delete ${selected.length} local branch(es)? Only the local branch pointer goes away — the commits are already merged into the default branch.`,
|
|
306
|
+
default: true,
|
|
307
|
+
})
|
|
308
|
+
if (!proceed) {
|
|
309
|
+
console.log(pc.dim('Cancelled.'))
|
|
310
|
+
return
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
for (const { repo, branch } of selected) {
|
|
314
|
+
const result = git(repo.path, ['branch', '-d', branch])
|
|
315
|
+
if (result.ok) ok(`${repo.dir}: deleted local branch ${branch}.`)
|
|
316
|
+
else fail(`${repo.dir}: could not delete ${branch} (git branch -d failed — it may not be fully merged locally).`)
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
54
320
|
function checkNode() {
|
|
55
321
|
const [major] = process.versions.node.split('.').map(Number)
|
|
56
322
|
if (major >= 20) {
|
package/src/commands/list.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import pc from 'picocolors'
|
|
2
2
|
import { discoverRepos, inspectRepos } from '../repos.js'
|
|
3
|
-
import { MASTER_BRANCH } from '../config.js'
|
|
4
3
|
import { loadConfig } from '../loadConfig.js'
|
|
5
4
|
import { tagName, tagExistsAsync } from '../tags.js'
|
|
6
5
|
import { releaseExistsAsync } from '../release.js'
|
|
@@ -41,7 +40,7 @@ export async function listCommand({ configPath, quick = false, showPath = false,
|
|
|
41
40
|
{
|
|
42
41
|
label: 'Branch',
|
|
43
42
|
value: (r) => r.branch ?? '(detached)',
|
|
44
|
-
style: (r, text) => (r.branch ===
|
|
43
|
+
style: (r, text) => (r.branch === r.defaultBranch ? pc.dim(text) : pc.yellow(text)),
|
|
45
44
|
},
|
|
46
45
|
{
|
|
47
46
|
label: 'Git',
|
package/src/commands/publish.js
CHANGED
|
@@ -5,19 +5,29 @@ import { loadConfig } from '../loadConfig.js'
|
|
|
5
5
|
import { npm } from '../exec.js'
|
|
6
6
|
import { fetchPublishedVersionAsync } from '../registry.js'
|
|
7
7
|
import { selectPackages } from '../selectPackages.js'
|
|
8
|
+
import { filterByNames } from '../filterByNames.js'
|
|
8
9
|
import { pMap } from '../pMap.js'
|
|
9
|
-
import { heading, stepHeading, ok, fail, columnWidths, formatRow } from '../ui.js'
|
|
10
|
+
import { heading, stepHeading, ok, fail, warn, columnWidths, formatRow } from '../ui.js'
|
|
10
11
|
|
|
11
12
|
export async function publishCommand({ configPath, packages, yes = false, dryRun = false } = {}) {
|
|
12
13
|
const config = loadConfig({ configPath })
|
|
13
|
-
const
|
|
14
|
-
if (
|
|
14
|
+
const allRepos = (await inspectRepos(discoverRepos(config))).filter((r) => r.version)
|
|
15
|
+
if (allRepos.length === 0) {
|
|
15
16
|
console.log(pc.yellow('No repos found.'))
|
|
16
17
|
return
|
|
17
18
|
}
|
|
18
19
|
|
|
19
20
|
heading('Publish to npm')
|
|
20
21
|
|
|
22
|
+
// Narrowed to --packages up front (a no-op when it wasn't given) — no
|
|
23
|
+
// reason to hit the registry for, or print the status of, packages
|
|
24
|
+
// nobody asked to publish.
|
|
25
|
+
const repos = filterByNames(allRepos, packages)
|
|
26
|
+
if (repos.length === 0) {
|
|
27
|
+
console.log(pc.dim('Nothing selected.'))
|
|
28
|
+
return
|
|
29
|
+
}
|
|
30
|
+
|
|
21
31
|
console.log(pc.dim(`Checking ${repos.length} package(s) against the registry...`))
|
|
22
32
|
// Each repo's registry lookup is a real network round trip — running them
|
|
23
33
|
// all at once instead of one at a time is where this actually pays off.
|
|
@@ -34,7 +44,11 @@ export async function publishCommand({ configPath, packages, yes = false, dryRun
|
|
|
34
44
|
|
|
35
45
|
const selected = await selectPackages({
|
|
36
46
|
items: withRegistry,
|
|
37
|
-
packages
|
|
47
|
+
// repos above is already the exact --packages match — passing those
|
|
48
|
+
// same dir names back through here just skips the checkbox (as
|
|
49
|
+
// before) without filterByNames re-warning about anything, since
|
|
50
|
+
// there's nothing left for it to not find.
|
|
51
|
+
packages: packages ? withRegistry.map((r) => r.dir) : undefined,
|
|
38
52
|
message: 'Pick packages to publish:',
|
|
39
53
|
buildChoice: (all) => {
|
|
40
54
|
const columns = [
|
|
@@ -68,6 +82,8 @@ export async function publishCommand({ configPath, packages, yes = false, dryRun
|
|
|
68
82
|
}
|
|
69
83
|
}
|
|
70
84
|
|
|
85
|
+
if (!dryRun && !(await ensureNpmLogin())) return
|
|
86
|
+
|
|
71
87
|
let index = 0
|
|
72
88
|
for (const repo of selected) {
|
|
73
89
|
index += 1
|
|
@@ -80,3 +96,24 @@ export async function publishCommand({ configPath, packages, yes = false, dryRun
|
|
|
80
96
|
else ok(`Published ${repo.name}@${repo.version}${dryRun ? ' (dry run).' : '.'}`)
|
|
81
97
|
}
|
|
82
98
|
}
|
|
99
|
+
|
|
100
|
+
// Without this, a batch of several packages would only find out about a
|
|
101
|
+
// missing/expired npm login at the very end of the first `npm publish` —
|
|
102
|
+
// after everything before it in the run (fetch, checkout, confirmation)
|
|
103
|
+
// already succeeded. Checked once for the whole batch, not per package,
|
|
104
|
+
// since npm auth isn't per-repo. `npm login` needs a real terminal — it
|
|
105
|
+
// can open a browser for npm's web-based OTP flow, or prompt directly.
|
|
106
|
+
async function ensureNpmLogin() {
|
|
107
|
+
if (npm('.', ['whoami'], { quiet: true }).ok) return true
|
|
108
|
+
|
|
109
|
+
warn('Not logged in to npm — running `npm login` first.')
|
|
110
|
+
npm('.', ['login'], { interactive: true })
|
|
111
|
+
|
|
112
|
+
if (npm('.', ['whoami'], { quiet: true }).ok) {
|
|
113
|
+
ok('Logged in to npm.')
|
|
114
|
+
return true
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
fail('npm login did not succeed — aborting before publishing anything.')
|
|
118
|
+
return false
|
|
119
|
+
}
|
package/src/commands/release.js
CHANGED
|
@@ -6,19 +6,29 @@ import { tagName, tagExists } from '../tags.js'
|
|
|
6
6
|
import { releaseExistsAsync, createRelease } from '../release.js'
|
|
7
7
|
import { extractChangelogSection } from '../changelog.js'
|
|
8
8
|
import { selectPackages } from '../selectPackages.js'
|
|
9
|
+
import { filterByNames } from '../filterByNames.js'
|
|
9
10
|
import { pMap } from '../pMap.js'
|
|
10
11
|
import { heading, stepHeading, ok, fail, columnWidths, formatRow } from '../ui.js'
|
|
11
12
|
|
|
12
13
|
export async function releaseCommand({ configPath, packages, yes = false, dryRun = false } = {}) {
|
|
13
14
|
const config = loadConfig({ configPath })
|
|
14
|
-
const
|
|
15
|
-
if (
|
|
15
|
+
const allRepos = (await inspectRepos(discoverRepos(config))).filter((r) => r.version)
|
|
16
|
+
if (allRepos.length === 0) {
|
|
16
17
|
console.log(pc.yellow('No repos found.'))
|
|
17
18
|
return
|
|
18
19
|
}
|
|
19
20
|
|
|
20
21
|
heading('GitHub releases')
|
|
21
22
|
|
|
23
|
+
// Narrowed to --packages up front (a no-op when it wasn't given) — no
|
|
24
|
+
// reason to check, or print the tag/release status of, packages nobody
|
|
25
|
+
// asked about.
|
|
26
|
+
const repos = filterByNames(allRepos, packages)
|
|
27
|
+
if (repos.length === 0) {
|
|
28
|
+
console.log(pc.dim('Nothing selected.'))
|
|
29
|
+
return
|
|
30
|
+
}
|
|
31
|
+
|
|
22
32
|
console.log(pc.dim(`Checking ${repos.length} package(s) for a tag and an existing release...`))
|
|
23
33
|
// A release always targets `v<local version>` — the tag `polyrepo bump`
|
|
24
34
|
// creates. No tag for the current version means there's nothing to
|
|
@@ -48,7 +58,10 @@ export async function releaseCommand({ configPath, packages, yes = false, dryRun
|
|
|
48
58
|
|
|
49
59
|
const selected = await selectPackages({
|
|
50
60
|
items: withStatus,
|
|
51
|
-
packages
|
|
61
|
+
// withStatus is already the exact --packages match — passing those
|
|
62
|
+
// same dir names back here just skips the checkbox without
|
|
63
|
+
// re-warning about anything (see publish.js for the same pattern).
|
|
64
|
+
packages: packages ? withStatus.map((r) => r.dir) : undefined,
|
|
52
65
|
message: 'Pick packages to create a GitHub Release for:',
|
|
53
66
|
buildChoice: (all) => {
|
|
54
67
|
const columns = [
|
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
import { confirm } from '@inquirer/prompts'
|
|
2
2
|
import pc from 'picocolors'
|
|
3
3
|
import { discoverRepos, inspectRepos } from '../repos.js'
|
|
4
|
-
import { MASTER_BRANCH } from '../config.js'
|
|
5
4
|
import { loadConfig } from '../loadConfig.js'
|
|
6
5
|
import { syncMaster } from '../masterSync.js'
|
|
7
6
|
import { selectPackages } from '../selectPackages.js'
|
|
8
7
|
import { heading, stepHeading, ok, fail, warn, columnWidths, formatRow } from '../ui.js'
|
|
9
8
|
import { startSpinner } from '../spinner.js'
|
|
10
9
|
|
|
11
|
-
export async function switchMasterCommand({ configPath, packages, yes = false } = {}) {
|
|
10
|
+
export async function switchMasterCommand({ configPath, packages, yes = false, force = false } = {}) {
|
|
12
11
|
const config = loadConfig({ configPath })
|
|
13
12
|
const discovered = discoverRepos(config)
|
|
14
13
|
if (discovered.length === 0) {
|
|
@@ -16,7 +15,7 @@ export async function switchMasterCommand({ configPath, packages, yes = false }
|
|
|
16
15
|
return
|
|
17
16
|
}
|
|
18
17
|
|
|
19
|
-
heading(
|
|
18
|
+
heading('Switch to default branch')
|
|
20
19
|
|
|
21
20
|
const spinner = startSpinner(`Checking ${discovered.length} package(s)...`)
|
|
22
21
|
const repos = await inspectRepos(discovered)
|
|
@@ -25,16 +24,21 @@ export async function switchMasterCommand({ configPath, packages, yes = false }
|
|
|
25
24
|
const selected = await selectPackages({
|
|
26
25
|
items: repos,
|
|
27
26
|
packages,
|
|
28
|
-
message:
|
|
27
|
+
message: "Pick repos to switch to their default branch and update:",
|
|
29
28
|
buildChoice: (all) => {
|
|
30
29
|
const columns = [{ value: (r) => r.dir }]
|
|
31
30
|
const widths = columnWidths(all, columns)
|
|
32
31
|
return (r) => ({
|
|
33
32
|
name:
|
|
34
33
|
formatRow(r, columns, widths) +
|
|
35
|
-
pc.dim(
|
|
34
|
+
pc.dim(
|
|
35
|
+
` (currently on: ${r.branch ?? '(detached)'}${r.clean ? '' : ', dirty'}${
|
|
36
|
+
r.branch !== r.defaultBranch ? `, default: ${r.defaultBranch}` : ''
|
|
37
|
+
})`,
|
|
38
|
+
) +
|
|
39
|
+
(force && !r.clean ? pc.red(' will discard uncommitted changes') : ''),
|
|
36
40
|
value: r,
|
|
37
|
-
checked: r.branch !==
|
|
41
|
+
checked: r.branch !== r.defaultBranch,
|
|
38
42
|
})
|
|
39
43
|
},
|
|
40
44
|
})
|
|
@@ -44,10 +48,15 @@ export async function switchMasterCommand({ configPath, packages, yes = false }
|
|
|
44
48
|
return
|
|
45
49
|
}
|
|
46
50
|
|
|
51
|
+
const dirtyCount = selected.filter((r) => !r.clean).length
|
|
52
|
+
|
|
47
53
|
if (!yes) {
|
|
48
54
|
const proceed = await confirm({
|
|
49
|
-
message:
|
|
50
|
-
|
|
55
|
+
message:
|
|
56
|
+
force && dirtyCount > 0
|
|
57
|
+
? `Switch ${selected.length} repo(s) to their default branch — ${dirtyCount} of them dirty, their uncommitted changes will be permanently discarded. Continue?`
|
|
58
|
+
: `Switch ${selected.length} repo(s) to their default branch and fast-forward?`,
|
|
59
|
+
default: !force,
|
|
51
60
|
})
|
|
52
61
|
if (!proceed) {
|
|
53
62
|
console.log(pc.dim('Cancelled.'))
|
|
@@ -60,17 +69,21 @@ export async function switchMasterCommand({ configPath, packages, yes = false }
|
|
|
60
69
|
index += 1
|
|
61
70
|
stepHeading(index, selected.length, repo.dir)
|
|
62
71
|
|
|
63
|
-
if (!repo.clean) {
|
|
72
|
+
if (!repo.clean && !force) {
|
|
64
73
|
warn(`Working tree is dirty — skipping to avoid discarding local changes.`)
|
|
65
74
|
continue
|
|
66
75
|
}
|
|
67
76
|
|
|
68
|
-
const result = syncMaster(repo)
|
|
77
|
+
const result = syncMaster(repo, { force })
|
|
69
78
|
if (!result.ok) {
|
|
70
79
|
fail(result.message)
|
|
71
80
|
continue
|
|
72
81
|
}
|
|
73
82
|
|
|
74
|
-
ok(
|
|
83
|
+
ok(
|
|
84
|
+
force && !repo.clean
|
|
85
|
+
? `Discarded local changes — now on ${repo.defaultBranch}, matching origin.`
|
|
86
|
+
: `Now on ${repo.defaultBranch}, up to date with origin.`,
|
|
87
|
+
)
|
|
75
88
|
}
|
|
76
89
|
}
|
package/src/commands/tag.js
CHANGED
|
@@ -5,27 +5,37 @@ import { loadConfig } from '../loadConfig.js'
|
|
|
5
5
|
import { syncMaster } from '../masterSync.js'
|
|
6
6
|
import { tagName, tagExists, tagExistsAsync, createAndPushTag } from '../tags.js'
|
|
7
7
|
import { selectPackages } from '../selectPackages.js'
|
|
8
|
+
import { filterByNames } from '../filterByNames.js'
|
|
8
9
|
import { pMap } from '../pMap.js'
|
|
9
10
|
import { heading, stepHeading, ok, fail, columnWidths, formatRow } from '../ui.js'
|
|
10
11
|
import { releaseOne } from './release.js'
|
|
11
12
|
|
|
12
13
|
// For packages whose version was bumped outside `polyrepo bump` (or before `bump`
|
|
13
14
|
// started tagging), there's no `v<version>` tag yet — `polyrepo release` refuses
|
|
14
|
-
// to touch those. This puts just the tag on the current version, on
|
|
15
|
-
//
|
|
16
|
-
// PR — no need to bump again just to get a tag. Offers to
|
|
17
|
-
// right after, since "I just caught this package's tag up" and "I
|
|
18
|
-
// release for it" are almost always the same reason to run this.
|
|
15
|
+
// to touch those. This puts just the tag on the current version, on the
|
|
16
|
+
// default branch's current tip, without touching the version number or
|
|
17
|
+
// opening a PR — no need to bump again just to get a tag. Offers to
|
|
18
|
+
// release right after, since "I just caught this package's tag up" and "I
|
|
19
|
+
// want a release for it" are almost always the same reason to run this.
|
|
19
20
|
export async function tagCommand({ configPath, packages, yes = false, dryRun = false, release = false } = {}) {
|
|
20
21
|
const config = loadConfig({ configPath })
|
|
21
|
-
const
|
|
22
|
-
if (
|
|
22
|
+
const allRepos = (await inspectRepos(discoverRepos(config))).filter((r) => r.version)
|
|
23
|
+
if (allRepos.length === 0) {
|
|
23
24
|
console.log(pc.yellow('No repos found.'))
|
|
24
25
|
return
|
|
25
26
|
}
|
|
26
27
|
|
|
27
28
|
heading('Tag current versions')
|
|
28
29
|
|
|
30
|
+
// Narrowed to --packages up front (a no-op when it wasn't given) — no
|
|
31
|
+
// reason to check, or print the tag status of, packages nobody asked
|
|
32
|
+
// about.
|
|
33
|
+
const repos = filterByNames(allRepos, packages)
|
|
34
|
+
if (repos.length === 0) {
|
|
35
|
+
console.log(pc.dim('Nothing selected.'))
|
|
36
|
+
return
|
|
37
|
+
}
|
|
38
|
+
|
|
29
39
|
console.log(pc.dim(`Checking ${repos.length} package(s) for an existing tag...`))
|
|
30
40
|
const withTag = await pMap(repos, async (r) => {
|
|
31
41
|
const tag = tagName(r.version)
|
|
@@ -36,7 +46,10 @@ export async function tagCommand({ configPath, packages, yes = false, dryRun = f
|
|
|
36
46
|
|
|
37
47
|
const selected = await selectPackages({
|
|
38
48
|
items: withTag,
|
|
39
|
-
packages
|
|
49
|
+
// withTag is already the exact --packages match — passing those same
|
|
50
|
+
// dir names back here just skips the checkbox without re-warning
|
|
51
|
+
// about anything (see publish.js for the same pattern).
|
|
52
|
+
packages: packages ? withTag.map((r) => r.dir) : undefined,
|
|
40
53
|
message: 'Pick packages to tag at their current version:',
|
|
41
54
|
buildChoice: (all) => {
|
|
42
55
|
const columns = [
|
|
@@ -80,7 +93,7 @@ export async function tagCommand({ configPath, packages, yes = false, dryRun = f
|
|
|
80
93
|
fail(syncResult.message)
|
|
81
94
|
continue
|
|
82
95
|
}
|
|
83
|
-
ok(
|
|
96
|
+
ok(`${repo.defaultBranch} is up to date.`)
|
|
84
97
|
|
|
85
98
|
// Re-checked here (not just trusting the table above) in case it
|
|
86
99
|
// changed between listing and now — same reasoning as bump's
|
package/src/config.js
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
|
-
export const MASTER_BRANCH = 'master'
|
|
2
|
-
|
|
3
1
|
export function bumpBranchName(version) {
|
|
4
2
|
return `${version}-version-bump`
|
|
5
3
|
}
|
|
4
|
+
|
|
5
|
+
// The inverse check — used by `doctor --clean-branches` to recognize a
|
|
6
|
+
// leftover bump branch among a repo's local branches without knowing
|
|
7
|
+
// which version it was for. Matches whatever bumpBranchName can produce:
|
|
8
|
+
// a semver-ish version (optionally with a pre-release/build suffix, same
|
|
9
|
+
// as bumpVersion's output) followed by "-version-bump".
|
|
10
|
+
const BUMP_BRANCH_PATTERN = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?-version-bump$/
|
|
11
|
+
|
|
12
|
+
export function isBumpBranchName(name) {
|
|
13
|
+
return BUMP_BRANCH_PATTERN.test(name)
|
|
14
|
+
}
|
package/src/github.js
CHANGED
|
@@ -19,7 +19,7 @@ function findPr(repo, branch, state) {
|
|
|
19
19
|
// Figures out where a previous (possibly interrupted) bump attempt for this
|
|
20
20
|
// exact branch left off, so a re-run resumes instead of failing on
|
|
21
21
|
// "branch already exists" or opening a duplicate PR:
|
|
22
|
-
// - 'merged' — the PR already landed; only a local
|
|
22
|
+
// - 'merged' — the PR already landed; only a local default-branch sync is left.
|
|
23
23
|
// - 'open' — a PR exists and just needs merging.
|
|
24
24
|
// - 'branch' — the branch was pushed but no PR was ever opened.
|
|
25
25
|
// - 'fresh' — nothing exists yet, do the full flow.
|