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.
- package/LICENSE +21 -0
- package/README.md +52 -0
- package/dist/cli.js +535 -0
- 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
|
+
}
|