From acb00c45bd75ef506b04cdb61d9a5cb21ccc77b0 Mon Sep 17 00:00:00 2001 From: "kiloloop-release[bot]" <269344367+kiloloop-release[bot]@users.noreply.github.com> Date: Tue, 29 Sep 2026 11:30:44 +0800 Subject: [PATCH] feat: agent-memory v0.1.3 Fixes sync on a memory home that has local changes and is behind upstream. `push` and `pull --agent NAME` commit the allowlisted paths, fetch, rebase local commits onto upstream, then deliver; `startup --pull` supplies its runtime name as the agent, so SessionStart resolves the same case. A plain `pull` updates a clean home and reports any commits still ahead. A rebase conflict aborts, names the conflicting paths and keeps the local commit. Unrelated uncommitted paths are reported and never stashed; local merge commits and a git operation already in progress need manual resolution first. Rebases share the 30 second network timeout. --- CHANGELOG.md | 6 + README.md | 11 +- docs/commands.md | 33 ++++-- src/agent_memory/__init__.py | 2 +- src/agent_memory/cli.py | 6 +- src/agent_memory/startup.py | 6 +- src/agent_memory/sync.py | 148 +++++++++++++++++-------- tests/test_startup.py | 22 ++++ tests/test_sync.py | 205 +++++++++++++++++++++++++++++++++-- 9 files changed, 360 insertions(+), 79 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 872bca5..ff84f47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to agent-memory are documented in this file. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## 0.1.3 - 2026-09-29 + +### Fixed + +- `pull`, `push`, and SessionStart sync can resolve homes with local memory changes behind upstream ([sync](docs/commands.md#sync)). + ## 0.1.2 - 2026-09-17 ### Added diff --git a/README.md b/README.md index 008362e..1e314ae 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,7 @@ Python 3.10+ is required; sync needs git 2.25+, hooks use Bash, and agent-memory enable --remote git@github.com:YOUR_ORG/agent-memory-store.git agent-memory push --agent codex # After another machine pushes, with a clean local tree: - agent-memory pull + agent-memory pull --agent claude ``` Elsewhere, `agent-memory clone --home `, then repeat @@ -111,13 +111,14 @@ points into a home's `projects/` tree. Project selection uses `--project` or a matching binding/marker. OACP is not required. Sync makes the home a git repository with an allowlist and `.oacp-memory-repo` -marker. `push` commits selected paths; `pull` fast-forwards a clean tree that -is not ahead or diverged. No merges; keys and setup receipts stay local. -Network verbs time out after 30 seconds. +marker. `push` and `pull --agent NAME` commit selected paths, rebase when behind, +and deliver. A plain `pull` updates a clean tree and reports unpushed commits. +Conflicts abort the rebase and retain local commits. Keys and setup receipts +stay local. Network and rebase operations time out after 30 seconds. `setup` installs a SessionStart hook and memory workflow. `startup` lists metadata for the four project files, then the three curated org files; it -injects no content. `--pull` refreshes first, warning on failure. The workflow +injects no content. `--pull` commits and syncs first under the runtime identity, warning on failure. The workflow tells the agent to run `recall` for an 8,000-character bounded read. Raise `--max-chars` or read remaining files directly when cut. `capture` records decisions during work. At the end, update threads and debt, optionally publish diff --git a/docs/commands.md b/docs/commands.md index 8b51ff9..e964343 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -78,17 +78,26 @@ credentials. `org init` is the org tier alone, for a home that already exists. the head of its `.gitignore` as a managed block (existing lines are kept, lines an earlier block carried are retired, and the write comes with a before/after receipt that names them), drops the sync marker, and makes -one commit. With `--remote`, `enable` also pushes that initial commit. `push` commits only what the allowlist selects, as a partial -commit, so anything else staged in the index stays staged and uncommitted; -it refuses a home that is behind or diverged, and a push the remote rejects -leaves the local commit in place and says so. A credential helper a sandbox -blocked is a distinct failure from a rejection -- git prompts for a username -and has no terminal to read it from -- and `push` names it as such; the remedy -is to rerun with the sandbox off. `pull` fast-forwards only when -the tree is clean, not ahead, not diverged, and has an upstream. `clone` -brings a memory repository down; `disable` removes the marker. The network -verbs time out after 30 seconds. The engine never reads memory content, -never merges, and never touches `keys/`. It needs git 2.25 or newer. +one commit. With `--remote`, `enable` also pushes that initial commit. + +`push` and `pull --agent NAME` commit only what the allowlist selects, fetch, +rebase local commits onto upstream when behind, then push. Anything else +staged stays staged and uncommitted. If unrelated changes remain when an +update is needed, the command reports the paths and stops without stashing +them. A rebase conflict aborts, names the conflicting paths, and retains the +local commit; differing same-path additions are never automatically resolved. +Identical additions already upstream need no duplicate commit after rebase. +Local merge commits require manual integration so their resolutions cannot +be lost. An existing Git operation must be finished before either verb can run. + +A plain `pull` updates a clean home and reports any commits still ahead; +a dirty home requires `--agent NAME`. `startup --pull` supplies its runtime +name as the agent, so SessionStart can also resolve dirty, behind homes. +A failed fetch or rejected push leaves newly committed work local. A credential +helper blocked by the sandbox is reported separately; rerun with the sandbox +off. `clone` brings a memory repository down; `disable` removes the marker. +Network and rebase operations time out after 30 seconds. The engine does not +read memory content directly and never touches `keys/`. It needs git 2.25 or newer. ## Archive and restore @@ -142,7 +151,7 @@ edited, never written through a symlink, and recorded in the receipt. `startup --runtime ` prints the session-start manifest: the project's four active files, then the three curated org files, each with its readability, size and modification time, and where the sync stands; with -`--pull` it fast-forwards the home first, and the files are described as +`--pull` it commits and syncs the home under the runtime identity first, and the files are described as the pull left them. `events/`, `debriefs/` and `archive/` are excluded. No content is included and no file is claimed as read (`content_injected: false`); the text, notice included, is cut at a diff --git a/src/agent_memory/__init__.py b/src/agent_memory/__init__.py index dc62c23..39d8dd2 100644 --- a/src/agent_memory/__init__.py +++ b/src/agent_memory/__init__.py @@ -4,6 +4,6 @@ from .home import HomeError, HomeResolution, resolve_home -__version__ = "0.1.2" +__version__ = "0.1.3" __all__ = ["HomeError", "HomeResolution", "__version__", "resolve_home"] diff --git a/src/agent_memory/cli.py b/src/agent_memory/cli.py index df1bfaf..c3f27a4 100644 --- a/src/agent_memory/cli.py +++ b/src/agent_memory/cli.py @@ -92,7 +92,7 @@ def add(name: str, handler: Callable[[argparse.Namespace], int], help_text: str, clone_ = add("clone", _clone, "Clone a memory repository into the home.") clone_.add_argument("url", help="git remote URL to clone") clone_.add_argument("--force", action="store_true", help="move a non-empty home aside before cloning") - add("pull", _pull, "Fast-forward the home from its upstream when the tree is clean and not ahead.") + add("pull", _pull, "Update from upstream; with --agent, commit allowlisted changes and deliver them too.", agent) add("push", _push, "Commit the allowlisted memory changes and push them when a remote exists.", agent) add("disable", _disable, "Remove the sync marker; the repository stays in place.") for name, handler, help_text, positional, positional_help in ( @@ -121,7 +121,7 @@ def add(name: str, handler: Callable[[argparse.Namespace], int], help_text: str, "startup", _startup, "Print the session-start manifest: the memory files to read, in order, with their readability, size and " - "age, and where the sync stands. Content is never included. With --pull, fast-forward the home first.", + "age, and where the sync stands. Content is never included. With --pull, commit and sync memory under the runtime identity first.", ) startup_.add_argument("--runtime", choices=startup.RUNTIMES, required=True, help="shape the output for this runtime's hook") 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: def _pull(args: argparse.Namespace) -> int: - return _report(sync.pull(resolve_home(args.home).path)) + return _report(sync.pull(resolve_home(args.home).path, agent=args.agent)) def _push(args: argparse.Namespace) -> int: diff --git a/src/agent_memory/startup.py b/src/agent_memory/startup.py index 52799dd..7e63724 100644 --- a/src/agent_memory/startup.py +++ b/src/agent_memory/startup.py @@ -72,7 +72,7 @@ def build_manifest( warnings: List[str] = list(notes) files: List[Dict[str, Any]] = [] - sync_info, pull_info = _sync(home, pull, runner, warnings) + sync_info, pull_info = _sync(home, pull, runner, warnings, runtime) if project is not None: try: layout.validate_project_name(project) @@ -154,14 +154,14 @@ def _entry(home: Path, path: Path, tier: str, name: str) -> Dict[str, Any]: def _sync( - home: Path, pull: bool, runner: Optional[GitRunner], warnings: List[str] + home: Path, pull: bool, runner: Optional[GitRunner], warnings: List[str], runtime: str ) -> Tuple[Dict[str, Any], Dict[str, Any]]: marker = sync.is_configured(home) info: Dict[str, Any] = {"marker": marker, "last_commit_at_utc": None} pull_info: Dict[str, Any] = {"requested": pull, "status": PULL_NOT_REQUESTED, "ok": True, "lines": []} if pull: try: - outcome = sync.pull(home, runner=runner) + outcome = sync.pull(home, agent=runtime, runner=runner) pull_info = {"requested": True, "status": outcome.status, "ok": outcome.ok, "lines": list(outcome.lines)} except (sync.SyncError, OSError) as exc: pull_info = {"requested": True, "status": PULL_ERROR, "ok": False, "lines": [f"memory pull: {exc}"]} diff --git a/src/agent_memory/sync.py b/src/agent_memory/sync.py index 18e4f63..297d7b8 100644 --- a/src/agent_memory/sync.py +++ b/src/agent_memory/sync.py @@ -4,8 +4,7 @@ The engine owns the sync marker, the managed block of the home's ``.gitignore``, the git state readout and the verbs ``init``, ``clone``, -``pull``, ``push`` and ``disable``. It never reads memory content, never -merges and never touches ``keys/``. +``pull``, ``push`` and ``disable``. It never reads memory content directly and never touches ``keys/``. The publication boundary ------------------------ @@ -27,10 +26,10 @@ every write comes back with a before/after receipt that names them. The network verbs (fetch, pull, push, clone) run under a 30 s timeout. -``pull`` fast-forwards only when the tree is clean, not ahead, not diverged -and an upstream exists. ``push`` refuses a repository that is behind or -diverged, and a push the remote rejects leaves the local commit in place and -says so: a local-only commit and remote delivery are distinct outcomes. +``push`` and ``pull --agent`` commit allowlisted changes before fetching, +rebase when behind, then deliver. A clean ``pull`` without an agent updates +and reports unpushed commits. Conflicts abort the rebase and keep local +commits; rejected delivery also leaves them local. """ from __future__ import annotations @@ -350,39 +349,43 @@ def clone(home: Path, url: str, *, force: bool = False, runner: Optional[GitRunn ) -def pull(home: Path, *, runner: Optional[GitRunner] = None) -> Outcome: - """Fast-forward ``home`` from its upstream when that is the only thing that could happen. +def pull( + home: Path, + *, + agent: Optional[str] = None, + runner: Optional[GitRunner] = None, + env: Optional[Mapping[str, str]] = None, +) -> Outcome: + """Update the home, publishing dirty memory only with an explicit identity. - Silent on a home that is not configured for sync. Every state that rules - a fast-forward out (a failed fetch, a dirty tree, divergence) is its own - status and not a success; being ahead or having no upstream is reported - and is not an error. + Clean pulls keep their read-mostly behavior: fast-forward, or rebase local + commits and report ahead. An explicit agent permits dirty publication/delivery; + SessionStart supplies its runtime name through this same entry point. """ home = _home(home) if not is_configured(home): return Outcome("not_configured", True) _require_repo(home, runner) + if _operation_in_progress(home, runner): + return Outcome("operation_in_progress", False, ("memory pull: a git operation is in progress; finish it first.",)) + if changed_paths(home, runner): + if agent: + return _sync_changes(home, agent=agent, runner=runner, env=env) + return Outcome("dirty", False, ("memory pull: uncommitted changes present; pass --agent NAME to commit and sync.",)) state = git_state(home, runner=runner, fetch=True) if not state.has_remote: return Outcome("local_only", True, ("memory pull: local-only repository; no remote to pull from.",)) if state.fetch_failed: return _fetch_failure(state, "memory pull") - if state.dirty: - return Outcome("dirty", False, ("memory pull: uncommitted changes present; not pulling.",)) - if state.diverged: - return Outcome("diverged", False, ("memory pull: diverged from upstream; resolve manually.",)) if not state.has_upstream: return Outcome("no_upstream", True, ("memory pull: no upstream branch configured; skipping.",)) - if state.ahead: - return Outcome("ahead", True, (f"memory pull: {state.ahead} unpushed commit(s); nothing to pull.",)) - if not state.behind: - return Outcome("up_to_date", True, ("memory pull: already synced.",)) - result = _git(home, ["pull", "--ff-only", "--quiet"], runner, timeout=NETWORK_TIMEOUT_SECONDS) - if result.timed_out: - return Outcome("pull_timed_out", False, (f"memory pull: {result.output}",)) - if not result.ok: - return Outcome("pull_failed", False, (f"memory pull: fast-forward failed: {result.output}",)) - return Outcome("synced", True, (f"memory pull: synced {state.behind} commit(s).",)) + updated = _update(home, state, runner) + if not updated.ok: + return updated + current = git_state(home, runner=runner, fetch=False) if state.behind else state + if current.ahead: + return Outcome("ahead", True, (*updated.lines, f"memory pull: {current.ahead} unpushed commit(s); use push to deliver.")) + return updated def push( @@ -392,36 +395,93 @@ def push( runner: Optional[GitRunner] = None, env: Optional[Mapping[str, str]] = None, ) -> Outcome: - """Commit the allowlisted memory changes and deliver them when a remote exists. - - Silent on a home that is not configured for sync. A home that is behind - or diverged is refused before anything is committed. A commit that could - not be delivered -- no remote, an unreachable one, a rejected push -- is - reported as such, distinctly from delivery; a credential helper a sandbox - blocked is named as its own cause, not as a rejection. - """ + """Commit allowlisted changes, update from upstream, and deliver to the remote.""" home = _home(home) if not is_configured(home): return Outcome("not_configured", True) _require_repo(home, runner) - state = git_state(home, runner=runner, fetch=True) - if state.has_remote and not state.fetch_failed: - if state.diverged: - return Outcome("diverged", False, ("memory push: diverged from upstream; resolve manually before pushing.",)) - if state.behind: - return Outcome( - "behind", False, (f"memory push: behind upstream by {state.behind} commit(s); pull before pushing.",) - ) + if _operation_in_progress(home, runner): + return Outcome("operation_in_progress", False, ("memory push: a git operation is in progress; finish it first.",)) + return _sync_changes(home, agent=agent, runner=runner, env=env) + + +def _operation_in_progress(home: Path, runner: Optional[GitRunner]) -> bool: + # Never abort or publish into an operation started by somebody else. + for name in ("rebase-merge", "rebase-apply", "MERGE_HEAD", "CHERRY_PICK_HEAD", "REVERT_HEAD"): + result = _git(home, ["rev-parse", "--git-path", name], runner) + if not result.ok: + raise SyncError(f"cannot inspect git operation state: {result.output}") + path = result.stdout.strip() + if path and (home / path).exists(): + return True + return False + + +def _sync_changes( + home: Path, *, agent: Optional[str], runner: Optional[GitRunner], env: Optional[Mapping[str, str]] +) -> Outcome: published = _publish(home, agent=agent, runner=runner, env=env) if not published.ok: return published + # Commit before fetch: even a network failure leaves a recoverable local commit. + state = git_state(home, runner=runner, fetch=True) lines = list(published.lines) if not state.has_remote: lines.append("memory push: no remote configured; the commit remains local.") return Outcome("local_only", True, tuple(lines), published.committed, published.preserved) + if not state.fetch_failed and state.has_upstream and state.behind: + updated = _update(home, state, runner) + lines.extend(updated.lines) + if not updated.ok: + return Outcome(updated.status, False, tuple(lines), published.committed, published.preserved) + state = git_state(home, runner=runner, fetch=False) return _deliver(home, published, state, lines, runner) +def _update(home: Path, state: GitState, runner: Optional[GitRunner]) -> Outcome: + if not state.behind: + return Outcome("up_to_date", True, ("memory pull: already synced.",)) + remaining = changed_paths(home, runner) + if remaining: + return Outcome("dirty", False, ( + "memory pull: cannot update with remaining uncommitted paths; left untouched: " + ", ".join(remaining), + )) + if not state.ahead: + result = _git(home, ["pull", "--ff-only", "--quiet"], runner, timeout=NETWORK_TIMEOUT_SECONDS) + if result.timed_out: + return Outcome("pull_timed_out", False, (f"memory pull: {result.output}",)) + if not result.ok: + return Outcome("pull_failed", False, (f"memory pull: fast-forward failed: {result.output}",)) + return Outcome("synced", True, (f"memory pull: synced {state.behind} commit(s).",)) + merges = _git(home, ["rev-list", "--merges", f"{state.upstream}..HEAD"], runner) + if not merges.ok or merges.stdout.strip(): + return Outcome("diverged", False, ( + "memory pull: local merge commits cannot be safely rebased automatically; resolve manually." + if merges.ok else f"memory pull: cannot inspect local history: {merges.output}", + )) + # Rebase exactly the fetched upstream. Do not stash unrelated edits or reuse + # a recorded conflict resolution: a conflicting same-path add must surface. + result = _git(home, ["-c", "rerere.enabled=false", "-c", "rebase.updateRefs=false", + "rebase", "--no-autostash", state.upstream], + runner, timeout=NETWORK_TIMEOUT_SECONDS) + if result.ok: + return Outcome("synced", True, ("memory pull: rebased local commits onto upstream.",)) + conflicts = _git(home, ["diff", "--name-only", "--diff-filter=U", "-z"], runner) + paths = [path for path in conflicts.stdout.split("\0") if path] if conflicts.ok else [] + if _operation_in_progress(home, runner): + aborted = _git(home, ["rebase", "--abort"], runner, timeout=NETWORK_TIMEOUT_SECONDS) + if not aborted.ok or _operation_in_progress(home, runner): + return Outcome("rebase_abort_failed", False, ( + f"memory pull: rebase failed and could not be aborted; manual recovery required. {aborted.output}", + )) + detail = ", ".join(paths) if paths else result.output + status = "pull_timed_out" if result.timed_out else "diverged" + return Outcome(status, False, ( + f"memory pull: {'rebase timed out' if result.timed_out else 'rebase failed'}; " + f"local commits retained. Conflicting paths or git error: {detail}", + )) + + def disable(home: Path) -> Outcome: """Remove the marker; the repository and its history stay in place.""" home = _home(home) @@ -552,9 +612,9 @@ def _deliver( failure = _fetch_failure(state, "memory push") lines.append(f"{failure.lines[0]} The commit remains local.") return Outcome(failure.status, False, tuple(lines), published.committed, published.preserved, receipt) - if not published.committed and state.has_upstream and not state.ahead: + if state.has_upstream and not state.ahead: lines.append("memory push: remote already up to date.") - return Outcome("up_to_date", True, tuple(lines), (), published.preserved, receipt) + return Outcome("up_to_date", True, tuple(lines), published.committed, published.preserved, receipt) result = _push_remote(home, state, runner) if not result.ok: if credential_helper_blocked(result.output): diff --git a/tests/test_startup.py b/tests/test_startup.py index 4be882b..f0fba5d 100644 --- a/tests/test_startup.py +++ b/tests/test_startup.py @@ -382,3 +382,25 @@ def test_cli_startup_project_flag_wins_over_the_binding(home: Path, isolated: Pa manifest = json.loads(capsys.readouterr().out) assert manifest["project"] == "other" and manifest["project_source"] == "flag" assert [entry["state"] for entry in manifest["files"][:4]] == [MISSING] * 4 + + +@pytest.mark.parametrize("runtime", ["claude", "codex"]) +def test_session_start_resolves_dirty_behind_home(home: Path, tmp_path: Path, git_env: None, runtime: str) -> None: + # Realistic sync layout with synthetic files and identities only. + from conftest import git, write + remote = tmp_path / "remote.git" + git("init", "--bare", "--quiet", str(remote), cwd=tmp_path) + assert sync.init(home, remote=str(remote)).ok + peer = tmp_path / "peer" + git("clone", "--quiet", str(remote), str(peer), cwd=tmp_path) + write(peer / "org-memory/events/peer.md", "peer content\n") + git("add", "org-memory/events/peer.md", cwd=peer) + git("commit", "-qm", "peer addition", cwd=peer) + git("push", cwd=peer) + write(home / "org-memory/events/local.md", "local content\n") + manifest = build_manifest(home, runtime=runtime, project="demo", pull=True) + assert manifest["pull"]["status"] == "pushed" and manifest["pull"]["ok"] + assert manifest["result"] == "ok" + assert git("status", "--porcelain", cwd=home) == "" + assert git("log", "-1", "--format=%s", cwd=home).startswith(f"memory: {runtime}@") + assert git("--git-dir", str(remote), "show", "main:org-memory/events/local.md", cwd=tmp_path) == "local content" diff --git a/tests/test_sync.py b/tests/test_sync.py index 3db313d..428dcac 100644 --- a/tests/test_sync.py +++ b/tests/test_sync.py @@ -720,7 +720,7 @@ def test_push_with_nothing_new_is_up_to_date(tmp_path: Path) -> None: assert outcome.committed == () -def test_push_refuses_a_repository_behind_its_upstream(tmp_path: Path) -> None: +def test_push_resolves_a_repository_behind_its_upstream(tmp_path: Path) -> None: remote, seed = _create_remote(tmp_path) home = tmp_path / "home" sync.clone(home, str(remote)) @@ -730,13 +730,13 @@ def test_push_refuses_a_repository_behind_its_upstream(tmp_path: Path) -> None: outcome = sync.push(home) - assert outcome.status == "behind" and not outcome.ok - assert "pull before pushing" in outcome.lines[0] - assert _commit_count(home) == before + assert outcome.status == "pushed" and outcome.ok + assert _commit_count(home) == before + 2 + assert _remote_has(remote, "org-memory/local.md") assert (home / "org-memory" / "local.md").read_text(encoding="utf-8") == "local update\n" -def test_push_refuses_a_diverged_repository_without_committing(tmp_path: Path) -> None: +def test_push_rebases_existing_and_new_commits(tmp_path: Path) -> None: remote, seed = _create_remote(tmp_path) home = tmp_path / "home" sync.clone(home, str(remote)) @@ -749,8 +749,9 @@ def test_push_refuses_a_diverged_repository_without_committing(tmp_path: Path) - outcome = sync.push(home) - assert outcome.status == "diverged" and not outcome.ok - assert _commit_count(home) == before + assert outcome.status == "pushed" and outcome.ok + assert _commit_count(home) == before + 2 + assert _remote_has(remote, "org-memory/uncommitted.md") assert (home / "org-memory" / "uncommitted.md").is_file() @@ -802,7 +803,7 @@ def test_pull_refuses_a_dirty_tree(tmp_path: Path) -> None: assert (home / "org-memory" / "recent.md").read_text(encoding="utf-8") == "edited locally\n" -def test_pull_refuses_a_diverged_repository(tmp_path: Path) -> None: +def test_pull_rebases_a_diverged_repository_and_reports_ahead(tmp_path: Path) -> None: remote, seed = _create_remote(tmp_path) home = tmp_path / "home" sync.clone(home, str(remote)) @@ -813,8 +814,8 @@ def test_pull_refuses_a_diverged_repository(tmp_path: Path) -> None: outcome = sync.pull(home) - assert outcome.status == "diverged" and not outcome.ok - assert not (home / "org-memory" / "remote.md").exists() + assert outcome.status == "ahead" and outcome.ok + assert (home / "org-memory" / "remote.md").exists() 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: outcome = sync.pull(home) assert outcome.status == "ahead" and outcome.ok - assert "1 unpushed commit(s)" in outcome.lines[0] + assert "1 unpushed commit(s)" in outcome.lines[-1] 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) - assert outcome.committed == ("org-memory/b.md",) # HEAD reports a rename by its destination assert _git("status", "--porcelain", cwd=home) == "" assert _git("show", "--name-status", "--format=", "HEAD", cwd=home) == "R100\torg-memory/a.md\torg-memory/b.md" + + +# Real git shapes; all repository names, identities and contents are synthetic. +@pytest.mark.parametrize("verb", [sync.pull, sync.push]) +@pytest.mark.parametrize("duplicate", [False, True]) +def test_dirty_behind_commits_rebases_delivers(tmp_path: Path, verb, duplicate: bool) -> None: + remote, seed = _create_remote(tmp_path) + home = tmp_path / "home" + sync.clone(home, str(remote)) + _commit_and_push(seed, "org-memory/peer.md", "peer content\n") + if duplicate: + _write(home / "org-memory/peer.md", "peer content\n") + _write(home / "org-memory/local.md", "local content\n") + outcome = verb(home, agent="test-agent") + assert outcome.ok and outcome.status == "pushed" + assert _git("status", "--porcelain", cwd=home) == "" + assert _git("rev-parse", "HEAD", cwd=home) == _git("rev-parse", "origin/main", cwd=home) + assert _remote_has(remote, "org-memory/local.md") + assert (home / "org-memory/peer.md").read_text() == "peer content\n" + assert _git("log", "-1", "--format=%s", cwd=home).startswith("memory: test-agent@") + + +@pytest.mark.parametrize("verb", [sync.pull, sync.push]) +def test_only_identical_untracked_add_becomes_up_to_date(tmp_path: Path, verb) -> None: + remote, seed = _create_remote(tmp_path) + home = tmp_path / "home" + sync.clone(home, str(remote)) + _commit_and_push(seed, "org-memory/peer.md", "same\n") + _write(home / "org-memory/peer.md", "same\n") + outcome = verb(home, agent="test-agent") + assert outcome.status == "up_to_date" and outcome.ok + assert outcome.committed == ("org-memory/peer.md",) + assert _git("rev-parse", "HEAD", cwd=home) == _git("rev-parse", "HEAD", cwd=seed) + assert _git("status", "--porcelain", cwd=home) == "" + + +@pytest.mark.parametrize("verb", [sync.pull, sync.push]) +@pytest.mark.parametrize("path", ["org-memory/recent.md", "org-memory/same path.md"]) +def test_conflict_aborts_and_retains_local_commit(tmp_path: Path, verb, path: str) -> None: + remote, seed = _create_remote(tmp_path) + home = tmp_path / "home" + sync.clone(home, str(remote)) + _commit_and_push(seed, path, "upstream\n") + _write(home / path, "local\n") + before = _git("rev-parse", "HEAD", cwd=home) + outcome = verb(home, agent="test-agent") + assert outcome.status == "diverged" and not outcome.ok + assert path in outcome.lines[-1] + assert _git("rev-parse", "HEAD^", cwd=home) == before + assert (home / path).read_text() == "local\n" + assert _git("status", "--porcelain", cwd=home) == "" + assert not (home / ".git/rebase-merge").exists() + assert not (home / ".git/rebase-apply").exists() + assert _git("--git-dir", str(remote), "show", f"main:{path}", cwd=tmp_path) == "upstream" + + +@pytest.mark.parametrize("verb", [sync.pull, sync.push]) +def test_behind_preserves_foreign_index_and_worktree(tmp_path: Path, verb) -> None: + remote, seed = _create_remote(tmp_path) + home = tmp_path / "home" + sync.clone(home, str(remote)) + _commit_and_push(seed, "org-memory/peer.md", "peer\n") + _write(home / RUNTIME_FILE, "staged\n") + _git("add", "-f", RUNTIME_FILE, cwd=home) + _write(home / RUNTIME_FILE, "unstaged\n") + _write(home / "org-memory/local.md", "memory\n") + _git("config", "rebase.autoStash", "true", cwd=home) + outcome = verb(home, agent="test-agent") + assert outcome.status == "dirty" and not outcome.ok + assert outcome.preserved == (RUNTIME_FILE,) + assert _git("show", f":{RUNTIME_FILE}", cwd=home) == "staged" + assert (home / RUNTIME_FILE).read_text() == "unstaged\n" + assert not _remote_has(remote, "org-memory/local.md") + assert not (home / ".git/rebase-merge").exists() + + +@pytest.mark.parametrize("verb", [sync.pull, sync.push]) +def test_existing_rebase_is_never_published_or_aborted(tmp_path: Path, verb) -> None: + remote, seed = _create_remote(tmp_path) + home = tmp_path / "home" + sync.clone(home, str(remote)) + _commit_and_push(seed, "org-memory/recent.md", "remote\n") + _write(home / "org-memory/recent.md", "local\n") + _git("add", "org-memory/recent.md", cwd=home) + _git("commit", "-qm", "local", cwd=home) + _git("fetch", cwd=home) + result = run_git(["rebase", "origin/main"], cwd=home) + assert not result.ok + before = _git("status", "--porcelain", cwd=home) + outcome = verb(home, agent="test-agent") + assert outcome.status == "operation_in_progress" and not outcome.ok + assert _git("status", "--porcelain", cwd=home) == before + assert (home / ".git/rebase-merge").exists() + + +@pytest.mark.parametrize("verb", [sync.pull, sync.push]) +def test_dirty_fetch_timeout_keeps_commit_and_identity(tmp_path: Path, verb) -> None: + remote, _ = _create_remote(tmp_path) + home = tmp_path / "home" + sync.clone(home, str(remote)) + _write(home / "org-memory/local.md", "local\n") + outcome = verb(home, agent="test-agent", runner=FetchTimesOut()) + assert outcome.status == "fetch_timed_out" and not outcome.ok + assert outcome.committed == ("org-memory/local.md",) + assert _git("status", "--porcelain", cwd=home) == "" + assert _git("log", "-1", "--format=%s", cwd=home).startswith("memory: test-agent@") + assert not _remote_has(remote, "org-memory/local.md") + + +def test_pull_cli_requires_agent_for_dirty_publication(tmp_path: Path, capsys) -> None: + from agent_memory.cli import main + remote, seed = _create_remote(tmp_path) + home = tmp_path / "home" + sync.clone(home, str(remote)) + _commit_and_push(seed, "org-memory/peer.md", "peer\n") + _write(home / "org-memory/local.md", "local\n") + assert main(["pull", "--home", str(home)]) == 1 + assert "--agent" in capsys.readouterr().err + assert main(["pull", "--home", str(home), "--agent", "test-agent"]) == 0 + assert _remote_has(remote, "org-memory/local.md") + + +@pytest.mark.parametrize("verb", [sync.pull, sync.push]) +@pytest.mark.parametrize("abort_fails", [False, True]) +def test_rebase_timeout_aborts_or_names_recovery_failure(tmp_path: Path, verb, abort_fails: bool) -> None: + remote, seed = _create_remote(tmp_path) + home = tmp_path / "home" + sync.clone(home, str(remote)) + _commit_and_push(seed, "org-memory/recent.md", "upstream\n") + _write(home / "org-memory/recent.md", "local\n") + before = _git("rev-parse", "HEAD", cwd=home) + + def runner(args, *, cwd, timeout=None): + if "rebase" in args and "--no-autostash" in args: + assert timeout == sync.NETWORK_TIMEOUT_SECONDS + # A real conflict leaves the same in-progress state a timeout can. + assert not run_git(args, cwd=cwd, timeout=timeout).ok + return GitResult(EXIT_TIMEOUT, "", "synthetic rebase timeout") + if list(args) == ["rebase", "--abort"] and abort_fails: + return GitResult(1, "", "synthetic abort failure") + return run_git(args, cwd=cwd, timeout=timeout) + + outcome = verb(home, agent="test-agent", runner=runner) + assert not outcome.ok + if abort_fails: + assert outcome.status == "rebase_abort_failed" + assert "manual recovery" in outcome.lines[-1] + assert (home / ".git/rebase-merge").exists() + else: + assert outcome.status == "pull_timed_out" + assert "org-memory/recent.md" in outcome.lines[-1] + assert _git("rev-parse", "HEAD^", cwd=home) == before + assert (home / "org-memory/recent.md").read_text() == "local\n" + assert _git("status", "--porcelain", cwd=home) == "" + assert not (home / ".git/rebase-merge").exists() + assert _git("--git-dir", str(remote), "show", "main:org-memory/recent.md", cwd=tmp_path) == "upstream" + + +@pytest.mark.parametrize("verb", [sync.pull, sync.push]) +def test_local_merge_content_is_not_dropped_by_rebase(tmp_path: Path, verb) -> None: + remote, seed = _create_remote(tmp_path) + home = tmp_path / "home" + sync.clone(home, str(remote)) + _git("checkout", "-qb", "side", cwd=home) + _write(home / "org-memory/side.md", "side\n") + _git("add", "org-memory/side.md", cwd=home) + _git("commit", "-qm", "side", cwd=home) + _git("checkout", "main", cwd=home) + _write(home / "org-memory/main.md", "main\n") + _git("add", "org-memory/main.md", cwd=home) + _git("commit", "-qm", "main", cwd=home) + _git("merge", "--no-commit", "side", cwd=home) + _write(home / "org-memory/merge-only.md", "merge resolution\n") + _git("add", "org-memory/merge-only.md", cwd=home) + _git("commit", "-qm", "merge", cwd=home) + before = _git("rev-parse", "HEAD", cwd=home) + _commit_and_push(seed, "org-memory/upstream.md", "upstream\n") + outcome = verb(home, agent="test-agent") + assert outcome.status == "diverged" and not outcome.ok + assert "merge commits" in outcome.lines[-1] + assert _git("rev-parse", "HEAD", cwd=home) == before + assert (home / "org-memory/merge-only.md").read_text() == "merge resolution\n"