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:
@@ -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 标准库,零第三方依赖
|
||||
|
||||
528
Releases/v1.0.0/dist/tree.sh
vendored
528
Releases/v1.0.0/dist/tree.sh
vendored
@@ -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 "$@"
|
||||
@@ -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 | 参数错误(路径不存在等) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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 "$@"
|
||||
@@ -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 <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)— 输出错误信息到 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 使用文档编写
|
||||
|
||||
---
|
||||
|
||||
*文档结束*
|
||||
Binary file not shown.
Reference in New Issue
Block a user