Files

298 lines
9.6 KiB
Python
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
任务工具函数。
提供:
is_safe_task_path - 校验任务路径是否可安全操作
find_task_by_name - 按名称查找任务目录
resolve_task_dir - 从名称、相对或绝对路径解析任务目录
archive_task_dir - 将任务归档到月度目录
run_task_hooks - 执行任务生命周期 hook
"""
from __future__ import annotations
import shutil
import sys
from datetime import datetime
from pathlib import Path
from .paths import get_repo_root, get_tasks_dir
# =============================================================================
# 路径安全
# =============================================================================
def is_safe_task_path(task_path: str, repo_root: Path | None = None) -> bool:
"""检查相对任务路径是否可安全操作。
参数:
task_path:任务路径(相对于 repo_root)。
repo_root:仓库根路径;默认自动检测。
返回:
安全时返回 True,危险时返回 False。
"""
if repo_root is None:
repo_root = get_repo_root()
normalized = task_path.replace("\\", "/")
# 检查空值或 null。
if not normalized or normalized == "null":
print("错误:任务路径为空或为 null。", file=sys.stderr)
return False
# 拒绝绝对路径。
if Path(task_path).is_absolute():
print(f"错误:不允许使用绝对路径:{task_path}", file=sys.stderr)
return False
# 拒绝 "."、".."、以 "./" 或 "../" 开头的路径,以及包含 ".." 的路径。
if normalized in (".", "..") or normalized.startswith("./") or normalized.startswith("../") or ".." in normalized:
print(f"错误:不允许路径穿越:{task_path}", file=sys.stderr)
return False
# 最终检查:确保解析结果不是仓库根目录。
abs_path = repo_root / Path(normalized)
if abs_path.exists():
try:
resolved = abs_path.resolve()
root_resolved = repo_root.resolve()
if resolved == root_resolved:
print(f"错误:路径解析到了仓库根目录:{task_path}", file=sys.stderr)
return False
except (OSError, IOError):
pass
return True
def is_within_tasks_dir(task_dir_abs: Path, repo_root: Path | None = None) -> bool:
"""检查解析后的任务目录确实位于 tasks 目录下。
真实任务直接位于 ``.trellis/tasks/<name>``。只有 ``task_dir_abs`` 是 tasks 目录的
直接子目录时才返回 True。
该检查保护 archive:未知名称会让 ``resolve_task_dir`` 回退到 ``repo_root/<name>``,
因此误输入 ``task.py archive src`` 会解析到真实的 ``src/`` 源码目录。没有此检查,
archive 会通过 ``shutil.move`` 将它移出仓库。同时拒绝 tasks 目录本身和 ``archive/``
下的任何路径(已归档任务)。
"""
if repo_root is None:
repo_root = get_repo_root()
try:
resolved = task_dir_abs.resolve()
tasks_resolved = get_tasks_dir(repo_root).resolve()
except (OSError, RuntimeError):
return False
if resolved.parent != tasks_resolved:
return False
return resolved.name != "archive"
# =============================================================================
# 任务查找
# =============================================================================
def find_task_by_name(task_name: str, tasks_dir: Path) -> Path | None:
"""按名称查找任务目录(精确匹配或后缀匹配)。
参数:
task_name:要查找的任务名称。
tasks_dir:tasks 目录路径。
返回:
任务目录的绝对路径;找不到时返回 None。
"""
if not task_name or not tasks_dir or not tasks_dir.is_dir():
return None
# 先尝试精确匹配。
exact_match = tasks_dir / task_name
if exact_match.is_dir():
return exact_match
# 再尝试后缀匹配(例如 "my-task" 匹配 "01-21-my-task")。
for d in tasks_dir.iterdir():
if d.is_dir() and d.name.endswith(f"-{task_name}"):
return d
return None
# =============================================================================
# 归档操作
# =============================================================================
def archive_task_dir(task_dir_abs: Path, repo_root: Path | None = None) -> Path | None:
"""将任务目录归档到 archive/{YYYY-MM}/。
参数:
task_dir_abs:任务目录的绝对路径。
repo_root:仓库根路径;默认自动检测。
返回:
归档目录路径;出错时返回 None。
"""
if not task_dir_abs.is_dir():
print(f"错误:找不到任务目录:{task_dir_abs}", file=sys.stderr)
return None
# 获取 tasks 目录(任务的父目录)。
tasks_dir = task_dir_abs.parent
archive_dir = tasks_dir / "archive"
year_month = datetime.now().strftime("%Y-%m")
month_dir = archive_dir / year_month
# 创建 archive 目录。
try:
month_dir.mkdir(parents=True, exist_ok=True)
except (OSError, IOError) as e:
print(f"错误:创建归档目录失败:{e}", file=sys.stderr)
return None
# 将任务移动到 archive。
task_name = task_dir_abs.name
dest = month_dir / task_name
try:
shutil.move(str(task_dir_abs), str(dest))
except (OSError, IOError, shutil.Error) as e:
print(f"错误:移动任务到归档目录失败:{e}", file=sys.stderr)
return None
return dest
def archive_task_complete(
task_dir_abs: Path,
repo_root: Path | None = None
) -> dict[str, str]:
"""完成归档流程:移动任务目录。
参数:
task_dir_abs:任务目录的绝对路径。
repo_root:仓库根路径;默认自动检测。
返回:
包含归档结果信息的 dict。
"""
if not task_dir_abs.is_dir():
print(f"错误:找不到任务目录:{task_dir_abs}", file=sys.stderr)
return {}
archive_dest = archive_task_dir(task_dir_abs, repo_root)
if archive_dest:
return {"archived_to": str(archive_dest)}
return {}
# =============================================================================
# 任务目录解析
# =============================================================================
def resolve_task_dir(target_dir: str, repo_root: Path) -> Path:
"""将任务目录说明解析为绝对路径。
支持:
- 绝对路径:/path/to/task
- 相对路径:.trellis/tasks/01-31-my-task
- 任务名称:my-task(使用 find_task_by_name 查找)
参数:
target_dir:任务目录说明。
repo_root:仓库根路径。
返回:
解析后的绝对路径。
"""
if not target_dir:
return Path()
normalized = target_dir.replace("\\", "/")
while normalized.startswith("./"):
normalized = normalized[2:]
# 绝对路径。
if Path(target_dir).is_absolute():
return Path(target_dir)
# 相对路径(包含路径分隔符或以 .trellis 开头)。
if "/" in normalized or normalized.startswith(".trellis"):
return repo_root / Path(normalized)
# 任务名称——尝试在 tasks 目录查找。
tasks_dir = get_tasks_dir(repo_root)
found = find_task_by_name(target_dir, tasks_dir)
if found:
return found
# 回退为相对路径处理。
return repo_root / Path(normalized)
# =============================================================================
# 生命周期 Hook
# =============================================================================
def run_task_hooks(event: str, task_json_path: Path, repo_root: Path) -> None:
"""运行任务事件对应的生命周期 hook。
参数:
event:事件名称(例如 "after_create")。
task_json_path:任务 task.json 的绝对路径。
repo_root:用于 cwd 和配置查找的仓库根路径。
"""
import os
import subprocess
from .config import get_hooks
from .log import Colors, colored
commands = get_hooks(event, repo_root)
if not commands:
return
env = {**os.environ, "TASK_JSON_PATH": str(task_json_path)}
for cmd in commands:
try:
result = subprocess.run(
cmd,
shell=True,
cwd=repo_root,
env=env,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
)
if result.returncode != 0:
print(
colored(f"[WARN] Hook 执行失败({event}):{cmd}", Colors.YELLOW),
file=sys.stderr,
)
if result.stderr.strip():
print(f" {result.stderr.strip()}", file=sys.stderr)
except Exception as e:
print(
colored(f"[WARN] Hook 出错({event}):{cmd} — {e}", Colors.YELLOW),
file=sys.stderr,
)
# =============================================================================
# 主入口(用于测试)
# =============================================================================
if __name__ == "__main__":
repo = get_repo_root()
tasks = get_tasks_dir(repo)
print(f"Tasks 目录:{tasks}")
print(f"is_safe_task_path('.trellis/tasks/test'): {is_safe_task_path('.trellis/tasks/test', repo)}")
print(f"is_safe_task_path('../test'): {is_safe_task_path('../test', repo)}")