agmem 0.1.1__py3-none-any.whl → 0.1.3__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 → agmem-0.1.3.dist-info}/METADATA +157 -16
- agmem-0.1.3.dist-info/RECORD +105 -0
- memvcs/__init__.py +1 -1
- memvcs/cli.py +45 -31
- memvcs/commands/__init__.py +9 -9
- memvcs/commands/add.py +83 -76
- memvcs/commands/audit.py +59 -0
- memvcs/commands/blame.py +46 -53
- memvcs/commands/branch.py +13 -33
- memvcs/commands/checkout.py +27 -32
- memvcs/commands/clean.py +18 -23
- memvcs/commands/clone.py +11 -1
- memvcs/commands/commit.py +40 -39
- memvcs/commands/daemon.py +109 -76
- memvcs/commands/decay.py +77 -0
- memvcs/commands/diff.py +56 -57
- memvcs/commands/distill.py +90 -0
- memvcs/commands/federated.py +53 -0
- memvcs/commands/fsck.py +86 -61
- memvcs/commands/garden.py +40 -35
- memvcs/commands/gc.py +51 -0
- memvcs/commands/graph.py +41 -48
- memvcs/commands/init.py +16 -24
- memvcs/commands/log.py +25 -40
- memvcs/commands/merge.py +69 -27
- memvcs/commands/pack.py +129 -0
- memvcs/commands/prove.py +66 -0
- memvcs/commands/pull.py +31 -1
- memvcs/commands/push.py +4 -2
- memvcs/commands/recall.py +145 -0
- memvcs/commands/reflog.py +13 -22
- memvcs/commands/remote.py +1 -0
- memvcs/commands/repair.py +66 -0
- memvcs/commands/reset.py +23 -33
- memvcs/commands/resolve.py +130 -0
- memvcs/commands/resurrect.py +82 -0
- memvcs/commands/search.py +3 -4
- memvcs/commands/serve.py +2 -1
- memvcs/commands/show.py +66 -36
- memvcs/commands/stash.py +34 -34
- memvcs/commands/status.py +27 -35
- memvcs/commands/tag.py +23 -47
- memvcs/commands/test.py +30 -44
- memvcs/commands/timeline.py +111 -0
- memvcs/commands/tree.py +26 -27
- memvcs/commands/verify.py +110 -0
- memvcs/commands/when.py +115 -0
- memvcs/core/access_index.py +167 -0
- memvcs/core/audit.py +124 -0
- memvcs/core/config_loader.py +3 -1
- memvcs/core/consistency.py +214 -0
- memvcs/core/crypto_verify.py +280 -0
- memvcs/core/decay.py +185 -0
- memvcs/core/diff.py +158 -143
- memvcs/core/distiller.py +277 -0
- memvcs/core/encryption.py +169 -0
- memvcs/core/federated.py +86 -0
- memvcs/core/gardener.py +176 -145
- memvcs/core/hooks.py +48 -14
- memvcs/core/ipfs_remote.py +39 -0
- memvcs/core/knowledge_graph.py +135 -138
- memvcs/core/llm/__init__.py +10 -0
- memvcs/core/llm/anthropic_provider.py +50 -0
- memvcs/core/llm/base.py +27 -0
- memvcs/core/llm/factory.py +30 -0
- memvcs/core/llm/openai_provider.py +36 -0
- memvcs/core/merge.py +260 -170
- memvcs/core/objects.py +110 -101
- memvcs/core/pack.py +92 -0
- memvcs/core/pii_scanner.py +147 -146
- memvcs/core/privacy_budget.py +63 -0
- memvcs/core/refs.py +132 -115
- memvcs/core/remote.py +38 -0
- memvcs/core/repository.py +254 -164
- memvcs/core/schema.py +155 -113
- memvcs/core/staging.py +60 -65
- memvcs/core/storage/__init__.py +20 -18
- memvcs/core/storage/base.py +74 -70
- memvcs/core/storage/gcs.py +70 -68
- memvcs/core/storage/local.py +42 -40
- memvcs/core/storage/s3.py +105 -110
- memvcs/core/temporal_index.py +121 -0
- memvcs/core/test_runner.py +101 -93
- memvcs/core/trust.py +103 -0
- memvcs/core/vector_store.py +56 -36
- memvcs/core/zk_proofs.py +26 -0
- memvcs/integrations/mcp_server.py +1 -3
- memvcs/integrations/web_ui/server.py +25 -26
- memvcs/retrieval/__init__.py +22 -0
- memvcs/retrieval/base.py +54 -0
- memvcs/retrieval/pack.py +128 -0
- memvcs/retrieval/recaller.py +105 -0
- memvcs/retrieval/strategies.py +314 -0
- memvcs/utils/__init__.py +3 -3
- memvcs/utils/helpers.py +52 -52
- agmem-0.1.1.dist-info/RECORD +0 -67
- {agmem-0.1.1.dist-info → agmem-0.1.3.dist-info}/WHEEL +0 -0
- {agmem-0.1.1.dist-info → agmem-0.1.3.dist-info}/entry_points.txt +0 -0
- {agmem-0.1.1.dist-info → agmem-0.1.3.dist-info}/licenses/LICENSE +0 -0
- {agmem-0.1.1.dist-info → agmem-0.1.3.dist-info}/top_level.txt +0 -0
memvcs/core/refs.py
CHANGED
|
@@ -12,21 +12,21 @@ from datetime import datetime
|
|
|
12
12
|
# Minimum length for partial commit hash; full SHA-256 hex is 64 chars
|
|
13
13
|
COMMIT_HASH_MIN_LEN = 4
|
|
14
14
|
COMMIT_HASH_MAX_LEN = 64
|
|
15
|
-
COMMIT_HASH_HEX_CHARS = set(
|
|
15
|
+
COMMIT_HASH_HEX_CHARS = set("0123456789abcdef")
|
|
16
16
|
|
|
17
17
|
|
|
18
18
|
def _safe_ref_name(name: str) -> bool:
|
|
19
19
|
"""Return True if name is safe for single-component ref (reflog, HEAD file). No slashes."""
|
|
20
|
-
if not name or name in (
|
|
20
|
+
if not name or name in (".", ".."):
|
|
21
21
|
return False
|
|
22
|
-
if
|
|
22
|
+
if "/" in name or "\\" in name or "\0" in name:
|
|
23
23
|
return False
|
|
24
24
|
return True
|
|
25
25
|
|
|
26
26
|
|
|
27
27
|
def _ref_path_under_root(name: str, base_dir: Path) -> bool:
|
|
28
28
|
"""Return True if name is a valid ref name and (base_dir / name) stays under base_dir (Git-style)."""
|
|
29
|
-
if not name or name in (
|
|
29
|
+
if not name or name in (".", "..") or "\0" in name or "\\" in name:
|
|
30
30
|
return False
|
|
31
31
|
try:
|
|
32
32
|
resolved = (base_dir / name).resolve()
|
|
@@ -38,74 +38,82 @@ def _ref_path_under_root(name: str, base_dir: Path) -> bool:
|
|
|
38
38
|
|
|
39
39
|
def _valid_commit_hash(candidate: str) -> bool:
|
|
40
40
|
"""Return True if candidate looks like a commit hash (hex, within length)."""
|
|
41
|
-
if
|
|
41
|
+
if (
|
|
42
|
+
not candidate
|
|
43
|
+
or len(candidate) < COMMIT_HASH_MIN_LEN
|
|
44
|
+
or len(candidate) > COMMIT_HASH_MAX_LEN
|
|
45
|
+
):
|
|
42
46
|
return False
|
|
43
47
|
return all(c in COMMIT_HASH_HEX_CHARS for c in candidate.lower())
|
|
44
48
|
|
|
45
49
|
|
|
46
50
|
class RefsManager:
|
|
47
51
|
"""Manages references (HEAD, branches, tags)."""
|
|
48
|
-
|
|
52
|
+
|
|
49
53
|
def __init__(self, mem_dir: Path):
|
|
50
54
|
self.mem_dir = Path(mem_dir)
|
|
51
|
-
self.refs_dir = self.mem_dir /
|
|
52
|
-
self.heads_dir = self.refs_dir /
|
|
53
|
-
self.tags_dir = self.refs_dir /
|
|
54
|
-
self.remotes_dir = self.refs_dir /
|
|
55
|
-
self.head_file = self.mem_dir /
|
|
56
|
-
self.stash_file = self.mem_dir /
|
|
57
|
-
self.reflog_dir = self.mem_dir /
|
|
55
|
+
self.refs_dir = self.mem_dir / "refs"
|
|
56
|
+
self.heads_dir = self.refs_dir / "heads"
|
|
57
|
+
self.tags_dir = self.refs_dir / "tags"
|
|
58
|
+
self.remotes_dir = self.refs_dir / "remotes"
|
|
59
|
+
self.head_file = self.mem_dir / "HEAD"
|
|
60
|
+
self.stash_file = self.mem_dir / "stash"
|
|
61
|
+
self.reflog_dir = self.mem_dir / "logs"
|
|
58
62
|
self._ensure_directories()
|
|
59
|
-
|
|
63
|
+
|
|
60
64
|
def _ensure_directories(self):
|
|
61
65
|
"""Create reference directories."""
|
|
62
66
|
self.heads_dir.mkdir(parents=True, exist_ok=True)
|
|
63
67
|
self.tags_dir.mkdir(parents=True, exist_ok=True)
|
|
64
68
|
self.remotes_dir.mkdir(parents=True, exist_ok=True)
|
|
65
|
-
|
|
66
|
-
def init_head(self, branch_name: str =
|
|
69
|
+
|
|
70
|
+
def init_head(self, branch_name: str = "main"):
|
|
67
71
|
"""Initialize HEAD to point to a branch."""
|
|
68
72
|
if not _ref_path_under_root(branch_name, self.heads_dir):
|
|
69
73
|
raise ValueError(f"Invalid branch name: {branch_name!r}")
|
|
70
|
-
self.head_file.write_text(f
|
|
74
|
+
self.head_file.write_text(f"ref: refs/heads/{branch_name}\n")
|
|
71
75
|
branch_file = self.heads_dir / branch_name
|
|
72
76
|
if not branch_file.exists():
|
|
73
77
|
branch_file.parent.mkdir(parents=True, exist_ok=True)
|
|
74
|
-
branch_file.write_text(
|
|
75
|
-
|
|
78
|
+
branch_file.write_text("")
|
|
79
|
+
|
|
76
80
|
def get_head(self) -> Dict[str, str]:
|
|
77
81
|
"""
|
|
78
82
|
Get current HEAD reference.
|
|
79
|
-
|
|
83
|
+
|
|
80
84
|
Returns:
|
|
81
85
|
Dict with 'type' ('branch' or 'commit') and 'value'
|
|
82
86
|
"""
|
|
83
87
|
if not self.head_file.exists():
|
|
84
|
-
return {
|
|
85
|
-
|
|
88
|
+
return {"type": "branch", "value": "main"}
|
|
89
|
+
|
|
86
90
|
content = self.head_file.read_text().strip()
|
|
87
|
-
|
|
88
|
-
if content.startswith(
|
|
91
|
+
|
|
92
|
+
if content.startswith("ref: "):
|
|
89
93
|
# Points to a branch (e.g. refs/heads/main or refs/heads/feature/test)
|
|
90
94
|
ref_path = content[5:].strip()
|
|
91
|
-
branch_name =
|
|
92
|
-
|
|
95
|
+
branch_name = (
|
|
96
|
+
ref_path[len("refs/heads/") :]
|
|
97
|
+
if ref_path.startswith("refs/heads/")
|
|
98
|
+
else ref_path.split("/")[-1]
|
|
99
|
+
)
|
|
100
|
+
return {"type": "branch", "value": branch_name}
|
|
93
101
|
elif content:
|
|
94
102
|
# Detached HEAD - points directly to commit
|
|
95
|
-
return {
|
|
96
|
-
|
|
97
|
-
return {
|
|
98
|
-
|
|
103
|
+
return {"type": "commit", "value": content}
|
|
104
|
+
|
|
105
|
+
return {"type": "branch", "value": "main"}
|
|
106
|
+
|
|
99
107
|
def set_head_branch(self, branch_name: str):
|
|
100
108
|
"""Set HEAD to point to a branch."""
|
|
101
109
|
if not _ref_path_under_root(branch_name, self.heads_dir):
|
|
102
110
|
raise ValueError(f"Invalid branch name: {branch_name!r}")
|
|
103
|
-
self.head_file.write_text(f
|
|
104
|
-
|
|
111
|
+
self.head_file.write_text(f"ref: refs/heads/{branch_name}\n")
|
|
112
|
+
|
|
105
113
|
def set_head_detached(self, commit_hash: str):
|
|
106
114
|
"""Set HEAD to point directly to a commit (detached)."""
|
|
107
|
-
self.head_file.write_text(f
|
|
108
|
-
|
|
115
|
+
self.head_file.write_text(f"{commit_hash}\n")
|
|
116
|
+
|
|
109
117
|
def get_branch_commit(self, branch_name: str) -> Optional[str]:
|
|
110
118
|
"""Get the commit hash for a branch."""
|
|
111
119
|
if not _ref_path_under_root(branch_name, self.heads_dir):
|
|
@@ -115,23 +123,23 @@ class RefsManager:
|
|
|
115
123
|
content = branch_file.read_text().strip()
|
|
116
124
|
return content if content else None
|
|
117
125
|
return None
|
|
118
|
-
|
|
126
|
+
|
|
119
127
|
def set_branch_commit(self, branch_name: str, commit_hash: str):
|
|
120
128
|
"""Set the commit hash for a branch."""
|
|
121
129
|
if not _ref_path_under_root(branch_name, self.heads_dir):
|
|
122
130
|
raise ValueError(f"Invalid branch name: {branch_name!r}")
|
|
123
131
|
branch_file = self.heads_dir / branch_name
|
|
124
132
|
branch_file.parent.mkdir(parents=True, exist_ok=True)
|
|
125
|
-
branch_file.write_text(f
|
|
126
|
-
|
|
133
|
+
branch_file.write_text(f"{commit_hash}\n")
|
|
134
|
+
|
|
127
135
|
def create_branch(self, branch_name: str, commit_hash: Optional[str] = None) -> bool:
|
|
128
136
|
"""
|
|
129
137
|
Create a new branch.
|
|
130
|
-
|
|
138
|
+
|
|
131
139
|
Args:
|
|
132
140
|
branch_name: Name of the new branch
|
|
133
141
|
commit_hash: Commit to point to (None for current HEAD)
|
|
134
|
-
|
|
142
|
+
|
|
135
143
|
Returns:
|
|
136
144
|
True if created, False if branch already exists
|
|
137
145
|
"""
|
|
@@ -146,18 +154,18 @@ class RefsManager:
|
|
|
146
154
|
if commit_hash is None:
|
|
147
155
|
# Point to current HEAD commit
|
|
148
156
|
head = self.get_head()
|
|
149
|
-
if head[
|
|
150
|
-
commit_hash = self.get_branch_commit(head[
|
|
157
|
+
if head["type"] == "branch":
|
|
158
|
+
commit_hash = self.get_branch_commit(head["value"])
|
|
151
159
|
else:
|
|
152
|
-
commit_hash = head[
|
|
153
|
-
|
|
154
|
-
branch_file.write_text(f
|
|
160
|
+
commit_hash = head["value"]
|
|
161
|
+
|
|
162
|
+
branch_file.write_text(f"{commit_hash}\n" if commit_hash else "")
|
|
155
163
|
return True
|
|
156
|
-
|
|
164
|
+
|
|
157
165
|
def delete_branch(self, branch_name: str) -> bool:
|
|
158
166
|
"""
|
|
159
167
|
Delete a branch.
|
|
160
|
-
|
|
168
|
+
|
|
161
169
|
Returns:
|
|
162
170
|
True if deleted, False if branch doesn't exist
|
|
163
171
|
"""
|
|
@@ -168,46 +176,46 @@ class RefsManager:
|
|
|
168
176
|
branch_file.unlink()
|
|
169
177
|
return True
|
|
170
178
|
return False
|
|
171
|
-
|
|
179
|
+
|
|
172
180
|
def list_branches(self) -> List[str]:
|
|
173
181
|
"""List all branch names (supports nested names like feature/test)."""
|
|
174
182
|
if not self.heads_dir.exists():
|
|
175
183
|
return []
|
|
176
184
|
branches = []
|
|
177
|
-
for p in self.heads_dir.rglob(
|
|
185
|
+
for p in self.heads_dir.rglob("*"):
|
|
178
186
|
if p.is_file():
|
|
179
187
|
branches.append(str(p.relative_to(self.heads_dir)))
|
|
180
188
|
return sorted(branches)
|
|
181
|
-
|
|
189
|
+
|
|
182
190
|
def branch_exists(self, branch_name: str) -> bool:
|
|
183
191
|
"""Check if a branch exists."""
|
|
184
192
|
if not _ref_path_under_root(branch_name, self.heads_dir):
|
|
185
193
|
return False
|
|
186
194
|
return (self.heads_dir / branch_name).exists()
|
|
187
|
-
|
|
195
|
+
|
|
188
196
|
def get_current_branch(self) -> Optional[str]:
|
|
189
197
|
"""Get the name of the current branch, or None if detached."""
|
|
190
198
|
head = self.get_head()
|
|
191
|
-
if head[
|
|
192
|
-
return head[
|
|
199
|
+
if head["type"] == "branch":
|
|
200
|
+
return head["value"]
|
|
193
201
|
return None
|
|
194
|
-
|
|
202
|
+
|
|
195
203
|
def is_detached(self) -> bool:
|
|
196
204
|
"""Check if HEAD is detached."""
|
|
197
205
|
head = self.get_head()
|
|
198
|
-
return head[
|
|
199
|
-
|
|
206
|
+
return head["type"] == "commit"
|
|
207
|
+
|
|
200
208
|
# Tag management
|
|
201
|
-
|
|
202
|
-
def create_tag(self, tag_name: str, commit_hash: str, message: str =
|
|
209
|
+
|
|
210
|
+
def create_tag(self, tag_name: str, commit_hash: str, message: str = "") -> bool:
|
|
203
211
|
"""
|
|
204
212
|
Create a new tag.
|
|
205
|
-
|
|
213
|
+
|
|
206
214
|
Args:
|
|
207
215
|
tag_name: Name of the tag
|
|
208
216
|
commit_hash: Commit to tag
|
|
209
217
|
message: Optional tag message
|
|
210
|
-
|
|
218
|
+
|
|
211
219
|
Returns:
|
|
212
220
|
True if created, False if tag already exists
|
|
213
221
|
"""
|
|
@@ -218,13 +226,13 @@ class RefsManager:
|
|
|
218
226
|
return False
|
|
219
227
|
|
|
220
228
|
tag_file.parent.mkdir(parents=True, exist_ok=True)
|
|
221
|
-
tag_file.write_text(f
|
|
229
|
+
tag_file.write_text(f"{commit_hash}\n")
|
|
222
230
|
return True
|
|
223
|
-
|
|
231
|
+
|
|
224
232
|
def delete_tag(self, tag_name: str) -> bool:
|
|
225
233
|
"""
|
|
226
234
|
Delete a tag.
|
|
227
|
-
|
|
235
|
+
|
|
228
236
|
Returns:
|
|
229
237
|
True if deleted, False if tag doesn't exist
|
|
230
238
|
"""
|
|
@@ -235,7 +243,7 @@ class RefsManager:
|
|
|
235
243
|
tag_file.unlink()
|
|
236
244
|
return True
|
|
237
245
|
return False
|
|
238
|
-
|
|
246
|
+
|
|
239
247
|
def get_tag_commit(self, tag_name: str) -> Optional[str]:
|
|
240
248
|
"""Get the commit hash for a tag."""
|
|
241
249
|
if not _ref_path_under_root(tag_name, self.tags_dir):
|
|
@@ -244,17 +252,17 @@ class RefsManager:
|
|
|
244
252
|
if tag_file.exists():
|
|
245
253
|
return tag_file.read_text().strip()
|
|
246
254
|
return None
|
|
247
|
-
|
|
255
|
+
|
|
248
256
|
def list_tags(self) -> List[str]:
|
|
249
257
|
"""List all tag names (supports nested names)."""
|
|
250
258
|
if not self.tags_dir.exists():
|
|
251
259
|
return []
|
|
252
260
|
tags = []
|
|
253
|
-
for p in self.tags_dir.rglob(
|
|
261
|
+
for p in self.tags_dir.rglob("*"):
|
|
254
262
|
if p.is_file():
|
|
255
263
|
tags.append(str(p.relative_to(self.tags_dir)))
|
|
256
264
|
return sorted(tags)
|
|
257
|
-
|
|
265
|
+
|
|
258
266
|
def tag_exists(self, tag_name: str) -> bool:
|
|
259
267
|
"""Check if a tag exists."""
|
|
260
268
|
if not _ref_path_under_root(tag_name, self.tags_dir):
|
|
@@ -272,7 +280,9 @@ class RefsManager:
|
|
|
272
280
|
return content if content else None
|
|
273
281
|
return None
|
|
274
282
|
|
|
275
|
-
def set_remote_branch_commit(
|
|
283
|
+
def set_remote_branch_commit(
|
|
284
|
+
self, remote_name: str, branch_name: str, commit_hash: str
|
|
285
|
+
) -> None:
|
|
276
286
|
"""Set remote-tracking branch (e.g. after fetch)."""
|
|
277
287
|
if not _safe_ref_name(remote_name):
|
|
278
288
|
raise ValueError(f"Invalid remote name: {remote_name!r}")
|
|
@@ -281,51 +291,52 @@ class RefsManager:
|
|
|
281
291
|
raise ValueError(f"Invalid branch name: {branch_name!r}")
|
|
282
292
|
ref_file = self.remotes_dir / remote_name / branch_name
|
|
283
293
|
ref_file.parent.mkdir(parents=True, exist_ok=True)
|
|
284
|
-
ref_file.write_text(commit_hash +
|
|
285
|
-
|
|
294
|
+
ref_file.write_text(commit_hash + "\n")
|
|
295
|
+
|
|
286
296
|
def resolve_ref(self, ref: str, object_store=None) -> Optional[str]:
|
|
287
297
|
"""
|
|
288
298
|
Resolve a reference to a commit hash.
|
|
289
|
-
|
|
299
|
+
|
|
290
300
|
Supports:
|
|
291
301
|
- Branch names
|
|
292
302
|
- Tag names
|
|
293
303
|
- Partial commit hashes
|
|
294
304
|
- HEAD
|
|
295
305
|
- HEAD~n (nth parent)
|
|
296
|
-
|
|
306
|
+
|
|
297
307
|
Args:
|
|
298
308
|
ref: Reference to resolve
|
|
299
|
-
|
|
309
|
+
|
|
300
310
|
Returns:
|
|
301
311
|
Commit hash or None if not found
|
|
302
312
|
"""
|
|
303
313
|
ref = ref.strip()
|
|
304
|
-
|
|
314
|
+
|
|
305
315
|
# Handle HEAD
|
|
306
|
-
if ref ==
|
|
316
|
+
if ref == "HEAD":
|
|
307
317
|
head = self.get_head()
|
|
308
|
-
if head[
|
|
309
|
-
return head[
|
|
318
|
+
if head["type"] == "commit":
|
|
319
|
+
return head["value"]
|
|
310
320
|
else:
|
|
311
|
-
return self.get_branch_commit(head[
|
|
312
|
-
|
|
321
|
+
return self.get_branch_commit(head["value"])
|
|
322
|
+
|
|
313
323
|
# Handle HEAD~n
|
|
314
|
-
if ref.startswith(
|
|
324
|
+
if ref.startswith("HEAD~"):
|
|
315
325
|
try:
|
|
316
326
|
n = int(ref[5:])
|
|
317
327
|
if n < 0:
|
|
318
328
|
return None
|
|
319
329
|
head = self.get_head()
|
|
320
|
-
if head[
|
|
321
|
-
commit_hash = self.get_branch_commit(head[
|
|
330
|
+
if head["type"] == "branch":
|
|
331
|
+
commit_hash = self.get_branch_commit(head["value"])
|
|
322
332
|
else:
|
|
323
|
-
commit_hash = head[
|
|
333
|
+
commit_hash = head["value"]
|
|
324
334
|
if not commit_hash:
|
|
325
335
|
return None
|
|
326
336
|
# Walk back n parents when object_store is available
|
|
327
337
|
if object_store is not None and n > 0:
|
|
328
338
|
from .objects import Commit
|
|
339
|
+
|
|
329
340
|
for _ in range(n):
|
|
330
341
|
commit = Commit.load(object_store, commit_hash)
|
|
331
342
|
if not commit or not commit.parents:
|
|
@@ -334,39 +345,41 @@ class RefsManager:
|
|
|
334
345
|
return commit_hash
|
|
335
346
|
except ValueError:
|
|
336
347
|
return None
|
|
337
|
-
|
|
348
|
+
|
|
338
349
|
# Check branches
|
|
339
350
|
if self.branch_exists(ref):
|
|
340
351
|
return self.get_branch_commit(ref)
|
|
341
352
|
|
|
342
353
|
# Check remote-tracking refs (e.g. origin/main)
|
|
343
|
-
if
|
|
344
|
-
parts = ref.split(
|
|
354
|
+
if "/" in ref:
|
|
355
|
+
parts = ref.split("/", 1)
|
|
345
356
|
if len(parts) == 2:
|
|
346
357
|
remote_name, branch_name = parts
|
|
347
|
-
if _safe_ref_name(remote_name) and _ref_path_under_root(
|
|
358
|
+
if _safe_ref_name(remote_name) and _ref_path_under_root(
|
|
359
|
+
branch_name, self.heads_dir
|
|
360
|
+
):
|
|
348
361
|
remote_hash = self.get_remote_branch_commit(remote_name, branch_name)
|
|
349
362
|
if remote_hash:
|
|
350
363
|
return remote_hash
|
|
351
|
-
|
|
364
|
+
|
|
352
365
|
# Check tags
|
|
353
366
|
if self.tag_exists(ref):
|
|
354
367
|
return self.get_tag_commit(ref)
|
|
355
|
-
|
|
368
|
+
|
|
356
369
|
# Check stash refs (stash@{n})
|
|
357
|
-
if ref.startswith(
|
|
358
|
-
if ref ==
|
|
370
|
+
if ref.startswith("stash@"):
|
|
371
|
+
if ref == "stash":
|
|
359
372
|
return self.get_stash_commit(0)
|
|
360
|
-
if ref.startswith(
|
|
373
|
+
if ref.startswith("stash@{") and ref.endswith("}"):
|
|
361
374
|
try:
|
|
362
375
|
n = int(ref[7:-1])
|
|
363
376
|
return self.get_stash_commit(n)
|
|
364
377
|
except ValueError:
|
|
365
378
|
pass
|
|
366
|
-
|
|
379
|
+
|
|
367
380
|
# Assume it's a commit hash (full or partial); validate to avoid path/injection
|
|
368
381
|
return ref if _valid_commit_hash(ref) else None
|
|
369
|
-
|
|
382
|
+
|
|
370
383
|
# Reflog - log of HEAD changes
|
|
371
384
|
def append_reflog(self, ref_name: str, old_hash: str, new_hash: str, message: str):
|
|
372
385
|
"""Append entry to reflog."""
|
|
@@ -374,12 +387,12 @@ class RefsManager:
|
|
|
374
387
|
raise ValueError(f"Invalid ref name for reflog: {ref_name!r}")
|
|
375
388
|
self.reflog_dir.mkdir(parents=True, exist_ok=True)
|
|
376
389
|
log_file = self.reflog_dir / ref_name
|
|
377
|
-
timestamp = datetime.utcnow().strftime(
|
|
390
|
+
timestamp = datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%S.%fZ")
|
|
378
391
|
line = f"{new_hash} {old_hash} {timestamp} {message}\n"
|
|
379
|
-
with open(log_file,
|
|
392
|
+
with open(log_file, "a") as f:
|
|
380
393
|
f.write(line)
|
|
381
|
-
|
|
382
|
-
def get_reflog(self, ref_name: str =
|
|
394
|
+
|
|
395
|
+
def get_reflog(self, ref_name: str = "HEAD", max_count: int = 20) -> List[Dict]:
|
|
383
396
|
"""Get reflog entries for a reference."""
|
|
384
397
|
if not _safe_ref_name(ref_name):
|
|
385
398
|
return []
|
|
@@ -387,29 +400,31 @@ class RefsManager:
|
|
|
387
400
|
if not log_file.exists():
|
|
388
401
|
return []
|
|
389
402
|
entries = []
|
|
390
|
-
for line in reversed(log_file.read_text().strip().split(
|
|
403
|
+
for line in reversed(log_file.read_text().strip().split("\n")):
|
|
391
404
|
if not line:
|
|
392
405
|
continue
|
|
393
|
-
parts = line.split(
|
|
406
|
+
parts = line.split(" ", 3)
|
|
394
407
|
if len(parts) >= 4:
|
|
395
|
-
entries.append(
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
408
|
+
entries.append(
|
|
409
|
+
{
|
|
410
|
+
"hash": parts[0],
|
|
411
|
+
"old_hash": parts[1],
|
|
412
|
+
"timestamp": parts[2],
|
|
413
|
+
"message": parts[3],
|
|
414
|
+
}
|
|
415
|
+
)
|
|
401
416
|
if len(entries) >= max_count:
|
|
402
417
|
break
|
|
403
418
|
return entries
|
|
404
|
-
|
|
419
|
+
|
|
405
420
|
# Stash - stack of stashed changes
|
|
406
|
-
def stash_push(self, commit_hash: str, message: str =
|
|
421
|
+
def stash_push(self, commit_hash: str, message: str = "") -> int:
|
|
407
422
|
"""Push a commit onto the stash stack. Returns stash index."""
|
|
408
423
|
stashes = self._load_stash_list()
|
|
409
|
-
stashes.insert(0, {
|
|
424
|
+
stashes.insert(0, {"hash": commit_hash, "message": message or "WIP"})
|
|
410
425
|
self._save_stash_list(stashes)
|
|
411
426
|
return 0
|
|
412
|
-
|
|
427
|
+
|
|
413
428
|
def stash_pop(self, index: int = 0) -> Optional[str]:
|
|
414
429
|
"""Pop stash at index. Returns commit hash or None."""
|
|
415
430
|
stashes = self._load_stash_list()
|
|
@@ -417,31 +432,33 @@ class RefsManager:
|
|
|
417
432
|
return None
|
|
418
433
|
entry = stashes.pop(index)
|
|
419
434
|
self._save_stash_list(stashes)
|
|
420
|
-
return entry[
|
|
421
|
-
|
|
435
|
+
return entry["hash"]
|
|
436
|
+
|
|
422
437
|
def stash_list(self) -> List[Dict]:
|
|
423
438
|
"""List all stashes."""
|
|
424
439
|
return self._load_stash_list()
|
|
425
|
-
|
|
440
|
+
|
|
426
441
|
def get_stash_commit(self, index: int) -> Optional[str]:
|
|
427
442
|
"""Get commit hash for stash at index."""
|
|
428
443
|
stashes = self._load_stash_list()
|
|
429
444
|
if 0 <= index < len(stashes):
|
|
430
|
-
return stashes[index][
|
|
445
|
+
return stashes[index]["hash"]
|
|
431
446
|
return None
|
|
432
|
-
|
|
447
|
+
|
|
433
448
|
def _load_stash_list(self) -> List[Dict]:
|
|
434
449
|
"""Load stash list from disk."""
|
|
435
450
|
if not self.stash_file.exists():
|
|
436
451
|
return []
|
|
437
452
|
try:
|
|
438
453
|
import json
|
|
454
|
+
|
|
439
455
|
data = json.loads(self.stash_file.read_text())
|
|
440
456
|
return data if isinstance(data, list) else []
|
|
441
457
|
except (json.JSONDecodeError, TypeError):
|
|
442
458
|
return []
|
|
443
|
-
|
|
459
|
+
|
|
444
460
|
def _save_stash_list(self, stashes: List[Dict]):
|
|
445
461
|
"""Save stash list to disk."""
|
|
446
462
|
import json
|
|
463
|
+
|
|
447
464
|
self.stash_file.write_text(json.dumps(stashes, indent=2))
|
memvcs/core/remote.py
CHANGED
|
@@ -164,6 +164,28 @@ class Remote:
|
|
|
164
164
|
refs = RefsManager(self.mem_dir)
|
|
165
165
|
store = ObjectStore(self.objects_dir)
|
|
166
166
|
|
|
167
|
+
# Push conflict detection: remote tip must be ancestor of local tip (non-fast-forward reject)
|
|
168
|
+
remote_heads = remote_refs / "heads"
|
|
169
|
+
for b in refs.list_branches():
|
|
170
|
+
if branch and b != branch:
|
|
171
|
+
continue
|
|
172
|
+
local_ch = refs.get_branch_commit(b)
|
|
173
|
+
if not local_ch:
|
|
174
|
+
continue
|
|
175
|
+
remote_branch_file = remote_heads / b
|
|
176
|
+
if remote_branch_file.exists():
|
|
177
|
+
remote_ch = remote_branch_file.read_text().strip()
|
|
178
|
+
if remote_ch and _valid_object_hash(remote_ch):
|
|
179
|
+
from .merge import MergeEngine
|
|
180
|
+
from .repository import Repository
|
|
181
|
+
|
|
182
|
+
repo = Repository(self.repo_path)
|
|
183
|
+
engine = MergeEngine(repo)
|
|
184
|
+
if not engine.find_common_ancestor(remote_ch, local_ch) == remote_ch:
|
|
185
|
+
raise ValueError(
|
|
186
|
+
"Push rejected: remote has diverged. Pull and merge first."
|
|
187
|
+
)
|
|
188
|
+
|
|
167
189
|
# Collect objects to push
|
|
168
190
|
to_push = set()
|
|
169
191
|
for b in refs.list_branches():
|
|
@@ -206,6 +228,14 @@ class Remote:
|
|
|
206
228
|
(remote_tags_dir / t).parent.mkdir(parents=True, exist_ok=True)
|
|
207
229
|
(remote_tags_dir / t).write_text(ch + "\n")
|
|
208
230
|
|
|
231
|
+
try:
|
|
232
|
+
from .audit import append_audit
|
|
233
|
+
|
|
234
|
+
append_audit(
|
|
235
|
+
self.mem_dir, "push", {"remote": self.name, "branch": branch, "copied": copied}
|
|
236
|
+
)
|
|
237
|
+
except Exception:
|
|
238
|
+
pass
|
|
209
239
|
return f"Pushed {copied} object(s) to {self.name}"
|
|
210
240
|
|
|
211
241
|
def fetch(self, branch: Optional[str] = None) -> str:
|
|
@@ -275,4 +305,12 @@ class Remote:
|
|
|
275
305
|
if ch:
|
|
276
306
|
refs.create_tag(tag_name, ch)
|
|
277
307
|
|
|
308
|
+
try:
|
|
309
|
+
from .audit import append_audit
|
|
310
|
+
|
|
311
|
+
append_audit(
|
|
312
|
+
self.mem_dir, "fetch", {"remote": self.name, "branch": branch, "copied": copied}
|
|
313
|
+
)
|
|
314
|
+
except Exception:
|
|
315
|
+
pass
|
|
278
316
|
return f"Fetched {copied} object(s) from {self.name}"
|