@mindfoldhq/trellis 0.5.10 → 0.5.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -15,14 +15,19 @@ Design
15
15
  ------
16
16
  - Scripts only stage SPECIFIC product paths (journal files, index.md, the
17
17
  current task dir, the archive dir). Never the whole `.trellis/` tree.
18
- - If plain `git add <specific>` fails with "ignored by", retry with
19
- `git add -f <specific>` forcing only the paths the script knows it owns.
20
- This is safe because the paths are narrow; it is NOT equivalent to
21
- `git add -f .trellis/` (which would fan out to backups/worktrees/runtime).
22
- - If the -f retry also fails, print an explicit warning that includes a
23
- negative example: ``Do NOT use `git add -f .trellis/` ...``
24
-
25
- The wider-grain forbidden command stays forbidden.
18
+ - If plain `git add <specific>` fails with "ignored by", DO NOT retry with
19
+ ``-f``. The presence of `.trellis/` in `.gitignore` is treated as user
20
+ intent ("keep .trellis/ local-only"). The script warns and skips the
21
+ auto-commit; users who want auto-staging can either fix their `.gitignore`
22
+ or set ``session_auto_commit: false`` and manage git themselves.
23
+ - The warning includes a negative example: ``Do NOT use `git add -f .trellis/` ...``
24
+ so any AI rereading the log doesn't reinvent the bug.
25
+
26
+ History note: 0.5.10 introduced an automatic ``git add -f`` retry on the
27
+ specific paths. That was reverted in 0.5.11 — auto-forcing into a tree the
28
+ user had gitignored violates user intent even when the path list is narrow.
29
+ The wider-grain forbidden command stays forbidden, and the narrow-grain auto
30
+ ``-f`` is gone too.
26
31
  """
27
32
 
28
33
  from __future__ import annotations
@@ -145,20 +150,18 @@ def _stderr_indicates_ignored(stderr: str) -> bool:
145
150
  def safe_git_add(
146
151
  paths: list[str], repo_root: Path
147
152
  ) -> tuple[bool, bool, str]:
148
- """Run `git add` on specific paths, retrying with -f if .gitignore blocks.
153
+ """Run `git add` on specific paths; never retry with -f.
149
154
 
150
- Returns (success, used_force, stderr). On success, callers should still
151
- `git diff --cached` to detect whether anything was actually staged.
155
+ Returns ``(success, used_force, stderr)``. The ``used_force`` field is
156
+ kept for signature compatibility with the 0.5.10 implementation but is
157
+ always ``False`` — we never auto-force.
152
158
 
153
159
  Behavior:
154
160
  - No paths passed → success, no force, empty stderr.
155
- - Plain `git add <paths>` succeeds → return.
156
- - Plain fails with "ignored by" retry with `git add -f <paths>`.
157
- - Retry succeeds return success with used_force=True.
158
- - Retry fails → return failure; caller should print the gitignore
159
- warning (see :func:`print_gitignore_warning`).
160
- - Plain fails with a non-ignored error → return failure; do NOT retry
161
- with -f (we only force when ignore is the cause).
161
+ - Plain ``git add -- <paths>`` succeeds → return success.
162
+ - Plain fails (any reason ignored or otherwise) return failure with
163
+ the stderr. Callers should inspect the stderr (see
164
+ :func:`print_gitignore_warning`) and skip the auto-commit.
162
165
  """
163
166
  if not paths:
164
167
  return True, False, ""
@@ -166,14 +169,7 @@ def safe_git_add(
166
169
  rc, _, err = run_git(["add", "--", *paths], cwd=repo_root)
167
170
  if rc == 0:
168
171
  return True, False, ""
169
-
170
- if not _stderr_indicates_ignored(err):
171
- return False, False, err
172
-
173
- rc2, _, err2 = run_git(["add", "-f", "--", *paths], cwd=repo_root)
174
- if rc2 == 0:
175
- return True, True, err2 or err
176
- return False, True, err2 or err
172
+ return False, False, err
177
173
 
178
174
 
179
175
  def print_gitignore_warning(paths: list[str]) -> None:
@@ -187,6 +183,15 @@ def print_gitignore_warning(paths: list[str]) -> None:
187
183
  "[WARN] git add failed because .trellis/ paths are ignored by your .gitignore.",
188
184
  file=sys.stderr,
189
185
  )
186
+ print(
187
+ "[WARN] Skipping auto-commit. The journal/task files were still written to disk;",
188
+ file=sys.stderr,
189
+ )
190
+ print(
191
+ "[WARN] git was not touched.",
192
+ file=sys.stderr,
193
+ )
194
+ print("[WARN]", file=sys.stderr)
190
195
  print(
191
196
  "[WARN] Trellis manages these specific paths and they should be tracked:",
192
197
  file=sys.stderr,
@@ -219,6 +224,27 @@ def print_gitignore_warning(paths: list[str]) -> None:
219
224
  for sub in TRELLIS_IGNORED_SUBPATHS:
220
225
  print(f"[WARN] {sub}", file=sys.stderr)
221
226
  print("[WARN]", file=sys.stderr)
227
+ print(
228
+ "[WARN] Or, if you intentionally keep .trellis/ local-only, set in",
229
+ file=sys.stderr,
230
+ )
231
+ print(
232
+ "[WARN] .trellis/config.yaml:",
233
+ file=sys.stderr,
234
+ )
235
+ print(
236
+ "[WARN] session_auto_commit: false",
237
+ file=sys.stderr,
238
+ )
239
+ print(
240
+ "[WARN] so the scripts skip git entirely and you can review / commit",
241
+ file=sys.stderr,
242
+ )
243
+ print(
244
+ "[WARN] manually with `git status` / `git add` / `git commit`.",
245
+ file=sys.stderr,
246
+ )
247
+ print("[WARN]", file=sys.stderr)
222
248
  print(
223
249
  "[WARN] Do NOT use `git add -f .trellis/` — it pulls in backups, worktrees,",
224
250
  file=sys.stderr,
@@ -14,8 +14,12 @@ Provides:
14
14
  from __future__ import annotations
15
15
 
16
16
  import json
17
+ import os
18
+ import re
19
+ import subprocess
17
20
  from pathlib import Path
18
21
 
22
+ from .active_task import resolve_context_key
19
23
  from .config import get_git_packages
20
24
  from .git import run_git
21
25
  from .packages_context import get_packages_section
@@ -40,6 +44,14 @@ from .paths import (
40
44
  # Helpers
41
45
  # =============================================================================
42
46
 
47
+ _PACKAGE_NAME = "@mindfoldhq/trellis"
48
+ _UPDATE_CHECK_TIMEOUT_SECONDS = 1.0
49
+ _VERSION_RE = re.compile(
50
+ r"^\s*(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-([0-9A-Za-z.-]+))?\s*$"
51
+ )
52
+ _VERSION_TOKEN_RE = re.compile(r"\b\d+(?:\.\d+){1,2}(?:-[0-9A-Za-z.-]+)?\b")
53
+
54
+
43
55
  def _collect_package_git_info(repo_root: Path) -> list[dict]:
44
56
  """Collect git status and recent commits for packages with independent git repos.
45
57
 
@@ -109,6 +121,158 @@ def _append_package_git_context(lines: list[str], package_git_info: list[dict])
109
121
  lines.append("")
110
122
 
111
123
 
124
+ def _read_project_version(repo_root: Path) -> str | None:
125
+ try:
126
+ version = (repo_root / DIR_WORKFLOW / ".version").read_text(
127
+ encoding="utf-8"
128
+ ).strip()
129
+ except OSError:
130
+ return None
131
+ return version or None
132
+
133
+
134
+ def _fetch_trellis_version_output() -> str | None:
135
+ try:
136
+ result = subprocess.run(
137
+ ["trellis", "--version"],
138
+ capture_output=True,
139
+ text=True,
140
+ encoding="utf-8",
141
+ errors="replace",
142
+ timeout=_UPDATE_CHECK_TIMEOUT_SECONDS,
143
+ )
144
+ except (OSError, subprocess.SubprocessError, TimeoutError):
145
+ return None
146
+
147
+ if result.returncode != 0:
148
+ return None
149
+ output = f"{result.stdout}\n{result.stderr}".strip()
150
+ return output or None
151
+
152
+
153
+ def _extract_available_update_version(output: str) -> str | None:
154
+ update_match = re.search(
155
+ r"Trellis update available:\s*"
156
+ r"(?P<current>\S+)\s*(?:→|->)\s*(?P<latest>\S+)",
157
+ output,
158
+ )
159
+ if update_match:
160
+ return update_match.group("latest").strip()
161
+ candidates = _VERSION_TOKEN_RE.findall(output)
162
+ return candidates[-1] if candidates else None
163
+
164
+
165
+ def _resolve_available_update_version() -> str | None:
166
+ output = _fetch_trellis_version_output()
167
+ if not output:
168
+ return None
169
+ return _extract_available_update_version(output)
170
+
171
+
172
+ def _parse_version(version: str) -> tuple[tuple[int, int, int], tuple[str, ...] | None] | None:
173
+ match = _VERSION_RE.match(version)
174
+ if not match:
175
+ return None
176
+ major, minor, patch, prerelease = match.groups()
177
+ numbers = (int(major), int(minor or "0"), int(patch or "0"))
178
+ prerelease_parts = tuple(prerelease.split(".")) if prerelease else None
179
+ return numbers, prerelease_parts
180
+
181
+
182
+ def _compare_prerelease(
183
+ left: tuple[str, ...] | None,
184
+ right: tuple[str, ...] | None,
185
+ ) -> int:
186
+ if left is None and right is None:
187
+ return 0
188
+ if left is None:
189
+ return 1
190
+ if right is None:
191
+ return -1
192
+
193
+ for left_part, right_part in zip(left, right):
194
+ if left_part == right_part:
195
+ continue
196
+ left_numeric = left_part.isdigit()
197
+ right_numeric = right_part.isdigit()
198
+ if left_numeric and right_numeric:
199
+ left_int = int(left_part)
200
+ right_int = int(right_part)
201
+ return (left_int > right_int) - (left_int < right_int)
202
+ if left_numeric:
203
+ return -1
204
+ if right_numeric:
205
+ return 1
206
+ return (left_part > right_part) - (left_part < right_part)
207
+
208
+ return (len(left) > len(right)) - (len(left) < len(right))
209
+
210
+
211
+ def _compare_versions(left: str, right: str) -> int | None:
212
+ parsed_left = _parse_version(left)
213
+ parsed_right = _parse_version(right)
214
+ if parsed_left is None or parsed_right is None:
215
+ return None
216
+
217
+ left_numbers, left_prerelease = parsed_left
218
+ right_numbers, right_prerelease = parsed_right
219
+ if left_numbers != right_numbers:
220
+ return (left_numbers > right_numbers) - (left_numbers < right_numbers)
221
+ return _compare_prerelease(left_prerelease, right_prerelease)
222
+
223
+
224
+ def _update_marker_path(repo_root: Path) -> Path:
225
+ context_key = resolve_context_key()
226
+ if not context_key:
227
+ terminal_key = os.environ.get("TERM_SESSION_ID", "").strip()
228
+ context_key = terminal_key or f"ppid-{os.getppid()}"
229
+ safe_key = re.sub(r"[^A-Za-z0-9._-]+", "_", context_key).strip("._-")
230
+ if not safe_key:
231
+ safe_key = "session"
232
+ return (
233
+ repo_root
234
+ / DIR_WORKFLOW
235
+ / ".runtime"
236
+ / f"update-check-{safe_key[:160]}.marker"
237
+ )
238
+
239
+
240
+ def _mark_update_check_attempted(repo_root: Path) -> bool:
241
+ marker_path = _update_marker_path(repo_root)
242
+ if marker_path.exists():
243
+ return False
244
+ try:
245
+ marker_path.parent.mkdir(parents=True, exist_ok=True)
246
+ marker_path.write_text("checked\n", encoding="utf-8")
247
+ except OSError:
248
+ pass
249
+ return True
250
+
251
+
252
+ def _get_update_hint(repo_root: Path) -> str | None:
253
+ marker_path = _update_marker_path(repo_root)
254
+ if marker_path.exists():
255
+ return None
256
+
257
+ current_version = _read_project_version(repo_root)
258
+ if not current_version:
259
+ return None
260
+
261
+ latest_version = _resolve_available_update_version()
262
+ if not latest_version:
263
+ return None
264
+
265
+ _mark_update_check_attempted(repo_root)
266
+ comparison = _compare_versions(current_version, latest_version)
267
+ if comparison is None or comparison >= 0:
268
+ return None
269
+
270
+ return (
271
+ f"Trellis update available: {current_version} -> {latest_version}, "
272
+ f"run npm install -g {_PACKAGE_NAME}@latest"
273
+ )
274
+
275
+
112
276
  # =============================================================================
113
277
  # JSON Output
114
278
  # =============================================================================
@@ -571,4 +735,10 @@ def output_text(repo_root: Path | None = None) -> None:
571
735
  Args:
572
736
  repo_root: Repository root path. Defaults to auto-detected.
573
737
  """
738
+ if repo_root is None:
739
+ repo_root = get_repo_root()
740
+ update_hint = _get_update_hint(repo_root)
741
+ if update_hint:
742
+ print(update_hint)
743
+ print("")
574
744
  print(get_context_text(repo_root))
@@ -24,6 +24,7 @@ from pathlib import Path
24
24
 
25
25
  from .config import (
26
26
  get_packages,
27
+ get_session_auto_commit,
27
28
  is_monorepo,
28
29
  resolve_package,
29
30
  validate_package,
@@ -391,16 +392,28 @@ def _auto_commit_archive(task_name: str, repo_root: Path) -> None:
391
392
  """Stage Trellis-owned task paths and commit after archive.
392
393
 
393
394
  Only stages specific subpaths (the archive subtree and active task dirs),
394
- never the whole `.trellis/` tree. If `.gitignore` excludes `.trellis/`,
395
- falls back to `git add -f <specific>` and emits a warning that explicitly
396
- forbids `git add -f .trellis/` (which would fan out to caches/backups).
395
+ never the whole ``.trellis/`` tree. If ``.gitignore`` blocks the paths,
396
+ we warn + skip — we do NOT retry with ``git add -f``. The warning
397
+ explicitly forbids ``git add -f .trellis/`` (which would fan out to
398
+ caches/backups) and points users at ``session_auto_commit: false``.
399
+
400
+ Honors ``session_auto_commit`` in ``.trellis/config.yaml``: when set to
401
+ ``false``, this function returns immediately without touching git
402
+ (the archive directory move on disk is unaffected).
397
403
  """
404
+ if not get_session_auto_commit(repo_root):
405
+ print(
406
+ "[OK] session_auto_commit: false — skipping git stage/commit.",
407
+ file=sys.stderr,
408
+ )
409
+ return
410
+
398
411
  paths = safe_archive_paths_to_add(repo_root)
399
412
  if not paths:
400
413
  print("[OK] No task changes to commit.", file=sys.stderr)
401
414
  return
402
415
 
403
- success, used_force, err = safe_git_add(paths, repo_root)
416
+ success, _, err = safe_git_add(paths, repo_root)
404
417
  if not success:
405
418
  if err and "ignored by" in err.lower():
406
419
  print_gitignore_warning(paths)
@@ -411,12 +424,6 @@ def _auto_commit_archive(task_name: str, repo_root: Path) -> None:
411
424
  )
412
425
  return
413
426
 
414
- if used_force:
415
- print(
416
- "[OK] Staged Trellis-owned paths with -f (specific paths, not .trellis/).",
417
- file=sys.stderr,
418
- )
419
-
420
427
  rc, _, _ = run_git(
421
428
  ["diff", "--cached", "--quiet", "--", *paths], cwd=repo_root
422
429
  )
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mindfoldhq/trellis",
3
- "version": "0.5.10",
3
+ "version": "0.5.12",
4
4
  "description": "AI capabilities grow like ivy — Trellis provides the structure to guide them along a disciplined path",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",