zai-cli 0.1.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.
- zai/__init__.py +1 -0
- zai/__main__.py +4 -0
- zai/cli/__init__.py +1 -0
- zai/cli/common.py +16 -0
- zai/cli/integrations.py +319 -0
- zai/cli/interactive.py +518 -0
- zai/cli/settings.py +436 -0
- zai/cli/utilities.py +227 -0
- zai/cli/workflows.py +137 -0
- zai/commands/commit.md +24 -0
- zai/commands/explain.md +17 -0
- zai/commands/feature.md +34 -0
- zai/commands/fix.md +14 -0
- zai/commands/review.md +22 -0
- zai/config.py +307 -0
- zai/core/__init__.py +0 -0
- zai/core/agent.py +701 -0
- zai/core/cancellation.py +67 -0
- zai/core/commands.py +85 -0
- zai/core/context.py +299 -0
- zai/core/errors.py +125 -0
- zai/core/fallback.py +171 -0
- zai/core/hooks.py +115 -0
- zai/core/memory.py +57 -0
- zai/core/process.py +204 -0
- zai/core/repomap.py +381 -0
- zai/core/runtime.py +29 -0
- zai/core/security.py +33 -0
- zai/core/session.py +425 -0
- zai/core/storage.py +193 -0
- zai/core/streaming.py +157 -0
- zai/core/tool_schema.py +133 -0
- zai/core/undo.py +443 -0
- zai/core/watch.py +80 -0
- zai/main.py +210 -0
- zai/mcp/__init__.py +0 -0
- zai/mcp/client.py +431 -0
- zai/mcp/manager.py +118 -0
- zai/plugins/__init__.py +2 -0
- zai/plugins/base.py +49 -0
- zai/plugins/loader.py +404 -0
- zai/providers/__init__.py +22 -0
- zai/providers/anthropic.py +131 -0
- zai/providers/base.py +67 -0
- zai/providers/cerebras.py +57 -0
- zai/providers/gemini.py +119 -0
- zai/providers/groq.py +116 -0
- zai/providers/ollama.py +62 -0
- zai/providers/openai.py +124 -0
- zai/providers/openrouter.py +63 -0
- zai/providers/qwen.py +47 -0
- zai/skills/__init__.py +0 -0
- zai/skills/registry.py +52 -0
- zai/tools/__init__.py +0 -0
- zai/tools/browser.py +224 -0
- zai/tools/code_runner.py +49 -0
- zai/tools/files.py +53 -0
- zai/tools/git.py +38 -0
- zai/tools/search.py +157 -0
- zai/tools/vision.py +128 -0
- zai/ui/__init__.py +0 -0
- zai/ui/input.py +199 -0
- zai_cli-0.1.0.dist-info/METADATA +722 -0
- zai_cli-0.1.0.dist-info/RECORD +68 -0
- zai_cli-0.1.0.dist-info/WHEEL +5 -0
- zai_cli-0.1.0.dist-info/entry_points.txt +2 -0
- zai_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- zai_cli-0.1.0.dist-info/top_level.txt +1 -0
zai/core/undo.py
ADDED
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import json
|
|
3
|
+
import uuid
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from .errors import FileError
|
|
8
|
+
from .security import resolve_project_path
|
|
9
|
+
from .storage import atomic_write_json, atomic_write_text, file_lock
|
|
10
|
+
|
|
11
|
+
MAX_ACTIONS = 50
|
|
12
|
+
MAX_BACKUP_BYTES = 2 * 1024 * 1024
|
|
13
|
+
MAX_TOTAL_BACKUP_BYTES = 10 * 1024 * 1024
|
|
14
|
+
SENSITIVE_NAMES = {
|
|
15
|
+
".env",
|
|
16
|
+
".env.local",
|
|
17
|
+
".env.production",
|
|
18
|
+
"credentials.json",
|
|
19
|
+
"secrets.json",
|
|
20
|
+
"id_rsa",
|
|
21
|
+
"id_ed25519",
|
|
22
|
+
}
|
|
23
|
+
SENSITIVE_SUFFIXES = {".pem", ".key", ".p12", ".pfx"}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _undo_dir(cwd: str | Path) -> Path:
|
|
27
|
+
return Path(cwd).resolve() / ".zai" / "undo"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _journal_path(cwd: str | Path) -> Path:
|
|
31
|
+
return _undo_dir(cwd) / "journal.json"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _redo_path(cwd: str | Path) -> Path:
|
|
35
|
+
return _undo_dir(cwd) / "redo.json"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _load_actions_unlocked(cwd: str | Path) -> list[dict]:
|
|
39
|
+
journal = _journal_path(cwd)
|
|
40
|
+
if not journal.exists():
|
|
41
|
+
return []
|
|
42
|
+
try:
|
|
43
|
+
data = json.loads(journal.read_text(encoding="utf-8"))
|
|
44
|
+
return data if isinstance(data, list) else []
|
|
45
|
+
except (OSError, UnicodeError, json.JSONDecodeError):
|
|
46
|
+
corrupt = journal.with_name(
|
|
47
|
+
f"{journal.name}.corrupt-"
|
|
48
|
+
f"{datetime.now().strftime('%Y%m%d-%H%M%S')}"
|
|
49
|
+
)
|
|
50
|
+
try:
|
|
51
|
+
journal.replace(corrupt)
|
|
52
|
+
except OSError:
|
|
53
|
+
pass
|
|
54
|
+
return []
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _load_actions(cwd: str | Path) -> list[dict]:
|
|
58
|
+
with file_lock(_journal_path(cwd)):
|
|
59
|
+
return _load_actions_unlocked(cwd)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _save_actions_unlocked(cwd: str | Path, actions: list[dict]) -> None:
|
|
63
|
+
atomic_write_json(_journal_path(cwd), actions, lock=False)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _load_redo_unlocked(cwd: str | Path) -> list[dict]:
|
|
67
|
+
path = _redo_path(cwd)
|
|
68
|
+
if not path.exists():
|
|
69
|
+
return []
|
|
70
|
+
try:
|
|
71
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
72
|
+
return data if isinstance(data, list) else []
|
|
73
|
+
except (OSError, UnicodeError, json.JSONDecodeError):
|
|
74
|
+
return []
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _save_redo_unlocked(cwd: str | Path, actions: list[dict]) -> None:
|
|
78
|
+
atomic_write_json(_redo_path(cwd), actions, lock=False)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _relative(cwd: str | Path, path: str | Path) -> str:
|
|
82
|
+
return str(Path(path).resolve().relative_to(Path(cwd).resolve()))
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _hash_file(path: Path) -> str | None:
|
|
86
|
+
if not path.is_file():
|
|
87
|
+
return None
|
|
88
|
+
digest = hashlib.sha256()
|
|
89
|
+
try:
|
|
90
|
+
with path.open("rb") as handle:
|
|
91
|
+
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
|
92
|
+
digest.update(chunk)
|
|
93
|
+
return digest.hexdigest()
|
|
94
|
+
except OSError:
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _is_sensitive(path: Path) -> bool:
|
|
99
|
+
name = path.name.lower()
|
|
100
|
+
return (
|
|
101
|
+
name in SENSITIVE_NAMES
|
|
102
|
+
or path.suffix.lower() in SENSITIVE_SUFFIXES
|
|
103
|
+
or name.startswith(".env.")
|
|
104
|
+
or any(part.lower() in {".ssh", ".aws", ".gnupg"} for part in path.parts)
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _delete_backup(cwd: str | Path, action: dict) -> None:
|
|
109
|
+
backup = action.get("backup")
|
|
110
|
+
if backup:
|
|
111
|
+
(_undo_dir(cwd) / backup).unlink(missing_ok=True)
|
|
112
|
+
redo_backup = action.get("redo_backup")
|
|
113
|
+
if redo_backup:
|
|
114
|
+
(_undo_dir(cwd) / redo_backup).unlink(missing_ok=True)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _action_paths(action: dict) -> list[str]:
|
|
118
|
+
if action.get("type") == "rename":
|
|
119
|
+
return [action.get("source", ""), action.get("destination", "")]
|
|
120
|
+
return [action.get("path", "")]
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _summary(action: dict) -> str:
|
|
124
|
+
action_type = action.get("type")
|
|
125
|
+
paths = _action_paths(action)
|
|
126
|
+
if action_type == "restore_file":
|
|
127
|
+
return f"Restore previous content of {paths[0]}"
|
|
128
|
+
if action_type == "delete_file":
|
|
129
|
+
return f"Delete newly created file {paths[0]}"
|
|
130
|
+
if action_type == "rename":
|
|
131
|
+
return f"Rename {paths[1]} back to {paths[0]}"
|
|
132
|
+
if action_type == "remove_folder":
|
|
133
|
+
return f"Remove newly created folder {paths[0]}"
|
|
134
|
+
return f"Unknown operation on {', '.join(filter(None, paths))}"
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _normalize_action(action: dict) -> dict:
|
|
138
|
+
normalized = dict(action)
|
|
139
|
+
normalized.setdefault(
|
|
140
|
+
"id",
|
|
141
|
+
hashlib.sha256(
|
|
142
|
+
json.dumps(action, sort_keys=True).encode("utf-8")
|
|
143
|
+
).hexdigest()[:12],
|
|
144
|
+
)
|
|
145
|
+
normalized.setdefault("summary", _summary(normalized))
|
|
146
|
+
normalized.setdefault("paths", list(filter(None, _action_paths(normalized))))
|
|
147
|
+
return normalized
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _prune(cwd: str | Path, actions: list[dict]) -> list[dict]:
|
|
151
|
+
while len(actions) > MAX_ACTIONS:
|
|
152
|
+
_delete_backup(cwd, actions.pop(0))
|
|
153
|
+
|
|
154
|
+
def backup_size(action: dict) -> int:
|
|
155
|
+
backup = action.get("backup")
|
|
156
|
+
if not backup:
|
|
157
|
+
return 0
|
|
158
|
+
try:
|
|
159
|
+
return (_undo_dir(cwd) / backup).stat().st_size
|
|
160
|
+
except OSError:
|
|
161
|
+
return 0
|
|
162
|
+
|
|
163
|
+
total = sum(backup_size(action) for action in actions)
|
|
164
|
+
while actions and total > MAX_TOTAL_BACKUP_BYTES:
|
|
165
|
+
removed = actions.pop(0)
|
|
166
|
+
total -= backup_size(removed)
|
|
167
|
+
_delete_backup(cwd, removed)
|
|
168
|
+
return actions
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _append_action(cwd: str | Path, action: dict) -> None:
|
|
172
|
+
journal = _journal_path(cwd)
|
|
173
|
+
with file_lock(journal):
|
|
174
|
+
actions = _load_actions_unlocked(cwd)
|
|
175
|
+
actions.append(_normalize_action({
|
|
176
|
+
**action,
|
|
177
|
+
"id": uuid.uuid4().hex,
|
|
178
|
+
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
179
|
+
}))
|
|
180
|
+
for redo_action in _load_redo_unlocked(cwd):
|
|
181
|
+
_delete_backup(cwd, redo_action)
|
|
182
|
+
_save_redo_unlocked(cwd, [])
|
|
183
|
+
_save_actions_unlocked(cwd, _prune(cwd, actions))
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def record_file_change(
|
|
187
|
+
cwd: str,
|
|
188
|
+
path: str | Path,
|
|
189
|
+
previous_content: str | None,
|
|
190
|
+
) -> bool:
|
|
191
|
+
"""Record a verified file mutation. Returns False when backup is skipped."""
|
|
192
|
+
target = Path(path).resolve()
|
|
193
|
+
action = {
|
|
194
|
+
"type": "delete_file" if previous_content is None else "restore_file",
|
|
195
|
+
"path": _relative(cwd, target),
|
|
196
|
+
"expected_hash": _hash_file(target),
|
|
197
|
+
}
|
|
198
|
+
if previous_content is not None:
|
|
199
|
+
encoded = previous_content.encode("utf-8")
|
|
200
|
+
if _is_sensitive(target) or len(encoded) > MAX_BACKUP_BYTES:
|
|
201
|
+
return False
|
|
202
|
+
backup_name = f"{uuid.uuid4().hex}.txt"
|
|
203
|
+
backup_path = _undo_dir(cwd) / backup_name
|
|
204
|
+
atomic_write_text(backup_path, previous_content, lock=False)
|
|
205
|
+
action["backup"] = backup_name
|
|
206
|
+
action["backup_hash"] = hashlib.sha256(encoded).hexdigest()
|
|
207
|
+
_append_action(cwd, action)
|
|
208
|
+
return True
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def record_rename(cwd: str, source: str | Path, destination: str | Path) -> None:
|
|
212
|
+
destination_path = Path(destination).resolve()
|
|
213
|
+
_append_action(cwd, {
|
|
214
|
+
"type": "rename",
|
|
215
|
+
"source": _relative(cwd, source),
|
|
216
|
+
"destination": _relative(cwd, destination_path),
|
|
217
|
+
"expected_hash": _hash_file(destination_path),
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def record_created_folder(cwd: str, path: str | Path) -> None:
|
|
222
|
+
_append_action(cwd, {
|
|
223
|
+
"type": "remove_folder",
|
|
224
|
+
"path": _relative(cwd, path),
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _conflict(path: Path, expected_hash: str | None) -> bool:
|
|
229
|
+
if expected_hash is None:
|
|
230
|
+
return False
|
|
231
|
+
return _hash_file(path) != expected_hash
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def list_actions(cwd: str) -> list[dict]:
|
|
235
|
+
"""Return inspectable undo actions, newest first."""
|
|
236
|
+
return [_normalize_action(item) for item in reversed(_load_actions(cwd))]
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def get_action(cwd: str, identifier: str) -> dict | None:
|
|
240
|
+
matches = [
|
|
241
|
+
item
|
|
242
|
+
for item in list_actions(cwd)
|
|
243
|
+
if item["id"] == identifier or item["id"].startswith(identifier)
|
|
244
|
+
]
|
|
245
|
+
return matches[0] if len(matches) == 1 else None
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _write_redo_backup(cwd: str, content: str) -> tuple[str, str]:
|
|
249
|
+
encoded = content.encode("utf-8")
|
|
250
|
+
backup_name = f"redo-{uuid.uuid4().hex}.txt"
|
|
251
|
+
atomic_write_text(_undo_dir(cwd) / backup_name, content, lock=False)
|
|
252
|
+
return backup_name, hashlib.sha256(encoded).hexdigest()
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _undo_one(cwd: str, action: dict) -> tuple[str, dict | None]:
|
|
256
|
+
action_type = action.get("type")
|
|
257
|
+
redo_action = dict(action)
|
|
258
|
+
try:
|
|
259
|
+
if action_type == "restore_file":
|
|
260
|
+
path = resolve_project_path(cwd, action["path"])
|
|
261
|
+
backup = _undo_dir(cwd) / action["backup"]
|
|
262
|
+
if not backup.is_file():
|
|
263
|
+
return "Cannot undo: backup file is missing.", None
|
|
264
|
+
if _conflict(path, action.get("expected_hash")):
|
|
265
|
+
return (
|
|
266
|
+
f"Cannot undo: {path.name} changed after the AI edit. "
|
|
267
|
+
"Current content was preserved."
|
|
268
|
+
), None
|
|
269
|
+
current = path.read_text(encoding="utf-8")
|
|
270
|
+
redo_backup, redo_hash = _write_redo_backup(cwd, current)
|
|
271
|
+
previous = backup.read_text(encoding="utf-8")
|
|
272
|
+
if hashlib.sha256(previous.encode("utf-8")).hexdigest() != action.get(
|
|
273
|
+
"backup_hash"
|
|
274
|
+
):
|
|
275
|
+
_delete_backup(cwd, {"redo_backup": redo_backup})
|
|
276
|
+
return "Cannot undo: backup integrity check failed.", None
|
|
277
|
+
atomic_write_text(path, previous, mode=0o644, lock=False)
|
|
278
|
+
redo_action.update({
|
|
279
|
+
"redo_backup": redo_backup,
|
|
280
|
+
"redo_hash": redo_hash,
|
|
281
|
+
"undo_hash": _hash_file(path),
|
|
282
|
+
})
|
|
283
|
+
message = f"Restored: {path.name}"
|
|
284
|
+
elif action_type == "delete_file":
|
|
285
|
+
path = resolve_project_path(cwd, action["path"])
|
|
286
|
+
if path.exists() and not path.is_file():
|
|
287
|
+
return f"Cannot undo: not a file: {path.name}", None
|
|
288
|
+
if path.exists() and _conflict(path, action.get("expected_hash")):
|
|
289
|
+
return (
|
|
290
|
+
f"Cannot undo: {path.name} changed after creation. "
|
|
291
|
+
"Current content was preserved."
|
|
292
|
+
), None
|
|
293
|
+
if path.exists():
|
|
294
|
+
current = path.read_text(encoding="utf-8")
|
|
295
|
+
redo_backup, redo_hash = _write_redo_backup(cwd, current)
|
|
296
|
+
redo_action.update({
|
|
297
|
+
"redo_backup": redo_backup,
|
|
298
|
+
"redo_hash": redo_hash,
|
|
299
|
+
})
|
|
300
|
+
path.unlink()
|
|
301
|
+
message = f"Deleted new file: {path.name}"
|
|
302
|
+
elif action_type == "rename":
|
|
303
|
+
source = resolve_project_path(cwd, action["source"])
|
|
304
|
+
destination = resolve_project_path(cwd, action["destination"])
|
|
305
|
+
if not destination.exists():
|
|
306
|
+
return f"Cannot undo rename: path missing: {destination.name}", None
|
|
307
|
+
if source.exists():
|
|
308
|
+
return f"Cannot undo rename: destination exists: {source.name}", None
|
|
309
|
+
if destination.is_file() and _conflict(
|
|
310
|
+
destination,
|
|
311
|
+
action.get("expected_hash"),
|
|
312
|
+
):
|
|
313
|
+
return (
|
|
314
|
+
f"Cannot undo rename: {destination.name} changed after rename."
|
|
315
|
+
), None
|
|
316
|
+
source.parent.mkdir(parents=True, exist_ok=True)
|
|
317
|
+
destination.rename(source)
|
|
318
|
+
redo_action["undo_hash"] = _hash_file(source)
|
|
319
|
+
message = f"Restored name: {destination.name} -> {source.name}"
|
|
320
|
+
elif action_type == "remove_folder":
|
|
321
|
+
path = resolve_project_path(cwd, action["path"])
|
|
322
|
+
if path.exists() and not path.is_dir():
|
|
323
|
+
return f"Cannot undo: not a folder: {path.name}", None
|
|
324
|
+
if path.exists():
|
|
325
|
+
try:
|
|
326
|
+
path.rmdir()
|
|
327
|
+
except OSError:
|
|
328
|
+
return f"Cannot undo: folder is not empty: {path.name}", None
|
|
329
|
+
message = f"Deleted new folder: {path.name}"
|
|
330
|
+
else:
|
|
331
|
+
return "Cannot undo: unknown journal action.", None
|
|
332
|
+
except (FileError, OSError, UnicodeError) as error:
|
|
333
|
+
return f"Cannot undo: {error}", None
|
|
334
|
+
return message, redo_action
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def undo_action(cwd: str, identifier: str | None = None) -> str:
|
|
338
|
+
"""Undo the latest action, or all actions through a selected ID."""
|
|
339
|
+
journal = _journal_path(cwd)
|
|
340
|
+
with file_lock(journal):
|
|
341
|
+
actions = _load_actions_unlocked(cwd)
|
|
342
|
+
if not actions:
|
|
343
|
+
return "Nothing to undo."
|
|
344
|
+
normalized = [_normalize_action(item) for item in actions]
|
|
345
|
+
target_index = len(actions) - 1
|
|
346
|
+
if identifier:
|
|
347
|
+
matches = [
|
|
348
|
+
index
|
|
349
|
+
for index, item in enumerate(normalized)
|
|
350
|
+
if item["id"] == identifier or item["id"].startswith(identifier)
|
|
351
|
+
]
|
|
352
|
+
if len(matches) != 1:
|
|
353
|
+
return "Cannot undo: action ID not found or ambiguous."
|
|
354
|
+
target_index = matches[0]
|
|
355
|
+
redo_actions = _load_redo_unlocked(cwd)
|
|
356
|
+
messages: list[str] = []
|
|
357
|
+
while len(actions) - 1 >= target_index:
|
|
358
|
+
action = normalized[len(actions) - 1]
|
|
359
|
+
message, redo_action = _undo_one(cwd, action)
|
|
360
|
+
if redo_action is None:
|
|
361
|
+
_save_actions_unlocked(cwd, actions)
|
|
362
|
+
_save_redo_unlocked(cwd, redo_actions)
|
|
363
|
+
return "\n".join(messages + [message])
|
|
364
|
+
actions.pop()
|
|
365
|
+
redo_actions.append(redo_action)
|
|
366
|
+
messages.append(message)
|
|
367
|
+
_save_actions_unlocked(cwd, actions)
|
|
368
|
+
_save_redo_unlocked(cwd, redo_actions)
|
|
369
|
+
return "\n".join(messages)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def undo_last_action(cwd: str) -> str:
|
|
373
|
+
return undo_action(cwd)
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def redo_last_action(cwd: str) -> str:
|
|
377
|
+
journal = _journal_path(cwd)
|
|
378
|
+
with file_lock(journal):
|
|
379
|
+
redo_actions = _load_redo_unlocked(cwd)
|
|
380
|
+
if not redo_actions:
|
|
381
|
+
return "Nothing to redo."
|
|
382
|
+
action = redo_actions[-1]
|
|
383
|
+
action_type = action.get("type")
|
|
384
|
+
try:
|
|
385
|
+
if action_type in {"restore_file", "delete_file"}:
|
|
386
|
+
path = resolve_project_path(cwd, action["path"])
|
|
387
|
+
expected = action.get("undo_hash")
|
|
388
|
+
if path.exists() and _conflict(path, expected):
|
|
389
|
+
return f"Cannot redo: {path.name} changed after undo."
|
|
390
|
+
backup = _undo_dir(cwd) / action.get("redo_backup", "")
|
|
391
|
+
if not backup.is_file():
|
|
392
|
+
return "Cannot redo: redo backup is missing."
|
|
393
|
+
content = backup.read_text(encoding="utf-8")
|
|
394
|
+
if hashlib.sha256(content.encode("utf-8")).hexdigest() != action.get(
|
|
395
|
+
"redo_hash"
|
|
396
|
+
):
|
|
397
|
+
return "Cannot redo: redo backup integrity check failed."
|
|
398
|
+
atomic_write_text(path, content, mode=0o644, lock=False)
|
|
399
|
+
action["expected_hash"] = _hash_file(path)
|
|
400
|
+
message = f"Redone file change: {path.name}"
|
|
401
|
+
elif action_type == "rename":
|
|
402
|
+
source = resolve_project_path(cwd, action["source"])
|
|
403
|
+
destination = resolve_project_path(cwd, action["destination"])
|
|
404
|
+
if not source.exists() or destination.exists():
|
|
405
|
+
return "Cannot redo rename: source missing or destination exists."
|
|
406
|
+
if source.is_file() and _conflict(source, action.get("undo_hash")):
|
|
407
|
+
return f"Cannot redo rename: {source.name} changed after undo."
|
|
408
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
409
|
+
source.rename(destination)
|
|
410
|
+
action["expected_hash"] = _hash_file(destination)
|
|
411
|
+
message = f"Redone rename: {source.name} -> {destination.name}"
|
|
412
|
+
elif action_type == "remove_folder":
|
|
413
|
+
path = resolve_project_path(cwd, action["path"])
|
|
414
|
+
if path.exists():
|
|
415
|
+
return f"Cannot redo: path already exists: {path.name}"
|
|
416
|
+
path.mkdir(parents=True)
|
|
417
|
+
message = f"Redone folder creation: {path.name}"
|
|
418
|
+
else:
|
|
419
|
+
return "Cannot redo: unknown journal action."
|
|
420
|
+
except (FileError, OSError, UnicodeError) as error:
|
|
421
|
+
return f"Cannot redo: {error}"
|
|
422
|
+
redo_actions.pop()
|
|
423
|
+
_delete_backup(cwd, {"redo_backup": action.get("redo_backup")})
|
|
424
|
+
action.pop("redo_backup", None)
|
|
425
|
+
action.pop("redo_hash", None)
|
|
426
|
+
action.pop("undo_hash", None)
|
|
427
|
+
actions = _load_actions_unlocked(cwd)
|
|
428
|
+
actions.append(action)
|
|
429
|
+
_save_actions_unlocked(cwd, _prune(cwd, actions))
|
|
430
|
+
_save_redo_unlocked(cwd, redo_actions)
|
|
431
|
+
return message
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def clear_actions(cwd: str) -> int:
|
|
435
|
+
journal = _journal_path(cwd)
|
|
436
|
+
with file_lock(journal):
|
|
437
|
+
actions = _load_actions_unlocked(cwd)
|
|
438
|
+
redo_actions = _load_redo_unlocked(cwd)
|
|
439
|
+
for action in actions + redo_actions:
|
|
440
|
+
_delete_backup(cwd, action)
|
|
441
|
+
_save_actions_unlocked(cwd, [])
|
|
442
|
+
_save_redo_unlocked(cwd, [])
|
|
443
|
+
return len(actions) + len(redo_actions)
|
zai/core/watch.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import time
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
|
|
6
|
+
console = Console()
|
|
7
|
+
|
|
8
|
+
_observer = None
|
|
9
|
+
_watching = False
|
|
10
|
+
|
|
11
|
+
SKIP_NAMES = {'.git', '__pycache__', 'node_modules', '.venv', 'dist', 'build', '.mypy_cache'}
|
|
12
|
+
SKIP_EXT = {'.pyc', '.pyo', '.pyd', '.swp', '.tmp', '.log'}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _should_skip(path: str) -> bool:
|
|
16
|
+
p = Path(path)
|
|
17
|
+
if p.suffix in SKIP_EXT:
|
|
18
|
+
return True
|
|
19
|
+
for part in p.parts:
|
|
20
|
+
if part in SKIP_NAMES:
|
|
21
|
+
return True
|
|
22
|
+
return False
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def start_watch(cwd: str, callback) -> bool:
|
|
26
|
+
"""
|
|
27
|
+
Start watching `cwd` for file changes.
|
|
28
|
+
`callback(filename)` is called when a relevant file is modified.
|
|
29
|
+
Returns True if started, False if watchdog not installed.
|
|
30
|
+
"""
|
|
31
|
+
global _observer, _watching
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
from watchdog.observers import Observer
|
|
35
|
+
from watchdog.events import FileSystemEventHandler
|
|
36
|
+
except ImportError:
|
|
37
|
+
console.print("[yellow]Install watchdog to use watch mode: pip install watchdog[/yellow]")
|
|
38
|
+
return False
|
|
39
|
+
|
|
40
|
+
class Handler(FileSystemEventHandler):
|
|
41
|
+
def __init__(self):
|
|
42
|
+
self._cooldown: dict = {}
|
|
43
|
+
|
|
44
|
+
def on_modified(self, event):
|
|
45
|
+
if event.is_directory:
|
|
46
|
+
return
|
|
47
|
+
src = event.src_path
|
|
48
|
+
if _should_skip(src):
|
|
49
|
+
return
|
|
50
|
+
now = time.time()
|
|
51
|
+
if now - self._cooldown.get(src, 0) < 3:
|
|
52
|
+
return
|
|
53
|
+
self._cooldown[src] = now
|
|
54
|
+
fname = os.path.basename(src)
|
|
55
|
+
console.print(f"\n[dim]~ {fname} changed[/dim]")
|
|
56
|
+
callback(fname)
|
|
57
|
+
|
|
58
|
+
if _observer:
|
|
59
|
+
_observer.stop()
|
|
60
|
+
_observer.join()
|
|
61
|
+
|
|
62
|
+
_observer = Observer()
|
|
63
|
+
_observer.schedule(Handler(), cwd, recursive=True)
|
|
64
|
+
_observer.start()
|
|
65
|
+
_watching = True
|
|
66
|
+
return True
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def stop_watch():
|
|
70
|
+
"""Stop the file watcher."""
|
|
71
|
+
global _observer, _watching
|
|
72
|
+
if _observer:
|
|
73
|
+
_observer.stop()
|
|
74
|
+
_observer.join()
|
|
75
|
+
_observer = None
|
|
76
|
+
_watching = False
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def is_watching() -> bool:
|
|
80
|
+
return _watching
|