SOTONO / ZSHCMDS

SOTONO / DOTFILES / ZSH

02 / 06

wt

移動 / 選択zsh/repo.zsh依存: fzf · wtp

ABOUT

worktree を選んで移動、作成、掃除まで。worktree 運用のハブコマンド。

現在のリポジトリの worktree を fzf で選んで cd する。サブコマンドで作成と掃除も担う。wt create は sotono/<slug> ブランチ(slug 省略時はタイムスタンプ)を、fzf で選んだベースブランチから wtp で作成して cd する。wt clean は親以外の linked worktree を 1 件ずつ確認しながら削除し、--node なら worktree は残して node_modules だけを消す。削除対象の中に自分がいる場合は先に親 worktree へ退避する。

USAGE

wt
wt create [slug]
wt clean [--node]

OPTIONS

create [slug]
ベースブランチを fzf で選び、sotono/<slug> の worktree を作成
clean
親以外の worktree を確認付きで削除
clean --node
worktree は残し node_modules のみ削除

EXAMPLES

$ wt

worktree を fzf で選んで cd

$ wt create fix-login

sotono/fix-login ブランチの worktree を作成

$ wt clean --node

各 worktree の node_modules だけ削除

DEMO

DEMO GIF SLOT

src/assets/gifs/wt.gif を追加するとここに表示されます

SOURCE

# y/N を読み、y/yes なら成功を返す内部関数
_wt_confirm() {
    local prompt="$1"
    local reply

    if [[ -n "$prompt" ]]; then
        printf '%s [y/N] ' "$prompt"
    else
        printf '[y/N] '
    fi
    if ! read -r reply; then
        echo
        return 1
    fi

    [[ "$reply" == [yY] || "$reply" == [yY][eE][sS] ]]
}

# cwd が対象 worktree 配下なら親 worktree へ移る内部関数
_wt_leave_worktree_if_inside() {
    local target_path="$1"
    local main_path="$2"

    if [[ "$PWD" == "$target_path" || "$PWD" == "$target_path"/* ]]; then
        cd "$main_path" || return 1
        echo "作業ディレクトリを親ワークツリーへ移動しました: $main_path"
    fi
}

# 親以外の linked worktree を確認付きで削除する。
# --node は削除せず、各 linked worktree 直下の node_modules だけ消す。
# usage: _wt_clean [--node]
_wt_clean() {
    local node_only=0

    while [[ $# -gt 0 ]]; do
        case "$1" in
            --node)
                node_only=1
                shift
                ;;
            -*)
                echo "unknown option: $1" >&2
                echo "usage: wt clean [--node]" >&2
                return 1
                ;;
            *)
                echo "usage: wt clean [--node]" >&2
                return 1
                ;;
        esac
    done

    local worktree_choices
    worktree_choices=$(_git_worktree_choices .) || return 1

    local main_line
    main_line=$(printf '%s\n' "$worktree_choices" | sed '/^$/d' | sed -n '1p')
    local main_path
    main_path=$(printf '%s\n' "$main_line" | cut -f2-)

    local linked_choices
    linked_choices=$(printf '%s\n' "$worktree_choices" | sed '/^$/d' | sed '1d')

    if [[ -z "$linked_choices" ]]; then
        echo "追加のワークツリーはありません"
        return 1
    fi

    local branch path
    local -a targets=()

    if (( node_only )); then
        echo "親以外のワークツリーの node_modules:"
        while IFS=$'\t' read -r branch path; do
            [[ -n "$path" ]] || continue
            if [[ -e "$path/node_modules" ]]; then
                targets+=("$branch"$'\t'"$path")
                printf '  %s\n    %s\n' "$branch" "$path/node_modules"
            fi
        done <<< "$linked_choices"

        if (( ${#targets[@]} == 0 )); then
            echo "削除する node_modules はありません"
            return 0
        fi

        echo
        if ! _wt_confirm "これらの node_modules を削除しますか?"; then
            echo "キャンセルしました"
            return 1
        fi

        local removed=0 failed=0
        for entry in "${targets[@]}"; do
            branch=$(printf '%s\n' "$entry" | cut -f1)
            path=$(printf '%s\n' "$entry" | cut -f2-)
            if rm -rf -- "$path/node_modules"; then
                echo "削除しました: $branch  $path/node_modules"
                removed=$((removed + 1))
            else
                echo "削除に失敗しました: $branch  $path/node_modules" >&2
                failed=$((failed + 1))
            fi
        done

        echo "完了: ${removed} 件削除${failed:+, ${failed} 件失敗}"
        (( failed == 0 ))
        return
    fi

    echo "親ワークツリー: $main_path"
    echo "削除候補:"
    while IFS=$'\t' read -r branch path; do
        [[ -n "$path" ]] || continue
        printf '  %s\n    %s\n' "$branch" "$path"
    done <<< "$linked_choices"
    echo

    local removed=0 skipped=0 failed=0
    while IFS=$'\t' read -r branch path; do
        [[ -n "$path" ]] || continue

        printf 'このワークツリーを削除しますか?\n  %s\n  %s\n' "$branch" "$path"
        if ! _wt_confirm ""; then
            echo "スキップしました: $branch"
            skipped=$((skipped + 1))
            echo
            continue
        fi

        if ! _wt_leave_worktree_if_inside "$path" "$main_path"; then
            echo "親ワークツリーへ移動できないためスキップします: $path" >&2
            failed=$((failed + 1))
            echo
            continue
        fi

        if git worktree remove --force --force -- "$path"; then
            echo "削除しました: $branch  $path"
            removed=$((removed + 1))
        else
            echo "削除に失敗しました: $branch  $path" >&2
            failed=$((failed + 1))
        fi
        echo
    done <<< "$linked_choices"

    echo "完了: ${removed} 件削除, ${skipped} 件スキップ${failed:+, ${failed} 件失敗}"
    (( failed == 0 ))
}

# 現在のリポジトリで新しい worktree を作って cd する
# usage: _wt_create [slug]
#   - slug 省略時は sotono/YYYYMMDDHHMMSS のブランチを作成
#   - slug 指定時は sotono/<slug> のブランチを作成
#   - fzf でベースブランチ(ローカル + リモート、更新順)を選択
_wt_create() {
    local slug="${1:-$(date +%Y%m%d%H%M%S)}"
    local new_branch="sotono/${slug}"

    if git show-ref --verify --quiet "refs/heads/${new_branch}"; then
        echo "ブランチが既に存在します: ${new_branch}"
        return 1
    fi

    local base_choice
    base_choice=$({
        git for-each-ref --sort=-committerdate refs/heads/ \
            --format='%(committerdate:relative)%09%(refname:short)%09%(refname:short)'
        git for-each-ref --sort=-committerdate refs/remotes/ \
            --format='%(committerdate:relative)%09%(refname:short)%09%(refname:short)' |
            grep -v '/HEAD$'
    } | awk -F '\t' '!seen[$2]++' |
        fzf --prompt='base branch> ' \
            --header='ベースブランチを選択 (更新順)' \
            --with-nth=1,2 --delimiter=$'\t')

    if [[ -z "$base_choice" ]]; then
        echo "キャンセルしました"
        return 1
    fi

    local base_branch
    base_branch=$(printf '%s\n' "$base_choice" | cut -f3-)

    local base_commit="$base_branch"
    if [[ "$base_branch" == */* ]] && git show-ref --verify --quiet "refs/remotes/${base_branch}"; then
        base_commit="$base_branch"
    fi

    echo "Creating worktree: ${new_branch} (from ${base_branch})"
    if ! wtp add -b "${new_branch}" "${base_commit}"; then
        echo "wtp add に失敗しました"
        return 1
    fi

    local worktree_path
    worktree_path=$(wtp cd "${new_branch}") || {
        echo "wtp cd でパスを取得できませんでした: ${new_branch}"
        return 1
    }

    cd "${worktree_path}" || return 1
    echo "${worktree_path}" >> "$REPOHIST_FILE"
    echo "${worktree_path}"
}

# 現在のリポジトリ内の worktree を fzf で選んで cd する(repo の第2段階と同じ UI)
# usage: wt
#        wt create [slug]
#        wt clean
#        wt clean --node
wt() {
    if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
        echo "このディレクトリはGitリポジトリではありません"
        return 1
    fi

    case "${1:-}" in
        create)
            shift
            _wt_create "$@"
            ;;
        clean)
            shift
            _wt_clean "$@"
            ;;
        "")
            local worktree_choices
            worktree_choices=$(_git_worktree_choices .)
            local worktree_count
            worktree_count=$(printf '%s\n' "$worktree_choices" | sed '/^$/d' | wc -l | tr -d ' ')

            if (( worktree_count <= 1 )); then
                echo "追加のワークツリーはありません"
                return 1
            fi

            local selected
            selected=$(printf '%s\n' "$worktree_choices" | fzf --prompt='worktree> ')
            if [[ -n "$selected" ]]; then
                cd "$(printf '%s\n' "$selected" | cut -f2-)"
            fi
            ;;
        *)
            echo "unknown option: $1" >&2
            echo "usage: wt" >&2
            echo "       wt create [slug]" >&2
            echo "       wt clean [--node]" >&2
            return 1
            ;;
    esac
}
zsh/repo.zsh