create-new-feature.sh 9.53 KB
#!/usr/bin/env bash

set -e

JSON_MODE=false
SHORT_NAME=""
BRANCH_NUMBER=""
ARGS=()
i=1
while [ $i -le $# ]; do
    arg="${!i}"
    case "$arg" in
        --json)
            JSON_MODE=true
            ;;
        --short-name)
            if [ $((i + 1)) -gt $# ]; then
                echo '错误:--short-name 需要提供值' >&2
                exit 1
            fi
            i=$((i + 1))
            next_arg="${!i}"
            # 检查下一个参数是否为另一个选项(以 -- 开头)
            if [[ "$next_arg" == --* ]]; then
                echo '错误:--short-name 需要提供值' >&2
                exit 1
            fi
            SHORT_NAME="$next_arg"
            ;;
        --number)
            if [ $((i + 1)) -gt $# ]; then
                echo '错误:--number 需要提供值' >&2
                exit 1
            fi
            i=$((i + 1))
            next_arg="${!i}"
            if [[ "$next_arg" == --* ]]; then
                echo '错误:--number 需要提供值' >&2
                exit 1
            fi
            BRANCH_NUMBER="$next_arg"
            ;;
        --help|-h)
            echo "用法:$0 [--json] [--short-name <短名>] [--number N] <功能描述>"
            echo ""
            echo "选项:"
            echo "  --json              以 JSON 格式输出"
            echo "  --short-name <短名> 为分支提供自定义短名(2-4 个单词)"
            echo "  --number N          手动指定分支编号(覆盖自动检测)"
            echo "  --help, -h          显示帮助信息"
            echo ""
            echo "示例:"
            echo "  $0 '新增用户登录与鉴权能力' --short-name 'user-auth'"
            echo "  $0 '为 API 接入 OAuth2 登录' --number 5"
            exit 0
            ;;
        *)
            ARGS+=("$arg")
            ;;
    esac
    i=$((i + 1))
done

FEATURE_DESCRIPTION="${ARGS[*]}"
if [ -z "$FEATURE_DESCRIPTION" ]; then
    echo "用法:$0 [--json] [--short-name <短名>] [--number N] <功能描述>" >&2
    exit 1
fi

# 通过查找项目标记来定位仓库根目录
find_repo_root() {
    local dir="$1"
    while [ "$dir" != "/" ]; do
        if [ -d "$dir/.git" ] || [ -d "$dir/.specify" ]; then
            echo "$dir"
            return 0
        fi
        dir="$(dirname "$dir")"
    done
    return 1
}

# 从 specs 目录中获取最大编号
get_highest_from_specs() {
    local specs_dir="$1"
    local highest=0

    if [ -d "$specs_dir" ]; then
        for dir in "$specs_dir"/*; do
            [ -d "$dir" ] || continue
            dirname=$(basename "$dir")
            number=$(echo "$dirname" | grep -o '^[0-9]\+' || echo "0")
            number=$((10#$number))
            if [ "$number" -gt "$highest" ]; then
                highest=$number
            fi
        done
    fi

    echo "$highest"
}

# 从 git 分支中获取最大编号
get_highest_from_branches() {
    local highest=0

    # 获取所有分支(本地 + 远端)
    branches=$(git branch -a 2>/dev/null || echo "")

    if [ -n "$branches" ]; then
        while IFS= read -r branch; do
            # 清理分支名:移除前缀标记与远端前缀
            clean_branch=$(echo "$branch" | sed 's/^[* ]*//; s|^remotes/[^/]*/||')

            # 若分支符合 ###-*,则提取功能编号
            if echo "$clean_branch" | grep -q '^[0-9]\{3\}-'; then
                number=$(echo "$clean_branch" | grep -o '^[0-9]\{3\}' || echo "0")
                number=$((10#$number))
                if [ "$number" -gt "$highest" ]; then
                    highest=$number
                fi
            fi
        done <<< "$branches"
    fi

    echo "$highest"
}

# 检查现有分支(本地 + 远端)并返回下一个可用编号
check_existing_branches() {
    local specs_dir="$1"

    # 拉取所有远端以获取最新分支信息(无远端时忽略错误)
    git fetch --all --prune 2>/dev/null || true

    # 获取所有分支中的最大编号(不局限于匹配 short name)
    local highest_branch=$(get_highest_from_branches)

    # 获取所有 specs 中的最大编号(不局限于匹配 short name)
    local highest_spec=$(get_highest_from_specs "$specs_dir")

    # 两者取最大值
    local max_num=$highest_branch
    if [ "$highest_spec" -gt "$max_num" ]; then
        max_num=$highest_spec
    fi

    # 返回下一个编号
    echo $((max_num + 1))
}

# 清理并格式化分支名
clean_branch_name() {
    local name="$1"
    echo "$name" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/-\+/-/g' | sed 's/^-//' | sed 's/-$//'
}

# 解析仓库根目录:优先使用 git 信息;如不可用则回退为查找仓库标记,
# 以兼容使用 --no-git 初始化的仓库。
SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"

if git rev-parse --show-toplevel >/dev/null 2>&1; then
    REPO_ROOT=$(git rev-parse --show-toplevel)
    HAS_GIT=true
else
    REPO_ROOT="$(find_repo_root "$SCRIPT_DIR")"
    if [ -z "$REPO_ROOT" ]; then
        echo "错误:无法确定仓库根目录。请在仓库内运行本脚本。" >&2
        exit 1
    fi
    HAS_GIT=false
fi

cd "$REPO_ROOT"

SPECS_DIR="$REPO_ROOT/specs"
mkdir -p "$SPECS_DIR"

# 生成分支名:包含停用词过滤与长度过滤
generate_branch_name() {
    local description="$1"

    # 常见停用词(用于过滤)
    local stop_words="^(i|a|an|the|to|for|of|in|on|at|by|with|from|is|are|was|were|be|been|being|have|has|had|do|does|did|will|would|should|could|can|may|might|must|shall|this|that|these|those|my|your|our|their|want|need|add|get|set)$"

    # 转小写并按单词拆分
    local clean_name=$(echo "$description" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/ /g')

    # 过滤单词:移除停用词与长度 < 3 的词(除非在原描述中以大写出现,通常为缩写)
    local meaningful_words=()
    for word in $clean_name; do
        # 跳过空词
        [ -z "$word" ] && continue

        # 保留:不属于停用词,并且(长度 >= 3 或为可能的缩写)
        if ! echo "$word" | grep -qiE "$stop_words"; then
            if [ ${#word} -ge 3 ]; then
                meaningful_words+=("$word")
            elif echo "$description" | grep -q "\b${word^^}\b"; then
                # 原描述中若以大写出现则保留(很可能是缩写)
                meaningful_words+=("$word")
            fi
        fi
    done

    # 若有有效词,则取前 3-4 个
    if [ ${#meaningful_words[@]} -gt 0 ]; then
        local max_words=3
        if [ ${#meaningful_words[@]} -eq 4 ]; then max_words=4; fi

        local result=""
        local count=0
        for word in "${meaningful_words[@]}"; do
            if [ $count -ge $max_words ]; then break; fi
            if [ -n "$result" ]; then result="$result-"; fi
            result="$result$word"
            count=$((count + 1))
        done
        echo "$result"
    else
        # 若未找到有效词:回退为原始逻辑
        local cleaned=$(clean_branch_name "$description")
        echo "$cleaned" | tr '-' '\n' | grep -v '^$' | head -3 | tr '\n' '-' | sed 's/-$//'
    fi
}

# 生成分支名
if [ -n "$SHORT_NAME" ]; then
    # 使用用户提供的 short name,并做清理
    BRANCH_SUFFIX=$(clean_branch_name "$SHORT_NAME")
else
    # 根据描述智能过滤生成
    BRANCH_SUFFIX=$(generate_branch_name "$FEATURE_DESCRIPTION")
fi

# 确定分支编号
if [ -z "$BRANCH_NUMBER" ]; then
    if [ "$HAS_GIT" = true ]; then
        # 检查远端已有分支
        BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR")
    else
        # 回退为本地目录检查
        HIGHEST=$(get_highest_from_specs "$SPECS_DIR")
        BRANCH_NUMBER=$((HIGHEST + 1))
    fi
fi

# 强制按十进制解释,避免八进制转换(例如 010 在八进制会变为 8,但这里应是十进制 10)
FEATURE_NUM=$(printf "%03d" "$((10#$BRANCH_NUMBER))")
BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}"

# GitHub 对分支名有 244 字节限制:必要时校验并截断
MAX_BRANCH_LENGTH=244
if [ ${#BRANCH_NAME} -gt $MAX_BRANCH_LENGTH ]; then
    # 计算需要从后缀中截断的长度
    # 分支名包含:功能编号(3)+ 连字符(1)= 4 个字符
    MAX_SUFFIX_LENGTH=$((MAX_BRANCH_LENGTH - 4))

    # 尽量按单词边界截断后缀
    TRUNCATED_SUFFIX=$(echo "$BRANCH_SUFFIX" | cut -c1-$MAX_SUFFIX_LENGTH)
    # 若截断产生了末尾连字符则移除
    TRUNCATED_SUFFIX=$(echo "$TRUNCATED_SUFFIX" | sed 's/-$//')

    ORIGINAL_BRANCH_NAME="$BRANCH_NAME"
    BRANCH_NAME="${FEATURE_NUM}-${TRUNCATED_SUFFIX}"

    >&2 echo "[specify] Warning: 分支名超过 GitHub 的 244 字节限制"
    >&2 echo "[specify] 原始:$ORIGINAL_BRANCH_NAME${#ORIGINAL_BRANCH_NAME} 字节)"
    >&2 echo "[specify] 已截断为:$BRANCH_NAME${#BRANCH_NAME} 字节)"
fi

if [ "$HAS_GIT" = true ]; then
    git checkout -b "$BRANCH_NAME"
else
    >&2 echo "[specify] Warning: 未检测到 Git 仓库;已跳过创建分支 $BRANCH_NAME"
fi

FEATURE_DIR="$SPECS_DIR/$BRANCH_NAME"
mkdir -p "$FEATURE_DIR"

TEMPLATE="$REPO_ROOT/.specify/templates/spec-template.md"
SPEC_FILE="$FEATURE_DIR/spec.md"
if [ -f "$TEMPLATE" ]; then cp "$TEMPLATE" "$SPEC_FILE"; else touch "$SPEC_FILE"; fi

# 为当前会话设置 SPECIFY_FEATURE 环境变量
export SPECIFY_FEATURE="$BRANCH_NAME"

if $JSON_MODE; then
    printf '{"BRANCH_NAME":"%s","SPEC_FILE":"%s","FEATURE_NUM":"%s"}\n' "$BRANCH_NAME" "$SPEC_FILE" "$FEATURE_NUM"
else
    echo "BRANCH_NAME: $BRANCH_NAME"
    echo "SPEC_FILE: $SPEC_FILE"
    echo "FEATURE_NUM: $FEATURE_NUM"
    echo "已设置 SPECIFY_FEATURE 环境变量为:$BRANCH_NAME"
fi