happy-stacks 0.3.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 (165) hide show
  1. package/README.md +93 -40
  2. package/bin/happys.mjs +158 -16
  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 +3 -4
  10. package/docs/isolated-linux-vm.md +82 -0
  11. package/docs/mobile-ios.md +112 -54
  12. package/docs/monorepo-migration.md +286 -0
  13. package/docs/server-flavors.md +19 -3
  14. package/docs/stacks.md +35 -0
  15. package/package.json +5 -1
  16. package/scripts/auth.mjs +32 -10
  17. package/scripts/build.mjs +55 -8
  18. package/scripts/daemon.mjs +166 -10
  19. package/scripts/dev.mjs +198 -50
  20. package/scripts/doctor.mjs +0 -4
  21. package/scripts/edison.mjs +6 -4
  22. package/scripts/env.mjs +150 -0
  23. package/scripts/env_cmd.test.mjs +128 -0
  24. package/scripts/init.mjs +8 -3
  25. package/scripts/install.mjs +207 -69
  26. package/scripts/lint.mjs +24 -4
  27. package/scripts/migrate.mjs +3 -12
  28. package/scripts/mobile.mjs +88 -104
  29. package/scripts/mobile_dev_client.mjs +83 -0
  30. package/scripts/monorepo.mjs +1096 -0
  31. package/scripts/monorepo_port.test.mjs +1470 -0
  32. package/scripts/provision/linux-ubuntu-review-pr.sh +51 -0
  33. package/scripts/review.mjs +908 -0
  34. package/scripts/review_pr.mjs +353 -0
  35. package/scripts/run.mjs +101 -21
  36. package/scripts/service.mjs +2 -2
  37. package/scripts/setup.mjs +189 -68
  38. package/scripts/setup_pr.mjs +586 -38
  39. package/scripts/stack.mjs +990 -196
  40. package/scripts/stack_archive_cmd.test.mjs +91 -0
  41. package/scripts/stack_editor_workspace_monorepo_root.test.mjs +65 -0
  42. package/scripts/stack_env_cmd.test.mjs +87 -0
  43. package/scripts/stack_happy_cmd.test.mjs +126 -0
  44. package/scripts/stack_interactive_monorepo_group.test.mjs +71 -0
  45. package/scripts/stack_monorepo_defaults.test.mjs +62 -0
  46. package/scripts/stack_monorepo_server_light_from_happy_spec.test.mjs +66 -0
  47. package/scripts/stack_server_flavors_defaults.test.mjs +55 -0
  48. package/scripts/stack_shorthand_cmd.test.mjs +55 -0
  49. package/scripts/stack_wt_list.test.mjs +128 -0
  50. package/scripts/tailscale.mjs +37 -1
  51. package/scripts/test.mjs +45 -8
  52. package/scripts/tui.mjs +395 -39
  53. package/scripts/typecheck.mjs +24 -4
  54. package/scripts/utils/auth/daemon_gate.mjs +55 -0
  55. package/scripts/utils/auth/daemon_gate.test.mjs +37 -0
  56. package/scripts/utils/auth/guided_pr_auth.mjs +79 -0
  57. package/scripts/utils/auth/guided_stack_web_login.mjs +75 -0
  58. package/scripts/utils/auth/interactive_stack_auth.mjs +72 -0
  59. package/scripts/utils/auth/login_ux.mjs +32 -13
  60. package/scripts/utils/auth/sources.mjs +26 -0
  61. package/scripts/utils/auth/stack_guided_login.mjs +353 -0
  62. package/scripts/utils/cli/cli_registry.mjs +43 -4
  63. package/scripts/utils/cli/cwd_scope.mjs +136 -0
  64. package/scripts/utils/cli/cwd_scope.test.mjs +110 -0
  65. package/scripts/utils/cli/log_forwarder.mjs +157 -0
  66. package/scripts/utils/cli/prereqs.mjs +75 -0
  67. package/scripts/utils/cli/prereqs.test.mjs +34 -0
  68. package/scripts/utils/cli/progress.mjs +126 -0
  69. package/scripts/utils/cli/verbosity.mjs +12 -0
  70. package/scripts/utils/cli/wizard.mjs +17 -9
  71. package/scripts/utils/cli/wizard_prompt_worktree_source_lazy.test.mjs +60 -0
  72. package/scripts/utils/dev/daemon.mjs +61 -4
  73. package/scripts/utils/dev/expo_dev.mjs +430 -0
  74. package/scripts/utils/dev/expo_dev.test.mjs +76 -0
  75. package/scripts/utils/dev/server.mjs +36 -42
  76. package/scripts/utils/dev_auth_key.mjs +169 -0
  77. package/scripts/utils/edison/git_roots.mjs +29 -0
  78. package/scripts/utils/edison/git_roots.test.mjs +36 -0
  79. package/scripts/utils/env/env.mjs +7 -3
  80. package/scripts/utils/env/env_file.mjs +4 -2
  81. package/scripts/utils/env/env_file.test.mjs +44 -0
  82. package/scripts/utils/expo/command.mjs +52 -0
  83. package/scripts/utils/expo/expo.mjs +20 -1
  84. package/scripts/utils/expo/metro_ports.mjs +114 -0
  85. package/scripts/utils/git/git.mjs +67 -0
  86. package/scripts/utils/git/worktrees.mjs +80 -25
  87. package/scripts/utils/git/worktrees_monorepo.test.mjs +54 -0
  88. package/scripts/utils/handy_master_secret.mjs +94 -0
  89. package/scripts/utils/mobile/config.mjs +31 -0
  90. package/scripts/utils/mobile/dev_client_links.mjs +60 -0
  91. package/scripts/utils/mobile/identifiers.mjs +47 -0
  92. package/scripts/utils/mobile/identifiers.test.mjs +42 -0
  93. package/scripts/utils/mobile/ios_xcodeproj_patch.mjs +128 -0
  94. package/scripts/utils/mobile/ios_xcodeproj_patch.test.mjs +98 -0
  95. package/scripts/utils/net/lan_ip.mjs +24 -0
  96. package/scripts/utils/net/ports.mjs +9 -1
  97. package/scripts/utils/net/tcp_forward.mjs +162 -0
  98. package/scripts/utils/net/url.mjs +30 -0
  99. package/scripts/utils/net/url.test.mjs +20 -0
  100. package/scripts/utils/paths/localhost_host.mjs +50 -3
  101. package/scripts/utils/paths/paths.mjs +159 -40
  102. package/scripts/utils/paths/paths_monorepo.test.mjs +58 -0
  103. package/scripts/utils/paths/paths_server_flavors.test.mjs +45 -0
  104. package/scripts/utils/proc/commands.mjs +2 -3
  105. package/scripts/utils/proc/parallel.mjs +25 -0
  106. package/scripts/utils/proc/pm.mjs +176 -22
  107. package/scripts/utils/proc/pm_spawn.test.mjs +76 -0
  108. package/scripts/utils/proc/pm_stack_cache_env.test.mjs +142 -0
  109. package/scripts/utils/proc/proc.mjs +136 -4
  110. package/scripts/utils/proc/proc.test.mjs +77 -0
  111. package/scripts/utils/review/base_ref.mjs +74 -0
  112. package/scripts/utils/review/base_ref.test.mjs +54 -0
  113. package/scripts/utils/review/chunks.mjs +55 -0
  114. package/scripts/utils/review/chunks.test.mjs +51 -0
  115. package/scripts/utils/review/findings.mjs +165 -0
  116. package/scripts/utils/review/findings.test.mjs +85 -0
  117. package/scripts/utils/review/head_slice.mjs +153 -0
  118. package/scripts/utils/review/head_slice.test.mjs +91 -0
  119. package/scripts/utils/review/instructions/deep.md +20 -0
  120. package/scripts/utils/review/runners/coderabbit.mjs +61 -0
  121. package/scripts/utils/review/runners/coderabbit.test.mjs +59 -0
  122. package/scripts/utils/review/runners/codex.mjs +61 -0
  123. package/scripts/utils/review/runners/codex.test.mjs +35 -0
  124. package/scripts/utils/review/slices.mjs +140 -0
  125. package/scripts/utils/review/slices.test.mjs +32 -0
  126. package/scripts/utils/review/targets.mjs +24 -0
  127. package/scripts/utils/review/targets.test.mjs +36 -0
  128. package/scripts/utils/sandbox/review_pr_sandbox.mjs +106 -0
  129. package/scripts/utils/server/flavor_scripts.mjs +98 -0
  130. package/scripts/utils/server/flavor_scripts.test.mjs +146 -0
  131. package/scripts/utils/server/mobile_api_url.mjs +61 -0
  132. package/scripts/utils/server/mobile_api_url.test.mjs +41 -0
  133. package/scripts/utils/server/prisma_import.mjs +37 -0
  134. package/scripts/utils/server/prisma_import.test.mjs +70 -0
  135. package/scripts/utils/server/ui_env.mjs +14 -0
  136. package/scripts/utils/server/ui_env.test.mjs +46 -0
  137. package/scripts/utils/server/urls.mjs +14 -4
  138. package/scripts/utils/server/validate.mjs +53 -16
  139. package/scripts/utils/server/validate.test.mjs +89 -0
  140. package/scripts/utils/service/autostart_darwin.mjs +42 -2
  141. package/scripts/utils/service/autostart_darwin.test.mjs +50 -0
  142. package/scripts/utils/stack/context.mjs +2 -2
  143. package/scripts/utils/stack/editor_workspace.mjs +6 -6
  144. package/scripts/utils/stack/interactive_stack_config.mjs +185 -0
  145. package/scripts/utils/stack/pr_stack_name.mjs +16 -0
  146. package/scripts/utils/stack/runtime_state.mjs +2 -1
  147. package/scripts/utils/stack/startup.mjs +120 -13
  148. package/scripts/utils/stack/startup_server_light_dirs.test.mjs +64 -0
  149. package/scripts/utils/stack/startup_server_light_generate.test.mjs +70 -0
  150. package/scripts/utils/stack/startup_server_light_legacy.test.mjs +88 -0
  151. package/scripts/utils/stack/stop.mjs +15 -4
  152. package/scripts/utils/stack_context.mjs +23 -0
  153. package/scripts/utils/stack_runtime_state.mjs +104 -0
  154. package/scripts/utils/stacks.mjs +38 -0
  155. package/scripts/utils/tailscale/ip.mjs +116 -0
  156. package/scripts/utils/ui/ansi.mjs +39 -0
  157. package/scripts/utils/ui/qr.mjs +17 -0
  158. package/scripts/utils/validate.mjs +88 -0
  159. package/scripts/where.mjs +2 -2
  160. package/scripts/worktrees.mjs +755 -179
  161. package/scripts/worktrees_archive_cmd.test.mjs +245 -0
  162. package/scripts/worktrees_cursor_monorepo_root.test.mjs +63 -0
  163. package/scripts/worktrees_list_specs_no_recurse.test.mjs +33 -0
  164. package/scripts/worktrees_monorepo_use_group.test.mjs +67 -0
  165. package/scripts/utils/dev/expo_web.mjs +0 -112
@@ -1,22 +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';
28
+ import { isSandboxed } from './utils/env/sandbox.mjs';
17
29
  import { existsSync } from 'node:fs';
18
30
  import { getHomeEnvLocalPath, getHomeEnvPath, resolveUserConfigEnvPath } from './utils/env/config.mjs';
19
31
  import { detectServerComponentDirMismatch } from './utils/server/validate.mjs';
32
+ import { listAllStackNames } from './utils/stack/stacks.mjs';
33
+ import { parseDotenv } from './utils/env/dotenv.mjs';
34
+
35
+ const DEFAULT_COMPONENTS = ['happy', 'happy-cli', 'happy-server-light', 'happy-server'];
36
+ const HAPPY_MONOREPO_GROUP_COMPONENTS = ['happy', 'happy-cli', 'happy-server'];
20
37
 
21
38
  function getActiveStackName() {
22
39
  return (process.env.HAPPY_STACKS_STACK ?? process.env.HAPPY_LOCAL_STACK ?? '').trim() || 'main';
@@ -26,8 +43,36 @@ function isMainStack() {
26
43
  return getActiveStackName() === 'main';
27
44
  }
28
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
+
29
75
  function resolveComponentWorktreeDir({ rootDir, component, spec }) {
30
- const worktreesRoot = getWorktreesRoot(rootDir);
31
76
  const raw = (spec ?? '').trim();
32
77
 
33
78
  if (!raw) {
@@ -36,19 +81,53 @@ function resolveComponentWorktreeDir({ rootDir, component, spec }) {
36
81
  }
37
82
 
38
83
  if (raw === 'default' || raw === 'main') {
39
- return join(getComponentsDir(rootDir), component);
84
+ return getDefaultComponentDir(rootDir, component);
40
85
  }
41
86
 
42
87
  if (raw === 'active') {
43
88
  return getComponentDir(rootDir, component);
44
89
  }
45
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.
46
124
  if (isAbsolute(raw)) {
125
+ const monoRoot = coerceHappyMonorepoRootFromPath(raw);
126
+ const sub = happyMonorepoSubdirForComponent(component);
127
+ if (monoRoot && sub) return join(monoRoot, sub);
47
128
  return raw;
48
129
  }
49
-
50
- // Interpret as <owner>/<rest...> under components/.worktrees/<component>/.
51
- return join(worktreesRoot, component, ...raw.split('/'));
130
+ return null;
52
131
  }
53
132
 
54
133
  async function isWorktreeClean(dir) {
@@ -116,6 +195,181 @@ async function getWorktreeGitDir(worktreeDir) {
116
195
  return isAbsolute(gitDir) ? gitDir : resolve(worktreeDir, gitDir);
117
196
  }
118
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
+
119
373
  async function ensureWorktreeExclude(worktreeDir, patterns) {
120
374
  const gitDir = await getWorktreeGitDir(worktreeDir);
121
375
  const excludePath = join(gitDir, 'info', 'exclude');
@@ -162,41 +416,100 @@ async function installDependencies({ dir }) {
162
416
  return { installed: false, reason: 'no package manager detected (no package.json)' };
163
417
  }
164
418
 
419
+ // IMPORTANT:
420
+ // When a caller requests --json, stdout must be reserved for JSON output only.
421
+ // Package managers (especially Yarn) write progress to stdout, which would corrupt JSON parsing
422
+ // in wrappers like `stack pr`.
423
+ const jsonMode = Boolean((process.argv ?? []).includes('--json'));
424
+ const runForJson = async (cmd, args) => {
425
+ try {
426
+ const out = await runCapture(cmd, args, { cwd: dir });
427
+ if (out) process.stderr.write(out);
428
+ } catch (e) {
429
+ const out = String(e?.out ?? '');
430
+ const err = String(e?.err ?? '');
431
+ if (out) process.stderr.write(out);
432
+ if (err) process.stderr.write(err);
433
+ throw e;
434
+ }
435
+ };
436
+
165
437
  if (pm.kind === 'pnpm') {
166
- await run('pnpm', ['install', '--frozen-lockfile'], { cwd: dir });
438
+ if (jsonMode) {
439
+ await runForJson('pnpm', ['install', '--frozen-lockfile']);
440
+ } else {
441
+ await run('pnpm', ['install', '--frozen-lockfile'], { cwd: dir });
442
+ }
167
443
  return { installed: true, reason: null };
168
444
  }
169
445
  if (pm.kind === 'yarn') {
170
446
  // Works for yarn classic; yarn berry will ignore/translate flags as needed.
171
- await run('yarn', ['install', '--frozen-lockfile'], { cwd: dir });
447
+ if (jsonMode) {
448
+ await runForJson('yarn', ['install', '--frozen-lockfile']);
449
+ } else {
450
+ await run('yarn', ['install', '--frozen-lockfile'], { cwd: dir });
451
+ }
172
452
  return { installed: true, reason: null };
173
453
  }
174
454
  // npm
175
455
  if (pm.lockfile && pm.lockfile !== 'package.json') {
176
- await run('npm', ['ci'], { cwd: dir });
456
+ if (jsonMode) {
457
+ await runForJson('npm', ['ci']);
458
+ } else {
459
+ await run('npm', ['ci'], { cwd: dir });
460
+ }
177
461
  } else {
178
- await run('npm', ['install'], { cwd: dir });
462
+ if (jsonMode) {
463
+ await runForJson('npm', ['install']);
464
+ } else {
465
+ await run('npm', ['install'], { cwd: dir });
466
+ }
179
467
  }
180
468
  return { installed: true, reason: null };
181
469
  }
182
470
 
183
- async function maybeSetupDeps({ repoRoot, baseDir, worktreeDir, depsMode }) {
471
+ function allowNodeModulesSymlinkForComponent(component) {
472
+ const c = String(component ?? '').trim();
473
+ if (!c) return true;
474
+ // Expo/Metro commonly breaks with symlinked node_modules. Avoid symlinks for the Happy UI worktree by default.
475
+ // Override if you *really* want to experiment:
476
+ // HAPPY_STACKS_WT_ALLOW_HAPPY_NODE_MODULES_SYMLINK=1
477
+ const allowHappySymlink =
478
+ (process.env.HAPPY_STACKS_WT_ALLOW_HAPPY_NODE_MODULES_SYMLINK ?? process.env.HAPPY_LOCAL_WT_ALLOW_HAPPY_NODE_MODULES_SYMLINK ?? '')
479
+ .toString()
480
+ .trim() === '1';
481
+ if (c === 'happy' && !allowHappySymlink) return false;
482
+ return true;
483
+ }
484
+
485
+ async function maybeSetupDeps({ repoRoot, baseDir, worktreeDir, depsMode, component }) {
184
486
  if (!depsMode || depsMode === 'none') {
185
487
  return { mode: 'none', linked: false, installed: false, message: null };
186
488
  }
187
489
 
188
490
  // Prefer explicit baseDir if provided, otherwise link from the primary checkout (repoRoot).
189
491
  const linkFrom = baseDir || repoRoot;
492
+ const allowSymlink = allowNodeModulesSymlinkForComponent(component);
190
493
 
191
494
  if (depsMode === 'link' || depsMode === 'link-or-install') {
192
- const res = await linkNodeModules({ fromDir: linkFrom, toDir: worktreeDir });
193
- if (res.linked) {
194
- return { mode: depsMode, linked: true, installed: false, message: null };
195
- }
196
- if (depsMode === 'link') {
197
- return { mode: depsMode, linked: false, installed: false, message: res.reason };
495
+ if (!allowSymlink) {
496
+ const msg =
497
+ `[wt] refusing to symlink node_modules for ${component} (Expo/Metro is often broken by symlinks).\n` +
498
+ `[wt] Fix: use --deps=install (recommended). To override: set HAPPY_STACKS_WT_ALLOW_HAPPY_NODE_MODULES_SYMLINK=1`;
499
+ if (depsMode === 'link') {
500
+ return { mode: depsMode, linked: false, installed: false, message: msg };
501
+ }
502
+ // link-or-install: fall through to install.
503
+ } else {
504
+ const res = await linkNodeModules({ fromDir: linkFrom, toDir: worktreeDir });
505
+ if (res.linked) {
506
+ return { mode: depsMode, linked: true, installed: false, message: null };
507
+ }
508
+ if (depsMode === 'link') {
509
+ return { mode: depsMode, linked: false, installed: false, message: res.reason };
510
+ }
511
+ // fall through to install
198
512
  }
199
- // fall through to install
200
513
  }
201
514
 
202
515
  const inst = await installDependencies({ dir: worktreeDir });
@@ -255,7 +568,7 @@ function parseWorktreeListPorcelain(out) {
255
568
 
256
569
  function getComponentRepoRoot(rootDir, component) {
257
570
  // Respect component dir overrides so repos can live outside components/ (e.g. an existing checkout at ../happy-server).
258
- return getComponentDir(rootDir, component);
571
+ return getComponentRepoDir(rootDir, component);
259
572
  }
260
573
 
261
574
  async function resolveOwners(repoRoot) {
@@ -398,7 +711,8 @@ async function migrateComponentWorktrees({ rootDir, component }) {
398
711
  renamed += 1;
399
712
  }
400
713
 
401
- const destPath = join(wtRoot, component, owner, ...rest.split('/'));
714
+ const repoKey = worktreeRepoKeyForComponent(rootDir, component);
715
+ const destPath = join(wtRoot, repoKey, owner, ...rest.split('/'));
402
716
  await mkdir(dirname(destPath), { recursive: true });
403
717
 
404
718
  if (resolve(destPath) !== resolve(wtPath)) {
@@ -430,11 +744,18 @@ async function migrateComponentWorktrees({ rootDir, component }) {
430
744
  }
431
745
 
432
746
  async function cmdMigrate({ rootDir }) {
433
- const components = ['happy', 'happy-cli', 'happy-server-light', 'happy-server'];
434
-
435
747
  let totalMoved = 0;
436
748
  let totalRenamed = 0;
437
- for (const component of components) {
749
+ const seenRepoKeys = new Set();
750
+ const migrateComponents = [];
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) {
438
759
  const res = await migrateComponentWorktrees({ rootDir, component });
439
760
  totalMoved += res.moved;
440
761
  totalRenamed += res.renamed;
@@ -450,15 +771,27 @@ async function cmdMigrate({ rootDir }) {
450
771
 
451
772
  if (await pathExists(envPath)) {
452
773
  const raw = (await readTextIfExists(envPath)) ?? '';
774
+ const hasHappyMonorepo = isActiveHappyMonorepo(rootDir, 'happy');
453
775
  const rewrite = (v) => {
454
776
  if (!v.includes('/components/')) {
455
777
  return v;
456
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
+ }
457
788
  return v
458
789
  .replace('/components/happy-worktrees/', '/components/.worktrees/happy/')
459
790
  .replace('/components/happy-cli-worktrees/', '/components/.worktrees/happy-cli/')
791
+ .replace('/components/happy-server-worktrees/', '/components/.worktrees/happy-server/')
460
792
  .replace('/components/happy-resume-upstream-clean', '/components/.worktrees/happy/')
461
- .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/');
462
795
  };
463
796
 
464
797
  for (const component of ['happy', 'happy-cli', 'happy-server-light', 'happy-server']) {
@@ -486,6 +819,8 @@ async function cmdUse({ rootDir, args, flags }) {
486
819
  throw new Error('[wt] usage: happys wt use <component> <owner/branch|path|default>');
487
820
  }
488
821
 
822
+ let updateComponents = [component];
823
+
489
824
  // Safety: main stack should not be repointed to arbitrary worktrees by default.
490
825
  // This is the most common “oops, the main stack now runs my PR checkout” footgun (especially for agents).
491
826
  const force = Boolean(flags?.has('--force'));
@@ -493,7 +828,7 @@ async function cmdUse({ rootDir, args, flags }) {
493
828
  throw new Error(
494
829
  `[wt] refusing to change main stack component override by default.\n` +
495
830
  `- stack: main\n` +
496
- `- component: ${component}\n` +
831
+ `- component: ${component}${updateComponents.length > 1 ? ` (monorepo group: ${updateComponents.join(', ')})` : ''}\n` +
497
832
  `- requested: ${spec}\n` +
498
833
  `\n` +
499
834
  `Recommendation:\n` +
@@ -506,7 +841,6 @@ async function cmdUse({ rootDir, args, flags }) {
506
841
  );
507
842
  }
508
843
 
509
- const key = componentDirEnvKey(component);
510
844
  const worktreesRoot = getWorktreesRoot(rootDir);
511
845
  const envPath = process.env.HAPPY_STACKS_ENV_FILE?.trim()
512
846
  ? process.env.HAPPY_STACKS_ENV_FILE.trim()
@@ -515,48 +849,65 @@ async function cmdUse({ rootDir, args, flags }) {
515
849
  : null;
516
850
 
517
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: '' }));
518
857
  // Clear override by setting it to empty (env.local keeps a record of last use, but override becomes inactive).
519
- await (envPath
520
- ? ensureEnvFileUpdated({ envPath, updates: [{ key, value: '' }] })
521
- : ensureEnvLocalUpdated({ rootDir, updates: [{ key, value: '' }] }));
522
- return { component, activeDir: join(getComponentsDir(rootDir), component), mode: 'default' };
523
- }
524
-
525
- let dir = spec;
526
- if (!isAbsolute(dir)) {
527
- // Allow passing a repo-relative path (e.g. "components/happy-cli") as an escape hatch.
528
- const rel = resolve(getWorkspaceDir(rootDir), dir);
529
- if (await pathExists(rel)) {
530
- dir = rel;
531
- } else {
532
- // Interpret as <owner>/<rest...> under components/.worktrees/<component>/.
533
- 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
+ );
534
884
  }
535
- } else {
536
- dir = resolve(dir);
537
885
  }
538
886
 
539
- if (!(await pathExists(dir))) {
540
- throw new Error(`[wt] target does not exist: ${dir}`);
887
+ if (!(await pathExists(writeDir))) {
888
+ throw new Error(`[wt] target does not exist: ${writeDir}`);
541
889
  }
542
890
 
543
- if (component === 'happy-server-light' || component === 'happy-server') {
544
- const mismatch = detectServerComponentDirMismatch({ rootDir, serverComponentName: component, serverDir: dir });
545
- if (mismatch) {
546
- throw new Error(
547
- `[wt] invalid target for ${component}:\n` +
548
- `- expected a checkout of: ${mismatch.expected}\n` +
549
- `- but the path points inside: ${mismatch.actual}\n` +
550
- `- path: ${mismatch.serverDir}\n` +
551
- `Fix: pick a worktree under components/.worktrees/${mismatch.expected}/ (or run: happys wt use ${mismatch.actual} <spec>).`
552
- );
553
- }
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
+ );
554
904
  }
555
905
 
556
- await (envPath
557
- ? ensureEnvFileUpdated({ envPath, updates: [{ key, value: dir }] })
558
- : ensureEnvLocalUpdated({ rootDir, updates: [{ key, value: dir }] }));
559
- 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 };
560
911
  }
561
912
 
562
913
  async function cmdUseInteractive({ rootDir }) {
@@ -566,25 +917,7 @@ async function cmdUseInteractive({ rootDir }) {
566
917
  throw new Error('[wt] component is required');
567
918
  }
568
919
 
569
- const wtRoot = getWorktreesRoot(rootDir);
570
- const base = join(wtRoot, component);
571
- const specs = [];
572
- const walk = async (d, prefix) => {
573
- const entries = await readdir(d, { withFileTypes: true });
574
- for (const e of entries) {
575
- if (!e.isDirectory()) continue;
576
- const p = join(d, e.name);
577
- const nextPrefix = prefix ? `${prefix}/${e.name}` : e.name;
578
- if (await pathExists(join(p, '.git'))) {
579
- specs.push(nextPrefix);
580
- }
581
- await walk(p, nextPrefix);
582
- }
583
- };
584
- if (await pathExists(base)) {
585
- await walk(base, '');
586
- }
587
- specs.sort();
920
+ const specs = await listWorktreeSpecs({ rootDir, component });
588
921
 
589
922
  const kindOptions = [{ label: 'default', value: 'default' }];
590
923
  if (specs.length) {
@@ -657,8 +990,9 @@ async function cmdNew({ rootDir, argv }) {
657
990
  const branchName = `${owner}/${slug}`;
658
991
 
659
992
  const worktreesRoot = getWorktreesRoot(rootDir);
660
- const destPath = join(worktreesRoot, component, owner, ...slug.split('/'));
661
- 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 });
662
996
 
663
997
  // Ensure remotes are present.
664
998
  await git(repoRoot, ['fetch', '--all', '--prune', '--quiet']);
@@ -673,37 +1007,23 @@ async function cmdNew({ rootDir, argv }) {
673
1007
  // If the branch already exists (common when migrating between workspaces),
674
1008
  // attach a new worktree to that branch instead of failing.
675
1009
  if (await gitOk(repoRoot, ['show-ref', '--verify', `refs/heads/${branchName}`])) {
676
- await git(repoRoot, ['worktree', 'add', destPath, branchName]);
1010
+ await git(repoRoot, ['worktree', 'add', destWorktreeRoot, branchName]);
677
1011
  } else {
678
- await git(repoRoot, ['worktree', 'add', '-b', branchName, destPath, base]);
1012
+ await git(repoRoot, ['worktree', 'add', '-b', branchName, destWorktreeRoot, base]);
679
1013
  }
680
1014
 
681
1015
  const depsMode = parseDepsMode(kv.get('--deps'));
682
- const deps = await maybeSetupDeps({ repoRoot, baseDir: baseWorktreeDir || '', worktreeDir: destPath, depsMode });
1016
+ const depsDir = resolveComponentSpecToDir({ rootDir, component, spec: destWorktreeRoot }) ?? destWorktreeRoot;
1017
+ const deps = await maybeSetupDeps({ repoRoot, baseDir: baseWorktreeDir || '', worktreeDir: depsDir, depsMode, component });
683
1018
 
684
1019
  const shouldUse = flags.has('--use');
685
1020
  const force = flags.has('--force');
686
1021
  if (shouldUse) {
687
- if (isMainStack() && !force) {
688
- throw new Error(
689
- `[wt] refusing to set main stack component override via --use by default.\n` +
690
- `- stack: main\n` +
691
- `- component: ${component}\n` +
692
- `- new worktree: ${destPath}\n` +
693
- `\n` +
694
- `Recommendation:\n` +
695
- `- Use an isolated stack instead:\n` +
696
- ` happys stack new exp1 --interactive\n` +
697
- ` happys stack wt exp1 -- use ${component} ${owner}/${slug}\n` +
698
- `\n` +
699
- `If you really intend to repoint the main stack, re-run with --force:\n` +
700
- ` happys wt new ${component} ${slug} --use --force\n`
701
- );
702
- }
703
- const key = componentDirEnvKey(component);
704
- 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 });
705
1024
  }
706
- 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 };
707
1027
  }
708
1028
 
709
1029
  async function cmdDuplicate({ rootDir, argv }) {
@@ -765,111 +1085,132 @@ async function cmdPr({ rootDir, argv }) {
765
1085
  throw new Error(`[wt] unable to parse PR: ${prInput}`);
766
1086
  }
767
1087
 
768
- const remoteName = (kv.get('--remote') ?? '').trim() || 'upstream';
769
- const { owner } = await resolveRemoteOwner(repoRoot, remoteName);
1088
+ const remoteFromArg = (kv.get('--remote') ?? '').trim();
1089
+ const canFetchByUrl = !remoteFromArg && pr.owner && pr.repo;
1090
+ const fetchTarget = canFetchByUrl ? `https://github.com/${pr.owner}/${pr.repo}.git` : null;
1091
+
1092
+ // If we can fetch directly from the PR URL's repo, do it. This avoids any assumptions about local
1093
+ // remote names like "origin" vs "upstream" and works even when the repo doesn't have that remote set up.
1094
+ const remoteName = canFetchByUrl ? '' : await normalizeRemoteName(repoRoot, remoteFromArg || 'upstream');
1095
+ const { owner } = canFetchByUrl ? { owner: pr.owner } : await resolveRemoteOwner(repoRoot, remoteName);
770
1096
 
771
1097
  const slugExtra = sanitizeSlugPart(kv.get('--slug') ?? '');
772
1098
  const slug = slugExtra ? `pr/${pr.number}-${slugExtra}` : `pr/${pr.number}`;
773
1099
  const branchName = `${owner}/${slug}`;
774
1100
 
775
1101
  const worktreesRoot = getWorktreesRoot(rootDir);
776
- const destPath = join(worktreesRoot, component, owner, ...slug.split('/'));
777
- 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 });
778
1105
 
779
- const exists = await pathExists(destPath);
1106
+ const exists = await pathExists(destWorktreeRoot);
780
1107
  const doUpdate = flags.has('--update');
781
1108
  if (exists && !doUpdate) {
782
- 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`);
783
1110
  }
784
1111
 
785
1112
  // Fetch PR head ref (GitHub convention). Use + to allow force-updated PR branches when --force is set.
786
- const force = flags.has('--force');
1113
+ // In sandbox mode, be more aggressive: the entire workspace is disposable, so it's safe to
1114
+ // reset an existing local PR branch to the fetched PR head if needed.
1115
+ const force = flags.has('--force') || isSandboxed();
787
1116
  let oldHead = null;
788
1117
  const prRef = `refs/pull/${pr.number}/head`;
789
1118
  if (exists) {
790
1119
  // Update existing worktree.
791
1120
  const stash = await maybeStash({
792
- dir: destPath,
1121
+ dir: destWorktreeRoot,
793
1122
  enabled: flags.has('--stash'),
794
1123
  keep: flags.has('--stash-keep'),
795
1124
  message: `[happy-stacks] wt pr ${component} ${pr.number}`,
796
1125
  });
797
- if (!(await isWorktreeClean(destPath)) && !stash.stashed) {
798
- 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.`);
799
1128
  }
800
1129
 
801
- oldHead = (await git(destPath, ['rev-parse', 'HEAD'])).trim();
802
- await git(repoRoot, ['fetch', '--quiet', remoteName, prRef]);
1130
+ oldHead = (await git(destWorktreeRoot, ['rev-parse', 'HEAD'])).trim();
1131
+ await git(repoRoot, ['fetch', '--quiet', fetchTarget ?? remoteName, prRef]);
803
1132
  const newTip = (await git(repoRoot, ['rev-parse', 'FETCH_HEAD'])).trim();
804
1133
 
805
1134
  const isAncestor = await gitOk(repoRoot, ['merge-base', '--is-ancestor', oldHead, newTip]);
806
1135
  if (!isAncestor && !force) {
1136
+ const hint = fetchTarget
1137
+ ? `[wt] re-run with: happys wt pr ${component} ${pr.number} --update --force`
1138
+ : `[wt] re-run with: happys wt pr ${component} ${pr.number} --remote=${remoteName} --update --force`;
807
1139
  throw new Error(
808
1140
  `[wt] PR update is not a fast-forward (likely force-push) for ${branchName}\n` +
809
- `[wt] re-run with: happys wt pr ${component} ${pr.number} --remote=${remoteName} --update --force`
1141
+ hint
810
1142
  );
811
1143
  }
812
1144
 
813
1145
  // Update working tree to the fetched tip.
814
1146
  if (isAncestor) {
815
- await git(destPath, ['merge', '--ff-only', newTip]);
1147
+ await git(destWorktreeRoot, ['merge', '--ff-only', newTip]);
816
1148
  } else {
817
- await git(destPath, ['reset', '--hard', newTip]);
1149
+ await git(destWorktreeRoot, ['reset', '--hard', newTip]);
818
1150
  }
819
1151
 
820
1152
  // Only attempt to restore stash if update succeeded without forcing a conflict state.
821
- 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 });
822
1154
  if (stashPop.popError) {
823
1155
  if (!force && oldHead) {
824
- await hardReset({ dir: destPath, target: oldHead });
1156
+ await hardReset({ dir: destWorktreeRoot, target: oldHead });
825
1157
  throw new Error(
826
1158
  `[wt] PR updated, but restoring stashed changes conflicted.\n` +
827
1159
  `[wt] Reverted update to keep your working tree clean.\n` +
828
- `[wt] Worktree: ${destPath}\n` +
1160
+ `[wt] Worktree: ${destWorktreeRoot}\n` +
829
1161
  `[wt] Re-run with --update --stash --force to keep the conflict state for manual resolution.`
830
1162
  );
831
1163
  }
832
1164
  // Keep conflict state in place (or if we can't revert).
833
1165
  throw new Error(
834
1166
  `[wt] PR updated, but restoring stashed changes conflicted.\n` +
835
- `[wt] Worktree: ${destPath}\n` +
1167
+ `[wt] Worktree: ${destWorktreeRoot}\n` +
836
1168
  `[wt] Conflicts are left in place for manual resolution (--force).`
837
1169
  );
838
1170
  }
839
1171
  } else {
840
- await git(repoRoot, ['fetch', '--quiet', remoteName, prRef]);
1172
+ await git(repoRoot, ['fetch', '--quiet', fetchTarget ?? remoteName, prRef]);
841
1173
  const newTip = (await git(repoRoot, ['rev-parse', 'FETCH_HEAD'])).trim();
842
1174
 
843
1175
  const branchExists = await gitOk(repoRoot, ['show-ref', '--verify', '--quiet', `refs/heads/${branchName}`]);
844
1176
  if (branchExists) {
845
1177
  if (!force) {
846
- throw new Error(`[wt] branch already exists: ${branchName}\n[wt] re-run with --force to reset it to the PR head`);
1178
+ // If the branch already points at the fetched PR tip, we can safely just attach a worktree.
1179
+ const branchHead = (await git(repoRoot, ['rev-parse', branchName])).trim();
1180
+ if (branchHead !== newTip) {
1181
+ throw new Error(`[wt] branch already exists: ${branchName}\n[wt] re-run with --force to reset it to the PR head`);
1182
+ }
1183
+ await git(repoRoot, ['worktree', 'add', destWorktreeRoot, branchName]);
1184
+ } else {
1185
+ await git(repoRoot, ['branch', '-f', branchName, newTip]);
1186
+ await git(repoRoot, ['worktree', 'add', destWorktreeRoot, branchName]);
847
1187
  }
848
- await git(repoRoot, ['branch', '-f', branchName, newTip]);
849
- await git(repoRoot, ['worktree', 'add', destPath, branchName]);
850
1188
  } else {
851
1189
  // Create worktree at PR head (new local branch).
852
- await git(repoRoot, ['worktree', 'add', '-b', branchName, destPath, newTip]);
1190
+ await git(repoRoot, ['worktree', 'add', '-b', branchName, destWorktreeRoot, newTip]);
853
1191
  }
854
1192
  }
855
1193
 
856
1194
  // Optional deps handling (useful when PR branches add/change dependencies).
857
1195
  const depsMode = parseDepsMode(kv.get('--deps'));
858
- const deps = await maybeSetupDeps({ repoRoot, baseDir: repoRoot, worktreeDir: destPath, depsMode });
1196
+ const depsDir = resolveComponentSpecToDir({ rootDir, component, spec: destWorktreeRoot }) ?? destWorktreeRoot;
1197
+ const deps = await maybeSetupDeps({ repoRoot, baseDir: repoRoot, worktreeDir: depsDir, depsMode, component });
859
1198
 
860
1199
  const shouldUse = flags.has('--use');
861
1200
  if (shouldUse) {
862
1201
  // Reuse cmdUse so it writes to env.local or stack env file depending on context.
863
- await cmdUse({ rootDir, args: [component, destPath], flags });
1202
+ await cmdUse({ rootDir, args: [component, destWorktreeRoot], flags });
864
1203
  }
865
1204
 
866
- const newHead = (await git(destPath, ['rev-parse', 'HEAD'])).trim();
1205
+ const newHead = (await git(destWorktreeRoot, ['rev-parse', 'HEAD'])).trim();
867
1206
  const res = {
868
1207
  component,
869
1208
  pr: pr.number,
870
1209
  remote: remoteName,
871
1210
  branch: branchName,
872
- path: destPath,
1211
+ path: depsDir,
1212
+ worktreeRoot: destWorktreeRoot,
1213
+ repoKey,
873
1214
  used: shouldUse,
874
1215
  updated: exists,
875
1216
  oldHead,
@@ -1310,6 +1651,13 @@ async function openTerminalAuto({ dir, shell }) {
1310
1651
  return { kind: 'current' };
1311
1652
  }
1312
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
+
1313
1661
  async function cmdShell({ rootDir, argv }) {
1314
1662
  const { flags, kv } = parseArgs(argv);
1315
1663
  const json = wantsJson(argv, { flags });
@@ -1318,10 +1666,11 @@ async function cmdShell({ rootDir, argv }) {
1318
1666
  const spec = positionals[2] ?? '';
1319
1667
  if (!component) {
1320
1668
  throw new Error(
1321
- '[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]'
1322
1670
  );
1323
1671
  }
1324
- const dir = resolveComponentWorktreeDir({ rootDir, component, spec });
1672
+ const packageDir = resolveComponentWorktreeDir({ rootDir, component, spec });
1673
+ const dir = resolveMonorepoEditorDir({ component, dir: packageDir, preferPackageDir: flags.has('--package') });
1325
1674
  if (!(await pathExists(dir))) {
1326
1675
  throw new Error(`[wt] target does not exist: ${dir}`);
1327
1676
  }
@@ -1367,9 +1716,10 @@ async function cmdCode({ rootDir, argv }) {
1367
1716
  const component = positionals[1];
1368
1717
  const spec = positionals[2] ?? '';
1369
1718
  if (!component) {
1370
- 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]');
1371
1720
  }
1372
- const dir = resolveComponentWorktreeDir({ rootDir, component, spec });
1721
+ const packageDir = resolveComponentWorktreeDir({ rootDir, component, spec });
1722
+ const dir = resolveMonorepoEditorDir({ component, dir: packageDir, preferPackageDir: flags.has('--package') });
1373
1723
  if (!(await pathExists(dir))) {
1374
1724
  throw new Error(`[wt] target does not exist: ${dir}`);
1375
1725
  }
@@ -1391,9 +1741,10 @@ async function cmdCursor({ rootDir, argv }) {
1391
1741
  const component = positionals[1];
1392
1742
  const spec = positionals[2] ?? '';
1393
1743
  if (!component) {
1394
- 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]');
1395
1745
  }
1396
- const dir = resolveComponentWorktreeDir({ rootDir, component, spec });
1746
+ const packageDir = resolveComponentWorktreeDir({ rootDir, component, spec });
1747
+ const dir = resolveMonorepoEditorDir({ component, dir: packageDir, preferPackageDir: flags.has('--package') });
1397
1748
  if (!(await pathExists(dir))) {
1398
1749
  throw new Error(`[wt] target does not exist: ${dir}`);
1399
1750
  }
@@ -1422,8 +1773,6 @@ async function cmdCursor({ rootDir, argv }) {
1422
1773
  throw new Error("[wt] Cursor CLI 'cursor' not found on PATH (and non-macOS fallback is unavailable).");
1423
1774
  }
1424
1775
 
1425
- const DEFAULT_COMPONENTS = ['happy', 'happy-cli', 'happy-server-light', 'happy-server'];
1426
-
1427
1776
  async function cmdSyncAll({ rootDir, argv }) {
1428
1777
  const { flags, kv } = parseArgs(argv);
1429
1778
  const json = wantsJson(argv, { flags });
@@ -1432,15 +1781,22 @@ async function cmdSyncAll({ rootDir, argv }) {
1432
1781
  const components = DEFAULT_COMPONENTS;
1433
1782
 
1434
1783
  const results = [];
1784
+ const seenRepoKeys = new Set();
1435
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);
1436
1792
  try {
1437
1793
  const res = await cmdSync({
1438
1794
  rootDir,
1439
1795
  argv: remote ? ['sync', component, `--remote=${remote}`] : ['sync', component],
1440
1796
  });
1441
- results.push({ component, ok: true, ...res });
1797
+ results.push({ component, ok: true, skipped: false, repoKey, ...res });
1442
1798
  } catch (e) {
1443
- results.push({ component, ok: false, error: String(e?.message ?? e) });
1799
+ results.push({ component, ok: false, skipped: false, repoKey, error: String(e?.message ?? e) });
1444
1800
  }
1445
1801
  }
1446
1802
 
@@ -1451,7 +1807,9 @@ async function cmdSyncAll({ rootDir, argv }) {
1451
1807
 
1452
1808
  const lines = ['[wt] sync-all:'];
1453
1809
  for (const r of results) {
1454
- if (r.ok) {
1810
+ if (r.ok && r.skipped) {
1811
+ lines.push(`- ↪ ${r.component}: skipped (${r.reason})`);
1812
+ } else if (r.ok) {
1455
1813
  lines.push(`- ✅ ${r.component}: ${r.mirrorBranch} -> ${r.upstreamRef}`);
1456
1814
  } else {
1457
1815
  lines.push(`- ❌ ${r.component}: ${r.error}`);
@@ -1474,7 +1832,7 @@ async function cmdUpdateAll({ rootDir, argv }) {
1474
1832
  const { flags, kv } = parseArgs(argv);
1475
1833
  const positionals = argv.filter((a) => !a.startsWith('--'));
1476
1834
  const maybeComponent = positionals[1]?.trim() ? positionals[1].trim() : '';
1477
- const components = maybeComponent ? [maybeComponent] : DEFAULT_COMPONENTS;
1835
+ const requestedComponents = maybeComponent ? [maybeComponent] : DEFAULT_COMPONENTS;
1478
1836
 
1479
1837
  const json = wantsJson(argv, { flags });
1480
1838
 
@@ -1486,6 +1844,15 @@ async function cmdUpdateAll({ rootDir, argv }) {
1486
1844
  const stash = flags.has('--stash');
1487
1845
  const stashKeep = flags.has('--stash-keep');
1488
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
+
1489
1856
  const results = [];
1490
1857
  for (const component of components) {
1491
1858
  const paths = await listComponentWorktreePaths({ rootDir, component });
@@ -1556,43 +1923,230 @@ async function cmdNewInteractive({ rootDir, argv }) {
1556
1923
  });
1557
1924
  }
1558
1925
 
1559
- async function cmdList({ rootDir, args }) {
1560
- const component = args[0];
1561
- if (!component) {
1562
- throw new Error('[wt] usage: happys wt list <component>');
1926
+ async function cmdListOne({ rootDir, component, activeOnly = false }) {
1927
+ const wtRoot = getWorktreesRoot(rootDir);
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: [] };
1563
1934
  }
1564
1935
 
1565
- const wtRoot = getWorktreesRoot(rootDir);
1566
- const dir = join(wtRoot, component);
1567
1936
  if (!(await pathExists(dir))) {
1568
- return { component, activeDir: (process.env[key] ?? '').trim() || join(getComponentsDir(rootDir), component), worktrees: [] };
1937
+ return { component, activeDir: active, worktrees: [] };
1569
1938
  }
1570
1939
 
1571
- const leafs = [];
1940
+ const worktrees = [];
1572
1941
  const walk = async (d) => {
1942
+ // In git worktrees, ".git" is usually a file that points to the shared git dir.
1943
+ // If this is a worktree root, record it and do not descend into it (avoids traversing huge trees like node_modules).
1944
+ if (await pathExists(join(d, '.git'))) {
1945
+ worktrees.push(d);
1946
+ return;
1947
+ }
1573
1948
  const entries = await readdir(d, { withFileTypes: true });
1574
1949
  for (const e of entries) {
1575
- if (!e.isDirectory()) {
1576
- continue;
1577
- }
1578
- const p = join(d, e.name);
1579
- leafs.push(p);
1580
- await walk(p);
1950
+ if (!e.isDirectory()) continue;
1951
+ if (e.name === 'node_modules') continue;
1952
+ if (e.name.startsWith('.')) continue;
1953
+ await walk(join(d, e.name));
1581
1954
  }
1582
1955
  };
1583
1956
  await walk(dir);
1584
- leafs.sort();
1957
+ worktrees.sort();
1585
1958
 
1586
- const key = componentDirEnvKey(component);
1587
- const active = (process.env[key] ?? '').trim() || join(getComponentsDir(rootDir), component);
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 };
1962
+ }
1588
1963
 
1589
- const worktrees = [];
1590
- for (const p of leafs) {
1591
- if (await pathExists(join(p, '.git'))) {
1592
- worktrees.push(p);
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
+
1968
+ const component = args[0];
1969
+ if (!component) {
1970
+ const results = [];
1971
+ for (const c of DEFAULT_COMPONENTS) {
1972
+ results.push(await cmdListOne({ rootDir, component: c, activeOnly }));
1973
+ }
1974
+ return { components: DEFAULT_COMPONENTS, results };
1975
+ }
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 = '';
1593
2049
  }
1594
2050
  }
1595
- return { component, activeDir: active, worktrees };
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
+ };
1596
2150
  }
1597
2151
 
1598
2152
  async function main() {
@@ -1608,7 +2162,7 @@ async function main() {
1608
2162
  printResult({
1609
2163
  json,
1610
2164
  data: {
1611
- 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'],
1612
2166
  interactive: ['new', 'use'],
1613
2167
  },
1614
2168
  text: [
@@ -1616,7 +2170,7 @@ async function main() {
1616
2170
  ' happys wt migrate [--json]',
1617
2171
  ' happys wt sync <component> [--remote=<name>] [--json]',
1618
2172
  ' happys wt sync-all [--remote=<name>] [--json]',
1619
- ' happys wt list <component> [--json]',
2173
+ ' happys wt list [component] [--active|--all] [--json]',
1620
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]',
1621
2175
  ' happys wt duplicate <component> <fromWorktreeSpec|path|active|default> <newSlug> [--remote=<name>] [--deps=none|link|install|link-or-install] [--use] [--json]',
1622
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]',
@@ -1629,15 +2183,20 @@ async function main() {
1629
2183
  ' happys wt shell <component> [worktreeSpec|active|default|main|path] [--shell=/bin/zsh] [--json]',
1630
2184
  ' happys wt code <component> [worktreeSpec|active|default|main|path] [--json]',
1631
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]',
1632
2187
  '',
1633
2188
  'selectors:',
1634
2189
  ' (omitted) or "active": current active checkout (env override if set; else components/<component>)',
1635
- ' "default" or "main": components/<component>',
1636
- ' "<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...>)',
1637
2192
  ' "<absolute path>": explicit checkout path',
1638
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
+ '',
1639
2198
  'components:',
1640
- ' happy | happy-cli | happy-server-light | happy-server',
2199
+ ` ${DEFAULT_COMPONENTS.join(' | ')}`,
1641
2200
  ].join('\n'),
1642
2201
  });
1643
2202
  return;
@@ -1792,18 +2351,35 @@ async function main() {
1792
2351
  return;
1793
2352
  }
1794
2353
  if (cmd === 'list') {
1795
- const res = await cmdList({ rootDir, args: positionals.slice(1) });
2354
+ const res = await cmdList({ rootDir, args: positionals.slice(1), flags });
1796
2355
  if (json) {
1797
2356
  printResult({ json, data: res });
1798
2357
  } else {
1799
- const lines = [`[wt] ${res.component} worktrees:`, `- active: ${res.activeDir}`];
1800
- for (const p of res.worktrees) {
1801
- lines.push(`- ${p}`);
2358
+ const results = Array.isArray(res?.results) ? res.results : [res];
2359
+ const lines = [];
2360
+ for (const r of results) {
2361
+ lines.push(`[wt] ${r.component} worktrees:`);
2362
+ lines.push(`- active: ${r.activeDir}`);
2363
+ for (const p of r.worktrees) {
2364
+ lines.push(`- ${p}`);
2365
+ }
2366
+ lines.push('');
1802
2367
  }
1803
2368
  printResult({ json: false, text: lines.join('\n') });
1804
2369
  }
1805
2370
  return;
1806
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
+ }
1807
2383
  throw new Error(`[wt] unknown command: ${cmd}`);
1808
2384
  }
1809
2385