paths.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447
  1. #!/usr/bin/env python3
  2. """
  3. Common path utilities for Trellis workflow.
  4. Provides:
  5. get_repo_root - Get repository root directory
  6. get_developer - Get developer name
  7. get_workspace_dir - Get developer workspace directory
  8. get_tasks_dir - Get tasks directory
  9. get_active_journal_file - Get current journal file
  10. """
  11. from __future__ import annotations
  12. import re
  13. from datetime import datetime
  14. from pathlib import Path
  15. # =============================================================================
  16. # Path Constants (change here to rename directories)
  17. # =============================================================================
  18. # Directory names
  19. DIR_WORKFLOW = ".trellis"
  20. DIR_WORKSPACE = "workspace"
  21. DIR_TASKS = "tasks"
  22. DIR_ARCHIVE = "archive"
  23. DIR_SPEC = "spec"
  24. DIR_SCRIPTS = "scripts"
  25. # File names
  26. FILE_DEVELOPER = ".developer"
  27. FILE_CURRENT_TASK = ".current-task"
  28. FILE_TASK_JSON = "task.json"
  29. FILE_JOURNAL_PREFIX = "journal-"
  30. # =============================================================================
  31. # Repository Root
  32. # =============================================================================
  33. def get_repo_root(start_path: Path | None = None) -> Path:
  34. """Find the nearest directory containing .trellis/ folder.
  35. This handles nested git repos correctly (e.g., test project inside another repo).
  36. Args:
  37. start_path: Starting directory to search from. Defaults to current directory.
  38. Returns:
  39. Path to repository root, or current directory if no .trellis/ found.
  40. """
  41. current = (start_path or Path.cwd()).resolve()
  42. while current != current.parent:
  43. if (current / DIR_WORKFLOW).is_dir():
  44. return current
  45. current = current.parent
  46. # Fallback to current directory if no .trellis/ found
  47. return Path.cwd().resolve()
  48. # =============================================================================
  49. # Developer
  50. # =============================================================================
  51. def get_developer(repo_root: Path | None = None) -> str | None:
  52. """Get developer name from .developer file.
  53. Args:
  54. repo_root: Repository root path. Defaults to auto-detected.
  55. Returns:
  56. Developer name or None if not initialized.
  57. """
  58. if repo_root is None:
  59. repo_root = get_repo_root()
  60. dev_file = repo_root / DIR_WORKFLOW / FILE_DEVELOPER
  61. if not dev_file.is_file():
  62. return None
  63. try:
  64. content = dev_file.read_text(encoding="utf-8")
  65. for line in content.splitlines():
  66. if line.startswith("name="):
  67. return line.split("=", 1)[1].strip()
  68. except (OSError, IOError):
  69. pass
  70. return None
  71. def check_developer(repo_root: Path | None = None) -> bool:
  72. """Check if developer is initialized.
  73. Args:
  74. repo_root: Repository root path. Defaults to auto-detected.
  75. Returns:
  76. True if developer is initialized.
  77. """
  78. return get_developer(repo_root) is not None
  79. # =============================================================================
  80. # Tasks Directory
  81. # =============================================================================
  82. def get_tasks_dir(repo_root: Path | None = None) -> Path:
  83. """Get tasks directory path.
  84. Args:
  85. repo_root: Repository root path. Defaults to auto-detected.
  86. Returns:
  87. Path to tasks directory.
  88. """
  89. if repo_root is None:
  90. repo_root = get_repo_root()
  91. return repo_root / DIR_WORKFLOW / DIR_TASKS
  92. # =============================================================================
  93. # Workspace Directory
  94. # =============================================================================
  95. def get_workspace_dir(repo_root: Path | None = None) -> Path | None:
  96. """Get developer workspace directory.
  97. Args:
  98. repo_root: Repository root path. Defaults to auto-detected.
  99. Returns:
  100. Path to workspace directory or None if developer not set.
  101. """
  102. if repo_root is None:
  103. repo_root = get_repo_root()
  104. developer = get_developer(repo_root)
  105. if developer:
  106. return repo_root / DIR_WORKFLOW / DIR_WORKSPACE / developer
  107. return None
  108. # =============================================================================
  109. # Journal File
  110. # =============================================================================
  111. def get_active_journal_file(repo_root: Path | None = None) -> Path | None:
  112. """Get the current active journal file.
  113. Args:
  114. repo_root: Repository root path. Defaults to auto-detected.
  115. Returns:
  116. Path to active journal file or None if not found.
  117. """
  118. if repo_root is None:
  119. repo_root = get_repo_root()
  120. workspace_dir = get_workspace_dir(repo_root)
  121. if workspace_dir is None or not workspace_dir.is_dir():
  122. return None
  123. latest: Path | None = None
  124. highest = 0
  125. for f in workspace_dir.glob(f"{FILE_JOURNAL_PREFIX}*.md"):
  126. if not f.is_file():
  127. continue
  128. # Extract number from filename
  129. name = f.stem # e.g., "journal-1"
  130. match = re.search(r"(\d+)$", name)
  131. if match:
  132. num = int(match.group(1))
  133. if num > highest:
  134. highest = num
  135. latest = f
  136. return latest
  137. def count_lines(file_path: Path) -> int:
  138. """Count lines in a file.
  139. Args:
  140. file_path: Path to file.
  141. Returns:
  142. Number of lines, or 0 if file doesn't exist.
  143. """
  144. if not file_path.is_file():
  145. return 0
  146. try:
  147. return len(file_path.read_text(encoding="utf-8").splitlines())
  148. except (OSError, IOError):
  149. return 0
  150. # =============================================================================
  151. # Current Task Management
  152. # =============================================================================
  153. def normalize_task_ref(task_ref: str) -> str:
  154. """Normalize a task ref for stable runtime storage.
  155. Stored refs should prefer repo-relative POSIX paths like
  156. `.trellis/tasks/03-27-my-task`, even on Windows. Absolute paths are preserved
  157. unless they can later be converted back to repo-relative form by callers.
  158. """
  159. normalized = task_ref.strip()
  160. if not normalized:
  161. return ""
  162. path_obj = Path(normalized)
  163. if path_obj.is_absolute():
  164. return str(path_obj)
  165. normalized = normalized.replace("\\", "/")
  166. while normalized.startswith("./"):
  167. normalized = normalized[2:]
  168. if normalized.startswith(f"{DIR_TASKS}/"):
  169. return f"{DIR_WORKFLOW}/{normalized}"
  170. return normalized
  171. def resolve_task_ref(task_ref: str, repo_root: Path | None = None) -> Path | None:
  172. """Resolve a task ref to an absolute task directory path."""
  173. if repo_root is None:
  174. repo_root = get_repo_root()
  175. normalized = normalize_task_ref(task_ref)
  176. if not normalized:
  177. return None
  178. path_obj = Path(normalized)
  179. if path_obj.is_absolute():
  180. return path_obj
  181. if normalized.startswith(f"{DIR_WORKFLOW}/"):
  182. return repo_root / path_obj
  183. return repo_root / DIR_WORKFLOW / DIR_TASKS / path_obj
  184. def get_current_task(
  185. repo_root: Path | None = None,
  186. platform_input: dict | None = None,
  187. platform: str | None = None,
  188. ) -> str | None:
  189. """Get current task directory path (relative to repo_root).
  190. Args:
  191. repo_root: Repository root path. Defaults to auto-detected.
  192. Returns:
  193. Relative path to current task directory or None.
  194. """
  195. if repo_root is None:
  196. repo_root = get_repo_root()
  197. from .active_task import resolve_active_task
  198. return resolve_active_task(repo_root, platform_input, platform).task_path
  199. def get_current_task_abs(
  200. repo_root: Path | None = None,
  201. platform_input: dict | None = None,
  202. platform: str | None = None,
  203. ) -> Path | None:
  204. """Get current task directory absolute path.
  205. Args:
  206. repo_root: Repository root path. Defaults to auto-detected.
  207. Returns:
  208. Absolute path to current task directory or None.
  209. """
  210. if repo_root is None:
  211. repo_root = get_repo_root()
  212. relative = get_current_task(repo_root, platform_input, platform)
  213. if relative:
  214. return resolve_task_ref(relative, repo_root)
  215. return None
  216. def get_current_task_source(
  217. repo_root: Path | None = None,
  218. platform_input: dict | None = None,
  219. platform: str | None = None,
  220. ) -> tuple[str, str | None, str | None]:
  221. """Get active task source as (`source`, `context_key`, `task_path`)."""
  222. if repo_root is None:
  223. repo_root = get_repo_root()
  224. from .active_task import get_current_task_source as _get_source
  225. return _get_source(repo_root, platform_input, platform)
  226. def set_current_task(
  227. task_path: str,
  228. repo_root: Path | None = None,
  229. platform_input: dict | None = None,
  230. platform: str | None = None,
  231. ) -> bool:
  232. """Set current task in session scope.
  233. Args:
  234. task_path: Task directory path (relative to repo_root).
  235. repo_root: Repository root path. Defaults to auto-detected.
  236. Returns:
  237. True on success, False on error.
  238. """
  239. if repo_root is None:
  240. repo_root = get_repo_root()
  241. from .active_task import set_active_task
  242. return set_active_task(
  243. task_path,
  244. repo_root,
  245. platform_input=platform_input,
  246. platform=platform,
  247. ) is not None
  248. def clear_current_task(
  249. repo_root: Path | None = None,
  250. platform_input: dict | None = None,
  251. platform: str | None = None,
  252. ) -> bool:
  253. """Clear current task in session scope.
  254. Args:
  255. repo_root: Repository root path. Defaults to auto-detected.
  256. Returns:
  257. True on success.
  258. """
  259. if repo_root is None:
  260. repo_root = get_repo_root()
  261. from .active_task import clear_active_task
  262. clear_active_task(
  263. repo_root,
  264. platform_input=platform_input,
  265. platform=platform,
  266. )
  267. return True
  268. def has_current_task(repo_root: Path | None = None) -> bool:
  269. """Check if has current task.
  270. Args:
  271. repo_root: Repository root path. Defaults to auto-detected.
  272. Returns:
  273. True if current task is set.
  274. """
  275. return get_current_task(repo_root) is not None
  276. # =============================================================================
  277. # Task ID Generation
  278. # =============================================================================
  279. def generate_task_date_prefix() -> str:
  280. """Generate task ID based on date (MM-DD format).
  281. Returns:
  282. Date prefix string (e.g., "01-21").
  283. """
  284. return datetime.now().strftime("%m-%d")
  285. # =============================================================================
  286. # Monorepo / Package Paths
  287. # =============================================================================
  288. def get_spec_dir(package: str | None = None, repo_root: Path | None = None) -> Path:
  289. """Get the spec directory path.
  290. Single-repo: .trellis/spec
  291. Monorepo with package: .trellis/spec/<package>
  292. Uses lazy import to avoid circular dependency with config.py.
  293. """
  294. if repo_root is None:
  295. repo_root = get_repo_root()
  296. from .config import get_spec_base
  297. base = get_spec_base(package, repo_root)
  298. return repo_root / DIR_WORKFLOW / base
  299. def get_package_path(package: str, repo_root: Path | None = None) -> Path | None:
  300. """Get a package's source directory absolute path from config.
  301. Returns:
  302. Absolute path to the package directory, or None if not found.
  303. """
  304. if repo_root is None:
  305. repo_root = get_repo_root()
  306. from .config import get_packages
  307. packages = get_packages(repo_root)
  308. if not packages or package not in packages:
  309. return None
  310. info = packages[package]
  311. if isinstance(info, dict):
  312. rel_path = info.get("path", package)
  313. else:
  314. rel_path = str(info)
  315. return repo_root / rel_path
  316. # =============================================================================
  317. # Main Entry (for testing)
  318. # =============================================================================
  319. if __name__ == "__main__":
  320. repo = get_repo_root()
  321. print(f"Repository root: {repo}")
  322. print(f"Developer: {get_developer(repo)}")
  323. print(f"Tasks dir: {get_tasks_dir(repo)}")
  324. print(f"Workspace dir: {get_workspace_dir(repo)}")
  325. print(f"Journal file: {get_active_journal_file(repo)}")
  326. print(f"Current task: {get_current_task(repo)}")