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/src/git.js
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import { execSync } from 'node:child_process';
|
|
2
|
+
import { readdirSync, statSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Execute a git command and return trimmed stdout.
|
|
7
|
+
* @param {string} args - git arguments
|
|
8
|
+
* @param {object} opts - { cwd, silent, allowFailure }
|
|
9
|
+
* @returns {string} stdout
|
|
10
|
+
*/
|
|
11
|
+
export function git(args, opts = {}) {
|
|
12
|
+
const { cwd, silent = false, allowFailure = false } = opts;
|
|
13
|
+
try {
|
|
14
|
+
return execSync(`git ${args}`, {
|
|
15
|
+
cwd,
|
|
16
|
+
encoding: 'utf-8',
|
|
17
|
+
stdio: silent ? ['pipe', 'pipe', 'pipe'] : ['pipe', 'pipe', 'inherit'],
|
|
18
|
+
}).trim();
|
|
19
|
+
} catch (err) {
|
|
20
|
+
if (allowFailure) return '';
|
|
21
|
+
// Surface git's stderr so users can see what actually went wrong
|
|
22
|
+
const stderr = (err.stderr || '').trim();
|
|
23
|
+
const msg = stderr || err.message;
|
|
24
|
+
throw new Error(msg);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Execute a git command and return output as array of lines.
|
|
30
|
+
*/
|
|
31
|
+
export function gitLines(args, opts = {}) {
|
|
32
|
+
const out = git(args, { ...opts, silent: true });
|
|
33
|
+
if (!out) return [];
|
|
34
|
+
return out.split('\n').filter(Boolean);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Check if cwd is inside a git work tree.
|
|
39
|
+
*/
|
|
40
|
+
export function isInsideWorkTree(cwd) {
|
|
41
|
+
try {
|
|
42
|
+
const result = execSync('git rev-parse --is-inside-work-tree', {
|
|
43
|
+
cwd,
|
|
44
|
+
encoding: 'utf-8',
|
|
45
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
46
|
+
}).trim();
|
|
47
|
+
return result === 'true';
|
|
48
|
+
} catch {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Get the current branch name.
|
|
55
|
+
*/
|
|
56
|
+
export function getCurrentBranch(cwd) {
|
|
57
|
+
return git('rev-parse --abbrev-ref HEAD', { cwd, silent: true });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Parse `git remote -v` into [{ name, url }] (fetch entries only).
|
|
62
|
+
*/
|
|
63
|
+
export function getRemotes(cwd) {
|
|
64
|
+
const lines = gitLines('remote -v', { cwd });
|
|
65
|
+
const remotes = [];
|
|
66
|
+
const seen = new Set();
|
|
67
|
+
for (const line of lines) {
|
|
68
|
+
const match = line.match(/^(\S+)\s+(\S+)\s+\(fetch\)$/);
|
|
69
|
+
if (match && !seen.has(match[1])) {
|
|
70
|
+
seen.add(match[1]);
|
|
71
|
+
remotes.push({ name: match[1], url: match[2] });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return remotes;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Detect the default branch of a remote repo via ls-remote.
|
|
79
|
+
* Falls back to 'main' if detection fails.
|
|
80
|
+
*/
|
|
81
|
+
export function detectDefaultBranch(url) {
|
|
82
|
+
try {
|
|
83
|
+
const output = execSync(`git ls-remote --symref "${url}" HEAD`, {
|
|
84
|
+
encoding: 'utf-8',
|
|
85
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
86
|
+
timeout: 30000,
|
|
87
|
+
});
|
|
88
|
+
const match = output.match(/ref:\s+refs\/heads\/(\S+)/);
|
|
89
|
+
if (match) return match[1];
|
|
90
|
+
} catch {
|
|
91
|
+
// fall through
|
|
92
|
+
}
|
|
93
|
+
return 'main';
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Check if `git subtree` command is available.
|
|
98
|
+
*/
|
|
99
|
+
export function hasSubtreeCommand() {
|
|
100
|
+
try {
|
|
101
|
+
// `git subtree` with no args prints usage and exits non-zero,
|
|
102
|
+
// but that still means it's available. A missing command would
|
|
103
|
+
// throw with a different error (e.g. "is not a git command").
|
|
104
|
+
execSync('git subtree 2>&1', {
|
|
105
|
+
encoding: 'utf-8',
|
|
106
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
107
|
+
shell: true,
|
|
108
|
+
});
|
|
109
|
+
return true;
|
|
110
|
+
} catch (err) {
|
|
111
|
+
const output = (err.stdout || '') + (err.stderr || '');
|
|
112
|
+
// If the output contains usage info, subtree is available
|
|
113
|
+
if (output.includes('git subtree')) return true;
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* List top-level directories that match a remote name (i.e. are subtree prefixes).
|
|
120
|
+
*/
|
|
121
|
+
export function getSubtreePrefixes(cwd) {
|
|
122
|
+
const remotes = getRemotes(cwd);
|
|
123
|
+
const remoteNames = new Set(remotes.map((r) => r.name));
|
|
124
|
+
const prefixes = [];
|
|
125
|
+
try {
|
|
126
|
+
const entries = readdirSync(cwd);
|
|
127
|
+
for (const entry of entries) {
|
|
128
|
+
if (entry.startsWith('.')) continue;
|
|
129
|
+
const full = join(cwd, entry);
|
|
130
|
+
try {
|
|
131
|
+
if (statSync(full).isDirectory() && remoteNames.has(entry)) {
|
|
132
|
+
const remote = remotes.find((r) => r.name === entry);
|
|
133
|
+
prefixes.push({ name: entry, url: remote?.url || '' });
|
|
134
|
+
}
|
|
135
|
+
} catch {
|
|
136
|
+
// skip unreadable entries
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
} catch {
|
|
140
|
+
// empty
|
|
141
|
+
}
|
|
142
|
+
return prefixes;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Find the SHA of the last subtree add/pull merge commit for a given prefix.
|
|
147
|
+
* Subtree merges have messages like "Merge commit '...' as '<prefix>'" or
|
|
148
|
+
* "Merge commit '...' into ...".
|
|
149
|
+
*/
|
|
150
|
+
function findLastSubtreeMerge(cwd, prefixName) {
|
|
151
|
+
// Look for the merge commit that added/updated this subtree
|
|
152
|
+
const lines = execSync(
|
|
153
|
+
`git log --merges --format=%H -- "${prefixName}"`,
|
|
154
|
+
{ cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
|
|
155
|
+
).trim();
|
|
156
|
+
if (!lines) return null;
|
|
157
|
+
// The first (most recent) merge commit touching this prefix
|
|
158
|
+
return lines.split('\n')[0].trim();
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Get subtree prefixes that have changes (uncommitted or committed since last subtree merge).
|
|
163
|
+
*/
|
|
164
|
+
export function getChangedSubtrees(cwd) {
|
|
165
|
+
const prefixes = getSubtreePrefixes(cwd);
|
|
166
|
+
const changed = [];
|
|
167
|
+
|
|
168
|
+
for (const prefix of prefixes) {
|
|
169
|
+
// Check 1: uncommitted/unstaged changes in the working tree
|
|
170
|
+
let hasUncommitted = false;
|
|
171
|
+
try {
|
|
172
|
+
execSync(`git diff --quiet -- "${prefix.name}"`, {
|
|
173
|
+
cwd,
|
|
174
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
175
|
+
});
|
|
176
|
+
// Also check staged but not yet committed
|
|
177
|
+
execSync(`git diff --quiet --cached -- "${prefix.name}"`, {
|
|
178
|
+
cwd,
|
|
179
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
180
|
+
});
|
|
181
|
+
} catch {
|
|
182
|
+
hasUncommitted = true;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (hasUncommitted) {
|
|
186
|
+
changed.push(prefix);
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Check 2: committed changes since the last subtree add/pull merge
|
|
191
|
+
try {
|
|
192
|
+
const mergeCommit = findLastSubtreeMerge(cwd, prefix.name);
|
|
193
|
+
if (mergeCommit) {
|
|
194
|
+
// Are there any commits after the merge that touch this prefix?
|
|
195
|
+
const commits = execSync(
|
|
196
|
+
`git log --oneline "${mergeCommit}..HEAD" -- "${prefix.name}"`,
|
|
197
|
+
{ cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
|
|
198
|
+
).trim();
|
|
199
|
+
if (commits) {
|
|
200
|
+
changed.push(prefix);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
} catch {
|
|
204
|
+
// If we can't determine, don't mark as changed
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return changed;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Get the upstream default branch for a remote (from the fetched tracking refs).
|
|
212
|
+
* Returns the branch name or null if not found.
|
|
213
|
+
*/
|
|
214
|
+
export function getRemoteBranch(cwd, remoteName) {
|
|
215
|
+
try {
|
|
216
|
+
const refs = execSync(`git branch -r --list "${remoteName}/*"`, {
|
|
217
|
+
cwd,
|
|
218
|
+
encoding: 'utf-8',
|
|
219
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
220
|
+
}).trim();
|
|
221
|
+
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;
|
|
226
|
+
} catch {
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Check if the working tree has uncommitted (staged or unstaged) changes.
|
|
233
|
+
*/
|
|
234
|
+
export function hasUncommittedChanges(cwd) {
|
|
235
|
+
try {
|
|
236
|
+
execSync('git diff --quiet', { cwd, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
237
|
+
execSync('git diff --quiet --cached', { cwd, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
238
|
+
return false;
|
|
239
|
+
} catch {
|
|
240
|
+
return true;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Extract repo name from URL (basename without .git suffix).
|
|
246
|
+
*/
|
|
247
|
+
export function extractRepoName(url) {
|
|
248
|
+
const base = url.replace(/\/$/, '').split('/').pop() || '';
|
|
249
|
+
return base.replace(/\.git$/, '');
|
|
250
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import * as ui from './ui.js';
|
|
4
|
+
import { runInit } from './commands/init.js';
|
|
5
|
+
import { runAdd } from './commands/add.js';
|
|
6
|
+
import { runPull } from './commands/pull.js';
|
|
7
|
+
import { runStatus } from './commands/status.js';
|
|
8
|
+
import { runPush } from './commands/push.js';
|
|
9
|
+
import { runBranch } from './commands/branch.js';
|
|
10
|
+
import { pathToFileURL } from 'node:url';
|
|
11
|
+
import { realpathSync } from 'node:fs';
|
|
12
|
+
|
|
13
|
+
// ── Argument Parsing ───────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
export function parseArgs(argv) {
|
|
16
|
+
const args = argv.slice(2); // skip node + script
|
|
17
|
+
|
|
18
|
+
if (args[0] === '--version' || args[0] === '-v' || args[0] === 'version') {
|
|
19
|
+
return { command: 'version', positional: [], flags: {} };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Handle top-level --help / -h before command
|
|
23
|
+
if (args.length === 0 || args[0] === '--help' || args[0] === '-h') {
|
|
24
|
+
return { command: undefined, positional: [], flags: { help: true } };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const command = args[0];
|
|
28
|
+
const positional = [];
|
|
29
|
+
const flags = {};
|
|
30
|
+
|
|
31
|
+
for (let i = 1; i < args.length; i++) {
|
|
32
|
+
const arg = args[i];
|
|
33
|
+
if (arg === '--full-history') {
|
|
34
|
+
flags.fullHistory = true;
|
|
35
|
+
} else if (arg === '--json') {
|
|
36
|
+
flags.json = true;
|
|
37
|
+
} else if (arg === '--dry-run') {
|
|
38
|
+
flags.dryRun = true;
|
|
39
|
+
} else if (arg === '--prefix' && i + 1 < args.length) {
|
|
40
|
+
flags.prefix = args[++i];
|
|
41
|
+
} else if (arg === '--branch' && i + 1 < args.length) {
|
|
42
|
+
flags.branch = args[++i];
|
|
43
|
+
} else if (arg === '--help' || arg === '-h') {
|
|
44
|
+
flags.help = true;
|
|
45
|
+
} else if (arg.startsWith('-')) {
|
|
46
|
+
ui.error(`Unknown flag: ${arg}`);
|
|
47
|
+
process.exit(1);
|
|
48
|
+
} else {
|
|
49
|
+
positional.push(arg);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return { command, positional, flags };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ── Main ───────────────────────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
export async function main() {
|
|
59
|
+
const { command, positional, flags } = parseArgs(process.argv);
|
|
60
|
+
|
|
61
|
+
if (!command || command === 'help' || flags.help) {
|
|
62
|
+
ui.usage();
|
|
63
|
+
process.exit(0);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
switch (command) {
|
|
67
|
+
case 'version': {
|
|
68
|
+
ui.version();
|
|
69
|
+
break;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
case 'init': {
|
|
73
|
+
if (positional.length < 2) {
|
|
74
|
+
ui.error('Usage: unirepo init <dir> <repo-url> [repo-url...]');
|
|
75
|
+
process.exit(1);
|
|
76
|
+
}
|
|
77
|
+
const [dir, ...repos] = positional;
|
|
78
|
+
await runInit({ dir, repos, fullHistory: flags.fullHistory || false });
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
case 'add': {
|
|
83
|
+
if (positional.length < 1) {
|
|
84
|
+
ui.error('Usage: unirepo add <repo-url> [--prefix <name>] [--branch <name>] [--full-history]');
|
|
85
|
+
process.exit(1);
|
|
86
|
+
}
|
|
87
|
+
await runAdd({
|
|
88
|
+
url: positional[0],
|
|
89
|
+
prefix: flags.prefix,
|
|
90
|
+
branch: flags.branch,
|
|
91
|
+
fullHistory: flags.fullHistory || false,
|
|
92
|
+
});
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
case 'pull': {
|
|
97
|
+
await runPull({
|
|
98
|
+
subtrees: positional.length > 0 ? positional : undefined,
|
|
99
|
+
branch: flags.branch,
|
|
100
|
+
fullHistory: flags.fullHistory || false,
|
|
101
|
+
});
|
|
102
|
+
break;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
case 'status': {
|
|
106
|
+
await runStatus({ json: flags.json || false });
|
|
107
|
+
break;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
case 'push': {
|
|
111
|
+
await runPush({
|
|
112
|
+
subtrees: positional.length > 0 ? positional : undefined,
|
|
113
|
+
branch: flags.branch,
|
|
114
|
+
dryRun: flags.dryRun || false,
|
|
115
|
+
});
|
|
116
|
+
break;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
case 'branch': {
|
|
120
|
+
await runBranch({ name: positional[0] });
|
|
121
|
+
break;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
default:
|
|
125
|
+
ui.error(`Unknown command: ${command}`);
|
|
126
|
+
ui.blank();
|
|
127
|
+
ui.usage();
|
|
128
|
+
process.exit(1);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Resolve symlinks so this works when invoked via an npm-installed bin symlink
|
|
133
|
+
// (e.g. /usr/local/bin/unirepo → /usr/local/lib/node_modules/unirepo/src/index.js).
|
|
134
|
+
function isDirectRunCheck() {
|
|
135
|
+
if (!process.argv[1]) return false;
|
|
136
|
+
try {
|
|
137
|
+
const realArgv = realpathSync(process.argv[1]);
|
|
138
|
+
return import.meta.url === pathToFileURL(realArgv).href;
|
|
139
|
+
} catch {
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
const isDirectRun = isDirectRunCheck();
|
|
144
|
+
|
|
145
|
+
if (isDirectRun) {
|
|
146
|
+
main().catch((err) => {
|
|
147
|
+
ui.error(err.message);
|
|
148
|
+
process.exit(1);
|
|
149
|
+
});
|
|
150
|
+
}
|
package/src/templates.js
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
export const AGENTS_MD = `# Monorepo Workflow
|
|
2
|
+
|
|
3
|
+
## Model
|
|
4
|
+
|
|
5
|
+
This repository is a git-subtree monorepo.
|
|
6
|
+
|
|
7
|
+
- Each top-level subtree directory maps to an upstream repository.
|
|
8
|
+
- Subtree directory names often match git remote names, but verify the mapping before manual subtree commands.
|
|
9
|
+
- Run monorepo commands from the repository root.
|
|
10
|
+
|
|
11
|
+
## Rules
|
|
12
|
+
|
|
13
|
+
- Never use regular \`git push\` to publish the monorepo itself as a deployment target.
|
|
14
|
+
- Work in a single monorepo branch and reuse that branch name when pushing subtree branches upstream.
|
|
15
|
+
- Keep changes scoped to the subtree or subtrees you intend to update.
|
|
16
|
+
- Edit files inside subtree directories, not in unrelated top-level folders.
|
|
17
|
+
- Prefer separate commits per subtree unless the change is tightly coupled across repos.
|
|
18
|
+
- Push only subtrees that actually changed.
|
|
19
|
+
- Keep subtree directory, remote name, and pushed branch consistent.
|
|
20
|
+
- Do not mix files from different subtrees in a subtree PR.
|
|
21
|
+
|
|
22
|
+
## CLI
|
|
23
|
+
|
|
24
|
+
Preferred when the \`unirepo\` CLI is available:
|
|
25
|
+
|
|
26
|
+
\`\`\`bash
|
|
27
|
+
unirepo version
|
|
28
|
+
unirepo status
|
|
29
|
+
unirepo branch <branch>
|
|
30
|
+
unirepo pull
|
|
31
|
+
unirepo push --dry-run
|
|
32
|
+
unirepo push
|
|
33
|
+
unirepo add <repo-url> --branch <branch>
|
|
34
|
+
\`\`\`
|
|
35
|
+
|
|
36
|
+
- \`status\` shows tracked subtrees, upstream branches, the current push branch, and changed files.
|
|
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.
|
|
39
|
+
- \`push --dry-run\` is the safe first step before a real push.
|
|
40
|
+
- \`push\` without subtree names auto-detects changed subtrees. \`push <subtree>\` pushes one subtree.
|
|
41
|
+
- \`add\` imports another repository as a subtree. Use \`--branch\` to import from a non-default upstream branch.
|
|
42
|
+
|
|
43
|
+
## Workflow
|
|
44
|
+
|
|
45
|
+
1. Create or reuse one branch name for the work.
|
|
46
|
+
CLI:
|
|
47
|
+
\`\`\`bash
|
|
48
|
+
unirepo branch <branch>
|
|
49
|
+
\`\`\`
|
|
50
|
+
Git:
|
|
51
|
+
\`\`\`bash
|
|
52
|
+
git checkout -b <branch>
|
|
53
|
+
\`\`\`
|
|
54
|
+
|
|
55
|
+
2. Pull upstream updates when needed.
|
|
56
|
+
CLI:
|
|
57
|
+
\`\`\`bash
|
|
58
|
+
unirepo pull
|
|
59
|
+
\`\`\`
|
|
60
|
+
Git:
|
|
61
|
+
\`\`\`bash
|
|
62
|
+
git subtree pull --prefix=<subtree> <remote-or-url> <branch> --squash
|
|
63
|
+
\`\`\`
|
|
64
|
+
|
|
65
|
+
3. Make changes inside one or more subtree directories.
|
|
66
|
+
|
|
67
|
+
4. Commit in the monorepo. Prefer one commit per subtree unless the change is intentionally coupled.
|
|
68
|
+
\`\`\`bash
|
|
69
|
+
git add <subtree>/
|
|
70
|
+
git commit -m "feat(<subtree>): ..."
|
|
71
|
+
\`\`\`
|
|
72
|
+
|
|
73
|
+
5. Inspect what changed before pushing.
|
|
74
|
+
CLI:
|
|
75
|
+
\`\`\`bash
|
|
76
|
+
unirepo status
|
|
77
|
+
\`\`\`
|
|
78
|
+
Git:
|
|
79
|
+
\`\`\`bash
|
|
80
|
+
git diff --name-only HEAD
|
|
81
|
+
\`\`\`
|
|
82
|
+
|
|
83
|
+
6. Push only changed subtrees.
|
|
84
|
+
CLI:
|
|
85
|
+
\`\`\`bash
|
|
86
|
+
unirepo push --dry-run
|
|
87
|
+
unirepo push
|
|
88
|
+
unirepo push <subtree>
|
|
89
|
+
\`\`\`
|
|
90
|
+
Git:
|
|
91
|
+
\`\`\`bash
|
|
92
|
+
git subtree push --prefix=<subtree> <remote-or-url> <branch>
|
|
93
|
+
\`\`\`
|
|
94
|
+
|
|
95
|
+
## Raw Git Subtree
|
|
96
|
+
|
|
97
|
+
Use these when operating without the CLI:
|
|
98
|
+
|
|
99
|
+
\`\`\`bash
|
|
100
|
+
# Add a subtree
|
|
101
|
+
git subtree add --prefix=libfoo https://github.com/example/libfoo.git main --squash
|
|
102
|
+
|
|
103
|
+
# Pull updates from upstream
|
|
104
|
+
git subtree pull --prefix=libfoo https://github.com/example/libfoo.git main --squash
|
|
105
|
+
|
|
106
|
+
# Push local changes upstream
|
|
107
|
+
git subtree push --prefix=libfoo https://github.com/example/libfoo.git <branch>
|
|
108
|
+
\`\`\`
|
|
109
|
+
|
|
110
|
+
- If subtree directory and remote name match, the remote name often works in place of the full URL.
|
|
111
|
+
- Reuse the same branch name across the monorepo and each upstream subtree repo.
|
|
112
|
+
|
|
113
|
+
## PRs
|
|
114
|
+
|
|
115
|
+
- Open one PR per upstream subtree repo.
|
|
116
|
+
- Use the same branch name in each upstream repo.
|
|
117
|
+
- Target the default branch of the upstream repo unless that repo's workflow says otherwise.
|
|
118
|
+
- Ensure each PR contains only changes from its own subtree.
|
|
119
|
+
`;
|
|
120
|
+
|
|
121
|
+
export const GITIGNORE = `.DS_Store
|
|
122
|
+
|
|
123
|
+
.idea/
|
|
124
|
+
.vscode/
|
|
125
|
+
|
|
126
|
+
*.swp
|
|
127
|
+
*.swo
|
|
128
|
+
`;
|