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