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.
Files changed (104) hide show
  1. package/README.md +64 -33
  2. package/bin/happys.mjs +44 -1
  3. package/docs/codex-mcp-resume.md +130 -0
  4. package/docs/commit-audits/happy/leeroy-wip.commit-analysis.md +17640 -0
  5. package/docs/commit-audits/happy/leeroy-wip.commit-export.fuller-stat.md +3845 -0
  6. package/docs/commit-audits/happy/leeroy-wip.commit-inventory.md +102 -0
  7. package/docs/commit-audits/happy/leeroy-wip.commit-manual-review.md +1452 -0
  8. package/docs/commit-audits/happy/leeroy-wip.manual-review-queue.md +116 -0
  9. package/docs/happy-development.md +1 -2
  10. package/docs/monorepo-migration.md +286 -0
  11. package/docs/server-flavors.md +19 -3
  12. package/docs/stacks.md +35 -0
  13. package/package.json +1 -1
  14. package/scripts/auth.mjs +21 -3
  15. package/scripts/build.mjs +1 -1
  16. package/scripts/dev.mjs +20 -7
  17. package/scripts/doctor.mjs +0 -4
  18. package/scripts/edison.mjs +2 -2
  19. package/scripts/env.mjs +150 -0
  20. package/scripts/env_cmd.test.mjs +128 -0
  21. package/scripts/init.mjs +5 -2
  22. package/scripts/install.mjs +99 -57
  23. package/scripts/migrate.mjs +3 -12
  24. package/scripts/monorepo.mjs +1096 -0
  25. package/scripts/monorepo_port.test.mjs +1470 -0
  26. package/scripts/review.mjs +715 -24
  27. package/scripts/review_pr.mjs +5 -20
  28. package/scripts/run.mjs +21 -15
  29. package/scripts/setup.mjs +147 -25
  30. package/scripts/setup_pr.mjs +19 -28
  31. package/scripts/stack.mjs +493 -157
  32. package/scripts/stack_archive_cmd.test.mjs +91 -0
  33. package/scripts/stack_editor_workspace_monorepo_root.test.mjs +65 -0
  34. package/scripts/stack_env_cmd.test.mjs +87 -0
  35. package/scripts/stack_happy_cmd.test.mjs +126 -0
  36. package/scripts/stack_interactive_monorepo_group.test.mjs +71 -0
  37. package/scripts/stack_monorepo_defaults.test.mjs +62 -0
  38. package/scripts/stack_monorepo_server_light_from_happy_spec.test.mjs +66 -0
  39. package/scripts/stack_server_flavors_defaults.test.mjs +55 -0
  40. package/scripts/stack_shorthand_cmd.test.mjs +55 -0
  41. package/scripts/stack_wt_list.test.mjs +128 -0
  42. package/scripts/tui.mjs +88 -2
  43. package/scripts/utils/cli/cli_registry.mjs +20 -5
  44. package/scripts/utils/cli/cwd_scope.mjs +56 -2
  45. package/scripts/utils/cli/cwd_scope.test.mjs +40 -7
  46. package/scripts/utils/cli/prereqs.mjs +8 -5
  47. package/scripts/utils/cli/prereqs.test.mjs +34 -0
  48. package/scripts/utils/cli/wizard.mjs +17 -9
  49. package/scripts/utils/cli/wizard_prompt_worktree_source_lazy.test.mjs +60 -0
  50. package/scripts/utils/dev/daemon.mjs +14 -1
  51. package/scripts/utils/dev/expo_dev.mjs +188 -4
  52. package/scripts/utils/dev/server.mjs +21 -17
  53. package/scripts/utils/edison/git_roots.mjs +29 -0
  54. package/scripts/utils/edison/git_roots.test.mjs +36 -0
  55. package/scripts/utils/env/env.mjs +7 -3
  56. package/scripts/utils/env/env_file.mjs +4 -2
  57. package/scripts/utils/env/env_file.test.mjs +44 -0
  58. package/scripts/utils/git/worktrees.mjs +63 -12
  59. package/scripts/utils/git/worktrees_monorepo.test.mjs +54 -0
  60. package/scripts/utils/net/tcp_forward.mjs +162 -0
  61. package/scripts/utils/paths/paths.mjs +118 -3
  62. package/scripts/utils/paths/paths_monorepo.test.mjs +58 -0
  63. package/scripts/utils/paths/paths_server_flavors.test.mjs +45 -0
  64. package/scripts/utils/proc/commands.mjs +2 -3
  65. package/scripts/utils/proc/pm.mjs +113 -16
  66. package/scripts/utils/proc/pm_spawn.test.mjs +76 -0
  67. package/scripts/utils/proc/pm_stack_cache_env.test.mjs +142 -0
  68. package/scripts/utils/proc/proc.mjs +68 -10
  69. package/scripts/utils/proc/proc.test.mjs +77 -0
  70. package/scripts/utils/review/chunks.mjs +55 -0
  71. package/scripts/utils/review/chunks.test.mjs +51 -0
  72. package/scripts/utils/review/findings.mjs +165 -0
  73. package/scripts/utils/review/findings.test.mjs +85 -0
  74. package/scripts/utils/review/head_slice.mjs +153 -0
  75. package/scripts/utils/review/head_slice.test.mjs +91 -0
  76. package/scripts/utils/review/instructions/deep.md +20 -0
  77. package/scripts/utils/review/runners/coderabbit.mjs +56 -14
  78. package/scripts/utils/review/runners/coderabbit.test.mjs +59 -0
  79. package/scripts/utils/review/runners/codex.mjs +32 -22
  80. package/scripts/utils/review/runners/codex.test.mjs +35 -0
  81. package/scripts/utils/review/slices.mjs +140 -0
  82. package/scripts/utils/review/slices.test.mjs +32 -0
  83. package/scripts/utils/server/flavor_scripts.mjs +98 -0
  84. package/scripts/utils/server/flavor_scripts.test.mjs +146 -0
  85. package/scripts/utils/server/prisma_import.mjs +37 -0
  86. package/scripts/utils/server/prisma_import.test.mjs +70 -0
  87. package/scripts/utils/server/ui_env.mjs +14 -0
  88. package/scripts/utils/server/ui_env.test.mjs +46 -0
  89. package/scripts/utils/server/validate.mjs +53 -16
  90. package/scripts/utils/server/validate.test.mjs +89 -0
  91. package/scripts/utils/stack/editor_workspace.mjs +4 -4
  92. package/scripts/utils/stack/interactive_stack_config.mjs +185 -0
  93. package/scripts/utils/stack/startup.mjs +113 -13
  94. package/scripts/utils/stack/startup_server_light_dirs.test.mjs +64 -0
  95. package/scripts/utils/stack/startup_server_light_generate.test.mjs +70 -0
  96. package/scripts/utils/stack/startup_server_light_legacy.test.mjs +88 -0
  97. package/scripts/utils/tailscale/ip.mjs +116 -0
  98. package/scripts/utils/ui/ansi.mjs +39 -0
  99. package/scripts/where.mjs +2 -2
  100. package/scripts/worktrees.mjs +627 -137
  101. package/scripts/worktrees_archive_cmd.test.mjs +245 -0
  102. package/scripts/worktrees_cursor_monorepo_root.test.mjs +63 -0
  103. package/scripts/worktrees_list_specs_no_recurse.test.mjs +33 -0
  104. package/scripts/worktrees_monorepo_use_group.test.mjs +67 -0
@@ -1,25 +1,39 @@
1
1
  import './utils/env/env.mjs';
2
- import { mkdir, readFile, readdir, rm, symlink, writeFile } from 'node:fs/promises';
3
- import { dirname, isAbsolute, join, resolve } from 'node:path';
2
+ import { copyFile, mkdir, readFile, readdir, realpath, rename, rm, symlink, writeFile } from 'node:fs/promises';
3
+ import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
4
4
  import { parseArgs } from './utils/cli/args.mjs';
5
5
  import { pathExists } from './utils/fs/fs.mjs';
6
6
  import { run, runCapture } from './utils/proc/proc.mjs';
7
7
  import { commandExists, resolveCommandPath } from './utils/proc/commands.mjs';
8
- import { componentDirEnvKey, getComponentDir, getComponentsDir, getHappyStacksHomeDir, getRootDir, getWorkspaceDir } from './utils/paths/paths.mjs';
9
- import { inferRemoteNameForOwner, parseGithubOwner } from './utils/git/worktrees.mjs';
10
- import { getWorktreesRoot } from './utils/git/worktrees.mjs';
8
+ import {
9
+ componentDirEnvKey,
10
+ coerceHappyMonorepoRootFromPath,
11
+ getComponentDir,
12
+ getComponentRepoDir,
13
+ getComponentsDir,
14
+ getHappyStacksHomeDir,
15
+ getRootDir,
16
+ getWorkspaceDir,
17
+ happyMonorepoSubdirForComponent,
18
+ isHappyMonorepoRoot,
19
+ resolveStackEnvPath,
20
+ } from './utils/paths/paths.mjs';
21
+ import { getWorktreesRoot, inferRemoteNameForOwner, listWorktreeSpecs, parseGithubOwner, resolveComponentSpecToDir } from './utils/git/worktrees.mjs';
11
22
  import { parseGithubPullRequest, sanitizeSlugPart } from './utils/git/refs.mjs';
12
23
  import { readTextIfExists } from './utils/fs/ops.mjs';
13
24
  import { isTty, prompt, promptSelect, withRl } from './utils/cli/wizard.mjs';
14
25
  import { printResult, wantsHelp, wantsJson } from './utils/cli/cli.mjs';
15
26
  import { ensureEnvLocalUpdated } from './utils/env/env_local.mjs';
16
- import { ensureEnvFileUpdated } from './utils/env/env_file.mjs';
27
+ import { ensureEnvFilePruned, ensureEnvFileUpdated } from './utils/env/env_file.mjs';
17
28
  import { isSandboxed } from './utils/env/sandbox.mjs';
18
29
  import { existsSync } from 'node:fs';
19
30
  import { getHomeEnvLocalPath, getHomeEnvPath, resolveUserConfigEnvPath } from './utils/env/config.mjs';
20
31
  import { detectServerComponentDirMismatch } from './utils/server/validate.mjs';
32
+ import { listAllStackNames } from './utils/stack/stacks.mjs';
33
+ import { parseDotenv } from './utils/env/dotenv.mjs';
21
34
 
22
35
  const DEFAULT_COMPONENTS = ['happy', 'happy-cli', 'happy-server-light', 'happy-server'];
36
+ const HAPPY_MONOREPO_GROUP_COMPONENTS = ['happy', 'happy-cli', 'happy-server'];
23
37
 
24
38
  function getActiveStackName() {
25
39
  return (process.env.HAPPY_STACKS_STACK ?? process.env.HAPPY_LOCAL_STACK ?? '').trim() || 'main';
@@ -29,8 +43,36 @@ function isMainStack() {
29
43
  return getActiveStackName() === 'main';
30
44
  }
31
45
 
46
+ function isHappyMonorepoGroupComponent(component) {
47
+ return HAPPY_MONOREPO_GROUP_COMPONENTS.includes(String(component ?? '').trim());
48
+ }
49
+
50
+ function isActiveHappyMonorepo(rootDir, component) {
51
+ const repoDir = getComponentRepoDir(rootDir, component);
52
+ return isHappyMonorepoRoot(repoDir);
53
+ }
54
+
55
+ function worktreeRepoKeyForComponent(rootDir, component) {
56
+ const c = String(component ?? '').trim();
57
+ if (isHappyMonorepoGroupComponent(c) && isActiveHappyMonorepo(rootDir, c)) {
58
+ return 'happy';
59
+ }
60
+ return c;
61
+ }
62
+
63
+ function componentOverrideKeys(component) {
64
+ const key = componentDirEnvKey(component);
65
+ return { key, legacyKey: key.replace(/^HAPPY_STACKS_/, 'HAPPY_LOCAL_') };
66
+ }
67
+
68
+ function getDefaultComponentDir(rootDir, component) {
69
+ const { key, legacyKey } = componentOverrideKeys(component);
70
+ // Clone env so we can suppress the override for this lookup.
71
+ const env = { ...process.env, [key]: '', [legacyKey]: '' };
72
+ return getComponentDir(rootDir, component, env);
73
+ }
74
+
32
75
  function resolveComponentWorktreeDir({ rootDir, component, spec }) {
33
- const worktreesRoot = getWorktreesRoot(rootDir);
34
76
  const raw = (spec ?? '').trim();
35
77
 
36
78
  if (!raw) {
@@ -39,19 +81,53 @@ function resolveComponentWorktreeDir({ rootDir, component, spec }) {
39
81
  }
40
82
 
41
83
  if (raw === 'default' || raw === 'main') {
42
- return join(getComponentsDir(rootDir), component);
84
+ return getDefaultComponentDir(rootDir, component);
43
85
  }
44
86
 
45
87
  if (raw === 'active') {
46
88
  return getComponentDir(rootDir, component);
47
89
  }
48
90
 
91
+ if (!isAbsolute(raw)) {
92
+ // Allow passing a repo-relative path (e.g. "components/happy") as an escape hatch.
93
+ const rel = resolve(getWorkspaceDir(rootDir), raw);
94
+ if (existsSync(rel)) {
95
+ return (
96
+ resolveComponentSpecToDir({ rootDir, component, spec: rel }) ??
97
+ // Should never happen because rel is absolute and non-empty.
98
+ rel
99
+ );
100
+ }
101
+ }
102
+
103
+ // Absolute paths and <owner>/<branch...> specs.
104
+ const resolved = resolveComponentSpecToDir({ rootDir, component, spec: raw });
105
+ if (resolved) {
106
+ // If this is a happy monorepo group component, allow resolving worktree specs that live under
107
+ // `.worktrees/happy/...` even when the current default checkout is still split-repo.
108
+ try {
109
+ if (!existsSync(resolved) && isHappyMonorepoGroupComponent(component)) {
110
+ const monoResolved = resolveComponentSpecToDir({ rootDir, component: 'happy', spec: raw });
111
+ if (monoResolved && existsSync(monoResolved)) {
112
+ const monoRoot = coerceHappyMonorepoRootFromPath(monoResolved);
113
+ const sub = happyMonorepoSubdirForComponent(component);
114
+ if (monoRoot && sub) return join(monoRoot, sub);
115
+ }
116
+ }
117
+ } catch {
118
+ // ignore and fall back to resolved
119
+ }
120
+ return resolved;
121
+ }
122
+
123
+ // Fallback: treat raw as a literal path.
49
124
  if (isAbsolute(raw)) {
125
+ const monoRoot = coerceHappyMonorepoRootFromPath(raw);
126
+ const sub = happyMonorepoSubdirForComponent(component);
127
+ if (monoRoot && sub) return join(monoRoot, sub);
50
128
  return raw;
51
129
  }
52
-
53
- // Interpret as <owner>/<rest...> under components/.worktrees/<component>/.
54
- return join(worktreesRoot, component, ...raw.split('/'));
130
+ return null;
55
131
  }
56
132
 
57
133
  async function isWorktreeClean(dir) {
@@ -119,6 +195,181 @@ async function getWorktreeGitDir(worktreeDir) {
119
195
  return isAbsolute(gitDir) ? gitDir : resolve(worktreeDir, gitDir);
120
196
  }
121
197
 
198
+ async function gitShowTopLevel(dir) {
199
+ return (await git(dir, ['rev-parse', '--show-toplevel'])).trim();
200
+ }
201
+
202
+ function getTodayYmd() {
203
+ const now = new Date();
204
+ const y = String(now.getFullYear());
205
+ const m = String(now.getMonth() + 1).padStart(2, '0');
206
+ const d = String(now.getDate()).padStart(2, '0');
207
+ return `${y}-${m}-${d}`;
208
+ }
209
+
210
+ function parseGitdirFile(contents) {
211
+ const raw = (contents ?? '').toString();
212
+ const line = raw
213
+ .split('\n')
214
+ .map((l) => l.trim())
215
+ .find((l) => l.startsWith('gitdir:'));
216
+ const path = line?.slice('gitdir:'.length).trim();
217
+ return path || null;
218
+ }
219
+
220
+ function inferSourceRepoDirFromLinkedGitDir(linkedGitDir) {
221
+ // Typical worktree gitdir: "<repo>/.git/worktrees/<name>"
222
+ // We want "<repo>".
223
+ const worktreesDir = dirname(linkedGitDir);
224
+ const gitDir = dirname(worktreesDir);
225
+ if (basename(worktreesDir) !== 'worktrees' || basename(gitDir) !== '.git') {
226
+ return null;
227
+ }
228
+ return dirname(gitDir);
229
+ }
230
+
231
+ function isJsonMode() {
232
+ return Boolean((process.argv ?? []).includes('--json'));
233
+ }
234
+
235
+ async function runMaybeQuiet(cmd, args, options) {
236
+ if (isJsonMode()) {
237
+ await runCapture(cmd, args, options);
238
+ return;
239
+ }
240
+ await run(cmd, args, options);
241
+ }
242
+
243
+ async function detachGitWorktree({ worktreeDir, expectedBranch = null }) {
244
+ const gitPath = join(worktreeDir, '.git');
245
+
246
+ // If `.git` is already a directory, it's already detached.
247
+ if (await pathExists(join(worktreeDir, '.git', 'HEAD'))) {
248
+ const head = (await git(worktreeDir, ['rev-parse', 'HEAD'])).trim();
249
+ let branch = null;
250
+ try {
251
+ const b = (await git(worktreeDir, ['symbolic-ref', '--quiet', '--short', 'HEAD'])).trim();
252
+ branch = b || null;
253
+ } catch {
254
+ branch = null;
255
+ }
256
+ // Already detached repos have no "source" repo to prune, and we must not delete the branch here.
257
+ const gitDir = await getWorktreeGitDir(worktreeDir);
258
+ return { worktreeDir, head, branch, sourceRepoDir: null, linkedGitDir: gitDir, alreadyDetached: true };
259
+ }
260
+
261
+ const gitFileContents = await readFile(gitPath, 'utf-8');
262
+ const linkedGitDirFromFile = parseGitdirFile(gitFileContents);
263
+ if (!linkedGitDirFromFile) {
264
+ throw new Error(`[wt] expected ${gitPath} to be a linked worktree .git file`);
265
+ }
266
+ const linkedGitDir = isAbsolute(linkedGitDirFromFile) ? linkedGitDirFromFile : resolve(worktreeDir, linkedGitDirFromFile);
267
+
268
+ // If the worktree's linked gitdir has been deleted (common after manual moves/prunes),
269
+ // we can still archive it by reconstructing a standalone repo from the source repo.
270
+ const linkedGitDirExists = await pathExists(linkedGitDir);
271
+ const isBrokenLinkedWorktree = !linkedGitDirExists;
272
+
273
+ let branch = null;
274
+ let head = '';
275
+
276
+ if (!isBrokenLinkedWorktree) {
277
+ head = (await git(worktreeDir, ['rev-parse', 'HEAD'])).trim();
278
+ try {
279
+ const b = (await git(worktreeDir, ['symbolic-ref', '--quiet', '--short', 'HEAD'])).trim();
280
+ branch = b || null;
281
+ } catch {
282
+ branch = null;
283
+ }
284
+ } else {
285
+ branch = expectedBranch || null;
286
+ }
287
+
288
+ let sourceRepoDir = null;
289
+ if (!isBrokenLinkedWorktree) {
290
+ const commonDir = (await git(worktreeDir, ['rev-parse', '--path-format=absolute', '--git-common-dir'])).trim();
291
+ sourceRepoDir = dirname(commonDir);
292
+ } else {
293
+ sourceRepoDir = inferSourceRepoDirFromLinkedGitDir(linkedGitDir);
294
+ if (!sourceRepoDir) {
295
+ throw new Error(`[wt] unable to infer source repo dir from broken linked gitdir: ${linkedGitDir}`);
296
+ }
297
+ if (!head) {
298
+ try {
299
+ if (branch) {
300
+ head = (await runCapture('git', ['rev-parse', branch], { cwd: sourceRepoDir })).trim();
301
+ } else {
302
+ head = (await runCapture('git', ['rev-parse', 'HEAD'], { cwd: sourceRepoDir })).trim();
303
+ }
304
+ } catch {
305
+ head = '';
306
+ }
307
+ }
308
+ }
309
+
310
+ await rename(gitPath, join(worktreeDir, '.git.worktree'));
311
+ await runMaybeQuiet('git', ['init'], { cwd: worktreeDir });
312
+
313
+ const remoteName = 'archive-source';
314
+ if (sourceRepoDir) {
315
+ await runMaybeQuiet('git', ['remote', 'add', remoteName, sourceRepoDir], { cwd: worktreeDir });
316
+ await runMaybeQuiet('git', ['fetch', '--tags', remoteName], { cwd: worktreeDir });
317
+ }
318
+
319
+ if (branch) {
320
+ await runMaybeQuiet('git', ['update-ref', `refs/heads/${branch}`, head], { cwd: worktreeDir });
321
+ await runMaybeQuiet('git', ['symbolic-ref', 'HEAD', `refs/heads/${branch}`], { cwd: worktreeDir });
322
+ } else {
323
+ await writeFile(join(worktreeDir, '.git', 'HEAD'), `${head}\n`, 'utf-8');
324
+ }
325
+
326
+ // Preserve staged state by copying the per-worktree index into the new repo.
327
+ if (!isBrokenLinkedWorktree) {
328
+ await copyFile(join(linkedGitDir, 'index'), join(worktreeDir, '.git', 'index')).catch(() => {});
329
+ } else if (head) {
330
+ // Populate the index from HEAD without touching the working tree, so uncommitted changes remain intact.
331
+ await runMaybeQuiet('git', ['read-tree', head], { cwd: worktreeDir }).catch(() => {});
332
+ }
333
+ // Avoid leaving a confusing untracked file behind in the archived repo.
334
+ await rm(join(worktreeDir, '.git.worktree'), { force: true }).catch(() => {});
335
+
336
+ return { worktreeDir, head, branch, sourceRepoDir, linkedGitDir, alreadyDetached: false };
337
+ }
338
+
339
+ async function findStacksReferencingWorktree({ rootDir, worktreeDir }) {
340
+ const workspaceDir = getWorkspaceDir(rootDir);
341
+ const wtReal = await realpath(worktreeDir).catch(() => resolve(worktreeDir));
342
+ const stackNames = await listAllStackNames();
343
+ const hits = [];
344
+
345
+ for (const name of stackNames) {
346
+ const { envPath } = resolveStackEnvPath(name);
347
+ const contents = await readFile(envPath, 'utf-8').catch(() => '');
348
+ if (!contents) continue;
349
+ const parsed = parseDotenv(contents);
350
+ const keys = [];
351
+
352
+ for (const [k, v] of parsed.entries()) {
353
+ if (!k.startsWith('HAPPY_STACKS_COMPONENT_DIR_') && !k.startsWith('HAPPY_LOCAL_COMPONENT_DIR_')) {
354
+ continue;
355
+ }
356
+ const raw = String(v ?? '').trim();
357
+ if (!raw) continue;
358
+ const abs = isAbsolute(raw) ? raw : resolve(workspaceDir, raw);
359
+ const absReal = await realpath(abs).catch(() => resolve(abs));
360
+ if (absReal === wtReal || absReal.startsWith(wtReal + '/')) {
361
+ keys.push(k);
362
+ }
363
+ }
364
+
365
+ if (keys.length) {
366
+ hits.push({ name, envPath, keys });
367
+ }
368
+ }
369
+
370
+ return hits;
371
+ }
372
+
122
373
  async function ensureWorktreeExclude(worktreeDir, patterns) {
123
374
  const gitDir = await getWorktreeGitDir(worktreeDir);
124
375
  const excludePath = join(gitDir, 'info', 'exclude');
@@ -317,7 +568,7 @@ function parseWorktreeListPorcelain(out) {
317
568
 
318
569
  function getComponentRepoRoot(rootDir, component) {
319
570
  // Respect component dir overrides so repos can live outside components/ (e.g. an existing checkout at ../happy-server).
320
- return getComponentDir(rootDir, component);
571
+ return getComponentRepoDir(rootDir, component);
321
572
  }
322
573
 
323
574
  async function resolveOwners(repoRoot) {
@@ -460,7 +711,8 @@ async function migrateComponentWorktrees({ rootDir, component }) {
460
711
  renamed += 1;
461
712
  }
462
713
 
463
- const destPath = join(wtRoot, component, owner, ...rest.split('/'));
714
+ const repoKey = worktreeRepoKeyForComponent(rootDir, component);
715
+ const destPath = join(wtRoot, repoKey, owner, ...rest.split('/'));
464
716
  await mkdir(dirname(destPath), { recursive: true });
465
717
 
466
718
  if (resolve(destPath) !== resolve(wtPath)) {
@@ -494,7 +746,16 @@ async function migrateComponentWorktrees({ rootDir, component }) {
494
746
  async function cmdMigrate({ rootDir }) {
495
747
  let totalMoved = 0;
496
748
  let totalRenamed = 0;
749
+ const seenRepoKeys = new Set();
750
+ const migrateComponents = [];
497
751
  for (const component of DEFAULT_COMPONENTS) {
752
+ const repoKey = worktreeRepoKeyForComponent(rootDir, component);
753
+ if (seenRepoKeys.has(repoKey)) continue;
754
+ seenRepoKeys.add(repoKey);
755
+ migrateComponents.push(component);
756
+ }
757
+
758
+ for (const component of migrateComponents) {
498
759
  const res = await migrateComponentWorktrees({ rootDir, component });
499
760
  totalMoved += res.moved;
500
761
  totalRenamed += res.renamed;
@@ -510,15 +771,27 @@ async function cmdMigrate({ rootDir }) {
510
771
 
511
772
  if (await pathExists(envPath)) {
512
773
  const raw = (await readTextIfExists(envPath)) ?? '';
774
+ const hasHappyMonorepo = isActiveHappyMonorepo(rootDir, 'happy');
513
775
  const rewrite = (v) => {
514
776
  if (!v.includes('/components/')) {
515
777
  return v;
516
778
  }
779
+ if (hasHappyMonorepo) {
780
+ return v
781
+ .replace('/components/happy-worktrees/', '/components/.worktrees/happy/')
782
+ .replace('/components/happy-cli-worktrees/', '/components/.worktrees/happy/')
783
+ .replace('/components/happy-server-worktrees/', '/components/.worktrees/happy/')
784
+ .replace('/components/happy-resume-upstream-clean', '/components/.worktrees/happy/')
785
+ .replace('/components/happy-cli-resume-upstream-clean', '/components/.worktrees/happy/')
786
+ .replace('/components/happy-server-resume-upstream-clean', '/components/.worktrees/happy/');
787
+ }
517
788
  return v
518
789
  .replace('/components/happy-worktrees/', '/components/.worktrees/happy/')
519
790
  .replace('/components/happy-cli-worktrees/', '/components/.worktrees/happy-cli/')
791
+ .replace('/components/happy-server-worktrees/', '/components/.worktrees/happy-server/')
520
792
  .replace('/components/happy-resume-upstream-clean', '/components/.worktrees/happy/')
521
- .replace('/components/happy-cli-resume-upstream-clean', '/components/.worktrees/happy-cli/');
793
+ .replace('/components/happy-cli-resume-upstream-clean', '/components/.worktrees/happy-cli/')
794
+ .replace('/components/happy-server-resume-upstream-clean', '/components/.worktrees/happy-server/');
522
795
  };
523
796
 
524
797
  for (const component of ['happy', 'happy-cli', 'happy-server-light', 'happy-server']) {
@@ -546,6 +819,8 @@ async function cmdUse({ rootDir, args, flags }) {
546
819
  throw new Error('[wt] usage: happys wt use <component> <owner/branch|path|default>');
547
820
  }
548
821
 
822
+ let updateComponents = [component];
823
+
549
824
  // Safety: main stack should not be repointed to arbitrary worktrees by default.
550
825
  // This is the most common “oops, the main stack now runs my PR checkout” footgun (especially for agents).
551
826
  const force = Boolean(flags?.has('--force'));
@@ -553,7 +828,7 @@ async function cmdUse({ rootDir, args, flags }) {
553
828
  throw new Error(
554
829
  `[wt] refusing to change main stack component override by default.\n` +
555
830
  `- stack: main\n` +
556
- `- component: ${component}\n` +
831
+ `- component: ${component}${updateComponents.length > 1 ? ` (monorepo group: ${updateComponents.join(', ')})` : ''}\n` +
557
832
  `- requested: ${spec}\n` +
558
833
  `\n` +
559
834
  `Recommendation:\n` +
@@ -566,7 +841,6 @@ async function cmdUse({ rootDir, args, flags }) {
566
841
  );
567
842
  }
568
843
 
569
- const key = componentDirEnvKey(component);
570
844
  const worktreesRoot = getWorktreesRoot(rootDir);
571
845
  const envPath = process.env.HAPPY_STACKS_ENV_FILE?.trim()
572
846
  ? process.env.HAPPY_STACKS_ENV_FILE.trim()
@@ -575,48 +849,65 @@ async function cmdUse({ rootDir, args, flags }) {
575
849
  : null;
576
850
 
577
851
  if (spec === 'default' || spec === 'main') {
852
+ // If the active checkout is a monorepo, reset the whole monorepo group together.
853
+ if (isHappyMonorepoGroupComponent(component) && isActiveHappyMonorepo(rootDir, component)) {
854
+ updateComponents = HAPPY_MONOREPO_GROUP_COMPONENTS;
855
+ }
856
+ const updates = updateComponents.map((c) => ({ key: componentDirEnvKey(c), value: '' }));
578
857
  // Clear override by setting it to empty (env.local keeps a record of last use, but override becomes inactive).
579
- await (envPath
580
- ? ensureEnvFileUpdated({ envPath, updates: [{ key, value: '' }] })
581
- : ensureEnvLocalUpdated({ rootDir, updates: [{ key, value: '' }] }));
582
- return { component, activeDir: join(getComponentsDir(rootDir), component), mode: 'default' };
583
- }
584
-
585
- let dir = spec;
586
- if (!isAbsolute(dir)) {
587
- // Allow passing a repo-relative path (e.g. "components/happy-cli") as an escape hatch.
588
- const rel = resolve(getWorkspaceDir(rootDir), dir);
589
- if (await pathExists(rel)) {
590
- dir = rel;
591
- } else {
592
- // Interpret as <owner>/<rest...> under components/.worktrees/<component>/.
593
- dir = join(worktreesRoot, component, ...spec.split('/'));
858
+ await (envPath ? ensureEnvFileUpdated({ envPath, updates }) : ensureEnvLocalUpdated({ rootDir, updates }));
859
+ return { component, activeDir: getDefaultComponentDir(rootDir, component), mode: 'default', updatedComponents: updateComponents };
860
+ }
861
+
862
+ // Resolve the target to a concrete directory. This returns a component directory (e.g. .../cli)
863
+ // in monorepo mode, and a repo root for single-repo components.
864
+ const resolvedDir = resolveComponentWorktreeDir({ rootDir, component, spec });
865
+ if (!resolvedDir) {
866
+ throw new Error(`[wt] unable to resolve spec: ${spec}`);
867
+ }
868
+
869
+ let writeDir = resolvedDir;
870
+ if (isHappyMonorepoGroupComponent(component)) {
871
+ const monoRoot = coerceHappyMonorepoRootFromPath(resolvedDir);
872
+ if (monoRoot) {
873
+ updateComponents = HAPPY_MONOREPO_GROUP_COMPONENTS;
874
+ writeDir = monoRoot;
875
+ } else if (isActiveHappyMonorepo(rootDir, component)) {
876
+ // If the active checkout is a monorepo, refuse switching to a non-monorepo target for group components.
877
+ updateComponents = HAPPY_MONOREPO_GROUP_COMPONENTS;
878
+ throw new Error(
879
+ `[wt] invalid target for happy monorepo component '${component}':\n` +
880
+ `- expected a path inside the happy monorepo (contains expo-app/cli/server)\n` +
881
+ `- but got: ${resolvedDir}\n` +
882
+ `Fix: pick a worktree under ${join(worktreesRoot, 'happy')}/ or pass an absolute path to a monorepo checkout.`
883
+ );
594
884
  }
595
- } else {
596
- dir = resolve(dir);
597
885
  }
598
886
 
599
- if (!(await pathExists(dir))) {
600
- throw new Error(`[wt] target does not exist: ${dir}`);
887
+ if (!(await pathExists(writeDir))) {
888
+ throw new Error(`[wt] target does not exist: ${writeDir}`);
601
889
  }
602
890
 
603
- if (component === 'happy-server-light' || component === 'happy-server') {
604
- const mismatch = detectServerComponentDirMismatch({ rootDir, serverComponentName: component, serverDir: dir });
605
- if (mismatch) {
606
- throw new Error(
607
- `[wt] invalid target for ${component}:\n` +
608
- `- expected a checkout of: ${mismatch.expected}\n` +
609
- `- but the path points inside: ${mismatch.actual}\n` +
610
- `- path: ${mismatch.serverDir}\n` +
611
- `Fix: pick a worktree under components/.worktrees/${mismatch.expected}/ (or run: happys wt use ${mismatch.actual} <spec>).`
612
- );
613
- }
891
+ for (const c of updateComponents) {
892
+ if (c !== 'happy-server-light' && c !== 'happy-server') continue;
893
+ const serverDir = resolveComponentSpecToDir({ rootDir, component: c, spec: writeDir }) ?? writeDir;
894
+ const mismatch = detectServerComponentDirMismatch({ rootDir, serverComponentName: c, serverDir });
895
+ if (!mismatch) continue;
896
+ const expectedRepoKey = worktreeRepoKeyForComponent(rootDir, mismatch.expected);
897
+ throw new Error(
898
+ `[wt] invalid target for ${c}:\n` +
899
+ `- expected a checkout of: ${mismatch.expected}\n` +
900
+ `- but the path points inside: ${mismatch.actual}\n` +
901
+ `- path: ${mismatch.serverDir}\n` +
902
+ `Fix: pick a worktree under components/.worktrees/${expectedRepoKey}/ (or run: happys wt use ${mismatch.actual} <spec>).`
903
+ );
614
904
  }
615
905
 
616
- await (envPath
617
- ? ensureEnvFileUpdated({ envPath, updates: [{ key, value: dir }] })
618
- : ensureEnvLocalUpdated({ rootDir, updates: [{ key, value: dir }] }));
619
- return { component, activeDir: dir, mode: 'override' };
906
+ const updates = updateComponents.map((c) => ({ key: componentDirEnvKey(c), value: writeDir }));
907
+ await (envPath ? ensureEnvFileUpdated({ envPath, updates }) : ensureEnvLocalUpdated({ rootDir, updates }));
908
+
909
+ const activeDir = resolveComponentSpecToDir({ rootDir, component, spec: writeDir }) ?? writeDir;
910
+ return { component, activeDir, mode: 'override', updatedComponents: updateComponents };
620
911
  }
621
912
 
622
913
  async function cmdUseInteractive({ rootDir }) {
@@ -626,25 +917,7 @@ async function cmdUseInteractive({ rootDir }) {
626
917
  throw new Error('[wt] component is required');
627
918
  }
628
919
 
629
- const wtRoot = getWorktreesRoot(rootDir);
630
- const base = join(wtRoot, component);
631
- const specs = [];
632
- const walk = async (d, prefix) => {
633
- const entries = await readdir(d, { withFileTypes: true });
634
- for (const e of entries) {
635
- if (!e.isDirectory()) continue;
636
- const p = join(d, e.name);
637
- const nextPrefix = prefix ? `${prefix}/${e.name}` : e.name;
638
- if (await pathExists(join(p, '.git'))) {
639
- specs.push(nextPrefix);
640
- }
641
- await walk(p, nextPrefix);
642
- }
643
- };
644
- if (await pathExists(base)) {
645
- await walk(base, '');
646
- }
647
- specs.sort();
920
+ const specs = await listWorktreeSpecs({ rootDir, component });
648
921
 
649
922
  const kindOptions = [{ label: 'default', value: 'default' }];
650
923
  if (specs.length) {
@@ -717,8 +990,9 @@ async function cmdNew({ rootDir, argv }) {
717
990
  const branchName = `${owner}/${slug}`;
718
991
 
719
992
  const worktreesRoot = getWorktreesRoot(rootDir);
720
- const destPath = join(worktreesRoot, component, owner, ...slug.split('/'));
721
- await mkdir(dirname(destPath), { recursive: true });
993
+ const repoKey = worktreeRepoKeyForComponent(rootDir, component);
994
+ const destWorktreeRoot = join(worktreesRoot, repoKey, owner, ...slug.split('/'));
995
+ await mkdir(dirname(destWorktreeRoot), { recursive: true });
722
996
 
723
997
  // Ensure remotes are present.
724
998
  await git(repoRoot, ['fetch', '--all', '--prune', '--quiet']);
@@ -733,37 +1007,23 @@ async function cmdNew({ rootDir, argv }) {
733
1007
  // If the branch already exists (common when migrating between workspaces),
734
1008
  // attach a new worktree to that branch instead of failing.
735
1009
  if (await gitOk(repoRoot, ['show-ref', '--verify', `refs/heads/${branchName}`])) {
736
- await git(repoRoot, ['worktree', 'add', destPath, branchName]);
1010
+ await git(repoRoot, ['worktree', 'add', destWorktreeRoot, branchName]);
737
1011
  } else {
738
- await git(repoRoot, ['worktree', 'add', '-b', branchName, destPath, base]);
1012
+ await git(repoRoot, ['worktree', 'add', '-b', branchName, destWorktreeRoot, base]);
739
1013
  }
740
1014
 
741
1015
  const depsMode = parseDepsMode(kv.get('--deps'));
742
- const deps = await maybeSetupDeps({ repoRoot, baseDir: baseWorktreeDir || '', worktreeDir: destPath, depsMode, component });
1016
+ const depsDir = resolveComponentSpecToDir({ rootDir, component, spec: destWorktreeRoot }) ?? destWorktreeRoot;
1017
+ const deps = await maybeSetupDeps({ repoRoot, baseDir: baseWorktreeDir || '', worktreeDir: depsDir, depsMode, component });
743
1018
 
744
1019
  const shouldUse = flags.has('--use');
745
1020
  const force = flags.has('--force');
746
1021
  if (shouldUse) {
747
- if (isMainStack() && !force) {
748
- throw new Error(
749
- `[wt] refusing to set main stack component override via --use by default.\n` +
750
- `- stack: main\n` +
751
- `- component: ${component}\n` +
752
- `- new worktree: ${destPath}\n` +
753
- `\n` +
754
- `Recommendation:\n` +
755
- `- Use an isolated stack instead:\n` +
756
- ` happys stack new exp1 --interactive\n` +
757
- ` happys stack wt exp1 -- use ${component} ${owner}/${slug}\n` +
758
- `\n` +
759
- `If you really intend to repoint the main stack, re-run with --force:\n` +
760
- ` happys wt new ${component} ${slug} --use --force\n`
761
- );
762
- }
763
- const key = componentDirEnvKey(component);
764
- await ensureEnvLocalUpdated({ rootDir, updates: [{ key, value: destPath }] });
1022
+ // Delegate to cmdUse so monorepo components stay coherent (and so stack-mode writes to the stack env file).
1023
+ await cmdUse({ rootDir, args: [component, destWorktreeRoot], flags });
765
1024
  }
766
- return { component, branch: branchName, path: destPath, base, used: shouldUse, deps };
1025
+
1026
+ return { component, branch: branchName, path: depsDir, base, used: shouldUse, deps, repoKey, worktreeRoot: destWorktreeRoot };
767
1027
  }
768
1028
 
769
1029
  async function cmdDuplicate({ rootDir, argv }) {
@@ -839,13 +1099,14 @@ async function cmdPr({ rootDir, argv }) {
839
1099
  const branchName = `${owner}/${slug}`;
840
1100
 
841
1101
  const worktreesRoot = getWorktreesRoot(rootDir);
842
- const destPath = join(worktreesRoot, component, owner, ...slug.split('/'));
843
- await mkdir(dirname(destPath), { recursive: true });
1102
+ const repoKey = worktreeRepoKeyForComponent(rootDir, component);
1103
+ const destWorktreeRoot = join(worktreesRoot, repoKey, owner, ...slug.split('/'));
1104
+ await mkdir(dirname(destWorktreeRoot), { recursive: true });
844
1105
 
845
- const exists = await pathExists(destPath);
1106
+ const exists = await pathExists(destWorktreeRoot);
846
1107
  const doUpdate = flags.has('--update');
847
1108
  if (exists && !doUpdate) {
848
- throw new Error(`[wt] destination already exists: ${destPath}\n[wt] re-run with --update to refresh it`);
1109
+ throw new Error(`[wt] destination already exists: ${destWorktreeRoot}\n[wt] re-run with --update to refresh it`);
849
1110
  }
850
1111
 
851
1112
  // Fetch PR head ref (GitHub convention). Use + to allow force-updated PR branches when --force is set.
@@ -857,16 +1118,16 @@ async function cmdPr({ rootDir, argv }) {
857
1118
  if (exists) {
858
1119
  // Update existing worktree.
859
1120
  const stash = await maybeStash({
860
- dir: destPath,
1121
+ dir: destWorktreeRoot,
861
1122
  enabled: flags.has('--stash'),
862
1123
  keep: flags.has('--stash-keep'),
863
1124
  message: `[happy-stacks] wt pr ${component} ${pr.number}`,
864
1125
  });
865
- if (!(await isWorktreeClean(destPath)) && !stash.stashed) {
866
- throw new Error(`[wt] worktree is not clean (${destPath}). Re-run with --stash to auto-stash changes.`);
1126
+ if (!(await isWorktreeClean(destWorktreeRoot)) && !stash.stashed) {
1127
+ throw new Error(`[wt] worktree is not clean (${destWorktreeRoot}). Re-run with --stash to auto-stash changes.`);
867
1128
  }
868
1129
 
869
- oldHead = (await git(destPath, ['rev-parse', 'HEAD'])).trim();
1130
+ oldHead = (await git(destWorktreeRoot, ['rev-parse', 'HEAD'])).trim();
870
1131
  await git(repoRoot, ['fetch', '--quiet', fetchTarget ?? remoteName, prRef]);
871
1132
  const newTip = (await git(repoRoot, ['rev-parse', 'FETCH_HEAD'])).trim();
872
1133
 
@@ -883,27 +1144,27 @@ async function cmdPr({ rootDir, argv }) {
883
1144
 
884
1145
  // Update working tree to the fetched tip.
885
1146
  if (isAncestor) {
886
- await git(destPath, ['merge', '--ff-only', newTip]);
1147
+ await git(destWorktreeRoot, ['merge', '--ff-only', newTip]);
887
1148
  } else {
888
- await git(destPath, ['reset', '--hard', newTip]);
1149
+ await git(destWorktreeRoot, ['reset', '--hard', newTip]);
889
1150
  }
890
1151
 
891
1152
  // Only attempt to restore stash if update succeeded without forcing a conflict state.
892
- const stashPop = await maybePopStash({ dir: destPath, stashed: stash.stashed, keep: stash.kept });
1153
+ const stashPop = await maybePopStash({ dir: destWorktreeRoot, stashed: stash.stashed, keep: stash.kept });
893
1154
  if (stashPop.popError) {
894
1155
  if (!force && oldHead) {
895
- await hardReset({ dir: destPath, target: oldHead });
1156
+ await hardReset({ dir: destWorktreeRoot, target: oldHead });
896
1157
  throw new Error(
897
1158
  `[wt] PR updated, but restoring stashed changes conflicted.\n` +
898
1159
  `[wt] Reverted update to keep your working tree clean.\n` +
899
- `[wt] Worktree: ${destPath}\n` +
1160
+ `[wt] Worktree: ${destWorktreeRoot}\n` +
900
1161
  `[wt] Re-run with --update --stash --force to keep the conflict state for manual resolution.`
901
1162
  );
902
1163
  }
903
1164
  // Keep conflict state in place (or if we can't revert).
904
1165
  throw new Error(
905
1166
  `[wt] PR updated, but restoring stashed changes conflicted.\n` +
906
- `[wt] Worktree: ${destPath}\n` +
1167
+ `[wt] Worktree: ${destWorktreeRoot}\n` +
907
1168
  `[wt] Conflicts are left in place for manual resolution (--force).`
908
1169
  );
909
1170
  }
@@ -919,34 +1180,37 @@ async function cmdPr({ rootDir, argv }) {
919
1180
  if (branchHead !== newTip) {
920
1181
  throw new Error(`[wt] branch already exists: ${branchName}\n[wt] re-run with --force to reset it to the PR head`);
921
1182
  }
922
- await git(repoRoot, ['worktree', 'add', destPath, branchName]);
1183
+ await git(repoRoot, ['worktree', 'add', destWorktreeRoot, branchName]);
923
1184
  } else {
924
1185
  await git(repoRoot, ['branch', '-f', branchName, newTip]);
925
- await git(repoRoot, ['worktree', 'add', destPath, branchName]);
1186
+ await git(repoRoot, ['worktree', 'add', destWorktreeRoot, branchName]);
926
1187
  }
927
1188
  } else {
928
1189
  // Create worktree at PR head (new local branch).
929
- await git(repoRoot, ['worktree', 'add', '-b', branchName, destPath, newTip]);
1190
+ await git(repoRoot, ['worktree', 'add', '-b', branchName, destWorktreeRoot, newTip]);
930
1191
  }
931
1192
  }
932
1193
 
933
1194
  // Optional deps handling (useful when PR branches add/change dependencies).
934
1195
  const depsMode = parseDepsMode(kv.get('--deps'));
935
- const deps = await maybeSetupDeps({ repoRoot, baseDir: repoRoot, worktreeDir: destPath, depsMode, component });
1196
+ const depsDir = resolveComponentSpecToDir({ rootDir, component, spec: destWorktreeRoot }) ?? destWorktreeRoot;
1197
+ const deps = await maybeSetupDeps({ repoRoot, baseDir: repoRoot, worktreeDir: depsDir, depsMode, component });
936
1198
 
937
1199
  const shouldUse = flags.has('--use');
938
1200
  if (shouldUse) {
939
1201
  // Reuse cmdUse so it writes to env.local or stack env file depending on context.
940
- await cmdUse({ rootDir, args: [component, destPath], flags });
1202
+ await cmdUse({ rootDir, args: [component, destWorktreeRoot], flags });
941
1203
  }
942
1204
 
943
- const newHead = (await git(destPath, ['rev-parse', 'HEAD'])).trim();
1205
+ const newHead = (await git(destWorktreeRoot, ['rev-parse', 'HEAD'])).trim();
944
1206
  const res = {
945
1207
  component,
946
1208
  pr: pr.number,
947
1209
  remote: remoteName,
948
1210
  branch: branchName,
949
- path: destPath,
1211
+ path: depsDir,
1212
+ worktreeRoot: destWorktreeRoot,
1213
+ repoKey,
950
1214
  used: shouldUse,
951
1215
  updated: exists,
952
1216
  oldHead,
@@ -1387,6 +1651,13 @@ async function openTerminalAuto({ dir, shell }) {
1387
1651
  return { kind: 'current' };
1388
1652
  }
1389
1653
 
1654
+ function resolveMonorepoEditorDir({ component, dir, preferPackageDir = false }) {
1655
+ if (!isHappyMonorepoGroupComponent(component)) return dir;
1656
+ if (preferPackageDir) return dir;
1657
+ const monoRoot = coerceHappyMonorepoRootFromPath(dir);
1658
+ return monoRoot || dir;
1659
+ }
1660
+
1390
1661
  async function cmdShell({ rootDir, argv }) {
1391
1662
  const { flags, kv } = parseArgs(argv);
1392
1663
  const json = wantsJson(argv, { flags });
@@ -1395,10 +1666,11 @@ async function cmdShell({ rootDir, argv }) {
1395
1666
  const spec = positionals[2] ?? '';
1396
1667
  if (!component) {
1397
1668
  throw new Error(
1398
- '[wt] usage: happys wt shell <component> [worktreeSpec|active|default|main|path] [--shell=/bin/zsh] [--terminal=auto|current|ghostty|iterm|terminal] [--new-window] [--json]'
1669
+ '[wt] usage: happys wt shell <component> [worktreeSpec|active|default|main|path] [--package] [--shell=/bin/zsh] [--terminal=auto|current|ghostty|iterm|terminal] [--new-window] [--json]'
1399
1670
  );
1400
1671
  }
1401
- const dir = resolveComponentWorktreeDir({ rootDir, component, spec });
1672
+ const packageDir = resolveComponentWorktreeDir({ rootDir, component, spec });
1673
+ const dir = resolveMonorepoEditorDir({ component, dir: packageDir, preferPackageDir: flags.has('--package') });
1402
1674
  if (!(await pathExists(dir))) {
1403
1675
  throw new Error(`[wt] target does not exist: ${dir}`);
1404
1676
  }
@@ -1444,9 +1716,10 @@ async function cmdCode({ rootDir, argv }) {
1444
1716
  const component = positionals[1];
1445
1717
  const spec = positionals[2] ?? '';
1446
1718
  if (!component) {
1447
- throw new Error('[wt] usage: happys wt code <component> [worktreeSpec|active|default|main|path] [--json]');
1719
+ throw new Error('[wt] usage: happys wt code <component> [worktreeSpec|active|default|main|path] [--package] [--json]');
1448
1720
  }
1449
- const dir = resolveComponentWorktreeDir({ rootDir, component, spec });
1721
+ const packageDir = resolveComponentWorktreeDir({ rootDir, component, spec });
1722
+ const dir = resolveMonorepoEditorDir({ component, dir: packageDir, preferPackageDir: flags.has('--package') });
1450
1723
  if (!(await pathExists(dir))) {
1451
1724
  throw new Error(`[wt] target does not exist: ${dir}`);
1452
1725
  }
@@ -1468,9 +1741,10 @@ async function cmdCursor({ rootDir, argv }) {
1468
1741
  const component = positionals[1];
1469
1742
  const spec = positionals[2] ?? '';
1470
1743
  if (!component) {
1471
- throw new Error('[wt] usage: happys wt cursor <component> [worktreeSpec|active|default|main|path] [--json]');
1744
+ throw new Error('[wt] usage: happys wt cursor <component> [worktreeSpec|active|default|main|path] [--package] [--json]');
1472
1745
  }
1473
- const dir = resolveComponentWorktreeDir({ rootDir, component, spec });
1746
+ const packageDir = resolveComponentWorktreeDir({ rootDir, component, spec });
1747
+ const dir = resolveMonorepoEditorDir({ component, dir: packageDir, preferPackageDir: flags.has('--package') });
1474
1748
  if (!(await pathExists(dir))) {
1475
1749
  throw new Error(`[wt] target does not exist: ${dir}`);
1476
1750
  }
@@ -1507,15 +1781,22 @@ async function cmdSyncAll({ rootDir, argv }) {
1507
1781
  const components = DEFAULT_COMPONENTS;
1508
1782
 
1509
1783
  const results = [];
1784
+ const seenRepoKeys = new Set();
1510
1785
  for (const component of components) {
1786
+ const repoKey = worktreeRepoKeyForComponent(rootDir, component);
1787
+ if (seenRepoKeys.has(repoKey)) {
1788
+ results.push({ component, ok: true, skipped: true, reason: `shared repo (${repoKey})` });
1789
+ continue;
1790
+ }
1791
+ seenRepoKeys.add(repoKey);
1511
1792
  try {
1512
1793
  const res = await cmdSync({
1513
1794
  rootDir,
1514
1795
  argv: remote ? ['sync', component, `--remote=${remote}`] : ['sync', component],
1515
1796
  });
1516
- results.push({ component, ok: true, ...res });
1797
+ results.push({ component, ok: true, skipped: false, repoKey, ...res });
1517
1798
  } catch (e) {
1518
- results.push({ component, ok: false, error: String(e?.message ?? e) });
1799
+ results.push({ component, ok: false, skipped: false, repoKey, error: String(e?.message ?? e) });
1519
1800
  }
1520
1801
  }
1521
1802
 
@@ -1526,7 +1807,9 @@ async function cmdSyncAll({ rootDir, argv }) {
1526
1807
 
1527
1808
  const lines = ['[wt] sync-all:'];
1528
1809
  for (const r of results) {
1529
- if (r.ok) {
1810
+ if (r.ok && r.skipped) {
1811
+ lines.push(`- ↪ ${r.component}: skipped (${r.reason})`);
1812
+ } else if (r.ok) {
1530
1813
  lines.push(`- ✅ ${r.component}: ${r.mirrorBranch} -> ${r.upstreamRef}`);
1531
1814
  } else {
1532
1815
  lines.push(`- ❌ ${r.component}: ${r.error}`);
@@ -1549,7 +1832,7 @@ async function cmdUpdateAll({ rootDir, argv }) {
1549
1832
  const { flags, kv } = parseArgs(argv);
1550
1833
  const positionals = argv.filter((a) => !a.startsWith('--'));
1551
1834
  const maybeComponent = positionals[1]?.trim() ? positionals[1].trim() : '';
1552
- const components = maybeComponent ? [maybeComponent] : DEFAULT_COMPONENTS;
1835
+ const requestedComponents = maybeComponent ? [maybeComponent] : DEFAULT_COMPONENTS;
1553
1836
 
1554
1837
  const json = wantsJson(argv, { flags });
1555
1838
 
@@ -1561,6 +1844,15 @@ async function cmdUpdateAll({ rootDir, argv }) {
1561
1844
  const stash = flags.has('--stash');
1562
1845
  const stashKeep = flags.has('--stash-keep');
1563
1846
 
1847
+ const seenRepoKeys = new Set();
1848
+ const components = [];
1849
+ for (const c of requestedComponents) {
1850
+ const repoKey = worktreeRepoKeyForComponent(rootDir, c);
1851
+ if (seenRepoKeys.has(repoKey)) continue;
1852
+ seenRepoKeys.add(repoKey);
1853
+ components.push(c);
1854
+ }
1855
+
1564
1856
  const results = [];
1565
1857
  for (const component of components) {
1566
1858
  const paths = await listComponentWorktreePaths({ rootDir, component });
@@ -1631,11 +1923,15 @@ async function cmdNewInteractive({ rootDir, argv }) {
1631
1923
  });
1632
1924
  }
1633
1925
 
1634
- async function cmdListOne({ rootDir, component }) {
1926
+ async function cmdListOne({ rootDir, component, activeOnly = false }) {
1635
1927
  const wtRoot = getWorktreesRoot(rootDir);
1636
- const dir = join(wtRoot, component);
1637
- const key = componentDirEnvKey(component);
1638
- const active = (process.env[key] ?? '').trim() || join(getComponentsDir(rootDir), component);
1928
+ const repoKey = worktreeRepoKeyForComponent(rootDir, component);
1929
+ const dir = join(wtRoot, repoKey);
1930
+ const active = getComponentDir(rootDir, component);
1931
+
1932
+ if (activeOnly) {
1933
+ return { component, activeDir: active, worktrees: [] };
1934
+ }
1639
1935
 
1640
1936
  if (!(await pathExists(dir))) {
1641
1937
  return { component, activeDir: active, worktrees: [] };
@@ -1660,19 +1956,197 @@ async function cmdListOne({ rootDir, component }) {
1660
1956
  await walk(dir);
1661
1957
  worktrees.sort();
1662
1958
 
1663
- return { component, activeDir: active, worktrees };
1959
+ const sub = happyMonorepoSubdirForComponent(component);
1960
+ const mapped = repoKey === 'happy' && isActiveHappyMonorepo(rootDir, component) && sub ? worktrees.map((p) => join(p, sub)) : worktrees;
1961
+ return { component, activeDir: active, worktrees: mapped };
1664
1962
  }
1665
1963
 
1666
- async function cmdList({ rootDir, args }) {
1964
+ async function cmdList({ rootDir, args, flags }) {
1965
+ const wantsAll = flags?.has('--all') || flags?.has('--all-worktrees');
1966
+ const activeOnly = !wantsAll && (flags?.has('--active') || flags?.has('--active-only'));
1967
+
1667
1968
  const component = args[0];
1668
1969
  if (!component) {
1669
1970
  const results = [];
1670
1971
  for (const c of DEFAULT_COMPONENTS) {
1671
- results.push(await cmdListOne({ rootDir, component: c }));
1972
+ results.push(await cmdListOne({ rootDir, component: c, activeOnly }));
1672
1973
  }
1673
1974
  return { components: DEFAULT_COMPONENTS, results };
1674
1975
  }
1675
- return await cmdListOne({ rootDir, component });
1976
+ return await cmdListOne({ rootDir, component, activeOnly });
1977
+ }
1978
+
1979
+ async function cmdArchive({ rootDir, argv }) {
1980
+ const { flags, kv } = parseArgs(argv);
1981
+ const dryRun = flags.has('--dry-run');
1982
+ const deleteBranch = !flags.has('--no-delete-branch');
1983
+ const detachStacks = flags.has('--detach-stacks');
1984
+
1985
+ const positionals = argv.filter((a) => !a.startsWith('--'));
1986
+ const component = (positionals[1] ?? '').trim();
1987
+ const spec = (positionals[2] ?? '').trim();
1988
+ if (!component) {
1989
+ throw new Error(
1990
+ '[wt] usage: happys wt archive <component> <worktreeSpec|path|active|default|main> [--dry-run] [--date=YYYY-MM-DD] [--no-delete-branch] [--detach-stacks] [--json]'
1991
+ );
1992
+ }
1993
+ if (!spec) {
1994
+ throw new Error(
1995
+ '[wt] usage: happys wt archive <component> <worktreeSpec|path|active|default|main> [--dry-run] [--date=YYYY-MM-DD] [--no-delete-branch] [--detach-stacks] [--json]'
1996
+ );
1997
+ }
1998
+
1999
+ const resolved = resolveComponentWorktreeDir({ rootDir, component, spec });
2000
+ if (!resolved) {
2001
+ throw new Error(`[wt] unable to resolve worktree: ${component} ${spec}`);
2002
+ }
2003
+
2004
+ let worktreeDir = resolved;
2005
+ try {
2006
+ worktreeDir = await gitShowTopLevel(resolved);
2007
+ } catch {
2008
+ // Broken worktrees can have a missing linked gitdir; fall back to the resolved directory.
2009
+ worktreeDir = resolved;
2010
+ }
2011
+ const worktreesRoot = resolve(getWorktreesRoot(rootDir));
2012
+ const worktreesRootReal = await realpath(worktreesRoot).catch(() => worktreesRoot);
2013
+ const worktreeDirReal = await realpath(worktreeDir).catch(() => worktreeDir);
2014
+ const rel = relative(worktreesRootReal, worktreeDirReal);
2015
+ if (!rel || rel.startsWith('..') || isAbsolute(rel)) {
2016
+ throw new Error(`[wt] refusing to archive non-worktree path (expected under ${worktreesRoot}): ${worktreeDir}`);
2017
+ }
2018
+
2019
+ const date = (kv.get('--date') ?? '').toString().trim() || getTodayYmd();
2020
+ const archiveRoot = join(dirname(worktreesRoot), '.worktrees-archive', date);
2021
+ const destDir = join(archiveRoot, rel);
2022
+
2023
+ const expectedBranch = rel.split('/').slice(1).join('/') || null;
2024
+ let head = '';
2025
+ let branch = null;
2026
+ try {
2027
+ head = (await git(worktreeDir, ['rev-parse', 'HEAD'])).trim();
2028
+ try {
2029
+ const b = (await git(worktreeDir, ['symbolic-ref', '--quiet', '--short', 'HEAD'])).trim();
2030
+ branch = b || null;
2031
+ } catch {
2032
+ branch = null;
2033
+ }
2034
+ } catch {
2035
+ // For broken linked worktrees, fall back to the branch implied by the worktree path.
2036
+ branch = expectedBranch;
2037
+ try {
2038
+ const gitFileContents = await readFile(join(worktreeDir, '.git'), 'utf-8');
2039
+ const linkedGitDirFromFile = parseGitdirFile(gitFileContents);
2040
+ if (linkedGitDirFromFile) {
2041
+ const linkedGitDir = isAbsolute(linkedGitDirFromFile) ? linkedGitDirFromFile : resolve(worktreeDir, linkedGitDirFromFile);
2042
+ const sourceRepoDir = inferSourceRepoDirFromLinkedGitDir(linkedGitDir);
2043
+ if (sourceRepoDir && branch) {
2044
+ head = (await runCapture('git', ['rev-parse', branch], { cwd: sourceRepoDir })).trim();
2045
+ }
2046
+ }
2047
+ } catch {
2048
+ head = '';
2049
+ }
2050
+ }
2051
+
2052
+ const workspaceDir = getWorkspaceDir(rootDir);
2053
+ const sourcePath = relative(workspaceDir, worktreeDir);
2054
+
2055
+ const linkedStacks = await findStacksReferencingWorktree({ rootDir, worktreeDir });
2056
+ if (dryRun) {
2057
+ return { ok: true, dryRun: true, component, worktreeDir, destDir, head, branch, deleteBranch, detachStacks, linkedStacks };
2058
+ }
2059
+
2060
+ let shouldDetachStacks = detachStacks;
2061
+ if (linkedStacks.length && !shouldDetachStacks) {
2062
+ const names = linkedStacks.map((s) => s.name).join(', ');
2063
+ if (!isTty() || isJsonMode()) {
2064
+ throw new Error(`[wt] refusing to archive worktree still referenced by stack(s): ${names}. Re-run with --detach-stacks.`);
2065
+ }
2066
+ const action = await withRl(async (rl) => {
2067
+ return await promptSelect(rl, {
2068
+ title: `Worktree is still referenced by stack(s): ${names}`,
2069
+ options: [
2070
+ { label: 'abort (recommended)', value: 'abort' },
2071
+ { label: 'detach those stacks from this worktree', value: 'detach' },
2072
+ { label: 'archive the linked stacks (also archives this worktree)', value: 'archive-stacks' },
2073
+ ],
2074
+ defaultIndex: 0,
2075
+ });
2076
+ });
2077
+
2078
+ if (action === 'abort') {
2079
+ throw new Error('[wt] archive aborted');
2080
+ }
2081
+ if (action === 'archive-stacks') {
2082
+ for (const s of linkedStacks) {
2083
+ // eslint-disable-next-line no-await-in-loop
2084
+ await run(process.execPath, [join(rootDir, 'scripts', 'stack.mjs'), 'archive', s.name, `--date=${date}`], { cwd: rootDir, env: process.env });
2085
+ }
2086
+ return {
2087
+ ok: true,
2088
+ dryRun: false,
2089
+ component,
2090
+ worktreeDir,
2091
+ destDir,
2092
+ head,
2093
+ branch,
2094
+ deleteBranch,
2095
+ detachStacks: false,
2096
+ linkedStacks,
2097
+ archivedVia: 'stack-archive',
2098
+ };
2099
+ }
2100
+ shouldDetachStacks = true;
2101
+ }
2102
+
2103
+ for (const s of linkedStacks) {
2104
+ if (!shouldDetachStacks) break;
2105
+ // eslint-disable-next-line no-await-in-loop
2106
+ await ensureEnvFilePruned({ envPath: s.envPath, removeKeys: s.keys });
2107
+ }
2108
+
2109
+ const detached = await detachGitWorktree({ worktreeDir, expectedBranch: expectedBranch ?? branch ?? null });
2110
+
2111
+ await mkdir(dirname(destDir), { recursive: true });
2112
+ await rename(worktreeDir, destDir);
2113
+
2114
+ const meta = [
2115
+ `archivedAt=${new Date().toISOString()}`,
2116
+ `component=${component}`,
2117
+ `ref=${rel.split('/').slice(1).join('/')}`,
2118
+ `sourcePath=${sourcePath}`,
2119
+ `head=${detached.head || head}`,
2120
+ '',
2121
+ ].join('\n');
2122
+ await writeFile(join(destDir, 'ARCHIVE_META.txt'), meta, 'utf-8');
2123
+
2124
+ // Remove the stale worktree registry entry (its path is now gone).
2125
+ if (detached.sourceRepoDir && !detached.alreadyDetached) {
2126
+ await runMaybeQuiet('git', ['worktree', 'prune'], { cwd: detached.sourceRepoDir });
2127
+ }
2128
+
2129
+ if (deleteBranch && detached.branch && detached.sourceRepoDir && !detached.alreadyDetached) {
2130
+ const worktreesRaw = await runCapture('git', ['worktree', 'list', '--porcelain'], { cwd: detached.sourceRepoDir });
2131
+ const inUse = worktreesRaw.includes(`branch refs/heads/${detached.branch}`);
2132
+ if (inUse) {
2133
+ throw new Error(`[wt] refusing to delete branch still checked out by a worktree: ${detached.branch}`);
2134
+ }
2135
+ await runMaybeQuiet('git', ['branch', '-D', detached.branch], { cwd: detached.sourceRepoDir });
2136
+ }
2137
+
2138
+ return {
2139
+ ok: true,
2140
+ dryRun: false,
2141
+ component,
2142
+ worktreeDir,
2143
+ destDir,
2144
+ head: detached.head || head,
2145
+ branch: detached.branch,
2146
+ deleteBranch,
2147
+ detachStacks,
2148
+ linkedStacks,
2149
+ };
1676
2150
  }
1677
2151
 
1678
2152
  async function main() {
@@ -1688,7 +2162,7 @@ async function main() {
1688
2162
  printResult({
1689
2163
  json,
1690
2164
  data: {
1691
- commands: ['migrate', 'sync', 'sync-all', 'list', 'new', 'pr', 'use', 'status', 'update', 'update-all', 'push', 'git', 'shell', 'code', 'cursor'],
2165
+ commands: ['migrate', 'sync', 'sync-all', 'list', 'new', 'pr', 'use', 'status', 'update', 'update-all', 'push', 'git', 'shell', 'code', 'cursor', 'archive'],
1692
2166
  interactive: ['new', 'use'],
1693
2167
  },
1694
2168
  text: [
@@ -1696,7 +2170,7 @@ async function main() {
1696
2170
  ' happys wt migrate [--json]',
1697
2171
  ' happys wt sync <component> [--remote=<name>] [--json]',
1698
2172
  ' happys wt sync-all [--remote=<name>] [--json]',
1699
- ' happys wt list [component] [--json]',
2173
+ ' happys wt list [component] [--active|--all] [--json]',
1700
2174
  ' happys wt new <component> <slug> [--from=upstream|origin] [--remote=<name>] [--base=<ref>|--base-worktree=<spec>] [--deps=none|link|install|link-or-install] [--use] [--force] [--interactive|-i] [--json]',
1701
2175
  ' happys wt duplicate <component> <fromWorktreeSpec|path|active|default> <newSlug> [--remote=<name>] [--deps=none|link|install|link-or-install] [--use] [--json]',
1702
2176
  ' happys wt pr <component> <pr-url|number> [--remote=upstream] [--slug=<name>] [--deps=none|link|install|link-or-install] [--use] [--update] [--stash|--stash-keep] [--force] [--json]',
@@ -1709,13 +2183,18 @@ async function main() {
1709
2183
  ' happys wt shell <component> [worktreeSpec|active|default|main|path] [--shell=/bin/zsh] [--json]',
1710
2184
  ' happys wt code <component> [worktreeSpec|active|default|main|path] [--json]',
1711
2185
  ' happys wt cursor <component> [worktreeSpec|active|default|main|path] [--json]',
2186
+ ' happys wt archive <component> <worktreeSpec|active|default|main|path> [--dry-run] [--date=YYYY-MM-DD] [--no-delete-branch] [--detach-stacks] [--json]',
1712
2187
  '',
1713
2188
  'selectors:',
1714
2189
  ' (omitted) or "active": current active checkout (env override if set; else components/<component>)',
1715
- ' "default" or "main": components/<component>',
1716
- ' "<owner>/<branch...>": components/.worktrees/<component>/<owner>/<branch...>',
2190
+ ' "default" or "main": components/<component> (monorepo: derived from components/happy)',
2191
+ ' "<owner>/<branch...>": components/.worktrees/<component>/<owner>/<branch...> (monorepo: components/.worktrees/happy/<owner>/<branch...>)',
1717
2192
  ' "<absolute path>": explicit checkout path',
1718
2193
  '',
2194
+ 'monorepo notes:',
2195
+ '- happy, happy-cli, and happy-server can share a single git worktree (slopus/happy).',
2196
+ '- In monorepo mode, `wt use` updates all three component dir overrides together.',
2197
+ '',
1719
2198
  'components:',
1720
2199
  ` ${DEFAULT_COMPONENTS.join(' | ')}`,
1721
2200
  ].join('\n'),
@@ -1872,7 +2351,7 @@ async function main() {
1872
2351
  return;
1873
2352
  }
1874
2353
  if (cmd === 'list') {
1875
- const res = await cmdList({ rootDir, args: positionals.slice(1) });
2354
+ const res = await cmdList({ rootDir, args: positionals.slice(1), flags });
1876
2355
  if (json) {
1877
2356
  printResult({ json, data: res });
1878
2357
  } else {
@@ -1890,6 +2369,17 @@ async function main() {
1890
2369
  }
1891
2370
  return;
1892
2371
  }
2372
+ if (cmd === 'archive') {
2373
+ const res = await cmdArchive({ rootDir, argv });
2374
+ if (json) {
2375
+ printResult({ json, data: res });
2376
+ } else if (res.dryRun) {
2377
+ printResult({ json: false, text: `[wt] would archive ${res.component}: ${res.worktreeDir} -> ${res.destDir} (dry-run)` });
2378
+ } else {
2379
+ printResult({ json: false, text: `[wt] archived ${res.component}: ${res.destDir}` });
2380
+ }
2381
+ return;
2382
+ }
1893
2383
  throw new Error(`[wt] unknown command: ${cmd}`);
1894
2384
  }
1895
2385