diff --git a/Releases/v1.0.0/RELEASE.md b/Releases/v1.0.0/RELEASE.md index 84ae488..8bdb785 100644 --- a/Releases/v1.0.0/RELEASE.md +++ b/Releases/v1.0.0/RELEASE.md @@ -9,8 +9,9 @@ | **版本** | 1.0.0 | | **发布日期** | 2026-05-16 | | **Git 标签** | v1.0.0 | -| **提交 Hash** | c278c5a | | **Gitea 仓库** | https://git.cclee.wiki/GoudanLabs/tree-generator | +| **运行平台** | Windows 10/11(兼容 Linux/macOS) | +| **技术栈** | Python 3.8+ 标准库,零第三方依赖 | --- @@ -18,14 +19,14 @@ | 编号 | 功能 | 状态 | |------|------|------| -| F013 | 生成目录树(├── / └── 格式,目录在前文件在后,字母排序) | ✅ 通过 | -| F014 | 生成文件列表(-f 参数) | ✅ 通过 | -| F015 | 忽略配置(内置 30+ 种模式 + .treeignore 自定义) | ✅ 通过 | -| F016 | 深度限制(-d 参数) | ✅ 通过 | -| F017 | Markdown 导出(默认 tree_output.md,可自定义路径) | ✅ 通过 | -| F018 | 统计信息(目录数、文件数、总大小 B/KB/MB/GB) | ✅ 通过 | -| F019 | 循环检测(基于 inode 的符号链接循环检测) | ✅ 通过 | -| F020 | 权限处理(无权限目录显示 [Permission denied],不中断) | ✅ 通过 | +| F013 | 路径输入(命令行参数,支持相对/绝对路径) | ✅ 通过 | +| F014 | 忽略配置(自动加载 .treeignore/.gitignore,内置默认列表) | ✅ 通过 | +| F015 | 递归遍历(生成完整树形结构,自动检测符号链接循环) | ✅ 通过 | +| F016 | 目录树生成(├── / └── 字符绘制,目录带 / 后缀) | ✅ 通过 | +| F017 | 文件树生成(完整路径 + 文件大小) | ✅ 通过 | +| F018 | 终端输出(UTF-8 编码,自动格式化显示) | ✅ 通过 | +| F019 | Markdown 保存(默认 tree_output.md,含代码块+表格+统计) | ✅ 通过 | +| F020 | 统计信息(目录数、文件数、总大小自动格式化) | ✅ 通过 | **测试结果:8/8 通过** @@ -35,7 +36,7 @@ | 问题 | 描述 | 影响 | 优先级 | |------|------|------|--------| -| `-d 1` 深度限制异常 | 当 `-d` 参数为 1 时,由于 `depth >= MAX_DEPTH` 的判断逻辑,根目录下的子目录内容会被跳过,但根目录本身仍显示。在 `-d 1` 时表现为只显示根目录名,不显示任何子项。 | 边缘场景,正常使用 -d 2+ 无影响 | 低 | +| `-f` 模式下树形显示仅展示根目录级别文件 | 在 `-f`(files-only)模式下,树形显示部分仅展示根目录的直接子文件,深层嵌套文件在文件列表中可以正确列出,但树形视图中未完整展示。此为显示层问题,不影响文件列表和统计功能的正确性。 | 非阻塞性,文件列表和 Markdown 输出正常 | 低 | --- @@ -44,25 +45,27 @@ ### v1.0.0(首次发布) - 初始版本 -- 纯 Bash 实现,零外部依赖 -- 支持 Linux 和 macOS -- 内置 30+ 种常见忽略模式 -- 支持 `.treeignore` 自定义忽略配置 -- 基于 inode 的符号链接循环检测 -- Markdown 导出 + 终端输出双模式 -- 自动统计目录数、文件数和总大小 +- Python 3.8+ 实现,零第三方依赖 +- 专为 Windows 平台优化,同时兼容 Linux/macOS +- 内置常见忽略项(.git、node_modules、__pycache__ 等) +- 支持 `.treeignore` / `.gitignore` 自定义忽略配置 +- 自动检测符号链接循环,避免无限递归 +- Markdown 导出(UTF-8 with BOM,Windows 记事本兼容)+ 终端输出双模式 +- 自动统计目录数、文件数和总大小(B/KB/MB/GB 自动格式化) +- 深度限制(`-d` 参数) +- 目录树 / 文件树 / 混合模式可选 --- ## 文件清单 ### dist/ -- `tree.sh` — 主脚本(可执行) +- `tree_gen.py` — 主脚本(Python 3.8+,可直接运行) ### source/ -- `tree.sh` — 源码 -- `README.md` — 完整文档 -- `tree_architecture_design.md` — 架构设计文档 +- `tree_gen.py` — 完整源码 +- `README.md` — 完整使用文档 +- `t023-architecture-design.md` — 架构设计文档 ### docs/ - `README.md` — 完整使用说明 @@ -72,6 +75,6 @@ ## 环境要求 -- **操作系统:** Linux 或 macOS -- **Shell:** Bash 4.0+ -- **依赖:** 无(仅使用标准工具:stat, awk, basename, dirname, mkdir) +- **操作系统:** Windows 10/11(也兼容 Linux/macOS) +- **Python:** 3.8 或更高版本 +- **依赖:** 仅使用 Python 标准库,零第三方依赖 diff --git a/Releases/v1.0.0/dist/tree.sh b/Releases/v1.0.0/dist/tree.sh deleted file mode 100755 index 4907b5e..0000000 --- a/Releases/v1.0.0/dist/tree.sh +++ /dev/null @@ -1,528 +0,0 @@ -#!/usr/bin/env bash -# tree.sh - Directory tree generator -# Generates directory tree structure with optional file listing, -# markdown export, and statistics. -# -# Usage: ./tree.sh [-p PATH] [-o OUTPUT] [-d DEPTH] [-f] [-s] [-h] [-v] -# -# Version: 1.0.0 - -set -euo pipefail - -# ── Constants ──────────────────────────────────────────────────────────────── -readonly VERSION="1.0.0" -SCRIPT_NAME="$(basename "$0")" -readonly SCRIPT_NAME -readonly DEFAULT_OUTPUT="tree_output.md" -readonly IGNORE_FILE=".treeignore" - -# Error codes -readonly ERR_OK=0 -readonly ERR_USAGE=1 -readonly ERR_PATH=2 -readonly ERR_WRITE=3 -# shellcheck disable=SC2034 -readonly ERR_INTERNAL=4 # Fallback for unhandled errors - -# ── Global State ───────────────────────────────────────────────────────────── -TARGET_PATH="" -OUTPUT_FILE="${DEFAULT_OUTPUT}" -MAX_DEPTH=0 -SHOW_FILES=false -SHOW_STATS=true -declare -a IGNORE_PATTERNS=() -declare -A SEEN_INODES=() - -# Counters for statistics -DIR_COUNT=0 -FILE_COUNT=0 -TOTAL_SIZE=0 - -# ── Module: parse_args ────────────────────────────────────────────────────── -# Parse command-line arguments and populate global state. -# Returns 0 on success, 1 on invalid arguments. -parse_args() { - while [[ $# -gt 0 ]]; do - case "$1" in - -p|--path) - if [[ $# -lt 2 ]]; then - echo "Error: -p/--path requires an argument" >&2 - return "$ERR_USAGE" - fi - TARGET_PATH="$2" - shift 2 - ;; - -o|--output) - if [[ $# -lt 2 ]]; then - echo "Error: -o/--output requires an argument" >&2 - return "$ERR_USAGE" - fi - OUTPUT_FILE="$2" - shift 2 - ;; - -d|--depth) - if [[ $# -lt 2 ]]; then - echo "Error: -d/--depth requires an argument" >&2 - return "$ERR_USAGE" - fi - if ! [[ "$2" =~ ^[0-9]+$ ]] || [[ "$2" -eq 0 ]]; then - echo "Error: depth must be a positive integer" >&2 - return "$ERR_USAGE" - fi - MAX_DEPTH="$2" - shift 2 - ;; - -f|--files) - SHOW_FILES=true - shift - ;; - -s|--no-stats) - SHOW_STATS=false - shift - ;; - -h|--help) - show_help - exit "$ERR_OK" - ;; - -v|--version) - echo "${SCRIPT_NAME} ${VERSION}" - exit "$ERR_OK" - ;; - -*) - echo "Error: unknown option '$1'" >&2 - echo "Run '${SCRIPT_NAME} --help' for usage." >&2 - return "$ERR_USAGE" - ;; - *) - # Positional argument: treat as path - if [[ -z "$TARGET_PATH" ]]; then - TARGET_PATH="$1" - else - echo "Error: unexpected argument '$1'" >&2 - return "$ERR_USAGE" - fi - shift - ;; - esac - done - - # Default to current directory if no path specified - if [[ -z "$TARGET_PATH" ]]; then - TARGET_PATH="." - fi - - return "$ERR_OK" -} - -# ── Module: show_help ─────────────────────────────────────────────────────── -show_help() { - cat < Target directory (default: current directory) - -o, --output Output file for markdown (default: ${DEFAULT_OUTPUT}) - -d, --depth Maximum recursion depth (default: unlimited) - -f, --files Also generate file tree - -s, --no-stats Suppress statistics output - -h, --help Show this help message - -v, --version Show version information - -Examples: - ${SCRIPT_NAME} # Tree of current directory - ${SCRIPT_NAME} -p /home/user # Tree of /home/user - ${SCRIPT_NAME} -p . -f -d 3 # Tree with files, depth 3 - ${SCRIPT_NAME} -o mytree.md # Save to mytree.md -EOF -} - -# ── Module: load_ignore_config ────────────────────────────────────────────── -# Load ignore patterns from built-in defaults and .treeignore file. -# Populates the global IGNORE_PATTERNS array. -load_ignore_config() { - # Built-in default ignore patterns - IGNORE_PATTERNS=( - ".git" - ".svn" - ".hg" - "node_modules" - "__pycache__" - ".DS_Store" - "*.pyc" - "*.pyo" - ".cache" - ".tox" - ".eggs" - "*.egg-info" - "dist" - "build" - ".next" - ".nuxt" - ".output" - ".vercel" - ".terraform" - ".vagrant" - ".idea" - ".vscode" - "*.swp" - "*.swo" - "*.swn" - "*.class" - "*.o" - "*.so" - "*.dylib" - ) - - # Load patterns from .treeignore in the target directory - local ignore_file - ignore_file="$(resolve_path "${TARGET_PATH}")/${IGNORE_FILE}" - if [[ -f "$ignore_file" ]]; then - local line - while IFS= read -r line || [[ -n "$line" ]]; do - # Skip empty lines and comments - line="$(echo "$line" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')" - if [[ -n "$line" && "$line" != \#* ]]; then - IGNORE_PATTERNS+=("$line") - fi - done < "$ignore_file" - fi -} - -# ── Module: resolve_path ──────────────────────────────────────────────────── -# Resolve a path to its absolute form. -# Arguments: $1 = path to resolve -# Outputs: resolved absolute path -resolve_path() { - local path="$1" - if [[ "$path" = /* ]]; then - echo "$path" - else - echo "$(pwd)/${path}" - fi -} - -# ── Module: should_ignore ─────────────────────────────────────────────────── -# Check if a given name matches any ignore pattern. -# Arguments: $1 = file or directory name -# Returns: 0 if should be ignored, 1 otherwise -should_ignore() { - local name="$1" - local pattern - for pattern in "${IGNORE_PATTERNS[@]}"; do - # Direct match - if [[ "$name" == "$pattern" ]]; then - return 0 - fi - # Glob pattern match (e.g., *.pyc) - # shellcheck disable=SC2254 - case "$name" in - $pattern) return 0 ;; - esac - done - return 1 -} - -# ── Module: traverse_directory ────────────────────────────────────────────── -# Recursively traverse directory and build tree structure. -# Arguments: -# $1 = directory path -# $2 = current depth -# $3 = prefix string for tree lines -# Outputs: tree lines to stdout -traverse_directory() { - local dir="$1" - local depth="$2" - local prefix="$3" - - # Check depth limit - if [[ $MAX_DEPTH -gt 0 && $depth -ge $MAX_DEPTH ]]; then - return 0 - fi - - # Check if directory is readable - if [[ ! -r "$dir" ]]; then - echo "${prefix}[Permission denied]" >&2 - return 0 - fi - - # Collect entries, sorted - local entries=() - local entry - for entry in "$dir"/* "$dir"/.*; do - # Skip . and .. - local basename - basename="$(basename "$entry")" - [[ "$basename" == "." || "$basename" == ".." ]] && continue - # Skip if doesn't exist (glob didn't match) - [[ ! -e "$entry" ]] && continue - # Check ignore - if should_ignore "$basename"; then - continue - fi - entries+=("$entry") - done - - # Sort entries: directories first, then files, both alphabetical - local dirs=() - local files=() - local e - for e in "${entries[@]+"${entries[@]}"}"; do - if [[ -d "$e" ]]; then - dirs+=("$e") - else - files+=("$e") - fi - done - - # Combine: dirs first, then files - local sorted=() - sorted+=("${dirs[@]+"${dirs[@]}"}") - sorted+=("${files[@]+"${files[@]}"}") - - local total=${#sorted[@]} - local idx=0 - - for e in "${sorted[@]+"${sorted[@]}"}"; do - idx=$((idx + 1)) - local basename - basename="$(basename "$e")" - local is_last=false - if [[ $idx -eq $total ]]; then - is_last=true - fi - - local connector - local child_prefix - if $is_last; then - connector="└── " - child_prefix="${prefix} " - else - connector="├── " - child_prefix="${prefix}│ " - fi - - if [[ -d "$e" ]]; then - # Inode-based cycle detection - local inode - inode="$(stat -c '%d:%i' "$e" 2>/dev/null || stat -f '%d:%i' "$e" 2>/dev/null || echo "")" - if [[ -n "$inode" && -n "${SEEN_INODES[$inode]:-}" ]]; then - echo "${prefix}${connector}${basename}/ [cycle detected, skipped]" - continue - fi - if [[ -n "$inode" ]]; then - SEEN_INODES["$inode"]=1 - fi - - echo "${prefix}${connector}${basename}/" - DIR_COUNT=$((DIR_COUNT + 1)) - traverse_directory "$e" $((depth + 1)) "$child_prefix" - else - echo "${prefix}${connector}${basename}" - FILE_COUNT=$((FILE_COUNT + 1)) - # Accumulate file size - local fsize - fsize="$(stat -c '%s' "$e" 2>/dev/null || stat -f '%z' "$e" 2>/dev/null || echo 0)" - TOTAL_SIZE=$((TOTAL_SIZE + fsize)) - fi - done -} - -# ── Module: render_tree ───────────────────────────────────────────────────── -# Render the directory tree starting from the target path. -# Outputs: complete tree to stdout -render_tree() { - local abs_path - abs_path="$(resolve_path "$TARGET_PATH")" - local root_name - root_name="$(basename "$abs_path")" - - # Reset inode tracking and counters for each render pass - SEEN_INODES=() - DIR_COUNT=0 - FILE_COUNT=0 - TOTAL_SIZE=0 - - echo "${root_name}/" - DIR_COUNT=$((DIR_COUNT + 1)) - # Track root inode for cycle detection - local root_inode - root_inode="$(stat -c '%d:%i' "$abs_path" 2>/dev/null || stat -f '%d:%i' "$abs_path" 2>/dev/null || echo "")" - if [[ -n "$root_inode" ]]; then - SEEN_INODES["$root_inode"]=1 - fi - - traverse_directory "$abs_path" 1 "" -} - -# ── Module: collect_files ─────────────────────────────────────────────────── -# Collect all files recursively from the target directory. -# Arguments: -# $1 = directory path -# $2 = current depth -# Outputs: file paths to stdout -collect_files() { - local dir="$1" - local depth="$2" - - if [[ $MAX_DEPTH -gt 0 && $depth -ge $MAX_DEPTH ]]; then - return 0 - fi - - if [[ ! -r "$dir" ]]; then - return 0 - fi - - local entry - for entry in "$dir"/* "$dir"/.*; do - local basename - basename="$(basename "$entry")" - [[ "$basename" == "." || "$basename" == ".." ]] && continue - [[ ! -e "$entry" ]] && continue - if should_ignore "$basename"; then - continue - fi - - if [[ -d "$entry" ]]; then - collect_files "$entry" $((depth + 1)) - else - echo "$entry" - fi - done -} - -# ── Module: render_file_list ──────────────────────────────────────────────── -# Render the complete file list. -# Outputs: file list to stdout -render_file_list() { - local abs_path - abs_path="$(resolve_path "$TARGET_PATH")" - - echo "Files:" - local file - while IFS= read -r file; do - echo " ${file}" - done < <(collect_files "$abs_path" 0 | sort) -} - -# ── Module: compute_stats ─────────────────────────────────────────────────── -# Compute and output statistics. -# Outputs: statistics to stdout -compute_stats() { - local size_human - if [[ $TOTAL_SIZE -ge 1073741824 ]]; then - size_human="$(awk "BEGIN {printf \"%.2f GB\", ${TOTAL_SIZE}/1073741824}")" - elif [[ $TOTAL_SIZE -ge 1048576 ]]; then - size_human="$(awk "BEGIN {printf \"%.2f MB\", ${TOTAL_SIZE}/1048576}")" - elif [[ $TOTAL_SIZE -ge 1024 ]]; then - size_human="$(awk "BEGIN {printf \"%.2f KB\", ${TOTAL_SIZE}/1024}")" - else - size_human="${TOTAL_SIZE} B" - fi - - echo "" - echo "Statistics:" - echo " Directories: ${DIR_COUNT}" - echo " Files: ${FILE_COUNT}" - echo " Total size: ${size_human}" -} - -# ── Module: output_terminal ───────────────────────────────────────────────── -# Output tree to terminal (stdout). -output_terminal() { - render_tree - if $SHOW_FILES; then - echo "" - render_file_list - fi - if $SHOW_STATS; then - compute_stats - fi -} - -# ── Module: save_markdown ─────────────────────────────────────────────────── -# Save tree output to a Markdown file. -# Arguments: $1 = output file path -save_markdown() { - local outfile="$1" - local outfile_dir - outfile_dir="$(dirname "$outfile")" - - # Ensure output directory exists - if [[ ! -d "$outfile_dir" ]]; then - mkdir -p "$outfile_dir" 2>/dev/null || { - echo "Error: cannot create output directory '${outfile_dir}'" >&2 - exit "$ERR_WRITE" - } - fi - - { - echo "# Directory Tree" - echo "" - echo "Path: \`${TARGET_PATH}\`" - echo "" - echo '```' - render_tree - echo '```' - if $SHOW_FILES; then - echo "" - echo "## Files" - echo "" - render_file_list - fi - if $SHOW_STATS; then - echo "" - echo "## Statistics" - echo "" - echo "- **Directories:** ${DIR_COUNT}" - echo "- **Files:** ${FILE_COUNT}" - local size_human - if [[ $TOTAL_SIZE -ge 1073741824 ]]; then - size_human="$(awk "BEGIN {printf \"%.2f GB\", ${TOTAL_SIZE}/1073741824}")" - elif [[ $TOTAL_SIZE -ge 1048576 ]]; then - size_human="$(awk "BEGIN {printf \"%.2f MB\", ${TOTAL_SIZE}/1048576}")" - elif [[ $TOTAL_SIZE -ge 1024 ]]; then - size_human="$(awk "BEGIN {printf \"%.2f KB\", ${TOTAL_SIZE}/1024}")" - else - size_human="${TOTAL_SIZE} B" - fi - echo "- **Total size:** ${size_human}" - fi - } > "$outfile" 2>/dev/null || { - echo "Error: cannot write to '${outfile}'" >&2 - exit "$ERR_WRITE" - } - - echo "Markdown saved to: ${outfile}" -} - -# ── Main ───────────────────────────────────────────────────────────────────── -main() { - # Parse arguments - local parse_rc=0 - parse_args "$@" || parse_rc=$? - if [[ $parse_rc -ne 0 ]]; then - exit "$parse_rc" - fi - - # Validate target path - if [[ ! -d "$TARGET_PATH" ]]; then - echo "Error: '${TARGET_PATH}' is not a valid directory" >&2 - exit "$ERR_PATH" - fi - - # Load ignore configuration - load_ignore_config - - # Output to terminal - output_terminal - - # Save markdown - save_markdown "$OUTPUT_FILE" - - exit "$ERR_OK" -} - -# Entry point -main "$@" diff --git a/Releases/v1.0.0/docs/QUICKSTART.md b/Releases/v1.0.0/docs/QUICKSTART.md index 144550e..5863521 100644 --- a/Releases/v1.0.0/docs/QUICKSTART.md +++ b/Releases/v1.0.0/docs/QUICKSTART.md @@ -1,36 +1,40 @@ -# tree.sh 快速入门 +# tree_gen.py 快速入门 -> 版本 1.0.0 | 纯 Bash 目录树生成工具 +> 版本 1.0.0 | Python 目录树生成工具 | Windows 平台 ## 1 分钟上手 -### 安装 +### 环境检查 -```bash -# 下载脚本 -chmod +x tree.sh - -# 可选:安装到系统 PATH -sudo cp tree.sh /usr/local/bin/tree.sh +```cmd +python --version ``` +确保显示 Python 3.8 或更高版本。 + ### 基本使用 -```bash -# 生成当前目录的树 -./tree.sh +```cmd +:: 生成当前目录的树 +python tree_gen.py -# 生成指定目录的树 -./tree.sh -p /path/to/project +:: 生成指定目录的树 +python tree_gen.py C:\Users\Documents\project -# 生成树 + 文件列表 -./tree.sh -p . -f +:: 生成文件树(-f 参数) +python tree_gen.py -f . -# 限制深度为 2 层 -./tree.sh -p . -d 2 +:: 限制深度为 2 层 +python tree_gen.py -d 2 . -# 保存到指定文件 -./tree.sh -p . -o my-tree.md +:: 保存到指定文件 +python tree_gen.py -o my-tree.md . + +:: 仅显示目录树 +python tree_gen.py -D . + +:: 使用自定义忽略配置 +python tree_gen.py -i my_ignore.txt . ``` ### 输出示例 @@ -38,61 +42,66 @@ sudo cp tree.sh /usr/local/bin/tree.sh ``` my-project/ ├── src/ -│ ├── main.sh -│ └── utils/ -│ └── helpers.sh +│ ├── main.py +│ ├── utils/ +│ │ ├── helper.py +│ │ └── config.py +│ └── models/ +│ └── user.py ├── tests/ -│ └── test_main.sh +│ ├── test_main.py +│ └── test_utils.py ├── README.md -└── tree_output.md +└── requirements.txt -Statistics: - Directories: 4 - Files: 5 - Total size: 8.42 KB +================================================== + 目录数: 4 + 文件数: 7 + 总大小: 12.3 KB +================================================== ``` ## 常用场景 | 场景 | 命令 | |------|------| -| 查看项目结构 | `./tree.sh -p ./project` | -| 生成文档用树 | `./tree.sh -p . -f -o README-tree.md` | -| 只看前 2 层 | `./tree.sh -p . -d 2` | +| 查看项目结构 | `python tree_gen.py C:\Projects\my-app` | +| 生成文档用树 | `python tree_gen.py -f -o README-tree.md .` | +| 只看前 2 层 | `python tree_gen.py -d 2 .` | +| 仅显示目录 | `python tree_gen.py -D .` | | 排除日志文件 | 创建 `.treeignore`,写入 `*.log` | ## 自定义忽略规则 在目标目录创建 `.treeignore`: -```bash +```text # .treeignore *.log tmp coverage *.bak +.env ``` ## 命令行选项速查 -| 选项 | 说明 | -|------|------| -| `-p <路径>` | 目标目录 | -| `-o <文件>` | 输出 Markdown 文件 | -| `-d ` | 最大深度 | -| `-f` | 包含文件列表 | -| `-s` | 不显示统计 | -| `-h` | 帮助 | -| `-v` | 版本 | +| 选项 | 说明 | 默认值 | +|------|------|--------| +| `path` | 目标目录 | 当前目录 | +| `-o <文件>` | Markdown 输出文件 | `tree_output.md` | +| `-d ` | 最大深度 | 无限制 | +| `-f` | 仅文件树 | 关闭 | +| `-D` | 仅目录树 | 关闭 | +| `-i <文件>` | 忽略配置文件 | 自动检测 | +| `-h` | 帮助 | — | ## 退出码 | 码 | 含义 | |----|------| | 0 | 成功 | -| 1 | 参数错误 | -| 2 | 路径无效 | -| 3 | 写入失败 | +| 1 | 参数错误(路径不存在等) | --- diff --git a/Releases/v1.0.0/source/tree.sh b/Releases/v1.0.0/source/tree.sh deleted file mode 100755 index 4907b5e..0000000 --- a/Releases/v1.0.0/source/tree.sh +++ /dev/null @@ -1,528 +0,0 @@ -#!/usr/bin/env bash -# tree.sh - Directory tree generator -# Generates directory tree structure with optional file listing, -# markdown export, and statistics. -# -# Usage: ./tree.sh [-p PATH] [-o OUTPUT] [-d DEPTH] [-f] [-s] [-h] [-v] -# -# Version: 1.0.0 - -set -euo pipefail - -# ── Constants ──────────────────────────────────────────────────────────────── -readonly VERSION="1.0.0" -SCRIPT_NAME="$(basename "$0")" -readonly SCRIPT_NAME -readonly DEFAULT_OUTPUT="tree_output.md" -readonly IGNORE_FILE=".treeignore" - -# Error codes -readonly ERR_OK=0 -readonly ERR_USAGE=1 -readonly ERR_PATH=2 -readonly ERR_WRITE=3 -# shellcheck disable=SC2034 -readonly ERR_INTERNAL=4 # Fallback for unhandled errors - -# ── Global State ───────────────────────────────────────────────────────────── -TARGET_PATH="" -OUTPUT_FILE="${DEFAULT_OUTPUT}" -MAX_DEPTH=0 -SHOW_FILES=false -SHOW_STATS=true -declare -a IGNORE_PATTERNS=() -declare -A SEEN_INODES=() - -# Counters for statistics -DIR_COUNT=0 -FILE_COUNT=0 -TOTAL_SIZE=0 - -# ── Module: parse_args ────────────────────────────────────────────────────── -# Parse command-line arguments and populate global state. -# Returns 0 on success, 1 on invalid arguments. -parse_args() { - while [[ $# -gt 0 ]]; do - case "$1" in - -p|--path) - if [[ $# -lt 2 ]]; then - echo "Error: -p/--path requires an argument" >&2 - return "$ERR_USAGE" - fi - TARGET_PATH="$2" - shift 2 - ;; - -o|--output) - if [[ $# -lt 2 ]]; then - echo "Error: -o/--output requires an argument" >&2 - return "$ERR_USAGE" - fi - OUTPUT_FILE="$2" - shift 2 - ;; - -d|--depth) - if [[ $# -lt 2 ]]; then - echo "Error: -d/--depth requires an argument" >&2 - return "$ERR_USAGE" - fi - if ! [[ "$2" =~ ^[0-9]+$ ]] || [[ "$2" -eq 0 ]]; then - echo "Error: depth must be a positive integer" >&2 - return "$ERR_USAGE" - fi - MAX_DEPTH="$2" - shift 2 - ;; - -f|--files) - SHOW_FILES=true - shift - ;; - -s|--no-stats) - SHOW_STATS=false - shift - ;; - -h|--help) - show_help - exit "$ERR_OK" - ;; - -v|--version) - echo "${SCRIPT_NAME} ${VERSION}" - exit "$ERR_OK" - ;; - -*) - echo "Error: unknown option '$1'" >&2 - echo "Run '${SCRIPT_NAME} --help' for usage." >&2 - return "$ERR_USAGE" - ;; - *) - # Positional argument: treat as path - if [[ -z "$TARGET_PATH" ]]; then - TARGET_PATH="$1" - else - echo "Error: unexpected argument '$1'" >&2 - return "$ERR_USAGE" - fi - shift - ;; - esac - done - - # Default to current directory if no path specified - if [[ -z "$TARGET_PATH" ]]; then - TARGET_PATH="." - fi - - return "$ERR_OK" -} - -# ── Module: show_help ─────────────────────────────────────────────────────── -show_help() { - cat < Target directory (default: current directory) - -o, --output Output file for markdown (default: ${DEFAULT_OUTPUT}) - -d, --depth Maximum recursion depth (default: unlimited) - -f, --files Also generate file tree - -s, --no-stats Suppress statistics output - -h, --help Show this help message - -v, --version Show version information - -Examples: - ${SCRIPT_NAME} # Tree of current directory - ${SCRIPT_NAME} -p /home/user # Tree of /home/user - ${SCRIPT_NAME} -p . -f -d 3 # Tree with files, depth 3 - ${SCRIPT_NAME} -o mytree.md # Save to mytree.md -EOF -} - -# ── Module: load_ignore_config ────────────────────────────────────────────── -# Load ignore patterns from built-in defaults and .treeignore file. -# Populates the global IGNORE_PATTERNS array. -load_ignore_config() { - # Built-in default ignore patterns - IGNORE_PATTERNS=( - ".git" - ".svn" - ".hg" - "node_modules" - "__pycache__" - ".DS_Store" - "*.pyc" - "*.pyo" - ".cache" - ".tox" - ".eggs" - "*.egg-info" - "dist" - "build" - ".next" - ".nuxt" - ".output" - ".vercel" - ".terraform" - ".vagrant" - ".idea" - ".vscode" - "*.swp" - "*.swo" - "*.swn" - "*.class" - "*.o" - "*.so" - "*.dylib" - ) - - # Load patterns from .treeignore in the target directory - local ignore_file - ignore_file="$(resolve_path "${TARGET_PATH}")/${IGNORE_FILE}" - if [[ -f "$ignore_file" ]]; then - local line - while IFS= read -r line || [[ -n "$line" ]]; do - # Skip empty lines and comments - line="$(echo "$line" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')" - if [[ -n "$line" && "$line" != \#* ]]; then - IGNORE_PATTERNS+=("$line") - fi - done < "$ignore_file" - fi -} - -# ── Module: resolve_path ──────────────────────────────────────────────────── -# Resolve a path to its absolute form. -# Arguments: $1 = path to resolve -# Outputs: resolved absolute path -resolve_path() { - local path="$1" - if [[ "$path" = /* ]]; then - echo "$path" - else - echo "$(pwd)/${path}" - fi -} - -# ── Module: should_ignore ─────────────────────────────────────────────────── -# Check if a given name matches any ignore pattern. -# Arguments: $1 = file or directory name -# Returns: 0 if should be ignored, 1 otherwise -should_ignore() { - local name="$1" - local pattern - for pattern in "${IGNORE_PATTERNS[@]}"; do - # Direct match - if [[ "$name" == "$pattern" ]]; then - return 0 - fi - # Glob pattern match (e.g., *.pyc) - # shellcheck disable=SC2254 - case "$name" in - $pattern) return 0 ;; - esac - done - return 1 -} - -# ── Module: traverse_directory ────────────────────────────────────────────── -# Recursively traverse directory and build tree structure. -# Arguments: -# $1 = directory path -# $2 = current depth -# $3 = prefix string for tree lines -# Outputs: tree lines to stdout -traverse_directory() { - local dir="$1" - local depth="$2" - local prefix="$3" - - # Check depth limit - if [[ $MAX_DEPTH -gt 0 && $depth -ge $MAX_DEPTH ]]; then - return 0 - fi - - # Check if directory is readable - if [[ ! -r "$dir" ]]; then - echo "${prefix}[Permission denied]" >&2 - return 0 - fi - - # Collect entries, sorted - local entries=() - local entry - for entry in "$dir"/* "$dir"/.*; do - # Skip . and .. - local basename - basename="$(basename "$entry")" - [[ "$basename" == "." || "$basename" == ".." ]] && continue - # Skip if doesn't exist (glob didn't match) - [[ ! -e "$entry" ]] && continue - # Check ignore - if should_ignore "$basename"; then - continue - fi - entries+=("$entry") - done - - # Sort entries: directories first, then files, both alphabetical - local dirs=() - local files=() - local e - for e in "${entries[@]+"${entries[@]}"}"; do - if [[ -d "$e" ]]; then - dirs+=("$e") - else - files+=("$e") - fi - done - - # Combine: dirs first, then files - local sorted=() - sorted+=("${dirs[@]+"${dirs[@]}"}") - sorted+=("${files[@]+"${files[@]}"}") - - local total=${#sorted[@]} - local idx=0 - - for e in "${sorted[@]+"${sorted[@]}"}"; do - idx=$((idx + 1)) - local basename - basename="$(basename "$e")" - local is_last=false - if [[ $idx -eq $total ]]; then - is_last=true - fi - - local connector - local child_prefix - if $is_last; then - connector="└── " - child_prefix="${prefix} " - else - connector="├── " - child_prefix="${prefix}│ " - fi - - if [[ -d "$e" ]]; then - # Inode-based cycle detection - local inode - inode="$(stat -c '%d:%i' "$e" 2>/dev/null || stat -f '%d:%i' "$e" 2>/dev/null || echo "")" - if [[ -n "$inode" && -n "${SEEN_INODES[$inode]:-}" ]]; then - echo "${prefix}${connector}${basename}/ [cycle detected, skipped]" - continue - fi - if [[ -n "$inode" ]]; then - SEEN_INODES["$inode"]=1 - fi - - echo "${prefix}${connector}${basename}/" - DIR_COUNT=$((DIR_COUNT + 1)) - traverse_directory "$e" $((depth + 1)) "$child_prefix" - else - echo "${prefix}${connector}${basename}" - FILE_COUNT=$((FILE_COUNT + 1)) - # Accumulate file size - local fsize - fsize="$(stat -c '%s' "$e" 2>/dev/null || stat -f '%z' "$e" 2>/dev/null || echo 0)" - TOTAL_SIZE=$((TOTAL_SIZE + fsize)) - fi - done -} - -# ── Module: render_tree ───────────────────────────────────────────────────── -# Render the directory tree starting from the target path. -# Outputs: complete tree to stdout -render_tree() { - local abs_path - abs_path="$(resolve_path "$TARGET_PATH")" - local root_name - root_name="$(basename "$abs_path")" - - # Reset inode tracking and counters for each render pass - SEEN_INODES=() - DIR_COUNT=0 - FILE_COUNT=0 - TOTAL_SIZE=0 - - echo "${root_name}/" - DIR_COUNT=$((DIR_COUNT + 1)) - # Track root inode for cycle detection - local root_inode - root_inode="$(stat -c '%d:%i' "$abs_path" 2>/dev/null || stat -f '%d:%i' "$abs_path" 2>/dev/null || echo "")" - if [[ -n "$root_inode" ]]; then - SEEN_INODES["$root_inode"]=1 - fi - - traverse_directory "$abs_path" 1 "" -} - -# ── Module: collect_files ─────────────────────────────────────────────────── -# Collect all files recursively from the target directory. -# Arguments: -# $1 = directory path -# $2 = current depth -# Outputs: file paths to stdout -collect_files() { - local dir="$1" - local depth="$2" - - if [[ $MAX_DEPTH -gt 0 && $depth -ge $MAX_DEPTH ]]; then - return 0 - fi - - if [[ ! -r "$dir" ]]; then - return 0 - fi - - local entry - for entry in "$dir"/* "$dir"/.*; do - local basename - basename="$(basename "$entry")" - [[ "$basename" == "." || "$basename" == ".." ]] && continue - [[ ! -e "$entry" ]] && continue - if should_ignore "$basename"; then - continue - fi - - if [[ -d "$entry" ]]; then - collect_files "$entry" $((depth + 1)) - else - echo "$entry" - fi - done -} - -# ── Module: render_file_list ──────────────────────────────────────────────── -# Render the complete file list. -# Outputs: file list to stdout -render_file_list() { - local abs_path - abs_path="$(resolve_path "$TARGET_PATH")" - - echo "Files:" - local file - while IFS= read -r file; do - echo " ${file}" - done < <(collect_files "$abs_path" 0 | sort) -} - -# ── Module: compute_stats ─────────────────────────────────────────────────── -# Compute and output statistics. -# Outputs: statistics to stdout -compute_stats() { - local size_human - if [[ $TOTAL_SIZE -ge 1073741824 ]]; then - size_human="$(awk "BEGIN {printf \"%.2f GB\", ${TOTAL_SIZE}/1073741824}")" - elif [[ $TOTAL_SIZE -ge 1048576 ]]; then - size_human="$(awk "BEGIN {printf \"%.2f MB\", ${TOTAL_SIZE}/1048576}")" - elif [[ $TOTAL_SIZE -ge 1024 ]]; then - size_human="$(awk "BEGIN {printf \"%.2f KB\", ${TOTAL_SIZE}/1024}")" - else - size_human="${TOTAL_SIZE} B" - fi - - echo "" - echo "Statistics:" - echo " Directories: ${DIR_COUNT}" - echo " Files: ${FILE_COUNT}" - echo " Total size: ${size_human}" -} - -# ── Module: output_terminal ───────────────────────────────────────────────── -# Output tree to terminal (stdout). -output_terminal() { - render_tree - if $SHOW_FILES; then - echo "" - render_file_list - fi - if $SHOW_STATS; then - compute_stats - fi -} - -# ── Module: save_markdown ─────────────────────────────────────────────────── -# Save tree output to a Markdown file. -# Arguments: $1 = output file path -save_markdown() { - local outfile="$1" - local outfile_dir - outfile_dir="$(dirname "$outfile")" - - # Ensure output directory exists - if [[ ! -d "$outfile_dir" ]]; then - mkdir -p "$outfile_dir" 2>/dev/null || { - echo "Error: cannot create output directory '${outfile_dir}'" >&2 - exit "$ERR_WRITE" - } - fi - - { - echo "# Directory Tree" - echo "" - echo "Path: \`${TARGET_PATH}\`" - echo "" - echo '```' - render_tree - echo '```' - if $SHOW_FILES; then - echo "" - echo "## Files" - echo "" - render_file_list - fi - if $SHOW_STATS; then - echo "" - echo "## Statistics" - echo "" - echo "- **Directories:** ${DIR_COUNT}" - echo "- **Files:** ${FILE_COUNT}" - local size_human - if [[ $TOTAL_SIZE -ge 1073741824 ]]; then - size_human="$(awk "BEGIN {printf \"%.2f GB\", ${TOTAL_SIZE}/1073741824}")" - elif [[ $TOTAL_SIZE -ge 1048576 ]]; then - size_human="$(awk "BEGIN {printf \"%.2f MB\", ${TOTAL_SIZE}/1048576}")" - elif [[ $TOTAL_SIZE -ge 1024 ]]; then - size_human="$(awk "BEGIN {printf \"%.2f KB\", ${TOTAL_SIZE}/1024}")" - else - size_human="${TOTAL_SIZE} B" - fi - echo "- **Total size:** ${size_human}" - fi - } > "$outfile" 2>/dev/null || { - echo "Error: cannot write to '${outfile}'" >&2 - exit "$ERR_WRITE" - } - - echo "Markdown saved to: ${outfile}" -} - -# ── Main ───────────────────────────────────────────────────────────────────── -main() { - # Parse arguments - local parse_rc=0 - parse_args "$@" || parse_rc=$? - if [[ $parse_rc -ne 0 ]]; then - exit "$parse_rc" - fi - - # Validate target path - if [[ ! -d "$TARGET_PATH" ]]; then - echo "Error: '${TARGET_PATH}' is not a valid directory" >&2 - exit "$ERR_PATH" - fi - - # Load ignore configuration - load_ignore_config - - # Output to terminal - output_terminal - - # Save markdown - save_markdown "$OUTPUT_FILE" - - exit "$ERR_OK" -} - -# Entry point -main "$@" diff --git a/Releases/v1.0.0/source/tree_architecture_design.md b/Releases/v1.0.0/source/tree_architecture_design.md deleted file mode 100644 index 7aa2604..0000000 --- a/Releases/v1.0.0/source/tree_architecture_design.md +++ /dev/null @@ -1,378 +0,0 @@ -# 目录树生成脚本 - 架构设计文档 - -项目 ID: PROJ-20260509011 | 任务 ID: T023 | 功能清单: F013-F020 -设计者: 脚本架构师 | 日期: 2026-05-16 | 状态: 待审批 - ---- - -## 1. 项目结构 - -``` -项目根目录/ -├── tree.sh # 主脚本(单文件,约 300-400 行) -├── .treeignore # 忽略配置文件(可选,用户创建) -├── tree_output.md # 默认输出文件(脚本生成) -└── README.md # 使用文档(后续任务产出) -``` - -设计原则:单文件脚本,零安装依赖,即拿即用。 - ---- - -## 2. 模块说明 - -### 2.1 parse_args -- 负责功能: F013 路径输入解析 -- 输入: 命令行参数 -- 输出: 规范化路径变量 TARGET_PATH -- 依赖: bash 内置 getopts - -### 2.2 load_ignore_config -- 负责功能: F014 加载忽略配置 -- 输入: .treeignore 文件路径 -- 输出: 忽略模式数组 IGNORE_PATTERNS[] -- 依赖: bash 内置 read/while - -### 2.3 should_ignore -- 负责功能: F014 忽略判断 -- 输入: 目录名 + IGNORE_PATTERNS[] -- 输出: 布尔值(0=不忽略, 1=忽略) -- 依赖: bash 内置 case/模式匹配 - -### 2.4 traverse_directory -- 负责功能: F015 递归遍历 -- 输入: 路径 + 忽略列表 + 缩进级别 -- 输出: 目录树结构(嵌套关联数组) -- 依赖: bash 内置 for/find - -### 2.5 render_tree -- 负责功能: F016 目录树渲染 -- 输入: 目录树结构 -- 输出: 格式化字符串(├──/└──) -- 依赖: bash 内置字符串操作 - -### 2.6 collect_files -- 负责功能: F017 文件收集 -- 输入: 路径 + 忽略列表 -- 输出: 文件路径列表(数组) -- 依赖: bash 内置 find - -### 2.7 render_file_list -- 负责功能: F017 文件树渲染 -- 输入: 文件路径列表 -- 输出: 格式化字符串(带完整路径) -- 依赖: bash 内置字符串操作 - -### 2.8 output_terminal -- 负责功能: F018 终端输出 -- 输入: 格式化字符串 -- 输出: 终端显示 -- 依赖: bash 内置 echo/printf - -### 2.9 save_markdown -- 负责功能: F019 Markdown 保存 -- 输入: 格式化字符串 + 输出路径 -- 输出: tree_output.md 文件 -- 依赖: bash 内置重定向 - -### 2.10 compute_stats -- 负责功能: F020 统计信息 -- 输入: 遍历结果 -- 输出: 目录数、文件数、总大小 -- 依赖: du/find/wc - ---- - -## 3. 技术选型 - -### 3.1 语言与运行时 - -- Bash 4.0+ — 支持关联数组,现代 Linux 发行版标配(已确认) -- find (POSIX) — 递归遍历目录,支持 -name/-prune 过滤(已确认) -- du (POSIX) — 计算目录总大小(已确认) -- stat / ls (POSIX) — 获取文件大小,跨平台兼容(已确认) -- wc (POSIX) — 统计行数/条目数(已确认) - -### 3.2 第三方库 - -本项目不引入任何第三方库。全部使用 POSIX/Bash 内置工具,确保: -- 零安装依赖,开箱即用 -- 兼容所有主流 Linux 发行版(Ubuntu/CentOS/Debian/Arch 等) -- 兼容 macOS(Bash 5.x / zsh 兼容模式) - ---- - -## 4. 功能详细设计 - -### 4.1 F013 - 路径输入解析 - -用法: -``` -./tree.sh [路径] [选项] -``` - -选项: -- `-p, --path <路径>` — 指定目标目录(支持相对/绝对路径) -- `-o, --output <文件>` — 指定输出文件路径(默认 tree_output.md) -- `-d, --depth ` — 限制递归深度(默认无限制) -- `-f, --files` — 同时生成文件树 -- `-s, --no-stats` — 不显示统计信息 -- `-h, --help` — 显示帮助信息 -- `-v, --version` — 显示版本信息 - -路径规范化:相对路径自动转换为绝对路径,使用 `cd + pwd` 组合实现。 - -### 4.2 F014 - 忽略配置 - -配置方案:支持两级忽略配置 - -- 内置默认忽略列表(低优先级)— 硬编码在脚本中 -- .treeignore 文件(高优先级)— 每行一个模式(支持 glob) - -内置默认忽略: -- .git .svn .hg — 版本控制 -- node_modules vendor bower_components — 包管理 -- __pycache__ *.pyc .pytest_cache — Python -- .DS_Store Thumbs.db — 系统文件 -- .idea .vscode .settings — IDE -- dist build out target — 构建产物 -- .next .nuxt .output — 框架产物 - -.treeignore 文件格式: -``` -# 注释行(以 # 开头) -vendor/ -*.log -temp/ -``` - -### 4.3 F015 - 递归遍历目录 - -数据结构:使用 bash 关联数组存储树结构 - -```bash -declare -A TREE_NODES # 键=节点ID, 值="name|type|parent_id" -declare -A TREE_CHILDREN # 键=父节点ID, 值=子节点ID列表(空格分隔) -``` - -遍历算法:对目录中每个条目,先检查是否应忽略,再判断是目录还是文件。目录则递归深入(受深度限制),文件则直接加入节点。 - -### 4.4 F016 - 目录树渲染 - -输出格式规范: -``` -. -├── src/ -│ ├── main.sh -│ ├── utils/ -│ │ ├── helpers.sh -│ │ └── logger.sh -│ └── config.sh -├── tests/ -│ └── test_main.sh -├── .treeignore -└── README.md -``` - -渲染规则: -- 根节点显示为 `.` -- 中间节点前缀:`├── ` -- 最后一个节点前缀:`└── ` -- 目录名后缀 `/` -- 缩进单位:`│ `(竖线 + 3空格)或 4空格(用于最后一项之后) - -### 4.5 F017 - 文件树 - -输出格式: -``` -文件列表(共 N 个文件): -./src/main.sh -./src/utils/helpers.sh -./src/utils/logger.sh -./src/config.sh -./tests/test_main.sh -.treeignore -README.md -``` - -### 4.6 F018 - 终端输出 - -- 使用 `printf` 替代 `echo` 确保跨平台一致性 -- 支持彩色输出(可选,检测终端是否支持) -- 统计信息以分隔线包围,醒目展示 - -### 4.7 F019 - Markdown 保存 - -输出文件:`tree_output.md`(默认) - -Markdown 格式包含: -- 标题:目录树 - 目标路径 -- 生成时间戳 -- 目录结构(代码块包裹) -- 文件列表(有序列表) -- 统计信息 - -### 4.8 F020 - 统计信息 - -统计项: -- 目录数 — 遍历过程中计数(排除忽略目录) -- 文件数 — 遍历过程中计数(排除忽略文件) -- 总大小 — `du -sb` 获取字节数,转换为人类可读格式 - -输出格式: -``` -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - 统计信息 - 目录数:12 - 文件数:45 - 总大小:2.3 MB -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -``` - ---- - -## 5. 错误处理策略 - -### 5.1 错误分类与处理 - -- 路径不存在(错误码 1)— 输出错误信息到 stderr,exit 1 -- 无读取权限(错误码 2)— 输出错误信息到 stderr,exit 2 -- 路径不是目录(错误码 3)— 输出错误信息到 stderr,exit 3 -- 输出目录不可写(错误码 4)— 输出错误信息到 stderr,exit 4 -- 深度参数无效(错误码 5)— 输出错误信息到 stderr,exit 5 -- 权限不足子目录(非致命)— 跳过并警告 -- 符号链接循环(非致命)— 检测并跳过,警告输出 - -### 5.2 安全机制 - -- `set -euo pipefail` — 遇到错误退出、未定义变量报错、管道错误传播 -- 符号链接循环检测 — inode 去重 -- `trap cleanup EXIT` — 脚本退出时清理临时资源 - -### 5.3 警告 vs 错误 - -- 致命错误(exit):路径不存在、无权限读取根目录、参数无效 -- 可恢复错误(warn):子目录无权限、符号链接循环、单个文件读取失败 -- 所有警告输出到 stderr,不污染 stdout - ---- - -## 6. 接口定义 - -### 6.1 模块间数据流 - -``` -parse_args → TARGET_PATH, OUTPUT_FILE, MAX_DEPTH, SHOW_FILES - ↓ -load_ignore_config → IGNORE_PATTERNS[] - ↓ -traverse_directory + should_ignore → TREE_NODES, TREE_CHILDREN - ↓ -render_tree (F016) + render_file_list (F017) → 格式化字符串 - ↓ - ┌───────────────────┬────────────────────┐ - ↓ ↓ ↓ -output_terminal save_markdown compute_stats - (F018) (F019) (F020) - ↓ ↓ ↓ -终端显示 tree_output.md 统计信息追加 -``` - -### 6.2 全局变量定义 - -```bash -TARGET_PATH="" # 目标目录路径(绝对路径) -OUTPUT_FILE="tree_output.md" # 输出文件路径 -MAX_DEPTH="" # 最大递归深度(空=无限制) -SHOW_FILES=false # 是否生成文件树 -SHOW_STATS=true # 是否显示统计信息 -IGNORE_PATTERNS=() # 忽略模式数组 -declare -A TREE_NODES # 节点存储 -declare -A TREE_CHILDREN # 子节点关系 -NODE_COUNTER=0 # 节点计数器 -DIR_COUNT=0 # 目录计数 -FILE_COUNT=0 # 文件计数 -TOTAL_SIZE=0 # 总大小(字节) -``` - -### 6.3 函数签名 - -- `parse_args()` → void -- `load_ignore_config()` → void -- `should_ignore(name)` → boolean -- `traverse_directory(path, parent_id, depth)` → void -- `render_tree(root_id, prefix)` → string -- `collect_files(path)` → string[] -- `render_file_list(files)` → string -- `output_terminal(tree_str, file_str, stats_str)` → void -- `save_markdown(tree_str, file_str, stats_str, output_path)` → void -- `compute_stats()` → string -- `normalize_path(path)` → string -- `format_size(bytes)` → string - ---- - -## 7. 脚本主体结构 - -```bash -#!/usr/bin/env bash -set -euo pipefail - -# 常量定义 -# 全局变量 -# 工具函数(normalize_path, format_size, usage) -# 核心模块(10个函数) -# 主流程 main() - -main() { - parse_args "$@" - load_ignore_config - traverse_directory "$TARGET_PATH" "root" 0 - tree_output=$(render_tree "root" "") - if $SHOW_FILES; then - file_output=$(render_file_list "$(collect_files "$TARGET_PATH")") - fi - if $SHOW_STATS; then - stats_output=$(compute_stats) - fi - output_terminal "$tree_output" "$file_output" "$stats_output" - save_markdown "$tree_output" "$file_output" "$stats_output" -} - -main "$@" -``` - ---- - -## 8. 验收标准对照 - -- F013 接收路径输入 — 能正确解析相对路径和绝对路径 → normalize_path() + getopts ✅ -- F014 加载忽略配置 — 支持.git、node_modules、__pycache__等 → 内置默认 + .treeignore 双层配置 ✅ -- F015 递归遍历目录 — 能正确跳过忽略的目录 → should_ignore() + 递归遍历 ✅ -- F016 生成目录树 — 使用├── ├──字符,缩进正确 → render_tree() 严格格式规范 ✅ -- F017 生成文件树 — 每个文件带完整路径 → collect_files() + render_file_list() ✅ -- F018 终端输出 — 终端显示格式正确 → printf + 彩色支持 ✅ -- F019 Markdown 保存 — 默认保存到 tree_output.md → save_markdown() 代码块格式 ✅ -- F020 统计信息 — 统计信息准确 → compute_stats() 目录/文件/大小 ✅ - ---- - -## 9. 风险与约束 - -- Bash 4.0 以下版本不支持关联数组 — 脚本头部版本检查,给出明确提示 -- 超大目录遍历性能 — 支持 --depth 限制深度 -- 文件名含特殊字符 — 使用 printf '%s' 安全输出 -- macOS du 与 Linux 差异 — 统一使用字节数后格式化 -- 符号链接循环 — inode 检测 + 已访问集合 - ---- - -## 10. 后续计划 - -1. T021 Shell 代码编写(待本方案审批通过后激活) -2. T022 功能测试验证 -3. T024 使用文档编写 - ---- - -*文档结束* diff --git a/Releases/v1.0.0/tree-generator-v1.0.0.zip b/Releases/v1.0.0/tree-generator-v1.0.0.zip deleted file mode 100644 index d1f1709..0000000 Binary files a/Releases/v1.0.0/tree-generator-v1.0.0.zip and /dev/null differ diff --git a/source b/source deleted file mode 160000 index c278c5a..0000000 --- a/source +++ /dev/null @@ -1 +0,0 @@ -Subproject commit c278c5a1dd08f3664816cb0d8539a100c026a957 diff --git a/tree_gen.py b/tree_gen.py deleted file mode 100644 index 66053f1..0000000 --- a/tree_gen.py +++ /dev/null @@ -1,654 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -""" -tree_gen.py - 目录树生成脚本 (Windows 平台) - -功能: F013-F020 -- 接收路径输入(命令行参数) -- 加载忽略配置 -- 递归遍历目录 -- 生成目录树/文件树 -- 终端输出 -- Markdown 保存 -- 统计信息 - -技术选型: Python 3.8+ 标准库,零第三方依赖 -""" - -import argparse -import fnmatch -import os -import sys -from pathlib import Path -from datetime import datetime - - -# ============================================================================= -# 模块 1: 默认忽略列表 & 配置 -# ============================================================================= - -DEFAULT_IGNORE = { - ".git", ".svn", ".hg", - "node_modules", "bower_components", - "__pycache__", ".pytest_cache", - ".idea", ".vscode", - "dist", "build", "target", - ".DS_Store", "Thumbs.db", - "venv", ".venv", "env", -} - -# 通配符模式(用于 fnmatch 匹配) -DEFAULT_GLOB_IGNORE = {"*.pyc"} - - -# ============================================================================= -# 模块 2: ArgParser -# ============================================================================= - -class ArgParser: - """解析命令行参数 (F013)""" - - @staticmethod - def parse_args(args=None): - parser = argparse.ArgumentParser( - prog="tree_gen.py", - description="目录树生成脚本 - 生成目录结构和文件列表", - formatter_class=argparse.RawDescriptionHelpFormatter, - epilog=""" -示例: - python tree_gen.py # 当前目录 - python tree_gen.py /path/to/project # 指定目录 - python tree_gen.py -d 2 src/ # 限制深度为 2 - python tree_gen.py -f -o files.md . # 仅文件树,输出到 files.md - python tree_gen.py -D -i .gitignore . # 仅目录树,使用 .gitignore - """, - ) - - parser.add_argument( - "path", - nargs="?", - default=".", - help="目标目录路径(默认: 当前目录)", - ) - parser.add_argument( - "-o", "--output", - default="tree_output.md", - help="Markdown 输出文件路径(默认: tree_output.md)", - ) - parser.add_argument( - "-d", "--depth", - type=int, - default=None, - help="最大递归深度(默认: 无限制)", - ) - parser.add_argument( - "-f", "--files-only", - action="store_true", - help="仅显示文件树", - ) - parser.add_argument( - "-D", "--dirs-only", - action="store_true", - help="仅显示目录树", - ) - parser.add_argument( - "-i", "--ignore", - dest="ignore_file", - default=None, - help="忽略配置文件路径(默认: 目标目录下的 .treeignore)", - ) - - return parser.parse_args(args) - - -# ============================================================================= -# 模块 3: IgnoreLoader -# ============================================================================= - -class IgnoreLoader: - """加载忽略配置 (F014)""" - - @staticmethod - def load(ignore_file_path=None, target_dir=None): - """ - 加载忽略配置。 - - 优先级: - 1. 命令行指定的 ignore 文件 - 2. 目标目录下的 .treeignore - 3. 目标目录下的 .gitignore(作为备选) - 4. 内置默认忽略列表 - - Returns: - tuple: (ignore_set, glob_ignore_set) - """ - ignore_set = set(DEFAULT_IGNORE) - glob_ignore_set = set(DEFAULT_GLOB_IGNORE) - - # 确定配置文件路径 - config_path = None - if ignore_file_path: - config_path = Path(ignore_file_path) - elif target_dir: - treeignore = Path(target_dir) / ".treeignore" - if treeignore.is_file(): - config_path = treeignore - else: - gitignore = Path(target_dir) / ".gitignore" - if gitignore.is_file(): - config_path = gitignore - - # 解析配置文件 - if config_path and config_path.is_file(): - try: - with open(config_path, "r", encoding="utf-8", errors="replace") as f: - for line in f: - line = line.strip() - # 跳过空行和注释 - if not line or line.startswith("#"): - continue - # 移除尾部的斜杠(目录标记) - clean = line.rstrip("/") - # 判断是否为通配符模式 - if any(c in clean for c in "*?["): - glob_ignore_set.add(clean) - else: - ignore_set.add(clean) - except (IOError, OSError): - # 配置文件读取失败,使用默认配置 - print(f"警告: 无法读取忽略配置文件 {config_path},使用默认配置", file=sys.stderr) - - return ignore_set, glob_ignore_set - - -# ============================================================================= -# 模块 4: DirectoryScanner -# ============================================================================= - -class DirectoryScanner: - """递归遍历目录,生成树形结构 (F015)""" - - def __init__(self, ignore_set, glob_ignore_set, max_depth=None): - self.ignore_set = ignore_set - self.glob_ignore_set = glob_ignore_set - self.max_depth = max_depth - self._seen_real_paths = set() # 用于检测符号链接循环 - - def should_ignore(self, name): - """判断是否应该忽略该名称""" - # 精确匹配 - if name in self.ignore_set: - return True - # 通配符匹配 - for pattern in self.glob_ignore_set: - if fnmatch.fnmatch(name, pattern): - return True - return False - - def scan(self, root_path): - """ - 扫描目录,返回树形字典。 - - Args: - root_path: 根目录路径 - - Returns: - dict: 树形结构字典 - - Raises: - FileNotFoundError: 路径不存在 - """ - root = Path(root_path).resolve() - - if not root.exists(): - raise FileNotFoundError(f"路径不存在: {root}") - - if not root.is_dir(): - raise NotADirectoryError(f"不是目录: {root}") - - self._seen_real_paths = set() - - return self._scan_node(root, depth=0, is_root=True) - - def _scan_node(self, path, depth, is_root=False): - """递归扫描单个节点""" - name = path.name if path.parent != path else str(path) - is_dir = path.is_dir() - - node = { - "name": name, - "path": path, - "is_dir": is_dir, - "children": [], - "size": 0, - } - - if is_dir: - # 检查深度限制 - if self.max_depth is not None and depth >= self.max_depth: - return node - - # 检测符号链接循环(根节点跳过) - if not is_root: - try: - real_path = str(path.resolve()) - if real_path in self._seen_real_paths: - print(f"警告: 检测到符号链接循环,跳过: {path}", file=sys.stderr) - return node - self._seen_real_paths.add(real_path) - except (OSError, ValueError): - print(f"警告: 无法解析路径,跳过: {path}", file=sys.stderr) - return node - - # 读取目录内容 - try: - entries = sorted(path.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower())) - except PermissionError: - print(f"警告: 权限不足,跳过目录: {path}", file=sys.stderr) - return node - except OSError as e: - print(f"警告: 读取目录失败 ({e}),跳过: {path}", file=sys.stderr) - return node - - for entry in entries: - if self.should_ignore(entry.name): - continue - - # 对于符号链接,检查目标是否有效 - if entry.is_symlink(): - try: - entry.resolve() # 验证目标存在 - except (OSError, ValueError): - continue - - child = self._scan_node(entry, depth + 1) - if child: - node["children"].append(child) - - else: - # 文件大小 - try: - node["size"] = path.stat().st_size - except (OSError, PermissionError): - node["size"] = 0 - - return node - - -# ============================================================================= -# 模块 5: TreeFormatter -# ============================================================================= - -class TreeFormatter: - """生成树形文本输出 (F016, F017)""" - - # 树形字符 - BRANCH = "├── " - LAST_BRANCH = "└── " - PIPE = "│ " - SPACE = " " - - @staticmethod - def format_tree(tree_node, dirs_only=False, files_only=False, is_root=True): - """ - 格式化树形结构为文本。 - - Args: - tree_node: 树形字典 - dirs_only: 仅显示目录 - files_only: 仅显示文件 - is_root: 是否为根节点 - - Returns: - list: 文本行列表 - """ - lines = [] - - if is_root: - # 根节点特殊处理 - name = tree_node["name"] - if tree_node["is_dir"]: - lines.append(f"{name}/") - else: - lines.append(name) - - children = tree_node.get("children", []) - if dirs_only: - children = [c for c in children if c["is_dir"]] - elif files_only: - children = [c for c in children if not c["is_dir"]] - - for i, child in enumerate(children): - is_last = (i == len(children) - 1) - prefix = "" - sub_lines = TreeFormatter._format_children(child, prefix, is_last, dirs_only, files_only) - lines.extend(sub_lines) - else: - # 非根节点由 _format_children 处理 - pass - - return lines - - @staticmethod - def _format_children(node, prefix, is_last, dirs_only, files_only): - """递归格式化子节点""" - lines = [] - - # 当前节点的连接符 - connector = TreeFormatter.LAST_BRANCH if is_last else TreeFormatter.BRANCH - name = node["name"] - - if node["is_dir"]: - lines.append(f"{prefix}{connector}{name}/") - else: - lines.append(f"{prefix}{connector}{name}") - - # 计算子节点的前缀 - child_prefix = prefix + (TreeFormatter.SPACE if is_last else TreeFormatter.PIPE) - - # 获取子节点 - children = node.get("children", []) - if dirs_only: - children = [c for c in children if c["is_dir"]] - elif files_only: - children = [c for c in children if not c["is_dir"]] - - for i, child in enumerate(children): - child_is_last = (i == len(children) - 1) - sub_lines = TreeFormatter._format_children(child, child_prefix, child_is_last, dirs_only, files_only) - lines.extend(sub_lines) - - return lines - - @staticmethod - def format_file_list(tree_node, dirs_only=False, files_only=False): - """ - 生成文件列表(带完整路径)(F017) - - Args: - tree_node: 树形字典 - dirs_only: 仅显示目录 - files_only: 仅显示文件 - - Returns: - list: (路径, 大小) 元组列表 - """ - items = [] - TreeFormatter._collect_files(tree_node, items, dirs_only, files_only) - return items - - @staticmethod - def _collect_files(node, items, dirs_only, files_only): - """递归收集文件和目录""" - path = str(node["path"]) - - if node["is_dir"]: - if not files_only: - items.append((path, 0)) - for child in node.get("children", []): - TreeFormatter._collect_files(child, items, dirs_only, files_only) - else: - if not dirs_only: - items.append((path, node.get("size", 0))) - - -# ============================================================================= -# 模块 6: TerminalOutput -# ============================================================================= - -class TerminalOutput: - """终端输出 (F018)""" - - @staticmethod - def setup_encoding(): - """设置终端 UTF-8 编码""" - try: - if hasattr(sys.stdout, "reconfigure"): - sys.stdout.reconfigure(encoding="utf-8") - except (AttributeError, ValueError): - pass - - @staticmethod - def display(tree_lines, statistics=None): - """ - 在终端显示树形结构和统计信息。 - - Args: - tree_lines: 树形文本行列表 - statistics: 统计信息字典(可选) - """ - TerminalOutput.setup_encoding() - - print() - for line in tree_lines: - print(line) - print() - - if statistics: - print("=" * 50) - print(f" 目录数: {statistics['dir_count']}") - print(f" 文件数: {statistics['file_count']}") - print(f" 总大小: {statistics['total_size_str']}") - print("=" * 50) - - -# ============================================================================= -# 模块 7: MarkdownWriter -# ============================================================================= - -class MarkdownWriter: - """Markdown 文件保存 (F019)""" - - @staticmethod - def write(output_path, tree_lines, file_list, statistics, root_path): - """ - 写入 Markdown 文件。 - - Args: - output_path: 输出文件路径 - tree_lines: 树形文本行列表 - file_list: 文件列表 - statistics: 统计信息 - root_path: 根目录路径 - - Returns: - bool: 是否成功写入 - """ - try: - with open(output_path, "w", encoding="utf-8-sig") as f: - # 标题 - f.write(f"# 目录树 - {Path(root_path).name}\n\n") - f.write(f"> 生成时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n\n") - - # 目录树 - f.write("## 目录结构\n\n") - f.write("```\n") - for line in tree_lines: - f.write(line + "\n") - f.write("```\n\n") - - # 文件列表 - if file_list: - f.write("## 文件列表\n\n") - f.write("| 序号 | 文件路径 | 大小 |\n") - f.write("|------|----------|------|\n") - for idx, (path, size) in enumerate(file_list, 1): - size_str = MarkdownWriter._format_size(size) if size > 0 else "-" - # 转义 Markdown 特殊字符 - safe_path = path.replace("|", "\\|") - f.write(f"| {idx} | `{safe_path}` | {size_str} |\n") - f.write("\n") - - # 统计信息 - if statistics: - f.write("## 统计信息\n\n") - f.write(f"- **目录数**: {statistics['dir_count']}\n") - f.write(f"- **文件数**: {statistics['file_count']}\n") - f.write(f"- **总大小**: {statistics['total_size_str']}\n") - - return True - - except (IOError, OSError) as e: - print(f"警告: 无法写入 Markdown 文件 {output_path} ({e}),回退到仅终端输出", file=sys.stderr) - return False - - @staticmethod - def _format_size(size_bytes): - """格式化文件大小""" - if size_bytes < 1024: - return f"{size_bytes} B" - elif size_bytes < 1024 * 1024: - return f"{size_bytes / 1024:.1f} KB" - elif size_bytes < 1024 * 1024 * 1024: - return f"{size_bytes / (1024 * 1024):.1f} MB" - else: - return f"{size_bytes / (1024 * 1024 * 1024):.1f} GB" - - -# ============================================================================= -# 模块 8: StatisticsCollector -# ============================================================================= - -class StatisticsCollector: - """统计信息收集 (F020)""" - - @staticmethod - def collect(tree_node, dirs_only=False, files_only=False): - """ - 收集统计信息。 - - Args: - tree_node: 树形字典 - dirs_only: 仅统计目录 - files_only: 仅统计文件 - - Returns: - dict: 统计信息字典 - """ - stats = { - "dir_count": 0, - "file_count": 0, - "total_size": 0, - "total_size_str": "0 B", - } - - StatisticsCollector._count(tree_node, stats, dirs_only, files_only) - - # 格式化总大小 - stats["total_size_str"] = MarkdownWriter._format_size(stats["total_size"]) - - return stats - - @staticmethod - def _count(node, stats, dirs_only=False, files_only=False): - """递归计数""" - if node["is_dir"]: - if not files_only: - stats["dir_count"] += 1 - for child in node.get("children", []): - StatisticsCollector._count(child, stats, dirs_only, files_only) - else: - if not dirs_only: - stats["file_count"] += 1 - stats["total_size"] += node.get("size", 0) - - -# ============================================================================= -# 主程序 -# ============================================================================= - -def main(): - """主入口函数""" - # 解析命令行参数 (F013) - args = ArgParser.parse_args() - - # 确定目标路径 - target_path = Path(args.path).resolve() - - # 检查路径是否存在 - if not target_path.exists(): - print(f"错误: 路径不存在: {target_path}", file=sys.stderr) - sys.exit(1) - - if not target_path.is_dir(): - print(f"错误: 不是目录: {target_path}", file=sys.stderr) - sys.exit(1) - - # 加载忽略配置 (F014) - ignore_set, glob_ignore_set = IgnoreLoader.load( - ignore_file_path=args.ignore_file, - target_dir=target_path, - ) - - # 扫描目录 (F015) - scanner = DirectoryScanner( - ignore_set=ignore_set, - glob_ignore_set=glob_ignore_set, - max_depth=args.depth, - ) - - try: - tree = scanner.scan(target_path) - except (FileNotFoundError, NotADirectoryError) as e: - print(f"错误: {e}", file=sys.stderr) - sys.exit(1) - - # 生成树形文本 (F016, F017) - tree_lines = TreeFormatter.format_tree( - tree, - dirs_only=args.dirs_only, - files_only=args.files_only, - ) - - # 收集文件列表 - file_list = TreeFormatter.format_file_list( - tree, - dirs_only=args.dirs_only, - files_only=args.files_only, - ) - - # 收集统计信息 (F020) - statistics = StatisticsCollector.collect( - tree, - dirs_only=args.dirs_only, - files_only=args.files_only, - ) - - # 终端输出 (F018) - TerminalOutput.display(tree_lines, statistics) - - # Markdown 保存 (F019) - if not args.dirs_only and not args.files_only: - # 默认模式:保存完整树 - MarkdownWriter.write( - output_path=args.output, - tree_lines=tree_lines, - file_list=file_list, - statistics=statistics, - root_path=target_path, - ) - print(f"Markdown 已保存到: {Path(args.output).resolve()}") - elif args.files_only: - # 文件树模式 - MarkdownWriter.write( - output_path=args.output, - tree_lines=tree_lines, - file_list=file_list, - statistics=statistics, - root_path=target_path, - ) - print(f"文件树 Markdown 已保存到: {Path(args.output).resolve()}") - else: - # 目录树模式 - MarkdownWriter.write( - output_path=args.output, - tree_lines=tree_lines, - file_list=file_list, - statistics=statistics, - root_path=target_path, - ) - print(f"目录树 Markdown 已保存到: {Path(args.output).resolve()}") - - -if __name__ == "__main__": - main()