agent-memory-cli 0.1.2__tar.gz → 0.1.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. {agent_memory_cli-0.1.2 → agent_memory_cli-0.1.3}/CHANGELOG.md +6 -0
  2. {agent_memory_cli-0.1.2 → agent_memory_cli-0.1.3}/PKG-INFO +7 -6
  3. {agent_memory_cli-0.1.2 → agent_memory_cli-0.1.3}/README.md +6 -5
  4. {agent_memory_cli-0.1.2 → agent_memory_cli-0.1.3}/src/agent_memory/__init__.py +1 -1
  5. {agent_memory_cli-0.1.2 → agent_memory_cli-0.1.3}/src/agent_memory/cli.py +3 -3
  6. {agent_memory_cli-0.1.2 → agent_memory_cli-0.1.3}/src/agent_memory/startup.py +3 -3
  7. {agent_memory_cli-0.1.2 → agent_memory_cli-0.1.3}/src/agent_memory/sync.py +104 -44
  8. {agent_memory_cli-0.1.2 → agent_memory_cli-0.1.3}/tests/test_startup.py +22 -0
  9. {agent_memory_cli-0.1.2 → agent_memory_cli-0.1.3}/tests/test_sync.py +194 -11
  10. {agent_memory_cli-0.1.2 → agent_memory_cli-0.1.3}/.gitignore +0 -0
  11. {agent_memory_cli-0.1.2 → agent_memory_cli-0.1.3}/LICENSE +0 -0
  12. {agent_memory_cli-0.1.2 → agent_memory_cli-0.1.3}/Makefile +0 -0
  13. {agent_memory_cli-0.1.2 → agent_memory_cli-0.1.3}/pyproject.toml +0 -0
  14. {agent_memory_cli-0.1.2 → agent_memory_cli-0.1.3}/scripts/check_installed_wheel.py +0 -0
  15. {agent_memory_cli-0.1.2 → agent_memory_cli-0.1.3}/scripts/release_notes.py +0 -0
  16. {agent_memory_cli-0.1.2 → agent_memory_cli-0.1.3}/src/agent_memory/__main__.py +0 -0
  17. {agent_memory_cli-0.1.2 → agent_memory_cli-0.1.3}/src/agent_memory/archive.py +0 -0
  18. {agent_memory_cli-0.1.2 → agent_memory_cli-0.1.3}/src/agent_memory/debrief.py +0 -0
  19. {agent_memory_cli-0.1.2 → agent_memory_cli-0.1.3}/src/agent_memory/doctor.py +0 -0
  20. {agent_memory_cli-0.1.2 → agent_memory_cli-0.1.3}/src/agent_memory/events.py +0 -0
  21. {agent_memory_cli-0.1.2 → agent_memory_cli-0.1.3}/src/agent_memory/git_runner.py +0 -0
  22. {agent_memory_cli-0.1.2 → agent_memory_cli-0.1.3}/src/agent_memory/home.py +0 -0
  23. {agent_memory_cli-0.1.2 → agent_memory_cli-0.1.3}/src/agent_memory/layout.py +0 -0
  24. {agent_memory_cli-0.1.2 → agent_memory_cli-0.1.3}/src/agent_memory/org.py +0 -0
  25. {agent_memory_cli-0.1.2 → agent_memory_cli-0.1.3}/src/agent_memory/prerequisites.py +0 -0
  26. {agent_memory_cli-0.1.2 → agent_memory_cli-0.1.3}/src/agent_memory/publication.py +0 -0
  27. {agent_memory_cli-0.1.2 → agent_memory_cli-0.1.3}/src/agent_memory/setup/__init__.py +0 -0
  28. {agent_memory_cli-0.1.2 → agent_memory_cli-0.1.3}/src/agent_memory/setup/claude.py +0 -0
  29. {agent_memory_cli-0.1.2 → agent_memory_cli-0.1.3}/src/agent_memory/setup/codex.py +0 -0
  30. {agent_memory_cli-0.1.2 → agent_memory_cli-0.1.3}/src/agent_memory/setup/common.py +0 -0
  31. {agent_memory_cli-0.1.2 → agent_memory_cli-0.1.3}/src/agent_memory/setup/legacy.py +0 -0
  32. {agent_memory_cli-0.1.2 → agent_memory_cli-0.1.3}/src/agent_memory/status.py +0 -0
  33. {agent_memory_cli-0.1.2 → agent_memory_cli-0.1.3}/src/agent_memory/templates/org-memory/decisions.md +0 -0
  34. {agent_memory_cli-0.1.2 → agent_memory_cli-0.1.3}/src/agent_memory/templates/org-memory/recent.md +0 -0
  35. {agent_memory_cli-0.1.2 → agent_memory_cli-0.1.3}/src/agent_memory/templates/org-memory/rules.md +0 -0
  36. {agent_memory_cli-0.1.2 → agent_memory_cli-0.1.3}/src/agent_memory/templates/project-memory/decision_log.md +0 -0
  37. {agent_memory_cli-0.1.2 → agent_memory_cli-0.1.3}/src/agent_memory/templates/project-memory/known_debt.md +0 -0
  38. {agent_memory_cli-0.1.2 → agent_memory_cli-0.1.3}/src/agent_memory/templates/project-memory/open_threads.md +0 -0
  39. {agent_memory_cli-0.1.2 → agent_memory_cli-0.1.3}/src/agent_memory/templates/project-memory/project_facts.md +0 -0
  40. {agent_memory_cli-0.1.2 → agent_memory_cli-0.1.3}/src/agent_memory/templates/workflow/SKILL.md +0 -0
  41. {agent_memory_cli-0.1.2 → agent_memory_cli-0.1.3}/src/agent_memory/workflow.py +0 -0
  42. {agent_memory_cli-0.1.2 → agent_memory_cli-0.1.3}/tests/conformance/org_memory/cases/bad_layout/expected.json +0 -0
  43. {agent_memory_cli-0.1.2 → agent_memory_cli-0.1.3}/tests/conformance/org_memory/cases/bad_layout/org-memory/debriefs/.hidden/2026/08/20260825-alice-abc12345.md +0 -0
  44. {agent_memory_cli-0.1.2 → agent_memory_cli-0.1.3}/tests/conformance/org_memory/cases/bad_layout/org-memory/debriefs/demo-project/2026/08/20260825-alice-1f3a9c2b.md +0 -0
  45. {agent_memory_cli-0.1.2 → agent_memory_cli-0.1.3}/tests/conformance/org_memory/cases/bad_layout/org-memory/debriefs/demo-project/2026/08/20260825-alice-ABCD.md +0 -0
  46. {agent_memory_cli-0.1.2 → agent_memory_cli-0.1.3}/tests/conformance/org_memory/cases/bad_layout/org-memory/debriefs/demo-project/2026/08/20260825_alice_abc12345.md +0 -0
  47. {agent_memory_cli-0.1.2 → agent_memory_cli-0.1.3}/tests/conformance/org_memory/cases/bad_layout/org-memory/debriefs/demo-project/2026/09/20260825-alice-def12345.md +0 -0
  48. {agent_memory_cli-0.1.2 → agent_memory_cli-0.1.3}/tests/conformance/org_memory/cases/bad_layout/org-memory/debriefs/demo-project/20260825-alice-abc12345.md +0 -0
  49. {agent_memory_cli-0.1.2 → agent_memory_cli-0.1.3}/tests/conformance/org_memory/cases/empty_store/expected.json +0 -0
  50. {agent_memory_cli-0.1.2 → agent_memory_cli-0.1.3}/tests/conformance/org_memory/cases/empty_store/org-memory/debriefs/.gitkeep +0 -0
  51. {agent_memory_cli-0.1.2 → agent_memory_cli-0.1.3}/tests/conformance/org_memory/cases/missing_debriefs_dir/expected.json +0 -0
  52. {agent_memory_cli-0.1.2 → agent_memory_cli-0.1.3}/tests/conformance/org_memory/cases/missing_debriefs_dir/org-memory/recent.md +0 -0
  53. {agent_memory_cli-0.1.2 → agent_memory_cli-0.1.3}/tests/conformance/org_memory/cases/staging_artifact/expected.json +0 -0
  54. {agent_memory_cli-0.1.2 → agent_memory_cli-0.1.3}/tests/conformance/org_memory/cases/staging_artifact/org-memory/debriefs/demo-project/2026/08/.stage.20260825-alice-77xx88yy.md.a1b2c3 +0 -0
  55. {agent_memory_cli-0.1.2 → agent_memory_cli-0.1.3}/tests/conformance/org_memory/cases/staging_artifact/org-memory/debriefs/demo-project/2026/08/20260825-alice-1f3a9c2b.md +0 -0
  56. {agent_memory_cli-0.1.2 → agent_memory_cli-0.1.3}/tests/conformance/org_memory/cases/valid_store/expected.json +0 -0
  57. {agent_memory_cli-0.1.2 → agent_memory_cli-0.1.3}/tests/conformance/org_memory/cases/valid_store/org-memory/debriefs/.gitkeep +0 -0
  58. {agent_memory_cli-0.1.2 → agent_memory_cli-0.1.3}/tests/conformance/org_memory/cases/valid_store/org-memory/debriefs/Demo_Project/2026/08/20260825-bob-ops-9f00aa11.md +0 -0
  59. {agent_memory_cli-0.1.2 → agent_memory_cli-0.1.3}/tests/conformance/org_memory/cases/valid_store/org-memory/debriefs/demo-project/2026/08/20260825-alice-1f3a9c2b.md +0 -0
  60. {agent_memory_cli-0.1.2 → agent_memory_cli-0.1.3}/tests/conformance/org_memory/cases/valid_store/org-memory/debriefs/demo.project/2026/07/20260701-Alice_2.dev-00aa11bb.md +0 -0
  61. {agent_memory_cli-0.1.2 → agent_memory_cli-0.1.3}/tests/conformance/org_memory/cases/valid_store/org-memory/debriefs/other-project/2026/12/20261203-bob-9e0d44aa.md +0 -0
  62. {agent_memory_cli-0.1.2 → agent_memory_cli-0.1.3}/tests/conftest.py +0 -0
  63. {agent_memory_cli-0.1.2 → agent_memory_cli-0.1.3}/tests/fixtures/setup/claude_settings_fleet.json +0 -0
  64. {agent_memory_cli-0.1.2 → agent_memory_cli-0.1.3}/tests/fixtures/setup/codex_hooks_kernel.json +0 -0
  65. {agent_memory_cli-0.1.2 → agent_memory_cli-0.1.3}/tests/golden/canonical_memory_gitignore.txt +0 -0
  66. {agent_memory_cli-0.1.2 → agent_memory_cli-0.1.3}/tests/golden/debrief_record.md +0 -0
  67. {agent_memory_cli-0.1.2 → agent_memory_cli-0.1.3}/tests/golden/doctor_memory_0.4.5.json +0 -0
  68. {agent_memory_cli-0.1.2 → agent_memory_cli-0.1.3}/tests/golden/doctor_memory_0.4.5.txt +0 -0
  69. {agent_memory_cli-0.1.2 → agent_memory_cli-0.1.3}/tests/golden/event_record.md +0 -0
  70. {agent_memory_cli-0.1.2 → agent_memory_cli-0.1.3}/tests/test_archive.py +0 -0
  71. {agent_memory_cli-0.1.2 → agent_memory_cli-0.1.3}/tests/test_cli.py +0 -0
  72. {agent_memory_cli-0.1.2 → agent_memory_cli-0.1.3}/tests/test_debrief.py +0 -0
  73. {agent_memory_cli-0.1.2 → agent_memory_cli-0.1.3}/tests/test_doctor.py +0 -0
  74. {agent_memory_cli-0.1.2 → agent_memory_cli-0.1.3}/tests/test_events.py +0 -0
  75. {agent_memory_cli-0.1.2 → agent_memory_cli-0.1.3}/tests/test_home.py +0 -0
  76. {agent_memory_cli-0.1.2 → agent_memory_cli-0.1.3}/tests/test_layout.py +0 -0
  77. {agent_memory_cli-0.1.2 → agent_memory_cli-0.1.3}/tests/test_org.py +0 -0
  78. {agent_memory_cli-0.1.2 → agent_memory_cli-0.1.3}/tests/test_prerequisites.py +0 -0
  79. {agent_memory_cli-0.1.2 → agent_memory_cli-0.1.3}/tests/test_setup.py +0 -0
  80. {agent_memory_cli-0.1.2 → agent_memory_cli-0.1.3}/tests/test_status.py +0 -0
  81. {agent_memory_cli-0.1.2 → agent_memory_cli-0.1.3}/tests/test_workflow.py +0 -0
@@ -4,6 +4,12 @@ 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.3 - 2026-09-29
8
+
9
+ ### Fixed
10
+
11
+ - `pull`, `push`, and SessionStart sync can resolve homes with local memory changes behind upstream ([sync](docs/commands.md#sync)).
12
+
7
13
  ## 0.1.2 - 2026-09-17
8
14
 
9
15
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: agent-memory-cli
3
- Version: 0.1.2
3
+ Version: 0.1.3
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
@@ -105,7 +105,7 @@ Python 3.10+ is required; sync needs git 2.25+, hooks use Bash, and
105
105
  agent-memory enable --remote git@github.com:YOUR_ORG/agent-memory-store.git
106
106
  agent-memory push --agent codex
107
107
  # After another machine pushes, with a clean local tree:
108
- agent-memory pull
108
+ agent-memory pull --agent claude
109
109
  ```
110
110
 
111
111
  Elsewhere, `agent-memory clone <remote-url> --home <local-home>`, then repeat
@@ -141,13 +141,14 @@ points into a home's `projects/` tree. Project selection uses `--project` or a
141
141
  matching binding/marker. OACP is not required.
142
142
 
143
143
  Sync makes the home a git repository with an allowlist and `.oacp-memory-repo`
144
- marker. `push` commits selected paths; `pull` fast-forwards a clean tree that
145
- is not ahead or diverged. No merges; keys and setup receipts stay local.
146
- Network verbs time out after 30 seconds.
144
+ marker. `push` and `pull --agent NAME` commit selected paths, rebase when behind,
145
+ and deliver. A plain `pull` updates a clean tree and reports unpushed commits.
146
+ Conflicts abort the rebase and retain local commits. Keys and setup receipts
147
+ stay local. Network and rebase operations time out after 30 seconds.
147
148
 
148
149
  `setup` installs a SessionStart hook and memory workflow. `startup` lists
149
150
  metadata for the four project files, then the three curated org files; it
150
- injects no content. `--pull` refreshes first, warning on failure. The workflow
151
+ injects no content. `--pull` commits and syncs first under the runtime identity, warning on failure. The workflow
151
152
  tells the agent to run `recall` for an 8,000-character bounded read. Raise
152
153
  `--max-chars` or read remaining files directly when cut. `capture` records
153
154
  decisions during work. At the end, update threads and debt, optionally publish
@@ -75,7 +75,7 @@ Python 3.10+ is required; sync needs git 2.25+, hooks use Bash, and
75
75
  agent-memory enable --remote git@github.com:YOUR_ORG/agent-memory-store.git
76
76
  agent-memory push --agent codex
77
77
  # After another machine pushes, with a clean local tree:
78
- agent-memory pull
78
+ agent-memory pull --agent claude
79
79
  ```
80
80
 
81
81
  Elsewhere, `agent-memory clone <remote-url> --home <local-home>`, then repeat
@@ -111,13 +111,14 @@ points into a home's `projects/` tree. Project selection uses `--project` or a
111
111
  matching binding/marker. OACP is not required.
112
112
 
113
113
  Sync makes the home a git repository with an allowlist and `.oacp-memory-repo`
114
- marker. `push` commits selected paths; `pull` fast-forwards a clean tree that
115
- is not ahead or diverged. No merges; keys and setup receipts stay local.
116
- Network verbs time out after 30 seconds.
114
+ marker. `push` and `pull --agent NAME` commit selected paths, rebase when behind,
115
+ and deliver. A plain `pull` updates a clean tree and reports unpushed commits.
116
+ Conflicts abort the rebase and retain local commits. Keys and setup receipts
117
+ stay local. Network and rebase operations time out after 30 seconds.
117
118
 
118
119
  `setup` installs a SessionStart hook and memory workflow. `startup` lists
119
120
  metadata for the four project files, then the three curated org files; it
120
- injects no content. `--pull` refreshes first, warning on failure. The workflow
121
+ injects no content. `--pull` commits and syncs first under the runtime identity, warning on failure. The workflow
121
122
  tells the agent to run `recall` for an 8,000-character bounded read. Raise
122
123
  `--max-chars` or read remaining files directly when cut. `capture` records
123
124
  decisions during work. At the end, update threads and debt, optionally publish
@@ -4,6 +4,6 @@
4
4
 
5
5
  from .home import HomeError, HomeResolution, resolve_home
6
6
 
7
- __version__ = "0.1.2"
7
+ __version__ = "0.1.3"
8
8
 
9
9
  __all__ = ["HomeError", "HomeResolution", "__version__", "resolve_home"]
@@ -92,7 +92,7 @@ def build_parser() -> argparse.ArgumentParser:
92
92
  clone_ = add("clone", _clone, "Clone a memory repository into the home.")
93
93
  clone_.add_argument("url", help="git remote URL to clone")
94
94
  clone_.add_argument("--force", action="store_true", help="move a non-empty home aside before cloning")
95
- add("pull", _pull, "Fast-forward the home from its upstream when the tree is clean and not ahead.")
95
+ add("pull", _pull, "Update from upstream; with --agent, commit allowlisted changes and deliver them too.", agent)
96
96
  add("push", _push, "Commit the allowlisted memory changes and push them when a remote exists.", agent)
97
97
  add("disable", _disable, "Remove the sync marker; the repository stays in place.")
98
98
  for name, handler, help_text, positional, positional_help in (
@@ -121,7 +121,7 @@ def build_parser() -> argparse.ArgumentParser:
121
121
  "startup",
122
122
  _startup,
123
123
  "Print the session-start manifest: the memory files to read, in order, with their readability, size and "
124
- "age, and where the sync stands. Content is never included. With --pull, fast-forward the home first.",
124
+ "age, and where the sync stands. Content is never included. With --pull, commit and sync memory under the runtime identity first.",
125
125
  )
126
126
  startup_.add_argument("--runtime", choices=startup.RUNTIMES, required=True, help="shape the output for this runtime's hook")
127
127
  startup_.add_argument("--project", metavar="ID", help="the project tier to list (default: the one the binding or marker names)")
@@ -297,7 +297,7 @@ def _clone(args: argparse.Namespace) -> int:
297
297
 
298
298
 
299
299
  def _pull(args: argparse.Namespace) -> int:
300
- return _report(sync.pull(resolve_home(args.home).path))
300
+ return _report(sync.pull(resolve_home(args.home).path, agent=args.agent))
301
301
 
302
302
 
303
303
  def _push(args: argparse.Namespace) -> int:
@@ -72,7 +72,7 @@ def build_manifest(
72
72
  warnings: List[str] = list(notes)
73
73
  files: List[Dict[str, Any]] = []
74
74
 
75
- sync_info, pull_info = _sync(home, pull, runner, warnings)
75
+ sync_info, pull_info = _sync(home, pull, runner, warnings, runtime)
76
76
  if project is not None:
77
77
  try:
78
78
  layout.validate_project_name(project)
@@ -154,14 +154,14 @@ def _entry(home: Path, path: Path, tier: str, name: str) -> Dict[str, Any]:
154
154
 
155
155
 
156
156
  def _sync(
157
- home: Path, pull: bool, runner: Optional[GitRunner], warnings: List[str]
157
+ home: Path, pull: bool, runner: Optional[GitRunner], warnings: List[str], runtime: str
158
158
  ) -> Tuple[Dict[str, Any], Dict[str, Any]]:
159
159
  marker = sync.is_configured(home)
160
160
  info: Dict[str, Any] = {"marker": marker, "last_commit_at_utc": None}
161
161
  pull_info: Dict[str, Any] = {"requested": pull, "status": PULL_NOT_REQUESTED, "ok": True, "lines": []}
162
162
  if pull:
163
163
  try:
164
- outcome = sync.pull(home, runner=runner)
164
+ outcome = sync.pull(home, agent=runtime, runner=runner)
165
165
  pull_info = {"requested": True, "status": outcome.status, "ok": outcome.ok, "lines": list(outcome.lines)}
166
166
  except (sync.SyncError, OSError) as exc:
167
167
  pull_info = {"requested": True, "status": PULL_ERROR, "ok": False, "lines": [f"memory pull: {exc}"]}
@@ -4,8 +4,7 @@
4
4
 
5
5
  The engine owns the sync marker, the managed block of the home's
6
6
  ``.gitignore``, the git state readout and the verbs ``init``, ``clone``,
7
- ``pull``, ``push`` and ``disable``. It never reads memory content, never
8
- merges and never touches ``keys/``.
7
+ ``pull``, ``push`` and ``disable``. It never reads memory content directly and never touches ``keys/``.
9
8
 
10
9
  The publication boundary
11
10
  ------------------------
@@ -27,10 +26,10 @@ except the ones an earlier block carried and the current one retired, and
27
26
  every write comes back with a before/after receipt that names them.
28
27
 
29
28
  The network verbs (fetch, pull, push, clone) run under a 30 s timeout.
30
- ``pull`` fast-forwards only when the tree is clean, not ahead, not diverged
31
- and an upstream exists. ``push`` refuses a repository that is behind or
32
- diverged, and a push the remote rejects leaves the local commit in place and
33
- says so: a local-only commit and remote delivery are distinct outcomes.
29
+ ``push`` and ``pull --agent`` commit allowlisted changes before fetching,
30
+ rebase when behind, then deliver. A clean ``pull`` without an agent updates
31
+ and reports unpushed commits. Conflicts abort the rebase and keep local
32
+ commits; rejected delivery also leaves them local.
34
33
  """
35
34
 
36
35
  from __future__ import annotations
@@ -350,39 +349,43 @@ def clone(home: Path, url: str, *, force: bool = False, runner: Optional[GitRunn
350
349
  )
351
350
 
352
351
 
353
- def pull(home: Path, *, runner: Optional[GitRunner] = None) -> Outcome:
354
- """Fast-forward ``home`` from its upstream when that is the only thing that could happen.
352
+ def pull(
353
+ home: Path,
354
+ *,
355
+ agent: Optional[str] = None,
356
+ runner: Optional[GitRunner] = None,
357
+ env: Optional[Mapping[str, str]] = None,
358
+ ) -> Outcome:
359
+ """Update the home, publishing dirty memory only with an explicit identity.
355
360
 
356
- Silent on a home that is not configured for sync. Every state that rules
357
- a fast-forward out (a failed fetch, a dirty tree, divergence) is its own
358
- status and not a success; being ahead or having no upstream is reported
359
- and is not an error.
361
+ Clean pulls keep their read-mostly behavior: fast-forward, or rebase local
362
+ commits and report ahead. An explicit agent permits dirty publication/delivery;
363
+ SessionStart supplies its runtime name through this same entry point.
360
364
  """
361
365
  home = _home(home)
362
366
  if not is_configured(home):
363
367
  return Outcome("not_configured", True)
364
368
  _require_repo(home, runner)
369
+ if _operation_in_progress(home, runner):
370
+ return Outcome("operation_in_progress", False, ("memory pull: a git operation is in progress; finish it first.",))
371
+ if changed_paths(home, runner):
372
+ if agent:
373
+ return _sync_changes(home, agent=agent, runner=runner, env=env)
374
+ return Outcome("dirty", False, ("memory pull: uncommitted changes present; pass --agent NAME to commit and sync.",))
365
375
  state = git_state(home, runner=runner, fetch=True)
366
376
  if not state.has_remote:
367
377
  return Outcome("local_only", True, ("memory pull: local-only repository; no remote to pull from.",))
368
378
  if state.fetch_failed:
369
379
  return _fetch_failure(state, "memory pull")
370
- if state.dirty:
371
- return Outcome("dirty", False, ("memory pull: uncommitted changes present; not pulling.",))
372
- if state.diverged:
373
- return Outcome("diverged", False, ("memory pull: diverged from upstream; resolve manually.",))
374
380
  if not state.has_upstream:
375
381
  return Outcome("no_upstream", True, ("memory pull: no upstream branch configured; skipping.",))
376
- if state.ahead:
377
- return Outcome("ahead", True, (f"memory pull: {state.ahead} unpushed commit(s); nothing to pull.",))
378
- if not state.behind:
379
- return Outcome("up_to_date", True, ("memory pull: already synced.",))
380
- result = _git(home, ["pull", "--ff-only", "--quiet"], runner, timeout=NETWORK_TIMEOUT_SECONDS)
381
- if result.timed_out:
382
- return Outcome("pull_timed_out", False, (f"memory pull: {result.output}",))
383
- if not result.ok:
384
- return Outcome("pull_failed", False, (f"memory pull: fast-forward failed: {result.output}",))
385
- return Outcome("synced", True, (f"memory pull: synced {state.behind} commit(s).",))
382
+ updated = _update(home, state, runner)
383
+ if not updated.ok:
384
+ 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
386
389
 
387
390
 
388
391
  def push(
@@ -392,36 +395,93 @@ def push(
392
395
  runner: Optional[GitRunner] = None,
393
396
  env: Optional[Mapping[str, str]] = None,
394
397
  ) -> Outcome:
395
- """Commit the allowlisted memory changes and deliver them when a remote exists.
396
-
397
- Silent on a home that is not configured for sync. A home that is behind
398
- or diverged is refused before anything is committed. A commit that could
399
- not be delivered -- no remote, an unreachable one, a rejected push -- is
400
- reported as such, distinctly from delivery; a credential helper a sandbox
401
- blocked is named as its own cause, not as a rejection.
402
- """
398
+ """Commit allowlisted changes, update from upstream, and deliver to the remote."""
403
399
  home = _home(home)
404
400
  if not is_configured(home):
405
401
  return Outcome("not_configured", True)
406
402
  _require_repo(home, runner)
407
- state = git_state(home, runner=runner, fetch=True)
408
- if state.has_remote and not state.fetch_failed:
409
- if state.diverged:
410
- return Outcome("diverged", False, ("memory push: diverged from upstream; resolve manually before pushing.",))
411
- if state.behind:
412
- return Outcome(
413
- "behind", False, (f"memory push: behind upstream by {state.behind} commit(s); pull before pushing.",)
414
- )
403
+ if _operation_in_progress(home, runner):
404
+ return Outcome("operation_in_progress", False, ("memory push: a git operation is in progress; finish it first.",))
405
+ return _sync_changes(home, agent=agent, runner=runner, env=env)
406
+
407
+
408
+ def _operation_in_progress(home: Path, runner: Optional[GitRunner]) -> bool:
409
+ # Never abort or publish into an operation started by somebody else.
410
+ for name in ("rebase-merge", "rebase-apply", "MERGE_HEAD", "CHERRY_PICK_HEAD", "REVERT_HEAD"):
411
+ result = _git(home, ["rev-parse", "--git-path", name], runner)
412
+ if not result.ok:
413
+ raise SyncError(f"cannot inspect git operation state: {result.output}")
414
+ path = result.stdout.strip()
415
+ if path and (home / path).exists():
416
+ return True
417
+ return False
418
+
419
+
420
+ def _sync_changes(
421
+ home: Path, *, agent: Optional[str], runner: Optional[GitRunner], env: Optional[Mapping[str, str]]
422
+ ) -> Outcome:
415
423
  published = _publish(home, agent=agent, runner=runner, env=env)
416
424
  if not published.ok:
417
425
  return published
426
+ # Commit before fetch: even a network failure leaves a recoverable local commit.
427
+ state = git_state(home, runner=runner, fetch=True)
418
428
  lines = list(published.lines)
419
429
  if not state.has_remote:
420
430
  lines.append("memory push: no remote configured; the commit remains local.")
421
431
  return Outcome("local_only", True, tuple(lines), published.committed, published.preserved)
432
+ if not state.fetch_failed and state.has_upstream and state.behind:
433
+ updated = _update(home, state, runner)
434
+ lines.extend(updated.lines)
435
+ if not updated.ok:
436
+ return Outcome(updated.status, False, tuple(lines), published.committed, published.preserved)
437
+ state = git_state(home, runner=runner, fetch=False)
422
438
  return _deliver(home, published, state, lines, runner)
423
439
 
424
440
 
441
+ def _update(home: Path, state: GitState, runner: Optional[GitRunner]) -> Outcome:
442
+ if not state.behind:
443
+ return Outcome("up_to_date", True, ("memory pull: already synced.",))
444
+ remaining = changed_paths(home, runner)
445
+ if remaining:
446
+ return Outcome("dirty", False, (
447
+ "memory pull: cannot update with remaining uncommitted paths; left untouched: " + ", ".join(remaining),
448
+ ))
449
+ if not state.ahead:
450
+ result = _git(home, ["pull", "--ff-only", "--quiet"], runner, timeout=NETWORK_TIMEOUT_SECONDS)
451
+ if result.timed_out:
452
+ return Outcome("pull_timed_out", False, (f"memory pull: {result.output}",))
453
+ if not result.ok:
454
+ 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).",))
456
+ merges = _git(home, ["rev-list", "--merges", f"{state.upstream}..HEAD"], runner)
457
+ if not merges.ok or merges.stdout.strip():
458
+ return Outcome("diverged", False, (
459
+ "memory pull: local merge commits cannot be safely rebased automatically; resolve manually."
460
+ if merges.ok else f"memory pull: cannot inspect local history: {merges.output}",
461
+ ))
462
+ # Rebase exactly the fetched upstream. Do not stash unrelated edits or reuse
463
+ # a recorded conflict resolution: a conflicting same-path add must surface.
464
+ result = _git(home, ["-c", "rerere.enabled=false", "-c", "rebase.updateRefs=false",
465
+ "rebase", "--no-autostash", state.upstream],
466
+ runner, timeout=NETWORK_TIMEOUT_SECONDS)
467
+ if result.ok:
468
+ return Outcome("synced", True, ("memory pull: rebased local commits onto upstream.",))
469
+ conflicts = _git(home, ["diff", "--name-only", "--diff-filter=U", "-z"], runner)
470
+ paths = [path for path in conflicts.stdout.split("\0") if path] if conflicts.ok else []
471
+ if _operation_in_progress(home, runner):
472
+ aborted = _git(home, ["rebase", "--abort"], runner, timeout=NETWORK_TIMEOUT_SECONDS)
473
+ if not aborted.ok or _operation_in_progress(home, runner):
474
+ return Outcome("rebase_abort_failed", False, (
475
+ f"memory pull: rebase failed and could not be aborted; manual recovery required. {aborted.output}",
476
+ ))
477
+ detail = ", ".join(paths) if paths else result.output
478
+ status = "pull_timed_out" if result.timed_out else "diverged"
479
+ return Outcome(status, False, (
480
+ f"memory pull: {'rebase timed out' if result.timed_out else 'rebase failed'}; "
481
+ f"local commits retained. Conflicting paths or git error: {detail}",
482
+ ))
483
+
484
+
425
485
  def disable(home: Path) -> Outcome:
426
486
  """Remove the marker; the repository and its history stay in place."""
427
487
  home = _home(home)
@@ -552,9 +612,9 @@ def _deliver(
552
612
  failure = _fetch_failure(state, "memory push")
553
613
  lines.append(f"{failure.lines[0]} The commit remains local.")
554
614
  return Outcome(failure.status, False, tuple(lines), published.committed, published.preserved, receipt)
555
- if not published.committed and state.has_upstream and not state.ahead:
615
+ if state.has_upstream and not state.ahead:
556
616
  lines.append("memory push: remote already up to date.")
557
- return Outcome("up_to_date", True, tuple(lines), (), published.preserved, receipt)
617
+ return Outcome("up_to_date", True, tuple(lines), published.committed, published.preserved, receipt)
558
618
  result = _push_remote(home, state, runner)
559
619
  if not result.ok:
560
620
  if credential_helper_blocked(result.output):
@@ -382,3 +382,25 @@ def test_cli_startup_project_flag_wins_over_the_binding(home: Path, isolated: Pa
382
382
  manifest = json.loads(capsys.readouterr().out)
383
383
  assert manifest["project"] == "other" and manifest["project_source"] == "flag"
384
384
  assert [entry["state"] for entry in manifest["files"][:4]] == [MISSING] * 4
385
+
386
+
387
+ @pytest.mark.parametrize("runtime", ["claude", "codex"])
388
+ def test_session_start_resolves_dirty_behind_home(home: Path, tmp_path: Path, git_env: None, runtime: str) -> None:
389
+ # Realistic sync layout with synthetic files and identities only.
390
+ from conftest import git, write
391
+ remote = tmp_path / "remote.git"
392
+ git("init", "--bare", "--quiet", str(remote), cwd=tmp_path)
393
+ assert sync.init(home, remote=str(remote)).ok
394
+ peer = tmp_path / "peer"
395
+ git("clone", "--quiet", str(remote), str(peer), cwd=tmp_path)
396
+ write(peer / "org-memory/events/peer.md", "peer content\n")
397
+ git("add", "org-memory/events/peer.md", cwd=peer)
398
+ git("commit", "-qm", "peer addition", cwd=peer)
399
+ git("push", cwd=peer)
400
+ write(home / "org-memory/events/local.md", "local content\n")
401
+ manifest = build_manifest(home, runtime=runtime, project="demo", pull=True)
402
+ assert manifest["pull"]["status"] == "pushed" and manifest["pull"]["ok"]
403
+ assert manifest["result"] == "ok"
404
+ assert git("status", "--porcelain", cwd=home) == ""
405
+ assert git("log", "-1", "--format=%s", cwd=home).startswith(f"memory: {runtime}@")
406
+ assert git("--git-dir", str(remote), "show", "main:org-memory/events/local.md", cwd=tmp_path) == "local content"
@@ -720,7 +720,7 @@ def test_push_with_nothing_new_is_up_to_date(tmp_path: Path) -> None:
720
720
  assert outcome.committed == ()
721
721
 
722
722
 
723
- def test_push_refuses_a_repository_behind_its_upstream(tmp_path: Path) -> None:
723
+ def test_push_resolves_a_repository_behind_its_upstream(tmp_path: Path) -> None:
724
724
  remote, seed = _create_remote(tmp_path)
725
725
  home = tmp_path / "home"
726
726
  sync.clone(home, str(remote))
@@ -730,13 +730,13 @@ def test_push_refuses_a_repository_behind_its_upstream(tmp_path: Path) -> None:
730
730
 
731
731
  outcome = sync.push(home)
732
732
 
733
- assert outcome.status == "behind" and not outcome.ok
734
- assert "pull before pushing" in outcome.lines[0]
735
- assert _commit_count(home) == before
733
+ assert outcome.status == "pushed" and outcome.ok
734
+ assert _commit_count(home) == before + 2
735
+ assert _remote_has(remote, "org-memory/local.md")
736
736
  assert (home / "org-memory" / "local.md").read_text(encoding="utf-8") == "local update\n"
737
737
 
738
738
 
739
- def test_push_refuses_a_diverged_repository_without_committing(tmp_path: Path) -> None:
739
+ def test_push_rebases_existing_and_new_commits(tmp_path: Path) -> None:
740
740
  remote, seed = _create_remote(tmp_path)
741
741
  home = tmp_path / "home"
742
742
  sync.clone(home, str(remote))
@@ -749,8 +749,9 @@ def test_push_refuses_a_diverged_repository_without_committing(tmp_path: Path) -
749
749
 
750
750
  outcome = sync.push(home)
751
751
 
752
- assert outcome.status == "diverged" and not outcome.ok
753
- assert _commit_count(home) == before
752
+ assert outcome.status == "pushed" and outcome.ok
753
+ assert _commit_count(home) == before + 2
754
+ assert _remote_has(remote, "org-memory/uncommitted.md")
754
755
  assert (home / "org-memory" / "uncommitted.md").is_file()
755
756
 
756
757
 
@@ -802,7 +803,7 @@ def test_pull_refuses_a_dirty_tree(tmp_path: Path) -> None:
802
803
  assert (home / "org-memory" / "recent.md").read_text(encoding="utf-8") == "edited locally\n"
803
804
 
804
805
 
805
- def test_pull_refuses_a_diverged_repository(tmp_path: Path) -> None:
806
+ def test_pull_rebases_a_diverged_repository_and_reports_ahead(tmp_path: Path) -> None:
806
807
  remote, seed = _create_remote(tmp_path)
807
808
  home = tmp_path / "home"
808
809
  sync.clone(home, str(remote))
@@ -813,8 +814,8 @@ def test_pull_refuses_a_diverged_repository(tmp_path: Path) -> None:
813
814
 
814
815
  outcome = sync.pull(home)
815
816
 
816
- assert outcome.status == "diverged" and not outcome.ok
817
- assert not (home / "org-memory" / "remote.md").exists()
817
+ assert outcome.status == "ahead" and outcome.ok
818
+ assert (home / "org-memory" / "remote.md").exists()
818
819
 
819
820
 
820
821
  def test_pull_with_unpushed_commits_is_not_an_error(tmp_path: Path) -> None:
@@ -828,7 +829,7 @@ def test_pull_with_unpushed_commits_is_not_an_error(tmp_path: Path) -> None:
828
829
  outcome = sync.pull(home)
829
830
 
830
831
  assert outcome.status == "ahead" and outcome.ok
831
- assert "1 unpushed commit(s)" in outcome.lines[0]
832
+ assert "1 unpushed commit(s)" in outcome.lines[-1]
832
833
 
833
834
 
834
835
  def test_pull_when_already_synced(tmp_path: Path) -> None:
@@ -998,3 +999,185 @@ def test_push_commits_a_rename_git_detects_in_the_working_tree(tmp_path: Path) -
998
999
  assert outcome.committed == ("org-memory/b.md",) # HEAD reports a rename by its destination
999
1000
  assert _git("status", "--porcelain", cwd=home) == ""
1000
1001
  assert _git("show", "--name-status", "--format=", "HEAD", cwd=home) == "R100\torg-memory/a.md\torg-memory/b.md"
1002
+
1003
+
1004
+ # Real git shapes; all repository names, identities and contents are synthetic.
1005
+ @pytest.mark.parametrize("verb", [sync.pull, sync.push])
1006
+ @pytest.mark.parametrize("duplicate", [False, True])
1007
+ def test_dirty_behind_commits_rebases_delivers(tmp_path: Path, verb, duplicate: bool) -> None:
1008
+ remote, seed = _create_remote(tmp_path)
1009
+ home = tmp_path / "home"
1010
+ sync.clone(home, str(remote))
1011
+ _commit_and_push(seed, "org-memory/peer.md", "peer content\n")
1012
+ if duplicate:
1013
+ _write(home / "org-memory/peer.md", "peer content\n")
1014
+ _write(home / "org-memory/local.md", "local content\n")
1015
+ outcome = verb(home, agent="test-agent")
1016
+ assert outcome.ok and outcome.status == "pushed"
1017
+ assert _git("status", "--porcelain", cwd=home) == ""
1018
+ assert _git("rev-parse", "HEAD", cwd=home) == _git("rev-parse", "origin/main", cwd=home)
1019
+ assert _remote_has(remote, "org-memory/local.md")
1020
+ assert (home / "org-memory/peer.md").read_text() == "peer content\n"
1021
+ assert _git("log", "-1", "--format=%s", cwd=home).startswith("memory: test-agent@")
1022
+
1023
+
1024
+ @pytest.mark.parametrize("verb", [sync.pull, sync.push])
1025
+ def test_only_identical_untracked_add_becomes_up_to_date(tmp_path: Path, verb) -> None:
1026
+ remote, seed = _create_remote(tmp_path)
1027
+ home = tmp_path / "home"
1028
+ sync.clone(home, str(remote))
1029
+ _commit_and_push(seed, "org-memory/peer.md", "same\n")
1030
+ _write(home / "org-memory/peer.md", "same\n")
1031
+ outcome = verb(home, agent="test-agent")
1032
+ assert outcome.status == "up_to_date" and outcome.ok
1033
+ assert outcome.committed == ("org-memory/peer.md",)
1034
+ assert _git("rev-parse", "HEAD", cwd=home) == _git("rev-parse", "HEAD", cwd=seed)
1035
+ assert _git("status", "--porcelain", cwd=home) == ""
1036
+
1037
+
1038
+ @pytest.mark.parametrize("verb", [sync.pull, sync.push])
1039
+ @pytest.mark.parametrize("path", ["org-memory/recent.md", "org-memory/same path.md"])
1040
+ def test_conflict_aborts_and_retains_local_commit(tmp_path: Path, verb, path: str) -> None:
1041
+ remote, seed = _create_remote(tmp_path)
1042
+ home = tmp_path / "home"
1043
+ sync.clone(home, str(remote))
1044
+ _commit_and_push(seed, path, "upstream\n")
1045
+ _write(home / path, "local\n")
1046
+ before = _git("rev-parse", "HEAD", cwd=home)
1047
+ outcome = verb(home, agent="test-agent")
1048
+ assert outcome.status == "diverged" and not outcome.ok
1049
+ assert path in outcome.lines[-1]
1050
+ assert _git("rev-parse", "HEAD^", cwd=home) == before
1051
+ assert (home / path).read_text() == "local\n"
1052
+ assert _git("status", "--porcelain", cwd=home) == ""
1053
+ assert not (home / ".git/rebase-merge").exists()
1054
+ assert not (home / ".git/rebase-apply").exists()
1055
+ assert _git("--git-dir", str(remote), "show", f"main:{path}", cwd=tmp_path) == "upstream"
1056
+
1057
+
1058
+ @pytest.mark.parametrize("verb", [sync.pull, sync.push])
1059
+ def test_behind_preserves_foreign_index_and_worktree(tmp_path: Path, verb) -> None:
1060
+ remote, seed = _create_remote(tmp_path)
1061
+ home = tmp_path / "home"
1062
+ sync.clone(home, str(remote))
1063
+ _commit_and_push(seed, "org-memory/peer.md", "peer\n")
1064
+ _write(home / RUNTIME_FILE, "staged\n")
1065
+ _git("add", "-f", RUNTIME_FILE, cwd=home)
1066
+ _write(home / RUNTIME_FILE, "unstaged\n")
1067
+ _write(home / "org-memory/local.md", "memory\n")
1068
+ _git("config", "rebase.autoStash", "true", cwd=home)
1069
+ outcome = verb(home, agent="test-agent")
1070
+ assert outcome.status == "dirty" and not outcome.ok
1071
+ assert outcome.preserved == (RUNTIME_FILE,)
1072
+ assert _git("show", f":{RUNTIME_FILE}", cwd=home) == "staged"
1073
+ assert (home / RUNTIME_FILE).read_text() == "unstaged\n"
1074
+ assert not _remote_has(remote, "org-memory/local.md")
1075
+ assert not (home / ".git/rebase-merge").exists()
1076
+
1077
+
1078
+ @pytest.mark.parametrize("verb", [sync.pull, sync.push])
1079
+ def test_existing_rebase_is_never_published_or_aborted(tmp_path: Path, verb) -> None:
1080
+ remote, seed = _create_remote(tmp_path)
1081
+ home = tmp_path / "home"
1082
+ sync.clone(home, str(remote))
1083
+ _commit_and_push(seed, "org-memory/recent.md", "remote\n")
1084
+ _write(home / "org-memory/recent.md", "local\n")
1085
+ _git("add", "org-memory/recent.md", cwd=home)
1086
+ _git("commit", "-qm", "local", cwd=home)
1087
+ _git("fetch", cwd=home)
1088
+ result = run_git(["rebase", "origin/main"], cwd=home)
1089
+ assert not result.ok
1090
+ before = _git("status", "--porcelain", cwd=home)
1091
+ outcome = verb(home, agent="test-agent")
1092
+ assert outcome.status == "operation_in_progress" and not outcome.ok
1093
+ assert _git("status", "--porcelain", cwd=home) == before
1094
+ assert (home / ".git/rebase-merge").exists()
1095
+
1096
+
1097
+ @pytest.mark.parametrize("verb", [sync.pull, sync.push])
1098
+ def test_dirty_fetch_timeout_keeps_commit_and_identity(tmp_path: Path, verb) -> None:
1099
+ remote, _ = _create_remote(tmp_path)
1100
+ home = tmp_path / "home"
1101
+ sync.clone(home, str(remote))
1102
+ _write(home / "org-memory/local.md", "local\n")
1103
+ outcome = verb(home, agent="test-agent", runner=FetchTimesOut())
1104
+ assert outcome.status == "fetch_timed_out" and not outcome.ok
1105
+ assert outcome.committed == ("org-memory/local.md",)
1106
+ assert _git("status", "--porcelain", cwd=home) == ""
1107
+ assert _git("log", "-1", "--format=%s", cwd=home).startswith("memory: test-agent@")
1108
+ assert not _remote_has(remote, "org-memory/local.md")
1109
+
1110
+
1111
+ def test_pull_cli_requires_agent_for_dirty_publication(tmp_path: Path, capsys) -> None:
1112
+ from agent_memory.cli import main
1113
+ remote, seed = _create_remote(tmp_path)
1114
+ home = tmp_path / "home"
1115
+ sync.clone(home, str(remote))
1116
+ _commit_and_push(seed, "org-memory/peer.md", "peer\n")
1117
+ _write(home / "org-memory/local.md", "local\n")
1118
+ assert main(["pull", "--home", str(home)]) == 1
1119
+ assert "--agent" in capsys.readouterr().err
1120
+ assert main(["pull", "--home", str(home), "--agent", "test-agent"]) == 0
1121
+ assert _remote_has(remote, "org-memory/local.md")
1122
+
1123
+
1124
+ @pytest.mark.parametrize("verb", [sync.pull, sync.push])
1125
+ @pytest.mark.parametrize("abort_fails", [False, True])
1126
+ def test_rebase_timeout_aborts_or_names_recovery_failure(tmp_path: Path, verb, abort_fails: bool) -> None:
1127
+ remote, seed = _create_remote(tmp_path)
1128
+ home = tmp_path / "home"
1129
+ sync.clone(home, str(remote))
1130
+ _commit_and_push(seed, "org-memory/recent.md", "upstream\n")
1131
+ _write(home / "org-memory/recent.md", "local\n")
1132
+ before = _git("rev-parse", "HEAD", cwd=home)
1133
+
1134
+ def runner(args, *, cwd, timeout=None):
1135
+ if "rebase" in args and "--no-autostash" in args:
1136
+ assert timeout == sync.NETWORK_TIMEOUT_SECONDS
1137
+ # A real conflict leaves the same in-progress state a timeout can.
1138
+ assert not run_git(args, cwd=cwd, timeout=timeout).ok
1139
+ return GitResult(EXIT_TIMEOUT, "", "synthetic rebase timeout")
1140
+ if list(args) == ["rebase", "--abort"] and abort_fails:
1141
+ return GitResult(1, "", "synthetic abort failure")
1142
+ return run_git(args, cwd=cwd, timeout=timeout)
1143
+
1144
+ outcome = verb(home, agent="test-agent", runner=runner)
1145
+ assert not outcome.ok
1146
+ if abort_fails:
1147
+ assert outcome.status == "rebase_abort_failed"
1148
+ assert "manual recovery" in outcome.lines[-1]
1149
+ assert (home / ".git/rebase-merge").exists()
1150
+ else:
1151
+ assert outcome.status == "pull_timed_out"
1152
+ assert "org-memory/recent.md" in outcome.lines[-1]
1153
+ assert _git("rev-parse", "HEAD^", cwd=home) == before
1154
+ assert (home / "org-memory/recent.md").read_text() == "local\n"
1155
+ assert _git("status", "--porcelain", cwd=home) == ""
1156
+ assert not (home / ".git/rebase-merge").exists()
1157
+ assert _git("--git-dir", str(remote), "show", "main:org-memory/recent.md", cwd=tmp_path) == "upstream"
1158
+
1159
+
1160
+ @pytest.mark.parametrize("verb", [sync.pull, sync.push])
1161
+ def test_local_merge_content_is_not_dropped_by_rebase(tmp_path: Path, verb) -> None:
1162
+ remote, seed = _create_remote(tmp_path)
1163
+ home = tmp_path / "home"
1164
+ sync.clone(home, str(remote))
1165
+ _git("checkout", "-qb", "side", cwd=home)
1166
+ _write(home / "org-memory/side.md", "side\n")
1167
+ _git("add", "org-memory/side.md", cwd=home)
1168
+ _git("commit", "-qm", "side", cwd=home)
1169
+ _git("checkout", "main", cwd=home)
1170
+ _write(home / "org-memory/main.md", "main\n")
1171
+ _git("add", "org-memory/main.md", cwd=home)
1172
+ _git("commit", "-qm", "main", cwd=home)
1173
+ _git("merge", "--no-commit", "side", cwd=home)
1174
+ _write(home / "org-memory/merge-only.md", "merge resolution\n")
1175
+ _git("add", "org-memory/merge-only.md", cwd=home)
1176
+ _git("commit", "-qm", "merge", cwd=home)
1177
+ before = _git("rev-parse", "HEAD", cwd=home)
1178
+ _commit_and_push(seed, "org-memory/upstream.md", "upstream\n")
1179
+ outcome = verb(home, agent="test-agent")
1180
+ assert outcome.status == "diverged" and not outcome.ok
1181
+ assert "merge commits" in outcome.lines[-1]
1182
+ assert _git("rev-parse", "HEAD", cwd=home) == before
1183
+ assert (home / "org-memory/merge-only.md").read_text() == "merge resolution\n"