diff --git a/.gitignore b/.gitignore index 496ee2c..cdf8d34 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,7 @@ -.DS_Store \ No newline at end of file +.DS_Store + +# Local OMX session artifacts +.omx/ +findings.md +progress.md +task_plan.md diff --git a/README.md b/README.md index 3dee107..e3212e6 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,11 @@ wt add 3000 # - Installs dependencies # - Starts dev server on port 3000 +# Names with trailing digits also set the dev PORT +wt add ui9083 +# - Creates branch feat/ui9083 +# - Starts the configured dev command with PORT=9083 + # 3. Navigate between worktrees wt 3000 # Jump to feature worktree wt main # Return to main repository @@ -97,9 +102,15 @@ wt rm 3000 | `wt lang` | Set CLI language (en/zh) | `wt lang set zh` | | `wt theme` | Switch `wt list` theme (`box`\|`sage`\|`archer`) | `wt theme set box` | | `wt help` | Show command reference | `wt help` | +| `wt help ` | Show subcommand help | `wt help add` | | `wt reinstall` | Update to latest version | `wt reinstall` | | `wt uninstall` | Remove worktree.sh | `wt uninstall` | +## Help and Naming Rules + +- Every core command supports `wt --help`, and `wt help ` is the equivalent long form. +- `wt add ` uses trailing digits as the dev `PORT`. For example, `wt add ui9083` keeps the worktree name `ui9083`, creates branch `feat/ui9083`, and runs the configured dev command with `PORT=9083`. + ## Advanced Features ### Broadcasting Changes Across Worktrees @@ -122,6 +133,12 @@ wt sync 3000 3001 # Disable auto dev server wt config set add.serve-dev.enabled false +# Wait longer for a slow startup script before reporting success +wt config set add.serve-dev.ready-timeout 120 + +# Keep a short post-ready observation window for launcher scripts +wt config set add.serve-dev.settle-timeout 8 + # Change branch prefix wt config set add.branch-prefix "feature/" @@ -131,6 +148,9 @@ wt config list Configuration is stored per-project in `~/.worktree.sh/projects//config.kv`. +- `add.serve-dev.ready-timeout` controls how long `wt add` waits for the inferred port to start listening. +- `add.serve-dev.settle-timeout` keeps watching briefly after readiness so wrapper scripts like `start-smart.sh` can exit without causing a false failure. + ## Use Cases ### Multiple UI Iterations diff --git a/README.zh-CN.md b/README.zh-CN.md index ee3583d..842e60c 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -42,6 +42,11 @@ wt add 3000 # - 安装依赖 # - 在端口 3000 上启动开发服务器 +# 名称以数字结尾时,也会把这些数字当作 dev PORT +wt add ui9083 +# - 创建分支 feat/ui9083 +# - 以 PORT=9083 启动配置好的 dev 命令 + # 3. 在 worktree 之间导航 wt 3000 # 跳转到功能 worktree wt main # 返回主仓库 @@ -97,9 +102,15 @@ wt rm 3000 | `wt lang` | 设置 CLI 语言(en/zh) | `wt lang set zh` | | `wt theme` | 切换 `wt list` 主题(`box`\|`sage`\|`archer`) | `wt theme set box` | | `wt help` | 显示命令参考 | `wt help` | +| `wt help ` | 显示子命令帮助 | `wt help add` | | `wt reinstall` | 更新到最新版本 | `wt reinstall` | | `wt uninstall` | 删除 worktree.sh | `wt uninstall` | +## Help 与命名规则 + +- 所有核心命令都支持 `wt --help`,而 `wt help ` 是等价的长写法。 +- `wt add ` 会把名称末尾的数字当作 dev `PORT`。例如 `wt add ui9083` 会保留 worktree 名称 `ui9083`、创建分支 `feat/ui9083`,并以 `PORT=9083` 启动已配置的 dev 命令。 + ## 高级功能 ### 跨 Worktree 广播更改 @@ -122,6 +133,12 @@ wt sync 3000 3001 # 禁用自动开发服务器 wt config set add.serve-dev.enabled false +# 启动脚本较慢时,延长端口就绪等待时间 +wt config set add.serve-dev.ready-timeout 120 + +# 对启动器脚本保留一个短暂的就绪后观察窗口 +wt config set add.serve-dev.settle-timeout 8 + # 更改分支前缀 wt config set add.branch-prefix "feature/" @@ -131,6 +148,9 @@ wt config list 配置按项目存储在 `~/.worktree.sh/projects//config.kv` 中。 +- `add.serve-dev.ready-timeout` 控制 `wt add` 等待目标端口开始监听的最长秒数。 +- `add.serve-dev.settle-timeout` 会在端口就绪后再短暂观察一段时间,避免 `start-smart.sh` 这类包装启动脚本被误判为失败。 + ## 使用场景 ### 多开 UI 抽卡 diff --git a/bin/lib/commands.sh b/bin/lib/commands.sh index dea3fda..562563a 100644 --- a/bin/lib/commands.sh +++ b/bin/lib/commands.sh @@ -295,6 +295,198 @@ theme_prompt_interactive() { esac } +is_help_token() { + case "${1:-}" in + -h | --help | help) + return 0 + ;; + *) + return 1 + ;; + esac +} + +shell_hook_usage() { + case "$LANGUAGE" in + zh) + cat << 'HOOK_USAGE_ZH' +wt shell-hook 用法: + wt shell-hook zsh + wt shell-hook bash + +将输出通过 eval/source 加载以安装包装函数,例如: + eval "$(wt shell-hook zsh)" +HOOK_USAGE_ZH + ;; + *) + cat << 'HOOK_USAGE_EN' +wt shell-hook usage: + wt shell-hook zsh + wt shell-hook bash + +Pipe the output into eval/source to install the wrapper, e.g. + eval "$(wt shell-hook zsh)" +HOOK_USAGE_EN + ;; + esac +} + +uninstall_usage() { + case "$LANGUAGE" in + zh) + cat << 'UNINSTALL_USAGE_ZH' +wt uninstall 用法: + wt uninstall [--shell ] [--prefix ] + +选项: + --shell 指定要清理的 shell(zsh|bash|none;默认自动检测) + --prefix wt 安装目录(默认 $HOME/.local/bin) + --help 查看帮助 +UNINSTALL_USAGE_ZH + ;; + *) + cat << 'UNINSTALL_USAGE_EN' +wt uninstall usage: + wt uninstall [--shell ] [--prefix ] + +Options: + --shell Shell to clean hooks for (zsh|bash|none; default: auto-detect) + --prefix Directory where wt is installed (default: $HOME/.local/bin) + --help Show this help +UNINSTALL_USAGE_EN + ;; + esac +} + +reinstall_usage() { + case "$LANGUAGE" in + zh) + cat << 'REINSTALL_USAGE_ZH' +wt reinstall 用法: + wt reinstall [--shell ] [--prefix ] + +说明: + - 先运行 uninstall.sh,再运行 install.sh + - 默认目标目录为 ~/.local/bin(可通过 WT_INSTALL_PREFIX 或 --prefix 覆盖) + - 需在包含 install.sh 与 uninstall.sh 的仓库目录中运行 +REINSTALL_USAGE_ZH + ;; + *) + cat << 'REINSTALL_USAGE_EN' +wt reinstall usage: + wt reinstall [--shell ] [--prefix ] + +Notes: + - Executes uninstall.sh followed by install.sh from this checkout + - Targets ~/.local/bin by default (override via WT_INSTALL_PREFIX or --prefix) + - Must be invoked from a checkout that contains install.sh and uninstall.sh +REINSTALL_USAGE_EN + ;; + esac +} + +init_usage() { + case "$LANGUAGE" in + zh) + cat << 'INIT_USAGE_ZH' +wt init 用法: + wt init + +请在目标仓库运行: +- 创建 ~/.worktree.sh/projects//config.kv(如不存在)。 +- 设置 repo.path 为仓库根目录(根据 git common dir 推导)。 +INIT_USAGE_ZH + ;; + *) + cat << 'INIT_USAGE_EN' +wt init usage: + wt init + +Run inside the target repository: +- Create ~/.worktree.sh/projects//config.kv when missing. +- Set repo.path to the repository root (derived from git common dir). +INIT_USAGE_EN + ;; + esac +} + +command_usage() { + local command="${1:-}" + + case "$command" in + help) + msg help_usage + ;; + list) + msg list_usage + ;; + main) + msg main_usage + ;; + path) + msg path_usage + ;; + add) + msg add_usage + ;; + merge) + msg merge_usage + ;; + sync) + msg sync_usage + ;; + rm | remove) + msg remove_usage + ;; + clean) + msg clean_usage + ;; + detach) + msg detach_usage + ;; + config) + config_usage + ;; + lang) + msg lang_usage + ;; + theme) + msg theme_usage + ;; + shell-hook) + shell_hook_usage + ;; + init) + init_usage + ;; + uninstall) + uninstall_usage + ;; + reinstall) + reinstall_usage + ;; + *) + return 1 + ;; + esac +} + +maybe_print_command_help() { + local command="${1:-}" + shift || true + + if [ $# -eq 0 ]; then + return 1 + fi + + if ! is_help_token "$1"; then + return 1 + fi + + command_usage "$command" || return 1 + return 0 +} + cmd_config() { local scope="$CURRENT_SCOPE" if [ $# -eq 0 ]; then @@ -509,28 +701,7 @@ cmd_shell_hook() { case "$1" in -h | --help | help) - case "$LANGUAGE" in - zh) - cat << 'HOOK_USAGE_ZH' -wt shell-hook 用法: - wt shell-hook zsh - wt shell-hook bash - -将输出通过 eval/source 加载以安装包装函数,例如: - eval "$(wt shell-hook zsh)" -HOOK_USAGE_ZH - ;; - *) - cat << 'HOOK_USAGE_EN' -wt shell-hook usage: - wt shell-hook zsh - wt shell-hook bash - -Pipe the output into eval/source to install the wrapper, e.g. - eval "$(wt shell-hook zsh)" -HOOK_USAGE_EN - ;; - esac + shell_hook_usage return ;; esac @@ -545,7 +716,7 @@ HOOK_USAGE_EN sed "s/__WT_BIN_PATH__/$wt_exec_path_escaped/g" << 'WT_HOOK' # wt shell integration: auto-cd after wt add/path/main/remove/clean wt() { - local __wt_out __wt_status __wt_cmd __wt_should_cd=0 + local __wt_out __wt_status __wt_cmd __wt_should_cd=0 __wt_has_help=0 __wt_arg local __wt_bin="__WT_BIN_PATH__" if [ ! -x "$__wt_bin" ]; then @@ -576,6 +747,15 @@ wt() { __wt_cmd="$1" fi + for __wt_arg in "$@"; do + case "$__wt_arg" in + help | -h | --help) + __wt_has_help=1 + break + ;; + esac + done + case "$__wt_cmd" in add|main|path) __wt_should_cd=1 @@ -594,6 +774,10 @@ wt() { ;; esac + if [ "$__wt_has_help" -eq 1 ]; then + __wt_should_cd=0 + fi + if [ $__wt_should_cd -eq 1 ] && [ -n "$__wt_out" ] && [ -d "$__wt_out" ]; then cd "$__wt_out" || return $? return 0 @@ -798,30 +982,7 @@ cmd_uninstall() { prefix="$1" ;; -h | --help | help) - case "$LANGUAGE" in - zh) - cat << 'UNINSTALL_USAGE_ZH' -wt uninstall 用法: - wt uninstall [--shell ] [--prefix ] - -选项: - --shell 指定要清理的 shell(zsh|bash|none;默认自动检测) - --prefix wt 安装目录(默认 $HOME/.local/bin) - --help 查看帮助 -UNINSTALL_USAGE_ZH - ;; - *) - cat << 'UNINSTALL_USAGE_EN' -wt uninstall usage: - wt uninstall [--shell ] [--prefix ] - -Options: - --shell Shell to clean hooks for (zsh|bash|none; default: auto-detect) - --prefix Directory where wt is installed (default: $HOME/.local/bin) - --help Show this help -UNINSTALL_USAGE_EN - ;; - esac + uninstall_usage return ;; --) @@ -895,30 +1056,7 @@ cmd_reinstall() { prefix="$1" ;; -h | --help | help) - case "$LANGUAGE" in - zh) - cat << 'REINSTALL_USAGE_ZH' -wt reinstall 用法: - wt reinstall [--shell ] [--prefix ] - -说明: - - 先运行 uninstall.sh,再运行 install.sh - - 默认目标目录为 ~/.local/bin(可通过 WT_INSTALL_PREFIX 或 --prefix 覆盖) - - 需在包含 install.sh 与 uninstall.sh 的仓库目录中运行 -REINSTALL_USAGE_ZH - ;; - *) - cat << 'REINSTALL_USAGE_EN' -wt reinstall usage: - wt reinstall [--shell ] [--prefix ] - -Notes: - - Executes uninstall.sh followed by install.sh from this checkout - - Targets ~/.local/bin by default (override via WT_INSTALL_PREFIX or --prefix) - - Must be invoked from a checkout that contains install.sh and uninstall.sh -REINSTALL_USAGE_EN - ;; - esac + reinstall_usage return ;; --) @@ -1118,28 +1256,7 @@ cmd_init() { assume_yes=1 ;; --help | help) - case "$LANGUAGE" in - zh) - cat << 'INIT_USAGE_ZH' -wt init 用法: - wt init - -请在目标仓库运行: -- 创建 ~/.worktree.sh/projects//config.kv(如不存在)。 -- 设置 repo.path 为仓库根目录(根据 git common dir 推导)。 -INIT_USAGE_ZH - ;; - *) - cat << 'INIT_USAGE_EN' -wt init usage: - wt init - -Run inside the target repository: -- Create ~/.worktree.sh/projects//config.kv when missing. -- Set repo.path to the repository root (derived from git common dir). -INIT_USAGE_EN - ;; - esac + init_usage return ;; -*) @@ -1869,7 +1986,15 @@ remove_worktree_by_name() { fi local branch="" - branch=$(branch_for "$name") + if ! branch=$(worktree_branch_for_path "$target_path" 2> /dev/null); then + branch="" + fi + + validate_removable_worktree_branch "$name" "$branch" "$WORKTREE_BRANCH_PREFIX" || true + + if ! run_configured_hook_command "$REMOVE_PRE_COMMAND" "$PROJECT_DIR_ABS" "remove.pre" "$name" "$target_path" "$branch"; then + die "remove pre-command failed" + fi info "$(msg removing_worktree "$target_path")" local removal_succeeded=0 @@ -1888,9 +2013,11 @@ remove_worktree_by_name() { die "$(msg remove_failed)" fi - if [ -n "$branch" ] && git_project show-ref --verify --quiet "refs/heads/$branch"; then - git_project branch -D "$branch" >&2 || true - info "$(msg removed_branch "$branch")" + if [ -n "$WT_VALIDATED_REMOVE_BRANCH" ] && git_project show-ref --verify --quiet "refs/heads/$WT_VALIDATED_REMOVE_BRANCH"; then + git_project branch -D "$WT_VALIDATED_REMOVE_BRANCH" >&2 || true + info "$(msg removed_branch "$WT_VALIDATED_REMOVE_BRANCH")" + else + log_skipped_branch_removal "$target_path" "$branch" fi info "$(msg worktree_removed "$name")" @@ -2161,8 +2288,8 @@ cmd_remove() { assume_yes=1 ;; -h | --help | help) - usage - exit 0 + command_usage rm + return ;; -*) die "$(msg remove_unknown_option "$1")" @@ -2220,8 +2347,8 @@ cmd_remove_global() { assume_yes=1 ;; -h | --help | help) - usage - exit 0 + command_usage rm + return ;; -*) die "$(msg remove_unknown_option "$1")" @@ -2281,19 +2408,14 @@ cmd_remove_global() { fi fi - if [ -z "$branch_name" ]; then - if [ -z "$branch_prefix" ]; then - branch_prefix="$CONFIG_DEFAULT_WORKTREE_ADD_BRANCH_PREFIX" - fi - if [ -n "$branch_prefix" ]; then - branch_name="${branch_prefix}${name}" - fi - fi + validate_removable_worktree_branch "$name" "$branch_name" "$branch_prefix" || true info "$(msg removing_worktree "$target_path")" if git_at_path "$repo_path" worktree remove "$target_path" --force >&2; then - if [ -n "$branch_name" ]; then - git_at_path "$repo_path" branch -D "$branch_name" > /dev/null 2>&1 || true + if [ -n "$WT_VALIDATED_REMOVE_BRANCH" ]; then + git_at_path "$repo_path" branch -D "$WT_VALIDATED_REMOVE_BRANCH" > /dev/null 2>&1 || true + else + log_skipped_branch_removal "$target_path" "$branch_name" fi git_at_path "$repo_path" worktree prune --expire now >&2 || true info "$(msg worktree_removed "$target_path")" @@ -2322,8 +2444,8 @@ cmd_detach() { force=1 ;; -h | --help | help) - usage - exit 0 + command_usage detach + return ;; --*) die "$(msg detach_unknown_option "$1")" @@ -2405,9 +2527,16 @@ cmd_clean() { suffix="${base#"$WORKTREE_NAME_PREFIX"}" if [[ "$suffix" =~ ^[0-9]+$ ]]; then info "$(msg cleaning_worktree "$suffix")" + if ! branch_name=$(worktree_branch_for_path "$worktree_path" 2> /dev/null); then + branch_name="" + fi + validate_removable_worktree_branch "$suffix" "$branch_name" "$WORKTREE_BRANCH_PREFIX" || true git_project worktree remove "$worktree_path" --force >&2 || true - branch_name=$(branch_for "$suffix") - git_project branch -D "$branch_name" > /dev/null 2>&1 || true + if [ -n "$WT_VALIDATED_REMOVE_BRANCH" ]; then + git_project branch -D "$WT_VALIDATED_REMOVE_BRANCH" > /dev/null 2>&1 || true + else + log_skipped_branch_removal "$worktree_path" "$branch_name" + fi removed=$((removed + 1)) if [ "$current_abs" = "$worktree_path" ]; then current_removed=1 @@ -2524,18 +2653,14 @@ cmd_clean_global() { continue fi + validate_removable_worktree_branch "$suffix" "$branch_name" "$branch_prefix" || true + info "$(msg cleaning_worktree "$suffix")" if git_at_path "$repo_path" worktree remove "$wt_path" --force >&2; then - if [ -z "$branch_name" ]; then - if [ -z "$branch_prefix" ]; then - branch_prefix="$CONFIG_DEFAULT_WORKTREE_ADD_BRANCH_PREFIX" - fi - if [ -n "$branch_prefix" ]; then - branch_name="${branch_prefix}${suffix}" - fi - fi - if [ -n "$branch_name" ]; then - git_at_path "$repo_path" branch -D "$branch_name" > /dev/null 2>&1 || true + if [ -n "$WT_VALIDATED_REMOVE_BRANCH" ]; then + git_at_path "$repo_path" branch -D "$WT_VALIDATED_REMOVE_BRANCH" > /dev/null 2>&1 || true + else + log_skipped_branch_removal "$wt_path" "$branch_name" fi git_at_path "$repo_path" worktree prune --expire now >&2 || true removed=$((removed + 1)) @@ -2593,6 +2718,10 @@ wt_main() { scope=$(current_scope) CURRENT_SCOPE="$scope" + if [ "$command" != "help" ] && maybe_print_command_help "$command" "$@"; then + return + fi + case "$command" in config) cmd_config "$@" @@ -2623,7 +2752,16 @@ wt_main() { return ;; help) - usage + if [ $# -eq 0 ]; then + usage + return + fi + if [ $# -gt 1 ]; then + die "$(msg unexpected_extra_argument "$2")" + fi + if ! command_usage "$1"; then + usage_exit 1 + fi return ;; version) diff --git a/bin/lib/runtime.sh b/bin/lib/runtime.sh index 19aad18..d874b09 100644 --- a/bin/lib/runtime.sh +++ b/bin/lib/runtime.sh @@ -22,10 +22,14 @@ readonly CONFIG_DEFAULT_WORKING_REPO_PATH="${HOME}/Developer/your-project" readonly CONFIG_DEFAULT_WORKTREE_ADD_BRANCH_PREFIX="feat/" readonly CONFIG_DEFAULT_SERVE_DEV_LOGGING_PATH="tmp" readonly CONFIG_DEFAULT_SERVE_DEV_ENABLED=1 +readonly CONFIG_DEFAULT_SERVE_DEV_READY_TIMEOUT=60 +readonly CONFIG_DEFAULT_SERVE_DEV_SETTLE_TIMEOUT=5 readonly CONFIG_DEFAULT_INSTALL_DEPS_ENABLED=1 readonly CONFIG_DEFAULT_COPY_ENV_ENABLED=1 readonly CONFIG_DEFAULT_INSTALL_DEPS_COMMAND="" readonly CONFIG_DEFAULT_SERVE_DEV_COMMAND="" +readonly CONFIG_DEFAULT_ADD_POST_COMMAND="" +readonly CONFIG_DEFAULT_REMOVE_PRE_COMMAND="" readonly CONFIG_DEFAULT_LANGUAGE="en" readonly CONFIG_DEFAULT_THEME="box" readonly -a CONFIG_DEFAULT_COPY_ENV_FILES=(".env" ".env.local") @@ -36,10 +40,14 @@ WORKTREE_NAME_PREFIX="" WORKTREE_BRANCH_PREFIX="" SERVE_DEV_LOGGING_PATH="" SERVE_DEV_ENABLED=0 +SERVE_DEV_READY_TIMEOUT=0 +SERVE_DEV_SETTLE_TIMEOUT=0 INSTALL_DEPS_ENABLED=0 COPY_ENV_ENABLED=0 INSTALL_DEPS_COMMAND="" SERVE_DEV_COMMAND="" +ADD_POST_COMMAND="" +REMOVE_PRE_COMMAND="" LANGUAGE="$CONFIG_DEFAULT_LANGUAGE" LIST_THEME="$CONFIG_DEFAULT_THEME" WORKING_REPO_PATH_CONFIGURED=0 @@ -1814,6 +1822,21 @@ project_remove_worktree() { return 1 fi + local hook_name="" + if [ -n "$branch" ]; then + hook_name="${branch##*/}" + else + hook_name="$(basename "$path")" + if [ -n "$WORKTREE_NAME_PREFIX" ] && [[ "$hook_name" == "$WORKTREE_NAME_PREFIX"* ]]; then + hook_name="${hook_name#"$WORKTREE_NAME_PREFIX"}" + fi + fi + + if ! run_configured_hook_command "$REMOVE_PRE_COMMAND" "$PROJECT_DIR_ABS" "remove.pre" "$hook_name" "$path" "$branch"; then + PROJECT_REMOVE_LAST_ERROR='remove pre-command failed' + return 1 + fi + local removal_failure=0 local removal_output="" @@ -1846,7 +1869,9 @@ project_remove_worktree() { fi if [ -n "$branch" ] && git_at_path "$repo_path" show-ref --verify --quiet "refs/heads/$branch"; then - if git_at_path "$repo_path" branch -D "$branch" > /dev/null 2>&1; then + if protected_worktree_branch "$branch"; then + info "$(msg remove_branch_skip_protected "$path" "$branch" "$branch")" + elif git_at_path "$repo_path" branch -D "$branch" > /dev/null 2>&1; then info "$(msg removed_branch "$branch")" fi fi @@ -2337,6 +2362,9 @@ config_default_value() { add.install-deps.command) printf '%s\n' "$CONFIG_DEFAULT_INSTALL_DEPS_COMMAND" ;; + add.post-command) + printf '%s\n' "$CONFIG_DEFAULT_ADD_POST_COMMAND" + ;; add.serve-dev.enabled) config_bool_to_string "$CONFIG_DEFAULT_SERVE_DEV_ENABLED" ;; @@ -2346,6 +2374,15 @@ config_default_value() { add.serve-dev.logging-path) printf '%s\n' "$CONFIG_DEFAULT_SERVE_DEV_LOGGING_PATH" ;; + add.serve-dev.ready-timeout) + printf '%s\n' "$CONFIG_DEFAULT_SERVE_DEV_READY_TIMEOUT" + ;; + add.serve-dev.settle-timeout) + printf '%s\n' "$CONFIG_DEFAULT_SERVE_DEV_SETTLE_TIMEOUT" + ;; + remove.pre-command) + printf '%s\n' "$CONFIG_DEFAULT_REMOVE_PRE_COMMAND" + ;; *) return 1 ;; @@ -2621,6 +2658,18 @@ init_settings_apply_from_sources() { SERVE_DEV_COMMAND="$value" fi + if value=$(config_get "add.serve-dev.ready-timeout" 2> /dev/null); then + [ -n "$value" ] && SERVE_DEV_READY_TIMEOUT="$value" + fi + + if value=$(config_get "add.serve-dev.settle-timeout" 2> /dev/null); then + [ -n "$value" ] && SERVE_DEV_SETTLE_TIMEOUT="$value" + fi + + if value=$(config_get "add.post-command" 2> /dev/null); then + ADD_POST_COMMAND="$value" + fi + if value=$(config_get "add.install-deps.enabled" 2> /dev/null); then if bool_val=$(parse_bool "$value" 2> /dev/null); then INSTALL_DEPS_ENABLED="$bool_val" @@ -2660,6 +2709,10 @@ init_settings_apply_from_sources() { fi fi + if value=$(config_get "remove.pre-command" 2> /dev/null); then + REMOVE_PRE_COMMAND="$value" + fi + return 0 } @@ -2667,12 +2720,16 @@ init_settings_defaults() { WORKING_REPO_PATH="$CONFIG_DEFAULT_WORKING_REPO_PATH" SERVE_DEV_LOGGING_PATH="$CONFIG_DEFAULT_SERVE_DEV_LOGGING_PATH" SERVE_DEV_ENABLED="$CONFIG_DEFAULT_SERVE_DEV_ENABLED" + SERVE_DEV_READY_TIMEOUT="$CONFIG_DEFAULT_SERVE_DEV_READY_TIMEOUT" + SERVE_DEV_SETTLE_TIMEOUT="$CONFIG_DEFAULT_SERVE_DEV_SETTLE_TIMEOUT" INSTALL_DEPS_ENABLED="$CONFIG_DEFAULT_INSTALL_DEPS_ENABLED" COPY_ENV_ENABLED="$CONFIG_DEFAULT_COPY_ENV_ENABLED" WORKTREE_BRANCH_PREFIX="$CONFIG_DEFAULT_WORKTREE_ADD_BRANCH_PREFIX" COPY_ENV_FILE_SELECTION=("${CONFIG_DEFAULT_COPY_ENV_FILES[@]}") INSTALL_DEPS_COMMAND="$CONFIG_DEFAULT_INSTALL_DEPS_COMMAND" SERVE_DEV_COMMAND="$CONFIG_DEFAULT_SERVE_DEV_COMMAND" + ADD_POST_COMMAND="$CONFIG_DEFAULT_ADD_POST_COMMAND" + REMOVE_PRE_COMMAND="$CONFIG_DEFAULT_REMOVE_PRE_COMMAND" WORKING_REPO_PATH_CONFIGURED=0 LANGUAGE="$CONFIG_DEFAULT_LANGUAGE" LIST_THEME="$CONFIG_DEFAULT_THEME" @@ -2771,7 +2828,7 @@ usage() { 核心命令: init 将当前仓库设为 wt 的默认项目 - add 创建新 worktree,复制环境文件、安装依赖并启动 dev server(可通过 wt config 调整) + add 创建新 worktree;名称为纯数字或以数字结尾时会自动推断 dev 端口 main 跳转到主 worktree merge 将指定 worktree 的分支(feat/)合并到主分支 sync [all|name ...] 将主工作区的暂存改动同步到其他 worktree @@ -2785,6 +2842,10 @@ usage() { reinstall 运行 uninstall.sh + install.sh 重新部署 wt help 显示此帮助 +帮助: + wt help 查看子命令帮助 + wt --help 与上面等价,例如 wt add --help + ${dir_banner_zh} USAGE_ZH @@ -2798,7 +2859,7 @@ Usage: Core commands: init Remember this repository as wt's default project - add Create a new worktree, copy env files, install deps, start dev server (tunable via wt config) + add Create a new worktree; numeric names or trailing digits also infer the dev port main Jump to the main worktree merge Merge the feature branch (feat/) back into the base branch sync [all|name ...] Sync staged changes from the main workspace into other worktrees @@ -2812,6 +2873,10 @@ Core commands: reinstall Run uninstall.sh + install.sh to refresh wt help Show this guide +Help: + wt help Show subcommand help + wt --help Same as above, for example wt add --help + ${dir_banner_en} USAGE_EN @@ -3083,6 +3148,91 @@ worktree_branch_for_path() { worktree_branch_for_path_in_repo "$PROJECT_DIR_ABS" "$lookup_path" } +protected_worktree_branch() { + case "${1:-}" in + main | master | develop | dev) + return 0 + ;; + esac + + return 1 +} + +expected_worktree_branch_name() { + if [ $# -lt 1 ]; then + return 1 + fi + + local name="$1" + local branch_prefix="${2:-}" + + if [ -n "$branch_prefix" ]; then + printf '%s%s\n' "$branch_prefix" "$name" + else + printf '%s\n' "$name" + fi +} + +WT_VALIDATED_REMOVE_BRANCH="" +WT_REMOVE_BRANCH_REASON="" +WT_REMOVE_BRANCH_EXPECTED="" + +validate_removable_worktree_branch() { + if [ $# -lt 2 ]; then + return 1 + fi + + local name="$1" + local actual_branch="$2" + local branch_prefix="${3:-}" + + WT_VALIDATED_REMOVE_BRANCH="" + WT_REMOVE_BRANCH_REASON="" + WT_REMOVE_BRANCH_EXPECTED="" + WT_REMOVE_BRANCH_EXPECTED=$(expected_worktree_branch_name "$name" "$branch_prefix") + + if [ -z "$actual_branch" ]; then + WT_REMOVE_BRANCH_REASON='missing' + return 1 + fi + + if protected_worktree_branch "$actual_branch"; then + WT_REMOVE_BRANCH_REASON='protected' + return 1 + fi + + if [ -n "$WT_REMOVE_BRANCH_EXPECTED" ] && [ "$actual_branch" != "$WT_REMOVE_BRANCH_EXPECTED" ]; then + WT_REMOVE_BRANCH_REASON='mismatch' + return 1 + fi + + WT_VALIDATED_REMOVE_BRANCH="$actual_branch" + return 0 +} + +log_skipped_branch_removal() { + if [ $# -lt 1 ]; then + return 1 + fi + + local target_path="$1" + local actual_branch="${2:-}" + local expected_branch="${3:-$WT_REMOVE_BRANCH_EXPECTED}" + local reason="${4:-$WT_REMOVE_BRANCH_REASON}" + + case "$reason" in + protected) + info "$(msg remove_branch_skip_protected "$target_path" "$actual_branch" "$expected_branch")" + ;; + mismatch) + info "$(msg remove_branch_skip_mismatch "$target_path" "$actual_branch" "$expected_branch")" + ;; + missing) + info "$(msg remove_branch_skip_missing "$target_path" "$expected_branch")" + ;; + esac +} + branch_for() { local name="$1" local target_path @@ -3178,12 +3328,45 @@ git_at_path() { } copy_env_file() { - local file_name="$1" + local entry="$1" local target_dir="$2" - if [ -f "$PROJECT_DIR_ABS/$file_name" ]; then - cp "$PROJECT_DIR_ABS/$file_name" "$target_dir/" - info "$(msg copy_env_file "$file_name")" + local source_rel="$entry" + local dest_rel="" + + if [[ "$entry" == *:* ]]; then + source_rel="${entry%%:*}" + dest_rel="${entry#*:}" + fi + + if [ -z "$dest_rel" ]; then + dest_rel="$source_rel" + fi + + local source_path="$PROJECT_DIR_ABS/$source_rel" + local dest_path="$target_dir/$dest_rel" + + if [ -d "$source_path" ]; then + mkdir -p "$dest_path" + if command -v rsync > /dev/null 2>&1; then + rsync -a "$source_path/" "$dest_path/" + else + cp -R "$source_path"/. "$dest_path/" + fi + info "$(msg copy_env_file "$source_rel" "$dest_rel")" + return fi + + if [ -f "$source_path" ]; then + local dest_dir + dest_dir=$(dirname "$dest_path") + mkdir -p "$dest_dir" + dest_path=${dest_path%/} + cp "$source_path" "$dest_path" + info "$(msg copy_env_file "$source_rel" "$dest_rel")" + return + fi + + info "$(msg copy_env_missing "$source_rel")" } command_exists_for_line() { @@ -3212,6 +3395,81 @@ command_exists_for_line() { return 1 } +port_is_listening() { + local port="${1:-}" + if [ -z "$port" ]; then + return 1 + fi + + case "$port" in + *[!0-9]*) + return 1 + ;; + esac + + if command -v lsof > /dev/null 2>&1; then + lsof -nP -iTCP:"$port" -sTCP:LISTEN > /dev/null 2>&1 + return $? + fi + + (exec 3<> "/dev/tcp/127.0.0.1/$port") > /dev/null 2>&1 +} + +wait_for_dev_port() { + local port="${1:?port is required}" + local timeout_sec="${2:-60}" + local pid="${3:-}" + local exit_grace_sec="${4:-2}" + local waited=0 + local dead_wait=0 + + while [ "$waited" -lt "$timeout_sec" ]; do + if port_is_listening "$port"; then + return 0 + fi + + if [ -n "$pid" ] && ! kill -0 "$pid" 2> /dev/null; then + dead_wait=$((dead_wait + 1)) + if [ "$dead_wait" -ge "$exit_grace_sec" ]; then + return 1 + fi + else + dead_wait=0 + fi + + sleep 1 + waited=$((waited + 1)) + done + + return 2 +} + +wait_for_dev_settle() { + local port="${1:?port is required}" + local pid="${2:-}" + local timeout_sec="${3:-5}" + local waited=0 + + if [ -z "$pid" ]; then + return 0 + fi + + while [ "$waited" -lt "$timeout_sec" ]; do + if ! kill -0 "$pid" 2> /dev/null; then + return 0 + fi + + if ! port_is_listening "$port"; then + return 1 + fi + + sleep 1 + waited=$((waited + 1)) + done + + return 0 +} + node_lockfile_present() { local worktree_path="${1:?worktree path is required}" if [ -f "$worktree_path/package-lock.json" ] || [ -f "$worktree_path/npm-shrinkwrap.json" ]; then @@ -3280,6 +3538,42 @@ run_install_command() { ) } +run_configured_hook_command() { + local command_line="${1:-}" + local cwd="${2:-}" + local phase="${3:-hook}" + local worktree_name="${4:-}" + local worktree_path="${5:-}" + local worktree_branch="${6:-}" + + if [ -z "$command_line" ]; then + return 0 + fi + + if ! command_exists_for_line "$command_line"; then + info "⚠️ $phase hook command not found: $command_line" + return 1 + fi + + if [ -z "$cwd" ] || [ ! -d "$cwd" ]; then + info "⚠️ $phase hook working directory missing: $cwd" + return 1 + fi + + info "⚙️ Running $phase hook: $command_line" + + ( + cd "$cwd" || exit 1 + env \ + WT_HOOK_PHASE="$phase" \ + WT_PROJECT_ROOT="$PROJECT_DIR_ABS" \ + WT_WORKTREE_NAME="$worktree_name" \ + WT_WORKTREE_PATH="$worktree_path" \ + WT_WORKTREE_BRANCH="$worktree_branch" \ + sh -c "$command_line" + ) +} + command_slug_from_line() { local line="$1" if [ -z "$line" ]; then @@ -4049,17 +4343,54 @@ start_dev_server() { pid=$(cat "$pid_file" 2> /dev/null || true) fi - sleep 1 + if [ -n "$port" ]; then + local ready_timeout="$SERVE_DEV_READY_TIMEOUT" + local settle_timeout="$SERVE_DEV_SETTLE_TIMEOUT" - if [ -n "$pid" ] && kill -0 "$pid" 2> /dev/null; then - if [ -n "$port" ]; then - info "$(msg dev_started_port "$port")" + case "$ready_timeout" in + '' | *[!0-9]*) + if [ -n "${WT_DEV_READY_TIMEOUT:-}" ] && [[ ! "${WT_DEV_READY_TIMEOUT:-}" =~ [^0-9] ]]; then + ready_timeout="${WT_DEV_READY_TIMEOUT}" + else + ready_timeout="$CONFIG_DEFAULT_SERVE_DEV_READY_TIMEOUT" + fi + ;; + esac + case "$settle_timeout" in + '' | *[!0-9]*) + if [ -n "${WT_DEV_SETTLE_TIMEOUT:-}" ] && [[ ! "${WT_DEV_SETTLE_TIMEOUT:-}" =~ [^0-9] ]]; then + settle_timeout="${WT_DEV_SETTLE_TIMEOUT}" + else + settle_timeout="$CONFIG_DEFAULT_SERVE_DEV_SETTLE_TIMEOUT" + fi + ;; + esac + + if wait_for_dev_port "$port" "$ready_timeout" "$pid"; then + if ! wait_for_dev_settle "$port" "$pid" "$settle_timeout"; then + local pid_display="${pid:-unknown}" + info "$(msg dev_failed "$pid_display" "$log_file")" + else + info "$(msg dev_started_port "$port")" + fi else - info "$(msg dev_started_default)" + local wait_status=$? + local pid_display="${pid:-unknown}" + if [ "$wait_status" -eq 1 ]; then + info "$(msg dev_failed "$pid_display" "$log_file")" + else + info "$(msg dev_timeout "$port" "$ready_timeout" "$log_file")" + fi fi else - local pid_display="${pid:-unknown}" - info "$(msg dev_failed "$pid_display" "$log_file")" + sleep 1 + + if [ -n "$pid" ] && kill -0 "$pid" 2> /dev/null; then + info "$(msg dev_started_default)" + else + local pid_display="${pid:-unknown}" + info "$(msg dev_failed "$pid_display" "$log_file")" + fi fi info "$(msg dev_log_hint "$log_file")" @@ -4645,6 +4976,13 @@ cmd_add() { shift || true [ -n "$name" ] || die "$(msg add_requires_name)" + if is_help_token "$name"; then + command_usage add + return + fi + if [[ "$name" == -* ]]; then + die "$(msg add_unknown_option "$name")" + fi if ! validate_worktree_name "$name"; then die "$(msg invalid_worktree_name "$name")" fi @@ -4687,8 +5025,8 @@ cmd_add() { while [ $# -gt 0 ]; do case "$1" in -h | --help | help) - usage - exit 0 + command_usage add + return ;; --) shift @@ -4711,16 +5049,13 @@ cmd_add() { if [ "$run_dev" -eq 1 ]; then effective_port=$(port_from_name "$name") - if [ -z "$effective_port" ]; then - if [ -n "$port_candidate" ] && [ "$port_candidate" -lt 1024 ]; then + if [ -z "$effective_port" ] && [ -n "$port_candidate" ]; then + if [ "$port_candidate" -lt 1024 ]; then skip_dev=1 skip_dev_port="$port_candidate" skip_dev_reason="reserved" - elif [ -n "$port_candidate" ] && [ "$port_candidate" -gt 65535 ]; then + elif [ "$port_candidate" -gt 65535 ]; then info "$(msg fallback_default_port)" - else - skip_dev=1 - skip_dev_reason="no_port" fi fi fi @@ -4783,6 +5118,10 @@ cmd_add() { run_install_command "$worktree_path" "$install_command" fi + if ! run_configured_hook_command "$ADD_POST_COMMAND" "$worktree_path" "add.post" "$name" "$worktree_path" "$branch"; then + die "add post-command failed" + fi + if [ "$run_dev" -eq 1 ]; then if [ "$skip_dev" -eq 1 ]; then if [ "$skip_dev_reason" = "reserved" ]; then @@ -4805,6 +5144,8 @@ cmd_add() { config_print_effective() { local has_language_line=0 local has_theme_line=0 + local has_serve_ready_timeout_line=0 + local has_serve_settle_timeout_line=0 if [ -f "$CONFIG_FILE" ]; then if [ -s "$CONFIG_FILE" ]; then @@ -4814,6 +5155,12 @@ config_print_effective() { if grep -q '^theme=' "$CONFIG_FILE" 2> /dev/null; then has_theme_line=1 fi + if grep -q '^add\.serve-dev\.ready-timeout=' "$CONFIG_FILE" 2> /dev/null; then + has_serve_ready_timeout_line=1 + fi + if grep -q '^add\.serve-dev\.settle-timeout=' "$CONFIG_FILE" 2> /dev/null; then + has_serve_settle_timeout_line=1 + fi cat "$CONFIG_FILE" else info "$(msg config_list_empty "$CONFIG_FILE")" @@ -4847,6 +5194,28 @@ config_print_effective() { printf 'theme=%s\n' "$effective_theme" fi + local effective_ready_timeout + if effective_ready_timeout=$(config_get_or_default "add.serve-dev.ready-timeout" 2> /dev/null); then + : + else + effective_ready_timeout="$CONFIG_DEFAULT_SERVE_DEV_READY_TIMEOUT" + fi + + if [ "$has_serve_ready_timeout_line" -eq 0 ]; then + printf 'add.serve-dev.ready-timeout=%s\n' "$effective_ready_timeout" + fi + + local effective_settle_timeout + if effective_settle_timeout=$(config_get_or_default "add.serve-dev.settle-timeout" 2> /dev/null); then + : + else + effective_settle_timeout="$CONFIG_DEFAULT_SERVE_DEV_SETTLE_TIMEOUT" + fi + + if [ "$has_serve_settle_timeout_line" -eq 0 ]; then + printf 'add.serve-dev.settle-timeout=%s\n' "$effective_settle_timeout" + fi + return 0 } @@ -4878,6 +5247,8 @@ wt config - 查看或更新 worktree.sh 配置 add.serve-dev.enabled 是否在 wt add 后启动开发服务 add.serve-dev.command 启动开发服务的命令(留空则自动推断) add.serve-dev.logging-path Dev 服务日志所在子目录(默认: tmp) + add.serve-dev.ready-timeout 等待端口开始监听的秒数(默认: 60) + add.serve-dev.settle-timeout 端口就绪后继续观察的秒数(默认: 5,用于兼容短命启动脚本) theme wt list 的显示主题(box 或 sage,默认 box,可用 "wt theme" 切换) 语言与主题: @@ -4910,6 +5281,8 @@ Supported keys: add.serve-dev.enabled Whether wt add starts the dev service add.serve-dev.command Command used to start the dev service (empty = auto-detect) add.serve-dev.logging-path Subdirectory for dev logs (default: tmp) + add.serve-dev.ready-timeout Seconds to wait for the dev port to start listening (default: 60) + add.serve-dev.settle-timeout Extra seconds to observe the port after readiness (default: 5; useful for short-lived launcher scripts) theme Theme for wt list output (box or sage; default box). Use "wt theme" to change. Language & theme: diff --git a/bin/messages.sh b/bin/messages.sh index 4cfe390..884a516 100644 --- a/bin/messages.sh +++ b/bin/messages.sh @@ -5,7 +5,10 @@ msg_en() { shift || true case "$key" in copy_env_file) - printf '📄 Copy %s' "$1" + printf '📄 Copy %s → %s' "$1" "$2" + ;; + copy_env_missing) + printf '⚠️ Skipping copy; source not found: %s' "$1" ;; command_not_found) printf '⚠️ Command not available: %s; skipping' "$1" @@ -20,7 +23,7 @@ msg_en() { printf '🚀 Starting dev command' ;; dev_started_port) - printf '✅ Dev command running on port %s' "$1" + printf '✅ Dev command is ready; port %s is listening' "$1" ;; dev_started_default) printf '✅ Dev command running' @@ -28,6 +31,9 @@ msg_en() { dev_failed) printf '⚠️ Dev command may not have started correctly (PID: %s); check %s' "$1" "$2" ;; + dev_timeout) + printf '⚠️ Dev command did not become ready on port %s within %s seconds; check %s' "$1" "$2" "$3" + ;; dev_log_hint) printf '📝 Dev command log: tail -f %s' "$1" ;; @@ -232,6 +238,172 @@ Non-interactive: wt lang help Show this help LANG_USAGE_EN ;; + help_usage) + cat << 'HELP_USAGE_EN' +wt help - Show global or command-specific help + +Usage: + wt help + wt help + +Examples: + wt help add + wt help sync + +Tip: + Every core command also accepts --help, for example: + wt add --help +HELP_USAGE_EN + ;; + list_usage) + cat << 'LIST_USAGE_EN' +wt list - Show known worktrees for the current project + +Usage: + wt + wt list + wt list --help + +Notes: + - Inside a configured project, shows that project's main worktree plus siblings. + - Outside a configured project, use wt help for command discovery or wt init in a repo first. +LIST_USAGE_EN + ;; + main_usage) + cat << 'MAIN_USAGE_EN' +wt main - Print the main worktree path + +Usage: + wt main + wt main --help + +Notes: + - With shell-hook installed, calling wt main jumps back to the main repository automatically. + - Without shell-hook, it prints the absolute path so you can cd manually. +MAIN_USAGE_EN + ;; + path_usage) + cat << 'PATH_USAGE_EN' +wt path - Print a worktree path without switching directories + +Usage: + wt path + wt path --help + +Examples: + wt path 3000 + wt path ui9083 +PATH_USAGE_EN + ;; + add_usage) + cat << 'ADD_USAGE_EN' +wt add - Create a new worktree from the current branch + +Usage: + wt add + wt add --help + +What controls: + - Worktree path suffix (for example ../project.) + - Branch suffix (for example feat/) + - Dev PORT when the name is numeric or ends with digits + +Port rules: + - wt add 9083 -> PORT=9083 + - wt add ui9083 -> worktree name ui9083, branch feat/ui9083, PORT=9083 + - Ports below 1024 are treated as reserved and are not passed to the dev command + - Ports above 65535 fall back to the default dev port behavior + +Examples: + wt add 3000 + wt add ui9083 + wt add feat-auth +ADD_USAGE_EN + ;; + merge_usage) + cat << 'MERGE_USAGE_EN' +wt merge - Merge a worktree branch back into the current base branch + +Usage: + wt merge + wt merge --help + +Examples: + wt merge 3000 + wt merge ui9083 + +Notes: + - Run from the main worktree on the branch you want to merge into. + - Both the main worktree and target worktree must be clean. +MERGE_USAGE_EN + ;; + sync_usage) + cat << 'SYNC_USAGE_EN' +wt sync - Apply staged changes from the main worktree into other worktrees + +Usage: + wt sync all + wt sync [name ...] + wt sync --help + +Examples: + wt sync all + wt sync 3000 ui9083 + +Notes: + - Sync reads the staged diff from the current main worktree. + - Target worktrees must be clean before syncing. +SYNC_USAGE_EN + ;; + remove_usage) + cat << 'REMOVE_USAGE_EN' +wt rm - Remove worktrees and their feature branches + +Usage: + wt rm [name ...] + wt rm --help + +Examples: + wt rm 3000 + wt rm ui9083 + wt rm # Remove the current worktree when run inside it + +Notes: + - Protected or mismatched branches are skipped instead of deleted. + - Alias: wt remove +REMOVE_USAGE_EN + ;; + clean_usage) + cat << 'CLEAN_USAGE_EN' +wt clean - Remove numeric worktrees for the current project + +Usage: + wt clean + wt clean --help + +Notes: + - Matches worktrees whose suffix is only digits, such as 3000 or 9083. + - Leaves named worktrees such as ui9083 or feat-auth untouched. +CLEAN_USAGE_EN + ;; + detach_usage) + cat << 'DETACH_USAGE_EN' +wt detach - Remove all tracked worktrees for a project + +Usage: + wt detach [-y|--yes] [project-slug] + wt detach --help + +Examples: + wt detach + wt detach -y + wt detach my-project + +Notes: + - In a project workspace, the current project slug is used by default. + - In global mode, wt can prompt you to choose a project when no slug is provided. +DETACH_USAGE_EN + ;; init_prompt_repo_path) printf 'Repository path for wt to track?' ;; @@ -511,6 +683,15 @@ LANG_USAGE_EN remove_failed) printf 'Failed to remove %s' "$1" ;; + remove_branch_skip_protected) + printf '⚠️ Skipping branch deletion for %s: protected branch %s (expected exact match %s)' "$1" "$2" "$3" + ;; + remove_branch_skip_mismatch) + printf '⚠️ Skipping branch deletion for %s: current branch %s does not exactly match expected %s' "$1" "$2" "$3" + ;; + remove_branch_skip_missing) + printf '⚠️ Skipping branch deletion for %s: current branch could not be verified (expected exact match %s)' "$1" "$2" + ;; detach_prompt_worktree) printf 'Remove worktree %s? [Y/n]' "$1" ;; @@ -846,7 +1027,10 @@ msg_zh() { shift || true case "$key" in copy_env_file) - printf '📄 复制 %s' "$1" + printf '📄 复制 %s → %s' "$1" "$2" + ;; + copy_env_missing) + printf '⚠️ 源文件不存在,已跳过:%s' "$1" ;; command_not_found) printf '⚠️ 未找到命令:%s,已跳过' "$1" @@ -861,7 +1045,7 @@ msg_zh() { printf '🚀 正在启动开发命令' ;; dev_started_port) - printf '✅ 开发命令已在端口 %s 运行' "$1" + printf '✅ 开发命令已就绪,端口 %s 已开始监听' "$1" ;; dev_started_default) printf '✅ 开发命令已启动' @@ -869,6 +1053,9 @@ msg_zh() { dev_failed) printf '⚠️ 开发命令进程可能未正确启动 (PID: %s),请检查 %s' "$1" "$2" ;; + dev_timeout) + printf '⚠️ 开发命令在 %s 秒内未能在端口 %s 就绪,请检查 %s' "$2" "$1" "$3" + ;; dev_log_hint) printf '📝 开发命令日志:tail -f %s' "$1" ;; @@ -1073,6 +1260,172 @@ wt lang - 管理 worktree.sh 界面语言 wt lang help 显示本帮助 LANG_USAGE_ZH ;; + help_usage) + cat << 'HELP_USAGE_ZH' +wt help - 查看全局帮助或子命令帮助 + +用法: + wt help + wt help + +示例: + wt help add + wt help sync + +提示: + 核心命令都支持 --help,例如: + wt add --help +HELP_USAGE_ZH + ;; + list_usage) + cat << 'LIST_USAGE_ZH' +wt list - 查看当前项目已登记的 worktree + +用法: + wt + wt list + wt list --help + +说明: + - 在已配置项目中,会显示主 worktree 及其兄弟 worktree。 + - 在未配置目录中,可先用 wt help 看命令,或进入仓库执行 wt init。 +LIST_USAGE_ZH + ;; + main_usage) + cat << 'MAIN_USAGE_ZH' +wt main - 输出主 worktree 路径 + +用法: + wt main + wt main --help + +说明: + - 安装 shell-hook 后,执行 wt main 会自动切回主仓库。 + - 未安装 shell-hook 时,会输出绝对路径,方便手动 cd。 +MAIN_USAGE_ZH + ;; + path_usage) + cat << 'PATH_USAGE_ZH' +wt path - 输出 worktree 路径而不切换目录 + +用法: + wt path + wt path --help + +示例: + wt path 3000 + wt path ui9083 +PATH_USAGE_ZH + ;; + add_usage) + cat << 'ADD_USAGE_ZH' +wt add - 基于当前分支创建新的 worktree + +用法: + wt add + wt add --help + + 会同时决定: + - worktree 路径后缀(例如 ../project.) + - 分支后缀(例如 feat/) + - 当名称为纯数字或以数字结尾时,对应 dev 命令的 PORT + +端口规则: + - wt add 9083 -> PORT=9083 + - wt add ui9083 -> worktree 名称为 ui9083,分支为 feat/ui9083,PORT=9083 + - 小于 1024 的端口视为保留端口,不会传给 dev 命令 + - 大于 65535 的端口会回退到默认 dev 端口行为 + +示例: + wt add 3000 + wt add ui9083 + wt add feat-auth +ADD_USAGE_ZH + ;; + merge_usage) + cat << 'MERGE_USAGE_ZH' +wt merge - 将指定 worktree 分支合并回当前主分支 + +用法: + wt merge + wt merge --help + +示例: + wt merge 3000 + wt merge ui9083 + +说明: + - 请在主 worktree 中、目标主分支上执行。 + - 主 worktree 和目标 worktree 都必须是干净状态。 +MERGE_USAGE_ZH + ;; + sync_usage) + cat << 'SYNC_USAGE_ZH' +wt sync - 将主 worktree 已暂存的改动应用到其他 worktree + +用法: + wt sync all + wt sync [name ...] + wt sync --help + +示例: + wt sync all + wt sync 3000 ui9083 + +说明: + - sync 会读取当前主 worktree 已暂存的 diff。 + - 目标 worktree 在同步前必须保持干净。 +SYNC_USAGE_ZH + ;; + remove_usage) + cat << 'REMOVE_USAGE_ZH' +wt rm - 删除 worktree 及其功能分支 + +用法: + wt rm [name ...] + wt rm --help + +示例: + wt rm 3000 + wt rm ui9083 + wt rm # 在 worktree 目录内执行时删除当前 worktree + +说明: + - 受保护分支或分支名不匹配时,会跳过删分支而不是强删。 + - 别名:wt remove +REMOVE_USAGE_ZH + ;; + clean_usage) + cat << 'CLEAN_USAGE_ZH' +wt clean - 批量删除当前项目中的纯数字 worktree + +用法: + wt clean + wt clean --help + +说明: + - 只匹配后缀为纯数字的 worktree,例如 3000、9083。 + - 不会删除 ui9083、feat-auth 这类带名字的 worktree。 +CLEAN_USAGE_ZH + ;; + detach_usage) + cat << 'DETACH_USAGE_ZH' +wt detach - 删除某个项目登记的全部 worktree + +用法: + wt detach [-y|--yes] [project-slug] + wt detach --help + +示例: + wt detach + wt detach -y + wt detach my-project + +说明: + - 在项目工作区内执行时,默认使用当前项目 slug。 + - 在全局模式下,如果不传 slug,wt 可以交互式让你选择项目。 +DETACH_USAGE_ZH + ;; init_prompt_repo_path) printf 'wt 追踪的主仓库地址?' ;; @@ -1355,6 +1708,15 @@ LANG_USAGE_ZH remove_failed) printf '删除 %s 失败' "$1" ;; + remove_branch_skip_protected) + printf '⚠️ 跳过删除分支:%s 当前挂着受保护分支 %s(期望精确匹配 %s)' "$1" "$2" "$3" + ;; + remove_branch_skip_mismatch) + printf '⚠️ 跳过删除分支:%s 当前分支 %s 与期望分支 %s 不完全一致' "$1" "$2" "$3" + ;; + remove_branch_skip_missing) + printf '⚠️ 跳过删除分支:无法确认 %s 当前分支(期望精确匹配 %s)' "$1" "$2" + ;; detach_prompt_worktree) printf '移除工作树 %s?[Y/n]' "$1" ;; diff --git a/config-example.kv b/config-example.kv index c365db3..5f1de8b 100644 --- a/config-example.kv +++ b/config-example.kv @@ -8,3 +8,7 @@ add.install-deps.command=npm ci add.serve-dev.enabled=true add.serve-dev.command=npm run dev add.serve-dev.logging-path=tmp +add.serve-dev.ready-timeout=60 +add.serve-dev.settle-timeout=5 +add.post-command=bash "$WT_PROJECT_ROOT/scripts/post-add.sh" +remove.pre-command=bash "$WT_PROJECT_ROOT/scripts/pre-remove.sh" diff --git a/scripts/sync-installed-wt.sh b/scripts/sync-installed-wt.sh new file mode 100755 index 0000000..4c68333 --- /dev/null +++ b/scripts/sync-installed-wt.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +readonly SCRIPT_DIR +PREFIX="${WT_SYNC_PREFIX:-$HOME/.local/bin}" +readonly PREFIX + +exec bash "$SCRIPT_DIR/install.sh" --shell none --prefix "$PREFIX" diff --git a/tests/test_rm_guard.sh b/tests/test_rm_guard.sh new file mode 100755 index 0000000..101cbc5 --- /dev/null +++ b/tests/test_rm_guard.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../bin" && pwd)" +# shellcheck source=../bin/lib/runtime.sh +source "$SCRIPT_DIR/lib/runtime.sh" + +pass=0 +fail=0 + +assert_equals() { + local expected="$1" + local actual="$2" + local message="$3" + + if [ "$expected" = "$actual" ]; then + printf ' ✓ %s\n' "$message" + pass=$((pass + 1)) + else + printf ' ✗ %s\n 期望: %s\n 实际: %s\n' "$message" "$expected" "$actual" + fail=$((fail + 1)) + fi +} + +assert_empty() { + local actual="$1" + local message="$2" + + if [ -z "$actual" ]; then + printf ' ✓ %s\n' "$message" + pass=$((pass + 1)) + else + printf ' ✗ %s\n 实际: %s\n' "$message" "$actual" + fail=$((fail + 1)) + fi +} + +printf '=== 精确匹配分支删除保护 ===\n' + +validate_removable_worktree_branch "ui9083" "wt-feat/ui9083" "wt-feat/" || true +assert_equals "wt-feat/ui9083" "$WT_VALIDATED_REMOVE_BRANCH" '标准 worktree 分支允许删除' +assert_empty "$WT_REMOVE_BRANCH_REASON" '标准 worktree 分支不记录跳过原因' + +validate_removable_worktree_branch "ui9083" "dev" "wt-feat/" || true +assert_empty "$WT_VALIDATED_REMOVE_BRANCH" '受保护分支不会被加入删除列表' +assert_equals "protected" "$WT_REMOVE_BRANCH_REASON" 'dev 被识别为受保护分支' +assert_equals "wt-feat/ui9083" "$WT_REMOVE_BRANCH_EXPECTED" '受保护分支仍保留期望分支名' + +validate_removable_worktree_branch "ui9083" "feature/ui9083" "wt-feat/" || true +assert_empty "$WT_VALIDATED_REMOVE_BRANCH" '分支名前缀不匹配时不会删除' +assert_equals "mismatch" "$WT_REMOVE_BRANCH_REASON" '前缀不匹配记录 mismatch' + +validate_removable_worktree_branch "ui9083" "" "wt-feat/" || true +assert_empty "$WT_VALIDATED_REMOVE_BRANCH" '无法确认当前分支时不会猜测删除目标' +assert_equals "missing" "$WT_REMOVE_BRANCH_REASON" '缺失分支记录 missing' + +printf '\n' +if [ "$fail" -eq 0 ]; then + printf '✅ rm 分支保护测试全部通过,共 %d 项。\n' "$pass" +else + printf '❌ rm 分支保护测试存在失败:%d 通过,%d 失败。\n' "$pass" "$fail" + exit 1 +fi diff --git a/tests/test_shell_hook.sh b/tests/test_shell_hook.sh new file mode 100755 index 0000000..adeb69b --- /dev/null +++ b/tests/test_shell_hook.sh @@ -0,0 +1,112 @@ +#!/usr/bin/env bash + +set -euo pipefail + +REPO_BIN="$(cd "$(dirname "${BASH_SOURCE[0]}")/../bin" && pwd)" +SCRIPT_DIR="$REPO_BIN" +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/lib/runtime.sh" +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/lib/commands.sh" + +pass=0 +fail=0 +tmpdirs=() + +assert_equals() { + local expected="$1" + local actual="$2" + local message="$3" + + if [ "$expected" = "$actual" ]; then + printf ' ✓ %s\n' "$message" + pass=$((pass + 1)) + else + printf ' ✗ %s\n 期望: %s\n 实际: %s\n' "$message" "$expected" "$actual" + fail=$((fail + 1)) + fi +} + +mktempdir() { + local dir + dir=$(mktemp -d) + tmpdirs+=("$dir") + printf '%s\n' "$dir" +} + +cleanup() { + if [ "${tmpdirs+x}" != x ]; then + return + fi + + local dir + for dir in "${tmpdirs[@]}"; do + rm -rf "$dir" + done +} +trap cleanup EXIT + +printf '=== shell-hook help guard ===\n' + +fake_root=$(mktempdir) +cat > "$fake_root/wt" << 'EOF' +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +case "${1:-}|${2:-}" in + -h\||--help\||help\|) + printf '%s\n' "$script_dir/help-target" + ;; + rm\|--help|add\|--help|path\|--help) + printf '%s\n' "$script_dir/help-target" + ;; + rm\|demo|add\|demo|path\|demo|demo\|) + printf '%s\n' "$script_dir/jump-target" + ;; + *) + printf '%s\n' "$script_dir/default-target" + ;; +esac +EOF +chmod +x "$fake_root/wt" +mkdir -p "$fake_root/help-target" "$fake_root/jump-target" "$fake_root/default-target" + +original_script_dir="$SCRIPT_DIR" +SCRIPT_DIR="$fake_root" +hook="$(cmd_shell_hook bash)" +SCRIPT_DIR="$original_script_dir" + +workspace=$(mktempdir) +mkdir -p "$workspace/start" +cd "$workspace/start" + +eval "$hook" + +before_pwd=$(pwd) +rm_help_output="$(wt rm --help)" +after_rm_help_pwd=$(pwd) +assert_equals "$fake_root/help-target" "$rm_help_output" 'wt rm --help 保留原始输出' +assert_equals "$before_pwd" "$after_rm_help_pwd" 'wt rm --help 不触发 auto-cd' + +root_help_output="$(wt --help)" +after_root_help_pwd=$(pwd) +assert_equals "$fake_root/help-target" "$root_help_output" 'wt --help 保留原始输出' +assert_equals "$before_pwd" "$after_root_help_pwd" 'wt --help 不触发 auto-cd' + +short_help_output="$(wt -h)" +after_short_help_pwd=$(pwd) +assert_equals "$fake_root/help-target" "$short_help_output" 'wt -h 保留原始输出' +assert_equals "$before_pwd" "$after_short_help_pwd" 'wt -h 不触发 auto-cd' + +wt rm demo > /dev/null +assert_equals "$fake_root/jump-target" "$(pwd)" 'wt rm demo 仍会根据路径输出自动切换目录' + +printf '\n' +if [ "$fail" -eq 0 ]; then + printf '✅ shell-hook 测试全部通过,共 %d 项。\n' "$pass" +else + printf '❌ shell-hook 测试存在失败:%d 通过,%d 失败。\n' "$pass" "$fail" + exit 1 +fi