-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathwriter.py
More file actions
293 lines (235 loc) · 8.96 KB
/
Copy pathwriter.py
File metadata and controls
293 lines (235 loc) · 8.96 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
"""File writing, backup, and diff operations for refactoring.
This module handles the safe mutation of source files: creating backups,
computing diffs for user review, and writing approved changes. It is the
only module that performs destructive file operations.
"""
import difflib
import shutil
import subprocess
from dataclasses import dataclass, field
from datetime import UTC, datetime
from pathlib import Path
from pyagent.logging import get_logger
logger = get_logger(__name__)
# Backups live under ``<root>/.pyagent/backups/<relative-path>/`` so every
# artifact pyagent writes — plans, batches, backups — is consolidated in one
# gitignorable directory. The legacy per-file ``.pyagent_backup/`` layout is
# no longer written but is still checked by ``restore_backup`` so users who
# upgrade can still recover from older backups.
_PYAGENT_DIR_NAME = ".pyagent"
_BACKUPS_SUBDIR = "backups"
_LEGACY_BACKUP_DIR_NAME = ".pyagent_backup"
@dataclass(frozen=True)
class FileChange:
"""A proposed change to a single file.
Attributes:
path: Absolute path to the target file.
original: The original file content.
refactored: The proposed new content.
explanation: Human-readable explanation of what changed and why.
"""
path: Path
original: str
refactored: str
explanation: str
@property
def has_changes(self) -> bool:
"""Return True if the refactored content differs from the original."""
return self.original != self.refactored
@property
def diff(self) -> str:
"""Compute a unified diff between original and refactored content."""
original_lines = self.original.splitlines(keepends=True)
refactored_lines = self.refactored.splitlines(keepends=True)
return "".join(
difflib.unified_diff(
original_lines,
refactored_lines,
fromfile=f"a/{self.path.name}",
tofile=f"b/{self.path.name}",
lineterm="",
)
)
@property
def stat_summary(self) -> str:
"""Return a short summary of lines added/removed."""
original_lines = self.original.splitlines()
refactored_lines = self.refactored.splitlines()
diff_lines = list(difflib.unified_diff(original_lines, refactored_lines, n=0))
added = sum(
1
for line in diff_lines
if line.startswith("+") and not line.startswith("+++")
)
removed = sum(
1
for line in diff_lines
if line.startswith("-") and not line.startswith("---")
)
return f"[green]+{added}[/green] [red]-{removed}[/red]"
@dataclass
class RefactorPlan:
"""A collection of proposed file changes from a refactoring operation.
Attributes:
changes: List of proposed file changes.
summary: High-level summary of the refactoring.
"""
changes: list[FileChange] = field(default_factory=list)
summary: str = ""
@property
def files_changed(self) -> int:
"""Number of files with actual changes."""
return sum(1 for c in self.changes if c.has_changes)
def add_change(
self,
path: Path,
original: str,
refactored: str,
explanation: str,
) -> None:
"""Add a file change to the plan.
Args:
path: Path to the target file.
original: Original file content.
refactored: Proposed new content.
explanation: What changed and why.
"""
self.changes.append(
FileChange(
path=path,
original=original,
refactored=refactored,
explanation=explanation,
)
)
def is_git_repo(path: Path) -> bool:
"""Check if a path is inside a git repository.
Args:
path: Any path to check.
Returns:
True if the path is tracked by git.
"""
try:
result = subprocess.run(
["git", "rev-parse", "--is-inside-work-tree"],
cwd=path if path.is_dir() else path.parent,
capture_output=True,
text=True,
timeout=5,
)
return result.returncode == 0
except (FileNotFoundError, subprocess.TimeoutExpired):
return False
def has_uncommitted_changes(path: Path) -> bool:
"""Check if a file has uncommitted changes in git.
Args:
path: Path to the file to check.
Returns:
True if the file has uncommitted modifications.
"""
try:
result = subprocess.run(
["git", "diff", "--name-only", str(path)],
cwd=path.parent,
capture_output=True,
text=True,
timeout=5,
)
return bool(result.stdout.strip())
except (FileNotFoundError, subprocess.TimeoutExpired):
return False
def _resolve_backup_dir(path: Path, root: Path | None) -> Path:
"""Return the directory that should hold a backup of *path*.
When *root* is provided, backups go under
``<root>/.pyagent/backups/<relative-parent>/`` — one consolidated tree per
project. When *root* is None (single-file mode), backups live next to the
file in ``<path.parent>/.pyagent/backups/``, still consolidated but scoped
to the file's directory.
"""
base_root = root if root is not None else path.parent
try:
rel_parent = path.resolve().relative_to(base_root.resolve()).parent
except ValueError:
# *path* is outside *root* — fall back to a flat layout under the root.
rel_parent = Path(".")
return base_root / _PYAGENT_DIR_NAME / _BACKUPS_SUBDIR / rel_parent
def create_backup(path: Path, *, root: Path | None = None) -> Path:
"""Create a timestamped backup of a file.
Backups are stored under ``<root>/.pyagent/backups/<rel>/`` preserving
the file's path relative to *root*. When *root* is omitted, the file's
parent directory is used as the root.
Args:
path: Path to the file to back up.
root: Project root. When set, the backup layout mirrors the source
tree under ``<root>/.pyagent/backups/`` so a whole-project
refactor produces a single consolidated backup tree.
Returns:
Path to the backup file.
Raises:
FileNotFoundError: If the source file does not exist.
"""
if not path.exists():
raise FileNotFoundError(f"Cannot back up non-existent file: {path}")
backup_dir = _resolve_backup_dir(path, root)
backup_dir.mkdir(parents=True, exist_ok=True)
timestamp = datetime.now(UTC).strftime("%Y%m%d_%H%M%S")
backup_name = f"{path.stem}_{timestamp}{path.suffix}"
backup_path = backup_dir / backup_name
shutil.copy2(path, backup_path)
logger.info("Backed up %s → %s", path.name, backup_path)
return backup_path
def write_changes(
plan: RefactorPlan,
*,
backup: bool = True,
root: Path | None = None,
) -> list[Path]:
"""Write all approved changes in a refactor plan to disk.
Args:
plan: The refactoring plan with file changes.
backup: Whether to create backups before writing.
root: Project root for the consolidated backup layout. When set, all
backups land under ``<root>/.pyagent/backups/`` mirroring the
source tree. When None, each file's parent directory is used
(single-file mode).
Returns:
List of paths that were written.
"""
written: list[Path] = []
for change in plan.changes:
if not change.has_changes:
logger.info("Skipping %s — no changes", change.path)
continue
if backup:
create_backup(change.path, root=root)
change.path.write_text(change.refactored, encoding="utf-8")
logger.info("Wrote refactored content to %s", change.path)
written.append(change.path)
return written
def restore_backup(path: Path, *, root: Path | None = None) -> bool:
"""Restore the most recent backup of a file.
Looks in the consolidated ``<root>/.pyagent/backups/`` tree first, then
falls back to the legacy per-directory ``.pyagent_backup/`` layout so
backups from older pyagent versions are still recoverable.
Args:
path: Path to the file to restore.
root: Project root used when locating consolidated backups. When
None, the file's parent is used.
Returns:
True if a backup was found and restored, False otherwise.
"""
candidate_dirs: list[Path] = [
_resolve_backup_dir(path, root),
path.parent / _LEGACY_BACKUP_DIR_NAME, # upgrade fallback
]
pattern = f"{path.stem}_*{path.suffix}"
matches: list[Path] = []
for candidate in candidate_dirs:
if candidate.exists():
matches.extend(candidate.glob(pattern))
if not matches:
return False
latest = max(matches, key=lambda p: p.stat().st_mtime)
shutil.copy2(latest, path)
logger.info("Restored %s from %s", path.name, latest)
return True