@wmfs/lerna-sync 1.38.0 → 1.39.0
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/.circleci/config.yml +21 -0
- package/CHANGELOG.md +15 -0
- package/README.md +147 -59
- package/lib/discover.js +52 -0
- package/lib/execute-plan.js +141 -0
- package/lib/github-graphql-query.js +39 -36
- package/lib/index.js +78 -345
- package/lib/inspect.js +99 -0
- package/lib/plan-sync.js +182 -0
- package/lib/report.js +195 -0
- package/lib/utils/git.js +59 -0
- package/lib/utils/map-limit.js +17 -0
- package/lib/utils/package-folders-from-patterns.js +25 -0
- package/package.json +7 -7
- package/test/discover-spec.js +128 -0
- package/test/execute-plan-spec.js +233 -0
- package/test/helpers/fixtures.js +64 -0
- package/test/helpers/git-sandbox.js +87 -0
- package/test/inspect-spec.js +148 -0
- package/test/plan-sync-spec.js +247 -0
- package/test/report-spec.js +178 -0
- package/test/sync-spec.js +130 -8
- package/test/utils-spec.js +136 -0
- package/lib/actions/clone.js +0 -46
- package/lib/actions/index.js +0 -4
- package/lib/actions/pull.js +0 -52
- package/lib/utils/Messages.js +0 -69
- package/lib/utils/run.js +0 -11
package/.circleci/config.yml
CHANGED
|
@@ -65,6 +65,27 @@ jobs:
|
|
|
65
65
|
key: v1-deps-{{ checksum "package.json" }}
|
|
66
66
|
paths:
|
|
67
67
|
- node_modules
|
|
68
|
+
- run:
|
|
69
|
+
name: get github app token
|
|
70
|
+
command: |
|
|
71
|
+
node -e '
|
|
72
|
+
const crypto = require("crypto");
|
|
73
|
+
const key = Buffer.from(process.env.GH_APP_PRIVATE_KEY_B64, "base64").toString();
|
|
74
|
+
const b64 = o => Buffer.from(JSON.stringify(o)).toString("base64url");
|
|
75
|
+
const now = Math.floor(Date.now() / 1000);
|
|
76
|
+
const unsigned = b64({ alg: "RS256", typ: "JWT" }) + "." +
|
|
77
|
+
b64({ iat: now - 60, exp: now + 540, iss: process.env.GH_APP_ID });
|
|
78
|
+
const jwt = unsigned + "." + crypto.sign("RSA-SHA256", Buffer.from(unsigned), key).toString("base64url");
|
|
79
|
+
const headers = { Authorization: `Bearer ${jwt}`, Accept: "application/vnd.github+json" };
|
|
80
|
+
const repo = `${process.env.CIRCLE_PROJECT_USERNAME}/${process.env.CIRCLE_PROJECT_REPONAME}`;
|
|
81
|
+
(async () => {
|
|
82
|
+
const inst = await (await fetch(`https://api.github.com/repos/${repo}/installation`, { headers })).json();
|
|
83
|
+
const res = await (await fetch(`https://api.github.com/app/installations/${inst.id}/access_tokens`, { method: "POST", headers })).json();
|
|
84
|
+
if (!res.token) { console.error(inst, res); process.exit(1); }
|
|
85
|
+
console.log(`export GH_TOKEN=${res.token}`);
|
|
86
|
+
console.log(`export GIT_CREDENTIALS=x-access-token:${res.token}`);
|
|
87
|
+
})();
|
|
88
|
+
' >> "$BASH_ENV"
|
|
68
89
|
- run:
|
|
69
90
|
name: release
|
|
70
91
|
command: npx semantic-release
|
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,18 @@
|
|
|
1
|
+
## [1.39.0](https://github.com/wmfs/lerna-sync/compare/v1.38.0...v1.39.0) (2026-10-22)
|
|
2
|
+
|
|
3
|
+
### New Feature(s) :rocket:
|
|
4
|
+
|
|
5
|
+
* [sc-33783] Improve Lerna Sync scalability ([#222](https://github.com/wmfs/lerna-sync/issues/222)) ([74ee189](https://github.com/wmfs/lerna-sync/commit/74ee189080c367190ae28c7c405158c310c94b14))
|
|
6
|
+
* try new token approach ([#225](https://github.com/wmfs/lerna-sync/issues/225)) ([bff962d](https://github.com/wmfs/lerna-sync/commit/bff962d8ebdced954dd10833dc2e89edb63c7326))
|
|
7
|
+
|
|
8
|
+
### Build system dependencies :hammer:
|
|
9
|
+
|
|
10
|
+
* **deps-dev:** update dependency mocha to v12.0.3 ([#224](https://github.com/wmfs/lerna-sync/issues/224)) ([c4ab8d3](https://github.com/wmfs/lerna-sync/commit/c4ab8d3f7adffd28be01616edc71422687a232f8))
|
|
11
|
+
|
|
12
|
+
### CICD Configuration Changes :construction_worker:
|
|
13
|
+
|
|
14
|
+
* new step in config.yml ([76154e6](https://github.com/wmfs/lerna-sync/commit/76154e6c6962dcd938d7d3c2e94019849251fe3a))
|
|
15
|
+
|
|
1
16
|
## [1.38.0](https://github.com/wmfs/lerna-sync/compare/v1.37.1...v1.38.0) (2026-09-30)
|
|
2
17
|
|
|
3
18
|
### Bug Fix(es) :bug:
|
package/README.md
CHANGED
|
@@ -21,99 +21,187 @@ A package to synchronize distributed GitHub repos inside a Lerna monorepo.
|
|
|
21
21
|
|
|
22
22
|
**This is the situation *lerna-sync* is designed to help with.** :smiley:
|
|
23
23
|
|
|
24
|
-
##
|
|
24
|
+
## How it works
|
|
25
25
|
|
|
26
|
-
You
|
|
26
|
+
You keep an **empty husk** of a monorepo: a `lerna.json` and a set of empty package directories. Our [tymly](https://github.com/wmfs/tymly) repo is an example. Each time you sync, lerna-sync:
|
|
27
|
+
|
|
28
|
+
1. **Discovers** every repo in your GitHub organization with one paginated GraphQL query, collecting each repo's default branch, latest commit and `package.json`. Archived repos and forks are skipped.
|
|
29
|
+
2. **Inspects** the directories already in your monorepo: which are git repos, which branch is checked out, and whether there are local changes or a merge or rebase in progress.
|
|
30
|
+
3. **Plans** what to do with every repo, using your [routing function](#routing-function) to decide which repos belong in the monorepo and where.
|
|
31
|
+
4. **Executes** the plan, running git in parallel, and prints a summary of what happened and what needs your attention.
|
|
32
|
+
|
|
33
|
+
### What happens to each repo
|
|
34
|
+
|
|
35
|
+
lerna-sync never creates merge commits and never touches a branch you're working on.
|
|
36
|
+
|
|
37
|
+
| Local state | What lerna-sync does |
|
|
38
|
+
| ----------- | -------------------- |
|
|
39
|
+
| Not cloned yet | Clones it into the directory the routing function chose |
|
|
40
|
+
| On the default branch, clean | Fetches, then fast-forwards (`git merge --ff-only`) |
|
|
41
|
+
| On another branch | Fetches only. Your branch and working tree are left alone, and the report lists any new commits on the default branch for you to merge or rebase yourself. Your local default branch is fast-forwarded in the background when that's safe. |
|
|
42
|
+
| Detached HEAD | Fetches only, as above |
|
|
43
|
+
| Uncommitted changes on the default branch | Fetches only, and reports how far behind it is |
|
|
44
|
+
| Unpushed commits, or diverged from GitHub | Fetches only, and reports it |
|
|
45
|
+
| Untracked files that an update would overwrite | Doesn't update, and reports it |
|
|
46
|
+
| Mid-merge or mid-rebase | Leaves it alone, and reports it |
|
|
47
|
+
| A directory that isn't a git repo | Leaves it alone. Never clones over it. |
|
|
48
|
+
| `origin` points at a different owner (e.g. a personal fork) | Leaves it alone, and reports it |
|
|
49
|
+
| No longer in the org, or no longer selected by the routing function | Leaves it alone, and lists it under *Housekeeping* |
|
|
50
|
+
| In a different directory from the one the routing function now chooses | Updates it as normal, with a warning. Never moves it. |
|
|
51
|
+
|
|
52
|
+
Each repo's **default branch is read from GitHub**, so repos using `main` work as well as those using `master`.
|
|
53
|
+
|
|
54
|
+
## Requirements
|
|
55
|
+
|
|
56
|
+
* [Node.js](https://nodejs.org/en/download/) 20 or later.
|
|
57
|
+
* [Git](https://git-scm.com/downloads) on your `PATH`.
|
|
58
|
+
* A **[GitHub access token](https://github.com/settings/tokens)** that can read your organization's repos (see [Credentials](#credentials)).
|
|
59
|
+
|
|
60
|
+
## Installation
|
|
27
61
|
|
|
28
62
|
``` bash
|
|
29
|
-
npm install lerna
|
|
63
|
+
npm install @wmfs/lerna-sync --save
|
|
30
64
|
```
|
|
31
65
|
|
|
32
|
-
##
|
|
66
|
+
## Usage
|
|
33
67
|
|
|
34
|
-
|
|
68
|
+
``` javascript
|
|
69
|
+
const path = require('path')
|
|
70
|
+
const LernaSync = require('@wmfs/lerna-sync')
|
|
71
|
+
|
|
72
|
+
async function main () {
|
|
73
|
+
const lernaSync = new LernaSync({
|
|
74
|
+
monorepoPath: path.resolve(__dirname, '..'),
|
|
75
|
+
gitHubToken: process.env.MY_GITHUB_TOKEN,
|
|
76
|
+
gitHubOrgName: 'wmfs',
|
|
77
|
+
lernaPackageRouterFunction: routePackage
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
const { errorCount } = await lernaSync.sync()
|
|
81
|
+
if (errorCount > 0) process.exitCode = 1
|
|
82
|
+
}
|
|
35
83
|
|
|
36
|
-
|
|
84
|
+
main().catch(err => {
|
|
85
|
+
console.error(err)
|
|
86
|
+
process.exitCode = 1
|
|
87
|
+
})
|
|
88
|
+
```
|
|
37
89
|
|
|
38
|
-
|
|
39
|
-
* The [lerna.json](https://github.com/wmfs/tymly/blob/master/lerna.json) file is pretty basic stuff.
|
|
90
|
+
See [tymly/lib/sync.js](https://github.com/wmfs/tymly/blob/master/lib/sync.js) for a complete example, including command-line flags.
|
|
40
91
|
|
|
41
|
-
|
|
92
|
+
### Options
|
|
42
93
|
|
|
43
|
-
|
|
94
|
+
| Option | Required | Default | Notes |
|
|
95
|
+
| ------ | -------- | ------- | ----- |
|
|
96
|
+
| `monorepoPath` | Yes | | Path to the monorepo husk: the directory containing `lerna.json`. |
|
|
97
|
+
| `gitHubToken` | Yes | | Token used to query the GitHub API. |
|
|
98
|
+
| `gitHubOrgName` | Yes | | The GitHub organization to sync from, e.g. `wmfs`. |
|
|
99
|
+
| `lernaPackageRouterFunction` | Yes | | Decides which repos to sync and where. See [Routing function](#routing-function). |
|
|
100
|
+
| `dryRun` | No | `false` | Plan and print what would happen, without running any git commands that change anything. |
|
|
101
|
+
| `verbose` | No | `false` | Also list every repo that was ignored, with the reason. |
|
|
102
|
+
| `concurrency` | No | `8` when inspecting, `6` for git network operations | How many repos to work on at once. Lower it on slow disks or connections. |
|
|
103
|
+
| `cloneUrl` | No | `https://github.com/<org>/<repo>.git` | `(orgName, repoName) => url`. Use it to clone over SSH, e.g. `` (org, name) => `git@github.com:${org}/${name}.git` ``. |
|
|
104
|
+
| `updateLocalDefaultBranch` | No | `true` | When you're on another branch, also fast-forward your local default branch if it can be done safely. |
|
|
44
105
|
|
|
45
|
-
|
|
106
|
+
### Return value
|
|
46
107
|
|
|
47
|
-
|
|
108
|
+
`sync()` resolves with:
|
|
48
109
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
110
|
+
``` javascript
|
|
111
|
+
{
|
|
112
|
+
plan, // every decision lerna-sync made, with its reason
|
|
113
|
+
results, // what happened for each repo that git was run on (empty on a dry run)
|
|
114
|
+
errorCount, // repos where git failed
|
|
115
|
+
attentionCount // repos that need you to look at them
|
|
116
|
+
}
|
|
117
|
+
```
|
|
52
118
|
|
|
119
|
+
Use `errorCount` to set your script's exit code.
|
|
53
120
|
|
|
54
121
|
## Routing function
|
|
55
122
|
|
|
56
|
-
|
|
57
|
-
Also, you might like to take advantage of Lerna's support for multiple-package directories to help structure things a bit better.
|
|
123
|
+
Your organization probably has repos that shouldn't come anywhere near your monorepo. You might also want to use Lerna's support for multiple package directories to give things some structure.
|
|
58
124
|
|
|
59
|
-
|
|
125
|
+
The routing function handles both. lerna-sync calls it once for each repo in the organization that has a valid `package.json`, passing that `package.json` as a parsed object. Repos with no `package.json` are skipped without calling it.
|
|
126
|
+
|
|
127
|
+
It should return one of:
|
|
128
|
+
|
|
129
|
+
* **A directory name**, such as `'plugins'`, to sync the repo into that directory. It must match an entry in the `packages` array of the monorepo's `lerna.json` (`'plugins'` for `"plugins/*"`). If it doesn't, the repo is ignored and the reason says so.
|
|
130
|
+
* **`{ skip: 'reason' }`** to leave the repo out and say why. The reason appears in the report, which helps when someone expects a repo that isn't there.
|
|
131
|
+
* **`null` or `undefined`** to leave the repo out silently. Use this for repos that are nothing to do with the monorepo.
|
|
132
|
+
|
|
133
|
+
If the function throws, that repo is ignored with the error as its reason, and the rest of the sync carries on.
|
|
60
134
|
|
|
61
135
|
``` javascript
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
if (
|
|
75
|
-
|
|
76
|
-
const keywords = gitHubPackageObj.keywords
|
|
77
|
-
keywordToPackageMap.forEach(
|
|
78
|
-
tuple => {
|
|
79
|
-
if (keywords.indexOf(tuple[0]) !== -1) {
|
|
80
|
-
lernaPackageName = tuple[1]
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
)
|
|
84
|
-
return lernaPackageName
|
|
136
|
+
// Use keywords in each repo's package.json to pick its directory.
|
|
137
|
+
// The first matching keyword wins.
|
|
138
|
+
const KEYWORD_FOLDERS = [
|
|
139
|
+
['blueprint', 'blueprints'],
|
|
140
|
+
['plugin', 'plugins'],
|
|
141
|
+
['package', 'packages']
|
|
142
|
+
]
|
|
143
|
+
|
|
144
|
+
function routePackage (pkg) {
|
|
145
|
+
const keywords = Array.isArray(pkg.keywords) ? pkg.keywords : []
|
|
146
|
+
if (!keywords.includes('tymly')) return null
|
|
147
|
+
|
|
148
|
+
if (pkg.config && pkg.config.tymly && pkg.config.tymly.sync === false) {
|
|
149
|
+
return { skip: 'Opted out (config.tymly.sync = false)' }
|
|
85
150
|
}
|
|
151
|
+
|
|
152
|
+
const match = KEYWORD_FOLDERS.find(([keyword]) => keywords.includes(keyword))
|
|
153
|
+
return match ? match[1] : { skip: 'No folder keyword' }
|
|
86
154
|
}
|
|
87
155
|
```
|
|
88
156
|
|
|
89
|
-
|
|
157
|
+
## The report
|
|
158
|
+
|
|
159
|
+
As each repo finishes, lerna-sync prints a progress line, then a summary grouped by what you need to do:
|
|
160
|
+
|
|
161
|
+
```
|
|
162
|
+
Downloading repo information from https://github.com/wmfs
|
|
163
|
+
Cloned plugins/tymly-rbac-plugin
|
|
164
|
+
On branch plugins/tymly-pg-plugin (feature/connection-pool, master has 1 new commit)
|
|
165
|
+
Updated plugins/tymly-core (pulled 1 commit)
|
|
166
|
+
|
|
167
|
+
Cloned (1)
|
|
168
|
+
plugins/tymly-rbac-plugin Plugin for Tymly
|
|
169
|
+
|
|
170
|
+
Updated (1)
|
|
171
|
+
plugins/tymly-core 1 new commit
|
|
172
|
+
ff3037f fix: handle missing state machine (Jane Smith, 2 hours ago)
|
|
90
173
|
|
|
91
|
-
|
|
174
|
+
Needs your attention (1)
|
|
175
|
+
plugins/tymly-pg-plugin On feature/connection-pool: master has 1 commit(s) you don't
|
|
176
|
+
8ccdfe1 feat: support schema search paths (Jane Smith, 3 hours ago)
|
|
92
177
|
|
|
93
|
-
|
|
178
|
+
1 up to date, 1 cloned, 1 updated, 1 need attention, 0 errors
|
|
94
179
|
|
|
95
|
-
|
|
180
|
+
Dependencies may have changed. Reinstall/relink before running anything.
|
|
181
|
+
|
|
182
|
+
Done (14 seconds).
|
|
183
|
+
```
|
|
96
184
|
|
|
97
|
-
|
|
185
|
+
The possible sections are *Cloned*, *Updated*, *On branches (left alone)*, *Needs your attention*, *Housekeeping* and *Errors*. The reminder about dependencies only appears when a repo was cloned or an update changed a `package.json`.
|
|
98
186
|
|
|
99
|
-
|
|
187
|
+
## Credentials
|
|
100
188
|
|
|
101
|
-
|
|
189
|
+
lerna-sync uses credentials in two separate places:
|
|
102
190
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
| `LERNA_SYNC_MONOREPO_PATH` | Should be set to a directory where a `lerna.json` can be found. |
|
|
106
|
-
| `LERNA_SYNC_GITHUB_TOKEN` | Generate a new GitHub token [here](https://github.com/settings/tokens), a description of "lerna-sync connection" will do.
|
|
107
|
-
| `LERNA_SYNC_GITHUB_ORG` | Name of the GitHub organization holding all your repos. |
|
|
108
|
-
| `LERNA_SYNC_GITHUB_USER` | Your GitHub username (used in conjunction with `LERNA_SYNC_GITHUB_TOKEN` to fetch/pull repos. |
|
|
191
|
+
* **The GitHub API** (discovering repos) uses `gitHubToken`. A classic token needs the **repo** scope to see private repos. A fine-grained token needs read access to **Contents** and **Metadata** for the organization's repos. Without that, private repos are silently missing from the results.
|
|
192
|
+
* **Git** (clone and fetch) uses **your own git credentials**, not the token. For HTTPS, set up a credential helper such as [Git Credential Manager](https://github.com/git-ecosystem/git-credential-manager) or run `gh auth setup-git`. For SSH, use the `cloneUrl` option and make sure your key is loaded in `ssh-agent`.
|
|
109
193
|
|
|
110
|
-
|
|
194
|
+
Because many git commands run in parallel with their output captured, lerna-sync runs git non-interactively. A missing credential makes that repo fail with an error instead of waiting for a password prompt nobody can see. Each git command also has a 5-minute timeout. A credential manager that opens its own sign-in window can still do so.
|
|
195
|
+
|
|
196
|
+
## Development
|
|
111
197
|
|
|
112
198
|
``` bash
|
|
113
|
-
npm
|
|
199
|
+
npm test # mocha with coverage (nyc)
|
|
200
|
+
npm run lint # standard
|
|
114
201
|
```
|
|
115
202
|
|
|
116
|
-
|
|
203
|
+
The tests need git on your `PATH` but no network access or GitHub token. Tests that exercise git create throwaway repos in your temp directory, using local bare repos as the "GitHub" origin, and ignore your global git config.
|
|
204
|
+
|
|
205
|
+
## License
|
|
117
206
|
|
|
118
|
-
|
|
119
|
-
[MIT](https://github.com/wmfs/lerna-sync/blob/master/LICENSE)
|
|
207
|
+
[MIT](https://github.com/wmfs/lerna-sync/blob/master/LICENSE)
|
package/lib/discover.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
const query = require('./github-graphql-query')
|
|
2
|
+
const chalk = require('chalk')
|
|
3
|
+
|
|
4
|
+
function parsePackageJson (repoName, blob) {
|
|
5
|
+
if (!blob || typeof blob.text !== 'string') return null
|
|
6
|
+
if (blob.isTruncated) {
|
|
7
|
+
console.warn(chalk.yellow(`package.json for ${repoName} was truncated, skipping`))
|
|
8
|
+
return null
|
|
9
|
+
}
|
|
10
|
+
try {
|
|
11
|
+
return JSON.parse(blob.text)
|
|
12
|
+
} catch (e) {
|
|
13
|
+
console.warn(chalk.yellow(`Unable to parse package.json for ${repoName}`))
|
|
14
|
+
return null
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
module.exports = async function discover (graphql, org) {
|
|
19
|
+
const repos = []
|
|
20
|
+
let after = null
|
|
21
|
+
|
|
22
|
+
console.log(chalk.gray(`Downloading repo information from https://github.com/${org}`))
|
|
23
|
+
|
|
24
|
+
do {
|
|
25
|
+
const { organization } = await graphql(query.orgRepos, { org, after })
|
|
26
|
+
const { nodes, pageInfo } = organization.repositories
|
|
27
|
+
|
|
28
|
+
for (const node of nodes) {
|
|
29
|
+
if (node.isArchived) continue // belt and braces
|
|
30
|
+
|
|
31
|
+
const branch = node.defaultBranchRef
|
|
32
|
+
if (!branch) continue // empty repo, nothing to sync
|
|
33
|
+
|
|
34
|
+
const commit = branch.target
|
|
35
|
+
const blob = node.packageJson
|
|
36
|
+
|
|
37
|
+
repos.push({
|
|
38
|
+
name: node.name,
|
|
39
|
+
isPrivate: node.isPrivate,
|
|
40
|
+
defaultBranch: branch.name,
|
|
41
|
+
sha: commit.oid,
|
|
42
|
+
messageHeadline: commit.messageHeadline,
|
|
43
|
+
author: commit.author,
|
|
44
|
+
packageJson: parsePackageJson(node.name, blob)
|
|
45
|
+
})
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
after = pageInfo.hasNextPage ? pageInfo.endCursor : null
|
|
49
|
+
} while (after)
|
|
50
|
+
|
|
51
|
+
return repos
|
|
52
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
const path = require('path')
|
|
2
|
+
const { git, tryGit } = require('./utils/git')
|
|
3
|
+
const mapLimit = require('./utils/map-limit')
|
|
4
|
+
const { ACTIONS, REASONS } = require('./plan-sync')
|
|
5
|
+
|
|
6
|
+
const OUTCOMES = Object.freeze({
|
|
7
|
+
CLONED: 'CLONED',
|
|
8
|
+
UPDATED: 'UPDATED',
|
|
9
|
+
CURRENT: 'CURRENT',
|
|
10
|
+
BEHIND: 'BEHIND', // fetched only; the default branch has commits HEAD doesn't
|
|
11
|
+
LOCAL_AHEAD: 'LOCAL_AHEAD', // unpushed commits on the default branch
|
|
12
|
+
DIVERGED: 'DIVERGED',
|
|
13
|
+
BLOCKED_BY_UNTRACKED: 'BLOCKED_BY_UNTRACKED',
|
|
14
|
+
FAILED: 'FAILED'
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
const HANDLERS = {
|
|
18
|
+
[ACTIONS.CLONE]: cloneRepo,
|
|
19
|
+
[ACTIONS.FAST_FORWARD]: fastForward,
|
|
20
|
+
[ACTIONS.FETCH_ONLY]: fetchOnly
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function executePlan (plan, options) {
|
|
24
|
+
const runnable = plan.decisions.filter(d => d.action !== ACTIONS.NONE)
|
|
25
|
+
|
|
26
|
+
return mapLimit(runnable, options.concurrency || 6, async decision => {
|
|
27
|
+
let result
|
|
28
|
+
try {
|
|
29
|
+
result = { decision, ...(await HANDLERS[decision.action](decision, options)) }
|
|
30
|
+
} catch (err) {
|
|
31
|
+
result = { decision, outcome: OUTCOMES.FAILED, error: cleanError(err) }
|
|
32
|
+
}
|
|
33
|
+
if (options.onResult) options.onResult(result) // progress line as each one finishes
|
|
34
|
+
return result
|
|
35
|
+
})
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function cloneRepo (d, { monorepoPath, gitHubOrgName, cloneUrl }) {
|
|
39
|
+
const dest = path.join(monorepoPath, ...d.folder.split('/'), d.remote.name)
|
|
40
|
+
const url = cloneUrl
|
|
41
|
+
? cloneUrl(gitHubOrgName, d.remote.name) // e.g. allow SSH: git@github.com:wmfs/x.git
|
|
42
|
+
: `https://github.com/${gitHubOrgName}/${d.remote.name}.git`
|
|
43
|
+
|
|
44
|
+
await git(['clone', '--quiet', url, dest], monorepoPath)
|
|
45
|
+
return { outcome: OUTCOMES.CLONED }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function fastForward (d) {
|
|
49
|
+
const cwd = d.local.path
|
|
50
|
+
const upstream = `origin/${d.defaultBranch}`
|
|
51
|
+
|
|
52
|
+
await gitFetch(cwd)
|
|
53
|
+
const { ahead, behind } = await aheadBehind(cwd, upstream)
|
|
54
|
+
|
|
55
|
+
if (behind === 0) {
|
|
56
|
+
return { outcome: ahead > 0 ? OUTCOMES.LOCAL_AHEAD : OUTCOMES.CURRENT, ahead, behind }
|
|
57
|
+
}
|
|
58
|
+
if (ahead > 0) {
|
|
59
|
+
return { outcome: OUTCOMES.DIVERGED, ahead, behind, commits: await incoming(cwd, upstream) }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const commits = await incoming(cwd, upstream)
|
|
63
|
+
const before = await git(['rev-parse', 'HEAD'], cwd)
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
await git(['merge', '--ff-only', '--quiet', upstream], cwd)
|
|
67
|
+
} catch (err) {
|
|
68
|
+
if (/untracked working tree files would be overwritten/i.test(err.stderr || '')) {
|
|
69
|
+
return { outcome: OUTCOMES.BLOCKED_BY_UNTRACKED, ahead, behind, commits }
|
|
70
|
+
}
|
|
71
|
+
throw err
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const changed = await git(['diff', '--name-only', before, 'HEAD', '--', 'package.json'], cwd)
|
|
75
|
+
return { outcome: OUTCOMES.UPDATED, ahead, behind, commits, packageJsonChanged: changed.length > 0 }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function fetchOnly (d, { updateLocalDefaultBranch = true }) {
|
|
79
|
+
const cwd = d.local.path
|
|
80
|
+
const upstream = `origin/${d.defaultBranch}`
|
|
81
|
+
|
|
82
|
+
await gitFetch(cwd)
|
|
83
|
+
|
|
84
|
+
// On a feature branch or detached HEAD, local master isn't checked out, so it can be
|
|
85
|
+
// fast-forwarded without touching the working tree. Skip it for LOCAL_CHANGES:
|
|
86
|
+
// there, master *is* checked out.
|
|
87
|
+
if (updateLocalDefaultBranch && d.reason !== REASONS.LOCAL_CHANGES) {
|
|
88
|
+
await fastForwardRef(cwd, d.defaultBranch, upstream)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const { ahead, behind } = await aheadBehind(cwd, upstream)
|
|
92
|
+
return {
|
|
93
|
+
outcome: behind > 0 ? OUTCOMES.BEHIND : OUTCOMES.CURRENT,
|
|
94
|
+
ahead,
|
|
95
|
+
behind,
|
|
96
|
+
commits: behind > 0 ? await incoming(cwd, upstream) : []
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Helpers
|
|
101
|
+
|
|
102
|
+
const gitFetch = cwd => git(['fetch', '--quiet', '--prune', 'origin'], cwd)
|
|
103
|
+
|
|
104
|
+
async function aheadBehind (cwd, upstream) {
|
|
105
|
+
const out = await git(['rev-list', '--left-right', '--count', `HEAD...${upstream}`], cwd)
|
|
106
|
+
const [ahead, behind] = out.split(/\s+/).map(Number)
|
|
107
|
+
return { ahead, behind }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function incoming (cwd, upstream, limit = 10) {
|
|
111
|
+
const out = await git(
|
|
112
|
+
['log', `--max-count=${limit}`, '--format=%h%x09%an%x09%ar%x09%s', `HEAD..${upstream}`],
|
|
113
|
+
cwd
|
|
114
|
+
)
|
|
115
|
+
if (!out) return []
|
|
116
|
+
return out.split('\n').map(line => {
|
|
117
|
+
const [sha, author, when, subject] = line.split('\t')
|
|
118
|
+
return { sha, author, when, subject }
|
|
119
|
+
})
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function fastForwardRef (cwd, branch, upstream) {
|
|
123
|
+
const ref = `refs/heads/${branch}`
|
|
124
|
+
const current = await tryGit(['rev-parse', '--verify', '--quiet', ref], cwd)
|
|
125
|
+
if (!current) return // no local master branch, nothing to update
|
|
126
|
+
|
|
127
|
+
// Exit code 0 only if local master is an ancestor of origin/master, i.e. a fast-forward
|
|
128
|
+
const isFastForward = await tryGit(['merge-base', '--is-ancestor', ref, upstream], cwd)
|
|
129
|
+
if (isFastForward === null) return
|
|
130
|
+
|
|
131
|
+
// Passing the old SHA makes this a compare-and-swap
|
|
132
|
+
await tryGit(['update-ref', ref, upstream, current], cwd)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function cleanError (err) {
|
|
136
|
+
if (err.killed) return err.message // e.g. "git fetch timed out after 300s"
|
|
137
|
+
const text = (err.stderr || err.message || String(err)).trim()
|
|
138
|
+
return text.split('\n').slice(-3).join('\n')
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
module.exports = { executePlan, OUTCOMES }
|
|
@@ -1,41 +1,44 @@
|
|
|
1
|
-
module.exports =
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
1
|
+
module.exports = {
|
|
2
|
+
orgRepos: `
|
|
3
|
+
query orgRepos($org: String!, $after: String) {
|
|
4
|
+
organization(login: $org) {
|
|
5
|
+
repositories(
|
|
6
|
+
first: 100
|
|
7
|
+
after: $after
|
|
8
|
+
isFork: false
|
|
9
|
+
isArchived: false
|
|
10
|
+
orderBy: { field: NAME, direction: ASC }
|
|
11
|
+
) {
|
|
12
|
+
pageInfo {
|
|
13
|
+
hasNextPage
|
|
14
|
+
endCursor
|
|
15
|
+
}
|
|
16
|
+
nodes {
|
|
17
|
+
name
|
|
18
|
+
isArchived
|
|
19
|
+
isPrivate
|
|
20
|
+
packageJson: object(expression: "HEAD:package.json") {
|
|
21
|
+
... on Blob {
|
|
22
|
+
text
|
|
23
|
+
isTruncated
|
|
24
|
+
}
|
|
8
25
|
}
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
target {
|
|
20
|
-
... on Commit {
|
|
21
|
-
history(first: 1) {
|
|
22
|
-
edges {
|
|
23
|
-
node {
|
|
24
|
-
oid
|
|
25
|
-
messageHeadline,
|
|
26
|
-
author {
|
|
27
|
-
name
|
|
28
|
-
date
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
}
|
|
26
|
+
defaultBranchRef {
|
|
27
|
+
name
|
|
28
|
+
target {
|
|
29
|
+
... on Commit {
|
|
30
|
+
oid
|
|
31
|
+
messageHeadline
|
|
32
|
+
author {
|
|
33
|
+
name
|
|
34
|
+
date
|
|
35
|
+
}
|
|
37
36
|
}
|
|
37
|
+
}
|
|
38
38
|
}
|
|
39
|
+
}
|
|
39
40
|
}
|
|
41
|
+
}
|
|
40
42
|
}
|
|
41
|
-
`
|
|
43
|
+
`
|
|
44
|
+
}
|