unirepo-cli 0.2.1 → 0.4.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/README.md +22 -2
- package/package.json +1 -1
- package/src/commands/add.js +3 -1
- package/src/commands/branch.js +3 -3
- package/src/commands/init.js +3 -1
- package/src/commands/pr.js +175 -0
- package/src/commands/pull.js +74 -19
- package/src/commands/status.js +2 -2
- package/src/git.js +85 -5
- package/src/github.js +60 -1
- package/src/index.js +99 -1
- package/src/templates.js +12 -1
- package/src/ui.js +16 -0
package/README.md
CHANGED
|
@@ -142,6 +142,7 @@ AI coding agents work best when they can see the full change at once.
|
|
|
142
142
|
| `status` | Show subtrees, branches, and what changed |
|
|
143
143
|
| `branch [name]` | Create or show the current push branch |
|
|
144
144
|
| `push [subtree...]` | Push changed subtrees upstream |
|
|
145
|
+
| `pr [subtree...]` | Open pull requests for changed subtree repos |
|
|
145
146
|
|
|
146
147
|
|
|
147
148
|
### init
|
|
@@ -173,13 +174,14 @@ Adds a repo to an existing workspace. The directory name defaults to the repo na
|
|
|
173
174
|
### pull
|
|
174
175
|
|
|
175
176
|
```bash
|
|
176
|
-
unirepo pull [subtree...]
|
|
177
|
+
unirepo pull [subtree...] [--prefix <name>]
|
|
177
178
|
```
|
|
178
179
|
|
|
179
|
-
Pulls upstream changes. Without arguments, pulls all tracked subtrees.
|
|
180
|
+
Pulls upstream changes. Without arguments, pulls all tracked subtrees. When you use `--branch` without naming a subtree, `unirepo` skips tracked subtrees that do not have that upstream branch.
|
|
180
181
|
|
|
181
182
|
| Flag | Effect |
|
|
182
183
|
| --- | --- |
|
|
184
|
+
| `--prefix <name>` | Pull only one tracked subtree |
|
|
183
185
|
| `--branch <name>` | Pull a specific upstream branch |
|
|
184
186
|
| `--full-history` | Pull full history instead of squash |
|
|
185
187
|
|
|
@@ -212,6 +214,24 @@ Pushes changed subtrees upstream. Without arguments, auto-detects which subtrees
|
|
|
212
214
|
| `--branch <name>` | Override the upstream branch name |
|
|
213
215
|
| `--dry-run` | Show what would run without executing |
|
|
214
216
|
|
|
217
|
+
### pr
|
|
218
|
+
|
|
219
|
+
```bash
|
|
220
|
+
unirepo pr [subtree...] --title <title> [--body <text>]
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
Opens one GitHub pull request per changed subtree repo. Without subtree arguments, `unirepo` auto-detects changed subtrees. Use explicit subtree names when you want PRs for only part of the workspace.
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
| Flag | Effect |
|
|
227
|
+
| --- | --- |
|
|
228
|
+
| `--title <title>` | Shared PR title for all selected repos |
|
|
229
|
+
| `--body <text>` | Shared PR description |
|
|
230
|
+
| `--base <name>` | Override the base branch for all selected repos |
|
|
231
|
+
| `--head <name>` | Override the head branch name (default: current branch) |
|
|
232
|
+
| `--draft` | Create draft pull requests |
|
|
233
|
+
| `--dry-run` | Show the `gh pr create` commands without executing |
|
|
234
|
+
|
|
215
235
|
|
|
216
236
|
## How It Works
|
|
217
237
|
|
package/package.json
CHANGED
package/src/commands/add.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { git, detectDefaultBranch, extractRepoName } from '../git.js';
|
|
1
|
+
import { git, detectDefaultBranch, extractRepoName, setConfiguredSubtreeBranch } from '../git.js';
|
|
2
2
|
import { validateGitSubtree, validateUrls, validateInsideMonorepo, validateNameAvailable, validateReachable } from '../validate.js';
|
|
3
3
|
import * as ui from '../ui.js';
|
|
4
4
|
|
|
@@ -39,6 +39,8 @@ export async function runAdd({ url, prefix, branch, fullHistory }) {
|
|
|
39
39
|
git(`subtree add --squash --prefix="${name}" "${name}" "${upstreamBranch}"`, { cwd });
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
setConfiguredSubtreeBranch(cwd, name, upstreamBranch);
|
|
43
|
+
|
|
42
44
|
// ── Done ─────────────────────────────────────────────────────────────────
|
|
43
45
|
ui.step(4, 4, 'Done!');
|
|
44
46
|
ui.addSummary(name, url);
|
package/src/commands/branch.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { git, getCurrentBranch, getSubtreePrefixes,
|
|
1
|
+
import { git, getCurrentBranch, getSubtreePrefixes, getTrackedSubtreeBranch } from '../git.js';
|
|
2
2
|
import { validateInsideMonorepo } from '../validate.js';
|
|
3
3
|
import * as ui from '../ui.js';
|
|
4
4
|
|
|
@@ -17,7 +17,7 @@ export async function runBranch({ name }) {
|
|
|
17
17
|
ui.info(`Current branch: ${currentBranch}`);
|
|
18
18
|
ui.blank();
|
|
19
19
|
for (const s of subtrees) {
|
|
20
|
-
const upstream =
|
|
20
|
+
const upstream = getTrackedSubtreeBranch(cwd, s.name) || 'unknown';
|
|
21
21
|
ui.info(` ${s.name} upstream: ${upstream} push target: ${currentBranch}`);
|
|
22
22
|
}
|
|
23
23
|
ui.blank();
|
|
@@ -49,6 +49,6 @@ export async function runBranch({ name }) {
|
|
|
49
49
|
ui.info(' 1. Make changes in subtree directories');
|
|
50
50
|
ui.info(' 2. Commit in the monorepo');
|
|
51
51
|
ui.info(` 3. unirepo push`);
|
|
52
|
-
ui.info(' 4.
|
|
52
|
+
ui.info(' 4. unirepo pr --title "..." --body "..."');
|
|
53
53
|
ui.blank();
|
|
54
54
|
}
|
package/src/commands/init.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { mkdirSync, writeFileSync, existsSync, rmSync } from 'node:fs';
|
|
2
2
|
import { resolve, join } from 'node:path';
|
|
3
|
-
import { git, detectDefaultBranch, extractRepoName } from '../git.js';
|
|
3
|
+
import { git, detectDefaultBranch, extractRepoName, setConfiguredSubtreeBranch } from '../git.js';
|
|
4
4
|
import { validateGitSubtree, validateUrls, validateNoDuplicateNames, validateReachable } from '../validate.js';
|
|
5
5
|
import { AGENTS_MD, GITIGNORE } from '../templates.js';
|
|
6
6
|
import * as ui from '../ui.js';
|
|
@@ -79,6 +79,8 @@ export async function runInit({ dir, repos, fullHistory }) {
|
|
|
79
79
|
git(`subtree add --squash --prefix="${name}" "${name}" "${branch}"`, { cwd: absDir });
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
setConfiguredSubtreeBranch(absDir, name, branch);
|
|
83
|
+
|
|
82
84
|
ui.success(`${name} imported`);
|
|
83
85
|
}
|
|
84
86
|
} catch (err) {
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getCurrentBranch,
|
|
3
|
+
getSubtreePrefixes,
|
|
4
|
+
getChangedSubtrees,
|
|
5
|
+
getTrackedSubtreeBranch,
|
|
6
|
+
hasRemoteBranch,
|
|
7
|
+
} from '../git.js';
|
|
8
|
+
import { isGhAvailable, getGitHubRepoSlugFromUrl, createPullRequest } from '../github.js';
|
|
9
|
+
import { validateInsideMonorepo } from '../validate.js';
|
|
10
|
+
import * as ui from '../ui.js';
|
|
11
|
+
|
|
12
|
+
function quoteShellArg(value) {
|
|
13
|
+
return JSON.stringify(value);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function selectPrSubtrees(allPrefixes, changedPrefixes, requestedSubtrees) {
|
|
17
|
+
if (!requestedSubtrees || requestedSubtrees.length === 0) {
|
|
18
|
+
return changedPrefixes;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const prefixNames = new Set(allPrefixes.map((prefix) => prefix.name));
|
|
22
|
+
for (const name of requestedSubtrees) {
|
|
23
|
+
if (!prefixNames.has(name)) {
|
|
24
|
+
throw new Error(`"${name}" is not a tracked subtree. Run "unirepo status" to see available subtrees.`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return allPrefixes.filter((prefix) => requestedSubtrees.includes(prefix.name));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function resolvePrBaseBranch({ requestedBase, trackedBranch }) {
|
|
32
|
+
return requestedBase || trackedBranch || 'main';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function planPrTargets({
|
|
36
|
+
allPrefixes,
|
|
37
|
+
changedPrefixes,
|
|
38
|
+
requestedSubtrees,
|
|
39
|
+
requestedBase,
|
|
40
|
+
headBranch,
|
|
41
|
+
getTrackedBranchFn = () => null,
|
|
42
|
+
getRepoSlugFn = () => null,
|
|
43
|
+
}) {
|
|
44
|
+
return selectPrSubtrees(allPrefixes, changedPrefixes, requestedSubtrees).map((prefix) => ({
|
|
45
|
+
name: prefix.name,
|
|
46
|
+
repo: getRepoSlugFn(prefix.url),
|
|
47
|
+
url: prefix.url,
|
|
48
|
+
base: resolvePrBaseBranch({
|
|
49
|
+
requestedBase,
|
|
50
|
+
trackedBranch: getTrackedBranchFn(prefix.name),
|
|
51
|
+
}),
|
|
52
|
+
head: headBranch,
|
|
53
|
+
}));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function runPr({ subtrees: requestedSubtrees, title, body, base, head, draft, dryRun }) {
|
|
57
|
+
const cwd = process.cwd();
|
|
58
|
+
|
|
59
|
+
ui.header('Creating pull requests');
|
|
60
|
+
ui.blank();
|
|
61
|
+
|
|
62
|
+
validateInsideMonorepo(cwd);
|
|
63
|
+
|
|
64
|
+
if (!isGhAvailable()) {
|
|
65
|
+
throw new Error('GitHub CLI is not available or not authenticated. Install gh and run "gh auth login".');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const currentBranch = getCurrentBranch(cwd);
|
|
69
|
+
const headBranch = head || currentBranch;
|
|
70
|
+
const allPrefixes = getSubtreePrefixes(cwd);
|
|
71
|
+
const changedPrefixes = getChangedSubtrees(cwd);
|
|
72
|
+
const targets = planPrTargets({
|
|
73
|
+
allPrefixes,
|
|
74
|
+
changedPrefixes,
|
|
75
|
+
requestedSubtrees,
|
|
76
|
+
requestedBase: base,
|
|
77
|
+
headBranch,
|
|
78
|
+
getTrackedBranchFn: (prefixName) => getTrackedSubtreeBranch(cwd, prefixName),
|
|
79
|
+
getRepoSlugFn: getGitHubRepoSlugFromUrl,
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
if (targets.length === 0) {
|
|
83
|
+
ui.info('No changed subtrees detected. Nothing to open PRs for.');
|
|
84
|
+
ui.info('Push a changed subtree first or pass explicit subtree names.');
|
|
85
|
+
ui.blank();
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
ui.info(`Head branch: ${headBranch}`);
|
|
90
|
+
ui.info(`Subtrees: ${targets.map((target) => target.name).join(', ')}`);
|
|
91
|
+
ui.blank();
|
|
92
|
+
|
|
93
|
+
const dryRunCommands = [];
|
|
94
|
+
let succeeded = 0;
|
|
95
|
+
let failed = 0;
|
|
96
|
+
|
|
97
|
+
for (let i = 0; i < targets.length; i++) {
|
|
98
|
+
const target = targets[i];
|
|
99
|
+
|
|
100
|
+
ui.repoStep(i + 1, targets.length, target.name, dryRun ? 'Planning PR for' : 'Opening PR for');
|
|
101
|
+
ui.repoDetail('Repo', target.repo || target.url);
|
|
102
|
+
ui.repoDetail('Base', target.base);
|
|
103
|
+
ui.repoDetail('Head', target.head);
|
|
104
|
+
|
|
105
|
+
if (!target.repo) {
|
|
106
|
+
ui.error(`Failed to open PR for ${target.name}: remote URL is not a GitHub repository.`);
|
|
107
|
+
failed++;
|
|
108
|
+
ui.blank();
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (!hasRemoteBranch(cwd, target.name, target.head)) {
|
|
113
|
+
ui.error(`Failed to open PR for ${target.name}: upstream branch "${target.head}" does not exist. Run "unirepo push ${target.name}" first.`);
|
|
114
|
+
failed++;
|
|
115
|
+
ui.blank();
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (!hasRemoteBranch(cwd, target.name, target.base)) {
|
|
120
|
+
ui.error(`Failed to open PR for ${target.name}: base branch "${target.base}" does not exist upstream.`);
|
|
121
|
+
failed++;
|
|
122
|
+
ui.blank();
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const ghCommand = [
|
|
127
|
+
'gh pr create',
|
|
128
|
+
`--repo ${quoteShellArg(target.repo)}`,
|
|
129
|
+
`--base ${quoteShellArg(target.base)}`,
|
|
130
|
+
`--head ${quoteShellArg(target.head)}`,
|
|
131
|
+
`--title ${quoteShellArg(title)}`,
|
|
132
|
+
`--body ${quoteShellArg(body)}`,
|
|
133
|
+
draft ? '--draft' : '',
|
|
134
|
+
]
|
|
135
|
+
.filter(Boolean)
|
|
136
|
+
.join(' ');
|
|
137
|
+
|
|
138
|
+
if (dryRun) {
|
|
139
|
+
dryRunCommands.push(ghCommand);
|
|
140
|
+
succeeded++;
|
|
141
|
+
ui.success(`${target.name} PR planned`);
|
|
142
|
+
ui.blank();
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
try {
|
|
147
|
+
const url = createPullRequest({
|
|
148
|
+
repo: target.repo,
|
|
149
|
+
base: target.base,
|
|
150
|
+
head: target.head,
|
|
151
|
+
title,
|
|
152
|
+
body,
|
|
153
|
+
draft,
|
|
154
|
+
});
|
|
155
|
+
ui.success(`${target.name} PR opened${url ? `: ${url}` : ''}`);
|
|
156
|
+
succeeded++;
|
|
157
|
+
} catch (err) {
|
|
158
|
+
ui.error(`Failed to open PR for ${target.name}: ${err.message}`);
|
|
159
|
+
failed++;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
ui.blank();
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (dryRunCommands.length > 0) {
|
|
166
|
+
ui.dryRun(dryRunCommands);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (failed === 0) {
|
|
170
|
+
ui.success(`${dryRun ? 'Planned' : 'Opened'} ${succeeded} pull request(s)`);
|
|
171
|
+
} else {
|
|
172
|
+
ui.warning(`${dryRun ? 'Planned' : 'Opened'} ${succeeded}, ${failed} failed`);
|
|
173
|
+
}
|
|
174
|
+
ui.blank();
|
|
175
|
+
}
|
package/src/commands/pull.js
CHANGED
|
@@ -1,7 +1,53 @@
|
|
|
1
|
-
import { git,
|
|
1
|
+
import { git, getSubtreePrefixes, getTrackedSubtreeBranch, hasRemoteBranch, setConfiguredSubtreeBranch } from '../git.js';
|
|
2
2
|
import { validateGitSubtree, validateInsideMonorepo } from '../validate.js';
|
|
3
3
|
import * as ui from '../ui.js';
|
|
4
4
|
|
|
5
|
+
export function selectPullSubtrees(allPrefixes, requestedSubtrees) {
|
|
6
|
+
if (!requestedSubtrees || requestedSubtrees.length === 0) {
|
|
7
|
+
return allPrefixes;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const prefixNames = new Set(allPrefixes.map((prefix) => prefix.name));
|
|
11
|
+
for (const name of requestedSubtrees) {
|
|
12
|
+
if (!prefixNames.has(name)) {
|
|
13
|
+
throw new Error(`"${name}" is not a tracked subtree. Run "unirepo status" to see available subtrees.`);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return allPrefixes.filter((prefix) => requestedSubtrees.includes(prefix.name));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function planPullTargets({
|
|
21
|
+
allPrefixes,
|
|
22
|
+
requestedSubtrees,
|
|
23
|
+
branch,
|
|
24
|
+
hasRemoteBranchFn = () => true,
|
|
25
|
+
}) {
|
|
26
|
+
const explicitSelection = Boolean(requestedSubtrees && requestedSubtrees.length > 0);
|
|
27
|
+
const selectedPrefixes = selectPullSubtrees(allPrefixes, requestedSubtrees);
|
|
28
|
+
|
|
29
|
+
if (!branch || explicitSelection) {
|
|
30
|
+
return { toPull: selectedPrefixes, skipped: [] };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const toPull = [];
|
|
34
|
+
const skipped = [];
|
|
35
|
+
|
|
36
|
+
for (const prefix of selectedPrefixes) {
|
|
37
|
+
if (hasRemoteBranchFn(prefix.name, branch)) {
|
|
38
|
+
toPull.push(prefix);
|
|
39
|
+
} else {
|
|
40
|
+
skipped.push(prefix);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return { toPull, skipped };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function resolvePullUpstreamBranch({ requestedBranch, trackedBranch }) {
|
|
48
|
+
return requestedBranch || trackedBranch || 'main';
|
|
49
|
+
}
|
|
50
|
+
|
|
5
51
|
export async function runPull({ subtrees: requestedSubtrees, branch, fullHistory }) {
|
|
6
52
|
const cwd = process.cwd();
|
|
7
53
|
|
|
@@ -12,32 +58,38 @@ export async function runPull({ subtrees: requestedSubtrees, branch, fullHistory
|
|
|
12
58
|
validateInsideMonorepo(cwd);
|
|
13
59
|
|
|
14
60
|
const allPrefixes = getSubtreePrefixes(cwd);
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
throw new Error(`"${name}" is not a tracked subtree. Run "unirepo status" to see available subtrees.`);
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
toPull = allPrefixes.filter((prefix) => requestedSubtrees.includes(prefix.name));
|
|
25
|
-
} else {
|
|
26
|
-
toPull = allPrefixes;
|
|
27
|
-
}
|
|
61
|
+
const { toPull, skipped } = planPullTargets({
|
|
62
|
+
allPrefixes,
|
|
63
|
+
requestedSubtrees,
|
|
64
|
+
branch,
|
|
65
|
+
hasRemoteBranchFn: (remoteName, branchName) => hasRemoteBranch(cwd, remoteName, branchName),
|
|
66
|
+
});
|
|
28
67
|
|
|
29
68
|
if (toPull.length === 0) {
|
|
30
|
-
|
|
69
|
+
if (branch && skipped.length > 0) {
|
|
70
|
+
ui.info(`No tracked subtrees have upstream branch "${branch}". Nothing to pull.`);
|
|
71
|
+
} else {
|
|
72
|
+
ui.info('No tracked subtrees found. Nothing to pull.');
|
|
73
|
+
}
|
|
31
74
|
ui.blank();
|
|
32
75
|
return;
|
|
33
76
|
}
|
|
34
77
|
|
|
78
|
+
if (skipped.length > 0) {
|
|
79
|
+
ui.warning(`Skipping ${skipped.map((prefix) => prefix.name).join(', ')}: no upstream branch "${branch}"`);
|
|
80
|
+
ui.blank();
|
|
81
|
+
}
|
|
82
|
+
|
|
35
83
|
let succeeded = 0;
|
|
36
84
|
let failed = 0;
|
|
37
85
|
|
|
38
86
|
for (let i = 0; i < toPull.length; i++) {
|
|
39
87
|
const prefix = toPull[i];
|
|
40
|
-
const
|
|
88
|
+
const trackedBranch = getTrackedSubtreeBranch(cwd, prefix.name);
|
|
89
|
+
const upstreamBranch = resolvePullUpstreamBranch({
|
|
90
|
+
requestedBranch: branch,
|
|
91
|
+
trackedBranch,
|
|
92
|
+
});
|
|
41
93
|
const squashFlag = fullHistory ? '' : '--squash ';
|
|
42
94
|
|
|
43
95
|
ui.repoStep(i + 1, toPull.length, prefix.name, 'Pulling');
|
|
@@ -46,6 +98,7 @@ export async function runPull({ subtrees: requestedSubtrees, branch, fullHistory
|
|
|
46
98
|
|
|
47
99
|
try {
|
|
48
100
|
git(`subtree pull ${squashFlag}--prefix="${prefix.name}" "${prefix.name}" "${upstreamBranch}"`, { cwd });
|
|
101
|
+
setConfiguredSubtreeBranch(cwd, prefix.name, upstreamBranch);
|
|
49
102
|
ui.success(`${prefix.name} pulled`);
|
|
50
103
|
succeeded++;
|
|
51
104
|
} catch (err) {
|
|
@@ -56,10 +109,12 @@ export async function runPull({ subtrees: requestedSubtrees, branch, fullHistory
|
|
|
56
109
|
ui.blank();
|
|
57
110
|
}
|
|
58
111
|
|
|
59
|
-
if (failed === 0) {
|
|
112
|
+
if (failed === 0 && skipped.length === 0) {
|
|
60
113
|
ui.success(`All ${succeeded} subtree(s) pulled`);
|
|
114
|
+
} else if (failed === 0) {
|
|
115
|
+
ui.success(`${succeeded} subtree(s) pulled, ${skipped.length} skipped`);
|
|
61
116
|
} else {
|
|
62
|
-
ui.warning(`${succeeded} pulled, ${failed} failed`);
|
|
117
|
+
ui.warning(`${succeeded} pulled, ${failed} failed${skipped.length > 0 ? `, ${skipped.length} skipped` : ''}`);
|
|
63
118
|
}
|
|
64
119
|
ui.blank();
|
|
65
|
-
}
|
|
120
|
+
}
|
package/src/commands/status.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { getCurrentBranch, getSubtreePrefixes, getChangedSubtrees,
|
|
1
|
+
import { getCurrentBranch, getSubtreePrefixes, getChangedSubtrees, getTrackedSubtreeBranch } from '../git.js';
|
|
2
2
|
import { validateInsideMonorepo } from '../validate.js';
|
|
3
3
|
import * as ui from '../ui.js';
|
|
4
4
|
|
|
@@ -15,7 +15,7 @@ export async function runStatus({ json }) {
|
|
|
15
15
|
const subtrees = prefixes.map((p) => ({
|
|
16
16
|
name: p.name,
|
|
17
17
|
url: p.url,
|
|
18
|
-
upstream:
|
|
18
|
+
upstream: getTrackedSubtreeBranch(cwd, p.name) || 'unknown',
|
|
19
19
|
pushBranch: branch,
|
|
20
20
|
changed: changedNames.has(p.name),
|
|
21
21
|
}));
|
package/src/git.js
CHANGED
|
@@ -2,6 +2,14 @@ import { execSync } from 'node:child_process';
|
|
|
2
2
|
import { readdirSync, statSync } from 'node:fs';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
|
|
5
|
+
function quoteShellArg(value) {
|
|
6
|
+
return JSON.stringify(value);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function getSubtreeBranchConfigKey(prefixName) {
|
|
10
|
+
return `unirepo.subtree.${prefixName}.branch`;
|
|
11
|
+
}
|
|
12
|
+
|
|
5
13
|
/**
|
|
6
14
|
* Execute a git command and return trimmed stdout.
|
|
7
15
|
* @param {string} args - git arguments
|
|
@@ -213,21 +221,93 @@ export function getChangedSubtrees(cwd) {
|
|
|
213
221
|
*/
|
|
214
222
|
export function getRemoteBranch(cwd, remoteName) {
|
|
215
223
|
try {
|
|
216
|
-
const
|
|
224
|
+
const headRef = execSync(`git symbolic-ref --quiet --short "refs/remotes/${remoteName}/HEAD"`, {
|
|
225
|
+
cwd,
|
|
226
|
+
encoding: 'utf-8',
|
|
227
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
228
|
+
}).trim();
|
|
229
|
+
if (headRef) {
|
|
230
|
+
const slash = headRef.indexOf('/');
|
|
231
|
+
return slash >= 0 ? headRef.slice(slash + 1) : headRef;
|
|
232
|
+
}
|
|
233
|
+
} catch {
|
|
234
|
+
// fall through
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
try {
|
|
238
|
+
const refs = execSync(`git for-each-ref --format="%(refname:short)" "refs/remotes/${remoteName}"`, {
|
|
217
239
|
cwd,
|
|
218
240
|
encoding: 'utf-8',
|
|
219
241
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
220
242
|
}).trim();
|
|
221
243
|
if (!refs) return null;
|
|
222
|
-
|
|
223
|
-
const
|
|
224
|
-
|
|
225
|
-
|
|
244
|
+
|
|
245
|
+
const branches = refs
|
|
246
|
+
.split('\n')
|
|
247
|
+
.map((ref) => ref.trim())
|
|
248
|
+
.filter(Boolean)
|
|
249
|
+
.map((ref) => {
|
|
250
|
+
const slash = ref.indexOf('/');
|
|
251
|
+
return slash >= 0 ? ref.slice(slash + 1) : ref;
|
|
252
|
+
})
|
|
253
|
+
.filter((ref) => ref !== 'HEAD');
|
|
254
|
+
|
|
255
|
+
if (branches.length === 0) {
|
|
256
|
+
return null;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (branches.includes('main')) return 'main';
|
|
260
|
+
if (branches.includes('master')) return 'master';
|
|
261
|
+
return branches[0];
|
|
226
262
|
} catch {
|
|
227
263
|
return null;
|
|
228
264
|
}
|
|
229
265
|
}
|
|
230
266
|
|
|
267
|
+
/**
|
|
268
|
+
* Get the branch explicitly tracked for a subtree in local git config.
|
|
269
|
+
*/
|
|
270
|
+
export function getConfiguredSubtreeBranch(cwd, prefixName) {
|
|
271
|
+
const branch = git(`config --get ${quoteShellArg(getSubtreeBranchConfigKey(prefixName))}`, {
|
|
272
|
+
cwd,
|
|
273
|
+
silent: true,
|
|
274
|
+
allowFailure: true,
|
|
275
|
+
});
|
|
276
|
+
return branch || null;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Persist the branch currently tracked for a subtree in local git config.
|
|
281
|
+
*/
|
|
282
|
+
export function setConfiguredSubtreeBranch(cwd, prefixName, branch) {
|
|
283
|
+
git(
|
|
284
|
+
`config ${quoteShellArg(getSubtreeBranchConfigKey(prefixName))} ${quoteShellArg(branch)}`,
|
|
285
|
+
{ cwd, silent: true }
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Get the branch unirepo should treat as the subtree's upstream branch.
|
|
291
|
+
*/
|
|
292
|
+
export function getTrackedSubtreeBranch(cwd, prefixName) {
|
|
293
|
+
return getConfiguredSubtreeBranch(cwd, prefixName) || getRemoteBranch(cwd, prefixName);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Check whether a remote has a specific branch.
|
|
298
|
+
*/
|
|
299
|
+
export function hasRemoteBranch(cwd, remoteName, branch) {
|
|
300
|
+
try {
|
|
301
|
+
execSync(`git ls-remote --exit-code --heads "${remoteName}" "${branch}"`, {
|
|
302
|
+
cwd,
|
|
303
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
304
|
+
});
|
|
305
|
+
return true;
|
|
306
|
+
} catch {
|
|
307
|
+
return false;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
231
311
|
/**
|
|
232
312
|
* Check if the working tree has uncommitted (staged or unstaged) changes.
|
|
233
313
|
*/
|
package/src/github.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { execSync } from 'node:child_process';
|
|
1
|
+
import { execFileSync, execSync } from 'node:child_process';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Thin wrapper around the `gh` CLI. We shell out rather than hitting the
|
|
@@ -23,6 +23,19 @@ function runGh(args, { timeout = 30000 } = {}) {
|
|
|
23
23
|
}
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
function runGhArgs(args, { timeout = 30000 } = {}) {
|
|
27
|
+
try {
|
|
28
|
+
return execFileSync('gh', args, {
|
|
29
|
+
encoding: 'utf-8',
|
|
30
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
31
|
+
timeout,
|
|
32
|
+
});
|
|
33
|
+
} catch (err) {
|
|
34
|
+
const stderr = (err.stderr || '').toString().trim();
|
|
35
|
+
throw new Error(stderr || err.message);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
26
39
|
function runGhJson(args, opts) {
|
|
27
40
|
const out = runGh(args, opts);
|
|
28
41
|
if (!out.trim()) return [];
|
|
@@ -111,3 +124,49 @@ export function searchRepos(query, limit = SEARCH_LIMIT) {
|
|
|
111
124
|
isPrivate: r.isPrivate,
|
|
112
125
|
}));
|
|
113
126
|
}
|
|
127
|
+
|
|
128
|
+
export function getGitHubRepoSlugFromUrl(url) {
|
|
129
|
+
const trimmed = url.replace(/\/$/, '').replace(/\.git$/, '');
|
|
130
|
+
const patterns = [
|
|
131
|
+
/^https?:\/\/github\.com\/([^/]+)\/([^/]+)$/i,
|
|
132
|
+
/^git@github\.com:([^/]+)\/([^/]+)$/i,
|
|
133
|
+
/^ssh:\/\/git@github\.com\/([^/]+)\/([^/]+)$/i,
|
|
134
|
+
];
|
|
135
|
+
|
|
136
|
+
for (const pattern of patterns) {
|
|
137
|
+
const match = trimmed.match(pattern);
|
|
138
|
+
if (match) {
|
|
139
|
+
return `${match[1]}/${match[2]}`;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function createPullRequest({ repo, base, head, title, body, draft = false }) {
|
|
147
|
+
const args = [
|
|
148
|
+
'pr',
|
|
149
|
+
'create',
|
|
150
|
+
'--repo',
|
|
151
|
+
repo,
|
|
152
|
+
'--base',
|
|
153
|
+
base,
|
|
154
|
+
'--head',
|
|
155
|
+
head,
|
|
156
|
+
'--title',
|
|
157
|
+
title,
|
|
158
|
+
'--body',
|
|
159
|
+
body,
|
|
160
|
+
];
|
|
161
|
+
|
|
162
|
+
if (draft) {
|
|
163
|
+
args.push('--draft');
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const output = runGhArgs(args);
|
|
167
|
+
const lines = output
|
|
168
|
+
.split('\n')
|
|
169
|
+
.map((line) => line.trim())
|
|
170
|
+
.filter(Boolean);
|
|
171
|
+
return lines.at(-1) || '';
|
|
172
|
+
}
|
package/src/index.js
CHANGED
|
@@ -8,6 +8,7 @@ import { runPull } from './commands/pull.js';
|
|
|
8
8
|
import { runStatus } from './commands/status.js';
|
|
9
9
|
import { runPush } from './commands/push.js';
|
|
10
10
|
import { runBranch } from './commands/branch.js';
|
|
11
|
+
import { runPr } from './commands/pr.js';
|
|
11
12
|
import { pathToFileURL } from 'node:url';
|
|
12
13
|
import { realpathSync } from 'node:fs';
|
|
13
14
|
|
|
@@ -37,10 +38,20 @@ export function parseArgs(argv) {
|
|
|
37
38
|
flags.json = true;
|
|
38
39
|
} else if (arg === '--dry-run') {
|
|
39
40
|
flags.dryRun = true;
|
|
41
|
+
} else if (arg === '--draft') {
|
|
42
|
+
flags.draft = true;
|
|
40
43
|
} else if (arg === '--prefix' && i + 1 < args.length) {
|
|
41
44
|
flags.prefix = args[++i];
|
|
42
45
|
} else if (arg === '--branch' && i + 1 < args.length) {
|
|
43
46
|
flags.branch = args[++i];
|
|
47
|
+
} else if (arg === '--title' && i + 1 < args.length) {
|
|
48
|
+
flags.title = args[++i];
|
|
49
|
+
} else if (arg === '--body' && i + 1 < args.length) {
|
|
50
|
+
flags.body = args[++i];
|
|
51
|
+
} else if (arg === '--base' && i + 1 < args.length) {
|
|
52
|
+
flags.base = args[++i];
|
|
53
|
+
} else if (arg === '--head' && i + 1 < args.length) {
|
|
54
|
+
flags.head = args[++i];
|
|
44
55
|
} else if (arg === '--help' || arg === '-h') {
|
|
45
56
|
flags.help = true;
|
|
46
57
|
} else if (arg.startsWith('-')) {
|
|
@@ -54,6 +65,59 @@ export function parseArgs(argv) {
|
|
|
54
65
|
return { command, positional, flags };
|
|
55
66
|
}
|
|
56
67
|
|
|
68
|
+
const COMMAND_USAGE = {
|
|
69
|
+
init: 'Usage: unirepo init <dir> <repo-url> [repo-url...]',
|
|
70
|
+
add: 'Usage: unirepo add <repo-url> [--prefix <name>] [--branch <name>] [--full-history]',
|
|
71
|
+
pull: 'Usage: unirepo pull [subtree...] [--prefix <name>] [--branch <name>] [--full-history]',
|
|
72
|
+
status: 'Usage: unirepo status [--json]',
|
|
73
|
+
push: 'Usage: unirepo push [subtree...] [--branch <name>] [--dry-run]',
|
|
74
|
+
branch: 'Usage: unirepo branch [name]',
|
|
75
|
+
pr: 'Usage: unirepo pr [subtree...] --title <title> [--body <text>] [--base <name>] [--head <name>] [--draft] [--dry-run]',
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const COMMAND_FLAGS = {
|
|
79
|
+
version: new Set(),
|
|
80
|
+
init: new Set(['fullHistory']),
|
|
81
|
+
add: new Set(['prefix', 'branch', 'fullHistory']),
|
|
82
|
+
pull: new Set(['prefix', 'branch', 'fullHistory']),
|
|
83
|
+
status: new Set(['json']),
|
|
84
|
+
push: new Set(['branch', 'dryRun']),
|
|
85
|
+
branch: new Set(),
|
|
86
|
+
pr: new Set(['title', 'body', 'base', 'head', 'draft', 'dryRun']),
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const FLAG_NAMES = {
|
|
90
|
+
fullHistory: '--full-history',
|
|
91
|
+
json: '--json',
|
|
92
|
+
dryRun: '--dry-run',
|
|
93
|
+
prefix: '--prefix',
|
|
94
|
+
branch: '--branch',
|
|
95
|
+
title: '--title',
|
|
96
|
+
body: '--body',
|
|
97
|
+
base: '--base',
|
|
98
|
+
head: '--head',
|
|
99
|
+
draft: '--draft',
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
export function validateCommandFlags(command, flags) {
|
|
103
|
+
const allowedFlags = COMMAND_FLAGS[command];
|
|
104
|
+
if (!allowedFlags) {
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const unsupportedFlags = Object.keys(flags)
|
|
109
|
+
.filter((flag) => flag !== 'help' && !allowedFlags.has(flag))
|
|
110
|
+
.map((flag) => FLAG_NAMES[flag] || `--${flag}`);
|
|
111
|
+
|
|
112
|
+
if (unsupportedFlags.length === 0) {
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const label = unsupportedFlags.length === 1 ? 'Flag' : 'Flags';
|
|
117
|
+
const verb = unsupportedFlags.length === 1 ? 'is' : 'are';
|
|
118
|
+
throw new Error(`${label} ${unsupportedFlags.join(', ')} ${verb} not supported for "${command}".`);
|
|
119
|
+
}
|
|
120
|
+
|
|
57
121
|
// ── Main ───────────────────────────────────────────────────────────────────────
|
|
58
122
|
|
|
59
123
|
export async function main() {
|
|
@@ -64,6 +128,17 @@ export async function main() {
|
|
|
64
128
|
process.exit(0);
|
|
65
129
|
}
|
|
66
130
|
|
|
131
|
+
try {
|
|
132
|
+
validateCommandFlags(command, flags);
|
|
133
|
+
} catch (err) {
|
|
134
|
+
ui.error(err.message);
|
|
135
|
+
const usage = COMMAND_USAGE[command];
|
|
136
|
+
if (usage) {
|
|
137
|
+
ui.info(usage);
|
|
138
|
+
}
|
|
139
|
+
process.exit(1);
|
|
140
|
+
}
|
|
141
|
+
|
|
67
142
|
switch (command) {
|
|
68
143
|
case 'version': {
|
|
69
144
|
ui.version();
|
|
@@ -102,8 +177,13 @@ export async function main() {
|
|
|
102
177
|
}
|
|
103
178
|
|
|
104
179
|
case 'pull': {
|
|
180
|
+
if (flags.prefix && positional.length > 0) {
|
|
181
|
+
ui.error('Usage: unirepo pull [subtree...] [--prefix <name>] [--branch <name>] [--full-history]');
|
|
182
|
+
ui.info('Use either subtree arguments or --prefix, not both.');
|
|
183
|
+
process.exit(1);
|
|
184
|
+
}
|
|
105
185
|
await runPull({
|
|
106
|
-
subtrees: positional.length > 0 ? positional : undefined,
|
|
186
|
+
subtrees: flags.prefix ? [flags.prefix] : positional.length > 0 ? positional : undefined,
|
|
107
187
|
branch: flags.branch,
|
|
108
188
|
fullHistory: flags.fullHistory || false,
|
|
109
189
|
});
|
|
@@ -129,6 +209,24 @@ export async function main() {
|
|
|
129
209
|
break;
|
|
130
210
|
}
|
|
131
211
|
|
|
212
|
+
case 'pr': {
|
|
213
|
+
if (!flags.title) {
|
|
214
|
+
ui.error('Usage: unirepo pr [subtree...] --title <title> [--body <text>] [--base <name>] [--head <name>] [--draft] [--dry-run]');
|
|
215
|
+
ui.info('Provide a shared PR title with --title. Use --body to set the description.');
|
|
216
|
+
process.exit(1);
|
|
217
|
+
}
|
|
218
|
+
await runPr({
|
|
219
|
+
subtrees: positional.length > 0 ? positional : undefined,
|
|
220
|
+
title: flags.title,
|
|
221
|
+
body: flags.body || '',
|
|
222
|
+
base: flags.base,
|
|
223
|
+
head: flags.head,
|
|
224
|
+
draft: flags.draft || false,
|
|
225
|
+
dryRun: flags.dryRun || false,
|
|
226
|
+
});
|
|
227
|
+
break;
|
|
228
|
+
}
|
|
229
|
+
|
|
132
230
|
default:
|
|
133
231
|
ui.error(`Unknown command: ${command}`);
|
|
134
232
|
ui.blank();
|
package/src/templates.js
CHANGED
|
@@ -30,14 +30,16 @@ unirepo branch <branch>
|
|
|
30
30
|
unirepo pull
|
|
31
31
|
unirepo push --dry-run
|
|
32
32
|
unirepo push
|
|
33
|
+
unirepo pr --title "feat: ..." --body "..."
|
|
33
34
|
unirepo add <repo-url> --branch <branch>
|
|
34
35
|
\`\`\`
|
|
35
36
|
|
|
36
37
|
- \`status\` shows tracked subtrees, upstream branches, the current push branch, and changed files.
|
|
37
38
|
- \`branch <name>\` creates the local branch name you should reuse when pushing subtrees upstream.
|
|
38
|
-
- \`pull\` updates one or more tracked subtrees from upstream before or during your work.
|
|
39
|
+
- \`pull\` updates one or more tracked subtrees from upstream before or during your work. Use \`--prefix\` when you want a branch-specific pull for just one subtree.
|
|
39
40
|
- \`push --dry-run\` is the safe first step before a real push.
|
|
40
41
|
- \`push\` without subtree names auto-detects changed subtrees. \`push <subtree>\` pushes one subtree.
|
|
42
|
+
- \`pr\` opens one PR per changed or explicitly selected subtree repo after those branches have been pushed.
|
|
41
43
|
- \`add\` imports another repository as a subtree. Use \`--branch\` to import from a non-default upstream branch.
|
|
42
44
|
|
|
43
45
|
## Workflow
|
|
@@ -56,6 +58,7 @@ git checkout -b <branch>
|
|
|
56
58
|
CLI:
|
|
57
59
|
\`\`\`bash
|
|
58
60
|
unirepo pull
|
|
61
|
+
unirepo pull --prefix <subtree> --branch <branch>
|
|
59
62
|
\`\`\`
|
|
60
63
|
Git:
|
|
61
64
|
\`\`\`bash
|
|
@@ -92,6 +95,13 @@ Git:
|
|
|
92
95
|
git subtree push --prefix=<subtree> <remote-or-url> <branch>
|
|
93
96
|
\`\`\`
|
|
94
97
|
|
|
98
|
+
7. Open one PR per upstream subtree repo after push.
|
|
99
|
+
CLI:
|
|
100
|
+
\`\`\`bash
|
|
101
|
+
unirepo pr --title "feat: ..." --body "..."
|
|
102
|
+
unirepo pr <subtree> --title "feat: ..."
|
|
103
|
+
\`\`\`
|
|
104
|
+
|
|
95
105
|
## Raw Git Subtree
|
|
96
106
|
|
|
97
107
|
Use these when operating without the CLI:
|
|
@@ -115,6 +125,7 @@ git subtree push --prefix=libfoo https://github.com/example/libfoo.git <branch>
|
|
|
115
125
|
- Open one PR per upstream subtree repo.
|
|
116
126
|
- Use the same branch name in each upstream repo.
|
|
117
127
|
- Target the default branch of the upstream repo unless that repo's workflow says otherwise.
|
|
128
|
+
- Run \`unirepo pr\` only after the head branch exists upstream for that subtree repo.
|
|
118
129
|
- Ensure each PR contains only changes from its own subtree.
|
|
119
130
|
`;
|
|
120
131
|
|
package/src/ui.js
CHANGED
|
@@ -179,6 +179,7 @@ ${chalk.bold('Commands:')}
|
|
|
179
179
|
${chalk.green('pull')} [subtree...] Pull subtree updates from upstream
|
|
180
180
|
${chalk.green('status')} Show tracked subtrees and changes
|
|
181
181
|
${chalk.green('push')} [subtree...] Push changed subtrees upstream
|
|
182
|
+
${chalk.green('pr')} [subtree...] Open PRs for changed subtree repos
|
|
182
183
|
${chalk.green('branch')} [name] Create a branch on all upstream repos
|
|
183
184
|
${chalk.green('version')} Show the installed CLI version
|
|
184
185
|
|
|
@@ -195,6 +196,7 @@ ${chalk.bold('Add options:')}
|
|
|
195
196
|
--full-history Import full git history
|
|
196
197
|
|
|
197
198
|
${chalk.bold('Pull options:')}
|
|
199
|
+
--prefix <name> Pull only one tracked subtree
|
|
198
200
|
--branch <name> Pull a specific upstream branch for all selected subtrees
|
|
199
201
|
--full-history Pull full history instead of squash mode
|
|
200
202
|
|
|
@@ -205,6 +207,14 @@ ${chalk.bold('Push options:')}
|
|
|
205
207
|
--branch <name> Branch name for upstream push (default: current)
|
|
206
208
|
--dry-run Show commands without executing
|
|
207
209
|
|
|
210
|
+
${chalk.bold('PR options:')}
|
|
211
|
+
--title <title> Shared PR title (required)
|
|
212
|
+
--body <text> Shared PR description
|
|
213
|
+
--base <name> Override the base branch for all selected repos
|
|
214
|
+
--head <name> Override the head branch (default: current branch)
|
|
215
|
+
--draft Create draft PRs
|
|
216
|
+
--dry-run Show gh pr create commands without executing
|
|
217
|
+
|
|
208
218
|
${chalk.bold('Examples:')}
|
|
209
219
|
${chalk.dim('# Create monorepo from multiple repos')}
|
|
210
220
|
npx unirepo init my-monorepo https://github.com/org/api.git https://github.com/org/web.git
|
|
@@ -216,6 +226,9 @@ ${chalk.bold('Examples:')}
|
|
|
216
226
|
${chalk.dim('# Pull upstream updates before working')}
|
|
217
227
|
npx unirepo pull
|
|
218
228
|
|
|
229
|
+
${chalk.dim('# Pull one subtree from a specific upstream branch')}
|
|
230
|
+
npx unirepo pull --prefix api --branch release/2026-04
|
|
231
|
+
|
|
219
232
|
${chalk.dim('# Check status')}
|
|
220
233
|
npx unirepo status
|
|
221
234
|
|
|
@@ -224,5 +237,8 @@ ${chalk.bold('Examples:')}
|
|
|
224
237
|
|
|
225
238
|
${chalk.dim('# Push changes upstream')}
|
|
226
239
|
npx unirepo push --dry-run
|
|
240
|
+
|
|
241
|
+
${chalk.dim('# Open PRs for changed subtree repos')}
|
|
242
|
+
npx unirepo pr --title "feat: add auth flow" --body "Cross-repo auth changes"
|
|
227
243
|
`);
|
|
228
244
|
}
|