@happier-dev/stack 0.1.0-preview.5.1
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 +501 -0
- package/bin/hstack.mjs +348 -0
- package/docs/codex-mcp-resume.md +129 -0
- package/docs/edison.md +74 -0
- package/docs/forking-and-branding.md +189 -0
- package/docs/happy-development.md +22 -0
- package/docs/isolated-linux-vm.md +243 -0
- package/docs/menubar.md +244 -0
- package/docs/mobile-ios.md +322 -0
- package/docs/monorepo-migration.md +20 -0
- package/docs/paths-and-env.md +154 -0
- package/docs/remote-access.md +43 -0
- package/docs/server-flavors.md +147 -0
- package/docs/stacks.md +330 -0
- package/docs/tauri.md +60 -0
- package/docs/worktrees-and-forks.md +133 -0
- package/extras/swiftbar/auth-login.sh +29 -0
- package/extras/swiftbar/git-cache-refresh.sh +122 -0
- package/extras/swiftbar/hstack-term.sh +133 -0
- package/extras/swiftbar/hstack.5s.sh +296 -0
- package/extras/swiftbar/hstack.sh +35 -0
- package/extras/swiftbar/icons/happy-green.png +0 -0
- package/extras/swiftbar/icons/happy-orange.png +0 -0
- package/extras/swiftbar/icons/happy-red.png +0 -0
- package/extras/swiftbar/icons/logo-white.png +0 -0
- package/extras/swiftbar/install.sh +265 -0
- package/extras/swiftbar/lib/git.sh +629 -0
- package/extras/swiftbar/lib/icons.sh +92 -0
- package/extras/swiftbar/lib/render.sh +999 -0
- package/extras/swiftbar/lib/system.sh +244 -0
- package/extras/swiftbar/lib/utils.sh +717 -0
- package/extras/swiftbar/set-interval.sh +65 -0
- package/extras/swiftbar/set-server-flavor.sh +61 -0
- package/extras/swiftbar/wt-pr.sh +140 -0
- package/node_modules/@happier-dev/cli-common/README.md +6 -0
- package/node_modules/@happier-dev/cli-common/dist/index.d.ts +4 -0
- package/node_modules/@happier-dev/cli-common/dist/index.d.ts.map +1 -0
- package/node_modules/@happier-dev/cli-common/dist/index.js +4 -0
- package/node_modules/@happier-dev/cli-common/dist/index.js.map +1 -0
- package/node_modules/@happier-dev/cli-common/dist/links/index.d.ts +18 -0
- package/node_modules/@happier-dev/cli-common/dist/links/index.d.ts.map +1 -0
- package/node_modules/@happier-dev/cli-common/dist/links/index.js +25 -0
- package/node_modules/@happier-dev/cli-common/dist/links/index.js.map +1 -0
- package/node_modules/@happier-dev/cli-common/dist/links.d.ts +2 -0
- package/node_modules/@happier-dev/cli-common/dist/links.d.ts.map +1 -0
- package/node_modules/@happier-dev/cli-common/dist/links.js +2 -0
- package/node_modules/@happier-dev/cli-common/dist/links.js.map +1 -0
- package/node_modules/@happier-dev/cli-common/dist/update/index.d.ts +67 -0
- package/node_modules/@happier-dev/cli-common/dist/update/index.d.ts.map +1 -0
- package/node_modules/@happier-dev/cli-common/dist/update/index.js +259 -0
- package/node_modules/@happier-dev/cli-common/dist/update/index.js.map +1 -0
- package/node_modules/@happier-dev/cli-common/dist/workspaces/index.d.ts +17 -0
- package/node_modules/@happier-dev/cli-common/dist/workspaces/index.d.ts.map +1 -0
- package/node_modules/@happier-dev/cli-common/dist/workspaces/index.js +80 -0
- package/node_modules/@happier-dev/cli-common/dist/workspaces/index.js.map +1 -0
- package/node_modules/@happier-dev/cli-common/package.json +26 -0
- package/package.json +77 -0
- package/scripts/auth.mjs +1829 -0
- package/scripts/auth_copy_from_pglite_lock_in_use.integration.test.mjs +90 -0
- package/scripts/auth_copy_from_runCapture.integration.test.mjs +447 -0
- package/scripts/auth_help_cmd.test.mjs +28 -0
- package/scripts/auth_login_flow_in_tty.test.mjs +100 -0
- package/scripts/auth_login_force_default.test.mjs +66 -0
- package/scripts/auth_login_guided_server_no_expo.test.mjs +126 -0
- package/scripts/auth_login_method_override.test.mjs +67 -0
- package/scripts/auth_login_print_includes_configure_links.test.mjs +99 -0
- package/scripts/auth_status_server_validation.integration.test.mjs +140 -0
- package/scripts/build.mjs +266 -0
- package/scripts/bundleWorkspaceDeps.mjs +38 -0
- package/scripts/bundleWorkspaceDeps.test.mjs +77 -0
- package/scripts/ci.mjs +135 -0
- package/scripts/ci.test.mjs +50 -0
- package/scripts/cli-link.mjs +57 -0
- package/scripts/completion.mjs +395 -0
- package/scripts/contrib.mjs +333 -0
- package/scripts/daemon.mjs +1160 -0
- package/scripts/daemon.status_scope.test.mjs +51 -0
- package/scripts/daemon_cmd.mjs +26 -0
- package/scripts/daemon_dist_guard.test.mjs +171 -0
- package/scripts/daemon_invalid_auth_reseed_stack_name.integration.test.mjs +608 -0
- package/scripts/daemon_server_scoped_state.test.mjs +49 -0
- package/scripts/daemon_start_verification.integration.test.mjs +296 -0
- package/scripts/dev.mjs +545 -0
- package/scripts/doctor.mjs +340 -0
- package/scripts/doctor_cmd.test.mjs +22 -0
- package/scripts/doctor_ui_index_missing.test.mjs +37 -0
- package/scripts/eas.mjs +367 -0
- package/scripts/eas_platform_parsing.test.mjs +63 -0
- package/scripts/edison.mjs +1848 -0
- package/scripts/env.mjs +149 -0
- package/scripts/env_cmd.test.mjs +118 -0
- package/scripts/exit_cleanup_kills_detached_children_on_crash.integration.test.mjs +80 -0
- package/scripts/happier.mjs +82 -0
- package/scripts/import.mjs +1327 -0
- package/scripts/init.mjs +464 -0
- package/scripts/install.mjs +550 -0
- package/scripts/lint.mjs +177 -0
- package/scripts/menubar.mjs +202 -0
- package/scripts/migrate.mjs +318 -0
- package/scripts/mobile.mjs +353 -0
- package/scripts/mobile_dev_client.mjs +87 -0
- package/scripts/monorepo.mjs +2234 -0
- package/scripts/monorepo_port.apply.integration.test.mjs +680 -0
- package/scripts/monorepo_port.conflicts.integration.test.mjs +454 -0
- package/scripts/monorepo_port.validation.integration.test.mjs +486 -0
- package/scripts/orchestrated_stack_auth_flow.test.mjs +134 -0
- package/scripts/orchestrated_stack_auth_flow_resolve_port.test.mjs +98 -0
- package/scripts/orchestrated_stack_auth_flow_webapp_url.test.mjs +119 -0
- package/scripts/pack.mjs +257 -0
- package/scripts/pack.test.mjs +68 -0
- package/scripts/pglite_lock.integration.test.mjs +152 -0
- package/scripts/provision/linux-ubuntu-e2e.sh +132 -0
- package/scripts/provision/linux-ubuntu-review-pr.sh +66 -0
- package/scripts/provision/macos-lima-happy-vm.sh +192 -0
- package/scripts/provision/macos-lima-hstack-e2e.sh +100 -0
- package/scripts/release.mjs +53 -0
- package/scripts/release_binary_smoke.integration.test.mjs +159 -0
- package/scripts/review.mjs +1752 -0
- package/scripts/review_pr.mjs +435 -0
- package/scripts/run.mjs +561 -0
- package/scripts/run_script_with_stack_env.restart_port_reuse.test.mjs +30 -0
- package/scripts/self.mjs +465 -0
- package/scripts/self_host.mjs +9 -0
- package/scripts/self_host_binary_smoke.integration.test.mjs +94 -0
- package/scripts/self_host_runtime.mjs +883 -0
- package/scripts/self_host_runtime.test.mjs +82 -0
- package/scripts/self_host_systemd.real.integration.test.mjs +367 -0
- package/scripts/server_flavor.mjs +148 -0
- package/scripts/service.mjs +868 -0
- package/scripts/service_mode_help.test.mjs +27 -0
- package/scripts/setup.mjs +1324 -0
- package/scripts/setup_non_interactive_flag.test.mjs +60 -0
- package/scripts/setup_pr.mjs +605 -0
- package/scripts/setup_pr_orchestrated_auth_flow_util_import.test.mjs +117 -0
- package/scripts/stack/command_arguments.mjs +91 -0
- package/scripts/stack/copy_auth_from_stack.mjs +111 -0
- package/scripts/stack/delegated_script_commands.mjs +92 -0
- package/scripts/stack/help_text.mjs +110 -0
- package/scripts/stack/port_reservation.mjs +74 -0
- package/scripts/stack/repo_checkout_resolution.mjs +31 -0
- package/scripts/stack/run_script_with_stack_env.mjs +634 -0
- package/scripts/stack/stack_daemon_command.mjs +219 -0
- package/scripts/stack/stack_delegated_help.mjs +81 -0
- package/scripts/stack/stack_environment.mjs +151 -0
- package/scripts/stack/stack_environment.sanitization.test.mjs +75 -0
- package/scripts/stack/stack_happier_passthrough_command.mjs +63 -0
- package/scripts/stack/stack_info_snapshot.mjs +167 -0
- package/scripts/stack/stack_mobile_install_command.mjs +61 -0
- package/scripts/stack/stack_resume_command.mjs +76 -0
- package/scripts/stack/stack_stop_command.mjs +34 -0
- package/scripts/stack/stack_workspace_command.mjs +83 -0
- package/scripts/stack/transient_repo_overrides.mjs +29 -0
- package/scripts/stack.mjs +2388 -0
- package/scripts/stack_archive_cmd.integration.test.mjs +31 -0
- package/scripts/stack_audit_fix_light_env.test.mjs +129 -0
- package/scripts/stack_background_pinned_stack_json.test.mjs +81 -0
- package/scripts/stack_copy_auth_server_scoped.test.mjs +243 -0
- package/scripts/stack_daemon_cmd.integration.test.mjs +484 -0
- package/scripts/stack_eas_help.test.mjs +72 -0
- package/scripts/stack_editor_workspace_monorepo_root.test.mjs +102 -0
- package/scripts/stack_env_cmd.test.mjs +107 -0
- package/scripts/stack_guided_login_bundle_error_parse.test.mjs +20 -0
- package/scripts/stack_guided_login_inner_invocation.test.mjs +46 -0
- package/scripts/stack_happy_cmd.integration.test.mjs +263 -0
- package/scripts/stack_info_snapshot_running_status.test.mjs +186 -0
- package/scripts/stack_interactive_monorepo_group.test.mjs +128 -0
- package/scripts/stack_monorepo_defaults.test.mjs +31 -0
- package/scripts/stack_monorepo_repo_dev_token.test.mjs +32 -0
- package/scripts/stack_monorepo_server_light_from_happy_spec.test.mjs +37 -0
- package/scripts/stack_new_name_normalize_cmd.test.mjs +38 -0
- package/scripts/stack_pr_name_normalize_cmd.test.mjs +84 -0
- package/scripts/stack_resume_cmd.integration.test.mjs +134 -0
- package/scripts/stack_server_flavors_defaults.test.mjs +64 -0
- package/scripts/stack_shorthand_cmd.integration.test.mjs +74 -0
- package/scripts/stack_stop_sweeps_legacy_infra_without_kind.integration.test.mjs +44 -0
- package/scripts/stack_stop_sweeps_when_runtime_missing.integration.test.mjs +42 -0
- package/scripts/stack_stop_sweeps_when_runtime_stale.integration.test.mjs +50 -0
- package/scripts/stack_wt_list.test.mjs +117 -0
- package/scripts/start_ui_required_default.test.mjs +63 -0
- package/scripts/stop.mjs +190 -0
- package/scripts/stopStackWithEnv_no_autosweep_when_runtime_missing.integration.test.mjs +95 -0
- package/scripts/swiftbar_git_monorepo_cmd.test.mjs +75 -0
- package/scripts/swiftbar_render_monorepo_wt_actions.integration.test.mjs +116 -0
- package/scripts/swiftbar_utils_cmd.test.mjs +92 -0
- package/scripts/swiftbar_wt_pr_backcompat.test.mjs +162 -0
- package/scripts/systemd_unit_info.test.mjs +24 -0
- package/scripts/tailscale.mjs +490 -0
- package/scripts/test_ci.mjs +36 -0
- package/scripts/test_cmd.mjs +274 -0
- package/scripts/test_cmd.test.mjs +133 -0
- package/scripts/test_integration.mjs +33 -0
- package/scripts/testkit/auth_testkit.mjs +121 -0
- package/scripts/testkit/doctor_testkit.mjs +68 -0
- package/scripts/testkit/monorepo_port_testkit.mjs +157 -0
- package/scripts/testkit/stack_archive_command_testkit.mjs +55 -0
- package/scripts/testkit/stack_new_monorepo_testkit.mjs +83 -0
- package/scripts/testkit/stack_script_command_testkit.mjs +27 -0
- package/scripts/testkit/stack_stop_sweeps_testkit.mjs +172 -0
- package/scripts/testkit/worktrees_monorepo_testkit.mjs +53 -0
- package/scripts/tools.mjs +70 -0
- package/scripts/tui.mjs +914 -0
- package/scripts/tui_stopStackForTuiExit_no_autosweep.integration.test.mjs +95 -0
- package/scripts/typecheck.mjs +178 -0
- package/scripts/ui_gateway.mjs +247 -0
- package/scripts/uninstall.mjs +179 -0
- package/scripts/utils/auth/credentials_paths.mjs +181 -0
- package/scripts/utils/auth/credentials_paths.test.mjs +187 -0
- package/scripts/utils/auth/daemon_gate.mjs +66 -0
- package/scripts/utils/auth/daemon_gate.test.mjs +116 -0
- package/scripts/utils/auth/decode_jwt_payload_unsafe.mjs +16 -0
- package/scripts/utils/auth/dev_key.mjs +163 -0
- package/scripts/utils/auth/files.mjs +56 -0
- package/scripts/utils/auth/guided_pr_auth.mjs +86 -0
- package/scripts/utils/auth/guided_stack_web_login.mjs +56 -0
- package/scripts/utils/auth/handy_master_secret.mjs +42 -0
- package/scripts/utils/auth/interactive_stack_auth.mjs +70 -0
- package/scripts/utils/auth/login_ux.mjs +105 -0
- package/scripts/utils/auth/orchestrated_stack_auth_flow.mjs +291 -0
- package/scripts/utils/auth/sources.mjs +28 -0
- package/scripts/utils/auth/stable_scope_id.mjs +91 -0
- package/scripts/utils/auth/stable_scope_id.test.mjs +51 -0
- package/scripts/utils/auth/stack_guided_login.mjs +438 -0
- package/scripts/utils/cli/arg_values.mjs +23 -0
- package/scripts/utils/cli/arg_values.test.mjs +43 -0
- package/scripts/utils/cli/args.mjs +17 -0
- package/scripts/utils/cli/cli.mjs +24 -0
- package/scripts/utils/cli/cli_registry.mjs +440 -0
- package/scripts/utils/cli/cwd_scope.mjs +158 -0
- package/scripts/utils/cli/cwd_scope.test.mjs +154 -0
- package/scripts/utils/cli/flags.mjs +17 -0
- package/scripts/utils/cli/log_forwarder.mjs +157 -0
- package/scripts/utils/cli/normalize.mjs +16 -0
- package/scripts/utils/cli/prereqs.mjs +103 -0
- package/scripts/utils/cli/prereqs.test.mjs +33 -0
- package/scripts/utils/cli/progress.mjs +141 -0
- package/scripts/utils/cli/smoke_help.mjs +44 -0
- package/scripts/utils/cli/verbosity.mjs +11 -0
- package/scripts/utils/cli/wizard.mjs +139 -0
- package/scripts/utils/cli/wizard_promptSelect.test.mjs +44 -0
- package/scripts/utils/cli/wizard_prompt_worktree_source_lazy.test.mjs +132 -0
- package/scripts/utils/cli/wizard_worktree_slug.test.mjs +33 -0
- package/scripts/utils/crypto/tokens.mjs +14 -0
- package/scripts/utils/dev/daemon.mjs +232 -0
- package/scripts/utils/dev/daemon_watch_resilience.test.mjs +224 -0
- package/scripts/utils/dev/expo_dev.buildEnv.test.mjs +35 -0
- package/scripts/utils/dev/expo_dev.mjs +478 -0
- package/scripts/utils/dev/expo_dev.test.mjs +89 -0
- package/scripts/utils/dev/expo_dev_restart_port_reservation.test.mjs +120 -0
- package/scripts/utils/dev/expo_dev_verbose_logs.test.mjs +60 -0
- package/scripts/utils/dev/server.mjs +180 -0
- package/scripts/utils/dev_auth_key.mjs +7 -0
- package/scripts/utils/edison/git_roots.mjs +30 -0
- package/scripts/utils/edison/git_roots.test.mjs +49 -0
- package/scripts/utils/env/config.mjs +52 -0
- package/scripts/utils/env/dotenv.mjs +32 -0
- package/scripts/utils/env/dotenv.test.mjs +32 -0
- package/scripts/utils/env/env.mjs +130 -0
- package/scripts/utils/env/env_file.mjs +98 -0
- package/scripts/utils/env/env_file.test.mjs +49 -0
- package/scripts/utils/env/env_local.mjs +25 -0
- package/scripts/utils/env/load_env_file.mjs +34 -0
- package/scripts/utils/env/read.mjs +30 -0
- package/scripts/utils/env/sandbox.mjs +13 -0
- package/scripts/utils/env/scrub_env.mjs +69 -0
- package/scripts/utils/env/scrub_env.test.mjs +102 -0
- package/scripts/utils/env/values.mjs +13 -0
- package/scripts/utils/expo/command.mjs +65 -0
- package/scripts/utils/expo/expo.mjs +139 -0
- package/scripts/utils/expo/expo_state_running.test.mjs +48 -0
- package/scripts/utils/expo/metro_ports.mjs +101 -0
- package/scripts/utils/expo/metro_ports.test.mjs +35 -0
- package/scripts/utils/fs/atomic_dir_swap.mjs +55 -0
- package/scripts/utils/fs/atomic_dir_swap.test.mjs +54 -0
- package/scripts/utils/fs/file_has_content.mjs +10 -0
- package/scripts/utils/fs/fs.mjs +11 -0
- package/scripts/utils/fs/json.mjs +25 -0
- package/scripts/utils/fs/ops.mjs +29 -0
- package/scripts/utils/fs/package_json.mjs +8 -0
- package/scripts/utils/fs/tail.mjs +12 -0
- package/scripts/utils/git/dev_checkout.mjs +127 -0
- package/scripts/utils/git/dev_checkout.test.mjs +115 -0
- package/scripts/utils/git/git.mjs +67 -0
- package/scripts/utils/git/parse_name_status_z.mjs +21 -0
- package/scripts/utils/git/refs.mjs +26 -0
- package/scripts/utils/git/worktrees.mjs +323 -0
- package/scripts/utils/git/worktrees_monorepo.test.mjs +60 -0
- package/scripts/utils/git/worktrees_pathstyle.test.mjs +53 -0
- package/scripts/utils/llm/assist.mjs +260 -0
- package/scripts/utils/llm/codex_exec.mjs +61 -0
- package/scripts/utils/llm/codex_exec.test.mjs +46 -0
- package/scripts/utils/llm/hstack_runner.mjs +59 -0
- package/scripts/utils/llm/tools.mjs +56 -0
- package/scripts/utils/llm/tools.test.mjs +67 -0
- package/scripts/utils/menubar/swiftbar.mjs +121 -0
- package/scripts/utils/menubar/swiftbar.test.mjs +85 -0
- package/scripts/utils/mobile/config.mjs +35 -0
- package/scripts/utils/mobile/dev_client_links.mjs +59 -0
- package/scripts/utils/mobile/identifiers.mjs +46 -0
- package/scripts/utils/mobile/identifiers.test.mjs +41 -0
- package/scripts/utils/mobile/ios_xcodeproj_patch.mjs +128 -0
- package/scripts/utils/mobile/ios_xcodeproj_patch.test.mjs +131 -0
- package/scripts/utils/net/bind_mode.mjs +39 -0
- package/scripts/utils/net/dns.mjs +10 -0
- package/scripts/utils/net/lan_ip.mjs +24 -0
- package/scripts/utils/net/ports.mjs +110 -0
- package/scripts/utils/net/tcp_forward.mjs +162 -0
- package/scripts/utils/net/url.mjs +30 -0
- package/scripts/utils/net/url.test.mjs +29 -0
- package/scripts/utils/paths/canonical_home.mjs +15 -0
- package/scripts/utils/paths/canonical_home.test.mjs +28 -0
- package/scripts/utils/paths/localhost_host.mjs +112 -0
- package/scripts/utils/paths/localhost_host.test.mjs +58 -0
- package/scripts/utils/paths/paths.mjs +302 -0
- package/scripts/utils/paths/paths_env_win32.test.mjs +36 -0
- package/scripts/utils/paths/paths_monorepo.test.mjs +58 -0
- package/scripts/utils/paths/paths_server_flavors.test.mjs +50 -0
- package/scripts/utils/paths/runtime.mjs +41 -0
- package/scripts/utils/pglite_lock.mjs +107 -0
- package/scripts/utils/proc/commands.mjs +33 -0
- package/scripts/utils/proc/exit_cleanup.mjs +57 -0
- package/scripts/utils/proc/happy_monorepo_deps.mjs +37 -0
- package/scripts/utils/proc/happy_monorepo_deps.test.mjs +89 -0
- package/scripts/utils/proc/ownership.mjs +217 -0
- package/scripts/utils/proc/ownership_killProcessGroupOwnedByStack.test.mjs +216 -0
- package/scripts/utils/proc/ownership_listPidsWithEnvNeedles.test.mjs +88 -0
- package/scripts/utils/proc/package_scripts.mjs +38 -0
- package/scripts/utils/proc/package_scripts.test.mjs +58 -0
- package/scripts/utils/proc/parallel.mjs +25 -0
- package/scripts/utils/proc/pids.mjs +11 -0
- package/scripts/utils/proc/pm.mjs +478 -0
- package/scripts/utils/proc/pm_spawn.integration.test.mjs +131 -0
- package/scripts/utils/proc/pm_stack_cache_env.test.mjs +313 -0
- package/scripts/utils/proc/proc.mjs +331 -0
- package/scripts/utils/proc/proc.test.mjs +85 -0
- package/scripts/utils/proc/terminate.mjs +69 -0
- package/scripts/utils/proc/terminate.test.mjs +54 -0
- package/scripts/utils/proc/watch.mjs +63 -0
- package/scripts/utils/review/augment_runner_integration.test.mjs +105 -0
- package/scripts/utils/review/base_ref.mjs +82 -0
- package/scripts/utils/review/base_ref.test.mjs +89 -0
- package/scripts/utils/review/chunks.mjs +55 -0
- package/scripts/utils/review/chunks.test.mjs +107 -0
- package/scripts/utils/review/detached_worktree.mjs +61 -0
- package/scripts/utils/review/detached_worktree.test.mjs +61 -0
- package/scripts/utils/review/findings.mjs +278 -0
- package/scripts/utils/review/findings.test.mjs +203 -0
- package/scripts/utils/review/head_slice.mjs +132 -0
- package/scripts/utils/review/head_slice.test.mjs +117 -0
- package/scripts/utils/review/instructions/deep.md +20 -0
- package/scripts/utils/review/prompts.mjs +279 -0
- package/scripts/utils/review/prompts.test.mjs +77 -0
- package/scripts/utils/review/run_reviewers_safe.mjs +12 -0
- package/scripts/utils/review/run_reviewers_safe.test.mjs +45 -0
- package/scripts/utils/review/runners/augment.mjs +91 -0
- package/scripts/utils/review/runners/augment.test.mjs +64 -0
- package/scripts/utils/review/runners/claude.mjs +92 -0
- package/scripts/utils/review/runners/claude.test.mjs +47 -0
- package/scripts/utils/review/runners/coderabbit.mjs +105 -0
- package/scripts/utils/review/runners/coderabbit.test.mjs +32 -0
- package/scripts/utils/review/runners/codex.mjs +129 -0
- package/scripts/utils/review/runners/codex.test.mjs +115 -0
- package/scripts/utils/review/slice_mode.mjs +20 -0
- package/scripts/utils/review/slice_mode.test.mjs +69 -0
- package/scripts/utils/review/sliced_runner.mjs +39 -0
- package/scripts/utils/review/sliced_runner.test.mjs +57 -0
- package/scripts/utils/review/slices.mjs +140 -0
- package/scripts/utils/review/slices.test.mjs +41 -0
- package/scripts/utils/review/targets.mjs +23 -0
- package/scripts/utils/review/targets.test.mjs +31 -0
- package/scripts/utils/review/tool_home_seed.mjs +106 -0
- package/scripts/utils/review/tool_home_seed.test.mjs +124 -0
- package/scripts/utils/review/uncommitted_ops.mjs +77 -0
- package/scripts/utils/review/uncommitted_ops.test.mjs +117 -0
- package/scripts/utils/sandbox/review_pr_sandbox.mjs +105 -0
- package/scripts/utils/server/apply_server_light_env_defaults.mjs +14 -0
- package/scripts/utils/server/flavor_scripts.mjs +138 -0
- package/scripts/utils/server/flavor_scripts.test.mjs +115 -0
- package/scripts/utils/server/infra/happy_server_infra.mjs +444 -0
- package/scripts/utils/server/mobile_api_url.mjs +60 -0
- package/scripts/utils/server/mobile_api_url.test.mjs +58 -0
- package/scripts/utils/server/port.mjs +55 -0
- package/scripts/utils/server/prisma_import.mjs +36 -0
- package/scripts/utils/server/prisma_import.test.mjs +78 -0
- package/scripts/utils/server/server.mjs +109 -0
- package/scripts/utils/server/ui_build_check.mjs +37 -0
- package/scripts/utils/server/ui_build_check.test.mjs +70 -0
- package/scripts/utils/server/ui_env.mjs +13 -0
- package/scripts/utils/server/ui_env.test.mjs +57 -0
- package/scripts/utils/server/urls.mjs +100 -0
- package/scripts/utils/server/validate.mjs +60 -0
- package/scripts/utils/server/validate.test.mjs +76 -0
- package/scripts/utils/service/autostart_darwin.mjs +198 -0
- package/scripts/utils/service/autostart_darwin.test.mjs +49 -0
- package/scripts/utils/service/autostart_darwin_keepalive.test.mjs +19 -0
- package/scripts/utils/stack/cli_identities.mjs +29 -0
- package/scripts/utils/stack/context.mjs +19 -0
- package/scripts/utils/stack/dirs.mjs +26 -0
- package/scripts/utils/stack/editor_workspace.mjs +126 -0
- package/scripts/utils/stack/interactive_stack_config.mjs +266 -0
- package/scripts/utils/stack/interactive_stack_config.port_validation.test.mjs +93 -0
- package/scripts/utils/stack/interactive_stack_config.remote_validation.test.mjs +122 -0
- package/scripts/utils/stack/interactive_stack_config.stack_name_validation.test.mjs +76 -0
- package/scripts/utils/stack/interactive_stack_config_testkit.mjs +18 -0
- package/scripts/utils/stack/names.mjs +27 -0
- package/scripts/utils/stack/names.test.mjs +26 -0
- package/scripts/utils/stack/pr_stack_name.mjs +16 -0
- package/scripts/utils/stack/runtime_state.mjs +88 -0
- package/scripts/utils/stack/stacks.mjs +40 -0
- package/scripts/utils/stack/startup.mjs +370 -0
- package/scripts/utils/stack/startup_server_light_dirs.test.mjs +119 -0
- package/scripts/utils/stack/startup_server_light_generate.test.mjs +20 -0
- package/scripts/utils/stack/startup_server_light_legacy.test.mjs +79 -0
- package/scripts/utils/stack/startup_server_light_testkit.mjs +106 -0
- package/scripts/utils/stack/stop.mjs +284 -0
- package/scripts/utils/stack_context.mjs +1 -0
- package/scripts/utils/stack_runtime_state.mjs +1 -0
- package/scripts/utils/stacks.mjs +1 -0
- package/scripts/utils/tailscale/ip.mjs +116 -0
- package/scripts/utils/tauri/stack_overrides.mjs +22 -0
- package/scripts/utils/test/collect_test_files.mjs +29 -0
- package/scripts/utils/time/get_today_ymd.mjs +7 -0
- package/scripts/utils/tui/cleanup.mjs +38 -0
- package/scripts/utils/ui/ansi.mjs +47 -0
- package/scripts/utils/ui/browser.mjs +31 -0
- package/scripts/utils/ui/browser.test.mjs +56 -0
- package/scripts/utils/ui/clipboard.mjs +38 -0
- package/scripts/utils/ui/layout.mjs +44 -0
- package/scripts/utils/ui/qr.mjs +17 -0
- package/scripts/utils/ui/terminal_launcher.mjs +129 -0
- package/scripts/utils/ui/text.mjs +16 -0
- package/scripts/utils/update/auto_update_notice.mjs +93 -0
- package/scripts/utils/validate.mjs +5 -0
- package/scripts/where.mjs +138 -0
- package/scripts/worktrees.mjs +2174 -0
- package/scripts/worktrees_archive_cmd.integration.test.mjs +228 -0
- package/scripts/worktrees_cursor_monorepo_root.test.mjs +23 -0
- package/scripts/worktrees_list_specs_no_recurse.test.mjs +32 -0
- package/scripts/worktrees_monorepo_testkit.test.mjs +29 -0
- package/scripts/worktrees_monorepo_use_group.test.mjs +41 -0
|
@@ -0,0 +1,2174 @@
|
|
|
1
|
+
import './utils/env/env.mjs';
|
|
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
|
+
import { parseArgs } from './utils/cli/args.mjs';
|
|
5
|
+
import { pathExists } from './utils/fs/fs.mjs';
|
|
6
|
+
import { run, runCapture } from './utils/proc/proc.mjs';
|
|
7
|
+
import { commandExists, resolveCommandPath } from './utils/proc/commands.mjs';
|
|
8
|
+
import {
|
|
9
|
+
coerceHappyMonorepoRootFromPath,
|
|
10
|
+
getComponentRepoDir,
|
|
11
|
+
getHappyStacksHomeDir,
|
|
12
|
+
getDevRepoDir,
|
|
13
|
+
getRootDir,
|
|
14
|
+
getRepoDir,
|
|
15
|
+
getWorkspaceDir,
|
|
16
|
+
resolveStackEnvPath,
|
|
17
|
+
} from './utils/paths/paths.mjs';
|
|
18
|
+
import {
|
|
19
|
+
WORKTREE_CATEGORIES,
|
|
20
|
+
getWorktreeArchiveRoot,
|
|
21
|
+
getWorktreeCategoryRoot,
|
|
22
|
+
inferRemoteNameForOwner,
|
|
23
|
+
listWorktreeSpecs,
|
|
24
|
+
parseGithubOwner,
|
|
25
|
+
parseGithubOwnerRepo,
|
|
26
|
+
resolveComponentSpecToDir,
|
|
27
|
+
} from './utils/git/worktrees.mjs';
|
|
28
|
+
import { parseGithubPullRequest, sanitizeSlugPart } from './utils/git/refs.mjs';
|
|
29
|
+
import { readTextIfExists } from './utils/fs/ops.mjs';
|
|
30
|
+
import { isTty, prompt, promptSelect, withRl } from './utils/cli/wizard.mjs';
|
|
31
|
+
import { printResult, wantsHelp, wantsJson } from './utils/cli/cli.mjs';
|
|
32
|
+
import { ensureEnvLocalUpdated } from './utils/env/env_local.mjs';
|
|
33
|
+
import { ensureEnvFilePruned, ensureEnvFileUpdated } from './utils/env/env_file.mjs';
|
|
34
|
+
import { isSandboxed } from './utils/env/sandbox.mjs';
|
|
35
|
+
import { applyStackCacheEnv } from './utils/proc/pm.mjs';
|
|
36
|
+
import { existsSync } from 'node:fs';
|
|
37
|
+
import { getHomeEnvLocalPath, getHomeEnvPath, resolveUserConfigEnvPath } from './utils/env/config.mjs';
|
|
38
|
+
import { detectServerComponentDirMismatch } from './utils/server/validate.mjs';
|
|
39
|
+
import { listAllStackNames } from './utils/stack/stacks.mjs';
|
|
40
|
+
import { parseDotenv } from './utils/env/dotenv.mjs';
|
|
41
|
+
import { bold, cyan, dim, green } from './utils/ui/ansi.mjs';
|
|
42
|
+
import { getTodayYmd } from './utils/time/get_today_ymd.mjs';
|
|
43
|
+
|
|
44
|
+
const DEFAULT_REPO_COMPONENT = 'happier-ui';
|
|
45
|
+
const REPO_DIR_ENV_KEY = 'HAPPIER_STACK_REPO_DIR';
|
|
46
|
+
|
|
47
|
+
function getActiveStackName() {
|
|
48
|
+
return (process.env.HAPPIER_STACK_STACK ?? '').trim() || 'main';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function isMainStack() {
|
|
52
|
+
return getActiveStackName() === 'main';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function worktreeRepoKeyForComponent(rootDir, component) {
|
|
56
|
+
// Worktrees are repo-scoped (monorepo-only) and no longer nested by component key.
|
|
57
|
+
void rootDir;
|
|
58
|
+
void component;
|
|
59
|
+
return '';
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function getActiveRepoDir(rootDir) {
|
|
63
|
+
return getRepoDir(rootDir, process.env);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function getDefaultRepoDir(rootDir) {
|
|
67
|
+
// Clone env so we can suppress the override for this lookup.
|
|
68
|
+
const env = { ...process.env, [REPO_DIR_ENV_KEY]: '' };
|
|
69
|
+
return getRepoDir(rootDir, env);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function resolveComponentWorktreeDir({ rootDir, component, spec }) {
|
|
73
|
+
const raw = (spec ?? '').trim();
|
|
74
|
+
void component;
|
|
75
|
+
|
|
76
|
+
if (!raw) {
|
|
77
|
+
// Default: current active repo dir (env override if present, otherwise <workspace>/happier).
|
|
78
|
+
return getActiveRepoDir(rootDir);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (raw === 'default' || raw === 'main') {
|
|
82
|
+
return getDefaultRepoDir(rootDir);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (raw === 'dev') {
|
|
86
|
+
return getDevRepoDir(rootDir, process.env);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (raw === 'active') {
|
|
90
|
+
return getActiveRepoDir(rootDir);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (!isAbsolute(raw)) {
|
|
94
|
+
// Allow passing a workspace-relative path as an escape hatch.
|
|
95
|
+
const rel = resolve(getWorkspaceDir(rootDir), raw);
|
|
96
|
+
if (existsSync(rel)) {
|
|
97
|
+
return coerceHappyMonorepoRootFromPath(rel) ?? rel;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Absolute paths and workspace-relative specs (Option C).
|
|
102
|
+
const resolved = resolveComponentSpecToDir({ rootDir, component: DEFAULT_REPO_COMPONENT, spec: raw });
|
|
103
|
+
if (resolved) {
|
|
104
|
+
return coerceHappyMonorepoRootFromPath(resolved) ?? resolved;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Fallback: treat raw as a literal path.
|
|
108
|
+
if (isAbsolute(raw)) {
|
|
109
|
+
return coerceHappyMonorepoRootFromPath(raw) ?? raw;
|
|
110
|
+
}
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function isWorktreeClean(dir) {
|
|
115
|
+
const dirty = (await git(dir, ['status', '--porcelain'])).trim();
|
|
116
|
+
return !dirty;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function maybeStash({ dir, enabled, keep, message }) {
|
|
120
|
+
if (!enabled && !keep) {
|
|
121
|
+
return { stashed: false, kept: false };
|
|
122
|
+
}
|
|
123
|
+
const clean = await isWorktreeClean(dir);
|
|
124
|
+
if (clean) {
|
|
125
|
+
return { stashed: false, kept: false };
|
|
126
|
+
}
|
|
127
|
+
const msg = message || `hstack auto-stash (${new Date().toISOString()})`;
|
|
128
|
+
// Include untracked files (-u). If stash applies cleanly later, we'll pop.
|
|
129
|
+
await git(dir, ['stash', 'push', '-u', '-m', msg]);
|
|
130
|
+
return { stashed: true, kept: Boolean(keep) };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function maybePopStash({ dir, stashed, keep }) {
|
|
134
|
+
if (!stashed || keep) {
|
|
135
|
+
return { popped: false, popError: null };
|
|
136
|
+
}
|
|
137
|
+
try {
|
|
138
|
+
await git(dir, ['stash', 'pop']);
|
|
139
|
+
return { popped: true, popError: null };
|
|
140
|
+
} catch (e) {
|
|
141
|
+
// On conflicts, `git stash pop` keeps the stash entry.
|
|
142
|
+
return { popped: false, popError: String(e?.message ?? e) };
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function hardReset({ dir, target }) {
|
|
147
|
+
await git(dir, ['reset', '--hard', target]);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function git(root, args) {
|
|
151
|
+
return await runCapture('git', args, { cwd: root });
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function gitOk(root, args) {
|
|
155
|
+
try {
|
|
156
|
+
await runCapture('git', args, { cwd: root });
|
|
157
|
+
return true;
|
|
158
|
+
} catch {
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function parseDepsMode(raw) {
|
|
164
|
+
const v = (raw ?? '').trim().toLowerCase();
|
|
165
|
+
if (!v) return 'none';
|
|
166
|
+
if (v === 'none') return 'none';
|
|
167
|
+
if (v === 'link' || v === 'symlink') return 'link';
|
|
168
|
+
if (v === 'install') return 'install';
|
|
169
|
+
if (v === 'link-or-install' || v === 'linkorinstall') return 'link-or-install';
|
|
170
|
+
throw new Error(`[wt] invalid --deps value: ${raw}. Expected one of: none | link | install | link-or-install`);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function getWorktreeGitDir(worktreeDir) {
|
|
174
|
+
const gitDir = (await git(worktreeDir, ['rev-parse', '--git-dir'])).trim();
|
|
175
|
+
// rev-parse may return a relative path.
|
|
176
|
+
return isAbsolute(gitDir) ? gitDir : resolve(worktreeDir, gitDir);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function gitShowTopLevel(dir) {
|
|
180
|
+
return (await git(dir, ['rev-parse', '--show-toplevel'])).trim();
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function parseGitdirFile(contents) {
|
|
184
|
+
const raw = (contents ?? '').toString();
|
|
185
|
+
const line = raw
|
|
186
|
+
.split('\n')
|
|
187
|
+
.map((l) => l.trim())
|
|
188
|
+
.find((l) => l.startsWith('gitdir:'));
|
|
189
|
+
const path = line?.slice('gitdir:'.length).trim();
|
|
190
|
+
return path || null;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function inferSourceRepoDirFromLinkedGitDir(linkedGitDir) {
|
|
194
|
+
// Typical worktree gitdir: "<repo>/.git/worktrees/<name>"
|
|
195
|
+
// We want "<repo>".
|
|
196
|
+
const worktreesDir = dirname(linkedGitDir);
|
|
197
|
+
const gitDir = dirname(worktreesDir);
|
|
198
|
+
if (basename(worktreesDir) !== 'worktrees' || basename(gitDir) !== '.git') {
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
return dirname(gitDir);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function isJsonMode() {
|
|
205
|
+
return Boolean((process.argv ?? []).includes('--json'));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async function runMaybeQuiet(cmd, args, options) {
|
|
209
|
+
if (isJsonMode()) {
|
|
210
|
+
await runCapture(cmd, args, options);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
await run(cmd, args, options);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async function detachGitWorktree({ worktreeDir, expectedBranch = null }) {
|
|
217
|
+
const gitPath = join(worktreeDir, '.git');
|
|
218
|
+
|
|
219
|
+
// If `.git` is already a directory, it's already detached.
|
|
220
|
+
if (await pathExists(join(worktreeDir, '.git', 'HEAD'))) {
|
|
221
|
+
const head = (await git(worktreeDir, ['rev-parse', 'HEAD'])).trim();
|
|
222
|
+
let branch = null;
|
|
223
|
+
try {
|
|
224
|
+
const b = (await git(worktreeDir, ['symbolic-ref', '--quiet', '--short', 'HEAD'])).trim();
|
|
225
|
+
branch = b || null;
|
|
226
|
+
} catch {
|
|
227
|
+
branch = null;
|
|
228
|
+
}
|
|
229
|
+
// Already detached repos have no "source" repo to prune, and we must not delete the branch here.
|
|
230
|
+
const gitDir = await getWorktreeGitDir(worktreeDir);
|
|
231
|
+
return { worktreeDir, head, branch, sourceRepoDir: null, linkedGitDir: gitDir, alreadyDetached: true };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const gitFileContents = await readFile(gitPath, 'utf-8');
|
|
235
|
+
const linkedGitDirFromFile = parseGitdirFile(gitFileContents);
|
|
236
|
+
if (!linkedGitDirFromFile) {
|
|
237
|
+
throw new Error(`[wt] expected ${gitPath} to be a linked worktree .git file`);
|
|
238
|
+
}
|
|
239
|
+
const linkedGitDir = isAbsolute(linkedGitDirFromFile) ? linkedGitDirFromFile : resolve(worktreeDir, linkedGitDirFromFile);
|
|
240
|
+
|
|
241
|
+
// If the worktree's linked gitdir has been deleted (common after manual moves/prunes),
|
|
242
|
+
// we can still archive it by reconstructing a standalone repo from the source repo.
|
|
243
|
+
const linkedGitDirExists = await pathExists(linkedGitDir);
|
|
244
|
+
const isBrokenLinkedWorktree = !linkedGitDirExists;
|
|
245
|
+
|
|
246
|
+
let branch = null;
|
|
247
|
+
let head = '';
|
|
248
|
+
|
|
249
|
+
if (!isBrokenLinkedWorktree) {
|
|
250
|
+
head = (await git(worktreeDir, ['rev-parse', 'HEAD'])).trim();
|
|
251
|
+
try {
|
|
252
|
+
const b = (await git(worktreeDir, ['symbolic-ref', '--quiet', '--short', 'HEAD'])).trim();
|
|
253
|
+
branch = b || null;
|
|
254
|
+
} catch {
|
|
255
|
+
branch = null;
|
|
256
|
+
}
|
|
257
|
+
} else {
|
|
258
|
+
branch = expectedBranch || null;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
let sourceRepoDir = null;
|
|
262
|
+
if (!isBrokenLinkedWorktree) {
|
|
263
|
+
const commonDir = (await git(worktreeDir, ['rev-parse', '--path-format=absolute', '--git-common-dir'])).trim();
|
|
264
|
+
sourceRepoDir = dirname(commonDir);
|
|
265
|
+
} else {
|
|
266
|
+
sourceRepoDir = inferSourceRepoDirFromLinkedGitDir(linkedGitDir);
|
|
267
|
+
if (!sourceRepoDir) {
|
|
268
|
+
throw new Error(`[wt] unable to infer source repo dir from broken linked gitdir: ${linkedGitDir}`);
|
|
269
|
+
}
|
|
270
|
+
if (!head) {
|
|
271
|
+
try {
|
|
272
|
+
if (branch) {
|
|
273
|
+
head = (await runCapture('git', ['rev-parse', branch], { cwd: sourceRepoDir })).trim();
|
|
274
|
+
} else {
|
|
275
|
+
head = (await runCapture('git', ['rev-parse', 'HEAD'], { cwd: sourceRepoDir })).trim();
|
|
276
|
+
}
|
|
277
|
+
} catch {
|
|
278
|
+
head = '';
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
await rename(gitPath, join(worktreeDir, '.git.worktree'));
|
|
284
|
+
await runMaybeQuiet('git', ['init'], { cwd: worktreeDir });
|
|
285
|
+
|
|
286
|
+
const remoteName = 'archive-source';
|
|
287
|
+
if (sourceRepoDir) {
|
|
288
|
+
await runMaybeQuiet('git', ['remote', 'add', remoteName, sourceRepoDir], { cwd: worktreeDir });
|
|
289
|
+
await runMaybeQuiet('git', ['fetch', '--tags', remoteName], { cwd: worktreeDir });
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (branch) {
|
|
293
|
+
await runMaybeQuiet('git', ['update-ref', `refs/heads/${branch}`, head], { cwd: worktreeDir });
|
|
294
|
+
await runMaybeQuiet('git', ['symbolic-ref', 'HEAD', `refs/heads/${branch}`], { cwd: worktreeDir });
|
|
295
|
+
} else {
|
|
296
|
+
await writeFile(join(worktreeDir, '.git', 'HEAD'), `${head}\n`, 'utf-8');
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Preserve staged state by copying the per-worktree index into the new repo.
|
|
300
|
+
if (!isBrokenLinkedWorktree) {
|
|
301
|
+
await copyFile(join(linkedGitDir, 'index'), join(worktreeDir, '.git', 'index')).catch(() => {});
|
|
302
|
+
} else if (head) {
|
|
303
|
+
// Populate the index from HEAD without touching the working tree, so uncommitted changes remain intact.
|
|
304
|
+
await runMaybeQuiet('git', ['read-tree', head], { cwd: worktreeDir }).catch(() => {});
|
|
305
|
+
}
|
|
306
|
+
// Avoid leaving a confusing untracked file behind in the archived repo.
|
|
307
|
+
await rm(join(worktreeDir, '.git.worktree'), { force: true }).catch(() => {});
|
|
308
|
+
|
|
309
|
+
return { worktreeDir, head, branch, sourceRepoDir, linkedGitDir, alreadyDetached: false };
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
async function findStacksReferencingWorktree({ rootDir, worktreeDir }) {
|
|
313
|
+
const workspaceDir = getWorkspaceDir(rootDir);
|
|
314
|
+
const wtReal = await realpath(worktreeDir).catch(() => resolve(worktreeDir));
|
|
315
|
+
const stackNames = await listAllStackNames();
|
|
316
|
+
const hits = [];
|
|
317
|
+
|
|
318
|
+
for (const name of stackNames) {
|
|
319
|
+
const { envPath } = resolveStackEnvPath(name);
|
|
320
|
+
const contents = await readFile(envPath, 'utf-8').catch(() => '');
|
|
321
|
+
if (!contents) continue;
|
|
322
|
+
const parsed = parseDotenv(contents);
|
|
323
|
+
const keys = [];
|
|
324
|
+
|
|
325
|
+
const raw = String(parsed.get(REPO_DIR_ENV_KEY) ?? '').trim();
|
|
326
|
+
if (raw) {
|
|
327
|
+
const abs = isAbsolute(raw) ? raw : resolve(workspaceDir, raw);
|
|
328
|
+
const absReal = await realpath(abs).catch(() => resolve(abs));
|
|
329
|
+
if (absReal === wtReal || absReal.startsWith(wtReal + '/')) {
|
|
330
|
+
keys.push(REPO_DIR_ENV_KEY);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
if (keys.length) {
|
|
335
|
+
hits.push({ name, envPath, keys });
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
return hits;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
async function ensureWorktreeExclude(worktreeDir, patterns) {
|
|
343
|
+
const gitDir = await getWorktreeGitDir(worktreeDir);
|
|
344
|
+
const excludePath = join(gitDir, 'info', 'exclude');
|
|
345
|
+
const existing = (await readFile(excludePath, 'utf-8').catch(() => '')).toString();
|
|
346
|
+
const existingLines = new Set(existing.split('\n').map((l) => l.trim()).filter(Boolean));
|
|
347
|
+
const want = patterns.map((p) => p.trim()).filter(Boolean).filter((p) => !existingLines.has(p));
|
|
348
|
+
if (!want.length) return;
|
|
349
|
+
const next = (existing ? existing.replace(/\s*$/, '') + '\n' : '') + want.join('\n') + '\n';
|
|
350
|
+
await mkdir(dirname(excludePath), { recursive: true });
|
|
351
|
+
await writeFile(excludePath, next, 'utf-8');
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
async function detectPackageManager(dir) {
|
|
355
|
+
if (await pathExists(join(dir, 'package.json'))) return { kind: 'yarn' };
|
|
356
|
+
return { kind: null };
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
async function linkNodeModules({ fromDir, toDir }) {
|
|
360
|
+
const src = join(fromDir, 'node_modules');
|
|
361
|
+
const dest = join(toDir, 'node_modules');
|
|
362
|
+
|
|
363
|
+
if (!(await pathExists(src))) {
|
|
364
|
+
return { linked: false, reason: `source node_modules missing: ${src}` };
|
|
365
|
+
}
|
|
366
|
+
if (await pathExists(dest)) {
|
|
367
|
+
return { linked: false, reason: `dest node_modules already exists: ${dest}` };
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
await symlink(src, dest);
|
|
371
|
+
// Worktrees sometimes treat node_modules symlinks oddly; ensure it's excluded even if .gitignore misses it.
|
|
372
|
+
await ensureWorktreeExclude(toDir, ['node_modules']);
|
|
373
|
+
return { linked: true, reason: null };
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
async function installDependencies({ dir }) {
|
|
377
|
+
const pm = await detectPackageManager(dir);
|
|
378
|
+
if (!pm.kind) {
|
|
379
|
+
return { installed: false, reason: 'no package manager detected (no package.json)' };
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const env = await applyStackCacheEnv(process.env);
|
|
383
|
+
|
|
384
|
+
// IMPORTANT:
|
|
385
|
+
// When a caller requests --json, stdout must be reserved for JSON output only.
|
|
386
|
+
// Package managers (especially Yarn) write progress to stdout, which would corrupt JSON parsing
|
|
387
|
+
// in wrappers like `stack pr`.
|
|
388
|
+
const jsonMode = Boolean((process.argv ?? []).includes('--json'));
|
|
389
|
+
const runForJson = async (cmd, args) => {
|
|
390
|
+
try {
|
|
391
|
+
const out = await runCapture(cmd, args, { cwd: dir, env });
|
|
392
|
+
if (out) process.stderr.write(out);
|
|
393
|
+
} catch (e) {
|
|
394
|
+
const out = String(e?.out ?? '');
|
|
395
|
+
const err = String(e?.err ?? '');
|
|
396
|
+
if (out) process.stderr.write(out);
|
|
397
|
+
if (err) process.stderr.write(err);
|
|
398
|
+
throw e;
|
|
399
|
+
}
|
|
400
|
+
};
|
|
401
|
+
|
|
402
|
+
// Yarn-only.
|
|
403
|
+
// Works for yarn classic; yarn berry will ignore/translate flags as needed.
|
|
404
|
+
if (jsonMode) {
|
|
405
|
+
await runForJson('yarn', ['install', '--frozen-lockfile']);
|
|
406
|
+
} else {
|
|
407
|
+
await run('yarn', ['install', '--frozen-lockfile'], { cwd: dir, env });
|
|
408
|
+
}
|
|
409
|
+
return { installed: true, reason: null };
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function allowNodeModulesSymlinkForRepo() {
|
|
413
|
+
// Expo/Metro commonly breaks with symlinked node_modules. Avoid symlinks by default.
|
|
414
|
+
// Override if you *really* want to experiment:
|
|
415
|
+
// HAPPIER_STACK_WT_ALLOW_NODE_MODULES_SYMLINK=1
|
|
416
|
+
return (process.env.HAPPIER_STACK_WT_ALLOW_NODE_MODULES_SYMLINK ?? '').toString().trim() === '1';
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
async function maybeSetupDeps({ repoRoot, baseDir, worktreeDir, depsMode, component }) {
|
|
420
|
+
if (!depsMode || depsMode === 'none') {
|
|
421
|
+
return { mode: 'none', linked: false, installed: false, message: null };
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// Prefer explicit baseDir if provided, otherwise link from the primary checkout (repoRoot).
|
|
425
|
+
const linkFrom = baseDir || repoRoot;
|
|
426
|
+
const allowSymlink = allowNodeModulesSymlinkForRepo();
|
|
427
|
+
|
|
428
|
+
if (depsMode === 'link' || depsMode === 'link-or-install') {
|
|
429
|
+
if (!allowSymlink) {
|
|
430
|
+
const msg =
|
|
431
|
+
`[wt] refusing to symlink node_modules by default (Expo/Metro is often broken by symlinks).\n` +
|
|
432
|
+
`[wt] Fix: use --deps=install (recommended). To override: set HAPPIER_STACK_WT_ALLOW_NODE_MODULES_SYMLINK=1`;
|
|
433
|
+
if (depsMode === 'link') {
|
|
434
|
+
return { mode: depsMode, linked: false, installed: false, message: msg };
|
|
435
|
+
}
|
|
436
|
+
// link-or-install: fall through to install.
|
|
437
|
+
} else {
|
|
438
|
+
const res = await linkNodeModules({ fromDir: linkFrom, toDir: worktreeDir });
|
|
439
|
+
if (res.linked) {
|
|
440
|
+
return { mode: depsMode, linked: true, installed: false, message: null };
|
|
441
|
+
}
|
|
442
|
+
if (depsMode === 'link') {
|
|
443
|
+
return { mode: depsMode, linked: false, installed: false, message: res.reason };
|
|
444
|
+
}
|
|
445
|
+
// fall through to install
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const inst = await installDependencies({ dir: worktreeDir });
|
|
450
|
+
return { mode: depsMode, linked: false, installed: Boolean(inst.installed), message: inst.reason };
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
async function normalizeRemoteName(repoRoot, remoteName) {
|
|
454
|
+
const want = (remoteName ?? '').trim();
|
|
455
|
+
if (!want) return want;
|
|
456
|
+
|
|
457
|
+
// Some checkouts use `origin`, others use `fork`. Treat them as interchangeable if one is missing.
|
|
458
|
+
if (await gitOk(repoRoot, ['remote', 'get-url', want])) {
|
|
459
|
+
return want;
|
|
460
|
+
}
|
|
461
|
+
if (want === 'origin' && (await gitOk(repoRoot, ['remote', 'get-url', 'fork']))) {
|
|
462
|
+
return 'fork';
|
|
463
|
+
}
|
|
464
|
+
if (want === 'fork' && (await gitOk(repoRoot, ['remote', 'get-url', 'origin']))) {
|
|
465
|
+
return 'origin';
|
|
466
|
+
}
|
|
467
|
+
return want;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function parseWorktreeListPorcelain(out) {
|
|
471
|
+
const blocks = out
|
|
472
|
+
.split('\n\n')
|
|
473
|
+
.map((b) => b.trim())
|
|
474
|
+
.filter(Boolean);
|
|
475
|
+
|
|
476
|
+
return blocks
|
|
477
|
+
.map((block) => {
|
|
478
|
+
const lines = block
|
|
479
|
+
.split('\n')
|
|
480
|
+
.map((l) => l.trim())
|
|
481
|
+
.filter(Boolean);
|
|
482
|
+
const wt = { path: null, head: null, branchRef: null, detached: false };
|
|
483
|
+
for (const line of lines) {
|
|
484
|
+
if (line.startsWith('worktree ')) {
|
|
485
|
+
wt.path = line.slice('worktree '.length).trim();
|
|
486
|
+
} else if (line.startsWith('HEAD ')) {
|
|
487
|
+
wt.head = line.slice('HEAD '.length).trim();
|
|
488
|
+
} else if (line.startsWith('branch ')) {
|
|
489
|
+
wt.branchRef = line.slice('branch '.length).trim();
|
|
490
|
+
} else if (line === 'detached') {
|
|
491
|
+
wt.detached = true;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
if (!wt.path) {
|
|
495
|
+
return null;
|
|
496
|
+
}
|
|
497
|
+
return wt;
|
|
498
|
+
})
|
|
499
|
+
.filter(Boolean);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function getComponentRepoRoot(rootDir, component) {
|
|
503
|
+
// Repo-only model: components/services are derived from the active repo checkout.
|
|
504
|
+
return getComponentRepoDir(rootDir, component);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
async function resolveOwners(repoRoot) {
|
|
508
|
+
const originRemote = await normalizeRemoteName(repoRoot, 'origin') || 'origin';
|
|
509
|
+
const originUrl = (await git(repoRoot, ['remote', 'get-url', originRemote])).trim();
|
|
510
|
+
const upstreamUrl = (await git(repoRoot, ['remote', 'get-url', 'upstream']).catch(() => '')).trim();
|
|
511
|
+
|
|
512
|
+
const originOwner = parseGithubOwner(originUrl);
|
|
513
|
+
const upstreamOwner = parseGithubOwner(upstreamUrl);
|
|
514
|
+
|
|
515
|
+
if (!originOwner) {
|
|
516
|
+
throw new Error(`[wt] unable to parse origin owner for ${repoRoot} (${originRemote} -> ${originUrl})`);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
return { originOwner, upstreamOwner: upstreamOwner ?? originOwner };
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
async function resolveRemoteOwner(repoRoot, remoteName) {
|
|
523
|
+
const resolvedRemoteName = await normalizeRemoteName(repoRoot, remoteName);
|
|
524
|
+
const remoteUrl = (await git(repoRoot, ['remote', 'get-url', resolvedRemoteName])).trim();
|
|
525
|
+
const owner = parseGithubOwner(remoteUrl);
|
|
526
|
+
if (!owner) {
|
|
527
|
+
throw new Error(`[wt] unable to parse owner for remote '${resolvedRemoteName}' in ${repoRoot} (${remoteUrl})`);
|
|
528
|
+
}
|
|
529
|
+
return { owner, remoteUrl, remoteName: resolvedRemoteName };
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
async function resolveRemoteDefaultBranchName(repoRoot, remoteName, { component } = {}) {
|
|
533
|
+
// Some repos use non-`main` distribution branches on origin. For legacy compatibility, if a
|
|
534
|
+
// branch matching the component name exists on the chosen remote, prefer it. Otherwise fall
|
|
535
|
+
// back to the remote's HEAD branch, then `main`.
|
|
536
|
+
if (component) {
|
|
537
|
+
const ref = `refs/remotes/${remoteName}/${component}`;
|
|
538
|
+
if (await gitOk(repoRoot, ['show-ref', '--verify', '--quiet', ref])) {
|
|
539
|
+
return component;
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
const remoteHead = (await git(repoRoot, ['symbolic-ref', '-q', '--short', `refs/remotes/${remoteName}/HEAD`]).catch(() => '')).trim();
|
|
544
|
+
if (remoteHead.startsWith(`${remoteName}/`)) {
|
|
545
|
+
return remoteHead.slice(remoteName.length + 1);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
return 'main';
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function inferTargetOwner({ branchName, branchRemote, originOwner, upstreamOwner }) {
|
|
552
|
+
const lower = branchName.toLowerCase();
|
|
553
|
+
if (branchName.startsWith(`${originOwner}/`)) {
|
|
554
|
+
return originOwner;
|
|
555
|
+
}
|
|
556
|
+
if (branchName.startsWith(`${upstreamOwner}/`)) {
|
|
557
|
+
return upstreamOwner;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
if (branchRemote === 'upstream' || lower.includes('upstream')) {
|
|
561
|
+
return upstreamOwner;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
return originOwner;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function branchRest({ branchName, owner }) {
|
|
568
|
+
return branchName.startsWith(`${owner}/`) ? branchName.slice(owner.length + 1) : branchName;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// NOTE: legacy migrate command removed (no compatibility with old multi-repo layouts).
|
|
572
|
+
|
|
573
|
+
async function cmdUse({ rootDir, args, flags }) {
|
|
574
|
+
const component = args.length >= 2 ? args[0] : DEFAULT_REPO_COMPONENT;
|
|
575
|
+
const spec = args.length >= 2 ? args[1] : args[0];
|
|
576
|
+
if (!spec) {
|
|
577
|
+
throw new Error('[wt] usage: hstack wt use <main|dev|pr/...|local/...|tmp/...|path> [--force]');
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
void component;
|
|
581
|
+
|
|
582
|
+
// Safety: main stack should not be repointed to arbitrary worktrees by default.
|
|
583
|
+
// This is the most common “oops, the main stack now runs my PR checkout” footgun (especially for agents).
|
|
584
|
+
const force = Boolean(flags?.has('--force'));
|
|
585
|
+
if (!force && isMainStack() && spec !== 'default' && spec !== 'main') {
|
|
586
|
+
throw new Error(
|
|
587
|
+
`[wt] refusing to repoint the main stack by default.\n` +
|
|
588
|
+
`- stack: main\n` +
|
|
589
|
+
`- requested: ${spec}\n` +
|
|
590
|
+
`\n` +
|
|
591
|
+
`Recommendation:\n` +
|
|
592
|
+
`- Create a new isolated stack and switch that stack instead:\n` +
|
|
593
|
+
` hstack stack new exp1 --interactive\n` +
|
|
594
|
+
` hstack stack wt exp1 -- use ${spec}\n` +
|
|
595
|
+
`\n` +
|
|
596
|
+
`If you really intend to repoint the main stack, re-run with --force:\n` +
|
|
597
|
+
` hstack wt use ${spec} --force\n`
|
|
598
|
+
);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
const workspaceDir = getWorkspaceDir(rootDir);
|
|
602
|
+
const envPath = process.env.HAPPIER_STACK_ENV_FILE?.trim() ? process.env.HAPPIER_STACK_ENV_FILE.trim() : null;
|
|
603
|
+
|
|
604
|
+
if (spec === 'default' || spec === 'main') {
|
|
605
|
+
const repoDir = getDefaultRepoDir(rootDir);
|
|
606
|
+
const updates = [{ key: REPO_DIR_ENV_KEY, value: repoDir }];
|
|
607
|
+
await (envPath ? ensureEnvFileUpdated({ envPath, updates }) : ensureEnvLocalUpdated({ rootDir, updates }));
|
|
608
|
+
return { activeDir: repoDir, repoDir, mode: 'default' };
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// Resolve the target to a concrete repo directory.
|
|
612
|
+
const resolvedDir = resolveComponentWorktreeDir({ rootDir, component: DEFAULT_REPO_COMPONENT, spec });
|
|
613
|
+
if (!resolvedDir) {
|
|
614
|
+
throw new Error(`[wt] unable to resolve spec: ${spec}`);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
const monoRoot = coerceHappyMonorepoRootFromPath(resolvedDir);
|
|
618
|
+
if (!monoRoot) {
|
|
619
|
+
throw new Error(
|
|
620
|
+
`[wt] invalid target for hstack worktrees:\n` +
|
|
621
|
+
`- expected a path inside the Happier monorepo (contains apps/ui|apps/cli|apps/server)\n` +
|
|
622
|
+
`- but got: ${resolvedDir}\n` +
|
|
623
|
+
`Fix: pick a checkout under ${workspaceDir}/{main,dev,pr,local,tmp}/ or pass an absolute path to a Happier checkout/worktree.`
|
|
624
|
+
);
|
|
625
|
+
}
|
|
626
|
+
const writeDir = monoRoot;
|
|
627
|
+
|
|
628
|
+
if (!(await pathExists(writeDir))) {
|
|
629
|
+
throw new Error(`[wt] target does not exist: ${writeDir}`);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
const updates = [{ key: REPO_DIR_ENV_KEY, value: writeDir }];
|
|
633
|
+
await (envPath ? ensureEnvFileUpdated({ envPath, updates }) : ensureEnvLocalUpdated({ rootDir, updates }));
|
|
634
|
+
|
|
635
|
+
const activeDir = writeDir;
|
|
636
|
+
return { activeDir, repoDir: writeDir, mode: 'override' };
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
async function cmdUseInteractive({ rootDir }) {
|
|
640
|
+
await withRl(async (rl) => {
|
|
641
|
+
// eslint-disable-next-line no-console
|
|
642
|
+
console.log('');
|
|
643
|
+
// eslint-disable-next-line no-console
|
|
644
|
+
console.log(bold('Switch active worktree'));
|
|
645
|
+
|
|
646
|
+
const specs = await listWorktreeSpecs({ rootDir, component: DEFAULT_REPO_COMPONENT });
|
|
647
|
+
const devDir = getDevRepoDir(rootDir, process.env);
|
|
648
|
+
const hasDev = Boolean(devDir && (await pathExists(join(devDir, '.git'))));
|
|
649
|
+
|
|
650
|
+
const kindOptions = [{ label: `default (${dim('main checkout')})`, value: 'default' }];
|
|
651
|
+
if (hasDev) {
|
|
652
|
+
kindOptions.push({ label: `dev (${dim('dev checkout')})`, value: 'dev' });
|
|
653
|
+
}
|
|
654
|
+
if (specs.length) {
|
|
655
|
+
kindOptions.push({ label: `pick existing worktree (${green('recommended')})`, value: 'pick' });
|
|
656
|
+
}
|
|
657
|
+
const choice = await promptSelect(rl, {
|
|
658
|
+
title: `${bold('Target')}\n${dim(`Pick which ${cyan('repo')} checkout should become active.`)}`,
|
|
659
|
+
options: kindOptions,
|
|
660
|
+
defaultIndex: 0,
|
|
661
|
+
});
|
|
662
|
+
if (choice === 'dev') {
|
|
663
|
+
await cmdUse({ rootDir, args: ['dev'], flags: new Set(['--force']) });
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
if (choice === 'pick') {
|
|
667
|
+
const picked = await promptSelect(rl, {
|
|
668
|
+
title: `${bold(`Available ${cyan('repo')} worktrees`)}`,
|
|
669
|
+
options: specs.map((s) => ({ label: s, value: s })),
|
|
670
|
+
defaultIndex: 0,
|
|
671
|
+
});
|
|
672
|
+
await cmdUse({ rootDir, args: [picked], flags: new Set(['--force']) });
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
await cmdUse({ rootDir, args: ['default'], flags: new Set(['--force']) });
|
|
676
|
+
});
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
async function cmdNew({ rootDir, argv }) {
|
|
680
|
+
const positionals = argv.filter((a) => !a.startsWith('--'));
|
|
681
|
+
const legacyComponent = positionals[2] ? positionals[1] : '';
|
|
682
|
+
const component = DEFAULT_REPO_COMPONENT;
|
|
683
|
+
const slugInput = positionals[2] ? positionals[2] : positionals[1];
|
|
684
|
+
void legacyComponent;
|
|
685
|
+
|
|
686
|
+
if (!slugInput) {
|
|
687
|
+
throw new Error(
|
|
688
|
+
'[wt] usage: hstack wt new <slug> [--category=local|tmp] [--from=upstream|origin] [--remote=<name>] [--base=<ref>|--base-worktree=<spec>] [--deps=none|link|install|link-or-install] [--use]'
|
|
689
|
+
);
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
const { flags, kv } = parseArgs(argv.slice(1));
|
|
693
|
+
const repoRoot = getComponentRepoRoot(rootDir, component);
|
|
694
|
+
if (!(await pathExists(repoRoot))) {
|
|
695
|
+
throw new Error(`[wt] missing repo at ${repoRoot}`);
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
const parseCategoryFromSlug = (raw) => {
|
|
699
|
+
const s = String(raw ?? '').trim();
|
|
700
|
+
const parts = s.split('/').filter(Boolean);
|
|
701
|
+
const first = parts[0] ?? '';
|
|
702
|
+
if (first === 'tmp') return { category: 'tmp', slug: parts.slice(1).join('/') };
|
|
703
|
+
if (first === 'local') return { category: 'local', slug: parts.slice(1).join('/') };
|
|
704
|
+
return { category: '', slug: s };
|
|
705
|
+
};
|
|
706
|
+
|
|
707
|
+
const categoryFlag = (kv.get('--category') ?? '').toString().trim().toLowerCase();
|
|
708
|
+
const fromSlug = parseCategoryFromSlug(slugInput);
|
|
709
|
+
const category = categoryFlag || fromSlug.category || 'local';
|
|
710
|
+
const slug = fromSlug.slug || String(slugInput).trim();
|
|
711
|
+
if (!slug) {
|
|
712
|
+
throw new Error('[wt] invalid slug (empty after parsing category prefix).');
|
|
713
|
+
}
|
|
714
|
+
if (category !== 'local' && category !== 'tmp') {
|
|
715
|
+
throw new Error(`[wt] invalid --category: ${category}. Expected: local | tmp`);
|
|
716
|
+
}
|
|
717
|
+
if (slug.startsWith('pr/')) {
|
|
718
|
+
throw new Error(`[wt] "pr/" is reserved. Use: hstack wt pr <number|url>`);
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
const remoteOverride = (kv.get('--remote') ?? '').trim();
|
|
722
|
+
const from = (kv.get('--from') ?? '').trim().toLowerCase() || 'upstream';
|
|
723
|
+
const remoteName = remoteOverride || (from === 'origin' ? 'origin' : 'upstream');
|
|
724
|
+
const baseBranch = (kv.get('--base-branch') ?? process.env.HAPPIER_STACK_DEV_BRANCH ?? 'dev').toString().trim() || 'dev';
|
|
725
|
+
|
|
726
|
+
const baseOverride = (kv.get('--base') ?? '').trim();
|
|
727
|
+
const baseWorktreeSpec = (kv.get('--base-worktree') ?? kv.get('--from-worktree') ?? '').trim();
|
|
728
|
+
let baseFromWorktree = '';
|
|
729
|
+
let baseWorktreeDir = '';
|
|
730
|
+
if (!baseOverride && baseWorktreeSpec) {
|
|
731
|
+
baseWorktreeDir = resolveComponentWorktreeDir({ rootDir, component, spec: baseWorktreeSpec });
|
|
732
|
+
if (!(await pathExists(baseWorktreeDir))) {
|
|
733
|
+
throw new Error(`[wt] --base-worktree does not exist: ${baseWorktreeDir}`);
|
|
734
|
+
}
|
|
735
|
+
const branch = (await git(baseWorktreeDir, ['rev-parse', '--abbrev-ref', 'HEAD'])).trim();
|
|
736
|
+
if (branch && branch !== 'HEAD') {
|
|
737
|
+
baseFromWorktree = branch;
|
|
738
|
+
} else {
|
|
739
|
+
baseFromWorktree = (await git(baseWorktreeDir, ['rev-parse', 'HEAD'])).trim();
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
const base = baseOverride || baseFromWorktree || `${remoteName}/${baseBranch}`;
|
|
744
|
+
|
|
745
|
+
const workspaceDir = getWorkspaceDir(rootDir);
|
|
746
|
+
const localOwner =
|
|
747
|
+
(process.env.HAPPIER_STACK_OWNER ?? '').toString().trim() ||
|
|
748
|
+
(process.env.USER ?? process.env.LOGNAME ?? '').toString().trim() ||
|
|
749
|
+
'unknown';
|
|
750
|
+
const destWorktreeRoot = join(workspaceDir, category, localOwner, ...slug.split('/'));
|
|
751
|
+
await mkdir(dirname(destWorktreeRoot), { recursive: true });
|
|
752
|
+
|
|
753
|
+
// Ensure remotes are present.
|
|
754
|
+
await git(repoRoot, ['fetch', '--all', '--prune', '--quiet']);
|
|
755
|
+
if (!baseOverride && !baseFromWorktree) {
|
|
756
|
+
await git(repoRoot, ['fetch', '--quiet', remoteName, baseBranch]);
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
const branchName = `${localOwner}/${slug}`;
|
|
760
|
+
|
|
761
|
+
// If the branch already exists (common when migrating between workspaces),
|
|
762
|
+
// attach a new worktree to that branch instead of failing.
|
|
763
|
+
if (await gitOk(repoRoot, ['show-ref', '--verify', `refs/heads/${branchName}`])) {
|
|
764
|
+
await git(repoRoot, ['worktree', 'add', destWorktreeRoot, branchName]);
|
|
765
|
+
} else {
|
|
766
|
+
await git(repoRoot, ['worktree', 'add', '-b', branchName, destWorktreeRoot, base]);
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
const depsMode = parseDepsMode(kv.get('--deps'));
|
|
770
|
+
const depsDir = destWorktreeRoot;
|
|
771
|
+
const deps = await maybeSetupDeps({ repoRoot, baseDir: baseWorktreeDir || '', worktreeDir: depsDir, depsMode, component });
|
|
772
|
+
|
|
773
|
+
const shouldUse = flags.has('--use');
|
|
774
|
+
const force = flags.has('--force');
|
|
775
|
+
if (shouldUse) {
|
|
776
|
+
// Delegate to cmdUse so monorepo components stay coherent (and so stack-mode writes to the stack env file).
|
|
777
|
+
await cmdUse({ rootDir, args: [destWorktreeRoot], flags });
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
return { component, category, owner: localOwner, branch: branchName, path: depsDir, base, used: shouldUse, deps, worktreeRoot: destWorktreeRoot };
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
async function cmdDuplicate({ rootDir, argv }) {
|
|
784
|
+
const { flags, kv } = parseArgs(argv);
|
|
785
|
+
const json = wantsJson(argv, { flags });
|
|
786
|
+
|
|
787
|
+
const positionals = argv.filter((a) => !a.startsWith('--'));
|
|
788
|
+
const legacyComponent = positionals[3] ? positionals[1] : '';
|
|
789
|
+
const component = DEFAULT_REPO_COMPONENT;
|
|
790
|
+
const fromSpec = positionals[3] ? positionals[2] : positionals[1];
|
|
791
|
+
const slug = positionals[3] ? positionals[3] : positionals[2];
|
|
792
|
+
void legacyComponent;
|
|
793
|
+
|
|
794
|
+
if (!fromSpec || !slug) {
|
|
795
|
+
throw new Error(
|
|
796
|
+
'[wt] usage: hstack wt duplicate <fromWorktreeSpec|path|active|default> <newSlug> [--remote=<name>] [--deps=none|link|install|link-or-install] [--use] [--json]'
|
|
797
|
+
);
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
// Prefer inferring the remote from the source spec's owner when possible (owner/<branch...>).
|
|
801
|
+
const remoteOverride = (kv.get('--remote') ?? '').trim();
|
|
802
|
+
let remoteName = remoteOverride;
|
|
803
|
+
if (!remoteName && !isAbsolute(fromSpec)) {
|
|
804
|
+
const owner = String(fromSpec).trim().split('/')[0];
|
|
805
|
+
if (owner && owner !== 'active' && owner !== 'default' && owner !== 'main') {
|
|
806
|
+
const repoRoot = getComponentRepoRoot(rootDir, component);
|
|
807
|
+
remoteName = await normalizeRemoteName(repoRoot, await inferRemoteNameForOwner({ repoDir: repoRoot, owner }));
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
const depsMode = (kv.get('--deps') ?? '').trim();
|
|
812
|
+
const forwarded = ['new', component, slug, `--base-worktree=${fromSpec}`];
|
|
813
|
+
if (remoteName) forwarded.push(`--remote=${remoteName}`);
|
|
814
|
+
if (depsMode) forwarded.push(`--deps=${depsMode}`);
|
|
815
|
+
if (flags.has('--use')) forwarded.push('--use');
|
|
816
|
+
if (flags.has('--force')) forwarded.push('--force');
|
|
817
|
+
if (json) forwarded.push('--json');
|
|
818
|
+
|
|
819
|
+
// Delegate to cmdNew for the actual implementation (single source of truth).
|
|
820
|
+
return await cmdNew({ rootDir, argv: forwarded });
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
async function cmdPr({ rootDir, argv }) {
|
|
824
|
+
const { flags, kv } = parseArgs(argv);
|
|
825
|
+
const json = wantsJson(argv, { flags });
|
|
826
|
+
const positionals = argv.filter((a) => !a.startsWith('--'));
|
|
827
|
+
const legacyComponent = positionals[2] ? positionals[1] : '';
|
|
828
|
+
const component = DEFAULT_REPO_COMPONENT;
|
|
829
|
+
const prInput = positionals[2] ? positionals[2] : positionals[1];
|
|
830
|
+
void legacyComponent;
|
|
831
|
+
|
|
832
|
+
if (!prInput) {
|
|
833
|
+
throw new Error(
|
|
834
|
+
'[wt] usage: hstack wt pr <pr-url|number> [--remote=upstream] [--slug=<name>] [--deps=none|link|install|link-or-install] [--use] [--update] [--force] [--json]'
|
|
835
|
+
);
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
const repoRoot = getComponentRepoRoot(rootDir, component);
|
|
839
|
+
if (!(await pathExists(repoRoot))) {
|
|
840
|
+
throw new Error(`[wt] missing repo at ${repoRoot}`);
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
const pr = parseGithubPullRequest(prInput);
|
|
844
|
+
if (!pr?.number || !Number.isFinite(pr.number)) {
|
|
845
|
+
throw new Error(`[wt] unable to parse PR: ${prInput}`);
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
const remoteFromArg = (kv.get('--remote') ?? '').trim();
|
|
849
|
+
const canFetchByUrl = !remoteFromArg && pr.owner && pr.repo;
|
|
850
|
+
const fetchTarget = canFetchByUrl ? `https://github.com/${pr.owner}/${pr.repo}.git` : null;
|
|
851
|
+
|
|
852
|
+
// If we can fetch directly from the PR URL's repo, do it. This avoids any assumptions about local
|
|
853
|
+
// remote names like "origin" vs "upstream" and works even when the repo doesn't have that remote set up.
|
|
854
|
+
const remoteName = canFetchByUrl ? '' : await normalizeRemoteName(repoRoot, remoteFromArg || 'upstream');
|
|
855
|
+
const baseOwnerRepo = canFetchByUrl ? { owner: pr.owner, repo: pr.repo } : parseGithubOwnerRepo((await git(repoRoot, ['remote', 'get-url', remoteName])).trim());
|
|
856
|
+
if (!baseOwnerRepo?.owner) {
|
|
857
|
+
throw new Error(`[wt] unable to resolve base repo owner for PR fetch (remote=${remoteName || 'url'})`);
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
const canonicalRaw = (process.env.HAPPIER_STACK_CANONICAL_REPO ?? '').toString().trim();
|
|
861
|
+
const canonical = (() => {
|
|
862
|
+
const m = canonicalRaw.match(/^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)$/);
|
|
863
|
+
if (m) return { owner: m[1], repo: m[2] };
|
|
864
|
+
return { owner: 'leeroybrun', repo: 'happier-dev' };
|
|
865
|
+
})();
|
|
866
|
+
const isCanonical = baseOwnerRepo.owner === canonical.owner && (baseOwnerRepo.repo ? baseOwnerRepo.repo === canonical.repo : true);
|
|
867
|
+
|
|
868
|
+
const slugExtra = sanitizeSlugPart(kv.get('--slug') ?? '');
|
|
869
|
+
const name = slugExtra ? `${pr.number}-${slugExtra}` : String(pr.number);
|
|
870
|
+
const branchName = isCanonical ? `pr/${name}` : `pr/${baseOwnerRepo.owner}/${name}`;
|
|
871
|
+
|
|
872
|
+
const workspaceDir = getWorkspaceDir(rootDir);
|
|
873
|
+
const destWorktreeRoot = isCanonical ? join(workspaceDir, 'pr', name) : join(workspaceDir, 'pr', baseOwnerRepo.owner, name);
|
|
874
|
+
await mkdir(dirname(destWorktreeRoot), { recursive: true });
|
|
875
|
+
|
|
876
|
+
const exists = await pathExists(destWorktreeRoot);
|
|
877
|
+
const doUpdate = flags.has('--update');
|
|
878
|
+
if (exists && !doUpdate) {
|
|
879
|
+
throw new Error(`[wt] destination already exists: ${destWorktreeRoot}\n[wt] re-run with --update to refresh it`);
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
// Fetch PR head ref (GitHub convention). Use + to allow force-updated PR branches when --force is set.
|
|
883
|
+
// In sandbox mode, be more aggressive: the entire workspace is disposable, so it's safe to
|
|
884
|
+
// reset an existing local PR branch to the fetched PR head if needed.
|
|
885
|
+
const force = flags.has('--force') || isSandboxed();
|
|
886
|
+
let oldHead = null;
|
|
887
|
+
const prRef = `refs/pull/${pr.number}/head`;
|
|
888
|
+
if (exists) {
|
|
889
|
+
// Update existing worktree.
|
|
890
|
+
const stash = await maybeStash({
|
|
891
|
+
dir: destWorktreeRoot,
|
|
892
|
+
enabled: flags.has('--stash'),
|
|
893
|
+
keep: flags.has('--stash-keep'),
|
|
894
|
+
message: `[hstack] wt pr ${pr.number}`,
|
|
895
|
+
});
|
|
896
|
+
if (!(await isWorktreeClean(destWorktreeRoot)) && !stash.stashed) {
|
|
897
|
+
throw new Error(`[wt] worktree is not clean (${destWorktreeRoot}). Re-run with --stash to auto-stash changes.`);
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
oldHead = (await git(destWorktreeRoot, ['rev-parse', 'HEAD'])).trim();
|
|
901
|
+
await git(repoRoot, ['fetch', '--quiet', fetchTarget ?? remoteName, prRef]);
|
|
902
|
+
const newTip = (await git(repoRoot, ['rev-parse', 'FETCH_HEAD'])).trim();
|
|
903
|
+
|
|
904
|
+
const isAncestor = await gitOk(repoRoot, ['merge-base', '--is-ancestor', oldHead, newTip]);
|
|
905
|
+
if (!isAncestor && !force) {
|
|
906
|
+
const hint = fetchTarget
|
|
907
|
+
? `[wt] re-run with: hstack wt pr ${pr.number} --update --force`
|
|
908
|
+
: `[wt] re-run with: hstack wt pr ${pr.number} --remote=${remoteName} --update --force`;
|
|
909
|
+
throw new Error(
|
|
910
|
+
`[wt] PR update is not a fast-forward (likely force-push) for ${branchName}\n` +
|
|
911
|
+
hint
|
|
912
|
+
);
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
// Update working tree to the fetched tip.
|
|
916
|
+
if (isAncestor) {
|
|
917
|
+
await git(destWorktreeRoot, ['merge', '--ff-only', newTip]);
|
|
918
|
+
} else {
|
|
919
|
+
await git(destWorktreeRoot, ['reset', '--hard', newTip]);
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
// Only attempt to restore stash if update succeeded without forcing a conflict state.
|
|
923
|
+
const stashPop = await maybePopStash({ dir: destWorktreeRoot, stashed: stash.stashed, keep: stash.kept });
|
|
924
|
+
if (stashPop.popError) {
|
|
925
|
+
if (!force && oldHead) {
|
|
926
|
+
await hardReset({ dir: destWorktreeRoot, target: oldHead });
|
|
927
|
+
throw new Error(
|
|
928
|
+
`[wt] PR updated, but restoring stashed changes conflicted.\n` +
|
|
929
|
+
`[wt] Reverted update to keep your working tree clean.\n` +
|
|
930
|
+
`[wt] Worktree: ${destWorktreeRoot}\n` +
|
|
931
|
+
`[wt] Re-run with --update --stash --force to keep the conflict state for manual resolution.`
|
|
932
|
+
);
|
|
933
|
+
}
|
|
934
|
+
// Keep conflict state in place (or if we can't revert).
|
|
935
|
+
throw new Error(
|
|
936
|
+
`[wt] PR updated, but restoring stashed changes conflicted.\n` +
|
|
937
|
+
`[wt] Worktree: ${destWorktreeRoot}\n` +
|
|
938
|
+
`[wt] Conflicts are left in place for manual resolution (--force).`
|
|
939
|
+
);
|
|
940
|
+
}
|
|
941
|
+
} else {
|
|
942
|
+
await git(repoRoot, ['fetch', '--quiet', fetchTarget ?? remoteName, prRef]);
|
|
943
|
+
const newTip = (await git(repoRoot, ['rev-parse', 'FETCH_HEAD'])).trim();
|
|
944
|
+
|
|
945
|
+
const branchExists = await gitOk(repoRoot, ['show-ref', '--verify', '--quiet', `refs/heads/${branchName}`]);
|
|
946
|
+
if (branchExists) {
|
|
947
|
+
if (!force) {
|
|
948
|
+
// If the branch already points at the fetched PR tip, we can safely just attach a worktree.
|
|
949
|
+
const branchHead = (await git(repoRoot, ['rev-parse', branchName])).trim();
|
|
950
|
+
if (branchHead !== newTip) {
|
|
951
|
+
throw new Error(`[wt] branch already exists: ${branchName}\n[wt] re-run with --force to reset it to the PR head`);
|
|
952
|
+
}
|
|
953
|
+
await git(repoRoot, ['worktree', 'add', destWorktreeRoot, branchName]);
|
|
954
|
+
} else {
|
|
955
|
+
await git(repoRoot, ['branch', '-f', branchName, newTip]);
|
|
956
|
+
await git(repoRoot, ['worktree', 'add', destWorktreeRoot, branchName]);
|
|
957
|
+
}
|
|
958
|
+
} else {
|
|
959
|
+
// Create worktree at PR head (new local branch).
|
|
960
|
+
await git(repoRoot, ['worktree', 'add', '-b', branchName, destWorktreeRoot, newTip]);
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
// Optional deps handling (useful when PR branches add/change dependencies).
|
|
965
|
+
const depsMode = parseDepsMode(kv.get('--deps'));
|
|
966
|
+
const depsDir = destWorktreeRoot;
|
|
967
|
+
const deps = await maybeSetupDeps({ repoRoot, baseDir: repoRoot, worktreeDir: depsDir, depsMode, component });
|
|
968
|
+
|
|
969
|
+
const shouldUse = flags.has('--use');
|
|
970
|
+
if (shouldUse) {
|
|
971
|
+
// Reuse cmdUse so it writes to env.local or stack env file depending on context.
|
|
972
|
+
await cmdUse({ rootDir, args: [destWorktreeRoot], flags });
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
const newHead = (await git(destWorktreeRoot, ['rev-parse', 'HEAD'])).trim();
|
|
976
|
+
const res = {
|
|
977
|
+
component,
|
|
978
|
+
pr: pr.number,
|
|
979
|
+
remote: remoteName || (fetchTarget ? 'url' : ''),
|
|
980
|
+
category: 'pr',
|
|
981
|
+
baseRepo: baseOwnerRepo.owner ? `${baseOwnerRepo.owner}/${baseOwnerRepo.repo ?? ''}`.replace(/\/$/, '') : null,
|
|
982
|
+
canonicalRepo: `${canonical.owner}/${canonical.repo}`,
|
|
983
|
+
branch: branchName,
|
|
984
|
+
path: depsDir,
|
|
985
|
+
worktreeRoot: destWorktreeRoot,
|
|
986
|
+
used: shouldUse,
|
|
987
|
+
updated: exists,
|
|
988
|
+
oldHead,
|
|
989
|
+
newHead,
|
|
990
|
+
deps,
|
|
991
|
+
};
|
|
992
|
+
if (json) {
|
|
993
|
+
return res;
|
|
994
|
+
}
|
|
995
|
+
return res;
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
async function cmdStatus({ rootDir, argv }) {
|
|
999
|
+
const positionals = argv.filter((a) => !a.startsWith('--'));
|
|
1000
|
+
const legacyComponent = positionals[2] ? positionals[1] : '';
|
|
1001
|
+
const component = DEFAULT_REPO_COMPONENT;
|
|
1002
|
+
const spec = positionals[2] ? positionals[2] : (positionals[1] ?? '');
|
|
1003
|
+
void legacyComponent;
|
|
1004
|
+
|
|
1005
|
+
const dir = resolveComponentWorktreeDir({ rootDir, component, spec });
|
|
1006
|
+
if (!(await pathExists(dir))) {
|
|
1007
|
+
throw new Error(`[wt] target does not exist: ${dir}`);
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
const branch = (await git(dir, ['rev-parse', '--abbrev-ref', 'HEAD'])).trim();
|
|
1011
|
+
const head = (await git(dir, ['rev-parse', 'HEAD'])).trim();
|
|
1012
|
+
const dirty = (await git(dir, ['status', '--porcelain'])).trim();
|
|
1013
|
+
const isClean = !dirty;
|
|
1014
|
+
|
|
1015
|
+
let upstream = null;
|
|
1016
|
+
try {
|
|
1017
|
+
upstream = (await git(dir, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'])).trim();
|
|
1018
|
+
} catch {
|
|
1019
|
+
upstream = null;
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
let ahead = null;
|
|
1023
|
+
let behind = null;
|
|
1024
|
+
if (upstream) {
|
|
1025
|
+
try {
|
|
1026
|
+
const counts = (await git(dir, ['rev-list', '--left-right', '--count', `${upstream}...HEAD`])).trim();
|
|
1027
|
+
const [left, right] = counts.split(/\s+/g).map((n) => Number(n));
|
|
1028
|
+
behind = Number.isFinite(left) ? left : null;
|
|
1029
|
+
ahead = Number.isFinite(right) ? right : null;
|
|
1030
|
+
} catch {
|
|
1031
|
+
ahead = null;
|
|
1032
|
+
behind = null;
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
const conflicts = (await git(dir, ['diff', '--name-only', '--diff-filter=U']).catch(() => '')).trim().split('\n').filter(Boolean);
|
|
1037
|
+
|
|
1038
|
+
return { component, dir, branch, head, upstream, ahead, behind, isClean, conflicts };
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
async function cmdPush({ rootDir, argv }) {
|
|
1042
|
+
const { flags, kv } = parseArgs(argv);
|
|
1043
|
+
const positionals = argv.filter((a) => !a.startsWith('--'));
|
|
1044
|
+
const legacyComponent = positionals[2] ? positionals[1] : '';
|
|
1045
|
+
const component = DEFAULT_REPO_COMPONENT;
|
|
1046
|
+
const spec = positionals[2] ? positionals[2] : (positionals[1] ?? '');
|
|
1047
|
+
void legacyComponent;
|
|
1048
|
+
|
|
1049
|
+
const dir = resolveComponentWorktreeDir({ rootDir, component, spec });
|
|
1050
|
+
if (!(await pathExists(dir))) {
|
|
1051
|
+
throw new Error(`[wt] target does not exist: ${dir}`);
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
const branch = (await git(dir, ['rev-parse', '--abbrev-ref', 'HEAD'])).trim();
|
|
1055
|
+
if (!branch || branch === 'HEAD') {
|
|
1056
|
+
throw new Error('[wt] cannot push detached HEAD (checkout a branch first)');
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
let remote = (kv.get('--remote') ?? '').trim() || 'origin';
|
|
1060
|
+
remote = (await normalizeRemoteName(dir, remote)) || remote;
|
|
1061
|
+
const args = ['push', '-u', remote, 'HEAD'];
|
|
1062
|
+
if (flags.has('--dry-run')) {
|
|
1063
|
+
args.push('--dry-run');
|
|
1064
|
+
}
|
|
1065
|
+
await git(dir, args);
|
|
1066
|
+
return { component, dir, remote, branch, dryRun: flags.has('--dry-run') };
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
async function cmdUpdate({ rootDir, argv }) {
|
|
1070
|
+
const { flags, kv } = parseArgs(argv);
|
|
1071
|
+
const positionals = argv.filter((a) => !a.startsWith('--'));
|
|
1072
|
+
const legacyComponent = positionals[2] ? positionals[1] : '';
|
|
1073
|
+
const component = DEFAULT_REPO_COMPONENT;
|
|
1074
|
+
const spec = positionals[2] ? positionals[2] : (positionals[1] ?? '');
|
|
1075
|
+
void legacyComponent;
|
|
1076
|
+
|
|
1077
|
+
const repoRoot = getComponentRepoRoot(rootDir, component);
|
|
1078
|
+
if (!(await pathExists(repoRoot))) {
|
|
1079
|
+
throw new Error(`[wt] missing repo at ${repoRoot}`);
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
const dir = resolveComponentWorktreeDir({ rootDir, component, spec });
|
|
1083
|
+
if (!(await pathExists(dir))) {
|
|
1084
|
+
throw new Error(`[wt] target does not exist: ${dir}`);
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
const statusBefore = await cmdStatus({ rootDir, argv: ['status', component, dir] });
|
|
1088
|
+
if (!statusBefore.isClean && !flags.has('--stash') && !flags.has('--stash-keep')) {
|
|
1089
|
+
throw new Error(`[wt] working tree is not clean (${dir}). Re-run with --stash to auto-stash changes.`);
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
let remoteName = (kv.get('--remote') ?? '').trim() || 'upstream';
|
|
1093
|
+
const remote = await resolveRemoteOwner(repoRoot, remoteName);
|
|
1094
|
+
remoteName = remote.remoteName;
|
|
1095
|
+
const { owner } = remote;
|
|
1096
|
+
const defaultBranch = await resolveRemoteDefaultBranchName(repoRoot, remoteName);
|
|
1097
|
+
const mirrorBranch = `${owner}/${defaultBranch}`;
|
|
1098
|
+
|
|
1099
|
+
const baseOverride = (kv.get('--base') ?? '').trim();
|
|
1100
|
+
const base = baseOverride || mirrorBranch;
|
|
1101
|
+
|
|
1102
|
+
// Keep the mirror branch updated when using the default base.
|
|
1103
|
+
if (!baseOverride) {
|
|
1104
|
+
await cmdSync({ rootDir, argv: ['sync', component, `--remote=${remoteName}`] });
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
const mode = flags.has('--merge') ? 'merge' : 'rebase';
|
|
1108
|
+
const dryRun = flags.has('--dry-run');
|
|
1109
|
+
const force = flags.has('--force');
|
|
1110
|
+
const stashRequested = flags.has('--stash') || flags.has('--stash-keep');
|
|
1111
|
+
const stashKeep = flags.has('--stash-keep');
|
|
1112
|
+
|
|
1113
|
+
if (dryRun && stashRequested) {
|
|
1114
|
+
throw new Error('[wt] --dry-run cannot be combined with --stash/--stash-keep (it would modify your working tree)');
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
const conflictFiles = async () => {
|
|
1118
|
+
const out = (await git(dir, ['diff', '--name-only', '--diff-filter=U']).catch(() => '')).trim();
|
|
1119
|
+
return out ? out.split('\n').filter(Boolean) : [];
|
|
1120
|
+
};
|
|
1121
|
+
|
|
1122
|
+
const abortMerge = async () => {
|
|
1123
|
+
await git(dir, ['merge', '--abort']).catch(() => {});
|
|
1124
|
+
};
|
|
1125
|
+
const abortRebase = async () => {
|
|
1126
|
+
await git(dir, ['rebase', '--abort']).catch(() => {});
|
|
1127
|
+
};
|
|
1128
|
+
|
|
1129
|
+
// Dry-run: try a merge and abort to see if it would conflict.
|
|
1130
|
+
if (dryRun) {
|
|
1131
|
+
const status = await cmdStatus({ rootDir, argv: ['status', component, dir] });
|
|
1132
|
+
if (!status.isClean) {
|
|
1133
|
+
throw new Error(`[wt] working tree is not clean (${dir}). Commit/stash first.`);
|
|
1134
|
+
}
|
|
1135
|
+
let ok = true;
|
|
1136
|
+
let conflicts = [];
|
|
1137
|
+
try {
|
|
1138
|
+
await git(dir, ['merge', '--no-commit', '--no-ff', '--no-stat', base]);
|
|
1139
|
+
conflicts = await conflictFiles();
|
|
1140
|
+
ok = conflicts.length === 0;
|
|
1141
|
+
} catch {
|
|
1142
|
+
conflicts = await conflictFiles();
|
|
1143
|
+
ok = conflicts.length === 0 ? false : false;
|
|
1144
|
+
} finally {
|
|
1145
|
+
await abortMerge();
|
|
1146
|
+
}
|
|
1147
|
+
return { component, dir, mode, base, dryRun: true, ok, conflicts };
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
// Optionally stash before applying.
|
|
1151
|
+
const oldHead = (await git(dir, ['rev-parse', 'HEAD'])).trim();
|
|
1152
|
+
const stash = await maybeStash({
|
|
1153
|
+
dir,
|
|
1154
|
+
enabled: flags.has('--stash'),
|
|
1155
|
+
keep: stashKeep,
|
|
1156
|
+
message: `[hstack] wt update ${component}`,
|
|
1157
|
+
});
|
|
1158
|
+
if (!(await isWorktreeClean(dir)) && !stash.stashed) {
|
|
1159
|
+
throw new Error(`[wt] working tree is not clean (${dir}). Re-run with --stash to auto-stash changes.`);
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
// Apply update.
|
|
1163
|
+
if (mode === 'merge') {
|
|
1164
|
+
try {
|
|
1165
|
+
await git(dir, ['merge', '--no-edit', base]);
|
|
1166
|
+
const stashPop = await maybePopStash({ dir, stashed: stash.stashed, keep: stash.kept });
|
|
1167
|
+
if (stashPop.popError) {
|
|
1168
|
+
if (!force) {
|
|
1169
|
+
await hardReset({ dir, target: oldHead });
|
|
1170
|
+
return {
|
|
1171
|
+
component,
|
|
1172
|
+
dir,
|
|
1173
|
+
mode,
|
|
1174
|
+
base,
|
|
1175
|
+
ok: false,
|
|
1176
|
+
conflicts: [],
|
|
1177
|
+
error: 'stash-pop-conflict',
|
|
1178
|
+
message:
|
|
1179
|
+
`[wt] update succeeded, but restoring stashed changes conflicted.\n` +
|
|
1180
|
+
`[wt] Reverted update. Worktree: ${dir}\n` +
|
|
1181
|
+
`[wt] Re-run with --stash --force to keep the conflict state for manual resolution.`,
|
|
1182
|
+
stash,
|
|
1183
|
+
stashPop,
|
|
1184
|
+
};
|
|
1185
|
+
}
|
|
1186
|
+
return {
|
|
1187
|
+
component,
|
|
1188
|
+
dir,
|
|
1189
|
+
mode,
|
|
1190
|
+
base,
|
|
1191
|
+
ok: false,
|
|
1192
|
+
conflicts: await conflictFiles(),
|
|
1193
|
+
forceApplied: true,
|
|
1194
|
+
error: 'stash-pop-conflict',
|
|
1195
|
+
message: `[wt] update succeeded, but restoring stashed changes conflicted (kept for manual resolution). Worktree: ${dir}`,
|
|
1196
|
+
stash,
|
|
1197
|
+
stashPop,
|
|
1198
|
+
};
|
|
1199
|
+
}
|
|
1200
|
+
return { component, dir, mode, base, ok: true, conflicts: [], stash, stashPop };
|
|
1201
|
+
} catch {
|
|
1202
|
+
const conflicts = await conflictFiles();
|
|
1203
|
+
if (!force) {
|
|
1204
|
+
await abortMerge();
|
|
1205
|
+
}
|
|
1206
|
+
return { component, dir, mode, base, ok: false, conflicts, forceApplied: force, stash, stashPop: { popped: false } };
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
// Default: rebase (preferred for clean PR branches).
|
|
1211
|
+
try {
|
|
1212
|
+
await git(dir, ['rebase', base]);
|
|
1213
|
+
const stashPop = await maybePopStash({ dir, stashed: stash.stashed, keep: stash.kept });
|
|
1214
|
+
if (stashPop.popError) {
|
|
1215
|
+
if (!force) {
|
|
1216
|
+
await hardReset({ dir, target: oldHead });
|
|
1217
|
+
return {
|
|
1218
|
+
component,
|
|
1219
|
+
dir,
|
|
1220
|
+
mode,
|
|
1221
|
+
base,
|
|
1222
|
+
ok: false,
|
|
1223
|
+
conflicts: [],
|
|
1224
|
+
error: 'stash-pop-conflict',
|
|
1225
|
+
message:
|
|
1226
|
+
`[wt] update succeeded, but restoring stashed changes conflicted.\n` +
|
|
1227
|
+
`[wt] Reverted update. Worktree: ${dir}\n` +
|
|
1228
|
+
`[wt] Re-run with --stash --force to keep the conflict state for manual resolution.`,
|
|
1229
|
+
stash,
|
|
1230
|
+
stashPop,
|
|
1231
|
+
};
|
|
1232
|
+
}
|
|
1233
|
+
return {
|
|
1234
|
+
component,
|
|
1235
|
+
dir,
|
|
1236
|
+
mode,
|
|
1237
|
+
base,
|
|
1238
|
+
ok: false,
|
|
1239
|
+
conflicts: await conflictFiles(),
|
|
1240
|
+
forceApplied: true,
|
|
1241
|
+
error: 'stash-pop-conflict',
|
|
1242
|
+
message: `[wt] update succeeded, but restoring stashed changes conflicted (kept for manual resolution). Worktree: ${dir}`,
|
|
1243
|
+
stash,
|
|
1244
|
+
stashPop,
|
|
1245
|
+
};
|
|
1246
|
+
}
|
|
1247
|
+
return { component, dir, mode, base, ok: true, conflicts: [], stash, stashPop };
|
|
1248
|
+
} catch {
|
|
1249
|
+
const conflicts = await conflictFiles();
|
|
1250
|
+
if (!force) {
|
|
1251
|
+
await abortRebase();
|
|
1252
|
+
}
|
|
1253
|
+
return { component, dir, mode, base, ok: false, conflicts, forceApplied: force, stash, stashPop: { popped: false } };
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
function splitDoubleDash(argv) {
|
|
1258
|
+
const idx = argv.indexOf('--');
|
|
1259
|
+
if (idx < 0) {
|
|
1260
|
+
return { before: argv, after: [] };
|
|
1261
|
+
}
|
|
1262
|
+
return { before: argv.slice(0, idx), after: argv.slice(idx + 1) };
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
async function cmdGit({ rootDir, argv }) {
|
|
1266
|
+
const { before, after } = splitDoubleDash(argv);
|
|
1267
|
+
const { flags, kv } = parseArgs(before);
|
|
1268
|
+
const json = wantsJson(before, { flags });
|
|
1269
|
+
|
|
1270
|
+
const positionals = before.filter((a) => !a.startsWith('--'));
|
|
1271
|
+
const legacyComponent = positionals[2] ? positionals[1] : '';
|
|
1272
|
+
const component = DEFAULT_REPO_COMPONENT;
|
|
1273
|
+
const spec = positionals[2] ? positionals[2] : (positionals[1] ?? '');
|
|
1274
|
+
void legacyComponent;
|
|
1275
|
+
if (!after.length) {
|
|
1276
|
+
throw new Error('[wt] git requires args after `--` (example: hstack wt git main -- status)');
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
const dir = resolveComponentWorktreeDir({ rootDir, component, spec });
|
|
1280
|
+
if (!(await pathExists(dir))) {
|
|
1281
|
+
throw new Error(`[wt] target does not exist: ${dir}`);
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
const remote = (kv.get('--remote') ?? '').trim();
|
|
1285
|
+
// Convenience: allow `--remote=<name>` to imply `git fetch <name> ...` etc by user choice.
|
|
1286
|
+
const args = [...after];
|
|
1287
|
+
if (remote && (args[0] === 'fetch' || args[0] === 'pull' || args[0] === 'push') && !args.includes(remote)) {
|
|
1288
|
+
// leave untouched; user should pass remote explicitly for correctness
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
if (json) {
|
|
1292
|
+
const stdout = await git(dir, args);
|
|
1293
|
+
return { component, dir, args, stdout };
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
await run('git', args, { cwd: dir });
|
|
1297
|
+
return { component, dir, args };
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
async function cmdSync({ rootDir, argv }) {
|
|
1301
|
+
void argv;
|
|
1302
|
+
const { kv } = parseArgs(argv);
|
|
1303
|
+
const component = DEFAULT_REPO_COMPONENT;
|
|
1304
|
+
const repoRoot = getComponentRepoRoot(rootDir, component);
|
|
1305
|
+
if (!(await pathExists(repoRoot))) {
|
|
1306
|
+
throw new Error(`[wt] missing repo at ${repoRoot}`);
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
let remoteName = (kv.get('--remote') ?? '').trim() || 'upstream';
|
|
1310
|
+
const remote = await resolveRemoteOwner(repoRoot, remoteName);
|
|
1311
|
+
remoteName = remote.remoteName;
|
|
1312
|
+
const { owner } = remote;
|
|
1313
|
+
const defaultBranch = await resolveRemoteDefaultBranchName(repoRoot, remoteName);
|
|
1314
|
+
|
|
1315
|
+
await git(repoRoot, ['fetch', '--quiet', remoteName, defaultBranch]);
|
|
1316
|
+
|
|
1317
|
+
const mirrorBranch = `${owner}/${defaultBranch}`;
|
|
1318
|
+
await git(repoRoot, ['branch', '-f', mirrorBranch, `${remoteName}/${defaultBranch}`]);
|
|
1319
|
+
// Best-effort: set upstream (works even if already set).
|
|
1320
|
+
await git(repoRoot, ['branch', '--set-upstream-to', `${remoteName}/${defaultBranch}`, mirrorBranch]).catch(() => {});
|
|
1321
|
+
|
|
1322
|
+
return { component, remote: remoteName, mirrorBranch, upstreamRef: `${remoteName}/${defaultBranch}` };
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
async function fileExists(path) {
|
|
1326
|
+
try {
|
|
1327
|
+
return await pathExists(path);
|
|
1328
|
+
} catch {
|
|
1329
|
+
return false;
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
async function pickBestShell({ kv, prefer = null } = {}) {
|
|
1334
|
+
const fromFlag = (kv?.get('--shell') ?? '').trim();
|
|
1335
|
+
const fromEnv = (process.env.HAPPIER_STACK_WT_SHELL ?? '').trim();
|
|
1336
|
+
const fromShellEnv = (process.env.SHELL ?? '').trim();
|
|
1337
|
+
const want = (fromFlag || fromEnv || prefer || fromShellEnv).trim();
|
|
1338
|
+
if (want) {
|
|
1339
|
+
return want;
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
const candidates =
|
|
1343
|
+
process.platform === 'win32'
|
|
1344
|
+
? []
|
|
1345
|
+
: ['/bin/zsh', '/usr/bin/zsh', '/bin/bash', '/usr/bin/bash', '/bin/sh', '/usr/bin/sh'];
|
|
1346
|
+
for (const c of candidates) {
|
|
1347
|
+
// eslint-disable-next-line no-await-in-loop
|
|
1348
|
+
if (await fileExists(c)) {
|
|
1349
|
+
return c;
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
return process.env.SHELL || '/bin/sh';
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
function escapeForShellDoubleQuotes(s) {
|
|
1356
|
+
return (s ?? '').replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
async function openTerminalAuto({ dir, shell }) {
|
|
1360
|
+
const termPref = (process.env.HAPPIER_STACK_WT_TERMINAL ?? '').trim().toLowerCase();
|
|
1361
|
+
const order = termPref ? [termPref] : ['ghostty', 'iterm', 'terminal', 'current'];
|
|
1362
|
+
|
|
1363
|
+
for (const t of order) {
|
|
1364
|
+
if (t === 'current') {
|
|
1365
|
+
return { kind: 'current' };
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
if (t === 'ghostty') {
|
|
1369
|
+
if (await commandExists('ghostty')) {
|
|
1370
|
+
try {
|
|
1371
|
+
// Best-effort. Ghostty supports --working-directory on recent builds.
|
|
1372
|
+
await run('ghostty', ['--working-directory', dir], { cwd: dir, env: process.env, stdio: 'inherit' });
|
|
1373
|
+
return { kind: 'ghostty' };
|
|
1374
|
+
} catch {
|
|
1375
|
+
// fall through
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
if (t === 'iterm') {
|
|
1381
|
+
if (process.platform === 'darwin') {
|
|
1382
|
+
try {
|
|
1383
|
+
const cmd = `cd "${escapeForShellDoubleQuotes(dir)}"; exec "${escapeForShellDoubleQuotes(shell)}" -i`;
|
|
1384
|
+
// Create a new iTerm window and cd into the directory.
|
|
1385
|
+
await run('osascript', [
|
|
1386
|
+
'-e',
|
|
1387
|
+
'tell application "iTerm" to activate',
|
|
1388
|
+
'-e',
|
|
1389
|
+
'tell application "iTerm" to create window with default profile',
|
|
1390
|
+
'-e',
|
|
1391
|
+
`tell application "iTerm" to tell current session of current window to write text "${cmd}"`,
|
|
1392
|
+
]);
|
|
1393
|
+
return { kind: 'iterm' };
|
|
1394
|
+
} catch {
|
|
1395
|
+
// fall through
|
|
1396
|
+
}
|
|
1397
|
+
}
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
if (t === 'terminal') {
|
|
1401
|
+
if (process.platform === 'darwin') {
|
|
1402
|
+
try {
|
|
1403
|
+
// Terminal.app: `open -a Terminal <dir>` opens a window in that dir.
|
|
1404
|
+
await run('open', ['-a', 'Terminal', dir], { cwd: dir, env: process.env, stdio: 'inherit' });
|
|
1405
|
+
return { kind: 'terminal' };
|
|
1406
|
+
} catch {
|
|
1407
|
+
// fall through
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
return { kind: 'current' };
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
function resolveMonorepoEditorDir({ dir, preferPackageDir = false }) {
|
|
1417
|
+
if (preferPackageDir) return dir;
|
|
1418
|
+
return coerceHappyMonorepoRootFromPath(dir) || dir;
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
async function cmdShell({ rootDir, argv }) {
|
|
1422
|
+
const { flags, kv } = parseArgs(argv);
|
|
1423
|
+
const json = wantsJson(argv, { flags });
|
|
1424
|
+
const positionals = argv.filter((a) => !a.startsWith('--'));
|
|
1425
|
+
const legacyComponent = positionals[2] ? positionals[1] : '';
|
|
1426
|
+
const component = DEFAULT_REPO_COMPONENT;
|
|
1427
|
+
const spec = positionals[2] ? positionals[2] : (positionals[1] ?? '');
|
|
1428
|
+
void legacyComponent;
|
|
1429
|
+
const packageDir = resolveComponentWorktreeDir({ rootDir, component, spec });
|
|
1430
|
+
const dir = resolveMonorepoEditorDir({ dir: packageDir, preferPackageDir: flags.has('--package') });
|
|
1431
|
+
if (!(await pathExists(dir))) {
|
|
1432
|
+
throw new Error(`[wt] target does not exist: ${dir}`);
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
const shell = await pickBestShell({ kv });
|
|
1436
|
+
const args = ['-i'];
|
|
1437
|
+
const terminalFlag = (kv.get('--terminal') ?? '').trim().toLowerCase();
|
|
1438
|
+
const newWindow = flags.has('--new-window');
|
|
1439
|
+
const wantTerminal = terminalFlag || (newWindow ? 'auto' : 'current');
|
|
1440
|
+
|
|
1441
|
+
if (json) {
|
|
1442
|
+
return { component, dir, shell, args, terminal: wantTerminal };
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
// This launches a new interactive shell with cwd=dir. It can't change the parent shell, but this is a "real" cd.
|
|
1446
|
+
if (wantTerminal === 'current') {
|
|
1447
|
+
await run(shell, args, { cwd: dir, env: process.env, stdio: 'inherit' });
|
|
1448
|
+
return { component, dir, shell, args, terminal: 'current' };
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
if (wantTerminal === 'auto') {
|
|
1452
|
+
const chosen = await openTerminalAuto({ dir, shell });
|
|
1453
|
+
if (chosen.kind === 'current') {
|
|
1454
|
+
await run(shell, args, { cwd: dir, env: process.env, stdio: 'inherit' });
|
|
1455
|
+
}
|
|
1456
|
+
return { component, dir, shell, args, terminal: chosen.kind };
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
// Explicit terminal selection (best-effort).
|
|
1460
|
+
process.env.HAPPIER_STACK_WT_TERMINAL = wantTerminal;
|
|
1461
|
+
const chosen = await openTerminalAuto({ dir, shell });
|
|
1462
|
+
if (chosen.kind === 'current') {
|
|
1463
|
+
await run(shell, args, { cwd: dir, env: process.env, stdio: 'inherit' });
|
|
1464
|
+
}
|
|
1465
|
+
return { component, dir, shell, args, terminal: chosen.kind };
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
async function cmdCode({ rootDir, argv }) {
|
|
1469
|
+
const { flags } = parseArgs(argv);
|
|
1470
|
+
const json = wantsJson(argv, { flags });
|
|
1471
|
+
const positionals = argv.filter((a) => !a.startsWith('--'));
|
|
1472
|
+
const legacyComponent = positionals[2] ? positionals[1] : '';
|
|
1473
|
+
const component = DEFAULT_REPO_COMPONENT;
|
|
1474
|
+
const spec = positionals[2] ? positionals[2] : (positionals[1] ?? '');
|
|
1475
|
+
void legacyComponent;
|
|
1476
|
+
const packageDir = resolveComponentWorktreeDir({ rootDir, component, spec });
|
|
1477
|
+
const dir = resolveMonorepoEditorDir({ dir: packageDir, preferPackageDir: flags.has('--package') });
|
|
1478
|
+
if (!(await pathExists(dir))) {
|
|
1479
|
+
throw new Error(`[wt] target does not exist: ${dir}`);
|
|
1480
|
+
}
|
|
1481
|
+
const codePath = await resolveCommandPath('code', { cwd: rootDir, env: process.env });
|
|
1482
|
+
if (!codePath) {
|
|
1483
|
+
throw new Error("[wt] VS Code CLI 'code' not found on PATH. In VS Code: Cmd+Shift+P → 'Shell Command: Install code command in PATH'.");
|
|
1484
|
+
}
|
|
1485
|
+
if (json) {
|
|
1486
|
+
return { component, dir, cmd: 'code', resolvedCmd: codePath };
|
|
1487
|
+
}
|
|
1488
|
+
await run(codePath, [dir], { cwd: rootDir, env: process.env, stdio: 'inherit' });
|
|
1489
|
+
return { component, dir, cmd: 'code', resolvedCmd: codePath };
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
async function cmdCursor({ rootDir, argv }) {
|
|
1493
|
+
const { flags } = parseArgs(argv);
|
|
1494
|
+
const json = wantsJson(argv, { flags });
|
|
1495
|
+
const positionals = argv.filter((a) => !a.startsWith('--'));
|
|
1496
|
+
const legacyComponent = positionals[2] ? positionals[1] : '';
|
|
1497
|
+
const component = DEFAULT_REPO_COMPONENT;
|
|
1498
|
+
const spec = positionals[2] ? positionals[2] : (positionals[1] ?? '');
|
|
1499
|
+
void legacyComponent;
|
|
1500
|
+
const packageDir = resolveComponentWorktreeDir({ rootDir, component, spec });
|
|
1501
|
+
const dir = resolveMonorepoEditorDir({ dir: packageDir, preferPackageDir: flags.has('--package') });
|
|
1502
|
+
if (!(await pathExists(dir))) {
|
|
1503
|
+
throw new Error(`[wt] target does not exist: ${dir}`);
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
const cursorPath = await resolveCommandPath('cursor', { cwd: rootDir, env: process.env });
|
|
1507
|
+
const hasCursorCli = Boolean(cursorPath);
|
|
1508
|
+
if (json) {
|
|
1509
|
+
return {
|
|
1510
|
+
component,
|
|
1511
|
+
dir,
|
|
1512
|
+
cmd: hasCursorCli ? 'cursor' : process.platform === 'darwin' ? 'open -a Cursor' : null,
|
|
1513
|
+
resolvedCmd: cursorPath || null,
|
|
1514
|
+
};
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
if (hasCursorCli) {
|
|
1518
|
+
await run(cursorPath, [dir], { cwd: rootDir, env: process.env, stdio: 'inherit' });
|
|
1519
|
+
return { component, dir, cmd: 'cursor', resolvedCmd: cursorPath };
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
if (process.platform === 'darwin') {
|
|
1523
|
+
await run('open', ['-a', 'Cursor', dir], { cwd: rootDir, env: process.env, stdio: 'inherit' });
|
|
1524
|
+
return { component, dir, cmd: 'open -a Cursor' };
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
throw new Error("[wt] Cursor CLI 'cursor' not found on PATH (and non-macOS fallback is unavailable).");
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
async function cmdSyncAll({ rootDir, argv }) {
|
|
1531
|
+
const { flags, kv } = parseArgs(argv);
|
|
1532
|
+
const json = wantsJson(argv, { flags });
|
|
1533
|
+
|
|
1534
|
+
const remote = (kv.get('--remote') ?? '').trim();
|
|
1535
|
+
|
|
1536
|
+
const component = DEFAULT_REPO_COMPONENT;
|
|
1537
|
+
const results = [];
|
|
1538
|
+
try {
|
|
1539
|
+
const res = await cmdSync({
|
|
1540
|
+
rootDir,
|
|
1541
|
+
argv: remote ? ['sync', component, `--remote=${remote}`] : ['sync', component],
|
|
1542
|
+
});
|
|
1543
|
+
results.push({ component, ok: true, skipped: false, ...res });
|
|
1544
|
+
} catch (e) {
|
|
1545
|
+
results.push({ component, ok: false, skipped: false, error: String(e?.message ?? e) });
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
const ok = results.every((r) => r.ok);
|
|
1549
|
+
if (json) return { ok, results };
|
|
1550
|
+
|
|
1551
|
+
const lines = ['[wt] sync-all:'];
|
|
1552
|
+
for (const r of results) {
|
|
1553
|
+
if (r.ok) {
|
|
1554
|
+
lines.push(`- ✅ ${r.component}: ${r.mirrorBranch} -> ${r.upstreamRef}`);
|
|
1555
|
+
} else {
|
|
1556
|
+
lines.push(`- ❌ ${r.component}: ${r.error}`);
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1559
|
+
return { ok, results, text: lines.join('\n') };
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
async function listRepoWorktreePaths({ rootDir }) {
|
|
1563
|
+
const repoRoot = getDefaultRepoDir(rootDir);
|
|
1564
|
+
if (!(await pathExists(repoRoot))) {
|
|
1565
|
+
return [];
|
|
1566
|
+
}
|
|
1567
|
+
const out = await git(repoRoot, ['worktree', 'list', '--porcelain']);
|
|
1568
|
+
const wts = parseWorktreeListPorcelain(out);
|
|
1569
|
+
return wts.map((w) => w.path).filter(Boolean);
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
async function cmdUpdateAll({ rootDir, argv }) {
|
|
1573
|
+
const { flags, kv } = parseArgs(argv);
|
|
1574
|
+
void argv;
|
|
1575
|
+
const json = wantsJson(argv, { flags });
|
|
1576
|
+
|
|
1577
|
+
const remote = (kv.get('--remote') ?? '').trim();
|
|
1578
|
+
const base = (kv.get('--base') ?? '').trim();
|
|
1579
|
+
const mode = flags.has('--merge') ? 'merge' : 'rebase';
|
|
1580
|
+
const dryRun = flags.has('--dry-run');
|
|
1581
|
+
const force = flags.has('--force');
|
|
1582
|
+
const stash = flags.has('--stash');
|
|
1583
|
+
const stashKeep = flags.has('--stash-keep');
|
|
1584
|
+
|
|
1585
|
+
const component = DEFAULT_REPO_COMPONENT;
|
|
1586
|
+
const results = [];
|
|
1587
|
+
const repoRoot = getDefaultRepoDir(rootDir);
|
|
1588
|
+
const paths = await listRepoWorktreePaths({ rootDir });
|
|
1589
|
+
for (const dir of paths) {
|
|
1590
|
+
if (resolve(dir) === resolve(repoRoot)) {
|
|
1591
|
+
// Keep the default checkout stable; update-all is intended for worktrees.
|
|
1592
|
+
continue;
|
|
1593
|
+
}
|
|
1594
|
+
try {
|
|
1595
|
+
const args = ['update', dir];
|
|
1596
|
+
if (remote) args.push(`--remote=${remote}`);
|
|
1597
|
+
if (base) args.push(`--base=${base}`);
|
|
1598
|
+
if (mode === 'merge') args.push('--merge');
|
|
1599
|
+
if (dryRun) args.push('--dry-run');
|
|
1600
|
+
if (stash) args.push('--stash');
|
|
1601
|
+
if (stashKeep) args.push('--stash-keep');
|
|
1602
|
+
if (force) args.push('--force');
|
|
1603
|
+
const res = await cmdUpdate({ rootDir, argv: args });
|
|
1604
|
+
results.push({ component, dir, ...res });
|
|
1605
|
+
} catch (e) {
|
|
1606
|
+
results.push({ component, dir, ok: false, error: String(e?.message ?? e) });
|
|
1607
|
+
}
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
const ok = results.every((r) => r.ok);
|
|
1611
|
+
if (json) {
|
|
1612
|
+
return { ok, mode, dryRun, force, base: base || '(mirror)', remote: remote || '(default)', results };
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1615
|
+
const lines = [
|
|
1616
|
+
`[wt] update-all (${mode}${dryRun ? ', dry-run' : ''}${force ? ', force' : ''})`,
|
|
1617
|
+
base ? `- base: ${base}` : '- base: <mirror owner/<default-branch>>',
|
|
1618
|
+
remote ? `- remote: ${remote}` : '- remote: upstream',
|
|
1619
|
+
];
|
|
1620
|
+
for (const r of results) {
|
|
1621
|
+
if (r.ok) {
|
|
1622
|
+
lines.push(`- ✅ ${r.component}: ${r.dir}`);
|
|
1623
|
+
} else if (r.conflicts?.length) {
|
|
1624
|
+
lines.push(`- ⚠️ ${r.component}: conflicts (${r.dir})`);
|
|
1625
|
+
for (const f of r.conflicts) lines.push(` - ${f}`);
|
|
1626
|
+
} else {
|
|
1627
|
+
lines.push(`- ❌ ${r.component}: ${r.error} (${r.dir})`);
|
|
1628
|
+
}
|
|
1629
|
+
}
|
|
1630
|
+
return { ok, results, text: lines.join('\n') };
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
async function cmdNewInteractive({ rootDir, argv }) {
|
|
1634
|
+
const { flags, kv } = parseArgs(argv);
|
|
1635
|
+
await withRl(async (rl) => {
|
|
1636
|
+
// eslint-disable-next-line no-console
|
|
1637
|
+
console.log('');
|
|
1638
|
+
// eslint-disable-next-line no-console
|
|
1639
|
+
console.log(bold('Create a worktree'));
|
|
1640
|
+
// eslint-disable-next-line no-console
|
|
1641
|
+
console.log(dim('Recommended: base worktrees on upstream to keep PR history clean.'));
|
|
1642
|
+
|
|
1643
|
+
const slug = await prompt(rl, `${dim('Worktree slug')} (example: my-feature, or tmp/e2e-test): `, { defaultValue: '' });
|
|
1644
|
+
if (!slug) {
|
|
1645
|
+
throw new Error('[wt] slug is required');
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1648
|
+
// Default remote is upstream; allow override.
|
|
1649
|
+
const remote = await prompt(rl, `${dim('Remote name')} (default: upstream): `, { defaultValue: 'upstream' });
|
|
1650
|
+
|
|
1651
|
+
const args = ['new', slug, `--remote=${remote}`];
|
|
1652
|
+
if (kv.get('--base')?.trim()) {
|
|
1653
|
+
args.push(`--base=${kv.get('--base').trim()}`);
|
|
1654
|
+
}
|
|
1655
|
+
if (flags.has('--use')) {
|
|
1656
|
+
args.push('--use');
|
|
1657
|
+
}
|
|
1658
|
+
await cmdNew({ rootDir, argv: args });
|
|
1659
|
+
});
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1662
|
+
async function cmdList({ rootDir, args, flags }) {
|
|
1663
|
+
const wantsAll = flags?.has('--all') || flags?.has('--all-worktrees');
|
|
1664
|
+
const activeOnly = !wantsAll && (flags?.has('--active') || flags?.has('--active-only'));
|
|
1665
|
+
void args;
|
|
1666
|
+
|
|
1667
|
+
const dirs = WORKTREE_CATEGORIES.map((c) => getWorktreeCategoryRoot(rootDir, c, process.env));
|
|
1668
|
+
const activeDir = getActiveRepoDir(rootDir);
|
|
1669
|
+
const mainDir = getDefaultRepoDir(rootDir);
|
|
1670
|
+
const devDir = getDevRepoDir(rootDir, process.env);
|
|
1671
|
+
|
|
1672
|
+
if (activeOnly) {
|
|
1673
|
+
return { activeDir, worktrees: [] };
|
|
1674
|
+
}
|
|
1675
|
+
|
|
1676
|
+
const worktrees = [];
|
|
1677
|
+
if (await pathExists(join(mainDir, '.git'))) {
|
|
1678
|
+
worktrees.push(mainDir);
|
|
1679
|
+
}
|
|
1680
|
+
if (await pathExists(join(devDir, '.git'))) {
|
|
1681
|
+
worktrees.push(devDir);
|
|
1682
|
+
}
|
|
1683
|
+
const walk = async (d) => {
|
|
1684
|
+
// In git worktrees, ".git" is usually a file that points to the shared git dir.
|
|
1685
|
+
// If this is a worktree root, record it and do not descend into it (avoids traversing huge trees like node_modules).
|
|
1686
|
+
if (await pathExists(join(d, '.git'))) {
|
|
1687
|
+
worktrees.push(d);
|
|
1688
|
+
return;
|
|
1689
|
+
}
|
|
1690
|
+
const entries = await readdir(d, { withFileTypes: true });
|
|
1691
|
+
for (const e of entries) {
|
|
1692
|
+
if (!e.isDirectory()) continue;
|
|
1693
|
+
if (e.name === 'node_modules') continue;
|
|
1694
|
+
if (e.name.startsWith('.')) continue;
|
|
1695
|
+
await walk(join(d, e.name));
|
|
1696
|
+
}
|
|
1697
|
+
};
|
|
1698
|
+
|
|
1699
|
+
for (const dir of dirs) {
|
|
1700
|
+
if (!(await pathExists(dir))) continue;
|
|
1701
|
+
// eslint-disable-next-line no-await-in-loop
|
|
1702
|
+
await walk(dir);
|
|
1703
|
+
}
|
|
1704
|
+
worktrees.sort();
|
|
1705
|
+
|
|
1706
|
+
return { activeDir, worktrees };
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1709
|
+
async function cmdArchive({ rootDir, argv }) {
|
|
1710
|
+
const { flags, kv } = parseArgs(argv);
|
|
1711
|
+
const dryRun = flags.has('--dry-run');
|
|
1712
|
+
const deleteBranch = !flags.has('--no-delete-branch');
|
|
1713
|
+
const detachStacks = flags.has('--detach-stacks');
|
|
1714
|
+
|
|
1715
|
+
const positionals = argv.filter((a) => !a.startsWith('--'));
|
|
1716
|
+
const legacyComponent = positionals[2] ? String(positionals[1] ?? '').trim() : '';
|
|
1717
|
+
const component = DEFAULT_REPO_COMPONENT;
|
|
1718
|
+
const spec = String(positionals[2] ? positionals[2] : positionals[1] ?? '').trim();
|
|
1719
|
+
void legacyComponent;
|
|
1720
|
+
if (!spec) {
|
|
1721
|
+
throw new Error(
|
|
1722
|
+
'[wt] usage: hstack wt archive <worktreeSpec|path|active|default|main> [--dry-run] [--date=YYYY-MM-DD] [--no-delete-branch] [--detach-stacks] [--json]'
|
|
1723
|
+
);
|
|
1724
|
+
}
|
|
1725
|
+
|
|
1726
|
+
const resolved = resolveComponentWorktreeDir({ rootDir, component, spec });
|
|
1727
|
+
if (!resolved) {
|
|
1728
|
+
throw new Error(`[wt] unable to resolve worktree: ${spec}`);
|
|
1729
|
+
}
|
|
1730
|
+
|
|
1731
|
+
let worktreeDir = resolved;
|
|
1732
|
+
try {
|
|
1733
|
+
worktreeDir = await gitShowTopLevel(resolved);
|
|
1734
|
+
} catch {
|
|
1735
|
+
// Broken worktrees can have a missing linked gitdir; fall back to the resolved directory.
|
|
1736
|
+
worktreeDir = resolved;
|
|
1737
|
+
}
|
|
1738
|
+
const workspaceDir = resolve(getWorkspaceDir(rootDir));
|
|
1739
|
+
const workspaceDirReal = await realpath(workspaceDir).catch(() => workspaceDir);
|
|
1740
|
+
const worktreeDirReal = await realpath(worktreeDir).catch(() => worktreeDir);
|
|
1741
|
+
const rel = relative(workspaceDirReal, worktreeDirReal);
|
|
1742
|
+
if (!rel || rel.startsWith('..') || isAbsolute(rel)) {
|
|
1743
|
+
throw new Error(`[wt] refusing to archive non-worktree path (expected under ${workspaceDir}): ${worktreeDir}`);
|
|
1744
|
+
}
|
|
1745
|
+
const cat = rel.split('/').filter(Boolean)[0] ?? '';
|
|
1746
|
+
if (!WORKTREE_CATEGORIES.includes(cat)) {
|
|
1747
|
+
throw new Error(`[wt] refusing to archive non-worktree path (expected under ${workspaceDir}/{pr,local,tmp}): ${worktreeDir}`);
|
|
1748
|
+
}
|
|
1749
|
+
|
|
1750
|
+
const date = (kv.get('--date') ?? '').toString().trim() || getTodayYmd();
|
|
1751
|
+
const archiveRoot = join(getWorktreeArchiveRoot(rootDir, process.env), date);
|
|
1752
|
+
const destDir = join(archiveRoot, rel);
|
|
1753
|
+
|
|
1754
|
+
const expectedBranch = rel || null;
|
|
1755
|
+
let head = '';
|
|
1756
|
+
let branch = null;
|
|
1757
|
+
try {
|
|
1758
|
+
head = (await git(worktreeDir, ['rev-parse', 'HEAD'])).trim();
|
|
1759
|
+
try {
|
|
1760
|
+
const b = (await git(worktreeDir, ['symbolic-ref', '--quiet', '--short', 'HEAD'])).trim();
|
|
1761
|
+
branch = b || null;
|
|
1762
|
+
} catch {
|
|
1763
|
+
branch = null;
|
|
1764
|
+
}
|
|
1765
|
+
} catch {
|
|
1766
|
+
// For broken linked worktrees, fall back to the branch implied by the worktree path.
|
|
1767
|
+
branch = expectedBranch;
|
|
1768
|
+
try {
|
|
1769
|
+
const gitFileContents = await readFile(join(worktreeDir, '.git'), 'utf-8');
|
|
1770
|
+
const linkedGitDirFromFile = parseGitdirFile(gitFileContents);
|
|
1771
|
+
if (linkedGitDirFromFile) {
|
|
1772
|
+
const linkedGitDir = isAbsolute(linkedGitDirFromFile) ? linkedGitDirFromFile : resolve(worktreeDir, linkedGitDirFromFile);
|
|
1773
|
+
const sourceRepoDir = inferSourceRepoDirFromLinkedGitDir(linkedGitDir);
|
|
1774
|
+
if (sourceRepoDir && branch) {
|
|
1775
|
+
head = (await runCapture('git', ['rev-parse', branch], { cwd: sourceRepoDir })).trim();
|
|
1776
|
+
}
|
|
1777
|
+
}
|
|
1778
|
+
} catch {
|
|
1779
|
+
head = '';
|
|
1780
|
+
}
|
|
1781
|
+
}
|
|
1782
|
+
|
|
1783
|
+
const sourcePath = relative(workspaceDir, worktreeDir);
|
|
1784
|
+
|
|
1785
|
+
const linkedStacks = await findStacksReferencingWorktree({ rootDir, worktreeDir });
|
|
1786
|
+
if (dryRun) {
|
|
1787
|
+
return { ok: true, dryRun: true, component, worktreeDir, destDir, head, branch, deleteBranch, detachStacks, linkedStacks };
|
|
1788
|
+
}
|
|
1789
|
+
|
|
1790
|
+
let shouldDetachStacks = detachStacks;
|
|
1791
|
+
if (linkedStacks.length && !shouldDetachStacks) {
|
|
1792
|
+
const names = linkedStacks.map((s) => s.name).join(', ');
|
|
1793
|
+
if (!isTty() || isJsonMode()) {
|
|
1794
|
+
throw new Error(`[wt] refusing to archive worktree still referenced by stack(s): ${names}. Re-run with --detach-stacks.`);
|
|
1795
|
+
}
|
|
1796
|
+
const action = await withRl(async (rl) => {
|
|
1797
|
+
return await promptSelect(rl, {
|
|
1798
|
+
title: `${bold('Worktree is still referenced')}\n${dim(`This worktree is pinned by stack(s): ${cyan(names)}`)}`,
|
|
1799
|
+
options: [
|
|
1800
|
+
{ label: `abort (${green('recommended')})`, value: 'abort' },
|
|
1801
|
+
{ label: `detach those stacks from this worktree`, value: 'detach' },
|
|
1802
|
+
{ label: `archive the linked stacks (also archives this worktree)`, value: 'archive-stacks' },
|
|
1803
|
+
],
|
|
1804
|
+
defaultIndex: 0,
|
|
1805
|
+
});
|
|
1806
|
+
});
|
|
1807
|
+
|
|
1808
|
+
if (action === 'abort') {
|
|
1809
|
+
throw new Error('[wt] archive aborted');
|
|
1810
|
+
}
|
|
1811
|
+
if (action === 'archive-stacks') {
|
|
1812
|
+
for (const s of linkedStacks) {
|
|
1813
|
+
// eslint-disable-next-line no-await-in-loop
|
|
1814
|
+
await run(process.execPath, [join(rootDir, 'scripts', 'stack.mjs'), 'archive', s.name, `--date=${date}`], { cwd: rootDir, env: process.env });
|
|
1815
|
+
}
|
|
1816
|
+
return {
|
|
1817
|
+
ok: true,
|
|
1818
|
+
dryRun: false,
|
|
1819
|
+
component,
|
|
1820
|
+
worktreeDir,
|
|
1821
|
+
destDir,
|
|
1822
|
+
head,
|
|
1823
|
+
branch,
|
|
1824
|
+
deleteBranch,
|
|
1825
|
+
detachStacks: false,
|
|
1826
|
+
linkedStacks,
|
|
1827
|
+
archivedVia: 'stack-archive',
|
|
1828
|
+
};
|
|
1829
|
+
}
|
|
1830
|
+
shouldDetachStacks = true;
|
|
1831
|
+
}
|
|
1832
|
+
|
|
1833
|
+
for (const s of linkedStacks) {
|
|
1834
|
+
if (!shouldDetachStacks) break;
|
|
1835
|
+
// eslint-disable-next-line no-await-in-loop
|
|
1836
|
+
await ensureEnvFilePruned({ envPath: s.envPath, removeKeys: s.keys });
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1839
|
+
const detached = await detachGitWorktree({ worktreeDir, expectedBranch: expectedBranch ?? branch ?? null });
|
|
1840
|
+
|
|
1841
|
+
await mkdir(dirname(destDir), { recursive: true });
|
|
1842
|
+
await rename(worktreeDir, destDir);
|
|
1843
|
+
|
|
1844
|
+
const meta = [
|
|
1845
|
+
`archivedAt=${new Date().toISOString()}`,
|
|
1846
|
+
`component=${component}`,
|
|
1847
|
+
`ref=${rel}`,
|
|
1848
|
+
`sourcePath=${sourcePath}`,
|
|
1849
|
+
`head=${detached.head || head}`,
|
|
1850
|
+
'',
|
|
1851
|
+
].join('\n');
|
|
1852
|
+
await writeFile(join(destDir, 'ARCHIVE_META.txt'), meta, 'utf-8');
|
|
1853
|
+
|
|
1854
|
+
// Remove the stale worktree registry entry (its path is now gone).
|
|
1855
|
+
if (detached.sourceRepoDir && !detached.alreadyDetached) {
|
|
1856
|
+
await runMaybeQuiet('git', ['worktree', 'prune'], { cwd: detached.sourceRepoDir });
|
|
1857
|
+
}
|
|
1858
|
+
|
|
1859
|
+
if (deleteBranch && detached.branch && detached.sourceRepoDir && !detached.alreadyDetached) {
|
|
1860
|
+
const worktreesRaw = await runCapture('git', ['worktree', 'list', '--porcelain'], { cwd: detached.sourceRepoDir });
|
|
1861
|
+
const inUse = worktreesRaw.includes(`branch refs/heads/${detached.branch}`);
|
|
1862
|
+
if (inUse) {
|
|
1863
|
+
throw new Error(`[wt] refusing to delete branch still checked out by a worktree: ${detached.branch}`);
|
|
1864
|
+
}
|
|
1865
|
+
await runMaybeQuiet('git', ['branch', '-D', detached.branch], { cwd: detached.sourceRepoDir });
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1868
|
+
return {
|
|
1869
|
+
ok: true,
|
|
1870
|
+
dryRun: false,
|
|
1871
|
+
component,
|
|
1872
|
+
worktreeDir,
|
|
1873
|
+
destDir,
|
|
1874
|
+
head: detached.head || head,
|
|
1875
|
+
branch: detached.branch,
|
|
1876
|
+
deleteBranch,
|
|
1877
|
+
detachStacks,
|
|
1878
|
+
linkedStacks,
|
|
1879
|
+
};
|
|
1880
|
+
}
|
|
1881
|
+
|
|
1882
|
+
async function main() {
|
|
1883
|
+
const rootDir = getRootDir(import.meta.url);
|
|
1884
|
+
const argv = process.argv.slice(2);
|
|
1885
|
+
const helpSepIdx = argv.indexOf('--');
|
|
1886
|
+
const helpScopeArgv = helpSepIdx === -1 ? argv : argv.slice(0, helpSepIdx);
|
|
1887
|
+
const { flags } = parseArgs(helpScopeArgv);
|
|
1888
|
+
const positionals = helpScopeArgv.filter((a) => a && a !== '--' && !a.startsWith('-'));
|
|
1889
|
+
const cmd = positionals[0] ?? 'help';
|
|
1890
|
+
const interactive = argv.includes('--interactive') || argv.includes('-i');
|
|
1891
|
+
const json = wantsJson(helpScopeArgv, { flags });
|
|
1892
|
+
|
|
1893
|
+
const wantsHelpFlag = wantsHelp(helpScopeArgv, { flags });
|
|
1894
|
+
|
|
1895
|
+
const usageLines = [
|
|
1896
|
+
'hstack wt sync [--remote=<name>] [--json]',
|
|
1897
|
+
'hstack wt sync-all [--remote=<name>] [--json]',
|
|
1898
|
+
'hstack wt list [--active|--all] [--json]',
|
|
1899
|
+
'hstack wt new <slug> [--category=local|tmp] [--from=upstream|origin] [--remote=<name>] [--base=<ref>|--base-worktree=<spec>] [--deps=none|link|install|link-or-install] [--use] [--force] [--interactive|-i] [--json]',
|
|
1900
|
+
'hstack wt duplicate <fromWorktreeSpec|path|active|default> <newSlug> [--remote=<name>] [--deps=none|link|install|link-or-install] [--use] [--json]',
|
|
1901
|
+
'hstack wt pr <pr-url|number> [--remote=upstream] [--slug=<name>] [--deps=none|link|install|link-or-install] [--use] [--update] [--stash|--stash-keep] [--force] [--json]',
|
|
1902
|
+
'hstack wt use <main|dev|pr/...|local/...|tmp/...|path> [--force] [--interactive|-i] [--json]',
|
|
1903
|
+
'hstack wt status [worktreeSpec|default|path] [--json]',
|
|
1904
|
+
'hstack wt update [worktreeSpec|default|path] [--remote=upstream] [--base=<ref>] [--rebase|--merge] [--dry-run] [--stash|--stash-keep] [--force] [--json]',
|
|
1905
|
+
'hstack wt update-all [--remote=upstream] [--base=<ref>] [--rebase|--merge] [--dry-run] [--stash|--stash-keep] [--force] [--json]',
|
|
1906
|
+
'hstack wt push [worktreeSpec|default|path] [--remote=origin] [--dry-run] [--json]',
|
|
1907
|
+
'hstack wt git [worktreeSpec|active|main|dev|path] -- <git args...> [--json]',
|
|
1908
|
+
'hstack wt shell [worktreeSpec|active|main|dev|path] [--shell=/bin/zsh] [--json]',
|
|
1909
|
+
'hstack wt code [worktreeSpec|active|main|dev|path] [--json]',
|
|
1910
|
+
'hstack wt cursor [worktreeSpec|active|main|dev|path] [--json]',
|
|
1911
|
+
'hstack wt archive <worktreeSpec|active|main|dev|path> [--dry-run] [--date=YYYY-MM-DD] [--no-delete-branch] [--detach-stacks] [--json]',
|
|
1912
|
+
];
|
|
1913
|
+
const usageByCmd = (() => {
|
|
1914
|
+
const map = new Map();
|
|
1915
|
+
for (const line of usageLines) {
|
|
1916
|
+
const parts = line.trim().split(/\s+/);
|
|
1917
|
+
if (parts[0] !== 'hstack' || parts[1] !== 'wt') continue;
|
|
1918
|
+
const c = parts[2] ?? '';
|
|
1919
|
+
if (c) map.set(c, line);
|
|
1920
|
+
}
|
|
1921
|
+
return map;
|
|
1922
|
+
})();
|
|
1923
|
+
|
|
1924
|
+
if (wantsHelpFlag && cmd !== 'help') {
|
|
1925
|
+
const usage = usageByCmd.get(cmd);
|
|
1926
|
+
if (usage) {
|
|
1927
|
+
printResult({
|
|
1928
|
+
json,
|
|
1929
|
+
data: { ok: true, cmd, usage },
|
|
1930
|
+
text: [`[wt ${cmd}] usage:`, ` ${usage}`, '', 'see also:', ' hstack wt --help'].join('\n'),
|
|
1931
|
+
});
|
|
1932
|
+
return;
|
|
1933
|
+
}
|
|
1934
|
+
}
|
|
1935
|
+
|
|
1936
|
+
if (wantsHelpFlag || cmd === 'help') {
|
|
1937
|
+
printResult({
|
|
1938
|
+
json,
|
|
1939
|
+
data: {
|
|
1940
|
+
commands: ['sync', 'sync-all', 'list', 'new', 'pr', 'use', 'status', 'update', 'update-all', 'push', 'git', 'shell', 'code', 'cursor', 'archive'],
|
|
1941
|
+
interactive: ['new', 'use'],
|
|
1942
|
+
},
|
|
1943
|
+
text: [
|
|
1944
|
+
'[wt] usage:',
|
|
1945
|
+
...usageLines.map((l) => ` ${l}`),
|
|
1946
|
+
'',
|
|
1947
|
+
'selectors:',
|
|
1948
|
+
' (omitted) or "active": current active checkout (env override if set; else <workspace>/main)',
|
|
1949
|
+
' "main": stable checkout under <workspace>/main',
|
|
1950
|
+
' "dev": development checkout under <workspace>/dev',
|
|
1951
|
+
' "pr/...": PR worktrees under <workspace>/pr/...',
|
|
1952
|
+
' "local/...": local worktrees under <workspace>/local/<owner>/...',
|
|
1953
|
+
' "tmp/...": temporary worktrees under <workspace>/tmp/<owner>/...',
|
|
1954
|
+
' "<absolute path>": explicit checkout path',
|
|
1955
|
+
'',
|
|
1956
|
+
'note:',
|
|
1957
|
+
'- Worktrees are repo-scoped (monorepo-only). Component selection is intentionally removed.',
|
|
1958
|
+
].join('\n'),
|
|
1959
|
+
});
|
|
1960
|
+
return;
|
|
1961
|
+
}
|
|
1962
|
+
|
|
1963
|
+
const commandsNeedingComponent = new Set([
|
|
1964
|
+
'sync',
|
|
1965
|
+
'list',
|
|
1966
|
+
'new',
|
|
1967
|
+
'duplicate',
|
|
1968
|
+
'pr',
|
|
1969
|
+
'use',
|
|
1970
|
+
'status',
|
|
1971
|
+
'update',
|
|
1972
|
+
'update-all',
|
|
1973
|
+
'push',
|
|
1974
|
+
'git',
|
|
1975
|
+
'shell',
|
|
1976
|
+
'code',
|
|
1977
|
+
'cursor',
|
|
1978
|
+
'archive',
|
|
1979
|
+
]);
|
|
1980
|
+
const legacyComponents = new Set(['happier-ui', 'happier-cli', 'happier-server-light', 'happier-server']);
|
|
1981
|
+
const effectiveArgv = (() => {
|
|
1982
|
+
if (!commandsNeedingComponent.has(cmd)) return argv;
|
|
1983
|
+
const pos = argv.filter((a) => !a.startsWith('--'));
|
|
1984
|
+
const maybeComponent = (pos[1] ?? '').trim();
|
|
1985
|
+
if (legacyComponents.has(maybeComponent)) return argv;
|
|
1986
|
+
// Insert the default component right after the command; legacy command handlers keep working.
|
|
1987
|
+
const idx = argv.indexOf(cmd);
|
|
1988
|
+
if (idx === -1) return argv;
|
|
1989
|
+
const next = argv.slice();
|
|
1990
|
+
next.splice(idx + 1, 0, DEFAULT_REPO_COMPONENT);
|
|
1991
|
+
return next;
|
|
1992
|
+
})();
|
|
1993
|
+
const effectivePositionals = effectiveArgv.filter((a) => !a.startsWith('--'));
|
|
1994
|
+
|
|
1995
|
+
if (cmd === 'use') {
|
|
1996
|
+
if (interactive && isTty()) {
|
|
1997
|
+
await cmdUseInteractive({ rootDir });
|
|
1998
|
+
} else {
|
|
1999
|
+
const res = await cmdUse({ rootDir, args: effectivePositionals.slice(1), flags });
|
|
2000
|
+
printResult({ json, data: res, text: `[wt] active dir -> ${res.activeDir}` });
|
|
2001
|
+
}
|
|
2002
|
+
return;
|
|
2003
|
+
}
|
|
2004
|
+
if (cmd === 'new') {
|
|
2005
|
+
if (interactive && isTty()) {
|
|
2006
|
+
await cmdNewInteractive({ rootDir, argv: effectiveArgv.slice(1) });
|
|
2007
|
+
} else {
|
|
2008
|
+
const res = await cmdNew({ rootDir, argv: effectiveArgv });
|
|
2009
|
+
printResult({
|
|
2010
|
+
json,
|
|
2011
|
+
data: res,
|
|
2012
|
+
text: `[wt] created worktree: ${res.path} (${res.branch} based on ${res.base})`,
|
|
2013
|
+
});
|
|
2014
|
+
}
|
|
2015
|
+
return;
|
|
2016
|
+
}
|
|
2017
|
+
if (cmd === 'duplicate') {
|
|
2018
|
+
const res = await cmdDuplicate({ rootDir, argv: effectiveArgv });
|
|
2019
|
+
printResult({
|
|
2020
|
+
json,
|
|
2021
|
+
data: res,
|
|
2022
|
+
text: `[wt] duplicated worktree: ${res.path} (${res.branch} based on ${res.base})`,
|
|
2023
|
+
});
|
|
2024
|
+
return;
|
|
2025
|
+
}
|
|
2026
|
+
if (cmd === 'pr') {
|
|
2027
|
+
const res = await cmdPr({ rootDir, argv: effectiveArgv });
|
|
2028
|
+
printResult({
|
|
2029
|
+
json,
|
|
2030
|
+
data: res,
|
|
2031
|
+
text: `[wt] created PR worktree: ${res.path} (${res.branch})`,
|
|
2032
|
+
});
|
|
2033
|
+
return;
|
|
2034
|
+
}
|
|
2035
|
+
if (cmd === 'sync') {
|
|
2036
|
+
const res = await cmdSync({ rootDir, argv: effectiveArgv });
|
|
2037
|
+
printResult({ json, data: res, text: `[wt] synced ${res.mirrorBranch} -> ${res.upstreamRef}` });
|
|
2038
|
+
return;
|
|
2039
|
+
}
|
|
2040
|
+
if (cmd === 'sync-all') {
|
|
2041
|
+
const res = await cmdSyncAll({ rootDir, argv });
|
|
2042
|
+
if (json) {
|
|
2043
|
+
printResult({ json, data: res });
|
|
2044
|
+
} else {
|
|
2045
|
+
printResult({ json: false, text: res.text });
|
|
2046
|
+
}
|
|
2047
|
+
return;
|
|
2048
|
+
}
|
|
2049
|
+
if (cmd === 'status') {
|
|
2050
|
+
const res = await cmdStatus({ rootDir, argv: effectiveArgv });
|
|
2051
|
+
if (json) {
|
|
2052
|
+
printResult({ json, data: res });
|
|
2053
|
+
} else {
|
|
2054
|
+
const lines = [
|
|
2055
|
+
`[wt] ${res.dir}`,
|
|
2056
|
+
`- branch: ${res.branch}`,
|
|
2057
|
+
`- upstream: ${res.upstream ?? '(none)'}`,
|
|
2058
|
+
`- ahead/behind: ${res.ahead ?? '?'} / ${res.behind ?? '?'}`,
|
|
2059
|
+
`- clean: ${res.isClean ? 'yes' : 'no'}`,
|
|
2060
|
+
`- conflicts: ${res.conflicts.length ? res.conflicts.join(', ') : '(none)'}`,
|
|
2061
|
+
];
|
|
2062
|
+
printResult({ json: false, text: lines.join('\n') });
|
|
2063
|
+
}
|
|
2064
|
+
return;
|
|
2065
|
+
}
|
|
2066
|
+
if (cmd === 'update') {
|
|
2067
|
+
const res = await cmdUpdate({ rootDir, argv: effectiveArgv });
|
|
2068
|
+
if (json) {
|
|
2069
|
+
printResult({ json, data: res });
|
|
2070
|
+
} else if (res.ok) {
|
|
2071
|
+
printResult({ json: false, text: `[wt] updated (${res.mode}) from ${res.base}` });
|
|
2072
|
+
} else {
|
|
2073
|
+
if (res.message) {
|
|
2074
|
+
printResult({ json: false, text: res.message });
|
|
2075
|
+
return;
|
|
2076
|
+
}
|
|
2077
|
+
const text =
|
|
2078
|
+
`[wt] update had conflicts (${res.mode}) from ${res.base}\n` +
|
|
2079
|
+
`worktree: ${res.dir}\n` +
|
|
2080
|
+
`conflicts:\n` +
|
|
2081
|
+
(res.conflicts.length ? res.conflicts.map((f) => `- ${f}`).join('\n') : '- (unknown)') +
|
|
2082
|
+
`\n` +
|
|
2083
|
+
(res.forceApplied
|
|
2084
|
+
? '[wt] conflicts left in place for manual resolution (--force)'
|
|
2085
|
+
: '[wt] update aborted; re-run with --force to keep conflict state for manual resolution');
|
|
2086
|
+
printResult({ json: false, text });
|
|
2087
|
+
}
|
|
2088
|
+
return;
|
|
2089
|
+
}
|
|
2090
|
+
if (cmd === 'update-all') {
|
|
2091
|
+
const res = await cmdUpdateAll({ rootDir, argv: effectiveArgv });
|
|
2092
|
+
if (json) {
|
|
2093
|
+
printResult({ json, data: res });
|
|
2094
|
+
} else {
|
|
2095
|
+
printResult({ json: false, text: res.text });
|
|
2096
|
+
}
|
|
2097
|
+
return;
|
|
2098
|
+
}
|
|
2099
|
+
if (cmd === 'push') {
|
|
2100
|
+
const res = await cmdPush({ rootDir, argv: effectiveArgv });
|
|
2101
|
+
printResult({
|
|
2102
|
+
json,
|
|
2103
|
+
data: res,
|
|
2104
|
+
text: res.dryRun
|
|
2105
|
+
? `[wt] would push ${res.branch} -> ${res.remote} (dry-run)`
|
|
2106
|
+
: `[wt] pushed ${res.branch} -> ${res.remote}`,
|
|
2107
|
+
});
|
|
2108
|
+
return;
|
|
2109
|
+
}
|
|
2110
|
+
if (cmd === 'git') {
|
|
2111
|
+
const res = await cmdGit({ rootDir, argv: effectiveArgv });
|
|
2112
|
+
if (json) {
|
|
2113
|
+
printResult({ json, data: res });
|
|
2114
|
+
}
|
|
2115
|
+
return;
|
|
2116
|
+
}
|
|
2117
|
+
if (cmd === 'shell') {
|
|
2118
|
+
const res = await cmdShell({ rootDir, argv: effectiveArgv });
|
|
2119
|
+
if (json) {
|
|
2120
|
+
printResult({ json, data: res });
|
|
2121
|
+
}
|
|
2122
|
+
return;
|
|
2123
|
+
}
|
|
2124
|
+
if (cmd === 'code') {
|
|
2125
|
+
const res = await cmdCode({ rootDir, argv: effectiveArgv });
|
|
2126
|
+
if (json) {
|
|
2127
|
+
printResult({ json, data: res });
|
|
2128
|
+
}
|
|
2129
|
+
return;
|
|
2130
|
+
}
|
|
2131
|
+
if (cmd === 'cursor') {
|
|
2132
|
+
const res = await cmdCursor({ rootDir, argv: effectiveArgv });
|
|
2133
|
+
if (json) {
|
|
2134
|
+
printResult({ json, data: res });
|
|
2135
|
+
}
|
|
2136
|
+
return;
|
|
2137
|
+
}
|
|
2138
|
+
if (cmd === 'list') {
|
|
2139
|
+
const res = await cmdList({ rootDir, args: effectivePositionals.slice(1), flags });
|
|
2140
|
+
if (json) {
|
|
2141
|
+
printResult({ json, data: res });
|
|
2142
|
+
} else {
|
|
2143
|
+
const results = Array.isArray(res?.results) ? res.results : [res];
|
|
2144
|
+
const lines = [];
|
|
2145
|
+
for (const r of results) {
|
|
2146
|
+
lines.push('[wt] worktrees:');
|
|
2147
|
+
lines.push(`- active: ${r.activeDir}`);
|
|
2148
|
+
for (const p of r.worktrees) {
|
|
2149
|
+
lines.push(`- ${p}`);
|
|
2150
|
+
}
|
|
2151
|
+
lines.push('');
|
|
2152
|
+
}
|
|
2153
|
+
printResult({ json: false, text: lines.join('\n') });
|
|
2154
|
+
}
|
|
2155
|
+
return;
|
|
2156
|
+
}
|
|
2157
|
+
if (cmd === 'archive') {
|
|
2158
|
+
const res = await cmdArchive({ rootDir, argv: effectiveArgv });
|
|
2159
|
+
if (json) {
|
|
2160
|
+
printResult({ json, data: res });
|
|
2161
|
+
} else if (res.dryRun) {
|
|
2162
|
+
printResult({ json: false, text: `[wt] would archive ${res.worktreeDir} -> ${res.destDir} (dry-run)` });
|
|
2163
|
+
} else {
|
|
2164
|
+
printResult({ json: false, text: `[wt] archived: ${res.destDir}` });
|
|
2165
|
+
}
|
|
2166
|
+
return;
|
|
2167
|
+
}
|
|
2168
|
+
throw new Error(`[wt] unknown command: ${cmd}`);
|
|
2169
|
+
}
|
|
2170
|
+
|
|
2171
|
+
main().catch((err) => {
|
|
2172
|
+
console.error('[wt] failed:', err);
|
|
2173
|
+
process.exit(1);
|
|
2174
|
+
});
|