From 8b82a58b44a753a409039e108327679b2d7f5042 Mon Sep 17 00:00:00 2001 From: Joris Wouter Jonkers Date: Thu, 10 Sep 2026 17:23:52 +0200 Subject: [PATCH 1/2] fix(sidecar): re-register a missing origin remote instead of crashing on attach A prior crashed boot can leave the vault dir as a real git repo (Repo.init persisted) but without the origin remote registered (create_remote/fetch never completed). On the next boot attach() hit `self._repo.remotes.origin.fetch()` and GitPython raised `AttributeError: 'IterableList' object has no attribute 'origin'`, crash-looping the pod. Look origin up by name via a defensive `_origin()` helper; if it is None re-create the remote and fetch, so attach() converges instead of crashing. Also use the same lookup in the init_adopted path. Verified: 14 tests pass (including the new test_attach_readds_origin_when_missing which reproduces the crash), ruff + mypy clean. --- .../src/basic_memory_git_sync/sync.py | 27 +++++++++++++++++-- basic-memory-git-sync/tests/unit/test_sync.py | 17 ++++++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/basic-memory-git-sync/src/basic_memory_git_sync/sync.py b/basic-memory-git-sync/src/basic_memory_git_sync/sync.py index 0b7dace..b6c8d13 100644 --- a/basic-memory-git-sync/src/basic_memory_git_sync/sync.py +++ b/basic-memory-git-sync/src/basic_memory_git_sync/sync.py @@ -34,7 +34,7 @@ from typing import Protocol import structlog -from git import Actor, GitCommandError, Repo +from git import Actor, GitCommandError, Remote, Repo from basic_memory_git_sync.settings import Settings @@ -98,7 +98,15 @@ def attach(self) -> None: if self._vault_dir.exists() and (self._vault_dir / ".git").exists(): self._repo = Repo(self._vault_dir) self._log.info("backstop.attached", dir=str(self._vault_dir)) - self._repo.remotes.origin.fetch() + origin = self._origin() + if origin is None: + # A previous crashed boot left a .git with no origin remote + # (e.g. Repo.init persisted but the create_remote/fetch did + # not). Re-register it before fetching so attach converges + # instead of crashing on the missing `.origin`. + self._log.info("backstop.registering_origin", dir=str(self._vault_dir)) + origin = self._repo.create_remote("origin", self._clone_url) + origin.fetch() return self._vault_dir.parent.mkdir(parents=True, exist_ok=True) @@ -200,6 +208,21 @@ def _require_repo(self) -> Repo: raise RuntimeError("VaultGitBackstop.attach() must be called before poll_once()") return self._repo + def _origin(self) -> Remote | None: + """Look up the `origin` remote by name, or None if absent. + + GitPython's ``repo.remotes.origin`` attribute raises when no remote + named ``origin`` is registered (e.g. after a crashed ``Repo.init`` + that persisted the repo without the remote). Look it up defensively + by name so attach() can re-register it instead of crashing. + """ + if self._repo is None: + return None + try: + return self._repo.remotes["origin"] + except IndexError: + return None + def run_forever( settings: Settings, diff --git a/basic-memory-git-sync/tests/unit/test_sync.py b/basic-memory-git-sync/tests/unit/test_sync.py index 9350692..7f3871a 100644 --- a/basic-memory-git-sync/tests/unit/test_sync.py +++ b/basic-memory-git-sync/tests/unit/test_sync.py @@ -89,6 +89,23 @@ def test_attach_adopts_existing_non_git_dir(tmp_path: Path, remote: Path) -> Non assert b._repo.remotes.origin.url == str(remote) +def test_attach_readds_origin_when_missing(tmp_path: Path, remote: Path) -> None: + """A previous crashed boot left a .git with no origin remote. + + Reproduces the production crash: a prior boot did ``Repo.init`` but died + before ``create_remote``/fetch persisted, so ``attach()`` sees a real + .git but ``repo.remotes.origin`` raises. The new ``_origin()`` look-up + must re-register the remote and fetch instead of crashing. + """ + v = tmp_path / "vault" + v.mkdir(parents=True) + Repo.init(v) + b = VaultGitBackstop(clone_url=str(remote), vault_dir=v, push=False) + b.attach() # must not raise + assert b._repo.remotes["origin"].url == str(remote) + assert b._repo.active_branch.name == "main" + + def test_poll_commits_nothing_when_clean(backstop: VaultGitBackstop) -> None: result = backstop.poll_once() assert result.committed is False From e5dc6f94d53fa64e9b528ac2aa1d9609e5cb760f Mon Sep 17 00:00:00 2001 From: Joris Wouter Jonkers Date: Thu, 10 Sep 2026 17:31:54 +0200 Subject: [PATCH 2/2] fix(sidecar): adopt the tracked branch when recovering a lost origin A bare Repo.init leaves the repo on the git default branch (often master), so after re-registering a missing origin we must check out the tracked branch (main) to converge. Fixes the CI failure where the re-add-origin attach left the checkout on master. --- basic-memory-git-sync/src/basic_memory_git_sync/sync.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/basic-memory-git-sync/src/basic_memory_git_sync/sync.py b/basic-memory-git-sync/src/basic_memory_git_sync/sync.py index b6c8d13..e999468 100644 --- a/basic-memory-git-sync/src/basic_memory_git_sync/sync.py +++ b/basic-memory-git-sync/src/basic_memory_git_sync/sync.py @@ -106,7 +106,14 @@ def attach(self) -> None: # instead of crashing on the missing `.origin`. self._log.info("backstop.registering_origin", dir=str(self._vault_dir)) origin = self._repo.create_remote("origin", self._clone_url) - origin.fetch() + with self._repo.git.custom_environment(**self._git_env()): + origin.fetch() + # A bare Repo.init (no remote, no branch) leaves the repo on + # the git default branch (often `master`); adopt the branch we + # track so polls commit to the right ref. + if self._repo.active_branch.name != self._branch: + self._repo.git.checkout("-B", self._branch, f"origin/{self._branch}") + self._log.info("backstop.branch_adopted", branch=self._branch) return self._vault_dir.parent.mkdir(parents=True, exist_ok=True)