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,1470 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { spawn } from 'node:child_process';
|
|
7
|
+
|
|
8
|
+
import { run, runCapture } from './utils/proc/proc.mjs';
|
|
9
|
+
|
|
10
|
+
async function withTempRoot(t) {
|
|
11
|
+
const dir = await mkdtemp(join(tmpdir(), 'happy-stacks-monorepo-port-'));
|
|
12
|
+
t.after(async () => {
|
|
13
|
+
await rm(dir, { recursive: true, force: true });
|
|
14
|
+
});
|
|
15
|
+
return dir;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function gitEnv() {
|
|
19
|
+
const clean = {};
|
|
20
|
+
for (const [k, v] of Object.entries(process.env)) {
|
|
21
|
+
if (k.startsWith('HAPPY_STACKS_') || k.startsWith('HAPPY_LOCAL_')) continue;
|
|
22
|
+
clean[k] = v;
|
|
23
|
+
}
|
|
24
|
+
return {
|
|
25
|
+
...clean,
|
|
26
|
+
GIT_AUTHOR_NAME: 'Test',
|
|
27
|
+
GIT_AUTHOR_EMAIL: 'test@example.com',
|
|
28
|
+
GIT_COMMITTER_NAME: 'Test',
|
|
29
|
+
GIT_COMMITTER_EMAIL: 'test@example.com',
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function initMonorepoStub({ dir, env, seed = {} }) {
|
|
34
|
+
await mkdir(dir, { recursive: true });
|
|
35
|
+
await run('git', ['init', '-q'], { cwd: dir, env });
|
|
36
|
+
await run('git', ['checkout', '-q', '-b', 'main'], { cwd: dir, env });
|
|
37
|
+
await mkdir(join(dir, 'expo-app'), { recursive: true });
|
|
38
|
+
await mkdir(join(dir, 'cli'), { recursive: true });
|
|
39
|
+
await mkdir(join(dir, 'server'), { recursive: true });
|
|
40
|
+
await writeFile(join(dir, 'expo-app', 'package.json'), '{}\n', 'utf-8');
|
|
41
|
+
await writeFile(join(dir, 'cli', 'package.json'), '{}\n', 'utf-8');
|
|
42
|
+
await writeFile(join(dir, 'server', 'package.json'), '{}\n', 'utf-8');
|
|
43
|
+
for (const [rel, content] of Object.entries(seed)) {
|
|
44
|
+
// eslint-disable-next-line no-await-in-loop
|
|
45
|
+
await mkdir(join(dir, rel.split('/').slice(0, -1).join('/')), { recursive: true });
|
|
46
|
+
// eslint-disable-next-line no-await-in-loop
|
|
47
|
+
await writeFile(join(dir, rel), content, 'utf-8');
|
|
48
|
+
}
|
|
49
|
+
await run('git', ['add', '.'], { cwd: dir, env });
|
|
50
|
+
await run('git', ['commit', '-q', '-m', 'chore: init monorepo'], { cwd: dir, env });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function initSplitRepoStub({ dir, env, name, seed = {} }) {
|
|
54
|
+
await mkdir(dir, { recursive: true });
|
|
55
|
+
await run('git', ['init', '-q'], { cwd: dir, env });
|
|
56
|
+
await run('git', ['checkout', '-q', '-b', 'main'], { cwd: dir, env });
|
|
57
|
+
await writeFile(join(dir, 'package.json'), '{}\n', 'utf-8');
|
|
58
|
+
for (const [rel, content] of Object.entries(seed)) {
|
|
59
|
+
// eslint-disable-next-line no-await-in-loop
|
|
60
|
+
await mkdir(join(dir, rel.split('/').slice(0, -1).join('/')), { recursive: true });
|
|
61
|
+
// eslint-disable-next-line no-await-in-loop
|
|
62
|
+
await writeFile(join(dir, rel), content, 'utf-8');
|
|
63
|
+
}
|
|
64
|
+
await run('git', ['add', '.'], { cwd: dir, env });
|
|
65
|
+
await run('git', ['commit', '-q', '-m', `chore: init ${name}`], { cwd: dir, env });
|
|
66
|
+
return (await runCapture('git', ['rev-parse', 'HEAD'], { cwd: dir, env })).trim();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
test('monorepo port applies split-repo commits into subdirectories', async (t) => {
|
|
70
|
+
const root = await withTempRoot(t);
|
|
71
|
+
const target = join(root, 'target-mono');
|
|
72
|
+
const sourceCli = join(root, 'source-cli');
|
|
73
|
+
|
|
74
|
+
// Target monorepo stub
|
|
75
|
+
await mkdir(target, { recursive: true });
|
|
76
|
+
await run('git', ['init', '-q'], { cwd: target, env: gitEnv() });
|
|
77
|
+
await run('git', ['checkout', '-q', '-b', 'main'], { cwd: target, env: gitEnv() });
|
|
78
|
+
await mkdir(join(target, 'expo-app'), { recursive: true });
|
|
79
|
+
await mkdir(join(target, 'cli'), { recursive: true });
|
|
80
|
+
await mkdir(join(target, 'server'), { recursive: true });
|
|
81
|
+
await writeFile(join(target, 'expo-app', 'package.json'), '{}\n', 'utf-8');
|
|
82
|
+
await writeFile(join(target, 'cli', 'package.json'), '{}\n', 'utf-8');
|
|
83
|
+
// Seed the target with the "base" file so the ported patch has something to apply to.
|
|
84
|
+
await writeFile(join(target, 'cli', 'hello.txt'), 'v1\n', 'utf-8');
|
|
85
|
+
await writeFile(join(target, 'server', 'package.json'), '{}\n', 'utf-8');
|
|
86
|
+
await run('git', ['add', '.'], { cwd: target, env: gitEnv() });
|
|
87
|
+
await run('git', ['commit', '-q', '-m', 'chore: init monorepo'], { cwd: target, env: gitEnv() });
|
|
88
|
+
|
|
89
|
+
// Source CLI repo with one change commit
|
|
90
|
+
await mkdir(sourceCli, { recursive: true });
|
|
91
|
+
await run('git', ['init', '-q'], { cwd: sourceCli, env: gitEnv() });
|
|
92
|
+
await run('git', ['checkout', '-q', '-b', 'main'], { cwd: sourceCli, env: gitEnv() });
|
|
93
|
+
await writeFile(join(sourceCli, 'package.json'), '{}\n', 'utf-8');
|
|
94
|
+
await writeFile(join(sourceCli, 'hello.txt'), 'v1\n', 'utf-8');
|
|
95
|
+
await run('git', ['add', '.'], { cwd: sourceCli, env: gitEnv() });
|
|
96
|
+
await run('git', ['commit', '-q', '-m', 'chore: init cli'], { cwd: sourceCli, env: gitEnv() });
|
|
97
|
+
const base = (await runCapture('git', ['rev-parse', 'HEAD'], { cwd: sourceCli, env: gitEnv() })).trim();
|
|
98
|
+
await writeFile(join(sourceCli, 'hello.txt'), 'v2\n', 'utf-8');
|
|
99
|
+
await run('git', ['add', '.'], { cwd: sourceCli, env: gitEnv() });
|
|
100
|
+
await run('git', ['commit', '-q', '-m', 'feat: update hello'], { cwd: sourceCli, env: gitEnv() });
|
|
101
|
+
|
|
102
|
+
// Run port command
|
|
103
|
+
const out = await runCapture(
|
|
104
|
+
process.execPath,
|
|
105
|
+
[
|
|
106
|
+
join(process.cwd(), 'scripts', 'monorepo.mjs'),
|
|
107
|
+
'port',
|
|
108
|
+
`--target=${target}`,
|
|
109
|
+
`--branch=port/test`,
|
|
110
|
+
`--from-happy-cli=${sourceCli}`,
|
|
111
|
+
`--from-happy-cli-base=${base}`,
|
|
112
|
+
'--json',
|
|
113
|
+
],
|
|
114
|
+
{ cwd: process.cwd(), env: gitEnv() }
|
|
115
|
+
);
|
|
116
|
+
const parsed = JSON.parse(out.trim());
|
|
117
|
+
assert.equal(parsed.ok, true);
|
|
118
|
+
|
|
119
|
+
const content = (await readFile(join(target, 'cli', 'hello.txt'), 'utf-8')).toString();
|
|
120
|
+
assert.equal(content, 'v2\n');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('monorepo port --skip-applied skips patches that are already present in the target', async (t) => {
|
|
124
|
+
const root = await withTempRoot(t);
|
|
125
|
+
const target = join(root, 'target-mono');
|
|
126
|
+
const sourceCli = join(root, 'source-cli');
|
|
127
|
+
|
|
128
|
+
// Target monorepo stub (already at v2 for cli/hello.txt)
|
|
129
|
+
await mkdir(target, { recursive: true });
|
|
130
|
+
await run('git', ['init', '-q'], { cwd: target, env: gitEnv() });
|
|
131
|
+
await run('git', ['checkout', '-q', '-b', 'main'], { cwd: target, env: gitEnv() });
|
|
132
|
+
await mkdir(join(target, 'expo-app'), { recursive: true });
|
|
133
|
+
await mkdir(join(target, 'cli'), { recursive: true });
|
|
134
|
+
await mkdir(join(target, 'server'), { recursive: true });
|
|
135
|
+
await writeFile(join(target, 'expo-app', 'package.json'), '{}\n', 'utf-8');
|
|
136
|
+
await writeFile(join(target, 'cli', 'package.json'), '{}\n', 'utf-8');
|
|
137
|
+
await writeFile(join(target, 'cli', 'hello.txt'), 'v2\n', 'utf-8');
|
|
138
|
+
await writeFile(join(target, 'server', 'package.json'), '{}\n', 'utf-8');
|
|
139
|
+
await run('git', ['add', '.'], { cwd: target, env: gitEnv() });
|
|
140
|
+
await run('git', ['commit', '-q', '-m', 'chore: init monorepo'], { cwd: target, env: gitEnv() });
|
|
141
|
+
|
|
142
|
+
// Source CLI repo with one change commit (v1 -> v2)
|
|
143
|
+
await mkdir(sourceCli, { recursive: true });
|
|
144
|
+
await run('git', ['init', '-q'], { cwd: sourceCli, env: gitEnv() });
|
|
145
|
+
await run('git', ['checkout', '-q', '-b', 'main'], { cwd: sourceCli, env: gitEnv() });
|
|
146
|
+
await writeFile(join(sourceCli, 'package.json'), '{}\n', 'utf-8');
|
|
147
|
+
await writeFile(join(sourceCli, 'hello.txt'), 'v1\n', 'utf-8');
|
|
148
|
+
await run('git', ['add', '.'], { cwd: sourceCli, env: gitEnv() });
|
|
149
|
+
await run('git', ['commit', '-q', '-m', 'chore: init cli'], { cwd: sourceCli, env: gitEnv() });
|
|
150
|
+
const base = (await runCapture('git', ['rev-parse', 'HEAD'], { cwd: sourceCli, env: gitEnv() })).trim();
|
|
151
|
+
await writeFile(join(sourceCli, 'hello.txt'), 'v2\n', 'utf-8');
|
|
152
|
+
await run('git', ['add', '.'], { cwd: sourceCli, env: gitEnv() });
|
|
153
|
+
await run('git', ['commit', '-q', '-m', 'feat: update hello'], { cwd: sourceCli, env: gitEnv() });
|
|
154
|
+
|
|
155
|
+
// Run port command with skip-applied
|
|
156
|
+
const out = await runCapture(
|
|
157
|
+
process.execPath,
|
|
158
|
+
[
|
|
159
|
+
join(process.cwd(), 'scripts', 'monorepo.mjs'),
|
|
160
|
+
'port',
|
|
161
|
+
`--target=${target}`,
|
|
162
|
+
`--branch=port/test-skip`,
|
|
163
|
+
`--from-happy-cli=${sourceCli}`,
|
|
164
|
+
`--from-happy-cli-base=${base}`,
|
|
165
|
+
'--skip-applied',
|
|
166
|
+
'--json',
|
|
167
|
+
],
|
|
168
|
+
{ cwd: process.cwd(), env: gitEnv() }
|
|
169
|
+
);
|
|
170
|
+
const parsed = JSON.parse(out.trim());
|
|
171
|
+
assert.equal(parsed.ok, true);
|
|
172
|
+
|
|
173
|
+
const content = (await readFile(join(target, 'cli', 'hello.txt'), 'utf-8')).toString();
|
|
174
|
+
assert.equal(content, 'v2\n');
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test('monorepo port accepts monorepo sources without double-prefixing paths', async (t) => {
|
|
178
|
+
const root = await withTempRoot(t);
|
|
179
|
+
const target = join(root, 'target-mono');
|
|
180
|
+
const source = join(root, 'source-mono');
|
|
181
|
+
|
|
182
|
+
// Target monorepo stub
|
|
183
|
+
await mkdir(target, { recursive: true });
|
|
184
|
+
await run('git', ['init', '-q'], { cwd: target, env: gitEnv() });
|
|
185
|
+
await run('git', ['checkout', '-q', '-b', 'main'], { cwd: target, env: gitEnv() });
|
|
186
|
+
await mkdir(join(target, 'expo-app'), { recursive: true });
|
|
187
|
+
await mkdir(join(target, 'cli'), { recursive: true });
|
|
188
|
+
await mkdir(join(target, 'server'), { recursive: true });
|
|
189
|
+
await writeFile(join(target, 'expo-app', 'package.json'), '{}\n', 'utf-8');
|
|
190
|
+
await writeFile(join(target, 'cli', 'package.json'), '{}\n', 'utf-8');
|
|
191
|
+
await writeFile(join(target, 'server', 'package.json'), '{}\n', 'utf-8');
|
|
192
|
+
await writeFile(join(target, 'expo-app', 'hello.txt'), 'v1\n', 'utf-8');
|
|
193
|
+
await run('git', ['add', '.'], { cwd: target, env: gitEnv() });
|
|
194
|
+
await run('git', ['commit', '-q', '-m', 'chore: init monorepo'], { cwd: target, env: gitEnv() });
|
|
195
|
+
|
|
196
|
+
// Source monorepo repo with one change commit in expo-app/
|
|
197
|
+
await mkdir(source, { recursive: true });
|
|
198
|
+
await run('git', ['init', '-q'], { cwd: source, env: gitEnv() });
|
|
199
|
+
await run('git', ['checkout', '-q', '-b', 'main'], { cwd: source, env: gitEnv() });
|
|
200
|
+
await mkdir(join(source, 'expo-app'), { recursive: true });
|
|
201
|
+
await mkdir(join(source, 'cli'), { recursive: true });
|
|
202
|
+
await mkdir(join(source, 'server'), { recursive: true });
|
|
203
|
+
await writeFile(join(source, 'expo-app', 'package.json'), '{}\n', 'utf-8');
|
|
204
|
+
await writeFile(join(source, 'cli', 'package.json'), '{}\n', 'utf-8');
|
|
205
|
+
await writeFile(join(source, 'server', 'package.json'), '{}\n', 'utf-8');
|
|
206
|
+
await writeFile(join(source, 'expo-app', 'hello.txt'), 'v1\n', 'utf-8');
|
|
207
|
+
await run('git', ['add', '.'], { cwd: source, env: gitEnv() });
|
|
208
|
+
await run('git', ['commit', '-q', '-m', 'chore: init source monorepo'], { cwd: source, env: gitEnv() });
|
|
209
|
+
const base = (await runCapture('git', ['rev-parse', 'HEAD'], { cwd: source, env: gitEnv() })).trim();
|
|
210
|
+
await writeFile(join(source, 'expo-app', 'hello.txt'), 'v2\n', 'utf-8');
|
|
211
|
+
await run('git', ['add', '.'], { cwd: source, env: gitEnv() });
|
|
212
|
+
await run('git', ['commit', '-q', '-m', 'feat: update expo-app hello'], { cwd: source, env: gitEnv() });
|
|
213
|
+
|
|
214
|
+
const out = await runCapture(
|
|
215
|
+
process.execPath,
|
|
216
|
+
[
|
|
217
|
+
join(process.cwd(), 'scripts', 'monorepo.mjs'),
|
|
218
|
+
'port',
|
|
219
|
+
`--target=${target}`,
|
|
220
|
+
`--branch=port/test-mono-source`,
|
|
221
|
+
`--from-happy=${source}`,
|
|
222
|
+
`--from-happy-base=${base}`,
|
|
223
|
+
'--json',
|
|
224
|
+
],
|
|
225
|
+
{ cwd: process.cwd(), env: gitEnv() }
|
|
226
|
+
);
|
|
227
|
+
const parsed = JSON.parse(out.trim());
|
|
228
|
+
assert.equal(parsed.ok, true);
|
|
229
|
+
const content = (await readFile(join(target, 'expo-app', 'hello.txt'), 'utf-8')).toString();
|
|
230
|
+
assert.equal(content, 'v2\n');
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test('monorepo port --continue-on-failure completes even when some patches do not apply', async (t) => {
|
|
234
|
+
const root = await withTempRoot(t);
|
|
235
|
+
const target = join(root, 'target-mono');
|
|
236
|
+
const sourceCli = join(root, 'source-cli');
|
|
237
|
+
|
|
238
|
+
// Target monorepo stub with v3 already (so v1->v2 patch won't apply, and also can't be detected as already-applied).
|
|
239
|
+
await mkdir(target, { recursive: true });
|
|
240
|
+
await run('git', ['init', '-q'], { cwd: target, env: gitEnv() });
|
|
241
|
+
await run('git', ['checkout', '-q', '-b', 'main'], { cwd: target, env: gitEnv() });
|
|
242
|
+
await mkdir(join(target, 'expo-app'), { recursive: true });
|
|
243
|
+
await mkdir(join(target, 'cli'), { recursive: true });
|
|
244
|
+
await mkdir(join(target, 'server'), { recursive: true });
|
|
245
|
+
await writeFile(join(target, 'expo-app', 'package.json'), '{}\n', 'utf-8');
|
|
246
|
+
await writeFile(join(target, 'cli', 'package.json'), '{}\n', 'utf-8');
|
|
247
|
+
await writeFile(join(target, 'server', 'package.json'), '{}\n', 'utf-8');
|
|
248
|
+
await writeFile(join(target, 'cli', 'hello.txt'), 'v3\n', 'utf-8');
|
|
249
|
+
await run('git', ['add', '.'], { cwd: target, env: gitEnv() });
|
|
250
|
+
await run('git', ['commit', '-q', '-m', 'chore: init monorepo'], { cwd: target, env: gitEnv() });
|
|
251
|
+
|
|
252
|
+
// Source CLI repo with one change commit (v1 -> v2)
|
|
253
|
+
await mkdir(sourceCli, { recursive: true });
|
|
254
|
+
await run('git', ['init', '-q'], { cwd: sourceCli, env: gitEnv() });
|
|
255
|
+
await run('git', ['checkout', '-q', '-b', 'main'], { cwd: sourceCli, env: gitEnv() });
|
|
256
|
+
await writeFile(join(sourceCli, 'package.json'), '{}\n', 'utf-8');
|
|
257
|
+
await writeFile(join(sourceCli, 'hello.txt'), 'v1\n', 'utf-8');
|
|
258
|
+
await run('git', ['add', '.'], { cwd: sourceCli, env: gitEnv() });
|
|
259
|
+
await run('git', ['commit', '-q', '-m', 'chore: init cli'], { cwd: sourceCli, env: gitEnv() });
|
|
260
|
+
const base = (await runCapture('git', ['rev-parse', 'HEAD'], { cwd: sourceCli, env: gitEnv() })).trim();
|
|
261
|
+
await writeFile(join(sourceCli, 'hello.txt'), 'v2\n', 'utf-8');
|
|
262
|
+
await run('git', ['add', '.'], { cwd: sourceCli, env: gitEnv() });
|
|
263
|
+
await run('git', ['commit', '-q', '-m', 'feat: update hello'], { cwd: sourceCli, env: gitEnv() });
|
|
264
|
+
|
|
265
|
+
// Run port command: patch should fail to apply, but command succeeds.
|
|
266
|
+
const out = await runCapture(
|
|
267
|
+
process.execPath,
|
|
268
|
+
[
|
|
269
|
+
join(process.cwd(), 'scripts', 'monorepo.mjs'),
|
|
270
|
+
'port',
|
|
271
|
+
`--target=${target}`,
|
|
272
|
+
`--branch=port/test-continue`,
|
|
273
|
+
`--from-happy-cli=${sourceCli}`,
|
|
274
|
+
`--from-happy-cli-base=${base}`,
|
|
275
|
+
'--skip-applied',
|
|
276
|
+
'--continue-on-failure',
|
|
277
|
+
'--json',
|
|
278
|
+
],
|
|
279
|
+
{ cwd: process.cwd(), env: gitEnv() }
|
|
280
|
+
);
|
|
281
|
+
const parsed = JSON.parse(out.trim());
|
|
282
|
+
assert.equal(parsed.ok, false);
|
|
283
|
+
assert.equal(parsed.results[0].failedPatches, 1);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
test('monorepo port auto-skips identical "new file" patches when the file already exists in the target', async (t) => {
|
|
287
|
+
const root = await withTempRoot(t);
|
|
288
|
+
const target = join(root, 'target-mono');
|
|
289
|
+
const sourceCli = join(root, 'source-cli');
|
|
290
|
+
|
|
291
|
+
// Target monorepo stub already contains cli/newfile.txt with the same content.
|
|
292
|
+
await mkdir(target, { recursive: true });
|
|
293
|
+
await run('git', ['init', '-q'], { cwd: target, env: gitEnv() });
|
|
294
|
+
await run('git', ['checkout', '-q', '-b', 'main'], { cwd: target, env: gitEnv() });
|
|
295
|
+
await mkdir(join(target, 'expo-app'), { recursive: true });
|
|
296
|
+
await mkdir(join(target, 'cli'), { recursive: true });
|
|
297
|
+
await mkdir(join(target, 'server'), { recursive: true });
|
|
298
|
+
await writeFile(join(target, 'expo-app', 'package.json'), '{}\n', 'utf-8');
|
|
299
|
+
await writeFile(join(target, 'cli', 'package.json'), '{}\n', 'utf-8');
|
|
300
|
+
await writeFile(join(target, 'server', 'package.json'), '{}\n', 'utf-8');
|
|
301
|
+
await writeFile(join(target, 'cli', 'newfile.txt'), 'same\n', 'utf-8');
|
|
302
|
+
await run('git', ['add', '.'], { cwd: target, env: gitEnv() });
|
|
303
|
+
await run('git', ['commit', '-q', '-m', 'chore: init monorepo'], { cwd: target, env: gitEnv() });
|
|
304
|
+
|
|
305
|
+
// Source CLI repo adds newfile.txt in a single commit.
|
|
306
|
+
await mkdir(sourceCli, { recursive: true });
|
|
307
|
+
await run('git', ['init', '-q'], { cwd: sourceCli, env: gitEnv() });
|
|
308
|
+
await run('git', ['checkout', '-q', '-b', 'main'], { cwd: sourceCli, env: gitEnv() });
|
|
309
|
+
await writeFile(join(sourceCli, 'package.json'), '{}\n', 'utf-8');
|
|
310
|
+
await run('git', ['add', '.'], { cwd: sourceCli, env: gitEnv() });
|
|
311
|
+
await run('git', ['commit', '-q', '-m', 'chore: init cli'], { cwd: sourceCli, env: gitEnv() });
|
|
312
|
+
const base = (await runCapture('git', ['rev-parse', 'HEAD'], { cwd: sourceCli, env: gitEnv() })).trim();
|
|
313
|
+
await writeFile(join(sourceCli, 'newfile.txt'), 'same\n', 'utf-8');
|
|
314
|
+
await run('git', ['add', '.'], { cwd: sourceCli, env: gitEnv() });
|
|
315
|
+
await run('git', ['commit', '-q', '-m', 'feat: add newfile'], { cwd: sourceCli, env: gitEnv() });
|
|
316
|
+
|
|
317
|
+
const out = await runCapture(
|
|
318
|
+
process.execPath,
|
|
319
|
+
[
|
|
320
|
+
join(process.cwd(), 'scripts', 'monorepo.mjs'),
|
|
321
|
+
'port',
|
|
322
|
+
`--target=${target}`,
|
|
323
|
+
`--branch=port/test-identical-newfile`,
|
|
324
|
+
`--from-happy-cli=${sourceCli}`,
|
|
325
|
+
`--from-happy-cli-base=${base}`,
|
|
326
|
+
'--json',
|
|
327
|
+
],
|
|
328
|
+
{ cwd: process.cwd(), env: gitEnv() }
|
|
329
|
+
);
|
|
330
|
+
|
|
331
|
+
const parsed = JSON.parse(out.trim());
|
|
332
|
+
assert.equal(parsed.ok, true);
|
|
333
|
+
assert.equal(parsed.results[0].failedPatches, 0);
|
|
334
|
+
// This commit cannot be applied (it would "create" an existing file), so the port must skip it.
|
|
335
|
+
assert.equal(parsed.results[0].appliedPatches, 0);
|
|
336
|
+
assert.equal(parsed.results[0].skippedAlreadyApplied, 0);
|
|
337
|
+
assert.equal(parsed.results[0].skippedAlreadyExistsIdentical, 1);
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
test('monorepo port --onto-current applies onto the currently checked-out branch', async (t) => {
|
|
341
|
+
const root = await withTempRoot(t);
|
|
342
|
+
const target = join(root, 'target-mono');
|
|
343
|
+
const sourceCli = join(root, 'source-cli');
|
|
344
|
+
|
|
345
|
+
// Target monorepo stub on a custom branch (so we can verify it doesn't switch).
|
|
346
|
+
await mkdir(target, { recursive: true });
|
|
347
|
+
await run('git', ['init', '-q'], { cwd: target, env: gitEnv() });
|
|
348
|
+
await run('git', ['checkout', '-q', '-b', 'main'], { cwd: target, env: gitEnv() });
|
|
349
|
+
await mkdir(join(target, 'expo-app'), { recursive: true });
|
|
350
|
+
await mkdir(join(target, 'cli'), { recursive: true });
|
|
351
|
+
await mkdir(join(target, 'server'), { recursive: true });
|
|
352
|
+
await writeFile(join(target, 'expo-app', 'package.json'), '{}\n', 'utf-8');
|
|
353
|
+
await writeFile(join(target, 'cli', 'package.json'), '{}\n', 'utf-8');
|
|
354
|
+
await writeFile(join(target, 'server', 'package.json'), '{}\n', 'utf-8');
|
|
355
|
+
await writeFile(join(target, 'cli', 'hello.txt'), 'v1\n', 'utf-8');
|
|
356
|
+
await run('git', ['add', '.'], { cwd: target, env: gitEnv() });
|
|
357
|
+
await run('git', ['commit', '-q', '-m', 'chore: init monorepo'], { cwd: target, env: gitEnv() });
|
|
358
|
+
await run('git', ['checkout', '-q', '-b', 'existing'], { cwd: target, env: gitEnv() });
|
|
359
|
+
|
|
360
|
+
// Source CLI repo with one change commit (v1 -> v2)
|
|
361
|
+
await mkdir(sourceCli, { recursive: true });
|
|
362
|
+
await run('git', ['init', '-q'], { cwd: sourceCli, env: gitEnv() });
|
|
363
|
+
await run('git', ['checkout', '-q', '-b', 'main'], { cwd: sourceCli, env: gitEnv() });
|
|
364
|
+
await writeFile(join(sourceCli, 'package.json'), '{}\n', 'utf-8');
|
|
365
|
+
await writeFile(join(sourceCli, 'hello.txt'), 'v1\n', 'utf-8');
|
|
366
|
+
await run('git', ['add', '.'], { cwd: sourceCli, env: gitEnv() });
|
|
367
|
+
await run('git', ['commit', '-q', '-m', 'chore: init cli'], { cwd: sourceCli, env: gitEnv() });
|
|
368
|
+
const base = (await runCapture('git', ['rev-parse', 'HEAD'], { cwd: sourceCli, env: gitEnv() })).trim();
|
|
369
|
+
await writeFile(join(sourceCli, 'hello.txt'), 'v2\n', 'utf-8');
|
|
370
|
+
await run('git', ['add', '.'], { cwd: sourceCli, env: gitEnv() });
|
|
371
|
+
await run('git', ['commit', '-q', '-m', 'feat: update hello'], { cwd: sourceCli, env: gitEnv() });
|
|
372
|
+
|
|
373
|
+
const out = await runCapture(
|
|
374
|
+
process.execPath,
|
|
375
|
+
[
|
|
376
|
+
join(process.cwd(), 'scripts', 'monorepo.mjs'),
|
|
377
|
+
'port',
|
|
378
|
+
`--target=${target}`,
|
|
379
|
+
`--from-happy-cli=${sourceCli}`,
|
|
380
|
+
`--from-happy-cli-base=${base}`,
|
|
381
|
+
'--onto-current',
|
|
382
|
+
'--json',
|
|
383
|
+
],
|
|
384
|
+
{ cwd: process.cwd(), env: gitEnv() }
|
|
385
|
+
);
|
|
386
|
+
const parsed = JSON.parse(out.trim());
|
|
387
|
+
assert.equal(parsed.ok, true);
|
|
388
|
+
|
|
389
|
+
const branch = (await runCapture('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: target, env: gitEnv() })).trim();
|
|
390
|
+
assert.equal(branch, 'existing');
|
|
391
|
+
const content = (await readFile(join(target, 'cli', 'hello.txt'), 'utf-8')).toString();
|
|
392
|
+
assert.equal(content, 'v2\n');
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
test('monorepo port branches from target default base (not current HEAD)', async (t) => {
|
|
396
|
+
const root = await withTempRoot(t);
|
|
397
|
+
const target = join(root, 'target-mono');
|
|
398
|
+
const sourceCli = join(root, 'source-cli');
|
|
399
|
+
|
|
400
|
+
// Target monorepo stub on main with cli/hello.txt=v1.
|
|
401
|
+
await mkdir(target, { recursive: true });
|
|
402
|
+
await run('git', ['init', '-q'], { cwd: target, env: gitEnv() });
|
|
403
|
+
await run('git', ['checkout', '-q', '-b', 'main'], { cwd: target, env: gitEnv() });
|
|
404
|
+
await mkdir(join(target, 'expo-app'), { recursive: true });
|
|
405
|
+
await mkdir(join(target, 'cli'), { recursive: true });
|
|
406
|
+
await mkdir(join(target, 'server'), { recursive: true });
|
|
407
|
+
await writeFile(join(target, 'expo-app', 'package.json'), '{}\n', 'utf-8');
|
|
408
|
+
await writeFile(join(target, 'cli', 'package.json'), '{}\n', 'utf-8');
|
|
409
|
+
await writeFile(join(target, 'server', 'package.json'), '{}\n', 'utf-8');
|
|
410
|
+
await writeFile(join(target, 'cli', 'hello.txt'), 'v1\n', 'utf-8');
|
|
411
|
+
await run('git', ['add', '.'], { cwd: target, env: gitEnv() });
|
|
412
|
+
await run('git', ['commit', '-q', '-m', 'chore: init monorepo'], { cwd: target, env: gitEnv() });
|
|
413
|
+
|
|
414
|
+
// Create a divergent branch and leave it checked out (simulates running port from a non-base branch).
|
|
415
|
+
await run('git', ['checkout', '-q', '-b', 'dev'], { cwd: target, env: gitEnv() });
|
|
416
|
+
await writeFile(join(target, 'cli', 'hello.txt'), 'v3\n', 'utf-8');
|
|
417
|
+
await run('git', ['add', 'cli/hello.txt'], { cwd: target, env: gitEnv() });
|
|
418
|
+
await run('git', ['commit', '-q', '-m', 'chore: dev drift'], { cwd: target, env: gitEnv() });
|
|
419
|
+
|
|
420
|
+
// Source CLI repo with one change commit (v1 -> v2).
|
|
421
|
+
await mkdir(sourceCli, { recursive: true });
|
|
422
|
+
await run('git', ['init', '-q'], { cwd: sourceCli, env: gitEnv() });
|
|
423
|
+
await run('git', ['checkout', '-q', '-b', 'main'], { cwd: sourceCli, env: gitEnv() });
|
|
424
|
+
await writeFile(join(sourceCli, 'package.json'), '{}\n', 'utf-8');
|
|
425
|
+
await writeFile(join(sourceCli, 'hello.txt'), 'v1\n', 'utf-8');
|
|
426
|
+
await run('git', ['add', '.'], { cwd: sourceCli, env: gitEnv() });
|
|
427
|
+
await run('git', ['commit', '-q', '-m', 'chore: init cli'], { cwd: sourceCli, env: gitEnv() });
|
|
428
|
+
const base = (await runCapture('git', ['rev-parse', 'HEAD'], { cwd: sourceCli, env: gitEnv() })).trim();
|
|
429
|
+
await writeFile(join(sourceCli, 'hello.txt'), 'v2\n', 'utf-8');
|
|
430
|
+
await run('git', ['add', '.'], { cwd: sourceCli, env: gitEnv() });
|
|
431
|
+
await run('git', ['commit', '-q', '-m', 'feat: update hello'], { cwd: sourceCli, env: gitEnv() });
|
|
432
|
+
|
|
433
|
+
// Port should branch from target main by default (not dev), so the v1->v2 patch applies.
|
|
434
|
+
const out = await runCapture(
|
|
435
|
+
process.execPath,
|
|
436
|
+
[
|
|
437
|
+
join(process.cwd(), 'scripts', 'monorepo.mjs'),
|
|
438
|
+
'port',
|
|
439
|
+
`--target=${target}`,
|
|
440
|
+
`--branch=port/test-target-base`,
|
|
441
|
+
`--from-happy-cli=${sourceCli}`,
|
|
442
|
+
`--from-happy-cli-base=${base}`,
|
|
443
|
+
'--json',
|
|
444
|
+
],
|
|
445
|
+
{ cwd: process.cwd(), env: gitEnv() }
|
|
446
|
+
);
|
|
447
|
+
const parsed = JSON.parse(out.trim());
|
|
448
|
+
assert.equal(parsed.ok, true);
|
|
449
|
+
|
|
450
|
+
const content = (await readFile(join(target, 'cli', 'hello.txt'), 'utf-8')).toString();
|
|
451
|
+
assert.equal(content, 'v2\n');
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
test('monorepo port prints an actionable summary in non-json mode when patches fail', async (t) => {
|
|
455
|
+
const root = await withTempRoot(t);
|
|
456
|
+
const target = join(root, 'target-mono');
|
|
457
|
+
const sourceCli = join(root, 'source-cli');
|
|
458
|
+
|
|
459
|
+
// Target monorepo stub with cli/hello.txt=v3 (so v1->v2 patch fails).
|
|
460
|
+
await mkdir(target, { recursive: true });
|
|
461
|
+
await run('git', ['init', '-q'], { cwd: target, env: gitEnv() });
|
|
462
|
+
await run('git', ['checkout', '-q', '-b', 'main'], { cwd: target, env: gitEnv() });
|
|
463
|
+
await mkdir(join(target, 'expo-app'), { recursive: true });
|
|
464
|
+
await mkdir(join(target, 'cli'), { recursive: true });
|
|
465
|
+
await mkdir(join(target, 'server'), { recursive: true });
|
|
466
|
+
await writeFile(join(target, 'expo-app', 'package.json'), '{}\n', 'utf-8');
|
|
467
|
+
await writeFile(join(target, 'cli', 'package.json'), '{}\n', 'utf-8');
|
|
468
|
+
await writeFile(join(target, 'server', 'package.json'), '{}\n', 'utf-8');
|
|
469
|
+
await writeFile(join(target, 'cli', 'hello.txt'), 'v3\n', 'utf-8');
|
|
470
|
+
await run('git', ['add', '.'], { cwd: target, env: gitEnv() });
|
|
471
|
+
await run('git', ['commit', '-q', '-m', 'chore: init monorepo'], { cwd: target, env: gitEnv() });
|
|
472
|
+
|
|
473
|
+
// Source CLI repo with one change commit (v1 -> v2).
|
|
474
|
+
await mkdir(sourceCli, { recursive: true });
|
|
475
|
+
await run('git', ['init', '-q'], { cwd: sourceCli, env: gitEnv() });
|
|
476
|
+
await run('git', ['checkout', '-q', '-b', 'main'], { cwd: sourceCli, env: gitEnv() });
|
|
477
|
+
await writeFile(join(sourceCli, 'package.json'), '{}\n', 'utf-8');
|
|
478
|
+
await writeFile(join(sourceCli, 'hello.txt'), 'v1\n', 'utf-8');
|
|
479
|
+
await run('git', ['add', '.'], { cwd: sourceCli, env: gitEnv() });
|
|
480
|
+
await run('git', ['commit', '-q', '-m', 'chore: init cli'], { cwd: sourceCli, env: gitEnv() });
|
|
481
|
+
const base = (await runCapture('git', ['rev-parse', 'HEAD'], { cwd: sourceCli, env: gitEnv() })).trim();
|
|
482
|
+
await writeFile(join(sourceCli, 'hello.txt'), 'v2\n', 'utf-8');
|
|
483
|
+
await run('git', ['add', '.'], { cwd: sourceCli, env: gitEnv() });
|
|
484
|
+
await run('git', ['commit', '-q', '-m', 'feat: update hello'], { cwd: sourceCli, env: gitEnv() });
|
|
485
|
+
|
|
486
|
+
// Run without --json and ensure it prints a useful failure summary.
|
|
487
|
+
const out = await runCapture(
|
|
488
|
+
process.execPath,
|
|
489
|
+
[
|
|
490
|
+
join(process.cwd(), 'scripts', 'monorepo.mjs'),
|
|
491
|
+
'port',
|
|
492
|
+
`--target=${target}`,
|
|
493
|
+
`--branch=port/test-nonjson`,
|
|
494
|
+
`--from-happy-cli=${sourceCli}`,
|
|
495
|
+
`--from-happy-cli-base=${base}`,
|
|
496
|
+
'--continue-on-failure',
|
|
497
|
+
],
|
|
498
|
+
{ cwd: process.cwd(), env: gitEnv() }
|
|
499
|
+
);
|
|
500
|
+
|
|
501
|
+
assert.ok(out.includes('port complete with failures'), `expected failure summary in stdout\n${out}`);
|
|
502
|
+
assert.ok(out.includes('feat: update hello'), `expected failed patch subject in stdout\n${out}`);
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
test('monorepo port status reports the current patch and conflicted files during git am', async (t) => {
|
|
506
|
+
const root = await withTempRoot(t);
|
|
507
|
+
const target = join(root, 'target-mono');
|
|
508
|
+
const sourceCli = join(root, 'source-cli');
|
|
509
|
+
|
|
510
|
+
// Target monorepo stub with cli/hello.txt="value=target".
|
|
511
|
+
await mkdir(target, { recursive: true });
|
|
512
|
+
await run('git', ['init', '-q'], { cwd: target, env: gitEnv() });
|
|
513
|
+
await run('git', ['checkout', '-q', '-b', 'main'], { cwd: target, env: gitEnv() });
|
|
514
|
+
await mkdir(join(target, 'expo-app'), { recursive: true });
|
|
515
|
+
await mkdir(join(target, 'cli'), { recursive: true });
|
|
516
|
+
await mkdir(join(target, 'server'), { recursive: true });
|
|
517
|
+
await writeFile(join(target, 'expo-app', 'package.json'), '{}\n', 'utf-8');
|
|
518
|
+
await writeFile(join(target, 'cli', 'package.json'), '{}\n', 'utf-8');
|
|
519
|
+
await writeFile(join(target, 'server', 'package.json'), '{}\n', 'utf-8');
|
|
520
|
+
await writeFile(join(target, 'cli', 'hello.txt'), 'value=target\n', 'utf-8');
|
|
521
|
+
await run('git', ['add', '.'], { cwd: target, env: gitEnv() });
|
|
522
|
+
await run('git', ['commit', '-q', '-m', 'chore: init monorepo'], { cwd: target, env: gitEnv() });
|
|
523
|
+
|
|
524
|
+
// Source CLI repo with base="value=base" and a commit changing to "value=source".
|
|
525
|
+
await mkdir(sourceCli, { recursive: true });
|
|
526
|
+
await run('git', ['init', '-q'], { cwd: sourceCli, env: gitEnv() });
|
|
527
|
+
await run('git', ['checkout', '-q', '-b', 'main'], { cwd: sourceCli, env: gitEnv() });
|
|
528
|
+
await writeFile(join(sourceCli, 'package.json'), '{}\n', 'utf-8');
|
|
529
|
+
await writeFile(join(sourceCli, 'hello.txt'), 'value=base\n', 'utf-8');
|
|
530
|
+
await run('git', ['add', '.'], { cwd: sourceCli, env: gitEnv() });
|
|
531
|
+
await run('git', ['commit', '-q', '-m', 'chore: init cli'], { cwd: sourceCli, env: gitEnv() });
|
|
532
|
+
const base = (await runCapture('git', ['rev-parse', 'HEAD'], { cwd: sourceCli, env: gitEnv() })).trim();
|
|
533
|
+
await writeFile(join(sourceCli, 'hello.txt'), 'value=source\n', 'utf-8');
|
|
534
|
+
await run('git', ['add', '.'], { cwd: sourceCli, env: gitEnv() });
|
|
535
|
+
await run('git', ['commit', '-q', '-m', 'feat: update hello'], { cwd: sourceCli, env: gitEnv() });
|
|
536
|
+
|
|
537
|
+
// Start a port that will stop with an am conflict.
|
|
538
|
+
await assert.rejects(
|
|
539
|
+
async () =>
|
|
540
|
+
await runCapture(
|
|
541
|
+
process.execPath,
|
|
542
|
+
[
|
|
543
|
+
join(process.cwd(), 'scripts', 'monorepo.mjs'),
|
|
544
|
+
'port',
|
|
545
|
+
`--target=${target}`,
|
|
546
|
+
`--branch=port/test-status`,
|
|
547
|
+
`--from-happy-cli=${sourceCli}`,
|
|
548
|
+
`--from-happy-cli-base=${base}`,
|
|
549
|
+
'--3way',
|
|
550
|
+
],
|
|
551
|
+
{ cwd: process.cwd(), env: gitEnv() }
|
|
552
|
+
)
|
|
553
|
+
);
|
|
554
|
+
|
|
555
|
+
const out = await runCapture(
|
|
556
|
+
process.execPath,
|
|
557
|
+
[join(process.cwd(), 'scripts', 'monorepo.mjs'), 'port', 'status', `--target=${target}`, '--json'],
|
|
558
|
+
{ cwd: process.cwd(), env: gitEnv() }
|
|
559
|
+
);
|
|
560
|
+
const parsed = JSON.parse(out.trim());
|
|
561
|
+
assert.equal(parsed.ok, true);
|
|
562
|
+
assert.equal(parsed.inProgress, true);
|
|
563
|
+
assert.ok(parsed.currentPatch?.subject?.includes('feat: update hello'), `expected subject in status\n${out}`);
|
|
564
|
+
// Depending on git's 3-way behavior, it may stop without creating unmerged entries.
|
|
565
|
+
// In that case, status should still expose the file(s) touched by the current patch.
|
|
566
|
+
assert.ok(
|
|
567
|
+
parsed.conflictedFiles.includes('cli/hello.txt') || parsed.currentPatch?.files?.includes('cli/hello.txt'),
|
|
568
|
+
`expected cli/hello.txt in conflictedFiles or currentPatch.files\n${out}`
|
|
569
|
+
);
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
test('monorepo port continue runs git am --continue after conflicts are resolved', async (t) => {
|
|
573
|
+
const root = await withTempRoot(t);
|
|
574
|
+
const target = join(root, 'target-mono');
|
|
575
|
+
const sourceCli = join(root, 'source-cli');
|
|
576
|
+
|
|
577
|
+
// Target monorepo stub with cli/hello.txt="value=target".
|
|
578
|
+
await mkdir(target, { recursive: true });
|
|
579
|
+
await run('git', ['init', '-q'], { cwd: target, env: gitEnv() });
|
|
580
|
+
await run('git', ['checkout', '-q', '-b', 'main'], { cwd: target, env: gitEnv() });
|
|
581
|
+
await mkdir(join(target, 'expo-app'), { recursive: true });
|
|
582
|
+
await mkdir(join(target, 'cli'), { recursive: true });
|
|
583
|
+
await mkdir(join(target, 'server'), { recursive: true });
|
|
584
|
+
await writeFile(join(target, 'expo-app', 'package.json'), '{}\n', 'utf-8');
|
|
585
|
+
await writeFile(join(target, 'cli', 'package.json'), '{}\n', 'utf-8');
|
|
586
|
+
await writeFile(join(target, 'server', 'package.json'), '{}\n', 'utf-8');
|
|
587
|
+
await writeFile(join(target, 'cli', 'hello.txt'), 'value=target\n', 'utf-8');
|
|
588
|
+
await run('git', ['add', '.'], { cwd: target, env: gitEnv() });
|
|
589
|
+
await run('git', ['commit', '-q', '-m', 'chore: init monorepo'], { cwd: target, env: gitEnv() });
|
|
590
|
+
|
|
591
|
+
// Source CLI repo with base="value=base" and a commit changing to "value=source".
|
|
592
|
+
await mkdir(sourceCli, { recursive: true });
|
|
593
|
+
await run('git', ['init', '-q'], { cwd: sourceCli, env: gitEnv() });
|
|
594
|
+
await run('git', ['checkout', '-q', '-b', 'main'], { cwd: sourceCli, env: gitEnv() });
|
|
595
|
+
await writeFile(join(sourceCli, 'package.json'), '{}\n', 'utf-8');
|
|
596
|
+
await writeFile(join(sourceCli, 'hello.txt'), 'value=base\n', 'utf-8');
|
|
597
|
+
await run('git', ['add', '.'], { cwd: sourceCli, env: gitEnv() });
|
|
598
|
+
await run('git', ['commit', '-q', '-m', 'chore: init cli'], { cwd: sourceCli, env: gitEnv() });
|
|
599
|
+
const base = (await runCapture('git', ['rev-parse', 'HEAD'], { cwd: sourceCli, env: gitEnv() })).trim();
|
|
600
|
+
await writeFile(join(sourceCli, 'hello.txt'), 'value=source\n', 'utf-8');
|
|
601
|
+
await run('git', ['add', '.'], { cwd: sourceCli, env: gitEnv() });
|
|
602
|
+
await run('git', ['commit', '-q', '-m', 'feat: update hello'], { cwd: sourceCli, env: gitEnv() });
|
|
603
|
+
|
|
604
|
+
// Start a port that will stop with an am conflict.
|
|
605
|
+
await assert.rejects(
|
|
606
|
+
async () =>
|
|
607
|
+
await runCapture(
|
|
608
|
+
process.execPath,
|
|
609
|
+
[
|
|
610
|
+
join(process.cwd(), 'scripts', 'monorepo.mjs'),
|
|
611
|
+
'port',
|
|
612
|
+
`--target=${target}`,
|
|
613
|
+
`--branch=port/test-continue-helper`,
|
|
614
|
+
`--from-happy-cli=${sourceCli}`,
|
|
615
|
+
`--from-happy-cli-base=${base}`,
|
|
616
|
+
'--3way',
|
|
617
|
+
],
|
|
618
|
+
{ cwd: process.cwd(), env: gitEnv() }
|
|
619
|
+
)
|
|
620
|
+
);
|
|
621
|
+
|
|
622
|
+
// Resolve the conflict by choosing "value=source".
|
|
623
|
+
await writeFile(join(target, 'cli', 'hello.txt'), 'value=source\n', 'utf-8');
|
|
624
|
+
await run('git', ['add', 'cli/hello.txt'], { cwd: target, env: gitEnv() });
|
|
625
|
+
|
|
626
|
+
const out = await runCapture(
|
|
627
|
+
process.execPath,
|
|
628
|
+
[join(process.cwd(), 'scripts', 'monorepo.mjs'), 'port', 'continue', `--target=${target}`, '--json'],
|
|
629
|
+
{ cwd: process.cwd(), env: gitEnv() }
|
|
630
|
+
);
|
|
631
|
+
const parsed = JSON.parse(out.trim());
|
|
632
|
+
assert.equal(parsed.ok, true);
|
|
633
|
+
assert.equal(parsed.inProgress, false);
|
|
634
|
+
|
|
635
|
+
const content = (await readFile(join(target, 'cli', 'hello.txt'), 'utf-8')).toString();
|
|
636
|
+
assert.equal(content, 'value=source\n');
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
test('monorepo port guide refuses to run in non-tty mode', async (t) => {
|
|
640
|
+
const root = await withTempRoot(t);
|
|
641
|
+
const target = join(root, 'target-mono');
|
|
642
|
+
await mkdir(target, { recursive: true });
|
|
643
|
+
await run('git', ['init', '-q'], { cwd: target, env: gitEnv() });
|
|
644
|
+
await run('git', ['checkout', '-q', '-b', 'main'], { cwd: target, env: gitEnv() });
|
|
645
|
+
await mkdir(join(target, 'expo-app'), { recursive: true });
|
|
646
|
+
await mkdir(join(target, 'cli'), { recursive: true });
|
|
647
|
+
await mkdir(join(target, 'server'), { recursive: true });
|
|
648
|
+
await writeFile(join(target, 'expo-app', 'package.json'), '{}\n', 'utf-8');
|
|
649
|
+
await writeFile(join(target, 'cli', 'package.json'), '{}\n', 'utf-8');
|
|
650
|
+
await writeFile(join(target, 'server', 'package.json'), '{}\n', 'utf-8');
|
|
651
|
+
await run('git', ['add', '.'], { cwd: target, env: gitEnv() });
|
|
652
|
+
await run('git', ['commit', '-q', '-m', 'chore: init monorepo'], { cwd: target, env: gitEnv() });
|
|
653
|
+
|
|
654
|
+
await assert.rejects(
|
|
655
|
+
async () =>
|
|
656
|
+
await runCapture(
|
|
657
|
+
process.execPath,
|
|
658
|
+
[join(process.cwd(), 'scripts', 'monorepo.mjs'), 'port', 'guide', `--target=${target}`],
|
|
659
|
+
{ cwd: process.cwd(), env: gitEnv() }
|
|
660
|
+
)
|
|
661
|
+
);
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
test('monorepo port guide can wait for conflict resolution and finish the port', async (t) => {
|
|
665
|
+
const root = await withTempRoot(t);
|
|
666
|
+
const target = join(root, 'target-mono');
|
|
667
|
+
const sourceCli = join(root, 'source-cli');
|
|
668
|
+
|
|
669
|
+
// Target monorepo stub with cli/hello.txt="value=target".
|
|
670
|
+
await mkdir(target, { recursive: true });
|
|
671
|
+
await run('git', ['init', '-q'], { cwd: target, env: gitEnv() });
|
|
672
|
+
await run('git', ['checkout', '-q', '-b', 'main'], { cwd: target, env: gitEnv() });
|
|
673
|
+
await mkdir(join(target, 'expo-app'), { recursive: true });
|
|
674
|
+
await mkdir(join(target, 'cli'), { recursive: true });
|
|
675
|
+
await mkdir(join(target, 'server'), { recursive: true });
|
|
676
|
+
await writeFile(join(target, 'expo-app', 'package.json'), '{}\n', 'utf-8');
|
|
677
|
+
await writeFile(join(target, 'cli', 'package.json'), '{}\n', 'utf-8');
|
|
678
|
+
await writeFile(join(target, 'server', 'package.json'), '{}\n', 'utf-8');
|
|
679
|
+
await writeFile(join(target, 'cli', 'hello.txt'), 'value=target\n', 'utf-8');
|
|
680
|
+
await run('git', ['add', '.'], { cwd: target, env: gitEnv() });
|
|
681
|
+
await run('git', ['commit', '-q', '-m', 'chore: init monorepo'], { cwd: target, env: gitEnv() });
|
|
682
|
+
|
|
683
|
+
// Source CLI repo: keep main at base commit, then branch for the change.
|
|
684
|
+
await mkdir(sourceCli, { recursive: true });
|
|
685
|
+
await run('git', ['init', '-q'], { cwd: sourceCli, env: gitEnv() });
|
|
686
|
+
await run('git', ['checkout', '-q', '-b', 'main'], { cwd: sourceCli, env: gitEnv() });
|
|
687
|
+
await writeFile(join(sourceCli, 'package.json'), '{}\n', 'utf-8');
|
|
688
|
+
await writeFile(join(sourceCli, 'hello.txt'), 'value=base\n', 'utf-8');
|
|
689
|
+
await run('git', ['add', '.'], { cwd: sourceCli, env: gitEnv() });
|
|
690
|
+
await run('git', ['commit', '-q', '-m', 'chore: init cli'], { cwd: sourceCli, env: gitEnv() });
|
|
691
|
+
await run('git', ['checkout', '-q', '-b', 'feature'], { cwd: sourceCli, env: gitEnv() });
|
|
692
|
+
await writeFile(join(sourceCli, 'hello.txt'), 'value=source\n', 'utf-8');
|
|
693
|
+
await run('git', ['add', '.'], { cwd: sourceCli, env: gitEnv() });
|
|
694
|
+
await run('git', ['commit', '-q', '-m', 'feat: update hello'], { cwd: sourceCli, env: gitEnv() });
|
|
695
|
+
|
|
696
|
+
// Run guide in "test TTY" mode so it can prompt even under non-interactive test runners.
|
|
697
|
+
const scriptPath = join(process.cwd(), 'scripts', 'monorepo.mjs');
|
|
698
|
+
const inputLines = [
|
|
699
|
+
target, // Target monorepo path
|
|
700
|
+
'main', // Target base ref
|
|
701
|
+
'port/test-guide', // New branch name
|
|
702
|
+
'1', // Use 3-way merge: yes
|
|
703
|
+
'', // Path to old happy (skip)
|
|
704
|
+
sourceCli, // Path to old happy-cli
|
|
705
|
+
'main', // old happy-cli base ref
|
|
706
|
+
'', // Path to old happy-server (skip)
|
|
707
|
+
];
|
|
708
|
+
|
|
709
|
+
const child = spawn(
|
|
710
|
+
process.execPath,
|
|
711
|
+
[scriptPath, 'port', 'guide'],
|
|
712
|
+
{ cwd: process.cwd(), env: { ...gitEnv(), HAPPY_STACKS_TEST_TTY: '1' }, stdio: ['pipe', 'pipe', 'pipe'] }
|
|
713
|
+
);
|
|
714
|
+
|
|
715
|
+
let out = '';
|
|
716
|
+
let err = '';
|
|
717
|
+
let conflictSeen = false;
|
|
718
|
+
let finished = false;
|
|
719
|
+
let exitCode = null;
|
|
720
|
+
const waitFor = async (predicate, timeoutMs) => {
|
|
721
|
+
const started = Date.now();
|
|
722
|
+
while (Date.now() - started < timeoutMs) {
|
|
723
|
+
if (predicate()) return;
|
|
724
|
+
// eslint-disable-next-line no-await-in-loop
|
|
725
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
726
|
+
}
|
|
727
|
+
throw new Error(`timeout waiting for condition\nstdout:\n${out}\nstderr:\n${err}`);
|
|
728
|
+
};
|
|
729
|
+
|
|
730
|
+
child.stdout?.on('data', (d) => (out += d.toString()));
|
|
731
|
+
child.stderr?.on('data', (d) => (err += d.toString()));
|
|
732
|
+
child.on('exit', (code) => {
|
|
733
|
+
finished = true;
|
|
734
|
+
exitCode = code;
|
|
735
|
+
});
|
|
736
|
+
|
|
737
|
+
const sendLine = (line) => child.stdin?.write(String(line) + '\n');
|
|
738
|
+
|
|
739
|
+
// Feed the wizard answers step-by-step (readline can be picky under non-tty runners).
|
|
740
|
+
await waitFor(() => out.includes('Target monorepo path:'), 5_000);
|
|
741
|
+
sendLine(inputLines[0]);
|
|
742
|
+
await waitFor(() => out.includes('Target base ref:'), 5_000);
|
|
743
|
+
sendLine(inputLines[1]);
|
|
744
|
+
await waitFor(() => out.includes('New branch name:'), 5_000);
|
|
745
|
+
sendLine(inputLines[2]);
|
|
746
|
+
await waitFor(() => out.includes('Use 3-way merge'), 5_000);
|
|
747
|
+
sendLine(inputLines[3]);
|
|
748
|
+
await waitFor(() => out.includes('Path to old happy repo'), 5_000);
|
|
749
|
+
sendLine(inputLines[4]);
|
|
750
|
+
await waitFor(() => out.includes('Path to old happy-cli repo'), 5_000);
|
|
751
|
+
sendLine(inputLines[5]);
|
|
752
|
+
await waitFor(() => out.includes('old happy-cli base ref'), 5_000);
|
|
753
|
+
sendLine(inputLines[6]);
|
|
754
|
+
await waitFor(() => out.includes('Path to old happy-server repo'), 5_000);
|
|
755
|
+
sendLine(inputLines[7]);
|
|
756
|
+
|
|
757
|
+
// Wait for the guide to detect a conflict and start waiting for user action.
|
|
758
|
+
await waitFor(() => out.includes('guide: conflict detected') || out.includes('guide: waiting for conflict resolution'), 10_000);
|
|
759
|
+
conflictSeen = true;
|
|
760
|
+
|
|
761
|
+
// Wait until the guide is actually prompting for the action.
|
|
762
|
+
await waitFor(() => out.includes('Pick [1-5] (default: 1):'), 10_000);
|
|
763
|
+
|
|
764
|
+
// Resolve conflict in target repo by choosing value=source and staging.
|
|
765
|
+
await writeFile(join(target, 'cli', 'hello.txt'), 'value=source\n', 'utf-8');
|
|
766
|
+
await run('git', ['add', 'cli/hello.txt'], { cwd: target, env: gitEnv() });
|
|
767
|
+
|
|
768
|
+
// Tell the guide to continue.
|
|
769
|
+
sendLine('');
|
|
770
|
+
|
|
771
|
+
await waitFor(() => finished, 20_000);
|
|
772
|
+
assert.ok(conflictSeen, `expected conflict handling markers\nstdout:\n${out}\nstderr:\n${err}`);
|
|
773
|
+
assert.ok(out.includes('guide complete') || out.includes('port complete'), `expected completion output\nstdout:\n${out}\nstderr:\n${err}`);
|
|
774
|
+
assert.equal(exitCode, 0, `expected guide to exit 0\nstdout:\n${out}\nstderr:\n${err}`);
|
|
775
|
+
|
|
776
|
+
const content = (await readFile(join(target, 'cli', 'hello.txt'), 'utf-8')).toString();
|
|
777
|
+
assert.equal(content, 'value=source\n');
|
|
778
|
+
});
|
|
779
|
+
|
|
780
|
+
test('monorepo port works via bin/happys.mjs entrypoint (CLI registry end-to-end)', async (t) => {
|
|
781
|
+
const root = await withTempRoot(t);
|
|
782
|
+
const target = join(root, 'target-mono');
|
|
783
|
+
const sourceCli = join(root, 'source-cli');
|
|
784
|
+
|
|
785
|
+
// Target monorepo stub
|
|
786
|
+
await mkdir(target, { recursive: true });
|
|
787
|
+
await run('git', ['init', '-q'], { cwd: target, env: gitEnv() });
|
|
788
|
+
await run('git', ['checkout', '-q', '-b', 'main'], { cwd: target, env: gitEnv() });
|
|
789
|
+
await mkdir(join(target, 'expo-app'), { recursive: true });
|
|
790
|
+
await mkdir(join(target, 'cli'), { recursive: true });
|
|
791
|
+
await mkdir(join(target, 'server'), { recursive: true });
|
|
792
|
+
await writeFile(join(target, 'expo-app', 'package.json'), '{}\n', 'utf-8');
|
|
793
|
+
await writeFile(join(target, 'cli', 'package.json'), '{}\n', 'utf-8');
|
|
794
|
+
await writeFile(join(target, 'server', 'package.json'), '{}\n', 'utf-8');
|
|
795
|
+
await writeFile(join(target, 'cli', 'hello.txt'), 'v1\n', 'utf-8');
|
|
796
|
+
await run('git', ['add', '.'], { cwd: target, env: gitEnv() });
|
|
797
|
+
await run('git', ['commit', '-q', '-m', 'chore: init monorepo'], { cwd: target, env: gitEnv() });
|
|
798
|
+
|
|
799
|
+
// Source CLI repo with one change commit
|
|
800
|
+
await mkdir(sourceCli, { recursive: true });
|
|
801
|
+
await run('git', ['init', '-q'], { cwd: sourceCli, env: gitEnv() });
|
|
802
|
+
await run('git', ['checkout', '-q', '-b', 'main'], { cwd: sourceCli, env: gitEnv() });
|
|
803
|
+
await writeFile(join(sourceCli, 'package.json'), '{}\n', 'utf-8');
|
|
804
|
+
await writeFile(join(sourceCli, 'hello.txt'), 'v1\n', 'utf-8');
|
|
805
|
+
await run('git', ['add', '.'], { cwd: sourceCli, env: gitEnv() });
|
|
806
|
+
await run('git', ['commit', '-q', '-m', 'chore: init cli'], { cwd: sourceCli, env: gitEnv() });
|
|
807
|
+
const base = (await runCapture('git', ['rev-parse', 'HEAD'], { cwd: sourceCli, env: gitEnv() })).trim();
|
|
808
|
+
await writeFile(join(sourceCli, 'hello.txt'), 'v2\n', 'utf-8');
|
|
809
|
+
await run('git', ['add', '.'], { cwd: sourceCli, env: gitEnv() });
|
|
810
|
+
await run('git', ['commit', '-q', '-m', 'feat: update hello'], { cwd: sourceCli, env: gitEnv() });
|
|
811
|
+
|
|
812
|
+
const env = { ...gitEnv(), HAPPY_STACKS_HOME_DIR: join(root, 'home') };
|
|
813
|
+
const out = await runCapture(
|
|
814
|
+
process.execPath,
|
|
815
|
+
[
|
|
816
|
+
join(process.cwd(), 'bin', 'happys.mjs'),
|
|
817
|
+
'monorepo',
|
|
818
|
+
'port',
|
|
819
|
+
`--target=${target}`,
|
|
820
|
+
`--branch=port/test-happys`,
|
|
821
|
+
'--base=main',
|
|
822
|
+
`--from-happy-cli=${sourceCli}`,
|
|
823
|
+
`--from-happy-cli-base=${base}`,
|
|
824
|
+
'--json',
|
|
825
|
+
],
|
|
826
|
+
{ cwd: process.cwd(), env }
|
|
827
|
+
);
|
|
828
|
+
|
|
829
|
+
const parsed = JSON.parse(out.trim());
|
|
830
|
+
assert.equal(parsed.ok, true);
|
|
831
|
+
|
|
832
|
+
const content = (await readFile(join(target, 'cli', 'hello.txt'), 'utf-8')).toString();
|
|
833
|
+
assert.equal(content, 'v2\n');
|
|
834
|
+
});
|
|
835
|
+
|
|
836
|
+
test('monorepo port can port multiple split repos into the same monorepo branch (including renames)', async (t) => {
|
|
837
|
+
const root = await withTempRoot(t);
|
|
838
|
+
const target = join(root, 'target-mono');
|
|
839
|
+
const sourceUi = join(root, 'source-happy');
|
|
840
|
+
const sourceCli = join(root, 'source-happy-cli');
|
|
841
|
+
const sourceServer = join(root, 'source-happy-server');
|
|
842
|
+
|
|
843
|
+
// Target monorepo stub seeded with base files for all three subdirs.
|
|
844
|
+
await mkdir(target, { recursive: true });
|
|
845
|
+
await run('git', ['init', '-q'], { cwd: target, env: gitEnv() });
|
|
846
|
+
await run('git', ['checkout', '-q', '-b', 'main'], { cwd: target, env: gitEnv() });
|
|
847
|
+
await mkdir(join(target, 'expo-app'), { recursive: true });
|
|
848
|
+
await mkdir(join(target, 'cli'), { recursive: true });
|
|
849
|
+
await mkdir(join(target, 'server'), { recursive: true });
|
|
850
|
+
await writeFile(join(target, 'expo-app', 'package.json'), '{}\n', 'utf-8');
|
|
851
|
+
await writeFile(join(target, 'cli', 'package.json'), '{}\n', 'utf-8');
|
|
852
|
+
await writeFile(join(target, 'server', 'package.json'), '{}\n', 'utf-8');
|
|
853
|
+
await writeFile(join(target, 'expo-app', 'hello.txt'), 'ui-v1\n', 'utf-8');
|
|
854
|
+
await writeFile(join(target, 'cli', 'hello.txt'), 'cli-v1\n', 'utf-8');
|
|
855
|
+
await writeFile(join(target, 'server', 'hello.txt'), 'srv-v1\n', 'utf-8');
|
|
856
|
+
await run('git', ['add', '.'], { cwd: target, env: gitEnv() });
|
|
857
|
+
await run('git', ['commit', '-q', '-m', 'chore: init monorepo'], { cwd: target, env: gitEnv() });
|
|
858
|
+
|
|
859
|
+
// UI repo: update hello + add extra
|
|
860
|
+
await mkdir(sourceUi, { recursive: true });
|
|
861
|
+
await run('git', ['init', '-q'], { cwd: sourceUi, env: gitEnv() });
|
|
862
|
+
await run('git', ['checkout', '-q', '-b', 'main'], { cwd: sourceUi, env: gitEnv() });
|
|
863
|
+
await writeFile(join(sourceUi, 'package.json'), '{}\n', 'utf-8');
|
|
864
|
+
await writeFile(join(sourceUi, 'hello.txt'), 'ui-v1\n', 'utf-8');
|
|
865
|
+
await run('git', ['add', '.'], { cwd: sourceUi, env: gitEnv() });
|
|
866
|
+
await run('git', ['commit', '-q', '-m', 'chore: init ui'], { cwd: sourceUi, env: gitEnv() });
|
|
867
|
+
const uiBase = (await runCapture('git', ['rev-parse', 'HEAD'], { cwd: sourceUi, env: gitEnv() })).trim();
|
|
868
|
+
await writeFile(join(sourceUi, 'hello.txt'), 'ui-v2\n', 'utf-8');
|
|
869
|
+
await writeFile(join(sourceUi, 'extra.txt'), 'extra-ui\n', 'utf-8');
|
|
870
|
+
await run('git', ['add', '.'], { cwd: sourceUi, env: gitEnv() });
|
|
871
|
+
await run('git', ['commit', '-q', '-m', 'feat: update ui + add extra'], { cwd: sourceUi, env: gitEnv() });
|
|
872
|
+
|
|
873
|
+
// CLI repo: rename hello -> greeting
|
|
874
|
+
await mkdir(sourceCli, { recursive: true });
|
|
875
|
+
await run('git', ['init', '-q'], { cwd: sourceCli, env: gitEnv() });
|
|
876
|
+
await run('git', ['checkout', '-q', '-b', 'main'], { cwd: sourceCli, env: gitEnv() });
|
|
877
|
+
await writeFile(join(sourceCli, 'package.json'), '{}\n', 'utf-8');
|
|
878
|
+
await writeFile(join(sourceCli, 'hello.txt'), 'cli-v1\n', 'utf-8');
|
|
879
|
+
await run('git', ['add', '.'], { cwd: sourceCli, env: gitEnv() });
|
|
880
|
+
await run('git', ['commit', '-q', '-m', 'chore: init cli'], { cwd: sourceCli, env: gitEnv() });
|
|
881
|
+
const cliBase = (await runCapture('git', ['rev-parse', 'HEAD'], { cwd: sourceCli, env: gitEnv() })).trim();
|
|
882
|
+
await run('git', ['mv', 'hello.txt', 'greeting.txt'], { cwd: sourceCli, env: gitEnv() });
|
|
883
|
+
await writeFile(join(sourceCli, 'greeting.txt'), 'cli-v2\n', 'utf-8');
|
|
884
|
+
await run('git', ['add', '.'], { cwd: sourceCli, env: gitEnv() });
|
|
885
|
+
await run('git', ['commit', '-q', '-m', 'feat: rename hello to greeting'], { cwd: sourceCli, env: gitEnv() });
|
|
886
|
+
|
|
887
|
+
// Server repo: add routes.txt
|
|
888
|
+
await mkdir(sourceServer, { recursive: true });
|
|
889
|
+
await run('git', ['init', '-q'], { cwd: sourceServer, env: gitEnv() });
|
|
890
|
+
await run('git', ['checkout', '-q', '-b', 'main'], { cwd: sourceServer, env: gitEnv() });
|
|
891
|
+
await writeFile(join(sourceServer, 'package.json'), '{}\n', 'utf-8');
|
|
892
|
+
await writeFile(join(sourceServer, 'hello.txt'), 'srv-v1\n', 'utf-8');
|
|
893
|
+
await run('git', ['add', '.'], { cwd: sourceServer, env: gitEnv() });
|
|
894
|
+
await run('git', ['commit', '-q', '-m', 'chore: init server'], { cwd: sourceServer, env: gitEnv() });
|
|
895
|
+
const serverBase = (await runCapture('git', ['rev-parse', 'HEAD'], { cwd: sourceServer, env: gitEnv() })).trim();
|
|
896
|
+
await writeFile(join(sourceServer, 'routes.txt'), 'routes\n', 'utf-8');
|
|
897
|
+
await run('git', ['add', '.'], { cwd: sourceServer, env: gitEnv() });
|
|
898
|
+
await run('git', ['commit', '-q', '-m', 'feat: add routes'], { cwd: sourceServer, env: gitEnv() });
|
|
899
|
+
|
|
900
|
+
const out = await runCapture(
|
|
901
|
+
process.execPath,
|
|
902
|
+
[
|
|
903
|
+
join(process.cwd(), 'scripts', 'monorepo.mjs'),
|
|
904
|
+
'port',
|
|
905
|
+
`--target=${target}`,
|
|
906
|
+
`--branch=port/test-multi`,
|
|
907
|
+
'--base=main',
|
|
908
|
+
'--3way',
|
|
909
|
+
'--json',
|
|
910
|
+
`--from-happy=${sourceUi}`,
|
|
911
|
+
`--from-happy-base=${uiBase}`,
|
|
912
|
+
`--from-happy-cli=${sourceCli}`,
|
|
913
|
+
`--from-happy-cli-base=${cliBase}`,
|
|
914
|
+
`--from-happy-server=${sourceServer}`,
|
|
915
|
+
`--from-happy-server-base=${serverBase}`,
|
|
916
|
+
],
|
|
917
|
+
{ cwd: process.cwd(), env: gitEnv() }
|
|
918
|
+
);
|
|
919
|
+
const parsed = JSON.parse(out.trim());
|
|
920
|
+
assert.equal(parsed.ok, true);
|
|
921
|
+
|
|
922
|
+
assert.equal((await readFile(join(target, 'expo-app', 'hello.txt'), 'utf-8')).toString(), 'ui-v2\n');
|
|
923
|
+
assert.equal((await readFile(join(target, 'expo-app', 'extra.txt'), 'utf-8')).toString(), 'extra-ui\n');
|
|
924
|
+
assert.equal((await readFile(join(target, 'cli', 'greeting.txt'), 'utf-8')).toString(), 'cli-v2\n');
|
|
925
|
+
await assert.rejects(async () => await readFile(join(target, 'cli', 'hello.txt'), 'utf-8'));
|
|
926
|
+
assert.equal((await readFile(join(target, 'server', 'routes.txt'), 'utf-8')).toString(), 'routes\n');
|
|
927
|
+
});
|
|
928
|
+
|
|
929
|
+
test('monorepo port guide quit leaves a plan; port continue resumes and completes after conflicts are resolved', async (t) => {
|
|
930
|
+
const root = await withTempRoot(t);
|
|
931
|
+
const target = join(root, 'target-mono');
|
|
932
|
+
const sourceCli = join(root, 'source-cli');
|
|
933
|
+
|
|
934
|
+
// Target monorepo stub with cli/hello.txt="value=target".
|
|
935
|
+
await mkdir(target, { recursive: true });
|
|
936
|
+
await run('git', ['init', '-q'], { cwd: target, env: gitEnv() });
|
|
937
|
+
await run('git', ['checkout', '-q', '-b', 'main'], { cwd: target, env: gitEnv() });
|
|
938
|
+
await mkdir(join(target, 'expo-app'), { recursive: true });
|
|
939
|
+
await mkdir(join(target, 'cli'), { recursive: true });
|
|
940
|
+
await mkdir(join(target, 'server'), { recursive: true });
|
|
941
|
+
await writeFile(join(target, 'expo-app', 'package.json'), '{}\n', 'utf-8');
|
|
942
|
+
await writeFile(join(target, 'cli', 'package.json'), '{}\n', 'utf-8');
|
|
943
|
+
await writeFile(join(target, 'server', 'package.json'), '{}\n', 'utf-8');
|
|
944
|
+
await writeFile(join(target, 'cli', 'hello.txt'), 'value=target\n', 'utf-8');
|
|
945
|
+
await run('git', ['add', '.'], { cwd: target, env: gitEnv() });
|
|
946
|
+
await run('git', ['commit', '-q', '-m', 'chore: init monorepo'], { cwd: target, env: gitEnv() });
|
|
947
|
+
|
|
948
|
+
// Source CLI repo: keep main at base, then create a feature branch with two commits.
|
|
949
|
+
await mkdir(sourceCli, { recursive: true });
|
|
950
|
+
await run('git', ['init', '-q'], { cwd: sourceCli, env: gitEnv() });
|
|
951
|
+
await run('git', ['checkout', '-q', '-b', 'main'], { cwd: sourceCli, env: gitEnv() });
|
|
952
|
+
await writeFile(join(sourceCli, 'package.json'), '{}\n', 'utf-8');
|
|
953
|
+
await writeFile(join(sourceCli, 'hello.txt'), 'value=base\n', 'utf-8');
|
|
954
|
+
await run('git', ['add', '.'], { cwd: sourceCli, env: gitEnv() });
|
|
955
|
+
await run('git', ['commit', '-q', '-m', 'chore: init cli'], { cwd: sourceCli, env: gitEnv() });
|
|
956
|
+
await run('git', ['checkout', '-q', '-b', 'feature'], { cwd: sourceCli, env: gitEnv() });
|
|
957
|
+
await writeFile(join(sourceCli, 'hello.txt'), 'value=source\n', 'utf-8');
|
|
958
|
+
await run('git', ['add', '.'], { cwd: sourceCli, env: gitEnv() });
|
|
959
|
+
await run('git', ['commit', '-q', '-m', 'feat: update hello'], { cwd: sourceCli, env: gitEnv() });
|
|
960
|
+
await writeFile(join(sourceCli, 'extra.txt'), 'extra\n', 'utf-8');
|
|
961
|
+
await run('git', ['add', '.'], { cwd: sourceCli, env: gitEnv() });
|
|
962
|
+
await run('git', ['commit', '-q', '-m', 'feat: add extra'], { cwd: sourceCli, env: gitEnv() });
|
|
963
|
+
|
|
964
|
+
const scriptPath = join(process.cwd(), 'scripts', 'monorepo.mjs');
|
|
965
|
+
const child = spawn(
|
|
966
|
+
process.execPath,
|
|
967
|
+
[scriptPath, 'port', 'guide'],
|
|
968
|
+
{ cwd: process.cwd(), env: { ...gitEnv(), HAPPY_STACKS_TEST_TTY: '1' }, stdio: ['pipe', 'pipe', 'pipe'] }
|
|
969
|
+
);
|
|
970
|
+
|
|
971
|
+
let out = '';
|
|
972
|
+
let err = '';
|
|
973
|
+
let exitCode = null;
|
|
974
|
+
const waitFor = async (predicate, timeoutMs) => {
|
|
975
|
+
const started = Date.now();
|
|
976
|
+
while (Date.now() - started < timeoutMs) {
|
|
977
|
+
if (predicate()) return;
|
|
978
|
+
// eslint-disable-next-line no-await-in-loop
|
|
979
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
980
|
+
}
|
|
981
|
+
throw new Error(`timeout waiting for condition\nstdout:\n${out}\nstderr:\n${err}`);
|
|
982
|
+
};
|
|
983
|
+
|
|
984
|
+
child.stdout?.on('data', (d) => (out += d.toString()));
|
|
985
|
+
child.stderr?.on('data', (d) => (err += d.toString()));
|
|
986
|
+
child.on('exit', (code) => {
|
|
987
|
+
exitCode = code;
|
|
988
|
+
});
|
|
989
|
+
const sendLine = (line) => child.stdin?.write(String(line) + '\n');
|
|
990
|
+
|
|
991
|
+
// Feed the wizard answers step-by-step.
|
|
992
|
+
await waitFor(() => out.includes('Target monorepo path:'), 5_000);
|
|
993
|
+
sendLine(target);
|
|
994
|
+
await waitFor(() => out.includes('Target base ref:'), 5_000);
|
|
995
|
+
sendLine('main');
|
|
996
|
+
await waitFor(() => out.includes('New branch name:'), 5_000);
|
|
997
|
+
sendLine('port/test-guide-quit');
|
|
998
|
+
await waitFor(() => out.includes('Use 3-way merge'), 5_000);
|
|
999
|
+
sendLine('1');
|
|
1000
|
+
await waitFor(() => out.includes('Path to old happy repo'), 5_000);
|
|
1001
|
+
sendLine('');
|
|
1002
|
+
await waitFor(() => out.includes('Path to old happy-cli repo'), 5_000);
|
|
1003
|
+
sendLine(sourceCli);
|
|
1004
|
+
await waitFor(() => out.includes('old happy-cli base ref'), 5_000);
|
|
1005
|
+
sendLine('main');
|
|
1006
|
+
await waitFor(() => out.includes('Path to old happy-server repo'), 5_000);
|
|
1007
|
+
sendLine('');
|
|
1008
|
+
|
|
1009
|
+
// Wait for conflict prompt, then quit.
|
|
1010
|
+
await waitFor(() => out.includes('guide: waiting for conflict resolution') || out.includes('guide: conflict detected'), 10_000);
|
|
1011
|
+
await waitFor(() => out.includes('Pick [1-5] (default: 1):'), 10_000);
|
|
1012
|
+
sendLine('5');
|
|
1013
|
+
await waitFor(() => exitCode !== null, 10_000);
|
|
1014
|
+
assert.notEqual(exitCode, 0, `expected guide to exit non-zero on quit\nstdout:\n${out}\nstderr:\n${err}`);
|
|
1015
|
+
|
|
1016
|
+
// Ensure the plan exists.
|
|
1017
|
+
const planRel = (await runCapture('git', ['rev-parse', '--git-path', 'happy-stacks/monorepo-port-plan.json'], { cwd: target, env: gitEnv() })).trim();
|
|
1018
|
+
const planAbs = planRel.startsWith('/') ? planRel : join(target, planRel);
|
|
1019
|
+
assert.equal(await readFile(planAbs, 'utf-8').then(() => true), true);
|
|
1020
|
+
|
|
1021
|
+
// Resolve + stage conflict.
|
|
1022
|
+
await writeFile(join(target, 'cli', 'hello.txt'), 'value=source\n', 'utf-8');
|
|
1023
|
+
await run('git', ['add', 'cli/hello.txt'], { cwd: target, env: gitEnv() });
|
|
1024
|
+
|
|
1025
|
+
// Continue should complete `git am` and then resume remaining patches from the plan (including extra.txt).
|
|
1026
|
+
const contOut = await runCapture(
|
|
1027
|
+
process.execPath,
|
|
1028
|
+
[scriptPath, 'port', 'continue', `--target=${target}`, '--json'],
|
|
1029
|
+
{ cwd: process.cwd(), env: gitEnv() }
|
|
1030
|
+
);
|
|
1031
|
+
const parsed = JSON.parse(contOut.trim());
|
|
1032
|
+
assert.equal(parsed.ok, true);
|
|
1033
|
+
assert.equal(parsed.inProgress, false);
|
|
1034
|
+
|
|
1035
|
+
assert.equal((await readFile(join(target, 'cli', 'hello.txt'), 'utf-8')).toString(), 'value=source\n');
|
|
1036
|
+
assert.equal((await readFile(join(target, 'cli', 'extra.txt'), 'utf-8')).toString(), 'extra\n');
|
|
1037
|
+
|
|
1038
|
+
await assert.rejects(async () => await readFile(planAbs, 'utf-8'));
|
|
1039
|
+
});
|
|
1040
|
+
|
|
1041
|
+
test('monorepo port rejects when target repo is dirty', async (t) => {
|
|
1042
|
+
const root = await withTempRoot(t);
|
|
1043
|
+
const target = join(root, 'target-mono');
|
|
1044
|
+
const sourceCli = join(root, 'source-cli');
|
|
1045
|
+
const env = gitEnv();
|
|
1046
|
+
|
|
1047
|
+
await initMonorepoStub({ dir: target, env, seed: { 'cli/hello.txt': 'v1\n' } });
|
|
1048
|
+
const base = await initSplitRepoStub({ dir: sourceCli, env, name: 'cli', seed: { 'hello.txt': 'v1\n' } });
|
|
1049
|
+
await writeFile(join(sourceCli, 'hello.txt'), 'v2\n', 'utf-8');
|
|
1050
|
+
await run('git', ['add', '.'], { cwd: sourceCli, env });
|
|
1051
|
+
await run('git', ['commit', '-q', '-m', 'feat: update hello'], { cwd: sourceCli, env });
|
|
1052
|
+
|
|
1053
|
+
// Make target dirty.
|
|
1054
|
+
await writeFile(join(target, 'cli', 'uncommitted.txt'), 'dirty\n', 'utf-8');
|
|
1055
|
+
|
|
1056
|
+
await assert.rejects(async () => {
|
|
1057
|
+
await runCapture(
|
|
1058
|
+
process.execPath,
|
|
1059
|
+
[
|
|
1060
|
+
join(process.cwd(), 'scripts', 'monorepo.mjs'),
|
|
1061
|
+
'port',
|
|
1062
|
+
`--target=${target}`,
|
|
1063
|
+
`--branch=port/test-dirty`,
|
|
1064
|
+
'--base=main',
|
|
1065
|
+
`--from-happy-cli=${sourceCli}`,
|
|
1066
|
+
`--from-happy-cli-base=${base}`,
|
|
1067
|
+
'--json',
|
|
1068
|
+
],
|
|
1069
|
+
{ cwd: process.cwd(), env }
|
|
1070
|
+
);
|
|
1071
|
+
});
|
|
1072
|
+
});
|
|
1073
|
+
|
|
1074
|
+
test('monorepo port rejects invalid target repo layout', async (t) => {
|
|
1075
|
+
const root = await withTempRoot(t);
|
|
1076
|
+
const target = join(root, 'not-a-mono');
|
|
1077
|
+
const sourceCli = join(root, 'source-cli');
|
|
1078
|
+
const env = gitEnv();
|
|
1079
|
+
|
|
1080
|
+
await mkdir(target, { recursive: true });
|
|
1081
|
+
await run('git', ['init', '-q'], { cwd: target, env });
|
|
1082
|
+
await run('git', ['checkout', '-q', '-b', 'main'], { cwd: target, env });
|
|
1083
|
+
await writeFile(join(target, 'README.md'), 'not a monorepo\n', 'utf-8');
|
|
1084
|
+
await run('git', ['add', '.'], { cwd: target, env });
|
|
1085
|
+
await run('git', ['commit', '-q', '-m', 'chore: init'], { cwd: target, env });
|
|
1086
|
+
|
|
1087
|
+
const base = await initSplitRepoStub({ dir: sourceCli, env, name: 'cli', seed: { 'hello.txt': 'v1\n' } });
|
|
1088
|
+
|
|
1089
|
+
await assert.rejects(async () => {
|
|
1090
|
+
await runCapture(
|
|
1091
|
+
process.execPath,
|
|
1092
|
+
[
|
|
1093
|
+
join(process.cwd(), 'scripts', 'monorepo.mjs'),
|
|
1094
|
+
'port',
|
|
1095
|
+
`--target=${target}`,
|
|
1096
|
+
`--branch=port/test-invalid-target`,
|
|
1097
|
+
'--base=main',
|
|
1098
|
+
`--from-happy-cli=${sourceCli}`,
|
|
1099
|
+
`--from-happy-cli-base=${base}`,
|
|
1100
|
+
],
|
|
1101
|
+
{ cwd: process.cwd(), env }
|
|
1102
|
+
);
|
|
1103
|
+
});
|
|
1104
|
+
});
|
|
1105
|
+
|
|
1106
|
+
test('monorepo port validates incompatible flags with --onto-current', async (t) => {
|
|
1107
|
+
const root = await withTempRoot(t);
|
|
1108
|
+
const target = join(root, 'target-mono');
|
|
1109
|
+
const sourceCli = join(root, 'source-cli');
|
|
1110
|
+
const env = gitEnv();
|
|
1111
|
+
|
|
1112
|
+
await initMonorepoStub({ dir: target, env, seed: { 'cli/hello.txt': 'v1\n' } });
|
|
1113
|
+
const base = await initSplitRepoStub({ dir: sourceCli, env, name: 'cli', seed: { 'hello.txt': 'v1\n' } });
|
|
1114
|
+
|
|
1115
|
+
await assert.rejects(async () => {
|
|
1116
|
+
await runCapture(
|
|
1117
|
+
process.execPath,
|
|
1118
|
+
[
|
|
1119
|
+
join(process.cwd(), 'scripts', 'monorepo.mjs'),
|
|
1120
|
+
'port',
|
|
1121
|
+
`--target=${target}`,
|
|
1122
|
+
'--onto-current',
|
|
1123
|
+
`--branch=port/nope`,
|
|
1124
|
+
`--from-happy-cli=${sourceCli}`,
|
|
1125
|
+
`--from-happy-cli-base=${base}`,
|
|
1126
|
+
],
|
|
1127
|
+
{ cwd: process.cwd(), env }
|
|
1128
|
+
);
|
|
1129
|
+
});
|
|
1130
|
+
|
|
1131
|
+
await assert.rejects(async () => {
|
|
1132
|
+
await runCapture(
|
|
1133
|
+
process.execPath,
|
|
1134
|
+
[
|
|
1135
|
+
join(process.cwd(), 'scripts', 'monorepo.mjs'),
|
|
1136
|
+
'port',
|
|
1137
|
+
`--target=${target}`,
|
|
1138
|
+
'--onto-current',
|
|
1139
|
+
`--base=main`,
|
|
1140
|
+
`--from-happy-cli=${sourceCli}`,
|
|
1141
|
+
`--from-happy-cli-base=${base}`,
|
|
1142
|
+
],
|
|
1143
|
+
{ cwd: process.cwd(), env }
|
|
1144
|
+
);
|
|
1145
|
+
});
|
|
1146
|
+
});
|
|
1147
|
+
|
|
1148
|
+
test('monorepo port succeeds with an empty commit range (no patches)', async (t) => {
|
|
1149
|
+
const root = await withTempRoot(t);
|
|
1150
|
+
const target = join(root, 'target-mono');
|
|
1151
|
+
const sourceCli = join(root, 'source-cli');
|
|
1152
|
+
const env = gitEnv();
|
|
1153
|
+
|
|
1154
|
+
await initMonorepoStub({ dir: target, env, seed: { 'cli/hello.txt': 'v1\n' } });
|
|
1155
|
+
const base = await initSplitRepoStub({ dir: sourceCli, env, name: 'cli', seed: { 'hello.txt': 'v1\n' } });
|
|
1156
|
+
|
|
1157
|
+
const out = await runCapture(
|
|
1158
|
+
process.execPath,
|
|
1159
|
+
[
|
|
1160
|
+
join(process.cwd(), 'scripts', 'monorepo.mjs'),
|
|
1161
|
+
'port',
|
|
1162
|
+
`--target=${target}`,
|
|
1163
|
+
`--branch=port/empty-range`,
|
|
1164
|
+
'--base=main',
|
|
1165
|
+
`--from-happy-cli=${sourceCli}`,
|
|
1166
|
+
`--from-happy-cli-base=${base}`,
|
|
1167
|
+
'--json',
|
|
1168
|
+
],
|
|
1169
|
+
{ cwd: process.cwd(), env }
|
|
1170
|
+
);
|
|
1171
|
+
|
|
1172
|
+
const parsed = JSON.parse(out.trim());
|
|
1173
|
+
assert.equal(parsed.ok, true);
|
|
1174
|
+
assert.equal(parsed.results[0].patches, 0);
|
|
1175
|
+
});
|
|
1176
|
+
|
|
1177
|
+
test('monorepo port skips already-applied patches even without --skip-applied', async (t) => {
|
|
1178
|
+
const root = await withTempRoot(t);
|
|
1179
|
+
const target = join(root, 'target-mono');
|
|
1180
|
+
const sourceCli = join(root, 'source-cli');
|
|
1181
|
+
const env = gitEnv();
|
|
1182
|
+
|
|
1183
|
+
await initMonorepoStub({ dir: target, env, seed: { 'cli/hello.txt': 'v2\n' } });
|
|
1184
|
+
const base = await initSplitRepoStub({ dir: sourceCli, env, name: 'cli', seed: { 'hello.txt': 'v1\n' } });
|
|
1185
|
+
await writeFile(join(sourceCli, 'hello.txt'), 'v2\n', 'utf-8');
|
|
1186
|
+
await run('git', ['add', '.'], { cwd: sourceCli, env });
|
|
1187
|
+
await run('git', ['commit', '-q', '-m', 'feat: update hello'], { cwd: sourceCli, env });
|
|
1188
|
+
|
|
1189
|
+
const out = await runCapture(
|
|
1190
|
+
process.execPath,
|
|
1191
|
+
[
|
|
1192
|
+
join(process.cwd(), 'scripts', 'monorepo.mjs'),
|
|
1193
|
+
'port',
|
|
1194
|
+
`--target=${target}`,
|
|
1195
|
+
`--branch=port/skip-applied-default`,
|
|
1196
|
+
'--base=main',
|
|
1197
|
+
`--from-happy-cli=${sourceCli}`,
|
|
1198
|
+
`--from-happy-cli-base=${base}`,
|
|
1199
|
+
'--json',
|
|
1200
|
+
],
|
|
1201
|
+
{ cwd: process.cwd(), env }
|
|
1202
|
+
);
|
|
1203
|
+
const parsed = JSON.parse(out.trim());
|
|
1204
|
+
assert.equal(parsed.ok, true);
|
|
1205
|
+
assert.ok(parsed.results[0].skippedAlreadyApplied >= 1);
|
|
1206
|
+
assert.equal((await readFile(join(target, 'cli', 'hello.txt'), 'utf-8')).toString(), 'v2\n');
|
|
1207
|
+
});
|
|
1208
|
+
|
|
1209
|
+
test('monorepo port auto-skips identical multi-file new-file patches when all files already exist identically', async (t) => {
|
|
1210
|
+
const root = await withTempRoot(t);
|
|
1211
|
+
const target = join(root, 'target-mono');
|
|
1212
|
+
const sourceCli = join(root, 'source-cli');
|
|
1213
|
+
const env = gitEnv();
|
|
1214
|
+
|
|
1215
|
+
await initMonorepoStub({
|
|
1216
|
+
dir: target,
|
|
1217
|
+
env,
|
|
1218
|
+
seed: { 'cli/a.txt': 'same-a\n', 'cli/b.txt': 'same-b\n' },
|
|
1219
|
+
});
|
|
1220
|
+
|
|
1221
|
+
// Source: base commit with no a/b, then one commit adding both.
|
|
1222
|
+
const base = await initSplitRepoStub({ dir: sourceCli, env, name: 'cli', seed: {} });
|
|
1223
|
+
await writeFile(join(sourceCli, 'a.txt'), 'same-a\n', 'utf-8');
|
|
1224
|
+
await writeFile(join(sourceCli, 'b.txt'), 'same-b\n', 'utf-8');
|
|
1225
|
+
await run('git', ['add', '.'], { cwd: sourceCli, env });
|
|
1226
|
+
await run('git', ['commit', '-q', '-m', 'feat: add a + b'], { cwd: sourceCli, env });
|
|
1227
|
+
|
|
1228
|
+
const out = await runCapture(
|
|
1229
|
+
process.execPath,
|
|
1230
|
+
[
|
|
1231
|
+
join(process.cwd(), 'scripts', 'monorepo.mjs'),
|
|
1232
|
+
'port',
|
|
1233
|
+
`--target=${target}`,
|
|
1234
|
+
`--branch=port/identical-multi-newfiles`,
|
|
1235
|
+
'--base=main',
|
|
1236
|
+
`--from-happy-cli=${sourceCli}`,
|
|
1237
|
+
`--from-happy-cli-base=${base}`,
|
|
1238
|
+
'--json',
|
|
1239
|
+
],
|
|
1240
|
+
{ cwd: process.cwd(), env }
|
|
1241
|
+
);
|
|
1242
|
+
|
|
1243
|
+
const parsed = JSON.parse(out.trim());
|
|
1244
|
+
assert.equal(parsed.ok, true);
|
|
1245
|
+
assert.equal(parsed.results[0].appliedPatches, 0);
|
|
1246
|
+
assert.equal(parsed.results[0].skippedAlreadyExistsIdentical, 1);
|
|
1247
|
+
});
|
|
1248
|
+
|
|
1249
|
+
test('monorepo port does not auto-skip new-file patch when the target file exists with different content', async (t) => {
|
|
1250
|
+
const root = await withTempRoot(t);
|
|
1251
|
+
const target = join(root, 'target-mono');
|
|
1252
|
+
const sourceCli = join(root, 'source-cli');
|
|
1253
|
+
const env = gitEnv();
|
|
1254
|
+
|
|
1255
|
+
await initMonorepoStub({ dir: target, env, seed: { 'cli/newfile.txt': 'target\n' } });
|
|
1256
|
+
const base = await initSplitRepoStub({ dir: sourceCli, env, name: 'cli', seed: {} });
|
|
1257
|
+
await writeFile(join(sourceCli, 'newfile.txt'), 'source\n', 'utf-8');
|
|
1258
|
+
await run('git', ['add', '.'], { cwd: sourceCli, env });
|
|
1259
|
+
await run('git', ['commit', '-q', '-m', 'feat: add newfile'], { cwd: sourceCli, env });
|
|
1260
|
+
|
|
1261
|
+
const out = await runCapture(
|
|
1262
|
+
process.execPath,
|
|
1263
|
+
[
|
|
1264
|
+
join(process.cwd(), 'scripts', 'monorepo.mjs'),
|
|
1265
|
+
'port',
|
|
1266
|
+
`--target=${target}`,
|
|
1267
|
+
`--branch=port/newfile-differs`,
|
|
1268
|
+
'--base=main',
|
|
1269
|
+
'--continue-on-failure',
|
|
1270
|
+
`--from-happy-cli=${sourceCli}`,
|
|
1271
|
+
`--from-happy-cli-base=${base}`,
|
|
1272
|
+
'--json',
|
|
1273
|
+
],
|
|
1274
|
+
{ cwd: process.cwd(), env }
|
|
1275
|
+
);
|
|
1276
|
+
const parsed = JSON.parse(out.trim());
|
|
1277
|
+
assert.equal(parsed.ok, false);
|
|
1278
|
+
assert.equal(parsed.results[0].failedPatches, 1);
|
|
1279
|
+
assert.equal(parsed.results[0].skippedAlreadyExistsIdentical, 0);
|
|
1280
|
+
});
|
|
1281
|
+
|
|
1282
|
+
test('monorepo port reports "git am already in progress" even when the target worktree is dirty', async (t) => {
|
|
1283
|
+
const root = await withTempRoot(t);
|
|
1284
|
+
const target = join(root, 'target-mono');
|
|
1285
|
+
const sourceCli = join(root, 'source-cli');
|
|
1286
|
+
const env = gitEnv();
|
|
1287
|
+
|
|
1288
|
+
await initMonorepoStub({ dir: target, env, seed: { 'cli/hello.txt': 'value=target\n' } });
|
|
1289
|
+
|
|
1290
|
+
// Source CLI repo: base differs from target to force an am conflict.
|
|
1291
|
+
await mkdir(sourceCli, { recursive: true });
|
|
1292
|
+
await run('git', ['init', '-q'], { cwd: sourceCli, env });
|
|
1293
|
+
await run('git', ['checkout', '-q', '-b', 'main'], { cwd: sourceCli, env });
|
|
1294
|
+
await writeFile(join(sourceCli, 'package.json'), '{}\n', 'utf-8');
|
|
1295
|
+
await writeFile(join(sourceCli, 'hello.txt'), 'value=base\n', 'utf-8');
|
|
1296
|
+
await run('git', ['add', '.'], { cwd: sourceCli, env });
|
|
1297
|
+
await run('git', ['commit', '-q', '-m', 'chore: init cli'], { cwd: sourceCli, env });
|
|
1298
|
+
const base = (await runCapture('git', ['rev-parse', 'HEAD'], { cwd: sourceCli, env })).trim();
|
|
1299
|
+
await writeFile(join(sourceCli, 'hello.txt'), 'value=source\n', 'utf-8');
|
|
1300
|
+
await run('git', ['add', '.'], { cwd: sourceCli, env });
|
|
1301
|
+
await run('git', ['commit', '-q', '-m', 'feat: update hello'], { cwd: sourceCli, env });
|
|
1302
|
+
|
|
1303
|
+
// Start a port that will stop with an am conflict (leaves git am state).
|
|
1304
|
+
await assert.rejects(async () => {
|
|
1305
|
+
await runCapture(
|
|
1306
|
+
process.execPath,
|
|
1307
|
+
[
|
|
1308
|
+
join(process.cwd(), 'scripts', 'monorepo.mjs'),
|
|
1309
|
+
'port',
|
|
1310
|
+
`--target=${target}`,
|
|
1311
|
+
`--branch=port/am-in-progress`,
|
|
1312
|
+
'--base=main',
|
|
1313
|
+
'--3way',
|
|
1314
|
+
`--from-happy-cli=${sourceCli}`,
|
|
1315
|
+
`--from-happy-cli-base=${base}`,
|
|
1316
|
+
],
|
|
1317
|
+
{ cwd: process.cwd(), env }
|
|
1318
|
+
);
|
|
1319
|
+
});
|
|
1320
|
+
|
|
1321
|
+
// Make the worktree dirty while am is in progress (this happens naturally for many conflicts,
|
|
1322
|
+
// but we force it here to ensure we prefer the more actionable "git am in progress" error).
|
|
1323
|
+
await writeFile(join(target, 'cli', 'dirty.txt'), 'dirty\n', 'utf-8');
|
|
1324
|
+
|
|
1325
|
+
// Re-running should complain specifically about the in-progress am.
|
|
1326
|
+
await assert.rejects(
|
|
1327
|
+
async () => {
|
|
1328
|
+
await runCapture(
|
|
1329
|
+
process.execPath,
|
|
1330
|
+
[
|
|
1331
|
+
join(process.cwd(), 'scripts', 'monorepo.mjs'),
|
|
1332
|
+
'port',
|
|
1333
|
+
`--target=${target}`,
|
|
1334
|
+
`--onto-current`,
|
|
1335
|
+
`--from-happy-cli=${sourceCli}`,
|
|
1336
|
+
`--from-happy-cli-base=${base}`,
|
|
1337
|
+
],
|
|
1338
|
+
{ cwd: process.cwd(), env }
|
|
1339
|
+
);
|
|
1340
|
+
},
|
|
1341
|
+
(err) => {
|
|
1342
|
+
const msg = String(err?.err ?? err?.message ?? err ?? '');
|
|
1343
|
+
assert.ok(msg.includes('git am operation is already in progress'), `expected git am in-progress error\n${msg}`);
|
|
1344
|
+
return true;
|
|
1345
|
+
}
|
|
1346
|
+
);
|
|
1347
|
+
});
|
|
1348
|
+
|
|
1349
|
+
test('monorepo port --dry-run does not create a branch or modify the target repo', async (t) => {
|
|
1350
|
+
const root = await withTempRoot(t);
|
|
1351
|
+
const target = join(root, 'target-mono');
|
|
1352
|
+
const sourceCli = join(root, 'source-cli');
|
|
1353
|
+
const env = gitEnv();
|
|
1354
|
+
|
|
1355
|
+
await initMonorepoStub({ dir: target, env, seed: { 'cli/hello.txt': 'v1\n' } });
|
|
1356
|
+
const base = await initSplitRepoStub({ dir: sourceCli, env, name: 'cli', seed: { 'hello.txt': 'v1\n' } });
|
|
1357
|
+
await writeFile(join(sourceCli, 'hello.txt'), 'v2\n', 'utf-8');
|
|
1358
|
+
await run('git', ['add', '.'], { cwd: sourceCli, env });
|
|
1359
|
+
await run('git', ['commit', '-q', '-m', 'feat: update hello'], { cwd: sourceCli, env });
|
|
1360
|
+
|
|
1361
|
+
const beforeHead = (await runCapture('git', ['rev-parse', 'HEAD'], { cwd: target, env })).trim();
|
|
1362
|
+
const beforeBranch = (await runCapture('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: target, env })).trim();
|
|
1363
|
+
|
|
1364
|
+
const out = await runCapture(
|
|
1365
|
+
process.execPath,
|
|
1366
|
+
[
|
|
1367
|
+
join(process.cwd(), 'scripts', 'monorepo.mjs'),
|
|
1368
|
+
'port',
|
|
1369
|
+
`--target=${target}`,
|
|
1370
|
+
'--dry-run',
|
|
1371
|
+
`--branch=port/dry-run`,
|
|
1372
|
+
`--from-happy-cli=${sourceCli}`,
|
|
1373
|
+
`--from-happy-cli-base=${base}`,
|
|
1374
|
+
'--json',
|
|
1375
|
+
],
|
|
1376
|
+
{ cwd: process.cwd(), env }
|
|
1377
|
+
);
|
|
1378
|
+
const parsed = JSON.parse(out.trim());
|
|
1379
|
+
assert.equal(parsed.ok, true);
|
|
1380
|
+
assert.equal(parsed.dryRun, true);
|
|
1381
|
+
|
|
1382
|
+
const afterHead = (await runCapture('git', ['rev-parse', 'HEAD'], { cwd: target, env })).trim();
|
|
1383
|
+
const afterBranch = (await runCapture('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: target, env })).trim();
|
|
1384
|
+
assert.equal(afterHead, beforeHead);
|
|
1385
|
+
assert.equal(afterBranch, beforeBranch);
|
|
1386
|
+
|
|
1387
|
+
const hasDryBranch = await runCapture('git', ['show-ref', '--verify', '--quiet', 'refs/heads/port/dry-run'], {
|
|
1388
|
+
cwd: target,
|
|
1389
|
+
env,
|
|
1390
|
+
})
|
|
1391
|
+
.then(() => true)
|
|
1392
|
+
.catch(() => false);
|
|
1393
|
+
assert.equal(hasDryBranch, false);
|
|
1394
|
+
|
|
1395
|
+
assert.equal((await readFile(join(target, 'cli', 'hello.txt'), 'utf-8')).toString(), 'v1\n');
|
|
1396
|
+
});
|
|
1397
|
+
|
|
1398
|
+
test('monorepo port rejects when --branch already exists in the target repo', async (t) => {
|
|
1399
|
+
const root = await withTempRoot(t);
|
|
1400
|
+
const target = join(root, 'target-mono');
|
|
1401
|
+
const sourceCli = join(root, 'source-cli');
|
|
1402
|
+
const env = gitEnv();
|
|
1403
|
+
|
|
1404
|
+
await initMonorepoStub({ dir: target, env, seed: { 'cli/hello.txt': 'v1\n' } });
|
|
1405
|
+
await run('git', ['checkout', '-q', '-b', 'port/existing'], { cwd: target, env });
|
|
1406
|
+
await run('git', ['checkout', '-q', 'main'], { cwd: target, env });
|
|
1407
|
+
|
|
1408
|
+
const base = await initSplitRepoStub({ dir: sourceCli, env, name: 'cli', seed: { 'hello.txt': 'v1\n' } });
|
|
1409
|
+
|
|
1410
|
+
await assert.rejects(async () => {
|
|
1411
|
+
await runCapture(
|
|
1412
|
+
process.execPath,
|
|
1413
|
+
[
|
|
1414
|
+
join(process.cwd(), 'scripts', 'monorepo.mjs'),
|
|
1415
|
+
'port',
|
|
1416
|
+
`--target=${target}`,
|
|
1417
|
+
`--branch=port/existing`,
|
|
1418
|
+
'--base=main',
|
|
1419
|
+
`--from-happy-cli=${sourceCli}`,
|
|
1420
|
+
`--from-happy-cli-base=${base}`,
|
|
1421
|
+
],
|
|
1422
|
+
{ cwd: process.cwd(), env }
|
|
1423
|
+
);
|
|
1424
|
+
});
|
|
1425
|
+
});
|
|
1426
|
+
|
|
1427
|
+
test('monorepo port can port from a non-HEAD ref (--from-happy-cli-ref) without changing the source repo checkout', async (t) => {
|
|
1428
|
+
const root = await withTempRoot(t);
|
|
1429
|
+
const target = join(root, 'target-mono');
|
|
1430
|
+
const sourceCli = join(root, 'source-cli');
|
|
1431
|
+
const env = gitEnv();
|
|
1432
|
+
|
|
1433
|
+
await initMonorepoStub({ dir: target, env, seed: { 'cli/hello.txt': 'v1\n' } });
|
|
1434
|
+
|
|
1435
|
+
await mkdir(sourceCli, { recursive: true });
|
|
1436
|
+
await run('git', ['init', '-q'], { cwd: sourceCli, env });
|
|
1437
|
+
await run('git', ['checkout', '-q', '-b', 'main'], { cwd: sourceCli, env });
|
|
1438
|
+
await writeFile(join(sourceCli, 'package.json'), '{}\n', 'utf-8');
|
|
1439
|
+
await writeFile(join(sourceCli, 'hello.txt'), 'v1\n', 'utf-8');
|
|
1440
|
+
await run('git', ['add', '.'], { cwd: sourceCli, env });
|
|
1441
|
+
await run('git', ['commit', '-q', '-m', 'chore: init cli'], { cwd: sourceCli, env });
|
|
1442
|
+
|
|
1443
|
+
// Create a feature branch commit, then go back to main to ensure HEAD is not the ref we’re porting.
|
|
1444
|
+
await run('git', ['checkout', '-q', '-b', 'feature'], { cwd: sourceCli, env });
|
|
1445
|
+
await writeFile(join(sourceCli, 'hello.txt'), 'v2\n', 'utf-8');
|
|
1446
|
+
await run('git', ['add', '.'], { cwd: sourceCli, env });
|
|
1447
|
+
await run('git', ['commit', '-q', '-m', 'feat: update hello'], { cwd: sourceCli, env });
|
|
1448
|
+
await run('git', ['checkout', '-q', 'main'], { cwd: sourceCli, env });
|
|
1449
|
+
const headBranch = (await runCapture('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: sourceCli, env })).trim();
|
|
1450
|
+
assert.equal(headBranch, 'main');
|
|
1451
|
+
|
|
1452
|
+
const out = await runCapture(
|
|
1453
|
+
process.execPath,
|
|
1454
|
+
[
|
|
1455
|
+
join(process.cwd(), 'scripts', 'monorepo.mjs'),
|
|
1456
|
+
'port',
|
|
1457
|
+
`--target=${target}`,
|
|
1458
|
+
`--branch=port/from-ref`,
|
|
1459
|
+
'--base=main',
|
|
1460
|
+
`--from-happy-cli=${sourceCli}`,
|
|
1461
|
+
'--from-happy-cli-ref=feature',
|
|
1462
|
+
'--from-happy-cli-base=main',
|
|
1463
|
+
'--json',
|
|
1464
|
+
],
|
|
1465
|
+
{ cwd: process.cwd(), env }
|
|
1466
|
+
);
|
|
1467
|
+
const parsed = JSON.parse(out.trim());
|
|
1468
|
+
assert.equal(parsed.ok, true);
|
|
1469
|
+
assert.equal((await readFile(join(target, 'cli', 'hello.txt'), 'utf-8')).toString(), 'v2\n');
|
|
1470
|
+
});
|