| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285 |
- """
- Safe git-add helpers for Trellis-owned paths.
- Why this module exists
- ----------------------
- A real user incident: a project's `.gitignore` listed `.trellis/` (company-wide
- template / personal habit). When `add_session.py` and `task.py archive` ran
- their auto-commit and `git add` failed with `ignored by .gitignore`, the AI
- agent driving the workflow "fixed" it by retrying with
- `git add -f .trellis/` — which fan-out-included every ignored subtree
- (`.trellis/.backup-*/`, `.trellis/worktrees/`, `.trellis/.template-hashes.json`,
- `.trellis/.runtime/`), committing 548 files / 83474 lines of caches/backups.
- Design
- ------
- - Scripts only stage SPECIFIC product paths (journal files, index.md, the
- current task dir, the archive dir). Never the whole `.trellis/` tree.
- - If plain `git add <specific>` fails with "ignored by", DO NOT retry with
- ``-f``. The presence of `.trellis/` in `.gitignore` is treated as user
- intent ("keep .trellis/ local-only"). The script warns and skips the
- auto-commit; users who want auto-staging can either fix their `.gitignore`
- or set ``session_auto_commit: false`` and manage git themselves.
- - The warning includes a negative example: ``Do NOT use `git add -f .trellis/` ...``
- so any AI rereading the log doesn't reinvent the bug.
- History note: 0.5.10 introduced an automatic ``git add -f`` retry on the
- specific paths. That was reverted in 0.5.11 — auto-forcing into a tree the
- user had gitignored violates user intent even when the path list is narrow.
- The wider-grain forbidden command stays forbidden, and the narrow-grain auto
- ``-f`` is gone too.
- """
- 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,
- )
- # Paths under .trellis/ that must NEVER be auto-staged. Listed here so the
- # warning to the user can show concrete subpaths to ignore individually
- # instead of ignoring the whole `.trellis/` tree.
- TRELLIS_IGNORED_SUBPATHS = (
- ".trellis/.backup-*",
- ".trellis/worktrees/",
- ".trellis/.template-hashes.json",
- ".trellis/.runtime/",
- ".trellis/.cache/",
- )
- def safe_trellis_paths_to_add(repo_root: Path) -> list[str]:
- """Return the list of repo-relative paths the auto-commit should stage.
- Only includes paths that exist on disk so callers don't pass non-existent
- arguments to git. The caller is responsible for `git diff --cached`
- checking afterwards.
- Included:
- - .trellis/workspace/<developer>/journal-*.md
- - .trellis/workspace/<developer>/index.md
- - .trellis/tasks/<task-dir>/ (every active task directory)
- - .trellis/tasks/archive/ (whole archive subtree, if present)
- Excluded (intentionally — these must not be staged):
- - .trellis/.backup-*, .trellis/worktrees/,
- .trellis/.template-hashes.json, .trellis/.runtime/, .trellis/.cache/
- """
- paths: list[str] = []
- # Workspace journal files + 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"
- )
- # Active tasks: each direct child of tasks/ that is a directory and not
- # the archive root. The archive subtree is added as a single path below.
- tasks_dir = repo_root / DIR_WORKFLOW / DIR_TASKS
- if tasks_dir.is_dir():
- 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]:
- """Return paths to stage after `task.py archive`.
- Scoped to ONLY the paths the archive operation actually touched:
- - the archive subtree (where the freshly-moved task lives)
- - the source task directory (for source-side deletes; caller pairs
- this with `git rm --cached` since `git add` won't stage deletes
- for a path that no longer exists in the working tree)
- - any child task directories whose `task.json` was edited to drop
- the archived parent (parent-children relationship update)
- This narrow scope avoids "scope creep" — dirty changes in OTHER
- active task dirs (parallel-window edits) are NOT bundled into the
- archive commit. Callers handle each kind of change in its own
- commit boundary.
- Backwards-compat: with no arguments, the function walks the whole
- `.trellis/tasks/` subtree the old way (active tasks + archive). New
- callers should always pass `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:
- # Narrow scope — only paths that still exist on disk (so
- # `git add` doesn't choke on the moved-away source). The caller
- # handles the source-side deletes via `git rm --cached`
- # explicitly.
- 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
- # Legacy wide scope (no task_name): preserve old behavior so callers
- # that have not been updated keep working.
- 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 error indicates the path is excluded by .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]:
- """Run `git add` on specific paths; never retry with -f.
- Returns ``(success, used_force, stderr)``. The ``used_force`` field is
- kept for signature compatibility with the 0.5.10 implementation but is
- always ``False`` — we never auto-force.
- Behavior:
- - No paths passed → success, no force, empty stderr.
- - Plain ``git add -- <paths>`` succeeds → return success.
- - Plain fails (any reason — ignored or otherwise) → return failure with
- the stderr. Callers should inspect the stderr (see
- :func:`print_gitignore_warning`) and skip the auto-commit.
- """
- 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:
- """Explain to the user (and any AI reading the log) what to do.
- CRITICAL: includes the negative example
- ``Do NOT use `git add -f .trellis/``` — agents reading the warning are
- known to invent that command, which fans out to ignored caches/backups.
- """
- print(
- "[WARN] git add failed because .trellis/ paths are ignored by your .gitignore.",
- file=sys.stderr,
- )
- print(
- "[WARN] Skipping auto-commit. The journal/task files were still written to disk;",
- file=sys.stderr,
- )
- print(
- "[WARN] git was not touched.",
- file=sys.stderr,
- )
- print("[WARN]", file=sys.stderr)
- print(
- "[WARN] Trellis manages these specific paths and they should be tracked:",
- file=sys.stderr,
- )
- if paths:
- for p in paths:
- print(f"[WARN] {p}", file=sys.stderr)
- else:
- print(
- "[WARN] .trellis/workspace/<developer>/{journal-*.md,index.md}",
- file=sys.stderr,
- )
- print(
- "[WARN] .trellis/tasks/<task-dir>/",
- file=sys.stderr,
- )
- print(
- "[WARN] .trellis/tasks/archive/",
- file=sys.stderr,
- )
- print("[WARN]", file=sys.stderr)
- print(
- "[WARN] Recommended: change your .gitignore from `.trellis/` to specific",
- file=sys.stderr,
- )
- print(
- "[WARN] subpaths that should remain ignored, e.g.:",
- file=sys.stderr,
- )
- for sub in TRELLIS_IGNORED_SUBPATHS:
- print(f"[WARN] {sub}", file=sys.stderr)
- print("[WARN]", file=sys.stderr)
- print(
- "[WARN] Or, if you intentionally keep .trellis/ local-only, set in",
- file=sys.stderr,
- )
- print(
- "[WARN] .trellis/config.yaml:",
- file=sys.stderr,
- )
- print(
- "[WARN] session_auto_commit: false",
- file=sys.stderr,
- )
- print(
- "[WARN] so the scripts skip git entirely and you can review / commit",
- file=sys.stderr,
- )
- print(
- "[WARN] manually with `git status` / `git add` / `git commit`.",
- file=sys.stderr,
- )
- print("[WARN]", file=sys.stderr)
- print(
- "[WARN] Do NOT use `git add -f .trellis/` — it pulls in backups, worktrees,",
- file=sys.stderr,
- )
- print(
- "[WARN] and runtime caches that should never be committed.",
- file=sys.stderr,
- )
|