elspais 0.11.1__py3-none-any.whl → 0.43.5__py3-none-any.whl

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.
Files changed (148) hide show
  1. elspais/__init__.py +2 -11
  2. elspais/{sponsors/__init__.py → associates.py} +102 -58
  3. elspais/cli.py +395 -79
  4. elspais/commands/__init__.py +9 -3
  5. elspais/commands/analyze.py +121 -173
  6. elspais/commands/changed.py +15 -30
  7. elspais/commands/config_cmd.py +13 -16
  8. elspais/commands/edit.py +60 -44
  9. elspais/commands/example_cmd.py +319 -0
  10. elspais/commands/hash_cmd.py +167 -183
  11. elspais/commands/health.py +1177 -0
  12. elspais/commands/index.py +98 -114
  13. elspais/commands/init.py +103 -26
  14. elspais/commands/reformat_cmd.py +41 -444
  15. elspais/commands/rules_cmd.py +7 -3
  16. elspais/commands/trace.py +444 -321
  17. elspais/commands/validate.py +195 -415
  18. elspais/config/__init__.py +799 -5
  19. elspais/{core/content_rules.py → content_rules.py} +20 -3
  20. elspais/docs/cli/assertions.md +67 -0
  21. elspais/docs/cli/commands.md +304 -0
  22. elspais/docs/cli/config.md +262 -0
  23. elspais/docs/cli/format.md +66 -0
  24. elspais/docs/cli/git.md +45 -0
  25. elspais/docs/cli/health.md +190 -0
  26. elspais/docs/cli/hierarchy.md +60 -0
  27. elspais/docs/cli/ignore.md +72 -0
  28. elspais/docs/cli/mcp.md +245 -0
  29. elspais/docs/cli/quickstart.md +58 -0
  30. elspais/docs/cli/traceability.md +89 -0
  31. elspais/docs/cli/validation.md +96 -0
  32. elspais/graph/GraphNode.py +383 -0
  33. elspais/graph/__init__.py +40 -0
  34. elspais/graph/annotators.py +927 -0
  35. elspais/graph/builder.py +1886 -0
  36. elspais/graph/deserializer.py +248 -0
  37. elspais/graph/factory.py +284 -0
  38. elspais/graph/metrics.py +127 -0
  39. elspais/graph/mutations.py +161 -0
  40. elspais/graph/parsers/__init__.py +156 -0
  41. elspais/graph/parsers/code.py +213 -0
  42. elspais/graph/parsers/comments.py +112 -0
  43. elspais/graph/parsers/config_helpers.py +29 -0
  44. elspais/graph/parsers/heredocs.py +225 -0
  45. elspais/graph/parsers/journey.py +131 -0
  46. elspais/graph/parsers/remainder.py +79 -0
  47. elspais/graph/parsers/requirement.py +347 -0
  48. elspais/graph/parsers/results/__init__.py +6 -0
  49. elspais/graph/parsers/results/junit_xml.py +229 -0
  50. elspais/graph/parsers/results/pytest_json.py +313 -0
  51. elspais/graph/parsers/test.py +305 -0
  52. elspais/graph/relations.py +78 -0
  53. elspais/graph/serialize.py +216 -0
  54. elspais/html/__init__.py +8 -0
  55. elspais/html/generator.py +731 -0
  56. elspais/html/templates/trace_view.html.j2 +2151 -0
  57. elspais/mcp/__init__.py +47 -29
  58. elspais/mcp/__main__.py +5 -1
  59. elspais/mcp/file_mutations.py +138 -0
  60. elspais/mcp/server.py +2016 -247
  61. elspais/testing/__init__.py +4 -4
  62. elspais/testing/config.py +3 -0
  63. elspais/testing/mapper.py +1 -1
  64. elspais/testing/result_parser.py +25 -21
  65. elspais/testing/scanner.py +301 -12
  66. elspais/utilities/__init__.py +1 -0
  67. elspais/utilities/docs_loader.py +115 -0
  68. elspais/utilities/git.py +607 -0
  69. elspais/{core → utilities}/hasher.py +8 -22
  70. elspais/utilities/md_renderer.py +189 -0
  71. elspais/{core → utilities}/patterns.py +58 -57
  72. elspais/utilities/reference_config.py +626 -0
  73. elspais/validation/__init__.py +19 -0
  74. elspais/validation/format.py +264 -0
  75. {elspais-0.11.1.dist-info → elspais-0.43.5.dist-info}/METADATA +7 -4
  76. elspais-0.43.5.dist-info/RECORD +80 -0
  77. elspais/config/defaults.py +0 -173
  78. elspais/config/loader.py +0 -494
  79. elspais/core/__init__.py +0 -21
  80. elspais/core/git.py +0 -352
  81. elspais/core/models.py +0 -320
  82. elspais/core/parser.py +0 -640
  83. elspais/core/rules.py +0 -514
  84. elspais/mcp/context.py +0 -171
  85. elspais/mcp/serializers.py +0 -112
  86. elspais/reformat/__init__.py +0 -50
  87. elspais/reformat/detector.py +0 -119
  88. elspais/reformat/hierarchy.py +0 -246
  89. elspais/reformat/line_breaks.py +0 -220
  90. elspais/reformat/prompts.py +0 -123
  91. elspais/reformat/transformer.py +0 -264
  92. elspais/trace_view/__init__.py +0 -54
  93. elspais/trace_view/coverage.py +0 -183
  94. elspais/trace_view/generators/__init__.py +0 -12
  95. elspais/trace_view/generators/base.py +0 -329
  96. elspais/trace_view/generators/csv.py +0 -122
  97. elspais/trace_view/generators/markdown.py +0 -175
  98. elspais/trace_view/html/__init__.py +0 -31
  99. elspais/trace_view/html/generator.py +0 -1006
  100. elspais/trace_view/html/templates/base.html +0 -283
  101. elspais/trace_view/html/templates/components/code_viewer_modal.html +0 -14
  102. elspais/trace_view/html/templates/components/file_picker_modal.html +0 -20
  103. elspais/trace_view/html/templates/components/legend_modal.html +0 -69
  104. elspais/trace_view/html/templates/components/review_panel.html +0 -118
  105. elspais/trace_view/html/templates/partials/review/help/help-panel.json +0 -244
  106. elspais/trace_view/html/templates/partials/review/help/onboarding.json +0 -77
  107. elspais/trace_view/html/templates/partials/review/help/tooltips.json +0 -237
  108. elspais/trace_view/html/templates/partials/review/review-comments.js +0 -928
  109. elspais/trace_view/html/templates/partials/review/review-data.js +0 -961
  110. elspais/trace_view/html/templates/partials/review/review-help.js +0 -679
  111. elspais/trace_view/html/templates/partials/review/review-init.js +0 -177
  112. elspais/trace_view/html/templates/partials/review/review-line-numbers.js +0 -429
  113. elspais/trace_view/html/templates/partials/review/review-packages.js +0 -1029
  114. elspais/trace_view/html/templates/partials/review/review-position.js +0 -540
  115. elspais/trace_view/html/templates/partials/review/review-resize.js +0 -115
  116. elspais/trace_view/html/templates/partials/review/review-status.js +0 -659
  117. elspais/trace_view/html/templates/partials/review/review-sync.js +0 -992
  118. elspais/trace_view/html/templates/partials/review-styles.css +0 -2238
  119. elspais/trace_view/html/templates/partials/scripts.js +0 -1741
  120. elspais/trace_view/html/templates/partials/styles.css +0 -1756
  121. elspais/trace_view/models.py +0 -353
  122. elspais/trace_view/review/__init__.py +0 -60
  123. elspais/trace_view/review/branches.py +0 -1149
  124. elspais/trace_view/review/models.py +0 -1205
  125. elspais/trace_view/review/position.py +0 -609
  126. elspais/trace_view/review/server.py +0 -1056
  127. elspais/trace_view/review/status.py +0 -470
  128. elspais/trace_view/review/storage.py +0 -1367
  129. elspais/trace_view/scanning.py +0 -213
  130. elspais/trace_view/specs/README.md +0 -84
  131. elspais/trace_view/specs/tv-d00001-template-architecture.md +0 -36
  132. elspais/trace_view/specs/tv-d00002-css-extraction.md +0 -37
  133. elspais/trace_view/specs/tv-d00003-js-extraction.md +0 -43
  134. elspais/trace_view/specs/tv-d00004-build-embedding.md +0 -40
  135. elspais/trace_view/specs/tv-d00005-test-format.md +0 -78
  136. elspais/trace_view/specs/tv-d00010-review-data-models.md +0 -33
  137. elspais/trace_view/specs/tv-d00011-review-storage.md +0 -33
  138. elspais/trace_view/specs/tv-d00012-position-resolution.md +0 -33
  139. elspais/trace_view/specs/tv-d00013-git-branches.md +0 -31
  140. elspais/trace_view/specs/tv-d00014-review-api-server.md +0 -31
  141. elspais/trace_view/specs/tv-d00015-status-modifier.md +0 -27
  142. elspais/trace_view/specs/tv-d00016-js-integration.md +0 -33
  143. elspais/trace_view/specs/tv-p00001-html-generator.md +0 -33
  144. elspais/trace_view/specs/tv-p00002-review-system.md +0 -29
  145. elspais-0.11.1.dist-info/RECORD +0 -101
  146. {elspais-0.11.1.dist-info → elspais-0.43.5.dist-info}/WHEEL +0 -0
  147. {elspais-0.11.1.dist-info → elspais-0.43.5.dist-info}/entry_points.txt +0 -0
  148. {elspais-0.11.1.dist-info → elspais-0.43.5.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,607 @@
1
+ """Git state management for elspais.
2
+
3
+ Provides functions to query git status and detect changes to requirement files,
4
+ enabling detection of:
5
+ - Uncommitted changes to spec files
6
+ - New (untracked) requirement files
7
+ - Files changed vs main/master branch
8
+ - Moved requirements (comparing current location to committed state)
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ import subprocess
15
+ import tempfile
16
+ from contextlib import contextmanager
17
+ from dataclasses import dataclass, field
18
+ from pathlib import Path
19
+ from typing import Any, Iterator
20
+
21
+
22
+ def _clean_git_env() -> dict[str, str]:
23
+ """Return environment with GIT_DIR/GIT_WORK_TREE removed.
24
+
25
+ Use when running git commands with explicit cwd to prevent
26
+ inherited git context from overriding the provided path.
27
+ """
28
+ env = os.environ.copy()
29
+ env.pop("GIT_DIR", None)
30
+ env.pop("GIT_WORK_TREE", None)
31
+ return env
32
+
33
+
34
+ @contextmanager
35
+ def temporary_worktree(repo_root: Path, ref: str = "HEAD") -> Iterator[Path]:
36
+ """Create a temporary git worktree for a ref.
37
+
38
+ Creates a detached worktree at the specified ref, yields its path,
39
+ then cleans up the worktree automatically on exit.
40
+
41
+ Usage:
42
+ with temporary_worktree(repo_root, "HEAD") as worktree_path:
43
+ committed_graph = build_graph(repo_root=worktree_path)
44
+ # work with committed state...
45
+
46
+ Args:
47
+ repo_root: Path to the repository root.
48
+ ref: Git ref to checkout (default: HEAD).
49
+
50
+ Yields:
51
+ Path to the temporary worktree.
52
+
53
+ Raises:
54
+ subprocess.CalledProcessError: If git worktree commands fail.
55
+ """
56
+ with tempfile.TemporaryDirectory() as tmp:
57
+ worktree_path = Path(tmp) / "worktree"
58
+
59
+ subprocess.run(
60
+ ["git", "worktree", "add", "--detach", str(worktree_path), ref],
61
+ cwd=repo_root,
62
+ env=_clean_git_env(),
63
+ capture_output=True,
64
+ check=True,
65
+ )
66
+
67
+ try:
68
+ yield worktree_path
69
+ finally:
70
+ subprocess.run(
71
+ ["git", "worktree", "remove", "--force", str(worktree_path)],
72
+ cwd=repo_root,
73
+ env=_clean_git_env(),
74
+ capture_output=True,
75
+ )
76
+
77
+
78
+ @dataclass
79
+ class GitChangeInfo:
80
+ """Information about git changes to requirement files.
81
+
82
+ Attributes:
83
+ modified_files: Files with uncommitted modifications (staged or unstaged).
84
+ untracked_files: New files not yet tracked by git.
85
+ branch_changed_files: Files changed between current branch and main/master.
86
+ committed_req_locations: REQ ID -> file path mapping from committed state (HEAD).
87
+ """
88
+
89
+ modified_files: set[str] = field(default_factory=set)
90
+ untracked_files: set[str] = field(default_factory=set)
91
+ branch_changed_files: set[str] = field(default_factory=set)
92
+ committed_req_locations: dict[str, str] = field(default_factory=dict)
93
+
94
+ @property
95
+ def all_changed_files(self) -> set[str]:
96
+ """Get all files with any kind of change."""
97
+ return self.modified_files | self.untracked_files | self.branch_changed_files
98
+
99
+ @property
100
+ def uncommitted_files(self) -> set[str]:
101
+ """Get all files with uncommitted changes (modified or untracked)."""
102
+ return self.modified_files | self.untracked_files
103
+
104
+
105
+ @dataclass
106
+ class MovedRequirement:
107
+ """Information about a requirement that was moved between files.
108
+
109
+ Attributes:
110
+ req_id: The requirement ID (e.g., 'd00001').
111
+ old_path: Path in the committed state.
112
+ new_path: Path in the current working directory.
113
+ """
114
+
115
+ req_id: str
116
+ old_path: str
117
+ new_path: str
118
+
119
+
120
+ def get_repo_root(start_path: Path | None = None) -> Path | None:
121
+ """Find the git repository root.
122
+
123
+ Args:
124
+ start_path: Path to start searching from (default: current directory)
125
+
126
+ Returns:
127
+ Path to repository root, or None if not in a git repository
128
+ """
129
+ try:
130
+ result = subprocess.run(
131
+ ["git", "rev-parse", "--show-toplevel"],
132
+ cwd=start_path or Path.cwd(),
133
+ env=_clean_git_env() if start_path else None,
134
+ capture_output=True,
135
+ text=True,
136
+ check=True,
137
+ )
138
+ return Path(result.stdout.strip())
139
+ except (subprocess.CalledProcessError, FileNotFoundError):
140
+ return None
141
+
142
+
143
+ def get_modified_files(repo_root: Path) -> tuple[set[str], set[str]]:
144
+ """Get sets of modified and untracked files according to git status.
145
+
146
+ Args:
147
+ repo_root: Path to repository root
148
+
149
+ Returns:
150
+ Tuple of (modified_files, untracked_files):
151
+ - modified_files: Tracked files with changes (M, A, R, etc.)
152
+ - untracked_files: New files not yet tracked (??)
153
+ """
154
+ try:
155
+ result = subprocess.run(
156
+ ["git", "status", "--porcelain", "--untracked-files=all"],
157
+ cwd=repo_root,
158
+ env=_clean_git_env(),
159
+ capture_output=True,
160
+ text=True,
161
+ check=True,
162
+ )
163
+ modified_files: set[str] = set()
164
+ untracked_files: set[str] = set()
165
+
166
+ for line in result.stdout.split("\n"):
167
+ if line and len(line) >= 3:
168
+ # Format: "XY filename" or "XY orig -> renamed"
169
+ # XY = two-letter status (e.g., " M", "??", "A ", "R ")
170
+ status_code = line[:2]
171
+ file_path = line[3:].strip()
172
+
173
+ # Handle renames: "orig -> new"
174
+ if " -> " in file_path:
175
+ file_path = file_path.split(" -> ")[1]
176
+
177
+ if file_path:
178
+ if status_code == "??":
179
+ untracked_files.add(file_path)
180
+ else:
181
+ modified_files.add(file_path)
182
+
183
+ return modified_files, untracked_files
184
+ except (subprocess.CalledProcessError, FileNotFoundError):
185
+ return set(), set()
186
+
187
+
188
+ def get_changed_vs_branch(repo_root: Path, base_branch: str = "main") -> set[str]:
189
+ """Get set of files changed between current branch and base branch.
190
+
191
+ Args:
192
+ repo_root: Path to repository root
193
+ base_branch: Name of base branch (default: 'main')
194
+
195
+ Returns:
196
+ Set of file paths changed vs base branch
197
+ """
198
+ # Try local branch first, then remote
199
+ for branch_ref in [base_branch, f"origin/{base_branch}"]:
200
+ try:
201
+ result = subprocess.run(
202
+ ["git", "diff", "--name-only", f"{branch_ref}...HEAD"],
203
+ cwd=repo_root,
204
+ env=_clean_git_env(),
205
+ capture_output=True,
206
+ text=True,
207
+ check=True,
208
+ )
209
+ changed_files: set[str] = set()
210
+ for line in result.stdout.split("\n"):
211
+ if line.strip():
212
+ changed_files.add(line.strip())
213
+ return changed_files
214
+ except subprocess.CalledProcessError:
215
+ continue
216
+ except FileNotFoundError:
217
+ return set()
218
+
219
+ return set()
220
+
221
+
222
+ def _extract_req_locations_from_graph(graph: Any, repo_root: Path | None = None) -> dict[str, str]:
223
+ """Extract REQ ID -> file path mapping from a TraceGraph.
224
+
225
+ This is the graph-based replacement for the old regex-based extraction.
226
+ Uses the same parsing logic that build_graph() uses.
227
+
228
+ Args:
229
+ graph: A TraceGraph instance.
230
+ repo_root: Repository root for relativizing paths (uses graph.repo_root if None).
231
+
232
+ Returns:
233
+ Dict mapping REQ ID (just the suffix, e.g., 'd00001') to relative file path.
234
+ """
235
+ from elspais.graph.GraphNode import NodeKind
236
+
237
+ req_locations: dict[str, str] = {}
238
+
239
+ # Get repo_root for path relativization
240
+ if repo_root is None:
241
+ repo_root = getattr(graph, "repo_root", None)
242
+
243
+ for node in graph.all_nodes():
244
+ if node.kind == NodeKind.REQUIREMENT and node.source:
245
+ # Extract just the suffix (e.g., 'd00001' from 'REQ-d00001')
246
+ req_id = node.id
247
+ if req_id.startswith("REQ-"):
248
+ # Handle possible associated prefix like "REQ-CAL-d00001"
249
+ parts = req_id[4:].split("-")
250
+ if len(parts) >= 2 and len(parts[0]) > 1 and parts[0].isupper():
251
+ # Has associated prefix (e.g., "CAL-d00001"), use last part
252
+ req_id = parts[-1]
253
+ else:
254
+ # No prefix, just use what's after "REQ-"
255
+ req_id = parts[-1]
256
+
257
+ # Get source path and make it relative if needed
258
+ source_path = node.source.path
259
+ if repo_root:
260
+ try:
261
+ # If path is absolute, make it relative to repo_root
262
+ path_obj = Path(source_path)
263
+ if path_obj.is_absolute():
264
+ source_path = str(path_obj.relative_to(repo_root))
265
+ except ValueError:
266
+ # Path is not relative to repo_root, keep as-is
267
+ pass
268
+
269
+ req_locations[req_id] = source_path
270
+
271
+ return req_locations
272
+
273
+
274
+ def get_req_locations_from_graph(
275
+ repo_root: Path,
276
+ scan_sponsors: bool = False,
277
+ ) -> dict[str, str]:
278
+ """Get REQ ID -> file path mapping from a graph built at the given path.
279
+
280
+ This is the graph-based approach that uses build_graph() to parse
281
+ requirements using the project's configuration.
282
+
283
+ Args:
284
+ repo_root: Path to repository root (or worktree path).
285
+ scan_sponsors: Whether to include sponsor/associated repos.
286
+
287
+ Returns:
288
+ Dict mapping REQ ID (e.g., 'd00001') to relative file path.
289
+ """
290
+ from elspais.graph.factory import build_graph
291
+
292
+ # Build graph with minimal scanning (we only need requirements)
293
+ graph = build_graph(
294
+ repo_root=repo_root,
295
+ scan_code=False,
296
+ scan_tests=False,
297
+ scan_sponsors=scan_sponsors,
298
+ )
299
+
300
+ return _extract_req_locations_from_graph(graph, repo_root)
301
+
302
+
303
+ def detect_moved_requirements(
304
+ committed_locations: dict[str, str],
305
+ current_locations: dict[str, str],
306
+ ) -> list[MovedRequirement]:
307
+ """Detect requirements that have been moved between files.
308
+
309
+ Args:
310
+ committed_locations: REQ ID -> path mapping from committed state
311
+ current_locations: REQ ID -> path mapping from current state
312
+
313
+ Returns:
314
+ List of MovedRequirement objects for requirements whose location changed
315
+ """
316
+ moved = []
317
+ for req_id, old_path in committed_locations.items():
318
+ if req_id in current_locations:
319
+ new_path = current_locations[req_id]
320
+ if old_path != new_path:
321
+ moved.append(
322
+ MovedRequirement(
323
+ req_id=req_id,
324
+ old_path=old_path,
325
+ new_path=new_path,
326
+ )
327
+ )
328
+ return moved
329
+
330
+
331
+ def get_git_changes(
332
+ repo_root: Path | None = None,
333
+ spec_dir: str = "spec",
334
+ base_branch: str = "main",
335
+ base_ref: str = "HEAD",
336
+ ) -> GitChangeInfo:
337
+ """Get comprehensive git change information for requirement files.
338
+
339
+ This is the main entry point for git change detection. It gathers:
340
+ - Modified files (uncommitted changes to tracked files)
341
+ - Untracked files (new files not yet in git)
342
+ - Branch changed files (files changed vs main/master)
343
+ - Committed REQ locations (for move detection via graph-based comparison)
344
+
345
+ Uses git worktree + build_graph() to properly parse committed state,
346
+ respecting project configuration rather than hardcoded regex patterns.
347
+
348
+ Args:
349
+ repo_root: Path to repository root (auto-detected if None)
350
+ spec_dir: Spec directory relative to repo root (deprecated, ignored)
351
+ base_branch: Base branch for comparison (default: 'main')
352
+ base_ref: Git ref for committed state comparison (default: 'HEAD')
353
+
354
+ Returns:
355
+ GitChangeInfo with all change information
356
+ """
357
+ if repo_root is None:
358
+ repo_root = get_repo_root()
359
+ if repo_root is None:
360
+ return GitChangeInfo()
361
+
362
+ modified, untracked = get_modified_files(repo_root)
363
+ branch_changed = get_changed_vs_branch(repo_root, base_branch)
364
+
365
+ # Get committed locations using graph-based approach via git worktree
366
+ committed_locations: dict[str, str] = {}
367
+ try:
368
+ with temporary_worktree(repo_root, base_ref) as worktree_path:
369
+ committed_locations = get_req_locations_from_graph(worktree_path)
370
+ except subprocess.CalledProcessError:
371
+ # Worktree creation failed (e.g., no commits yet), fall back to empty
372
+ pass
373
+
374
+ return GitChangeInfo(
375
+ modified_files=modified,
376
+ untracked_files=untracked,
377
+ branch_changed_files=branch_changed,
378
+ committed_req_locations=committed_locations,
379
+ )
380
+
381
+
382
+ def filter_spec_files(files: set[str], spec_dir: str = "spec") -> set[str]:
383
+ """Filter a set of files to only include spec directory files.
384
+
385
+ Args:
386
+ files: Set of file paths
387
+ spec_dir: Spec directory prefix
388
+
389
+ Returns:
390
+ Set of files that are in the spec directory
391
+ """
392
+ prefix = f"{spec_dir}/"
393
+ return {f for f in files if f.startswith(prefix) and f.endswith(".md")}
394
+
395
+
396
+ # ─────────────────────────────────────────────────────────────────────────────
397
+ # Safety Branch Utilities (REQ-o00063)
398
+ # ─────────────────────────────────────────────────────────────────────────────
399
+
400
+
401
+ def get_current_branch(repo_root: Path) -> str | None:
402
+ """Get the name of the current git branch.
403
+
404
+ Args:
405
+ repo_root: Path to repository root
406
+
407
+ Returns:
408
+ Branch name, or None if not on a branch (detached HEAD)
409
+ """
410
+ try:
411
+ result = subprocess.run(
412
+ ["git", "rev-parse", "--abbrev-ref", "HEAD"],
413
+ cwd=repo_root,
414
+ env=_clean_git_env(),
415
+ capture_output=True,
416
+ text=True,
417
+ check=True,
418
+ )
419
+ branch = result.stdout.strip()
420
+ return branch if branch != "HEAD" else None
421
+ except (subprocess.CalledProcessError, FileNotFoundError):
422
+ return None
423
+
424
+
425
+ def create_safety_branch(
426
+ repo_root: Path,
427
+ req_id: str,
428
+ ) -> dict[str, Any]:
429
+ """Create a safety branch with timestamped name before file mutations.
430
+
431
+ Safety branches allow reverting file mutations by preserving the pre-mutation
432
+ state of spec files.
433
+
434
+ Args:
435
+ repo_root: Path to repository root
436
+ req_id: Requirement ID being modified (used in branch name)
437
+
438
+ Returns:
439
+ Dict with 'success', 'branch_name', and optional 'error'
440
+ """
441
+ from datetime import datetime
442
+
443
+ timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
444
+ branch_name = f"safety/{req_id}-{timestamp}"
445
+
446
+ try:
447
+ # Create the branch at current HEAD
448
+ subprocess.run(
449
+ ["git", "branch", branch_name],
450
+ cwd=repo_root,
451
+ env=_clean_git_env(),
452
+ capture_output=True,
453
+ text=True,
454
+ check=True,
455
+ )
456
+ return {"success": True, "branch_name": branch_name}
457
+ except subprocess.CalledProcessError as e:
458
+ return {"success": False, "error": f"Failed to create branch: {e.stderr}"}
459
+ except FileNotFoundError:
460
+ return {"success": False, "error": "git not found"}
461
+
462
+
463
+ def list_safety_branches(repo_root: Path) -> list[str]:
464
+ """List all safety branches in the repository.
465
+
466
+ Args:
467
+ repo_root: Path to repository root
468
+
469
+ Returns:
470
+ List of branch names starting with 'safety/'
471
+ """
472
+ try:
473
+ result = subprocess.run(
474
+ ["git", "branch", "--list", "safety/*"],
475
+ cwd=repo_root,
476
+ env=_clean_git_env(),
477
+ capture_output=True,
478
+ text=True,
479
+ check=True,
480
+ )
481
+ branches = []
482
+ for line in result.stdout.strip().split("\n"):
483
+ if line:
484
+ # Remove leading '* ' or ' ' from branch name
485
+ branch = line.strip().lstrip("* ")
486
+ if branch:
487
+ branches.append(branch)
488
+ return branches
489
+ except (subprocess.CalledProcessError, FileNotFoundError):
490
+ return []
491
+
492
+
493
+ def restore_from_safety_branch(
494
+ repo_root: Path,
495
+ branch_name: str,
496
+ spec_dir: str = "spec",
497
+ ) -> dict[str, Any]:
498
+ """Restore spec files from a safety branch.
499
+
500
+ This checks out the spec directory from the safety branch, effectively
501
+ reverting any file mutations made after the safety branch was created.
502
+
503
+ Args:
504
+ repo_root: Path to repository root
505
+ branch_name: Name of the safety branch to restore from
506
+ spec_dir: Spec directory relative to repo root
507
+
508
+ Returns:
509
+ Dict with 'success', 'files_restored', and optional 'error'
510
+ """
511
+ # Verify branch exists
512
+ branches = list_safety_branches(repo_root)
513
+ if branch_name not in branches:
514
+ return {"success": False, "error": f"Branch '{branch_name}' not found"}
515
+
516
+ try:
517
+ # Checkout spec directory from safety branch
518
+ subprocess.run(
519
+ ["git", "checkout", branch_name, "--", f"{spec_dir}/"],
520
+ cwd=repo_root,
521
+ env=_clean_git_env(),
522
+ capture_output=True,
523
+ text=True,
524
+ check=True,
525
+ )
526
+
527
+ # Get list of restored files
528
+ status_result = subprocess.run(
529
+ ["git", "diff", "--name-only", "--cached"],
530
+ cwd=repo_root,
531
+ env=_clean_git_env(),
532
+ capture_output=True,
533
+ text=True,
534
+ check=True,
535
+ )
536
+ files_restored = [
537
+ f for f in status_result.stdout.strip().split("\n") if f.startswith(spec_dir)
538
+ ]
539
+
540
+ # Reset staging area (we only want working directory changes)
541
+ subprocess.run(
542
+ ["git", "reset", "HEAD", f"{spec_dir}/"],
543
+ cwd=repo_root,
544
+ env=_clean_git_env(),
545
+ capture_output=True,
546
+ check=True,
547
+ )
548
+
549
+ return {"success": True, "files_restored": files_restored}
550
+ except subprocess.CalledProcessError as e:
551
+ return {"success": False, "error": f"Failed to restore: {e.stderr}"}
552
+ except FileNotFoundError:
553
+ return {"success": False, "error": "git not found"}
554
+
555
+
556
+ def delete_safety_branch(
557
+ repo_root: Path,
558
+ branch_name: str,
559
+ ) -> dict[str, Any]:
560
+ """Delete a safety branch.
561
+
562
+ Args:
563
+ repo_root: Path to repository root
564
+ branch_name: Name of the branch to delete
565
+
566
+ Returns:
567
+ Dict with 'success' and optional 'error'
568
+ """
569
+ # Only allow deleting safety branches
570
+ if not branch_name.startswith("safety/"):
571
+ return {"success": False, "error": "Can only delete safety/ branches"}
572
+
573
+ try:
574
+ subprocess.run(
575
+ ["git", "branch", "-D", branch_name],
576
+ cwd=repo_root,
577
+ env=_clean_git_env(),
578
+ capture_output=True,
579
+ text=True,
580
+ check=True,
581
+ )
582
+ return {"success": True}
583
+ except subprocess.CalledProcessError as e:
584
+ return {"success": False, "error": f"Failed to delete branch: {e.stderr}"}
585
+ except FileNotFoundError:
586
+ return {"success": False, "error": "git not found"}
587
+
588
+
589
+ __all__ = [
590
+ "GitChangeInfo",
591
+ "MovedRequirement",
592
+ "get_repo_root",
593
+ "get_modified_files",
594
+ "get_changed_vs_branch",
595
+ "detect_moved_requirements",
596
+ "get_git_changes",
597
+ "filter_spec_files",
598
+ # Graph-based location extraction
599
+ "temporary_worktree",
600
+ "get_req_locations_from_graph",
601
+ # Safety branch utilities (REQ-o00063)
602
+ "get_current_branch",
603
+ "create_safety_branch",
604
+ "list_safety_branches",
605
+ "restore_from_safety_branch",
606
+ "delete_safety_branch",
607
+ ]