proj-janitor 0.2.7

Scan, back up, restore, and clean old projects safely
proj-janitor-0.2.7 is not a library.

proj-janitor

扫描、备份、恢复并安全清理旧项目的轻量命令行工具。适合在多年积累的项目目录(如 ~/projects)中先盘点、再归档、需要时恢复,最后删除构建产物或整个已备份项目。

安全原则

  • scan 只读:报告项目大小、Git 分支和工作区状态。
  • backup 优先记录可恢复信息:已推送到 GitHub 的干净项目只写入远程 URL 和提交 SHA;其余项目回退为 tar.gz 并记录 SHA-256。
  • 备份目录可通过 BACKUP_DEST 环境变量指定,也可用 --backup-dest 指定;命令行参数优先。空的 BACKUP_DEST 按未设置处理。恢复仍使用 --dest。
  • restore 默认恢复到 manifest 记录的原始位置,指定 --dest 时恢复到新目录;dry-run 规划时校验归档 SHA-256;实际恢复在确认后、写入前预检整批摘要,解包时再用同一文件句柄复核,且不覆盖已有目录。
  • verify 命令校验归档 SHA-256;Git 记录只验证 manifest 中的 URL 和提交格式,不联网检查远程。该命令从 Rust 0.2.5 开始包含在 crates.io 发布版中。
  • clean 默认 dry-run:只匹配白名单内的构建产物目录/文件。
  • Unix 上新建的备份根目录和项目目录权限为 0700,归档和 manifest 为 0600;已有目录权限不变。Windows 使用备份目录继承的 ACL。
  • 永远不删除项目根目录,永远跳过 .git,不跟随符号链接。
  • 真正删除需要同时提供 --apply 和交互确认(或 --yes)。

安装

Rust 命令行版(推荐免 Python 环境)

需要 Rust 1.88 或更新版本。安装 Rust 工具链后,在仓库根目录运行:

cargo install --locked proj-janitor

当前 crates.io 已发布版本为 0.2.6。需要固定版本时运行:

cargo install --locked proj-janitor --version 0.2.6

发布内容与验证信息见 Rust 版 0.2.6 发布记录。

从源码开发安装可运行 cargo install --locked --path .。安装后可直接使用 projjan。Rust 版与 Python 版共用 CLI 名称,若同时安装,后安装的版本会覆盖同名入口;需要并存时可分别安装到不同 --root 并使用对应路径。

如需安装 main 的最新开发版本,可直接从 Git 安装:

cargo install --locked --git https://github.com/TangCan/proj-janitor.git --branch main proj-janitor

这会安装 main 当前源码,随后的提交可能改变功能;日常使用建议安装 crates.io 稳定版 0.2.6。

Rust 版会生成和读取 schema v2 manifest,支持 scan、backup、restore、verify、clean。默认备份策略与 Python 版一致:有充分本地证据表明可从 GitHub 恢复时仅记录 URL 与提交,否则创建 tar.gz 归档。归档备份会把硬链接按普通文件内容保存,不保留链接关系;FIFO、设备和 socket 不归档。归档恢复拒绝跨平台越界路径、重复成员、硬链接、未知成员类型、大小写或 Unicode 规范化冲突路径和特殊文件;Unix 可恢复项目内符号链接。Windows 也支持文件与目录符号链接恢复,但运行账户必须有创建符号链接的权限。Git 策略恢复需本机安装 Git;Git LFS 项目还需将 git-lfs 命令安装到 PATH,安装方法见 GitHub 文档。

Rust 版首次从源码安装需要 Cargo 下载依赖;安装完成后的程序不需要 Python。backup 仍可用 BACKUP_DEST 或 --backup-dest 指定目录。

Python 版

python3 -m venv .venv
source .venv/bin/activate
pip install -e .

也可以不安装,直接运行:

python3 -m proj_janitor --help

使用

1. 盘点

projjan scan ~/richard_plus
projjan scan ~/richard_plus --json
projjan scan ~/richard_plus --all

输出项目名、大小、Git 分支/状态、识别到的项目标记和最后修改时间。默认只识别带 .git、pyproject.toml、package.json、Cargo.toml 等标记的目录;--all 会把普通目录也列出来。

2. 备份

projjan backup ~/richard_plus/amp_zk ~/richard_plus/bos_dynamic --backup-dest ~/backups/projects
projjan backup ~/richard_plus --backup-dest ~/backups/projects --dry-run
projjan backup ~/richard_plus --backup-dest ~/backups/projects --include-artifacts
projjan backup ~/richard_plus --backup-dest ~/backups/projects --always-archive
# 归档时排除 .git 元数据,恢复后不含 Git 历史
projjan backup ~/richard_plus/bos_dynamic --backup-dest ~/backups/projects --always-archive --exclude-git-metadata

也可设置默认备份目录,之后省略选项:

export BACKUP_DEST=~/backups/projects
projjan backup ~/richard_plus --dry-run
projjan backup ~/richard_plus

备份策略按项目自动选择:

  • git:工作区干净(包含子模块)、无 stash,所有本地分支和本地 tag 均已推送,且远程及子模块远程受支持时,仅记录 Git 远程、分支、上游和提交 SHA,不生成归档。未初始化(目录内无额外文件)或已初始化且干净的子模块均支持;恢复时按主仓库记录的 gitlink 提交递归克隆子模块。含 Git LFS 属性的仓库也支持,但备份与恢复都要求安装 git-lfs,并使用默认 GitHub LFS endpoint。
  • archive:其余情况回退为 tar.gz,并在清单和 Rust/Python 命令输出中说明回退原因(例如 working tree is not clean、Git LFS is not installed、submodule URL is not a supported GitHub URL、remote is not a supported GitHub URL、local tag is not pushed: v1)。修改过或提交位置不匹配的子模块会回退归档;若需归档时 remote/submodule/LFS URL 含 userinfo、query 或 fragment,或本地 Git 配置含认证 header、密码、内联 helper、认证参数、自定义 SSH command 或项目内 credential 文件,自动备份会失败且不写归档或 manifest。
  • 备份会在写入前拒绝 Windows 保留名或其他不可移植的项目目录名,避免生成无法由 restore 接受的 manifest。
  • 存在本地 tag 时,URL-only 判定会执行 git ls-remote --tags --refs <remote>,逐个比较远程 tag 对象 ID;远程不可达、tag 缺失或指向不一致都会回退归档。没有本地 tag 时不会发起这次网络查询。
  • 受支持的 GitHub HTTPS 主 remote URL 可以内嵌 PAT(如 https://user:token@github.com/... 或 https://token@github.com/...);只有采用 Git-only 时才会安全使用,记录和命令输出会去掉用户名/令牌,只保留 https://github.com/...。如果需归档,则不论 fetch/push URL 是否受支持,只要其中含凭据、query 或 fragment,或本地 Git 配置含认证 header、credential password、helper 中无法识别的参数、敏感 service URL 或指向项目内的 credential 文件,自动备份都会报错并且不写 archive/manifest。helper 仅允许标准 cache --timeout 和 store --file 参数形式;Git helper 由 shell 执行;store --file 路径仅接受字面参数,shell 引号、变量、命令替换、named-home、通配符和其它展开语法会被拒绝。独立 --file ~/path 形式会按 home 目录展开并检查。其它未识别参数也按可能包含不透明编码凭据处理,避免其随 .git/config 落入归档。
  • 子模块 URL 支持无凭据的 GitHub URL,以及相对于 GitHub 主仓库的 ./ / ../ URL。含凭据、query 或 fragment 的 URL 会让自动备份失败,不生成归档/manifest,避免敏感值被二次保存或写入恢复仓库的 .git/config。Git 备份只记录主仓库提交,不镜像子模块对象;恢复需要子模块远端仍可访问且固定提交仍存在。恢复会先在临时目录取回全部子模块,成功后才发布目标目录。
  • Git LFS 内容与 Git 提交中的指针分开存放。恢复会在临时仓库配置本地 LFS filter 并执行 git lfs pull;LFS 对象缺失或不可访问时恢复失败,不创建目标目录。.lfsconfig 和自定义 LFS endpoint 暂不支持。
  • 归档会包含 .git/config 和项目跟踪文件(包括 .gitmodules)。如果其中内嵌凭据,归档也会包含该凭据。显式使用 --always-archive 或 --include-artifacts 时仍会按要求打包;执行前应清理凭据并限制归档访问。

默认归档会排除构建产物与依赖缓存,避免把 node_modules、target、虚拟环境打进压缩包;--include-artifacts 强制完整归档,--always-archive 强制所有项目都归档。

Rust 0.2.6 支持 --exclude-git-metadata:使用 archive 策略时跳过项目中的 .git 目录/文件,可显著缩小含大型 Git 历史的项目备份。该选项不修改源项目,也不强制 archive 策略;从这种归档恢复后只得到工作树,没有分支、tag、提交历史或 remote 配置。Python 包 0.1.0 尚不包含该选项。需要保留 Git 历史时不要使用。

每个项目使用一个稳定的项目目录,不再创建 backup-.../ 运行目录。纯 git 备份只在该目录下写清单:

backups/
└── xtask_todo/
    ├── manifest-20261023-122828.json
    └── manifest-20261023-122832.json

需要归档的项目也写入同一个项目目录,归档文件名带运行时间戳:

backups/
├── xtask_todo/
│   ├── manifest-20261023-103117.json
│   └── xtask_todo-20261023-103117.tar.gz
└── another_project/
    ├── manifest-20261023-103117.json
    └── another_project-20261023-103117.tar.gz
  • 一次运行包含多个项目时,每个项目目录各写一份只包含该项目记录的清单。
  • 同一项目不同时间的清单和归档共用项目目录,文件名带时间戳。
  • 不同根目录下存在同名、大小写不同或 Unicode 规范化等价的项目名时,项目目录会追加 8 位路径哈希,例如 app-1a2b3c4d/。
  • 同一秒内重复备份同一项目时,文件名会追加 -2、-3,不会覆盖已有文件。
  • 旧版本生成的 backup-YYYYmmdd-HHMMSS/ 目录不会被自动迁移或删除。

清单使用 schema_version: 2,顶层记录 include_artifacts、always_archive、exclude_git_metadata 和 records。exclude_git_metadata 在项目使用 archive 策略且启用了排除选项时为 true;它表示所选策略,不表示源项目一定含有 .git。git 记录包含 remote_url、branch、commit、upstream;archive 记录包含归档路径、大小、源大小、SHA-256 和可选的 fallback_reason。

3. 清理

projjan clean ~/richard_plus
projjan clean ~/richard_plus --apply
projjan clean ~/richard_plus --apply --yes
projjan clean ~/richard_plus --rules node,python-cache
projjan clean . --list-rules

默认展示将被删除的目录/文件及可回收空间,不会删除任何内容。确认后加 --apply;非交互环境还需 --yes。POSIX Python 使用目录文件描述符相对删除,并核对计划时记录的项目根身份及打开后的目标目录身份;Rust 记录并核对打开的目录 capability 根身份,再限定相对删除范围。Python Windows 暂使用根目录身份与路径校验回退。所有实现都会核对清理计划记录的目标身份,若目标已被替换则拒绝删除;极窄的检查与 unlink 间竞态仍需注意。

规则 匹配名称
python-cache __pycache__, .pytest_cache, .mypy_cache, .ruff_cache, *.pyc, .coverage, htmlcov
python-env .venv, venv, .tox, .nox, *.egg-info
node node_modules, .next, .nuxt, .svelte-kit, .turbo, .parcel-cache, coverage
build build, dist, out, target, CMakeFiles, .gradle
editor .cache, .DS_Store, Thumbs.db

推荐流程:

projjan scan ~/richard_plus
projjan backup ~/richard_plus --backup-dest ~/backups/projects
projjan clean ~/richard_plus
projjan clean ~/richard_plus --apply

旧项目确认不再需要后,再手动删除源目录,备份归档保留在 BACKUP_DEST 或 --backup-dest 指定的位置。

4. 恢复

restore 根据 manifest 自动恢复项目。省略 --dest 时,恢复到 manifest 中记录的原始 source 路径;指定 --dest 时,恢复到 DIR/<项目名>/:

# 恢复到原始位置;交互终端会先列出目标并要求确认
projjan restore ~/backups/projects/xtask_todo/manifest-20261023-122828.json

# 非交互脚本显式确认;--json 的实际恢复也必须带 --yes
projjan restore <manifest> --yes

# 恢复到副本目录
projjan restore <manifest> --dest ~/restored

# 只预览计划,不写文件
projjan restore <manifest> --dry-run
projjan restore <manifest> --dest ~/restored --dry-run

# 多记录 manifest 只恢复指定项目
projjan restore <manifest> --dest ~/restored --project xtask_todo

# 输出纯 JSON
projjan restore <manifest> --dest ~/restored --dry-run --json

# Rust 0.2.5 及更新版本:校验归档 SHA-256,并检查 Git 备份元数据(不联网)
projjan verify <manifest>

# 只校验指定项目,并输出 JSON
projjan verify <manifest> --project xtask_todo --json

verify 的 Git 结果标记为 metadata-only:不会联网确认远程仓库或提交是否仍可用。

恢复规则:

  • 省略 --dest 时,目标取自每条记录的 source;source 必须是绝对路径、文件名等于项目名,且不能包含 .. 或 NUL。伪造或损坏的 manifest 可能指向任意可写路径,因此实际恢复到原始位置前必须确认 manifest 来源可信。

  • 原始位置模式默认要求交互确认;标准输入不是 TTY 时必须显式加 --yes,--json 的实际恢复也必须加 --yes。--dest 模式不需要该确认。

  • git 策略:初始化仓库,将脱敏 URL 写入 origin,再直接从远程 fetch 分支和 tags(不写 FETCH_HEAD),最后 git checkout --detach <commit>。恢复后是 detached HEAD,不会自动创建或切换原分支;远程必须仍包含该提交。

  • Git 恢复的 init、remote add、fetch、checkout 和 HEAD 校验单步最长等待 10 分钟;超时会尝试终止 Git 进程树并报告失败,目标项目不会发布到最终路径。

  • 远程 URL 带凭据或查询参数时,Git 恢复失败会隐藏原始错误文本,避免错误输出回显敏感 URL。

  • 带认证信息、查询或 fragment 的 URL 通过 Git 的临时配置环境传递;若本机 Git 不支持此方式,工具会拒绝恢复,并提示改用 credential helper/SSH 或升级 Git。

  • 该方式避免凭据写入仓库配置或命令行,但同权限进程仍可能检查 Git 进程环境;共享机器应优先使用 credential helper/SSH。

  • archive 策略:默认先按 manifest 校验 SHA-256;执行恢复时会在同一打开的归档句柄上重新校验,再从该句柄解包,避免计划后归档路径被替换。--no-verify 可显式跳过校验。

  • 归档会先解压到临时目录,确认顶层目录为项目名后再移动到目标位置;拒绝绝对路径、.. 路径、Windows 保留设备名/非法字符/尾随点空格、含冒号的成员路径/链接目标(避免 ADS)和越界链接。

  • 目标目录已存在或是符号链接时拒绝覆盖;需要恢复副本时请指定 --dest,或先自行移走旧目录。

  • 多项目恢复按顺序执行;中途失败时不自动回滚,错误信息会列出此前已恢复的项目和目标。

  • manifest 中的归档绝对路径失效时,会尝试 manifest 同目录及 <项目名>/ 下的同名归档,便于整体移动备份目录。

  • 原始位置路径与备份时的机器和目录布局绑定;在另一台机器或不同用户下恢复时,应使用 --dest。

  • 恢复后如需继续开发 Git 项目,可自行创建分支:

    git -C ~/restored/xtask_todo switch -c main
    

git 恢复依赖远程仓库和本机凭据配置;私有仓库可使用 gh auth setup-git、SSH 或 credential helper。恢复后,仓库的 origin 会移除 URL 中的认证信息、查询和 fragment,保留 SSH 登录名;使用查询参数定位仓库的特殊远程需在恢复后手动重设 origin。后续访问私有仓库需使用本机凭据配置。工具只验证 manifest 中的 URL 形式和提交格式,不会预检远程是否仍存在该提交。

项目结构

.
├── proj_janitor/       # Python 实现
│   ├── config.py       # 项目标记与清理规则
│   ├── models.py       # 数据模型与大小格式化
│   ├── scanner.py      # 项目发现、大小与 Git 状态
│   ├── backup.py       # URL-only 记录、tar.gz 归档与清单
│   ├── restore.py      # 清单校验、Git/归档恢复与安全解压
│   ├── cleaner.py      # 清理计划与执行
│   └── cli.py          # argparse 命令行入口
├── src/                # Rust CLI
│   ├── main.rs         # CLI 入口与命令分发
│   ├── project.rs      # 项目发现与 Git 可恢复性
│   ├── backup.rs       # 备份计划执行与 manifest
│   ├── archive.rs      # 归档、校验与安全解压
│   ├── restore.rs      # manifest 预检与恢复
│   ├── clean.rs        # 清理计划与执行
│   └── tests.rs        # Rust 单元测试
└── tests/
    ├── test_*.py      # Python unittest 测试
    └── rust_cli.rs    # Rust CLI 集成测试

开发

python3 -m unittest discover -s tests -v

最近验证(2026-11-02):Python 共 166 项(Linux 本地 165 项通过、1 项 Windows 专属测试跳过);Rust main 开发版通过 43 项单元测试及 16 项 CLI 集成测试,cargo fmt --check 和 Clippy 通过。当前 main CI run 41386003202 18/18 jobs、CodeQL run 41386003215 三项分析通过。Rust 0.2.6 已从 crates.io 安装验证,projjan --version 输出 0.2.6;main 0.2.7 候选隐藏 backup --help 中的 BACKUP_DEST 实际路径,并补强备份失败清理的身份检查;尚未发布。候选发布前检查见 0.2.7 发布记录。Python 版无第三方运行时依赖,要求 Python 3.10+。 Rust 0.2.6 已发布至 crates.io;详见 Rust 版 0.2.6 发布记录。

Rust 版开发检查:

cargo fmt --check
cargo clippy --all-targets --locked -- -D warnings
cargo test --locked
cargo package --locked
cargo audit
actionlint

Python 版回归检查:

python3 -m unittest discover -s tests -v

许可证

本项目采用 MIT License。

安全漏洞报告方式见 SECURITY.md。

后续计划

  • 支持 git bundle,保留完整提交历史并减小归档体积。
  • 增量备份与保留策略(例如只保留最近 N 份)。
  • 配置文件(排除规则、备份目标、保留策略)。
  • 恢复时对 Git 远程提交做联网预检。