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 CHANGED
@@ -4,63 +4,102 @@
4
4
  [![CI](https://github.com/Poko18/unirepo/actions/workflows/ci.yml/badge.svg)](https://github.com/Poko18/unirepo/actions/workflows/ci.yml)
5
5
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
6
6
 
7
- **The workspace for cross-repo coding.**
7
+ ### One workspace to refactor your whole stack in one go
8
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.
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 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.
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
- ## Why unirepo?
15
+ ### Example task
15
16
 
16
- **Before unirepo:**
17
+ You need to update:
17
18
 
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
- ```
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
- ## Quick Start
27
+ **Without `unirepo`**
37
28
 
38
29
  ```bash
39
- npx unirepo-cli init my-workspace \
40
- https://github.com/org/api.git \
41
- https://github.com/org/web.git
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
- This creates:
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
- Then work across repos as if they were one:
77
+ # commit once from the workspace
78
+ git add .
79
+ git commit -m "feat: add auth flow"
55
80
 
56
- ```bash
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
- That's it. Each subtree gets pushed to its upstream repo automatically.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unirepo-cli",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "CLI tool for creating and managing git-subtree monorepos — run your agents across repos",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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);
@@ -1,4 +1,4 @@
1
- import { git, getCurrentBranch, getSubtreePrefixes, getRemoteBranch } from '../git.js';
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 = getRemoteBranch(cwd, s.name) || 'unknown';
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();
@@ -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) {
@@ -1,7 +1,53 @@
1
- import { git, getRemoteBranch, getSubtreePrefixes } from '../git.js';
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
- 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
- }
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
- ui.info('No tracked subtrees found. Nothing to pull.');
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 upstreamBranch = branch || getRemoteBranch(cwd, prefix.name) || 'main';
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
+ }
@@ -1,4 +1,4 @@
1
- import { getCurrentBranch, getSubtreePrefixes, getChangedSubtrees, getRemoteBranch } from '../git.js';
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: getRemoteBranch(cwd, p.name) || 'unknown',
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 refs = execSync(`git branch -r --list "${remoteName}/*"`, {
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
- // e.g. "Hello-World/master" → "master"
223
- const first = refs.split('\n')[0].trim();
224
- const slash = first.indexOf('/');
225
- return slash >= 0 ? first.slice(slash + 1) : first;
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