execdiff 0.0.1__tar.gz → 0.0.3__tar.gz
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.
- {execdiff-0.0.1 → execdiff-0.0.3}/PKG-INFO +1 -1
- execdiff-0.0.3/README.md +193 -0
- execdiff-0.0.3/execdiff/__init__.py +371 -0
- {execdiff-0.0.1 → execdiff-0.0.3}/execdiff.egg-info/PKG-INFO +1 -1
- {execdiff-0.0.1 → execdiff-0.0.3}/pyproject.toml +1 -1
- execdiff-0.0.1/README.md +0 -85
- execdiff-0.0.1/execdiff/__init__.py +0 -159
- {execdiff-0.0.1 → execdiff-0.0.3}/LICENSE +0 -0
- {execdiff-0.0.1 → execdiff-0.0.3}/execdiff.egg-info/SOURCES.txt +0 -0
- {execdiff-0.0.1 → execdiff-0.0.3}/execdiff.egg-info/dependency_links.txt +0 -0
- {execdiff-0.0.1 → execdiff-0.0.3}/execdiff.egg-info/top_level.txt +0 -0
- {execdiff-0.0.1 → execdiff-0.0.3}/setup.cfg +0 -0
execdiff-0.0.3/README.md
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
# ExecDiff
|
|
2
|
+
|
|
3
|
+
See what AI-generated code will change before running it.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Problem
|
|
8
|
+
|
|
9
|
+
AI coding tools and agents today can:
|
|
10
|
+
|
|
11
|
+
- install dependencies
|
|
12
|
+
- create files
|
|
13
|
+
- modify configs
|
|
14
|
+
- run migrations
|
|
15
|
+
- delete project files
|
|
16
|
+
|
|
17
|
+
All automatically.
|
|
18
|
+
|
|
19
|
+
When something breaks after execution, tools cannot answer:
|
|
20
|
+
|
|
21
|
+
> What exactly changed because of this action?
|
|
22
|
+
|
|
23
|
+
Git tracks source code changes —
|
|
24
|
+
but it does **not** track execution side effects like:
|
|
25
|
+
|
|
26
|
+
- newly installed Python packages
|
|
27
|
+
- runtime-created files
|
|
28
|
+
- deleted files
|
|
29
|
+
- modified configs
|
|
30
|
+
|
|
31
|
+
So tools often fall back to:
|
|
32
|
+
|
|
33
|
+
> regenerate and try again
|
|
34
|
+
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
## Solution
|
|
38
|
+
|
|
39
|
+
ExecDiff allows tools to run AI-generated code and observe:
|
|
40
|
+
|
|
41
|
+
> what changed in the workspace because of that execution
|
|
42
|
+
|
|
43
|
+
It detects:
|
|
44
|
+
|
|
45
|
+
- files created
|
|
46
|
+
- files modified
|
|
47
|
+
- files deleted
|
|
48
|
+
- Python packages installed
|
|
49
|
+
|
|
50
|
+
inside a specific workspace
|
|
51
|
+
during a specific execution window.
|
|
52
|
+
|
|
53
|
+
---
|
|
54
|
+
|
|
55
|
+
## Installation
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
pip install execdiff
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
## Example
|
|
64
|
+
|
|
65
|
+
Create a test script:
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
import execdiff
|
|
69
|
+
import json
|
|
70
|
+
import os
|
|
71
|
+
|
|
72
|
+
os.makedirs("workspace", exist_ok=True)
|
|
73
|
+
|
|
74
|
+
diff = execdiff.run_traced(
|
|
75
|
+
"touch workspace/test.txt",
|
|
76
|
+
workspace="workspace"
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
print(json.dumps(diff, indent=2))
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Run:
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
python test.py
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
---
|
|
89
|
+
|
|
90
|
+
## API Reference
|
|
91
|
+
|
|
92
|
+
### `start_action_trace(workspace=".")`
|
|
93
|
+
|
|
94
|
+
Start tracing a workspace for changes. Must be called before any operations.
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
import execdiff
|
|
98
|
+
|
|
99
|
+
execdiff.start_action_trace(workspace="./my_workspace")
|
|
100
|
+
# ... your code that makes changes ...
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### `stop_action_trace()`
|
|
104
|
+
|
|
105
|
+
Stop tracing and return a diff of all changes detected. Automatically logs to `.execdiff/logs/actions.jsonl`.
|
|
106
|
+
|
|
107
|
+
```python
|
|
108
|
+
diff = execdiff.stop_action_trace()
|
|
109
|
+
# Returns: {"files": {...}, "packages": {...}}
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
### `last_action_summary(workspace=".")`
|
|
113
|
+
|
|
114
|
+
Get a human-readable summary of the last action trace without parsing JSON.
|
|
115
|
+
|
|
116
|
+
```python
|
|
117
|
+
summary = execdiff.last_action_summary(workspace=".")
|
|
118
|
+
print(summary)
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Output example:
|
|
122
|
+
```
|
|
123
|
+
Last AI Action:
|
|
124
|
+
|
|
125
|
+
Created:
|
|
126
|
+
- output.txt
|
|
127
|
+
- data.json
|
|
128
|
+
|
|
129
|
+
Installed:
|
|
130
|
+
- requests==2.32.0
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
### `snapshot_workspace_state(workspace)`
|
|
134
|
+
|
|
135
|
+
Take a full metadata snapshot of the workspace (files with mtime/size, installed packages).
|
|
136
|
+
|
|
137
|
+
```python
|
|
138
|
+
state = execdiff.snapshot_workspace_state(workspace=".")
|
|
139
|
+
# Returns: {"files": {...}, "packages": {...}}
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
---
|
|
143
|
+
|
|
144
|
+
## Output Format
|
|
145
|
+
|
|
146
|
+
### Diff Structure
|
|
147
|
+
|
|
148
|
+
```json
|
|
149
|
+
{
|
|
150
|
+
"files": {
|
|
151
|
+
"created": [{"path": "file.txt", "mtime": 123.45, "size": 1024}],
|
|
152
|
+
"modified": [{"path": "config.yaml", "before_mtime": 123, "after_mtime": 124, "before_size": 512, "after_size": 1024}],
|
|
153
|
+
"deleted": [{"path": "old_file.txt", "mtime": 123.45, "size": 256}]
|
|
154
|
+
},
|
|
155
|
+
"packages": {
|
|
156
|
+
"installed": [{"name": "requests", "version": "2.32.0"}],
|
|
157
|
+
"upgraded": [{"name": "django", "before_version": "3.2", "after_version": "4.0"}],
|
|
158
|
+
"removed": [{"name": "deprecated_lib", "version": "1.0"}]
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
### Log File
|
|
164
|
+
|
|
165
|
+
All action traces are automatically persisted to `.execdiff/logs/actions.jsonl`:
|
|
166
|
+
|
|
167
|
+
```json
|
|
168
|
+
{
|
|
169
|
+
"timestamp": "2026-02-18T18:19:35.872838",
|
|
170
|
+
"workspace": "/path/to/workspace",
|
|
171
|
+
"diff": {...}
|
|
172
|
+
}
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
---
|
|
176
|
+
|
|
177
|
+
## Use Cases
|
|
178
|
+
|
|
179
|
+
ExecDiff can help AI coding tools:
|
|
180
|
+
|
|
181
|
+
- preview changes before applying generated code
|
|
182
|
+
- detect unintended file or dependency changes
|
|
183
|
+
- explain execution impact to users
|
|
184
|
+
- debug failed automation
|
|
185
|
+
- build undo / rollback systems
|
|
186
|
+
|
|
187
|
+
---
|
|
188
|
+
|
|
189
|
+
## License
|
|
190
|
+
|
|
191
|
+
MIT
|
|
192
|
+
|
|
193
|
+
````
|
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
import sysconfig
|
|
2
|
+
import re
|
|
3
|
+
import json
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
|
|
6
|
+
# --- Full Workspace Metadata Snapshot and Action Trace ---
|
|
7
|
+
_action_trace_before = None
|
|
8
|
+
|
|
9
|
+
def snapshot_workspace_state(workspace):
|
|
10
|
+
"""
|
|
11
|
+
Take a full snapshot of the workspace state: files (mtime, size) and installed packages (name, version).
|
|
12
|
+
Returns:
|
|
13
|
+
dict: {"files": {relpath: {"mtime": float, "size": int}}, "packages": {name: {"version": str}}}
|
|
14
|
+
"""
|
|
15
|
+
# File snapshot
|
|
16
|
+
files = {}
|
|
17
|
+
for root, dirs, filelist in os.walk(workspace):
|
|
18
|
+
for fname in filelist:
|
|
19
|
+
fpath = os.path.join(root, fname)
|
|
20
|
+
relpath = os.path.relpath(fpath, workspace)
|
|
21
|
+
try:
|
|
22
|
+
files[relpath] = {
|
|
23
|
+
"mtime": os.path.getmtime(fpath),
|
|
24
|
+
"size": os.path.getsize(fpath)
|
|
25
|
+
}
|
|
26
|
+
except (OSError, IOError):
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
# Package snapshot
|
|
30
|
+
packages = {}
|
|
31
|
+
site_packages = sysconfig.get_paths()["purelib"]
|
|
32
|
+
dist_info_re = re.compile(r"^(?P<name>.+?)-(?P<version>[^-]+)\.dist-info$")
|
|
33
|
+
try:
|
|
34
|
+
for entry in os.listdir(site_packages):
|
|
35
|
+
m = dist_info_re.match(entry)
|
|
36
|
+
if m:
|
|
37
|
+
name = m.group("name").replace('_', '-')
|
|
38
|
+
version = m.group("version")
|
|
39
|
+
packages[name.lower()] = {"version": version}
|
|
40
|
+
except Exception:
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
return {"files": files, "packages": packages}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def start_action_trace(workspace="."):
|
|
47
|
+
"""
|
|
48
|
+
Take and store a full workspace metadata snapshot for later diffing.
|
|
49
|
+
"""
|
|
50
|
+
global _action_trace_before, _workspace
|
|
51
|
+
_workspace = workspace
|
|
52
|
+
_action_trace_before = snapshot_workspace_state(workspace)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def stop_action_trace():
|
|
56
|
+
"""
|
|
57
|
+
Take a new snapshot and compute diff (files: created/modified/deleted, packages: installed/removed/upgraded).
|
|
58
|
+
Returns:
|
|
59
|
+
dict: {"files": {...}, "packages": {...}}
|
|
60
|
+
"""
|
|
61
|
+
global _action_trace_before, _workspace
|
|
62
|
+
if _action_trace_before is None:
|
|
63
|
+
raise RuntimeError("start_action_trace() must be called before stop_action_trace()")
|
|
64
|
+
after = snapshot_workspace_state(_workspace)
|
|
65
|
+
before = _action_trace_before
|
|
66
|
+
|
|
67
|
+
# File diffs
|
|
68
|
+
before_files = before["files"]
|
|
69
|
+
after_files = after["files"]
|
|
70
|
+
created = []
|
|
71
|
+
modified = []
|
|
72
|
+
deleted = []
|
|
73
|
+
for f in after_files:
|
|
74
|
+
if f not in before_files:
|
|
75
|
+
created.append({"path": f, **after_files[f]})
|
|
76
|
+
else:
|
|
77
|
+
b, a = before_files[f], after_files[f]
|
|
78
|
+
if b["mtime"] != a["mtime"] or b["size"] != a["size"]:
|
|
79
|
+
modified.append({"path": f, "before_mtime": b["mtime"], "after_mtime": a["mtime"], "before_size": b["size"], "after_size": a["size"]})
|
|
80
|
+
for f in before_files:
|
|
81
|
+
if f not in after_files:
|
|
82
|
+
deleted.append({"path": f, **before_files[f]})
|
|
83
|
+
|
|
84
|
+
# Package diffs
|
|
85
|
+
before_pkgs = before["packages"]
|
|
86
|
+
after_pkgs = after["packages"]
|
|
87
|
+
installed = []
|
|
88
|
+
removed = []
|
|
89
|
+
upgraded = []
|
|
90
|
+
for name in after_pkgs:
|
|
91
|
+
if name not in before_pkgs:
|
|
92
|
+
installed.append({"name": name, "version": after_pkgs[name]["version"]})
|
|
93
|
+
else:
|
|
94
|
+
if before_pkgs[name]["version"] != after_pkgs[name]["version"]:
|
|
95
|
+
upgraded.append({"name": name, "before_version": before_pkgs[name]["version"], "after_version": after_pkgs[name]["version"]})
|
|
96
|
+
for name in before_pkgs:
|
|
97
|
+
if name not in after_pkgs:
|
|
98
|
+
removed.append({"name": name, "version": before_pkgs[name]["version"]})
|
|
99
|
+
|
|
100
|
+
diff = {
|
|
101
|
+
"files": {
|
|
102
|
+
"created": created,
|
|
103
|
+
"modified": modified,
|
|
104
|
+
"deleted": deleted
|
|
105
|
+
},
|
|
106
|
+
"packages": {
|
|
107
|
+
"installed": installed,
|
|
108
|
+
"removed": removed,
|
|
109
|
+
"upgraded": upgraded
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
_persist_action_log(diff)
|
|
114
|
+
return diff
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _persist_action_log(diff):
|
|
118
|
+
"""
|
|
119
|
+
Persist the action trace diff to global logs directory.
|
|
120
|
+
Uses EXECDIFF_LOG_DIR env var, or defaults to ~/.execdiff/logs/
|
|
121
|
+
"""
|
|
122
|
+
# Get log directory from env or use home directory
|
|
123
|
+
log_base = os.environ.get('EXECDIFF_LOG_DIR') or os.path.expanduser('~/.execdiff/logs')
|
|
124
|
+
log_file = os.path.join(log_base, "actions.jsonl")
|
|
125
|
+
|
|
126
|
+
try:
|
|
127
|
+
os.makedirs(log_base, exist_ok=True)
|
|
128
|
+
except Exception:
|
|
129
|
+
return
|
|
130
|
+
|
|
131
|
+
try:
|
|
132
|
+
entry = {
|
|
133
|
+
"timestamp": datetime.utcnow().isoformat(),
|
|
134
|
+
"workspace": os.path.abspath(_workspace),
|
|
135
|
+
"diff": diff
|
|
136
|
+
}
|
|
137
|
+
with open(log_file, "a") as f:
|
|
138
|
+
f.write(json.dumps(entry) + "\n")
|
|
139
|
+
except Exception:
|
|
140
|
+
pass
|
|
141
|
+
"""Minimal passive execution tracing library for file system snapshots."""
|
|
142
|
+
|
|
143
|
+
import os
|
|
144
|
+
import subprocess
|
|
145
|
+
import time
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
# Module-level variables to store the initial snapshot, workspace, package snapshot, and execution window
|
|
149
|
+
_initial_snapshot = None
|
|
150
|
+
_workspace = "."
|
|
151
|
+
_initial_packages = None
|
|
152
|
+
_execution_start_time = None
|
|
153
|
+
_execution_end_time = None
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def start_trace(workspace="."):
|
|
157
|
+
"""
|
|
158
|
+
Snapshot all files in the specified workspace directory recursively.
|
|
159
|
+
Stores the snapshot in a module-level variable for later comparison.
|
|
160
|
+
|
|
161
|
+
Args:
|
|
162
|
+
workspace (str): The workspace directory to trace. Defaults to ".".
|
|
163
|
+
"""
|
|
164
|
+
global _initial_snapshot, _workspace, _initial_packages, _execution_start_time
|
|
165
|
+
_workspace = workspace
|
|
166
|
+
_initial_snapshot = _take_snapshot()
|
|
167
|
+
_initial_packages = _snapshot_packages()
|
|
168
|
+
_execution_start_time = time.time()
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def stop_trace():
|
|
172
|
+
"""
|
|
173
|
+
Take a new snapshot and compare with the previous one.
|
|
174
|
+
|
|
175
|
+
Returns:
|
|
176
|
+
dict: A dictionary containing detailed information about created, modified, and deleted files:
|
|
177
|
+
{
|
|
178
|
+
"files": {
|
|
179
|
+
"created": [{"path": <file_path>, "mtime": <modified_time>}, ...],
|
|
180
|
+
"modified": [{"path": <file_path>, "before_mtime": <mtime>, "after_mtime": <mtime>}, ...],
|
|
181
|
+
"deleted": [{"path": <file_path>, "before_mtime": <mtime>}, ...]
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
"""
|
|
185
|
+
global _execution_end_time
|
|
186
|
+
if _initial_snapshot is None or _initial_packages is None or _execution_start_time is None:
|
|
187
|
+
raise RuntimeError("start_trace() must be called before stop_trace()")
|
|
188
|
+
|
|
189
|
+
_execution_end_time = time.time()
|
|
190
|
+
current_snapshot = _take_snapshot()
|
|
191
|
+
current_packages = _snapshot_packages()
|
|
192
|
+
|
|
193
|
+
# Only include files whose mtime falls within the execution window
|
|
194
|
+
def in_window(mtime):
|
|
195
|
+
return _execution_start_time <= mtime <= _execution_end_time
|
|
196
|
+
|
|
197
|
+
# Find newly created files
|
|
198
|
+
created_files = []
|
|
199
|
+
for file_path in sorted(set(current_snapshot.keys()) - set(_initial_snapshot.keys())):
|
|
200
|
+
mtime = current_snapshot[file_path]
|
|
201
|
+
if in_window(mtime):
|
|
202
|
+
created_files.append({
|
|
203
|
+
"path": file_path,
|
|
204
|
+
"mtime": mtime
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
# Find modified files (files that existed before but have different mtime)
|
|
208
|
+
modified_files = []
|
|
209
|
+
for file_path in sorted(_initial_snapshot.keys()):
|
|
210
|
+
if file_path in current_snapshot:
|
|
211
|
+
before_mtime = _initial_snapshot[file_path]
|
|
212
|
+
after_mtime = current_snapshot[file_path]
|
|
213
|
+
if after_mtime != before_mtime and in_window(after_mtime):
|
|
214
|
+
modified_files.append({
|
|
215
|
+
"path": file_path,
|
|
216
|
+
"before_mtime": before_mtime,
|
|
217
|
+
"after_mtime": after_mtime
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
# Find deleted files (files that existed before but don't exist now)
|
|
221
|
+
deleted_files = []
|
|
222
|
+
for file_path in sorted(set(_initial_snapshot.keys()) - set(current_snapshot.keys())):
|
|
223
|
+
before_mtime = _initial_snapshot[file_path]
|
|
224
|
+
if in_window(before_mtime):
|
|
225
|
+
deleted_files.append({
|
|
226
|
+
"path": file_path,
|
|
227
|
+
"before_mtime": before_mtime
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
# Find newly installed packages using pip freeze
|
|
231
|
+
installed_packages = []
|
|
232
|
+
new_pkgs = current_packages - _initial_packages
|
|
233
|
+
for pkg in sorted(new_pkgs):
|
|
234
|
+
if "==" in pkg:
|
|
235
|
+
name, version = pkg.split("==", 1)
|
|
236
|
+
installed_packages.append({"name": name, "version": version})
|
|
237
|
+
|
|
238
|
+
return {
|
|
239
|
+
"files": {
|
|
240
|
+
"created": created_files,
|
|
241
|
+
"modified": modified_files,
|
|
242
|
+
"deleted": deleted_files
|
|
243
|
+
},
|
|
244
|
+
"packages": {
|
|
245
|
+
"installed": installed_packages
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
def run_traced(command, workspace="."):
|
|
250
|
+
"""
|
|
251
|
+
Trace the effects of running a shell command in a subprocess.
|
|
252
|
+
|
|
253
|
+
Args:
|
|
254
|
+
command (list or str): The command to run (as for subprocess.run)
|
|
255
|
+
workspace (str): The workspace directory to trace. Defaults to ".".
|
|
256
|
+
|
|
257
|
+
Returns:
|
|
258
|
+
dict: The diff as returned by stop_trace().
|
|
259
|
+
"""
|
|
260
|
+
start_trace(workspace)
|
|
261
|
+
subprocess.run(command, shell=isinstance(command, str))
|
|
262
|
+
return stop_trace()
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _snapshot_packages():
|
|
266
|
+
"""
|
|
267
|
+
Take a snapshot of installed Python packages using pip freeze.
|
|
268
|
+
Returns:
|
|
269
|
+
set: Set of 'package==version' strings.
|
|
270
|
+
"""
|
|
271
|
+
try:
|
|
272
|
+
result = subprocess.run(["python3", "-m", "pip", "freeze"], capture_output=True, text=True, check=True)
|
|
273
|
+
lines = result.stdout.strip().split("\n")
|
|
274
|
+
return set(line for line in lines if line and not line.startswith("-") and "==" in line)
|
|
275
|
+
except Exception:
|
|
276
|
+
return set()
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _take_snapshot():
|
|
280
|
+
"""
|
|
281
|
+
Take a snapshot of all files in the workspace directory recursively.
|
|
282
|
+
|
|
283
|
+
Returns:
|
|
284
|
+
dict: A dictionary mapping relative file paths to their last modified time.
|
|
285
|
+
"""
|
|
286
|
+
file_dict = {}
|
|
287
|
+
|
|
288
|
+
for root, dirs, files in os.walk(_workspace):
|
|
289
|
+
for file in files:
|
|
290
|
+
file_path = os.path.join(root, file)
|
|
291
|
+
relative_path = os.path.relpath(file_path, _workspace)
|
|
292
|
+
try:
|
|
293
|
+
mtime = os.path.getmtime(file_path)
|
|
294
|
+
file_dict[relative_path] = mtime
|
|
295
|
+
except (OSError, IOError):
|
|
296
|
+
# Skip files that can't be accessed
|
|
297
|
+
pass
|
|
298
|
+
|
|
299
|
+
return file_dict
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def last_action_summary(workspace="."):
|
|
303
|
+
"""
|
|
304
|
+
Read the latest action trace from global logs and return a human-readable summary.
|
|
305
|
+
Uses EXECDIFF_LOG_DIR env var, or defaults to ~/.execdiff/logs/
|
|
306
|
+
Returns:
|
|
307
|
+
str: Human-readable summary of the last AI action, or a message if no log exists.
|
|
308
|
+
"""
|
|
309
|
+
log_base = os.environ.get('EXECDIFF_LOG_DIR') or os.path.expanduser('~/.execdiff/logs')
|
|
310
|
+
log_file = os.path.join(log_base, "actions.jsonl")
|
|
311
|
+
|
|
312
|
+
if not os.path.exists(log_file):
|
|
313
|
+
return "No action history found."
|
|
314
|
+
|
|
315
|
+
try:
|
|
316
|
+
# Read the last line
|
|
317
|
+
with open(log_file, "r") as f:
|
|
318
|
+
lines = f.readlines()
|
|
319
|
+
if not lines:
|
|
320
|
+
return "No action history found."
|
|
321
|
+
last_line = lines[-1].strip()
|
|
322
|
+
|
|
323
|
+
entry = json.loads(last_line)
|
|
324
|
+
diff = entry.get("diff", {})
|
|
325
|
+
files = diff.get("files", {})
|
|
326
|
+
packages = diff.get("packages", {})
|
|
327
|
+
|
|
328
|
+
# Build summary
|
|
329
|
+
summary_lines = ["Last AI Action:\n"]
|
|
330
|
+
|
|
331
|
+
# Packages
|
|
332
|
+
pkg_installed = packages.get("installed", [])
|
|
333
|
+
if pkg_installed:
|
|
334
|
+
summary_lines.append("Installed:")
|
|
335
|
+
for pkg in pkg_installed:
|
|
336
|
+
summary_lines.append(f"- {pkg['name']}=={pkg['version']}")
|
|
337
|
+
|
|
338
|
+
pkg_upgraded = packages.get("upgraded", [])
|
|
339
|
+
if pkg_upgraded:
|
|
340
|
+
summary_lines.append("Upgraded:")
|
|
341
|
+
for pkg in pkg_upgraded:
|
|
342
|
+
summary_lines.append(f"- {pkg['name']}: {pkg['before_version']} → {pkg['after_version']}")
|
|
343
|
+
|
|
344
|
+
pkg_removed = packages.get("removed", [])
|
|
345
|
+
if pkg_removed:
|
|
346
|
+
summary_lines.append("Removed:")
|
|
347
|
+
for pkg in pkg_removed:
|
|
348
|
+
summary_lines.append(f"- {pkg['name']}")
|
|
349
|
+
|
|
350
|
+
# Files
|
|
351
|
+
file_modified = files.get("modified", [])
|
|
352
|
+
if file_modified:
|
|
353
|
+
summary_lines.append("Modified:")
|
|
354
|
+
for f in file_modified:
|
|
355
|
+
summary_lines.append(f"- {f['path']}")
|
|
356
|
+
|
|
357
|
+
file_created = files.get("created", [])
|
|
358
|
+
if file_created:
|
|
359
|
+
summary_lines.append("Created:")
|
|
360
|
+
for f in file_created:
|
|
361
|
+
summary_lines.append(f"- {f['path']}")
|
|
362
|
+
|
|
363
|
+
file_deleted = files.get("deleted", [])
|
|
364
|
+
if file_deleted:
|
|
365
|
+
summary_lines.append("Deleted:")
|
|
366
|
+
for f in file_deleted:
|
|
367
|
+
summary_lines.append(f"- {f['path']}")
|
|
368
|
+
|
|
369
|
+
return "\n".join(summary_lines) if len(summary_lines) > 1 else "No changes detected."
|
|
370
|
+
except Exception:
|
|
371
|
+
return "Error reading action history."
|
execdiff-0.0.1/README.md
DELETED
|
@@ -1,85 +0,0 @@
|
|
|
1
|
-
# execdiff
|
|
2
|
-
Track what changes when AI-generated code runs
|
|
3
|
-
|
|
4
|
-
## Features
|
|
5
|
-
- Trace file system changes (created, modified, deleted files)
|
|
6
|
-
- Detect newly installed Python packages
|
|
7
|
-
- Track changes only within a specified workspace directory
|
|
8
|
-
- Execution window: only changes made during the traced execution are reported
|
|
9
|
-
- Simple API, no classes
|
|
10
|
-
|
|
11
|
-
## API Usage
|
|
12
|
-
|
|
13
|
-
### Basic Tracing
|
|
14
|
-
```python
|
|
15
|
-
import execdiff
|
|
16
|
-
|
|
17
|
-
execdiff.start_trace(workspace=".")
|
|
18
|
-
# ... your code that makes changes ...
|
|
19
|
-
diff = execdiff.stop_trace()
|
|
20
|
-
print(diff)
|
|
21
|
-
```
|
|
22
|
-
|
|
23
|
-
### Trace a Subprocess Command
|
|
24
|
-
```python
|
|
25
|
-
import execdiff
|
|
26
|
-
import json
|
|
27
|
-
|
|
28
|
-
diff = execdiff.run_traced(["touch", "example.txt"])
|
|
29
|
-
print(json.dumps(diff, indent=2))
|
|
30
|
-
```
|
|
31
|
-
|
|
32
|
-
### Diff Output Structure
|
|
33
|
-
The result is a dictionary with:
|
|
34
|
-
- `files`: created, modified, and deleted files (with mtimes, only those changed during execution)
|
|
35
|
-
- `packages`: newly installed Python packages (name and version)
|
|
36
|
-
|
|
37
|
-
Example:
|
|
38
|
-
```json
|
|
39
|
-
{
|
|
40
|
-
"files": {
|
|
41
|
-
"created": [ {"path": "example.txt", "mtime": 1234567890.0} ],
|
|
42
|
-
"modified": [],
|
|
43
|
-
"deleted": []
|
|
44
|
-
},
|
|
45
|
-
"packages": {
|
|
46
|
-
"installed": [ {"name": "requests", "version": "2.32.0"} ]
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
```
|
|
50
|
-
|
|
51
|
-
## Installation
|
|
52
|
-
This package is self-contained and requires only Python 3. No external dependencies are needed for core tracing features. For package detection, ensure `pip` is available in your environment.
|
|
53
|
-
|
|
54
|
-
Clone the repository and use the code directly, or copy `execdiff/` into your project.
|
|
55
|
-
|
|
56
|
-
## Running the Test
|
|
57
|
-
To verify the MVP end-to-end:
|
|
58
|
-
|
|
59
|
-
```bash
|
|
60
|
-
python3 test.py
|
|
61
|
-
```
|
|
62
|
-
|
|
63
|
-
You should see output like:
|
|
64
|
-
|
|
65
|
-
```
|
|
66
|
-
{
|
|
67
|
-
"files": {
|
|
68
|
-
"created": [
|
|
69
|
-
{"path": "test_workspace/ai_created.txt", "mtime": ...}
|
|
70
|
-
],
|
|
71
|
-
"modified": [],
|
|
72
|
-
"deleted": []
|
|
73
|
-
},
|
|
74
|
-
"packages": {
|
|
75
|
-
"installed": []
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
```
|
|
79
|
-
|
|
80
|
-
## Contributing
|
|
81
|
-
- Please open issues or pull requests for bugs, ideas, or improvements.
|
|
82
|
-
- Keep the implementation simple and function-based (no classes).
|
|
83
|
-
|
|
84
|
-
## License
|
|
85
|
-
MIT
|
|
@@ -1,159 +0,0 @@
|
|
|
1
|
-
"""Minimal passive execution tracing library for file system snapshots."""
|
|
2
|
-
|
|
3
|
-
import os
|
|
4
|
-
import subprocess
|
|
5
|
-
import time
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
# Module-level variables to store the initial snapshot, workspace, package snapshot, and execution window
|
|
9
|
-
_initial_snapshot = None
|
|
10
|
-
_workspace = "."
|
|
11
|
-
_initial_packages = None
|
|
12
|
-
_execution_start_time = None
|
|
13
|
-
_execution_end_time = None
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
def start_trace(workspace="."):
|
|
17
|
-
"""
|
|
18
|
-
Snapshot all files in the specified workspace directory recursively.
|
|
19
|
-
Stores the snapshot in a module-level variable for later comparison.
|
|
20
|
-
|
|
21
|
-
Args:
|
|
22
|
-
workspace (str): The workspace directory to trace. Defaults to ".".
|
|
23
|
-
"""
|
|
24
|
-
global _initial_snapshot, _workspace, _initial_packages, _execution_start_time
|
|
25
|
-
_workspace = workspace
|
|
26
|
-
_initial_snapshot = _take_snapshot()
|
|
27
|
-
_initial_packages = _snapshot_packages()
|
|
28
|
-
_execution_start_time = time.time()
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
def stop_trace():
|
|
32
|
-
"""
|
|
33
|
-
Take a new snapshot and compare with the previous one.
|
|
34
|
-
|
|
35
|
-
Returns:
|
|
36
|
-
dict: A dictionary containing detailed information about created, modified, and deleted files:
|
|
37
|
-
{
|
|
38
|
-
"files": {
|
|
39
|
-
"created": [{"path": <file_path>, "mtime": <modified_time>}, ...],
|
|
40
|
-
"modified": [{"path": <file_path>, "before_mtime": <mtime>, "after_mtime": <mtime>}, ...],
|
|
41
|
-
"deleted": [{"path": <file_path>, "before_mtime": <mtime>}, ...]
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
"""
|
|
45
|
-
global _execution_end_time
|
|
46
|
-
if _initial_snapshot is None or _initial_packages is None or _execution_start_time is None:
|
|
47
|
-
raise RuntimeError("start_trace() must be called before stop_trace()")
|
|
48
|
-
|
|
49
|
-
_execution_end_time = time.time()
|
|
50
|
-
current_snapshot = _take_snapshot()
|
|
51
|
-
current_packages = _snapshot_packages()
|
|
52
|
-
|
|
53
|
-
# Only include files whose mtime falls within the execution window
|
|
54
|
-
def in_window(mtime):
|
|
55
|
-
return _execution_start_time <= mtime <= _execution_end_time
|
|
56
|
-
|
|
57
|
-
# Find newly created files
|
|
58
|
-
created_files = []
|
|
59
|
-
for file_path in sorted(set(current_snapshot.keys()) - set(_initial_snapshot.keys())):
|
|
60
|
-
mtime = current_snapshot[file_path]
|
|
61
|
-
if in_window(mtime):
|
|
62
|
-
created_files.append({
|
|
63
|
-
"path": file_path,
|
|
64
|
-
"mtime": mtime
|
|
65
|
-
})
|
|
66
|
-
|
|
67
|
-
# Find modified files (files that existed before but have different mtime)
|
|
68
|
-
modified_files = []
|
|
69
|
-
for file_path in sorted(_initial_snapshot.keys()):
|
|
70
|
-
if file_path in current_snapshot:
|
|
71
|
-
before_mtime = _initial_snapshot[file_path]
|
|
72
|
-
after_mtime = current_snapshot[file_path]
|
|
73
|
-
if after_mtime != before_mtime and in_window(after_mtime):
|
|
74
|
-
modified_files.append({
|
|
75
|
-
"path": file_path,
|
|
76
|
-
"before_mtime": before_mtime,
|
|
77
|
-
"after_mtime": after_mtime
|
|
78
|
-
})
|
|
79
|
-
|
|
80
|
-
# Find deleted files (files that existed before but don't exist now)
|
|
81
|
-
deleted_files = []
|
|
82
|
-
for file_path in sorted(set(_initial_snapshot.keys()) - set(current_snapshot.keys())):
|
|
83
|
-
before_mtime = _initial_snapshot[file_path]
|
|
84
|
-
if in_window(before_mtime):
|
|
85
|
-
deleted_files.append({
|
|
86
|
-
"path": file_path,
|
|
87
|
-
"before_mtime": before_mtime
|
|
88
|
-
})
|
|
89
|
-
|
|
90
|
-
# Find newly installed packages using pip freeze
|
|
91
|
-
installed_packages = []
|
|
92
|
-
new_pkgs = current_packages - _initial_packages
|
|
93
|
-
for pkg in sorted(new_pkgs):
|
|
94
|
-
if "==" in pkg:
|
|
95
|
-
name, version = pkg.split("==", 1)
|
|
96
|
-
installed_packages.append({"name": name, "version": version})
|
|
97
|
-
|
|
98
|
-
return {
|
|
99
|
-
"files": {
|
|
100
|
-
"created": created_files,
|
|
101
|
-
"modified": modified_files,
|
|
102
|
-
"deleted": deleted_files
|
|
103
|
-
},
|
|
104
|
-
"packages": {
|
|
105
|
-
"installed": installed_packages
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
def run_traced(command, workspace="."):
|
|
110
|
-
"""
|
|
111
|
-
Trace the effects of running a shell command in a subprocess.
|
|
112
|
-
|
|
113
|
-
Args:
|
|
114
|
-
command (list or str): The command to run (as for subprocess.run)
|
|
115
|
-
workspace (str): The workspace directory to trace. Defaults to ".".
|
|
116
|
-
|
|
117
|
-
Returns:
|
|
118
|
-
dict: The diff as returned by stop_trace().
|
|
119
|
-
"""
|
|
120
|
-
start_trace(workspace)
|
|
121
|
-
subprocess.run(command, shell=isinstance(command, str))
|
|
122
|
-
return stop_trace()
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
def _snapshot_packages():
|
|
126
|
-
"""
|
|
127
|
-
Take a snapshot of installed Python packages using pip freeze.
|
|
128
|
-
Returns:
|
|
129
|
-
set: Set of 'package==version' strings.
|
|
130
|
-
"""
|
|
131
|
-
try:
|
|
132
|
-
result = subprocess.run(["python3", "-m", "pip", "freeze"], capture_output=True, text=True, check=True)
|
|
133
|
-
lines = result.stdout.strip().split("\n")
|
|
134
|
-
return set(line for line in lines if line and not line.startswith("-") and "==" in line)
|
|
135
|
-
except Exception:
|
|
136
|
-
return set()
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
def _take_snapshot():
|
|
140
|
-
"""
|
|
141
|
-
Take a snapshot of all files in the workspace directory recursively.
|
|
142
|
-
|
|
143
|
-
Returns:
|
|
144
|
-
dict: A dictionary mapping relative file paths to their last modified time.
|
|
145
|
-
"""
|
|
146
|
-
file_dict = {}
|
|
147
|
-
|
|
148
|
-
for root, dirs, files in os.walk(_workspace):
|
|
149
|
-
for file in files:
|
|
150
|
-
file_path = os.path.join(root, file)
|
|
151
|
-
relative_path = os.path.relpath(file_path, _workspace)
|
|
152
|
-
try:
|
|
153
|
-
mtime = os.path.getmtime(file_path)
|
|
154
|
-
file_dict[relative_path] = mtime
|
|
155
|
-
except (OSError, IOError):
|
|
156
|
-
# Skip files that can't be accessed
|
|
157
|
-
pass
|
|
158
|
-
|
|
159
|
-
return file_dict
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|