179 lines
5.4 KiB
Python
179 lines
5.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
|
||
|
|
archive_task_dir - Archive task to monthly directory
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import shutil
|
||
|
|
import sys
|
||
|
|
from datetime import datetime
|
||
|
|
from pathlib import Path
|
||
|
|
|
||
|
|
from .paths import get_repo_root
|
||
|
|
|
||
|
|
|
||
|
|
# =============================================================================
|
||
|
|
# 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()
|
||
|
|
|
||
|
|
# Check empty or null
|
||
|
|
if not task_path or task_path == "null":
|
||
|
|
print("Error: empty or null task path", file=sys.stderr)
|
||
|
|
return False
|
||
|
|
|
||
|
|
# Reject absolute paths
|
||
|
|
if task_path.startswith("/"):
|
||
|
|
print(f"Error: absolute path not allowed: {task_path}", file=sys.stderr)
|
||
|
|
return False
|
||
|
|
|
||
|
|
# Reject ".", "..", paths starting with "./" or "../", or containing ".."
|
||
|
|
if task_path in (".", "..") or task_path.startswith("./") or task_path.startswith("../") or ".." in task_path:
|
||
|
|
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 / task_path
|
||
|
|
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 {}
|
||
|
|
|
||
|
|
|
||
|
|
# =============================================================================
|
||
|
|
# Main Entry (for testing)
|
||
|
|
# =============================================================================
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
from .paths import get_tasks_dir
|
||
|
|
|
||
|
|
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)}")
|