275 lines
8.4 KiB
Python
275 lines
8.4 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Task utility functions.
|
|
|
|
Provides:
|
|
is_safe_task_path - Validate task path is safe to operate on
|
|
find_task_by_name - Find task directory by name
|
|
resolve_task_dir - Resolve task directory from name, relative, or absolute path
|
|
archive_task_dir - Archive task to monthly directory
|
|
run_task_hooks - Run lifecycle hooks for task events
|
|
"""
|
|
|
|
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
|
|
|
|
|
|
# =============================================================================
|
|
# 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
|
|
|
|
|
|
# =============================================================================
|
|
# Task Lookup
|
|
# =============================================================================
|
|
|
|
def find_task_by_name(task_name: str, tasks_dir: Path) -> Path | None:
|
|
"""Find task directory by name (exact or suffix match).
|
|
|
|
Args:
|
|
task_name: Task name to find.
|
|
tasks_dir: Tasks directory path.
|
|
|
|
Returns:
|
|
Absolute path to task directory, or None if not found.
|
|
"""
|
|
if not task_name or not tasks_dir or not tasks_dir.is_dir():
|
|
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
|
|
|
|
return None
|
|
|
|
|
|
# =============================================================================
|
|
# Archive Operations
|
|
# =============================================================================
|
|
|
|
def archive_task_dir(task_dir_abs: Path, repo_root: Path | None = None) -> Path | None:
|
|
"""Archive a task directory to archive/{YYYY-MM}/.
|
|
|
|
Args:
|
|
task_dir_abs: Absolute path to task directory.
|
|
repo_root: Repository root path. Defaults to auto-detected.
|
|
|
|
Returns:
|
|
Path to archived directory, or None on error.
|
|
"""
|
|
if not task_dir_abs.is_dir():
|
|
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
|
|
|
|
# Create archive directory
|
|
try:
|
|
month_dir.mkdir(parents=True, exist_ok=True)
|
|
except (OSError, IOError) as e:
|
|
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
|
|
|
|
try:
|
|
shutil.move(str(task_dir_abs), str(dest))
|
|
except (OSError, IOError, shutil.Error) as e:
|
|
print(f"Error: Failed to move task to archive: {e}", file=sys.stderr)
|
|
return None
|
|
|
|
return dest
|
|
|
|
|
|
def archive_task_complete(
|
|
task_dir_abs: Path,
|
|
repo_root: Path | None = None
|
|
) -> dict[str, str]:
|
|
"""Complete archive workflow: archive directory.
|
|
|
|
Args:
|
|
task_dir_abs: Absolute path to task directory.
|
|
repo_root: Repository root path. Defaults to auto-detected.
|
|
|
|
Returns:
|
|
Dict with archive result info.
|
|
"""
|
|
if not task_dir_abs.is_dir():
|
|
print(f"Error: task directory not found: {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 {}
|
|
|
|
|
|
# =============================================================================
|
|
# Task Directory Resolution
|
|
# =============================================================================
|
|
|
|
def resolve_task_dir(target_dir: str, repo_root: Path) -> Path:
|
|
"""Resolve task directory to absolute path.
|
|
|
|
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)
|
|
|
|
Args:
|
|
target_dir: Task directory specification.
|
|
repo_root: Repository root path.
|
|
|
|
Returns:
|
|
Resolved absolute path.
|
|
"""
|
|
if not target_dir:
|
|
return Path()
|
|
|
|
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)
|
|
|
|
|
|
# =============================================================================
|
|
# Lifecycle Hooks
|
|
# =============================================================================
|
|
|
|
def run_task_hooks(event: str, task_json_path: Path, repo_root: Path) -> None:
|
|
"""Run lifecycle hooks for a task event.
|
|
|
|
Args:
|
|
event: Event name (e.g. "after_create").
|
|
task_json_path: Absolute path to the task's task.json.
|
|
repo_root: Repository root for cwd and config lookup.
|
|
"""
|
|
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 failed ({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 error ({event}): {cmd} — {e}", Colors.YELLOW),
|
|
file=sys.stderr,
|
|
)
|
|
|
|
|
|
# =============================================================================
|
|
# Main Entry (for testing)
|
|
# =============================================================================
|
|
|
|
if __name__ == "__main__":
|
|
repo = get_repo_root()
|
|
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)}")
|