polyrepo-cli 1.0.0 → 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +139 -38
- package/package.json +1 -1
- package/src/changes.js +14 -14
- package/src/commands/bump.js +9 -8
- package/src/commands/doctor.js +272 -6
- package/src/commands/list.js +1 -2
- package/src/commands/publish.js +41 -4
- package/src/commands/release.js +16 -3
- package/src/commands/switchMaster.js +24 -11
- package/src/commands/tag.js +22 -9
- package/src/config.js +11 -2
- package/src/github.js +1 -1
- package/src/index.js +92 -21
- package/src/masterSync.js +26 -9
- package/src/repos.js +44 -5
- package/test/config.test.js +22 -0
package/src/index.js
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import fs from 'node:fs'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { fileURLToPath } from 'node:url'
|
|
2
5
|
import { Command } from 'commander'
|
|
3
6
|
import pc from 'picocolors'
|
|
4
7
|
import { listCommand } from './commands/list.js'
|
|
@@ -14,6 +17,12 @@ import { outdatedCommand } from './commands/outdated.js'
|
|
|
14
17
|
import { prsCommand } from './commands/prs.js'
|
|
15
18
|
import { cloneCommand } from './commands/clone.js'
|
|
16
19
|
|
|
20
|
+
// Read once from package.json rather than a literal string here — the two
|
|
21
|
+
// silently drifted apart before (this file said 1.0.0 while package.json
|
|
22
|
+
// had already moved to 1.0.1).
|
|
23
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
24
|
+
const { version: CLI_VERSION } = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'))
|
|
25
|
+
|
|
17
26
|
const program = new Command()
|
|
18
27
|
|
|
19
28
|
const PACKAGES_OPTION = [
|
|
@@ -85,9 +94,9 @@ function wrapText(text, width) {
|
|
|
85
94
|
program
|
|
86
95
|
.name('polyrepo')
|
|
87
96
|
.description(
|
|
88
|
-
'Manage local npm package repos: pick which directories to scan (setup), clone missing ones from GitHub (clone), see their state (list), outdated dependencies (outdated), or open PRs (prs), run a health check (doctor), keep them on an up-to-date
|
|
97
|
+
'Manage local npm package repos: pick which directories to scan (setup), clone missing ones from GitHub (clone), see their state (list), outdated dependencies (outdated), or open PRs (prs), run a health check (doctor), keep them on an up-to-date default branch (switch-master), release a version through a PR (bump), publish to npm (publish), tag an already-current version (tag), create GitHub Releases (release), or run any command across every repo (exec).',
|
|
89
98
|
)
|
|
90
|
-
.version(
|
|
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
|
|
127
|
+
$ polyrepo switch-master Update selected repos to their latest default branch
|
|
119
128
|
$ polyrepo bump --dry-run Preview a version bump, nothing is pushed
|
|
120
129
|
$ polyrepo bump --minor --packages a,b --yes Bump specific packages' minor version, non-interactively
|
|
121
130
|
$ polyrepo publish Publish packages that are ahead of the registry
|
|
@@ -285,41 +294,101 @@ Examples:
|
|
|
285
294
|
|
|
286
295
|
program
|
|
287
296
|
.command('doctor')
|
|
288
|
-
.description('Check environment
|
|
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-master\`), or ahead only (unpushed local
|
|
326
|
+
commits) — surfaced before a command trips over it.
|
|
327
|
+
Branch protection whether each repo's default branch actually has
|
|
328
|
+
GitHub branch protection enabled. Report-only —
|
|
329
|
+
enabling protection is a policy choice, not
|
|
330
|
+
something to set on your behalf.
|
|
331
|
+
Stale bump branches \`bump\` merges through a PR with the branch left on
|
|
332
|
+
origin (see \`bump\` above), so a local copy sticks
|
|
333
|
+
around too. Reports how many have an already-merged
|
|
334
|
+
PR; \`--clean-branches\` turns that into a checkbox
|
|
335
|
+
to delete the local ones you pick (\`git branch -d\`
|
|
336
|
+
— never the branch on origin, and refuses instead
|
|
337
|
+
of forcing if a branch isn't actually fully merged
|
|
338
|
+
locally).
|
|
339
|
+
Cross-package deps does any local package's dependency range no longer
|
|
340
|
+
match another local package's current version.
|
|
341
|
+
|
|
342
|
+
Run this first if any other command is behaving strangely, and any time
|
|
343
|
+
you rename a branch on GitHub or want to check for accumulated cruft.
|
|
299
344
|
|
|
300
345
|
Examples:
|
|
301
346
|
$ polyrepo doctor
|
|
347
|
+
$ polyrepo doctor --clean-branches
|
|
302
348
|
`,
|
|
303
349
|
)
|
|
304
|
-
.action(() =>
|
|
350
|
+
.action((opts) =>
|
|
351
|
+
doctorCommand({
|
|
352
|
+
configPath: program.opts().config,
|
|
353
|
+
cleanBranches: Boolean(opts.cleanBranches),
|
|
354
|
+
}),
|
|
355
|
+
)
|
|
305
356
|
|
|
306
357
|
program
|
|
307
358
|
.command('switch-master')
|
|
308
359
|
.alias('sm')
|
|
309
|
-
.description('Pick repos and switch each to
|
|
360
|
+
.description('Pick repos and switch each to its up-to-date default branch.')
|
|
310
361
|
.option(...PACKAGES_OPTION)
|
|
311
362
|
.option(...YES_OPTION)
|
|
363
|
+
.option(
|
|
364
|
+
'--force',
|
|
365
|
+
"Discard uncommitted changes and any local-only commits on a repo's default branch, hard-resetting it to match origin.",
|
|
366
|
+
)
|
|
312
367
|
.addHelpText(
|
|
313
368
|
'after',
|
|
314
369
|
`
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
repo
|
|
370
|
+
Each repo's default branch is detected per repo (from origin — GitHub
|
|
371
|
+
defaults new repos to "main", but plenty of people rename it, "master"
|
|
372
|
+
included, so this never assumes one name for every repo). For each
|
|
373
|
+
selected repo: fetch, checkout its default branch, fast-forward-only
|
|
374
|
+
merge. A repo with uncommitted changes is skipped with a warning, never
|
|
375
|
+
touched. If the local default branch has diverged from origin
|
|
376
|
+
(fast-forward impossible), that repo is reported and left alone for you
|
|
377
|
+
to resolve by hand.
|
|
378
|
+
|
|
379
|
+
--force changes this: dirty repos are no longer skipped, and every
|
|
380
|
+
selected repo gets \`git checkout -f <default branch>\` + \`git reset --hard
|
|
381
|
+
origin/<default branch>\` instead of the safe fast-forward-only merge —
|
|
382
|
+
uncommitted changes to tracked files and any local-only commits on that
|
|
383
|
+
branch are permanently discarded (untracked files are left alone, this
|
|
384
|
+
isn't \`git clean\`). The proceed confirmation says how many selected
|
|
385
|
+
repos are dirty and defaults to "No" when --force would actually discard
|
|
386
|
+
something.
|
|
319
387
|
|
|
320
388
|
Examples:
|
|
321
389
|
$ polyrepo switch-master
|
|
322
390
|
$ polyrepo sm --packages vue-toast-kit,os-detect --yes
|
|
391
|
+
$ polyrepo sm --packages vue-toast-kit --force Discard its local changes and hard-reset to origin
|
|
323
392
|
`,
|
|
324
393
|
)
|
|
325
394
|
.action((opts) =>
|
|
@@ -327,12 +396,13 @@ Examples:
|
|
|
327
396
|
configPath: program.opts().config,
|
|
328
397
|
packages: opts.packages ? opts.packages.split(',') : undefined,
|
|
329
398
|
yes: Boolean(opts.yes),
|
|
399
|
+
force: Boolean(opts.force),
|
|
330
400
|
}),
|
|
331
401
|
)
|
|
332
402
|
|
|
333
403
|
program
|
|
334
404
|
.command('bump')
|
|
335
|
-
.description('Pick packages, bump their version (patch by default), PR, merge to
|
|
405
|
+
.description('Pick packages, bump their version (patch by default), PR, merge to the default branch, and tag.')
|
|
336
406
|
.option('--dry-run', 'Print every step without pushing, opening, merging, or tagging anything for real.')
|
|
337
407
|
.option('--minor', 'Bump the minor version instead of patch (e.g. 1.2.9 → 1.3.0).')
|
|
338
408
|
.option('--major', 'Bump the major version instead of patch (e.g. 1.2.9 → 2.0.0).')
|
|
@@ -421,9 +491,10 @@ program
|
|
|
421
491
|
`
|
|
422
492
|
For a package whose version was bumped some other way (not through
|
|
423
493
|
\`polyrepo bump\`, or before it started tagging) — puts the \`v<version>\` tag on
|
|
424
|
-
|
|
425
|
-
something to work from. Re-syncs
|
|
426
|
-
\`bump\` does. Already-tagged packages are
|
|
494
|
+
its default branch's current tip, no version change and no PR, so
|
|
495
|
+
\`polyrepo release\` has something to work from. Re-syncs the default branch
|
|
496
|
+
first for each package, same as \`bump\` does. Already-tagged packages are
|
|
497
|
+
shown but unchecked by default
|
|
427
498
|
(picking one anyway just confirms the tag is there, harmless). After
|
|
428
499
|
tagging, asks whether to create a GitHub Release right away for whatever
|
|
429
500
|
was just tagged (same as running \`polyrepo release\` for exactly those
|
package/src/masterSync.js
CHANGED
|
@@ -1,18 +1,35 @@
|
|
|
1
|
-
import { MASTER_BRANCH } from './config.js'
|
|
2
1
|
import { git } from './exec.js'
|
|
3
2
|
|
|
4
|
-
// fetch → checkout
|
|
5
|
-
// (never skipped under --dry-run) since it's
|
|
6
|
-
// downstream logic needs an accurate picture of
|
|
7
|
-
|
|
3
|
+
// fetch → checkout the repo's default branch → fast-forward-only merge.
|
|
4
|
+
// Always run for real (never skipped under --dry-run) since it's
|
|
5
|
+
// read-only/reversible and downstream logic needs an accurate picture of
|
|
6
|
+
// where the default branch actually is. Uses `repo.defaultBranch`
|
|
7
|
+
// (detected per repo — see repos.js's detectDefaultBranchAsync) rather
|
|
8
|
+
// than assuming "master", since that isn't universal.
|
|
9
|
+
//
|
|
10
|
+
// `force: true` (only `switch-master --force` sets this) trades the safe
|
|
11
|
+
// fast-forward-only merge for `checkout -f` + `reset --hard` — it discards
|
|
12
|
+
// any uncommitted changes to tracked files and any local commits the
|
|
13
|
+
// default branch has that origin doesn't, unconditionally. Untracked
|
|
14
|
+
// files are left alone (this isn't `git clean`). Callers that don't pass
|
|
15
|
+
// it keep the original safe behavior — bump/tag rely on that, they never
|
|
16
|
+
// force.
|
|
17
|
+
export function syncMaster(repo, { force = false } = {}) {
|
|
18
|
+
const branch = repo.defaultBranch
|
|
8
19
|
if (!git(repo.path, ['fetch', 'origin']).ok) {
|
|
9
20
|
return { ok: false, message: 'git fetch origin failed.' }
|
|
10
21
|
}
|
|
11
|
-
if (!git(repo.path, ['checkout',
|
|
12
|
-
return { ok: false, message: `git checkout ${
|
|
22
|
+
if (!git(repo.path, ['checkout', ...(force ? ['-f'] : []), branch]).ok) {
|
|
23
|
+
return { ok: false, message: `git checkout ${branch} failed.` }
|
|
13
24
|
}
|
|
14
|
-
if (
|
|
15
|
-
|
|
25
|
+
if (force) {
|
|
26
|
+
if (!git(repo.path, ['reset', '--hard', `origin/${branch}`]).ok) {
|
|
27
|
+
return { ok: false, message: `git reset --hard origin/${branch} failed.` }
|
|
28
|
+
}
|
|
29
|
+
return { ok: true }
|
|
30
|
+
}
|
|
31
|
+
if (!git(repo.path, ['merge', '--ff-only', `origin/${branch}`]).ok) {
|
|
32
|
+
return { ok: false, message: `Local ${branch} has diverged from origin — resolve manually.` }
|
|
16
33
|
}
|
|
17
34
|
return { ok: true }
|
|
18
35
|
}
|
package/src/repos.js
CHANGED
|
@@ -16,6 +16,44 @@ function toEntry(dirPath) {
|
|
|
16
16
|
}
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
// GitHub itself defaults a new repo to "main", and plenty of people rename
|
|
20
|
+
// it back to "master" (or something else) — there's no one right answer,
|
|
21
|
+
// so this is only the last resort once nothing else could tell us.
|
|
22
|
+
const FALLBACK_DEFAULT_BRANCH = 'main'
|
|
23
|
+
|
|
24
|
+
async function readCachedOriginHead(repo) {
|
|
25
|
+
const result = await gitAsync(repo.path, ['symbolic-ref', 'refs/remotes/origin/HEAD'])
|
|
26
|
+
if (!result.ok || !result.stdout) return null
|
|
27
|
+
const match = result.stdout.match(/^refs\/remotes\/origin\/(.+)$/)
|
|
28
|
+
return match ? match[1] : null
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Detected once per repo (cached alongside branch/clean below) rather than
|
|
32
|
+
// assumed — a mixed folder of repos can easily have some on "master" and
|
|
33
|
+
// some on "main". In order:
|
|
34
|
+
// 1. the locally cached origin/HEAD ref — set by `git clone` (or a prior
|
|
35
|
+
// `git remote set-head`), no network needed, the common case;
|
|
36
|
+
// 2. otherwise ask origin directly, read-only (`git ls-remote --symref`
|
|
37
|
+
// doesn't write any local ref, unlike `git remote set-head --auto`);
|
|
38
|
+
// 3. offline or no working origin — guess from whichever of
|
|
39
|
+
// master/main actually exists as a local branch;
|
|
40
|
+
// 4. still nothing to go on — GitHub's own default, "main".
|
|
41
|
+
export async function detectDefaultBranchAsync(repo) {
|
|
42
|
+
const cached = await readCachedOriginHead(repo)
|
|
43
|
+
if (cached) return cached
|
|
44
|
+
|
|
45
|
+
const remoteHead = await gitAsync(repo.path, ['ls-remote', '--symref', 'origin', 'HEAD'])
|
|
46
|
+
const remoteMatch = remoteHead.ok && remoteHead.stdout.match(/^ref:\s*refs\/heads\/(\S+)\s+HEAD/m)
|
|
47
|
+
if (remoteMatch) return remoteMatch[1]
|
|
48
|
+
|
|
49
|
+
for (const candidate of ['master', 'main']) {
|
|
50
|
+
const exists = await gitAsync(repo.path, ['show-ref', '--verify', '--quiet', `refs/heads/${candidate}`])
|
|
51
|
+
if (exists.ok) return candidate
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return FALLBACK_DEFAULT_BRANCH
|
|
55
|
+
}
|
|
56
|
+
|
|
19
57
|
// `config.roots` — folders whose direct subdirectories are packages (the
|
|
20
58
|
// original C:\work\NPM-style layout). `config.packages` — individual
|
|
21
59
|
// package folders given directly, for a one-off repo that doesn't live
|
|
@@ -76,18 +114,19 @@ export function readPackageJson(repo) {
|
|
|
76
114
|
}
|
|
77
115
|
|
|
78
116
|
// Full snapshot used everywhere a package list is shown: name, version,
|
|
79
|
-
// current branch, and whether the working tree has
|
|
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) {
|
|
@@ -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
|
+
})
|