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.
- elspais/__init__.py +2 -11
- elspais/{sponsors/__init__.py → associates.py} +102 -58
- elspais/cli.py +395 -79
- elspais/commands/__init__.py +9 -3
- elspais/commands/analyze.py +121 -173
- elspais/commands/changed.py +15 -30
- elspais/commands/config_cmd.py +13 -16
- elspais/commands/edit.py +60 -44
- elspais/commands/example_cmd.py +319 -0
- elspais/commands/hash_cmd.py +167 -183
- elspais/commands/health.py +1177 -0
- elspais/commands/index.py +98 -114
- elspais/commands/init.py +103 -26
- elspais/commands/reformat_cmd.py +41 -444
- elspais/commands/rules_cmd.py +7 -3
- elspais/commands/trace.py +444 -321
- elspais/commands/validate.py +195 -415
- elspais/config/__init__.py +799 -5
- elspais/{core/content_rules.py → content_rules.py} +20 -3
- elspais/docs/cli/assertions.md +67 -0
- elspais/docs/cli/commands.md +304 -0
- elspais/docs/cli/config.md +262 -0
- elspais/docs/cli/format.md +66 -0
- elspais/docs/cli/git.md +45 -0
- elspais/docs/cli/health.md +190 -0
- elspais/docs/cli/hierarchy.md +60 -0
- elspais/docs/cli/ignore.md +72 -0
- elspais/docs/cli/mcp.md +245 -0
- elspais/docs/cli/quickstart.md +58 -0
- elspais/docs/cli/traceability.md +89 -0
- elspais/docs/cli/validation.md +96 -0
- elspais/graph/GraphNode.py +383 -0
- elspais/graph/__init__.py +40 -0
- elspais/graph/annotators.py +927 -0
- elspais/graph/builder.py +1886 -0
- elspais/graph/deserializer.py +248 -0
- elspais/graph/factory.py +284 -0
- elspais/graph/metrics.py +127 -0
- elspais/graph/mutations.py +161 -0
- elspais/graph/parsers/__init__.py +156 -0
- elspais/graph/parsers/code.py +213 -0
- elspais/graph/parsers/comments.py +112 -0
- elspais/graph/parsers/config_helpers.py +29 -0
- elspais/graph/parsers/heredocs.py +225 -0
- elspais/graph/parsers/journey.py +131 -0
- elspais/graph/parsers/remainder.py +79 -0
- elspais/graph/parsers/requirement.py +347 -0
- elspais/graph/parsers/results/__init__.py +6 -0
- elspais/graph/parsers/results/junit_xml.py +229 -0
- elspais/graph/parsers/results/pytest_json.py +313 -0
- elspais/graph/parsers/test.py +305 -0
- elspais/graph/relations.py +78 -0
- elspais/graph/serialize.py +216 -0
- elspais/html/__init__.py +8 -0
- elspais/html/generator.py +731 -0
- elspais/html/templates/trace_view.html.j2 +2151 -0
- elspais/mcp/__init__.py +47 -29
- elspais/mcp/__main__.py +5 -1
- elspais/mcp/file_mutations.py +138 -0
- elspais/mcp/server.py +2016 -247
- elspais/testing/__init__.py +4 -4
- elspais/testing/config.py +3 -0
- elspais/testing/mapper.py +1 -1
- elspais/testing/result_parser.py +25 -21
- elspais/testing/scanner.py +301 -12
- elspais/utilities/__init__.py +1 -0
- elspais/utilities/docs_loader.py +115 -0
- elspais/utilities/git.py +607 -0
- elspais/{core → utilities}/hasher.py +8 -22
- elspais/utilities/md_renderer.py +189 -0
- elspais/{core → utilities}/patterns.py +58 -57
- elspais/utilities/reference_config.py +626 -0
- elspais/validation/__init__.py +19 -0
- elspais/validation/format.py +264 -0
- {elspais-0.11.1.dist-info → elspais-0.43.5.dist-info}/METADATA +7 -4
- elspais-0.43.5.dist-info/RECORD +80 -0
- elspais/config/defaults.py +0 -173
- elspais/config/loader.py +0 -494
- elspais/core/__init__.py +0 -21
- elspais/core/git.py +0 -352
- elspais/core/models.py +0 -320
- elspais/core/parser.py +0 -640
- elspais/core/rules.py +0 -514
- elspais/mcp/context.py +0 -171
- elspais/mcp/serializers.py +0 -112
- elspais/reformat/__init__.py +0 -50
- elspais/reformat/detector.py +0 -119
- elspais/reformat/hierarchy.py +0 -246
- elspais/reformat/line_breaks.py +0 -220
- elspais/reformat/prompts.py +0 -123
- elspais/reformat/transformer.py +0 -264
- elspais/trace_view/__init__.py +0 -54
- elspais/trace_view/coverage.py +0 -183
- elspais/trace_view/generators/__init__.py +0 -12
- elspais/trace_view/generators/base.py +0 -329
- elspais/trace_view/generators/csv.py +0 -122
- elspais/trace_view/generators/markdown.py +0 -175
- elspais/trace_view/html/__init__.py +0 -31
- elspais/trace_view/html/generator.py +0 -1006
- elspais/trace_view/html/templates/base.html +0 -283
- elspais/trace_view/html/templates/components/code_viewer_modal.html +0 -14
- elspais/trace_view/html/templates/components/file_picker_modal.html +0 -20
- elspais/trace_view/html/templates/components/legend_modal.html +0 -69
- elspais/trace_view/html/templates/components/review_panel.html +0 -118
- elspais/trace_view/html/templates/partials/review/help/help-panel.json +0 -244
- elspais/trace_view/html/templates/partials/review/help/onboarding.json +0 -77
- elspais/trace_view/html/templates/partials/review/help/tooltips.json +0 -237
- elspais/trace_view/html/templates/partials/review/review-comments.js +0 -928
- elspais/trace_view/html/templates/partials/review/review-data.js +0 -961
- elspais/trace_view/html/templates/partials/review/review-help.js +0 -679
- elspais/trace_view/html/templates/partials/review/review-init.js +0 -177
- elspais/trace_view/html/templates/partials/review/review-line-numbers.js +0 -429
- elspais/trace_view/html/templates/partials/review/review-packages.js +0 -1029
- elspais/trace_view/html/templates/partials/review/review-position.js +0 -540
- elspais/trace_view/html/templates/partials/review/review-resize.js +0 -115
- elspais/trace_view/html/templates/partials/review/review-status.js +0 -659
- elspais/trace_view/html/templates/partials/review/review-sync.js +0 -992
- elspais/trace_view/html/templates/partials/review-styles.css +0 -2238
- elspais/trace_view/html/templates/partials/scripts.js +0 -1741
- elspais/trace_view/html/templates/partials/styles.css +0 -1756
- elspais/trace_view/models.py +0 -353
- elspais/trace_view/review/__init__.py +0 -60
- elspais/trace_view/review/branches.py +0 -1149
- elspais/trace_view/review/models.py +0 -1205
- elspais/trace_view/review/position.py +0 -609
- elspais/trace_view/review/server.py +0 -1056
- elspais/trace_view/review/status.py +0 -470
- elspais/trace_view/review/storage.py +0 -1367
- elspais/trace_view/scanning.py +0 -213
- elspais/trace_view/specs/README.md +0 -84
- elspais/trace_view/specs/tv-d00001-template-architecture.md +0 -36
- elspais/trace_view/specs/tv-d00002-css-extraction.md +0 -37
- elspais/trace_view/specs/tv-d00003-js-extraction.md +0 -43
- elspais/trace_view/specs/tv-d00004-build-embedding.md +0 -40
- elspais/trace_view/specs/tv-d00005-test-format.md +0 -78
- elspais/trace_view/specs/tv-d00010-review-data-models.md +0 -33
- elspais/trace_view/specs/tv-d00011-review-storage.md +0 -33
- elspais/trace_view/specs/tv-d00012-position-resolution.md +0 -33
- elspais/trace_view/specs/tv-d00013-git-branches.md +0 -31
- elspais/trace_view/specs/tv-d00014-review-api-server.md +0 -31
- elspais/trace_view/specs/tv-d00015-status-modifier.md +0 -27
- elspais/trace_view/specs/tv-d00016-js-integration.md +0 -33
- elspais/trace_view/specs/tv-p00001-html-generator.md +0 -33
- elspais/trace_view/specs/tv-p00002-review-system.md +0 -29
- elspais-0.11.1.dist-info/RECORD +0 -101
- {elspais-0.11.1.dist-info → elspais-0.43.5.dist-info}/WHEEL +0 -0
- {elspais-0.11.1.dist-info → elspais-0.43.5.dist-info}/entry_points.txt +0 -0
- {elspais-0.11.1.dist-info → elspais-0.43.5.dist-info}/licenses/LICENSE +0 -0
elspais/utilities/git.py
ADDED
|
@@ -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
|
+
]
|