unirepo-cli 0.1.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/LICENSE +21 -0
- package/README.md +216 -0
- package/package.json +41 -0
- package/src/commands/add.js +45 -0
- package/src/commands/branch.js +54 -0
- package/src/commands/init.js +95 -0
- package/src/commands/pull.js +65 -0
- package/src/commands/push.js +90 -0
- package/src/commands/status.js +29 -0
- package/src/git.js +250 -0
- package/src/index.js +150 -0
- package/src/templates.js +128 -0
- package/src/ui.js +218 -0
- package/src/validate.js +85 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Tadej Satler
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
# unirepo
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/unirepo-cli)
|
|
4
|
+
[](https://github.com/Poko18/unirepo/actions/workflows/ci.yml)
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
|
|
7
|
+
**The workspace for cross-repo coding.**
|
|
8
|
+
|
|
9
|
+
unirepo turns a set of GitHub repos into a single unified workspace. Edit code across any of them, commit once, and push updates back to their original repos — all in one branch.
|
|
10
|
+
|
|
11
|
+
Built for the era of AI coding agents: give your agent one place to work, and let it refactor, ship features, and update shared APIs across your entire stack.
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
## Why unirepo?
|
|
15
|
+
|
|
16
|
+
**Before unirepo:**
|
|
17
|
+
|
|
18
|
+
```
|
|
19
|
+
~/work/api ← clone, checkout branch, edit
|
|
20
|
+
~/work/web ← clone, checkout same branch, edit
|
|
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
|
+
```
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
## Quick Start
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
npx unirepo-cli init my-workspace \
|
|
40
|
+
https://github.com/org/api.git \
|
|
41
|
+
https://github.com/org/web.git
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
This creates:
|
|
45
|
+
|
|
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
|
+
```
|
|
53
|
+
|
|
54
|
+
Then work across repos as if they were one:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
cd my-workspace
|
|
58
|
+
# edit files in api/ and web/
|
|
59
|
+
git add . && git commit -m "feat: update shared types"
|
|
60
|
+
unirepo push
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
That's it. Each subtree gets pushed to its upstream repo automatically.
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
## Install
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
npm install -g unirepo-cli
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Once installed, the command is just `unirepo`:
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
unirepo --version
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Or use without installing:
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
npx unirepo-cli <command>
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
## Commands
|
|
86
|
+
|
|
87
|
+
| Command | Description |
|
|
88
|
+
| --- | --- |
|
|
89
|
+
| `init <dir> <repo...>` | Create a new workspace from one or more repos |
|
|
90
|
+
| `add <repo>` | Add another repo to the workspace |
|
|
91
|
+
| `pull [subtree...]` | Pull upstream changes into tracked subtrees |
|
|
92
|
+
| `status` | Show subtrees, branches, and what changed |
|
|
93
|
+
| `branch [name]` | Create or show the current push branch |
|
|
94
|
+
| `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
|
+
|
|
128
|
+
## Command Reference
|
|
129
|
+
|
|
130
|
+
### init
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
unirepo init <dir> <repo-url> [repo-url...]
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Creates a new git repo at `<dir>`, imports each URL as a subtree, and generates an `AGENTS.md` workflow guide.
|
|
137
|
+
|
|
138
|
+
| Flag | Effect |
|
|
139
|
+
| --- | --- |
|
|
140
|
+
| `--full-history` | Import full git history (default: squash) |
|
|
141
|
+
|
|
142
|
+
### add
|
|
143
|
+
|
|
144
|
+
```bash
|
|
145
|
+
unirepo add <repo-url> [--prefix <name>] [--branch <name>]
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Adds a repo to an existing workspace. The directory name defaults to the repo name.
|
|
149
|
+
|
|
150
|
+
| Flag | Effect |
|
|
151
|
+
| --- | --- |
|
|
152
|
+
| `--prefix <name>` | Override the subtree directory name |
|
|
153
|
+
| `--branch <name>` | Import from a specific upstream branch |
|
|
154
|
+
| `--full-history` | Import full git history |
|
|
155
|
+
|
|
156
|
+
### pull
|
|
157
|
+
|
|
158
|
+
```bash
|
|
159
|
+
unirepo pull [subtree...]
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Pulls upstream changes. Without arguments, pulls all tracked subtrees.
|
|
163
|
+
|
|
164
|
+
| Flag | Effect |
|
|
165
|
+
| --- | --- |
|
|
166
|
+
| `--branch <name>` | Pull a specific upstream branch |
|
|
167
|
+
| `--full-history` | Pull full history instead of squash |
|
|
168
|
+
|
|
169
|
+
### status
|
|
170
|
+
|
|
171
|
+
```bash
|
|
172
|
+
unirepo status [--json]
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
Shows tracked subtrees, their upstream branches, the current push branch, and which subtrees have changes.
|
|
176
|
+
|
|
177
|
+
### branch
|
|
178
|
+
|
|
179
|
+
```bash
|
|
180
|
+
unirepo branch [name]
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
With a name: creates and switches to a new branch. Without: shows the current branch and push targets.
|
|
184
|
+
|
|
185
|
+
### push
|
|
186
|
+
|
|
187
|
+
```bash
|
|
188
|
+
unirepo push [subtree...] [--dry-run]
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
Pushes changed subtrees upstream. Without arguments, auto-detects which subtrees have changes.
|
|
192
|
+
|
|
193
|
+
| Flag | Effect |
|
|
194
|
+
| --- | --- |
|
|
195
|
+
| `--branch <name>` | Override the upstream branch name |
|
|
196
|
+
| `--dry-run` | Show what would run without executing |
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
## How It Works
|
|
200
|
+
|
|
201
|
+
unirepo is a thin wrapper around [`git subtree`](https://git-scm.com/book/en/v2/Git-Tools-Advanced-Merging#_subtree_merge). Each repo you add becomes a directory in your workspace with a matching git remote. When you push, unirepo splits your commits per subtree and pushes each to its upstream.
|
|
202
|
+
|
|
203
|
+
There's no lock-in — the workspace is a standard git repo. You can always fall back to raw `git subtree` commands.
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
## Development
|
|
207
|
+
|
|
208
|
+
```bash
|
|
209
|
+
npm test # run tests
|
|
210
|
+
npm run build # syntax check
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
## License
|
|
215
|
+
|
|
216
|
+
MIT — see [LICENSE](LICENSE).
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "unirepo-cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "CLI tool for creating and managing git-subtree monorepos — run your agents across repos",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"unirepo": "./src/index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"src/"
|
|
11
|
+
],
|
|
12
|
+
"engines": {
|
|
13
|
+
"node": ">=18"
|
|
14
|
+
},
|
|
15
|
+
"scripts": {
|
|
16
|
+
"start": "node src/index.js",
|
|
17
|
+
"test": "node --test",
|
|
18
|
+
"build": "node --check src/*.js src/commands/*.js"
|
|
19
|
+
},
|
|
20
|
+
"keywords": [
|
|
21
|
+
"git",
|
|
22
|
+
"subtree",
|
|
23
|
+
"monorepo",
|
|
24
|
+
"cli",
|
|
25
|
+
"developer-tools",
|
|
26
|
+
"agents"
|
|
27
|
+
],
|
|
28
|
+
"repository": {
|
|
29
|
+
"type": "git",
|
|
30
|
+
"url": "https://github.com/Poko18/unirepo.git"
|
|
31
|
+
},
|
|
32
|
+
"homepage": "https://github.com/Poko18/unirepo#readme",
|
|
33
|
+
"bugs": {
|
|
34
|
+
"url": "https://github.com/Poko18/unirepo/issues"
|
|
35
|
+
},
|
|
36
|
+
"author": "Poko18",
|
|
37
|
+
"license": "MIT",
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"chalk": "^5.3.0"
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { git, detectDefaultBranch, extractRepoName } from '../git.js';
|
|
2
|
+
import { validateGitSubtree, validateUrls, validateInsideMonorepo, validateNameAvailable, validateReachable } from '../validate.js';
|
|
3
|
+
import * as ui from '../ui.js';
|
|
4
|
+
|
|
5
|
+
export async function runAdd({ url, prefix, branch, fullHistory }) {
|
|
6
|
+
const cwd = process.cwd();
|
|
7
|
+
|
|
8
|
+
// ── Preflight ────────────────────────────────────────────────────────────
|
|
9
|
+
ui.header('Adding repository');
|
|
10
|
+
ui.blank();
|
|
11
|
+
|
|
12
|
+
ui.step(1, 4, 'Running preflight checks...');
|
|
13
|
+
validateGitSubtree();
|
|
14
|
+
validateInsideMonorepo(cwd);
|
|
15
|
+
validateUrls([url]);
|
|
16
|
+
|
|
17
|
+
const name = prefix || extractRepoName(url);
|
|
18
|
+
validateNameAvailable(name, cwd);
|
|
19
|
+
ui.info(`Checking ${url}...`);
|
|
20
|
+
validateReachable(url);
|
|
21
|
+
ui.success('All checks passed');
|
|
22
|
+
|
|
23
|
+
// ── Detect branch ────────────────────────────────────────────────────────
|
|
24
|
+
ui.step(2, 4, branch ? `Using upstream branch for ${name}...` : `Detecting default branch for ${name}...`);
|
|
25
|
+
const upstreamBranch = branch || detectDefaultBranch(url);
|
|
26
|
+
ui.repoDetail('Branch', upstreamBranch);
|
|
27
|
+
|
|
28
|
+
// ── Add remote + fetch ───────────────────────────────────────────────────
|
|
29
|
+
ui.step(3, 4, 'Adding remote and fetching...');
|
|
30
|
+
git(`remote add "${name}" "${url}"`, { cwd, silent: true });
|
|
31
|
+
|
|
32
|
+
if (fullHistory) {
|
|
33
|
+
ui.repoDetail('Mode', 'full history');
|
|
34
|
+
git(`fetch --no-tags "${name}" "${upstreamBranch}"`, { cwd });
|
|
35
|
+
git(`subtree add --prefix="${name}" "${name}" "${upstreamBranch}"`, { cwd });
|
|
36
|
+
} else {
|
|
37
|
+
ui.repoDetail('Mode', 'shallow + squash');
|
|
38
|
+
git(`fetch --no-tags --depth 1 "${name}" "${upstreamBranch}"`, { cwd });
|
|
39
|
+
git(`subtree add --squash --prefix="${name}" "${name}" "${upstreamBranch}"`, { cwd });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ── Done ─────────────────────────────────────────────────────────────────
|
|
43
|
+
ui.step(4, 4, 'Done!');
|
|
44
|
+
ui.addSummary(name, url);
|
|
45
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { git, getCurrentBranch, getSubtreePrefixes, getRemoteBranch } from '../git.js';
|
|
2
|
+
import { validateInsideMonorepo } from '../validate.js';
|
|
3
|
+
import * as ui from '../ui.js';
|
|
4
|
+
|
|
5
|
+
export async function runBranch({ name }) {
|
|
6
|
+
const cwd = process.cwd();
|
|
7
|
+
|
|
8
|
+
validateInsideMonorepo(cwd);
|
|
9
|
+
|
|
10
|
+
const subtrees = getSubtreePrefixes(cwd);
|
|
11
|
+
const currentBranch = getCurrentBranch(cwd);
|
|
12
|
+
|
|
13
|
+
if (!name) {
|
|
14
|
+
// No name given — show current state
|
|
15
|
+
ui.header('Branch');
|
|
16
|
+
ui.blank();
|
|
17
|
+
ui.info(`Current branch: ${currentBranch}`);
|
|
18
|
+
ui.blank();
|
|
19
|
+
for (const s of subtrees) {
|
|
20
|
+
const upstream = getRemoteBranch(cwd, s.name) || 'unknown';
|
|
21
|
+
ui.info(` ${s.name} upstream: ${upstream} push target: ${currentBranch}`);
|
|
22
|
+
}
|
|
23
|
+
ui.blank();
|
|
24
|
+
ui.info('Use "unirepo branch <name>" to switch all subtrees to a new branch.');
|
|
25
|
+
ui.blank();
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
ui.header('Branch');
|
|
30
|
+
ui.blank();
|
|
31
|
+
|
|
32
|
+
if (currentBranch === name) {
|
|
33
|
+
ui.info(`Already on branch "${name}"`);
|
|
34
|
+
ui.blank();
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Create and switch to the new branch locally
|
|
39
|
+
git(`checkout -b "${name}"`, { cwd, silent: true });
|
|
40
|
+
ui.success(`Switched to new branch "${name}"`);
|
|
41
|
+
ui.blank();
|
|
42
|
+
|
|
43
|
+
for (const s of subtrees) {
|
|
44
|
+
ui.success(`${s.name} → push will target "${name}" upstream`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
ui.blank();
|
|
48
|
+
ui.info('Next steps:');
|
|
49
|
+
ui.info(' 1. Make changes in subtree directories');
|
|
50
|
+
ui.info(' 2. Commit in the monorepo');
|
|
51
|
+
ui.info(` 3. unirepo push`);
|
|
52
|
+
ui.info(' 4. Open one PR per upstream repo');
|
|
53
|
+
ui.blank();
|
|
54
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync, existsSync, rmSync } from 'node:fs';
|
|
2
|
+
import { resolve, join } from 'node:path';
|
|
3
|
+
import { git, detectDefaultBranch, extractRepoName } from '../git.js';
|
|
4
|
+
import { validateGitSubtree, validateUrls, validateNoDuplicateNames, validateReachable } from '../validate.js';
|
|
5
|
+
import { AGENTS_MD, GITIGNORE } from '../templates.js';
|
|
6
|
+
import * as ui from '../ui.js';
|
|
7
|
+
|
|
8
|
+
export async function runInit({ dir, repos, fullHistory }) {
|
|
9
|
+
const absDir = resolve(dir);
|
|
10
|
+
|
|
11
|
+
if (existsSync(absDir)) {
|
|
12
|
+
throw new Error(`Directory "${dir}" already exists. Choose a different name or remove it first.`);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// ── Preflight ────────────────────────────────────────────────────────────
|
|
16
|
+
ui.header('Creating monorepo');
|
|
17
|
+
ui.blank();
|
|
18
|
+
|
|
19
|
+
ui.step(1, 3 + repos.length, 'Running preflight checks...');
|
|
20
|
+
validateGitSubtree();
|
|
21
|
+
validateUrls(repos);
|
|
22
|
+
validateNoDuplicateNames(repos);
|
|
23
|
+
for (const url of repos) {
|
|
24
|
+
ui.info(`Checking ${url}...`);
|
|
25
|
+
validateReachable(url);
|
|
26
|
+
}
|
|
27
|
+
ui.success('All checks passed');
|
|
28
|
+
|
|
29
|
+
// From here on, clean up the directory if anything fails
|
|
30
|
+
let created = false;
|
|
31
|
+
try {
|
|
32
|
+
// ── Initialize repo ──────────────────────────────────────────────────────
|
|
33
|
+
ui.step(2, 3 + repos.length, 'Initializing git repository...');
|
|
34
|
+
mkdirSync(absDir, { recursive: true });
|
|
35
|
+
created = true;
|
|
36
|
+
git('init', { cwd: absDir, silent: true });
|
|
37
|
+
git('commit --allow-empty -m "chore: initial commit"', { cwd: absDir, silent: true });
|
|
38
|
+
ui.repoDetail('Location', absDir);
|
|
39
|
+
|
|
40
|
+
// ── Scaffold files ───────────────────────────────────────────────────────
|
|
41
|
+
ui.step(3, 3 + repos.length, 'Writing scaffold files...');
|
|
42
|
+
const scaffoldFiles = [];
|
|
43
|
+
|
|
44
|
+
writeFileSync(join(absDir, 'AGENTS.md'), AGENTS_MD);
|
|
45
|
+
scaffoldFiles.push('AGENTS.md');
|
|
46
|
+
|
|
47
|
+
writeFileSync(join(absDir, '.gitignore'), GITIGNORE);
|
|
48
|
+
scaffoldFiles.push('.gitignore');
|
|
49
|
+
|
|
50
|
+
git(`add ${scaffoldFiles.join(' ')}`, { cwd: absDir, silent: true });
|
|
51
|
+
git('commit -m "chore: add monorepo scaffolding"', { cwd: absDir, silent: true });
|
|
52
|
+
ui.success(`Wrote ${scaffoldFiles.join(', ')}`);
|
|
53
|
+
|
|
54
|
+
// ── Import repos ─────────────────────────────────────────────────────────
|
|
55
|
+
for (let i = 0; i < repos.length; i++) {
|
|
56
|
+
const url = repos[i];
|
|
57
|
+
const name = extractRepoName(url);
|
|
58
|
+
const stepNum = 4 + i;
|
|
59
|
+
|
|
60
|
+
ui.blank();
|
|
61
|
+
ui.repoStep(stepNum, 3 + repos.length, name, 'Importing');
|
|
62
|
+
ui.repoDetail('URL', url);
|
|
63
|
+
|
|
64
|
+
// Detect default branch
|
|
65
|
+
const branch = detectDefaultBranch(url);
|
|
66
|
+
ui.repoDetail('Branch', branch);
|
|
67
|
+
|
|
68
|
+
// Add remote
|
|
69
|
+
git(`remote add "${name}" "${url}"`, { cwd: absDir, silent: true });
|
|
70
|
+
|
|
71
|
+
// Fetch
|
|
72
|
+
if (fullHistory) {
|
|
73
|
+
ui.repoDetail('Mode', 'full history');
|
|
74
|
+
git(`fetch --no-tags "${name}" "${branch}"`, { cwd: absDir });
|
|
75
|
+
git(`subtree add --prefix="${name}" "${name}" "${branch}"`, { cwd: absDir });
|
|
76
|
+
} else {
|
|
77
|
+
ui.repoDetail('Mode', 'shallow + squash');
|
|
78
|
+
git(`fetch --no-tags --depth 1 "${name}" "${branch}"`, { cwd: absDir });
|
|
79
|
+
git(`subtree add --squash --prefix="${name}" "${name}" "${branch}"`, { cwd: absDir });
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
ui.success(`${name} imported`);
|
|
83
|
+
}
|
|
84
|
+
} catch (err) {
|
|
85
|
+
// Clean up partially created directory so the user can retry
|
|
86
|
+
if (created) {
|
|
87
|
+
try { rmSync(absDir, { recursive: true, force: true }); } catch {}
|
|
88
|
+
}
|
|
89
|
+
throw err;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ── Summary ──────────────────────────────────────────────────────────────
|
|
93
|
+
const subtreeNames = repos.map((url) => extractRepoName(url));
|
|
94
|
+
ui.initSummary(absDir, repos.length, subtreeNames);
|
|
95
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { git, getRemoteBranch, getSubtreePrefixes } from '../git.js';
|
|
2
|
+
import { validateGitSubtree, validateInsideMonorepo } from '../validate.js';
|
|
3
|
+
import * as ui from '../ui.js';
|
|
4
|
+
|
|
5
|
+
export async function runPull({ subtrees: requestedSubtrees, branch, fullHistory }) {
|
|
6
|
+
const cwd = process.cwd();
|
|
7
|
+
|
|
8
|
+
ui.header('Pulling subtrees');
|
|
9
|
+
ui.blank();
|
|
10
|
+
|
|
11
|
+
validateGitSubtree();
|
|
12
|
+
validateInsideMonorepo(cwd);
|
|
13
|
+
|
|
14
|
+
const allPrefixes = getSubtreePrefixes(cwd);
|
|
15
|
+
let toPull;
|
|
16
|
+
|
|
17
|
+
if (requestedSubtrees && requestedSubtrees.length > 0) {
|
|
18
|
+
const prefixNames = new Set(allPrefixes.map((prefix) => prefix.name));
|
|
19
|
+
for (const name of requestedSubtrees) {
|
|
20
|
+
if (!prefixNames.has(name)) {
|
|
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
|
+
}
|
|
28
|
+
|
|
29
|
+
if (toPull.length === 0) {
|
|
30
|
+
ui.info('No tracked subtrees found. Nothing to pull.');
|
|
31
|
+
ui.blank();
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let succeeded = 0;
|
|
36
|
+
let failed = 0;
|
|
37
|
+
|
|
38
|
+
for (let i = 0; i < toPull.length; i++) {
|
|
39
|
+
const prefix = toPull[i];
|
|
40
|
+
const upstreamBranch = branch || getRemoteBranch(cwd, prefix.name) || 'main';
|
|
41
|
+
const squashFlag = fullHistory ? '' : '--squash ';
|
|
42
|
+
|
|
43
|
+
ui.repoStep(i + 1, toPull.length, prefix.name, 'Pulling');
|
|
44
|
+
ui.repoDetail('Branch', upstreamBranch);
|
|
45
|
+
ui.repoDetail('Mode', fullHistory ? 'full history' : 'squash');
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
git(`subtree pull ${squashFlag}--prefix="${prefix.name}" "${prefix.name}" "${upstreamBranch}"`, { cwd });
|
|
49
|
+
ui.success(`${prefix.name} pulled`);
|
|
50
|
+
succeeded++;
|
|
51
|
+
} catch (err) {
|
|
52
|
+
ui.error(`Failed to pull ${prefix.name}: ${err.message}`);
|
|
53
|
+
failed++;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
ui.blank();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (failed === 0) {
|
|
60
|
+
ui.success(`All ${succeeded} subtree(s) pulled`);
|
|
61
|
+
} else {
|
|
62
|
+
ui.warning(`${succeeded} pulled, ${failed} failed`);
|
|
63
|
+
}
|
|
64
|
+
ui.blank();
|
|
65
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { git, getCurrentBranch, getSubtreePrefixes, getChangedSubtrees, hasUncommittedChanges } from '../git.js';
|
|
2
|
+
import { validateGitSubtree, validateInsideMonorepo } from '../validate.js';
|
|
3
|
+
import * as ui from '../ui.js';
|
|
4
|
+
|
|
5
|
+
export async function runPush({ subtrees: requestedSubtrees, branch, dryRun }) {
|
|
6
|
+
const cwd = process.cwd();
|
|
7
|
+
|
|
8
|
+
// ── Preflight ────────────────────────────────────────────────────────────
|
|
9
|
+
ui.header('Pushing subtrees');
|
|
10
|
+
ui.blank();
|
|
11
|
+
|
|
12
|
+
validateGitSubtree();
|
|
13
|
+
validateInsideMonorepo(cwd);
|
|
14
|
+
|
|
15
|
+
const currentBranch = getCurrentBranch(cwd);
|
|
16
|
+
const pushBranch = branch || currentBranch;
|
|
17
|
+
|
|
18
|
+
// ── Check for uncommitted work ────────────────────────────────────────────
|
|
19
|
+
const hasUncommitted = hasUncommittedChanges(cwd);
|
|
20
|
+
if (hasUncommitted) {
|
|
21
|
+
ui.warning('You have uncommitted changes. Only committed work will be pushed upstream.');
|
|
22
|
+
ui.info('Commit your changes first, then run push again.');
|
|
23
|
+
ui.blank();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// ── Determine which subtrees to push ─────────────────────────────────────
|
|
27
|
+
let toPush;
|
|
28
|
+
|
|
29
|
+
if (requestedSubtrees && requestedSubtrees.length > 0) {
|
|
30
|
+
// Validate that requested subtrees exist
|
|
31
|
+
const allPrefixes = getSubtreePrefixes(cwd);
|
|
32
|
+
const prefixNames = new Set(allPrefixes.map((p) => p.name));
|
|
33
|
+
for (const name of requestedSubtrees) {
|
|
34
|
+
if (!prefixNames.has(name)) {
|
|
35
|
+
throw new Error(`"${name}" is not a tracked subtree. Run "unirepo status" to see available subtrees.`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
toPush = allPrefixes.filter((p) => requestedSubtrees.includes(p.name));
|
|
39
|
+
} else {
|
|
40
|
+
// Auto-detect changed subtrees
|
|
41
|
+
toPush = getChangedSubtrees(cwd);
|
|
42
|
+
if (toPush.length === 0) {
|
|
43
|
+
ui.info('No changed subtrees detected. Nothing to push.');
|
|
44
|
+
if (!hasUncommitted) {
|
|
45
|
+
ui.info('Make changes inside subtree directories and commit before pushing.');
|
|
46
|
+
}
|
|
47
|
+
ui.blank();
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
ui.info(`Branch: ${pushBranch}`);
|
|
53
|
+
ui.info(`Subtrees to push: ${toPush.map((p) => p.name).join(', ')}`);
|
|
54
|
+
ui.blank();
|
|
55
|
+
|
|
56
|
+
// ── Dry run ──────────────────────────────────────────────────────────────
|
|
57
|
+
if (dryRun) {
|
|
58
|
+
const commands = toPush.map(
|
|
59
|
+
(p) => `git subtree push --prefix="${p.name}" "${p.name}" "${pushBranch}"`
|
|
60
|
+
);
|
|
61
|
+
ui.dryRun(commands);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ── Push each subtree serially ───────────────────────────────────────────
|
|
66
|
+
let succeeded = 0;
|
|
67
|
+
let failed = 0;
|
|
68
|
+
|
|
69
|
+
for (const prefix of toPush) {
|
|
70
|
+
ui.pushStart(prefix.name, pushBranch);
|
|
71
|
+
ui.pushSlow();
|
|
72
|
+
try {
|
|
73
|
+
git(`subtree push --prefix="${prefix.name}" "${prefix.name}" "${pushBranch}"`, { cwd });
|
|
74
|
+
ui.success(`${prefix.name} pushed`);
|
|
75
|
+
succeeded++;
|
|
76
|
+
} catch (err) {
|
|
77
|
+
ui.error(`Failed to push ${prefix.name}: ${err.message}`);
|
|
78
|
+
failed++;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ── Summary ──────────────────────────────────────────────────────────────
|
|
83
|
+
ui.blank();
|
|
84
|
+
if (failed === 0) {
|
|
85
|
+
ui.success(`All ${succeeded} subtree(s) pushed to branch "${pushBranch}"`);
|
|
86
|
+
} else {
|
|
87
|
+
ui.warning(`${succeeded} pushed, ${failed} failed`);
|
|
88
|
+
}
|
|
89
|
+
ui.blank();
|
|
90
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { getCurrentBranch, getSubtreePrefixes, getChangedSubtrees, getRemoteBranch } from '../git.js';
|
|
2
|
+
import { validateInsideMonorepo } from '../validate.js';
|
|
3
|
+
import * as ui from '../ui.js';
|
|
4
|
+
|
|
5
|
+
export async function runStatus({ json }) {
|
|
6
|
+
const cwd = process.cwd();
|
|
7
|
+
|
|
8
|
+
validateInsideMonorepo(cwd);
|
|
9
|
+
|
|
10
|
+
const branch = getCurrentBranch(cwd);
|
|
11
|
+
const prefixes = getSubtreePrefixes(cwd);
|
|
12
|
+
const changed = getChangedSubtrees(cwd);
|
|
13
|
+
const changedNames = new Set(changed.map((c) => c.name));
|
|
14
|
+
|
|
15
|
+
const subtrees = prefixes.map((p) => ({
|
|
16
|
+
name: p.name,
|
|
17
|
+
url: p.url,
|
|
18
|
+
upstream: getRemoteBranch(cwd, p.name) || 'unknown',
|
|
19
|
+
pushBranch: branch,
|
|
20
|
+
changed: changedNames.has(p.name),
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
if (json) {
|
|
24
|
+
console.log(JSON.stringify({ pushBranch: branch, subtrees }, null, 2));
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
ui.subtreeTable(subtrees, branch);
|
|
29
|
+
}
|