polyrepo-cli 1.0.1 → 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 CHANGED
@@ -23,12 +23,16 @@ with `--dry-run` before anything actually changes.
23
23
  - **`prs`** — one table of every open pull request across every
24
24
  package (`gh pr list`) — useful after an interrupted `bump` run to
25
25
  see what's still waiting to be merged.
26
- - **`doctor`** — a one-command health check: is the environment set up
27
- correctly (Node/git/gh/npm, authentication), and does any local
28
- package still depend on an incompatible version of another local
29
- package.
30
- - **`switch-master`** — fast-forward selected repos to an up-to-date
31
- `master`.
26
+ - **`doctor`** — a one-command health check: environment (Node/git/gh/npm,
27
+ authentication), branch health (default-branch name drift, stale
28
+ remote-tracking refs, divergence from origin, detached `HEAD`,
29
+ missing branch protection, leftover `bump` branches — with a few
30
+ safe, non-destructive self-repairs along the way, and
31
+ `--clean-branches` for an interactive local-branch cleanup), and
32
+ cross-package dependency drift.
33
+ - **`switch-master`** — fast-forward selected repos to their up-to-date
34
+ default branch, whatever it's actually named (`master`, `main`, or
35
+ anything else — detected per repo, not assumed).
32
36
  - **`bump`** — bump a package's version (patch by default, or
33
37
  `--minor`/`--major`) through a branch → PR → merge, then tag the
34
38
  release. Safe to re-run if a previous attempt was interrupted
@@ -265,21 +269,77 @@ polyrepo prs
265
269
  polyrepo prs --packages vue-toast-kit,os-detect
266
270
  ```
267
271
 
268
- ### `polyrepo doctor`
272
+ ### `polyrepo doctor [options]`
269
273
 
270
- A read-only health check in three sections:
274
+ A health check in seven sections — mostly read-only diagnosis, plus a
275
+ few small, non-destructive self-repairs:
271
276
 
272
277
  1. **Environment** — Node.js version (20+ required), whether
273
278
  `git`/`gh`/`npm` are on `PATH`, and whether `gh`/`npm` are
274
279
  authenticated (npm auth is only a warning — it's only needed for
275
280
  `publish`).
276
281
  2. **Config** — how many packages the current config actually
277
- resolves to, and which repos are dirty or off `master`.
278
- 3. **Cross-package dependencies** the same dependency-drift check
282
+ resolves to, and which repos are dirty, in a detached `HEAD` state,
283
+ or off their default branch.
284
+ 3. **Remote sync** — two related repairs, both per-repo pointer
285
+ refreshes that never touch a file, branch, or commit:
286
+ - compares each repo's locally cached default-branch name (the
287
+ same value `switch-master`/`bump`/`tag` all use — see
288
+ `switch-master` above) against what GitHub actually reports
289
+ right now. Git never refreshes that local cache on its own, so
290
+ renaming a repo's default branch on GitHub after it was cloned
291
+ would otherwise go unnoticed by every other command forever —
292
+ wherever it's drifted, this fixes it with `git remote set-head
293
+ origin --auto`;
294
+ - runs `git remote prune origin` on every repo, dropping local
295
+ `remotes/origin/x` refs left over for branches already deleted
296
+ on GitHub (these tend to accumulate — every PR branch a `bump`
297
+ or manual workflow ever created and later got deleted on GitHub
298
+ leaves one behind locally until pruned).
299
+
300
+ Both are skipped for a repo GitHub can't be reached for (offline,
301
+ or `gh` not authenticated).
302
+ 4. **Branch sync** — fetches and compares each repo's local default
303
+ branch against `origin/<default>`: **diverged** (both ahead and
304
+ behind — a fast-forward won't work, needs resolving by hand),
305
+ **behind only** (safe to fast-forward with `switch-master`), or
306
+ **ahead only** (local commits not yet pushed). Surfaces this
307
+ before some other command trips over it mid-run instead of after.
308
+ 5. **Branch protection** — whether each repo's default branch
309
+ actually has GitHub branch protection enabled right now.
310
+ Report-only; enabling protection is a policy decision, not
311
+ something this fixes on your behalf.
312
+ 6. **Stale bump branches** — `bump` merges through a PR with the
313
+ branch intentionally left on origin (`--delete-branch=false`, see
314
+ `bump` above), so every completed bump leaves a local branch copy
315
+ behind too, forever. This reports how many local branches match
316
+ `<version>-version-bump` **and** already have a merged PR.
317
+ `--clean-branches` turns that into a checkbox — pick which ones to
318
+ delete locally (`git branch -d`, which refuses instead of forcing
319
+ if a branch somehow isn't actually fully merged locally; the
320
+ branch on origin is never touched, deleting that is out of scope
321
+ here — it's more sensitive shared state).
322
+ 7. **Cross-package dependencies** — the same dependency-drift check
279
323
  that runs at the end of `bump`, available on demand without
280
324
  bumping anything.
281
325
 
282
- Worth running first if any other command is behaving unexpectedly.
326
+ Worth running first if any other command is behaving unexpectedly, any
327
+ time you rename a default branch on GitHub, or just periodically to
328
+ catch accumulated cruft (stale remote-tracking refs, leftover bump
329
+ branches) before it piles up.
330
+
331
+ **Options:**
332
+
333
+ | Flag | What it does |
334
+ | --- | --- |
335
+ | `--clean-branches` | After scanning, show a checkbox of local bump branches whose PR is already merged; delete the ones you pick. |
336
+
337
+ ```bash
338
+ polyrepo doctor
339
+
340
+ # also review and clean up leftover local bump branches
341
+ polyrepo doctor --clean-branches
342
+ ```
283
343
 
284
344
  ```bash
285
345
  polyrepo doctor
@@ -287,22 +347,55 @@ polyrepo doctor
287
347
 
288
348
  ### `polyrepo switch-master` (alias `sm`)
289
349
 
290
- 1. Shows a checkbox list of every repo with its current branch; repos
291
- not currently on `master` are pre-selected.
350
+ Each repo's **default branch is detected per repo**, not assumed
351
+ GitHub itself defaults a new repo to `main`, and plenty of people
352
+ rename it (`master` included), so a mixed folder of repos can easily
353
+ have some on `master` and some on `main`. Detection prefers the
354
+ locally cached `origin/HEAD` ref (no network — set by `git clone`),
355
+ falls back to asking origin directly (`git ls-remote --symref`,
356
+ read-only), then to whichever of `master`/`main` exists as a local
357
+ branch, and finally to `main` (GitHub's own default) if nothing else
358
+ could tell it.
359
+
360
+ 1. Shows a checkbox list of every repo with its current branch (and
361
+ its default branch, when the two differ); repos not currently on
362
+ their default branch are pre-selected.
292
363
  2. After confirming, for each selected repo, one at a time (each
293
364
  step's result prints immediately, not after the whole batch):
294
365
  - a dirty working tree is skipped with a warning, untouched;
295
- - otherwise: `git fetch origin` → `git checkout master`
296
- `git merge --ff-only origin/master`.
297
- 3. If local `master` has diverged from `origin/master` (fast-forward
366
+ - otherwise: `git fetch origin` → `git checkout <default branch>`
367
+ `git merge --ff-only origin/<default branch>`.
368
+ 3. If the local default branch has diverged from origin (fast-forward
298
369
  isn't possible), that repo is reported and left alone to resolve by
299
- hand — no `--force`/`reset --hard` is ever used.
370
+ hand.
371
+
372
+ `--force` changes step 2 and 3 for every selected repo: a dirty
373
+ working tree is no longer skipped, and each repo gets
374
+ `git checkout -f <default branch>` + `git reset --hard
375
+ origin/<default branch>` instead of the safe fast-forward-only merge —
376
+ uncommitted changes to tracked files and any local-only commits on
377
+ that branch are permanently discarded (untracked files are left
378
+ alone, this isn't `git clean`). The checkbox marks which selected
379
+ repos would lose changes, and the proceed confirmation says how many
380
+ and defaults to "No" instead of "Yes" whenever `--force` would
381
+ actually discard something.
382
+
383
+ **Options:**
384
+
385
+ | Flag | What it does |
386
+ | --- | --- |
387
+ | `--packages <a,b,c>` | Package list instead of the interactive checkbox. |
388
+ | `--yes` | Skip the "proceed?" confirmation. |
389
+ | `--force` | Discard uncommitted changes and local-only commits on the default branch, hard-resetting it to origin. |
300
390
 
301
391
  ```bash
302
392
  polyrepo switch-master
303
393
 
304
394
  # no checkbox, specific repos, no confirmation — for scripts
305
395
  polyrepo switch-master --packages vue-toast-kit,os-detect --yes
396
+
397
+ # discard local changes on a repo you don't need anymore
398
+ polyrepo switch-master --packages vue-toast-kit --force
306
399
  ```
307
400
 
308
401
  ### `polyrepo bump [options]`
@@ -313,15 +406,17 @@ polyrepo switch-master --packages vue-toast-kit,os-detect --yes
313
406
  the parts below it to `0`, same as any semver tool). Packages
314
407
  with a dirty working tree are marked — they'll be skipped. The
315
408
  highlighted package's description shows what's actually changed
316
- since the last git tag (`git log <tag>..master`) — if that's empty,
317
- there's probably nothing worth bumping. These previews are computed
318
- for every package in parallel, not one at a time.
409
+ since the last git tag (`git log <tag>..<default branch>`) — if
410
+ that's empty, there's probably nothing worth bumping. These
411
+ previews are computed for every package in parallel, not one at a
412
+ time.
319
413
  2. After confirming, for each selected package, one at a time, with
320
414
  live progress:
321
- 1. `git fetch origin` → `git checkout master`
322
- `git merge --ff-only origin/master` (the bump branch is always
323
- created from an up-to-date master, not whatever branch the repo
324
- happened to be on);
415
+ 1. `git fetch origin` → `git checkout <default branch>`
416
+ `git merge --ff-only origin/<default branch>` (the bump branch
417
+ is always created from an up-to-date default branch detected
418
+ per repo, see `switch-master` above — not whatever branch the
419
+ repo happened to be on);
325
420
  2. **checks the state of a previous attempt** — is there already a
326
421
  merged PR, an open PR, or just a pushed branch named
327
422
  `<new-version>-version-bump` (e.g. `1.2.10-version-bump` — the
@@ -329,8 +424,8 @@ polyrepo switch-master --packages vue-toast-kit,os-detect --yes
329
424
  different bumps never collide). Depending on what's found, it
330
425
  resumes from the right place instead of failing on "branch
331
426
  already exists" or opening a duplicate PR:
332
- - **already merged** — nothing to do (master was already synced
333
- in step 1), go straight to tagging;
427
+ - **already merged** — nothing to do (the default branch was
428
+ already synced in step 1), go straight to tagging;
334
429
  - **open PR exists** — merge that one, don't open a new one;
335
430
  - **branch pushed, no PR** — reuse the branch, open a PR;
336
431
  - **nothing exists** — the full flow from scratch.
@@ -344,7 +439,8 @@ polyrepo switch-master --packages vue-toast-kit,os-detect --yes
344
439
  review, not a finished changelog. Packages without a
345
440
  `CHANGELOG.md` don't get one created. Both files are committed
346
441
  together;
347
- 4. `gh pr create` against `master` (if there isn't one already);
442
+ 4. `gh pr create` against the default branch (if there isn't one
443
+ already);
348
444
  5. with `--wait-checks`: wait for the PR's CI checks via
349
445
  `gh pr checks --watch` (with a real terminal, live-updating). No
350
446
  checks configured isn't an error — there's just nothing to wait
@@ -352,9 +448,9 @@ polyrepo switch-master --packages vue-toast-kit,os-detect --yes
352
448
  skip the merge;
353
449
  6. `gh pr merge --merge` — through a PR, not a direct push, since
354
450
  these repos require it;
355
- 7. `git checkout master` → `git fetch origin` →
356
- `git merge --ff-only origin/master` — local master is synced to
357
- the just-merged PR;
451
+ 7. `git checkout <default branch>` → `git fetch origin` →
452
+ `git merge --ff-only origin/<default branch>` — local default
453
+ branch is synced to the just-merged PR;
358
454
  8. **git tag** `v<new-version>` (e.g. `v1.2.10`) is created and
359
455
  pushed if it doesn't already exist (idempotent, like everything
360
456
  else here — a re-run won't try to create it twice).
@@ -458,9 +554,9 @@ it again or opening a PR:
458
554
  pre-selected; already-tagged ones can still be picked manually
459
555
  (harmless — it just confirms the tag is there).
460
556
  3. After confirming, for each selected package, one at a time:
461
- `git fetch`/`checkout master`/`merge --ff-only` (tags an up-to-date
462
- master, same as `bump`), then creates and pushes the tag if it's
463
- missing.
557
+ `git fetch`/`checkout <default branch>`/`merge --ff-only` (tags an
558
+ up-to-date default branch, same as `bump`), then creates and pushes
559
+ the tag if it's missing.
464
560
  4. If at least one package was actually tagged (and it wasn't a
465
561
  `--dry-run`), it asks: "Create a GitHub Release for the N
466
562
  package(s) just tagged?" — answering yes runs the same process as
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "polyrepo-cli",
3
- "version": "1.0.1",
3
+ "version": "1.0.2",
4
4
  "description": "Interactive CLI for managing a folder of local npm package repos: version bumps through a PR, npm publish, GitHub releases, and cross-package dependency drift checks — all from one tool.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/changes.js CHANGED
@@ -1,21 +1,21 @@
1
- import { MASTER_BRANCH } from './config.js'
2
1
  import { git, gitAsync } from './exec.js'
3
2
  import { pMap } from './pMap.js'
4
3
 
5
4
  const PREVIEW_LIMIT = 5
6
5
 
7
- // Summarizes what changed on local master since the last `v*` tag (or the
8
- // last few commits if there's no tag yet), so the bump checklist can show
9
- // whether a package actually has anything worth releasing. Reflects local
10
- // repo state — run `polyrepo switch-master` first if it might be stale. Run
11
- // through pMap across many repos at once (see describeRecentChangesForAll)
12
- // — this builds the bump checkbox's preview, and waiting on 3 sequential
13
- // git calls per repo, 17 times over, added up to a real pause before the
14
- // prompt even appeared.
6
+ // Summarizes what changed on the local default branch since the last `v*`
7
+ // tag (or the last few commits if there's no tag yet), so the bump
8
+ // checklist can show whether a package actually has anything worth
9
+ // releasing. Reflects local repo state — run `polyrepo switch-master`
10
+ // first if it might be stale. Run through pMap across many repos at once
11
+ // (see describeRecentChangesForAll) — this builds the bump checkbox's
12
+ // preview, and waiting on 3 sequential git calls per repo, 17 times over,
13
+ // added up to a real pause before the prompt even appeared.
15
14
  async function describeRecentChangesAsync(repo) {
16
- const tagResult = await gitAsync(repo.path, ['describe', '--tags', '--abbrev=0', '--match', 'v*', MASTER_BRANCH])
15
+ const branch = repo.defaultBranch
16
+ const tagResult = await gitAsync(repo.path, ['describe', '--tags', '--abbrev=0', '--match', 'v*', branch])
17
17
  const sinceTag = tagResult.ok ? tagResult.stdout : null
18
- const range = sinceTag ? `${sinceTag}..${MASTER_BRANCH}` : MASTER_BRANCH
18
+ const range = sinceTag ? `${sinceTag}..${branch}` : branch
19
19
 
20
20
  const [countResult, logResult] = await Promise.all([
21
21
  gitAsync(repo.path, ['rev-list', '--count', range]),
@@ -34,9 +34,9 @@ export function describeRecentChangesForAll(repos, concurrency) {
34
34
  // Uncapped commit list from the last `v*` tag to HEAD — used right before
35
35
  // committing a version bump (see bump.js) to draft a CHANGELOG.md entry, so
36
36
  // unlike describeRecentChanges (capped for a preview) this needs the whole
37
- // list. Uses HEAD rather than MASTER_BRANCH because it's called from
38
- // exactly where that matters: after checking out the bump branch, whose
39
- // HEAD is master's tip at that point anyway.
37
+ // list. Uses HEAD rather than the repo's default branch because it's
38
+ // called from exactly where that matters: after checking out the bump
39
+ // branch, whose HEAD is the default branch's tip at that point anyway.
40
40
  export function fullCommitLinesSince(repo) {
41
41
  const tagResult = git(repo.path, ['describe', '--tags', '--abbrev=0', '--match', 'v*', 'HEAD'], { quiet: true })
42
42
  const sinceTag = tagResult.ok ? tagResult.stdout : null
@@ -2,7 +2,7 @@ import fs from 'node:fs'
2
2
  import { confirm } from '@inquirer/prompts'
3
3
  import pc from 'picocolors'
4
4
  import { discoverRepos, inspectRepos, readPackageJson } from '../repos.js'
5
- import { MASTER_BRANCH, bumpBranchName } from '../config.js'
5
+ import { bumpBranchName } from '../config.js'
6
6
  import { loadConfig } from '../loadConfig.js'
7
7
  import { bumpVersion, replaceVersionInText } from '../version.js'
8
8
  import { git } from '../exec.js'
@@ -84,7 +84,7 @@ export async function bumpCommand({
84
84
 
85
85
  if (!yes) {
86
86
  const proceed = await confirm({
87
- message: `Bump ${selected.length} package(s), open a PR, and merge each into ${MASTER_BRANCH}?${
87
+ message: `Bump ${selected.length} package(s), open a PR, and merge each into its default branch?${
88
88
  dryRun ? ' (dry run — no changes will actually be pushed)' : ''
89
89
  }`,
90
90
  default: true,
@@ -113,7 +113,7 @@ async function bumpOne(repo, { dryRun, waitChecks }) {
113
113
 
114
114
  const syncResult = syncMaster(repo)
115
115
  if (!syncResult.ok) return fail(syncResult.message)
116
- ok(`${MASTER_BRANCH} is up to date.`)
116
+ ok(`${repo.defaultBranch} is up to date.`)
117
117
 
118
118
  const branchName = bumpBranchName(repo.newVersion)
119
119
  const state = detectBumpState(repo, branchName)
@@ -172,7 +172,7 @@ async function bumpOne(repo, { dryRun, waitChecks }) {
172
172
 
173
173
  if (state.status !== 'open') {
174
174
  const prResult = createPr(repo, {
175
- base: MASTER_BRANCH,
175
+ base: repo.defaultBranch,
176
176
  branch: branchName,
177
177
  title: `chore: bump version to ${repo.newVersion}`,
178
178
  body: `Bump version: ${repo.version} → ${repo.newVersion}.`,
@@ -195,9 +195,9 @@ async function bumpOne(repo, { dryRun, waitChecks }) {
195
195
 
196
196
  const resyncResult = syncMaster(repo)
197
197
  if (!resyncResult.ok) return fail(resyncResult.message)
198
- ok(`Local ${MASTER_BRANCH} synced to origin at ${repo.newVersion}.`)
198
+ ok(`Local ${repo.defaultBranch} synced to origin at ${repo.newVersion}.`)
199
199
  } else {
200
- ok(`Already merged as PR #${state.pr.number} — ${MASTER_BRANCH} already has it.`)
200
+ ok(`Already merged as PR #${state.pr.number} — ${repo.defaultBranch} already has it.`)
201
201
  }
202
202
 
203
203
  const tag = tagName(repo.newVersion)
@@ -213,8 +213,9 @@ async function bumpOne(repo, { dryRun, waitChecks }) {
213
213
  // Reuse a local branch left over from a previous attempt if there is one,
214
214
  // otherwise track the remote branch if a previous attempt got as far as
215
215
  // pushing it, otherwise create it fresh from the current (just-synced)
216
- // master. Trying "reuse" first before falling back is what makes this safe
217
- // to call again after any partial failure without any state bookkeeping.
216
+ // default branch. Trying "reuse" first before falling back is what makes
217
+ // this safe to call again after any partial failure without any state
218
+ // bookkeeping.
218
219
  function checkoutBumpBranch(repo, branchName) {
219
220
  if (git(repo.path, ['checkout', branchName], { quiet: true }).ok) {
220
221
  return { ok: true, reused: true }
@@ -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',
@@ -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
  }
@@ -12,11 +12,11 @@ import { releaseOne } from './release.js'
12
12
 
13
13
  // For packages whose version was bumped outside `polyrepo bump` (or before `bump`
14
14
  // started tagging), there's no `v<version>` tag yet — `polyrepo release` refuses
15
- // to touch those. This puts just the tag on the current version, on
16
- // master's current tip, without touching the version number or opening a
17
- // PR — no need to bump again just to get a tag. Offers to release
18
- // right after, since "I just caught this package's tag up" and "I want a
19
- // 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.
20
20
  export async function tagCommand({ configPath, packages, yes = false, dryRun = false, release = false } = {}) {
21
21
  const config = loadConfig({ configPath })
22
22
  const allRepos = (await inspectRepos(discoverRepos(config))).filter((r) => r.version)
@@ -93,7 +93,7 @@ export async function tagCommand({ configPath, packages, yes = false, dryRun = f
93
93
  fail(syncResult.message)
94
94
  continue
95
95
  }
96
- ok('master is up to date.')
96
+ ok(`${repo.defaultBranch} is up to date.`)
97
97
 
98
98
  // Re-checked here (not just trusting the table above) in case it
99
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.
package/src/index.js CHANGED
@@ -1,4 +1,7 @@
1
1
  #!/usr/bin/env node
2
+ import fs from 'node:fs'
3
+ import path from 'node:path'
4
+ import { fileURLToPath } from 'node:url'
2
5
  import { Command } from 'commander'
3
6
  import pc from 'picocolors'
4
7
  import { listCommand } from './commands/list.js'
@@ -14,6 +17,12 @@ import { outdatedCommand } from './commands/outdated.js'
14
17
  import { prsCommand } from './commands/prs.js'
15
18
  import { cloneCommand } from './commands/clone.js'
16
19
 
20
+ // Read once from package.json rather than a literal string here — the two
21
+ // silently drifted apart before (this file said 1.0.0 while package.json
22
+ // had already moved to 1.0.1).
23
+ const __dirname = path.dirname(fileURLToPath(import.meta.url))
24
+ const { version: CLI_VERSION } = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'))
25
+
17
26
  const program = new Command()
18
27
 
19
28
  const PACKAGES_OPTION = [
@@ -85,9 +94,9 @@ function wrapText(text, width) {
85
94
  program
86
95
  .name('polyrepo')
87
96
  .description(
88
- 'Manage local npm package repos: pick which directories to scan (setup), clone missing ones from GitHub (clone), see their state (list), outdated dependencies (outdated), or open PRs (prs), run a health check (doctor), keep them on an up-to-date master (switch-master), release a version through a PR (bump), publish to npm (publish), tag an already-current version (tag), create GitHub Releases (release), or run any command across every repo (exec).',
97
+ 'Manage local npm package repos: pick which directories to scan (setup), clone missing ones from GitHub (clone), see their state (list), outdated dependencies (outdated), or open PRs (prs), run a health check (doctor), keep them on an up-to-date default branch (switch-master), release a version through a PR (bump), publish to npm (publish), tag an already-current version (tag), create GitHub Releases (release), or run any command across every repo (exec).',
89
98
  )
90
- .version('1.0.0')
99
+ .version(CLI_VERSION)
91
100
  .option(
92
101
  '--config <path>',
93
102
  'Path to a polyrepo.config.json listing roots/packages to scan (default: polyrepo.config.json next to this CLI).',
@@ -115,7 +124,7 @@ Examples:
115
124
  $ polyrepo list Show version + branch for every package
116
125
  $ polyrepo outdated Show outdated dependencies across every package
117
126
  $ polyrepo prs List open pull requests across every package
118
- $ polyrepo switch-master Update selected repos to the latest master
127
+ $ polyrepo switch-master Update selected repos to their latest default branch
119
128
  $ polyrepo bump --dry-run Preview a version bump, nothing is pushed
120
129
  $ polyrepo bump --minor --packages a,b --yes Bump specific packages' minor version, non-interactively
121
130
  $ polyrepo publish Publish packages that are ahead of the registry
@@ -285,41 +294,101 @@ Examples:
285
294
 
286
295
  program
287
296
  .command('doctor')
288
- .description('Check environment (node/git/gh/npm, auth), config, and cross-package dependency drift.')
297
+ .description('Check environment, config, branch health, and dependency drift — with a few safe self-repairs.')
298
+ .option(
299
+ '--clean-branches',
300
+ 'After scanning, show a checkbox of local bump branches whose PR is already merged, and delete the ones you pick.',
301
+ )
289
302
  .addHelpText(
290
303
  'after',
291
304
  `
292
- Read-only. Three sections: Environment (is Node.js new enough, are git/gh/npm
293
- on PATH and authenticated), Config (does polyrepo.config.json resolve to any
294
- packages, which repos are dirty or off master), and Cross-package
295
- dependencies (does any local package's dependencies/devDependencies/
296
- peerDependencies range no longer match another local package's current
297
- version e.g. after a \`bump\` that package's own package.json wasn't
298
- updated for). Run this first if any other command is behaving strangely.
305
+ Seven sections. Most are read-only diagnosis; three include a small,
306
+ non-destructive self-repair:
307
+
308
+ Environment Node.js version, git/gh/npm on PATH and authenticated.
309
+ Config how many packages the config resolves to; which are
310
+ dirty, in a detached HEAD state, or off their
311
+ default branch.
312
+ Remote sync compares each repo's locally cached default-branch
313
+ name against what GitHub reports right now — git
314
+ never refreshes that cache on its own, so a rename
315
+ on GitHub would otherwise go unnoticed by every
316
+ other command forever; drifted ones are fixed with
317
+ \`git remote set-head origin --auto\`. Also runs
318
+ \`git remote prune origin\` on every repo, dropping
319
+ local refs for branches already deleted on GitHub.
320
+ Both are pointer-only fixes — no file, branch, or
321
+ commit is ever touched.
322
+ Branch sync fetches and compares each repo's local default
323
+ branch against origin: diverged (needs manual
324
+ resolution), behind only (safe to fast-forward with
325
+ \`switch-master\`), or ahead only (unpushed local
326
+ commits) — surfaced before a command trips over it.
327
+ Branch protection whether each repo's default branch actually has
328
+ GitHub branch protection enabled. Report-only —
329
+ enabling protection is a policy choice, not
330
+ something to set on your behalf.
331
+ Stale bump branches \`bump\` merges through a PR with the branch left on
332
+ origin (see \`bump\` above), so a local copy sticks
333
+ around too. Reports how many have an already-merged
334
+ PR; \`--clean-branches\` turns that into a checkbox
335
+ to delete the local ones you pick (\`git branch -d\`
336
+ — never the branch on origin, and refuses instead
337
+ of forcing if a branch isn't actually fully merged
338
+ locally).
339
+ Cross-package deps does any local package's dependency range no longer
340
+ match another local package's current version.
341
+
342
+ Run this first if any other command is behaving strangely, and any time
343
+ you rename a branch on GitHub or want to check for accumulated cruft.
299
344
 
300
345
  Examples:
301
346
  $ polyrepo doctor
347
+ $ polyrepo doctor --clean-branches
302
348
  `,
303
349
  )
304
- .action(() => doctorCommand({ configPath: program.opts().config }))
350
+ .action((opts) =>
351
+ doctorCommand({
352
+ configPath: program.opts().config,
353
+ cleanBranches: Boolean(opts.cleanBranches),
354
+ }),
355
+ )
305
356
 
306
357
  program
307
358
  .command('switch-master')
308
359
  .alias('sm')
309
- .description('Pick repos and switch each to an up-to-date master.')
360
+ .description('Pick repos and switch each to its up-to-date default branch.')
310
361
  .option(...PACKAGES_OPTION)
311
362
  .option(...YES_OPTION)
363
+ .option(
364
+ '--force',
365
+ "Discard uncommitted changes and any local-only commits on a repo's default branch, hard-resetting it to match origin.",
366
+ )
312
367
  .addHelpText(
313
368
  'after',
314
369
  `
315
- For each selected repo: fetch, checkout master, fast-forward-only merge.
316
- A repo with uncommitted changes is skipped with a warning, never touched.
317
- If local master has diverged from origin (fast-forward impossible), that
318
- repo is reported and left alone for you to resolve by hand.
370
+ Each repo's default branch is detected per repo (from origin GitHub
371
+ defaults new repos to "main", but plenty of people rename it, "master"
372
+ included, so this never assumes one name for every repo). For each
373
+ selected repo: fetch, checkout its default branch, fast-forward-only
374
+ merge. A repo with uncommitted changes is skipped with a warning, never
375
+ touched. If the local default branch has diverged from origin
376
+ (fast-forward impossible), that repo is reported and left alone for you
377
+ to resolve by hand.
378
+
379
+ --force changes this: dirty repos are no longer skipped, and every
380
+ selected repo gets \`git checkout -f <default branch>\` + \`git reset --hard
381
+ origin/<default branch>\` instead of the safe fast-forward-only merge —
382
+ uncommitted changes to tracked files and any local-only commits on that
383
+ branch are permanently discarded (untracked files are left alone, this
384
+ isn't \`git clean\`). The proceed confirmation says how many selected
385
+ repos are dirty and defaults to "No" when --force would actually discard
386
+ something.
319
387
 
320
388
  Examples:
321
389
  $ polyrepo switch-master
322
390
  $ polyrepo sm --packages vue-toast-kit,os-detect --yes
391
+ $ polyrepo sm --packages vue-toast-kit --force Discard its local changes and hard-reset to origin
323
392
  `,
324
393
  )
325
394
  .action((opts) =>
@@ -327,12 +396,13 @@ Examples:
327
396
  configPath: program.opts().config,
328
397
  packages: opts.packages ? opts.packages.split(',') : undefined,
329
398
  yes: Boolean(opts.yes),
399
+ force: Boolean(opts.force),
330
400
  }),
331
401
  )
332
402
 
333
403
  program
334
404
  .command('bump')
335
- .description('Pick packages, bump their version (patch by default), PR, merge to master, and tag.')
405
+ .description('Pick packages, bump their version (patch by default), PR, merge to the default branch, and tag.')
336
406
  .option('--dry-run', 'Print every step without pushing, opening, merging, or tagging anything for real.')
337
407
  .option('--minor', 'Bump the minor version instead of patch (e.g. 1.2.9 → 1.3.0).')
338
408
  .option('--major', 'Bump the major version instead of patch (e.g. 1.2.9 → 2.0.0).')
@@ -421,9 +491,10 @@ program
421
491
  `
422
492
  For a package whose version was bumped some other way (not through
423
493
  \`polyrepo bump\`, or before it started tagging) — puts the \`v<version>\` tag on
424
- master's current tip, no version change and no PR, so \`polyrepo release\` has
425
- something to work from. Re-syncs master first for each package, same as
426
- \`bump\` does. Already-tagged packages are shown but unchecked by default
494
+ its default branch's current tip, no version change and no PR, so
495
+ \`polyrepo release\` has something to work from. Re-syncs the default branch
496
+ first for each package, same as \`bump\` does. Already-tagged packages are
497
+ shown but unchecked by default
427
498
  (picking one anyway just confirms the tag is there, harmless). After
428
499
  tagging, asks whether to create a GitHub Release right away for whatever
429
500
  was just tagged (same as running \`polyrepo release\` for exactly those
package/src/masterSync.js CHANGED
@@ -1,18 +1,35 @@
1
- import { MASTER_BRANCH } from './config.js'
2
1
  import { git } from './exec.js'
3
2
 
4
- // fetch → checkout master → fast-forward-only merge. Always run for real
5
- // (never skipped under --dry-run) since it's read-only/reversible and
6
- // downstream logic needs an accurate picture of where master actually is.
7
- export function syncMaster(repo) {
3
+ // fetch → checkout the repo's default branch → fast-forward-only merge.
4
+ // Always run for real (never skipped under --dry-run) since it's
5
+ // read-only/reversible and downstream logic needs an accurate picture of
6
+ // where the default branch actually is. Uses `repo.defaultBranch`
7
+ // (detected per repo — see repos.js's detectDefaultBranchAsync) rather
8
+ // than assuming "master", since that isn't universal.
9
+ //
10
+ // `force: true` (only `switch-master --force` sets this) trades the safe
11
+ // fast-forward-only merge for `checkout -f` + `reset --hard` — it discards
12
+ // any uncommitted changes to tracked files and any local commits the
13
+ // default branch has that origin doesn't, unconditionally. Untracked
14
+ // files are left alone (this isn't `git clean`). Callers that don't pass
15
+ // it keep the original safe behavior — bump/tag rely on that, they never
16
+ // force.
17
+ export function syncMaster(repo, { force = false } = {}) {
18
+ const branch = repo.defaultBranch
8
19
  if (!git(repo.path, ['fetch', 'origin']).ok) {
9
20
  return { ok: false, message: 'git fetch origin failed.' }
10
21
  }
11
- if (!git(repo.path, ['checkout', MASTER_BRANCH]).ok) {
12
- return { ok: false, message: `git checkout ${MASTER_BRANCH} failed.` }
22
+ if (!git(repo.path, ['checkout', ...(force ? ['-f'] : []), branch]).ok) {
23
+ return { ok: false, message: `git checkout ${branch} failed.` }
13
24
  }
14
- if (!git(repo.path, ['merge', '--ff-only', `origin/${MASTER_BRANCH}`]).ok) {
15
- return { ok: false, message: `Local ${MASTER_BRANCH} has diverged from origin — resolve manually.` }
25
+ if (force) {
26
+ if (!git(repo.path, ['reset', '--hard', `origin/${branch}`]).ok) {
27
+ return { ok: false, message: `git reset --hard origin/${branch} failed.` }
28
+ }
29
+ return { ok: true }
30
+ }
31
+ if (!git(repo.path, ['merge', '--ff-only', `origin/${branch}`]).ok) {
32
+ return { ok: false, message: `Local ${branch} has diverged from origin — resolve manually.` }
16
33
  }
17
34
  return { ok: true }
18
35
  }
package/src/repos.js CHANGED
@@ -16,6 +16,44 @@ function toEntry(dirPath) {
16
16
  }
17
17
  }
18
18
 
19
+ // GitHub itself defaults a new repo to "main", and plenty of people rename
20
+ // it back to "master" (or something else) — there's no one right answer,
21
+ // so this is only the last resort once nothing else could tell us.
22
+ const FALLBACK_DEFAULT_BRANCH = 'main'
23
+
24
+ async function readCachedOriginHead(repo) {
25
+ const result = await gitAsync(repo.path, ['symbolic-ref', 'refs/remotes/origin/HEAD'])
26
+ if (!result.ok || !result.stdout) return null
27
+ const match = result.stdout.match(/^refs\/remotes\/origin\/(.+)$/)
28
+ return match ? match[1] : null
29
+ }
30
+
31
+ // Detected once per repo (cached alongside branch/clean below) rather than
32
+ // assumed — a mixed folder of repos can easily have some on "master" and
33
+ // some on "main". In order:
34
+ // 1. the locally cached origin/HEAD ref — set by `git clone` (or a prior
35
+ // `git remote set-head`), no network needed, the common case;
36
+ // 2. otherwise ask origin directly, read-only (`git ls-remote --symref`
37
+ // doesn't write any local ref, unlike `git remote set-head --auto`);
38
+ // 3. offline or no working origin — guess from whichever of
39
+ // master/main actually exists as a local branch;
40
+ // 4. still nothing to go on — GitHub's own default, "main".
41
+ export async function detectDefaultBranchAsync(repo) {
42
+ const cached = await readCachedOriginHead(repo)
43
+ if (cached) return cached
44
+
45
+ const remoteHead = await gitAsync(repo.path, ['ls-remote', '--symref', 'origin', 'HEAD'])
46
+ const remoteMatch = remoteHead.ok && remoteHead.stdout.match(/^ref:\s*refs\/heads\/(\S+)\s+HEAD/m)
47
+ if (remoteMatch) return remoteMatch[1]
48
+
49
+ for (const candidate of ['master', 'main']) {
50
+ const exists = await gitAsync(repo.path, ['show-ref', '--verify', '--quiet', `refs/heads/${candidate}`])
51
+ if (exists.ok) return candidate
52
+ }
53
+
54
+ return FALLBACK_DEFAULT_BRANCH
55
+ }
56
+
19
57
  // `config.roots` — folders whose direct subdirectories are packages (the
20
58
  // original C:\work\NPM-style layout). `config.packages` — individual
21
59
  // package folders given directly, for a one-off repo that doesn't live
@@ -76,18 +114,19 @@ export function readPackageJson(repo) {
76
114
  }
77
115
 
78
116
  // Full snapshot used everywhere a package list is shown: name, version,
79
- // current branch, and whether the working tree has uncommitted changes.
80
- // The two git calls per repo run concurrently across repos (see pMap)
81
- // instead of one repo waiting on the last.
117
+ // current branch, its default branch, and whether the working tree has
118
+ // uncommitted changes. The git calls per repo run concurrently across
119
+ // repos (see pMap) instead of one repo waiting on the last.
82
120
  export async function inspectRepoAsync(repo) {
83
121
  const pkg = readPackageJson(repo)
84
- const [branchResult, statusResult] = await Promise.all([
122
+ const [branchResult, statusResult, defaultBranch] = await Promise.all([
85
123
  gitAsync(repo.path, ['branch', '--show-current']),
86
124
  gitAsync(repo.path, ['status', '--porcelain']),
125
+ detectDefaultBranchAsync(repo),
87
126
  ])
88
127
  const branch = branchResult.ok ? branchResult.stdout || null : null
89
128
  const clean = statusResult.ok && statusResult.stdout === ''
90
- return { ...repo, ...pkg, branch, clean }
129
+ return { ...repo, ...pkg, branch, clean, defaultBranch }
91
130
  }
92
131
 
93
132
  export function inspectRepos(repos, concurrency) {
@@ -0,0 +1,22 @@
1
+ import { test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { bumpBranchName, isBumpBranchName } from '../src/config.js'
4
+
5
+ test('isBumpBranchName recognizes what bumpBranchName produces', () => {
6
+ assert.equal(isBumpBranchName(bumpBranchName('1.2.10')), true)
7
+ assert.equal(isBumpBranchName(bumpBranchName('0.0.1')), true)
8
+ assert.equal(isBumpBranchName(bumpBranchName('10.20.30')), true)
9
+ })
10
+
11
+ test('isBumpBranchName recognizes a pre-release/build suffix', () => {
12
+ assert.equal(isBumpBranchName('1.2.10-beta.1-version-bump'), true)
13
+ assert.equal(isBumpBranchName('1.2.10+build.5-version-bump'), true)
14
+ })
15
+
16
+ test('isBumpBranchName rejects unrelated branch names', () => {
17
+ assert.equal(isBumpBranchName('main'), false)
18
+ assert.equal(isBumpBranchName('feature/add-thing'), false)
19
+ assert.equal(isBumpBranchName('1.2.10-version-bump-extra'), false)
20
+ assert.equal(isBumpBranchName('version-bump'), false)
21
+ assert.equal(isBumpBranchName('v1.2.10-version-bump'), false)
22
+ })