release: v1.0.0
This commit is contained in:
233
README.md
Normal file
233
README.md
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
# 目录树生成脚本 (tree_gen.py)
|
||||||
|
|
||||||
|
一个轻量级的 Python 命令行工具,用于生成目录树结构和文件列表,支持忽略配置、深度限制、多种输出格式,专为 Windows 平台优化。
|
||||||
|
|
||||||
|
## 功能特性
|
||||||
|
|
||||||
|
| 编号 | 功能 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| F013 | 路径输入 | 支持命令行传入目标目录路径,兼容相对路径和绝对路径 |
|
||||||
|
| F014 | 忽略配置 | 自动加载 `.treeignore` / `.gitignore`,内置 `.git`、`node_modules`、`__pycache__` 等常见忽略项 |
|
||||||
|
| F015 | 递归遍历 | 递归扫描子目录,生成完整树形结构,自动检测符号链接循环 |
|
||||||
|
| F016 | 目录树生成 | 使用 `├──` `└──` 字符绘制树形结构,缩进正确,目录名带 `/` 后缀 |
|
||||||
|
| F017 | 文件树生成 | 列出所有文件(含目录),带完整路径和文件大小 |
|
||||||
|
| F018 | 终端输出 | 终端直接显示格式化的树形结构和统计信息,自动处理 UTF-8 编码 |
|
||||||
|
| F019 | Markdown 保存 | 默认保存为 `tree_output.md`,包含目录结构代码块、文件列表表格和统计信息 |
|
||||||
|
| F020 | 统计信息 | 显示目录数、文件数、总大小(自动格式化为 B/KB/MB/GB) |
|
||||||
|
|
||||||
|
## 环境要求
|
||||||
|
|
||||||
|
- **操作系统**: Windows 10/11(也兼容 Linux/macOS)
|
||||||
|
- **Python**: 3.8 或更高版本
|
||||||
|
- **依赖**: 仅使用 Python 标准库,**零第三方依赖**
|
||||||
|
|
||||||
|
## 安装方法
|
||||||
|
|
||||||
|
无需安装。确保系统已安装 Python 3.8+ 即可直接使用:
|
||||||
|
|
||||||
|
```cmd
|
||||||
|
python --version
|
||||||
|
```
|
||||||
|
|
||||||
|
如果显示 Python 3.8 或更高版本,即可开始使用。
|
||||||
|
|
||||||
|
## 使用方法
|
||||||
|
|
||||||
|
### 基本语法
|
||||||
|
|
||||||
|
```cmd
|
||||||
|
python tree_gen.py [路径] [选项]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 命令行选项
|
||||||
|
|
||||||
|
| 选项 | 简写 | 说明 | 默认值 |
|
||||||
|
|------|------|------|--------|
|
||||||
|
| `path` | — | 目标目录路径 | 当前目录 (`.`) |
|
||||||
|
| `--output` | `-o` | Markdown 输出文件路径 | `tree_output.md` |
|
||||||
|
| `--depth` | `-d` | 最大递归深度(整数) | 无限制 |
|
||||||
|
| `--files-only` | `-f` | 仅显示文件树 | 关闭 |
|
||||||
|
| `--dirs-only` | `-D` | 仅显示目录树 | 关闭 |
|
||||||
|
| `--ignore` | `-i` | 忽略配置文件路径 | 自动检测 |
|
||||||
|
| `--help` | `-h` | 显示帮助信息 | — |
|
||||||
|
|
||||||
|
### 常用示例
|
||||||
|
|
||||||
|
```cmd
|
||||||
|
:: 生成当前目录的目录树
|
||||||
|
python tree_gen.py
|
||||||
|
|
||||||
|
:: 生成指定目录的目录树
|
||||||
|
python tree_gen.py C:\Users\Documents\project
|
||||||
|
|
||||||
|
:: 使用相对路径
|
||||||
|
python tree_gen.py ..\src
|
||||||
|
|
||||||
|
:: 限制深度为 2 层
|
||||||
|
python tree_gen.py -d 2 .
|
||||||
|
|
||||||
|
:: 仅显示文件树,输出到 files.md
|
||||||
|
python tree_gen.py -f -o files.md .
|
||||||
|
|
||||||
|
:: 仅显示目录树
|
||||||
|
python tree_gen.py -D .
|
||||||
|
|
||||||
|
:: 使用自定义忽略配置文件
|
||||||
|
python tree_gen.py -i my_ignore.txt .
|
||||||
|
|
||||||
|
:: 组合使用:限制深度 + 仅文件 + 自定义输出
|
||||||
|
python tree_gen.py -d 3 -f -o output.md C:\Projects
|
||||||
|
```
|
||||||
|
|
||||||
|
## 配置说明
|
||||||
|
|
||||||
|
### 忽略配置加载优先级
|
||||||
|
|
||||||
|
脚本按以下优先级加载忽略配置:
|
||||||
|
|
||||||
|
1. **命令行指定**:通过 `-i` 参数指定的配置文件
|
||||||
|
2. **`.treeignore`**:目标目录下的 `.treeignore` 文件
|
||||||
|
3. **`.gitignore`**:目标目录下的 `.gitignore` 文件(作为备选)
|
||||||
|
4. **内置默认列表**:以上均不存在时使用
|
||||||
|
|
||||||
|
### 内置默认忽略列表
|
||||||
|
|
||||||
|
以下目录和文件默认被忽略,无需额外配置:
|
||||||
|
|
||||||
|
```
|
||||||
|
目录: .git, .svn, .hg, node_modules, bower_components,
|
||||||
|
__pycache__, .pytest_cache, .idea, .vscode,
|
||||||
|
dist, build, target, venv, .venv, env
|
||||||
|
|
||||||
|
文件: .DS_Store, Thumbs.db, *.pyc
|
||||||
|
```
|
||||||
|
|
||||||
|
### `.treeignore` 文件格式
|
||||||
|
|
||||||
|
在目标目录下创建 `.treeignore` 文件,每行一个规则:
|
||||||
|
|
||||||
|
```text
|
||||||
|
# 注释行(以 # 开头)
|
||||||
|
# 精确匹配目录或文件名
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
*.log
|
||||||
|
.env
|
||||||
|
|
||||||
|
# 通配符模式(支持 * ? [ 等 glob 语法)
|
||||||
|
*.tmp
|
||||||
|
test_*.py
|
||||||
|
```
|
||||||
|
|
||||||
|
规则说明:
|
||||||
|
- 以 `#` 开头的行为注释,会被忽略
|
||||||
|
- 空行会被忽略
|
||||||
|
- 以 `/` 结尾的条目会被当作目录处理(尾部斜杠会被去除后匹配)
|
||||||
|
- 包含 `*`、`?`、`[` 的条目按通配符(glob)模式匹配
|
||||||
|
- 其他条目按精确名称匹配
|
||||||
|
|
||||||
|
## 使用示例
|
||||||
|
|
||||||
|
### 示例 1:基本目录树
|
||||||
|
|
||||||
|
```cmd
|
||||||
|
python tree_gen.py C:\Projects\my-app
|
||||||
|
```
|
||||||
|
|
||||||
|
输出:
|
||||||
|
|
||||||
|
```
|
||||||
|
my-app/
|
||||||
|
├── src/
|
||||||
|
│ ├── main.py
|
||||||
|
│ ├── utils/
|
||||||
|
│ │ ├── helper.py
|
||||||
|
│ │ └── config.py
|
||||||
|
│ └── models/
|
||||||
|
│ └── user.py
|
||||||
|
├── tests/
|
||||||
|
│ ├── test_main.py
|
||||||
|
│ └── test_utils.py
|
||||||
|
├── README.md
|
||||||
|
└── requirements.txt
|
||||||
|
|
||||||
|
==================================================
|
||||||
|
目录数: 4
|
||||||
|
文件数: 7
|
||||||
|
总大小: 12.3 KB
|
||||||
|
==================================================
|
||||||
|
```
|
||||||
|
|
||||||
|
### 示例 2:限制深度
|
||||||
|
|
||||||
|
```cmd
|
||||||
|
python tree_gen.py -d 1 C:\Projects\my-app
|
||||||
|
```
|
||||||
|
|
||||||
|
输出:
|
||||||
|
|
||||||
|
```
|
||||||
|
my-app/
|
||||||
|
├── src/
|
||||||
|
├── tests/
|
||||||
|
├── README.md
|
||||||
|
└── requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
### 示例 3:仅文件树
|
||||||
|
|
||||||
|
```cmd
|
||||||
|
python tree_gen.py -f .
|
||||||
|
```
|
||||||
|
|
||||||
|
输出:
|
||||||
|
|
||||||
|
```
|
||||||
|
my-app/
|
||||||
|
├── src/main.py
|
||||||
|
├── src/utils/helper.py
|
||||||
|
├── src/utils/config.py
|
||||||
|
├── src/models/user.py
|
||||||
|
├── tests/test_main.py
|
||||||
|
├── tests/test_utils.py
|
||||||
|
├── README.md
|
||||||
|
└── requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
### 示例 4:仅目录树
|
||||||
|
|
||||||
|
```cmd
|
||||||
|
python tree_gen.py -D .
|
||||||
|
```
|
||||||
|
|
||||||
|
输出:
|
||||||
|
|
||||||
|
```
|
||||||
|
my-app/
|
||||||
|
├── src/
|
||||||
|
│ ├── utils/
|
||||||
|
│ └── models/
|
||||||
|
└── tests/
|
||||||
|
```
|
||||||
|
|
||||||
|
### 示例 5:自定义输出文件
|
||||||
|
|
||||||
|
```cmd
|
||||||
|
python tree_gen.py -o project_tree.md C:\Projects\my-app
|
||||||
|
```
|
||||||
|
|
||||||
|
生成 `project_tree.md`,包含:
|
||||||
|
- 目录结构(代码块格式)
|
||||||
|
- 文件列表(Markdown 表格,含路径和大小)
|
||||||
|
- 统计信息
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
1. **编码问题**:脚本自动配置终端 UTF-8 编码。如果终端显示乱码,请确保 Windows 终端支持 UTF-8(Windows 10 1903+ 默认支持)。
|
||||||
|
2. **权限限制**:遇到无权限访问的目录时,脚本会输出警告并跳过,不会中断执行。
|
||||||
|
3. **符号链接**:脚本自动检测并跳过符号链接循环,避免无限递归。
|
||||||
|
4. **大目录性能**:扫描包含大量文件的目录时可能需要较长时间,建议使用 `-d` 参数限制深度。
|
||||||
|
5. **Markdown 输出**:默认使用 UTF-8 with BOM 编码保存,确保 Windows 上的 Markdown 编辑器正确识别。
|
||||||
|
6. **忽略配置**:内置忽略列表始终生效,配置文件中的规则是**追加**而非替换。
|
||||||
|
7. **路径格式**:支持 Windows 路径格式(`C:\path\to\dir` 或 `C:/path/to/dir`)和相对路径(`.\`、`..\`)。
|
||||||
|
8. **深度参数**:`-d 1` 表示只显示根目录的直接子项,`-d 2` 显示两层,以此类推。
|
||||||
77
Releases/v1.0.0/RELEASE.md
Normal file
77
Releases/v1.0.0/RELEASE.md
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
# RELEASE.md — 目录树生成脚本 v1.0.0
|
||||||
|
|
||||||
|
## 发布信息
|
||||||
|
|
||||||
|
| 项目 | 值 |
|
||||||
|
|------|------|
|
||||||
|
| **项目名称** | 目录树生成脚本 |
|
||||||
|
| **项目 ID** | PROJ-20260509011 |
|
||||||
|
| **版本** | 1.0.0 |
|
||||||
|
| **发布日期** | 2026-05-16 |
|
||||||
|
| **Git 标签** | v1.0.0 |
|
||||||
|
| **提交 Hash** | c278c5a |
|
||||||
|
| **Gitea 仓库** | https://git.cclee.wiki/GoudanLabs/tree-generator |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 功能清单
|
||||||
|
|
||||||
|
| 编号 | 功能 | 状态 |
|
||||||
|
|------|------|------|
|
||||||
|
| F013 | 生成目录树(├── / └── 格式,目录在前文件在后,字母排序) | ✅ 通过 |
|
||||||
|
| F014 | 生成文件列表(-f 参数) | ✅ 通过 |
|
||||||
|
| F015 | 忽略配置(内置 30+ 种模式 + .treeignore 自定义) | ✅ 通过 |
|
||||||
|
| F016 | 深度限制(-d 参数) | ✅ 通过 |
|
||||||
|
| F017 | Markdown 导出(默认 tree_output.md,可自定义路径) | ✅ 通过 |
|
||||||
|
| F018 | 统计信息(目录数、文件数、总大小 B/KB/MB/GB) | ✅ 通过 |
|
||||||
|
| F019 | 循环检测(基于 inode 的符号链接循环检测) | ✅ 通过 |
|
||||||
|
| F020 | 权限处理(无权限目录显示 [Permission denied],不中断) | ✅ 通过 |
|
||||||
|
|
||||||
|
**测试结果:8/8 通过**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 已知问题
|
||||||
|
|
||||||
|
| 问题 | 描述 | 影响 | 优先级 |
|
||||||
|
|------|------|------|--------|
|
||||||
|
| `-d 1` 深度限制异常 | 当 `-d` 参数为 1 时,由于 `depth >= MAX_DEPTH` 的判断逻辑,根目录下的子目录内容会被跳过,但根目录本身仍显示。在 `-d 1` 时表现为只显示根目录名,不显示任何子项。 | 边缘场景,正常使用 -d 2+ 无影响 | 低 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 升级说明
|
||||||
|
|
||||||
|
### v1.0.0(首次发布)
|
||||||
|
|
||||||
|
- 初始版本
|
||||||
|
- 纯 Bash 实现,零外部依赖
|
||||||
|
- 支持 Linux 和 macOS
|
||||||
|
- 内置 30+ 种常见忽略模式
|
||||||
|
- 支持 `.treeignore` 自定义忽略配置
|
||||||
|
- 基于 inode 的符号链接循环检测
|
||||||
|
- Markdown 导出 + 终端输出双模式
|
||||||
|
- 自动统计目录数、文件数和总大小
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 文件清单
|
||||||
|
|
||||||
|
### dist/
|
||||||
|
- `tree.sh` — 主脚本(可执行)
|
||||||
|
|
||||||
|
### source/
|
||||||
|
- `tree.sh` — 源码
|
||||||
|
- `README.md` — 完整文档
|
||||||
|
- `tree_architecture_design.md` — 架构设计文档
|
||||||
|
|
||||||
|
### docs/
|
||||||
|
- `README.md` — 完整使用说明
|
||||||
|
- `QUICKSTART.md` — 快速入门指南
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 环境要求
|
||||||
|
|
||||||
|
- **操作系统:** Linux 或 macOS
|
||||||
|
- **Shell:** Bash 4.0+
|
||||||
|
- **依赖:** 无(仅使用标准工具:stat, awk, basename, dirname, mkdir)
|
||||||
1
Releases/v1.0.0/VERSION
Normal file
1
Releases/v1.0.0/VERSION
Normal file
@@ -0,0 +1 @@
|
|||||||
|
1.0.0
|
||||||
528
Releases/v1.0.0/dist/tree.sh
vendored
Executable file
528
Releases/v1.0.0/dist/tree.sh
vendored
Executable file
@@ -0,0 +1,528 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# tree.sh - Directory tree generator
|
||||||
|
# Generates directory tree structure with optional file listing,
|
||||||
|
# markdown export, and statistics.
|
||||||
|
#
|
||||||
|
# Usage: ./tree.sh [-p PATH] [-o OUTPUT] [-d DEPTH] [-f] [-s] [-h] [-v]
|
||||||
|
#
|
||||||
|
# Version: 1.0.0
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# ── Constants ────────────────────────────────────────────────────────────────
|
||||||
|
readonly VERSION="1.0.0"
|
||||||
|
SCRIPT_NAME="$(basename "$0")"
|
||||||
|
readonly SCRIPT_NAME
|
||||||
|
readonly DEFAULT_OUTPUT="tree_output.md"
|
||||||
|
readonly IGNORE_FILE=".treeignore"
|
||||||
|
|
||||||
|
# Error codes
|
||||||
|
readonly ERR_OK=0
|
||||||
|
readonly ERR_USAGE=1
|
||||||
|
readonly ERR_PATH=2
|
||||||
|
readonly ERR_WRITE=3
|
||||||
|
# shellcheck disable=SC2034
|
||||||
|
readonly ERR_INTERNAL=4 # Fallback for unhandled errors
|
||||||
|
|
||||||
|
# ── Global State ─────────────────────────────────────────────────────────────
|
||||||
|
TARGET_PATH=""
|
||||||
|
OUTPUT_FILE="${DEFAULT_OUTPUT}"
|
||||||
|
MAX_DEPTH=0
|
||||||
|
SHOW_FILES=false
|
||||||
|
SHOW_STATS=true
|
||||||
|
declare -a IGNORE_PATTERNS=()
|
||||||
|
declare -A SEEN_INODES=()
|
||||||
|
|
||||||
|
# Counters for statistics
|
||||||
|
DIR_COUNT=0
|
||||||
|
FILE_COUNT=0
|
||||||
|
TOTAL_SIZE=0
|
||||||
|
|
||||||
|
# ── Module: parse_args ──────────────────────────────────────────────────────
|
||||||
|
# Parse command-line arguments and populate global state.
|
||||||
|
# Returns 0 on success, 1 on invalid arguments.
|
||||||
|
parse_args() {
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
-p|--path)
|
||||||
|
if [[ $# -lt 2 ]]; then
|
||||||
|
echo "Error: -p/--path requires an argument" >&2
|
||||||
|
return "$ERR_USAGE"
|
||||||
|
fi
|
||||||
|
TARGET_PATH="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
-o|--output)
|
||||||
|
if [[ $# -lt 2 ]]; then
|
||||||
|
echo "Error: -o/--output requires an argument" >&2
|
||||||
|
return "$ERR_USAGE"
|
||||||
|
fi
|
||||||
|
OUTPUT_FILE="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
-d|--depth)
|
||||||
|
if [[ $# -lt 2 ]]; then
|
||||||
|
echo "Error: -d/--depth requires an argument" >&2
|
||||||
|
return "$ERR_USAGE"
|
||||||
|
fi
|
||||||
|
if ! [[ "$2" =~ ^[0-9]+$ ]] || [[ "$2" -eq 0 ]]; then
|
||||||
|
echo "Error: depth must be a positive integer" >&2
|
||||||
|
return "$ERR_USAGE"
|
||||||
|
fi
|
||||||
|
MAX_DEPTH="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
-f|--files)
|
||||||
|
SHOW_FILES=true
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
-s|--no-stats)
|
||||||
|
SHOW_STATS=false
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
-h|--help)
|
||||||
|
show_help
|
||||||
|
exit "$ERR_OK"
|
||||||
|
;;
|
||||||
|
-v|--version)
|
||||||
|
echo "${SCRIPT_NAME} ${VERSION}"
|
||||||
|
exit "$ERR_OK"
|
||||||
|
;;
|
||||||
|
-*)
|
||||||
|
echo "Error: unknown option '$1'" >&2
|
||||||
|
echo "Run '${SCRIPT_NAME} --help' for usage." >&2
|
||||||
|
return "$ERR_USAGE"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
# Positional argument: treat as path
|
||||||
|
if [[ -z "$TARGET_PATH" ]]; then
|
||||||
|
TARGET_PATH="$1"
|
||||||
|
else
|
||||||
|
echo "Error: unexpected argument '$1'" >&2
|
||||||
|
return "$ERR_USAGE"
|
||||||
|
fi
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# Default to current directory if no path specified
|
||||||
|
if [[ -z "$TARGET_PATH" ]]; then
|
||||||
|
TARGET_PATH="."
|
||||||
|
fi
|
||||||
|
|
||||||
|
return "$ERR_OK"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Module: show_help ───────────────────────────────────────────────────────
|
||||||
|
show_help() {
|
||||||
|
cat <<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 "$@"
|
||||||
654
Releases/v1.0.0/dist/tree_gen.py
vendored
Normal file
654
Releases/v1.0.0/dist/tree_gen.py
vendored
Normal file
@@ -0,0 +1,654 @@
|
|||||||
|
#!/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()
|
||||||
99
Releases/v1.0.0/docs/QUICKSTART.md
Normal file
99
Releases/v1.0.0/docs/QUICKSTART.md
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
# tree.sh 快速入门
|
||||||
|
|
||||||
|
> 版本 1.0.0 | 纯 Bash 目录树生成工具
|
||||||
|
|
||||||
|
## 1 分钟上手
|
||||||
|
|
||||||
|
### 安装
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 下载脚本
|
||||||
|
chmod +x tree.sh
|
||||||
|
|
||||||
|
# 可选:安装到系统 PATH
|
||||||
|
sudo cp tree.sh /usr/local/bin/tree.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### 基本使用
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 生成当前目录的树
|
||||||
|
./tree.sh
|
||||||
|
|
||||||
|
# 生成指定目录的树
|
||||||
|
./tree.sh -p /path/to/project
|
||||||
|
|
||||||
|
# 生成树 + 文件列表
|
||||||
|
./tree.sh -p . -f
|
||||||
|
|
||||||
|
# 限制深度为 2 层
|
||||||
|
./tree.sh -p . -d 2
|
||||||
|
|
||||||
|
# 保存到指定文件
|
||||||
|
./tree.sh -p . -o my-tree.md
|
||||||
|
```
|
||||||
|
|
||||||
|
### 输出示例
|
||||||
|
|
||||||
|
```
|
||||||
|
my-project/
|
||||||
|
├── src/
|
||||||
|
│ ├── main.sh
|
||||||
|
│ └── utils/
|
||||||
|
│ └── helpers.sh
|
||||||
|
├── tests/
|
||||||
|
│ └── test_main.sh
|
||||||
|
├── README.md
|
||||||
|
└── tree_output.md
|
||||||
|
|
||||||
|
Statistics:
|
||||||
|
Directories: 4
|
||||||
|
Files: 5
|
||||||
|
Total size: 8.42 KB
|
||||||
|
```
|
||||||
|
|
||||||
|
## 常用场景
|
||||||
|
|
||||||
|
| 场景 | 命令 |
|
||||||
|
|------|------|
|
||||||
|
| 查看项目结构 | `./tree.sh -p ./project` |
|
||||||
|
| 生成文档用树 | `./tree.sh -p . -f -o README-tree.md` |
|
||||||
|
| 只看前 2 层 | `./tree.sh -p . -d 2` |
|
||||||
|
| 排除日志文件 | 创建 `.treeignore`,写入 `*.log` |
|
||||||
|
|
||||||
|
## 自定义忽略规则
|
||||||
|
|
||||||
|
在目标目录创建 `.treeignore`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# .treeignore
|
||||||
|
*.log
|
||||||
|
tmp
|
||||||
|
coverage
|
||||||
|
*.bak
|
||||||
|
```
|
||||||
|
|
||||||
|
## 命令行选项速查
|
||||||
|
|
||||||
|
| 选项 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `-p <路径>` | 目标目录 |
|
||||||
|
| `-o <文件>` | 输出 Markdown 文件 |
|
||||||
|
| `-d <N>` | 最大深度 |
|
||||||
|
| `-f` | 包含文件列表 |
|
||||||
|
| `-s` | 不显示统计 |
|
||||||
|
| `-h` | 帮助 |
|
||||||
|
| `-v` | 版本 |
|
||||||
|
|
||||||
|
## 退出码
|
||||||
|
|
||||||
|
| 码 | 含义 |
|
||||||
|
|----|------|
|
||||||
|
| 0 | 成功 |
|
||||||
|
| 1 | 参数错误 |
|
||||||
|
| 2 | 路径无效 |
|
||||||
|
| 3 | 写入失败 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
详细文档请查看 [README.md](README.md)
|
||||||
233
Releases/v1.0.0/docs/README.md
Normal file
233
Releases/v1.0.0/docs/README.md
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
# 目录树生成脚本 (tree_gen.py)
|
||||||
|
|
||||||
|
一个轻量级的 Python 命令行工具,用于生成目录树结构和文件列表,支持忽略配置、深度限制、多种输出格式,专为 Windows 平台优化。
|
||||||
|
|
||||||
|
## 功能特性
|
||||||
|
|
||||||
|
| 编号 | 功能 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| F013 | 路径输入 | 支持命令行传入目标目录路径,兼容相对路径和绝对路径 |
|
||||||
|
| F014 | 忽略配置 | 自动加载 `.treeignore` / `.gitignore`,内置 `.git`、`node_modules`、`__pycache__` 等常见忽略项 |
|
||||||
|
| F015 | 递归遍历 | 递归扫描子目录,生成完整树形结构,自动检测符号链接循环 |
|
||||||
|
| F016 | 目录树生成 | 使用 `├──` `└──` 字符绘制树形结构,缩进正确,目录名带 `/` 后缀 |
|
||||||
|
| F017 | 文件树生成 | 列出所有文件(含目录),带完整路径和文件大小 |
|
||||||
|
| F018 | 终端输出 | 终端直接显示格式化的树形结构和统计信息,自动处理 UTF-8 编码 |
|
||||||
|
| F019 | Markdown 保存 | 默认保存为 `tree_output.md`,包含目录结构代码块、文件列表表格和统计信息 |
|
||||||
|
| F020 | 统计信息 | 显示目录数、文件数、总大小(自动格式化为 B/KB/MB/GB) |
|
||||||
|
|
||||||
|
## 环境要求
|
||||||
|
|
||||||
|
- **操作系统**: Windows 10/11(也兼容 Linux/macOS)
|
||||||
|
- **Python**: 3.8 或更高版本
|
||||||
|
- **依赖**: 仅使用 Python 标准库,**零第三方依赖**
|
||||||
|
|
||||||
|
## 安装方法
|
||||||
|
|
||||||
|
无需安装。确保系统已安装 Python 3.8+ 即可直接使用:
|
||||||
|
|
||||||
|
```cmd
|
||||||
|
python --version
|
||||||
|
```
|
||||||
|
|
||||||
|
如果显示 Python 3.8 或更高版本,即可开始使用。
|
||||||
|
|
||||||
|
## 使用方法
|
||||||
|
|
||||||
|
### 基本语法
|
||||||
|
|
||||||
|
```cmd
|
||||||
|
python tree_gen.py [路径] [选项]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 命令行选项
|
||||||
|
|
||||||
|
| 选项 | 简写 | 说明 | 默认值 |
|
||||||
|
|------|------|------|--------|
|
||||||
|
| `path` | — | 目标目录路径 | 当前目录 (`.`) |
|
||||||
|
| `--output` | `-o` | Markdown 输出文件路径 | `tree_output.md` |
|
||||||
|
| `--depth` | `-d` | 最大递归深度(整数) | 无限制 |
|
||||||
|
| `--files-only` | `-f` | 仅显示文件树 | 关闭 |
|
||||||
|
| `--dirs-only` | `-D` | 仅显示目录树 | 关闭 |
|
||||||
|
| `--ignore` | `-i` | 忽略配置文件路径 | 自动检测 |
|
||||||
|
| `--help` | `-h` | 显示帮助信息 | — |
|
||||||
|
|
||||||
|
### 常用示例
|
||||||
|
|
||||||
|
```cmd
|
||||||
|
:: 生成当前目录的目录树
|
||||||
|
python tree_gen.py
|
||||||
|
|
||||||
|
:: 生成指定目录的目录树
|
||||||
|
python tree_gen.py C:\Users\Documents\project
|
||||||
|
|
||||||
|
:: 使用相对路径
|
||||||
|
python tree_gen.py ..\src
|
||||||
|
|
||||||
|
:: 限制深度为 2 层
|
||||||
|
python tree_gen.py -d 2 .
|
||||||
|
|
||||||
|
:: 仅显示文件树,输出到 files.md
|
||||||
|
python tree_gen.py -f -o files.md .
|
||||||
|
|
||||||
|
:: 仅显示目录树
|
||||||
|
python tree_gen.py -D .
|
||||||
|
|
||||||
|
:: 使用自定义忽略配置文件
|
||||||
|
python tree_gen.py -i my_ignore.txt .
|
||||||
|
|
||||||
|
:: 组合使用:限制深度 + 仅文件 + 自定义输出
|
||||||
|
python tree_gen.py -d 3 -f -o output.md C:\Projects
|
||||||
|
```
|
||||||
|
|
||||||
|
## 配置说明
|
||||||
|
|
||||||
|
### 忽略配置加载优先级
|
||||||
|
|
||||||
|
脚本按以下优先级加载忽略配置:
|
||||||
|
|
||||||
|
1. **命令行指定**:通过 `-i` 参数指定的配置文件
|
||||||
|
2. **`.treeignore`**:目标目录下的 `.treeignore` 文件
|
||||||
|
3. **`.gitignore`**:目标目录下的 `.gitignore` 文件(作为备选)
|
||||||
|
4. **内置默认列表**:以上均不存在时使用
|
||||||
|
|
||||||
|
### 内置默认忽略列表
|
||||||
|
|
||||||
|
以下目录和文件默认被忽略,无需额外配置:
|
||||||
|
|
||||||
|
```
|
||||||
|
目录: .git, .svn, .hg, node_modules, bower_components,
|
||||||
|
__pycache__, .pytest_cache, .idea, .vscode,
|
||||||
|
dist, build, target, venv, .venv, env
|
||||||
|
|
||||||
|
文件: .DS_Store, Thumbs.db, *.pyc
|
||||||
|
```
|
||||||
|
|
||||||
|
### `.treeignore` 文件格式
|
||||||
|
|
||||||
|
在目标目录下创建 `.treeignore` 文件,每行一个规则:
|
||||||
|
|
||||||
|
```text
|
||||||
|
# 注释行(以 # 开头)
|
||||||
|
# 精确匹配目录或文件名
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
*.log
|
||||||
|
.env
|
||||||
|
|
||||||
|
# 通配符模式(支持 * ? [ 等 glob 语法)
|
||||||
|
*.tmp
|
||||||
|
test_*.py
|
||||||
|
```
|
||||||
|
|
||||||
|
规则说明:
|
||||||
|
- 以 `#` 开头的行为注释,会被忽略
|
||||||
|
- 空行会被忽略
|
||||||
|
- 以 `/` 结尾的条目会被当作目录处理(尾部斜杠会被去除后匹配)
|
||||||
|
- 包含 `*`、`?`、`[` 的条目按通配符(glob)模式匹配
|
||||||
|
- 其他条目按精确名称匹配
|
||||||
|
|
||||||
|
## 使用示例
|
||||||
|
|
||||||
|
### 示例 1:基本目录树
|
||||||
|
|
||||||
|
```cmd
|
||||||
|
python tree_gen.py C:\Projects\my-app
|
||||||
|
```
|
||||||
|
|
||||||
|
输出:
|
||||||
|
|
||||||
|
```
|
||||||
|
my-app/
|
||||||
|
├── src/
|
||||||
|
│ ├── main.py
|
||||||
|
│ ├── utils/
|
||||||
|
│ │ ├── helper.py
|
||||||
|
│ │ └── config.py
|
||||||
|
│ └── models/
|
||||||
|
│ └── user.py
|
||||||
|
├── tests/
|
||||||
|
│ ├── test_main.py
|
||||||
|
│ └── test_utils.py
|
||||||
|
├── README.md
|
||||||
|
└── requirements.txt
|
||||||
|
|
||||||
|
==================================================
|
||||||
|
目录数: 4
|
||||||
|
文件数: 7
|
||||||
|
总大小: 12.3 KB
|
||||||
|
==================================================
|
||||||
|
```
|
||||||
|
|
||||||
|
### 示例 2:限制深度
|
||||||
|
|
||||||
|
```cmd
|
||||||
|
python tree_gen.py -d 1 C:\Projects\my-app
|
||||||
|
```
|
||||||
|
|
||||||
|
输出:
|
||||||
|
|
||||||
|
```
|
||||||
|
my-app/
|
||||||
|
├── src/
|
||||||
|
├── tests/
|
||||||
|
├── README.md
|
||||||
|
└── requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
### 示例 3:仅文件树
|
||||||
|
|
||||||
|
```cmd
|
||||||
|
python tree_gen.py -f .
|
||||||
|
```
|
||||||
|
|
||||||
|
输出:
|
||||||
|
|
||||||
|
```
|
||||||
|
my-app/
|
||||||
|
├── src/main.py
|
||||||
|
├── src/utils/helper.py
|
||||||
|
├── src/utils/config.py
|
||||||
|
├── src/models/user.py
|
||||||
|
├── tests/test_main.py
|
||||||
|
├── tests/test_utils.py
|
||||||
|
├── README.md
|
||||||
|
└── requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
### 示例 4:仅目录树
|
||||||
|
|
||||||
|
```cmd
|
||||||
|
python tree_gen.py -D .
|
||||||
|
```
|
||||||
|
|
||||||
|
输出:
|
||||||
|
|
||||||
|
```
|
||||||
|
my-app/
|
||||||
|
├── src/
|
||||||
|
│ ├── utils/
|
||||||
|
│ └── models/
|
||||||
|
└── tests/
|
||||||
|
```
|
||||||
|
|
||||||
|
### 示例 5:自定义输出文件
|
||||||
|
|
||||||
|
```cmd
|
||||||
|
python tree_gen.py -o project_tree.md C:\Projects\my-app
|
||||||
|
```
|
||||||
|
|
||||||
|
生成 `project_tree.md`,包含:
|
||||||
|
- 目录结构(代码块格式)
|
||||||
|
- 文件列表(Markdown 表格,含路径和大小)
|
||||||
|
- 统计信息
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
1. **编码问题**:脚本自动配置终端 UTF-8 编码。如果终端显示乱码,请确保 Windows 终端支持 UTF-8(Windows 10 1903+ 默认支持)。
|
||||||
|
2. **权限限制**:遇到无权限访问的目录时,脚本会输出警告并跳过,不会中断执行。
|
||||||
|
3. **符号链接**:脚本自动检测并跳过符号链接循环,避免无限递归。
|
||||||
|
4. **大目录性能**:扫描包含大量文件的目录时可能需要较长时间,建议使用 `-d` 参数限制深度。
|
||||||
|
5. **Markdown 输出**:默认使用 UTF-8 with BOM 编码保存,确保 Windows 上的 Markdown 编辑器正确识别。
|
||||||
|
6. **忽略配置**:内置忽略列表始终生效,配置文件中的规则是**追加**而非替换。
|
||||||
|
7. **路径格式**:支持 Windows 路径格式(`C:\path\to\dir` 或 `C:/path/to/dir`)和相对路径(`.\`、`..\`)。
|
||||||
|
8. **深度参数**:`-d 1` 表示只显示根目录的直接子项,`-d 2` 显示两层,以此类推。
|
||||||
233
Releases/v1.0.0/source/README.md
Normal file
233
Releases/v1.0.0/source/README.md
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
# 目录树生成脚本 (tree_gen.py)
|
||||||
|
|
||||||
|
一个轻量级的 Python 命令行工具,用于生成目录树结构和文件列表,支持忽略配置、深度限制、多种输出格式,专为 Windows 平台优化。
|
||||||
|
|
||||||
|
## 功能特性
|
||||||
|
|
||||||
|
| 编号 | 功能 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| F013 | 路径输入 | 支持命令行传入目标目录路径,兼容相对路径和绝对路径 |
|
||||||
|
| F014 | 忽略配置 | 自动加载 `.treeignore` / `.gitignore`,内置 `.git`、`node_modules`、`__pycache__` 等常见忽略项 |
|
||||||
|
| F015 | 递归遍历 | 递归扫描子目录,生成完整树形结构,自动检测符号链接循环 |
|
||||||
|
| F016 | 目录树生成 | 使用 `├──` `└──` 字符绘制树形结构,缩进正确,目录名带 `/` 后缀 |
|
||||||
|
| F017 | 文件树生成 | 列出所有文件(含目录),带完整路径和文件大小 |
|
||||||
|
| F018 | 终端输出 | 终端直接显示格式化的树形结构和统计信息,自动处理 UTF-8 编码 |
|
||||||
|
| F019 | Markdown 保存 | 默认保存为 `tree_output.md`,包含目录结构代码块、文件列表表格和统计信息 |
|
||||||
|
| F020 | 统计信息 | 显示目录数、文件数、总大小(自动格式化为 B/KB/MB/GB) |
|
||||||
|
|
||||||
|
## 环境要求
|
||||||
|
|
||||||
|
- **操作系统**: Windows 10/11(也兼容 Linux/macOS)
|
||||||
|
- **Python**: 3.8 或更高版本
|
||||||
|
- **依赖**: 仅使用 Python 标准库,**零第三方依赖**
|
||||||
|
|
||||||
|
## 安装方法
|
||||||
|
|
||||||
|
无需安装。确保系统已安装 Python 3.8+ 即可直接使用:
|
||||||
|
|
||||||
|
```cmd
|
||||||
|
python --version
|
||||||
|
```
|
||||||
|
|
||||||
|
如果显示 Python 3.8 或更高版本,即可开始使用。
|
||||||
|
|
||||||
|
## 使用方法
|
||||||
|
|
||||||
|
### 基本语法
|
||||||
|
|
||||||
|
```cmd
|
||||||
|
python tree_gen.py [路径] [选项]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 命令行选项
|
||||||
|
|
||||||
|
| 选项 | 简写 | 说明 | 默认值 |
|
||||||
|
|------|------|------|--------|
|
||||||
|
| `path` | — | 目标目录路径 | 当前目录 (`.`) |
|
||||||
|
| `--output` | `-o` | Markdown 输出文件路径 | `tree_output.md` |
|
||||||
|
| `--depth` | `-d` | 最大递归深度(整数) | 无限制 |
|
||||||
|
| `--files-only` | `-f` | 仅显示文件树 | 关闭 |
|
||||||
|
| `--dirs-only` | `-D` | 仅显示目录树 | 关闭 |
|
||||||
|
| `--ignore` | `-i` | 忽略配置文件路径 | 自动检测 |
|
||||||
|
| `--help` | `-h` | 显示帮助信息 | — |
|
||||||
|
|
||||||
|
### 常用示例
|
||||||
|
|
||||||
|
```cmd
|
||||||
|
:: 生成当前目录的目录树
|
||||||
|
python tree_gen.py
|
||||||
|
|
||||||
|
:: 生成指定目录的目录树
|
||||||
|
python tree_gen.py C:\Users\Documents\project
|
||||||
|
|
||||||
|
:: 使用相对路径
|
||||||
|
python tree_gen.py ..\src
|
||||||
|
|
||||||
|
:: 限制深度为 2 层
|
||||||
|
python tree_gen.py -d 2 .
|
||||||
|
|
||||||
|
:: 仅显示文件树,输出到 files.md
|
||||||
|
python tree_gen.py -f -o files.md .
|
||||||
|
|
||||||
|
:: 仅显示目录树
|
||||||
|
python tree_gen.py -D .
|
||||||
|
|
||||||
|
:: 使用自定义忽略配置文件
|
||||||
|
python tree_gen.py -i my_ignore.txt .
|
||||||
|
|
||||||
|
:: 组合使用:限制深度 + 仅文件 + 自定义输出
|
||||||
|
python tree_gen.py -d 3 -f -o output.md C:\Projects
|
||||||
|
```
|
||||||
|
|
||||||
|
## 配置说明
|
||||||
|
|
||||||
|
### 忽略配置加载优先级
|
||||||
|
|
||||||
|
脚本按以下优先级加载忽略配置:
|
||||||
|
|
||||||
|
1. **命令行指定**:通过 `-i` 参数指定的配置文件
|
||||||
|
2. **`.treeignore`**:目标目录下的 `.treeignore` 文件
|
||||||
|
3. **`.gitignore`**:目标目录下的 `.gitignore` 文件(作为备选)
|
||||||
|
4. **内置默认列表**:以上均不存在时使用
|
||||||
|
|
||||||
|
### 内置默认忽略列表
|
||||||
|
|
||||||
|
以下目录和文件默认被忽略,无需额外配置:
|
||||||
|
|
||||||
|
```
|
||||||
|
目录: .git, .svn, .hg, node_modules, bower_components,
|
||||||
|
__pycache__, .pytest_cache, .idea, .vscode,
|
||||||
|
dist, build, target, venv, .venv, env
|
||||||
|
|
||||||
|
文件: .DS_Store, Thumbs.db, *.pyc
|
||||||
|
```
|
||||||
|
|
||||||
|
### `.treeignore` 文件格式
|
||||||
|
|
||||||
|
在目标目录下创建 `.treeignore` 文件,每行一个规则:
|
||||||
|
|
||||||
|
```text
|
||||||
|
# 注释行(以 # 开头)
|
||||||
|
# 精确匹配目录或文件名
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
*.log
|
||||||
|
.env
|
||||||
|
|
||||||
|
# 通配符模式(支持 * ? [ 等 glob 语法)
|
||||||
|
*.tmp
|
||||||
|
test_*.py
|
||||||
|
```
|
||||||
|
|
||||||
|
规则说明:
|
||||||
|
- 以 `#` 开头的行为注释,会被忽略
|
||||||
|
- 空行会被忽略
|
||||||
|
- 以 `/` 结尾的条目会被当作目录处理(尾部斜杠会被去除后匹配)
|
||||||
|
- 包含 `*`、`?`、`[` 的条目按通配符(glob)模式匹配
|
||||||
|
- 其他条目按精确名称匹配
|
||||||
|
|
||||||
|
## 使用示例
|
||||||
|
|
||||||
|
### 示例 1:基本目录树
|
||||||
|
|
||||||
|
```cmd
|
||||||
|
python tree_gen.py C:\Projects\my-app
|
||||||
|
```
|
||||||
|
|
||||||
|
输出:
|
||||||
|
|
||||||
|
```
|
||||||
|
my-app/
|
||||||
|
├── src/
|
||||||
|
│ ├── main.py
|
||||||
|
│ ├── utils/
|
||||||
|
│ │ ├── helper.py
|
||||||
|
│ │ └── config.py
|
||||||
|
│ └── models/
|
||||||
|
│ └── user.py
|
||||||
|
├── tests/
|
||||||
|
│ ├── test_main.py
|
||||||
|
│ └── test_utils.py
|
||||||
|
├── README.md
|
||||||
|
└── requirements.txt
|
||||||
|
|
||||||
|
==================================================
|
||||||
|
目录数: 4
|
||||||
|
文件数: 7
|
||||||
|
总大小: 12.3 KB
|
||||||
|
==================================================
|
||||||
|
```
|
||||||
|
|
||||||
|
### 示例 2:限制深度
|
||||||
|
|
||||||
|
```cmd
|
||||||
|
python tree_gen.py -d 1 C:\Projects\my-app
|
||||||
|
```
|
||||||
|
|
||||||
|
输出:
|
||||||
|
|
||||||
|
```
|
||||||
|
my-app/
|
||||||
|
├── src/
|
||||||
|
├── tests/
|
||||||
|
├── README.md
|
||||||
|
└── requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
### 示例 3:仅文件树
|
||||||
|
|
||||||
|
```cmd
|
||||||
|
python tree_gen.py -f .
|
||||||
|
```
|
||||||
|
|
||||||
|
输出:
|
||||||
|
|
||||||
|
```
|
||||||
|
my-app/
|
||||||
|
├── src/main.py
|
||||||
|
├── src/utils/helper.py
|
||||||
|
├── src/utils/config.py
|
||||||
|
├── src/models/user.py
|
||||||
|
├── tests/test_main.py
|
||||||
|
├── tests/test_utils.py
|
||||||
|
├── README.md
|
||||||
|
└── requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
### 示例 4:仅目录树
|
||||||
|
|
||||||
|
```cmd
|
||||||
|
python tree_gen.py -D .
|
||||||
|
```
|
||||||
|
|
||||||
|
输出:
|
||||||
|
|
||||||
|
```
|
||||||
|
my-app/
|
||||||
|
├── src/
|
||||||
|
│ ├── utils/
|
||||||
|
│ └── models/
|
||||||
|
└── tests/
|
||||||
|
```
|
||||||
|
|
||||||
|
### 示例 5:自定义输出文件
|
||||||
|
|
||||||
|
```cmd
|
||||||
|
python tree_gen.py -o project_tree.md C:\Projects\my-app
|
||||||
|
```
|
||||||
|
|
||||||
|
生成 `project_tree.md`,包含:
|
||||||
|
- 目录结构(代码块格式)
|
||||||
|
- 文件列表(Markdown 表格,含路径和大小)
|
||||||
|
- 统计信息
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
1. **编码问题**:脚本自动配置终端 UTF-8 编码。如果终端显示乱码,请确保 Windows 终端支持 UTF-8(Windows 10 1903+ 默认支持)。
|
||||||
|
2. **权限限制**:遇到无权限访问的目录时,脚本会输出警告并跳过,不会中断执行。
|
||||||
|
3. **符号链接**:脚本自动检测并跳过符号链接循环,避免无限递归。
|
||||||
|
4. **大目录性能**:扫描包含大量文件的目录时可能需要较长时间,建议使用 `-d` 参数限制深度。
|
||||||
|
5. **Markdown 输出**:默认使用 UTF-8 with BOM 编码保存,确保 Windows 上的 Markdown 编辑器正确识别。
|
||||||
|
6. **忽略配置**:内置忽略列表始终生效,配置文件中的规则是**追加**而非替换。
|
||||||
|
7. **路径格式**:支持 Windows 路径格式(`C:\path\to\dir` 或 `C:/path/to/dir`)和相对路径(`.\`、`..\`)。
|
||||||
|
8. **深度参数**:`-d 1` 表示只显示根目录的直接子项,`-d 2` 显示两层,以此类推。
|
||||||
445
Releases/v1.0.0/source/t023-architecture-design.md
Normal file
445
Releases/v1.0.0/source/t023-architecture-design.md
Normal file
@@ -0,0 +1,445 @@
|
|||||||
|
# 目录树生成脚本 — 技术方案设计(Windows 平台)
|
||||||
|
|
||||||
|
> 任务 ID: T023 | 项目 ID: PROJ-20260509011
|
||||||
|
> 功能清单: F013-F020(8 个功能,全部审批通过)
|
||||||
|
> 运行平台: Windows
|
||||||
|
> 设计日期: 2026-05-16
|
||||||
|
> 设计者: 脚本架构师
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 技术选型评估
|
||||||
|
|
||||||
|
### 1.1 功能级可行性分析(BAT vs Python)
|
||||||
|
|
||||||
|
| 功能编号 | 功能名称 | BAT 可行性 | Python 可行性 | 关键问题 |
|
||||||
|
|---------|---------|-----------|--------------|---------|
|
||||||
|
| F013 | 接收路径输入 | ✅ 完全可行 | ✅ 完全可行 | BAT: `%~1` 即可;Python: `argparse` |
|
||||||
|
| F014 | 加载忽略配置 | ⚠️ 勉强可行 | ✅ 完全可行 | BAT 读取配置文件需逐行 `for /f`,无结构化解析能力 |
|
||||||
|
| F015 | 递归遍历目录 | ⚠️ 勉强可行 | ✅ 完全可行 | BAT `for /r` 可递归但无法灵活控制跳过逻辑 |
|
||||||
|
| F016 | 生成目录树(├── 字符) | ❌ **不可行** | ✅ 完全可行 | Windows CMD 默认代码页 936 (GBK) 无法正确显示 `├` `─` 等 Unicode 制表符;需 `chcp 65001` 且仍有渲染问题。BAT 字符串拼接缩进极其困难 |
|
||||||
|
| F017 | 生成文件树 | ⚠️ 勉强可行 | ✅ 完全可行 | BAT `dir /s/b` 可列出文件,但格式化输出困难 |
|
||||||
|
| F018 | 终端输出 | ⚠️ 有条件可行 | ✅ 完全可行 | BAT 需处理 `chcp 65001` 编码切换,输出重定向时易乱码 |
|
||||||
|
| F019 | Markdown 保存 | ⚠️ 勉强可行 | ✅ 完全可行 | BAT 可 `echo > file.md`,但 Markdown 结构化内容拼接繁琐 |
|
||||||
|
| F020 | 统计信息(目录数/文件数/总大小) | ❌ **困难** | ✅ 完全可行 | BAT 无原生文件大小累加能力,`%%~zI` 在 `for` 循环中可用但累加大文件时易溢出(BAT 整数仅支持 32 位) |
|
||||||
|
|
||||||
|
### 1.2 BAT 核心缺陷总结
|
||||||
|
|
||||||
|
1. **Unicode 制表符渲染**:F016 要求输出 `├──` `└──` `│` 等字符,Windows CMD 默认代码页下这些字符会显示为乱码。虽然 `chcp 65001` 可以切换 UTF-8,但在 Windows 10 之前的系统上存在严重兼容性问题,且输出重定向到文件时编码转换不可靠。
|
||||||
|
2. **字符串处理**:BAT 的字符串拼接、缩进管理极其笨拙,无法优雅实现树形缩进逻辑。
|
||||||
|
3. **大文件统计**:BAT 的整数运算限制在 32 位有符号范围(约 ±21 亿),总大小超过 2GB 时会溢出。
|
||||||
|
4. **错误处理**:BAT 缺乏结构化异常处理机制(try/catch),错误恢复困难。
|
||||||
|
5. **配置解析**:BAT 无法优雅解析结构化配置文件(JSON/INI),只能逐行文本处理。
|
||||||
|
|
||||||
|
### 1.3 评估结论
|
||||||
|
|
||||||
|
**BAT 无法胜任 F016(目录树 Unicode 渲染)和 F020(大文件统计),其余功能也均存在明显缺陷。**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 技术选型结论
|
||||||
|
|
||||||
|
### 最终选择:Python 3.8+(标准库,无第三方依赖)
|
||||||
|
|
||||||
|
**理由:**
|
||||||
|
|
||||||
|
| 维度 | 说明 |
|
||||||
|
|-----|------|
|
||||||
|
| 功能覆盖 | 8 个功能全部可完美实现,无妥协 |
|
||||||
|
| Unicode 支持 | Python 原生 UTF-8 支持,`├──` 等字符渲染无问题 |
|
||||||
|
| 标准库 | `pathlib`、`os`、`argparse`、`datetime` 覆盖所有需求 |
|
||||||
|
| 文件大小 | Python 整数自动扩展,无溢出问题 |
|
||||||
|
| 跨版本兼容 | Python 3.8+ 覆盖 Windows 7 SP1 及以上所有版本 |
|
||||||
|
| 可维护性 | 代码结构清晰,模块化设计,易于扩展 |
|
||||||
|
| 部署 | 单文件 `.py`,用户只需安装 Python(Windows 10/11 可通过 Microsoft Store 一键安装) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
tree_generator/
|
||||||
|
├── tree_gen.py # 主程序(单文件,包含所有模块)
|
||||||
|
├── .treeignore # 忽略配置文件(可选,放在目标目录下)
|
||||||
|
├── tree_output.md # 默认输出文件(运行后生成)
|
||||||
|
└── README.md # 使用文档(由 Web 文档生成 Agent 创建)
|
||||||
|
```
|
||||||
|
|
||||||
|
**设计原则:**
|
||||||
|
- 主程序 `tree_gen.py` 为单文件,不拆分为多模块,便于分发和使用
|
||||||
|
- 忽略配置文件 `.treeignore` 放在目标目录下,遵循 `.gitignore` 惯例
|
||||||
|
- 输出文件默认在当前工作目录生成
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 模块说明表
|
||||||
|
|
||||||
|
| 模块名 | 负责功能 | 输入 | 输出 | 依赖 |
|
||||||
|
|-------|---------|------|------|------|
|
||||||
|
| `ArgParser` | F013 路径输入 | 命令行参数 `sys.argv` | 解析后的配置对象(目标路径、输出路径、深度限制等) | `argparse` 标准库 |
|
||||||
|
| `IgnoreLoader` | F014 忽略配置 | 配置文件路径(`.treeignore`) | 忽略目录名称集合 `set[str]` | `pathlib` 标准库 |
|
||||||
|
| `DirectoryScanner` | F015 递归遍历 | 目标路径 + 忽略集合 | 目录树结构(嵌套字典) | `pathlib`、`os` 标准库 |
|
||||||
|
| `TreeFormatter` | F016 目录树 + F017 文件树 | 目录树结构 | 格式化字符串(含 `├──` 缩进) | 无额外依赖 |
|
||||||
|
| `TerminalOutput` | F018 终端输出 | 格式化字符串 | 终端显示(stdout) | `sys` 标准库(编码设置) |
|
||||||
|
| `MarkdownWriter` | F019 Markdown 保存 | 格式化字符串 + 输出路径 | `.md` 文件 | `pathlib` 标准库 |
|
||||||
|
| `StatisticsCollector` | F020 统计信息 | 目录树结构 | 统计信息字典(目录数、文件数、总大小) | `os` 标准库 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 技术选型结论总结
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────┐
|
||||||
|
│ 最终技术选型:Python 3.8+ 标准库 │
|
||||||
|
│ │
|
||||||
|
│ 核心文件:tree_gen.py(单文件,约 300-400 行) │
|
||||||
|
│ 配置文件:.treeignore(可选) │
|
||||||
|
│ 输出文件:tree_output.md(默认) │
|
||||||
|
│ │
|
||||||
|
│ 选择理由: │
|
||||||
|
│ 1. BAT 无法正确处理 Unicode 制表符(F016) │
|
||||||
|
│ 2. BAT 整数溢出问题(F020 大文件统计) │
|
||||||
|
│ 3. Python 标准库完全覆盖所有 8 个功能 │
|
||||||
|
│ 4. 单文件部署,零第三方依赖 │
|
||||||
|
└─────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 错误处理策略
|
||||||
|
|
||||||
|
### 6.1 异常分类与处理
|
||||||
|
|
||||||
|
| 异常类型 | 触发条件 | 处理策略 | 用户提示 |
|
||||||
|
|---------|---------|---------|---------|
|
||||||
|
| `PathNotFoundError` | 目标路径不存在 | 捕获 `FileNotFoundError`,退出并提示 | `错误: 路径 "xxx" 不存在,请检查输入` |
|
||||||
|
| `PermissionError` | 无权限访问目录 | 跳过该目录,记录警告,继续遍历 | `警告: 无权限访问 "xxx",已跳过` |
|
||||||
|
| `SymlinkLoopError` | 符号链接循环 | 使用 `pathlib.Path.resolve()` 检测,跳过已访问路径 | `警告: 检测到符号链接循环,已跳过` |
|
||||||
|
| `EncodingError` | 文件名含特殊字符 | 使用 `errors='replace'` 容错 | 静默替换,不中断 |
|
||||||
|
| `OutputWriteError` | 无法写入输出文件 | 捕获 `IOError`/`PermissionError`,回退到仅终端输出 | `警告: 无法写入文件,仅显示到终端` |
|
||||||
|
| `InvalidConfigError` | 忽略配置文件格式错误 | 使用默认忽略列表,记录警告 | `警告: 配置文件格式错误,使用默认配置` |
|
||||||
|
|
||||||
|
### 6.2 退出码规范
|
||||||
|
|
||||||
|
| 退出码 | 含义 |
|
||||||
|
|-------|------|
|
||||||
|
| 0 | 成功完成 |
|
||||||
|
| 1 | 参数错误(路径不存在、格式错误) |
|
||||||
|
| 2 | 运行时错误(写入失败等) |
|
||||||
|
| 3 | 中断(Ctrl+C) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 接口定义
|
||||||
|
|
||||||
|
### 7.1 数据流图
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────┐ ┌──────────────┐ ┌─────────────────┐
|
||||||
|
│ 命令行参数 │────▶│ ArgParser │────▶│ Config 对象 │
|
||||||
|
│ sys.argv │ │ (F013) │ │ {path, output, │
|
||||||
|
└──────────┘ └──────────────┘ │ ignore, depth} │
|
||||||
|
└────────┬────────┘
|
||||||
|
│
|
||||||
|
┌───────────────────────────┼───────────────────────────┐
|
||||||
|
│ │ │
|
||||||
|
▼ ▼ ▼
|
||||||
|
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
|
||||||
|
│IgnoreLoader │ │Directory │ │Statistics │
|
||||||
|
│(F014) │ │Scanner │ │Collector │
|
||||||
|
│ │ │(F015) │ │(F020) │
|
||||||
|
│.treeignore │ │ │ │ │
|
||||||
|
└──────┬───────┘ └──────┬───────┘ └──────┬───────┘
|
||||||
|
│ │ │
|
||||||
|
▼ ▼ │
|
||||||
|
ignore_set tree_dict │
|
||||||
|
(set[str]) (嵌套字典) │
|
||||||
|
│ │
|
||||||
|
▼ │
|
||||||
|
┌──────────────────┐ │
|
||||||
|
│ TreeFormatter │◀────────────────────┘
|
||||||
|
│ (F016/F017) │
|
||||||
|
│ │
|
||||||
|
│ tree_str │
|
||||||
|
│ file_list_str │
|
||||||
|
└────────┬────────┘
|
||||||
|
│
|
||||||
|
┌───────────────────┼───────────────────┐
|
||||||
|
▼ ▼ ▼
|
||||||
|
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
|
||||||
|
│ Terminal │ │ Markdown │ │ 统计信息 │
|
||||||
|
│ Output │ │ Writer │ │ 输出 │
|
||||||
|
│ (F018) │ │ (F019) │ │ │
|
||||||
|
│ │ │ │ │ │
|
||||||
|
│ stdout │ │ tree_output │ │ 目录/文件/ │
|
||||||
|
│ │ │ .md │ │ 总大小 │
|
||||||
|
└──────────────┘ └──────────────┘ └──────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 核心数据结构
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Config 对象(ArgParser 输出)
|
||||||
|
Config = {
|
||||||
|
"target_path": Path, # 目标目录路径
|
||||||
|
"output_path": Path, # Markdown 输出路径(默认 tree_output.md)
|
||||||
|
"ignore_dirs": set[str], # 忽略目录名称集合
|
||||||
|
"max_depth": int | None, # 最大递归深度(None = 无限制)
|
||||||
|
"files_only": bool, # 仅显示文件模式
|
||||||
|
"dirs_only": bool, # 仅显示目录模式
|
||||||
|
}
|
||||||
|
|
||||||
|
# 目录树结构(DirectoryScanner 输出)
|
||||||
|
TreeDict = {
|
||||||
|
"name": str, # 目录/文件名
|
||||||
|
"path": Path, # 完整路径
|
||||||
|
"is_dir": bool, # 是否为目录
|
||||||
|
"children": list[TreeDict], # 子节点列表(目录时有效)
|
||||||
|
"size": int, # 文件大小(文件时有效)
|
||||||
|
}
|
||||||
|
|
||||||
|
# 统计信息(StatisticsCollector 输出)
|
||||||
|
Statistics = {
|
||||||
|
"dir_count": int, # 目录数量
|
||||||
|
"file_count": int, # 文件数量
|
||||||
|
"total_size": int, # 总大小(字节)
|
||||||
|
"total_size_human": str, # 人类可读大小(如 "1.23 GB")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.3 模块间接口
|
||||||
|
|
||||||
|
| 调用方 | 被调用方 | 接口方法 | 参数 | 返回值 |
|
||||||
|
|-------|---------|---------|------|--------|
|
||||||
|
| `main()` | `ArgParser` | `parse_args()` | `sys.argv[1:]` | `Config` |
|
||||||
|
| `main()` | `IgnoreLoader` | `load_ignores(config)` | `Config` | `set[str]` |
|
||||||
|
| `main()` | `DirectoryScanner` | `scan(path, ignore_set, max_depth)` | `Path, set[str], int\|None` | `TreeDict` |
|
||||||
|
| `main()` | `TreeFormatter` | `format_tree(tree, style)` | `TreeDict, str` | `str` |
|
||||||
|
| `main()` | `TreeFormatter` | `format_files(tree)` | `TreeDict` | `str` |
|
||||||
|
| `main()` | `TerminalOutput` | `output(text)` | `str` | `None` |
|
||||||
|
| `main()` | `MarkdownWriter` | `write(text, path)` | `str, Path` | `None` |
|
||||||
|
| `main()` | `StatisticsCollector` | `collect(tree)` | `TreeDict` | `Statistics` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 忽略配置方案(F014)
|
||||||
|
|
||||||
|
### 8.1 配置文件格式
|
||||||
|
|
||||||
|
文件名:`.treeignore`(放在目标目录下)
|
||||||
|
|
||||||
|
```
|
||||||
|
# 忽略配置示例
|
||||||
|
# 每行一个目录名,支持 # 注释
|
||||||
|
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
node_modules
|
||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
venv
|
||||||
|
.env
|
||||||
|
.idea
|
||||||
|
.vscode
|
||||||
|
dist
|
||||||
|
build
|
||||||
|
*.egg-info
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.2 内置默认忽略列表
|
||||||
|
|
||||||
|
当 `.treeignore` 不存在时,使用以下默认列表:
|
||||||
|
|
||||||
|
```python
|
||||||
|
DEFAULT_IGNORE = {
|
||||||
|
".git", ".svn", ".hg", # 版本控制
|
||||||
|
"node_modules", "bower_components", # Node.js
|
||||||
|
"__pycache__", "*.pyc", ".pytest_cache", # Python
|
||||||
|
".idea", ".vscode", # IDE
|
||||||
|
"dist", "build", "target", # 构建产物
|
||||||
|
".DS_Store", "Thumbs.db", # 系统文件
|
||||||
|
"venv", ".venv", "env", # 虚拟环境
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.3 匹配规则
|
||||||
|
|
||||||
|
- 目录名精确匹配(不区分大小写,Windows 特性)
|
||||||
|
- 支持 `*` 通配符(如 `*.pyc`)
|
||||||
|
- 忽略配置仅作用于目录级别,不递归检查文件内容
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 树形输出格式规范(F016/F017)
|
||||||
|
|
||||||
|
### 9.1 目录树格式
|
||||||
|
|
||||||
|
```
|
||||||
|
项目根目录/
|
||||||
|
├── src/
|
||||||
|
│ ├── main.py
|
||||||
|
│ ├── utils/
|
||||||
|
│ │ ├── helper.py
|
||||||
|
│ │ └── config.py
|
||||||
|
│ └── models/
|
||||||
|
│ └── user.py
|
||||||
|
├── tests/
|
||||||
|
│ ├── test_main.py
|
||||||
|
│ └── test_utils.py
|
||||||
|
├── config.json
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
**规则:**
|
||||||
|
- 目录名后加 `/` 后缀
|
||||||
|
- 分支符:`├── `(有后续兄弟节点)/ `└── `(最后一个节点)
|
||||||
|
- 缩进线:`│ `(有后续兄弟节点)/ ` `(无后续兄弟节点)
|
||||||
|
- 缩进单位:4 个字符(`│` + 3 空格 或 4 空格)
|
||||||
|
|
||||||
|
### 9.2 文件树格式
|
||||||
|
|
||||||
|
```
|
||||||
|
文件列表:
|
||||||
|
C:\project\src\main.py
|
||||||
|
C:\project\src\utils\helper.py
|
||||||
|
C:\project\src\utils\config.py
|
||||||
|
C:\project\src\models\user.py
|
||||||
|
C:\project\tests\test_main.py
|
||||||
|
C:\project\tests\test_utils.py
|
||||||
|
C:\project\config.json
|
||||||
|
C:\project\README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
**规则:**
|
||||||
|
- 每个文件一行,带完整绝对路径
|
||||||
|
- 使用 Windows 风格路径分隔符 `\`
|
||||||
|
- 按字母顺序排序
|
||||||
|
|
||||||
|
### 9.3 统计信息格式
|
||||||
|
|
||||||
|
```
|
||||||
|
统计信息:
|
||||||
|
目录数: 5
|
||||||
|
文件数: 8
|
||||||
|
总大小: 24.5 KB (25,088 字节)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Markdown 保存方案(F019)
|
||||||
|
|
||||||
|
### 10.1 输出文件格式
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# 目录树 — 项目根目录
|
||||||
|
|
||||||
|
> 生成时间: 2026-05-16 16:00:00
|
||||||
|
> 目标路径: C:\project
|
||||||
|
> 扫描深度: 无限制
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
项目根目录/
|
||||||
|
├── src/
|
||||||
|
│ ├── main.py
|
||||||
|
│ └── utils/
|
||||||
|
│ └── helper.py
|
||||||
|
├── config.json
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## 文件列表
|
||||||
|
|
||||||
|
| # | 文件路径 |
|
||||||
|
|---|---------|
|
||||||
|
| 1 | `C:\project\src\main.py` |
|
||||||
|
| 2 | `C:\project\src\utils\helper.py` |
|
||||||
|
| 3 | `C:\project\config.json` |
|
||||||
|
| 4 | `C:\project\README.md` |
|
||||||
|
|
||||||
|
## 统计信息
|
||||||
|
|
||||||
|
- **目录数:** 3
|
||||||
|
- **文件数:** 4
|
||||||
|
- **总大小:** 1.2 KB (1,234 字节)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10.2 保存策略
|
||||||
|
|
||||||
|
- 默认文件名:`tree_output.md`(当前工作目录)
|
||||||
|
- 可通过 `-o` / `--output` 参数指定自定义路径
|
||||||
|
- 文件已存在时覆盖写入(不追加)
|
||||||
|
- 编码:UTF-8 with BOM(Windows 记事本兼容)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 命令行接口设计
|
||||||
|
|
||||||
|
```
|
||||||
|
usage: tree_gen.py [-h] [-o OUTPUT] [-d DEPTH] [-f] [-D] [-i IGNORE_FILE] [path]
|
||||||
|
|
||||||
|
目录树生成脚本 - Windows 平台
|
||||||
|
|
||||||
|
positional arguments:
|
||||||
|
path 目标目录路径(默认: 当前目录)
|
||||||
|
|
||||||
|
options:
|
||||||
|
-h, --help 显示帮助信息
|
||||||
|
-o OUTPUT, --output OUTPUT
|
||||||
|
Markdown 输出文件路径(默认: tree_output.md)
|
||||||
|
-d DEPTH, --depth DEPTH
|
||||||
|
最大递归深度(默认: 无限制)
|
||||||
|
-f, --files-only 仅显示文件树
|
||||||
|
-D, --dirs-only 仅显示目录树
|
||||||
|
-i IGNORE_FILE, --ignore IGNORE_FILE
|
||||||
|
忽略配置文件路径(默认: 目标目录下的 .treeignore)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 使用示例
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 扫描当前目录
|
||||||
|
python tree_gen.py
|
||||||
|
|
||||||
|
# 扫描指定目录
|
||||||
|
python tree_gen.py C:\Users\test\project
|
||||||
|
|
||||||
|
# 指定输出文件和深度
|
||||||
|
python tree_gen.py C:\project -o output.md -d 3
|
||||||
|
|
||||||
|
# 仅显示文件
|
||||||
|
python tree_gen.py C:\project -f
|
||||||
|
|
||||||
|
# 自定义忽略配置
|
||||||
|
python tree_gen.py C:\project -i my_ignore.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 编码规范
|
||||||
|
|
||||||
|
| 项目 | 规范 |
|
||||||
|
|-----|------|
|
||||||
|
| 文件编码 | UTF-8(Python 源文件) |
|
||||||
|
| 输出编码 | UTF-8 with BOM(Markdown 文件) |
|
||||||
|
| 终端编码 | UTF-8(通过 `sys.stdout.reconfigure(encoding='utf-8')` 设置) |
|
||||||
|
| 行尾符 | LF(Python 标准) |
|
||||||
|
| 缩进 | 4 空格 |
|
||||||
|
| Python 版本 | 3.8+ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. 后续任务
|
||||||
|
|
||||||
|
| 任务 | 负责 Agent | 状态 |
|
||||||
|
|-----|-----------|------|
|
||||||
|
| T021: Python 代码实现 | BAT 编码 Agent(实际应为 Python 编码 Agent) | 待激活 |
|
||||||
|
| T022: 功能测试验证 | 测试验证 Agent | 待激活 |
|
||||||
|
| T024: 使用文档编写 | Web 文档生成 Agent | 待激活 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*文档结束。等待审批后进入编码阶段。*
|
||||||
528
Releases/v1.0.0/source/tree.sh
Executable file
528
Releases/v1.0.0/source/tree.sh
Executable file
@@ -0,0 +1,528 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# tree.sh - Directory tree generator
|
||||||
|
# Generates directory tree structure with optional file listing,
|
||||||
|
# markdown export, and statistics.
|
||||||
|
#
|
||||||
|
# Usage: ./tree.sh [-p PATH] [-o OUTPUT] [-d DEPTH] [-f] [-s] [-h] [-v]
|
||||||
|
#
|
||||||
|
# Version: 1.0.0
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# ── Constants ────────────────────────────────────────────────────────────────
|
||||||
|
readonly VERSION="1.0.0"
|
||||||
|
SCRIPT_NAME="$(basename "$0")"
|
||||||
|
readonly SCRIPT_NAME
|
||||||
|
readonly DEFAULT_OUTPUT="tree_output.md"
|
||||||
|
readonly IGNORE_FILE=".treeignore"
|
||||||
|
|
||||||
|
# Error codes
|
||||||
|
readonly ERR_OK=0
|
||||||
|
readonly ERR_USAGE=1
|
||||||
|
readonly ERR_PATH=2
|
||||||
|
readonly ERR_WRITE=3
|
||||||
|
# shellcheck disable=SC2034
|
||||||
|
readonly ERR_INTERNAL=4 # Fallback for unhandled errors
|
||||||
|
|
||||||
|
# ── Global State ─────────────────────────────────────────────────────────────
|
||||||
|
TARGET_PATH=""
|
||||||
|
OUTPUT_FILE="${DEFAULT_OUTPUT}"
|
||||||
|
MAX_DEPTH=0
|
||||||
|
SHOW_FILES=false
|
||||||
|
SHOW_STATS=true
|
||||||
|
declare -a IGNORE_PATTERNS=()
|
||||||
|
declare -A SEEN_INODES=()
|
||||||
|
|
||||||
|
# Counters for statistics
|
||||||
|
DIR_COUNT=0
|
||||||
|
FILE_COUNT=0
|
||||||
|
TOTAL_SIZE=0
|
||||||
|
|
||||||
|
# ── Module: parse_args ──────────────────────────────────────────────────────
|
||||||
|
# Parse command-line arguments and populate global state.
|
||||||
|
# Returns 0 on success, 1 on invalid arguments.
|
||||||
|
parse_args() {
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
-p|--path)
|
||||||
|
if [[ $# -lt 2 ]]; then
|
||||||
|
echo "Error: -p/--path requires an argument" >&2
|
||||||
|
return "$ERR_USAGE"
|
||||||
|
fi
|
||||||
|
TARGET_PATH="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
-o|--output)
|
||||||
|
if [[ $# -lt 2 ]]; then
|
||||||
|
echo "Error: -o/--output requires an argument" >&2
|
||||||
|
return "$ERR_USAGE"
|
||||||
|
fi
|
||||||
|
OUTPUT_FILE="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
-d|--depth)
|
||||||
|
if [[ $# -lt 2 ]]; then
|
||||||
|
echo "Error: -d/--depth requires an argument" >&2
|
||||||
|
return "$ERR_USAGE"
|
||||||
|
fi
|
||||||
|
if ! [[ "$2" =~ ^[0-9]+$ ]] || [[ "$2" -eq 0 ]]; then
|
||||||
|
echo "Error: depth must be a positive integer" >&2
|
||||||
|
return "$ERR_USAGE"
|
||||||
|
fi
|
||||||
|
MAX_DEPTH="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
-f|--files)
|
||||||
|
SHOW_FILES=true
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
-s|--no-stats)
|
||||||
|
SHOW_STATS=false
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
-h|--help)
|
||||||
|
show_help
|
||||||
|
exit "$ERR_OK"
|
||||||
|
;;
|
||||||
|
-v|--version)
|
||||||
|
echo "${SCRIPT_NAME} ${VERSION}"
|
||||||
|
exit "$ERR_OK"
|
||||||
|
;;
|
||||||
|
-*)
|
||||||
|
echo "Error: unknown option '$1'" >&2
|
||||||
|
echo "Run '${SCRIPT_NAME} --help' for usage." >&2
|
||||||
|
return "$ERR_USAGE"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
# Positional argument: treat as path
|
||||||
|
if [[ -z "$TARGET_PATH" ]]; then
|
||||||
|
TARGET_PATH="$1"
|
||||||
|
else
|
||||||
|
echo "Error: unexpected argument '$1'" >&2
|
||||||
|
return "$ERR_USAGE"
|
||||||
|
fi
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# Default to current directory if no path specified
|
||||||
|
if [[ -z "$TARGET_PATH" ]]; then
|
||||||
|
TARGET_PATH="."
|
||||||
|
fi
|
||||||
|
|
||||||
|
return "$ERR_OK"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Module: show_help ───────────────────────────────────────────────────────
|
||||||
|
show_help() {
|
||||||
|
cat <<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 "$@"
|
||||||
378
Releases/v1.0.0/source/tree_architecture_design.md
Normal file
378
Releases/v1.0.0/source/tree_architecture_design.md
Normal file
@@ -0,0 +1,378 @@
|
|||||||
|
# 目录树生成脚本 - 架构设计文档
|
||||||
|
|
||||||
|
项目 ID: PROJ-20260509011 | 任务 ID: T023 | 功能清单: F013-F020
|
||||||
|
设计者: 脚本架构师 | 日期: 2026-05-16 | 状态: 待审批
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
项目根目录/
|
||||||
|
├── tree.sh # 主脚本(单文件,约 300-400 行)
|
||||||
|
├── .treeignore # 忽略配置文件(可选,用户创建)
|
||||||
|
├── tree_output.md # 默认输出文件(脚本生成)
|
||||||
|
└── README.md # 使用文档(后续任务产出)
|
||||||
|
```
|
||||||
|
|
||||||
|
设计原则:单文件脚本,零安装依赖,即拿即用。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 模块说明
|
||||||
|
|
||||||
|
### 2.1 parse_args
|
||||||
|
- 负责功能: F013 路径输入解析
|
||||||
|
- 输入: 命令行参数
|
||||||
|
- 输出: 规范化路径变量 TARGET_PATH
|
||||||
|
- 依赖: bash 内置 getopts
|
||||||
|
|
||||||
|
### 2.2 load_ignore_config
|
||||||
|
- 负责功能: F014 加载忽略配置
|
||||||
|
- 输入: .treeignore 文件路径
|
||||||
|
- 输出: 忽略模式数组 IGNORE_PATTERNS[]
|
||||||
|
- 依赖: bash 内置 read/while
|
||||||
|
|
||||||
|
### 2.3 should_ignore
|
||||||
|
- 负责功能: F014 忽略判断
|
||||||
|
- 输入: 目录名 + IGNORE_PATTERNS[]
|
||||||
|
- 输出: 布尔值(0=不忽略, 1=忽略)
|
||||||
|
- 依赖: bash 内置 case/模式匹配
|
||||||
|
|
||||||
|
### 2.4 traverse_directory
|
||||||
|
- 负责功能: F015 递归遍历
|
||||||
|
- 输入: 路径 + 忽略列表 + 缩进级别
|
||||||
|
- 输出: 目录树结构(嵌套关联数组)
|
||||||
|
- 依赖: bash 内置 for/find
|
||||||
|
|
||||||
|
### 2.5 render_tree
|
||||||
|
- 负责功能: F016 目录树渲染
|
||||||
|
- 输入: 目录树结构
|
||||||
|
- 输出: 格式化字符串(├──/└──)
|
||||||
|
- 依赖: bash 内置字符串操作
|
||||||
|
|
||||||
|
### 2.6 collect_files
|
||||||
|
- 负责功能: F017 文件收集
|
||||||
|
- 输入: 路径 + 忽略列表
|
||||||
|
- 输出: 文件路径列表(数组)
|
||||||
|
- 依赖: bash 内置 find
|
||||||
|
|
||||||
|
### 2.7 render_file_list
|
||||||
|
- 负责功能: F017 文件树渲染
|
||||||
|
- 输入: 文件路径列表
|
||||||
|
- 输出: 格式化字符串(带完整路径)
|
||||||
|
- 依赖: bash 内置字符串操作
|
||||||
|
|
||||||
|
### 2.8 output_terminal
|
||||||
|
- 负责功能: F018 终端输出
|
||||||
|
- 输入: 格式化字符串
|
||||||
|
- 输出: 终端显示
|
||||||
|
- 依赖: bash 内置 echo/printf
|
||||||
|
|
||||||
|
### 2.9 save_markdown
|
||||||
|
- 负责功能: F019 Markdown 保存
|
||||||
|
- 输入: 格式化字符串 + 输出路径
|
||||||
|
- 输出: tree_output.md 文件
|
||||||
|
- 依赖: bash 内置重定向
|
||||||
|
|
||||||
|
### 2.10 compute_stats
|
||||||
|
- 负责功能: F020 统计信息
|
||||||
|
- 输入: 遍历结果
|
||||||
|
- 输出: 目录数、文件数、总大小
|
||||||
|
- 依赖: du/find/wc
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 技术选型
|
||||||
|
|
||||||
|
### 3.1 语言与运行时
|
||||||
|
|
||||||
|
- Bash 4.0+ — 支持关联数组,现代 Linux 发行版标配(已确认)
|
||||||
|
- find (POSIX) — 递归遍历目录,支持 -name/-prune 过滤(已确认)
|
||||||
|
- du (POSIX) — 计算目录总大小(已确认)
|
||||||
|
- stat / ls (POSIX) — 获取文件大小,跨平台兼容(已确认)
|
||||||
|
- wc (POSIX) — 统计行数/条目数(已确认)
|
||||||
|
|
||||||
|
### 3.2 第三方库
|
||||||
|
|
||||||
|
本项目不引入任何第三方库。全部使用 POSIX/Bash 内置工具,确保:
|
||||||
|
- 零安装依赖,开箱即用
|
||||||
|
- 兼容所有主流 Linux 发行版(Ubuntu/CentOS/Debian/Arch 等)
|
||||||
|
- 兼容 macOS(Bash 5.x / zsh 兼容模式)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 功能详细设计
|
||||||
|
|
||||||
|
### 4.1 F013 - 路径输入解析
|
||||||
|
|
||||||
|
用法:
|
||||||
|
```
|
||||||
|
./tree.sh [路径] [选项]
|
||||||
|
```
|
||||||
|
|
||||||
|
选项:
|
||||||
|
- `-p, --path <路径>` — 指定目标目录(支持相对/绝对路径)
|
||||||
|
- `-o, --output <文件>` — 指定输出文件路径(默认 tree_output.md)
|
||||||
|
- `-d, --depth <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 使用文档编写
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*文档结束*
|
||||||
654
Releases/v1.0.0/source/tree_gen.py
Normal file
654
Releases/v1.0.0/source/tree_gen.py
Normal file
@@ -0,0 +1,654 @@
|
|||||||
|
#!/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()
|
||||||
BIN
Releases/v1.0.0/tree-generator-v1.0.0.zip
Normal file
BIN
Releases/v1.0.0/tree-generator-v1.0.0.zip
Normal file
Binary file not shown.
1
source
Submodule
1
source
Submodule
Submodule source added at c278c5a1dd
654
tree_gen.py
Normal file
654
tree_gen.py
Normal file
@@ -0,0 +1,654 @@
|
|||||||
|
#!/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()
|
||||||
Reference in New Issue
Block a user