k-cli-for-devs 1.0.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.
- k_cli/__init__.py +77 -0
- k_cli/agents/__init__.py +0 -0
- k_cli/agents/adversarial_swarm.py +338 -0
- k_cli/agents/agent_core.py +255 -0
- k_cli/agents/background_daemon.py +141 -0
- k_cli/agents/orchestrator.py +376 -0
- k_cli/agents/persona.py +649 -0
- k_cli/agents/scaffold_engine.py +121 -0
- k_cli/agents/strands_agent.py +832 -0
- k_cli/agents/subagents.py +1496 -0
- k_cli/cli.py +3297 -0
- k_cli/core/__init__.py +0 -0
- k_cli/core/airgap.py +95 -0
- k_cli/core/credentials.py +548 -0
- k_cli/core/intent_sensor.py +177 -0
- k_cli/core/llm_driver.py +1028 -0
- k_cli/core/model_manager.py +1109 -0
- k_cli/core/models_hub.py +913 -0
- k_cli/core/prompting.py +41 -0
- k_cli/core/sdk.py +322 -0
- k_cli/core/session.py +826 -0
- k_cli/core/smart_router.py +230 -0
- k_cli/core/storage_manager.py +176 -0
- k_cli/core/viewport_engine.py +117 -0
- k_cli/demo/demo_runner.py +579 -0
- k_cli/git/__init__.py +0 -0
- k_cli/git/ai_bisect.py +208 -0
- k_cli/git/conflict_resolver.py +1039 -0
- k_cli/git/git_guard.py +417 -0
- k_cli/git/patcher.py +1175 -0
- k_cli/git/repo_map.py +1780 -0
- k_cli/git/smart_git.py +928 -0
- k_cli/git/verifier.py +969 -0
- k_cli/github/__init__.py +0 -0
- k_cli/github/dedup_engine.py +787 -0
- k_cli/github/github_client.py +1702 -0
- k_cli/github/github_engine.py +641 -0
- k_cli/github/local_hub.py +209 -0
- k_cli/github/pr_watcher.py +129 -0
- k_cli/github/trending.py +205 -0
- k_cli/tools/__init__.py +0 -0
- k_cli/tools/audit.py +79 -0
- k_cli/tools/chaos_immunity.py +377 -0
- k_cli/tools/codebase_qa.py +106 -0
- k_cli/tools/command_runner.py +256 -0
- k_cli/tools/diagram_generator.py +547 -0
- k_cli/tools/doc_retriever.py +1332 -0
- k_cli/tools/feature.py +105 -0
- k_cli/tools/ghost_daemon.py +122 -0
- k_cli/tools/incident_triage.py +1365 -0
- k_cli/tools/mcp_client.py +1846 -0
- k_cli/tools/repo_gardener.py +142 -0
- k_cli/tools/rules.py +109 -0
- k_cli/tools/security.py +52 -0
- k_cli/tools/security_healer.py +999 -0
- k_cli/tools/synapse_graph.py +155 -0
- k_cli/tui/__init__.py +0 -0
- k_cli/tui/diff_viewer.py +223 -0
- k_cli/tui/tui.py +1145 -0
- k_cli/tui/tui_animations.py +648 -0
- k_cli/tui/tui_app.py +2788 -0
- k_cli/ui/__init__.py +10 -0
- k_cli/ui/simple_repl.py +315 -0
- k_cli/web/__init__.py +7 -0
- k_cli/web/server.py +624 -0
- k_cli/web/static/app.js +830 -0
- k_cli/web/static/index.html +495 -0
- k_cli/web/static/monitor.html +189 -0
- k_cli/web/static/style.css +838 -0
- k_cli_for_devs-1.0.0.dist-info/METADATA +461 -0
- k_cli_for_devs-1.0.0.dist-info/RECORD +75 -0
- k_cli_for_devs-1.0.0.dist-info/WHEEL +5 -0
- k_cli_for_devs-1.0.0.dist-info/entry_points.txt +2 -0
- k_cli_for_devs-1.0.0.dist-info/licenses/LICENSE +21 -0
- k_cli_for_devs-1.0.0.dist-info/top_level.txt +1 -0
k_cli/git/git_guard.py
ADDED
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
"""
|
|
2
|
+
git_guard.py - Git Safety Net, Automated Checkpoints & Rollback for K-CLI
|
|
3
|
+
|
|
4
|
+
Provides repository auto-initialization, shadow git checkpoints before patch application,
|
|
5
|
+
atomic commits on verified success, instant working-tree rollback on verification failure,
|
|
6
|
+
and interactive user confirmation ([Apply], [Reject], [Diff], [Auto-Fix]).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import subprocess
|
|
11
|
+
import time
|
|
12
|
+
import uuid
|
|
13
|
+
from enum import Enum
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any, Callable, Dict, List, Optional, Tuple, Union
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class PatchConfirmationAction(str, Enum):
|
|
19
|
+
"""Actions available for interactive user patch confirmation."""
|
|
20
|
+
APPLY = "APPLY"
|
|
21
|
+
REJECT = "REJECT"
|
|
22
|
+
DIFF = "DIFF"
|
|
23
|
+
AUTO_FIX = "AUTO_FIX"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class GitGuard:
|
|
27
|
+
"""
|
|
28
|
+
Git safety manager providing atomic commits, diff tracking, shadow checkpoints,
|
|
29
|
+
automatic rollback for code modification workflows, and interactive patch review.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(self, repo_dir: Union[str, Path] = "."):
|
|
33
|
+
"""
|
|
34
|
+
Initializes GitGuard targeting a repository workspace directory.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
repo_dir: Path to the workspace / repository root directory.
|
|
38
|
+
"""
|
|
39
|
+
self.repo_dir = Path(repo_dir).resolve()
|
|
40
|
+
self._last_snapshot: Optional[str] = None
|
|
41
|
+
self._last_checkpoint: Optional[str] = None
|
|
42
|
+
self._checkpoints: Dict[str, Dict[str, Any]] = {}
|
|
43
|
+
|
|
44
|
+
def _run_git(self, args: List[str]) -> subprocess.CompletedProcess:
|
|
45
|
+
"""Helper to run git commands in the repository directory with default identity fallback."""
|
|
46
|
+
env = dict(os.environ)
|
|
47
|
+
env.setdefault("GIT_AUTHOR_NAME", "K-CLI")
|
|
48
|
+
env.setdefault("GIT_AUTHOR_EMAIL", "k-cli@local")
|
|
49
|
+
env.setdefault("GIT_COMMITTER_NAME", "K-CLI")
|
|
50
|
+
env.setdefault("GIT_COMMITTER_EMAIL", "k-cli@local")
|
|
51
|
+
|
|
52
|
+
return subprocess.run(
|
|
53
|
+
["git"] + args,
|
|
54
|
+
cwd=str(self.repo_dir),
|
|
55
|
+
capture_output=True,
|
|
56
|
+
text=True,
|
|
57
|
+
env=env,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
def is_git_repo(self) -> bool:
|
|
61
|
+
"""
|
|
62
|
+
Checks whether the workspace directory is inside a valid git repository.
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
True if valid git repository, False otherwise.
|
|
66
|
+
"""
|
|
67
|
+
if not self.repo_dir.exists() or not self.repo_dir.is_dir():
|
|
68
|
+
return False
|
|
69
|
+
|
|
70
|
+
res = self._run_git(["rev-parse", "--is-inside-work-tree"])
|
|
71
|
+
return res.returncode == 0 and res.stdout.strip() == "true"
|
|
72
|
+
|
|
73
|
+
def ensure_repo(self) -> bool:
|
|
74
|
+
"""
|
|
75
|
+
Ensures the workspace is a valid git repository, running `git init` if necessary.
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
True if repository is ready, False on failure.
|
|
79
|
+
"""
|
|
80
|
+
if self.is_git_repo():
|
|
81
|
+
return True
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
self.repo_dir.mkdir(parents=True, exist_ok=True)
|
|
85
|
+
res = self._run_git(["init"])
|
|
86
|
+
if res.returncode != 0:
|
|
87
|
+
return False
|
|
88
|
+
|
|
89
|
+
# Configure local identity defaults if not already set
|
|
90
|
+
self._run_git(["config", "user.name", "K-CLI"])
|
|
91
|
+
self._run_git(["config", "user.email", "k-cli@local"])
|
|
92
|
+
return True
|
|
93
|
+
except Exception:
|
|
94
|
+
return False
|
|
95
|
+
|
|
96
|
+
def create_checkpoint(
|
|
97
|
+
self,
|
|
98
|
+
name: Optional[str] = None,
|
|
99
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
100
|
+
) -> str:
|
|
101
|
+
"""
|
|
102
|
+
Auto-creates a shadow git checkpoint before applying any surgical patch.
|
|
103
|
+
Captures HEAD SHA, working tree diffs, staged changes, and untracked files.
|
|
104
|
+
Registers a shadow ref under `refs/kcli/checkpoints/<id>`.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
name: Optional custom checkpoint name or prefix.
|
|
108
|
+
metadata: Optional additional metadata dictionary to store with the checkpoint.
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
Checkpoint identifier string, or empty string in non-git environment.
|
|
112
|
+
"""
|
|
113
|
+
if not self.is_git_repo():
|
|
114
|
+
return ""
|
|
115
|
+
|
|
116
|
+
res_head = self._run_git(["rev-parse", "HEAD"])
|
|
117
|
+
head_sha = res_head.stdout.strip() if res_head.returncode == 0 else "EMPTY_REPO"
|
|
118
|
+
|
|
119
|
+
ts = int(time.time())
|
|
120
|
+
short_id = uuid.uuid4().hex[:8]
|
|
121
|
+
prefix = f"ckpt_{name}" if name else "ckpt"
|
|
122
|
+
checkpoint_id = f"{prefix}_{ts}_{short_id}"
|
|
123
|
+
|
|
124
|
+
# Capture current working tree state
|
|
125
|
+
res_status = self._run_git(["status", "--porcelain"])
|
|
126
|
+
staged_diff = self.get_diff(cached=True)
|
|
127
|
+
unstaged_diff = self.get_diff(cached=False)
|
|
128
|
+
untracked = self.get_untracked_files()
|
|
129
|
+
|
|
130
|
+
shadow_ref = None
|
|
131
|
+
if head_sha != "EMPTY_REPO":
|
|
132
|
+
shadow_ref = f"refs/kcli/checkpoints/{checkpoint_id}"
|
|
133
|
+
# Update shadow ref to point to current HEAD commit
|
|
134
|
+
self._run_git(["update-ref", shadow_ref, head_sha])
|
|
135
|
+
|
|
136
|
+
record: Dict[str, Any] = {
|
|
137
|
+
"checkpoint_id": checkpoint_id,
|
|
138
|
+
"head_sha": head_sha,
|
|
139
|
+
"shadow_ref": shadow_ref,
|
|
140
|
+
"timestamp": ts,
|
|
141
|
+
"status_output": res_status.stdout if res_status.returncode == 0 else "",
|
|
142
|
+
"staged_diff": staged_diff,
|
|
143
|
+
"unstaged_diff": unstaged_diff,
|
|
144
|
+
"untracked_files": untracked,
|
|
145
|
+
"metadata": metadata or {},
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
self._checkpoints[checkpoint_id] = record
|
|
149
|
+
self._last_checkpoint = checkpoint_id
|
|
150
|
+
self._last_snapshot = checkpoint_id
|
|
151
|
+
return checkpoint_id
|
|
152
|
+
|
|
153
|
+
def create_snapshot(self) -> str:
|
|
154
|
+
"""
|
|
155
|
+
Captures a snapshot token representing the current git HEAD and workspace status.
|
|
156
|
+
Maintains backward compatibility while also registering a shadow checkpoint.
|
|
157
|
+
|
|
158
|
+
Returns:
|
|
159
|
+
Snapshot identifier string, or empty string in non-git environment.
|
|
160
|
+
"""
|
|
161
|
+
if not self.is_git_repo():
|
|
162
|
+
return ""
|
|
163
|
+
|
|
164
|
+
res_head = self._run_git(["rev-parse", "HEAD"])
|
|
165
|
+
head_sha = res_head.stdout.strip() if res_head.returncode == 0 else "EMPTY_REPO"
|
|
166
|
+
|
|
167
|
+
res_status = self._run_git(["status", "--porcelain"])
|
|
168
|
+
status_hash = str(hash(res_status.stdout))
|
|
169
|
+
|
|
170
|
+
snapshot_id = f"snapshot_{head_sha[:10]}_{status_hash[:8]}"
|
|
171
|
+
self._last_snapshot = snapshot_id
|
|
172
|
+
# Also record checkpoint
|
|
173
|
+
self.create_checkpoint(name=f"snap_{head_sha[:8]}")
|
|
174
|
+
return snapshot_id
|
|
175
|
+
|
|
176
|
+
def restore_checkpoint(
|
|
177
|
+
self,
|
|
178
|
+
checkpoint_id: Optional[str] = None,
|
|
179
|
+
hard_reset: bool = True,
|
|
180
|
+
) -> bool:
|
|
181
|
+
"""
|
|
182
|
+
Restores workspace to the exact state of a specified shadow checkpoint.
|
|
183
|
+
|
|
184
|
+
Args:
|
|
185
|
+
checkpoint_id: Identifier of the checkpoint to restore. Defaults to the latest checkpoint.
|
|
186
|
+
hard_reset: If True, resets HEAD commit if new commits were made post-checkpoint.
|
|
187
|
+
|
|
188
|
+
Returns:
|
|
189
|
+
True if restoration succeeded, False otherwise.
|
|
190
|
+
"""
|
|
191
|
+
if not self.is_git_repo():
|
|
192
|
+
return False
|
|
193
|
+
|
|
194
|
+
target_id = checkpoint_id or self._last_checkpoint
|
|
195
|
+
ckpt = self._checkpoints.get(target_id) if target_id else None
|
|
196
|
+
|
|
197
|
+
try:
|
|
198
|
+
if ckpt and ckpt.get("head_sha") and ckpt["head_sha"] != "EMPTY_REPO":
|
|
199
|
+
head_sha = ckpt["head_sha"]
|
|
200
|
+
curr_head = self._run_git(["rev-parse", "HEAD"]).stdout.strip()
|
|
201
|
+
if hard_reset and curr_head != head_sha:
|
|
202
|
+
self._run_git(["reset", "--hard", head_sha])
|
|
203
|
+
else:
|
|
204
|
+
self._run_git(["reset", "HEAD"])
|
|
205
|
+
self._run_git(["restore", "."])
|
|
206
|
+
else:
|
|
207
|
+
self._run_git(["reset", "HEAD"])
|
|
208
|
+
self._run_git(["restore", "."])
|
|
209
|
+
|
|
210
|
+
# Remove untracked files and directories created after checkpoint
|
|
211
|
+
self._run_git(["clean", "-fd"])
|
|
212
|
+
return True
|
|
213
|
+
except Exception:
|
|
214
|
+
return False
|
|
215
|
+
|
|
216
|
+
def list_checkpoints(self) -> List[str]:
|
|
217
|
+
"""Returns list of created checkpoint IDs in chronological order."""
|
|
218
|
+
return list(self._checkpoints.keys())
|
|
219
|
+
|
|
220
|
+
def get_checkpoint(self, checkpoint_id: str) -> Optional[Dict[str, Any]]:
|
|
221
|
+
"""Returns checkpoint record dictionary or None if not found."""
|
|
222
|
+
return self._checkpoints.get(checkpoint_id)
|
|
223
|
+
|
|
224
|
+
def delete_checkpoint(self, checkpoint_id: str) -> bool:
|
|
225
|
+
"""Deletes a shadow checkpoint and cleans its ref."""
|
|
226
|
+
if checkpoint_id in self._checkpoints:
|
|
227
|
+
ckpt = self._checkpoints.pop(checkpoint_id)
|
|
228
|
+
if ckpt.get("shadow_ref"):
|
|
229
|
+
self._run_git(["update-ref", "-d", ckpt["shadow_ref"]])
|
|
230
|
+
if self._last_checkpoint == checkpoint_id:
|
|
231
|
+
self._last_checkpoint = list(self._checkpoints.keys())[-1] if self._checkpoints else None
|
|
232
|
+
return True
|
|
233
|
+
return False
|
|
234
|
+
|
|
235
|
+
def commit_success(
|
|
236
|
+
self,
|
|
237
|
+
message: str,
|
|
238
|
+
files: Optional[List[str]] = None,
|
|
239
|
+
) -> Optional[str]:
|
|
240
|
+
"""
|
|
241
|
+
Creates an atomic git commit with a semantic commit message on verified success.
|
|
242
|
+
|
|
243
|
+
Args:
|
|
244
|
+
message: Commit message (e.g. 'feat: implement quicksort').
|
|
245
|
+
files: Specific file paths to stage and commit. If None, stages all changes (`-A`).
|
|
246
|
+
|
|
247
|
+
Returns:
|
|
248
|
+
New commit SHA if committed, or current HEAD SHA if clean, or None on failure.
|
|
249
|
+
"""
|
|
250
|
+
if not self.is_git_repo():
|
|
251
|
+
return None
|
|
252
|
+
|
|
253
|
+
# Stage files
|
|
254
|
+
if files:
|
|
255
|
+
for f in files:
|
|
256
|
+
add_res = self._run_git(["add", str(f)])
|
|
257
|
+
if add_res.returncode != 0:
|
|
258
|
+
# File might have been deleted, try git rm / add -u
|
|
259
|
+
self._run_git(["add", "-u", str(f)])
|
|
260
|
+
else:
|
|
261
|
+
self._run_git(["add", "-A"])
|
|
262
|
+
|
|
263
|
+
# Check if anything is staged
|
|
264
|
+
diff_cached = self._run_git(["diff", "--cached", "--quiet"])
|
|
265
|
+
if diff_cached.returncode == 0:
|
|
266
|
+
# Nothing staged to commit; return current HEAD SHA if it exists
|
|
267
|
+
head_res = self._run_git(["rev-parse", "HEAD"])
|
|
268
|
+
return head_res.stdout.strip() if head_res.returncode == 0 else None
|
|
269
|
+
|
|
270
|
+
# Commit staged changes
|
|
271
|
+
commit_res = self._run_git(["commit", "-m", message])
|
|
272
|
+
if commit_res.returncode != 0:
|
|
273
|
+
return None
|
|
274
|
+
|
|
275
|
+
# Retrieve and return new commit SHA
|
|
276
|
+
head_res = self._run_git(["rev-parse", "HEAD"])
|
|
277
|
+
return head_res.stdout.strip() if head_res.returncode == 0 else None
|
|
278
|
+
|
|
279
|
+
def rollback(
|
|
280
|
+
self,
|
|
281
|
+
files: Optional[List[str]] = None,
|
|
282
|
+
checkpoint_id: Optional[str] = None,
|
|
283
|
+
) -> bool:
|
|
284
|
+
"""
|
|
285
|
+
Reverts working tree modifications and unstaged changes on verification failure.
|
|
286
|
+
If checkpoint_id is provided, rolls back to that shadow checkpoint.
|
|
287
|
+
|
|
288
|
+
Args:
|
|
289
|
+
files: Specific list of files to restore. If None, restores entire working tree.
|
|
290
|
+
checkpoint_id: Optional checkpoint ID to restore to.
|
|
291
|
+
|
|
292
|
+
Returns:
|
|
293
|
+
True if rollback succeeded, False if not a git repository or on error.
|
|
294
|
+
"""
|
|
295
|
+
if not self.is_git_repo():
|
|
296
|
+
return False
|
|
297
|
+
|
|
298
|
+
if checkpoint_id:
|
|
299
|
+
return self.restore_checkpoint(checkpoint_id)
|
|
300
|
+
|
|
301
|
+
try:
|
|
302
|
+
if files:
|
|
303
|
+
for f in files:
|
|
304
|
+
# Unstage file if staged
|
|
305
|
+
self._run_git(["restore", "--staged", str(f)])
|
|
306
|
+
# Discard modifications in working tree
|
|
307
|
+
self._run_git(["restore", str(f)])
|
|
308
|
+
# If untracked new file, clean it
|
|
309
|
+
self._run_git(["clean", "-f", str(f)])
|
|
310
|
+
else:
|
|
311
|
+
# Unstage all staged changes
|
|
312
|
+
self._run_git(["reset", "HEAD"])
|
|
313
|
+
# Discard modifications in working tree
|
|
314
|
+
self._run_git(["restore", "."])
|
|
315
|
+
# Remove untracked files and directories
|
|
316
|
+
self._run_git(["clean", "-fd"])
|
|
317
|
+
|
|
318
|
+
return True
|
|
319
|
+
except Exception:
|
|
320
|
+
return False
|
|
321
|
+
|
|
322
|
+
def get_diff(self, cached: bool = False, files: Optional[List[str]] = None) -> str:
|
|
323
|
+
"""
|
|
324
|
+
Returns active git diff for the repository.
|
|
325
|
+
|
|
326
|
+
Args:
|
|
327
|
+
cached: If True, returns diff of staged changes (`--cached`).
|
|
328
|
+
If False, returns diff of unstaged working tree changes.
|
|
329
|
+
files: Optional specific files to limit diff to.
|
|
330
|
+
|
|
331
|
+
Returns:
|
|
332
|
+
Diff output string, or empty string if clean or non-git environment.
|
|
333
|
+
"""
|
|
334
|
+
if not self.is_git_repo():
|
|
335
|
+
return ""
|
|
336
|
+
|
|
337
|
+
args = ["diff", "--cached"] if cached else ["diff"]
|
|
338
|
+
if files:
|
|
339
|
+
args.append("--")
|
|
340
|
+
args.extend([str(f) for f in files])
|
|
341
|
+
|
|
342
|
+
res = self._run_git(args)
|
|
343
|
+
return res.stdout if res.returncode == 0 else ""
|
|
344
|
+
|
|
345
|
+
def get_untracked_files(self) -> List[str]:
|
|
346
|
+
"""Returns list of untracked files in the repository."""
|
|
347
|
+
if not self.is_git_repo():
|
|
348
|
+
return []
|
|
349
|
+
res = self._run_git(["ls-files", "--others", "--exclude-standard"])
|
|
350
|
+
if res.returncode == 0 and res.stdout.strip():
|
|
351
|
+
return [line.strip() for line in res.stdout.splitlines() if line.strip()]
|
|
352
|
+
return []
|
|
353
|
+
|
|
354
|
+
def has_uncommitted_changes(self) -> bool:
|
|
355
|
+
"""Returns True if there are unstaged, staged, or untracked changes in the working tree."""
|
|
356
|
+
if not self.is_git_repo():
|
|
357
|
+
return False
|
|
358
|
+
diff = self.get_diff(cached=False)
|
|
359
|
+
cached_diff = self.get_diff(cached=True)
|
|
360
|
+
untracked = self.get_untracked_files()
|
|
361
|
+
return bool(diff.strip() or cached_diff.strip() or untracked)
|
|
362
|
+
|
|
363
|
+
def prompt_confirmation(
|
|
364
|
+
self,
|
|
365
|
+
diff_text: Optional[str] = None,
|
|
366
|
+
input_fn: Optional[Callable[[str], str]] = None,
|
|
367
|
+
display_fn: Optional[Callable[[str], None]] = None,
|
|
368
|
+
prompt_text: Optional[str] = None,
|
|
369
|
+
) -> PatchConfirmationAction:
|
|
370
|
+
"""
|
|
371
|
+
Interactive user confirmation prompting with [Apply], [Reject], [Diff], [Auto-Fix].
|
|
372
|
+
|
|
373
|
+
Args:
|
|
374
|
+
diff_text: Optional git diff string to display when [Diff] is selected.
|
|
375
|
+
input_fn: Callable for getting user input (defaults to builtin `input`).
|
|
376
|
+
display_fn: Callable for displaying messages (defaults to builtin `print`).
|
|
377
|
+
prompt_text: Custom prompt message.
|
|
378
|
+
|
|
379
|
+
Returns:
|
|
380
|
+
Selected PatchConfirmationAction (APPLY, REJECT, DIFF, or AUTO_FIX).
|
|
381
|
+
"""
|
|
382
|
+
_input = input_fn or input
|
|
383
|
+
_display = display_fn or print
|
|
384
|
+
active_diff = diff_text if diff_text is not None else self.get_diff()
|
|
385
|
+
|
|
386
|
+
msg = prompt_text or "Proposed changes ready. Options: [Apply] (a), [Reject] (r), [Diff] (d), [Auto-Fix] (f)"
|
|
387
|
+
|
|
388
|
+
while True:
|
|
389
|
+
try:
|
|
390
|
+
choice = _input(f"{msg}\nSelect action: ").strip().lower()
|
|
391
|
+
except (EOFError, KeyboardInterrupt):
|
|
392
|
+
return PatchConfirmationAction.REJECT
|
|
393
|
+
|
|
394
|
+
if choice in ("apply", "a", "1", "[apply]", "y", "yes"):
|
|
395
|
+
return PatchConfirmationAction.APPLY
|
|
396
|
+
elif choice in ("reject", "r", "2", "[reject]", "n", "no", "cancel"):
|
|
397
|
+
return PatchConfirmationAction.REJECT
|
|
398
|
+
elif choice in ("diff", "d", "3", "[diff]"):
|
|
399
|
+
if active_diff.strip():
|
|
400
|
+
_display(f"\n--- Proposed Diff ---\n{active_diff}\n---------------------")
|
|
401
|
+
else:
|
|
402
|
+
_display("\n[Diff]: No changes detected in working tree.")
|
|
403
|
+
continue
|
|
404
|
+
elif choice in ("auto-fix", "autofix", "fix", "f", "4", "[auto-fix]"):
|
|
405
|
+
return PatchConfirmationAction.AUTO_FIX
|
|
406
|
+
else:
|
|
407
|
+
_display(f"Invalid option '{choice}'. Please choose [Apply], [Reject], [Diff], or [Auto-Fix].")
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def confirm_patch_action(
|
|
411
|
+
diff_text: Optional[str] = None,
|
|
412
|
+
input_fn: Optional[Callable[[str], str]] = None,
|
|
413
|
+
display_fn: Optional[Callable[[str], None]] = None,
|
|
414
|
+
) -> PatchConfirmationAction:
|
|
415
|
+
"""Helper shortcut function for interactive patch confirmation."""
|
|
416
|
+
guard = GitGuard()
|
|
417
|
+
return guard.prompt_confirmation(diff_text=diff_text, input_fn=input_fn, display_fn=display_fn)
|