@mindfoldhq/trellis 0.5.12 → 0.5.14

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.
@@ -50,12 +50,140 @@ _VERSION_RE = re.compile(
50
50
  r"^\s*(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-([0-9A-Za-z.-]+))?\s*$"
51
51
  )
52
52
  _VERSION_TOKEN_RE = re.compile(r"\b\d+(?:\.\d+){1,2}(?:-[0-9A-Za-z.-]+)?\b")
53
+ _POLYREPO_IGNORED_DIRS = {
54
+ "node_modules",
55
+ "target",
56
+ "dist",
57
+ "build",
58
+ "out",
59
+ "bin",
60
+ "obj",
61
+ "vendor",
62
+ "coverage",
63
+ "tmp",
64
+ "__pycache__",
65
+ }
66
+ _POLYREPO_SCAN_MAX_DEPTH = 2
67
+
68
+
69
+ def _is_git_worktree(path: Path) -> bool:
70
+ """Return True when path is inside a Git worktree."""
71
+ rc, out, _ = run_git(["rev-parse", "--is-inside-work-tree"], cwd=path)
72
+ return rc == 0 and out.strip().lower() == "true"
73
+
74
+
75
+ def _parse_recent_commits(log_output: str) -> list[dict]:
76
+ """Parse `git log --oneline` output into structured commit entries."""
77
+ commits = []
78
+ for line in log_output.splitlines():
79
+ if not line.strip():
80
+ continue
81
+ parts = line.split(" ", 1)
82
+ if len(parts) >= 2:
83
+ commits.append({"hash": parts[0], "message": parts[1]})
84
+ elif len(parts) == 1:
85
+ commits.append({"hash": parts[0], "message": ""})
86
+ return commits
87
+
88
+
89
+ def _collect_git_repo_info(name: str, rel_path: str, repo_dir: Path) -> dict | None:
90
+ """Collect Git status for one known repository directory."""
91
+ if not (repo_dir / ".git").exists():
92
+ return None
93
+
94
+ _, branch_out, _ = run_git(["branch", "--show-current"], cwd=repo_dir)
95
+ branch = branch_out.strip() or "unknown"
96
+
97
+ _, status_out, _ = run_git(["status", "--porcelain"], cwd=repo_dir)
98
+ changes = len([l for l in status_out.splitlines() if l.strip()])
99
+
100
+ _, log_out, _ = run_git(["log", "--oneline", "-5"], cwd=repo_dir)
101
+
102
+ return {
103
+ "name": name,
104
+ "path": rel_path,
105
+ "branch": branch,
106
+ "isClean": changes == 0,
107
+ "uncommittedChanges": changes,
108
+ "recentCommits": _parse_recent_commits(log_out),
109
+ }
110
+
111
+
112
+ def _collect_root_git_info(repo_root: Path) -> dict:
113
+ """Collect root Git info without pretending a non-Git root is clean."""
114
+ if not _is_git_worktree(repo_root):
115
+ return {
116
+ "isRepo": False,
117
+ "branch": "",
118
+ "isClean": False,
119
+ "uncommittedChanges": 0,
120
+ "recentCommits": [],
121
+ }
122
+
123
+ _, branch_out, _ = run_git(["branch", "--show-current"], cwd=repo_root)
124
+ branch = branch_out.strip() or "unknown"
125
+
126
+ _, status_out, _ = run_git(["status", "--porcelain"], cwd=repo_root)
127
+ status_lines = [line for line in status_out.splitlines() if line.strip()]
128
+
129
+ _, short_out, _ = run_git(["status", "--short"], cwd=repo_root)
130
+
131
+ _, log_out, _ = run_git(["log", "--oneline", "-5"], cwd=repo_root)
132
+
133
+ return {
134
+ "isRepo": True,
135
+ "branch": branch,
136
+ "isClean": len(status_lines) == 0,
137
+ "uncommittedChanges": len(status_lines),
138
+ "statusShort": short_out.splitlines(),
139
+ "recentCommits": _parse_recent_commits(log_out),
140
+ }
141
+
142
+
143
+ def _discover_child_git_repos(repo_root: Path) -> list[tuple[str, str]]:
144
+ """Discover child Git repositories using the init-time polyrepo heuristic."""
145
+ found: list[str] = []
53
146
 
147
+ def is_candidate_dir(path: Path) -> bool:
148
+ name = path.name
149
+ return not name.startswith(".") and name not in _POLYREPO_IGNORED_DIRS
54
150
 
55
- def _collect_package_git_info(repo_root: Path) -> list[dict]:
56
- """Collect git status and recent commits for packages with independent git repos.
151
+ def scan(rel_dir: Path, depth: int) -> None:
152
+ if depth >= _POLYREPO_SCAN_MAX_DEPTH:
153
+ return
154
+ abs_dir = repo_root / rel_dir
155
+ try:
156
+ children = sorted(abs_dir.iterdir(), key=lambda p: p.name)
157
+ except OSError:
158
+ return
57
159
 
58
- Only packages marked with ``git: true`` in config.yaml are included.
160
+ for child in children:
161
+ if not child.is_dir() or not is_candidate_dir(child):
162
+ continue
163
+
164
+ child_rel = (
165
+ rel_dir / child.name if rel_dir != Path(".") else Path(child.name)
166
+ )
167
+ if (child / ".git").exists():
168
+ found.append(child_rel.as_posix())
169
+ continue
170
+ scan(child_rel, depth + 1)
171
+
172
+ scan(Path("."), 0)
173
+ if len(found) < 2:
174
+ return []
175
+ return [(path.replace("/", "_"), path) for path in sorted(found)]
176
+
177
+
178
+ def _collect_package_git_info(
179
+ repo_root: Path,
180
+ discover_unconfigured: bool = False,
181
+ ) -> list[dict]:
182
+ """Collect Git status for independent package repositories.
183
+
184
+ Packages marked with ``git: true`` in config.yaml are authoritative.
185
+ When the Trellis root is not a Git repo and no configured package repos are
186
+ available, optionally fall back to the bounded polyrepo child scan.
59
187
 
60
188
  Returns:
61
189
  List of dicts with keys: name, path, branch, isClean,
@@ -63,41 +191,56 @@ def _collect_package_git_info(repo_root: Path) -> list[dict]:
63
191
  Empty list if no git-repo packages are configured.
64
192
  """
65
193
  git_pkgs = get_git_packages(repo_root)
66
- if not git_pkgs:
67
- return []
68
-
69
194
  result = []
70
195
  for pkg_name, pkg_path in git_pkgs.items():
71
196
  pkg_dir = repo_root / pkg_path
72
- if not (pkg_dir / ".git").exists():
73
- continue
197
+ info = _collect_git_repo_info(pkg_name, pkg_path, pkg_dir)
198
+ if info is not None:
199
+ result.append(info)
74
200
 
75
- _, branch_out, _ = run_git(["branch", "--show-current"], cwd=pkg_dir)
76
- branch = branch_out.strip() or "unknown"
77
-
78
- _, status_out, _ = run_git(["status", "--porcelain"], cwd=pkg_dir)
79
- changes = len([l for l in status_out.splitlines() if l.strip()])
80
-
81
- _, log_out, _ = run_git(["log", "--oneline", "-5"], cwd=pkg_dir)
82
- commits = []
83
- for line in log_out.splitlines():
84
- if line.strip():
85
- parts = line.split(" ", 1)
86
- if len(parts) >= 2:
87
- commits.append({"hash": parts[0], "message": parts[1]})
88
- elif len(parts) == 1:
89
- commits.append({"hash": parts[0], "message": ""})
90
-
91
- result.append({
92
- "name": pkg_name,
93
- "path": pkg_path,
94
- "branch": branch,
95
- "isClean": changes == 0,
96
- "uncommittedChanges": changes,
97
- "recentCommits": commits,
98
- })
201
+ if result or not discover_unconfigured:
202
+ return result
203
+
204
+ discovered = []
205
+ for pkg_name, pkg_path in _discover_child_git_repos(repo_root):
206
+ info = _collect_git_repo_info(pkg_name, pkg_path, repo_root / pkg_path)
207
+ if info is not None:
208
+ discovered.append(info)
209
+ return discovered
99
210
 
100
- return result
211
+
212
+ def _append_root_git_context(lines: list[str], root_git_info: dict) -> None:
213
+ """Append root Git status without misleading non-Git roots."""
214
+ lines.append("## GIT STATUS")
215
+ if not root_git_info["isRepo"]:
216
+ lines.append("Root is not a Git repository.")
217
+ lines.append("Run Git commands from the package repository paths listed below.")
218
+ else:
219
+ lines.append(f"Branch: {root_git_info['branch']}")
220
+ if root_git_info["isClean"]:
221
+ lines.append("Working directory: Clean")
222
+ else:
223
+ lines.append(
224
+ f"Working directory: {root_git_info['uncommittedChanges']} "
225
+ "uncommitted change(s)"
226
+ )
227
+ lines.append("")
228
+ lines.append("Changes:")
229
+ for line in root_git_info.get("statusShort", [])[:10]:
230
+ lines.append(line)
231
+ lines.append("")
232
+
233
+ lines.append("## RECENT COMMITS")
234
+ if not root_git_info["isRepo"]:
235
+ lines.append(
236
+ "Root has no Git commit history because it is not a Git repository."
237
+ )
238
+ elif root_git_info["recentCommits"]:
239
+ for commit in root_git_info["recentCommits"]:
240
+ lines.append(f"{commit['hash']} {commit['message']}")
241
+ else:
242
+ lines.append("(no commits)")
243
+ lines.append("")
101
244
 
102
245
 
103
246
  def _append_package_git_context(lines: list[str], package_git_info: list[dict]) -> None:
@@ -301,24 +444,7 @@ def get_context_json(repo_root: Path | None = None) -> dict:
301
444
  f"{DIR_WORKFLOW}/{DIR_WORKSPACE}/{developer}/{journal_file.name}"
302
445
  )
303
446
 
304
- # Git info
305
- _, branch_out, _ = run_git(["branch", "--show-current"], cwd=repo_root)
306
- branch = branch_out.strip() or "unknown"
307
-
308
- _, status_out, _ = run_git(["status", "--porcelain"], cwd=repo_root)
309
- git_status_count = len([line for line in status_out.splitlines() if line.strip()])
310
- is_clean = git_status_count == 0
311
-
312
- # Recent commits
313
- _, log_out, _ = run_git(["log", "--oneline", "-5"], cwd=repo_root)
314
- commits = []
315
- for line in log_out.splitlines():
316
- if line.strip():
317
- parts = line.split(" ", 1)
318
- if len(parts) >= 2:
319
- commits.append({"hash": parts[0], "message": parts[1]})
320
- elif len(parts) == 1:
321
- commits.append({"hash": parts[0], "message": ""})
447
+ root_git_info = _collect_root_git_info(repo_root)
322
448
 
323
449
  # Tasks
324
450
  tasks = [
@@ -333,15 +459,19 @@ def get_context_json(repo_root: Path | None = None) -> dict:
333
459
  ]
334
460
 
335
461
  # Package git repos (independent sub-repositories)
336
- pkg_git_info = _collect_package_git_info(repo_root)
462
+ pkg_git_info = _collect_package_git_info(
463
+ repo_root,
464
+ discover_unconfigured=not root_git_info["isRepo"],
465
+ )
337
466
 
338
467
  result = {
339
468
  "developer": developer or "",
340
469
  "git": {
341
- "branch": branch,
342
- "isClean": is_clean,
343
- "uncommittedChanges": git_status_count,
344
- "recentCommits": commits,
470
+ "isRepo": root_git_info["isRepo"],
471
+ "branch": root_git_info["branch"],
472
+ "isClean": root_git_info["isClean"],
473
+ "uncommittedChanges": root_git_info["uncommittedChanges"],
474
+ "recentCommits": root_git_info["recentCommits"],
345
475
  },
346
476
  "tasks": {
347
477
  "active": tasks,
@@ -405,39 +535,17 @@ def get_context_text(repo_root: Path | None = None) -> str:
405
535
  lines.append(f"Name: {developer}")
406
536
  lines.append("")
407
537
 
408
- # Git status
409
- lines.append("## GIT STATUS")
410
- _, branch_out, _ = run_git(["branch", "--show-current"], cwd=repo_root)
411
- branch = branch_out.strip() or "unknown"
412
- lines.append(f"Branch: {branch}")
413
-
414
- _, status_out, _ = run_git(["status", "--porcelain"], cwd=repo_root)
415
- status_lines = [line for line in status_out.splitlines() if line.strip()]
416
- status_count = len(status_lines)
417
-
418
- if status_count == 0:
419
- lines.append("Working directory: Clean")
420
- else:
421
- lines.append(f"Working directory: {status_count} uncommitted change(s)")
422
- lines.append("")
423
- lines.append("Changes:")
424
- _, short_out, _ = run_git(["status", "--short"], cwd=repo_root)
425
- for line in short_out.splitlines()[:10]:
426
- lines.append(line)
427
- lines.append("")
428
-
429
- # Recent commits
430
- lines.append("## RECENT COMMITS")
431
- _, log_out, _ = run_git(["log", "--oneline", "-5"], cwd=repo_root)
432
- if log_out.strip():
433
- for line in log_out.splitlines():
434
- lines.append(line)
435
- else:
436
- lines.append("(no commits)")
437
- lines.append("")
538
+ root_git_info = _collect_root_git_info(repo_root)
539
+ _append_root_git_context(lines, root_git_info)
438
540
 
439
541
  # Package git repos — independent sub-repositories
440
- _append_package_git_context(lines, _collect_package_git_info(repo_root))
542
+ _append_package_git_context(
543
+ lines,
544
+ _collect_package_git_info(
545
+ repo_root,
546
+ discover_unconfigured=not root_git_info["isRepo"],
547
+ ),
548
+ )
441
549
 
442
550
  # Current task
443
551
  lines.append("## CURRENT TASK")
@@ -557,20 +665,7 @@ def get_context_record_json(repo_root: Path | None = None) -> dict:
557
665
  developer = get_developer(repo_root)
558
666
  tasks_dir = get_tasks_dir(repo_root)
559
667
 
560
- # Git info
561
- _, branch_out, _ = run_git(["branch", "--show-current"], cwd=repo_root)
562
- branch = branch_out.strip() or "unknown"
563
-
564
- _, status_out, _ = run_git(["status", "--porcelain"], cwd=repo_root)
565
- git_status_count = len([line for line in status_out.splitlines() if line.strip()])
566
-
567
- _, log_out, _ = run_git(["log", "--oneline", "-5"], cwd=repo_root)
568
- commits = []
569
- for line in log_out.splitlines():
570
- if line.strip():
571
- parts = line.split(" ", 1)
572
- if len(parts) >= 2:
573
- commits.append({"hash": parts[0], "message": parts[1]})
668
+ root_git_info = _collect_root_git_info(repo_root)
574
669
 
575
670
  # My tasks (single pass — collect statuses and filter by assignee)
576
671
  all_tasks_list = list(iter_active_tasks(tasks_dir))
@@ -610,15 +705,19 @@ def get_context_record_json(repo_root: Path | None = None) -> dict:
610
705
  }
611
706
 
612
707
  # Package git repos
613
- pkg_git_info = _collect_package_git_info(repo_root)
708
+ pkg_git_info = _collect_package_git_info(
709
+ repo_root,
710
+ discover_unconfigured=not root_git_info["isRepo"],
711
+ )
614
712
 
615
713
  result = {
616
714
  "developer": developer or "",
617
715
  "git": {
618
- "branch": branch,
619
- "isClean": git_status_count == 0,
620
- "uncommittedChanges": git_status_count,
621
- "recentCommits": commits,
716
+ "isRepo": root_git_info["isRepo"],
717
+ "branch": root_git_info["branch"],
718
+ "isClean": root_git_info["isClean"],
719
+ "uncommittedChanges": root_git_info["uncommittedChanges"],
720
+ "recentCommits": root_git_info["recentCommits"],
622
721
  },
623
722
  "myTasks": my_tasks,
624
723
  "currentTask": current_task_info,
@@ -673,39 +772,17 @@ def get_context_text_record(repo_root: Path | None = None) -> str:
673
772
  lines.append("(no active tasks assigned to you)")
674
773
  lines.append("")
675
774
 
676
- # GIT STATUS
677
- lines.append("## GIT STATUS")
678
- _, branch_out, _ = run_git(["branch", "--show-current"], cwd=repo_root)
679
- branch = branch_out.strip() or "unknown"
680
- lines.append(f"Branch: {branch}")
681
-
682
- _, status_out, _ = run_git(["status", "--porcelain"], cwd=repo_root)
683
- status_lines = [line for line in status_out.splitlines() if line.strip()]
684
- status_count = len(status_lines)
685
-
686
- if status_count == 0:
687
- lines.append("Working directory: Clean")
688
- else:
689
- lines.append(f"Working directory: {status_count} uncommitted change(s)")
690
- lines.append("")
691
- lines.append("Changes:")
692
- _, short_out, _ = run_git(["status", "--short"], cwd=repo_root)
693
- for line in short_out.splitlines()[:10]:
694
- lines.append(line)
695
- lines.append("")
696
-
697
- # RECENT COMMITS
698
- lines.append("## RECENT COMMITS")
699
- _, log_out, _ = run_git(["log", "--oneline", "-5"], cwd=repo_root)
700
- if log_out.strip():
701
- for line in log_out.splitlines():
702
- lines.append(line)
703
- else:
704
- lines.append("(no commits)")
705
- lines.append("")
775
+ root_git_info = _collect_root_git_info(repo_root)
776
+ _append_root_git_context(lines, root_git_info)
706
777
 
707
778
  # Package git repos — independent sub-repositories
708
- _append_package_git_context(lines, _collect_package_git_info(repo_root))
779
+ _append_package_git_context(
780
+ lines,
781
+ _collect_package_git_info(
782
+ repo_root,
783
+ discover_unconfigured=not root_git_info["isRepo"],
784
+ ),
785
+ )
709
786
 
710
787
  # CURRENT TASK
711
788
  lines.append("## CURRENT TASK")
@@ -337,6 +337,9 @@ def cmd_archive(args: argparse.Namespace) -> int:
337
337
 
338
338
  # Update status before archiving
339
339
  today = datetime.now().strftime("%Y-%m-%d")
340
+ # Names of child task dirs whose task.json gets modified below; passed
341
+ # into safe_archive_paths_to_add so they're staged in this commit.
342
+ modified_children: list[str] = []
340
343
  if task_json_path.is_file():
341
344
  data = read_json(task_json_path)
342
345
  if data:
@@ -361,6 +364,7 @@ def cmd_archive(args: argparse.Namespace) -> int:
361
364
  if child_data:
362
365
  child_data["parent"] = None
363
366
  write_json(child_json, child_data)
367
+ modified_children.append(child_dir_path.name)
364
368
 
365
369
  # Clear any session that still points at this task before the path moves.
366
370
  from .active_task import clear_task_from_sessions
@@ -375,7 +379,7 @@ def cmd_archive(args: argparse.Namespace) -> int:
375
379
 
376
380
  # Auto-commit unless --no-commit
377
381
  if not getattr(args, "no_commit", False):
378
- _auto_commit_archive(dir_name, repo_root)
382
+ _auto_commit_archive(dir_name, repo_root, modified_children)
379
383
 
380
384
  # Return the archive path
381
385
  print(f"{DIR_WORKFLOW}/{DIR_TASKS}/{DIR_ARCHIVE}/{year_month}/{dir_name}")
@@ -388,18 +392,26 @@ def cmd_archive(args: argparse.Namespace) -> int:
388
392
  return 1
389
393
 
390
394
 
391
- def _auto_commit_archive(task_name: str, repo_root: Path) -> None:
395
+ def _auto_commit_archive(
396
+ task_name: str,
397
+ repo_root: Path,
398
+ modified_children: list[str] | None = None,
399
+ ) -> None:
392
400
  """Stage Trellis-owned task paths and commit after archive.
393
401
 
394
- Only stages specific subpaths (the archive subtree and active task dirs),
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``.
402
+ Scoped narrowly to the archived task's source + destination paths
403
+ plus any child task dirs whose ``task.json`` was edited (parent →
404
+ children relationship update). Dirty changes in OTHER active task
405
+ dirs are NOT bundled into the archive commit.
399
406
 
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).
407
+ If ``.gitignore`` blocks the paths, we warn + skip — we do NOT
408
+ retry with ``git add -f``. The warning explicitly forbids
409
+ ``git add -f .trellis/`` (which would fan out to caches/backups)
410
+ and points users at ``session_auto_commit: false``.
411
+
412
+ Honors ``session_auto_commit`` in ``.trellis/config.yaml``: when
413
+ set to ``false``, this function returns immediately without
414
+ touching git (the archive directory move on disk is unaffected).
403
415
  """
404
416
  if not get_session_auto_commit(repo_root):
405
417
  print(
@@ -408,7 +420,9 @@ def _auto_commit_archive(task_name: str, repo_root: Path) -> None:
408
420
  )
409
421
  return
410
422
 
411
- paths = safe_archive_paths_to_add(repo_root)
423
+ paths = safe_archive_paths_to_add(
424
+ repo_root, task_name=task_name, modified_children=modified_children
425
+ )
412
426
  if not paths:
413
427
  print("[OK] No task changes to commit.", file=sys.stderr)
414
428
  return
@@ -424,8 +438,24 @@ def _auto_commit_archive(task_name: str, repo_root: Path) -> None:
424
438
  )
425
439
  return
426
440
 
441
+ # Belt-and-suspenders for the phantom-delete bug: `safe_git_add` uses
442
+ # `git add` (no -A) which only stages additions/modifications. The
443
+ # source task directory was moved away by `shutil.move`, so its files
444
+ # need an explicit `git rm --cached` to stage the deletions in this
445
+ # same commit — otherwise they sit as uncommitted "phantom deletes"
446
+ # against HEAD until something later picks them up.
447
+ #
448
+ # `--ignore-unmatch` makes this a no-op when the task was never tracked
449
+ # (e.g. archiving a task that lived only in working tree).
450
+ source_rel = f"{DIR_WORKFLOW}/{DIR_TASKS}/{task_name}"
451
+ run_git(
452
+ ["rm", "-r", "--cached", "--ignore-unmatch", "--", source_rel],
453
+ cwd=repo_root,
454
+ )
455
+
427
456
  rc, _, _ = run_git(
428
- ["diff", "--cached", "--quiet", "--", *paths], cwd=repo_root
457
+ ["diff", "--cached", "--quiet", "--", *paths, source_rel],
458
+ cwd=repo_root,
429
459
  )
430
460
  if rc == 0:
431
461
  print("[OK] No task changes to commit.", file=sys.stderr)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mindfoldhq/trellis",
3
- "version": "0.5.12",
3
+ "version": "0.5.14",
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",