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