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
memvcs/commands/blame.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""
|
|
2
|
+
agmem blame - Show who changed each line (Git-like) or trace semantic facts.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from ..commands.base import require_repo
|
|
9
|
+
from ..core.repository import Repository
|
|
10
|
+
from ..core.objects import Commit, Tree, Blob
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class BlameCommand:
|
|
14
|
+
"""Show author and commit for each line of a file, or trace semantic facts."""
|
|
15
|
+
|
|
16
|
+
name = 'blame'
|
|
17
|
+
help = 'Show who changed each line of a memory file, or trace semantic facts'
|
|
18
|
+
|
|
19
|
+
@staticmethod
|
|
20
|
+
def add_arguments(parser: argparse.ArgumentParser):
|
|
21
|
+
parser.add_argument(
|
|
22
|
+
'file',
|
|
23
|
+
nargs='?',
|
|
24
|
+
help='File to blame (path relative to current/)'
|
|
25
|
+
)
|
|
26
|
+
parser.add_argument(
|
|
27
|
+
'ref',
|
|
28
|
+
nargs='?',
|
|
29
|
+
default='HEAD',
|
|
30
|
+
help='Commit to blame at (default: HEAD)'
|
|
31
|
+
)
|
|
32
|
+
parser.add_argument(
|
|
33
|
+
'--query', '-q',
|
|
34
|
+
help='Semantic query to trace (e.g., "Why does agent think X?")'
|
|
35
|
+
)
|
|
36
|
+
parser.add_argument(
|
|
37
|
+
'--limit', '-n',
|
|
38
|
+
type=int,
|
|
39
|
+
default=5,
|
|
40
|
+
help='Number of results to show for semantic blame (default: 5)'
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
@staticmethod
|
|
44
|
+
def execute(args) -> int:
|
|
45
|
+
repo, code = require_repo()
|
|
46
|
+
if code != 0:
|
|
47
|
+
return code
|
|
48
|
+
|
|
49
|
+
# Semantic blame mode
|
|
50
|
+
if args.query:
|
|
51
|
+
return BlameCommand._semantic_blame(repo, args.query, args.limit)
|
|
52
|
+
|
|
53
|
+
# File blame mode
|
|
54
|
+
if not args.file:
|
|
55
|
+
print("Error: Either --query or a file path is required.")
|
|
56
|
+
print("Usage: agmem blame <file> [ref]")
|
|
57
|
+
print(" agmem blame --query \"Why does agent think X?\"")
|
|
58
|
+
return 1
|
|
59
|
+
|
|
60
|
+
return BlameCommand._file_blame(repo, args.file, args.ref)
|
|
61
|
+
|
|
62
|
+
@staticmethod
|
|
63
|
+
def _file_blame(repo, filepath: str, ref: str) -> int:
|
|
64
|
+
"""Traditional file-based blame."""
|
|
65
|
+
commit_hash = repo.resolve_ref(ref)
|
|
66
|
+
if not commit_hash:
|
|
67
|
+
print(f"Error: Unknown revision: {ref}")
|
|
68
|
+
return 1
|
|
69
|
+
|
|
70
|
+
# Get file content at commit
|
|
71
|
+
tree = repo.get_commit_tree(commit_hash)
|
|
72
|
+
if not tree:
|
|
73
|
+
print("Error: Could not load tree.")
|
|
74
|
+
return 1
|
|
75
|
+
|
|
76
|
+
# Find file in tree (support path like semantic/user-prefs.md)
|
|
77
|
+
blob_hash = None
|
|
78
|
+
for entry in tree.entries:
|
|
79
|
+
path = entry.path + '/' + entry.name if entry.path else entry.name
|
|
80
|
+
if path == filepath:
|
|
81
|
+
blob_hash = entry.hash
|
|
82
|
+
break
|
|
83
|
+
|
|
84
|
+
if not blob_hash:
|
|
85
|
+
print(f"Error: File not found in {ref}: {filepath}")
|
|
86
|
+
return 1
|
|
87
|
+
|
|
88
|
+
blob = Blob.load(repo.object_store, blob_hash)
|
|
89
|
+
if not blob:
|
|
90
|
+
print("Error: Could not load file content.")
|
|
91
|
+
return 1
|
|
92
|
+
|
|
93
|
+
lines = blob.content.decode('utf-8', errors='replace').splitlines()
|
|
94
|
+
commit = Commit.load(repo.object_store, commit_hash)
|
|
95
|
+
author_short = commit.author.split('<')[0].strip()[:20] if commit else 'unknown'
|
|
96
|
+
hash_short = commit_hash[:8]
|
|
97
|
+
|
|
98
|
+
for i, line in enumerate(lines, 1):
|
|
99
|
+
print(f"{hash_short} ({author_short:20} {i:4}) {line}")
|
|
100
|
+
|
|
101
|
+
return 0
|
|
102
|
+
|
|
103
|
+
@staticmethod
|
|
104
|
+
def _semantic_blame(repo, query: str, limit: int) -> int:
|
|
105
|
+
"""
|
|
106
|
+
Semantic blame - trace which commit introduced a fact.
|
|
107
|
+
|
|
108
|
+
Searches the vector store and shows provenance for matching chunks.
|
|
109
|
+
"""
|
|
110
|
+
try:
|
|
111
|
+
from ..core.vector_store import VectorStore
|
|
112
|
+
except ImportError:
|
|
113
|
+
print("Error: Vector search requires sqlite-vec.")
|
|
114
|
+
print("Install with: pip install agmem[vector]")
|
|
115
|
+
return 1
|
|
116
|
+
|
|
117
|
+
try:
|
|
118
|
+
vs = VectorStore(repo.root / '.mem')
|
|
119
|
+
results = vs.search_with_provenance(query, limit=limit)
|
|
120
|
+
except Exception as e:
|
|
121
|
+
print(f"Error: Vector search failed: {e}")
|
|
122
|
+
print("Try running 'agmem search --rebuild' to rebuild the index.")
|
|
123
|
+
return 1
|
|
124
|
+
|
|
125
|
+
if not results:
|
|
126
|
+
print("No matching facts found in memory.")
|
|
127
|
+
print("Try rebuilding the index with 'agmem search --rebuild'")
|
|
128
|
+
return 0
|
|
129
|
+
|
|
130
|
+
print(f"Semantic blame for: \"{query}\"")
|
|
131
|
+
print("=" * 60)
|
|
132
|
+
|
|
133
|
+
for i, result in enumerate(results, 1):
|
|
134
|
+
path = result['path']
|
|
135
|
+
content = result['content']
|
|
136
|
+
similarity = result['similarity']
|
|
137
|
+
commit_hash = result['commit_hash']
|
|
138
|
+
author = result['author']
|
|
139
|
+
indexed_at = result['indexed_at']
|
|
140
|
+
|
|
141
|
+
print(f"\n[{i}] {path}")
|
|
142
|
+
print(f" Similarity: {similarity:.2%}")
|
|
143
|
+
|
|
144
|
+
if commit_hash:
|
|
145
|
+
# Try to get commit details
|
|
146
|
+
commit = Commit.load(repo.object_store, commit_hash)
|
|
147
|
+
if commit:
|
|
148
|
+
print(f" Commit: {commit_hash[:8]}")
|
|
149
|
+
print(f" Author: {commit.author}")
|
|
150
|
+
print(f" Date: {commit.timestamp}")
|
|
151
|
+
print(f" Message: {commit.message}")
|
|
152
|
+
else:
|
|
153
|
+
print(f" Commit: {commit_hash[:8]} (details unavailable)")
|
|
154
|
+
if author:
|
|
155
|
+
print(f" Author: {author}")
|
|
156
|
+
else:
|
|
157
|
+
print(" Commit: (not tracked)")
|
|
158
|
+
if indexed_at:
|
|
159
|
+
print(f" Indexed: {indexed_at}")
|
|
160
|
+
|
|
161
|
+
# Show content preview
|
|
162
|
+
print(f"\n Content preview:")
|
|
163
|
+
for line in content.split('\n')[:5]:
|
|
164
|
+
print(f" {line[:70]}")
|
|
165
|
+
if len(content.split('\n')) > 5:
|
|
166
|
+
print(" ...")
|
|
167
|
+
|
|
168
|
+
print()
|
|
169
|
+
return 0
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""
|
|
2
|
+
agmem branch - List, create, or delete branches.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
|
|
7
|
+
from ..commands.base import require_repo
|
|
8
|
+
from ..core.repository import Repository
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class BranchCommand:
|
|
12
|
+
"""Manage branches."""
|
|
13
|
+
|
|
14
|
+
name = 'branch'
|
|
15
|
+
help = 'List, create, or delete branches'
|
|
16
|
+
|
|
17
|
+
@staticmethod
|
|
18
|
+
def add_arguments(parser: argparse.ArgumentParser):
|
|
19
|
+
parser.add_argument(
|
|
20
|
+
'name',
|
|
21
|
+
nargs='?',
|
|
22
|
+
help='Branch name to create or delete'
|
|
23
|
+
)
|
|
24
|
+
parser.add_argument(
|
|
25
|
+
'--delete', '-d',
|
|
26
|
+
action='store_true',
|
|
27
|
+
help='Delete a branch'
|
|
28
|
+
)
|
|
29
|
+
parser.add_argument(
|
|
30
|
+
'--force', '-D',
|
|
31
|
+
action='store_true',
|
|
32
|
+
help='Force delete a branch (even if not merged)'
|
|
33
|
+
)
|
|
34
|
+
parser.add_argument(
|
|
35
|
+
'--list', '-l',
|
|
36
|
+
action='store_true',
|
|
37
|
+
help='List all branches'
|
|
38
|
+
)
|
|
39
|
+
parser.add_argument(
|
|
40
|
+
'--all', '-a',
|
|
41
|
+
action='store_true',
|
|
42
|
+
help='List all branches including remote'
|
|
43
|
+
)
|
|
44
|
+
parser.add_argument(
|
|
45
|
+
'start_point',
|
|
46
|
+
nargs='?',
|
|
47
|
+
help='Commit to start the new branch from'
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
@staticmethod
|
|
51
|
+
def execute(args) -> int:
|
|
52
|
+
repo, code = require_repo()
|
|
53
|
+
if code != 0:
|
|
54
|
+
return code
|
|
55
|
+
|
|
56
|
+
if args.list or (not args.name and not args.delete):
|
|
57
|
+
return BranchCommand._list_branches(repo)
|
|
58
|
+
|
|
59
|
+
if args.delete or args.force:
|
|
60
|
+
if not args.name:
|
|
61
|
+
print("Error: Branch name required for deletion")
|
|
62
|
+
return 1
|
|
63
|
+
|
|
64
|
+
current = repo.refs.get_current_branch()
|
|
65
|
+
if args.name == current:
|
|
66
|
+
print(f"Error: Cannot delete current branch '{args.name}'")
|
|
67
|
+
print("Switch to another branch first.")
|
|
68
|
+
return 1
|
|
69
|
+
|
|
70
|
+
if repo.refs.delete_branch(args.name):
|
|
71
|
+
print(f"Deleted branch {args.name}")
|
|
72
|
+
return 0
|
|
73
|
+
print(f"Error: Branch '{args.name}' not found")
|
|
74
|
+
return 1
|
|
75
|
+
|
|
76
|
+
if args.name:
|
|
77
|
+
if repo.refs.branch_exists(args.name):
|
|
78
|
+
print(f"Error: A branch named '{args.name}' already exists.")
|
|
79
|
+
return 1
|
|
80
|
+
|
|
81
|
+
start_commit = None
|
|
82
|
+
if args.start_point:
|
|
83
|
+
start_commit = repo.resolve_ref(args.start_point)
|
|
84
|
+
if not start_commit:
|
|
85
|
+
print(f"Error: Not a valid object name: '{args.start_point}'")
|
|
86
|
+
return 1
|
|
87
|
+
|
|
88
|
+
if repo.refs.create_branch(args.name, start_commit):
|
|
89
|
+
print(f"Created branch {args.name}")
|
|
90
|
+
head = repo.refs.get_head()
|
|
91
|
+
if head.get("type") == "branch":
|
|
92
|
+
print(f" (based on {head['value']})")
|
|
93
|
+
return 0
|
|
94
|
+
print(f"Error: Could not create branch '{args.name}'")
|
|
95
|
+
return 1
|
|
96
|
+
|
|
97
|
+
return 0
|
|
98
|
+
|
|
99
|
+
@staticmethod
|
|
100
|
+
def _list_branches(repo) -> int:
|
|
101
|
+
"""List all branches with current branch marked."""
|
|
102
|
+
branches = repo.refs.list_branches()
|
|
103
|
+
current = repo.refs.get_current_branch()
|
|
104
|
+
if not branches:
|
|
105
|
+
print("No branches yet.")
|
|
106
|
+
return 0
|
|
107
|
+
for branch in branches:
|
|
108
|
+
prefix = "* " if branch == current else " "
|
|
109
|
+
print(f"{prefix}{branch}")
|
|
110
|
+
return 0
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""
|
|
2
|
+
agmem checkout - Switch branches or restore files.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
|
|
7
|
+
from ..commands.base import require_repo
|
|
8
|
+
from ..core.repository import Repository
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class CheckoutCommand:
|
|
12
|
+
"""Switch branches or restore working tree files."""
|
|
13
|
+
|
|
14
|
+
name = 'checkout'
|
|
15
|
+
help = 'Switch branches or restore working tree files'
|
|
16
|
+
|
|
17
|
+
@staticmethod
|
|
18
|
+
def add_arguments(parser: argparse.ArgumentParser):
|
|
19
|
+
parser.add_argument(
|
|
20
|
+
'ref',
|
|
21
|
+
help='Branch, tag, or commit to checkout'
|
|
22
|
+
)
|
|
23
|
+
parser.add_argument(
|
|
24
|
+
'-b',
|
|
25
|
+
action='store_true',
|
|
26
|
+
help='Create and checkout a new branch'
|
|
27
|
+
)
|
|
28
|
+
parser.add_argument(
|
|
29
|
+
'--force', '-f',
|
|
30
|
+
action='store_true',
|
|
31
|
+
help='Force checkout (discard local changes)'
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
@staticmethod
|
|
35
|
+
def execute(args) -> int:
|
|
36
|
+
repo, code = require_repo()
|
|
37
|
+
if code != 0:
|
|
38
|
+
return code
|
|
39
|
+
|
|
40
|
+
# Create and checkout new branch
|
|
41
|
+
if args.b:
|
|
42
|
+
branch_name = args.ref
|
|
43
|
+
|
|
44
|
+
# Check if branch already exists
|
|
45
|
+
if repo.refs.branch_exists(branch_name):
|
|
46
|
+
print(f"Error: A branch named '{branch_name}' already exists.")
|
|
47
|
+
return 1
|
|
48
|
+
|
|
49
|
+
# Get current HEAD commit
|
|
50
|
+
head = repo.refs.get_head()
|
|
51
|
+
if head['type'] == 'branch':
|
|
52
|
+
current_commit = repo.refs.get_branch_commit(head['value'])
|
|
53
|
+
else:
|
|
54
|
+
current_commit = head['value']
|
|
55
|
+
|
|
56
|
+
# Create branch
|
|
57
|
+
if not repo.refs.create_branch(branch_name, current_commit):
|
|
58
|
+
print(f"Error: Could not create branch '{branch_name}'")
|
|
59
|
+
return 1
|
|
60
|
+
|
|
61
|
+
# Switch to new branch
|
|
62
|
+
try:
|
|
63
|
+
repo.refs.set_head_branch(branch_name)
|
|
64
|
+
print(f"Switched to a new branch '{branch_name}'")
|
|
65
|
+
return 0
|
|
66
|
+
except Exception as e:
|
|
67
|
+
print(f"Error switching to branch: {e}")
|
|
68
|
+
return 1
|
|
69
|
+
|
|
70
|
+
# Regular checkout
|
|
71
|
+
ref = args.ref
|
|
72
|
+
|
|
73
|
+
# Check if it's a branch
|
|
74
|
+
is_branch = repo.refs.branch_exists(ref)
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
commit_hash = repo.checkout(ref, force=args.force)
|
|
78
|
+
|
|
79
|
+
if is_branch:
|
|
80
|
+
print(f"Switched to branch '{ref}'")
|
|
81
|
+
else:
|
|
82
|
+
# Check if it's a tag
|
|
83
|
+
if repo.refs.tag_exists(ref):
|
|
84
|
+
print(f"Note: checking out '{ref}'.")
|
|
85
|
+
print()
|
|
86
|
+
print("You are in 'detached HEAD' state. You can look around, make experimental")
|
|
87
|
+
print("changes and commit them, and you can discard any commits you make in this")
|
|
88
|
+
print("state without impacting any branches by switching back to a branch.")
|
|
89
|
+
else:
|
|
90
|
+
print(f"Note: checking out '{commit_hash[:8]}'.")
|
|
91
|
+
print()
|
|
92
|
+
print("You are in 'detached HEAD' state.")
|
|
93
|
+
|
|
94
|
+
return 0
|
|
95
|
+
|
|
96
|
+
except ValueError as e:
|
|
97
|
+
print(f"Error: {e}")
|
|
98
|
+
return 1
|
|
99
|
+
except Exception as e:
|
|
100
|
+
print(f"Error during checkout: {e}")
|
|
101
|
+
return 1
|
memvcs/commands/clean.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""
|
|
2
|
+
agmem clean - Remove untracked files (Git-like).
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from ..commands.base import require_repo
|
|
10
|
+
from ..core.repository import Repository
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class CleanCommand:
|
|
14
|
+
"""Remove untracked files from working directory."""
|
|
15
|
+
|
|
16
|
+
name = 'clean'
|
|
17
|
+
help = 'Remove untracked files from working directory'
|
|
18
|
+
|
|
19
|
+
@staticmethod
|
|
20
|
+
def add_arguments(parser: argparse.ArgumentParser):
|
|
21
|
+
parser.add_argument(
|
|
22
|
+
'-n', '--dry-run',
|
|
23
|
+
action='store_true',
|
|
24
|
+
help='Show what would be removed without removing'
|
|
25
|
+
)
|
|
26
|
+
parser.add_argument(
|
|
27
|
+
'-f', '--force',
|
|
28
|
+
action='store_true',
|
|
29
|
+
help='Required to actually remove files'
|
|
30
|
+
)
|
|
31
|
+
parser.add_argument(
|
|
32
|
+
'-d',
|
|
33
|
+
action='store_true',
|
|
34
|
+
help='Remove untracked directories too'
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
@staticmethod
|
|
38
|
+
def execute(args) -> int:
|
|
39
|
+
repo, code = require_repo()
|
|
40
|
+
if code != 0:
|
|
41
|
+
return code
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
status = repo.get_status()
|
|
45
|
+
untracked = status.get('untracked', [])
|
|
46
|
+
|
|
47
|
+
if not untracked:
|
|
48
|
+
print("Nothing to clean.")
|
|
49
|
+
return 0
|
|
50
|
+
|
|
51
|
+
if args.dry_run:
|
|
52
|
+
print("Would remove:")
|
|
53
|
+
for p in untracked:
|
|
54
|
+
print(f" {p}")
|
|
55
|
+
return 0
|
|
56
|
+
|
|
57
|
+
if not args.force:
|
|
58
|
+
print("Use -f to force removal of untracked files.")
|
|
59
|
+
return 1
|
|
60
|
+
|
|
61
|
+
removed = 0
|
|
62
|
+
for rel_path in untracked:
|
|
63
|
+
full_path = repo.current_dir / rel_path
|
|
64
|
+
if full_path.exists():
|
|
65
|
+
if full_path.is_file():
|
|
66
|
+
full_path.unlink()
|
|
67
|
+
removed += 1
|
|
68
|
+
print(f"Removed {rel_path}")
|
|
69
|
+
elif args.d and full_path.is_dir():
|
|
70
|
+
import shutil
|
|
71
|
+
shutil.rmtree(full_path)
|
|
72
|
+
removed += 1
|
|
73
|
+
print(f"Removed {rel_path}/")
|
|
74
|
+
|
|
75
|
+
print(f"Removed {removed} file(s)")
|
|
76
|
+
return 0
|
memvcs/commands/clone.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""
|
|
2
|
+
agmem clone - Clone a remote memory repository.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import shutil
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from memvcs.core.constants import MEMORY_TYPES
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class CloneCommand:
|
|
13
|
+
"""Clone a remote agmem repository."""
|
|
14
|
+
|
|
15
|
+
name = "clone"
|
|
16
|
+
help = "Clone a memory repository from a remote (file:// URL)"
|
|
17
|
+
|
|
18
|
+
@staticmethod
|
|
19
|
+
def add_arguments(parser: argparse.ArgumentParser):
|
|
20
|
+
parser.add_argument(
|
|
21
|
+
"url",
|
|
22
|
+
help="Remote URL (e.g. file:///path/to/remote-repo)",
|
|
23
|
+
)
|
|
24
|
+
parser.add_argument(
|
|
25
|
+
"directory",
|
|
26
|
+
nargs="?",
|
|
27
|
+
help="Local directory to clone into (default: infer from remote)",
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
@staticmethod
|
|
31
|
+
def execute(args) -> int:
|
|
32
|
+
from memvcs.core.remote import parse_remote_url
|
|
33
|
+
from memvcs.core.repository import Repository
|
|
34
|
+
|
|
35
|
+
url = args.url
|
|
36
|
+
if not url:
|
|
37
|
+
print("Error: URL required")
|
|
38
|
+
return 1
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
remote_path = parse_remote_url(url)
|
|
42
|
+
except ValueError as e:
|
|
43
|
+
print(f"Error: {e}")
|
|
44
|
+
return 1
|
|
45
|
+
|
|
46
|
+
remote_mem = remote_path / ".mem"
|
|
47
|
+
if not remote_mem.exists():
|
|
48
|
+
print(f"Error: Not an agmem repository: {remote_path}")
|
|
49
|
+
return 1
|
|
50
|
+
|
|
51
|
+
# Target directory (validate relative paths to avoid path traversal)
|
|
52
|
+
cwd = Path.cwd().resolve()
|
|
53
|
+
if args.directory:
|
|
54
|
+
p = Path(args.directory)
|
|
55
|
+
target = p.resolve()
|
|
56
|
+
if not p.is_absolute():
|
|
57
|
+
try:
|
|
58
|
+
target.relative_to(cwd)
|
|
59
|
+
except ValueError:
|
|
60
|
+
print("Error: Target path escapes current directory")
|
|
61
|
+
return 1
|
|
62
|
+
else:
|
|
63
|
+
target = cwd / remote_path.name
|
|
64
|
+
|
|
65
|
+
if target.exists() and any(target.iterdir()):
|
|
66
|
+
print(f"Error: Directory not empty: {target}")
|
|
67
|
+
return 1
|
|
68
|
+
|
|
69
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
70
|
+
|
|
71
|
+
# Copy .mem and current from remote
|
|
72
|
+
shutil.copytree(remote_mem, target / ".mem")
|
|
73
|
+
remote_current = remote_path / "current"
|
|
74
|
+
if remote_current.exists():
|
|
75
|
+
shutil.copytree(remote_current, target / "current")
|
|
76
|
+
else:
|
|
77
|
+
(target / "current").mkdir(parents=True)
|
|
78
|
+
for mem_type in MEMORY_TYPES:
|
|
79
|
+
(target / "current" / mem_type).mkdir(exist_ok=True)
|
|
80
|
+
|
|
81
|
+
# Set remote origin to source
|
|
82
|
+
import json
|
|
83
|
+
config_file = target / ".mem" / "config.json"
|
|
84
|
+
config = json.loads(config_file.read_text()) if config_file.exists() else {}
|
|
85
|
+
if "remotes" not in config:
|
|
86
|
+
config["remotes"] = {}
|
|
87
|
+
config["remotes"]["origin"] = {"url": url if url.startswith("file://") else f"file://{remote_path}"}
|
|
88
|
+
config_file.write_text(json.dumps(config, indent=2))
|
|
89
|
+
|
|
90
|
+
print(f"Cloned into {target}")
|
|
91
|
+
return 0
|