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.
@@ -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 { heading, ok, fail, warn } from '../ui.js'
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 offMaster = repos.filter((r) => r.branch !== 'master')
32
- if (offMaster.length > 0) {
33
- warn(`${offMaster.length} repo(s) are not on master: ${offMaster.map((r) => r.dir).join(', ')}`)
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) {
@@ -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 === MASTER_BRANCH ? pc.dim(text) : pc.yellow(text)),
43
+ style: (r, text) => (r.branch === r.defaultBranch ? pc.dim(text) : pc.yellow(text)),
45
44
  },
46
45
  {
47
46
  label: 'Git',
@@ -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 repos = (await inspectRepos(discoverRepos(config))).filter((r) => r.version)
14
- if (repos.length === 0) {
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
+ }
@@ -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 repos = (await inspectRepos(discoverRepos(config))).filter((r) => r.version)
15
- if (repos.length === 0) {
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(`Switch to ${MASTER_BRANCH}`)
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: 'Pick repos to switch to master and update:',
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(` (currently on: ${r.branch ?? '(detached)'}${r.clean ? '' : ', dirty'})`),
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 !== MASTER_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: `Switch ${selected.length} repo(s) to ${MASTER_BRANCH} and fast-forward?`,
50
- default: true,
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(`Now on ${MASTER_BRANCH}, up to date with origin.`)
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
  }
@@ -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
- // master's current tip, without touching the version number or opening a
16
- // PR — no need to bump again just to get a tag. Offers to release
17
- // right after, since "I just caught this package's tag up" and "I want a
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 repos = (await inspectRepos(discoverRepos(config))).filter((r) => r.version)
22
- if (repos.length === 0) {
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('master is up to date.')
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 master sync is left.
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.