add_session.py 17 KB

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