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,245 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import { mkdtemp, mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { dirname, join } from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
|
|
9
|
+
function runCmd(cmd, args, { cwd, env }) {
|
|
10
|
+
return new Promise((resolve, reject) => {
|
|
11
|
+
const cleanEnv = {};
|
|
12
|
+
for (const [k, v] of Object.entries(env ?? {})) {
|
|
13
|
+
if (v == null) continue;
|
|
14
|
+
cleanEnv[k] = String(v);
|
|
15
|
+
}
|
|
16
|
+
const proc = spawn(cmd, args, { cwd, env: cleanEnv, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
17
|
+
let stdout = '';
|
|
18
|
+
let stderr = '';
|
|
19
|
+
proc.stdout.on('data', (d) => (stdout += String(d)));
|
|
20
|
+
proc.stderr.on('data', (d) => (stderr += String(d)));
|
|
21
|
+
proc.on('error', reject);
|
|
22
|
+
proc.on('exit', (code) => resolve({ code: code ?? 0, stdout, stderr }));
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function runNode(args, { cwd, env }) {
|
|
27
|
+
return runCmd(process.execPath, args, { cwd, env });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function runOk(cmd, args, { cwd, env }) {
|
|
31
|
+
const res = await runCmd(cmd, args, { cwd, env });
|
|
32
|
+
assert.equal(res.code, 0, `expected exit 0 for ${cmd} ${args.join(' ')}\nstdout:\n${res.stdout}\nstderr:\n${res.stderr}`);
|
|
33
|
+
return res;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
test('happys wt archive detaches and moves a git worktree (preserving uncommitted changes)', async () => {
|
|
37
|
+
const scriptsDir = dirname(fileURLToPath(import.meta.url));
|
|
38
|
+
const rootDir = dirname(scriptsDir);
|
|
39
|
+
const tmp = await mkdtemp(join(tmpdir(), 'happy-stacks-wt-archive-'));
|
|
40
|
+
|
|
41
|
+
const storageDir = join(tmp, 'storage');
|
|
42
|
+
const homeDir = join(tmp, 'home');
|
|
43
|
+
const workspaceDir = join(tmp, 'workspace');
|
|
44
|
+
const componentsDir = join(workspaceDir, 'components');
|
|
45
|
+
|
|
46
|
+
const baseEnv = {
|
|
47
|
+
...Object.fromEntries(Object.entries(process.env).filter(([k]) => !k.startsWith('HAPPY_STACKS_') && !k.startsWith('HAPPY_LOCAL_'))),
|
|
48
|
+
GIT_TERMINAL_PROMPT: '0',
|
|
49
|
+
HAPPY_STACKS_HOME_DIR: homeDir,
|
|
50
|
+
HAPPY_STACKS_STORAGE_DIR: storageDir,
|
|
51
|
+
HAPPY_STACKS_WORKSPACE_DIR: workspaceDir,
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const repoDir = join(componentsDir, 'happy');
|
|
55
|
+
await mkdir(repoDir, { recursive: true });
|
|
56
|
+
await runOk('git', ['init', '-b', 'main'], { cwd: repoDir, env: baseEnv });
|
|
57
|
+
await runOk('git', ['config', 'user.name', 'Test'], { cwd: repoDir, env: baseEnv });
|
|
58
|
+
await runOk('git', ['config', 'user.email', 'test@example.com'], { cwd: repoDir, env: baseEnv });
|
|
59
|
+
await writeFile(join(repoDir, 'README.md'), 'hello\n', 'utf-8');
|
|
60
|
+
await runOk('git', ['add', 'README.md'], { cwd: repoDir, env: baseEnv });
|
|
61
|
+
await runOk('git', ['commit', '-m', 'init'], { cwd: repoDir, env: baseEnv });
|
|
62
|
+
|
|
63
|
+
const worktreeDir = join(componentsDir, '.worktrees', 'happy', 'slopus', 'pr', 'test-archive');
|
|
64
|
+
await mkdir(dirname(worktreeDir), { recursive: true });
|
|
65
|
+
await runOk('git', ['worktree', 'add', '-b', 'slopus/pr/test-archive', worktreeDir, 'main'], { cwd: repoDir, env: baseEnv });
|
|
66
|
+
|
|
67
|
+
await writeFile(join(worktreeDir, 'staged.txt'), 'staged\n', 'utf-8');
|
|
68
|
+
await runOk('git', ['add', 'staged.txt'], { cwd: worktreeDir, env: baseEnv });
|
|
69
|
+
await writeFile(join(worktreeDir, 'untracked.txt'), 'untracked\n', 'utf-8');
|
|
70
|
+
await writeFile(join(worktreeDir, 'README.md'), 'hello\nchanged\n', 'utf-8');
|
|
71
|
+
|
|
72
|
+
const beforeStatus = await runOk('git', ['status', '--porcelain'], { cwd: worktreeDir, env: baseEnv });
|
|
73
|
+
assert.ok(beforeStatus.stdout.includes('A staged.txt'), `expected staged file in status\n${beforeStatus.stdout}`);
|
|
74
|
+
assert.ok(beforeStatus.stdout.includes(' M README.md'), `expected modified file in status\n${beforeStatus.stdout}`);
|
|
75
|
+
assert.ok(beforeStatus.stdout.includes('?? untracked.txt'), `expected untracked file in status\n${beforeStatus.stdout}`);
|
|
76
|
+
|
|
77
|
+
const date = '2000-01-02';
|
|
78
|
+
// Simulate a minimal PATH environment like launchd/SwiftBar shells.
|
|
79
|
+
const nodeEnv = { ...baseEnv, PATH: '' };
|
|
80
|
+
const res = await runNode([join(rootDir, 'scripts', 'worktrees.mjs'), 'archive', 'happy', 'slopus/pr/test-archive', `--date=${date}`, '--json'], {
|
|
81
|
+
cwd: rootDir,
|
|
82
|
+
env: nodeEnv,
|
|
83
|
+
});
|
|
84
|
+
assert.equal(res.code, 0, `expected archive exit 0\nstdout:\n${res.stdout}\nstderr:\n${res.stderr}`);
|
|
85
|
+
const parsed = JSON.parse(res.stdout);
|
|
86
|
+
assert.equal(parsed.ok, true, `expected ok=true JSON output\n${res.stdout}`);
|
|
87
|
+
|
|
88
|
+
const archivedDir = join(componentsDir, '.worktrees-archive', date, 'happy', 'slopus', 'pr', 'test-archive');
|
|
89
|
+
assert.equal(parsed.destDir, archivedDir, `expected destDir in JSON output to match archive path\n${res.stdout}`);
|
|
90
|
+
const legacyGitFile = await stat(join(archivedDir, '.git.worktree')).catch(() => null);
|
|
91
|
+
assert.equal(legacyGitFile, null, 'expected .git.worktree to be removed (avoid untracked noise)');
|
|
92
|
+
const gitStat = await stat(join(archivedDir, '.git'));
|
|
93
|
+
assert.ok(gitStat.isDirectory(), 'expected archived .git to be a directory (detached repo)');
|
|
94
|
+
|
|
95
|
+
const meta = await readFile(join(archivedDir, 'ARCHIVE_META.txt'), 'utf-8');
|
|
96
|
+
assert.ok(meta.includes('component=happy'), `expected component in ARCHIVE_META.txt\n${meta}`);
|
|
97
|
+
assert.ok(meta.includes('ref=slopus/pr/test-archive'), `expected ref in ARCHIVE_META.txt\n${meta}`);
|
|
98
|
+
|
|
99
|
+
const afterStatus = await runOk('git', ['status', '--porcelain'], { cwd: archivedDir, env: baseEnv });
|
|
100
|
+
assert.ok(afterStatus.stdout.includes('A staged.txt'), `expected staged file preserved\n${afterStatus.stdout}`);
|
|
101
|
+
assert.ok(afterStatus.stdout.includes(' M README.md'), `expected modified file preserved\n${afterStatus.stdout}`);
|
|
102
|
+
assert.ok(afterStatus.stdout.includes('?? untracked.txt'), `expected untracked file preserved\n${afterStatus.stdout}`);
|
|
103
|
+
|
|
104
|
+
const list = await runOk('git', ['worktree', 'list', '--porcelain'], { cwd: repoDir, env: baseEnv });
|
|
105
|
+
assert.ok(!list.stdout.includes(worktreeDir), `expected source repo worktree entry pruned\n${list.stdout}`);
|
|
106
|
+
|
|
107
|
+
const branchExists = await runCmd('git', ['show-ref', '--verify', 'refs/heads/slopus/pr/test-archive'], { cwd: repoDir, env: baseEnv });
|
|
108
|
+
assert.notEqual(branchExists.code, 0, 'expected source repo branch deleted');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('happys wt archive refuses to break stacks unless --detach-stacks is provided', async () => {
|
|
112
|
+
const scriptsDir = dirname(fileURLToPath(import.meta.url));
|
|
113
|
+
const rootDir = dirname(scriptsDir);
|
|
114
|
+
const tmp = await mkdtemp(join(tmpdir(), 'happy-stacks-wt-archive-stacks-'));
|
|
115
|
+
|
|
116
|
+
const storageDir = join(tmp, 'storage');
|
|
117
|
+
const homeDir = join(tmp, 'home');
|
|
118
|
+
const workspaceDir = join(tmp, 'workspace');
|
|
119
|
+
const componentsDir = join(workspaceDir, 'components');
|
|
120
|
+
|
|
121
|
+
const baseEnv = {
|
|
122
|
+
...process.env,
|
|
123
|
+
GIT_TERMINAL_PROMPT: '0',
|
|
124
|
+
HAPPY_STACKS_HOME_DIR: homeDir,
|
|
125
|
+
HAPPY_STACKS_STORAGE_DIR: storageDir,
|
|
126
|
+
HAPPY_STACKS_WORKSPACE_DIR: workspaceDir,
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const repoDir = join(componentsDir, 'happy');
|
|
130
|
+
await mkdir(repoDir, { recursive: true });
|
|
131
|
+
await runOk('git', ['init', '-b', 'main'], { cwd: repoDir, env: baseEnv });
|
|
132
|
+
await runOk('git', ['config', 'user.name', 'Test'], { cwd: repoDir, env: baseEnv });
|
|
133
|
+
await runOk('git', ['config', 'user.email', 'test@example.com'], { cwd: repoDir, env: baseEnv });
|
|
134
|
+
await writeFile(join(repoDir, 'README.md'), 'hello\n', 'utf-8');
|
|
135
|
+
await runOk('git', ['add', 'README.md'], { cwd: repoDir, env: baseEnv });
|
|
136
|
+
await runOk('git', ['commit', '-m', 'init'], { cwd: repoDir, env: baseEnv });
|
|
137
|
+
|
|
138
|
+
const worktreeDir = join(componentsDir, '.worktrees', 'happy', 'slopus', 'pr', 'linked-to-stack');
|
|
139
|
+
await mkdir(dirname(worktreeDir), { recursive: true });
|
|
140
|
+
await runOk('git', ['worktree', 'add', '-b', 'slopus/pr/linked-to-stack', worktreeDir, 'main'], { cwd: repoDir, env: baseEnv });
|
|
141
|
+
await writeFile(join(worktreeDir, 'untracked.txt'), 'untracked\n', 'utf-8');
|
|
142
|
+
|
|
143
|
+
const stackName = 'exp-test';
|
|
144
|
+
const envPath = join(storageDir, stackName, 'env');
|
|
145
|
+
await mkdir(dirname(envPath), { recursive: true });
|
|
146
|
+
await writeFile(envPath, [`HAPPY_STACKS_STACK=${stackName}`, `HAPPY_STACKS_COMPONENT_DIR_HAPPY=${worktreeDir}`, ''].join('\n'), 'utf-8');
|
|
147
|
+
|
|
148
|
+
const date = '2000-01-03';
|
|
149
|
+
const nodeEnv = { ...baseEnv, PATH: '' };
|
|
150
|
+
|
|
151
|
+
const denied = await runNode(
|
|
152
|
+
[join(rootDir, 'scripts', 'worktrees.mjs'), 'archive', 'happy', 'slopus/pr/linked-to-stack', `--date=${date}`],
|
|
153
|
+
{ cwd: rootDir, env: nodeEnv }
|
|
154
|
+
);
|
|
155
|
+
assert.notEqual(denied.code, 0, `expected archive to refuse without --detach-stacks\nstdout:\n${denied.stdout}\nstderr:\n${denied.stderr}`);
|
|
156
|
+
|
|
157
|
+
const ok = await runNode(
|
|
158
|
+
[
|
|
159
|
+
join(rootDir, 'scripts', 'worktrees.mjs'),
|
|
160
|
+
'archive',
|
|
161
|
+
'happy',
|
|
162
|
+
'slopus/pr/linked-to-stack',
|
|
163
|
+
`--date=${date}`,
|
|
164
|
+
'--detach-stacks',
|
|
165
|
+
'--json',
|
|
166
|
+
],
|
|
167
|
+
{ cwd: rootDir, env: nodeEnv }
|
|
168
|
+
);
|
|
169
|
+
assert.equal(ok.code, 0, `expected archive to succeed with --detach-stacks\nstdout:\n${ok.stdout}\nstderr:\n${ok.stderr}`);
|
|
170
|
+
|
|
171
|
+
const nextEnv = await readFile(envPath, 'utf-8');
|
|
172
|
+
assert.ok(!nextEnv.includes('HAPPY_STACKS_COMPONENT_DIR_HAPPY='), `expected stack env to detach from worktree\n${nextEnv}`);
|
|
173
|
+
|
|
174
|
+
const archivedDir = join(componentsDir, '.worktrees-archive', date, 'happy', 'slopus', 'pr', 'linked-to-stack');
|
|
175
|
+
const gitStat = await stat(join(archivedDir, '.git'));
|
|
176
|
+
assert.ok(gitStat.isDirectory(), 'expected archived .git to be a directory (detached repo)');
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test('happys wt archive can archive a broken git worktree (missing .git/worktrees entry)', async () => {
|
|
180
|
+
const scriptsDir = dirname(fileURLToPath(import.meta.url));
|
|
181
|
+
const rootDir = dirname(scriptsDir);
|
|
182
|
+
const tmp = await mkdtemp(join(tmpdir(), 'happy-stacks-wt-archive-broken-'));
|
|
183
|
+
|
|
184
|
+
const storageDir = join(tmp, 'storage');
|
|
185
|
+
const homeDir = join(tmp, 'home');
|
|
186
|
+
const workspaceDir = join(tmp, 'workspace');
|
|
187
|
+
const componentsDir = join(workspaceDir, 'components');
|
|
188
|
+
|
|
189
|
+
const baseEnv = {
|
|
190
|
+
...Object.fromEntries(Object.entries(process.env).filter(([k]) => !k.startsWith('HAPPY_STACKS_') && !k.startsWith('HAPPY_LOCAL_'))),
|
|
191
|
+
GIT_TERMINAL_PROMPT: '0',
|
|
192
|
+
HAPPY_STACKS_HOME_DIR: homeDir,
|
|
193
|
+
HAPPY_STACKS_STORAGE_DIR: storageDir,
|
|
194
|
+
HAPPY_STACKS_WORKSPACE_DIR: workspaceDir,
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
const repoDir = join(componentsDir, 'happy');
|
|
198
|
+
await mkdir(repoDir, { recursive: true });
|
|
199
|
+
await runOk('git', ['init', '-b', 'main'], { cwd: repoDir, env: baseEnv });
|
|
200
|
+
await runOk('git', ['config', 'user.name', 'Test'], { cwd: repoDir, env: baseEnv });
|
|
201
|
+
await runOk('git', ['config', 'user.email', 'test@example.com'], { cwd: repoDir, env: baseEnv });
|
|
202
|
+
await writeFile(join(repoDir, 'README.md'), 'hello\n', 'utf-8');
|
|
203
|
+
await runOk('git', ['add', 'README.md'], { cwd: repoDir, env: baseEnv });
|
|
204
|
+
await runOk('git', ['commit', '-m', 'init'], { cwd: repoDir, env: baseEnv });
|
|
205
|
+
|
|
206
|
+
const worktreeDir = join(componentsDir, '.worktrees', 'happy', 'slopus', 'pr', 'broken-worktree');
|
|
207
|
+
await mkdir(dirname(worktreeDir), { recursive: true });
|
|
208
|
+
await runOk('git', ['worktree', 'add', '-b', 'slopus/pr/broken-worktree', worktreeDir, 'main'], { cwd: repoDir, env: baseEnv });
|
|
209
|
+
|
|
210
|
+
// Create uncommitted changes (no staging; the index will be deleted when we break the worktree).
|
|
211
|
+
await writeFile(join(worktreeDir, 'untracked.txt'), 'untracked\n', 'utf-8');
|
|
212
|
+
await writeFile(join(worktreeDir, 'README.md'), 'hello\nchanged\n', 'utf-8');
|
|
213
|
+
|
|
214
|
+
// Simulate a corrupted linked worktree by removing its gitdir entry from the source repo.
|
|
215
|
+
const gitFile = await readFile(join(worktreeDir, '.git'), 'utf-8');
|
|
216
|
+
const gitdirLine = gitFile
|
|
217
|
+
.split('\n')
|
|
218
|
+
.map((l) => l.trim())
|
|
219
|
+
.find((l) => l.startsWith('gitdir:'));
|
|
220
|
+
assert.ok(gitdirLine, `expected .git file to include gitdir line\n${gitFile}`);
|
|
221
|
+
const gitdir = gitdirLine.slice('gitdir:'.length).trim();
|
|
222
|
+
assert.ok(gitdir, `expected gitdir path\n${gitFile}`);
|
|
223
|
+
// Use an absolute path so we can rm it reliably.
|
|
224
|
+
const gitdirAbs = gitdir.startsWith('/') ? gitdir : join(worktreeDir, gitdir);
|
|
225
|
+
await rm(gitdirAbs, { recursive: true, force: true });
|
|
226
|
+
|
|
227
|
+
const date = '2000-01-05';
|
|
228
|
+
const nodeEnv = { ...baseEnv, PATH: '' };
|
|
229
|
+
const res = await runNode([join(rootDir, 'scripts', 'worktrees.mjs'), 'archive', 'happy', 'slopus/pr/broken-worktree', `--date=${date}`, '--json'], {
|
|
230
|
+
cwd: rootDir,
|
|
231
|
+
env: nodeEnv,
|
|
232
|
+
});
|
|
233
|
+
assert.equal(res.code, 0, `expected archive exit 0\nstdout:\n${res.stdout}\nstderr:\n${res.stderr}`);
|
|
234
|
+
const parsed = JSON.parse(res.stdout);
|
|
235
|
+
assert.equal(parsed.ok, true, `expected ok=true JSON output\n${res.stdout}`);
|
|
236
|
+
assert.equal(parsed.branch, 'slopus/pr/broken-worktree', 'expected branch name to be preserved');
|
|
237
|
+
|
|
238
|
+
const archivedDir = join(componentsDir, '.worktrees-archive', date, 'happy', 'slopus', 'pr', 'broken-worktree');
|
|
239
|
+
const gitStat = await stat(join(archivedDir, '.git'));
|
|
240
|
+
assert.ok(gitStat.isDirectory(), 'expected archived .git to be a directory (detached repo)');
|
|
241
|
+
|
|
242
|
+
const afterStatus = await runOk('git', ['status', '--porcelain'], { cwd: archivedDir, env: baseEnv });
|
|
243
|
+
assert.ok(afterStatus.stdout.includes(' M README.md'), `expected modified file preserved\n${afterStatus.stdout}`);
|
|
244
|
+
assert.ok(afterStatus.stdout.includes('?? untracked.txt'), `expected untracked file preserved\n${afterStatus.stdout}`);
|
|
245
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { dirname, join } from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
|
|
9
|
+
function runNode(args, { cwd, env }) {
|
|
10
|
+
return new Promise((resolve, reject) => {
|
|
11
|
+
const proc = spawn(process.execPath, args, { cwd, env, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
12
|
+
let stdout = '';
|
|
13
|
+
let stderr = '';
|
|
14
|
+
proc.stdout.on('data', (d) => (stdout += String(d)));
|
|
15
|
+
proc.stderr.on('data', (d) => (stderr += String(d)));
|
|
16
|
+
proc.on('error', reject);
|
|
17
|
+
proc.on('exit', (code) => resolve({ code: code ?? 0, stdout, stderr }));
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
test('happys wt cursor opens the monorepo root (not a subpackage dir) in monorepo worktrees', async () => {
|
|
22
|
+
const scriptsDir = dirname(fileURLToPath(import.meta.url));
|
|
23
|
+
const rootDir = dirname(scriptsDir);
|
|
24
|
+
const tmp = await mkdtemp(join(tmpdir(), 'happy-stacks-wt-cursor-mono-'));
|
|
25
|
+
|
|
26
|
+
const workspaceDir = join(tmp, 'workspace');
|
|
27
|
+
const homeDir = join(tmp, 'home');
|
|
28
|
+
const sandboxDir = join(tmp, 'sandbox');
|
|
29
|
+
|
|
30
|
+
const monoRoot = join(workspaceDir, 'components', '.worktrees', 'happy', 'slopus', 'tmp', 'mono-wt');
|
|
31
|
+
await mkdir(join(monoRoot, 'expo-app'), { recursive: true });
|
|
32
|
+
await mkdir(join(monoRoot, 'cli'), { recursive: true });
|
|
33
|
+
await mkdir(join(monoRoot, 'server'), { recursive: true });
|
|
34
|
+
await writeFile(join(monoRoot, '.git'), 'gitdir: dummy\n', 'utf-8');
|
|
35
|
+
await writeFile(join(monoRoot, 'expo-app', 'package.json'), '{}\n', 'utf-8');
|
|
36
|
+
await writeFile(join(monoRoot, 'cli', 'package.json'), '{}\n', 'utf-8');
|
|
37
|
+
await writeFile(join(monoRoot, 'server', 'package.json'), '{}\n', 'utf-8');
|
|
38
|
+
|
|
39
|
+
const env = {
|
|
40
|
+
...process.env,
|
|
41
|
+
HAPPY_STACKS_HOME_DIR: homeDir,
|
|
42
|
+
HAPPY_STACKS_WORKSPACE_DIR: workspaceDir,
|
|
43
|
+
HAPPY_STACKS_SANDBOX_DIR: sandboxDir,
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const resHappy = await runNode(
|
|
47
|
+
[join(rootDir, 'scripts', 'worktrees.mjs'), 'cursor', 'happy', 'slopus/tmp/mono-wt', '--json'],
|
|
48
|
+
{ cwd: rootDir, env }
|
|
49
|
+
);
|
|
50
|
+
assert.equal(resHappy.code, 0, `expected exit 0, got ${resHappy.code}\nstdout:\n${resHappy.stdout}\nstderr:\n${resHappy.stderr}`);
|
|
51
|
+
const parsedHappy = JSON.parse(resHappy.stdout);
|
|
52
|
+
assert.equal(parsedHappy.dir, monoRoot);
|
|
53
|
+
|
|
54
|
+
const resCli = await runNode(
|
|
55
|
+
[join(rootDir, 'scripts', 'worktrees.mjs'), 'cursor', 'happy-cli', 'slopus/tmp/mono-wt', '--json'],
|
|
56
|
+
{ cwd: rootDir, env }
|
|
57
|
+
);
|
|
58
|
+
assert.equal(resCli.code, 0, `expected exit 0, got ${resCli.code}\nstdout:\n${resCli.stdout}\nstderr:\n${resCli.stderr}`);
|
|
59
|
+
const parsedCli = JSON.parse(resCli.stdout);
|
|
60
|
+
assert.equal(parsedCli.dir, monoRoot);
|
|
61
|
+
|
|
62
|
+
await rm(tmp, { recursive: true, force: true });
|
|
63
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
|
|
7
|
+
import { listWorktreeSpecs } from './utils/git/worktrees.mjs';
|
|
8
|
+
|
|
9
|
+
test('listWorktreeSpecs does not recurse into worktree roots', async () => {
|
|
10
|
+
const tmp = await mkdtemp(join(tmpdir(), 'happy-stacks-list-wt-specs-'));
|
|
11
|
+
try {
|
|
12
|
+
const workspaceDir = join(tmp, 'workspace');
|
|
13
|
+
const env = { ...process.env, HAPPY_STACKS_WORKSPACE_DIR: workspaceDir };
|
|
14
|
+
const rootDir = tmp;
|
|
15
|
+
|
|
16
|
+
const wtRoot = join(workspaceDir, 'components', '.worktrees', 'happy', 'slopus', 'tmp', 'mono-wt');
|
|
17
|
+
await mkdir(wtRoot, { recursive: true });
|
|
18
|
+
await writeFile(join(wtRoot, '.git'), 'gitdir: dummy\n', 'utf-8');
|
|
19
|
+
|
|
20
|
+
// If listWorktreeSpecs incorrectly recurses into worktree roots, it would discover this nested ".git"
|
|
21
|
+
// and return an extra spec.
|
|
22
|
+
const nested = join(wtRoot, 'nested');
|
|
23
|
+
await mkdir(nested, { recursive: true });
|
|
24
|
+
await writeFile(join(nested, '.git'), 'gitdir: dummy\n', 'utf-8');
|
|
25
|
+
|
|
26
|
+
const specs = await listWorktreeSpecs({ rootDir, component: 'happy', env });
|
|
27
|
+
assert.ok(specs.includes('slopus/tmp/mono-wt'), specs.join('\n'));
|
|
28
|
+
assert.ok(!specs.includes('slopus/tmp/mono-wt/nested'), specs.join('\n'));
|
|
29
|
+
} finally {
|
|
30
|
+
await rm(tmp, { recursive: true, force: true });
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { dirname, join } from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
|
|
9
|
+
function runNode(args, { cwd, env }) {
|
|
10
|
+
return new Promise((resolve, reject) => {
|
|
11
|
+
const proc = spawn(process.execPath, args, { cwd, env, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
12
|
+
let stdout = '';
|
|
13
|
+
let stderr = '';
|
|
14
|
+
proc.stdout.on('data', (d) => (stdout += String(d)));
|
|
15
|
+
proc.stderr.on('data', (d) => (stderr += String(d)));
|
|
16
|
+
proc.on('error', reject);
|
|
17
|
+
proc.on('exit', (code) => resolve({ code: code ?? 0, stdout, stderr }));
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
test('happys wt use switches all monorepo group components when target is a monorepo worktree', async () => {
|
|
22
|
+
const scriptsDir = dirname(fileURLToPath(import.meta.url));
|
|
23
|
+
const rootDir = dirname(scriptsDir);
|
|
24
|
+
const tmp = await mkdtemp(join(tmpdir(), 'happy-stacks-wt-use-mono-'));
|
|
25
|
+
|
|
26
|
+
const workspaceDir = join(tmp, 'workspace');
|
|
27
|
+
const homeDir = join(tmp, 'home');
|
|
28
|
+
const sandboxDir = join(tmp, 'sandbox');
|
|
29
|
+
const envFile = join(tmp, 'env');
|
|
30
|
+
|
|
31
|
+
const monoRoot = join(workspaceDir, 'components', '.worktrees', 'happy', 'slopus', 'tmp', 'mono-wt');
|
|
32
|
+
await mkdir(join(monoRoot, 'expo-app'), { recursive: true });
|
|
33
|
+
await mkdir(join(monoRoot, 'cli'), { recursive: true });
|
|
34
|
+
await mkdir(join(monoRoot, 'server'), { recursive: true });
|
|
35
|
+
await writeFile(join(monoRoot, '.git'), 'gitdir: dummy\n', 'utf-8');
|
|
36
|
+
await writeFile(join(monoRoot, 'expo-app', 'package.json'), '{}\n', 'utf-8');
|
|
37
|
+
await writeFile(join(monoRoot, 'cli', 'package.json'), '{}\n', 'utf-8');
|
|
38
|
+
await writeFile(join(monoRoot, 'server', 'package.json'), '{}\n', 'utf-8');
|
|
39
|
+
|
|
40
|
+
await writeFile(envFile, '', 'utf-8');
|
|
41
|
+
|
|
42
|
+
const env = {
|
|
43
|
+
...process.env,
|
|
44
|
+
HAPPY_STACKS_STACK: 'exp',
|
|
45
|
+
HAPPY_LOCAL_STACK: 'exp',
|
|
46
|
+
HAPPY_STACKS_HOME_DIR: homeDir,
|
|
47
|
+
HAPPY_STACKS_WORKSPACE_DIR: workspaceDir,
|
|
48
|
+
HAPPY_STACKS_SANDBOX_DIR: sandboxDir,
|
|
49
|
+
HAPPY_STACKS_ENV_FILE: envFile,
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const res = await runNode(
|
|
53
|
+
[join(rootDir, 'scripts', 'worktrees.mjs'), 'use', 'happy', 'slopus/tmp/mono-wt', '--force', '--json'],
|
|
54
|
+
{ cwd: rootDir, env }
|
|
55
|
+
);
|
|
56
|
+
assert.equal(res.code, 0, `expected exit 0, got ${res.code}\nstdout:\n${res.stdout}\nstderr:\n${res.stderr}`);
|
|
57
|
+
|
|
58
|
+
const parsed = JSON.parse(res.stdout);
|
|
59
|
+
assert.deepEqual(parsed.updatedComponents, ['happy', 'happy-cli', 'happy-server']);
|
|
60
|
+
|
|
61
|
+
const contents = await readFile(envFile, 'utf-8');
|
|
62
|
+
assert.ok(contents.includes(`HAPPY_STACKS_COMPONENT_DIR_HAPPY=${monoRoot}\n`), contents);
|
|
63
|
+
assert.ok(contents.includes(`HAPPY_STACKS_COMPONENT_DIR_HAPPY_CLI=${monoRoot}\n`), contents);
|
|
64
|
+
assert.ok(contents.includes(`HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER=${monoRoot}\n`), contents);
|
|
65
|
+
|
|
66
|
+
await rm(tmp, { recursive: true, force: true });
|
|
67
|
+
});
|