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.
- package/README.md +93 -40
- package/bin/happys.mjs +158 -16
- package/docs/codex-mcp-resume.md +130 -0
- package/docs/commit-audits/happy/leeroy-wip.commit-analysis.md +17640 -0
- package/docs/commit-audits/happy/leeroy-wip.commit-export.fuller-stat.md +3845 -0
- package/docs/commit-audits/happy/leeroy-wip.commit-inventory.md +102 -0
- package/docs/commit-audits/happy/leeroy-wip.commit-manual-review.md +1452 -0
- package/docs/commit-audits/happy/leeroy-wip.manual-review-queue.md +116 -0
- package/docs/happy-development.md +3 -4
- package/docs/isolated-linux-vm.md +82 -0
- package/docs/mobile-ios.md +112 -54
- package/docs/monorepo-migration.md +286 -0
- package/docs/server-flavors.md +19 -3
- package/docs/stacks.md +35 -0
- package/package.json +5 -1
- package/scripts/auth.mjs +32 -10
- package/scripts/build.mjs +55 -8
- package/scripts/daemon.mjs +166 -10
- package/scripts/dev.mjs +198 -50
- package/scripts/doctor.mjs +0 -4
- package/scripts/edison.mjs +6 -4
- package/scripts/env.mjs +150 -0
- package/scripts/env_cmd.test.mjs +128 -0
- package/scripts/init.mjs +8 -3
- package/scripts/install.mjs +207 -69
- package/scripts/lint.mjs +24 -4
- package/scripts/migrate.mjs +3 -12
- package/scripts/mobile.mjs +88 -104
- package/scripts/mobile_dev_client.mjs +83 -0
- package/scripts/monorepo.mjs +1096 -0
- package/scripts/monorepo_port.test.mjs +1470 -0
- package/scripts/provision/linux-ubuntu-review-pr.sh +51 -0
- package/scripts/review.mjs +908 -0
- package/scripts/review_pr.mjs +353 -0
- package/scripts/run.mjs +101 -21
- package/scripts/service.mjs +2 -2
- package/scripts/setup.mjs +189 -68
- package/scripts/setup_pr.mjs +586 -38
- package/scripts/stack.mjs +990 -196
- package/scripts/stack_archive_cmd.test.mjs +91 -0
- package/scripts/stack_editor_workspace_monorepo_root.test.mjs +65 -0
- package/scripts/stack_env_cmd.test.mjs +87 -0
- package/scripts/stack_happy_cmd.test.mjs +126 -0
- package/scripts/stack_interactive_monorepo_group.test.mjs +71 -0
- package/scripts/stack_monorepo_defaults.test.mjs +62 -0
- package/scripts/stack_monorepo_server_light_from_happy_spec.test.mjs +66 -0
- package/scripts/stack_server_flavors_defaults.test.mjs +55 -0
- package/scripts/stack_shorthand_cmd.test.mjs +55 -0
- package/scripts/stack_wt_list.test.mjs +128 -0
- package/scripts/tailscale.mjs +37 -1
- package/scripts/test.mjs +45 -8
- package/scripts/tui.mjs +395 -39
- package/scripts/typecheck.mjs +24 -4
- package/scripts/utils/auth/daemon_gate.mjs +55 -0
- package/scripts/utils/auth/daemon_gate.test.mjs +37 -0
- package/scripts/utils/auth/guided_pr_auth.mjs +79 -0
- package/scripts/utils/auth/guided_stack_web_login.mjs +75 -0
- package/scripts/utils/auth/interactive_stack_auth.mjs +72 -0
- package/scripts/utils/auth/login_ux.mjs +32 -13
- package/scripts/utils/auth/sources.mjs +26 -0
- package/scripts/utils/auth/stack_guided_login.mjs +353 -0
- package/scripts/utils/cli/cli_registry.mjs +43 -4
- package/scripts/utils/cli/cwd_scope.mjs +136 -0
- package/scripts/utils/cli/cwd_scope.test.mjs +110 -0
- package/scripts/utils/cli/log_forwarder.mjs +157 -0
- package/scripts/utils/cli/prereqs.mjs +75 -0
- package/scripts/utils/cli/prereqs.test.mjs +34 -0
- package/scripts/utils/cli/progress.mjs +126 -0
- package/scripts/utils/cli/verbosity.mjs +12 -0
- package/scripts/utils/cli/wizard.mjs +17 -9
- package/scripts/utils/cli/wizard_prompt_worktree_source_lazy.test.mjs +60 -0
- package/scripts/utils/dev/daemon.mjs +61 -4
- package/scripts/utils/dev/expo_dev.mjs +430 -0
- package/scripts/utils/dev/expo_dev.test.mjs +76 -0
- package/scripts/utils/dev/server.mjs +36 -42
- package/scripts/utils/dev_auth_key.mjs +169 -0
- package/scripts/utils/edison/git_roots.mjs +29 -0
- package/scripts/utils/edison/git_roots.test.mjs +36 -0
- package/scripts/utils/env/env.mjs +7 -3
- package/scripts/utils/env/env_file.mjs +4 -2
- package/scripts/utils/env/env_file.test.mjs +44 -0
- package/scripts/utils/expo/command.mjs +52 -0
- package/scripts/utils/expo/expo.mjs +20 -1
- package/scripts/utils/expo/metro_ports.mjs +114 -0
- package/scripts/utils/git/git.mjs +67 -0
- package/scripts/utils/git/worktrees.mjs +80 -25
- package/scripts/utils/git/worktrees_monorepo.test.mjs +54 -0
- package/scripts/utils/handy_master_secret.mjs +94 -0
- package/scripts/utils/mobile/config.mjs +31 -0
- package/scripts/utils/mobile/dev_client_links.mjs +60 -0
- package/scripts/utils/mobile/identifiers.mjs +47 -0
- package/scripts/utils/mobile/identifiers.test.mjs +42 -0
- package/scripts/utils/mobile/ios_xcodeproj_patch.mjs +128 -0
- package/scripts/utils/mobile/ios_xcodeproj_patch.test.mjs +98 -0
- package/scripts/utils/net/lan_ip.mjs +24 -0
- package/scripts/utils/net/ports.mjs +9 -1
- package/scripts/utils/net/tcp_forward.mjs +162 -0
- package/scripts/utils/net/url.mjs +30 -0
- package/scripts/utils/net/url.test.mjs +20 -0
- package/scripts/utils/paths/localhost_host.mjs +50 -3
- package/scripts/utils/paths/paths.mjs +159 -40
- package/scripts/utils/paths/paths_monorepo.test.mjs +58 -0
- package/scripts/utils/paths/paths_server_flavors.test.mjs +45 -0
- package/scripts/utils/proc/commands.mjs +2 -3
- package/scripts/utils/proc/parallel.mjs +25 -0
- package/scripts/utils/proc/pm.mjs +176 -22
- package/scripts/utils/proc/pm_spawn.test.mjs +76 -0
- package/scripts/utils/proc/pm_stack_cache_env.test.mjs +142 -0
- package/scripts/utils/proc/proc.mjs +136 -4
- package/scripts/utils/proc/proc.test.mjs +77 -0
- package/scripts/utils/review/base_ref.mjs +74 -0
- package/scripts/utils/review/base_ref.test.mjs +54 -0
- package/scripts/utils/review/chunks.mjs +55 -0
- package/scripts/utils/review/chunks.test.mjs +51 -0
- package/scripts/utils/review/findings.mjs +165 -0
- package/scripts/utils/review/findings.test.mjs +85 -0
- package/scripts/utils/review/head_slice.mjs +153 -0
- package/scripts/utils/review/head_slice.test.mjs +91 -0
- package/scripts/utils/review/instructions/deep.md +20 -0
- package/scripts/utils/review/runners/coderabbit.mjs +61 -0
- package/scripts/utils/review/runners/coderabbit.test.mjs +59 -0
- package/scripts/utils/review/runners/codex.mjs +61 -0
- package/scripts/utils/review/runners/codex.test.mjs +35 -0
- package/scripts/utils/review/slices.mjs +140 -0
- package/scripts/utils/review/slices.test.mjs +32 -0
- package/scripts/utils/review/targets.mjs +24 -0
- package/scripts/utils/review/targets.test.mjs +36 -0
- package/scripts/utils/sandbox/review_pr_sandbox.mjs +106 -0
- package/scripts/utils/server/flavor_scripts.mjs +98 -0
- package/scripts/utils/server/flavor_scripts.test.mjs +146 -0
- package/scripts/utils/server/mobile_api_url.mjs +61 -0
- package/scripts/utils/server/mobile_api_url.test.mjs +41 -0
- package/scripts/utils/server/prisma_import.mjs +37 -0
- package/scripts/utils/server/prisma_import.test.mjs +70 -0
- package/scripts/utils/server/ui_env.mjs +14 -0
- package/scripts/utils/server/ui_env.test.mjs +46 -0
- package/scripts/utils/server/urls.mjs +14 -4
- package/scripts/utils/server/validate.mjs +53 -16
- package/scripts/utils/server/validate.test.mjs +89 -0
- package/scripts/utils/service/autostart_darwin.mjs +42 -2
- package/scripts/utils/service/autostart_darwin.test.mjs +50 -0
- package/scripts/utils/stack/context.mjs +2 -2
- package/scripts/utils/stack/editor_workspace.mjs +6 -6
- package/scripts/utils/stack/interactive_stack_config.mjs +185 -0
- package/scripts/utils/stack/pr_stack_name.mjs +16 -0
- package/scripts/utils/stack/runtime_state.mjs +2 -1
- package/scripts/utils/stack/startup.mjs +120 -13
- package/scripts/utils/stack/startup_server_light_dirs.test.mjs +64 -0
- package/scripts/utils/stack/startup_server_light_generate.test.mjs +70 -0
- package/scripts/utils/stack/startup_server_light_legacy.test.mjs +88 -0
- package/scripts/utils/stack/stop.mjs +15 -4
- package/scripts/utils/stack_context.mjs +23 -0
- package/scripts/utils/stack_runtime_state.mjs +104 -0
- package/scripts/utils/stacks.mjs +38 -0
- package/scripts/utils/tailscale/ip.mjs +116 -0
- package/scripts/utils/ui/ansi.mjs +39 -0
- package/scripts/utils/ui/qr.mjs +17 -0
- package/scripts/utils/validate.mjs +88 -0
- package/scripts/where.mjs +2 -2
- package/scripts/worktrees.mjs +755 -179
- package/scripts/worktrees_archive_cmd.test.mjs +245 -0
- package/scripts/worktrees_cursor_monorepo_root.test.mjs +63 -0
- package/scripts/worktrees_list_specs_no_recurse.test.mjs +33 -0
- package/scripts/worktrees_monorepo_use_group.test.mjs +67 -0
- package/scripts/utils/dev/expo_web.mjs +0 -112
package/scripts/worktrees.mjs
CHANGED
|
@@ -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 {
|
|
9
|
-
|
|
10
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
456
|
+
if (jsonMode) {
|
|
457
|
+
await runForJson('npm', ['ci']);
|
|
458
|
+
} else {
|
|
459
|
+
await run('npm', ['ci'], { cwd: dir });
|
|
460
|
+
}
|
|
177
461
|
} else {
|
|
178
|
-
|
|
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
|
-
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
if (!
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
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(
|
|
540
|
-
throw new Error(`[wt] target does not exist: ${
|
|
887
|
+
if (!(await pathExists(writeDir))) {
|
|
888
|
+
throw new Error(`[wt] target does not exist: ${writeDir}`);
|
|
541
889
|
}
|
|
542
890
|
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
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
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
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
|
|
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
|
|
661
|
-
|
|
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',
|
|
1010
|
+
await git(repoRoot, ['worktree', 'add', destWorktreeRoot, branchName]);
|
|
677
1011
|
} else {
|
|
678
|
-
await git(repoRoot, ['worktree', 'add', '-b', branchName,
|
|
1012
|
+
await git(repoRoot, ['worktree', 'add', '-b', branchName, destWorktreeRoot, base]);
|
|
679
1013
|
}
|
|
680
1014
|
|
|
681
1015
|
const depsMode = parseDepsMode(kv.get('--deps'));
|
|
682
|
-
const
|
|
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
|
-
|
|
688
|
-
|
|
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
|
-
|
|
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
|
|
769
|
-
const
|
|
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
|
|
777
|
-
|
|
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(
|
|
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: ${
|
|
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
|
-
|
|
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:
|
|
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(
|
|
798
|
-
throw new Error(`[wt] worktree is not clean (${
|
|
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(
|
|
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
|
-
|
|
1141
|
+
hint
|
|
810
1142
|
);
|
|
811
1143
|
}
|
|
812
1144
|
|
|
813
1145
|
// Update working tree to the fetched tip.
|
|
814
1146
|
if (isAncestor) {
|
|
815
|
-
await git(
|
|
1147
|
+
await git(destWorktreeRoot, ['merge', '--ff-only', newTip]);
|
|
816
1148
|
} else {
|
|
817
|
-
await git(
|
|
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:
|
|
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:
|
|
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: ${
|
|
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: ${
|
|
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
|
-
|
|
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,
|
|
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
|
|
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,
|
|
1202
|
+
await cmdUse({ rootDir, args: [component, destWorktreeRoot], flags });
|
|
864
1203
|
}
|
|
865
1204
|
|
|
866
|
-
const newHead = (await git(
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
1560
|
-
const
|
|
1561
|
-
|
|
1562
|
-
|
|
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:
|
|
1937
|
+
return { component, activeDir: active, worktrees: [] };
|
|
1569
1938
|
}
|
|
1570
1939
|
|
|
1571
|
-
const
|
|
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
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
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
|
-
|
|
1957
|
+
worktrees.sort();
|
|
1585
1958
|
|
|
1586
|
-
const
|
|
1587
|
-
const
|
|
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
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
'
|
|
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
|
|
1800
|
-
|
|
1801
|
-
|
|
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
|
|