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..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 @@ -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,22 @@ 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) + 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) @@ -200,6 +215,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