specfact-cli 0.4.0__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.
Potentially problematic release.
This version of specfact-cli might be problematic. Click here for more details.
- specfact_cli/__init__.py +14 -0
- specfact_cli/agents/__init__.py +23 -0
- specfact_cli/agents/analyze_agent.py +392 -0
- specfact_cli/agents/base.py +95 -0
- specfact_cli/agents/plan_agent.py +202 -0
- specfact_cli/agents/registry.py +176 -0
- specfact_cli/agents/sync_agent.py +133 -0
- specfact_cli/analyzers/__init__.py +10 -0
- specfact_cli/analyzers/code_analyzer.py +775 -0
- specfact_cli/cli.py +397 -0
- specfact_cli/commands/__init__.py +7 -0
- specfact_cli/commands/enforce.py +87 -0
- specfact_cli/commands/import_cmd.py +355 -0
- specfact_cli/commands/init.py +119 -0
- specfact_cli/commands/plan.py +1090 -0
- specfact_cli/commands/repro.py +172 -0
- specfact_cli/commands/sync.py +408 -0
- specfact_cli/common/__init__.py +24 -0
- specfact_cli/common/logger_setup.py +673 -0
- specfact_cli/common/logging_utils.py +41 -0
- specfact_cli/common/text_utils.py +52 -0
- specfact_cli/common/utils.py +48 -0
- specfact_cli/comparators/__init__.py +10 -0
- specfact_cli/comparators/plan_comparator.py +391 -0
- specfact_cli/generators/__init__.py +13 -0
- specfact_cli/generators/plan_generator.py +105 -0
- specfact_cli/generators/protocol_generator.py +115 -0
- specfact_cli/generators/report_generator.py +200 -0
- specfact_cli/generators/workflow_generator.py +111 -0
- specfact_cli/importers/__init__.py +6 -0
- specfact_cli/importers/speckit_converter.py +773 -0
- specfact_cli/importers/speckit_scanner.py +704 -0
- specfact_cli/models/__init__.py +32 -0
- specfact_cli/models/deviation.py +105 -0
- specfact_cli/models/enforcement.py +150 -0
- specfact_cli/models/plan.py +97 -0
- specfact_cli/models/protocol.py +28 -0
- specfact_cli/modes/__init__.py +18 -0
- specfact_cli/modes/detector.py +126 -0
- specfact_cli/modes/router.py +153 -0
- specfact_cli/sync/__init__.py +11 -0
- specfact_cli/sync/repository_sync.py +279 -0
- specfact_cli/sync/speckit_sync.py +388 -0
- specfact_cli/utils/__init__.py +57 -0
- specfact_cli/utils/console.py +69 -0
- specfact_cli/utils/feature_keys.py +213 -0
- specfact_cli/utils/git.py +241 -0
- specfact_cli/utils/ide_setup.py +381 -0
- specfact_cli/utils/prompts.py +179 -0
- specfact_cli/utils/structure.py +496 -0
- specfact_cli/utils/yaml_utils.py +200 -0
- specfact_cli/validators/__init__.py +19 -0
- specfact_cli/validators/fsm.py +260 -0
- specfact_cli/validators/repro_checker.py +320 -0
- specfact_cli/validators/schema.py +200 -0
- specfact_cli-0.4.0.dist-info/METADATA +332 -0
- specfact_cli-0.4.0.dist-info/RECORD +60 -0
- specfact_cli-0.4.0.dist-info/WHEEL +4 -0
- specfact_cli-0.4.0.dist-info/entry_points.txt +2 -0
- specfact_cli-0.4.0.dist-info/licenses/LICENSE.md +55 -0
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Feature key normalization and conversion utilities.
|
|
3
|
+
|
|
4
|
+
Provides functions to normalize feature keys across different formats
|
|
5
|
+
to enable consistent comparison and merging of plans.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
|
|
10
|
+
from beartype import beartype
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@beartype
|
|
14
|
+
def normalize_feature_key(key: str) -> str:
|
|
15
|
+
"""
|
|
16
|
+
Normalize feature keys for comparison by removing prefixes and underscores.
|
|
17
|
+
|
|
18
|
+
Converts various formats to a canonical form:
|
|
19
|
+
- `000_CONTRACT_FIRST_TEST_MANAGER` -> `CONTRACTFIRSTTESTMANAGER`
|
|
20
|
+
- `FEATURE-CONTRACTFIRSTTESTMANAGER` -> `CONTRACTFIRSTTESTMANAGER`
|
|
21
|
+
- `FEATURE-001` -> `001`
|
|
22
|
+
- `CONTRACT_FIRST_TEST_MANAGER` -> `CONTRACTFIRSTTESTMANAGER`
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
key: Feature key in any format
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
Normalized key (uppercase, no prefixes, no underscores)
|
|
29
|
+
|
|
30
|
+
Examples:
|
|
31
|
+
>>> normalize_feature_key("000_CONTRACT_FIRST_TEST_MANAGER")
|
|
32
|
+
'CONTRACTFIRSTTESTMANAGER'
|
|
33
|
+
>>> normalize_feature_key("FEATURE-CONTRACTFIRSTTESTMANAGER")
|
|
34
|
+
'CONTRACTFIRSTTESTMANAGER'
|
|
35
|
+
>>> normalize_feature_key("FEATURE-001")
|
|
36
|
+
'001'
|
|
37
|
+
"""
|
|
38
|
+
# Remove common prefixes
|
|
39
|
+
key = key.replace("FEATURE-", "").replace("000_", "").replace("001_", "")
|
|
40
|
+
|
|
41
|
+
# Remove underscores and spaces, convert to uppercase
|
|
42
|
+
normalized = re.sub(r"[_\s-]", "", key).upper()
|
|
43
|
+
|
|
44
|
+
return normalized
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@beartype
|
|
48
|
+
def to_sequential_key(key: str, index: int) -> str:
|
|
49
|
+
"""
|
|
50
|
+
Convert any feature key to sequential format (FEATURE-001, FEATURE-002, ...).
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
key: Original feature key
|
|
54
|
+
index: Sequential index (1-based)
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
Sequential feature key (e.g., FEATURE-001)
|
|
58
|
+
|
|
59
|
+
Examples:
|
|
60
|
+
>>> to_sequential_key("000_CONTRACT_FIRST_TEST_MANAGER", 1)
|
|
61
|
+
'FEATURE-001'
|
|
62
|
+
>>> to_sequential_key("FEATURE-CONTRACTFIRSTTESTMANAGER", 5)
|
|
63
|
+
'FEATURE-005'
|
|
64
|
+
"""
|
|
65
|
+
return f"FEATURE-{index:03d}"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@beartype
|
|
69
|
+
def to_classname_key(class_name: str) -> str:
|
|
70
|
+
"""
|
|
71
|
+
Convert class name to feature key format (FEATURE-CLASSNAME).
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
class_name: Class name (e.g., ContractFirstTestManager)
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
Feature key (e.g., FEATURE-CONTRACTFIRSTTESTMANAGER)
|
|
78
|
+
|
|
79
|
+
Examples:
|
|
80
|
+
>>> to_classname_key("ContractFirstTestManager")
|
|
81
|
+
'FEATURE-CONTRACTFIRSTTESTMANAGER'
|
|
82
|
+
>>> to_classname_key("CodeAnalyzer")
|
|
83
|
+
'FEATURE-CODEANALYZER'
|
|
84
|
+
"""
|
|
85
|
+
return f"FEATURE-{class_name.upper()}"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@beartype
|
|
89
|
+
def to_underscore_key(title: str, prefix: str = "000") -> str:
|
|
90
|
+
"""
|
|
91
|
+
Convert feature title to underscore format (000_FEATURE_NAME).
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
title: Feature title (e.g., "Contract First Test Manager")
|
|
95
|
+
prefix: Prefix to use (default: "000")
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
Feature key (e.g., 000_CONTRACT_FIRST_TEST_MANAGER)
|
|
99
|
+
|
|
100
|
+
Examples:
|
|
101
|
+
>>> to_underscore_key("Contract First Test Manager")
|
|
102
|
+
'000_CONTRACT_FIRST_TEST_MANAGER'
|
|
103
|
+
>>> to_underscore_key("User Authentication", "001")
|
|
104
|
+
'001_USER_AUTHENTICATION'
|
|
105
|
+
"""
|
|
106
|
+
# Convert title to uppercase and replace spaces with underscores
|
|
107
|
+
key = title.upper().replace(" ", "_")
|
|
108
|
+
|
|
109
|
+
return f"{prefix}_{key}"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@beartype
|
|
113
|
+
def find_feature_by_normalized_key(features: list, target_key: str) -> dict | None:
|
|
114
|
+
"""
|
|
115
|
+
Find a feature in a list by matching normalized keys.
|
|
116
|
+
|
|
117
|
+
Useful for comparing features across plans with different key formats.
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
features: List of feature dictionaries with 'key' field
|
|
121
|
+
target_key: Target key to find (will be normalized)
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
Feature dictionary if found, None otherwise
|
|
125
|
+
|
|
126
|
+
Examples:
|
|
127
|
+
>>> features = [{"key": "000_CONTRACT_FIRST_TEST_MANAGER", "title": "..."}]
|
|
128
|
+
>>> find_feature_by_normalized_key(features, "FEATURE-CONTRACTFIRSTTESTMANAGER")
|
|
129
|
+
{'key': '000_CONTRACT_FIRST_TEST_MANAGER', 'title': '...'}
|
|
130
|
+
"""
|
|
131
|
+
target_normalized = normalize_feature_key(target_key)
|
|
132
|
+
|
|
133
|
+
for feature in features:
|
|
134
|
+
if "key" not in feature:
|
|
135
|
+
continue
|
|
136
|
+
|
|
137
|
+
feature_normalized = normalize_feature_key(feature["key"])
|
|
138
|
+
if feature_normalized == target_normalized:
|
|
139
|
+
return feature
|
|
140
|
+
|
|
141
|
+
return None
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@beartype
|
|
145
|
+
def convert_feature_keys(features: list, target_format: str = "sequential", start_index: int = 1) -> list:
|
|
146
|
+
"""
|
|
147
|
+
Convert feature keys to a consistent format.
|
|
148
|
+
|
|
149
|
+
Args:
|
|
150
|
+
features: List of feature dictionaries with 'key' field
|
|
151
|
+
target_format: Target format ('sequential', 'classname', or 'underscore')
|
|
152
|
+
start_index: Starting index for sequential format (default: 1)
|
|
153
|
+
|
|
154
|
+
Returns:
|
|
155
|
+
List of features with converted keys
|
|
156
|
+
|
|
157
|
+
Examples:
|
|
158
|
+
>>> features = [{"key": "000_CONTRACT_FIRST_TEST_MANAGER", "title": "Contract First Test Manager"}]
|
|
159
|
+
>>> convert_feature_keys(features, "sequential")
|
|
160
|
+
[{'key': 'FEATURE-001', 'title': 'Contract First Test Manager', ...}]
|
|
161
|
+
"""
|
|
162
|
+
converted = []
|
|
163
|
+
current_index = start_index
|
|
164
|
+
|
|
165
|
+
for feature in features:
|
|
166
|
+
if "key" not in feature:
|
|
167
|
+
continue
|
|
168
|
+
|
|
169
|
+
original_key = feature["key"]
|
|
170
|
+
title = feature.get("title", "")
|
|
171
|
+
|
|
172
|
+
if target_format == "sequential":
|
|
173
|
+
new_key = to_sequential_key(original_key, current_index)
|
|
174
|
+
current_index += 1
|
|
175
|
+
elif target_format == "classname":
|
|
176
|
+
# Extract class name from original key if possible
|
|
177
|
+
class_name = _extract_class_name(original_key, title)
|
|
178
|
+
new_key = to_classname_key(class_name)
|
|
179
|
+
elif target_format == "underscore":
|
|
180
|
+
prefix = str(current_index - 1).zfill(3)
|
|
181
|
+
new_key = to_underscore_key(title, prefix)
|
|
182
|
+
current_index += 1
|
|
183
|
+
else:
|
|
184
|
+
# Keep original key if format not recognized
|
|
185
|
+
new_key = original_key
|
|
186
|
+
|
|
187
|
+
new_feature = feature.copy()
|
|
188
|
+
new_feature["key"] = new_key
|
|
189
|
+
converted.append(new_feature)
|
|
190
|
+
|
|
191
|
+
return converted
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _extract_class_name(key: str, title: str) -> str:
|
|
195
|
+
"""Extract class name from feature key or title."""
|
|
196
|
+
# Try to extract from key first
|
|
197
|
+
if "FEATURE-" in key:
|
|
198
|
+
class_part = key.replace("FEATURE-", "")
|
|
199
|
+
# Convert to PascalCase if needed
|
|
200
|
+
if "_" in class_part or "-" in class_part:
|
|
201
|
+
# Convert underscore/hyphen to PascalCase
|
|
202
|
+
parts = re.split(r"[_-]", class_part.lower())
|
|
203
|
+
return "".join(word.capitalize() for word in parts)
|
|
204
|
+
# Already class-like (uppercase), convert to PascalCase
|
|
205
|
+
return class_part.title()
|
|
206
|
+
|
|
207
|
+
# Fall back to title
|
|
208
|
+
if title:
|
|
209
|
+
# Convert title to PascalCase class name
|
|
210
|
+
parts = re.split(r"[_\s-]", title)
|
|
211
|
+
return "".join(word.capitalize() for word in parts)
|
|
212
|
+
|
|
213
|
+
return "UnknownClass"
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Git operations utilities.
|
|
3
|
+
|
|
4
|
+
This module provides helpers for common Git operations used by the CLI.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import git
|
|
13
|
+
from beartype import beartype
|
|
14
|
+
from git import Repo
|
|
15
|
+
from icontract import ensure, require
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class GitOperations:
|
|
19
|
+
"""Helper class for Git operations."""
|
|
20
|
+
|
|
21
|
+
@beartype
|
|
22
|
+
@require(lambda repo_path: isinstance(repo_path, (Path, str)), "Repo path must be Path or str")
|
|
23
|
+
def __init__(self, repo_path: Path | str = ".") -> None:
|
|
24
|
+
"""
|
|
25
|
+
Initialize Git operations.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
repo_path: Path to the Git repository
|
|
29
|
+
"""
|
|
30
|
+
self.repo_path = Path(repo_path)
|
|
31
|
+
self.repo: Repo | None = None
|
|
32
|
+
|
|
33
|
+
if self._is_git_repo():
|
|
34
|
+
self.repo = Repo(self.repo_path)
|
|
35
|
+
|
|
36
|
+
def _is_git_repo(self) -> bool:
|
|
37
|
+
"""
|
|
38
|
+
Check if path is a Git repository.
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
True if path is a Git repository
|
|
42
|
+
"""
|
|
43
|
+
try:
|
|
44
|
+
_ = Repo(self.repo_path)
|
|
45
|
+
return True
|
|
46
|
+
except git.exc.InvalidGitRepositoryError:
|
|
47
|
+
return False
|
|
48
|
+
|
|
49
|
+
def init(self) -> None:
|
|
50
|
+
"""Initialize a new Git repository."""
|
|
51
|
+
self.repo = Repo.init(self.repo_path)
|
|
52
|
+
|
|
53
|
+
@beartype
|
|
54
|
+
@require(
|
|
55
|
+
lambda branch_name: isinstance(branch_name, str) and len(branch_name) > 0,
|
|
56
|
+
"Branch name must be non-empty string",
|
|
57
|
+
)
|
|
58
|
+
@require(lambda self: self.repo is not None, "Git repository must be initialized")
|
|
59
|
+
def create_branch(self, branch_name: str, checkout: bool = True) -> None:
|
|
60
|
+
"""
|
|
61
|
+
Create a new branch.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
branch_name: Name of the new branch
|
|
65
|
+
checkout: Whether to checkout the new branch
|
|
66
|
+
|
|
67
|
+
Raises:
|
|
68
|
+
ValueError: If repository is not initialized
|
|
69
|
+
"""
|
|
70
|
+
if self.repo is None:
|
|
71
|
+
raise ValueError("Git repository not initialized")
|
|
72
|
+
|
|
73
|
+
new_branch = self.repo.create_head(branch_name)
|
|
74
|
+
if checkout:
|
|
75
|
+
new_branch.checkout()
|
|
76
|
+
|
|
77
|
+
@beartype
|
|
78
|
+
@require(
|
|
79
|
+
lambda branch_name: isinstance(branch_name, str) and len(branch_name) > 0,
|
|
80
|
+
"Branch name must be non-empty string",
|
|
81
|
+
)
|
|
82
|
+
@require(lambda self: self.repo is not None, "Git repository must be initialized")
|
|
83
|
+
def checkout(self, branch_name: str) -> None:
|
|
84
|
+
"""
|
|
85
|
+
Checkout an existing branch.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
branch_name: Name of the branch to checkout
|
|
89
|
+
|
|
90
|
+
Raises:
|
|
91
|
+
ValueError: If repository is not initialized
|
|
92
|
+
"""
|
|
93
|
+
if self.repo is None:
|
|
94
|
+
raise ValueError("Git repository not initialized")
|
|
95
|
+
|
|
96
|
+
self.repo.heads[branch_name].checkout()
|
|
97
|
+
|
|
98
|
+
@beartype
|
|
99
|
+
@require(lambda files: isinstance(files, (list, Path, str)), "Files must be list, Path, or str")
|
|
100
|
+
@require(lambda self: self.repo is not None, "Git repository must be initialized")
|
|
101
|
+
def add(self, files: list[Path | str] | Path | str) -> None:
|
|
102
|
+
"""
|
|
103
|
+
Add files to the staging area.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
files: File(s) to add
|
|
107
|
+
|
|
108
|
+
Raises:
|
|
109
|
+
ValueError: If repository is not initialized
|
|
110
|
+
"""
|
|
111
|
+
if self.repo is None:
|
|
112
|
+
raise ValueError("Git repository not initialized")
|
|
113
|
+
|
|
114
|
+
if isinstance(files, (Path, str)):
|
|
115
|
+
files = [files]
|
|
116
|
+
|
|
117
|
+
for file_path in files:
|
|
118
|
+
self.repo.index.add([str(file_path)])
|
|
119
|
+
|
|
120
|
+
@beartype
|
|
121
|
+
@require(lambda message: isinstance(message, str) and len(message) > 0, "Commit message must be non-empty string")
|
|
122
|
+
@require(lambda self: self.repo is not None, "Git repository must be initialized")
|
|
123
|
+
@ensure(lambda result: result is not None, "Must return commit object")
|
|
124
|
+
def commit(self, message: str) -> Any:
|
|
125
|
+
"""
|
|
126
|
+
Commit staged changes.
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
message: Commit message
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
Commit object
|
|
133
|
+
|
|
134
|
+
Raises:
|
|
135
|
+
ValueError: If repository is not initialized
|
|
136
|
+
"""
|
|
137
|
+
if self.repo is None:
|
|
138
|
+
raise ValueError("Git repository not initialized")
|
|
139
|
+
|
|
140
|
+
return self.repo.index.commit(message)
|
|
141
|
+
|
|
142
|
+
@beartype
|
|
143
|
+
@require(lambda remote: isinstance(remote, str) and len(remote) > 0, "Remote name must be non-empty string")
|
|
144
|
+
@require(
|
|
145
|
+
lambda branch: branch is None or (isinstance(branch, str) and len(branch) > 0),
|
|
146
|
+
"Branch name must be None or non-empty string",
|
|
147
|
+
)
|
|
148
|
+
@require(lambda self: self.repo is not None, "Git repository must be initialized")
|
|
149
|
+
def push(self, remote: str = "origin", branch: str | None = None) -> None:
|
|
150
|
+
"""
|
|
151
|
+
Push commits to remote repository.
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
remote: Remote name (default: origin)
|
|
155
|
+
branch: Branch name (default: current branch)
|
|
156
|
+
|
|
157
|
+
Raises:
|
|
158
|
+
ValueError: If repository is not initialized
|
|
159
|
+
"""
|
|
160
|
+
if self.repo is None:
|
|
161
|
+
raise ValueError("Git repository not initialized")
|
|
162
|
+
|
|
163
|
+
if branch is None:
|
|
164
|
+
branch = self.repo.active_branch.name
|
|
165
|
+
|
|
166
|
+
origin = self.repo.remote(name=remote)
|
|
167
|
+
origin.push(branch)
|
|
168
|
+
|
|
169
|
+
@beartype
|
|
170
|
+
@require(lambda self: self.repo is not None, "Git repository must be initialized")
|
|
171
|
+
@ensure(lambda result: isinstance(result, str) and len(result) > 0, "Must return non-empty branch name")
|
|
172
|
+
def get_current_branch(self) -> str:
|
|
173
|
+
"""
|
|
174
|
+
Get the name of the current branch.
|
|
175
|
+
|
|
176
|
+
Returns:
|
|
177
|
+
Current branch name
|
|
178
|
+
|
|
179
|
+
Raises:
|
|
180
|
+
ValueError: If repository is not initialized
|
|
181
|
+
"""
|
|
182
|
+
if self.repo is None:
|
|
183
|
+
raise ValueError("Git repository not initialized")
|
|
184
|
+
|
|
185
|
+
return self.repo.active_branch.name
|
|
186
|
+
|
|
187
|
+
@beartype
|
|
188
|
+
@require(lambda self: self.repo is not None, "Git repository must be initialized")
|
|
189
|
+
@ensure(lambda result: isinstance(result, list), "Must return list")
|
|
190
|
+
@ensure(lambda result: all(isinstance(b, str) for b in result), "All items must be strings")
|
|
191
|
+
def list_branches(self) -> list[str]:
|
|
192
|
+
"""
|
|
193
|
+
List all branches.
|
|
194
|
+
|
|
195
|
+
Returns:
|
|
196
|
+
List of branch names
|
|
197
|
+
|
|
198
|
+
Raises:
|
|
199
|
+
ValueError: If repository is not initialized
|
|
200
|
+
"""
|
|
201
|
+
if self.repo is None:
|
|
202
|
+
raise ValueError("Git repository not initialized")
|
|
203
|
+
|
|
204
|
+
return [str(head) for head in self.repo.heads]
|
|
205
|
+
|
|
206
|
+
@beartype
|
|
207
|
+
@require(lambda self: self.repo is not None, "Git repository must be initialized")
|
|
208
|
+
@ensure(lambda result: isinstance(result, bool), "Must return boolean")
|
|
209
|
+
def is_clean(self) -> bool:
|
|
210
|
+
"""
|
|
211
|
+
Check if the working directory is clean.
|
|
212
|
+
|
|
213
|
+
Returns:
|
|
214
|
+
True if working directory is clean
|
|
215
|
+
|
|
216
|
+
Raises:
|
|
217
|
+
ValueError: If repository is not initialized
|
|
218
|
+
"""
|
|
219
|
+
if self.repo is None:
|
|
220
|
+
raise ValueError("Git repository not initialized")
|
|
221
|
+
|
|
222
|
+
return not self.repo.is_dirty()
|
|
223
|
+
|
|
224
|
+
@beartype
|
|
225
|
+
@require(lambda self: self.repo is not None, "Git repository must be initialized")
|
|
226
|
+
@ensure(lambda result: isinstance(result, list), "Must return list")
|
|
227
|
+
@ensure(lambda result: all(isinstance(f, str) for f in result), "All items must be strings")
|
|
228
|
+
def get_changed_files(self) -> list[str]:
|
|
229
|
+
"""
|
|
230
|
+
Get list of changed files.
|
|
231
|
+
|
|
232
|
+
Returns:
|
|
233
|
+
List of changed file paths
|
|
234
|
+
|
|
235
|
+
Raises:
|
|
236
|
+
ValueError: If repository is not initialized
|
|
237
|
+
"""
|
|
238
|
+
if self.repo is None:
|
|
239
|
+
raise ValueError("Git repository not initialized")
|
|
240
|
+
|
|
241
|
+
return [item.a_path for item in self.repo.index.diff(None) if item.a_path is not None]
|