agmem 0.1.1__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.
- agmem-0.1.1.dist-info/METADATA +656 -0
- agmem-0.1.1.dist-info/RECORD +67 -0
- agmem-0.1.1.dist-info/WHEEL +5 -0
- agmem-0.1.1.dist-info/entry_points.txt +2 -0
- agmem-0.1.1.dist-info/licenses/LICENSE +21 -0
- agmem-0.1.1.dist-info/top_level.txt +1 -0
- memvcs/__init__.py +9 -0
- memvcs/cli.py +178 -0
- memvcs/commands/__init__.py +23 -0
- memvcs/commands/add.py +258 -0
- memvcs/commands/base.py +23 -0
- memvcs/commands/blame.py +169 -0
- memvcs/commands/branch.py +110 -0
- memvcs/commands/checkout.py +101 -0
- memvcs/commands/clean.py +76 -0
- memvcs/commands/clone.py +91 -0
- memvcs/commands/commit.py +174 -0
- memvcs/commands/daemon.py +267 -0
- memvcs/commands/diff.py +157 -0
- memvcs/commands/fsck.py +203 -0
- memvcs/commands/garden.py +107 -0
- memvcs/commands/graph.py +151 -0
- memvcs/commands/init.py +61 -0
- memvcs/commands/log.py +103 -0
- memvcs/commands/mcp.py +59 -0
- memvcs/commands/merge.py +88 -0
- memvcs/commands/pull.py +65 -0
- memvcs/commands/push.py +143 -0
- memvcs/commands/reflog.py +52 -0
- memvcs/commands/remote.py +51 -0
- memvcs/commands/reset.py +98 -0
- memvcs/commands/search.py +163 -0
- memvcs/commands/serve.py +54 -0
- memvcs/commands/show.py +125 -0
- memvcs/commands/stash.py +97 -0
- memvcs/commands/status.py +112 -0
- memvcs/commands/tag.py +117 -0
- memvcs/commands/test.py +132 -0
- memvcs/commands/tree.py +156 -0
- memvcs/core/__init__.py +21 -0
- memvcs/core/config_loader.py +245 -0
- memvcs/core/constants.py +12 -0
- memvcs/core/diff.py +380 -0
- memvcs/core/gardener.py +466 -0
- memvcs/core/hooks.py +151 -0
- memvcs/core/knowledge_graph.py +381 -0
- memvcs/core/merge.py +474 -0
- memvcs/core/objects.py +323 -0
- memvcs/core/pii_scanner.py +343 -0
- memvcs/core/refs.py +447 -0
- memvcs/core/remote.py +278 -0
- memvcs/core/repository.py +522 -0
- memvcs/core/schema.py +414 -0
- memvcs/core/staging.py +227 -0
- memvcs/core/storage/__init__.py +72 -0
- memvcs/core/storage/base.py +359 -0
- memvcs/core/storage/gcs.py +308 -0
- memvcs/core/storage/local.py +182 -0
- memvcs/core/storage/s3.py +369 -0
- memvcs/core/test_runner.py +371 -0
- memvcs/core/vector_store.py +313 -0
- memvcs/integrations/__init__.py +5 -0
- memvcs/integrations/mcp_server.py +267 -0
- memvcs/integrations/web_ui/__init__.py +1 -0
- memvcs/integrations/web_ui/server.py +352 -0
- memvcs/utils/__init__.py +9 -0
- memvcs/utils/helpers.py +178 -0
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""
|
|
2
|
+
agmem commit - Save staged changes as a snapshot.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
|
|
8
|
+
from ..commands.base import require_repo
|
|
9
|
+
from ..core.schema import SchemaValidator
|
|
10
|
+
from ..core.hooks import run_pre_commit_hooks
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class CommitCommand:
|
|
14
|
+
"""Create a commit from staged changes."""
|
|
15
|
+
|
|
16
|
+
name = 'commit'
|
|
17
|
+
help = 'Save staged changes as a memory snapshot'
|
|
18
|
+
|
|
19
|
+
@staticmethod
|
|
20
|
+
def add_arguments(parser: argparse.ArgumentParser):
|
|
21
|
+
parser.add_argument(
|
|
22
|
+
'-m', '--message',
|
|
23
|
+
required=True,
|
|
24
|
+
help='Commit message describing the changes'
|
|
25
|
+
)
|
|
26
|
+
parser.add_argument(
|
|
27
|
+
'--author',
|
|
28
|
+
help='Override default author'
|
|
29
|
+
)
|
|
30
|
+
parser.add_argument(
|
|
31
|
+
'--no-verify',
|
|
32
|
+
action='store_true',
|
|
33
|
+
help='Skip pre-commit hooks and schema validation'
|
|
34
|
+
)
|
|
35
|
+
parser.add_argument(
|
|
36
|
+
'--strict',
|
|
37
|
+
action='store_true',
|
|
38
|
+
help='Treat schema warnings as errors'
|
|
39
|
+
)
|
|
40
|
+
parser.add_argument(
|
|
41
|
+
'--run-tests',
|
|
42
|
+
action='store_true',
|
|
43
|
+
help='Run memory tests before committing'
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
@staticmethod
|
|
47
|
+
def _get_blob_hash(file_info) -> str:
|
|
48
|
+
"""Get blob hash from StagedFile or dict (for hooks that pass either)."""
|
|
49
|
+
if hasattr(file_info, 'blob_hash'):
|
|
50
|
+
return file_info.blob_hash
|
|
51
|
+
if isinstance(file_info, dict):
|
|
52
|
+
return file_info.get('blob_hash') or file_info.get('hash') or ''
|
|
53
|
+
return ''
|
|
54
|
+
|
|
55
|
+
@staticmethod
|
|
56
|
+
def _validate_staged_files(repo, staged: dict, strict: bool) -> tuple:
|
|
57
|
+
"""
|
|
58
|
+
Validate staged files for schema compliance.
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
Tuple of (success, validation_results)
|
|
62
|
+
"""
|
|
63
|
+
validation_results = {}
|
|
64
|
+
has_errors = False
|
|
65
|
+
|
|
66
|
+
for filepath, file_info in staged.items():
|
|
67
|
+
blob_hash = CommitCommand._get_blob_hash(file_info)
|
|
68
|
+
if not blob_hash:
|
|
69
|
+
continue
|
|
70
|
+
|
|
71
|
+
# Read content from object store
|
|
72
|
+
from ..core.objects import Blob
|
|
73
|
+
blob = Blob.load(repo.object_store, blob_hash)
|
|
74
|
+
if not blob:
|
|
75
|
+
continue
|
|
76
|
+
|
|
77
|
+
try:
|
|
78
|
+
content = blob.content.decode('utf-8')
|
|
79
|
+
except UnicodeDecodeError:
|
|
80
|
+
# Skip binary files
|
|
81
|
+
continue
|
|
82
|
+
|
|
83
|
+
# Validate the file
|
|
84
|
+
result = SchemaValidator.validate(content, filepath, strict=strict)
|
|
85
|
+
validation_results[filepath] = result
|
|
86
|
+
|
|
87
|
+
if not result.valid:
|
|
88
|
+
has_errors = True
|
|
89
|
+
|
|
90
|
+
return not has_errors, validation_results
|
|
91
|
+
|
|
92
|
+
@staticmethod
|
|
93
|
+
def _print_validation_results(results: dict) -> None:
|
|
94
|
+
"""Print validation results in a readable format."""
|
|
95
|
+
for filepath, result in results.items():
|
|
96
|
+
if not result.valid or result.warnings:
|
|
97
|
+
print(f"\n {filepath}:")
|
|
98
|
+
for error in result.errors:
|
|
99
|
+
print(f" ✗ {error.field}: {error.message}")
|
|
100
|
+
for warning in result.warnings:
|
|
101
|
+
print(f" ⚠ {warning.field}: {warning.message}")
|
|
102
|
+
|
|
103
|
+
@staticmethod
|
|
104
|
+
def _run_hooks_and_validation(repo, staged: dict, args) -> tuple:
|
|
105
|
+
"""Run pre-commit hooks and schema validation. Returns (success, error_code or 0)."""
|
|
106
|
+
hook_result = run_pre_commit_hooks(repo, staged)
|
|
107
|
+
if not hook_result.success:
|
|
108
|
+
print("Pre-commit hook failed:")
|
|
109
|
+
for error in hook_result.errors:
|
|
110
|
+
print(f" ✗ {error}")
|
|
111
|
+
print("\nUse --no-verify to bypass hooks (not recommended).")
|
|
112
|
+
return False, 1
|
|
113
|
+
valid, results = CommitCommand._validate_staged_files(repo, staged, strict=args.strict)
|
|
114
|
+
if not valid:
|
|
115
|
+
print("Schema validation failed:")
|
|
116
|
+
CommitCommand._print_validation_results(results)
|
|
117
|
+
print("\nFix the errors above or use --no-verify to bypass validation.")
|
|
118
|
+
return False, 1
|
|
119
|
+
if any(r.warnings for r in results.values()):
|
|
120
|
+
print("Schema validation warnings:")
|
|
121
|
+
CommitCommand._print_validation_results(results)
|
|
122
|
+
return True, 0
|
|
123
|
+
|
|
124
|
+
@staticmethod
|
|
125
|
+
def _run_memory_tests(repo) -> int:
|
|
126
|
+
"""Run memory tests if available. Returns 1 on failure, 0 on success or skip."""
|
|
127
|
+
try:
|
|
128
|
+
from ..core.test_runner import TestRunner
|
|
129
|
+
test_runner = TestRunner(repo)
|
|
130
|
+
test_result = test_runner.run_all()
|
|
131
|
+
if not test_result.passed:
|
|
132
|
+
print("Memory tests failed:")
|
|
133
|
+
for failure in test_result.failures:
|
|
134
|
+
print(f" ✗ {failure.test_name}: {failure.message}")
|
|
135
|
+
print("\nFix failing tests before committing.")
|
|
136
|
+
return 1
|
|
137
|
+
print(f"Memory tests passed: {test_result.passed_count}/{test_result.total_count}")
|
|
138
|
+
except ImportError:
|
|
139
|
+
print("Warning: Test runner not available. Skipping tests.")
|
|
140
|
+
return 0
|
|
141
|
+
|
|
142
|
+
@staticmethod
|
|
143
|
+
def execute(args) -> int:
|
|
144
|
+
repo, code = require_repo()
|
|
145
|
+
if code != 0:
|
|
146
|
+
return code
|
|
147
|
+
staged = repo.staging.get_staged_files()
|
|
148
|
+
if not staged:
|
|
149
|
+
print("Error: No changes staged for commit.")
|
|
150
|
+
print("Run 'agmem add <file>' to stage changes first.")
|
|
151
|
+
return 1
|
|
152
|
+
if not args.no_verify:
|
|
153
|
+
ok, err = CommitCommand._run_hooks_and_validation(repo, staged, args)
|
|
154
|
+
if not ok:
|
|
155
|
+
return err
|
|
156
|
+
if args.run_tests:
|
|
157
|
+
if CommitCommand._run_memory_tests(repo) != 0:
|
|
158
|
+
return 1
|
|
159
|
+
metadata = {
|
|
160
|
+
'files_changed': len(staged),
|
|
161
|
+
'timestamp': datetime.utcnow().isoformat() + 'Z'
|
|
162
|
+
}
|
|
163
|
+
if args.author:
|
|
164
|
+
config = repo.get_config()
|
|
165
|
+
config['author']['name'] = args.author
|
|
166
|
+
repo.set_config(config)
|
|
167
|
+
try:
|
|
168
|
+
commit_hash = repo.commit(args.message, metadata)
|
|
169
|
+
print(f"[{repo.refs.get_current_branch() or 'HEAD'} {commit_hash[:8]}] {args.message}")
|
|
170
|
+
print(f" {len(staged)} file(s) changed")
|
|
171
|
+
return 0
|
|
172
|
+
except Exception as e:
|
|
173
|
+
print(f"Error creating commit: {e}")
|
|
174
|
+
return 1
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
"""
|
|
2
|
+
agmem daemon - Auto-sync daemon for automatic commits.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
import time
|
|
9
|
+
import signal
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from ..commands.base import require_repo
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class DaemonCommand:
|
|
16
|
+
"""Control the auto-sync daemon."""
|
|
17
|
+
|
|
18
|
+
name = 'daemon'
|
|
19
|
+
help = 'Start/stop the auto-sync daemon for automatic commits'
|
|
20
|
+
|
|
21
|
+
@staticmethod
|
|
22
|
+
def add_arguments(parser: argparse.ArgumentParser):
|
|
23
|
+
parser.add_argument(
|
|
24
|
+
'action',
|
|
25
|
+
choices=['start', 'stop', 'status', 'run'],
|
|
26
|
+
help='Daemon action: start, stop, status, or run (foreground)'
|
|
27
|
+
)
|
|
28
|
+
parser.add_argument(
|
|
29
|
+
'--debounce',
|
|
30
|
+
type=int,
|
|
31
|
+
default=30,
|
|
32
|
+
help='Seconds to wait after changes before committing (default: 30)'
|
|
33
|
+
)
|
|
34
|
+
parser.add_argument(
|
|
35
|
+
'--pidfile',
|
|
36
|
+
help='PID file path (default: .mem/daemon.pid)'
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
@staticmethod
|
|
40
|
+
def execute(args) -> int:
|
|
41
|
+
repo, code = require_repo()
|
|
42
|
+
if code != 0:
|
|
43
|
+
return code
|
|
44
|
+
|
|
45
|
+
if args.pidfile:
|
|
46
|
+
pid_file = Path(args.pidfile).resolve()
|
|
47
|
+
try:
|
|
48
|
+
pid_file.relative_to(repo.root.resolve())
|
|
49
|
+
except ValueError:
|
|
50
|
+
print("Error: --pidfile must be under repository root")
|
|
51
|
+
return 1
|
|
52
|
+
else:
|
|
53
|
+
pid_file = repo.root / '.mem' / 'daemon.pid'
|
|
54
|
+
|
|
55
|
+
if args.action == 'start':
|
|
56
|
+
return DaemonCommand._start(repo, pid_file, args.debounce)
|
|
57
|
+
elif args.action == 'stop':
|
|
58
|
+
return DaemonCommand._stop(pid_file)
|
|
59
|
+
elif args.action == 'status':
|
|
60
|
+
return DaemonCommand._status(pid_file)
|
|
61
|
+
elif args.action == 'run':
|
|
62
|
+
return DaemonCommand._run(repo, args.debounce)
|
|
63
|
+
|
|
64
|
+
return 1
|
|
65
|
+
|
|
66
|
+
@staticmethod
|
|
67
|
+
def _start(repo, pid_file: Path, debounce: int) -> int:
|
|
68
|
+
"""Start daemon in background."""
|
|
69
|
+
# Check if already running
|
|
70
|
+
if pid_file.exists():
|
|
71
|
+
pid = int(pid_file.read_text().strip())
|
|
72
|
+
try:
|
|
73
|
+
os.kill(pid, 0)
|
|
74
|
+
print(f"Daemon already running (PID: {pid})")
|
|
75
|
+
return 1
|
|
76
|
+
except OSError:
|
|
77
|
+
pid_file.unlink()
|
|
78
|
+
|
|
79
|
+
# Fork to background (Unix only)
|
|
80
|
+
if os.name != 'posix':
|
|
81
|
+
print("Background daemon not supported on this platform.")
|
|
82
|
+
print("Use 'agmem daemon run' to run in foreground.")
|
|
83
|
+
return 1
|
|
84
|
+
|
|
85
|
+
# First fork
|
|
86
|
+
try:
|
|
87
|
+
pid = os.fork()
|
|
88
|
+
if pid > 0:
|
|
89
|
+
# Parent exits
|
|
90
|
+
print(f"Daemon started (PID: {pid})")
|
|
91
|
+
return 0
|
|
92
|
+
except OSError as e:
|
|
93
|
+
print(f"Fork failed: {e}")
|
|
94
|
+
return 1
|
|
95
|
+
|
|
96
|
+
# Decouple from parent
|
|
97
|
+
os.setsid()
|
|
98
|
+
os.umask(0)
|
|
99
|
+
|
|
100
|
+
# Second fork
|
|
101
|
+
try:
|
|
102
|
+
pid = os.fork()
|
|
103
|
+
if pid > 0:
|
|
104
|
+
sys.exit(0)
|
|
105
|
+
except OSError:
|
|
106
|
+
sys.exit(1)
|
|
107
|
+
|
|
108
|
+
# Write PID file
|
|
109
|
+
pid_file.write_text(str(os.getpid()))
|
|
110
|
+
|
|
111
|
+
# Redirect stdio
|
|
112
|
+
sys.stdout.flush()
|
|
113
|
+
sys.stderr.flush()
|
|
114
|
+
|
|
115
|
+
# Run daemon
|
|
116
|
+
return DaemonCommand._run(repo, debounce, pid_file)
|
|
117
|
+
|
|
118
|
+
@staticmethod
|
|
119
|
+
def _stop(pid_file: Path) -> int:
|
|
120
|
+
"""Stop running daemon."""
|
|
121
|
+
if not pid_file.exists():
|
|
122
|
+
print("No daemon running")
|
|
123
|
+
return 0
|
|
124
|
+
|
|
125
|
+
pid = int(pid_file.read_text().strip())
|
|
126
|
+
|
|
127
|
+
try:
|
|
128
|
+
os.kill(pid, signal.SIGTERM)
|
|
129
|
+
print(f"Stopped daemon (PID: {pid})")
|
|
130
|
+
pid_file.unlink()
|
|
131
|
+
return 0
|
|
132
|
+
except OSError as e:
|
|
133
|
+
print(f"Could not stop daemon: {e}")
|
|
134
|
+
pid_file.unlink()
|
|
135
|
+
return 1
|
|
136
|
+
|
|
137
|
+
@staticmethod
|
|
138
|
+
def _status(pid_file: Path) -> int:
|
|
139
|
+
"""Show daemon status."""
|
|
140
|
+
if not pid_file.exists():
|
|
141
|
+
print("Daemon is not running")
|
|
142
|
+
return 0
|
|
143
|
+
|
|
144
|
+
pid = int(pid_file.read_text().strip())
|
|
145
|
+
|
|
146
|
+
try:
|
|
147
|
+
os.kill(pid, 0)
|
|
148
|
+
print(f"Daemon is running (PID: {pid})")
|
|
149
|
+
return 0
|
|
150
|
+
except OSError:
|
|
151
|
+
print("Daemon is not running (stale PID file)")
|
|
152
|
+
pid_file.unlink()
|
|
153
|
+
return 0
|
|
154
|
+
|
|
155
|
+
@staticmethod
|
|
156
|
+
def _run(repo, debounce: int, pid_file: Path = None) -> int:
|
|
157
|
+
"""Run daemon in foreground."""
|
|
158
|
+
try:
|
|
159
|
+
from watchdog.observers import Observer
|
|
160
|
+
from watchdog.events import FileSystemEventHandler
|
|
161
|
+
except ImportError:
|
|
162
|
+
print("Daemon requires watchdog. Install with: pip install agmem[daemon]")
|
|
163
|
+
return 1
|
|
164
|
+
|
|
165
|
+
current_dir = repo.root / 'current'
|
|
166
|
+
|
|
167
|
+
if not current_dir.exists():
|
|
168
|
+
print("No current/ directory to watch")
|
|
169
|
+
return 1
|
|
170
|
+
|
|
171
|
+
class MemoryFileHandler(FileSystemEventHandler):
|
|
172
|
+
def __init__(self):
|
|
173
|
+
self.last_change = 0
|
|
174
|
+
self.pending = False
|
|
175
|
+
|
|
176
|
+
def on_any_event(self, event):
|
|
177
|
+
# Ignore .mem and hidden files
|
|
178
|
+
if '.mem' in event.src_path or '/.' in event.src_path:
|
|
179
|
+
return
|
|
180
|
+
|
|
181
|
+
# Ignore directories
|
|
182
|
+
if event.is_directory:
|
|
183
|
+
return
|
|
184
|
+
|
|
185
|
+
self.last_change = time.time()
|
|
186
|
+
self.pending = True
|
|
187
|
+
|
|
188
|
+
handler = MemoryFileHandler()
|
|
189
|
+
observer = Observer()
|
|
190
|
+
observer.schedule(handler, str(current_dir), recursive=True)
|
|
191
|
+
observer.start()
|
|
192
|
+
|
|
193
|
+
print(f"Watching {current_dir} (debounce: {debounce}s)")
|
|
194
|
+
print("Press Ctrl+C to stop")
|
|
195
|
+
|
|
196
|
+
# Handle signals
|
|
197
|
+
running = True
|
|
198
|
+
|
|
199
|
+
def signal_handler(signum, frame):
|
|
200
|
+
nonlocal running
|
|
201
|
+
running = False
|
|
202
|
+
|
|
203
|
+
signal.signal(signal.SIGTERM, signal_handler)
|
|
204
|
+
signal.signal(signal.SIGINT, signal_handler)
|
|
205
|
+
|
|
206
|
+
try:
|
|
207
|
+
while running:
|
|
208
|
+
time.sleep(1)
|
|
209
|
+
|
|
210
|
+
if handler.pending:
|
|
211
|
+
elapsed = time.time() - handler.last_change
|
|
212
|
+
if elapsed >= debounce:
|
|
213
|
+
# Auto-commit
|
|
214
|
+
DaemonCommand._auto_commit(repo)
|
|
215
|
+
handler.pending = False
|
|
216
|
+
finally:
|
|
217
|
+
observer.stop()
|
|
218
|
+
observer.join()
|
|
219
|
+
|
|
220
|
+
if pid_file and pid_file.exists():
|
|
221
|
+
pid_file.unlink()
|
|
222
|
+
|
|
223
|
+
print("Daemon stopped")
|
|
224
|
+
return 0
|
|
225
|
+
|
|
226
|
+
@staticmethod
|
|
227
|
+
def _auto_commit(repo):
|
|
228
|
+
"""Perform automatic commit."""
|
|
229
|
+
from datetime import datetime
|
|
230
|
+
|
|
231
|
+
try:
|
|
232
|
+
# Check for changes
|
|
233
|
+
status = repo.get_status()
|
|
234
|
+
|
|
235
|
+
if not status.get('modified') and not status.get('untracked'):
|
|
236
|
+
return
|
|
237
|
+
|
|
238
|
+
# Stage all changes in current/ (validate path stays under current/)
|
|
239
|
+
current_dir = repo.root / 'current'
|
|
240
|
+
for memory_file in current_dir.glob('**/*'):
|
|
241
|
+
if memory_file.is_file() and '.mem' not in str(memory_file):
|
|
242
|
+
try:
|
|
243
|
+
rel_path = memory_file.relative_to(current_dir)
|
|
244
|
+
except ValueError:
|
|
245
|
+
continue
|
|
246
|
+
rel_str = str(rel_path)
|
|
247
|
+
if repo._path_under_current_dir(rel_str) is None:
|
|
248
|
+
continue
|
|
249
|
+
try:
|
|
250
|
+
repo.stage_file(rel_str)
|
|
251
|
+
except Exception:
|
|
252
|
+
pass
|
|
253
|
+
|
|
254
|
+
# Commit
|
|
255
|
+
timestamp = datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S')
|
|
256
|
+
repo.commit(
|
|
257
|
+
f"auto: update memory state ({timestamp})",
|
|
258
|
+
{'auto_commit': True}
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
print(f"[{timestamp}] Auto-committed changes")
|
|
262
|
+
|
|
263
|
+
except Exception as e:
|
|
264
|
+
# Write conflict lock if there's an issue
|
|
265
|
+
conflict_file = repo.root / '.mem' / 'CONFLICT.lock'
|
|
266
|
+
conflict_file.write_text(f"Auto-commit failed: {e}")
|
|
267
|
+
print(f"Warning: Auto-commit failed, wrote CONFLICT.lock: {e}")
|
memvcs/commands/diff.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""
|
|
2
|
+
agmem diff - Show changes between commits, commit and working tree, etc.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from ..commands.base import require_repo
|
|
10
|
+
from ..core.diff import DiffEngine
|
|
11
|
+
from ..core.repository import Repository
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class DiffCommand:
|
|
15
|
+
"""Show differences between commits."""
|
|
16
|
+
|
|
17
|
+
name = 'diff'
|
|
18
|
+
help = 'Show changes between commits, commit and working tree, etc.'
|
|
19
|
+
|
|
20
|
+
@staticmethod
|
|
21
|
+
def add_arguments(parser: argparse.ArgumentParser):
|
|
22
|
+
parser.add_argument(
|
|
23
|
+
'ref1',
|
|
24
|
+
nargs='?',
|
|
25
|
+
help='First commit/branch to compare'
|
|
26
|
+
)
|
|
27
|
+
parser.add_argument(
|
|
28
|
+
'ref2',
|
|
29
|
+
nargs='?',
|
|
30
|
+
help='Second commit/branch to compare'
|
|
31
|
+
)
|
|
32
|
+
parser.add_argument(
|
|
33
|
+
'--cached', '--staged',
|
|
34
|
+
action='store_true',
|
|
35
|
+
help='Show changes staged for commit'
|
|
36
|
+
)
|
|
37
|
+
parser.add_argument(
|
|
38
|
+
'--stat',
|
|
39
|
+
action='store_true',
|
|
40
|
+
help='Show diffstat instead of full diff'
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
@staticmethod
|
|
44
|
+
def execute(args) -> int:
|
|
45
|
+
repo, code = require_repo()
|
|
46
|
+
if code != 0:
|
|
47
|
+
return code
|
|
48
|
+
|
|
49
|
+
engine = DiffEngine(repo.object_store)
|
|
50
|
+
|
|
51
|
+
# Determine what to diff
|
|
52
|
+
if args.cached:
|
|
53
|
+
# Diff staged changes against HEAD
|
|
54
|
+
head_commit = repo.get_head_commit()
|
|
55
|
+
if not head_commit:
|
|
56
|
+
print("No commits yet. Nothing to diff.")
|
|
57
|
+
return 0
|
|
58
|
+
|
|
59
|
+
staged_files = repo.staging.get_staged_files()
|
|
60
|
+
if not staged_files:
|
|
61
|
+
print("No staged changes.")
|
|
62
|
+
return 0
|
|
63
|
+
|
|
64
|
+
# Get staged content
|
|
65
|
+
working_files = {}
|
|
66
|
+
for path, sf in staged_files.items():
|
|
67
|
+
from ..core.objects import Blob
|
|
68
|
+
blob = Blob.load(repo.object_store, sf.blob_hash)
|
|
69
|
+
if blob:
|
|
70
|
+
working_files[path] = blob.content
|
|
71
|
+
|
|
72
|
+
tree_diff = engine.diff_working_dir(
|
|
73
|
+
head_commit.store(repo.object_store),
|
|
74
|
+
working_files
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
print(engine.format_diff(tree_diff, 'HEAD', 'staged'))
|
|
78
|
+
return 0
|
|
79
|
+
|
|
80
|
+
# Diff between two refs
|
|
81
|
+
if args.ref1 and args.ref2:
|
|
82
|
+
commit1 = repo.resolve_ref(args.ref1)
|
|
83
|
+
commit2 = repo.resolve_ref(args.ref2)
|
|
84
|
+
|
|
85
|
+
if not commit1:
|
|
86
|
+
print(f"Error: Unknown revision: {args.ref1}")
|
|
87
|
+
return 1
|
|
88
|
+
if not commit2:
|
|
89
|
+
print(f"Error: Unknown revision: {args.ref2}")
|
|
90
|
+
return 1
|
|
91
|
+
|
|
92
|
+
tree_diff = engine.diff_commits(commit1, commit2)
|
|
93
|
+
|
|
94
|
+
if args.stat:
|
|
95
|
+
print(f" {tree_diff.added_count} file(s) added")
|
|
96
|
+
print(f" {tree_diff.deleted_count} file(s) deleted")
|
|
97
|
+
print(f" {tree_diff.modified_count} file(s) modified")
|
|
98
|
+
else:
|
|
99
|
+
print(engine.format_diff(tree_diff, args.ref1, args.ref2))
|
|
100
|
+
|
|
101
|
+
return 0
|
|
102
|
+
|
|
103
|
+
# Diff working tree against a ref
|
|
104
|
+
if args.ref1:
|
|
105
|
+
commit_hash = repo.resolve_ref(args.ref1)
|
|
106
|
+
if not commit_hash:
|
|
107
|
+
print(f"Error: Unknown revision: {args.ref1}")
|
|
108
|
+
return 1
|
|
109
|
+
|
|
110
|
+
# Get working directory files
|
|
111
|
+
working_files = {}
|
|
112
|
+
for root, dirs, files in os.walk(repo.current_dir):
|
|
113
|
+
dirs[:] = [d for d in dirs if not d.startswith('.')]
|
|
114
|
+
for filename in files:
|
|
115
|
+
full_path = Path(root) / filename
|
|
116
|
+
rel_path = str(full_path.relative_to(repo.current_dir))
|
|
117
|
+
working_files[rel_path] = full_path.read_bytes()
|
|
118
|
+
|
|
119
|
+
tree_diff = engine.diff_working_dir(commit_hash, working_files)
|
|
120
|
+
|
|
121
|
+
if args.stat:
|
|
122
|
+
print(f" {tree_diff.added_count} file(s) added")
|
|
123
|
+
print(f" {tree_diff.deleted_count} file(s) deleted")
|
|
124
|
+
print(f" {tree_diff.modified_count} file(s) modified")
|
|
125
|
+
else:
|
|
126
|
+
print(engine.format_diff(tree_diff, args.ref1, 'working'))
|
|
127
|
+
|
|
128
|
+
return 0
|
|
129
|
+
|
|
130
|
+
# Default: diff working tree against HEAD
|
|
131
|
+
head_commit = repo.get_head_commit()
|
|
132
|
+
if not head_commit:
|
|
133
|
+
print("No commits yet. Nothing to diff.")
|
|
134
|
+
return 0
|
|
135
|
+
|
|
136
|
+
# Get working directory files
|
|
137
|
+
working_files = {}
|
|
138
|
+
for root, dirs, files in os.walk(repo.current_dir):
|
|
139
|
+
dirs[:] = [d for d in dirs if not d.startswith('.')]
|
|
140
|
+
for filename in files:
|
|
141
|
+
full_path = Path(root) / filename
|
|
142
|
+
rel_path = str(full_path.relative_to(repo.current_dir))
|
|
143
|
+
working_files[rel_path] = full_path.read_bytes()
|
|
144
|
+
|
|
145
|
+
tree_diff = engine.diff_working_dir(
|
|
146
|
+
head_commit.store(repo.object_store),
|
|
147
|
+
working_files
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
if args.stat:
|
|
151
|
+
print(f" {tree_diff.added_count} file(s) added")
|
|
152
|
+
print(f" {tree_diff.deleted_count} file(s) deleted")
|
|
153
|
+
print(f" {tree_diff.modified_count} file(s) modified")
|
|
154
|
+
else:
|
|
155
|
+
print(engine.format_diff(tree_diff, 'HEAD', 'working'))
|
|
156
|
+
|
|
157
|
+
return 0
|