happy-stacks 0.4.0 → 0.5.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 +64 -33
- package/bin/happys.mjs +44 -1
- package/docs/codex-mcp-resume.md +130 -0
- package/docs/commit-audits/happy/leeroy-wip.commit-analysis.md +17640 -0
- package/docs/commit-audits/happy/leeroy-wip.commit-export.fuller-stat.md +3845 -0
- package/docs/commit-audits/happy/leeroy-wip.commit-inventory.md +102 -0
- package/docs/commit-audits/happy/leeroy-wip.commit-manual-review.md +1452 -0
- package/docs/commit-audits/happy/leeroy-wip.manual-review-queue.md +116 -0
- package/docs/happy-development.md +1 -2
- package/docs/monorepo-migration.md +286 -0
- package/docs/server-flavors.md +19 -3
- package/docs/stacks.md +35 -0
- package/package.json +1 -1
- package/scripts/auth.mjs +21 -3
- package/scripts/build.mjs +1 -1
- package/scripts/dev.mjs +20 -7
- package/scripts/doctor.mjs +0 -4
- package/scripts/edison.mjs +2 -2
- package/scripts/env.mjs +150 -0
- package/scripts/env_cmd.test.mjs +128 -0
- package/scripts/init.mjs +5 -2
- package/scripts/install.mjs +99 -57
- package/scripts/migrate.mjs +3 -12
- package/scripts/monorepo.mjs +1096 -0
- package/scripts/monorepo_port.test.mjs +1470 -0
- package/scripts/review.mjs +715 -24
- package/scripts/review_pr.mjs +5 -20
- package/scripts/run.mjs +21 -15
- package/scripts/setup.mjs +147 -25
- package/scripts/setup_pr.mjs +19 -28
- package/scripts/stack.mjs +493 -157
- package/scripts/stack_archive_cmd.test.mjs +91 -0
- package/scripts/stack_editor_workspace_monorepo_root.test.mjs +65 -0
- package/scripts/stack_env_cmd.test.mjs +87 -0
- package/scripts/stack_happy_cmd.test.mjs +126 -0
- package/scripts/stack_interactive_monorepo_group.test.mjs +71 -0
- package/scripts/stack_monorepo_defaults.test.mjs +62 -0
- package/scripts/stack_monorepo_server_light_from_happy_spec.test.mjs +66 -0
- package/scripts/stack_server_flavors_defaults.test.mjs +55 -0
- package/scripts/stack_shorthand_cmd.test.mjs +55 -0
- package/scripts/stack_wt_list.test.mjs +128 -0
- package/scripts/tui.mjs +88 -2
- package/scripts/utils/cli/cli_registry.mjs +20 -5
- package/scripts/utils/cli/cwd_scope.mjs +56 -2
- package/scripts/utils/cli/cwd_scope.test.mjs +40 -7
- package/scripts/utils/cli/prereqs.mjs +8 -5
- package/scripts/utils/cli/prereqs.test.mjs +34 -0
- package/scripts/utils/cli/wizard.mjs +17 -9
- package/scripts/utils/cli/wizard_prompt_worktree_source_lazy.test.mjs +60 -0
- package/scripts/utils/dev/daemon.mjs +14 -1
- package/scripts/utils/dev/expo_dev.mjs +188 -4
- package/scripts/utils/dev/server.mjs +21 -17
- package/scripts/utils/edison/git_roots.mjs +29 -0
- package/scripts/utils/edison/git_roots.test.mjs +36 -0
- package/scripts/utils/env/env.mjs +7 -3
- package/scripts/utils/env/env_file.mjs +4 -2
- package/scripts/utils/env/env_file.test.mjs +44 -0
- package/scripts/utils/git/worktrees.mjs +63 -12
- package/scripts/utils/git/worktrees_monorepo.test.mjs +54 -0
- package/scripts/utils/net/tcp_forward.mjs +162 -0
- package/scripts/utils/paths/paths.mjs +118 -3
- package/scripts/utils/paths/paths_monorepo.test.mjs +58 -0
- package/scripts/utils/paths/paths_server_flavors.test.mjs +45 -0
- package/scripts/utils/proc/commands.mjs +2 -3
- package/scripts/utils/proc/pm.mjs +113 -16
- package/scripts/utils/proc/pm_spawn.test.mjs +76 -0
- package/scripts/utils/proc/pm_stack_cache_env.test.mjs +142 -0
- package/scripts/utils/proc/proc.mjs +68 -10
- package/scripts/utils/proc/proc.test.mjs +77 -0
- package/scripts/utils/review/chunks.mjs +55 -0
- package/scripts/utils/review/chunks.test.mjs +51 -0
- package/scripts/utils/review/findings.mjs +165 -0
- package/scripts/utils/review/findings.test.mjs +85 -0
- package/scripts/utils/review/head_slice.mjs +153 -0
- package/scripts/utils/review/head_slice.test.mjs +91 -0
- package/scripts/utils/review/instructions/deep.md +20 -0
- package/scripts/utils/review/runners/coderabbit.mjs +56 -14
- package/scripts/utils/review/runners/coderabbit.test.mjs +59 -0
- package/scripts/utils/review/runners/codex.mjs +32 -22
- package/scripts/utils/review/runners/codex.test.mjs +35 -0
- package/scripts/utils/review/slices.mjs +140 -0
- package/scripts/utils/review/slices.test.mjs +32 -0
- package/scripts/utils/server/flavor_scripts.mjs +98 -0
- package/scripts/utils/server/flavor_scripts.test.mjs +146 -0
- package/scripts/utils/server/prisma_import.mjs +37 -0
- package/scripts/utils/server/prisma_import.test.mjs +70 -0
- package/scripts/utils/server/ui_env.mjs +14 -0
- package/scripts/utils/server/ui_env.test.mjs +46 -0
- package/scripts/utils/server/validate.mjs +53 -16
- package/scripts/utils/server/validate.test.mjs +89 -0
- package/scripts/utils/stack/editor_workspace.mjs +4 -4
- package/scripts/utils/stack/interactive_stack_config.mjs +185 -0
- package/scripts/utils/stack/startup.mjs +113 -13
- package/scripts/utils/stack/startup_server_light_dirs.test.mjs +64 -0
- package/scripts/utils/stack/startup_server_light_generate.test.mjs +70 -0
- package/scripts/utils/stack/startup_server_light_legacy.test.mjs +88 -0
- package/scripts/utils/tailscale/ip.mjs +116 -0
- package/scripts/utils/ui/ansi.mjs +39 -0
- package/scripts/where.mjs +2 -2
- package/scripts/worktrees.mjs +627 -137
- package/scripts/worktrees_archive_cmd.test.mjs +245 -0
- package/scripts/worktrees_cursor_monorepo_root.test.mjs +63 -0
- package/scripts/worktrees_list_specs_no_recurse.test.mjs +33 -0
- package/scripts/worktrees_monorepo_use_group.test.mjs +67 -0
|
@@ -0,0 +1,1096 @@
|
|
|
1
|
+
import './utils/env/env.mjs';
|
|
2
|
+
import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
5
|
+
|
|
6
|
+
import { parseArgs } from './utils/cli/args.mjs';
|
|
7
|
+
import { printResult, wantsHelp, wantsJson } from './utils/cli/cli.mjs';
|
|
8
|
+
import { pathExists } from './utils/fs/fs.mjs';
|
|
9
|
+
import { run, runCapture } from './utils/proc/proc.mjs';
|
|
10
|
+
import { isHappyMonorepoRoot } from './utils/paths/paths.mjs';
|
|
11
|
+
import { isTty, prompt, promptSelect, withRl } from './utils/cli/wizard.mjs';
|
|
12
|
+
import { bold, cyan, dim, green, red, yellow } from './utils/ui/ansi.mjs';
|
|
13
|
+
|
|
14
|
+
function usage() {
|
|
15
|
+
return [
|
|
16
|
+
'[monorepo] usage:',
|
|
17
|
+
' happys monorepo port --target=/abs/path/to/monorepo [--branch=port/<name>] [--base=<ref>] [--onto-current] [--dry-run] [--3way] [--skip-applied] [--continue-on-failure] [--json]',
|
|
18
|
+
' happys monorepo port guide [--target=/abs/path/to/monorepo] [--json]',
|
|
19
|
+
' happys monorepo port status [--target=/abs/path/to/monorepo] [--json]',
|
|
20
|
+
' happys monorepo port continue [--target=/abs/path/to/monorepo] [--json]',
|
|
21
|
+
' [--from-happy=/abs/path/to/old-happy --from-happy-base=<ref> --from-happy-ref=<ref>]',
|
|
22
|
+
' [--from-happy-cli=/abs/path/to/old-happy-cli --from-happy-cli-base=<ref> --from-happy-cli-ref=<ref>]',
|
|
23
|
+
' [--from-happy-server=/abs/path/to/old-happy-server --from-happy-server-base=<ref> --from-happy-server-ref=<ref>]',
|
|
24
|
+
'',
|
|
25
|
+
'what it does:',
|
|
26
|
+
'- Best-effort ports commits from split repos into the slopus/happy monorepo layout by applying patches into:',
|
|
27
|
+
' - old happy (UI) -> expo-app/',
|
|
28
|
+
' - old happy-cli (CLI) -> cli/',
|
|
29
|
+
' - old happy-server -> server/',
|
|
30
|
+
'',
|
|
31
|
+
'notes:',
|
|
32
|
+
'- This preserves commit messages/authors (via `git format-patch` + `git am`).',
|
|
33
|
+
'- The target monorepo should already contain the "base" version of each subtree (typically a clean checkout of upstream/main).',
|
|
34
|
+
'- Already-applied patches are auto-skipped when detected (exact-match via reverse apply-check).',
|
|
35
|
+
'- Identical \"new file\" patches are auto-skipped when the target already contains the same file content.',
|
|
36
|
+
'- Conflicts may require manual resolution. If `git am` stops, fix conflicts then run:',
|
|
37
|
+
' git am --continue',
|
|
38
|
+
' or abort with:',
|
|
39
|
+
' git am --abort',
|
|
40
|
+
].join('\n');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function git(cwd, args, options = {}) {
|
|
44
|
+
return await runCapture('git', args, { cwd, ...options });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function gitOk(cwd, args) {
|
|
48
|
+
try {
|
|
49
|
+
await git(cwd, args);
|
|
50
|
+
return true;
|
|
51
|
+
} catch {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function resolveGitRoot(dir) {
|
|
57
|
+
const d = resolve(String(dir ?? '').trim());
|
|
58
|
+
if (!d) return '';
|
|
59
|
+
try {
|
|
60
|
+
return (await git(d, ['rev-parse', '--show-toplevel'])).trim();
|
|
61
|
+
} catch {
|
|
62
|
+
return '';
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function ensureCleanGitWorktree(repoRoot) {
|
|
67
|
+
const dirty = (await git(repoRoot, ['status', '--porcelain'])).trim();
|
|
68
|
+
if (dirty) {
|
|
69
|
+
throw new Error(`[monorepo] target repo is not clean: ${repoRoot}\n[monorepo] fix: commit/stash changes and re-run`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function ensureNoGitAmInProgress(repoRoot) {
|
|
74
|
+
try {
|
|
75
|
+
const rel = (await git(repoRoot, ['rev-parse', '--git-path', 'rebase-apply'])).trim();
|
|
76
|
+
if (!rel) return;
|
|
77
|
+
const p = rel.startsWith('/') ? rel : join(repoRoot, rel);
|
|
78
|
+
if (!(await pathExists(p))) return;
|
|
79
|
+
if ((await pathExists(join(p, 'applying'))) || (await pathExists(join(p, 'patch')))) {
|
|
80
|
+
throw new Error(
|
|
81
|
+
[
|
|
82
|
+
'[monorepo] a git am operation is already in progress in the target repo.',
|
|
83
|
+
'[monorepo] fix: resolve it first, then re-run.',
|
|
84
|
+
`- continue: git -C ${repoRoot} am --continue`,
|
|
85
|
+
`- abort: git -C ${repoRoot} am --abort`,
|
|
86
|
+
].join('\n')
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
} catch (err) {
|
|
90
|
+
// If git isn't happy with --git-path for some reason, fail open; the later git am will fail anyway.
|
|
91
|
+
if (String(err?.message ?? '').includes('a git am operation is already in progress')) throw err;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function isGitAmInProgress(repoRoot) {
|
|
96
|
+
try {
|
|
97
|
+
const rel = (await git(repoRoot, ['rev-parse', '--git-path', 'rebase-apply'])).trim();
|
|
98
|
+
if (!rel) return false;
|
|
99
|
+
const p = rel.startsWith('/') ? rel : join(repoRoot, rel);
|
|
100
|
+
if (!(await pathExists(p))) return false;
|
|
101
|
+
if ((await pathExists(join(p, 'applying'))) || (await pathExists(join(p, 'patch')))) return true;
|
|
102
|
+
return false;
|
|
103
|
+
} catch {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function ensureBranch(repoRoot, branch) {
|
|
109
|
+
const b = String(branch ?? '').trim();
|
|
110
|
+
if (!b) return;
|
|
111
|
+
const exists = await gitOk(repoRoot, ['show-ref', '--verify', '--quiet', `refs/heads/${b}`]);
|
|
112
|
+
if (exists) {
|
|
113
|
+
throw new Error(`[monorepo] target branch already exists: ${b}\n[monorepo] fix: pick a new --branch name`);
|
|
114
|
+
}
|
|
115
|
+
await git(repoRoot, ['checkout', '-b', b]);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function resolveDefaultBaseRef(sourceRepoRoot) {
|
|
119
|
+
const candidates = ['upstream/main', 'origin/main', 'main', 'master'];
|
|
120
|
+
for (const c of candidates) {
|
|
121
|
+
if (await gitOk(sourceRepoRoot, ['rev-parse', '--verify', '--quiet', c])) {
|
|
122
|
+
return c;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return '';
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function resolveDefaultTargetBaseRef(targetRepoRoot) {
|
|
129
|
+
try {
|
|
130
|
+
const sym = (await git(targetRepoRoot, ['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'])).trim();
|
|
131
|
+
const m = /^refs\/remotes\/origin\/(.+)$/.exec(sym);
|
|
132
|
+
if (m?.[1]) {
|
|
133
|
+
const ref = `origin/${m[1]}`;
|
|
134
|
+
if (await gitOk(targetRepoRoot, ['rev-parse', '--verify', '--quiet', ref])) {
|
|
135
|
+
return ref;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
} catch {
|
|
139
|
+
// ignore
|
|
140
|
+
}
|
|
141
|
+
return await resolveDefaultBaseRef(targetRepoRoot);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function resolveTargetRepoRootFromArgs({ kv }) {
|
|
145
|
+
const target = (kv.get('--target') ?? '').trim();
|
|
146
|
+
const targetHint = target || process.cwd();
|
|
147
|
+
const repoRoot = await resolveGitRoot(targetHint);
|
|
148
|
+
if (!repoRoot) {
|
|
149
|
+
throw new Error(`[monorepo] target is not a git repo: ${targetHint}`);
|
|
150
|
+
}
|
|
151
|
+
if (!isHappyMonorepoRoot(repoRoot)) {
|
|
152
|
+
throw new Error(`[monorepo] target does not look like a slopus/happy monorepo root (missing expo-app/cli/server): ${repoRoot}`);
|
|
153
|
+
}
|
|
154
|
+
return repoRoot;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function resolveGitPath(repoRoot, relPath) {
|
|
158
|
+
const rel = (await git(repoRoot, ['rev-parse', '--git-path', relPath])).trim();
|
|
159
|
+
if (!rel) return '';
|
|
160
|
+
return rel.startsWith('/') ? rel : join(repoRoot, rel);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function section(title) {
|
|
164
|
+
// eslint-disable-next-line no-console
|
|
165
|
+
console.log('');
|
|
166
|
+
// eslint-disable-next-line no-console
|
|
167
|
+
console.log(bold(title));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function noteLine(s) {
|
|
171
|
+
// eslint-disable-next-line no-console
|
|
172
|
+
console.log(dim(s));
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function resolvePortPlanPath(targetRepoRoot) {
|
|
176
|
+
return await resolveGitPath(targetRepoRoot, 'happy-stacks/monorepo-port-plan.json');
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function writePortPlan(targetRepoRoot, plan) {
|
|
180
|
+
const p = await resolvePortPlanPath(targetRepoRoot);
|
|
181
|
+
if (!p) return '';
|
|
182
|
+
await mkdir(dirname(p), { recursive: true });
|
|
183
|
+
await writeFile(p, JSON.stringify(plan ?? null, null, 2) + '\n', 'utf-8');
|
|
184
|
+
return p;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function readPortPlan(targetRepoRoot) {
|
|
188
|
+
const p = await resolvePortPlanPath(targetRepoRoot);
|
|
189
|
+
if (!p) return { path: '', plan: null };
|
|
190
|
+
if (!(await pathExists(p))) return { path: p, plan: null };
|
|
191
|
+
try {
|
|
192
|
+
const raw = await readFile(p, 'utf-8');
|
|
193
|
+
return { path: p, plan: JSON.parse(raw) };
|
|
194
|
+
} catch {
|
|
195
|
+
return { path: p, plan: null };
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async function deletePortPlan(targetRepoRoot) {
|
|
200
|
+
const p = await resolvePortPlanPath(targetRepoRoot);
|
|
201
|
+
if (!p) return;
|
|
202
|
+
await rm(p, { force: true });
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function listConflictedFiles(repoRoot) {
|
|
206
|
+
const out = (await git(repoRoot, ['status', '--porcelain'])).trim();
|
|
207
|
+
if (!out) return [];
|
|
208
|
+
const files = [];
|
|
209
|
+
for (const line of out.split(/\r?\n/)) {
|
|
210
|
+
// Porcelain v1: XY <path>
|
|
211
|
+
// Unmerged states include: UU, AA, DD, AU, UA, DU, UD
|
|
212
|
+
const xy = line.slice(0, 2);
|
|
213
|
+
const isUnmerged = xy.includes('U') || xy === 'AA' || xy === 'DD';
|
|
214
|
+
if (!isUnmerged) continue;
|
|
215
|
+
const path = line.slice(3).trim();
|
|
216
|
+
if (path) files.push(path);
|
|
217
|
+
}
|
|
218
|
+
return Array.from(new Set(files)).sort();
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async function formatPatchesToDir({ sourceRepoRoot, base, head, outDir }) {
|
|
222
|
+
await run('git', ['format-patch', '--quiet', '--output-directory', outDir, `${base}..${head}`], { cwd: sourceRepoRoot });
|
|
223
|
+
const entries = await readdir(outDir, { withFileTypes: true });
|
|
224
|
+
const patches = entries
|
|
225
|
+
.filter((e) => e.isFile() && e.name.endsWith('.patch'))
|
|
226
|
+
.map((e) => join(outDir, e.name))
|
|
227
|
+
.sort();
|
|
228
|
+
return patches;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function parsePatchMeta(patchText) {
|
|
232
|
+
const lines = patchText.split(/\r?\n/);
|
|
233
|
+
let fromSha = '';
|
|
234
|
+
let subject = '';
|
|
235
|
+
for (const line of lines) {
|
|
236
|
+
if (!fromSha && line.startsWith('From ')) {
|
|
237
|
+
const parts = line.trim().split(/\s+/);
|
|
238
|
+
if (parts.length >= 2) fromSha = parts[1];
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
if (!subject && line.startsWith('Subject:')) {
|
|
242
|
+
subject = line.slice('Subject:'.length).trim();
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
if (fromSha && subject) break;
|
|
246
|
+
}
|
|
247
|
+
return { fromSha, subject };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function parseApplyErrorPaths(errText) {
|
|
251
|
+
const text = String(errText ?? '').trim();
|
|
252
|
+
if (!text) return { kind: 'unknown', paths: [] };
|
|
253
|
+
const paths = new Set();
|
|
254
|
+
|
|
255
|
+
for (const m of text.matchAll(/error:\s+(\S+):\s+already exists in working directory/g)) {
|
|
256
|
+
if (m?.[1]) paths.add(m[1]);
|
|
257
|
+
}
|
|
258
|
+
for (const m of text.matchAll(/error:\s+patch failed:\s+(\S+):\d+/g)) {
|
|
259
|
+
if (m?.[1]) paths.add(m[1]);
|
|
260
|
+
}
|
|
261
|
+
for (const m of text.matchAll(/error:\s+(\S+):\s+does not exist in index/g)) {
|
|
262
|
+
if (m?.[1]) paths.add(m[1]);
|
|
263
|
+
}
|
|
264
|
+
for (const m of text.matchAll(/error:\s+(\S+):\s+No such file or directory/g)) {
|
|
265
|
+
if (m?.[1]) paths.add(m[1]);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const kind = text.includes('already exists in working directory')
|
|
269
|
+
? 'already_exists'
|
|
270
|
+
: text.includes('patch does not apply') || text.includes('patch failed:')
|
|
271
|
+
? 'patch_failed'
|
|
272
|
+
: text.includes('does not exist in index') || text.includes('No such file or directory')
|
|
273
|
+
? 'missing_path'
|
|
274
|
+
: 'unknown';
|
|
275
|
+
|
|
276
|
+
return { kind, paths: Array.from(paths) };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function extractUnifiedDiffs(patchText) {
|
|
280
|
+
const lines = patchText.split(/\r?\n/);
|
|
281
|
+
const diffs = [];
|
|
282
|
+
let i = 0;
|
|
283
|
+
while (i < lines.length) {
|
|
284
|
+
const line = lines[i];
|
|
285
|
+
if (!line.startsWith('diff --git ')) {
|
|
286
|
+
i += 1;
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const m = /^diff --git a\/(.+?) b\/(.+?)$/.exec(line.trim());
|
|
291
|
+
const bPath = m?.[2] ?? '';
|
|
292
|
+
const diff = {
|
|
293
|
+
bPath,
|
|
294
|
+
plusPath: '',
|
|
295
|
+
isNewFile: false,
|
|
296
|
+
isDeletedFile: false,
|
|
297
|
+
isBinary: false,
|
|
298
|
+
noTrailingNewline: false,
|
|
299
|
+
addedLines: [],
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
i += 1;
|
|
303
|
+
let inHunk = false;
|
|
304
|
+
while (i < lines.length && !lines[i].startsWith('diff --git ')) {
|
|
305
|
+
const l = lines[i];
|
|
306
|
+
if (l.startsWith('new file mode')) diff.isNewFile = true;
|
|
307
|
+
if (l.startsWith('deleted file mode')) diff.isDeletedFile = true;
|
|
308
|
+
if (l.startsWith('GIT binary patch')) diff.isBinary = true;
|
|
309
|
+
if (l.startsWith('--- /dev/null')) diff.isNewFile = true;
|
|
310
|
+
if (l.startsWith('+++ /dev/null')) diff.isDeletedFile = true;
|
|
311
|
+
if (l.startsWith('+++ b/')) diff.plusPath = l.slice('+++ b/'.length).trim();
|
|
312
|
+
if (l.startsWith('@@ ')) inHunk = true;
|
|
313
|
+
if (inHunk) {
|
|
314
|
+
if (l === '\') {
|
|
315
|
+
diff.noTrailingNewline = true;
|
|
316
|
+
} else if (l.startsWith('+') && !l.startsWith('+++ ')) {
|
|
317
|
+
diff.addedLines.push(l.slice(1));
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
i += 1;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
diffs.push(diff);
|
|
324
|
+
}
|
|
325
|
+
return diffs;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
async function checkPureNewFilesAlreadyExistIdentically({ targetRepoRoot, directory, patchText }) {
|
|
329
|
+
const diffs = extractUnifiedDiffs(patchText);
|
|
330
|
+
if (!diffs.length) return { ok: false, paths: [] };
|
|
331
|
+
|
|
332
|
+
// Only safe to auto-skip if this patch contains *only* new files.
|
|
333
|
+
if (diffs.some((d) => !d.isNewFile || d.isDeletedFile || d.isBinary)) {
|
|
334
|
+
return { ok: false, paths: [] };
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const prefix = directory ? `${directory}/` : '';
|
|
338
|
+
const paths = [];
|
|
339
|
+
for (const d of diffs) {
|
|
340
|
+
const rel = d.plusPath || d.bPath;
|
|
341
|
+
if (!rel) return { ok: false, paths: [] };
|
|
342
|
+
|
|
343
|
+
let expected = '';
|
|
344
|
+
if (d.addedLines.length > 0) {
|
|
345
|
+
expected = d.addedLines.join('\n') + '\n';
|
|
346
|
+
if (d.noTrailingNewline && expected.endsWith('\n')) {
|
|
347
|
+
expected = expected.slice(0, -1);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const full = join(targetRepoRoot, `${prefix}${rel}`);
|
|
352
|
+
let actual = '';
|
|
353
|
+
try {
|
|
354
|
+
// eslint-disable-next-line no-await-in-loop
|
|
355
|
+
actual = await readFile(full, 'utf-8');
|
|
356
|
+
} catch {
|
|
357
|
+
return { ok: false, paths: [] };
|
|
358
|
+
}
|
|
359
|
+
if (actual !== expected) {
|
|
360
|
+
return { ok: false, paths: [] };
|
|
361
|
+
}
|
|
362
|
+
paths.push(`${prefix}${rel}`);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
return { ok: true, paths };
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
async function applyPatches({ targetRepoRoot, directory, patches, threeWay, skipApplied, continueOnFailure, quietGit }) {
|
|
369
|
+
if (!patches.length) {
|
|
370
|
+
return { applied: [], skippedAlreadyApplied: [], skippedAlreadyExistsIdentical: [], failed: [] };
|
|
371
|
+
}
|
|
372
|
+
const dirArgs = directory ? ['--directory', `${directory}/`] : [];
|
|
373
|
+
const applied = [];
|
|
374
|
+
const skippedAlreadyApplied = [];
|
|
375
|
+
const skippedAlreadyExistsIdentical = [];
|
|
376
|
+
const failed = [];
|
|
377
|
+
|
|
378
|
+
for (const patch of patches) {
|
|
379
|
+
const patchFile = basename(patch);
|
|
380
|
+
// eslint-disable-next-line no-await-in-loop
|
|
381
|
+
const patchText = await readFile(patch, 'utf-8');
|
|
382
|
+
const { fromSha, subject } = parsePatchMeta(patchText);
|
|
383
|
+
const entry = { patch: patchFile, fromSha, subject };
|
|
384
|
+
|
|
385
|
+
// Preflight check (fast-ish): is this patch clearly already present or a no-op?
|
|
386
|
+
let applyCheckErr = '';
|
|
387
|
+
let appliesCleanly = false;
|
|
388
|
+
try {
|
|
389
|
+
// eslint-disable-next-line no-await-in-loop
|
|
390
|
+
await runCapture('git', ['apply', '--check', ...dirArgs, patch], { cwd: targetRepoRoot });
|
|
391
|
+
appliesCleanly = true;
|
|
392
|
+
} catch (e) {
|
|
393
|
+
appliesCleanly = false;
|
|
394
|
+
applyCheckErr = String(e?.err ?? e?.message ?? e ?? '').trim();
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
if (!appliesCleanly) {
|
|
398
|
+
// Auto-skip identical "new file" patches when the target already contains the same content.
|
|
399
|
+
// This commonly happens when a commit was already folded into the monorepo history during migration.
|
|
400
|
+
// eslint-disable-next-line no-await-in-loop
|
|
401
|
+
const identical = await checkPureNewFilesAlreadyExistIdentically({ targetRepoRoot, directory, patchText });
|
|
402
|
+
if (identical.ok) {
|
|
403
|
+
skippedAlreadyExistsIdentical.push({ ...entry, paths: identical.paths });
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// If the reverse patch applies, the change is already present.
|
|
408
|
+
//
|
|
409
|
+
// This is safe (it requires an exact match of the patch content) and avoids stopping early
|
|
410
|
+
// when the monorepo already includes some split-repo commits.
|
|
411
|
+
//
|
|
412
|
+
// `--skip-applied` is kept as a compatibility flag (and a hint to users), but the behavior is effectively always-on.
|
|
413
|
+
let reverseApplies = false;
|
|
414
|
+
try {
|
|
415
|
+
// eslint-disable-next-line no-await-in-loop
|
|
416
|
+
await runCapture('git', ['apply', '-R', '--check', ...dirArgs, patch], { cwd: targetRepoRoot });
|
|
417
|
+
reverseApplies = true;
|
|
418
|
+
} catch {
|
|
419
|
+
reverseApplies = false;
|
|
420
|
+
}
|
|
421
|
+
if (reverseApplies) {
|
|
422
|
+
skippedAlreadyApplied.push(entry);
|
|
423
|
+
continue;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// Apply with full mailinfo/commit metadata. This may succeed even when `git apply --check` fails (e.g. with --3way).
|
|
428
|
+
try {
|
|
429
|
+
const tryAm = async ({ use3way }) => {
|
|
430
|
+
const args = ['am', '--quiet', ...(use3way ? ['--3way'] : []), ...dirArgs, patch];
|
|
431
|
+
if (quietGit) {
|
|
432
|
+
// eslint-disable-next-line no-await-in-loop
|
|
433
|
+
await runCapture('git', args, { cwd: targetRepoRoot });
|
|
434
|
+
} else {
|
|
435
|
+
// eslint-disable-next-line no-await-in-loop
|
|
436
|
+
await run('git', args, { cwd: targetRepoRoot });
|
|
437
|
+
}
|
|
438
|
+
};
|
|
439
|
+
|
|
440
|
+
try {
|
|
441
|
+
// eslint-disable-next-line no-await-in-loop
|
|
442
|
+
await tryAm({ use3way: threeWay });
|
|
443
|
+
} catch (amErr) {
|
|
444
|
+
const amText = String(amErr?.err ?? amErr?.message ?? amErr ?? '').trim();
|
|
445
|
+
const ancestorFail =
|
|
446
|
+
threeWay &&
|
|
447
|
+
(amText.includes('could not build fake ancestor') || amText.includes('sha1 information is lacking or useless'));
|
|
448
|
+
if (!ancestorFail) {
|
|
449
|
+
throw amErr;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// `git am --3way` requires the blob(s) referenced by the patch to exist in the target repo's object database.
|
|
453
|
+
// When porting into a minimal or mismatched target, those blobs may not exist, causing a hard failure.
|
|
454
|
+
// Fall back to non-3way so users can resolve the patch manually.
|
|
455
|
+
// eslint-disable-next-line no-await-in-loop
|
|
456
|
+
await run('git', ['am', '--abort'], { cwd: targetRepoRoot, stdio: 'ignore' }).catch(() => {});
|
|
457
|
+
// eslint-disable-next-line no-await-in-loop
|
|
458
|
+
await tryAm({ use3way: false });
|
|
459
|
+
}
|
|
460
|
+
applied.push(entry);
|
|
461
|
+
} catch (e) {
|
|
462
|
+
const err = String(e?.err ?? e?.message ?? e ?? '').trim();
|
|
463
|
+
const applyMeta = parseApplyErrorPaths(applyCheckErr || '');
|
|
464
|
+
const amMeta = parseApplyErrorPaths(err || '');
|
|
465
|
+
failed.push({
|
|
466
|
+
...entry,
|
|
467
|
+
applyCheckErr,
|
|
468
|
+
err,
|
|
469
|
+
kind: applyMeta.kind === 'unknown' ? amMeta.kind : applyMeta.kind,
|
|
470
|
+
paths: Array.from(new Set([...(applyMeta.paths ?? []), ...(amMeta.paths ?? [])])),
|
|
471
|
+
});
|
|
472
|
+
if (!continueOnFailure) {
|
|
473
|
+
throw new Error(
|
|
474
|
+
[
|
|
475
|
+
`[monorepo] failed applying patch: ${subject || patchFile}`,
|
|
476
|
+
fromSha ? `[monorepo] from: ${fromSha}` : '',
|
|
477
|
+
applyCheckErr ? `[monorepo] apply --check:\n${applyCheckErr}` : '',
|
|
478
|
+
err ? `[monorepo] git am:\n${err}` : '',
|
|
479
|
+
'[monorepo] fix: resolve conflicts then run `git am --continue` (or abort with `git am --abort`)',
|
|
480
|
+
]
|
|
481
|
+
.filter(Boolean)
|
|
482
|
+
.join('\n')
|
|
483
|
+
);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// Best-effort mode: abort and continue.
|
|
487
|
+
// eslint-disable-next-line no-await-in-loop
|
|
488
|
+
await run('git', ['am', '--abort'], { cwd: targetRepoRoot, stdio: 'ignore' }).catch(() => {});
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
return {
|
|
493
|
+
applied,
|
|
494
|
+
skippedAlreadyApplied,
|
|
495
|
+
skippedAlreadyExistsIdentical,
|
|
496
|
+
failed,
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
async function portOne({
|
|
501
|
+
label,
|
|
502
|
+
sourcePath,
|
|
503
|
+
sourceRef,
|
|
504
|
+
sourceBase,
|
|
505
|
+
targetRepoRoot,
|
|
506
|
+
targetSubdir,
|
|
507
|
+
dryRun,
|
|
508
|
+
threeWay,
|
|
509
|
+
skipApplied,
|
|
510
|
+
continueOnFailure,
|
|
511
|
+
quietGit,
|
|
512
|
+
}) {
|
|
513
|
+
const sourceRepoRoot = await resolveGitRoot(sourcePath);
|
|
514
|
+
if (!sourceRepoRoot) {
|
|
515
|
+
throw new Error(`[monorepo] ${label}: not a git repo: ${sourcePath}`);
|
|
516
|
+
}
|
|
517
|
+
const sourceIsMonorepo = isHappyMonorepoRoot(sourceRepoRoot);
|
|
518
|
+
// If the source is already a monorepo, its patches already contain `expo-app/`, `cli/`, etc.
|
|
519
|
+
// In that case, applying with `--directory <subdir>/` would double-prefix paths.
|
|
520
|
+
const effectiveTargetSubdir = sourceIsMonorepo ? '' : targetSubdir;
|
|
521
|
+
const head = (await git(sourceRepoRoot, ['rev-parse', '--verify', sourceRef || 'HEAD'])).trim();
|
|
522
|
+
const baseRef = sourceBase || (await resolveDefaultBaseRef(sourceRepoRoot));
|
|
523
|
+
if (!baseRef) {
|
|
524
|
+
throw new Error(`[monorepo] ${label}: could not infer a base ref. Pass --${label}-base=<ref>.`);
|
|
525
|
+
}
|
|
526
|
+
const base = (await git(sourceRepoRoot, ['merge-base', baseRef, head])).trim();
|
|
527
|
+
if (!base) {
|
|
528
|
+
throw new Error(`[monorepo] ${label}: failed to compute merge-base for ${baseRef}..${head}`);
|
|
529
|
+
}
|
|
530
|
+
if (base === head) {
|
|
531
|
+
return { label, sourceRepoRoot, baseRef, head, patches: 0, skipped: true, reason: 'no commits to port' };
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
const tmp = await mkdtemp(join(tmpdir(), 'happy-stacks-port-'));
|
|
535
|
+
try {
|
|
536
|
+
const patches = await formatPatchesToDir({ sourceRepoRoot, base, head, outDir: tmp });
|
|
537
|
+
if (dryRun) {
|
|
538
|
+
return {
|
|
539
|
+
label,
|
|
540
|
+
sourceRepoRoot,
|
|
541
|
+
sourceIsMonorepo,
|
|
542
|
+
baseRef,
|
|
543
|
+
head,
|
|
544
|
+
patches: patches.length,
|
|
545
|
+
skipped: false,
|
|
546
|
+
dryRun: true,
|
|
547
|
+
targetSubdir: effectiveTargetSubdir || null,
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
const res = await applyPatches({
|
|
551
|
+
targetRepoRoot,
|
|
552
|
+
directory: effectiveTargetSubdir,
|
|
553
|
+
patches,
|
|
554
|
+
threeWay,
|
|
555
|
+
skipApplied,
|
|
556
|
+
continueOnFailure,
|
|
557
|
+
quietGit,
|
|
558
|
+
});
|
|
559
|
+
return {
|
|
560
|
+
label,
|
|
561
|
+
sourceRepoRoot,
|
|
562
|
+
sourceIsMonorepo,
|
|
563
|
+
baseRef,
|
|
564
|
+
head,
|
|
565
|
+
patches: patches.length,
|
|
566
|
+
appliedPatches: res.applied.length,
|
|
567
|
+
skippedAlreadyApplied: res.skippedAlreadyApplied.length,
|
|
568
|
+
skippedAlreadyExistsIdentical: res.skippedAlreadyExistsIdentical.length,
|
|
569
|
+
failedPatches: res.failed.length,
|
|
570
|
+
report: res,
|
|
571
|
+
skipped: false,
|
|
572
|
+
targetSubdir: effectiveTargetSubdir || null,
|
|
573
|
+
};
|
|
574
|
+
} finally {
|
|
575
|
+
await rm(tmp, { recursive: true, force: true }).catch(() => {});
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
async function cmdPortRun({ argv, flags, kv, json, silent = false }) {
|
|
580
|
+
const target = (kv.get('--target') ?? '').trim();
|
|
581
|
+
if (!target) {
|
|
582
|
+
throw new Error('[monorepo] missing --target=/abs/path/to/monorepo');
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
const targetRepoRoot = await resolveGitRoot(target);
|
|
586
|
+
if (!targetRepoRoot) {
|
|
587
|
+
throw new Error(`[monorepo] target is not a git repo: ${target}`);
|
|
588
|
+
}
|
|
589
|
+
if (!isHappyMonorepoRoot(targetRepoRoot)) {
|
|
590
|
+
throw new Error(
|
|
591
|
+
`[monorepo] target does not look like a slopus/happy monorepo root (missing expo-app/cli/server): ${targetRepoRoot}`
|
|
592
|
+
);
|
|
593
|
+
}
|
|
594
|
+
// Prefer a clearer error message if the user is in the middle of conflict resolution.
|
|
595
|
+
// (A git am session often makes the worktree dirty, which would otherwise trigger a generic "not clean" error.)
|
|
596
|
+
await ensureNoGitAmInProgress(targetRepoRoot);
|
|
597
|
+
await ensureCleanGitWorktree(targetRepoRoot);
|
|
598
|
+
|
|
599
|
+
const ontoCurrent = flags.has('--onto-current');
|
|
600
|
+
const branchOverride = (kv.get('--branch') ?? '').trim();
|
|
601
|
+
const baseOverride = (kv.get('--base') ?? '').trim();
|
|
602
|
+
const dryRun = flags.has('--dry-run');
|
|
603
|
+
const threeWay = flags.has('--3way');
|
|
604
|
+
const skipApplied = flags.has('--skip-applied');
|
|
605
|
+
const continueOnFailure = flags.has('--continue-on-failure');
|
|
606
|
+
const quietGit = json;
|
|
607
|
+
let baseRefUsed = null;
|
|
608
|
+
let branchLabel = null;
|
|
609
|
+
if (!dryRun) {
|
|
610
|
+
if (ontoCurrent) {
|
|
611
|
+
if (branchOverride) {
|
|
612
|
+
throw new Error('[monorepo] --onto-current cannot be combined with --branch (it applies onto the currently checked-out branch)');
|
|
613
|
+
}
|
|
614
|
+
if (baseOverride) {
|
|
615
|
+
throw new Error('[monorepo] --onto-current cannot be combined with --base (it does not checkout a base ref)');
|
|
616
|
+
}
|
|
617
|
+
baseRefUsed = null;
|
|
618
|
+
branchLabel = (await git(targetRepoRoot, ['rev-parse', '--abbrev-ref', 'HEAD'])).trim() || 'HEAD';
|
|
619
|
+
} else {
|
|
620
|
+
const baseRef = baseOverride || (await resolveDefaultTargetBaseRef(targetRepoRoot));
|
|
621
|
+
if (!baseRef) {
|
|
622
|
+
throw new Error('[monorepo] could not infer a target base ref. Pass --base=<ref>.');
|
|
623
|
+
}
|
|
624
|
+
baseRefUsed = baseRef;
|
|
625
|
+
const branch = branchOverride || `port/${Date.now()}`;
|
|
626
|
+
branchLabel = branch;
|
|
627
|
+
// Always start the port branch from a stable base (usually origin/main), rather than whatever is currently checked out.
|
|
628
|
+
await git(targetRepoRoot, ['checkout', '--quiet', baseRef]);
|
|
629
|
+
await ensureBranch(targetRepoRoot, branch);
|
|
630
|
+
}
|
|
631
|
+
} else {
|
|
632
|
+
branchLabel = ontoCurrent ? 'onto-current' : branchOverride || `port/${Date.now()}`;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
const sources = [
|
|
636
|
+
{
|
|
637
|
+
label: 'from-happy',
|
|
638
|
+
path: (kv.get('--from-happy') ?? '').trim(),
|
|
639
|
+
ref: (kv.get('--from-happy-ref') ?? '').trim(),
|
|
640
|
+
base: (kv.get('--from-happy-base') ?? '').trim(),
|
|
641
|
+
subdir: 'expo-app',
|
|
642
|
+
},
|
|
643
|
+
{
|
|
644
|
+
label: 'from-happy-cli',
|
|
645
|
+
path: (kv.get('--from-happy-cli') ?? '').trim(),
|
|
646
|
+
ref: (kv.get('--from-happy-cli-ref') ?? '').trim(),
|
|
647
|
+
base: (kv.get('--from-happy-cli-base') ?? '').trim(),
|
|
648
|
+
subdir: 'cli',
|
|
649
|
+
},
|
|
650
|
+
{
|
|
651
|
+
label: 'from-happy-server',
|
|
652
|
+
path: (kv.get('--from-happy-server') ?? '').trim(),
|
|
653
|
+
ref: (kv.get('--from-happy-server-ref') ?? '').trim(),
|
|
654
|
+
base: (kv.get('--from-happy-server-base') ?? '').trim(),
|
|
655
|
+
subdir: 'server',
|
|
656
|
+
},
|
|
657
|
+
].filter((s) => s.path);
|
|
658
|
+
|
|
659
|
+
if (!sources.length) {
|
|
660
|
+
throw new Error('[monorepo] nothing to port. Provide at least one of: --from-happy, --from-happy-cli, --from-happy-server');
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// Already checked above (keep just one check so errors stay consistent).
|
|
664
|
+
|
|
665
|
+
const results = [];
|
|
666
|
+
for (const s of sources) {
|
|
667
|
+
if (!(await pathExists(s.path))) {
|
|
668
|
+
throw new Error(`[monorepo] ${s.label}: source path does not exist: ${s.path}`);
|
|
669
|
+
}
|
|
670
|
+
// eslint-disable-next-line no-await-in-loop
|
|
671
|
+
const r = await portOne({
|
|
672
|
+
label: s.label,
|
|
673
|
+
sourcePath: s.path,
|
|
674
|
+
sourceRef: s.ref,
|
|
675
|
+
sourceBase: s.base,
|
|
676
|
+
targetRepoRoot,
|
|
677
|
+
targetSubdir: s.subdir,
|
|
678
|
+
dryRun,
|
|
679
|
+
threeWay,
|
|
680
|
+
skipApplied,
|
|
681
|
+
continueOnFailure,
|
|
682
|
+
quietGit,
|
|
683
|
+
});
|
|
684
|
+
results.push(r);
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
const ok = dryRun || results.every((r) => (r.failedPatches ?? 0) === 0);
|
|
688
|
+
const summary = dryRun
|
|
689
|
+
? `[monorepo] dry run complete (${branchLabel})`
|
|
690
|
+
: ok
|
|
691
|
+
? `[monorepo] port complete (${branchLabel})`
|
|
692
|
+
: `[monorepo] port complete with failures (${branchLabel})`;
|
|
693
|
+
const failureDetails = (() => {
|
|
694
|
+
if (json || dryRun || ok) return '';
|
|
695
|
+
const lines = [];
|
|
696
|
+
for (const r of results) {
|
|
697
|
+
const report = r.report;
|
|
698
|
+
const failed = report?.failed ?? [];
|
|
699
|
+
if (!failed.length) continue;
|
|
700
|
+
lines.push('');
|
|
701
|
+
lines.push(`[monorepo] ${r.label}: failed patches (${failed.length})`);
|
|
702
|
+
for (const f of failed.slice(0, 12)) {
|
|
703
|
+
const subj = String(f.subject ?? '').replace(/^\[PATCH \d+\/\d+\]\s*/, '');
|
|
704
|
+
const kind = f.kind ? ` (${f.kind})` : '';
|
|
705
|
+
const paths = (f.paths ?? []).slice(0, 3).join(', ');
|
|
706
|
+
lines.push(`- ${subj || f.patch}${kind}${paths ? ` -> ${paths}` : ''}`);
|
|
707
|
+
}
|
|
708
|
+
if (failed.length > 12) {
|
|
709
|
+
lines.push(`- ...and ${failed.length - 12} more`);
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
return lines.join('\n');
|
|
713
|
+
})();
|
|
714
|
+
|
|
715
|
+
const hints = ok || json
|
|
716
|
+
? ''
|
|
717
|
+
: [
|
|
718
|
+
'',
|
|
719
|
+
'[monorepo] next steps:',
|
|
720
|
+
'- for a full machine-readable report: re-run with `--json`.',
|
|
721
|
+
'- to resolve interactively (recommended): re-run without `--continue-on-failure` so it stops at the first conflict, then use `git am --continue`.',
|
|
722
|
+
].join('\n');
|
|
723
|
+
|
|
724
|
+
const data = { ok, targetRepoRoot, branch: branchLabel, ontoCurrent, dryRun, base: baseRefUsed, results };
|
|
725
|
+
if (!silent) {
|
|
726
|
+
printResult({
|
|
727
|
+
json,
|
|
728
|
+
data,
|
|
729
|
+
text: json ? '' : `${summary}${failureDetails}${hints}`,
|
|
730
|
+
});
|
|
731
|
+
}
|
|
732
|
+
return data;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
async function cmdPortStatus({ kv, json }) {
|
|
736
|
+
const targetRepoRoot = await resolveTargetRepoRootFromArgs({ kv });
|
|
737
|
+
const inProgress = await isGitAmInProgress(targetRepoRoot);
|
|
738
|
+
const branch = (await git(targetRepoRoot, ['rev-parse', '--abbrev-ref', 'HEAD'])).trim() || 'HEAD';
|
|
739
|
+
const conflictedFiles = await listConflictedFiles(targetRepoRoot);
|
|
740
|
+
|
|
741
|
+
let currentPatch = null;
|
|
742
|
+
if (inProgress) {
|
|
743
|
+
try {
|
|
744
|
+
const raw = await git(targetRepoRoot, ['am', '--show-current-patch']);
|
|
745
|
+
const meta = parsePatchMeta(raw);
|
|
746
|
+
const diffs = extractUnifiedDiffs(raw);
|
|
747
|
+
const filesRaw = Array.from(new Set(diffs.map((d) => d.plusPath || d.bPath).filter(Boolean))).sort();
|
|
748
|
+
const files = [];
|
|
749
|
+
for (const f of filesRaw) {
|
|
750
|
+
// `git am --directory <subdir>` applies patches under a directory, but `--show-current-patch`
|
|
751
|
+
// still shows the original (unprefixed) paths. Best-effort map them to the monorepo layout.
|
|
752
|
+
// eslint-disable-next-line no-await-in-loop
|
|
753
|
+
if (await pathExists(join(targetRepoRoot, f))) {
|
|
754
|
+
files.push(f);
|
|
755
|
+
continue;
|
|
756
|
+
}
|
|
757
|
+
const candidates = [`expo-app/${f}`, `cli/${f}`, `server/${f}`];
|
|
758
|
+
let mapped = '';
|
|
759
|
+
for (const c of candidates) {
|
|
760
|
+
// eslint-disable-next-line no-await-in-loop
|
|
761
|
+
if (await pathExists(join(targetRepoRoot, c))) {
|
|
762
|
+
mapped = c;
|
|
763
|
+
break;
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
files.push(mapped || f);
|
|
767
|
+
}
|
|
768
|
+
currentPatch = { subject: meta.subject || '', fromSha: meta.fromSha || '', files, filesRaw };
|
|
769
|
+
} catch {
|
|
770
|
+
currentPatch = { subject: '', fromSha: '', files: [], filesRaw: [] };
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
const text = (() => {
|
|
775
|
+
if (json) return '';
|
|
776
|
+
const lines = [];
|
|
777
|
+
const okMark = inProgress ? yellow('!') : green('✓');
|
|
778
|
+
lines.push(`${bold('[monorepo]')} ${bold('port status')} ${dim(`(${branch})`)} ${okMark}`);
|
|
779
|
+
lines.push(`${dim('target:')} ${targetRepoRoot}`);
|
|
780
|
+
lines.push(`${dim('git am in progress:')} ${inProgress ? yellow('yes') : green('no')}`);
|
|
781
|
+
if (inProgress && currentPatch?.subject) {
|
|
782
|
+
lines.push(`${dim('current patch:')} ${cyan(currentPatch.subject)}`);
|
|
783
|
+
}
|
|
784
|
+
if (inProgress && currentPatch?.files?.length) {
|
|
785
|
+
lines.push(
|
|
786
|
+
`${dim('patch files:')} ${currentPatch.files.slice(0, 6).join(', ')}${currentPatch.files.length > 6 ? dim(', ...') : ''}`
|
|
787
|
+
);
|
|
788
|
+
}
|
|
789
|
+
if (conflictedFiles.length) {
|
|
790
|
+
lines.push(`${yellow('conflicted files:')} ${dim(`(${conflictedFiles.length})`)}`);
|
|
791
|
+
for (const f of conflictedFiles.slice(0, 20)) lines.push(` - ${f}`);
|
|
792
|
+
if (conflictedFiles.length > 20) lines.push(` - ...and ${conflictedFiles.length - 20} more`);
|
|
793
|
+
}
|
|
794
|
+
if (inProgress) {
|
|
795
|
+
lines.push('');
|
|
796
|
+
lines.push(bold('[monorepo] next steps:'));
|
|
797
|
+
lines.push(`- ${dim('resolve + stage:')} git -C ${targetRepoRoot} add <files>`);
|
|
798
|
+
lines.push(`- ${dim('continue:')} git -C ${targetRepoRoot} am --continue`);
|
|
799
|
+
lines.push(`- ${dim('skip patch:')} git -C ${targetRepoRoot} am --skip`);
|
|
800
|
+
lines.push(`- ${dim('abort:')} git -C ${targetRepoRoot} am --abort`);
|
|
801
|
+
lines.push(`- ${dim('helper:')} happys monorepo port continue --target=${targetRepoRoot}`);
|
|
802
|
+
}
|
|
803
|
+
return lines.join('\n');
|
|
804
|
+
})();
|
|
805
|
+
|
|
806
|
+
printResult({
|
|
807
|
+
json,
|
|
808
|
+
data: { ok: true, targetRepoRoot, branch, inProgress, currentPatch, conflictedFiles },
|
|
809
|
+
text,
|
|
810
|
+
});
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
async function cmdPortContinue({ kv, json }) {
|
|
814
|
+
const targetRepoRoot = await resolveTargetRepoRootFromArgs({ kv });
|
|
815
|
+
const runAmContinue = async () => {
|
|
816
|
+
const inProgressBefore = await isGitAmInProgress(targetRepoRoot);
|
|
817
|
+
if (!inProgressBefore) return { ok: true, didRun: false };
|
|
818
|
+
try {
|
|
819
|
+
await runCapture('git', ['am', '--continue'], { cwd: targetRepoRoot });
|
|
820
|
+
return { ok: true, didRun: true };
|
|
821
|
+
} catch (err) {
|
|
822
|
+
const conflictedFiles = await listConflictedFiles(targetRepoRoot);
|
|
823
|
+
const stderr = String(err?.err ?? err?.message ?? err ?? '').trim();
|
|
824
|
+
const hint = [
|
|
825
|
+
`${red('[monorepo]')} continue failed (still conflicted).`,
|
|
826
|
+
conflictedFiles.length ? `[monorepo] conflicted files: ${conflictedFiles.join(', ')}` : '',
|
|
827
|
+
stderr ? `[monorepo] git:\n${stderr}` : '',
|
|
828
|
+
`[monorepo] next: resolve, then re-run: happys monorepo port continue --target=${targetRepoRoot}`,
|
|
829
|
+
]
|
|
830
|
+
.filter(Boolean)
|
|
831
|
+
.join('\n');
|
|
832
|
+
printResult({
|
|
833
|
+
json,
|
|
834
|
+
data: { ok: false, targetRepoRoot, inProgress: true, conflictedFiles },
|
|
835
|
+
text: json ? '' : hint,
|
|
836
|
+
});
|
|
837
|
+
process.exitCode = 1;
|
|
838
|
+
return { ok: false, didRun: true };
|
|
839
|
+
}
|
|
840
|
+
};
|
|
841
|
+
|
|
842
|
+
// 1) If an am session is in progress, advance it.
|
|
843
|
+
const amRes = await runAmContinue();
|
|
844
|
+
if (!amRes.ok) return;
|
|
845
|
+
|
|
846
|
+
// 2) If we're no longer in an am session, and a guide plan exists, resume the port onto the current branch.
|
|
847
|
+
const inProgressAfter = await isGitAmInProgress(targetRepoRoot);
|
|
848
|
+
const branch = (await git(targetRepoRoot, ['rev-parse', '--abbrev-ref', 'HEAD'])).trim() || 'HEAD';
|
|
849
|
+
if (!inProgressAfter) {
|
|
850
|
+
const { plan } = await readPortPlan(targetRepoRoot);
|
|
851
|
+
if (plan?.resumeArgv && Array.isArray(plan.resumeArgv)) {
|
|
852
|
+
try {
|
|
853
|
+
const resumeArgv = [...plan.resumeArgv];
|
|
854
|
+
const { flags, kv } = parseArgs(resumeArgv);
|
|
855
|
+
const jsonWanted = json || wantsJson(resumeArgv, { flags });
|
|
856
|
+
await cmdPortRun({ argv: resumeArgv, flags, kv, json: jsonWanted, silent: json === true });
|
|
857
|
+
await deletePortPlan(targetRepoRoot);
|
|
858
|
+
} catch {
|
|
859
|
+
// cmdPortRun prints its own conflict context; leave the plan file so the user can retry after resolving.
|
|
860
|
+
process.exitCode = process.exitCode ?? 1;
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
const stillInProgress = await isGitAmInProgress(targetRepoRoot);
|
|
866
|
+
printResult({
|
|
867
|
+
json,
|
|
868
|
+
data: { ok: !stillInProgress, targetRepoRoot, branch, inProgress: stillInProgress },
|
|
869
|
+
text: json
|
|
870
|
+
? ''
|
|
871
|
+
: stillInProgress
|
|
872
|
+
? `${yellow('[monorepo]')} continue paused (conflicts remain) ${dim(`(${branch})`)}`
|
|
873
|
+
: `${green('[monorepo]')} continue complete ${dim(`(${branch})`)}`,
|
|
874
|
+
});
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
async function cmdPortGuide({ kv, json }) {
|
|
878
|
+
if (!isTty()) {
|
|
879
|
+
throw new Error('[monorepo] port guide requires a TTY. Re-run in an interactive terminal.');
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
const targetDefault = (kv.get('--target') ?? '').trim() || process.cwd();
|
|
883
|
+
await withRl(async (rl) => {
|
|
884
|
+
// eslint-disable-next-line no-console
|
|
885
|
+
console.log(
|
|
886
|
+
[
|
|
887
|
+
'',
|
|
888
|
+
bold(`✨ ${cyan('Happy Stacks')} monorepo port ✨`),
|
|
889
|
+
'',
|
|
890
|
+
'This wizard ports commits from split repos into the Happy monorepo layout:',
|
|
891
|
+
`- ${cyan('happy')} → expo-app/`,
|
|
892
|
+
`- ${cyan('happy-cli')} → cli/`,
|
|
893
|
+
`- ${cyan('happy-server')} → server/`,
|
|
894
|
+
'',
|
|
895
|
+
bold('Notes:'),
|
|
896
|
+
`- Uses ${cyan('git format-patch')} + ${cyan('git am')} (preserves author + messages)`,
|
|
897
|
+
`- Stops on conflicts so you can resolve and continue`,
|
|
898
|
+
'',
|
|
899
|
+
].join('\n')
|
|
900
|
+
);
|
|
901
|
+
|
|
902
|
+
const targetInput = (await prompt(rl, 'Target monorepo path: ', { defaultValue: targetDefault })).trim();
|
|
903
|
+
const targetRepoRoot = await resolveGitRoot(targetInput);
|
|
904
|
+
if (!targetRepoRoot || !isHappyMonorepoRoot(targetRepoRoot)) {
|
|
905
|
+
throw new Error(`[monorepo] invalid target (expected slopus/happy monorepo root): ${targetInput}`);
|
|
906
|
+
}
|
|
907
|
+
await ensureCleanGitWorktree(targetRepoRoot);
|
|
908
|
+
await ensureNoGitAmInProgress(targetRepoRoot);
|
|
909
|
+
|
|
910
|
+
const baseDefault = await resolveDefaultTargetBaseRef(targetRepoRoot);
|
|
911
|
+
const base = (await prompt(rl, 'Target base ref: ', { defaultValue: baseDefault || 'origin/main' })).trim();
|
|
912
|
+
const branch = (await prompt(rl, 'New branch name: ', { defaultValue: `port/${Date.now()}` })).trim();
|
|
913
|
+
const use3way =
|
|
914
|
+
(await promptSelect(rl, {
|
|
915
|
+
title: 'Use 3-way merge (recommended)?',
|
|
916
|
+
options: [
|
|
917
|
+
{ label: 'yes (recommended)', value: true },
|
|
918
|
+
{ label: 'no', value: false },
|
|
919
|
+
],
|
|
920
|
+
defaultIndex: 0,
|
|
921
|
+
})) === true;
|
|
922
|
+
|
|
923
|
+
const fromHappy = (await prompt(rl, 'Path to old happy repo (UI) [optional]: ', { defaultValue: '' })).trim();
|
|
924
|
+
const fromHappyBase = fromHappy ? (await prompt(rl, 'old happy base ref: ', { defaultValue: 'upstream/main' })).trim() : '';
|
|
925
|
+
const fromHappyCli = (await prompt(rl, 'Path to old happy-cli repo [optional]: ', { defaultValue: '' })).trim();
|
|
926
|
+
const fromHappyCliBase = fromHappyCli ? (await prompt(rl, 'old happy-cli base ref: ', { defaultValue: 'upstream/main' })).trim() : '';
|
|
927
|
+
const fromHappyServer = (await prompt(rl, 'Path to old happy-server repo [optional]: ', { defaultValue: '' })).trim();
|
|
928
|
+
const fromHappyServerBase = fromHappyServer
|
|
929
|
+
? (await prompt(rl, 'old happy-server base ref: ', { defaultValue: 'upstream/main' })).trim()
|
|
930
|
+
: '';
|
|
931
|
+
|
|
932
|
+
if (!fromHappy && !fromHappyCli && !fromHappyServer) {
|
|
933
|
+
throw new Error('[monorepo] guide: nothing to port. Provide at least one source path.');
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
section('Plan');
|
|
937
|
+
noteLine(`${dim('target:')} ${targetRepoRoot}`);
|
|
938
|
+
noteLine(`${dim('base:')} ${base}`);
|
|
939
|
+
noteLine(`${dim('branch:')} ${branch}`);
|
|
940
|
+
noteLine(`${dim('3-way:')} ${use3way ? green('enabled') : yellow('disabled')}`);
|
|
941
|
+
// eslint-disable-next-line no-console
|
|
942
|
+
console.log('');
|
|
943
|
+
// eslint-disable-next-line no-console
|
|
944
|
+
console.log(bold('Sources:'));
|
|
945
|
+
if (fromHappy) noteLine(`- ${cyan('happy')} ${fromHappy} ${dim(`(base=${fromHappyBase})`)}`);
|
|
946
|
+
if (fromHappyCli) noteLine(`- ${cyan('happy-cli')} ${fromHappyCli} ${dim(`(base=${fromHappyCliBase})`)}`);
|
|
947
|
+
if (fromHappyServer) noteLine(`- ${cyan('happy-server')} ${fromHappyServer} ${dim(`(base=${fromHappyServerBase})`)}`);
|
|
948
|
+
|
|
949
|
+
const baseSourceArgs = [
|
|
950
|
+
...(fromHappy ? [`--from-happy=${fromHappy}`, `--from-happy-base=${fromHappyBase}`] : []),
|
|
951
|
+
...(fromHappyCli ? [`--from-happy-cli=${fromHappyCli}`, `--from-happy-cli-base=${fromHappyCliBase}`] : []),
|
|
952
|
+
...(fromHappyServer ? [`--from-happy-server=${fromHappyServer}`, `--from-happy-server-base=${fromHappyServerBase}`] : []),
|
|
953
|
+
];
|
|
954
|
+
|
|
955
|
+
const initialArgv = [
|
|
956
|
+
'port',
|
|
957
|
+
`--target=${targetRepoRoot}`,
|
|
958
|
+
`--branch=${branch}`,
|
|
959
|
+
`--base=${base}`,
|
|
960
|
+
...(use3way ? ['--3way'] : []),
|
|
961
|
+
...baseSourceArgs,
|
|
962
|
+
...(json ? ['--json'] : []),
|
|
963
|
+
];
|
|
964
|
+
|
|
965
|
+
const resumeArgv = [
|
|
966
|
+
'port',
|
|
967
|
+
`--target=${targetRepoRoot}`,
|
|
968
|
+
'--onto-current',
|
|
969
|
+
...(use3way ? ['--3way'] : []),
|
|
970
|
+
...baseSourceArgs,
|
|
971
|
+
...(json ? ['--json'] : []),
|
|
972
|
+
];
|
|
973
|
+
|
|
974
|
+
await writePortPlan(targetRepoRoot, {
|
|
975
|
+
version: 1,
|
|
976
|
+
createdAt: new Date().toISOString(),
|
|
977
|
+
targetRepoRoot,
|
|
978
|
+
base,
|
|
979
|
+
branch,
|
|
980
|
+
use3way,
|
|
981
|
+
resumeArgv,
|
|
982
|
+
});
|
|
983
|
+
|
|
984
|
+
// eslint-disable-next-line no-console
|
|
985
|
+
console.log('');
|
|
986
|
+
// eslint-disable-next-line no-console
|
|
987
|
+
console.log(`${bold('[monorepo]')} guide: starting port ${dim(`(${branch})`)}`);
|
|
988
|
+
|
|
989
|
+
let first = true;
|
|
990
|
+
while (true) {
|
|
991
|
+
const attemptArgv = first ? initialArgv : resumeArgv;
|
|
992
|
+
first = false;
|
|
993
|
+
const { flags: attemptFlags, kv: attemptKv } = parseArgs(attemptArgv);
|
|
994
|
+
const jsonWanted = wantsJson(attemptArgv, { flags: attemptFlags });
|
|
995
|
+
try {
|
|
996
|
+
// eslint-disable-next-line no-await-in-loop
|
|
997
|
+
await cmdPortRun({ argv: attemptArgv, flags: attemptFlags, kv: attemptKv, json: jsonWanted });
|
|
998
|
+
break;
|
|
999
|
+
} catch (e) {
|
|
1000
|
+
// If we stopped because of a git am conflict, drive an interactive resolution loop.
|
|
1001
|
+
// Otherwise, rethrow.
|
|
1002
|
+
// eslint-disable-next-line no-await-in-loop
|
|
1003
|
+
const inProgress = await isGitAmInProgress(targetRepoRoot);
|
|
1004
|
+
if (!inProgress) {
|
|
1005
|
+
throw e;
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
// eslint-disable-next-line no-console
|
|
1009
|
+
console.log('');
|
|
1010
|
+
// eslint-disable-next-line no-console
|
|
1011
|
+
console.log(`${yellow('[monorepo]')} guide: conflict detected`);
|
|
1012
|
+
// eslint-disable-next-line no-console
|
|
1013
|
+
console.log(dim('[monorepo] guide: waiting for conflict resolution'));
|
|
1014
|
+
|
|
1015
|
+
while (await isGitAmInProgress(targetRepoRoot)) {
|
|
1016
|
+
// eslint-disable-next-line no-await-in-loop
|
|
1017
|
+
await cmdPortStatus({ kv: attemptKv, json: false });
|
|
1018
|
+
|
|
1019
|
+
const action = await promptSelect(rl, {
|
|
1020
|
+
title: bold('Resolve conflicts, then choose an action:'),
|
|
1021
|
+
options: [
|
|
1022
|
+
{ label: `${green('continue')} (git am --continue)`, value: 'continue' },
|
|
1023
|
+
{ label: `${cyan('show status again')}`, value: 'status' },
|
|
1024
|
+
{ label: `${yellow('skip current patch')} (git am --skip)`, value: 'skip' },
|
|
1025
|
+
{ label: `${red('abort')} (git am --abort)`, value: 'abort' },
|
|
1026
|
+
{ label: `${dim('quit guide (leave state as-is)')}`, value: 'quit' },
|
|
1027
|
+
],
|
|
1028
|
+
defaultIndex: 0,
|
|
1029
|
+
});
|
|
1030
|
+
|
|
1031
|
+
if (action === 'status') {
|
|
1032
|
+
continue;
|
|
1033
|
+
}
|
|
1034
|
+
if (action === 'abort') {
|
|
1035
|
+
await runCapture('git', ['am', '--abort'], { cwd: targetRepoRoot });
|
|
1036
|
+
await deletePortPlan(targetRepoRoot);
|
|
1037
|
+
throw new Error('[monorepo] guide aborted (git am --abort)');
|
|
1038
|
+
}
|
|
1039
|
+
if (action === 'skip') {
|
|
1040
|
+
await runCapture('git', ['am', '--skip'], { cwd: targetRepoRoot });
|
|
1041
|
+
continue;
|
|
1042
|
+
}
|
|
1043
|
+
if (action === 'quit') {
|
|
1044
|
+
throw new Error('[monorepo] guide stopped (git am still in progress). Run `happys monorepo port status` / `... continue` to proceed.');
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
// continue
|
|
1048
|
+
// eslint-disable-next-line no-await-in-loop
|
|
1049
|
+
await cmdPortContinue({ kv: attemptKv, json: false });
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
await deletePortPlan(targetRepoRoot);
|
|
1055
|
+
// eslint-disable-next-line no-console
|
|
1056
|
+
console.log(`${green('[monorepo]')} guide complete`);
|
|
1057
|
+
});
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
async function main() {
|
|
1061
|
+
const argv = process.argv.slice(2);
|
|
1062
|
+
const { flags, kv } = parseArgs(argv);
|
|
1063
|
+
const json = wantsJson(argv, { flags });
|
|
1064
|
+
|
|
1065
|
+
const positionals = argv.filter((a) => !a.startsWith('--'));
|
|
1066
|
+
const cmd = positionals[0] || 'help';
|
|
1067
|
+
const sub = positionals[1] || '';
|
|
1068
|
+
if (wantsHelp(argv, { flags }) || cmd === 'help') {
|
|
1069
|
+
printResult({ json, data: {}, text: usage() });
|
|
1070
|
+
return;
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
if (cmd !== 'port') {
|
|
1074
|
+
throw new Error(`[monorepo] unknown subcommand: ${cmd} (expected: port)`);
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
if (sub === 'status') {
|
|
1078
|
+
await cmdPortStatus({ kv, json });
|
|
1079
|
+
return;
|
|
1080
|
+
}
|
|
1081
|
+
if (sub === 'continue') {
|
|
1082
|
+
await cmdPortContinue({ kv, json });
|
|
1083
|
+
return;
|
|
1084
|
+
}
|
|
1085
|
+
if (sub === 'guide') {
|
|
1086
|
+
await cmdPortGuide({ kv, json });
|
|
1087
|
+
return;
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
await cmdPortRun({ argv, flags, kv, json });
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
main().catch((err) => {
|
|
1094
|
+
console.error('[monorepo] failed:', err);
|
|
1095
|
+
process.exit(1);
|
|
1096
|
+
});
|