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 +142 -36
- package/package.json +1 -1
- package/src/changes.js +14 -14
- package/src/commands/bump.js +12 -11
- package/src/commands/doctor.js +272 -6
- package/src/commands/list.js +1 -2
- package/src/commands/{switchMaster.js → switchDefault.js} +27 -12
- package/src/commands/tag.js +8 -8
- package/src/config.js +11 -2
- package/src/defaultBranchSync.js +42 -0
- package/src/github.js +1 -1
- package/src/index.js +116 -30
- package/src/repos.js +44 -5
- package/src/spinner.js +1 -1
- package/src/ui.js +1 -1
- package/test/config.test.js +22 -0
- package/src/masterSync.js +0 -18
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:
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
`
|
|
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
|
|
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
|
|
278
|
-
|
|
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
|
-
|
|
346
|
+
```bash
|
|
347
|
+
polyrepo doctor
|
|
348
|
+
```
|
|
289
349
|
|
|
290
|
-
|
|
291
|
-
|
|
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
|
|
296
|
-
`git merge --ff-only origin
|
|
297
|
-
3. If local
|
|
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
|
|
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-
|
|
399
|
+
polyrepo switch-default
|
|
303
400
|
|
|
304
401
|
# no checkbox, specific repos, no confirmation — for scripts
|
|
305
|
-
polyrepo switch-
|
|
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
|
|
317
|
-
there's probably nothing worth bumping. These
|
|
318
|
-
for every package in parallel, not one at a
|
|
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
|
|
322
|
-
`git merge --ff-only origin
|
|
323
|
-
created from an up-to-date
|
|
324
|
-
|
|
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 (
|
|
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
|
|
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
|
|
356
|
-
`git merge --ff-only origin
|
|
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
|
|
462
|
-
|
|
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.
|
|
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
|
|
8
|
-
// last few commits if there's no tag yet), so the bump
|
|
9
|
-
// whether a package actually has anything worth
|
|
10
|
-
// repo state — run `polyrepo switch-
|
|
11
|
-
// through pMap across many repos at once
|
|
12
|
-
// — this builds the bump checkbox's
|
|
13
|
-
// git calls per repo, 17 times over,
|
|
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
|
|
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}..${
|
|
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
|
|
38
|
-
// exactly where that matters: after checking out the bump
|
|
39
|
-
// HEAD is
|
|
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
|
package/src/commands/bump.js
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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
|
|
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 =
|
|
114
|
+
const syncResult = syncDefaultBranch(repo)
|
|
115
115
|
if (!syncResult.ok) return fail(syncResult.message)
|
|
116
|
-
ok(`${
|
|
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:
|
|
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 =
|
|
196
|
+
const resyncResult = syncDefaultBranch(repo)
|
|
197
197
|
if (!resyncResult.ok) return fail(resyncResult.message)
|
|
198
|
-
ok(`Local ${
|
|
198
|
+
ok(`Local ${repo.defaultBranch} synced to origin at ${repo.newVersion}.`)
|
|
199
199
|
} else {
|
|
200
|
-
ok(`Already merged as PR #${state.pr.number} — ${
|
|
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
|
-
//
|
|
217
|
-
// to call again after any partial failure without any state
|
|
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 }
|
package/src/commands/doctor.js
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
|
+
import { checkbox, confirm } from '@inquirer/prompts'
|
|
1
2
|
import pc from 'picocolors'
|
|
2
3
|
import { discoverRepos, inspectRepos } from '../repos.js'
|
|
3
4
|
import { loadConfig } from '../loadConfig.js'
|
|
4
|
-
import { run } from '../exec.js'
|
|
5
|
+
import { run, git, gitAsync, ghAsync } from '../exec.js'
|
|
5
6
|
import { findStaleLocalDeps } from '../crossDeps.js'
|
|
6
|
-
import {
|
|
7
|
+
import { isBumpBranchName } from '../config.js'
|
|
8
|
+
import { pMap } from '../pMap.js'
|
|
9
|
+
import { heading, ok, fail, warn, columnWidths, formatRow, promptTheme } from '../ui.js'
|
|
7
10
|
import { startSpinner } from '../spinner.js'
|
|
8
11
|
|
|
9
|
-
export async function doctorCommand({ configPath } = {}) {
|
|
12
|
+
export async function doctorCommand({ configPath, cleanBranches = false } = {}) {
|
|
10
13
|
heading('Environment')
|
|
11
14
|
checkNode()
|
|
12
15
|
checkGit()
|
|
@@ -28,10 +31,42 @@ export async function doctorCommand({ configPath } = {}) {
|
|
|
28
31
|
if (dirty.length > 0) {
|
|
29
32
|
warn(`${dirty.length} repo(s) have uncommitted changes: ${dirty.map((r) => r.dir).join(', ')}`)
|
|
30
33
|
}
|
|
31
|
-
const
|
|
32
|
-
if (
|
|
33
|
-
warn(`${
|
|
34
|
+
const detached = repos.filter((r) => r.branch === null)
|
|
35
|
+
if (detached.length > 0) {
|
|
36
|
+
warn(`${detached.length} repo(s) are in a detached HEAD state: ${detached.map((r) => r.dir).join(', ')}`)
|
|
34
37
|
}
|
|
38
|
+
const offDefault = repos.filter((r) => r.branch !== null && r.branch !== r.defaultBranch)
|
|
39
|
+
if (offDefault.length > 0) {
|
|
40
|
+
warn(`${offDefault.length} repo(s) are not on their default branch: ${offDefault.map((r) => r.dir).join(', ')}`)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
heading('Remote sync')
|
|
45
|
+
if (repos.length > 0) {
|
|
46
|
+
await checkRemoteSync(repos)
|
|
47
|
+
} else {
|
|
48
|
+
console.log(pc.dim('Skipped — no packages discovered.'))
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
heading('Branch sync')
|
|
52
|
+
if (repos.length > 0) {
|
|
53
|
+
await checkBranchSync(repos)
|
|
54
|
+
} else {
|
|
55
|
+
console.log(pc.dim('Skipped — no packages discovered.'))
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
heading('Branch protection')
|
|
59
|
+
if (repos.length > 0) {
|
|
60
|
+
await checkBranchProtection(repos)
|
|
61
|
+
} else {
|
|
62
|
+
console.log(pc.dim('Skipped — no packages discovered.'))
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
heading('Stale bump branches')
|
|
66
|
+
if (repos.length > 0) {
|
|
67
|
+
await checkStaleBumpBranches(repos, { cleanBranches })
|
|
68
|
+
} else {
|
|
69
|
+
console.log(pc.dim('Skipped — no packages discovered.'))
|
|
35
70
|
}
|
|
36
71
|
|
|
37
72
|
heading('Cross-package dependencies')
|
|
@@ -51,6 +86,237 @@ export async function doctorCommand({ configPath } = {}) {
|
|
|
51
86
|
}
|
|
52
87
|
}
|
|
53
88
|
|
|
89
|
+
// Every other command trusts the locally cached `origin/HEAD` ref as the
|
|
90
|
+
// fast, no-network path for "what's this repo's default branch" (see
|
|
91
|
+
// detectDefaultBranchAsync in repos.js) — but git never refreshes that
|
|
92
|
+
// cache on its own, so if the default branch gets renamed on GitHub after
|
|
93
|
+
// a repo was cloned, every command would keep using the old name forever
|
|
94
|
+
// with nothing to notice or fix it. This repairs it with `git remote
|
|
95
|
+
// set-head origin --auto` wherever it's drifted — a safe, non-destructive
|
|
96
|
+
// pointer refresh, not a change to any file, branch, or commit. Bundled
|
|
97
|
+
// with `git remote prune origin` (same "keep local remote-tracking state
|
|
98
|
+
// honest" spirit): drops local `remotes/origin/x` refs for branches
|
|
99
|
+
// already deleted on GitHub — also just local bookkeeping, touches
|
|
100
|
+
// nothing shared.
|
|
101
|
+
async function checkRemoteSync(repos) {
|
|
102
|
+
const spinner = startSpinner(`Checking ${repos.length} package(s) against GitHub, and pruning stale remote-tracking refs...`)
|
|
103
|
+
const results = await pMap(repos, async (r) => {
|
|
104
|
+
const ghResult = await ghAsync(r.path, [
|
|
105
|
+
'repo',
|
|
106
|
+
'view',
|
|
107
|
+
'--json',
|
|
108
|
+
'defaultBranchRef',
|
|
109
|
+
'-q',
|
|
110
|
+
'.defaultBranchRef.name',
|
|
111
|
+
])
|
|
112
|
+
const actual = ghResult.ok && ghResult.stdout ? ghResult.stdout : null
|
|
113
|
+
|
|
114
|
+
const pruneResult = await gitAsync(r.path, ['remote', 'prune', 'origin'])
|
|
115
|
+
const pruned = pruneResult.ok ? [...pruneResult.stdout.matchAll(/\[pruned\] origin\/(\S+)/g)].map((m) => m[1]) : []
|
|
116
|
+
|
|
117
|
+
return { repo: r, actual, pruned }
|
|
118
|
+
})
|
|
119
|
+
spinner.stop()
|
|
120
|
+
|
|
121
|
+
const checked = results.filter((r) => r.actual)
|
|
122
|
+
if (checked.length === 0) {
|
|
123
|
+
console.log(pc.dim('Default branch: skipped — could not reach GitHub for any package (offline, or `gh` not authenticated).'))
|
|
124
|
+
} else {
|
|
125
|
+
const drifted = checked.filter((r) => r.actual !== r.repo.defaultBranch)
|
|
126
|
+
for (const { repo, actual } of drifted) {
|
|
127
|
+
const fixResult = git(repo.path, ['remote', 'set-head', 'origin', '--auto'])
|
|
128
|
+
if (fixResult.ok) {
|
|
129
|
+
ok(`${repo.dir}: local cache said "${repo.defaultBranch}", GitHub says "${actual}" — refreshed the local cache.`)
|
|
130
|
+
} else {
|
|
131
|
+
fail(`${repo.dir}: local cache said "${repo.defaultBranch}", GitHub says "${actual}" — could not refresh it (git remote set-head failed).`)
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
if (drifted.length === 0) {
|
|
135
|
+
ok(`${checked.length} package(s) checked — local default-branch cache matches GitHub.`)
|
|
136
|
+
}
|
|
137
|
+
const uncheckedCount = repos.length - checked.length
|
|
138
|
+
if (uncheckedCount > 0) {
|
|
139
|
+
console.log(pc.dim(` (${uncheckedCount} package(s) could not be checked against GitHub.)`))
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const pruned = results.filter((r) => r.pruned.length > 0)
|
|
144
|
+
if (pruned.length > 0) {
|
|
145
|
+
for (const { repo, pruned: branches } of pruned) {
|
|
146
|
+
ok(`${repo.dir}: pruned ${branches.length} stale remote-tracking ref(s) (${branches.join(', ')}).`)
|
|
147
|
+
}
|
|
148
|
+
} else {
|
|
149
|
+
ok('No stale remote-tracking refs to prune.')
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Whether each repo's local default branch actually matches origin —
|
|
154
|
+
// surfaced here proactively so it's known before a `switch-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) {
|
package/src/commands/list.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import pc from 'picocolors'
|
|
2
2
|
import { discoverRepos, inspectRepos } from '../repos.js'
|
|
3
|
-
import { MASTER_BRANCH } from '../config.js'
|
|
4
3
|
import { loadConfig } from '../loadConfig.js'
|
|
5
4
|
import { tagName, tagExistsAsync } from '../tags.js'
|
|
6
5
|
import { releaseExistsAsync } from '../release.js'
|
|
@@ -41,7 +40,7 @@ export async function listCommand({ configPath, quick = false, showPath = false,
|
|
|
41
40
|
{
|
|
42
41
|
label: 'Branch',
|
|
43
42
|
value: (r) => r.branch ?? '(detached)',
|
|
44
|
-
style: (r, text) => (r.branch ===
|
|
43
|
+
style: (r, text) => (r.branch === r.defaultBranch ? pc.dim(text) : pc.yellow(text)),
|
|
45
44
|
},
|
|
46
45
|
{
|
|
47
46
|
label: 'Git',
|
|
@@ -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 {
|
|
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
|
|
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(
|
|
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
|
|
27
|
+
message: 'Pick repos to switch to their default branch and update:',
|
|
29
28
|
buildChoice: (all) => {
|
|
30
29
|
const columns = [{ value: (r) => r.dir }]
|
|
31
30
|
const widths = columnWidths(all, columns)
|
|
32
31
|
return (r) => ({
|
|
33
32
|
name:
|
|
34
33
|
formatRow(r, columns, widths) +
|
|
35
|
-
pc.dim(
|
|
34
|
+
pc.dim(
|
|
35
|
+
` (currently on: ${r.branch ?? '(detached)'}${r.clean ? '' : ', dirty'}${
|
|
36
|
+
r.branch !== r.defaultBranch ? `, default: ${r.defaultBranch}` : ''
|
|
37
|
+
})`,
|
|
38
|
+
) +
|
|
39
|
+
(force && !r.clean ? pc.red(` will discard uncommitted changes${clean ? ' and untracked files' : ''}`) : ''),
|
|
36
40
|
value: r,
|
|
37
|
-
checked: r.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:
|
|
50
|
-
|
|
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 =
|
|
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(
|
|
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
|
}
|
package/src/commands/tag.js
CHANGED
|
@@ -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 {
|
|
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
|
-
//
|
|
17
|
-
// PR — no need to bump again just to get a tag. Offers to
|
|
18
|
-
// right after, since "I just caught this package's tag up" and "I
|
|
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 =
|
|
91
|
+
const syncResult = syncDefaultBranch(repo)
|
|
92
92
|
if (!syncResult.ok) {
|
|
93
93
|
fail(syncResult.message)
|
|
94
94
|
continue
|
|
95
95
|
}
|
|
96
|
-
ok(
|
|
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
|
|
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 {
|
|
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
|
|
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(
|
|
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-
|
|
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
|
|
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
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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(() =>
|
|
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-
|
|
308
|
-
.alias('
|
|
309
|
-
.description('Pick repos and switch each to
|
|
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
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
repo
|
|
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-
|
|
322
|
-
$ polyrepo
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
425
|
-
something to work from. Re-syncs
|
|
426
|
-
\`bump\` does. Already-tagged packages are
|
|
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
|
|
80
|
-
// The
|
|
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-
|
|
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-
|
|
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
|
-
}
|