polyrepo-cli 1.0.1 → 1.0.3

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,18 @@ 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-default`** — 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). `--force` hard-resets
36
+ a dirty repo to match origin; add `--clean` to also wipe untracked
37
+ files/directories.
32
38
  - **`bump`** — bump a package's version (patch by default, or
33
39
  `--minor`/`--major`) through a branch → PR → merge, then tag the
34
40
  release. Safe to re-run if a previous attempt was interrupted
@@ -265,44 +271,141 @@ polyrepo prs
265
271
  polyrepo prs --packages vue-toast-kit,os-detect
266
272
  ```
267
273
 
268
- ### `polyrepo doctor`
274
+ ### `polyrepo doctor [options]`
269
275
 
270
- A read-only health check in three sections:
276
+ A health check in seven sections — mostly read-only diagnosis, plus a
277
+ few small, non-destructive self-repairs:
271
278
 
272
279
  1. **Environment** — Node.js version (20+ required), whether
273
280
  `git`/`gh`/`npm` are on `PATH`, and whether `gh`/`npm` are
274
281
  authenticated (npm auth is only a warning — it's only needed for
275
282
  `publish`).
276
283
  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
284
+ resolves to, and which repos are dirty, in a detached `HEAD` state,
285
+ or off their default branch.
286
+ 3. **Remote sync** — two related repairs, both per-repo pointer
287
+ refreshes that never touch a file, branch, or commit:
288
+ - compares each repo's locally cached default-branch name (the
289
+ same value `switch-default`/`bump`/`tag` all use — see
290
+ `switch-default` above) against what GitHub actually reports
291
+ right now. Git never refreshes that local cache on its own, so
292
+ renaming a repo's default branch on GitHub after it was cloned
293
+ would otherwise go unnoticed by every other command forever —
294
+ wherever it's drifted, this fixes it with `git remote set-head
295
+ origin --auto`;
296
+ - runs `git remote prune origin` on every repo, dropping local
297
+ `remotes/origin/x` refs left over for branches already deleted
298
+ on GitHub (these tend to accumulate — every PR branch a `bump`
299
+ or manual workflow ever created and later got deleted on GitHub
300
+ leaves one behind locally until pruned).
301
+
302
+ Both are skipped for a repo GitHub can't be reached for (offline,
303
+ or `gh` not authenticated).
304
+ 4. **Branch sync** — fetches and compares each repo's local default
305
+ branch against `origin/<default>`: **diverged** (both ahead and
306
+ behind — a fast-forward won't work, needs resolving by hand),
307
+ **behind only** (safe to fast-forward with `switch-default`), or
308
+ **ahead only** (local commits not yet pushed). Surfaces this
309
+ before some other command trips over it mid-run instead of after.
310
+ 5. **Branch protection** — whether each repo's default branch
311
+ actually has GitHub branch protection enabled right now.
312
+ Report-only; enabling protection is a policy decision, not
313
+ something this fixes on your behalf.
314
+ 6. **Stale bump branches** — `bump` merges through a PR with the
315
+ branch intentionally left on origin (`--delete-branch=false`, see
316
+ `bump` above), so every completed bump leaves a local branch copy
317
+ behind too, forever. This reports how many local branches match
318
+ `<version>-version-bump` **and** already have a merged PR.
319
+ `--clean-branches` turns that into a checkbox — pick which ones to
320
+ delete locally (`git branch -d`, which refuses instead of forcing
321
+ if a branch somehow isn't actually fully merged locally; the
322
+ branch on origin is never touched, deleting that is out of scope
323
+ here — it's more sensitive shared state).
324
+ 7. **Cross-package dependencies** — the same dependency-drift check
279
325
  that runs at the end of `bump`, available on demand without
280
326
  bumping anything.
281
327
 
282
- Worth running first if any other command is behaving unexpectedly.
328
+ Worth running first if any other command is behaving unexpectedly, any
329
+ time you rename a default branch on GitHub, or just periodically to
330
+ catch accumulated cruft (stale remote-tracking refs, leftover bump
331
+ branches) before it piles up.
332
+
333
+ **Options:**
334
+
335
+ | Flag | What it does |
336
+ | --- | --- |
337
+ | `--clean-branches` | After scanning, show a checkbox of local bump branches whose PR is already merged; delete the ones you pick. |
283
338
 
284
339
  ```bash
285
340
  polyrepo doctor
341
+
342
+ # also review and clean up leftover local bump branches
343
+ polyrepo doctor --clean-branches
286
344
  ```
287
345
 
288
- ### `polyrepo switch-master` (alias `sm`)
346
+ ```bash
347
+ polyrepo doctor
348
+ ```
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
+ ### `polyrepo switch-default` (alias `sd`)
351
+
352
+ Each repo's **default branch is detected per repo**, not assumed —
353
+ GitHub itself defaults a new repo to `main`, and plenty of people
354
+ rename it (`master` included), so a mixed folder of repos can easily
355
+ have some on `master` and some on `main`. Detection prefers the
356
+ locally cached `origin/HEAD` ref (no network — set by `git clone`),
357
+ falls back to asking origin directly (`git ls-remote --symref`,
358
+ read-only), then to whichever of `master`/`main` exists as a local
359
+ branch, and finally to `main` (GitHub's own default) if nothing else
360
+ could tell it.
361
+
362
+ 1. Shows a checkbox list of every repo with its current branch (and
363
+ its default branch, when the two differ); repos not currently on
364
+ their default branch are pre-selected.
292
365
  2. After confirming, for each selected repo, one at a time (each
293
366
  step's result prints immediately, not after the whole batch):
294
367
  - 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
368
+ - otherwise: `git fetch origin` → `git checkout <default branch>`
369
+ `git merge --ff-only origin/<default branch>`.
370
+ 3. If the local default branch has diverged from origin (fast-forward
298
371
  isn't possible), that repo is reported and left alone to resolve by
299
- hand — no `--force`/`reset --hard` is ever used.
372
+ hand.
373
+
374
+ `--force` changes step 2 and 3 for every selected repo: a dirty
375
+ working tree is no longer skipped, and each repo gets
376
+ `git checkout -f <default branch>` + `git reset --hard
377
+ origin/<default branch>` instead of the safe fast-forward-only merge —
378
+ uncommitted changes to tracked files and any local-only commits on
379
+ that branch are permanently discarded. Untracked files are still left
380
+ alone at this point (this isn't `git clean`) — add `--clean` to also
381
+ run `git clean -fd` (removes untracked files/directories that aren't
382
+ gitignored; gitignored paths like `node_modules` are still left
383
+ alone, this doesn't use `-x`). `--clean` only means anything alongside
384
+ `--force` — without it there's nothing to discard in the first place.
385
+ The checkbox marks which selected repos would lose changes, and the
386
+ proceed confirmation says how many and defaults to "No" instead of
387
+ "Yes" whenever `--force` would actually discard something.
388
+
389
+ **Options:**
390
+
391
+ | Flag | What it does |
392
+ | --- | --- |
393
+ | `--packages <a,b,c>` | Package list instead of the interactive checkbox. |
394
+ | `--yes` | Skip the "proceed?" confirmation. |
395
+ | `--force` | Discard uncommitted changes and local-only commits on the default branch, hard-resetting it to origin. |
396
+ | `--clean` | With `--force`, also remove untracked files/directories (`git clean -fd`). |
300
397
 
301
398
  ```bash
302
- polyrepo switch-master
399
+ polyrepo switch-default
303
400
 
304
401
  # no checkbox, specific repos, no confirmation — for scripts
305
- polyrepo switch-master --packages vue-toast-kit,os-detect --yes
402
+ polyrepo switch-default --packages vue-toast-kit,os-detect --yes
403
+
404
+ # discard local changes on a repo you don't need anymore
405
+ polyrepo switch-default --packages vue-toast-kit --force
406
+
407
+ # same, but also wipe untracked build output etc.
408
+ polyrepo switch-default --packages vue-toast-kit --force --clean
306
409
  ```
307
410
 
308
411
  ### `polyrepo bump [options]`
@@ -313,15 +416,17 @@ polyrepo switch-master --packages vue-toast-kit,os-detect --yes
313
416
  the parts below it to `0`, same as any semver tool). Packages
314
417
  with a dirty working tree are marked — they'll be skipped. The
315
418
  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.
419
+ since the last git tag (`git log <tag>..<default branch>`) — if
420
+ that's empty, there's probably nothing worth bumping. These
421
+ previews are computed for every package in parallel, not one at a
422
+ time.
319
423
  2. After confirming, for each selected package, one at a time, with
320
424
  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);
425
+ 1. `git fetch origin` → `git checkout <default branch>`
426
+ `git merge --ff-only origin/<default branch>` (the bump branch
427
+ is always created from an up-to-date default branch detected
428
+ per repo, see `switch-default` above — not whatever branch the
429
+ repo happened to be on);
325
430
  2. **checks the state of a previous attempt** — is there already a
326
431
  merged PR, an open PR, or just a pushed branch named
327
432
  `<new-version>-version-bump` (e.g. `1.2.10-version-bump` — the
@@ -329,8 +434,8 @@ polyrepo switch-master --packages vue-toast-kit,os-detect --yes
329
434
  different bumps never collide). Depending on what's found, it
330
435
  resumes from the right place instead of failing on "branch
331
436
  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;
437
+ - **already merged** — nothing to do (the default branch was
438
+ already synced in step 1), go straight to tagging;
334
439
  - **open PR exists** — merge that one, don't open a new one;
335
440
  - **branch pushed, no PR** — reuse the branch, open a PR;
336
441
  - **nothing exists** — the full flow from scratch.
@@ -344,7 +449,8 @@ polyrepo switch-master --packages vue-toast-kit,os-detect --yes
344
449
  review, not a finished changelog. Packages without a
345
450
  `CHANGELOG.md` don't get one created. Both files are committed
346
451
  together;
347
- 4. `gh pr create` against `master` (if there isn't one already);
452
+ 4. `gh pr create` against the default branch (if there isn't one
453
+ already);
348
454
  5. with `--wait-checks`: wait for the PR's CI checks via
349
455
  `gh pr checks --watch` (with a real terminal, live-updating). No
350
456
  checks configured isn't an error — there's just nothing to wait
@@ -352,9 +458,9 @@ polyrepo switch-master --packages vue-toast-kit,os-detect --yes
352
458
  skip the merge;
353
459
  6. `gh pr merge --merge` — through a PR, not a direct push, since
354
460
  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;
461
+ 7. `git checkout <default branch>` → `git fetch origin` →
462
+ `git merge --ff-only origin/<default branch>` — local default
463
+ branch is synced to the just-merged PR;
358
464
  8. **git tag** `v<new-version>` (e.g. `v1.2.10`) is created and
359
465
  pushed if it doesn't already exist (idempotent, like everything
360
466
  else here — a re-run won't try to create it twice).
@@ -458,9 +564,9 @@ it again or opening a PR:
458
564
  pre-selected; already-tagged ones can still be picked manually
459
565
  (harmless — it just confirms the tag is there).
460
566
  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.
567
+ `git fetch`/`checkout <default branch>`/`merge --ff-only` (tags an
568
+ up-to-date default branch, same as `bump`), then creates and pushes
569
+ the tag if it's missing.
464
570
  4. If at least one package was actually tagged (and it wasn't a
465
571
  `--dry-run`), it asks: "Create a GitHub Release for the N
466
572
  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.3",
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-default`
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,11 +2,11 @@ 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'
9
- import { syncMaster } from '../masterSync.js'
9
+ import { syncDefaultBranch } from '../defaultBranchSync.js'
10
10
  import { detectBumpState, createPr, mergePr } from '../github.js'
11
11
  import { tagName, tagExists, createAndPushTag } from '../tags.js'
12
12
  import { describeRecentChangesForAll, formatRecentChanges, fullCommitLinesSince } from '../changes.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,
@@ -111,9 +111,9 @@ async function bumpOne(repo, { dryRun, waitChecks }) {
111
111
  return
112
112
  }
113
113
 
114
- const syncResult = syncMaster(repo)
114
+ const syncResult = syncDefaultBranch(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}.`,
@@ -193,11 +193,11 @@ async function bumpOne(repo, { dryRun, waitChecks }) {
193
193
  }
194
194
  ok(`Merged PR #${prNumber}.`)
195
195
 
196
- const resyncResult = syncMaster(repo)
196
+ const resyncResult = syncDefaultBranch(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-default` 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-default\`): ${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
- import { syncMaster } from '../masterSync.js'
5
+ import { syncDefaultBranch } from '../defaultBranchSync.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 switchDefaultCommand({ configPath, packages, yes = false, force = false, clean = 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${clean ? ' and untracked files' : ''}`) : ''),
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,17 @@ 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${
58
+ clean ? ' and untracked files' : ''
59
+ } will be permanently discarded. Continue?`
60
+ : `Switch ${selected.length} repo(s) to their default branch and fast-forward?`,
61
+ default: !force,
51
62
  })
52
63
  if (!proceed) {
53
64
  console.log(pc.dim('Cancelled.'))
@@ -60,17 +71,21 @@ export async function switchMasterCommand({ configPath, packages, yes = false }
60
71
  index += 1
61
72
  stepHeading(index, selected.length, repo.dir)
62
73
 
63
- if (!repo.clean) {
74
+ if (!repo.clean && !force) {
64
75
  warn(`Working tree is dirty — skipping to avoid discarding local changes.`)
65
76
  continue
66
77
  }
67
78
 
68
- const result = syncMaster(repo)
79
+ const result = syncDefaultBranch(repo, { force, clean })
69
80
  if (!result.ok) {
70
81
  fail(result.message)
71
82
  continue
72
83
  }
73
84
 
74
- ok(`Now on ${MASTER_BRANCH}, up to date with origin.`)
85
+ ok(
86
+ force && !repo.clean
87
+ ? `Discarded local changes${clean ? ' and untracked files' : ''} — now on ${repo.defaultBranch}, matching origin.`
88
+ : `Now on ${repo.defaultBranch}, up to date with origin.`,
89
+ )
75
90
  }
76
91
  }
@@ -2,7 +2,7 @@ import { confirm } from '@inquirer/prompts'
2
2
  import pc from 'picocolors'
3
3
  import { discoverRepos, inspectRepos } from '../repos.js'
4
4
  import { loadConfig } from '../loadConfig.js'
5
- import { syncMaster } from '../masterSync.js'
5
+ import { syncDefaultBranch } from '../defaultBranchSync.js'
6
6
  import { tagName, tagExists, tagExistsAsync, createAndPushTag } from '../tags.js'
7
7
  import { selectPackages } from '../selectPackages.js'
8
8
  import { filterByNames } from '../filterByNames.js'
@@ -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)
@@ -88,12 +88,12 @@ export async function tagCommand({ configPath, packages, yes = false, dryRun = f
88
88
  index += 1
89
89
  stepHeading(index, selected.length, `${repo.dir} ${repo.tag}`)
90
90
 
91
- const syncResult = syncMaster(repo)
91
+ const syncResult = syncDefaultBranch(repo)
92
92
  if (!syncResult.ok) {
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
+ }
@@ -0,0 +1,42 @@
1
+ import { git } from './exec.js'
2
+
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-default --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 unless `clean: true` is also given (only valid
15
+ // together with `force`) — that additionally runs `git clean -fd`,
16
+ // removing untracked files/directories that aren't gitignored (build
17
+ // output that isn't in .gitignore is the common case). `-x` is
18
+ // deliberately not used — gitignored paths like node_modules are left
19
+ // alone even under --clean. Callers that don't pass `force` keep the
20
+ // original safe behavior — bump/tag rely on that, they never force.
21
+ export function syncDefaultBranch(repo, { force = false, clean = false } = {}) {
22
+ const branch = repo.defaultBranch
23
+ if (!git(repo.path, ['fetch', 'origin']).ok) {
24
+ return { ok: false, message: 'git fetch origin failed.' }
25
+ }
26
+ if (!git(repo.path, ['checkout', ...(force ? ['-f'] : []), branch]).ok) {
27
+ return { ok: false, message: `git checkout ${branch} failed.` }
28
+ }
29
+ if (force) {
30
+ if (!git(repo.path, ['reset', '--hard', `origin/${branch}`]).ok) {
31
+ return { ok: false, message: `git reset --hard origin/${branch} failed.` }
32
+ }
33
+ if (clean && !git(repo.path, ['clean', '-fd']).ok) {
34
+ return { ok: false, message: 'git clean -fd failed.' }
35
+ }
36
+ return { ok: true }
37
+ }
38
+ if (!git(repo.path, ['merge', '--ff-only', `origin/${branch}`]).ok) {
39
+ return { ok: false, message: `Local ${branch} has diverged from origin — resolve manually.` }
40
+ }
41
+ return { ok: true }
42
+ }
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,9 +1,12 @@
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'
5
8
  import { doctorCommand } from './commands/doctor.js'
6
- import { switchMasterCommand } from './commands/switchMaster.js'
9
+ import { switchDefaultCommand } from './commands/switchDefault.js'
7
10
  import { bumpCommand } from './commands/bump.js'
8
11
  import { publishCommand } from './commands/publish.js'
9
12
  import { tagCommand } from './commands/tag.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-default), 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-default 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,54 +294,130 @@ 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-default\`), 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
- .command('switch-master')
308
- .alias('sm')
309
- .description('Pick repos and switch each to an up-to-date master.')
358
+ .command('switch-default')
359
+ .alias('sd')
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
+ )
367
+ .option(
368
+ '--clean',
369
+ 'With --force, also remove untracked files/directories (git clean -fd) — only valid together with --force.',
370
+ )
312
371
  .addHelpText(
313
372
  'after',
314
373
  `
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.
374
+ Each repo's default branch is detected per repo (from origin GitHub
375
+ defaults new repos to "main", but plenty of people rename it, "master"
376
+ included, so this never assumes one name for every repo). For each
377
+ selected repo: fetch, checkout its default branch, fast-forward-only
378
+ merge. A repo with uncommitted changes is skipped with a warning, never
379
+ touched. If the local default branch has diverged from origin
380
+ (fast-forward impossible), that repo is reported and left alone for you
381
+ to resolve by hand.
382
+
383
+ --force changes this: dirty repos are no longer skipped, and every
384
+ selected repo gets \`git checkout -f <default branch>\` + \`git reset --hard
385
+ origin/<default branch>\` instead of the safe fast-forward-only merge —
386
+ uncommitted changes to tracked files and any local-only commits on that
387
+ branch are permanently discarded. Untracked files are still left alone
388
+ at this point (this isn't \`git clean\`) — add --clean to also run
389
+ \`git clean -fd\` (removes untracked files/directories that aren't
390
+ gitignored; gitignored paths like node_modules are still left alone,
391
+ this doesn't use -x). --clean only means anything alongside --force —
392
+ without it there's nothing to discard in the first place. The proceed
393
+ confirmation says how many selected repos are dirty and defaults to
394
+ "No" when --force would actually discard something.
319
395
 
320
396
  Examples:
321
- $ polyrepo switch-master
322
- $ polyrepo sm --packages vue-toast-kit,os-detect --yes
397
+ $ polyrepo switch-default
398
+ $ polyrepo sd --packages vue-toast-kit,os-detect --yes
399
+ $ polyrepo sd --packages vue-toast-kit --force Discard its local changes and hard-reset to origin
400
+ $ polyrepo sd --packages vue-toast-kit --force --clean Also remove untracked build output etc.
323
401
  `,
324
402
  )
325
- .action((opts) =>
326
- switchMasterCommand({
403
+ .action((opts) => {
404
+ if (opts.clean && !opts.force) {
405
+ console.error('--clean only makes sense together with --force.')
406
+ process.exitCode = 1
407
+ return
408
+ }
409
+ return switchDefaultCommand({
327
410
  configPath: program.opts().config,
328
411
  packages: opts.packages ? opts.packages.split(',') : undefined,
329
412
  yes: Boolean(opts.yes),
330
- }),
331
- )
413
+ force: Boolean(opts.force),
414
+ clean: Boolean(opts.clean),
415
+ })
416
+ })
332
417
 
333
418
  program
334
419
  .command('bump')
335
- .description('Pick packages, bump their version (patch by default), PR, merge to master, and tag.')
420
+ .description('Pick packages, bump their version (patch by default), PR, merge to the default branch, and tag.')
336
421
  .option('--dry-run', 'Print every step without pushing, opening, merging, or tagging anything for real.')
337
422
  .option('--minor', 'Bump the minor version instead of patch (e.g. 1.2.9 → 1.3.0).')
338
423
  .option('--major', 'Bump the major version instead of patch (e.g. 1.2.9 → 2.0.0).')
@@ -421,9 +506,10 @@ program
421
506
  `
422
507
  For a package whose version was bumped some other way (not through
423
508
  \`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
509
+ its default branch's current tip, no version change and no PR, so
510
+ \`polyrepo release\` has something to work from. Re-syncs the default branch
511
+ first for each package, same as \`bump\` does. Already-tagged packages are
512
+ shown but unchecked by default
427
513
  (picking one anyway just confirms the tag is there, harmless). After
428
514
  tagging, asks whether to create a GitHub Release right away for whatever
429
515
  was just tagged (same as running \`polyrepo release\` for exactly those
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) {
package/src/spinner.js CHANGED
@@ -9,7 +9,7 @@ const CLEAR_LINE = '\r\x1b[K'
9
9
 
10
10
  // Covers the "nothing is printed for several seconds" gap while a parallel
11
11
  // batch (pMap) is in flight and there's no per-item progress to show yet —
12
- // list's full mode, doctor, and the initial repo scan in bump/switch-master
12
+ // list's full mode, doctor, and the initial repo scan in bump/switch-default
13
13
  // all used to just sit there silently. Falls back to a single static line
14
14
  // when stdout isn't a real terminal (piped output, CI logs) — redrawing
15
15
  // with carriage returns there would just dump a stream of raw \r bytes
package/src/ui.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import pc from 'picocolors'
2
2
 
3
3
  // Column width/row-formatting is shared by `printTable` (the `list` output)
4
- // and by the checkbox choice labels in `bump`/`switch-master`/`publish` —
4
+ // and by the checkbox choice labels in `bump`/`switch-default`/`publish` —
5
5
  // so a package name column lines up the same way whether it's shown in a
6
6
  // static table or as a pickable list, instead of each command reinventing
7
7
  // its own ad-hoc padding (which is how columns used to drift out of line
@@ -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
+ })
package/src/masterSync.js DELETED
@@ -1,18 +0,0 @@
1
- import { MASTER_BRANCH } from './config.js'
2
- import { git } from './exec.js'
3
-
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) {
8
- if (!git(repo.path, ['fetch', 'origin']).ok) {
9
- return { ok: false, message: 'git fetch origin failed.' }
10
- }
11
- if (!git(repo.path, ['checkout', MASTER_BRANCH]).ok) {
12
- return { ok: false, message: `git checkout ${MASTER_BRANCH} failed.` }
13
- }
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.` }
16
- }
17
- return { ok: true }
18
- }