#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ Add a new session to journal file and update index.md. Usage: python3 add_session.py --title "Title" --commit "hash" --summary "Summary" [--package cli] python3 add_session.py --title "Title" --branch "feat/my-branch" # Pipe detailed content via stdin (use --stdin to opt in): cat << 'EOF' | python3 add_session.py --stdin --title "Title" --summary "Summary" EOF # Structured content (repeatable; a section with no bullets is omitted): python3 add_session.py --title "Title" --change "Did X" --test "Ran Y" --next-step "Do Z" Branch resolution order: 1. --branch CLI arg (explicit) 2. task.json branch field (from active task, if still exists) 3. git branch --show-current (auto-detect) 4. None (omitted gracefully) """ from __future__ import annotations import argparse import re import sys from datetime import datetime from pathlib import Path from common.paths import ( DIR_TASKS, DIR_WORKFLOW, FILE_JOURNAL_PREFIX, get_repo_root, get_current_task, get_developer, get_workspace_dir, ) from common.developer import ensure_developer from common.git import run_git from common.log import Colors, colored from common.safe_commit import ( print_gitignore_warning, safe_git_add, safe_trellis_paths_to_add, ) from common.tasks import load_task from common.types import TaskInfo from common.config import ( get_packages, get_session_auto_commit, get_session_commit_message, get_max_journal_lines, is_monorepo, resolve_package, validate_package, ) # ============================================================================= # Helper Functions # ============================================================================= def get_latest_journal_info(dev_dir: Path) -> tuple[Path | None, int, int]: """Get latest journal file info. Returns: Tuple of (file_path, file_number, line_count). """ latest_file: Path | None = None latest_num = -1 for f in dev_dir.glob(f"{FILE_JOURNAL_PREFIX}*.md"): if not f.is_file(): continue match = re.search(r"(\d+)$", f.stem) if match: num = int(match.group(1)) if num > latest_num: latest_num = num latest_file = f if latest_file: lines = len(latest_file.read_text(encoding="utf-8").splitlines()) return latest_file, latest_num, lines return None, 0, 0 def get_current_session(index_file: Path) -> int: """Get current session number from index.md.""" if not index_file.is_file(): return 0 content = index_file.read_text(encoding="utf-8") for line in content.splitlines(): if "Total Sessions" in line: match = re.search(r":\s*(\d+)", line) if match: return int(match.group(1)) return 0 def _extract_journal_num(filename: str) -> int: """Extract journal number from filename for sorting.""" match = re.search(r"(\d+)", filename) return int(match.group(1)) if match else 0 def count_journal_files(dev_dir: Path, active_num: int) -> str: """Count journal files and return table rows.""" active_file = f"{FILE_JOURNAL_PREFIX}{active_num}.md" result_lines = [] files = sorted( [f for f in dev_dir.glob(f"{FILE_JOURNAL_PREFIX}*.md") if f.is_file()], key=lambda f: _extract_journal_num(f.stem), reverse=True ) for f in files: filename = f.name lines = len(f.read_text(encoding="utf-8").splitlines()) status = "Active" if filename == active_file else "Archived" result_lines.append(f"| `{filename}` | ~{lines} | {status} |") return "\n".join(result_lines) def get_current_git_branch(repo_root: Path) -> str | None: """Return the current checkout branch, or None for detached/non-git states.""" rc, branch_out, _ = run_git(["branch", "--show-current"], cwd=repo_root) if rc != 0: return None detected = branch_out.strip() return detected or None def branch_ref_exists(repo_root: Path, branch: str) -> bool: """Return True when branch exists locally or as the local origin ref.""" for ref in (f"refs/heads/{branch}", f"refs/remotes/origin/{branch}"): rc, _, _ = run_git(["show-ref", "--verify", "--quiet", ref], cwd=repo_root) if rc == 0: return True return False def resolve_session_branch( repo_root: Path, cli_branch: str | None, task_data: TaskInfo | None, ) -> str | None: """Resolve journal branch without trusting stale task.json branch fields.""" if cli_branch: return cli_branch current_branch = get_current_git_branch(repo_root) raw_task_branch = task_data.raw.get("branch") if task_data else None task_branch = raw_task_branch.strip() if isinstance(raw_task_branch, str) else "" if not task_branch: return current_branch if branch_ref_exists(repo_root, task_branch): return task_branch if current_branch: print( f"Warning: task.json branch '{task_branch}' no longer exists locally or as origin/{task_branch}; using current branch '{current_branch}'.", file=sys.stderr, ) return current_branch print( f"Warning: task.json branch '{task_branch}' no longer exists locally or as origin/{task_branch}; omitting branch.", file=sys.stderr, ) return None def is_git_worktree(repo_root: Path) -> bool: """Return True when repo_root is a linked worktree (not the main working tree). Standard test: `git rev-parse --git-dir` (per-worktree) differs from `git rev-parse --git-common-dir` (shared across all worktrees) once both are resolved to absolute paths. In the main working tree these are the same directory. """ rc_dir, git_dir, _ = run_git(["rev-parse", "--git-dir"], cwd=repo_root) rc_common, git_common_dir, _ = run_git( ["rev-parse", "--git-common-dir"], cwd=repo_root ) if rc_dir != 0 or rc_common != 0: return False git_dir_path = (repo_root / git_dir.strip()).resolve() git_common_dir_path = (repo_root / git_common_dir.strip()).resolve() return git_dir_path != git_common_dir_path def warn_if_parallel_worktree(repo_root: Path) -> None: """Non-blocking note: index.md conflicts across parallel worktrees/branches are expected and safe. Only fires when running in a linked git worktree (not the main tree) with `session_auto_commit` enabled (#415 quick-fix tier). """ if not get_session_auto_commit(repo_root): return if not is_git_worktree(repo_root): return print( colored( "[NOTE] Running in a git worktree with session_auto_commit enabled: " "journal-*.md files auto-merge via .gitattributes, but index.md " "conflicts across parallel worktrees/branches are expected and safe " "to resolve by picking either side (task state lives in task.json, " "not index.md). See .trellis/spec/cli/backend/directory-structure.md " '("Workspace Journal Merge Behavior").', Colors.YELLOW, ), file=sys.stderr, ) def create_new_journal_file( dev_dir: Path, num: int, developer: str, today: str, max_lines: int = 2000, ) -> Path: """Create a new journal file.""" prev_num = num - 1 new_file = dev_dir / f"{FILE_JOURNAL_PREFIX}{num}.md" content = f"""# Journal - {developer} (Part {num}) > Continuation from `{FILE_JOURNAL_PREFIX}{prev_num}.md` (archived at ~{max_lines} lines) > Started: {today} --- """ new_file.write_text(content, encoding="utf-8") return new_file def _render_bullet_section(header: str, items: list[str], bullet_prefix: str = "- ") -> str: """Render a Markdown section as bullets, or "" when there is no content. A section with zero provided values is omitted entirely from the rendered entry rather than falling back to a placeholder string. """ if not items: return "" bullets = "\n".join(f"{bullet_prefix}{item}" for item in items) return f"\n\n### {header}\n\n{bullets}" def _render_main_changes(changes: list[str], extra_content: str | None) -> str: """Render the Main Changes section from --change bullets or freeform content.""" if changes: return _render_bullet_section("Main Changes", changes) if extra_content: return f"\n\n### Main Changes\n\n{extra_content}" return "" def generate_session_content( session_num: int, title: str, commit: str, summary: str, today: str, package: str | None = None, branch: str | None = None, changes: list[str] | None = None, extra_content: str | None = None, tests: list[str] | None = None, next_steps: list[str] | None = None, ) -> str: """Generate session content.""" if commit and commit != "-": commit_table = """| Hash | Message | |------|---------|""" for c in commit.split(","): c = c.strip() commit_table += f"\n| `{c}` | (see git log) |" else: commit_table = "(No commits - planning session)" package_line = f"\n**Package**: {package}" if package else "" branch_line = f"\n**Branch**: `{branch}`" if branch else "" main_changes_section = _render_main_changes(changes or [], extra_content) testing_section = _render_bullet_section("Testing", tests or [], bullet_prefix="- [OK] ") next_steps_section = _render_bullet_section("Next Steps", next_steps or []) return f""" ## Session {session_num}: {title} **Date**: {today} **Task**: {title}{package_line}{branch_line} ### Summary {summary}{main_changes_section} ### Git Commits {commit_table}{testing_section} ### Status [OK] **Completed**{next_steps_section} """ def update_index( index_file: Path, dev_dir: Path, title: str, commit: str, new_session: int, active_file: str, today: str, branch: str | None = None, ) -> bool: """Update index.md with new session info.""" # Format commit for display commit_display = "-" if commit and commit != "-": commit_display = re.sub(r"([a-f0-9]{7,})", r"`\1`", commit.replace(",", ", ")) # Get file number from active_file name match = re.search(r"(\d+)", active_file) active_num = int(match.group(1)) if match else 0 files_table = count_journal_files(dev_dir, active_num) print(f"Updating index.md for session {new_session}...") print(f" Title: {title}") print(f" Commit: {commit_display}") print(f" Active File: {active_file}") print() content = index_file.read_text(encoding="utf-8") if "@@@auto:current-status" not in content: print("Error: Markers not found in index.md. Please ensure markers exist.", file=sys.stderr) return False # Process sections lines = content.splitlines() new_lines = [] in_current_status = False in_active_documents = False in_session_history = False header_written = False for line in lines: if "@@@auto:current-status" in line: new_lines.append(line) in_current_status = True new_lines.append(f"- **Active File**: `{active_file}`") new_lines.append(f"- **Total Sessions**: {new_session}") new_lines.append(f"- **Last Active**: {today}") continue if "@@@/auto:current-status" in line: in_current_status = False new_lines.append(line) continue if "@@@auto:active-documents" in line: new_lines.append(line) in_active_documents = True new_lines.append("| File | Lines | Status |") new_lines.append("|------|-------|--------|") new_lines.append(files_table) continue if "@@@/auto:active-documents" in line: in_active_documents = False new_lines.append(line) continue if "@@@auto:session-history" in line: new_lines.append(line) in_session_history = True header_written = False continue if "@@@/auto:session-history" in line: in_session_history = False new_lines.append(line) continue if in_current_status: continue if in_active_documents: continue if in_session_history: # Migrate old 4/6-column headers to 5-column Branch-only history. if re.match( r"^\|\s*#\s*\|\s*Date\s*\|\s*Title\s*\|\s*Commits\s*\|\s*Branch\s*\|\s*Base Branch\s*\|\s*$", line, ): new_lines.append("| # | Date | Title | Commits | Branch |") continue if re.match(r"^\|\s*#\s*\|\s*Date\s*\|\s*Title\s*\|\s*Commits\s*\|\s*Branch\s*\|\s*$", line): new_lines.append("| # | Date | Title | Commits | Branch |") continue if re.match(r"^\|\s*#\s*\|\s*Date\s*\|\s*Title\s*\|\s*Commits\s*\|\s*$", line): new_lines.append("| # | Date | Title | Commits | Branch |") continue if re.match(r"^\|[-| ]+\|\s*$", line) and not header_written: new_lines.append("|---|------|-------|---------|--------|") new_lines.append(f"| {new_session} | {today} | {title} | {commit_display} | `{branch or '-'}` |") header_written = True continue new_lines.append(line) continue new_lines.append(line) index_file.write_text("\n".join(new_lines), encoding="utf-8") print("[OK] Updated index.md successfully!") return True # ============================================================================= # Main Function # ============================================================================= def _auto_commit_workspace(repo_root: Path) -> None: """Stage Trellis-owned workspace + current-task paths and commit. Path scope is restricted to specific products: the current developer's journal files + index.md, and ONLY the current task directory (resolved via ``get_current_task``). We never `git add` the whole `.trellis/` tree or iterate over all active task dirs (#303: parallel-window dirty task dirs must not be bundled into the session auto-commit). If `.gitignore` blocks the specific paths we warn + skip — never retry with ``-f``. Honors ``session_auto_commit`` in ``.trellis/config.yaml``: when set to ``false``, this function returns immediately without touching git (journal/index files are still written to disk by the caller). """ if not get_session_auto_commit(repo_root): print( "[OK] session_auto_commit: false — skipping git stage/commit.", file=sys.stderr, ) return commit_msg = get_session_commit_message(repo_root) # Resolve the current task so staging is scoped to its dir only. The ref # is ``.trellis/tasks/`` (or under archive/) — pass the bare name. current = get_current_task(repo_root) if current: task_name = Path(current).name paths = safe_trellis_paths_to_add(repo_root, task_name=task_name) else: # Current task unknown (0 or >=2 parallel sessions — exactly the # parallel-window case #303 is about). Do NOT fall back to the wide # `tasks_dir.iterdir()` scan; that would re-leak other tasks' dirty # dirs into the session commit. Stage only the developer's journal/ # index and skip every task dir. paths = [ p for p in safe_trellis_paths_to_add(repo_root, task_name=None) if not p.startswith(f"{DIR_WORKFLOW}/{DIR_TASKS}/") ] if not paths: print("[OK] No workspace changes to commit.", file=sys.stderr) return success, _, err = safe_git_add(paths, repo_root) if not success: if err and "ignored by" in err.lower(): print_gitignore_warning(paths) else: print( f"[WARN] git add failed: {err.strip() if err else 'unknown error'}", file=sys.stderr, ) return # Check if there are staged changes for the paths we just staged. rc, _, _ = run_git( ["diff", "--cached", "--quiet", "--", *paths], cwd=repo_root ) if rc == 0: print("[OK] No workspace changes to commit.", file=sys.stderr) return rc, _, commit_err = run_git(["commit", "-m", commit_msg], cwd=repo_root) if rc == 0: print(f"[OK] Auto-committed: {commit_msg}", file=sys.stderr) else: print( f"[WARN] Auto-commit failed: {commit_err.strip()}", file=sys.stderr, ) def add_session( title: str, commit: str = "-", summary: str = "Session summary was not supplied.", changes: list[str] | None = None, extra_content: str | None = None, tests: list[str] | None = None, next_steps: list[str] | None = None, auto_commit: bool = True, package: str | None = None, branch: str | None = None, ) -> int: """Add a new session.""" repo_root = get_repo_root() warn_if_parallel_worktree(repo_root) ensure_developer(repo_root) developer = get_developer(repo_root) if not developer: print("Error: Developer not initialized", file=sys.stderr) return 1 dev_dir = get_workspace_dir(repo_root) if not dev_dir: print("Error: Workspace directory not found", file=sys.stderr) return 1 max_lines = get_max_journal_lines(repo_root) index_file = dev_dir / "index.md" today = datetime.now().strftime("%Y-%m-%d") journal_file, current_num, current_lines = get_latest_journal_info(dev_dir) current_session = get_current_session(index_file) new_session = current_session + 1 session_content = generate_session_content( new_session, title, commit, summary, today, package, branch, changes=changes, extra_content=extra_content, tests=tests, next_steps=next_steps, ) content_lines = len(session_content.splitlines()) print("========================================", file=sys.stderr) print("ADD SESSION", file=sys.stderr) print("========================================", file=sys.stderr) print("", file=sys.stderr) print(f"Session: {new_session}", file=sys.stderr) print(f"Title: {title}", file=sys.stderr) print(f"Commit: {commit}", file=sys.stderr) print("", file=sys.stderr) print(f"Current journal file: {FILE_JOURNAL_PREFIX}{current_num}.md", file=sys.stderr) print(f"Current lines: {current_lines}", file=sys.stderr) print(f"New content lines: {content_lines}", file=sys.stderr) print(f"Total after append: {current_lines + content_lines}", file=sys.stderr) print("", file=sys.stderr) target_file = journal_file target_num = current_num if current_lines + content_lines > max_lines: target_num = current_num + 1 print(f"[!] Exceeds {max_lines} lines, creating {FILE_JOURNAL_PREFIX}{target_num}.md", file=sys.stderr) target_file = create_new_journal_file(dev_dir, target_num, developer, today, max_lines) print(f"Created: {target_file}", file=sys.stderr) # Append session content if target_file: with target_file.open("a", encoding="utf-8") as f: f.write(session_content) print(f"[OK] Appended session to {target_file.name}", file=sys.stderr) print("", file=sys.stderr) # Update index.md active_file = f"{FILE_JOURNAL_PREFIX}{target_num}.md" if not update_index( index_file, dev_dir, title, commit, new_session, active_file, today, branch, ): return 1 print("", file=sys.stderr) print("========================================", file=sys.stderr) print(f"[OK] Session {new_session} added successfully!", file=sys.stderr) print("========================================", file=sys.stderr) print("", file=sys.stderr) print("Files updated:", file=sys.stderr) print(f" - {target_file.name if target_file else 'journal'}", file=sys.stderr) print(" - index.md", file=sys.stderr) # Auto-commit workspace changes if auto_commit: print("", file=sys.stderr) _auto_commit_workspace(repo_root) return 0 # ============================================================================= # Main Entry # ============================================================================= def main() -> int: """CLI entry point.""" parser = argparse.ArgumentParser( description="Add a new session to journal file and update index.md" ) parser.add_argument("--title", required=True, help="Session title") parser.add_argument("--commit", default="-", help="Comma-separated commit hashes") parser.add_argument("--summary", default="Session summary was not supplied.", help="Brief summary") parser.add_argument("--content-file", help="Path to file with detailed content") parser.add_argument("--package", help="Package name tag (e.g., cli, docs-site)") parser.add_argument("--branch", help="Branch name (auto-detected if omitted)") parser.add_argument("--change", action="append", help="Main Changes bullet (repeatable)") parser.add_argument("--test", action="append", help="Testing bullet (repeatable)") parser.add_argument("--next-step", action="append", help="Next Steps bullet (repeatable)") parser.add_argument("--no-commit", action="store_true", help="Skip auto-commit of workspace changes") parser.add_argument("--stdin", action="store_true", help="Read extra content from stdin (explicit opt-in)") args = parser.parse_args() extra_content: str | None = None if args.content_file: content_path = Path(args.content_file) if content_path.is_file(): extra_content = content_path.read_text(encoding="utf-8") elif args.stdin: extra_content = sys.stdin.read() # Load active task once — shared by package and branch resolution repo_root = get_repo_root() current = get_current_task(repo_root) task_data = load_task(repo_root / current) if current else None package = args.package if package: # CLI source: fail-fast in monorepo, ignore in single-repo if not is_monorepo(repo_root): print("Warning: --package ignored in single-repo project", file=sys.stderr) package = None elif not validate_package(package, repo_root): packages = get_packages(repo_root) available = ", ".join(sorted(packages.keys())) if packages else "(none)" print(f"Error: unknown package '{package}'. Available: {available}", file=sys.stderr) return 1 else: # Inferred: active task's task.json.package → default_package → None task_package = task_data.package if task_data else None package = resolve_package(task_package, repo_root) branch = resolve_session_branch(repo_root, args.branch, task_data) return add_session( args.title, args.commit, args.summary, changes=args.change, extra_content=extra_content, tests=args.test, next_steps=args.next_step, auto_commit=not args.no_commit, package=package, branch=branch, ) if __name__ == "__main__": sys.exit(main())