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/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
+ }
@@ -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
+ `;