升级 Trellis 并完成项目审查(07-26-trellis-upgrade-project-audit)

这个提交包含在:
2026-07-26 18:10:49 +08:00
父节点 b3dadfa1bf
当前提交 f223f559ad
共修改 77 个文件,包含 5850 行新增和 1327 行删除
+175 -41
查看文件
@@ -12,9 +12,12 @@ Usage:
<session content here>
EOF
# Structured content (repeatable; a section with no bullets is omitted):
python3 add_session.py --title "Title" --change "Did X" --test "Ran Y" --next-step "Do Z"
Branch resolution order:
1. --branch CLI arg (explicit)
2. task.json branch field (from active task)
2. task.json branch field (from active task, if still exists)
3. git branch --show-current (auto-detect)
4. None (omitted gracefully)
"""
@@ -28,6 +31,8 @@ from datetime import datetime
from pathlib import Path
from common.paths import (
DIR_TASKS,
DIR_WORKFLOW,
FILE_JOURNAL_PREFIX,
get_repo_root,
get_current_task,
@@ -36,12 +41,14 @@ from common.paths import (
)
from common.developer import ensure_developer
from common.git import run_git
from common.log import Colors, colored
from common.safe_commit import (
print_gitignore_warning,
safe_git_add,
safe_trellis_paths_to_add,
)
from common.tasks import load_task
from common.types import TaskInfo
from common.config import (
get_packages,
get_session_auto_commit,
@@ -124,6 +131,99 @@ def count_journal_files(dev_dir: Path, active_num: int) -> str:
return "\n".join(result_lines)
def get_current_git_branch(repo_root: Path) -> str | None:
"""Return the current checkout branch, or None for detached/non-git states."""
rc, branch_out, _ = run_git(["branch", "--show-current"], cwd=repo_root)
if rc != 0:
return None
detected = branch_out.strip()
return detected or None
def branch_ref_exists(repo_root: Path, branch: str) -> bool:
"""Return True when branch exists locally or as the local origin ref."""
for ref in (f"refs/heads/{branch}", f"refs/remotes/origin/{branch}"):
rc, _, _ = run_git(["show-ref", "--verify", "--quiet", ref], cwd=repo_root)
if rc == 0:
return True
return False
def resolve_session_branch(
repo_root: Path,
cli_branch: str | None,
task_data: TaskInfo | None,
) -> str | None:
"""Resolve journal branch without trusting stale task.json branch fields."""
if cli_branch:
return cli_branch
current_branch = get_current_git_branch(repo_root)
raw_task_branch = task_data.raw.get("branch") if task_data else None
task_branch = raw_task_branch.strip() if isinstance(raw_task_branch, str) else ""
if not task_branch:
return current_branch
if branch_ref_exists(repo_root, task_branch):
return task_branch
if current_branch:
print(
f"Warning: task.json branch '{task_branch}' no longer exists locally or as origin/{task_branch}; using current branch '{current_branch}'.",
file=sys.stderr,
)
return current_branch
print(
f"Warning: task.json branch '{task_branch}' no longer exists locally or as origin/{task_branch}; omitting branch.",
file=sys.stderr,
)
return None
def is_git_worktree(repo_root: Path) -> bool:
"""Return True when repo_root is a linked worktree (not the main working tree).
Standard test: `git rev-parse --git-dir` (per-worktree) differs from
`git rev-parse --git-common-dir` (shared across all worktrees) once both
are resolved to absolute paths. In the main working tree these are the
same directory.
"""
rc_dir, git_dir, _ = run_git(["rev-parse", "--git-dir"], cwd=repo_root)
rc_common, git_common_dir, _ = run_git(
["rev-parse", "--git-common-dir"], cwd=repo_root
)
if rc_dir != 0 or rc_common != 0:
return False
git_dir_path = (repo_root / git_dir.strip()).resolve()
git_common_dir_path = (repo_root / git_common_dir.strip()).resolve()
return git_dir_path != git_common_dir_path
def warn_if_parallel_worktree(repo_root: Path) -> None:
"""Non-blocking note: index.md conflicts across parallel worktrees/branches
are expected and safe. Only fires when running in a linked git worktree
(not the main tree) with `session_auto_commit` enabled (#415 quick-fix tier).
"""
if not get_session_auto_commit(repo_root):
return
if not is_git_worktree(repo_root):
return
print(
colored(
"[NOTE] Running in a git worktree with session_auto_commit enabled: "
"journal-*.md files auto-merge via .gitattributes, but index.md "
"conflicts across parallel worktrees/branches are expected and safe "
"to resolve by picking either side (task state lives in task.json, "
"not index.md). See .trellis/spec/cli/backend/directory-structure.md "
'("Workspace Journal Merge Behavior").',
Colors.YELLOW,
),
file=sys.stderr,
)
def create_new_journal_file(
dev_dir: Path, num: int, developer: str, today: str, max_lines: int = 2000,
) -> Path:
@@ -143,15 +243,39 @@ def create_new_journal_file(
return new_file
def _render_bullet_section(header: str, items: list[str], bullet_prefix: str = "- ") -> str:
"""Render a Markdown section as bullets, or "" when there is no content.
A section with zero provided values is omitted entirely from the
rendered entry rather than falling back to a placeholder string.
"""
if not items:
return ""
bullets = "\n".join(f"{bullet_prefix}{item}" for item in items)
return f"\n\n### {header}\n\n{bullets}"
def _render_main_changes(changes: list[str], extra_content: str | None) -> str:
"""Render the Main Changes section from --change bullets or freeform content."""
if changes:
return _render_bullet_section("Main Changes", changes)
if extra_content:
return f"\n\n### Main Changes\n\n{extra_content}"
return ""
def generate_session_content(
session_num: int,
title: str,
commit: str,
summary: str,
extra_content: str,
today: str,
package: str | None = None,
branch: str | None = None,
changes: list[str] | None = None,
extra_content: str | None = None,
tests: list[str] | None = None,
next_steps: list[str] | None = None,
) -> str:
"""Generate session content."""
if commit and commit != "-":
@@ -166,6 +290,10 @@ def generate_session_content(
package_line = f"\n**Package**: {package}" if package else ""
branch_line = f"\n**Branch**: `{branch}`" if branch else ""
main_changes_section = _render_main_changes(changes or [], extra_content)
testing_section = _render_bullet_section("Testing", tests or [], bullet_prefix="- [OK] ")
next_steps_section = _render_bullet_section("Next Steps", next_steps or [])
return f"""
## Session {session_num}: {title}
@@ -175,27 +303,15 @@ def generate_session_content(
### Summary
{summary}
### Main Changes
{extra_content}
{summary}{main_changes_section}
### Git Commits
{commit_table}
### Testing
- [OK] (Add test results)
{commit_table}{testing_section}
### Status
[OK] **Completed**
### Next Steps
- None - task complete
[OK] **Completed**{next_steps_section}
"""
@@ -319,12 +435,14 @@ def update_index(
# =============================================================================
def _auto_commit_workspace(repo_root: Path) -> None:
"""Stage Trellis-owned workspace + task paths and commit.
"""Stage Trellis-owned workspace + current-task paths and commit.
Path scope is restricted to specific products (journal files, index.md,
active task dirs, the archive subtree). We never `git add` the whole
`.trellis/` tree, and if `.gitignore` blocks the specific paths we
warn + skip — never retry with ``-f``.
Path scope is restricted to specific products: the current developer's
journal files + index.md, and ONLY the current task directory (resolved
via ``get_current_task``). We never `git add` the whole `.trellis/` tree
or iterate over all active task dirs (#303: parallel-window dirty task
dirs must not be bundled into the session auto-commit). If `.gitignore`
blocks the specific paths we warn + skip — never retry with ``-f``.
Honors ``session_auto_commit`` in ``.trellis/config.yaml``: when set to
``false``, this function returns immediately without touching git
@@ -338,7 +456,23 @@ def _auto_commit_workspace(repo_root: Path) -> None:
return
commit_msg = get_session_commit_message(repo_root)
paths = safe_trellis_paths_to_add(repo_root)
# Resolve the current task so staging is scoped to its dir only. The ref
# is ``.trellis/tasks/<name>`` (or under archive/) — pass the bare name.
current = get_current_task(repo_root)
if current:
task_name = Path(current).name
paths = safe_trellis_paths_to_add(repo_root, task_name=task_name)
else:
# Current task unknown (0 or >=2 parallel sessions — exactly the
# parallel-window case #303 is about). Do NOT fall back to the wide
# `tasks_dir.iterdir()` scan; that would re-leak other tasks' dirty
# dirs into the session commit. Stage only the developer's journal/
# index and skip every task dir.
paths = [
p
for p in safe_trellis_paths_to_add(repo_root, task_name=None)
if not p.startswith(f"{DIR_WORKFLOW}/{DIR_TASKS}/")
]
if not paths:
print("[OK] No workspace changes to commit.", file=sys.stderr)
return
@@ -375,14 +509,18 @@ def _auto_commit_workspace(repo_root: Path) -> None:
def add_session(
title: str,
commit: str = "-",
summary: str = "(Add summary)",
extra_content: str = "(Add details)",
summary: str = "Session summary was not supplied.",
changes: list[str] | None = None,
extra_content: str | None = None,
tests: list[str] | None = None,
next_steps: list[str] | None = None,
auto_commit: bool = True,
package: str | None = None,
branch: str | None = None,
) -> int:
"""Add a new session."""
repo_root = get_repo_root()
warn_if_parallel_worktree(repo_root)
ensure_developer(repo_root)
developer = get_developer(repo_root)
@@ -405,8 +543,9 @@ def add_session(
new_session = current_session + 1
session_content = generate_session_content(
new_session, title, commit, summary, extra_content, today, package,
branch,
new_session, title, commit, summary, today, package, branch,
changes=changes, extra_content=extra_content, tests=tests,
next_steps=next_steps,
)
content_lines = len(session_content.splitlines())
@@ -483,10 +622,13 @@ def main() -> int:
)
parser.add_argument("--title", required=True, help="Session title")
parser.add_argument("--commit", default="-", help="Comma-separated commit hashes")
parser.add_argument("--summary", default="(Add summary)", help="Brief summary")
parser.add_argument("--summary", default="Session summary was not supplied.", help="Brief summary")
parser.add_argument("--content-file", help="Path to file with detailed content")
parser.add_argument("--package", help="Package name tag (e.g., cli, docs-site)")
parser.add_argument("--branch", help="Branch name (auto-detected if omitted)")
parser.add_argument("--change", action="append", help="Main Changes bullet (repeatable)")
parser.add_argument("--test", action="append", help="Testing bullet (repeatable)")
parser.add_argument("--next-step", action="append", help="Next Steps bullet (repeatable)")
parser.add_argument("--no-commit", action="store_true",
help="Skip auto-commit of workspace changes")
parser.add_argument("--stdin", action="store_true",
@@ -494,7 +636,7 @@ def main() -> int:
args = parser.parse_args()
extra_content = "(Add details)"
extra_content: str | None = None
if args.content_file:
content_path = Path(args.content_file)
if content_path.is_file():
@@ -523,20 +665,12 @@ def main() -> int:
task_package = task_data.package if task_data else None
package = resolve_package(task_package, repo_root)
# Resolve branch: CLI → task.json → git auto-detect → None
branch = args.branch
if not branch:
if task_data and task_data.raw.get("branch"):
branch = task_data.raw["branch"]
else:
_, branch_out, _ = run_git(["branch", "--show-current"], cwd=repo_root)
detected = branch_out.strip()
if detected:
branch = detected
branch = resolve_session_branch(repo_root, args.branch, task_data)
return add_session(
args.title, args.commit, args.summary, extra_content,
args.title, args.commit, args.summary,
changes=args.change, extra_content=extra_content, tests=args.test,
next_steps=args.next_step,
auto_commit=not args.no_commit,
package=package,
branch=branch,