release: v1.0.0 - 目录树生成脚本 Python 版本正式发布

- 更新为 Python 3.8+ 实现(tree_gen.py)
- 8 个功能全部通过(F013-F020)
- 更新 RELEASE.md、QUICKSTART.md 为 Python 版本
- 移除旧 Bash 版本文件(tree.sh)
- 添加架构设计文档(t023-architecture-design.md)
This commit is contained in:
2026-05-16 17:35:55 +08:00
parent 2f521deff8
commit fbad6a4647
8 changed files with 81 additions and 2158 deletions

View File

@@ -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 实现,零外部依赖
- 支持 LinuxmacOS
- 内置 30+ 种常见忽略模式
- 支持 `.treeignore` 自定义忽略配置
- 基于 inode 的符号链接循环检测
- Markdown 导出 + 终端输出双模式
- 自动统计目录数、文件数和总大小
- Python 3.8+ 实现,零第三方依赖
- 专为 Windows 平台优化,同时兼容 Linux/macOS
- 内置常见忽略项(.git、node_modules、__pycache__ 等)
- 支持 `.treeignore` / `.gitignore` 自定义忽略配置
- 自动检测符号链接循环,避免无限递归
- Markdown 导出UTF-8 with BOMWindows 记事本兼容)+ 终端输出双模式
- 自动统计目录数、文件数和总大小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 @@
## 环境要求
- **操作系统:** LinuxmacOS
- **Shell** Bash 4.0+
- **依赖:** 无(仅使用标准工具stat, awk, basename, dirname, mkdir
- **操作系统:** Windows 10/11也兼容 Linux/macOS
- **Python** 3.8 或更高版本
- **依赖:** 仅使用 Python 标准库,零第三方依赖

View File

@@ -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 <<EOF
${SCRIPT_NAME} ${VERSION} - Directory tree generator
Usage: ${SCRIPT_NAME} [OPTIONS] [PATH]
Options:
-p, --path <PATH> Target directory (default: current directory)
-o, --output <FILE> Output file for markdown (default: ${DEFAULT_OUTPUT})
-d, --depth <N> 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 "$@"

View File

@@ -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 <N>` | 最大深度 |
| `-f` | 包含文件列表 |
| `-s` | 不显示统计 |
| `-h` | 帮助 |
| `-v` | 版本 |
| 选项 | 说明 | 默认值 |
|------|------|--------|
| `path` | 目标目录 | 当前目录 |
| `-o <文件>` | Markdown 输出文件 | `tree_output.md` |
| `-d <N>` | 最大深度 | 无限制 |
| `-f` | 仅文件树 | 关闭 |
| `-D` | 仅目录树 | 关闭 |
| `-i <文件>` | 忽略配置文件 | 自动检测 |
| `-h` | 帮助 | — |
## 退出码
| 码 | 含义 |
|----|------|
| 0 | 成功 |
| 1 | 参数错误 |
| 2 | 路径无效 |
| 3 | 写入失败 |
| 1 | 参数错误(路径不存在等) |
---

View File

@@ -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 <<EOF
${SCRIPT_NAME} ${VERSION} - Directory tree generator
Usage: ${SCRIPT_NAME} [OPTIONS] [PATH]
Options:
-p, --path <PATH> Target directory (default: current directory)
-o, --output <FILE> Output file for markdown (default: ${DEFAULT_OUTPUT})
-d, --depth <N> 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 "$@"

View File

@@ -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 等)
- 兼容 macOSBash 5.x / zsh 兼容模式)
---
## 4. 功能详细设计
### 4.1 F013 - 路径输入解析
用法:
```
./tree.sh [路径] [选项]
```
选项:
- `-p, --path <路径>` — 指定目标目录(支持相对/绝对路径)
- `-o, --output <文件>` — 指定输出文件路径(默认 tree_output.md
- `-d, --depth <N>` — 限制递归深度(默认无限制)
- `-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— 输出错误信息到 stderrexit 1
- 无读取权限(错误码 2— 输出错误信息到 stderrexit 2
- 路径不是目录(错误码 3— 输出错误信息到 stderrexit 3
- 输出目录不可写(错误码 4— 输出错误信息到 stderrexit 4
- 深度参数无效(错误码 5— 输出错误信息到 stderrexit 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 使用文档编写
---
*文档结束*

1
source

Submodule source deleted from c278c5a1dd

View File

@@ -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()