unirepo-cli 0.2.0 → 0.3.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 +93 -75
- package/package.json +1 -1
- package/src/commands/add.js +3 -1
- package/src/commands/branch.js +2 -2
- package/src/commands/init.js +3 -1
- package/src/commands/pull.js +74 -19
- package/src/commands/status.js +2 -2
- package/src/git.js +85 -5
- package/src/index.js +63 -1
- package/src/templates.js +2 -1
- package/src/ui.js +4 -0
package/README.md
CHANGED
|
@@ -4,63 +4,102 @@
|
|
|
4
4
|
[](https://github.com/Poko18/unirepo/actions/workflows/ci.yml)
|
|
5
5
|
[](LICENSE)
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
### One workspace to refactor your whole stack in one go
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
Work across multiple repos like they’re one. Unirepo turns multiple GitHub repositories into a single unified workspace. Edit backend, frontend, and shared code in one tree, commit from one place, and push changes back to each repo using the same branch.
|
|
10
10
|
|
|
11
|
-
Built for
|
|
11
|
+
### Built for AI coding agents
|
|
12
12
|
|
|
13
|
+
AI agents need full context to make correct changes—not one repo at a time. Unirepo gives them a single workspace to refactor your entire stack, keep changes consistent, and push updates back to each repo.
|
|
13
14
|
|
|
14
|
-
|
|
15
|
+
### Example task
|
|
15
16
|
|
|
16
|
-
|
|
17
|
+
You need to update:
|
|
17
18
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
~/work/shared ← clone, checkout same branch, edit
|
|
22
|
-
# remember to push all three, open three PRs
|
|
23
|
-
```
|
|
24
|
-
|
|
25
|
-
**With unirepo:**
|
|
26
|
-
|
|
27
|
-
```
|
|
28
|
-
~/work/my-workspace/
|
|
29
|
-
├── api/ ← edit
|
|
30
|
-
├── web/ ← edit
|
|
31
|
-
└── shared/ ← edit
|
|
32
|
-
# one commit, one push — all three repos updated
|
|
33
|
-
```
|
|
19
|
+
- `api/` for a new endpoint
|
|
20
|
+
- `web/` for the UI
|
|
21
|
+
- `shared/` for shared types
|
|
34
22
|
|
|
23
|
+
<table>
|
|
24
|
+
<tr>
|
|
25
|
+
<td width="50%" valign="top">
|
|
35
26
|
|
|
36
|
-
|
|
27
|
+
**Without `unirepo`**
|
|
37
28
|
|
|
38
29
|
```bash
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
30
|
+
# clone and branch in each repo
|
|
31
|
+
git clone git@github.com:org/api.git
|
|
32
|
+
git clone git@github.com:org/web.git
|
|
33
|
+
git clone git@github.com:org/shared.git
|
|
34
|
+
|
|
35
|
+
cd api
|
|
36
|
+
git checkout -b feature-auth
|
|
37
|
+
cd ../web
|
|
38
|
+
git checkout -b feature-auth
|
|
39
|
+
cd ../shared
|
|
40
|
+
git checkout -b feature-auth
|
|
41
|
+
|
|
42
|
+
# edit in 3 separate checkouts
|
|
43
|
+
|
|
44
|
+
# commit in each repo
|
|
45
|
+
cd ../api
|
|
46
|
+
git add .
|
|
47
|
+
git commit -m "feat: add auth"
|
|
48
|
+
cd ../web
|
|
49
|
+
git add .
|
|
50
|
+
git commit -m "feat: add auth UI"
|
|
51
|
+
cd ../shared
|
|
52
|
+
git add .
|
|
53
|
+
git commit -m "feat: add auth types"
|
|
54
|
+
|
|
55
|
+
# push from each repo
|
|
56
|
+
cd ../api
|
|
57
|
+
git push -u origin feature-auth
|
|
58
|
+
cd ../web
|
|
59
|
+
git push -u origin feature-auth
|
|
60
|
+
cd ../shared
|
|
61
|
+
git push -u origin feature-auth
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
</td>
|
|
65
|
+
<td width="50%" valign="top">
|
|
66
|
+
|
|
67
|
+
**With `unirepo`**
|
|
43
68
|
|
|
44
|
-
|
|
69
|
+
```bash
|
|
70
|
+
# create one workspace and one branch
|
|
71
|
+
unirepo init my-workspace <repo...>
|
|
72
|
+
cd my-workspace
|
|
73
|
+
unirepo branch feature-auth
|
|
45
74
|
|
|
46
|
-
|
|
47
|
-
my-workspace/
|
|
48
|
-
├── api/ ← from github.com/org/api
|
|
49
|
-
├── web/ ← from github.com/org/web
|
|
50
|
-
├── AGENTS.md ← workflow guide for humans and agents
|
|
51
|
-
└── .gitignore
|
|
52
|
-
```
|
|
75
|
+
# edit api/, web/, and shared/ together
|
|
53
76
|
|
|
54
|
-
|
|
77
|
+
# commit once from the workspace
|
|
78
|
+
git add .
|
|
79
|
+
git commit -m "feat: add auth flow"
|
|
55
80
|
|
|
56
|
-
|
|
57
|
-
cd my-workspace
|
|
58
|
-
# edit files in api/ and web/
|
|
59
|
-
git add . && git commit -m "feat: update shared types"
|
|
81
|
+
# push changed subtrees
|
|
60
82
|
unirepo push
|
|
61
83
|
```
|
|
62
84
|
|
|
63
|
-
|
|
85
|
+
</td>
|
|
86
|
+
</tr>
|
|
87
|
+
</table>
|
|
88
|
+
|
|
89
|
+
## Same workflow, fewer commands
|
|
90
|
+
|
|
91
|
+
`unirepo` keeps your upstream repos separate while removing:
|
|
92
|
+
|
|
93
|
+
- Repo and branch juggling
|
|
94
|
+
- Repeated setup and commands
|
|
95
|
+
- Context switching
|
|
96
|
+
- Inconsistent cross-repo changes
|
|
97
|
+
|
|
98
|
+
It enables:
|
|
99
|
+
|
|
100
|
+
- One place to see the full change
|
|
101
|
+
- Safe cross-repo refactors
|
|
102
|
+
- A single workspace for humans and AI
|
|
64
103
|
|
|
65
104
|
|
|
66
105
|
## Install
|
|
@@ -82,6 +121,17 @@ npx unirepo-cli <command>
|
|
|
82
121
|
```
|
|
83
122
|
|
|
84
123
|
|
|
124
|
+
## Why it works well for agents
|
|
125
|
+
|
|
126
|
+
AI coding agents work best when they can see the full change at once.
|
|
127
|
+
|
|
128
|
+
- Update backend, frontend, and shared contracts in one pass
|
|
129
|
+
- Commit coordinated changes from one repo
|
|
130
|
+
- Check affected subtrees with `unirepo status`
|
|
131
|
+
- Push only changed subtrees with `unirepo push`
|
|
132
|
+
- Reuse the generated `AGENTS.md` workflow guide
|
|
133
|
+
|
|
134
|
+
|
|
85
135
|
## Commands
|
|
86
136
|
|
|
87
137
|
| Command | Description |
|
|
@@ -92,40 +142,7 @@ npx unirepo-cli <command>
|
|
|
92
142
|
| `status` | Show subtrees, branches, and what changed |
|
|
93
143
|
| `branch [name]` | Create or show the current push branch |
|
|
94
144
|
| `push [subtree...]` | Push changed subtrees upstream |
|
|
95
|
-
| `version` | Show CLI version |
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
## Workflow
|
|
99
|
-
|
|
100
|
-
A typical session looks like this:
|
|
101
|
-
|
|
102
|
-
```bash
|
|
103
|
-
# 1. Create a branch for your work
|
|
104
|
-
unirepo branch feature-auth
|
|
105
|
-
|
|
106
|
-
# 2. Pull latest upstream changes
|
|
107
|
-
unirepo pull
|
|
108
|
-
|
|
109
|
-
# 3. Edit files across repos
|
|
110
|
-
vim api/src/auth.js web/src/login.tsx
|
|
111
|
-
|
|
112
|
-
# 4. Commit in the monorepo
|
|
113
|
-
git add . && git commit -m "feat: add OAuth flow"
|
|
114
|
-
|
|
115
|
-
# 5. Check what will be pushed
|
|
116
|
-
unirepo status
|
|
117
|
-
unirepo push --dry-run
|
|
118
|
-
|
|
119
|
-
# 6. Push to upstream repos
|
|
120
|
-
unirepo push
|
|
121
|
-
|
|
122
|
-
# 7. Open one PR per upstream repo
|
|
123
|
-
```
|
|
124
|
-
|
|
125
|
-
The branch name you create in the workspace is reused when pushing to each upstream repo.
|
|
126
|
-
|
|
127
145
|
|
|
128
|
-
## Command Reference
|
|
129
146
|
|
|
130
147
|
### init
|
|
131
148
|
|
|
@@ -156,13 +173,14 @@ Adds a repo to an existing workspace. The directory name defaults to the repo na
|
|
|
156
173
|
### pull
|
|
157
174
|
|
|
158
175
|
```bash
|
|
159
|
-
unirepo pull [subtree...]
|
|
176
|
+
unirepo pull [subtree...] [--prefix <name>]
|
|
160
177
|
```
|
|
161
178
|
|
|
162
|
-
Pulls upstream changes. Without arguments, pulls all tracked subtrees.
|
|
179
|
+
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.
|
|
163
180
|
|
|
164
181
|
| Flag | Effect |
|
|
165
182
|
| --- | --- |
|
|
183
|
+
| `--prefix <name>` | Pull only one tracked subtree |
|
|
166
184
|
| `--branch <name>` | Pull a specific upstream branch |
|
|
167
185
|
| `--full-history` | Pull full history instead of squash |
|
|
168
186
|
|
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();
|
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) {
|
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/index.js
CHANGED
|
@@ -54,6 +54,52 @@ export function parseArgs(argv) {
|
|
|
54
54
|
return { command, positional, flags };
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
const COMMAND_USAGE = {
|
|
58
|
+
init: 'Usage: unirepo init <dir> <repo-url> [repo-url...]',
|
|
59
|
+
add: 'Usage: unirepo add <repo-url> [--prefix <name>] [--branch <name>] [--full-history]',
|
|
60
|
+
pull: 'Usage: unirepo pull [subtree...] [--prefix <name>] [--branch <name>] [--full-history]',
|
|
61
|
+
status: 'Usage: unirepo status [--json]',
|
|
62
|
+
push: 'Usage: unirepo push [subtree...] [--branch <name>] [--dry-run]',
|
|
63
|
+
branch: 'Usage: unirepo branch [name]',
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const COMMAND_FLAGS = {
|
|
67
|
+
version: new Set(),
|
|
68
|
+
init: new Set(['fullHistory']),
|
|
69
|
+
add: new Set(['prefix', 'branch', 'fullHistory']),
|
|
70
|
+
pull: new Set(['prefix', 'branch', 'fullHistory']),
|
|
71
|
+
status: new Set(['json']),
|
|
72
|
+
push: new Set(['branch', 'dryRun']),
|
|
73
|
+
branch: new Set(),
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const FLAG_NAMES = {
|
|
77
|
+
fullHistory: '--full-history',
|
|
78
|
+
json: '--json',
|
|
79
|
+
dryRun: '--dry-run',
|
|
80
|
+
prefix: '--prefix',
|
|
81
|
+
branch: '--branch',
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
export function validateCommandFlags(command, flags) {
|
|
85
|
+
const allowedFlags = COMMAND_FLAGS[command];
|
|
86
|
+
if (!allowedFlags) {
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const unsupportedFlags = Object.keys(flags)
|
|
91
|
+
.filter((flag) => flag !== 'help' && !allowedFlags.has(flag))
|
|
92
|
+
.map((flag) => FLAG_NAMES[flag] || `--${flag}`);
|
|
93
|
+
|
|
94
|
+
if (unsupportedFlags.length === 0) {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const label = unsupportedFlags.length === 1 ? 'Flag' : 'Flags';
|
|
99
|
+
const verb = unsupportedFlags.length === 1 ? 'is' : 'are';
|
|
100
|
+
throw new Error(`${label} ${unsupportedFlags.join(', ')} ${verb} not supported for "${command}".`);
|
|
101
|
+
}
|
|
102
|
+
|
|
57
103
|
// ── Main ───────────────────────────────────────────────────────────────────────
|
|
58
104
|
|
|
59
105
|
export async function main() {
|
|
@@ -64,6 +110,17 @@ export async function main() {
|
|
|
64
110
|
process.exit(0);
|
|
65
111
|
}
|
|
66
112
|
|
|
113
|
+
try {
|
|
114
|
+
validateCommandFlags(command, flags);
|
|
115
|
+
} catch (err) {
|
|
116
|
+
ui.error(err.message);
|
|
117
|
+
const usage = COMMAND_USAGE[command];
|
|
118
|
+
if (usage) {
|
|
119
|
+
ui.info(usage);
|
|
120
|
+
}
|
|
121
|
+
process.exit(1);
|
|
122
|
+
}
|
|
123
|
+
|
|
67
124
|
switch (command) {
|
|
68
125
|
case 'version': {
|
|
69
126
|
ui.version();
|
|
@@ -102,8 +159,13 @@ export async function main() {
|
|
|
102
159
|
}
|
|
103
160
|
|
|
104
161
|
case 'pull': {
|
|
162
|
+
if (flags.prefix && positional.length > 0) {
|
|
163
|
+
ui.error('Usage: unirepo pull [subtree...] [--prefix <name>] [--branch <name>] [--full-history]');
|
|
164
|
+
ui.info('Use either subtree arguments or --prefix, not both.');
|
|
165
|
+
process.exit(1);
|
|
166
|
+
}
|
|
105
167
|
await runPull({
|
|
106
|
-
subtrees: positional.length > 0 ? positional : undefined,
|
|
168
|
+
subtrees: flags.prefix ? [flags.prefix] : positional.length > 0 ? positional : undefined,
|
|
107
169
|
branch: flags.branch,
|
|
108
170
|
fullHistory: flags.fullHistory || false,
|
|
109
171
|
});
|
package/src/templates.js
CHANGED
|
@@ -35,7 +35,7 @@ unirepo add <repo-url> --branch <branch>
|
|
|
35
35
|
|
|
36
36
|
- \`status\` shows tracked subtrees, upstream branches, the current push branch, and changed files.
|
|
37
37
|
- \`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.
|
|
38
|
+
- \`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
39
|
- \`push --dry-run\` is the safe first step before a real push.
|
|
40
40
|
- \`push\` without subtree names auto-detects changed subtrees. \`push <subtree>\` pushes one subtree.
|
|
41
41
|
- \`add\` imports another repository as a subtree. Use \`--branch\` to import from a non-default upstream branch.
|
|
@@ -56,6 +56,7 @@ git checkout -b <branch>
|
|
|
56
56
|
CLI:
|
|
57
57
|
\`\`\`bash
|
|
58
58
|
unirepo pull
|
|
59
|
+
unirepo pull --prefix <subtree> --branch <branch>
|
|
59
60
|
\`\`\`
|
|
60
61
|
Git:
|
|
61
62
|
\`\`\`bash
|
package/src/ui.js
CHANGED
|
@@ -195,6 +195,7 @@ ${chalk.bold('Add options:')}
|
|
|
195
195
|
--full-history Import full git history
|
|
196
196
|
|
|
197
197
|
${chalk.bold('Pull options:')}
|
|
198
|
+
--prefix <name> Pull only one tracked subtree
|
|
198
199
|
--branch <name> Pull a specific upstream branch for all selected subtrees
|
|
199
200
|
--full-history Pull full history instead of squash mode
|
|
200
201
|
|
|
@@ -216,6 +217,9 @@ ${chalk.bold('Examples:')}
|
|
|
216
217
|
${chalk.dim('# Pull upstream updates before working')}
|
|
217
218
|
npx unirepo pull
|
|
218
219
|
|
|
220
|
+
${chalk.dim('# Pull one subtree from a specific upstream branch')}
|
|
221
|
+
npx unirepo pull --prefix api --branch release/2026-04
|
|
222
|
+
|
|
219
223
|
${chalk.dim('# Check status')}
|
|
220
224
|
npx unirepo status
|
|
221
225
|
|