agent-memory-cli 0.1.3__tar.gz → 0.1.4__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.
- {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/CHANGELOG.md +11 -0
- {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/PKG-INFO +1 -1
- {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/src/agent_memory/__init__.py +1 -1
- {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/src/agent_memory/doctor.py +9 -7
- {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/src/agent_memory/startup.py +45 -2
- {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/src/agent_memory/sync.py +178 -43
- {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/tests/test_doctor.py +19 -15
- {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/tests/test_startup.py +78 -0
- {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/tests/test_sync.py +166 -3
- {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/.gitignore +0 -0
- {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/LICENSE +0 -0
- {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/Makefile +0 -0
- {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/README.md +0 -0
- {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/pyproject.toml +0 -0
- {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/scripts/check_installed_wheel.py +0 -0
- {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/scripts/release_notes.py +0 -0
- {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/src/agent_memory/__main__.py +0 -0
- {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/src/agent_memory/archive.py +0 -0
- {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/src/agent_memory/cli.py +0 -0
- {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/src/agent_memory/debrief.py +0 -0
- {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/src/agent_memory/events.py +0 -0
- {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/src/agent_memory/git_runner.py +0 -0
- {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/src/agent_memory/home.py +0 -0
- {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/src/agent_memory/layout.py +0 -0
- {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/src/agent_memory/org.py +0 -0
- {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/src/agent_memory/prerequisites.py +0 -0
- {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/src/agent_memory/publication.py +0 -0
- {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/src/agent_memory/setup/__init__.py +0 -0
- {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/src/agent_memory/setup/claude.py +0 -0
- {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/src/agent_memory/setup/codex.py +0 -0
- {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/src/agent_memory/setup/common.py +0 -0
- {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/src/agent_memory/setup/legacy.py +0 -0
- {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/src/agent_memory/status.py +0 -0
- {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/src/agent_memory/templates/org-memory/decisions.md +0 -0
- {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/src/agent_memory/templates/org-memory/recent.md +0 -0
- {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/src/agent_memory/templates/org-memory/rules.md +0 -0
- {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/src/agent_memory/templates/project-memory/decision_log.md +0 -0
- {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/src/agent_memory/templates/project-memory/known_debt.md +0 -0
- {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/src/agent_memory/templates/project-memory/open_threads.md +0 -0
- {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/src/agent_memory/templates/project-memory/project_facts.md +0 -0
- {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/src/agent_memory/templates/workflow/SKILL.md +0 -0
- {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/src/agent_memory/workflow.py +0 -0
- {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/tests/conformance/org_memory/cases/bad_layout/expected.json +0 -0
- {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/tests/conformance/org_memory/cases/bad_layout/org-memory/debriefs/.hidden/2026/08/20260825-alice-abc12345.md +0 -0
- {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/tests/conformance/org_memory/cases/bad_layout/org-memory/debriefs/demo-project/2026/08/20260825-alice-1f3a9c2b.md +0 -0
- {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/tests/conformance/org_memory/cases/bad_layout/org-memory/debriefs/demo-project/2026/08/20260825-alice-ABCD.md +0 -0
- {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/tests/conformance/org_memory/cases/bad_layout/org-memory/debriefs/demo-project/2026/08/20260825_alice_abc12345.md +0 -0
- {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/tests/conformance/org_memory/cases/bad_layout/org-memory/debriefs/demo-project/2026/09/20260825-alice-def12345.md +0 -0
- {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/tests/conformance/org_memory/cases/bad_layout/org-memory/debriefs/demo-project/20260825-alice-abc12345.md +0 -0
- {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/tests/conformance/org_memory/cases/empty_store/expected.json +0 -0
- {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/tests/conformance/org_memory/cases/empty_store/org-memory/debriefs/.gitkeep +0 -0
- {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/tests/conformance/org_memory/cases/missing_debriefs_dir/expected.json +0 -0
- {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/tests/conformance/org_memory/cases/missing_debriefs_dir/org-memory/recent.md +0 -0
- {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/tests/conformance/org_memory/cases/staging_artifact/expected.json +0 -0
- {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/tests/conformance/org_memory/cases/staging_artifact/org-memory/debriefs/demo-project/2026/08/.stage.20260825-alice-77xx88yy.md.a1b2c3 +0 -0
- {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/tests/conformance/org_memory/cases/staging_artifact/org-memory/debriefs/demo-project/2026/08/20260825-alice-1f3a9c2b.md +0 -0
- {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/tests/conformance/org_memory/cases/valid_store/expected.json +0 -0
- {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/tests/conformance/org_memory/cases/valid_store/org-memory/debriefs/.gitkeep +0 -0
- {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/tests/conformance/org_memory/cases/valid_store/org-memory/debriefs/Demo_Project/2026/08/20260825-bob-ops-9f00aa11.md +0 -0
- {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/tests/conformance/org_memory/cases/valid_store/org-memory/debriefs/demo-project/2026/08/20260825-alice-1f3a9c2b.md +0 -0
- {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/tests/conformance/org_memory/cases/valid_store/org-memory/debriefs/demo.project/2026/07/20260701-Alice_2.dev-00aa11bb.md +0 -0
- {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/tests/conformance/org_memory/cases/valid_store/org-memory/debriefs/other-project/2026/12/20261203-bob-9e0d44aa.md +0 -0
- {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/tests/conftest.py +0 -0
- {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/tests/fixtures/setup/claude_settings_fleet.json +0 -0
- {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/tests/fixtures/setup/codex_hooks_kernel.json +0 -0
- {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/tests/golden/canonical_memory_gitignore.txt +0 -0
- {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/tests/golden/debrief_record.md +0 -0
- {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/tests/golden/doctor_memory_0.4.5.json +0 -0
- {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/tests/golden/doctor_memory_0.4.5.txt +0 -0
- {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/tests/golden/event_record.md +0 -0
- {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/tests/test_archive.py +0 -0
- {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/tests/test_cli.py +0 -0
- {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/tests/test_debrief.py +0 -0
- {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/tests/test_events.py +0 -0
- {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/tests/test_home.py +0 -0
- {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/tests/test_layout.py +0 -0
- {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/tests/test_org.py +0 -0
- {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/tests/test_prerequisites.py +0 -0
- {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/tests/test_setup.py +0 -0
- {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/tests/test_status.py +0 -0
- {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/tests/test_workflow.py +0 -0
|
@@ -4,6 +4,17 @@ All notable changes to agent-memory are documented in this file. The format
|
|
|
4
4
|
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the
|
|
5
5
|
project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
+
## 0.1.4 - 2026-10-26
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- `pull` and `push` sync one at a time per home and never abort a rebase they did not start ([sync](docs/commands.md#sync)).
|
|
12
|
+
- `pull` with only unpushed commits no longer also reports "already synced" ([sync](docs/commands.md#sync)).
|
|
13
|
+
- `startup --pull` no longer publishes a debrief or event another agent wrote ([startup](docs/commands.md#startup)).
|
|
14
|
+
- `pull` and `push` update past untracked files the incoming commits do not touch, so SessionStart keeps pulling ([sync](docs/commands.md#sync)).
|
|
15
|
+
- `doctor` on Python 3.14 reports a debrief record it cannot inspect as unreadable, not as an irregular file ([doctor](docs/commands.md#doctor)).
|
|
16
|
+
- `enable` starts a new memory repository on `main` even where git's `init.defaultBranch` is `master` ([sync](docs/commands.md#sync)).
|
|
17
|
+
|
|
7
18
|
## 0.1.3 - 2026-09-29
|
|
8
19
|
|
|
9
20
|
### Fixed
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: agent-memory-cli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.4
|
|
4
4
|
Summary: Cross-session memory for coding agents: plain files, git-native, no server
|
|
5
5
|
Project-URL: Homepage, https://github.com/kiloloop/agent-memory
|
|
6
6
|
Project-URL: Source, https://github.com/kiloloop/agent-memory
|
|
@@ -237,7 +237,7 @@ def check_org_memory(home: Path) -> Category:
|
|
|
237
237
|
for name in sorted(dirnames):
|
|
238
238
|
entry = current / name
|
|
239
239
|
try:
|
|
240
|
-
is_link =
|
|
240
|
+
is_link = stat.S_ISLNK(os.lstat(entry).st_mode)
|
|
241
241
|
except OSError as exc:
|
|
242
242
|
walk_errors.append(f"{_relative(entry)}: {exc.__class__.__name__}")
|
|
243
243
|
continue
|
|
@@ -258,16 +258,18 @@ def check_org_memory(home: Path) -> Category:
|
|
|
258
258
|
staging.append(rel)
|
|
259
259
|
continue
|
|
260
260
|
# The namespace holds regular files reached without following links;
|
|
261
|
-
# a classification failure surfaces, never raises.
|
|
261
|
+
# a classification failure surfaces, never raises. lstat, not the
|
|
262
|
+
# pathlib predicates: since Python 3.14 those answer False on any
|
|
263
|
+
# OSError, so a record that could not be inspected would read as irregular.
|
|
262
264
|
try:
|
|
263
|
-
|
|
264
|
-
irregular.append(f"{rel} (symlink)")
|
|
265
|
-
continue
|
|
266
|
-
regular = file_path.is_file()
|
|
265
|
+
mode = os.lstat(file_path).st_mode
|
|
267
266
|
except OSError as exc:
|
|
268
267
|
walk_errors.append(f"{rel}: {exc.__class__.__name__}")
|
|
269
268
|
continue
|
|
270
|
-
if
|
|
269
|
+
if stat.S_ISLNK(mode):
|
|
270
|
+
irregular.append(f"{rel} (symlink)")
|
|
271
|
+
continue
|
|
272
|
+
if not stat.S_ISREG(mode):
|
|
271
273
|
irregular.append(f"{rel} (not a regular file)")
|
|
272
274
|
continue
|
|
273
275
|
total += 1
|
|
@@ -14,6 +14,12 @@ files, both from the layout table; ``events/``, ``debriefs/`` and
|
|
|
14
14
|
``archive/`` are excluded), and the rendered text is cut at a character
|
|
15
15
|
budget with a notice, so a hook can never flood a session.
|
|
16
16
|
|
|
17
|
+
With ``--pull`` the session's runtime is the publishing identity, and a
|
|
18
|
+
path another agent wrote is not its to publish: a debrief names its agent in
|
|
19
|
+
its filename and an event in its frontmatter, so either one, when it names
|
|
20
|
+
someone else, is held for that agent's own session. Files that carry no
|
|
21
|
+
author (the curated org files, the project tier) publish as before.
|
|
22
|
+
|
|
17
23
|
Output shapes: ``--json`` is the manifest itself, ``schema_version`` first;
|
|
18
24
|
the default is what the runtime's hook expects on stdout: plain text for
|
|
19
25
|
claude, whose session start takes stdout as context, and the hook JSON
|
|
@@ -28,8 +34,9 @@ import stat
|
|
|
28
34
|
from pathlib import Path
|
|
29
35
|
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
|
30
36
|
|
|
31
|
-
from . import layout, sync
|
|
37
|
+
from . import debrief, events, layout, sync
|
|
32
38
|
from .git_runner import GitRunner, run_git
|
|
39
|
+
from .publication import WriterError
|
|
33
40
|
|
|
34
41
|
SCHEMA_VERSION = 1
|
|
35
42
|
RUNTIME_CLAUDE = "claude"
|
|
@@ -46,6 +53,8 @@ RESULT_OK = "ok"
|
|
|
46
53
|
RESULT_DEGRADED = "degraded"
|
|
47
54
|
PULL_NOT_REQUESTED = "not_requested"
|
|
48
55
|
PULL_ERROR = "error"
|
|
56
|
+
#: The most of an event the author check reads; the frontmatter sits at its head.
|
|
57
|
+
_EVENT_HEAD_BYTES = 64 * 1024
|
|
49
58
|
|
|
50
59
|
|
|
51
60
|
def build_manifest(
|
|
@@ -161,7 +170,7 @@ def _sync(
|
|
|
161
170
|
pull_info: Dict[str, Any] = {"requested": pull, "status": PULL_NOT_REQUESTED, "ok": True, "lines": []}
|
|
162
171
|
if pull:
|
|
163
172
|
try:
|
|
164
|
-
outcome = sync.pull(home, agent=runtime, runner=runner)
|
|
173
|
+
outcome = sync.pull(home, agent=runtime, runner=runner, hold=lambda path: _held(home, path, runtime))
|
|
165
174
|
pull_info = {"requested": True, "status": outcome.status, "ok": outcome.ok, "lines": list(outcome.lines)}
|
|
166
175
|
except (sync.SyncError, OSError) as exc:
|
|
167
176
|
pull_info = {"requested": True, "status": PULL_ERROR, "ok": False, "lines": [f"memory pull: {exc}"]}
|
|
@@ -176,6 +185,40 @@ def _sync(
|
|
|
176
185
|
return info, pull_info
|
|
177
186
|
|
|
178
187
|
|
|
188
|
+
def _held(home: Path, relative: str, runtime: str) -> bool:
|
|
189
|
+
"""Whether ``relative`` names an author other than ``runtime``; no author named means not held."""
|
|
190
|
+
author = author_of(home, relative)
|
|
191
|
+
return author is not None and author != runtime
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def author_of(home: Path, relative: str) -> Optional[str]:
|
|
195
|
+
"""The agent a memory path names as its author, or ``None`` when it names none.
|
|
196
|
+
|
|
197
|
+
A debrief, ``org-memory/debriefs/<project>/<YYYY>/<MM>/<YYYYMMDD>-<agent>-<session>.md``,
|
|
198
|
+
names it in the filename: the session id has no dash, so the last dash
|
|
199
|
+
splits the agent from it whatever dashes the agent's own name carries. An
|
|
200
|
+
event, ``org-memory/events/<name>.md``, names it in the frontmatter
|
|
201
|
+
``agent:`` field. Anything else, and anything that does not parse, names
|
|
202
|
+
no one.
|
|
203
|
+
"""
|
|
204
|
+
parts = relative.split("/")
|
|
205
|
+
if parts[:2] == [layout.ORG.pattern, "debriefs"] and len(parts) == 6 and parts[5].endswith(".md"):
|
|
206
|
+
date, _, rest = parts[5][: -len(".md")].partition("-")
|
|
207
|
+
agent, _, session = rest.rpartition("-")
|
|
208
|
+
if len(date) == 8 and date.isdigit() and debrief.SESSION_RE.match(session) and debrief.AGENT_RE.match(agent):
|
|
209
|
+
return agent
|
|
210
|
+
return None
|
|
211
|
+
if parts[:2] == [layout.ORG.pattern, events.EVENTS_DIR] and len(parts) == 3 and parts[2].endswith(".md"):
|
|
212
|
+
try:
|
|
213
|
+
with open(home / relative, "rb") as handle:
|
|
214
|
+
frontmatter, _ = debrief.split_record(handle.read(_EVENT_HEAD_BYTES))
|
|
215
|
+
except (OSError, ValueError, WriterError):
|
|
216
|
+
return None
|
|
217
|
+
agent = frontmatter.get("agent", "")
|
|
218
|
+
return agent if debrief.AGENT_RE.match(agent) else None
|
|
219
|
+
return None
|
|
220
|
+
|
|
221
|
+
|
|
179
222
|
# --- rendering --------------------------------------------------------------
|
|
180
223
|
|
|
181
224
|
|
|
@@ -29,7 +29,17 @@ The network verbs (fetch, pull, push, clone) run under a 30 s timeout.
|
|
|
29
29
|
``push`` and ``pull --agent`` commit allowlisted changes before fetching,
|
|
30
30
|
rebase when behind, then deliver. A clean ``pull`` without an agent updates
|
|
31
31
|
and reports unpushed commits. Conflicts abort the rebase and keep local
|
|
32
|
-
commits; rejected delivery also leaves them local.
|
|
32
|
+
commits; rejected delivery also leaves them local. ``pull`` takes a hold
|
|
33
|
+
predicate from its caller: a selected path it holds is left uncommitted for
|
|
34
|
+
whoever wrote it, which keeps the engine content-blind when the predicate
|
|
35
|
+
has to read a file to name its author. An update never moves a tracked
|
|
36
|
+
change, but an untracked path the incoming commits do not touch rides
|
|
37
|
+
through the fast-forward or rebase in place.
|
|
38
|
+
|
|
39
|
+
``pull`` and ``push`` hold an exclusive lock under the git directory, so one
|
|
40
|
+
of them syncs a home at a time and a second waits for the first. A rebase
|
|
41
|
+
found in progress is never this verb's to abort: one is refused before
|
|
42
|
+
anything is written, and checked for again just before the verb's own rebase.
|
|
33
43
|
"""
|
|
34
44
|
|
|
35
45
|
from __future__ import annotations
|
|
@@ -39,18 +49,34 @@ import os
|
|
|
39
49
|
import shutil
|
|
40
50
|
import socket
|
|
41
51
|
import tempfile
|
|
52
|
+
import time
|
|
53
|
+
from contextlib import contextmanager
|
|
42
54
|
from dataclasses import dataclass
|
|
43
55
|
from pathlib import Path
|
|
44
|
-
from typing import Iterable, List, Mapping, Optional, Sequence, Tuple
|
|
56
|
+
from typing import Callable, Iterable, Iterator, List, Mapping, Optional, Sequence, Tuple
|
|
45
57
|
|
|
46
58
|
from . import layout
|
|
47
59
|
from .git_runner import GitResult, GitRunner, run_git
|
|
48
60
|
|
|
61
|
+
try:
|
|
62
|
+
import fcntl
|
|
63
|
+
except ImportError: # pragma: no cover - no flock here; the in-progress checks still apply
|
|
64
|
+
fcntl = None # type: ignore[assignment]
|
|
65
|
+
|
|
49
66
|
MARKER_FILE = layout.MARKER_FILE
|
|
50
67
|
GITIGNORE_FILE = layout.GITIGNORE_FILE
|
|
51
68
|
MARKER_TEXT = "agent-memory sync repository. Remove this file to disable syncing locally.\n"
|
|
52
69
|
NETWORK_TIMEOUT_SECONDS = 30
|
|
53
70
|
DEFAULT_REMOTE = "origin"
|
|
71
|
+
#: The branch a new memory repository starts on, whatever git's local ``init.defaultBranch`` says.
|
|
72
|
+
DEFAULT_BRANCH = "main"
|
|
73
|
+
|
|
74
|
+
#: The sync lock, a file under the git directory that ``pull`` and ``push`` hold with flock.
|
|
75
|
+
LOCK_FILE = "agent-memory-sync.lock"
|
|
76
|
+
#: How long a verb waits for another sync of the same home: the longest a holder can
|
|
77
|
+
#: run is its four network-bounded steps (fetch, rebase, abort, push).
|
|
78
|
+
LOCK_WAIT_SECONDS: float = 4 * NETWORK_TIMEOUT_SECONDS
|
|
79
|
+
_LOCK_POLL_SECONDS = 0.1
|
|
54
80
|
|
|
55
81
|
#: The agent name a commit is published under, when the caller does not pass one.
|
|
56
82
|
ENV_AGENT = "AGENT_MEMORY_AGENT"
|
|
@@ -148,6 +174,8 @@ class Outcome:
|
|
|
148
174
|
#: Paths that were staged outside the selection and were left exactly as found.
|
|
149
175
|
preserved: Tuple[str, ...] = ()
|
|
150
176
|
receipt: Optional[GitignoreReceipt] = None
|
|
177
|
+
#: Selected paths the caller's hold predicate kept out of the commit, home-relative.
|
|
178
|
+
held: Tuple[str, ...] = ()
|
|
151
179
|
|
|
152
180
|
|
|
153
181
|
# --- marker and ignore file -------------------------------------------------
|
|
@@ -293,6 +321,10 @@ def init(
|
|
|
293
321
|
result = _git(home, ["init", "--quiet"], runner)
|
|
294
322
|
if not result.ok:
|
|
295
323
|
raise SyncError(f"git init failed: {result.output}")
|
|
324
|
+
# Name the unborn branch here rather than with --initial-branch, which needs git 2.28.
|
|
325
|
+
result = _git(home, ["symbolic-ref", "HEAD", f"refs/heads/{DEFAULT_BRANCH}"], runner)
|
|
326
|
+
if not result.ok:
|
|
327
|
+
raise SyncError(f"git symbolic-ref failed: {result.output}")
|
|
296
328
|
receipt = ensure_gitignore(home)
|
|
297
329
|
lines = receipt.lines()
|
|
298
330
|
if write_marker(home):
|
|
@@ -355,37 +387,44 @@ def pull(
|
|
|
355
387
|
agent: Optional[str] = None,
|
|
356
388
|
runner: Optional[GitRunner] = None,
|
|
357
389
|
env: Optional[Mapping[str, str]] = None,
|
|
390
|
+
hold: Optional[Callable[[str], bool]] = None,
|
|
358
391
|
) -> Outcome:
|
|
359
392
|
"""Update the home, publishing dirty memory only with an explicit identity.
|
|
360
393
|
|
|
361
394
|
Clean pulls keep their read-mostly behavior: fast-forward, or rebase local
|
|
362
395
|
commits and report ahead. An explicit agent permits dirty publication/delivery;
|
|
363
|
-
SessionStart supplies its runtime name through this same entry point
|
|
396
|
+
SessionStart supplies its runtime name through this same entry point, with a
|
|
397
|
+
``hold`` predicate that keeps another agent's pending paths out of the commit.
|
|
364
398
|
"""
|
|
365
399
|
home = _home(home)
|
|
366
400
|
if not is_configured(home):
|
|
367
401
|
return Outcome("not_configured", True)
|
|
368
402
|
_require_repo(home, runner)
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
if
|
|
373
|
-
return
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
403
|
+
with _sync_lock(home, runner, "memory pull") as busy:
|
|
404
|
+
if busy:
|
|
405
|
+
return busy
|
|
406
|
+
if _operation_in_progress(home, runner):
|
|
407
|
+
return Outcome("operation_in_progress", False, ("memory pull: a git operation is in progress; finish it first.",))
|
|
408
|
+
if changed_paths(home, runner):
|
|
409
|
+
if agent:
|
|
410
|
+
return _sync_changes(home, agent=agent, runner=runner, env=env, hold=hold)
|
|
411
|
+
return Outcome("dirty", False, ("memory pull: uncommitted changes present; pass --agent NAME to commit and sync.",))
|
|
412
|
+
state = git_state(home, runner=runner, fetch=True)
|
|
413
|
+
if not state.has_remote:
|
|
414
|
+
return Outcome("local_only", True, ("memory pull: local-only repository; no remote to pull from.",))
|
|
415
|
+
if state.fetch_failed:
|
|
416
|
+
return _fetch_failure(state, "memory pull")
|
|
417
|
+
if not state.has_upstream:
|
|
418
|
+
return Outcome("no_upstream", True, ("memory pull: no upstream branch configured; skipping.",))
|
|
419
|
+
updated = _update(home, state, runner)
|
|
420
|
+
if not updated.ok:
|
|
421
|
+
return updated
|
|
422
|
+
current = git_state(home, runner=runner, fetch=False) if state.behind else state
|
|
423
|
+
if current.ahead:
|
|
424
|
+
# Nothing came down unless it was behind; "already synced" would contradict the count.
|
|
425
|
+
pulled = updated.lines if state.behind else ()
|
|
426
|
+
return Outcome("ahead", True, (*pulled, f"memory pull: {current.ahead} unpushed commit(s); use push to deliver."))
|
|
384
427
|
return updated
|
|
385
|
-
current = git_state(home, runner=runner, fetch=False) if state.behind else state
|
|
386
|
-
if current.ahead:
|
|
387
|
-
return Outcome("ahead", True, (*updated.lines, f"memory pull: {current.ahead} unpushed commit(s); use push to deliver."))
|
|
388
|
-
return updated
|
|
389
428
|
|
|
390
429
|
|
|
391
430
|
def push(
|
|
@@ -400,9 +439,49 @@ def push(
|
|
|
400
439
|
if not is_configured(home):
|
|
401
440
|
return Outcome("not_configured", True)
|
|
402
441
|
_require_repo(home, runner)
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
442
|
+
with _sync_lock(home, runner, "memory push") as busy:
|
|
443
|
+
if busy:
|
|
444
|
+
return busy
|
|
445
|
+
if _operation_in_progress(home, runner):
|
|
446
|
+
return Outcome("operation_in_progress", False, ("memory push: a git operation is in progress; finish it first.",))
|
|
447
|
+
return _sync_changes(home, agent=agent, runner=runner, env=env)
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
@contextmanager
|
|
451
|
+
def _sync_lock(home: Path, runner: Optional[GitRunner], verb: str) -> Iterator[Optional[Outcome]]:
|
|
452
|
+
"""Hold the home's sync lock; yield ``None``, or the outcome to return when another sync kept it.
|
|
453
|
+
|
|
454
|
+
flock belongs to the open file, so the kernel releases it when the holder exits,
|
|
455
|
+
however it exits: a crashed sync never leaves the home locked. The file stays.
|
|
456
|
+
"""
|
|
457
|
+
if fcntl is None: # pragma: no cover
|
|
458
|
+
yield None
|
|
459
|
+
return
|
|
460
|
+
result = _git(home, ["rev-parse", "--git-path", LOCK_FILE], runner)
|
|
461
|
+
if not result.ok or not result.stdout.strip():
|
|
462
|
+
raise SyncError(f"cannot locate the git directory for the sync lock: {result.output}")
|
|
463
|
+
fd = os.open(home / result.stdout.strip(), os.O_RDWR | os.O_CREAT, 0o644)
|
|
464
|
+
try:
|
|
465
|
+
if _flock_within(fd, LOCK_WAIT_SECONDS):
|
|
466
|
+
yield None
|
|
467
|
+
else:
|
|
468
|
+
yield Outcome("sync_in_progress", False, (
|
|
469
|
+
f"{verb}: another agent-memory sync held this home for {LOCK_WAIT_SECONDS:g}s; nothing was changed.",
|
|
470
|
+
))
|
|
471
|
+
finally:
|
|
472
|
+
os.close(fd)
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def _flock_within(fd: int, seconds: float) -> bool:
|
|
476
|
+
deadline = time.monotonic() + seconds
|
|
477
|
+
while True:
|
|
478
|
+
try:
|
|
479
|
+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
480
|
+
return True
|
|
481
|
+
except BlockingIOError:
|
|
482
|
+
if time.monotonic() >= deadline:
|
|
483
|
+
return False
|
|
484
|
+
time.sleep(_LOCK_POLL_SECONDS)
|
|
406
485
|
|
|
407
486
|
|
|
408
487
|
def _operation_in_progress(home: Path, runner: Optional[GitRunner]) -> bool:
|
|
@@ -418,9 +497,14 @@ def _operation_in_progress(home: Path, runner: Optional[GitRunner]) -> bool:
|
|
|
418
497
|
|
|
419
498
|
|
|
420
499
|
def _sync_changes(
|
|
421
|
-
home: Path,
|
|
500
|
+
home: Path,
|
|
501
|
+
*,
|
|
502
|
+
agent: Optional[str],
|
|
503
|
+
runner: Optional[GitRunner],
|
|
504
|
+
env: Optional[Mapping[str, str]],
|
|
505
|
+
hold: Optional[Callable[[str], bool]] = None,
|
|
422
506
|
) -> Outcome:
|
|
423
|
-
published = _publish(home, agent=agent, runner=runner, env=env)
|
|
507
|
+
published = _publish(home, agent=agent, runner=runner, env=env, hold=hold)
|
|
424
508
|
if not published.ok:
|
|
425
509
|
return published
|
|
426
510
|
# Commit before fetch: even a network failure leaves a recoverable local commit.
|
|
@@ -428,12 +512,14 @@ def _sync_changes(
|
|
|
428
512
|
lines = list(published.lines)
|
|
429
513
|
if not state.has_remote:
|
|
430
514
|
lines.append("memory push: no remote configured; the commit remains local.")
|
|
431
|
-
return Outcome("local_only", True, tuple(lines), published.committed, published.preserved
|
|
515
|
+
return Outcome("local_only", True, tuple(lines), published.committed, published.preserved,
|
|
516
|
+
held=published.held)
|
|
432
517
|
if not state.fetch_failed and state.has_upstream and state.behind:
|
|
433
518
|
updated = _update(home, state, runner)
|
|
434
519
|
lines.extend(updated.lines)
|
|
435
520
|
if not updated.ok:
|
|
436
|
-
return Outcome(updated.status, False, tuple(lines), published.committed, published.preserved
|
|
521
|
+
return Outcome(updated.status, False, tuple(lines), published.committed, published.preserved,
|
|
522
|
+
held=published.held)
|
|
437
523
|
state = git_state(home, runner=runner, fetch=False)
|
|
438
524
|
return _deliver(home, published, state, lines, runner)
|
|
439
525
|
|
|
@@ -442,9 +528,10 @@ def _update(home: Path, state: GitState, runner: Optional[GitRunner]) -> Outcome
|
|
|
442
528
|
if not state.behind:
|
|
443
529
|
return Outcome("up_to_date", True, ("memory pull: already synced.",))
|
|
444
530
|
remaining = changed_paths(home, runner)
|
|
445
|
-
|
|
531
|
+
blocking = _blocking_paths(home, state, remaining, runner)
|
|
532
|
+
if blocking:
|
|
446
533
|
return Outcome("dirty", False, (
|
|
447
|
-
"memory pull: cannot update with remaining uncommitted paths; left untouched: " + ", ".join(
|
|
534
|
+
"memory pull: cannot update with remaining uncommitted paths; left untouched: " + ", ".join(blocking),
|
|
448
535
|
))
|
|
449
536
|
if not state.ahead:
|
|
450
537
|
result = _git(home, ["pull", "--ff-only", "--quiet"], runner, timeout=NETWORK_TIMEOUT_SECONDS)
|
|
@@ -452,20 +539,24 @@ def _update(home: Path, state: GitState, runner: Optional[GitRunner]) -> Outcome
|
|
|
452
539
|
return Outcome("pull_timed_out", False, (f"memory pull: {result.output}",))
|
|
453
540
|
if not result.ok:
|
|
454
541
|
return Outcome("pull_failed", False, (f"memory pull: fast-forward failed: {result.output}",))
|
|
455
|
-
return Outcome("synced", True, (f"memory pull: synced {state.behind} commit(s).",))
|
|
542
|
+
return Outcome("synced", True, (f"memory pull: synced {state.behind} commit(s).", *_kept_lines(remaining)))
|
|
456
543
|
merges = _git(home, ["rev-list", "--merges", f"{state.upstream}..HEAD"], runner)
|
|
457
544
|
if not merges.ok or merges.stdout.strip():
|
|
458
545
|
return Outcome("diverged", False, (
|
|
459
546
|
"memory pull: local merge commits cannot be safely rebased automatically; resolve manually."
|
|
460
547
|
if merges.ok else f"memory pull: cannot inspect local history: {merges.output}",
|
|
461
548
|
))
|
|
549
|
+
# The sync lock keeps every other agent-memory verb out; this catches a rebase another
|
|
550
|
+
# tool began after the precheck, which a failed rebase of ours would otherwise abort.
|
|
551
|
+
if _operation_in_progress(home, runner):
|
|
552
|
+
return Outcome("operation_in_progress", False, ("memory pull: a git operation began during the sync; finish it first.",))
|
|
462
553
|
# Rebase exactly the fetched upstream. Do not stash unrelated edits or reuse
|
|
463
554
|
# a recorded conflict resolution: a conflicting same-path add must surface.
|
|
464
555
|
result = _git(home, ["-c", "rerere.enabled=false", "-c", "rebase.updateRefs=false",
|
|
465
556
|
"rebase", "--no-autostash", state.upstream],
|
|
466
557
|
runner, timeout=NETWORK_TIMEOUT_SECONDS)
|
|
467
558
|
if result.ok:
|
|
468
|
-
return Outcome("synced", True, ("memory pull: rebased local commits onto upstream.",))
|
|
559
|
+
return Outcome("synced", True, ("memory pull: rebased local commits onto upstream.", *_kept_lines(remaining)))
|
|
469
560
|
conflicts = _git(home, ["diff", "--name-only", "--diff-filter=U", "-z"], runner)
|
|
470
561
|
paths = [path for path in conflicts.stdout.split("\0") if path] if conflicts.ok else []
|
|
471
562
|
if _operation_in_progress(home, runner):
|
|
@@ -482,6 +573,38 @@ def _update(home: Path, state: GitState, runner: Optional[GitRunner]) -> Outcome
|
|
|
482
573
|
))
|
|
483
574
|
|
|
484
575
|
|
|
576
|
+
def _blocking_paths(home: Path, state: GitState, remaining: Sequence[str], runner: Optional[GitRunner]) -> List[str]:
|
|
577
|
+
"""The uncommitted paths an update cannot carry: every tracked change, and any untracked
|
|
578
|
+
path that an incoming commit adds, changes or removes, itself or as a parent directory."""
|
|
579
|
+
if not remaining:
|
|
580
|
+
return []
|
|
581
|
+
untracked = set(_untracked_paths(home, runner))
|
|
582
|
+
if not untracked:
|
|
583
|
+
return list(remaining)
|
|
584
|
+
incoming = _git(home, ["diff", "--name-only", "-z", f"HEAD...{state.upstream}"], runner)
|
|
585
|
+
if not incoming.ok:
|
|
586
|
+
return list(remaining)
|
|
587
|
+
touched = [path for path in incoming.stdout.split("\0") if path]
|
|
588
|
+
return [path for path in remaining if path not in untracked or any(_overlaps(path, other) for other in touched)]
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
def _overlaps(path: str, other: str) -> bool:
|
|
592
|
+
return path == other or path.startswith(f"{other}/") or other.startswith(f"{path}/")
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def _untracked_paths(home: Path, runner: Optional[GitRunner]) -> List[str]:
|
|
596
|
+
result = _git(home, ["ls-files", "--others", "--exclude-standard", "-z"], runner)
|
|
597
|
+
if not result.ok:
|
|
598
|
+
raise SyncError(f"git ls-files failed: {result.output}")
|
|
599
|
+
return [path for path in result.stdout.split("\0") if path]
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
def _kept_lines(remaining: Sequence[str]) -> Tuple[str, ...]:
|
|
603
|
+
if not remaining:
|
|
604
|
+
return ()
|
|
605
|
+
return (f"memory pull: {len(remaining)} untracked path(s) left in place: {', '.join(remaining)}",)
|
|
606
|
+
|
|
607
|
+
|
|
485
608
|
def disable(home: Path) -> Outcome:
|
|
486
609
|
"""Remove the marker; the repository and its history stay in place."""
|
|
487
610
|
home = _home(home)
|
|
@@ -559,6 +682,7 @@ def _publish(
|
|
|
559
682
|
agent: Optional[str],
|
|
560
683
|
runner: Optional[GitRunner],
|
|
561
684
|
env: Optional[Mapping[str, str]],
|
|
685
|
+
hold: Optional[Callable[[str], bool]] = None,
|
|
562
686
|
) -> Outcome:
|
|
563
687
|
selected, outside = select_paths(home, runner)
|
|
564
688
|
if outside:
|
|
@@ -571,8 +695,14 @@ def _publish(
|
|
|
571
695
|
f"{', '.join(outside)}",
|
|
572
696
|
),
|
|
573
697
|
)
|
|
574
|
-
|
|
698
|
+
held = tuple(path for path in selected if hold is not None and hold(path))
|
|
699
|
+
selected = [path for path in selected if path not in held]
|
|
700
|
+
preserved = tuple(sorted(set(staged_paths(home, runner)) - set(selected) - set(held)))
|
|
575
701
|
lines: List[str] = []
|
|
702
|
+
if held:
|
|
703
|
+
lines.append(
|
|
704
|
+
f"memory publish: {len(held)} path(s) another agent wrote left for it to publish: {', '.join(held)}"
|
|
705
|
+
)
|
|
576
706
|
if preserved:
|
|
577
707
|
lines.append(
|
|
578
708
|
f"memory publish: {len(preserved)} staged path(s) outside the allowlist left staged and "
|
|
@@ -580,22 +710,22 @@ def _publish(
|
|
|
580
710
|
)
|
|
581
711
|
if not selected:
|
|
582
712
|
lines.append("memory publish: no memory changes to commit.")
|
|
583
|
-
return Outcome("nothing_to_commit", True, tuple(lines), (), preserved)
|
|
713
|
+
return Outcome("nothing_to_commit", True, tuple(lines), (), preserved, held=held)
|
|
584
714
|
|
|
585
715
|
message = commit_message(len(selected), resolve_agent(agent, env))
|
|
586
716
|
with _pathspec_file(selected) as pathspec:
|
|
587
717
|
added = _git(home, ["--literal-pathspecs", "add", *_from_file(pathspec)], runner)
|
|
588
718
|
if not added.ok:
|
|
589
|
-
return Outcome("commit_failed", False, (*lines, f"memory publish: git add failed: {added.output}"))
|
|
719
|
+
return Outcome("commit_failed", False, (*lines, f"memory publish: git add failed: {added.output}"), held=held)
|
|
590
720
|
committed = _git(home, ["--literal-pathspecs", "commit", "--quiet", "-m", message, *_from_file(pathspec)], runner)
|
|
591
721
|
if not committed.ok:
|
|
592
|
-
return Outcome("commit_failed", False, (*lines, f"memory publish: git commit failed: {committed.output}"))
|
|
722
|
+
return Outcome("commit_failed", False, (*lines, f"memory publish: git commit failed: {committed.output}"), held=held)
|
|
593
723
|
published = committed_paths(home, runner)
|
|
594
724
|
stray = sorted(path for path in published if not layout.is_allowed_memory_path(path))
|
|
595
725
|
if stray:
|
|
596
726
|
raise SyncError(f"memory publish: HEAD commits paths outside the allowlist: {', '.join(stray)}")
|
|
597
727
|
lines.append(f"memory publish: committed {len(published)} file(s).")
|
|
598
|
-
return Outcome("committed", True, tuple(lines), tuple(published), preserved)
|
|
728
|
+
return Outcome("committed", True, tuple(lines), tuple(published), preserved, held=held)
|
|
599
729
|
|
|
600
730
|
|
|
601
731
|
def _deliver(
|
|
@@ -611,10 +741,12 @@ def _deliver(
|
|
|
611
741
|
return _helper_blocked(lines, published, receipt)
|
|
612
742
|
failure = _fetch_failure(state, "memory push")
|
|
613
743
|
lines.append(f"{failure.lines[0]} The commit remains local.")
|
|
614
|
-
return Outcome(failure.status, False, tuple(lines), published.committed, published.preserved, receipt
|
|
744
|
+
return Outcome(failure.status, False, tuple(lines), published.committed, published.preserved, receipt,
|
|
745
|
+
held=published.held)
|
|
615
746
|
if state.has_upstream and not state.ahead:
|
|
616
747
|
lines.append("memory push: remote already up to date.")
|
|
617
|
-
return Outcome("up_to_date", True, tuple(lines), published.committed, published.preserved, receipt
|
|
748
|
+
return Outcome("up_to_date", True, tuple(lines), published.committed, published.preserved, receipt,
|
|
749
|
+
held=published.held)
|
|
618
750
|
result = _push_remote(home, state, runner)
|
|
619
751
|
if not result.ok:
|
|
620
752
|
if credential_helper_blocked(result.output):
|
|
@@ -622,9 +754,11 @@ def _deliver(
|
|
|
622
754
|
what = "timed out" if result.timed_out else "was rejected"
|
|
623
755
|
lines.append(f"memory push: the push {what}; the commit remains local. {result.output}".rstrip())
|
|
624
756
|
status = "push_timed_out" if result.timed_out else "push_failed"
|
|
625
|
-
return Outcome(status, False, tuple(lines), published.committed, published.preserved, receipt
|
|
757
|
+
return Outcome(status, False, tuple(lines), published.committed, published.preserved, receipt,
|
|
758
|
+
held=published.held)
|
|
626
759
|
lines.append("memory push: delivered to the remote.")
|
|
627
|
-
return Outcome("pushed", True, tuple(lines), published.committed, published.preserved, receipt
|
|
760
|
+
return Outcome("pushed", True, tuple(lines), published.committed, published.preserved, receipt,
|
|
761
|
+
held=published.held)
|
|
628
762
|
|
|
629
763
|
|
|
630
764
|
def _push_remote(home: Path, state: GitState, runner: Optional[GitRunner]) -> GitResult:
|
|
@@ -656,7 +790,8 @@ def _helper_blocked(
|
|
|
656
790
|
) -> Outcome:
|
|
657
791
|
lines.append(_HELPER_BLOCKED_LINE)
|
|
658
792
|
return Outcome(
|
|
659
|
-
"credential_helper_blocked", False, tuple(lines), published.committed, published.preserved, receipt
|
|
793
|
+
"credential_helper_blocked", False, tuple(lines), published.committed, published.preserved, receipt,
|
|
794
|
+
held=published.held,
|
|
660
795
|
)
|
|
661
796
|
|
|
662
797
|
|
|
@@ -268,37 +268,37 @@ def test_a_store_that_cannot_be_inspected_is_an_error_not_uninitialized(tmp_path
|
|
|
268
268
|
|
|
269
269
|
|
|
270
270
|
def test_directory_classification_failure_surfaces(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
271
|
-
# An
|
|
271
|
+
# An lstat failure on a directory entry is reported, never raised.
|
|
272
272
|
root = tmp_path / "home"
|
|
273
273
|
root.mkdir()
|
|
274
274
|
_write_debrief(root)
|
|
275
|
-
|
|
275
|
+
real_lstat = os.lstat
|
|
276
276
|
|
|
277
|
-
def flaky(
|
|
278
|
-
if
|
|
279
|
-
raise PermissionError(13, "Permission denied", str(
|
|
280
|
-
return
|
|
277
|
+
def flaky(path: object, *args: object, **kwargs: object) -> os.stat_result:
|
|
278
|
+
if isinstance(path, (str, os.PathLike)) and Path(path).name == "demo-project":
|
|
279
|
+
raise PermissionError(13, "Permission denied", str(path))
|
|
280
|
+
return real_lstat(path, *args, **kwargs)
|
|
281
281
|
|
|
282
|
-
monkeypatch.setattr(
|
|
282
|
+
monkeypatch.setattr(os, "lstat", flaky)
|
|
283
283
|
cat = check_org_memory(root)
|
|
284
284
|
assert ("debriefs-unreadable", Severity.error) in _rows(cat)
|
|
285
285
|
assert not any(r.name == "debriefs-layout" and "empty store" in r.message for r in cat.results)
|
|
286
286
|
|
|
287
287
|
|
|
288
288
|
def test_record_classification_failure_surfaces(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
289
|
-
#
|
|
289
|
+
# An lstat failure while classifying a record lands in the unreadable row; other records still pass.
|
|
290
290
|
root = tmp_path / "home"
|
|
291
291
|
root.mkdir()
|
|
292
292
|
_write_debrief(root)
|
|
293
293
|
victim = _write_debrief(root, "20260825-bob-77aa88bb.md")
|
|
294
|
-
|
|
294
|
+
real_lstat = os.lstat
|
|
295
295
|
|
|
296
|
-
def flaky(
|
|
297
|
-
if
|
|
298
|
-
raise PermissionError(13, "Permission denied", str(
|
|
299
|
-
return
|
|
296
|
+
def flaky(path: object, *args: object, **kwargs: object) -> os.stat_result:
|
|
297
|
+
if isinstance(path, (str, os.PathLike)) and Path(path).name == victim.name:
|
|
298
|
+
raise PermissionError(13, "Permission denied", str(path))
|
|
299
|
+
return real_lstat(path, *args, **kwargs)
|
|
300
300
|
|
|
301
|
-
monkeypatch.setattr(
|
|
301
|
+
monkeypatch.setattr(os, "lstat", flaky)
|
|
302
302
|
rows = _org_rows(root)
|
|
303
303
|
assert ("debriefs-unreadable", Severity.error) in rows
|
|
304
304
|
assert ("debriefs-layout", Severity.ok) in rows
|
|
@@ -836,7 +836,11 @@ def test_report_points_at_memory_lint_only_when_it_is_on_path(tmp_path: Path) ->
|
|
|
836
836
|
assert doctor.to_json(categories, memory_lint="/opt/bin/memory-lint")["memory_lint"] == "/opt/bin/memory-lint"
|
|
837
837
|
|
|
838
838
|
|
|
839
|
-
def test_cli_exit_codes_follow_the_error_rows(
|
|
839
|
+
def test_cli_exit_codes_follow_the_error_rows(
|
|
840
|
+
tmp_path: Path, git_env: None, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch
|
|
841
|
+
) -> None:
|
|
842
|
+
# The verdict is the last line only without the memory-lint pointer, so pin the lookup off the host's PATH.
|
|
843
|
+
monkeypatch.setattr(doctor, "find_memory_lint", lambda: None)
|
|
840
844
|
home, _ = synced_home(tmp_path)
|
|
841
845
|
assert main(["doctor", "--home", str(home)]) == 0
|
|
842
846
|
assert capsys.readouterr().out.rstrip("\n").endswith("No issues found.")
|
|
@@ -25,6 +25,7 @@ from agent_memory.startup import (
|
|
|
25
25
|
MISSING,
|
|
26
26
|
READABLE,
|
|
27
27
|
UNREADABLE,
|
|
28
|
+
author_of,
|
|
28
29
|
build_manifest,
|
|
29
30
|
render_codex_hook,
|
|
30
31
|
render_text,
|
|
@@ -404,3 +405,80 @@ def test_session_start_resolves_dirty_behind_home(home: Path, tmp_path: Path, gi
|
|
|
404
405
|
assert git("status", "--porcelain", cwd=home) == ""
|
|
405
406
|
assert git("log", "-1", "--format=%s", cwd=home).startswith(f"memory: {runtime}@")
|
|
406
407
|
assert git("--git-dir", str(remote), "show", "main:org-memory/events/local.md", cwd=tmp_path) == "local content"
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
@pytest.mark.parametrize(
|
|
411
|
+
("relative", "author"),
|
|
412
|
+
[
|
|
413
|
+
("org-memory/debriefs/demo/2026/10/20261018-claude-ab12cd34.md", "claude"),
|
|
414
|
+
("org-memory/debriefs/demo/2026/10/20261018-bob-ops-9f00aa11.md", "bob-ops"),
|
|
415
|
+
("org-memory/debriefs/demo/2026/10/20261018-claude-AB12.md", None),
|
|
416
|
+
("org-memory/debriefs/demo/20261018-claude-ab12cd34.md", None),
|
|
417
|
+
("org-memory/events/20261018-120000-peer-rule.md", "codex"),
|
|
418
|
+
("org-memory/events/20261018-120001-no-frontmatter.md", None),
|
|
419
|
+
("org-memory/events/20261018-120002-missing.md", None),
|
|
420
|
+
("org-memory/recent.md", None),
|
|
421
|
+
("projects/demo/memory/open_threads.md", None),
|
|
422
|
+
],
|
|
423
|
+
)
|
|
424
|
+
def test_author_of_reads_debrief_names_and_event_frontmatter(home: Path, relative: str, author: str) -> None:
|
|
425
|
+
from conftest import write
|
|
426
|
+
write(home / "org-memory/events/20261018-120000-peer-rule.md", "---\ncreated_at_utc: x\nagent: codex\n---\n\nbody\n")
|
|
427
|
+
write(home / "org-memory/events/20261018-120001-no-frontmatter.md", "just text\n")
|
|
428
|
+
assert author_of(home, relative) == author
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
@pytest.mark.parametrize("runtime", ["claude", "codex"])
|
|
432
|
+
def test_session_start_holds_another_agents_paths_and_still_pulls(
|
|
433
|
+
home: Path, tmp_path: Path, git_env: None, runtime: str
|
|
434
|
+
) -> None:
|
|
435
|
+
# Synthetic files and identities only.
|
|
436
|
+
from conftest import git, write
|
|
437
|
+
peer_name = "codex" if runtime == "claude" else "claude"
|
|
438
|
+
remote = tmp_path / "remote.git"
|
|
439
|
+
git("init", "--bare", "--quiet", str(remote), cwd=tmp_path)
|
|
440
|
+
assert sync.init(home, remote=str(remote)).ok
|
|
441
|
+
peer = tmp_path / "peer"
|
|
442
|
+
git("clone", "--quiet", str(remote), str(peer), cwd=tmp_path)
|
|
443
|
+
write(peer / "org-memory/events/upstream.md", "upstream content\n")
|
|
444
|
+
git("add", "org-memory/events/upstream.md", cwd=peer)
|
|
445
|
+
git("commit", "-qm", "peer addition", cwd=peer)
|
|
446
|
+
git("push", cwd=peer)
|
|
447
|
+
|
|
448
|
+
peer_debrief = f"org-memory/debriefs/demo/2026/10/20261018-{peer_name}-ab12cd34.md"
|
|
449
|
+
peer_event = "org-memory/events/20261018-120000-peer-rule.md"
|
|
450
|
+
own_debrief = f"org-memory/debriefs/demo/2026/10/20261018-{runtime}-ef56ab78.md"
|
|
451
|
+
write(home / peer_debrief, "peer debrief\n")
|
|
452
|
+
write(home / peer_event, f"---\ncreated_at_utc: x\nagent: {peer_name}\n---\n\nbody\n")
|
|
453
|
+
write(home / own_debrief, "own debrief\n")
|
|
454
|
+
write(home / "projects/demo/memory/open_threads.md", "unattributed edit\n")
|
|
455
|
+
|
|
456
|
+
manifest = build_manifest(home, runtime=runtime, project="demo", pull=True)
|
|
457
|
+
assert manifest["pull"]["status"] == "pushed" and manifest["pull"]["ok"], manifest["pull"]
|
|
458
|
+
assert any("another agent wrote left for it to publish" in line for line in manifest["pull"]["lines"])
|
|
459
|
+
assert (home / "org-memory/events/upstream.md").read_text() == "upstream content\n"
|
|
460
|
+
assert sorted(git("ls-files", "--others", "--exclude-standard", cwd=home).splitlines()) == sorted([peer_debrief, peer_event])
|
|
461
|
+
published = git("--git-dir", str(remote), "ls-tree", "-r", "--name-only", "main", cwd=tmp_path).splitlines()
|
|
462
|
+
assert own_debrief in published and "projects/demo/memory/open_threads.md" in published
|
|
463
|
+
assert peer_debrief not in published and peer_event not in published
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def test_session_start_with_only_held_paths_still_fast_forwards(home: Path, tmp_path: Path, git_env: None) -> None:
|
|
467
|
+
from conftest import git, write
|
|
468
|
+
remote = tmp_path / "remote.git"
|
|
469
|
+
git("init", "--bare", "--quiet", str(remote), cwd=tmp_path)
|
|
470
|
+
assert sync.init(home, remote=str(remote)).ok
|
|
471
|
+
peer = tmp_path / "peer"
|
|
472
|
+
git("clone", "--quiet", str(remote), str(peer), cwd=tmp_path)
|
|
473
|
+
write(peer / "org-memory/events/upstream.md", "upstream content\n")
|
|
474
|
+
git("add", "org-memory/events/upstream.md", cwd=peer)
|
|
475
|
+
git("commit", "-qm", "peer addition", cwd=peer)
|
|
476
|
+
git("push", cwd=peer)
|
|
477
|
+
held = "org-memory/debriefs/demo/2026/10/20261018-codex-ab12cd34.md"
|
|
478
|
+
write(home / held, "peer debrief\n")
|
|
479
|
+
head = git("rev-parse", "HEAD", cwd=home)
|
|
480
|
+
|
|
481
|
+
manifest = build_manifest(home, runtime="claude", project="demo", pull=True)
|
|
482
|
+
assert manifest["pull"]["ok"] and manifest["result"] == "ok", manifest["pull"]
|
|
483
|
+
assert git("rev-parse", "HEAD", cwd=home) == git("--git-dir", str(remote), "rev-parse", "main", cwd=tmp_path) != head
|
|
484
|
+
assert git("status", "--porcelain", "--untracked-files=all", cwd=home) == f"?? {held}"
|
|
@@ -2,8 +2,11 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import os
|
|
4
4
|
import subprocess
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
from contextlib import contextmanager
|
|
5
8
|
from pathlib import Path
|
|
6
|
-
from typing import Dict, List, Optional, Sequence, Tuple
|
|
9
|
+
from typing import Dict, Iterator, List, Optional, Sequence, Tuple
|
|
7
10
|
|
|
8
11
|
import pytest
|
|
9
12
|
|
|
@@ -136,11 +139,12 @@ class FetchTimesOut:
|
|
|
136
139
|
|
|
137
140
|
def test_fetch_pull_push_and_clone_receive_network_timeouts(tmp_path: Path) -> None:
|
|
138
141
|
root = tmp_path / "home"
|
|
139
|
-
root.mkdir()
|
|
142
|
+
(root / ".git").mkdir(parents=True)
|
|
140
143
|
_write(root / MARKER, "memory sync enabled\n")
|
|
141
144
|
common = {
|
|
142
145
|
("rev-parse", "--is-inside-work-tree"): "true",
|
|
143
146
|
("rev-parse", "--show-toplevel"): str(root),
|
|
147
|
+
("rev-parse", "--git-path", sync.LOCK_FILE): f".git/{sync.LOCK_FILE}",
|
|
144
148
|
("remote",): "origin",
|
|
145
149
|
("rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"): "origin/main",
|
|
146
150
|
}
|
|
@@ -815,6 +819,10 @@ def test_pull_rebases_a_diverged_repository_and_reports_ahead(tmp_path: Path) ->
|
|
|
815
819
|
outcome = sync.pull(home)
|
|
816
820
|
|
|
817
821
|
assert outcome.status == "ahead" and outcome.ok
|
|
822
|
+
assert outcome.lines == (
|
|
823
|
+
"memory pull: rebased local commits onto upstream.",
|
|
824
|
+
"memory pull: 1 unpushed commit(s); use push to deliver.",
|
|
825
|
+
)
|
|
818
826
|
assert (home / "org-memory" / "remote.md").exists()
|
|
819
827
|
|
|
820
828
|
|
|
@@ -829,7 +837,8 @@ def test_pull_with_unpushed_commits_is_not_an_error(tmp_path: Path) -> None:
|
|
|
829
837
|
outcome = sync.pull(home)
|
|
830
838
|
|
|
831
839
|
assert outcome.status == "ahead" and outcome.ok
|
|
832
|
-
|
|
840
|
+
# Nothing came down, so no "already synced" ahead of the unpushed count.
|
|
841
|
+
assert outcome.lines == ("memory pull: 1 unpushed commit(s); use push to deliver.",)
|
|
833
842
|
|
|
834
843
|
|
|
835
844
|
def test_pull_when_already_synced(tmp_path: Path) -> None:
|
|
@@ -926,6 +935,29 @@ def test_init_is_idempotent(tmp_path: Path) -> None:
|
|
|
926
935
|
assert {path: path.read_bytes() for path in home.rglob("*") if path.is_file() and ".git" not in path.parts} == before
|
|
927
936
|
|
|
928
937
|
|
|
938
|
+
@pytest.mark.parametrize("local_default", ["master", "main"])
|
|
939
|
+
def test_init_starts_a_new_repository_on_main_whatever_the_local_default(
|
|
940
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, local_default: str
|
|
941
|
+
) -> None:
|
|
942
|
+
monkeypatch.setenv("GIT_CONFIG_VALUE_0", local_default)
|
|
943
|
+
home = _local_home(tmp_path)
|
|
944
|
+
assert _git("symbolic-ref", "--short", "HEAD", cwd=home) == "main"
|
|
945
|
+
assert _git("branch", "--format=%(refname:short)", cwd=home) == "main"
|
|
946
|
+
|
|
947
|
+
|
|
948
|
+
def test_init_keeps_the_branch_of_an_existing_repository(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
949
|
+
monkeypatch.setenv("GIT_CONFIG_VALUE_0", "master")
|
|
950
|
+
home = tmp_path / "home"
|
|
951
|
+
home.mkdir()
|
|
952
|
+
_git("init", "--quiet", cwd=home)
|
|
953
|
+
|
|
954
|
+
assert sync.init(home).ok
|
|
955
|
+
assert sync.init(home).ok
|
|
956
|
+
|
|
957
|
+
assert _git("branch", "--format=%(refname:short)", cwd=home) == "master"
|
|
958
|
+
assert _head_paths(home) == [".gitignore", MARKER]
|
|
959
|
+
|
|
960
|
+
|
|
929
961
|
def test_init_adds_origin_when_another_remote_exists(tmp_path: Path) -> None:
|
|
930
962
|
remote = tmp_path / "memory.git"
|
|
931
963
|
other = tmp_path / "other.git"
|
|
@@ -1094,6 +1126,87 @@ def test_existing_rebase_is_never_published_or_aborted(tmp_path: Path, verb) ->
|
|
|
1094
1126
|
assert (home / ".git/rebase-merge").exists()
|
|
1095
1127
|
|
|
1096
1128
|
|
|
1129
|
+
@pytest.mark.parametrize("verb", [sync.pull, sync.push])
|
|
1130
|
+
def test_a_rebase_begun_after_the_precheck_is_never_aborted(tmp_path: Path, verb) -> None:
|
|
1131
|
+
# The in-progress precheck runs before the fetch. A rebase somebody else starts
|
|
1132
|
+
# once the upstream has been read, just before ours, is theirs and must survive.
|
|
1133
|
+
remote, seed = _create_remote(tmp_path)
|
|
1134
|
+
home = tmp_path / "home"
|
|
1135
|
+
sync.clone(home, str(remote))
|
|
1136
|
+
_write(home / "org-memory/local.md", "local\n")
|
|
1137
|
+
_git("add", "org-memory/local.md", cwd=home)
|
|
1138
|
+
_git("commit", "-qm", "local", cwd=home)
|
|
1139
|
+
_commit_and_push(seed, "org-memory/remote.md", "remote\n")
|
|
1140
|
+
|
|
1141
|
+
def runner(args, *, cwd, timeout=None):
|
|
1142
|
+
result = run_git(args, cwd=cwd, timeout=timeout)
|
|
1143
|
+
if tuple(args[:2]) == ("rev-list", "--merges"):
|
|
1144
|
+
# Stops at the failing exec with a clean tree and its state directory in place.
|
|
1145
|
+
assert not run_git(["rebase", "--exec", "false", "HEAD~1"], cwd=cwd).ok
|
|
1146
|
+
assert (home / ".git/rebase-merge").exists()
|
|
1147
|
+
return result
|
|
1148
|
+
|
|
1149
|
+
outcome = verb(home, agent="test-agent", runner=runner)
|
|
1150
|
+
assert outcome.status == "operation_in_progress" and not outcome.ok
|
|
1151
|
+
assert (home / ".git/rebase-merge").exists()
|
|
1152
|
+
assert not _remote_has(remote, "org-memory/local.md")
|
|
1153
|
+
|
|
1154
|
+
|
|
1155
|
+
@contextmanager
|
|
1156
|
+
def _held_sync_lock(home: Path) -> Iterator[None]:
|
|
1157
|
+
"""Hold the home's sync lock the way a concurrent agent-memory process would."""
|
|
1158
|
+
fcntl = pytest.importorskip("fcntl")
|
|
1159
|
+
with open(home / ".git" / sync.LOCK_FILE, "a") as handle:
|
|
1160
|
+
fcntl.flock(handle, fcntl.LOCK_EX)
|
|
1161
|
+
yield
|
|
1162
|
+
|
|
1163
|
+
|
|
1164
|
+
@pytest.mark.parametrize("verb", [sync.pull, sync.push])
|
|
1165
|
+
def test_a_sync_held_elsewhere_past_the_wait_changes_nothing(
|
|
1166
|
+
tmp_path: Path, verb, monkeypatch: pytest.MonkeyPatch
|
|
1167
|
+
) -> None:
|
|
1168
|
+
remote, seed = _create_remote(tmp_path)
|
|
1169
|
+
home = tmp_path / "home"
|
|
1170
|
+
sync.clone(home, str(remote))
|
|
1171
|
+
_commit_and_push(seed, "org-memory/remote.md", "remote\n")
|
|
1172
|
+
_write(home / "org-memory/local.md", "local\n")
|
|
1173
|
+
monkeypatch.setattr(sync, "LOCK_WAIT_SECONDS", 0.2)
|
|
1174
|
+
runner = FetchTimesOut()
|
|
1175
|
+
|
|
1176
|
+
with _held_sync_lock(home):
|
|
1177
|
+
outcome = verb(home, agent="test-agent", runner=runner)
|
|
1178
|
+
|
|
1179
|
+
assert outcome.status == "sync_in_progress" and not outcome.ok
|
|
1180
|
+
assert "another agent-memory sync" in outcome.lines[-1]
|
|
1181
|
+
assert not any(call[0] in {"fetch", "add", "commit", "rebase"} for call in runner.calls)
|
|
1182
|
+
assert not (home / "org-memory/remote.md").exists()
|
|
1183
|
+
assert _git("status", "--porcelain", cwd=home) == "?? org-memory/local.md"
|
|
1184
|
+
|
|
1185
|
+
|
|
1186
|
+
def test_a_second_sync_waits_for_the_first_to_finish(tmp_path: Path) -> None:
|
|
1187
|
+
pytest.importorskip("fcntl")
|
|
1188
|
+
remote, seed = _create_remote(tmp_path)
|
|
1189
|
+
home = tmp_path / "home"
|
|
1190
|
+
sync.clone(home, str(remote))
|
|
1191
|
+
_commit_and_push(seed, "org-memory/remote.md", "remote\n")
|
|
1192
|
+
held = threading.Event()
|
|
1193
|
+
|
|
1194
|
+
def first_sync() -> None:
|
|
1195
|
+
with _held_sync_lock(home):
|
|
1196
|
+
held.set()
|
|
1197
|
+
time.sleep(0.3)
|
|
1198
|
+
|
|
1199
|
+
holder = threading.Thread(target=first_sync)
|
|
1200
|
+
holder.start()
|
|
1201
|
+
assert held.wait(timeout=10), "the first sync never took the lock"
|
|
1202
|
+
started = time.monotonic()
|
|
1203
|
+
outcome = sync.pull(home)
|
|
1204
|
+
holder.join()
|
|
1205
|
+
|
|
1206
|
+
assert outcome.status == "synced" and outcome.ok
|
|
1207
|
+
assert time.monotonic() - started >= 0.2, "the second sync ran alongside the first"
|
|
1208
|
+
|
|
1209
|
+
|
|
1097
1210
|
@pytest.mark.parametrize("verb", [sync.pull, sync.push])
|
|
1098
1211
|
def test_dirty_fetch_timeout_keeps_commit_and_identity(tmp_path: Path, verb) -> None:
|
|
1099
1212
|
remote, _ = _create_remote(tmp_path)
|
|
@@ -1181,3 +1294,53 @@ def test_local_merge_content_is_not_dropped_by_rebase(tmp_path: Path, verb) -> N
|
|
|
1181
1294
|
assert "merge commits" in outcome.lines[-1]
|
|
1182
1295
|
assert _git("rev-parse", "HEAD", cwd=home) == before
|
|
1183
1296
|
assert (home / "org-memory/merge-only.md").read_text() == "merge resolution\n"
|
|
1297
|
+
|
|
1298
|
+
|
|
1299
|
+
# Real git shapes; all repository names, identities and contents are synthetic.
|
|
1300
|
+
def test_pull_holds_paths_the_predicate_names_and_updates_past_them(tmp_path: Path) -> None:
|
|
1301
|
+
remote, seed = _create_remote(tmp_path)
|
|
1302
|
+
home = tmp_path / "home"
|
|
1303
|
+
sync.clone(home, str(remote))
|
|
1304
|
+
_commit_and_push(seed, "org-memory/peer.md", "peer content\n")
|
|
1305
|
+
_write(home / "org-memory/held.md", "someone else's\n")
|
|
1306
|
+
_write(home / "org-memory/local.md", "local content\n")
|
|
1307
|
+
outcome = sync.pull(home, agent="test-agent", hold=lambda path: path == "org-memory/held.md")
|
|
1308
|
+
assert outcome.ok and outcome.status == "pushed", outcome.lines
|
|
1309
|
+
assert outcome.held == ("org-memory/held.md",)
|
|
1310
|
+
assert outcome.committed == ("org-memory/local.md",)
|
|
1311
|
+
assert _git("status", "--porcelain", cwd=home) == "?? org-memory/held.md"
|
|
1312
|
+
assert _remote_has(remote, "org-memory/local.md") and not _remote_has(remote, "org-memory/held.md")
|
|
1313
|
+
assert (home / "org-memory/peer.md").read_text() == "peer content\n"
|
|
1314
|
+
assert any("1 untracked path(s) left in place: org-memory/held.md" in line for line in outcome.lines)
|
|
1315
|
+
|
|
1316
|
+
|
|
1317
|
+
@pytest.mark.parametrize(
|
|
1318
|
+
("held", "incoming"),
|
|
1319
|
+
[("org-memory/held.md", "org-memory/held.md"), ("org-memory/held/inner.md", "org-memory/held")],
|
|
1320
|
+
)
|
|
1321
|
+
def test_an_untracked_path_the_upstream_touches_still_blocks_the_update(tmp_path: Path, held: str, incoming: str) -> None:
|
|
1322
|
+
remote, seed = _create_remote(tmp_path)
|
|
1323
|
+
home = tmp_path / "home"
|
|
1324
|
+
sync.clone(home, str(remote))
|
|
1325
|
+
_commit_and_push(seed, incoming, "upstream\n")
|
|
1326
|
+
_write(home / held, "local\n")
|
|
1327
|
+
before = _git("rev-parse", "HEAD", cwd=home)
|
|
1328
|
+
outcome = sync.pull(home, agent="test-agent", hold=lambda path: path == held)
|
|
1329
|
+
assert outcome.status == "dirty" and not outcome.ok
|
|
1330
|
+
assert outcome.lines[-1].endswith(f"left untouched: {held}")
|
|
1331
|
+
assert _git("rev-parse", "HEAD", cwd=home) == before
|
|
1332
|
+
assert (home / held).read_text() == "local\n"
|
|
1333
|
+
|
|
1334
|
+
|
|
1335
|
+
def test_a_tracked_change_left_behind_still_blocks_the_update(tmp_path: Path) -> None:
|
|
1336
|
+
remote, seed = _create_remote(tmp_path)
|
|
1337
|
+
home = tmp_path / "home"
|
|
1338
|
+
sync.clone(home, str(remote))
|
|
1339
|
+
_commit_and_push(seed, "org-memory/recent.md", "base\n")
|
|
1340
|
+
assert sync.pull(home).ok
|
|
1341
|
+
_commit_and_push(seed, "org-memory/peer.md", "peer content\n")
|
|
1342
|
+
_write(home / "org-memory/recent.md", "edited by someone else\n")
|
|
1343
|
+
outcome = sync.pull(home, agent="test-agent", hold=lambda path: path == "org-memory/recent.md")
|
|
1344
|
+
assert outcome.status == "dirty" and not outcome.ok
|
|
1345
|
+
assert "org-memory/recent.md" in outcome.lines[-1]
|
|
1346
|
+
assert not (home / "org-memory/peer.md").exists()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/src/agent_memory/templates/org-memory/decisions.md
RENAMED
|
File without changes
|
{agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/src/agent_memory/templates/org-memory/recent.md
RENAMED
|
File without changes
|
{agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/src/agent_memory/templates/org-memory/rules.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/src/agent_memory/templates/workflow/SKILL.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/tests/fixtures/setup/claude_settings_fleet.json
RENAMED
|
File without changes
|
{agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/tests/fixtures/setup/codex_hooks_kernel.json
RENAMED
|
File without changes
|
{agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/tests/golden/canonical_memory_gitignore.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|