""" Trellis 管理路径的安全 git-add 辅助函数。 为什么需要此模块 ---------------------- 真实事故:某项目的 `.gitignore` 列出了 `.trellis/`(公司模板或个人习惯)。 当 `add_session.py` 和 `task.py archive` 执行自动提交、`git add` 因 `ignored by .gitignore` 失败时,驱动 workflow 的 AI agent 曾用 `git add -f .trellis/` 重试“修复”——这会递归包含所有被忽略的子目录 (`.trellis/.backup-*/`、`.trellis/worktrees/`、`.trellis/.template-hashes.json`、 `.trellis/.runtime/`),最终提交了 548 个文件、83,474 行缓存/备份。 设计 ------ - 脚本只 stage 明确的产品路径(journal 文件、index.md、当前任务目录和归档目录), 绝不 stage 整个 `.trellis/` 树。 - 如果普通 `git add ` 因“ignored by”失败,**不要**使用 ``-f`` 重试。 `.gitignore` 中存在 `.trellis/` 被视为用户意图(“仅在本地保留 .trellis/”)。 脚本会警告并跳过自动提交;需要自动 stage 时,用户应修正 `.gitignore`,或设置 ``session_auto_commit: false`` 后自行管理 Git。 - 警告包含反例 ``不要使用 `git add -f .trellis/`……``,避免 AI 重读日志时再次引入该 bug。 历史说明:0.5.10 曾对指定路径自动重试 ``git add -f``,0.5.11 已撤销——即使路径 列表很窄,向用户明确 gitignore 的目录自动强制写入仍违背用户意图。宽范围的禁用命令 继续禁止,窄范围自动 ``-f`` 也已移除。 """ from __future__ import annotations import sys from pathlib import Path from .git import run_git from .paths import ( DIR_ARCHIVE, DIR_TASKS, DIR_WORKFLOW, DIR_WORKSPACE, FILE_JOURNAL_PREFIX, get_developer, ) # .trellis/ 下绝不能自动暂存的路径。列出这些具体子路径,便于向用户提示逐项 # 忽略,而不是把整个 `.trellis/` 树一并忽略。 TRELLIS_IGNORED_SUBPATHS = ( ".trellis/.backup-*", ".trellis/worktrees/", ".trellis/.template-hashes.json", ".trellis/.runtime/", ".trellis/.cache/", ) def safe_trellis_paths_to_add( repo_root: Path, task_name: str | None = None, ) -> list[str]: """返回自动提交应暂存的仓库相对路径列表。 只包含磁盘上存在的路径,避免向 git 传入不存在的参数;调用方负责随后检查 `git diff --cached`。 包含: - .trellis/workspace//journal-*.md - .trellis/workspace//index.md - .trellis/tasks//(传入 ``task_name`` 时仅包含当前任务目录;如果任务 已位于 archive/ 下,同时包含其归档位置) 明确排除(不得暂存): - .trellis/.backup-*, .trellis/worktrees/, .trellis/.template-hashes.json, .trellis/.runtime/, .trellis/.cache/ 范围契约(见 #303 / break-loop 分析):传入 ``task_name`` 时,任务部分只暂存该 任务目录,不会通过 ``tasks_dir.iterdir()`` 遍历所有活动任务。这与 :func:`safe_archive_paths_to_add` 一致,避免并行窗口中 OTHER 任务目录的脏改动 被打包进 session 自动提交。 向后兼容:未传 ``task_name`` 时保留旧的宽范围行为,遍历所有活动任务目录及 archive 子树。新调用方应始终传入 ``task_name``。 """ paths: list[str] = [] # 工作区 journal 文件和 index.md。 developer = get_developer(repo_root) if developer: ws = repo_root / DIR_WORKFLOW / DIR_WORKSPACE / developer if ws.is_dir(): for f in sorted(ws.glob(f"{FILE_JOURNAL_PREFIX}*.md")): if f.is_file(): paths.append( f"{DIR_WORKFLOW}/{DIR_WORKSPACE}/{developer}/{f.name}" ) index_md = ws / "index.md" if index_md.is_file(): paths.append( f"{DIR_WORKFLOW}/{DIR_WORKSPACE}/{developer}/index.md" ) tasks_dir = repo_root / DIR_WORKFLOW / DIR_TASKS if not tasks_dir.is_dir(): return paths if task_name is not None: # 窄范围:只处理当前任务目录(活动或已归档)。绝不遍历全部任务;并行窗口 # 中其他任务目录的脏改动不得泄漏到 session 自动提交。 active_task = tasks_dir / task_name if active_task.is_dir(): paths.append(f"{DIR_WORKFLOW}/{DIR_TASKS}/{task_name}") archived_task = tasks_dir / DIR_ARCHIVE / task_name if archived_task.is_dir(): paths.append( f"{DIR_WORKFLOW}/{DIR_TASKS}/{DIR_ARCHIVE}/{task_name}" ) return paths # 旧的宽范围行为(未传 task_name):处理 tasks/ 下除 archive 外的每个直接子目录, # 以及整个 archive 子树。 for child in sorted(tasks_dir.iterdir()): if not child.is_dir(): continue if child.name == DIR_ARCHIVE: continue paths.append(f"{DIR_WORKFLOW}/{DIR_TASKS}/{child.name}") archive_dir = tasks_dir / DIR_ARCHIVE if archive_dir.is_dir(): paths.append(f"{DIR_WORKFLOW}/{DIR_TASKS}/{DIR_ARCHIVE}") return paths def safe_archive_paths_to_add( repo_root: Path, task_name: str | None = None, modified_children: list[str] | None = None, ) -> list[str]: """返回执行 `task.py archive` 后应暂存的路径。 范围严格限制为归档操作实际触碰的路径: - 归档子树(刚移动的任务所在位置); - 源任务目录(用于记录源端删除;调用方需配合 `git rm --cached`,因为工作树中 已不存在的路径无法由 `git add` 暂存删除); - `task.json` 被编辑以移除已归档父任务的所有子任务目录(更新 parent/children 关系)。 这种窄范围可避免“范围蔓延”:其他活动任务目录(并行窗口改动)的脏变更不会 被打包进归档提交。调用方应在各自的提交边界处理不同类型的变化。 向后兼容:不传参数时保留旧行为,遍历整个 `.trellis/tasks/` 子树(活动任务和 archive)。新调用方应始终传入 ``task_name``。 """ paths: list[str] = [] tasks_dir = repo_root / DIR_WORKFLOW / DIR_TASKS if not tasks_dir.is_dir(): return paths archive_dir = tasks_dir / DIR_ARCHIVE if task_name is not None: # 窄范围:只返回磁盘上仍存在的路径(避免 `git add` 因源目录已移动而失败)。 # 源目录删除由调用方显式使用 `git rm --cached` 处理。 if archive_dir.is_dir(): paths.append( f"{DIR_WORKFLOW}/{DIR_TASKS}/{DIR_ARCHIVE}" ) for child_name in modified_children or []: paths.append(f"{DIR_WORKFLOW}/{DIR_TASKS}/{child_name}") return paths # 旧的宽范围行为(未传 task_name):保留兼容性,供尚未更新的调用方继续使用。 if archive_dir.is_dir(): paths.append(f"{DIR_WORKFLOW}/{DIR_TASKS}/{DIR_ARCHIVE}") for child in sorted(tasks_dir.iterdir()): if not child.is_dir(): continue if child.name == DIR_ARCHIVE: continue paths.append(f"{DIR_WORKFLOW}/{DIR_TASKS}/{child.name}") return paths def _stderr_indicates_ignored(stderr: str) -> bool: """判断 git add 错误是否表示路径被 .gitignore 排除。""" if not stderr: return False lowered = stderr.lower() return "ignored by" in lowered def safe_git_add( paths: list[str], repo_root: Path ) -> tuple[bool, bool, str]: """对明确路径执行 `git add`,绝不使用 ``-f`` 重试。 返回 ``(success, used_force, stderr)``。``used_force`` 为兼容 0.5.10 实现而保留, 但始终为 ``False``,因为脚本不会自动强制添加。 行为: - 未传路径 → 成功、不强制、stderr 为空; - 普通 ``git add -- `` 成功 → 返回成功; - 普通添加因任意原因失败(被忽略或其他原因)→ 返回失败和 stderr。调用方应 检查 stderr(参见 :func:`print_gitignore_warning`)并跳过自动提交。 """ if not paths: return True, False, "" rc, _, err = run_git(["add", "--", *paths], cwd=repo_root) if rc == 0: return True, False, "" return False, False, err def print_gitignore_warning(paths: list[str]) -> None: """向用户(以及阅读日志的 AI)说明应采取的操作。 重要:包含反例 ``不要使用 `git add -f .trellis/` ``;已知 agent 可能会重造该命令, 从而递归加入被忽略的缓存和备份。 """ print( "[WARN] git add 失败:.trellis/ 路径被 .gitignore 忽略。", file=sys.stderr, ) print( "[WARN] 跳过自动提交;journal/task 文件仍已写入磁盘。", file=sys.stderr, ) print( "[WARN] 未操作 Git。", file=sys.stderr, ) print("[WARN]", file=sys.stderr) print( "[WARN] Trellis 管理以下明确路径,建议将它们纳入跟踪:", file=sys.stderr, ) if paths: for p in paths: print(f"[WARN] {p}", file=sys.stderr) else: print( "[WARN] .trellis/workspace//{journal-*.md,index.md}", file=sys.stderr, ) print( "[WARN] .trellis/tasks//", file=sys.stderr, ) print( "[WARN] .trellis/tasks/archive/", file=sys.stderr, ) print("[WARN]", file=sys.stderr) print( "[WARN] 建议:将 .gitignore 中的 `.trellis/` 改为需要忽略的具体", file=sys.stderr, ) print( "[WARN] 子路径,例如:", file=sys.stderr, ) for sub in TRELLIS_IGNORED_SUBPATHS: print(f"[WARN] {sub}", file=sys.stderr) print("[WARN]", file=sys.stderr) print( "[WARN] 或者,如果你有意只在本地保留 .trellis/,请在以下位置设置:", file=sys.stderr, ) print( "[WARN] .trellis/config.yaml:", file=sys.stderr, ) print( "[WARN] session_auto_commit: false", file=sys.stderr, ) print( "[WARN] 这样脚本会完全跳过 Git,由你自行审阅/提交", file=sys.stderr, ) print( "[WARN] 由你使用 `git status` / `git add` / `git commit` 手动完成。", file=sys.stderr, ) print("[WARN]", file=sys.stderr) print( "[WARN] 不要使用 `git add -f .trellis/`——它会加入备份、worktree、", file=sys.stderr, ) print( "[WARN] 以及不应提交的 runtime 缓存。", file=sys.stderr, )