chore: 升级 Trellis 并精简规则(09-05-trellis-upgrade-rule-cleanup)
这个提交包含在:
@@ -3,9 +3,10 @@
|
||||
Task utility functions.
|
||||
|
||||
Provides:
|
||||
is_safe_task_path - Validate task path is safe to operate on
|
||||
is_within_tasks_dir - Check a resolved path is a task directly under tasks/
|
||||
find_task_by_name - Find task directory by name
|
||||
resolve_task_dir - Resolve task directory from name, relative, or absolute path
|
||||
archive_destination_for - Path a task would be archived to
|
||||
archive_task_dir - Archive task to monthly directory
|
||||
run_task_hooks - Run lifecycle hooks for task events
|
||||
"""
|
||||
@@ -16,70 +17,28 @@ import shutil
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from .paths import get_repo_root, get_tasks_dir
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import subprocess
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Path Safety
|
||||
# =============================================================================
|
||||
|
||||
def is_safe_task_path(task_path: str, repo_root: Path | None = None) -> bool:
|
||||
"""Check if a relative task path is safe to operate on.
|
||||
|
||||
Args:
|
||||
task_path: Task path (relative to repo_root).
|
||||
repo_root: Repository root path. Defaults to auto-detected.
|
||||
|
||||
Returns:
|
||||
True if safe, False if dangerous.
|
||||
"""
|
||||
if repo_root is None:
|
||||
repo_root = get_repo_root()
|
||||
|
||||
normalized = task_path.replace("\\", "/")
|
||||
|
||||
# Check empty or null
|
||||
if not normalized or normalized == "null":
|
||||
print("Error: empty or null task path", file=sys.stderr)
|
||||
return False
|
||||
|
||||
# Reject absolute paths
|
||||
if Path(task_path).is_absolute():
|
||||
print(f"Error: absolute path not allowed: {task_path}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
# Reject ".", "..", paths starting with "./" or "../", or containing ".."
|
||||
if normalized in (".", "..") or normalized.startswith("./") or normalized.startswith("../") or ".." in normalized:
|
||||
print(f"Error: path traversal not allowed: {task_path}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
# Final check: ensure resolved path is not the repo root
|
||||
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"Error: path resolves to repo root: {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:
|
||||
"""Check that a resolved task directory really is a task under the tasks dir.
|
||||
|
||||
A real task lives directly at ``.trellis/tasks/<name>``. This returns True
|
||||
only when ``task_dir_abs`` is an immediate child of the tasks directory.
|
||||
|
||||
Guards archive: ``resolve_task_dir`` falls back to ``repo_root/<name>`` for
|
||||
an unknown name, so a mistyped ``task.py archive src`` resolves to the real
|
||||
``src/`` source directory. Without this check archive would ``shutil.move``
|
||||
it out of the repo. Also rejects the tasks dir itself and anything nested
|
||||
under ``archive/`` (already-archived tasks).
|
||||
Narrows ``resolve_task_dir``'s containment for archive: that chokepoint
|
||||
accepts anything under the tasks dir, including a task already in
|
||||
``archive/<YYYY-MM>/``. This rejects those, plus the tasks dir itself, so
|
||||
``shutil.move`` never re-archives an archived task into a nested copy.
|
||||
"""
|
||||
if repo_root is None:
|
||||
repo_root = get_repo_root()
|
||||
@@ -100,25 +59,46 @@ def is_within_tasks_dir(task_dir_abs: Path, repo_root: Path | None = None) -> bo
|
||||
def find_task_by_name(task_name: str, tasks_dir: Path) -> Path | None:
|
||||
"""Find task directory by name (exact or suffix match).
|
||||
|
||||
A task name is a single directory name under ``tasks_dir``, never a path:
|
||||
names carrying a separator or a dot segment are rejected before the join,
|
||||
so ``".."`` cannot hand back the tasks dir's own parent.
|
||||
|
||||
An ambiguous suffix (two tasks created on different days with the same
|
||||
slug) is a failure, not a coin flip — ``iterdir()`` order is filesystem
|
||||
order, so silently picking the first match picks a different task on a
|
||||
different machine.
|
||||
|
||||
Args:
|
||||
task_name: Task name to find.
|
||||
tasks_dir: Tasks directory path.
|
||||
|
||||
Returns:
|
||||
Absolute path to task directory, or None if not found.
|
||||
Absolute path to task directory, or None if not found or ambiguous.
|
||||
"""
|
||||
if not task_name or not tasks_dir or not tasks_dir.is_dir():
|
||||
return None
|
||||
|
||||
if "/" in task_name or "\\" in task_name or task_name in (".", ".."):
|
||||
print(f"Error: invalid task name: {task_name}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
# Try exact match first
|
||||
exact_match = tasks_dir / task_name
|
||||
if exact_match.is_dir():
|
||||
return exact_match
|
||||
|
||||
# Try suffix match (e.g., "my-task" matches "01-21-my-task")
|
||||
for d in tasks_dir.iterdir():
|
||||
if d.is_dir() and d.name.endswith(f"-{task_name}"):
|
||||
return d
|
||||
matches = sorted(
|
||||
d for d in tasks_dir.iterdir()
|
||||
if d.is_dir() and d.name.endswith(f"-{task_name}")
|
||||
)
|
||||
if len(matches) == 1:
|
||||
return matches[0]
|
||||
if matches:
|
||||
print(f"Error: ambiguous task name '{task_name}' matches:", file=sys.stderr)
|
||||
for match in matches:
|
||||
print(f" - {match.name}", file=sys.stderr)
|
||||
print("Pass the full task directory name.", file=sys.stderr)
|
||||
|
||||
return None
|
||||
|
||||
@@ -127,6 +107,13 @@ def find_task_by_name(task_name: str, tasks_dir: Path) -> Path | None:
|
||||
# Archive Operations
|
||||
# =============================================================================
|
||||
|
||||
def archive_destination_for(task_dir_abs: Path) -> Path:
|
||||
"""Path a task would be archived to: <tasks>/archive/<YYYY-MM>/<name>."""
|
||||
tasks_dir = task_dir_abs.parent
|
||||
year_month = datetime.now().strftime("%Y-%m")
|
||||
return tasks_dir / "archive" / year_month / task_dir_abs.name
|
||||
|
||||
|
||||
def archive_task_dir(task_dir_abs: Path, repo_root: Path | None = None) -> Path | None:
|
||||
"""Archive a task directory to archive/{YYYY-MM}/.
|
||||
|
||||
@@ -141,11 +128,8 @@ def archive_task_dir(task_dir_abs: Path, repo_root: Path | None = None) -> Path
|
||||
print(f"Error: task directory not found: {task_dir_abs}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
# Get tasks directory (parent of the task)
|
||||
tasks_dir = task_dir_abs.parent
|
||||
archive_dir = tasks_dir / "archive"
|
||||
year_month = datetime.now().strftime("%Y-%m")
|
||||
month_dir = archive_dir / year_month
|
||||
dest = archive_destination_for(task_dir_abs)
|
||||
month_dir = dest.parent
|
||||
|
||||
# Create archive directory
|
||||
try:
|
||||
@@ -154,9 +138,22 @@ def archive_task_dir(task_dir_abs: Path, repo_root: Path | None = None) -> Path
|
||||
print(f"Error: Failed to create archive directory: {e}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
# Move task to archive
|
||||
task_name = task_dir_abs.name
|
||||
dest = month_dir / task_name
|
||||
# shutil.move into an existing directory moves the source *inside* it,
|
||||
# producing archive/<month>/<task>/<task>/ and returning a path that is not
|
||||
# where the task actually landed. That wrong path then flows into the
|
||||
# printed result, the after_archive hook's TASK_JSON_PATH, and auto-commit
|
||||
# staging, so refuse before the move instead.
|
||||
if dest.exists():
|
||||
print(
|
||||
f"Error: refusing to archive {task_dir_abs}: "
|
||||
f"archive destination already exists: {dest}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(
|
||||
"Move or rename the existing archived task, then retry.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
shutil.move(str(task_dir_abs), str(dest))
|
||||
@@ -195,53 +192,210 @@ def archive_task_complete(
|
||||
# Task Directory Resolution
|
||||
# =============================================================================
|
||||
|
||||
def resolve_task_dir(target_dir: str, repo_root: Path) -> Path:
|
||||
"""Resolve task directory to absolute path.
|
||||
def resolve_task_dir(target_dir: str, repo_root: Path) -> Path | None:
|
||||
"""Resolve task directory to an absolute path inside the tasks directory.
|
||||
|
||||
Supports:
|
||||
- Absolute path: /path/to/task
|
||||
- Relative path: .trellis/tasks/01-31-my-task
|
||||
- Task name: my-task (uses find_task_by_name for lookup)
|
||||
|
||||
This is the containment chokepoint for every command that accepts a task
|
||||
directory argument. The candidate is resolved (following symlinks) and must
|
||||
land strictly under ``.trellis/tasks/``; archived tasks under
|
||||
``archive/<YYYY-MM>/`` qualify. Traversal (``../victim``), absolute paths
|
||||
outside the repo, a task dir symlinked out of the tasks tree, and the
|
||||
tasks directory itself are all rejected here so no caller has to re-check.
|
||||
The containment base is the tasks dir's own real location, so a
|
||||
``.trellis`` that is itself a symlink into a shared store (#567) works.
|
||||
|
||||
Args:
|
||||
target_dir: Task directory specification.
|
||||
repo_root: Repository root path.
|
||||
|
||||
Returns:
|
||||
Resolved absolute path.
|
||||
Absolute path spelled through the repo's own tasks dir (in-repo
|
||||
lexical form), or None when it is not a location inside the tasks
|
||||
directory (an error naming the path is printed to stderr).
|
||||
"""
|
||||
if not target_dir:
|
||||
return Path()
|
||||
print("Error: task directory is required", file=sys.stderr)
|
||||
return None
|
||||
|
||||
normalized = target_dir.replace("\\", "/")
|
||||
while normalized.startswith("./"):
|
||||
normalized = normalized[2:]
|
||||
|
||||
# Absolute path
|
||||
if Path(target_dir).is_absolute():
|
||||
return Path(target_dir)
|
||||
|
||||
# Relative path (contains path separator or starts with .trellis)
|
||||
if "/" in normalized or normalized.startswith(".trellis"):
|
||||
return repo_root / Path(normalized)
|
||||
|
||||
# Task name - try to find in tasks directory
|
||||
tasks_dir = get_tasks_dir(repo_root)
|
||||
found = find_task_by_name(target_dir, tasks_dir)
|
||||
if found:
|
||||
return found
|
||||
|
||||
# Fallback to treating as relative path
|
||||
return repo_root / Path(normalized)
|
||||
if Path(target_dir).is_absolute():
|
||||
candidate = Path(target_dir)
|
||||
elif "/" in normalized or normalized.startswith(".trellis"):
|
||||
# Relative path (contains path separator or starts with .trellis)
|
||||
candidate = repo_root / Path(normalized)
|
||||
else:
|
||||
# Task name - must resolve inside the tasks directory. The historical
|
||||
# fallback to repo_root/<name> only ever produced a path the check
|
||||
# below rejects, so a miss ends here instead.
|
||||
candidate = find_task_by_name(target_dir, tasks_dir)
|
||||
if candidate is None:
|
||||
# find_task_by_name reports invalid names and ambiguity itself.
|
||||
print(
|
||||
f"Error: could not resolve task '{target_dir}' under {tasks_dir}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
resolved = candidate.resolve()
|
||||
tasks_lexical = get_tasks_dir(repo_root.resolve())
|
||||
tasks_resolved = tasks_lexical.resolve()
|
||||
except (OSError, RuntimeError) as e:
|
||||
print(f"Error: could not resolve task directory '{target_dir}': {e}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
if resolved == tasks_resolved:
|
||||
print(
|
||||
f"Error: refusing to use '{target_dir}': {tasks_resolved} is the tasks "
|
||||
"directory itself, not a task",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return None
|
||||
|
||||
if tasks_resolved not in resolved.parents:
|
||||
print(
|
||||
f"Error: refusing to use '{target_dir}': {resolved} is outside {tasks_resolved}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return None
|
||||
|
||||
# Hand back the path spelled through the repo's own tasks dir rather than
|
||||
# `resolved`: `.trellis` may be a symlink into a store outside the repo
|
||||
# (#567), in which case `resolved` sits outside the repo even though the
|
||||
# task is legitimate, and callers converting to a repo-relative ref for
|
||||
# storage would refuse it.
|
||||
return tasks_lexical / resolved.relative_to(tasks_resolved)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Lifecycle Hooks
|
||||
# =============================================================================
|
||||
|
||||
HOOK_TIMEOUT_SECONDS = 60
|
||||
HOOK_KILL_GRACE_SECONDS = 5
|
||||
HOOK_OUTPUT_LIMIT = 2000
|
||||
|
||||
|
||||
def _kill_hook_tree(proc: subprocess.Popen[str]) -> None:
|
||||
"""Kill a timed-out hook's whole process tree, not just the shell.
|
||||
|
||||
With ``shell=True`` the direct child is the shell; the actual work runs in
|
||||
its children. Killing only the shell leaves grandchildren alive holding the
|
||||
inherited stdout/stderr pipes, and collecting output afterwards then blocks
|
||||
until those orphans exit — the exact "command that never returns" the
|
||||
timeout exists to prevent.
|
||||
|
||||
POSIX: the hook is started with ``start_new_session=True``, so the shell and
|
||||
every descendant share one fresh process group; SIGKILL the group.
|
||||
Windows: ``taskkill /F /T`` walks the tree (best effort).
|
||||
Either way ``proc.kill()`` is the fallback.
|
||||
|
||||
Limitation: a hook that calls ``setsid`` itself leaves the group and
|
||||
survives this. Accepted and out of scope — a hook is arbitrary code, and the
|
||||
timeout is a liveness guarantee, not a containment boundary.
|
||||
"""
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
|
||||
if os.name == "posix":
|
||||
try:
|
||||
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
|
||||
return
|
||||
except (ProcessLookupError, PermissionError, OSError):
|
||||
pass
|
||||
else:
|
||||
try:
|
||||
subprocess.run(
|
||||
["taskkill", "/F", "/T", "/PID", str(proc.pid)],
|
||||
capture_output=True,
|
||||
)
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
|
||||
try:
|
||||
proc.kill()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _release_hook_process(proc: subprocess.Popen[str]) -> None:
|
||||
"""Close our pipe ends and reap a finished hook under a bound.
|
||||
|
||||
``Popen`` used as a context manager calls ``wait()`` with no timeout when
|
||||
the block exits. That is the one place the liveness guarantee could still
|
||||
be lost: if ``_kill_hook_tree`` failed outright — both the process-group
|
||||
kill and the direct kill raising — the lifecycle command would block there
|
||||
forever, which is the exact hang the timeout exists to prevent.
|
||||
"""
|
||||
import subprocess
|
||||
|
||||
for stream in (proc.stdout, proc.stderr, proc.stdin):
|
||||
if stream is not None:
|
||||
try:
|
||||
stream.close()
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
proc.wait(timeout=HOOK_KILL_GRACE_SECONDS)
|
||||
except (subprocess.TimeoutExpired, OSError, ValueError):
|
||||
# Leaves a zombie until this process exits. Fail-open and bounded beats
|
||||
# a task command that never returns.
|
||||
pass
|
||||
|
||||
|
||||
def _decode_hook_output(raw: object) -> str:
|
||||
"""TimeoutExpired carries bytes or str depending on the platform."""
|
||||
if raw is None:
|
||||
return ""
|
||||
if isinstance(raw, bytes):
|
||||
return raw.decode("utf-8", errors="replace")
|
||||
return str(raw)
|
||||
|
||||
|
||||
def _print_hook_stream(name: str, text: str) -> None:
|
||||
"""Print a captured hook stream to stderr, truncated."""
|
||||
text = text.strip()
|
||||
if not text:
|
||||
return
|
||||
if len(text) > HOOK_OUTPUT_LIMIT:
|
||||
text = (
|
||||
text[:HOOK_OUTPUT_LIMIT]
|
||||
+ f"\n… ({len(text) - HOOK_OUTPUT_LIMIT} more characters truncated)"
|
||||
)
|
||||
print(f" {name}:", file=sys.stderr)
|
||||
for line in text.splitlines():
|
||||
print(f" {line}", file=sys.stderr)
|
||||
|
||||
|
||||
def run_task_hooks(event: str, task_json_path: Path, repo_root: Path) -> None:
|
||||
"""Run lifecycle hooks for a task event.
|
||||
|
||||
Hooks are shell commands read from ``.trellis/config.yaml`` and executed
|
||||
with ``shell=True`` from the repo root — see the trust boundary note in
|
||||
``.trellis/spec/cli/backend/script-conventions.md``.
|
||||
|
||||
Fail-open by design: a broken hook warns and the lifecycle command
|
||||
continues. The warning names the event, command, exit status, and both
|
||||
captured streams, because a hook whose only symptom is "nothing happened"
|
||||
is undebuggable. A hook that hangs is bounded by
|
||||
``HOOK_TIMEOUT_SECONDS``; output is captured, so without the timeout the
|
||||
user sees a task command that never returns and prints nothing. On timeout
|
||||
the hook's entire process tree is killed (see ``_kill_hook_tree``) and
|
||||
output is collected under a bounded grace, so a surviving grandchild
|
||||
holding the pipes cannot re-create that same hang. Cleanup is bounded for
|
||||
the same reason — see ``_release_hook_process``.
|
||||
|
||||
Args:
|
||||
event: Event name (e.g. "after_create").
|
||||
task_json_path: Absolute path to the task's task.json.
|
||||
@@ -259,28 +413,74 @@ def run_task_hooks(event: str, task_json_path: Path, repo_root: Path) -> None:
|
||||
|
||||
env = {**os.environ, "TASK_JSON_PATH": str(task_json_path)}
|
||||
|
||||
# POSIX only: a fresh session puts the shell and every descendant in one
|
||||
# process group, which is what makes the timeout kill the whole tree.
|
||||
popen_kwargs: dict = {}
|
||||
if os.name == "posix":
|
||||
popen_kwargs["start_new_session"] = True
|
||||
|
||||
for cmd in commands:
|
||||
proc = None
|
||||
try:
|
||||
result = subprocess.run(
|
||||
proc = subprocess.Popen(
|
||||
cmd,
|
||||
shell=True,
|
||||
cwd=repo_root,
|
||||
env=env,
|
||||
capture_output=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
**popen_kwargs,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
try:
|
||||
stdout, stderr = proc.communicate(timeout=HOOK_TIMEOUT_SECONDS)
|
||||
except subprocess.TimeoutExpired as e:
|
||||
_kill_hook_tree(proc)
|
||||
stdout = _decode_hook_output(e.stdout)
|
||||
stderr = _decode_hook_output(e.stderr)
|
||||
try:
|
||||
# Bounded: an orphan that escaped the kill still holds
|
||||
# the pipes, and waiting on it forever here would be
|
||||
# the very hang the timeout prevents.
|
||||
rest_out, rest_err = proc.communicate(
|
||||
timeout=HOOK_KILL_GRACE_SECONDS
|
||||
)
|
||||
stdout = rest_out or stdout
|
||||
stderr = rest_err or stderr
|
||||
except (subprocess.TimeoutExpired, OSError, ValueError):
|
||||
pass
|
||||
print(
|
||||
colored(f"[WARN] Hook failed ({event}): {cmd}", Colors.YELLOW),
|
||||
colored(
|
||||
f"[WARN] Hook timed out ({event}) after "
|
||||
f"{HOOK_TIMEOUT_SECONDS}s: {cmd}",
|
||||
Colors.YELLOW,
|
||||
),
|
||||
file=sys.stderr,
|
||||
)
|
||||
if result.stderr.strip():
|
||||
print(f" {result.stderr.strip()}", file=sys.stderr)
|
||||
print(f" cwd: {repo_root}", file=sys.stderr)
|
||||
_print_hook_stream("stdout", stdout)
|
||||
_print_hook_stream("stderr", stderr)
|
||||
continue
|
||||
|
||||
if proc.returncode != 0:
|
||||
print(
|
||||
colored(
|
||||
f"[WARN] Hook failed ({event}): exit {proc.returncode}: {cmd}",
|
||||
Colors.YELLOW,
|
||||
),
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(f" cwd: {repo_root}", file=sys.stderr)
|
||||
_print_hook_stream("stdout", stdout or "")
|
||||
_print_hook_stream("stderr", stderr or "")
|
||||
except Exception as e:
|
||||
print(
|
||||
colored(f"[WARN] Hook error ({event}): {cmd} — {e}", Colors.YELLOW),
|
||||
colored(
|
||||
f"[WARN] Hook error ({event}): {cmd} — {type(e).__name__}: {e}",
|
||||
Colors.YELLOW,
|
||||
),
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
@@ -294,5 +494,5 @@ if __name__ == "__main__":
|
||||
tasks = get_tasks_dir(repo)
|
||||
|
||||
print(f"Tasks dir: {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)}")
|
||||
print(f"resolve_task_dir('.trellis/tasks/test'): {resolve_task_dir('.trellis/tasks/test', repo)}")
|
||||
print(f"resolve_task_dir('../test'): {resolve_task_dir('../test', repo)}")
|
||||
|
||||
在新工单中引用
屏蔽一个用户