monopoly-repo 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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +52 -0
  3. package/dist/cli.js +535 -0
  4. package/package.json +46 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Itay Mendelawy
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,52 @@
1
+ # monopoly-repo
2
+
3
+ > Move code between git repos without losing history.
4
+
5
+ A small CLI that extracts a file or directory from one git repo and stages it
6
+ into another, carrying its full commit history along with it. Built on
7
+ [`git-filter-repo`](https://github.com/newren/git-filter-repo).
8
+
9
+ ## Install
10
+
11
+ ```sh
12
+ npm install -g monopoly-repo
13
+ ```
14
+
15
+ Or run it on demand without installing:
16
+
17
+ ```sh
18
+ npx monopoly-repo move <source> --to <repo> [--as <path>] [--dry-run]
19
+ ```
20
+
21
+ The installed command is `monopoly`.
22
+
23
+ ## Usage
24
+
25
+ ```sh
26
+ monopoly move <source> --to <repo> [--as <path>] [--dry-run]
27
+ ```
28
+
29
+ ### Examples
30
+
31
+ ```sh
32
+ # Move a package from a monorepo to a dedicated repo
33
+ monopoly move packages/auth --to ../auth-service --as auth
34
+
35
+ # Move a dedicated repo back into a monorepo
36
+ monopoly move auth --to ../my-monorepo --as packages/auth
37
+
38
+ # Preview without making changes
39
+ monopoly move src/utils/logger.ts --to ../shared --as logger.ts --dry-run
40
+ ```
41
+
42
+ All changes are staged locally. Nothing is committed or pushed — you review
43
+ `git status` in the target repo and commit when ready.
44
+
45
+ ## Requirements
46
+
47
+ - Node.js >= 18
48
+ - `git` and [`git-filter-repo`](https://github.com/newren/git-filter-repo) on your PATH
49
+
50
+ ## License
51
+
52
+ MIT — see [LICENSE](./LICENSE). Learn more at [monopoly-repo.dev](https://monopoly-repo.dev).
package/dist/cli.js ADDED
@@ -0,0 +1,535 @@
1
+ #!/usr/bin/env node
2
+ // src/args.ts
3
+ import path from "path";
4
+ function parseArgs(argv) {
5
+ if (argv.length === 0 || argv.includes("-h") || argv.includes("--help")) {
6
+ return { kind: "help" };
7
+ }
8
+ if (argv.includes("-v") || argv.includes("--version")) {
9
+ return { kind: "version" };
10
+ }
11
+ const command = argv[0];
12
+ if (command !== "move") {
13
+ throw usageError(`Unknown command: ${command}.`);
14
+ }
15
+ const rest = argv.slice(1);
16
+ let source;
17
+ let to;
18
+ let as;
19
+ let dryRun = false;
20
+ for (let i = 0;i < rest.length; i++) {
21
+ const arg = rest[i];
22
+ if (arg === "--to") {
23
+ to = takeValue("--to", rest, ++i);
24
+ } else if (arg === "--as") {
25
+ as = takeValue("--as", rest, ++i);
26
+ } else if (arg === "--dry-run") {
27
+ dryRun = true;
28
+ } else if (arg.startsWith("-")) {
29
+ throw usageError(`Unknown option: ${arg}.`);
30
+ } else if (!source) {
31
+ source = arg;
32
+ } else {
33
+ throw usageError(`Unexpected argument: ${arg}.`);
34
+ }
35
+ }
36
+ if (!source)
37
+ throw usageError("Missing <source> argument.");
38
+ if (!to)
39
+ throw usageError("Missing --to <repo> option.");
40
+ return {
41
+ kind: "move",
42
+ source,
43
+ to,
44
+ as: as ?? path.basename(source),
45
+ dryRun
46
+ };
47
+ }
48
+ function usageError(msg) {
49
+ return new Error(`${msg} Run monopoly --help for usage.`);
50
+ }
51
+ function takeValue(name, rest, i) {
52
+ const value = rest[i];
53
+ if (!value)
54
+ throw usageError(`${name} requires a value.`);
55
+ return value;
56
+ }
57
+
58
+ // src/validate.ts
59
+ import fs2 from "fs";
60
+ import path4 from "path";
61
+
62
+ // src/git.ts
63
+ import { spawnSync } from "child_process";
64
+ import path2 from "path";
65
+ function run(cmd, args, cwd) {
66
+ const result = spawnSync(cmd, args, {
67
+ cwd,
68
+ encoding: "utf-8",
69
+ maxBuffer: 50 * 1024 * 1024
70
+ });
71
+ return {
72
+ stdout: (result.stdout ?? "").trimEnd(),
73
+ stderr: (result.stderr ?? "").trimEnd(),
74
+ exitCode: result.status ?? 1,
75
+ success: result.status === 0
76
+ };
77
+ }
78
+ function toGitPath(p) {
79
+ return p.split(path2.sep).join("/");
80
+ }
81
+ function git(args, cwd) {
82
+ return run("git", args, cwd);
83
+ }
84
+ function requireSuccess(result, message) {
85
+ if (!result.success) {
86
+ throw new Error(`${message}: ${result.stderr}`);
87
+ }
88
+ }
89
+ function getGitVersion() {
90
+ const result = git(["--version"]);
91
+ if (!result.success)
92
+ throw new Error("git is not installed or not in PATH.");
93
+ return result.stdout.replace(/^git version\s+/, "").trim();
94
+ }
95
+ function parseGitVersion(version) {
96
+ const parts = version.split(".");
97
+ return [parseInt(parts[0] ?? "0", 10), parseInt(parts[1] ?? "0", 10)];
98
+ }
99
+ function isGitRepo(dir) {
100
+ return git(["rev-parse", "--git-dir"], dir).success;
101
+ }
102
+ function hasUncommittedChanges(dir) {
103
+ const status = git(["status", "--porcelain"], dir);
104
+ return status.success && status.stdout.length > 0;
105
+ }
106
+ function getCurrentBranch(dir) {
107
+ const result = git(["symbolic-ref", "--short", "HEAD"], dir);
108
+ if (result.success)
109
+ return result.stdout;
110
+ return git(["rev-parse", "--short", "HEAD"], dir).stdout;
111
+ }
112
+ function getHeadHash(dir) {
113
+ const result = git(["rev-parse", "HEAD"], dir);
114
+ return result.success ? result.stdout : "";
115
+ }
116
+ function countCommitsForPath(filePath, cwd) {
117
+ const result = git(["rev-list", "--count", "--full-history", "HEAD", "--", filePath], cwd);
118
+ return result.success ? parseInt(result.stdout, 10) : 0;
119
+ }
120
+ function countCommits(cwd) {
121
+ const result = git(["rev-list", "--count", "HEAD"], cwd);
122
+ return result.success ? parseInt(result.stdout, 10) : 0;
123
+ }
124
+ function hasAnyCommits(dir) {
125
+ return countCommits(dir) > 0;
126
+ }
127
+ function findRepoRoot(startPath) {
128
+ const result = git(["rev-parse", "--show-toplevel"], startPath);
129
+ return result.success ? result.stdout : null;
130
+ }
131
+
132
+ // src/filter-repo.ts
133
+ import { spawnSync as spawnSync2 } from "child_process";
134
+ import fs from "fs";
135
+ import os from "os";
136
+ import path3 from "path";
137
+ var FILTER_REPO_URL = "https://raw.githubusercontent.com/newren/git-filter-repo/main/git-filter-repo";
138
+ var cachedCmd;
139
+ function filterRepo(args, cwd) {
140
+ const resolved = resolveFilterRepo();
141
+ if (!resolved) {
142
+ return {
143
+ stdout: "",
144
+ stderr: "git-filter-repo not found and could not be auto-installed",
145
+ exitCode: 1,
146
+ success: false
147
+ };
148
+ }
149
+ return run(resolved.command, [...resolved.prefixArgs, ...args], cwd);
150
+ }
151
+ function isFilterRepoInstalled() {
152
+ return resolveFilterRepo() !== null;
153
+ }
154
+ function resolveFilterRepo() {
155
+ if (cachedCmd !== undefined)
156
+ return cachedCmd;
157
+ const existing = findFilterRepoOnDisk();
158
+ if (existing) {
159
+ cachedCmd = { command: existing, prefixArgs: [] };
160
+ return cachedCmd;
161
+ }
162
+ cachedCmd = provisionFilterRepo();
163
+ return cachedCmd;
164
+ }
165
+ function findFilterRepoOnDisk() {
166
+ const envPath = process.env.GIT_FILTER_REPO;
167
+ if (envPath && fs.existsSync(envPath))
168
+ return envPath;
169
+ const check = spawnSync2("git-filter-repo", ["--version"], {
170
+ stdio: "ignore",
171
+ timeout: 5000
172
+ });
173
+ if (check.status === 0)
174
+ return "git-filter-repo";
175
+ const home = os.homedir();
176
+ const candidates = [
177
+ path3.join(home, ".local", "bin", "git-filter-repo"),
178
+ "/usr/local/bin/git-filter-repo",
179
+ "/opt/homebrew/bin/git-filter-repo"
180
+ ];
181
+ const pyLibDir = path3.join(home, "Library", "Python");
182
+ try {
183
+ for (const ver of fs.readdirSync(pyLibDir)) {
184
+ candidates.push(path3.join(pyLibDir, ver, "bin", "git-filter-repo"));
185
+ }
186
+ } catch {}
187
+ return candidates.find((c) => {
188
+ try {
189
+ return fs.existsSync(c);
190
+ } catch {
191
+ return false;
192
+ }
193
+ }) ?? null;
194
+ }
195
+ function provisionFilterRepo() {
196
+ const python = findPython();
197
+ if (!python)
198
+ return null;
199
+ const cacheDir = path3.join(os.homedir(), ".cache", "monopoly");
200
+ const scriptPath = path3.join(cacheDir, "git-filter-repo");
201
+ if (fs.existsSync(scriptPath)) {
202
+ const check2 = spawnSync2(python, [scriptPath, "--version"], {
203
+ stdio: "ignore",
204
+ timeout: 5000
205
+ });
206
+ if (check2.status === 0)
207
+ return { command: python, prefixArgs: [scriptPath] };
208
+ try {
209
+ fs.unlinkSync(scriptPath);
210
+ } catch {}
211
+ }
212
+ process.stderr.write(`git-filter-repo not found, downloading...
213
+ `);
214
+ try {
215
+ fs.mkdirSync(cacheDir, { recursive: true });
216
+ } catch {
217
+ return null;
218
+ }
219
+ if (!downloadFile(FILTER_REPO_URL, scriptPath))
220
+ return null;
221
+ const check = spawnSync2(python, [scriptPath, "--version"], {
222
+ stdio: "ignore",
223
+ timeout: 5000
224
+ });
225
+ if (check.status !== 0) {
226
+ try {
227
+ fs.unlinkSync(scriptPath);
228
+ } catch {}
229
+ return null;
230
+ }
231
+ process.stderr.write(`git-filter-repo ready.
232
+ `);
233
+ return { command: python, prefixArgs: [scriptPath] };
234
+ }
235
+ function downloadFile(url, dest) {
236
+ const curlResult = spawnSync2("curl", ["-fsSL", "-o", dest, url], {
237
+ stdio: "ignore",
238
+ timeout: 30000
239
+ });
240
+ if (curlResult.status === 0)
241
+ return true;
242
+ const wgetResult = spawnSync2("wget", ["-q", "-O", dest, url], {
243
+ stdio: "ignore",
244
+ timeout: 30000
245
+ });
246
+ return wgetResult.status === 0;
247
+ }
248
+ function findPython() {
249
+ const py3 = spawnSync2("python3", ["--version"], {
250
+ stdio: "ignore",
251
+ timeout: 5000
252
+ });
253
+ if (py3.status === 0)
254
+ return "python3";
255
+ const py = spawnSync2("python", ["--version"], {
256
+ encoding: "utf-8",
257
+ timeout: 5000
258
+ });
259
+ if (py.status === 0 && (py.stdout ?? "").includes("Python 3")) {
260
+ return "python";
261
+ }
262
+ return null;
263
+ }
264
+
265
+ // src/validate.ts
266
+ function validate(args) {
267
+ assertGitVersion();
268
+ assertFilterRepoInstalled();
269
+ const resolvedSource = path4.resolve(args.source);
270
+ const sourceStat = safeStat(resolvedSource);
271
+ if (!sourceStat) {
272
+ throw new Error(`Source path does not exist: ${args.source}`);
273
+ }
274
+ const realSource = fs2.realpathSync(resolvedSource);
275
+ const isDirectory = sourceStat.isDirectory();
276
+ const sourceDir = isDirectory ? realSource : path4.dirname(realSource);
277
+ const sourceRepoRoot = findRepoRoot(sourceDir);
278
+ if (!sourceRepoRoot) {
279
+ throw new Error(`Source path is not inside a git repository: ${args.source}`);
280
+ }
281
+ const extractionPath = toGitPath(path4.relative(sourceRepoRoot, realSource));
282
+ if (!extractionPath || extractionPath.startsWith("..")) {
283
+ throw new Error(`Source path is outside the repository root: ${args.source}`);
284
+ }
285
+ const sourceCommitCount = countCommitsForPath(extractionPath, sourceRepoRoot);
286
+ if (sourceCommitCount === 0) {
287
+ throw new Error(`No commits found for ${args.source}. The path exists but has no git history.`);
288
+ }
289
+ const resolvedTarget = path4.resolve(args.to);
290
+ if (!isGitRepo(resolvedTarget)) {
291
+ throw new Error(`Target is not a git repository: ${args.to}`);
292
+ }
293
+ const targetRepoRoot = findRepoRoot(resolvedTarget) ?? resolvedTarget;
294
+ if (hasUncommittedChanges(targetRepoRoot)) {
295
+ throw new Error(`Target repo has uncommitted changes: ${args.to}. Commit or stash them first.`);
296
+ }
297
+ const targetFullPath = path4.join(targetRepoRoot, args.as);
298
+ if (fs2.existsSync(targetFullPath)) {
299
+ throw new Error(`Target path already exists: ${args.to}/${args.as}. Remove it or choose a different --as path.`);
300
+ }
301
+ return {
302
+ sourceRepoRoot,
303
+ extractionPath,
304
+ targetRepoRoot,
305
+ sourceBranch: getCurrentBranch(sourceRepoRoot),
306
+ sourceHead: getHeadHash(sourceRepoRoot),
307
+ commitCount: sourceCommitCount,
308
+ isDirectory
309
+ };
310
+ }
311
+ function assertGitVersion() {
312
+ const version = getGitVersion();
313
+ const [major, minor] = parseGitVersion(version);
314
+ if (major < 2 || major === 2 && minor < 22) {
315
+ throw new Error(`git >= 2.22.0 is required (found ${version}).`);
316
+ }
317
+ }
318
+ function assertFilterRepoInstalled() {
319
+ if (!isFilterRepoInstalled()) {
320
+ throw new Error("git-filter-repo is required and could not be auto-installed (needs Python 3 + network). " + "Install manually: pip install git-filter-repo");
321
+ }
322
+ }
323
+ function safeStat(p) {
324
+ try {
325
+ return fs2.statSync(p);
326
+ } catch {
327
+ return null;
328
+ }
329
+ }
330
+
331
+ // src/move.ts
332
+ import fs3 from "fs";
333
+ import path5 from "path";
334
+ import os2 from "os";
335
+ function executeMove(args, ctx) {
336
+ const tmpDir = fs3.mkdtempSync(path5.join(os2.tmpdir(), "monopoly-"));
337
+ const targetAs = toGitPath(args.as);
338
+ try {
339
+ const extractDir = cloneAndFilter(tmpDir, ctx);
340
+ const commitCount = countCommits(extractDir);
341
+ if (ctx.isDirectory) {
342
+ restructureDirectory(extractDir, targetAs);
343
+ } else {
344
+ restructureFile(extractDir, ctx.extractionPath, targetAs);
345
+ }
346
+ mergeIntoTarget(extractDir, targetAs, ctx, commitCount);
347
+ return {
348
+ sourcePath: args.source,
349
+ targetRepo: args.to,
350
+ targetFullPath: `${args.to}/${targetAs}`,
351
+ commitCount
352
+ };
353
+ } finally {
354
+ fs3.rmSync(tmpDir, { recursive: true, force: true });
355
+ }
356
+ }
357
+ function cloneAndFilter(tmpDir, ctx) {
358
+ requireSuccess(git(["clone", "--single-branch", ctx.sourceRepoRoot, "extract"], tmpDir), "Failed to clone source repo");
359
+ const extractDir = path5.join(tmpDir, "extract");
360
+ requireSuccess(git(["config", "user.email", "monopoly@local"], extractDir), "Failed to configure git identity");
361
+ requireSuccess(git(["config", "user.name", "monopoly"], extractDir), "Failed to configure git identity");
362
+ const filterFlag = ctx.isDirectory ? "--subdirectory-filter" : "--path";
363
+ requireSuccess(filterRepo([filterFlag, ctx.extractionPath, "--force", "--quiet"], extractDir), "git-filter-repo failed");
364
+ return extractDir;
365
+ }
366
+ function restructureDirectory(extractDir, targetPath) {
367
+ fs3.mkdirSync(path5.join(extractDir, targetPath), { recursive: true });
368
+ const topLevel = targetPath.split("/")[0];
369
+ const entries = fs3.readdirSync(extractDir).filter((e) => e !== ".git" && e !== topLevel);
370
+ if (entries.length > 0) {
371
+ requireSuccess(git(["mv", ...entries, targetPath], extractDir), "Failed to restructure files");
372
+ }
373
+ requireSuccess(git(["commit", "-m", "chore(monopoly): restructure for target path"], extractDir), "Failed to commit restructured files");
374
+ }
375
+ function restructureFile(extractDir, originalPath, targetName) {
376
+ if (originalPath === targetName)
377
+ return;
378
+ const targetDir = path5.dirname(targetName);
379
+ if (targetDir !== ".") {
380
+ fs3.mkdirSync(path5.join(extractDir, targetDir), { recursive: true });
381
+ }
382
+ requireSuccess(git(["mv", originalPath, targetName], extractDir), "Failed to restructure file");
383
+ requireSuccess(git(["commit", "-m", "chore(monopoly): restructure for target path"], extractDir), "Failed to commit restructured file");
384
+ }
385
+ function mergeIntoTarget(extractDir, targetAs, ctx, commitCount) {
386
+ if (!hasAnyCommits(ctx.targetRepoRoot)) {
387
+ git(["commit", "--allow-empty", "-m", "chore: initialize repository"], ctx.targetRepoRoot);
388
+ }
389
+ const remoteName = "monopoly-temp";
390
+ requireSuccess(git(["remote", "add", remoteName, extractDir], ctx.targetRepoRoot), "Failed to add temp remote");
391
+ try {
392
+ requireSuccess(git(["fetch", remoteName], ctx.targetRepoRoot), "Failed to fetch from temp clone");
393
+ const extractBranch = getCurrentBranch(extractDir);
394
+ const mergeResult = git([
395
+ "merge",
396
+ `${remoteName}/${extractBranch}`,
397
+ "--allow-unrelated-histories",
398
+ "--no-commit"
399
+ ], ctx.targetRepoRoot);
400
+ if (mergeResult.stderr.includes("CONFLICT")) {
401
+ git(["merge", "--abort"], ctx.targetRepoRoot);
402
+ throw new Error(`Merge conflict: ${mergeResult.stderr}. Resolve manually or choose a different --as path.`);
403
+ }
404
+ } finally {
405
+ git(["remote", "remove", remoteName], ctx.targetRepoRoot);
406
+ }
407
+ writeMergeMessage(targetAs, ctx, commitCount);
408
+ }
409
+ function writeMergeMessage(targetAs, ctx, commitCount) {
410
+ const sourceRepoName = path5.basename(ctx.sourceRepoRoot);
411
+ const msg = `monopoly: move ${sourceRepoName}:${ctx.extractionPath} → ${targetAs}
412
+
413
+ Source repo: ${ctx.sourceRepoRoot}
414
+ Source path: ${ctx.extractionPath}
415
+ Source HEAD: ${ctx.sourceHead}
416
+ Extracted: ${commitCount} commits
417
+ `;
418
+ const gitDirResult = git(["rev-parse", "--git-dir"], ctx.targetRepoRoot);
419
+ const gitDir = gitDirResult.success ? path5.resolve(ctx.targetRepoRoot, gitDirResult.stdout) : path5.join(ctx.targetRepoRoot, ".git");
420
+ fs3.writeFileSync(path5.join(gitDir, "MERGE_MSG"), msg, "utf-8");
421
+ }
422
+
423
+ // src/version.ts
424
+ var VERSION = "0.1.0";
425
+
426
+ // src/output.ts
427
+ var tty = process.stdout.isTTY;
428
+ var style = {
429
+ green: (s) => tty ? `\x1B[32m${s}\x1B[0m` : s,
430
+ red: (s) => tty ? `\x1B[31m${s}\x1B[0m` : s,
431
+ cyan: (s) => tty ? `\x1B[36m${s}\x1B[0m` : s,
432
+ bold: (s) => tty ? `\x1B[1m${s}\x1B[0m` : s
433
+ };
434
+ function printHelp() {
435
+ console.log(`monopoly - Move code between git repos without losing history.
436
+
437
+ Usage:
438
+ monopoly move <source> --to <repo> [--as <path>] [--dry-run]
439
+
440
+ Commands:
441
+ move Extract a file or directory from one repo and stage it
442
+ into another, carrying its full git history.
443
+
444
+ Arguments:
445
+ <source> File or directory to move (e.g. packages/auth)
446
+
447
+ Options:
448
+ --to <repo> Path to locally cloned target repository
449
+ --as <path> Target path inside the repo (default: source basename)
450
+ --dry-run Show what would happen without making changes
451
+ -h, --help Show this help message
452
+ -v, --version Show version
453
+
454
+ Examples:
455
+ monopoly move packages/auth --to ../stable --as auth
456
+ monopoly move ../stable/auth --to . --as packages/auth
457
+ monopoly move src/utils/logger.ts --to ../shared --as logger.ts
458
+
459
+ All changes are staged locally. Nothing is committed or pushed.`);
460
+ }
461
+ function printVersion() {
462
+ console.log(`monopoly ${VERSION}`);
463
+ }
464
+ function printSuccess(result) {
465
+ console.log(`${style.green("✓")} Move staged in ${result.targetRepo}
466
+
467
+ Source: ${result.sourcePath} (${result.commitCount} commits extracted)
468
+ Target: ${result.targetFullPath}
469
+ Seam: staged (not yet committed)
470
+
471
+ Review the staged changes:
472
+ cd ${result.targetRepo} && git status
473
+
474
+ When ready:
475
+ git commit
476
+ git push
477
+
478
+ To remove the source from this repo:
479
+ git rm -r ${result.sourcePath}
480
+ git commit -m "chore: remove ${result.sourcePath} (graduated to ${result.targetRepo})"
481
+
482
+ Don't forget to replace direct usage of ${result.sourcePath} with
483
+ an external dependency (package, subtree, runtime import, etc).`);
484
+ }
485
+ function printDryRun(args, ctx) {
486
+ console.log(`${style.cyan("[dry-run]")} Would perform the following:
487
+
488
+ 1. Clone source repo to temp directory
489
+ 2. Run git-filter-repo to extract ${args.source}
490
+ 3. Restructure extracted files for target path: ${args.as}
491
+ 4. Fetch into ${args.to} and merge with --allow-unrelated-histories
492
+ 5. Write seam metadata to .git/MERGE_MSG
493
+ 6. Stage all changes (no commit)
494
+
495
+ Source repo: ${ctx.sourceRepoRoot}
496
+ Source path: ${ctx.extractionPath}
497
+ Target repo: ${ctx.targetRepoRoot}
498
+ Target path: ${args.as}
499
+ Source branch: ${ctx.sourceBranch}
500
+ Source HEAD: ${ctx.sourceHead}
501
+ Commits: ~${ctx.commitCount} (estimate from source history)
502
+ Is directory: ${ctx.isDirectory}
503
+
504
+ ${style.bold("No changes were made.")}`);
505
+ }
506
+ function printError(msg) {
507
+ console.error(`${style.red("Error:")} ${msg}`);
508
+ }
509
+
510
+ // src/main.ts
511
+ function main() {
512
+ try {
513
+ const args = parseArgs(process.argv.slice(2));
514
+ if (args.kind === "help") {
515
+ printHelp();
516
+ return;
517
+ }
518
+ if (args.kind === "version") {
519
+ printVersion();
520
+ return;
521
+ }
522
+ const ctx = validate(args);
523
+ if (args.dryRun) {
524
+ printDryRun(args, ctx);
525
+ return;
526
+ }
527
+ const result = executeMove(args, ctx);
528
+ printSuccess(result);
529
+ } catch (err) {
530
+ const msg = err instanceof Error ? err.message : String(err);
531
+ printError(msg);
532
+ process.exit(1);
533
+ }
534
+ }
535
+ main();
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "monopoly-repo",
3
+ "version": "0.1.0",
4
+ "description": "Move code between git repos without losing history",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "homepage": "https://monopoly-repo.dev",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/itaymendel/monopoly-repo.git",
11
+ "directory": "cli"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/itaymendel/monopoly-repo/issues"
15
+ },
16
+ "keywords": [
17
+ "git",
18
+ "monorepo",
19
+ "polyrepo",
20
+ "git-filter-repo",
21
+ "history",
22
+ "cli"
23
+ ],
24
+ "bin": {
25
+ "monopoly": "dist/cli.js"
26
+ },
27
+ "engines": {
28
+ "node": ">=18"
29
+ },
30
+ "files": [
31
+ "dist/cli.js"
32
+ ],
33
+ "publishConfig": {
34
+ "access": "public"
35
+ },
36
+ "scripts": {
37
+ "dev": "bun run src/main.ts",
38
+ "build": "bun build src/main.ts --compile --outfile dist/monopoly",
39
+ "build:npm": "bun scripts/build-npm.ts",
40
+ "prepublishOnly": "bun scripts/build-npm.ts",
41
+ "test": "bun test"
42
+ },
43
+ "devDependencies": {
44
+ "bun-types": "^1.3.13"
45
+ }
46
+ }