chore: 升级 Trellis 并精简规则(09-05-trellis-upgrade-rule-cleanup)

这个提交包含在:
2026-09-06 11:14:41 +08:00
父节点 ab82fe30c0
当前提交 c3cef5cfc4
共修改 48 个文件,包含 4588 行新增和 1053 行删除
+113 -17
查看文件
@@ -12,10 +12,13 @@ Provides:
from __future__ import annotations
import os
import re
from datetime import datetime
from pathlib import Path
from .git import main_worktree_root
# =============================================================================
# Path Constants (change here to rename directories)
@@ -35,6 +38,17 @@ FILE_CURRENT_TASK = ".current-task"
FILE_TASK_JSON = "task.json"
FILE_JOURNAL_PREFIX = "journal-"
# Environment override for the developer identity, ahead of the .developer file.
ENV_DEVELOPER = "TRELLIS_DEVELOPER"
# Appended to every "no developer set" error so the two non-obvious sources are
# discoverable from the failure itself.
DEVELOPER_HINT = (
f" Or set {ENV_DEVELOPER}=<your-name> in the environment.\n"
f" A linked git worktree inherits {DIR_WORKFLOW}/{FILE_DEVELOPER} from its "
f"main checkout — run init_developer.py there to cover every worktree."
)
# =============================================================================
# Repository Root
@@ -66,8 +80,40 @@ def get_repo_root(start_path: Path | None = None) -> Path:
# Developer
# =============================================================================
def _read_developer_file(dev_file: Path) -> str | None:
"""Read the `name=` field out of a .developer file, or None."""
if not dev_file.is_file():
return None
try:
content = dev_file.read_text(encoding="utf-8")
except (OSError, IOError):
return None
for line in content.splitlines():
if line.startswith("name="):
return line.split("=", 1)[1].strip() or None
return None
def get_developer(repo_root: Path | None = None) -> str | None:
"""Get developer name from .developer file.
"""Get the developer name for this checkout.
Resolution order, first hit wins (a CLI `--assignee` flag overrides all of
it, before this function is ever called):
1. The ``TRELLIS_DEVELOPER`` environment variable.
2. ``.trellis/.developer`` in this checkout.
3. ``.trellis/.developer`` in the main checkout, when this checkout is a
linked git worktree.
Step 3 exists because `.developer` is gitignored on purpose — it carries a
personal identity and no tracked file should. A fresh `git worktree add`
therefore starts with no identity file of its own, which used to make every
task.py command fail until init_developer.py was re-run per worktree. The
main checkout's file is read, never copied: a copy would go stale and shadow
later changes made in the main checkout.
Args:
repo_root: Repository root path. Defaults to auto-detected.
@@ -75,23 +121,22 @@ def get_developer(repo_root: Path | None = None) -> str | None:
Returns:
Developer name or None if not initialized.
"""
env_name = os.environ.get(ENV_DEVELOPER, "").strip()
if env_name:
return env_name
if repo_root is None:
repo_root = get_repo_root()
dev_file = repo_root / DIR_WORKFLOW / FILE_DEVELOPER
local = _read_developer_file(repo_root / DIR_WORKFLOW / FILE_DEVELOPER)
if local:
return local
if not dev_file.is_file():
main_root = main_worktree_root(repo_root)
if main_root is None:
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
return _read_developer_file(main_root / DIR_WORKFLOW / FILE_DEVELOPER)
def check_developer(repo_root: Path | None = None) -> bool:
@@ -233,7 +278,25 @@ def normalize_task_ref(task_ref: str) -> str:
def resolve_task_ref(task_ref: str, repo_root: Path | None = None) -> Path | None:
"""Resolve a task ref to an absolute task directory path."""
"""Resolve a task ref to an absolute task directory path inside the repo.
Returns None when the ref resolves outside `repo_root`. Every reader of the
active task — `task.py`, the shared hooks, the platform extensions — comes
through here, so containment is enforced at this one point rather than at
each call site.
It matters because a ref is not always something the user typed. It round
trips through the session pointer under `.trellis/.runtime/sessions/`, and
`..` segments used to survive that trip intact: `_canonical_task_ref`
compares lexically, and a lexical `relative_to` accepts
`<root>/.trellis/tasks/../../../elsewhere` because the string does start
with the root. The ref was then stored verbatim and replayed on every later
turn, so `task.py start .trellis/tasks/../../../elsewhere` both rewrote that
directory's `task.json` and fed its files to the model.
Resolving here also normalises the path, so callers get a ref without `..`
to store.
"""
if repo_root is None:
repo_root = get_repo_root()
@@ -241,14 +304,47 @@ def resolve_task_ref(task_ref: str, repo_root: Path | None = None) -> Path | Non
if not normalized:
return None
try:
root = repo_root.resolve()
except OSError:
return None
path_obj = Path(normalized)
if path_obj.is_absolute():
return path_obj
candidate = path_obj
elif normalized.startswith(f"{DIR_WORKFLOW}/"):
candidate = root / path_obj
else:
candidate = root / DIR_WORKFLOW / DIR_TASKS / path_obj
if normalized.startswith(f"{DIR_WORKFLOW}/"):
return repo_root / path_obj
# resolve() collapses `..` and follows symlinks, so a task directory that
# links outside the repo is refused too. Both sides are resolved because
# repo_root itself may sit behind a symlink (/tmp on macOS does).
try:
resolved = candidate.resolve()
workflow_real = (root / DIR_WORKFLOW).resolve()
except OSError:
return None
return repo_root / DIR_WORKFLOW / DIR_TASKS / path_obj
try:
resolved.relative_to(root)
return resolved
except ValueError:
pass
# `.trellis` may itself be a symlink into a store outside the repo (#567).
# The workflow dir's own real location is then a second legitimate
# containment base: a ref through that link never left the workflow tree.
# A ref that escapes BOTH bases (traversal, absolute path elsewhere, a
# task dir symlinked out of the tree) is still refused.
try:
rel = resolved.relative_to(workflow_real)
except ValueError:
return None
# Map back to the in-repo (lexical) form so callers store the same
# repo-relative ref as in the non-symlinked layout.
return root / DIR_WORKFLOW / rel
def get_current_task(