445 lines
12 KiB
Python
445 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Common path utilities for Trellis workflow.
|
|
|
|
Provides:
|
|
get_repo_root - Get repository root directory
|
|
get_developer - Get developer name
|
|
get_workspace_dir - Get developer workspace directory
|
|
get_tasks_dir - Get tasks directory
|
|
get_active_journal_file - Get current journal file
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
|
|
|
|
# =============================================================================
|
|
# Path Constants (change here to rename directories)
|
|
# =============================================================================
|
|
|
|
# Directory names
|
|
DIR_WORKFLOW = ".trellis"
|
|
DIR_WORKSPACE = "workspace"
|
|
DIR_TASKS = "tasks"
|
|
DIR_ARCHIVE = "archive"
|
|
DIR_SPEC = "spec"
|
|
DIR_SCRIPTS = "scripts"
|
|
|
|
# File names
|
|
FILE_DEVELOPER = ".developer"
|
|
FILE_CURRENT_TASK = ".current-task"
|
|
FILE_TASK_JSON = "task.json"
|
|
FILE_JOURNAL_PREFIX = "journal-"
|
|
|
|
|
|
# =============================================================================
|
|
# Repository Root
|
|
# =============================================================================
|
|
|
|
def get_repo_root(start_path: Path | None = None) -> Path:
|
|
"""Find the nearest directory containing .trellis/ folder.
|
|
|
|
This handles nested git repos correctly (e.g., test project inside another repo).
|
|
|
|
Args:
|
|
start_path: Starting directory to search from. Defaults to current directory.
|
|
|
|
Returns:
|
|
Path to repository root, or current directory if no .trellis/ found.
|
|
"""
|
|
current = (start_path or Path.cwd()).resolve()
|
|
|
|
while current != current.parent:
|
|
if (current / DIR_WORKFLOW).is_dir():
|
|
return current
|
|
current = current.parent
|
|
|
|
# Fallback to current directory if no .trellis/ found
|
|
return Path.cwd().resolve()
|
|
|
|
|
|
# =============================================================================
|
|
# Developer
|
|
# =============================================================================
|
|
|
|
def get_developer(repo_root: Path | None = None) -> str | None:
|
|
"""Get developer name from .developer file.
|
|
|
|
Args:
|
|
repo_root: Repository root path. Defaults to auto-detected.
|
|
|
|
Returns:
|
|
Developer name or None if not initialized.
|
|
"""
|
|
if repo_root is None:
|
|
repo_root = get_repo_root()
|
|
|
|
dev_file = repo_root / DIR_WORKFLOW / FILE_DEVELOPER
|
|
|
|
if not dev_file.is_file():
|
|
return None
|
|
|
|
try:
|
|
content = dev_file.read_text(encoding="utf-8")
|
|
for line in content.splitlines():
|
|
if line.startswith("name="):
|
|
return line.split("=", 1)[1].strip()
|
|
except (OSError, IOError):
|
|
pass
|
|
|
|
return None
|
|
|
|
|
|
def check_developer(repo_root: Path | None = None) -> bool:
|
|
"""Check if developer is initialized.
|
|
|
|
Args:
|
|
repo_root: Repository root path. Defaults to auto-detected.
|
|
|
|
Returns:
|
|
True if developer is initialized.
|
|
"""
|
|
return get_developer(repo_root) is not None
|
|
|
|
|
|
# =============================================================================
|
|
# Tasks Directory
|
|
# =============================================================================
|
|
|
|
def get_tasks_dir(repo_root: Path | None = None) -> Path:
|
|
"""Get tasks directory path.
|
|
|
|
Args:
|
|
repo_root: Repository root path. Defaults to auto-detected.
|
|
|
|
Returns:
|
|
Path to tasks directory.
|
|
"""
|
|
if repo_root is None:
|
|
repo_root = get_repo_root()
|
|
return repo_root / DIR_WORKFLOW / DIR_TASKS
|
|
|
|
|
|
# =============================================================================
|
|
# Workspace Directory
|
|
# =============================================================================
|
|
|
|
def get_workspace_dir(repo_root: Path | None = None) -> Path | None:
|
|
"""Get developer workspace directory.
|
|
|
|
Args:
|
|
repo_root: Repository root path. Defaults to auto-detected.
|
|
|
|
Returns:
|
|
Path to workspace directory or None if developer not set.
|
|
"""
|
|
if repo_root is None:
|
|
repo_root = get_repo_root()
|
|
|
|
developer = get_developer(repo_root)
|
|
if developer:
|
|
return repo_root / DIR_WORKFLOW / DIR_WORKSPACE / developer
|
|
return None
|
|
|
|
|
|
# =============================================================================
|
|
# Journal File
|
|
# =============================================================================
|
|
|
|
def get_active_journal_file(repo_root: Path | None = None) -> Path | None:
|
|
"""Get the current active journal file.
|
|
|
|
Args:
|
|
repo_root: Repository root path. Defaults to auto-detected.
|
|
|
|
Returns:
|
|
Path to active journal file or None if not found.
|
|
"""
|
|
if repo_root is None:
|
|
repo_root = get_repo_root()
|
|
|
|
workspace_dir = get_workspace_dir(repo_root)
|
|
if workspace_dir is None or not workspace_dir.is_dir():
|
|
return None
|
|
|
|
latest: Path | None = None
|
|
highest = 0
|
|
|
|
for f in workspace_dir.glob(f"{FILE_JOURNAL_PREFIX}*.md"):
|
|
if not f.is_file():
|
|
continue
|
|
|
|
# Extract number from filename
|
|
name = f.stem # e.g., "journal-1"
|
|
match = re.search(r"(\d+)$", name)
|
|
if match:
|
|
num = int(match.group(1))
|
|
if num > highest:
|
|
highest = num
|
|
latest = f
|
|
|
|
return latest
|
|
|
|
|
|
def count_lines(file_path: Path) -> int:
|
|
"""Count lines in a file.
|
|
|
|
Args:
|
|
file_path: Path to file.
|
|
|
|
Returns:
|
|
Number of lines, or 0 if file doesn't exist.
|
|
"""
|
|
if not file_path.is_file():
|
|
return 0
|
|
|
|
try:
|
|
return len(file_path.read_text(encoding="utf-8").splitlines())
|
|
except (OSError, IOError):
|
|
return 0
|
|
|
|
|
|
# =============================================================================
|
|
# Current Task Management
|
|
# =============================================================================
|
|
|
|
def _get_current_task_file(repo_root: Path | None = None) -> Path:
|
|
"""Get .current-task file path.
|
|
|
|
Args:
|
|
repo_root: Repository root path. Defaults to auto-detected.
|
|
|
|
Returns:
|
|
Path to .current-task file.
|
|
"""
|
|
if repo_root is None:
|
|
repo_root = get_repo_root()
|
|
return repo_root / DIR_WORKFLOW / FILE_CURRENT_TASK
|
|
|
|
|
|
def normalize_task_ref(task_ref: str) -> str:
|
|
"""Normalize a task ref for stable storage in .current-task.
|
|
|
|
Stored refs should prefer repo-relative POSIX paths like
|
|
`.trellis/tasks/03-27-my-task`, even on Windows. Absolute paths are preserved
|
|
unless they can later be converted back to repo-relative form by callers.
|
|
"""
|
|
normalized = task_ref.strip()
|
|
if not normalized:
|
|
return ""
|
|
|
|
path_obj = Path(normalized)
|
|
if path_obj.is_absolute():
|
|
return str(path_obj)
|
|
|
|
normalized = normalized.replace("\\", "/")
|
|
while normalized.startswith("./"):
|
|
normalized = normalized[2:]
|
|
|
|
if normalized.startswith(f"{DIR_TASKS}/"):
|
|
return f"{DIR_WORKFLOW}/{normalized}"
|
|
|
|
return normalized
|
|
|
|
|
|
def resolve_task_ref(task_ref: str, repo_root: Path | None = None) -> Path | None:
|
|
"""Resolve a task ref from .current-task to an absolute task directory path."""
|
|
if repo_root is None:
|
|
repo_root = get_repo_root()
|
|
|
|
normalized = normalize_task_ref(task_ref)
|
|
if not normalized:
|
|
return None
|
|
|
|
path_obj = Path(normalized)
|
|
if path_obj.is_absolute():
|
|
return path_obj
|
|
|
|
if normalized.startswith(f"{DIR_WORKFLOW}/"):
|
|
return repo_root / path_obj
|
|
|
|
return repo_root / DIR_WORKFLOW / DIR_TASKS / path_obj
|
|
|
|
|
|
def get_current_task(repo_root: Path | None = None) -> str | None:
|
|
"""Get current task directory path (relative to repo_root).
|
|
|
|
Args:
|
|
repo_root: Repository root path. Defaults to auto-detected.
|
|
|
|
Returns:
|
|
Relative path to current task directory or None.
|
|
"""
|
|
current_file = _get_current_task_file(repo_root)
|
|
|
|
if not current_file.is_file():
|
|
return None
|
|
|
|
try:
|
|
content = current_file.read_text(encoding="utf-8").strip()
|
|
return normalize_task_ref(content) if content else None
|
|
except (OSError, IOError):
|
|
return None
|
|
|
|
|
|
def get_current_task_abs(repo_root: Path | None = None) -> Path | None:
|
|
"""Get current task directory absolute path.
|
|
|
|
Args:
|
|
repo_root: Repository root path. Defaults to auto-detected.
|
|
|
|
Returns:
|
|
Absolute path to current task directory or None.
|
|
"""
|
|
if repo_root is None:
|
|
repo_root = get_repo_root()
|
|
|
|
relative = get_current_task(repo_root)
|
|
if relative:
|
|
return resolve_task_ref(relative, repo_root)
|
|
return None
|
|
|
|
|
|
def set_current_task(task_path: str, repo_root: Path | None = None) -> bool:
|
|
"""Set current task.
|
|
|
|
Args:
|
|
task_path: Task directory path (relative to repo_root).
|
|
repo_root: Repository root path. Defaults to auto-detected.
|
|
|
|
Returns:
|
|
True on success, False on error.
|
|
"""
|
|
if repo_root is None:
|
|
repo_root = get_repo_root()
|
|
|
|
normalized = normalize_task_ref(task_path)
|
|
if not normalized:
|
|
return False
|
|
|
|
# Verify task directory exists
|
|
full_path = resolve_task_ref(normalized, repo_root)
|
|
if full_path is None or not full_path.is_dir():
|
|
return False
|
|
|
|
try:
|
|
normalized = full_path.relative_to(repo_root).as_posix()
|
|
except ValueError:
|
|
normalized = str(full_path)
|
|
|
|
current_file = _get_current_task_file(repo_root)
|
|
|
|
try:
|
|
current_file.write_text(normalized, encoding="utf-8")
|
|
return True
|
|
except (OSError, IOError):
|
|
return False
|
|
|
|
|
|
def clear_current_task(repo_root: Path | None = None) -> bool:
|
|
"""Clear current task.
|
|
|
|
Args:
|
|
repo_root: Repository root path. Defaults to auto-detected.
|
|
|
|
Returns:
|
|
True on success.
|
|
"""
|
|
current_file = _get_current_task_file(repo_root)
|
|
|
|
try:
|
|
if current_file.is_file():
|
|
current_file.unlink()
|
|
return True
|
|
except (OSError, IOError):
|
|
return False
|
|
|
|
|
|
def has_current_task(repo_root: Path | None = None) -> bool:
|
|
"""Check if has current task.
|
|
|
|
Args:
|
|
repo_root: Repository root path. Defaults to auto-detected.
|
|
|
|
Returns:
|
|
True if current task is set.
|
|
"""
|
|
return get_current_task(repo_root) is not None
|
|
|
|
|
|
# =============================================================================
|
|
# Task ID Generation
|
|
# =============================================================================
|
|
|
|
def generate_task_date_prefix() -> str:
|
|
"""Generate task ID based on date (MM-DD format).
|
|
|
|
Returns:
|
|
Date prefix string (e.g., "01-21").
|
|
"""
|
|
return datetime.now().strftime("%m-%d")
|
|
|
|
|
|
# =============================================================================
|
|
# Monorepo / Package Paths
|
|
# =============================================================================
|
|
|
|
|
|
def get_spec_dir(package: str | None = None, repo_root: Path | None = None) -> Path:
|
|
"""Get the spec directory path.
|
|
|
|
Single-repo: .trellis/spec
|
|
Monorepo with package: .trellis/spec/<package>
|
|
|
|
Uses lazy import to avoid circular dependency with config.py.
|
|
"""
|
|
if repo_root is None:
|
|
repo_root = get_repo_root()
|
|
|
|
from .config import get_spec_base
|
|
|
|
base = get_spec_base(package, repo_root)
|
|
return repo_root / DIR_WORKFLOW / base
|
|
|
|
|
|
def get_package_path(package: str, repo_root: Path | None = None) -> Path | None:
|
|
"""Get a package's source directory absolute path from config.
|
|
|
|
Returns:
|
|
Absolute path to the package directory, or None if not found.
|
|
"""
|
|
if repo_root is None:
|
|
repo_root = get_repo_root()
|
|
|
|
from .config import get_packages
|
|
|
|
packages = get_packages(repo_root)
|
|
if not packages or package not in packages:
|
|
return None
|
|
|
|
info = packages[package]
|
|
if isinstance(info, dict):
|
|
rel_path = info.get("path", package)
|
|
else:
|
|
rel_path = str(info)
|
|
|
|
return repo_root / rel_path
|
|
|
|
|
|
# =============================================================================
|
|
# Main Entry (for testing)
|
|
# =============================================================================
|
|
|
|
if __name__ == "__main__":
|
|
repo = get_repo_root()
|
|
print(f"Repository root: {repo}")
|
|
print(f"Developer: {get_developer(repo)}")
|
|
print(f"Tasks dir: {get_tasks_dir(repo)}")
|
|
print(f"Workspace dir: {get_workspace_dir(repo)}")
|
|
print(f"Journal file: {get_active_journal_file(repo)}")
|
|
print(f"Current task: {get_current_task(repo)}")
|