add_session.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. """
  4. Add a new session to journal file and update index.md.
  5. Usage:
  6. python add_session.py --title "Title" --commit "hash" --summary "Summary" [--package cli]
  7. python add_session.py --title "Title" --branch "feat/my-branch"
  8. # Pipe detailed content via stdin (use --stdin to opt in):
  9. cat << 'EOF' | python add_session.py --stdin --title "Title" --summary "Summary"
  10. <session content here>
  11. EOF
  12. Branch resolution order:
  13. 1. --branch CLI arg (explicit)
  14. 2. task.json branch field (from active task)
  15. 3. git branch --show-current (auto-detect)
  16. 4. None (omitted gracefully)
  17. """
  18. from __future__ import annotations
  19. import argparse
  20. import re
  21. import sys
  22. from datetime import datetime
  23. from pathlib import Path
  24. from common.paths import (
  25. DIR_TASKS,
  26. DIR_WORKFLOW,
  27. FILE_JOURNAL_PREFIX,
  28. get_repo_root,
  29. get_current_task,
  30. get_developer,
  31. get_workspace_dir,
  32. )
  33. from common.developer import ensure_developer
  34. from common.git import run_git
  35. from common.safe_commit import (
  36. print_gitignore_warning,
  37. safe_git_add,
  38. safe_trellis_paths_to_add,
  39. )
  40. from common.tasks import load_task
  41. from common.config import (
  42. get_packages,
  43. get_session_auto_commit,
  44. get_session_commit_message,
  45. get_max_journal_lines,
  46. is_monorepo,
  47. resolve_package,
  48. validate_package,
  49. )
  50. # =============================================================================
  51. # Helper Functions
  52. # =============================================================================
  53. def get_latest_journal_info(dev_dir: Path) -> tuple[Path | None, int, int]:
  54. """Get latest journal file info.
  55. Returns:
  56. Tuple of (file_path, file_number, line_count).
  57. """
  58. latest_file: Path | None = None
  59. latest_num = -1
  60. for f in dev_dir.glob(f"{FILE_JOURNAL_PREFIX}*.md"):
  61. if not f.is_file():
  62. continue
  63. match = re.search(r"(\d+)$", f.stem)
  64. if match:
  65. num = int(match.group(1))
  66. if num > latest_num:
  67. latest_num = num
  68. latest_file = f
  69. if latest_file:
  70. lines = len(latest_file.read_text(encoding="utf-8").splitlines())
  71. return latest_file, latest_num, lines
  72. return None, 0, 0
  73. def get_current_session(index_file: Path) -> int:
  74. """Get current session number from index.md."""
  75. if not index_file.is_file():
  76. return 0
  77. content = index_file.read_text(encoding="utf-8")
  78. for line in content.splitlines():
  79. if "Total Sessions" in line:
  80. match = re.search(r":\s*(\d+)", line)
  81. if match:
  82. return int(match.group(1))
  83. return 0
  84. def _extract_journal_num(filename: str) -> int:
  85. """Extract journal number from filename for sorting."""
  86. match = re.search(r"(\d+)", filename)
  87. return int(match.group(1)) if match else 0
  88. def count_journal_files(dev_dir: Path, active_num: int) -> str:
  89. """Count journal files and return table rows."""
  90. active_file = f"{FILE_JOURNAL_PREFIX}{active_num}.md"
  91. result_lines = []
  92. files = sorted(
  93. [f for f in dev_dir.glob(f"{FILE_JOURNAL_PREFIX}*.md") if f.is_file()],
  94. key=lambda f: _extract_journal_num(f.stem),
  95. reverse=True
  96. )
  97. for f in files:
  98. filename = f.name
  99. lines = len(f.read_text(encoding="utf-8").splitlines())
  100. status = "Active" if filename == active_file else "Archived"
  101. result_lines.append(f"| `{filename}` | ~{lines} | {status} |")
  102. return "\n".join(result_lines)
  103. def create_new_journal_file(
  104. dev_dir: Path, num: int, developer: str, today: str, max_lines: int = 2000,
  105. ) -> Path:
  106. """Create a new journal file."""
  107. prev_num = num - 1
  108. new_file = dev_dir / f"{FILE_JOURNAL_PREFIX}{num}.md"
  109. content = f"""# Journal - {developer} (Part {num})
  110. > Continuation from `{FILE_JOURNAL_PREFIX}{prev_num}.md` (archived at ~{max_lines} lines)
  111. > Started: {today}
  112. ---
  113. """
  114. new_file.write_text(content, encoding="utf-8")
  115. return new_file
  116. def generate_session_content(
  117. session_num: int,
  118. title: str,
  119. commit: str,
  120. summary: str,
  121. extra_content: str,
  122. today: str,
  123. package: str | None = None,
  124. branch: str | None = None,
  125. ) -> str:
  126. """Generate session content."""
  127. if commit and commit != "-":
  128. commit_table = """| Hash | Message |
  129. |------|---------|"""
  130. for c in commit.split(","):
  131. c = c.strip()
  132. commit_table += f"\n| `{c}` | (see git log) |"
  133. else:
  134. commit_table = "(No commits - planning session)"
  135. package_line = f"\n**Package**: {package}" if package else ""
  136. branch_line = f"\n**Branch**: `{branch}`" if branch else ""
  137. return f"""
  138. ## Session {session_num}: {title}
  139. **Date**: {today}
  140. **Task**: {title}{package_line}{branch_line}
  141. ### Summary
  142. {summary}
  143. ### Main Changes
  144. {extra_content}
  145. ### Git Commits
  146. {commit_table}
  147. ### Testing
  148. - [OK] (Add test results)
  149. ### Status
  150. [OK] **Completed**
  151. ### Next Steps
  152. - None - task complete
  153. """
  154. def update_index(
  155. index_file: Path,
  156. dev_dir: Path,
  157. title: str,
  158. commit: str,
  159. new_session: int,
  160. active_file: str,
  161. today: str,
  162. branch: str | None = None,
  163. ) -> bool:
  164. """Update index.md with new session info."""
  165. # Format commit for display
  166. commit_display = "-"
  167. if commit and commit != "-":
  168. commit_display = re.sub(r"([a-f0-9]{7,})", r"`\1`", commit.replace(",", ", "))
  169. # Get file number from active_file name
  170. match = re.search(r"(\d+)", active_file)
  171. active_num = int(match.group(1)) if match else 0
  172. files_table = count_journal_files(dev_dir, active_num)
  173. print(f"Updating index.md for session {new_session}...")
  174. print(f" Title: {title}")
  175. print(f" Commit: {commit_display}")
  176. print(f" Active File: {active_file}")
  177. print()
  178. content = index_file.read_text(encoding="utf-8")
  179. if "@@@auto:current-status" not in content:
  180. print("Error: Markers not found in index.md. Please ensure markers exist.", file=sys.stderr)
  181. return False
  182. # Process sections
  183. lines = content.splitlines()
  184. new_lines = []
  185. in_current_status = False
  186. in_active_documents = False
  187. in_session_history = False
  188. header_written = False
  189. for line in lines:
  190. if "@@@auto:current-status" in line:
  191. new_lines.append(line)
  192. in_current_status = True
  193. new_lines.append(f"- **Active File**: `{active_file}`")
  194. new_lines.append(f"- **Total Sessions**: {new_session}")
  195. new_lines.append(f"- **Last Active**: {today}")
  196. continue
  197. if "@@@/auto:current-status" in line:
  198. in_current_status = False
  199. new_lines.append(line)
  200. continue
  201. if "@@@auto:active-documents" in line:
  202. new_lines.append(line)
  203. in_active_documents = True
  204. new_lines.append("| File | Lines | Status |")
  205. new_lines.append("|------|-------|--------|")
  206. new_lines.append(files_table)
  207. continue
  208. if "@@@/auto:active-documents" in line:
  209. in_active_documents = False
  210. new_lines.append(line)
  211. continue
  212. if "@@@auto:session-history" in line:
  213. new_lines.append(line)
  214. in_session_history = True
  215. header_written = False
  216. continue
  217. if "@@@/auto:session-history" in line:
  218. in_session_history = False
  219. new_lines.append(line)
  220. continue
  221. if in_current_status:
  222. continue
  223. if in_active_documents:
  224. continue
  225. if in_session_history:
  226. # Migrate old 4/6-column headers to 5-column Branch-only history.
  227. if re.match(
  228. r"^\|\s*#\s*\|\s*Date\s*\|\s*Title\s*\|\s*Commits\s*\|\s*Branch\s*\|\s*Base Branch\s*\|\s*$",
  229. line,
  230. ):
  231. new_lines.append("| # | Date | Title | Commits | Branch |")
  232. continue
  233. if re.match(r"^\|\s*#\s*\|\s*Date\s*\|\s*Title\s*\|\s*Commits\s*\|\s*Branch\s*\|\s*$", line):
  234. new_lines.append("| # | Date | Title | Commits | Branch |")
  235. continue
  236. if re.match(r"^\|\s*#\s*\|\s*Date\s*\|\s*Title\s*\|\s*Commits\s*\|\s*$", line):
  237. new_lines.append("| # | Date | Title | Commits | Branch |")
  238. continue
  239. if re.match(r"^\|[-| ]+\|\s*$", line) and not header_written:
  240. new_lines.append("|---|------|-------|---------|--------|")
  241. new_lines.append(f"| {new_session} | {today} | {title} | {commit_display} | `{branch or '-'}` |")
  242. header_written = True
  243. continue
  244. new_lines.append(line)
  245. continue
  246. new_lines.append(line)
  247. index_file.write_text("\n".join(new_lines), encoding="utf-8")
  248. print("[OK] Updated index.md successfully!")
  249. return True
  250. # =============================================================================
  251. # Main Function
  252. # =============================================================================
  253. def _auto_commit_workspace(repo_root: Path) -> None:
  254. """Stage Trellis-owned workspace + current-task paths and commit.
  255. Path scope is restricted to specific products: the current developer's
  256. journal files + index.md, and ONLY the current task directory (resolved
  257. via ``get_current_task``). We never `git add` the whole `.trellis/` tree
  258. or iterate over all active task dirs (#303: parallel-window dirty task
  259. dirs must not be bundled into the session auto-commit). If `.gitignore`
  260. blocks the specific paths we warn + skip — never retry with ``-f``.
  261. Honors ``session_auto_commit`` in ``.trellis/config.yaml``: when set to
  262. ``false``, this function returns immediately without touching git
  263. (journal/index files are still written to disk by the caller).
  264. """
  265. if not get_session_auto_commit(repo_root):
  266. print(
  267. "[OK] session_auto_commit: false — skipping git stage/commit.",
  268. file=sys.stderr,
  269. )
  270. return
  271. commit_msg = get_session_commit_message(repo_root)
  272. # Resolve the current task so staging is scoped to its dir only. The ref
  273. # is ``.trellis/tasks/<name>`` (or under archive/) — pass the bare name.
  274. current = get_current_task(repo_root)
  275. if current:
  276. task_name = Path(current).name
  277. paths = safe_trellis_paths_to_add(repo_root, task_name=task_name)
  278. else:
  279. # Current task unknown (0 or >=2 parallel sessions — exactly the
  280. # parallel-window case #303 is about). Do NOT fall back to the wide
  281. # `tasks_dir.iterdir()` scan; that would re-leak other tasks' dirty
  282. # dirs into the session commit. Stage only the developer's journal/
  283. # index and skip every task dir.
  284. paths = [
  285. p
  286. for p in safe_trellis_paths_to_add(repo_root, task_name=None)
  287. if not p.startswith(f"{DIR_WORKFLOW}/{DIR_TASKS}/")
  288. ]
  289. if not paths:
  290. print("[OK] No workspace changes to commit.", file=sys.stderr)
  291. return
  292. success, _, err = safe_git_add(paths, repo_root)
  293. if not success:
  294. if err and "ignored by" in err.lower():
  295. print_gitignore_warning(paths)
  296. else:
  297. print(
  298. f"[WARN] git add failed: {err.strip() if err else 'unknown error'}",
  299. file=sys.stderr,
  300. )
  301. return
  302. # Check if there are staged changes for the paths we just staged.
  303. rc, _, _ = run_git(
  304. ["diff", "--cached", "--quiet", "--", *paths], cwd=repo_root
  305. )
  306. if rc == 0:
  307. print("[OK] No workspace changes to commit.", file=sys.stderr)
  308. return
  309. rc, _, commit_err = run_git(["commit", "-m", commit_msg], cwd=repo_root)
  310. if rc == 0:
  311. print(f"[OK] Auto-committed: {commit_msg}", file=sys.stderr)
  312. else:
  313. print(
  314. f"[WARN] Auto-commit failed: {commit_err.strip()}",
  315. file=sys.stderr,
  316. )
  317. def add_session(
  318. title: str,
  319. commit: str = "-",
  320. summary: str = "(Add summary)",
  321. extra_content: str = "(Add details)",
  322. auto_commit: bool = True,
  323. package: str | None = None,
  324. branch: str | None = None,
  325. ) -> int:
  326. """Add a new session."""
  327. repo_root = get_repo_root()
  328. ensure_developer(repo_root)
  329. developer = get_developer(repo_root)
  330. if not developer:
  331. print("Error: Developer not initialized", file=sys.stderr)
  332. return 1
  333. dev_dir = get_workspace_dir(repo_root)
  334. if not dev_dir:
  335. print("Error: Workspace directory not found", file=sys.stderr)
  336. return 1
  337. max_lines = get_max_journal_lines(repo_root)
  338. index_file = dev_dir / "index.md"
  339. today = datetime.now().strftime("%Y-%m-%d")
  340. journal_file, current_num, current_lines = get_latest_journal_info(dev_dir)
  341. current_session = get_current_session(index_file)
  342. new_session = current_session + 1
  343. session_content = generate_session_content(
  344. new_session, title, commit, summary, extra_content, today, package,
  345. branch,
  346. )
  347. content_lines = len(session_content.splitlines())
  348. print("========================================", file=sys.stderr)
  349. print("ADD SESSION", file=sys.stderr)
  350. print("========================================", file=sys.stderr)
  351. print("", file=sys.stderr)
  352. print(f"Session: {new_session}", file=sys.stderr)
  353. print(f"Title: {title}", file=sys.stderr)
  354. print(f"Commit: {commit}", file=sys.stderr)
  355. print("", file=sys.stderr)
  356. print(f"Current journal file: {FILE_JOURNAL_PREFIX}{current_num}.md", file=sys.stderr)
  357. print(f"Current lines: {current_lines}", file=sys.stderr)
  358. print(f"New content lines: {content_lines}", file=sys.stderr)
  359. print(f"Total after append: {current_lines + content_lines}", file=sys.stderr)
  360. print("", file=sys.stderr)
  361. target_file = journal_file
  362. target_num = current_num
  363. if current_lines + content_lines > max_lines:
  364. target_num = current_num + 1
  365. print(f"[!] Exceeds {max_lines} lines, creating {FILE_JOURNAL_PREFIX}{target_num}.md", file=sys.stderr)
  366. target_file = create_new_journal_file(dev_dir, target_num, developer, today, max_lines)
  367. print(f"Created: {target_file}", file=sys.stderr)
  368. # Append session content
  369. if target_file:
  370. with target_file.open("a", encoding="utf-8") as f:
  371. f.write(session_content)
  372. print(f"[OK] Appended session to {target_file.name}", file=sys.stderr)
  373. print("", file=sys.stderr)
  374. # Update index.md
  375. active_file = f"{FILE_JOURNAL_PREFIX}{target_num}.md"
  376. if not update_index(
  377. index_file,
  378. dev_dir,
  379. title,
  380. commit,
  381. new_session,
  382. active_file,
  383. today,
  384. branch,
  385. ):
  386. return 1
  387. print("", file=sys.stderr)
  388. print("========================================", file=sys.stderr)
  389. print(f"[OK] Session {new_session} added successfully!", file=sys.stderr)
  390. print("========================================", file=sys.stderr)
  391. print("", file=sys.stderr)
  392. print("Files updated:", file=sys.stderr)
  393. print(f" - {target_file.name if target_file else 'journal'}", file=sys.stderr)
  394. print(" - index.md", file=sys.stderr)
  395. # Auto-commit workspace changes
  396. if auto_commit:
  397. print("", file=sys.stderr)
  398. _auto_commit_workspace(repo_root)
  399. return 0
  400. # =============================================================================
  401. # Main Entry
  402. # =============================================================================
  403. def main() -> int:
  404. """CLI entry point."""
  405. parser = argparse.ArgumentParser(
  406. description="Add a new session to journal file and update index.md"
  407. )
  408. parser.add_argument("--title", required=True, help="Session title")
  409. parser.add_argument("--commit", default="-", help="Comma-separated commit hashes")
  410. parser.add_argument("--summary", default="(Add summary)", help="Brief summary")
  411. parser.add_argument("--content-file", help="Path to file with detailed content")
  412. parser.add_argument("--package", help="Package name tag (e.g., cli, docs-site)")
  413. parser.add_argument("--branch", help="Branch name (auto-detected if omitted)")
  414. parser.add_argument("--no-commit", action="store_true",
  415. help="Skip auto-commit of workspace changes")
  416. parser.add_argument("--stdin", action="store_true",
  417. help="Read extra content from stdin (explicit opt-in)")
  418. args = parser.parse_args()
  419. extra_content = "(Add details)"
  420. if args.content_file:
  421. content_path = Path(args.content_file)
  422. if content_path.is_file():
  423. extra_content = content_path.read_text(encoding="utf-8")
  424. elif args.stdin:
  425. extra_content = sys.stdin.read()
  426. # Load active task once — shared by package and branch resolution
  427. repo_root = get_repo_root()
  428. current = get_current_task(repo_root)
  429. task_data = load_task(repo_root / current) if current else None
  430. package = args.package
  431. if package:
  432. # CLI source: fail-fast in monorepo, ignore in single-repo
  433. if not is_monorepo(repo_root):
  434. print("Warning: --package ignored in single-repo project", file=sys.stderr)
  435. package = None
  436. elif not validate_package(package, repo_root):
  437. packages = get_packages(repo_root)
  438. available = ", ".join(sorted(packages.keys())) if packages else "(none)"
  439. print(f"Error: unknown package '{package}'. Available: {available}", file=sys.stderr)
  440. return 1
  441. else:
  442. # Inferred: active task's task.json.package → default_package → None
  443. task_package = task_data.package if task_data else None
  444. package = resolve_package(task_package, repo_root)
  445. # Resolve branch: CLI → task.json → git auto-detect → None
  446. branch = args.branch
  447. if not branch:
  448. if task_data and task_data.raw.get("branch"):
  449. branch = task_data.raw["branch"]
  450. else:
  451. _, branch_out, _ = run_git(["branch", "--show-current"], cwd=repo_root)
  452. detected = branch_out.strip()
  453. if detected:
  454. branch = detected
  455. return add_session(
  456. args.title, args.commit, args.summary, extra_content,
  457. auto_commit=not args.no_commit,
  458. package=package,
  459. branch=branch,
  460. )
  461. if __name__ == "__main__":
  462. sys.exit(main())