From c278c5a1dd08f3664816cb0d8539a100c026a957 Mon Sep 17 00:00:00 2001 From: Agent Date: Sat, 16 May 2026 14:57:11 +0800 Subject: [PATCH] release: v1.0.0 --- README.md | 303 +++++++++++++++++++++ tree.sh | 528 ++++++++++++++++++++++++++++++++++++ tree_architecture_design.md | 378 ++++++++++++++++++++++++++ 3 files changed, 1209 insertions(+) create mode 100644 README.md create mode 100755 tree.sh create mode 100644 tree_architecture_design.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..1f61416 --- /dev/null +++ b/README.md @@ -0,0 +1,303 @@ +# tree.sh — 目录树生成脚本 + +一个用纯 Bash 编写的轻量级目录树生成工具,支持忽略配置、递归遍历、文件列表、Markdown 导出和统计信息。无需安装任何依赖,开箱即用。 + +**版本:** 1.0.0 + +--- + +## 功能特性 + +| 功能 | 说明 | +|------|------| +| **目录树生成** | 使用 `├──` / `└──` 字符生成标准树形结构,目录在前、文件在后,按字母排序 | +| **文件列表** | 可选生成完整文件列表(`-f`),显示所有文件的绝对路径 | +| **忽略配置** | 内置 30+ 种常见忽略模式(`.git`、`node_modules`、`__pycache__` 等),支持 `.treeignore` 自定义 | +| **深度限制** | 通过 `-d` 参数限制递归深度,避免过深遍历 | +| **Markdown 导出** | 默认保存为 `tree_output.md`,也可自定义输出路径 | +| **统计信息** | 自动统计目录数、文件数和总大小(自动转换为 B / KB / MB / GB) | +| **循环检测** | 基于 inode 检测符号链接循环,防止无限递归 | +| **权限处理** | 对无权限目录显示 `[Permission denied]`,不会中断执行 | + +--- + +## 环境要求 + +- **操作系统:** Linux 或 macOS +- **Shell:** Bash 4.0 或更高版本 +- **依赖:** 无(仅使用 Bash 内置命令和 `stat`、`awk`、`basename`、`dirname`、`mkdir` 等标准工具) + +检查 Bash 版本: + +```bash +bash --version +``` + +--- + +## 安装方法 + +**零安装。** 直接下载或克隆脚本后即可运行: + +```bash +chmod +x tree.sh +./tree.sh -p /path/to/target +``` + +也可以将脚本放到 PATH 中的某个目录,方便全局调用: + +```bash +sudo cp tree.sh /usr/local/bin/tree.sh +tree.sh -p /path/to/target +``` + +--- + +## 使用方法 + +### 基本用法 + +```bash +# 生成当前目录的树形结构 +./tree.sh + +# 生成指定目录的树形结构 +./tree.sh -p /home/user/project + +# 使用相对路径 +./tree.sh -p ../some-project +``` + +### 命令行选项 + +| 选项 | 简写 | 说明 | 默认值 | +|------|------|------|--------| +| `--path <路径>` | `-p` | 指定目标目录 | 当前目录 (`.`) | +| `--output <文件>` | `-o` | 指定 Markdown 输出文件 | `tree_output.md` | +| `--depth ` | `-d` | 限制递归深度(正整数) | 无限制 | +| `--files` | `-f` | 同时生成文件列表 | 关闭 | +| `--no-stats` | `-s` | 不显示统计信息 | 显示 | +| `--help` | `-h` | 显示帮助信息 | — | +| `--version` | `-v` | 显示版本信息 | — | + +### 常用示例 + +```bash +# 生成当前目录的完整树(含文件列表和统计) +./tree.sh -f + +# 只查看前 2 层目录结构 +./tree.sh -d 2 + +# 生成项目树并保存到自定义文件 +./tree.sh -p /var/www/myapp -o project-tree.md + +# 深度 3 + 文件列表 + 保存 +./tree.sh -p ./src -d 3 -f -o src-tree.md + +# 仅终端输出,不保存 Markdown 文件(重定向) +./tree.sh -p . -s > /dev/null + +# 查看帮助 +./tree.sh -h + +# 查看版本 +./tree.sh -v +``` + +--- + +## 配置说明 + +### 内置忽略列表 + +脚本默认忽略以下目录和文件类型: + +**版本控制:** +- `.git`、`.svn`、`.hg` + +**包管理 & 缓存:** +- `node_modules`、`__pycache__`、`.cache`、`.tox`、`.eggs` + +**构建产物:** +- `dist`、`build`、`.next`、`.nuxt`、`.output`、`.vercel`、`.terraform`、`.vagrant` + +**IDE & 编辑器:** +- `.idea`、`.vscode` + +**编译产物:** +- `*.pyc`、`*.pyo`、`*.egg-info`、`*.swp`、`*.swo`、`*.swn`、`*.class`、`*.o`、`*.so`、`*.dylib` + +**系统文件:** +- `.DS_Store` + +### 自定义忽略配置 + +在目标目录下创建 `.treeignore` 文件,每行一个忽略模式: + +```bash +# .treeignore 示例 +# 注释以 # 开头 + +# 忽略特定目录 +logs +tmp +*.log + +# 忽略特定文件类型 +*.bak +*.tmp +*.orig + +# 忽略特定名称 +.env.local +coverage +``` + +**规则:** +- 空行和以 `#` 开头的行会被忽略(作为注释) +- 支持精确匹配(如 `logs`)和 glob 通配符(如 `*.log`) +- 忽略模式会追加到内置列表之后,不会覆盖内置规则 + +--- + +## 使用示例 + +### 示例 1:基本目录树 + +```bash +$ ./tree.sh -p ./my-project +``` + +**终端输出:** +``` +my-project/ +├── src/ +│ ├── main.sh +│ ├── utils/ +│ │ ├── helpers.sh +│ │ └── validators.sh +│ └── config.sh +├── tests/ +│ ├── test_main.sh +│ └── test_utils.sh +├── README.md +└── tree_output.md + +Files: + /home/user/my-project/src/main.sh + /home/user/my-project/src/utils/helpers.sh + /home/user/my-project/src/utils/validators.sh + /home/user/my-project/src/config.sh + /home/user/my-project/tests/test_main.sh + /home/user/my-project/tests/test_utils.sh + /home/user/my-project/README.md + /home/user/my-project/tree_output.md + +Statistics: + Directories: 4 + Files: 8 + Total size: 12.34 KB +``` + +### 示例 2:限制深度 + +```bash +$ ./tree.sh -p ./my-project -d 2 +``` + +**输出:** +``` +my-project/ +├── src/ +│ ├── main.sh +│ ├── utils/ +│ └── config.sh +├── tests/ +│ ├── test_main.sh +│ └── test_utils.sh +├── README.md +└── tree_output.md +``` + +### 示例 3:生成 Markdown 文件 + +```bash +$ ./tree.sh -p ./my-project -f -o my-tree.md +``` + +**生成的 `my-tree.md` 内容:** +```markdown +# Directory Tree + +Path: `./my-project` + +``` +my-project/ +├── src/ +│ ├── main.sh +│ ├── utils/ +│ │ ├── helpers.sh +│ │ └── validators.sh +│ └── config.sh +├── tests/ +│ ├── test_main.sh +│ └── test_utils.sh +├── README.md +└── tree_output.md +``` + +## Files + + /home/user/my-project/src/main.sh + ... + +## Statistics + +- **Directories:** 4 +- **Files:** 8 +- **Total size:** 12.34 KB +``` + +### 示例 4:结合 .treeignore 使用 + +```bash +# 创建 .treeignore +echo -e "*.log\ntmp\ncoverage" > .treeignore + +# 运行脚本(自动加载 .treeignore) +./tree.sh -p . +``` + +--- + +## 注意事项 + +1. **路径必须存在且为目录**:如果指定的路径不存在或不是目录,脚本会报错退出(退出码 2)。 + +2. **深度参数必须为正整数**:`-d` 参数必须传入大于 0 的整数,否则报错退出(退出码 1)。 + +3. **符号链接循环检测**:脚本基于 inode 检测循环引用。如果检测到循环,会显示 `[cycle detected, skipped]` 并跳过,不会无限递归。 + +4. **权限问题**:遇到无权限读取的目录时,会显示 `[Permission denied]` 并继续处理其他目录,不会中断整个流程。 + +5. **输出文件目录自动创建**:如果 `-o` 指定的输出路径中的目录不存在,脚本会自动创建。 + +6. **Markdown 文件始终生成**:脚本每次运行都会同时输出到终端和保存 Markdown 文件(默认 `tree_output.md`)。终端输出和 Markdown 内容一致。 + +7. **大小写敏感**:忽略模式匹配区分大小写。 + +8. **隐藏文件**:以 `.` 开头的隐藏文件/目录会被正常遍历(除非被忽略规则匹配)。 + +9. **退出码**: + - `0`:成功 + - `1`:参数错误 + - `2`:路径无效 + - `3`:写入失败 + +--- + +## 项目信息 + +- **项目 ID:** PROJ-20260509011 +- **功能覆盖:** F013–F020(8 个功能全部测试通过) diff --git a/tree.sh b/tree.sh new file mode 100755 index 0000000..4907b5e --- /dev/null +++ b/tree.sh @@ -0,0 +1,528 @@ +#!/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/tree_architecture_design.md b/tree_architecture_design.md new file mode 100644 index 0000000..7aa2604 --- /dev/null +++ b/tree_architecture_design.md @@ -0,0 +1,378 @@ +# 目录树生成脚本 - 架构设计文档 + +项目 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 使用文档编写 + +--- + +*文档结束*